From fa7a4d51bdd212376513e917a62f772a50783020 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 4 Jul 2026 21:39:43 -0600 Subject: [PATCH] listen(): fire the ready tone non-blocking so you can talk over it The "go" tone was awaited before the VAD read loop started, adding ~1.1s of dead time before capture effectively began. Now the tone fires as a background task right after the mic warmup and the read loop starts immediately, so the person can talk over the dial tone and the turn also ends sooner on silence. The tone still bleeds into the captured head; VAD skips it (pure tone != speech) and the min_speech_ms guard is unchanged. Applied to all three recorder paths (VAD, fixed-duration Linux, macOS stream); the tone task is cancelled in the finally so a short recording is never outlived by the tone. --- src/mcspeak/audio.py | 60 ++++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 19 deletions(-) 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(