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() 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( async def record_audio_until_silence(
out_path: Path, out_path: Path,
silence_threshold_ms: int = 1500, silence_threshold_ms: int = 1500,
@ -291,16 +312,13 @@ async def record_audio_until_silence(
stderr=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
) )
# Cue the person only AFTER the mic is live (see warmup rationale above), so # Warm up (mic live before the cue so the first word isn't clipped), then
# the first word isn't clipped by pw-record's stream startup. The beep is # fire the "go" tone WITHOUT awaiting it and drop straight into the VAD
# captured as leading audio but VAD skips it (pure tone != speech) and the # loop — so the person can talk right over the tone instead of waiting ~1s
# min_speech_ms guard below keeps it from ending the recording. # for it to finish.
if ready_tone is not None: if ready_tone is not None:
await asyncio.sleep(warmup_ms / 1000) await asyncio.sleep(warmup_ms / 1000)
try: tone_task = _fire_ready_tone(ready_tone)
await play_audio(ready_tone, expected_seconds=0.3)
except Exception:
pass # tone failure is non-fatal — keep recording
pcm_buf = bytearray() pcm_buf = bytearray()
leftover = b"" leftover = b""
@ -343,6 +361,8 @@ async def record_audio_until_silence(
# The inner read returned no data — break outer loop # The inner read returned no data — break outer loop
break break
finally: 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) await _terminate_quietly(proc)
if not pcm_buf: if not pcm_buf:
@ -415,15 +435,12 @@ async def record_audio(
stdout=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
# Cue the person only AFTER the mic is live so the first word isn't clipped # Warm up (mic live before the cue), then fire the "go" tone WITHOUT
# by pw-record's stream startup (the beep bleeds harmlessly into the head of # awaiting it so recording is underway immediately and the person can talk
# the WAV; Parakeet ignores it). The duration timeout starts after the cue. # over it. The beep bleeds harmlessly into the head of the WAV.
if ready_tone is not None: if ready_tone is not None:
await asyncio.sleep(warmup_ms / 1000) await asyncio.sleep(warmup_ms / 1000)
try: tone_task = _fire_ready_tone(ready_tone)
await play_audio(ready_tone, expected_seconds=0.3)
except Exception:
pass # tone failure is non-fatal — keep recording
try: try:
# pw-record never exits on its own — wait the desired duration then term. # pw-record never exits on its own — wait the desired duration then term.
await asyncio.wait_for(proc.wait(), timeout=duration_seconds) await asyncio.wait_for(proc.wait(), timeout=duration_seconds)
@ -438,6 +455,9 @@ async def record_audio(
pass pass
await proc.wait() await proc.wait()
raise 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 # 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 # 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, 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: if ready_tone is not None:
await asyncio.sleep(warmup_ms / 1000) await asyncio.sleep(warmup_ms / 1000)
try: tone_task = _fire_ready_tone(ready_tone)
await play_audio(ready_tone, expected_seconds=0.3)
except Exception:
pass # tone failure is non-fatal — keep recording
pcm = bytearray() pcm = bytearray()
@ -504,6 +523,9 @@ async def _record_fixed_stream(
pass pass
await proc.wait() await proc.wait()
raise raise
finally:
if tone_task is not None and not tone_task.done():
tone_task.cancel()
if len(pcm) < 100: if len(pcm) < 100:
raise PlaybackError( raise PlaybackError(