113 KiB
Audio Agent Engine — Updates
This file documents all notable changes to the audio-agent-engine project. Entries are written in English and ordered newest-first.
2026-10-21 — Feat: every HTTP verb for tools (list / get / add / update / delete / …)
Changed
methodnow accepts any HTTP verb (src/tools.rs), case-insensitive:GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS,TRACEand custom verbs (e.g.PURGE). Before this change anything other thanGETfell through toPOST, soPUT/PATCH/DELETEtools silently became POSTs.- Argument placement follows the verb. The arguments left after the URL
placeholders go to the query string for the read-style verbs
(
GET,HEAD,OPTIONS,TRACE,DELETE) and to the JSON body for every other verb (POST,PUT,PATCH, custom); body-style verbs always send a body ({}when nothing is left). This makes a CRUD surface expressible:GET /items?limit=10,GET /items/{id},POST /items,PATCH|PUT /items/{id},DELETE /items/{id},HEAD /items/{id}. - New helpers
http_method(),args_in_body()andplace_args()insrc/tools.rs;ToolRegistry::executenow builds the request throughClient::request(method, url)instead of separateget/postbranches. - An invalid verb is warned at load and, at execution time, returned to the
model as
Error: invalid HTTP method '<verb>' for tool '<name>'(never a panic, never a blocking failure).
Notes
DELETEis query-style: put the id in the URL (/items/{id}) and any extra argument ends up as a query parameter.- Tests: two new unit tests (
every_http_verb_is_accepted,args_placement_follows_the_verb) —cargo test --bin audio-agent-engine -- tools::→ 9 passed. - Docs updated: TOOL_CALLING.md (IT/EN/ES, per-verb argument table + full CRUD example set) and README.md.
2026-10-21 — Feat: runtime tool definitions — GET / POST /api/tools
Added
- New REST endpoint
POST /api/tools(src/tools_api.rs). The body is the same JSON array used byLLM_TOOLS_FILE; it installs a new set of tool definitions at runtime, without a restart (until now the file was only read once at startup). The definitions apply from the next turn on. GET /api/toolsreports the active definitions (in the OpenAItoolsshape they are advertised to the model with) plus their origin:sourceisfile,requestornone.- Errors:
400empty body / non-UTF-8 / invalid JSON / JSON array with no tool definition,503LLM disabled. Responses use the shared{ok, …}/{ok:false,error}shapes. - OpenAPI/Swagger UI: both handlers are documented (tag
tools, schemaToolsResponse) insrc/apidoc.rsand appear at/docs.
Changed
ToolRegistrycan be built from a string —ToolRegistry::from_json(src/tools.rs) is the new core builder;loadnow reads the file and delegates to it, so a body accepted by the endpoint is also valid as anLLM_TOOLS_FILE(and vice versa). Addedlen()/names().- The LLM client's registry is swappable —
LlmClient::toolsmoved fromOption<Arc<ToolRegistry>>to anArc<RwLock<…>>withset_tools(…)/tools_from_request()(src/llm.rs). The registry is re-read on every request and at the start of every turn (turn_manager.rs), so an override takes effect on the next turn while a session in flight is unaffected. - Docs: new TOOL_CALLING.md (IT/EN/ES operating guide) and a "Runtime definitions" subsection in README.md.
Notes
- The override lives in memory only: it never writes the tools file, and a
restart restores the
LLM_TOOLS_FILEdefinitions. When the endpoint is never called, the file definitions stay in use — that remains the default. - Verified on the running service:
GETbefore any POST →{"count":2,"source":"file"}; after a POST →{"count":1,"source":"request"}; invalid JSON and[]→400; restart → back to{"count":2,"source":"file"}.
2026-10-17 — Feat: POST /api/transcribe — Parakeet transcription of an uploaded audio file
Added
- New REST endpoint
POST /api/transcribe(src/transcribe.rs). The body is the raw audio file (any containerffmpegcan read: wav, mp3, m4a, ogg, flac…). The file is decoded to mono 16-bit PCM at 16 kHz (ffmpeg -ac 1 -ar 16000 -f s16le pipe:1) and the already-loaded Parakeet TDT model fromAppState::stttranscribes the whole take in a single inference on a blocking thread (the inference is CPU-bound and holds the ONNX session lock). Response:{"ok": true, "text": …, "duration_ms": …, "tokens": …}. - Errors:
400empty body,503STT model not loaded,502ffmpeg decode failed,500temp-file / inference failure. Uploads capped at 32 MB (same as/api/customvoice). - OpenAPI/Swagger UI: the endpoint is documented (tag
transcribe, schemaTranscribeResponse) insrc/apidoc.rsand appears at/docs.
Notes
- Whole-file, single transcript by design (no VAD segmentation) and no
langparameter: Parakeet TDT v3 is multilingual, the language is auto-detected. - No second ONNX environment is created — the model is shared with the live session (respects the one-environment-per-process constraint). The upload is spilled to a temp file (ffmpeg needs a seekable input) and removed afterwards.
- Verified end-to-end:
voice_profiles/solis.wav(14.9 s, Spanish) →duration_ms=14918,tokens=83, correct transcript; empty body →400.
2026-10-17 — Feat: OpenAPI document + Swagger UI at /docs
Added
- Swagger UI for the REST surface. The engine now serves an interactive
Swagger UI at
GET /docsbacked by an OpenAPI 3 document atGET /api-docs/openapi.json. The document is derived from the handlers withutoipa(utoipa = "5",axum_extras) and rendered byutoipa-swagger-uiv8 (axum 0.7). New modulesrc/apidoc.rsholds the#[derive(OpenApi)]document (ApiDoc) and the response schemas (PhrasesResponse,PhraseEntry,SetVoiceResponse,ClearResponse,ErrorResponse); the endpoints carry#[utoipa::path]annotations. - Documented endpoints:
GET /health,GET /api/customvoice/phrases,POST /api/customvoice(query paramslang/textvia#[derive(IntoParams)]onSetParams; rawapplication/octet-streambody),POST /api/customvoice/clear. WebSocket (/ws) and the HTML client pages are intentionally excluded (streaming / static assets).
Notes
- The
zipcrate is pinned to the 2.2.x line inCargo.toml: theutoipa-swagger-ui8.1 build script (which unpacks the embedded UI assets) targets the older zip 2.x API and does not compile against zip 2.3+/2.6. The rootaxumstays at 0.7.9 —utoipa-swagger-ui8.1 resolves to it, so no duplicate Axum is pulled in. - Swagger UI is only for the REST endpoints;
/docsrequires the same HTTP/HTTPS origin as the engine.
2026-10-17 — Feat: custom voice accepts an uploaded audio file + typed transcript
Added
- Upload source on the
/customvoicepage. A Source toggle now offers two ways to build the reference: Record now (the existing microphone flow, read a predefined phrase for ≥ 10 s) or Upload a file — pick any audio fileffmpegcan read (accept="audio/*": wav, mp3, m4a, ogg, flac…) and type (or paste) its transcript. In upload mode the transcript is required and gates the Use it button, since the typed text is what drives the clone. Both modes share the same Use it / Reset actions and preview player. textquery parameter onPOST /api/customvoice. The endpoint is nowPOST /api/customvoice?lang=<code>[&text=<transcript>]. A non-blanktextoverrides the language's predefined reading phrase for the transcript written to the sibling.txtand passed toset_reference; when absent or blank the predefined phrase is used as before (so a plain recording is unchanged). Backend:SetParamsgainedtext: Option<String>andset_custom_voiceresolves the transcript from it (src/customvoice.rs).
Notes
- No format constraints are imposed on the upload: the same ffmpeg probe used for recordings handles whatever container is sent. The 32 MB body cap already covers uncompressed WAVs.
- README updated (feature bullet, usage comment, Custom voice builder flow and
API/route tables,
textparameter).
2026-10-17 — Fix: short utterances ("uno", "due") dropped by the STT gate
Fixed
- The probability average now counts voiced windows only. The gate's
avg_probwas the mean of the raw VAD probability over every window from speech start to segment close — including theVAD_MIN_SILENCE_MS(700 ms) hangover that ends the utterance. For a long phrase that silence is a small fraction, but for a short word it is most of the segment: "uno" (~350 ms of voice + ~700 ms of silence) averaged 0.065–0.199 and was discarded astoo_soft, even though the user was plainly speaking. In the same session the long phrases passed at 0.32–0.60. The average is now taken only over windows whose probability is ≥VAD_NEG_THRESHOLD(src/vad/silero.rs), so the voiced portion scores its true value and short real words clear the gate while noise still does not.
Notes
VAD_MIN_AVG_PROBstays at0.25; its meaning is now stricter (it no longer benefits from the silence dilution), but genuine short words score well above it. README /.env/.env.example/src/vad/mod.rsdocs aligned.- Tune with
VAD_MIN_AVG_PROB: raise it if noisy segments become hallucinated turns, lower it if genuinely soft speech is still dropped.
2026-10-17 — Fix: too many utterances dropped by the STT gate
Fixed
VAD_MIN_AVG_PROBlowered0.45→0.25. Log analysis showed 62 drops, alltoo_soft(none for duration), still occurring after a restart. Accepted utterances measuredavg_prob0.47–0.76 while dropped ones measured 0.08–0.45 — including plainly real speech cut by a hair (2136 ms @ 0.428, 3576 ms @ 0.436). The gate sat almost on top of the per-frame speech threshold (VAD_THRESHOLD=0.5): a real utterance hovers around the threshold on its onset/release, so its average lands just under 0.45 and the segment is discarded. The0.25default leaves a proper margin — genuine soft speech (0.3–0.45) now passes, while clearly noisy segments (below 0.25) are still filtered. UpdateVAD_MIN_AVG_PROBin.env(default,.env.example,src/vad/mod.rsand README aligned).
Notes
- Tune with
VAD_MIN_AVG_PROB: lower it further if genuine speech is still dropped, raise it if noisy segments are turning into hallucinated turns.0.0disables the probability gate.
2026-10-17 — Fix: custom voice still louder than the default voice
Fixed
- Custom voice level pushed below the reference. Matching the shipped
reference at the integrated level was not enough: a measured take lands at
≈
-23.25 LUFSagainstsolis.wav's ≈-23.44 LUFS, i.e. the same loudness, yet it still sounded louder. The difference is dynamic range and peaks — the user take measures ≈6.2 LRA/-2.2 dBTPversus1.6 LRA/-7.8 dBTPfor the mastered reference, so its peaks sit ~5.5 dB hotter and read as "louder". The normalization target is now-26 LUFSwith a-5 dBTPceiling, which drops a take ~3 dB and pulls its peaks down to ≈-5 dBTPso a custom voice sits just under the default instead of on top of it. - Defaults changed:
CUSTOMVOICE_TARGET_LUFS-23→-26,CUSTOMVOICE_TRUE_PEAK-1.5→-5(code,.env,.env.example, README).
Notes
- Lower
CUSTOMVOICE_TARGET_LUFSfurther for a quieter custom voice; the value is applied at record time, so a take must be re-recorded (or the endpoint re-posted) for a change to take effect. - The custom voice lives only in memory (
TtsEngine::set_reference): a restart reverts the reference to the configuredTTS_REFERENCE_AUDIOvoice.
2026-10-17 — Fix: custom voice louder than the default voice
Fixed
- Loudness target aligned with the shipped reference. The custom-voice
chain normalized to
-18 LUFSwhilevoice_profiles/solis.wavmeasures ≈-23.4 LUFS, so a custom voice came out ~5.4 dB louder than the voice it replaced. The default is now-23(CUSTOMVOICE_TARGET_LUFS), matching the reference — swapping voices no longer changes how loud the agent sounds. - Mono downmix moved inside the filter chain.
-ac 1used to be applied at the output stage, i.e. after loudnorm: a stereo upload was measured as stereo and then folded to mono, landing the written file ~3 dB below the target.aformat=channel_layouts=mononow runs first, so loudnorm measures the signal that is actually written.
Notes
- Verified:
solis.wav(stereo, -23.4 LUFS) and a quiet mono take (-48.6 LUFS) both render to exactly -23.00 LUFS, mono 44.1 kHz.cargo test→ 25 passed.
2026-10-17 — Post-processing for the custom voice (level match + cleanup)
Added
- Post-processing chain for a recorded custom voice
(
src/customvoice.rs), applied before the reference is installed:highpass=f=80(rumble/DC), optionalafftdndenoise +deesser, leading/trailing silence trim (silenceremove+areverse) and a two-pass EBU R128loudnormwithlinear=true. - New env knobs:
CUSTOMVOICE_POSTPROCESS(defaulttrue),CUSTOMVOICE_DENOISE(defaulttrue),CUSTOMVOICE_TARGET_LUFS(default-18),CUSTOMVOICE_TRUE_PEAK(default-1.5).
Notes
- The loudness stage measures the cleaned signal first, then applies the result as a single constant gain — a per-frame (single-pass) norm is what makes takes jump in volume, which is the problem this fixes. Verified: a take at -48.6 LUFS and one at -17.5 LUFS both render to ≈ -18 LUFS with TP at -1.5 dBTP.
- Any stage failing (unreadable take, all-silence clip, filter quirk) degrades to the plain transcode with a warning — a take is never lost.
- Error handling verified: garbage input →
502with a clean message, unknown language →400.cargo test→ 25 passed.
2026-10-17 — Custom voice builder (/customvoice) + Mivocero side menu
Added
- Custom-voice builder page at
GET /customvoice(src/web/customvoice.html). It asks the user for a language, shows a predefined phrase to read (long enough for cloning, ~10 s), records it withMediaRecorderand installs the take as the active voice. The Stop button is disabled until 10 s have elapsed (timer + progress bar), the take auto-stops at 20 s, and it can be played back and discarded before use. - Backend endpoints (
src/customvoice.rs):GET /api/customvoice/phrases(reading phrases per language + the configured default),POST /api/customvoice?lang=<code>(raw recording body),POST /api/customvoice/clear. The body cap of the upload route is raised to 32 MB. - Side menu on
/mivocero(☰ drawer) linking to the chat and to the custom-voice page; the same drawer is on/customvoice.
Notes
- Phrases live server-side, so the reference transcript always matches the text
the user read.
POST /api/customvoicetranscodes the upload to mono 16-bit PCM WAV atOUTPUT_SAMPLE_RATE(44.1 kHz) withffmpeg, writes it next toTTS_REFERENCE_AUDIO(the directory the Fish Audio backend can read) with a sibling<name>.txt, and installs it viaTtsEngine::set_reference— live for new replies, no restart. POST /api/customvoice/clearrestores the reference configured at startup (TTS_REFERENCE_AUDIO) rather than dropping it entirely.- Verified: uploading an audio file returns
{"ok":true,…}and produces a 44.1 kHz/monopcm_s16leWAV with its.txt;clearlogs the restore ofsolis.wav. Tests:cargo test→ 25 passed.
2026-10-17 — Tool calling survives a mis-matched server-side tool parser
Fixed
- Tool calls dropped on the streaming path are recovered. The production
vLLM server runs
--tool-call-parser hermes, which expects the<tool_call>{"name":…,"arguments":{…}}</tool_call>JSON shape; the Qwen3 model emits the XML shape instead, so the parser throws and the call is discarded. In streaming the turn then ended with an empty reply (finish_reason: "stop") and notool_callever fired — the assistant either stayed silent or narrated a call it never made. - Non-streaming fallback in the turn loop
(
src/orchestrator/turn_manager.rs): a round that comes back empty while tools are configured is re-asked once without streaming via the newLlmClient::chat_reply(src/llm.rs), which returns both the visible text and the parsed tool calls. - Structured calls are parsed too —
tools::parse_openai_tool_calls(src/tools.rs) reads the standardmessage.tool_calls[]array, so the engine keeps working when the server parser does match (and returnsfinish_reason: "tool_calls"). The native<tool_call>text path is unchanged.
Notes
- End-to-end verified: "Che tempo fa a New York adesso?" now emits
tool_call {name: get_weather, arguments: {city: "New York"}}and answers with live wttr.in data. A tool-free turn is unchanged (no extra request). - The clean fix remains server-side —
--tool-call-parser qwen3_codermatches the Qwen3 XML format. The fallback makes the engine correct regardless, at the cost of one extra request only on an otherwise-empty round. - Tests:
cargo test→ 25 passed (2 new intools::tests).
2026-10-17 — Longer end-of-utterance window (VAD_MIN_SILENCE_MS 300 → 700 ms)
Changed
VAD_MIN_SILENCE_MSraised from300to700ms in.env/.env.example, and the fallback default insrc/vad/mod.rsaligned to700. This is the silence window the VAD waits for before closing an utterance (seeSileroVad::step,silence_run_ms >= min_silence_ms).- README — updated the config table and the
SpeechEndtrigger row.
Notes
- With
300ms a brief thinking pause (~0,3 s) closed the turn and sent a partial sentence to the STT/LLM.700ms tolerates a natural mid-sentence pause before the agent answers. The trade-off is a slightly later reply (the agent now waits ~0,7 s of silence instead of ~0,3 s), which the filler / start-filler cues already mask. - The backchannel micro-pause window is unaffected: its
pause_max_ms(260) is still below the new cap (min_silence_ms - 32 = 668).
2026-10-17 — Mivocero: barge-in indicator + karaoke on the user bubbles
Added
- Server
{"type":"barge_in","source":"vad","prob":…}event insrc/ws/session.rs— emitted when the server-side VAD onset interrupts the agent. The onset is detected in the session task, so the browser had no other way to know a barge-in had happened. - Barge-in indicator in
src/web/mivocero.html— a red Barge-in pill in the header that flashes and bumps a session counter. Driven by thebarge_inevent (VAD onset) and by the local Stop button; the counter resets on each connect.
Changed
- Karaoke on the user bubbles in
src/web/mivocero.html— the user's own messages (STT transcript and typed text) are now rendered word by word like the assistant's, with colours tuned for the blue bubble. Because the STT returns the whole transcript only once the utterance has ended, the words are lit by a short left-to-right sweep (USER_REVEAL_MS_PER_WORD, ~90 ms/word) instead of following the audio playhead. - README — documented the
barge_inevent in both the server→client table and the auditoutrow, and updated the Mivocero section (karaoke, barge-in indicator).
2026-10-17 — VAD barge-in gated on audible output (no more silent cancelled replies)
Added
AudioMixer::is_audible()insrc/audio/mixer.rs— reports whether the agent has audible output: the reply is playing out (tts_primed) or a latency-mask cue (filler / start filler) is queued. Unlikeis_active()it isfalsewhile the reply is still being synthesised or sits in the TTS pre-buffer — nothing has reached the user's ears yet.TurnManager::is_audible()insrc/orchestrator/turn_manager.rs— forwards the mixer method for the barge-in gate.
Changed
- VAD-driven barge-in gate in
src/ws/session.rs— aSpeechOnsetnow interrupts only whenTurnManager::is_audible()is true, instead ofis_speaking()(is_active()).is_speaking()is retained for the backchannel gate, where cueing over any in-flight TTS would still be wrong.
Notes
- Root cause:
is_speaking()is alreadytrueas soon as the TTS request is in flight (tts_stream_active), so a VAD onset landing during the synthesis / pre-buffer window (~1.2–1.6 s) cancelled the reply before a single sample was played. On the analysed session (s-1792251858371-0001, ~210 s, 21 turns) three replies streamed their text to the client but produced zero playback, and three more were truncated after 0.1–0.7 s; several of the onsets were false triggers (the utterance was later dropped by the gate withavg_prob0.28 / 0.21). - A reply cancelled this way is not orphaned: the next turn's
on_eoualready cancels the previous in-flight synthesis and restarts the stream, so the audio follows the newest turn instead of being lost.
2026-10-17 — Filler fade-out on reply start (no more overlap with the answer)
Changed
- Mixer filler fade-out in
src/audio/mixer.rs— when the reply's playback starts (TTS pre-buffer reached) while an end-of-turn filler or a start filler is still queued, the cue is now faded out overFILLER_FADEOUT_FRAMES(4 frames = 80 ms) and then cut, instead of being ducked tofiller_gain_duckand left playing underneath the whole answer. The filler is only a latency mask, so it now hands off cleanly to the reply.- New
AudioMixer::filler_fade_framescounter, armed on the priming transition and reset bymark_stream_start/flush.
- New
START_FILLER_GO_DELAYraised from1200to1800ms in.env/.env.example— the cue now only fires when the first audio chunk is genuinely late, instead of on the boundary where it used to overlap the beginning of the reply.
Notes
- Root cause: with
START_FILLER_GO_DELAY=1200the first TTS audio arrived 0.75–1.4 s after the cue, while thesf03clip is ~2.83 s long — so the cue played for its first second at full gain and its tail then overlapped the start of the answer. On the analysed session this happened on 5 of 6 start fillers (allsf03).
2026-10-17 — Utterance gate: filter spurious VAD turns before STT
Added
- Utterance gate in
src/ws/session.rs— a VAD segment is now dropped before transcription when it is too short or too "soft", so the STT can no longer turn a VAD false-trigger (breath, click, acoustic echo, noise) into a hallucinated text turn that would each otherwise cost a full LLM turn. Two independent thresholds, both env-driven:VAD_MIN_UTTERANCE_MS(VadSettings::min_utterance_ms, default300,0disables) — drop segments shorter than this.VAD_MIN_AVG_PROB(VadSettings::min_avg_prob, default0.45,0.0disables) — drop segments whose average speech probability is below this. A dropped segment is logged and recorded in the audit asutterance_dropped(duration_ms,avg_prob,too_short,too_soft).
- Empty-transcript guard in
src/orchestrator/turn_manager.rs::on_eou— a transcript that decodes to an empty string (or has no tokens at all) no longer opens an LLM turn.
Configuration
-
New
.envkeys next to the otherVAD_*options:VAD_MIN_UTTERANCE_MS=300 VAD_MIN_AVG_PROB=0.45
Notes
- The gate runs only when
VAD_SEGMENT_STT=true. Raise the probability to be stricter; lower the duration to keep very short backchannels ("sì", "ok"). - Documentation updated: README (VAD configuration table, VAD wiring section, audit-event table) and project memory.
2026-10-17 — Cue methods panel in the Mivocero client (live cue policy)
Added
cuesWebSocket event — sent once at session start bysrc/ws/session.rs, it reports the effective policy of the three cues for the active language: the filler (delay_ms,gain_idle,gain_duck, clip count, bank dir), the start filler (go_delay_ms, clip count, bank dir) and the backchannel (gain, min-speech, interval, jitter, pause window, probability, volume jitter, per-turn cap, categories, clip count). The backchannelpause_max_msis capped to the VAD EOU window exactly likeBackchannelScheduler::pause_max; the bank paths are the language subdirectories (assets/fillers/<lang>). Purely informational.- Cue methods panel in
src/web/mivocero.html— a collapsible<details>section under the cue tiles with one card per cue: the method used (when it fires, how a clip is chosen) and the live parameters from thecuesevent, so the panel always matches the running configuration instead of hard-coded values.
Notes
- The panel is display-only; it changes no behaviour. Values reflect the
process configuration at session start (env-derived), so a
.envchange is visible after the next session.
2026-10-17 — Per-session audit log (structured JSONL)
Added
src/audit.rs— a best-effort per-session audit sink. Each WebSocket session is recorded to its own JSONL file (SESSION_AUDIT_DIR/<id>.jsonl, ids-<epoch_ms>-<seq>), one record per line:{"ts":…,"elapsed_ms":…,"event":…,"data":…}. Records are flushed as they are written, and any failure (directory/file creation, write) only disables the sink for that session — the session itself is never affected.- Event coverage (complete):
session_start— full non-secret configuration snapshot plus the active runtime capabilities (STT / VAD / tokenizer / LLM + model);control— inbounduser_textturns;out— every event sent to the client, tapped at the single sender point insrc/ws/session.rsso nothing is missed (user,assistant_delta,assistant_end,tool_call,cue,error,audio_sourcestate, …);interrupt— client barge-in (JSON or legacy text form);barge_in— VAD-driven barge-in (with the onset probability);vad_speech_start/vad_speech_end— utterance boundaries;media/reference/speak— external-audio control, voice-reference changes and direct TTS requests;language_switch— detected language change (from,to,generated) —src/orchestrator/turn_manager.rs;error— transport/receiver errors;session_end— reason, duration, session id, file and per-event counts.
- Config —
SESSION_AUDIT_ENABLED(defaulttrue) andSESSION_AUDIT_DIR(defaultlogs/sessions); documented in.env,.env.exampleand the README.logs/is git-ignored.
Changed
TurnManager::newtakes anArc<SessionAudit>(used for the language-switch record); every other event reaches the log through the sender tap.
Notes
- The audit path is off the audio hot path (the sink only touches the outbound text events, not the 20 ms frames) and is disabled cleanly when the LLM/VAD/… are off. Intended for post-mortem analysis and for tuning the latency knobs.
2026-10-17 — Cue emission indicators in the Mivocero demo
Added
{"type":"cue"}WebSocket event — the server now tells the client each time a latency-mask / backchannel cue is actually emitted into the mixer:- filler (end-of-turn) —
src/orchestrator/turn_manager.rs(schedule_filler), - start_filler (turn start) —
src/orchestrator/turn_manager.rs(schedule_start_filler), - backchannel (during user speech) —
src/ws/session.rs(mic task). The payload is{"type":"cue","kind":…,"name":…}and, for backchannel, also"category":…. A cue that is skipped (audio already arrived / playing, or a barge-in) sends nothing.
- filler (end-of-turn) —
BackchannelScheduler::maybe_emitnow returns(name, category)instead of just the name, so the backchannel cue carries its category.- Mivocero cue indicators —
src/web/mivocero.htmlgained a row of three tiles (Filler, Start filler, Backchannel) that flash (colour-coded dot + border glow) and bump a per-session counter on everycueevent, with a detail line showing the backchannel category (or the clip name). Counters reset on each connect.
Notes
- Only real emissions are reported, so the panel is a faithful view of what the
user actually hears — useful for tuning
FILLER_DELAY_MS/START_FILLER_GO_DELAYand the backchannel cadence.
2026-10-17 — Per-language subdirectory for every clip bank (base included)
Changed
src/main.rs— the startup bootstrap now generates and loads the three base banks (filler, start-filler, backchannel) from<dir>/<LANG_DEFAULT>/viaassets::lang_dirinstead of the top-level directory. Every language — including the default one — now lives in its own subdirectory, so the filler, start-filler and backchannel share the exact same layout for every language and no language is special-cased to the top level.src/assets.rs—lang_dirdoc updated accordingly.
Notes
- On the first start after this change the base clips are re-synthesized into
assets/<bank>/<lang>/. To avoid the re-synthesis, move the existing top-level*.rawfiles (andtexts.txt) into the matching language subdirectory by hand — this was done for theesdeployment. - No other language's clips are touched: the per-language subdirectory remains
what isolates each language's refresh (
remove_stale_clips).
2026-10-17 — Multilingual clip banks (backchannel, filler & start filler)
Added
src/lang.rs— new multilingual module. It holds built-in phrase tables for backchannel (category + phrase), filler (interjections) and start filler (turn-start acknowledgments, with Drama-3 style tags) in Spanish, English, Italian, French, German and Portuguese, exposes accessors for them, and implements the auxiliary LLM calls:detect_language(returns only the ISO 639-1 code, orund) andgenerate_backchannel_texts/generate_filler_texts/generate_start_filler_texts(LLM fallback for languages absent from the tables). It also definesLangBanks(a language's loaded clip banks, including its start-filler bank) andLanguageState(the session's active language plus a generation counter, kept in a parking-lotRwLock).- On-demand per-language generation — when the user's language differs from
the active one, that language's backchannel/filler/start-filler clips are
synthesized into a per-language subdirectory (
assets/backchannel/<lang>/,assets/fillers/<lang>/,assets/start_filler/<lang>/) and the session switches to them. Because the refresh pass only prunes clips inside the directory it is given, no other language's clips are ever removed; the base language keeps living in the top-level directories, so no migration is needed. - New env vars —
LANG_DEFAULT(defaultes),LANG_DETECT_ENABLED(defaulttrue),LANG_MIN_CHARS(default12),LANG_REDETECT_EVERY(default5) andLANG_LLM_FALLBACK_ENABLED(defaulttrue).
Changed
src/llm.rs— newLlmClient::simple_chatfor short, non-streaming auxiliary requests (explicit system + user message, no tools), used by the language detection and phrase-generation calls.src/config.rs— five newLANG_*fields, parsed infrom_env.src/assets.rs—lang_dirhelper plusensure_lang_backchannel_clips/ensure_lang_filler_clips/ensure_lang_start_filler_clips, which scope the text-bank refresh to<base>/<lang>/so each language is refreshed independently.src/orchestrator/backchannel.rs— the clip/category mapping is extracted intomap_clips, andBackchannelScheduler::retargetre-points a live scheduler to a new bank when the language changes.DEFAULT_CATEGORYis nowpub(crate)solang.rscan reuse it.src/orchestrator/turn_manager.rs—TurnManagernow holds anArc<LanguageState>instead of a single filler bank;next_fillerand the start filler read from the active language (schedule_start_fillerpicks fromlangs.current().start_filler). A newmaybe_detect_languageruns on each user turn (first turn + everyLANG_REDETECT_EVERY) and spawnsswitch_language_if_needed, which detects, generates/loads the target language's banks (start filler optional: a missing table just leaves that cue silent) and swaps the active state — in the background, so the reply is never blocked.src/ws/session.rs— builds the sharedLanguageState; the mic task polls the language generation counter each frame and callsretargeton the backchannel scheduler only when it changes..env/.env.example— new "Multilingual clip banks" section;README.md— new "Multilingual clip banks" section.
Why
- The banks were pre-synthesized in a single language, so a user speaking any other language heard Spanish cues — including the start filler. Generating the matching language on demand keeps the acknowledgment natural in a multilingual deployment, while the per-language directory layout guarantees that adding a language never destroys the clips already generated for the others.
Notes
- Detection needs the LLM (
LANG_DETECT_ENABLEDis a no-op without it); the LLM phrase fallback is used only for languages outside the built-in tables. - Generating a new language's bank calls the TTS backend once per phrase, which can take a few seconds on the first switch; it is idempotent (an already-generated language is only reloaded).
2026-10-16 — Mivocero: auto-disconnect an idle session after 2 minutes
Added
- Idle guard in the Mivocero client (
src/web/mivocero.html) — the session closes itself afterIDLE_TIMEOUT_MS(2 minutes) without any activity.touchActivity()re-arms the timer and is called on every server event, on every received audio frame and on every user action sent (sendJson), so an ongoing reply or a playing external source keeps the session alive. On expiry it logs the reason, appends an explanatory message to the chat and callsdisconnect(), which closes the WebSocket and releases the microphone.
Changed
- Reconnect after close —
ws.onclosenow clears thewsreference. Without it the Connect button stayed bound to the stale socket, so a session closed by the idle guard (or by any disconnect) could not be reopened without reloading the page.
Why
- A microphone-backed session left open keeps the device busy and the server streaming silent 20 ms frames forever. An inactivity timeout releases both without user action, while still allowing a long reply or a background radio to run uninterrupted.
Notes
- The timer counts any traffic in either direction: TTS audio is sent as binary frames, so speaking does not trip the timeout.
- The timeout is a client-side constant (
IDLE_TIMEOUT_MSin the HTML); change it there and rebuild (the page is embedded withinclude_str!).
2026-10-16 — Tool calling: configurable HTTP tools for the LLM
Added
src/tools.rs— new tool-calling module. It loads tool definitions from a JSON file and provides: aToolRegistry(builds the OpenAItoolsarray, executes a call over HTTP), a client-side parser for the model's native<tool_call>format, and aToolCallStripperthat removes tool-call blocks from the streamed reply — holding back a block that is still incomplete, so it is never spoken by the TTS nor shown in the chat box even when split across deltas (same hold-back pattern as the chain-of-thought stripper).tools.example.json— ready-to-use definitions:get_weather(GET with a{city}URL placeholder, wttr.in) andsend_webhook(POST with a custom header), covering both request shapes.- New env vars —
LLM_TOOLS_ENABLED(auto: on whenLLM_TOOLS_FILEis set),LLM_TOOLS_FILE,LLM_TOOL_MAX_ITERATIONS(default4),LLM_TOOL_TIMEOUT_SECS(default15) andLLM_TOOL_RESULT_MAX_CHARS(default2000). ChatMessage::tool—role: "tool"message used to hand a tool result back to the model.- WebSocket protocol — informational
{"type":"tool_call","name":"…","arguments":{…}}event emitted when the model calls a tool.
Changed
LlmClientcarries an optionalArc<ToolRegistry>(src/llm.rs); when present, every request advertisestools+tool_choice:"auto", so the model knows which functions it may call. The registry is built byload_tools(config); a missing or invalid file logs a warning and leaves tool calling disabled without breaking the pipeline.TurnManager::run_llm_turnnow runs a bounded tool loop: each round streams a reply through theToolCallStripper; if a call was emitted, the assistant message (with its native call) plus onetoolmessage per result are appended and the model is re-prompted, up toLLM_TOOL_MAX_ITERATIONS. The TTS pipeline is created once and every round's visible text flows into it, so the assistant can say something short before calling. Only the spoken text is written to the rolling history.
Why
- The deployment's vLLM server runs without a tool parser
(
--enable-auto-tool-choice --tool-call-parser …), so it never returns structuredtool_calls/finish_reason:"tool_calls". Tools are still advertised in the request and the Qwen model emits the call as text in its native format; parsing it client-side keeps the feature working with any server and requires no server-side reconfiguration. - Tool definitions live in a JSON file rather than
.envso the verbose JSON schemas stay readable and editable.
Notes
- Tool errors (unknown tool, transport failure, non-2xx status) are returned to the model as text so it can recover; a tool never fails the turn.
GETsends arguments not consumed by a{arg}placeholder as query parameters;POSTsends them as a JSON body.headersare sent verbatim.
2026-10-16 — External audio channel: inject a URL / radio / YouTube source
Added
src/media.rs— newMediaSourcemodule implementing an external audio source for a WebSocket session. A source is decoded by an external process and its raw PCM is pushed into the mixer's new external ring buffer.ffmpegdecodes direct URLs / radio / HLS (-f s16le -acodec pcm_s16le -ac 1 -ar 44100 pipe:1, with-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5); YouTube links are first resolved to a direct URL withyt-dlp(-f bestaudio/best -g --no-playlist). Child processes are killed on stop / replacement / disconnection (kill_on_drop(true)+CancellationToken), and a monotonic generation counter prevents a stale task from clearing a newer source.- Mixer external channel —
AudioMixergainsexternal_queueand a runtime gain (src/audio/mixer.rs). The mixing formula becomes… + (external × external_gain);set_external_gainupdates the gain (an atomic, so it takes effect on the next frame without rebuilding the mixer). The external queue is not cleared byflush()and does not count towardsis_active(): a barge-in stops the agent, not the background audio. - New env vars —
EXTERNAL_AUDIO_GAIN(default0.6),MEDIA_FFMPEG_BIN(defaultffmpeg),MEDIA_YTDLP_BIN(defaultyt-dlp) andMEDIA_YTDLP_ARGS(extrayt-dlpargs, e.g.--cookies …). - WebSocket protocol — new
audio_sourcemessage:{"type":"audio_source","action":"play","url":"…"},{"type":"audio_source","action":"stop"}and{"type":"audio_source","action":"volume","gain":0.6}. The server reports state back with{"type":"audio_source","state":"loading|playing|stopped|error","url":"…"}(src/ws/session.rs). - Mivocero client — footer bar with a URL input, Play / Stop
buttons, a volume slider (0–150 %) and a live state label, driving the external
channel (
src/web/mivocero.html).
Changed
TurnManager::set_external_gainforwards the client volume to the mixer.- New module declared in
src/main.rs(mod media;).
Why
- Lets the agent play a music/radio/YouTube bed under the voice — useful for demos (radio, background music) — while keeping the channel fully independent of the speech pipeline so it survives barge-in.
Notes
- Only needed when the external channel is used:
ffmpegfor URL/radio/HLS andyt-dlpfor YouTube. YouTube may require cookies in some environments; pass them viaMEDIA_YTDLP_ARGS=--cookies /path/cookies.txt. - A bounded source (VOD) is throttled to the mixer's drain rate via backpressure
(
remaining_capacity()+ 20 ms sleeps), so a fast decoder cannot overflow the ring buffer.
2026-10-16 — Drama 3 de-emphasis: sampling knobs + neutral style tag
Added
TTS_TEMPERATURE(default0.45),TTS_TOP_P(default0.75) andTTS_TOP_K(unset) — optional sampling overrides forwarded in the/v1/audio/speechpayload by thefishengine. An empty value omits the field so the server default applies. Newenv_opt_f32/env_opt_u32helpers insrc/config.rsimplement the "unset → fallback, empty →None" semantics.TTS_STYLE_TAG(default[conversational, casual]) — a neutral style tag prepended to every synthesis request whose text does not already start with a[...]tag (FishAudioTts::styled). Empty disables the prefix.
Changed
SpeechRequestgains optionaltemperature,top_pandtop_kfields (serialized only when set); the request body now uses the styled text asinput. Startup logsTTS de-emphasis settings …, and the per-request debug log shows the sampling values and whether the text was prefixed (styled=).
Why
- Drama 3 over-articulates: LLM replies could come back with a theatrical,
radio-drama cadence. Exaggerated intonations live in the tails of the acoustic
token distribution, so trimming the sampling tails (
temperature/top_p) flattens the delivery, and a neutral style tag steers untagged (LLM) prose into a conversational register. Clip banks carry their own tags and are untouched.
Notes
repetition_penaltyis deliberately not exposed: this server rejects any value other than1.0(Drama 3 request: repetition_penalty must be 1.0).min_p,seedand a nestedsampling_paramsare rejected too.- The voice-cloning reference (
solis.wav) is left unchanged for now — its "locutor profesional" prosody is a further, separate lever. .envgotcha surfaced during this change.dotenvyfails on unquoted values containing spaces, and a single bad line makes it discard the whole file: the process then silently starts with built-in defaults (no LLM, plain HTTP) whileserver.shstill reports the scheme from its own.envgrep. The style tag is therefore quoted (TTS_STYLE_TAG="[conversational, casual]").
2026-10-16 — Backchannel: random reaction depth inside the micro-pause
Changed
BackchannelScheduler::maybe_emitnow draws the depth at which it reacts per pause: the first time a micro-pause is seen, a target is picked uniformly in[BACKCHANNEL_PAUSE_MIN_MS, BACKCHANNEL_PAUSE_MAX_MS]and the cue fires only oncesilence_ms()reaches it (newpause_target_msfield, cleared when the pause ends or the turn resets). Previously the cue fired at the first analysis window inside the window, so with a 32 ms VAD step every cue landed at exactlyBACKCHANNEL_PAUSE_MIN_MS.- A pause that grows past the upper bound (on its way to end-of-utterance) is abandoned without firing.
Why
- Verification on a live session showed every cue at
pause_ms=96, i.e. all cues reacting at exactly the minimum — a residual regularity in an otherwise naturalized feature. Randomizing the depth removes it: cues now land anywhere in the window (observedpause_msof96,128,192, …).
Notes
- No new environment variables: the behaviour is driven by the existing
BACKCHANNEL_PAUSE_MIN_MS/BACKCHANNEL_PAUSE_MAX_MSwindow. - Because the VAD advances in 32 ms windows, the emitted
pause_msis a multiple of 32 ms; the randomly drawn target is what varies.
2026-10-16 — Backchannel sounds more natural (micro-pause cues, jittered timing, categories)
Added
BACKCHANNEL_JITTER_MS(default1200),BACKCHANNEL_PAUSE_MIN_MS(default96),BACKCHANNEL_PAUSE_MAX_MS(default260),BACKCHANNEL_PROB(default0.65) andBACKCHANNEL_VOLUME_JITTER(default0.2) — new.envknobs (.env,.env.example,BackchannelSettings) that shape the cue timing, spacing, hit rate and loudness drift.- Category prefix in
BACKCHANNEL_TEXTS: a line may be writtencategory::phrase(e.g.ack::[calm] Mm-hmm...,agree::[agreeing] Claro...);parse_textsstores the category alongside the phrase and defaults toackwhen the prefix is absent. VadDetector::silence_ms— exposes the running silence inside the current utterance (multiples of the 32 ms analysis window), i.e. the length of the micro-pause the speaker is currently in.FillerBank::names— read accessor for the clip names, used to map the bank order onto the phrase/category list.
Changed
BackchannelScheduler::maybe_emitnow takes the VAD micro-pause length and fires a cue only inside aPAUSE_MIN_MS..PAUSE_MAX_MSwindow (the upper bound is capped just belowVAD_MIN_SILENCE_MSso it never collides with end-of-utterance), at most once per pause (latched withpause_settled).- Cue timing is randomized: the first cue of a turn waits
MIN_SPEECH_MS + rand(0..JITTER_MS), and the gap to the next cue isINTERVAL_MS ± JITTER_MS/2— instead of a fixed interval. - Each qualifying pause passes a probabilistic gate (
PROB); a miss does not retry on the same pause. - The clip is chosen with an anti-repetition rule (skips the last two clips)
and its samples are scaled by ±
VOLUME_JITTERbefore being pushed tobackchannel_queue. - Selection follows a category progression through the turn — it prefers a
category other than the last one used and favours categories not yet used this
turn (e.g.
ackthenagree), falling back to a random clip. BACKCHANNEL_MAX_PER_TURNdefault lowered 8 → 3, so the agent emits a couple of cues per turn rather than chattering.
Why
- A fixed interval with a fixed clip made the backchannel feel mechanical and detached from the speaker. Real listeners react to pauses, not to a metronome: cueing inside the speaker's own micro-pauses, with jittered spacing, an occasional deliberate skip, mild volume drift and a little progression (acknowledge → agree) reads as attentive listening instead of a loop.
Notes
- The naturalness knobs are all optional; omitting them keeps the previous
behaviour except for the
MAX_PER_TURNdefault (now3). - Nested clamps in
from_env:interval_ms >= 500,pause_max >= pause_min, andgain/prob/volume_jitterare clamped to sane ranges. - A debug line is logged only when a cue is actually emitted:
backchannel emitted during user speech … pause_ms=…(enableaudio_agent_engine::orchestrator=debuginRUST_LOG).
2026-10-16 — Backchannel becomes a random phrase bank driven by BACKCHANNEL_TEXTS
Added
BACKCHANNEL_TEXTS(.env,.env.example,BackchannelSettings): the backchannel bank is now a list of phrases, one per line (a|separator is also accepted), with Drama-3 style tags passed straight through to the TTS..envships 8 Spanish phrases ([calm] Mm-hmm...,[attentive] Ajá..., …); the Rust default isDEFAULT_BACKCHANNEL_TEXTS.assets::ensure_backchannel_clips, implemented on top of the new genericassets::ensure_text_bank_clips: synthesizes one clip per phrase at startup (bc01,bc02, …,TEXT_BANK_CONCURRENCY = 4at a time) and removes clips left over from a previous list. The list the current clips were made from is stored inBACKCHANNEL_DIR/texts.txt; when it is unchanged and every clip is present the whole synthesis is skipped, so a restart costs ~2 s instead of ~1 s per phrase. The index is only written once every clip succeeded, so a partial failure is retried at the next startup.
Changed
BackchannelScheduler::maybe_emitpicks the clip at random (FillerBank::random, xorshift64 seeded from the clock) instead of round-robin, so the acknowledgment varies from turn to turn — the same behaviour the start filler already had. Thenext_indexcursor was dropped.assets::ensure_start_filler_clipsis now a thin wrapper overensure_text_bank_clips, so the start filler and the backchannel share one implementation. Its logs gained alabelfield (refreshing backchannel clips at startup …).- The backchannel no longer uses the fixed
assets::BACKCHANNEL_CLIPSlist (removed); its defaults moved toDEFAULT_BACKCHANNEL_TEXTS.
Why
- A fixed 4-clip bank repeated the same "mm-hmm / ajá" over and over; a curated
list with tags keeps the agent sounding natural and is editable straight from
.env, exactly like the start filler. - The old bootstrap (
ensure_clips) only generated missing clips, so changing the phrases required deleting files by hand; the phrase list is now the source of truth.
Notes
- Clips are named by position, so switching from the old names (
mmhmm,aja,claro,yaveo) tobc01…removes the four legacy files on the first restart after this change (logged asremoved stale backchannel clip). BACKCHANNEL_GAIN,BACKCHANNEL_MIN_SPEECH_MS,BACKCHANNEL_INTERVAL_MSandBACKCHANNEL_MAX_PER_TURNare unchanged.
2026-10-16 — Fix: start filler fired on every turn (gate on chunk arrival, not playback)
Fixed
TurnManager::schedule_start_fillergated the cue onAudioMixer::tts_playback_started, which only flips once the TTS pre-buffer (TTS_PREBUFFER_MS) is full — i.e. when playback starts, ~1.9 s after the dispatch in the reference deployment. The first response chunk actually arrives at ~0.7–1.0 s, so aSTART_FILLER_GO_DELAYof 1200 ms was almost always shorter than the gate's own signal and the acknowledgment was queued on essentially every turn.- The gate now uses the new
AudioMixer::tts_audio_arrived, latched as soon as the first TTS samples are queued for the turn (checked on the 20 ms ticker), before the pre-buffer is reached. Bothtts_audio_arrivedandtts_playback_startedare reset bymark_stream_start/flush. A fast reply that produces audio withinSTART_FILLER_GO_DELAYtherefore suppresses the cue, as intended.
Changed
- The skip log line is now
first response audio already arrived — start filler skipped(wasresponse audio already playing — start filler skipped). - The end-of-turn filler is unchanged: it still keys off
tts_playback_started(its 2500 ms delay is long enough that playback start is the right signal).
Notes
- No configuration change;
START_FILLER_GO_DELAY=1200now means what the.envcomment always said ("first audio chunk of the reply"). Verified in the log: a text turn in which the first chunk reached the mixer ~0.4 s after the TTS request loggedfirst response audio already arrived — start filler skippedwith noqueued start fillerfor the turn.
2026-10-16 — Start filler becomes conditional: START_FILLER_GO_DELAY (1200 ms)
Changed
- The start filler is no longer played on every turn: it is now a latency
mask, scheduled exactly like the end-of-turn filler but on its own channel and
with its own, shorter delay.
TurnManager::schedule_start_filler(replacingplay_start_filler) sleepsSTART_FILLER_GO_DELAYafter the turn is dispatched and queues a random phrase only if the first response audio has not started playing (AudioMixer::tts_playback_startedstillfalse); otherwise the cue is dropped. (The gate was later changed totts_audio_arrived— see the entry above, which fixes this cue firing on every turn.) A pending cue is cancelled on barge-in / new turn through the turn'sCancellationToken, andmixer.flushclears it once queued. - The scheduling now happens in
run_llm_turn/dispatch_ttsnext toschedule_filler(where the turn's cancellation token andmark_stream_startlive) instead of at the top ofhandle_user_turn, because the delay must be measured from the dispatch of the reply and be cancellable with it.
Added
START_FILLER_GO_DELAY(.env,.env.example, default1200ms): how long to wait for the first response audio before falling back to the start filler.
Why
- The cue is only useful when the reply is late; a fast reply (LLM + TTS first chunk under 1200 ms) is now answered directly, without an unnecessary "Un momento…" in front of it.
Notes
- The 22-clip bank, the random pick and the startup refresh are unchanged.
2026-10-16 — Start filler: random phrase bank driven by START_FILLER_TEXTS
Added
START_FILLER_TEXTS(.env,.env.example,src/config.rs) replaces the singleSTART_FILLER_TEXT: a list of phrases, one per line (a|separator is also accepted), e.g. the Spanish set"[calm] Un momento...","[thoughtful] A ver, veamos...", … with Drama-3 style tags passed straight through to the TTS..envships 22 phrases.assets::ensure_start_filler_clips: synthesizes one clip per phrase at startup (sf01,sf02, …, generatedSTART_FILLER_CONCURRENCY = 4at a time) and removes clips left over from a previous list. The list the current clips were made from is stored inSTART_FILLER_DIR/texts.txt; when it is unchanged and every clip is present the whole synthesis is skipped, so a restart costs ~2 s instead of ~1 s per phrase. The index is only written once every clip succeeded, so a partial failure is retried at the next startup.FillerBank::random: uniform pick over the bank using a per-bank xorshift64 state seeded from the clock;TurnManager::play_start_fillernow uses it, so the acknowledgment differs from turn to turn instead of always repeating the same sentence. (Caller renamed toschedule_start_fillerby the entry above, which also made the cue conditional.)
Why
- A single fixed acknowledgment ("Un momento…") becomes annoying across a long
conversation; a random phrase from a curated list keeps the agent sounding
natural, and the phrase list is editable straight from
.env. - Re-synthesizing 22 clips on every startup added ~21 s to the boot time (measured), so the bootstrap now tracks the phrase list and only re-synthesizes when it changed (or a clip is missing).
Notes
forcewas dropped fromassets::ensure_clips: the filler / backchannel banks keep the "generate only if missing" behaviour, and the start filler has its own list-driven bootstrap.server.shstartup wait raised from ~20 s to ~60 s: the first startup after a list change synthesizes the whole bank before the socket is bound.- A failed synthesis keeps the previous clip of that position; a barge-in still
clears the start-filler channel (
AudioMixer::flush), andSTART_FILLER_ENABLED=falsedisables it entirely.
2026-10-16 — Start filler: immediate turn-start acknowledgment on its own mixer channel
Added
START_FILLER_ENABLED/START_FILLER_TEXT/START_FILLER_DIR(.env,.env.example,src/config.rs). The start filler is a pre-generated clip queued the moment a user turn begins — no delay — on a dedicated mixer channel, so it acknowledges the turn while the LLM/TTS are still working, independently of the delayed latency filler. (Superseded by the entry above:START_FILLER_TEXTbecame theSTART_FILLER_TEXTSlist and the clip is now picked at random.)AudioMixer: newstart_filler_queuechannel, mixed asmixed = tts + ((filler + start_filler) × filler_gain) + (backchannel × BACKCHANNEL_GAIN), so the start filler shares the filler gain (idle1.0, ducked0.15under TTS). Included inis_active()and cleared byflush().TurnManager::play_start_filler, called fromhandle_user_turnright after the in-flight reply is cancelled.- Startup bootstrap synthesizes
START_FILLER_DIR/start_filler.rawwith the configured voice if it is missing (src/assets.rs); the clip is preloaded into its ownFillerBank, kept out of the round-robinFILLERS_DIRbank.
Why
- The delayed latency filler only fires after
FILLER_DELAY_MS(and only if the reply is still silent), so with the current fast TTFB (~0.73 s) the agent can stay silent for a moment after the user stops speaking. The start filler provides an immediate acknowledgment instead, without touching the existing delayed filler.
Notes
- (Superseded — see the entry above.) The clip was re-synthesized on every
startup (
ensure_clips(..., force = true)), so editingSTART_FILLER_TEXTtook effect at the next restart with no manual deletion. A new clip was written only on success, so a failed synthesis left the previous clip in place; the filler / backchannel banks kept the "generate only if missing" behaviour. - A barge-in clears the start filler along with every other channel
(
AudioMixer::flush);START_FILLER_ENABLED=falsedisables it entirely.
2026-10-16 — Fixed micro-gaps at the start of each sentence → TTS_PREBUFFER_MS 170 → 550 ms, LLM_STREAM_CHUNK_CHARS 120 → 240
Changed
TTS_PREBUFFER_MSraised170→550(.env,.env.example). With the current vocoder windows (first185.8ms, steady371.6ms) any pre-buffer in(186, 557]ms primes playback on the same second window, so550 mstakes the entire557 ms cushion at no extra TTFA (0.73 s).LLM_STREAM_CHUNK_CHARSraised120→240(.env,.env.example). The first-chunk threshold stays at20(early flush preserved), but every following chunk is now twice as long → fewer TTS requests, hence fewer chunk seams.- README (
Mixer & jitter buffering, theTTS_PREBUFFER_MS/LLM_STREAM_CHUNK_CHARStables and the TTFA note) updated to the new defaults and rationale. No Rust code changed — both values are read from.envat runtime.
Why
- After the seam-quality retune left the audible micro-interruptions at the
start of sentences. Diagnosis from
run/server.log: the defect is a TTS queue underrun at the chunk seams, not a timbre artifact. Each sentence / fragment is a separate HTTP request whose PCM is concatenated with no crossfade; the relay advances chunk-by-chunk, so the first dip in production empties the queue and the mixer logsTTS queue underrun — pausing playback until audio resumes(94 occurrences in the sampled log; 0running lowwarnings), then restarts withneed = 1→ an audible micro-gap at the start of the next sentence. - Why the queue empties: with
TTS_PIPELINE_DEPTH = 3on a single GPU the measured per-window cadence under concurrency is 552–613 ms (~1.5×) versus ~350 ms (~0.94×) for a lone request. A170 mscushion has no margin against that;550 msdoes, and it is latency-free because the pre-buffer already lands on the second window. - Measured in the log at the seam (12:56:36–38): chunks of
16384samples (queue_len=16384,prebuffer=7497= 170 ms) with two concurrent requests (text_len=119and19), each followed by an underrun-pause line.
Notes
- Both levers are pure configuration; the binary is unchanged, so
./server.sh restart(no rebuild) is enough to apply them. - The
550 mscushion is the ~557 ms gap to the third window start, so raising it further only adds latency (it would wait for the next window).
2026-10-16 — Drama-3 vocoder retuned for seam quality → stride 4 / followup 8 / overlap 16; TTS_PREBUFFER_MS 450 → 170 ms
Changed
- The Drama-3 launcher (
/root/drama3/start-drama3.sh, pm2start-drama3) now carries more decoder context per window. The previous config (stride4, flat ramp"[4,4,4]", overlap8, crossfade256) was replaced by:--vocoder.factory.stream_stride 4 --vocoder.factory.stream_followup_stride_ramp "[8,8,8]" --vocoder.factory.stream_followup_stride 8 --vocoder.factory.stream_overlap_tokens 16 --vocoder.factory.stream_crossfade_samples 512 --tts_engine.factory.enable_async_decode true TTS_PREBUFFER_MSlowered450→170(.env,.env.example,src/config.rs). With stride4the first vocoder window is 4 codec frames = 0.186 s, so170 msstarts playback on that first window and TTFA collapses to the server's own TTFB (~0.38 s).- README: the "Starting the Drama 3 backend (SGLang-Omni)" subsection and the "Mixer & jitter buffering" pre-buffer note were rewritten around the new stride/overlap values and the ~0.186 s first / ~0.372 s steady window cadence.
Why
- The previous latency-first config (stride
4, overlap8) produced audible micro-gaps and a slight detune / falsetto on long replies. Root cause: with only8tokens of decoder warm-up context per window, and one TTS request per reply chunk (PCM concatenated without crossfade at the request seams), the streaming decoder's state was too shallow and its timbre/pitch wobbled at each window and request boundary. - Seam context restored without breaking real-time: raising the overlap to
16at stride4pushes decode to 5× real time, which the box cannot sustain (ratio rose to 1.18–1.98×). Raising strides and overlap together — stride4(first window) / followup8(steady) with overlap16— keeps the decode load at a sustainable(16+8)/8 = 3×(~0.95× ratio) while giving each steady window twice the context, andcrossfade 512smooths the seams further. - A/B measurements on the live endpoint (
measure_tts.py, warm server, voice cloning, single request vs sentence-chunked like the engine) on a long multi-sentence reply: the amplitude jump at the seam boundaries dropped from the 99.5+ percentile of the signal to 96–98, i.e. the seams no longer stand out. TTFB stays ~0.38 s (the first window is unchanged) and the real-time ratio stays ~0.94–0.98×.
Notes
- The first window is unchanged (4 frames), so the residual latency is still
dominated by the server's fixed ~0.38 s TTFB; the steady window grew from
0.186 s to 0.372 s (8 frames) and arrives ~350 ms apart, so the pre-buffer
of
170 msstill starts playback on the very first window. Further latency gains would need a faster first token (model / preprocessing), not a smaller stride. measure_tts.pywas added to the repo: it streams/v1/audio/speechand prints the per-window arrival schedule, TTFB and real-time ratio (plus a seam-jump summary), so future A/B runs are one command.
2026-10-16 — Mivocero: karaoke word highlighting on the assistant reply
Changed
- The
/mivoceroclient now reveals the assistant reply in karaoke style (src/web/mivocero.html) instead of appending plain text. Each word is a<span>; words light up as they are spoken. The highlight is driven by the audio playhead: the playback AudioWorklet now counts the samples it has actually emitted and reports them (played) on its existing per-block message. The client maps that count onto a character offset with an estimated speaking rate (KARAOKE_CPS, ~15 chars/s) and marks each worddone/now/ pending. - The old blinking-caret "pending" style was removed; the whole reply is shown at once (dim) and progressively illuminated.
- A safety net finishes the highlight: once
assistant_endhas been received and playback has been idle for 600 ms after it had actually started, any word still pending is marked done, so a reply always ends fully lit. New turns and interrupts cancel the loop.
Notes
- The mapping from audio to text is inherently approximate (the server streams
text and PCM on the same socket without per-word timing), so
KARAOKE_CPSis a tunable estimate for the visual effect only — it never touches the audio. playedcounts only real samples, so the playback jitter buffer's silence and any underrun pauses do not advance the highlight.
2026-10-16 — End-to-end validation → TTS_PREBUFFER_MS 700 → 450 ms; Drama-3 startup documented
Changed
TTS_PREBUFFER_MSlowered700→450(.env,.env.example,src/config.rs). With the vocoder ramp"[10,12,16]"the first window is 10 codec frames = 0.464 s, so450 mssits just below it and playback now starts on that first window instead of waiting for the second one that700 msrequired.- README: new "Starting the Drama 3 backend (SGLang-Omni)" subsection under
the
fishplugin, with the exactsgl-omni servecommand, the pm2 workflow, the meaning of the two--vocoder.factory.*flags and the two CLI gotchas (nostages.prefix; the ramp tuple passed as a JSON string).
Why
- An end-to-end browser test (
RUST_LOG=…,audio_agent_engine::tts=debug,…) on the live engine confirmed the narrower windows: bursts of 10/12/16 frames (0.464 / 0.557 / 0.743 s) instead of a single 2.97 s block,prebuffer=30870samples (= 700 ms) and time-to-first-audio ≈ 1.1 s from dispatch (~0.64 s of which is the server's TTFB). Barge-in fired correctly on three interrupts. - Three tail underruns were still observed at the end of short replies: e.g.
2.46 s of audio produced in ~2.6 s (≈1.07× real time including the fixed
~0.64 s TTFB), so the queue drained just before the last words. The mixer
recovered via resilient resume, but the gap is audible.
450 mstargets the first 0.464 s window and trades a little more tail risk for a markedly earlier start.
Notes
- The residual deficit is dominated by the server's fixed ~0.64 s time-to-first-byte, which no pre-buffer value can remove. Going lower would need a faster first token (model / preprocessing), not a smaller stride.
2026-10-16 — Finer TTS delivery granularity (Drama-3 vocoder window) → TTS_PREBUFFER_MS 700 ms
Changed
- The SGLang Drama-3 server now emits audio in ~0.74 s windows instead of
~2.97 s bursts. Two flags were added to the server launcher
(
/root/drama3/start-drama3.sh, managed bypm2asstart-drama3):--vocoder.factory.stream_followup_stride 16and--vocoder.factory.stream_followup_stride_ramp "[10,12,16]"— upstream defaults were64and(10,12,32). TTS_PREBUFFER_MSdefault lowered900→700(.env,.env.example,src/config.rs). 700 ms is deliberately just under one 0.743 s window, so playback starts on the first burst rather than waiting for a second one.
Why
-
Drama-3's streaming granularity is a vocoder setting, not an engine one:
Drama3VocoderFactoryArgsbuffersstream_followup_stridecodec frames before decoding and emitting, andframe_length = hop_length * 4 = 2048samples (46.4 ms at 44.1 kHz). The upstream64therefore means a 131 072-sample burst every ~2.7 s. -
Measured against the live endpoint (single, uncontended request, PCM stream):
burst period ratio first audio before ( stride64)131 072 samples / 2.97 s 2.70–2.78 s 1.08× 0.96 s after ( stride16)32 768 samples / 0.743 s 0.69 s (σ ≈ 0.01 s) 1.04× 1.34 s -
The finer window is what makes the small pre-buffer viable: the producer still runs slightly faster than real time (1.04×), but it now tops the queue up every 0.69 s instead of every ~2.7 s, so arrival jitter is bounded by one window and no longer forces a multi-second buffer.
-
Prefix caching (RadixAttention) was evaluated and rejected: the client already sends a constant prefix (
model/voice/references), yet an A/B on the endpoint showed no TTFB difference (ref 0.62–0.64 s, no-ref 0.52 s) — that start-up is decode/vocoder, not prompt prefill.
Notes
- Each window re-decodes
stream_overlap_tokens(32) frames of context, so a smaller stride costs more vocoder compute: 96 frames decoded per 64 emitted becomes 48 per 16. The measured ratio moved 1.08× → 1.04×, still above real time;stride8 (0.371 s windows, 40 frames decoded per 8 emitted) is the next step down and should only be tried with the ratio re-measured. - The window lengths feed the vocoder's CUDA-graph capture set, so changing them requires a server restart.
stream_stride(first window) andstream_followup_stride_ramp(the windows after it) were left near their defaults — only the steady-state value drives the granularity of a long reply.- (Superseded: the
TTS_PREBUFFER_MSdefault is now450, targeting the ramp's 0.464 s first window — see the entry above.)
2026-10-16 — Resilient mixer resume + TTS_PREBUFFER_MS default 900 ms
Changed
- The mixer no longer re-fills the whole jitter buffer after a mid-reply
underrun. Previously, when the TTS queue emptied for more than the 200 ms
drain grace,
pull_frameclearedtts_primedandtts_stream_end, then required a fulltts_prebuffer_samplesrefill before playback resumed — adding a second, self-inflicted pause on top of the real underrun. It also left the mixer "primed" between turns becausemark_stream_startnever re-armedtts_primed. Now:- the full pre-buffer is awaited only at the first playback of a turn; once
playback has started, an underrun pauses and resumes as soon as any audio
is queued again (
need = 1); tts_stream_endis no longer cleared on drain, so a genuine end-of-stream is still honoured;mark_stream_startre-armstts_primed,tts_playback_startedandtts_drain_framesat the start of every turn.
- the full pre-buffer is awaited only at the first playback of a turn; once
playback has started, an underrun pauses and resumes as soon as any audio
is queued again (
TTS_PREBUFFER_MSdefault raised400→900(.env,.env.example,src/config.rs). With400the reply was still prone to drop-outs on long answers.
Why
- Direct measurement of the SGLang Drama-3 endpoint (a single, uncontended request) showed it delivers ~1.08× real time, in bursts every ~2.7 s (≈131 072 samples / 2.97 s per burst). Because the producer barely outruns the consumer and delivers in bursts, gapless playback requires a jitter buffer of roughly the burst period (~3 s); smaller buffers show a short real drop-out (pure silence) on long replies.
- The mixer fix removes the avoidable part of the pause (the full refill after an underrun) and recovers at the first byte, which is what makes the remaining gap equal to the backend stall rather than stall + refill.
- Default
900is a deliberate latency-first choice: it keeps time-to-first-audio low (~1.8 s) and accepts a rare short drop-out on very long replies. RaisingTTS_PREBUFFER_MStowards ~3000 trades that latency back for gaplessness. (Superseded: the default is now700, once the server's vocoder window was narrowed to ~0.74 s — see the entry above.)
Notes
- The residual drop-out is a property of the TTS backend (~1× real time), not of the mixer: no buffering smaller than the backend's burst period can bridge a delivery stall.
- Relay diagnostics at
debuglevel inrun_llm_turnlog per-chunk relay timing (TTS pipeline ...) to help localise seams.
2026-10-16 — Pipelined TTS: chunks are synthesized ahead of playback
Changed
- The TTS sink no longer waits for one chunk's stream to finish before
requesting the next. Previously
run_llm_turnloopedwhile let Some(chunk) = rx.recv() { tts.synthesize(chunk).await }, so a chunk's synthesis only started after the previous chunk had finished streaming end-to-end. Because the TTS engine needs ~0.6 s to emit its first audio byte, every chunk boundary left a gap roughly that long, which the old 1.5 s pre-buffer used to hide. - The sink is now split into two pieces:
- a collector that spawns one synthesis task per chunk as soon as the LLM produces it (bounded by a semaphore), each streaming into its own staging buffer;
- a relay that forwards those staging buffers into the mixer queue strictly in submission order, so playback stays in order and gapless. A chunk's synthesis therefore overlaps the previous chunk's playback, hiding the per-request start-up latency.
Added
- New
TTS_PIPELINE_DEPTHconfiguration (default3) insrc/config.rs: how many TTS requests may be in flight at once.1restores the previous strictly serial behaviour. SampleRingBuffer::remaining_capacity()(src/audio/ring_buffer.rs) so the relay never pushes past the mixer queue's free space — queued (i.e. currently playing) audio is never dropped to make room.
Why
- Direct measurement against the SGLang Drama-3 endpoint showed a fixed ~0.62 s start-up latency per request (request → first audio byte), with audio then streamed at roughly real time. Two concurrent requests are served in parallel (start-up rises only to ~0.84–1.0 s), so overlapping requests genuinely removes the inter-chunk gaps instead of just moving them.
- The gaps were independent of chunk size (120 → 60 chars left the same ~0.8 s hole), which confirmed the cause was serialization, not chunk length.
Notes
- Staging buffers are sized at 30 s of audio per in-flight request, which is comfortably more than any single chunk.
- With the pipeline in place the pre-buffer only has to cover arrival jitter
rather than the TTS engine's per-request start-up, so it can be kept small.
(Superseded: the default was later raised to
900— see the entry above.)
2026-10-16 — Mixer TTS pre-buffer is now configurable (default 400 ms)
Changed
- The TTS jitter buffer moved from a hardcoded 1.5 s constant to the
TTS_PREBUFFER_MSsetting (default400).AudioMixer(insrc/audio/mixer.rs) now takestts_prebuffer_samplesas a constructor argument instead of reading the removedTTS_PREBUFFER_SAMPLESconstant, andrun_sessionderives it from the config.
Why
- Log analysis of a voice turn (
"Conta da dieci a uno.") showed the first TTS request left ~1.2 s after the EOU and the first audio bytes arrived ~1.9 s, but playback only started at +4.2 s: the mixer had 1.486 s (65 536 samples) queued at +2.8 s, 14 ms short of the 1.5 s threshold, so it waited for the next Fish chunk, which landed 1.39 s later. The pre-buffer, not the text chunker, dominated the perceived latency. - Fish Audio now delivers near real time, so 1.5 s was far more buffer than needed. 400 ms removes ~1.1 s of time-to-first-audio while still absorbing normal inter-chunk jitter (the 200 ms drain grace is unchanged).
Notes
- Tune from
.env: raiseTTS_PREBUFFER_MSif you hear drop-outs, lower it for an even snappier start. - The low-buffer warning threshold now scales with the configured value
(
prebuffer / 4).
2026-10-16 — TTS starts on the first words, not on the first sentence
Changed
- The streaming reply is now chunked on a word boundary instead of waiting
for sentence punctuation.
SentenceSplitter(insrc/orchestrator/turn_manager.rs) keeps the sentence-terminator rule (.,!,?,;, newline) but adds an early flush: the first chunk is cut as soon asLLM_STREAM_FIRST_CHUNK_CHARScharacters are buffered, and every following chunk atLLM_STREAM_CHUNK_CHARS. The cut lands on the first whitespace at or past the threshold, so a chunk always holds at least that many characters and never splits a word. Previously the first chunk only left at a./!/?, so a long opening sentence delayed the first spoken word.
Added
- New
LLM_STREAM_FIRST_CHUNK_CHARS(default20) andLLM_STREAM_CHUNK_CHARS(default120) configuration insrc/config.rs. A small first threshold makes the agent start speaking on the first few words; the larger follow-up threshold keeps prosody good once the audio is flowing. Setting either to0disables that flush (chunks then end on sentence punctuation only).
Notes
- Verified on the running server: for a chat-box turn the first TTS request is
dispatched ~0.5 s after the user text, synthesizing
"Non ho occhi fisici né"(23 chars), well before the rest of the sentence ("accesso a telecamere o immagini, quindi non posso vedere nulla …") was generated. Before the change the whole opening sentence had to complete first. - The remaining time-to-first-audio is dominated by the mixer's 1.5 s TTS pre-buffer plus the TTS engine's first-chunk latency, not by the chunker.
2026-10-15 — Filler is now a latency guard: switchable and delay-gated
Changed
- The end-of-turn filler is no longer played unconditionally. It exists to
mask the wait for the reply, so playing it when the answer is already speaking
only gets in the way of the first words.
TurnManager::schedule_filler(insrc/orchestrator/turn_manager.rs) starts aFILLER_DELAY_MStimer when the turn is dispatched (both the LLM and the direct-TTS path) and queues the clip only if the response audio has not started by then. At most one filler per turn. - New
FILLER_ENABLED/FILLER_DELAY_MSconfiguration insrc/config.rs.FILLER_ENABLED=falsedisables the filler entirely (no clip is ever queued);FILLER_DELAY_MS(default2500) is the latency guard, tunable from.env. - The immediate filler pushes in
handle_user_turnandspeakwere removed in favour of the single scheduled path, so the playground's direct-TTS path follows the same rule.
Added
AudioMixer::tts_playback_started()insrc/audio/mixer.rs— set when the TTS pre-buffer is reached (i.e. the response audio actually starts), reset bymark_stream_startandflush. This is what the filler decision reads.
Notes
- The pending filler timer is bound to the turn's
CancellationToken, so a barge-in (interrupt, VAD onset, a new turn, or disconnect) drops it. - Verified from
.envon the running server:FILLER_DELAY_MS=100→pushed filler (delayed);FILLER_DELAY_MS=8000→response audio already playing — filler skipped;FILLER_ENABLED=false→ no filler activity at all. - With the current
LLM_ENABLE_THINKING=false+ Fish Audio setup, a short reply starts playing within 2.5 s, so the filler correctly stays silent; raiseFILLER_DELAY_MS(e.g. 1000) if you want it to fire more often.
2026-10-15 — LLM reasoning stage + /mivocero speech-to-speech chat client
Added
- OpenAI-compatible LLM client —
src/llm.rs. NewLlmClientposting to{LLM_BASE_URL}/chat/completions, with anAuthorization: Bearer {LLM_API_KEY}header and a bounded TCP connect timeout (no global request timeout, so SSE streams can stay open). Two entry points, selected at runtime viaLLM_STREAM:chat_stream— SSE token streaming (stream: true), handlingdata:lines anddata: [DONE];chat— a single blocking request.
- Chain-of-thought stripping — reasoning models (Qwen3, …) may emit their
plan in a separate
reasoning_contentdelta (vLLM) or inline between think markers (llama.cpp and others).reasoning_contentis ignored and inline think blocks are removed by an incrementalThinkStripperthat is stable across delta boundaries (it withholds a tail the size of the opening marker so a marker split between two deltas is still detected). Applied to both the chat box text and the synthesized audio. - LLM stage in
TurnManager— the user turn (STT transcript or chat-box text) is sent to the model and the reply is spoken by the TTS. A rolling history window (LLM_HISTORY_MESSAGES) is kept per connection and prefixed withLLM_SYSTEM_PROMPT. ASentenceSplitterchunks the streaming reply on.,!,?,;and newlines so the TTS starts on the first sentence (SENTENCE_SOFT_MAX= 220 chars forces a split on whitespace for punctuation-free runs). - Barge-in covers the LLM request — an interrupt (client JSON, VAD onset or
disconnect) cancels the in-flight HTTP request through a
CancellationTokenin thereqwesttokio::select!, cancels the TTS tasks and flushes the mixer. - Bidirectional text protocol on
/ws— client → server{"type":"user_text","text":…}and{"type":"interrupt"}; server → client{"type":"user","text":…},{"type":"assistant_delta","text":…},{"type":"assistant_end"}and{"type":"error","text":…}. Legacy text commands (INTERRUPT,SET_REFERENCE:,CLEAR_REFERENCE, raw text → direct TTS) are unchanged, so the playground keeps working. /mivoceroclient —src/web/mivocero.html, embedded viasrc/web/mod.rsand served bysrc/main.rs. A speech-to-speech example agent with a microphone (same AudioWorklet capture/playback path as the playground), a chat box that renders the user transcript and the streaming assistant reply, a text composer and an interrupt button.LLM_*configuration —Configfields andread_llm_env()insrc/config.rs:LLM_ENABLED,LLM_BASE_URL,LLM_API_KEY,LLM_MODEL,LLM_MAX_TOKENS,LLM_TEMPERATURE,LLM_STREAM,LLM_HISTORY_MESSAGES,LLM_ENABLE_THINKING,LLM_STRIP_REASONING,LLM_DIRECT_ANSWERS,LLM_SYSTEM_PROMPT.LLM_ENABLEDdefaults totruewhen bothLLM_BASE_URLandLLM_MODELare set.- Three independent chain-of-thought controls (each switchable from
.env) — a single mechanism does not cover every deployment:LLM_ENABLE_THINKING—LlmClientsendschat_template_kwargs: {"enable_thinking": <bool>}when the variable is set, turning Qwen3-style thinking off/on server-side (vLLM/SGLang). Unset omits the vendor-specific field so strict OpenAI-compatible servers keep accepting the body.LLM_STRIP_REASONING(defaulttrue) —reasoning_contentdeltas are dropped and inline think blocks removed;falsepasses the raw output (both fields) through.LLM_DIRECT_ANSWERS(defaulttrue) — appendsDIRECT_ANSWER_DIRECTIVEto the system prompt. Needed when the server runs no reasoning parser: the chain-of-thought is then written straight intocontentas ordinary prose and is neitherreasoning_contentnor tagged, so filtering cannot catch it.
Fixed
- Chat-box turns blocked the receive loop —
handle_control_messageinsrc/ws/session.rsawaitedTurnManager::on_user_textinline, so while a reply was being generated the session could not read any further client message: aninterruptsent during generation was only honoured once the turn had finished. Text turns now run in their own task, and the interrupt cancels the reply immediately (verified: 0 deltas emitted after the interrupt, session still usable for the next turn). - SSE cancellation latency —
LlmClient::chat_streamnow races the next stream chunk against theCancellationToken, so a barge-in drops the HTTP response instead of waiting for the model's next token.
Notes
- Without an LLM the pipeline degrades to a plain STT → TTS echo
(
LLM_ENABLED=false). src/orchestrator/turn_manager.rsgained unit tests for sentence splitting (terminators, split across deltas, long punctuation-free runs) andsrc/llm.rsfor think-block stripping and for the opt-in thinking field —cargo testpasses (13 passed, 1 ignored).- Verified against
Qwen/Qwen3.6-35B-A3Bon a local vLLM (http://10.8.0.4:8000/v1): withoutLLM_ENABLE_THINKING=falsethe model narrates "Here's a thinking process: 1. Analyze User Input …" directly incontent, which the TTS reads aloud; with it, the reply is the answer only.
2026-10-15 — Startup clip bootstrap (auto-generate filler / backchannel)
Added
- Startup asset bootstrap —
src/assets.rs. On boot the server checks every expected filler / backchannel clip and, if missing, synthesizes it with the configured TTS engine and writes it as*.raw(16-bit LE mono PCM at 44.1 kHz) into the target directory (created if needed). Existing clips are never overwritten, so a fresh checkout works with no manual generation step and a customized/partial bank is preserved. - Default clip definitions:
assets::FILLER_CLIPS(uhm,eh,mmm,aver,bueno,pues,vaya) andassets::BACKCHANNEL_CLIPS(mmhmm,aja,claro,yaveo). Backchannel clips are generated only whenBACKCHANNEL_ENABLED=true. - Wired in
src/main.rsbefore the banks are loaded; the TTS engine is now created earlier so it can be reused for generation.
Notes
- Generation is best-effort and never blocks startup: a synthesis failure logs a warning per clip and the server still starts (that bank stays silent).
- The scratch capture buffer during generation is a 30 s
SampleRingBufferdrained after synthesis.
2026-10-15 — Filler bank (round-robin end-of-utterance fillers)
Added
- Filler bank —
assets/fillers/*.raw. The end-of-utterance filler is no longer a single hard-coded clip;TurnManagernow selects clips round-robin from the bank viaFillerBank::nth(a per-turn cursor,TurnManager::next_filler). With a single clip the behaviour is unchanged. - Clips synthesized with the cloned reference voice (Spanish, 44.1 kHz, 16-bit
LE mono PCM):
uhm,eh,mmm,aver,bueno,pues,vaya(~0.1–0.9 s).
Notes
FillerBank::getis kept for reference/debugging but is no longer on the hot path (#[allow(dead_code)]).
2026-10-15 — During-utterance backchannel
Added
- Backchannel subsystem —
src/orchestrator/backchannel.rs. Emits short acknowledgments ("mm-hmm", "ajá", "claro") while the user is speaking, at a low fixed gain, to signal that the agent is listening without taking the turn. Distinct from the end-of-utterance filler injected by theTurnManager. BackchannelScheduler— per-session decision logic fed by the VAD:on_speech_startonVadEvent::SpeechStart,on_speech_endonSpeechEnd, andmaybe_emit()on every frame while speaking. A cue is emitted only when the user has spoken for at leastBACKCHANNEL_MIN_SPEECH_MS, at leastBACKCHANNEL_INTERVAL_MSelapsed since the last cue, fewer thanBACKCHANNEL_MAX_PER_TURNcues were emitted this turn, and the agent is not speaking (!TurnManager::is_speaking()).- Preloaded clip bank —
assets/backchannel/*.raw(16-bit LE mono PCM at 44.1 kHz), synthesized with the cloned reference voice, loaded into a sharedFillerBankand played round-robin. - Third mixer channel —
AudioMixergained a dedicatedbackchannel_queueand a fixedbackchannel_gain. The mix is nowtts + (filler × filler_gain) + (backchannel × backchannel_gain); the queue is cleared byflush()(barge-in). FillerBankgeneralization — one type now serves both the filler and the backchannel banks (load(dir, label), round-robinnth(index),is_empty()); the default filler lookup ("uhm") is unchanged.VadDetector::in_speech()— exposes whether the detector is inside a confirmed utterance, used to gate backchannel emission.- New
.envknobs (documented in.env.example):BACKCHANNEL_ENABLED,BACKCHANNEL_DIR,BACKCHANNEL_GAIN,BACKCHANNEL_MIN_SPEECH_MS,BACKCHANNEL_INTERVAL_MS,BACKCHANNEL_MAX_PER_TURN.
Notes
BACKCHANNEL_GAINis clamped to0..1andBACKCHANNEL_INTERVAL_MSis clamped to>= BACKCHANNEL_MIN_SPEECH_MSat load time.- Wiring lives in
src/ws/session.rs(mic/VAD task); the bank is created once insrc/main.rsand shared viaAppState.
2026-10-15 — Server-side VAD (Silero V5, ONNX)
Added
- VAD subsystem —
src/vad/mod.rs+src/vad/silero.rs. Runs Silero VAD v5 via ONNX Runtime on the same 16 kHz mono stream that feeds the STT engine. The model consumes 32 ms windows (512 samples) with a 64-sample context and a recurrentstate [2,1,128]; the graph shapes and thesrinput rank are introspected at load time. - Onset/offset state machine with hysteresis (
VAD_THRESHOLD/VAD_NEG_THRESHOLD) emitting three events:SpeechOnset(fast, for barge-in),SpeechStart, andSpeechEnd(carrying the captured PCM plus a pre-roll pad so the first phoneme is not clipped). - Fast server-side barge-in — a
SpeechOnsetinterrupts the agent when it is currently speaking (TurnManager::is_speaking), cancelling the in-flight TTS and flushing the mixer. - VAD-driven STT segmentation —
SttWorker::push_utterancereceives whole utterances instead of raw frames, so the STT no longer needs its own RMS segmentation (VAD_SEGMENT_STT=true, default). - Shared ONNX environment —
src/onnx.rs. ONNX Runtime allows a single environment per process, so the STT and VAD pipelines now share a lazily createdEnvironment(cheapArcclones) instead of each building one. - New
.envknobs (all documented in.env.example):VAD_ENABLED,VAD_MODEL_PATH,VAD_THRESHOLD,VAD_NEG_THRESHOLD,VAD_MIN_SPEECH_MS,VAD_MIN_SILENCE_MS,VAD_SPEECH_PAD_MS,VAD_BARGE_IN_MIN_SPEECH_MS,VAD_SEGMENT_STT,VAD_BARGE_IN,VAD_DEBUG. AudioMixer::is_active/tts_stream_activeflag tracking whether a TTS stream is in flight, independent of the queue length, so barge-in can gate on "the agent is speaking".
Fixed
- Duplicate analysis window appended to the utterance at speech start (the pre-roll ring already contained it) — the utterance is now seeded from the pre-roll ring only.
server.shnow also appendsaudio_agent_engine::vad=debugtoRUST_LOG(mirroring the earlier TTS fix), so VAD debug logs are never filtered out.
Verified
models/silero_vad.onnx is loaded at startup (state_shape=[2,1,128],
sr_rank=0). A live WSS session streaming 44.1 kHz speech produced, in
run/server.log:
INFO ws::session: VAD active for this session threshold=0.5 min_speech_ms=250 min_silence_ms=300 …
DEBUG vad::silero: VAD speech onset speech_ms=160 prob="0.999"
DEBUG vad::silero: VAD speech start speech_ms=256 prob="1.000" preroll_samples=4992
DEBUG vad::silero: VAD speech end duration_ms=1144 samples=18304 avg_prob="0.609"
INFO ws::session: VAD utterance → STT duration_ms=1144 samples=18304
INFO orchestrator::turn_manager: EOU detected tokens=5 text=Me escucha.
Talking over the agent (after a text TTS prompt) produced:
DEBUG vad::silero: VAD speech onset speech_ms=160 prob="0.999"
INFO ws::session: VAD barge-in — interrupting agent prob="0.999"
INFO orchestrator::turn_manager: interrupt received, flushing audio
2026-10-15 — TTS DEBUG logs actually reach run/server.log
Problem
The debug! diagnostics added to the TTS subsystem never appeared in the log,
even though the code and the DEFAULT_LOG_FILTER fallback were in place. Root
cause: the parent shell (IDE) already exports RUST_LOG=info, so
EnvFilter::try_from_default_env() succeeded and the
info,audio_agent_engine::tts=debug fallback was never used. In server.sh
the same happened — ${RUST_LOG:-info,audio_agent_engine::tts=debug} never
expanded the default because RUST_LOG was already set.
Fixed
server.shnow appends theaudio_agent_engine::tts=debugdirective to whateverRUST_LOGit inherits (unless that target is already mentioned), so the TTS debug logs cannot be silently filtered out.startlogs the effective filter.
Verified
A live WSS session (Hola, …) that is kept open for the whole synthesis now
emits, in run/server.log:
DEBUG audio_agent_engine::tts::fish_audio: TTS request dispatched endpoint=… has_reference=true text=…
DEBUG audio_agent_engine::tts::fish_audio: TTS time-to-first-byte ttfb_ms=0 bytes=8186
DEBUG audio_agent_engine::tts::fish_audio: TTS chunk received chunk=1 bytes=8186 samples=4093 queue_len=4093
DEBUG audio_agent_engine::tts::fish_audio: TTS stream summary chunk_count=9 total_samples=180224 audio_s="4.09" elapsed_ms=3347
Note: closing the WebSocket mid-stream triggers
turn_manager.interrupt()(barge-in), which cancels the TTS stream before any chunk arrives — so onlyTTS request dispatchedis logged in that case.
2026-10-15 — Optional HTTPS/WSS + clearer microphone diagnostics
Problem
Clicking "Connect & Start Mic" in the playground failed with a generic
mic error, with no WebSocket connection ever reaching the server. Browsers
only expose navigator.mediaDevices.getUserMedia in a secure context
(HTTPS or localhost); over plain HTTP on any other origin the API is simply
absent, so startAudio() threw before the socket was created.
Added
- Optional TLS termination —
src/tls.rs. Set bothTLS_CERTandTLS_KEY(PEM) in.envand the server serves the same Axum router over HTTPS/WSS viatokio-rustls+hyper(HTTP/1 upgrades enabled, so WebSocket handshakes work). Without them it stays on plain HTTP. The startup log now sayslistening (HTTPS)/listening (HTTP). ./server.sh cert [--force]— generates a self-signed certificate undercerts/whose SAN coverslocalhost,127.0.0.1,::1and the host's IPs (EXTRA_SANto append more).start/restartauto-generate it if TLS is enabled but the files are missing;statusshows the scheme and cert state.
Changed
- Playground diagnostics —
src/web/playground.htmlnow checks for a secure context before requesting the mic and reports the precise cause (insecure origin/NotAllowedError/NotFoundError) with a remediation hint, instead of a baremic error. - New config fields
Config::tls_cert/Config::tls_key(+tls_enabled()), read fromTLS_CERT/TLS_KEY.
Notes
- No new crates are downloaded:
hyper,hyper-util,tokio-rustls,rustlsandrustls-pki-typeswere already present as transitive deps ofreqwest(ringprovider), so they are declared withdefault-features = false.
2026-10-15 — server.sh control script (start / stop / restart)
Added server.sh, a small bash helper so the server does not have
to be managed by hand.
Usage
./server.sh start [--build] # start (auto-builds if binary missing)
./server.sh stop # graceful stop (SIGKILL after 10 s)
./server.sh restart [--build] # stop + start
./server.sh status # process / port / HTTP status
./server.sh logs [N] # tail run/server.log (default 50, follow)
./server.sh build # build the release binary only
- PID is tracked in
run/server.pid; logs are appended torun/server.log. startwaits until the port is accepting connections (the model load takes ~1.5 s) before reporting success.- Overridable env vars:
PROFILE(release|debug),RUST_LOG,PORT. - The script
cds to its own directory first, so.envand the relativemodels/parakeetpath resolve correctly regardless of the caller's cwd.
Documented in the README (Quick Start).
2026-10-15 — Parakeet TDT STT reimplemented in pure Rust (ONNX)
The previous STT was a placeholder that ran argmax over a single .onnx
file — it could not actually transcribe. It has been replaced with a complete,
dependency-free (no Python/NeMo) implementation of the NVIDIA Parakeet TDT
0.6B v3 transducer pipeline in src/stt/parakeet.rs,
mirroring the reference in istupakov/onnx-asr.
Change
- 3-stage ONNX pipeline, all sessions kept alive by the
ortEnvironment:nemo128.onnx— log-mel featureswaveforms/waveforms_lens→features/features_lens.encoder-model.int8.onnx(preferred, auto-detected) orencoder-model.onnx—audio_signal/length→outputs[1,1024,T']/encoded_lengths.decoder_joint-model.onnx— greedy TDT decoding.
- Runtime graph introspection:
d_model,num_durations,state_layersandstate_hiddenare read from the ONNX I/O shapes instead of being hardcoded. - Greedy TDT decode loop (token head + duration head; blank
<blk>= 8192;max_tokens_per_stepfromconfig.json, default 10). Encoder output is re-packed from[1, d_model, T']to contiguous[T', d_model]frames. - Internal text decoding:
vocab.txt(<piece> <id>, SentencePiece▁→ space) plus the reference spacing normalizer (\A\s|\s\B|(\s)\b). TheTranscriptstruct now carries atext: String. - Config:
STT_MODEL_PATHnow points to a directory (models/parakeetby default, wasmodels/parakeet-tdt.onnx).STT_TOKENIZER_PATHdefaults to empty. Updated.env,.env.exampleand the README. - TurnManager:
on_eounow usestranscript.textdirectly, falling back to the external tokenizer only when no text was produced.
Notes / fixes found during integration
- The
decoder_jointgraph expectstargetsandtarget_lengthas int32 (not int64);encoder_outputsand both state tensors are float. - The int8 encoder (~650 MB, self-contained) is preferred over the fp32 encoder
(42 MB + a 2.4 GB
.onnx.datasidecar) for speed and simpler deployment.
Verification
Opt-in end-to-end test decodes a real 16 kHz mono WAV through the model:
$ PARAKEET_TEST_WAV=/tmp/stt_test/solis16k.wav \
cargo test --release -- --ignored test_infer_real_audio --nocapture
tokens: 67 | text: "Bienvenidos a esta fascinante exploración del universo.
Como locutor profesional, les explicaré hoy cómo las ondas electromagnéticas
viajan por el espacio, transformando nuestra comprensión del cosmos."
test stt::parakeet::tests::test_infer_real_audio ... ok
The transcription matches the reference transcript verbatim (the source clip was
trimmed to 12 s). cargo test (unit tests) also passes.
2026-10-15 — Fix: short utterances swallowed by the TTS pre-buffer
Short phrases produced no audio at all. The mixer only started playing TTS
once the queue held TTS_PREBUFFER_SAMPLES (1.5 s). If an utterance's total
length was below that threshold (e.g. "Cristóbal Colón" ≈ 1.11 s / 49152
samples), the pre-buffer was never reached, so tts_primed stayed false and
the queued samples were never drained.
Change
src/audio/mixer.rs: added antts_stream_endflag withmark_stream_start()/mark_stream_end(). When the stream has ended, the mixer primes as soon as any sample is queued (stream_ended && tts_len > 0), bypassing the pre-buffer for short utterances. The flag is reset byflush(),mark_stream_start()and when the queue drains.src/orchestrator/turn_manager.rs: the spawned TTS tasks now callmixer.mark_stream_start()before synthesis andmixer.mark_stream_end()after it returns — only when the request was not cancelled, so barge-in does not falsely signal completion.
Verification
TTS stream complete chunk_count=6 total_samples=49152 duration_s="0.64"
TTS pre-buffer reached — starting TTS playback tts_queue_len=49152 prebuffer=66150 stream_ended=true
A raw WebSocket client sending "Cristóbal Colón" now receives ~1.12 s of
non-silent audio (previously the same request yielded only silence frames).
2026-10-15 — Dual-stack listener (fix localhost / ::1 connection refused)
The HTTP server bound only to 0.0.0.0 (IPv4). On hosts where localhost
resolves to ::1 first (confirmed via getent hosts localhost → ::1 localhost),
clients and preview/port-forwarding proxies that connect to localhost via IPv6
received connection refused, surfacing as "service is unavailable".
Change
src/main.rs: the listener now binds to the IPv6 wildcardSocketAddr::from((Ipv6Addr::UNSPECIFIED, port))instead of0.0.0.0. Withnet.ipv6.bindv6only = 0(Linux default, verified on this host) this is dual-stack: it accepts both IPv6 (::1/localhost) and IPv4 clients.
Verification
ss -ltnp | grep 8787 → LISTEN *:8787
curl http://[::1]:8787/ → 200
curl http://localhost:8787/ → 200
curl http://127.0.0.1:8787/ → 200
2026-10-14 — Modular TTS plugin system (engine-agnostic via .env)
The TTS layer was hard-coded to Fish Audio / SGLang Drama 3. To support
switching engines (e.g. Kokoro, or any future backend) without touching
orchestration code, the TTS subsystem is now a pluggable trait selected at
runtime via the TTS_ENGINE environment variable.
New TtsEngine trait (src/tts/mod.rs)
#[async_trait]
pub trait TtsEngine: Send + Sync {
fn name(&self) -> &str;
fn sample_rate(&self) -> u32;
async fn synthesize(&self, text: &str, tts_queue: &SampleRingBuffer, cancel: &CancellationToken) -> Result<()>;
fn set_reference(&self, _audio_path: Option<String>, _text: Option<String>) {} // default no-op
}
- Every plugin streams 16-bit mono PCM at
OUTPUT_SAMPLE_RATE(44.1 kHz) into the shared mixer queue. If an engine's native rate differs, it resamples internally (the mixer runs at a fixed rate). synthesize()must honor theCancellationTokenfor barge-in.set_reference()defaults to a no-op; only engines that support voice cloning override it.
Factory (src/tts/mod.rs::create_tts_engine)
TTS_ENGINE=fish → FishAudioTts (Fish Audio / SGLang Drama 3)
TTS_ENGINE=kokoro → KokoroTts (stub, implement in src/tts/kokoro.rs)
Unknown values panic at startup with a clear message.
Bundled plugins
| Plugin | File | Status |
|---|---|---|
fish (Fish Audio Drama 3) |
src/tts/fish_audio.rs |
Production-ready (renamed from sglang_client.rs) |
kokoro |
src/tts/kokoro.rs |
Stub — HTTP streaming call left as TODO with a commented example; includes a 24 kHz → 44.1 kHz linear resampler helper |
.env loading
- Added
dotenvyto auto-load a.envfile at startup (existing env vars take precedence). - Added
.env.exampledocumenting all env vars includingTTS_ENGINE,KOKORO_URL,KOKORO_VOICE.
Files changed
src/tts/mod.rs— newTtsEnginetrait +create_tts_enginefactory.src/tts/fish_audio.rs— renamed fromsglang_client.rs;FishAudioTtsnow implementsTtsEngine.src/tts/kokoro.rs— new Kokoro plugin stub (example for adding engines).src/tts/sglang_client.rs— deleted (moved tofish_audio.rs).src/config.rs— newtts_enginefield,TTS_ENGINEenv var (defaultfish).src/orchestrator/turn_manager.rs—tts: SglangTtsClient→tts: Arc<dyn TtsEngine>.src/ws/handler.rs—AppState.tts→Arc<dyn TtsEngine>.src/main.rs— usescreate_tts_engine(&config); loads.envviadotenvy.Cargo.toml— addedasync-trait,dotenvy..env.example— new file documenting all configuration env vars.
How to add a new engine
- Create
src/tts/myengine.rsand implementTtsEngine. - Add
pub mod myengine;tosrc/tts/mod.rs. - Register it in the
create_tts_enginematch. - Set
TTS_ENGINE=myenginein.env.
Build / verification
cargo build— 0 errors, 2 dead-code warnings (Kokoro stub fields).cargo test— 4/4 passed.- Server starts with
TTS_ENGINE=fish(default): log showsselected TTS engine engine="fish"and voice-cloning reference loaded.
2026-10-14 — Cancel TTS on WebSocket disconnect
When the client disconnected, the in-flight TTS synthesis task continued streaming from Fish Audio even though the WebSocket session was already closed, wasting bandwidth and server resources.
Fix (src/ws/session.rs)
- Added
turn_manager.interrupt()in the session cleanup path (after the receiver ends, before aborting the sender/mic tasks). - This cancels the TTS
CancellationToken, flushes the mixer queues, and stops the streaming HTTP request immediately.
2026-10-14 — Fix WebSocket frame timing (Burst ticker)
The 20 ms audio sender ticker used MissedTickBehavior::Skip, which dropped
tick beats whenever the Tokio event loop was busy (e.g. processing a large
Fish Audio TTS chunk). Each skipped tick meant a 20 ms gap in frame delivery
to the browser, producing audible micro-interruptions even though the server's
TTS queue was well-buffered.
Fix (src/ws/session.rs)
- Changed
MissedTickBehavior::Skip→MissedTickBehavior::Burst. - Missed ticks now trigger immediate back-to-back sends so the browser receives all due frames. The browser's jitter buffer (500 ms) absorbs the burst without underrunning.
2026-10-14 — Micro-interruption fix (grace period on TTS drain)
Even with the 1.5 s server pre-buffer and 500 ms browser jitter buffer,
brief audio gaps could still occur when the TTS queue momentarily hit zero
between Fish Audio chunks. The mixer immediately reset its tts_primed
state, forcing a full 1.5 s re-prime on the next chunk. The browser
playback processor did the same, re-entering buffering mode on any
underrun.
Server fix (src/audio/mixer.rs)
- Added
tts_drain_framescounter (Arc<AtomicUsize>) andTTS_DRAIN_GRACE_FRAMES = 10(200 ms). - When the TTS queue is empty while primed, the mixer now outputs silence
for up to 10 consecutive frames before resetting
tts_primed. This absorbs brief inter-chunk gaps without re-triggering the pre-buffer. flush()also resetstts_drain_frames.
Browser fix (src/web/playground.html)
PlaybackProcessornow tracksunderrunSamplesand only re-enters buffering mode after 100 ms (sampleRate * 0.10) of continuous underrun. Brief underruns produce silent output but keep playback running, so audio resumes immediately when the next WebSocket frame arrives.
2026-10-14 — Prevent overlapping TTS streams (double-Speak fix)
When the user clicked Speak twice in quick succession, two TTS synthesis tasks ran concurrently and both pushed PCM into the same mixer queue. This produced overlapping/garbled audio and roughly doubled the total duration (one observed run produced 83 s of audio for a ~42 s text).
Server fix (src/orchestrator/turn_manager.rs)
speak()now callsself.mixer.flush()immediately aftercancel_current_tts(). This clears any remaining PCM from the previous utterance so the new one starts from a clean queue.
Client fix (src/web/playground.html)
- The Speak button is now disabled (shows "Speaking...") as soon as a request is sent.
- A 1.5 s idle timer re-enables it once the server stops sending audio frames, preventing accidental double submissions.
2026-10-14 — Jitter buffer fixes (no more audio drop-outs)
Fish Audio Drama 3 streams PCM in unevenly-spaced chunks, which caused brief audible drop-outs (silence gaps) during playback. Fixed with a double jitter buffer.
Server-side TTS pre-buffer (src/audio/mixer.rs)
- Added
TTS_PREBUFFER_SAMPLES≈ 200 ms of audio (44.1 kHz × 0.20). - The mixer now tracks a
tts_primedflag (Arc<AtomicBool>). TTS samples are not consumed until the TTS queue holds at least the pre-buffer amount. - During the priming window only the filler ("uhm") backchannel is emitted, so the user hears an immediate response while TTS audio builds up.
- When the TTS queue empties (stream exhausted),
tts_primedresets tofalsefor the next request.flush()also resets it (barge-in). AudioMixer::pull_framenow usestts_queue.len()instead ofis_empty()for priming decisions.
Browser-side playback jitter buffer (src/web/playground.html)
- The
PlaybackProcessorAudioWorklet now starts in abufferingstate and only begins playing once the ring buffer holds ≥ 200 ms of samples. - On underrun (buffer drained mid-playback) it returns to
bufferingto avoid stuttering, then resumes once enough audio has re-accumulated. - Added
available()helper to compute the fill level of the ring buffer.
Build / verification
cargo build— 0 warnings.cargo test— 4/4 passed.
2026-10-14 — Voice cloning + 44.1 kHz native output
Sample rate aligned to Fish Audio native output
- Changed
OUTPUT_SAMPLE_RATEfrom 24 000 Hz to 44 100 Hz to match Fish Audio Drama 3's native PCM output. - Removed the TTS anti-aliasing resampler from
sglang_client.rs— Fish Audio PCM samples are pushed directly into the mixer queue. This eliminates any resampling artifacts ("metallic" sound) and pitch/speed mismatches. - The browser
AudioContextnow runs at 44 100 Hz, so playback is sample-accurate. OUTPUT_FRAME_SAMPLESis now 882 (44.1 kHz × 20 ms),OUTPUT_FRAME_BYTES= 1764.- Mic → STT path: browser capture resamples to 44.1 kHz, server resamples 44.1 kHz → 16 kHz for Parakeet (linear interpolation, STT-only path so aliasing is not a concern).
Voice cloning (reference audio)
Added support for Fish Audio's voice-cloning references field.
Config (env vars, applied at startup):
| Env var | Description |
|---|---|
TTS_REFERENCE_AUDIO |
Path to a WAV file on the Fish Audio server filesystem. |
TTS_REFERENCE_TEXT |
Reference transcript. If it points to an existing .txt file, its contents are loaded; otherwise the raw string is used. If unset, the server looks for a <audio-stem>.txt sibling of the WAV. |
Default: solis.wav + auto-loaded solis.txt from /root/pipecat-voice/voice_profiles/.
Runtime override (playground → server WebSocket text messages):
SET_REFERENCE:<path>|<text>— set or change the reference.<text>may be empty to re-use the sibling.txt.CLEAR_REFERENCE— disable voice cloning (use the model's default voice).
Files changed:
src/config.rs— newtts_reference_audio/tts_reference_textfields,Config::tts_reference()resolver with sibling-.txtfallback, new env-var parsing.src/tts/sglang_client.rs—Referencestruct,referencesfield inSpeechRequest(omitted whenNone), runtimeset_reference()backed by aparking_lot::RwLock<Option<Reference>>.src/orchestrator/turn_manager.rs— forwardsset_reference()to the TTS client.src/ws/session.rs— handlesSET_REFERENCE:/CLEAR_REFERENCEcontrol messages.src/web/playground.html— new "Voice Cloning" card with WAV path + transcript inputs and Set/Clear buttons; reference status indicator.
Build / verification
cargo build— 0 warnings.cargo test— 4/4 passed (tokenizer).- Direct Fish Audio
referencescall confirmed HTTP 200 +x-sample-rate: 44100. - End-to-end WebSocket test:
SET_REFERENCEfollowed by a text-to-speak request streams 44.1 kHz PCM back to the client.