3a0959f55d
Latency: replies are spoken sentence-by-sentence off the desk API's NDJSON endpoint, so the wait is time-to-first-sentence rather than the whole model call, and Deepgram's live websocket transcribes while you're still talking instead of uploading the WAV afterwards. Both fall back invisibly — a stream that fails before anything was said drops to converse(), and a socket that never opens just means the old one-shot path. Speaking lived in four near-copies in the controller (a reply, a holding line, a streamed sentence, a dialogue scene) that had already drifted: one didn't arm barge-in, another skipped the follow-up rule. It's now speech.Speaker plus an Utterance describing the policy differences, with collaborators injected so the whole of it tests without Qt or audio. The mouth follows the audio rather than a timer: tts.level_of reduces each PCM frame to a 0..1 loudness on a sqrt curve (speech sits well below peak, and a linear map leaves the mouth barely open during normal talking) and that indexes the talking frames, which the sprite script now draws as an openness ramp. Offline pyttsx3 has no waveform, so stale levels hand control back to the timed loop instead of freezing the mouth mid-syllable. Also: the pet starts where you left it (ignoring positions on monitors that are no longer connected, since restoring those faithfully is how it ends up somewhere unreachable), and `python -m bolt_pet --doctor` is a preflight that says what to do about each problem rather than only what's wrong. tests/test_pipeline_smoke.py breaks the pure-logic rule on purpose. Every unit test passed all week while notifications sat unspoken for minutes, the pet said things twice and [laughing] got read aloud — each an interaction between two individually-correct units. It drives whole turns against a real HTTP server on a loopback port, faking only the mic and the speakers. It found a NameError in the paint path that would have fired on every repaint while talking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
141 lines
4.7 KiB
Python
141 lines
4.7 KiB
Python
"""Mic capture + simple energy-based VAD utterance recording.
|
|
|
|
Ported from desk_client/bolt_desk.py's record_utterance() — same tuning
|
|
knobs, same behavior. Kept independent of any UI/threading model so it can
|
|
be unit tested by feeding it a fake "stream" object.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import wave
|
|
from typing import Optional, Protocol
|
|
|
|
import numpy as np
|
|
|
|
from .. import config
|
|
|
|
|
|
class AudioStream(Protocol):
|
|
"""Minimal shape of the object record_utterance() needs — matches
|
|
sounddevice.InputStream's .read(frames) -> (data, overflowed)."""
|
|
|
|
def read(self, frames: int): ...
|
|
|
|
|
|
def pcm_to_wav_bytes(pcm: np.ndarray, sample_rate: int = config.SAMPLE_RATE) -> bytes:
|
|
buf = io.BytesIO()
|
|
with wave.open(buf, "wb") as wf:
|
|
wf.setnchannels(1)
|
|
wf.setsampwidth(2)
|
|
wf.setframerate(sample_rate)
|
|
wf.writeframes(pcm.tobytes())
|
|
return buf.getvalue()
|
|
|
|
|
|
def rms(frame: np.ndarray) -> float:
|
|
return float(np.sqrt(np.mean(frame.astype(np.float64) ** 2)))
|
|
|
|
|
|
def _emit(on_frame, frame) -> None:
|
|
"""Hand a frame to a listener without letting it break the capture.
|
|
|
|
Streaming STT is an optimisation riding along with recording; if its
|
|
socket dies mid-utterance the recording must carry on untouched, because
|
|
the one-shot fallback is about to need the full buffer."""
|
|
if on_frame is None:
|
|
return
|
|
try:
|
|
on_frame(frame)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def record_utterance(
|
|
stream: AudioStream,
|
|
should_continue=lambda: True,
|
|
rms_threshold: int = None,
|
|
silence_end_sec: float = None,
|
|
max_utterance_s: float = None,
|
|
min_utterance_s: float = None,
|
|
grace_s: float = None,
|
|
frame_len: int = config.FRAME_LEN,
|
|
sample_rate: int = config.SAMPLE_RATE,
|
|
on_frame=None,
|
|
) -> Optional[np.ndarray]:
|
|
"""Capture one utterance from *stream*: wait for speech to start, stop
|
|
after trailing silence. Returns None if nothing usable was heard.
|
|
|
|
*should_continue* is polled each frame so a caller can cancel recording
|
|
(e.g. the pet window was closed) without needing threading primitives
|
|
baked into this function.
|
|
|
|
*on_frame*, if given, is called with each captured frame while speech is
|
|
in progress — that is how streaming STT transcribes as you talk rather
|
|
than after (see audio/stt_stream.py). It is fire-and-forget: this function
|
|
still returns the full buffer, so a failed stream costs nothing.
|
|
|
|
*grace_s* is how long to wait for speech to *begin* before giving up.
|
|
The controller stretches it for follow-up questions, where you're being
|
|
asked something and need a moment to think rather than having just said
|
|
the wake word on purpose.
|
|
"""
|
|
rms_threshold = config.RMS_THRESHOLD if rms_threshold is None else rms_threshold
|
|
silence_end_sec = config.SILENCE_END_SEC if silence_end_sec is None else silence_end_sec
|
|
max_utterance_s = config.MAX_UTTERANCE_S if max_utterance_s is None else max_utterance_s
|
|
min_utterance_s = config.MIN_UTTERANCE_S if min_utterance_s is None else min_utterance_s
|
|
grace_s = config.GRACE_SECONDS if grace_s is None else grace_s
|
|
|
|
frames: list[np.ndarray] = []
|
|
started = False
|
|
silence_frames = 0
|
|
silence_limit = int(silence_end_sec * sample_rate / frame_len)
|
|
max_frames = int(max_utterance_s * sample_rate / frame_len)
|
|
grace_frames = int(grace_s * sample_rate / frame_len) # how long to wait for speech to begin
|
|
waited = 0
|
|
|
|
while should_continue():
|
|
chunk, _ = stream.read(frame_len)
|
|
frame = np.asarray(chunk)[:, 0].copy()
|
|
frame_rms = rms(frame)
|
|
if not started:
|
|
waited += 1
|
|
if frame_rms >= rms_threshold:
|
|
started = True
|
|
frames.append(frame)
|
|
_emit(on_frame, frame)
|
|
elif waited > grace_frames:
|
|
return None # woke it up but said nothing
|
|
continue
|
|
frames.append(frame)
|
|
_emit(on_frame, frame)
|
|
if frame_rms < rms_threshold:
|
|
silence_frames += 1
|
|
if silence_frames >= silence_limit:
|
|
break
|
|
else:
|
|
silence_frames = 0
|
|
if len(frames) >= max_frames:
|
|
break
|
|
|
|
if not frames:
|
|
return None
|
|
pcm = np.concatenate(frames)
|
|
if len(pcm) < min_utterance_s * sample_rate:
|
|
return None
|
|
return pcm
|
|
|
|
|
|
def open_input_stream():
|
|
"""Real sounddevice input stream, imported lazily so pure-logic tests
|
|
(record_utterance with a fake stream) don't need PortAudio installed."""
|
|
import sounddevice as sd
|
|
|
|
return sd.InputStream(
|
|
samplerate=config.SAMPLE_RATE,
|
|
channels=1,
|
|
dtype="int16",
|
|
blocksize=config.FRAME_LEN,
|
|
device=config.MIC_DEVICE,
|
|
)
|