CallServer. A plugin that has to
reimplement them is a plugin the SDK failed.
Constants
REALTIME_SAMPLE_RATE_HZ is named in the SDK rather than in a plugin because the ratio is what
forces the residual buffer below, and that is an audio concern rather than a provider one.
frameDurationMs(pcm)
Duration of a PCM16 mono buffer in milliseconds. Use this for a playout clock rather than counting
frames: outbound chunk lengths are not fixed, so a frame count drifts against real time.
pcm16Rms(pcm)
How loud one frame is, as root mean square normalised to 0.0 to 1.0.
0.
Agent audio can be dropped
Everything above gets whole frames onto the wire. The wire can still refuse them. When the socket has more than 1 MB (MAX_AUDIO_BUFFER_BYTES) unflushed, sendAudio advances the
timeline and returns without sending, logging at most once every five seconds. The caller hears a
gap rather than the call wedging, and the timeline still advances because a dropped frame is a gap in
what they hear rather than a rewind: stalling the clock would make every later frame claim a time
that has already passed. Control frames are never shed, because a call that cannot be ended is the
failure this exists to prevent.
Watch session.bufferedBytes before pushing a continuous stream, and treat a zero as no evidence of
backpressure rather than proof of an idle socket, because it reads zero when the transport cannot
report it. See Call server.
The avatar tile has its own, tighter budget on the same socket, because a caller forgives a dropped
frame far more readily than a break in the voice. That one is TileStream’s, on
The avatar.
Inbound audio
onCallerAudio hands you the caller’s PCM already decoded and already validated: base64 that is not
canonical is rejected, and so is a payload that is not a whole number of PCM16 samples. A malformed
frame is dropped by the server with a log line and never reaches your handler.
What you do not get is a guarantee of 640 bytes. 20 ms is the nominal frame, and it is what
arrives on a normal call, but the only invariant the decoder enforces is a non-empty, even number of
bytes: at least one whole PCM16 sample, and no half sample at the end. Measure with
frameDurationMs(pcm) rather than counting frames, and never index into a caller frame at a fixed
offset.
resamplePcm16(pcm, srcHz, dstHz)
Linear-interpolation resample of PCM16 mono. Returns the input untouched when the rates match or the
buffer is empty.
Math.round for the output length, and Math.trunc rather than Math.floor when
interpolating, because Python’s int() truncates toward zero and the difference is audible between a
negative and a less-negative sample. The shared conformance vectors assert the two agree.
FrameAligner
Chops arbitrary-length PCM buffers into whole wire frames, carrying the remainder.
flush() pads rather than drops: the tail of the last word matters more than a few milliseconds of
silence. reset() is the barge-in case, where the held-back bytes belong to a turn the caller just
interrupted and must never be played.
The remainder problem, in numbers
A 24 kHz provider does not hand you round numbers, and whether a chunk divides cleanly is luck:
Five milliseconds sounds like nothing. It is not one seam: it is one per chunk, for the whole call,
and it always lands at the end of the audio rather than in the middle, which is why it is heard as
clipped word endings rather than as a glitch. The aligner carries those 186 bytes into the next push
and they are never lost at all.
Resetting on a barge-in
Three steps, and the order is the whole point:A complete realtime leg
Both directions of a 24 kHz provider, with the interrupt handled properly:Three traps worth naming
- Do not track timestamps yourself.
sendAudioowns the sequence number and the outbound timeline. Advancing your own clock and sending it is how timelines end up jumping backwards after an audio source is swapped. - Do not drop the residual at end of turn. That is the clipped-word-endings bug, and it is subtle enough to survive a demo and fail in production.
- Do not reuse one
FrameAligneracross calls. It carries per-turn state. One per call, andreset()on every interrupt.
The same bytes in both SDKs
standin/audio.py and src/audio.ts are the same constants, the same linear resample and the same
carry-the-remainder aligner, and the shared conformance vectors assert that both produce identical
output for the same input. The rounding above is a property of the pair rather than of this language:
it is written the way it is so that Python and TypeScript agree, not because either idiom is nicer.
That matters in practice because a Python worker and a TypeScript worker can share one StandIn
identity, and a call recorded from one has to be indistinguishable from a call recorded from the
other.
Next
Turn-taking
The segmenter, WAV handling and paced playback, for an agent that is not a realtime model.
Realtime providers
The playout clock, and why the agent answers itself on a speakerphone.
Call handler
sendAudio, cancelPlayback and the session that owns the timeline.Checking the install
Prove audio made the round trip without placing a real call.