Skip to content

feat(#83): outbound voice vertical slice — Harness text to TTS audio track - #125

Merged
madawei2699 merged 5 commits into
cf-sfufrom
codex/voice-tts-vertical-slice
Aug 26, 2026
Merged

feat(#83): outbound voice vertical slice — Harness text to TTS audio track#125
madawei2699 merged 5 commits into
cf-sfufrom
codex/voice-tts-vertical-slice

Conversation

@madawei2699

@madawei2699 madawei2699 commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Refs #83 — vertical slice only; this prerequisite does not implement the umbrella issue.

What this adds

This PR implements the local resident-Agent voice pipeline:

Harness response text → bounded sentence/chunk splitting → Doubao Speech Synthesis 2.0 → VoiceSpeaker ordering/backpressure/cancellation → OutboundVoiceSink.

The TTS provider is the real Doubao Speech Synthesis 2.0 integration:

  • V3 output-unidirectional HTTP streaming endpoint: POST /api/v3/tts/unidirectional
  • Latest console authentication: X-Api-Key
  • Reuses the same local DOUBAO_API_KEY as Doubao Streaming ASR 2.0
  • X-Api-Resource-Id: seed-tts-2.0
  • Default 2.0 speaker: zh_female_shuangkuaisisi_uranus_bigtts
  • Override: DOUBAO_TTS_VOICE
  • Requests PCM s16le, 24 kHz, mono and parses chunked response objects incrementally
  • Handles split/concatenated chunks, business errors, HTTP failures, truncation, idle timeout, cancellation, and credential redaction

The Doubao provider exposes both STT and TTS capabilities while keeping speech.stt.provider and speech.tts.provider as independent activation slots. speech setup doubao activates both capabilities from the same API key.

Local real-audio check

After building the runtime:

printf '%s' '<api-key>' | free4chat-agent speech setup doubao --stdin
free4chat-agent speech speak-tts --text "你好,世界。" --out /tmp/doubao-tts.pcm --wav

The command writes local PCM/WAV output and never prints the API key. Use a 2.0 speaker available to the account if overriding DOUBAO_TTS_VOICE.

Scope boundary

This PR deliberately does not change Worker/DO/MCP authorization or enable Agent SFU publishing. The output terminates at the injectable OutboundVoiceSink; room-audible delivery still requires a separate grant-gated publish boundary and real Pion/werift TrackLocal writer.

Verification

  • agent-runtime: type-check, lint, format check, build, pack check, and 185/185 tests pass
  • app: lint, type-check, and Cloudflare build pass
  • GitHub CI for this exact head is green
  • Commit author and committer are codex 267193182+codex@users.noreply.github.com

Follow-ups tracked under #83

  1. Grant-gated Agent audio publication and the real Pion/werift TrackLocal sink.
  2. Incremental Harness-output consumption where an adapter supports streaming updates.
  3. Full barge-in, reconnect-safe track reuse, and latency measurement.

…track

Smallest Universal Voice Agent slice (#83), entirely inside agent-runtime:
a resident Agent turns its local Harness response text into ordered
playable audio written to an injectable outbound track writer.

- speech/types.ts: TTS seam next to STT (StreamingTtsSession/Provider,
  TtsAudioChunk, descriptor createTtsProvider)
- voice/chunking.ts: pure incremental sentence/phrase chunker (CJK +
  latin terminators, decimal guard, clause-level emergency splits)
- voice/speaker.ts: VoiceSpeaker with strict FIFO ordering, bounded
  pending queue (backpressure at the sink write), epoch-based stale
  cancellation (newest turn wins; late provider frames never reach the
  sink), lazy single-sink lifecycle with dead-track recreation
- voice/providers/openaiCompatible.ts: reference BYOK adapter for any
  OpenAI-compatible /audio/speech endpoint returning raw PCM; injectable
  fetch, status-only HTTP errors, credential-redacted transport errors;
  registered in the production registry as "openai-compatible"
- voice/ttsProvider.ts: resolveConfiguredTtsProvider reusing the local
  BYOK credential store (FREE4CHAT_TTS_PROVIDER override)
- core/runtime.ts: optional createVoiceOutput wiring — cancel stale
  speech when a new addressed turn starts, speak after sendText, close
  on cleanup; voice failures never affect the text path

No Worker/DO/MCP changes: the RoomSession authorization boundary is
untouched and agent SFU sessions remain subscribe-only. Production
room-audible playback lands with the follow-up that extends the publish
boundary; until then the sink seam ships with deterministic fakes.

Tests (34 new): chunking determinism, ordering, backpressure,
cancellation/stale-frame filtering, truncation bound, sink lifecycle,
provider mapping without secret leakage, resolution matrix, and an
end-to-end runtime test driving Harness turns through chunker -> TTS ->
fake TrackLocal with mid-flight interruption.

Gates: agent-runtime build/lint/format/test (168 passing) + app
lint/type-check/build all green.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 95e07a10b5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread agent-runtime/src/speech/registry.ts Outdated
export function productionSpeechRegistry(): SpeechProviderRegistry {
const registry = new MutableSpeechProviderRegistry()
registry.register(doubaoSpeechProvider)
registry.register(openAiCompatibleSpeechProvider)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Store TTS selection separately from the STT provider

When an operator runs free4chat-agent speech setup openai-compatible, registering this TTS-only descriptor makes it available to the existing speech CLI, whose LocalSpeechStore.saveProvider() unconditionally writes the selected ID to speech.stt.provider. Subsequent createConfiguredSpeechTranscriber() calls therefore return null because this descriptor has no createSttProvider, disabling a previously configured Meeting Notes transcriber merely by setting up TTS. Persist a separate TTS selection or keep TTS-only providers out of the shared STT setup path.

Useful? React with 👍 / 👎.

const endpoint = `${this.config.baseUrl.replace(/\/+$/, "")}/audio/speech`
let response: Response
try {
response = await this.fetchImpl(endpoint, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Abort outstanding TTS requests when cancelling speech

If /audio/speech stalls, this fetch has neither an AbortSignal nor a timeout, and the session's close() is a no-op. VoiceSpeaker.cancel() cannot start the newer turn until the old async iterator unwinds, while VoiceSpeaker.close() waits on the same drain promise, so a hung provider can indefinitely block subsequent speech and prevent runtime cleanup from reaching leave/adapter/client shutdown. Track the active request with an AbortController and abort it from close().

Useful? React with 👍 / 👎.

})
} catch (error) {
if (this.stopped || myEpoch !== this.epoch) return
this.sinkBroken = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drop the rest of a turn after synthesis or sink failure

When synthesis or writeAudio() fails after the current chunk has been shifted, this catch reports turnFailed but leaves the remaining chunks in pending and keeps the same epoch. The drainPromise.finally() callback consequently starts another drain immediately, causing later sentences from the failed turn to be spoken and potentially emitting turnFinished after turnFailed. Clear the pending chunks or invalidate the epoch when failing a turn.

Useful? React with 👍 / 👎.


private async ensureSink(): Promise<OutboundVoiceSink> {
if (this.sink && !this.sinkBroken) return this.sink
this.sink = await this.createSink()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close the old sink before replacing it

After any drain error sets sinkBroken, the next call replaces this.sink without closing the previous sink. This leaks the old outbound track and also abandons a healthy sink when the error originated in TTS rather than writeAudio; repeated provider failures can therefore accumulate live track resources. Close and detach the old sink before creating its replacement, and only mark it broken for sink failures.

Useful? React with 👍 / 👎.

codex added 4 commits August 26, 2026 02:05
P1 from the exact-head review of PR #125: TTS resolution previously fell
back to config speech.stt.provider, and speech setup always wrote that
slot — so configuring a TTS-only provider could silently displace an
existing Doubao STT selection.

- storage: SpeechConfig gains speech.tts.provider; saveProvider accepts
  { slot: "stt" | "tts" } with the historical "stt" default so existing
  callers and config files behave exactly as before
- speech setup routes the slot by capability: stt-capable providers keep
  using the stt slot; TTS-only providers activate through the tts slot
- voice/ttsProvider.ts reads only FREE4CHAT_TTS_PROVIDER or
  speech.tts.provider (never the stt slot)
- openai-compatible provider/session gain a toJSON guard: TypeScript
  private fields are runtime-enumerable, so an accidental stringify of a
  resolved provider would otherwise emit the API key

Regression coverage: doubao STT + openai-compatible TTS both stay
selected/resolvable side by side; a tts-only `speech setup` run leaves
the existing stt selection, its credentials, and Doubao STT resolution
untouched; the stt slot alone never activates tts; provider/session
serialization never exposes the credential.

Gates: agent-runtime lint/format/build/pack + tests 172/172 green;
app lint/type-check/build green.
… interface

Replaces the placeholder openai-compatible provider with the real product
provider: 豆包语音合成模型 2.0 via the official V3 output-unidirectional
HTTP interface (POST /api/v3/tts/unidirectional, chunked NDJSON stream).

Protocol (per official docs and cross-checked OSS clients):
- X-Api-Key auth — the same console key family as Doubao ASR; no
  AppId/AccessToken; DOUBAO_API_KEY is reused for both capabilities
- X-Api-Resource-Id: seed-tts-2.0; speaker defaults to the 2.0 voice
  zh_female_shuangkuaisisi_uranus_bigtts, overridable with DOUBAO_TTS_VOICE
- request: user.uid + req_params{text, speaker,
  audio_params:{format:"pcm", sample_rate:24000}}
- response objects {code,message?,data?}: code 0 carries one base64 PCM
  chunk (raw s16le/24kHz/mono), code 20000000 terminates, anything else is
  an error; brace-aware incremental scanner tolerates newline-split or
  concatenated objects across network chunk boundaries

Robustness: connect timeout, per-chunk idle timeout, deterministic cancel
(abort raced against pending reads so close() always settles promptly),
truncated-stream failure (missing terminator fails loudly), empty-text
short-circuit, and key-redacted error surfaces on every path.

Selection/storage: saveProvider now activates a provider in every
capability slot it supports ({slots:[...]}, default ["stt"] unchanged);
`speech setup doubao` therefore powers speech.stt.provider AND
speech.tts.provider from one credential while slots stay independent.
The doubao descriptor now advertises stt+tts with createSttProvider and
createTtsProvider.

Local real-audio entry point (room SFU playback remains unwired):
  free4chat-agent speech speak-tts --text "..." --out out.pcm [--wav]
resolves the configured TTS provider exactly like the runtime wiring and
writes provider audio to a file without ever printing the key.

Removed: voice/providers/openaiCompatible* and their tests. Tests: new
doubaoTts protocol suite (headers/URL/body, boundary-crossing stream
parse, in-stream + HTTP errors, truncation, timeout, cancellation, key-leak
guards, WAV header) and resolver/CLI regressions proving one
DOUBAO_API_KEY resolves Doubao STT + Doubao TTS side by side.

Gates: agent-runtime lint/type-check/build/pack + tests 179/179 green;
app lint/type-check/build green (public docs updated).
…etion

P2 from the exact-head review of PR #125: classifyTtsStreamObject mapped
unparsable JSON and code-0 objects without audio data to a lenient shape
that DoubaoTtsSession.consumeObject treated as a normal stream end, so a
malformed or truncated response could silently complete spoken answers.

- only code 20000000 classifies as kind:end; balanced-but-unexpected
  objects and code-0 objects lacking non-empty data classify as invalid
- consumeObject maps invalid to a tts_invalid_stream_object protocol
  error instead of completion
- deterministic tests: malformed object fails, code-0-without-data fails,
  terminator-only stream completes normally, plus direct classifier
  coverage of every shape

Doubao TTS 2.0 V3 X-Api-Key / seed-tts-2.0 unidirectional implementation,
selection slots, CLI probe, and SFU boundary unchanged. Gates:
agent-runtime lint/type-check/build/pack + tests 183/183 green.
…tream

Live V3 verification against /api/v3/tts/unidirectional showed official
streams interleave code-0 frames carrying data:null plus a sentence
metadata object (phonemes/text/words) between audio chunks; the strict
classifier rejected them as invalid and failed real synthesis.

- code-0 frames with a plain-object sentence field are now classified as
  metadata and safely ignored while waiting for the terminator
- everything else stays fail-closed: malformed JSON, unexpected shapes,
  and code-0 objects without data AND without sentence metadata still
  raise tts_invalid_stream_object; only 20000000 ends a stream
- deterministic tests: full realistic stream (audio x2 + metadata +
  terminator) completes with both chunks intact; data:null without
  sentence metadata still fails closed; classifier covers the metadata
  shape directly

Verified live with the locally configured DOUBAO_API_KEY via
`speech speak-tts --wav`: 221288 PCM bytes written as a valid
RIFF/24kHz/mono WAV (key never printed). Gates: agent-runtime
lint/type-check/build/pack + tests 185/185 green.
@madawei2699
madawei2699 merged commit 3aa1a18 into cf-sfu Aug 26, 2026
6 checks passed
@madawei2699
madawei2699 deleted the codex/voice-tts-vertical-slice branch August 26, 2026 01:08
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