Voice Test Bench¶
roomkit.voice.testing is the bench a voice scenario is written with: a
simulated phone, a hook timeline, and stdlib WAV helpers. See the
Testing Patterns guide.
Simulated phone¶
ScenarioVoiceBackend ¶
ScenarioVoiceBackend(*, capabilities=NONE, frame_ms=20, capture_sample_rate=DEFAULT_SAMPLE_RATE)
Bases: MockVoiceBackend
A :class:MockVoiceBackend that plays audio at a transport's cadence
and keeps what the bot said.
The mock already injects frames (simulate_audio_received), records
what was sent (sent_audio) and simulates barge-in, session-ready and
disconnects. This adds the two things a scenario needs and a unit test
does not:
- :meth:
playcuts a WAV or a :class:PCMAudiointoframe_msframes and delivers them one perframe_msof wall-clock time — or as fast as the loop allows withrealtime=False, the level where the VAD is scripted and time is not what is under test; - every :meth:
send_audio/ :meth:send_audio_syncis captured per session with its format, readable back as a :class:PCMAudio(:meth:captured) or written to a WAV (:meth:write_capture), so a failed scenario can be listened to.
is_playing is true while a send_audio is in flight, on top of the
mock's start_playing / stop_playing; a mock TTS sends in
microseconds, so a scenario waits on the TTS hooks for "the bot is
speaking", not on this flag. capture_sample_rate is the format a raw
bytes send is captured at (a chunk carries its own): the realtime
channels send raw bytes, typically at 24 kHz. Still a pure transport
(RFC §12): no VAD, no speech intelligence, whatever capabilities it
is told to declare.
play
async
¶
Deliver source to the channel as frame_ms frames.
source is a :class:PCMAudio or the path of a WAV file (read off
the loop). With realtime each frame is due frame_ms after the
previous one, anchored on the first so the cadence does not drift, and
the call returns when the last frame's slot has elapsed; without it
the frames are delivered back to back, yielding to the loop between
two so a streaming STT or a barge-in can interleave, and the call
returns once the last frame has been handed to the channel. Neither is
when the channel has finished with the audio — wait on the hooks
(VoiceTrace) for that. Returns the number of frames sent.
captured ¶
Everything the bot sent to session so far, as one clip.
Raises the ValueError of a format mismatch seen during a send,
which the channel had swallowed.
Hook timeline¶
VoiceTrace ¶
VoiceTrace(kit, *, triggers=VOICE_TRIGGERS)
Subscribes to the voice hooks of a kit and records when each fired.
The record replaces the asyncio.sleep a voice test otherwise waits
with: await trace.wait_for(HookTrigger.ON_TRANSCRIPTION) returns the
moment the transcription hook ran, or raises TimeoutError naming what
did fire. Every entry keeps the hook's payload and its monotonic time, so
an order (:meth:sequence) or a latency (:meth:elapsed_ms) is read off
the timeline rather than off the channel's private state.
Observers are ASYNC hooks, which also see the triggers the channel
runs synchronously (ON_TRANSCRIPTION, BEFORE_TTS): the engine
fires the async observers once the sync chain has allowed the payload,
so a trigger whose sync chain blocked leaves no entry. The engine awaits
its observers (under the hook timeout), which is why they only append to
a list: a wait_for returns when the entry is recorded, not when the
channel is done with the turn. The hooks are registered on the kit under
names unique to this trace; :meth:close removes them.
entries ¶
The entries so far, oldest first, filtered by trigger, session and time.
after is a :class:TraceEntry (entries recorded after it, by
position: two hooks can share a clock tick) or a monotonic time
(entries strictly later).
wait_for
async
¶
Return the first entry for trigger (later than after, for session_id), waiting up to timeout seconds for it to arrive.
Raises TimeoutError naming the triggers that did fire, which is
what a failing voice test needs to read first.
TraceEntry
dataclass
¶
One hook firing, as the trace saw it.
t
instance-attribute
¶
time.monotonic() when the hook ran: the clock the channel's own
timings (barge-in confirmation, echo windows) read.
seq
instance-attribute
¶
Position in the timeline, from 0: two hooks can share a clock tick,
never a position, so after= anchors on it.
payload
instance-attribute
¶
What the hook received: a :class:VoiceSession (speech start and end),
a dataclass of :mod:roomkit.voice.events, the SessionStartedEvent, or
the text (BEFORE_TTS / AFTER_TTS).
session_id
instance-attribute
¶
The voice session the payload names; None when it names none
(BEFORE_TTS / AFTER_TTS carry the text alone).
VOICE_TRIGGERS
module-attribute
¶
VOICE_TRIGGERS = (HookTrigger.ON_SESSION_STARTED, HookTrigger.ON_SPEECH_START, HookTrigger.ON_SPEECH_END, HookTrigger.ON_PARTIAL_TRANSCRIPTION, HookTrigger.ON_TRANSCRIPTION, HookTrigger.BEFORE_TTS, HookTrigger.AFTER_TTS, HookTrigger.ON_BARGE_IN, HookTrigger.ON_TTS_CANCELLED, HookTrigger.ON_DTMF, HookTrigger.ON_TURN_COMPLETE, HookTrigger.ON_TURN_INCOMPLETE)
The hooks a voice turn fires, in the order a turn usually fires them.
WAV and PCM helpers¶
PCMAudio
dataclass
¶
Decoded PCM audio: the samples and the format needed to frame them.
sample_width is bytes per sample; 16-bit signed little-endian PCM
(sample_width=2) is what every WAV the bench reads or writes carries.
Two clips of the same format concatenate with +.
write_wav ¶
Write audio as a WAV file, creating the parent directory if needed.
pcm_frames ¶
Cut audio into frame_ms frames, timestamped from zero.
The last frame is padded with silence to a whole frame (0x80 for
8-bit PCM, which WAV stores unsigned): a backend always delivers full
frames, and so does a real transport.
silence ¶
duration_ms of digital silence, 16-bit mono.
tone ¶
A sine tone, 16-bit mono: loud enough for an energy VAD, cheap to make.
amplitude is a fraction of full scale (0 to 1).