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.
This commit is contained in:
Ryan Malloy 2026-07-04 21:39:43 -06:00
parent 177bf9f7cc
commit fa7a4d51bd

View File

@ -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(