VAPI-3990: return a bot's mark only after its queued audio has played out - #29
Merged
Merged
Conversation
… out
In Twilio's Media Streams protocol a bot sends a mark after queuing audio
and is told, via the mark echoed back, when that audio has finished
playing. Bots gate their next turn on it. The bridge echoed the mark the
instant it arrived, so a mark-gated bot was told its utterance had
finished while the audio was still queued and started its next turn over
its own speech.
Bandwidth's StartStream protocol sends no playback-complete signal and
BwStreamSource has nothing to wait on, so the bridge now tracks playout by
duration:
- Each outbound media frame extends a playout clock by its mulaw duration
(8 kHz mono, 8 bytes per ms).
- A mark becomes due at the clock's value when it arrived, so audio queued
after a mark does not delay it. Marks return in arrival order through a
single timer. Nothing queued means the mark returns at once.
- clear empties the queue and returns every outstanding mark immediately,
per Twilio's docs ("empties all buffered audio and causes any mark
messages to be sent back"). The ticket text said to discard them; the
documented behavior is followed instead so a mark-gated bot is never
left waiting.
- stop and close() drop pending marks, since nothing queued will play.
- Marks sent back now carry a sequenceNumber, as Twilio's do.
Expose pendingPlayoutMs() for tests and diagnostics, and export
mulawPayloadDurationMs(). Update AGENTS.md and replace the test that
pinned the synchronous echo with cases for the clock math, immediate echo
with nothing queued, holding a mark, ordering, non-delay by later audio,
stop, and clear.
Validating the clock against real Bandwidth playback needs the live
source from VAPI-3991.
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
…decode Review follow-ups: - Marks return systematically early on the live path: the playout clock starts when a frame reaches the bridge, but the caller hears it one-way network latency plus Bandwidth's jitter buffer later. Add BridgeOpts.playoutLatencyPadMs, added to the due time of any mark with audio ahead of it (idle marks are unaffected). Default 0 until VAPI-3991 yields real measurements. - mulawPayloadDurationMs derives the decoded length from the base64 string instead of allocating a Buffer per frame. A test checks it against a real decode for every padding shape. - Tests: the playout clock resets after clear (audio queued afterwards is timed from scratch, ~100 ms not ~1100 ms); close() drops pending marks and their timer; the latency pad. - clear now hands the outstanding marks to a direct echo loop instead of zeroing their due times, and mark sending is factored into sendMark().
smoghe-bw
approved these changes
Sep 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
In Twilio's Media Streams protocol a bot sends a
markafter queuing audio and is told, via themarkechoed back, when that audio has finished playing on the call. Bots gate their next turn on it. The bridge echoed the mark the instant it arrived, so a mark-gated bot was told its utterance had finished while the audio was still queued, and started its next turn over its own speech.Bandwidth's StartStream protocol sends no playback-complete signal and
BwStreamSourcehas nothing to wait on, so the bridge now tracks playout by duration.Changes
src/streams/bridge.tsclearempties the queue and returns every outstanding mark immediately.stopandclose()drop pending marks, since nothing queued will play.sequenceNumber, as Twilio's do.pendingPlayoutMs()for tests and diagnostics, and exportedmulawPayloadDurationMs(), which derives the decoded length from the base64 string so no Buffer is allocated per frame.BridgeOpts.playoutLatencyPadMs, added to the due time of any mark that has audio ahead of it. Default 0 until real measurements exist (see Not verified).AGENTS.md: note the duration-based playout model under the Stream entry.Deviation from the ticket
VAPI-3990 says a
clearshould discard pending marks. Twilio's docs say the opposite:The documented behavior is implemented. A mark-gated bot that barges in and never receives its mark would otherwise wait forever.
The ticket's Expected behavior section has been corrected to match, with a dated note, so QA tests against the right wording.
Before / after
Bot queues 1 s of audio then a mark:
Bot queues 5 s of audio, a mark, then sends
clear500 ms later:clearVerification
npm run typecheck && npx vitest run: 44 files, 330 passed, 6 skipped. The bridge test files were run three times in a row with no flakes.test/streams-wire.test.ts: the test that pinned the synchronous echo is replaced by cases for the clock math, immediate echo with nothing queued, holding a 300 ms mark, ordering, non-delay by later audio, stop, close(), clear, the clock resetting after clear, the latency pad, and the base64 length math against a real decode for every padding shape.Not verified
The clock starts when a frame reaches the bridge, but the caller hears it one-way network latency plus Bandwidth's jitter buffer later, so marks will still return slightly early on the live path. The bias is in the same direction as the original bug but likely tens of milliseconds rather than the full utterance.
playoutLatencyPadMsexists to absorb it once VAPI-3991 provides a live source to measure against; until then it defaults to 0.Reference: Twilio Media Streams WebSocket messages,
markandclear.