A realtime plugin needs none of this and pays nothing for it.
standin.voice is a module you do not import.One utterance per phrase
A Microsoft Teams call delivers audio continuously: silence is still frames, fifty a second.UtteranceSegmenter is the gate between that and a service that wants a phrase.
min_utterance_ms is measured on the loud part alone, from the frame that opened the gate to the last loud frame. Measuring the whole buffer would count the pre-roll and the trailing silence, which together are over a second at these defaults, so the floor could never fire and every click would reach the transcriber.
segmenter.speaking is true while the caller is mid-utterance. flush() takes what is held mid-utterance, for teardown. reset() abandons it, for a barge-in.
The WAV a speech engine hands back
It is rarely the one you wanted: 32-bit float, 44.1 kHz, stereo, or wrapped inWAVE_FORMAT_EXTENSIBLE, which ffmpeg emits even for plain PCM. Every one of those plays as noise on a call unconverted, and the header does not make the failure obvious.
LIST and fact chunks before the data and a fixed offset reads them as samples. Stereo is averaged rather than halved, so whoever is on the right channel is not lost. Floats outside the range are clamped, because wrapping is the loudest possible click. Eight-bit WAV is read as unsigned and centred on 128, which read as signed is a square wave of noise.
decode_wav raises ValueError on anything it cannot read, naming what it found. It is the one helper on this page that does raise, because a buffer that is not a WAV at all is a configuration mistake rather than a glitch, and playing it would be noise on a live call.
encode_wav(pcm, sample_rate_hz=SAMPLE_RATE_HZ) goes the other way, for a transcription service that will not take raw PCM. Pass the rate when you are wrapping something that is not at the call’s.
Playing it back
A text-to-speech engine returns a whole utterance at once. A call takes 20 milliseconds every 20 milliseconds. Sending the buffer in one go hands the service seconds of audio it must queue, and that queue is what makes a barge-in arrive too late to matter: the caller interrupts, the model stops, and the bot keeps talking for the length of what was already sent.playing is true while a buffer is going out. cancel() stops it, and is safe to call from a receive loop. Turns are serialised, so two cannot interleave into one stream the caller hears as both at once.
The result is a Playback: sent_ms against total_ms, which is the difference between “I told them” and “I started to”, plus interrupted and the complete property that is its inverse.
complete exists in Python only. The TypeScript Playback carries sentMs, totalMs and interrupted and nothing else, so a check ported between the languages has to be written as !interrupted there.One object that runs the whole turn
The three pieces above are the parts.VoiceLane is the assembly: one per call, fed the caller’s frames, responsible for everything between a frame arriving and an answer being heard.
feed never blocks and never raises. It runs on the receive path of a live call, so the turn it starts is detached: awaiting a model there stops frames arriving, and a lane that has stopped hearing the caller cannot notice an interruption.
The three callables
Each of those contracts is there for a reason.
transcribe is handed a phrase, not frames. Working out where the caller stopped is the segmenter’s job above, and it is the one part of this a transcription service will not do for you.
Its empty return is a signal rather than a failure. It is how you say “there were no words in that”, and the lane acts on it.
answer returning a plain string is the simple case. Returning an async iterator is what gets the first sentence out before the last one exists. The lane tells the two apart at runtime, so a callable is free to return either without a flag to configure.
synthesize returns PCM at the call’s rate, because what comes back goes straight to paced playback. Nothing in the lane converts for you, and the two cases are different:
- A WAV goes through
decode_wavand nothing else. It handles the sample rate, the channel count and the sample format in one step, and returns PCM16 mono at the call’s rate already. - Raw PCM at another rate goes through
resample_pcm16. That is the case where an engine hands back bare samples at 22.05 or 24 kHz.
The awkward parts
One turn at a time. A new utterance cancels the turn in flight before it starts its own. An agent asked two questions at once answers neither well, and both answers would be spoken over each other. The superseded turn stops where it stands, and whatever it had not yet spoken is never spoken. Whichever of your three callables was running is cancelled at its nextawait, so release anything it holds open in a finally.
Barge-in lands when the interruption starts, not when it ends. The moment the caller’s voice opens over the top of an answer, the buffered audio is dropped and StandIn is told to drop what it still holds. Waiting for that utterance to finish would spend the whole of it talking at somebody who has stopped listening, which is the difference between a call that feels alive and one that does not. barge_in() is public, for an interruption the caller’s audio does not show:
Nothing raises into the call
A step that fails is logged, and the caller hears a sentence.
Silence is the one thing a caller cannot interpret. Somebody on a phone call cannot tell a broken transcriber from an agent that is thinking, so they wait, and then they hang up.
TROUBLE_SPEAKING goes back through your own synthesize, once. The retry is guarded, because an engine that is down cannot say the sentence about being down, and the lane goes quiet rather than looping on it.
All three are exported from standin, so a test can assert on the sentence itself.
A long answer, spoken as it is written
Return an async iterator fromanswer and each piece is synthesized and played as it arrives.
synthesize call and one spoken buffer, so a token at a time is a synthesis request per word and speech chopped into syllables. Pieces that are blank are skipped. An interruption stops the loop, and what was never reached is never synthesized and never spoken.
Saying a line nobody asked for
A greeting, a handover, something that arrived from outside the call.say skips answer and goes straight to synthesis and paced playback, so the line can be interrupted like any other answer, and it queues behind whatever is already speaking rather than overlapping it. It returns the VoiceTurn, which is how you find out whether it was heard to the end. It does not supersede a turn in flight, so call barge_in() first when the line has to come before what is being said.
Closing the lane
aclose() drops the buffered audio, resets the segmenter, cancels the turn in flight and waits for it to unwind. Call it from your handler’s aclose, once.
After that, feed is a no-op rather than an error. Frames keep arriving for a moment after teardown begins, and the end of a call is the worst place to start raising from the receive path.
What each turn leaves behind
VoiceTurn is the record of one exchange: heard, said, interrupted and error. interrupted is not a failure. It is the most common way a real conversation goes.
Pass on_turn to receive one per caller turn the agent answered, and you have a transcript for free:
say: it hands you its VoiceTurn as the return value instead, so a greeting is missing from a transcript built only from on_turn.
A synthesis failure is the one that does reach it, with the trouble sentence as said and the engine’s message in error. Check error before filing a turn as something the agent meant to say.
Three more things on the constructor and the object. segmenter takes an UtteranceSegmenter you tuned yourself, with the floor and the four bounds above. speaking is true while audio is going out, and busy covers the whole turn, including the model’s own thinking. turn is the in-flight asyncio.Task, or None, which is how a test or a teardown lets one finish.
A superseded turn is cancelled here, at its next
await. The TypeScript twin cannot do that, because a promise cannot be cancelled, so it retires the older turn by generation number and lets it run to completion: a request already in flight there is still paid for and still has to release its own resources in a finally. Both are correct for their language, and a plugin ported between them has to know which it is getting.What this page does not cover
Two more shared lanes sit next to this one and are worth knowing exist before you write them yourself.- The echo guard. On a speakerphone the agent’s own voice comes back in, the model’s voice detection hears it, and the agent answers itself in a loop.
EchoGuardis the thing that stops it, on a playout clock rather than a wall clock. See Realtime providers. - The meeting gate. In a group call the prior question is whether to answer at all.
GroupGatedecides, and is inert on a one-to-one call. See Group calls.
Next
Audio
pcm16_rms, the resampler and the frame aligner these bounds are measured with.Call tools
Giving the agent behind this lane something to do besides talk.