From 2c4d7298ec05b47f185cd1d6bc553d499d5677a3 Mon Sep 17 00:00:00 2001 From: Shawn Date: Tue, 16 Jun 2026 17:15:22 -0400 Subject: [PATCH] Fix audio timeout when changing buffer size/sample rate Two bugs: 1. **Deadlock in set_audio_profile() (root cause of timeout)** jack_client.stop() was called BEFORE killing the JACK server. jack.Client.deactivate() is a synchronous call that blocks until the JACK RT callback thread acknowledges. When ALSA reconfigures (period/rate change), the RT thread can stall and deactivate() hangs permanently. Reversed order: kill JACK server first (via killall jackd) to force-kill the RT thread, then clean up the Python client. Same fix applied to device hot-swap path. 2. **Pipeline crash after period change** _CombFilter.buf and _AllpassFilter.buf were fixed-size arrays at BLOCK_SIZE (256). If JACK restarts with a different period (e.g. 512), self.buf[:] = block throws a shape mismatch in the RT callback -> audio dies permanently. Made internal buffers dynamically resize to match input block size. --- src/dsp/pipeline.py | 16 ++++++++++++++-- src/web/server.py | 17 ++++++++++------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/dsp/pipeline.py b/src/dsp/pipeline.py index 0ee1f6c..f4cc61e 100644 --- a/src/dsp/pipeline.py +++ b/src/dsp/pipeline.py @@ -228,7 +228,7 @@ class _DelayLine: class _CombFilter: """Comb filter for Schroeder reverb.""" - __slots__ = ("delay", "feedback", "damping", "damp_filt", "buf") + __slots__ = ("delay", "feedback", "damping", "damp_filt", "buf", "_buf_size") def __init__(self, delay_samples: int): line_len = max(BLOCK_SIZE * 2, delay_samples + 1) @@ -237,8 +237,14 @@ class _CombFilter: self.damping: float = 0.5 # low-pass damping coefficient self.damp_filt: float = 0.0 # state variable for damping self.buf = np.zeros(BLOCK_SIZE, dtype=np.float32) + self._buf_size = BLOCK_SIZE def process(self, block: np.ndarray) -> np.ndarray: + # Resize internal buffer if block size changed (e.g. JACK period switch) + n = len(block) + if n != self._buf_size: + self.buf = np.zeros(n, dtype=np.float32) + self._buf_size = n self.buf[:] = block # Write with feedback: out[n] = in[n] + feedback * damped_delayed delayed = self.delay.add_to_block(self.buf, self.delay.max_len - 1, self.feedback) @@ -255,15 +261,21 @@ class _CombFilter: class _AllpassFilter: """Allpass filter for Schroeder reverb.""" - __slots__ = ("delay", "gain", "buf") + __slots__ = ("delay", "gain", "buf", "_buf_size") def __init__(self, delay_samples: int): line_len = max(BLOCK_SIZE * 2, delay_samples + 1) self.delay = _DelayLine(line_len) self.gain: float = 0.5 self.buf = np.zeros(BLOCK_SIZE, dtype=np.float32) + self._buf_size = BLOCK_SIZE def process(self, block: np.ndarray) -> np.ndarray: + # Resize internal buffer if block size changed (e.g. JACK period switch) + n = len(block) + if n != self._buf_size: + self.buf = np.zeros(n, dtype=np.float32) + self._buf_size = n # out[n] = -gain * in[n] + delay[n - D] + gain * delay_output[n - D] # Standard allpass: out = -g * in + delayed + g * delayed_out # But block-wise: read delayed, write in + g * delayed, output = -g * in + delayed diff --git a/src/web/server.py b/src/web/server.py index 43b9355..889efc4 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -1480,17 +1480,18 @@ class WebServer: target_profile["period"], target_profile["rate"], ) - # Stop JACK audio client first jack_client = self.deps.jack_audio - if jack_client: - jack_client.stop() # Update LATENCY_PROFILES with custom entry so latency_profile resolves it LATENCY_PROFILES[effective_key] = target_profile # ── Kill JACK server FIRST (breaks any client deadlock) ── - # This interrupts the audio callback thread, releasing any lock - # that jack_client.stop() would need. + # jack.Client.deactivate() is a synchronous JACK protocol call + # that blocks until the RT callback thread acknowledges. If the + # RT thread is stuck (ALSA reconfiguring, xruns), deactivate() + # hangs permanently. Kill the server first to force-kill the + # RT thread, then clean up the client. + # See also: JackAudioClient.stop() which calls deactivate(). try: audio_sys.stop_jack() except Exception: @@ -1634,13 +1635,15 @@ class WebServer: try: audio_sys = self.deps.audio_system jack_client = self.deps.jack_audio - if jack_client: - jack_client.stop() + # Kill JACK server FIRST to avoid deadlock on + # jack.Client.deactivate() (same pattern as profile switch) if "input_device" in data: audio_sys.config.input_device = str(data["input_device"]) if "output_device" in data: audio_sys.config.output_device = str(data["output_device"]) audio_sys.stop_jack() + if jack_client: + jack_client.stop() ok = audio_sys.start_jack(timeout=10) if not ok: logger.warning("JACK restart failed after device change — rolling back")