Skip to content

feat: segmented speech-to-text on live calls, and a tool-call tone for voice agents - #18

Merged
dittops merged 57 commits into
mainfrom
feat/segmented-stt
Oct 5, 2026
Merged

dittops merged 57 commits into
mainfrom
feat/segmented-stt

Conversation

@dittops

@dittops dittops commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

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.

  • Engine: new crate segmented-stt.
    • Front end, detectors, segmenter, end-of-turn ladder, sequencer.
    • Attempt loop, limiter, upload breaker and quality filter.
    • Long-window budgets with admission.
    • Setup probe and language vote.
  • Wire families: OpenAI-compatible, ElevenLabs, Deepgram, AssemblyAI, Azure fast, Google.
    • Two commit transports: OpenAI's realtime transcription socket and Cartesia's finalize. Both are behind switches until a live probe.
  • Capability map and resolver: every live session is resolved against the map.
    • The result is streaming, segmented, buffered or refused, with a named code.
    • Each transcription deployment gets a published voice_capability:{id} record and GET /capabilities/stt.
  • Fallback to a second vendor: the deployment's fallback_models (FRD-022), the same list uploads already use.
  • Data region and retention: stt.data_region: eu and stt.data_retention: none.
    • They reach the streaming socket, the upload client and the segmented engine.
    • When a vendor cannot honour them, the call or upload is refused with stt_data_setting_unavailable before any audio is sent.
  • The greeting fix: each utterance carries its own may-be-cut flag. A reply after a protected greeting can be interrupted again.
  • Defects found and fixed along the way (table in the status doc):
    • Silero v5 was missing its 64-sample context, so real speech scored at most 0.011.
    • The OpenAI and Groq clients lost their callbacks at the first audio_end.
    • A /ws client could point uploads at a private base_url (SSRF).
    • Setup-probe verdicts and breakers were shared across deployments on the same host.
    • A changed limit never reached a limiter already in use.
    • Uploads were sent before the data-settings check on the passthrough path.
    • ONNX Runtime logged about 500 INFO lines per session.
  • SDKs (TypeScript and Python):
    • Segmented speech-to-text options.
    • VoiceAgentConfig (agent=) for voice agents.
    • base_url and the TypeScript provider field are now optional.
    • The config drift guards read the envelope from the spec.

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.

  • The tone (core/agent/tone.rs):
    • A 360 ms pulse every 1.6 s: 440 Hz with its octave, eased in and out, about -24 dBFS peak.
    • Generated, not recorded, and written in the session's output format (PCM16 or G.711 μ-law/A-law).
    • A compressed output format cannot carry it, so those sessions keep their phrases.
  • When it plays: across the agent's work on a tool call, from the call until the agent speaks again.
    • On 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.
    • The phrase comes once per span of tool work, even when the tool returned before the phrase was due.
    • Each pulse waits for 400 ms of quiet output, so it is heard apart from the phrase.
  • How it is delivered (VoiceManager::play_if_epoch): through the TTS egress.
    • It queues after speech, counts in the playout estimate, and is cut by a barge-in.
    • Only the pulses are queued, never the quiet between them, so an answer is never held behind silence.
  • Truncation accounting: the spoken ledger counts the tone as sound, not speech. The heard part of a reply cut by a barge-in, and the session's speaking-rate calibration, are unaffected by it.
  • Switch: fillers.tool_call_sound in bud-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 the segmented-stt crate, 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.

Shared Replaces
wav four WAV writers (gateway, OpenAI client, Groq client, crate)
vendor four request-id lookups and two error-body parsers; vendor hosts and keep-no-audio switches spelled out in several places; two Azure OpenAI URL builders; Groq's own duration parser
vendor::openai the OpenAI and Groq clients' response structs, and the crate's hand-walked JSON
vendor::{deepgram, elevenlabs} two parsers per vendor (prerecorded client and segmented sessions)
net three private-address (SSRF) classifiers
breaker two circuit-breaker state machines; the gateway's CircuitBreaker keeps its API
resample two streaming resampler wrappers
batch::http_request and the AssemblyAI upload two copies each, in the batch API and the prerecorded client

Bugs fixed while consolidating, each with a test that fails without the fix:

  • An AssemblyAI deployment set to stt.data_region: eu sent segmented audio to the US host. This was a data-residency defect in this PR's own code.
  • A self-hosted deployment's REST upload followed redirects, with its credential, to any host.
  • The gateway breaker stayed half-open until restart when its probe was answered with the caller's 4xx, or never reported. This affected the nine HTTP upload clients and streaming reconnects.
  • Groq decided retries by matching text its own messages never contained, so most 429s and 5xx were not retried; 498 and 504 never were.
  • The batch API:
    • sent keyterm to every Deepgram model (only Nova-3 reads it);
    • labelled every OpenAI upload audio.wav;
    • sent a source language to the translations route.
  • The OpenAI client and the batch API posted to /v1/v1 when their base ended in /v1.
  • Groq could not parse the reset durations it actually sends (2m59.56s).
  • A present but empty request-id header stopped the lookup.
  • Live calls built Azure OpenAI URLs with the deployment unencoded, and with an older default api-version than REST. Segmented sessions also sent an empty api-key header where REST refuses the deployment.

Review fixes (after a full review of this PR, each with a test):

  • A Retry-After too large for a Duration (1e30) panicked the shared parser; it is now read as no wait. Groq's retry waits at most 60 s.
  • The tool-call tone was made at 24 kHz on mulaw/alaw output, which plays at 8 kHz, so pulses were three times too long. It is now made at 8 kHz for G.711, once per session. A second tool in the same span no longer speaks a phrase over the tone.
  • The gateway resampler's flush disarmed the stale-filter reset when an utterance ended on a chunk boundary.
  • A caller's content type that is not a media type (mp3) failed the OpenAI batch upload; it goes as audio/wav again. ElevenLabs batch files are named by type.
  • The blocked-hostname and IP-literal SSRF rules are now shared too: a client-named segmented base is refused for metadata.google.internal and decimal private addresses, as gateway URLs already were.
  • The breaker reports state changes from under its one lock, so the waav_circuit_breaker_state gauge cannot miss one. The FileBreaker alias is gone.

CI: four constant-size chunks_exact calls became as_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 accepts speaches, an open-source Whisper server.

Deploy notes

  • Segmented STT ships switched off. WAAV_SEGMENTED_STT defaults to allowlist with an empty list, so nothing is segmented until a deployment is listed or the switch is set to on.
  • The tone is on by default for every voice agent.
  • Either merge order is safe. Older readers ignore the new projection and config keys.
  • Not built (sign-off or live-key items, listed in the status doc):
    • pause and resume after a false interruption;
    • switching the default to on;
    • the seven analytics columns;
    • drift and live-probe CI jobs;
    • live LiveKit and SIP tests;
    • removing the OpenAI and Groq OnSilence strategy.
  • The shared core changes existing gateway paths:
    • Breakers: a lost or 4xx-answered half-open probe is replaced (after a lease of the cooldown, at least 15 s) instead of stranding the breaker. The window counts the last N outcomes exactly instead of halving counters.
    • SSRF: the gateway's checks now also refuse IPv4 multicast, the reserved 240/4 range and 0/8. None of these is a vendor address.
    • Groq: retries follow the HTTP status.
  • Secrets / IAM: no changes.

Testing

Run on this branch:

cargo fmt --all --check                                                                   # clean
cargo clippy --all-targets --features dag-routing,turn-ensemble,noise-filter,openapi     # see note
cargo test --lib --features dag-routing,turn-ensemble,noise-filter,openapi                # 7388 passed, 4 failed (see note)
cargo test --features dag-routing --test e2e_mock_tests --test load_test_with_mocks --test server_startup \
  --test ws_tests --test keystone_wire --test provider_keystone_completeness \
  --test segmented_stt_ws --test segmented_stt_golden                                     # all 8 binaries pass (63 tests)
cargo test --features openapi --test openapi_drift                                         # 3 passed
cd ../segmented-stt && cargo clippy --all-targets -- -D warnings && cargo test             # clean; 483 passed
cd ../bud-auth && cargo test                                                              # 176 + 29 passed

Notes on the two non-green lines:

  • clippy: the one lint is pre-existing. It is manual_option_zip at core/stt/iflytek/client.rs:556, a file this PR does not touch, and it fires only with this host's clippy 0.1.96. The clippy (deny warnings) job on main passes.

    • Running the check above found that the message benchmark no longer compiled after ready.stt was added. That is fixed in 498f789.
  • lib tests: the 4 failures are core::onnx tests that need libonnxruntime.so. This host does not have it; CI installs it through its native-deps step.

  • Tone tests:

    • Engine scenarios on a paused clock: phrase then tone; a quick tool followed by long thinking; the tone ending when the agent speaks; barge-in; tone off; an output that cannot carry the tone; text-only sessions.
    • Encoder and pulse unit tests.
    • Ledger accounting.
    • VoiceManager delivery and epoch tests.
    • Each tone test was shown to fail against a mutated engine.

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.

  • Segmented STT
    • scribe-v2 on a voice agent over /v1/realtime and /ws, about 1 s per sentence, including barge-in.
    • The EU host and mip_opt_out were seen in the vendor URL.
    • Data settings fail closed on calls and uploads.
  • Tool-call tone
    • Realtime PCM16: "One moment.", then 0.6 s of quiet, then 4 pulses, then the answer.
    • /ws with 8 kHz μ-law: decoded pulse peak 1820, against 1817 on PCM16.
    • Switch off: the phrases go on.
    • response.cancel during the tone: no audio afterwards.

🤖 Generated with Claude Code

dittops and others added 30 commits October 5, 2026 11:20
…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>
dittops and others added 27 commits October 5, 2026 17:46
…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>
@dittops
dittops merged commit 7bbdfe2 into main Oct 5, 2026
17 checks passed
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.

1 participant