step2
This commit is contained in:
+172
-15
@@ -20,6 +20,25 @@ TTS_REFERENCE_AUDIO=/root/pipecat-voice/voice_profiles/solis.wav
|
|||||||
# otherwise the raw string is used. Leave empty to auto-load <audio>.txt.
|
# otherwise the raw string is used. Leave empty to auto-load <audio>.txt.
|
||||||
TTS_REFERENCE_TEXT=/root/pipecat-voice/voice_profiles/solis.txt
|
TTS_REFERENCE_TEXT=/root/pipecat-voice/voice_profiles/solis.txt
|
||||||
|
|
||||||
|
# --- Drama 3 de-emphasis (TTS_ENGINE=fish) ---
|
||||||
|
# Drama 3 can sound overly theatrical/recitative. Two knobs trim that register
|
||||||
|
# without changing the voice reference. Leave a sampling variable EMPTY to omit
|
||||||
|
# the field and fall back to the server default.
|
||||||
|
# Sampling temperature sent to SGLang. Lower values reduce over-articulation;
|
||||||
|
# the recitative "radio drama" register lives in the distribution tails, so a
|
||||||
|
# lower value makes the delivery flatter. Tunable range: 0.3–0.6.
|
||||||
|
TTS_TEMPERATURE=0.45
|
||||||
|
# Nucleus sampling top-p: trims the tail of the acoustic token distribution
|
||||||
|
# (exaggerated intonations). 0.7–0.8 is the useful range.
|
||||||
|
TTS_TOP_P=0.75
|
||||||
|
# Optional top-k. Leave empty to omit the field (server default).
|
||||||
|
TTS_TOP_K=
|
||||||
|
# Neutral style tag prepended to every synthesis request that does not already
|
||||||
|
# start with a "[...]" tag. LLM replies carry no tag (the system prompt forbids
|
||||||
|
# stage directions), so they get this register; clip banks with their own tag
|
||||||
|
# are left untouched. Empty disables the prefix.
|
||||||
|
TTS_STYLE_TAG="[conversational, casual]"
|
||||||
|
|
||||||
# --- Kokoro (TTS_ENGINE=kokoro) ---
|
# --- Kokoro (TTS_ENGINE=kokoro) ---
|
||||||
KOKORO_URL=http://localhost:8880
|
KOKORO_URL=http://localhost:8880
|
||||||
KOKORO_VOICE=af_heart
|
KOKORO_VOICE=af_heart
|
||||||
@@ -71,15 +90,50 @@ FILLER_ENABLED=true
|
|||||||
FILLER_DELAY_MS=2500
|
FILLER_DELAY_MS=2500
|
||||||
FILLER_GAIN_IDLE=1.0
|
FILLER_GAIN_IDLE=1.0
|
||||||
FILLER_GAIN_DUCK=0.15
|
FILLER_GAIN_DUCK=0.15
|
||||||
|
# --- Start filler (turn-start acknowledgment when the reply is late) ---
|
||||||
|
# A second latency mask, on its own mixer channel: if the first audio chunk of
|
||||||
|
# the reply has not reached the mixer within START_FILLER_GO_DELAY ms, one of the
|
||||||
|
# phrases below (picked at random) is played so the agent acknowledges the turn
|
||||||
|
# while the LLM/TTS are still working. If audio arrives in time the cue is
|
||||||
|
# dropped. It shares the filler gain, so it ducks out of the way once the reply
|
||||||
|
# starts.
|
||||||
|
START_FILLER_ENABLED=true
|
||||||
|
# How long (ms) to wait for the first audio chunk of the reply before playing the
|
||||||
|
# start filler (default 1200). Measured from chunk ARRIVAL (not from playback
|
||||||
|
# start, which would also include the TTS_PREBUFFER_MS jitter buffer). Same idea
|
||||||
|
# as FILLER_DELAY_MS, but shorter.
|
||||||
|
START_FILLER_GO_DELAY=500
|
||||||
|
# Phrases spoken by the start filler, ONE PER LINE (a single line with "|"
|
||||||
|
# separators also works). One clip is synthesized per phrase and a RANDOM one is
|
||||||
|
# played when the reply is late, so the cue varies turn to turn. The bank follows
|
||||||
|
# this list: editing it triggers a full re-synthesis at the next restart (no
|
||||||
|
# manual deletion needed), phrases dropped here are deleted on disk, and a
|
||||||
|
# failed synthesis keeps the previous clip. An unchanged list is skipped, so
|
||||||
|
# startup stays fast. Drama-3 style tags ([calm], [sigh], ...) are passed
|
||||||
|
# through to the TTS.
|
||||||
|
START_FILLER_TEXTS="[calm] Un momento...
|
||||||
|
[thoughtful] A ver, veamos...
|
||||||
|
[hesitant] Entonces... echemos un vistazo...
|
||||||
|
[sigh] Vale, ya voy..."
|
||||||
|
# Directory holding the start-filler clips (kept separate from FILLERS_DIR so
|
||||||
|
# they are not added to the round-robin filler bank).
|
||||||
|
START_FILLER_DIR=assets/start_filler
|
||||||
# TTS jitter buffer (ms): how much synthesized audio the mixer buffers before it
|
# TTS jitter buffer (ms): how much synthesized audio the mixer buffers before it
|
||||||
# starts playing the reply. Lower values start speaking sooner; higher values
|
# starts playing the reply. Lower values start speaking sooner; higher values
|
||||||
# absorb uneven TTS chunk arrival at the cost of added latency. Drama-3's vocoder
|
# absorb uneven TTS chunk arrival at the cost of added latency. Drama-3's vocoder
|
||||||
# emits audio in windows of `stream_followup_stride` codec frames (2048 samples =
|
# emits audio in windows of codec frames (2048 samples = 46.4 ms each); with the
|
||||||
# 46.4 ms each); with the recommended server setting (stride 16, ramp
|
# recommended server setting (stride 4, followup 8, overlap 16, crossfade 512)
|
||||||
# "[10,12,16]") the first window is 10 frames = 0.464 s, so 450 ms starts
|
# the first window is 4 frames = 185.8 ms and the steady windows are 8 frames =
|
||||||
# playback on that very first window. Mid-reply underruns resume as soon as any
|
# 371.6 ms. The first arrives at the server TTFB (~0.38 s) and the second ~350 ms
|
||||||
# audio is queued again (no full refill).
|
# later, so ANY value in (186, 557] ms primes on the same second window (TTFA
|
||||||
TTS_PREBUFFER_MS=450
|
# ~0.73 s); 550 ms takes the whole ~557 ms cushion for free. That cushion absorbs
|
||||||
|
# the drain when several TTS requests share one GPU and produce slower than real
|
||||||
|
# time (measured ~1.5x concurrent vs ~0.94x alone); without it every chunk seam
|
||||||
|
# starves the queue and pauses playback (a micro-gap at each sentence start).
|
||||||
|
# Below ~186 ms playback starts on the first window (TTFA ~= server TTFB) but
|
||||||
|
# leaves almost no cushion. Mid-reply underruns resume as soon as any audio is
|
||||||
|
# queued again (no refill).
|
||||||
|
TTS_PREBUFFER_MS=550
|
||||||
# Number of TTS requests kept in flight at once. Reply chunks are synthesized
|
# Number of TTS requests kept in flight at once. Reply chunks are synthesized
|
||||||
# ahead of playback (and replayed in order) so each request's start-up latency
|
# ahead of playback (and replayed in order) so each request's start-up latency
|
||||||
# hides behind the previous chunk's audio instead of leaving an audible gap.
|
# hides behind the previous chunk's audio instead of leaving an audible gap.
|
||||||
@@ -89,18 +143,98 @@ TTS_PIPELINE_DEPTH=3
|
|||||||
# --- Backchannel (short acknowledgments while the user speaks) ---
|
# --- Backchannel (short acknowledgments while the user speaks) ---
|
||||||
# Master switch. When disabled, no during-utterance acknowledgment is played.
|
# Master switch. When disabled, no during-utterance acknowledgment is played.
|
||||||
BACKCHANNEL_ENABLED=true
|
BACKCHANNEL_ENABLED=true
|
||||||
# Directory of preloaded "*.raw" PCM clips (16-bit LE mono, 44.1 kHz), e.g.
|
# Directory of the generated "*.raw" PCM clips (16-bit LE mono, 44.1 kHz). One
|
||||||
# mmhmm / ajá / claro. Played round-robin.
|
# clip is synthesized per phrase of BACKCHANNEL_TEXTS at startup.
|
||||||
BACKCHANNEL_DIR=assets/backchannel
|
BACKCHANNEL_DIR=assets/backchannel
|
||||||
|
# Phrases spoken by the backchannel, ONE PER LINE (a single line with "|"
|
||||||
|
# separators also works). One clip is synthesized per phrase and a RANDOM one is
|
||||||
|
# played, so the cue varies from turn to turn. The bank follows this list:
|
||||||
|
# editing it triggers a full re-synthesis at the next restart (no manual
|
||||||
|
# deletion needed), phrases dropped here are deleted on disk, and a failed
|
||||||
|
# synthesis keeps the previous clip. An unchanged list is skipped, so startup
|
||||||
|
# stays fast. Drama-3 style tags ([calm], ...) are passed through to the TTS.
|
||||||
|
#
|
||||||
|
# A line may start with an optional "category::" prefix (e.g. "agree::..."); the
|
||||||
|
# scheduler uses it to progress through the turn (an acknowledgment first,
|
||||||
|
# agreement later) and to avoid chaining two cues of the same kind. Lines
|
||||||
|
# without a prefix belong to the default "ack" category.
|
||||||
|
BACKCHANNEL_TEXTS="ack::[calm] Mm-hmm...
|
||||||
|
ack::[attentive] Ajá...
|
||||||
|
agree::[agreeing] Claro...
|
||||||
|
ack::[listening] Ya veo..."
|
||||||
# Mix gain applied to the backchannel channel (0..1). Kept low so the cue stays
|
# Mix gain applied to the backchannel channel (0..1). Kept low so the cue stays
|
||||||
# in the background.
|
# in the background.
|
||||||
BACKCHANNEL_GAIN=0.35
|
BACKCHANNEL_GAIN=0.55
|
||||||
# Continuous speech required (ms) before the first backchannel of a turn.
|
# Confirmed speech required (ms) before the first cue may be considered.
|
||||||
BACKCHANNEL_MIN_SPEECH_MS=1500
|
BACKCHANNEL_MIN_SPEECH_MS=1500
|
||||||
# Minimum spacing (ms) between two backchannels. Clamped to >= MIN_SPEECH_MS.
|
# Base spacing (ms) between two cues of the same turn; randomized by ±JITTER/2.
|
||||||
BACKCHANNEL_INTERVAL_MS=3000
|
BACKCHANNEL_INTERVAL_MS=3000
|
||||||
# Maximum backchannels emitted per user turn.
|
# Timing jitter (ms): delays the first cue by 0..JITTER after MIN_SPEECH_MS and
|
||||||
BACKCHANNEL_MAX_PER_TURN=2
|
# spreads the spacing, so cues don't land on a fixed cadence.
|
||||||
|
BACKCHANNEL_JITTER_MS=1200
|
||||||
|
# Micro-pause window (ms): the cue lands only while the user is in a short
|
||||||
|
# pause. The reaction DEPTH is drawn at random inside [MIN, MAX] per pause, so
|
||||||
|
# cues don't all land at exactly MIN. Below MIN the user is likely still
|
||||||
|
# mid-word; above MAX (capped below VAD_MIN_SILENCE_MS) the utterance is about
|
||||||
|
# to close.
|
||||||
|
BACKCHANNEL_PAUSE_MIN_MS=96
|
||||||
|
BACKCHANNEL_PAUSE_MAX_MS=260
|
||||||
|
# Probability (0..1) of cueing when a qualifying micro-pause occurs.
|
||||||
|
BACKCHANNEL_PROB=0.65
|
||||||
|
# Random per-cue volume scaling (± fraction) so cues don't sit at a fixed level.
|
||||||
|
BACKCHANNEL_VOLUME_JITTER=0.2
|
||||||
|
# Maximum cues emitted per user turn.
|
||||||
|
BACKCHANNEL_MAX_PER_TURN=3
|
||||||
|
|
||||||
|
# --- Multilingual clip banks (backchannel + filler + start filler) ---
|
||||||
|
# Every clip bank lives in a per-language subdirectory `<dir>/<lang>` named after
|
||||||
|
# the ISO 639-1 code (<dir>/<lang>/, e.g. assets/backchannel/es/), including the
|
||||||
|
# default language LANG_DEFAULT. When the user is detected speaking a different
|
||||||
|
# language, that language's banks are generated on demand into their own
|
||||||
|
# subdirectory and the session switches to them. Each language has its own
|
||||||
|
# directory, so regenerating one never removes the clips of another.
|
||||||
|
LANG_DEFAULT=es
|
||||||
|
# Detect the user's language with a dedicated LLM call (a short auxiliary
|
||||||
|
# request that returns only the ISO code) and switch the banks when it differs
|
||||||
|
# from the active one. Requires the LLM (LLM_ENABLED=true).
|
||||||
|
LANG_DETECT_ENABLED=true
|
||||||
|
# Minimum transcript length (chars) before a sample is sent to the LLM.
|
||||||
|
LANG_MIN_CHARS=12
|
||||||
|
# The first user turn is always checked; afterwards the language is re-checked
|
||||||
|
# once every N turns, so a mid-conversation switch is picked up without paying
|
||||||
|
# for a detection call on every turn.
|
||||||
|
LANG_REDETECT_EVERY=5
|
||||||
|
# Built-in phrase tables cover es/en/it/fr/de/pt. For any other language the
|
||||||
|
# phrase list is generated by the LLM; set false to keep only the tables.
|
||||||
|
LANG_LLM_FALLBACK_ENABLED=true
|
||||||
|
|
||||||
|
# --- Per-session audit log ---
|
||||||
|
# Writes one structured JSONL file per WebSocket session: a configuration
|
||||||
|
# snapshot at the start, every inbound control message and outbound event, the
|
||||||
|
# cue emissions, the language switches, the barge-ins / interrupts and an
|
||||||
|
# event-count summary at the end. Useful for post-mortem analysis and for
|
||||||
|
# tuning latency knobs (FILLER_DELAY_MS, START_FILLER_GO_DELAY, ...). One
|
||||||
|
# <id>.jsonl file per session, written under SESSION_AUDIT_DIR.
|
||||||
|
SESSION_AUDIT_ENABLED=true
|
||||||
|
SESSION_AUDIT_DIR=logs/sessions
|
||||||
|
|
||||||
|
# --- External audio source (URL / radio / YouTube) ---
|
||||||
|
# Injects an audio stream — a direct media URL, an Icecast/Shoutcast radio or a
|
||||||
|
# YouTube link — into the mixer's own "external" channel, mixed under the
|
||||||
|
# agent's voice. Demonstrated in the /mivocero client (URL box + Play/Stop +
|
||||||
|
# volume slider). Decoding is delegated to external tools:
|
||||||
|
# * ffmpeg — decodes direct URLs, radio and HLS streams (required);
|
||||||
|
# * yt-dlp — resolves YouTube URLs to a decodable stream (YouTube only).
|
||||||
|
# A barge-in (interrupt) stops the agent but leaves this background source
|
||||||
|
# playing; the external channel does not count as the agent speaking.
|
||||||
|
# Gain applied to the external channel (kept below 1 so it sits under the voice).
|
||||||
|
EXTERNAL_AUDIO_GAIN=0.6
|
||||||
|
# Override the decoder binaries if they are not on PATH.
|
||||||
|
MEDIA_FFMPEG_BIN=ffmpeg
|
||||||
|
MEDIA_YTDLP_BIN=yt-dlp
|
||||||
|
# Extra yt-dlp arguments (space-separated), e.g. to pass cookies or a JS
|
||||||
|
# runtime: "--cookies /path/cookies.txt". Leave empty normally.
|
||||||
|
MEDIA_YTDLP_ARGS=
|
||||||
|
|
||||||
# --- LLM reasoning (OpenAI-compatible chat-completions endpoint) ---
|
# --- LLM reasoning (OpenAI-compatible chat-completions endpoint) ---
|
||||||
# When enabled, the user transcript is sent to the model and the reply is
|
# When enabled, the user transcript is sent to the model and the reply is
|
||||||
@@ -143,12 +277,35 @@ LLM_DIRECT_ANSWERS=true
|
|||||||
# word-boundary flush (chunks then end on sentence punctuation only).
|
# word-boundary flush (chunks then end on sentence punctuation only).
|
||||||
LLM_STREAM_FIRST_CHUNK_CHARS=20
|
LLM_STREAM_FIRST_CHUNK_CHARS=20
|
||||||
# Same threshold for every chunk after the first. Larger chunks give better
|
# Same threshold for every chunk after the first. Larger chunks give better
|
||||||
# prosody; smaller ones keep the audio flowing while the model is generating.
|
# prosody and fewer TTS requests: each chunk is a separate request with its own
|
||||||
LLM_STREAM_CHUNK_CHARS=120
|
# start-up and its own audio seam, so a larger value cuts the number of seams
|
||||||
|
# (where the TTS queue can starve) per reply.
|
||||||
|
LLM_STREAM_CHUNK_CHARS=240
|
||||||
# Optional system prompt override. Leave unset for the built-in voice-assistant
|
# Optional system prompt override. Leave unset for the built-in voice-assistant
|
||||||
# prompt (see `DEFAULT_LLM_SYSTEM_PROMPT` in src/config.rs).
|
# prompt (see `DEFAULT_LLM_SYSTEM_PROMPT` in src/config.rs).
|
||||||
#LLM_SYSTEM_PROMPT=
|
#LLM_SYSTEM_PROMPT=
|
||||||
|
|
||||||
|
# --- Tool calling (configurable HTTP tools) ---
|
||||||
|
# Lets the model call external HTTP services (weather, home automation, a
|
||||||
|
# webhook, …) before answering. The deployment's vLLM server runs without a
|
||||||
|
# tool parser, so it returns the call as text in the Qwen native
|
||||||
|
# `<tool_call>` format: the engine parses it client-side, executes the HTTP
|
||||||
|
# request and hands the result back to the model (see src/tools.rs). The
|
||||||
|
# tool-call block is never spoken nor shown in the chat box.
|
||||||
|
# Enabled automatically when LLM_TOOLS_FILE points to a valid tool file.
|
||||||
|
LLM_TOOLS_ENABLED=false
|
||||||
|
# JSON file with the tool definitions (name, description, JSON schema, url,
|
||||||
|
# method, headers). See tools.example.json.
|
||||||
|
LLM_TOOLS_FILE=
|
||||||
|
# Maximum tool-call rounds per turn (call → execute → re-prompt). Bounds
|
||||||
|
# runaway loops; the first round is the model's initial reply.
|
||||||
|
LLM_TOOL_MAX_ITERATIONS=4
|
||||||
|
# Timeout of a single tool HTTP request, in seconds.
|
||||||
|
LLM_TOOL_TIMEOUT_SECS=15
|
||||||
|
# Tool responses are truncated to this many characters before being handed
|
||||||
|
# back to the model (keeps the context small).
|
||||||
|
LLM_TOOL_RESULT_MAX_CHARS=2000
|
||||||
|
|
||||||
# --- HTTPS (optional) ---
|
# --- HTTPS (optional) ---
|
||||||
# Set BOTH to serve HTTPS/WSS instead of plain HTTP. A secure context is
|
# Set BOTH to serve HTTPS/WSS instead of plain HTTP. A secure context is
|
||||||
# required by browsers to grant microphone access (getUserMedia) when the page
|
# required by browsers to grant microphone access (getUserMedia) when the page
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ Cargo.lock.temp
|
|||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
|
# Per-session audit logs (SESSION_AUDIT_DIR)
|
||||||
|
/logs/
|
||||||
|
|
||||||
# --- Variabili d'ambiente e credenziali ---
|
# --- Variabili d'ambiente e credenziali ---
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -36,9 +36,27 @@ Browser mic ──► WebSocket PCM ──► STT (Parakeet/ONNX) + VAD/EOU
|
|||||||
the reply is late*: it is scheduled `FILLER_DELAY_MS` (default 2500 ms) after
|
the reply is late*: it is scheduled `FILLER_DELAY_MS` (default 2500 ms) after
|
||||||
the turn starts and dropped if the response audio has already begun. Clips are
|
the turn starts and dropped if the response audio has already begun. Clips are
|
||||||
played round-robin from `FILLERS_DIR`; `FILLER_ENABLED=false` disables it.
|
played round-robin from `FILLERS_DIR`; `FILLER_ENABLED=false` disables it.
|
||||||
|
- **Start filler** — a second latency mask on its own mixer channel: if the first
|
||||||
|
audio chunk of the reply has not reached the mixer within
|
||||||
|
`START_FILLER_GO_DELAY` (default 1200 ms) the agent plays a short
|
||||||
|
acknowledgment ("Un momento...") while the LLM/TTS are still working, and drops
|
||||||
|
it as soon as audio arrives in time. One phrase is picked **at random** from
|
||||||
|
`START_FILLER_TEXTS`, so the cue varies turn to turn. Independent of the delayed
|
||||||
|
latency filler above; `START_FILLER_ENABLED=false` disables it. See
|
||||||
|
[Fillers](#fillers--end-of-utterance-acknowledgments).
|
||||||
- **During-utterance backchannel** — short acknowledgments ("mm-hmm", "ajá")
|
- **During-utterance backchannel** — short acknowledgments ("mm-hmm", "ajá")
|
||||||
are mixed in at a reduced gain *while the user is speaking*, so the agent
|
are mixed in at a reduced gain *while the user is speaking*, so the agent
|
||||||
signals it is listening without taking the turn. Plugin-style `.env` config.
|
signals it is listening without taking the turn. Like the start filler the
|
||||||
|
bank is a phrase list (`BACKCHANNEL_TEXTS`) with Drama-3 tags, one clip per
|
||||||
|
phrase synthesized at startup. To sound natural the scheduler cues only on a
|
||||||
|
VAD **micro-pause**, randomizes the timing, gates each pause probabilistically,
|
||||||
|
avoids repeating the recent clips, drifts the volume slightly and follows a
|
||||||
|
**category** progression through the turn (`category::phrase` lines).
|
||||||
|
- **External audio injection** — a dedicated mixer channel can play a background
|
||||||
|
source (direct audio URL, Icecast/Shoutcast radio, HLS, or a YouTube link)
|
||||||
|
alongside the agent's voice, with an independent runtime volume and its own
|
||||||
|
decoder process (`ffmpeg` for URLs/radio, `yt-dlp` for YouTube). See
|
||||||
|
[External audio injection](#external-audio-injection).
|
||||||
- **Barge-in / interrupt** — cancels in-flight synthesis and flushes audio,
|
- **Barge-in / interrupt** — cancels in-flight synthesis and flushes audio,
|
||||||
triggered client-side or server-side by the VAD.
|
triggered client-side or server-side by the VAD.
|
||||||
- **Voice cloning** — reference audio + transcript (Fish Audio `references`).
|
- **Voice cloning** — reference audio + transcript (Fish Audio `references`).
|
||||||
@@ -55,10 +73,20 @@ Browser mic ──► WebSocket PCM ──► STT (Parakeet/ONNX) + VAD/EOU
|
|||||||
(first chunk after a few words, tunable) for the lowest time-to-first-audio;
|
(first chunk after a few words, tunable) for the lowest time-to-first-audio;
|
||||||
chain-of-thought (`reasoning_content` / ` thinking`) is stripped from both the
|
chain-of-thought (`reasoning_content` / ` thinking`) is stripped from both the
|
||||||
audio and the chat box.
|
audio and the chat box.
|
||||||
|
- **Tool calling** — the model can call external HTTP tools described in a JSON
|
||||||
|
file (`LLM_TOOLS_FILE`) before answering. Calls are parsed client-side from
|
||||||
|
the model's native `<tool_call>` format, executed over HTTP and fed back to
|
||||||
|
the model; the tool-call block is never spoken. See
|
||||||
|
[Tool calling](#tool-calling).
|
||||||
- **Embedded playground** — a single-page HTML client served at `/`.
|
- **Embedded playground** — a single-page HTML client served at `/`.
|
||||||
- **Mivocero speech-to-speech client** — an example agent client at `/mivocero`
|
- **Mivocero speech-to-speech client** — an example agent client at `/mivocero`
|
||||||
with a microphone, a live chat box (user transcript + streaming assistant
|
with a microphone, a live chat box (user transcript + streaming assistant
|
||||||
reply) and a text composer, useful to drive a full STT → LLM → TTS turn.
|
reply) and a text composer, useful to drive a full STT → LLM → TTS turn.
|
||||||
|
- **Per-session audit log** — each WebSocket session can be recorded to a
|
||||||
|
structured JSONL file: the configuration snapshot, every inbound control
|
||||||
|
message and outbound event, the cue emissions, the language switches, the
|
||||||
|
barge-ins and an end-of-session summary. Best-effort and off the audio hot
|
||||||
|
path. See [Session audit log](#session-audit-log).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,6 +100,9 @@ Browser mic ──► WebSocket PCM ──► STT (Parakeet/ONNX) + VAD/EOU
|
|||||||
- *(Optional)* An OpenAI-compatible chat-completions endpoint (e.g. a local
|
- *(Optional)* An OpenAI-compatible chat-completions endpoint (e.g. a local
|
||||||
vLLM / SGLang server) for the LLM reasoning stage. Without it the pipeline is
|
vLLM / SGLang server) for the LLM reasoning stage. Without it the pipeline is
|
||||||
a plain STT → TTS echo.
|
a plain STT → TTS echo.
|
||||||
|
- *(Optional)* `ffmpeg` on `PATH` (or `MEDIA_FFMPEG_BIN`) to inject external
|
||||||
|
audio from a URL / radio stream, and `yt-dlp` (or `MEDIA_YTDLP_BIN`) to inject
|
||||||
|
a YouTube link. Both are only needed for the external audio channel.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -152,6 +183,10 @@ See [`.env.example`](.env.example) for a copy-paste template.
|
|||||||
| `TTS_VOICE` | `default` | Voice identifier. |
|
| `TTS_VOICE` | `default` | Voice identifier. |
|
||||||
| `TTS_REFERENCE_AUDIO` | `…/voice_profiles/solis.wav` | Voice-cloning reference WAV (path on the TTS server). |
|
| `TTS_REFERENCE_AUDIO` | `…/voice_profiles/solis.wav` | Voice-cloning reference WAV (path on the TTS server). |
|
||||||
| `TTS_REFERENCE_TEXT` | *(empty)* | Reference transcript. If it names an existing `.txt`, its contents are loaded; otherwise the raw string is used. Empty → auto-load `<audio-stem>.txt`. |
|
| `TTS_REFERENCE_TEXT` | *(empty)* | Reference transcript. If it names an existing `.txt`, its contents are loaded; otherwise the raw string is used. Empty → auto-load `<audio-stem>.txt`. |
|
||||||
|
| `TTS_TEMPERATURE` | `0.45` | Sampling temperature sent to the SGLang speech endpoint (`fish` engine). Lower values flatten the delivery — the recitative "radio drama" register lives in the distribution tails. Empty omits the field (server default). |
|
||||||
|
| `TTS_TOP_P` | `0.75` | Nucleus sampling `top_p` sent to SGLang. Trims the tail of the acoustic token distribution, where exaggerated intonations live. Empty omits the field. |
|
||||||
|
| `TTS_TOP_K` | *(unset)* | Optional `top_k` sampling. Unset/empty omits the field (server default). |
|
||||||
|
| `TTS_STYLE_TAG` | `[conversational, casual]` | Neutral style tag prepended to every synthesis request that does not already start with a `[...]` tag. LLM replies carry no tag (the system prompt forbids stage directions), so they get this register; clip banks with their own tag are left untouched. Empty disables the prefix. **Quote the value in `.env`** (it contains a space). |
|
||||||
| `KOKORO_URL` | `http://localhost:8880` | Kokoro HTTP endpoint (`kokoro` engine). |
|
| `KOKORO_URL` | `http://localhost:8880` | Kokoro HTTP endpoint (`kokoro` engine). |
|
||||||
| `KOKORO_VOICE` | `af_heart` | Kokoro voice name. |
|
| `KOKORO_VOICE` | `af_heart` | Kokoro voice name. |
|
||||||
| `STT_MODEL_PATH` | `models/parakeet` | Directory of the Parakeet TDT ONNX pipeline (see below). |
|
| `STT_MODEL_PATH` | `models/parakeet` | Directory of the Parakeet TDT ONNX pipeline (see below). |
|
||||||
@@ -167,19 +202,38 @@ See [`.env.example`](.env.example) for a copy-paste template.
|
|||||||
| `VAD_SEGMENT_STT` | `true` | Feed the STT engine with VAD-segmented utterances instead of the worker's RMS segmentation. |
|
| `VAD_SEGMENT_STT` | `true` | Feed the STT engine with VAD-segmented utterances instead of the worker's RMS segmentation. |
|
||||||
| `VAD_BARGE_IN` | `true` | Interrupt the agent when the user starts speaking over it. |
|
| `VAD_BARGE_IN` | `true` | Interrupt the agent when the user starts speaking over it. |
|
||||||
| `VAD_DEBUG` | `false` | Log the speech probability of every analysis window. |
|
| `VAD_DEBUG` | `false` | Log the speech probability of every analysis window. |
|
||||||
| `FILLERS_DIR` | `assets/fillers` | Directory of `*.raw` filler PCM files (16-bit LE mono, 44.1 kHz), played round-robin, at most once per turn. |
|
| `FILLERS_DIR` | `assets/fillers` | Directory of `*.raw` filler PCM files (16-bit LE mono, 44.1 kHz), played round-robin, at most once per turn. Clips live in a per-language subdirectory `<dir>/<lang>` (e.g. `assets/fillers/es/`). |
|
||||||
| `FILLER_ENABLED` | `true` | Master switch for the end-of-turn filler. `false` => no filler is ever queued. |
|
| `FILLER_ENABLED` | `true` | Master switch for the end-of-turn filler. `false` => no filler is ever queued. |
|
||||||
| `FILLER_DELAY_MS` | `2500` | How long (ms) to wait for the response audio to start before playing the filler. If the reply starts earlier, the filler is dropped instead of talking over the beginning of the answer. |
|
| `FILLER_DELAY_MS` | `2500` | How long (ms) to wait for the response audio to start before playing the filler. If the reply starts earlier, the filler is dropped instead of talking over the beginning of the answer. |
|
||||||
| `FILLER_GAIN_IDLE` | `1.0` | Filler gain when TTS is idle. |
|
| `FILLER_GAIN_IDLE` | `1.0` | Filler gain when TTS is idle. |
|
||||||
| `FILLER_GAIN_DUCK` | `0.15` | Filler gain while TTS is active (ducking). |
|
| `FILLER_GAIN_DUCK` | `0.15` | Filler gain while TTS is active (ducking). Also applies to the start-filler channel. |
|
||||||
| `TTS_PREBUFFER_MS` | `450` | Synthesized audio (ms) buffered on the TTS channel before the reply starts playing. Lower = the agent speaks sooner; higher = fewer drop-outs on uneven TTS chunk arrival. Only awaited at the start of a reply; mid-reply underruns resume as soon as any audio is queued again. |
|
| `START_FILLER_ENABLED` | `true` | Master switch for the **start filler** — a turn-start acknowledgment queued on its own mixer channel when the reply audio is late, independently of the delayed filler above. `false` => no cue is ever queued. |
|
||||||
|
| `START_FILLER_GO_DELAY` | `1200` | How long (ms) the agent waits for the **first audio chunk of the reply** to reach the mixer before queueing the start filler. If audio arrives within this window the cue is dropped instead of talking over the beginning of the answer. The gate is latched on chunk *arrival* (`AudioMixer::tts_audio_arrived`), not on playback start, so it does not wait for the `TTS_PREBUFFER_MS` jitter buffer. Same idea as `FILLER_DELAY_MS`, but shorter — the start filler only covers the gap before the reply begins, not the whole generation. |
|
||||||
|
| `START_FILLER_TEXTS` | `[calm] Un momento...` | Phrases spoken by the start filler, **one per line** (a `\|` separator also works). One clip is synthesized per phrase and a **random** one is played when the reply is late, so the acknowledgment varies. Editing the list re-synthesizes the whole bank at the next restart (no manual deletion); an unchanged list is skipped to keep startup fast, and a failed synthesis keeps the previous clip. Drama-3 style tags (`[calm]`, `[sigh]`, …) are passed through to the TTS. |
|
||||||
|
| `START_FILLER_DIR` | `assets/start_filler` | Directory holding the start-filler clips (`sf01.raw`, `sf02.raw`, …, plus a `texts.txt` list index). Clips live in a per-language subdirectory `<dir>/<lang>` (e.g. `assets/start_filler/es/`). Kept out of `FILLERS_DIR` so the clips are not part of the round-robin filler bank. |
|
||||||
|
| `TTS_PREBUFFER_MS` | `550` | Synthesized audio (ms) buffered on the TTS channel before the reply starts playing. Lower = the agent speaks sooner; higher = fewer drop-outs on uneven TTS chunk arrival. With the deployment's vocoder windows (first `185.8` ms, then `371.6` ms), any value in `(186, 557]` ms primes on the *same* second window, so `550` takes the whole cushion at no extra TTFA (~`0.73 s`). Below the first window (~`186` ms) playback starts on that window (TTFA ≈ server TTFB, ~`0.38 s`) but leaves almost no margin and exposes chunk seams. Only awaited at the start of a reply; mid-reply underruns resume as soon as any audio is queued again. |
|
||||||
| `TTS_PIPELINE_DEPTH` | `3` | How many TTS requests may be in flight at once. Chunks are synthesized ahead of playback and replayed in order, so each request's start-up latency hides behind the previous chunk's audio. `1` restores strictly serial synthesis. |
|
| `TTS_PIPELINE_DEPTH` | `3` | How many TTS requests may be in flight at once. Chunks are synthesized ahead of playback and replayed in order, so each request's start-up latency hides behind the previous chunk's audio. `1` restores strictly serial synthesis. |
|
||||||
| `BACKCHANNEL_ENABLED` | `true` | Master switch for during-utterance acknowledgments. |
|
| `BACKCHANNEL_ENABLED` | `true` | Master switch for during-utterance acknowledgments. |
|
||||||
| `BACKCHANNEL_DIR` | `assets/backchannel` | Directory of `*.raw` backchannel PCM clips (16-bit LE mono, 44.1 kHz). |
|
| `BACKCHANNEL_DIR` | `assets/backchannel` | Directory of the generated `*.raw` backchannel PCM clips (16-bit LE mono, 44.1 kHz; `bc01.raw`, `bc02.raw`, …, plus a `texts.txt` list index). Clips live in a per-language subdirectory `<dir>/<lang>` (e.g. `assets/backchannel/es/`). |
|
||||||
|
| `BACKCHANNEL_TEXTS` | `ack::[calm] Mm-hmm...` … | Phrases spoken by the backchannel, **one per line** (a `\|` separator also works). One clip is synthesized per phrase and one is played per cue. Editing the list re-synthesizes the whole bank at the next restart (no manual deletion); an unchanged list is skipped to keep startup fast, and a failed synthesis keeps the previous clip. Drama-3 style tags (`[calm]`, …) are passed through to the TTS. A line may carry an optional **`category::`** prefix (e.g. `agree::[agreeing] Claro...`) used to progress through the turn; a line without it belongs to the default `ack` category. |
|
||||||
| `BACKCHANNEL_GAIN` | `0.35` | Mix gain applied to the backchannel channel (0..1). |
|
| `BACKCHANNEL_GAIN` | `0.35` | Mix gain applied to the backchannel channel (0..1). |
|
||||||
| `BACKCHANNEL_MIN_SPEECH_MS` | `1500` | Continuous speech required before the first backchannel of a turn. |
|
| `BACKCHANNEL_MIN_SPEECH_MS` | `1500` | Confirmed speech required before the first cue may be considered. |
|
||||||
| `BACKCHANNEL_INTERVAL_MS` | `3000` | Minimum spacing between two backchannels (clamped to ≥ `BACKCHANNEL_MIN_SPEECH_MS`). |
|
| `BACKCHANNEL_INTERVAL_MS` | `3000` | Base spacing between two cues of the same turn; randomized by ±`BACKCHANNEL_JITTER_MS`/2. |
|
||||||
| `BACKCHANNEL_MAX_PER_TURN` | `2` | Maximum backchannels emitted per user turn. |
|
| `BACKCHANNEL_JITTER_MS` | `1200` | Timing jitter: delays the first cue by `0..JITTER` after `BACKCHANNEL_MIN_SPEECH_MS` and spreads the spacing. |
|
||||||
|
| `BACKCHANNEL_PAUSE_MIN_MS` | `96` | Lower bound of the VAD micro-pause window (ms). The reaction **depth** inside a pause is drawn at random in `[MIN, MAX]`, so cues don't all land at exactly `MIN`. Below this the user is likely still mid-word. |
|
||||||
|
| `BACKCHANNEL_PAUSE_MAX_MS` | `260` | Upper bound of the micro-pause window, capped below `VAD_MIN_SILENCE_MS` so a cue never lands on the pause that closes the turn. |
|
||||||
|
| `BACKCHANNEL_PROB` | `0.65` | Probability (0..1) of cueing on a qualifying micro-pause — keeps the agent from acknowledging every pause. |
|
||||||
|
| `BACKCHANNEL_VOLUME_JITTER` | `0.2` | Random per-cue volume scaling (± fraction), so cues don't sit at a fixed level. |
|
||||||
|
| `BACKCHANNEL_MAX_PER_TURN` | `3` | Maximum backchannels emitted per user turn. |
|
||||||
|
| `LANG_DEFAULT` | `es` | Base language of the **filler, start-filler and backchannel** banks — its clips live in the `<dir>/<lang>` subdirectory (e.g. `assets/backchannel/es/`). Other languages are generated on demand into their own per-language subdirectories. See [Multilingual clip banks](#multilingual-clip-banks). |
|
||||||
|
| `LANG_DETECT_ENABLED` | `true` | Master switch for LLM-based language detection. Requires the LLM (`LLM_ENABLED=true`). |
|
||||||
|
| `LANG_MIN_CHARS` | `12` | Minimum transcript length (chars) before a sample is sent to the detector. |
|
||||||
|
| `LANG_REDETECT_EVERY` | `5` | The first user turn is always checked; afterwards the language is re-checked once every N turns. |
|
||||||
|
| `LANG_LLM_FALLBACK_ENABLED` | `true` | Phrase tables cover `es/en/it/fr/de/pt`; for any other language the phrase list is generated with the LLM. `false` keeps only the tables. |
|
||||||
|
| `EXTERNAL_AUDIO_GAIN` | `0.6` | Mix gain applied to the external audio channel (0..1) at session start. Overridable at runtime from the Mivocero volume slider. |
|
||||||
|
| `MEDIA_FFMPEG_BIN` | `ffmpeg` | `ffmpeg` binary used to decode a URL / radio / HLS stream into PCM. |
|
||||||
|
| `MEDIA_YTDLP_BIN` | `yt-dlp` | `yt-dlp` binary used to resolve a YouTube link to a direct stream URL. |
|
||||||
|
| `MEDIA_YTDLP_ARGS` | *(empty)* | Extra whitespace-separated args appended to the `yt-dlp` invocation (e.g. `--cookies /path/cookies.txt` when YouTube asks for bot verification, or a custom `--extractor-args`). |
|
||||||
| `LLM_ENABLED` | *(auto)* | Master switch for the LLM reasoning stage. Default: enabled when `LLM_BASE_URL` and `LLM_MODEL` are both set. When `false`, the pipeline is a plain STT → TTS echo. |
|
| `LLM_ENABLED` | *(auto)* | Master switch for the LLM reasoning stage. Default: enabled when `LLM_BASE_URL` and `LLM_MODEL` are both set. When `false`, the pipeline is a plain STT → TTS echo. |
|
||||||
| `LLM_BASE_URL` | *(empty)* | Base URL of the OpenAI-compatible endpoint; the client posts to `{LLM_BASE_URL}/chat/completions`. |
|
| `LLM_BASE_URL` | *(empty)* | Base URL of the OpenAI-compatible endpoint; the client posts to `{LLM_BASE_URL}/chat/completions`. |
|
||||||
| `LLM_API_KEY` | `EMPTY` | Bearer token sent in the `Authorization` header (local servers usually ignore it). |
|
| `LLM_API_KEY` | `EMPTY` | Bearer token sent in the `Authorization` header (local servers usually ignore it). |
|
||||||
@@ -188,14 +242,21 @@ See [`.env.example`](.env.example) for a copy-paste template.
|
|||||||
| `LLM_TEMPERATURE` | `0.7` | Sampling temperature. |
|
| `LLM_TEMPERATURE` | `0.7` | Sampling temperature. |
|
||||||
| `LLM_STREAM` | `true` | `true` → SSE token streaming, TTS starts on the first words; `false` → one blocking request, the whole reply is synthesized at once. |
|
| `LLM_STREAM` | `true` | `true` → SSE token streaming, TTS starts on the first words; `false` → one blocking request, the whole reply is synthesized at once. |
|
||||||
| `LLM_STREAM_FIRST_CHUNK_CHARS` | `20` | Characters buffered before the **first** chunk is sent to the TTS, cut on a word boundary. Small = the agent starts speaking on the first few words. `0` disables the early flush. |
|
| `LLM_STREAM_FIRST_CHUNK_CHARS` | `20` | Characters buffered before the **first** chunk is sent to the TTS, cut on a word boundary. Small = the agent starts speaking on the first few words. `0` disables the early flush. |
|
||||||
| `LLM_STREAM_CHUNK_CHARS` | `120` | Same threshold for every following chunk (larger chunks give better prosody). `0` disables. |
|
| `LLM_STREAM_CHUNK_CHARS` | `240` | Same threshold for every following chunk (larger chunks give better prosody and fewer TTS seams). `0` disables. |
|
||||||
| `LLM_HISTORY_MESSAGES` | `16` | Rolling conversation window (user+assistant messages) kept across turns. |
|
| `LLM_HISTORY_MESSAGES` | `16` | Rolling conversation window (user+assistant messages) kept across turns. |
|
||||||
| `LLM_ENABLE_THINKING` | *(unset)* | `false` sends `chat_template_kwargs: {"enable_thinking": false}` — turns Qwen3-style thinking off on vLLM/SGLang so the model answers directly. Unset omits the vendor-specific field entirely (safe for strict OpenAI-compatible servers). |
|
| `LLM_ENABLE_THINKING` | *(unset)* | `false` sends `chat_template_kwargs: {"enable_thinking": false}` — turns Qwen3-style thinking off on vLLM/SGLang so the model answers directly. Unset omits the vendor-specific field entirely (safe for strict OpenAI-compatible servers). |
|
||||||
| `LLM_STRIP_REASONING` | `true` | Drop the chain-of-thought from the reply before it reaches the chat box / TTS: ignores `reasoning_content` deltas and removes inline think blocks. `false` passes the raw output through. |
|
| `LLM_STRIP_REASONING` | `true` | Drop the chain-of-thought from the reply before it reaches the chat box / TTS: ignores `reasoning_content` deltas and removes inline think blocks. `false` passes the raw output through. |
|
||||||
| `LLM_DIRECT_ANSWERS` | `true` | Append an "answer directly, never narrate your reasoning" directive to the system prompt. |
|
| `LLM_DIRECT_ANSWERS` | `true` | Append an "answer directly, never narrate your reasoning" directive to the system prompt. |
|
||||||
| `LLM_SYSTEM_PROMPT` | *(built-in)* | Overrides the system prompt. The default asks for short, spoken-friendly replies (no markdown / bullet points). |
|
| `LLM_SYSTEM_PROMPT` | *(built-in)* | Overrides the system prompt. The default asks for short, spoken-friendly replies (no markdown / bullet points). |
|
||||||
|
| `LLM_TOOLS_ENABLED` | *(auto)* | Master switch for tool calling. Default: enabled when `LLM_TOOLS_FILE` is a non-empty path. |
|
||||||
|
| `LLM_TOOLS_FILE` | *(empty)* | JSON file with the tool definitions (`name`, `description`, `parameters` JSON schema, `url`, `method`, `headers`). See [Tool calling](#tool-calling) and [`tools.example.json`](tools.example.json). |
|
||||||
|
| `LLM_TOOL_MAX_ITERATIONS` | `4` | Maximum tool-call rounds per turn (call → execute → re-prompt). Bounds runaway loops. |
|
||||||
|
| `LLM_TOOL_TIMEOUT_SECS` | `15` | Timeout of a single tool HTTP request. |
|
||||||
|
| `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | Tool responses are truncated to this many characters before being handed back to the model. |
|
||||||
| `TLS_CERT` | *(empty)* | PEM certificate chain. Set together with `TLS_KEY` to serve HTTPS/WSS. |
|
| `TLS_CERT` | *(empty)* | PEM certificate chain. Set together with `TLS_KEY` to serve HTTPS/WSS. |
|
||||||
| `TLS_KEY` | *(empty)* | PEM private key matching `TLS_CERT`. |
|
| `TLS_KEY` | *(empty)* | PEM private key matching `TLS_CERT`. |
|
||||||
|
| `SESSION_AUDIT_ENABLED` | `true` | Write a structured JSONL audit file per WebSocket session (config snapshot, inbound control messages, every outbound event, cues, language switches, barge-ins, end-of-session summary). Best-effort — a write failure never affects the session. See [Session audit log](#session-audit-log). |
|
||||||
|
| `SESSION_AUDIT_DIR` | `logs/sessions` | Directory holding the per-session audit files (`<id>.jsonl`). Relative to the process working directory; git-ignored. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -268,9 +329,10 @@ An unknown value panics at startup with a clear message.
|
|||||||
|
|
||||||
##### Starting the Drama 3 backend (SGLang-Omni)
|
##### Starting the Drama 3 backend (SGLang-Omni)
|
||||||
|
|
||||||
The `fish` engine talks to an **SGLang-Omni** server running Drama 3. Start it
|
The `fish` engine talks to an **SGLang-Omni** server running Drama 3. The
|
||||||
with the vocoder streaming window narrowed, otherwise the vocoder emits ~3 s
|
vocoder emits audio in **windows** of codec frames (2048 samples = 46.4 ms each);
|
||||||
bursts and the client is forced into a large pre-buffer:
|
the window sizes are the single biggest lever on latency, so the server must be
|
||||||
|
started with them tuned:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sgl-omni serve \
|
sgl-omni serve \
|
||||||
@@ -278,8 +340,12 @@ sgl-omni serve \
|
|||||||
--host 0.0.0.0 --port 8000 \
|
--host 0.0.0.0 --port 8000 \
|
||||||
--tts_engine.gpu_memory_fraction 0.45 \
|
--tts_engine.gpu_memory_fraction 0.45 \
|
||||||
--tts_engine.factory.max_running_requests 4 \
|
--tts_engine.factory.max_running_requests 4 \
|
||||||
--vocoder.factory.stream_followup_stride 16 \
|
--tts_engine.factory.enable_async_decode true \
|
||||||
--vocoder.factory.stream_followup_stride_ramp "[10,12,16]"
|
--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
|
||||||
```
|
```
|
||||||
|
|
||||||
In this deployment the launcher is `/root/drama3/start-drama3.sh` and the server
|
In this deployment the launcher is `/root/drama3/start-drama3.sh` and the server
|
||||||
@@ -290,26 +356,71 @@ pm2 restart start-drama3 --update-env # after editing the launcher
|
|||||||
pm2 logs start-drama3 --lines 100 # CUDA-graph rebuild takes ~2 min at start
|
pm2 logs start-drama3 --lines 100 # CUDA-graph rebuild takes ~2 min at start
|
||||||
```
|
```
|
||||||
|
|
||||||
The two `--vocoder.factory.*` overrides are the key part:
|
| Flag | Value | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `stream_stride` | `4` | **First** window, in codec frames (decoded with *no* overlap context). Sets the server TTFB floor: at `4` it is ~`0.38 s`, at `16` it is ~`1.2 s`, because the TTS engine must emit that many codec tokens before the vocoder can decode anything. |
|
||||||
|
| `stream_followup_stride` | `8` | Steady-state window: `8` frames = `0.372 s`, arriving ~`0.35 s` apart (the server runs at ~`0.95×` real time), so the client queue grows and never starves. Larger windows mean **fewer seams** per second, which is what removes the audible artifacts. Upstream default `64` ⇒ `2.97 s` bursts. |
|
||||||
|
| `stream_followup_stride_ramp` | `"[8,8,8]"` | Windows 2..4, as a JSON tuple. Keep it flat: a *rising* ramp (e.g. `[4,6,10]`, or the upstream `(10,12,32)`) makes later windows arrive after the small ones have drained, which gaps playback at the start of a reply. |
|
||||||
|
| `stream_overlap_tokens` | `16` | Decoder warm-up context. It is decoded and **discarded** on output, so it only costs compute. This is the seam-quality lever: with too little context the streaming decoder wobbles in timbre/pitch at each seam (an audible "detune"/falsetto on long replies). `16` with stride `8` is a `(16+8)/8 = 3×` real-time decode load and stays sustainable (~`0.95×` ratio); the same `16` with stride `4` would be 5× and is **not** sustainable. Upstream default `32`. |
|
||||||
|
| `stream_crossfade_samples` | `512` | ~`11.6 ms` blend at each window seam, smoothing the hand-off between windows. |
|
||||||
|
|
||||||
| Flag | Meaning |
|
Measured on this deployment (with the voice-cloning reference), server-side:
|
||||||
|---|---|
|
|
||||||
| `stream_followup_stride` | Steady-state window, in codec frames (`2048` samples = `46.4 ms` each). `16` ⇒ `0.743 s` bursts; the upstream default `64` ⇒ `2.97 s`. |
|
| Config | TTFB | Real-time ratio |
|
||||||
| `stream_followup_stride_ramp` | Window sizes for the first windows, as a JSON tuple. `[10,12,16]` emits a `0.464 s` then a `0.557 s` window first, so playback can start sooner. Upstream default `(10,12,32)`. |
|
|---|---|---|
|
||||||
|
| `stream_stride 4`, followup `8`, overlap `16`, crossfade `512` *(this deployment)* | **~0.38 s** | ~`0.94–0.98×` |
|
||||||
|
| `stream_stride 4`, followup `4`, overlap `8`, crossfade `256` | ~`0.38 s` | ~`0.95×` (but audible detune / micro-gaps on long replies) |
|
||||||
|
| `stream_stride 16`, overlap `16` | ~`1.2 s` | ~`1.0×` |
|
||||||
|
|
||||||
|
The engine's Time-to-First-Audio is essentially `TTFB + (TTS_PREBUFFER_MS − first
|
||||||
|
window)`, quantized to the window the pre-buffer lands on. With this config the
|
||||||
|
first window is `185.8` ms and the second `371.6` ms, so any `TTS_PREBUFFER_MS`
|
||||||
|
in `(186, 557]` ms primes on the **same** second window → TTFA ≈ `0.73 s`. The
|
||||||
|
deployment ships `550 ms`: the whole cushion, no extra latency. Dropping to a
|
||||||
|
value below the first window (~`186` ms) starts playback on the first window
|
||||||
|
(TTFA ≈ `0.38 s`) but leaves little margin, and under concurrent TTS requests the
|
||||||
|
per-request throughput dips below real time (measured ~`1.5×` vs ~`0.94×` alone),
|
||||||
|
so each chunk seam starves the queue and pauses playback — an audible micro-gap at
|
||||||
|
the start of each sentence.
|
||||||
|
|
||||||
Two gotchas when writing the command:
|
Two gotchas when writing the command:
|
||||||
|
|
||||||
- The flags are **dotted keys into the pydantic config**, so they go *without* a
|
- The flags are **dotted keys into the pydantic config**, so they go *without* a
|
||||||
`stages.` prefix — `--vocoder.factory.stream_followup_stride`, not
|
`stages.` prefix — `--vocoder.factory.stream_followup_stride`, not
|
||||||
`--stages.vocoder.factory.stream_followup_stride` (the latter is rejected).
|
`--stages.vocoder.factory.stream_followup_stride` (the latter is rejected).
|
||||||
- The ramp tuple must be passed as a **JSON string** — `"[10,12,16]"` — because
|
- The ramp tuple must be passed as a **JSON string** — `"[8,8,8]"` — because
|
||||||
the config manager parses non-scalar paths with JSON.
|
the config manager parses non-scalar paths with JSON.
|
||||||
|
|
||||||
Smaller stride = finer delivery granularity but more compute: each window
|
##### De-emphasis — reducing Drama 3's recitative register
|
||||||
re-decodes `stream_overlap_tokens` (default `32`) of context, so `stride 16`
|
|
||||||
decodes 48 frames per 16 emitted. Keep the stride at or below the client's
|
Drama 3 tends to over-articulate: an LLM reply can come back with a theatrical,
|
||||||
`TTS_PREBUFFER_MS` budget (see *Mixer & jitter buffering*) — with the defaults
|
radio-drama cadence rather than a conversational tone. The engine applies two
|
||||||
above, `TTS_PREBUFFER_MS=450` starts playback on the first window.
|
configurable mitigations, both in [`src/tts/fish_audio.rs`](src/tts/fish_audio.rs):
|
||||||
|
|
||||||
|
1. **Sampling knobs** — `temperature` / `top_p` / `top_k` are sent in the
|
||||||
|
`/v1/audio/speech` payload (`TTS_TEMPERATURE` default `0.45`, `TTS_TOP_P`
|
||||||
|
default `0.75`). Exaggerated intonations live in the tails of the acoustic
|
||||||
|
token distribution, so trimming them flattens the delivery. Lowering
|
||||||
|
`temperature` toward `0.3` flattens it further; `0.8+` makes it unstable and
|
||||||
|
theatrical again. An empty value omits the field (server default). Note that
|
||||||
|
this server **rejects `repetition_penalty`** with anything other than `1.0`,
|
||||||
|
so that knob cannot be used.
|
||||||
|
2. **Neutral style tag** — `TTS_STYLE_TAG` (default `[conversational, casual]`)
|
||||||
|
is prepended to every request whose text does not already start with a `[...]`
|
||||||
|
tag. LLM replies carry no tag (the system prompt forbids stage directions), so
|
||||||
|
they get this register; the filler/backchannel banks carry their own tags and
|
||||||
|
are left untouched. Empty disables the prefix. Quote the value in `.env`, as
|
||||||
|
it contains a space.
|
||||||
|
|
||||||
|
> **`.env` parsing note.** The file is read with `dotenvy`, which **fails on
|
||||||
|
> unquoted values containing spaces** — and a single bad line makes it discard the
|
||||||
|
> *entire* file (the process then silently falls back to built-in defaults: no
|
||||||
|
> LLM, plain HTTP). Always quote values that contain spaces, e.g.
|
||||||
|
> `TTS_STYLE_TAG="[conversational, casual]"`.
|
||||||
|
|
||||||
|
The remaining lever — replacing the voice-cloning reference with a more
|
||||||
|
conversational one — is not applied here: the default `solis.wav` is a
|
||||||
|
"locutor profesional" reading, whose prosody is part of the recitative tone.
|
||||||
|
|
||||||
#### `kokoro` — Kokoro TTS *(stub)*
|
#### `kokoro` — Kokoro TTS *(stub)*
|
||||||
|
|
||||||
@@ -494,27 +605,60 @@ All VAD logs live under the `audio_agent_engine::vad` target;
|
|||||||
|
|
||||||
## Startup clip bootstrap
|
## Startup clip bootstrap
|
||||||
|
|
||||||
The filler and backchannel banks are plain `*.raw` PCM files. At startup
|
The filler, start-filler and backchannel banks are plain `*.raw` PCM files
|
||||||
[`assets::ensure_clips`](src/assets.rs) checks each expected clip and, **if a
|
(16-bit LE mono PCM at 44.1 kHz). At startup the clip banks are bootstrapped with
|
||||||
clip is missing, synthesizes it with the configured TTS engine** and writes it
|
the configured TTS engine and the files are written to disk (the directory is
|
||||||
to disk (the directory is created if needed). Existing clips are never
|
created if needed), so a fresh checkout works without a manual generation step.
|
||||||
overwritten, so a fresh checkout works without a manual generation step and a
|
Every bank lives in a per-language subdirectory `<dir>/<lang>` (see
|
||||||
partial/customized bank is left untouched.
|
[Multilingual clip banks](#multilingual-clip-banks)); the startup bootstrap
|
||||||
|
targets the `LANG_DEFAULT` subdirectory (e.g. `assets/fillers/es/`).
|
||||||
|
|
||||||
- Defaults live in [`assets::FILLER_CLIPS`](src/assets.rs) (7 clips: `uhm`,
|
- Filler uses [`assets::ensure_clips`](src/assets.rs): each expected clip is
|
||||||
`eh`, `mmm`, `aver`, `bueno`, `pues`, `vaya`) and
|
checked and **synthesized only if missing**, so an existing bank is left
|
||||||
[`assets::BACKCHANNEL_CLIPS`](src/assets.rs) (4 clips: `mmhmm`, `aja`, `claro`,
|
untouched. Defaults live in [`assets::FILLER_CLIPS`](src/assets.rs) (7 clips:
|
||||||
`yaveo`). The generated voice matches the configured TTS voice-cloning
|
`uhm`, `eh`, `mmm`, `aver`, `bueno`, `pues`, `vaya`). The generated voice
|
||||||
reference.
|
matches the configured TTS voice-cloning reference.
|
||||||
- Backchannel clips are only generated when `BACKCHANNEL_ENABLED=true`.
|
- The **start filler** and the **backchannel** use the generic
|
||||||
|
[`assets::ensure_text_bank_clips`](src/assets.rs) (through
|
||||||
|
[`ensure_start_filler_clips`](src/assets.rs) / [`ensure_backchannel_clips`](src/assets.rs)):
|
||||||
|
both are driven by their phrase list (`START_FILLER_TEXTS` /
|
||||||
|
`BACKCHANNEL_TEXTS`) instead of a fixed clip set, so editing the list from
|
||||||
|
`.env` is enough to change what is spoken. The list the current clips were made
|
||||||
|
from is recorded in `<dir>/texts.txt`; when it is unchanged and every clip is
|
||||||
|
still on disk the synthesis is **skipped** (startup stays at ~2 s). Editing,
|
||||||
|
adding or dropping a phrase — or deleting a clip — triggers a **full refresh**
|
||||||
|
at the next restart, and clips left over from a previous list are removed.
|
||||||
|
Clips are named by position (`sf01`, `sf02`, … for the start filler; `bc01`,
|
||||||
|
`bc02`, … for the backchannel) and generated a few at a time
|
||||||
|
(`TEXT_BANK_CONCURRENCY`); a failed synthesis keeps the previous clip of that
|
||||||
|
position, and the index is only written once every clip succeeded, so a partial
|
||||||
|
failure is retried next time.
|
||||||
|
- Backchannel clips are only generated when `BACKCHANNEL_ENABLED=true`; its
|
||||||
|
default phrase list lives in
|
||||||
|
[`DEFAULT_BACKCHANNEL_TEXTS`](src/orchestrator/backchannel.rs).
|
||||||
- Generation is best-effort: if the TTS backend is unreachable each clip logs a
|
- Generation is best-effort: if the TTS backend is unreachable each clip logs a
|
||||||
warning and the server still starts (that bank simply stays silent).
|
warning and the server still starts (that bank simply stays silent).
|
||||||
|
|
||||||
```text
|
```text
|
||||||
INFO audio_agent_engine::assets: generating missing clips at startup label="filler" dir=assets/fillers missing=7 engine="fish-audio"
|
INFO audio_agent_engine::assets: generating clips at startup label="filler" dir=assets/fillers/es clips=7 engine="fish-audio"
|
||||||
INFO audio_agent_engine::assets: generated clip label="filler" name="uhm" samples=32768 path=assets/fillers/uhm.raw
|
INFO audio_agent_engine::assets: generated clip label="filler" name="uhm" samples=32768 path=assets/fillers/es/uhm.raw
|
||||||
...
|
...
|
||||||
INFO audio_agent_engine: clip banks bootstrapped generated=11
|
INFO audio_agent_engine::assets: refreshing backchannel clips at startup label="backchannel" dir=assets/backchannel/es clips=8 engine="fish-audio"
|
||||||
|
INFO audio_agent_engine::assets: generated backchannel clip label="backchannel" name="bc01" samples=28672 path=assets/backchannel/es/bc01.raw
|
||||||
|
...
|
||||||
|
INFO audio_agent_engine::assets: removed stale backchannel clip label="backchannel" path=assets/backchannel/es/mmhmm.raw
|
||||||
|
INFO audio_agent_engine::assets: refreshing start-filler clips at startup label="start-filler" dir=assets/start_filler/es clips=22 engine="fish-audio"
|
||||||
|
INFO audio_agent_engine::assets: generated start-filler clip label="start-filler" name="sf01" samples=36864 path=assets/start_filler/es/sf01.raw
|
||||||
|
...
|
||||||
|
INFO audio_agent_engine::assets: removed stale start-filler clip label="start-filler" path=assets/start_filler/es/start_filler.raw
|
||||||
|
INFO audio_agent_engine: clip banks bootstrapped generated=30
|
||||||
|
```
|
||||||
|
|
||||||
|
On a later startup with unchanged lists:
|
||||||
|
|
||||||
|
```text
|
||||||
|
INFO audio_agent_engine::assets: backchannel clips up to date — skipping synthesis label="backchannel" dir=assets/backchannel/es clips=8
|
||||||
|
INFO audio_agent_engine::assets: start-filler clips up to date — skipping synthesis label="start-filler" dir=assets/start_filler/es clips=22
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -546,25 +690,73 @@ PCM at 44.1 kHz** — selected **round-robin** (`TurnManager::next_filler` →
|
|||||||
different fillers. With a single clip the behaviour is unchanged. Missing clips
|
different fillers. With a single clip the behaviour is unchanged. Missing clips
|
||||||
are synthesized at startup ([Startup clip bootstrap](#startup-clip-bootstrap)).
|
are synthesized at startup ([Startup clip bootstrap](#startup-clip-bootstrap)).
|
||||||
|
|
||||||
|
### Start filler (turn-start acknowledgment when the reply is late)
|
||||||
|
|
||||||
|
A second latency mask, on its own mixer channel. It works exactly like the filler
|
||||||
|
above, but with its own — shorter — delay
|
||||||
|
([`TurnManager::schedule_start_filler`](src/orchestrator/turn_manager.rs)):
|
||||||
|
`START_FILLER_GO_DELAY` (default **1200 ms**) after the turn is dispatched, if the
|
||||||
|
first audio chunk of the reply has **not** reached the mixer yet
|
||||||
|
(`AudioMixer::tts_audio_arrived` is still `false`) the cue is queued; if audio
|
||||||
|
arrived within that window, the cue is dropped instead of talking over the
|
||||||
|
beginning of the answer.
|
||||||
|
|
||||||
|
The gate is latched on chunk *arrival* (at the moment anything is pushed on the
|
||||||
|
TTS queue, checked on the 20 ms ticker), not on playback start: unlike
|
||||||
|
`tts_playback_started`, which only flips once the `TTS_PREBUFFER_MS` jitter buffer
|
||||||
|
is full, it does not wait out the pre-buffer. With the reference deployment the
|
||||||
|
first chunk lands ~`0.7`–`1.0 s` after the dispatch while playback starts
|
||||||
|
~`1.9 s` later, so gating on playback made the cue fire on every turn; gating on
|
||||||
|
arrival lets a reply that produces audio within `START_FILLER_GO_DELAY` suppress
|
||||||
|
it. A pending cue is also dropped on barge-in / new turn via the turn's
|
||||||
|
`CancellationToken`. This means a fast reply is never preceded by an
|
||||||
|
acknowledgment — only a late one is masked.
|
||||||
|
|
||||||
|
It lives on its own mixer channel (`start_filler_queue`), so it plays
|
||||||
|
independently of the round-robin latency filler above, but it shares the filler
|
||||||
|
gain, so it ducks to `FILLER_GAIN_DUCK` once the reply starts. Disabled by
|
||||||
|
`START_FILLER_ENABLED=false`.
|
||||||
|
|
||||||
|
Its clips live in `START_FILLER_DIR` (default `assets/start_filler`) and are
|
||||||
|
**not** part of the round-robin `FILLERS_DIR` bank. One clip is synthesized per
|
||||||
|
phrase of `START_FILLER_TEXTS` (default `"[calm] Un momento..."`) with the
|
||||||
|
configured voice, and [`FillerBank::random`](src/orchestrator/turn_manager.rs)
|
||||||
|
picks one **uniformly at random**, so the acknowledgment varies instead of
|
||||||
|
repeating the same sentence. Editing the phrase list re-synthesizes the bank at
|
||||||
|
the next restart (see
|
||||||
|
[Startup clip bootstrap](#startup-clip-bootstrap)); a failed synthesis leaves the
|
||||||
|
previous clip in place.
|
||||||
|
|
||||||
|
```text
|
||||||
|
START_FILLER_TEXTS="[calm] Un momento...
|
||||||
|
[thoughtful] A ver, veamos...
|
||||||
|
[sigh] Vale, ya voy..."
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backchannel — during-utterance acknowledgments
|
## Backchannel — during-utterance acknowledgments
|
||||||
|
|
||||||
A **backchannel** is a short acknowledgment ("mm-hmm", "ajá", "claro") played
|
A **backchannel** is a short acknowledgment ("mm-hmm", "ajá", "claro") played
|
||||||
*while the user is still speaking*, at a low gain, to signal that the agent is
|
*while the user is still speaking*, at a low gain, to signal that the agent is
|
||||||
listening — without taking the turn. It is distinct from the **filler** — the
|
listening — without taking the turn. It is distinct from the **filler** and the
|
||||||
instant acknowledgment injected at end-of-utterance by the
|
**start filler** injected around end-of-utterance by the
|
||||||
[`TurnManager`](src/orchestrator/turn_manager.rs) from `FILLERS_DIR` (played
|
[`TurnManager`](src/orchestrator/turn_manager.rs) (delayed mask from
|
||||||
round-robin; see [Fillers](#fillers--end-of-utterance-acknowledgments)).
|
`FILLERS_DIR` played round-robin, and a shorter-delay cue from `START_FILLER_DIR`;
|
||||||
|
see [Fillers](#fillers--end-of-utterance-acknowledgments)).
|
||||||
|
|
||||||
It is implemented in [`src/orchestrator/backchannel.rs`](src/orchestrator/backchannel.rs).
|
It is implemented in [`src/orchestrator/backchannel.rs`](src/orchestrator/backchannel.rs).
|
||||||
|
|
||||||
### Clips
|
### Clips
|
||||||
|
|
||||||
`BACKCHANNEL_DIR` (default `assets/backchannel`) holds `*.raw` clips — **16-bit
|
`BACKCHANNEL_DIR` (default `assets/backchannel`) holds the generated `*.raw`
|
||||||
LE mono PCM at 44.1 kHz** — played round-robin. The bundled set is synthesized
|
clips — **16-bit LE mono PCM at 44.1 kHz** — one per phrase of
|
||||||
with the same cloned voice as the TTS (`mmhmm`, `aja`, `claro`, `yaveo`). The
|
`BACKCHANNEL_TEXTS`, named by position (`bc01`, `bc02`, …). Each phrase may carry
|
||||||
clips are loaded once at startup into a
|
an optional `category::` prefix; clips keep the list order, so the i-th clip
|
||||||
|
inherits the i-th phrase's category. The default list
|
||||||
|
([`DEFAULT_BACKCHANNEL_TEXTS`](src/orchestrator/backchannel.rs), Spanish with
|
||||||
|
Drama-3 tags) is synthesized with the same cloned voice as the TTS. The clips are
|
||||||
|
loaded once at startup into a
|
||||||
[`FillerBank`](src/orchestrator/turn_manager.rs) and shared across sessions.
|
[`FillerBank`](src/orchestrator/turn_manager.rs) and shared across sessions.
|
||||||
Missing clips are synthesized at startup
|
Missing clips are synthesized at startup
|
||||||
([Startup clip bootstrap](#startup-clip-bootstrap)).
|
([Startup clip bootstrap](#startup-clip-bootstrap)).
|
||||||
@@ -575,18 +767,46 @@ Missing clips are synthesized at startup
|
|||||||
|
|
||||||
| Signal | Action |
|
| Signal | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `VadEvent::SpeechStart` | begin a turn (`on_speech_start`) |
|
| `VadEvent::SpeechStart` | begin a turn (`on_speech_start`, randomizes the first-cue instant) |
|
||||||
| `VadEvent::SpeechEnd` | close the turn, reset counters (`on_speech_end`) |
|
| `VadEvent::SpeechEnd` | close the turn, reset counters (`on_speech_end`) |
|
||||||
| every frame while speaking | `maybe_emit()` attempts a cue |
|
| every frame while speaking | `maybe_emit(pause_ms)` attempts a cue |
|
||||||
|
|
||||||
`maybe_emit()` emits a clip **only when all** of the following hold:
|
`maybe_emit()` emits a clip **only when all** of the following hold:
|
||||||
|
|
||||||
- `BACKCHANNEL_ENABLED=true` and the clip bank is non-empty;
|
- `BACKCHANNEL_ENABLED=true` and the clip bank is non-empty;
|
||||||
- the user has been speaking for at least `BACKCHANNEL_MIN_SPEECH_MS`;
|
|
||||||
- at least `BACKCHANNEL_INTERVAL_MS` elapsed since the previous cue;
|
|
||||||
- fewer than `BACKCHANNEL_MAX_PER_TURN` cues were emitted this turn;
|
|
||||||
- the agent is **not** speaking (`!TurnManager::is_speaking()`) — cuing over the
|
- the agent is **not** speaking (`!TurnManager::is_speaking()`) — cuing over the
|
||||||
agent's own TTS would be an interruption, not a backchannel.
|
agent's own TTS would be an interruption, not a backchannel (checked by the
|
||||||
|
caller);
|
||||||
|
- the user is in a **micro-pause**: `VadDetector::silence_ms()` reached the
|
||||||
|
per-pause reaction depth, a random value drawn in `[BACKCHANNEL_PAUSE_MIN_MS,
|
||||||
|
BACKCHANNEL_PAUSE_MAX_MS]`, so the cue never lands mid-word *and* doesn't
|
||||||
|
always react at the same depth. A zero-length pause (voice ongoing) re-arms
|
||||||
|
the gate;
|
||||||
|
- the first cue is due (a per-turn randomized instant,
|
||||||
|
`BACKCHANNEL_MIN_SPEECH_MS + 0..BACKCHANNEL_JITTER_MS` after the speech start);
|
||||||
|
- a randomized `BACKCHANNEL_INTERVAL_MS ± JITTER/2` gap elapsed since the
|
||||||
|
previous cue;
|
||||||
|
- fewer than `BACKCHANNEL_MAX_PER_TURN` cues were emitted this turn;
|
||||||
|
- the **probabilistic gate** (`BACKCHANNEL_PROB`) does not skip this pause — so
|
||||||
|
the agent acknowledges some pauses but not all.
|
||||||
|
|
||||||
|
### Naturalness
|
||||||
|
|
||||||
|
Six mechanisms keep the cues from sounding like a metronome:
|
||||||
|
|
||||||
|
| Mechanism | How |
|
||||||
|
|---|---|
|
||||||
|
| Cue on micro-pauses | gated on the VAD `silence_ms()` (a `PAUSE_MIN..PAUSE_MAX` window, 32 ms steps, capped below `VAD_MIN_SILENCE_MS`), not on a timer |
|
||||||
|
| Randomized reaction depth | per pause, the depth to react at is drawn at random in `[PAUSE_MIN, PAUSE_MAX]`, so the cue doesn't always land at exactly `PAUSE_MIN` |
|
||||||
|
| Randomized timing | the first cue and every following gap are jittered by `BACKCHANNEL_JITTER_MS` |
|
||||||
|
| Probabilistic gate | `BACKCHANNEL_PROB` skips a share of the qualifying pauses |
|
||||||
|
| Anti-repetition | the last two clip names are excluded from the pick, across turns |
|
||||||
|
| Volume drift | each cue is scaled by a random ±`BACKCHANNEL_VOLUME_JITTER` factor |
|
||||||
|
|
||||||
|
The phrase list is further grouped in **categories**: the scheduler prefers a
|
||||||
|
category other than the previous cue's, then a category not yet used this turn,
|
||||||
|
so a turn tends to start with an acknowledgment (`ack`) and drift toward
|
||||||
|
agreement (`agree`) instead of repeating the same kind of nod.
|
||||||
|
|
||||||
### Mixing
|
### Mixing
|
||||||
|
|
||||||
@@ -594,13 +814,90 @@ The cue is pushed into the mixer's dedicated `backchannel_queue` and summed at a
|
|||||||
fixed low gain (`BACKCHANNEL_GAIN`, default `0.35`):
|
fixed low gain (`BACKCHANNEL_GAIN`, default `0.35`):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
mixed = tts + (filler × filler_gain) + (backchannel × BACKCHANNEL_GAIN)
|
mixed = tts + ((filler + start_filler) × filler_gain) + (backchannel × BACKCHANNEL_GAIN)
|
||||||
```
|
```
|
||||||
|
|
||||||
The queue is included in `AudioMixer::flush()`, so a barge-in clears any pending
|
The queue is included in `AudioMixer::flush()`, so a barge-in clears any pending
|
||||||
cue along with the TTS and filler audio. Logs live under the
|
cue along with the TTS and filler audio. At session start the effective policy is
|
||||||
`audio_agent_engine::orchestrator::backchannel` target (`INFO` when a cue is
|
logged under `audio_agent_engine::orchestrator::backchannel`
|
||||||
emitted).
|
(`backchannel scheduler ready …`, with the parsed categories), and every emitted
|
||||||
|
cue is logged under `audio_agent_engine::ws::session`
|
||||||
|
(`backchannel emitted during user speech`, with the clip name and the pause
|
||||||
|
length).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multilingual clip banks
|
||||||
|
|
||||||
|
The filler, start-filler and backchannel banks are pre-synthesized in a single
|
||||||
|
language — the base language `LANG_DEFAULT` (default `es`). When the user is
|
||||||
|
detected speaking a different language, the engine generates that language's
|
||||||
|
banks on demand and switches the session to them, **without touching** the clips
|
||||||
|
of any other language.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
Every language — **including the base `LANG_DEFAULT`** — lives in its own
|
||||||
|
subdirectory named after its ISO 639-1 code under `FILLERS_DIR` /
|
||||||
|
`START_FILLER_DIR` / `BACKCHANNEL_DIR`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assets/backchannel/es/ <- base language (LANG_DEFAULT), pre-synthesized
|
||||||
|
assets/fillers/es/
|
||||||
|
assets/start_filler/es/
|
||||||
|
assets/backchannel/en/ <- English, generated on demand
|
||||||
|
assets/backchannel/it/ <- Italian, generated on demand
|
||||||
|
assets/fillers/en/
|
||||||
|
assets/start_filler/en/
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the refresh pass (`remove_stale_clips`) only prunes `*.raw` files inside
|
||||||
|
the directory it is given, scoping each language — base included — to its own
|
||||||
|
subdirectory is what guarantees a regeneration never deletes another language's
|
||||||
|
clips. No language is special-cased to the top level.
|
||||||
|
|
||||||
|
### Phrase source
|
||||||
|
|
||||||
|
Phrases come from built-in tables (Spanish, English, Italian, French, German and
|
||||||
|
Portuguese) in [`src/lang.rs`](src/lang.rs) — one table for the backchannel, one
|
||||||
|
for the end-of-turn filler and one for the start filler; for any other language
|
||||||
|
they are generated with the LLM (`LANG_LLM_FALLBACK_ENABLED=true`). Each
|
||||||
|
language's list is then synthesized through the same
|
||||||
|
[`ensure_text_bank_clips`](src/assets.rs) pipeline used at startup, so an
|
||||||
|
already-generated language is only reloaded (its `texts.txt` index matches) and
|
||||||
|
never re-synthesized.
|
||||||
|
|
||||||
|
### Detection & switching
|
||||||
|
|
||||||
|
- A dedicated, short LLM call returns only the ISO 639-1 code
|
||||||
|
([`lang::detect_language`](src/lang.rs)); it runs on the first user turn and
|
||||||
|
then once every `LANG_REDETECT_EVERY` turns (default `5`), so a
|
||||||
|
mid-conversation switch is picked up without paying for a detection call on
|
||||||
|
every turn.
|
||||||
|
- Detection only runs when `LANG_DETECT_ENABLED=true`, the LLM is present, at
|
||||||
|
least one language-aware cue (backchannel / filler / start filler) is enabled
|
||||||
|
and the transcript is at least `LANG_MIN_CHARS` long (shorter samples are too
|
||||||
|
noisy to classify).
|
||||||
|
- If the detected code equals the base language the session falls back to the
|
||||||
|
startup banks; otherwise the language's clips are ensured/loaded into a new
|
||||||
|
bank and the session switches to it.
|
||||||
|
- The active language is shared between the consumers through an
|
||||||
|
`Arc<LanguageState>` ([`src/lang.rs`](src/lang.rs)): the end-of-turn and start
|
||||||
|
fillers ([`TurnManager`](src/orchestrator/turn_manager.rs)) read the active
|
||||||
|
bank per turn, while the backchannel scheduler is re-pointed via
|
||||||
|
[`BackchannelScheduler::retarget`](src/orchestrator/backchannel.rs) whenever
|
||||||
|
the language generation counter changes. Detection runs in the background, so
|
||||||
|
it never blocks the reply — it takes effect from the following turn.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `LANG_DEFAULT` | `es` | Language of the startup filler / start-filler / backchannel banks (each in its own `<dir>/<lang>` subdirectory) |
|
||||||
|
| `LANG_DETECT_ENABLED` | `true` | Master switch for LLM language detection |
|
||||||
|
| `LANG_MIN_CHARS` | `12` | Minimum transcript length before detecting |
|
||||||
|
| `LANG_REDETECT_EVERY` | `5` | Re-check the language every N user turns |
|
||||||
|
| `LANG_LLM_FALLBACK_ENABLED` | `true` | Generate phrases via LLM for languages absent from the tables |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -645,8 +942,9 @@ chat box ──────────────┘
|
|||||||
the **first** chunk is cut once `LLM_STREAM_FIRST_CHUNK_CHARS` (default `20`)
|
the **first** chunk is cut once `LLM_STREAM_FIRST_CHUNK_CHARS` (default `20`)
|
||||||
characters are buffered — so the agent starts speaking on the first few words
|
characters are buffered — so the agent starts speaking on the first few words
|
||||||
instead of waiting for a whole sentence — and every following chunk at
|
instead of waiting for a whole sentence — and every following chunk at
|
||||||
`LLM_STREAM_CHUNK_CHARS` (default `120`). Set either to `0` to disable that
|
`LLM_STREAM_CHUNK_CHARS` (default `240`). Set either to `0` to disable that
|
||||||
flush (chunks then end on sentence punctuation only).
|
flush (chunks then end on sentence punctuation only). Larger follow-up chunks
|
||||||
|
mean fewer TTS requests, hence fewer chunk seams to starve the queue.
|
||||||
- **Pipelined synthesis** — chunks are *not* synthesized one after another. Each
|
- **Pipelined synthesis** — chunks are *not* synthesized one after another. Each
|
||||||
chunk is requested as soon as the LLM produces it (up to `TTS_PIPELINE_DEPTH`,
|
chunk is requested as soon as the LLM produces it (up to `TTS_PIPELINE_DEPTH`,
|
||||||
default `3`, in flight at once) and streams into its own staging buffer; a
|
default `3`, in flight at once) and streams into its own staging buffer; a
|
||||||
@@ -666,6 +964,145 @@ chat box ──────────────┘
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Tool calling
|
||||||
|
|
||||||
|
With `LLM_TOOLS_FILE` pointing at a JSON file, the model can call external HTTP
|
||||||
|
services before answering. Tools are plain descriptions of an HTTP request, so
|
||||||
|
any service (weather API, webhook, home automation, internal REST endpoint) can
|
||||||
|
be exposed without touching the code:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": "Current weather and temperature for a city.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "city": { "type": "string" } },
|
||||||
|
"required": ["city"]
|
||||||
|
},
|
||||||
|
"url": "https://wttr.in/{city}?format=3",
|
||||||
|
"method": "GET",
|
||||||
|
"headers": { "X-Api-Key": "…" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `url` may contain `{arg}` placeholders substituted from the call arguments.
|
||||||
|
- `GET` sends the arguments that were **not** consumed by a placeholder as query
|
||||||
|
parameters; `POST` (the default) sends them as a JSON body.
|
||||||
|
- `headers` are optional and sent verbatim (API keys, etc.).
|
||||||
|
|
||||||
|
See [`tools.example.json`](tools.example.json) for a ready-to-use file.
|
||||||
|
|
||||||
|
### Why a client-side parser
|
||||||
|
|
||||||
|
The deployment's OpenAI-compatible server (vLLM) runs **without** a tool parser
|
||||||
|
(`--enable-auto-tool-choice --tool-call-parser …`), so it never returns
|
||||||
|
structured `tool_calls` or `finish_reason: "tool_calls"`. The tools are still
|
||||||
|
advertised in every request (`tools` + `tool_choice: "auto"`), so the Qwen model
|
||||||
|
knows about them and emits the call **as text** in its native format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<tool_call>
|
||||||
|
<function=get_weather>
|
||||||
|
<parameter=city>
|
||||||
|
Paris
|
||||||
|
</parameter>
|
||||||
|
</function>
|
||||||
|
</tool_call>
|
||||||
|
```
|
||||||
|
|
||||||
|
[`src/tools.rs`](src/tools.rs) parses that format client-side and works with any
|
||||||
|
server, no server-side reconfiguration needed.
|
||||||
|
|
||||||
|
### Turn loop
|
||||||
|
|
||||||
|
Each turn runs a bounded loop (`LLM_TOOL_MAX_ITERATIONS`, default `4`):
|
||||||
|
|
||||||
|
1. Stream one reply. A `ToolCallStripper` removes `<tool_call>` blocks from the
|
||||||
|
streamed text — a block that is still incomplete is **held back**, so it is
|
||||||
|
never spoken by the TTS nor shown in the chat box even when split across
|
||||||
|
deltas. The visible text is spoken as usual, so the assistant can say
|
||||||
|
something short ("Let me check…") before calling.
|
||||||
|
2. If the round produced no tool call, the turn ends.
|
||||||
|
3. Otherwise the assistant message (with its native call) plus one `tool` role
|
||||||
|
message per result are appended, and the loop re-prompts the model, which can
|
||||||
|
then answer using the results.
|
||||||
|
|
||||||
|
Tool errors (unknown tool, transport failure, non-2xx status) are returned to
|
||||||
|
the model as text, so it can recover and still produce an answer; the turn never
|
||||||
|
fails because of a tool. Only the *spoken* text is written to the rolling
|
||||||
|
history — the tool exchange stays local to the turn.
|
||||||
|
|
||||||
|
The `tool_call` event is also forwarded to the client for observability:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "tool_call", "name": "get_weather", "arguments": { "city": "Paris" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External audio injection
|
||||||
|
|
||||||
|
The mixer has a dedicated **external channel** (`external_queue`) that plays an
|
||||||
|
audio source injected from the client — a direct audio URL, an Icecast/Shoutcast
|
||||||
|
radio stream, an HLS playlist, or a YouTube link — mixed together with the
|
||||||
|
agent's voice. It is implemented by [`src/media.rs`](src/media.rs)
|
||||||
|
(`MediaSource`) and driven over the WebSocket by the `audio_source` message
|
||||||
|
(see [WebSocket Protocol](#websocket-protocol)). The Mivocero client exposes it
|
||||||
|
in its footer bar.
|
||||||
|
|
||||||
|
### Why `ffmpeg` / `yt-dlp`
|
||||||
|
|
||||||
|
The channel does not decode audio itself: it spawns an external decoder process
|
||||||
|
whose raw PCM output feeds the ring buffer.
|
||||||
|
|
||||||
|
- **`ffmpeg`** (URL / radio / HLS) — reads the source and writes
|
||||||
|
`-f s16le -acodec pcm_s16le -ac 1 -ar 44100 pipe:1`, i.e. exactly the mixer's
|
||||||
|
format (44.1 kHz, 16-bit LE, mono). URL inputs use `-reconnect 1
|
||||||
|
-reconnect_streamed 1 -reconnect_delay_max 5` so a dropped radio connection is
|
||||||
|
retried instead of ending the stream.
|
||||||
|
- **`yt-dlp`** (YouTube) — used only to *resolve* a YouTube page to a direct
|
||||||
|
media URL (`-f bestaudio/best -g --no-playlist`); that URL is then handed to
|
||||||
|
`ffmpeg`. YouTube-specific cookies/extractor tweaks go in `MEDIA_YTDLP_ARGS`
|
||||||
|
(e.g. `--cookies /path/cookies.txt` if the media server asks to "confirm
|
||||||
|
you're not a bot").
|
||||||
|
|
||||||
|
Both binaries are configurable (`MEDIA_FFMPEG_BIN`, `MEDIA_YTDLP_BIN`) and are
|
||||||
|
only required when the external channel is actually used.
|
||||||
|
|
||||||
|
### Backpressure
|
||||||
|
|
||||||
|
A live radio always produces audio at real time, but a bounded VOD source (a
|
||||||
|
YouTube video, a file) decodes far faster than real time. The decode task
|
||||||
|
respects the channel's `remaining_capacity()` and sleeps 20 ms whenever the
|
||||||
|
queue is full, so a fast source is throttled to the mixer's drain rate instead
|
||||||
|
of overflowing the ring buffer (which would drop the oldest samples).
|
||||||
|
|
||||||
|
### Independence from the agent
|
||||||
|
|
||||||
|
The external channel is deliberately **not** cleared by `AudioMixer::flush()`:
|
||||||
|
a barge-in stops the *agent* (TTS + fillers), not the background music. It also
|
||||||
|
does not count towards `AudioMixer::is_active()` — the agent is not considered
|
||||||
|
to be "speaking" because the music is playing. Its volume is a runtime atomic
|
||||||
|
(`AudioMixer::set_external_gain`) so the client slider takes effect on the next
|
||||||
|
mixed frame.
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
| Step | What happens |
|
||||||
|
|---|---|
|
||||||
|
| `play` | Any current source is stopped; state `loading` is emitted. For a YouTube URL `yt-dlp` resolves the direct URL first. |
|
||||||
|
| ready | `ffmpeg` is spawned and reading begins; state `playing` is emitted. |
|
||||||
|
| streaming | Decoded PCM is pushed into `external_queue` under backpressure. |
|
||||||
|
| `stop` / new `play` / disconnect | The `CancellationToken` is cancelled and the child processes are killed (`kill_on_drop(true)`); `stopped` (or `error`) is emitted. |
|
||||||
|
|
||||||
|
A monotonic generation counter guards against a stale task clearing the state of
|
||||||
|
a newer source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Mivocero chat client
|
## Mivocero chat client
|
||||||
|
|
||||||
A ready-to-use speech-to-speech example client is served at **`/mivocero`**
|
A ready-to-use speech-to-speech example client is served at **`/mivocero`**
|
||||||
@@ -685,12 +1122,77 @@ A ready-to-use speech-to-speech example client is served at **`/mivocero`**
|
|||||||
without using the microphone (`{"type":"user_text","text":…}`).
|
without using the microphone (`{"type":"user_text","text":…}`).
|
||||||
- **Interrupt button** — flushes audio and cancels the in-flight reply
|
- **Interrupt button** — flushes audio and cancels the in-flight reply
|
||||||
(`{"type":"interrupt"}`).
|
(`{"type":"interrupt"}`).
|
||||||
|
- **External audio bar** — footer controls to inject a background source by URL
|
||||||
|
(direct audio / radio / YouTube), with **Play** / **Stop** buttons, a volume
|
||||||
|
slider (0–150 %, sent as `EXTERNAL_AUDIO_GAIN` × slider) and a live state
|
||||||
|
label driven by the server's `audio_source` events. Demonstrates the
|
||||||
|
[external audio channel](#external-audio-injection).
|
||||||
|
- **Cue indicators** — a row of three tiles (**Filler**, **Start filler**,
|
||||||
|
**Backchannel**) that flash and bump a session counter each time the server
|
||||||
|
emits the matching cue, driven by the `{"type":"cue"}` events. The detail line
|
||||||
|
shows the backchannel category (or the clip name), so the emission cadence of
|
||||||
|
the latency masks is visible live. Counters reset on each connect.
|
||||||
|
- **Cue methods panel** — a collapsible **Cue methods** section below the tiles
|
||||||
|
that documents the method used by each cue (when it fires, how a clip is
|
||||||
|
chosen) and shows the **live parameters** reported by the server in the
|
||||||
|
`{"type":"cues"}` event sent at session start: filler delay / gains / clip
|
||||||
|
count, start-filler go-delay, and the backchannel gain, min-speech, interval,
|
||||||
|
jitter, pause window, probability, volume jitter, per-turn cap and categories
|
||||||
|
— all read from the effective runtime configuration (no hard-coded values).
|
||||||
|
- **Idle auto-disconnect** — the session closes itself after **2 minutes**
|
||||||
|
(`IDLE_TIMEOUT_MS`) without any traffic in either direction. The timer is
|
||||||
|
reset by every server event *and* audio frame received, and by every user
|
||||||
|
action sent (typed message, Stop, media controls) — so an ongoing reply or a
|
||||||
|
playing external source counts as activity. On expiry the WebSocket is closed,
|
||||||
|
the microphone is released, an explanatory message is appended to the chat and
|
||||||
|
the **Connect** button becomes available again.
|
||||||
|
|
||||||
Open `http://localhost:8787/mivocero` (or `https://…` for remote microphone
|
Open `http://localhost:8787/mivocero` (or `https://…` for remote microphone
|
||||||
access — see [HTTPS](#https-microphone-access)).
|
access — see [HTTPS](#https-microphone-access)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Session audit log
|
||||||
|
|
||||||
|
Every WebSocket session can be recorded to its own structured **JSONL** file —
|
||||||
|
one JSON object per line — under `SESSION_AUDIT_DIR` (default `logs/sessions`),
|
||||||
|
named `s-<epoch_ms>-<seq>.jsonl`. It is meant for post-mortem analysis and for
|
||||||
|
tuning the latency knobs (why a filler fired, when a cue landed, how long the
|
||||||
|
first audio took).
|
||||||
|
|
||||||
|
Each record carries a wall-clock timestamp, the elapsed time since the session
|
||||||
|
started, an event discriminator and a payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ts":1729170000000,"elapsed_ms":12,"event":"session_start","data":{"config":{…},"runtime":{…}}}
|
||||||
|
{"ts":1729170000123,"elapsed_ms":135,"event":"control","data":{"type":"user_text","text":"…"}}
|
||||||
|
{"ts":1729170000456,"elapsed_ms":468,"event":"out","data":{"type":"assistant_delta","text":"Hola,"}}
|
||||||
|
{"ts":1729170000500,"elapsed_ms":512,"event":"out","data":{"type":"cue","kind":"start_filler","name":"sf02"}}
|
||||||
|
{"ts":1729170009000,"elapsed_ms":9012,"event":"session_end","data":{"reason":"client_disconnected","counts":{…}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
| `event` | When | `data` |
|
||||||
|
|---|---|---|
|
||||||
|
| `session_start` | Session opens | `config` — the full non-secret configuration snapshot; `runtime` — which of STT / VAD / tokenizer / LLM are actually active (plus the LLM model). |
|
||||||
|
| `control` | `user_text` control message received | The text turn. |
|
||||||
|
| `out` | **Every** event sent to the client | The exact WS payload — `user`, `assistant_delta`, `assistant_end`, `tool_call`, `cue`, `error`, `audio_source` state, … Recorded at the single sender tap, so nothing sent to the browser is missed. |
|
||||||
|
| `interrupt` | Client barge-in (`interrupt` JSON or legacy `INTERRUPT`) | `source` (`client` / `client_legacy`). |
|
||||||
|
| `barge_in` | VAD-driven barge-in | `prob` (onset speech probability). |
|
||||||
|
| `vad_speech_start` / `vad_speech_end` | Utterance boundaries | `prob`; `duration_ms`, `avg_prob`. |
|
||||||
|
| `media` | External audio control | `action` (`play`/`stop`/`volume`) with `url` / `gain`. |
|
||||||
|
| `reference` | Voice-cloning reference change | `action` (`set`/`clear`) and the audio path. |
|
||||||
|
| `speak` | Direct TTS request (playground / fallback text) | `text`. |
|
||||||
|
| `language_switch` | Detected language changed | `from`, `to`, `generated` (whether clip banks were synthesized). |
|
||||||
|
| `error` | Transport / receiver error | `where`, `text`. |
|
||||||
|
| `session_end` | Session closes | `reason`, `duration_ms`, `session_id`, `file`, `counts` (per-event totals). |
|
||||||
|
|
||||||
|
The sink is **best-effort**: if the file cannot be opened, or a write fails, the
|
||||||
|
session runs unchanged (the failure is logged once in the tracing output and the
|
||||||
|
sink then goes quiet). Files are appended and flushed after every record, so the
|
||||||
|
log survives a crash or a `pm2 restart`. The directory is git-ignored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## WebSocket Protocol
|
## WebSocket Protocol
|
||||||
|
|
||||||
Connect to `ws://<host>:8787/ws`.
|
Connect to `ws://<host>:8787/ws`.
|
||||||
@@ -702,6 +1204,9 @@ Connect to `ws://<host>:8787/ws`.
|
|||||||
| **Binary** | Raw 16-bit LE mono PCM microphone frames (44.1 kHz). Resampled to 16 kHz server-side for STT. |
|
| **Binary** | Raw 16-bit LE mono PCM microphone frames (44.1 kHz). Resampled to 16 kHz server-side for STT. |
|
||||||
| **Text — `{"type":"user_text","text":"…"}`** | Text turn from the chat box: runs the same LLM → TTS path as a spoken utterance (no STT). |
|
| **Text — `{"type":"user_text","text":"…"}`** | Text turn from the chat box: runs the same LLM → TTS path as a spoken utterance (no STT). |
|
||||||
| **Text — `{"type":"interrupt"}`** | Barge-in: cancel the in-flight LLM reply / TTS and flush all queued audio. |
|
| **Text — `{"type":"interrupt"}`** | Barge-in: cancel the in-flight LLM reply / TTS and flush all queued audio. |
|
||||||
|
| **Text — `{"type":"audio_source","action":"play","url":"…"}`** | Inject an external audio source (direct URL / radio / HLS / YouTube) into the mixer's external channel, replacing any current one. |
|
||||||
|
| **Text — `{"type":"audio_source","action":"stop"}`** | Stop the external source and clear the channel. |
|
||||||
|
| **Text — `{"type":"audio_source","action":"volume","gain":0.6}`** | Set the external channel gain (0..1) at runtime. |
|
||||||
| **Text — `INTERRUPT`** *(also `interrupt`, `BAARGEIN`, `bargein`)* | Legacy barge-in form (kept for the playground). |
|
| **Text — `INTERRUPT`** *(also `interrupt`, `BAARGEIN`, `bargein`)* | Legacy barge-in form (kept for the playground). |
|
||||||
| **Text — `SET_REFERENCE:<path>\|<text>`** | Set/change the voice-cloning reference. `<text>` may be omitted to re-use the sibling `.txt`. |
|
| **Text — `SET_REFERENCE:<path>\|<text>`** | Set/change the voice-cloning reference. `<text>` may be omitted to re-use the sibling `.txt`. |
|
||||||
| **Text — `CLEAR_REFERENCE`** | Disable voice cloning. |
|
| **Text — `CLEAR_REFERENCE`** | Disable voice cloning. |
|
||||||
@@ -715,7 +1220,11 @@ Connect to `ws://<host>:8787/ws`.
|
|||||||
| **Text — `{"type":"user","text":"…"}`** | The user's turn (STT transcript or chat-box message) — rendered in the chat box. |
|
| **Text — `{"type":"user","text":"…"}`** | The user's turn (STT transcript or chat-box message) — rendered in the chat box. |
|
||||||
| **Text — `{"type":"assistant_delta","text":"…"}`** | Streaming chunk of the assistant reply (chain-of-thought already removed). |
|
| **Text — `{"type":"assistant_delta","text":"…"}`** | Streaming chunk of the assistant reply (chain-of-thought already removed). |
|
||||||
| **Text — `{"type":"assistant_end"}`** | The assistant turn is complete. |
|
| **Text — `{"type":"assistant_end"}`** | The assistant turn is complete. |
|
||||||
|
| **Text — `{"type":"tool_call","name":"…","arguments":{…}}`** | The model called a tool; the call has been executed (result is not echoed in the chat box, only in the model's next round). Purely informational. |
|
||||||
| **Text — `{"type":"error","text":"…"}`** | LLM request failed; the turn was aborted. |
|
| **Text — `{"type":"error","text":"…"}`** | LLM request failed; the turn was aborted. |
|
||||||
|
| **Text — `{"type":"audio_source","state":"…","url":"…"}`** | External audio channel state change: `loading` (decoding is being set up / YouTube URL resolving), `playing` (PCM is streaming), `stopped`, or `error` (with a `text` message). |
|
||||||
|
| **Text — `{"type":"cue","kind":"…","name":"…","category":"…"}`** | A latency-mask / backchannel cue was actually emitted into the mixer: `kind` is `filler` (end-of-turn), `start_filler` (turn start) or `backchannel` (during user speech); `name` is the clip name and `category` (backchannel only) its category. Fired only when the cue really plays — a skipped cue sends nothing. Drives the demo's cue indicators. |
|
||||||
|
| **Text — `{"type":"cues","lang":"…","config":{…}}`** | Sent once at session start: the effective policy of the three cues for the active language — the filler parameters (`delay_ms`, gains, clip count), the start-filler `go_delay_ms` and the full backchannel configuration (gain, min-speech, interval, jitter, pause window, probability, volume jitter, per-turn cap, categories). Purely informational; drives the demo's **Cue methods** panel so it shows the live values instead of hard-coded ones. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -736,23 +1245,32 @@ Constants are defined in [`src/config.rs`](src/config.rs):
|
|||||||
### Mixer & jitter buffering ([`src/audio/mixer.rs`](src/audio/mixer.rs))
|
### Mixer & jitter buffering ([`src/audio/mixer.rs`](src/audio/mixer.rs))
|
||||||
|
|
||||||
- Mixing formula:
|
- Mixing formula:
|
||||||
`mixed = tts + (filler × filler_gain) + (backchannel × BACKCHANNEL_GAIN)`.
|
`mixed = tts + ((filler + start_filler) × filler_gain) + (backchannel × BACKCHANNEL_GAIN) + (external × external_gain)`.
|
||||||
The filler gain is `1.0` when TTS is idle, else `filler_gain_duck` (default
|
The filler gain is `1.0` when TTS is idle, else `filler_gain_duck` (default
|
||||||
`0.15`); the backchannel gain is fixed (`BACKCHANNEL_GAIN`, default `0.35`).
|
`0.15`); the backchannel gain is fixed (`BACKCHANNEL_GAIN`, default `0.35`).
|
||||||
Output is clamped to `i16`.
|
The start-filler channel is summed with the filler, so it shares the same gain.
|
||||||
|
The external channel gain starts at `EXTERNAL_AUDIO_GAIN` (default `0.6`) and
|
||||||
|
is adjustable at runtime (`AudioMixer::set_external_gain`). Output is clamped
|
||||||
|
to `i16`.
|
||||||
- **Pre-buffer:** TTS playback does not start until the queue holds
|
- **Pre-buffer:** TTS playback does not start until the queue holds
|
||||||
≥ `TTS_PREBUFFER_MS` (default **450 ms**) of synthesized audio. It absorbs
|
≥ `TTS_PREBUFFER_MS` (default **550 ms**) of synthesized audio. It absorbs
|
||||||
uneven TTS chunk arrival; lower it to start speaking sooner, raise it for fewer
|
uneven TTS chunk arrival; lower it to start speaking sooner, raise it for fewer
|
||||||
drop-outs. With pipelined synthesis (above) the pre-buffer only has to cover
|
drop-outs. Note: Drama-3's *granularity* is set by the server's vocoder window
|
||||||
arrival jitter, not the TTS engine's per-request start-up, so it can stay
|
(`--vocoder.factory.stream_stride` / `stream_followup_stride`, in codec frames
|
||||||
small. Note: Drama-3 delivers ~1.04× real time, and its *granularity* is set by
|
of 2048 samples = 46.4 ms). With the deployment used here (stride `4`, followup
|
||||||
the server's vocoder window
|
`8`, overlap `16`) the first window is 4 frames = 0.186 s and the steady windows
|
||||||
(`--vocoder.factory.stream_followup_stride`, in codec frames of 2048 samples =
|
are 8 frames = 0.372 s, delivered ~0.35 s apart (the server runs at ~0.95× real
|
||||||
46.4 ms). With stride `16` and ramp `"[10,12,16]"` the first window is 10
|
time). Because `TTFA ≈ TTFB + (prebuffer − window)`, any pre-buffer in
|
||||||
frames = 0.464 s, so `450 ms` starts playback on that very first window (the
|
`(186, 557]` ms primes on the **same** second window → TTFA ≈ `0.73 s`; the
|
||||||
steady state then delivers a 0.743 s burst every ~0.69 s). With the upstream
|
default `550 ms` takes that whole cushion at no extra latency. A pre-buffer
|
||||||
default (`64`, a 2.97 s burst every ~2.7 s) gapless playback would instead
|
below ~`186 ms` starts playback on the first window (TTFA ≈ `0.38 s`) but leaves
|
||||||
require a ~3 s pre-buffer.
|
almost no margin: under concurrent TTS requests one GPU serves several requests
|
||||||
|
and the per-request throughput dips below real time (~`1.5×` vs ~`0.94×` alone),
|
||||||
|
so every chunk seam starves the queue and pauses playback → an audible
|
||||||
|
micro-gap at the start of each sentence. With the upstream default (`64`, a
|
||||||
|
2.97 s burst every ~2.7 s) gapless playback would instead require a ~3 s
|
||||||
|
pre-buffer; a large constant stride (`16`) both raises the TTFB (~1.2 s) and
|
||||||
|
bursts, and a growing ramp gaps the tail while the small early windows drain.
|
||||||
- **Resilient resume:** the full pre-buffer is only required at the *first*
|
- **Resilient resume:** the full pre-buffer is only required at the *first*
|
||||||
playback of a reply. If the queue empties mid-reply (after the 200 ms drain
|
playback of a reply. If the queue empties mid-reply (after the 200 ms drain
|
||||||
grace) playback pauses, but resumes as soon as *any* audio is queued again —
|
grace) playback pauses, but resumes as soon as *any* audio is queued again —
|
||||||
@@ -774,13 +1292,15 @@ audio-agent-engine/
|
|||||||
├── server.sh # start / stop / restart / status / logs / cert
|
├── server.sh # start / stop / restart / status / logs / cert
|
||||||
├── certs/ # self-signed TLS cert+key (generated)
|
├── certs/ # self-signed TLS cert+key (generated)
|
||||||
├── UPDATES.md # changelog (English, newest-first)
|
├── UPDATES.md # changelog (English, newest-first)
|
||||||
├── assets/fillers/ # *.raw filler PCM (16-bit LE mono, 44.1 kHz; round-robin)
|
├── tools.example.json # tool definitions for LLM tool calling (copy/edit)
|
||||||
├── assets/backchannel/ # *.raw backchannel PCM (16-bit LE mono, 44.1 kHz)
|
├── assets/fillers/<lang>/ # *.raw filler PCM (16-bit LE mono, 44.1 kHz; round-robin), one dir per language
|
||||||
|
├── assets/start_filler/<lang>/ # sf01.raw … turn-start start-filler clips + texts.txt index
|
||||||
|
├── assets/backchannel/<lang>/ # bc01.raw … backchannel clips + texts.txt index
|
||||||
├── models/ # Parakeet ONNX + Silero VAD + tokenizer
|
├── models/ # Parakeet ONNX + Silero VAD + tokenizer
|
||||||
└── src/
|
└── src/
|
||||||
├── main.rs # Axum server, routes, graceful shutdown
|
├── main.rs # Axum server, routes, graceful shutdown
|
||||||
├── config.rs # env-var config + audio constants
|
├── config.rs # env-var config + audio constants
|
||||||
├── assets.rs # startup clip bootstrap (auto-generate fillers/backchannel)
|
├── assets.rs # startup clip bootstrap (auto-generate fillers/backchannel/start filler)
|
||||||
├── tls.rs # optional HTTPS/WSS termination (rustls)
|
├── tls.rs # optional HTTPS/WSS termination (rustls)
|
||||||
├── onnx.rs # shared process-wide ONNX Runtime environment
|
├── onnx.rs # shared process-wide ONNX Runtime environment
|
||||||
├── audio/ # ring_buffer, mixer
|
├── audio/ # ring_buffer, mixer
|
||||||
@@ -791,6 +1311,9 @@ audio-agent-engine/
|
|||||||
├── stt/ # Parakeet ONNX + tokenizer + worker
|
├── stt/ # Parakeet ONNX + tokenizer + worker
|
||||||
├── vad/ # Silero VAD v5 + onset/offset state machine
|
├── vad/ # Silero VAD v5 + onset/offset state machine
|
||||||
├── llm.rs # OpenAI-compatible chat-completions client (streaming + think stripping)
|
├── llm.rs # OpenAI-compatible chat-completions client (streaming + think stripping)
|
||||||
|
├── tools.rs # configurable HTTP tool calling (native <tool_call> parser + executor)
|
||||||
|
├── media.rs # external audio source (URL / radio / YouTube) via ffmpeg + yt-dlp
|
||||||
|
├── audit.rs # per-session JSONL audit log (config snapshot, events, summary)
|
||||||
├── orchestrator/ # turn_manager (EOU/STT → LLM → filler → TTS) + backchannel
|
├── orchestrator/ # turn_manager (EOU/STT → LLM → filler → TTS) + backchannel
|
||||||
├── ws/ # handler, session (per-connection audio loop)
|
├── ws/ # handler, session (per-connection audio loop)
|
||||||
└── web/ # embedded playground.html + mivocero.html
|
└── web/ # embedded playground.html + mivocero.html
|
||||||
|
|||||||
+807
@@ -5,6 +5,813 @@ Entries are written in English and ordered newest-first.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-10-17 — Cue methods panel in the Mivocero client (live cue policy)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`cues` WebSocket event** — sent once at session start by
|
||||||
|
`src/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
|
||||||
|
backchannel `pause_max_ms` is capped to the VAD EOU window exactly like
|
||||||
|
`BackchannelScheduler::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
|
||||||
|
the `cues` event, 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 `.env` change 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`, id `s-<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` — inbound `user_text` turns;
|
||||||
|
- `out` — **every** event sent to the client, tapped at the single sender
|
||||||
|
point in `src/ws/session.rs` so nothing is missed (`user`,
|
||||||
|
`assistant_delta`, `assistant_end`, `tool_call`, `cue`, `error`,
|
||||||
|
`audio_source` state, …);
|
||||||
|
- `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` (default `true`) and
|
||||||
|
`SESSION_AUDIT_DIR` (default `logs/sessions`); documented in `.env`,
|
||||||
|
`.env.example` and the README. `logs/` is git-ignored.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `TurnManager::new` takes an `Arc<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.
|
||||||
|
- **`BackchannelScheduler::maybe_emit`** now returns `(name, category)` instead
|
||||||
|
of just the name, so the backchannel cue carries its category.
|
||||||
|
- **Mivocero cue indicators** — `src/web/mivocero.html` gained a row of three
|
||||||
|
tiles (**Filler**, **Start filler**, **Backchannel**) that flash (colour-coded
|
||||||
|
dot + border glow) and bump a per-session counter on every `cue` event, 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_DELAY` and 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>/` via [`assets::lang_dir`](src/assets.rs) instead 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_dir`](src/assets.rs) doc 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 `*.raw` files (and `texts.txt`) into the matching language
|
||||||
|
subdirectory by hand — this was done for the `es` deployment.
|
||||||
|
- 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, or `und`) and `generate_backchannel_texts` /
|
||||||
|
`generate_filler_texts` / `generate_start_filler_texts` (LLM fallback for
|
||||||
|
languages absent from the tables).
|
||||||
|
It also defines `LangBanks` (a language's loaded clip banks, including its
|
||||||
|
start-filler bank) and `LanguageState` (the session's active language plus a
|
||||||
|
generation counter, kept in a parking-lot `RwLock`).
|
||||||
|
- **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` (default `es`), `LANG_DETECT_ENABLED`
|
||||||
|
(default `true`), `LANG_MIN_CHARS` (default `12`), `LANG_REDETECT_EVERY`
|
||||||
|
(default `5`) and `LANG_LLM_FALLBACK_ENABLED` (default `true`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`src/llm.rs`** — new `LlmClient::simple_chat` for short, non-streaming
|
||||||
|
auxiliary requests (explicit system + user message, **no** tools), used by the
|
||||||
|
language detection and phrase-generation calls.
|
||||||
|
- **`src/config.rs`** — five new `LANG_*` fields, parsed in `from_env`.
|
||||||
|
- **`src/assets.rs`** — `lang_dir` helper plus `ensure_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
|
||||||
|
into `map_clips`, and `BackchannelScheduler::retarget` re-points a live
|
||||||
|
scheduler to a new bank when the language changes. `DEFAULT_CATEGORY` is now
|
||||||
|
`pub(crate)` so `lang.rs` can reuse it.
|
||||||
|
- **`src/orchestrator/turn_manager.rs`** — `TurnManager` now holds an
|
||||||
|
`Arc<LanguageState>` instead of a single filler bank; `next_filler` and the
|
||||||
|
start filler read from the active language (`schedule_start_filler` picks from
|
||||||
|
`langs.current().start_filler`). A new `maybe_detect_language` runs on each
|
||||||
|
user turn (first turn + every `LANG_REDETECT_EVERY`) and spawns
|
||||||
|
`switch_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 shared `LanguageState`; the mic task polls
|
||||||
|
the language generation counter each frame and calls `retarget` on 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_ENABLED` is 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`](src/web/mivocero.html)) —
|
||||||
|
the session closes itself after `IDLE_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 calls `disconnect()`, which closes the WebSocket and releases the
|
||||||
|
microphone.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Reconnect after close** — `ws.onclose` now clears the `ws` reference.
|
||||||
|
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_MS` in the HTML); change
|
||||||
|
it there and rebuild (the page is embedded with `include_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: a `ToolRegistry` (builds the OpenAI `tools` array,
|
||||||
|
executes a call over HTTP), a client-side parser for the model's **native**
|
||||||
|
`<tool_call>` format, and a `ToolCallStripper` that 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) and `send_webhook` (POST with a custom
|
||||||
|
header), covering both request shapes.
|
||||||
|
- **New env vars** — `LLM_TOOLS_ENABLED` (auto: on when `LLM_TOOLS_FILE` is set),
|
||||||
|
`LLM_TOOLS_FILE`, `LLM_TOOL_MAX_ITERATIONS` (default `4`),
|
||||||
|
`LLM_TOOL_TIMEOUT_SECS` (default `15`) and `LLM_TOOL_RESULT_MAX_CHARS`
|
||||||
|
(default `2000`).
|
||||||
|
- **`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
|
||||||
|
|
||||||
|
- **`LlmClient`** carries an optional `Arc<ToolRegistry>`
|
||||||
|
([`src/llm.rs`](src/llm.rs)); when present, every request advertises
|
||||||
|
`tools` + `tool_choice:"auto"`, so the model knows which functions it may
|
||||||
|
call. The registry is built by `load_tools(config)`; a missing or invalid file
|
||||||
|
logs a warning and leaves tool calling disabled without breaking the pipeline.
|
||||||
|
- **[`TurnManager::run_llm_turn`](src/orchestrator/turn_manager.rs)** now runs a
|
||||||
|
bounded tool loop: each round streams a reply through the `ToolCallStripper`;
|
||||||
|
if a call was emitted, the assistant message (with its native call) plus one
|
||||||
|
`tool` message per result are appended and the model is re-prompted, up to
|
||||||
|
`LLM_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
|
||||||
|
structured `tool_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 `.env` so 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.
|
||||||
|
- `GET` sends arguments not consumed by a `{arg}` placeholder as query
|
||||||
|
parameters; `POST` sends them as a JSON body. `headers` are sent verbatim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-16 — External audio channel: inject a URL / radio / YouTube source
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`src/media.rs`** — new [`MediaSource`](src/media.rs) module 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. `ffmpeg` decodes 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 with `yt-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** — `AudioMixer` gains `external_queue` and a runtime
|
||||||
|
gain ([`src/audio/mixer.rs`](src/audio/mixer.rs)). The mixing formula becomes
|
||||||
|
`… + (external × external_gain)`; `set_external_gain` updates the gain (an
|
||||||
|
atomic, so it takes effect on the next frame without rebuilding the mixer).
|
||||||
|
The external queue is **not** cleared by `flush()` and does not count towards
|
||||||
|
`is_active()`: a barge-in stops the agent, not the background audio.
|
||||||
|
- **New env vars** — `EXTERNAL_AUDIO_GAIN` (default `0.6`),
|
||||||
|
`MEDIA_FFMPEG_BIN` (default `ffmpeg`), `MEDIA_YTDLP_BIN` (default `yt-dlp`) and
|
||||||
|
`MEDIA_YTDLP_ARGS` (extra `yt-dlp` args, e.g. `--cookies …`).
|
||||||
|
- **WebSocket protocol** — new `audio_source` message:
|
||||||
|
`{"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`](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`](src/web/mivocero.html)).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- [`TurnManager::set_external_gain`](src/orchestrator/turn_manager.rs) forwards
|
||||||
|
the client volume to the mixer.
|
||||||
|
- New module declared in [`src/main.rs`](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: `ffmpeg` for URL/radio/HLS and
|
||||||
|
`yt-dlp` for YouTube. YouTube may require cookies in some environments; pass
|
||||||
|
them via `MEDIA_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`** (default `0.45`), **`TTS_TOP_P`** (default `0.75`) and
|
||||||
|
**`TTS_TOP_K`** (unset) — optional sampling overrides forwarded in the
|
||||||
|
`/v1/audio/speech` payload by the `fish` engine. An empty value omits the field
|
||||||
|
so the server default applies. New `env_opt_f32` / `env_opt_u32` helpers in
|
||||||
|
[`src/config.rs`](src/config.rs) implement 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`](src/tts/fish_audio.rs)). Empty disables
|
||||||
|
the prefix.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- [`SpeechRequest`](src/tts/fish_audio.rs) gains optional `temperature`, `top_p`
|
||||||
|
and `top_k` fields (serialized only when set); the request body now uses the
|
||||||
|
styled text as `input`. Startup logs `TTS 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_penalty` is deliberately **not** exposed: this server rejects any
|
||||||
|
value other than `1.0` (`Drama 3 request: repetition_penalty must be 1.0`).
|
||||||
|
`min_p`, `seed` and a nested `sampling_params` are rejected too.
|
||||||
|
- The voice-cloning reference (`solis.wav`) is left unchanged for now — its
|
||||||
|
"locutor profesional" prosody is a further, separate lever.
|
||||||
|
- **`.env` gotcha surfaced during this change.** `dotenvy` fails 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) while `server.sh` still reports the scheme from its own `.env` grep. 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_emit`](src/orchestrator/backchannel.rs) now 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 once `silence_ms()` reaches
|
||||||
|
it (new `pause_target_ms` field, 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 exactly
|
||||||
|
`BACKCHANNEL_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 (observed `pause_ms` of `96`, `128`, `192`, …).
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- No new environment variables: the behaviour is driven by the existing
|
||||||
|
`BACKCHANNEL_PAUSE_MIN_MS` / `BACKCHANNEL_PAUSE_MAX_MS` window.
|
||||||
|
- Because the VAD advances in 32 ms windows, the emitted `pause_ms` is 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`** (default `1200`), **`BACKCHANNEL_PAUSE_MIN_MS`**
|
||||||
|
(default `96`), **`BACKCHANNEL_PAUSE_MAX_MS`** (default `260`),
|
||||||
|
**`BACKCHANNEL_PROB`** (default `0.65`) and **`BACKCHANNEL_VOLUME_JITTER`**
|
||||||
|
(default `0.2`) — new `.env` knobs (`.env`, `.env.example`,
|
||||||
|
[`BackchannelSettings`](src/orchestrator/backchannel.rs)) that shape the cue
|
||||||
|
timing, spacing, hit rate and loudness drift.
|
||||||
|
- **Category prefix** in `BACKCHANNEL_TEXTS`: a line may be written
|
||||||
|
`category::phrase` (e.g. `ack::[calm] Mm-hmm...`, `agree::[agreeing] Claro...`);
|
||||||
|
[`parse_texts`](src/orchestrator/backchannel.rs) stores the category alongside
|
||||||
|
the phrase and defaults to `ack` when the prefix is absent.
|
||||||
|
- [`VadDetector::silence_ms`](src/vad/silero.rs) — 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`](src/orchestrator/turn_manager.rs) — read accessor for the
|
||||||
|
clip names, used to map the bank order onto the phrase/category list.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- [`BackchannelScheduler::maybe_emit`](src/orchestrator/backchannel.rs) now takes
|
||||||
|
the VAD micro-pause length and fires a cue **only inside a
|
||||||
|
`PAUSE_MIN_MS..PAUSE_MAX_MS` window** (the upper bound is capped just below
|
||||||
|
`VAD_MIN_SILENCE_MS` so it never collides with end-of-utterance), **at most once
|
||||||
|
per pause** (latched with `pause_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 is
|
||||||
|
`INTERVAL_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_JITTER`** before being pushed to
|
||||||
|
`backchannel_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. `ack` then `agree`), falling back to a random clip.
|
||||||
|
- `BACKCHANNEL_MAX_PER_TURN` default 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_TURN` default (now `3`).
|
||||||
|
- Nested clamps in `from_env`: `interval_ms >= 500`, `pause_max >= pause_min`,
|
||||||
|
and `gain`/`prob`/`volume_jitter` are clamped to sane ranges.
|
||||||
|
- A debug line is logged only when a cue is actually emitted:
|
||||||
|
`backchannel emitted during user speech … pause_ms=…` (enable
|
||||||
|
`audio_agent_engine::orchestrator=debug` in `RUST_LOG`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-16 — Backchannel becomes a random phrase bank driven by `BACKCHANNEL_TEXTS`
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`BACKCHANNEL_TEXTS`** (`.env`, `.env.example`,
|
||||||
|
[`BackchannelSettings`](src/orchestrator/backchannel.rs)): 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. `.env` ships 8
|
||||||
|
Spanish phrases (`[calm] Mm-hmm...`, `[attentive] Ajá...`, …); the Rust default
|
||||||
|
is [`DEFAULT_BACKCHANNEL_TEXTS`](src/orchestrator/backchannel.rs).
|
||||||
|
- [`assets::ensure_backchannel_clips`](src/assets.rs), implemented on top of the
|
||||||
|
new generic [`assets::ensure_text_bank_clips`](src/assets.rs): synthesizes **one
|
||||||
|
clip per phrase** at startup (`bc01`, `bc02`, …, `TEXT_BANK_CONCURRENCY = 4` at
|
||||||
|
a time) and removes clips left over from a previous list. The list the current
|
||||||
|
clips were made from is stored in `BACKCHANNEL_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_emit`](src/orchestrator/backchannel.rs) picks the
|
||||||
|
clip **at random** ([`FillerBank::random`](src/orchestrator/turn_manager.rs),
|
||||||
|
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. The
|
||||||
|
`next_index` cursor was dropped.
|
||||||
|
- `assets::ensure_start_filler_clips` is now a thin wrapper over
|
||||||
|
`ensure_text_bank_clips`, so the start filler and the backchannel share one
|
||||||
|
implementation. Its logs gained a `label` field
|
||||||
|
(`refreshing backchannel clips at startup …`).
|
||||||
|
- The backchannel no longer uses the fixed `assets::BACKCHANNEL_CLIPS` list
|
||||||
|
(removed); its defaults moved to `DEFAULT_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`) to `bc01`…**removes** the four legacy files on the first
|
||||||
|
restart after this change (logged as `removed stale backchannel clip`).
|
||||||
|
- `BACKCHANNEL_GAIN`, `BACKCHANNEL_MIN_SPEECH_MS`, `BACKCHANNEL_INTERVAL_MS` and
|
||||||
|
`BACKCHANNEL_MAX_PER_TURN` are unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-16 — Fix: start filler fired on every turn (gate on chunk arrival, not playback)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- [`TurnManager::schedule_start_filler`](src/orchestrator/turn_manager.rs) gated the
|
||||||
|
cue on `AudioMixer::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 a `START_FILLER_GO_DELAY` of 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`](src/audio/mixer.rs), 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. Both `tts_audio_arrived` and
|
||||||
|
`tts_playback_started` are reset by `mark_stream_start` / `flush`.
|
||||||
|
A fast reply that produces audio within `START_FILLER_GO_DELAY` therefore
|
||||||
|
suppresses the cue, as intended.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The skip log line is now `first response audio already arrived — start filler
|
||||||
|
skipped` (was `response 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=1200` now means what the `.env`
|
||||||
|
comment 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 logged `first response audio already arrived — start filler skipped`
|
||||||
|
with no `queued start filler` for 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`](src/orchestrator/turn_manager.rs)
|
||||||
|
(replacing `play_start_filler`) sleeps `START_FILLER_GO_DELAY` after the turn is
|
||||||
|
dispatched and queues a random phrase **only if the first response audio has not
|
||||||
|
started playing** (`AudioMixer::tts_playback_started` still `false`); otherwise
|
||||||
|
the cue is dropped. *(The gate was later changed to `tts_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's `CancellationToken`, and
|
||||||
|
`mixer.flush` clears it once queued.
|
||||||
|
- The scheduling now happens in `run_llm_turn` / `dispatch_tts` next to
|
||||||
|
`schedule_filler` (where the turn's cancellation token and `mark_stream_start`
|
||||||
|
live) instead of at the top of `handle_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`, default `1200` ms): 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`](src/config.rs)) replaces the single `START_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. `.env` ships 22 phrases.
|
||||||
|
- [`assets::ensure_start_filler_clips`](src/assets.rs): synthesizes **one clip per
|
||||||
|
phrase** at startup (`sf01`, `sf02`, …, generated
|
||||||
|
`START_FILLER_CONCURRENCY = 4` at a time) and removes clips left over from a
|
||||||
|
previous list. The list the current clips were made from is stored in
|
||||||
|
`START_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`](src/orchestrator/turn_manager.rs): uniform pick over the
|
||||||
|
bank using a per-bank xorshift64 state seeded from the clock;
|
||||||
|
[`TurnManager::play_start_filler`](src/orchestrator/turn_manager.rs) now uses it,
|
||||||
|
so the acknowledgment differs from turn to turn instead of always repeating the
|
||||||
|
same sentence. *(Caller renamed to `schedule_start_filler` by 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
|
||||||
|
|
||||||
|
- `force` was dropped from [`assets::ensure_clips`](src/assets.rs): the filler /
|
||||||
|
backchannel banks keep the "generate only if missing" behaviour, and the start
|
||||||
|
filler has its own list-driven bootstrap.
|
||||||
|
- [`server.sh`](server.sh) startup 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`), and
|
||||||
|
`START_FILLER_ENABLED=false` disables 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`](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_TEXT` became the `START_FILLER_TEXTS` list and the
|
||||||
|
clip is now picked at random.)*
|
||||||
|
- [`AudioMixer`](src/audio/mixer.rs): new `start_filler_queue` channel, mixed as
|
||||||
|
`mixed = tts + ((filler + start_filler) × filler_gain) + (backchannel ×
|
||||||
|
BACKCHANNEL_GAIN)`, so the start filler shares the filler gain (idle `1.0`,
|
||||||
|
ducked `0.15` under TTS). Included in `is_active()` and cleared by `flush()`.
|
||||||
|
- [`TurnManager::play_start_filler`](src/orchestrator/turn_manager.rs), called
|
||||||
|
from `handle_user_turn` right after the in-flight reply is cancelled.
|
||||||
|
- Startup bootstrap synthesizes `START_FILLER_DIR/start_filler.raw` with the
|
||||||
|
configured voice if it is missing ([`src/assets.rs`](src/assets.rs)); the clip
|
||||||
|
is preloaded into its own [`FillerBank`](src/orchestrator/turn_manager.rs),
|
||||||
|
kept out of the round-robin `FILLERS_DIR` bank.
|
||||||
|
|
||||||
|
### 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 editing `START_FILLER_TEXT` took
|
||||||
|
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=false` disables 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_MS` raised `170` → `550`** (`.env`, `.env.example`). With the
|
||||||
|
current vocoder windows (first `185.8` ms, steady `371.6` ms) any pre-buffer in
|
||||||
|
`(186, 557]` ms primes playback on the *same* second window, so `550 ms` takes
|
||||||
|
the entire ~557 ms cushion **at no extra TTFA** (~`0.73 s`).
|
||||||
|
- **`LLM_STREAM_CHUNK_CHARS` raised `120` → `240`** (`.env`, `.env.example`). The
|
||||||
|
first-chunk threshold stays at `20` (early flush preserved), but every following
|
||||||
|
chunk is now twice as long → **fewer TTS requests, hence fewer chunk seams**.
|
||||||
|
- **README** (`Mixer & jitter buffering`, the `TTS_PREBUFFER_MS` /
|
||||||
|
`LLM_STREAM_CHUNK_CHARS` tables and the TTFA note) updated to the new defaults
|
||||||
|
and rationale. No Rust code changed — both values are read from `.env` at
|
||||||
|
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 logs
|
||||||
|
`TTS queue underrun — pausing playback until audio resumes` (94 occurrences in
|
||||||
|
the sampled log; 0 `running low` warnings), then restarts with `need = 1` → an
|
||||||
|
audible micro-gap at the start of the next sentence.
|
||||||
|
- **Why the queue empties:** with `TTS_PIPELINE_DEPTH = 3` on 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. A `170 ms` cushion has no margin
|
||||||
|
against that; `550 ms` does, 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 `16384` samples
|
||||||
|
(`queue_len=16384`, `prebuffer=7497` = 170 ms) with two concurrent requests
|
||||||
|
(`text_len=119` and `19`), 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 ms` cushion 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`, pm2 `start-drama3`) now
|
||||||
|
carries more decoder context per window.** The previous config (stride `4`,
|
||||||
|
flat ramp `"[4,4,4]"`, overlap `8`, crossfade `256`) 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_MS` lowered `450` → `170`** (`.env`, `.env.example`,
|
||||||
|
[`src/config.rs`](src/config.rs)). With stride `4` the *first* vocoder window is
|
||||||
|
4 codec frames = **0.186 s**, so `170 ms` starts 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`, overlap `8`) produced **audible
|
||||||
|
micro-gaps and a slight detune / falsetto on long replies**. Root cause: with
|
||||||
|
only `8` tokens 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
|
||||||
|
`16` at stride `4` pushes decode to 5× real time, which the box **cannot**
|
||||||
|
sustain (ratio rose to 1.18–1.98×). Raising *strides and overlap together* —
|
||||||
|
stride `4` (first window) / followup `8` (steady) with overlap `16` — keeps the
|
||||||
|
decode load at a sustainable **`(16+8)/8 = 3×`** (~0.95× ratio) while giving
|
||||||
|
each steady window twice the context, and `crossfade 512` smooths 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 ms` still starts playback on the very first window. Further latency
|
||||||
|
gains would need a faster first token (model / preprocessing), not a smaller
|
||||||
|
stride.
|
||||||
|
- `measure_tts.py` was added to the repo: it streams `/v1/audio/speech` and 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
|
## 2026-10-16 — Mivocero: karaoke word highlighting on the assistant reply
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
[calm] Mm-hmm...
|
||||||
|
[attentive] Uh-huh...
|
||||||
|
[agreeing] Right...
|
||||||
|
[listening] I see...
|
||||||
|
[attentive] Yeah, yeah...
|
||||||
|
[calm] Got it...
|
||||||
|
[relaxed] Okay...
|
||||||
|
[thoughtful] Oh, I see...
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
[calm] Mm-hmm...
|
||||||
|
[attentive] Ajá...
|
||||||
|
[agreeing] Claro...
|
||||||
|
[listening] Ya veo...
|
||||||
|
[attentive] Sí, sí...
|
||||||
|
[calm] Entiendo...
|
||||||
|
[relaxed] Vale...
|
||||||
|
[thoughtful] Ah, ya...
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
[calm] Mmm...
|
||||||
|
[attentive] Eh già...
|
||||||
|
[agreeing] Certo...
|
||||||
|
[listening] Capisco...
|
||||||
|
[attentive] Sì, sì...
|
||||||
|
[calm] Ho capito...
|
||||||
|
[relaxed] Va bene...
|
||||||
|
[thoughtful] Ah, ecco...
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
Uhm...
|
||||||
|
Hmm...
|
||||||
|
Mmm...
|
||||||
|
Well...
|
||||||
|
Uh, let's see...
|
||||||
|
Okay...
|
||||||
|
Right...
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
Uhm...
|
||||||
|
Eh...
|
||||||
|
Mmm...
|
||||||
|
Vediamo...
|
||||||
|
Allora...
|
||||||
|
Beh...
|
||||||
|
Dunque...
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
|||||||
|
[calm] One moment...
|
||||||
|
[thoughtful] Let me see...
|
||||||
|
[sigh] Okay, coming up...
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,22 @@
|
|||||||
|
[calm] Un momento...
|
||||||
|
[thoughtful] A ver, veamos...
|
||||||
|
[calm][thinking] Déjame revisar...
|
||||||
|
[serious] A ver, déjame entender bien esto...
|
||||||
|
[hesitant] Entonces... echemos un vistazo...
|
||||||
|
[calm] Dame solo un segundo...
|
||||||
|
[sigh] Vale, ya voy...
|
||||||
|
[laughs] Buena pregunta... déjame ver...
|
||||||
|
[chuckle] De acuerdo, a ver qué encontramos...
|
||||||
|
[inhale] Bueno, vamos por partes...
|
||||||
|
[casual] Sí, espera un instante...
|
||||||
|
[confident] Recibido, me pongo a ello ahora mismo...
|
||||||
|
[excited] Perfecto, mira esto...
|
||||||
|
[enthusiastic] ¡Genial! Vamos a comprobarlo de inmediato...
|
||||||
|
[determined] Sin problema, me encargo ya...
|
||||||
|
[hesitant] Mmm... espera un segundo...
|
||||||
|
[confused] Déjame comprobarlo otra vez...
|
||||||
|
[curious] Curioso... a ver un momento...
|
||||||
|
[whispering] Shh, solo un instante...
|
||||||
|
[speaking softly, thinking out loud] Déjame echar un vistazo rápido...
|
||||||
|
[sighing gently, reassuring] Tranquilo, enseguida tengo la respuesta...
|
||||||
|
[focused, typing on a keyboard] Solo un momento, lo compruebo ya mismo...
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
|||||||
|
[calm] Un momento...
|
||||||
|
[thoughtful] Vediamo un po'...
|
||||||
|
[sigh] Va bene, arrivo...
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Measure Drama 3 / SGLang TTS streaming: TTFB, burst sizes, real-time ratio.
|
||||||
|
|
||||||
|
Streams /v1/audio/speech as raw PCM and reports how the audio arrives, so the
|
||||||
|
vocoder window schedule (stream_stride / ramp / followup) and the real-time
|
||||||
|
ratio can be checked. Run against the local SGLang-Omni server.
|
||||||
|
|
||||||
|
Two modes:
|
||||||
|
(default) one request for the whole text -> only the server's own vocoder
|
||||||
|
window seams are present.
|
||||||
|
--chunked split the text on sentence boundaries and issue one request per
|
||||||
|
sentence, concatenating the raw PCM like the engine does -> adds
|
||||||
|
the engine's per-chunk request boundaries on top of the seams.
|
||||||
|
Comparing the two WAVs tells whether an artifact comes from the
|
||||||
|
vocoder (present in both) or from the engine's chunking (only in
|
||||||
|
the chunked one).
|
||||||
|
"""
|
||||||
|
import array
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
URL = "http://localhost:8000/v1/audio/speech"
|
||||||
|
MODEL = "fishaudio/drama-3"
|
||||||
|
SR = 44100
|
||||||
|
BYTES_PER_SAMPLE = 2
|
||||||
|
|
||||||
|
# Voice-cloning reference (matches the engine's .env defaults).
|
||||||
|
REF_AUDIO = "/root/piperust/audio-agent-engine/voice_profiles/solis.wav"
|
||||||
|
REF_TEXT = "/root/piperust/audio-agent-engine/voice_profiles/solis.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def load_ref_text(path: str) -> str:
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def save_wav(path: str, pcm: bytes, sr: int) -> None:
|
||||||
|
with wave.open(path, "wb") as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(BYTES_PER_SAMPLE)
|
||||||
|
w.setframerate(sr)
|
||||||
|
w.writeframes(pcm)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_seams(pcm: bytes, boundaries: list[int]) -> None:
|
||||||
|
"""Compare the sample-to-sample jump exactly at each boundary against the
|
||||||
|
jumps inside the windows. A boundary jump no larger than the interior
|
||||||
|
distribution means no amplitude discontinuity there (the vocoder crossfade /
|
||||||
|
the engine's crossfade are doing their job)."""
|
||||||
|
s = array.array("h")
|
||||||
|
s.frombytes(pcm[: len(pcm) // BYTES_PER_SAMPLE * BYTES_PER_SAMPLE])
|
||||||
|
n = len(s)
|
||||||
|
if n < 2:
|
||||||
|
return
|
||||||
|
deltas = sorted(abs(s[i] - s[i - 1]) for i in range(1, n))
|
||||||
|
p999 = deltas[int(len(deltas) * 0.999)]
|
||||||
|
global_max = deltas[-1]
|
||||||
|
bounds = [b for b in boundaries[:-1] if 0 < b < n]
|
||||||
|
bj = [abs(s[b] - s[b - 1]) for b in bounds]
|
||||||
|
max_b = max(bj) if bj else 0
|
||||||
|
rank = deltas.index(max_b) / len(deltas) * 100 if bj else 0
|
||||||
|
print(f"seam analysis: boundaries={len(bounds)} max_boundary_jump={max_b} "
|
||||||
|
f"global_max_jump={global_max} global_p99.9={p999} "
|
||||||
|
f"max_boundary_percentile={rank:.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
def stream_pcm(text: str, use_ref: bool, t0: float):
|
||||||
|
"""POST one text and return (pcm_bytes, events, sample_rate).
|
||||||
|
events = list of (t_seconds, bytes_cumulative, chunk_bytes)."""
|
||||||
|
body = {
|
||||||
|
"model": MODEL,
|
||||||
|
"input": text,
|
||||||
|
"voice": "default",
|
||||||
|
"response_format": "pcm",
|
||||||
|
"stream": True,
|
||||||
|
}
|
||||||
|
if use_ref:
|
||||||
|
body["references"] = [
|
||||||
|
{"audio_path": REF_AUDIO, "text": load_ref_text(REF_TEXT)}
|
||||||
|
]
|
||||||
|
resp = requests.post(URL, json=body, stream=True, timeout=120)
|
||||||
|
resp.raise_for_status()
|
||||||
|
sr = int(resp.headers.get("x-sample-rate") or SR)
|
||||||
|
pcm = bytearray()
|
||||||
|
events = []
|
||||||
|
total = 0
|
||||||
|
with resp:
|
||||||
|
for chunk in resp.iter_content(chunk_size=None):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
total += len(chunk)
|
||||||
|
pcm += chunk
|
||||||
|
events.append((time.perf_counter() - t0, total, len(chunk)))
|
||||||
|
return bytes(pcm), events, sr
|
||||||
|
|
||||||
|
|
||||||
|
def summarise_events(events) -> None:
|
||||||
|
ttfb = events[0][0]
|
||||||
|
tlast = events[-1][0]
|
||||||
|
audio_s = (events[-1][1] // BYTES_PER_SAMPLE) / SR
|
||||||
|
print(f"chunks={len(events)} ttfb={ttfb*1000:.0f}ms "
|
||||||
|
f"last={tlast*1000:.0f}ms audio={audio_s:.2f}s "
|
||||||
|
f"wall={tlast:.2f}s ratio={tlast/audio_s:.3f}x")
|
||||||
|
|
||||||
|
|
||||||
|
def measure(text: str, use_ref: bool = True, out: str | None = None) -> None:
|
||||||
|
print(f"[single] text={text!r} ref={use_ref}")
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
pcm, events, sr = stream_pcm(text, use_ref, t0)
|
||||||
|
print("x-sample-rate:", sr)
|
||||||
|
summarise_events(events)
|
||||||
|
boundaries = [cum // BYTES_PER_SAMPLE for _, cum, _ in events]
|
||||||
|
analyze_seams(pcm, boundaries)
|
||||||
|
if out:
|
||||||
|
save_wav(out, pcm, sr)
|
||||||
|
print(f"saved WAV -> {out}")
|
||||||
|
|
||||||
|
|
||||||
|
def measure_chunked(text: str, use_ref: bool = True, out: str | None = None) -> None:
|
||||||
|
parts = [p for p in re.split(r"(?<=[.!?;])\s+", text.strip()) if p]
|
||||||
|
print(f"[chunked] parts={len(parts)} ref={use_ref}")
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
pcm = bytearray()
|
||||||
|
events = []
|
||||||
|
sr = SR
|
||||||
|
total = 0
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
t_req = time.perf_counter()
|
||||||
|
p, ev, sr = stream_pcm(part, use_ref, t0)
|
||||||
|
ttfb = ev[0][0] - (t_req - t0)
|
||||||
|
print(f" part {i}: {len(part):3d} chars ttfb={ttfb*1000:4.0f}ms "
|
||||||
|
f"audio={(len(p)//BYTES_PER_SAMPLE)/sr:.2f}s part={part[:40]!r}")
|
||||||
|
pcm += p
|
||||||
|
for t, cum, cb in ev:
|
||||||
|
total += cb
|
||||||
|
events.append((t, total, cb))
|
||||||
|
print("x-sample-rate:", sr)
|
||||||
|
summarise_events(events)
|
||||||
|
boundaries = [cum // BYTES_PER_SAMPLE for _, cum, _ in events]
|
||||||
|
analyze_seams(bytes(pcm), boundaries)
|
||||||
|
if out:
|
||||||
|
save_wav(out, bytes(pcm), sr)
|
||||||
|
print(f"saved WAV -> {out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = sys.argv[1:]
|
||||||
|
use_ref = "--no-ref" not in args
|
||||||
|
|
||||||
|
def arg_value(flag):
|
||||||
|
return args[args.index(flag) + 1] if flag in args else None
|
||||||
|
|
||||||
|
out = arg_value("--out")
|
||||||
|
text = arg_value("--text") or (
|
||||||
|
"Ciao, questa e' una prova per misurare la latenza del motore vocale. "
|
||||||
|
"Contiamo insieme da uno fino a venti: uno, due, tre, quattro, cinque, "
|
||||||
|
"sei, sette, otto, nove, dieci, undici, dodici, tredici, quattordici, "
|
||||||
|
"quindici, sedici, diciassette, diciotto, diciannove, venti.")
|
||||||
|
if "--chunked" in args:
|
||||||
|
measure_chunked(text, use_ref=use_ref, out=out)
|
||||||
|
else:
|
||||||
|
measure(text, use_ref=use_ref, out=out)
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
./server.sh restart
|
||||||
|
tail -f run/server.log
|
||||||
|
|
||||||
|
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
cargo r
|
||||||
|
|
||||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user