Skip to content

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:play cuts a WAV or a :class:PCMAudio into frame_ms frames and delivers them one per frame_ms of wall-clock time — or as fast as the loop allows with realtime=False, the level where the VAD is scripted and time is not what is under test;
  • every :meth:send_audio / :meth:send_audio_sync is 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.

frame_ms property

frame_ms

Frame duration :meth:play delivers at.

play async

play(session, source, *, realtime=True)

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

captured(session)

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.

write_capture

write_capture(session, path)

Write the bot's audio for session to a WAV file.

clear_capture

clear_capture(session)

Forget what the bot sent to session so far.

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.

triggers property

triggers

The triggers this trace observes.

close

close()

Remove this trace's hooks from the kit. The entries stay readable.

entries

entries(trigger=None, *, session_id=None, after=None)

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).

sequence

sequence()

The triggers in the order they fired.

first

first(trigger)

The oldest entry for trigger, or None if it never fired.

last

last(trigger)

The newest entry for trigger, or None if it never fired.

elapsed_ms staticmethod

elapsed_ms(start, end)

Milliseconds from start to end.

clear

clear()

Forget every entry recorded so far.

wait_for async

wait_for(trigger, *, timeout=2.0, after=None, session_id=None)

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

TraceEntry(t, seq, trigger, payload, room_id, session_id)

One hook firing, as the trace saw it.

t instance-attribute

t

time.monotonic() when the hook ran: the clock the channel's own timings (barge-in confirmation, echo windows) read.

seq instance-attribute

seq

Position in the timeline, from 0: two hooks can share a clock tick, never a position, so after= anchors on it.

payload instance-attribute

payload

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

session_id

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

PCMAudio(data, sample_rate=DEFAULT_SAMPLE_RATE, channels=1, sample_width=2)

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 +.

frame_align property

frame_align

Bytes per sample frame: one sample of every channel.

frame_bytes

frame_bytes(frame_ms)

Bytes in one frame_ms frame at this format.

read_wav

read_wav(path)

Read a whole WAV file into a :class:PCMAudio.

write_wav

write_wav(path, audio)

Write audio as a WAV file, creating the parent directory if needed.

pcm_frames

pcm_frames(audio, *, frame_ms=20)

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

silence(duration_ms, *, sample_rate=DEFAULT_SAMPLE_RATE)

duration_ms of digital silence, 16-bit mono.

tone

tone(duration_ms, *, frequency_hz=440.0, amplitude=0.5, sample_rate=DEFAULT_SAMPLE_RATE)

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).