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.
This commit is contained in:
2026-06-16 17:15:22 -04:00
parent 0fa8bd387a
commit 2c4d7298ec
2 changed files with 24 additions and 9 deletions
+14 -2
View File
@@ -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
+10 -7
View File
@@ -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")