Repository navigation
feat: segmented speech-to-text on live calls, and a tool-call tone for voice agents - #18
Merged
Merged
Conversation
…rate The plan bundle (2026-10-04) goes under docs/segmented-stt/, as its chapters say. The new waav-segmented-stt crate holds the logic that needs no gateway type, so its tests run in seconds. data/stt_live_routing.json is assemble.py --routing output; tests/expected/ is resolve.py --expected-json output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…criber interface Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y filter A commit socket never gets a second request: a second commit would commit the next utterance's audio or an empty buffer the vendor rejects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One task per session: front end, detector, segmenter, the end-of-turn ladder, upload units with holding, joining and in-order release, and turn assembly. It emits interims carrying the turn so far and exactly one result that is final and end of turn; never a final that is not end of turn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The map module types every routing field of schema version 2 (closed vocabularies as enums) and embeds data/stt_live_routing.json, the output of assemble.py --routing. The file is force-added: the repository's generic data/ ignore rule would otherwise leave include_str! with nothing to read. The resolver is a port of resolve() in docs/segmented-stt/capability-map/ resolve.py. Tests hold it to the reference: the 66 self-test cases, the release tables in tests/expected/release-*.json, and 12,822 resolutions in tests/expected/grid.jsonl written by tests/gen_parity.py, compared field by field including refusal texts, labels, warning details and notes. One extension: ResolveRequest::adapter_built skips a transport whose adapter is not in the build exactly as a transport of a later release. The rollout module reads WAAV_SEGMENTED_STT, WAAV_SEGMENTED_STT_ALLOWLIST and WAAV_STT_LIVE_RELEASE and refuses malformed values by name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…0 refusal wording The routing map stripped provenance, so the resolver could never see a recorded live probe when evidence comes from the map; it now keeps verified_by and probe_ref. Six refusal texts (Speechmatics melia-1 and oak-1, four WaaV Infer rows) used wordings the operator-clause rule did not know, so covered sessions were still told to ask the operator; they now use the canonical clause. Fixtures and reports regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The HTTP side of the transcriber layer: one request per call, never a retry. - transcriber/http.rs: HttpSettings read from WAAV_STT_SEGMENT_* (a malformed value names its variable), UploadClients (public and trusted HTTP/1.1 pools, rustls, no redirects, an ALPN HTTP/2 twin only for listed hosts), host_key, and pool warming by concurrent HEAD requests. - transcriber/wire: Auth (sensitive headers, echoed keys redacted), language, Retry-After and request-id helpers, one exchange with a bounded body read, and the shared status classification (402 as Auth, a named refused field, model-not-found codes, WaaV Infer's envelope by code). - Families: OpenAI-compatible (OpenAI, Groq, Azure OpenAI, self-hosted, WaaV Infer), ElevenLabs, Deepgram prerecorded, AssemblyAI Sync, Azure fast transcription and Google Recognize, each built from a plain config struct. - A loopback mock vendor and one error contract stamped into every family. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…adapter, dispatch and turn signals The crate builds the transcriber, the engine profile, the limiter rate and the quality policy from the resolved row. In the gateway: SegmentedStt (the engine as a BaseSTT), the process-wide Silero, SmartTurn and text models, the voice manager's live factory with the speech-event dispatcher and speech-time admission, finalize_stt as a flush, the detector start strategy and the Aborted turn event, and the vad_event, stt_warning, coded error and ready.stt wire shapes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session setup resolves the STT leg once, before anything is built: today's client, the segmented engine, or a named refusal (today's exact bytes for the two codes Bud may match on). The contract module writes ready.stt, the config warnings, vad_event, stt_segment_failed and stt_unavailable. A segmented Bud leg bills uploaded seconds. bud-auth gains stt.segmented, stt.capability_override and expected_languages, and an optional max_endpointing_ms. The agent engine can speak a notice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Segmented speech-to-text runs the Silero detector on every live call on a file-only model, and SmartTurn decides when a caller's turn is over. Both downloaded on first use into the user cache directory, which the image never filled: a replica without network had no detector. waav-gateway init now prepares them too, under $CACHE_PATH/models, where both detectors look first (core::model_dir); without CACHE_PATH they use the user cache as before. The Dockerfile copies the new segmented-stt crate into the planner and builder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k with Silero v5 expects every 512-sample chunk with the previous chunk's last 64 samples in front of it (32 at 8 kHz), as the reference wrapper sends it. The gateway sent the bare chunk, and the model then scores real speech near zero: over three seconds of recorded speech the highest probability was 0.011, against 0.98 to 1.00 with the context. Found by the segmented speech-to-text end-to-end run, where no caller turn was ever detected; the same detector backs the turn-detection ensemble. A unit test pins the input construction; an ignored live test checks a real recording (WAAV_SILERO_SPEECH_WAV) is heard as speech. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s (addendum B5) The OpenAI and Groq clients drop both callbacks in disconnect, which finalize_stt uses to flush at audio_end, so every audio_end after the first delivered nothing. A Groq flush that failed also stopped finalize_stt before its reconnect, leaving the session disconnected, and kept the lost turn's audio to upload again in front of the next turn's. The voice manager now keeps the provider callbacks it registered and registers them again after the reconnect, reconnects even after a failed flush (still reporting it), and Groq drops a failed turn's audio. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l (addendum B6) whisper-1 is retired on 2027-02-26. An empty OpenAI model now becomes gpt-transcribe on every path: the live client, the REST file route, the batch builder and the /realtime handler's input transcription. gpt-transcribe takes candidate lists, so it gets languages[] instead of language and keywords[] instead of key terms folded into the prompt; verbose_json stays Whisper's, and timestamps asked of a GPT model are omitted with a warning. The plugin metadata lists the current models. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…upload pool - The session language vote (addendum B7): with no session language, two segments the vendor detected alike pin it for every later upload; a language the client names ends the vote, and a mixed call keeps detection after five. - The control record: waav:stt_live:control in Bud's Redis lists deployments and capability rows (or provider:*) switched off on a running gateway. Read at session start (refreshed every 15 s); a listed session resolves as uncovered. An unreadable record keeps the last one. - SSRF: the public upload pool resolves names through a resolver that drops private, loopback and metadata addresses, and a base a /ws client named is refused at plan time when it is one (IP literals skip resolution). A Bud deployment's base, or the operator's OPENAI_BASE_URL, is trusted and uses its own pool. WAAV_ALLOW_LOOPBACK_ENDPOINTS is the escape hatch, as on every other vendor path. - is_file_only, for the /v1/realtime refusal of a file-only deployment with no agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y surface
Voice agents (/ws):
- the caller's speech is admitted when it starts, by the agent's own rule;
detector barge-in on a task of its own (DetectorSpeechStart), text-confirmed
for shorter overlaps; backchannels and the agent's own words coming back
(echo) are dropped before the client sees them
- the lost-turn rule: a spoken notice twice, the third lost turn in a row ends
the call with stt_unavailable (repeated_loss)
- a manual commit flushes the engine and waits for the sealed turn's final
instead of a fixed 300 ms
Conversation loop: detector barge-in (finding 3); measured end to end, a reply
stops 0.44 s into the caller's speech instead of after the transcript.
/v1/realtime cascade:
- bud.session.stt and bud.session.warning (WAAV_REALTIME_BUD_SESSION_EVENTS)
- speech_started/speech_stopped/committed timed by the detector
- transcription deltas from each segment's text (finding 4); a rewritten text
sends no wrong delta, completed stays authoritative
- a turn closed without text closes the item (failed on a lost segment)
- server_error for the gateway's own stt codes; turning turn detection on for a
manual agent on a buffering model is refused (stt_live_unsupported); the
client's turn mode reaches the resolver
- a file-only deployment with no agent is refused at the handshake
(stt_live_unsupported, reason realtime_needs_agent)
GET /capabilities/stt and the voice_capability:{endpoint_id} publisher tell Bud
how each model and deployment is served on a call. The integration test
segmented_stt_ws drives a real socket against a mock file-only vendor; CI runs
it and a segmented-stt job (fmt, clippy, tests, routing-data parity).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ription socket (Release 4) openai_realtime_transcription is a SegmentTranscriber of kind Commit: one socket per session (GA transcription session, turn detection off), each utterance resampled to 24 kHz, appended and committed by the gateway's detector, the completed transcript of that item returned. It is the only route to OpenAI's and Azure OpenAI's live-only models (gpt-live-transcribe, gpt-realtime-whisper) on a call, and the low-latency tier's route to gpt-transcribe. The session is reconfigured only when the model, language or context changes (so the language vote reaches it); any error or a missed deadline closes the socket so a late answer can never be read as the next item's. A socket is opened only to the vendor's host, a Bud deployment's address (realtime_url from the capability override) or the operator's base, never to one a client named. The gateway builds it only with WAAV_STT_COMMIT_TRANSPORT=1, to be set per vendor after a live probe as the plan requires; off, the live-only models stay refused by name. The resolver's Commit outcome runs the engine with the socket as its transcriber, reported to clients as segmented. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dendum B8) cartesia_manual_finalize sends each utterance to Cartesia's streaming socket and ends it with finalize when the gateway's detector decides, returning the final transcripts before flush_done; Cartesia's own client ends an utterance more than once. One socket per session, a new one when the language is pinned, closed on any error. It changes a streaming session deliberately, so it takes two gates: the capability map must record a live probe for the row (the gateway resolves with map evidence), and the operator sets WAAV_STT_CARTESIA_MANUAL_FINALIZE=1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… fix) An interruptible reply spoken through speak_if_epoch never ended the window of the protected utterance before it: every chunk of the reply extended the greeting's non-interruptible window (and the playback pump tagged it uninterruptible), so a reply after a protected greeting could not be cleared. Detector barge-in on a segmented session cannot work with that. Now the protected audio keeps its window until it has played and nothing after it extends the window. WAAV_PER_UTTERANCE_INTERRUPTIBILITY: segmented (default: segmented sessions, where detector barge-in needs it), all (streaming sessions too, with a release note), off (today). A confirming test pins today's defect. Also the plan's SmartTurn confirming test: 20 ms packets never accumulate the 50 mel frames the continuous pipeline waits for, so its model never runs on live audio. Not fixed here, as the plan says; segmented sessions run SmartTurn on each finished segment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…vendor minimum in price - The setup probe (Release 2): a guessed row or a self-hosted server answers half a second of silence before the caller speaks. A model the vendor does not serve is refused (stt_live_unsupported, model_not_served), a refused credential as deployment_misconfigured, refused optional fields are left out and remembered (stt_fields_reduced); an outage lets the session go ahead. Cached per target and credential. WAAV_STT_SETUP_PROBE, default on. - Long-window budgets: each limiter key tracks the row's hourly and daily request and audio caps (Groq's audio hours); a spent window refuses at the gate with no vendor call. Three rate-limited losses in 30 s mark the key overloaded for a minute. At setup a session hears stt_capacity_low, or from Release 3 is refused with stt_overloaded. - stt_rate_limited once per session; stt_degraded after three lost turns in a row on a session without an agent. - The row's billing rule (minimum per request, increment) gives each outcome its billed seconds; from Release 5 a unit is priced at that, not the uploaded seconds. - A dropped frame advances no sample time (LiveKit and SIP rule), pinned by a test against a no-drop control. - Resolution tests no longer read the process-wide detector model state other tests change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Deepgram streaming session against the mock socket runs uncovered and covered (Release 2); both must equal the committed recording once ready.stt is set aside. Regenerate with WAAV_UPDATE_GOLDEN=1 after a deliberate change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both SDKs gain transcription_mode on the STT config, the vad_event and stt_warning messages, ready.stt, and the coded error (code, recoverable, details). The OpenAPI document is regenerated with those shapes, and the config drift guards cover the new field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… interims Release 5, fallback to a second vendor: a live call uses the deployment's fallback_models, the list the file route already walks. A unit lost for a vendor-side reason goes again to the next fallback while its deadline allows, and the session stays there. stt_fallback_engaged is sent once. Each fallback is resolved through the map, admitted against its own limits, billed by its row's rule at its own price, and metered as served_endpoint_id with fallback_from. Release 5, data region and retention: stt.data_region: eu and stt.data_retention: none reach Deepgram (EU host, mip_opt_out), ElevenLabs (EU residency host, enable_logging=false), AssemblyAI uploads (EU host) and OpenAI segmented uploads (eu.api.openai.com), on streaming, upload and segmented paths. A vendor that cannot carry a setting is refused with stt_data_setting_unavailable before any audio is sent. Release 6, re-decoded interims: self-hosted models are re-decoded while the caller speaks (interim_results: live). Fix: the setup-probe verdict and the breaker are keyed by the target's address, so two deployments behind one host on different paths no longer share them. Found end to end: a healthy deployment's cached verdict hid an outage at the other path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… use LimiterRegistry kept the spec a key's first session brought, so a deployment override (or a new capability map) never applied until the gateway restarted. Found on pde-ditto: an Azure OpenAI deployment kept the map's 3-requests-a-minute default after its override raised it to 600, and its uploads went on being refused at the gate. The registry now brings the limiter to each session's spec: the rate and burst from now on (tokens earned at the old rate kept up to the new burst), and concurrency slots added or retired. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With RUST_LOG unset the filter was "info", and ONNX Runtime logs every graph optimisation at INFO: about 500 lines each time a session loads its speech detector and end-of-turn models (3,187 lines in 10 minutes of test calls on pde-ditto). The default is now "info,ort=warn". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Dockerfile sets RUST_LOG=info, so the gateway's own default filter never applied in a container. Same value as the code default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The upload route checked the deployment's data settings after the passthrough that forwards an Azure OpenAI or self-hosted file whole, so those uploads were sent regardless. Found on pde-ditto: an Azure OpenAI deployment set to keep no audio was refused on a live call but served on /v1/audio/transcriptions. The check now runs before any branch sends the file. A self-hosted server at the deployment's own address carries retention as well as region on uploads, as it already did on live calls (it is the operator's own server). Azure OpenAI keeps audio by account setting, so no retention is still refused there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ound Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The config drift guards were failing against the gateway's own spec, and the TypeScript one could never fail for the envelope: - Python: the /ws config envelope's `agent` (spec 025 voice agents) was unreachable from the SDK. - TypeScript: the envelope check tested a hand-written list of keys instead of the spec, so `agent` and `alias` were invisible to it. It now reads the envelope from openapi.yaml, as the Python guard does. Both mutations (dropping `agent` or `textOnly` from the reach sets) now fail it. - TypeScript: the extractor's expected required sets were stale. Since /ws legs address Bud deployments (FRD-023 RT6), STT `provider` and the conversation loop's `base_url` are optional. The gateway refuses a `base_url` under the Bud control plane. Both SDKs gain VoiceAgentConfig (`id`, `version`, `text_only`, `variables`) on the session and the Talk pipeline. An agent session invents no speech legs and sends no model on them, which the gateway refuses (`agent_owns_legs`). `base_url`, and in TypeScript the STT and TTS `provider`, become optional and are sent only when set. Both guards now cover AgentWebSocketConfig. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a running tool's phrase ("One moment."), the agent plays a soft
pulse (360 ms every 1.6 s, about -24 dBFS, 440 Hz with its octave) until
the tool returns, in place of further filler phrases. The caller hears
that the agent is working instead of dead air.
- fillers.tool_call_sound, on by default; an entry written before it
existed reads as on.
- The tone is generated, not recorded, and written in the session's TTS
format (PCM16 or G.711). A compressed output cannot carry it, so that
session keeps its phrases.
- It waits for the phrase's audio to arrive and play out, and goes out
through the TTS egress: queued after speech, counted in the playout
estimate, cut by a barge-in. Only pulses are queued, not the quiet
between them, so an answer arriving between pulses is heard at once.
- The spoken ledger counts the tone as audio, not speech, so a reply
truncated after a tone keeps what was actually heard, and the speaking
rate is calibrated on speech only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live on pde-ditto the first pulse sometimes reached the client as the phrase was still playing, so it followed the last word with no break and sounded like part of the speech. A pulse now waits for 400 ms of quiet output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e tool Live on pde-ditto the caller heard no tone: the agent's MCP tool returned in 0.4-1.7 s while its model thought for 2-8 s before and after each call, so a tool was never still running when the tone was due (after the 1.2 s phrase and its pause). The caller's wait is the agent's work on the tool call, so the phrase and the tone now span it: from the first tool call until the agent speaks again. The phrase comes once per span even when the tool returned before it was due. With the tone off nothing changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The segmented speech-to-text work added ready.stt; the benchmark's initializer was not updated, so clippy --all-targets did not compile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gateway carries a deployment's stt.data_region in data_region, and the refusal check counts AssemblyAI's EU address as applied, but the sync adapter looked only at region. A data_region: eu deployment's audio went to sync.assemblyai.com instead of being sent to sync.eu or refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gateway's checked writer moves into the segmented speech-to-text crate and is the only one: segmented utterance uploads, the prerecorded and regional upload clients, the REST route, and the OpenAI and Groq clients, whose own copies (and their tests) are removed. The bytes are unchanged; the OpenAI and Groq clients now report the shared writer's error text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… path A new segmented_stt::vendor module holds what every speech-to-text upload path needs from a vendor's HTTP exchange, used by segmented sessions and the gateway alike: - hosts (Deepgram, ElevenLabs, AssemblyAI async and sync, OpenAI, Groq, with their EU hosts) and the keep-no-audio switches - Azure OpenAI's URL builder, default api-version and key header; the gateway's TTS module re-exports them under their old names - the request-id lookup, replacing four gateway copies - retry-after and compound-duration parsing, replacing Groq's own - one error-body parser; vendor_message and vendor_code are views of it Behaviour changes, all fixes: - a request-id header present but empty no longer stops the search (prerecorded clients and the vendor span); - Groq reads the reset durations it actually sends (2m59.56s, 7.66s) and hours; its parser returned nothing for them; - segmented sessions build Azure OpenAI URLs like the REST route: the deployment percent-encoded, '.'/'..' refused, the same default api-version (2025-04-01-preview, was 2025-03-01-preview); - an error body carrying both a validation list and an error message reads the validation list on every path, as the gateway did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
segmented_stt::vendor::openai holds the /audio/transcriptions response (json, verbose_json, diarized_json) and the error envelope, as OpenAI, Groq, Azure OpenAI and self-hosted servers send them. The OpenAI and Groq clients drop their near-identical copies and use it under their existing names; segmented sessions parse into it instead of walking raw JSON. Each keeps its own confidence formula (Groq's per-segment score is now the SegmentScore trait). The types are lenient: only `text` is required, so a self-hosted server's partial segment or word no longer fails the OpenAI and Groq clients' parse. Integers are i64 and Groq's `seek` is optional. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
segmented_stt::vendor::{deepgram, elevenlabs} read each vendor's
file-transcription answer once into a neutral record: every channel,
alternative, word, speaker, token, duration and request id. The
gateway's prerecorded client and segmented sessions each keep their own
rules on top of it, unchanged:
- the gateway: every channel, words with speakers, runner-up hypotheses,
ElevenLabs' own text;
- segmented sessions: channel 0's best transcript, ElevenLabs' text
rebuilt from word and spacing tokens, a logprob confidence, languages
normalised for the session vote.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shared in segmented_stt::vendor, used by the gateway and segmented sessions alike: - Deepgram's key-terms parameter by model (`keyterm` on Nova-3, `keywords` on older models, none on Whisper). The batch API sent `keyterm` to every model. - the OpenAI-shaped URL join. The OpenAI client posted to `/v1/v1/audio/...` when OPENAI_BASE_URL or an endpoint override ended in /v1, as the SDKs' bases do. - the Whisper prompt: the caller's prompt, then the key terms. A self-hosted deployment's REST upload no longer follows redirects. Its own in-cluster address is trusted; a host a redirect names is not, and the upload carries the deployment's credential. Segmented sessions' upload clients already followed none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The retry loop matched the error's text for "rate limit" or a bare status
number, but this client's own messages for a 429 ("Rate limit exceeded:")
and a 5xx ("Server error:") contain neither, so most transient failures
were not retried; 498 (flex-tier capacity) and 504 never were. The
attempt now records the status Groq answered, and 408, 429, 498 and 5xx
are retried, with network failures as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The batch API's OpenAI upload is named and typed as what it is (audio.mp3, audio/mpeg, ...); it was always audio.wav/audio/wav, and OpenAI reads the format from the name. The mapping is shared (vendor::openai::upload_file_name). - It no longer sends a source language to the translations route, which takes none, and its URL uses the shared join (no /v1/v1). - A segmented session for an Azure OpenAI deployment without a key is refused at setup, as the REST route refuses it, instead of sending an empty api-key header on every utterance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
segmented_stt::net::is_public_ip is now the only classifier: segmented sessions' upload pools, the gateway's core::net checks (vendor and webhook URLs, redirects) and utils::url_validation all use it, instead of three copies that disagreed at the edges. Stricter where they disagreed: core::net now also refuses IPv4 multicast (224/4) and the reserved 240/4; url_validation also refuses the current network 0/8 and IPv6 multicast. None of these is a vendor address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er strands it segmented_stt::breaker::Breaker is the one Closed/Open/HalfOpen state machine. Segmented sessions' upload breakers use it with permits; the gateway's CircuitBreaker keeps its API (streaming reconnects, the nine HTTP upload clients through HttpBreaker) and adds the credentials-FATAL state, the per-provider gauge and the presets on top of it. Behaviour changes for the gateway's breakers, all from the shared core: - A half-open probe that reports nothing (cancelled, or an early return after the check) is replaced after a lease (the cooldown, at least 15 s); it used to deny every caller until restart. - HttpBreaker abandons a half-open probe answered with the caller's own 400/404/413/422 (CircuitBreaker::record_neutral), so the next call probes; it used to strand the breaker half-open. - The window holds the last N outcomes exactly instead of approximating them by halving the counters. The shared core trips only on a failure, as the gateway's did: a success that fills the window to its minimum no longer opens a segmented session's upload breaker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
segmented_stt::resample::MonoResampler is the one wrapper around rubato's FFT resampler (chunk sizing, buffering, whole-chunk processing, the zero-padded flush). Segmented sessions' front end (to 16 kHz) and the gateway's StreamResampler (egress and the realtime facade) wrap it with their own policies, unchanged: the chunk ceiling (2048 / 1024), what an error does (drop the chunk / pass the audio through), and the gateway's stale-stream reset and rate rebuilds. The offline whole-file Downsampler in waav-openai-audio is left as it is: a different job (length-exact decode), in a crate that must not pull in the segmented crate's network dependencies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The batch API and the prerecorded client each turned a BatchHttpRequest into a reqwest request, and each uploaded to AssemblyAI's /v2/upload. batch::http_request and batch::assemblyai_upload_request/upload_url are now the one copy; each caller keeps its own tracing and error handling. The batch API gains the prerecorded client's Content-Type rule: a builder's Content-Type is not forwarded on a multipart or JSON body (reqwest sets the multipart boundary itself). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
clippy::needless_update on the simple response literals that already set both of its fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st a minute The shared Retry-After parsers passed any finite, non-negative number to Duration::from_secs_f64, which panics on overflow: `Retry-After: 1e30` from a vendor or a self-hosted endpoint killed the STT task. They now use try_from_secs_f64 and read such a value as no wait. Groq's retry slept whatever Retry-After asked, now including hours and HTTP dates; it is capped at 60 s, the limit the segmented rate gate puts on the same signal. The HTTP status that decides a retry now travels with the failed attempt (SendFailure) instead of a field on the client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ession G.711 output (mulaw/alaw) is played at 8 kHz, but the tone was made and labelled at the session's configured rate or 24 kHz, so on a telephony call each pulse lasted three times as long at a third of the pitch. It is now made at 8 kHz for G.711 whatever rate the session names. The pulse is made and encoded once per session (the VoiceManager's config never changes) instead of rebuilt every turn and re-encoded every pulse. With the tone on, a second tool in the same span of tool work no longer gets its own phrase over the pulses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When an utterance ended on a chunk boundary, StreamResampler::flush cleared its last-call stamp although the filter was not reset, so the stale clear never ran and the filter tail bled into the next utterance. Nothing held is nothing to do again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Forwarding the caller's content_type to the OpenAI batch upload made a value like `mp3` fail the request in Part::mime_str; such a type now goes as audio/wav, as these uploads always did. ElevenLabs batch files are named by their type, like OpenAI's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The blocked hostnames and the IP-literal spellings (bracketed IPv6, decimal IPv4) move into the crate's net module. The gateway's SSRF check and the segmented sessions' check of a client-named base both call it, so a client base can no longer name metadata.google.internal or a decimal private address, and a `.localhost` name is refused by both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…under its lock The transcriber's breaker module only renamed crate::breaker::Breaker to FileBreaker; it is gone and callers use the one name. The gateway's CircuitBreaker read the state, ran the operation and read the state again (three locks) to publish its gauge, and a transition between the reads could be missed or published out of order. The state machine now reports each change of state from inside the operation that made it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…os flags - Stable clippy 1.99 adds chunks_exact_to_as_chunks: four constant-size chunks_exact calls become as_chunks. - gitleaks read `pricing_key deepgram:nova-2-medical` (a public model id) and a test's made-up `sk-live-0123456789abcdef` as secrets. The allowlist takes Deepgram model ids and a sequential hex run, matched against the extracted secret only; realistic keys in the same shapes still trip the gate. - typos read `speaches`, an open-source Whisper server, as a misspelling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Summary
This is the gateway side of two voice features, plus one shared core for every speech-to-text upload path (section 3). The Bud side (budapp, budadmin, budplayground, chart) is BudEcosystem/bud-runtime#3147.
1. Segmented speech-to-text: file-only models on live calls
A transcription model that only accepts recorded files can now serve a live call, a voice agent or
/v1/realtime. The gateway cuts the caller's audio at each pause (Silero with SmartTurn), uploads each piece, and returns the text in order with turn signals.The full status, by release, is in
docs/segmented-stt/IMPLEMENTATION_STATUS.md. It covers where the code is, every switch, how each part was verified, and what is not built.segmented-stt.voice_capability:{id}record andGET /capabilities/stt.fallback_models(FRD-022), the same list uploads already use.stt.data_region: euandstt.data_retention: none.stt_data_setting_unavailablebefore any audio is sent.audio_end./wsclient could point uploads at a privatebase_url(SSRF).VoiceAgentConfig(agent=) for voice agents.base_urland the TypeScriptproviderfield are now optional.2. A gentle tone while a voice agent works on a tool call
When a voice agent calls a tool, the caller hears the tool's phrase ("One moment."). After that, instead of more phrases or silence, a soft pulse plays until the agent speaks again.
core/agent/tone.rs):pde-ditto, an agent's MCP tool returned in 0.4–1.7 s while its model thought for 2–8 s around each call. A tone tied to the tool's own run time never played.VoiceManager::play_if_epoch): through the TTS egress.fillers.tool_call_soundinbud-auth, on by default. An entry written before it existed reads as on.3. One shared core for every speech-to-text upload path
The gateway already had file-upload code for
/v1/audio/transcriptions, the batch API, and the OpenAI, Groq and prerecorded clients. Segmented sessions had re-implemented parts of it. Each concern now has one implementation in thesegmented-sttcrate, which the gateway already depends on.Each path keeps its own request fields and rules on top of the shared core. Live segments send minimal fields that can be dropped when a vendor refuses one. The REST route still forwards files and returns the vendor's body verbatim, so its output is unchanged.
wavvendorvendor::openaivendor::{deepgram, elevenlabs}netbreakerCircuitBreakerkeeps its APIresamplebatch::http_requestand the AssemblyAI uploadBugs fixed while consolidating, each with a test that fails without the fix:
stt.data_region: eusent segmented audio to the US host. This was a data-residency defect in this PR's own code.keytermto every Deepgram model (only Nova-3 reads it);audio.wav;/v1/v1when their base ended in/v1.2m59.56s).api-keyheader where REST refuses the deployment.Review fixes (after a full review of this PR, each with a test):
Retry-Aftertoo large for aDuration(1e30) panicked the shared parser; it is now read as no wait. Groq's retry waits at most 60 s.mp3) failed the OpenAI batch upload; it goes asaudio/wavagain. ElevenLabs batch files are named by type.metadata.google.internaland decimal private addresses, as gateway URLs already were.waav_circuit_breaker_stategauge cannot miss one. TheFileBreakeralias is gone.CI: four constant-size
chunks_exactcalls becameas_chunks(new clippy 1.99 lint). gitleaks allowlists Deepgram model ids (pricing_key deepgram:nova-2-medical) and a sequential-hex test key, matched on the extracted secret only. typos acceptsspeaches, an open-source Whisper server.Deploy notes
WAAV_SEGMENTED_STTdefaults toallowlistwith an empty list, so nothing is segmented until a deployment is listed or the switch is set toon.on;OnSilencestrategy.Testing
Run on this branch:
Notes on the two non-green lines:
clippy: the one lint is pre-existing. It is
manual_option_zipatcore/stt/iflytek/client.rs:556, a file this PR does not touch, and it fires only with this host's clippy 0.1.96. Theclippy (deny warnings)job onmainpasses.ready.sttwas added. That is fixed in 498f789.lib tests: the 4 failures are
core::onnxtests that needlibonnxruntime.so. This host does not have it; CI installs it through its native-deps step.Tone tests:
Live on
pde-ditto(overlay image). This predates section 3: the shared-core consolidation is covered by the suites above but has not been deployed there yet.scribe-v2on a voice agent over/v1/realtimeand/ws, about 1 s per sentence, including barge-in.mip_opt_outwere seen in the vendor URL./wswith 8 kHz μ-law: decoded pulse peak 1820, against 1817 on PCM16.response.cancelduring the tone: no audio afterwards.🤖 Generated with Claude Code