Repository navigation
Voice schema option: message audio - #2613
Draft
kev (kevchoi) wants to merge 10 commits into
Draft
kev (kevchoi) wants to merge 10 commits into
kev (kevchoi) wants to merge 10 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>
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.
Draft exploration. This is one of three alternative voice-trace schemas, kept for comparison; it is not meant to merge as is.
Customer problem
A customer reading a voice call wants to see each message, play exactly that message's audio, and know whether its text is what was actually said or heard. Today speech lives on span names and vendor keys (
user_turn,livekit.agent_speaking,contrib.livekit.interrupted), so Lingua links audio to LLM steps by start time. That linking is weakest at barge-in, split user turns, and late realtime transcripts.Proposed contract
livekit.agent_session/pipecat.pipeline):metadata["voice.origin_unix_ms"]plusmetadata["voice.messages"], a map of message id toVoiceMessage. EachVoiceMessagerecords:committed, and state (complete/interrupted/unplayed/open);textandgenerated_text;text_status(final/partial/missing/not_captured) andtext_source;@braintrust/audioselections,produced_by_span_id, and nativesource_ids.voice.context_message_ids, aligned 1:1 withinput, andvoice.output_message_id.@braintrust/audiowhole-call recording is unchanged.Main tradeoff
Audio, text and evidence quality are keyed by the same id the thread view renders, and the LLM step link is explicit, so nothing is matched by time. The cost:
Implemented vs sketched
js/src/instrumentation/livekit/voice-schema/message-audio.ts, not exported);docs/voice-schema/message-audio/examples/;docs/voice-schema/message-audio/README.mdcovers fields, IDs, timing, capture points for LiveKit and Pipecat (cascaded and realtime), Lingua mapping, usefulness, edge cases, and an implementation order. No integration code changes.Checks
pnpm exec vitest run src/instrumentation/livekit/voice-schema/message-audio.test.ts(injs/): 8 passed.pnpm exec tsc --noEmit -p tsconfig.jsonand-p tsconfig.test.json(injs/): passed.pnpm exec eslint src/instrumentation/livekit/voice-schema/(injs/) andpnpm exec prettier --writeon the changed files: clean.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.
selectionsis always the full array. Fields only move forward:stategoes fromopento one terminal value;text_statusmay gopartial→final/missingandmissing→final, but neverpartialback frommissing;voice.messagesis no longer gated on content capture.SpeechHandlefirst appears at_pipelineReplyTaskImpl/_realtimeGenerationTaskImpl, andseqis assigned then.item.idat_conversationItemAdded.speech_id, which already carries thespeech_prefix (unplayed, orinterruptedif anything played).item_u1now has two fragments.nullfor inputs that are not items. They are read fromchatCtx.itemsoutside the PII path. Lingua checks length only wheninputis present.UNCOMMITTED_SPEECH_TIMEOUT_MSwith no item and no end-of-turn in progress, ascommitted: false,complete, with no text.voice.context_message_idsstays optional, and Lingua warns when a call has messages but no LLM span carries ids.text_sourceis allowed withouttext, as the expected source.timing_sourcewas added to the LinguaVoiceMessage(newVoiceTimingSource).turn_span_idandsource_idsare intentionally not stored.responseId/messageId, the provider'sresponse.output_item.added.generated_textis that item's transcript only.missingwhen the next response after the one that heard it is created, or at session close, and a late arrival can still upgrade it.Checks
js/):vitest run …/message-audio.test.ts: 13 passed;tsc --noEmitontsconfig.jsonandtsconfig.test.json: passed;prettier --check: clean.make generate-types: passed;cargo test -p lingua --lib processing::trajectory: 96 passed;cargo test -p lingua --test trajectory: 2 passed;cargo clippy -p lingua --all-targets -- -D warnings: clean;cargo fmt: applied.git statusis clean in both worktrees.Independent re-run after these commits: SDK
vitest run src/instrumentation/livekit/voice-schema13 passed andtsc --noEmitpassed; fullcargo test -p linguain the Lingua worktree passed (1471 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-schema16 passed,tsc --noEmitandprettier --checkpassed; fullcargo test -p lingua1471 passed, 0 failed.Update 2026-10-12: tool-only speech and realtime turn timing
SpeechHandlefor each reply, including tool-call-only steps. A speech record with no generated text and no played audio is never written, which leaves a gap inseq; the examples now show gaps at 1 and 4.unplayed. Used only for speech that has generated text but played no audio:textis the generated text (tts_input), andselectionsis empty.committed. For assistant records, it means the record entered the agent's chat context. Cancelled speech that never entered the context iscommitted: false,unplayedorinterrupted. Zod enforces this and the new unplayed constraints.end_ms. It is theinput_speech_stoppedreceipt time minus the configured server-VADsilence_duration_mswhen known (provider_events), otherwise the receipt time.end_of_turn_ms. It is the entry of_realtimeGenerationTaskImplfor the generation that first includes the item. Without a known silence setting the two values can coincide.end_of_turn_ms3600. Turn 2 uses the same 500 ms silence, so the realtimereschedulecall happens at 9150 instead of the scenario's 8900; this deviation is noted in the README.transcript_final_ms. Defined as the arrival of the final STT result:user_input_transcribedwithisFinal, realtimeinput_audio_transcription_completed, or a finalized PipecatTranscriptionFrame.item_u2now hasend_of_turn_ms8900 andtranscript_final_ms8950, withc-llm3starting at 8960, consistent withitem_u1.realtime_inferencespan exported asllm_response. The uncommitted-speech open question notes that Pipecat has no end-of-turn-in-progress signal.seqgaps.Checks
js/):vitest run …/message-audio.test.ts: 15 passed.tsc --noEmitontsconfig.jsonandtsconfig.test.json: passed.prettier --check: clean.specs/voice-schema-message-audio.mdchanged, so I skipped generate-types, cargo test and clippy; they last passed at243922a0.git statusis clean in both worktrees.Update 2026-10-12: assistant spoken text sources
textis the text aligned to played audio, withtext_source: "playout_aligned". LiveKit takes it fromplaybackFinished.synchronizedTranscript. Pipecat takes it from theTTSTextFrames that passed the output transport before the interruption, which counts as aligned only with word-timestamped TTS.generated_textis set when it differs: the TTS input (cascaded) or the item's output transcript (realtime).textis the generated text, withtext_sourcetts_inputorrealtime_output_transcript, which may overstate what was heard.playout_aligned; no example shows the fallback.context_id→ the LLM span opened atLLMFullResponseStartFrame, which givesproduced_by_span_idandvoice.output_message_id.msg_<uuid>id, andcommitted: true, when the assistant aggregator appends the message.item_a1now hasseq2 and includescommitted,text_statusandtext_source.transcript_final_ms.SpeechHandlecreation (Pipecat:TTSStartedFrame).Checks
js/):tsc --noEmiton both configs: passed;243922a0.git statusis clean in both worktrees.Update 2026-10-12: validation notes
interruptedorunplayed,interruptedmessages withoutinterrupted_at_ms, andend_msearlier thanstart_ms. Zod still rejects all of these at the SDK.textwhiletext_statusis notnot_captured.textis omitted until playout ends, and partial spoken text is never written.text_statusstaysmissing(ornot_captured) until playout ends, when its final value is set.Checks
js/):tsc --noEmiton both configs: passed;git statusis clean in both worktrees.Lingua builder 2026-10-12
The Lingua mapping for this option is now implemented in braintrustdata/lingua#491. 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 third round.Final independent re-run: SDK
vitest run src/instrumentation/livekit/voice-schema16 passed,tsc --noEmitandprettier --checkpassed; fullcargo test -p lingua1474 passed, 0 failed.Update 2026-10-12: LiveKit 1.9.2 capture alignment
The LiveKit capture section now cites
@livekit/agents1.9.2src/file:line for each hook and event.input_speech_started/stoppedcarry no provider times (llm/realtime.ts:16), so bounds are local receipt times, with the newtiming_source: "local_receipt".provider_events) remain only for integrations that see raw provider events, such as Pipecat.item_rt_u1at 300–3400 anditem_rt_u2at 7600–9000.userTurnCompletedreturns early when server turn detection is on (agent_activity.ts:3302).end_of_turn_msis the entry of_realtimeGenerationTaskImpl.provider_audio_end_msnow means "truncate sent to the provider".agent_activity.ts:4789), and only whenmessageTruncationis on, so it equals the played position: the example now has 2900 with playout stopping at 7900.speechHandle.id, captured at reply-task entry, not by the turn-levellk.speech_id.produced_by_span_idis thellm_responsespan started in that handle's reply task.activity.currentSpeechat first frame, with the playback hook installed independently of audio capture._itemAdded(speech_handle.ts:741), which LiveKit calls just before_conversationItemAdded(agent_activity.ts:4272/4312/4826)._realtimeGenerationTaskImplentry the integration diffsrealtimeSession.chatCtx.itemsand pairs the oldest new user item with the oldest open user record. This gives the id andcommitted: trueeven without a transcript.lk.agent_task.instructions.inputis logged one message per chat item. This is a customer-visible change for realtime: no appended transcripts and no system message.llmNodeargs[0].VoiceTimingSourcegainsLocalReceipt, and the bindings are regenerated.provider_audio_end_ms2900).Checks
js/):tsc --noEmiton both configs: passed;make generate-types: passed;cargo test -p lingua: all passed, including 1450 library tests and the 3voice_message_audio_*fixtures;cargo clippy -p lingua --all-targets -- -D warnings: clean;cargo fmt: applied.provider_audio_end_msfails the test.git statusis clean in both worktrees, and nothing leaked into the sibling worktrees.Update 2026-10-12: realtime item sources pinned
RealtimeModel(@livekit/agents-plugin-openai1.9.2):conversation.item.added/created→handleConversationItemCreated(realtime/realtime_model.ts:1409) →remoteChatCtx.insert(:1697), and thechatCtxgetter returnsremoteChatCtx.toChatCtx()(:589). The user audio item therefore exists before any transcript, and the chat-context diff works.vad_<seq>, becomescommitted: truewhen a generation answers it, and is not listed in context ids orinput. This is documented as a known limitation: there is no step link._itemAddedwrapper accepts only assistant messages; function calls and outputs (agent_activity.ts:4168/4175/4874/4881) are ignored.:4759,:4826) gets one record permessageId, each with its own seq, and playout is attributed per forwarded message.:4805) gets no item and stayscommitted: falsewith statecompleteorinterrupted. This is expected.committedmay go fromfalsetotrueonce, for assistant records written during playout.generation.ts:500;InputSpeechStoppedEventis atllm/realtime.ts:17.provider_audio_end_msequals the played position except when a non-finite position is clamped to 0 (:4794).Checks
js/):tsc --noEmiton both configs: passed;cargo test -p lingua: all passed, including 1450 library tests;cargo clippy -p lingua --all-targets -- -D warnings: clean;cargo fmt: applied.git statusis clean in both worktrees.Base:
delner/livekit-voice-recording@ 2f184fa.Schema at a glance 2026-10-12
73d428f6reorders the README to the shared 14-section skeleton, with a new "Schema at a glance": the logged contract as TS row shapes (matches zod), time and ID rules, abridged cascaded/realtime excerpts checked value-for-value against the examples, and a cascaded-vs-realtime table. Implemented/Sketch is marked per section. Lingua835d8693: the spec gains a matching "Schema at a glance" (input keys, Trajectory output, recorded vs inferred).voice.output_message_idback-link exists only for committed messages (README and test said always); selection offsets are segment ms, not call ms; this option writes noaudio.selectionson turn spans;voice.origin_unix_ms"equals" the recording origin is now "written as", with the mismatch as the clock-offset case;produced_by_span_idis aspan_id. Lingua doc comments forgenerated_text,request_id,Speech.idandVoiceCallorder now match the builder.message-audio.test.ts16 passed;tsc --noEmit(both configs) passed; prettier clean (repo root). eslint skipped: the file is in eslint's ignore list.cargo fmt --checkok;make generate-typesregenerated onlySpeech.ts,VoiceCall.ts,VoiceMessage.tsdoc comments; clippy-D warningsok;cargo test -p lingua1474 passed, 0 failed (coordinator re-run).call-0000, the deterministic segment-id format already in the base branches' tests.Companion PR: braintrustdata/lingua#491
Other options: speech spans (#2611, braintrustdata/lingua#489); call timeline (#2612, braintrustdata/lingua#490)
Comparison note: shared with Kev in the originating Slack thread; it links all six drafts.
🤖 Generated with Claude Code