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:
@@ -162,6 +162,11 @@ class ALSABackend(AudioBackend):
|
||||
class ALSAStream:
|
||||
"""Context manager for ALSA recording using arecord (raw PCM output)."""
|
||||
|
||||
# No audio for this long means capture is dead even though the process is
|
||||
# still up (device yanked, dsnoop shm gone). Generous enough that normal
|
||||
# scheduling hiccups never trip it.
|
||||
SILENCE_TIMEOUT = 15.0
|
||||
|
||||
def __init__(self, device: AudioDevice, sample_rate: int, channels: int,
|
||||
callback: Callable[[bytes], None], logger: logging.Logger):
|
||||
self.device = device
|
||||
@@ -169,12 +174,16 @@ class ALSAStream:
|
||||
self.channels = channels
|
||||
self.callback = callback
|
||||
self.logger = logger
|
||||
self.received_audio = False
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._stderr_thread = None
|
||||
self._process = None
|
||||
self._last_data = 0.0
|
||||
|
||||
def __enter__(self):
|
||||
self._running = True
|
||||
self._last_data = time.monotonic()
|
||||
cmd = [
|
||||
'arecord',
|
||||
'-D', self.device.id,
|
||||
@@ -207,8 +216,25 @@ class ALSAStream:
|
||||
self._process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.kill()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=1)
|
||||
for thread in (self._thread, self._stderr_thread):
|
||||
if thread:
|
||||
thread.join(timeout=1)
|
||||
|
||||
def check_alive(self):
|
||||
"""Raise if capture has stopped delivering audio.
|
||||
|
||||
Nothing else notices a dead arecord: _read_loop just returns, so
|
||||
without this the recorder would keep rolling over to new split files
|
||||
on schedule and write nothing into any of them — a failure that is
|
||||
invisible from the outside because the files still appear on time.
|
||||
"""
|
||||
rc = self._process.poll()
|
||||
if rc is not None:
|
||||
raise RuntimeError(f"arecord exited with code {rc}")
|
||||
idle = time.monotonic() - self._last_data
|
||||
if idle > self.SILENCE_TIMEOUT:
|
||||
raise RuntimeError(
|
||||
f"no audio from '{self.device.id}' for {idle:.0f}s")
|
||||
|
||||
def _log_stderr(self):
|
||||
for line in self._process.stderr:
|
||||
@@ -221,8 +247,11 @@ class ALSAStream:
|
||||
while self._running and self._process.poll() is None:
|
||||
try:
|
||||
data = self._process.stdout.read(chunk_size)
|
||||
if data:
|
||||
self.callback(data)
|
||||
if not data:
|
||||
break # EOF: arecord is on its way out, don't spin
|
||||
self._last_data = time.monotonic()
|
||||
self.received_audio = True
|
||||
self.callback(data)
|
||||
except Exception as e:
|
||||
self.logger.error(f"ALSA read error: {e}")
|
||||
break
|
||||
@@ -755,13 +784,21 @@ class SoundcardRecorder(BaseRecorder):
|
||||
|
||||
with backend.open_stream(
|
||||
self.device, self.sample_rate, self.channels, self._audio_callback
|
||||
):
|
||||
) as stream:
|
||||
self.logger.info(f"[{self.name}] Recording started")
|
||||
retry_count = 0
|
||||
|
||||
while self.running:
|
||||
time.sleep(0.5)
|
||||
self._flush_buffer_to_file()
|
||||
# Raises if capture died; the retry logic below then
|
||||
# reopens the device instead of silently recording
|
||||
# empty files forever.
|
||||
stream.check_alive()
|
||||
# Only a stream that actually delivered audio counts as
|
||||
# progress. Resetting on every healthy tick would let a
|
||||
# device that opens but never captures retry forever.
|
||||
if stream.received_audio:
|
||||
retry_count = 0
|
||||
|
||||
if self._clock() >= next_split:
|
||||
self._open_output_file()
|
||||
|
||||
Reference in New Issue
Block a user