Merge listen-talkover: non-blocking ready tone (talk over the dial)

This commit is contained in:
Ryan Malloy 2026-07-04 21:39:43 -06:00
commit 08f40ae2dc

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(