42 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-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.
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.