Files
piperust/UPDATES.md
T
2026-10-16 11:36:02 +01:00

906 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_frame`](src/audio/mixer.rs) cleared `tts_primed` *and*
`tts_stream_end`, then required a full `tts_prebuffer_samples` refill before
playback resumed — adding a second, self-inflicted pause on top of the real
underrun. It also left the mixer "primed" between turns because
`mark_stream_start` never re-armed `tts_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_end` is no longer cleared on drain, so a genuine end-of-stream is
still honoured;
- `mark_stream_start` re-arms `tts_primed`, `tts_playback_started` and
`tts_drain_frames` at the start of every turn.
- **`TTS_PREBUFFER_MS` default raised `400` → `900`** (`.env`, `.env.example`,
[`src/config.rs`](src/config.rs)). With `400` the 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 `900` is 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. Raising
`TTS_PREBUFFER_MS` towards ~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 `debug` level in [`run_llm_turn`](src/orchestrator/turn_manager.rs)
log 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_turn`](src/orchestrator/turn_manager.rs)
looped `while 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_DEPTH` configuration (default `3`)** in
[`src/config.rs`](src/config.rs): how many TTS requests may be in flight at
once. `1` restores the previous strictly serial behaviour.
- `SampleRingBuffer::remaining_capacity()` ([`src/audio/ring_buffer.rs`](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_MS` setting (default `400`).** `AudioMixer` (in
[`src/audio/mixer.rs`](src/audio/mixer.rs)) now takes `tts_prebuffer_samples`
as a constructor argument instead of reading the removed
`TTS_PREBUFFER_SAMPLES` constant, and [`run_session`](src/ws/session.rs)
derives 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`: raise `TTS_PREBUFFER_MS` if 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` (in
[`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs)) keeps
the sentence-terminator rule (`.`, `!`, `?`, `;`, newline) but adds an early
flush: the **first** chunk is cut as soon as
`LLM_STREAM_FIRST_CHUNK_CHARS` characters are buffered, and every following
chunk at `LLM_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` (default `20`) and
`LLM_STREAM_CHUNK_CHARS` (default `120`) configuration** in
[`src/config.rs`](src/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 to `0` disables 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`
(in [`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs))
starts a `FILLER_DELAY_MS` timer 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_MS` configuration** in
[`src/config.rs`](src/config.rs). `FILLER_ENABLED=false` disables the filler
entirely (no clip is ever queued); `FILLER_DELAY_MS` (default `2500`) is the
latency guard, tunable from `.env`.
- The immediate filler pushes in `handle_user_turn` and `speak` were removed in
favour of the single scheduled path, so the playground's direct-TTS path
follows the same rule.
### Added
- **`AudioMixer::tts_playback_started()`** in
[`src/audio/mixer.rs`](src/audio/mixer.rs) — set when the TTS pre-buffer is
reached (i.e. the response audio actually starts), reset by
`mark_stream_start` and `flush`. 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 `.env` on 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; raise
`FILLER_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`](src/llm.rs). New
`LlmClient` posting to `{LLM_BASE_URL}/chat/completions`, with an
`Authorization: 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 via `LLM_STREAM`:
- `chat_stream` — SSE token streaming (`stream: true`), handling `data:`
lines and `data: [DONE]`;
- `chat` — a single blocking request.
- **Chain-of-thought stripping** — reasoning models (Qwen3, …) may emit their
plan in a separate `reasoning_content` delta (vLLM) or inline between
think markers (llama.cpp and others). `reasoning_content` is ignored and
inline think blocks are removed by an incremental `ThinkStripper` that 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`](src/orchestrator/turn_manager.rs)** — 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 with `LLM_SYSTEM_PROMPT`. A `SentenceSplitter`
chunks 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
`CancellationToken` in the `reqwest` `tokio::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.
- **`/mivocero` client** — [`src/web/mivocero.html`](src/web/mivocero.html),
embedded via [`src/web/mod.rs`](src/web/mod.rs) and served by
[`src/main.rs`](src/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** — `Config` fields and `read_llm_env()` in
[`src/config.rs`](src/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_ENABLED` defaults to `true` when both `LLM_BASE_URL` and `LLM_MODEL`
are set.
- **Three independent chain-of-thought controls** (each switchable from `.env`)
— a single mechanism does not cover every deployment:
1. `LLM_ENABLE_THINKING` — `LlmClient` sends
`chat_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.
2. `LLM_STRIP_REASONING` (default `true`) — `reasoning_content` deltas are
dropped and inline think blocks removed; `false` passes the raw output
(both fields) through.
3. `LLM_DIRECT_ANSWERS` (default `true`) — appends
[`DIRECT_ANSWER_DIRECTIVE`](src/config.rs) to the system prompt. Needed
when the server runs **no** reasoning parser: the chain-of-thought is then
written straight into `content` as ordinary prose and is neither
`reasoning_content` nor tagged, so filtering cannot catch it.
### Fixed
- **Chat-box turns blocked the receive loop** — `handle_control_message` in
[`src/ws/session.rs`](src/ws/session.rs) awaited `TurnManager::on_user_text`
inline, so while a reply was being generated the session could not read any
further client message: an `interrupt` sent 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_stream` now races the next
stream chunk against the `CancellationToken`, 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.rs` gained unit tests for sentence splitting
(terminators, split across deltas, long punctuation-free runs) and
`src/llm.rs` for think-block stripping and for the opt-in thinking field —
`cargo test` passes (13 passed, 1 ignored).
- Verified against `Qwen/Qwen3.6-35B-A3B` on a local vLLM
(`http://10.8.0.4:8000/v1`): without `LLM_ENABLE_THINKING=false` the model
narrates "Here's a thinking process: 1. **Analyze User Input** …" directly in
`content`, 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`](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`](src/assets.rs) (`uhm`, `eh`, `mmm`, `aver`, `bueno`,
`pues`, `vaya`) and [`assets::BACKCHANNEL_CLIPS`](src/assets.rs) (`mmhmm`,
`aja`, `claro`, `yaveo`). Backchannel clips are generated only when
`BACKCHANNEL_ENABLED=true`.
- Wired in [`src/main.rs`](src/main.rs) **before** 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
[`SampleRingBuffer`](src/audio/ring_buffer.rs) drained 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; [`TurnManager`](src/orchestrator/turn_manager.rs)
now selects clips **round-robin** from the bank via
[`FillerBank::nth`](src/orchestrator/turn_manager.rs) (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::get` is 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`](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 the
[`TurnManager`](src/orchestrator/turn_manager.rs).
- **`BackchannelScheduler`** — per-session decision logic fed by the VAD:
`on_speech_start` on `VadEvent::SpeechStart`, `on_speech_end` on `SpeechEnd`,
and `maybe_emit()` on every frame while speaking. A cue is emitted only when
the user has spoken for at least `BACKCHANNEL_MIN_SPEECH_MS`, at least
`BACKCHANNEL_INTERVAL_MS` elapsed since the last cue, fewer than
`BACKCHANNEL_MAX_PER_TURN` cues 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 shared
[`FillerBank`](src/orchestrator/turn_manager.rs) and played round-robin.
- **Third mixer channel** — [`AudioMixer`](src/audio/mixer.rs) gained a dedicated
`backchannel_queue` and a fixed `backchannel_gain`. The mix is now
`tts + (filler × filler_gain) + (backchannel × backchannel_gain)`; the queue is
cleared by `flush()` (barge-in).
- **`FillerBank` generalization** — one type now serves both the filler and the
backchannel banks (`load(dir, label)`, round-robin `nth(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 `.env` knobs** (documented in [`.env.example`](.env.example)):
`BACKCHANNEL_ENABLED`, `BACKCHANNEL_DIR`, `BACKCHANNEL_GAIN`,
`BACKCHANNEL_MIN_SPEECH_MS`, `BACKCHANNEL_INTERVAL_MS`,
`BACKCHANNEL_MAX_PER_TURN`.
### Notes
- `BACKCHANNEL_GAIN` is clamped to `0..1` and `BACKCHANNEL_INTERVAL_MS` is
clamped to `>= BACKCHANNEL_MIN_SPEECH_MS` at load time.
- Wiring lives in [`src/ws/session.rs`](src/ws/session.rs) (mic/VAD task); the
bank is created once in [`src/main.rs`](src/main.rs) and shared via `AppState`.
---
## 2026-10-15 — Server-side VAD (Silero V5, ONNX)
### Added
- **VAD subsystem** — [`src/vad/mod.rs`](src/vad/mod.rs) +
[`src/vad/silero.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 recurrent
`state [2,1,128]`; the graph shapes and the `sr` input 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`, and `SpeechEnd` (carrying the captured PCM plus a
pre-roll pad so the first phoneme is not clipped).
- **Fast server-side barge-in** — a `SpeechOnset` interrupts 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_utterance` receives 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`](src/onnx.rs). ONNX Runtime allows
a single environment per process, so the STT and VAD pipelines now share a
lazily created `Environment` (cheap `Arc` clones) instead of each building one.
- **New `.env` knobs** (all documented in [`.env.example`](.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_active` flag 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.sh` now also appends `audio_agent_engine::vad=debug` to `RUST_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`:
```text
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:
```text
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.sh`](server.sh) now **appends** the
`audio_agent_engine::tts=debug` directive to whatever `RUST_LOG` it
inherits (unless that target is already mentioned), so the TTS debug logs
cannot be silently filtered out. `start` logs the effective filter.
### Verified
A live WSS session (`Hola, …`) that is kept open for the whole synthesis now
emits, in `run/server.log`:
```text
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 only
> `TTS request dispatched` is 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`](src/tls.rs). Set both `TLS_CERT`
and `TLS_KEY` (PEM) in `.env` and the server serves the same Axum router over
HTTPS/WSS via `tokio-rustls` + `hyper` (HTTP/1 upgrades enabled, so WebSocket
handshakes work). Without them it stays on plain HTTP. The startup log now
says `listening (HTTPS)` / `listening (HTTP)`.
- **`./server.sh cert [--force]`** — generates a self-signed certificate under
`certs/` whose SAN covers `localhost`, `127.0.0.1`, `::1` and the host's IPs
(`EXTRA_SAN` to append more). `start`/`restart` auto-generate it if TLS is
enabled but the files are missing; `status` shows the scheme and cert state.
### Changed
- **Playground diagnostics** — [`src/web/playground.html`](src/web/playground.html)
now 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 bare `mic error`.
- New config fields `Config::tls_cert` / `Config::tls_key` (+ `tls_enabled()`),
read from `TLS_CERT` / `TLS_KEY`.
### Notes
- No new crates are downloaded: `hyper`, `hyper-util`, `tokio-rustls`, `rustls`
and `rustls-pki-types` were already present as transitive deps of `reqwest`
(`ring` provider), so they are declared with `default-features = false`.
---
## 2026-10-15 — `server.sh` control script (start / stop / restart)
Added [`server.sh`](server.sh), a small bash helper so the server does not have
to be managed by hand.
### Usage
```bash
./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 to `run/server.log`.
- `start` waits 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 `cd`s to its own directory first, so `.env` and the relative
`models/parakeet` path 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`](src/stt/parakeet.rs),
mirroring the reference in [`istupakov/onnx-asr`](https://github.com/istupakov/onnx-asr).
### Change
- **3-stage ONNX pipeline**, all sessions kept alive by the `ort` `Environment`:
1. `nemo128.onnx` — log-mel features `waveforms`/`waveforms_lens` → `features`/`features_lens`.
2. `encoder-model.int8.onnx` (preferred, auto-detected) or `encoder-model.onnx` —
`audio_signal`/`length` → `outputs`[1,1024,T']/`encoded_lengths`.
3. `decoder_joint-model.onnx` — greedy **TDT** decoding.
- **Runtime graph introspection**: `d_model`, `num_durations`, `state_layers` and
`state_hidden` are 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_step` from `config.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`). The
`Transcript` struct now carries a `text: String`.
- **Config**: `STT_MODEL_PATH` now points to a **directory** (`models/parakeet`
by default, was `models/parakeet-tdt.onnx`). `STT_TOKENIZER_PATH` defaults to
empty. Updated `.env`, `.env.example` and the README.
- **TurnManager**: [`on_eou`](src/orchestrator/turn_manager.rs) now uses
`transcript.text` directly, falling back to the external tokenizer only when
no text was produced.
### Notes / fixes found during integration
- The `decoder_joint` graph expects `targets` and `target_length` as **int32**
(not int64); `encoder_outputs` and 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.data` sidecar) 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 an `tts_stream_end` flag with
`mark_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 by
`flush()`, `mark_stream_start()` and when the queue drains.
- `src/orchestrator/turn_manager.rs`: the spawned TTS tasks now call
`mixer.mark_stream_start()` before synthesis and `mixer.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 wildcard
`SocketAddr::from((Ipv6Addr::UNSPECIFIED, port))` instead of `0.0.0.0`.
With `net.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`)
```rust
#[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 the `CancellationToken` for 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 `dotenvy` to auto-load a `.env` file at startup (existing env vars take
precedence).
- Added `.env.example` documenting all env vars including `TTS_ENGINE`,
`KOKORO_URL`, `KOKORO_VOICE`.
### Files changed
- `src/tts/mod.rs` — new `TtsEngine` trait + `create_tts_engine` factory.
- `src/tts/fish_audio.rs` — renamed from `sglang_client.rs`; `FishAudioTts` now implements `TtsEngine`.
- `src/tts/kokoro.rs` — new Kokoro plugin stub (example for adding engines).
- `src/tts/sglang_client.rs` — deleted (moved to `fish_audio.rs`).
- `src/config.rs` — new `tts_engine` field, `TTS_ENGINE` env var (default `fish`).
- `src/orchestrator/turn_manager.rs` — `tts: SglangTtsClient` → `tts: Arc<dyn TtsEngine>`.
- `src/ws/handler.rs` — `AppState.tts` → `Arc<dyn TtsEngine>`.
- `src/main.rs` — uses `create_tts_engine(&config)`; loads `.env` via `dotenvy`.
- `Cargo.toml` — added `async-trait`, `dotenvy`.
- `.env.example` — new file documenting all configuration env vars.
### How to add a new engine
1. Create `src/tts/myengine.rs` and implement `TtsEngine`.
2. Add `pub mod myengine;` to `src/tts/mod.rs`.
3. Register it in the `create_tts_engine` match.
4. Set `TTS_ENGINE=myengine` in `.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 shows `selected 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_frames` counter (`Arc<AtomicUsize>`) and
`TTS_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 resets `tts_drain_frames`.
### Browser fix (`src/web/playground.html`)
- `PlaybackProcessor` now tracks `underrunSamples` and 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 calls `self.mixer.flush()` immediately after
`cancel_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_primed` flag (`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_primed` resets to `false`
for the next request. `flush()` also resets it (barge-in).
- `AudioMixer::pull_frame` now uses `tts_queue.len()` instead of `is_empty()`
for priming decisions.
### Browser-side playback jitter buffer (`src/web/playground.html`)
- The `PlaybackProcessor` AudioWorklet now starts in a `buffering` state and
only begins playing once the ring buffer holds ≥ 200 ms of samples.
- On underrun (buffer drained mid-playback) it returns to `buffering` to 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_RATE` from **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 `AudioContext` now runs at 44 100 Hz, so playback is sample-accurate.
- `OUTPUT_FRAME_SAMPLES` is 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` — new `tts_reference_audio` / `tts_reference_text` fields, `Config::tts_reference()` resolver with sibling-`.txt` fallback, new env-var parsing.
- `src/tts/sglang_client.rs` — `Reference` struct, `references` field in `SpeechRequest` (omitted when `None`), runtime `set_reference()` backed by a `parking_lot::RwLock<Option<Reference>>`.
- `src/orchestrator/turn_manager.rs` — forwards `set_reference()` to the TTS client.
- `src/ws/session.rs` — handles `SET_REFERENCE:` / `CLEAR_REFERENCE` control 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 `references` call confirmed HTTP 200 + `x-sample-rate: 44100`.
- End-to-end WebSocket test: `SET_REFERENCE` followed by a text-to-speak request streams 44.1 kHz PCM back to the client.