fix: soundcard capture detects a dead arecord instead of silently recording nothing

ALSAStream._read_loop just returned when arecord died or hit EOF, and
__exit__ never checked the exit status. SoundcardRecorder.record's inner
loop only slept and flushed, so nothing ever noticed: the recorder stayed
"running", kept rolling over to a new split file on schedule, and wrote
zero bytes into every one of them. The max_retries/reconnect path below
it was unreachable. Worst kind of failure for an unattended recorder —
files keep appearing, at the right times, with the right names.

ALSAStream now tracks when audio last arrived and exposes check_alive(),
which raises if the process exited or nothing has been captured for
SILENCE_TIMEOUT (15 s). The record loop calls it every 0.5 s tick, so a
dead device unwinds into the existing retry handler.

retry_count resets only when the stream actually delivered audio —
resetting on every healthy tick would let a device that opens but never
captures retry forever, which is the same silent failure by another
route.

Also: join the stderr thread in __exit__, and break _read_loop on EOF
rather than looping until poll() catches up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 20:21:05 +02:00
co-authored by Claude Opus 5
parent 24569efe20
commit 46af41b12d
4 changed files with 145 additions and 6 deletions
+99
View File
@@ -6,6 +6,7 @@ Run with: pytest tests/
import logging
import struct
import time
import wave
from datetime import datetime
from pathlib import Path
@@ -423,6 +424,77 @@ class TestAudioFileWriter:
assert info.samplerate == 44100
# ===========================================================================
# ALSAStream — capture liveness
# ===========================================================================
class TestALSAStreamLiveness:
"""check_alive() is what stops a dead arecord from being silently ignored."""
def _stream(self, poll_result=None) -> isr.ALSAStream:
device = isr.AudioDevice(
id="hw:0,0", name="Test", channels=2, sample_rate=44100, backend="alsa",
)
s = isr.ALSAStream(device, 44100, 2, lambda data: None, make_logger())
s._process = MagicMock()
s._process.poll.return_value = poll_result
s._last_data = time.monotonic()
return s
def test_healthy_stream_does_not_raise(self):
self._stream().check_alive()
def test_raises_when_arecord_exited(self):
s = self._stream(poll_result=1)
with pytest.raises(RuntimeError, match="exited with code 1"):
s.check_alive()
def test_raises_when_no_audio_within_timeout(self):
# Process still up, but nothing has arrived — device yanked, or the
# dsnoop shared memory went away underneath us.
s = self._stream()
s._last_data = time.monotonic() - (isr.ALSAStream.SILENCE_TIMEOUT + 1)
with pytest.raises(RuntimeError, match="no audio"):
s.check_alive()
def test_read_loop_records_arrival_of_audio(self):
s = self._stream()
s._running = True
s._process.stdout.read.side_effect = [b"\x00" * 64, b""] # one chunk, then EOF
chunks = []
s.callback = chunks.append
s._read_loop()
assert chunks == [b"\x00" * 64]
assert s.received_audio is True
def test_read_loop_stops_at_eof_without_spinning(self):
s = self._stream()
s._running = True
s._process.stdout.read.return_value = b""
s._read_loop()
assert s._process.stdout.read.call_count == 1
assert s.received_audio is False
class _DeadStream:
"""Stand-in for a capture stream that opens but never delivers audio."""
received_audio = False
def __enter__(self):
return self
def __exit__(self, *args):
return False
def check_alive(self):
raise RuntimeError("arecord exited with code 1")
# ===========================================================================
# RecorderManager — config loading
# ===========================================================================
@@ -631,6 +703,33 @@ class TestSoundcardRecorder:
with pytest.raises(ImportError, match="soundfile"):
rec._open_output_file()
def test_dead_capture_gives_up_instead_of_recording_nothing(self, tmp_path):
# A capture that opens but never delivers audio must exhaust
# max_retries and stop. Before check_alive() existed, record() sat in
# its inner loop forever, rolling over to a new split file every
# split_minutes and writing zero bytes into each one.
device = self._make_device()
audio_system = self._make_audio_system(device)
backend = MagicMock()
backend.open_stream.return_value = _DeadStream()
audio_system.get_backend.return_value = backend
cfg = {
"device": "default",
"sample_rate": 44100,
"channels": 2,
"format": "wav",
"output_directory": str(tmp_path),
"split_minutes": 60,
"max_retries": 2,
"retry_delay_seconds": 0,
}
rec = isr.SoundcardRecorder("sc_dead", cfg, make_logger(), audio_system=audio_system)
with patch.object(isr.time, "sleep"): # don't pay the 0.5 s poll interval
rec.record()
assert backend.open_stream.call_count == cfg["max_retries"]
@pytest.mark.skipif(not isr.SOUNDFILE_AVAILABLE, reason="soundfile not installed")
def test_flac_file_written_correctly(self, tmp_path):
import soundfile as sf