diff --git a/src/mcspeak/audio.py b/src/mcspeak/audio.py index 6c66d50..ac98150 100644 --- a/src/mcspeak/audio.py +++ b/src/mcspeak/audio.py @@ -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 diff --git a/src/mcspeak/server.py b/src/mcspeak/server.py index 5ee6986..faae28c 100644 --- a/src/mcspeak/server.py +++ b/src/mcspeak/server.py @@ -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, }