analyzer.py 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. """
  2. Analyzer — Performs frequency response measurement.
  3. Compares a recorded signal against a reference signal to determine
  4. the transfer function (frequency response) of the tape deck.
  5. """
  6. import numpy as np
  7. from scipy import signal
  8. from typing import Tuple, Dict
  9. def align_signals(reference: np.ndarray, recorded: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
  10. """
  11. Align recorded signal to the reference signal using cross-correlation.
  12. Accounts for latency introduced by the audio interface and tape path.
  13. """
  14. # Use cross-correlation to find the time delay
  15. # We correlate a portion of the signals for speed if they are long
  16. max_len = min(len(reference), len(recorded), 44100 * 2) # use max 2 seconds for alignment
  17. ref_slice = reference[:max_len]
  18. rec_slice = recorded[:max_len]
  19. # correlation
  20. corr = signal.correlate(rec_slice, ref_slice, mode='full')
  21. delay = np.argmax(corr) - len(ref_slice) + 1
  22. if delay > 0:
  23. # Recorded is delayed relative to reference
  24. aligned_rec = recorded[delay:]
  25. aligned_ref = reference[:len(aligned_rec)]
  26. elif delay < 0:
  27. # Should not happen physically, but handle just in case
  28. aligned_ref = reference[-delay:]
  29. aligned_rec = recorded[:len(aligned_ref)]
  30. else:
  31. aligned_ref = reference
  32. aligned_rec = recorded
  33. # Trim to same length
  34. min_len = min(len(aligned_ref), len(aligned_rec))
  35. return aligned_ref[:min_len], aligned_rec[:min_len]
  36. def calculate_transfer_function(
  37. reference: np.ndarray,
  38. recorded: np.ndarray,
  39. sample_rate: int = 44100,
  40. nperseg: int = 4096
  41. ) -> Tuple[np.ndarray, np.ndarray]:
  42. """
  43. Calculate the transfer function H(f) = cross_power(ref, rec) / auto_power(ref).
  44. Using Welch's method for better noise reduction.
  45. Returns:
  46. freqs: Frequency bins (Hz)
  47. response_db: Magnitude response in dB
  48. """
  49. # Ensure aligned
  50. ref_aligned, rec_aligned = align_signals(reference, recorded)
  51. if len(ref_aligned) < nperseg:
  52. # Fallback if signal is too short
  53. nperseg = len(ref_aligned)
  54. # Calculate cross power spectral density (Pxy) and power spectral density of reference (Pxx)
  55. freqs, Pxy = signal.csd(ref_aligned, rec_aligned, fs=sample_rate, nperseg=nperseg)
  56. _, Pxx = signal.welch(ref_aligned, fs=sample_rate, nperseg=nperseg)
  57. # Transfer function H = Pxy / Pxx
  58. # Add small epsilon to avoid division by zero
  59. H = Pxy / (Pxx + 1e-10)
  60. # Magnitude response
  61. magnitude = np.abs(H)
  62. # Convert to dB
  63. response_db = 20 * np.log10(magnitude + 1e-10)
  64. return freqs, response_db
  65. def smooth_curve(freqs: np.ndarray, response_db: np.ndarray, fraction: float = 1/6) -> np.ndarray:
  66. """
  67. Smooth the frequency response curve using fractional octave smoothing.
  68. fraction = 1/3 for third-octave, 1/6 for sixth-octave.
  69. """
  70. smoothed = np.zeros_like(response_db)
  71. for i, f in enumerate(freqs):
  72. if f == 0:
  73. smoothed[i] = response_db[i]
  74. continue
  75. # Octave bandwidth around frequency f
  76. f_lower = f * (2.0 ** (-fraction / 2.0))
  77. f_upper = f * (2.0 ** (fraction / 2.0))
  78. # Find indices within this band
  79. idx = np.where((freqs >= f_lower) & (freqs <= f_upper))[0]
  80. if len(idx) > 0:
  81. smoothed[i] = np.mean(response_db[idx])
  82. else:
  83. smoothed[i] = response_db[i]
  84. return smoothed