Repository navigation
Voice schema option: speech spans - #2611
Draft
kev (kevchoi) wants to merge 8 commits into
Draft
kev (kevchoi) wants to merge 8 commits into
kev (kevchoi) wants to merge 8 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>
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. This is one of several alternative voice-trace schemas; it is not meant to merge as-is.
Customer problem
Customers replaying a voice call need to know what each side said, hear the matching audio, see where the delay was, see who interrupted whom, and see which tools ran. They also need to see when evidence is missing. Today speech is recognized only by LiveKit span names and
contrib.livekit.*keys, so other integrations don't show up. Links from an utterance to its LLM step are guessed from start times.Proposed contract
speechtask span under its turn, carryingaudio.selectionsand a neutralmetadata["voice.speech"]. That key holds:complete,interrupted,incompleteorignored) and timing source;heard_by(user),produced_by(agent) andinterrupted_by;end_of_turn_ms;output.textandplanned_texthold the content.voice.call {framework, pipeline}. The recording layer (audio.recordings) is unchanged.docs/voice-schema/speech-spans/README.md.Main tradeoff
Per-utterance evidence is explicit and framework-neutral, at the cost of extra spans and late merge writes for links known only after the utterance ends. Delay diagnosis still depends on the pipeline step spans, which realtime pipelines don't have.
Implemented vs sketched
js/src/instrumentation/livekit/voice-schema/speech-spans.ts, not exported);docs/voice-schema/speech-spans/examples/);Checks
pnpm exec vitest run src/instrumentation/livekit/voice-schema(injs/): 6 passed. It failed as expected whenproduced_bypointed at a non-LLM span.pnpm exec tsc --noEmit -p tsconfig.json(injs/): passed.pnpm exec eslint src/instrumentation/livekit/voice-schema: passed.pnpm exec prettier --checkon the changed files: passed.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.
SDK
16dd784band Lingua265a4a46resolve gaps that an implementer would otherwise have had to invent:audio.selectionsrange. Span bounds run from the first fragment to the last.SpeechHandle, PipecatBotStarted→BotStopped). Pause and resume stays one span.s1now has two fragments.turn_id. The examples add a backchannels5, and Lingua accepts either parent.voice.call.origin_unix_msis the clock for every call-ms field, and Lingua finds calls viavoice.call.timing_sourcemust not beaudio_samples.Speechgainsstart_time/end_time. A new no-audio Lingua fixture covers this.voice.speechobject, because arrays are replaced on merge. The end write waits for the first LLM span, with a timeout, so most utterances need one write.timing_sourcevalue.span_clock).voice.speechnow replaces LiveKit name rules per trace and speaker, not per trace (new test).played_msis always > 0.output.textis optional everywhere.user_turn/assistant_turnnames.BaseObserveron thePipelineTask, using output-transport audio-write timestamps.played_mscomes from bytes written, and links use Braintrust span ids.Checks:
vitest run src/instrumentation/livekit/voice-schemaaudio_samples-without-audio andplayed_ms: 0rejections)tsc --noEmit -p tsconfig.json, eslint, prettier--checkcargo test -p lingua(private target dir)--all-targets,cargo fmt --checkSpeech.tschanged;git statusclean after commitStill skipped: wasm/TS binding tests, e2e, app.
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 (1475 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 second round. The follow-up changes below closed the remaining items.
Final independent re-run: SDK
vitest run src/instrumentation/livekit/voice-schema9 passed,tsc --noEmitandprettier --checkpassed; fullcargo test -p lingua1475 passed, 0 failed.Follow-up 2026-10-12: realtime spoken text
SDK
b3c971ddand Lingua23513037:output.textcomes only from playout-synchronized text,transcript.source: "playback_sync", in both cascaded and realtime. On LiveKit that isplaybackFinished.synchronizedTranscriptin content mode. On Pipecat it is the text frames observed while the bot speaks, cut at theInterruptionFrame.planned_textis the TTS input (cascaded) or the response transcript (realtime). Realtime response transcripts hold the full planned text, and OpenAI truncate deletes the server transcript.output.textis absent andtranscript.source(tts_input/model_output) describesplanned_textonly.s2ands4now useplayback_sync. Ther2response span logs the full planned text, and the next request's history holds the truncated text. The Lingua realtime fixture is updated to match.session.sayorTTSSpeakFramegreeting is parented to the call span withoutturn_id.UserStoppedwith no aggregation pushed) and when it is written.half_cascade: documented as the realtime user path plus the cascaded agent path.voice.speechkey that fails to parse is reported on its span and does not count toward per-speaker precedence. This was already the code's behaviour; it is now documented.Checks:
--checkcargo test -p lingua(private target dir)--all-targets,fmt --checkFollow-up 2026-10-12: naming tidy
SDK
e12b494a(README only):assistant_turnthroughout. Links target exportedllm_responsespan ids (nativellm_node/realtime_inference); a new "step spans" row says so.user_turn: an SDK-built realtimeuser_turncloses at the input buffer commit.playback_synccounts text frames at the output transport, so it can overstate what was heard when text deltas lead the audio.Checks: SDK vitest 9 passed; tsc, eslint and prettier
--checkclean. Lingua is unchanged.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-schema9 passed,tsc --noEmitandprettier --checkpassed; fullcargo test -p lingua1475 passed, 0 failed.LiveKit source alignment (2026-10-12)
SDK
330af3a7and Linguaf42171cealign the contract with@livekit/agents1.9.2. The README capture table now citessrc/voice/…:linefor each hook.input_speech_started/stoppedcarry no provider ms (llm/realtime.ts:16), so caller bounds are local receipt times withtiming_source: span_clock.provider_vadand provider-ms conversion now apply only to integrations that see raw provider events (Pipecat).end_of_turn_msis the entry of_realtimeGenerationTaskImpl(agent_activity.ts:4496), because there is no commit event anduserTurnCompletedreturns early.provider_played_msis thetruncateaudioEndMs, sent only withcapabilities.messageTruncation(agent_activity.ts:4789-4797) and computed from the same playback position. So it equalsplayed_msfor LiveKit.s2is now 2900/2900.produced_byis thellm_responsestarted in theSpeechHandle's reply task (_pipelineReplyTaskImpl:3747,_realtimeGenerationTaskImpl:4496), notlk.speech_id. That id is the turn-level_turnSpeechId(:4516), shared across tool continuations.activity.currentSpeech(:1168).io.ts:209/227) is installed even with audio capture off.voice.call.pipelineis the pipeline at call start; an LLM-type change in a handoff is out of MVP scope.s1is now 300–3400 withspan_clock; the end of turn moves from 3400 to 3600.s3is 7600–9000;r3and the later spans shift by 400 ms.s5moves afters4(12650–12950), because speaking over a realtime agent would interrupt it.Checks:
--checkcargo test -p lingua(private target dir)--all-targets,fmt --checkPrecision notes (SDK
a6f8ce4a, README only)produced_by:speechHandleStorage(agent.ts:70) is not exported. The implementable rule is the latestllm_node/realtime_inferencerow under the playout owner'scurrentSpeech._agentTurnSpanthat started beforeplaybackStarted;playback.ts:241already resolves that owner.provider_played_msexists only whenoutput.played === "partial"andmessageTruncationis supported (agent_activity.ts:4786-4789). Its value isMath.floor(playbackPositionInS * 1000).played_msisplaybackPosition(seconds,io.ts:294) × 1000.generateReplygenerations (agent_activity.ts:2058-2089).transcript.available_ms= last fragment end +transcriptionDelay(audio_recognition.ts:144).Checks:
--check: clean.Base:
delner/livekit-voice-recording(2f184fa).Schema at a glance 2026-10-12
961f4cdbreorders the README into the shared 14-section skeleton and adds "Schema at a glance": the logged row shapes as one TS block, time rules, ID links, and trimmed cascaded and realtime JSONC excerpts (checked value by value against the example files), plus a cascaded vs realtime table. Lingua61040980adds a glance to the spec covering the input keys read and theSpeechoutput fields, with what is recorded and what is inferred for each.audio.selectionsoffsets are ms from the start of their segment (@braintrust/audiosegmentSelections; LinguaAudioSelection), not call ms. The README, zod (segmentMs) and the test now map offsets throughrecording_start_offset_ms. The examples are unchanged: they have one segment starting at 0.metadata.tool_call_idequalstool_calls[].idon the requestingllm_response.contrib.livekit.request_span_idis not part of the contract.played_msandprovider_played_msare labelled as durations (zoddurationMs), andturn.reply_toandturn_idas span ids. The README also lists the fields Lingua does not read.voice.call.frameworkandpipelineas plain strings, so an unknown value is not rejected. The spec said it was.tsc --noEmitandprettier --checkon the changed files: clean. The leak grep matched onlycall-0000, the SDK's own segment id (already in the base branch's e2e snapshots).make generate-types(docs-only change), and eslint (the test file is eslint-ignored; the zod file is clean).Companion PR: braintrustdata/lingua#489
Other options: call timeline (#2612, braintrustdata/lingua#490); 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