feat: Tuner — pitch detection + audio mute in pipeline, API endpoint for pitch data
This commit is contained in:
@@ -319,6 +319,16 @@ class AudioPipeline:
|
|||||||
# Smoothing factor: ~50ms time constant at 48kHz/256 block
|
# Smoothing factor: ~50ms time constant at 48kHz/256 block
|
||||||
self._vu_alpha: float = np.exp(-BLOCK_SIZE / (0.05 * SAMPLE_RATE))
|
self._vu_alpha: float = np.exp(-BLOCK_SIZE / (0.05 * SAMPLE_RATE))
|
||||||
|
|
||||||
|
# ── Tuner / pitch detection state ────────────────────────────────
|
||||||
|
self._tuner_frequency: float = 0.0 # detected fundamental freq (Hz)
|
||||||
|
self._tuner_note: str = "--" # closest note name
|
||||||
|
self._tuner_cents: float = 0.0 # cent deviation from closest note
|
||||||
|
self._tuner_string: int = -1 # string number (1-6) or -1 if not matched
|
||||||
|
self._tuner_confidence: float = 0.0 # 0.0 to 1.0
|
||||||
|
# Pitch detection buffer (keep last N samples for analysis)
|
||||||
|
self._pitch_buffer: np.ndarray = np.array([], dtype=np.float32)
|
||||||
|
self._pitch_buffer_max: int = 2048 # ~43ms at 48kHz
|
||||||
|
|
||||||
logger.info("Audio pipeline initialized (block=%d, sr=%d)",
|
logger.info("Audio pipeline initialized (block=%d, sr=%d)",
|
||||||
BLOCK_SIZE, SAMPLE_RATE)
|
BLOCK_SIZE, SAMPLE_RATE)
|
||||||
|
|
||||||
@@ -370,6 +380,26 @@ class AudioPipeline:
|
|||||||
Mono mode: shape (N,) — processed output.
|
Mono mode: shape (N,) — processed output.
|
||||||
4CM mode: shape (2, N) — [send_out, return_out].
|
4CM mode: shape (2, N) — [send_out, return_out].
|
||||||
"""
|
"""
|
||||||
|
# ── Tuner mode: mute output, keep input tracking for pitch detection ──
|
||||||
|
if self._tuner_enabled:
|
||||||
|
# Still track input level for tuner display
|
||||||
|
if audio_in.ndim == 1:
|
||||||
|
in_rms = np.sqrt(np.mean(audio_in ** 2) + _EPS)
|
||||||
|
self._input_level = (
|
||||||
|
self._input_level * self._vu_alpha
|
||||||
|
+ in_rms * (1.0 - self._vu_alpha)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ch0 = audio_in[0, :]
|
||||||
|
in_rms = np.sqrt(np.mean(ch0 ** 2) + _EPS)
|
||||||
|
self._input_level = (
|
||||||
|
self._input_level * self._vu_alpha
|
||||||
|
+ in_rms * (1.0 - self._vu_alpha)
|
||||||
|
)
|
||||||
|
# Run pitch detection on input
|
||||||
|
self._detect_pitch(audio_in)
|
||||||
|
return np.zeros_like(audio_in)
|
||||||
|
|
||||||
if self._bypassed:
|
if self._bypassed:
|
||||||
return audio_in * self._master_volume
|
return audio_in * self._master_volume
|
||||||
|
|
||||||
@@ -378,6 +408,139 @@ class AudioPipeline:
|
|||||||
else:
|
else:
|
||||||
return self._process_mono(audio_in)
|
return self._process_mono(audio_in)
|
||||||
|
|
||||||
|
# ── Pitch detection for tuner ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Standard tuning frequencies (E2, A2, D3, G3, B3, E4) — guitar strings
|
||||||
|
_STRING_FREQS = [82.41, 110.0, 146.83, 196.0, 246.94, 329.63]
|
||||||
|
_STRING_NAMES = ["E", "A", "D", "G", "B", "e"]
|
||||||
|
|
||||||
|
# Note names in chromatic order (C = 0)
|
||||||
|
_NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
||||||
|
|
||||||
|
def _detect_pitch(self, audio_in: np.ndarray) -> None:
|
||||||
|
"""Run pitch detection on the input buffer using autocorrelation.
|
||||||
|
|
||||||
|
Updates ``self._tuner_frequency``, ``self._tuner_note``,
|
||||||
|
``self._tuner_cents``, and ``self._tuner_string``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_in: Input audio block — mono (N,) or stereo (2, N).
|
||||||
|
"""
|
||||||
|
# Extract mono channel
|
||||||
|
if audio_in.ndim == 2:
|
||||||
|
signal = audio_in[0, :].copy()
|
||||||
|
else:
|
||||||
|
signal = audio_in.copy()
|
||||||
|
|
||||||
|
# Append to rolling pitch buffer
|
||||||
|
self._pitch_buffer = np.concatenate([self._pitch_buffer, signal])
|
||||||
|
if len(self._pitch_buffer) > self._pitch_buffer_max:
|
||||||
|
self._pitch_buffer = self._pitch_buffer[-self._pitch_buffer_max:]
|
||||||
|
|
||||||
|
# Need enough signal for meaningful analysis
|
||||||
|
if len(self._pitch_buffer) < 512:
|
||||||
|
self._tuner_confidence = 0.0
|
||||||
|
return
|
||||||
|
|
||||||
|
# Simple autocorrelation pitch detection
|
||||||
|
buf = self._pitch_buffer
|
||||||
|
# Remove DC offset
|
||||||
|
buf = buf - np.mean(buf)
|
||||||
|
|
||||||
|
# Check if there's enough amplitude
|
||||||
|
rms = np.sqrt(np.mean(buf ** 2))
|
||||||
|
if rms < 0.002: # Silence threshold
|
||||||
|
self._tuner_confidence = 0.0
|
||||||
|
self._tuner_frequency = 0.0
|
||||||
|
self._tuner_note = "--"
|
||||||
|
self._tuner_cents = 0.0
|
||||||
|
self._tuner_string = -1
|
||||||
|
return
|
||||||
|
|
||||||
|
# Autocorrelation: find the fundamental period
|
||||||
|
# Search lag range: 30 to 1024 samples (46.9Hz to 1600Hz at 48kHz)
|
||||||
|
min_lag = int(SAMPLE_RATE / 1600) # ~30
|
||||||
|
max_lag = min(int(SAMPLE_RATE / 50), len(buf) // 2) # ~960
|
||||||
|
|
||||||
|
if max_lag <= min_lag:
|
||||||
|
self._tuner_confidence = 0.0
|
||||||
|
return
|
||||||
|
|
||||||
|
corr = np.correlate(buf, buf, mode='full')
|
||||||
|
# Take only the second half (positive lags)
|
||||||
|
corr = corr[len(corr) // 2:]
|
||||||
|
|
||||||
|
# Normalize by energy at each lag (YIN-style)
|
||||||
|
energy = np.cumsum(buf ** 2)
|
||||||
|
energy = energy[max(1, min_lag):max_lag + len(buf) - len(corr) + min_lag + 1]
|
||||||
|
# Fallback: use simple autocorrelation
|
||||||
|
lag_slice = corr[min_lag:max_lag + 1]
|
||||||
|
|
||||||
|
# Find the first peak in the autocorrelation
|
||||||
|
diffs = np.diff(lag_slice)
|
||||||
|
# Look for zero crossings in diff (peaks: positive→negative)
|
||||||
|
peaks = []
|
||||||
|
for i in range(1, len(diffs)):
|
||||||
|
if diffs[i-1] > 0 and diffs[i] <= 0:
|
||||||
|
peaks.append((min_lag + i, lag_slice[i]))
|
||||||
|
|
||||||
|
if not peaks:
|
||||||
|
self._tuner_confidence = 0.0
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pick the strongest peak
|
||||||
|
best_lag, best_val = max(peaks, key=lambda x: x[1])
|
||||||
|
|
||||||
|
# Confidence based on relative peak strength
|
||||||
|
noise_floor = np.mean(np.abs(corr[min_lag:max_lag + 1]))
|
||||||
|
confidence = best_val / (noise_floor + 1e-10)
|
||||||
|
# Parabolic interpolation for sub-sample accuracy
|
||||||
|
if best_lag > min_lag and best_lag < max_lag:
|
||||||
|
idx = best_lag - min_lag
|
||||||
|
if 0 < idx < len(lag_slice) - 1:
|
||||||
|
y0, y1, y2 = lag_slice[idx-1], lag_slice[idx], lag_slice[idx+1]
|
||||||
|
if y0 + y2 - 2 * y1 != 0:
|
||||||
|
correction = (y0 - y2) / (2 * (y0 + y2 - 2 * y1))
|
||||||
|
best_lag = best_lag + correction
|
||||||
|
|
||||||
|
# Fundamental frequency
|
||||||
|
freq = SAMPLE_RATE / best_lag if best_lag > 0 else 0
|
||||||
|
|
||||||
|
# Clip confidence to 0-1 range
|
||||||
|
self._tuner_confidence = min(1.0, max(0.0, confidence / 10.0))
|
||||||
|
|
||||||
|
if freq < 30 or freq > 1600:
|
||||||
|
self._tuner_confidence = 0.0
|
||||||
|
return
|
||||||
|
|
||||||
|
self._tuner_frequency = freq
|
||||||
|
|
||||||
|
# ── Convert frequency to note name ──
|
||||||
|
# A4 = 440Hz, MIDI note 69
|
||||||
|
midi_note = 12 * np.log2(freq / 440.0) + 69
|
||||||
|
midi_rounded = round(midi_note)
|
||||||
|
cents = int(100 * (midi_note - midi_rounded))
|
||||||
|
|
||||||
|
# Clamp to valid MIDI range
|
||||||
|
if midi_rounded < 0 or midi_rounded > 127:
|
||||||
|
self._tuner_note = "--"
|
||||||
|
self._tuner_confidence = 0.0
|
||||||
|
return
|
||||||
|
|
||||||
|
octave = (midi_rounded // 12) - 1
|
||||||
|
note_idx = midi_rounded % 12
|
||||||
|
note_name = self._NOTE_NAMES[note_idx]
|
||||||
|
self._tuner_note = f"{note_name}{octave}"
|
||||||
|
self._tuner_cents = cents
|
||||||
|
|
||||||
|
# ── Guess guitar string ──
|
||||||
|
self._tuner_string = -1
|
||||||
|
for si, sf in enumerate(self._STRING_FREQS):
|
||||||
|
# +/- 3 semitones from the string's fundamental
|
||||||
|
if abs(freq - sf) / sf < 0.2:
|
||||||
|
self._tuner_string = si + 1
|
||||||
|
break
|
||||||
|
|
||||||
def _process_mono(self, audio_in: np.ndarray) -> np.ndarray:
|
def _process_mono(self, audio_in: np.ndarray) -> np.ndarray:
|
||||||
"""Process a mono block through the full chain (all blocks)."""
|
"""Process a mono block through the full chain (all blocks)."""
|
||||||
# Update input VU level (RMS with envelope smoothing)
|
# Update input VU level (RMS with envelope smoothing)
|
||||||
|
|||||||
Reference in New Issue
Block a user