listen(): retry transcription timeouts; friendlier missing-mic error

- listen() now retries the Parakeet transcription up to 3x (the gateway
  serializes inference on one slot and intermittently times out). The
  recording is already saved, so retrying is free — no more manual
  transcribe() recovery.
- Add _terminate_quietly() and use it in all three recorder paths so
  terminating an already-exited pw-record (disconnected mic) no longer raises
  ProcessLookupError. That lets the mic-absent case reach the clear
  "No audio captured — is a microphone connected?" message.
This commit is contained in:
Ryan Malloy 2026-07-04 13:22:45 -06:00
parent 012064180a
commit 48279ba629
2 changed files with 66 additions and 32 deletions

View File

@ -217,6 +217,27 @@ async def _force_pwplay_volume_100() -> None:
return
async def _terminate_quietly(proc) -> None:
"""Stop a recorder subprocess that may have ALREADY exited.
When the mic is disconnected, pw-record exits instantly a later
proc.terminate() then raises ProcessLookupError, which otherwise masks the
friendly "no audio captured / is a mic connected?" error. Swallow that.
"""
try:
proc.terminate()
except ProcessLookupError:
return
try:
await asyncio.wait_for(proc.wait(), timeout=2.0)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
return
await proc.wait()
async def record_audio_until_silence(
out_path: Path,
silence_threshold_ms: int = 1500,
@ -322,12 +343,7 @@ async def record_audio_until_silence(
# The inner read returned no data — break outer loop
break
finally:
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=2.0)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
await _terminate_quietly(proc)
if not pcm_buf:
raise PlaybackError(
@ -414,14 +430,12 @@ async def record_audio(
# If we get here, pw-record exited early (mic gone, permission, etc.)
except asyncio.TimeoutError:
# Expected path — stop recording cleanly.
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=2.0)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
await _terminate_quietly(proc)
except asyncio.CancelledError:
proc.kill()
try:
proc.kill()
except ProcessLookupError:
pass
await proc.wait()
raise
@ -482,14 +496,12 @@ async def _record_fixed_stream(
try:
await asyncio.wait_for(_drain(), timeout=duration_seconds)
except asyncio.TimeoutError:
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=2.0)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
await _terminate_quietly(proc)
except asyncio.CancelledError:
proc.kill()
try:
proc.kill()
except ProcessLookupError:
pass
await proc.wait()
raise

View File

@ -43,6 +43,10 @@ OUTPUT_DIR_CONTAINER = Path("/output")
# the discipline _resolve_output_path uses for writes.
_TRANSCRIBE_INPUT_DIRS = (Path("/output"), Path("/tmp/mcspeak"))
# listen() retries transcription this many times (the Parakeet gateway
# intermittently times out; the recording is saved, so retrying is free).
_LISTEN_TRANSCRIBE_ATTEMPTS = 3
def _validate_readable_audio_path(audio_path: str) -> Path:
"""Resolve audio_path against the allowed input dirs and check existence.
@ -893,19 +897,37 @@ async def listen(
await ctx.info(f"Recording saved failed (non-fatal): {e}")
await ctx.info(f"Transcribing {rec_path.name}...")
try:
result = await transcribe_audio(
rec_path,
response_format=response_format,
timestamp_granularities=timestamp_granularities,
diarize=diarize,
num_speakers=num_speakers,
punctuation=punctuation,
min_confidence=min_confidence,
)
except TranscriptionError as e:
# The Parakeet gateway serializes inference on one slot and intermittently
# times out when the backend is busy/cold. The recording is already saved,
# so retry a couple of times before giving up rather than making the caller
# re-run transcribe() by hand.
result = None
last_err: TranscriptionError | None = None
for attempt in range(_LISTEN_TRANSCRIBE_ATTEMPTS):
try:
result = await transcribe_audio(
rec_path,
response_format=response_format,
timestamp_granularities=timestamp_granularities,
diarize=diarize,
num_speakers=num_speakers,
punctuation=punctuation,
min_confidence=min_confidence,
)
break
except TranscriptionError as e:
last_err = e
if attempt < _LISTEN_TRANSCRIBE_ATTEMPTS - 1:
await ctx.info(
f"Transcription attempt {attempt + 1} failed ({e}); retrying..."
)
await asyncio.sleep(0.75)
if result is None:
return {
"error": str(e),
"error": (
f"Transcription failed after {_LISTEN_TRANSCRIBE_ATTEMPTS} attempts: "
f"{last_err}"
),
"recorded": str(rec_path),
"saved_to": saved_to,
}