import argparse
import numpy as np
import matplotlib.pyplot as plt
from scipy.io import wavfile
from scipy import signal
from pathlib import Path
class ResamplerTester:
def __init__(self, input_rate, output_rate, duration=5.0, channels=2):
self.input_rate = input_rate
self.output_rate = output_rate
self.duration = duration
self.channels = channels
def generate_test_signals(self, output_dir="."):
output_dir = Path(output_dir)
output_dir.mkdir(exist_ok=True)
print(f"Generating test signals at {self.input_rate} Hz...")
print(f"Target output rate: {self.output_rate} Hz")
print(f"Duration: {self.duration} seconds")
print(f"Channels: {self.channels}")
print()
impulse = self._generate_impulse()
self._save_wav(output_dir / "test_impulse.wav", impulse)
print(f"✓ Generated test_impulse.wav (for filter frequency response)")
sweep = self._generate_sweep()
self._save_wav(output_dir / "test_sweep.wav", sweep)
print(f"✓ Generated test_sweep.wav (for spectrogram)")
print()
print("Test signal generation complete!")
print()
print("Next steps:")
print("1. Run your resampler on these files")
print("2. Use 'analyze' command to visualize results")
def _generate_impulse(self):
n_samples = int(self.duration * self.input_rate)
impulse = np.zeros((n_samples, self.channels), dtype=np.float32)
impulse_pos = min(int(0.5 * self.input_rate), n_samples - 1)
impulse[impulse_pos, :] = 1.0
return impulse
def _generate_sweep(self):
n_samples = int(self.duration * self.input_rate)
t = np.linspace(0, self.duration, n_samples)
f0 = 20
f1 = self.input_rate / 2 * 0.95
sweep = signal.chirp(t, f0, self.duration, f1, method='logarithmic')
fade_samples = int(0.1 * self.input_rate)
fade_in = np.linspace(0, 1, fade_samples)
fade_out = np.linspace(1, 0, fade_samples)
sweep[:fade_samples] *= fade_in
sweep[-fade_samples:] *= fade_out
sweep = sweep * 0.99
return np.column_stack([sweep] * self.channels).astype(np.float32)
def _save_wav(self, filename, data):
data_int16 = (data * 32767).astype(np.int16)
wavfile.write(filename, self.input_rate, data_int16)
def analyze_results(self, input_dir="."):
input_dir = Path(input_dir)
print("Analyzing resampled outputs...")
print()
fig = plt.figure(figsize=(18, 12))
fig.suptitle(f'Audio Resampler Quality Analysis: {self.input_rate} Hz → {self.output_rate} Hz',
fontsize=16, fontweight='bold')
self._analyze_filter_frequency_response(fig, input_dir)
self._analyze_sweep_spectrogram(fig, input_dir)
plt.tight_layout()
output_file = input_dir / "resampler_analysis.png"
plt.savefig(output_file, dpi=150, bbox_inches='tight')
print(f"\n✓ Analysis plot saved to: {output_file}")
plt.show()
def _analyze_filter_frequency_response(self, fig, input_dir):
impulse_file = input_dir / "test_impulse_resampled.wav"
if not impulse_file.exists():
print(f"⚠ Skipping filter frequency response: {impulse_file} not found")
return
rate, data = wavfile.read(impulse_file)
if data.dtype == np.int16:
data = data.astype(np.float32) / 32768.0
if len(data.shape) > 1:
data = data[:, 0]
peak_idx = np.argmax(np.abs(data))
window_size = int(0.1 * rate) start = max(0, peak_idx - window_size // 2)
end = min(len(data), start + window_size)
impulse_response = data[start:end]
ax = plt.subplot(2, 1, 1)
n_fft = 8192
freq_response = np.fft.rfft(impulse_response, n=n_fft)
freqs = np.fft.rfftfreq(n_fft, 1/rate)
magnitude_db = 20 * np.log10(np.abs(freq_response) + 1e-10)
ax.plot(freqs / 1000, magnitude_db, linewidth=0.8)
ax.set_xlabel('Frequency (kHz)', fontsize=11)
ax.set_ylabel('Magnitude (dB)', fontsize=11)
ax.set_title('Filter Frequency Response', fontsize=13, fontweight='bold')
ax.grid(True, alpha=0.3)
ax.set_ylim(-120, 10)
nyquist_in = self.input_rate / 2
ax.axvline(nyquist_in / 1000, color='r', linestyle='--',
alpha=0.5, linewidth=2, label=f'Input Nyquist ({nyquist_in:.0f} Hz)')
cutoff = nyquist_in * 0.95 ax.axvspan(0, cutoff / 1000, alpha=0.1, color='green', label='Passband')
ax.axvspan(nyquist_in / 1000, rate / 2000, alpha=0.1, color='red', label='Stopband')
ax.legend(loc='upper right', fontsize=9)
print(f"✓ Analyzed filter frequency response:")
print(f" Input rate: {self.input_rate} Hz, Output rate: {self.output_rate} Hz")
print(f" Input Nyquist: {nyquist_in:.0f} Hz, Output Nyquist: {rate/2:.0f} Hz")
print()
min_nyquist = min(nyquist_in, rate / 2)
passband_end_freq = min_nyquist * 0.9
passband_end_idx = np.argmax(freqs > passband_end_freq)
stopband_start_freq = min_nyquist * 1.1
stopband_start_idx = np.argmax(freqs > stopband_start_freq)
dc_level = magnitude_db[1]
if passband_end_idx > 10:
passband_magnitudes = magnitude_db[1:passband_end_idx]
passband_max = np.max(passband_magnitudes)
passband_min = np.min(passband_magnitudes)
passband_ripple = passband_max - passband_min
passband_mean = np.mean(passband_magnitudes)
print(f" PASSBAND (DC to {passband_end_freq:.0f} Hz):")
print(f" - Peak level: {passband_max:.2f} dB")
print(f" - Min level: {passband_min:.2f} dB")
print(f" - Ripple: {passband_ripple:.2f} dB (±{passband_ripple/2:.2f} dB)")
print(f" - Mean level: {passband_mean:.2f} dB")
cutoff_3db_idx = np.argmax(magnitude_db < passband_max - 3.0)
if cutoff_3db_idx > 0:
cutoff_3db_freq = freqs[cutoff_3db_idx]
print(f" - -3dB cutoff: {cutoff_3db_freq:.0f} Hz ({cutoff_3db_freq/min_nyquist:.2f} × Nyquist)")
if stopband_start_idx > 0 and stopband_start_idx < len(magnitude_db) - 10:
stopband_magnitudes = magnitude_db[stopband_start_idx:]
stopband_max = np.max(stopband_magnitudes)
stopband_min = np.min(stopband_magnitudes)
stopband_ripple = stopband_max - stopband_min
stopband_mean = np.mean(stopband_magnitudes)
attenuation = passband_max - stopband_max if passband_end_idx > 10 else -stopband_max
print(f"\n STOPBAND ({stopband_start_freq:.0f} Hz to {rate/2:.0f} Hz)")
print(f" - Peak level: {stopband_max:.2f} dB")
print(f" - Min level: {stopband_min:.2f} dB")
print(f" - Ripple: {stopband_ripple:.2f} dB")
print(f" - Mean level: {stopband_mean:.2f} dB")
print(f" - Attenuation: {attenuation:.2f} dB")
stopband_std = np.std(stopband_magnitudes)
print(f" - Ripple std dev: {stopband_std:.2f} dB")
print()
def _analyze_sweep_spectrogram(self, fig, input_dir):
sweep_file = input_dir / "test_sweep_resampled.wav"
if not sweep_file.exists():
print(f"⚠ Skipping frequency response: {sweep_file} not found")
return
rate, data = wavfile.read(sweep_file)
if data.dtype == np.int16:
data = data.astype(np.float32) / 32768.0
if len(data.shape) > 1:
data = data[:, 0]
ax = plt.subplot(2, 1, 2)
nperseg = 4096
f, t, Sxx = signal.spectrogram(data, rate, nperseg=nperseg,
noverlap=nperseg//2)
Sxx_db = 10 * np.log10(Sxx + 1e-10)
im = ax.pcolormesh(t, f / 1000, Sxx_db, shading='gouraud',
cmap='turbo', vmin=-80, vmax=0)
ax.set_xlabel('Time (s)', fontsize=11)
ax.set_ylabel('Frequency (kHz)', fontsize=11)
ax.set_title('Sweep Spectrogram', fontsize=13, fontweight='bold')
nyquist_in = self.input_rate / 2
ax.axhline(nyquist_in / 1000, color='r', linestyle='--',
alpha=0.7, linewidth=2, label=f'Input Nyquist ({nyquist_in:.0f} Hz)')
ax.axhspan(0, nyquist_in / 1000, alpha=0.05, color='green', zorder=0)
ax.axhspan(nyquist_in / 1000, rate / 2000, alpha=0.1, color='red', zorder=0)
ax.legend(loc='upper right', fontsize=9)
cbar = plt.colorbar(im, ax=ax, label='Magnitude (dB)')
cbar.set_label('Magnitude (dB)', fontsize=10)
print(f"✓ Analyzed sweep spectrogram")
def main():
parser = argparse.ArgumentParser(
description='Audio Resampler Quality Testing Suite',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
subparsers = parser.add_subparsers(dest='command', help='Command to run')
gen_parser = subparsers.add_parser('generate', help='Generate test signals')
gen_parser.add_argument('--input-rate', type=int, required=True,
help='Input sample rate (Hz)')
gen_parser.add_argument('--output-rate', type=int, required=True,
help='Target output sample rate (Hz)')
gen_parser.add_argument('--duration', type=float, default=5.0,
help='Duration of test signals (seconds, default: 5.0)')
gen_parser.add_argument('--channels', type=int, default=2,
help='Number of channels (default: 2)')
gen_parser.add_argument('--output-dir', type=str, default='.',
help='Output directory (default: current directory)')
analyze_parser = subparsers.add_parser('analyze', help='Analyze resampled outputs')
analyze_parser.add_argument('--input-rate', type=int, required=True,
help='Original input sample rate (Hz)')
analyze_parser.add_argument('--output-rate', type=int, required=True,
help='Target output sample rate (Hz)')
analyze_parser.add_argument('--input-dir', type=str, default='.',
help='Directory with resampled files (default: current directory)')
args = parser.parse_args()
if args.command is None:
parser.print_help()
return
if args.command == 'generate':
tester = ResamplerTester(
input_rate=args.input_rate,
output_rate=args.output_rate,
duration=args.duration,
channels=args.channels
)
tester.generate_test_signals(args.output_dir)
elif args.command == 'analyze':
tester = ResamplerTester(
input_rate=args.input_rate,
output_rate=args.output_rate
)
tester.analyze_results(args.input_dir)
if __name__ == '__main__':
main()