Repository navigation
Voice schema option: call timeline - #2612
Draft
kev (kevchoi) wants to merge 11 commits into
Draft
kev (kevchoi) wants to merge 11 commits into
kev (kevchoi) wants to merge 11 commits into
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 12, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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.
Exploration draft, one of several voice-trace schema options kept for review. Not for merge.
Problem
Voice-trace evidence today is scattered across span names (
user_turn,livekit.agent_speaking), vendor keys (contrib.livekit.*) and spans timed on different clocks. Realtime pipelines add transcripts that arrive late or never, and a provider view of an interruption that differs from what the caller heard. Customers can't easily see where time went, who talked over whom, or what evidence is missing.Proposed contract
Integrations log an append-only call timeline:
voice.timelinechild rows of the call span, each holding up to 256voice.eventswith a per-callseq.at_mson the recording's call clock and abasis(audio/local/provider).utterance_id,response_idandtool_call_idjoin events.span_idlinks into the step tree.voice_calls[].speechand step links from the log; the app's voice bar is unchanged.Main tradeoff
This option gives the most precise view of latency, overlap and missing evidence, because everything is on one clock with provenance. The cost is that speech and turns become a Lingua derivation over a new row type. The raw trace tree no longer shows speech by itself, and the SDK must deduplicate hooks that observe the same moment.
Implemented vs sketched
Implemented:
js/src/instrumentation/livekit/voice-schema/call-timeline.ts, not exported)docs/voice-schema/call-timeline/examples/)seq, ordering and start/end pairingSketched in
docs/voice-schema/call-timeline/README.md:No instrumentation emits the timeline yet.
Checks
pnpm exec vitest run src/instrumentation/livekit/voice-schema(injs/): 4 passed.pnpm exec tsc --noEmit -p tsconfig.json(injs/): passed.pnpm exec eslint src/instrumentation/livekit/voice-schema/: passed.prettier --writeon the changed files.Update 2026-10-12: semantics gaps resolved
A review against "can an SDK engineer start without inventing essential semantics" found gaps in this option; they are resolved below. This section supersedes the Checks section above where they differ.
speech_endis the real end: the VAD stop time minus the silence delay. LiveKit cascaded capture now uses newonStartOfSpeech/onEndOfSpeechhooks, not theEndOfTurnInfobounds. Lingua groups utterances into turns throughend_of_turn.utterance_ids.end_of_turnevents, ordered byseq.response_startin the window links to the turn's caller utterances.in_reply_tois only a hint, and Lingua warns when it disagrees with the window.end_of_turnjoins the window that contains its start.seqorder. A newmessageevent linksmessage_idwhen the chat item commits.voice.timelineobject is used everywhere. Chunks hold at most 256 events, flush at 64, and carry the backlog.voice.timeline_version(required, which also gates Lingua) andvoice.pipelineare now on the call span. A dropped backlog is reported asmissing{evidence: timeline}.seqis emission order andat_msis real time; Lingua orders speech byat_ms.chunk_indexandseq.call_end, else at the last event.span_idis a warning, as is the absence of a final chunk.recordings[].sources. Agent speech ends at the playedspeech_end;truncateis only a marker.u<n>utterance ids plus a latermessage.playouttext in both pipelines, withprovideronly as a fallback.played_ms.<context_id>/<n>.span_idcomes from the SDK's ownllm_responsespan.in_reply_tolists the VAD utterances since the previousend_of_turn.u2's STT final arriving before the VAD stop is emitted, soseqandat_msorder differ.Checks:
vitest run src/instrumentation/livekit/voice-schema8/8,tsc --noEmitpassed, eslint passed, prettier applied.cargo test -p lingua processing::trajectory96/96,--test trajectory3/3,cargo clippy --all-targets -D warningspassed, fmt applied, bindings regenerated.Independent re-run after these commits: SDK
vitest run src/instrumentation/livekit/voice-schema8 passed andtsc --noEmitpassed; fullcargo test -p linguain the Lingua worktree passed (1472 tests, 0 failed).Confirmation review 2026-10-12
After the gap fixes above, fresh reviewers re-read this option as an engineer about to implement it and gave a pass/fail verdict on whether any essential semantics were still left to invent. Result: PASS on the third round. The follow-up changes below closed the remaining items.
Final independent re-run: SDK
vitest run src/instrumentation/livekit/voice-schema8 passed,tsc --noEmitandprettier --checkpassed; fullcargo test -p lingua1472 passed, 0 failed.Update 2026-10-12: realtime caller timing
input_speech_started/input_speech_stoppedcarry no item id or audio ms, so the SDK assignsu<n>atspeech_start, timed at receipt (local).speech_endis dated at receipt minus the configured server-VAD silence (voice.server_vad_silence_mson the call span) when known. Otherwise it is the receipt time, which includes provider silence padding.input_audio_transcription_completedattaches by the cascaded rule and then emitsmessage{message_id: itemId}.u1/u2, and the README ID rule is fixed.audio_*_msis cumulative for the session, anchored at the call ms of the first audio frame sent in the session.local.localis used.in_reply_tois required on anyresponse_startemitted before its turn'send_of_turn. Lingua usesin_reply_towhenever it is present; theseqwindow is only a fallback (Lingua spec §3).speech_startis emitted at playout start (the firstplaybackStarted/playbackProgressed).basis: audio, andnin<context_id>/<n>is defined as the playout-stretch counter.VoiceCall.pipelinewording is reconciled: validated, not yet carried.half_cascadenote was added.Checks:
tsc --noEmitpassed, eslint passed, prettier applied.cargo test -p lingua --test trajectory3/3 (fixture updated), clippy--all-targets -D warningspassed,cargo fmt --checkpassed.processing::trajectory, because no Rust source changed; e2e; Lingua TS/wasm.Late realtime transcripts
end_of_turn) that has no final yet. This holds even when a later barge-in utterance is open.message: follows the same attachment. Utterances without a final never get amessage; Lingua message matching tolerates that and relies on themissingevent.half_cascade: agent ids follow the cascaded<speech_id>/<n>scheme.voice.pipeline: warning, field ignored;voice.timelinerows whose call span lacksvoice.timeline_version: warning, rows ignored.Checks:
tsc --noEmitpassed, eslint passed,prettier --checkpassed.Realtime transcript grouping
end_of_turngroup becomes one provider input item. A provider final attaches to the whole oldest group that lacks a final. The text goes on the group's first utterance, and the group's other utterances are marked covered, not missing, so later turns don't shift. This is stated in both the README and the Lingua spec.message: emitted only for providers with input item ids (OpenAI); none for Gemini Live.onStartOfSpeech/onEndOfSpeech,local), andend_of_turncomes from LiveKit's own commit (onEndOfTurn).Checks: SDK vitest 8/8,
tsc --noEmitpassed, eslint passed,prettier --checkpassed. Lingua: spec only; cargo not re-run.Lingua builder 2026-10-12
The Lingua mapping for this option is now implemented in braintrustdata/lingua#490. It was built from the spec by a second engineer, and the open points they settled are written into that spec. This README's status line now says so. Integration capture is still a sketch.
LiveKit source alignment 2026-10-12
Engineers who hadn't written this contract did an emission dry run against the real
@livekit/agents1.9.2 source, plus@livekit/agents-plugin-openai1.9.2 for realtime items. They mapped every field to the hook that supplies it. The first round found realtime signals the examples had assumed that LiveKit doesn't expose: provider timestamps on realtime speech events, a server-VAD commit event, and a provider truncation point independent of local playout. The changes below fix that, and every capture point is now cited by file:line. Result: PASS on the second round.Final independent re-run: SDK
vitest run src/instrumentation/livekit/voice-schema8 passed,tsc --noEmitandprettier --checkpassed; fullcargo test -p lingua1475 passed, 0 failed.Update 2026-10-12: aligned with @livekit/agents 1.9.2 sources
The LiveKit capture table now cites
@livekit/agents1.9.2src/file:line for every hook it relies on.input_speech_started/input_speech_stoppedcarry no provider ms, so caller speech is timed at local receipt.voice.server_vad_silence_msonly when that option is passed to the integration. Otherwise the end includes the silence padding.inferenceDurationas LiveKit does for nativeuser_speaking.end_of_turnis emitted at_realtimeGenerationTaskImplentry for a generation that answers caller input. In the realtime example this moved from 3400 to 3600. Tool continuations emit none.audioEndMsfrom the sameplaybackPosition, and only whencapabilities.messageTruncationis set. The realtime example now shows the truncate at 7900 with 2900, equal toplayed_ms. The README no longer presents a 300 ms provider/playout gap as LiveKit behavior; only integrations that observe the provider independently (Pipecat) can show one.response_id=SpeechHandle.id, captured at reply-task entry, and agentutterance_id=<SpeechHandle.id>/<n>in both pipelines (speech_2/0,speech_4/0).lk.speech_idis not used.response_start.span_id= thellm_responsespan started inside that handle's reply task.activity.currentSpeech, and the playback hook is installed independently of audio capture.message.end_of_turnis emitted only whenonEndOfTurnresolvestrueand commits, not when the turn was skipped while scheduling is paused. Itsat_msis the resolve time.in_reply_tois snapshotted inonPreemptiveGenerationand carried on that handle'sresponse_start, including discarded preemptive responses.voice.pipelineis the pipeline at call start. A handoff that changes the LLM type is out of MVP scope.call-timeline-*.jsonnow use the new example spans, and their expectedvoice_callswere regenerated. Only ids and event values changed: speech intervals, channels andstep_idsare identical.Checks:
tsc --noEmitpassed, eslint passed,prettier --checkpassed.cargo test -p lingua1475 passed / 0 failed,cargo clippy --all-targets -D warningspassed,cargo fmt --checkpassed.Capture rule precision (round 2)
message: emitted only from theAgentSession._conversationItemAddedhook.agent_activity.ts:3975(only whenspeechHandle.scheduled), not fromcommitSkippedUserTurn(:3272).:2054, and assistant items from:4273/:4827.transcript.end_of_turn:onEndOfTurnreturnstrueand neitherschedulingPaused(:1230) nornewTurnsBlocked(:550) was set before the call.:3975commit). The cascaded example now has the turn-1 transcript beforeend_of_turninseq.userTurnCompleteddrops emit noend_of_turn: AMDskipReply(:2697,:3316-3319), non-interruptible (:3328-3333) andStopResponse(:3388-3390). Their utterances fall back to the turn open at their start.response_start→ span:_pipelineReplyTaskImplentry, because LiveKit'sspeechHandleStorage(agent.ts:70) is not exported.inferenceSpanargument (realtime_inference,:4478).:2440.end_of_turn: excludes utterances still open at task entry.end_of_turnnotes.Checks:
tsc --noEmitpassed, eslint passed,prettier --checkpassed.cargo test -p lingua1475 passed / 0 failed, clippy-D warningspassed,fmt --checkpassed.Base:
delner/livekit-voice-recording(2f184fa).Schema at a glance 2026-10-12
3d5118e4,fb273cc0,a10e64cb; Linguaaca261a5,95849123): the SDK README follows the shared 14-section skeleton and opens with "Schema at a glance": the logged row shapes as one TS block, time and relationship summaries, trimmed cascaded/realtime JSONC excerpts checked value-for-value against the examples, and a cascaded-vs-realtime payload table. The Lingua spec opens with the input keys read and theVoiceCall/Speech/VoiceEventoutput shapes, each marked recorded or inferred.messageis emitted at the answering generation's entry, so the barge-inu2maps toitem_u2with no transcript. Provider finals carrymessage_id, and FIFO is only the fallback. The realtime example and both Lingua realtime fixtures were regenerated; speech andstep_idsare unchanged. A new test asserts that every committed caller utterance has amessage.voice.timeline_origin_unix_msrequired iff no recording;final: falsereserved;transcript.message_iddefined;missingemitters, withaudio/tool_resultreserved; agent-onlyresponse_id/interrupted/played_ms; callerspeech_endwithout a subtracted silence; rowmetricsvs call ms;tool_call_idjoin;truncate.audio_end_msorigin; stale Lingua spec lines (VoiceCall.pipeline,TRAJECTORY_METADATA_FIELDS).speech_endis the receipt time including the 500 ms server-VAD padding (u13400,u29000;voice.server_vad_silence_msremoved from the example). Turn 2:end_of_turn/generation/message9000,reschedule9300–9700, final audio 9700–12500. Barge-in 7900/2900 unchanged. Lingua realtime fixture selections now end at 3400/9000.voice.context_message_ids/voice.output_message_idon LLM spans as the recommended extension for click-message-to-audio.tsc --noEmitpassed, eslint passed,prettier --checkpassed (SDK).cargo test -p lingua1475 passed / 0 failed (Lingua).make generate-types, because no Rust source changed (only spec and fixture JSON). Also skipped: e2e and a live LiveKit run.a10e64cb: call spaninput.audiois optional when capture is off.Companion PR: braintrustdata/lingua#490
Other options: speech spans (#2611, braintrustdata/lingua#489); message audio (#2613, braintrustdata/lingua#491)
Comparison note: shared with Kev in the originating Slack thread; it links all six drafts.
🤖 Generated with Claude Code