diff --git a/src/mcspeak/audio.py b/src/mcspeak/audio.py index ac98150..11f31d1 100644 --- a/src/mcspeak/audio.py +++ b/src/mcspeak/audio.py @@ -238,6 +238,27 @@ async def _terminate_quietly(proc) -> None: await proc.wait() +def _fire_ready_tone(ready_tone: Path | None) -> "asyncio.Task | None": + """Play the "go" cue WITHOUT blocking capture, so the person can talk over it. + + The old behaviour awaited the tone before the read loop began, adding ~1s + of dead time. Firing it as a background task lets capture start immediately; + the tone bleeds into the recorded head but VAD skips it (pure tone != + speech) and the min_speech_ms guard keeps it from ending the recording. + Returns the task (or None) so the caller can cancel it on cleanup. + """ + if ready_tone is None: + return None + + async def _play() -> None: + try: + await play_audio(ready_tone, expected_seconds=0.3) + except Exception: + pass # the cue is optional — never fail a recording over it + + return asyncio.create_task(_play()) + + async def record_audio_until_silence( out_path: Path, silence_threshold_ms: int = 1500, @@ -291,16 +312,13 @@ async def record_audio_until_silence( stderr=asyncio.subprocess.DEVNULL, ) - # Cue the person only AFTER the mic is live (see warmup rationale above), so - # the first word isn't clipped by pw-record's stream startup. The beep is - # captured as leading audio but VAD skips it (pure tone != speech) and the - # min_speech_ms guard below keeps it from ending the recording. + # Warm up (mic live before the cue so the first word isn't clipped), then + # fire the "go" tone WITHOUT awaiting it and drop straight into the VAD + # loop — so the person can talk right over the tone instead of waiting ~1s + # for it to finish. if ready_tone is not None: await asyncio.sleep(warmup_ms / 1000) - try: - await play_audio(ready_tone, expected_seconds=0.3) - except Exception: - pass # tone failure is non-fatal — keep recording + tone_task = _fire_ready_tone(ready_tone) pcm_buf = bytearray() leftover = b"" @@ -343,6 +361,8 @@ async def record_audio_until_silence( # The inner read returned no data — break outer loop break finally: + if tone_task is not None and not tone_task.done(): + tone_task.cancel() # don't let a short recording outlive by the tone await _terminate_quietly(proc) if not pcm_buf: @@ -415,15 +435,12 @@ async def record_audio( stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) - # Cue the person only AFTER the mic is live so the first word isn't clipped - # by pw-record's stream startup (the beep bleeds harmlessly into the head of - # the WAV; Parakeet ignores it). The duration timeout starts after the cue. + # Warm up (mic live before the cue), then fire the "go" tone WITHOUT + # awaiting it so recording is underway immediately and the person can talk + # over it. The beep bleeds harmlessly into the head of the WAV. if ready_tone is not None: await asyncio.sleep(warmup_ms / 1000) - try: - await play_audio(ready_tone, expected_seconds=0.3) - except Exception: - pass # tone failure is non-fatal — keep recording + tone_task = _fire_ready_tone(ready_tone) try: # pw-record never exits on its own — wait the desired duration then term. await asyncio.wait_for(proc.wait(), timeout=duration_seconds) @@ -438,6 +455,9 @@ async def record_audio( pass await proc.wait() raise + finally: + if tone_task is not None and not tone_task.done(): + tone_task.cancel() # pw-record's exit code on SIGTERM is non-deterministic: some versions # return 0, some 1, some -15. The reliable signal is whether the WAV @@ -477,12 +497,11 @@ async def _record_fixed_stream( stderr=asyncio.subprocess.DEVNULL, ) + # Warm up, then fire the "go" tone WITHOUT awaiting so capture is underway + # immediately and the person can talk over it. if ready_tone is not None: await asyncio.sleep(warmup_ms / 1000) - try: - await play_audio(ready_tone, expected_seconds=0.3) - except Exception: - pass # tone failure is non-fatal — keep recording + tone_task = _fire_ready_tone(ready_tone) pcm = bytearray() @@ -504,6 +523,9 @@ async def _record_fixed_stream( pass await proc.wait() raise + finally: + if tone_task is not None and not tone_task.done(): + tone_task.cancel() if len(pcm) < 100: raise PlaybackError(