Skip to content

feat(audio): Add AudioSessionRecorder for audio schema v3 sessions - #2725

Merged
Luca Forstner (lforst) merged 1 commit into
delner/livekit-piifrom
delner/audio-session-recorder-v3
Nov 3, 2026
Merged

Luca Forstner (lforst) merged 1 commit into
delner/livekit-piifrom
delner/audio-session-recorder-v3

Conversation

@delner

Copy link
Copy Markdown
Contributor

Based on #2724

Why

Every voice integration has to describe its session the same way under audio schema v3: sources, a shared timeline, participants, speakers, recorded segments, and each span's place on that timeline. AudioSessionRecorder owns that shape once, so integrations only translate their framework's events into calls.

  • Provides common implementation for session shape to reduce duplication per integration.
  • Provides session-local IDs (participant-N, speaker-N) derived from native values.
  • With audio capture disabled, it can still describes the session, so timing and identities can still be relayed on the trace.

In sum, this enables LiveKit's move to schema v3 (next PR), and future voice integrations.

Example

How an integration uses it:

const audio = new AudioSessionRecorder({
  origin: Date.now(),
  sources: {
    "session-input": { label: "Agent input audio" },
    "agent-output": { label: "Agent audio" },
  },
  audio: { sampleRate: 48_000, segmentBytes }, // omit to describe without recording
  async onFile({ id, data, mimeType, metadata }) {
    // Upload `data` as input.audio[id], then log it with its audio.segments entry.
    session.log({ input: { audio: { [id]: await upload(data, mimeType) } }, metadata });
  },
});

session.log({ metadata: audio.session() }); // schema version, sources, timeline

const { id, added } = audio.participant("PA_x7Kq", {
  type: "user",
  attributes: { "contrib.livekit.participant_id": "PA_x7Kq" },
});
if (added) session.log({ metadata: added }); // audio.participants, first time only
span.log({ metadata: { "audio.participant_ids": [id] } });

audio.record("session-input", pcm, { sampleRate: 16_000, channelCount: 1, offsetMs });
span.log({ metadata: { "audio.selections": audioSelections([{ start, end }]) } });

await audio.finish();

Design notes

integration ──calls──► AudioSessionRecorder ──returns──► metadata to log
                            │
                            └─ AudioRecorder (when recording) ─► onFile(segment + entry)
  • The AudioSessionRecorder is responsible for managing the session, resolving references, etc but it is not responsible for instrumentation or trace data itself... it merely returns metadata that instrumentation can log.
  • It tracks commonly referenced objects like participant and speaker, and helps spans map to a common, PII-less ID. (e.g. participant-1)

…scription

AudioSessionRecorder describes one session's audio in the audio schema v3
metadata shape and never logs: session() returns its schema version, sources
and timeline; participant() and speaker() return a stable, session-local ID
(participant-N, speaker-N) for an opaque native key, plus the entry to publish
the first time only; onFile hands off each recorded segment with its
audio.segments entry, or an omitted entry when storing or encoding fails.
It records through an internal AudioRecorder when audio is configured, and
otherwise describes the session without audio.

audioSelections turns played or spoken intervals into a span's
audio.selections: rounded, sorted, and merged where they overlap or touch.

AudioRecorder now waits for an asynchronous onError, so an omitted segment is
handed off before the recorder finishes.
@lforst
Luca Forstner (lforst) merged commit eb59da8 into main Nov 3, 2026
61 checks passed
@lforst
Luca Forstner (lforst) deleted the delner/audio-session-recorder-v3 branch November 3, 2026 13:53
Luca Forstner (lforst) pushed a commit that referenced this pull request Nov 3, 2026
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:

```json
{
  "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 #2724's PII gate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants