Skip to main content
The StandIn wire is PCM 16 kHz, 16-bit, mono, little-endian in both directions. Almost nothing else is. The realtime speech-to-speech models speak 24 kHz, most TTS vendors emit 22.05 or 24 kHz, and none of them chunk on the wire’s frame boundary. So every plugin that is not a pure passthrough ends up writing the same two things: a resampler, because the rates differ, and a frame aligner, because a resampled buffer does not divide evenly into the wire’s 640-byte frame. They ship in the SDK because they are properties of the wire, not of any framework, for the same reason the sequence number lives in 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.
Knowing when your own audio stops playing is what lets an echo guard tell the caller’s voice apart from your agent’s voice coming back up a speakerphone. That clock is the whole basis of the guard, and Realtime providers is where it is spelled out.

pcm16Rms(pcm)

How loud one frame is, as root mean square normalised to 0.0 to 1.0.
It lives here because three separate things want it and every plugin that wanted it had been writing it again: the utterance segmenter opens its gate on it, the echo guard decides echo from barge-in with it, and a barge-in check of your own can use it directly. It is dependency-free and loop-simple for the same reason the resampler is: it runs on every inbound frame of every call. An odd trailing byte is dropped rather than throwing, and an empty buffer is 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.
An odd trailing byte is dropped rather than throwing. A truncated frame is a glitch; an exception in the audio path is a dropped call. The rounding is deliberately fussy, so that the Python and TypeScript SDKs produce byte-identical output: 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.
Resampled 24 kHz deltas do not divide evenly into the 640-byte frame. Without a residual, the leftover bytes are dropped and every turn loses a few milliseconds at the seams. Over a call that is audible as clipped word endings.
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:
Do 2 before 1 and the model stops while the bot keeps talking for the length of the buffer. Skip 3 and the first frame of the caller’s next answer is prefixed with the last few milliseconds of the turn they interrupted, which sounds like the agent stammering.

A complete realtime leg

Both directions of a 24 kHz provider, with the interrupt handled properly:

Three traps worth naming

  1. Do not track timestamps yourself. sendAudio owns 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.
  2. 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.
  3. Do not reuse one FrameAligner across calls. It carries per-turn state. One per call, and reset() 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.