Skip to content

feat(livekit): Trace LiveKit voice sessions with audio schema v3 - #2726

Merged
Luca Forstner (lforst) merged 11 commits into
delner/audio-session-recorder-v3from
delner/livekit-audio-v3
Nov 3, 2026
Merged

Luca Forstner (lforst) merged 11 commits into
delner/audio-session-recorder-v3from
delner/livekit-audio-v3

Conversation

@delner

Copy link
Copy Markdown
Contributor

Based on #2725

Why

LiveKit voice traces now follow audio schema v3. Some of the major changes from this:

  • Speech and user turns carry audio.selections in 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.
  • Attribution for sources, participants (e.g. a caller), and speakers (individual voices) including support for diarization (identifying which text belonged to what speaker.)
  • Links between when operations, when possible. e.g. A model response links to the transcripts in its context, a tool to the response that requested it, and played speech to the synthesis and model response that generated it.

Example

A two-caller order call with a diarizing STT (from the e2e fixture):

session          audio.schema.version: 3.0.0 · sources · speakers · segments · timeline
├── user_turn    input: "Hi. I'm calling with my partner, … Where is order demo one zero four two? …"
│   ├── user_speech ×4                                        audio.selections
│   ├── stt  "Hi. I'm calling with my partner,"               speaker-1
│   ├── stt  "who has a question about our order."            speaker-1
│   ├── stt  "Where is order demo one zero four two?"         speaker-2
│   └── …
└── agent_turn   input: (the transcript above) · turn.reply_to → user_turn
    ├── llm_response   uses_context → each stt
    ├── lookup_order   requested_by → llm_response
    ├── llm_response   uses_context → each stt · consumes_tool_result → lookup_order
    ├── tts            generated_by → llm_response
    └── agent_speech   "Your order … is scheduled for delivery on Friday."
                       audio.selections · generated_by → tts

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

  • Prefer keys and IDs native to the framework rather than heuristics. Replies match turns by LiveKit's identities, not timestamps. An STT span runs from the first result to the final, under the turn it began in. Heard text comes from the sink's synchronized transcript.
  • Lean on more reliable mechanics for timing. Agent playout is timed by the sink, even after an interruption. User speech is selected only with a local VAD, since provider speech events arrive late.
  • Identities can only be added when LiveKit knows them. Without rooms, diarization, or PII permitted, some forms of identification are not always possible.
  • New fields follow fix(livekit): Respect LiveKit's PII and project redaction settings #2724's PII gate.

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.
@lforst
Luca Forstner (lforst) merged commit 9c0665f into main Nov 3, 2026
117 of 119 checks passed
@lforst
Luca Forstner (lforst) deleted the delner/livekit-audio-v3 branch November 3, 2026 13:53
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants