diff --git a/.env.example b/.env.example
index 16da6a4..543ba1b 100644
--- a/.env.example
+++ b/.env.example
@@ -343,3 +343,12 @@ LLM_TOOL_RESULT_MAX_CHARS=2000
# Then open https://localhost:8787/ and accept the certificate warning.
TLS_CERT=certs/cert.pem
TLS_KEY=certs/key.pem
+
+# --- CORS (optional) ---
+# Comma-separated browser origins allowed to call the REST surface cross-origin
+# (single `*` = any origin). Empty = no CORS layer (default behaviour: the
+# engine answers cross-origin fetch requests without CORS headers). Needed when
+# the JavaScript client SDK (see sdk/) is served from another origin. It does
+# NOT affect /ws: a WebSocket handshake is not a CORS request, so the realtime
+# session works cross-origin regardless.
+# CORS_ALLOW_ORIGIN=https://your-app.example,http://localhost:3000
diff --git a/Cargo.lock b/Cargo.lock
index b8f7f96..57698dd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -73,6 +73,7 @@ dependencies = [
"tokio-rustls",
"tokio-util",
"tower",
+ "tower-http",
"tracing",
"tracing-subscriber",
"utoipa",
diff --git a/Cargo.toml b/Cargo.toml
index e91a4df..4f2d0f5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -44,6 +44,9 @@ zip = { version = ">=2.2.0, <2.3.0", default-features = false, features = ["defl
hyper = { version = "1", features = ["server", "http1"] }
hyper-util = { version = "0.1", features = ["tokio"] }
tower = "0.5"
+# Opt-in CORS for browser clients hosted on another origin (`CORS_ALLOW_ORIGIN`).
+# Already a transitive dependency; the `cors` feature pulls in no new crates.
+tower-http = { version = "0.6", default-features = false, features = ["cors"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-pki-types = { version = "1", features = ["std"] }
diff --git a/README.md b/README.md
index 50893a7..e61414b 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,9 @@ Browser mic ──► WebSocket PCM ──► STT (Parakeet/ONNX) + VAD/EOU
## Features
- **Full-duplex WebSocket audio** — 44.1 kHz / 16-bit mono PCM, 20 ms frames.
+- **JavaScript client SDK** — a dependency-free, single-file ESM module (browser
+ + Node) wrapping the WebSocket session and the REST surface, so the client can
+ live on another server. See [Client SDK (JavaScript)](#client-sdk-javascript).
- **Pluggable TTS engines** — switch engines (Fish Audio, Kokoro, …) via `.env`
without changing orchestration code. See [TTS Plugin System](#tts-plugin-system).
- **Latency filler** — a preloaded filler is played on end-of-utterance *only if
@@ -95,6 +98,11 @@ Browser mic ──► WebSocket PCM ──► STT (Parakeet/ONNX) + VAD/EOU
format (a round that streams back empty is re-asked without streaming),
executed over HTTP and fed back to the model; the tool-call block is never
spoken. See [Tool calling](#tool-calling).
+- **Per-session system prompt** — each WebSocket session gets a server-assigned
+ id (delivered to the client in a `{"type":"session","id":"…"}` message) and the
+ LLM system prompt can be read and overridden **for that session** — or
+ process-wide — at runtime through `GET`/`POST /api/prompt`, without a restart.
+ See [Runtime system prompt](#runtime-system-prompt-get--post-apiprompt).
- **Embedded playground** — a single-page HTML client served at `/`.
- **Mivocero speech-to-speech client** — an example agent client at `/mivocero`
with a microphone, a live chat box (user transcript + streaming assistant
@@ -223,7 +231,7 @@ 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_BARGE_IN` | `true` | Interrupt the agent when the user starts speaking over it. |
| `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. Clips live in a per-language subdirectory `
/` (e.g. `assets/fillers/es/`). |
+| `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, per-voice-profile subdirectory `//` (e.g. `assets/fillers/es/maricel/`). |
| `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_GAIN_IDLE` | `1.0` | Filler gain when TTS is idle. |
@@ -231,11 +239,12 @@ See [`.env.example`](.env.example) for a copy-paste template.
| `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` | `1800` | 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. If the cue does fire and the reply then starts, it is faded out (80 ms) and cut so it does not overlap the answer. |
| `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 `/` (e.g. `assets/start_filler/es/`). Kept out of `FILLERS_DIR` so the clips are not part of the round-robin filler bank. |
+| `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, per-voice-profile subdirectory `//` (e.g. `assets/start_filler/es/maricel/`). Kept out of `FILLERS_DIR` so the clips are not part of the round-robin filler bank. |
+| `ASSET_VOICE_PROFILE` | *(auto)* | Optional explicit cue-asset profile name. When unset, the engine derives it from the active voice setup (prefer `TTS_VOICE` when it is not `default`, otherwise fall back to the reference-audio file stem, then to `default`). |
| `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. |
| `BACKCHANNEL_ENABLED` | `true` | Master switch for during-utterance acknowledgments. |
-| `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 `/` (e.g. `assets/backchannel/es/`). |
+| `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, per-voice-profile subdirectory `//` (e.g. `assets/backchannel/es/maricel/`). |
| `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_MIN_SPEECH_MS` | `1500` | Confirmed speech required before the first cue may be considered. |
@@ -246,7 +255,7 @@ See [`.env.example`](.env.example) for a copy-paste template.
| `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 `/` 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_DEFAULT` | `es` | Base language of the **filler, start-filler and backchannel** banks — its clips live in the `//` subdirectory (e.g. `assets/backchannel/es/maricel/`). Other languages are generated on demand into their own language/voice-profile 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. |
@@ -268,7 +277,7 @@ See [`.env.example`](.env.example) for a copy-paste template.
| `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_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). Can also be overridden at runtime per session via `GET`/`POST /api/prompt` ([Runtime system prompt](#runtime-system-prompt-get--post-apiprompt)). |
| `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` — any HTTP verb, `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. |
@@ -276,6 +285,9 @@ See [`.env.example`](.env.example) for a copy-paste template.
| `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_KEY` | *(empty)* | PEM private key matching `TLS_CERT`. |
+| `CORS_ALLOW_ORIGIN` | *(empty)* | Comma-separated browser origins allowed to call the REST surface cross-origin (single `*` = any). Empty adds no CORS layer (default behaviour). Needed when the [client SDK](#client-sdk-javascript) is served from another origin. Does not affect `/ws` (a WebSocket handshake is not a CORS request). |
+| `AUTH_API_BASE` | `https://aquachile.azurianlab.com` | Base URL of the auth API proxied by `POST /api/auth/phone-login` ([Phone login](#phone-login-post-apiauthphone-login)). |
+| `AUTH_PHONE_LOGIN_PATH` | `/api/v1/auth/phone-login` | Login path on `AUTH_API_BASE`. |
| `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 (`.jsonl`). Relative to the process working directory; git-ignored. |
@@ -882,20 +894,26 @@ 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`:
+Every language — **including the base `LANG_DEFAULT`** — keeps a separate bank
+for each cue-asset voice profile 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/
+assets/backchannel/es/maricel/ <- base language (LANG_DEFAULT), profile maricel
+assets/fillers/es/maricel/
+assets/start_filler/es/maricel/
+assets/backchannel/es/solis/ <- same language, different voice profile
+assets/backchannel/en/maricel/ <- English, generated on demand
+assets/fillers/en/maricel/
+assets/start_filler/en/maricel/
```
+On startup, if a legacy single-voice bank still exists directly under
+`//` (for example `assets/backchannel/es/bc01.raw`), the engine
+copies those flat files into the active `///`
+directory when the target files are missing. The legacy files are left in
+place, so the migration is non-destructive.
+
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
@@ -997,7 +1015,9 @@ chat box ──────────────┘
overlaps each request's start-up latency with the previous chunk's playback,
so raising the pre-buffer is no longer needed to avoid inter-chunk gaps.
- **History** — a rolling window of the last `LLM_HISTORY_MESSAGES` user/assistant
- messages is kept per connection and prepended with `LLM_SYSTEM_PROMPT`.
+ messages is kept per connection and prepended with the effective system prompt
+ (the session override if any, else `LLM_SYSTEM_PROMPT`, else the built-in
+ default — see [Runtime system prompt](#runtime-system-prompt-get--post-apiprompt)).
- **Barge-in** — an interrupt (client JSON, VAD onset, or disconnect) cancels
both the in-flight HTTP request ([`CancellationToken`] in the `reqwest`
`tokio::select!`, including while waiting for the next SSE chunk) and the TTS
@@ -1007,6 +1027,51 @@ chat box ──────────────┘
- **Failure handling** — request errors are reported to the client as
`{"type":"error","text":…}` and the turn ends cleanly.
+### Runtime system prompt (`GET` / `POST /api/prompt`)
+
+The system prompt is normally the process-wide default (the built-in one, or
+`LLM_SYSTEM_PROMPT`). Because it is injected identically into every session, two
+clients cannot run with different instructions. `GET`/`POST /api/prompt` adds a
+**runtime, in-memory** override — per session or process-wide.
+
+Each WebSocket session is assigned a server id (e.g. `s-1729170000000-0001`, the
+same id used for its audit log) and the id is pushed to the client right after
+connect, before the `cues` message:
+
+```json
+{"type":"session","id":"s-1729170000000-0001"}
+```
+
+That id is the key of the REST surface:
+
+```bash
+# Effective prompt for a session (+ where it comes from)
+curl 'localhost:8787/api/prompt?session_id=s-1729170000000-0001'
+
+# Override it for that session
+curl -X POST localhost:8787/api/prompt -H 'Content-Type: application/json' \
+ -d '{"session_id":"s-1729170000000-0001","prompt":"You are a terse assistant. One sentence max."}'
+
+# Clear the override (falls back to the configured default)
+curl -X POST localhost:8787/api/prompt -H 'Content-Type: application/json' \
+ -d '{"session_id":"s-1729170000000-0001","prompt":null}'
+```
+
+- **Scope** — with a `session_id` the override applies to that session only; omit
+ it (`null`) to set a process-wide fallback used by every session without its
+ own override.
+- **Precedence** — session override → process-wide override → `LLM_SYSTEM_PROMPT`
+ → built-in default. The response's `source` field (`session` / `global` /
+ `env` / `default` / `none`) reports which one won, and `has_override` whether a
+ runtime override is in effect.
+- **In-memory only** — a restart restores the configured default, and a session
+ override is dropped when that session disconnects. An unknown/disconnected
+ `session_id` returns `404`.
+- `prompt: null` (or `""`) clears the override.
+- The response also carries `direct_answers_applied` (whether the
+ `LLM_DIRECT_ANSWERS` directive is currently appended).
+- See [`src/prompt.rs`](src/prompt.rs) and [`src/prompt_api.rs`](src/prompt_api.rs).
+
---
## Tool calling
@@ -1419,6 +1484,7 @@ Connect to `ws://:8787/ws`.
| **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. |
+| **Text — `{"type":"session","id":"…"}`** | Sent once right after connect: the server-assigned session id (also the audit-log file stem). It is the key used by `GET`/`POST /api/prompt` to read/override the system prompt of this session ([Runtime system prompt](#runtime-system-prompt-get--post-apiprompt)). |
| **Text — `{"type":"barge_in","source":"vad","prob":…}`** | The agent was interrupted. Sent for the server-side VAD barge-in (the browser has no other way to know, since the onset is detected in the session task); the client-side `interrupt` does not echo one. Drives the demo's barge-in indicator. |
---
@@ -1491,6 +1557,7 @@ audio-agent-engine/
├── certs/ # self-signed TLS cert+key (generated)
├── UPDATES.md # changelog (English, newest-first)
├── tools.example.json # tool definitions for LLM tool calling (copy/edit)
+├── sdk/ # dependency-free JavaScript client SDK (+ .d.ts, served at /sdk/)
├── assets/fillers// # *.raw filler PCM (16-bit LE mono, 44.1 kHz; round-robin), one dir per language
├── assets/start_filler// # sf01.raw … turn-start start-filler clips + texts.txt index
├── assets/backchannel// # bc01.raw … backchannel clips + texts.txt index
@@ -1511,6 +1578,9 @@ audio-agent-engine/
├── llm.rs # OpenAI-compatible chat-completions client (streaming + think stripping)
├── tools.rs # configurable HTTP tool calling (native parser + executor)
├── tools_api.rs # GET/POST /api/tools — runtime tool definitions (in-memory override)
+ ├── prompt.rs # per-session system-prompt registry (overrides + resolution)
+ ├── prompt_api.rs # GET/POST /api/prompt — read/override the LLM system prompt per session
+ ├── auth_api.rs # POST /api/auth/phone-login — server-side proxy to the AquaChile auth API
├── media.rs # external audio source (URL / radio / YouTube) via ffmpeg + yt-dlp
├── customvoice.rs # custom-voice builder endpoints (record → WAV reference → set_reference)
├── transcribe.rs # POST /api/transcribe — Parakeet transcription of an uploaded audio file
@@ -1534,7 +1604,12 @@ audio-agent-engine/
| `POST /api/transcribe` | Upload a raw audio file (any ffmpeg-readable container); returns the Parakeet transcript of the whole file (`text`, `duration_ms`, `tokens`). |
| `GET /api/tools` | The active LLM tool definitions and their origin (`source`: `file` / `request` / `none`). |
| `POST /api/tools` | Install LLM tool definitions at runtime (same JSON array as `LLM_TOOLS_FILE`) — in-memory, no restart ([Tool calling](#tool-calling)). |
+| `GET /api/prompt` | The effective LLM system prompt, with its origin (`source`: `session` / `global` / `env` / `default` / `none`). Optional `?session_id=…`; `404` for an unknown session ([Runtime system prompt](#runtime-system-prompt-get--post-apiprompt)). |
+| `POST /api/prompt` | Override the LLM system prompt at runtime — for one session (`{"session_id":"…","prompt":"…"}`) or process-wide (`prompt` only); `prompt: null` clears it. In-memory, no restart. |
+| `POST /api/auth/phone-login` | Authenticate a phone number (`{"phoneNumber":"+44…","session_id":"s-…"}`) through a **server-side proxy** to the AquaChile auth API (`AUTH_API_BASE` + `AUTH_PHONE_LOGIN_PATH`) and return its payload (`sessionToken` + `user`). When `session_id` is provided, the engine stores a sanitized user profile as hidden LLM context for that live session ([Phone login](#phone-login-post-apiauthphone-login)). |
| `GET /health` | Liveness probe (`ok`). |
+| `GET /sdk/audio-agent-client.js` | The dependency-free JavaScript client SDK ([Client SDK (JavaScript)](#client-sdk-javascript), source in [`sdk/`](sdk)). |
+| `GET /sdk/demo.html` | Demo client built on the SDK (source in [`sdk/demo.html`](sdk/demo.html)). |
| `GET /ws` | WebSocket upgrade → per-session audio loop. |
| `GET /docs` | Swagger UI for the REST surface ([API documentation](#api-documentation-swagger-ui)). |
| `GET /api-docs/openapi.json` | The raw OpenAPI 3 document the Swagger UI renders. |
@@ -1586,6 +1661,110 @@ curl -s -X POST http://localhost:8787/api/transcribe \
---
+## Phone login (`POST /api/auth/phone-login`)
+
+The SDK demo can authenticate the user by phone number, read from the page URL
+(`?phoneNumber=+44…`), at the start of the session. The call is made **through
+the engine**, not directly from the browser:
+
+```bash
+curl -X POST localhost:8787/api/auth/phone-login \
+ -H 'Content-Type: application/json' \
+ -d '{"phoneNumber":"+447432477433"}'
+```
+
+The engine forwards the same JSON to the upstream AquaChile auth API
+(`AUTH_API_BASE` + `AUTH_PHONE_LOGIN_PATH`) and returns its status and body
+unchanged — on success a payload like
+`{"sessionToken":"…","user":{"id":…,"name":…,"email":…,"permissions":[…]}}`.
+
+If the request also carries a live WebSocket `session_id`, the engine extracts a
+sanitized user profile (`name`, `email`, `businessRoles`, `permissionSet`,
+`centers`, `permissions`) and injects it into that session's LLM context as a
+hidden system block. The SDK demo does this automatically right after connect.
+
+The proxy exists because the upstream API rejects browser calls from the client
+origin: its CORS preflight answers `400 Disallowed CORS origin` and its response
+carries no `Access-Control-Allow-Origin`, so a direct `fetch` from the demo is
+blocked. Routing the call through the engine makes it same-origin for the
+browser. Errors: `400` for an empty/invalid body or a missing `phoneNumber`,
+`502` when the upstream is unreachable. See [`src/auth_api.rs`](src/auth_api.rs).
+
+---
+
+## Client SDK (JavaScript)
+
+[`sdk/audio-agent-client.js`](sdk/audio-agent-client.js) is a **dependency-free,
+single-file ESM module** (with [`sdk/audio-agent-client.d.ts`](sdk/audio-agent-client.d.ts))
+for writing clients against the engine. It is deliberately decoupled from the
+server: copy the file to any host, no build step, no npm install. The engine also
+serves it at `GET /sdk/audio-agent-client.js`.
+
+A ready-made **[demo client](sdk/demo.html)** built on the SDK is served at
+`GET /sdk/demo.html` — open `http://localhost:8787/sdk/demo.html` and press
+*Connect*.
+
+It covers:
+
+- **Realtime session** — `AudioAgentClient`: connects to `/ws`, sends PCM
+ (`sendAudio`) and typed controls (`sendUserText`, `interrupt`,
+ `playAudioSource`, `setReference`, `speak`, …), and emits one event per server
+ message (`assistant_delta`, `cue`, `barge_in`, `tool_call`, `audio_source`,
+ `error`, …) plus a local `audio` event carrying the decoded PCM frames.
+- **REST** — `AudioAgentRest`: `health`, `getTools` / `setTools`,
+ `getCustomVoicePhrases` / `setCustomVoice` / `clearCustomVoice`, `transcribe`,
+ `openapi`; failures throw an error carrying `status` and the parsed `body`.
+- **Browser audio helpers** — `MicCapture` (mic → 882-sample `Int16Array` frames,
+ via an inline AudioWorklet), `PcmPlayer` (jitter-buffered playback,
+ `flush()` for barge-in) and `AudioSession`, which wires the three together.
+- **PCM utilities** — `int16ArrayFromBytes`, `float32ToInt16`, `int16ToFloat32`,
+ `toInt16`, plus the `AUDIO` constants (44.1 kHz, 20 ms = 882 samples).
+
+### Browser
+
+```html
+
+```
+
+Serve the page from a **secure context** (`https://` or `localhost`) or the
+browser will refuse microphone access. Cross-origin pages must have CORS enabled
+on the engine (`CORS_ALLOW_ORIGIN=https://your.app`); `/ws` needs no CORS.
+
+### Node
+
+The core (client + REST + PCM helpers) is browser-agnostic. Node 22+ has a global
+`WebSocket`; on older versions pass one in (e.g. the `ws` package). The audio
+helpers (`MicCapture`, `PcmPlayer`, `AudioSession`) require the Web Audio API and
+are browser-only.
+
+```js
+import { AudioAgentClient, AudioAgentRest, toInt16 } from './sdk/audio-agent-client.js';
+import WebSocket from 'ws';
+
+const client = new AudioAgentClient({ baseUrl: 'http://localhost:8787', WebSocket });
+client.on('assistant_delta', (e) => process.stdout.write(e.text));
+await client.connect();
+client.sendUserText("what's the weather in Rome?");
+client.sendAudio(toInt16(new Float32Array(882))); // PCM 16-bit LE, 44.1 kHz
+
+const rest = new AudioAgentRest('http://localhost:8787');
+console.log(await rest.getTools());
+```
+
+See [`sdk/README.md`](sdk/README.md) for the full reference.
+
+---
+
## Development
```bash
diff --git a/TOOL_CALLING.md b/TOOL_CALLING.md
index 66d4b89..9caabc4 100644
--- a/TOOL_CALLING.md
+++ b/TOOL_CALLING.md
@@ -1,348 +1,12 @@
-# Tool calling — Guida operativa / Operating guide / Guía de uso
+# Tool calling — Operating guide
-Documento unico, tre lingue: [Italiano](#italiano) · [English](#english) · [Español](#español).
-Riferimenti al codice: [`src/tools.rs`](src/tools.rs), [`src/llm.rs`](src/llm.rs),
+Code references: [`src/tools.rs`](src/tools.rs), [`src/llm.rs`](src/llm.rs),
[`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs),
-[`tools.example.json`](tools.example.json).
+[`src/tools_api.rs`](src/tools_api.rs), [`tools.example.json`](tools.example.json).
---
-## Italiano
-
-### 1. Cosa fa
-
-Il modello LLM del motore audio può **chiamare servizi HTTP esterni** prima di
-rispondere. Un "tool" non è codice: è la **descrizione JSON di una richiesta
-HTTP** (URL, metodo, header, schema degli argomenti). Aggiungere un tool significa
-scrivere qualche riga in un file JSON — **nessuna modifica al codice Rust**.
-
-Flusso per ogni turno utente:
-
-1. Il motore allega l'array `tools` (formato OpenAI) a **ogni** richiesta di
- completamento, con `tool_choice: "auto"`.
-2. Il modello, se serve, emette la chiamata. Il server LLM di produzione può non
- avere un parser di tool: la chiamata torna **come testo** nel formato nativo
- Qwen (`…`), che il motore **parsa
- client-side**.
-3. Il motore esegue la richiesta HTTP, ne tronca il risultato e lo rimanda al
- modello come messaggio di ruolo `tool`.
-4. Il modello produce la risposta finale, che viene sintetizzata in voce.
-
-Il blocco `` viene **sempre rimosso** dal testo parlato e mostrato in
-chat: l'utente non lo sente e non lo vede mai.
-
-### 2. Abilitazione e override runtime
-
-| Variabile | Default | Significato |
-|---|---|---|
-| `LLM_TOOLS_ENABLED` | auto | Interruttore generale. Se non impostato vale `true` quando `LLM_TOOLS_FILE` è un percorso non vuoto. |
-| `LLM_TOOLS_FILE` | *(vuoto)* | Percorso del file JSON con le definizioni. Vuoto o assente ⇒ tool calling **disattivato**. |
-| `LLM_TOOL_MAX_ITERATIONS` | `4` | Numero massimo di round di tool per turno (chiamata → esecuzione → ri-prompt). Limita i loop infiniti. |
-| `LLM_TOOL_TIMEOUT_SECS` | `15` | Timeout di **una singola** richiesta HTTP del tool. |
-| `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | Il risultato del tool viene troncato a questo numero di caratteri prima di tornare al modello. |
-
-```dotenv
-LLM_TOOLS_ENABLED=true
-LLM_TOOLS_FILE=tools.example.json
-LLM_TOOL_MAX_ITERATIONS=4
-LLM_TOOL_TIMEOUT_SECS=15
-LLM_TOOL_RESULT_MAX_CHARS=2000
-```
-
-Attenzione: il file dei tool viene letto **una sola volta all'avvio**
-(`ToolRegistry::load`). Dopo aver modificato il JSON serve un **riavvio del
-servizio** perché le nuove definizioni siano attive.
-
-#### Override a runtime — `POST /api/tools`
-
-Per cambiare le definizioni **senza riavvio**, invia lo stesso array JSON a
-`POST /api/tools`:
-
-```bash
-curl -X POST localhost:8787/api/tools -H 'Content-Type: application/json' \
- -d '[{"name":"get_weather","description":"Meteo di una città","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]},"url":"https://wttr.in/{city}?format=3","method":"GET"}]'
-```
-
-- Le definizioni inviate **sostituiscono** quelle del file dal **turno
- successivo** (nessun riavvio). Se l'endpoint **non viene mai chiamato**, il
- motore continua a usare quelle di `LLM_TOOLS_FILE`: è il comportamento
- predefinito, e un riavvio lo ripristina.
-- L'override vive **solo in memoria**: non scrive sul file e non sopravvive al
- riavvio del processo.
-- `GET /api/tools` riporta le definizioni attive e la loro origine: `source` vale
- `file` (default da `.env`), `request` (installate via endpoint) oppure `none`.
-- Risposte: `200` con `{ok,count,source,tools}`; `400` se il body è vuoto, non
- UTF-8, non è un array JSON valido o non contiene alcuna definizione; `503` se
- l'LLM è disattivato.
-- Il body accetta **lo stesso formato del file**: un array che funziona qui
- funziona anche come `LLM_TOOLS_FILE`.
-
-```bash
-curl -s localhost:8787/api/tools
-# {"ok":true,"count":2,"source":"file","tools":[ … ]}
-```
-
-Implementazione: [`src/tools_api.rs`](src/tools_api.rs). Entrambi gli endpoint
-compaiono anche nella Swagger UI (`/docs`, tag `tools`).
-
-### 3. Definire un tool
-
-Il file è un array JSON. Ogni oggetto ha i campi seguenti:
-
-| Campo | Obbligatorio | Descrizione |
-|---|---|---|
-| `name` | sì | Nome della funzione che il modello invoca. Deve combaciare con lo `name` della definizione. |
-| `description` | consigliato | **È ciò che il modello legge per decidere se e quando chiamare il tool**: scrivila esplicita e orientata all'uso ("chiama questo tool quando l'utente chiede…"). |
-| `parameters` | no | JSON Schema degli argomenti. Se assente vale `{"type":"object","properties":{}}`. |
-| `url` | sì | URL di destinazione. Può contenere segnaposto `{argomento}`. |
-| `method` | no | **Qualsiasi** verbo HTTP, case-insensitive: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE`, e verbi custom (es. `PURGE`). Default `POST`. |
-| `headers` | no | Header inviati letteralmente (chiavi API, `Content-Type`, …). I valori possono contenere segnaposto `{argomento}`. |
-
-#### Sostituzione dei segnaposto
-
-In `url` **e** in `headers`, ogni occorrenza di `{nome_argomento}` viene
-sostituita con il valore corrispondente della chiamata (stringhe senza virgolette,
-numeri/booleani come testo).
-
-#### Dove finiscono gli argomenti (per verbo)
-
-Gli argomenti **già consumati** da un segnaposto `{arg}` nell'URL *non* vengono
-re-inviati: sono già nel path. I rimanenti seguono il verbo:
-
-| Verbo | Argomenti rimanenti |
-|---|---|
-| `GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE` | **query parameters** (nessun body) |
-| `POST`, `PUT`, `PATCH` e verbi custom | **body JSON** (in assenza di argomenti: `{}`) |
-
-### 4. Esempi
-
-**GET con segnaposto nell'URL** (il `city` finisce nel path, non nella query):
-
-```json
-{
- "name": "get_weather",
- "description": "Meteo e temperatura attuali di una città. Chiamalo ogni volta che l'utente chiede il meteo, la temperatura o le previsioni di un luogo specifico.",
- "parameters": {
- "type": "object",
- "properties": { "city": { "type": "string", "description": "Nome della città, es. 'Roma'" } },
- "required": ["city"]
- },
- "url": "https://wttr.in/{city}?format=3",
- "method": "GET"
-}
-```
-
-**POST con header autenticato** (segreto preso dagli argomenti):
-
-```json
-{
- "name": "create_note",
- "description": "Salva una nota nel gestionale interno.",
- "parameters": {
- "type": "object",
- "properties": {
- "token": { "type": "string", "description": "Token di sessione dell'utente" },
- "text": { "type": "string", "description": "Contenuto della nota" }
- },
- "required": ["token", "text"]
- },
- "url": "https://gestione.interno.local/api/notes",
- "method": "POST",
- "headers": { "Authorization": "Bearer {token}" }
-}
-```
-
-**POST semplice (webhook)**:
-
-```json
-{
- "name": "send_webhook",
- "description": "Invia un payload JSON all'endpoint webhook configurato (notifiche / home automation).",
- "parameters": {
- "type": "object",
- "properties": { "message": { "type": "string" } },
- "required": ["message"]
- },
- "url": "https://httpbin.org/post",
- "method": "POST",
- "headers": { "X-Tool": "audio-agent-engine" }
-}
-```
-
-**Superficie REST completa** — un tool per verbo sullo stesso endpoint
-(`lista` → `GET`, `dettaglio` → `GET` con `{id}`, `aggiungi` → `POST`,
-`aggiorna` → `PATCH`, `sostituisci` → `PUT`, `elimina` → `DELETE`,
-`esiste?` → `HEAD`):
-
-```json
-[
- {
- "name": "list_items",
- "description": "Elenca gli articoli, con filtri opzionali.",
- "parameters": {
- "type": "object",
- "properties": {
- "limit": { "type": "integer" },
- "search": { "type": "string" }
- }
- },
- "url": "https://api.example.local/api/items",
- "method": "GET"
- },
- {
- "name": "get_item",
- "description": "Dettaglio di un articolo per id.",
- "parameters": {
- "type": "object",
- "properties": { "id": { "type": "string" } },
- "required": ["id"]
- },
- "url": "https://api.example.local/api/items/{id}",
- "method": "GET"
- },
- {
- "name": "add_item",
- "description": "Crea un nuovo articolo.",
- "parameters": {
- "type": "object",
- "properties": { "name": { "type": "string" }, "qty": { "type": "integer" } },
- "required": ["name"]
- },
- "url": "https://api.example.local/api/items",
- "method": "POST"
- },
- {
- "name": "update_item",
- "description": "Aggiorna parzialmente un articolo (solo i campi inviati).",
- "parameters": {
- "type": "object",
- "properties": { "id": { "type": "string" }, "qty": { "type": "integer" } },
- "required": ["id"]
- },
- "url": "https://api.example.local/api/items/{id}",
- "method": "PATCH"
- },
- {
- "name": "delete_item",
- "description": "Elimina un articolo per id.",
- "parameters": {
- "type": "object",
- "properties": { "id": { "type": "string" } },
- "required": ["id"]
- },
- "url": "https://api.example.local/api/items/{id}",
- "method": "DELETE"
- },
- {
- "name": "item_exists",
- "description": "Verifica l'esistenza di un articolo (solo status, nessun body).",
- "parameters": {
- "type": "object",
- "properties": { "id": { "type": "string" } },
- "required": ["id"]
- },
- "url": "https://api.example.local/api/items/{id}",
- "method": "HEAD"
- }
-]
-```
-
-Richieste risultanti: `GET /api/items?limit=10`, `GET /api/items/5`,
-`POST /api/items` + body `{"name":"…"}`, `PATCH /api/items/5` + body `{"qty":2}`,
-`DELETE /api/items/5`, `HEAD /api/items/5`.
-
-Parti da [`tools.example.json`](tools.example.json): copialo, adattalo e punta
-`LLM_TOOLS_FILE` al tuo file.
-
-### 5. Ciclo multi-round e fallback in streaming
-
-- Ogni round produce **un** completamento; le chiamate trovate vengono eseguite e
- i risultati rinviati al modello nel round successivo (round-trip standard
- OpenAI: messaggio `assistant` con la chiamata, poi un messaggio `tool` per
- ciascun risultato).
-- Il ciclo si ferma quando non ci sono più chiamate, oppure al raggiungimento di
- `LLM_TOOL_MAX_ITERATIONS`.
-- **Fallback in streaming**: alcuni server OpenAI-compatible (vLLM con
- `--tool-call-parser` non allineato al modello) **mangiano** la chiamata sul
- percorso SSE: il round finisce vuoto, senza errore HTTP. Se un round torna
- vuoto e i tool sono configurati, il motore **rifà la richiesta una volta senza
- streaming** (`chat_reply`) e recupera la chiamata da lì. Se nemmeno lì c'è una
- chiamata ma c'è del testo, quel testo viene parlato. Questo fallback si attiva
- **solo** sui round vuoti: nessun costo sui turni normali.
-
-### 6. Errori e troncamento
-
-L'esecuzione di un tool **non fallisce mai** in modo bloccante: l'errore diventa
-testo che il modello può leggere e da cui può recuperare.
-
-| Situazione | Testo consegnato al modello |
-|---|---|
-| Tool inesistente | `Error: unknown tool ''` |
-| Verbo HTTP non valido nella definizione | `Error: invalid HTTP method '' for tool ''` |
-| Status HTTP non 2xx | `Error: HTTP — ` |
-| Errore di rete/timeout | `Error: tool request failed: ` |
-| Risultato più lungo del limite | Troncato a `LLM_TOOL_RESULT_MAX_CHARS`, con suffisso `… [truncated]` |
-
-### 7. Nota importante sul parser lato server
-
-Se il server LLM gira **con** un parser di tool, il flag deve combaciare con il
-formato nativo del modello (`--enable-auto-tool-choice --tool-call-parser …`).
-Il disallineamento è **silenzioso**: un parser `hermes` (che si aspetta JSON dentro
-``) contro un modello Qwen che emette XML fa fallire il parse, la
-chiamata viene scartata e in streaming la risposta torna vuota (nei log vLLM:
-`Error in extracting tool call from response`). Per questo modello il parser
-corretto è `qwen3_coder`. Il motore è comunque robusto: gestisce **entrambi** i
-formati, l'array strutturato `message.tool_calls[]` **e** il testo nativo
-``, più il fallback descritto al punto 5.
-
-Sintomo tipico di disallineamento: l'agente tace, oppure "narra" la chiamata senza
-eseguirla.
-
-### 8. Verifica end-to-end
-
-Con il servizio attivo su `ws://localhost:8787/ws`, invia un testo utente che
-deve innescare il tool e verifica gli eventi:
-
-```python
-import asyncio, json, websockets
-
-async def main():
- async with websockets.connect("ws://localhost:8787/ws") as ws:
- await ws.send(json.dumps({"type": "user_text", "text": "Che tempo fa a Roma?"}))
- while True:
- msg = json.loads(await ws.recv())
- if msg.get("type") == "tool_call":
- print("TOOL:", msg["name"], msg["arguments"])
- if msg.get("type") == "assistant_end":
- print("turno concluso")
- break
-
-asyncio.run(main())
-```
-
-Eventi attesi sul WebSocket, in ordine: `tool_call` (con `name` e `arguments`) e
-poi `assistant_end`. Il blocco `` non deve comparire in nessun
-`assistant_delta`.
-
-### 9. Limiti e buone pratiche
-
-- Le chiamate non vengono trasmesse in streaming al client: la risposta dell'agente
- arriva **dopo** l'esecuzione del tool. Sui tool lenti alza
- `LLM_TOOL_TIMEOUT_SECS` solo quanto serve o riduci il lavoro lato server.
-- La qualità del `description` determina l'uso del tool: il system prompt di
- default non parla dei tool, decide solo il modello leggendo le descrizioni.
-- Tieni i risultati **brevi**: oltre `LLM_TOOL_RESULT_MAX_CHARS` vengono troncati e
- il modello può perdere informazione utile.
-- Non esporre segreti in chiaro nel `url`: usa `headers` con segnaposto, oppure un
- endpoint interno.
-- Il file dei tool è letto a **caldo una sola volta**: dopo una modifica riavvia il
- servizio.
-
----
-
-## English
-
-### 1. What it does
+## 1. What it does
The engine's LLM can **call external HTTP services** before answering. A "tool" is
not code: it is the **JSON description of an HTTP request** (URL, method, headers,
@@ -364,7 +28,7 @@ Per user turn:
The `` block is **always stripped** from both the spoken and the
displayed text: the user never hears or sees it.
-### 2. Enabling it and runtime override
+## 2. Enabling it and runtime override
| Variable | Default | Meaning |
|---|---|---|
@@ -385,7 +49,7 @@ LLM_TOOL_RESULT_MAX_CHARS=2000
Note: the tools file is read **once at startup** (`ToolRegistry::load`). After
editing the JSON, **restart the service** for the new definitions to take effect.
-#### Runtime override — `POST /api/tools`
+### Runtime override — `POST /api/tools`
To change the definitions **without a restart**, POST the same JSON array to
`POST /api/tools`:
@@ -417,7 +81,7 @@ curl -s localhost:8787/api/tools
Implementation: [`src/tools_api.rs`](src/tools_api.rs). Both endpoints also show
up in the Swagger UI (`/docs`, tag `tools`).
-### 3. Defining a tool
+## 3. Defining a tool
The file is a JSON array. Each object has these fields:
@@ -430,12 +94,12 @@ The file is a JSON array. Each object has these fields:
| `method` | no | **Any** HTTP verb, case-insensitive: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE`, and custom verbs (e.g. `PURGE`). Default `POST`. |
| `headers` | no | Headers sent verbatim (API keys, `Content-Type`, …). Values may contain `{arg}` placeholders. |
-#### Placeholder substitution
+### Placeholder substitution
In `url` **and** `headers`, every `{argument_name}` is replaced with the matching
call argument (strings unquoted, numbers/booleans rendered as text).
-#### Where the arguments go (by verb)
+### Where the arguments go (by verb)
Arguments **already consumed** by a `{arg}` placeholder in the URL are *not* sent
again: they are already in the path. The remaining ones follow the verb:
@@ -445,7 +109,7 @@ again: they are already in the path. The remaining ones follow the verb:
| `GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE` | **query parameters** (no body) |
| `POST`, `PUT`, `PATCH` and custom verbs | **JSON body** (`{}` when nothing is left) |
-### 4. Examples
+## 4. Examples
**GET with a URL placeholder** (`city` goes into the path, not the query):
@@ -596,7 +260,7 @@ Resulting requests: `GET /api/items?limit=10`, `GET /api/items/5`,
Start from [`tools.example.json`](tools.example.json): copy it, adapt it and point
`LLM_TOOLS_FILE` at your file.
-### 5. Multi-round loop and streaming fallback
+## 5. Multi-round loop and streaming fallback
- Each round produces **one** completion; the calls found are executed and their
results are handed back for the next round (standard OpenAI round-trip: an
@@ -610,7 +274,7 @@ Start from [`tools.example.json`](tools.example.json): copy it, adapt it and poi
holds text, that text is spoken. The fallback only triggers on empty rounds, so
it costs nothing on normal turns.
-### 6. Errors and truncation
+## 6. Errors and truncation
Tool execution **never fails** in a blocking way: an error becomes text the model
can read and recover from.
@@ -623,7 +287,7 @@ can read and recover from.
| Network error / timeout | `Error: tool request failed: ` |
| Result longer than the limit | Truncated to `LLM_TOOL_RESULT_MAX_CHARS`, suffixed with `… [truncated]` |
-### 7. Important note on the server-side parser
+## 7. Important note on the server-side parser
If the LLM server runs **with** a tool parser, the flag must match the model's
native format (`--enable-auto-tool-choice --tool-call-parser …`). A mismatch is
@@ -638,7 +302,7 @@ fallback described in section 5.
Typical mismatch symptom: the agent goes silent, or "narrates" the call without
executing it.
-### 8. End-to-end verification
+## 8. End-to-end verification
With the service running on `ws://localhost:8787/ws`, send user text that must
trigger the tool and check the events:
@@ -663,7 +327,7 @@ asyncio.run(main())
Expected WebSocket events, in order: `tool_call` (with `name` and `arguments`) then
`assistant_end`. The `` block must not appear in any `assistant_delta`.
-### 9. Limits and best practices
+## 9. Limits and best practices
- Calls are not streamed to the client: the agent's answer arrives **after** the
tool execution. For slow tools, raise `LLM_TOOL_TIMEOUT_SECS` only as much as
@@ -675,346 +339,3 @@ Expected WebSocket events, in order: `tool_call` (with `name` and `arguments`) t
- Do not put secrets in cleartext in the `url`: use `headers` with placeholders, or
an internal endpoint.
- The tools file is read **once**: restart the service after editing it.
-
----
-
-## Español
-
-### 1. Qué hace
-
-El LLM del motor puede **llamar a servicios HTTP externos** antes de responder. Un
-"tool" no es código: es la **descripción JSON de una petición HTTP** (URL, método,
-cabeceras, esquema de argumentos). Añadir un tool significa escribir unas líneas en
-un archivo JSON — **sin tocar el código Rust**.
-
-Por cada turno del usuario:
-
-1. El motor adjunta el array `tools` (formato OpenAI) a **cada** petición de
- completion, con `tool_choice: "auto"`.
-2. Si hace falta, el modelo emite la llamada. El servidor LLM de producción puede
- no tener parser de tools: la llamada vuelve **como texto** en el formato nativo
- de Qwen (`…`), que el motor **parsea en el
- cliente**.
-3. El motor ejecuta la petición HTTP, trunca el resultado y lo devuelve al modelo
- como mensaje de rol `tool`.
-4. El modelo produce la respuesta final, que se sintetiza a voz.
-
-El bloque `` se **elimina siempre** del texto hablado y del mostrado en
-el chat: el usuario nunca lo oye ni lo ve.
-
-### 2. Activación y override en runtime
-
-| Variable | Por defecto | Significado |
-|---|---|---|
-| `LLM_TOOLS_ENABLED` | auto | Interruptor general. Si no se define vale `true` cuando `LLM_TOOLS_FILE` es una ruta no vacía. |
-| `LLM_TOOLS_FILE` | *(vacío)* | Ruta del archivo JSON con las definiciones. Vacío o inexistente ⇒ tool calling **desactivado**. |
-| `LLM_TOOL_MAX_ITERATIONS` | `4` | Número máximo de rondas de tool por turno (llamada → ejecución → re-prompt). Limita bucles infinitos. |
-| `LLM_TOOL_TIMEOUT_SECS` | `15` | Timeout de **una** petición HTTP del tool. |
-| `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | El resultado del tool se trunca a este número de caracteres antes de volver al modelo. |
-
-```dotenv
-LLM_TOOLS_ENABLED=true
-LLM_TOOLS_FILE=tools.example.json
-LLM_TOOL_MAX_ITERATIONS=4
-LLM_TOOL_TIMEOUT_SECS=15
-LLM_TOOL_RESULT_MAX_CHARS=2000
-```
-
-Atención: el archivo de tools se lee **una sola vez al arrancar**
-(`ToolRegistry::load`). Tras modificar el JSON hay que **reiniciar el servicio**
-para que las nuevas definiciones estén activas.
-
-#### Override en runtime — `POST /api/tools`
-
-Para cambiar las definiciones **sin reiniciar**, envía el mismo array JSON a
-`POST /api/tools`:
-
-```bash
-curl -X POST localhost:8787/api/tools -H 'Content-Type: application/json' \
- -d '[{"name":"get_weather","description":"Clima de una ciudad","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]},"url":"https://wttr.in/{city}?format=3","method":"GET"}]'
-```
-
-- Las definiciones enviadas **sustituyen** las del archivo desde el **siguiente
- turno** (sin reinicio). Si el endpoint **nunca se llama**, el motor sigue
- usando las de `LLM_TOOLS_FILE`: es el comportamiento por defecto, y un reinicio
- lo restaura.
-- El override vive **solo en memoria**: no escribe el archivo y no sobrevive al
- reinicio del proceso.
-- `GET /api/tools` informa de las definiciones activas y su origen: `source` vale
- `file` (el valor por defecto de `.env`), `request` (instaladas por el endpoint)
- o `none`.
-- Respuestas: `200` con `{ok,count,source,tools}`; `400` si el body está vacío,
- no es UTF-8, no es un array JSON válido o no contiene ninguna definición;
- `503` si el LLM está desactivado.
-- El body acepta **el mismo formato que el archivo**: un array que funciona aquí
- también funciona como `LLM_TOOLS_FILE`.
-
-```bash
-curl -s localhost:8787/api/tools
-# {"ok":true,"count":2,"source":"file","tools":[ … ]}
-```
-
-Implementación: [`src/tools_api.rs`](src/tools_api.rs). Ambos endpoints aparecen
-también en la Swagger UI (`/docs`, tag `tools`).
-
-### 3. Definir un tool
-
-El archivo es un array JSON. Cada objeto tiene estos campos:
-
-| Campo | Obligatorio | Descripción |
-|---|---|---|
-| `name` | sí | Nombre de la función que invoca el modelo. Debe coincidir con el `name` de la definición. |
-| `description` | recomendado | **Es lo que el modelo lee para decidir si y cuándo llamar al tool**: escríbela explícita y orientada al uso ("llama a este tool cuando el usuario pregunte…"). |
-| `parameters` | no | JSON Schema de los argumentos. Si falta vale `{"type":"object","properties":{}}`. |
-| `url` | sí | URL de destino. Puede contener marcadores `{argumento}`. |
-| `method` | no | **Cualquier** verbo HTTP, sin distinguir mayúsculas: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE` y verbos personalizados (p. ej. `PURGE`). Por defecto `POST`. |
-| `headers` | no | Cabeceras enviadas literalmente (claves API, `Content-Type`, …). Los valores pueden contener marcadores `{argumento}`. |
-
-#### Sustitución de marcadores
-
-En `url` **y** en `headers`, cada `{nombre_argumento}` se sustituye por el valor
-correspondiente de la llamada (cadenas sin comillas, números/booleanos como texto).
-
-#### Dónde van los argumentos (según el verbo)
-
-Los argumentos **ya consumidos** por un marcador `{arg}` de la URL *no* se envían
-otra vez: ya están en el path. Los restantes siguen al verbo:
-
-| Verbo | Argumentos restantes |
-|---|---|
-| `GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE` | **parámetros de query** (sin body) |
-| `POST`, `PUT`, `PATCH` y verbos personalizados | **cuerpo JSON** (`{}` si no queda ninguno) |
-
-### 4. Ejemplos
-
-**GET con marcador en la URL** (`city` va al path, no a la query):
-
-```json
-{
- "name": "get_weather",
- "description": "Clima y temperatura actuales de una ciudad. Llámalo cada vez que el usuario pregunte por el clima, la temperatura o el pronóstico de un lugar concreto.",
- "parameters": {
- "type": "object",
- "properties": { "city": { "type": "string", "description": "Nombre de la ciudad, p. ej. 'Madrid'" } },
- "required": ["city"]
- },
- "url": "https://wttr.in/{city}?format=3",
- "method": "GET"
-}
-```
-
-**POST con cabecera autenticada** (secreto tomado de los argumentos):
-
-```json
-{
- "name": "create_note",
- "description": "Guarda una nota en el sistema interno de gestión.",
- "parameters": {
- "type": "object",
- "properties": {
- "token": { "type": "string", "description": "Token de sesión del usuario" },
- "text": { "type": "string", "description": "Contenido de la nota" }
- },
- "required": ["token", "text"]
- },
- "url": "https://interno.example.local/api/notes",
- "method": "POST",
- "headers": { "Authorization": "Bearer {token}" }
-}
-```
-
-**POST simple (webhook)**:
-
-```json
-{
- "name": "send_webhook",
- "description": "Envía un payload JSON al endpoint webhook configurado (notificaciones / home automation).",
- "parameters": {
- "type": "object",
- "properties": { "message": { "type": "string" } },
- "required": ["message"]
- },
- "url": "https://httpbin.org/post",
- "method": "POST",
- "headers": { "X-Tool": "audio-agent-engine" }
-}
-```
-
-**Superficie REST completa** — un tool por verbo sobre el mismo endpoint
-(`lista` → `GET`, `detalle` → `GET` con `{id}`, `añade` → `POST`,
-`actualiza` → `PATCH`, `reemplaza` → `PUT`, `elimina` → `DELETE`,
-`¿existe?` → `HEAD`):
-
-```json
-[
- {
- "name": "list_items",
- "description": "Lista los artículos, con filtros opcionales.",
- "parameters": {
- "type": "object",
- "properties": {
- "limit": { "type": "integer" },
- "search": { "type": "string" }
- }
- },
- "url": "https://api.example.local/api/items",
- "method": "GET"
- },
- {
- "name": "get_item",
- "description": "Detalle de un artículo por id.",
- "parameters": {
- "type": "object",
- "properties": { "id": { "type": "string" } },
- "required": ["id"]
- },
- "url": "https://api.example.local/api/items/{id}",
- "method": "GET"
- },
- {
- "name": "add_item",
- "description": "Crea un artículo nuevo.",
- "parameters": {
- "type": "object",
- "properties": { "name": { "type": "string" }, "qty": { "type": "integer" } },
- "required": ["name"]
- },
- "url": "https://api.example.local/api/items",
- "method": "POST"
- },
- {
- "name": "update_item",
- "description": "Actualiza parcialmente un artículo (solo los campos enviados).",
- "parameters": {
- "type": "object",
- "properties": { "id": { "type": "string" }, "qty": { "type": "integer" } },
- "required": ["id"]
- },
- "url": "https://api.example.local/api/items/{id}",
- "method": "PATCH"
- },
- {
- "name": "replace_item",
- "description": "Reemplaza un artículo por completo.",
- "parameters": {
- "type": "object",
- "properties": { "id": { "type": "string" }, "name": { "type": "string" } },
- "required": ["id", "name"]
- },
- "url": "https://api.example.local/api/items/{id}",
- "method": "PUT"
- },
- {
- "name": "delete_item",
- "description": "Elimina un artículo por id.",
- "parameters": {
- "type": "object",
- "properties": { "id": { "type": "string" } },
- "required": ["id"]
- },
- "url": "https://api.example.local/api/items/{id}",
- "method": "DELETE"
- },
- {
- "name": "item_exists",
- "description": "Comprueba si existe un artículo (solo status, sin body).",
- "parameters": {
- "type": "object",
- "properties": { "id": { "type": "string" } },
- "required": ["id"]
- },
- "url": "https://api.example.local/api/items/{id}",
- "method": "HEAD"
- }
-]
-```
-
-Peticiones resultantes: `GET /api/items?limit=10`, `GET /api/items/5`,
-`POST /api/items` + body `{"name":"…"}`, `PATCH /api/items/5` + body `{"qty":2}`,
-`PUT /api/items/5` + body `{"name":"…"}`, `DELETE /api/items/5`,
-`HEAD /api/items/5`.
-
-Parte de [`tools.example.json`](tools.example.json): cópialo, adáptalo y apunta
-`LLM_TOOLS_FILE` a tu archivo.
-
-### 5. Bucle multi-ronda y fallback en streaming
-
-- Cada ronda produce **un** completion; las llamadas encontradas se ejecutan y sus
- resultados se devuelven al modelo en la ronda siguiente (round-trip estándar
- OpenAI: mensaje `assistant` con la llamada, luego un mensaje `tool` por cada
- resultado).
-- El bucle se detiene cuando no hay más llamadas, o al alcanzar
- `LLM_TOOL_MAX_ITERATIONS`.
-- **Fallback en streaming**: algunos servidores compatibles con OpenAI (vLLM con un
- `--tool-call-parser` que no coincide con el modelo) **se tragan** la llamada en la
- ruta SSE: la ronda termina vacía, sin error HTTP. Si una ronda vuelve vacía y hay
- tools configurados, el motor **repite la petición una vez sin streaming**
- (`chat_reply`) y recupera la llamada de ahí. Si tampoco hay llamada pero hay
- texto, ese texto se habla. El fallback se activa **solo** en rondas vacías: no
- cuesta nada en los turnos normales.
-
-### 6. Errores y truncado
-
-La ejecución de un tool **nunca falla** de forma bloqueante: el error se convierte
-en texto que el modelo puede leer y del que puede recuperarse.
-
-| Situación | Texto entregado al modelo |
-|---|---|
-| Tool inexistente | `Error: unknown tool ''` |
-| Verbo HTTP no válido en la definición | `Error: invalid HTTP method '' for tool ''` |
-| Estado HTTP distinto de 2xx | `Error: HTTP — ` |
-| Error de red / timeout | `Error: tool request failed: ` |
-| Resultado más largo que el límite | Truncado a `LLM_TOOL_RESULT_MAX_CHARS`, con sufijo `… [truncated]` |
-
-### 7. Nota importante sobre el parser del servidor
-
-Si el servidor LLM funciona **con** un parser de tools, el flag debe coincidir con
-el formato nativo del modelo (`--enable-auto-tool-choice --tool-call-parser …`). El
-desajuste es **silencioso**: un parser `hermes` (que espera JSON dentro de
-``) contra un modelo Qwen que emite XML hace fallar el parseo, la
-llamada se descarta y, en streaming, la respuesta vuelve vacía (logs de vLLM:
-`Error in extracting tool call from response`). El parser correcto para este modelo
-es `qwen3_coder`. El motor es robusto en cualquier caso: maneja **ambos** formatos,
-el array estructurado `message.tool_calls[]` **y** el texto nativo ``,
-además del fallback descrito en la sección 5.
-
-Síntoma típico del desajuste: el agente calla, o "narra" la llamada sin ejecutarla.
-
-### 8. Verificación end-to-end
-
-Con el servicio activo en `ws://localhost:8787/ws`, envía texto de usuario que deba
-disparar el tool y comprueba los eventos:
-
-```python
-import asyncio, json, websockets
-
-async def main():
- async with websockets.connect("ws://localhost:8787/ws") as ws:
- await ws.send(json.dumps({"type": "user_text", "text": "¿Qué tiempo hace en Madrid?"}))
- while True:
- msg = json.loads(await ws.recv())
- if msg.get("type") == "tool_call":
- print("TOOL:", msg["name"], msg["arguments"])
- if msg.get("type") == "assistant_end":
- print("turno terminado")
- break
-
-asyncio.run(main())
-```
-
-Eventos WebSocket esperados, en orden: `tool_call` (con `name` y `arguments`) y
-después `assistant_end`. El bloque `` no debe aparecer en ningún
-`assistant_delta`.
-
-### 9. Límites y buenas prácticas
-
-- Las llamadas no se transmiten en streaming al cliente: la respuesta del agente
- llega **después** de la ejecución del tool. Para tools lentos, sube
- `LLM_TOOL_TIMEOUT_SECS` solo lo necesario o reduce el trabajo en el servidor.
-- La `description` determina el uso del tool: el system prompt por defecto no habla
- de tools, decide solo el modelo leyendo las descripciones.
-- Mantén los resultados **cortos**: más allá de `LLM_TOOL_RESULT_MAX_CHARS` se
- truncan y el modelo puede perder información útil.
-- No pongas secretos en claro en la `url`: usa `headers` con marcadores, o un
- endpoint interno.
-- El archivo de tools se lee **una sola vez**: reinicia el servicio tras editarlo.
diff --git a/UPDATES.md b/UPDATES.md
index d724c53..3c65551 100644
--- a/UPDATES.md
+++ b/UPDATES.md
@@ -5,6 +5,267 @@ Entries are written in English and ordered newest-first.
---
+## 2026-10-21 — Feat: demo routes audio tool results to the external source
+
+### Changed
+
+- **Cue asset layout** — filler, start-filler and backchannel banks now live
+ under `///` instead of `//`, so
+ multiple TTS voice configurations can coexist on disk without overwriting one
+ another.
+- **[`src/config.rs`](src/config.rs)** — added a resolved cue-asset voice
+ profile (`Config::cue_voice_profile()`), with optional explicit override via
+ `ASSET_VOICE_PROFILE`. When unset, the engine derives the profile from the
+ active voice setup (prefer `TTS_VOICE` when it is not `default`, otherwise
+ fall back to the reference-audio file stem, then to `default`).
+- **[`src/assets.rs`](src/assets.rs)** / **[`src/main.rs`](src/main.rs)** /
+ **[`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs)** /
+ **[`src/ws/session.rs`](src/ws/session.rs)** — cue-bank bootstrap, runtime
+ language switching and cue metadata now all resolve the same
+ `/` directory layout.
+- **Legacy cue-bank migration** — when old single-voice banks still exist
+ directly under `//`, startup now copies their flat files
+ (`*.raw`, `texts.txt`, `.voice_signature`) into the active
+ `///` directory if the target files are missing.
+ The legacy files are kept in place.
+- **[`run/refresh_voice_assets.sh`](run/refresh_voice_assets.sh)** — the voice
+ asset refresh script now targets the current cue voice profile and refreshes
+ directories under `///`, keeping other voice
+ profiles intact.
+- **[`README.md`](README.md)** — documented the new cue-bank directory layout
+ and the optional `ASSET_VOICE_PROFILE` override.
+- **[`sdk/demo.html`](sdk/demo.html)** — a tool definition may now carry an
+ extra `audio: true` flag (ignored by the backend, which only reads
+ `name`/`description`/`parameters`/`url`/`method`/`headers`). When the model
+ calls such a tool, the demo rebuilds the call's effective URL (arguments of
+ read-style verbs are appended as query parameters, mirroring the backend's
+ argument placement), writes it into the external audio source field and
+ starts playback on the engine's dedicated channel. `get_today_podcast_mp3` is
+ flagged this way, so a podcast request is played back instead of only being
+ returned to the model. The manual Play button now shares the same
+ `playExternalSource()` helper.
+- **[`sdk/demo.html`](sdk/demo.html)** — the default session prompt and the
+ built-in tool descriptions now explicitly tell the model to call
+ `get_today_podcast_mp3` on the first relevant podcast/audio request and
+ `get_public_day_report` on the first relevant report/alerts request, to
+ reduce cases where the user has to repeat the same intent before tool calling
+ is triggered.
+- **Markdown document mode** — when the user explicitly asks for a Markdown
+ document, the demo prompt now instructs the model to keep a very short spoken
+ preface outside the block and place the full document inside
+ `...`.
+- **[`sdk/demo.html`](sdk/demo.html)** — audio tool results are queued on
+ `tool_call` and autoplay after a fixed post-`assistant_end` delay, rather
+ than waiting for the SDK player to become idle. This avoids false timeouts in
+ deployments where the engine keeps sending mixed 20 ms PCM frames
+ continuously, even during silence, while keeping the post-reply timing
+ deterministic.
+- **[`src/tools.rs`](src/tools.rs)** / **[`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs)** —
+ the engine now strips `...` blocks out of the
+ spoken stream, emits them separately as `assistant_markdown` events, and
+ keeps the document text in the assistant history for follow-up turns.
+- **[`sdk/demo.html`](sdk/demo.html)** — the SDK demo renders
+ `assistant_markdown` payloads as formatted Markdown bubbles in chat instead
+ of showing raw source text.
+- **[`sdk/audio-agent-client.js`](sdk/audio-agent-client.js)** /
+ **[`sdk/audio-agent-client.d.ts`](sdk/audio-agent-client.d.ts)** — the SDK now
+ exposes the `assistant_markdown` event type for clients that want chat-only,
+ non-TTS Markdown documents.
+- **[`sdk/audio-agent-client.js`](sdk/audio-agent-client.js)** /
+ **[`sdk/audio-agent-client.d.ts`](sdk/audio-agent-client.d.ts)** — `PcmPlayer`
+ now exposes `isIdle()` and emits a local `idle` event when its scheduled
+ audio has fully drained, giving browser clients a reliable post-playback
+ signal.
+
+---
+
+## 2026-10-21 — Chore: voice-asset refresh helper script
+
+### Added
+
+- **[`run/refresh_voice_assets.sh`](run/refresh_voice_assets.sh)** — bash helper
+ that reads the current `.env`, analyzes the active TTS voice / reference /
+ sampling / cue-text settings, computes a voice signature, inspects every cue
+ bank (`fillers`, `start_filler`, `backchannel`) across the discovered
+ languages, and decides which generated assets are stale.
+- The script stores a per-directory `.voice_signature`, supports `--dry-run`,
+ `--force`, `--lang `, `--base-lang-only` and `--no-restart`, deletes the
+ generated `*.raw` clips (plus `texts.txt` for text-driven banks) when a
+ refresh is needed, restarts `pm2-piperust`, and writes the updated signature
+ markers back to the asset directories.
+
+---
+
+## 2026-10-21 — Chore: SDK demo installs a default day-report tool
+
+### Changed
+
+- **[`sdk/demo.html`](sdk/demo.html)** — the demo now installs a default
+ runtime tool set through `POST /api/tools` right after connect:
+ `get_public_day_report`, mapped to
+ `GET https://aquachile.azurianlab.com/api/v1/day-report` with query
+ parameters `limit` and `date`, plus `get_today_podcast_mp3`, mapped to
+ `GET https://aquachile.azurianlab.com/api/v1/podcast/today/mp3` with query
+ parameter `download`. This lets the demo exercise real tool calling without
+ relying on an external `LLM_TOOLS_FILE`.
+
+---
+
+## 2026-10-21 — Feat: phone-login proxy (`POST /api/auth/phone-login`)
+
+### Added
+
+- **`POST /api/auth/phone-login`** ([`src/auth_api.rs`](src/auth_api.rs)) — a
+ server-side proxy that forwards `{ "phoneNumber": "…" }` to the AquaChile auth
+ API (`AUTH_API_BASE` + `AUTH_PHONE_LOGIN_PATH`) and returns its status and body
+ unchanged (on success: `sessionToken` + `user`). It exists because that API
+ rejects browser calls from the client origin (CORS preflight `400 Disallowed
+ CORS origin`, no `Access-Control-Allow-Origin`), so a direct `fetch` from the
+ demo is blocked; going through the engine makes the call same-origin. When the
+ request also carries a live `session_id`, the engine stores a sanitized user
+ profile (`name`, `email`, `businessRoles`, `permissionSet`, `centers`,
+ `permissions`) as hidden context for that session's LLM. Errors: `400` for an
+ empty/invalid body or missing `phoneNumber`, `404` for an unknown
+ `session_id`, `502` when the upstream is unreachable.
+- Config `AUTH_API_BASE` (default `https://aquachile.azurianlab.com`) and
+ `AUTH_PHONE_LOGIN_PATH` (default `/api/v1/auth/phone-login`).
+- Swagger UI (`/docs`) documents the endpoint (new `auth` tag,
+ `PhoneLoginRequest` schema).
+
+### Changed
+
+- **[JavaScript SDK](sdk/audio-agent-client.js)** — added
+ `phoneLogin(phoneNumber, sessionId?)` to `AudioAgentRest`; types updated in
+ [`sdk/audio-agent-client.d.ts`](sdk/audio-agent-client.d.ts) and the reference
+ in [`sdk/README.md`](sdk/README.md).
+- **[`sdk/demo.html`](sdk/demo.html)** — at session start it reads `phoneNumber`
+ from the page URL (`?phoneNumber=+44…`) and authenticates through the engine
+ proxy, showing the resolved user in the header, a chat chip and the event log.
+ (Replaces the earlier direct cross-origin `fetch`, which the browser blocked.)
+- **Hidden LLM user context** — the session user block now instructs the model
+ to treat `permissions`, `permissionSet`, `businessRoles` and `centers` as
+ access/domain scope, avoid fabricating unavailable access, constrain KPI /
+ report / alert interpretation to the verified user scope, and suggest next
+ steps aligned with the user's role.
+
+---
+
+## 2026-10-21 — Feat: per-session system prompt (`GET` / `POST /api/prompt`)
+
+### Added
+
+- **Server-assigned session id** — every WebSocket session is given an id
+ (`s--`, the same stem used for its audit log) by a new
+ per-session prompt registry ([`src/prompt.rs`](src/prompt.rs)) and the id is
+ pushed to the client right after connect, before `cues`, as
+ `{"type":"session","id":"…"}` ([`src/ws/session.rs`](src/ws/session.rs)).
+ `SessionAudit::new` now receives that id instead of generating its own, so the
+ audit file and the live session share one identifier.
+- **`GET` / `POST /api/prompt`** ([`src/prompt_api.rs`](src/prompt_api.rs)) — read
+ or override the LLM system prompt at runtime, **per session** (with a
+ `session_id`) or process-wide (without one). `GET` returns the effective prompt
+ plus metadata (`source`: `session` / `global` / `env` / `default` / `none`,
+ `has_override`, `direct_answers_applied`); `POST` installs an override
+ (`prompt: null` / `""` clears it). Overrides live **in memory only** — a
+ restart restores the configured default and a session override is dropped on
+ disconnect; an unknown session id returns `404`.
+- The LLM turn now resolves the system prompt through the registry
+ ([`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs)):
+ session override → process-wide override → `LLM_SYSTEM_PROMPT` → built-in
+ default. With `/api/prompt` never called the behaviour is unchanged.
+- `Config::llm_system_prompt_from_env` records whether the configured prompt came
+ from `LLM_SYSTEM_PROMPT` (reported as `source: "env"`).
+- Swagger UI (`/docs`) documents the new endpoint (new `prompt` tag, schemas
+ `PromptResponse` / `SetPromptRequest`).
+
+### Changed
+
+- **[JavaScript SDK](sdk/audio-agent-client.js)** — added the `session` event to
+ `CLIENT_EVENTS` and `getPrompt(sessionId?)` / `setPrompt(prompt, sessionId?)`
+ to `AudioAgentRest`; types updated in
+ [`sdk/audio-agent-client.d.ts`](sdk/audio-agent-client.d.ts) and the reference
+ in [`sdk/README.md`](sdk/README.md).
+- **[`sdk/demo.html`](sdk/demo.html)** — captures the session id from the
+ `session` event and adds a **System prompt** panel to reload / override / clear
+ the prompt of the current session over REST. It also applies a built-in
+ `SESSION_PROMPT` (the Vocero / AquaChile assistant instructions) automatically
+ at the start of every session, right after the `session` event.
+
+---
+
+## 2026-10-21 — Chore: English-only code and documentation convention
+
+### Changed
+
+- **All code is now English-only.** Every comment, doc-comment, error/log string
+ and test description across `src/` and `sdk/` was reviewed and the remaining
+ Italian was translated — e.g. [`src/stt/tokenizer.rs`](src/stt/tokenizer.rs).
+ Per-language *content* is intentionally left as-is: the voice-cloning reading
+ phrases in [`src/customvoice.rs`](src/customvoice.rs) and the clip phrase
+ tables in [`src/lang.rs`](src/lang.rs) must stay in their own language.
+- **Documentation is now English-only.** [`TOOL_CALLING.md`](TOOL_CALLING.md) and
+ [`sdk/README.md`](sdk/README.md) dropped their `Italiano` / `Español`
+ sections and [`README.md`](README.md) examples switched to English sample text.
+ This supersedes the previous IT/EN/ES documentation convention.
+
+---
+
+## 2026-10-21 — Feat: JavaScript client SDK + opt-in CORS
+
+### Added
+
+- **`sdk/audio-agent-client.js`** — a dependency-free, single-file ESM client
+ SDK (browser + Node) with types in
+ [`sdk/audio-agent-client.d.ts`](sdk/audio-agent-client.d.ts) and a
+ reference in [`sdk/README.md`](sdk/README.md). It exists so the
+ client can be **decoupled** from the engine and hosted on another server:
+ - `AudioAgentClient` — the realtime `/ws` session: `sendAudio()` (accepts
+ `Int16Array` / `Float32Array` / `ArrayBuffer` / view / `number[]`, normalized
+ to 16-bit LE mono 44.1 kHz), typed controls (`sendUserText`, `interrupt`,
+ `playAudioSource`, `stopAudioSource`, `setAudioSourceVolume`,
+ `setReference`, `clearReference`, `speak`), optional auto-reconnect with
+ exponential backoff, and one event per server message (`cues`, `user`,
+ `assistant_delta`, `assistant_end`, `cue`, `barge_in`, `tool_call`,
+ `audio_source`, `error`) plus a local `audio` event with the decoded frames.
+ - `AudioAgentRest` — typed REST wrapper: `health`, `getTools` / `setTools`,
+ `getCustomVoicePhrases` / `setCustomVoice` / `clearCustomVoice`,
+ `transcribe`, `openapi`; failures throw an error carrying `status`/`body`.
+ - Browser audio helpers `MicCapture` (mic → 882-sample `Int16Array` frames via
+ an inline AudioWorklet), `PcmPlayer` (jitter-buffered playback with
+ `flush()` for barge-in) and `AudioSession` (wires client + mic + player).
+ - PCM utilities `int16ArrayFromBytes`, `float32ToInt16`, `int16ToFloat32`,
+ `toInt16` and the `AUDIO` constants.
+- **`GET /sdk/audio-agent-client.js`** ([`src/main.rs`](src/main.rs),
+ [`src/web/mod.rs`](src/web/mod.rs)) — serves the SDK (embedded at compile
+ time, like the HTML clients) so a client hosted elsewhere can fetch it.
+- **`sdk/demo.html`** + **`GET /sdk/demo.html`** — a complete demo client built
+ on the SDK (English UI, like all the code in this project): connect/disconnect,
+ live session state, streaming
+ agent replies with word-level *karaoke* highlighting, user/agent chat bubbles,
+ inline chips for `cue` / `tool_call` / `audio_source` events, a barge-in
+ counter, a microphone toggle with a live level meter, a text-turn input, an
+ external audio source (play / stop / volume) and a raw event log. It imports
+ `./audio-agent-client.js`, so it works both served by the engine and copied to
+ another origin; it warns when opened outside a secure context (no mic on
+ `file://`).
+- **Opt-in CORS** — `CORS_ALLOW_ORIGIN` (comma-separated origins, or a single
+ `*`) adds a [`tower-http`](https://docs.rs/tower-http) CORS layer
+ ([`src/main.rs`](src/main.rs), [`src/config.rs`](src/config.rs)) for browser
+ clients on another origin. Empty (the default) adds **no** layer, so the
+ engine behaves exactly as before. The WebSocket `/ws` is unaffected (a
+ handshake is not a CORS request).
+
+### Notes
+
+- No build step for the SDK: copy the file, or import it from the engine origin.
+- `tower-http` was already a transitive dependency; the `cors` feature adds no
+ new crates.
+- Docs updated: [README.md](README.md#client-sdk-javascript) (new section, route
+ table, env table, feature bullet), [`.env.example`](.env.example), and the new
+ [`sdk/README.md`](sdk/README.md) (IT/EN/ES).
+
+---
+
## 2026-10-21 — Feat: every HTTP verb for tools (list / get / add / update / delete / …)
### Changed
diff --git a/assets/backchannel/ca/veducha/bc01.raw b/assets/backchannel/ca/veducha/bc01.raw
new file mode 100644
index 0000000..6f2ff8c
Binary files /dev/null and b/assets/backchannel/ca/veducha/bc01.raw differ
diff --git a/assets/backchannel/ca/veducha/bc02.raw b/assets/backchannel/ca/veducha/bc02.raw
new file mode 100644
index 0000000..a920090
Binary files /dev/null and b/assets/backchannel/ca/veducha/bc02.raw differ
diff --git a/assets/backchannel/ca/veducha/bc03.raw b/assets/backchannel/ca/veducha/bc03.raw
new file mode 100644
index 0000000..e906379
Binary files /dev/null and b/assets/backchannel/ca/veducha/bc03.raw differ
diff --git a/assets/backchannel/ca/veducha/bc04.raw b/assets/backchannel/ca/veducha/bc04.raw
new file mode 100644
index 0000000..b1e9d75
Binary files /dev/null and b/assets/backchannel/ca/veducha/bc04.raw differ
diff --git a/assets/backchannel/ca/veducha/bc05.raw b/assets/backchannel/ca/veducha/bc05.raw
new file mode 100644
index 0000000..6bb330a
Binary files /dev/null and b/assets/backchannel/ca/veducha/bc05.raw differ
diff --git a/assets/backchannel/ca/veducha/bc06.raw b/assets/backchannel/ca/veducha/bc06.raw
new file mode 100644
index 0000000..0575809
Binary files /dev/null and b/assets/backchannel/ca/veducha/bc06.raw differ
diff --git a/assets/backchannel/ca/veducha/bc07.raw b/assets/backchannel/ca/veducha/bc07.raw
new file mode 100644
index 0000000..26fc639
Binary files /dev/null and b/assets/backchannel/ca/veducha/bc07.raw differ
diff --git a/assets/backchannel/ca/veducha/bc08.raw b/assets/backchannel/ca/veducha/bc08.raw
new file mode 100644
index 0000000..862e2c7
Binary files /dev/null and b/assets/backchannel/ca/veducha/bc08.raw differ
diff --git a/assets/backchannel/ca/veducha/texts.txt b/assets/backchannel/ca/veducha/texts.txt
new file mode 100644
index 0000000..0d615fa
--- /dev/null
+++ b/assets/backchannel/ca/veducha/texts.txt
@@ -0,0 +1,8 @@
+Sí...
+D'acord...
+Entesos...
+Clar...
+Correcte...
+Perfecte...
+Comprenc...
+Genial...
\ No newline at end of file
diff --git a/assets/backchannel/en/.voice_signature b/assets/backchannel/en/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/backchannel/en/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/backchannel/en/bc01.raw b/assets/backchannel/en/bc01.raw
index a4167c9..3662e25 100644
Binary files a/assets/backchannel/en/bc01.raw and b/assets/backchannel/en/bc01.raw differ
diff --git a/assets/backchannel/en/bc02.raw b/assets/backchannel/en/bc02.raw
index 2959878..84898cc 100644
Binary files a/assets/backchannel/en/bc02.raw and b/assets/backchannel/en/bc02.raw differ
diff --git a/assets/backchannel/en/bc03.raw b/assets/backchannel/en/bc03.raw
index 7e10291..c1c0ad7 100644
Binary files a/assets/backchannel/en/bc03.raw and b/assets/backchannel/en/bc03.raw differ
diff --git a/assets/backchannel/en/bc04.raw b/assets/backchannel/en/bc04.raw
index be15687..ac94410 100644
Binary files a/assets/backchannel/en/bc04.raw and b/assets/backchannel/en/bc04.raw differ
diff --git a/assets/backchannel/en/bc05.raw b/assets/backchannel/en/bc05.raw
index 8345843..9cf9519 100644
Binary files a/assets/backchannel/en/bc05.raw and b/assets/backchannel/en/bc05.raw differ
diff --git a/assets/backchannel/en/bc06.raw b/assets/backchannel/en/bc06.raw
index d5e39b9..1bdf097 100644
Binary files a/assets/backchannel/en/bc06.raw and b/assets/backchannel/en/bc06.raw differ
diff --git a/assets/backchannel/en/bc07.raw b/assets/backchannel/en/bc07.raw
index 154b748..26757d6 100644
Binary files a/assets/backchannel/en/bc07.raw and b/assets/backchannel/en/bc07.raw differ
diff --git a/assets/backchannel/en/bc08.raw b/assets/backchannel/en/bc08.raw
index 80d3df3..a539400 100644
Binary files a/assets/backchannel/en/bc08.raw and b/assets/backchannel/en/bc08.raw differ
diff --git a/assets/backchannel/es/.voice_signature b/assets/backchannel/es/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/backchannel/es/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/backchannel/es/bc01.raw b/assets/backchannel/es/bc01.raw
index e6c19c3..7f60acb 100644
Binary files a/assets/backchannel/es/bc01.raw and b/assets/backchannel/es/bc01.raw differ
diff --git a/assets/backchannel/es/bc02.raw b/assets/backchannel/es/bc02.raw
index 41efc90..4a371d4 100644
Binary files a/assets/backchannel/es/bc02.raw and b/assets/backchannel/es/bc02.raw differ
diff --git a/assets/backchannel/es/bc03.raw b/assets/backchannel/es/bc03.raw
index 8d9d621..88db078 100644
Binary files a/assets/backchannel/es/bc03.raw and b/assets/backchannel/es/bc03.raw differ
diff --git a/assets/backchannel/es/bc04.raw b/assets/backchannel/es/bc04.raw
index b999b3e..bc10e45 100644
Binary files a/assets/backchannel/es/bc04.raw and b/assets/backchannel/es/bc04.raw differ
diff --git a/assets/backchannel/es/bc05.raw b/assets/backchannel/es/bc05.raw
index 3e48b6f..25a7f73 100644
Binary files a/assets/backchannel/es/bc05.raw and b/assets/backchannel/es/bc05.raw differ
diff --git a/assets/backchannel/es/bc06.raw b/assets/backchannel/es/bc06.raw
index 52f3a34..52ccbaf 100644
Binary files a/assets/backchannel/es/bc06.raw and b/assets/backchannel/es/bc06.raw differ
diff --git a/assets/backchannel/es/bc07.raw b/assets/backchannel/es/bc07.raw
index c3c7eee..0949dde 100644
Binary files a/assets/backchannel/es/bc07.raw and b/assets/backchannel/es/bc07.raw differ
diff --git a/assets/backchannel/es/bc08.raw b/assets/backchannel/es/bc08.raw
index 6e90ae5..54bc929 100644
Binary files a/assets/backchannel/es/bc08.raw and b/assets/backchannel/es/bc08.raw differ
diff --git a/assets/backchannel/es/veducha/.voice_signature b/assets/backchannel/es/veducha/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/backchannel/es/veducha/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/backchannel/es/veducha/bc01.raw b/assets/backchannel/es/veducha/bc01.raw
new file mode 100644
index 0000000..e614140
Binary files /dev/null and b/assets/backchannel/es/veducha/bc01.raw differ
diff --git a/assets/backchannel/es/veducha/bc02.raw b/assets/backchannel/es/veducha/bc02.raw
new file mode 100644
index 0000000..8cbcdc1
Binary files /dev/null and b/assets/backchannel/es/veducha/bc02.raw differ
diff --git a/assets/backchannel/es/veducha/bc03.raw b/assets/backchannel/es/veducha/bc03.raw
new file mode 100644
index 0000000..a471089
Binary files /dev/null and b/assets/backchannel/es/veducha/bc03.raw differ
diff --git a/assets/backchannel/es/veducha/bc04.raw b/assets/backchannel/es/veducha/bc04.raw
new file mode 100644
index 0000000..0f62709
Binary files /dev/null and b/assets/backchannel/es/veducha/bc04.raw differ
diff --git a/assets/backchannel/es/veducha/bc05.raw b/assets/backchannel/es/veducha/bc05.raw
new file mode 100644
index 0000000..be71ffb
Binary files /dev/null and b/assets/backchannel/es/veducha/bc05.raw differ
diff --git a/assets/backchannel/es/veducha/bc06.raw b/assets/backchannel/es/veducha/bc06.raw
new file mode 100644
index 0000000..359bcfd
Binary files /dev/null and b/assets/backchannel/es/veducha/bc06.raw differ
diff --git a/assets/backchannel/es/veducha/bc07.raw b/assets/backchannel/es/veducha/bc07.raw
new file mode 100644
index 0000000..f708891
Binary files /dev/null and b/assets/backchannel/es/veducha/bc07.raw differ
diff --git a/assets/backchannel/es/veducha/bc08.raw b/assets/backchannel/es/veducha/bc08.raw
new file mode 100644
index 0000000..8a1c8d0
Binary files /dev/null and b/assets/backchannel/es/veducha/bc08.raw differ
diff --git a/assets/backchannel/es/veducha/texts.txt b/assets/backchannel/es/veducha/texts.txt
new file mode 100644
index 0000000..769253f
--- /dev/null
+++ b/assets/backchannel/es/veducha/texts.txt
@@ -0,0 +1,8 @@
+[calm] Mm-hmm...
+[attentive] Ajá...
+[agreeing] Claro...
+[listening] Ya veo...
+[attentive] Sí, sí...
+[calm] Entiendo...
+[relaxed] Vale...
+[thoughtful] Ah, ya...
\ No newline at end of file
diff --git a/assets/backchannel/it/.voice_signature b/assets/backchannel/it/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/backchannel/it/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/backchannel/it/bc01.raw b/assets/backchannel/it/bc01.raw
index bc7da6c..dd8e0de 100644
Binary files a/assets/backchannel/it/bc01.raw and b/assets/backchannel/it/bc01.raw differ
diff --git a/assets/backchannel/it/bc02.raw b/assets/backchannel/it/bc02.raw
index 9822047..742e5d4 100644
Binary files a/assets/backchannel/it/bc02.raw and b/assets/backchannel/it/bc02.raw differ
diff --git a/assets/backchannel/it/bc03.raw b/assets/backchannel/it/bc03.raw
index 0bb8be0..010063b 100644
Binary files a/assets/backchannel/it/bc03.raw and b/assets/backchannel/it/bc03.raw differ
diff --git a/assets/backchannel/it/bc04.raw b/assets/backchannel/it/bc04.raw
index 8215632..999a854 100644
Binary files a/assets/backchannel/it/bc04.raw and b/assets/backchannel/it/bc04.raw differ
diff --git a/assets/backchannel/it/bc05.raw b/assets/backchannel/it/bc05.raw
index b9130f5..54d880d 100644
Binary files a/assets/backchannel/it/bc05.raw and b/assets/backchannel/it/bc05.raw differ
diff --git a/assets/backchannel/it/bc06.raw b/assets/backchannel/it/bc06.raw
index 1e78446..1263805 100644
Binary files a/assets/backchannel/it/bc06.raw and b/assets/backchannel/it/bc06.raw differ
diff --git a/assets/backchannel/it/bc07.raw b/assets/backchannel/it/bc07.raw
index bafc9b0..1185787 100644
Binary files a/assets/backchannel/it/bc07.raw and b/assets/backchannel/it/bc07.raw differ
diff --git a/assets/backchannel/it/bc08.raw b/assets/backchannel/it/bc08.raw
index 474ca4e..165c35d 100644
Binary files a/assets/backchannel/it/bc08.raw and b/assets/backchannel/it/bc08.raw differ
diff --git a/assets/backchannel/it/veducha/.voice_signature b/assets/backchannel/it/veducha/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/backchannel/it/veducha/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/backchannel/it/veducha/bc01.raw b/assets/backchannel/it/veducha/bc01.raw
new file mode 100644
index 0000000..61948fd
Binary files /dev/null and b/assets/backchannel/it/veducha/bc01.raw differ
diff --git a/assets/backchannel/it/veducha/bc02.raw b/assets/backchannel/it/veducha/bc02.raw
new file mode 100644
index 0000000..b959943
Binary files /dev/null and b/assets/backchannel/it/veducha/bc02.raw differ
diff --git a/assets/backchannel/it/veducha/bc03.raw b/assets/backchannel/it/veducha/bc03.raw
new file mode 100644
index 0000000..020ee57
Binary files /dev/null and b/assets/backchannel/it/veducha/bc03.raw differ
diff --git a/assets/backchannel/it/veducha/bc04.raw b/assets/backchannel/it/veducha/bc04.raw
new file mode 100644
index 0000000..30469a9
Binary files /dev/null and b/assets/backchannel/it/veducha/bc04.raw differ
diff --git a/assets/backchannel/it/veducha/bc05.raw b/assets/backchannel/it/veducha/bc05.raw
new file mode 100644
index 0000000..b8c8bba
Binary files /dev/null and b/assets/backchannel/it/veducha/bc05.raw differ
diff --git a/assets/backchannel/it/veducha/bc06.raw b/assets/backchannel/it/veducha/bc06.raw
new file mode 100644
index 0000000..6bb6667
Binary files /dev/null and b/assets/backchannel/it/veducha/bc06.raw differ
diff --git a/assets/backchannel/it/veducha/bc07.raw b/assets/backchannel/it/veducha/bc07.raw
new file mode 100644
index 0000000..6ab4678
Binary files /dev/null and b/assets/backchannel/it/veducha/bc07.raw differ
diff --git a/assets/backchannel/it/veducha/bc08.raw b/assets/backchannel/it/veducha/bc08.raw
new file mode 100644
index 0000000..06dfc29
Binary files /dev/null and b/assets/backchannel/it/veducha/bc08.raw differ
diff --git a/assets/backchannel/it/veducha/texts.txt b/assets/backchannel/it/veducha/texts.txt
new file mode 100644
index 0000000..d94d676
--- /dev/null
+++ b/assets/backchannel/it/veducha/texts.txt
@@ -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...
\ No newline at end of file
diff --git a/assets/backchannel/pt/.voice_signature b/assets/backchannel/pt/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/backchannel/pt/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/backchannel/sw/.voice_signature b/assets/backchannel/sw/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/backchannel/sw/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/backchannel/sw/bc01.raw b/assets/backchannel/sw/bc01.raw
deleted file mode 100644
index f4290fd..0000000
Binary files a/assets/backchannel/sw/bc01.raw and /dev/null differ
diff --git a/assets/backchannel/sw/bc02.raw b/assets/backchannel/sw/bc02.raw
deleted file mode 100644
index a040eaf..0000000
Binary files a/assets/backchannel/sw/bc02.raw and /dev/null differ
diff --git a/assets/backchannel/sw/bc03.raw b/assets/backchannel/sw/bc03.raw
deleted file mode 100644
index 6367113..0000000
Binary files a/assets/backchannel/sw/bc03.raw and /dev/null differ
diff --git a/assets/backchannel/sw/bc04.raw b/assets/backchannel/sw/bc04.raw
deleted file mode 100644
index b8553cb..0000000
Binary files a/assets/backchannel/sw/bc04.raw and /dev/null differ
diff --git a/assets/backchannel/sw/bc05.raw b/assets/backchannel/sw/bc05.raw
deleted file mode 100644
index 630114c..0000000
Binary files a/assets/backchannel/sw/bc05.raw and /dev/null differ
diff --git a/assets/backchannel/sw/bc06.raw b/assets/backchannel/sw/bc06.raw
deleted file mode 100644
index 66a667d..0000000
Binary files a/assets/backchannel/sw/bc06.raw and /dev/null differ
diff --git a/assets/backchannel/sw/bc07.raw b/assets/backchannel/sw/bc07.raw
deleted file mode 100644
index 3500540..0000000
Binary files a/assets/backchannel/sw/bc07.raw and /dev/null differ
diff --git a/assets/backchannel/sw/bc08.raw b/assets/backchannel/sw/bc08.raw
deleted file mode 100644
index 4eb7470..0000000
Binary files a/assets/backchannel/sw/bc08.raw and /dev/null differ
diff --git a/assets/backchannel/sw/texts.txt b/assets/backchannel/sw/texts.txt
deleted file mode 100644
index d12537e..0000000
--- a/assets/backchannel/sw/texts.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-Sawa...
-Ndiyo...
-Uh-huh...
-Sijui...
-Vizuri...
-Sawa...
-Hapo...
-Ee...
\ No newline at end of file
diff --git a/assets/fillers/ca/veducha/fl01.raw b/assets/fillers/ca/veducha/fl01.raw
new file mode 100644
index 0000000..dc69f5c
Binary files /dev/null and b/assets/fillers/ca/veducha/fl01.raw differ
diff --git a/assets/fillers/ca/veducha/fl02.raw b/assets/fillers/ca/veducha/fl02.raw
new file mode 100644
index 0000000..2399fe0
Binary files /dev/null and b/assets/fillers/ca/veducha/fl02.raw differ
diff --git a/assets/fillers/ca/veducha/fl03.raw b/assets/fillers/ca/veducha/fl03.raw
new file mode 100644
index 0000000..5a001f2
Binary files /dev/null and b/assets/fillers/ca/veducha/fl03.raw differ
diff --git a/assets/fillers/ca/veducha/fl04.raw b/assets/fillers/ca/veducha/fl04.raw
new file mode 100644
index 0000000..00e9db7
Binary files /dev/null and b/assets/fillers/ca/veducha/fl04.raw differ
diff --git a/assets/fillers/ca/veducha/fl05.raw b/assets/fillers/ca/veducha/fl05.raw
new file mode 100644
index 0000000..3efb059
Binary files /dev/null and b/assets/fillers/ca/veducha/fl05.raw differ
diff --git a/assets/fillers/ca/veducha/fl06.raw b/assets/fillers/ca/veducha/fl06.raw
new file mode 100644
index 0000000..aea20c6
Binary files /dev/null and b/assets/fillers/ca/veducha/fl06.raw differ
diff --git a/assets/fillers/ca/veducha/fl07.raw b/assets/fillers/ca/veducha/fl07.raw
new file mode 100644
index 0000000..a328aad
Binary files /dev/null and b/assets/fillers/ca/veducha/fl07.raw differ
diff --git a/assets/fillers/ca/veducha/texts.txt b/assets/fillers/ca/veducha/texts.txt
new file mode 100644
index 0000000..f37859d
--- /dev/null
+++ b/assets/fillers/ca/veducha/texts.txt
@@ -0,0 +1,7 @@
+umm...
+bé...
+a veure...
+doncs...
+hmm...
+és a dir...
+donem...
\ No newline at end of file
diff --git a/assets/fillers/en/.voice_signature b/assets/fillers/en/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/fillers/en/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/fillers/en/fl01.raw b/assets/fillers/en/fl01.raw
index 295a109..c1b8d07 100644
Binary files a/assets/fillers/en/fl01.raw and b/assets/fillers/en/fl01.raw differ
diff --git a/assets/fillers/en/fl02.raw b/assets/fillers/en/fl02.raw
index 39c6c4f..3f1059b 100644
Binary files a/assets/fillers/en/fl02.raw and b/assets/fillers/en/fl02.raw differ
diff --git a/assets/fillers/en/fl03.raw b/assets/fillers/en/fl03.raw
index 4fe624f..e7b142a 100644
Binary files a/assets/fillers/en/fl03.raw and b/assets/fillers/en/fl03.raw differ
diff --git a/assets/fillers/en/fl04.raw b/assets/fillers/en/fl04.raw
index 4270961..6f9b91d 100644
Binary files a/assets/fillers/en/fl04.raw and b/assets/fillers/en/fl04.raw differ
diff --git a/assets/fillers/en/fl05.raw b/assets/fillers/en/fl05.raw
index 170da77..d4aec03 100644
Binary files a/assets/fillers/en/fl05.raw and b/assets/fillers/en/fl05.raw differ
diff --git a/assets/fillers/en/fl06.raw b/assets/fillers/en/fl06.raw
index 9f5189c..7cce59c 100644
Binary files a/assets/fillers/en/fl06.raw and b/assets/fillers/en/fl06.raw differ
diff --git a/assets/fillers/en/fl07.raw b/assets/fillers/en/fl07.raw
index 6702bb1..114e349 100644
Binary files a/assets/fillers/en/fl07.raw and b/assets/fillers/en/fl07.raw differ
diff --git a/assets/fillers/es/.voice_signature b/assets/fillers/es/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/fillers/es/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/fillers/es/aver.raw b/assets/fillers/es/aver.raw
index 9c8a710..cc54d60 100644
Binary files a/assets/fillers/es/aver.raw and b/assets/fillers/es/aver.raw differ
diff --git a/assets/fillers/es/bueno.raw b/assets/fillers/es/bueno.raw
index 1ca90c8..f8bc35a 100644
Binary files a/assets/fillers/es/bueno.raw and b/assets/fillers/es/bueno.raw differ
diff --git a/assets/fillers/es/eh.raw b/assets/fillers/es/eh.raw
index 727c0d8..919d2ea 100644
Binary files a/assets/fillers/es/eh.raw and b/assets/fillers/es/eh.raw differ
diff --git a/assets/fillers/es/mmm.raw b/assets/fillers/es/mmm.raw
index 9ad0497..2ef9492 100644
Binary files a/assets/fillers/es/mmm.raw and b/assets/fillers/es/mmm.raw differ
diff --git a/assets/fillers/es/pues.raw b/assets/fillers/es/pues.raw
index e3a6a69..1463f9f 100644
Binary files a/assets/fillers/es/pues.raw and b/assets/fillers/es/pues.raw differ
diff --git a/assets/fillers/es/uhm.raw b/assets/fillers/es/uhm.raw
index 6447d70..c80f27f 100644
Binary files a/assets/fillers/es/uhm.raw and b/assets/fillers/es/uhm.raw differ
diff --git a/assets/fillers/es/vaya.raw b/assets/fillers/es/vaya.raw
index 3f9921a..88b56ac 100644
Binary files a/assets/fillers/es/vaya.raw and b/assets/fillers/es/vaya.raw differ
diff --git a/assets/fillers/es/veducha/.voice_signature b/assets/fillers/es/veducha/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/fillers/es/veducha/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/fillers/es/veducha/aver.raw b/assets/fillers/es/veducha/aver.raw
new file mode 100644
index 0000000..fe029dd
Binary files /dev/null and b/assets/fillers/es/veducha/aver.raw differ
diff --git a/assets/fillers/es/veducha/bueno.raw b/assets/fillers/es/veducha/bueno.raw
new file mode 100644
index 0000000..d79e9be
Binary files /dev/null and b/assets/fillers/es/veducha/bueno.raw differ
diff --git a/assets/fillers/es/veducha/eh.raw b/assets/fillers/es/veducha/eh.raw
new file mode 100644
index 0000000..ba0e22e
Binary files /dev/null and b/assets/fillers/es/veducha/eh.raw differ
diff --git a/assets/fillers/es/veducha/mmm.raw b/assets/fillers/es/veducha/mmm.raw
new file mode 100644
index 0000000..f7d53c2
Binary files /dev/null and b/assets/fillers/es/veducha/mmm.raw differ
diff --git a/assets/fillers/es/veducha/pues.raw b/assets/fillers/es/veducha/pues.raw
new file mode 100644
index 0000000..f62506a
Binary files /dev/null and b/assets/fillers/es/veducha/pues.raw differ
diff --git a/assets/fillers/es/veducha/uhm.raw b/assets/fillers/es/veducha/uhm.raw
new file mode 100644
index 0000000..6f58331
Binary files /dev/null and b/assets/fillers/es/veducha/uhm.raw differ
diff --git a/assets/fillers/es/veducha/vaya.raw b/assets/fillers/es/veducha/vaya.raw
new file mode 100644
index 0000000..4c7e723
Binary files /dev/null and b/assets/fillers/es/veducha/vaya.raw differ
diff --git a/assets/fillers/it/.voice_signature b/assets/fillers/it/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/fillers/it/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/fillers/it/fl01.raw b/assets/fillers/it/fl01.raw
index 65ceb25..25cb4bb 100644
Binary files a/assets/fillers/it/fl01.raw and b/assets/fillers/it/fl01.raw differ
diff --git a/assets/fillers/it/fl02.raw b/assets/fillers/it/fl02.raw
index 98a9985..2c85726 100644
Binary files a/assets/fillers/it/fl02.raw and b/assets/fillers/it/fl02.raw differ
diff --git a/assets/fillers/it/fl03.raw b/assets/fillers/it/fl03.raw
index 25b34b9..7d49809 100644
Binary files a/assets/fillers/it/fl03.raw and b/assets/fillers/it/fl03.raw differ
diff --git a/assets/fillers/it/fl04.raw b/assets/fillers/it/fl04.raw
index 537d582..5264391 100644
Binary files a/assets/fillers/it/fl04.raw and b/assets/fillers/it/fl04.raw differ
diff --git a/assets/fillers/it/fl05.raw b/assets/fillers/it/fl05.raw
index 58b7c8b..199f41b 100644
Binary files a/assets/fillers/it/fl05.raw and b/assets/fillers/it/fl05.raw differ
diff --git a/assets/fillers/it/fl06.raw b/assets/fillers/it/fl06.raw
index a64297c..607289e 100644
Binary files a/assets/fillers/it/fl06.raw and b/assets/fillers/it/fl06.raw differ
diff --git a/assets/fillers/it/fl07.raw b/assets/fillers/it/fl07.raw
index edafa28..781c54c 100644
Binary files a/assets/fillers/it/fl07.raw and b/assets/fillers/it/fl07.raw differ
diff --git a/assets/fillers/it/veducha/.voice_signature b/assets/fillers/it/veducha/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/fillers/it/veducha/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/fillers/it/veducha/fl01.raw b/assets/fillers/it/veducha/fl01.raw
new file mode 100644
index 0000000..21e27f8
Binary files /dev/null and b/assets/fillers/it/veducha/fl01.raw differ
diff --git a/assets/fillers/it/veducha/fl02.raw b/assets/fillers/it/veducha/fl02.raw
new file mode 100644
index 0000000..57c4bea
Binary files /dev/null and b/assets/fillers/it/veducha/fl02.raw differ
diff --git a/assets/fillers/it/veducha/fl03.raw b/assets/fillers/it/veducha/fl03.raw
new file mode 100644
index 0000000..93bd620
Binary files /dev/null and b/assets/fillers/it/veducha/fl03.raw differ
diff --git a/assets/fillers/it/veducha/fl04.raw b/assets/fillers/it/veducha/fl04.raw
new file mode 100644
index 0000000..b69807c
Binary files /dev/null and b/assets/fillers/it/veducha/fl04.raw differ
diff --git a/assets/fillers/it/veducha/fl05.raw b/assets/fillers/it/veducha/fl05.raw
new file mode 100644
index 0000000..143cb24
Binary files /dev/null and b/assets/fillers/it/veducha/fl05.raw differ
diff --git a/assets/fillers/it/veducha/fl06.raw b/assets/fillers/it/veducha/fl06.raw
new file mode 100644
index 0000000..485fb7b
Binary files /dev/null and b/assets/fillers/it/veducha/fl06.raw differ
diff --git a/assets/fillers/it/veducha/fl07.raw b/assets/fillers/it/veducha/fl07.raw
new file mode 100644
index 0000000..56fa44b
Binary files /dev/null and b/assets/fillers/it/veducha/fl07.raw differ
diff --git a/assets/fillers/it/veducha/texts.txt b/assets/fillers/it/veducha/texts.txt
new file mode 100644
index 0000000..858870a
--- /dev/null
+++ b/assets/fillers/it/veducha/texts.txt
@@ -0,0 +1,7 @@
+Uhm...
+Eh...
+Mmm...
+Vediamo...
+Allora...
+Beh...
+Dunque...
\ No newline at end of file
diff --git a/assets/fillers/pt/.voice_signature b/assets/fillers/pt/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/fillers/pt/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/fillers/pt/texts.txt b/assets/fillers/pt/texts.txt
new file mode 100644
index 0000000..8dca6d3
--- /dev/null
+++ b/assets/fillers/pt/texts.txt
@@ -0,0 +1,7 @@
+Hã...
+É...
+Mmm...
+Vejamos...
+Então...
+Bom...
+Pois...
\ No newline at end of file
diff --git a/assets/fillers/sw/.voice_signature b/assets/fillers/sw/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/fillers/sw/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/fillers/sw/fl01.raw b/assets/fillers/sw/fl01.raw
deleted file mode 100644
index 7e454e6..0000000
Binary files a/assets/fillers/sw/fl01.raw and /dev/null differ
diff --git a/assets/fillers/sw/fl02.raw b/assets/fillers/sw/fl02.raw
deleted file mode 100644
index caa73e9..0000000
Binary files a/assets/fillers/sw/fl02.raw and /dev/null differ
diff --git a/assets/fillers/sw/fl03.raw b/assets/fillers/sw/fl03.raw
deleted file mode 100644
index a0fc8fd..0000000
Binary files a/assets/fillers/sw/fl03.raw and /dev/null differ
diff --git a/assets/fillers/sw/fl04.raw b/assets/fillers/sw/fl04.raw
deleted file mode 100644
index 5d47cd7..0000000
Binary files a/assets/fillers/sw/fl04.raw and /dev/null differ
diff --git a/assets/fillers/sw/fl05.raw b/assets/fillers/sw/fl05.raw
deleted file mode 100644
index c8b76af..0000000
Binary files a/assets/fillers/sw/fl05.raw and /dev/null differ
diff --git a/assets/fillers/sw/fl06.raw b/assets/fillers/sw/fl06.raw
deleted file mode 100644
index 6222964..0000000
Binary files a/assets/fillers/sw/fl06.raw and /dev/null differ
diff --git a/assets/fillers/sw/fl07.raw b/assets/fillers/sw/fl07.raw
deleted file mode 100644
index 1825caf..0000000
Binary files a/assets/fillers/sw/fl07.raw and /dev/null differ
diff --git a/assets/start_filler/ca/veducha/sf01.raw b/assets/start_filler/ca/veducha/sf01.raw
new file mode 100644
index 0000000..95ea826
Binary files /dev/null and b/assets/start_filler/ca/veducha/sf01.raw differ
diff --git a/assets/start_filler/ca/veducha/sf02.raw b/assets/start_filler/ca/veducha/sf02.raw
new file mode 100644
index 0000000..ab5a452
Binary files /dev/null and b/assets/start_filler/ca/veducha/sf02.raw differ
diff --git a/assets/start_filler/ca/veducha/sf03.raw b/assets/start_filler/ca/veducha/sf03.raw
new file mode 100644
index 0000000..e289894
Binary files /dev/null and b/assets/start_filler/ca/veducha/sf03.raw differ
diff --git a/assets/start_filler/ca/veducha/texts.txt b/assets/start_filler/ca/veducha/texts.txt
new file mode 100644
index 0000000..209f281
--- /dev/null
+++ b/assets/start_filler/ca/veducha/texts.txt
@@ -0,0 +1,3 @@
+un moment...
+deixa'm veure...
+espera una mica...
\ No newline at end of file
diff --git a/assets/start_filler/en/.voice_signature b/assets/start_filler/en/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/start_filler/en/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/start_filler/en/sf01.raw b/assets/start_filler/en/sf01.raw
index 77c4bcc..381f980 100644
Binary files a/assets/start_filler/en/sf01.raw and b/assets/start_filler/en/sf01.raw differ
diff --git a/assets/start_filler/en/sf02.raw b/assets/start_filler/en/sf02.raw
index 7e6e641..12313fa 100644
Binary files a/assets/start_filler/en/sf02.raw and b/assets/start_filler/en/sf02.raw differ
diff --git a/assets/start_filler/en/sf03.raw b/assets/start_filler/en/sf03.raw
index 164f395..46781ff 100644
Binary files a/assets/start_filler/en/sf03.raw and b/assets/start_filler/en/sf03.raw differ
diff --git a/assets/start_filler/es/.voice_signature b/assets/start_filler/es/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/start_filler/es/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/start_filler/es/sf01.raw b/assets/start_filler/es/sf01.raw
index d0a5ae8..0dcb0da 100644
Binary files a/assets/start_filler/es/sf01.raw and b/assets/start_filler/es/sf01.raw differ
diff --git a/assets/start_filler/es/sf02.raw b/assets/start_filler/es/sf02.raw
index 0ff4a36..2e161f7 100644
Binary files a/assets/start_filler/es/sf02.raw and b/assets/start_filler/es/sf02.raw differ
diff --git a/assets/start_filler/es/sf03.raw b/assets/start_filler/es/sf03.raw
index 89e4c15..26651c7 100644
Binary files a/assets/start_filler/es/sf03.raw and b/assets/start_filler/es/sf03.raw differ
diff --git a/assets/start_filler/es/sf04.raw b/assets/start_filler/es/sf04.raw
index a390b9c..e40b46a 100644
Binary files a/assets/start_filler/es/sf04.raw and b/assets/start_filler/es/sf04.raw differ
diff --git a/assets/start_filler/es/sf05.raw b/assets/start_filler/es/sf05.raw
index b7bbea2..2794233 100644
Binary files a/assets/start_filler/es/sf05.raw and b/assets/start_filler/es/sf05.raw differ
diff --git a/assets/start_filler/es/sf06.raw b/assets/start_filler/es/sf06.raw
index 6a98e8a..dc72ca1 100644
Binary files a/assets/start_filler/es/sf06.raw and b/assets/start_filler/es/sf06.raw differ
diff --git a/assets/start_filler/es/sf07.raw b/assets/start_filler/es/sf07.raw
index 3d9f5c7..4bd324f 100644
Binary files a/assets/start_filler/es/sf07.raw and b/assets/start_filler/es/sf07.raw differ
diff --git a/assets/start_filler/es/sf08.raw b/assets/start_filler/es/sf08.raw
index 8324ef1..5b1d77e 100644
Binary files a/assets/start_filler/es/sf08.raw and b/assets/start_filler/es/sf08.raw differ
diff --git a/assets/start_filler/es/sf09.raw b/assets/start_filler/es/sf09.raw
index 20c1826..6cdf704 100644
Binary files a/assets/start_filler/es/sf09.raw and b/assets/start_filler/es/sf09.raw differ
diff --git a/assets/start_filler/es/sf10.raw b/assets/start_filler/es/sf10.raw
index bd50aa3..e5e58b5 100644
Binary files a/assets/start_filler/es/sf10.raw and b/assets/start_filler/es/sf10.raw differ
diff --git a/assets/start_filler/es/sf11.raw b/assets/start_filler/es/sf11.raw
index af1955f..7d3b406 100644
Binary files a/assets/start_filler/es/sf11.raw and b/assets/start_filler/es/sf11.raw differ
diff --git a/assets/start_filler/es/sf12.raw b/assets/start_filler/es/sf12.raw
index eeac82d..8d3ab8f 100644
Binary files a/assets/start_filler/es/sf12.raw and b/assets/start_filler/es/sf12.raw differ
diff --git a/assets/start_filler/es/sf13.raw b/assets/start_filler/es/sf13.raw
index d88e011..02a8947 100644
Binary files a/assets/start_filler/es/sf13.raw and b/assets/start_filler/es/sf13.raw differ
diff --git a/assets/start_filler/es/sf14.raw b/assets/start_filler/es/sf14.raw
index 3787f27..064ff67 100644
Binary files a/assets/start_filler/es/sf14.raw and b/assets/start_filler/es/sf14.raw differ
diff --git a/assets/start_filler/es/sf15.raw b/assets/start_filler/es/sf15.raw
index 419219b..15935e9 100644
Binary files a/assets/start_filler/es/sf15.raw and b/assets/start_filler/es/sf15.raw differ
diff --git a/assets/start_filler/es/sf16.raw b/assets/start_filler/es/sf16.raw
index 3fd693c..1f7a5f7 100644
Binary files a/assets/start_filler/es/sf16.raw and b/assets/start_filler/es/sf16.raw differ
diff --git a/assets/start_filler/es/sf17.raw b/assets/start_filler/es/sf17.raw
index 3806159..ebbf098 100644
Binary files a/assets/start_filler/es/sf17.raw and b/assets/start_filler/es/sf17.raw differ
diff --git a/assets/start_filler/es/sf18.raw b/assets/start_filler/es/sf18.raw
index 66e1e13..1611281 100644
Binary files a/assets/start_filler/es/sf18.raw and b/assets/start_filler/es/sf18.raw differ
diff --git a/assets/start_filler/es/sf19.raw b/assets/start_filler/es/sf19.raw
index 9ddc1b4..777aaae 100644
Binary files a/assets/start_filler/es/sf19.raw and b/assets/start_filler/es/sf19.raw differ
diff --git a/assets/start_filler/es/sf20.raw b/assets/start_filler/es/sf20.raw
index e2e3da0..b3bd9f3 100644
Binary files a/assets/start_filler/es/sf20.raw and b/assets/start_filler/es/sf20.raw differ
diff --git a/assets/start_filler/es/sf21.raw b/assets/start_filler/es/sf21.raw
index 7116a82..c8a9ca5 100644
Binary files a/assets/start_filler/es/sf21.raw and b/assets/start_filler/es/sf21.raw differ
diff --git a/assets/start_filler/es/sf22.raw b/assets/start_filler/es/sf22.raw
index b1f4154..8a74e7e 100644
Binary files a/assets/start_filler/es/sf22.raw and b/assets/start_filler/es/sf22.raw differ
diff --git a/assets/start_filler/es/veducha/.voice_signature b/assets/start_filler/es/veducha/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/start_filler/es/veducha/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/start_filler/es/veducha/sf01.raw b/assets/start_filler/es/veducha/sf01.raw
new file mode 100644
index 0000000..647912c
Binary files /dev/null and b/assets/start_filler/es/veducha/sf01.raw differ
diff --git a/assets/start_filler/es/veducha/sf02.raw b/assets/start_filler/es/veducha/sf02.raw
new file mode 100644
index 0000000..20e4259
Binary files /dev/null and b/assets/start_filler/es/veducha/sf02.raw differ
diff --git a/assets/start_filler/es/veducha/sf03.raw b/assets/start_filler/es/veducha/sf03.raw
new file mode 100644
index 0000000..e7b1df1
Binary files /dev/null and b/assets/start_filler/es/veducha/sf03.raw differ
diff --git a/assets/start_filler/es/veducha/sf04.raw b/assets/start_filler/es/veducha/sf04.raw
new file mode 100644
index 0000000..a789eef
Binary files /dev/null and b/assets/start_filler/es/veducha/sf04.raw differ
diff --git a/assets/start_filler/es/veducha/sf05.raw b/assets/start_filler/es/veducha/sf05.raw
new file mode 100644
index 0000000..2b5f32f
Binary files /dev/null and b/assets/start_filler/es/veducha/sf05.raw differ
diff --git a/assets/start_filler/es/veducha/sf06.raw b/assets/start_filler/es/veducha/sf06.raw
new file mode 100644
index 0000000..55f715f
Binary files /dev/null and b/assets/start_filler/es/veducha/sf06.raw differ
diff --git a/assets/start_filler/es/veducha/sf07.raw b/assets/start_filler/es/veducha/sf07.raw
new file mode 100644
index 0000000..091bcd3
Binary files /dev/null and b/assets/start_filler/es/veducha/sf07.raw differ
diff --git a/assets/start_filler/es/veducha/sf08.raw b/assets/start_filler/es/veducha/sf08.raw
new file mode 100644
index 0000000..c8d11c9
Binary files /dev/null and b/assets/start_filler/es/veducha/sf08.raw differ
diff --git a/assets/start_filler/es/veducha/sf09.raw b/assets/start_filler/es/veducha/sf09.raw
new file mode 100644
index 0000000..652027a
Binary files /dev/null and b/assets/start_filler/es/veducha/sf09.raw differ
diff --git a/assets/start_filler/es/veducha/sf10.raw b/assets/start_filler/es/veducha/sf10.raw
new file mode 100644
index 0000000..821f91d
Binary files /dev/null and b/assets/start_filler/es/veducha/sf10.raw differ
diff --git a/assets/start_filler/es/veducha/sf11.raw b/assets/start_filler/es/veducha/sf11.raw
new file mode 100644
index 0000000..81dfd25
Binary files /dev/null and b/assets/start_filler/es/veducha/sf11.raw differ
diff --git a/assets/start_filler/es/veducha/sf12.raw b/assets/start_filler/es/veducha/sf12.raw
new file mode 100644
index 0000000..964e688
Binary files /dev/null and b/assets/start_filler/es/veducha/sf12.raw differ
diff --git a/assets/start_filler/es/veducha/sf13.raw b/assets/start_filler/es/veducha/sf13.raw
new file mode 100644
index 0000000..c1439ae
Binary files /dev/null and b/assets/start_filler/es/veducha/sf13.raw differ
diff --git a/assets/start_filler/es/veducha/sf14.raw b/assets/start_filler/es/veducha/sf14.raw
new file mode 100644
index 0000000..fb52e2b
Binary files /dev/null and b/assets/start_filler/es/veducha/sf14.raw differ
diff --git a/assets/start_filler/es/veducha/sf15.raw b/assets/start_filler/es/veducha/sf15.raw
new file mode 100644
index 0000000..7bef746
Binary files /dev/null and b/assets/start_filler/es/veducha/sf15.raw differ
diff --git a/assets/start_filler/es/veducha/sf16.raw b/assets/start_filler/es/veducha/sf16.raw
new file mode 100644
index 0000000..32782da
Binary files /dev/null and b/assets/start_filler/es/veducha/sf16.raw differ
diff --git a/assets/start_filler/es/veducha/sf17.raw b/assets/start_filler/es/veducha/sf17.raw
new file mode 100644
index 0000000..b89bd48
Binary files /dev/null and b/assets/start_filler/es/veducha/sf17.raw differ
diff --git a/assets/start_filler/es/veducha/sf18.raw b/assets/start_filler/es/veducha/sf18.raw
new file mode 100644
index 0000000..a8184a5
Binary files /dev/null and b/assets/start_filler/es/veducha/sf18.raw differ
diff --git a/assets/start_filler/es/veducha/sf19.raw b/assets/start_filler/es/veducha/sf19.raw
new file mode 100644
index 0000000..e7f56af
Binary files /dev/null and b/assets/start_filler/es/veducha/sf19.raw differ
diff --git a/assets/start_filler/es/veducha/sf20.raw b/assets/start_filler/es/veducha/sf20.raw
new file mode 100644
index 0000000..11a692f
Binary files /dev/null and b/assets/start_filler/es/veducha/sf20.raw differ
diff --git a/assets/start_filler/es/veducha/sf21.raw b/assets/start_filler/es/veducha/sf21.raw
new file mode 100644
index 0000000..a8184a5
Binary files /dev/null and b/assets/start_filler/es/veducha/sf21.raw differ
diff --git a/assets/start_filler/es/veducha/sf22.raw b/assets/start_filler/es/veducha/sf22.raw
new file mode 100644
index 0000000..cf061ff
Binary files /dev/null and b/assets/start_filler/es/veducha/sf22.raw differ
diff --git a/assets/start_filler/es/veducha/texts.txt b/assets/start_filler/es/veducha/texts.txt
new file mode 100644
index 0000000..2412c82
--- /dev/null
+++ b/assets/start_filler/es/veducha/texts.txt
@@ -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...
\ No newline at end of file
diff --git a/assets/start_filler/it/.voice_signature b/assets/start_filler/it/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/start_filler/it/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/start_filler/it/sf01.raw b/assets/start_filler/it/sf01.raw
index 6fabd4a..9f2e798 100644
Binary files a/assets/start_filler/it/sf01.raw and b/assets/start_filler/it/sf01.raw differ
diff --git a/assets/start_filler/it/sf02.raw b/assets/start_filler/it/sf02.raw
index 8b5eb89..b458ce9 100644
Binary files a/assets/start_filler/it/sf02.raw and b/assets/start_filler/it/sf02.raw differ
diff --git a/assets/start_filler/it/sf03.raw b/assets/start_filler/it/sf03.raw
index 16bf384..a913b2c 100644
Binary files a/assets/start_filler/it/sf03.raw and b/assets/start_filler/it/sf03.raw differ
diff --git a/assets/start_filler/it/veducha/.voice_signature b/assets/start_filler/it/veducha/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/start_filler/it/veducha/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/start_filler/it/veducha/sf01.raw b/assets/start_filler/it/veducha/sf01.raw
new file mode 100644
index 0000000..49d5cd4
Binary files /dev/null and b/assets/start_filler/it/veducha/sf01.raw differ
diff --git a/assets/start_filler/it/veducha/sf02.raw b/assets/start_filler/it/veducha/sf02.raw
new file mode 100644
index 0000000..b63302d
Binary files /dev/null and b/assets/start_filler/it/veducha/sf02.raw differ
diff --git a/assets/start_filler/it/veducha/sf03.raw b/assets/start_filler/it/veducha/sf03.raw
new file mode 100644
index 0000000..a83b892
Binary files /dev/null and b/assets/start_filler/it/veducha/sf03.raw differ
diff --git a/assets/start_filler/it/veducha/texts.txt b/assets/start_filler/it/veducha/texts.txt
new file mode 100644
index 0000000..7f3046d
--- /dev/null
+++ b/assets/start_filler/it/veducha/texts.txt
@@ -0,0 +1,3 @@
+[calm] Un momento...
+[thoughtful] Vediamo un po'...
+[sigh] Va bene, arrivo...
\ No newline at end of file
diff --git a/assets/start_filler/pt/.voice_signature b/assets/start_filler/pt/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/start_filler/pt/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/start_filler/sw/.voice_signature b/assets/start_filler/sw/.voice_signature
new file mode 100644
index 0000000..b7cecd4
--- /dev/null
+++ b/assets/start_filler/sw/.voice_signature
@@ -0,0 +1 @@
+264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
diff --git a/assets/start_filler/sw/sf01.raw b/assets/start_filler/sw/sf01.raw
deleted file mode 100644
index 55162b5..0000000
Binary files a/assets/start_filler/sw/sf01.raw and /dev/null differ
diff --git a/assets/start_filler/sw/sf02.raw b/assets/start_filler/sw/sf02.raw
deleted file mode 100644
index 510639a..0000000
Binary files a/assets/start_filler/sw/sf02.raw and /dev/null differ
diff --git a/assets/start_filler/sw/sf03.raw b/assets/start_filler/sw/sf03.raw
deleted file mode 100644
index 6331ea1..0000000
Binary files a/assets/start_filler/sw/sf03.raw and /dev/null differ
diff --git a/assets/start_filler/sw/texts.txt b/assets/start_filler/sw/texts.txt
deleted file mode 100644
index fcc43d2..0000000
--- a/assets/start_filler/sw/texts.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-siku mbili...
-hebu nikujulishe...
-nitaangalia...
\ No newline at end of file
diff --git a/run/refresh_voice_assets.sh b/run/refresh_voice_assets.sh
new file mode 100755
index 0000000..4d90472
--- /dev/null
+++ b/run/refresh_voice_assets.sh
@@ -0,0 +1,413 @@
+#!/usr/bin/env bash
+#
+# Analyze the current TTS voice / cue-asset settings from `.env`, decide which
+# generated cue banks are stale, delete the generated files, and restart the
+# pm2 service so the engine regenerates them at startup.
+#
+# The script is intentionally conservative:
+# - by default it inspects every language subdirectory currently present under
+# `assets/fillers`, `assets/start_filler` and `assets/backchannel`, plus
+# `LANG_DEFAULT`;
+# - it stores a per-directory voice signature in `.voice_signature`;
+# - if the signature did not change, it leaves the bank alone;
+# - `--force` overrides that check and refreshes the selected banks anyway.
+#
+# Usage examples:
+# ./run/refresh_voice_assets.sh
+# ./run/refresh_voice_assets.sh --dry-run
+# ./run/refresh_voice_assets.sh --lang es --lang en
+# ./run/refresh_voice_assets.sh --base-lang-only --force
+#
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+ENV_FILE="$PROJECT_DIR/.env"
+PM2_APP="${PM2_APP:-pm2-piperust}"
+SIGNATURE_FILE=".voice_signature"
+
+DRY_RUN=0
+FORCE=0
+BASE_LANG_ONLY=0
+NO_RESTART=0
+declare -a REQUESTED_LANGS=()
+
+if [ ! -f "$ENV_FILE" ]; then
+ echo "[refresh_voice_assets] missing .env: $ENV_FILE" >&2
+ exit 1
+fi
+
+usage() {
+ cat <<'EOF'
+refresh_voice_assets.sh
+
+Analyze voice-related `.env` settings, refresh stale cue asset banks, and
+restart the pm2 service so the engine regenerates them.
+
+Options:
+ --dry-run Print what would be refreshed, do not delete or restart
+ --force Refresh the selected banks even when the signature matches
+ --lang Restrict the refresh to one language (repeatable)
+ --base-lang-only Restrict the refresh to LANG_DEFAULT
+ --no-restart Delete files but do not restart pm2
+ --help Show this help
+
+Environment override:
+ PM2_APP= pm2 app to restart (default: pm2-piperust)
+EOF
+}
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --dry-run) DRY_RUN=1 ;;
+ --force) FORCE=1 ;;
+ --base-lang-only) BASE_LANG_ONLY=1 ;;
+ --no-restart) NO_RESTART=1 ;;
+ --lang)
+ shift
+ [ $# -gt 0 ] || { echo "[refresh_voice_assets] --lang requires a value" >&2; exit 1; }
+ REQUESTED_LANGS+=("$1")
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "[refresh_voice_assets] unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+ shift
+done
+
+set -a
+# shellcheck disable=SC1090
+source "$ENV_FILE"
+set +a
+
+TTS_ENGINE="${TTS_ENGINE:-fish}"
+LANG_DEFAULT="${LANG_DEFAULT:-es}"
+FILLERS_DIR="${FILLERS_DIR:-assets/fillers}"
+START_FILLER_DIR="${START_FILLER_DIR:-assets/start_filler}"
+BACKCHANNEL_DIR="${BACKCHANNEL_DIR:-assets/backchannel}"
+TTS_MODEL="${TTS_MODEL:-}"
+TTS_VOICE="${TTS_VOICE:-}"
+ASSET_VOICE_PROFILE="${ASSET_VOICE_PROFILE:-}"
+TTS_REFERENCE_AUDIO="${TTS_REFERENCE_AUDIO:-}"
+TTS_REFERENCE_TEXT="${TTS_REFERENCE_TEXT:-}"
+TTS_TEMPERATURE="${TTS_TEMPERATURE:-}"
+TTS_TOP_P="${TTS_TOP_P:-}"
+TTS_TOP_K="${TTS_TOP_K:-}"
+TTS_STYLE_TAG="${TTS_STYLE_TAG:-}"
+SGLANG_URL="${SGLANG_URL:-}"
+KOKORO_URL="${KOKORO_URL:-}"
+KOKORO_VOICE="${KOKORO_VOICE:-}"
+START_FILLER_TEXTS="${START_FILLER_TEXTS:-}"
+BACKCHANNEL_TEXTS="${BACKCHANNEL_TEXTS:-}"
+
+abs_path() {
+ local p="$1"
+ if [[ "$p" = /* ]]; then
+ printf '%s\n' "$p"
+ else
+ printf '%s\n' "$PROJECT_DIR/$p"
+ fi
+}
+
+sha_file() {
+ local path="$1"
+ if [ -f "$path" ]; then
+ sha256sum "$path" | awk '{print $1}'
+ else
+ printf 'missing'
+ fi
+}
+
+sha_text() {
+ printf '%s' "$1" | sha256sum | awk '{print $1}'
+}
+
+count_entries() {
+ printf '%s' "$1" | tr '|' '\n' | awk 'NF { c++ } END { print c + 0 }'
+}
+
+sanitize_asset_segment() {
+ local raw="$1"
+ printf '%s' "$raw" \
+ | tr '[:upper:]' '[:lower:]' \
+ | sed -E 's/[^a-z0-9._ -]+/_/g; s/[ .-]+/_/g; s/^_+//; s/_+$//'
+}
+
+cue_voice_profile() {
+ local explicit
+ explicit="$(sanitize_asset_segment "$ASSET_VOICE_PROFILE")"
+ if [ -n "$explicit" ]; then
+ printf '%s\n' "$explicit"
+ return 0
+ fi
+
+ local voice
+ voice="$(sanitize_asset_segment "$TTS_VOICE")"
+ if [ -n "$voice" ] && [ "$voice" != "default" ]; then
+ printf '%s\n' "$voice"
+ return 0
+ fi
+
+ if [ -n "$TTS_REFERENCE_AUDIO" ]; then
+ local stem
+ stem="$(basename "${TTS_REFERENCE_AUDIO%.*}")"
+ stem="$(sanitize_asset_segment "$stem")"
+ if [ -n "$stem" ]; then
+ printf '%s\n' "$stem"
+ return 0
+ fi
+ fi
+
+ if [ -n "$voice" ]; then
+ printf '%s\n' "$voice"
+ else
+ printf 'default\n'
+ fi
+}
+
+read_reference_text_mode() {
+ if [ -z "$TTS_REFERENCE_TEXT" ]; then
+ printf 'auto'
+ elif [ -f "$TTS_REFERENCE_TEXT" ]; then
+ printf 'file'
+ else
+ printf 'inline'
+ fi
+}
+
+reference_text_hash() {
+ if [ -z "$TTS_REFERENCE_TEXT" ]; then
+ printf 'auto'
+ elif [ -f "$TTS_REFERENCE_TEXT" ]; then
+ sha_file "$TTS_REFERENCE_TEXT"
+ else
+ sha_text "$TTS_REFERENCE_TEXT"
+ fi
+}
+
+voice_signature() {
+ {
+ printf 'TTS_ENGINE=%s\n' "$TTS_ENGINE"
+ printf 'LANG_DEFAULT=%s\n' "$LANG_DEFAULT"
+ printf 'SGLANG_URL=%s\n' "$SGLANG_URL"
+ printf 'TTS_MODEL=%s\n' "$TTS_MODEL"
+ printf 'TTS_VOICE=%s\n' "$TTS_VOICE"
+ printf 'ASSET_VOICE_PROFILE=%s\n' "$ASSET_VOICE_PROFILE"
+ printf 'TTS_REFERENCE_AUDIO=%s\n' "$TTS_REFERENCE_AUDIO"
+ printf 'TTS_REFERENCE_AUDIO_HASH=%s\n' "$(sha_file "$TTS_REFERENCE_AUDIO")"
+ printf 'TTS_REFERENCE_TEXT_MODE=%s\n' "$(read_reference_text_mode)"
+ printf 'TTS_REFERENCE_TEXT_HASH=%s\n' "$(reference_text_hash)"
+ printf 'TTS_TEMPERATURE=%s\n' "$TTS_TEMPERATURE"
+ printf 'TTS_TOP_P=%s\n' "$TTS_TOP_P"
+ printf 'TTS_TOP_K=%s\n' "$TTS_TOP_K"
+ printf 'TTS_STYLE_TAG=%s\n' "$TTS_STYLE_TAG"
+ printf 'KOKORO_URL=%s\n' "$KOKORO_URL"
+ printf 'KOKORO_VOICE=%s\n' "$KOKORO_VOICE"
+ printf 'START_FILLER_TEXTS_HASH=%s\n' "$(sha_text "$START_FILLER_TEXTS")"
+ printf 'BACKCHANNEL_TEXTS_HASH=%s\n' "$(sha_text "$BACKCHANNEL_TEXTS")"
+ } | sha256sum | awk '{print $1}'
+}
+
+print_summary() {
+ echo "Project : $PROJECT_DIR"
+ echo "Env file : $ENV_FILE"
+ echo "pm2 app : $PM2_APP"
+ echo "TTS engine : $TTS_ENGINE"
+ echo "SGLang URL : ${SGLANG_URL:-}"
+ echo "TTS model : ${TTS_MODEL:-}"
+ echo "TTS voice : ${TTS_VOICE:-}"
+ echo "Cue voice profile : $CUE_VOICE_PROFILE"
+ echo "Reference audio : ${TTS_REFERENCE_AUDIO:-}"
+ echo "Reference audio hash : $(sha_file "$TTS_REFERENCE_AUDIO")"
+ echo "Reference text mode : $(read_reference_text_mode)"
+ echo "Reference text hash : $(reference_text_hash)"
+ echo "Temperature / top-p/k : ${TTS_TEMPERATURE:-} / ${TTS_TOP_P:-} / ${TTS_TOP_K:-}"
+ echo "Style tag : ${TTS_STYLE_TAG:-}"
+ echo "LANG_DEFAULT : $LANG_DEFAULT"
+ echo "FILLERS_DIR : $(abs_path "$FILLERS_DIR")"
+ echo "START_FILLER_DIR : $(abs_path "$START_FILLER_DIR")"
+ echo "BACKCHANNEL_DIR : $(abs_path "$BACKCHANNEL_DIR")"
+ echo "START_FILLER_TEXTS : $(count_entries "$START_FILLER_TEXTS") entries"
+ echo "BACKCHANNEL_TEXTS : $(count_entries "$BACKCHANNEL_TEXTS") entries"
+ echo "Voice signature : $CURRENT_SIGNATURE"
+}
+
+declare -A LANGS=()
+add_lang() {
+ local lang="$1"
+ [ -n "$lang" ] || return 0
+ LANGS["$lang"]=1
+}
+
+collect_languages_from_dir() {
+ local base_dir="$1"
+ [ -d "$base_dir" ] || return 0
+ local d
+ for d in "$base_dir"/*; do
+ [ -d "$d" ] || continue
+ add_lang "$(basename "$d")"
+ done
+}
+
+FILLERS_ABS="$(abs_path "$FILLERS_DIR")"
+START_FILLER_ABS="$(abs_path "$START_FILLER_DIR")"
+BACKCHANNEL_ABS="$(abs_path "$BACKCHANNEL_DIR")"
+CUE_VOICE_PROFILE="$(cue_voice_profile)"
+CURRENT_SIGNATURE="$(voice_signature)"
+
+if [ "${#REQUESTED_LANGS[@]}" -gt 0 ]; then
+ for lang in "${REQUESTED_LANGS[@]}"; do
+ add_lang "$lang"
+ done
+elif [ "$BASE_LANG_ONLY" -eq 1 ]; then
+ add_lang "$LANG_DEFAULT"
+else
+ add_lang "$LANG_DEFAULT"
+ collect_languages_from_dir "$FILLERS_ABS"
+ collect_languages_from_dir "$START_FILLER_ABS"
+ collect_languages_from_dir "$BACKCHANNEL_ABS"
+fi
+
+mapfile -t TARGET_LANGS < <(printf '%s\n' "${!LANGS[@]}" | sort)
+
+bank_dir() {
+ local bank_root="$1"
+ local lang="$2"
+ printf '%s/%s/%s\n' "$bank_root" "$lang" "$CUE_VOICE_PROFILE"
+}
+
+count_raw() {
+ local dir="$1"
+ if [ -d "$dir" ]; then
+ find "$dir" -maxdepth 1 -type f -name '*.raw' | wc -l | awk '{print $1}'
+ else
+ printf '0'
+ fi
+}
+
+saved_signature() {
+ local dir="$1"
+ local path="$dir/$SIGNATURE_FILE"
+ [ -f "$path" ] && cat "$path" || true
+}
+
+refresh_reason() {
+ local dir="$1"
+ local saved="$2"
+ if [ "$FORCE" -eq 1 ]; then
+ printf 'forced'
+ elif [ ! -d "$dir" ]; then
+ printf 'missing dir'
+ elif [ -z "$saved" ]; then
+ printf 'missing signature'
+ elif [ "$saved" != "$CURRENT_SIGNATURE" ]; then
+ printf 'signature changed'
+ else
+ printf ''
+ fi
+}
+
+clean_bank() {
+ local kind="$1"
+ local dir="$2"
+ mkdir -p "$dir"
+ find "$dir" -maxdepth 1 -type f -name '*.raw' -delete
+ rm -f "$dir/$SIGNATURE_FILE"
+ if [ "$kind" = "start-filler" ] || [ "$kind" = "backchannel" ]; then
+ rm -f "$dir/texts.txt"
+ fi
+}
+
+write_signature_marker() {
+ local dir="$1"
+ mkdir -p "$dir"
+ printf '%s\n' "$CURRENT_SIGNATURE" > "$dir/$SIGNATURE_FILE"
+}
+
+NEEDS_REFRESH=0
+declare -a PLAN=()
+
+print_summary
+echo
+echo "Target languages : ${TARGET_LANGS[*]}"
+echo
+printf '%-14s %-6s %-18s %-8s %-64s %s\n' "Bank" "Lang" "Voice profile" "Clips" "Saved signature" "Action"
+printf '%-14s %-6s %-18s %-8s %-64s %s\n' "--------------" "------" "------------------" "--------" "----------------------------------------------------------------" "----------------"
+
+for lang in "${TARGET_LANGS[@]}"; do
+ for kind in filler start-filler backchannel; do
+ case "$kind" in
+ filler) dir="$(bank_dir "$FILLERS_ABS" "$lang")" ;;
+ start-filler) dir="$(bank_dir "$START_FILLER_ABS" "$lang")" ;;
+ backchannel) dir="$(bank_dir "$BACKCHANNEL_ABS" "$lang")" ;;
+ esac
+ clips="$(count_raw "$dir")"
+ saved="$(saved_signature "$dir")"
+ reason="$(refresh_reason "$dir" "$saved")"
+ if [ -n "$reason" ]; then
+ action="refresh ($reason)"
+ NEEDS_REFRESH=1
+ PLAN+=("$kind::$dir")
+ else
+ action="keep"
+ fi
+ printf '%-14s %-6s %-18s %-8s %-64s %s\n' "$kind" "$lang" "$CUE_VOICE_PROFILE" "$clips" "${saved:-}" "$action"
+ done
+done
+
+if [ "$NEEDS_REFRESH" -eq 0 ]; then
+ echo
+ echo "No refresh needed: signatures already match the current .env voice settings."
+ exit 0
+fi
+
+echo
+if [ "$DRY_RUN" -eq 1 ]; then
+ echo "Dry run only: no files deleted, no pm2 restart."
+ exit 0
+fi
+
+echo "Cleaning stale/generated cue assets..."
+for item in "${PLAN[@]}"; do
+ kind="${item%%::*}"
+ dir="${item#*::}"
+ clean_bank "$kind" "$dir"
+ echo " cleaned $kind -> $dir"
+done
+
+if [ "$NO_RESTART" -eq 1 ]; then
+ echo
+ echo "Files cleaned. Restart skipped (--no-restart)."
+ echo "Run: pm2 restart $PM2_APP --update-env"
+ exit 0
+fi
+
+echo
+echo "Restarting pm2 app: $PM2_APP"
+pm2 restart "$PM2_APP" --update-env
+
+echo "Waiting for /health..."
+for _ in $(seq 1 30); do
+ if curl -fsS "http://localhost:${SERVER_PORT:-8787}/health" >/dev/null 2>&1; then
+ break
+ fi
+ sleep 1
+done
+
+echo "Writing signature markers..."
+for lang in "${TARGET_LANGS[@]}"; do
+ write_signature_marker "$(bank_dir "$FILLERS_ABS" "$lang")"
+ write_signature_marker "$(bank_dir "$START_FILLER_ABS" "$lang")"
+ write_signature_marker "$(bank_dir "$BACKCHANNEL_ABS" "$lang")"
+done
+
+echo
+echo "Done."
+echo "Tip: inspect recent regeneration logs with:"
+echo " pm2 logs $PM2_APP --nostream --lines 120 | rg 'refreshing|generated|up to date|clip banks bootstrapped'"
diff --git a/run/sdk_smoke.mjs b/run/sdk_smoke.mjs
new file mode 100644
index 0000000..ea85174
--- /dev/null
+++ b/run/sdk_smoke.mjs
@@ -0,0 +1,41 @@
+import { AudioAgentClient, AudioAgentRest } from '../sdk/audio-agent-client.js';
+
+const rest = new AudioAgentRest('http://localhost:8787');
+console.log('REST health =>', JSON.stringify(await rest.health()));
+const tools = await rest.getTools();
+console.log('REST tools =>', tools.count, tools.source);
+
+const client = new AudioAgentClient({ baseUrl: 'http://localhost:8787' });
+const seen = [];
+
+await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('timeout waiting for cues')), 8000);
+ client.on('cues', (e) => {
+ seen.push(`cues(lang=${e.lang})`);
+ clearTimeout(timer);
+ resolve();
+ });
+ client.on('error', (e) => console.error('ERR event', e));
+ client.connect().then(() => console.log('WS connected =>', client.url)).catch(reject);
+});
+
+// Observe the turn events + decoded PCM frames.
+let audioFrames = 0;
+client.on('user', (e) => seen.push(`user(${JSON.stringify(e.text)})`));
+client.on('assistant_delta', (e) => seen.push(`assistant_delta(len=${e.text.length})`));
+client.on('assistant_end', () => seen.push('assistant_end'));
+client.on('cue', (e) => seen.push(`cue(${e.kind})`));
+client.on('tool_call', (e) => seen.push(`tool_call(${e.name})`));
+client.on('audio', (e) => {
+ audioFrames++;
+ if (e.samples.length !== 882) seen.push(`BAD frame ${e.samples.length}`);
+});
+
+// Send a PCM frame (882 samples of silence) + a text turn, then confirm events flow.
+console.log('sendAudio =>', client.sendAudio(new Int16Array(882)));
+client.sendUserText('di solo "ciao"');
+await new Promise((r) => setTimeout(r, 12000));
+client.close();
+console.log('events seen:', seen.join(', '));
+console.log('audio frames decoded:', audioFrames);
+process.exit(0);
diff --git a/sdk/README.md b/sdk/README.md
new file mode 100644
index 0000000..9e7f9d8
--- /dev/null
+++ b/sdk/README.md
@@ -0,0 +1,158 @@
+# audio-agent-client — JavaScript SDK
+
+Client SDK for the `audio-agent-engine` engine. A single ESM file, zero
+dependencies, no build step: copy `audio-agent-client.js` wherever you want
+(another server, a CDN, your own project) and import it. The engine also serves
+it at `GET /sdk/audio-agent-client.js`.
+
+Files: [`audio-agent-client.js`](audio-agent-client.js) ·
+[`audio-agent-client.d.ts`](audio-agent-client.d.ts) ·
+[`demo.html`](demo.html).
+
+---
+
+## Demo
+
+[`demo.html`](demo.html) is a complete client built on the SDK:
+connect/disconnect, session state, streaming agent replies with word-level
+*karaoke* highlighting, chat bubbles, inline chips for `cue` / `tool_call` /
+`audio_source`, a barge-in counter, a microphone toggle with a level meter, a
+text-turn input, an external audio source (play/stop/volume), a **System prompt**
+panel (shows the server-assigned session id and lets you reload / override /
+clear the session's LLM prompt over REST), automatic phone-number authentication
+(read from `?phoneNumber=…` in the page URL at session start, via the engine's
+`/api/auth/phone-login` proxy; when a live session id is present the engine also
+stores a sanitized user profile as hidden LLM context), a default runtime tool
+set installed via `POST /api/tools` and a raw event log. A tool definition may
+carry an extra `audio: true` flag (the backend ignores it): when the model calls
+such a tool, the demo rebuilds the call's URL, puts it in the external audio
+source field and starts playback after a fixed post-`assistant_end` delay, so
+media results (e.g. `get_today_podcast_mp3`) do not overlap the agent's spoken
+reply while still autoplaying reliably. When the model emits a
+`...` block, the demo renders that document as
+formatted Markdown in chat while keeping it out of TTS.
+The engine serves it at `GET /sdk/demo.html`; you can also copy it elsewhere (it
+imports `./audio-agent-client.js` relatively). Opened from `file://` it works but
+without a microphone: it warns via a banner (a *secure context* is required).
+
+## Why
+
+The client is **decoupled** from the engine: the engine stays put, the UI can
+live on another origin/server. The SDK wraps the protocol (WebSocket + REST) and
+the PCM audio so you don't reimplement framing, sampling and events.
+
+## Install
+
+Nothing to install. Copy the file and import it, or load it from the engine:
+
+```js
+import { AudioSession, AudioAgentRest } from 'http://localhost:8787/sdk/audio-agent-client.js';
+```
+
+If the client is on **another origin**, enable CORS on the engine:
+
+```dotenv
+# engine .env
+CORS_ALLOW_ORIGIN=https://your-app.example # or * for any origin
+```
+
+The WebSocket (`/ws`) needs **no** CORS (the handshake is not a CORS request).
+For the microphone the browser requires a *secure context*: `https://` or
+`localhost`.
+
+## Quick start (browser)
+
+```js
+import { AudioSession } from './audio-agent-client.js';
+
+const session = new AudioSession({ baseUrl: 'https://engine.example.com:8787' });
+
+session.on('user', (e) => console.log('user:', e.text));
+session.on('assistant_delta', (e) => (bubble.textContent += e.text));
+session.on('error', (e) => console.error(e));
+
+await session.start(); // connect + microphone + playback
+session.sendUserText('hello'); // text turn (LLM → TTS)
+session.interrupt(); // barge-in
+await session.stop();
+```
+
+## API
+
+### `new AudioAgentClient(options)`
+
+| Option | Default | Description |
+|---|---|---|
+| `url` | — | Full `ws://` / `wss://` URL. Wins over `baseUrl`. |
+| `baseUrl` | — | Engine origin (`http://host:port`); `/ws` is appended. |
+| `path` | `/ws` | Path appended to `baseUrl`. |
+| `autoReconnect` | `false` | Reconnect after an unexpected close. |
+| `reconnectMinDelayMs` / `reconnectMaxDelayMs` | `500` / `8000` | Exponential backoff. |
+| `WebSocket` | global | WebSocket implementation (Node < 22: pass the `ws` one). |
+
+Methods: `connect()`, `close()`, `sendAudio(chunk)`, `sendUserText(text)`,
+`interrupt()`, `playAudioSource(url)`, `stopAudioSource()`,
+`setAudioSourceVolume(gain)`, `setReference(path, text?)`, `clearReference()`,
+`speak(text)`, `sendText(text)`, `sendControl(obj)`.
+Properties: `state` (`idle`/`connecting`/`open`/`closed`), `connected`.
+
+`sendAudio` accepts `Int16Array`, `Float32Array` (normalized -1..1),
+`ArrayBuffer`, a view, or `number[]`. The engine expects **16-bit LE mono PCM at
+44.1 kHz**; sending **882-sample** frames (20 ms) keeps the cadence.
+
+Events: `open`, `close`, `state`, `reconnecting`, plus one event per server
+`type` — `session`, `cues`, `user`, `assistant_delta`, `assistant_markdown`,
+`assistant_end`, `cue`, `barge_in`, `tool_call`, `audio_source`, `error` — and:
+- `audio` → `{ samples, buffer, sampleRate }` with the decoded PCM frames;
+- `event` → any raw JSON message;
+- `error` → either a transport error `{ error, event, fatal }` or the server
+ event `{ type: 'error', text }`.
+
+The `session` event (`{ type: 'session', id }`) is sent once at connect time and
+carries the server-assigned session id. It is the key used by the REST prompt
+endpoints, so capture it to read or override the system prompt for your session.
+
+Every `on()` returns an unsubscribe function.
+
+### `new AudioAgentRest(baseUrl, options?)`
+
+| Method | Endpoint |
+|---|---|
+| `health()` | `GET /health` |
+| `getTools()` | `GET /api/tools` |
+| `setTools(tools)` | `POST /api/tools` |
+| `getPrompt(sessionId?)` | `GET /api/prompt` |
+| `setPrompt(prompt, sessionId?)` | `POST /api/prompt` |
+| `phoneLogin(phoneNumber, sessionId?)` | `POST /api/auth/phone-login` |
+| `getCustomVoicePhrases()` | `GET /api/customvoice/phrases` |
+| `setCustomVoice(lang, audio, text?)` | `POST /api/customvoice` |
+| `clearCustomVoice()` | `POST /api/customvoice/clear` |
+| `transcribe(audio)` | `POST /api/transcribe` |
+| `openapi()` | `GET /api-docs/openapi.json` |
+
+On a non-2xx response the thrown error carries `status` and `body`.
+
+### Audio helpers (browser only)
+
+- **`MicCapture`** — microphone → `frame` events (`Int16Array`, 882 samples).
+ `await start()`, `stop()`.
+- **`PcmPlayer`** — jitter-buffered playback (`bufferMs`, default 150).
+ `push(chunk)`, `flush()` (barge-in), `setGain()`, `close()`.
+- **`AudioSession`** — wires client + mic + player and re-emits client events.
+ `await start({ microphone = true })`, `stop()`, `sendUserText()`, `interrupt()`.
+
+### PCM utilities
+
+`int16ArrayFromBytes(buffer)`, `float32ToInt16(floats)`,
+`int16ToFloat32(ints)`, `toInt16(chunk)` and the `AUDIO` constant
+(`sampleRate: 44100`, `frameSamples: 882`, `frameBytes: 1764`).
+
+## Common pitfalls
+
+- **Microphone denied** → the page is not a *secure context*: use `https://` or
+ `localhost`.
+- **`Failed to fetch` on REST from another origin** → set `CORS_ALLOW_ORIGIN` on
+ the engine.
+- **No playback** → the `AudioContext` starts suspended until a user gesture:
+ call `start()` / `player.resume()` from a click.
+- **Node: `no WebSocket implementation`** → Node < 22: pass the `ws` `WebSocket`.
diff --git a/sdk/audio-agent-client.d.ts b/sdk/audio-agent-client.d.ts
new file mode 100644
index 0000000..8870c77
--- /dev/null
+++ b/sdk/audio-agent-client.d.ts
@@ -0,0 +1,336 @@
+/**
+ * Type declarations for `audio-agent-client.js`.
+ *
+ * The SDK is plain ESM with no build step; this file exists so TypeScript
+ * consumers get autocompletion and type checking. Keep it in sync with the JS.
+ */
+
+export interface AudioFormat {
+ readonly sampleRate: 44100;
+ readonly frameSamples: 882;
+ readonly frameBytes: 1764;
+ readonly sttSampleRate: 16000;
+}
+
+export const AUDIO: AudioFormat;
+
+export const CLIENT_EVENTS: readonly string[];
+
+/** Union accepted by every audio-sending helper. */
+export type AudioChunk =
+ | Int16Array
+ | Float32Array
+ | ArrayBuffer
+ | ArrayBufferView
+ | number[];
+
+export function int16ArrayFromBytes(buffer: ArrayBuffer | ArrayBufferView): Int16Array;
+export function float32ToInt16(floats: Float32Array | number[]): Int16Array;
+export function int16ToFloat32(ints: Int16Array): Float32Array;
+export function toInt16(chunk: AudioChunk): Int16Array;
+
+/** Any object with a `type` discriminator, as emitted by the engine. */
+export interface ServerEvent {
+ type: string;
+ [key: string]: unknown;
+}
+
+/** Server-assigned session id, sent once at connect time. */
+export interface SessionEvent extends ServerEvent {
+ type: 'session';
+ id: string;
+}
+export interface CuesEvent extends ServerEvent {
+ type: 'cues';
+ lang: string;
+ config: Record;
+}
+export interface UserEvent extends ServerEvent {
+ type: 'user';
+ text: string;
+}
+export interface AssistantDeltaEvent extends ServerEvent {
+ type: 'assistant_delta';
+ text: string;
+}
+export interface AssistantMarkdownEvent extends ServerEvent {
+ type: 'assistant_markdown';
+ text: string;
+}
+export interface AssistantEndEvent extends ServerEvent {
+ type: 'assistant_end';
+}
+export interface CueEvent extends ServerEvent {
+ type: 'cue';
+ kind: 'filler' | 'start_filler' | 'backchannel';
+ name: string;
+ category?: string;
+ pause_ms?: number;
+}
+export interface BargeInEvent extends ServerEvent {
+ type: 'barge_in';
+ source: string;
+ prob?: number;
+}
+export interface ToolCallEvent extends ServerEvent {
+ type: 'tool_call';
+ name: string;
+ arguments: unknown;
+}
+export interface AudioSourceEvent extends ServerEvent {
+ type: 'audio_source';
+ state: 'loading' | 'playing' | 'stopped' | 'error';
+ url: string;
+ text?: string;
+}
+export interface ErrorEvent extends ServerEvent {
+ type: 'error';
+ text: string;
+}
+
+/** Payload of the local `audio` event: one decoded PCM frame. */
+export interface AudioFrame {
+ samples: Int16Array;
+ buffer: ArrayBuffer;
+ sampleRate: number;
+}
+
+/** Payload of the local `close` event. */
+export interface CloseEvent {
+ code: number;
+ reason: string;
+ wasClean: boolean;
+}
+
+/** Payload for connection-level failures (as opposed to a server `error` event). */
+export interface TransportError {
+ error: Error;
+ event?: unknown;
+ fatal: boolean;
+}
+
+export interface AudioAgentClientOptions {
+ /** Full `ws://`/`wss://` URL; wins over `baseUrl`. */
+ url?: string;
+ /** Engine origin, e.g. `http://localhost:8787`; `/ws` is appended. */
+ baseUrl?: string;
+ /** Path appended to `baseUrl` (default `/ws`). */
+ path?: string;
+ /** Reconnect automatically after an unexpected close (default `false`). */
+ autoReconnect?: boolean;
+ reconnectMinDelayMs?: number;
+ reconnectMaxDelayMs?: number;
+ /** WebSocket implementation to use (required on Node < 22, e.g. the `ws` package). */
+ WebSocket?: typeof WebSocket;
+}
+
+export type ConnectionState = 'idle' | 'connecting' | 'open' | 'closed';
+
+export class AudioAgentClient {
+ constructor(options?: AudioAgentClientOptions);
+ readonly url: string;
+ autoReconnect: boolean;
+ reconnectMinDelayMs: number;
+ reconnectMaxDelayMs: number;
+ readonly state: ConnectionState;
+ readonly connected: boolean;
+
+ connect(): Promise;
+ close(code?: number, reason?: string): void;
+
+ sendAudio(chunk: AudioChunk): boolean;
+ sendText(text: string): boolean;
+ sendControl(message: Record): boolean;
+ sendUserText(text: string): boolean;
+ interrupt(): boolean;
+ playAudioSource(url: string): boolean;
+ stopAudioSource(): boolean;
+ setAudioSourceVolume(gain: number): boolean;
+ setReference(path: string, text?: string): boolean;
+ clearReference(): boolean;
+ speak(text: string): boolean;
+
+ on(type: 'audio', handler: (frame: AudioFrame) => void): () => void;
+ on(type: 'open', handler: (info: { url: string }) => void): () => void;
+ on(type: 'close', handler: (info: CloseEvent) => void): () => void;
+ on(type: 'state', handler: (state: ConnectionState) => void): () => void;
+ on(
+ type: 'reconnecting',
+ handler: (info: { attempt: number; delay: number }) => void,
+ ): () => void;
+ on(type: 'error', handler: (payload: TransportError | ErrorEvent) => void): () => void;
+ on(type: 'event' | 'unknown', handler: (event: ServerEvent) => void): () => void;
+ on(type: string, handler: (payload: any) => void): () => void;
+
+ once(type: string, handler: (payload: any) => void): () => void;
+ off(type: string, handler: (payload: any) => void): void;
+ emit(type: string, payload?: unknown): void;
+}
+
+export interface AudioAgentRestOptions {
+ fetch?: typeof fetch;
+ headers?: Record;
+}
+
+export interface ToolsResponse {
+ ok: boolean;
+ count: number;
+ source: 'file' | 'request' | 'none';
+ tools: object[];
+}
+
+export interface PhrasesResponse {
+ default: string;
+ languages: Array<{ code: string; label: string; phrase: string }>;
+}
+
+export interface SetVoiceResponse {
+ ok: boolean;
+ lang: string;
+ audio: string;
+ phrase: string;
+}
+
+export interface TranscribeResponse {
+ ok: boolean;
+ text: string;
+ duration_ms: number;
+ tokens: number;
+}
+
+/** Where the effective system prompt comes from (decreasing precedence). */
+export type PromptSource = 'session' | 'global' | 'env' | 'default' | 'none';
+
+/** Response of `GET`/`POST /api/prompt`. */
+export interface PromptResponse {
+ ok: boolean;
+ session_id: string | null;
+ source: PromptSource;
+ prompt: string;
+ has_override: boolean;
+ direct_answers_applied: boolean;
+}
+
+/** Response of `POST /api/auth/phone-login` (upstream AquaChile payload). */
+export interface PhoneLoginResponse {
+ sessionToken?: string;
+ user?: Record;
+ [key: string]: any;
+}
+
+/** Error thrown by {@link AudioAgentRest}, carrying the HTTP status and body. */
+export interface RestError extends Error {
+ status: number;
+ body: unknown;
+}
+
+export class AudioAgentRest {
+ constructor(baseUrl: string, options?: AudioAgentRestOptions);
+ readonly baseUrl: string;
+
+ health(): Promise;
+ getTools(): Promise;
+ setTools(tools: object[]): Promise;
+ getPrompt(sessionId?: string): Promise;
+ setPrompt(prompt: string | null, sessionId?: string | null): Promise;
+ phoneLogin(phoneNumber: string, sessionId?: string | null): Promise;
+ getCustomVoicePhrases(): Promise;
+ setCustomVoice(
+ lang: string,
+ audio: Blob | ArrayBuffer | ArrayBufferView,
+ text?: string,
+ ): Promise;
+ clearCustomVoice(): Promise<{ ok: boolean }>;
+ transcribe(audio: Blob | ArrayBuffer | ArrayBufferView): Promise;
+ openapi(): Promise