Repository navigation
feat(livekit): Trace LiveKit voice sessions with audio schema v3 - #2726
Merged
Luca Forstner (lforst) merged 11 commits intoNov 3, 2026
Merged
Luca Forstner (lforst) merged 11 commits into
Luca Forstner (lforst) merged 11 commits into
Conversation
AudioSessionRecorder describes a session's audio in the v3 metadata shape and never logs: session() returns its sources and timeline, participant() and speaker() return a stable session-local ID plus the entry to publish on first sight, and onFile hands off each recorded segment with its entry. The integration writes each fact once, when it becomes known. LiveKit publishes the session's sources and timeline when it starts, each uploaded file together with its audio.segments entry, and each agent speech's audio.selections when its playout is confirmed (via the new audioSelections helper). Participants are LiveKit room connections, keyed by their native sid with its kind under contrib.livekit attributes; a session without a room has none.
…rruption Playback timing no longer depends on recording audio. The sink's playback events always place played speech on the timeline as agent_speech audio.selections, with LiveKit's native playback events, while recording (when enabled) separately captures the confirmed frames. Selections are the observed playout, not clipped to what was recorded. Playout is claimed by the agent_speaking span LiveKit starts in response to it, so a playout that finishes after its speech span has ended (as after an interruption) keeps its selection; previously it was lost when the sink reported no progress events.
User speech is observed audio: its source and its room participant, with no transcript text. Each STT span names the source, the turn's room participant, and the diarized voice LiveKit reports as audio.speakers, with the native label as an attribute rather than a display name. Agent speech is generated_by the synthesis that produced it. Messages associate by native identity only, never timing: a committed user message resolves to its turn's STT spans by LiveKit's native turn start, so a model response uses_context every transcript of the turn it answers. An assistant message belongs to the speech LiveKit still holds when it commits. LiveKit doesn't relate transcripts to speech intervals, so no transcribes link is inferred.
Two synthetic callers share one microphone. LiveKit Inference (Deepgram Nova-3 with diarize) labels each voice; the trace names both as audio.speakers on their STT spans, keeps LiveKit's label as contrib.livekit.speaker_id, and the answer's context links every transcript of the turn. Recorded at 16 kHz so replayed audio matches exactly. The scenario also fails clearly when TTS produces no agent audio: LiveKit commits a reply even when synthesis fails, is empty, or stalls.
A user_turn's input is the transcript LiveKit stamps on the turn itself (lk.pii.user_transcript), so it is the text the turn natively produced and needs no association. When LiveKit commits the turn's message, its native ID and speaking times decorate the turn as contrib.livekit metadata, matched by the exact turn start LiveKit copies onto the message. A message committed without that key (a late transcript after its turn committed) decorates no turn rather than guessing one.
When LiveKit times user speech with a local VAD (its default, or the app's own), each user_speech is selected from its native start and end, and each user_turn from LiveKit's first to last speech, including pauses but not the processing after. They are observed intervals, independent of recorded audio. Measured live against the recorded input (LiveKit 1.9.3, Silero, two-caller fixture): VAD starts land within 35 ms of the audio, ends 60-130 ms late. When the STT provider's speech events drive turns instead (turnDetection "stt" without a VAD), they arrived 0.4-1.1 s late, plus spurious zero-length intervals while the agent spoke, so those get no selections.
An agent_turn's input is the user message LiveKit stamps on the turn it starts (lk.pii.user_input): typed input, which has no user turn, and the transcript of spoken input. A reply LiveKit starts on its own has none.
…final LiveKit has no native STT span, and the span was created when the final transcript arrived, so it had no real start and, when LiveKit had already closed the turn on silence, landed on the session spanning the whole call. Each utterance's recognition now starts at the first result STT emits for it (an interim with text, or the speech events of non-streaming STT) and ends at its final, under the user turn open when recognition began. A final with nothing before it is an instant at its arrival.
An interrupted reply's text is kept when the sink reported what was heard (its synchronized transcript, which LiveKit builds the text from) and omitted otherwise, instead of matching the text against the heard suffix. That signal is recorded on the playout's own speech, so it holds when playback finishes after the speech span ends. A playout without progress events is taken as one contiguous run only on a sink that can't pause (LiveKit's own canPause), since LiveKit may pause one that can. Agent speech is generated_by every synthesis in its reply: an app that splits a reply into segments synthesizes each separately, and all of them make up the speech, including segments that start after it begins.
…entity LiveKit starts a reply within the user turn it answers: preemptively, from the final transcript that completes the user's words, or when the turn completes. The final-transcript handler now runs in its user turn's scope, as speech start already did, so a preemptive reply's agent_turn has turn.reply_to, and the reply's own user message (handed to it at start) is recorded against that turn before the model call can end. The turn start LiveKit copies onto the message remains a fallback key. Live, with preemptive generation on by default, turn.reply_to went from 1 of 7 agent turns to every reply but one to a late transcript that arrived with no turn open.
Luca Forstner (lforst)
approved these changes
Nov 3, 2026
kev (kevchoi)
added a commit
to braintrustdata/lingua
that referenced
this pull request
Nov 9, 2026
read/parse the required voice/audio specific stuff for building the Trajectory --- > AI-created or modified and not human-reviewed in its current form; treat this artifact as provisional. Updated 2026-11-07. Parses audio trace schema v3 metadata into typed fields on span import. The metadata comes from LiveKit voice traces logged by the Braintrust JS SDK (braintrustdata/braintrust-sdk-javascript#2726). Behavior doesn't change: nothing reads these fields until #573, so every existing trajectory builds as before. ## Import - **Header fields.** `SpanContext.voice: VoiceContext` holds `audio.schema.version`, `turn.id`, `turn.reply_to`, and `interrupted`, read from `contrib.livekit.interrupted`, the only framework key so far. `TRAJECTORY_METADATA_FIELDS` gains these four keys, so Brainstore headers carry them. A schema version that isn't a string is kept as an error; other parse errors go to `VoiceContext.errors`. - **Payload fields.** On a span with an audio schema version, import builds the universal `Recording` (#571) from `audio.timeline`, `audio.sources` and `audio.segments`, and stores it in `ImportedSpan.voice: VoicePayload`. These maps come from payloads because Brainstore headers can't carry maps of objects. - **One public recording type.** The SDK's shape (segments keyed by id, string channel keys, a float `origin_unix_ms`, a nested `attachment.ref`) only exists as private deserialization structs in `import/audio.rs`. Import sorts segments by `start_offset_ms`, parses channel keys as numbers and converts the origin to a datetime. - **Validation.** A missing or invalid origin, an unknown audio source, a bad channel key, a ready segment without an attachment and an unknown segment state go to `VoicePayload.errors` without dropping the recording. Only a missing origin leaves no recording. #573 reports these errors for voice spans only. - **Typed parsing.** `import/audio.rs` deserializes every field into typed structs through the existing `metadata_field` helper, now `pub(super)`, with no raw JSON field access. - **Structure check.** The `SpanContext` destructure ignores `voice` (`voice: _`) until #573 compares it in voice roots. ## Stack 1. #571: recording types. 2. **This PR:** typed import of audio trace schema v3 metadata. 3. #573: voice turns, stream integration and fixtures. This stack replaces #568, whose description has the overall design. ## Testing On a Starfolk box with stable Rust, on this PR's tip, `cargo fmt --all -- --check`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo test -p lingua` and binding generation pass, with no binding changes. No tests are added or changed; #573 tests these fields where it uses them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
kev (kevchoi)
added a commit
to braintrustdata/lingua
that referenced
this pull request
Nov 9, 2026
add voice trace fixtures and make it work --- > AI-created or modified and not human-reviewed in its current form; treat this artifact as provisional. Updated 2026-11-07. Builds LiveKit voice traces from the Braintrust JS SDK, which use audio trace schema v3 (braintrustdata/braintrust-sdk-javascript#2726), into the standard `Trajectory`: one `Turn` per conversational exchange, plus the session's `Recording`. This is the only PR in the stack that changes behavior. There's no separate voice tree, and universal message types don't change. ## Voice turns - **Grouping.** A `user_turn` opens an exchange. An `agent_turn` joins the exchange its `turn.reply_to` names, or opens its own with its input as the request. Other spans join the turn their `turn.id` names or, without one, their nearest ancestor's, as chat spans inherit `turn_id`. - **`request`.** The user's transcript, from the input of the turn span that opens the exchange. Input that import keeps as unsupported goes to `opaque_request`, as in chat. - **`response`.** What the caller heard: the played text of the last `agent_speech`, cut off where playback was. When nothing played, including an `agent_speech` with no output, the turn has no response. A failed final `agent_speech` becomes a work step that keeps its error, and the turn has no response, as in chat. - **Work steps.** - LLM calls are `AgentResponse` steps with their generated text, and tools are `ToolResult` steps. - STT and TTS are `ToolResult` steps with `span_type: "task"` that hold only text from messages import already parses: what STT heard, and what TTS spoke. - Earlier `agent_speech` spans are `AgentResponse` steps. - **`interrupted`.** Set when the `agent_turn` or any `agent_speech` reports `contrib.livekit.interrupted`. In staging traces, 7 of 8 barge-ins left no `agent_speech` at all. - **Times.** A turn starts at its opener, the `user_turn` or an `agent_turn` that opens its own exchange, and ends at its latest span. Work steps keep their own times, so an STT stream that opened before the caller spoke starts before its turn. - **Skipped turns.** A user turn with no transcript, no unsupported input and no reply, such as the SDK's empty trailing `user_turn`, produces no turn. - **Recordings.** Each session span emits the `Recording` that import built from its payload (#572) as one `TrajectoryEvent::Recording` (#571). ## Span kinds `kind()` in `trajectory/voice.rs` maps the SDK's task span names to user turn, agent turn, user speech, agent speech, and speech tasks (`stt`, `tts`). LLM and tool spans keep the `Role` chat gives them. Import copies no raw payloads, because speech steps come from parsed messages. ## Streaming - **Scope.** Voice planning covers the session, a span whose header has `audio.schema.version` 3.x, and its descendants. Spans outside every session stay on the chat path, so a session nested under the app's own spans doesn't change how the rest of the trace builds. - **Headers decide structure.** The #572 header fields alone decide each span's turn and work position. In a trace with a session, a payload whose voice fields differ from its header is a structure change. - **Incremental emission.** Voice spans go through the existing ordered drain loop, so turns stream as spans load and each payload is released after use. Output doesn't depend on load order or batch size, which the existing fixture variants check. - **Import failures.** Each of these is reported, and the rest of the call still builds: - a voice span with no `turn.id` of its own or on any ancestor; - an unresolved `turn.id` or `turn.reply_to`; - the recording problems import collects (#572), such as an unknown audio source, a ready segment with no attachment or a segment with an unknown state. A schema version that is unsupported or isn't a string is also an import failure. That session builds no turns, and the rest of the trace still builds. - **No voice header fields.** The trace builds exactly as it does today. - **Shared with chat.** LLM and tool steps in a voice turn build through chat's `work`, which now takes the span's role as a parameter. Chat's existing code otherwise stays as it was. STT and TTS steps build their `WorkStep` in `voice.rs`. `ownership()` carries `turn.id` down to descendants as it carries `turn_id`. ## Stack 1. #571: recording types. 2. #572: typed import of audio trace schema v3 metadata. 3. **This PR:** voice turns, stream integration and fixtures. This stack replaces #568, whose description has the overall design. ## Not in this PR - **Speech clips.** Per-turn audio pointers (`Turn.request_speech`, `AgentResponse.speech`) can be added later without breaking anything. - **Duplex and realtime traces.** These have no turn spans (braintrustdata/braintrust-sdk-javascript#2635). - **`metadata.links`.** Grouping a cascade doesn't need them. ## App follow-ups - **Event schema.** braintrustdata/braintrust#24801 adds the `recording` event to the app's zod `trajectoryEventSchema`, which would otherwise reject it and fail the stream. - **Merge step.** `importPreprocessedTrajectories` has to carry `recordings` once the app renders them. - **Browser fallback.** It loads payloads without metadata, so voice traces built there have no recordings. - **Aliases.** Nobody has confirmed that BTQL accepts header aliases such as `metadata_turn.id`. The app's JSON syntax tree treats an alias as a plain string. ## Testing - **Fixtures.** Two anonymized LiveKit cascade traces from staging, with real barge-ins: - `voice-interruption-before-playback.json`, cut off before anything played; - `voice-interruption-during-playback.json`, cut off mid-sentence. Ids that identify LiveKit participants, rooms, jobs or provider requests are replaced, as are attachment keys. - **Tests.** - Headers without voice fields build the same trajectory as a non-voice trace. - Small inline span lists cover cases the fixtures don't reach: an STT span that joins its turn through its parent, an `agent_speech` with no output, a failed final `agent_speech`, a trailing user turn that holds only unsupported input, a segment with an unknown state, a schema version that isn't a string, a payload that moves a span to another turn in a voice trace, and a session nested beside an LLM span, with a supported and an unsupported version. - **Checks.** On a Starfolk box with stable Rust, on the whole stack at this PR's tip, `cargo fmt --all -- --check`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo test -p lingua` (1,516 library tests) and binding generation pass, with no binding changes. CI's `typed-boundary` job passed on draft PR #586 at `72b081ae`, whose tree differs from this tip by one doc comment. - **App.** Not rerun after the restack. On a Starfolk box with Lingua at `55a720ca` and the schema line from braintrustdata/braintrust#24801, the thread view builds the during-playback fixture as 6 turns, three marked interrupted. ## Questions for review - **Fallback logic.** Two behaviors may need approval under the `AGENTS.md` rule on unapproved fallback logic: - headers without voice fields build the trace as a non-voice trace; - an `agent_turn` whose `turn.reply_to` doesn't resolve opens its own exchange, and the unresolved reference is reported as an import failure. - **Fixture size.** The staging fixtures are 164 KB and 241 KB; existing fixtures are at most 6 KB. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.
Based on #2725
Why
LiveKit voice traces now follow audio schema v3. Some of the major changes from this:
audio.selectionsin reference to the session's timeline, so a span points at its exact place in the recording. This is recorded even when audio capture is off.Example
A two-caller order call with a diarizing STT (from the e2e fixture):
Session metadata, illustrated for a call with a room participant:
{ "audio.schema.version": "3.0.0", "audio.sources": { "session-input": { "label": "Agent input audio" }, "agent-output": { "label": "Agent audio" } }, "audio.speakers": { "speaker-1": { "type": "user", "attributes": { "contrib.livekit.speaker_id": "0" } } }, "audio.participants": { "participant-1": { "type": "user", "attributes": { "contrib.livekit.participant_id": "PA_x7Kq", "contrib.livekit.participant_kind": "SIP" } } }, "audio.segments": { "part-0": { "state": "ready", "start_offset_ms": 0, "duration_ms": 30000, "attachment": { "ref": "/input/audio/part-0" } } } }Design notes