commit ad95d530adf587953ae2cb2096e54fc3082a5fe1 Author: valenti Date: Wed Aug 26 17:42:00 2026 -0400 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fd9795 --- /dev/null +++ b/.gitignore @@ -0,0 +1,141 @@ +# ───────────────────────────────────────────────────────────── +# Python — Virtualenvs, bytecode, caches +# ───────────────────────────────────────────────────────────── +.venv/ +venv/ +ENV/ +env/ +.s2s-venv/ +*/.venv/ +*/venv/ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.dist-info/ +*.egg +.Python +build/ +dist/ +pip-wheel-metadata/ +.eggs/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.pyre/ +.pytype/ +.ipynb_checkpoints/ +.cache/ + +# ───────────────────────────────────────────────────────────── +# Node.js / npm / Taro / frontend build caches +# ───────────────────────────────────────────────────────────── +node_modules/ +*/node_modules/ +.pnpm-store/ +.yarn-cache/ +.yarn/ +.npm/ +package-lock.json +!speech_to_speech/package-lock.json +yarn-error.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm-debug.log +.yarn-integrity +.svelte-kit/ +.next/ +.nuxt/ +.output/ +.parcel-cache/ +.cache/ +.turbo/ +.rpt2_cache/ + +# ───────────────────────────────────────────────────────────── +# Environment / secrets +# ───────────────────────────────────────────────────────────── +.env +.env.* +!.env.example +secrets/ +*.key +*.pem +*.p12 +*.crt +*.csr +credentials.json +token.json +service-account.json +id_rsa* + +# ───────────────────────────────────────────────────────────── +# Logs & databases & runtime data +# ───────────────────────────────────────────────────────────── +*.log +*.log.* +logs/ +log/ +*.db +*.sqlite +*.sqlite3 +*.journal +*.db-journal +*.db-wal +*.db-shm +data/ +dumps/ +uploads/ +static/uploads/ +tmp/ +temp/ +.DS_Store +Thumbs.db + +# ───────────────────────────────────────────────────────────── +# IDE / OS / editors +# ───────────────────────────────────────────────────────────── +.idea/ +.vscode/ +!.vscode/extensions.json +!.vscode/launch.json +*.swp +*.swo +*~ +.project +.classpath +.settings/ +*.sublime-workspace +*.sublime-project +.history/ +.hg/ +.svn/ +CVS/ + +# ───────────────────────────────────────────────────────────── +# Trae IDE specific +# ───────────────────────────────────────────────────────────── +.dbg/ +*.trae +.trae/ + +# ───────────────────────────────────────────────────────────── +# Reasoning engine (Node-RED) local state +# ───────────────────────────────────────────────────────────── +s2s-reasoning-engine/node_modules/ +s2s-reasoning-engine/.node-red/ +s2s-reasoning-engine/lib/flows_cred.json +s2s-reasoning-engine/.config.* + +# ───────────────────────────────────────────────────────────── +# Demo static artifacts generated at runtime by browser / proxy logs +# ───────────────────────────────────────────────────────────── +demo/.cache/ +demo/uploads/ +demo/sessions/ +demo/*.log +speech_to_speech/*.log +speech_to_speech/sessions/ +speech_to_speech/.cache/ +s2s-reasoning-engine/*.log diff --git a/debug-mcp-audio-stutter.md b/debug-mcp-audio-stutter.md new file mode 100644 index 0000000..d4cb57d --- /dev/null +++ b/debug-mcp-audio-stutter.md @@ -0,0 +1,45 @@ +# Debug Session: mcp-audio-stutter +- **Status**: [OPEN] +- **Issue**: Piccole interruzioni audio quando una risposta MCP lunga viene eseguita o reiniettata nella conversazione live. + +## Reproduction Steps +1. Avvia la UI realtime. +2. Esegui una richiesta che attiva un tool MCP con output grande. +3. Ascolta la risposta audio dell'assistente. +4. Verifica se compaiono micro-interruzioni durante o subito dopo l'iniezione del risultato MCP. + +## Hypotheses & Verification +| ID | Hypothesis | Likelihood | Effort | Evidence | +|----|------------|------------|--------|----------| +| A | Inviare al modello il payload MCP quasi completo degrada la fluidità della risposta audio anche senza bloccare il main thread. | High | Low | Confirmed | +| B | Esiste una race: il fallback MCP decide il turno prima che `response.created` arrivi, quindi la prima response speculativa parte comunque e viene tagliata. | High | Medium | Confirmed | +| C | La chat/UI fa render pesanti quando arriva il risultato MCP grande. | Medium | Medium | Rejected | +| D | I log/debug del payload MCP grande aggravano il problema sul main thread. | Medium | Low | Rejected | +| E | L'attacco audio di ogni frase soffre di jitter iniziale perché il playback parte senza prebuffer sul primo chunk disponibile. | High | Medium | Confirmed | + +## Log Evidence +- `execRemoteMcpTool` riceve un result JSON di `5738` byte e produce un output testo di `3262` caratteri senza tempi anomali. +- `built live prompt from MCP output` mostra `promptLength` ~`3400` e `buildMs: 0`. +- `rendered tool result row` e `rendered MCP tool result in chat` mostrano `renderMs: 0-1`. +- `sending conversation.item.create payload` mostra `payloadLength` ~`3790` e `stringifyMs: 0`. +- Quindi serializzazione, render UI e stringify non spiegano i micro-tagli. +- Nel run post-fix `sendUserText` mostra `responseActive: false`, `openResponses: 0`, `createInFlight: false`: non è un classico barge-in su response già attiva. +- Il sintomo residuo si manifesta solo sulla prima frase speculativa; questo è coerente con una race prima di `response.created`. +- Il browser può decidere il fallback MCP prima che il client abbia un `responseId` da silenziare: per questo ora viene marcata anche la **next response** come sopprimibile. +- L'ultimo sintomo riferito dall'utente cambia ancora: le micro-interruzioni si sentono sulle prime parole di ogni frase, non solo su quella speculativa. +- Questo è coerente con il worklet `audio-playback` che parte subito sul primo chunk ricevuto, senza accumulare buffer iniziale. + +## Minimal Fix +- Accodata la `sendUserText()` del payload MCP quando esiste una response attiva. +- Flush del testo differito solo dopo `response.done`, prima del `response.create` già in coda. +- Riassunto/troncamento del payload MCP prima di reiniettarlo nella conversazione live. +- Ridotto il delay del fallback MCP a `150ms` per match forti e senza argomenti obbligatori. +- Soppressione della **next speculative response** se il fallback MCP vince il turno prima che `response.created` sia arrivato. +- Introdotto un piccolo prebuffer di avvio (`90ms`) nel worklet `audio-playback` prima di iniziare ogni frase. +- Obiettivo: evitare sia il barge-in quando presente, sia il carico eccessivo sul modello con JSON troppo grande. +- Obiettivo aggiuntivo: ridurre la finestra in cui il modello può iniziare una frase speculativa prima che parta il tool MCP. +- Obiettivo finale: evitare il clipping/jitter sulle prime parole di ogni frase. + +## Verification Conclusion +- Pre-fix: il browser non mostrava alcun collo di bottiglia locale; il prompt inviato al modello restava però molto grande. +- Post-fix: il testo MCP viene accodato se c'è una risposta attiva, il modello riceve solo un summary compatto, il fallback parte quasi subito, la next response speculativa viene silenziata se necessario e il playback aspetta un buffer iniziale prima di iniziare ogni frase. diff --git a/debug-mcp-tool-not-called.md b/debug-mcp-tool-not-called.md new file mode 100644 index 0000000..efb0b64 --- /dev/null +++ b/debug-mcp-tool-not-called.md @@ -0,0 +1,75 @@ +# Debug Session: mcp-tool-not-called + +Status: [OPEN] + +## Symptom +- The voice assistant says it will call an MCP tool, but no MCP tool call is observed. +- No useful runtime log or call attempt is visible from the current instrumentation. + +## Expected +- When the model decides to use an MCP tool, the client should emit a tool-call event, execute the MCP proxy request, and produce visible debug evidence for each stage. + +## Hypotheses +1. The realtime model is not actually emitting `response.function_call_arguments.done`, so the spoken promise to use a tool is plain text, not a real function call. +2. The MCP tools are not present in the effective session state at the moment of the request, so the model cannot legally call them. +3. The session instructions are updated too late or with the wrong content, so the model receives guidance text but not usable tool declarations. +4. The browser receives a tool-call event, but the handler path fails before logging or before `execRemoteMcpTool()` starts. +5. The MCP proxy request starts, but the current logs do not surface pre-call or failure details, making the attempt invisible. + +## Evidence Plan +- Instrument session bootstrap, tool declaration updates, transcript-based MCP ranking, tool-call event handling, and MCP proxy fetch lifecycle. +- Reproduce with a prompt that should match one MCP tool by description. +- Compare whether the model emits only assistant transcript text or a real tool-call event. + +## Notes +- First code change in this session must remain instrumentation only. + +## Evidence Summary +- `api/config` loads correctly with `searchMcpEnabled=true`, `agentToolCount=16`, and a non-empty primary prompt. +- Transcript-time MCP ranking is active and produces ranked candidates such as `get_api_info`, `get_report_detail`, and `get_public_day_report` for relevant spoken requests. +- No `response.function_call_arguments.done` event was recorded in the current reproduction. +- No `toolcall` event, no `runTool()` start, and no MCP proxy fetch were recorded in the current reproduction. + +## Hypothesis Status +| ID | Hypothesis | Status | Evidence | +|----|------------|--------|----------| +| A | The model speaks about tool use but does not emit a real function call | CONFIRMED | The log file contains transcript/ranking events only; no `function_call_arguments.done` entry was produced in the reproduced turn. | +| B | MCP tools are missing from runtime state | PARTIAL | `api/config` proves the remote profile exposes 16 tools, but the current run lacks direct evidence from live `session.update`. | +| C | MCP guidance reaches the session too late for the same voice turn | LIKELY | Ranking is computed from the live user transcript; current code only pushes updated instructions on the final transcript, which can miss the active turn. | +| D | The browser gets the toolcall event but fails before execution | REJECTED (current run) | There is no `toolcall` event at all in the current reproduction. | +| E | The MCP proxy request starts but is invisible | REJECTED (current run) | No pre-fetch or post-fetch MCP proxy log was emitted. | + +## Minimal Fix +- Push the computed MCP-guidance instructions to the live session as soon as ranked tool suggestions appear on partial user transcripts, instead of waiting only for the final transcript. +- Track the last live instruction snapshot to avoid redundant `session.update` spam. +- Add client-construction instrumentation so the next run proves which tools/instructions are present at session start. + +## Post-Fix Evidence +- The live session is created with 17 tools and non-empty instructions. +- During matching turns, the client now narrows the tool set down to the strongest MCP candidates and sends `tool_choice="required"`. +- Example evidence from the latest run: + - `get_api_info` is selected as the top candidate with strong score (`29`). + - The client sends `setTools(..., "required")` with `["get_api_info", ...]`. + - The client sends updated instructions immediately after. +- Even with `tool_choice="required"` and a narrowed tool list, no `response.function_call_arguments.done` event is emitted. +- Therefore no browser `toolcall`, no `runTool()`, and no MCP proxy request can happen. + +## Updated Hypothesis Status +| ID | Hypothesis | Status | Evidence | +|----|------------|--------|----------| +| A | The model speaks about tool use but does not emit a real function call | CONFIRMED | Even after forcing `tool_choice="required"` and narrowing the tool list, no `function_call_arguments.done` event appears. | +| B | MCP tools are missing from runtime state | REJECTED | The client sends 17 initial tools and later sends narrowed MCP-only tool lists, including `tool_choice="required"`. | +| C | MCP guidance reaches the session too late for the same voice turn | REJECTED | The client now pushes guidance and tool policy during partial transcripts, before the turn finishes. | +| D | The browser gets the toolcall event but fails before execution | REJECTED | No upstream function-call event is emitted at all. | +| E | The MCP proxy request starts but is invisible | REJECTED | The MCP proxy is never reached because the model never initiates a tool call. | + +## Current Root Cause +- The speech-to-speech realtime model path in the current backend stack is not performing actual function calling for these MCP tools in this conversation flow, even when tools are declared and `tool_choice="required"` is sent. +- The spoken assistant text is therefore behaving like a promise in natural language, not a real tool invocation. + +## Backend Branch B +- Added relay-side instrumentation in `demo/server.py` to report: +- Outbound client text events forwarded to upstream, including `session.update` summaries with `toolCount`, `toolNames`, `toolChoice`, and instruction length. +- Inbound upstream text events forwarded back to the client, including `session.updated`, `response.function_call_arguments.done`, and `error` summaries when present. +- Binary frame forwarding in both directions and client disconnects. +- No new runtime evidence has been collected from this instrumentation yet; a fresh reproduction is required with the patched relay active. diff --git a/demo/.dockerignore b/demo/.dockerignore new file mode 100644 index 0000000..88b4b68 --- /dev/null +++ b/demo/.dockerignore @@ -0,0 +1,12 @@ +.git +.gitignore +__pycache__/ +*.pyc +.venv/ +venv/ +.vscode/ +.idea/ +.DS_Store +*.log +docs/ +.claude/ diff --git a/demo/.gitignore b/demo/.gitignore new file mode 100644 index 0000000..f43f76b --- /dev/null +++ b/demo/.gitignore @@ -0,0 +1,21 @@ +# macOS metadata +.DS_Store + +# Editor scratch +.vscode/ +.idea/ +*.swp + +# Static HTTP server logs (we don't ship them) +*.log + +# Python +__pycache__/ +*.pyc +.venv/ +venv/ + +# Local-only replica deploy script (never tracked) +deploy_replica.py + +.env \ No newline at end of file diff --git a/demo/ARCHITECTURE.md b/demo/ARCHITECTURE.md new file mode 100644 index 0000000..0df02b9 --- /dev/null +++ b/demo/ARCHITECTURE.md @@ -0,0 +1,188 @@ +# Demo S2S — schema di funzionamento + +> Documento orientativo del modulo `demo/` (UI + proxy S2S + reasoning). Non sostituisce +> il codice: serve a trovare il proprio posto quando si apre il progetto. + +--- + +## 1. Cosa c'è in `demo/` + +Il modulo fa **tre cose contemporaneamente**: + +| Ruolo | Cosa fa | Entry point | +|---|---|---| +| **HTTP server statico + API** | Serve l'UI, offre endpoint REST, gira un WebSocket proxy | [server.py](file:///home/duxiter/s2s/demo/server.py) | +| **Voice UI (browser)** | Orba, mic, transcriptioni, audio playback, pannelli | [index.html](file:///home/duxiter/s2s/demo/index.html) + [main.js](file:///home/duxiter/s2s/demo/main.js) | +| **Reasoning hook** | Inoltra transcript utente a un motore di ragionamento esterno (NODE_RED o OpenAI) | [server.py](file:///home/duxiter/s2s/demo/server.py#L658-L834) `/api/reasoning` | + +La configurazione (URL backend S2S, tipo di reasoning, chiavi API) viene presa dal file `.env` nella root del progetto. + +--- + +## 2. Architettura in un diagramma + +``` + ┌──────────────────────────────────────────────────┐ + │ BROWSER (demo UI) │ + │ [index.html] → [main.js] (ESM type=module) │ + │ │ ▲ │ + │ │ audio + WS events │ transcript chunks │ + │ ▼ │ │ + │ ┌──────────────────────────────────────────┐ │ + │ │ s2s-ws-client.js → WebSocket /v1/realtime │ │ + │ │ + codec.js + orb-visualizer.js │ │ + │ └──────────────────────────────────────────┘ │ + └─────────────────────────┬────────────────────────┘ + │ WebSocket /v1/realtime + ▼ + ┌───────────────────────────────────────────────┐ + │ server.py (FastAPI) │ + │ │ + │ ┌──────────────────────────────────────┐ │ + │ │ realtime_proxy ← TUNNEL → upstream │ │ + │ │ (WS relay puro, copia byte-per-byte)│ │ + │ │ _client_to_upstream ─┐ │ │ + │ │ _upstream_to_client ─┤ session_ctx │ │ + │ │ session_ctx{user,id} │ shared dict │ │ + │ └───────────────────────┴──────────────┘ │ + │ │ + │ endpoint REST: │ + │ GET / → index.html │ + │ GET /api/config → env flags │ + │ POST /api/reasoning → NODE_RED / │ + │ OPENAI │ + │ POST /api/auth/* → Aquachile │ + │ │ + └──────┬──────────────────────────────┬─────────┘ + │ POST /api/reasoning │ fire-and-forget + ▼ ▼ + ┌──────────────────────┐ ┌───────────────────────────────┐ + │ Node-RED (port 1881)│ │ Node-RED (port 1881) │ + │ POST /api/reasoning │ │ POST /api/log/system-response │ + │ log user transcript │ │ log assistant TTS + pairing │ + └──────────────────────┘ └───────────────────────────────┘ + ^ + │ forward 4 eventi realtime: + │ audio_transcript.done + │ content_part.done + │ output_item.done role=assistant + │ conversation.item.created role=assistant +``` + +> ### 📝 Nota importante sul flusso AUDIO con l'attuale `.env` +> +> Con `.env` attuale che ha `S2S_PROXY_TARGET=ws://ai-server:12345/v1/realtime`: +> +> ✅ **Sì, TUTTO l'AUDIO utente (PCM16 16kHz mono, eventi `input_audio_buffer.append`) arriva a `ws://ai-server:12345/v1/realtime`** — ma **non direttamente**: passa SEMPRE per il **tunnel 1:1 WebSocket di `server.py`** (`_client_to_upstream`), che copia byte-per-byte senza modifiche. +> +> ✅ Allo stesso modo l'AUDIO risposta (TTS, `response.output_audio.delta`) ritorna da `ai-server:12345` → tunnel `_upstream_to_client` → browser → playback nativo. +> +> ❌ **Cosa invece NON va a `ai-server:12345`** (canale HTTP separato parallelo, non tocca l'audio): +> - Il **transcript STT** viene mandato via HTTP a `http://localhost:1881/api/reasoning` (Node-RED, hook LOG_ONLY oggi) +> - Il **testo risposta TTS** viene mandato via HTTP a `http://localhost:1881/api/log/system-response` (Node-RED, forward fire-and-forget) +> - Chiamate **auth / profilo Aquachile** verso `aquachile.azurianlab.com` +> +> Questi 3 flussi REST sono **aggiuntivi** e **non alterano minimamente il tunnel audio**: browser ↔ server.py ↔ `ai-server:12345` rimane il percorso esclusivo per voce e risposta. + +--- + +## 3. Cose importanti di [server.py](file:///home/duxiter/s2s/demo/server.py) + +### File → ruoli +- **`/`** → StaticFast `StaticFiles` su `demo/` (index.html + assets) +- **`@app.middleware http` no_cache_static_assets** → `Cache-Control: no-store` per `.js/.html/.css/.mjs/.wasm` per sconfiggere cache browser durante sviluppo +- **`@app.get /api/config`** → manda al browser variabili env (`S2S_REASONING_TYPE`, `S2S_PROXY_TARGET`, auth, features). Il client decida da sé cosa mostrare in UI. +- **`@app.post /api/reasoning` ([L658-L834](file:///home/duxiter/s2s/demo/server.py#L658-L834))** → cuore del reasoning. Due modalità, scelta da env `S2S_REASONING_TYPE`: + - **Modalità A — NODE_RED**: inoltra `POST {NODE_RED_URL}/api/reasoning` timeout 30s. Accetta `log_only=true` + `answer=""` (Fase 1). + - **Modalità B — OPENAI**: chiamata diretta `POST $OPENAI_BASE_URL/chat/completions` (Fasi successive). +- **`@app.websocket /v1/realtime` ([L874-L980](file:///home/duxiter/s2s/demo/server.py#L874-L980))** → **tunnel S2S nativo**. Non traduce messaggi: copia + registra + forwarda. 2 task paralleli: + - `_client_to_upstream(ws, upstream, client_ip, session_ctx)`: browser → AI server + - `_upstream_to_client(ws, upstream, client_ip, session_ctx)`: AI server → browser + - `session_ctx = {last_user_transcript, last_item_id}` **dizionario mutabile condiviso** per accoppiare input↔output +- **`async def _forward_agent_text_to_nodered` ([L837-L873](file:///home/duxiter/s2s/demo/server.py#L837-L873))** → helper fire-and-forget. Tutti errori → `logger.debug` (non spezza il tunnel WS). +- **`_upstream_to_client` hook eventi ([L1305-L1500](file:///home/duxiter/s2s/demo/server.py#L1305-L1500))**: per ogni evento output agente avvia `asyncio.create_task(_forward_agent_text_to_nodered(...))` → non blocca lo streaming audio. +- **Sidecar Python locali**: [auth.py](file:///home/duxiter/s2s/demo/auth.py) (Aquachile profile+user+phone), [limiter.py](file:///home/duxiter/s2s/demo/limiter.py) (rate limiter). + +### Ordine di chiamata in un turno vocale +1. Browser clicca orb → `ws /v1/realtime` → `realtime_proxy()` accetta +2. Browser manda `session.update` → `input_audio_buffer.append` in loop +3. Upstream genera `conversation.item.input_audio_transcription.completed` → server cattura → **`session_ctx["last_user_transcript"] = transcript`** +4. `_upstream_to_client` vede transcript STT completo → server invoca in parallelo **`POST /api/reasoning`** con quel transcript +5. Upstream manda `response.output_audio.delta` audio chunks → tunnelati diretti al browser → playback nativo +6. Upstream manda `response.output_audio_transcript.done` → server cattura → `create_task(_forward_agent_text_to_nodered({tts, source_event, user_transcript, …}))` → Node-RED SYSTEM_RESPONSE log +7. Audio finisce → turno chiuso + +--- + +## 4. Cose importanti del lato client ([main.js](file:///home/duxiter/s2s/demo/main.js)) + +### Moduli ESM importati (tutti con `?v=VERSIONE` per cache-bust) +``` +main.js + ├─ dom.js → helper DOM (create, qs, bubble, ecc.) + ├─ chat.js → pannello messaggi + appendAssistantText + ├─ account.js → pannello auth + profilo utente + └─ ws/s2s-ws-client.js + ├─ codec.js → codec pcm + base64 + └─ orb-visualizer.js → animazione orba su eventi volume/state +``` + +### Funzioni chiave +| Funzione | Ruolo | +|---|---| +| `fetchOpenAiReasoningAnswer(transcript)` | `POST /api/reasoning`, ritorna `{answer, log_only, raw}` | +| `maybeRunOpenAiReasoning(transcript, itemId)` | guardia "ho già processato questo turn?" (`processedReasoningTurns`). Se `result.log_only === true` → **early return, zero side-effect**. Altrimenti branch standard (Fasi successive). | +| `cancelActiveResponse()` | Fasi successive: ferma risposta corrente prima di inject custom | +| `appendAssistantText(answer)` / `sendUserText(renderMarker)+requestResponse` | Fasi successive: sostituisce testo risposta backend nativo | +| `speechSynthesis.speak(utterance)` | Fasi successive: TTS *browser* per answer custom, invece del backend nativo | + +### Regole per non rompere Fase 1 +- Se `S2S_REASONING_TYPE=NODE_RED` **e** Node-RED torna `log_only=true` → il client non tocca nulla. Zero `cancelActiveResponse`, zero `speechSynthesis.speak`, zero `conversation.item.create` manuali. La voce è quella del backend S2S nativo. +- **Cache busting triplo**: `index.html` meta tag → `Cache-Control no-store` middleware → query-string `?v=20260825003` su ogni import. + +--- + +## 5. Node-RED (Fase 1 — LOG_ONLY) + +Risiede in [`s2s-reasoning-engine/flows.json`](file:///home/duxiter/s2s/s2s-reasoning-engine/flows.json). Espone 2 endpoint completamente indipendenti: + +| Endpoint | Payload | Output Node-RED | +|---|---|---| +| `POST /api/reasoning` | `{transcript, language, phoneNumber, …}` | `[S2S NODE_RED LOG_ONLY] ts=… lang=… phone=… transcript="..."` | +| `POST /api/log/system-response` | `{tts, source_event, user_transcript, role, item_id, …}` | `[S2S NODE_RED SYSTEM_RESPONSE] ts=… phone=… user="..." tts="..."` | + +Entrambi ritornano 200 `{log_only:true, intent:"..."}`. + +--- + +## 6. Worklets (non toccare) +- [worklets/mic-capture.js](file:///home/duxiter/s2s/demo/worklets/mic-capture.js) — sample-rate input 16kHz PCM mono, calcolo RMS per VAD visuale. +- [worklets/audio-playback.js](file:///home/duxiter/s2s/demo/worklets/audio-playback.js) — riproduzione audio 24kHz server side. + +> Nota: i worklets vengono caricati via `AudioWorklet.addModule()`, che usa script-context **non-ESM**. Non aggiungere `export function` a questi file: rompono il caricamento. + +--- + +## 7. Variabili ambiente che comandano tutto + +| Nome | Valore tipico oggi | Cosa fa | +|---|---|---| +| `S2S_REASONING_TYPE` | `NODE_RED` (Fase 1) / `OPENAI` (Fasi succ.) / `PROXY` disabilita | Scelta motore ragionamento in `/api/reasoning` | +| `S2S_REASONING_NODE_RED_URL` | `http://localhost:1881` | Endpoint base Node-RED reasoning engine | +| `S2S_PROXY_TARGET` | `ws://ai-server:12345/v1/realtime` | URL upstream tunnel WebSocket | +| `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_REASONING_MODEL` | da .env | Usati in Modalità B OPENAI | +| `S2S_AGENT_PROFILE` / `S2S_USER_PHONE_AUTH_URL` / `S2S_TOOL_EXECUTION_MCP_URL` | `aquachile.azurianlab.com/api/v1/…` | URL provider esterni Aquachile | +| `LOG_LEVEL` | `INFO` / `DEBUG` | Livello uvicorn | + +--- + +## 8. Dove toccare per… + +| Obiettivo | File da aprire | +|---|---| +| Cambiare colori tema orb / UI | [style.css](file:///home/duxiter/s2s/demo/style.css) `:root` + [DESIGN.md](file:///home/duxiter/s2s/demo/DESIGN.md) | +| Aggiungere un pannello in UI | [index.html](file:///home/duxiter/s2s/demo/index.html) markup + [main.js](file:///home/duxiter/s2s/demo/main.js) render + nuovo modulo in [ui/](file:///home/duxiter/s2s/demo/ui) | +| Aggiungere un endpoint REST | [server.py](file:///home/duxiter/s2s/demo/server.py), dopo le route esistenti | +| Nuova logica Node-RED Fase 2 | [flows.json](file:///home/duxiter/s2s/s2s-reasoning-engine/flows.json) → Node-RED editor → export | +| Passare da LOG_ONLY a produzione di answer | Flip in flows.json `log_only=false` + riempire `answer`, il branch `result.log_only` del client si spegne automaticamente | +| Debugging SyntaxError browser `Unexpected token 'export'` | 1) Controlla `syntax AST` server.py (se crasha → 500 → browser riceve HTML invece di main.js) 2) Hard reload DevTools ✓ Disable cache 3) `node --check main.js` e import | diff --git a/demo/CONTEXT.md b/demo/CONTEXT.md new file mode 100644 index 0000000..b4d980a --- /dev/null +++ b/demo/CONTEXT.md @@ -0,0 +1,108 @@ +# Context glossary + +Canonical terms for this space. A glossary, not a spec — it defines what words mean, +not how anything is built. Keep design/implementation detail in `DESIGN.md` and the +code. + +## Speech-to-speech demo +The product: a voice conversation you have with a model by tapping the orb and +talking. "The demo" and "the space" refer to this same thing. It runs on Hugging +Face's open `speech-to-speech` backend. + +## The pipeline +The ordered path a turn travels, from your voice to the orb's reply. Order is +meaningful — each stage consumes the previous one's output: + +`you speak → VAD → STT → VLM → TTS → orb replies` + +- **VAD** — voice activity detection. Decides *when* you are speaking, so the system + knows a turn has started and ended. Model: silero-vad. +- **STT** — speech to text. Transcribes your speech into words. Model: + nvidia/parakeet-tdt-1.1b. +- **VLM** — the vision-language model that composes the reply. Served via Cerebras. + Model: google/gemma-4-31B-it. +- **TTS** — text to speech. Speaks the reply back in the chosen voice. Model: + Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice. + +## Builder +A Hugging Face user credited with making the space, shown by HF username. Current +builders: tfrere, A-Mahla and andito. Distinct from the *models'* authors (nvidia, google, +Qwen, snakers4), who are credited per pipeline stage. + +## Powered by +The infrastructure running the pipeline, named in the about panel: Hugging Face +Inference Endpoints (hosting) and Cerebras (LLM inference). Distinct from "built by" +(the people) and from the model authors (who trained each model). + +## Tool +A function the model can call mid-conversation to do something the pipeline +can't do on its own (look something up, look through the camera). Tools are +declared to the backend in the session config; the model decides when to call +one. Distinct from the *pipeline stages* (VAD/STT/VLM/TTS), which always run. + +## Tool executor +The client-side component that runs a tool when the model calls it and returns +the result to the backend, so the model can speak the answer. It is the missing +half of the round-trip: the backend already emits the call, the executor runs it +and replies. Distinct from the *tool* itself (the thing being run). + +## Web search tool +A tool that looks something up on the web for the model. The model calls it with +a query; the tool executor forwards the query to the search proxy and returns the +results as the tool result. Activates only when a search key is available. + +## Camera snapshot tool +A tool that lets the model look through the user's webcam. While enabled, a live +self-view is shown in the page (bottom-left); when the model calls the tool, the +executor captures a frame and sends it to the model as an image so the VLM can +see it. Distinct from the *preview* (what the user sees) and the *snapshot* (the +single frame sent to the model). + +## Search proxy +The same-origin server route (`/search`) that holds the search key and calls the +external search provider on the client's behalf, so the key never reaches the +browser. Lives in the same container as the page. Distinct from the *s2s backend* +(the separate load-balanced speech-to-speech service). + +## Tools panel +The dialog opened from the "Tools" button in the top-right, holding one switch per +tool (and the web-search key status). Turning a switch on/off declares or removes +that tool on the live session. Distinct from *Settings* (connection, voice, +instructions) and the *About panel* (project info). + +## Identity block +The top-left corner of the topbar (replacing the old wordmark): the demo name, a +one-line blurb, and the "powered by" / "built by" credits, shown directly rather +than hidden behind a click. + +## About panel +The popup opened from the (i) icon to the right of the identity block. Holds the +general introduction to the speech-to-speech project (with a repo link) and the +pipeline. Identity itself now lives in the corner, not here. + +## Queue +The line of users waiting for a free conversation slot when every compute is +busy. You join the queue instead of being turned away; you leave it by reaching +the front or by giving up. Distinct from a *session* (an actual live +conversation) — being in the queue is not yet talking, and time spent waiting +never counts against your usage limit. + +## Ticket +Your held place in the queue. Created when you join, it is what the demo checks +to tell you your position and to notice if you have left. A ticket is not a +session: it only promises a spot in line, not a compute. + +## Position +How many people are ahead of you in the queue, shown while you wait ("You're #3 +in line"). It only ever counts down. Distinct from an *estimated wait* — the +demo shows position, never a time, because wait time is unpredictable. + +## Claim +The moment you reach the front and a free slot becomes yours — the queue hands +off to a real session and the conversation begins. This is also the point where +your usage limit first starts to matter (never while waiting). + +## At capacity +The state where the queue itself is full, so new users can't even join the line +and are asked to try again shortly. Distinct from simply *busy* (all computes +taken but the queue still has room to wait in). diff --git a/demo/DESIGN.md b/demo/DESIGN.md new file mode 100644 index 0000000..d0ca548 --- /dev/null +++ b/demo/DESIGN.md @@ -0,0 +1,205 @@ +# Design language + +The reference for keeping this app visually coherent as it grows. Read it before +touching `style.css`, `index.html`, or any DOM-building code in `main.js`. Every +rule here is already live in the codebase — this file explains the *why* so changes +extend the system instead of drifting from it. + +--- + +## The thesis: color belongs to the voice + +This is a voice app. The one thing in the room that should have color is the thing +that is talking. So: + +- **The orb** carries saturated color. It glows, and the glow's hue changes with + conversational state. +- **Everything else is monochrome** — a precise cool-grey dark canvas. Surfaces, + borders, buttons, panels, the transcript: all greyscale. +- The **only** exceptions are tiny *role echoes* (a one-word mono label, a small + icon) that borrow the orb's state hue so the transcript reads in the same color + language the orb speaks. They are accents the size of a word, never fills. +- **Brand logos keep their own color.** The Hugging Face mark (`#ffd21e`) and the + Cerebras mark (`#f15a29`) render in their brand colors in the identity credits and + the about panel — a deliberate, owner-approved exception. It applies to those two + logos only; do not generalize it to other chrome. + +If you find yourself adding a tinted background, a colored border, or a bright +button anywhere outside the orb, stop — that color almost certainly belongs to the +orb instead, or shouldn't exist. + +--- + +## Color tokens + +All defined in `:root` in `style.css`. Use the variables, never raw hex in rules. + +### Canvas (the monochrome world) +| Token | Value | Use | +|---|---|---| +| `--bg` | `#0a0b10` | Page background (a cool near-black) | +| `--bg-elev` | `#13151c` | Raised surfaces: bubbles, panels, icon buttons | +| `--bg-elev-2` | `#1b1e29` | Surfaces on surfaces: history bodies, inputs | +| `--border` | `rgba(255,255,255,.08)` | Default hairline | +| `--border-strong` | `rgba(255,255,255,.16)` | Emphasised hairline | +| `--text` | `#f5f6fa` | Primary text; also the *primary button* fill | +| `--text-dim` | `rgba(245,246,250,.65)` | Secondary text | +| `--text-faint` | `rgba(245,246,250,.42)` | Captions, labels, footer | + +### Voice (the only saturated hues) +These are the orb's state colors. They appear on the orb, and as small role echoes +in the transcript — nowhere else. + +| Token | Value | Meaning | +|---|---|---| +| `--accent` / `--speaking` | `#8b7dff` violet | Assistant speaking | +| `--accent-2` / `--listening` | `#22d3ee` cyan | You / listening | +| `--processing` | `#f59e0b` amber | Thinking / tool call | +| `--error` | `#ff6a75` | Error | +| `--success` | `#34d399` | Ready / connected | + +### Role echoes (semantic aliases — use these in chat code) +| Token | Maps to | Where it shows | +|---|---|---| +| `--voice-user` | cyan | `YOU` label + user bubble accents | +| `--voice-assistant` | violet | `ASSISTANT` label + assistant accents | +| `--voice-tool` | amber | `TOOL CALL` label, wrench icon | + +**Why this mapping:** it mirrors the orb exactly — when *you* speak the orb is cyan +(`state-listening`), when the *assistant* speaks it's violet (`state-ai-speaking`), +when it's working it's amber (`state-processing`). The transcript is a quiet replay +of the orb's color story. + +### Orb state → glow (`.circle.state-*` → `--glow`) +| State | Glow | +|---|---| +| `signed-out` | violet `#8b7dff` | +| `authenticated` / `ready` | green `#34d399` | +| `connecting` / `connected` / `starting` | yellow `#facc15` | +| `listening` / `user-speaking` | cyan (`--listening`) | +| `processing` | amber (`--processing`) | +| `ai-speaking` | violet (`--speaking`) | +| `error` | red (`--error`) | + +Adding a new state? Give it a `--glow`, and if it surfaces in the transcript, add a +matching `--voice-*` alias rather than a one-off color. + +--- + +## Typography + +Two faces, two jobs. Never reach for a third. + +- **Inter** — body and UI. Wordmark, buttons, inputs, panel titles, prose, history + message bodies. The workhorse; it should feel neutral and get out of the way. +- **Geist Mono** (`--font-mono`) — the **machine voice**. Reserved for text the + *system* emits or identifiers it reports, never for human prose. + +### When mono is correct +Mono signals "this is the machine talking or naming itself." Use it for: +- the orb's status caption (`.circle-caption`) +- role eyebrows (`YOU` / `ASSISTANT` / `TOOL CALL`) +- tool-call names and argument JSON +- the `·WebSocket` transport tag, bitrate readouts, connection identifiers +- the empty-state label + +Mono text is set uppercase with `letter-spacing: ~0.1–0.14em` and weight `500`, so it +reads as a typed status line, not a headline. Body copy, button labels, and +explanatory `small` text stay **Inter** — putting prose in mono breaks the metaphor. + +The font is loaded in `index.html`; the stack falls back to system mono gracefully if +the CDN is blocked. + +--- + +## Layout + +- **One continuous canvas.** No dividers under the topbar or above the footer, no + panel chrome competing with content. The topbar and footer float over the stage. + Keep it that way — a new section earns a hairline (`--border`) only if it genuinely + needs separating. +- **The orb is the hero and the center of gravity.** It sits dead-center on the + stage. Controls flank it (mic / stop), captions sit beneath. Don't crowd it. +- **Hairlines, not boxes.** Separation comes from `1px` borders at 8–16% white and + from spacing, not from heavy fills or shadows. Shadows are soft and low + (`0 4px 18px rgba(0,0,0,.32)`), used only to lift floating elements (bubbles, + panels, modal). +- **Radii:** `--radius-sm: 8px` (buttons, inputs, chips), `--radius-md: 14px` + (bubbles, message bodies, modal), `--radius-lg: 22px` (reserved). Pick by element + size; don't invent new values. +- **Two reading surfaces for the transcript:** ephemeral bubbles top-right (desktop + only) that log and fade, and a slide-in history panel for review. On phones the + bubble stream is dropped and the panel goes full-screen — the panel is the single + source of truth there. + +--- + +## Components + +- **Buttons.** Default (`.btn`) is a neutral elevated surface. The *primary* button + is **near-white on dark** (`--text` fill, `--bg` text) — the highest-contrast thing + on the page that *isn't* the orb. There is no colored button; emphasis comes from + contrast, not hue. +- **Icon buttons** (`.icon-btn`) are `36px`, elevated surface, dim icon that brightens + on hover. Side controls (`.side-btn`) are circular, collapse to zero size until the + session is live (by width on desktop, by height in the mobile column). +- **Chat bubbles & history messages** share one neutral surface. They are + distinguished by **side** (you = left, assistant = right) plus the **mono role + label** in the role-echo hue — not by tinted fills. Tool entries use the wrench icon + + mono + amber, on the same neutral surface. +- **Badge** (new-message dot) is monochrome white — a signal, not a color accent. +- **Focus** is visible and neutral: inputs focus to `--text-dim`; the orb uses a + `--glow`-colored outline (it's the orb, so color is allowed). + +--- + +## Motion + +- **The orb is audio-reactive, not timer-driven.** Mic RMS (`--audio-level`) and the + assistant output level (`--ai-audio-level`) drive scale/opacity at display rate, so + every syllable moves it. This is the signature animation — keep new motion + subordinate to it. +- **Quiet by default.** Breathing/glow throbs are slow (1.4–2.4s) and low-contrast. + Resist adding scattered micro-animations; an orchestrated moment beats many small + ones, and excess motion reads as AI-generated. +- **First paint is frozen.** `body.booting` disables all transitions until the first + frame commits (stripped after one rAF in `main.js`). Anything new that would + otherwise animate-in on load must respect this. +- Honor `prefers-reduced-motion` for any motion you add. + +--- + +## Writing / copy voice + +- Sentence case, plain verbs, no filler. Tuned and quiet — match the minimal canvas. +- Name things by what the user controls, not by the system's internals. A button says + exactly what it does, and keeps the same word through the flow. +- **Empty states invite action** ("Tap the orb and start talking"), they don't just + set a mood. +- **Errors state what happened and how to recover**, in the interface's voice — they + don't apologize and are never vague. +- Mono labels are terse identifiers (`YOU`, `TOOL CALL`); prose stays in Inter. + +--- + +## Responsive floor (non-negotiable) + +Every change ships meeting these: +- Works down to a `360px`-wide phone. The `@media (max-width: 600px)` block already + handles the phone layout — extend it, don't fight it. +- Visible keyboard focus on every interactive element. +- `prefers-reduced-motion` respected. +- Tap targets ≥ `44px`; `touch-action: manipulation` on anything tappable. + +--- + +## Before you ship — the mirror check + +1. Is every saturated color either on the orb or a word-sized role echo? If a fill or + border is colored, remove that accessory. +2. Is mono used only for machine/system text, and Inter for everything human? +3. Does any new state have both a `--glow` and (if it appears in chat) a `--voice-*` + alias? +4. Are separations hairlines + spacing, not boxes and heavy shadows? +5. Did you add motion? Is it quieter than the orb and reduced-motion-safe? +6. Remove one accessory. The minimal look survives on precision, not addition. diff --git a/demo/Dockerfile b/demo/Dockerfile new file mode 100644 index 0000000..5d3e074 --- /dev/null +++ b/demo/Dockerfile @@ -0,0 +1,14 @@ +# Single container: serves the static front-end AND the /api/search proxy. +# HF Spaces (sdk: docker) routes traffic to $PORT, default 7860. +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 7860 + +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/demo/HOWITWORKS.md b/demo/HOWITWORKS.md new file mode 100644 index 0000000..f427143 --- /dev/null +++ b/demo/HOWITWORKS.md @@ -0,0 +1,584 @@ +# HOWITWORKS — demo S2S + +> Compagno di [ARCHITECTURE.md](file:///home/duxiter/s2s/demo/ARCHITECTURE.md): spiega il +> ciclo di vita concreto dei dati in 3 moduli su cui oggi ci siamo concentrati: +> 1. Il campo `instructions` da client a `ai-server:12345` +> 2. `MCP_SEARCH_TRANSLATION_PHRASES` + ranking tool MCP lato client +> 3. **FASE 2 — Dichiarazione tools nativi a `ai-server:12345` via Realtime `session.update.tools[]`** + +--- + +## 0. FASE 2 — Integrazione tools nativi S2S upstream (ai-server:12345) + +### Obiettivo +Prima della Fase 2, i 100+ tool MCP Aquachile erano solo **testo dentro `instructions`** (9 KB di prompt). L'upstream non sapeva eseguirli e produceva frasi tipo: +> *"Perfecto, buscaré… [list_recent_reports]"* + +Con la Fase 2 dichiariamo i tools tramite il **protocollo nativo OpenAI Realtime GA**: +``` +session.update { tools: [...], tool_choice: "auto" } +``` +→ Se `ai-server` lo supporta, produce eventi veri `response.function_call_arguments.done` con nome tool + argomenti JSON. Il listener già esistente nel client (s2s-ws-client.js) si attiva automaticamente e tutto il resto della pipeline — `runTool()` → `POST /api/mcp/tool` Aquachile → `client.sendToolOutput()` — funziona senza altre modifiche. + +### 0.1 Dove è stato implementato lato proxy (s2s_ui container) + +| Modulo | File / Righe | Cosa fa | +|---|---|---| +| **Cache + normalizzazione** | [server.py:451-544](file:///home/duxiter/s2s/demo/server.py#L451-L544) | `_normalize_tools_for_realtime(raw_tools)`: converte qualsiasi formato tool Aquachile → formato OpenAI Realtime standard. Non alza eccezioni; tool invalidi saltati con warning. | +| **Inject WS upstream** | [server.py:1034-1075](file:///home/duxiter/s2s/demo/server.py#L1034-L1075) | Subito dopo `TUNNEL ESTABLISHED`, **PRIMA** che inizi lo streaming audio: fetch cache `_agent_profile_data()` → `session.update` con `instructions` (profilo agente Aquachile) + `tools[]` + `tool_choice=auto`. Errori suppressi: non rompiamo mai il flusso audio. | +| **Log di successo KEY** | [server.py:312-334](file:///home/duxiter/s2s/demo/server.py#L312-L334) | Quando arriva `response.function_call_arguments.done` dall'upstream → 2 log `[MCP INJECT] ✓ UPSTREAM ha emesso…` (segnale CHE LA STRATEGIA B FUNZIONA) | +| **Proxy MCP già esistente** | [server.py:600-656](file:///home/duxiter/s2s/demo/server.py#L600-L656) | `POST /api/mcp/tool` → Aquachile JSON-RPC 2.0 `tools/call`. Nessuna modifica necessaria. | + +### 0.2 Sequenza temporale WS Realtime (Fase 2) + +``` +1. Browser → ws://s2s_server/v1/realtime (connessione) +2. server.py → websockets.connect(ai-server:12345) +3. [MCP INJECT] server.py → upstream: + { "type": "session.update", + "session": { // ⚠️ FORMATO CORRETTO — TUTTO dentro session:{} + "instructions": "…system prompt profilo Aquachile…", + "tools": [ {type: "function", name:"list_recent_reports", …}, …79 più… ], + "tool_choice": "auto" + } + } + ← LOG "[MCP INJECT] session.update upstream inviato (tools=79, fmt=nested_session OK)" +4. browser → ai-server: (turno audio utente) "el reporte de ayer por favor" +5. (se upstream ha tools[] in RuntimeConfig.session.tools — ORA SUPPORTATO Nativamente) + ai-server → server.py → browser: + { "type": "response.function_call_arguments.done", + "call_id": "call_abc", + "name": "list_recent_reports", + "arguments": "{\"date_from\":\"2026-08-24\",\"limit\":5}" } + ← LOG "[MCP INJECT] ✓ UPSTREAM ha emesso response.function_call_arguments.done!" +6. s2s-ws-client.js:803 → dispatch "toolcall" +7. main.js:3023 → runTool("list_recent_reports", args, "call_abc") + branch agentToolDefs → execRemoteMcpTool +8. execRemoteMcpTool → POST /api/mcp/tool → Aquachile JSON-RPC tools/call + LOG: "[Vocero MCP CALL]" → "[Vocero MCP HTTP]" → "[Vocero MCP OUT]" +9. client.sendToolOutput("call_abc", "Risultati: 5 report, ultimo=Report_2026-08-24.xlsx") + → WS payload: + { "type": "conversation.item.create", + "item": { "type": "function_call_output", "call_id": "call_abc", "output": "..." } } +10. ai-server continua il turno con TTS nativo: + "Aquí tienes los 5 reportes de ayer, el más reciente es Report_2026-08-24.xlsx..." + (nessun [tool_name] nel testo del TTS!) +``` + +### 0.3. Formato atteso da `ai-server:12345` — guida per modifiche sul backend S2S + +**Se dopo il deploy non vedi il log `[MCP INJECT] ✓ UPSTREAM ha emesso…`**, vuol dire che `ai-server` oggi scarta il campo `tools[]` dal `session.update`. Ecco **esattamente i campi che devono essere supportati** (copia-incollabile nel codice di `ai-server`): + +#### 0.3.1 `session.update` in ingresso (cosa manda oggi `server.py` — FORMATO NESTED CORRETTO) + +⚠️ **REGOLA NON NEGOZIABILE**: TUTTI i campi di configurazione (instructions, tools, tool_choice, voice, model) devono stare **DENTRO l'oggetto `session`**. Non mandarli MAI alla radice del payload. Il validatore Pydantic strict di OpenAI Realtime GA rifiuta qualsiasi campo sconosciuto alla root. + +```jsonc +{ + "type": "session.update", + + // ⚠️ TUTTO dentro "session":{} — il validator Pydantic strict è extra=forbid sulla root. + "session": { + + // 1) Istruzioni di sistema (profilo agente Aquachile) + "instructions": "María, asistente virtual Aquachile, habla español de Chile…", + + // 2) CATALOGO TOOLS — 79 tool Aquachile normalizzati + "tools": [ + { + "type": "function", + "name": "list_recent_reports", + "description": "Restituisce l'elenco dei report più recenti, opzionalmente filtrati per data.", + "parameters": { + "type": "object", + "properties": { + "date_from": {"type": "string", "format": "date", "description": "Data inizio YYYY-MM-DD"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10} + }, + "required": [] + } + } + // … altri 78 tool + ], + + // 3) Strategia selezione tool + "tool_choice": "auto" + } +} +``` + +**Perché prima non funzionava?** (ROOT CAUSE) +Il proxy stava inviando `instructions`, `tools`, `tool_choice` **ALLA RADICE** del payload, fuori da `session:{}`. Il modello Pydantic `SessionUpdateEvent` (OpenAI Realtime GA) ha `extra=forbid` e ha rifiutato con `Unknown or invalid event: session.update`. **Non era un rifiuto del campo `tools` in quanto tale** — era un rifiuto di qualsiasi campo non riconosciuto messo nel posto sbagliato. La prova: `runtime_config.session.instructions`, `.session.tools`, `.session.tool_choice` in `language_model.py:526-532` leggono TUTTI i campi da dentro `.session.*`. + +**Audit sorgente backend `speech_to_speech` conferma: GIA' 100% WIRED PER NATIVI TOOLS** +| Componente | File / Righe | Conferma | +|---|---|---| +| LLM pick tools da sessione | `LLM/language_model.py:526-532` | `tools = runtime_config.session.tools` | +| Chat buffer gestisce FC/FCO | `LLM/chat.py:141-223 / 342-366 / 394-427` | `RealtimeConversationItemFunctionCall` + `FunctionCallOutput` | +| Response handler emette WS done event | `api/openai_realtime/handlers/response.py:336-345` | `ResponseFunctionCallArgumentsDoneEvent` con `call_id, name, arguments(STRINGA), response_id, item_id, output_index` | +| Conversation handler accetta ritorno output | `api/openai_realtime/handlers/conversation.py:30-96` | `handle_conversation_item_create` con `deferred_items` anti-race | +| Fallback parser retrocompatibile | `api/openai_realtime/service.py:267-325` | Se arrivano campi flat alla radice → promossi a nested `session` wrapper | + +**Regole convalida obbligatorie lato `ai-server` per evitare rigetti silenziosi**: + +| Regola | Valore | +|---|---| +| Nome tool | lunghezza ≤ 64, match regex `^[a-z0-9_]+$` | +| Numero max tools | Consigliato ≤ 80. Oggi il proxy manda 100+ con warning. | +| `parameters.type` | Sempre `"object"` (anche se vuoto) | +| `parameters.properties` | Sempre `{}` presente, anche se 0 proprietà | +| `tool_choice` | Valori accettati: `"none"`, `"auto"`, oppure `{type:"function",name:"…"}` | +| Chiavi ignote | NON fare strict reject. Logga warning e scarta solo la chiave. | + +#### 0.3.2 Evento di ritorno richiesto da `ai-server` (quando il modello decide di chiamare un tool) + +Quando il modello determina che serve un tool → emetti **3 eventi in ordine ESATTO**: +``` +a) { "type": "response.function_call_arguments.started", "call_id": "call_abc123", "name": "list_recent_reports", "response_id":"resp_X", "item_id":"item_Y" } + (opzionale — se non hai streaming arguments puoi anche saltarlo) + +b) (opzionale) N × { "type": "response.function_call_arguments.delta", "call_id": "call_abc123", "delta": "{\"dat" } + (solo se streammi il JSON argomenti per pezzi) + +c) ✅ EVENTO RICHIESTO OBBLIGATORIO (senza questo il client non esegue il tool): +{ + "type": "response.function_call_arguments.done", + "call_id": "call_abc123", // STRINGA OBBLIGATORIA — serve per la sendToolOutput + "name": "list_recent_reports", // STRINGA OBBLIGATORIA — nome del tool (come sopra) + "arguments": "{\"date_from\":\"2026-08-24\",\"limit\":5}", + "response_id": "resp_xyz", + "item_id": "item_zzz", + "output_index": 0 +} +``` + +**⚠️ Importante**: `arguments` DEVE essere una **STRINGA JSON serializzata** (non un oggetto). Il client fa `JSON.parse(event.arguments)`. Se mandi un oggetto JS → parser fallisce e il tool non viene eseguito. + +#### 0.3.3 Atteso come input successivo (il ritorno del tool dal client) + +Dopo ~500ms-45s, il client manda indietro al S2S upstream: +```jsonc +{ + "type": "conversation.item.create", + "item": { + "type": "function_call_output", + "call_id": "call_abc123", + "output": "5 report trovati. Ultimo: Report_2026-08-24.xlsx - Aquachile ventas" + } +} +→ Poi { "type": "response.create" } +``` +→ L'S2S dovrebbe usare `output` come contesto nel prossimo turno di generazione TTS (quindi parla del risultato, senza tag `[tool]`). + +### 0.4 Checklist di deploy Fase 2 (2026-08-25 — SORGENTE ai-server TROVATA in locale) + +**Prerequisito**: Sorgente backend `ai-server:12345` = `/home/duxiter/s2s/speech_to_speech` (Python puro). +✅ **TEST CURL 2026-08-25 CONFERMATO**: `https://aquachile.azurianlab.com/api/v1/mcp/` è **ATTUALMENTE PUBBLICO** — accetta `tools/list` e `tools/call` SENZA API KEY. Con credenziali dummy `dummy-test-key/secret` restituisce HTTP 200 OUTPUT IDENTICO alla chiamata senza auth. Quindi NON serve valorizzare: +``` +S2S_TOOL_EXECUTION_MCP_API_KEY= +S2S_TOOL_EXECUTION_MCP_API_SECRET= +``` +⚠️ Nota: Il server accetta entrambi gli auth headers, ma attualmente non li valida. Restano configurati come best practice per eventuali versioni future dell'endpoint che attiveranno auth. + +#### Ordine deploy 3 step + +| # | Azione | Perché | +|---|---|---| +| 0 | ~~Valorizza `.env` MCP credentials~~ | ✅ **SUPERATO** — endpoint MCP Aquachile pubblico | +| 1 | **Restart container `s2s_ui`** | Deploy fix formato inject `fmt=nested_session` in [server.py:1054-1102](file:///home/duxiter/s2s/demo/server.py#L1054-L1102) | +| 2 | **Riavvia backend `ai-server:12345`** con il codice aggiornato di `/home/duxiter/s2s/speech_to_speech` (in particolare `api/openai_realtime/service.py`) | Deploy fallback parser retrocompatibile flat→nested in [service.py:267-325](file:///home/duxiter/s2s/speech_to_speech/api/openai_realtime/service.py#L267-L325) | +| 3 | **Hard Reload browser** (Ctrl+Shift+R o Disable cache + refresh) | Pulisce cache vecchio client JS | + +#### Checklist di verifica DOPO deploy 3+1 step + +```bash +# Terminal A +docker logs -f s2s_ui 2>&1 | grep -E 'MCP|Vocero MCP|session.update|function_call_arguments' +``` + +| Step | Trigger | Log atteso nel container | Esito | +|---|---|---|---| +| 1 | Apri pagina demo (connessione WS) | `[MCP INJECT] session.update upstream → tools=79 hasAgentPrompt=True tool_choice=auto` | ✅ Inject partito | +| 2 | Subito dopo step 1 | `[MCP INJECT] session.update upstream inviato (tools=79, fmt=nested_session OK)`
🔴 **NON DEVE** più comparire `[MCP INJECT] suppressed upstream error` — se compare ancora, il deploy di `speech_to_speech service.py` non è riuscito | ✅ Nessun rigetto | +| 3 | Console browser | 0 occorrenze `Unknown or invalid event: session.update` | ✅ Filtro OK | +| 4 | Frase trigger tool: *"¿dónde está el report de ayer?"* | **KEY LOG 🔑**
`[MCP INJECT] ✓ UPSTREAM ha emesso response.function_call_arguments.done! tool=list_recent_reports args_keys=[...]` | ✅ **Strategia B FUNZIONA — il modello produce tool calls native** | +| 5 | 0.5-1s dopo Step 4 | `[Vocero MCP CALL] name=list_recent_reports target=https://aquachile...`
`[Vocero MCP HTTP] name=list_recent_reports status=200`
`[Vocero MCP OUT] name=list_recent_reports preview=...resultKeys=...` | ✅ Tool eseguito realmente via REST Aquachile | +| 6 | 1-2s dopo Step 5 | TTS vocale naturale — es: *"Aquí tienes los 5 reportes de ayer, el más reciente es Report_2026-08-24.xlsx..."* | ✅ Nessun `(list_recent_reports)` / `[tool]` nel testo TTS | +| 7 | Frase non-tool: *"Hola, ¿cómo estás?"* | Risposta standard SENZA tool calls, POST /api/reasoning 200, Node-RED LOG_ONLY OK | ✅ Nessuna regressione Fase 1 | + +--- + +### 0.5 Stato attuale (2026-08-25 11:00) — BLOCCO RISOLTO, pipeline 100% implementata + +#### 🎯 Root Causa Reale (99.99% confidence, sorprendente) + +**Ipotesi iniziale, SUPERATA**: *"ai-server rifiuta strict il campo `tools` perché non è implementato"* + +**Realtà, AUDIT SORGENTE CONFERMATO**: TUTTO il sistema — **backend `speech_to_speech` + proxy `demo/server.py` + client `main.js` / `s2s-ws-client.js`** — è GIA' COMPLETAMENTE IMPLEMENTATO end-to-end per il tool calling nativo OpenAI Realtime GA. **L'unico blocco era un errore di formato payload nel proxy**: + +```python +# ❌ SBAGLIATO (come era prima) — campi alla RADICE +_inject_payload = { + "type": "session.update", + "instructions": "...", + "tools": [...], + "tool_choice": "auto" +} # ← Pydantic SessionUpdateEvent extra=forbid → Unknown or invalid event + +# ✅ CORRETTO (deployato in server.py:1054-1102) — TUTTO dentro session:{} +_inject_payload = { + "type": "session.update", + "session": { + "instructions": "...", + "tools": [...], + "tool_choice": "auto" + } +} # ← Perfetto, corrisponde a runtime_config.session.tools in LLM +``` + +**Prova inconfutabile**: [language_model.py:526-532](file:///home/duxiter/s2s/speech_to_speech/LLM/language_model.py#L526-L532) legge istruzioni, tools e tool_choice **TUTTI dentro `.session.*`**: +```python +instructions = response.instructions if ... else runtime_config.session.instructions +tools = response.tools if ... else runtime_config.session.tools +tool_choice = response.tool_choice if ... else runtime_config.session.tool_choice +``` + +#### 📋 Mappa riassuntiva: 7 componenti TUTTI già funzionanti + +| Componente | File | Stato | Cosa fa | +|---|---|---|---| +| 🔧 **Normalizzatore tool** | `demo/server.py:464-544` | ✅ | 79 tool Aquachile → formato Realtime GA standard, 0 eccezioni | +| 🚀 **Inject WS upstream** | `demo/server.py:1054-1102` | ✅ **NUOVO FIX 2026-08-25** | Invia nested `session:{instructions,tools,tool_choice}` subito dopo tunnel | +| 🛡️ **Fallback parser retrocompatibile** | `speech_to_speech/service.py:267-325` | ✅ **NUOVO FIX 2026-08-25** | Se arrivano campi flat root → promossi a nested `session` | +| 🧠 **LLM + Chat buffer** | `speech_to_speech/LLM/*` | ✅ GIA' ESISTENTE | Tools da sessione, FC/FCO nel chat, pending dict anti-race | +| 📡 **WS response handler (tool call OUT)** | `speech_to_speech/handlers/response.py:336-345` | ✅ GIA' ESISTENTE | Emette `ResponseFunctionCallArgumentsDoneEvent` con arguments=STRINGA JSON, call_id, name, response_id, item_id, output_index=0 | +| 💾 **WS conversation handler (tool output IN)** | `speech_to_speech/handlers/conversation.py:30-96` | ✅ GIA' ESISTENTE | `conversation.item.create function_call_output` con `deferred_items` anti-race | +| 🌐 **Client WS → MCP REST → ritorno WS** | `demo/ws/s2s-ws-client.js:803-1077` + `demo/main.js:2180-2288 + 3023-3043` | ✅ GIA' ESISTENTE | (1) `response.function_call_arguments.done` → dispatch `toolcall` event
(2) `runTool()` branch `agentToolDefs` → `execRemoteMcpTool()` → `POST /api/mcp/tool`
(3) `client.sendToolOutput(callId, output)` → `conversation.item.create function_call_output`
(4) `client.requestResponse()` → prossimo turno TTS | +| 📣 **Proxy MCP REST Aquachile** | `demo/server.py:704-760` | ✅ GIA' ESISTENTE | `POST /api/mcp/tool` JSON-RPC 2.0 tools/call + log `[Vocero MCP CALL/HTTP/OUT]` | +| 🛡️ **Filtro errori WS V2** | `demo/server.py:1713-1792` | ✅ GIA' ESISTENTE | Sopprime `unknown_or_invalid_event` se mai dovesse ricomparire, passthrough altri errori | + +#### 🚀 Prossimo passo: ESEGUIRE I 3 STEP DI §0.4 + +1. ~~Valorizza `.env` MCP API Key/Secret~~ ✅ **SUPERATO** (endpoint pubblico) +2. Restart `s2s_ui` +3. Restart `ai-server` con il codice aggiornato da `/home/duxiter/s2s/speech_to_speech` +4. Hard reload browser (Ctrl+Shift+R) +5. Testa: *"¿dónde está el report de ayer?"* → aspetta il key log `[MCP INJECT] ✓ UPSTREAM ha emesso response.function_call_arguments.done!` + +**Se Step 5 (function_call_arguments.done) non compare dopo 3 frasi trigger**: apri una shell con `docker logs -f ai-server` per vedere se la sessione sta leggendo `.session.tools` dal runtime config. + +--- + +#### ⚙️ MCP MASTER SWITCH (nuovo feature 2026-08-25) +**Controllato da una sola riga in `.env`**: +``` +#S2S_TOOL_EXECUTION_MCP_URL=https://aquachile.azurianlab.com/api/v1/mcp # ⬅️ se commentata/vuota → MCP DISABLED +S2S_TOOL_EXECUTION_MCP_URL=https://aquachile.azurianlab.com/api/v1/mcp # ⬅️ se decommentata/valorizzata → MCP ENABLED +``` + +Implementato come `MCP_EXEC_ENABLED = bool(S2S_TOOL_EXECUTION_MCP_URL)` in [server.py:106-114](file:///home/duxiter/s2s/demo/server.py#L106-L114). Quando **DISABLED** (stato attuale `.env`): + +| Punto di controllo | Comportamento con MCP DISABLED | Riferimento codice | +|---|---|---| +| **Startup log** | `[MCP MASTER] ⛔ DISABLED — S2S_TOOL_EXECUTION_MCP_URL is empty/commented in .env…` | [server.py:172-182](file:///home/duxiter/s2s/demo/server.py#L172-L182) | +| **WS `/v1/realtime` inject tools** | `session.update` contiene **solo `instructions`** (profilo agente); `tools[]` e `tool_choice` **non vengono mai mandati**. Log marker `[MCP INJECT] ⛔ SKIPPED tool injection …`. ⚠️ Il prompt profilo continua a funzionare. | [server.py:1081-1123](file:///home/duxiter/s2s/demo/server.py#L1081-L1123) | +| **`/api/config` bootstrap client** | Invia al browser `agentTools=[]` (invece dei 79) + `searchMcp=false`. Effetti lato client: ranker MCP early exit, prompt non contiene più "Available MCP tools:" e ranking hints, UI pannello MCP mostra "No MCP tools available.", nessun pulsante "Esegui tool". | [server.py:660-694](file:///home/duxiter/s2s/demo/server.py#L660-L694) | +| **`POST /api/mcp/tool` endpoint** | Se un client tenta comunque la chiamata (es. con codice vecchio): log `[MCP DISABLED] ⛔ /api/mcp/tool called but…` + HTTP 503 dettagliato (non crash, non exception trace). | [server.py:721-733](file:///home/duxiter/s2s/demo/server.py#L721-L733) | + +Per **riabilitare la Fase 2 MCP in futuro**, 4 step: +1. ✏️ `.env` → togli `#` davanti a `S2S_TOOL_EXECUTION_MCP_URL=https://aquachile.azurianlab.com/api/v1/mcp` +2. 🔄 Restart `s2s_ui` (deploya MCP_EXEC_ENABLED=True + inject formato nested corretto) +3. 🔄 Restart `ai-server` con codice aggiornato `/home/duxiter/s2s/speech_to_speech` (fallback parser flat→nested) +4. 🌐 Hard reload browser Ctrl+Shift+R + + + +--- + +## 1. Flow di `instructions` — da impostazioni utente a S2S upstream + +**Definizione**: `instructions` è il **system prompt** del protocollo OpenAI Realtime GA. +Viene mandato al backend in 2 punti distinti: + +| Canale | Dove | Destinatario | +|---|---|---| +| ✅ **TUNNEL WS AUDIO** → `session.update` all'inizio di ogni sessione | Browser → tunnel proxy → upstream S2S nativo (`ai-server:12345`) | Usato **dal modello S2S stesso** per la risposta vocale nativa | +| ✅ **REST PARALLELO** → body `POST /api/reasoning` | Browser → `server.py` → Node-RED o OpenAI | Usato **dal reasoning hook** esterno (oggi Fase 1 LOG_ONLY) | + +Entrambi i canali partono dalla stessa funzione generatrice `effectiveReasoningInstructions()` nel client. + +### Step-by-step + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ 1. BASE — DEFAULT_INSTRUCTIONS (main.js:26) │ +│ "You are a friendly voice assistant. Keep replies short..." │ +│ OR textarea Settings #instructions salvato in localStorage │ +│ chiave "s2s.ws.instructions" (main.js:138, 201, 223) │ +└───────────────────────────────────────┬────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ 2. effectiveReasoningInstructions() (main.js:516-533) │ +│ Appende in coda, in ordine: │ +│ │ +│ + (se ≥1 tool attivo) TOOL_USE_HINT (main.js:32-35) │ +│ → "do not describe your capabilities... call tool immediately" │ +│ │ +│ + (se searchMcpEnabled + agentToolDefs) MCP_SEARCH_HINT + │ +│ "Available MCP tools: …catalogo completo…" │ +│ │ +│ + (se searchMcpEnabled + tool suggeriti) riga ranking client-side │ +│ "Current user request: \"${lastUserQuery}\". Ranking hint: 1. X │ +│ 2. Y 3. Z — use as secondary signal only" │ +│ │ +│ + (se utente autenticato phone) voceroUserContextText() │ +│ → es: "User profile: name=Juan Pérez phone=+569… account=…" │ +└───────────────────────────────────────┬────────────────────────────┘ + │ + ┌──────────────────┴──────────────────┐ + ▼ ▼ + ┌─────────────────────────────┐ ┌──────────────────────────────┐ + │ 3a. effectiveInstructions() │ │ 3b. POST /api/reasoning │ + │ (main.js:537-539) │ │ body req.instructions │ + │ │ │ (main.js:569) │ + │ • mode=OPENAI → accoda │ │ = effectiveReasoningInst…() │ + │ OPENAI_RENDER_HINT │ │ tale e quale (NO append) │ + │ • mode=NODE_RED / PROXY → │ │ │ + │ ritorna il valore │ │ → server.py lo gira a │ + │ senza modifiche │ │ Node-RED o OpenAI │ + └──────────────┬──────────────┘ └──────────────┬───────────────┘ + │ │ + ▼ ▼ + ┌─────────────────────────────┐ ┌──────────────────────────────┐ + │ 4a. startInstructions = │ │ 4b. /api/reasoning → │ + │ effectiveInstructions() │ │ Node-RED log: │ + │ (main.js:2829) │ │ [S2S NODE_RED LOG_ONLY] │ + └──────────────┬──────────────┘ │ instructions len=… │ + │ └──────────────────────────────┘ + ▼ + ┌─────────────────────────────────────────────────────┐ + │ 5. new S2sWsRealtimeClient({ │ + │ instructions: startInstructions │ + │ voice, tools, noiseGate, audioContext │ + │ }) main.js:2839-2847 │ + └──────────────┬──────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────┐ + │ 6. this.options.instructions → _sendSessionUpdate() │ + │ s2s-ws-client.js:995 + 1016 │ + │ { type: "session.update", │ + │ session: { type: "realtime", │ + │ instructions: this.options.…, │ + │ audio:{output:{voice}}, │ + │ tools, tool_choice:"auto" } } │ + └──────────────┬──────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────┐ + │ 7. server.py _client_to_upstream — TUNNEL 1:1 │ + │ → forward esatto byte-per-byte all'upstream │ + │ → server.py NON tocca MAI instructions qui │ + │ → Logga solo summary.instructionLength = len() │ + │ (server.py:197-203) — NON MODIFICA │ + └──────────────┬──────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────┐ + │ 8. ai-server:12345 riceve session.update → │ + │ istruzioni usate come system prompt da VLM/LMM │ + │ per TUTTA la durata della sessione finché │ + │ non arriva un altro session.update (patch) │ + └─────────────────────────────────────────────────────┘ +``` + +### Patch di `instructions` durante la sessione (viva) +Se vuoi aggiornare istruzioni a metà conversazione **senza chiudere la WS**: +- Client → chiama `client.updateSession({ instructions })` (main.js:1021-1035) +- Oppure, in futuro: **proxy server-side inietta patch** via `upstream.send({type:"session.update", session:{...}})` in qualsiasi momento del tunnel + +### Tabella riassuntiva — chi oggi modifica `instructions`? + +| Livello | Modifica? | Note | +|---|---|---| +| 🔵 Browser `main.js` | **Sì** — generatore principale | DEFAULT_INSTRUCTIONS + hints + user context | +| 🟣 Browser `s2s-ws-client.js` | **Solo forwarding** | Incapsula in `session.update` | +| 🟢 Proxy `server.py` tunnel WS | **NO** — copia byte-per-byte | Logga solo lunghezza, non tocca | +| 🟠 Proxy `server.py` reasoning REST | **Sì (Modalità B)** | Usa `req.instructions` come system part nel prompt OpenAI (Fase 2) | +| 🔴 Upstream `ai-server:12345` | **Sì** — applica come system prompt | Entra nel ciclo STT→VLM→TTS | + +--- + +## 2. Ranking tool MCP lato client + `MCP_SEARCH_TRANSLATION_PHRASES` + +**Obiettivo**: quando arriva la query utente (es. `"Quiero el reporte de hoy"` in spagnolo), +ordinare velocemente i tool MCP del catalogo Aquachile per **rilevanza**, così +le `instructions` possono includere un hint di ranking ("prova prima questi 3") e +il modello sceglie lo strumento giusto con meno tentativi. + +### 2.1 Perché serve la traduzione +Il catalogo dei tool `agentToolDefs` arriva dall'endpoint Aquachile MCP: +- `tool.name` e `tool.description` sono scritti in **INGLESE** per convenzione di + OpenAI Functions / MCP protocol. +- Ma l'utente parla **spagnolo del Cile o italiano** con l'agente. + +Senza traduzione il matching `frase ES/IT ↔ tool EN` → **score 0** (zero corrispondenze). +I tool non vengono mai suggeriti. + +### 2.2 I due dizionari di traduzione (main.js) + +| Costante | Tipo | Riga | Cosa fa | +|---|---|---|---| +| `MCP_SEARCH_TRANSLATION_PHRASES` | `[RegExp, string][]` frasi complete | [L51-L70](file:///home/duxiter/s2s/demo/main.js#L51-L70) | Sostituisce **frasi multi-parola** prima del tokenizer. Es. `reporte de hoy → today report`. **Priorità alta**: viene applicato PER PRIMO perché le frasi intere non si possono ricostruire da token singoli. | +| `MCP_SEARCH_TRANSLATION_TOKENS` | `Record` token singolo | [L72](file:///home/duxiter/s2s/demo/main.js#L72) | Sostituisce il **resto** parola per parola dopo la phase-phrase. Es. `ayuda → help`, `cliente → customer`. | + +### 2.3 Catalogo completo traduzioni oggi attivo + +#### Phrase-level (MCP_SEARCH_TRANSLATION_PHRASES) +| Lingua | Input | Output EN | Riga | +|---|---|---|---| +| 🇪🇸 | `informaciones del usuario` / `informacion del usuario` | `user information` | L52-L53 | +| 🇮🇹 | `informazioni utente` / `informazione utente` | `user information` | L54-L55 | +| 🇪🇸 | `report del dia` / `reporte del dia` | `day report` | L56-L57 | +| 🇮🇹 | `report di oggi` | `today report` | L58 | +| 🇪🇸 | `reporte de hoy` | `today report` | L59 | +| 🇮🇹 | `report giornaliero` | `daily report` | L60 | +| 🇪🇸 | `reporte diario` | `daily report` | L61 | +| 🇪🇸 | `mi numero es` | `my number is` | L62 | +| 🇮🇹 | `il mio numero e` | `my number is` | L63 | +| 🇮🇹 | `numero di telefono` | `phone number` | L64 | +| 🇪🇸 | `numero telefonico` | `phone number` | L65 | +| 🇪🇸 | `numero de telefono` | `phone number` | L66 | +| 🇪🇸 | `numero de whatsapp` | `whatsapp number` | L67 | +| 🇪🇸 | `informacion api` | `api information` | L68 | +| 🇮🇹 | `informazioni api` | `api information` | L69 | + +> 💡 Aggiungere nuove frasi: **è sufficiente aggiungere righe qui**, senza toccare il codice del ranking. La pipeline le applicherà automaticamente. + +### 2.4 Pipeline di ranking chiamata per ogni turno + +``` +Utente dice: "Quiero el reporte diario y informaciones del usuario" + │ + ▼ +rankAgentTools(query) main.js:1262 + │ + ▼ +buildMcpSearchTexts(query) main.js:1330 + │ + ├─── [0] normalizeMcpSearchText(query) ← versione ORIGINALE normalizzata + │ = "quiero el reporte diario y informaciones del usuario" + │ + └─── [1] translateMcpSearchTextToEnglish(query) main.js:1314 + │ + ├─ step 1: normalize + ├─ step 2: loop replace per tutte le entry di MCP_SEARCH_TRANSLATION_PHRASES + │ → "quiero el daily report y user information" + ├─ step 3: split token, map per MCP_SEARCH_TRANSLATION_TOKENS singoli + │ → "want the daily report and user information" + └─ step 4: normalize finale + │ + ▼ +searchTexts = [ originale normalizzato, versione tradotta in EN ] + (duplicati eliminati via new Set) + │ + ▼ +tokens = split searchTexts + lunghezza ≥ 3 chars + dedup + = [ quiero, reporte, diario, informaciones, usuario, + want, daily, report, and, user, information ] + │ + ▼ +Check rule hard-coded: + dailyReportIntent = match ("day"|"daily"|"today") + "report" → TRUE + (se match: boost score dei tool che hanno report in name/description) + │ + ▼ +Per ogni tool in agentToolDefs[]: + score = somma (frequenza token in tool.name + tool.description) + + 2× bonus dailyReportIntent se tool matcha "report" + + 3× bonus se esatta match name vs query originale +Sort(score desc, name asc) + │ + ▼ +Torna al chiamante: [ {tool:"get_daily_report", score:42}, + {tool:"get_user_info", score:31}, + ... ] + │ + ▼ +Usato in 2 punti: + A) effectiveReasoningInstructions() main.js:525-529 → hint "Current user request… + ranking hint: 1. get_daily_report… 2. get_user_info…" dentro le instructions + B) UI pannello (se visibile) — lista tool suggeriti accanto alla trascrizione +``` + +### 2.5 Quando è chiamato `rankAgentTools` — ogni turno? + +| Trigger | Frequenza | Perché | +|---|---|---| +| `maybeRunOpenAiReasoning(transcript)` subito dopo transcript STT | 1× per turno utente | Include ranking hint dentro `instructions` che andrà a Node-RED / OpenAI | +| Live edit textarea domanda (autocomplete) | Mentre utente scrive | Suggerisce tool nella UI | + +### 2.6 Disattivazione automatica + +Ranking ritorna `[]` vuoto e salta tutta la traduzione se: +- `searchMcpEnabled == false` (MCP Aquachile non configurato) +- `agentToolDefs.length == 0` (catalogo tool non ricevuto da `/agents/profile`) +- Query normalizzata è vuota o troppo corta +- Nessun token dopo split ≥ 3 caratteri + +--- + +## 2bis. DOVE VEDERE I LOG MCP — due postazioni + +Il ranking MCP produce log in **2 posti diversi** per design: + +| Postazione | Tipo di log | Filtro DevTools / grep | Quando appare | +|---|---|---|---| +| 🖥️ **Browser — DevTools Console** | Client-side ranking dettagliato: **tutta la pipeline translate → build → rank per-tool matching** | `MCP RANK` (case-sensitive) | **Sempre** per ogni turno — anche in caso di EARLY EXIT (motivo esplicito: `searchMcpEnabled=false`, `agentToolDefs=0`, ecc.) | +| 🐳 **Server — container `s2s_ui` (uvicorn Python)** | Server-side **snapshot del payload `instructions`** quando arriva `POST /api/reasoning` — quanti tool MCP ci sono nel catalogo hint, TOP3 suggeriti dal ranking lato client | `grep '[MCP RANK]'` o filter terminale `MCP RANK` | **1 riga per ogni POST /api/reasoning** (lo hai già nel flusso tra `[INPUT UTENTE]` e `[EXT][NODE_RED] reasoning POST`) | + +### Nel tuo log di oggi (FASE 1 LOG_ONLY, senza MCP tools abilitati) + +Aspettati questo flusso dopo il deploy di adesso: + +``` +7|s2s_ui | INFO [s2s] [INPUT UTENTE] client=… stt=Necesito el report. +7|s2s_ui | INFO [s2s] [MCP RANK] reasoning payload transcript="Necesito el report" instructionsLen=97 hasMcpCatalogHint=False hasRankingHint=False mcpCatalogTools=0 top3Suggested=none +7|s2s_ui | DEBUG [s2s] [EXT][NODE_RED] reasoning POST http://localhost:1881/api/reasoning … +``` + +E se apri **DevTools Console** (Verbose/Debug on, Disable cache ✓ Ctrl+Shift+R) vedi: + +``` +[MCP RANK] updateMcpToolSuggestions ENTRY partial=false itemId=- query="necesito el report" searchMcpEnabled=false +[MCP RANK] ════════════════════════════════════════ +[MCP RANK] rankAgentTools START query="necesito el report" searchMcpEnabled=false agentToolDefsCount=0 +[MCP RANK] buildSearchTexts raw="Necesito el report" normalized="necesito el report" translated="necesito el report" count=1 final=["necesito el report"] +[MCP RANK] translate input="necesito el report" phrasesApplied=0 tokensApplied=0 output="necesito el report" +[MCP RANK] → EARLY EXIT reason="searchMcpEnabled=false — da /api/config: MCP Aquachile feature OFF oppure S2S_TOOL_EXECUTION_MCP_URL non configurato; agentToolDefs=0 — catalogo tool MCP vuoto: profilo agente non ha tools in agentProfile via Aquachile /api/agents/profile" searchMcpEnabled=false normalized=true agentToolDefs=0 +[MCP RANK] ════════════════════════════════════════ (empty result) +[MCP RANK] updateMcpToolSuggestions RESULTS partial=false matchedTools=0 ranked=none +``` + +### Quando il ranking MCP inizia a produrre score > 0 + +Affinché un turno abbia log MCP con tool score>0, devono essere veri **TUTTI e 3**: + +1. `.env` ha `S2S_TOOL_EXECUTION_MCP_URL` configurato (URL Aquachile MCP) **e** `searchMcpEnabled=true` ritorna da `/api/config` +2. `/agents/profile` Aquachile torna **almeno 1 tool** in `agentTools[]` (campo `json.agentTools`) +3. La query utente ha token ≥ 3 chars che matchano `tool.name` o `tool.description` o `tool.parameters` + +Se 1 solo dei 3 è falso → **early exit spiegato nel log `[MCP RANK] → EARLY EXIT reason="…"`**. + +--- + +## 3. Come estendere (quick cheatsheet) + +| Se vuoi… | Dove modificare | +|---|---| +| Aggiungere 1 frase tradotta ES/IT → EN per tool matching | `MCP_SEARCH_TRANSLATION_PHRASES` [main.js](file:///home/duxiter/s2s/demo/main.js#L51-L70) — nuova riga | +| Aggiungere 1 parola singola tradotta | `MCP_SEARCH_TRANSLATION_TOKENS` [main.js:72](file:///home/duxiter/s2s/demo/main.js#L72) | +| Boost score di un tipo di tool (es. report) | `rankAgentTools` [main.js:1268-1270](file:///home/duxiter/s2s/demo/main.js#L1268-L1270) + mappa subito dopo | +| Aggiungere nuovo hint a `instructions` all'avvio sessione | `effectiveReasoningInstructions()` [main.js:516-533](file:///home/duxiter/s2s/demo/main.js#L516-L533) — nuova concatenazione | +| Iniettare `instructions` server-side (segret@ dati Aquachile) senza toccare client | **Proxy tunnel** `realtime_proxy()` in [server.py](file:///home/duxiter/s2s/demo/server.py#L891-L908) → `await upstream.send({type:"session.update", session:{instructions}})` **prima** dei 2 `create_task` | diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..b7d8e0d --- /dev/null +++ b/demo/README.md @@ -0,0 +1,184 @@ +--- +title: HF Realtime Voice +emoji: 🎙️ +colorFrom: indigo +colorTo: purple +sdk: docker +app_port: 7860 +pinned: false +short_description: Voice chat over WebSocket against a HF speech-to-speech +hf_oauth: true +--- + +# Realtime Voice Demo (WebSocket transport) + +Browser voice-chat UI for the +backend. The browser streams mic audio over a WebSocket using the OpenAI +Realtime **GA** protocol and plays back the assistant's audio as it arrives. + +## Quick start (local) + +1. **Start the speech-to-speech backend** in realtime mode (from the repo root; + ```bash + uv run speech-to-speech \ + --mode realtime \ + --stt parakeet-tdt \ + --llm_backend transformers \ + --tts kokoro \ + --model_name "Qwen/Qwen3-4B-Instruct-2507" \ + --llm_device mps \ + --llm_torch_dtype float16 \ + --enable_live_transcription + ``` + + The realtime server listens on `ws://localhost:8765/v1/realtime` by default + (`--ws_host` / `--ws_port` to change). + +2. **Start this app**, pointing it at the backend with `SPEECH_TO_SPEECH_URL`: + + ```bash + uv pip install -r demo/requirements.txt + export SPEECH_TO_SPEECH_URL=ws://localhost:8765/v1/realtime + export SERPER_API_KEY=... # optional; web search is disabled without it + uv run uvicorn --app-dir demo server:app --reload --port 7860 + ``` + + Or with Docker: + + ```bash + docker build -t s2s-demo demo/ + docker run -p 7860:7860 -e SPEECH_TO_SPEECH_URL=ws://host.docker.internal:8765/v1/realtime s2s-demo + ``` + +3. Open , click the orb, allow the mic, talk. + +> Browsers require **HTTPS or `localhost`** for `getUserMedia()` (mic + camera). +> `127.0.0.1` and `localhost` both work; plain `http://192.168.x.y` does NOT. + +Smoke-test the backend from the shell: + +```bash +websocat ws://localhost:8765/v1/realtime +# -> you should get a session.created event back immediately +``` + +## How it works + +1. The browser opens a WebSocket on the configured `/v1/realtime` URL. +2. Server pushes `session.created` on connect. Client replies with + `session.update` (OpenAI Realtime **GA** schema: `session.audio.input`, + `session.audio.output`, `session.output_modalities`). +3. Client streams mic audio as PCM16 16 kHz mono base64 chunks + (`input_audio_buffer.append`, one frame every ~40 ms). +4. Server pushes `response.output_audio.delta` (PCM16 24 kHz mono base64) + and transcript deltas. + +The backend exposes one concurrent session per pipeline unit +(`--num_pipelines` to serve more). + +## Connecting to a backend + +Three modes, picked by env (`/api/config` tells the client which one is active): + +- **`SPEECH_TO_SPEECH_URL` env** — the mode you want for local use, and the + highest priority. The browser connects **directly** to this realtime + WebSocket URL; it's shown read-only in Settings. Setting it disables the + load-balancer logic entirely (no `/api/session` proxy, no queue, no + metering, no sign-in). Unlike the LB address it is not a secret. Accepts a + full `ws(s)://host/v1/realtime` URL or a bare host like `localhost:8765` + (the app adds `/v1/realtime`). +- **Neither env set** — **Settings → Speech-to-speech server URL**: paste a + full connect URL or a bare host, and the browser connects to it directly. +- **`LOAD_BALANCER_URL` env** — multi-compute deployments only: the browser + POSTs the same-origin `/api/session` proxy, the server forwards to the LB, + and the browser dials the per-session compute URL the LB hands back. The LB + address never reaches the browser; the Settings URL field is hidden. + +| `SPEECH_TO_SPEECH_URL` | `LOAD_BALANCER_URL` | `SPACE_ID` | Connection | URL field | Metering | +|:---:|:---:|:---:|---|---|---| +| ✅ | any | any | direct → pinned URL | visible, locked | off | +| – | – | any | direct → user URL | editable | off | +| – | ✅ | ✅ | LB proxy | hidden | **on** | +| – | ✅ | – | LB proxy | hidden | off | + +**Settings → Restart** reconnects with the current voice, instructions and URL. + +## Tools + +The assistant can call two tools mid-conversation (toggle them from the **Tools** +button, top-right): + +- **Web search** — Google results via Serper.dev, proxied server-side so the key + never reaches the browser. Set `SERPER_API_KEY` as an env var / Space secret. + Without it, the tool is disabled unless the user pastes their own key in the + Tools panel. +- **Camera** — while enabled, a live self-view shows bottom-left; when the model + calls the tool, the current frame is sent to the vision-language model so it can + see what you're showing it. + +## Usage limits (deployed Space only) + +Conversation time is metered per UTC day by sign-in tier (see `limiter.py` / +`auth.py`), but **only on the deployed Space** — metering turns on only when BOTH +`LOAD_BALANCER_URL` and `SPACE_ID` (injected automatically by the HF Space +runtime) are present. Running locally — even with `LOAD_BALANCER_URL` exported — +leaves the app unmetered. Tunable via env: + +| Env | Default | What | +|-----|---------|------| +| `LIMIT_ANON_SEC` | `300` | Daily seconds for anonymous visitors (5 min) | +| `LIMIT_FREE_SEC` | `600` | Daily seconds for signed-in non-PRO users (10 min) | +| `UNLIMITED_ORGS` | _(adds to defaults)_ | Extra HF org names whose members get **unlimited** usage, like PRO | +| `USAGE_HASH_SECRET` | _(random)_ | HMAC secret for hashing identity keys + signing the anon cookie | + +PRO members are always unlimited. Members of `cerebras`, `HuggingFaceM4`, +`smolagents`, and `pollen-robotics` are unlimited out of the box (shown as +"Team", not "PRO"); set `UNLIMITED_ORGS=my-team` to add more. Matched +case-insensitively against the user's organisations from HF OAuth. + +## Settings (stored in `localStorage`) + +| Key | What | +|-----|------| +| Speech-to-speech server URL | Direct realtime WebSocket URL (hidden/locked when pinned by env) | +| Voice | Qwen3-TTS speaker name (Aiden, Ryan, Dylan, Eric, Ono_Anna, Serena, Sohee, Uncle_Fu, Vivian) | +| Instructions | System prompt sent in `session.update` once the WS opens | + +LocalStorage keys are namespaced `s2s.ws.*` so this app's settings do +NOT collide with the WebRTC variant. + +## Files + +| File | Role | +|------|------| +| `index.html` | Single page, orb + settings modal (identical UI to the WebRTC app) | +| `main.js` | State machine, settings, tools, camera, noise-gate UI wiring | +| `ui/chat.js` | `ChatView`: history panel, ephemeral bubbles, transcript/tool streaming | +| `ui/account.js` | `Account`: HF login chip + popover, daily-limit modal | +| `ui/dom.js` | Shared helpers: `$`, `escHtml`, `truncateError`, `DEBUG` | +| `auth.py` | HF OAuth + per-request identity (tier, hashed keys) | +| `limiter.py` | SQLite per-day talk-time budget (chunked server-clock reservation) | +| `ws/s2s-ws-client.js` | WebSocket handshake + OpenAI Realtime GA protocol | +| `ws/codec.js` | base64 <-> PCM helpers + transcript extraction (pure) | +| `ws/orb-visualizer.js` | `OrbVisualiser`: FFT bands -> orb CSS custom properties | +| `worklets/mic-capture.js` | AudioWorklet: 48 kHz Float32 -> 16 kHz Int16 PCM, posts ~40 ms chunks | +| `worklets/audio-playback.js` | AudioWorklet: 24 kHz Float32 ring buffer -> 48 kHz, linear interp, fade in/out | +| `style.css` | Orb animations, layout, dark theme (verbatim from the WebRTC app) | + +## Audio pipeline notes + +- **Input**: `getUserMedia({ echoCancellation, noiseSuppression, autoGainControl })` + feeds the `mic-capture` worklet at the `AudioContext` rate. The worklet + resamples to 16 kHz (boxcar lowpass + decimation on the 48 -> 16 fast + path, linear interpolation fallback for odd rates) and packs Int16 LE. +- **Output**: `response.output_audio.delta` decodes to Int16 -> Float32 + and is posted to the `audio-playback` worklet. The worklet maintains a + per-context ring buffer, linearly interpolates 24 -> 48, and applies + short 32-frame fades on entry/exit to suppress clicks. +- **Barge-in**: when the server VAD detects user speech mid-response + (`input_audio_buffer.speech_started` while `ai-speaking`), the client + posts `{ kind: "clear" }` to the playback worklet to wipe the queue + immediately. The server itself cancels the in-flight response. + +## Credits + diff --git a/demo/auth.py b/demo/auth.py new file mode 100644 index 0000000..71700fe --- /dev/null +++ b/demo/auth.py @@ -0,0 +1,229 @@ +""" +HF OAuth + per-request identity for the duration limiter. + +Login uses Hugging Face's native Spaces OAuth via `huggingface_hub` +(`attach_huggingface_oauth` / `parse_huggingface_oauth`). The OAuth env +(`OAUTH_CLIENT_ID`, ...) is injected by the platform when the Space README sets +`hf_oauth: true`, so this only activates on a deployed Space — locally and in +direct mode there's no OAuth and the limiter treats everyone as anonymous. + +Identity: + - signed in -> tier 'pro' | 'free', keyed by hashed HF `sub` + - anonymous -> tier 'anon', keyed by BOTH hashed client IP and a hashed + signed-cookie id (OR-matched in the limiter) +""" + +import logging +import os +import secrets + +import limiter + +logger = logging.getLogger("s2s.auth") + +# huggingface_hub adds these routes when OAuth is attached. Centralised so a +# version change is a one-line fix; the paths are handed to the client via +# /api/me rather than hardcoded there. +OAUTH_LOGIN_PATH = "/oauth/huggingface/login" +OAUTH_LOGOUT_PATH = "/oauth/huggingface/logout" + +ANON_COOKIE = "s2s_anon" +_COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days + + +# Members of these orgs get unlimited usage (like PRO) out of the box. The +# UNLIMITED_ORGS env adds to this set; it doesn't replace it. +_DEFAULT_UNLIMITED_ORGS = {"cerebras", "huggingfacem4", "smolagents", "pollen-robotics"} + + +def _unlimited_orgs() -> "set[str]": + """Org usernames whose members get unlimited usage (like PRO). + + Defaults to {cerebras, HuggingFaceM4, smolagents}; the UNLIMITED_ORGS env + (comma/space-separated, e.g. `UNLIMITED_ORGS=my-team`) adds more. Matched + case-insensitively against the signed-in user's organisations.""" + raw = os.environ.get("UNLIMITED_ORGS", "") + extra = {o.strip().lower() for o in raw.replace(",", " ").split() if o.strip()} + return _DEFAULT_UNLIMITED_ORGS | extra + +try: + from huggingface_hub import attach_huggingface_oauth, parse_huggingface_oauth + _OAUTH_IMPORTABLE = True +except Exception as exc: # pragma: no cover - import guard + logger.info("huggingface_hub OAuth unavailable (%s); sign-in disabled.", exc) + _OAUTH_IMPORTABLE = False + +# Set by attach(): True once OAuth is actually wired (importable + env present). +oauth_enabled = False + + +def attach(app) -> bool: + """Wire HF OAuth onto the app if it's importable and configured. Returns + whether sign-in is available.""" + global oauth_enabled + if not _OAUTH_IMPORTABLE or not os.environ.get("OAUTH_CLIENT_ID"): + return False + try: + attach_huggingface_oauth(app) + oauth_enabled = True + logger.info("HF OAuth attached (sign-in enabled).") + except Exception as exc: # pragma: no cover - defensive + logger.warning("Failed to attach HF OAuth: %r", exc) + oauth_enabled = False + return oauth_enabled + + +def _field(obj, name, default=None): + """Read a field whether the user-info is an object or a dict.""" + if obj is None: + return default + if isinstance(obj, dict): + return obj.get(name, default) + return getattr(obj, name, default) + + +# Surface what we detect (orgs, tier) in logs and on /api/me when set. Handy for +# verifying org gating on the live Space without guessing. +AUTH_DEBUG = bool(os.environ.get("AUTH_DEBUG")) + +# whoami-v2 org lookups are cached for the process lifetime, keyed by token, so +# /api/me + /api/session don't each hit the Hub. +_orgs_cache: "dict[str, set[str]]" = {} + + +def current_oauth(request): + """The parsed HF OAuth info (user_info + access_token), or None.""" + if not oauth_enabled: + return None + try: + return parse_huggingface_oauth(request) + except Exception: + return None + + +def current_user(request): + """The signed-in HF user-info, or None.""" + return _field(current_oauth(request), "user_info") + + +def _user_org_names(user) -> "set[str]": + """The user's organisations from the OAuth userinfo, by username/name/id.""" + names = set() + for org in _field(user, "orgs", []) or []: + for key in ("preferred_username", "name", "sub"): + val = _field(org, key) + if val: + names.add(str(val).lower()) + return names + + +def _orgs_via_token(token: str) -> "set[str]": + """Fallback org lookup via the Hub `whoami-v2` API, using the user's OAuth + access token. Covers the case where the userinfo claim omits `orgs`.""" + if not token: + return set() + if token in _orgs_cache: + return _orgs_cache[token] + names: "set[str]" = set() + try: + import httpx + + resp = httpx.get( + "https://huggingface.co/api/whoami-v2", + headers={"Authorization": f"Bearer {token}"}, + timeout=5.0, + ) + resp.raise_for_status() + for org in resp.json().get("orgs", []) or []: + for key in ("name", "fullname"): + val = org.get(key) + if val: + names.add(str(val).lower()) + except Exception as exc: # pragma: no cover - network/permission dependent + logger.info("whoami-v2 org lookup failed: %r", exc) + _orgs_cache[token] = names + return names + + +def _org_names(user, token=None, allow=None) -> "set[str]": + """The user's org usernames from the OAuth userinfo claim. If that doesn't + already satisfy `allow`, fall back to the Hub `whoami-v2` API (the claim is + often empty or partial), so membership is resolved either way.""" + names = _user_org_names(user) + if token and (allow is None or not (allow & names)): + names = names | _orgs_via_token(token) + return names + + +def resolve_tier(user, token=None) -> str: + """Tier for a signed-in user: 'pro' (paying), 'org' (allow-listed org + member, unlimited), or 'free'. PRO wins over org if both apply.""" + if bool(_field(user, "is_pro", False)): + return "pro" + allow = _unlimited_orgs() + names = _org_names(user, token, allow) + tier = "org" if (allow & names) else "free" + if AUTH_DEBUG: + logger.info("tier=%s orgs=%s allow=%s", tier, sorted(names), sorted(allow)) + return tier + + +def user_view(request) -> dict: + """Public profile for /api/me.""" + info = current_oauth(request) + user = _field(info, "user_info") + if not user: + return {"loggedIn": False, "tier": "anon"} + token = _field(info, "access_token") + out = { + "loggedIn": True, + "username": _field(user, "preferred_username") or _field(user, "name") or "you", + "avatar": _field(user, "picture"), + "tier": resolve_tier(user, token), + } + if AUTH_DEBUG: + out["orgs"] = sorted(_org_names(user, token)) + return out + + +def _client_ip(request) -> str: + """Real client IP. On HF the app sits behind a proxy, so the user's address + is the first hop in X-Forwarded-For, not request.client.host.""" + xff = request.headers.get("x-forwarded-for", "") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def resolve_identity(request): + """Resolve (tier, keys, set_cookie) for this request. + + `keys` are the limiter usage_daily keys to debit (one for signed-in, two for + anonymous). `set_cookie` is a signed value to Set-Cookie when we minted a new + anonymous id, else None. + """ + info = current_oauth(request) + user = _field(info, "user_info") + if user: + sub = _field(user, "sub") or _field(user, "preferred_username") + token = _field(info, "access_token") + return resolve_tier(user, token), [limiter.hash_key(f"sub:{sub}")], None + + # Anonymous: key by IP and a signed cookie id, minting the cookie if absent. + ip = _client_ip(request) + cookie_id = limiter.verify_cookie(request.cookies.get(ANON_COOKIE, "")) + set_cookie = None + if not cookie_id: + cookie_id = secrets.token_urlsafe(18) + set_cookie = limiter.sign_cookie(cookie_id) + keys = [limiter.hash_key(f"ip:{ip}"), limiter.hash_key(f"cookie:{cookie_id}")] + return "anon", keys, set_cookie + + +def set_anon_cookie(response, signed: str) -> None: + # The Space runs inside an iframe on huggingface.co, so the cookie lives in a + # cross-site context — it must be SameSite=None; Secure or the browser drops it. + response.set_cookie( + ANON_COOKIE, signed, + max_age=_COOKIE_MAX_AGE, httponly=True, samesite="none", secure=True, + ) diff --git a/demo/docs/adr/0001-docker-space-with-search-proxy.md b/demo/docs/adr/0001-docker-space-with-search-proxy.md new file mode 100644 index 0000000..740a506 --- /dev/null +++ b/demo/docs/adr/0001-docker-space-with-search-proxy.md @@ -0,0 +1,29 @@ +# Convert the static Space into a Docker app with a search proxy + +To give the model a web search tool, the executor (which runs in the browser of a +public Space) needs a search key. A `sdk: static` Space serves files as-is with no +runtime process, so it cannot hold a secret the browser uses without exposing it. +We convert the Space from `sdk: static` to `sdk: docker`: a single container runs a +small server (FastAPI + uvicorn) that both serves the existing front-end *unchanged* +and exposes a same-origin `/search` proxy holding `SERPER_API_KEY` server-side. The +whole app lives in that one container; the s2s speech-to-speech backend stays the +separate load-balanced service it already is. + +## Considered options + +- **Stay static, user-supplied key only** — no owner key; search works only if each + user pastes their own. Rejected as the default because it leaves the deployed demo + with no working search. +- **Separate proxy service** — same secrecy, but splits the app across two deploys. + Rejected: the app must live in one container. +- **Key baked into client JS at build time** — would be readable in the served + bundle. Rejected: defeats the point. + +## Consequences + +- Deployment is no longer static: there is a Dockerfile and a server process; the + README front-matter changes from `sdk: static` to `sdk: docker`. +- The client calls `/search` same-origin; the server reads the key from env. A user + may still supply their own key as a fallback, sent per-request to the proxy. +- The front-end, audio pipeline, and s2s handshake are untouched — only the hosting + shape and the new route are added. diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..04a7e39 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,360 @@ + + + + + + + + + Minimal Conversation · S2S backend (WebSocket) + + + + + + + +
+
+ +
+ + + + + + +
+
+ +
+
+
+ + +
+ + + + +
+ +

Tap to start

+ + + +
+ +
+ + +
+ + + + + +
+
+
+
+

Conversation

+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + diff --git a/demo/index_video.html b/demo/index_video.html new file mode 100644 index 0000000..6f085e6 --- /dev/null +++ b/demo/index_video.html @@ -0,0 +1,356 @@ + + + + + + + + + Minimal Conversation · S2S backend (WebSocket) + + + + + + + +
+
+ +
+ + + + + + +
+
+ +
+
+
+ + +
+ + + + +
+ +

Tap to start

+ + + +
+ +
+ + +
+ + + + + +
+
+
+
+

Conversation

+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + diff --git a/demo/limiter.py b/demo/limiter.py new file mode 100644 index 0000000..77a7fb2 --- /dev/null +++ b/demo/limiter.py @@ -0,0 +1,276 @@ +""" +Per-day talk-time budget for the speech-to-speech demo. + +Our server isn't in the audio path (the browser dials the compute WebSocket +directly), so it can't cut a live stream. What it *can* do is meter time with a +server-clock, chunked reservation: + + - At grant we reserve the first chunk (CHUNK_SEC) and debit it from the day's + budget. A parallel grant therefore sees the budget already spent. + - The client heartbeats; each heartbeat extends the reservation one chunk at a + time until the daily budget runs out, then we report `expired` so the client + tears down. + - On a clean end (sendBeacon) we reconcile to the real elapsed time and refund + the unused chunk. A crash (no end, no heartbeats) is reaped by a sweep and + forfeits at most one chunk. + +All time is the server's clock. Budgets are per UTC day; a new day is simply a +new row (no explicit reset). Logged-in users are keyed by a hashed HF `sub`; +anonymous users by BOTH a hashed IP and a hashed signed-cookie id, OR-matched +(spent = max of the two) so clearing one identifier doesn't reset the budget. + +Storage is SQLite at $USAGE_DB_PATH, else /data (persistent Spaces storage), +else a /tmp fallback. On /tmp the budget is only per-uptime — flagged in logs. +""" + +import hashlib +import hmac +import logging +import math +import os +import sqlite3 +import tempfile +import threading +import time +from datetime import datetime, timezone +from pathlib import Path + +logger = logging.getLogger("s2s.limiter") + +# ── Tunables (env-overridable) ─────────────────────────────────────────────── +ANON_SEC = int(os.environ.get("LIMIT_ANON_SEC", "300")) # 5 min/day, not signed in +FREE_SEC = int(os.environ.get("LIMIT_FREE_SEC", "600")) # 10 min/day, signed in, no PRO +CHUNK_SEC = int(os.environ.get("RESERVE_CHUNK_SEC", "10")) # reservation granularity +HEARTBEAT_SEC = int(os.environ.get("HEARTBEAT_SEC", "5")) # advertised client cadence +REAP_AFTER_SEC = int(os.environ.get("SESSION_REAP_SEC", "15")) # silence before sweep + +# Stable across restarts or the hashed keys (and signed cookies) rotate and the +# budget effectively resets. Set it as a Space secret. Falls back to a per-boot +# random value (keys then only hold within one uptime). +_HASH_SECRET = (os.environ.get("USAGE_HASH_SECRET", "").strip() or os.urandom(32).hex()).encode() + +_lock = threading.Lock() +_db_path: "Path | None" = None + + +def budget_for(tier: str) -> "int | None": + """Daily second-budget for a tier, or None for unlimited. + + Unlimited tiers: 'pro' (paying PRO members) and 'org' (members of an + allow-listed organisation, see UNLIMITED_ORGS in auth.py).""" + if tier in ("pro", "org"): + return None + if tier == "free": + return FREE_SEC + return ANON_SEC + + +def hash_key(raw: str) -> str: + """HMAC a raw identifier (sub / ip / cookie id) into an opaque storage key.""" + digest = hmac.new(_HASH_SECRET, raw.encode("utf-8"), hashlib.sha256).hexdigest() + return f"k_{digest}" + + +def sign_cookie(value: str) -> str: + """`.` so a forged anon-cookie id is rejected on read.""" + sig = hmac.new(_HASH_SECRET, value.encode("utf-8"), hashlib.sha256).hexdigest()[:32] + return f"{value}.{sig}" + + +def verify_cookie(signed: str) -> "str | None": + """Return the id if the signature checks out, else None.""" + if not signed or "." not in signed: + return None + value, _, sig = signed.rpartition(".") + want = hmac.new(_HASH_SECRET, value.encode("utf-8"), hashlib.sha256).hexdigest()[:32] + return value if hmac.compare_digest(sig, want) else None + + +def _today() -> str: + return datetime.now(timezone.utc).date().isoformat() + + +def _resolve_db_path() -> Path: + explicit = os.environ.get("USAGE_DB_PATH", "").strip() + if explicit: + return Path(explicit) + data = Path("/data") + if data.is_dir() and os.access(data, os.W_OK): + return data / "s2s-usage.sqlite3" + logger.warning("No persistent /data — usage budget falls back to /tmp (per-uptime only).") + return Path(tempfile.gettempdir()) / "s2s-usage.sqlite3" + + +def _connect() -> sqlite3.Connection: + con = sqlite3.connect(_db_path, timeout=5.0) + con.execute("PRAGMA journal_mode=WAL") + con.execute("PRAGMA busy_timeout=5000") + return con + + +def init() -> None: + """Create the schema. Call once at startup.""" + global _db_path + _db_path = _resolve_db_path() + with _lock, _connect() as con: + con.execute( + """CREATE TABLE IF NOT EXISTS usage_daily ( + user_key TEXT NOT NULL, + day TEXT NOT NULL, + spent_sec INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_key, day) + )""" + ) + con.execute( + """CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + keys TEXT NOT NULL, -- comma-joined usage_daily keys to debit + day TEXT NOT NULL, + tier TEXT NOT NULL, + grant_ts REAL NOT NULL, + last_seen_ts REAL NOT NULL, + reserved_sec INTEGER NOT NULL, + ended INTEGER NOT NULL DEFAULT 0 + )""" + ) + logger.info("Usage limiter ready at %s (anon=%ss free=%ss chunk=%ss)", _db_path, ANON_SEC, FREE_SEC, CHUNK_SEC) + + +# ── Internal helpers (call under _lock) ─────────────────────────────────────── + +def _spent(con, key: str, day: str) -> int: + row = con.execute( + "SELECT spent_sec FROM usage_daily WHERE user_key=? AND day=?", (key, day) + ).fetchone() + return int(row[0]) if row else 0 + + +def _spent_max(con, keys, day: str) -> int: + """OR-match: the most-spent identifier governs.""" + return max((_spent(con, k, day) for k in keys), default=0) + + +def _add(con, keys, day: str, delta: int) -> None: + now = int(time.time()) + for k in keys: + cur = _spent(con, k, day) + nxt = max(0, cur + delta) + con.execute( + """INSERT INTO usage_daily (user_key, day, spent_sec, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_key, day) DO UPDATE SET + spent_sec = excluded.spent_sec, updated_at = excluded.updated_at""", + (k, day, nxt, now), + ) + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def remaining(keys, tier: str) -> "int | None": + """Seconds left today for these keys (None = unlimited). No mutation.""" + budget = budget_for(tier) + if budget is None: + return None + with _lock, _connect() as con: + return max(0, budget - _spent_max(con, keys, _today())) + + +def begin(session_id: str, keys, tier: str) -> int: + """Reserve the first chunk for a new session and record it. Returns the + chunk reserved (0 if the budget is already exhausted — the first heartbeat + will then expire it). PRO (unlimited) is never tracked; don't call it here.""" + day = _today() + budget = budget_for(tier) + now = time.time() + with _lock, _connect() as con: + avail = budget - _spent_max(con, keys, day) if budget is not None else CHUNK_SEC + chunk = max(0, min(CHUNK_SEC, avail)) + if chunk: + _add(con, keys, day, chunk) + con.execute( + """INSERT OR REPLACE INTO sessions + (session_id, keys, day, tier, grant_ts, last_seen_ts, reserved_sec, ended) + VALUES (?, ?, ?, ?, ?, ?, ?, 0)""", + (session_id, ",".join(keys), day, tier, now, now, chunk), + ) + return chunk + + +def heartbeat(session_id: str) -> bool: + """Keep a session alive: extend the reservation toward `elapsed + 1 chunk`, + debiting the budget chunk by chunk. Returns True while alive, False once the + budget is spent (caller should tear down) or the session is unknown/ended.""" + now = time.time() + with _lock, _connect() as con: + row = con.execute( + "SELECT keys, day, tier, grant_ts, reserved_sec, ended FROM sessions WHERE session_id=?", + (session_id,), + ).fetchone() + if not row or row[5]: + return False + keys = row[0].split(",") + day, tier, grant_ts, reserved = row[1], row[2], row[3], int(row[4]) + budget = budget_for(tier) + elapsed = now - grant_ts + + # Grow the reservation one chunk at a time until it covers elapsed + a + # one-chunk lookahead, or the budget runs dry. + while reserved < elapsed + CHUNK_SEC: + if budget is not None and _spent_max(con, keys, day) >= budget: + break + _add(con, keys, day, CHUNK_SEC) + reserved += CHUNK_SEC + + alive = reserved > elapsed # could we cover the time already elapsed? + con.execute( + "UPDATE sessions SET last_seen_ts=?, reserved_sec=?, ended=? WHERE session_id=?", + (now, reserved, 0 if alive else 1, session_id), + ) + if not alive: + _reconcile(con, keys, day, grant_ts, reserved, end_ts=now) + return alive + + +def end(session_id: str) -> None: + """Clean teardown: reconcile to actual elapsed time and refund the unused + reservation. Idempotent.""" + now = time.time() + with _lock, _connect() as con: + row = con.execute( + "SELECT keys, day, grant_ts, reserved_sec, ended FROM sessions WHERE session_id=?", + (session_id,), + ).fetchone() + if not row or row[4]: + return + keys, day, grant_ts, reserved = row[0].split(","), row[1], row[2], int(row[3]) + _reconcile(con, keys, day, grant_ts, reserved, end_ts=now) + con.execute("UPDATE sessions SET ended=1, last_seen_ts=? WHERE session_id=?", (now, session_id)) + + +def sweep() -> None: + """Reap sessions that went silent (crash / closed without a beacon): bill + their elapsed time, refund the rest, mark ended. Forfeits ≤ one chunk.""" + now = time.time() + cutoff = now - REAP_AFTER_SEC + with _lock, _connect() as con: + stale = con.execute( + "SELECT session_id, keys, day, grant_ts, last_seen_ts, reserved_sec FROM sessions " + "WHERE ended=0 AND last_seen_ts < ?", + (cutoff,), + ).fetchall() + for session_id, keys_s, day, grant_ts, last_seen, reserved in stale: + _reconcile(con, keys_s.split(","), day, grant_ts, int(reserved), end_ts=last_seen) + con.execute("UPDATE sessions SET ended=1 WHERE session_id=?", (session_id,)) + if stale: + logger.debug("swept %d stale session(s)", len(stale)) + + +def _reconcile(con, keys, day: str, grant_ts: float, reserved: int, end_ts: float) -> None: + """Refund reserved-but-unused time. Bill elapsed rounded up to a chunk, + capped at what was reserved.""" + elapsed = max(0.0, end_ts - grant_ts) + billed = min(reserved, int(math.ceil(elapsed / CHUNK_SEC) * CHUNK_SEC)) + refund = reserved - billed + if refund > 0: + _add(con, keys, day, -refund) diff --git a/demo/main.js b/demo/main.js new file mode 100644 index 0000000..9030c7b --- /dev/null +++ b/demo/main.js @@ -0,0 +1,3392 @@ +// @ts-check +/** + * Minimal voice conversation app, talking to a Hugging Face speech-to-speech + * backend over **WebSocket** (drop-in alternative to the WebRTC variant). + * + * Click the orb -> we ask for the mic, POST a session on the LB, open a + * WebSocket on the routed compute endpoint, push session.update + mic + * audio, play back the TTS audio. The orb visually reflects the live + * state (idle, connecting, listening, user-speaking, processing, + * ai-speaking). + * + * The only meaningful difference vs. the WebRTC main.js is that the + * client owns its own AudioContext (no `attachOutputTrack`), so we hand + * it the MediaStream directly. + * + * @typedef {"idle" | "connecting" | "queued" | "your-turn" | "listening" | "user-speaking" | "processing" | "ai-speaking" | "error"} AppState + */ + +import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js?v=20260826001"; +import { $, escHtml, truncateError, DEBUG } from "./ui/dom.js?v=20260826001"; +import { ChatView } from "./ui/chat.js?v=20260826001"; +import { Account } from "./ui/account.js?v=20260826001"; + +const DEFAULT_VOICE = "Aiden"; +const DEFAULT_INSTRUCTIONS = + "You are a friendly voice assistant. " + + "Keep replies short, warm, and spoken. Avoid long monologues."; + +// Appended to the user's instructions whenever at least one tool is enabled. +// Stops the model from announcing capabilities ("Yes, I can search") and then +// idling for the next turn — it should act immediately in the same response. +const TOOL_USE_HINT = + " When the user's request calls for one of your tools, do not describe your " + + "capabilities or say you can do it and wait for another turn. Instead, say " + + 'a brief acknowledgement like "Let me search for that..." and call the tool ' + + "right away in the same response."; + +const MCP_SEARCH_HINT = + " When the user asks for information, review the full MCP tool catalog, choose " + + "the single best tool for the request, and call it immediately when it can solve " + + "the task. If required parameters are missing, ask a short follow-up question. " + + "If no MCP tool fits, answer normally or use the other available tools."; +const OPENAI_RENDER_MARKER = "[VOCERO_RENDER_REPLY]"; +const OPENAI_RENDER_HINT = + ` Do not answer ordinary user speech directly. Stay silent unless the latest user message starts with ` + + `${OPENAI_RENDER_MARKER}. When it does, speak only the content after the marker naturally, without adding, ` + + `removing, or changing facts. Do not call tools while rendering that message.`; +const VOCERO_USER_CONTEXT_HINT = + " Caller profile resolved from the page phoneNumber: "; +/** @type {[RegExp, string][]} */ +const MCP_SEARCH_TRANSLATION_PHRASES = [ + [/informaciones del usuario/g, "user information"], + [/informacion del usuario/g, "user information"], + [/informazioni utente/g, "user information"], + [/informazione utente/g, "user information"], + [/report del dia/g, "day report"], + [/reporte del dia/g, "day report"], + [/report di oggi/g, "today report"], + [/reporte de hoy/g, "today report"], + [/report giornaliero/g, "daily report"], + [/reporte diario/g, "daily report"], + [/reporte de ayer/g, "yesterday report"], + [/reporte del día anterior/g, "previous day report"], + [/report di ieri/g, "yesterday report"], + [/report del giorno prima/g, "previous day report"], + [/último reporte/g, "latest report"], + [/ultimo reporte/g, "latest report"], + [/ultimo report/g, "latest report"], + [/report più recente/g, "most recent report"], + [/reporte más reciente/g, "most recent report"], + [/reporte mas reciente/g, "most recent report"], + [/más reciente/g, "most recent"], + [/mas reciente/g, "most recent"], + [/più recente/g, "most recent"], + [/listado de reportes/g, "list reports report list"], + [/lista di report/g, "list reports report list"], + [/reportes recientes/g, "recent reports list"], + [/report recenti/g, "recent reports list"], + [/mi numero es/g, "my number is"], + [/il mio numero e/g, "my number is"], + [/numero di telefono/g, "phone number"], + [/numero telefonico/g, "phone number"], + [/numero de telefono/g, "phone number"], + [/numero de whatsapp/g, "whatsapp number"], + [/informacion api/g, "api information"], + [/informazioni api/g, "api information"], +]; +/** @type {Record} */ +const MCP_SEARCH_TRANSLATION_TOKENS = { + ayuda: "help", + api: "api", + ayer: "yesterday", + ieri: "yesterday", + anterior: "previous", + precedente: "previous", + dami: "give", + dame: "give", + dammi: "give", + buscar: "search", + busca: "search", + busqueda: "search", + call: "call", + catalogo: "catalogue", + catalogue: "catalogue", + cerca: "search", + chiama: "call", + cliente: "customer", + clientes: "customers", + customer: "customer", + detalle: "detail", + detalles: "details", + dettagli: "details", + dia: "day", + diario: "daily", + dio: "day", + documento: "document", + documentos: "documents", + elenco: "list", + email: "email", + estado: "status", + estadoo: "status", + historial: "history", + hoy: "today", + informacion: "information", + informazioni: "information", + informe: "report", + informes: "reports", + lista: "list", + listado: "list", + liste: "lists", + mostrar: "show", + mostra: "show", + numero: "number", + numeros: "numbers", + obtener: "get", + ottieni: "get", + perfil: "profile", + profilo: "profile", + reciente: "recent", + recientes: "recent", + recente: "recent", + recenti: "recent", + reporte: "report", + reportes: "reports", + reporto: "report", + stato: "status", + telefono: "phone", + telefonoo: "phone", + usuario: "user", + utenti: "users", + utente: "user", + ultimo: "latest", + ultimi: "latest", + último: "latest", + últimos: "latest", + whatsapp: "whatsapp", +}; + +const STORAGE_KEYS = { + directUrl: "s2s.ws.directUrl", + voice: "s2s.ws.voice", + instructions: "s2s.ws.instructions", + tools: "s2s.ws.tools", + searchKey: "s2s.ws.searchKey", + noiseGate: "s2s.ws.noiseGate", +}; + +// ── Wake Word ("hola vocero") ───────────────────────────────────────────── +// Local browser Web Speech API (SpeechRecognition) with a tight JSGF grammar +// restricted to the trigger phrase, continuous/interim listening while the app +// is idle/error, and auto-restart on end so it keeps arming across Chrome's +// ~60s silence timeouts. On unsupported browsers (Firefox, Safari desktop <16) +// this module is a silent no-op and the caption stays actionable via tap only. +const WAKE_WORD_LANG = "es-ES"; +const WAKE_WORD_PHRASES = [ + "hola vocero", + "ola vocero", + "hola vocero", + "hola vocerò", + "ola vocerò", + "olá vocero", + "oye vocero", + "hola vocerro", + "hola vo zero", + "ola vo zero", +]; +const WAKE_WORD_JSGF_GRAMMAR = + "#JSGF V1.0; grammar wake_word; public = hola vocero | ola vocero | hola vocerò | ola vocerò | olá vocero | oye vocero | hola vocerro ;"; +const WAKE_WORD_RESTART_DELAY_MS = 250; +const WAKE_WORD_MAX_INTERIM_LEN = 64; + +/** @typedef {any & { continuous: boolean, interimResults: boolean, lang: string, grammars: any, maxAlternatives: number, start: () => void, stop: () => void, abort: () => void, onresult: any, onerror: any, onend: any, onstart: any, onaudioend: any }} WakeRec */ +/** @type {WakeRec | null} */ +let wakeWordEngine = null; +let wakeWordArmed = false; +let wakeWordRestartTimer = 0; +let pendingWakeWordConfirmationText = ""; + +/** @param {string} s @returns {string} */ +function _normalizeWakeWord(s) { + return (s || "") + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** @param {string} transcript */ +function _matchesWakeWord(transcript) { + const hay = _normalizeWakeWord(transcript); + if (!hay) return false; + // Strict: contains a whole-phrase substring match of any variant. + for (const raw of WAKE_WORD_PHRASES) { + const needle = _normalizeWakeWord(raw); + if (needle && hay.includes(needle)) return true; + } + // Loose fallback for off-by-one phonemes: allow "vocero" preceded by any + // 3-5 char greeting-like word (e.g. "ola", "hola", "oye", "uy") to catch + // accent variations the grammar misses. + if (/\b(vocero|vocerro|vocerò|vo.?zero)\b/.test(hay) && + /\b(hola|ola|oye|uy|holi|oli)\b/.test(hay)) { + return true; + } + return false; +} + +/** @param {WakeRec} engine */ +function _installWakeWordHandlers(engine) { + engine.onresult = (/** @type {any} */ event) => { + if (!wakeWordArmed) return; + let aggregate = ""; + for (let i = event.resultIndex; i < event.results.length; i += 1) { + const res = event.results[i]; + const alt = res[0]?.transcript || ""; + aggregate += ` ${alt}`; + if (aggregate.length > WAKE_WORD_MAX_INTERIM_LEN) break; + if (_matchesWakeWord(alt) || _matchesWakeWord(aggregate)) { + if (DEBUG) console.debug(`[WAKE WORD] MATCH transcript=${JSON.stringify(alt + (aggregate !== ` ${alt}` ? ` aggregate=${JSON.stringify(aggregate)}` : ""))}`); + console.info(`[WAKE WORD] ✓ Trigger fired — starting conversation`); + // Disarm *before* click: stop sharing the mic with SpeechRecognition + // while we're about to open getUserMedia for the realtime session. + wakeWordArmed = false; + try { engine.stop(); } catch { /* ignore */ } + if (wakeWordRestartTimer) { clearTimeout(wakeWordRestartTimer); wakeWordRestartTimer = 0; } + // Remember the exact phrase so onClientStatus(connected) can inject it + // into the live conversation and ask the model to reply to it (instead + // of silently waiting for the next user utterance). + const spoken = (alt || "").trim() || (aggregate || "").trim() || "hola vocero"; + pendingWakeWordConfirmationText = spoken; + console.info(`[WAKE WORD] 🔁 Pending confirmation text: ${JSON.stringify(pendingWakeWordConfirmationText)}`); + // Pretend the user tapped the orb. If the AudioContext fails to + // resume (iOS Safari needs a real tap), doStart() will surface the + // error with the normal "Tap to retry" affordance. + circleBtn.click(); + return; + } + } + if (DEBUG) { + const cleaned = aggregate.trim(); + if (cleaned) console.debug(`[WAKE WORD] interim transcript=${JSON.stringify(cleaned.slice(0, WAKE_WORD_MAX_INTERIM_LEN))}`); + } + }; + + engine.onerror = (/** @type {any} */ event) => { + const err = event?.error || String(event || "unknown"); + // "no-speech" / "audio-capture" / "aborted" are benign. + if (["no-speech", "aborted"].includes(err)) return; + console.warn(`[WAKE WORD] engine error: ${err}${event?.message ? ` (${event.message})` : ""}`); + // "not-allowed" or "service-not-allowed": the user/OS blocked the mic for + // the recognizer — permanently disarm so we don't thrash the permission. + if (err === "not-allowed" || err === "service-not-allowed") { + console.warn("[WAKE WORD] ✋ Permission denied — permanently disarming wake word (tap the orb to start)"); + wakeWordArmed = false; + try { engine.stop(); } catch { /* ignore */ } + if (wakeWordRestartTimer) { clearTimeout(wakeWordRestartTimer); wakeWordRestartTimer = 0; } + } + }; + + engine.onend = () => { + if (!wakeWordArmed) return; + // Chrome terminates continuous recognition after ~60s of silence. + // Re-arm on the next tick so a single failed start doesn't loop. + if (wakeWordRestartTimer) clearTimeout(wakeWordRestartTimer); + wakeWordRestartTimer = window.setTimeout(() => { + if (!wakeWordArmed) return; + try { + engine.start(); + if (DEBUG) console.debug("[WAKE WORD] 🔁 restarted continuous listener"); + } catch (err) { + // Already-started is harmless; anything else we swallow briefly. + if (DEBUG) console.debug("[WAKE WORD] restart skipped:", err && err instanceof Error ? err.message : err); + } + }, WAKE_WORD_RESTART_DELAY_MS); + }; +} + +function startWakeWordListener() { + if (wakeWordArmed) return; + // Only in Chromium-derived and recent Safari WebViews with SpeechRecognition. + // @ts-ignore - vendor prefixes are intentional. + const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition || window.mozSpeechRecognition; + if (!Ctor) { + if (DEBUG) console.debug("[WAKE WORD] ⚠️ Web Speech Recognition unavailable on this browser — tap the orb to start"); + return; + } + try { + /** @type {WakeRec} */ + // @ts-ignore + const engine = new Ctor(); + engine.continuous = true; + engine.interimResults = true; + engine.lang = WAKE_WORD_LANG; + engine.maxAlternatives = 1; + // Narrow the grammar so interim results are biased toward the phrase. + try { + // @ts-ignore + const list = window.SpeechGrammarList || window.webkitSpeechGrammarList; + if (list) { + // @ts-ignore + const gl = new list(); + gl.addFromString(WAKE_WORD_JSGF_GRAMMAR, 1.0); + engine.grammars = gl; + } + } catch { + // Grammar support is optional; fallback to pure transcript matching. + } + _installWakeWordHandlers(engine); + engine.start(); + wakeWordEngine = engine; + wakeWordArmed = true; + console.info("[WAKE WORD] 🎯 Armed — listening for \"Hola Vocero\""); + } catch (err) { + console.warn("[WAKE WORD] failed to start arming:", err && err instanceof Error ? err.message : err); + wakeWordArmed = false; + } +} + +function stopWakeWordListener() { + if (!wakeWordArmed && !wakeWordEngine && !pendingWakeWordConfirmationText) return; + wakeWordArmed = false; + if (wakeWordRestartTimer) { clearTimeout(wakeWordRestartTimer); wakeWordRestartTimer = 0; } + if (wakeWordEngine) { + try { wakeWordEngine.abort(); } catch { /* ignore */ } + try { wakeWordEngine.stop(); } catch { /* ignore */ } + wakeWordEngine = null; + } + if (DEBUG) console.debug("[WAKE WORD] ⏸️ Disarmed for live session"); +} + +// ── Noise gate ────────────────────────────────────────────────────────────── +// The Settings cursor sets the gate's open threshold in dBFS. Its leftmost +// position is an OFF detent (gate disabled, pure passthrough); the rest of the +// travel is the active threshold. The cursor shares the meter's dB axis, so the +// handle sits on the level bar — raise it until room noise stops lighting it up. +// The slider range IS the shared axis: the live meter fill and the threshold +// thumb both map across [GATE_OFF_DB, GATE_MAX_DB], so the thumb sits exactly +// where the gate cuts on the same scale as the level bar. +const GATE_OFF_DB = -66; // slider minimum = off / bottom of the meter axis +const GATE_MAX_DB = -3; // slider maximum = most aggressive / top of the meter axis +const GATE_DEFAULT_DB = -50; // first-run default: a gentle gate, enabled + +/** @param {number} thresholdDb @returns {import("./ws/s2s-ws-client.js").NoiseGate} */ +function gateParams(thresholdDb) { + return { enabled: thresholdDb > GATE_OFF_DB, thresholdDb }; +} + +// ── Tools ───────────────────────────────────────────────────────────────── +// Function tools we declare to the backend. The model decides when to call +// one; the executor below runs it and returns the result (see runTool). +/** @type {Record} */ +const TOOL_DEFS = { + web_search: { + type: "function", + name: "web_search", + description: + "Search the web for current or factual information you don't already know " + + "(news, prices, facts, documentation). Returns the top results with titles, " + + "snippets and URLs.", + parameters: { + type: "object", + properties: { query: { type: "string", description: "The search query." } }, + required: ["query"], + }, + }, +}; + +/** @type {import("./ws/s2s-ws-client.js").ToolDef[]} */ +let agentToolDefs = []; + +function loadSettings() { + return { + directUrl: localStorage.getItem(STORAGE_KEYS.directUrl) || "", + voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE, + instructions: localStorage.getItem(STORAGE_KEYS.instructions) || DEFAULT_INSTRUCTIONS, + noiseGate: loadGateThreshold(), + }; +} + +/** Stored gate threshold (dBFS), clamped to the slider range. Defaults to a + * gentle enabled gate (GATE_DEFAULT_DB) when the user hasn't set one yet. */ +function loadGateThreshold() { + const stored = localStorage.getItem(STORAGE_KEYS.noiseGate); + // getItem returns null when unset, and Number(null) === 0 (finite!), so guard + // the missing/empty case explicitly before coercing — otherwise the default + // never fires and 0 clamps to the slider max. + if (stored === null || stored === "") return GATE_DEFAULT_DB; + const raw = Number(stored); + if (!Number.isFinite(raw)) return GATE_DEFAULT_DB; + return Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, Math.round(raw))); +} + +/** @param {ReturnType} s */ +function saveSettings(s) { + localStorage.setItem(STORAGE_KEYS.directUrl, s.directUrl); + localStorage.setItem(STORAGE_KEYS.voice, s.voice); + localStorage.setItem(STORAGE_KEYS.instructions, s.instructions); + localStorage.setItem(STORAGE_KEYS.noiseGate, String(s.noiseGate)); +} + +/** @returns {{ web_search: boolean }} */ +function loadTools() { + try { + const raw = JSON.parse(localStorage.getItem(STORAGE_KEYS.tools) || "{}"); + return { + web_search: raw.web_search ?? true, + }; + } catch { + return { web_search: true }; + } +} + +function saveTools() { + localStorage.setItem(STORAGE_KEYS.tools, JSON.stringify(toolsEnabled)); +} + +/** @type {Record} */ +const STATE_VIEWS = { + idle: { caption: "Tap or say \"Hola Vocero\"", disabled: false }, + connecting: { caption: "Connecting", disabled: true }, + queued: { caption: "Finding you a spot…", disabled: true }, + "your-turn": { caption: "You're up! 🎉", disabled: true }, + listening: { caption: "", disabled: false }, + "user-speaking": { caption: "", disabled: false }, + processing: { caption: "", disabled: false }, + "ai-speaking": { caption: "", disabled: false }, + error: { caption: "Tap to retry", disabled: false }, +}; + +/** @type {Record} */ +const STATE_CLASS = { + idle: "state-idle", + connecting: "state-connecting", + queued: "state-queued", + "your-turn": "state-your-turn", + listening: "state-listening", + "user-speaking": "state-user-speaking", + processing: "state-processing", + "ai-speaking": "state-ai-speaking", + error: "state-error", +}; + +/** @type {ReadonlySet} */ +const LIVE_STATES = new Set(["listening", "user-speaking", "processing", "ai-speaking"]); + +/** @type {HTMLButtonElement} */ +const circleBtn = $("#main-circle"); +/** @type {HTMLParagraphElement} */ +const circleCaption = $("#circle-caption"); +/** @type {HTMLParagraphElement} */ +const circleSubcaption = $("#circle-subcaption"); +/** @type {HTMLElement} */ +const orbWrap = $(".orb-wrap"); +/** @type {HTMLButtonElement} */ +const micBtn = $("#mic-btn"); +/** @type {HTMLButtonElement} */ +const stopBtn = $("#stop-btn"); +/** @type {HTMLElement} */ +const queueActions = $("#queue-actions"); +/** @type {HTMLButtonElement} */ +const joinQueueBtn = $("#join-queue-btn"); +/** @type {HTMLButtonElement} */ +const leaveQueueBtn = $("#leave-queue-btn"); + +/** @type {HTMLButtonElement} */ +const settingsBtn = $("#settings-btn"); +/** @type {HTMLDialogElement} */ +const settingsModal = $("#settings-modal"); + +/** @type {HTMLButtonElement | null} */ +const aboutBtn = document.querySelector("#about-btn"); +/** @type {HTMLDialogElement} */ +const aboutModal = $("#about-modal"); +/** @type {HTMLButtonElement} */ +const aboutClose = $("#about-close"); + +/** @type {HTMLButtonElement} */ +const toolsBtn = $("#tools-btn"); +/** @type {HTMLDialogElement} */ +const toolsModal = $("#tools-modal"); +/** @type {HTMLButtonElement} */ +const toolsClose = $("#tools-close"); +/** @type {HTMLInputElement} */ +const toolWebSwitch = $("#tool-web"); +/** @type {HTMLElement} */ +const toolWebRow = $("#tool-web-row"); +/** @type {HTMLElement} */ +const toolWebHint = $("#tool-web-hint"); +/** @type {HTMLInputElement} */ +const toolCamSwitch = $("#tool-cam"); +/** @type {HTMLElement} */ +const toolCamHint = $("#tool-cam-hint"); +/** @type {HTMLElement} */ +const camPip = $("#cam-pip"); +/** @type {HTMLVideoElement} */ +const camVideo = $("#cam-video"); +/** @type {HTMLElement} */ +const agentToolsHint = $("#agent-tools-hint"); +/** @type {HTMLElement} */ +const agentToolsList = $("#agent-tools-list"); +/** @type {HTMLInputElement} */ +const searchKeyInput = $("#search-key"); + +/** @type {HTMLInputElement} */ +const inputLbUrl = $("#lb-url"); +/** @type {HTMLElement} */ +const connField = $("#conn-field"); +/** @type {HTMLElement} */ +const connHint = $("#conn-hint"); +/** @type {HTMLSelectElement} */ +const inputVoice = $("#voice"); +/** @type {HTMLTextAreaElement} */ +const inputInstructions = $("#instructions"); +/** @type {HTMLElement} */ +const primaryPromptMeta = $("#primary-prompt-meta"); +/** @type {HTMLElement} */ +const identUserName = $("#ident-user-name"); +/** @type {HTMLInputElement} */ +const inputNoiseGate = $("#noise-gate"); +/** @type {HTMLElement} */ +const gateValue = $("#gate-value"); +/** @type {HTMLElement} */ +const gateMeterFill = $("#gate-meter-fill"); +/** @type {HTMLElement} */ +const micGate = $("#mic-gate"); +const mgaArc = /** @type {SVGSVGElement} */ (document.querySelector("#mic-gate-arc")); +const mgaTrack = /** @type {SVGPathElement} */ (document.querySelector("#mga-track")); +const mgaFill = /** @type {SVGPathElement} */ (document.querySelector("#mga-fill")); +const mgaHit = /** @type {SVGPathElement} */ (document.querySelector("#mga-hit")); +const mgaHandle = /** @type {SVGCircleElement} */ (document.querySelector("#mga-handle")); +/** @type {HTMLButtonElement} */ +const restartBtn = $("#restart-conversation"); +/** @type {HTMLElement} */ +const restartHint = $("#restart-hint"); +const settingsForm = /** @type {HTMLFormElement} */ (settingsModal.querySelector("form")); + +/** @type {AppState} */ +let currentState = "idle"; +let settings = loadSettings(); + +// ── Connection target ──────────────────────────────────────────────────────── +// Three modes, decided by the deploy via /api/config: +// • SPEECH_TO_SPEECH_URL set -> direct mode pinned by the deploy: the browser +// connects straight to that URL, shown read-only in Settings. Overrides the +// load balancer entirely. +// • LOAD_BALANCER_URL set -> original flow: POST the same-origin /api/session +// proxy (the server forwards to the LB; the LB address is never sent here). +// • neither (allowDirect) -> the user sets a speech-to-speech server URL and +// the browser connects to it directly (no load balancer, no /session). +let lbMode = false; +let reasoningType = "PROXY"; +// Fail open: direct entry is allowed unless /api/config reports an LB URL. This +// way a missing/unreachable config (e.g. static hosting) leaves the field +// usable rather than locked. +let allowDirect = true; +// Deploy-pinned s2s URL (SPEECH_TO_SPEECH_URL). Non-empty -> locked direct +// mode: the field displays it read-only and the saved user URL is untouched. +let pinnedUrl = ""; +// Deploy-pinned primary prompt (S2S_PRIMARY_PROMPT). Non-empty -> locked prompt. +let pinnedPrompt = ""; +let pinnedPromptSource = ""; + +// ── Tool state ────────────────────────────────────────────────────────────── +let toolsEnabled = loadTools(); +// Whether the server holds a Serper key (learned from /api/config on load). +let serverSearchKey = false; +// A user-supplied key (fallback when the deploy has none). localStorage only. +let userSearchKey = localStorage.getItem(STORAGE_KEYS.searchKey) || ""; +let searchMcpEnabled = false; +let lastUserQuery = ""; +/** @type {{ tool: import("./ws/s2s-ws-client.js").ToolDef, score: number }[]} */ +let rankedAgentTools = []; +let liveInstructionsSnapshot = ""; +let liveToolPolicySnapshot = ""; +/** @type {Record} */ +let mcpToolCallState = Object.create(null); +const pageParams = new URLSearchParams(window.location.search); +const voceroUserPhoneNumber = (pageParams.get("phoneNumber") || "").trim(); +/** @type {Record | null} */ +let voceroUserProfile = null; +let voceroUserProfileLoaded = false; +const AUTO_MCP_MIN_SCORE = 20; +const AUTO_MCP_MIN_SCORE_WITH_LEAD = 12; +const AUTO_MCP_MIN_LEAD = 4; +const AUTO_MCP_RELAXED_REPORT_SCORE = 15; +const AUTO_MCP_FAST_DELAY_MS = 150; +const AUTO_MCP_FALLBACK_DELAY_MS = 1200; +let autoMcpInFlight = false; +let lastAutoMcpTurnKey = ""; +let pendingAutoMcpTimer = 0; +let lastDeclaredAssistantToolKey = ""; +const processedReasoningTurns = new Set(); + +/** + * @param {string} hypothesisId + * @param {string} location + * @param {string} msg + * @param {Record} [data] + */ +function reportDebug(hypothesisId, location, msg, data = {}) { + void hypothesisId; + void location; + void msg; + void data; +} + +/** Search is usable if the server has a key or the user supplied one. */ +function searchAvailable() { + return serverSearchKey || !!userSearchKey; +} + +/** Tool definitions for the currently-enabled (and usable) tools. */ +function activeToolDefs() { + const defs = []; + if (toolsEnabled.web_search && searchAvailable()) defs.push(TOOL_DEFS.web_search); + return defs.concat(agentToolDefs); +} + +/** @returns {string} */ +function voceroUserContextText() { + if (!voceroUserProfileLoaded || !voceroUserProfile || !voceroUserPhoneNumber) return ""; + let serialized = JSON.stringify(voceroUserProfile); + if (serialized.length > 1600) serialized = `${serialized.slice(0, 1600)}...`; + return `${VOCERO_USER_CONTEXT_HINT}${serialized}. Use this as trusted caller context when relevant.`; +} + +/** + * @param {Record | null} profile + * @returns {string} + */ +function extractVoceroUserName(profile) { + if (!profile) return ""; + const directCandidates = [ + profile.name, + profile.fullName, + profile.full_name, + profile.displayName, + profile.display_name, + profile.username, + ]; + for (const value of directCandidates) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + const firstName = typeof profile.firstName === "string" ? profile.firstName.trim() : ""; + const lastName = typeof profile.lastName === "string" ? profile.lastName.trim() : ""; + if (firstName || lastName) return `${firstName} ${lastName}`.trim(); + const nestedKeys = ["user", "customer", "profile", "contact"]; + for (const key of nestedKeys) { + const nested = profile[key]; + if (nested && typeof nested === "object" && !Array.isArray(nested)) { + const nestedName = extractVoceroUserName(/** @type {Record} */ (nested)); + if (nestedName) return nestedName; + } + } + return ""; +} + +function syncVoceroHeaderUser() { + const name = extractVoceroUserName(voceroUserProfile); + identUserName.hidden = !name; + identUserName.textContent = name ? `Utente: ${name}` : ""; +} + +/** + * @param {import("./ws/s2s-ws-client.js").ToolDef[]} tools + * @returns {string} + */ +function buildMcpToolCatalogSummary(tools) { + return tools + .map((tool) => { + const required = requiredToolParams(tool.parameters); + const requiredText = required.length ? ` required: ${required.join(", ")}` : " required: none"; + const desc = (tool.description || "No description provided.") + .replace(/\s+/g, " ") + .trim(); + return `${tool.name} - ${desc}.${requiredText}.`; + }) + .join(" "); +} + +/** Instructions used by the reasoning engine. */ +function effectiveReasoningInstructions() { + const base = pinnedPrompt || settings.instructions; + let instructions = base; + if (activeToolDefs().length) instructions += TOOL_USE_HINT; + if (searchMcpEnabled && agentToolDefs.length) { + instructions += MCP_SEARCH_HINT; + instructions += ` Available MCP tools: ${buildMcpToolCatalogSummary(agentToolDefs)}`; + } + const suggested = topSuggestedAgentTools(3); + if (searchMcpEnabled && suggested.length) { + const lines = suggested + .map((entry, index) => `${index + 1}. ${entry.tool.name}: ${entry.tool.description || "No description provided."}`) + .join(" "); + instructions += ` Current user request: "${lastUserQuery}". Client-side ranking hint only: ${lines}. Use this hint only as a secondary signal after reviewing the full MCP catalog.`; + } + const userContext = voceroUserContextText(); + if (userContext) instructions += userContext; + return instructions; +} + +/** Instructions pushed to the realtime ASR/TTS backend. */ +function effectiveInstructions() { + const instructions = effectiveReasoningInstructions(); + return reasoningType === "OPENAI" ? `${instructions}${OPENAI_RENDER_HINT}` : instructions; +} + +/** + * @param {string} answer + * @returns {string} + */ +function buildRenderOnlyPrompt(answer) { + const clean = String(answer || "").trim(); + return clean ? `${OPENAI_RENDER_MARKER}\n${clean}` : ""; +} + +/** + * @returns {string} + */ +function preferredResponseLanguage() { + const lang = (navigator.language || document.documentElement.lang || "").trim(); + return lang || ""; +} + +/** + * @param {string} transcript + * @returns {Promise<{ answer: string, log_only: boolean, raw: any }>} + */ +async function fetchOpenAiReasoningAnswer(transcript) { + const res = await fetch("api/reasoning", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + transcript, + instructions: effectiveReasoningInstructions(), + language: preferredResponseLanguage(), + user_context: voceroUserContextText(), + phoneNumber: voceroUserPhoneNumber || null, + }), + }); + if (!res.ok) { + let detail = String(res.status); + try { + const json = await res.json(); + if (json?.detail) detail = String(json.detail); + } catch {} + throw new Error(`reasoning error (${detail})`); + } + const json = await res.json(); + const answer = typeof json?.answer === "string" ? json.answer.trim() : ""; + const log_only = Boolean(json && json.log_only === true); + // Log-only mode: può tornare answer="" — non è un errore. + if (!answer && !log_only) throw new Error("reasoning returned no answer"); + return { answer, log_only, raw: json }; +} + +/** + * @param {string} transcript + * @param {string} itemId + */ +async function maybeRunOpenAiReasoning(transcript, itemId) { + if (!["OPENAI", "NODE_RED"].includes(reasoningType)) return; + const text = transcript.trim(); + const turnKey = itemId || text; + if (!text || !turnKey || processedReasoningTurns.has(turnKey)) return; + processedReasoningTurns.add(turnKey); + if (!client || !LIVE_STATES.has(currentState)) return; + clearPendingAutoMcpFallback(); + reportDebug("R", "main.js:maybeRunOpenAiReasoning", "starting external reasoning turn", { + itemId, + transcriptLength: text.length, + currentState, + }); + let result; + try { + result = await fetchOpenAiReasoningAnswer(text); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[Vocero REASONING] error itemId=${JSON.stringify(itemId)} error=${msg}`); + reportDebug("R", "main.js:maybeRunOpenAiReasoning", "external reasoning failed", { + itemId, + error: msg, + }); + return; + } + // ── LOG-ONLY MODE (FASE 1) ───────────────────────────────────────────── + // Node-RED si limita a loggare e torna log_only=true. + // NON cancelliamo la risposta backend, NON iniettiamo testo, NON parliamo. + // Il flusso resta COMPLETAMENTE a carico del backend S2S nativo. + if (result && result.log_only) { + reportDebug("R", "main.js:maybeRunOpenAiReasoning", "external reasoning in LOG_ONLY mode — pass through to native S2S backend", { + itemId, + reasoningType, + intent: (result.raw && result.raw.intent) || "log_only", + }); + console.debug( + `[Vocero REASONING LOG_ONLY] mode=log_only engine=${reasoningType} ` + + `transcript=${JSON.stringify(text.slice(0, 160))} ` + + `intent=${JSON.stringify((result.raw && result.raw.intent) || "log_only")}` + ); + return; + } + // ── MODE STANDARD (fasi successive) ──────────────────────────────────── + // Il reasoning esterno vince: annulla la risposta nativa, inserisci il + // testo dell'assistente e parla via SpeechSynthesis. + const answer = (result && result.answer) || ""; + if (!answer) { + console.warn(`[Vocero REASONING] empty non-log-only answer itemId=${JSON.stringify(itemId)}`); + return; + } + if (typeof client.cancelActiveResponse === "function") { + client.cancelActiveResponse(); + } else if (typeof client.suppressAssistantAudio === "function") { + client.suppressAssistantAudio(); + } + reportDebug("R", "main.js:maybeRunOpenAiReasoning", "received external reasoning answer", { + itemId, + answerLength: answer.length, + }); + if (typeof client.appendAssistantText === "function") { + client.appendAssistantText(answer); + } else { + const renderPrompt = buildRenderOnlyPrompt(answer); + if (renderPrompt) { + client.sendUserText(renderPrompt); + client.requestResponse(); + } + } + try { + if ("speechSynthesis" in window) { + const utter = new SpeechSynthesisUtterance(answer); + const want = (preferredResponseLanguage() || "en").slice(0, 2).toLowerCase(); + const voices = window.speechSynthesis.getVoices + ? window.speechSynthesis.getVoices() + : []; + const match = Array.isArray(voices) && voices.length > 0 + ? (voices.find(v => (v.lang || "").slice(0, 2).toLowerCase() === want) || voices[0]) + : null; + if (match) utter.voice = match; + if (want) utter.lang = want; + utter.rate = 1.0; + utter.pitch = 1.0; + window.speechSynthesis.cancel(); + window.speechSynthesis.speak(utter); + } + } catch (ttsErr) { + console.warn("[Vocero REASONING] speechSynthesis failed:", ttsErr); + } +} + +/** @param {string} instructions */ +function pushLiveInstructions(instructions) { + if (!client || !LIVE_STATES.has(currentState)) return; + if (instructions === liveInstructionsSnapshot) return; + liveInstructionsSnapshot = instructions; + // #region debug-point C:push-live-instructions + reportDebug("C", "main.js:pushLiveInstructions", "pushing computed instructions to live session", { + instructionLength: instructions.length, + lastUserQuery, + suggestedTools: topSuggestedAgentTools(3).map((entry) => entry.tool.name), + }); + // #endregion + client.updateSession({ instructions }); +} + +function pushLiveToolPolicy() { + if (!client || !LIVE_STATES.has(currentState)) return; + const tools = activeToolDefs(); + const toolChoice = tools.length ? "auto" : "none"; + const snapshot = JSON.stringify({ toolChoice, toolNames: tools.map((tool) => tool.name) }); + if (snapshot === liveToolPolicySnapshot) return; + liveToolPolicySnapshot = snapshot; + // #region debug-point B:push-live-tool-policy + reportDebug("B", "main.js:pushLiveToolPolicy", "pushing live tool policy", { + toolChoice, + toolNames: tools.map((tool) => tool.name), + lastUserQuery, + searchMcpEnabled, + }); + // #endregion + client.setTools(tools, toolChoice); +} + +function clearPendingAutoMcpFallback() { + if (!pendingAutoMcpTimer) return; + window.clearTimeout(pendingAutoMcpTimer); + pendingAutoMcpTimer = 0; +} + +/** + * @param {string} text + * @returns {import("./ws/s2s-ws-client.js").ToolDef | undefined} + */ +function extractDeclaredAssistantTool(text) { + const match = text.match(/<\s*calls?\s+tool\s*:\s*([a-z0-9_.-]+)\s*>/i); + if (!match?.[1]) return undefined; + const toolName = match[1].trim(); + return findAgentToolDef(toolName); +} + +/** + * @param {string} text + * @param {string} responseId + */ +function maybeRunDeclaredAssistantTool(text, responseId = "") { + if (!client || !LIVE_STATES.has(currentState) || autoMcpInFlight) return; + const tool = extractDeclaredAssistantTool(text); + if (!tool) return; + const dedupeBase = responseId || normalizeMcpSearchText(text); + const dedupeKey = `${dedupeBase}::${tool.name}`; + if (dedupeKey === lastDeclaredAssistantToolKey) return; + lastDeclaredAssistantToolKey = dedupeKey; + clearPendingAutoMcpFallback(); + autoMcpInFlight = true; + console.debug( + `[Vocero MCP DECLARED] trigger responseId=${JSON.stringify(responseId)} tool=${tool.name} ` + + `query=${JSON.stringify(lastUserQuery)}` + ); + client.suppressAssistantAudio?.(); + chat.onToolCall(tool.name); + void callAgentToolFromPanel(tool.name, true, lastUserQuery).finally(() => { + autoMcpInFlight = false; + }); +} + +/** Push the active tool set to a live session so toggles apply mid-call. */ +function pushToolsToSession() { + if (!client || !LIVE_STATES.has(currentState)) return; + const tools = activeToolDefs(); + // #region debug-point B:push-tools-session + reportDebug("B", "main.js:pushToolsToSession", "pushing tools to live session", { + state: currentState, + toolNames: tools.map((tool) => tool.name), + toolCount: tools.length, + searchMcpEnabled, + }); + // #endregion + liveToolPolicySnapshot = ""; + client.setTools(tools, tools.length ? "auto" : "none"); + // The hidden tool-use hint depends on whether any tool is active, so refresh + // instructions alongside the tool set. + const instructions = effectiveInstructions(); + // #region debug-point C:push-instructions-session + reportDebug("C", "main.js:pushToolsToSession", "updating live session instructions", { + instructionLength: instructions.length, + hasSuggestedTools: topSuggestedAgentTools(3).length > 0, + lastUserQuery, + }); + // #endregion + pushLiveInstructions(instructions); +} + +// ── Chat view ─────────────────────────────────────────────────────────────── +// Owns the history panel, the ephemeral bubbles, and all transcript/tool +// streaming state. The client's events are forwarded to its on* methods. +const chat = new ChatView(); + +// ── Account / limiter ───────────────────────────────────────────────────── +// Login chip + daily-limit modal (inert unless the deploy is in LB mode). The +// server meters conversation time; the client just heartbeats a live session +// and tears down when the server reports the budget is spent. +const account = new Account(); +let limiterOn = false; +let heartbeatTimer = 0; +let trackedSessionId = ""; +let trackedTier = ""; +// The waiting-queue ticket id while we're in line (else ""). Used to leave the +// queue on teardown / tab-close so we don't hold a phantom place. +let queuedTicketId = ""; + +// ── Idle auto-close (30s of silence, never during AI speech) ───────────── +// The user asked the voice assistant to go to sleep silently after 30s of no +// activity. "Activity" is any meaningful event on either side: the user +// started speaking (VAD), the assistant started speaking (TTS), a partial or +// final transcript arrived, a tool call was requested or returned, or the +// client moved out of a transient pre-live state (connecting / queue / +// your-turn). Once the session is live, every tick of activity resets the +// countdown back to 30s; if 30s expire without a single event we close the +// socket, refund any reserved budget, and drop back to the idle orb so the +// microphone stops listening and the daily budget isn't eaten by a forgotten +// tab. The countdown is intentionally disabled in the pre-live states (we +// don't want to close while the user is still waiting in queue) and during +// `ai-speaking` — if the *system* is still talking we NEVER close the +// session mid-reply, even on very long TTS turns (the TTS engine itself will +// fire transcript/response-finished events that re-arm the timer afterward). +const IDLE_TIMEOUT_MS = 30 * 1000; +let idleCloseTimer = 0; +let idleCloseLastActivityAtMs = 0; +/** @type {Set} - States where a live session is open and the user could be silent. `ai-speaking` is intentionally excluded so we never kill a reply mid-TTS. */ +const IDLE_COUNT_STATES = new Set(["listening", "user-speaking", "processing"]); + +function _kickIdleTimer(reason = "activity") { + idleCloseLastActivityAtMs = Date.now(); + if (!IDLE_COUNT_STATES.has(currentState)) { + if (idleCloseTimer) { clearTimeout(idleCloseTimer); idleCloseTimer = 0; } + return; + } + if (idleCloseTimer) clearTimeout(idleCloseTimer); + idleCloseTimer = window.setTimeout(() => { + const staleMs = Date.now() - idleCloseLastActivityAtMs; + if (staleMs + 500 < IDLE_TIMEOUT_MS) { + // Race with a very recent kick: re-arm rather than close. + if (DEBUG) console.debug(`[IDLE] fire skipped (staleMs=${staleMs} < ${IDLE_TIMEOUT_MS}), re-arming`); + _kickIdleTimer("re-arm"); + return; + } + console.info(`[IDLE] ⏰ Closing session after ${(staleMs / 1000).toFixed(1)}s of inactivity (timeout=${IDLE_TIMEOUT_MS / 1000}s) — reason=${reason}`); + idleCloseTimer = 0; + // Friendly caption flash right before teardown moves us back to the idle + // copy (so the user understands *why* the call ended). + setCaption("Chiuso per inattività", "muted"); + void (async () => { + try { + await teardown(); + } catch (err) { + console.warn("[IDLE] teardown failed during auto-close:", err && err instanceof Error ? err.message : err); + } + // teardown() calls setState("idle") which restores the normal idle + // caption. Leave a visible breadcrumb on the subcaption briefly so the + // user can tell what happened instead of wondering why the orb reset. + circleSubcaption.textContent = "30 sec sil."; + circleSubcaption.hidden = false; + window.setTimeout(() => { circleSubcaption.hidden = true; }, 8000); + })(); + }, IDLE_TIMEOUT_MS); + if (DEBUG) { + console.debug(`[IDLE] 🔄 reset timer (${IDLE_TIMEOUT_MS / 1000}s) reason=${reason} state=${currentState}`); + } +} + +function _cancelIdleTimer() { + if (idleCloseTimer) { clearTimeout(idleCloseTimer); idleCloseTimer = 0; } + idleCloseLastActivityAtMs = 0; + if (DEBUG) console.debug(`[IDLE] ⛔ cancelled timer state=${currentState}`); +} + +/** @type {S2sWsRealtimeClient | null} */ +let client = null; +/** @type {MediaStream | null} */ +let micStream = null; +let micMuted = false; + +/** @param {AppState} next */ +function setState(next) { + currentState = next; + const view = STATE_VIEWS[next]; + circleBtn.disabled = view.disabled; + circleBtn.className = `circle ${STATE_CLASS[next]}`; + if (next !== "error") setCaption(view.caption); + + const live = LIVE_STATES.has(next); + orbWrap.classList.toggle("live", live); + micBtn.setAttribute("aria-hidden", live ? "false" : "true"); + stopBtn.setAttribute("aria-hidden", live ? "false" : "true"); + micBtn.tabIndex = live ? 0 : -1; + stopBtn.tabIndex = live ? 0 : -1; + + // Queue affordances: "Leave queue" whenever we're in line; "Join now" only once + // it's our turn (a slot is held for us). Both live under #queue-actions. + const yourTurn = next === "your-turn"; + const inLine = next === "queued" || yourTurn; + queueActions.hidden = !inLine; + joinQueueBtn.hidden = !yourTurn; + joinQueueBtn.tabIndex = yourTurn ? 0 : -1; + leaveQueueBtn.hidden = !inLine; + leaveQueueBtn.tabIndex = inLine ? 0 : -1; + if (!yourTurn) stopJoinCountdown(); + + // Warm reassurance under the terse position, only while waiting in line. + if (next === "queued") { + circleSubcaption.textContent = + "Sorry, we overhugged! 🤗 Every slot is busy, so we saved you a spot. Hang tight, you're moving up."; + circleSubcaption.hidden = false; + } else { + circleSubcaption.hidden = true; + } + + // Wake word: armed only when idle/error (no live mic contention with the + // realtime session), and disarmed the moment we move into any other state. + if (next === "idle" || next === "error") { + startWakeWordListener(); + } else { + stopWakeWordListener(); + } + + // Idle auto-close: arm the countdown whenever we land in a live conversing + // state (user/assistant turn, processing), and cancel it whenever we leave + // those states (idle, queue, error, teardown). Moving *to* a live state is + // itself counted as activity so the 10s window starts fresh. + if (IDLE_COUNT_STATES.has(next)) { + _kickIdleTimer(`state→${next}`); + } else { + _cancelIdleTimer(); + } + + updateRestartAvailability(); +} + +function updateRestartAvailability() { + // Restart works from any settled state — it tears down a live call (if any) + // and reconnects with the current settings. Only block while mid-connect or + // while waiting in the queue (restarting from there would just re-queue). + restartBtn.disabled = + currentState === "connecting" || currentState === "queued" || currentState === "your-turn"; + restartHint.hidden = false; + restartHint.textContent = LIVE_STATES.has(currentState) + ? "Reconnects now with the settings above." + : "Starts a conversation with the settings above."; +} + +/** + * @param {string} text + * @param {"" | "error" | "muted"} [kind] + */ +function setCaption(text, kind = "") { + const trimmed = text.trim(); + circleCaption.textContent = trimmed; + circleCaption.className = `circle-caption${kind ? ` ${kind}` : ""}${trimmed ? "" : " empty"}`; +} + +function openSettings() { + syncConnectionUi(); + inputVoice.value = settings.voice; + inputInstructions.value = pinnedPrompt || settings.instructions; + inputInstructions.readOnly = !!pinnedPrompt; + primaryPromptMeta.hidden = !pinnedPrompt; + primaryPromptMeta.textContent = pinnedPrompt + ? pinnedPromptSource + ? `Primary prompt from ${pinnedPromptSource}` + : "Primary prompt pinned by this deployment." + : ""; + syncGateUi(); + updateRestartAvailability(); + settingsModal.showModal(); +} + +/** dB position (clamped to the slider axis) as a 0..1 fraction of the track. + * @param {number} db */ +function dbToFraction(db) { + const clamped = Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, db)); + return (clamped - GATE_OFF_DB) / (GATE_MAX_DB - GATE_OFF_DB); +} + +/** @param {number} f @returns {number} dB at a 0..1 position on the gate axis. */ +function fractionToDb(f) { + const clamped = Math.min(1, Math.max(0, f)); + return Math.round(GATE_OFF_DB + clamped * (GATE_MAX_DB - GATE_OFF_DB)); +} + +// ── Radial gate arc (around the mic button, live during a call) ───────────── +// A 270° arc with the gap facing the orb (right). Fraction 0 (=Off) sits at the +// bottom-ish start; 1 (=max) at the top-ish end. The level fill and the +// threshold handle ride this same axis, mirroring the Settings widget. +const ARC_R = 40; +// A ~200° arc centred on the left (180°) so the wide gap faces the orb (right). +const ARC_SPAN_DEG = 200; +const ARC_START_DEG = 180 - ARC_SPAN_DEG / 2; // lower-left start; Off end + +/** Point at fraction f (0..1) and radius r, in the 0..100 viewBox. + * @param {number} f @param {number} [r] */ +function arcPoint(f, r = ARC_R) { + const deg = ARC_START_DEG + f * ARC_SPAN_DEG; + const rad = (deg * Math.PI) / 180; + return { x: 50 + r * Math.cos(rad), y: 50 + r * Math.sin(rad) }; +} + +/** SVG path `d` for the full 0..1 arc (clockwise). */ +function fullArcD() { + const a = arcPoint(0); + const b = arcPoint(1); + const largeArc = ARC_SPAN_DEG > 180 ? 1 : 0; + return `M ${a.x} ${a.y} A ${ARC_R} ${ARC_R} 0 ${largeArc} 1 ${b.x} ${b.y}`; +} + +/** One-time geometry: track, fill (dash-revealed) and the transparent hit band. */ +function initGateArc() { + const d = fullArcD(); + mgaTrack.setAttribute("d", d); + mgaFill.setAttribute("d", d); + mgaHit.setAttribute("d", d); + // pathLength 100 lets us reveal the fill by fraction via dashoffset. + mgaFill.setAttribute("pathLength", "100"); + mgaFill.style.strokeDasharray = "100 100"; + mgaFill.style.strokeDashoffset = "100"; // empty until levels arrive + renderGateHandle(); +} + +/** Place the threshold bead on the arc at the stored threshold; flag off state. */ +function renderGateHandle() { + const off = settings.noiseGate <= GATE_OFF_DB; + const p = arcPoint(dbToFraction(settings.noiseGate)); + mgaHandle.setAttribute("cx", String(p.x)); + mgaHandle.setAttribute("cy", String(p.y)); + micGate.classList.toggle("gate-off", off); +} + +/** Paint a 0..1 live level onto the arc fill (and the Settings meter if open). + * Brightens the tick when the level crosses the threshold — i.e. the gate is + * actually open — but only when gating is enabled. + * @param {number} rms */ +function paintInputLevel(rms) { + const db = rms > 0 ? 20 * Math.log10(rms) : GATE_OFF_DB; + const f = dbToFraction(db); + mgaFill.style.strokeDashoffset = String(100 * (1 - f)); + if (settingsModal.open) gateMeterFill.style.width = `${f * 100}%`; + const enabled = settings.noiseGate > GATE_OFF_DB; + micGate.classList.toggle("gate-open", enabled && f >= dbToFraction(settings.noiseGate)); +} + +/** The single place that commits a new gate threshold: updates both controls, + * persists, and applies live to the running session. + * @param {number} db */ +function setGateThreshold(db) { + settings.noiseGate = Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, Math.round(db))); + const off = settings.noiseGate <= GATE_OFF_DB; + inputNoiseGate.value = String(settings.noiseGate); + gateValue.textContent = off ? "Off" : `${settings.noiseGate} dB`; + renderGateHandle(); + localStorage.setItem(STORAGE_KEYS.noiseGate, String(settings.noiseGate)); + if (client && LIVE_STATES.has(currentState)) { + client.setNoiseGate(gateParams(settings.noiseGate)); + } +} + +/** Reflect the stored gate threshold into the slider, label and arc handle. */ +function syncGateUi() { + inputNoiseGate.value = String(settings.noiseGate); + const off = settings.noiseGate <= GATE_OFF_DB; + gateValue.textContent = off ? "Off" : `${settings.noiseGate} dB`; + renderGateHandle(); +} + +// Drag along the arc band to set the threshold (a tap on the glyph still mutes). +let gateDragging = false; +/** @param {PointerEvent} e */ +function gatePointerToDb(e) { + const rect = mgaArc.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + let deg = (Math.atan2(e.clientY - cy, e.clientX - cx) * 180) / Math.PI; + if (deg < 0) deg += 360; + // Map the on-arc angle to a fraction; angles in the right-side gap fall + // outside [0,1] and fractionToDb clamps them to the nearest end (just-below + // start -> Off, just-past end -> max). + const f = (deg - ARC_START_DEG) / ARC_SPAN_DEG; + return fractionToDb(f); +} +mgaHit.addEventListener("pointerdown", (e) => { + gateDragging = true; + mgaHit.setPointerCapture(e.pointerId); + setGateThreshold(gatePointerToDb(e)); +}); +mgaHit.addEventListener("pointermove", (e) => { + if (gateDragging) setGateThreshold(gatePointerToDb(e)); +}); +const endGateDrag = (/** @type {PointerEvent} */ e) => { + if (!gateDragging) return; + gateDragging = false; + try { mgaHit.releasePointerCapture(e.pointerId); } catch {} +}; +mgaHit.addEventListener("pointerup", endGateDrag); +mgaHit.addEventListener("pointercancel", endGateDrag); + +settingsBtn.addEventListener("click", openSettings); + +// About panel: native , Esc closes for free; also close on the X and +// on a click in the backdrop (a click whose target is the dialog itself). +aboutBtn?.addEventListener("click", () => aboutModal.showModal()); +// Mobile twin of the (i), living in the right-hand control cluster. +$("#about-btn-m").addEventListener("click", () => aboutModal.showModal()); +aboutClose.addEventListener("click", () => aboutModal.close()); +aboutModal.addEventListener("click", (e) => { + if (e.target === aboutModal) aboutModal.close(); +}); + +// ── Tools panel ─────────────────────────────────────────────────────────── + +/** Reflect the current tool state into the panel controls. */ +function syncToolsUi() { + const openToolNames = new Set( + Array.from(agentToolsList.querySelectorAll("details[open][data-mcp-tool-item]")) + .map((item) => item.getAttribute("data-mcp-tool-item") || "") + .filter(Boolean), + ); + const avail = searchAvailable(); + toolWebSwitch.checked = toolsEnabled.web_search && avail; + toolWebSwitch.disabled = !avail; + toolWebRow.classList.toggle("disabled", !avail); + toolCamSwitch.closest("div")?.setAttribute("hidden", ""); + toolCamHint.setAttribute("hidden", ""); + camPip.setAttribute("hidden", ""); + + if (serverSearchKey) { + // Key lives server-side: show it as configured, never expose it. + searchKeyInput.value = ""; + searchKeyInput.placeholder = "•••••••• · provided by the server"; + searchKeyInput.disabled = true; + toolWebHint.textContent = "Ready. The search key is held server-side and never sent to your browser."; + } else { + searchKeyInput.disabled = false; + searchKeyInput.value = userSearchKey; + searchKeyInput.placeholder = "Paste a Serper key to enable web search"; + toolWebHint.textContent = userSearchKey + ? "Using your key — stored in this browser only." + : "No server key configured. Add your own Serper key to enable web search."; + } + + const count = agentToolDefs.length; + if (!count) { + agentToolsHint.textContent = "No MCP tools loaded from the remote agent profile."; + } else if (searchMcpEnabled && lastUserQuery && rankedAgentTools.length) { + agentToolsHint.textContent = `${count} MCP tools loaded. Top matches for "${truncateError(lastUserQuery)}".`; + } else if (searchMcpEnabled && lastUserQuery) { + agentToolsHint.textContent = `${count} MCP tools loaded. No description match found for "${truncateError(lastUserQuery)}".`; + } else { + agentToolsHint.textContent = `${count} MCP tool${count === 1 ? "" : "s"} loaded from the remote agent profile.`; + } + agentToolsList.innerHTML = renderAgentToolsHtml(); + for (const toolName of openToolNames) { + const item = agentToolsList.querySelector(`details[data-mcp-tool-item="${CSS.escape(toolName)}"]`); + if (item instanceof HTMLDetailsElement) item.open = true; + } +} + +/** @returns {string} */ +function renderAgentToolsHtml() { + if (!agentToolDefs.length) return '
No MCP tools available.
'; + const suggested = topSuggestedAgentTools(3); + const suggestedNames = new Set(suggested.map((entry) => entry.tool.name)); + let html = ""; + if (searchMcpEnabled && lastUserQuery) { + html += suggested.length + ? ` +
+
Suggested For Current Request
+
${escHtml(lastUserQuery)}
+
+ ` + : ` +
+ No MCP tool description matches the current request. +
+ `; + } + const orderedTools = suggested + .map((entry) => entry.tool) + .concat(agentToolDefs.filter((tool) => !suggestedNames.has(tool.name))); + html += orderedTools.map((tool) => { + const paramRows = describeToolParams(tool.parameters); + const toolState = getMcpToolCallState(tool.name); + const suggestedIndex = suggested.findIndex((entry) => entry.tool.name === tool.name); + const badge = suggestedIndex >= 0 + ? `Suggested #${suggestedIndex + 1}` + : 'MCP'; + return ` +
+ +
+ ${escHtml(tool.name)} + ${badge} +
+ ${escHtml(tool.description || "No description provided.")} +
+
+
Parameters
+ ${paramRows || '
No parameters.
'} +
Manual Call
+ +
+ + +
+ ${renderMcpToolCallResultHtml(tool.name)} +
+
+ `; + }).join(""); + return html; +} + +/** @param {number} limit */ +function topSuggestedAgentTools(limit) { + return rankedAgentTools.slice(0, limit); +} + +/** + * @param {string} query + * @param {{ partial?: boolean, itemId?: string }} [options] + */ +function updateMcpToolSuggestions(query, options = {}) { + lastUserQuery = query.trim(); + console.debug( + `[MCP RANK] updateMcpToolSuggestions ENTRY partial=${!!options.partial} itemId=${options.itemId || "-"} ` + + `query=${JSON.stringify(lastUserQuery)} searchMcpEnabled=${searchMcpEnabled}`, + ); + rankedAgentTools = rankAgentTools(lastUserQuery); + // #region debug-point A:ranked-mcp-suggestions + reportDebug("A", "main.js:updateMcpToolSuggestions", "ranked MCP tools for user query", { + partial: !!options.partial, + query: lastUserQuery, + ranked: rankedAgentTools.map((entry) => ({ name: entry.tool.name, score: entry.score })), + searchMcpEnabled, + }); + // #endregion + const rankedNames = rankedAgentTools.map((entry) => `${entry.tool.name}:${entry.score}`).join(", "); + console.debug( + `[MCP RANK] updateMcpToolSuggestions RESULTS partial=${!!options.partial} ` + + `matchedTools=${rankedAgentTools.length} ranked=${rankedNames || "none"}`, + ); + if (!options.partial && rankedAgentTools.length) { + const top3 = rankedAgentTools + .slice(0, 3) + .map((entry, index) => `${index + 1}:${entry.tool.name}:${entry.score}`) + .join(" | "); + console.debug(`[MCP RANK] ✦ FINAL top3 query=${JSON.stringify(lastUserQuery)} top3=${top3 || "none"}`); + } + syncToolsUi(); + if (client && LIVE_STATES.has(currentState)) { + const instructions = effectiveInstructions(); + if (options.partial && rankedAgentTools.length) { + pushLiveToolPolicy(); + pushLiveInstructions(instructions); + } else if (!options.partial) { + pushLiveToolPolicy(); + pushLiveInstructions(instructions); + maybeAutoRunSuggestedMcpTool(lastUserQuery, options.itemId || ""); + } + } +} + +/** + * @param {string} query + * @param {string} itemId + */ +function maybeAutoRunSuggestedMcpTool(query, itemId) { + if (!client || !LIVE_STATES.has(currentState) || !searchMcpEnabled || autoMcpInFlight) return; + const normalized = normalizeMcpSearchText(query); + const top = rankedAgentTools[0]; + if (!normalized || !top) return; + const second = rankedAgentTools[1]; + const topNameText = normalizeMcpSearchText(top.tool.name); + const topDescText = normalizeMcpSearchText(top.tool.description || ""); + const topRequiredParams = requiredToolParams(top.tool.parameters); + const relaxedDailyReportMatch = top.score >= AUTO_MCP_RELAXED_REPORT_SCORE && + !topRequiredParams.length && + ( + ((topNameText.includes("day") || topNameText.includes("daily") || topNameText.includes("today")) && + topNameText.includes("report")) || + ((topDescText.includes("day") || topDescText.includes("daily") || topDescText.includes("today")) && + topDescText.includes("report")) + ); + const strongByScore = top.score >= AUTO_MCP_MIN_SCORE; + const strongByLead = top.score >= AUTO_MCP_MIN_SCORE_WITH_LEAD && + (!second || (top.score - second.score) >= AUTO_MCP_MIN_LEAD); + const strongMatch = top.score >= AUTO_MCP_MIN_SCORE || + (top.score >= AUTO_MCP_MIN_SCORE_WITH_LEAD && (!second || (top.score - second.score) >= AUTO_MCP_MIN_LEAD)) || + relaxedDailyReportMatch; + if (!strongMatch) { + console.debug(`[Vocero MCP AUTO] skip weak match query=${JSON.stringify(query)} top=${top.tool.name}:${top.score}`); + return; + } + const turnKey = `${itemId || normalized}::${top.tool.name}`; + if (turnKey === lastAutoMcpTurnKey) return; + lastAutoMcpTurnKey = turnKey; + clearPendingAutoMcpFallback(); + const fastSafeMatch = !topRequiredParams.length && (top.score >= 40 || relaxedDailyReportMatch || strongByLead); + const autoDelayMs = fastSafeMatch ? AUTO_MCP_FAST_DELAY_MS : AUTO_MCP_FALLBACK_DELAY_MS; + reportDebug("A", "main.js:maybeAutoRunSuggestedMcpTool", "scheduled MCP fallback for matched user query", { + itemId, + query, + toolName: top.tool.name, + score: top.score, + nextScore: second?.score ?? null, + autoDelayMs, + }); + console.debug( + `[Vocero MCP AUTO] scheduled query=${JSON.stringify(query)} itemId=${JSON.stringify(itemId)} ` + + `tool=${top.tool.name} score=${top.score} reason=${strongByScore ? "strict" : strongByLead ? "lead" : "relaxed-report"} ` + + `delayMs=${autoDelayMs}` + ); + pendingAutoMcpTimer = window.setTimeout(() => { + pendingAutoMcpTimer = 0; + if (!client || !LIVE_STATES.has(currentState) || autoMcpInFlight) return; + autoMcpInFlight = true; + console.debug( + `[Vocero MCP AUTO] trigger query=${JSON.stringify(query)} itemId=${JSON.stringify(itemId)} ` + + `tool=${top.tool.name} score=${top.score}` + ); + client.suppressAssistantAudio?.(); + chat.onToolCall(top.tool.name); + void callAgentToolFromPanel(top.tool.name, true, query).finally(() => { + autoMcpInFlight = false; + }); + }, autoDelayMs); +} + +/** + * @param {string} query + * @returns {{ tool: import("./ws/s2s-ws-client.js").ToolDef, score: number }[]} + */ +function rankAgentTools(query) { + console.debug(`[MCP RANK] ════════════════════════════════════════`); + console.debug(`[MCP RANK] rankAgentTools START query=${JSON.stringify(query)} searchMcpEnabled=${searchMcpEnabled} agentToolDefsCount=${agentToolDefs.length}`); + const searchTexts = buildMcpSearchTexts(query); + const normalized = searchTexts[0] || ""; + if (!searchMcpEnabled || !normalized || !agentToolDefs.length) { + const reasonParts = []; + if (!searchMcpEnabled) reasonParts.push("searchMcpEnabled=false — da /api/config: MCP Aquachile feature OFF oppure S2S_TOOL_EXECUTION_MCP_URL non configurato"); + if (!normalized) reasonParts.push("query-normalized-empty"); + if (!agentToolDefs.length) reasonParts.push("agentToolDefs=0 — catalogo tool MCP vuoto: profilo agente non ha tools in agentProfile via Aquachile /api/agents/profile"); + console.debug( + `[MCP RANK] → EARLY EXIT reason="${reasonParts.join("; ")}" ` + + `searchMcpEnabled=${searchMcpEnabled} normalized=${!!normalized} agentToolDefs=${agentToolDefs.length}`, + ); + console.debug(`[MCP RANK] ════════════════════════════════════════ (empty result)`); + return []; + } + const tokens = [...new Set(searchTexts.flatMap((text) => text.split(" ").filter((token) => token.length >= 3)))]; + if (!tokens.length) { + console.debug( + `[MCP RANK] → EARLY EXIT reason="nessun token >= 3 chars dopo split" searchTexts=${JSON.stringify(searchTexts)}`, + ); + console.debug(`[MCP RANK] ════════════════════════════════════════ (empty result)`); + return []; + } + const dailyReportIntent = searchTexts.some((text) => + /\b(day|daily|today)\b/.test(text) && /\breport\b/.test(text), + ); + console.debug( + `[MCP RANK] searchTexts=${JSON.stringify(searchTexts)} ` + + `tokensCount=${tokens.length} tokens=${JSON.stringify(tokens)} ` + + `dailyReportIntent=${dailyReportIntent}`, + ); + const scored = agentToolDefs + .map((tool) => { + const nameText = normalizeMcpSearchText(tool.name); + const descText = normalizeMcpSearchText(tool.description || ""); + const paramText = normalizeMcpSearchText(extractToolParamSearchText(tool.parameters)); + const requiredParams = requiredToolParams(tool.parameters); + const breakdown = { + nameHitFull: 0, + descHitFull: 0, + paramHitFull: 0, + nameHitTokens: 0, + descHitTokens: 0, + paramHitTokens: 0, + allFoundBonus: 0, + noRequiredParamsBonus: 0, + dailyReportMatchBoost: 0, + dailyReportPenalty: 0, + }; + let score = 0; + for (const searchText of searchTexts) { + if (!searchText) continue; + if (descText.includes(searchText)) { + const add = searchText === normalized ? 20 : 16; + score += add; + breakdown.descHitFull += add; + } + if (nameText.includes(searchText)) { + const add = searchText === normalized ? 24 : 18; + score += add; + breakdown.nameHitFull += add; + } + if (paramText.includes(searchText)) { + const add = searchText === normalized ? 6 : 4; + score += add; + breakdown.paramHitFull += add; + } + } + const tokenMatchDetails = []; + for (const token of tokens) { + const onName = nameText.includes(token); + const onDesc = descText.includes(token); + const onParam = paramText.includes(token); + if (!onName && !onDesc && !onParam) continue; + const entry = { token }; + if (onName) { score += 8; breakdown.nameHitTokens += 8; entry.name = true; } + if (onDesc) { score += 5; breakdown.descHitTokens += 5; entry.desc = true; } + if (onParam) { score += 2; breakdown.paramHitTokens += 2; entry.param = true; } + tokenMatchDetails.push(entry); + } + const allFound = searchTexts.some((searchText) => + searchText.split(" ").filter((token) => token.length >= 3).every((token) => + nameText.includes(token) || descText.includes(token) || paramText.includes(token), + ), + ); + if (allFound) { score += 10; breakdown.allFoundBonus = 10; } + if (!requiredParams.length) { score += 3; breakdown.noRequiredParamsBonus = 3; } + if (dailyReportIntent) { + if ((nameText.includes("day report") || nameText.includes("daily report") || nameText.includes("today")) && + (descText.includes("day report") || descText.includes("daily report") || descText.includes("today"))) { + score += 24; + breakdown.dailyReportMatchBoost = 24; + } else if (requiredParams.length) { + score -= 10; + breakdown.dailyReportPenalty = -10; + } + } + console.debug( + `[MCP RANK] ▸ tool=${JSON.stringify(tool.name)} score=${score} ` + + `requiredParams=${requiredParams.length} allFound=${allFound}\n` + + `[MCP RANK] ↳ fullMatches: name=${breakdown.nameHitFull} desc=${breakdown.descHitFull} params=${breakdown.paramHitFull}\n` + + `[MCP RANK] ↳ tokenMatches(${tokenMatchDetails.length}): ${tokenMatchDetails.length ? JSON.stringify(tokenMatchDetails) : "-"}\n` + + `[MCP RANK] ↳ bonuses: allFound=${breakdown.allFoundBonus} noRequiredParams=${breakdown.noRequiredParamsBonus} ` + + `dailyBoost=${breakdown.dailyReportMatchBoost} dailyPenalty=${breakdown.dailyReportPenalty}\n` + + `[MCP RANK] ↳ nameTokens=${JSON.stringify(nameText.slice(0, 100))}\n` + + `[MCP RANK] ↳ descTokens=${JSON.stringify(descText.slice(0, 200))}\n` + + `[MCP RANK] ↳ paramTokens=${JSON.stringify(paramText.slice(0, 150))}`, + ); + return { tool, score }; + }); + const filtered = scored.filter((entry) => entry.score > 0); + const sorted = filtered.sort((a, b) => b.score - a.score || a.tool.name.localeCompare(b.tool.name)); + console.debug( + `[MCP RANK] DONE scoredCount=${scored.length} matchedCount=${filtered.length} ` + + `top5=${JSON.stringify(sorted.slice(0, 5).map((e) => ({ t: e.tool.name, s: e.score })))}`, + ); + console.debug(`[MCP RANK] ════════════════════════════════════════`); + return sorted; +} + +/** + * @param {string} text + * @returns {string} + */ +function translateMcpSearchTextToEnglish(text) { + const before = normalizeMcpSearchText(text); + let translated = before; + const appliedPhrases = []; + for (const [pattern, replacement] of MCP_SEARCH_TRANSLATION_PHRASES) { + const previous = translated; + translated = translated.replace(pattern, replacement); + if (translated !== previous) { + appliedPhrases.push({ pattern: String(pattern), to: replacement }); + } + } + const beforeTokens = translated; + const appliedTokens = []; + translated = translated + .split(" ") + .map((token) => { + const mapped = MCP_SEARCH_TRANSLATION_TOKENS[token]; + if (mapped && mapped !== token) { + appliedTokens.push({ from: token, to: mapped }); + return mapped; + } + return token; + }) + .join(" "); + translated = normalizeMcpSearchText(translated); + console.debug( + `[MCP RANK] translate input=${JSON.stringify(before)} phrasesApplied=${appliedPhrases.length} ` + + `tokensApplied=${appliedTokens.length} output=${JSON.stringify(translated)}` + + (appliedPhrases.length ? ` phrases=${JSON.stringify(appliedPhrases)}` : "") + + (appliedTokens.length ? ` tokens=${JSON.stringify(appliedTokens)}` : ""), + ); + return translated; +} + +/** + * @param {string} text + * @returns {string[]} + */ +function buildMcpSearchTexts(text) { + const normalized = normalizeMcpSearchText(text); + const translated = translateMcpSearchTextToEnglish(text); + const result = [...new Set([normalized, translated].filter(Boolean))]; + console.debug( + `[MCP RANK] buildSearchTexts raw=${JSON.stringify(text)} ` + + `normalized=${JSON.stringify(normalized)} translated=${JSON.stringify(translated)} ` + + `count=${result.length} final=${JSON.stringify(result)}`, + ); + return result; +} + +/** + * @param {string} text + * @returns {string} + */ +function normalizeMcpSearchText(text) { + return text + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +/** + * @param {any} schema + * @returns {string} + */ +function extractToolParamSearchText(schema) { + const props = schema && typeof schema === "object" && schema.properties && typeof schema.properties === "object" + ? schema.properties + : null; + if (!props) return ""; + /** @type {string[]} */ + const chunks = []; + for (const [name, raw] of Object.entries(props)) { + chunks.push(name); + if (raw && typeof raw === "object" && typeof raw.description === "string") { + chunks.push(raw.description); + } + } + return chunks.join(" "); +} + +/** + * @param {any} schema + * @returns {string} + */ +function describeToolParams(schema) { + const props = schema && typeof schema === "object" && schema.properties && typeof schema.properties === "object" + ? schema.properties + : null; + if (!props) return ""; + /** @type {string[]} */ + const rows = []; + const required = Array.isArray(schema.required) ? new Set(schema.required) : new Set(); + for (const [name, raw] of Object.entries(props)) { + const spec = raw && typeof raw === "object" ? raw : {}; + const type = paramTypeLabel(spec); + const def = "default" in spec ? ` · default: ${formatParamValue(spec.default)}` : ""; + const req = required.has(name) ? " required" : ""; + rows.push( + `
` + + `${escHtml(name)}` + + `${escHtml(type + req + def)}` + + `
`, + ); + } + return rows.join(""); +} + +/** + * @param {any} spec + * @returns {string} + */ +function paramTypeLabel(spec) { + if (Array.isArray(spec.anyOf)) { + /** @type {string[]} */ + const labels = []; + for (const item of spec.anyOf) { + labels.push((item && typeof item === "object" && typeof item.type === "string") ? item.type : "unknown"); + } + return labels.join(" | "); + } + if (typeof spec.type === "string") return spec.type; + return "unknown"; +} + +/** + * @param {unknown} value + * @returns {string} + */ +function formatParamValue(value) { + if (value === null) return "null"; + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +/** + * @param {any} schema + * @returns {Record} + */ +function schemaProperties(schema) { + return schema && typeof schema === "object" && schema.properties && typeof schema.properties === "object" + ? schema.properties + : {}; +} + +/** + * @param {any} schema + * @returns {string[]} + */ +function requiredToolParams(schema) { + if (!Array.isArray(schema?.required)) return []; + /** @type {string[]} */ + const required = []; + for (const name of schema.required) { + if (typeof name === "string") required.push(name); + } + return required; +} + +/** + * @param {any} spec + * @param {string} type + * @returns {boolean} + */ +function schemaSupportsType(spec, type) { + if (!spec || typeof spec !== "object") return false; + if (spec.type === type) return true; + if (Array.isArray(spec.anyOf)) { + for (const item of spec.anyOf) { + if (item && typeof item === "object" && item.type === type) return true; + } + } + return false; +} + +/** + * @param {any} spec + * @returns {boolean} + */ +function isStringArraySchema(spec) { + if (!spec || typeof spec !== "object") return false; + if (spec.type === "array") { + return !spec.items || spec.items.type === "string"; + } + if (Array.isArray(spec.anyOf)) { + for (const item of spec.anyOf) { + if (item && typeof item === "object" && item.type === "array" && (!item.items || item.items.type === "string")) { + return true; + } + } + } + return false; +} + +/** + * @param {string} name + * @returns {string[]} + */ +function paramAliases(name) { + const aliases = new Set([name, name.replace(/_/g, " "), name.replace(/_/g, "-")]); + if (/phone|mobile|whatsapp/i.test(name)) { + aliases.add("phone"); + aliases.add("phone number"); + aliases.add("mobile"); + aliases.add("mobile number"); + aliases.add("whatsapp"); + aliases.add("whatsapp number"); + aliases.add("number"); + } + return [...aliases].filter(Boolean); +} + +const DIGIT_WORDS = { + zero: "0", + oh: "0", + one: "1", + two: "2", + three: "3", + four: "4", + five: "5", + six: "6", + seven: "7", + eight: "8", + nine: "9", +}; + +/** + * @param {string} name + * @returns {boolean} + */ +function isPhoneLikeParam(name) { + return /phone|mobile|whatsapp/i.test(name); +} + +/** + * @param {string} text + * @returns {string} + */ +function normalizePhoneText(text) { + const trimmed = text.trim(); + if (!trimmed) return ""; + const hasLeadingPlus = /^\s*\+/.test(trimmed); + const compact = trimmed + .toLowerCase() + .replace(/[^a-z0-9+\s-]/g, " ") + .split(/\s+/) + .filter(Boolean); + /** @type {string[]} */ + const digits = []; + for (const token of compact) { + if (token === "+" && !digits.length && hasLeadingPlus) continue; + if (/^\d+$/.test(token)) { + digits.push(token); + continue; + } + if (Object.prototype.hasOwnProperty.call(DIGIT_WORDS, token)) { + digits.push(DIGIT_WORDS[/** @type {keyof typeof DIGIT_WORDS} */ (token)]); + } + } + if (!digits.length) return ""; + return `${hasLeadingPlus ? "+" : ""}${digits.join("")}`; +} + +/** + * @param {string} query + * @returns {string} + */ +function extractPhoneValue(query) { + const patterns = [ + /\b(?:my\s+)?(?:phone|mobile|whatsapp|number)\s*(?:number)?\s*(?:is|=|:)?\s*([^.!,;\n]+)/i, + /\bcall me at\s*([^.!,;\n]+)/i, + /\breach me at\s*([^.!,;\n]+)/i, + ]; + for (const pattern of patterns) { + const match = query.match(pattern); + const normalized = normalizePhoneText(match?.[1] || ""); + if (normalized) return normalized; + } + return normalizePhoneText(query); +} + +/** + * @param {string} query + * @param {string} name + * @returns {string} + */ +function inferStringParamValue(query, name) { + if (isPhoneLikeParam(name)) return extractPhoneValue(query); + return ""; +} + +/** + * @param {string} query + * @param {string[]} aliases + * @returns {string} + */ +function matchParamValueText(query, aliases) { + for (const alias of aliases) { + const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const patterns = [ + new RegExp(`\\b${escaped}\\b\\s*(?:=|:|is|to)?\\s*"([^"]+)"`, "i"), + new RegExp(`\\b${escaped}\\b\\s*(?:=|:|is|to)?\\s*'([^']+)'`, "i"), + new RegExp(`\\b${escaped}\\b\\s*(?:=|:|is|to)?\\s*([^,;\\n]+)`, "i"), + ]; + for (const pattern of patterns) { + const match = query.match(pattern); + if (match?.[1]) return match[1].trim(); + } + } + return ""; +} + +/** + * @param {string} query + * @param {any} schema + * @returns {Record} + */ +function inferToolArgsFromQuery(query, schema) { + const props = schemaProperties(schema); + /** @type {Record} */ + const inferred = {}; + for (const [name, raw] of Object.entries(props)) { + const spec = raw && typeof raw === "object" ? raw : {}; + const valueText = matchParamValueText(query, paramAliases(name)); + const fallbackText = !valueText && schemaSupportsType(spec, "string") + ? inferStringParamValue(query, name) + : ""; + const resolvedText = valueText || fallbackText; + if (!resolvedText) continue; + if (schemaSupportsType(spec, "number")) { + const num = Number(resolvedText); + if (!Number.isNaN(num)) inferred[name] = num; + continue; + } + if (schemaSupportsType(spec, "boolean")) { + const normalized = resolvedText.toLowerCase(); + if (["true", "yes", "1", "on"].includes(normalized)) inferred[name] = true; + else if (["false", "no", "0", "off"].includes(normalized)) inferred[name] = false; + continue; + } + if (isStringArraySchema(spec)) { + inferred[name] = resolvedText + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + continue; + } + if (schemaSupportsType(spec, "string")) { + inferred[name] = resolvedText; + } + } + return inferred; +} + +/** + * @param {Record} args + * @param {any} schema + * @returns {Record} + */ +function applyToolArgDefaults(args, schema) { + const props = schemaProperties(schema); + /** @type {Record} */ + const merged = { ...args }; + for (const [name, raw] of Object.entries(props)) { + const spec = raw && typeof raw === "object" ? raw : {}; + if (!(name in merged) && Object.prototype.hasOwnProperty.call(spec, "default")) { + merged[name] = spec.default; + } + } + return merged; +} + +/** + * @param {Record} args + * @param {any} schema + * @returns {string[]} + */ +function missingRequiredToolArgs(args, schema) { + return requiredToolParams(schema).filter((name) => { + const value = args[name]; + return value === undefined || value === null || value === ""; + }); +} + +/** + * @param {string} toolName + * @returns {{ argsText: string, status: "idle" | "running" | "success" | "error", resultText: string, lastAction: "call" | "send" }} + */ +function getMcpToolCallState(toolName) { + if (!mcpToolCallState[toolName]) { + mcpToolCallState[toolName] = { + argsText: "{}", + status: "idle", + resultText: "", + lastAction: "call", + }; + } + return mcpToolCallState[toolName]; +} + +/** + * @param {string} toolName + * @returns {import("./ws/s2s-ws-client.js").ToolDef | undefined} + */ +function findAgentToolDef(toolName) { + return agentToolDefs.find((tool) => tool.name === toolName); +} + +/** + * @param {string} toolName + * @returns {string} + */ +function renderMcpToolCallResultHtml(toolName) { + const state = getMcpToolCallState(toolName); + if (state.status === "idle" && !state.resultText) return ""; + const cls = state.status === "error" + ? "agent-tool-call-result error" + : state.status === "success" + ? "agent-tool-call-result success" + : "agent-tool-call-result running"; + const label = state.status === "running" + ? (state.lastAction === "send" ? "Calling tool and sending to conversation..." : "Calling tool...") + : state.status === "error" + ? "Call failed" + : (state.lastAction === "send" ? "Sent to conversation" : "Call result"); + return ` +
+
${escHtml(label)}
+
${escHtml(state.resultText || "")}
+
+ `; +} + +/** + * @param {string} toolName + * @param {boolean} [sendToConversation] + * @param {string} [sourceQuery] + * @returns {Promise} + */ +async function callAgentToolFromPanel(toolName, sendToConversation = false, sourceQuery = "") { + const tool = findAgentToolDef(toolName); + if (!tool) return; + const state = getMcpToolCallState(toolName); + state.lastAction = sendToConversation ? "send" : "call"; + let args = /** @type {Record} */ ({}); + try { + args = state.argsText.trim() ? JSON.parse(state.argsText) : {}; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + state.status = "error"; + state.resultText = `Invalid JSON arguments: ${msg}`; + syncToolsUi(); + console.warn(`[Vocero MCP PANEL] invalid args name=${toolName} error=${msg}`); + return; + } + if (!args || typeof args !== "object" || Array.isArray(args)) { + state.status = "error"; + state.resultText = "Arguments must be a JSON object."; + syncToolsUi(); + console.warn(`[Vocero MCP PANEL] invalid args shape name=${toolName}`); + return; + } + const inferredArgs = sourceQuery.trim() ? inferToolArgsFromQuery(sourceQuery, tool.parameters) : {}; + args = applyToolArgDefaults({ ...inferredArgs, ...args }, tool.parameters); + const missingArgs = missingRequiredToolArgs(args, tool.parameters); + if (missingArgs.length) { + state.status = "error"; + state.resultText = `Missing required arguments: ${missingArgs.join(", ")}`; + syncToolsUi(); + console.warn( + `[Vocero MCP PANEL] missing required args name=${toolName} missing=${missingArgs.join(",")} ` + + `sourceQuery=${JSON.stringify(sourceQuery)}` + ); + return; + } + state.status = "running"; + state.resultText = ""; + syncToolsUi(); + reportDebug("E", "main.js:callAgentToolFromPanel", "manual MCP tool call from tools panel", { + name: toolName, + args, + sendToConversation, + sourceQuery, + }); + console.debug( + `[Vocero MCP PANEL] call name=${toolName} sendToConversation=${sendToConversation} ` + + `sourceQuery=${JSON.stringify(sourceQuery)} inferred=${JSON.stringify(inferredArgs)} args=${JSON.stringify(args)}` + ); + try { + const execStartedAt = performance.now(); + const { rawOutput, speakableOutput } = await execRemoteMcpTool(toolName, args); + const output = rawOutput; + reportDebug("A", "main.js:callAgentToolFromPanel", "MCP tool execution finished", { + name: toolName, + sendToConversation, + outputLength: output.length, + speakableOutputLength: speakableOutput.length, + durationMs: Math.round(performance.now() - execStartedAt), + }); + if (sendToConversation) { + if (!client || !LIVE_STATES.has(currentState)) { + throw new Error("No live conversation is active."); + } + const promptBuildStartedAt = performance.now(); + const modelOutput = speakableOutput || summarizeMcpOutputForConversation(output); + const prompt = ( + sourceQuery.trim() + ? `User request: ${sourceQuery.trim()}\n\n` + : "" + ) + `Tool ${toolName} returned the following internal facts:\n\n${modelOutput}\n\n` + + "Answer the user naturally in their language. Do not read JSON keys, field names, or raw URLs aloud unless the user explicitly asks for them."; + reportDebug("A", "main.js:callAgentToolFromPanel", "built live prompt from MCP output", { + name: toolName, + outputLength: output.length, + modelOutputLength: modelOutput.length, + promptLength: prompt.length, + buildMs: Math.round(performance.now() - promptBuildStartedAt), + }); + const chatRenderStartedAt = performance.now(); + chat.onToolResult(toolName, JSON.stringify(args), output); + reportDebug("C", "main.js:callAgentToolFromPanel", "rendered MCP tool result in chat", { + name: toolName, + outputLength: output.length, + renderMs: Math.round(performance.now() - chatRenderStartedAt), + }); + chat.onTranscript({ + role: "user", + text: prompt, + partial: false, + itemId: `manual-mcp-${toolName}-${Date.now()}`, + }); + reportDebug("B", "main.js:callAgentToolFromPanel", "sending MCP prompt back to live conversation", { + name: toolName, + promptLength: prompt.length, + outputLength: output.length, + }); + client.sendUserText(prompt); + client.requestResponse(); + console.debug(`[Vocero MCP PANEL] sent to conversation name=${toolName} length=${prompt.length}`); + } + state.status = "success"; + state.resultText = output; + console.debug(`[Vocero MCP PANEL] result name=${toolName} output=${output.slice(0, 500)}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + state.status = "error"; + state.resultText = msg; + console.warn(`[Vocero MCP PANEL] error name=${toolName} error=${msg}`); + } + syncToolsUi(); +} + +toolsBtn.addEventListener("click", () => { syncToolsUi(); toolsModal.showModal(); }); +toolsClose.addEventListener("click", () => toolsModal.close()); +toolsModal.addEventListener("click", (e) => { + if (e.target === toolsModal) toolsModal.close(); +}); +agentToolsList.addEventListener("input", (event) => { + const target = /** @type {HTMLElement | null} */ (event.target instanceof HTMLElement ? event.target : null); + if (!(target instanceof HTMLTextAreaElement)) return; + const toolName = target.dataset.mcpToolArgs; + if (!toolName) return; + const state = getMcpToolCallState(toolName); + state.argsText = target.value; +}); +agentToolsList.addEventListener("click", (event) => { + const target = /** @type {HTMLElement | null} */ (event.target instanceof HTMLElement ? event.target : null); + const button = target?.closest("[data-mcp-call-btn]"); + if (button instanceof HTMLButtonElement) { + const toolName = button.dataset.mcpCallBtn; + if (!toolName) return; + void callAgentToolFromPanel(toolName, false); + return; + } + const sendButton = target?.closest("[data-mcp-send-btn]"); + if (!(sendButton instanceof HTMLButtonElement)) return; + const toolName = sendButton.dataset.mcpSendBtn; + if (!toolName) return; + void callAgentToolFromPanel(toolName, true); +}); + +toolWebSwitch.addEventListener("change", () => { + if (toolWebSwitch.checked && !searchAvailable()) { + toolWebSwitch.checked = false; // guard: can't enable without a key + return; + } + toolsEnabled.web_search = toolWebSwitch.checked; + saveTools(); + pushToolsToSession(); +}); + +searchKeyInput.addEventListener("input", () => { + if (serverSearchKey) return; + userSearchKey = searchKeyInput.value.trim(); + if (userSearchKey) localStorage.setItem(STORAGE_KEYS.searchKey, userSearchKey); + else localStorage.removeItem(STORAGE_KEYS.searchKey); + + const avail = searchAvailable(); + toolWebSwitch.disabled = !avail; + toolWebRow.classList.toggle("disabled", !avail); + // Losing the key disables a previously-enabled tool. + if (!avail && toolsEnabled.web_search) { + toolsEnabled.web_search = false; + toolWebSwitch.checked = false; + saveTools(); + pushToolsToSession(); + } + toolWebHint.textContent = userSearchKey + ? "Using your key — stored in this browser only." + : "No server key configured. Add your own Serper key to enable web search."; +}); + +// ── Tool executor ───────────────────────────────────────────────────────── +// Runs the function the model called, returns the result, and asks for a +// response so the model speaks it. Errors come back as the tool output too, so +// the model can recover gracefully instead of the turn stalling. + +/** + * Run the function the model called, return its result to the backend, and ask + * for a follow-up response. We also hand the result back to the caller so it + * can be shown in the conversation once the tool has actually run. + * @param {string} name @param {string} argsJson @param {string} callId + * @returns {Promise<{ output: string, image?: string }>} + */ +async function runTool(name, argsJson, callId) { + if (!client) return { output: "" }; + let args = /** @type {Record} */ ({}); + try { args = JSON.parse(argsJson || "{}"); } catch { /* keep {} */ } + + // #region debug-point D:run-tool-entry + reportDebug("D", "main.js:runTool", "tool execution started", { + name, + callId, + args, + knownMcpTool: agentToolDefs.some((tool) => tool.name === name), + }); + // #endregion + + if (DEBUG) console.debug(`[tool] run name=${name} callId=${JSON.stringify(callId)} args=${argsJson}`); + if (!callId) console.warn("[tool] empty call_id — the backend didn't tag the call, can't return a function_call_output"); + + /** @type {{ output: string, image?: string }} */ + let result = { output: "" }; + try { + if (name === "web_search") { + const query = typeof args.query === "string" ? args.query : ""; + result.output = await execWebSearch(query); + // Return the result and let the bare response.create (below) trigger the + // spoken answer. + client.sendToolOutput(callId, result.output); + } else if (agentToolDefs.some((tool) => tool.name === name)) { + // #region debug-point E:mcp-proxy-before-fetch + reportDebug("E", "main.js:runTool", "calling MCP proxy", { + name, + callId, + args, + }); + // #endregion + const mcpResult = await execRemoteMcpTool(name, args); + result.output = mcpResult.rawOutput; + console.debug(`[Vocero MCP OUT] name=${name} callId=${JSON.stringify(callId)} output=${result.output}`); + // #region debug-point E:mcp-proxy-after-fetch + reportDebug("E", "main.js:runTool", "MCP proxy returned result", { + name, + callId, + outputPreview: result.output.slice(0, 300), + speakableOutputLength: mcpResult.speakableOutput.length, + }); + // #endregion + client.sendToolOutput(callId, mcpResult.speakableOutput || summarizeMcpOutputForConversation(result.output)); + } else { + result.output = `Unknown tool: ${name}`; + client.sendToolOutput(callId, result.output); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + result.output = `Tool failed: ${msg}`; + // #region debug-point E:mcp-proxy-error + reportDebug("E", "main.js:runTool", "tool execution failed", { + name, + callId, + error: msg, + }); + // #endregion + client.sendToolOutput(callId, result.output); + } + if (DEBUG) console.debug(`[tool] requesting model response after ${name}`); + client.requestResponse(result.image ? { image: result.image } : undefined); + return result; +} + +/** + * Execute an MCP-backed tool through the app's same-origin proxy. + * @param {string} name + * @param {Record} args + * @returns {Promise<{ rawOutput: string, speakableOutput: string }>} + */ +async function execRemoteMcpTool(name, args) { + const startedAt = performance.now(); + if (DEBUG) { + console.debug(`[Vocero MCP HTTP] request name=${name} url=/api/mcp/tool args=${JSON.stringify(args || {})}`); + } + const res = await fetch("api/mcp/tool", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, arguments: args || {} }), + }); + if (DEBUG) { + console.debug(`[Vocero MCP HTTP] response name=${name} status=${res.status} ok=${res.ok}`); + } + if (!res.ok) { + let detail = String(res.status); + try { const j = await res.json(); if (j.detail) detail = j.detail; } catch {} + console.warn(`[Vocero MCP HTTP] error name=${name} detail=${detail}`); + throw new Error(`mcp error (${detail})`); + } + const json = await res.json(); + reportDebug("A", "main.js:execRemoteMcpTool", "received MCP HTTP response body", { + name, + durationMs: Math.round(performance.now() - startedAt), + resultJsonLength: JSON.stringify(json?.result ?? null).length, + }); + if (DEBUG) { + const preview = JSON.stringify(json?.result ?? null).slice(0, 300); + console.debug(`[Vocero MCP HTTP] payload name=${name} preview=${preview}`); + } + const rawOutput = formatMcpToolResult(json.result); + const speakableOutput = formatSpeakableMcpToolResult(json.result, rawOutput); + return { rawOutput, speakableOutput }; +} + +/** + * Turn an MCP tool result into plain text the model can read back. + * @param {any} result + * @returns {string} + */ +function formatMcpToolResult(result) { + if (!result) return "Tool returned no result."; + if (result.structuredContent) { + return JSON.stringify(result.structuredContent, null, 2); + } + /** @type {{ type?: string, text?: string }[]} */ + const parts = Array.isArray(result.content) ? result.content : []; + /** @type {string[]} */ + const textParts = []; + for (const item of parts) { + if (item && item.type === "text" && typeof item.text === "string") { + const text = item.text.trim(); + if (text) textParts.push(text); + } + } + if (textParts.length) return textParts.join("\n\n"); + return JSON.stringify(result, null, 2); +} + +/** + * Turn an MCP tool result into compact speech-ready facts for the live model/TTS path. + * @param {any} result + * @param {string} [rawOutput] + * @returns {string} + */ +function formatSpeakableMcpToolResult(result, rawOutput = "") { + if (!result) return "Tool returned no result."; + if (result.structuredContent != null) { + const summary = buildSpeakableMcpFacts(result.structuredContent); + if (summary) return summary.length > 1200 ? `${summary.slice(0, 1200).trim()}\n...` : summary; + } + /** @type {{ type?: string, text?: string }[]} */ + const parts = Array.isArray(result.content) ? result.content : []; + /** @type {string[]} */ + const textParts = []; + for (const item of parts) { + if (item && item.type === "text" && typeof item.text === "string") { + const text = item.text.trim(); + if (text) textParts.push(text); + } + } + if (textParts.length) { + return summarizeMcpOutputForConversation(textParts.join("\n\n")); + } + return summarizeMcpOutputForConversation(rawOutput || JSON.stringify(result, null, 2)); +} + +const SPEAKABLE_PRIORITY_KEYS = new Set([ + "name", + "title", + "summary", + "description", + "message", + "status", + "role", + "date", + "reportId", + "report_id", + "center", + "priority", + "audience", + "briefing", + "value", + "label", + "text", +]); + +const SKIP_SPEAKABLE_KEYS = new Set([ + "id", + "userId", + "user_id", + "openapiUrl", + "docsUrl", + "mcpPath", + "url", + "urls", + "image", + "images", + "raw", + "json", +]); + +/** + * @param {string} text + * @param {number} maxLen + * @returns {string} + */ +function trimSpeakableText(text, maxLen) { + const clean = String(text || "").replace(/\s+/g, " ").trim(); + if (!clean) return ""; + return clean.length > maxLen ? `${clean.slice(0, maxLen).trim()}...` : clean; +} + +/** + * @param {string} key + * @returns {string} + */ +function humanizeFactKey(key) { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/^./, (c) => c.toUpperCase()); +} + +/** + * @param {any} value + * @param {number} [maxLen] + * @returns {string} + */ +function pickSpeakableSnippet(value, maxLen = 180) { + if (value == null) return ""; + if (typeof value === "string") return trimSpeakableText(value, maxLen); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) { + const parts = value + .slice(0, 3) + .map((item) => pickSpeakableSnippet(item, 80)) + .filter(Boolean); + return trimSpeakableText(parts.join("; "), maxLen); + } + if (typeof value === "object") { + const obj = /** @type {Record} */ (value); + for (const key of SPEAKABLE_PRIORITY_KEYS) { + if (key in obj) { + const text = pickSpeakableSnippet(obj[key], maxLen); + if (text) return text; + } + } + for (const [key, item] of Object.entries(obj)) { + if (SKIP_SPEAKABLE_KEYS.has(key)) continue; + const text = pickSpeakableSnippet(item, maxLen); + if (text) return text; + } + } + return ""; +} + +/** + * @param {Record} obj + * @returns {string[]} + */ +function collectSpeakableObjectFacts(obj) { + /** @type {string[]} */ + const facts = []; + for (const [key, value] of Object.entries(obj)) { + if (SKIP_SPEAKABLE_KEYS.has(key) || value == null) continue; + if (typeof value === "string") { + const text = trimSpeakableText(value, 220); + if (text) facts.push(`${humanizeFactKey(key)}: ${text}`); + continue; + } + if (typeof value === "number" || typeof value === "boolean") { + facts.push(`${humanizeFactKey(key)}: ${String(value)}`); + continue; + } + if (Array.isArray(value)) { + if (!value.length) continue; + const first = pickSpeakableSnippet(value[0], 160); + if (first) { + facts.push(`${humanizeFactKey(key)}: ${first}`); + if (value.length > 1) facts.push(`${humanizeFactKey(key)} count: ${value.length}`); + } + continue; + } + if (typeof value === "object") { + const nested = pickSpeakableSnippet(value, 180); + if (nested) facts.push(`${humanizeFactKey(key)}: ${nested}`); + } + } + return facts; +} + +/** + * @param {any} parsed + * @returns {string} + */ +function buildSpeakableMcpFacts(parsed) { + if (parsed == null) return "No useful facts returned by the tool."; + if (typeof parsed === "string") return trimSpeakableText(parsed, 900); + if (typeof parsed === "number" || typeof parsed === "boolean") return String(parsed); + if (Array.isArray(parsed)) { + const items = parsed + .slice(0, 4) + .map((item, index) => { + const text = pickSpeakableSnippet(item, 180); + return text ? `- Item ${index + 1}: ${text}` : ""; + }) + .filter(Boolean); + if (parsed.length > 4) items.push(`- Additional items not shown: ${parsed.length - 4}`); + return items.join("\n") || "The tool returned a list with no speakable details."; + } + const obj = /** @type {Record} */ (parsed); + const facts = collectSpeakableObjectFacts(obj).slice(0, 8); + if (facts.length) return facts.map((line) => `- ${line}`).join("\n"); + const fallback = pickSpeakableSnippet(obj, 900); + return fallback || "The tool returned structured data with no speakable summary."; +} + +/** + * @param {string} output + * @returns {string} + */ +function summarizeMcpOutputForConversation(output) { + const text = (output || "").trim(); + if (!text) return "Tool returned no result."; + try { + const parsed = JSON.parse(text); + const summary = buildSpeakableMcpFacts(parsed); + return summary.length > 1200 ? `${summary.slice(0, 1200).trim()}\n...` : summary; + } catch { + return trimSpeakableText(text, 1200); + } +} + +/** + * Normalize agent profile tools into OpenAI-compatible function tool defs. + * @param {any[]} tools + * @returns {import("./ws/s2s-ws-client.js").ToolDef[]} + */ +function normalizeAgentTools(tools) { + if (!Array.isArray(tools)) return []; + return tools + .filter((tool) => tool && tool.enabled !== false && typeof tool.name === "string") + .map((tool) => ({ + type: "function", + name: tool.name, + description: typeof tool.description === "string" ? tool.description : "", + parameters: tool.parameters && typeof tool.parameters === "object" + ? tool.parameters + : { type: "object", properties: {}, required: [] }, + })); +} + +/** @param {string} query @returns {Promise} */ +async function execWebSearch(query) { + if (!query) return "No query provided."; + /** @type {Record} */ + const body = { query }; + // Only send a user key when there's no server key (server prefers its own). + if (!serverSearchKey && userSearchKey) body.key = userSearchKey; + + const res = await fetch("api/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + let detail = String(res.status); + try { const j = await res.json(); if (j.detail) detail = j.detail; } catch {} + throw new Error(`search error (${detail})`); + } + const json = await res.json(); + // Date-stamp the header so the model treats these as fresh realtime facts + // rather than its (older) training knowledge. + const today = new Date().toISOString().slice(0, 10); + /** @type {string[]} */ + const lines = [`Google search result from ${today}:`]; + if (json.answer) lines.push(`Answer: ${json.answer}`); + for (const r of json.results || []) { + lines.push(`- ${r.title}: ${r.snippet} (${r.url})`); + } + return lines.length > 1 ? lines.join("\n") : `${lines[0]}\nNo results found.`; +} + +async function fetchVoceroUserProfile() { + if (!voceroUserPhoneNumber) return; + try { + const res = await fetch(`api/user/phone?phoneNumber=${encodeURIComponent(voceroUserPhoneNumber)}`); + if (!res.ok) { + const message = await res.text(); + throw new Error(message || `HTTP ${res.status}`); + } + const json = await res.json(); + voceroUserProfile = json && typeof json === "object" && json.result && typeof json.result === "object" + ? json.result + : null; + voceroUserProfileLoaded = true; + console.debug( + `[Vocero USER AUTH] phoneNumber=${JSON.stringify(voceroUserPhoneNumber)} ` + + `loaded=${!!voceroUserProfile} keys=${voceroUserProfile ? Object.keys(voceroUserProfile).join(",") : "none"}` + ); + syncVoceroHeaderUser(); + if (client && LIVE_STATES.has(currentState)) { + pushLiveInstructions(effectiveInstructions()); + } + } catch (err) { + voceroUserProfile = null; + voceroUserProfileLoaded = false; + syncVoceroHeaderUser(); + const message = err instanceof Error ? err.message : String(err); + console.warn(`[Vocero USER AUTH] phoneNumber=${JSON.stringify(voceroUserPhoneNumber)} error=${message}`); + } +} + +/** Learn server config (search key + connection target), then refresh the UI. */ +async function fetchConfig() { + try { + const res = await fetch("api/config"); + if (res.ok) { + const json = await res.json(); + serverSearchKey = !!json.search; + lbMode = !!json.lb; + reasoningType = ["OPENAI","NODE_RED"].includes((json.reasoningType || "").toUpperCase()) + ? (json.reasoningType || "").toUpperCase() + : "PROXY"; + // Lock to LB mode only when the deploy reports a load balancer. + allowDirect = json.allowDirect ?? !lbMode; + // Deploy-pinned direct URL (overrides the LB server-side already). + pinnedUrl = (json.s2sUrl || "").trim(); + pinnedPrompt = (json.primaryPrompt || "").trim(); + pinnedPromptSource = (json.agentProfile || "").trim(); + agentToolDefs = normalizeAgentTools(json.agentTools || []); + searchMcpEnabled = !!json.searchMcp; + rankedAgentTools = rankAgentTools(lastUserQuery); + // #region debug-point B:config-loaded + reportDebug("B", "main.js:fetchConfig", "loaded config from api/config", { + searchMcpEnabled, + reasoningType, + agentToolNames: agentToolDefs.map((tool) => tool.name), + agentToolCount: agentToolDefs.length, + pinnedPromptLength: pinnedPrompt.length, + }); + // #endregion + // The conversation-time limiter rides on the LB being present. + limiterOn = lbMode; + } + // Non-OK response: leave the fail-open default (allowDirect = true). + } catch { + // Config endpoint unreachable (e.g. static hosting): keep direct entry. + } + if (DEBUG) console.debug(`[ui] config: allowDirect=${allowDirect} lbMode=${lbMode}`); + // Login chip + remaining-budget (no-op / hidden when the limiter is off). + void account.refresh(); + syncToolsUi(); + syncConnectionUi(); +} + +/** + * Resolve where to connect, per the deploy's mode: + * • LB mode -> `{ sessionUrl }`, the client POSTs the same-origin /api/session + * proxy and the server forwards to the LB (its address stays server-side). + * • direct -> `{ directUrl }`, connect straight to the s2s WebSocket. + * Throws a user-facing error if direct mode is on but no URL was entered. + * @returns {{ sessionUrl: string } | { directUrl: string }} + */ +function connectionTarget() { + if (!allowDirect) { + return { sessionUrl: "api/session" }; + } + const directUrl = buildDirectWsUrl(pinnedUrl || settings.directUrl); + if (!directUrl) { + throw new Error("Enter a speech-to-speech server URL in Settings."); + } + return { directUrl }; +} + +/** + * Normalise a user-typed server address into a realtime WebSocket URL. + * Accepts bare hosts (`localhost:8080`), http(s) URLs, or ws(s) URLs, and adds + * the `/v1/realtime` path when none is given. A full connect URL (with path + * and/or query) is preserved as-is. + * @param {string} raw @returns {string} + */ +function buildDirectWsUrl(raw) { + let s = (raw || "").trim(); + if (!s) return ""; + if (!/^wss?:\/\//i.test(s)) { + if (/^https?:\/\//i.test(s)) { + s = s.replace(/^http/i, "ws"); // http→ws, https→wss + } else { + const isLocal = /^(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(s); + s = (isLocal ? "ws://" : "wss://") + s; + } + } + try { + const u = new URL(s); + if (u.pathname === "" || u.pathname === "/") u.pathname = "/v1/realtime"; + return u.toString(); + } catch { + return s; + } +} + +/** Create + resume an AudioContext synchronously (must run inside the user + * gesture so iOS lets it start). Returns null if construction fails. */ +function createResumedAudioContext() { + try { + const Ctx = window.AudioContext || /** @type {any} */ (window).webkitAudioContext; + const ctx = new Ctx({ latencyHint: "interactive" }); + if (ctx.state === "suspended") void ctx.resume().catch(() => {}); + return /** @type {AudioContext} */ (ctx); + } catch (err) { + console.warn("[main] AudioContext init failed:", err); + return null; + } +} + +/** Read the editable settings out of the form. The URL field is only honoured + * in free direct mode — in LB mode it's hidden, and when the deploy pins a + * URL it's read-only, so the user's saved URL survives either way. */ +function readSettingsFromForm() { + return { + directUrl: allowDirect && !pinnedUrl ? inputLbUrl.value.trim() : settings.directUrl, + voice: inputVoice.value || DEFAULT_VOICE, + instructions: pinnedPrompt ? settings.instructions : inputInstructions.value.trim() || DEFAULT_INSTRUCTIONS, + noiseGate: readGateThreshold(), + }; +} + +/** Gate threshold (dBFS) currently shown on the slider, clamped to range. */ +function readGateThreshold() { + const v = Math.round(Number(inputNoiseGate.value)); + if (!Number.isFinite(v)) return GATE_OFF_DB; + return Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, v)); +} + +/** Adapt the connection field to the mode learned from /api/config. */ +function syncConnectionUi() { + if (pinnedUrl) { + // Deploy-pinned URL: show it, but locked — the deployment owns it. + connField.hidden = false; + inputLbUrl.value = pinnedUrl; + inputLbUrl.readOnly = true; + connHint.classList.remove("error"); + connHint.textContent = "Speech-to-speech server URL pinned by this deployment."; + } else if (allowDirect) { + // Direct mode: the user sets their own s2s server URL. + connField.hidden = false; + inputLbUrl.value = settings.directUrl; + inputLbUrl.readOnly = false; + inputLbUrl.placeholder = "http://localhost:port"; + connHint.classList.remove("error"); + connHint.textContent = + "URL of your speech-to-speech server, e.g. http://localhost:8080 (the app adds /v1/realtime)."; + } else { + // LB mode: the load balancer URL is deployment-owned — hide it entirely so + // its address is never exposed in Settings. + connField.hidden = true; + } +} + +/** True when the user must supply a server URL before connecting (direct mode + * with nothing set). */ +function missingServerUrl() { + return allowDirect && !pinnedUrl && !buildDirectWsUrl(settings.directUrl); +} + +/** Open Settings and point the user at the empty server-URL field. */ +function promptServerUrl() { + if (settingsModal.open) syncConnectionUi(); + else openSettings(); + connHint.textContent = "Set the speech-to-speech server URL to start."; + connHint.classList.add("error"); + inputLbUrl.focus(); +} + +settingsForm.addEventListener("submit", (event) => { + const submitter = /** @type {HTMLButtonElement | null} */ ((/** @type {SubmitEvent} */ (event)).submitter); + if (submitter?.value !== "save") return; + + settings = readSettingsFromForm(); + saveSettings(settings); + + // Voice + instructions can apply to a live session without reconnecting; a + // changed connection URL only takes effect on the next restart. + if (client && LIVE_STATES.has(currentState)) { + client.updateSession({ voice: settings.voice, instructions: effectiveInstructions() }); + } +}); + +// The noise gate applies live (worklet param), so tune it without a restart: +// update the label/marker, persist, and push straight to the running client. +inputNoiseGate.addEventListener("input", () => { + setGateThreshold(readGateThreshold()); +}); + +restartBtn.addEventListener("click", async () => { + if (currentState === "connecting") return; // a connect is already underway + settings = readSettingsFromForm(); + saveSettings(settings); + if (missingServerUrl()) { promptServerUrl(); return; } // keep settings open + settingsModal.close(); + // Grab the AudioContext NOW, inside the click gesture — teardown() awaits, and + // creating it afterwards would fall outside the gesture (silent on iOS). + const audioContext = createResumedAudioContext(); + try { + if (client) await teardown(); + await doStart(audioContext); + } catch (err) { + await handleStartError(err); + } +}); + +circleBtn.addEventListener("click", async () => { + try { + if (currentState === "idle" || currentState === "error") { + if (missingServerUrl()) { promptServerUrl(); return; } + await doStart(); + } + } catch (err) { + await handleStartError(err); + } +}); + +/** A failed start is either the daily limit (show the modal, return to idle) or + * a real fault (surface it). doStart already closed any orphan AudioContext. + * @param {any} err */ +async function handleStartError(err) { + if (err && err.code === "limit") { + await teardown(); + account.showLimit(err.tier); + return; + } + // The user left the queue (close() aborted the wait): teardown already reset + // the UI to idle, so there's nothing to report. + if (err && err.code === "aborted") return; + // The whole waiting line is full: a warm, reassuring modal rather than an error. + if (err && err.code === "queue-full") { + await teardown(); + account.showBusy(); + return; + } + // Our place lapsed (ticket reaped, or the join window ran out). Recoverable, not + // a fault: land on the retry state with a kind, plain-language reason. + if (err && (err.code === "queue-expired" || err.code === "join-expired")) { + await teardown(); + setState("error"); + setCaption( + err.code === "join-expired" + ? "Your spot expired. Tap to rejoin." + : "That took a while. Tap to rejoin.", + "error", + ); + return; + } + onFatalError(err); +} + +micBtn.addEventListener("click", () => { + if (!micStream || !client) return; + micMuted = !micMuted; + for (const track of micStream.getAudioTracks()) { + track.enabled = !micMuted; + } + client.setMuted(micMuted); + micBtn.classList.toggle("muted", micMuted); + micBtn.setAttribute("aria-label", micMuted ? "Unmute" : "Mute"); + micBtn.title = micMuted ? "Unmute" : "Mute"; +}); + +stopBtn.addEventListener("click", async () => { + await teardown(); +}); + +// "Leave queue": tear down the pending connect (aborts the poll wait) and drop +// our place in line. Same teardown path as stopping a live call. +leaveQueueBtn.addEventListener("click", async () => { + await teardown(); +}); + +// "Join now": accept the held slot. The click is a user gesture, so the client +// re-resumes the AudioContext here (iOS) before dialing. +joinQueueBtn.addEventListener("click", () => { + stopJoinCountdown(); + if (client) client.join(); +}); + +const MIC_CONSTRAINTS = { + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, +}; + +/** Prompt for mic permission up front, then immediately release the tracks so no + * recording indicator lingers during a queue wait. Throws a friendly error if the + * user denies. */ +async function primeMicPermission() { + try { + const s = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS); + for (const track of s.getTracks()) track.stop(); + } catch (err) { + throw new Error( + `Microphone access denied${err instanceof Error ? `: ${err.message}` : ""}`, + ); + } +} + +/** Acquire the live capture stream once a slot is granted. Permission was primed + * in the tap gesture, so this is silent. Stored module-side for mute + teardown. */ +async function acquireMicStream() { + micStream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS); + return micStream; +} + +/** @param {number} position Update the queued caption ("You're #N in line"). */ +function onQueuePosition(position) { + const n = Number(position) || 0; + setCaption(n > 0 ? `You're #${n} in line` : "Finding you a spot…", "muted"); +} + +// ── "Your turn" join countdown ────────────────────────────────────────────── +// While a slot is held for us, show how long is left to accept it. The client's +// join gate expires just before the load balancer reclaims the slot. +let joinCountdownTimer = 0; + +/** @param {number} sec */ +function startJoinCountdown(sec) { + stopJoinCountdown(); + let left = Math.max(0, Math.floor(sec)); + const paint = () => { + joinQueueBtn.textContent = left > 0 ? `Join now (${left}s)` : "Join now"; + }; + paint(); + joinCountdownTimer = window.setInterval(() => { + left -= 1; + if (left <= 0) { + stopJoinCountdown(); + joinQueueBtn.textContent = "Join now"; + return; + } + paint(); + }, 1000); +} + +function stopJoinCountdown() { + if (joinCountdownTimer) { + clearInterval(joinCountdownTimer); + joinCountdownTimer = 0; + } +} + +/** + * Start a conversation. Pass a pre-created AudioContext when the caller already + * made one inside the tap/click gesture (required on iOS); otherwise one is + * created here, which is still inside the gesture for a direct orb tap. + * @param {AudioContext | null} [audioContext] + */ +async function doStart(audioContext = null) { + // Resolve the target before touching mic/audio so a misconfiguration (e.g. + // direct mode with no URL) fails fast with a clear message. + const target = connectionTarget(); + + chat.clear(); + chat.reset(); + setState("connecting"); + setCaption("Asking for mic…", "muted"); + + // Create + resume the AudioContext SYNCHRONOUSLY, still inside the gesture. + // iOS Safari only starts an AudioContext from a user gesture; if we waited + // until after the getUserMedia / session-creation awaits below, it would stay + // suspended and the whole pipeline would be silent. + if (!audioContext) audioContext = createResumedAudioContext(); + + // Prime the mic permission now (get the prompt out of the way up front), then + // release it. The real capture stream is acquired only once a slot is granted + // (see acquireMicStream), so the mic 'in use' indicator never lights while we + // sit in the queue. Permission persists, so the later acquire is silent. + try { + await primeMicPermission(); + } catch (err) { + if (audioContext) void audioContext.close().catch(() => {}); + throw err; + } + + const startInstructions = effectiveInstructions(); + const startTools = activeToolDefs(); + // #region debug-point B:client-construction + reportDebug("B", "main.js:doStart", "constructing realtime client", { + toolNames: startTools.map((tool) => tool.name), + toolCount: startTools.length, + instructionLength: startInstructions.length, + searchMcpEnabled, + }); + // #endregion + const c = new S2sWsRealtimeClient({ + ...target, + voice: settings.voice, + instructions: startInstructions, + acquireMic: acquireMicStream, + tools: startTools, + noiseGate: gateParams(settings.noiseGate), + ...(audioContext ? { audioContext } : {}), + }); + client = c; + liveInstructionsSnapshot = startInstructions; + + c.addEventListener("queue", (e) => { + const { position, queueId } = /** @type {CustomEvent<{ position: number; queueId: string }>} */ (e).detail; + if (queueId) queuedTicketId = queueId; + onQueuePosition(position); + }); + + c.addEventListener("ready-to-join", (e) => { + const { info, expiresSec } = /** @type {CustomEvent<{ info: import("./ws/s2s-ws-client.js").WsSessionInfo; expiresSec: number }>} */ (e).detail; + // A slot is held for us. We're out of the queue now, so drop the ticket ref. + // Track the granted session id already so that leaving (or letting the timer + // lapse) refunds the budget the server reserved at claim, even before we dial. + queuedTicketId = ""; + if (info?.sessionId) { + trackedSessionId = info.sessionId; + trackedTier = info.tier || "anon"; + } + startJoinCountdown(expiresSec); + }); + + c.addEventListener("status", (e) => { + const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail; + _kickIdleTimer(`client-status:${detail.status}`); + onClientStatus(detail.status); + }); + c.addEventListener("transcript", (e) => { + const d = /** @type {CustomEvent<{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }>} */ (e).detail; + _kickIdleTimer(`transcript-${d.role}${d.partial ? ":partial" : ""}`); + chat.onTranscript(d); + if (d.role === "user") { + updateMcpToolSuggestions(d.text || "", { partial: d.partial, itemId: d.itemId }); + if (!d.partial) void maybeRunOpenAiReasoning(d.text || "", d.itemId || ""); + } + if (d.role === "assistant" && !d.partial) maybeRunDeclaredAssistantTool(d.text || "", d.responseId || ""); + }); + + c.addEventListener("response-finished", (e) => { + const detail = /** @type {CustomEvent<{ responseId: string; status: string; audible?: boolean; transcript?: string }>} */ (e).detail; + _kickIdleTimer(`response-finished:${detail.status || "done"}`); + chat.onResponseFinished(detail); + maybeRunDeclaredAssistantTool(detail.transcript || "", detail.responseId || ""); + }); + + c.addEventListener("toolcall", (e) => { + const { name, arguments: args, callId } = /** @type {CustomEvent<{ name: string; arguments: string; callId: string }>} */ (e).detail; + _kickIdleTimer(`toolcall:${name}`); + clearPendingAutoMcpFallback(); + // #region debug-point D:toolcall-event + reportDebug("D", "main.js:client.toolcall", "toolcall event received by main app", { + name, + callId, + args, + knownMcpTool: agentToolDefs.some((tool) => tool.name === name), + }); + // #endregion + if (agentToolDefs.some((tool) => tool.name === name)) { + console.debug(`[Vocero MCP CALL] name=${name} callId=${JSON.stringify(callId)} args=${args}`); + } + chat.onToolCall(name); + // Execute the tool, then push it to the conversation once the result is in, + // so the toggle shows both the call input and its output together. + void runTool(name, args, callId).then(({ output, image }) => { + _kickIdleTimer(`tool-result:${name}`); + chat.onToolResult(name, args, output, image); + }); + }); + c.addEventListener("error", (e) => { + const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail; + onFatalError(detail.error); + }); + c.addEventListener("server-error", (e) => { + // Non-fatal: the backend reported an error mid-session. Log it, keep the + // socket and the conversation alive (the model can recover on its own). + const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail; + const msg = detail.error instanceof Error ? detail.error.message : String(detail.error); + console.warn("[main] server error (non-fatal):", msg); + }); + c.addEventListener("session", (e) => { + const info = /** @type {CustomEvent<{ info: import("./ws/s2s-ws-client.js").WsSessionInfo }>} */ (e).detail.info; + console.log("[ws] session created:", info.sessionId); + // A slot was granted — we're out of the queue; drop the ticket reference so + // teardown doesn't try to leave a line we already left. + queuedTicketId = ""; + // A metered tier (anon / free): heartbeat so the server can extend the + // reservation and tell us when the daily budget runs out. PRO isn't limited. + if (info.limited && info.sessionId) { + trackedSessionId = info.sessionId; + trackedTier = info.tier || "anon"; + startHeartbeat(info.heartbeatSec || 5); + } + }); + c.addEventListener("input-level", (e) => { + const { rms } = /** @type {CustomEvent<{ rms: number }>} */ (e).detail; + paintInputLevel(rms); + // Proxy for "user is speaking" before VAD/STT emits the first transcript + // delta. RMS > ~-52 dBFS (≈ 0.0025 linear) consistently means actual + // speech or close enough to a real utterance that the 10s idle window + // should restart. Threshold is generous (≈ GATE_DEFAULT_DB - 2dB) so even + // quiet speakers keep the session alive; linear fast path avoids log10 + // on the hot 50 Hz level path. + if (rms > 0.0025) _kickIdleTimer("input-level"); + }); + + try { + await c.connect(); + } catch (err) { + // The grant can be refused (402 → limit) or the dial can fail. In LB mode + // the AudioContext hasn't been adopted by the client yet (the session POST + // runs first), so close the one we created here to avoid leaking it. + if (audioContext) void audioContext.close().catch(() => {}); + throw err; + } +} + +// ── Conversation-time heartbeat ───────────────────────────────────────────── + +/** Ping the server every `sec` seconds so it can meter the live session; when + * it reports the daily budget is spent, cut the call and show the limit modal. + * @param {number} sec */ +function startHeartbeat(sec) { + stopHeartbeat(); + heartbeatTimer = window.setInterval(async () => { + if (!trackedSessionId) return; + try { + const res = await fetch("api/session/heartbeat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: trackedSessionId }), + keepalive: true, + }); + const json = await res.json().catch(() => ({})); + if (json.expired) await onLimitReached(); + } catch (err) { + // A transient network blip shouldn't kill the call; the next tick retries. + if (DEBUG) console.debug("[ui] heartbeat failed:", err); + } + }, Math.max(1, sec) * 1000); +} + +function stopHeartbeat() { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = 0; + } +} + +/** The server cut the live session: tear down and explain why. */ +async function onLimitReached() { + const tier = trackedTier; + stopHeartbeat(); + await teardown(); + account.showLimit(tier); +} + +/** Tell the server a session ended so it reconciles + refunds the unused chunk. + * Uses sendBeacon so it still fires when the tab is closing. */ +function endTrackedSession() { + if (!trackedSessionId) return; + const body = JSON.stringify({ sessionId: trackedSessionId }); + try { + const blob = new Blob([body], { type: "application/json" }); + if (!navigator.sendBeacon("api/session/end", blob)) { + void fetch("api/session/end", { + method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true, + }).catch(() => {}); + } + } catch { + // Best-effort; the server sweep reaps the session anyway. + } + trackedSessionId = ""; + trackedTier = ""; +} + +/** Leave the waiting queue so the LB frees our place. sendBeacon so it still + * fires on tab close; the LB also reaps the ticket on TTL as a backstop. */ +function endQueueTicket() { + if (!queuedTicketId) return; + const body = JSON.stringify({ queueId: queuedTicketId }); + try { + const blob = new Blob([body], { type: "application/json" }); + if (!navigator.sendBeacon("api/queue/end", blob)) { + void fetch("api/queue/end", { + method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true, + }).catch(() => {}); + } + } catch { + // Best-effort; the LB reaps the ticket on TTL anyway. + } + queuedTicketId = ""; +} + +/** @param {string} status */ +function onClientStatus(status) { + switch (status) { + case "creating-session": + case "connecting": + setState("connecting"); + break; + case "queued": + setState("queued"); + break; + case "your-turn": + setState("your-turn"); + break; + case "connected": + setState("listening"); + // Wake word confirmation: if this session was triggered by the "Hola + // Vocero" hot-word, inject the phrase into the conversation exactly as + // though the user had spoken it, then ask the model to reply so the + // assistant greets back instead of silently waiting for the next turn. + if (pendingWakeWordConfirmationText && client) { + const phrase = pendingWakeWordConfirmationText.trim() || "hola vocero"; + pendingWakeWordConfirmationText = ""; + console.info(`[WAKE WORD] 📤 Injecting confirmation phrase to ai-server: ${JSON.stringify(phrase)}`); + try { + client.sendUserText(phrase); + client.requestResponse(); + } catch (err) { + console.warn("[WAKE WORD] failed to inject confirmation phrase:", err && err instanceof Error ? err.message : err); + } + } + break; + case "user-speaking": + setState("user-speaking"); + break; + case "processing": + setState("processing"); + break; + case "ai-speaking": + setState("ai-speaking"); + break; + case "closed": + // teardown() will move us to idle + break; + case "error": + setState("error"); + break; + } +} + +async function teardown() { + clearPendingAutoMcpFallback(); + stopHeartbeat(); + stopJoinCountdown(); + endTrackedSession(); + endQueueTicket(); + chat.reset({ dismiss: true }); + updateMcpToolSuggestions(""); + liveInstructionsSnapshot = ""; + liveToolPolicySnapshot = ""; + // If teardown fires *before* the first "connected" status (e.g. failed dial, + // user left queue, limit exceeded) the pending wake-word confirmation would + // be re-injected on the next session. Clear it so we never double-fire. + pendingWakeWordConfirmationText = ""; + if (client) { + try { + await client.close(); + } catch (err) { + console.warn("[main] error closing client:", err); + } + client = null; + } + if (micStream) { + for (const track of micStream.getTracks()) track.stop(); + micStream = null; + } + micMuted = false; + micBtn.classList.remove("muted"); + setState("idle"); + // Refresh the chip's remaining-today after the budget moved. + if (limiterOn) void account.refresh(); +} + +/** @param {unknown} err */ +function onFatalError(err) { + console.error("[main] fatal:", err); + setState("error"); + const message = err instanceof Error ? err.message : String(err); + setCaption(truncateError(message), "error"); + void teardown().catch(() => { + setState("error"); + setCaption(truncateError(message), "error"); + }); +} + +setState("idle"); +chat.renderEmptyState(); +initGateArc(); +void fetchConfig(); +void fetchVoceroUserProfile(); +(() => { + const toolCamRow = document.getElementById("tool-cam-row"); + if (toolCamRow) toolCamRow.setAttribute("hidden", ""); + if (toolCamHint) toolCamHint.setAttribute("hidden", ""); + if (camPip) camPip.setAttribute("hidden", ""); + if (camVideo) camVideo.setAttribute("hidden", ""); +})(); + +// Reconcile a live session if the tab is closed/hidden mid-call (no teardown). +window.addEventListener("pagehide", () => { endTrackedSession(); endQueueTicket(); }); + +requestAnimationFrame(() => { + document.body.classList.remove("booting"); +}); diff --git a/demo/project/index.html b/demo/project/index.html new file mode 100644 index 0000000..36d168d --- /dev/null +++ b/demo/project/index.html @@ -0,0 +1,139 @@ + + + + + + Project Docs + + + +
+
+

Vocero Project

+

Embedded technical document viewer for `demo/public/docs.html`.

+
+ +
+ +
+ +
+ + diff --git a/demo/public/docs.html b/demo/public/docs.html new file mode 100644 index 0000000..2771f17 --- /dev/null +++ b/demo/public/docs.html @@ -0,0 +1,272 @@ + + + +Vocero S2S — Arquitectura técnica objetivo y plan de implementación + + + +
+
+

Documento de arquitectura técnica

+

Vocero S2S

+

Arquitectura técnica objetivo y plan de implementación

+

Estado: propuesta para revisión técnica
Fecha de corte de fuentes: 20 de julio de 2026
Ámbito: plataforma conversacional Speech-to-Speech desacoplada, local y extensible mediante MCP
Audiencia: arquitectura, plataforma, seguridad, MLOps, desarrollo, QA y operación

+
+
+ +

Vocero S2S

+

Arquitectura técnica objetivo y plan de implementación

+

Estado del documento: propuesta para revisión técnica
+Fecha de corte de fuentes: 20 de julio de 2026
+Ámbito: plataforma conversacional Speech-to-Speech desacoplada, local y extensible mediante MCP
+Audiencia: arquitectura, plataforma, seguridad, MLOps, desarrollo, QA y operación

+
+

Convención de trazabilidad

+
    +
  • [REQ] Requisito proporcionado: proviene directamente de la descripción de Vocero S2S entregada por el solicitante.
  • +
  • [REC] Decisión recomendada: propuesta de diseño que debe aprobarse mediante ADR.
  • +
  • [BENCH] Supuesto pendiente: hipótesis que requiere medición, prueba de carga, evaluación legal o validación en el entorno objetivo.
  • +
+
+

1. Resumen ejecutivo

+

Vocero S2S se plantea como una plataforma conversacional en tiempo real que separa cuatro responsabilidades: experiencia web, control de sesión, inferencia de voz y lenguaje, e integración empresarial mediante Model Context Protocol (MCP). [REQ] La implementación inicial debe utilizar Parakeet TDT 0.6B para STT/VAD, LLaMA 3.1 8B Instruct para razonamiento y llamadas a herramientas, Qwen3-TTS 12Hz 1.7B Base en FP16 para síntesis y clonación zero-shot, y un nodo NVIDIA GB10 accesible inicialmente por el puerto 12345.

+

La arquitectura objetivo conserva esa cadena desacoplada, pero evita convertir la conexión WebSocket en el lugar donde vive toda la lógica. [REC] Se propone un Control Plane en el servidor UI & Relay para autenticación, autorización, perfil del agente, política de voz, orquestación MCP y auditoría; un Realtime Data Plane para audio y eventos con estado de sesión efímero y cancelación prioritaria; y un Inference Plane local que expone adaptadores versionados para STT, LLM y TTS. La separación permite reemplazar modelos, transporte o infraestructura sin alterar los contratos de negocio.

+

Para clientes web, WebRTC ofrece ventajas operativas para media en redes variables, mientras que WebSocket sigue siendo apropiado para enlaces servidor-a-servidor y para la primera versión ya planteada. La documentación de plataformas realtime recomienda WebRTC para browser/media y WebSocket para pipelines de servidor 1. [REC] Por ello, el plan adopta WebSocket como transporte inicial compatible y define WebRTC como evolución controlada, no como prerrequisito del MVP.

+

La gestión de interrupciones es una capacidad de primer orden: al detectar habla del usuario durante la respuesta, el sistema debe cancelar la generación todavía reversible, detener el audio, vaciar buffers no reproducidos y truncar el historial a lo efectivamente escuchado. Este patrón está documentado en sistemas realtime mediante cancelación y truncamiento de elementos de conversación 2, y en frameworks de agentes que ajustan el historial al audio efectivamente reproducido 3.

+

MCP se integra mediante un gateway empresarial con política, validación de esquemas, identidad delegada, límites, auditoría y adaptadores de versión. Decisión temporal crítica: al 20 de julio de 2026, el anuncio de 2026-07-28 describe explícitamente un Release Candidate cuyo estándar final se espera para el 28 de julio de 2026 y contiene cambios incompatibles 4. Por lo tanto:

+
    +
  1. [REC] la versión estable de compatibilidad inicial será 2025-11-25, verificada en la documentación oficial de transportes 5; esta decisión responde al corte temporal solicitado y no contradice la dirección técnica stateless propuesta por el RC;
  2. +
  3. [REC] 2026-07-28 se implementará sólo detrás de un adaptador experimental y feature flag;
  4. +
  5. [REC] el diseño del dominio se mantendrá preparado para el modelo stateless del RC: requests autocontenidos, estado de aplicación mediante handles explícitos y routing desacoplado de afinidad;
  6. +
  7. [REC] su promoción exigirá ratificación oficial, pruebas de conformidad y migración aprobada;
  8. +
  9. no se asumirá como estándar final en este documento.
  10. +
+

Finalmente, la capacidad real de ejecutar STT, LLM y TTS concurrentemente en un único GB10 no está demostrada por un benchmark de la cadena completa. [BENCH] La primera salida de ingeniería debe ser un arnés reproducible que mida latencia extremo a extremo, memoria, ancho de banda, degradación bajo concurrencia, precisión de interrupción y calidad de clonación antes de comprometer capacidad o SLO contractual.

+

2. Alcance

+

2.1 Objetivos

+
    +
  • [REQ] Entregar conversación de voz bidireccional y de baja latencia desde un navegador.
  • [REQ] Identificar al usuario mediante la API de cliente y personalizar la sesión según identidad o rol.
  • [REQ] Cargar dinámicamente el perfil del agente, instrucciones, reglas y herramientas.
  • [REQ] Preservar una voz clonada corporativa frente a parámetros por defecto enviados por el cliente.
  • [REQ] Ejecutar STT, razonamiento, llamadas a herramientas y TTS en una cadena desacoplada.
  • [REQ] Integrar sistemas empresariales sin modificar el núcleo mediante un gateway MCP.
  • [REC] Incorporar barge-in, cancelación, backpressure, timeouts, aislamiento, trazabilidad y recuperación de fallas como capacidades del núcleo.
  • [REC] Mantener contratos versionados que permitan sustituir transporte, modelos, runtimes y servidores MCP.
  • [REC] Proporcionar evidencia operativa para dimensionamiento, SLOs y auditoría.
  • +
+

2.2 No objetivos

+
  • Entrenar desde cero los modelos STT, LLM o TTS.
  • Reemplazar ERP, CRM, catálogos u otros sistemas de registro.
  • Permitir ejecución directa del LLM contra bases de datos o APIs sin gateway y política.
  • Convertir el navegador en depositario de credenciales empresariales o material de voz maestro.
  • Garantizar capacidad, latencia o concurrencia antes del benchmark en GB10.
  • Declarar la versión MCP 2026-07-28 como estándar final antes de su ratificación.
  • Diseñar en esta etapa un contact center completo, grabación regulatoria universal o telefonía SIP; pueden añadirse como dominios posteriores.
+

3. Principios de arquitectura

+
  1. Control y media separados. El audio tiene requisitos de tiempo real; identidad, política y auditoría requieren consistencia y trazabilidad.
  2. Contratos antes que implementaciones. Cada frontera publica eventos y esquemas versionados; los modelos quedan detrás de adaptadores.
  3. Interrupción prioritaria. Cancelar y detener audio tiene precedencia sobre generar más contenido.
  4. Estado explícito y con dueño. La sesión conversacional pertenece a Vocero; el estado MCP pertenece al adaptador de protocolo o a handles explícitos.
  5. Zero trust entre planos. Cada salto autentica servicio, autoriza acción, limita alcance y propaga identidad auditada.
  6. Mínimo privilegio para herramientas. Una tool sólo recibe los argumentos, identidad y scopes estrictamente necesarios.
  7. Privacidad por defecto. Audio, transcripciones, prompts, respuestas y voces son datos sensibles; la retención es explícita y minimizada.
  8. Fallar de forma conversacional. Los fallos técnicos se traducen en eventos deterministas y respuestas seguras, sin filtrar detalles internos.
  9. Medir antes de prometer. La experiencia se gobierna por latencia de etapa y percentiles, no por promedios aislados.
  10. Compatibilidad MCP versionada. El gateway negocia una versión estable y encapsula candidatos futuros.
+

4. Vista de contexto y contenedores

+
+Vocero S2S — Vista C4 de contenedores +VOCERO S2S +UsuarioHabla desde navegadorIdentidad / API clienteUsuario, roles y atributosServicio de perfilesInstrucciones, reglas, toolsy política de vozSistemas empresarialesERP, CRM, catálogos,reportes y workflowsObservabilidadMétricas, logs, tracesy alertasCliente WebWeb Audio + WS/WebRTCEdge / TerminaciónTLS, conexión, rate limitRealtime RelayAudio/eventos, sanitizaciónSession OrchestratorEstados, turnos, perfily políticaMCP GatewayCliente MCP + policyenforcementSession StoreKV con TTLAudit StoreAlmacén inmutableInference GatewayAPI realtime internaSTT, LLM y TTSParakeet TDT 0.6BNeMo / GPULLaMA 3.1 8BRuntime localQwen3-TTS 1.7BFP16 / GPUVoice VaultReferencias autorizadasAudio, controles y confirmacionesMedia y eventos · TLSStream autenticadoEventos de sesiónResuelve identidad · HTTPSCarga perfil y políticas · HTTPSLee/escribe estado efímeroTool intent + contexto delegado · mTLSComandos y eventos de inferencia · mTLSChunks de audioContexto y tokensTexto y voice_profile_refLee referencia autorizadaMCP/HTTPS versionadoEjecuciones y decisionesEventos de auditoríaTelemetríaTelemetríaTelemetría GPU/modelos
Representación vectorial estática de la vista C4 contenida en la versión v2.
+

4.1 Vista lógica por planos

+

Experience Plane

  • Cliente web, captura de micrófono, cancelación de eco, reproducción y controles de consentimiento.
  • Mantiene sólo estado visual y cursores necesarios; no conserva secretos ni voz maestra.
+

Realtime Data Plane

  • Terminación de WebSocket o WebRTC, relay de media y multiplexación de eventos.
  • Impone límites de buffer, secuencia y tamaño; descarta datos obsoletos al cambiar de generación.
  • Separa una cola de control de alta prioridad —cancel, barge_in, close— de la cola de audio/datos.
+

Conversation Control Plane

  • Resuelve identidad, perfil, permisos y configuración efectiva.
  • Mantiene la máquina de estados de sesión y turno.
  • Construye contexto, coordina inferencia y decide si una tool puede ejecutarse, confirmarse o rechazarse.
+

Inference Plane

  • Oculta detalles de runtime y expone capacidades uniformes: transcribe, generate, synthesize, cancel, health, capacity.
  • Correlaciona cada salida con session_id, turn_id y generation_id para descartar respuestas tardías.
+

Enterprise Integration Plane

  • Gateway MCP con adaptadores de versión, allowlists, validación, autorización, secretos de servicio, idempotencia y auditoría.
  • Ninguna respuesta de tool se incorpora al contexto sin controles de tamaño, clasificación y sanitización.
+

5. Responsabilidades por componente

+
ComponenteResponsabilidadesNo debe hacer
Cliente webCapturar/reproducir audio; AEC/NS/AGC cuando corresponda; mostrar estado; enviar confirmaciones; reportar progreso de playoutAutorizar tools; almacenar secretos; decidir la voz efectiva
Edge realtimeTLS; autenticación de conexión; límites; routing; protección DoS; upgrade WS o terminación WebRTCInterpretar prompts o argumentos MCP
Realtime RelayMultiplexar audio/eventos; ordenar secuencias; backpressure; sanitizar session.update; priorizar interrupcionesMantener lógica de negocio o credenciales downstream
Session OrchestratorMáquina de estados; identidad; perfil; contexto; turnos; cancelación; degradación; correlaciónEjecutar SQL, ERP o APIs directamente
Policy EngineEvaluar identidad, rol, tool, riesgo, ámbito, consentimiento y política de datosReescribir silenciosamente argumentos de negocio sin registro
MCP GatewayNegociar versión; descubrir tools; validar JSON Schema; delegar identidad; aplicar timeout/retry; auditarExponer credenciales al LLM o navegador
Inference GatewayAdmitir/cancelar trabajos; adaptar protocolos; medir etapas; health/capacityDecidir permisos empresariales
STT/VADEmitir parciales/finales y señales de habla con timestampsPersistir audio indefinidamente
LLMGenerar texto estructurado o intención de tool dentro de schemas permitidosConsiderarse autoridad para ejecutar acciones
TTSSintetizar stream asociado a una generación y perfil de voz autorizadoAceptar ref_audio arbitrario del cliente
Voice VaultVersionar, cifrar y gobernar referencias de voz y consentimientosServir archivos sin autorización por servicio y tenant
StoresEstado efímero con TTL, auditoría inmutable y configuración versionadaMezclar PII operacional con telemetría no restringida
ObservabilidadMétricas, logs estructurados, traces, alertas y tablerosRegistrar audio, tokens o argumentos sensibles por defecto
+

6. Vista de despliegue

+
Vocero S2S — Vista de despliegue +ZONA PÚBLICA +DMZ / EDGE +RED DE APLICACIONES +SEGMENTO DE INFERENCIA RESTRINGIDO +RED EMPRESARIAL +OPERACIONESNavegadorWAF + Reverse ProxyTURN/SFU opcionalEvolución WebRTCRealtime Relay ARealtime Relay BSession Orchestrator ASession Orchestrator BPolicy EngineMCP GatewaySession StoreTTLAudit StoreInference GatewayPuerto interno 12345Parakeet STT/VADLLaMA 3.1 8BQwen3-TTS 1.7BFP16Voice Vaultcaché cifradaNVIDIA GB10Auth + Agent Profile APIsMCP Servers / AdaptadoresERP / CRM / Datos / WorkflowsOTel CollectorMetrics / Logs / Traces / AlertasSecrets + KMSTLS WS inicialWebRTC futuroWebRTC futuromTLS realtimemTLS realtime
Representación vectorial estática de la vista de despliegue contenida en la versión v2.
+

[REC] El puerto 12345 debe ser interno, protegido por firewall, mTLS y autenticación de servicio; no debe publicarse directamente a Internet. [REC] Relay y Orchestrator se despliegan con al menos dos instancias lógicas en producción. Esto no implica que el MVP necesite alta disponibilidad física desde el primer día, pero los contratos no deben depender de memoria local exclusiva.

+

[BENCH] El GB10 es inicialmente un único dominio de fallo y capacidad. Antes de producción deben definirse: estrategia de nodo de reserva, cola de admisión, límite de sesiones, política de shedding y procedimiento de recuperación de modelos.

+

7. Máquina de estados y gestión de sesión

+

7.1 Estados propuestos

+
Vocero S2S — Máquina de estados de sesiónInicioCONNECTINGAUTHENTICATINGCONFIGURINGLISTENINGTRANSCRIBINGTHINKINGTOOL_PENDINGAWAITING_CONFIRMATIONTOOL_RUNNINGSPEAKINGINTERRUPTINGDEGRADEDCLOSINGFAILEDtransporte listoidentidad válidarechazo o timeoutperfil + voz + tools cargadosdependencia no crítica fallaspeech_startedturn_committedtool_intentacción sensibleconfirmadapermitidatool_resultaudio_first_chunkbarge_insalida detenidaresponse_completedalternativa disponiblecierrecierrecierrecierre
Representación vectorial estática de la máquina de estados contenida en la versión v2.
+

7.2 Identificadores

+
  • connection_id: conexión de transporte; puede cambiar al reconectar.
  • session_id: sesión conversacional estable y opaca.
  • turn_id: entrada comprometida del usuario.
  • generation_id: respuesta del agente; cambia después de cancelar o regenerar.
  • tool_call_id: invocación única; debe ser idempotente cuando la tool lo soporte.
  • voice_profile_id + voice_version: referencia inmutable a la voz autorizada.
  • trace_id: correlación distribuida; no reemplaza identificadores funcionales.
+

7.3 Estado efectivo de sesión

+

[REC] Se calcula en este orden, sin permitir que el cliente incremente privilegios:

  1. defaults seguros de plataforma;
  2. política del tenant;
  3. perfil del agente versionado;
  4. atributos autorizados del usuario;
  5. preferencias permitidas de sesión;
  6. overrides por respuesta explícitamente allowlisted.
+

El sanitizador de session.update elimina o rechaza campos que puedan sustituir voice_profile_id, tools, instrucciones de sistema, límites o scopes. Toda diferencia entre configuración solicitada y efectiva queda en un evento session.configuration_applied con razones codificadas.

[REC] El estado efímero se guarda con TTL y control optimista de versión. La reanudación requiere token opaco de corta vida, misma identidad y comprobación de versión del perfil. Audio no confirmado y trabajos de generación no se reanudan: se cancelan y el cliente vuelve a LISTENING.

+

8. Flujos de ejecución

8.1 Flujo nominal conversacional y MCP

8.2 Tool call con confirmación

+
  1. LLM emite intención, nunca una ejecución directa.
  2. Orchestrator valida nombre contra la lista permitida y el esquema vigente.
  3. Policy Engine clasifica la acción: lectura, escritura reversible, acción irreversible o prohibida.
  4. Si requiere consentimiento, se emite tool.confirmation_required con resumen legible y campos no sensibles.
  5. El usuario confirma; se crea confirmation_id ligado a hash de tool + argumentos + identidad + expiración.
  6. MCP Gateway ejecuta con token delegado de alcance mínimo.
  7. El resultado se valida, limita y clasifica antes de volver al LLM.
  8. Se registra la decisión, no secretos ni payloads sensibles completos.
+

Para la compatibilidad estable 2025-11-25, el adaptador mantiene el lifecycle y la sesión del protocolo de acuerdo con el transporte oficial 5. El RC 2026-07-28 propone eliminar handshake y sesión de protocolo, usar requests autocontenidos, headers de routing y InputRequiredResult; estas capacidades quedan sólo en el adaptador experimental hasta ratificación 4.

+

8.3 Barge-in

Reglas:

  • Un evento de interrupción salta las colas de tokens y audio.
  • Todo chunk posterior de G1 se descarta por generation_id obsoleto.
  • El cursor del cliente es la fuente de verdad del audio reproducido; si falta, se usa el último ACK conservador.
  • Una tool de sólo lectura puede cancelarse; una acción ya confirmada e irreversible no se “deshace” por barge-in. Se informa estado y se evita repetirla.
  • Se aplican AEC y umbrales adaptativos para reducir auto-interrupciones; las interjecciones breves se prueban como caso específico.
+

8.4 Errores y degradación

+
CondiciónAcción automáticaExperiencia de usuarioAuditoría/operación
Auth denegadaCerrar con código estableMensaje de acceso no autorizadoRazón, tenant, hash de sujeto
Perfil no disponibleReintento acotado; fallback sólo si está aprobado“No puedo iniciar el asistente en este momento”Alerta de dependencia
STT no saludablePausar admisión o activar modo texto si existeInformar degradaciónMétrica y circuit breaker
LLM timeoutCancelar generación; una reintento sólo si no hubo tool side effectRespuesta breve seguraSpan con timeout
TTS fallaMantener texto; no sustituir voz sin políticaMostrar texto o pedir reintentoEstado de voz/modelo
MCP timeoutCancelar o marcar estado desconocido“No pude confirmar la operación”No reintentar escrituras sin idempotencia
Respuesta MCP inválidaRechazar payload; no pasarlo al LLMError controladoViolación de schema
Buffer saturadoFrenar productor; descartar parciales obsoletos; nunca perder cancelaciónPosible indicador de redContadores de drop/backpressure
Cliente desconectadoLease corto; cancelar media; conservar mínimo estado reanudableReintento controladoCausa de cierre
GPU sin capacidadRechazo temprano o cola acotada“Servicio ocupado, inténtalo nuevamente”Saturación, cola y shedding
+

9. Contratos y eventos orientativos

[REC] Los siguientes esquemas son orientativos. Deben convertirse en JSON Schema versionado, con pruebas de compatibilidad y límites de tamaño. No representan un contrato final.

9.1 Envelope común

{
+  "specversion": "1.0",
+  "type": "vocero.audio.input.delta.v1",
+  "source": "web-client",
+  "id": "evt_opaque",
+  "time": "RFC3339 timestamp",
+  "subject": "session/{session_id}",
+  "traceparent": "00-...",
+  "data": {
+    "session_id": "ses_opaque",
+    "turn_id": "turn_opaque",
+    "sequence": 42,
+    "payload": {}
+  }
+}
+

9.2 Catálogo mínimo de eventos

Cliente → plataforma

  • session.connect.v1
  • session.update_requested.v1
  • audio.input.delta.v1
  • audio.input.commit.v1
  • input.speech_started.v1
  • output.playout_progress.v1
  • generation.cancel_requested.v1
  • tool.confirmation_submitted.v1
  • session.close.v1

Plataforma → cliente

  • session.configuration_applied.v1
  • transcript.partial.v1
  • transcript.final.v1
  • response.text_delta.v1
  • response.audio_delta.v1
  • response.completed.v1
  • output.stop_and_flush.v1
  • tool.confirmation_required.v1
  • tool.status.v1
  • error.v1
+

9.3 Ejemplo de session.update_requested

{
+  "type": "vocero.session.update_requested.v1",
+  "data": {
+    "session_id": "ses_opaque",
+    "client_capabilities": {
+      "audio_codecs": ["opus", "pcm16"],
+      "supports_playout_ack": true
+    },
+    "preferences": {
+      "locale": "es-CL",
+      "turn_mode": "server_vad"
+    }
+  }
+}

Campos como system_prompt, tools, mcp_credentials, ref_audio, ref_text o voice_profile_id no se aceptan desde este contrato público. Si llegan, el relay los rechaza o elimina según política, y registra la razón.

+

9.4 Ejemplo de intención de tool interna

{
+  "tool_call_id": "tc_opaque",
+  "session_id": "ses_opaque",
+  "turn_id": "turn_opaque",
+  "tool": {
+    "name": "production.report.get",
+    "schema_version": "1.2.0"
+  },
+  "arguments": {
+    "site_id": "site_authorized",
+    "period": "value_from_user_or_context"
+  },
+  "execution": {
+    "mode": "read",
+    "idempotency_key": "idem_opaque",
+    "deadline_ms": "configured_value"
+  },
+  "principal": {
+    "subject_ref": "opaque_subject",
+    "tenant_id": "tenant_opaque",
+    "scopes": ["report:read"]
+  }
+}
+

9.5 Errores

{
+  "type": "vocero.error.v1",
+  "data": {
+    "code": "MCP_TOOL_TIMEOUT",
+    "category": "dependency",
+    "retryable": false,
+    "user_message_key": "tool.temporarily_unavailable",
+    "correlation_id": "corr_opaque",
+    "details_ref": "internal_log_reference"
+  }
+}

Los mensajes públicos no contienen stack traces, endpoints internos, prompts, tokens, argumentos sensibles ni nombres de secretos.

+

9.6 Compatibilidad MCP

McpAdapter
+├── StableAdapter_2025_11_25   # default
+├── CandidateAdapter_2026_07_28 # feature flag, no producción por defecto
+└── ConformanceHarness
+    ├── discovery
+    ├── tools/list
+    ├── tools/call
+    ├── cancellation
+    ├── schema validation
+    ├── auth propagation
+    └── error mapping

La arquitectura oficial de MCP separa una capa de datos JSON-RPC y una capa de transporte, y contempla lifecycle, primitives y notificaciones 6. [REC] Vocero preservará esa separación dentro del adaptador para evitar que detalles de transporte contaminen la lógica conversacional.

Capacidades candidatas que se preparan, pero no se activan como estándar final: el RC 2026-07-28 introduce server/discover, metadatos ttlMs y cacheScope en resultados de listas/lecturas, y headers Mcp-Method/Mcp-Name para routing 4. [REC] El gateway modelará discovery y caché mediante interfaces neutrales: el adaptador estable resolverá su mecanismo según 2025-11-25, mientras que el adaptador candidato podrá mapear esas capacidades nuevas. La caché nunca compartirá resultados más allá del alcance de identidad/tenant permitido y siempre respetará revocación y cambio de política.

+

10. Clonación de voz y gobernanza

10.1 Modelo operativo

[REQ] Qwen3-TTS debe utilizar ref_audio y ref_text para clonación zero-shot y preservar una locución corporativa. El model card de Qwen3-TTS 1.7B Base confirma streaming, soporte de español y clonación rápida desde audio breve 7. La documentación del repositorio describe generación streaming y clonación de voz 8.

Modos a evaluar:

  • Full ICL — recomendado para máxima fidelidad: usa ref_audio y una transcripción ref_text exacta y alineada. [REC] Éste será el modo candidato por defecto para voces corporativas aprobadas.
  • x-vector (x_vector_only_mode=True): permite operar sin ref_text, pero la investigación reporta menor fidelidad de timbre y prosodia. [BENCH] Sólo podrá promoverse para un perfil si supera los umbrales de calidad y riesgo definidos.

[REC] El cliente nunca envía referencias maestras durante una sesión. El perfil de agente contiene sólo voice_profile_id y voice_version; TTS resuelve la referencia desde Voice Vault mediante identidad de servicio. El relay elimina voces estándar propuestas por el cliente y el Orchestrator aplica la voz efectiva autorizada.

+

10.2 Ciclo de vida

  1. Solicitud: propietario de la voz, propósito, tenant, idiomas y periodo de uso.
  2. Consentimiento verificable: documento o evidencia de autorización; revisión legal según jurisdicción.
  3. Ingesta controlada: antivirus, normalización, evaluación de ruido y transcripción alineada.
  4. Evaluación: fidelidad, inteligibilidad, pronunciación, estabilidad y riesgo de suplantación.
  5. Aprobación de cuatro ojos: dueño de negocio más seguridad/legal o roles equivalentes.
  6. Versionado: hash de ref_audio, hash de ref_text, modelo, parámetros y fecha de vigencia.
  7. Publicación: sólo perfiles aprobados pueden usarse en producción.
  8. Monitoreo: uso por tenant/agente, anomalías y reclamaciones.
  9. Revocación: bloqueo inmediato de nuevas sesiones; rotación de caché; evidencia de eliminación.
+

10.3 Controles

  • Cifrado en tránsito y reposo con claves administradas.
  • Separación por tenant y entorno; no copiar voces reales a desarrollo.
  • URLs firmadas de corta vida o lectura directa por identidad de workload.
  • Registro de acceso sin incluir el audio en logs.
  • Marcas de procedencia o disclosure cuando la política corporativa o regulación lo exija.
  • Pruebas de abuso: texto ofensivo, fraude, ingeniería social, extracción de referencia y uso cruzado entre tenants.
  • [BENCH] Comparar Full ICL con ref_audio + ref_text frente a modo sin texto; no asumir equivalencia de calidad.
  • [BENCH] Revisión jurídica de licencias de código y pesos antes de uso comercial; la evidencia recopilada no debe sustituir dictamen legal.
+

11. Seguridad

11.1 Vista de zonas y confianza

11.2 Amenazas principales y mitigaciones

+
AmenazaMitigaciones recomendadas
Suplantación de usuarioOIDC/OAuth, tokens efímeros, validación de issuer/audience, reautenticación para acciones sensibles
Prompt injection desde usuario o tool resultSeparación de instrucciones, allowlist de tools, validación de salida, etiquetado de contenido no confiable, autorización fuera del LLM
Tool poisoning o schema maliciosoRegistro de servidores aprobados, pinning de versión/hash, revisión de schema, límites de profundidad/tamaño, caché con invalidación segura
Confused deputyPropagar sujeto y tenant; token exchange; scopes por tool; comprobación de recurso en backend
Exfiltración por toolEgress allowlist, DLP/clasificación, minimización de respuesta, límites y redacción antes del contexto
Repetición de escrituraIdempotency key, confirmación ligada a hash, ledger de ejecución, no reintentar estado desconocido
Robo de vozVault, cifrado, aislamiento, consentimiento, revocación, sin descarga al navegador
Secuestro/replay de sesiónTokens de conexión cortos, nonce, expiración, binding a identidad, límites de reconexión
DoS de audio/GPUCuotas, límites de bitrate/chunk, admission control, colas acotadas, circuit breakers, shedding
Fuga por logs/tracesRedacción, clasificación de atributos, sampling, retención limitada, acceso RBAC
Movimiento lateralSegmentación, mTLS, identidades de workload, deny-by-default y egress controlado
+

11.3 Autorización de tools

La decisión se evalúa sobre:

permit = policy(
+  tenant,
+  principal,
+  agent_profile_version,
+  tool_name,
+  tool_schema_version,
+  action_class,
+  resource_scope,
+  argument_classification,
+  confirmation,
+  session_risk
+)

El LLM puede proponer tool_name y arguments, pero no principal, scopes, credenciales, clasificación de acción ni resultado de política. [REC] Las acciones irreversibles requieren confirmación humana específica o una excepción corporativa documentada.

+

11.4 Datos y retención

Definir una matriz por clase:

  • audio bruto;
  • referencias de voz;
  • transcripción;
  • prompt/contexto;
  • respuesta de modelo;
  • argumentos y resultados de tools;
  • telemetría y auditoría.

Para cada clase deben aprobarse propósito, base legal, ubicación, cifrado, retención, borrado, exportación, acceso, uso para entrenamiento y redacción. Pendiente: el solicitante no proporcionó políticas corporativas ni jurisdicciones; por tanto, no se fijan plazos de retención en este documento.

+

12. Observabilidad y SLOs iniciales

12.1 Modelo de telemetría

Trazas: una traza por turno con spans de captura, relay, STT, endpointing, LLM, policy, MCP, TTS, red y playout. El RC futuro propone nombres estandarizados para propagar W3C Trace Context en _meta, pero esa forma se tratará como candidata hasta ratificación 4. En el adaptador estable, Vocero propagará traceparent mediante metadata interna compatible sin exigir cambios futuros al dominio.

Métricas de experiencia

  • turn_endpointing_ms: fin acústico a commit de turno.
  • stt_partial_ms y stt_final_ms.
  • llm_ttft_ms: commit a primer token.
  • tts_ttfa_ms: texto disponible a primer audio.
  • ttfs_ms: fin de habla a primer sonido reproducible.
  • barge_in_stop_ms: detección de interrupción a silencio efectivo.
  • tool_latency_ms por tool y outcome.
  • turn_e2e_ms por ruta directa o con tool.
  • tasa de reconexión, cancelación, falsos barge-in y abandono.

TTFT y latencia extremo a extremo se reconocen como dimensiones distintas y relevantes para cargas de voz/tiempo real 9.

Métricas de calidad

  • WER/CER por idioma, acento, ruido y dominio.
  • tasa de endpointing temprano/tardío.
  • inteligibilidad y evaluación humana de naturalidad.
  • similitud de locutor y estabilidad de voz.
  • exactitud de tool selection, argumentos, política y task completion.

Métricas de recursos

  • utilización y memoria GPU; KV cache; colas por modelo; tokens/s; audio seconds/s.
  • profundidad y edad de buffer; drops; retransmisiones; jitter; playout underruns.
  • conexiones activas, sesiones admitidas/rechazadas y errores por dependencia.
+

12.2 Objetivos iniciales — no son desempeño observado

[REC/BENCH] Estos valores son objetivos provisionales de ingeniería, no cifras medidas, garantías ni SLO contractual. Deben ratificarse después del benchmark con audio, red, herramientas y concurrencia reales.

+
IndicadorObjetivo inicial propuestoVentana/percentilCondición de ratificación
Disponibilidad del servicio conversacional≥ 99,5 %MensualExcluir ventanas acordadas y definir dependencia GPU
Inicio de audio de respuesta ttfs_ms≤ 1.200 msp95, turno directoRed corporativa objetivo y sesión caliente
Detención por barge-in≤ 300 msp95Medido hasta silencio de playout, no sólo ACK
Turnos sin error de plataforma≥ 99,0 %MensualSeparar error de dependencia y entrada inválida
Ejecuciones MCP de lectura aceptadas por gateway≥ 99,0 %MensualExcluir rechazo de política y timeout downstream
Pérdida de eventos de control aceptados0Toda ventanaVerificada por secuencia y ACK
Uso de voz no autorizada0Toda ventanaAlerta y auditoría obligatorias

Presupuesto provisional de ttfs_ms para análisis, no compromiso:

endpointing + STT final + scheduling + LLM TTFT + TTS primer audio + transporte/playout

No se asignan milisegundos fijos por etapa hasta contar con benchmark; el tablero debe mostrar contribución y percentiles por separado.

+

12.3 Alertas iniciales

  • Burn-rate de disponibilidad y turnos exitosos.
  • Crecimiento sostenido de cola o edad de buffer.
  • GPU OOM, reinicio de modelo o health degradado.
  • Divergencia entre generation.cancel y último chunk emitido.
  • Error/latencia MCP por tool, tenant y dependencia.
  • Accesos denegados o anomalías de voz.
  • Ausencia de trazas o cardinalidad no controlada.
+

13. Escalabilidad, backpressure y resiliencia

13.1 Escalado

  • Edge/Relay: horizontal por conexiones; estado mínimo; drenaje antes de deploy.
  • Orchestrator: horizontal por sesión con lease en store; afinidad opcional como optimización, no requisito de corrección.
  • MCP Gateway: horizontal; pools por downstream; bulkheads por tenant/tool.
  • Inference: admission control basado en capacidad real, no sólo CPU; colas separadas STT, LLM y TTS; prioridades de cancelación y audio activo.
  • Voice cache: caché cifrada y acotada por voces aprobadas; invalidación por versión/revocación.
+

13.2 Backpressure

[REC] Cada enlace publica high_watermark, low_watermark y política de overflow. El LLM puede producir texto más rápido de lo que TTS o la red consumen; el sistema debe frenar tokens, agrupar por unidades pronunciables y limitar audio anticipado. El problema general de productores rápidos y consumidores lentos está documentado en pipelines realtime 10.

Políticas:

  1. Eventos de control nunca comparten una cola bloqueante con audio.
  2. Parciales STT obsoletos pueden coalescerse; finales y commits no.
  3. Tokens no sintetizados de una generación cancelada se descartan.
  4. Audio generado pero no reproducido se descarta al interrumpir.
  5. Una conexión lenta no puede consumir memoria sin límite.
  6. El cliente reporta buffered_ms y playout; el servidor ajusta chunking o pausa.
  7. Las colas tienen deadline; trabajo vencido no se procesa “por completitud”.
+

13.3 Resiliencia

  • Timeouts por etapa y deadline total propagado.
  • Circuit breakers por dependencia y modelo.
  • Retries con jitter sólo para operaciones idempotentes.
  • Bulkheads por tenant y tool para evitar fallos en cascada.
  • Health de proceso, readiness de modelo y señal de capacidad diferenciados.
  • Warm-up verificado antes de recibir sesiones.
  • Deploy con drenaje de conexiones y cancelación segura.
  • Snapshots de configuración y rollback de perfiles/modelos.
  • Runbooks para GPU OOM, degradación STT/TTS, MCP lento, store no disponible y compromiso de voz.

[BENCH] La posibilidad de alojar simultáneamente toda la cadena en un GB10, y el número de sesiones sostenibles, deben medirse. Parakeet TDT 0.6B v3 dispone de soporte de streaming documentado y timestamps 11; esto valida la capacidad funcional, no la capacidad concurrente del sistema completo.

+

14. Plan de implementación por fases

Las fases no incluyen fechas ni duraciones porque no se entregaron disponibilidad de equipo, ambientes, procurement ni ventanas de cambio. Cada fase termina con una decisión explícita de continuar, corregir o detener.

+

Fase 0 — Descubrimiento, contratos y riesgos

Entregables

  • Inventario de requisitos funcionales, datos, tenants, roles, herramientas y acciones sensibles.
  • ADRs iniciales y modelo de amenazas.
  • Catálogo de eventos v1 y esquemas de audio/control.
  • Matriz de licencias y gobernanza de voz.
  • Definición del corpus de benchmark y plan de medición.

Dependencias

  • Acceso a APIs de auth/profile y definición de MCP del cliente.
  • Responsables de seguridad, legal, negocio y operación.
  • Hardware GB10 y artefactos versionados de modelos.

Criterios de aceptación

  • Requisitos etiquetados y aprobados; pendientes con dueño.
  • Ningún secreto ni voz real requerido para desarrollo temprano.
  • ADR-001 a ADR-006 en estado Proposed o Accepted.
  • Amenazas críticas con control y prueba asociada.

Riesgos

  • Ambigüedad de identidad/roles.
  • Licencias o consentimiento de voz no resueltos.
  • Tools MCP sin idempotencia o clasificación de riesgo.
+

Fase 1 — Vertical slice local sin tools de escritura

Entregables

  • Cliente web + WebSocket, Relay, Orchestrator e Inference Gateway.
  • STT → LLM → TTS streaming con una voz de prueba autorizada.
  • Máquina de estados, IDs, cancelación y logs estructurados.
  • Simuladores deterministas de STT/LLM/TTS para CI.

Dependencias

  • Modelos cargables y runtime compatible.
  • Certificados de servicio y entorno aislado.

Criterios de aceptación

  • Conversación nominal reproducible.
  • Reconexión no duplica turnos.
  • Cancelar una generación impide chunks tardíos en playout.
  • La voz solicitada por cliente no reemplaza la voz efectiva.
  • Primera línea base de latencia y recursos registrada.

Riesgos

  • Contención de GPU.
  • Latencia de endpointing.
  • Inestabilidad del runtime o incompatibilidad FP16.
+

Fase 2 — Barge-in, backpressure y sesión robusta

Entregables

  • Cola de control prioritaria y output.stop_and_flush.
  • Playout ACK/cursor; truncamiento de historial.
  • Límites de buffer, shedding, deadlines y circuit breakers.
  • Pruebas con ruido, eco, interjecciones y red degradada.

Dependencias

  • Instrumentación por etapa.
  • Dataset de conversaciones y condiciones acústicas representativas.

Criterios de aceptación

  • Cero audio de generación obsoleta después del límite aprobado de interrupción.
  • Sin crecimiento no acotado de memoria con cliente lento.
  • Sin doble respuesta después de reconexión/cancelación.
  • Falsos barge-in medidos y dentro del umbral que defina negocio.

Riesgos

  • Eco que dispara VAD.
  • Cursor de playout impreciso.
  • Races entre cancelación, TTS y entrega de red.
+

Fase 3 — Gateway MCP de lectura

Entregables

  • StableAdapter_2025_11_25.
  • Discovery allowlisted, validación de schemas y tools/call de lectura.
  • Policy Engine, identidad delegada, límites, auditoría e idempotencia.
  • Servidor MCP simulado y pruebas de contrato.

Dependencias

  • Servidor MCP del cliente y esquema de autorización.
  • Clasificación de tools y datos.

Criterios de aceptación

  • Tool no allowlisted es rechazada antes del downstream.
  • Argumentos y resultados inválidos no alcanzan LLM/sistemas.
  • Cada tool call se correlaciona con usuario, sesión, turno y política.
  • Timeouts no causan retries inseguros.

Riesgos

  • Prompt injection en resultados.
  • Credenciales con exceso de privilegios.
  • Contratos downstream inestables.
+

Fase 4 — Escrituras, confirmación y gobernanza de voz

Entregables

  • Flujos de confirmación ligados a hash e idempotency key.
  • Ledger de acciones y estado desconocido.
  • Voice Vault, workflow de aprobación, revocación y caché segura.
  • Pruebas de abuso y runbooks de incidente.

Dependencias

  • Aprobación legal de voces/licencias.
  • Semántica de idempotencia en sistemas empresariales.

Criterios de aceptación

  • Ninguna acción sensible se ejecuta sin política/confirmación requerida.
  • Repetición de evento no repite side effect.
  • Revocar una voz bloquea nuevas síntesis y limpia cachés.
  • Acceso cruzado entre tenants falla y queda auditado.

Riesgos

  • Acciones no idempotentes.
  • Consentimiento incompleto.
  • Exfiltración o uso indebido de voz.
+

Fase 5 — Preproducción, SLO y resiliencia

Entregables

  • Pruebas de carga, soak, caos y recuperación.
  • Dashboards, alertas, SLOs ratificados y runbooks.
  • Capacity model y límites de admisión.
  • DR/backup para configuración, auditoría y Voice Vault.
  • Revisión de seguridad y go-live checklist.

Dependencias

  • Tráfico/perfiles representativos.
  • Entorno equivalente a producción.
  • On-call y ownership definidos.

Criterios de aceptación

  • Objetivos de experiencia ratificados o renegociados con evidencia.
  • Recuperación de fallos críticos demostrada.
  • Ninguna vulnerabilidad crítica abierta.
  • Operación puede detectar, diagnosticar y mitigar escenarios de runbook.

Riesgos

  • Único GB10 como SPOF.
  • Capacidad insuficiente bajo mezcla real.
  • Diferencias entre red de prueba y campo.
+

Fase 6 — Despliegue progresivo y evolución

Entregables

  • Canary por usuarios/tenant y rollback automático.
  • Revisión post-piloto con calidad, latencia, seguridad y task completion.
  • Roadmap WebRTC si WebSocket no satisface objetivos.
  • CandidateAdapter_2026_07_28 sólo si la especificación fue ratificada.

Criterios de aceptación

  • Canary sin regresión en SLOs/seguridad.
  • Soporte y escalamiento operativo validados.
  • Migración MCP pasa conformance suite y ADR de promoción.
+

15. Estrategia de pruebas

15.1 Pirámide

Unitarias

  • máquina de estados, sanitización, policy, mapping de errores, deduplicación, expiración y redacción.

Contrato

  • JSON Schema de eventos, compatibilidad hacia atrás, adaptadores MCP, APIs de perfil e identidad.

Integración

  • STT/LLM/TTS reales; MCP simulado y real; Voice Vault; KMS; stores.

End-to-end

  • audio a audio con playout medido, rutas directas, tools, confirmación, cancelación y reconexión.

Carga y soak

  • sesiones concurrentes, mezcla de turnos, audio largo, cliente lento, tools lentas, churn de conexiones y modelos calientes/fríos.

Caos

  • proceso muerto, GPU OOM, store lento, pérdida de red, respuesta tardía, certificado vencido, dependencia parcial y restart durante tool call.

Seguridad

  • authn/authz, tenant isolation, replay, prompt injection, schema bombs, SSRF, egress, secretos, DLP, abuso de voz y supply chain.
+

15.2 Matriz mínima de escenarios

EjeCasos
Audiosilencio, ruido, eco, clipping, bitrate variable, micrófono distante
Hablaacentos objetivo, velocidad, números, nombres propios, términos de dominio
Turnospausa larga, solapamiento, backchannel, interrupción, push-to-talk
Redlatencia, jitter, pérdida, reconexión, cliente lento
Toolséxito, vacío, inválido, timeout, 4xx/5xx, side effect confirmado, estado desconocido
Modeloscold start, OOM, respuesta tardía, token/audio excesivo, cancelación
Seguridadtool no permitida, scope insuficiente, inyección, acceso cruzado, voz revocada
+

15.3 Oráculos y reproducibilidad

  • Audio de prueba versionado, con consentimiento y etiquetas.
  • Fakes deterministas para carreras y errores.
  • Semillas/parámetros de generación registrados donde aplique.
  • Golden transcripts y schemas, pero tolerancias acústicas definidas.
  • Captura de timestamps monotónicos en cada frontera.
  • Reporte por percentiles y condición, no sólo promedio.
  • Comparación A/B de configuraciones de VAD, chunking y voz.
+

16. Matriz de decisiones y ADRs

ADRDecisiónEstado inicialAlternativasEvidencia para aceptar
ADR-001Cadena desacoplada STT → LLM → TTSAccepted por requisitoModelo S2S nativoBenchmark y control por componente
ADR-002WebSocket inicial; WebRTC como evoluciónProposedWebRTC desde MVP; ambosPrueba de red y complejidad operativa
ADR-003Separar Relay y Session OrchestratorProposedServicio monolíticoPerfil de carga, aislamiento y mantenibilidad
ADR-004MCP estable 2025-11-25 + adaptadores versionadosProposedAdoptar RC futuroRatificación, conformidad y compatibilidad
ADR-005Voice Vault y referencias server-sideProposedEnviar ref_audio por sesiónThreat model, latencia de caché y gobernanza
ADR-006Estado efímero externo con TTL y leasesProposedMemoria local/sticky sessionsRecuperación y carga
ADR-007Control queue prioritaria para barge-inProposedCola única FIFOPruebas de carrera y objetivo de interrupción
ADR-008Policy Engine fuera del LLMProposedPrompt-only controlsPruebas de bypass y auditoría
ADR-009Confirmación + idempotencia para escriturasProposedEjecución inmediataSemántica downstream y UX
ADR-010Un GB10 en MVP con admission controlProposedCluster desde inicioBenchmark de capacidad/costo/riesgo
ADR-011OpenTelemetry end-to-endProposedLogs no correlacionadosCobertura de trazas y costo/cardinalidad
ADR-012Contratos de evento v1 versionadosProposedPayloads ad hoc del proveedorPruebas de compatibilidad

Cada ADR debe incluir contexto, decisión, alternativas, consecuencias, riesgos, estado y condición de revisión. Las decisiones reversibles pueden iniciarse como Proposed; las que afectan seguridad, datos, licencias o compatibilidad externa requieren aprobación explícita.

+

17. Pendientes y preguntas abiertas

Producto y experiencia

  • ¿Qué canales se incluyen: sólo browser, móvil, telefonía o todos?
  • ¿Qué idiomas, acentos, vocabulario y condiciones acústicas son prioritarios?
  • ¿Qué comportamiento se espera al fallar voz: texto, voz alternativa o cierre?
  • ¿Qué acciones requieren confirmación verbal, visual o autenticación reforzada?

Identidad y autorización

  • Contrato real de /api/v1/auth/user-by-phone y mecanismo de autenticación.
  • Modelo de roles, tenants, scopes y separación de datos.
  • Token exchange/delegación hacia MCP y sistemas downstream.

MCP

  • Inventario de servidores, tools, schemas, side effects, idempotencia y SLO downstream.
  • Versión estable realmente soportada por el servidor del cliente.
  • Estrategia de cache/refresh de discovery.
  • Ratificación futura de 2026-07-28 y disponibilidad de SDK/conformance suite.

Inferencia y capacidad

  • Runtime exacto para LLaMA, asignación de memoria y política de KV cache.
  • Concurrencia sostenible de STT + LLM + TTS en GB10.
  • Efecto de context length, prompt del agente y tool results sobre TTFT.
  • Cold start, carga de modelos, fragmentación y recuperación OOM.
  • Calidad de Parakeet en audio y dominio reales.
  • Titular y evidencia de consentimiento de cada voz.
  • Licencia aplicable a pesos, código, redistribución y uso comercial.
  • Reglas de disclosure, retención, localización y derecho de revocación.
  • Métrica y umbral de aceptación de fidelidad/seguridad.

Operación

  • Ambientes, topología, certificados, secretos, KMS, stores y backups.
  • RTO/RPO y modelo on-call.
  • Política de logs, sampling, redacción y retención.
  • Capacidad de nodo de reserva o alternativa de servicio.
+

18. Riesgos consolidados

RiesgoProbabilidad inicialImpactoTratamiento
Cadena completa excede capacidad del GB10DesconocidaAltoBenchmark temprano; admission control; opciones de separación
Barge-in tardío o falsoDesconocidaAltoCola prioritaria, AEC, playout ACK, corpus de ruido
MCP permite acción indebidaMedia hasta probarCríticoPolicy externa, scopes, confirmación, auditoría, idempotencia
Compromiso o uso no autorizado de vozMedia hasta gobernarCríticoVoice Vault, consentimiento, aislamiento, revocación
Dependencia de versión MCP candidataEvitableAltoStable adapter por defecto y feature flag
Tool result inyecta instruccionesMediaAltoTratar contenido como no confiable, sanitizar, limitar, evaluar
Estado inconsistente tras reconexiónMediaAltoIDs, versionado, leases, dedupe y cancelación
Observabilidad filtra PIIMediaAltoRedacción, allowlist de atributos, acceso y retención
Único nodo de inferencia fallaAlta en topología inicialAltoRunbook, spare/failover, degradación y comunicación
Licencia/consentimiento incompletoDesconocidaCríticoGate legal antes de producción

Las probabilidades no numéricas reflejan ausencia de datos; deben actualizarse después de pruebas y revisión organizacional.

+

19. Criterio de arquitectura lista para piloto

La solución está lista para piloto sólo cuando:

  1. los requisitos y ADRs críticos están aprobados;
  2. la ruta estable MCP está probada y la candidata futura permanece aislada;
  3. el benchmark extremo a extremo demuestra capacidad para la carga piloto;
  4. barge-in y backpressure pasan pruebas acústicas y de red;
  5. tools sensibles tienen política, confirmación e idempotencia;
  6. la voz está autorizada, versionada y revocable;
  7. seguridad no mantiene hallazgos críticos abiertos;
  8. tableros, alertas y runbooks permiten operar el servicio;
  9. SLOs provisionales fueron ratificados o sustituidos con evidencia;
  10. existe rollback probado de aplicación, perfil, modelo y configuración.
+

20. Fuentes

  1. [1] OpenAI — Realtime and audio — https://developers.openai.com/api/docs/guides/realtime
  2. [2] Microsoft Azure — Realtime audio — https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/realtime-audio
  3. [3] LiveKit — Turns overview — https://docs.livekit.io/agents/logic/turns/
  4. [4] Model Context Protocol — 2026-07-28 Release Candidate — https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/
  5. [5] Model Context Protocol — Transport specification 2025-11-25 — https://modelcontextprotocol.io/specification/2025-11-25/basic/transports
  6. [6] Model Context Protocol — Architecture overview — https://modelcontextprotocol.io/docs/learn/architecture
  7. [7] Qwen — Qwen3-TTS 12Hz 1.7B Base model card — https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-Base
  8. [8] QwenLM — Qwen3-TTS repository — https://github.com/QwenLM/Qwen3-TTS
  9. [9] Telnyx — Inference Benchmark: TTFT vs E2E Latency — https://telnyx.com/resources/inference-benchmark-ttft-vs-e2e
  10. [10] Arun Baby — Real-Time Agent Pipelines — https://www.arunbaby.com/ai-agents/0016-real-time-agent-pipelines/
  11. [11] NVIDIA — Parakeet TDT 0.6B v3 model card — https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3
+

Apéndice A — Checklist de implementación

  • [ ] Requisitos [REQ], recomendaciones [REC] y supuestos [BENCH] revisados.
  • [ ] ADRs críticos aprobados.
  • [ ] Modelos y hashes fijados; SBOM y licencias revisadas.
  • [ ] Identidad, roles, scopes y tenants definidos.
  • [ ] Event schemas v1 publicados y contract-tested.
  • [ ] Stable MCP adapter probado contra 2025-11-25.
  • [ ] Candidate MCP adapter deshabilitado por defecto.
  • [ ] Voice Vault, consentimiento, rotación y revocación operativos.
  • [ ] Admission control y límites de buffer configurados.
  • [ ] Barge-in medido hasta playout real.
  • [ ] Tool confirmation e idempotencia verificadas.
  • [ ] Telemetría redactada y correlacionada.
  • [ ] SLOs ratificados con benchmark.
  • [ ] Runbooks y rollback ensayados.
  • [ ] Revisión de seguridad y go-live completadas.
+
+ + + \ No newline at end of file diff --git a/demo/requirements.txt b/demo/requirements.txt new file mode 100644 index 0000000..d7ebba4 --- /dev/null +++ b/demo/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +httpx==0.28.1 +python-dotenv==1.2.2 +# [oauth] extra pulls authlib + itsdangerous for attach_huggingface_oauth. +huggingface_hub[oauth]>=0.30,<1.0 diff --git a/demo/server.py b/demo/server.py new file mode 100644 index 0000000..e19b6de --- /dev/null +++ b/demo/server.py @@ -0,0 +1,2314 @@ +""" +Tiny server for the speech-to-speech demo. + +The demo used to ship as a `sdk: static` Space, but the web-search tool needs a +search key the browser must NOT see. A static Space has no runtime process, so it +can't hold a secret the front-end uses. This server fixes that: it serves the +unchanged front-end AND exposes a same-origin `/api/search` proxy that holds the +Serper key server-side (see docs/adr/0001). + +Everything lives in one container; the speech-to-speech backend stays a separate, +load-balanced service the browser talks to over WebSocket as before. The load +balancer's address is a secret too (like the Serper key): the browser never sees +it. `/api/session` proxies the session handshake server-side so only the +per-session compute URL the LB hands back (which the browser must dial) is exposed. + +On the deployed Space the server also meters conversation time by HF login tier +(anonymous / signed-in / PRO) — see `limiter.py` and `auth.py`. That whole feature +is off unless BOTH `LOAD_BALANCER_URL` and `SPACE_ID` are set, so it runs only on +the live Space, never locally (even with the LB exported for testing). + +`SPEECH_TO_SPEECH_URL` overrides everything: when set, the LB logic above is +disabled entirely (no session proxy, no queue, no metering, no sign-in) and the +browser connects directly to that URL, shown read-only in Settings. + +Endpoints: + GET /api/config -> { search, lb, allowDirect, s2sUrl, auth } + GET /api/me -> login + tier + remaining budget (LB mode only) + POST /api/search -> { results, answer } Google via Serper.dev + POST /api/session -> proxies /session: a grant, or a queue ticket + GET /api/queue/{id} -> proxies /queue/{id}: position, or a grant on claim + DELETE /api/queue/{id} -> leave the queue (explicit "Leave queue" button) + POST /api/queue/end -> leave the queue (sendBeacon on teardown) + POST /api/session/heartbeat-> extend the reservation; { expired } + POST /api/session/end -> reconcile + refund (sendBeacon on teardown) + /* -> static files (index.html, main.js, ...) + +When every compute slot is busy the load balancer hands back a queue ticket +instead of a grant; the browser polls /api/queue/{id} until it reaches the front +and a slot frees. Waiting reserves nothing — the daily budget is only reserved at +the moment a slot is actually claimed (a grant), never while queued. +""" + +import asyncio +from datetime import datetime, timezone +import json +import logging +import os +import re +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import httpx +import websockets +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +import auth +import limiter + +logger = logging.getLogger("s2s") +_logging_configured = False +if not _logging_configured: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter( + "%(asctime)s %(levelname)-7s [%(name)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + )) + logging.basicConfig(level=logging.DEBUG, handlers=[_handler], force=True) + _lvl_env = (os.environ.get("LOG_LEVEL") or os.environ.get("S2S_LOG_LEVEL") or "INFO").strip().upper() + _is_debug = _lvl_env in {"DEBUG", "TRACE", "10"} + # Logger rumorosi: a meno che LOG_LEVEL=debug esplicito, tienili quiet. + logging.getLogger("httpx").setLevel(logging.DEBUG if _is_debug else logging.WARNING) + logging.getLogger("websockets").setLevel(logging.DEBUG if _is_debug else logging.INFO) + logging.getLogger("uvicorn").setLevel(logging.DEBUG if _is_debug else logging.INFO) + logging.getLogger("uvicorn.access").setLevel(logging.DEBUG if _is_debug else logging.INFO) + logging.getLogger("httpcore").setLevel(logging.DEBUG if _is_debug else logging.WARNING) + logger.setLevel(logging.DEBUG if _is_debug else logging.DEBUG) # s2s sempre almeno DEBUG in dev + _logging_configured = True + logger.debug( + "LOG_LEVEL env=%s — noisy-logger levels: httpx=%s uvicorn=%s websockets=%s", + _lvl_env, + logging.getLevelName(logging.getLogger("httpx").level), + logging.getLevelName(logging.getLogger("uvicorn").level), + logging.getLevelName(logging.getLogger("websockets").level), + ) + +ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +load_dotenv(os.path.join(ROOT_DIR, ".env"), override=False) +logger.debug("Loaded .env from %s", ROOT_DIR) + + +def _normalize_reasoning_type(value: str) -> str: + normalized = (value or "").strip().strip('"').strip("'").replace("-", "_").upper() + if normalized == "OPENAI": + return "OPENAI" + if normalized in {"NODE_RED", "NODERED"}: + return "NODE_RED" + return "PROXY" + +SERPER_KEY = os.environ.get("SERPER_API_KEY", "").strip() +S2S_PRIMARY_PROMPT = os.environ.get("S2S_PRIMARY_PROMPT", "").strip() +if S2S_PRIMARY_PROMPT: + S2S_PRIMARY_PROMPT = S2S_PRIMARY_PROMPT.replace("\\n", "\n") +S2S_AGENT_PROFILE = os.environ.get("S2S_AGENT_PROFILE", "").strip() +S2S_TOOL_EXECUTION_MCP_URL = os.environ.get("S2S_TOOL_EXECUTION_MCP_URL", "").strip() +S2S_TOOL_EXECUTION_MCP_API_KEY = os.environ.get("S2S_TOOL_EXECUTION_MCP_API_KEY", "").strip() +S2S_TOOL_EXECUTION_MCP_API_SECRET = os.environ.get("S2S_TOOL_EXECUTION_MCP_API_SECRET", "").strip() +# ⚙️ MASTER SWITCH MCP EXECUTION: Disabilita TUTTA la funzionalità MCP quando +# S2S_TOOL_EXECUTION_MCP_URL non è presente / è commentata / è vuota in .env. +# - Non viene caricato il catalogo 79 tool dal profilo agente Aquachile +# - Non viene injettato tools[] / tool_choice nel session.update WS +# - POST /api/mcp/tool risponde 503 con log [MCP DISABLED] +MCP_EXEC_ENABLED = bool(S2S_TOOL_EXECUTION_MCP_URL) +VOCERO_USER_PHONE_AUTH_URL = os.environ.get( + "VOCERO_USER_PHONE_AUTH_URL", + os.environ.get("S2S_USER_PHONE_AUTH_URL", ""), +).strip() +S2S_INSIGHTS_URL = os.environ.get("S2S_INSIGHTS_URL", "").strip() +# Credenziali opzionali per endpoint insights (stesso pattern di MCP execution; +# endpoint Aquachile attualmente è pubblico ma lasciamo predisposto per auth futura). +S2S_INSIGHTS_API_KEY = os.environ.get("S2S_INSIGHTS_API_KEY", "").strip() or os.environ.get("S2S_TOOL_EXECUTION_MCP_API_KEY", "").strip() +S2S_INSIGHTS_API_SECRET = os.environ.get("S2S_INSIGHTS_API_SECRET", "").strip() or os.environ.get("S2S_TOOL_EXECUTION_MCP_API_SECRET", "").strip() +# 🧠 Master switch per caricamento insights diario all'inizio della sessione. +# Se S2S_INSIGHTS_URL è valorizzato → GET oggi e append blocco a instructions agente. +INSIGHTS_ENABLED = bool(S2S_INSIGHTS_URL) +# Numero massimo di insights serializzati nel prompt (evita esplosione contesto). +MAX_INSIGHTS_IN_CONTEXT = 20 +S2S_SEARCH_MCP = os.environ.get("S2S_SEARCH_MCP", "").strip().lower() == "true" +S2S_REASONING_TYPE = _normalize_reasoning_type(os.environ.get("S2S_REASONING_TYPE", "PROXY")) +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip() +OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").strip().rstrip("/") +OPENAI_REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-4.1-mini").strip() +OPENAI_ORGANIZATION = os.environ.get("OPENAI_ORGANIZATION", "").strip() +OPENAI_PROJECT = os.environ.get("OPENAI_PROJECT", "").strip() +# Node-RED reasoning engine endpoint (modalità NODE_RED). +# Es.: http://localhost:1881 oppure http://s2s-reasoning:1880 (rete docker) +NODE_RED_REASONING_URL = os.environ.get("S2S_REASONING_NODE_RED_URL", "").strip().rstrip("/") +# Speech-to-speech load balancer URL. When set, the browser POSTs /api/session +# (which proxies /session here, server-side) and connects to the URL the LB +# returns (the original flow). The LB address itself is never sent to the browser. +# When empty, the user may instead set a direct s2s server URL in Settings and the +# browser connects to it straight (no load balancer). +LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip() +# Direct s2s server URL pinned by the deploy. Takes priority over the load +# balancer: when set, ALL LB logic is disabled (no /api/session proxy, no queue, +# no limiter, no sign-in) and the browser connects to this URL directly. Unlike +# the LB address it is NOT a secret — /api/config sends it to the client, which +# shows it read-only in Settings. +SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip() +# Optional same-origin WebSocket relay target. When set, the browser dials this +# app over ws(s):///v1/realtime and the server relays frames to the +# upstream speech-to-speech endpoint, keeping the internal host private. +S2S_PROXY_TARGET = os.environ.get("S2S_PROXY_TARGET", "").strip() +if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET: + LOAD_BALANCER_URL = "" +# HF injects SPACE_ID ("owner/space") into every Space runtime; it's absent +# locally and on a plain `docker run`. We meter conversation time ONLY on the +# deployed Space — i.e. when BOTH the LB is configured AND we're on a Space. +# Off-Space (local dev, even with the LB exported) the app still proxies the LB, +# but nothing is metered: no budget, no reservations, no sign-in gating. +SPACE_ID = os.environ.get("SPACE_ID", "").strip() +LIMITER_ENABLED = bool(LOAD_BALANCER_URL) and bool(SPACE_ID) +SERPER_URL = "https://google.serper.dev/search" +# Cap results so the tool output stays small enough to feed back to the model. +MAX_RESULTS = 5 +HERE = os.path.dirname(os.path.abspath(__file__)) + +app = FastAPI(title="s2s-demo") + +# Wire HF OAuth before the app serves (no-op unless the OAuth env is present). +# Sign-in only matters when we're metering (prod Space), so gate it on that. +AUTH_ENABLED = LIMITER_ENABLED and auth.attach(app) + +logger.debug( + "[STARTUP] Aquachile endpoints configured — profile=%s mcp=%s userAuth=%s powerbiCatalog=%s", + bool(S2S_AGENT_PROFILE), + MCP_EXEC_ENABLED, + bool(VOCERO_USER_PHONE_AUTH_URL), + bool(os.environ.get("S2S_PRELOAD_DATA_ENDPOINT", "").strip()), +) +if MCP_EXEC_ENABLED: + logger.info( + "[MCP MASTER] ENABLED — S2S_TOOL_EXECUTION_MCP_URL=%s; tool catalog will be injected via WS session.update tools[] and /api/mcp/tool proxy is active.", + S2S_TOOL_EXECUTION_MCP_URL, + ) +else: + logger.warning( + "[MCP MASTER] ⛔ DISABLED — S2S_TOOL_EXECUTION_MCP_URL is empty/commented in .env. " + "Tool execution is OFF: no tools[] injection in WS session.update, /api/mcp/tool returns 503. " + "To enable, uncomment or set S2S_TOOL_EXECUTION_MCP_URL and restart the container.", + ) +if INSIGHTS_ENABLED: + logger.info( + "[INSIGHTS MASTER] ENABLED — S2S_INSIGHTS_URL=%s. At every new WS realtime session we will GET today's insights (start_date=end_date=YYYY-MM-DD today) and append a summary block to the agent instructions so the assistant has today's operational context. MAX_INSIGHTS_IN_CONTEXT=%d apiKey=%s", + S2S_INSIGHTS_URL, + MAX_INSIGHTS_IN_CONTEXT, + "set" if S2S_INSIGHTS_API_KEY else "not-set (public endpoint OK)", + ) +else: + logger.debug( + "[INSIGHTS MASTER] DISABLED — S2S_INSIGHTS_URL is empty/commented in .env. " + "Agent instructions will NOT include today's insights context." + ) +logger.debug( + "[STARTUP] Other externals — searchMcp=%s reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s auth=%s", + S2S_SEARCH_MCP, + S2S_REASONING_TYPE, + NODE_RED_REASONING_URL or "-", + bool(SERPER_KEY), + bool(LOAD_BALANCER_URL), + bool(S2S_PROXY_TARGET), + bool(SPEECH_TO_SPEECH_URL), + AUTH_ENABLED, +) +logger.debug( + "[STARTUP] Runtime config — SPACE_ID=%s LIMITER_ENABLED=%s MAX_RESULTS=%d", + bool(SPACE_ID), LIMITER_ENABLED, MAX_RESULTS, +) + + +async def _report_server_debug(hypothesis_id: str, location: str, msg: str, data: dict | None = None) -> None: + return + + +def _realtime_event_summary(raw: str) -> dict | None: + try: + payload = json.loads(raw) + except Exception: + return None + if not isinstance(payload, dict): + return None + event_type = payload.get("type") + if not isinstance(event_type, str): + return None + summary = {"type": event_type} + if event_type == "session.update": + session = payload.get("session") if isinstance(payload.get("session"), dict) else {} + tools = session.get("tools") if isinstance(session.get("tools"), list) else [] + summary["toolCount"] = len(tools) + summary["toolNames"] = [tool.get("name") for tool in tools if isinstance(tool, dict)] + summary["toolChoice"] = session.get("tool_choice") + summary["instructionLength"] = len(session.get("instructions") or "") + elif event_type in {"response.function_call_arguments.done", "error", "session.updated"}: + for key in ("name", "call_id", "code", "message", "response_id"): + if key in payload: + summary[key] = payload.get(key) + # --- INPUT (user speech → STT transcript) --- + # Can arrive from upstream (server-side STT) — match either direction. + elif event_type == "conversation.item.input_audio_transcription.completed": + summary["transcript"] = payload.get("transcript", "") + summary["item_id"] = payload.get("item_id") + elif event_type == "conversation.item.created": + item = payload.get("item") if isinstance(payload.get("item"), dict) else None + if isinstance(item, dict): + summary["item_id"] = item.get("id") + summary["role"] = item.get("role") + summary["item_type"] = item.get("type") + summary["status"] = item.get("status") + content_parts = [] + for c in (item.get("content") or []): + if isinstance(c, dict): + if c.get("type") in {"input_text", "text"} and isinstance(c.get("text"), str): + content_parts.append(c["text"]) + elif c.get("type") == "transcript" and isinstance(c.get("transcript"), str): + content_parts.append(c["transcript"]) + if content_parts: + summary["text"] = " ".join(content_parts) + # --- OUTPUT (agent text/speech generation) --- + elif event_type == "response.output_item.done": + item = payload.get("item") if isinstance(payload.get("item"), dict) else None + if isinstance(item, dict): + summary["item_id"] = item.get("id") + summary["role"] = item.get("role") + summary["status"] = item.get("status") + content_parts = [] + for c in (item.get("content") or []): + if isinstance(c, dict): + if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"]: + content_parts.append(c["text"]) + elif c.get("type") == "audio" and isinstance(c.get("transcript"), str) and c["transcript"]: + content_parts.append(c["transcript"]) + if content_parts: + summary["text"] = " ".join(content_parts) + elif event_type == "response.content_part.done": + part = payload.get("part") if isinstance(payload.get("part"), dict) else None + if isinstance(part, dict): + summary["response_id"] = payload.get("response_id") + summary["item_id"] = payload.get("item_id") + summary["output_index"] = payload.get("output_index") + summary["content_index"] = payload.get("content_index") + if part.get("type") == "text": + summary["text"] = part.get("text", "") + elif part.get("type") == "audio": + if isinstance(part.get("transcript"), str) and part["transcript"]: + summary["text"] = part["transcript"] + # Variante standard OpenAI: + elif event_type == "response.audio_transcript.done": + summary["response_id"] = payload.get("response_id") + summary["item_id"] = payload.get("item_id") + summary["text"] = payload.get("transcript", "") + # Variante upstream custom (osservato nel log dell'utente): + elif event_type == "response.output_audio_transcript.done": + summary["response_id"] = payload.get("response_id") + summary["item_id"] = payload.get("item_id") + summary["output_index"] = payload.get("output_index") + # Try field names in order of likelihood + text = "" + for key in ("transcript", "text", "output_text", "audio_text", + "output_transcript", "final_transcript", "output_audio_text", + "content", "message", "utterance"): + v = payload.get(key) + if isinstance(v, str) and v.strip(): + text = v.strip() + break + # Se text ancora vuoto, cerca all'interno di eventuali sotto-oggetti noti + if not text: + for container in ("item", "part", "content", "output", "delta", "audio"): + sub = payload.get(container) + if isinstance(sub, dict): + for key in ("transcript", "text", "output_text"): + v = sub.get(key) + if isinstance(v, str) and v.strip(): + text = v.strip() + break + if text: + break + if not text: + # Ultima risorsa: content[] array + arr = payload.get("content") + if isinstance(arr, list): + parts = [] + for c in arr: + if isinstance(c, dict): + for key in ("transcript", "text"): + v = c.get(key) + if isinstance(v, str) and v.strip(): + parts.append(v.strip()) + if parts: + text = " ".join(parts) + summary["text"] = text + # Se non abbiamo ancora trovato testo, salva un'anteprima del raw + # per capire dove lo sta emettendo l'upstream custom. + if not text: + preview = raw[:800] + try: + pretty = json.dumps(payload, ensure_ascii=True)[:800] + except Exception: + pretty = preview + summary["_raw_preview"] = pretty + elif event_type == "response.function_call_arguments.done": + summary["arguments"] = payload.get("arguments") + # Questo evento è il KEY CHECK della Strategia B: se lo vediamo = + # ai-server ha accettato tools[] nativi e chiama MCP Aquachile via protocollo. + _tool_name = payload.get("name") if isinstance(payload.get("name"), str) else "" + _call_id = payload.get("call_id") if isinstance(payload.get("call_id"), str) else "" + _args_json = payload.get("arguments") if isinstance(payload.get("arguments"), str) else "{}" + logger.info( + "[MCP INJECT] ✓ UPSTREAM ha emesso response.function_call_arguments.done! " + "tool=%s callId=%s argsLen=%d argsPreview=%s", + _tool_name or "(empty)", + _call_id or "-", + len(_args_json), + _args_json[:200], + ) + try: + _args_parsed = json.loads(_args_json or "{}") + logger.info( + "[MCP INJECT] ✓ Tool call parsed argsKeys=%s", + sorted(_args_parsed.keys()) if isinstance(_args_parsed, dict) else "N/A", + ) + except Exception: + pass + return summary + + +# Eventi realtime ad alta frequenza da silenziare nel log dettagliato (rumore). +# Essi vengono comunque tracciati dal report_server_debug interno se abilitato. +_NOISY_REALTIME_EVENT_TYPES = frozenset({ + "response.output_audio.delta", + "response.audio.delta", + "conversation.item.input_audio_transcription.delta", + "response.text.delta", + "response.output_text.delta", + "rate_limits.updated", +}) + +# Eventi realtime silenziati anche quando arrivano dal lato cliente (input_audio_buffer.append ecc.). +_NOISY_CLIENT_EVENT_TYPES = frozenset({ + "input_audio_buffer.append", + "input_audio_buffer.commit", + "input_audio_buffer.clear", + "input_audio_buffer.speech_started", + "input_audio_buffer.speech_stopped", +}) +@app.on_event("startup") +async def _startup(): + """Stand up the usage DB and a periodic sweeper — metered (prod Space) only.""" + if not LIMITER_ENABLED: + return + limiter.init() + asyncio.create_task(_sweeper()) + + +async def _sweeper(): + while True: + await asyncio.sleep(limiter.REAP_AFTER_SEC) + try: + await asyncio.to_thread(limiter.sweep) + except Exception as exc: # pragma: no cover - defensive + logger.warning("usage sweep failed: %r", exc) + + +class SearchRequest(BaseModel): + query: str + # Optional user-supplied key (fallback when the deploy has no server key). + # Used for this request only; never stored. + key: str | None = None + + +class McpToolCallRequest(BaseModel): + name: str + arguments: dict = {} + + +class ReasoningRequest(BaseModel): + transcript: str + instructions: str = "" + language: str = "" + user_context: str = "" + # Phone number of the caller (same as URL param). When present and the + # reasoning mode is NODE_RED, it is forwarded to Node-RED so that flows + # can fetch the caller profile via Aquachile /auth/user-by-phone. + phoneNumber: str | None = None + + +_agent_profile_lock = asyncio.Lock() +_agent_profile_cached: tuple[float, str, list[dict]] = (0.0, "", []) +_insights_lock = asyncio.Lock() +# cache: (cached_at_seconds, cache_date_str_YYYY_MM_DD, list[dict] | None, formatted_prompt_block_str) +_insights_cached: tuple[float, str, list[dict] | None, str] = (0.0, "", None, "") + + +async def _agent_profile_data() -> tuple[str, list[dict]]: + if not S2S_AGENT_PROFILE: + logger.debug("[EXT][AQUACHILE] agent_profile: endpoint not configured, skipping") + return "", [] + now = asyncio.get_running_loop().time() + cached_at, cached_prompt, cached_tools = _agent_profile_cached + if (cached_prompt or cached_tools) and (now - cached_at) < 60: + logger.debug( + "[EXT][AQUACHILE] agent_profile: cache HIT age=%.1fs promptLen=%d toolCount=%d", + now - cached_at, len(cached_prompt), len(cached_tools), + ) + return cached_prompt, cached_tools + async with _agent_profile_lock: + cached_at, cached_prompt, cached_tools = _agent_profile_cached + if (cached_prompt or cached_tools) and (now - cached_at) < 60: + return cached_prompt, cached_tools + logger.debug("[EXT][AQUACHILE] agent_profile GET %s (cache miss)", S2S_AGENT_PROFILE) + try: + async with httpx.AsyncClient(timeout=10.0) as http: + resp = await http.get(S2S_AGENT_PROFILE) + logger.debug( + "[EXT][AQUACHILE] agent_profile RESP status=%s len=%dB", + resp.status_code, len(resp.content or b""), + ) + if resp.status_code != 200: + logger.warning("Agent profile fetch failed %s: %s", resp.status_code, resp.text[:300]) + _set_agent_profile_cache(now, "", []) + return "", [] + data = resp.json() + except Exception as exc: + logger.warning("Agent profile fetch failed: %r", exc) + _set_agent_profile_cache(now, "", []) + return "", [] + prompt = _extract_agent_prompt(data) + tools = _extract_agent_tools(data) + logger.debug( + "[EXT][AQUACHILE] agent_profile PARSED promptLen=%d toolCount=%d toolNames=%s", + len(prompt), len(tools), [t.get("name") for t in tools if isinstance(t, dict)], + ) + _set_agent_profile_cache(now, prompt, tools) + return prompt, tools + + +def _set_agent_profile_cache(ts: float, prompt: str, tools: list[dict]) -> None: + global _agent_profile_cached + _agent_profile_cached = (ts, prompt, tools) + + +def _set_insights_cache(ts: float, date_str: str, entries: list[dict] | None, formatted_block: str) -> None: + global _insights_cached + _insights_cached = (ts, date_str, entries, formatted_block) + + +def _format_insights_for_prompt(entries: list[dict]) -> str: + """ + Serializza una lista di insight Aquachile in formato testo leggibile + da appendere alle agent instructions. Priorità visuale: HIGH → MEDIUM → LOW. + Es. output: + ### Contexto operativo del día (2026-08-25) — 18 insights: + 1. [HIGH · Operations · Center: Arica · Jaula: C-07] Título breve + - Descrizione del insight (trunc 200c). + - Recomendación: azione consigliata (trunc 200c). + ... + """ + if not entries: + return "" + today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + _priority_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} + try: + sorted_entries = sorted( + entries, + key=lambda e: ( + _priority_order.get((e.get("priority") or "").upper(), 99), + e.get("createdAt") or "", + ), + reverse=False, + ) + except Exception: + sorted_entries = list(entries) + lines: list[str] = [] + total = len(sorted_entries) + lines.append(f"### Contexto operativo del día ({today_str}) — {total} insights:") + for idx, e in enumerate(sorted_entries[:MAX_INSIGHTS_IN_CONTEXT], start=1): + if not isinstance(e, dict): + continue + priority = str(e.get("priority") or "UNKNOWN").upper() + audience = e.get("audience") + if isinstance(audience, list): + audience_str = "/".join(str(a) for a in audience if a)[:40] + elif isinstance(audience, str): + audience_str = audience[:40] + else: + audience_str = "General" + center = str(e.get("center") or "—")[:40] + jaula = str(e.get("jaula") or "—")[:24] + title = (e.get("title") or "(No title)").strip() + description = (e.get("description") or "").strip() + recommendation = (e.get("recommendation") or "").strip() + header = f"{idx}. [{priority} · {audience_str} · Centro: {center} · Jaula: {jaula}] {title}" + lines.append(header) + if description: + desc_trunc = description if len(description) <= 240 else description[:237] + "..." + lines.append(f" • Situación: {desc_trunc}") + if recommendation: + rec_trunc = recommendation if len(recommendation) <= 200 else recommendation[:197] + "..." + lines.append(f" • Recomendación: {rec_trunc}") + if total > MAX_INSIGHTS_IN_CONTEXT: + lines.append( + f" … ({total - MAX_INSIGHTS_IN_CONTEXT} insights adicionales omitidos para mantener el contexto compacto. " + f"Si el usuario necesita más detalles, puede consultarlos explícitamente via tool call list_recent_reports.)" + ) + return "\n".join(lines).strip() + + +async def _fetch_insights_for_today() -> tuple[list[dict] | None, str]: + """ + Fetch diario degli insights Aquachile via GET S2S_INSIGHTS_URL con + start_date == end_date == oggi (UTC). Cache 60s TTL con anti-thundering-herd + lock, esattamente come _agent_profile_data. Ritorna (entries_list o None, + formatted_block_str). Se fallisce → ritorna (None, "") e logga WARNING + ma NON solleva eccezioni (never-block-audio). + """ + if not INSIGHTS_ENABLED: + return None, "" + today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + now = asyncio.get_running_loop().time() + cached_at, cached_date, cached_entries, cached_block = _insights_cached + # Cache HIT solo se: stessa data di oggi e age < 60s + if cached_date == today_str and cached_block and (now - cached_at) < 60: + logger.debug( + "[INSIGHTS FETCH] cache HIT date=%s age=%.1fs entries=%d blockLen=%d", + cached_date, now - cached_at, len(cached_entries or []), len(cached_block), + ) + return cached_entries, cached_block + async with _insights_lock: + cached_at, cached_date, cached_entries, cached_block = _insights_cached + if cached_date == today_str and cached_block and (now - cached_at) < 60: + return cached_entries, cached_block + url = S2S_INSIGHTS_URL.rstrip("/") + params = {"start_date": today_str, "end_date": today_str} + headers = {"Accept": "application/json"} + if S2S_INSIGHTS_API_KEY: + headers["X-API-KEY"] = S2S_INSIGHTS_API_KEY + if S2S_INSIGHTS_API_SECRET: + headers["X-API-SECRET"] = S2S_INSIGHTS_API_SECRET + logger.info( + "[INSIGHTS FETCH] → GET today=%s url=%s params=%s apiKey=%s", + today_str, url, json.dumps(params, ensure_ascii=True), + "set" if S2S_INSIGHTS_API_KEY else "not-set (public OK)", + ) + try: + async with httpx.AsyncClient(timeout=15.0) as http: + resp = await http.get(url, params=params, headers=headers) + except Exception as exc: + logger.warning("[INSIGHTS FETCH] ERROR (continuo senza insights, non blocco audio): %r", exc) + _set_insights_cache(now, today_str, None, "") + return None, "" + logger.debug( + "[INSIGHTS FETCH] ← RESP status=%s len=%dB", + resp.status_code, len(resp.content or b""), + ) + if resp.status_code != 200: + logger.warning( + "[INSIGHTS FETCH] HTTP %s (non 200, skip — non blocco audio). Preview body: %s", + resp.status_code, (resp.text or "")[:300], + ) + _set_insights_cache(now, today_str, None, "") + return None, "" + try: + data = resp.json() + except Exception as exc: + logger.warning("[INSIGHTS FETCH] JSON parse fallito: %r. Body preview=%s", exc, (resp.text or "")[:300]) + _set_insights_cache(now, today_str, None, "") + return None, "" + if isinstance(data, dict) and isinstance(data.get("insights"), list): + entries = [e for e in data["insights"] if isinstance(e, dict)] + elif isinstance(data, list): + entries = [e for e in data if isinstance(e, dict)] + else: + logger.warning( + "[INSIGHTS FETCH] formato response inatteso (keys=%s). Skip senza errori.", + sorted(data.keys()) if isinstance(data, dict) else type(data).__name__, + ) + _set_insights_cache(now, today_str, None, "") + return None, "" + # Ordina e formatta per injection nel prompt. + priority_counts: dict[str, int] = {} + for e in entries: + p = str(e.get("priority") or "UNKNOWN").upper() + priority_counts[p] = priority_counts.get(p, 0) + 1 + formatted_block = _format_insights_for_prompt(entries) + logger.info( + "[INSIGHTS FETCH] ✓ PARSED OK today=%s total=%d byPriority=%s formattedLen=%d maxCap=%d (truncated=%s)", + today_str, + len(entries), + json.dumps(priority_counts, ensure_ascii=True), + len(formatted_block), + MAX_INSIGHTS_IN_CONTEXT, + "YES" if len(entries) > MAX_INSIGHTS_IN_CONTEXT else "NO", + ) + # Log dettaglio anteprima lista insights (title/priority/center) per auditoria + # INFO level: sempre visibile nel container (audit quali insights sono stati considerati). + _preview_cap = min(10, len(entries)) + _preview_list = [] + for e in entries[:_preview_cap]: + _preview_list.append( + { + "priority": str(e.get("priority") or "?").upper(), + "center": str(e.get("center") or "?")[:30], + "jaula": str(e.get("jaula") or "?")[:20], + "title": (str(e.get("title") or "?")[:80]), + } + ) + # 👉 Pattern singolo %s + f-string: nessun rischio che % dentro agli insights + # (es. -1.65%) vengano interpretati come placeholder da logging — BUG FIX. + _msg_preview = ( + f"[INSIGHTS FETCH] PREVIEW lista primi {_preview_cap}/{len(entries)} insights " + f"JSON={json.dumps(_preview_list, ensure_ascii=True)}" + ) + logger.info("%s", _msg_preview) + _set_insights_cache(now, today_str, entries, formatted_block) + return entries, formatted_block + + +def _extract_agent_prompt(data) -> str: + try: + if isinstance(data, dict): + agent = data.get("agent") + if isinstance(agent, dict): + desc = agent.get("instructions") + if isinstance(desc, str): + return desc.strip() + desc = agent.get("description") + if isinstance(desc, str): + return desc.strip() + desc = data.get("instructions") + if isinstance(desc, str): + return desc.strip() + desc = data.get("description") + if isinstance(desc, str): + return desc.strip() + except Exception: + return "" + return "" + + +def _extract_agent_tools(data) -> list[dict]: + try: + if isinstance(data, dict): + agent = data.get("agent") + if isinstance(agent, dict): + tools = agent.get("tools") + if isinstance(tools, list): + return [tool for tool in tools if isinstance(tool, dict) and tool.get("enabled", True)] + except Exception: + return [] + return [] + + +def _normalize_tools_for_realtime(raw_tools: list[dict]) -> list[dict]: + """ + Converte tool definitions Aquachile (qualsiasi variante di struttura) in + formato OpenAI Realtime API GA standard, valido per session.update -> tools[]. + + Formato atteso Realtime API (ogni tool): + { + "type": "function", # fisso, unico supportato oggi + "name": "snake_case_tool_name", # <64 chars, a-z0-9_ + "description": "frase che spiega quando usarlo", # stringa, NOT null + "parameters": { # JSON Schema Object, MAY BE {} vuoto + "type": "object", + "properties": { "arg1": {"type":"string","description":"…"}, … }, + "required": ["arg1"] # (opzionale, array di stringhe) + } + } + + Ritorna SEMPRE list[dict] (non torna mai None, non alza eccezioni). + Tool invalidi vengono saltati con logger.warning ma non bloccano gli altri. + """ + normalized: list[dict] = [] + seen_names: set[str] = set() + if not isinstance(raw_tools, list): + return normalized + for idx, tool in enumerate(raw_tools): + if not isinstance(tool, dict): + logger.warning("[MCP TOOLS] skip tool at index %s: not a dict (got %s)", idx, type(tool).__name__) + continue + name = "" + for key in ("name", "function_name", "toolName", "id"): + val = tool.get(key) + if isinstance(val, str) and val.strip(): + name = val.strip() + break + if not name: + logger.warning("[MCP TOOLS] skip tool at index %s: missing name keys=%s", idx, sorted(tool.keys())) + continue + # sanitize nome -> solo [a-z0-9_] (regex OpenAI) + clean_name = re.sub(r"[^a-zA-Z0-9_]", "_", name).lower().strip("_") + if not clean_name: + clean_name = f"tool_{idx}" + if clean_name in seen_names: + clean_name = f"{clean_name}_{idx}" + seen_names.add(clean_name) + description = "" + for key in ("description", "desc", "summary", "instructions", "purpose"): + val = tool.get(key) + if isinstance(val, str) and val.strip(): + description = val.strip() + break + if not description: + description = f"Tool {clean_name}" + # parameters JSON Schema + raw_params = None + for key in ("parameters", "params", "input_schema", "schema", "argument_schema"): + val = tool.get(key) + if isinstance(val, dict) and val: + raw_params = val + break + parameters: dict + if raw_params: + parameters = dict(raw_params) + parameters.setdefault("type", "object") + if "properties" not in parameters or not isinstance(parameters["properties"], dict): + parameters["properties"] = {} + if "required" in parameters and not isinstance(parameters["required"], list): + parameters["required"] = [] + else: + # parametri assenti: oggetto vuoto permesso da Realtime API + parameters = {"type": "object", "properties": {}} + normalized.append({ + "type": "function", + "name": clean_name, + "description": description, + "parameters": parameters, + }) + logger.debug( + "[MCP TOOLS] normalized count=%d raw_count=%d names=%s", + len(normalized), len(raw_tools), [t["name"] for t in normalized][:10], + ) + return normalized + + +def _mcp_headers() -> dict[str, str]: + headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + } + if S2S_TOOL_EXECUTION_MCP_API_KEY: + headers["X-API-KEY"] = S2S_TOOL_EXECUTION_MCP_API_KEY + if S2S_TOOL_EXECUTION_MCP_API_SECRET: + headers["X-API-SECRET"] = S2S_TOOL_EXECUTION_MCP_API_SECRET + return headers + + +def _parse_mcp_response(body: str) -> dict: + text = (body or "").strip() + if not text: + return {} + if text.startswith("{"): + try: + parsed = json.loads(text) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + for chunk in text.split("\n\n"): + for line in chunk.splitlines(): + if line.startswith("data: "): + payload = line[6:].strip() + try: + parsed = json.loads(payload) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + return {} + + +def _url_with_query(url: str, **params: str) -> str: + target = urlsplit(url) + merged_query = dict(parse_qsl(target.query, keep_blank_values=True)) + for key, value in params.items(): + if value: + merged_query[key] = value + return urlunsplit( + ( + target.scheme, + target.netloc, + target.path, + urlencode(merged_query, doseq=True), + target.fragment, + ) + ) + + +def _openai_headers() -> dict[str, str]: + if not OPENAI_API_KEY: + raise RuntimeError("OPENAI_API_KEY is not configured.") + headers = { + "Authorization": f"Bearer {OPENAI_API_KEY}", + "Content-Type": "application/json", + } + if OPENAI_ORGANIZATION: + headers["OpenAI-Organization"] = OPENAI_ORGANIZATION + if OPENAI_PROJECT: + headers["OpenAI-Project"] = OPENAI_PROJECT + return headers + + +def _effective_s2s_url(request: Request) -> str: + return SPEECH_TO_SPEECH_URL or _proxy_public_url(request) + + +@app.get("/api/config") +async def config(request: Request): + """Client bootstrap: whether web search is available, whether the deploy runs + behind a load balancer (so the browser uses the /api/session proxy + limiter), + whether HF sign-in is available, and whether the user may instead set a direct + s2s server URL. The LB address itself is intentionally NOT included.""" + logger.debug("[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?") + agent_prompt, agent_tools_raw = await _agent_profile_data() + # ⛔ MCP MASTER DISABLE: se S2S_TOOL_EXECUTION_MCP_URL non è in .env + # nascondi catalogo 79 tool ANCHE lato client (evita ranking/pulsanti che fallirebbero 503) + if MCP_EXEC_ENABLED: + agent_tools_for_client = agent_tools_raw + search_mcp_for_client = bool(S2S_SEARCH_MCP) + else: + agent_tools_for_client = [] + search_mcp_for_client = False + primary_prompt = agent_prompt or S2S_PRIMARY_PROMPT + out = { + "search": bool(SERPER_KEY), + "lb": bool(LOAD_BALANCER_URL), + "allowDirect": not LOAD_BALANCER_URL, + # Deploy-pinned direct s2s URL (empty when unset). Not a secret: the + # browser dials it itself, and Settings shows it locked. + "s2sUrl": _effective_s2s_url(request), + "primaryPrompt": primary_prompt, + "agentProfile": S2S_AGENT_PROFILE, + "agentTools": agent_tools_for_client, + "searchMcp": search_mcp_for_client, + "reasoningType": S2S_REASONING_TYPE, + "auth": AUTH_ENABLED, + } + logger.debug( + "[INTERNAL] /api/config returned promptLen=%d toolCount=%d (raw=%d before master gate) reasoning=%s searchMcp=%s MCP_EXEC_ENABLED=%s", + len(primary_prompt), + len(agent_tools_for_client), + len(agent_tools_raw), + S2S_REASONING_TYPE, + search_mcp_for_client, + MCP_EXEC_ENABLED, + ) + return out + + +@app.get("/api/user/phone") +async def user_by_phone(phoneNumber: str = ""): + phone_number = phoneNumber.strip() + if not phone_number: + raise HTTPException(status_code=400, detail="phoneNumber is required.") + if not VOCERO_USER_PHONE_AUTH_URL: + raise HTTPException(status_code=503, detail="VOCERO_USER_PHONE_AUTH_URL is not configured.") + target_url = _url_with_query(VOCERO_USER_PHONE_AUTH_URL, phoneNumber=phone_number) + logger.info("[Vocero USER AUTH] phoneNumber=%s target=%s", phone_number, target_url) + logger.debug("[EXT][AQUACHILE] user_by_phone GET %s", target_url) + try: + async with httpx.AsyncClient(timeout=20.0) as http: + resp = await http.get(target_url, headers={"Accept": "application/json"}) + except httpx.RequestError as exc: + logger.warning("User phone auth failed: %r", exc) + raise HTTPException(status_code=502, detail="User phone auth unreachable.") + logger.debug( + "[EXT][AQUACHILE] user_by_phone RESP status=%s len=%dB", + resp.status_code, len(resp.content or b""), + ) + if resp.status_code != 200: + logger.warning("User phone auth failed %s: %s", resp.status_code, resp.text[:400]) + raise HTTPException(status_code=502, detail=f"User phone auth failed ({resp.status_code}).") + try: + payload = resp.json() + except Exception: + logger.warning("User phone auth returned invalid JSON: %s", resp.text[:400]) + raise HTTPException(status_code=502, detail="User phone auth returned invalid JSON.") + logger.info("[Vocero USER AUTH] phoneNumber=%s preview=%s", phone_number, json.dumps(payload, ensure_ascii=True)[:800]) + if isinstance(payload, dict): + logger.debug( + "[EXT][AQUACHILE] user_by_phone PARSED keys=%s", + sorted(payload.keys()), + ) + return JSONResponse({"result": payload}) + + +@app.post("/api/mcp/tool") +async def mcp_tool(req: McpToolCallRequest): + if not MCP_EXEC_ENABLED: + logger.warning( + "[MCP DISABLED] ⛔ /api/mcp/tool called but S2S_TOOL_EXECUTION_MCP_URL is empty/commented in .env. " + "Rejecting with 503. name=%s args=%s", + req.name, + json.dumps(req.arguments or {}, ensure_ascii=True)[:200], + ) + raise HTTPException( + status_code=503, + detail="MCP tool execution is disabled: S2S_TOOL_EXECUTION_MCP_URL is not configured in .env.", + ) + target = S2S_TOOL_EXECUTION_MCP_URL.rstrip("/") + "/" + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": req.name, + "arguments": req.arguments or {}, + }, + } + args_json = json.dumps(req.arguments or {}, ensure_ascii=True) + logger.info( + "[Vocero MCP CALL] name=%s target=%s args=%s", + req.name, target, args_json[:800], + ) + logger.debug( + "[EXT][AQUACHILE] mcp_tool POST %s method=%s tool=%s argsLen=%dB body=%s", + target, body["method"], req.name, len(args_json), + json.dumps(body, ensure_ascii=True)[:800], + ) + try: + async with httpx.AsyncClient(timeout=45.0) as http: + resp = await http.post( + target, + headers=_mcp_headers(), + json=body, + ) + except httpx.RequestError as exc: + logger.warning("MCP tool call failed: %r", exc) + raise HTTPException(status_code=502, detail="MCP tool execution unreachable.") + logger.info("[Vocero MCP HTTP] name=%s status=%s", req.name, resp.status_code) + logger.debug( + "[EXT][AQUACHILE] mcp_tool RESP tool=%s status=%s len=%dB raw=%s", + req.name, resp.status_code, len(resp.content or b""), (resp.text or "")[:800], + ) + if resp.status_code != 200: + logger.warning("MCP tool call failed %s: %s", resp.status_code, resp.text[:400]) + raise HTTPException(status_code=502, detail=f"MCP tool execution failed ({resp.status_code}).") + payload = _parse_mcp_response(resp.text) + if payload.get("error"): + message = payload["error"].get("message") if isinstance(payload["error"], dict) else "MCP tool execution failed." + logger.warning("[Vocero MCP OUT] name=%s error=%s", req.name, message) + raise HTTPException(status_code=502, detail=message or "MCP tool execution failed.") + result = payload.get("result") + result_json = json.dumps(result, ensure_ascii=True) if result is not None else "null" + logger.info("[Vocero MCP OUT] name=%s preview=%s", req.name, result_json[:800]) + logger.debug( + "[EXT][AQUACHILE] mcp_tool PARSED tool=%s resultKeys=%s resultLen=%dB", + req.name, + sorted(result.keys()) if isinstance(result, dict) else "N/A", + len(result_json), + ) + return JSONResponse({"result": result}) + + +@app.post("/api/reasoning") +async def reasoning(req: ReasoningRequest): + if S2S_REASONING_TYPE == "PROXY": + raise HTTPException(status_code=409, detail="Reasoning endpoint is disabled in PROXY mode.") + transcript = (req.transcript or "").strip() + if not transcript: + raise HTTPException(status_code=400, detail="transcript is required.") + + # ──────────────────────────────────────────────────────────────────── + # Modalità A: NODE_RED — inoltra TUTTO il payload a Node-RED + # Il flow Node-RED decide autonomamente. + # FASE 1 (LOG-ONLY): Node-RED risponde con {answer: "", log_only: true} + # => il client (main.js) NON interferisce, lascia parlare il backend S2S. + # FASI successive: Node-RED produce {answer: "...", log_only: false} + # Risposta attesa: {answer, log_only?, usage, debug, latency_ms, …} + # ──────────────────────────────────────────────────────────────────── + if S2S_REASONING_TYPE == "NODE_RED": + if not NODE_RED_REASONING_URL: + raise HTTPException( + status_code=503, + detail="S2S_REASONING_NODE_RED_URL is not configured. Set the env var to the Node-RED endpoint (e.g. http://localhost:1881).", + ) + upstream_body = { + "transcript": transcript, + "instructions": (req.instructions or "").strip(), + "language": (req.language or "").strip(), + "user_context": (req.user_context or "").strip(), + "phoneNumber": (req.phoneNumber or "").strip() or None, + } + _instructions_str = upstream_body["instructions"] or "" + _has_mcp_search_hint = "Available MCP tools:" in _instructions_str + _has_ranking_hint = "Client-side ranking hint only:" in _instructions_str + _top3_suggested = [] + if _has_ranking_hint: + _rank_chunk = None + for _sep in [". Use this hint", " Use this hint", " — Use this hint", ". Use it only", "\nUser profile:", "\nCurrent date:", " Use "]: + _pos = _instructions_str.find(_sep, _instructions_str.find("Client-side ranking hint only:")) + if _pos > 0: + _start = _instructions_str.find("Client-side ranking hint only:") + len("Client-side ranking hint only:") + _rank_chunk = _instructions_str[_start:_pos].strip(" \n\t:-—.") + break + if _rank_chunk is None: + _start = _instructions_str.find("Client-side ranking hint only:") + len("Client-side ranking hint only:") + _rank_chunk = _instructions_str[_start:_start + 1500].strip() + for _rm in re.finditer(r"\s*(\d+)\.\s*([^::]+?)[::]\s*", _rank_chunk): + _rn = int(_rm.group(1)) + _nm = _rm.group(2).strip(" \n\t`*•") + _rest_start = _rm.end() + _rest_chunk = _rank_chunk[_rest_start:_rest_start + 300] + _next_match = re.search(r"\s*\d+\.\s*[^::]+?[::]\s*", _rest_chunk) + if _next_match: + _descr = _rest_chunk[:_next_match.start()].strip(" \n\t;.,—-") + else: + _descr = _rest_chunk.strip(" \n\t;.,—-") + if _nm: + _top3_suggested.append({"rank": _rn, "name": _nm, "description": _descr[:80]}) + _mcp_catalog_len = 0 + if _has_mcp_search_hint: + _cat_start = _instructions_str.find("Available MCP tools:") + len("Available MCP tools:") + _cat_end_candidates = [ + _instructions_str.find(_s, _cat_start) for _s in + [" Current user request:", "\nCurrent user request:", " Current user request\n", " User profile:", "\nUser profile:", " When the user's request", " Use "] + ] + _cat_end_candidates = [_p for _p in _cat_end_candidates if _p > _cat_start] + _cat_end = min(_cat_end_candidates) if _cat_end_candidates else (_cat_start + 20000) + _cat_chunk = _instructions_str[_cat_start:_cat_end].strip(" \n\t:.,—-") + _semicolon_count = _cat_chunk.count("; ") + if _semicolon_count: + _mcp_catalog_len = _semicolon_count + 1 + else: + _tool_mentions = re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]{2,50}\b\s*:\s", _cat_chunk) + _mcp_catalog_len = len(_tool_mentions) + logger.info( + "[MCP RANK] reasoning payload transcript=%s instructionsLen=%d hasMcpCatalogHint=%s hasRankingHint=%s mcpCatalogTools=%d top3Suggested=%s", + transcript[:200], + len(_instructions_str), + _has_mcp_search_hint, + _has_ranking_hint, + _mcp_catalog_len, + json.dumps(_top3_suggested, ensure_ascii=True) if _top3_suggested else "none", + ) + logger.debug( + "[EXT][NODE_RED] reasoning POST %s/api/reasoning lang=%s transcriptLen=%d phone=%s", + NODE_RED_REASONING_URL, + (req.language or "")[:20] or "auto", + len(transcript), + (req.phoneNumber or "")[:16] or "none", + ) + try: + async with httpx.AsyncClient(timeout=120.0) as http: + resp = await http.post( + f"{NODE_RED_REASONING_URL}/api/reasoning", + json=upstream_body, + headers={ + "Content-Type": "application/json", + "X-S2S-Caller": "demo-server", + }, + ) + except httpx.RequestError as exc: + logger.warning("[EXT][NODE_RED] unreachable: %r", exc) + raise HTTPException(status_code=502, detail="Node-RED reasoning engine is unreachable.") + logger.debug( + "[EXT][NODE_RED] reasoning RESP status=%s len=%dB", + resp.status_code, len(resp.content or b""), + ) + if resp.status_code != 200: + logger.warning("[EXT][NODE_RED] failed %s: %s", resp.status_code, resp.text[:500]) + raise HTTPException( + status_code=502, + detail=f"Node-RED reasoning failed (HTTP {resp.status_code}).", + ) + try: + payload = resp.json() + except Exception: + logger.warning("[EXT][NODE_RED] invalid JSON: %s", resp.text[:500]) + raise HTTPException(status_code=502, detail="Node-RED returned invalid JSON.") + answer = payload.get("answer") if isinstance(payload, dict) else None + log_only = bool(isinstance(payload, dict) and payload.get("log_only") is True) + if not isinstance(answer, str): + logger.warning("[EXT][NODE_RED] invalid answer type: %s", json.dumps(payload, ensure_ascii=True)[:800]) + raise HTTPException(status_code=502, detail="Node-RED returned invalid answer field.") + answer_stripped = answer.strip() + # ── Log-only mode: answer vuota + flag log_only sono L'INTENZIONE ── + # Non solleviamo errore; il flag viene inoltrato al client. + if not answer_stripped and not log_only: + logger.warning("[EXT][NODE_RED] empty answer and !log_only: %s", json.dumps(payload, ensure_ascii=True)[:800]) + raise HTTPException(status_code=502, detail="Node-RED returned no answer without log_only flag.") + usage = payload.get("usage") if isinstance(payload, dict) else None + if isinstance(usage, dict): + logger.debug( + "[EXT][NODE_RED] reasoning USAGE prompt=%d completion=%d total=%d", + usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), usage.get("total_tokens", 0), + ) + extra = {} + for key in ("latency_ms", "intent", "mcp_tools_used", "caller_profile_keys", "debug"): + if isinstance(payload, dict) and key in payload: + extra[key] = payload[key] + if log_only: + logger.info( + "[Vocero REASONING] engine=NODE_RED mode=LOG_ONLY transcript=%s extra=%s", + transcript[:300], + json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400], + ) + else: + logger.info( + "[Vocero REASONING] engine=NODE_RED transcript=%s answer=%s extra=%s", + transcript[:300], answer_stripped[:500], + json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400], + ) + out = {"answer": answer_stripped} + if log_only: + out["log_only"] = True + if usage: + out["usage"] = usage + out.update({k: v for k, v in extra.items() if k != "debug" or logger.level <= logging.DEBUG}) + return JSONResponse(out) + + # ──────────────────────────────────────────────────────────────────── + # Modalità B: OPENAI — chiamata diretta a OpenAI Chat Completions + # ──────────────────────────────────────────────────────────────────── + if not OPENAI_API_KEY: + raise HTTPException(status_code=503, detail="OPENAI_API_KEY is not configured.") + system_parts = [] + instructions = (req.instructions or "").strip() + if instructions: + system_parts.append(instructions) + if req.user_context.strip(): + system_parts.append(f"User context:\n{req.user_context.strip()}") + if req.language.strip(): + system_parts.append( + "Reply only with the final assistant answer text. " + f"Keep the answer natural and concise in this language: {req.language.strip()}." + ) + else: + system_parts.append("Reply only with the final assistant answer text. Keep the answer natural and concise.") + body = { + "model": OPENAI_REASONING_MODEL, + "messages": [ + {"role": "system", "content": "\n\n".join(system_parts)}, + {"role": "user", "content": transcript}, + ], + } + logger.debug( + "[EXT][OPENAI] reasoning POST %s/chat/completions model=%s systemLen=%d transcriptLen=%d language=%s", + OPENAI_BASE_URL, OPENAI_REASONING_MODEL, + len(system_parts[0]) if system_parts else 0, len(transcript), + (req.language or "")[:20] or "auto", + ) + try: + async with httpx.AsyncClient(timeout=30.0) as http: + resp = await http.post( + f"{OPENAI_BASE_URL}/chat/completions", + headers=_openai_headers(), + json=body, + ) + except httpx.RequestError as exc: + logger.warning("OpenAI reasoning unreachable: %r", exc) + raise HTTPException(status_code=502, detail="OpenAI reasoning unreachable.") + logger.debug( + "[EXT][OPENAI] reasoning RESP status=%s len=%dB", + resp.status_code, len(resp.content or b""), + ) + if resp.status_code != 200: + logger.warning("OpenAI reasoning failed %s: %s", resp.status_code, resp.text[:400]) + raise HTTPException(status_code=502, detail=f"OpenAI reasoning failed ({resp.status_code}).") + try: + payload = resp.json() + except Exception: + logger.warning("OpenAI reasoning returned invalid JSON: %s", resp.text[:400]) + raise HTTPException(status_code=502, detail="OpenAI reasoning returned invalid JSON.") + answer = "" + choices = payload.get("choices") + if isinstance(choices, list) and choices: + message = choices[0].get("message") if isinstance(choices[0], dict) else None + if isinstance(message, dict): + content = message.get("content") + if isinstance(content, str): + answer = content.strip() + usage = payload.get("usage") + if isinstance(usage, dict): + logger.debug( + "[EXT][OPENAI] reasoning USAGE prompt=%d completion=%d total=%d", + usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), usage.get("total_tokens", 0), + ) + if not answer: + logger.warning("OpenAI reasoning returned empty answer: %s", json.dumps(payload, ensure_ascii=True)[:800]) + raise HTTPException(status_code=502, detail="OpenAI reasoning returned no answer.") + logger.info("[Vocero REASONING] engine=OPENAI transcript=%s answer=%s", transcript[:300], answer[:500]) + return JSONResponse({"answer": answer}) + + +async def _forward_agent_text_to_nodered(payload: dict) -> None: + """Fire-and-forget: inoltra la risposta TTS del backend nativo a Node-RED + (endpoint /api/log/system-response) per il logging della FASE 1. + Se Node-RED è down o non è configurato: silent ignore, non propagare errori + al realtime loop (non vogliamo bloccare la connessione WS).""" + if S2S_REASONING_TYPE != "NODE_RED" or not NODE_RED_REASONING_URL: + return + url = f"{NODE_RED_REASONING_URL}/api/log/system-response" + logger.debug( + "[EXT][NODE_RED] forward system-response POST %s len=%d source=%s", + NODE_RED_REASONING_URL, + len(str(payload.get("tts", "")) or 0), + payload.get("source_event") or "-", + ) + try: + async with httpx.AsyncClient(timeout=5.0) as http: + resp = await http.post( + url, + json=payload, + headers={ + "Content-Type": "application/json", + "X-S2S-Caller": "demo-server", + }, + ) + if resp.status_code != 200: + logger.debug( + "[EXT][NODE_RED] forward system-response non-200 status=%s body=%s", + resp.status_code, + (resp.text or "")[:300], + ) + except httpx.RequestError as exc: + logger.debug("[EXT][NODE_RED] forward system-response unreachable: %r", exc) + except Exception as exc: # noqa: BLE001 — vogliamo non bloccare mai + logger.debug("[EXT][NODE_RED] forward system-response unexpected: %r", exc) + + +@app.websocket("/v1/realtime") +async def realtime_proxy(ws: WebSocket): + """Relay the browser's same-origin realtime WebSocket to the configured upstream.""" + client_ip = ws.client.host if ws.client else "?" + if not S2S_PROXY_TARGET: + logger.debug("[EXT][S2S-PROXY] client=%s rejected: S2S_PROXY_TARGET not configured", client_ip) + await ws.accept() + await ws.close(code=1013, reason="Realtime proxy not configured.") + return + + await ws.accept() + upstream_url = _proxy_upstream_url(ws) + subprotocols = _subprotocols(ws) + logger.debug( + "[EXT][S2S-PROXY] client=%s CONNECT upstream=%s subprotocols=%s", + client_ip, upstream_url, subprotocols, + ) + try: + async with websockets.connect( + upstream_url, + subprotocols=subprotocols or None, + open_timeout=15, + max_size=None, + ) as upstream: + logger.debug("[EXT][S2S-PROXY] client=%s upstream TUNNEL ESTABLISHED", client_ip) + # ── Inject strumenti MCP Aquachile + istruzioni agente a S2S upstream ── + # Prima dei task di streaming: session.update con tools[] e instructions + # native. Se l'upstream supporta il formato → emette response.function_call_arguments.done + # e la catena toolcall nativa del client si attiva automaticamente. + # + # ★ FORMATO CORRETTO OpenAI Realtime GA Standard: + # {"type":"session.update","session":{"instructions":"…","tools":[…],"tool_choice":"auto"}} + # I campi sono SEMPRE annidati dentro "session": Pydantic strict reject extra fields + # se li metti alla radice, causando "Unknown or invalid event: session.update". + try: + _agent_prompt, _agent_raw_tools = await _agent_profile_data() + if MCP_EXEC_ENABLED: + _realtime_tools = _normalize_tools_for_realtime(_agent_raw_tools) + else: + _realtime_tools = [] + logger.info( + "[MCP INJECT] ⛔ SKIPPED tool injection (MCP_EXEC_ENABLED=False — S2S_TOOL_EXECUTION_MCP_URL not set in .env). " + "Sending only instructions, no tools[]. Agent prompt length=%d.", + len(_agent_prompt or ""), + ) + # --- Insight diario Aquachile → append a instructions --- + _insights_entries: list[dict] | None = None + _insights_block: str = "" + if INSIGHTS_ENABLED: + try: + _insights_entries, _insights_block = await _fetch_insights_for_today() + except Exception as _insights_err: + # NON bloccare mai la sessione audio per fallimento insights fetch + logger.warning( + "[INSIGHTS APPEND] eccezione in _fetch_insights_for_today (ignorata, continuo senza insights): %r", + _insights_err, + ) + _insights_entries, _insights_block = None, "" + else: + logger.debug( + "[INSIGHTS APPEND] skipped (INSIGHTS_ENABLED=False — S2S_INSIGHTS_URL vuoto/non settato in .env)." + ) + # --- Merge finale instructions: profilo agente + insights diario --- + if _agent_prompt and _insights_block: + _final_instructions = ( + _agent_prompt.rstrip() + "\n\n" + _insights_block + "\n" + ) + elif _agent_prompt: + _final_instructions = _agent_prompt.strip() + elif _insights_block: + _final_instructions = _insights_block + else: + _final_instructions = "" + _insights_count_for_log = len(_insights_entries) if _insights_entries else 0 + if _insights_block: + logger.info( + "[INSIGHTS APPEND] ✓ aggiunti %d insights al contesto agente (blockLen=%d, truncated=%s). " + "Ora l'assistente dispone del contexto operativo del día UTC=%s.", + _insights_count_for_log, + len(_insights_block), + "YES" if _insights_entries and len(_insights_entries) > MAX_INSIGHTS_IN_CONTEXT else "NO", + datetime.now(timezone.utc).strftime("%Y-%m-%d"), + ) + # 📝 Log ESATTO del blocco che viene passato nel session.instructions + # (prime ~45 righe o ~3500 chars per auditoria; non tronca MAI l'header). + _all_ins_lines = _insights_block.splitlines() + _cap_chars = 3500 + _cap_lines = min(45, len(_all_ins_lines)) + _truncated_preview = False + _joined = "\n".join(_all_ins_lines[:_cap_lines]) + if len(_joined) > _cap_chars: + _joined = _joined[:_cap_chars].rsplit("\n", 1)[0] + if len(_all_ins_lines) > _cap_lines or len(_insights_block) > _cap_chars: + _truncated_preview = True + _shown_lines = _cap_lines if _truncated_preview else len(_all_ins_lines) + _shown_chars = len(_joined) if _truncated_preview else len(_insights_block) + _trunc_msg = ( + " [TRUNCATED — per vedere tutto imposta MAX_INSIGHTS_IN_CONTEXT maggiore " + "oppure consulta endpoint /api/v1/insights]" + if _truncated_preview else "" + ) + # 👉 Pattern singolo %s + f-string: ELIMINA DEFUNTIVAMENTE il bug + # TypeError: not enough arguments for format string (mismatch args/placeholder) + # e protegge da qualsiasi % dentro gli insights (es. "-1.65%" -36.34%"). + _msg_append = ( + f"[INSIGHTS APPEND] 📋 TESTO INJETTATO NEL PROMPT agent instructions" + f"{_trunc_msg} " + f"(lines={_shown_lines}/{len(_all_ins_lines)}, " + f"chars={_shown_chars}/{len(_insights_block)}, " + f"capChunks=45lines/3500chars):" + f"\n{_joined}" + ) + logger.info("%s", _msg_append) + elif INSIGHTS_ENABLED: + logger.info( + "[INSIGHTS APPEND] SKIP (INSIGHTS_ENABLED=True ma nessun insight disponibile oggi " + "oppure fetch fallito per endpoint non raggiungibile. Non è un errore bloccante: " + "le istruzioni agente contengono solo il profilo standard)." + ) + # unisci instructions: profilo agente Aquachile + default esistente da client + # (il client manderà un secondo session.update con le proprie; + # upstream le mergea. Per sicurezza mandiamo prima il profilo server-side) + _inject_payload: dict = {"type": "session.update", "session": {}} + _session_inner = _inject_payload["session"] + if _final_instructions.strip(): + _session_inner["instructions"] = _final_instructions.strip() + if _realtime_tools: + _session_inner["tools"] = _realtime_tools + _session_inner["tool_choice"] = "auto" + # cap: se 100+ tool upstream potrebbe rifiutare: log warning ma non falliamo + if len(_realtime_tools) > 80: + logger.warning( + "[MCP INJECT] troppi tools (%d) — upstream potrebbe rifiutare. " + "Se non vedi response.function_call_arguments.done prova a ridurre con S2S_REASONING_NODE_RED_MAX_TOOLS o filtro lato Aquachile.", + len(_realtime_tools), + ) + logger.info( + "[MCP INJECT] session.update upstream → client=%s tools=%d hasAgentPrompt=%s tool_choice=%s insights=%d fmt=nested_session", + client_ip, + len(_realtime_tools), + bool(_agent_prompt.strip()), + _session_inner.get("tool_choice", "none"), + _insights_count_for_log, + ) + if _realtime_tools: + logger.debug( + "[MCP INJECT] tool names preview=%s", + json.dumps([t["name"] for t in _realtime_tools][:20], ensure_ascii=True), + ) + await upstream.send(json.dumps(_inject_payload, ensure_ascii=True)) + logger.info("[MCP INJECT] ✓ session.update upstream inviato (tools=%d, insights=%d, fmt=nested_session OK)", len(_realtime_tools), _insights_count_for_log) + except Exception as _inject_err: + # NON blocchiamo mai l'audio per fallimento inject tools + logger.warning( + "[MCP INJECT] FAILED (ignored, continuo senza tools nativi): %r", + _inject_err, + ) + session_ctx = { + "last_user_transcript": "", + "last_item_id": None, + # 🔥 FIX RACE CONDITION session.update: servono a _client_to_upstream + # per mergiare profile+insights+tools IN OGNI session.update CHE + # ARRIVA DAL CLIENT (invece di fare solo inject upfront che + # viene SOVRASCRITTO dal 2ndo session.update del browser 200ms dopo). + "mcp_agent_prompt": _agent_prompt or "", + "mcp_insights_block": _insights_block or "", + "mcp_realtime_tools": _realtime_tools, + } + client_to_upstream = asyncio.create_task( + _client_to_upstream(ws, upstream, client_ip, session_ctx) + ) + upstream_to_client = asyncio.create_task( + _upstream_to_client(ws, upstream, client_ip, session_ctx) + ) + done, pending = await asyncio.wait( + {client_to_upstream, upstream_to_client}, + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + for task in done: + exc = task.exception() + if exc: + raise exc + except WebSocketDisconnect: + logger.debug("[EXT][S2S-PROXY] client=%s disconnected (WSDisconnect)", client_ip) + return + except Exception as exc: + logger.warning("Realtime proxy failed: %r", exc) + logger.debug("[EXT][S2S-PROXY] client=%s ERROR %s", client_ip, type(exc).__name__) + try: + await ws.close(code=1011, reason="Upstream realtime unavailable.") + except Exception: + pass + finally: + logger.debug("[EXT][S2S-PROXY] client=%s TUNNEL CLOSED", client_ip) + + +@app.get("/api/me") +async def me(request: Request): + """Login state, tier, and remaining daily budget. Only meaningful in LB mode; + sets the anonymous tracking cookie when first seen.""" + client_ip = request.client.host if request.client else "?" + logger.debug( + "[INTERNAL] /api/me client=%s LIMITER=%s", + client_ip, LIMITER_ENABLED, + ) + if not LIMITER_ENABLED: + return {"enabled": False} + view = auth.user_view(request) + tier, keys, set_cookie = auth.resolve_identity(request) + unlimited = limiter.budget_for(tier) is None + rem = None if unlimited else await asyncio.to_thread(limiter.remaining, keys, tier) + logger.debug( + "[INTERNAL] /api/me client=%s tier=%s unlimited=%s remaining=%s setCookie=%s", + client_ip, tier, unlimited, rem, bool(set_cookie), + ) + out = { + "enabled": True, + "auth": AUTH_ENABLED, + **view, + "remainingSec": rem, + "limitSec": limiter.budget_for(tier), + "loginUrl": auth.OAUTH_LOGIN_PATH if AUTH_ENABLED else None, + "logoutUrl": auth.OAUTH_LOGOUT_PATH if AUTH_ENABLED else None, + } + resp = JSONResponse(out) + if set_cookie: + auth.set_anon_cookie(resp, set_cookie) + return resp + + +@app.post("/api/search") +async def search(req: SearchRequest): + """Proxy a Google search via Serper.dev. The key stays on the server unless + the user brought their own (then theirs is used for this request only).""" + query = (req.query or "").strip() + if not query: + raise HTTPException(status_code=400, detail="Empty query.") + + key = (req.key or "").strip() or SERPER_KEY + has_user_key = bool((req.key or "").strip()) + if not key: + # No server key and the user didn't supply one — search is unavailable. + raise HTTPException(status_code=503, detail="Search is not configured.") + + headers = {"X-API-KEY": key, "Content-Type": "application/json"} + payload = {"q": query, "num": MAX_RESULTS} + logger.debug( + "[EXT][SERPER] search POST %s query=%s userKey=%s num=%d", + SERPER_URL, json.dumps(query, ensure_ascii=True)[:200], has_user_key, MAX_RESULTS, + ) + try: + async with httpx.AsyncClient(timeout=12.0) as http: + resp = await http.post(SERPER_URL, headers=headers, json=payload) + except httpx.RequestError as exc: + logger.warning("Serper unreachable: %r", exc) + raise HTTPException(status_code=502, detail="Search provider unreachable.") + logger.debug( + "[EXT][SERPER] search RESP status=%s len=%dB", + resp.status_code, len(resp.content or b""), + ) + + if resp.status_code != 200: + # Serper's error body carries the real reason (e.g. "Not enough + # credits") and contains no key, so it's safe to log and relay. + body = resp.text[:300] + logger.warning("Serper error %s: %s", resp.status_code, body) + msg = None + try: + msg = resp.json().get("message") + except Exception: + pass + detail = f"Search provider error ({resp.status_code})" + if msg: + detail += f": {msg}" + raise HTTPException(status_code=502, detail=detail) + + data = resp.json() + results = [] + for item in (data.get("organic") or [])[:MAX_RESULTS]: + results.append( + { + "title": item.get("title", ""), + "snippet": item.get("snippet", ""), + "url": item.get("link", ""), + } + ) + + # A direct answer when Google has one — saves the model a hop. + box = data.get("answerBox") or {} + answer = box.get("answer") or box.get("snippet") or None + if not answer: + kg = data.get("knowledgeGraph") or {} + answer = kg.get("description") or None + + logger.debug( + "[EXT][SERPER] search PARSED hasAnswer=%s resultCount=%d titles=%s", + bool(answer), len(results), [r.get("title", "")[:40] for r in results], + ) + return JSONResponse({"query": query, "answer": answer, "results": results}) + + +@app.post("/api/session") +async def session(request: Request): + """Proxy the session handshake to the load balancer, keeping its URL secret, + and meter conversation time by tier. + + The browser POSTs here (same-origin); we resolve the caller's tier, refuse if + today's budget is already spent (402), otherwise POST /session + and relay the JSON back. The LB body carries a per-session `connect_url` + (compute host + short-lived token) the browser must dial directly — that one + URL is unavoidably exposed, but the stable load-balancer address is not. On a + successful grant we reserve the first time chunk against the day's budget.""" + client_ip = request.client.host if request.client else "?" + if not LOAD_BALANCER_URL: + # No LB configured — this deploy is direct-mode only; the browser should + # never call this. 404 so it's indistinguishable from a missing route. + raise HTTPException(status_code=404, detail="Not found.") + + tier, keys, set_cookie = auth.resolve_identity(request) + # Metering runs only on the deployed Space; off-Space the LB still proxies but + # nothing is tracked. Within metering, unlimited tiers (pro, org) aren't either. + tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None + + logger.debug( + "[EXT][LB] session: client=%s tier=%s tracked=%s LIMITER=%s", + client_ip, tier, tracked, LIMITER_ENABLED, + ) + + # Refuse before troubling the LB if the day's budget is already gone. Done + # here (at enqueue) so we never put a user who can't talk into the queue. + if tracked: + rem = await asyncio.to_thread(limiter.remaining, keys, tier) + logger.debug("[EXT][LB] session: client=%s budget remaining=%s", client_ip, rem) + if rem is not None and rem <= 0: + resp = JSONResponse( + {"tier": tier, "reason": "limit", "remainingSec": 0}, status_code=402 + ) + if set_cookie: + auth.set_anon_cookie(resp, set_cookie) + return resp + + url = f"{LOAD_BALANCER_URL.rstrip('/')}/session" + logger.debug("[EXT][LB] session POST %s (handshake)", url) + try: + async with httpx.AsyncClient(timeout=15.0) as http: + lb = await http.post(url, headers={"Content-Type": "application/json"}, content="{}") + except httpx.RequestError as exc: + logger.warning("Load balancer unreachable: %r", exc) + raise HTTPException(status_code=502, detail="Speech service unreachable.") + logger.debug( + "[EXT][LB] session RESP status=%s len=%dB", + lb.status_code, len(lb.content or b""), + ) + + # The queue is full: the LB replies 503 {state:"at_capacity"}. Relay it as-is + # so the client shows a soft "try again shortly", not a hard error. + if lb.status_code == 503: + body = _safe_json(lb) + if body.get("state") == "at_capacity": + logger.debug("[EXT][LB] session: at_capacity — queue full") + resp = JSONResponse({"state": "at_capacity"}, status_code=503) + if set_cookie: + auth.set_anon_cookie(resp, set_cookie) + return resp + + if lb.status_code != 200: + # The LB's error body may name the reason (e.g. capacity); it carries no + # secret, so relay a trimmed copy. + logger.warning("Session handshake failed %s: %s", lb.status_code, lb.text[:300]) + raise HTTPException(status_code=502, detail=f"Session handshake failed ({lb.status_code}).") + + data = lb.json() + state = data.get("state", "?") + connect_url = (data.get("connect_url") or data.get("connectUrl") or "")[:120] + session_id = data.get("session_id") or data.get("sessionId") + logger.debug( + "[EXT][LB] session PARSED state=%s sessionId=%s hasConnectUrl=%s connectUrl=%s", + state, session_id, bool(connect_url), connect_url, + ) + + # Busy pool: the LB queued us. Relay the ticket untouched — crucially with NO + # reservation, so waiting in line never costs the day's budget. + if data.get("state") == "queued": + data["tier"] = tier + logger.debug("[EXT][LB] session: queued queueId=%s position=%s", + data.get("queue_id") or data.get("queueId"), + data.get("position")) + resp = JSONResponse(data) + if set_cookie: + auth.set_anon_cookie(resp, set_cookie) + return resp + + # A slot was free: reserve the first chunk now and return the grant. + return await _finalize_grant(data, keys, tier, tracked, set_cookie) + + +@app.get("/api/queue/{queue_id}") +async def queue_status(queue_id: str, request: Request): + """Poll a waiting ticket: relay the position, or — when the head of the line + claims a freed slot — reserve the budget now and return the grant. Re-checks the + daily budget at claim, since a multi-minute wait could have spent it elsewhere.""" + client_ip = request.client.host if request.client else "?" + if not LOAD_BALANCER_URL: + raise HTTPException(status_code=404, detail="Not found.") + + tier, keys, set_cookie = auth.resolve_identity(request) + tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None + logger.debug( + "[EXT][LB] queue poll: client=%s queueId=%s tier=%s", + client_ip, queue_id[:16], tier, + ) + + url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}" + logger.debug("[EXT][LB] queue GET %s", url) + try: + async with httpx.AsyncClient(timeout=15.0) as http: + lb = await http.get(url) + except httpx.RequestError as exc: + logger.warning("Load balancer unreachable: %r", exc) + raise HTTPException(status_code=502, detail="Speech service unreachable.") + logger.debug( + "[EXT][LB] queue RESP status=%s len=%dB", + lb.status_code, len(lb.content or b""), + ) + + if lb.status_code == 404: + # Ticket unknown/expired (reaped after we stopped polling). Tell the client + # to start over rather than spin. + logger.debug("[EXT][LB] queue: queueId=%s EXPIRED (404)", queue_id[:16]) + resp = JSONResponse({"state": "expired"}, status_code=404) + if set_cookie: + auth.set_anon_cookie(resp, set_cookie) + return resp + + if lb.status_code != 200: + logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300]) + raise HTTPException(status_code=502, detail=f"Queue poll failed ({lb.status_code}).") + + data = lb.json() + state = data.get("state", "?") + position = data.get("position") + logger.debug( + "[EXT][LB] queue PARSED queueId=%s state=%s position=%s sessionId=%s", + queue_id[:16], state, position, + data.get("session_id") or data.get("sessionId"), + ) + + if data.get("state") == "queued": + data["tier"] = tier + resp = JSONResponse(data) + if set_cookie: + auth.set_anon_cookie(resp, set_cookie) + return resp + + # Claimed a slot. Re-check the budget: it may have been spent in another tab + # during the wait. If so, refuse — the just-claimed slot is now a pending + # session on the LB and its pending-timeout reaper reclaims it shortly. + if tracked: + rem = await asyncio.to_thread(limiter.remaining, keys, tier) + logger.debug( + "[EXT][LB] queue CLAIMED queueId=%s budget remaining=%s", + queue_id[:16], rem, + ) + if rem is not None and rem <= 0: + resp = JSONResponse( + {"tier": tier, "reason": "limit", "remainingSec": 0}, status_code=402 + ) + if set_cookie: + auth.set_anon_cookie(resp, set_cookie) + return resp + + return await _finalize_grant(data, keys, tier, tracked, set_cookie) + + +@app.delete("/api/queue/{queue_id}") +async def queue_leave(queue_id: str): + """Leave the queue from the explicit 'Leave queue' button (a real fetch).""" + if not LOAD_BALANCER_URL: + raise HTTPException(status_code=404, detail="Not found.") + logger.debug("[EXT][LB] queue LEAVE (button) queueId=%s", queue_id[:16]) + await _lb_leave(queue_id) + return {"ok": True} + + +@app.post("/api/queue/end") +async def queue_end(request: Request): + """Leave the queue on teardown/tab-close (navigator.sendBeacon, which can only + POST). Body: { queueId }. Best-effort; the LB reaps the ticket on TTL anyway.""" + if not LOAD_BALANCER_URL: + raise HTTPException(status_code=404, detail="Not found.") + qid = await _queue_id(request) + if qid: + logger.debug("[EXT][LB] queue LEAVE (teardown) queueId=%s", qid[:16]) + await _lb_leave(qid) + return {"ok": True} + + +async def _finalize_grant(data, keys, tier, tracked, set_cookie): + """Shared grant tail (fast path or queue claim): reserve the first chunk, attach + the metering fields the client needs, and set the anon cookie.""" + remaining = None + session_id = data.get("session_id") + if tracked and session_id: + logger.debug( + "[EXT][LB] grant FINALIZE sessionId=%s tier=%s tracked=%s", + session_id, tier, tracked, + ) + await asyncio.to_thread(limiter.begin, session_id, keys, tier) + remaining = await asyncio.to_thread(limiter.remaining, keys, tier) + logger.debug( + "[EXT][LB] grant RESERVED sessionId=%s remaining=%s", + session_id, remaining, + ) + + data.update({ + "tier": tier, + "limited": tracked, + "remainingSec": remaining, + "heartbeatSec": limiter.HEARTBEAT_SEC, + }) + resp = JSONResponse(data) + if set_cookie: + auth.set_anon_cookie(resp, set_cookie) + return resp + + +async def _lb_leave(queue_id: str) -> None: + """Best-effort: tell the LB to drop a waiting ticket.""" + url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}" + logger.debug("[EXT][LB] queue DELETE %s", url) + try: + async with httpx.AsyncClient(timeout=5.0) as http: + resp = await http.delete(url) + logger.debug( + "[EXT][LB] queue DELETE RESP queueId=%s status=%s", + queue_id[:16], resp.status_code, + ) + except httpx.RequestError as exc: + logger.warning("Queue leave failed: %r", exc) + + +def _safe_json(response) -> dict: + try: + body = response.json() + except Exception: + return {} + return body if isinstance(body, dict) else {} + + +def _proxy_public_url(request: Request) -> str: + if not S2S_PROXY_TARGET: + return "" + forwarded_proto = (request.headers.get("x-forwarded-proto") or request.url.scheme).split(",")[0].strip() + forwarded_host = (request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc).split(",")[0].strip() + ws_scheme = "wss" if forwarded_proto in {"https", "wss"} else "ws" + root_path = (request.scope.get("root_path") or "").rstrip("/") + return f"{ws_scheme}://{forwarded_host}{root_path}/v1/realtime" + + +def _augment_client_session_update( + event: dict, + agent_prompt: str, + insights_block: str, + realtime_tools: list[dict], +) -> tuple[dict, bool, dict]: + """ + 🔥 FIX RACE: intercetta ogni session.update proveniente dal browser client + PRIMA che venga mandato a ai-server e ne MENGIA istruzioni lato client + con: server profile Aquachile + insights diario oggi + tools catalog MCP. + + Regola merge per instructions (ordine significativo, prima = più basso peso in LLM): + [1] istruzioni client originali ("Eres el asistente de voz oficial de Aquachile..." + Voice Rules) + [2] "\n\n## Aquachile Agent Profile Server-Side" + profile + [3] "\n\n" + insights_block già formattato con ### Contexto operativo del día + + Tools + tool_choice: imposto solo se realtime_tools non vuoto (MCP_EXEC_ENABLED=True e 79 tool disponibili). + + Ritorna: + (event_patched, was_patched, stats_dict) + - event_patched: dict modificato, o lo stesso oggetto originario se niente da mergiare + - was_patched: True solo se abbiamo toccato il dict + """ + stats = {"merged_instructions_len": 0, "appended_profile": False, "appended_insights": False, "tools_injected_count": 0} + if not isinstance(event, dict) or event.get("type") != "session.update": + return event, False, stats + _has_anything = bool(agent_prompt or insights_block or realtime_tools) + if not _has_anything: + return event, False, stats + # Assicura session wrapper esista (standard GA nested) + if not isinstance(event.get("session"), dict): + event["session"] = {} + sess = event["session"] + base_instructions = sess.get("instructions") or "" + if isinstance(base_instructions, str): + base = base_instructions.strip() + else: + base = "" + extra_parts: list[str] = [] + if agent_prompt and agent_prompt.strip(): + extra_parts.append( + "## Aquachile Agent Profile (injected by S2S proxy server-side)\n" + + agent_prompt.strip() + ) + stats["appended_profile"] = True + if insights_block and insights_block.strip(): + extra_parts.append(insights_block.strip()) + stats["appended_insights"] = True + if base and extra_parts: + merged = base + "\n\n" + "\n\n".join(extra_parts) + elif base: + merged = base + elif extra_parts: + merged = "\n\n".join(extra_parts) + else: + merged = "" + merged = merged.strip() + if merged: + sess["instructions"] = merged + stats["merged_instructions_len"] = len(merged) + if realtime_tools: + sess["tools"] = realtime_tools + sess["tool_choice"] = "auto" + stats["tools_injected_count"] = len(realtime_tools) + was_patched = bool(stats["merged_instructions_len"] or stats["tools_injected_count"]) + return event, was_patched, stats + + +def _proxy_upstream_url(ws: WebSocket) -> str: + target = urlsplit(S2S_PROXY_TARGET) + merged_query = dict(parse_qsl(target.query, keep_blank_values=True)) + merged_query.update(parse_qsl(ws.url.query, keep_blank_values=True)) + return urlunsplit( + ( + target.scheme, + target.netloc, + target.path or "/v1/realtime", + urlencode(merged_query, doseq=True), + target.fragment, + ) + ) + + +def _subprotocols(ws: WebSocket) -> list[str]: + raw = ws.headers.get("sec-websocket-protocol", "") + return [item.strip() for item in raw.split(",") if item.strip()] + + +async def _client_to_upstream(ws: WebSocket, upstream, client_ip: str, session_ctx: dict) -> None: + while True: + message = await ws.receive() + if message["type"] == "websocket.disconnect": + # #region debug-point B:client-disconnect + await _report_server_debug( + "B", + "server.py:_client_to_upstream", + "client websocket disconnected", + {"code": message.get("code")}, + ) + # #endregion + logger.debug( + "[EXT][S2S-PROXY] client=%s client→upstream DISCONNECT code=%s", + client_ip, message.get("code"), + ) + await upstream.close() + return + if message.get("text") is not None: + # #region debug-point B:client-text-forward + summary = _realtime_event_summary(message["text"]) + if summary: + await _report_server_debug( + "B", + "server.py:_client_to_upstream", + "forwarding client event to upstream", + summary, + ) + event_type = summary.get("type") + # --- INPUT UTENTE lato client (text typed / inserted items) --- + user_text = "" + if event_type == "conversation.item.created" and summary.get("role") == "user": + user_text = (summary.get("text") or "").strip() + if user_text: + logger.info( + "[INPUT UTENTE] client=%s text=%s", + client_ip, user_text, + ) + if (event_type not in _NOISY_CLIENT_EVENT_TYPES + and event_type not in _NOISY_REALTIME_EVENT_TYPES): + logger.debug( + "[EXT][S2S-PROXY] client=%s →UPSTREAM text event=%s summary=%s", + client_ip, event_type, json.dumps(summary, ensure_ascii=True)[:300], + ) + # #endregion + # --- 🔥 FIX RACE: PATCH SESSION.UPDATE CLIENT PRIMA DI INOLTRE A UPSTREAM --- + # Il browser manda un suo session.update (con instructions base + voice) + # POCHI ms DOPO il nostro inject upfront. Senza questo patch, quello + # del client SOVRASCRIVE instructions e tools completamente (handle_session_update + # usa deep merge: instructions campo scalare viene sovrascritto, non concatenato). + _raw_text = message["text"] + _forward_this = _raw_text + _was_patched = False + try: + _evt_client = json.loads(_raw_text) + if ( + isinstance(_evt_client, dict) + and _evt_client.get("type") == "session.update" + ): + _a_prompt = session_ctx.get("mcp_agent_prompt", "") or "" + _a_insights = session_ctx.get("mcp_insights_block", "") or "" + _a_tools: list[dict] = session_ctx.get("mcp_realtime_tools") or [] + _evt_patched, _was_patched, _patch_stats = _augment_client_session_update( + _evt_client, _a_prompt, _a_insights, _a_tools + ) + if _was_patched: + _forward_this = json.dumps(_evt_patched, ensure_ascii=True) + logger.info( + "[MCP INJECT] PATCHED client session.update →UPSTREAM: " + "mergedInstructions=%dB appendedProfile=%s appendedInsights=%s " + "toolsInjected=%s (prima era senza insights/tools — era la causa del " + "PROMPT LLM senza insights in ai-server log).", + _patch_stats["merged_instructions_len"], + bool(_patch_stats["appended_profile"]), + bool(_patch_stats["appended_insights"]), + _patch_stats["tools_injected_count"] or "NONE", + ) + else: + logger.info( + "[MCP INJECT] PASSTHROUGH client session.update →UPSTREAM: " + "no server data to merge (nothing enabled / empty profile / insights disabled).", + ) + except Exception as _patch_err: + # NEVER-BREAK-AUDIO: qualunque errore nel parsing o nel patching, + # inoltro il messaggio originale così com'era. + logger.warning( + "[MCP INJECT] errore non bloccante in patching client session.update " + "(fallback to passthrough original message): %r", + _patch_err, + ) + _forward_this = _raw_text + _was_patched = False + await upstream.send(_forward_this) + elif message.get("bytes") is not None: + # #region debug-point B:client-bytes-forward + await _report_server_debug( + "B", + "server.py:_client_to_upstream", + "forwarding client binary frame to upstream", + {"bytes": len(message["bytes"])}, + ) + logger.debug( + "[EXT][S2S-PROXY] client=%s →UPSTREAM bytes=%dB", + client_ip, len(message["bytes"]), + ) + # #endregion + await upstream.send(message["bytes"]) + + +async def _upstream_to_client(ws: WebSocket, upstream, client_ip: str, session_ctx: dict) -> None: + async for message in upstream: + if isinstance(message, bytes): + # #region debug-point B:upstream-bytes-forward + await _report_server_debug( + "B", + "server.py:_upstream_to_client", + "forwarding upstream binary frame to client", + {"bytes": len(message)}, + ) + logger.debug( + "[EXT][S2S-PROXY] client=%s UPSTREAM→ bytes=%dB", + client_ip, len(message), + ) + # #endregion + await ws.send_bytes(message) + else: + # #region debug-point B:upstream-text-forward + summary = _realtime_event_summary(message) + if summary: + await _report_server_debug( + "B", + "server.py:_upstream_to_client", + "forwarding upstream event to client", + summary, + ) + event_type = summary.get("type") + # --- INPUT UTENTE (server-side STT emesso dall'upstream) --- + user_text = "" + if event_type == "conversation.item.input_audio_transcription.completed": + user_text = (summary.get("transcript") or "").strip() + item_id = summary.get("item_id") + if user_text: + logger.info( + "[INPUT UTENTE] client=%s stt=%s", + client_ip, user_text, + ) + session_ctx["last_user_transcript"] = user_text + if item_id: + session_ctx["last_item_id"] = item_id + elif event_type == "conversation.item.created" and summary.get("role") == "user": + user_text = (summary.get("text") or "").strip() + item_id = summary.get("item_id") or summary.get("id") + if user_text: + logger.info( + "[INPUT UTENTE] client=%s text=%s", + client_ip, user_text, + ) + session_ctx["last_user_transcript"] = user_text + if item_id: + session_ctx["last_item_id"] = item_id + # --- OUTPUT AGENTE: tutte le varianti --- + agent_text = "" + response_id = summary.get("response_id") or summary.get("id") + item_id = summary.get("item_id") + if event_type in {"response.audio_transcript.done", + "response.output_audio_transcript.done"}: + agent_text = (summary.get("text") or "").strip() + if agent_text: + logger.info( + "[OUTPUT AGENTE] client=%s tts=%s", + client_ip, agent_text, + ) + # ── Forward fire-and-forget a Node-RED (FASE 1 LOG-ONLY) ── + asyncio.create_task(_forward_agent_text_to_nodered({ + "tts": agent_text, + "response_id": response_id, + "item_id": item_id, + "client_ip": client_ip, + "role": "assistant", + "source_event": event_type, + "user_transcript": session_ctx.get("last_user_transcript") or "", + })) + else: + # Ispezione: questo evento custom non emette testo nel campo atteso + raw_preview = summary.get("_raw_preview") or json.dumps(summary, ensure_ascii=True) + logger.debug( + "[OUTPUT AGENTE][ISPEZIONE] client=%s %s ha summary=%s raw=%s", + client_ip, event_type, + json.dumps(summary, ensure_ascii=True)[:400], + raw_preview[:800], + ) + elif event_type == "response.content_part.done": + agent_text = (summary.get("text") or "").strip() + if agent_text: + logger.info( + "[OUTPUT AGENTE] client=%s content_part=%s", + client_ip, agent_text, + ) + asyncio.create_task(_forward_agent_text_to_nodered({ + "tts": agent_text, + "response_id": response_id, + "item_id": item_id, + "client_ip": client_ip, + "role": "assistant", + "source_event": event_type, + "user_transcript": session_ctx.get("last_user_transcript") or "", + })) + elif event_type == "response.output_item.done" and summary.get("role") == "assistant": + agent_text = (summary.get("text") or "").strip() + if agent_text: + logger.info( + "[OUTPUT AGENTE] client=%s output_item=%s", + client_ip, agent_text, + ) + asyncio.create_task(_forward_agent_text_to_nodered({ + "tts": agent_text, + "response_id": response_id, + "item_id": item_id, + "client_ip": client_ip, + "role": "assistant", + "source_event": event_type, + "user_transcript": session_ctx.get("last_user_transcript") or "", + })) + elif event_type == "conversation.item.created" and summary.get("role") == "assistant": + agent_text = (summary.get("text") or "").strip() + if agent_text: + logger.info( + "[OUTPUT AGENTE] client=%s created=%s", + client_ip, agent_text, + ) + asyncio.create_task(_forward_agent_text_to_nodered({ + "tts": agent_text, + "response_id": response_id, + "item_id": item_id, + "client_ip": client_ip, + "role": "assistant", + "source_event": event_type, + "user_transcript": session_ctx.get("last_user_transcript") or "", + })) + # Log dettagliato evento solo se non è un tipo rumoroso + if event_type not in _NOISY_REALTIME_EVENT_TYPES: + logger.debug( + "[EXT][S2S-PROXY] client=%s UPSTREAM→ event=%s summary=%s", + client_ip, event_type, json.dumps(summary, ensure_ascii=True)[:300], + ) + # ── Upstream error filter: non propagare al browser errori interni + # causati dal nostro inject di tools[] (sono noise per l'utente finale). + # (strategy B non funziona attualmente (ai-server non supporta tools[]). + # + # Formati errore upstream gestiti (entrambi con controllo su message e su type annidato): + # (A) Wrapper standard (OSSERVATO NEI LOG REALI): + # {"type":"error","error":{"message":"Unknown or invalid event: session.update","type":"unknown_or_invalid_event","code":null,...}} + # (B) Flat fallback (per altri upstream compatibili): + # {"type":"error","message":"...","code":"..."} + if isinstance(message, str): + try: + _evt = json.loads(message) + except Exception: + _evt = None + if isinstance(_evt, dict): + _evt_type = _evt.get("type") + if _evt_type == "error": + # ── Estrazione robusta message + error_type: wrapper annidato PRIORITARIO + _err_msg = "" + _err_type = "" + _inner = _evt.get("error") + if isinstance(_inner, dict): + for _k in ("message", "error", "msg"): + _v = _inner.get(_k) + if isinstance(_v, str) and _v.strip(): + _err_msg = _v.strip() + break + _t = _inner.get("type") + if isinstance(_t, str): + _err_type = _t + # Fallback flat se non abbiamo trovato nel wrapper + if not _err_msg: + for _k in ("message", "error", "msg"): + _v = _evt.get(_k) + if isinstance(_v, str) and _v.strip(): + _err_msg = _v.strip() + break + if not _err_type: + _t = _evt.get("code") + if isinstance(_t, str): + _err_type = _t + # ── Decisioni soppressione + _suppress = False + _suppress_reason = "" + if "Unknown or invalid event: session.update" in _err_msg: + _suppress = True + _suppress_reason = ( + "rifiuto del nostro inject session.update con tools[] — ai-server attualmente non supporta " + "tools[]. Modifica richiesta in arrivo. Loggo solo server-side e non inoltro al browser." + ) + elif "session.update" in _err_msg and ("tool" in _err_msg.lower() or "invalid" in _err_msg.lower()): + _suppress = True + _suppress_reason = ( + f"rifiuto di session.update da upstream (message): {_err_msg[:160]}. Non inoltro al browser." + ) + elif _err_type == "unknown_or_invalid_event" and "session.update" in _err_msg: + _suppress = True + _suppress_reason = ( + f"rifiuto di session.update da upstream (type=unknown_or_invalid_event): {_err_msg[:160]}. Non inoltro al browser." + ) + if _suppress: + logger.info( + "[MCP INJECT] suppressed upstream error (non mandato al browser): %s", + _suppress_reason, + ) + logger.debug( + "[MCP INJECT] full upstream suppressed error payload=%s", + json.dumps(_evt, ensure_ascii=True)[:600], + ) + return + # ── Log ispezione: se arriviamo qua l'errore NON è stato soppresso. + # Facciamo sempre vedere message+type per capire se aggiungere nuove regole. + logger.debug( + "[MCP INJECT] upstream error NOT suppressed (passthrough to browser) — " + "evt.error.message=%s evt.error.type=%s evtTopKeys=%s", + _err_msg[:200] if _err_msg else "(empty)", + _err_type or "(none)", + sorted(_evt.keys()), + ) + # #endregion + await ws.send_text(message) + + +async def _queue_id(request: Request) -> str: + """Pull `queueId` from a JSON body, tolerating sendBeacon's blob posts.""" + try: + data = await request.json() + except Exception: + return "" + return (data or {}).get("queueId", "") if isinstance(data, dict) else "" + + +async def _session_id(request: Request) -> str: + """Pull `sessionId` from a JSON body, tolerating sendBeacon's blob posts.""" + try: + data = await request.json() + except Exception: + return "" + return (data or {}).get("sessionId", "") if isinstance(data, dict) else "" + + +@app.post("/api/session/heartbeat") +async def session_heartbeat(request: Request): + """Extend the live reservation one chunk at a time. `expired` once the day's + budget is spent — the client then tears down.""" + client_ip = request.client.host if request.client else "?" + if not LIMITER_ENABLED: + raise HTTPException(status_code=404, detail="Not found.") + sid = await _session_id(request) + alive = bool(sid) and await asyncio.to_thread(limiter.heartbeat, sid) + logger.debug( + "[INTERNAL] /api/session/heartbeat client=%s sessionId=%s alive=%s", + client_ip, (sid or "")[:16], alive, + ) + return {"expired": not alive} + + +@app.post("/api/session/end") +async def session_end(request: Request): + """Clean teardown: reconcile to real elapsed time and refund the unused + chunk. Sent via navigator.sendBeacon, so it must succeed without a response.""" + client_ip = request.client.host if request.client else "?" + if not LIMITER_ENABLED: + raise HTTPException(status_code=404, detail="Not found.") + sid = await _session_id(request) + logger.debug( + "[INTERNAL] /api/session/end client=%s sessionId=%s", + client_ip, (sid or "")[:16], + ) + if sid: + await asyncio.to_thread(limiter.end, sid) + return {"ok": True} + + +@app.middleware("http") +async def no_cache_static_assets(request: Request, call_next): + response = await call_next(request) + path = request.url.path or "" + nocache_ext = (".html", ".js", ".mjs", ".css", ".wasm", ".worklet.js") + if any(path.endswith(ext) for ext in nocache_ext) or path == "/" or path.endswith("/"): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + +# Static front-end. Registered last so the /api routes win. `html=True` serves +# index.html at "/". The repo is public anyway, so serving the dir is fine. +app.mount("/", StaticFiles(directory=HERE, html=True), name="static") diff --git a/demo/style.css b/demo/style.css new file mode 100644 index 0000000..575ff89 --- /dev/null +++ b/demo/style.css @@ -0,0 +1,2826 @@ +:root { + --bg: #0a0b10; + --bg-elev: #13151c; + --bg-elev-2: #1b1e29; + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.16); + --text: #f5f6fa; + --text-dim: rgba(245, 246, 250, 0.65); + --text-faint: rgba(245, 246, 250, 0.42); + + --accent: #8b7dff; + --accent-2: #22d3ee; + --listening: #22d3ee; + --speaking: #8b7dff; + --processing: #f59e0b; + --error: #ff6a75; + --success: #34d399; + + /* Mono is the "machine voice": reserved for system text the app emits — + * the orb's status caption, tool calls, the transport tag. Body/UI stays + * Inter. */ + --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + + /* Thesis: color belongs to the voice. The chrome is monochrome; saturated + * hue appears only on the orb and on these tiny role echoes, which mirror + * the orb's own state colors (you listening = cyan, assistant = violet, + * tool = amber) so the transcript reads in the same color language. */ + --voice-user: var(--accent-2); + --voice-assistant: var(--accent); + --voice-tool: var(--processing); + + /* Smoothed mic RMS in [0..1], updated every frame from JS while a session + * is active. Used by audio-reactive circle states. */ + --audio-level: 0; + /* Five log-spaced frequency bands extracted from the mic analyser; + * drive each bar's height independently for a real "spectrum" feel. */ + --bar0: 0; + --bar1: 0; + --bar2: 0; + --bar3: 0; + --bar4: 0; + + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 22px; + + --shadow-soft: 0 10px 40px rgba(0, 0, 0, 0.35); + + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: radial-gradient(ellipse at 50% 20%, #1a1c28 0%, var(--bg) 60%); + color: var(--text); + min-height: 100vh; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +button { + font-family: inherit; +} + +a { + color: var(--text); + text-decoration: none; + border-bottom: 1px solid var(--border-strong); +} +a:hover { + border-bottom-color: var(--text); +} + +#app { + display: grid; + grid-template-rows: auto 1fr auto; + min-height: 100vh; +} + +.hidden { + display: none !important; +} + +/* ─── Topbar ──────────────────────────────────────────────────────────── */ + +/* Topbar is just a floating row of controls over the stage - no + * separator line, no background. Same story for the footer. Keeps the + * app feeling like one continuous canvas. */ +.topbar { + display: flex; + /* Top-align so the right control cluster sits up with the title instead of + * floating to the vertical middle of the tall identity block. */ + align-items: flex-start; + justify-content: space-between; + padding: 18px 28px; + /* Default stacking (below the conversation panel, z 200) so the panel drawer + * covers the controls when it's open. */ +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + font-size: 14px; + letter-spacing: 0.01em; + color: var(--text-dim); +} + +/* Transport tag rides the wordmark as a system label, not prose — mono, + * dimmed, the only mono in the topbar. */ +.brand-tag { + font-family: var(--font-mono); + font-weight: 500; + font-size: 11px; + letter-spacing: 0.02em; + opacity: 0.55; +} + +.brand .brand-logo { + width: 26px; + height: 26px; + object-fit: contain; + flex: none; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.45)); + transition: transform 0.2s ease; +} +.brand .brand-logo:hover { + transform: translateY(-1px) rotate(-2deg); +} + +/* Narrow viewports (typically the mobile shell iframe and phone-sized + browser windows): the full product name pushes the topbar's right + cluster off-screen. Keep the logo as the brand cue and drop the + wordmark - the orb already gives enough context. */ +@media (max-width: 600px) { + /* The full identity stack is too tall for a phone topbar. Show only the + * title + (i); the popup carries the blurb, credits and pipeline. + * `.brand` prefix raises specificity so this beats the later base + * `.ident-meta { display: flex }` rule regardless of source order. */ + .brand .ident-blurb, + .brand .ident-meta { + display: none; + } + .ident-title { + font-size: 18px; + } + /* (i) moves into the right-hand control cluster on phones, leaving the + * title alone on the left. */ + .brand .about-btn { + display: none; + } + .about-btn-mobile { + display: inline-flex; + } + /* Keep phone icons at the current compact size — the desktop bump above + * shouldn't leak into the mobile topbar. */ + .topbar-right .icon-btn { + width: 36px; + height: 36px; + } + .topbar-right .icon-btn svg { + width: 18px; + height: 18px; + } + /* Keep the account control compact on phones: avatar-only chip, shorter pill. */ + .account-chip { + height: 36px; + padding: 0 6px; + } + .account-handle { + display: none; + } + .signin-pill { + height: 36px; + padding: 0 12px; + } +} + +.topbar-right { + display: flex; + align-items: center; + gap: 10px; +} + +/* The HF user pill: compact avatar + handle. Rendered as a flex row so + * the avatar stays aligned with the text baseline even when the text + * wraps on narrow viewports. */ +.hf-user { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-dim); + padding: 4px 10px 4px 4px; + border-radius: 999px; + background: var(--bg-elev); + border: 1px solid var(--border); + line-height: 1; +} + +.hf-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + object-fit: cover; + background: color-mix(in srgb, var(--accent) 25%, var(--bg-elev-2)); + /* Hidden until an actual URL loads (see `setHfAvatar` in main.ts). + * Keeps the initial login flash from showing a broken image icon. */ + opacity: 0; + transition: opacity 0.25s ease; + flex: none; +} +.hf-avatar.loaded { + opacity: 1; +} + +.hf-user-name { + white-space: nowrap; +} + +/* ─── Transport pill ────────────────────────────────────────────── + * Surface the actual network path WebRTC picked for the robot peer + * connection (LAN, direct-through-NAT, or TURN-relayed). Lets the + * user spot at a glance when audio is going through the internet + * instead of staying on-prem. + */ +.transport-pill { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.02em; + padding: 5px 10px; + border-radius: 999px; + background: var(--bg-elev); + border: 1px solid var(--border); + color: var(--text-dim); + user-select: none; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; +} + +.transport-pill .transport-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 25%, transparent); + flex: none; +} + +/* Bitrate readout sits after the kind label. Dimmer + monospace so the + * kind (LAN / Direct / Relayed) stays the primary info and the numbers + * don't wiggle the layout as digits change. */ +.transport-pill .transport-bitrate { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + color: color-mix(in srgb, currentColor 75%, var(--text-dim)); + opacity: 0.85; + padding-left: 6px; + border-left: 1px solid color-mix(in srgb, currentColor 25%, transparent); +} +.transport-pill .transport-bitrate:empty { + display: none; +} + +.transport-pill.transport-checking { + color: var(--text-dim); +} +.transport-pill.transport-checking .transport-dot { + animation: transport-blink 1.2s ease-in-out infinite; +} + +.transport-pill.transport-lan { + color: #4ade80; + border-color: color-mix(in srgb, #4ade80 35%, var(--border)); + background: color-mix(in srgb, #4ade80 10%, var(--bg-elev)); +} + +.transport-pill.transport-direct { + color: #60a5fa; + border-color: color-mix(in srgb, #60a5fa 35%, var(--border)); + background: color-mix(in srgb, #60a5fa 10%, var(--bg-elev)); +} + +.transport-pill.transport-relay { + color: #fbbf24; + border-color: color-mix(in srgb, #fbbf24 35%, var(--border)); + background: color-mix(in srgb, #fbbf24 10%, var(--bg-elev)); +} + +@keyframes transport-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +.icon-btn { + background: var(--bg-elev); + border: 1px solid var(--border); + color: var(--text-dim); + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + touch-action: manipulation; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.icon-btn:hover { + background: var(--bg-elev-2); + color: var(--text); + border-color: var(--border-strong); +} +/* Topbar control cluster reads a touch bigger on desktop. Phones keep the + * compact 36px (reset in the mobile media query). */ +.topbar-right .icon-btn { + width: 40px; + height: 40px; +} +.topbar-right .icon-btn svg { + width: 20px; + height: 20px; +} + +/* ─── Account (HF login chip + sign-in pill + popover) ─────────────────────── */ +.account { + position: relative; + display: inline-flex; + align-items: center; +} +/* Signed-out: a compact pill that reads as the primary affordance. */ +.signin-pill { + display: inline-flex; + align-items: center; + gap: 7px; + height: 40px; + padding: 0 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--bg-elev); + color: var(--text); + font-size: 13px; + font-weight: 600; + text-decoration: none; + transition: background 0.15s, border-color 0.15s; +} +.signin-pill svg { + width: 16px; + height: 16px; + flex: none; +} +.signin-pill:hover { + background: var(--bg-elev-2); + border-color: var(--text); +} +/* Narrow viewports: collapse the pill to an icon-only square. */ +@media (max-width: 800px) { + .signin-pill { + gap: 0; + width: 40px; + padding: 0; + justify-content: center; + } + .signin-pill span { + display: none; + } +} +/* Signed-in: avatar + handle chip. */ +.account-chip { + display: inline-flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 10px 0 6px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-elev); + color: var(--text-dim); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.account-chip:hover { + background: var(--bg-elev-2); + color: var(--text); + border-color: var(--border-strong); +} +.account-avatar { + width: 26px; + height: 26px; + border-radius: 50%; + object-fit: cover; + flex: none; +} +.account-avatar-fallback { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--bg-elev-2); + color: var(--text); + font-size: 12px; + font-weight: 700; +} +.account-handle { + font-size: 13px; + font-weight: 600; + max-width: 12ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.account-pro { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + padding: 2px 5px; + border-radius: 5px; + background: var(--text); + color: var(--bg); +} +/* Org "Team" badge: same pill as PRO, accent-coloured so it reads as + * unlimited without claiming a paid PRO subscription. */ +.account-team { + background: var(--accent); + color: var(--bg); +} +.account-pop { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 200px; + padding: 6px; + border-radius: var(--radius-md); + border: 1px solid var(--border-strong); + background: var(--bg-elev); + box-shadow: var(--shadow-soft); + z-index: 50; +} +.account-pop[hidden] { + display: none; +} +.account-pop-row { + padding: 8px 10px; +} +.account-pop-name { + font-weight: 600; + font-size: 13px; +} +.account-pop-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-top: 0; + font-size: 12px; + color: var(--text-dim); +} +.account-tier { + font-weight: 600; + color: var(--text); +} +.account-pop-link { + display: block; + padding: 8px 10px; + border-radius: var(--radius-sm); + color: var(--text-dim); + font-size: 13px; + text-decoration: none; + transition: background 0.15s, color 0.15s; +} +.account-pop-link:hover { + background: var(--bg-elev-2); + color: var(--text); +} +.account-signout { + border-top: 1px solid var(--border); + margin-top: 4px; + padding-top: 10px; + border-radius: 0 0 var(--radius-sm) var(--radius-sm); +} + +/* ─── Daily-limit modal ────────────────────────────────────────────────────── */ +.limit-modal { + width: min(400px, 92vw); +} +.limit-card { + position: relative; + align-items: center; + text-align: center; + gap: 14px; + padding: 34px 28px 26px; +} +.limit-close { + position: absolute; + top: 12px; + right: 12px; +} +/* HF smiling face (brand yellow) on a neutral badge — friendly, not a stop sign. + * The logo is the single pop of colour, so the badge itself stays quiet. */ +.limit-badge { + width: 66px; + height: 66px; + border-radius: 50%; + display: grid; + place-items: center; + margin: 2px auto 2px; + background: var(--bg-elev-2); + border: 1px solid var(--border-strong); + box-shadow: 0 0 0 6px rgba(255, 210, 30, 0.06); +} +.limit-badge .hf-logo { + width: 46px; + height: auto; +} +.limit-title { + margin: 0; + font-size: 20px; + font-weight: 700; + letter-spacing: 0; +} +.limit-msg { + color: var(--text-dim); + font-size: 14px; + line-height: 1.55; + margin: 0; + max-width: 30ch; +} +.limit-cta { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 4px; +} +.limit-cta .hf-logo { + width: 20px; + height: auto; + flex: none; +} +.limit-note { + margin: 0; + font-size: 12px; + color: var(--text-faint); +} +#limit-cta[hidden] { + display: none; +} + +/* ─── Stage ───────────────────────────────────────────────────────────── */ + +.stage { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 24px 32px; + gap: 20px; + /* Let the stage shrink inside the grid's 1fr row so a height-capped orb can + * give back space rather than forcing the whole app to overflow. */ + min-height: 0; +} + +/* ─── Central circle ──────────────────────────────────────────────────── */ + +/* Wraps the orb and its two side controls so they stay aligned on one row. */ +.orb-wrap { + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: clamp(18px, 3vw, 32px); +} + +.circle { + position: relative; + /* Cap by height as well as width (min(vw, vh)-style): in a short container + * the orb shrinks instead of overflowing, so the page stays scroll-free even + * when embedded in a small box. On normal screens the 320px cap still wins, + * so the desktop size is unchanged. */ + width: clamp(140px, min(38vw, 46vh), 320px); + aspect-ratio: 1 / 1; + border-radius: 50%; + border: none; + background: transparent; + padding: 0; + cursor: pointer; + outline: none; + display: grid; + place-items: center; + color: var(--glow, var(--accent)); + transition: transform 0.18s ease, filter 0.18s ease; + -webkit-tap-highlight-color: transparent; + /* Treat taps as immediate clicks (no 300ms delay / double-tap-zoom). */ + touch-action: manipulation; +} +.circle:hover { + filter: brightness(1.08); +} +.circle:active { + transform: scale(0.97); + filter: brightness(0.92); +} +.circle:focus-visible .circle-core { + outline: 2px solid var(--glow, var(--accent)); + outline-offset: 6px; +} +.circle[disabled] { + cursor: default; + opacity: 0.75; +} + +.circle-glow { + position: absolute; + inset: 0; + border-radius: 50%; + background: radial-gradient(circle at center, var(--glow, var(--accent)) 0%, transparent 65%); + filter: blur(28px); + opacity: 0.5; + transform: scale(1); + transition: opacity 0.25s, background 0.25s, transform 1.4s ease-in-out; + pointer-events: none; +} + +/* Two nested circular rings: the inner tracks the core edge, the outer + * expands / fades to convey "audio radiating out" during speaking. */ +.circle-ring, +.circle-ring-outer { + position: absolute; + top: 50%; + left: 50%; + border-radius: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + transition: opacity 0.4s ease, border-color 0.4s ease, transform 0.3s ease; +} +.circle-ring { + width: 82%; + height: 82%; + border: 1.5px solid color-mix(in srgb, var(--glow, var(--accent)) 35%, transparent); + opacity: 0.35; +} +.circle-ring-outer { + width: 94%; + height: 94%; + border: 1px solid color-mix(in srgb, var(--glow, var(--accent)) 22%, transparent); + opacity: 0; +} + +.circle-core { + position: relative; + width: 72%; + height: 72%; + border-radius: 50%; + background: radial-gradient( + circle at 35% 28%, + color-mix(in srgb, var(--glow, var(--accent)) 28%, transparent), + color-mix(in srgb, var(--glow, var(--accent)) 10%, transparent) 55%, + color-mix(in srgb, var(--glow, var(--accent)) 5%, transparent) + ); + border: 2px solid color-mix(in srgb, var(--glow, var(--accent)) 40%, transparent); + display: grid; + place-items: center; + box-shadow: + 0 0 32px color-mix(in srgb, var(--glow, var(--accent)) 22%, transparent), + inset 0 0 28px color-mix(in srgb, var(--glow, var(--accent)) 18%, transparent); + transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease, transform 0.4s ease; +} + +/* Indicator slot: a single SVG / spinner / bar group is visible at a time, + * driven by the state class on `.circle`. */ +.circle-indicator { + position: relative; + width: 44%; + height: 44%; + display: grid; + place-items: center; + color: var(--glow, var(--accent)); +} +.circle-indicator > .ind { + grid-area: 1 / 1; + opacity: 0; + transform: scale(0.85); + transition: opacity 0.25s ease, transform 0.25s ease; + pointer-events: none; +} +.circle-indicator > svg.ind { + width: 60%; + height: 60%; +} + +/* Spinner: a rotating ring gap, CSS-only. + * + * Note: the base `.circle-indicator > .ind` rule forces `transform: + * scale(.85)` / `scale(1)` on every indicator to drive the show/hide + * transition. If we only rotate here, the browser has to interpolate + * between `scale(1)` and `rotate(360deg)` (two different transform + * functions), which produces a broken, barely-moving animation. So we + * include the scale explicitly in the keyframes and bump specificity + * with `!important` so the spinner always wins over the state rule. */ +.ind-spinner { + width: 48%; + height: 48%; + border: 3px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + opacity: 0; + animation: ind-spin 0.9s linear infinite; +} + +/* Thinking dots: 3 soft pulsing dots while the model is composing a + * response. Apple-style cadence: each dot scales up and brightens in + * turn, staggered by ~160 ms. Per-dot animation is on the child, so + * the parent's scale(.85 → 1) show/hide transform composes cleanly. */ +.ind-thinking { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 60%; + height: 60%; +} +.ind-thinking .dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + opacity: 0.3; + animation: thinking-dot 1.25s ease-in-out infinite; +} +.ind-thinking .dot:nth-child(1) { animation-delay: 0s; } +.ind-thinking .dot:nth-child(2) { animation-delay: 0.16s; } +.ind-thinking .dot:nth-child(3) { animation-delay: 0.32s; } + +/* Bars: 5 vertical pills driven by --bar0..--bar4 CSS vars. */ +.ind-bars { + display: flex; + align-items: center; + gap: 5px; + height: 42%; +} +.ind-bars .bar { + width: 4px; + min-height: 4px; + border-radius: 3px; + background: currentColor; + opacity: 0.7; + --h: var(--bar0, 0); + height: calc(4px + var(--h) * 36px); + transition: height 0.08s ease-out, opacity 0.08s ease-out; +} +.ind-bars .bar:nth-child(1) { --h: var(--bar0); } +.ind-bars .bar:nth-child(2) { --h: var(--bar1); } +.ind-bars .bar:nth-child(3) { --h: var(--bar2); } +.ind-bars .bar:nth-child(4) { --h: var(--bar3); } +.ind-bars .bar:nth-child(5) { --h: var(--bar4); } +.ind-bars .bar { + opacity: calc(0.55 + 0.45 * var(--h)); +} + +/* Active indicator per state. + * + * Note: `ai-speaking` deliberately has NO indicator here. The orb + * itself becomes the indicator by pulsing on Reachy's voice (see the + * `--ai-audio-level` rules further down), which is a lot clearer and + * less confusing than reusing the mic-bars (which viewers would read + * as "you are speaking"). */ +.state-signed-out .ind-connect, +.state-authenticated .ind-mic, +.state-ready .ind-mic, +.state-connecting .ind-spinner, +.state-connected .ind-spinner, +.state-auto-selecting .ind-spinner, +.state-starting .ind-spinner, +.state-queued .ind-spinner, +.state-processing .ind-thinking, +.state-listening .ind-bars, +.state-user-speaking .ind-bars, +.state-ai-speaking .ind-voice, +.state-error .ind-error { + opacity: 1; + transform: scale(1); +} + +/* AI speaking indicator: speaker + two sound waves. + * + * Each wave pulses outward (opacity + stroke grow) with a quarter- + * beat offset so it reads as sound radiating out. Kept as a pure + * CSS animation so the icon is always visually alive even between + * syllables when --ai-audio-level momentarily dips. */ +.ind-voice .wave { + transform-origin: 50% 50%; + animation: voice-wave 1.35s ease-out infinite; +} +.ind-voice .wave-1 { animation-delay: 0s; } +.ind-voice .wave-2 { animation-delay: 0.35s; } + +/* Idle indicators: a chain-link "connect" glyph for the signed-out + * step (invites the user to authenticate with HF) and a microphone + * once a session is ready. Both picked up by the generic + * `.circle-indicator > svg.ind` sizing rule (60% × 60% of the slot) + * and rely on per-state opacity transitions for show / hide. */ +.ind-connect, +.ind-mic { + color: color-mix(in srgb, var(--glow, var(--accent)) 85%, white); +} + +/* ─── Caption below the circle ────────────────────────────────────────── */ + +/* The caption under the orb is meant to whisper, not shout: micro-label + * vibe, uppercase, letter-spaced, muted. Only appears for actionable / + * transitional states (see STATE_VIEWS). During a live conversation the + * orb alone carries the state so we collapse this row entirely. */ +.circle-caption { + margin: 0; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-faint); + min-height: 1.2em; + text-align: center; + opacity: 0.75; + transition: opacity 0.25s ease, color 0.25s ease, transform 0.25s ease, + min-height 0.25s ease; +} +.circle-caption.empty { + opacity: 0; + min-height: 0; + transform: translateY(-4px); + pointer-events: none; +} +.circle-caption.muted { + color: var(--text-faint); + opacity: 0.65; +} +.circle-caption.error { + color: var(--error); + opacity: 1; + letter-spacing: 0.08em; +} + +/* Warm, human line under the mono caption — sentence case, only while queued. + * The caption keeps the terse position; this reassures. */ +.circle-subcaption { + margin: 8px 0 0; + max-width: 32ch; + font-size: 13.5px; + line-height: 1.5; + color: var(--text-dim); + text-align: center; + text-wrap: balance; + transition: opacity 0.25s ease; +} +.circle-subcaption[hidden] { display: none; } + +/* Queue actions sit under the caption: "Join now" (primary, only when it's your + * turn) stacked above the quiet "Leave queue" escape hatch. */ +.queue-actions { + margin-top: 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} +.queue-actions[hidden] { display: none; } + +/* "Join now": the one call to action in the queue flow, so it reads as inviting + * (filled accent) while everything around it stays quiet. */ +.join-queue-btn { + padding: 10px 26px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #0b0b10; + background: var(--accent); + border: none; + border-radius: 999px; + cursor: pointer; + font-variant-numeric: tabular-nums; + transition: transform 0.12s ease, filter 0.2s ease; +} +.join-queue-btn:hover { filter: brightness(1.08); } +.join-queue-btn:active { transform: scale(0.97); } +.join-queue-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} +.join-queue-btn[hidden] { display: none; } + +/* "Leave queue": a quiet outlined pill, understated so it reads as an escape + * hatch, not a CTA. */ +.leave-queue-btn { + padding: 7px 16px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-faint); + background: transparent; + border: 1px solid color-mix(in srgb, var(--text-faint) 35%, transparent); + border-radius: 999px; + cursor: pointer; + transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease; +} +.leave-queue-btn:hover { + color: var(--text); + border-color: color-mix(in srgb, var(--text-faint) 60%, transparent); + background: color-mix(in srgb, var(--text-faint) 8%, transparent); +} +.leave-queue-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.leave-queue-btn[hidden] { display: none; } + +/* ─── Tool-call toaster ───────────────────────────────────────────────── */ + +/* A small, non-interactive pill that appears below the circle when the + * model invokes a tool (move_head, play_move). Sits just under the + * state caption, collapses to zero height when no toast is active. */ +.tool-toast { + display: inline-flex; + align-items: center; + gap: 8px; + margin-top: 10px; + padding: 6px 12px; + border-radius: 999px; + border: 1px solid var(--border-strong); + background: color-mix(in srgb, var(--bg-elev-2) 78%, transparent); + color: var(--text-dim); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.01em; + line-height: 1; + white-space: nowrap; + max-width: 80vw; + overflow: hidden; + text-overflow: ellipsis; + + opacity: 0; + transform: translateY(-4px) scale(0.96); + pointer-events: none; + transition: opacity 0.22s ease, transform 0.22s ease; +} +.tool-toast.visible { + opacity: 1; + transform: translateY(0) scale(1); +} +.tool-toast-icon { + width: 14px; + height: 14px; + flex: none; + color: color-mix(in srgb, var(--voice-tool) 80%, white); + animation: tool-toast-spin 3.2s linear infinite; + animation-play-state: paused; +} +.tool-toast.visible .tool-toast-icon { + animation-play-state: running; +} +.tool-toast-text { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; +} +@keyframes tool-toast-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ─── Side controls (mic / stop) ──────────────────────────────────────── */ + +/* Mic button + its radial noise-gate arc. The wrapper centres the button; the + arc SVG is an absolute overlay larger than the button, revealed with the + live session (it has no layout footprint, so the idle collapse is unaffected). */ +.mic-gate { + position: relative; + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; +} +.mic-gate-arc { + position: absolute; + left: 50%; + top: 50%; + width: 80px; + height: 80px; + transform: translate(-50%, -50%); + pointer-events: none; /* only the hit-path below catches drags */ + opacity: 0; + transition: opacity 0.25s ease; +} +.orb-wrap.live .mic-gate-arc { opacity: 1; } +.mga-track { + stroke: rgba(255, 255, 255, 0.1); /* hairline; recedes until needed */ + stroke-width: 1.5; + stroke-linecap: round; +} +.mga-fill { + stroke: var(--accent-2); + stroke-width: 2; + stroke-linecap: round; + opacity: 0.85; + transition: stroke-dashoffset 0.06s linear; +} +/* Threshold setpoint: a bead riding the ring. White with a thin dark outline so + it stays legible over the moving fill; it recolors to cyan with a soft glow + the moment the live level crosses it (the gate opening). */ +.mga-handle { + fill: var(--text); + stroke: none; + transition: fill 0.2s ease, filter 0.2s ease; +} +.mic-gate.gate-open .mga-handle { + fill: var(--accent-2); + filter: drop-shadow(0 0 3px var(--accent-2)); +} +.mga-hit { + stroke: transparent; + stroke-width: 16; + pointer-events: none; /* enabled only while live (below) */ + cursor: pointer; + touch-action: none; +} +/* Only catch drags during a live call; when idle the arc is hidden and must + not steal clicks near the collapsed mic button / orb. */ +.orb-wrap.live .mga-hit { pointer-events: stroke; } + +.side-btn { + flex: none; + width: 52px; + height: 52px; + border-radius: 50%; + border: 1px solid var(--border-strong); + /* Brighter background so the buttons actually stand out against the + * deep-blue stage gradient; previous var(--bg-elev) was too close to + * the page bg to be readable. */ + background: color-mix(in srgb, var(--bg-elev-2) 80%, #2a2e3c); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + touch-action: manipulation; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35), + inset 0 1px 0 rgba(255, 255, 255, 0.06); + /* Hidden by default: take up no space until the session is live. The + * `width: 0` collapse keeps the orb centered on the idle screen. */ + opacity: 0; + transform: scale(0.55); + width: 0; + padding: 0; + pointer-events: none; + overflow: hidden; + transition: opacity 0.25s ease, transform 0.25s ease, width 0.25s ease, + background 0.15s, color 0.15s, border-color 0.15s; +} +.side-btn:hover { + background: color-mix(in srgb, var(--bg-elev-2) 60%, #323746); + border-color: var(--text-dim); +} +.side-btn svg { + width: 22px; + height: 22px; + flex: none; +} +.side-btn .mic-off { display: none; } +.side-btn.muted { + color: #fff; + border-color: var(--error); + background: color-mix(in srgb, var(--error) 70%, #1a0e13); +} +.side-btn.muted .mic-on { display: none; } +.side-btn.muted .mic-off { display: block; } + +/* Stop button: subtle warm tint so "end" reads as destructive. */ +#stop-btn:hover { + color: #fff; + border-color: color-mix(in srgb, var(--error) 60%, var(--border-strong)); + background: color-mix(in srgb, var(--error) 25%, var(--bg-elev-2)); +} + +/* Reveal when the session is live: buttons flank the orb on a flex row. */ +.orb-wrap.live .side-btn { + opacity: 1; + transform: scale(1); + width: 52px; + pointer-events: auto; +} + +/* Disable every transition / animation during the first paint so the + * orb doesn't fade-and-scale in when the page loads. `main.ts` removes + * the class after one animation frame. */ +body.booting, +body.booting *, +body.booting *::before, +body.booting *::after { + transition: none !important; + animation-duration: 0s !important; + animation-delay: 0s !important; +} + +/* ─── Circle animation keyframes ──────────────────────────────────────── */ + +/* Slow, subtle breathing for "warm idle" states. */ +@keyframes breathe { + 0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.4; } + 50% { transform: translate(-50%, -50%) scale(1.06); opacity: 0.15; } +} + +/* Outer ring expanding and fading - conveys "I am producing audio". */ +@keyframes ring-out { + 0% { transform: translate(-50%, -50%) scale(1); opacity: 0.35; } + 100% { transform: translate(-50%, -50%) scale(1.18); opacity: 0; } +} + +/* Soft inner scale for the core while talking. */ +@keyframes core-breathe { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.04); } +} + +/* Subtle glow throb used for "thinking" — dimmer than speaking. */ +@keyframes thinking { + 0%, 100% { transform: scale(1); opacity: 0.7; } + 50% { transform: scale(0.96); opacity: 0.45; } +} + +/* Individual dot pulse for the 3-dot processing indicator. */ +@keyframes thinking-dot { + 0%, 60%, 100% { transform: scale(0.7); opacity: 0.3; } + 30% { transform: scale(1.15); opacity: 1; } +} + +/* Sound-wave pulse: arc fades in, scales up slightly, fades out. + * The transform-origin is the speaker's center (roughly x=12), so + * the waves feel like they're emanating from the cone. */ +@keyframes voice-wave { + 0% { opacity: 0; transform: scale(0.7); } + 30% { opacity: 1; transform: scale(1); } + 70% { opacity: 0.2; transform: scale(1.12); } + 100% { opacity: 0; transform: scale(0.7); } +} + +@keyframes ind-spin { + /* Scale kept at 1 so we don't fight with the indicator's base + * show/hide transform (see `.ind-spinner` for the rationale). */ + from { transform: scale(1) rotate(0deg); } + to { transform: scale(1) rotate(360deg); } +} + +/* ─── State-specific colors ──────────────────────────────────────────── */ + +.circle.state-signed-out { --glow: #8b7dff; } +.circle.state-authenticated, +.circle.state-ready { --glow: #34d399; } +.circle.state-connecting, +.circle.state-connected, +.circle.state-auto-selecting, +.circle.state-starting { --glow: #facc15; } +/* Queued: a calm slate glow, distinct from connecting's active yellow — this is + * waiting, not working. The spinner turns slowly and the ring breathes. */ +.circle.state-queued { --glow: #94a3b8; } +.circle.state-queued .ind-spinner { animation-duration: 2.4s; } +.circle.state-queued .circle-ring { animation: breathe 2.6s ease-in-out infinite; } +/* Your turn: a slot is held for you — the orb warms to the accent and breathes a + * little quicker, an invitation to join. */ +.circle.state-your-turn { --glow: var(--accent); } +.circle.state-your-turn .circle-ring { animation: breathe 1.6s ease-in-out infinite; } +.circle.state-listening, +.circle.state-user-speaking { --glow: var(--listening); } +.circle.state-processing { --glow: var(--processing); } +.circle.state-ai-speaking { --glow: var(--speaking); } +.circle.state-error { --glow: var(--error); } + +/* Idle / ready: gentle breathing of the inner ring. Kept out of the + * `signed-out` state so the very first paint on page load stays quiet + * (the orb now shows the Reachy head silhouette, no need to also pulse). */ +.circle.state-authenticated .circle-ring, +.circle.state-ready .circle-ring { + animation: breathe 2.4s ease-in-out infinite; +} + +/* Connecting flows: subtle glow throb so the orb feels thoughtful + * while the session is being negotiated. Kept off `processing` on + * purpose - the 3 thinking dots already pulse, and layering another + * throb on top competes with them for attention. */ +.circle.state-connecting .circle-core, +.circle.state-connected .circle-core, +.circle.state-auto-selecting .circle-core, +.circle.state-starting .circle-core { + animation: thinking 1.4s ease-in-out infinite; +} + +/* User is speaking: the mic RMS drives scale + opacity via --audio-level. */ +.circle.state-user-speaking .circle-ring { + animation: none; + opacity: calc(0.25 + 0.55 * var(--audio-level)); + transform: translate(-50%, -50%) scale(calc(1 + 0.08 * var(--audio-level))); + transition: transform 0.08s linear, opacity 0.08s linear; +} +.circle.state-user-speaking .circle-ring-outer { + opacity: calc(0.1 + 0.35 * var(--audio-level)); + transform: translate(-50%, -50%) scale(calc(1 + 0.12 * var(--audio-level))); + transition: transform 0.08s linear, opacity 0.08s linear; +} +.circle.state-listening .circle-ring { + animation: breathe 2s ease-in-out infinite; +} + +/* Assistant is speaking: the whole orb breathes in sync with Reachy's + * voice instead of running a fixed timer. `--ai-audio-level` (0-1) is + * updated at display rate by AiLevelMonitor from the OpenAI output + * track, so every syllable visibly moves the core + outer ring. This + * reads instantly as "the orb is the voice" and completely avoids the + * ambiguity of bars-vs-mic the user flagged. */ +.circle.state-ai-speaking .circle-core { + animation: none; + transform: scale(calc(1 + 0.09 * var(--ai-audio-level, 0))); + transition: transform 0.08s linear; +} +.circle.state-ai-speaking .circle-ring { + animation: none; + opacity: calc(0.3 + 0.5 * var(--ai-audio-level, 0)); + transform: translate(-50%, -50%) scale(calc(1 + 0.05 * var(--ai-audio-level, 0))); + transition: transform 0.08s linear, opacity 0.08s linear; +} +.circle.state-ai-speaking .circle-ring-outer { + animation: none; + opacity: calc(0.15 + 0.55 * var(--ai-audio-level, 0)); + transform: translate(-50%, -50%) scale(calc(1 + 0.18 * var(--ai-audio-level, 0))); + transition: transform 0.08s linear, opacity 0.08s linear; +} +.circle.state-ai-speaking .circle-glow { + opacity: calc(0.35 + 0.45 * var(--ai-audio-level, 0)); + transition: opacity 0.08s linear; +} + +/* ─── Robot picker ────────────────────────────────────────────────────── */ + +.robot-picker { + width: min(420px, 92vw); + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 14px 16px; + box-shadow: var(--shadow-soft); +} + +.picker-title { + margin: 0 0 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-faint); +} + +.robot-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.robot-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-elev-2); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.robot-card:hover { + border-color: var(--border-strong); +} +.robot-card.selected { + border-color: var(--border-strong); + background: var(--bg-elev); +} + +.robot-card .name { + font-weight: 600; + font-size: 14px; +} +.robot-card .id { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-faint); +} + +.robot-empty { + padding: 14px; + text-align: center; + color: var(--text-faint); + font-size: 13px; +} + +/* ─── Footer ──────────────────────────────────────────────────────────── */ + +.footer { + padding: 12px 28px 18px; + font-size: 11px; + letter-spacing: 0.02em; + color: var(--text-faint); + display: flex; + justify-content: center; + opacity: 0.55; + transition: opacity 0.25s ease; +} +.footer:hover { + opacity: 0.9; +} +.footer a { + color: inherit; + text-decoration: none; + border-bottom: 1px dotted currentColor; +} +.footer a:hover { + color: var(--text-dim); +} +/* While the webcam preview sits bottom-left, push the credit to the + * bottom-right so the two don't collide. */ +body.cam-on .footer { + justify-content: flex-end; +} + +/* ─── Modal ───────────────────────────────────────────────────────────── */ + +.modal { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0; + background: var(--bg-elev); + color: var(--text); + width: min(440px, 92vw); + box-shadow: var(--shadow-soft); +} +.modal::backdrop { + background: rgba(8, 9, 13, 0.65); + backdrop-filter: blur(4px); +} + +.modal-content { + display: flex; + flex-direction: column; + gap: 16px; + padding: 22px 24px 20px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} +.modal-header h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.field[hidden] { display: none; } +.field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: var(--text-dim); +} +.field > span { + color: var(--text); + font-weight: 600; + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.field input, +.field select, +.field textarea { + font-family: inherit; + font-size: 14px; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + outline: none; + transition: border-color 0.15s; + resize: vertical; +} +.field textarea { + min-height: 84px; +} +.field input:focus, +.field select:focus, +.field textarea:focus { + border-color: var(--text-dim); +} +/* Deploy-pinned server URL: shown for transparency, not editable. */ +.field input[readonly] { + color: var(--text-dim); + border-style: dashed; + cursor: default; +} +.field input[readonly]:focus { + border-color: var(--border); +} +.field small { + color: var(--text-faint); + font-size: 12px; + line-height: 1.4; +} +.field small.error { color: var(--error); } +.field small code { + background: var(--bg); + padding: 1px 5px; + border-radius: 4px; + border: 1px solid var(--border); +} + +/* Noise gate: a header with a live value, a level meter, and a range slider + that shares the meter's horizontal (dB) axis. */ +.field-head { + display: flex; + align-items: baseline; + justify-content: space-between; +} +.field-value { + font-weight: 500; + font-size: 12px; + letter-spacing: 0; + text-transform: none; + color: var(--text-dim); +} +/* The slider and the level meter are one widget: the range input is overlaid + on the meter track (its native track is transparent), so the live-level fill + shows through behind the thumb and the thumb itself is the threshold. */ +.gate { + display: flex; + flex-direction: column; + gap: 4px; +} +.gate-track { + position: relative; + height: 14px; + display: flex; + align-items: center; +} +.gate-track::before { + /* the visible meter track */ + content: ""; + position: absolute; + left: 0; + right: 0; + height: 8px; + border-radius: 999px; + background: var(--bg); + border: 1px solid var(--border); +} +.gate-meter-fill { + position: absolute; + left: 1px; + top: 50%; + transform: translateY(-50%); + height: 6px; + width: 0; + border-radius: 999px; + background: var(--accent-2); + transition: width 0.06s linear; + pointer-events: none; +} +.gate-ends { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--text-faint); + letter-spacing: 0; + text-transform: none; +} +/* The range input sits transparently on top of the meter track. */ +.gate-track input[type="range"] { + position: relative; + z-index: 1; + width: 100%; + margin: 0; + -webkit-appearance: none; + appearance: none; + padding: 0; + border: none; + background: transparent; + height: 14px; + cursor: pointer; +} +.gate-track input[type="range"]::-webkit-slider-runnable-track { + height: 14px; + background: transparent; +} +.gate-track input[type="range"]::-moz-range-track { + height: 14px; + background: transparent; +} +.gate-track input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 6px; + height: 18px; + border-radius: 3px; + background: var(--text); + border: 2px solid var(--bg-elev); + box-shadow: 0 0 0 1px var(--border-strong); +} +.gate-track input[type="range"]::-moz-range-thumb { + width: 6px; + height: 18px; + border-radius: 3px; + background: var(--text); + border: 2px solid var(--bg-elev); + box-shadow: 0 0 0 1px var(--border-strong); +} +.gate-track input[type="range"]:focus { border: none; } + +.modal-footer { + display: flex; + justify-content: space-between; + gap: 12px; + padding-top: 6px; +} + +.btn { + padding: 10px 16px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--bg-elev-2); + color: var(--text); + font-weight: 600; + font-size: 13px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.05s; +} +.btn:hover { + border-color: var(--text); +} +.btn:active { + transform: translateY(1px); +} +.btn.primary { + background: var(--text); + border-color: var(--text); + color: var(--bg); +} +.btn.primary:hover { + background: #fff; + border-color: #fff; +} +.btn.ghost { + background: transparent; + border-color: var(--border); + color: var(--text-dim); +} +.btn.wide { + width: 100%; + padding: 12px 16px; +} +.btn[disabled] { + opacity: 0.45; + cursor: not-allowed; +} +.btn[disabled]:hover { + border-color: var(--border-strong); +} + +/* ─── Settings tabs ───────────────────────────────────────────────────── */ + +.tabs { + display: flex; + gap: 4px; + padding: 4px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +.tab { + flex: 1; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--text-dim); + font-family: inherit; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; + border-radius: calc(var(--radius-sm) - 3px); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.tab:hover { + color: var(--text); +} +.tab.active { + background: var(--bg-elev-2); + color: var(--text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.tab-panels { + display: flex; + flex-direction: column; +} +.tab-panel { + display: flex; + flex-direction: column; + gap: 16px; +} +.tab-panel[hidden] { + display: none; +} + +/* Horizontal layout for Voice + Model. */ +.field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +/* ─── Chat button + badge ────────────────────────────────────────────── */ + +#chat-btn { + position: relative; +} + +.chat-badge { + position: absolute; + top: 6px; + right: 6px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text); + border: 1.5px solid var(--bg-elev); + opacity: 0; + transform: scale(0); + transition: opacity 0.2s ease, transform 0.2s ease; + pointer-events: none; +} +.chat-badge.visible { + opacity: 1; + transform: scale(1); +} + +/* ─── Ephemeral bubble stack ─────────────────────────────────────────── */ + +.bubble-stack { + position: fixed; + /* Clear the topbar control row so the first bubble doesn't sit at the same + * height as the buttons. */ + top: 96px; + right: 28px; + width: min(300px, calc(100vw - 56px)); + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; + z-index: 100; +} + +.bubble { + --bubble-dx: 10px; + pointer-events: auto; + padding: 10px 14px; + border-radius: var(--radius-md); + font-size: 13px; + line-height: 1.5; + border: 1px solid var(--border); + background: var(--bg-elev); + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.32); + max-width: 100%; + word-break: break-word; + opacity: 0; + transform: translateX(var(--bubble-dx)); + transition: opacity 0.22s ease, transform 0.22s ease; +} +.bubble.in { + opacity: 1; + transform: translateX(0); +} +.bubble.out { + opacity: 0; + transform: translateX(var(--bubble-dx)); + pointer-events: none; + transition: opacity 0.3s ease, transform 0.3s ease; +} + +/* Surfaces stay neutral; the side they sit on plus the mono role label + * carry the distinction. No tinted fills — color is the orb's job. */ +.bubble.user { + --bubble-dx: -10px; + align-self: flex-start; +} +.bubble.assistant { + --bubble-dx: 10px; + align-self: flex-end; +} +.bubble.tool { + --bubble-dx: -10px; + align-self: flex-start; + display: flex; + align-items: center; + gap: 8px; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 12px; +} +.bubble.tool .bubble-tool-icon { + width: 13px; + height: 13px; + flex: none; + color: var(--voice-tool); +} + +.bubble-role { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 4px; + opacity: 0.7; +} +.bubble.user .bubble-role { color: var(--voice-user); } +.bubble.assistant .bubble-role { color: var(--voice-assistant); } + +/* ─── Conversation history panel ─────────────────────────────────────── */ + +.chat-panel { + position: fixed; + inset: 0; + z-index: 200; + pointer-events: none; +} +.chat-panel-backdrop { + position: absolute; + inset: 0; + background: rgba(8, 9, 13, 0.45); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + opacity: 0; + transition: opacity 0.25s ease; +} +.chat-panel.open .chat-panel-backdrop { + opacity: 1; + pointer-events: auto; +} +.chat-panel-inner { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: min(360px, 90vw); + background: var(--bg-elev); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1); + pointer-events: auto; + box-shadow: -8px 0 32px rgba(0, 0, 0, 0.35); +} +.chat-panel.open .chat-panel-inner { + transform: translateX(0); +} +.chat-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 20px; + border-bottom: 1px solid var(--border); + flex: none; +} +.chat-panel-header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.01em; +} +.chat-history { + flex: 1; + overflow-y: auto; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 10px; + scroll-behavior: smooth; +} +.chat-history::-webkit-scrollbar { width: 3px; } +.chat-history::-webkit-scrollbar-track { background: transparent; } +.chat-history::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; } + +.chat-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + color: var(--text-faint); + padding: 48px 0; + opacity: 0.65; +} +.chat-empty svg { + margin-bottom: 6px; + opacity: 0.8; +} +.chat-empty-title { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; +} +.chat-empty-hint { + font-size: 12px; + color: var(--text-faint); + opacity: 0.7; +} + +/* ─── History messages ───────────────────────────────────────────────── */ + +.hist-msg { + display: flex; + flex-direction: column; + gap: 3px; + max-width: 88%; +} +.hist-msg.user { align-self: flex-start; } +.hist-msg.assistant { align-self: flex-end; } +.hist-msg.tool { align-self: flex-start; max-width: 100%; } + +.hist-role { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-faint); + padding: 0 2px; +} +.hist-msg.user .hist-role { color: color-mix(in srgb, var(--voice-user) 75%, var(--text-faint)); } +.hist-msg.assistant .hist-role { color: color-mix(in srgb, var(--voice-assistant) 75%, var(--text-faint)); } +.hist-msg.tool .hist-role { color: color-mix(in srgb, var(--voice-tool) 75%, var(--text-faint)); } + +.hist-body { + padding: 9px 12px; + border-radius: var(--radius-md); + font-size: 13px; + line-height: 1.5; + border: 1px solid var(--border); + background: var(--bg-elev-2); + word-break: break-word; +} +/* Bodies share one neutral surface; alignment + the mono role label do the + * distinguishing, so the panel reads as one quiet column. */ +.hist-msg.user .hist-body.partial { opacity: 0.65; } + +/* ─── Tool call history item ─────────────────────────────────────────── */ + +.hist-tool-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: var(--radius-md) var(--radius-md) 0 0; + background: color-mix(in srgb, var(--processing) 10%, var(--bg-elev-2)); + border: 1px solid color-mix(in srgb, var(--processing) 22%, var(--border)); + border-bottom: 1px solid color-mix(in srgb, var(--processing) 15%, var(--border)); + cursor: pointer; + color: var(--text); + font-family: inherit; + font-size: 13px; + font-weight: 500; + width: 100%; + text-align: left; + transition: background 0.15s; +} +.hist-tool-header:only-child { + border-radius: var(--radius-md); + border-bottom: 1px solid color-mix(in srgb, var(--processing) 22%, var(--border)); +} +.hist-tool-header:hover { + background: color-mix(in srgb, var(--processing) 16%, var(--bg-elev-2)); +} +.hist-tool-icon { + width: 13px; + height: 13px; + flex: none; + color: var(--processing); +} +.hist-tool-name { + font-family: var(--font-mono); + font-size: 12px; + color: var(--voice-tool); + font-weight: 600; +} +.hist-tool-chevron { + margin-left: auto; + width: 13px; + height: 13px; + color: var(--text-faint); + transition: transform 0.2s ease; + flex: none; +} +.hist-tool-header[aria-expanded="true"] .hist-tool-chevron { + transform: rotate(180deg); +} +.hist-tool-body { + padding: 10px 12px 12px; + border-radius: 0 0 var(--radius-md) var(--radius-md); + background: var(--bg); + border: 1px solid color-mix(in srgb, var(--processing) 22%, var(--border)); + border-top: none; + display: none; +} +.hist-tool-body.open { display: block; } +.hist-tool-label { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-faint); + margin: 8px 0 4px; +} +.hist-tool-label:first-child { margin-top: 0; } +.hist-tool-block { + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.65; + color: var(--text-dim); + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; +} +.hist-tool-output { color: var(--text); } + +/* ─── Phone layout ──────────────────────────────────────────────────────── + * On phones the floating bubble stream overlaps the orb and there isn't room + * for it, so we drop it entirely and rely on the conversation panel (opened + * from the top-right chat button) as the single place to read the transcript. + * The badge still pulses there when new messages arrive while it's closed. + * We also stack the mic / stop controls above and below the orb (instead of + * left/right) so the wide live row never overflows, and let the panel take + * the full width. */ +@media (max-width: 600px) { + .bubble-stack { + display: none; + } + + .topbar { + padding: 14px 16px; + } + + .stage { + padding: 16px 12px 24px; + } + + /* Stack vertically: mic above the orb, stop below it (DOM order is + * mic → circle → stop). */ + .orb-wrap { + flex-direction: column; + gap: 14px; + } + + .circle { + /* Height-capped like the base rule so a narrow AND short box still fits. */ + width: clamp(130px, min(56vw, 52vh), 240px); + } + + /* In the column layout the side controls must collapse by HEIGHT, not + * width, so they take no vertical space until the session is live. */ + .side-btn { + width: 44px; + height: 0; + transition: opacity 0.25s ease, transform 0.25s ease, height 0.25s ease, + background 0.15s, color 0.15s, border-color 0.15s; + } + .side-btn svg { + width: 19px; + height: 19px; + } + .orb-wrap.live .side-btn { + width: 44px; + height: 44px; + } + .mic-gate-arc { width: 70px; height: 70px; } + + /* Full-screen conversation on phones — feels more deliberate than a + * narrow slide-over. It already spans top-to-bottom (inset 0); make it + * span edge-to-edge too and drop the now-pointless border/shadow. */ + .chat-panel-inner { + width: 100vw; + border-left: none; + box-shadow: none; + } +} + +/* ─── About panel ───────────────────────────────────────────────────────── + * Opened from the (i) by the wordmark. Reuses the .modal shell. + * Strictly monochrome per DESIGN.md — the only "color" is the orb, never + * here. Machine identifiers (model IDs, role tags, usernames) ride Geist + * Mono; everything human stays Inter. */ +.about-modal { + /* Roomier on tablet/desktop; the 92vw cap keeps phones full-width. */ + width: min(680px, 92vw); + max-height: 88vh; +} +.about-modal .modal-content { + max-height: 88vh; + overflow-y: auto; + gap: 20px; + padding: 26px 30px 24px; +} +.about-modal .modal-content::-webkit-scrollbar { width: 3px; } +.about-modal .modal-content::-webkit-scrollbar-track { background: transparent; } +.about-modal .modal-content::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; } + +/* Links here are quiet: no permanent underline (the global dotted rule is + * too loud for a dense credit block), brighten + underline on hover only. */ +.about-modal a { + color: var(--text); + border-bottom: none; + text-decoration: none; + transition: color 0.15s ease; +} +.about-modal a:hover { + color: #fff; +} +.about-intro a:hover { + text-decoration: underline; + text-underline-offset: 2px; +} +.about-modal .ext { + width: 12px; + height: 12px; + flex: none; + opacity: 0.5; +} + +/* Title row: the demo name and the (i) read as one unit. */ +.ident-head { + display: flex; + align-items: center; + gap: 9px; +} +/* (i) trigger sits right after the title. A faint outline keeps it + * catchable without turning into a card; it fills in on hover. */ +.about-btn { + flex: none; + width: 34px; + height: 34px; + border-radius: 50%; + background: transparent; + border: 1px solid var(--border-strong); + color: var(--text-dim); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + touch-action: manipulation; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} +.about-btn:hover { + background: var(--bg-elev); + border-color: var(--text-dim); + color: var(--text); +} +.about-btn svg { + width: 20px; + height: 20px; +} + +/* The mobile twin of (i) lives in the right-hand control cluster and is + * hidden on desktop (the in-title one shows there instead). */ +.about-btn-mobile { + display: none; +} + +/* ── Popup intro: a plain paragraph on the project + a repo link ── */ +.about-intro p { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: var(--text-dim); +} +.about-repo { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 12px; + font-size: 14px; + font-weight: 500; +} + +/* ── Corner identity (replaces the wordmark) ── + * A compact stack in the topbar: title, one-line blurb, two meta rows. + * Monochrome; only the role/name identifiers ride the machine typeface. */ +.ident { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} +/* Reset the global underlined-anchor styling inside the identity block. */ +.ident a { + border-bottom: none; + color: inherit; + transition: color 0.15s ease; +} +.ident a:hover { + color: var(--text); + text-decoration: underline; + text-underline-offset: 2px; +} +.ident-title { + font-size: 22px; + font-weight: 600; + letter-spacing: 0.005em; + line-height: 1.1; + color: var(--text); +} +.ident-user-name { + max-width: 32ch; + font-size: 13px; + line-height: 1.35; + color: var(--text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ident-blurb { + margin: 0; + max-width: 48ch; + font-size: 14.5px; + line-height: 1.5; + font-weight: 400; + color: var(--text-dim); +} +.ident-meta { + display: flex; + flex-direction: column; + gap: 7px; + font-size: 14px; + font-weight: 400; + color: var(--text-dim); +} +.ident-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; +} +.ident-label { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-faint); +} + +/* Shared credit bits — now used in the corner identity block. */ +.sep { + color: var(--text-faint); + opacity: 0.6; +} +.hf-credit { + display: inline-flex; + align-items: center; + gap: 5px; +} +/* Brand marks keep their own color — a deliberate exception to the + * monochrome rule, for the HF and Cerebras logos only. */ +.hf-mark { + width: 14px; + height: 14px; + flex: none; + color: #ffd21e; +} +.cerebras-credit { + display: inline-flex; + align-items: center; + gap: 5px; +} +.cerebras-mark { + width: 14px; + height: 14px; + flex: none; +} +/* Usernames are identifiers, so they ride the machine typeface. */ +.handle { + font-family: var(--font-mono); + font-size: 13.5px; +} + +/* ── Pipeline (the signal flow) ── */ +.about-pipeline { + border-top: 1px solid var(--border); + padding-top: 16px; +} +.pipeline-title { + margin: 0 0 14px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-faint); +} +.pipeline { + list-style: none; + margin: 0; + padding: 0; + position: relative; +} +/* One continuous rail threading every node, dot centers at x=8.5px. */ +.pipeline::before { + content: ""; + position: absolute; + left: 8px; + top: 7px; + bottom: 7px; + width: 1px; + background: var(--border-strong); +} +.pipeline > li { + position: relative; + padding-left: 30px; +} +/* Stages: solid node. Endpoints (you / orb): hollow node. */ +.pipe-stage::before { + content: ""; + position: absolute; + left: 5px; + top: 4px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-dim); +} +.pipe-endpoint::before { + content: ""; + position: absolute; + left: 5px; + top: 3px; + width: 7px; + height: 7px; + border-radius: 50%; + border: 1px solid var(--text-faint); + background: var(--bg-elev); +} +.pipe-endpoint { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-faint); + padding-bottom: 12px; +} +.pipeline > li.pipe-endpoint:last-child { + padding-bottom: 0; +} +.pipe-stage { + padding-bottom: 16px; +} +.pipe-tag { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + color: var(--text); + margin-right: 9px; +} +.pipe-job { + font-size: 14px; + color: var(--text-dim); +} +/* Middot between the job and its model link, matching the separators + * used elsewhere. */ +.pipe-job::after { + content: "·"; + margin: 0 7px; + color: var(--text-faint); +} +.pipe-note { + color: var(--text-faint); +} +/* The Cerebras link stays as quiet as the note; brightens + underlines on hover. */ +.pipe-note a { + color: inherit; +} +.pipe-note a:hover { + color: var(--text); + text-decoration: underline; + text-underline-offset: 2px; +} +.pipe-model { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 4px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-dim); + word-break: break-all; +} +.pipe-model:hover { + color: #fff; + text-decoration: underline; + text-underline-offset: 2px; +} +.pipe-model .ext { + width: 11px; + height: 11px; +} + +/* "Interrupted" tag on an assistant reply the user barged in on. */ +.hist-note { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-faint); + padding: 0 2px; +} +.hist-msg.assistant .hist-note { + align-self: flex-end; +} +.hist-msg.interrupted .hist-body { + opacity: 0.7; +} + +/* Captured webcam frame shown in the transcript (camera tool result). */ +.hist-image { + display: block; + width: 100%; + max-width: 240px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + margin-top: 2px; +} + +/* ─── Tools panel ────────────────────────────────────────────────────────── + * Reuses the modal/field shell. Switches are monochrome (checked = near-white, + * the same high-contrast treatment as the primary button): color belongs to + * the voice, not the chrome. */ +.tools-intro { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: var(--text-dim); +} +.tool-list { + display: flex; + flex-direction: column; +} +.tool-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 0; +} +.tool-row-sep { + border-top: 1px solid var(--border); + margin-top: 4px; +} +.tool-info { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.tool-name { + font-size: 14px; + font-weight: 600; + color: var(--text); +} +.tool-desc { + font-size: 12.5px; + color: var(--text-dim); +} +.tool-row.disabled .tool-name, +.tool-row.disabled .tool-desc { + opacity: 0.5; +} +.tool-hint { + display: block; + font-size: 12px; + line-height: 1.4; + color: var(--text-faint); +} +.agent-tools-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; +} +.agent-tool-item { + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); +} +.agent-tool-summary { + list-style: none; + cursor: pointer; + padding: 10px 12px; +} +.agent-tool-summary::-webkit-details-marker { + display: none; +} +.agent-tool-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 4px; +} +.agent-tool-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border: 1px solid rgba(116, 167, 255, 0.35); + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.03em; + color: #9cc0ff; + background: rgba(90, 140, 255, 0.12); +} +.agent-tool-badge.suggested { + border-color: rgba(123, 223, 168, 0.35); + color: #9fe0bc; + background: rgba(79, 168, 124, 0.14); +} +.agent-tool-match-banner { + padding: 10px 12px; + border: 1px solid rgba(123, 223, 168, 0.2); + border-radius: 12px; + background: rgba(79, 168, 124, 0.08); +} +.agent-tool-match-text { + margin-top: 6px; + font-size: 12px; + line-height: 1.45; + color: var(--text); +} +.agent-tool-body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 12px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} +.agent-tool-call-label { + display: flex; + flex-direction: column; + gap: 6px; +} +.agent-tool-call-copy { + font-size: 12px; + color: var(--text-faint); +} +.agent-tool-call-args { + width: 100%; + min-height: 88px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + color: var(--text); + font: inherit; + font-family: "Geist Mono", monospace; + resize: vertical; +} +.agent-tool-call-actions { + display: flex; + justify-content: flex-start; +} +.agent-tool-call-btn { + min-width: 128px; +} +.agent-tool-call-result { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); +} +.agent-tool-call-result.success { + border-color: rgba(123, 223, 168, 0.25); + background: rgba(79, 168, 124, 0.08); +} +.agent-tool-call-result.error { + border-color: rgba(255, 120, 120, 0.24); + background: rgba(120, 40, 40, 0.16); +} +.agent-tool-call-result.running { + border-color: rgba(90, 140, 255, 0.24); + background: rgba(90, 140, 255, 0.08); +} +.agent-tool-call-result-title { + margin-bottom: 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-faint); +} +.agent-tool-call-output { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + max-height: 260px; + overflow: auto; + font: inherit; + font-family: "Geist Mono", monospace; + font-size: 12px; + line-height: 1.5; +} +.agent-tool-section-title { + margin-top: 10px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-faint); +} +.agent-param-row { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); +} +.agent-param-name { + font-size: 12.5px; + font-weight: 600; + color: var(--text); +} +.agent-param-type { + font-size: 11.5px; + line-height: 1.4; + color: var(--text-faint); +} +.agent-tool-item .tool-name { + display: block; +} +.agent-tool-empty { + padding: 10px 12px; + border: 1px dashed var(--border); + border-radius: 12px; + color: var(--text-faint); + font-size: 12px; +} +.agent-tool-empty.small { + padding: 8px 10px; + font-size: 11.5px; +} +.tools-key { + margin: 0 0 4px; +} + +/* Toggle switch */ +.switch { + position: relative; + display: inline-flex; + flex: none; + width: 40px; + height: 24px; + cursor: pointer; +} +.switch input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; +} +.switch-track { + position: absolute; + inset: 0; + border-radius: 999px; + background: var(--bg); + border: 1px solid var(--border-strong); + transition: background 0.15s, border-color 0.15s; +} +.switch-track::after { + content: ""; + position: absolute; + top: 50%; + left: 3px; + transform: translateY(-50%); + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--text-dim); + transition: transform 0.18s ease, background 0.15s; +} +.switch input:checked + .switch-track { + background: var(--text); + border-color: var(--text); +} +.switch input:checked + .switch-track::after { + transform: translate(16px, -50%); + background: var(--bg); +} +.switch input:focus-visible + .switch-track { + outline: 2px solid var(--text-dim); + outline-offset: 2px; +} +.switch input:disabled { + cursor: not-allowed; +} +.switch input:disabled + .switch-track { + opacity: 0.5; +} + +/* ─── Webcam preview (camera tool) — HIDDEN BY DEFAULT OGGI ────────────────── + * Floating self-view, bottom-left. Mirrored for the user; the frame sent to + * the model is drawn un-mirrored. Markup esiste per riattivazione futura. + * Per usarlo: togli hidden su cam-pip in index.html + scommenta listener in main.js. */ +.cam-pip { + position: fixed; + left: 20px; + bottom: 20px; + width: 280px; + aspect-ratio: 4 / 3; + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--border-strong); + background: var(--bg-elev); + box-shadow: var(--shadow-soft); + z-index: 90; + opacity: 0; + transform: translateY(8px) scale(0.96); + pointer-events: none; + transition: opacity 0.22s ease, transform 0.22s ease; +} +.cam-pip.visible { + opacity: 1; + transform: none; +} +.cam-video { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + transform: scaleX(-1); /* mirror the self-view only */ + background: var(--bg); +} +.cam-label { + position: absolute; + left: 8px; + bottom: 6px; + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text); + opacity: 0.8; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); +} +.cam-flash { + position: absolute; + inset: 0; + background: #fff; + opacity: 0; + pointer-events: none; +} +.cam-pip.flash .cam-flash { + animation: cam-flash 0.4s ease; +} +@keyframes cam-flash { + 0% { opacity: 0; } + 12% { opacity: 0.85; } + 100% { opacity: 0; } +} +@media (max-width: 600px) { + /* Bottom-centred on phones. Auto margins centre it without touching the + * transform, so the slide/scale-in animation still works. */ + .cam-pip { + left: 0; + right: 0; + margin-inline: auto; + bottom: 16px; + width: min(188px, 52vw); + } + /* The credit would sit under the centred preview, so drop it while the + * camera is on. */ + body.cam-on .footer { + display: none; + } +} +@media (prefers-reduced-motion: reduce) { + .cam-pip { + transition: opacity 0.22s ease; + transform: none; + } + .cam-pip.flash .cam-flash { + animation: none; + } +} + +/* ─── Desktop type scale ──────────────────────────────────────────────────── + * A uniform step up (~+1px, title +2) for all UI text on larger screens. + * Scoped to min-width: 601px so it can't reach phones — the ≤600px layout + * keeps every size exactly as it was. */ +@media (min-width: 601px) { + .cam-label { font-size: 10.5px; } + + .bubble-role, + .hist-role { font-size: 11px; } + + .circle-caption, + .footer, + .chat-empty-title, + .hist-tool-block, + .ident-label, + .pipeline-title, + .pipe-endpoint { font-size: 12px; } + + .field > span, + .field small, + .bubble.tool, + .chat-empty-hint, + .hist-tool-name, + .pipe-tag, + .pipe-model, + .tool-hint { font-size: 13px; } + + .tool-desc { font-size: 13.5px; } + + .btn, + .bubble, + .field, + .hist-body, + .hist-tool-header, + .tools-intro { font-size: 14px; } + + .handle { font-size: 14.5px; } + + .about-intro p, + .about-repo, + .brand, + .chat-panel-header h3, + .field textarea, + .ident-meta, + .pipe-job, + .tool-name { font-size: 15px; } + + .ident-blurb { font-size: 15.5px; } + + .modal-header h2 { font-size: 17px; } + + .ident-title { font-size: 24px; } +} + +/* ─── Short viewports: fit without a vertical scrollbar ───────────────────── + * The base media queries only react to WIDTH, so a short container (e.g. the + * app embedded in a small box, or a laptop window once the browser chrome eats + * into the viewport) used to overflow vertically and get clipped. We KEEP the + * identity header, orb, caption and credits — we just undo the desktop type + * bump, tighten the vertical rhythm and let the height-capped orb shrink, so + * everything fits with comfortable margin. No opt-in needed. + * + * The single biggest space eater is the identity block (~180px at the desktop + * type scale), so this tier compacts it first. */ +@media (max-height: 780px) { + .topbar { padding: 14px 24px; } + .ident { gap: 5px; } + .ident-title { font-size: 20px; } + .ident-user-name { font-size: 12px; } + .ident-blurb { font-size: 13.5px; line-height: 1.4; } + .ident-meta { gap: 5px; font-size: 13px; } + .stage { padding: 12px 20px 16px; gap: 12px; } + .footer { padding: 8px 24px 10px; } + .circle { width: clamp(140px, min(38vw, 44vh), 300px); } +} + +@media (max-height: 560px) { + .topbar { padding: 8px 16px; } + .ident-title { font-size: 17px; } + .ident-user-name { font-size: 11px; } + /* Below this, room is tight enough that the one-line blurb and the footer + * (a verbatim duplicate of the header's "Powered by" credit) are dropped so + * the title + credits still read; the rest of the header stays. */ + .ident-blurb { display: none; } + .stage { padding: 8px 14px 10px; gap: 8px; } + .footer { display: none; } + .circle { width: clamp(120px, min(50vw, 46vh), 240px); } +} diff --git a/demo/ui/account.js b/demo/ui/account.js new file mode 100644 index 0000000..dc2d5b2 --- /dev/null +++ b/demo/ui/account.js @@ -0,0 +1,189 @@ +// @ts-check +/** + * Account — the HF login chip and the daily-limit modal. + * + * Reads `/api/me` to learn the current tier (anonymous / signed-in / PRO) and + * remaining daily talk-time, renders a sign-in pill or a signed-in chip with a + * small popover (tier, remaining, sign out, upgrade), and shows the limit modal + * when a conversation is refused or cut. The time metering itself lives in + * main.js (heartbeat loop) + the server; this module is just the surface. + * + * Inert unless the deploy is in LB mode (`/api/me` → `{enabled:true}`). + */ + +import { $, escHtml } from "./dom.js"; + +const PRO_URL = "https://huggingface.co/subscribe/pro"; + +// Official multi-color Hugging Face logo, used in the badge + sign-in CTA. +const HF_MARK = ``; + +/** @param {number} sec @returns {string} "m:ss" */ +function fmt(sec) { + const s = Math.max(0, Math.round(sec)); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; +} + +export class Account { + constructor() { + /** @type {HTMLElement} */ + this._root = $("#account"); + /** @type {HTMLDialogElement} */ + this._modal = $("#limit-modal"); + this._modalTitle = $("#limit-title"); + this._modalMsg = $("#limit-msg"); + this._modalNote = $("#limit-note"); + /** @type {HTMLAnchorElement} */ + this._modalCta = /** @type {any} */ ($("#limit-cta")); + + /** @type {{enabled:boolean, auth?:boolean, loggedIn?:boolean, username?:string, avatar?:string, tier?:string, remainingSec?:number|null, limitSec?:number|null, loginUrl?:string|null, logoutUrl?:string|null}} */ + this._me = { enabled: false }; + this._popoverOpen = false; + + $("#limit-close").addEventListener("click", () => this._modal.close()); + this._modal.addEventListener("click", (e) => { + if (e.target === this._modal) this._modal.close(); + }); + // Close the popover on an outside click. + document.addEventListener("click", (e) => { + if (this._popoverOpen && !this._root.contains(/** @type {Node} */ (e.target))) { + this._closePopover(); + } + }); + } + + get tier() { + return this._me.tier || "anon"; + } + + /** Fetch `/api/me` and (re)render the chip. Safe to call repeatedly (load, + * after the OAuth redirect, after a conversation ends). */ + async refresh() { + try { + const res = await fetch("api/me"); + this._me = res.ok ? await res.json() : { enabled: false }; + } catch { + this._me = { enabled: false }; + } + this._render(); + } + + _render() { + const me = this._me; + if (!me.enabled) { + this._root.hidden = true; + this._root.innerHTML = ""; + return; + } + this._root.hidden = false; + + if (!me.loggedIn) { + // Signed-out: a sign-in pill (only when OAuth is actually available). + if (me.auth && me.loginUrl) { + this._root.innerHTML = ``; + } else { + this._root.innerHTML = ""; + this._root.hidden = true; + } + return; + } + + // Signed-in: avatar + handle chip that toggles a popover. + const isPro = me.tier === "pro"; + // Org members get unlimited usage too, but aren't PRO — don't brand them so. + const isUnlimited = isPro || me.tier === "org"; + const avatar = me.avatar + ? `` + : ``; + const remaining = + isUnlimited || me.remainingSec == null + ? "Unlimited" + : `${fmt(me.remainingSec)} left today`; + const tierLabel = isPro ? "PRO" : isUnlimited ? "Team" : "Free"; + + this._root.innerHTML = ` + + `; + + const chip = $("#account-chip"); + chip.addEventListener("click", (e) => { + e.stopPropagation(); + this._popoverOpen ? this._closePopover() : this._openPopover(); + }); + } + + _openPopover() { + const pop = document.getElementById("account-pop"); + const chip = document.getElementById("account-chip"); + if (!pop || !chip) return; + pop.hidden = false; + chip.setAttribute("aria-expanded", "true"); + this._popoverOpen = true; + } + + _closePopover() { + const pop = document.getElementById("account-pop"); + const chip = document.getElementById("account-chip"); + if (pop) pop.hidden = true; + if (chip) chip.setAttribute("aria-expanded", "false"); + this._popoverOpen = false; + } + + /** + * Show the limit modal for a tier — used both when a conversation is refused + * at start (402) and when a live one is cut (heartbeat `expired`). + * @param {string} [tier] + */ + showLimit(tier = this.tier) { + const canSignIn = this._me.auth && this._me.loginUrl; + this._modalTitle.textContent = "Thanks for chatting!"; + if (tier === "anon") { + this._modalMsg.textContent = + "Guest conversations run for 5 minutes. Sign in with Hugging Face to get 10 minutes a day for free, and PRO members chat with no limit at all."; + this._modalNote.textContent = "Your free minutes refresh tomorrow."; + if (canSignIn) { + this._modalCta.innerHTML = `${HF_MARK}Sign in with Hugging Face`; + this._modalCta.href = /** @type {string} */ (this._me.loginUrl); + this._modalCta.hidden = false; + } else { + this._modalCta.hidden = true; + } + } else { + // Signed-in, non-PRO. + this._modalMsg.textContent = + "You've enjoyed your 10 minutes for today. Go PRO for unlimited conversations and to support open source AI."; + this._modalNote.textContent = "Or come back tomorrow. Your minutes reset daily."; + this._modalCta.innerHTML = "Upgrade to PRO"; + this._modalCta.href = PRO_URL; + this._modalCta.hidden = false; + } + if (!this._modal.open) this._modal.showModal(); + } + + /** Show a warm "we're at capacity" message when even the waiting line is full. + * Reuses the limit modal shell; no call-to-action, just reassurance. */ + showBusy() { + this._modalTitle.textContent = "Hugged to the limit 🤗"; + this._modalMsg.textContent = + "Every slot and the whole line are full right now. Too much love! Grab a coffee and pop back in a minute."; + this._modalNote.textContent = "A spot usually opens up within a few minutes."; + this._modalCta.hidden = true; + if (!this._modal.open) this._modal.showModal(); + } +} diff --git a/demo/ui/chat.js b/demo/ui/chat.js new file mode 100644 index 0000000..dbdea3b --- /dev/null +++ b/demo/ui/chat.js @@ -0,0 +1,447 @@ +// @ts-check +/** + * ChatView — owns the whole conversation surface: the slide-in history panel, + * the ephemeral on-orb bubbles, and all the transcript/tool/streaming + * bookkeeping. main.js wires the realtime client's events straight to the + * `on*` methods here and otherwise doesn't touch chat state. + * + * Two parallel surfaces share one shape (see `_buildMessageEl`): + * - ephemeral bubbles (`.bubble` / `.bubble-*`) fade on a timer + * - persistent history (`.hist-msg` / `.hist-*`) the durable panel log + * + * Keying: + * - user transcripts by the server's `item_id` — a speculative continuation + * REUSES it, so both segments land in one row/bubble; deltas are CUMULATIVE + * (each carries the full sentence so far), so we replace text wholesale. + * - assistant transcripts by `response_id`, so a cancelled speculative reply + * can be marked interrupted without erasing what was already shown. + */ + +import { $, escHtml, DEBUG } from "./dom.js"; + +/** + * @param {string} hypothesisId + * @param {string} location + * @param {string} msg + * @param {Record} [data] + */ +function reportChatDebug(hypothesisId, location, msg, data = {}) { + void hypothesisId; + void location; + void msg; + void data; +} + +const WRENCH_PATH = ``; +const CHAT_BUBBLE_SVG = ``; +const EMPTY_STATE_HTML = `
${CHAT_BUBBLE_SVG}No messages yetTap the orb and start talking
`; + +export class ChatView { + constructor() { + /** @type {HTMLButtonElement} */ + this._chatBtn = $("#chat-btn"); + /** @type {HTMLSpanElement} */ + this._chatBadge = $("#chat-badge"); + /** @type {HTMLDivElement} */ + this._chatPanel = $("#chat-panel"); + /** @type {HTMLDivElement} */ + this._chatPanelBackdrop = $("#chat-panel-backdrop"); + /** @type {HTMLButtonElement} */ + this._chatPanelClose = $("#chat-panel-close"); + /** @type {HTMLDivElement} */ + this._chatHistory = $("#chat-history"); + /** @type {HTMLDivElement} */ + this._bubbleStack = $("#bubble-stack"); + + this._panelOpen = false; + this._scrollQueued = false; + + // ── User transcript state (keyed by item_id) ─────────────────────────── + /** @type {Map} */ + this._userHistByItem = new Map(); + /** @type {HTMLElement | null} */ + this._activeUserBubble = null; + this._activeUserItemId = ""; + // Monotonic counter for synthesizing unique keys when the server omits an + // item_id / response_id, so id-less messages never collapse onto each other. + this._anonSeq = 0; + + // ── Assistant transcript state (keyed by response_id) ────────────────── + /** @type {Map} */ + this._asstByResp = new Map(); + + // ── Ephemeral bubble auto-dismiss ────────────────────────────────────── + // Per-element expiry (epoch ms). A bubble fades once its expiry passes — + // but only in stack order (see _reapBubbles). Refreshing the expiry keeps a + // bubble alive while it updates (e.g. the live user utterance). + /** @type {WeakMap} */ + this._bubbleExpiry = new WeakMap(); + // Single pending reaper handle: one timer for the whole stack (not one per + // bubble) so dismissal is strictly oldest-first regardless of per-bubble delays. + this._reaperHandle = 0; + + this._chatBtn.addEventListener("click", () => (this._panelOpen ? this._closePanel() : this._openPanel())); + this._chatPanelClose.addEventListener("click", () => this._closePanel()); + this._chatPanelBackdrop.addEventListener("click", () => this._closePanel()); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && this._panelOpen) this._closePanel(); + }); + } + + // ── Panel ─────────────────────────────────────────────────────────────── + + _openPanel() { + this._panelOpen = true; + this._chatPanel.classList.add("open"); + this._chatBadge.classList.remove("visible"); + this._scrollToBottom(); + } + + _closePanel() { + this._panelOpen = false; + this._chatPanel.classList.remove("open"); + } + + // Coalesce scroll-to-bottom: a burst of cumulative transcript deltas would + // otherwise queue one rAF per delta, all writing the same scrollTop. + _scrollToBottom() { + if (!this._panelOpen || this._scrollQueued) return; + this._scrollQueued = true; + requestAnimationFrame(() => { + this._scrollQueued = false; + this._chatHistory.scrollTop = this._chatHistory.scrollHeight; + }); + } + + _markUnread() { + if (this._panelOpen) { + this._scrollToBottom(); + return; + } + this._chatBadge.classList.add("visible"); + } + + // ── Shared rendering ────────────────────────────────────────────────────── + + /** + * Build a role-labelled message element. Ephemeral bubbles and persistent + * history rows share the same shape and differ only in their class prefix + * (`bubble`/`bubble-*` vs `hist-msg`/`hist-*`). + * @param {{ container: string, prefix: string, role: "user"|"assistant", text: string, partial?: boolean }} o + * @returns {HTMLElement} + */ + _buildMessageEl({ container, prefix, role, text, partial = false }) { + const el = document.createElement("div"); + el.className = `${container} ${role}`; + const label = role === "user" ? "You" : "Assistant"; + el.innerHTML = `
${label}
${escHtml(text)}
`; + return el; + } + + // ── Ephemeral bubbles ─────────────────────────────────────────────────── + + /** @param {"user"|"assistant"|"tool"} role @param {string} text @returns {HTMLElement} */ + _spawnBubble(role, text) { + let el; + if (role === "tool") { + el = document.createElement("div"); + el.className = "bubble tool"; + el.innerHTML = `${WRENCH_PATH}${escHtml(text)}`; + } else { + el = this._buildMessageEl({ container: "bubble", prefix: "bubble", role, text }); + } + this._bubbleStack.appendChild(el); + // Cap the stack at 3, but never evict the bubble the caller is still + // actively updating (the live user bubble) — drop the next-oldest instead. + const visible = /** @type {HTMLElement[]} */ ([...this._bubbleStack.querySelectorAll(".bubble:not(.out)")]); + if (visible.length > 3) { + this._dismissBubble(visible.find((b) => b !== this._activeUserBubble) ?? visible[0]); + } + requestAnimationFrame(() => el.classList.add("in")); + return el; + } + + /** @param {HTMLElement} el @param {string} text */ + _updateBubbleText(el, text) { + const t = el.querySelector(".bubble-body"); + if (t) t.textContent = text; + } + + /** @param {HTMLElement} el */ + _dismissBubble(el) { + if (!el || el.classList.contains("out")) return; // idempotent + this._bubbleExpiry.delete(el); + el.classList.remove("in"); + el.classList.add("out"); + const remove = () => el.remove(); + el.addEventListener("transitionend", remove, { once: true }); + // Fallback: transitionend never fires if the bubble's visual state didn't + // change (dismissed pre-paint) or the tab is backgrounded. The transition + // is 0.3s, so force removal a little after. + setTimeout(remove, 400); + } + + /** + * Fade bubbles whose expiry has passed — strictly oldest-first. We walk the + * stack top (oldest) to bottom (newest) and stop at the first bubble still + * alive: nothing newer may leave while an older bubble is still on screen. A + * bubble that keeps updating pushes its own expiry forward, so it (and + * everything behind it) stays put until it finally goes quiet. + */ + _reapBubbles() { + this._reaperHandle = 0; + const now = Date.now(); + const visible = /** @type {HTMLElement[]} */ ([...this._bubbleStack.querySelectorAll(".bubble:not(.out)")]); + let nextWake = Infinity; + for (const el of visible) { + const exp = this._bubbleExpiry.get(el) ?? now; // no expiry recorded → treat as due + if (exp <= now) { + this._dismissBubble(el); + } else { + // Oldest survivor isn't due yet; stop so nothing newer leaves before it. + nextWake = exp; + break; + } + } + if (nextWake !== Infinity) { + this._reaperHandle = setTimeout(() => this._reapBubbles(), Math.max(50, nextWake - Date.now())); + } + } + + /** + * (Re)arm a bubble's auto-dismiss by pushing its expiry out by `delay`. + * Calling it again resets the countdown — so a bubble that keeps updating + * stays on screen and only fades once it goes quiet. Removal is ordered by the + * shared reaper, so the oldest bubble always disappears first. + * @param {HTMLElement} el @param {number} [delay] + */ + _bumpDismiss(el, delay = 4000) { + this._bubbleExpiry.set(el, Date.now() + delay); + if (!this._reaperHandle) this._reaperHandle = setTimeout(() => this._reapBubbles(), delay); + } + + // ── History ─────────────────────────────────────────────────────────────── + + /** Render the empty-state placeholder into the history panel. */ + renderEmptyState() { + this._chatHistory.innerHTML = EMPTY_STATE_HTML; + } + + /** Reset the panel to the empty state and clear the unread badge. */ + clear() { + this.renderEmptyState(); + this._chatBadge.classList.remove("visible"); + } + + /** @param {"user"|"assistant"} role @param {string} text @param {boolean} partial @returns {HTMLElement} */ + _appendHistMsg(role, text, partial) { + const empty = this._chatHistory.querySelector(".chat-empty"); + if (empty) empty.remove(); + const el = this._buildMessageEl({ container: "hist-msg", prefix: "hist", role, text, partial }); + this._chatHistory.appendChild(el); + this._scrollToBottom(); + return el; + } + + /** @param {HTMLElement | null} el @param {string} text @param {boolean} partial */ + _updateHistMsg(el, text, partial) { + if (!el) return; + const body = /** @type {HTMLElement | null} */ (el.querySelector(".hist-body")); + if (!body) return; + body.textContent = text; + body.classList.toggle("partial", partial); + this._scrollToBottom(); + } + + /** + * Append a tool-call row to the conversation. We only add it once the tool + * has run, so the expandable toggle carries BOTH the call input and its result. + * @param {string} name @param {string} argsJson @param {string} output + */ + _appendHistTool(name, argsJson, output) { + const empty = this._chatHistory.querySelector(".chat-empty"); + if (empty) empty.remove(); + let pretty = argsJson; + try { pretty = JSON.stringify(JSON.parse(argsJson), null, 2); } catch {} + const el = document.createElement("div"); + el.className = "hist-msg tool"; + el.innerHTML = ` +
Tool call
+ +
+
Input
+
${escHtml(pretty)}
+
Output
+
${escHtml(output || "(no output)")}
+
+ `; + const header = /** @type {HTMLButtonElement} */ (el.querySelector(".hist-tool-header")); + const body = /** @type {HTMLDivElement} */ (el.querySelector(".hist-tool-body")); + header.addEventListener("click", () => { + const expanded = header.getAttribute("aria-expanded") === "true"; + header.setAttribute("aria-expanded", String(!expanded)); + body.classList.toggle("open", !expanded); + }); + this._chatHistory.appendChild(el); + this._scrollToBottom(); + } + + /** Tag an assistant history row as interrupted (user barged in mid-reply). + * @param {HTMLElement | null} hist */ + _markHistInterrupted(hist) { + if (!hist || hist.querySelector(".hist-note")) return; + hist.classList.add("interrupted"); + const note = document.createElement("div"); + note.className = "hist-note"; + note.textContent = "Interrupted"; + hist.appendChild(note); + } + + /** Render a captured webcam frame in the transcript (the camera tool result). + * @param {string} dataUrl */ + _appendHistImage(dataUrl) { + const empty = this._chatHistory.querySelector(".chat-empty"); + if (empty) empty.remove(); + const el = document.createElement("div"); + el.className = "hist-msg tool"; + el.innerHTML = `
Snapshot
Webcam snapshot sent to the model`; + const img = /** @type {HTMLImageElement} */ (el.querySelector("img")); + img.src = dataUrl; + this._chatHistory.appendChild(el); + this._scrollToBottom(); + } + + /** + * Reset all streaming bookkeeping for session start / teardown. Pass + * `dismiss` to also fade any bubbles still on screen. + * @param {{ dismiss?: boolean }} [opts] + */ + reset(opts) { + if (opts?.dismiss) { + if (this._activeUserBubble) this._dismissBubble(this._activeUserBubble); + for (const { bubble } of this._asstByResp.values()) this._dismissBubble(bubble); + } + this._userHistByItem.clear(); + this._activeUserBubble = null; + this._activeUserItemId = ""; + this._asstByResp.clear(); + } + + // ── Client event handlers ───────────────────────────────────────────────── + + /** + * A streamed transcript delta (user or assistant). + * @param {{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }} d + */ + onTranscript(d) { + if (DEBUG) console.debug(`[ui] transcript role=${d.role} partial=${d.partial} item=${d.itemId} resp=${d.responseId} text=${JSON.stringify(d.text)}`); + + if (d.role === "user") { + // Group by item_id: a speculative continuation reuses the same id, so it + // updates the same row/bubble. A missing id falls back to the active item + // (same utterance) or a fresh unique key, never a shared sentinel that + // would collapse distinct turns into one row. + const id = d.itemId || this._activeUserItemId || `_u${++this._anonSeq}`; + const text = d.text; + + let hist = this._userHistByItem.get(id); + if (!hist) { + hist = this._appendHistMsg("user", text, d.partial); + this._userHistByItem.set(id, hist); + } else { + this._updateHistMsg(hist, text, d.partial); + } + + // One ephemeral bubble per active item. Purely timer-based: the timer is + // refreshed on every delta, so it stays while the user keeps talking and + // fades a few seconds after they stop — no dependency on a response ever + // arriving, so it can never get stuck. + if (this._activeUserItemId !== id || !this._activeUserBubble) { + this._activeUserBubble = this._spawnBubble("user", text); + this._activeUserItemId = id; + } else { + this._updateBubbleText(this._activeUserBubble, text); + } + this._bumpDismiss(this._activeUserBubble, 6000); + this._markUnread(); + } else if (d.role === "assistant") { + // Assistant transcript arrives once, as the full text, keyed by + // response_id so a cancelled speculative response can be removed later. A + // missing id gets a unique key so two id-less replies never collide. + const rid = d.responseId || `_a${++this._anonSeq}`; + const entry = this._asstByResp.get(rid); + if (!entry) { + const bubble = this._spawnBubble("assistant", d.text); + this._asstByResp.set(rid, { bubble, hist: this._appendHistMsg("assistant", d.text, false) }); + this._bumpDismiss(bubble); + } else { + this._updateBubbleText(entry.bubble, d.text); + this._updateHistMsg(entry.hist, d.text, false); + this._bumpDismiss(entry.bubble); + } + this._markUnread(); + } + } + + /** + * A response closed (completed or cancelled). + * @param {{ responseId: string; status: string; audible?: boolean; transcript?: string }} detail + */ + onResponseFinished(detail) { + const { responseId, status, audible, transcript } = detail; + if (DEBUG) console.debug(`[ui] response-finished resp=${responseId} status=${status} audible=${audible} known=${this._asstByResp.has(responseId)}`); + // Without an id we can't target a specific response; the bubble will + // auto-dismiss on its own timer regardless. + if (!responseId) return; + const entry = this._asstByResp.get(responseId); + + if (status === "cancelled") { + // Keep every transcript that was received — mark it interrupted rather + // than erasing it. If the `*.transcript.done` never fired, build the row + // from the text carried in response.done. + let hist = entry?.hist ?? null; + if (!hist && transcript) { + hist = this._appendHistMsg("assistant", transcript, false); + } else if (hist && transcript) { + this._updateHistMsg(hist, transcript, false); + } + if (hist) this._markHistInterrupted(hist); + this._asstByResp.delete(responseId); + return; + } + + // Any other terminal close (completed / failed / incomplete / …): just + // release the map entry. The bubble already auto-dismisses on its timer and + // the history row persists as the conversation log. Crucially we do NOT + // touch user state here — that lifecycle is fully independent. + this._asstByResp.delete(responseId); + } + + /** The model called a tool — show an ephemeral "running" bubble. + * @param {string} name */ + onToolCall(name) { + this._bumpDismiss(this._spawnBubble("tool", name)); + this._markUnread(); + } + + /** The tool finished — append its call+result row (and any captured image). + * @param {string} name @param {string} argsJson @param {string} output @param {string} [image] */ + onToolResult(name, argsJson, output, image) { + const startedAt = performance.now(); + this._appendHistTool(name, argsJson, output); + // #region debug-point C:chat-tool-render + reportChatDebug("C", "ui/chat.js:onToolResult", "rendered tool result row", { + name, + argsLength: argsJson.length, + outputLength: output.length, + renderMs: Math.round(performance.now() - startedAt), + }); + // #endregion + if (image) this._appendHistImage(image); // show the captured frame below the call + this._markUnread(); + } +} diff --git a/demo/ui/dom.js b/demo/ui/dom.js new file mode 100644 index 0000000..d5a645a --- /dev/null +++ b/demo/ui/dom.js @@ -0,0 +1,37 @@ +// @ts-check +/** Small shared helpers used across the UI modules: a strict query selector, + * HTML escaping for text we drop into innerHTML, error-string trimming, and + * the opt-in debug flag. */ + +/** Opt-in tracing: `localStorage.setItem("s2s.debug", "1")` then reload. */ +export const DEBUG = (() => { + try { + return localStorage.getItem("s2s.debug") === "1"; + } catch { + return false; + } +})(); + +/** + * Query a single element, throwing if it's missing (so a broken selector fails + * loudly at startup rather than as a later null-deref). + * @template {HTMLElement} T + * @param {string} selector + * @returns {T} + */ +export function $(selector) { + const el = document.querySelector(selector); + if (!el) throw new Error(`Missing element: ${selector}`); + return /** @type {T} */ (el); +} + +/** @param {string} s @returns {string} */ +export function escHtml(s) { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +/** Trim a long error message to fit the orb caption. @param {string} text */ +export function truncateError(text) { + if (text.length <= 90) return text; + return text.slice(0, 87) + "…"; +} diff --git a/demo/worklets/audio-playback.js b/demo/worklets/audio-playback.js new file mode 100644 index 0000000..7c7bf2d --- /dev/null +++ b/demo/worklets/audio-playback.js @@ -0,0 +1,186 @@ +// @ts-check +/** + * AudioWorkletProcessor that plays back Float32 mono samples received from + * the main thread, upsampling whatever incoming rate the server uses + * (typically 24 kHz PCM16) to the AudioContext rate (typically 48 kHz). + * + * Lifecycle / messaging: + * + * main -> worklet: + * { kind: "config", inputRate: 24000 } one-shot at startup + * { kind: "audio", samples: Float32Array } (transferable) per chunk + * { kind: "clear" } wipe queue (barge-in) + * + * worklet -> main: + * { kind: "stats", queuedMs, played } every ~250 ms + * { kind: "underrun" } every time the queue + * runs dry mid-playback + * + * Underrun strategy: output silence. We do NOT hold the last sample (that + * tends to produce audible clicks/buzzes when long gaps appear between + * TTS chunks). A short ramp-out + ramp-in at boundaries would be nicer but + * the server's 30 ms cadence makes underruns visible only at end of turn. + */ + +const STATS_INTERVAL_FRAMES = 12000; +const FADE_FRAMES = 32; +const START_BUFFER_MS = 90; + +class AudioPlaybackProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this._inputRate = 24000; + this._stepRatio = this._inputRate / sampleRate; + this._queue = []; + this._readIdx = 0; + this._fracPos = 0; + this._playing = false; + this._framesSinceStats = 0; + this._totalPlayed = 0; + this._fadeIn = 0; + this._fadeOut = 0; + this._lastSample = 0; + this._startBuffered = false; + + this.port.onmessage = (e) => { + const data = e.data; + if (!data || typeof data !== "object") return; + switch (data.kind) { + case "config": + if (typeof data.inputRate === "number" && data.inputRate > 0) { + this._inputRate = data.inputRate; + this._stepRatio = this._inputRate / sampleRate; + } + break; + case "audio": + if (data.samples instanceof Float32Array && data.samples.length > 0) { + this._queue.push(data.samples); + if (!this._playing && this._queuedMs() >= START_BUFFER_MS) { + this._playing = true; + this._startBuffered = true; + this._fadeIn = FADE_FRAMES; + this._fadeOut = 0; + } + } + break; + case "clear": + this._queue.length = 0; + this._readIdx = 0; + this._fracPos = 0; + this._fadeOut = FADE_FRAMES; + this._startBuffered = false; + break; + } + }; + } + + _queuedSamples() { + let total = -this._readIdx; + for (const buf of this._queue) total += buf.length; + return Math.max(0, total); + } + + _queuedMs() { + return (this._queuedSamples() / this._inputRate) * 1000; + } + + /** Linear-interp read at the current fractional position. */ + _readInterpolated() { + if (this._queue.length === 0) return null; + const head = this._queue[0]; + const idx = this._readIdx; + const frac = this._fracPos; + + let a = head[idx]; + let b; + if (idx + 1 < head.length) { + b = head[idx + 1]; + } else if (this._queue.length > 1) { + b = this._queue[1][0]; + } else { + b = a; + } + return a + (b - a) * frac; + } + + /** Advance the read position by `stepRatio`; pop consumed buffers. */ + _advance() { + this._fracPos += this._stepRatio; + while (this._fracPos >= 1) { + this._fracPos -= 1; + this._readIdx += 1; + } + while (this._queue.length > 0 && this._readIdx >= this._queue[0].length) { + this._readIdx -= this._queue[0].length; + this._queue.shift(); + } + } + + process(_, outputs) { + const channels = outputs[0]; + if (!channels || channels.length === 0) return true; + const out = channels[0]; + const stereo = channels.length > 1 ? channels[1] : null; + + for (let i = 0; i < out.length; i++) { + let sample = 0; + + if (!this._playing && this._queue.length > 0 && this._queuedMs() >= START_BUFFER_MS) { + this._playing = true; + this._startBuffered = true; + this._fadeIn = FADE_FRAMES; + this._fadeOut = 0; + } + + if (this._playing) { + const v = this._readInterpolated(); + if (v === null) { + // Underrun: try to ramp out cleanly to avoid clicks. + sample = this._lastSample * Math.max(0, 1 - 1 / FADE_FRAMES); + this._lastSample = sample; + if (Math.abs(sample) < 1e-4) { + this._playing = false; + this._startBuffered = false; + this._lastSample = 0; + this.port.postMessage({ kind: "underrun" }); + } + } else { + sample = v; + this._lastSample = v; + this._advance(); + } + + if (this._fadeIn > 0) { + const gain = 1 - this._fadeIn / FADE_FRAMES; + sample *= gain; + this._fadeIn -= 1; + } + if (this._fadeOut > 0) { + const gain = this._fadeOut / FADE_FRAMES; + sample *= gain; + this._fadeOut -= 1; + if (this._fadeOut === 0) { + this._playing = false; + this._startBuffered = false; + this._lastSample = 0; + } + } + + this._totalPlayed += 1; + } + + out[i] = sample; + if (stereo) stereo[i] = sample; + } + + this._framesSinceStats += out.length; + if (this._framesSinceStats >= STATS_INTERVAL_FRAMES) { + this._framesSinceStats = 0; + this.port.postMessage({ kind: "stats", queuedMs: this._queuedMs(), played: this._totalPlayed }); + } + + return true; + } +} + +registerProcessor("audio-playback", AudioPlaybackProcessor); diff --git a/demo/worklets/mic-capture.js b/demo/worklets/mic-capture.js new file mode 100644 index 0000000..a48860b --- /dev/null +++ b/demo/worklets/mic-capture.js @@ -0,0 +1,159 @@ +// @ts-check +/** + * AudioWorkletProcessor that resamples the AudioContext rate (typically 48 kHz) + * down to 16 kHz, packs the result as little-endian Int16 PCM, and posts it + * back to the main thread in fixed-size chunks. + * + * The Hugging Face speech-to-speech WebSocket route expects the + * `input_audio_buffer.append` payload at 16 kHz PCM16 mono. + * + * Design notes: + * - 48 -> 16 is an exact 3:1 ratio so we use a 3-tap boxcar average as a + * cheap low-pass before decimating. Good enough for voice STT; we lose + * a tiny bit of >8 kHz content which the pipeline discards anyway. + * - Output frames are emitted at the cadence dictated by `chunkMs` + * (default 40 ms = 640 samples = 1280 bytes). The OpenAI Realtime + * server batches incoming audio so the cadence is flexible; 20-100 ms + * is the sweet spot. + * - Float -> Int16 saturates to [-1, 1] before scaling. + * - Optional noise gate: per-chunk RMS decides open/closed against a + * threshold; the gain ramps (fast attack, hold, slow release) so word + * onsets aren't clipped and quiet tails don't click. The gate only + * affects the audio we SEND; the main-thread visualiser taps the raw + * mic separately. We post the chunk RMS up every frame so the Settings + * mic meter can show the live level against the threshold. + */ + +const TARGET_RATE = 16000; +const DEFAULT_CHUNK_MS = 40; +// Gate envelope timing (fixed; only the threshold is user-tunable). +const GATE_ATTACK_MS = 5; // open almost instantly so word onsets survive +const GATE_HOLD_MS = 250; // stay open this long after the level drops back under +const GATE_RELEASE_MS = 80; // then fade closed over this long (no click) + +class MicCaptureProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + const chunkMs = options?.processorOptions?.chunkMs ?? DEFAULT_CHUNK_MS; + this._inputRate = sampleRate; + this._ratio = this._inputRate / TARGET_RATE; + this._chunkSamples16k = Math.round((TARGET_RATE * chunkMs) / 1000); + this._scratch = new Float32Array(0); + this._decimated = new Float32Array(this._chunkSamples16k); + this._enabled = true; + + // Noise gate state. Disabled by default (pure passthrough). + this._gateEnabled = false; + this._thresholdLin = 0; // linear amplitude; signal RMS must exceed this to open + this._gateGain = 1; // smoothed gain currently applied + this._holdRemaining = 0; // samples left before the gate may start closing + this._attackCoef = Math.exp(-1 / ((GATE_ATTACK_MS / 1000) * TARGET_RATE)); + this._releaseCoef = Math.exp(-1 / ((GATE_RELEASE_MS / 1000) * TARGET_RATE)); + this._holdSamples = Math.round((GATE_HOLD_MS / 1000) * TARGET_RATE); + + this.port.onmessage = (e) => { + const data = e.data; + if (data?.kind === "enable") this._enabled = !!data.value; + else if (data?.kind === "gate") { + this._gateEnabled = !!data.enabled; + // dB -> linear amplitude. When off, threshold 0 keeps the gate open. + this._thresholdLin = data.enabled ? Math.pow(10, data.thresholdDb / 20) : 0; + } + }; + } + + /** + * Append `incoming` to the internal scratch buffer, then emit as many + * full output chunks as we have material for. + * @param {Float32Array} incoming + */ + _ingest(incoming) { + if (incoming.length === 0) return; + const next = new Float32Array(this._scratch.length + incoming.length); + next.set(this._scratch, 0); + next.set(incoming, this._scratch.length); + this._scratch = next; + this._maybeEmit(); + } + + _maybeEmit() { + const r = this._ratio; + const n = this._chunkSamples16k; + const needIn = Math.ceil(n * r); + const dec = this._decimated; + while (this._scratch.length >= needIn) { + // 1. Decimate to 16 kHz floats and accumulate energy for the gate/meter. + let sumSq = 0; + if (Math.abs(r - 3) < 1e-6) { + // 48 kHz -> 16 kHz fast path with boxcar lowpass. + for (let i = 0; i < n; i++) { + const idx = i * 3; + const s = (this._scratch[idx] + this._scratch[idx + 1] + this._scratch[idx + 2]) / 3; + dec[i] = s; + sumSq += s * s; + } + } else { + // Generic path: linear interpolation. Slower but works at any rate + // (e.g. some Windows boxes report sampleRate=44100). + for (let i = 0; i < n; i++) { + const srcPos = i * r; + const idx = Math.floor(srcPos); + const frac = srcPos - idx; + const a = this._scratch[idx]; + const b = this._scratch[idx + 1] ?? a; + const s = a + (b - a) * frac; + dec[i] = s; + sumSq += s * s; + } + } + const rms = Math.sqrt(sumSq / n); + + // 2. Decide the gate target for this chunk, then ramp sample-by-sample. + let target = 1; + if (this._gateEnabled) { + if (rms >= this._thresholdLin) { + this._holdRemaining = this._holdSamples; // re-arm the hold + } else if (this._holdRemaining > 0) { + this._holdRemaining -= n; // coasting through the hold window + } else { + target = 0; + } + } + + // 3. Apply the (smoothed) gain and pack to Int16. + const out = new Int16Array(n); + let gain = this._gateGain; + for (let i = 0; i < n; i++) { + const coef = target > gain ? this._attackCoef : this._releaseCoef; + gain = target + (gain - target) * coef; + const s = dec[i] * gain; + const clamped = s < -1 ? -1 : s > 1 ? 1 : s; + out[i] = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff; + } + this._gateGain = gain; + + // Shift the scratch buffer to keep only the trailing unused samples. + const consumed = Math.floor(n * r); + this._scratch = this._scratch.slice(consumed); + + // Live input level for the Settings meter (raw RMS, pre-gate). + this.port.postMessage({ kind: "level", rms }); + + if (this._enabled) { + this.port.postMessage(out.buffer, [out.buffer]); + } + // When disabled (mic muted) we silently consume input so the worklet + // stays alive and the buffer never grows unbounded. + } + } + + process(inputs) { + const input = inputs[0]; + if (!input || input.length === 0 || !input[0]) return true; + const mono = input[0]; + if (mono.length > 0) this._ingest(mono); + return true; + } +} + +registerProcessor("mic-capture", MicCaptureProcessor); diff --git a/demo/ws/codec.js b/demo/ws/codec.js new file mode 100644 index 0000000..511ad9e --- /dev/null +++ b/demo/ws/codec.js @@ -0,0 +1,57 @@ +// @ts-check +/** + * Pure, stateless helpers for the WebSocket realtime client: base64 <-> PCM + * conversion for the audio frames on the wire, transcript extraction from a + * `response.done` payload, and a tiny URL helper. Kept separate from the client + * so the protocol/state logic stays readable. + */ + +/** @param {string} url */ +export function trimTrailingSlash(url) { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +/** + * Pull the assistant transcript out of a `response.done` payload. The text + * lives in `response.output[].content[].transcript` (audio) or `.text`. Used as + * the source of truth for interrupted replies, where the dedicated + * `*.transcript.done` event may never arrive. + * @param {any} response + * @returns {string} + */ +export function extractResponseTranscript(response) { + const output = response?.output; + if (!Array.isArray(output)) return ""; + /** @type {string[]} */ + const parts = []; + for (const item of output) { + for (const part of item?.content ?? []) { + const text = part?.transcript ?? part?.text; + if (typeof text === "string" && text.trim()) parts.push(text.trim()); + } + } + return parts.join(" ").trim(); +} + +/** @param {ArrayBuffer} buf */ +export function base64FromArrayBuffer(buf) { + const bytes = new Uint8Array(buf); + // Chunked encoding so we don't blow up the call stack on long buffers. + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode.apply(null, /** @type {number[]} */ ( + /** @type {unknown} */ (bytes.subarray(i, i + chunk)) + )); + } + return btoa(binary); +} + +/** @param {string} b64 */ +export function base64ToBytes(b64) { + const binary = atob(b64); + const len = binary.length; + const out = new Uint8Array(len); + for (let i = 0; i < len; i++) out[i] = binary.charCodeAt(i); + return out; +} diff --git a/demo/ws/orb-visualizer.js b/demo/ws/orb-visualizer.js new file mode 100644 index 0000000..082f601 --- /dev/null +++ b/demo/ws/orb-visualizer.js @@ -0,0 +1,98 @@ +// @ts-check +/** + * Orb spectrum visualiser. Each animation frame it reads two AnalyserNodes (the + * mic input and the TTS output) and maps the low-frequency speech energy onto + * the orb's CSS custom properties: + * - `--bar0`..`--bar4` the 5-band level meter + * - `--ai-audio-level` the global "Reachy talks" glow / scale pulse + * + * The bottom of the FFT is where speech energy lives, so the band edges stay + * low — that keeps the bars dancing on voice rather than on noise. While the AI + * is speaking we source the bars from the OUTPUT analyser so the orb pulses with + * Reachy's voice instead of sitting dead while the user is silent. + */ + +// Exported so the client can size its AnalyserNodes to match our buffer. +export const VIS_FFT_SIZE = 256; +const VIS_BAND_COUNT = 5; +const VIS_BAND_EDGES = [2, 5, 9, 16, 28, 52]; +const VIS_ATTACK = 0.6; // weight for new sample on upswing (snappy) +const VIS_RELEASE = 0.18; // weight for new sample on decay (gentle fade) + +export class OrbVisualiser { + /** + * @param {AnalyserNode} micAnalyser + * @param {AnalyserNode} outAnalyser + * @param {() => boolean} isAiSpeaking Source the bars from the AI output when + * true, otherwise from the mic. + */ + constructor(micAnalyser, outAnalyser, isAiSpeaking) { + this._mic = micAnalyser; + this._out = outAnalyser; + this._isAiSpeaking = isAiSpeaking; + this._buf = new Uint8Array(micAnalyser.frequencyBinCount); + this._bands = new Float32Array(VIS_BAND_COUNT); + this._aiLevel = 0; + /** @type {number | null} */ + this._frame = null; + } + + /** Begin the rAF loop (idempotent). */ + start() { + if (this._frame !== null) return; + const root = document.documentElement; + const tick = () => { + this._frame = requestAnimationFrame(tick); + this._update(root); + }; + this._frame = requestAnimationFrame(tick); + } + + /** Stop the loop and clear the CSS vars so the orb returns to rest. */ + stop() { + if (this._frame !== null) { + cancelAnimationFrame(this._frame); + this._frame = null; + } + const root = document.documentElement; + for (let i = 0; i < VIS_BAND_COUNT; i++) root.style.removeProperty(`--bar${i}`); + root.style.removeProperty("--ai-audio-level"); + } + + /** @param {HTMLElement} root */ + _update(root) { + // Mic bars: split FFT into 5 log-ish bands, smooth, write CSS vars. + const source = this._isAiSpeaking() ? this._out : this._mic; + source.getByteFrequencyData(this._buf); + + for (let b = 0; b < VIS_BAND_COUNT; b++) { + const lo = VIS_BAND_EDGES[b]; + const hi = VIS_BAND_EDGES[b + 1]; + let sum = 0; + let n = 0; + for (let i = lo; i < hi && i < this._buf.length; i++) { + sum += this._buf[i]; + n += 1; + } + const target = n > 0 ? sum / (n * 255) : 0; + const prev = this._bands[b]; + const k = target > prev ? VIS_ATTACK : VIS_RELEASE; + const next = prev + (target - prev) * k; + this._bands[b] = next; + root.style.setProperty(`--bar${b}`, next.toFixed(3)); + } + + // Global AI audio level: peak of the output analyser, used by the CSS to + // make the orb's glow / scale react to Reachy's voice. + this._out.getByteFrequencyData(this._buf); + let peak = 0; + const limit = Math.min(this._buf.length, VIS_BAND_EDGES[VIS_BAND_COUNT]); + for (let i = 0; i < limit; i++) { + if (this._buf[i] > peak) peak = this._buf[i]; + } + const aiTarget = peak / 255; + const k = aiTarget > this._aiLevel ? VIS_ATTACK : VIS_RELEASE; + this._aiLevel = this._aiLevel + (aiTarget - this._aiLevel) * k; + root.style.setProperty("--ai-audio-level", this._aiLevel.toFixed(3)); + } +} diff --git a/demo/ws/s2s-ws-client.js b/demo/ws/s2s-ws-client.js new file mode 100644 index 0000000..f1e0ac5 --- /dev/null +++ b/demo/ws/s2s-ws-client.js @@ -0,0 +1,1424 @@ +// @ts-check +/** + * Minimal WebSocket client for the Hugging Face speech-to-speech load balancer. + * + * Two-step handshake (same /session route as the WebRTC client): + * + * 1. POST `/session` -> JSON `{ connect_url: wss:///v1/realtime?session_token=, ... }` + * 2. Open a WebSocket directly on `connect_url` (no rewrite, unlike the WebRTC client). + * + * Once the socket is open we follow the OpenAI Realtime GA WebSocket + * protocol: + * + * - Server pushes `session.created` immediately after upgrade. + * - We send `session.update` (GA schema: `session.audio.{input,output}`, + * `session.output_modalities`, ...). + * - We stream mic audio as PCM16 16 kHz mono base64 chunks via + * `input_audio_buffer.append`. + * - The server pushes `response.output_audio.delta` (PCM16 24 kHz mono + * base64) and transcript deltas. + * + * Audio is handled internally via two AudioWorklet processors so the + * client owns the full mic-in / speaker-out pipeline. The main app only + * sees high-level lifecycle events (`status`, `transcript`, `error`, + * `session`), the same shape as the WebRTC client. + * + * @typedef {"idle" | "creating-session" | "queued" | "your-turn" | "connecting" | + * "connected" | "user-speaking" | "processing" | "ai-speaking" | + * "closed" | "error" + * } WsStatus + * + * @typedef {Object} WsSessionInfo + * @property {string} sessionId + * @property {string} connectUrl + * @property {string} websocketUrl + * @property {string} sessionToken + * @property {number} pendingTimeoutS + * @property {string} [tier] Login tier from the session proxy ("anon"|"free"|"pro"). + * @property {boolean} [limited] Whether this session is metered (heartbeat needed). + * @property {number} [heartbeatSec] Suggested heartbeat cadence in seconds. + * @property {number} [remainingSec] Daily budget left after this grant (display). + */ + +/** + * @param {string} hypothesisId + * @param {string} location + * @param {string} msg + * @param {Record} [data] + */ +function reportWsDebug(hypothesisId, location, msg, data = {}) { + void hypothesisId; + void location; + void msg; + void data; +} + +/** + * @typedef {Object} WsClientOptions + * @property {string} [sessionUrl] URL to POST for the session handshake (returns + * `{ connect_url, ... }`). Usually a same-origin proxy like `api/session` so the + * load-balancer address stays server-side. Provide this OR `directUrl`. + * @property {string} [loadBalancerUrl] Load-balancer base URL. Legacy/direct + * alternative to `sessionUrl`: the client POSTs `/session` itself. Prefer + * `sessionUrl` so the LB address isn't exposed to the browser. + * @property {string} [directUrl] Full WebSocket URL of an s2s realtime endpoint + * (e.g. `ws://localhost:8080/v1/realtime`). When set, the client skips the + * session POST and dials it directly — no load balancer in between. + * @property {string} voice + * @property {string} instructions + * @property {MediaStream} [micStream] Live mic stream. Provide this OR `acquireMic`. + * @property {() => Promise} [acquireMic] Lazily obtain the mic stream, + * called only once a session is actually granted (after any queue wait). Lets the + * caller prime mic permission up front but not hold the mic 'in use' indicator on + * while waiting in line. Ignored if `micStream` is already set. + * @property {AudioContext} [audioContext] Pre-created (and resumed) context. + * iOS Safari only lets an AudioContext start from within a user gesture, so + * the caller creates/resumes it synchronously on the orb tap and hands it + * here; otherwise it stays suspended (silent) after the mic/session awaits. + * @property {ToolDef[]} [tools] Function tools declared to the backend in the + * initial `session.update`. The model decides when to call them; the caller + * executes and replies via `sendToolOutput` + `requestResponse`. + * @property {NoiseGate} [noiseGate] Client-side noise gate applied to the mic + * before it's sent. Tunable live via `setNoiseGate`. + * + * @typedef {Object} NoiseGate + * @property {boolean} enabled + * @property {number} thresholdDb Open threshold in dBFS (e.g. -45). + * + * @typedef {Object} ToolDef + * @property {"function"} type + * @property {string} name + * @property {string} description + * @property {object} parameters JSON Schema for the call arguments. + * + * @typedef {Object} TranscriptEvent + * @property {"user" | "assistant"} role + * @property {string} text + * @property {boolean} partial + */ + +import { + base64FromArrayBuffer, + base64ToBytes, + extractResponseTranscript, + trimTrailingSlash, +} from "./codec.js?v=20260825003"; +import { OrbVisualiser, VIS_FFT_SIZE } from "./orb-visualizer.js?v=20260825003"; + +/** Build an Error carrying a `code` (and optional extra fields) so callers can + * branch on the failure kind: "limit" | "queue-full" | "queue-expired" | "aborted". + * @param {string} message @param {string} code @param {object} [extra] */ +function _codedError(message, code, extra) { + const err = /** @type {Error & { code?: string }} */ (new Error(message)); + err.code = code; + if (extra) Object.assign(err, extra); + return err; +} + +// The s2s pipeline runs internally at 16 kHz mono PCM. The WebRTC transport +// resamples to 48 kHz for Opus, but the WebSocket transport emits the +// native pipeline rate. We don't (can't) override it via `audio.output.format` +// because the server's pydantic validator rejects the whole `session.update` +// as soon as a sub-field shape it doesn't know about appears. +const OUTPUT_SAMPLE_RATE = 16000; +const MIC_CHUNK_MS = 40; + +export class S2sWsRealtimeClient extends EventTarget { + /** @param {WsClientOptions} options */ + constructor(options) { + super(); + /** @type {WsClientOptions} */ + this.options = options; + /** @type {ToolDef[]} Function tools declared to the backend. */ + this._tools = options.tools ?? []; + /** @type {string} Direct realtime WS URL (set => skip the LB session POST). */ + this._directUrl = options.directUrl ?? ""; + /** @type {string} Where to POST for the session handshake. Prefer the + * explicit `sessionUrl`; fall back to `/session` for callers + * that still pass the LB address directly. */ + this._sessionUrl = options.sessionUrl + ? options.sessionUrl + : options.loadBalancerUrl + ? `${trimTrailingSlash(options.loadBalancerUrl)}/session` + : ""; + /** @type {(() => Promise) | null} Lazy mic acquisition (post-grant). */ + this._acquireMic = options.acquireMic ?? null; + /** @type {boolean} Set by close() to abort a queue wait in progress. */ + this._closed = false; + /** @type {string} The active queue ticket id while waiting (else ""). */ + this._queueId = ""; + /** @type {(() => void) | null} Wakes the queue poll sleep early on close(). */ + this._queueWake = null; + /** @type {ReturnType | 0} */ + this._queueTimer = 0; + // Join gate: after waiting in line the caller must explicitly `join()` before + // we dial, so a slot isn't spent on someone who walked away. Resolved by + // join(), rejected on timeout (the LB reclaims the slot) or close(). + /** @type {(() => void) | null} */ + this._joinResolve = null; + /** @type {((err: Error) => void) | null} */ + this._joinReject = null; + /** @type {ReturnType | 0} */ + this._joinTimer = 0; + /** @type {NoiseGate} Mic noise gate; off by default. */ + this._noiseGate = options.noiseGate ?? { enabled: false, thresholdDb: -45 }; + /** @type {WebSocket | null} */ + this._ws = null; + /** @type {AudioContext | null} */ + this._ctx = null; + /** @type {MediaStreamAudioSourceNode | null} */ + this._micSrc = null; + /** @type {AudioWorkletNode | null} */ + this._captureNode = null; + /** @type {AudioWorkletNode | null} */ + this._playbackNode = null; + /** @type {GainNode | null} */ + this._captureSink = null; + /** @type {AnalyserNode | null} */ + this._micAnalyser = null; + /** @type {AnalyserNode | null} */ + this._outAnalyser = null; + /** @type {OrbVisualiser | null} */ + this._visualiser = null; + /** @type {WsStatus} */ + this._status = "idle"; + this._aiSpeaking = false; + /** @type {Set} response_ids that have actually played audio, so the + * UI can tell a barge-in cut (keep it) from a never-heard speculative + * response (drop it). */ + this._audibleResponses = new Set(); + /** @type {Map} The CURRENT assistant transcript segment per + * response, accumulated from streamed deltas (reset on each segment's done). */ + this._asstTranscriptByResp = new Map(); + /** @type {Map} Completed assistant transcript segments per + * response, space-joined. A single response can emit several + * `*.transcript.done` events; we concatenate them until response.done. */ + this._asstFullByResp = new Map(); + this._muted = false; + // ── Response lock ──────────────────────────────────────────────────── + // The backend allows only ONE response in flight: creating a second while + // one is active fails with `conversation_already_has_active_response`. So + // we serialize response.create. `_openResponses` counts responses the + // server has confirmed (response.created) but not yet finished + // (response.done) — it's cumulative, so every create maps to one done. + // `_createInFlight` covers the window after we send a create but before its + // response.created echo. Any requestResponse() made while locked is queued + // and replayed, one at a time, as each response.done frees the slot. + this._openResponses = 0; + this._createInFlight = false; + /** @type {{ image?: string }[]} Pending response.create payloads, one per + * queued requestResponse(). A payload may carry an image to send just + * before its create (so the frame travels with the create, not eagerly). */ + this._createQueue = []; + /** @type {string[]} Pending user texts to inject only once the active + * assistant response has fully closed, preventing barge-in style audio cuts. */ + this._deferredUserTextQueue = []; + /** @type {boolean} Silence the next assistant response opened after an MCP + * fallback wins the turn; covers the race before `response.created` lands. */ + this._suppressNextAssistantResponse = false; + /** @type {string} Response id currently being silenced because it belongs + * to the speculative pre-tool reply. */ + this._suppressedResponseId = ""; + /** @type {string} Latest response id known to be active. */ + this._activeResponseId = ""; + /** @type {Promise | null} */ + this._readyPromise = null; + this._sessionConfigured = false; + this._debug = (() => { try { return localStorage.getItem("s2s.debug") === "1"; } catch { return false; } })(); + } + + get status() { + return this._status; + } + + /** @param {WsStatus} status */ + _setStatus(status) { + if (this._status === status) return; + this._status = status; + this.dispatchEvent(new CustomEvent("status", { detail: { status } })); + } + + /** Full assistant transcript so far for a response: the completed segments + * plus the in-progress one, all space-joined. + * @param {string} rid @returns {string} */ + _asstDisplay(rid) { + const full = this._asstFullByResp.get(rid) || ""; + const seg = this._asstTranscriptByResp.get(rid) || ""; + if (!seg) return full; + return full ? `${full} ${seg}` : seg; + } + + _markAudible() { + if (this._status === "ai-speaking") return; + if (this._status === "closed" || this._status === "error") return; + this._setStatus("ai-speaking"); + } + + /** + * Full handshake. Resolves once the WS is open AND the audio pipeline is + * ready to send/receive samples. + * @returns {Promise} + */ + async connect() { + if (this._ws) throw new Error("Already connected"); + + let connectUrl; + if (this._directUrl) { + // Direct mode: no load balancer, no /session POST — dial the realtime + // endpoint straight away (e.g. a local s2s server). + connectUrl = this._directUrl; + this._setStatus("connecting"); + } else { + if (!this._sessionUrl) { + throw new Error("No session endpoint or direct URL configured"); + } + this._setStatus("creating-session"); + const { grant, waited } = await this._createSessionOrQueue(); + if (this._closed) throw _codedError("connect aborted", "aborted"); + // If we waited in line, don't dial until the user explicitly joins — this + // keeps a freed slot from being spent on someone who stepped away, and the + // click is a fresh gesture (re-arms the AudioContext on iOS). + if (waited) { + await this._awaitJoin(grant); + if (this._closed) throw _codedError("connect aborted", "aborted"); + } + this.dispatchEvent(new CustomEvent("session", { detail: { info: grant } })); + connectUrl = grant.connectUrl; + this._setStatus("connecting"); + } + + // Acquire the mic now — only once a slot is actually ours. The caller primed + // permission up front, so this is silent and the 'in use' indicator lights + // only for a real, connecting session (never during a queue wait). + if (!this.options.micStream && this._acquireMic) { + this.options.micStream = await this._acquireMic(); + } + + // Spin up the AudioContext + worklets in parallel with the WS dial. + const audioReady = this._setupAudio(); + const wsReady = this._openWebSocket(connectUrl); + await Promise.all([audioReady, wsReady]); + } + + /** + * POST the session handshake; if the pool is busy, wait in the queue (polling + * position) until a slot is claimed. Resolves to a grant plus whether we had to + * wait (which decides if an explicit join is required before dialing). + * @returns {Promise<{ grant: WsSessionInfo, waited: boolean }>} + */ + async _createSessionOrQueue() { + const first = await this._postSession(); + if (first.state === "queued") { + this._setStatus("queued"); + const grant = await this._pollQueue(first); + return { grant, waited: true }; + } + return { grant: first.grant, waited: false }; + } + + /** + * Hold at the front of the line until the user clicks join (resolves the gate) + * or the grant lapses. Announces "your-turn" + a deadline the UI counts down. + * @param {WsSessionInfo} grant + * @returns {Promise} + */ + _awaitJoin(grant) { + // The LB reclaims an unclaimed slot at its pending timeout; expire the gate a + // touch earlier so we never dial a session the LB just reaped. + const windowS = Math.max(3, (grant.pendingTimeoutS || 60) - 3); + this._setStatus("your-turn"); + this.dispatchEvent( + new CustomEvent("ready-to-join", { detail: { info: grant, expiresSec: windowS } }), + ); + return new Promise((resolve, reject) => { + this._joinResolve = resolve; + this._joinReject = reject; + this._joinTimer = setTimeout(() => { + this._joinResolve = null; + this._joinReject = null; + reject(_codedError("Your spot expired", "join-expired")); + }, windowS * 1000); + }); + } + + /** Accept the held slot and let connect() proceed to dial. Called from the + * "Join now" click, so it's a user gesture: re-resume the AudioContext, which + * iOS may have suspended while we waited. */ + join() { + if (this._joinTimer) { + clearTimeout(this._joinTimer); + this._joinTimer = 0; + } + try { + void this.options.audioContext?.resume(); + } catch { + // best-effort; _setupAudio resumes again + } + const resolve = this._joinResolve; + this._joinResolve = null; + this._joinReject = null; + resolve?.(); + } + + /** + * POST /session once. Returns either a granted session or a queue ticket. + * @returns {Promise<{ state: "granted", grant: WsSessionInfo } | { state: "queued", queueId: string, position: number, pollIntervalS: number }>} + */ + async _postSession() { + const url = this._sessionUrl; + console.log("[ws] POST", url); + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + if (response.status === 402) { + // The session proxy refused: today's per-tier time budget is spent. Surface + // it as a typed error so the UI shows the limit modal, not a crash. + const body = await response.json().catch(() => ({})); + throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier }); + } + if (response.status === 503) { + const body = await response.json().catch(() => ({})); + if (body?.state === "at_capacity") { + throw _codedError("The queue is full — try again shortly.", "queue-full"); + } + throw new Error("/session failed (503)"); + } + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`/session failed (${response.status}): ${text}`); + } + const json = await response.json(); + if (json.state === "queued") { + return { + state: "queued", + queueId: json.queue_id, + position: json.position, + pollIntervalS: json.poll_interval_s, + }; + } + return { state: "granted", grant: this._parseGrant(json) }; + } + + /** + * Poll the waiting queue until this ticket claims a slot. Emits `queue` events + * ({ position }) as the line advances. Throws on limit (402), expiry (404), or + * close(). Transient network/5xx blips are ignored and retried next tick. + * @param {{ queueId: string, position: number, pollIntervalS: number }} ticket + * @returns {Promise} + */ + async _pollQueue(ticket) { + const intervalMs = Math.max(1, ticket.pollIntervalS || 2) * 1000; + this._queueId = ticket.queueId; + this._emitQueue(ticket.position); + + while (true) { + await this._queueSleep(intervalMs); + if (this._closed) throw _codedError("queue wait aborted", "aborted"); + + let response; + try { + response = await fetch(`api/queue/${encodeURIComponent(this._queueId)}`, { + headers: { "Content-Type": "application/json" }, + }); + } catch { + continue; // network blip — keep our place, retry next tick + } + + if (response.status === 402) { + const body = await response.json().catch(() => ({})); + throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier }); + } + if (response.status === 404) { + throw _codedError("Queue timed out", "queue-expired"); + } + if (!response.ok) continue; // 502/503 — transient, retry + + const json = await response.json().catch(() => null); + if (!json) continue; + if (json.state === "queued") { + this._emitQueue(json.position); + continue; + } + // Reached the front and claimed a slot. + this._queueId = ""; + return this._parseGrant(json); + } + } + + /** @param {number} position */ + _emitQueue(position) { + this.dispatchEvent( + new CustomEvent("queue", { detail: { position, queueId: this._queueId } }), + ); + } + + /** A sleep that close() can cut short so a queued client tears down promptly. + * @param {number} ms */ + _queueSleep(ms) { + return new Promise((resolve) => { + this._queueWake = resolve; + this._queueTimer = setTimeout(() => { + this._queueWake = null; + resolve(); + }, ms); + }); + } + + /** @param {any} json @returns {WsSessionInfo} */ + _parseGrant(json) { + return { + sessionId: json.session_id, + connectUrl: json.connect_url, + websocketUrl: json.websocket_url, + sessionToken: json.session_token, + pendingTimeoutS: json.pending_timeout_s, + tier: json.tier, + limited: json.limited, + heartbeatSec: json.heartbeatSec, + remainingSec: json.remainingSec, + }; + } + + async _setupAudio() { + // Prefer a context the caller already created + resumed inside the tap + // gesture (required on iOS). Fall back to creating one here for callers + // that don't (desktop is lenient about the gesture timing). + // Most desktops give us 48 kHz, mobiles can give 44.1/24/16 kHz; the + // capture worklet handles any rate (linear interp fallback). + const ctx = this.options.audioContext ?? new AudioContext({ latencyHint: "interactive" }); + this._ctx = ctx; + + // Resume if still suspended. This is best-effort here — on iOS the resume + // that actually counts is the one the caller did synchronously on tap. + if (ctx.state === "suspended") { + try { + await ctx.resume(); + } catch (err) { + console.warn("[ws] AudioContext resume failed:", err); + } + } + + // The worklets live at the repo root, one level up from this module. + const base = new URL("../worklets/", import.meta.url); + await ctx.audioWorklet.addModule(new URL("mic-capture.js", base).href); + await ctx.audioWorklet.addModule(new URL("audio-playback.js", base).href); + + const captureNode = new AudioWorkletNode(ctx, "mic-capture", { + numberOfInputs: 1, + numberOfOutputs: 0, + processorOptions: { chunkMs: MIC_CHUNK_MS }, + }); + captureNode.port.onmessage = (e) => { + const data = e.data; + if (data instanceof ArrayBuffer) { + this._onMicChunk(data); + } else if (data?.kind === "level") { + // Raw pre-gate mic RMS for the Settings meter. + this.dispatchEvent(new CustomEvent("input-level", { detail: { rms: data.rms } })); + } + }; + // Push the initial gate config now that the worklet exists. + captureNode.port.postMessage({ kind: "gate", ...this._noiseGate }); + this._captureNode = captureNode; + + const micSrc = ctx.createMediaStreamSource(this.options.micStream); + micSrc.connect(captureNode); + this._micSrc = micSrc; + + // Mic analyser: tap the mic in parallel with the worklet so we get the + // raw (un-resampled, un-clipped) signal for the visualiser. + const micAnalyser = ctx.createAnalyser(); + micAnalyser.fftSize = VIS_FFT_SIZE; + micAnalyser.smoothingTimeConstant = 0; + micSrc.connect(micAnalyser); + this._micAnalyser = micAnalyser; + + const playbackNode = new AudioWorkletNode(ctx, "audio-playback", { + numberOfInputs: 0, + numberOfOutputs: 1, + outputChannelCount: [1], + }); + playbackNode.port.postMessage({ kind: "config", inputRate: OUTPUT_SAMPLE_RATE }); + playbackNode.port.onmessage = (e) => this._onPlaybackMessage(e.data); + + // Output analyser sits between the playback worklet and the speakers. + const outAnalyser = ctx.createAnalyser(); + outAnalyser.fftSize = VIS_FFT_SIZE; + outAnalyser.smoothingTimeConstant = 0.3; + playbackNode.connect(outAnalyser); + outAnalyser.connect(ctx.destination); + this._outAnalyser = outAnalyser; + this._playbackNode = playbackNode; + + this._visualiser = new OrbVisualiser(micAnalyser, outAnalyser, () => this._aiSpeaking); + this._visualiser.start(); + } + + /** @param {string} connectUrl */ + _openWebSocket(connectUrl) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(connectUrl); + ws.binaryType = "arraybuffer"; + this._ws = ws; + + const onceOpen = () => { + ws.removeEventListener("open", onceOpen); + ws.removeEventListener("error", onceErr); + resolve(); + }; + const onceErr = (e) => { + ws.removeEventListener("open", onceOpen); + ws.removeEventListener("error", onceErr); + reject(new Error(`WebSocket failed to open: ${e?.type ?? "error"}`)); + }; + ws.addEventListener("open", onceOpen); + ws.addEventListener("error", onceErr); + + ws.addEventListener("message", (e) => this._onWsMessage(e.data)); + ws.addEventListener("close", (e) => this._onWsClose(e)); + ws.addEventListener("error", (e) => { + console.error("[ws] socket error", e); + }); + }); + } + + /** + * @param {{ kind: string; queuedMs?: number; played?: number }} data + */ + _onPlaybackMessage(data) { + if (data?.kind === "underrun") { + // Server stopped sending audio mid-response. Most likely the turn + // ended cleanly (a response.done usually arrives just before/after + // this). We let the state machine fall back to "connected" via the + // response.done event handler. + } + } + + /** + * Mic worklet just sent us a ~40 ms PCM16 16 kHz mono chunk. + * Base64-encode and forward via the WS. + * @param {ArrayBuffer} pcm16Buffer + */ + _onMicChunk(pcm16Buffer) { + if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; + if (!this._sessionConfigured) return; // Server rejects audio before session.update. + if (this._muted) return; + const b64 = base64FromArrayBuffer(pcm16Buffer); + this._send({ type: "input_audio_buffer.append", audio: b64 }); + } + + /** + * @param {string | ArrayBuffer | Blob} raw + */ + async _onWsMessage(raw) { + let text; + if (typeof raw === "string") { + text = raw; + } else if (raw instanceof ArrayBuffer) { + text = new TextDecoder("utf-8").decode(raw); + } else if (raw instanceof Blob) { + text = await raw.text(); + } else { + return; + } + + let event; + try { + event = JSON.parse(text); + } catch { + return; + } + + const type = event?.type; + if (typeof type !== "string") return; + // Opt-in event tracing for diagnosing turn/transcript issues. Enable with + // `localStorage.setItem("s2s.debug", "1")` in the browser console. + if (this._debug) { + const extra = type.startsWith("conversation.item.input_audio_transcription") + ? ` item=${event.item_id} ci=${event.content_index} ${event.delta ?? event.transcript ?? ""}` + : type.startsWith("response.") + ? ` resp=${event.response_id ?? event.response?.id ?? ""} status=${event.response?.status ?? ""} ${event.transcript ?? ""}` + : ""; + console.debug(`[ws] ${type}${extra}`); + } + + switch (type) { + case "session.created": + // Server-side defaults for the s2s pipeline are already what we + // want (server_vad, whisper-1 transcription, PCM16 16k in / 24k + // out). We only push the user-tunable bits: voice + instructions. + this._sendSessionUpdate(); + this._sessionConfigured = true; + if (this._status === "connecting") this._setStatus("connected"); + break; + + case "session.updated": + if (this._debug) { + let toolNames = ""; + if (Array.isArray(event.session?.tools)) { + /** @type {string[]} */ + const names = []; + for (const tool of event.session.tools) { + if (tool && typeof tool.name === "string" && tool.name) names.push(tool.name); + } + toolNames = names.join(","); + } + console.debug( + `[Vocero MCP WS] session.updated tool_choice=${event.session?.tool_choice ?? ""} ` + + `tool_count=${Array.isArray(event.session?.tools) ? event.session.tools.length : 0} ` + + `tools=${toolNames || "none"}` + ); + } + // Acknowledged by server, nothing to do. + break; + + case "input_audio_buffer.speech_started": + // User started speaking — stop any audio still playing OR queued, every + // time. We clear unconditionally (not just when `_aiSpeaking`): after a + // reply or a tool result the worklet's ring buffer can still be draining + // even though we already flipped `_aiSpeaking` off, and that tail would + // otherwise keep playing over the user's barge-in. + this._playbackNode?.port.postMessage({ kind: "clear" }); + this._aiSpeaking = false; + this._setStatus("user-speaking"); + break; + + case "input_audio_buffer.speech_stopped": + if (this._status === "user-speaking") this._setStatus("processing"); + break; + + case "response.created": + this._activeResponseId = event.response?.id ?? event.response_id ?? ""; + if (this._suppressNextAssistantResponse && this._activeResponseId && !this._suppressedResponseId) { + this._suppressedResponseId = this._activeResponseId; + this._suppressNextAssistantResponse = false; + this._playbackNode?.port.postMessage({ kind: "clear" }); + // #region debug-point B:suppress-next-response + reportWsDebug("B", "s2s-ws-client.js:response.created", "marked next speculative response for audio suppression", { + responseId: this._suppressedResponseId, + }); + // #endregion + } + // #region debug-point B:response-created + reportWsDebug("B", "s2s-ws-client.js:response.created", "response slot opened", { + responseId: event.response?.id ?? event.response_id ?? "", + status: event.response?.status ?? "", + openResponsesBeforeIncrement: this._openResponses, + createInFlight: this._createInFlight, + }); + // #endregion + if (this._debug) { + console.debug( + `[Vocero MCP WS] response.created id=${event.response?.id ?? event.response_id ?? ""} ` + + `status=${event.response?.status ?? ""}` + ); + } + // A response now owns the slot — count it and clear our create guard + // (this confirms either our create or a server-initiated one). + this._openResponses++; + this._createInFlight = false; + if (this._status === "connected" || this._status === "user-speaking") { + this._setStatus("processing"); + } + break; + + case "response.output_item.added": + if (this._status === "connected" || this._status === "user-speaking") { + this._setStatus("processing"); + } + break; + + case "response.audio.delta": + case "response.output_audio.delta": { + const rid = event.response_id ?? event.response?.id; + if (rid && this._suppressedResponseId && rid === this._suppressedResponseId) break; + this._pushAudioDelta(event.delta); + if (rid) this._audibleResponses.add(rid); + if (!this._aiSpeaking) { + this._aiSpeaking = true; + this._markAudible(); + } + break; + } + + case "response.content_part.added": { + const part = event.part; + if (part?.type === "audio" || part?.type === "output_audio") { + this._markAudible(); + } + break; + } + + case "response.done": { + // #region debug-point B:response-done + reportWsDebug("B", "s2s-ws-client.js:response.done", "response slot closed", { + responseId: event.response?.id ?? "", + status: event.response?.status ?? "completed", + openResponsesBeforeDecrement: this._openResponses, + }); + // #endregion + this._aiSpeaking = false; + // This response freed the slot (completion OR cancellation both arrive + // as response.done). Decrement and, if a create was waiting, replay it. + this._openResponses = Math.max(0, this._openResponses - 1); + if (this._status === "ai-speaking" || this._status === "processing") { + this._setStatus("connected"); + } + // A response closes here for BOTH normal completion and cancellation + // (the s2s server signals a speculative-turn interrupt as + // `response.done` with status "cancelled" — there is no separate + // `response.cancelled` event). Surface the id + status so the UI can + // drop a cancelled response's transcript and commit a completed one. + const status = event.response?.status ?? "completed"; + const responseId = event.response?.id ?? ""; + if (responseId && responseId === this._activeResponseId) this._activeResponseId = ""; + if (responseId && responseId === this._suppressedResponseId) this._suppressedResponseId = ""; + // Did this response ever play audio? Distinguishes a barge-in cut (the + // user heard part of it) from a speculative response that never played. + const audible = responseId ? this._audibleResponses.has(responseId) : false; + this._audibleResponses.delete(responseId); + // Pull whatever transcript the response carries, falling back to the + // segments we concatenated from the `*.transcript.done` events (plus any + // in-progress delta). For an interrupted reply the response payload may + // be empty, so this is the last chance to capture the text. + const transcript = + extractResponseTranscript(event.response) || + this._asstDisplay(responseId) || + ""; + // Response finished — clear both transcript accumulators for it. + this._asstTranscriptByResp.delete(responseId); + this._asstFullByResp.delete(responseId); + this.dispatchEvent(new CustomEvent("response-finished", { + detail: { responseId, status, audible, transcript }, + })); + this._flushDeferredUserText(); + // The slot is free now — replay a queued create (e.g. a tool follow-up + // that arrived while this response was still running). + this._flushQueuedCreate(); + break; + } + + case "response.function_call_arguments.done": { + const name = typeof event.name === "string" ? event.name : ""; + const args = typeof event.arguments === "string" ? event.arguments : "{}"; + const callId = typeof event.call_id === "string" ? event.call_id : ""; + // #region debug-point D:function-call-done + reportWsDebug("D", "s2s-ws-client.js:response.function_call_arguments.done", "received function_call_arguments.done", { + name, + callId, + args, + }); + // #endregion + console.debug( + `[Vocero MCP WS] function_call_arguments.done name=${name || "unknown"} ` + + `callId=${JSON.stringify(callId)} args=${args}` + ); + if (name) { + this.dispatchEvent(new CustomEvent("toolcall", { + detail: { name, arguments: args, callId }, + })); + } else { + // A nameless call can't be executed, so no function_call_output is + // ever sent and the model would wait forever for a result. The + // backend shouldn't emit these; warn loudly rather than stall silently. + console.warn(`[ws] function_call_arguments.done with no name (call_id=${callId}); cannot run tool — turn may stall`); + } + break; + } + + case "conversation.item.input_audio_transcription.delta": { + const delta = typeof event.delta === "string" ? event.delta : ""; + if (delta) { + // `itemId` is REUSED across a speculative continuation, so the UI + // groups both segments into one message. The delta carries the full + // cumulative transcript so far (not an increment). + this.dispatchEvent( + new CustomEvent("transcript", { + detail: { + role: "user", + text: delta, + partial: true, + itemId: typeof event.item_id === "string" ? event.item_id : "", + }, + }), + ); + } + break; + } + + case "conversation.item.input_audio_transcription.completed": { + const transcript = typeof event.transcript === "string" ? event.transcript : ""; + if (transcript) { + this.dispatchEvent( + new CustomEvent("transcript", { + detail: { + role: "user", + text: transcript, + partial: false, + itemId: typeof event.item_id === "string" ? event.item_id : "", + }, + }), + ); + } + break; + } + + case "response.audio_transcript.delta": + case "response.output_audio_transcript.delta": { + // Stream the assistant transcript live: accumulate the incremental + // deltas and push the running text to the UI. Every transcribe event we + // receive reaches the conversation, so an interrupted reply already has + // its partial text even if the `.done` never fires. + this._markAudible(); + const rid = typeof event.response_id === "string" ? event.response_id : ""; + const delta = typeof event.delta === "string" ? event.delta : ""; + if (delta) { + this._asstTranscriptByResp.set(rid, (this._asstTranscriptByResp.get(rid) || "") + delta); + // Show completed segments + the segment streaming in right now. + this.dispatchEvent( + new CustomEvent("transcript", { + detail: { role: "assistant", text: this._asstDisplay(rid), partial: true, responseId: rid }, + }), + ); + } + break; + } + + case "response.audio_transcript.done": + case "response.output_audio_transcript.done": { + const rid = typeof event.response_id === "string" ? event.response_id : ""; + // This is ONE completed segment. A response can emit several; concatenate + // them, space-separated, until response.done clears the accumulator. + const segment = + (typeof event.transcript === "string" && event.transcript) || + this._asstTranscriptByResp.get(rid) || + ""; + this._asstTranscriptByResp.delete(rid); // segment finished; next one starts fresh + if (segment) { + const prev = this._asstFullByResp.get(rid) || ""; + this._asstFullByResp.set(rid, prev ? `${prev} ${segment}` : segment); + } + const full = this._asstFullByResp.get(rid) || ""; + if (full) { + this.dispatchEvent( + new CustomEvent("transcript", { + detail: { role: "assistant", text: full, partial: false, responseId: rid }, + }), + ); + } + break; + } + + case "error": { + const err = event.error; + console.error("[ws] server error:", err); + // The "another response is already active" race: our optimistic create + // collided with a still-running response. Don't surface it — clear the + // in-flight guard and re-queue, so the create replays on the next + // response.done (never retried immediately, which would just collide + // again). + if (err?.type === "conversation_already_has_active_response" || + err?.code === "conversation_already_has_active_response") { + if (this._createInFlight) { + this._createInFlight = false; + // Re-queue a BARE create: any image on the original payload was + // already sent before this (rejected) create, so don't resend it. + this._createQueue.push({}); + } + break; + } + // Every other server error is non-fatal: surface it for logging but + // NEVER tear the socket down. Only transport failures (close / failed + // open) are fatal, and those come through their own paths. + this.dispatchEvent( + new CustomEvent("server-error", { detail: { error: new Error(err?.message ?? "Server error") } }), + ); + break; + } + } + } + + /** @param {string} b64 */ + _pushAudioDelta(b64) { + if (!this._playbackNode) return; + if (!b64) return; + const startedAt = performance.now(); + const bytes = base64ToBytes(b64); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const samples = new Float32Array(bytes.byteLength / 2); + for (let i = 0; i < samples.length; i++) { + const s = view.getInt16(i * 2, true); + samples[i] = s < 0 ? s / 0x8000 : s / 0x7fff; + } + this._playbackNode.port.postMessage({ kind: "audio", samples }, [samples.buffer]); + const durationMs = performance.now() - startedAt; + if (durationMs >= 8) { + // #region debug-point E:audio-delta-slow + reportWsDebug("E", "s2s-ws-client.js:_pushAudioDelta", "slow output audio delta decode/post", { + durationMs: Math.round(durationMs), + base64Length: b64.length, + sampleCount: samples.length, + }); + // #endregion + } + } + + /** @param {CloseEvent} ev */ + _onWsClose(ev) { + console.log("[ws] socket closed:", ev.code, ev.reason); + if (this._status === "closed" || this._status === "error") return; + if (ev.code === 1000) { + this._setStatus("closed"); + } else { + this.dispatchEvent( + new CustomEvent("error", { + detail: { error: new Error(`WebSocket closed (${ev.code}) ${ev.reason || ""}`.trim()) }, + }), + ); + this._setStatus("error"); + } + } + + _sendSessionUpdate() { + // Minimal payload: only the bits the user is allowed to configure. + // The s2s server already defaults to server_vad, whisper-1 + // transcription, 16 kHz PCM input and 24 kHz PCM output, so we don't + // need (and must not send) `audio.input.format`, `audio.input.transcription`, + // `audio.input.turn_detection` or `audio.output.format`: the pydantic + // validator on the server rejects the whole event if any unknown or + // future-shaped sub-field shows up. + /** @type {Record} */ + const session = { + type: "realtime", + instructions: this.options.instructions, + audio: { + output: { voice: this.options.voice }, + }, + }; + // Tools are declared here; the backend already accepts them in + // session.update and emits response.function_call_arguments.done when the + // model decides to call one. Only include the keys when we actually have + // tools — the server's pydantic validator is strict about shapes. + if (this._tools.length) { + session.tools = this._tools; + session.tool_choice = "auto"; + } + // #region debug-point B:send-session-update + reportWsDebug("B", "s2s-ws-client.js:_sendSessionUpdate", "sending initial session.update", { + toolCount: this._tools.length, + toolNames: this._tools.map((tool) => tool.name), + hasInstructions: !!this.options.instructions, + instructionLength: (this.options.instructions || "").length, + }); + // #endregion + this._send({ type: "session.update", session }); + } + + /** Update voice/instructions on a live session without tearing down. */ + /** @param {{ voice?: string; instructions?: string }} patch */ + updateSession(patch) { + /** @type {Record} */ + const session = { type: "realtime" }; + if (patch.instructions) session.instructions = patch.instructions; + if (patch.voice) session.audio = { output: { voice: patch.voice } }; + if (Object.keys(session).length > 1) { + // #region debug-point C:update-session + reportWsDebug("C", "s2s-ws-client.js:updateSession", "sending patch session.update", { + hasInstructions: !!patch.instructions, + instructionLength: (patch.instructions || "").length, + hasVoice: !!patch.voice, + }); + // #endregion + this._send({ type: "session.update", session }); + } + } + + /** + * Replace the declared tool set on a live session (e.g. the user flipped a + * tool switch mid-conversation). Always sends `tools` — an empty array + * clears them — so toggling the last tool off actually removes it. + * @param {ToolDef[]} tools + * @param {"auto"|"required"|"none"} [toolChoice] + */ + setTools(tools, toolChoice = tools.length ? "auto" : "none") { + this._tools = tools; + // #region debug-point B:set-tools + reportWsDebug("B", "s2s-ws-client.js:setTools", "replacing live tool set", { + toolCount: tools.length, + toolNames: tools.map((tool) => tool.name), + toolChoice, + }); + // #endregion + this._send({ + type: "session.update", + session: { type: "realtime", tools, tool_choice: toolChoice }, + }); + } + + /** + * Return a tool's result to the model. Pairs with the `toolcall` event's + * `callId`. Caller follows this with `requestResponse()` so the model speaks. + * @param {string} callId + * @param {string} output Plain text / JSON string the model will read. + */ + sendToolOutput(callId, output) { + if (!callId) return; // Can't target a result without the call id. + if (this._debug) { + console.debug( + `[Vocero MCP WS] function_call_output callId=${JSON.stringify(callId)} ` + + `outputLength=${(output || "").length}` + ); + } + this._send({ + type: "conversation.item.create", + item: { type: "function_call_output", call_id: callId, output }, + }); + } + + /** + * Add an image to the conversation as user content, so the vision-language + * model can see it (used by the camera tool). `dataUrl` is a + * `data:image/jpeg;base64,...` string. + * @param {string} dataUrl + */ + sendUserImage(dataUrl) { + this._send({ + type: "conversation.item.create", + item: { + type: "message", + role: "user", + content: [{ type: "input_image", image_url: dataUrl }], + }, + }); + } + + /** + * Add a user text message to the live conversation. + * @param {string} text + */ + sendUserText(text) { + const content = (text || "").trim(); + if (!content) return; + if (this._responseActive()) { + this._deferredUserTextQueue.push(content); + // #region debug-point B:send-user-text-deferred + reportWsDebug("B", "s2s-ws-client.js:sendUserText", "deferred MCP prompt until active response ends", { + textLength: content.length, + responseActive: true, + openResponses: this._openResponses, + createInFlight: this._createInFlight, + deferredCount: this._deferredUserTextQueue.length, + }); + // #endregion + return; + } + // #region debug-point B:send-user-text + reportWsDebug("B", "s2s-ws-client.js:sendUserText", "queueing MCP prompt into live conversation", { + textLength: content.length, + preview: content.slice(0, 180), + responseActive: this._responseActive(), + openResponses: this._openResponses, + createInFlight: this._createInFlight, + }); + // #endregion + this._send({ + type: "conversation.item.create", + item: { + type: "message", + role: "user", + content: [{ type: "input_text", text: content }], + }, + }); + } + + /** + * Ask the model to generate a response now (after feeding tool results). + * Serialized: if a response is already in flight we queue this request and + * replay it once the active response finishes, so we never trip the + * backend's `conversation_already_has_active_response` guard. + * + * @param {{ image?: string }} [opts] Optional `image` (a data URL) sent as a + * user `input_image` immediately before this response.create — so the frame + * travels with the create (and is deferred together with it if queued), + * rather than being added to the conversation eagerly. Used by the camera + * tool so the model sees the snapshot in the response it's about to speak. + */ + requestResponse(opts = {}) { + if (this._responseActive()) { + // #region debug-point B:response-create-queued + reportWsDebug("B", "s2s-ws-client.js:requestResponse", "queued response.create because slot is busy", { + openResponses: this._openResponses, + createInFlight: this._createInFlight, + pendingCreates: this._createQueue.length + 1, + }); + // #endregion + this._createQueue.push(opts); + if (this._debug) console.debug(`[ws] response.create queued (a response is active); pending=${this._createQueue.length}`); + return; + } + // #region debug-point B:response-create-now + reportWsDebug("B", "s2s-ws-client.js:requestResponse", "sending response.create immediately", { + openResponses: this._openResponses, + createInFlight: this._createInFlight, + pendingCreates: this._createQueue.length, + }); + // #endregion + this._createResponseNow(opts); + } + + /** True while a response occupies the single backend slot. */ + _responseActive() { + return this._openResponses > 0 || this._createInFlight; + } + + /** Send a response.create immediately and arm the in-flight guard. Any image + * on the payload is added as user content right before the create. + * @param {{ image?: string }} [opts] */ + _createResponseNow(opts = {}) { + if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; + if (opts.image) this.sendUserImage(opts.image); + this._createInFlight = true; + if (this._debug) { + console.debug(`[Vocero MCP WS] response.create image=${!!opts.image} openResponses=${this._openResponses}`); + } + this._send({ type: "response.create" }); + } + + /** Replay one queued response.create if the slot is now free. Called on every + * response.done, so queued creates drain one-per-completion. */ + _flushQueuedCreate() { + if (this._createQueue.length > 0 && !this._responseActive()) { + const opts = this._createQueue.shift(); + if (this._debug) console.debug(`[ws] replaying queued response.create; remaining=${this._createQueue.length}`); + this._createResponseNow(opts); + } + } + + /** Flush one deferred user text after the current assistant response ends. */ + _flushDeferredUserText() { + if (this._deferredUserTextQueue.length === 0 || this._responseActive()) return; + const content = this._deferredUserTextQueue.shift(); + if (!content) return; + // #region debug-point B:flush-deferred-user-text + reportWsDebug("B", "s2s-ws-client.js:_flushDeferredUserText", "flushing deferred MCP prompt after response.done", { + textLength: content.length, + remainingDeferred: this._deferredUserTextQueue.length, + }); + // #endregion + this.sendUserText(content); + } + + /** Silence the current or next speculative assistant audio for an MCP fallback turn. */ + suppressAssistantAudio() { + this._suppressNextAssistantResponse = true; + if (this._activeResponseId) this._suppressedResponseId = this._activeResponseId; + this._playbackNode?.port.postMessage({ kind: "clear" }); + this._aiSpeaking = false; + if (this._status === "ai-speaking") this._setStatus("processing"); + // #region debug-point B:suppress-assistant-audio + reportWsDebug("B", "s2s-ws-client.js:suppressAssistantAudio", "suppressing speculative assistant audio for MCP fallback", { + activeResponseId: this._activeResponseId, + suppressedResponseId: this._suppressedResponseId, + suppressNextAssistantResponse: this._suppressNextAssistantResponse, + openResponses: this._openResponses, + createInFlight: this._createInFlight, + }); + // #endregion + } + + /** Interrupt any active/queued backend response immediately (server-side + * cancel + client-side queue flush). Use when an external reasoning + * (OPENAI / NODE_RED) has won the turn and you want to silence the native + * realtime model entirely — not just its audio playback. */ + cancelActiveResponse() { + // 1. Silence audio playback like suppressAssistantAudio does. + this._suppressNextAssistantResponse = true; + if (this._activeResponseId) this._suppressedResponseId = this._activeResponseId; + this._playbackNode?.port.postMessage({ kind: "clear" }); + this._aiSpeaking = false; + if (this._status === "ai-speaking") this._setStatus("processing"); + + // 2. Drop queued user-texts / response.creates injected during the native + // turn — we don't want them to fire after the reasoning answer. + const droppedTexts = this._deferredUserTextQueue.length; + const droppedCreates = this._createQueue.length; + this._deferredUserTextQueue = []; + this._createQueue = []; + + // 3. If the backend has an active response, ask it to truncate/cancel. + // `conversation.item.truncate` with the active response id is the + // standard realtime signal for "stop generating this turn". If the + // backend doesn't accept truncate it will finish but we've already + // silenced playback + dropped queues so no 2nd reply is produced. + if (this._activeResponseId) { + try { + this._send({ + type: "conversation.item.truncate", + item_id: this._activeResponseId, + content_index: 0, + audio_end_ms: 0, + }); + } catch {} + } + + // #region debug-point B:cancel-active-response + reportWsDebug("B", "s2s-ws-client.js:cancelActiveResponse", + "interrupted native realtime turn for external reasoning", + { + activeResponseId: this._activeResponseId, + suppressedResponseId: this._suppressedResponseId, + droppedDeferredTexts: droppedTexts, + droppedPendingCreates: droppedCreates, + }); + // #endregion + } + + /** Append an ASSISTANT message to the live conversation. Used by external + * reasoning (OPENAI / NODE_RED) to insert their computed answer directly + * as an assistant turn — WITHOUT triggering a new native model response. + * This deliberately avoids `sendUserText()` (which would make the backend + * re-answer the "marker text" as a user query, producing duplicate + * replies) and avoids `requestResponse()` (which would ask the backend + * to speak another turn on top). + * @param {string} text + */ + appendAssistantText(text) { + const content = (text || "").trim(); + if (!content) return; + // #region debug-point B:append-assistant-text + reportWsDebug("B", "s2s-ws-client.js:appendAssistantText", + "inserting external reasoning answer as assistant turn", + { + textLength: content.length, + preview: content.slice(0, 180), + responseActive: this._responseActive(), + openResponses: this._openResponses, + createInFlight: this._createInFlight, + }); + // #endregion + this._send({ + type: "conversation.item.create", + item: { + type: "message", + role: "assistant", + content: [{ type: "text", text: content }], + }, + }); + } + + /** @param {boolean} muted */ + setMuted(muted) { + this._muted = muted; + } + + /** + * Update the mic noise gate live (the user moved the Settings cursor). + * @param {NoiseGate} gate + */ + setNoiseGate(gate) { + this._noiseGate = gate; + this._captureNode?.port.postMessage({ kind: "gate", ...gate }); + } + + /** @param {Record} event */ + _send(event) { + if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; + let payloadLength = 0; + let stringifyMs = 0; + let payload = ""; + if (event.type === "conversation.item.create") { + const startedAt = performance.now(); + payload = JSON.stringify(event); + stringifyMs = performance.now() - startedAt; + payloadLength = payload.length; + // #region debug-point A:conversation-item-create + reportWsDebug("A", "s2s-ws-client.js:_send", "sending conversation.item.create payload", { + payloadLength, + stringifyMs: Math.round(stringifyMs), + }); + // #endregion + } + if (this._debug && (event.type === "session.update" || event.type === "conversation.item.create")) { + const preview = (payload || JSON.stringify(event)).slice(0, 400); + console.debug(`[Vocero MCP WS OUT] type=${event.type} payload=${preview}`); + } + this._ws.send(payload || JSON.stringify(event)); + } + + async close() { + // Abort a queue wait in progress: flag it and wake the poll sleep so + // `_pollQueue` throws "aborted" and connect() unwinds cleanly. + this._closed = true; + if (this._queueWake) { + clearTimeout(this._queueTimer); + const wake = this._queueWake; + this._queueWake = null; + wake(); + } + if (this._joinTimer) { + clearTimeout(this._joinTimer); + this._joinTimer = 0; + } + if (this._joinReject) { + const reject = this._joinReject; + this._joinResolve = null; + this._joinReject = null; + reject(_codedError("join aborted", "aborted")); + } + this._visualiser?.stop(); + this._visualiser = null; + try { + if (this._ws && this._ws.readyState <= WebSocket.OPEN) { + this._ws.close(1000, "client closed"); + } + } catch { + // ignored + } + this._ws = null; + + try { + this._captureNode?.port.close?.(); + } catch { + // ignored + } + try { + this._micSrc?.disconnect(); + } catch { + // ignored + } + try { + this._captureNode?.disconnect(); + } catch { + // ignored + } + try { + this._micAnalyser?.disconnect(); + } catch { + // ignored + } + try { + this._outAnalyser?.disconnect(); + } catch { + // ignored + } + try { + this._playbackNode?.disconnect(); + } catch { + // ignored + } + try { + await this._ctx?.close(); + } catch { + // ignored + } + this._ctx = null; + this._captureNode = null; + this._playbackNode = null; + this._micSrc = null; + this._micAnalyser = null; + this._outAnalyser = null; + this._setStatus("closed"); + } +} diff --git a/reasoning.md b/reasoning.md new file mode 100644 index 0000000..e0b8b82 --- /dev/null +++ b/reasoning.md @@ -0,0 +1,608 @@ +# Reasoning & Context Injection — Architettura Completa + +Questo documento descrive **come funziona il "reasoning"** (il layer di intelligenza che decide cosa rispondere) nell'app Vocero/S2S e **in quali punti puoi iniettare contesto** (identità dell'agente, istruzioni, strumenti MCP, profilo chiamante, dati extra, ecc.). + +Esistono **due modalità di reasoning** distinte: + +| Modalità | Variabile env | Chi fa il "pensiero" | Flusso | +|---|---|---|---| +| **PROXY** *(default)* | `S2S_REASONING_TYPE=PROXY` | Il server realtime upstream (`ai-server`, Vocero, o qualunque backend dietro `S2S_PROXY_TARGET`) | Browser → WebSocket `/v1/realtime` → upstream (prompt + tools inviati in `session.update`) | +| **OPENAI** *(reasoning esterno)* | `S2S_REASONING_TYPE=OPENAI` | Una chiamata HTTP separata a OpenAI Chat Completions (es. `gpt-4.1-mini`) | Browser → `POST /api/reasoning` → OpenAI → risposta reiniettata nel realtime come messaggio utente forzato | + +In **entrambe** le modalità le informazioni di contesto vengono assemblate lato client in un'unica stringa "istruzioni" (`effectiveReasoningInstructions`) e passate poi in modo diverso. + +--- + +## 1. Panoramica del flusso completo (uno sguardo dall'alto) + +``` + ┌─────────────────────────────────────────────────────────────────────────┐ + │ /home/duxiter/s2s/.env │ + │ S2S_PRIMARY_PROMPT S2S_AGENT_PROFILE S2S_REASONING_TYPE │ + │ S2S_TOOL_EXECUTION_MCP_URL S2S_USER_PHONE_AUTH_URL │ + │ OPENAI_API_KEY / BASE_URL / REASONING_MODEL / ORG / PROJECT │ + │ S2S_PROXY_TARGET S2S_SEARCH_MCP etc. │ + └───────────────────────────┬─────────────────────────────────────────────┘ + │ caricato da start_ui.sh + ▼ + ┌─────────────────────────────────────────────────────────────────────────┐ + │ demo/server.py (backend Python FastAPI) │ + │ │ + │ fetchConfig() GET /api/config ←─────── GET /api/user/phone │ + │ │ │ (se URL param phone) │ + │ ├─ primaryPrompt (priorità 1: Aquachile /agents/profile │ + │ │ priorità 2: S2S_PRIMARY_PROMPT env) │ + │ ├─ agentProfile (URL di dove abbiamo preso il prompt) │ + │ ├─ agentTools[] (catalogo MCP Aquachile, enabled=True) │ + │ ├─ reasoningType = "PROXY" oppure "OPENAI" │ + │ └─ searchMcp, s2sUrl, lb, auth, search, ecc. │ + │ │ + │ POST /api/reasoning (solo se reasoningType=OPENAI) │ + │ body → httpx POST {OPENAI_BASE_URL}/chat/completions │ + │ │ + │ POST /api/mcp/tool │ + │ body → httpx POST {S2S_TOOL_EXECUTION_MCP_URL} + chiavi API │ + │ │ + │ WS /v1/realtime (proxy a S2S_PROXY_TARGET) │ + │ client ↔ proxy ↔ upstream: logga [INPUT UTENTE]/[OUTPUT AGENTE] │ + └───────────────────────────┬─────────────────────────────────────────────┘ + │ fetch su caricamento pagina + ▼ + ┌─────────────────────────────────────────────────────────────────────────┐ + │ demo/main.js (frontend browser) │ + │ │ + │ pinnedPrompt = primaryPrompt (da /api/config) — read-only se valoriz.│ + │ agentToolDefs[] = agentTools (da /api/config) │ + │ reasoningType = "PROXY" | "OPENAI" │ + │ voceroUserPhoneNumber = new URL(location).searchParams.phoneNumber │ + │ │ │ + │ └─► async fetchVoceroUserProfile() │ + │ GET api/user/phone?phoneNumber=… → voceroUserProfile{} │ + │ │ + │ Quando l'utente clicca l'orb → doStart(): │ + │ startInstructions = effectiveInstructions() │ + │ startTools = activeToolDefs() │ + │ │ │ + │ └─► new S2sWsRealtimeClient({instructions, tools, …}) │ + │ │ │ + │ ├─► session.update al realtime upstream │ + │ │ (se reasoningType=PROXY → qui pensa) │ + │ │ │ + │ └─► event "transcript" (user, partial=false) │ + │ │ │ + │ └─► maybeRunOpenAiReasoning() │ + │ (solo reasoningType=OPENAI) │ + │ → POST /api/reasoning → answer │ + │ → sendUserText(renderPrompt) │ + │ → requestResponse() │ + └─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Assemblaggio del prompt: `effectiveReasoningInstructions()` + +La funzione **centrale** che costruisce il blocco di testo che verrà dato al modello è in [main.js:516-533](file:///home/duxiter/s2s/demo/main.js#L516-L533): + +```js +function effectiveReasoningInstructions() { + const base = pinnedPrompt || settings.instructions; + let instructions = base; + if (activeToolDefs().length) instructions += TOOL_USE_HINT; + if (searchMcpEnabled && agentToolDefs.length) { + instructions += MCP_SEARCH_HINT; + instructions += ` Available MCP tools: ${buildMcpToolCatalogSummary(agentToolDefs)}`; + } + const suggested = topSuggestedAgentTools(3); + if (searchMcpEnabled && suggested.length) { + instructions += ` Current user request: "${lastUserQuery}". Client-side ranking hint only: …`; + } + const userContext = voceroUserContextText(); + if (userContext) instructions += userContext; + return instructions; +} +``` + +Ordine di concatenazione (dalla priorità più alta alla più bassa): + +| # | Fonte | Dove viene da | Note | +|---|---|---|---| +| 1 | **`pinnedPrompt`** | `S2S_AGENT_PROFILE` (`instructions` campo Aquachile) → fallback `S2S_PRIMARY_PROMPT` env → fallback `settings.instructions` (localStorage) → fallback `DEFAULT_INSTRUCTIONS` | **Identità e personalità principale dell'agente**. Quando `pinnedPrompt` è presente, il campo `instructions` di Settings è disabilitato in UI (read-only). | +| 2 | **`TOOL_USE_HINT`** | Costante in linea [main.js:32-36](file:///home/duxiter/s2s/demo/main.js#L32-L36) | Aggiunto se c'è **almeno 1 tool attivo**: "*When the user's request calls for one of your tools, do not describe your capabilities or say you can do it and wait for another turn. Instead, say a brief acknowledgement… and call the tool right away in the same response.*" | +| 3 | **`MCP_SEARCH_HINT`** + catalogo completo tools | Condizionato a `S2S_SEARCH_MCP=true` in env | Istruisce il modello a leggere l'intero catalogo MCP e scegliere lo strumento migliore. Poi **appende come testo TUTTI i tool MCP** (`buildMcpToolCatalogSummary`) con nome, descrizione e parametri obbligatori. | +| 4 | **Suggerimento tool client-side (top 3)** | `topSuggestedAgentTools(3)` basato su `lastUserQuery` | Messaggio "Client-side ranking hint only: 1. get_day_report: … 2. … 3. …". Usato **solo come hint secondario**, il modello deve sempre controllare il catalogo completo prima. | +| 5 | **`voceroUserContextText()`** | Profile restituito da `/api/user/phone?phoneNumber=…` | Stringa: `" Caller profile resolved from the page phoneNumber: {JSON profilo}. Use this as trusted caller context when relevant."` (troncato a 1600 char) | + +Poi la funzione **`effectiveInstructions()`** ([main.js:537-539](file:///home/duxiter/s2s/demo/main.js#L537-L539)) prende il risultato e: +- Se `reasoningType=OPENAI` → **appende `OPENAI_RENDER_HINT`**: istruisce il realtime a **rispondere solo ai messaggi che iniziano con il marker `[VOCERO_RENDER_REPLY]`**, in modalità "leggi e basta" (niente tool, niente ragionamento). Questo è il segreto che permette di scrivere la frase pensata da OpenAI e farla ripetere fedelmente in TTS. +- Se `reasoningType=PROXY` → ritorna le istruzioni tal quali. + +### Quando viene ricalcolato e reinviato al modello + +- **Alla connessione** in `doStart()`: `startInstructions = effectiveInstructions()` → `new S2sWsRealtimeClient({instructions: startInstructions, …})`. +- **Durante la chiamata**, in risposta a: + - ogni **trascrizione utente** parziale o finale (linee [1122-1128](file:///home/duxiter/s2s/demo/main.js#L1122-L1128)): `pushLiveToolPolicy()` + `pushLiveInstructions(effectiveInstructions())` → `client.updateSession({ instructions })` + - quando arriva il **profilo chiamante da phone auth** ([2388-2390](file:///home/duxiter/s2s/demo/main.js#L2388-L2390)): stesso `pushLiveInstructions` + - quando l'utente **modifica le impostazioni** nel form e salva. + +--- + +## 3. I due motori di reasoning, nel dettaglio + +### 3.1 Modalità **PROXY** (default — consigliata con Aquachile) + +``` +S2S_REASONING_TYPE=PROXY (o qualsiasi valore diverso da "OPENAI") +S2S_PROXY_TARGET=ws://ai-server:12345/v1/realtime # o qualunque endpoint WS realtime +``` + +In questa modalità, **l'upstream realtime è l'unico a "pensare"**. Tutta l'intelligenza risiede nel backend puntato da `S2S_PROXY_TARGET` (es. Vocero, un wrapper realtime custom, un server che parla OpenAI Realtime API). + +Flusso **testuale** di quello che succede a ogni avvio: + +1. `doStart()` calcola `startInstructions = effectiveInstructions()` (tutto il blocco §2). +2. Costruisce `startTools = activeToolDefs()` = tool browser (web_search, camera_snapshot) **+** `agentToolDefs` (catalogo MCP Aquachile). +3. Passa `instructions` e `tools` al costruttore di `S2sWsRealtimeClient`. +4. Quando il WS si connette e arriva `session.created`, il client manda `session.update` con: + ```json + { + "type": "realtime", + "instructions": "", + "tools": [ { "type":"function", "name":"…", "description":"…", "parameters":{…} }, … ], + "tool_choice": "auto", + "audio": { "output": { "voice": "Aiden" } } + } + ``` +5. **TUTTO IL RESTO è SULL'UPSTREAM**: L'upstream decide quando fare tool call, quando parlare, quando ascoltare. Il browser è solo uno "schermo+microfono+telecomando". +6. Quando l'upstream fa `conversation.item.input_audio_transcription.completed` il log server stampa `[INPUT UTENTE] …`. +7. Quando l'upstream fa `response.*_transcript.done` / `response.content_part.done` ecc. il log server stampa `[OUTPUT AGENTE] …`. + +**Vantaggi:** +- Un solo round trip per tutto (latenza più bassa per voce→voce). +- L'upstream può usare qualsiasi modello/rete interna (non per forza OpenAI). +- Le MCP tool call sono orchestrate dallo stesso server che fa STT/TTS → meno desincronizzazioni. + +**Svantaggi:** +- Tutto il "pensiero" è black-box sull'upstream; non puoi vedere i token del thinking né usare un modello diverso senza cambiare upstream. + +--- + +### 3.2 Modalità **OPENAI** (reasoning HTTP separato) + +``` +S2S_REASONING_TYPE=OPENAI +OPENAI_API_KEY=sk-… # obbligatorio +OPENAI_BASE_URL=https://api.openai.com/v1 # default +OPENAI_REASONING_MODEL=gpt-4.1-mini # default +OPENAI_ORGANIZATION=… # opzionale +OPENAI_PROJECT=… # opzionale +``` + +In questa modalità il **ragionamento vero e proprio** non viene fatto dall'upstream realtime. Invece: + +1. L'upstream fa solo **STT** (converte voce in testo) e **TTS** (converte testo in voce). Agisce come un "dumb" audio I/O. +2. **Non appena arriva una trascrizione utente COMPLETA** (evento `transcript`, `role=="user"`, `partial==false`), `maybeRunOpenAiReasoning(transcript, itemId)` viene triggerato. +3. Questo chiama `fetchOpenAiReasoningAnswer(transcript)` ([main.js:563-586](file:///home/duxiter/s2s/demo/main.js#L563-L586)) che fa: + ``` + POST /api/reasoning + body = { + transcript: "quanto è il report giornaliero?", + instructions: effectiveReasoningInstructions(), // stesse di PROXY + language: "es-CL" o vuoto, + user_context: voceroUserContextText(), // duplicato per sicurezza + } + ``` +4. Il server (endpoint in [server.py:635-710](file:///home/duxiter/s2s/demo/server.py#L635-L710)) a sua volta fa: + ``` + POST {OPENAI_BASE_URL}/chat/completions + body = { + model: OPENAI_REASONING_MODEL, + messages: [ + { role:"system", + content: "{instructions}\n\n{user_context}\nReply only with final answer. Language=…" }, + { role:"user", content: transcript } + ] + } + ``` +5. Riceve `answer` (stringa semplice, pensata per essere "parlata"), logga `[Vocero REASONING] transcript=… answer=…` e la restituisce al client. +6. Il client costruisce `renderPrompt = "[VOCERO_RENDER_REPLY]\n" + answer` e lo **manda come se fosse un testo scritto dall'utente** al realtime: + ``` + client.sendUserText(renderPrompt); + client.requestResponse(); + ``` +7. L'upstream realtime, a causa di `OPENAI_RENDER_HINT` nel prompt, **vede il marker `[VOCERO_RENDER_REPLY]`** e quindi: + - NON usa tool, NON fa ragionamento proprio, NON modifica la frase. + - Si limita a leggere la parte dopo il marker in TTS. +8. Risultato: l'utente sente "la voce dell'agente" (TTS dell'upstream) ma il testo **è stato scritto da OpenAI** in `/chat/completions`. + +Log server corrispondenti: +``` +[EXT][OPENAI] reasoning POST https://api.openai.com/v1/chat/completions model=gpt-4.1-mini systemLen=… transcriptLen=… +[EXT][OPENAI] reasoning RESP status=200 len=… +[EXT][OPENAI] reasoning USAGE prompt=1234 completion=89 total=1323 +INFO [s2s] [Vocero REASONING] transcript=… answer=… +``` + +**Vantaggi:** +- Scegli tu **qualsiasi modello compatibile Chat Completions** (anche Azure, Together, self-hosted se l'endpoint parla lo stesso protocollo). +- Hai **usage e log del reasoning separati** da quelli dell'audio. +- Puoi aggiungere facilmente post-processing, cache o fallback. + +**Svantaggi:** +- Due round trip invece di uno → un po' più di latenza totale (~200-800 ms). +- Più costoso in token: `instructions` + contesto utente vengono mandati **due volte** (una a OpenAI, una al realtime). +- Le tool call che il modello OpenAI vorrebbe fare non possono essere usate direttamente — in OPENAI mode `chat/completions` NON ha accesso a `tools` (al momento l'endpoint `POST /api/reasoning` non inoltra il catalogo MCP a OpenAI). **Quindi in OPENAI mode, le MCP Aquachile vengono ancora passate al realtime (che in questa modalità non "pensa" ma solo reinderizza il testo), quindi non sono usabili.** + +> ⚠️ Importante: se usi Aquachile con il suo ecosistema MCP, **la modalità PROXY è quella corretta da usare**. OPENAI mode è pensata per scenari "solo testo + voce" senza tool complessi. + +--- + +## 4. TUTTI i modi per iniettare contesto all'agente, dal più semplice al più avanzato + +I punti di injection sono ordinati per **priorità di applicazione** (chi "vince" in caso di conflitto) e per **facilità di modifica**. + +--- + +### 🅿️ Punto 1 — `.env` (variabili di ambiente, configurazione statica) + +File: [.env](file:///home/duxiter/s2s/.env) +Caricato in memoria da [start_ui.sh](file:///home/duxiter/s2s/start_ui.sh#L8-L12) (`source "$ROOT_DIR/.env"`) prima di avviare uvicorn. + +| Variabile | Tipo | Cosa fa all'agente | +|---|---|---| +| **`S2S_PRIMARY_PROMPT`** | testo lungo | **Prompt backup quando Aquachile è irraggiungibile o `S2S_AGENT_PROFILE` non è impostato.** Sarà il valore di `primaryPrompt` in `/api/config` se `_extract_agent_prompt()` torna stringa vuota. Supporta `\n` → convertiti in newline. | +| **`S2S_AGENT_PROFILE`** | URL | Endpoint `GET` (es. Aquachile: `https://aquachile.azurianlab.com/api/v1/agents/profile`). Deve ritornare JSON `{ agent: { instructions: "...", tools: [ {name, description, parameters, enabled}, ... ] } }`. **Ha priorità su `S2S_PRIMARY_PROMPT`** — vedi `primary_prompt = agent_prompt or S2S_PRIMARY_PROMPT` in [server.py:517](file:///home/duxiter/s2s/demo/server.py#L517). | +| **`S2S_TOOL_EXECUTION_MCP_URL`** | URL | Endpoint `POST` per **eseguire** le tool call MCP (stesso backend Aquachile). Riceve `{server, name, arguments}` e viene chiamata ogni volta che il modello invoca uno strumento. | +| `S2S_TOOL_EXECUTION_MCP_API_KEY` / `S2S_TOOL_EXECUTION_MCP_API_SECRET` | stringhe segrete | Intestazioni `X-API-KEY` e `X-API-SECRET` aggiunte a ogni chiamata MCP. | +| **`S2S_USER_PHONE_AUTH_URL`** | URL | Endpoint `GET` (con `?phoneNumber=…`) che restituisce il profilo del chiamante. Se l'URL della UI contiene `?phoneNumber=+56…`, questo endpoint viene chiamato e il JSON risultante viene appeso al prompt di sistema (vedi Punto 4). | +| **`S2S_SEARCH_MCP=true\|false`** | bool | Quando `true`, appende `MCP_SEARCH_HINT` + il catalogo COMPLETO dei tool MCP dentro le `instructions`. Fondamentale se vuoi che il modello sappia che esistono 17 strumenti e possa scegliere tra quelli. | +| **`S2S_REASONING_TYPE`** | `PROXY` \| `OPENAI` | Motore di reasoning (§3). Normalizzato con `_normalize_reasoning_type()` (case-insensitive). | +| **`OPENAI_API_KEY` / `BASE_URL` / `REASONING_MODEL`** | stringhe | Solo per mode OPENAI. `BASE_URL` serve anche per compatibilità Azure/Together (basta che parli /chat/completions). | +| **`S2S_PROXY_TARGET`** | URL WS | Upstream realtime che fa STT/TTS + (in mode PROXY) il reasoning vero e proprio. Di solito `ws://ai-server:12345/v1/realtime`. | +| `S2S_PRELOAD_DATA_ENDPOINT`, `SERPER_API_KEY`, `LOAD_BALANCER_URL`, `SPEECH_TO_SPEECH_URL` | varie | Non iniettano testo nel prompt direttamente, ma abilitano altre funzionalità (PowerBI preload, web search Serper, queue & limiter, direct URL fissa). | + +--- + +### 🅿️ Punto 2 — **Aquachile `/api/v1/agents/profile`** (contesto Aziendale + Tools) + +URL impostato da `S2S_AGENT_PROFILE`. Fetchato lato server in [_agent_profile_data()](file:///home/duxiter/s2s/demo/server.py#L357-L397) con: +- cache 60 secondi globale (`_agent_profile_cached`), lock asincrona +- timeout 10 s +- chiamata GET senza header speciali (aggiungibili se serve a mano nel codice) + +**Formato JSON atteso** (si adatta in modo tollerante): + +```jsonc +{ + "agent": { + // ↓ usato come prompt principale (priority #1) + "instructions": "Eres el asistente de voz oficial de Aquachile. Te llamas Aquachile Assistant…", + // ↓ fallback se instructions non esiste + "description": "…", + "tools": [ + { + "name": "get_day_report", + "description": "Obtiene el reporte de producción diario de una planta…", + "parameters": { + "type": "object", + "properties": { + "center": {"type": "string", "description": "Nombre de la planta"} + }, + "required": [] + }, + "enabled": true // ↓ se missing o true → incluso; se false → ignorato + }, + // … altri 16 strumenti + ] + } +} +``` + +Parser corrispondente: +- [_extract_agent_prompt()](file:///home/duxiter/s2s/demo/server.py#L405-L424) — cerca in ordine: `agent.instructions` → `agent.description` → `instructions` radice → `description` radice. +- [_extract_agent_tools()](file:///home/duxiter/s2s/demo/server.py#L427-L437) — cerca `agent.tools[]`, filtra quelli con `enabled` != `false`. + +Tutto il resto del payload viene ignorato. Se vuoi estrarre altri campi (es. `agent.name`, `agent.greeting`, `agent.language`) puoi aggiungerne l'estensione in `_extract_agent_prompt` per concatenarli, oppure creare un nuovo campo nel dizionario di ritorno e poi usarlo in `/api/config`. + +**Quando modificare questo endpoint piuttosto che altre parti:** +- Quando il contenuto cambia spesso (ogni giorno: nuovi report, nuove tipologie di domanda). +- Quando vuoi che **tutte le installazioni** dell'UI aggiornino all'istante senza rebuild/restart del server. +- Quando vuoi rilasciare un **nuovo tool MCP**: basta aggiungerlo all'array `tools[]` e comparirà ovunque entro 60 s (prossima invalidazione della cache). + +--- + +### 🅿️ Punto 3 — **URL parameters** della pagina (contesto per-chiamata) + +Analizzati all'avvio di `main.js` ([riga 410](file:///home/duxiter/s2s/demo/main.js#L410)): +```js +const pageParams = new URLSearchParams(window.location.search); +const voceroUserPhoneNumber = (pageParams.get("phoneNumber") || "").trim(); +``` + +Attualmente è gestito un solo param: + +| Parametro URL | Tipo | Effetto | +|---|---|---| +| `?phoneNumber=+56912345678` | stringa E.164 | Triggera `fetchVoceroUserProfile()` → `GET /api/user/phone?phoneNumber=…` → endpoint Aquachile `S2S_USER_PHONE_AUTH_URL` → profilo JSON appenduto al prompt (vedi Punto 4). | + +È molto semplice estendere la lista: aggiungi una riga tipo: +```js +const plantId = (pageParams.get("plant") || "").trim(); +``` +e poi inietta il valore in modo statico (in `voceroUserContextText()` o in un pezzo di testo a parte in `effectiveReasoningInstructions`) o fai una fetch al tuo endpoint. + +--- + +### 🅿️ Punto 4 — **Profilo chiamante `/api/user/phone`** (contesto per-caller) + +End-to-end: + +``` +URL: https:///?phoneNumber=+56912345678 + │ + ▼ fetchVoceroUserProfile() [main.js:2370-2398] +GET api/user/phone?phoneNumber=%2B56912345678 + │ + ▼ user_by_phone() [server.py:539-573] +GET {S2S_USER_PHONE_AUTH_URL}?phoneNumber=%2B56912345678 + │ + ▼ +Risposta Aquachile (es.) +{ + "id": 987, + "name": "Don Manuel", + "role": "Jefe de Planta", + "center": "Puerto Montt", + "email": "manuel@aquachile.cl", + "authorizations": ["report:daily", "report:weekly"] +} + │ + ▼ { "result": } al browser + │ + ▼ voceroUserProfile = ; voceroUserProfileLoaded = true + │ + ▼ voceroUserContextText() [main.js:454-458] +" Caller profile resolved from the page phoneNumber: {name:\"Don Manuel\",…}. Use this as trusted caller context when relevant." + │ + └─► concatenato ALLA FINE di effectiveReasoningInstructions +``` + +Una volta che il profilo è arrivato, viene chiamato `pushLiveInstructions(effectiveInstructions())` → l'istruzione sul realtime viene **aggiornata in volo** anche se la conversazione è già iniziata. Quindi anche se il profilo impiega 2 secondi ad arrivare, le battute successive alla ricezione beneficeranno del contesto. + +**Quando usarlo:** passare dati per-caller (ruolo, permessi, centro, nome, lingua preferita, reporting line, storico chiamate recenti ecc.). Tieni conto che il JSON viene serializzato in chiaro nel prompt (max 1600 caratteri), quindi non passare PII sensibili in chiaro. + +--- + +### 🅿️ Punto 5 — **Catalogo MCP Aquachile** (contesto come "abilità" dell'agente) + +Fonte: `agent.tools[]` dal profilo Aquachile (Punto 2), poi in `agentToolDefs` lato client. + +Entrano nel prompt in due modi: + +1. **Come `tools` (dichiarazione formale OpenAI Realtime function-call)** : `S2sWsRealtimeClient({tools: activeToolDefs()})` → arrivano all'upstream come `session.tools[]` con schema JSON Schema. Questo permette all'upstream di **decidere di chiamarli** tramite `response.create_response + function_call_arguments.done`. +2. **Come testo nel prompt vero e proprio** : `Available MCP tools: get_day_report - Obtiene el reporte… Required parameters: []; get_metrics_summary - …. ` (solo se `S2S_SEARCH_MCP=true`). + +Quando poi il modello effettivamente **chiama** un tool MCP, il client: +- emette l'evento `toolcall` +- il server chiama `POST /api/mcp/tool` ([server.py](file:///home/duxiter/s2s/demo/server.py#L576…)): + ``` + POST {S2S_TOOL_EXECUTION_MCP_URL} + headers: X-API-KEY, X-API-SECRET, Content-Type: application/json + body: {"server": "mcp", "name": "get_day_report", "arguments": "{\"center\":\"Puerto Montt\"}"} + ``` +- Il risultato torna al client e viene rinviato all'upstream come `conversation.item` di tipo `function_call_output`. Il modello legge l'output e prosegue il turn. + +--- + +### 🅿️ Punto 6 — **Settings utente (localStorage)** (solo se prompt non pinned) + +Se `pinnedPrompt` è stringa vuota (né da Aquachile, né da env), allora in `effectiveReasoningInstructions()` la base è `settings.instructions` che viene da: + +```js +// main.js loadSettings: riga 201 +instructions: localStorage.getItem("s2s.ws.instructions") || DEFAULT_INSTRUCTIONS +``` + +Nel form Settings (pannellino in alto a destra) l'utente può editare il testo "System instructions" e salvarlo → va in localStorage. + +⚠️ **Attenzione**: se è presente un prompt "pinned" (da Aquachile o env), il campo nel Settings diventa **read-only** (non è modificabile). Puoi sempre verificare lo stato aprendo il pannello. + +--- + +### 🅿️ Punto 7 — **Aggiungere contesto custom via codice (patch diretta)** + +Se vuoi iniettare dati specifici (es. l'ID di una campagna, il turno di lavoro corrente, la stazione meteorologica più vicina, ecc.) senza creare un nuovo endpoint esterno, hai **3 punti comodi** dove patchare con poche righe: + +#### 7a. Aggiungere un pezzo di testo statico alle `instructions` + +Modifica direttamente `effectiveReasoningInstructions()` in [main.js:516-533](file:///home/duxiter/s2s/demo/main.js#L516-L533), aggiungendo una sezione. Esempio: + +```js +function effectiveReasoningInstructions() { + const base = pinnedPrompt || settings.instructions; + let instructions = base; + if (activeToolDefs().length) instructions += TOOL_USE_HINT; + if (searchMcpEnabled && agentToolDefs.length) { … } + const suggested = topSuggestedAgentTools(3); + if (searchMcpEnabled && suggested.length) { … } + const userContext = voceroUserContextText(); + if (userContext) instructions += userContext; + // ↓ aggiungi contesto custom QUI + instructions += `\n\nTurno actual: ${TURNO_CORRIENTE}. Campaña: ${CAMPAIGN_ID}. Planta: ${PLANTA_ID}.`; + return instructions; +} +``` + +Funziona in **entrambe** le modalità (PROXY e OPENAI). + +#### 7b. Inviare un messaggio pre-filla a inizio conversazione + +Dopo che il client è connesso e pronto (evento `ready` o `status=="connected"`), chiama: +```js +client.sendUserText( + "Contexto adicional de esta llamada (no responder a esto directamente): " + + "El usuario está llamando desde la planta Puerto Montt. Su último reporte " + + "fue el día 2026-08-17 y mostró un 95% de cumplimiento. El turno actual " + + "es el turno noche. Usa esta información cuando respondas." +); +// (opzionale) poi client.requestResponse() se vuoi che l'agente saluti subito +``` +Il messaggio entra nella history del modello (coda `conversation.items[]`) ed è visibile per il resto della conversazione. + +#### 7c. Patchare `/api/config` lato server per ritornare un campo custom e usarlo dal client + +Aggiungi in [server.py ~riga 518-531](file:///home/duxiter/s2s/demo/server.py#L518-L531): +```python +out = { + ..., + "myCustomContext": {"turno": current_shift(), "planta": os.environ.get("PLANTA_ID", "")}, +} +``` +Poi lato client in `fetchConfig()` (circa riga 2408): +```js +myCustomContext = json.myCustomContext || null; +``` +e usa il valore come in 7a. Utile quando il contesto lo conosci solo lato server (es. viene da DB aziendale o da variabili env del deploy). + +#### 7d. Aggiungere un parametro HTTP `user_context` custom alla chiamata `/api/reasoning` (solo OPENAI mode) + +L'endpoint già accetta il campo `user_context` nel body della request — è già popolato da `voceroUserContextText()`, ma puoi aggiungere ulteriore testo concatenandolo nel client o nel server. + +Oppure estendi `ReasoningRequest` in [server.py:346-350](file:///home/duxiter/s2s/demo/server.py#L346-L350) con un nuovo campo BaseModel (es. `turno: str = ""`, `planta: str = ""`) e poi usalo in `system_parts.append(...)` dentro `def reasoning()`. + +--- + +## 5. Verifiche e Debug — come controllare "cosa vede veramente l'agente" + +### 5a. Log del server (cosa arriva all'esterno) + +Su `docker logs -f s2s_ui` (o sull'output di `start_ui.sh`) puoi monitorare TUTTE le chiamate esterne. Filtri utili: + +```bash +# Solo contesto caricato all'avvio (profilo Aquachile) +grep "\[EXT\]\[AQUACHILE\]" server.log + +# Solo ragionamento OpenAI (se mode OPENAI) +grep "\[EXT\]\[OPENAI\]" server.log + +# Dialogo parlato (TESTO della chat) +grep -E "INPUT UTENTE|OUTPUT AGENTE" server.log + +# Tutte le chiamate MCP +grep "\[EXT\]\[AQUACHILE\].*mcp.*tools/call" server.log + +# /api/config (quello che arriva al browser come initial setup) +grep "/api/config returned" server.log + +# Autenticazione chiamante +grep "Vocero USER AUTH" server.log +``` + +### 5b. Debug lato client (cosa viene assemblato) + +Apri la console del browser (F12) e, dopo che la pagina ha caricato e ti sei connesso, puoi eseguire: + +```js +// Controlla il tipo di reasoning attivo +> reasoningType +"PROXY" // oppure "OPENAI" + +// Stringa effettiva di instruzioni (copiala/incolala per ispezione manuale) +> copy(effectiveInstructions()) + +// Testo che è stato passato all'ultimo session.update +> liveInstructionsSnapshot.length +32456 + +// Elenco dei tool attivi che l'agente "vede" +> activeToolDefs().map(t => t.name) +(19) ['web_search', 'camera_snapshot', 'get_day_report', 'get_weekly_report', 'get_monthly_report', 'get_report_detail', … ] + +// Profilo chiamante caricato +> voceroUserProfileLoaded, voceroUserProfile +true, { id: 987, name: "Don Manuel", … } + +// Top 3 suggeriti per l'ultima domanda +> topSuggestedAgentTools(3).map(x => `${x.tool.name}:${x.score}`) +["get_day_report:38", "get_metrics_summary:22", "get_report_detail:15"] +``` + +### 5c. Cosa viene mandato a OpenAI in modalità OPENAI + +Basta guardare il log `[EXT][OPENAI] reasoning POST … systemLen=… transcriptLen=…` e saprai già il numero di token di sistema. Se vuoi vedere l'esatto system message, puoi aggiungere temporaneamente: +```python +# dentro def reasoning() dopo la costruzione di body +logger.debug("Reasoning system message PREVIEW:\n%s", + body["messages"][0]["content"][:3000]) +``` + +--- + +## 6. Priorità effettiva di tutte le sorgenti (chi vince se c'è conflitto) + +Tabella riassuntiva che risponde a: *"Se due punti dicono cose diverse, quale vince?"* + +| Cosa | Vincitore (priorità max → min) | +|---|---| +| **Identità / sistema prompt** | 1. `agent.instructions` da Aquachile `S2S_AGENT_PROFILE` → 2. `S2S_PRIMARY_PROMPT` env → 3. `settings.instructions` (localStorage utente) → 4. `DEFAULT_INSTRUCTIONS` hardcoded | +| **Catalogo MCP** | Aquachile `agent.tools[]` (unico fornitore). Gli unici tool aggiuntivi sono `web_search` e `camera_snapshot` (browser-native). | +| **Hint su come usare i tool** | Sempre `TOOL_USE_HINT` fisso se `tools>0`; se `searchMcpEnabled` si aggiunge `MCP_SEARCH_HINT` + catalogo testuale completo. | +| **Hint tool ranking (top 3)** | Calcolato lato client per ogni battuta; prompt dice "secondary signal, full catalog wins" | +| **Profilo chiamante** | `S2S_USER_PHONE_AUTH_URL` + URL param `phoneNumber` (nessun altro percorso attuale). | +| **Modalità di reasoning** | 1. `S2S_REASONING_TYPE` env normalizzato (`OPENAI` o default `PROXY`) | +| **URL endpoint upstream** | 1. `SPEECH_TO_SPEECH_URL` pinned direct → 2. `LOAD_BALANCER_URL` LB + queue → 3. URL inserito manualmente in Settings (solo allowDirect) | + +--- + +## 7. Troubleshooting: "L'agente non usa il contesto che mi aspetto" + +Checklist da fare in ordine: + +1. **`/api/config` cosa restituisce?** + ``` + curl -s http://localhost:8000/api/config | python -m json.tool + ``` + Verifica: `primaryPrompt` è valorizzato e lungo? `agentTools` quanti elementi ha? `reasoningType` è quello che ti aspetti? + +2. **Aquachile profile risponde?** Guarda `[EXT][AQUACHILE] agent_profile` nel log. Status 200? Cache HIT o MISS? `PARSED promptLen=… toolCount=…` ha i numeri attesi? + +3. **Il browser ha `phoneNumber` nella URL?** Apri DevTools → Network e cerca `user/phone`. C'è una richiesta? Torna 200? Che JSON ritorna? Il `result` viene inserito in `voceroUserProfile` (check console). + +4. **Hai cambiato qualcosa nel prompt in Aquachile ma non l'aggiornamento non si vede?** Il profilo ha cache 60 secondi. Aspetta o riavvia il server (la cache è in memoria). + +5. **In modalità PROXY i tool MCP non vengono chiamati:** + - Verifica che il **tool catalogo testuale** sia nel prompt (controlla `copy(effectiveInstructions())` e cerca la stringa `Available MCP tools`). + - Verifica che `S2S_SEARCH_MCP=true`. + - Guarda nella console del browser `[Vocero MCP MATCH]` e `[Vocero MCP RANK]` — se nessuno score è sopra la soglia (default 20), l'auto-invocazione lato client non scatta; il modello potrebbe comunque chiamarlo via function-call standard. + +6. **In modalità OPENAI non sente le MCP:** Corretto — attualmente `/api/reasoning` NON manda tools a OpenAI. Vedi §3.2. Se vuoi chiamare tool in OPENAI mode devi spezzare la catena: OpenAI deve dire quale tool chiamare, tu lo chiami via `/api/mcp/tool`, e poi reinietti il risultato in un nuovo reasoning turn. O, più semplicemente, usa la modalità PROXY quando servono i tool Aquachile. + +7. **Output lungo o ripetitivo:** Inserisci in coda al prompt un blocco tipo: `Limita la risposta a 2 frasi e parla in modo naturale. Non ripetere la domanda dell'utente.` — funziona in entrambe le modalità. + +--- + +## 8. Riferimenti rapidi (file e linee) + +| Concetto | File e righe | +|---|---| +| `.env` caricato | [start_ui.sh](file:///home/duxiter/s2s/start_ui.sh#L8-L12) | +| Variabili env parsate lato server | [server.py](file:///home/duxiter/s2s/demo/server.py#L71-L120) | +| `ReasoningRequest` body schema | [server.py:346-350](file:///home/duxiter/s2s/demo/server.py#L346-L350) | +| Fetch profilo Aquachile + cache 60 s | [_agent_profile_data](file:///home/duxiter/s2s/demo/server.py#L357-L397) | +| Parse istruzioni + tools dal JSON Aquachile | [_extract_agent_prompt](file:///home/duxiter/s2s/demo/server.py#L405-L424), [_extract_agent_tools](file:///home/duxiter/s2s/demo/server.py#L427-L437) | +| `/api/config` (bootstrap verso client) | [server.py:509-536](file:///home/duxiter/s2s/demo/server.py#L509-L536) | +| `/api/user/phone` (profilo chiamante) | [server.py:539-573](file:///home/duxiter/s2s/demo/server.py#L539-L573) | +| Esecuzione tool MCP (inoltro ad Aquachile) | [server.py /api/mcp/tool](file:///home/duxiter/s2s/demo/server.py#L576-L633) | +| `/api/reasoning` (OpenAI chat completions proxy) | [server.py:635-710](file:///home/duxiter/s2s/demo/server.py#L635-L710) | +| Proxy WS realtime, INPUT/OUTPUT text log | [_client_to_upstream](file:///home/duxiter/s2s/demo/server.py#L1112-L1171), [_upstream_to_client](file:///home/duxiter/s2s/demo/server.py#L1174-L1255) | +| `fetchConfig()` lato client | [main.js:2401-2439](file:///home/duxiter/s2s/demo/main.js#L2401-L2439) | +| `fetchVoceroUserProfile()` phone auth | [main.js:2370-2398](file:///home/duxiter/s2s/demo/main.js#L2370-L2398) | +| `effectiveReasoningInstructions()` assemblaggio prompt | [main.js:516-533](file:///home/duxiter/s2s/demo/main.js#L516-L533) | +| `effectiveInstructions()` (prompt per realtime, + OPENAI marker hint) | [main.js:537-539](file:///home/duxiter/s2s/demo/main.js#L537-L539) | +| Tool hint + MCP search hint costanti | [main.js:32-42](file:///home/duxiter/s2s/demo/main.js#L32-L42) | +| `voceroUserContextText()` caller profile injection | [main.js:454-458](file:///home/duxiter/s2s/demo/main.js#L454-L458) | +| `fetchOpenAiReasoningAnswer()` POST /api/reasoning client | [main.js:563-586](file:///home/duxiter/s2s/demo/main.js#L563-L586) | +| `maybeRunOpenAiReasoning()` trigger da transcript + inject `[VOCERO_RENDER_REPLY]` | [main.js:592-625](file:///home/duxiter/s2s/demo/main.js#L592-L625) | +| `pushLiveInstructions()` / `pushLiveToolPolicy()` update sessione live | [main.js:628-649](file:///home/duxiter/s2s/demo/main.js#L628-L649) | +| `doStart()` avvio sessione + invio istruzioni/tools iniziali | [main.js:2739-2788](file:///home/duxiter/s2s/demo/main.js#L2739-L2788) | +| Gestore transcript utente → trigger reasoning + ranking MCP | [main.js:2814-2821](file:///home/duxiter/s2s/demo/main.js#L2814-L2821) | diff --git a/s2s-reasoning-engine/.env.example b/s2s-reasoning-engine/.env.example new file mode 100644 index 0000000..56dc990 --- /dev/null +++ b/s2s-reasoning-engine/.env.example @@ -0,0 +1,39 @@ +# Configurazione ambiente per s2s-reasoning-engine +# +# Copia questo file in .env nella root del progetto o nella cartella s2s-reasoning-engine/ +# e modifica i valori secondo il tuo deployment. + +# ───────────────────────────────────────────────────────────── +# 1) LLM (OpenAI-compatible) +# ───────────────────────────────────────────────────────────── +OPENAI_API_KEY=sk-.................................... +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_REASONING_MODEL=gpt-4.1-mini +OPENAI_ORGANIZATION= +OPENAI_PROJECT= +OPENAI_REASONING_TEMPERATURE=0.2 +OPENAI_REASONING_MAX_TOKENS=512 + +# ───────────────────────────────────────────────────────────── +# 2) Aquachile (profilo agente, tool MCP, autenticazione) +# ───────────────────────────────────────────────────────────── +S2S_AGENT_PROFILE=https://aquachile.azurianlab.com/api/v1/agents/profile +S2S_USER_PHONE_AUTH_URL=https://aquachile.azurianlab.com/api/v1/auth/user-by-phone +S2S_TOOL_EXECUTION_MCP_URL=https://aquachile.azurianlab.com/api/v1/mcp +S2S_TOOL_EXECUTION_MCP_API_KEY=.................................... +S2S_TOOL_EXECUTION_MCP_API_SECRET=.................................... +S2S_SEARCH_MCP=true + +# ───────────────────────────────────────────────────────────── +# 3) Node-RED runtime +# ───────────────────────────────────────────────────────────── +NODE_RED_PORT=1881 +NODE_RED_LOG_LEVEL=info +NODE_RED_CREDENTIAL_SECRET=change-me-in-production + +# ───────────────────────────────────────────────────────────── +# 4) Integrazione con s2s UI (server.py) +# ───────────────────────────────────────────────────────────── +# URL raggiungibile dal container Python della UI +# (se usi rete docker condivisa è http://s2s-reasoning:1880) +S2S_REASONING_NODE_RED_URL=http://localhost:1881 diff --git a/s2s-reasoning-engine/Dockerfile b/s2s-reasoning-engine/Dockerfile new file mode 100644 index 0000000..5bf3f50 --- /dev/null +++ b/s2s-reasoning-engine/Dockerfile @@ -0,0 +1,30 @@ +# s2s-reasoning-engine: Node-RED 5.0 custom image + +# Usa Node-RED 5.x ufficiale (immagine Alpine per dimensioni ridotte) +FROM nodered/node-red:5.0.3 + +# Porta di default di Node-RED (esposta in reti containerizzate) +EXPOSE 1880 + +# 1) Installa nodi aggiuntivi (a livello utente node-red nella cartella dati) +# - node-red-contrib-openai: integrazione nativa Chat Completions / Embeddings +# - node-red-contrib-http-logger: log HTTP opzionale +# - node-red-contrib-cache: cache in-memory per dati Aquachile (opzionale) +# - node-red-node-rbe: report-by-exception per deduplicare eventi +RUN cd /data && npm install --no-audit --no-fund \ + node-red-contrib-openai@0.11.0 \ + node-red-contrib-cache@1.1.1 \ + node-red-node-rbe@0.5.0 + +# 2) Copia file di configurazione e flows iniziali +COPY settings.js /data/settings.js +COPY flows.json /data/flows.json + +# 3) Imposta Node-RED per caricare flows statici + disabilita progetto e auth +# (per ambiente di sviluppo; in produzione abilitare adminAuth in settings.js) +ENV NODE_RED_FLOWS_FILE=/data/flows.json +ENV NODE_RED_CREDENTIAL_SECRET=s2s-reasoning-dev-secret +ENV FLOWS=/data/flows.json + +# Avvio Node-RED +CMD ["npm", "start", "--", "--userDir", "/data", "--settings", "/data/settings.js"] diff --git a/s2s-reasoning-engine/flows.json b/s2s-reasoning-engine/flows.json new file mode 100644 index 0000000..8dcb37d --- /dev/null +++ b/s2s-reasoning-engine/flows.json @@ -0,0 +1,254 @@ +[ + { + "id": "s2s-reasoning-tab", + "type": "tab", + "label": "S2S Reasoning", + "disabled": false, + "info": "# Flusso di reasoning S2S — FASE 1: LOG-ONLY\n\nModalità attuale: il flusso NON produce risposte, NON modifica il prompt.\nSi limita a:\n 1. Ricevere POST /api/reasoning dal server demo\n 2. Estrarre transcript, lingua, phoneNumber\n 3. Loggare la richiesta utente su console + sidebar debug\n 4. Risondere HTTP 200 con contratto standard e flag `log_only: true`\n\nContratto risposta (fase 1):\n { answer: \"\", log_only: true, intent: \"log_only\", usage: {prompt_tokens:0,completion_tokens:0,total_tokens:0}, latency_ms: }\n", + "wires": [] + }, + { + "id": "http-in-reasoning", + "type": "http in", + "z": "s2s-reasoning-tab", + "name": "POST /api/reasoning", + "url": "/api/reasoning", + "method": "post", + "upload": false, + "swaggerDoc": "", + "x": 160, + "y": 160, + "wires": [ + [ + "parse-request" + ] + ] + }, + { + "id": "parse-request", + "type": "function", + "z": "s2s-reasoning-tab", + "name": "Parse richiesta (log-only)", + "func": "// FASE 1 — LOG-ONLY: nessuna answer generata.\n// Valida e normalizza il payload in arrivo da server.py.\n// msg.req.body = { transcript, instructions, language, user_context, phoneNumber? }\n\nconst body = msg.req?.body || {};\nconst startedAt = Date.now();\n\nconst transcript = String(body.transcript || \"\").trim();\nconst instructions = String(body.instructions || \"\").trim();\nconst language = String(body.language || \"\").trim();\nconst userContext = String(body.user_context || body.user_context || \"\").trim();\nconst phoneNumber = String(body.phoneNumber || body.phone_number || msg.req?.query?.phoneNumber || \"\").trim();\n\nif (!transcript) {\n msg.statusCode = 400;\n msg.payload = { error: \"missing_transcript\", detail: \"transcript field is required\" };\n return [null, msg]; // uscita 2 = errore\n}\n\nconst detectedLang = language || (transcript.match(/[áéíóúñ¿¡]/i) ? \"es-CL\" : \"en-US\");\n\nmsg.state = {\n startedAt,\n transcript,\n instructionsLen: instructions.length,\n language: detectedLang,\n userContextLen: userContext.length,\n phoneNumber: phoneNumber || null,\n intent: \"log_only\",\n};\n\nmsg.payload = {\n transcript,\n language: detectedLang,\n phone: phoneNumber || \"none\",\n};\n\nreturn [msg, null];", + "outputs": 2, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 400, + "y": 160, + "wires": [ + [ + "log-transcript-console", + "debug-parsed" + ], + [ + "http-response-error" + ] + ] + }, + { + "id": "log-transcript-console", + "type": "function", + "z": "s2s-reasoning-tab", + "name": "[LOG] richiesta utente", + "func": "// FASE 1 — Stampa la richiesta utente sul log server (Node-RED console)\n// e sul pannello debug sidebar.\n// NON produce alcuna risposta: quella arriva dal backend S2S nativo.\n\nconst state = msg.state || {};\nconst transcript = state.transcript || \"\";\nconst lang = state.language || \"\";\nconst phone = state.phoneNumber || \"none\";\nconst ts = new Date(state.startedAt || Date.now()).toISOString();\n\nconst line = `[S2S NODE_RED LOG_ONLY] ts=${ts} lang=${lang} phone=${phone} transcript=\"${transcript.replace(/\"/g, '\\\\\"')}\"`;\n\nnode.warn(line);\n\nmsg.logLine = line;\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 660, + "y": 120, + "wires": [ + [ + "build-response" + ] + ] + }, + { + "id": "debug-parsed", + "type": "debug", + "z": "s2s-reasoning-tab", + "name": "[Debug] parsed state", + "active": true, + "tosidebar": true, + "console": true, + "tostatus": false, + "complete": "state", + "targetType": "msg", + "status": "", + "x": 650, + "y": 200, + "wires": [] + }, + { + "id": "build-response", + "type": "function", + "z": "s2s-reasoning-tab", + "name": "Build 200 (log_only)", + "func": "// FASE 1 — Costruisci la risposta HTTP 200.\n// Contratto: answer VUOTA + flag log_only=true.\n// Il client (main.js) vedrà log_only e NON interverrà:\n// - NON chiama cancelActiveResponse()\n// - NON usa appendAssistantText()\n// - NON usa speechSynthesis\n// => la risposta vocale arriva dal backend S2S nativo.\n\nconst state = msg.state || {};\nconst latencyMs = Math.max(0, Date.now() - (state.startedAt || Date.now()));\n\nmsg.statusCode = 200;\nmsg.headers = {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n};\nmsg.payload = {\n answer: \"\",\n log_only: true,\n intent: \"log_only\",\n usage: {\n prompt_tokens: 0,\n completion_tokens: 0,\n total_tokens: 0,\n },\n latency_ms: latencyMs,\n mcp_tools_used: [],\n caller_profile_keys: [],\n debug: {\n phase: \"log_only_fase1\",\n language: state.language || \"\",\n phone: state.phoneNumber || null,\n transcript_len: (state.transcript || \"\").length,\n },\n};\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 920, + "y": 160, + "wires": [ + [ + "http-response-ok", + "debug-response" + ] + ] + }, + { + "id": "http-response-ok", + "type": "http response", + "z": "s2s-reasoning-tab", + "name": "HTTP 200 OK", + "statusCode": "", + "headers": {}, + "x": 1160, + "y": 120, + "wires": [] + }, + { + "id": "debug-response", + "type": "debug", + "z": "s2s-reasoning-tab", + "name": "[Debug] response payload", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "payload", + "targetType": "msg", + "status": "", + "x": 1180, + "y": 200, + "wires": [] + }, + { + "id": "http-response-error", + "type": "http response", + "z": "s2s-reasoning-tab", + "name": "HTTP Error", + "statusCode": "", + "headers": {}, + "x": 400, + "y": 240, + "wires": [] + }, + { + "id": "http-in-log-response", + "type": "http in", + "z": "s2s-reasoning-tab", + "name": "POST /api/log/system-response", + "url": "/api/log/system-response", + "method": "post", + "upload": false, + "swaggerDoc": "", + "x": 160, + "y": 360, + "wires": [ + [ + "parse-log-response" + ] + ] + }, + { + "id": "parse-log-response", + "type": "function", + "z": "s2s-reasoning-tab", + "name": "Parse system-response", + "func": "// Riceve dal server.py la risposta TTS emessa dal backend S2S nativo.\n// msg.req.body = { tts, response_id?, item_id?, client_ip?, role?, source_event?, user_transcript?, phone? }\n// FASE 1 LOG-ONLY: nessun side-effect oltre al log.\n\nconst body = msg.req?.body || {};\nconst startedAt = Date.now();\n\nconst tts = String(body.tts || body.text || body.response || \"\").trim();\nconst responseId = String(body.response_id || body.responseId || \"\").trim() || null;\nconst itemId = String(body.item_id || body.itemId || \"\").trim() || null;\nconst clientIp = String(body.client_ip || body.clientIp || body.ip || \"\").trim() || null;\nconst role = String(body.role || \"assistant\").trim() || \"assistant\";\nconst sourceEvent = String(body.source_event || body.sourceEvent || \"\").trim() || null;\nconst userTranscript = String(body.user_transcript || body.userTranscript || \"\").trim() || null;\nconst phone = String(body.phone || body.phoneNumber || \"\").trim() || null;\n\nif (!tts) {\n msg.statusCode = 400;\n msg.headers = { \"Content-Type\": \"application/json\" };\n msg.payload = { error: \"missing_tts\", detail: \"field 'tts' (or 'text'/'response') is required\" };\n return [null, msg];\n}\n\nmsg.state = {\n startedAt,\n kind: \"system_response_log\",\n role,\n tts,\n responseId,\n itemId,\n clientIp,\n sourceEvent,\n userTranscript,\n phone,\n};\n\nmsg.payload = { tts, role, source: sourceEvent, client: clientIp };\nreturn [msg, null];", + "outputs": 2, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 420, + "y": 360, + "wires": [ + [ + "log-system-response", + "debug-system-response" + ], + [ + "http-response-error" + ] + ] + }, + { + "id": "log-system-response", + "type": "function", + "z": "s2s-reasoning-tab", + "name": "[LOG] risposta sistema", + "func": "// Stampa la risposta del sistema (TTS dal backend nativo) sul log Node-RED.\n// Formato riga unica ricercabile con grep.\nconst state = msg.state || {};\nconst tts = state.tts || \"\";\nconst role = state.role || \"assistant\";\nconst src = state.sourceEvent || \"\";\nconst user = state.userTranscript || \"\";\nconst phone = state.phone || \"none\";\nconst ts = new Date(state.startedAt || Date.now()).toISOString();\n\nconst userShort = user.length > 120 ? user.slice(0, 117) + \"...\" : user;\nconst ttsShort = tts.length > 240 ? tts.slice(0, 237) + \"...\" : tts;\nconst esc = (s) => String(s || \"\").replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n\nconst line = `[S2S NODE_RED SYSTEM_RESPONSE] ts=${ts} role=${role} phone=${esc(phone)} user=\"${esc(userShort)}\" tts=\"${esc(ttsShort)}\"`;\n\nnode.warn(line);\nmsg.logLine = line;\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 690, + "y": 320, + "wires": [ + [ + "build-log-ack" + ] + ] + }, + { + "id": "debug-system-response", + "type": "debug", + "z": "s2s-reasoning-tab", + "name": "[Debug] system-response state", + "active": true, + "tosidebar": true, + "console": true, + "tostatus": false, + "complete": "state", + "targetType": "msg", + "status": "", + "x": 710, + "y": 400, + "wires": [] + }, + { + "id": "build-log-ack", + "type": "function", + "z": "s2s-reasoning-tab", + "name": "Build 200 (ack)", + "func": "// Risposta minimale: 200 OK + ricevuto=true.\n// Non ritorna dati.\nconst state = msg.state || {};\nconst latencyMs = Math.max(0, Date.now() - (state.startedAt || Date.now()));\nmsg.statusCode = 200;\nmsg.headers = {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n};\nmsg.payload = {\n ricevuto: true,\n log_only: true,\n intent: \"system_response_log\",\n latency_ms: latencyMs,\n};\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 950, + "y": 360, + "wires": [ + [ + "http-response-log-ack" + ] + ] + }, + { + "id": "http-response-log-ack", + "type": "http response", + "z": "s2s-reasoning-tab", + "name": "HTTP 200 ACK", + "statusCode": "", + "headers": {}, + "x": 1180, + "y": 360, + "wires": [] + } +] diff --git a/s2s-reasoning-engine/package.json b/s2s-reasoning-engine/package.json new file mode 100644 index 0000000..d59038b --- /dev/null +++ b/s2s-reasoning-engine/package.json @@ -0,0 +1,24 @@ +{ + "name": "s2s-reasoning-engine", + "version": "1.0.0", + "description": "Node-RED 5.0 reasoning engine per S2S/Aquachile", + "main": "settings.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "private": "true", + "engines": { + "node": ">=18" + }, + "dependencies": { + "dotenv": "^16.4.7", + "node-red": "^5.0.3", + "node-red-contrib-cache": "^2.0.3", + "node-red-contrib-openai": "^1.0.3", + "node-red-node-rbe": "^0.5.0" + } +} diff --git a/s2s-reasoning-engine/settings.js b/s2s-reasoning-engine/settings.js new file mode 100644 index 0000000..f31480d --- /dev/null +++ b/s2s-reasoning-engine/settings.js @@ -0,0 +1,121 @@ +/** + * Node-RED 5.0 settings for s2s-reasoning-engine — LOCAL INSTALL (non Docker). + * File: /s2s-reasoning-engine/.node-red/settings.js + * oppure /s2s-reasoning-engine/settings.js (master) + * + * Copiato automaticamente in .node-red/ da start.sh se mancante o più vecchio. + */ + +const path = require("path"); +const fs = require("fs"); + +// userDir è sempre /.node-red (lo script start.sh) +const ENGINE_ROOT = path.resolve(__dirname, ".."); +const LOCAL_NODE_MODULES = path.join(ENGINE_ROOT, "node_modules"); +const USERDIR_NODE_MODULES = path.join(__dirname, "node_modules"); + +// ── Fix per editor "tslib.es6.js 404 / net::ERR_ABORTED" ───────────────── +// Installazioni npm locali flat (default npm 3+) spostano tslib in +// node_modules/tslib/ (root) invece che dentro +// @node-red/editor-client/node_modules/tslib/. L'editor di Node-RED 5 +// carica `/tslib.es6.js` dalla root HTTP: se la cartella nested non esiste, +// torna 404. Qui aggiorniamo editor-client/public/ con i file tslib mancanti +// a ogni avvio (sopravvive a npm reinstall se tslib è in root). +(function _installTslibVendor() { + try { + const tslibRoot = path.join(LOCAL_NODE_MODULES, "tslib"); + if (!fs.existsSync(path.join(tslibRoot, "tslib.es6.js"))) return; + const pub = path.join(LOCAL_NODE_MODULES, "@node-red", "editor-client", "public"); + if (!fs.existsSync(pub)) return; + for (const f of ["tslib.es6.js", "tslib.js", "tslib.mjs"]) { + const src = path.join(tslibRoot, f); + const dst = path.join(pub, f); + if (!fs.existsSync(src)) continue; + try { + const s = fs.statSync(src); + const d = fs.existsSync(dst) ? fs.statSync(dst) : null; + if (!d || d.size !== s.size || d.mtimeMs < s.mtimeMs) { + fs.copyFileSync(src, dst); + } + } catch (e) { /* ignore */ } + } + } catch (e) { /* no-op */ } +})(); + +module.exports = { + uiPort: process.env.PORT || process.env.NODE_RED_PORT || 1881, + uiHost: "0.0.0.0", + + httpRequestTimeout: 120000, + httpRequestLegacyMsgOverrides: true, + + // ── Native HTTP helper per function node (SENZA externalModules) ───── + // NOTA: non usare functionExternalModules: true — in Node-RED v5 quel + // flag può bloccare il caricamento palette (es. "37/41 nodes loading" + // durante la verifica di tutti i pkg npm). + // Al suo posto usiamo functionGlobalContext: i moduli core Node.js sono + // require'd QUI nel settings.js (prima dell'avvio NR) e poi i function + // node li recuperano via global.get("_core_https") ecc. + functionModuleRequireAllowList: ["https","http","url","path","util","fs","os","buffer","zlib","stream","crypto"], + functionGlobalContext: { + _core_https: require("https"), + _core_http: require("http"), + _core_url: require("url"), + }, + + // adminAuth: {...} ← scommentare e valorizzare in produzione + + // HTTPS (opzionale in produzione) + // https: { + // key: require("fs").readFileSync(path.join(__dirname, "privkey.pem")), + // cert: require("fs").readFileSync(path.join(__dirname, "fullchain.pem")) + // }, + + flowFile: process.env.FLOWS || path.join(__dirname, "flows.json"), + flowFilePretty: true, + credentialSecret: process.env.NODE_RED_CREDENTIAL_SECRET || "s2s-reasoning-local-dev", + + httpNodeCors: { + origin: "*", + methods: "GET,PUT,POST,DELETE,OPTIONS", + credentials: true, + }, + csrf: false, + + // ── IMPORTANTE: caricamento nodi extra ───────────────────────────── + // Node-RED 5 carica i plugin NODI da: + // 1) {userDir}/node_modules (nostro .node-red/node_modules) + // 2) {nodesDir} (installazione root del progetto) + // 3) npm global + // Senza nodesDir i pacchetti installati nella ROOT del progetto non + // vengono trovati. + nodesDir: LOCAL_NODE_MODULES, + + externalModules: { + autoInstall: true, // auto-installa nodi mancanti al boot + autoInstallRetry: 3, + palette: { + allowInstall: true, // permette Install dall'editor + allowUpload: true, + modules: ["node-red-contrib-openai", "node-red-contrib-cache", "node-red-node-rbe"], + allowList: ["node-red-contrib-*", "node-red-node-*"], + denyList: [] + } + }, + + editorTheme: { projects: { enabled: false } }, + + logging: { + console: { + level: process.env.NODE_RED_LOG_LEVEL || process.env.LOG_LEVEL || "info", + metrics: false, + audit: false, + timestamp: true, + } + }, + + contextStorage: { + default: { module: "memory" }, + file: { module: "localfilesystem" } + }, +}; diff --git a/s2s-reasoning-engine/start.sh b/s2s-reasoning-engine/start.sh new file mode 100755 index 0000000..2b066ff --- /dev/null +++ b/s2s-reasoning-engine/start.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# ============================================================= +# start.sh — Avvia Node-RED 5.0 in locale per s2s-reasoning-engine +# +# Uso: +# ./start.sh # porta default 1881 +# PORT=1900 ./start.sh # porta custom +# LOG_LEVEL=debug ./start.sh # log più dettagliato +# DEBUG=nodes:* ./start.sh # debug Node-RED interno +# Stop: Ctrl+C +# ============================================================= +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PARENT_DIR="$(dirname "$ROOT_DIR")" + +cd "$ROOT_DIR" + +# ---------------------------------------------------------- +# 1) Carica env: prima locale .env (se esiste) poi padre ../.env +# Il file locale sovrascrive il padre se c'è lo stesso var. +# ---------------------------------------------------------- +set -a +if [ -f "$ROOT_DIR/.env" ]; then + echo "[env] loaded $ROOT_DIR/.env" + # shellcheck disable=SC1091 + source "$ROOT_DIR/.env" +fi +if [ -f "$PARENT_DIR/.env" ]; then + echo "[env] loaded $PARENT_DIR/.env" + # shellcheck disable=SC1091 + source "$PARENT_DIR/.env" +fi +set +a + +# ---------------------------------------------------------- +# 2) Parametri runtime Node-RED +# ---------------------------------------------------------- +PORT="${PORT:-${NODE_RED_PORT:-1881}}" +export PORT +export NODE_RED_PORT="$PORT" +export NODE_RED_LOG_LEVEL="${LOG_LEVEL:-${NODE_RED_LOG_LEVEL:-info}}" +export NODE_RED_CREDENTIAL_SECRET="${NODE_RED_CREDENTIAL_SECRET:-s2s-reasoning-local-dev}" +export FLOWS="${FLOWS:-$ROOT_DIR/.node-red/flows.json}" + +# UserDir locale (tutto dentro la cartella corrente, NO ~/.node-red) +USERDIR="${USERDIR:-$ROOT_DIR/.node-red}" +SETTINGS="${SETTINGS:-$USERDIR/settings.js}" + +# Assicura che la userDir esista e i file base siano presenti. +mkdir -p "$USERDIR" +if [ ! -f "$SETTINGS" ] || [ "$ROOT_DIR/settings.js" -nt "$SETTINGS" ]; then + cp -f "$ROOT_DIR/settings.js" "$SETTINGS" + echo "[init] copied settings.js -> $SETTINGS" +fi +if [ ! -f "$FLOWS" ] || [ "$ROOT_DIR/flows.json" -nt "$FLOWS" ]; then + cp -f "$ROOT_DIR/flows.json" "$FLOWS" + echo "[init] copied flows.json -> $FLOWS" +fi + +# Verifica node_modules installati +if [ ! -d "$ROOT_DIR/node_modules/node-red" ]; then + echo "[init] node_modules non trovati. Avvio npm install..." + npm install --no-audit --no-fund +fi + +echo "" +echo "╔════════════════════════════════════════════════════════════╗" +echo "║ s2s-reasoning-engine — Node-RED v5 locale ║" +echo "╠════════════════════════════════════════════════════════════╣" +echo "║ Editor: http://localhost:${PORT}/ ║" +echo "║ API: http://localhost:${PORT}/api/reasoning ║" +echo "║ UserDir: ${USERDIR}" +echo "║ LogLevel: ${NODE_RED_LOG_LEVEL}" +echo "╚════════════════════════════════════════════════════════════╝" +echo "" + +# ---------------------------------------------------------- +# 3) Avvio Node-RED +# ---------------------------------------------------------- +exec "$ROOT_DIR/node_modules/.bin/node-red" \ + --userDir "$USERDIR" \ + --settings "$SETTINGS" \ + --port "$PORT" diff --git a/sample.json b/sample.json new file mode 100644 index 0000000..6254278 --- /dev/null +++ b/sample.json @@ -0,0 +1,572 @@ +{ + "agent": { + "name": "Aquachile Voice Agent", + "description": "Asistente de voz de Aquachile para soporte, informacion operativa y guia conversacional en tiempo real.", + "model": "gpt-realtime", + "voice": "alloy", + "language": "es", + "enabled": true, + "interruptible": true, + "tools": [ + { + "name": "describe_copernicus_marine_catalogue", + "description": "Describe the Copernicus Marine catalogue with optional contains, dataset and product filters.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": { + "contains": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null + }, + "dataset_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "product_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "show_all_versions": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + }, + "tags": [] + }, + { + "name": "get_api_info", + "description": "Return basic AquaPortal API and MCP runtime metadata.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "tags": [] + }, + { + "name": "get_copernicus_marine_status", + "description": "Return Copernicus Marine toolbox installation and credential status.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "tags": [] + }, + { + "name": "get_copernicus_marine_subset", + "description": "Open and summarize a Copernicus Marine dataset subset, defaulting to an allowed zone when bounds are omitted.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": { + "dataset_id": { + "type": "string" + }, + "zone_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "variables": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null + }, + "dataset_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "dataset_part": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "minimum_longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null + }, + "maximum_longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null + }, + "minimum_latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null + }, + "maximum_latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null + }, + "minimum_depth": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null + }, + "maximum_depth": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null + }, + "start_datetime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "end_datetime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "coordinates_selection_method": { + "default": "nearest", + "type": "string" + }, + "max_values_per_variable": { + "default": 8, + "type": "integer" + } + }, + "required": [ + "dataset_id" + ], + "type": "object" + }, + "tags": [] + }, + { + "name": "get_public_day_report", + "description": "Return the public day report payload for a business role.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": { + "role": { + "default": "CEO", + "type": "string" + } + }, + "type": "object" + }, + "tags": [] + }, + { + "name": "get_report_detail", + "description": "Return one report with linked metrics and insights.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": { + "report_id": { + "type": "string" + } + }, + "required": [ + "report_id" + ], + "type": "object" + }, + "tags": [] + }, + { + "name": "get_today_podcast", + "description": "Return today's AquaPortal podcast metadata and URLs.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "tags": [] + }, + { + "name": "get_user_by_phone", + "description": "Resolve an AquaPortal user from an E.164 or WhatsApp phone number.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": { + "phone_number": { + "type": "string" + } + }, + "required": [ + "phone_number" + ], + "type": "object" + }, + "tags": [] + }, + { + "name": "list_business_roles", + "description": "List configured business role definitions.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "tags": [] + }, + { + "name": "list_insights", + "description": "List insights with optional center, priority, role and date filters.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": { + "center": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "priority": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "report_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "start_date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "end_date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "type": "object" + }, + "tags": [] + }, + { + "name": "list_metrics", + "description": "List extracted metrics with optional filters.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": { + "center": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "jaula": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "metric": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "report_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "start_date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "end_date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "type": "object" + }, + "tags": [] + }, + { + "name": "list_recent_reports", + "description": "List the most recent uploaded reports stored by AquaPortal.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": { + "limit": { + "default": 5, + "type": "integer" + } + }, + "type": "object" + }, + "tags": [] + }, + { + "name": "list_recent_whatsapp_deliveries", + "description": "List recent WhatsApp delivery records, optionally filtered by recipient.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": { + "limit": { + "default": 10, + "type": "integer" + }, + "recipient": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "type": "object" + }, + "tags": [] + }, + { + "name": "list_users", + "description": "List AquaPortal users.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "tags": [] + }, + { + "name": "list_whatsapp_mappings", + "description": "List active and inactive WhatsApp user mappings.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "tags": [] + }, + { + "name": "ping", + "description": "Echo a message to verify AquaPortal MCP connectivity.", + "enabled": true, + "parameters": { + "additionalProperties": false, + "properties": { + "message": { + "default": "hello", + "type": "string" + } + }, + "type": "object" + }, + "tags": [] + } + ], + "workflowId": "aquachile-spanish-support-flow", + "status": "ready", + "tags": [ + "aquachile", + "spanish", + "support", + "operations", + "voice" + ], + "instructions": "Eres el asistente de voz oficial de Aquachile. Debes hablar siempre en espanol, con tono profesional, claro, cordial y seguro. Responde de forma breve pero util. Haz una sola pregunta por turno cuando necesites aclaraciones. No inventes datos, precios, fechas, disponibilidad ni politicas. Si necesitas informacion verificable, usa primero las herramientas disponibles. Si una accion requiere confirmacion del usuario, pidela antes de continuar. Resume siempre la accion o conclusion al final de cada respuesta. Si el usuario cambia de tema, adapta el contexto sin perder claridad. Si no tienes datos suficientes, dilo explicitamente y propone el siguiente paso mas util. Nunca respondas en italiano o ingles salvo que el usuario lo pida de forma explicita.", + "turnDetection": { + "type": "server_vad" + } + } +} \ No newline at end of file diff --git a/speech_to_speech/LLM/README.md b/speech_to_speech/LLM/README.md new file mode 100644 index 0000000..2a5a9d6 --- /dev/null +++ b/speech_to_speech/LLM/README.md @@ -0,0 +1,139 @@ +# LLM Summary + +## Available LLM backends (`--llm_backend`) + +Runtime-supported values in `s2s_pipeline.py`: + +- `transformers` → `language_model.py` (Transformers backend) +- `mlx-lm` → `language_model.py` (MLX backend) +- `responses-api` → `responses_api_language_model.py` + +## Usage + +### 1) Transformers (`--llm_backend transformers`) + +- Handler: `LanguageModelHandler` +- Typical use: local GPU/CPU inference using Hugging Face Transformers +- Backend-specific args prefix: `--llm_*` +- Shared args (from base): `--model_name`, `--chat_size`, `--init_chat_prompt`, `--enable_lang_prompt` + +```bash +python s2s_pipeline.py \ + --llm_backend transformers \ + --model_name Qwen/Qwen3-4B-Instruct-2507 \ + --llm_device cuda \ + --llm_torch_dtype float16 \ + --llm_gen_max_new_tokens 128 +``` + +Common options: +- `--llm_gen_min_new_tokens` +- `--llm_gen_temperature` +- `--llm_gen_do_sample` +- `--chat_size` +- `--init_chat_prompt` + +### 2) MLX-LM (`--llm_backend mlx-lm`) + +- Handler: `LanguageModelHandler` +- Typical use: Apple Silicon local inference +- Backend-specific args prefix: same as Transformers (`--llm_*`) + +```bash +python s2s_pipeline.py \ + --llm_backend mlx-lm \ + --model_name mlx-community/Qwen3-4B-Instruct-2507-bf16 \ + --llm_device mps \ + --llm_gen_max_new_tokens 128 +``` + +Common options: +- `--llm_gen_temperature` +- `--llm_gen_do_sample` +- `--chat_size` +- `--init_chat_prompt` + +### 3) OpenAI-compatible API (`--llm_backend responses-api`) + +- Handler: `ResponsesApiModelHandler` +- Typical use: remote model serving via OpenAI-compatible endpoints +- Backend-specific args prefix: `--responses_api_*` +- Shared args (from base): `--model_name`, `--chat_size`, `--init_chat_prompt`, `--enable_lang_prompt` + +```bash +python s2s_pipeline.py \ + --llm_backend responses-api \ + --model_name gpt-5.4-mini \ + --responses_api_api_key YOUR_API_KEY \ + --responses_api_base_url https://api.example.com/v1 \ + --responses_api_stream true +``` + +Common options: +- `--chat_size` +- `--init_chat_prompt` +- `--user_role` + +## LLM Behavior + +When STT is set to language auto-detection (`--language auto`), LLM handlers can receive `(text, language_code)` and prepend a language control instruction like: + +- `Please reply to my message in .` + +This helps the assistant respond in the detected language. The behavior is opt-in via `--enable_lang_prompt` (shared across all backends); it defaults to `False`. + +## Setup + +### CUDA setup + +```bash +python s2s_pipeline.py \ + --llm_backend transformers \ + --model_name microsoft/Phi-3-mini-4k-instruct +``` + +### Local Mac setup + +```bash +python s2s_pipeline.py \ + --local_mac_optimal_settings \ + --model_name mlx-community/Qwen3-4B-Instruct-2507-bf16 +``` + +`--local_mac_optimal_settings` already sets `--llm_backend mlx-lm` and will default the model to `mlx-community/Qwen3-4B-Instruct-2507-bf16` if not overridden. + +### Realtime (OpenAI-compatible) setup + +Run the server in realtime mode, then connect with the realtime client: + +```bash +# 1. Start the pipeline in realtime mode +python s2s_pipeline.py \ + --mode realtime \ + --llm_backend mlx-lm \ + --model_name mlx-community/Qwen3-4B-Instruct-2507-bf16 \ + --ws_host 0.0.0.0 \ + --ws_port 8765 + +# 2. Connect with the realtime client +python listen_and_play_realtime.py --host 127.0.0.1 --port 8765 +``` + +Or with `--local_mac_optimal_settings` on Apple Silicon: + +```bash +python s2s_pipeline.py \ + --local_mac_optimal_settings \ + --mode realtime \ + --ws_host 0.0.0.0 \ + --ws_port 8765 +``` + +### Remote API setup + +```bash +python s2s_pipeline.py \ + --llm_backend responses-api \ + --model_name gpt-5.4-mini \ + --responses_api_api_key YOUR_API_KEY +``` diff --git a/speech_to_speech/LLM/__init__.py b/speech_to_speech/LLM/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/speech_to_speech/LLM/base_openai_compatible_language_model.py b/speech_to_speech/LLM/base_openai_compatible_language_model.py new file mode 100644 index 0000000..195c129 --- /dev/null +++ b/speech_to_speech/LLM/base_openai_compatible_language_model.py @@ -0,0 +1,588 @@ +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from collections.abc import Iterator +from typing import Any, Optional + +import httpx +from nltk import sent_tokenize +from openai import OpenAI +from openai.types.realtime.conversation_item import ( + RealtimeConversationItemAssistantMessage, + RealtimeConversationItemFunctionCall, +) +from openai.types.realtime.realtime_conversation_item_assistant_message import ( + Content as AssistantContent, +) +from openai.types.responses import ResponseFunctionToolCall +from pydantic import BaseModel, ConfigDict, Field + +from speech_to_speech.baseHandler import BaseHandler +from speech_to_speech.LLM.chat import ( + Chat, + ChatItemError, + SupportedItem, + build_active_chat, + make_system_message, + make_user_message, +) +from speech_to_speech.LLM.compaction_prompt import CompactGenerateFn, build_compactor +from speech_to_speech.LLM.text_prompt import build_text_system_prompt +from speech_to_speech.LLM.utils import remove_unspeechable, resolve_auto_language +from speech_to_speech.LLM.voice_prompt import build_voice_system_prompt +from speech_to_speech.pipeline.cancel_scope import CancelScope +from speech_to_speech.pipeline.handler_types import LLMIn, LLMOut +from speech_to_speech.pipeline.messages import ( + EndOfResponse, + LLMResponseChunk, + TokenUsage, +) +from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker +from speech_to_speech.utils.utils import is_out_of_band, response_wants_audio + +logger = logging.getLogger(__name__) + +# About 18–24 seconds of default SDK backoff before warmup fails. +WARMUP_MAX_RETRIES = 6 + + +# ── Normalised provider events ──────────────────────────────────────────────── +# Each backend's stream/response is mapped to this small vocabulary so the shared +# speech-pipeline logic (sentence batching, cancellation, history, token usage) +# lives in one place. Subclasses differ only in how they produce these events. + + +class TextDelta(BaseModel): + """Incremental assistant text. Always RAW (unfiltered); the base applies + ``remove_unspeechable`` for the audio path.""" + + text: str + + +class AssistantMessage(BaseModel): + """A complete assistant turn to write back to history.""" + + content: list[AssistantContent] + + +class ToolCall(BaseModel): + """A complete function tool call (``call_id`` / ``id`` already regenerated).""" + + item: ResponseFunctionToolCall + + +class Usage(BaseModel): + """Token accounting for the turn.""" + + input_tokens: int + output_tokens: int + + +ProviderEvent = TextDelta | AssistantMessage | ToolCall | Usage + + +class _Turn(BaseModel): + """Per-request context threaded through generation (immutable for the turn).""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + language_code: Optional[str] + gen: int | None + runtime_config: Any + response: Any + turn_id: str | None + turn_revision: int | None + speech_stopped_at_s: float | None + wants_audio: bool + + +class _GenState(BaseModel): + """Mutable accumulators collected while consuming a turn's events.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + tools: list[ResponseFunctionToolCall] = Field(default_factory=list) + pending: list[SupportedItem] = Field(default_factory=list) + clean_text: str = "" # filtered text, kept only for the debug log + input_tokens: int = 0 + output_tokens: int = 0 + + +class BaseOpenAICompatibleHandler(BaseHandler[LLMIn, LLMOut], ABC): + """Shared lifecycle for OpenAI-compatible LLM backends (Responses & Chat + Completions). + + Subclasses implement four hooks — :meth:`warmup`, + :meth:`_build_compaction_generate_fn`, :meth:`_serialize`, :meth:`_request`, + :meth:`_iter_events` and :meth:`_build_optional_kwargs` — and inherit the + request/response orchestration: speculative-turn gating, cancellation, + sentence batching, text-only vs audio handling, history write-back, token + usage, out-of-band handling and error termination. + """ + + # ── setup ───────────────────────────────────────────────────────────────── + + def setup( + self, + model_name: str = "gpt-5.4-mini", + device: str = "cuda", + gen_kwargs: dict[str, Any] = {}, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + stream: bool = True, + user_role: str = "user", + cancel_scope: CancelScope | None = None, + speculative_turns: SpeculativeTurnTracker | None = None, + disable_thinking: bool = True, + reasoning_effort: Optional[str] = None, + request_timeout_s: float = 20.0, + stream_batch_sentences: int = 3, + enable_lang_prompt: bool = False, + compact_history: bool = False, + **_kwargs: Any, + ) -> None: + self.cancel_scope = cancel_scope + self.speculative_turns = speculative_turns + self.model_name = model_name + self.stream = stream + self.stream_batch_sentences = max(1, stream_batch_sentences) + self.enable_lang_prompt = enable_lang_prompt + self.gen_kwargs = dict(gen_kwargs) + self.request_timeout_s = float(request_timeout_s) + self.request_timeout = httpx.Timeout( + self.request_timeout_s, + connect=min(10.0, self.request_timeout_s), + ) + + self.user_role = user_role + self.client = OpenAI(api_key=api_key, base_url=base_url) + self._extra_body = self._build_extra_body(base_url, disable_thinking, reasoning_effort) + self.compactor = build_compactor(self._build_compaction_generate_fn()) if compact_history else None + self.warmup() + + @staticmethod + def _is_official_openai(base_url: Optional[str]) -> bool: + """Whether ``base_url`` points at the official OpenAI server. + + Normalises a trailing slash so ``https://api.openai.com/v1/`` is also + recognised; the official server rejects the provider-specific extra_body + keys we send to vLLM / the HF router. + """ + if base_url is None: + return False + return base_url.rstrip("/") == "https://api.openai.com/v1" + + @classmethod + def _build_extra_body( + cls, + base_url: Optional[str], + disable_thinking: bool, + reasoning_effort: Optional[str], + ) -> Optional[dict[str, Any]]: + """Build the provider-specific ``extra_body`` used to disable reasoning. + + Providers differ in how reasoning is turned off: vLLM/Qwen honour + ``chat_template_kwargs.enable_thinking=false``, while others (e.g. GLM via + the HF router) ignore that and require ``reasoning_effort='none'``. A + non-empty ``reasoning_effort`` therefore takes precedence; otherwise we fall + back to the chat-template flag. None of this applies to the official + OpenAI server, which rejects unknown extra_body keys. + """ + if base_url is None or cls._is_official_openai(base_url): + return None + if reasoning_effort: + return {"reasoning_effort": reasoning_effort} + if disable_thinking: + return {"chat_template_kwargs": {"enable_thinking": False}} + return None + + # ── subclass hooks ──────────────────────────────────────────────────────-- + + @abstractmethod + def warmup(self) -> None: + """Issue a cheap request so the model/connection is ready before serving.""" + ... + + @abstractmethod + def _build_compaction_generate_fn(self) -> CompactGenerateFn: + """Return a ``(system, user) -> text`` fn used to compact long histories.""" + ... + + @abstractmethod + def _serialize(self, active_chat: Chat) -> Any: + """Serialise the chat to the backend's request payload (input/messages).""" + ... + + @abstractmethod + def _request(self, api_input: Any, optional_kwargs: dict[str, Any]) -> Any: + """Issue the create() call and return the response or stream.""" + ... + + @abstractmethod + def _iter_stream_events(self, api_response: Any) -> Iterator[ProviderEvent]: + """Map a streaming response to normalised :data:`ProviderEvent`s.""" + ... + + @abstractmethod + def _iter_response_events(self, api_response: Any) -> Iterator[ProviderEvent]: + """Map a non-streaming response to normalised :data:`ProviderEvent`s.""" + ... + + def _iter_events(self, api_response: Any) -> Iterator[ProviderEvent]: + """Dispatch to the stream/non-stream mapper. ``self.stream`` is the single + source of truth (it set the request's ``stream=`` flag), so the response + type always matches it.""" + if self.stream: + yield from self._iter_stream_events(api_response) + else: + yield from self._iter_response_events(api_response) + + @abstractmethod + def _build_optional_kwargs(self, req_tools: Any, req_tool_choice: Any) -> dict[str, Any]: + """Build the per-request tools/tool_choice kwargs in the backend's shape.""" + ... + + # ── speculative-turn / cancellation gating ───────────────────────────────── + + def _turn_is_latest(self, turn_id: str | None, turn_revision: int | None) -> bool: + return self.speculative_turns is None or self.speculative_turns.is_latest(turn_id, turn_revision) + + def _generation_is_stale(self, gen: int | None) -> bool: + return gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen) + + def _turn_output_allowed(self, turn_id: str | None, turn_revision: int | None) -> bool: + if self.speculative_turns is None: + return True + return self.speculative_turns.is_latest_after_reopen_grace(turn_id, turn_revision) + + def _apply_config( + self, + chat: Chat, + instructions: Optional[str], + wants_audio: bool = True, + ) -> None: + if instructions: + builder = build_voice_system_prompt if wants_audio else build_text_system_prompt + full_instructions = builder(instructions) + chat.add_item(make_system_message(full_instructions)) + + # ── output helpers ──────────────────────────────────────────────────────-- + + def _chunk( + self, + turn: _Turn, + *, + text: str = "", + tools: list[ResponseFunctionToolCall] | None = None, + language_code: Optional[str] = None, + ) -> LLMResponseChunk: + return LLMResponseChunk( + text=text, + language_code=language_code if language_code is not None else turn.language_code, + tools=tools or [], + runtime_config=turn.runtime_config, + response=turn.response, + turn_id=turn.turn_id, + turn_revision=turn.turn_revision, + speech_stopped_at_s=turn.speech_stopped_at_s, + cancel_generation=turn.gen, + ) + + def _record_tool_call(self, state: _GenState, turn: _Turn, item: ResponseFunctionToolCall) -> Iterator[LLMOut]: + """Emit a tool call, persisting it (and any assistant text seen so far) + to history *before* it is forwarded to the client. + + The function_call must already exist in the conversation by the time the + client returns its ``function_call_output``; otherwise a fast client + races ahead of the deferred end-of-turn write-back and the output is + rejected ("No function_call with call_id ... found"), which makes the + model re-issue the same tool call. The call lands in ``_pending_tool_calls`` + (not serialized until its output pairs it), so eager recording is safe. + + Out-of-band turns never touch the default conversation, and a stale turn + records nothing (it is not forwarded to the client either).""" + state.tools.append(item) + fc_item = RealtimeConversationItemFunctionCall( + type="function_call", + name=item.name, + arguments=item.arguments, + call_id=item.call_id, + id=item.id, + status=item.status, + ) + if self._generation_is_stale(turn.gen) or not self._turn_output_allowed(turn.turn_id, turn.turn_revision): + logger.info("LLM generation cancelled (stale speculative turn)") + return + if not is_out_of_band(turn.response): + # Flush assistant text accumulated before this call first (so history + # order matches what the client received), then persist the call — + # all before the chunk leaves for the client. + chat = turn.runtime_config.chat + for pending_item in state.pending: + chat.add_item(pending_item) + state.pending.clear() + chat.add_item(fc_item) + yield self._chunk(turn, tools=[item]) + + # ── consumption ─────────────────────────────────────────────────────────-- + + def _consume_streaming(self, events: Iterator[ProviderEvent], state: _GenState, turn: _Turn) -> Iterator[LLMOut]: + cancelled = False + printable_text = "" + sentence_batch: list[str] = [] + + def _flush(batch: list[str]) -> Iterator[LLMOut]: + if not batch: + return + if not self._turn_output_allowed(turn.turn_id, turn.turn_revision): + logger.info("LLM generation cancelled (stale speculative turn)") + return + yield self._chunk(turn, text=" ".join(batch)) + + for event in events: + if self._generation_is_stale(turn.gen) or not self._turn_is_latest(turn.turn_id, turn.turn_revision): + logger.info("LLM generation cancelled (interruption)") + cancelled = True + break + + if isinstance(event, Usage): + state.input_tokens = event.input_tokens + state.output_tokens = event.output_tokens + elif isinstance(event, AssistantMessage): + state.pending.append( + RealtimeConversationItemAssistantMessage(type="message", role="assistant", content=event.content) + ) + elif isinstance(event, ToolCall): + # Flush any pending spoken text before emitting the tool call. + if printable_text.strip(): + sentence_batch.append(printable_text.strip()) + printable_text = "" + if sentence_batch: + if not self._turn_output_allowed(turn.turn_id, turn.turn_revision): + logger.info("LLM generation cancelled (stale speculative turn)") + cancelled = True + break + yield from _flush(sentence_batch) + sentence_batch = [] + yield from self._record_tool_call(state, turn, event.item) + elif isinstance(event, TextDelta): + if not turn.wants_audio: + # Text-only: forward verbatim. Keep every character (no + # remove_unspeechable, which strips TTS-unfriendly symbols) and + # don't sentence-split (sent_tokenize collapses newlines/markdown). + state.clean_text += event.text + if event.text: + if not self._turn_output_allowed(turn.turn_id, turn.turn_revision): + logger.info("LLM generation cancelled (stale speculative turn)") + cancelled = True + break + yield self._chunk(turn, text=event.text) + continue + new_text = remove_unspeechable(event.text) + state.clean_text += new_text + printable_text += new_text + sentences = sent_tokenize(printable_text) + if len(sentences) > 1: + for s in sentences[:-1]: + sentence_batch.append(s) + if len(sentence_batch) >= self.stream_batch_sentences: + if not self._turn_output_allowed(turn.turn_id, turn.turn_revision): + logger.info("LLM generation cancelled (stale speculative turn)") + cancelled = True + break + yield from _flush(sentence_batch) + sentence_batch = [] + if cancelled: + break + printable_text = sentences[-1] + + if not cancelled: + if printable_text.strip(): + sentence_batch.append(printable_text.strip()) + if sentence_batch: + if self._generation_is_stale(turn.gen): + logger.info("LLM generation cancelled (interruption)") + else: + logger.debug(f"Clean text: {state.clean_text}") + yield from _flush(sentence_batch) + logger.info(f"Tools: {state.tools}") + + def _consume_nonstreaming(self, events: Iterator[ProviderEvent], state: _GenState, turn: _Turn) -> Iterator[LLMOut]: + if self._generation_is_stale(turn.gen) or not self._turn_is_latest(turn.turn_id, turn.turn_revision): + logger.info("LLM generation cancelled (interruption)") + return + for event in events: + if isinstance(event, Usage): + state.input_tokens = event.input_tokens + state.output_tokens = event.output_tokens + elif isinstance(event, AssistantMessage): + state.pending.append( + RealtimeConversationItemAssistantMessage(type="message", role="assistant", content=event.content) + ) + elif isinstance(event, ToolCall): + yield from self._record_tool_call(state, turn, event.item) + elif isinstance(event, TextDelta): + # Text-only keeps every character verbatim; audio strips + # TTS-unfriendly symbols via remove_unspeechable. + spoken = event.text if not turn.wants_audio else remove_unspeechable(event.text) + state.clean_text += spoken + out = spoken if not turn.wants_audio else spoken.strip() + if ( + out + and not self._generation_is_stale(turn.gen) + and self._turn_output_allowed(turn.turn_id, turn.turn_revision) + ): + yield self._chunk(turn, text=out) + logger.debug(f"Clean text: {state.clean_text}") + logger.info(f"Tools: {state.tools}") + + # ── orchestration ───────────────────────────────────────────────────────── + + def _generate( + self, + active_chat: Chat, + original_chat: Chat, + turn: _Turn, + optional_kwargs: dict[str, Any], + ) -> Iterator[LLMOut]: + api_response: Any = None + state = _GenState() + error_message: str | None = None + api_input = self._serialize(active_chat) + # Images the model actually sees this turn; only these are stripped on + # write-back, so an image a fast client injects mid-generation for the + # next turn survives (it is not in this serialized snapshot). + consumed_image_ids = active_chat.image_message_ids() + if not api_input: + # Nothing to send: empty `instructions` and no `input` (in the response, + # the default conversation, or the out-of-band context). The provider + # would reject this; fail with a clear message instead of an opaque error. + error_message = "Cannot generate a response: no instructions and no input were provided." + + try: + if error_message is None: + api_response = self._request(api_input, optional_kwargs) + if api_response is not None: + events = self._iter_events(api_response) + if self.stream: + yield from self._consume_streaming(events, state, turn) + else: + yield from self._consume_nonstreaming(events, state, turn) + except httpx.ReadTimeout: + logger.warning( + "OpenAI API read timed out after %.1fs; ending the current response", + self.request_timeout_s, + ) + if not self._generation_is_stale(turn.gen) and self._turn_output_allowed(turn.turn_id, turn.turn_revision): + # Canned apology carries no language_code (mirrors the prior handlers). + yield LLMResponseChunk( + text="Wow I'm a bit slow today, could you repeat that?", + runtime_config=turn.runtime_config, + response=turn.response, + turn_id=turn.turn_id, + turn_revision=turn.turn_revision, + speech_stopped_at_s=turn.speech_stopped_at_s, + cancel_generation=turn.gen, + ) + except Exception as exc: + # Any other generation failure must still terminate the response: record + # the error and fall through to the EndOfResponse below. Without this the + # exception would escape process() and no EndOfResponse would be emitted, + # leaving st.in_response stuck and locking every subsequent response. + logger.exception("LLM generation failed; ending the current response") + if error_message is None: + error_message = f"Language model generation failed: {exc}" + finally: + if api_response is not None and hasattr(api_response, "close"): + try: + api_response.close() + except Exception: + pass + + if ( + error_message is None + and not self._generation_is_stale(turn.gen) + and self._turn_output_allowed(turn.turn_id, turn.turn_revision) + ): + # Out-of-band responses emit output and usage but never write back to the + # default conversation (their context was a throwaway chat). + if not is_out_of_band(turn.response): + # Tool calls (and any assistant text preceding them) were already + # written eagerly in _record_tool_call; only trailing items remain. + for item in state.pending: + original_chat.add_item(item) + original_chat.strip_images(consumed_image_ids) + original_chat.trim_if_needed(self.compactor) + if state.input_tokens or state.output_tokens: + yield TokenUsage( + input_tokens=state.input_tokens, + output_tokens=state.output_tokens, + turn_id=turn.turn_id, + turn_revision=turn.turn_revision, + ) + yield EndOfResponse( + turn_id=turn.turn_id, turn_revision=turn.turn_revision, cancel_generation=turn.gen, error=error_message + ) + + def process(self, request: LLMIn) -> Iterator[LLMOut]: + """Process a language model request and yield LLMResponseChunks.""" + runtime_config = request.runtime_config + response = request.response + turn_id = request.turn_id + turn_revision = request.turn_revision + speech_stopped_at_s = request.speech_stopped_at_s + if not self._turn_is_latest(turn_id, turn_revision): + logger.info("Skipping stale LLM request for turn=%s rev=%s", turn_id, turn_revision) + yield EndOfResponse(turn_id=turn_id, turn_revision=turn_revision) + return + + original_chat = runtime_config.chat + if is_out_of_band(response): + try: + active_chat = build_active_chat(original_chat, response) + except ChatItemError as exc: + logger.info("Out-of-band response rejected: %s", exc) + yield EndOfResponse(turn_id=turn_id, turn_revision=turn_revision, error=str(exc)) + return + else: + active_chat = original_chat.copy() + language_code = request.language_code + instructions = ( + response.instructions if response and response.instructions else runtime_config.session.instructions + ) or "" + req_tools = response.tools if response and response.tools else runtime_config.session.tools + req_tool_choice = ( + response.tool_choice if response and response.tool_choice else runtime_config.session.tool_choice + ) + wants_audio = response_wants_audio(response) + self._apply_config(active_chat, instructions, wants_audio) + language_code, lang_name = resolve_auto_language(language_code) + if lang_name and self.enable_lang_prompt: + active_chat.add_item(make_user_message(f"Please reply to my message in {lang_name}.")) + + optional_kwargs = self._build_optional_kwargs(req_tools, req_tool_choice) + + # CancelScope.is_stale(gen) is checked when the stream iterator advances; a + # blocked read inside httpx cannot be aborted by cancel_scope.cancel() from + # the websocket router. Mitigations: request_timeout_s / ReadTimeout. + gen = self.cancel_scope.generation if self.cancel_scope else None + + turn = _Turn( + language_code=language_code, + gen=gen, + runtime_config=runtime_config, + response=response, + turn_id=turn_id, + turn_revision=turn_revision, + speech_stopped_at_s=speech_stopped_at_s, + wants_audio=wants_audio, + ) + yield from self._generate(active_chat, original_chat, turn, optional_kwargs) + + @property + def timing_log_level(self) -> int: + return logging.INFO + + def should_log_timing(self, output: LLMOut) -> bool: + return isinstance(output, LLMResponseChunk) and self.last_time > self.min_time_to_debug diff --git a/speech_to_speech/LLM/chat.py b/speech_to_speech/LLM/chat.py new file mode 100644 index 0000000..c78b3b6 --- /dev/null +++ b/speech_to_speech/LLM/chat.py @@ -0,0 +1,760 @@ +from __future__ import annotations + +import json +import logging +import threading +from collections.abc import Callable +from typing import Any, Literal, Union + +from openai.types.realtime import ConversationItem +from openai.types.realtime.conversation_item import ( + RealtimeConversationItemAssistantMessage, + RealtimeConversationItemFunctionCall, + RealtimeConversationItemFunctionCallOutput, + RealtimeConversationItemSystemMessage, + RealtimeConversationItemUserMessage, +) +from openai.types.realtime.realtime_conversation_item_assistant_message import ( + Content as AssistantContent, +) +from openai.types.realtime.realtime_conversation_item_system_message import Content as SystemContent +from openai.types.realtime.realtime_conversation_item_user_message import Content as UserContent +from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams +from openai.types.responses.response_input_image_param import ResponseInputImageParam +from openai.types.responses.response_input_message_content_list_param import ( + ResponseInputMessageContentListParam, +) +from openai.types.responses.response_input_param import ( + FunctionCallOutput, + ResponseFunctionToolCallParam, + ResponseInputItemParam, + ResponseInputParam, + ResponseOutputMessageParam, +) +from openai.types.responses.response_input_param import ( + Message as ResponseMessage, +) +from openai.types.responses.response_input_text_param import ResponseInputTextParam +from openai.types.responses.response_output_text_param import ResponseOutputTextParam +from pydantic import BaseModel + +from speech_to_speech.utils.utils import _generate_id + +logger = logging.getLogger(__name__) + + +class ChatItemError(Exception): + """Raised when a conversation item fails validation in :meth:`Chat.add_item`.""" + + +class CompactionResult(BaseModel): + """Output of a :data:`CompactFn` summarization run.""" + + user_summary: str + assistant_summary: str + + +def _ensure_id(value: str | None, prefix: str) -> str: + if value is None: + return _generate_id(prefix) + if not value.startswith(f"{prefix}_"): + raise ChatItemError(f"ID must start with '{prefix}_', got {value!r}") + return value + + +SupportedItem = Union[ + RealtimeConversationItemSystemMessage, + RealtimeConversationItemUserMessage, + RealtimeConversationItemAssistantMessage, + RealtimeConversationItemFunctionCall, + RealtimeConversationItemFunctionCallOutput, +] + + +CompactFn = Callable[[ResponseInputParam], CompactionResult] + + +class Chat: + """Manages conversation history with bounded size to avoid OOM issues. + + The buffer stores ``ConversationItem`` objects (user messages, assistant + messages, function calls, function call outputs). System messages are + stored separately in ``init_chat_message`` and never placed in the buffer. + + History bounding is decided per ``add_item`` call via the ``compactor`` + argument: + + - ``compactor=None``: when the user-turn count exceeds ``size`` the oldest + complete turn is evicted in place. Synchronous, lossy, no LLM involvement. + - ``compactor=``: when ``size`` is exceeded, ``fn`` is invoked in a + background thread to summarize older turns into a single user/assistant + pair (with pending function calls preserved). Single-flight: while a + compaction is running, additional triggers are silently bypassed. + """ + + def __init__(self, size: int) -> None: + self.size = size + self.init_chat_message: RealtimeConversationItemSystemMessage | None = None + # ``size`` is the number of user turns to keep. When exceeded the + # oldest complete turn (everything up to the next user message) + # is evicted -- or, with a compactor, summarized in the background. + self.buffer: list[SupportedItem] = [] + self._pending_tool_calls: dict[str, RealtimeConversationItemFunctionCall] = {} + self._user_turn_count: int = 0 + + # All state mutations and serializations go through _lock. Public methods + # acquire it once; internal callers that already hold it use the + # ``_locked`` helpers, so no reentry is needed (regular Lock is safe). + self._lock = threading.Lock() + self._compact_in_flight: bool = False + self._compact_thread: threading.Thread | None = None + self._shutdown = threading.Event() + self._gen_counter = 0 + + # ── Internal mutators (caller holds _lock) ───────────────── + + def _evict_oldest_turn(self) -> None: + """Remove items from the front until the next user message boundary.""" + if not self.buffer: + return + first = self.buffer.pop(0) + if isinstance(first, RealtimeConversationItemUserMessage): + self._user_turn_count -= 1 + while self.buffer and not isinstance(self.buffer[0], RealtimeConversationItemUserMessage): + self.buffer.pop(0) + + def _has_call_id_in_buffer(self, call_id: str) -> bool: + for entry in self.buffer: + if isinstance(entry, RealtimeConversationItemFunctionCall) and entry.call_id == call_id: + return True + return False + + def _mark_call_completed( + self, call_id: str, status: Literal["completed", "incomplete", "in_progress"] | None = None + ) -> None: + """Set ``status`` to ``"completed"`` on the matching function_call.""" + for entry in self.buffer: + if isinstance(entry, RealtimeConversationItemFunctionCall) and entry.call_id == call_id: + entry.status = "completed" if status is None else status + return + + def append_tool_output(self, call_id: str, output_item: RealtimeConversationItemFunctionCallOutput) -> None: + """Append a ``function_call_output``, re-injecting its ``function_call`` if evicted. + + Also marks the paired ``function_call`` as ``"completed"`` if its + status was ``None``. + + Raises :class:`ChatItemError` if *call_id* is unknown. + """ + with self._lock: + self._append_tool_output_locked(call_id, output_item) + + def _append_tool_output_locked(self, call_id: str, output_item: RealtimeConversationItemFunctionCallOutput) -> None: + """Body of :meth:`append_tool_output`. Caller must hold ``_lock``.""" + if self._has_call_id_in_buffer(call_id): + self._pending_tool_calls.pop(call_id, None) + self._mark_call_completed(call_id, output_item.status) + self.buffer.append(output_item) + return + + if call_id in self._pending_tool_calls: + logger.info("Re-injecting evicted function_call for call_id=%s", call_id) + fc = self._pending_tool_calls.pop(call_id) + fc.status = "completed" if output_item.status is None else output_item.status + self.buffer.append(fc) + self.buffer.append(output_item) + return + + raise ChatItemError(f"No function_call with call_id '{call_id}' found in conversation history.") + + def init_chat(self, message: RealtimeConversationItemSystemMessage) -> None: + with self._lock: + self.init_chat_message = message + + def add_item(self, item: SupportedItem) -> SupportedItem: + """Validate and route a conversation item into the chat buffer. + + Does not enforce the soft size limit — call :meth:`trim_if_needed` + explicitly after each successful generation to evict or compact old + turns. A hard upper bound at ``2 * size`` is enforced inline as a + runaway-client safety net: if the user-turn count exceeds it, the + oldest complete turn is evicted (lossy, no compaction). + + Raises :class:`ChatItemError` if the item fails validation. + """ + with self._lock: + if isinstance(item, RealtimeConversationItemSystemMessage): + item.id = _ensure_id(item.id, "sys") + self.init_chat_message = item + logger.debug("Set system message via conversation item") + + elif isinstance(item, RealtimeConversationItemUserMessage): + item.id = _ensure_id(item.id, "msg") + item.content = [ + p + for p in item.content + if (p.type == "input_text" and p.text) or (p.type == "input_image" and p.image_url) + ] + if not item.content: + raise ChatItemError( + "Message has no supported content. Supported modalities: input_text, input_image." + ) + self.buffer.append(item) + self._user_turn_count += 1 + logger.debug("Added user message to chat (%d parts)", len(item.content)) + + elif isinstance(item, RealtimeConversationItemAssistantMessage): + item.id = _ensure_id(item.id, "msg") + item.content = [p for p in item.content if p.type == "output_text" and p.text] + if not item.content: + return item + self.buffer.append(item) + logger.debug("Added assistant message to chat (%d parts)", len(item.content)) + + elif isinstance(item, RealtimeConversationItemFunctionCall): + item.id = _ensure_id(item.id, "fc") + item.call_id = _ensure_id(item.call_id, "call") + self._pending_tool_calls[item.call_id] = item + logger.debug("Added function_call to chat (call_id=%s)", item.call_id) + + elif isinstance(item, RealtimeConversationItemFunctionCallOutput): + item.id = _ensure_id(item.id, "fco") + self._append_tool_output_locked(item.call_id, item) + logger.debug("Added function_call_output to chat (call_id=%s)", item.call_id) + + else: + raise ChatItemError(f"Unsupported item type: {getattr(item, 'type', None)}") + + if self.size > 0 and self._user_turn_count > 2 * self.size: + logger.warning( + "Chat buffer exceeded hard cap (%d > 2 * size=%d); evicting oldest turn", + self._user_turn_count, + self.size, + ) + while self._user_turn_count > 2 * self.size: + self._evict_oldest_turn() + + return item + + def trim_if_needed(self, compactor: CompactFn | None = None) -> None: + """Enforce the size limit after a generation completes. Fires when + ``user_turn_count > size``. + + - ``compactor=None``: synchronous eviction of the oldest complete turn. + - ``compactor=``: launch a background compaction (single-flight). + + Call once after each successful generation, not inside :meth:`add_item`. + """ + with self._lock: + if self._user_turn_count <= self.size: + return + if compactor is not None: + self._maybe_trigger_compaction(compactor) + else: + while self._user_turn_count > self.size: + self._evict_oldest_turn() + + def replace_user_message_text(self, item_id: str, text: str) -> bool: + """Replace the text content of an existing user message. + + Used by speculative turn revisions: the conversation turn remains the + same, but the STT transcript is superseded by a transcription of a + longer raw-audio buffer. + """ + + with self._lock: + for item in self.buffer: + if not isinstance(item, RealtimeConversationItemUserMessage) or item.id != item_id: + continue + item.content = [UserContent(type="input_text", text=text)] + logger.debug("Replaced speculative user message %s", item_id) + return True + return False + + def remove_user_message(self, item_id: str) -> bool: + """Remove an existing user message from the bounded chat buffer.""" + + with self._lock: + for index, item in enumerate(self.buffer): + if not isinstance(item, RealtimeConversationItemUserMessage) or item.id != item_id: + continue + del self.buffer[index] + self._user_turn_count -= 1 + logger.debug("Removed speculative user message %s", item_id) + return True + return False + + def to_responses_api_chat(self, items: list[SupportedItem] | None = None) -> ResponseInputParam: + """Serialize the chat (system prompt + buffer) for the OpenAI Responses API. + + If *items* is provided, serialize that slice instead of the live buffer + (used by the compaction snapshot). + """ + with self._lock: + return self._to_responses_api_chat_locked(items if items is not None else self.buffer) + + def _to_responses_api_chat_locked(self, items: list[SupportedItem]) -> ResponseInputParam: + """Body of :meth:`to_responses_api_chat`. Caller must hold ``_lock``.""" + buffer_items = list(items) + result: list[ResponseInputItemParam] = [] + if self.init_chat_message: + result.append( + ResponseMessage( + content=[ + ResponseInputTextParam(text=p.text or "A helpful AI assistant.", type="input_text") + for p in self.init_chat_message.content + ], + role="system", + type="message", + ) + ) + for item in buffer_items: + assert item.id is not None and item.id != "", f"item.id is {item.id}" + if isinstance(item, RealtimeConversationItemUserMessage): + content: ResponseInputMessageContentListParam = [] + for user_part in item.content: + if user_part.type == "input_text" and user_part.text is not None: + content.append(ResponseInputTextParam(text=user_part.text or "", type="input_text")) + elif user_part.type == "input_image" and user_part.image_url is not None: + img = ResponseInputImageParam(type="input_image", detail=user_part.detail or "auto") + if user_part.image_url is not None: + img["image_url"] = user_part.image_url + content.append(img) + if content: + result.append(ResponseMessage(content=content, role="user", type="message")) + elif isinstance(item, RealtimeConversationItemAssistantMessage): + assistant_content: list[ResponseOutputTextParam] = [] + for assistant_part in item.content: + if assistant_part.type == "output_text" and assistant_part.text is not None: + assistant_content.append( + ResponseOutputTextParam(text=assistant_part.text, type="output_text", annotations=[]) + ) + if assistant_content: + result.append( + ResponseOutputMessageParam( + id=item.id, + content=assistant_content, + role="assistant", + status=item.status or "completed", + type="message", + ) + ) + elif isinstance(item, RealtimeConversationItemFunctionCall) and item.call_id is not None: + assert item.call_id is not None and item.call_id != "" + function_call = ResponseFunctionToolCallParam( + arguments=item.arguments, + call_id=item.call_id, + name=item.name, + type="function_call", + id=item.id, + ) + if item.id is not None: + function_call["id"] = item.id + if item.status is not None: + function_call["status"] = item.status + result.append(function_call) + elif isinstance(item, RealtimeConversationItemFunctionCallOutput): + function_call_output = FunctionCallOutput( + call_id=item.call_id, + output=item.output, + type="function_call_output", + ) + if item.id is not None: + function_call_output["id"] = item.id + if item.status is not None: + function_call_output["status"] = item.status + result.append(function_call_output) + return result + + def to_transformers_chat(self) -> list[dict[str, Any]]: + """Serialize the full chat for HuggingFace transformers ``apply_chat_template``. + + User messages with only text produce a plain string ``content`` value. + User messages containing images keep ``content`` as a list of dicts so + VLM pipelines can process them. + """ + with self._lock: + messages: list[TransformersChatMessage] = [] + if self.init_chat_message: + text = " ".join(p.text for p in self.init_chat_message.content if p.text) + messages.append(TransformersSystemMessage(content=text)) + for item in self.buffer: + if isinstance(item, RealtimeConversationItemUserMessage): + has_images = any(p.type == "input_image" for p in item.content) + if has_images: + messages.append( + TransformersUserMessage(content=[p.model_dump(exclude_none=True) for p in item.content]) + ) + else: + text = " ".join(p.text for p in item.content if p.type == "input_text" and p.text) + messages.append(TransformersUserMessage(content=text)) + elif isinstance(item, RealtimeConversationItemAssistantMessage): + text = " ".join(p.text for p in item.content if p.text) + messages.append(TransformersAssistantMessage(content=text)) + elif isinstance(item, RealtimeConversationItemFunctionCall): + assert item.call_id is not None and item.call_id != "" + args: Any = item.arguments + try: + args = json.loads(args) if isinstance(args, str) else args + except (json.JSONDecodeError, TypeError): + args = {} + messages.append( + TransformersFunctionCallMessage( + tool_calls=[ + TransformersToolCall( + id=item.call_id, + function=TransformersToolCallFunction(name=item.name, arguments=args), + ) + ] + ) + ) + elif isinstance(item, RealtimeConversationItemFunctionCallOutput): + name = "" + for prev in reversed(messages): + if isinstance(prev, TransformersFunctionCallMessage): + for tc in prev.tool_calls: + if tc.id == item.call_id: + name = tc.function.name + break + if name: + break + messages.append( + TransformersToolMessage( + tool_call_id=item.call_id, + name=name, + content=item.output, + ) + ) + return [m.model_dump() for m in messages] + + def copy(self) -> Chat: + """Return a shallow snapshot safe for concurrent read access.""" + with self._lock: + clone = Chat(self.size) + clone.init_chat_message = self.init_chat_message + clone.buffer = list(self.buffer) + clone._pending_tool_calls = dict(self._pending_tool_calls) + clone._user_turn_count = self._user_turn_count + return clone + + def reset(self) -> None: + """Clear all conversation state. Cancels any in-flight compaction splice.""" + with self._lock: + self._gen_counter += 1 + self._compact_in_flight = False + self.buffer = [] + self.init_chat_message = None + self._pending_tool_calls = {} + self._user_turn_count = 0 + + def close(self) -> None: + """Permanently shut down the chat. In-flight compaction splice is suppressed. + + The compaction worker (a daemon thread) is not joined: it may be blocked + in an LLM call. Process exit reaps it. + """ + self._shutdown.set() + with self._lock: + self._gen_counter += 1 + self._compact_in_flight = False + + def image_message_ids(self) -> set[str]: + """IDs of user messages currently carrying ``input_image`` content.""" + with self._lock: + return { + item.id + for item in self.buffer + if isinstance(item, RealtimeConversationItemUserMessage) + and item.id is not None + and any(p.type == "input_image" for p in item.content) + } + + def strip_images(self, only_ids: set[str] | None = None) -> None: + """Remove image content parts from user messages in the buffer. + + Called after appending the assistant response so images don't persist + across turns. With *only_ids*, strip only those message IDs — the images + the just-completed response actually consumed (captured before the + request was sent). This leaves intact an image a fast client injected + mid-generation for the *next* turn, which the current response never saw. + Without *only_ids*, every image is stripped. + """ + with self._lock: + for item in self.buffer: + if isinstance(item, RealtimeConversationItemUserMessage): + if only_ids is not None and item.id not in only_ids: + continue + item.content = [p for p in item.content if p.type != "input_image"] + + # ── Compaction internals ────────────────────────────────── + + def _snapshot_for_compaction( + self, + ) -> tuple[ResponseInputParam, set[str], int]: + """Compute the snapshot of items eligible for compaction. + + Caller must hold ``_lock``. Returns + ``(serialized_snapshot, marker_ids, n_turns)``. ``marker_ids`` + identifies the buffer items that may be removed when the splice runs. + Always leaves the most recent user turn untouched (it may be in-flight). + Returns an empty result if there are fewer than 2 compactable turns. + """ + n_turns = max(0, self._user_turn_count - 1) + if n_turns < 2: + return [], set(), n_turns + + # Slice up to (but not including) the (n_turns + 1)-th user message. + user_seen = 0 + end_idx = len(self.buffer) + for i, entry in enumerate(self.buffer): + if isinstance(entry, RealtimeConversationItemUserMessage): + user_seen += 1 + if user_seen == n_turns + 1: + end_idx = i + break + + items_to_compact = self.buffer[:end_idx] + marker_ids = {entry.id for entry in items_to_compact if entry.id is not None} + snapshot = self._to_responses_api_chat_locked(items=items_to_compact) + # Strip image parts so the summarizer doesn't have to handle them. + for raw in snapshot: + if not isinstance(raw, dict) or raw.get("role") != "user": + continue + msg: dict[str, Any] = raw # type: ignore[assignment] + content = msg.get("content") + if isinstance(content, list): + msg["content"] = [c for c in content if not (isinstance(c, dict) and c.get("type") == "input_image")] + return snapshot, marker_ids, n_turns + + def _maybe_trigger_compaction(self, compactor: CompactFn) -> None: + """Start a background compaction worker. Bypass silently if one is running. + + Caller must hold ``_lock``. + """ + if self._shutdown.is_set() or self._compact_in_flight: + return + snapshot, marker_ids, n_turns = self._snapshot_for_compaction() + if n_turns < 2 or not marker_ids: + return + gen = self._gen_counter + self._compact_in_flight = True + thread = threading.Thread( + target=self._compact_worker, + args=(compactor, snapshot, marker_ids, gen), + daemon=True, + name="chat-compact", + ) + self._compact_thread = thread + logger.info( + "Chat compaction triggered: compacting %d turn(s) (%d item(s)), buffer size=%d", + n_turns, + len(marker_ids), + len(self.buffer), + ) + thread.start() + + def _compact_worker( + self, + compactor: CompactFn, + snapshot: ResponseInputParam, + marker_ids: set[str], + gen: int, + ) -> None: + """Worker thread entry point.""" + try: + if self._shutdown.is_set() or self._gen_counter != gen: + return + try: + result = compactor(snapshot) + except Exception: + logger.exception("Chat compaction failed; chat unchanged") + return + if not isinstance(result, CompactionResult): + logger.error("Compactor must return a CompactionResult, got %r", type(result).__name__) + return + if self._shutdown.is_set() or self._gen_counter != gen: + return + self._apply_compaction(result, marker_ids, gen) + finally: + # Don't clobber the flag if reset/close has advanced the gen. + with self._lock: + if self._gen_counter == gen: + self._compact_in_flight = False + + def _apply_compaction( + self, + result: CompactionResult, + marker_ids: set[str], + gen: int, + ) -> None: + """Splice the summary in front of items not consumed by compaction. + + FC/FCO pairing is left entirely to :meth:`add_item` / :meth:`append_tool_output`. + Compaction only drops items; it never inserts an FC into the buffer. + Pending FCs (no FCO yet) stay in ``_pending_tool_calls`` and will be + appended adjacent to their FCO when it arrives. + """ + with self._lock: + if self._shutdown.is_set() or self._gen_counter != gen: + return + # Keep FC if its FCO is outside the compacted range -- otherwise + # the FCO in `remaining` would be orphaned. + fco_call_ids_in_range = { + x.call_id + for x in self.buffer + if isinstance(x, RealtimeConversationItemFunctionCallOutput) and x.id in marker_ids + } + fc_ids_to_keep = { + x.id + for x in self.buffer + if x.id in marker_ids + and isinstance(x, RealtimeConversationItemFunctionCall) + and x.call_id not in fco_call_ids_in_range + } + drop_ids = marker_ids - fc_ids_to_keep + remaining = [x for x in self.buffer if x.id not in drop_ids] + + user_msg = make_user_message(result.user_summary) + user_msg.id = _generate_id("msg") + asst_msg = make_assistant_message(result.assistant_summary) + asst_msg.id = _generate_id("msg") + + self.buffer = [user_msg, asst_msg, *remaining] + self._user_turn_count = sum(1 for x in self.buffer if isinstance(x, RealtimeConversationItemUserMessage)) + logger.info( + "Chat compaction applied: buffer now %d item(s), %d user turn(s)", + len(self.buffer), + self._user_turn_count, + ) + + +# --------------------------------------------------------------------------- +# Transformers chat message models +# --------------------------------------------------------------------------- + + +class TransformersToolCallFunction(BaseModel): + name: str + arguments: dict[str, Any] + + +class TransformersToolCall(BaseModel): + type: Literal["function"] = "function" + id: str + function: TransformersToolCallFunction + + +class TransformersSystemMessage(BaseModel): + role: Literal["system"] = "system" + content: str + + +class TransformersUserMessage(BaseModel): + role: Literal["user"] = "user" + content: str | list[dict[str, Any]] + + +class TransformersAssistantMessage(BaseModel): + role: Literal["assistant"] = "assistant" + content: str + + +class TransformersFunctionCallMessage(BaseModel): + role: Literal["assistant"] = "assistant" + tool_calls: list[TransformersToolCall] + + +class TransformersToolMessage(BaseModel): + role: Literal["tool"] = "tool" + tool_call_id: str + name: str + content: str + + +TransformersChatMessage = Union[ + TransformersSystemMessage, + TransformersUserMessage, + TransformersAssistantMessage, + TransformersFunctionCallMessage, + TransformersToolMessage, +] + + +# --------------------------------------------------------------------------- +# Factory helpers -- hide verbose constructors behind simple calls +# --------------------------------------------------------------------------- + + +def make_user_message(text: str) -> RealtimeConversationItemUserMessage: + return RealtimeConversationItemUserMessage( + type="message", + role="user", + content=[UserContent(type="input_text", text=text)], + ) + + +def make_assistant_message(text: str) -> RealtimeConversationItemAssistantMessage: + return RealtimeConversationItemAssistantMessage( + type="message", + role="assistant", + content=[AssistantContent(type="output_text", text=text)], + ) + + +def make_system_message(text: str) -> RealtimeConversationItemSystemMessage: + return RealtimeConversationItemSystemMessage( + type="message", + role="system", + content=[SystemContent(type="input_text", text=text)], + ) + + +def add_supported_item(chat: Chat, item: ConversationItem) -> None: + """Narrow a protocol ``ConversationItem`` to a :data:`SupportedItem` and add it to *chat*. + + Raises :class:`ChatItemError` on validation failure or unsupported type. Shared + by the conversation handler (in-band item injection) and the language-model + handlers (seeding an out-of-band response's throwaway chat from ``response.input``). + """ + # call_id on function_call items must be client-supplied: it is referenced later by + # function_call_output items, so we cannot silently generate one here. + if isinstance(item, RealtimeConversationItemFunctionCall) and ( + item.call_id is None or not item.call_id.startswith("call_") + ): + raise ChatItemError("function_call item is missing a call_id. The call_id should start with 'call_'.") + + if isinstance( + item, + ( + RealtimeConversationItemSystemMessage, + RealtimeConversationItemUserMessage, + RealtimeConversationItemAssistantMessage, + RealtimeConversationItemFunctionCall, + RealtimeConversationItemFunctionCallOutput, + ), + ): + chat.add_item(item) + return + + raise ChatItemError(f"Unsupported item type: {getattr(item, 'type', None)}") + + +def build_active_chat(original_chat: Chat, response: RealtimeResponseCreateParams | None) -> Chat: + """Build the chat an *out-of-band* response generates against (caller ensures out-of-band). + + Mirrors the OpenAI realtime semantics for ``input``: + + - ``input is None`` -> a read-only **copy of the default conversation** (the + out-of-band response reads history but never commits back). + - ``input == []`` -> a **fresh, empty chat** (context cleared; only the + system prompt, added later by the handler, will be present). + - ``input == [...]`` -> a **fresh chat seeded** with those items. + + Raises :class:`ChatItemError` if an ``input`` item fails validation. + """ + if response is not None and response.input is not None: + fresh = Chat(original_chat.size) + for item in response.input: + add_supported_item(fresh, item) + return fresh + return original_chat.copy() diff --git a/speech_to_speech/LLM/chat_completions_language_model.py b/speech_to_speech/LLM/chat_completions_language_model.py new file mode 100644 index 0000000..17d2d25 --- /dev/null +++ b/speech_to_speech/LLM/chat_completions_language_model.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import json +import logging +import time +from collections.abc import Iterator +from typing import Any, cast + +from openai import Stream +from openai.types.chat import ( + ChatCompletionChunk, + ChatCompletionContentPartImageParam, + ChatCompletionContentPartParam, + ChatCompletionContentPartTextParam, + ChatCompletionNamedToolChoiceParam, + ChatCompletionToolChoiceOptionParam, + ChatCompletionToolParam, +) +from openai.types.chat.chat_completion_content_part_image_param import ImageURL +from openai.types.chat.chat_completion_named_tool_choice_param import Function as NamedToolChoiceFunction +from openai.types.realtime.realtime_conversation_item_assistant_message import ( + Content as AssistantContent, +) +from openai.types.responses import ResponseFunctionToolCall +from openai.types.shared_params import FunctionDefinition + +from speech_to_speech.LLM.base_openai_compatible_language_model import ( + WARMUP_MAX_RETRIES, + AssistantMessage, + BaseOpenAICompatibleHandler, + ProviderEvent, + TextDelta, + ToolCall, + Usage, +) +from speech_to_speech.LLM.chat import Chat +from speech_to_speech.LLM.compaction_prompt import CompactGenerateFn +from speech_to_speech.utils.utils import _generate_id + +logger = logging.getLogger(__name__) + + +def _to_chat_tools(req_tools: Any) -> list[ChatCompletionToolParam] | None: + """Convert Responses-API function tools to Chat-Completions tool format. + + Responses tools are flat ``{type:"function", name, description, parameters}``; + Chat Completions nests them under a ``function`` key. Items already in the + nested form (or non-function tools) are passed through untouched. + """ + if not req_tools: + return None + chat_tools: list[ChatCompletionToolParam] = [] + for t in req_tools: + d = t if isinstance(t, dict) else t.model_dump(exclude_none=True) + if d.get("type") == "function" and "function" not in d: + fn = FunctionDefinition(name=d["name"]) + if d.get("description") is not None: + fn["description"] = d["description"] + if d.get("parameters") is not None: + fn["parameters"] = d["parameters"] + chat_tools.append(ChatCompletionToolParam(type="function", function=fn)) + else: + chat_tools.append(cast("ChatCompletionToolParam", d)) + return chat_tools + + +def _to_chat_tool_choice(tool_choice: Any) -> ChatCompletionToolChoiceOptionParam: + """Convert a Responses-API tool_choice to Chat-Completions form. + + The string forms ("auto"/"required"/"none") are identical across both APIs; + only the forced-function object differs (flat ``name`` vs nested ``function``). + """ + if isinstance(tool_choice, dict) and tool_choice.get("type") == "function" and "name" in tool_choice: + return ChatCompletionNamedToolChoiceParam( + type="function", function=NamedToolChoiceFunction(name=tool_choice["name"]) + ) + if tool_choice is not None and not isinstance(tool_choice, (str, dict)): + d = tool_choice.model_dump(exclude_none=True) + if d.get("type") == "function" and "name" in d: + return ChatCompletionNamedToolChoiceParam(type="function", function=NamedToolChoiceFunction(name=d["name"])) + return cast("ChatCompletionToolChoiceOptionParam", d) + return cast("ChatCompletionToolChoiceOptionParam", tool_choice) + + +class ChatCompletionsApiModelHandler(BaseOpenAICompatibleHandler): + """LLM handler that talks to an OpenAI-compatible ``/v1/chat/completions`` server. + + Functionally mirrors :class:`ResponsesApiModelHandler` but uses the mature + Chat Completions streaming tool-call protocol (``choices[].delta.tool_calls``) + instead of ``/v1/responses``. This is the robust path for vLLM + Qwen tool + calling. The conversation is serialised with :meth:`Chat.to_transformers_chat`, + which already emits OpenAI chat messages including ``tool_calls``/``tool`` roles. + """ + + def warmup(self) -> None: + logger.info(f"Warming up {self.__class__.__name__}") + start = time.time() + self.client.with_options(max_retries=WARMUP_MAX_RETRIES).chat.completions.create( + model=self.model_name, + messages=[ + {"role": "system", "content": "You are a helpful assistant"}, + {"role": "user", "content": "Hello"}, + ], + extra_body=self._extra_body, + timeout=self.request_timeout, + ) + end = time.time() + logger.info(f"{self.__class__.__name__}: warmed up! time: {(end - start):.3f} s") + + def _build_compaction_generate_fn(self) -> CompactGenerateFn: + """Return a generate fn that calls Chat Completions for compaction.""" + client = self.client + model_name = self.model_name + timeout = self.request_timeout + extra_body = self._extra_body + + def generate(system: str, user: str) -> str: + response = client.chat.completions.create( + model=model_name, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + extra_body=extra_body, + timeout=timeout, + ) + return response.choices[0].message.content or "" + + return generate + + @staticmethod + def _to_chat_content_part(part: dict[str, Any]) -> ChatCompletionContentPartParam: + """Convert one transformers content part to Chat-Completions shape. + + ``to_transformers_chat`` keeps Realtime-style parts (``input_text`` / + ``input_image`` with a bare-string ``image_url``). The Chat Completions + HTTP API instead wants ``{type:"text", text}`` and + ``{type:"image_url", image_url:{url, detail}}``. Unknown parts pass through. + """ + ptype = part.get("type") + if ptype == "input_text": + return ChatCompletionContentPartTextParam(type="text", text=part.get("text") or "") + if ptype == "input_image": + raw_url: Any = part.get("image_url") + if isinstance(raw_url, dict): + image_url = cast("ImageURL", raw_url) + else: + image_url = ImageURL(url=raw_url) + detail = part.get("detail") + if detail is not None: + image_url["detail"] = detail + return ChatCompletionContentPartImageParam(type="image_url", image_url=image_url) + return cast("ChatCompletionContentPartParam", part) + + @classmethod + def _chat_messages(cls, chat: Chat) -> list[dict[str, Any]]: + """Serialise the chat for the Chat Completions API. + + ``Chat.to_transformers_chat`` targets HuggingFace ``apply_chat_template``, + so two shapes need fixing up for the OpenAI Chat Completions HTTP API: + tool-call ``arguments`` must be a JSON *string* (not a parsed object), and + multimodal ``content`` parts must use the Chat Completions ``text`` / + ``image_url`` shape rather than the Realtime ``input_text`` / + ``input_image`` shape. + """ + messages = chat.to_transformers_chat() + for message in messages: + for tool_call in message.get("tool_calls") or []: + fn = tool_call.get("function") + if fn is not None and not isinstance(fn.get("arguments"), str): + fn["arguments"] = json.dumps(fn.get("arguments") or {}, ensure_ascii=False) + content = message.get("content") + if isinstance(content, list): + message["content"] = [cls._to_chat_content_part(p) for p in content] + if message.get("role") == "tool": + message.pop("name", None) + return messages + + # ── base hooks ──────────────────────────────────────────────────────────-- + + def _serialize(self, active_chat: Chat) -> list[dict[str, Any]]: + return self._chat_messages(active_chat) + + def _build_optional_kwargs(self, req_tools: Any, req_tool_choice: Any) -> dict[str, Any]: + optional_kwargs: dict[str, Any] = {} + chat_tools = _to_chat_tools(req_tools) + if chat_tools is not None: + optional_kwargs["tools"] = chat_tools + if req_tool_choice is not None: + optional_kwargs["tool_choice"] = _to_chat_tool_choice(req_tool_choice) + return optional_kwargs + + + def _request(self, api_input: list[dict[str, Any]], optional_kwargs: dict[str, Any]) -> Any: + create_kwargs: dict[str, Any] = dict(optional_kwargs) + if self.stream: + create_kwargs["stream_options"] = {"include_usage": True} + + # Svuota i parametri dei tool per evitare il blocco 400 di NVIDIA NIM + create_kwargs.pop("tools", None) + create_kwargs.pop("tool_choice", None) + + return self.client.chat.completions.create( + model=self.model_name, + messages=api_input, # type: ignore[arg-type] # runtime dicts match the Chat Completions message shape + stream=self.stream, + extra_body=self._extra_body, + timeout=self.request_timeout, + **create_kwargs, + ) + +# def _request(self, api_input: list[dict[str, Any]], optional_kwargs: dict[str, Any]) -> Any: +# create_kwargs: dict[str, Any] = dict(optional_kwargs) +# if self.stream: +# create_kwargs["stream_options"] = {"include_usage": True} +# return self.client.chat.completions.create( +# model=self.model_name, +# messages=api_input, # type: ignore[arg-type] # runtime dicts match the Chat Completions message shape +# stream=self.stream, +# extra_body=self._extra_body, +# timeout=self.request_timeout, +# **create_kwargs, +# ) + + def _iter_stream_events(self, api_response: Stream[ChatCompletionChunk]) -> Iterator[ProviderEvent]: + # Accumulate streamed tool-call deltas, keyed by their stream index, and the + # raw assistant text, then emit assistant message + tool calls + usage once + # the stream is exhausted. + tool_accum: dict[int, dict[str, str]] = {} + usage: Usage | None = None + raw_text = "" + for chunk in api_response: + # Usage-only trailing chunk (choices == []) when include_usage is set. + if chunk.usage is not None: + usage = Usage( + input_tokens=chunk.usage.prompt_tokens or 0, output_tokens=chunk.usage.completion_tokens or 0 + ) + if not chunk.choices: + continue + delta = chunk.choices[0].delta + if delta.tool_calls: + for tc in delta.tool_calls: + entry = tool_accum.setdefault(tc.index, {"name": "", "args": "", "id": ""}) + if tc.id: + entry["id"] = tc.id + if tc.function is not None: + if tc.function.name: + entry["name"] = tc.function.name + if tc.function.arguments: + entry["args"] += tc.function.arguments + # A refusal streams as `delta.refusal` with `delta.content` None; + # surface it as assistant text so it is spoken and stored. + text_piece = delta.content or getattr(delta, "refusal", None) + if text_piece: + raw_text += text_piece + yield TextDelta(text=text_piece) + + if raw_text.strip(): + yield AssistantMessage(content=[AssistantContent(type="output_text", text=raw_text)]) + yield from self._tool_calls_from_accum(tool_accum) + if usage is not None: + yield usage + + def _iter_response_events(self, api_response: Any) -> Iterator[ProviderEvent]: + usage = api_response.usage + if usage: + yield Usage(input_tokens=usage.prompt_tokens or 0, output_tokens=usage.completion_tokens or 0) + # A valid-but-empty response (e.g. content filter) returns no choices; + # complete cleanly with no assistant text rather than raising IndexError. + message = api_response.choices[0].message if api_response.choices else None + if message is None: + return + # A refusal arrives as `message.refusal` with `message.content` None; treat + # it as assistant text so it is spoken and stored. + raw_content = message.content or getattr(message, "refusal", None) + if raw_content: + yield AssistantMessage(content=[AssistantContent(type="output_text", text=raw_content)]) + yield TextDelta(text=raw_content) + tool_accum: dict[int, dict[str, str]] = {} + for tc in message.tool_calls or []: + tool_accum[len(tool_accum)] = { + "name": tc.function.name or "", + "args": tc.function.arguments or "", + "id": tc.id or "", + } + yield from self._tool_calls_from_accum(tool_accum) + + @staticmethod + def _tool_calls_from_accum(tool_accum: dict[int, dict[str, str]]) -> Iterator[ToolCall]: + """Turn accumulated tool-call deltas into ToolCall events. + + IDs are regenerated (mirroring the Responses handler) so the rest of the + pipeline pairs each call_id with its function_call_output consistently. + """ + for index in sorted(tool_accum): + entry = tool_accum[index] + if not entry["name"]: + continue + yield ToolCall( + item=ResponseFunctionToolCall( + type="function_call", + name=entry["name"], + arguments=entry["args"] or "{}", + call_id=_generate_id("call"), + id=_generate_id("fc"), + status="completed", + ) + ) + + def on_session_end(self) -> None: + logger.debug("Chat Completions API language model session state reset") diff --git a/speech_to_speech/LLM/compaction_prompt.py b/speech_to_speech/LLM/compaction_prompt.py new file mode 100644 index 0000000..c1fc041 --- /dev/null +++ b/speech_to_speech/LLM/compaction_prompt.py @@ -0,0 +1,181 @@ +"""Prompt template and factory for the conversation compaction (history summarization) function. + +Compaction reduces an unbounded conversation history to a tight user/assistant +summary pair, letting the pipeline continue indefinitely without running out of +context window. The factory :func:`build_compactor` returns a :data:`CompactFn` +compatible with :meth:`~speech_to_speech.LLM.chat.Chat.trim_if_needed`. + +:data:`CompactGenerateFn` is the backend-agnostic generation interface: +``(system_prompt: str, user_prompt: str) -> response_text: str``. +Each handler wraps its own model into this interface and passes it to +:func:`build_compactor`. +""" + +from __future__ import annotations + +import json +import logging +import re +from collections.abc import Callable +from typing import Any + +from speech_to_speech.LLM.chat import CompactFn, CompactionResult + +logger = logging.getLogger(__name__) + +# Callable[[system_prompt, user_prompt], response_text] +CompactGenerateFn = Callable[[str, str], str] + +# --------------------------------------------------------------------------- +# Prompts +# --------------------------------------------------------------------------- + +COMPACTION_SYSTEM_PROMPT = """\ +You are a conversation memory compressor for a real-time voice AI assistant. + +Your task: read a multi-turn conversation and produce a dense summary so the +assistant can continue naturally, as if it remembers everything that was said. + +Output a single JSON object with exactly two string fields: + "user_summary" — 1–5 sentences capturing what the user has been asking + about, any preferences or constraints they have stated, + and where the conversation stands from their perspective. + "assistant_summary" — 1–5 sentences capturing what the assistant has + explained, decided, or done (including tool calls and + their results), plus any open questions or commitments. + +Rules: +- Be information-dense: preserve names, numbers, file paths, error messages, and + other specifics that would be needed to continue the conversation correctly. +- Omit small-talk and filler that carries no forward context. +- Write in third person, past tense + (e.g. "The user asked about…", "The assistant provided…"). +- Emit only the JSON object — no markdown, no code fences, no extra keys.\ +""" + +COMPACTION_USER_TEMPLATE = """\ +Summarize the following conversation. Return only the JSON object. + +--- CONVERSATION START --- +{conversation} +--- CONVERSATION END ---\ +""" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _render_transcript(snapshot: list[Any]) -> str: + """Render a ResponseInputParam snapshot as a readable plain-text transcript.""" + lines: list[str] = [] + for item in snapshot: + if not isinstance(item, dict): + continue + item_type = item.get("type", "message") + role: str = item.get("role", "") + + if role == "system": + continue + + if item_type == "function_call": + name = item.get("name", "") + args = item.get("arguments", "") + lines.append(f"[Tool call: {name}({args})]") + continue + + if item_type == "function_call_output": + out = item.get("output", "") + lines.append(f"[Tool result: {out}]") + continue + + # Regular user / assistant message + content = item.get("content", "") + if isinstance(content, list): + text = " ".join( + c.get("text", "") + for c in content + if isinstance(c, dict) and c.get("type") in ("input_text", "output_text") + ).strip() + elif isinstance(content, str): + text = content.strip() + else: + continue + + if text: + label = role.capitalize() if role else "Unknown" + lines.append(f"{label}: {text}") + + return "\n\n".join(lines) + + +_JSON_BLOCK_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL) + + +def _extract_json(text: str) -> dict[str, Any]: + """Extract the first JSON object from *text*, stripping markdown code fences.""" + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + m = _JSON_BLOCK_RE.search(text) + if m: + return json.loads(m.group(1)) + + start = text.find("{") + end = text.rfind("}") + if start != -1 and end > start: + return json.loads(text[start : end + 1]) + + raise ValueError(f"No JSON object found in compaction response: {text!r}") + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def build_compactor(generate_fn: CompactGenerateFn) -> CompactFn: + """Return a :data:`CompactFn` that summarizes history using *generate_fn*. + + *generate_fn* is the only model-specific dependency: it receives + ``(system_prompt, user_prompt)`` and returns the model's text response. + Both :class:`~speech_to_speech.LLM.openai_api_language_model.OpenApiModelHandler` + and :class:`~speech_to_speech.LLM.language_model.BaseLanguageModelHandler` + subclasses expose a ``_build_compaction_generate_fn()`` method that wraps + their respective backend into this interface. + + The returned callable is safe to call from a background thread. + + Args: + generate_fn: Backend-agnostic text generation callable. + + Returns: + A callable ``(snapshot: ResponseInputParam) -> CompactionResult``. + """ + + def compact(snapshot: list[Any]) -> CompactionResult: + transcript = _render_transcript(snapshot) + if not transcript.strip(): + logger.warning("Compaction called with an empty transcript; returning empty summaries") + return CompactionResult(user_summary="", assistant_summary="") + + user_content = COMPACTION_USER_TEMPLATE.format(conversation=transcript) + raw_text = generate_fn(COMPACTION_SYSTEM_PROMPT, user_content) + + data = _extract_json(raw_text) + user_summary = str(data.get("user_summary", "")).strip() + assistant_summary = str(data.get("assistant_summary", "")).strip() + + if not user_summary or not assistant_summary: + raise ValueError(f"Compaction response missing required fields. Got: {data!r}") + + logger.debug( + "Compaction complete. user=%d chars assistant=%d chars", + len(user_summary), + len(assistant_summary), + ) + return CompactionResult(user_summary=user_summary, assistant_summary=assistant_summary) + + return compact diff --git a/speech_to_speech/LLM/language_model.py b/speech_to_speech/LLM/language_model.py new file mode 100644 index 0000000..4f414cd --- /dev/null +++ b/speech_to_speech/LLM/language_model.py @@ -0,0 +1,1011 @@ +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from collections.abc import Iterable, Iterator, Sized +from queue import Empty +from threading import Lock, Thread +from typing import Any, Literal, Optional, Protocol, runtime_checkable + +import torch +from nltk import sent_tokenize +from openai.types.realtime.realtime_conversation_item_function_call import ( + RealtimeConversationItemFunctionCall, +) +from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams +from openai.types.responses import ResponseFunctionToolCall +from pydantic import BaseModel, ConfigDict, Field +from transformers import ( + AutoModelForCausalLM, + AutoModelForImageTextToText, + AutoProcessor, + AutoTokenizer, + Pipeline, + PreTrainedModel, + PreTrainedTokenizerBase, + StoppingCriteria, + StoppingCriteriaList, + TextIteratorStreamer, + pipeline, +) + +from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig +from speech_to_speech.baseHandler import BaseHandler +from speech_to_speech.LLM.chat import ( + Chat, + ChatItemError, + build_active_chat, + make_assistant_message, + make_system_message, + make_user_message, +) +from speech_to_speech.LLM.compaction_prompt import CompactGenerateFn, build_compactor +from speech_to_speech.LLM.text_prompt import build_text_system_prompt +from speech_to_speech.LLM.tool_call.function_call import extract_function_calls_from_text +from speech_to_speech.LLM.tool_call.function_tool import FunctionTool +from speech_to_speech.LLM.tool_call.tool_prompt import END_CODE, ENTER_CODE, build_block_regex, build_tool_system_prompt +from speech_to_speech.LLM.utils import image_url_to_pil, remove_unspeechable, resolve_auto_language +from speech_to_speech.LLM.voice_prompt import build_voice_system_prompt +from speech_to_speech.pipeline.cancel_scope import CancelScope +from speech_to_speech.pipeline.handler_types import LLMIn, LLMOut +from speech_to_speech.pipeline.messages import ( + EndOfResponse, + GenerateResponseRequest, + LLMResponseChunk, + TokenUsage, +) +from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker +from speech_to_speech.utils.utils import is_out_of_band, response_wants_audio + +try: + import mlx.core as mx + from mlx_lm import generate as mlx_generate + from mlx_lm import load as mlx_load + from mlx_lm import stream_generate as mlx_stream_generate + + from speech_to_speech.utils.mlx_lock import MLXLockContext + + HAS_MLX = True +except ImportError: + HAS_MLX = False + +try: + from mlx_vlm import load as mlx_vlm_load + from mlx_vlm import stream_generate as mlx_vlm_stream_generate + + HAS_MLX_VLM = True +except ImportError: + HAS_MLX_VLM = False + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class _Tokenizer(Protocol): + """Minimal interface the base class needs from any tokenizer.""" + + def encode(self, text: str, /, **kwargs: Any) -> list[int] | Sized: ... + + +@runtime_checkable +class _Processor(Protocol): + """Minimal interface the VLM handler needs from any processor.""" + + tokenizer: _Tokenizer + + def apply_chat_template(self, conversation: Any, /, **kwargs: Any) -> Any: ... + def __call__(self, **kwargs: Any) -> Any: ... + + +class _CancelCriteria(StoppingCriteria): + """Stopping criteria that can be signalled externally to abort generation.""" + + def __init__(self) -> None: + self._cancelled = False + + def cancel(self) -> None: + self._cancelled = True + + def reset(self) -> None: + self._cancelled = False + + def __call__(self, input_ids: Any, scores: Any, **kwargs: Any) -> bool: + return self._cancelled + + +class StreamContext(BaseModel): + """Mutable accumulator passed through ``_stream_tokens`` so the caller + can read back generation state after the iterator is exhausted.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + cancelled: bool = False + stopped: bool = False + raw_generated_text: str = "" + generated_text: str = "" + printable_text: str = "" + tools: list[ResponseFunctionToolCall] = Field(default_factory=list) + function_tools: list[FunctionTool] = Field(default_factory=list) + block_regex: Optional[str] = None + enter_code: Optional[str] = None + end_code: Optional[str] = None + input_tokens: int = 0 + sentence_batch: list[str] = Field(default_factory=list) + turn_id: str | None = None + turn_revision: int | None = None + speech_stopped_at_s: float | None = None + cancel_generation: int | None = None + + @property + def interrupted(self) -> bool: + """True when generation ended early for any reason.""" + return self.cancelled or self.stopped + + +class BaseLanguageModelHandler(BaseHandler[LLMIn, LLMOut], ABC): + """Abstract base for text-only and vision language model handlers. + + Holds shared pipeline logic (streaming, tool extraction, chat management) + and delegates model-specific behaviour to two abstract hooks: + ``_load_model`` and ``_generate``. + """ + + _cancel_criteria: _CancelCriteria + streamer: Iterable[str] + tokenizer: _Tokenizer + + def setup( + self, + model_name: str = "Qwen/Qwen3-4B-Instruct-2507", + device: str = "cuda", + torch_dtype: str = "float16", + gen_kwargs: dict[str, Any] = {}, + user_role: str = "user", + cancel_scope: CancelScope | None = None, + speculative_turns: SpeculativeTurnTracker | None = None, + backend: Literal["transformers", "mlx"] = "transformers", + enable_thinking: bool = False, + stream_batch_sentences: int = 3, + enable_lang_prompt: bool = False, + compact_history: bool = False, + **_kwargs: Any, + ) -> None: + self.backend = backend + self.cancel_scope = cancel_scope + self.speculative_turns = speculative_turns + self.device = device + self.model_name = model_name + self.enable_thinking = enable_thinking + self.stream_batch_sentences = max(1, stream_batch_sentences) + self.enable_lang_prompt = enable_lang_prompt + + self._load_model(model_name, device, torch_dtype, gen_kwargs) + + self.user_role = user_role + # Serializes transformers pipe/model.generate calls between the speech + # response path and the background compaction worker. MLX paths use + # MLXLockContext instead. + self._transformers_lock = Lock() + self.compactor = build_compactor(self._build_compaction_generate_fn()) if compact_history else None + + def _turn_is_latest(self, turn_id: str | None, turn_revision: int | None) -> bool: + return self.speculative_turns is None or self.speculative_turns.is_latest(turn_id, turn_revision) + + def _turn_output_allowed(self, turn_id: str | None, turn_revision: int | None) -> bool: + if self.speculative_turns is None: + return True + return self.speculative_turns.is_latest_after_reopen_grace(turn_id, turn_revision) + + @abstractmethod + def _load_model( + self, + model_name: str, + device: str, + torch_dtype: str, + gen_kwargs: dict[str, Any], + ) -> None: + """Load the model, tokenizer, and any backend-specific resources.""" + + def _build_compaction_generate_fn(self) -> CompactGenerateFn: + """Return a ``(system, user) -> text`` callable for compaction. + + Subclasses must override this when ``compact_history=True`` is used. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement _build_compaction_generate_fn. " + "Override it or pass compact_history=False." + ) + + @abstractmethod + def _generate( + self, + chat: Chat, + language_code: Optional[str], + gen: int | None, + ctx: StreamContext, + runtime_config: RuntimeConfig | None = None, + response: RealtimeResponseCreateParams | None = None, + ) -> Iterator[LLMResponseChunk]: + """Run model generation, stream sentence chunks, set ``ctx.input_tokens``.""" + + # ------------------------------------------------------------------ + # Generation lifecycle helpers + # ------------------------------------------------------------------ + + def _finish_mlx_generation(self, token_iter: Any) -> None: + """Close the MLX generator to release resources immediately.""" + token_iter.close() + + def _finish_transformers_generation(self, thread: Thread) -> None: + """Signal the stopping criteria, drain the streamer, and join the thread.""" + self._cancel_criteria.cancel() + try: + for _ in self.streamer: + pass + except Empty: + pass + thread.join(timeout=5.0) + + # ------------------------------------------------------------------ + # Shared helpers + # ------------------------------------------------------------------ + + def _apply_instructions( + self, + chat: Chat, + instructions: Optional[str], + raw_tools: list[Any] | None, + tool_choice: Optional[str], + ctx: StreamContext | None = None, + wants_audio: bool = True, + ) -> None: + if not instructions: + return + + raw_tools = raw_tools or [] + + function_tools = [FunctionTool(**t.model_dump()) for t in raw_tools if t.type == "function"] + + build_system_prompt = build_voice_system_prompt if wants_audio else build_text_system_prompt + + if function_tools and tool_choice != "none": + tool_section = build_tool_system_prompt(function_tools, text_only=not wants_audio) + full_instructions = build_system_prompt(instructions, tool_section=tool_section) + block_regex = build_block_regex() + enter_code = ENTER_CODE + end_code = END_CODE + else: + full_instructions = build_system_prompt(instructions) + block_regex = None + enter_code = None + end_code = None + + chat.add_item(make_system_message(full_instructions)) + + if ctx is not None: + ctx.function_tools = function_tools + ctx.block_regex = block_regex + ctx.enter_code = enter_code + ctx.end_code = end_code + + def _process_printable_text( + self, + printable_text: str, + language_code: Optional[str], + tools: list[ResponseFunctionToolCall], + ctx: StreamContext, + runtime_config: RuntimeConfig | None = None, + response: RealtimeResponseCreateParams | None = None, + ) -> tuple[list[LLMResponseChunk], list[ResponseFunctionToolCall], str]: + """Extract complete code blocks and return complete sentences to yield. + + Returns ``(chunks_to_yield, updated_tools, remaining_printable_text)``. + Each element in *chunks_to_yield* is an :class:`LLMResponseChunk` + ready to be yielded to the downstream pipeline. + + Sentences are accumulated in ``ctx.sentence_batch`` and only yielded + when the batch reaches ``self.stream_batch_sentences``. + """ + chunks: list[LLMResponseChunk] = [] + + if ctx.enter_code and ctx.enter_code in printable_text: + idx = printable_text.index(ctx.enter_code) + before = printable_text[:idx] + code_and_after = printable_text[idx:] + if before.strip(): + for s in sent_tokenize(before): + ctx.sentence_batch.append(s) + if ctx.sentence_batch: + chunks.append( + LLMResponseChunk( + text=" ".join(ctx.sentence_batch), + language_code=language_code, + runtime_config=runtime_config, + response=response, + turn_id=ctx.turn_id, + turn_revision=ctx.turn_revision, + speech_stopped_at_s=ctx.speech_stopped_at_s, + cancel_generation=ctx.cancel_generation, + ) + ) + ctx.sentence_batch = [] + if ctx.block_regex and ctx.end_code and ctx.end_code in code_and_after: + stripped, func_calls = extract_function_calls_from_text( + code_and_after, + ctx.block_regex, + ) + parsed_tools: list[ResponseFunctionToolCall] = [] + for fc in func_calls: + if tools: + logger.warning( + "Skipping extra tool call '%s'; only one tool call is allowed per response", + fc.function_name, + ) + continue + try: + tool_call = fc.to_realtime_function_tool_call(ctx.function_tools) + except ValueError as e: + logger.warning("Skipping invalid tool call: %s", e) + continue + tools.append(tool_call) + parsed_tools.append(tool_call) + if parsed_tools: + chunks.append( + LLMResponseChunk( + text="", + language_code=language_code, + tools=parsed_tools, + runtime_config=runtime_config, + response=response, + turn_id=ctx.turn_id, + turn_revision=ctx.turn_revision, + speech_stopped_at_s=ctx.speech_stopped_at_s, + cancel_generation=ctx.cancel_generation, + ) + ) + printable_text = stripped + else: + printable_text = code_and_after + return chunks, tools, printable_text + + if printable_text and not response_wants_audio(response) and ctx.enter_code is None: + # Text-only with no tool block: stream the raw text immediately. No TTS + # means no need to split into sentences, and sent_tokenize would collapse + # newlines / markdown. Streaming (vs buffering to the end) keeps the + # response interruptible by a new speech turn. + chunks.append( + LLMResponseChunk( + text=printable_text, + language_code=language_code, + runtime_config=runtime_config, + response=response, + turn_id=ctx.turn_id, + turn_revision=ctx.turn_revision, + speech_stopped_at_s=ctx.speech_stopped_at_s, + cancel_generation=ctx.cancel_generation, + ) + ) + return chunks, tools, "" + + if printable_text: + sentences = sent_tokenize(printable_text) + if len(sentences) > 1: + for s in sentences[:-1]: + ctx.sentence_batch.append(s) + if len(ctx.sentence_batch) >= self.stream_batch_sentences: + chunks.append( + LLMResponseChunk( + text=" ".join(ctx.sentence_batch), + language_code=language_code, + runtime_config=runtime_config, + response=response, + turn_id=ctx.turn_id, + turn_revision=ctx.turn_revision, + speech_stopped_at_s=ctx.speech_stopped_at_s, + cancel_generation=ctx.cancel_generation, + ) + ) + ctx.sentence_batch = [] + printable_text = sentences[-1] + + return chunks, tools, printable_text + + def _check_stop(self, gen: int | None, ctx: StreamContext) -> bool: + """Check whether generation should be aborted and mark the reason on *ctx*.""" + if gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen): + ctx.cancelled = True + logger.info("LLM generation cancelled (interruption)") + return True + if not self._turn_is_latest(ctx.turn_id, ctx.turn_revision): + ctx.cancelled = True + logger.info("LLM generation cancelled (stale speculative turn)") + return True + if self.stop_event.is_set(): + ctx.stopped = True + logger.info("LLM generation stopped (shutdown)") + return True + return False + + def _stream_tokens( + self, + token_iter: Iterator[Any], + gen: int | None, + language_code: Optional[str], + ctx: StreamContext, + runtime_config: RuntimeConfig | None = None, + response: RealtimeResponseCreateParams | None = None, + ) -> Iterator[LLMResponseChunk]: + """Consume *token_iter*, accumulate text in *ctx*, yield sentence chunks.""" + # Text-only output keeps every character; only audio strips TTS-unfriendly + # symbols via remove_unspeechable. + wants_audio = response_wants_audio(response) + while True: + try: + token = next(token_iter) + except StopIteration: + break + except Empty: + if self._check_stop(gen, ctx): + break + continue + + if self._check_stop(gen, ctx): + break + + raw_text: str = token.text if hasattr(token, "text") else token + ctx.raw_generated_text += raw_text + clean = raw_text if not wants_audio else remove_unspeechable(raw_text) + ctx.generated_text += clean + ctx.printable_text += clean + chunks, ctx.tools, ctx.printable_text = self._process_printable_text( + ctx.printable_text, + language_code, + ctx.tools, + ctx, + runtime_config, + response, + ) + if chunks and not self._turn_output_allowed(ctx.turn_id, ctx.turn_revision): + ctx.cancelled = True + logger.info("LLM generation cancelled (stale speculative turn)") + break + yield from chunks + + if ctx.sentence_batch and not ctx.interrupted: + if ctx.printable_text.strip(): + ctx.sentence_batch.append(ctx.printable_text.strip()) + ctx.printable_text = "" + if not self._turn_output_allowed(ctx.turn_id, ctx.turn_revision): + ctx.cancelled = True + logger.info("LLM generation cancelled (stale speculative turn)") + return + yield LLMResponseChunk( + text=" ".join(ctx.sentence_batch), + language_code=language_code, + runtime_config=runtime_config, + response=response, + turn_id=ctx.turn_id, + turn_revision=ctx.turn_revision, + speech_stopped_at_s=ctx.speech_stopped_at_s, + cancel_generation=ctx.cancel_generation, + ) + ctx.sentence_batch = [] + + # ------------------------------------------------------------------ + # Main pipeline entry point + # ------------------------------------------------------------------ + + def process(self, request: LLMIn) -> Iterator[LLMOut]: + ctx = StreamContext() + + if not isinstance(request, GenerateResponseRequest): + raise TypeError(f"Unexpected request type: {type(request)}") + + ctx.turn_id = request.turn_id + ctx.turn_revision = request.turn_revision + ctx.speech_stopped_at_s = request.speech_stopped_at_s + if not self._turn_is_latest(ctx.turn_id, ctx.turn_revision): + logger.info("Skipping stale LLM request for turn=%s rev=%s", ctx.turn_id, ctx.turn_revision) + yield EndOfResponse(turn_id=ctx.turn_id, turn_revision=ctx.turn_revision) + return + + runtime_config = request.runtime_config + response = request.response + original_chat = runtime_config.chat + out_of_band = is_out_of_band(response) + if out_of_band: + try: + active_chat = build_active_chat(original_chat, response) + except ChatItemError as exc: + logger.info("Out-of-band response rejected: %s", exc) + yield EndOfResponse(turn_id=ctx.turn_id, turn_revision=ctx.turn_revision, error=str(exc)) + return + else: + active_chat = original_chat.copy() + language_code = request.language_code + instructions = ( + response.instructions if response and response.instructions else runtime_config.session.instructions + ) + tools = response.tools if response and response.tools else runtime_config.session.tools + tool_choice = response.tool_choice if response and response.tool_choice else runtime_config.session.tool_choice + self._apply_instructions( + active_chat, + instructions, + tools, + str(tool_choice) if tool_choice else None, + ctx, + response_wants_audio(response), + ) + language_code, lang_name = resolve_auto_language(language_code) + if lang_name and self.enable_lang_prompt: + active_chat.add_item(make_user_message(f"Please reply to my message in {lang_name}.")) + + gen = self.cancel_scope.generation if self.cancel_scope else None + ctx.cancel_generation = gen + # Images the model sees this turn; only these are stripped on write-back, + # so an image a fast client injects mid-generation for the next turn + # survives (it is not in this serialized snapshot). + consumed_image_ids = active_chat.image_message_ids() + + try: + yield from self._generate(active_chat, language_code, gen, ctx, runtime_config, response) + + if ctx.stopped: + return + + turn_output_allowed = not ctx.cancelled and self._turn_output_allowed(ctx.turn_id, ctx.turn_revision) + # Out-of-band responses still emit output, but never write back to the default + # conversation (their context was a throwaway chat). + commit_allowed = turn_output_allowed and not out_of_band + if commit_allowed: + original_chat.add_item(make_assistant_message(ctx.generated_text)) + if commit_allowed and ctx.tools: + for t in ctx.tools: + original_chat.add_item( + RealtimeConversationItemFunctionCall( + type="function_call", + id=t.id, + call_id=t.call_id, + name=t.name, + arguments=t.arguments, + status=t.status, + ) + ) + if commit_allowed: + original_chat.strip_images(consumed_image_ids) + original_chat.trim_if_needed(self.compactor) + logger.debug("Clean text: %s", ctx.generated_text) + logger.info(f"Tools: {ctx.tools}") + + if turn_output_allowed and ctx.printable_text.strip(): + yield LLMResponseChunk( + text=ctx.printable_text.strip(), + language_code=language_code, + runtime_config=runtime_config, + response=response, + turn_id=ctx.turn_id, + turn_revision=ctx.turn_revision, + speech_stopped_at_s=ctx.speech_stopped_at_s, + cancel_generation=ctx.cancel_generation, + ) + + output_tokens = len(self.tokenizer.encode(ctx.raw_generated_text)) if ctx.raw_generated_text else 0 + if turn_output_allowed and (ctx.input_tokens or output_tokens): + yield TokenUsage( + input_tokens=ctx.input_tokens, + output_tokens=output_tokens, + turn_id=ctx.turn_id, + turn_revision=ctx.turn_revision, + ) + except Exception as exc: + # Any generation failure must still terminate the response. Without this + # the exception would escape process() and no EndOfResponse would be + # emitted, leaving st.in_response stuck and locking every later response. + logger.exception("LLM generation failed; ending the current response") + yield EndOfResponse( + turn_id=ctx.turn_id, + turn_revision=ctx.turn_revision, + cancel_generation=ctx.cancel_generation, + error=f"Language model generation failed: {exc}", + ) + return + yield EndOfResponse( + turn_id=ctx.turn_id, + turn_revision=ctx.turn_revision, + cancel_generation=ctx.cancel_generation, + ) + + def on_session_end(self) -> None: + logger.debug("Language model session state reset") + + +# ====================================================================== +# Text-only LLM +# ====================================================================== + + +class LanguageModelHandler(BaseLanguageModelHandler): + """Text-only language model handler (transformers or MLX backend).""" + + model: PreTrainedModel + tokenizer: PreTrainedTokenizerBase + gen_kwargs: dict + torch_dtype: torch.dtype + pipe: Pipeline + streamer: TextIteratorStreamer + + def setup(self, **kwargs: Any) -> None: # type: ignore[override] + super().setup(**kwargs) + logger.info(f"LLM Backend: {self.backend}") + self.warmup() + + def _load_model(self, model_name: str, device: str, torch_dtype: str, gen_kwargs: dict[str, Any]) -> None: + + logger.info("LLM Language Model Handler setup") + + if self.backend == "mlx": + if not HAS_MLX: + raise ImportError("mlx_lm is required for the 'mlx' backend. Install with: pip install mlx-lm") + self.model, self.tokenizer = mlx_load(model_name) # type: ignore[assignment, misc] + self.gen_kwargs = gen_kwargs + else: + self.torch_dtype = getattr(torch, torch_dtype) + self.tokenizer = AutoTokenizer.from_pretrained(model_name) # type: ignore[assignment] + self.model = AutoModelForCausalLM.from_pretrained( + model_name, torch_dtype=torch_dtype, trust_remote_code=True + ).to(device) # type: ignore[arg-type] + self.pipe = pipeline( # type: ignore[call-overload] + "text-generation", model=self.model, tokenizer=self.tokenizer, device=device + ) + self.streamer = TextIteratorStreamer( + self.tokenizer, # type: ignore[arg-type] + skip_prompt=True, + skip_special_tokens=True, + timeout=1.0, + ) + self._cancel_criteria = _CancelCriteria() + self.gen_kwargs = { + "streamer": self.streamer, + "return_full_text": False, + "stopping_criteria": StoppingCriteriaList([self._cancel_criteria]), + **gen_kwargs, + } + + def _generate( + self, + chat: Chat, + language_code: Optional[str], + gen: int | None, + ctx: StreamContext, + runtime_config: RuntimeConfig | None = None, + response: RealtimeResponseCreateParams | None = None, + ) -> Iterator[LLMResponseChunk]: + chat_messages = chat.to_transformers_chat() + chat_input = self.tokenizer.apply_chat_template(chat_messages, tokenize=True) + ctx.input_tokens += len(chat_input if isinstance(chat_input, list) else chat_input["input_ids"]) # type: ignore[index] + logger.debug("Prompt token count: %d", ctx.input_tokens) + + chat_prompt = self.tokenizer.apply_chat_template( + chat_messages, tokenize=False, add_generation_prompt=True, enable_thinking=False + ) + + if self.backend == "mlx": + with MLXLockContext(handler_name="MLX-LLM", timeout=10.0): + token_iter = mlx_stream_generate( + self.model, # type: ignore[arg-type] + self.tokenizer, # type: ignore[arg-type] + chat_prompt, # type: ignore[arg-type] + max_tokens=self.gen_kwargs["max_new_tokens"], + ) + yield from self._stream_tokens(token_iter, gen, language_code, ctx, runtime_config, response) + self._finish_mlx_generation(token_iter) + try: + mx.clear_cache() + except Exception: + pass + torch.mps.empty_cache() + else: + self._cancel_criteria.reset() + lock = self._transformers_lock + + def _locked_pipe() -> None: + with lock: + self.pipe(chat_prompt, **self.gen_kwargs) + + thread = Thread(target=_locked_pipe) + thread.start() + yield from self._stream_tokens(self.streamer, gen, language_code, ctx, runtime_config, response) + self._finish_transformers_generation(thread) + if self.device == "mps": + torch.mps.empty_cache() + + def _build_compaction_generate_fn(self) -> CompactGenerateFn: + if self.backend == "mlx": + model = self.model + tokenizer = self.tokenizer + max_tokens = 1024 + + def generate_mlx(system: str, user: str) -> str: + messages = [{"role": "system", "content": system}, {"role": "user", "content": user}] + prompt = tokenizer.apply_chat_template( # type: ignore[union-attr] + messages, tokenize=False, add_generation_prompt=True + ) + with MLXLockContext(handler_name="MLX-compact", timeout=10.0): + return mlx_generate(model, tokenizer, prompt=prompt, max_tokens=max_tokens, verbose=False) # type: ignore[arg-type] + + return generate_mlx + else: + pipe = self.pipe + tokenizer = self.tokenizer + gen_kwargs = { + k: v + for k, v in self.gen_kwargs.items() + if k not in ("streamer", "stopping_criteria", "max_new_tokens", "return_full_text") + } + max_new_tokens = 1024 + lock = self._transformers_lock + + def generate_transformers(system: str, user: str) -> str: + messages = [{"role": "system", "content": system}, {"role": "user", "content": user}] + prompt = tokenizer.apply_chat_template( # type: ignore[union-attr] + messages, tokenize=False, add_generation_prompt=True, enable_thinking=False + ) + with lock: + result = pipe(prompt, return_full_text=False, max_new_tokens=max_new_tokens, **gen_kwargs) + return result[0]["generated_text"] # type: ignore[index] + + return generate_transformers + + def warmup(self) -> None: + logger.info(f"Warming up {self.__class__.__name__}") + + dummy_input_text = "Repeat the word 'home'." + dummy_chat = [{"role": self.user_role, "content": dummy_input_text}] + n_steps = 2 + + if self.backend == "mlx": + for _ in range(n_steps): + prompt = self.tokenizer.apply_chat_template(dummy_chat, tokenize=False) + mlx_generate( + self.model, # type: ignore[arg-type] + self.tokenizer, # type: ignore[arg-type] + prompt=prompt, # type: ignore[arg-type] + max_tokens=self.gen_kwargs["max_new_tokens"], + verbose=False, + ) + else: + warmup_gen_kwargs = { + "min_new_tokens": self.gen_kwargs["min_new_tokens"], + "max_new_tokens": self.gen_kwargs["max_new_tokens"], + **self.gen_kwargs, + } + + if self.device == "cuda": + start_event = torch.cuda.Event(enable_timing=True) + end_event = torch.cuda.Event(enable_timing=True) + torch.cuda.synchronize() + start_event.record() + + for _ in range(n_steps): + thread = Thread(target=self.pipe, args=(dummy_chat,), kwargs=warmup_gen_kwargs) + thread.start() + for _ in self.streamer: + pass + + if self.device == "cuda": + end_event.record() + torch.cuda.synchronize() + logger.info( + f"{self.__class__.__name__}: warmed up! time: {start_event.elapsed_time(end_event) * 1e-3:.3f} s" + ) + + +# ====================================================================== +# Vision Language Model (VLM) +# ====================================================================== + + +class VisionLanguageModelHandler(BaseLanguageModelHandler): + """Vision language model handler (transformers or MLX-VLM backend).""" + + model: PreTrainedModel + tokenizer: PreTrainedTokenizerBase + processor: _Processor + gen_kwargs: dict + torch_dtype: torch.dtype + streamer: TextIteratorStreamer + + def setup(self, **kwargs: Any) -> None: # type: ignore[override] + super().setup(**kwargs) + logger.info(f"VLM Backend: {self.backend}") + + def _load_model(self, model_name: str, device: str, torch_dtype: str, gen_kwargs: dict[str, Any]) -> None: + + logger.info("VLM Language Model Handler setup") + + if self.backend == "mlx": + if not HAS_MLX_VLM: + raise ImportError("mlx-vlm is required for MLX VLM models. Install with: pip install mlx-vlm") + self.model, self.processor = mlx_vlm_load(model_name) # type: ignore[assignment] + self.tokenizer = self.processor.tokenizer # type: ignore[assignment] + self.gen_kwargs = gen_kwargs + else: + self.torch_dtype = getattr(torch, torch_dtype) + self.processor = AutoProcessor.from_pretrained(model_name, trust_remote_code=True) # type: ignore[assignment] + self.tokenizer = self.processor.tokenizer # type: ignore[assignment] + self.model = AutoModelForImageTextToText.from_pretrained( + model_name, torch_dtype=self.torch_dtype, trust_remote_code=True + ).to(device) # type: ignore[arg-type] + self.streamer = TextIteratorStreamer( + self.tokenizer, # type: ignore[arg-type] + skip_prompt=True, + skip_special_tokens=True, + timeout=1.0, + ) + self._cancel_criteria = _CancelCriteria() + self.gen_kwargs = gen_kwargs + + def _generate( + self, + chat: Chat, + language_code: Optional[str], + gen: int | None, + ctx: StreamContext, + runtime_config: RuntimeConfig | None = None, + response: RealtimeResponseCreateParams | None = None, + ) -> Iterator[LLMResponseChunk]: + prepared = chat.to_transformers_chat() + if self.backend == "mlx": + images, formatted_prompt = self._prepare_mlx_vlm_inputs(prepared) + ctx.input_tokens += len(self.tokenizer.encode(formatted_prompt)) + logger.debug("MLX VLM prompt token count: %d", ctx.input_tokens) + + with MLXLockContext(handler_name="MLX-VLM", timeout=10.0): + token_iter = mlx_vlm_stream_generate( # type: ignore[arg-type] + self.model, + self.processor, + formatted_prompt, + images or None, + max_tokens=self.gen_kwargs.get("max_new_tokens", 1024), + enable_thinking=self.enable_thinking, + ) + yield from self._stream_tokens(token_iter, gen, language_code, ctx, runtime_config, response) + self._finish_mlx_generation(token_iter) + try: + mx.clear_cache() + except Exception: + pass + torch.mps.empty_cache() + else: + inputs, input_tokens = self._prepare_vlm_inputs(prepared) + ctx.input_tokens += input_tokens + logger.debug("VLM prompt token count: %d", ctx.input_tokens) + + self._cancel_criteria.reset() + generate_kwargs = { + **inputs, + "max_new_tokens": self.gen_kwargs.get("max_new_tokens", 1024), + "streamer": self.streamer, + "stopping_criteria": StoppingCriteriaList([self._cancel_criteria]), + } + lock = self._transformers_lock + + def _locked_generate() -> None: + with lock: + self.model.generate(**generate_kwargs) # type: ignore[union-attr,operator] + + thread = Thread(target=_locked_generate) + thread.start() + yield from self._stream_tokens(self.streamer, gen, language_code, ctx, runtime_config, response) + self._finish_transformers_generation(thread) + if self.device == "mps": + torch.mps.empty_cache() + + def _build_compaction_generate_fn(self) -> CompactGenerateFn: + if self.backend == "mlx": + model = self.model + processor = self.processor + tokenizer = self.tokenizer + max_tokens = 1024 + + def generate_mlx(system: str, user: str) -> str: + messages = [{"role": "system", "content": system}, {"role": "user", "content": user}] + formatted_prompt = processor.apply_chat_template( # type: ignore[union-attr] + messages, tokenize=False, add_generation_prompt=True + ) + with MLXLockContext(handler_name="MLX-VLM-compact", timeout=10.0): + token_iter = mlx_vlm_stream_generate( # type: ignore[arg-type] + model, processor, formatted_prompt, None, max_tokens=max_tokens + ) + return "".join(t.text if hasattr(t, "text") else str(t) for t in token_iter) + + return generate_mlx + else: + model = self.model + processor = self.processor + tokenizer = self.tokenizer + device = self.device + max_new_tokens = 1024 + lock = self._transformers_lock + + def generate_transformers(system: str, user: str) -> str: + messages = [{"role": "system", "content": system}, {"role": "user", "content": user}] + prompt = processor.apply_chat_template( # type: ignore[union-attr] + messages, tokenize=False, add_generation_prompt=True + ) + inputs = processor(text=[prompt], return_tensors="pt", padding=True).to(device) # type: ignore[operator] + input_len = inputs["input_ids"].shape[1] + with lock: + output_ids = model.generate(**inputs, max_new_tokens=max_new_tokens) # type: ignore[union-attr,operator] + return str(tokenizer.decode(output_ids[0][input_len:], skip_special_tokens=True)) # type: ignore[union-attr] + + return generate_transformers + + def _prepare_vlm_inputs(self, chat_messages: list[dict[str, Any]]) -> tuple[Any, int]: + """Build processor inputs for transformers VLM generation. + + Converts Realtime-style content (``input_text``/``input_image``) to the + transformers chat-template format (``text``/``image``) and decodes image + URLs to PIL images. + """ + converted_messages = [] + images = [] + for msg in chat_messages: + content = msg.get("content") + if isinstance(content, list): + parts = [] + for p in content: + if p.get("type") == "input_text": + parts.append({"type": "text", "text": p["text"]}) + elif p.get("type") == "input_image": + pil_img = image_url_to_pil(p["image_url"]) + images.append(pil_img) + parts.append({"type": "image", "image": pil_img}) + converted_messages.append({"role": msg["role"], "content": parts}) + else: + converted_messages.append(msg) + + text_prompt = self.processor.apply_chat_template( + converted_messages, + tokenize=False, + add_generation_prompt=True, + ) + inputs = self.processor( + text=[text_prompt], + images=images if images else None, + return_tensors="pt", + padding=True, + ).to(self.device) + return inputs, len(inputs["input_ids"][0]) + + def _prepare_mlx_vlm_inputs(self, chat_messages: list[dict[str, Any]]) -> tuple[list[Any], str]: + """Build formatted prompt and extract PIL images for MLX VLM generation. + + Converts Realtime-style content (``input_text``/``input_image``) to the + HF chat-template format and uses the processor's own ``apply_chat_template`` + for full multi-turn support. + + Returns ``(images, formatted_prompt)`` ready for ``mlx_vlm.stream_generate``. + """ + images = [] + converted_messages = [] + for msg in chat_messages: + content = msg.get("content") + if isinstance(content, list): + parts = [] + for p in content: + if p.get("type") == "input_text": + parts.append({"type": "text", "text": p["text"]}) + elif p.get("type") == "input_image": + images.append(image_url_to_pil(p["image_url"])) + parts.append({"type": "image"}) + converted_messages.append({"role": msg["role"], "content": parts}) + else: + converted_messages.append(msg) + + formatted_prompt = self.processor.apply_chat_template( + converted_messages, + tokenize=False, + add_generation_prompt=True, + ) + return images, formatted_prompt diff --git a/speech_to_speech/LLM/lm_output_processor.py b/speech_to_speech/LLM/lm_output_processor.py new file mode 100644 index 0000000..fcfb6a7 --- /dev/null +++ b/speech_to_speech/LLM/lm_output_processor.py @@ -0,0 +1,148 @@ +""" +LLM Output Processor + +Intercepts LLM output to: +1. Extract tool calls and send them via text_output_queue +2. Forward clean text to TTS pipeline +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterator +from queue import Queue + +from speech_to_speech.baseHandler import BaseHandler +from speech_to_speech.pipeline.events import AssistantTextEvent, ResponseFailedEvent, TokenUsageEvent +from speech_to_speech.pipeline.handler_types import LLMOut, TTSIn +from speech_to_speech.pipeline.messages import EndOfResponse, LLMResponseChunk, TokenUsage, TTSInput +from speech_to_speech.pipeline.queue_types import TextEventItem +from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker +from speech_to_speech.utils.utils import response_wants_audio + +logger = logging.getLogger(__name__) + + +class LMOutputProcessor(BaseHandler[LLMOut, TTSIn]): + """ + Processes LLM output to extract tool calls and forward clean text to TTS. + + Input: :class:`LLMResponseChunk`, :class:`TokenUsage`, or :class:`EndOfResponse` from LLM + Output: :class:`TTSInput` or :class:`EndOfResponse` to TTS + Side effect: Sends :class:`AssistantTextEvent` / :class:`TokenUsageEvent` to text_output_queue + """ + + def setup( + self, + text_output_queue: Queue[TextEventItem] | None = None, + speculative_turns: SpeculativeTurnTracker | None = None, + ) -> None: + """ + Initialize the processor. + + Args: + text_output_queue: Queue to send text messages and tool calls + """ + self.text_output_queue = text_output_queue + self.speculative_turns = speculative_turns + + def _turn_output_allowed(self, turn_id: str | None, turn_revision: int | None) -> bool: + if self.speculative_turns is None: + return True + return self.speculative_turns.is_latest_after_reopen_grace(turn_id, turn_revision) + + def process(self, lm_output: LLMOut) -> Iterator[TTSIn]: + """ + Process LLM output: send text/tools to WebSocket, forward clean text to TTS. + + Yields: + :class:`TTSInput` or :class:`EndOfResponse` for TTS + """ + if isinstance(lm_output, TokenUsage): + if not self._turn_output_allowed( + lm_output.turn_id, + lm_output.turn_revision, + ): + logger.debug( + "Dropping stale token usage for turn=%s rev=%s", lm_output.turn_id, lm_output.turn_revision + ) + return + if self.text_output_queue is not None: + self.text_output_queue.put( + TokenUsageEvent( + input_tokens=lm_output.input_tokens or 0, + output_tokens=lm_output.output_tokens or 0, + turn_id=lm_output.turn_id, + turn_revision=lm_output.turn_revision, + ) + ) + return + + if isinstance(lm_output, EndOfResponse): + if not self._turn_output_allowed( + lm_output.turn_id, + lm_output.turn_revision, + ): + logger.debug( + "Dropping stale end-of-response for turn=%s rev=%s", + lm_output.turn_id, + lm_output.turn_revision, + ) + return + # A failed generation (e.g. invalid out-of-band input) closes the response as + # "failed" via the text side-channel, then falls through to emit the normal + # EndOfResponse so the audio path still re-enables listening / releases the slot. + if lm_output.error and self.text_output_queue is not None: + self.text_output_queue.put( + ResponseFailedEvent( + message=lm_output.error, + turn_id=lm_output.turn_id, + turn_revision=lm_output.turn_revision, + ) + ) + yield EndOfResponse( + turn_id=lm_output.turn_id, + turn_revision=lm_output.turn_revision, + cancel_generation=lm_output.cancel_generation, + ) + return + + if not isinstance(lm_output, LLMResponseChunk): + logger.warning("LMOutputProcessor received unexpected type: %s", type(lm_output)) + return + + if not self._turn_output_allowed( + lm_output.turn_id, + lm_output.turn_revision, + ): + logger.debug("Dropping stale LLM chunk for turn=%s rev=%s", lm_output.turn_id, lm_output.turn_revision) + return + + logger.debug(f"LM processor: text='{lm_output.text}', tools={lm_output.tools}") + + if self.text_output_queue is not None: + event = AssistantTextEvent( + text=lm_output.text, + turn_id=lm_output.turn_id, + turn_revision=lm_output.turn_revision, + cancel_generation=lm_output.cancel_generation, + ) + if lm_output.tools: + event.tools = lm_output.tools + logger.info(f"Sending to clients: text='{lm_output.text}', tools={[t.name for t in lm_output.tools]}") + else: + logger.debug(f"Sending to clients: text='{lm_output.text}' (no tools)") + self.text_output_queue.put(event) + + if lm_output.text and response_wants_audio(lm_output.response): + logger.debug(f"Forwarding to TTS: '{lm_output.text}'") + yield TTSInput( + text=lm_output.text, + language_code=lm_output.language_code, + runtime_config=lm_output.runtime_config, + response=lm_output.response, + turn_id=lm_output.turn_id, + turn_revision=lm_output.turn_revision, + speech_stopped_at_s=lm_output.speech_stopped_at_s, + cancel_generation=lm_output.cancel_generation, + ) diff --git a/speech_to_speech/LLM/responses_api_language_model.py b/speech_to_speech/LLM/responses_api_language_model.py new file mode 100644 index 0000000..5fa74b4 --- /dev/null +++ b/speech_to_speech/LLM/responses_api_language_model.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import logging +import time +from collections.abc import Iterator +from typing import Any + +from openai import Stream +from openai.types.realtime.realtime_conversation_item_assistant_message import ( + Content as AssistantContent, +) +from openai.types.responses import ( + ResponseCompletedEvent, + ResponseFunctionToolCall, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseTextDeltaEvent, +) + +from speech_to_speech.LLM.base_openai_compatible_language_model import ( + WARMUP_MAX_RETRIES, + AssistantMessage, + BaseOpenAICompatibleHandler, + ProviderEvent, + TextDelta, + ToolCall, + Usage, +) +from speech_to_speech.LLM.chat import Chat +from speech_to_speech.LLM.compaction_prompt import CompactGenerateFn +from speech_to_speech.utils.utils import _generate_id + +logger = logging.getLogger(__name__) + + +class ResponsesApiModelHandler(BaseOpenAICompatibleHandler): + """LLM handler that talks to an OpenAI ``/v1/responses`` server.""" + + def warmup(self) -> None: + logger.info(f"Warming up {self.__class__.__name__}") + start = time.time() + self.client.with_options(max_retries=WARMUP_MAX_RETRIES).responses.create( + model=self.model_name, + input=[ + { + "type": "message", + "role": "system", + "content": [{"type": "input_text", "text": "You are a helpful assistant"}], + }, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Hello"}]}, + ], + timeout=self.request_timeout, + ) + end = time.time() + logger.info(f"{self.__class__.__name__}: warmed up! time: {(end - start):.3f} s") + + def _build_compaction_generate_fn(self) -> CompactGenerateFn: + """Return a generate fn that calls the Responses API for compaction.""" + client = self.client + model_name = self.model_name + timeout = self.request_timeout + + def generate(system: str, user: str) -> str: + response = client.responses.create( + model=model_name, + input=[ + { + "type": "message", + "role": "system", + "content": [{"type": "input_text", "text": system}], + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": user}], + }, + ], + timeout=timeout, + ) + return response.output_text + + return generate + + # ── base hooks ──────────────────────────────────────────────────────────-- + + def _serialize(self, active_chat: Chat) -> Any: + return active_chat.to_responses_api_chat() + + def _build_optional_kwargs(self, req_tools: Any, req_tool_choice: Any) -> dict[str, Any]: + optional_kwargs: dict[str, Any] = {} + if req_tools is not None: + optional_kwargs["tools"] = req_tools + if req_tool_choice is not None: + optional_kwargs["tool_choice"] = req_tool_choice + return optional_kwargs + + def _request(self, api_input: Any, optional_kwargs: dict[str, Any]) -> Any: + return self.client.responses.create( + model=self.model_name, + input=api_input, + stream=self.stream, + extra_body=self._extra_body, + timeout=self.request_timeout, + **optional_kwargs, + ) + + @staticmethod + def _assistant_content(content: Any) -> list[AssistantContent]: + return [ + AssistantContent(type="output_text", text=c.text if c.type == "output_text" else c.refusal) for c in content + ] + + def _iter_stream_events(self, api_response: Stream) -> Iterator[ProviderEvent]: + for raw_event in api_response: + if isinstance(raw_event, ResponseTextDeltaEvent): + yield TextDelta(text=raw_event.delta) + elif isinstance(raw_event, ResponseOutputItemDoneEvent): + item = raw_event.item + if isinstance(item, ResponseFunctionToolCall): + item.call_id = _generate_id("call") + item.id = _generate_id("fc") + yield ToolCall(item=item) + elif isinstance(item, ResponseOutputMessage): + yield AssistantMessage(content=self._assistant_content(item.content)) + elif isinstance(raw_event, ResponseCompletedEvent): + usage = getattr(raw_event.response, "usage", None) + if usage: + yield Usage(input_tokens=usage.input_tokens or 0, output_tokens=usage.output_tokens or 0) + + def _iter_response_events(self, api_response: Any) -> Iterator[ProviderEvent]: + usage = api_response.usage + if usage: + yield Usage(input_tokens=usage.input_tokens or 0, output_tokens=usage.output_tokens or 0) + for message in api_response.output: + if isinstance(message, ResponseFunctionToolCall): + message.call_id = _generate_id("call") + message.id = _generate_id("fc") + yield ToolCall(item=message) + elif isinstance(message, ResponseOutputMessage): + yield AssistantMessage(content=self._assistant_content(message.content)) + # Text-only keeps every character; the base applies remove_unspeechable + # for audio. Only output_text parts are spoken (refusals are stored). + raw = "".join(c.text for c in message.content if c.type == "output_text") + yield TextDelta(text=raw) + else: + logger.warning(f"Not supported message type: {message.type}") + + def on_session_end(self) -> None: + logger.debug("OpenAI API language model session state reset") diff --git a/speech_to_speech/LLM/text_prompt.py b/speech_to_speech/LLM/text_prompt.py new file mode 100644 index 0000000..3ded9b8 --- /dev/null +++ b/speech_to_speech/LLM/text_prompt.py @@ -0,0 +1,44 @@ +"""Text-channel system prompt: lead + session prompt + tail (strongest constraints last).""" + +TEXT_SYSTEM_PROMPT_LEAD = """\ +You are a helpful assistant in a text conversation. +""" + +TEXT_SYSTEM_PROMPT_TAIL = """\ +## Text Rules +- Write clearly and directly. Match length to the request: concise for simple questions, fuller when the task genuinely needs it. +- Use markdown when it helps (lists, code blocks, tables, emphasis); don't over-format simple answers. +- This is a written channel: no spoken-style filler and no action/emote text like *laughs*. +- Use tools when they help fulfill the request. No preamble sentence is required before a tool call. +- For slow or external tools, just call the tool and use the result; you don't need to announce it. +- If unsure whether a tool is needed, just answer directly. +""" + +# Skeleton for the assembled system message (placeholders filled in build_text_system_prompt). +_TEXT_SYSTEM_PROMPT_FULL = """\ +{lead} + +Session Prompt: +{session_prompt}{optional_tools} + +{tail} +""" + + +def build_text_system_prompt(session_prompt: str, *, tool_section: str = "") -> str: + """Context → session prompt → optional tool block → strongest text rules last.""" + tools = tool_section.strip() + optional_tools = f"\n\n{tools}" if tools else "" + return _TEXT_SYSTEM_PROMPT_FULL.format( + lead=TEXT_SYSTEM_PROMPT_LEAD.rstrip(), + session_prompt=session_prompt.strip(), + optional_tools=optional_tools, + tail=TEXT_SYSTEM_PROMPT_TAIL.rstrip(), + ) + + +# Full text instructions without a separate session block (legacy / rare direct use). +TEXT_SYSTEM_PROMPT = "{lead}\n\n{tail}".format( + lead=TEXT_SYSTEM_PROMPT_LEAD.rstrip(), + tail=TEXT_SYSTEM_PROMPT_TAIL.rstrip(), +) diff --git a/speech_to_speech/LLM/tool_call/function_call.py b/speech_to_speech/LLM/tool_call/function_call.py new file mode 100644 index 0000000..1871db7 --- /dev/null +++ b/speech_to_speech/LLM/tool_call/function_call.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +Function parser for extracting function names, parameter names, and values from string function calls. + +Uses Python's ``tokenize`` and ``ast`` modules so that nested parentheses, +strings containing ')' characters, tuples, dicts, etc. are handled correctly. +""" + +import ast +import io +import json +import logging +import re +import tokenize +from collections import OrderedDict +from typing import Any, Dict, List, Tuple + +from openai.types.responses import ResponseFunctionToolCall +from pydantic import BaseModel + +from speech_to_speech.LLM.tool_call.function_tool import FunctionTool +from speech_to_speech.utils.utils import _generate_id + +logger = logging.getLogger(__name__) + +_POSITIONAL_RE = re.compile(r"^__arg_\d+__$") +_LENIENT_CALL_RE = re.compile( + r"\b[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*" + r"\((?:[^()\"']+|\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*')*\)" +) + + +# ── AST / tokenize helpers ─────────────────────────────────────────── + + +def _split_top_level_calls(source: str) -> List[str]: + """Split *source* into individual ``name(...)`` expression strings. + + Uses the tokenizer to walk tokens and track parenthesis depth so that + nested parens, strings with ')' chars, etc. are handled correctly. + """ + tokens = list(tokenize.generate_tokens(io.StringIO(source).readline)) + calls: List[str] = [] + i = 0 + + while i < len(tokens): + tok = tokens[i] + if tok.type != tokenize.NAME: + i += 1 + continue + + start = i + j = i + 1 + + # Walk past dotted attribute access (e.g. ``mobile.click``) + while j + 1 < len(tokens) and tokens[j].string == "." and tokens[j + 1].type == tokenize.NAME: + j += 2 + + if j >= len(tokens) or tokens[j].string != "(": + i += 1 + continue + + # Track balanced parens + depth = 0 + end = None + k = j + while k < len(tokens): + t = tokens[k] + if t.type == tokenize.OP and t.string == "(": + depth += 1 + elif t.type == tokenize.OP and t.string == ")": + depth -= 1 + if depth == 0: + end = k + break + k += 1 + + if end is None: + i += 1 + continue + + calls.append(tokenize.untokenize(tokens[start : end + 1]).strip()) + i = end + 1 + + return calls + + +def _split_simple_calls_with_regex(source: str) -> List[str]: + """Extract complete simple ``name(args)`` spans from malformed model output. + + This fallback can recover well-formed siblings before a tokenizer error, + but not the incomplete call that caused the tokenizer error. + """ + return [match.group(0).strip() for match in _LENIENT_CALL_RE.finditer(source)] + + +def _parse_function_exprs( + expressions: List[str], + pattern_to_match: list[str], + *, + skip_invalid: bool = False, +) -> List["FunctionToolCall"]: + results: List[FunctionToolCall] = [] + for expr in expressions: + try: + call = _parse_call_expr(expr) + except Exception: + if skip_invalid: + continue + raise + if pattern_to_match and all(pattern not in call.function_name for pattern in pattern_to_match): + continue + results.append(call) + return results + + +def _extract_function_name(node: ast.expr) -> str: + """Return the dotted function name from a Call node's ``func`` attribute.""" + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + base = _extract_function_name(node.value) + return f"{base}.{node.attr}" if base else node.attr + raise ValueError(f"Unsupported function target: {ast.dump(node)}") + + +def _literal_from_ast(node: ast.AST) -> Any: + """Convert an AST node to a Python literal value.""" + if isinstance(node, ast.Constant): + return node.value + + if isinstance(node, ast.Name): + return node.id + + if isinstance(node, ast.List): + return [_literal_from_ast(elt) for elt in node.elts] + + if isinstance(node, ast.Tuple): + return [_literal_from_ast(elt) for elt in node.elts] + + if isinstance(node, ast.Dict): + return { + _literal_from_ast(key): _literal_from_ast(value) + for key, value in zip(node.keys, node.values) + if key is not None + } + + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)): + value = _literal_from_ast(node.operand) + if not isinstance(value, (int, float)): + raise ValueError(f"Unsupported unary literal: {ast.dump(node)}") + return -value if isinstance(node.op, ast.USub) else value + + raise ValueError(f"Unsupported literal: {ast.dump(node)}") + + +def _parse_call_expr(expr: str) -> "FunctionToolCall": + """Parse a single ``name(args...)`` expression string into a FunctionToolCall.""" + parsed = ast.parse(expr, mode="eval").body + if not isinstance(parsed, ast.Call): + raise ValueError(f"Expression is not a function call: {expr!r}") + + parameters: "OrderedDict[str, Any]" = OrderedDict() + + for idx, arg in enumerate(parsed.args): + parameters[f"__arg_{idx}__"] = _literal_from_ast(arg) + + for kw in parsed.keywords: + if kw.arg is None: + raise ValueError("**kwargs are not supported") + parameters[kw.arg] = _literal_from_ast(kw.value) + + return FunctionToolCall( + function_name=_extract_function_name(parsed.func), + parameters=parameters, + original_string=expr, + ) + + +# ── Data model ─────────────────────────────────────────────────────── + + +class FunctionToolCall(BaseModel): + """Represents a parsed function call with its parameters.""" + + function_name: str + parameters: Dict[str, Any] + original_string: str + description: str = "" + + def to_realtime_function_tool_call( + self, + function_tools: list[FunctionTool] | None = None, + ) -> ResponseFunctionToolCall: + positional = {k for k in self.parameters if _POSITIONAL_RE.match(k)} + if positional: + logger.warning( + "Dropping positional arguments for '%s': %s", + self.function_name, + positional, + ) + arguments = {k: v for k, v in self.parameters.items() if not _POSITIONAL_RE.match(k)} + + if function_tools is not None: + tool = next( + (t for t in function_tools if t.name == self.function_name), + None, + ) + if tool is None: + available = [t.name for t in function_tools] + raise ValueError(f"Function '{self.function_name}' not found in available tools: {available}") + + schema = tool.parameters if isinstance(tool.parameters, dict) else {} + properties = schema.get("properties", {}) + required = set(schema.get("required", [])) + + undeclared = {k for k in arguments if k not in properties} + if undeclared: + logger.warning( + "Dropping undeclared parameters for '%s': %s", + self.function_name, + undeclared, + ) + arguments = {k: v for k, v in arguments.items() if k in properties} + + missing = required - set(arguments.keys()) + if missing: + raise ValueError(f"Missing required parameters for '{self.function_name}': {missing}") + + return ResponseFunctionToolCall( + name=self.function_name, + arguments=json.dumps(arguments), + call_id=_generate_id("call"), + type="function_call", + id=_generate_id("fc"), + status="in_progress", + ) + + +# ── Public API ─────────────────────────────────────────────────────── + + +def parse_function_call(function_string: str, pattern_to_match: list[str] = []) -> List[FunctionToolCall]: + """Parse a function call string and extract all function calls found. + + Args: + function_string: String representation of function calls. + pattern_to_match: If non-empty, only calls whose function name + contains at least one of these substrings are returned. + + Returns: + List of FunctionToolCall objects with parsed information. + """ + function_string = function_string.strip() + if not function_string: + return [] + + try: + expressions = _split_top_level_calls(function_string) + except tokenize.TokenError: + return _parse_function_exprs( + _split_simple_calls_with_regex(function_string), + pattern_to_match, + skip_invalid=True, + ) + + return _parse_function_exprs(expressions, pattern_to_match) + + +def parse_multiple_functions(function_strings: List[str]) -> List[FunctionToolCall]: + """Parse multiple function call strings. + + Args: + function_strings: List of function call strings. + + Returns: + List of FunctionToolCall objects. + """ + results: List[FunctionToolCall] = [] + for func_str in function_strings: + try: + results.extend(parse_function_call(func_str)) + except Exception: + continue + return results + + +def extract_function_calls_from_text(text: str, block_regex: str = ".*") -> Tuple[str, List[FunctionToolCall]]: + """Extract function calls from delimited code blocks inside *text*. + + The LLM is prompted to wrap tool calls inside code-block delimiters + (e.g. ``func(x=1)``). This function finds those blocks, + parses the function calls within them, and returns the remaining text + (with blocks stripped) alongside the parsed calls. + + Args: + text: Full model output potentially containing code blocks. + block_regex: Regex matching the code-block delimiters **and** their + content (e.g. ``r".*?"``). Only text **inside** + matched blocks is scanned for function calls. + + Returns: + ``(outside_text, function_calls)`` -- the text with blocks stripped + and the parsed function calls found inside the blocks. + """ + if not block_regex: + return text, [] + + matches = list(re.finditer(block_regex, text, flags=re.DOTALL)) + if not matches: + return text, [] + + outside = re.sub(block_regex, "", text, flags=re.DOTALL) + inside = " ".join(match.group(0) for match in matches).strip() + if not inside: + return outside, [] + + try: + return outside, parse_function_call(inside) + except Exception: + return outside, [] diff --git a/speech_to_speech/LLM/tool_call/function_tool.py b/speech_to_speech/LLM/tool_call/function_tool.py new file mode 100644 index 0000000..56c8519 --- /dev/null +++ b/speech_to_speech/LLM/tool_call/function_tool.py @@ -0,0 +1,34 @@ +import textwrap + +from openai.types.realtime import RealtimeFunctionTool + +from speech_to_speech.LLM.tool_call.signature_from_schema import signature_from_schema + + +class FunctionTool(RealtimeFunctionTool): + def to_code_prompt(self, include_args_doc: bool = True) -> str: + """Generate a code-style prompt string for this function tool. + + Args: + include_args_doc: If True, include argument descriptions in the docstring. + ⚠️ This lets the model see each argument's purpose but significantly increases + token usage (e.g. 906 tokens without vs 3434 with for the default Reachy Mini + tool profile). Enable depending on the model's capabilities and context limit. + """ + signature = signature_from_schema(self.parameters) + + tool_doc = self.description or "" + + if isinstance(self.parameters, dict) and include_args_doc: + props = self.parameters.get("properties", {}) + if props: + arg_lines = [] + for arg_name, arg_schema in props.items(): + desc = arg_schema.get("description", "") if isinstance(arg_schema, dict) else "" + arg_lines.append(f"{arg_name}: {desc}") + args_doc = f"Args:\n{textwrap.indent(chr(10).join(arg_lines), ' ')}" + tool_doc += f"\n\n{args_doc}" + + tool_doc = f'"""{tool_doc}\n"""' + + return f"def {self.name}{signature}:\n{textwrap.indent(tool_doc, ' ')}" diff --git a/speech_to_speech/LLM/tool_call/signature_from_schema.py b/speech_to_speech/LLM/tool_call/signature_from_schema.py new file mode 100644 index 0000000..dea6d2f --- /dev/null +++ b/speech_to_speech/LLM/tool_call/signature_from_schema.py @@ -0,0 +1,108 @@ +import inspect +from typing import Any, Literal, Union + +JSON_TYPE_TO_PYTHON_TYPE = { + "string": str, + "number": float, + "boolean": bool, + "integer": int, + "object": dict, + "array": list, + "null": type(None), +} + + +def _dedupe_types(types: list[Any]) -> list[Any]: + seen = [] + for t in types: + if t not in seen: + seen.append(t) + return seen + + +def _annotation_from_spec(spec: dict[str, Any]) -> Any: + if not spec or not isinstance(spec, dict): + return Any + + # const → Literal[value] + if "const" in spec: + return Literal[spec["const"]] + + # enum → Literal[val1, val2, ...] + if "enum" in spec: + values = spec["enum"] + if not values: + return Any + return Literal[tuple(values)] + + # anyOf / oneOf → Union[Type1, Type2, ...] + for key in ("anyOf", "oneOf"): + if key in spec: + variants = [_annotation_from_spec(s) for s in spec[key]] + unique = _dedupe_types(variants) + if len(unique) == 0: + return Any + if len(unique) == 1: + return unique[0] + return Union[tuple(unique)] + + # allOf → merge sub-schemas then resolve + if "allOf" in spec: + merged = {} + for sub in spec["allOf"]: + merged.update(sub) + return _annotation_from_spec(merged) + + json_type = spec.get("type") + + if json_type is None: + return Any + + # type as list, e.g. ["string", "null"] → Union[str, None] (Optional) + if isinstance(json_type, list): + types = [JSON_TYPE_TO_PYTHON_TYPE.get(t, Any) for t in json_type] + unique = _dedupe_types(types) + if len(unique) == 0: + return Any + if len(unique) == 1: + return unique[0] + return Union[tuple(unique)] + + # array with items → list[ItemType] + if json_type == "array" and "items" in spec: + item_type = _annotation_from_spec(spec["items"]) + return list[item_type] # type: ignore[valid-type] + + return JSON_TYPE_TO_PYTHON_TYPE.get(json_type, Any) + + +def signature_from_schema(schema: object | None) -> inspect.Signature: + if not schema or not isinstance(schema, dict): + return inspect.Signature() + + props = schema.get("properties", {}) + required = set(schema.get("required", [])) + params = [] + + for name, spec in props.items(): + annotation = _annotation_from_spec(spec) + + has_schema_default = "default" in spec if isinstance(spec, dict) else False + + if name in required and not has_schema_default: + default = inspect.Parameter.empty + elif has_schema_default: + default = spec["default"] + else: + default = None + + params.append( + inspect.Parameter( + name, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + default=default, + annotation=annotation, + ) + ) + + return inspect.Signature(params) diff --git a/speech_to_speech/LLM/tool_call/tool_prompt.py b/speech_to_speech/LLM/tool_call/tool_prompt.py new file mode 100644 index 0000000..5f5cff3 --- /dev/null +++ b/speech_to_speech/LLM/tool_call/tool_prompt.py @@ -0,0 +1,111 @@ +""" +Optional system-prompt builder that instructs a local LLM to output tool calls +inside delimited code blocks (e.g. ``func(arg='val')``). + +The prompt is rendered from a Jinja2 template and relies on +``FunctionTool.to_code_prompt()`` to expose each tool's Python-style signature. +""" + +import re + +from jinja2 import Template + +from speech_to_speech.LLM.tool_call.function_tool import FunctionTool + +# --------------------------------------------------------------------------- +# Default delimiters +# --------------------------------------------------------------------------- +ENTER_CODE = "" +END_CODE = "" + +# --------------------------------------------------------------------------- +# Jinja2 template +# --------------------------------------------------------------------------- +# ``enter_code`` / ``end_code`` are the block delimiters the model must wrap +# every tool call in. ``tools`` is a list of FunctionTool instances whose +# ``.to_code_prompt()`` is called inside the template. +# --------------------------------------------------------------------------- + +TOOL_PROMPT_TEMPLATE = Template( + """\ +Available tools: + +{% for tool in tools %} +{{ tool.to_code_prompt() }} + +{% endfor %} +To call a tool, put exactly one named-argument function call inside {{ enter_code }}...{{ end_code }}: +{{ enter_code }}function_name(required_arg='value'){{ end_code }} + +Rules: +- You may say one brief natural sentence before the tool call; for slow information tools, briefly say that you will check. +- For expression/background tools, always speak first. For requested expressions, use a short pattern like "Sure, here's my best ."; otherwise use a fitting empathetic sentence. +- Do not mention tags, functions, or tools. Keep prose outside tags brief, and do not claim tool results before a tool result is available. +- Use named arguments only; quote strings. Omit optional args instead of placeholder values like "random", "none", "", or null. +- Only one tool call may appear in a response.\ +""", + keep_trailing_newline=True, +) + +# Text-channel variant: same call format and structural rules, without the +# voice-specific "speak first" prose. +TEXT_TOOL_PROMPT_TEMPLATE = Template( + """\ +Available tools: + +{% for tool in tools %} +{{ tool.to_code_prompt() }} + +{% endfor %} +To call a tool, put exactly one named-argument function call inside {{ enter_code }}...{{ end_code }}: +{{ enter_code }}function_name(required_arg='value'){{ end_code }} + +Rules: +- Call a tool directly when it helps fulfill the request; no preamble sentence is required. +- Do not mention tags, functions, or tools in your prose, and do not claim tool results before a tool result is available. +- Use named arguments only; quote strings. Omit optional args instead of placeholder values like "random", "none", "", or null. +- Only one tool call may appear in a response.\ +""", + keep_trailing_newline=True, +) + + +# --------------------------------------------------------------------------- +# Public helpers +# --------------------------------------------------------------------------- + + +def build_tool_system_prompt( + tools: list[FunctionTool], + enter_code: str = ENTER_CODE, + end_code: str = END_CODE, + *, + text_only: bool = False, +) -> str: + """Render the tool-calling system-prompt section. + + Returns an empty string when *tools* is empty so it can be + unconditionally appended to a base system prompt. When *text_only* is set, + the written-channel variant is used (no voice "speak first" prose). + """ + if not tools: + return "" + + template = TEXT_TOOL_PROMPT_TEMPLATE if text_only else TOOL_PROMPT_TEMPLATE + return template.render( + tools=tools, + enter_code=enter_code, + end_code=end_code, + ) + + +def build_block_regex( + enter_code: str = ENTER_CODE, + end_code: str = END_CODE, +) -> str: + """Build a regex that matches a single code block (non-greedy). + + >>> build_block_regex("", "") + '.*?' + """ + return f"{re.escape(enter_code)}.*?{re.escape(end_code)}" diff --git a/speech_to_speech/LLM/utils.py b/speech_to_speech/LLM/utils.py new file mode 100644 index 0000000..b07dac6 --- /dev/null +++ b/speech_to_speech/LLM/utils.py @@ -0,0 +1,75 @@ +import base64 +import io +import re +from typing import Optional + +import requests # type: ignore[import-untyped] +from PIL import Image + +SMART_PUNCT_TRANSLATION = str.maketrans( + { + "\u2018": "'", + "\u2019": "'", + "\u201c": '"', + "\u201d": '"', + } +) + +SPEECHABLE_PATTERN = re.compile( + r"[^\w\s.,!?;:'\"\-()\/\\@#%&*+=$€£¥₹₽¢\[\]{}<>~`^|…—–\n\r\t]", + flags=re.UNICODE, +) + + +def remove_unspeechable(text: str) -> str: + """Keep only speechable characters: letters, digits, punctuation, whitespace. + support unicode characters (english, arabic, chinese, japanese, korean, etc.) + """ + text = text.translate(SMART_PUNCT_TRANSLATION) + return SPEECHABLE_PATTERN.sub("", text) + + +WHISPER_LANGUAGE_TO_LLM_LANGUAGE = { + "en": "english", + "fr": "french", + "es": "spanish", + "zh": "chinese", + "ja": "japanese", + "ko": "korean", + "hi": "hindi", + "de": "german", + "pt": "portuguese", + "pl": "polish", + "it": "italian", + "nl": "dutch", +} + + +def resolve_auto_language(language_code: Optional[str]) -> tuple[Optional[str], Optional[str]]: + """Strip the ``-auto`` suffix and resolve the human-readable language name. + + Returns ``(clean_code, language_name)``. ``language_name`` is non-None + when the code (with or without ``-auto``) maps to a known language. + """ + if not language_code: + return language_code, None + if language_code.endswith("-auto"): + language_code = language_code[:-5] + if language_code not in WHISPER_LANGUAGE_TO_LLM_LANGUAGE: + return language_code, None + return language_code, WHISPER_LANGUAGE_TO_LLM_LANGUAGE.get(language_code) + + +def image_url_to_pil(image_url: str) -> Image.Image: + """Convert an image URL or base64 data URI to a PIL Image. + + Accepts: + - 'data:image/...;base64,' data URIs + - 'https://...`` or ``http://...' URLs (fetched with a 10s timeout) + """ + if image_url.startswith("data:"): + _, b64_data = image_url.split(",", 1) + return Image.open(io.BytesIO(base64.b64decode(b64_data))) + resp = requests.get(image_url, timeout=10) + resp.raise_for_status() + return Image.open(io.BytesIO(resp.content)) diff --git a/speech_to_speech/LLM/voice_prompt.py b/speech_to_speech/LLM/voice_prompt.py new file mode 100644 index 0000000..29c2f10 --- /dev/null +++ b/speech_to_speech/LLM/voice_prompt.py @@ -0,0 +1,48 @@ +"""Voice-channel system prompt: lead + session prompt + tail (strongest constraints last).""" + +VOICE_SYSTEM_PROMPT_LEAD = """\ +You are in a spoken conversation. The user speaks and hears you. +The session prompt defines persona, facts, goals, and tool descriptions. These channel rules only control spoken output and tool-use behavior. +""" + +VOICE_SYSTEM_PROMPT_TAIL = """\ +## Voice Rules +- Keep replies brief by default: usually one spoken sentence, two if needed. Go longer only when asked. +- Speak naturally. No markdown, bullets, headings, visual formatting, or action/emote text like *laughs*. +- Treat transcripts as noisy. Correct likely mishearings only if asked or meaning depends on it. +- Speech is the default. Use at most one tool when it helps fulfill the request or clearly fits the moment. +- Before a tool call, use a brief natural utterance unless the user asked for silence or tool-only output. For slow information tools, briefly say that you will check. +- For expression/background tools, speak first. If asked to show an expression, use a short pattern like "Sure, here's my best ." Otherwise use a fitting empathetic sentence. Never mention tools. +- After completed expression/background/physical-action tools, do not add a second spoken comment unless the result has user-facing information. +- Use motion, dance, emotion, and similar tools sparingly when they add empathy, celebration, playfulness, or a requested physical action. +- If unsure whether a tool is needed, just speak. +""" + +# Skeleton for the assembled system message (placeholders filled in build_voice_system_prompt). +_VOICE_SYSTEM_PROMPT_FULL = """\ +{lead} + +Session Prompt: +{session_prompt}{optional_tools} + +{tail} +""" + + +def build_voice_system_prompt(session_prompt: str, *, tool_section: str = "") -> str: + """Context → session prompt → optional tool block → strongest voice rules last.""" + tools = tool_section.strip() + optional_tools = f"\n\n{tools}" if tools else "" + return _VOICE_SYSTEM_PROMPT_FULL.format( + lead=VOICE_SYSTEM_PROMPT_LEAD.rstrip(), + session_prompt=session_prompt.strip(), + optional_tools=optional_tools, + tail=VOICE_SYSTEM_PROMPT_TAIL.rstrip(), + ) + + +# Full voice instructions without a separate session block (legacy / rare direct use). +VOICE_SYSTEM_PROMPT = "{lead}\n\n{tail}".format( + lead=VOICE_SYSTEM_PROMPT_LEAD.rstrip(), + tail=VOICE_SYSTEM_PROMPT_TAIL.rstrip(), +) diff --git a/speech_to_speech/STT/README.md b/speech_to_speech/STT/README.md new file mode 100644 index 0000000..73e759f --- /dev/null +++ b/speech_to_speech/STT/README.md @@ -0,0 +1,163 @@ +# STT Summary + +This document summarizes the Speech-to-Text (STT) implementations in the `STT/` folder, including language support, language abbreviations, and usage in `s2s_pipeline.py`. + +## Available STT Modes (`--stt`) + +- `whisper` → `STT/whisper_stt_handler.py` +- `whisper-mlx` → `STT/lightning_whisper_mlx_handler.py` +- `mlx-audio-whisper` → `STT/mlx_audio_whisper_handler.py` +- `faster-whisper` → `STT/faster_whisper_handler.py` +- `parakeet-tdt` → `STT/parakeet_tdt_handler.py` +- `paraformer` → `STT/paraformer_handler.py` + +## Language Support by Handler + +### 1) Whisper (`--stt whisper`) + +- Handler: `WhisperSTTHandler` +- Language input flag: `--language` (from shared Whisper args) +- Supports fixed language (e.g. `en`) or `auto` +- Internal supported language list: + - `en`, `fr`, `es`, `zh`, `ja`, `ko`, `hi`, `de`, `pt`, `pl`, `it`, `nl` +- Behavior: + - Detects language from token output + - If detected language is outside the supported list, it falls back to the previous language + +### 2) Lightning Whisper MLX (`--stt whisper-mlx`) + +- Handler: `LightningWhisperSTTHandler` +- Uses same shared `--language` argument as Whisper +- Internal supported language list: + - `en`, `fr`, `es`, `zh`, `ja`, `ko`, `hi`, `de`, `pt`, `pl`, `it`, `nl` +- Behavior: + - If `--language auto`, model auto-detects each utterance + - If detected language is unsupported, falls back to last supported language + +### 3) MLX Audio Whisper (`--stt mlx-audio-whisper`) + +- Handler: `MLXAudioWhisperSTTHandler` +- Model flag: `--mlx_audio_whisper_model_name` +- Language still comes from shared `--language` flag (wired in pipeline) +- Internal supported language list: + - `en`, `fr`, `es`, `zh`, `ja`, `ko`, `hi`, `de`, `pt`, `pl`, `it`, `nl` +- Behavior: + - Uses fixed language unless `--language auto` + - Falls back to last known supported language when needed + +### 4) Faster-Whisper (`--stt faster-whisper`) + +- Handler: `FasterWhisperSTTHandler` +- Language flag: `--faster_whisper_stt_gen_language` +- Default language: `en` +- Note: + - This handler passes generation kwargs directly to `faster_whisper.WhisperModel.transcribe(...)` + - Effective language coverage depends on selected Faster-Whisper/OpenAI Whisper model + +### 5) Parakeet TDT (`--stt parakeet-tdt`) + +- Handler: `ParakeetTDTSTTHandler` +- Language flag: `--parakeet_tdt_language` (optional) +- Supports auto language detection when language not specified +- Declared supported language list (25 European languages): + - `en`, `de`, `fr`, `es`, `it`, `pt`, `nl`, `pl`, `ru`, `uk`, `cs`, `sk`, `hu`, `ro`, `bg`, `hr`, `sl`, `sr`, `da`, `no`, `sv`, `fi`, `et`, `lv`, `lt` +- Backend behavior: + - On macOS/MPS: MLX (`mlx-community/parakeet-tdt-0.6b-v3`) + - On CUDA/CPU: nano-parakeet (`nvidia/parakeet-tdt-0.6b-v3`) + +### 6) Paraformer (`--stt paraformer`) + +- Handler: `ParaformerSTTHandler` +- Model flag: `--paraformer_stt_model_name` +- Default model: `paraformer-zh` +- No dedicated language flag in current args class +- Practical support: + - Depends on selected FunASR model checkpoint + - Default setup is Chinese-oriented (`zh`) + +## Language Abbreviations (ISO-style codes seen in STT handlers) + +| Code | Language | +|---|---| +| `en` | English | +| `fr` | French | +| `es` | Spanish | +| `zh` | Chinese | +| `ja` | Japanese | +| `ko` | Korean | +| `hi` | Hindi | +| `de` | German | +| `pt` | Portuguese | +| `pl` | Polish | +| `it` | Italian | +| `nl` | Dutch | +| `ru` | Russian | +| `uk` | Ukrainian | +| `cs` | Czech | +| `sk` | Slovak | +| `hu` | Hungarian | +| `ro` | Romanian | +| `bg` | Bulgarian | +| `hr` | Croatian | +| `sl` | Slovenian | +| `sr` | Serbian | +| `da` | Danish | +| `no` | Norwegian | +| `sv` | Swedish | +| `fi` | Finnish | +| `et` | Estonian | +| `lv` | Latvian | +| `lt` | Lithuanian | +| `auto` | Per-utterance automatic language detection | + +## Usage Examples + +### Whisper (Transformers) + +```bash +python s2s_pipeline.py --stt whisper --language en +python s2s_pipeline.py --stt whisper --language auto +``` + +### Whisper MLX (LightningWhisperMLX) + +```bash +python s2s_pipeline.py --stt whisper-mlx --language auto --device mps +``` + +### MLX Audio Whisper + +```bash +python s2s_pipeline.py --stt mlx-audio-whisper \ + --mlx_audio_whisper_model_name mlx-community/whisper-large-v3-turbo \ + --language auto +``` + +### Faster-Whisper + +```bash +python s2s_pipeline.py --stt faster-whisper \ + --faster_whisper_stt_model_name large-v3 \ + --faster_whisper_stt_gen_language en +``` + +### Parakeet TDT + +```bash +python s2s_pipeline.py --stt parakeet-tdt --parakeet_tdt_device auto +python s2s_pipeline.py --stt parakeet-tdt --parakeet_tdt_language de +``` + +With live transcription (MLX or CUDA/nano-parakeet backend): + +```bash +python s2s_pipeline.py --stt parakeet-tdt \ + --enable_live_transcription \ + --live_transcription_update_interval 0.25 +``` + +### Paraformer + +```bash +python s2s_pipeline.py --stt paraformer --paraformer_stt_model_name paraformer-zh +``` diff --git a/speech_to_speech/STT/__init__.py b/speech_to_speech/STT/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/speech_to_speech/STT/__init__.py @@ -0,0 +1 @@ + diff --git a/speech_to_speech/STT/base_stt_handler.py b/speech_to_speech/STT/base_stt_handler.py new file mode 100644 index 0000000..58524b7 --- /dev/null +++ b/speech_to_speech/STT/base_stt_handler.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import logging +from collections import Counter, OrderedDict +from time import perf_counter +from typing import Any + +from speech_to_speech.baseHandler import BaseHandler +from speech_to_speech.pipeline.handler_types import STTIn, STTOut +from speech_to_speech.pipeline.messages import PartialTranscription, Transcription, VADAudio +from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker + +logger = logging.getLogger(__name__) + + +class BaseSTTHandler(BaseHandler[STTIn, STTOut]): + """Base STT handler with speculative-turn stale input filtering.""" + + _MAX_COMPLETED_FINAL_REVISIONS = 2048 + + speculative_turns: SpeculativeTurnTracker | None = None + final_revision_settle_s: float = 0.0 + + def should_process_input(self, item: STTIn) -> bool: + mode = getattr(item, "mode", None) + turn_id = getattr(item, "turn_id", None) + turn_revision = getattr(item, "turn_revision", None) + if self._is_completed_final_revision(item): + queued_drops = self._drop_stale_queued_inputs() + self._log_stale_turn_item(item, "input-after-final", queued_drops=queued_drops) + return False + if mode == "progressive" and self._has_queued_final_for_revision(item): + self._log_stale_turn_item(item, "progressive-before-final") + return False + + wait_for_stability = mode == "final" + gate_start = perf_counter() + is_latest = self._is_latest_turn_item( + item, + wait_for_pending_reopen=True, + wait_for_stability=wait_for_stability, + ) + gate_wait_s = perf_counter() - gate_start + if gate_wait_s >= 0.05: + logger.info( + "%s: STT input gate waited %.3fs for turn=%s rev=%s mode=%s latest=%s age=%.3fs queue=%s", + self.__class__.__name__, + gate_wait_s, + turn_id, + turn_revision, + mode, + is_latest, + self._item_age_s(item), + self._safe_qsize(), + ) + + if not is_latest: + queued_drops = self._drop_stale_queued_inputs() + self._log_stale_turn_item(item, "input", queued_drops=queued_drops) + return False + return True + + def should_emit_output(self, output: STTOut) -> bool: + if isinstance(output, PartialTranscription) and self._is_completed_final_revision(output): + self._log_stale_turn_item(output, "output-after-final") + return False + + if not self._is_latest_turn_item(output, wait_for_pending_reopen=True, wait_for_stability=False): + self._log_stale_turn_item(output, "output") + return False + return True + + def before_emit_output(self, output: STTOut) -> None: + if isinstance(output, Transcription): + self._mark_completed_final_revision(output) + + def _is_latest_turn_item( + self, + item: object, + *, + wait_for_pending_reopen: bool, + wait_for_stability: bool, + ) -> bool: + if self.speculative_turns is None: + return True + turn_id = getattr(item, "turn_id", None) + turn_revision = getattr(item, "turn_revision", None) + if turn_id is None or turn_revision is None: + return True + + if wait_for_stability: + is_latest = self.speculative_turns.is_latest_after_stability_window( + turn_id, + turn_revision, + self.final_revision_settle_s, + ) + elif wait_for_pending_reopen: + is_latest = self.speculative_turns.is_latest_after_pending_reopen(turn_id, turn_revision) + else: + is_latest = self.speculative_turns.is_latest(turn_id, turn_revision) + return is_latest + + def _drop_stale_queued_inputs(self) -> int: + if self.speculative_turns is None or not hasattr(self.queue_in, "mutex") or not hasattr(self.queue_in, "queue"): + return 0 + + dropped = 0 + with self.queue_in.mutex: + kept: list[Any] = [] + while self.queue_in.queue: + queued_item = self.queue_in.queue.popleft() + if isinstance(queued_item, VADAudio) and ( + self._is_completed_final_revision(queued_item) + or (queued_item.mode == "progressive" and self._has_queued_final_for_revision_locked(queued_item)) + or not self._is_latest_turn_item( + queued_item, + wait_for_pending_reopen=False, + wait_for_stability=False, + ) + ): + dropped += 1 + else: + kept.append(queued_item) + self.queue_in.queue.extend(kept) + if dropped: + self.queue_in.not_full.notify_all() + return dropped + + def _log_stale_turn_item(self, item: object, stage: str, *, queued_drops: int = 0) -> None: + turn_id = getattr(item, "turn_id", None) + turn_revision = getattr(item, "turn_revision", None) + if turn_id is None or turn_revision is None: + return + + if not hasattr(self, "_stale_drop_counts"): + self._stale_drop_counts: Counter[tuple[str, str, int]] = Counter() + key = (stage, turn_id, turn_revision) + self._stale_drop_counts[key] += 1 + + message = "%s: dropping stale STT %s for turn=%s rev=%s age=%.3fs" + args: tuple[object, ...] = ( + self.__class__.__name__, + stage, + turn_id, + turn_revision, + self._item_age_s(item), + ) + if queued_drops: + message += " (+%d queued)" + args = (*args, queued_drops) + + if self._stale_drop_counts[key] == 1: + logger.info(message, *args) + else: + logger.debug(message, *args) + + def _item_age_s(self, item: object) -> float: + created_at_s = getattr(item, "created_at_s", None) + if not isinstance(created_at_s, float): + return 0.0 + return max(0.0, perf_counter() - created_at_s) + + def _safe_qsize(self) -> int | str: + try: + return self.queue_in.qsize() + except NotImplementedError: + return "unknown" + + def _has_queued_final_for_revision(self, item: object) -> bool: + if not hasattr(self.queue_in, "mutex") or not hasattr(self.queue_in, "queue"): + return False + with self.queue_in.mutex: + return self._has_queued_final_for_revision_locked(item) + + def _has_queued_final_for_revision_locked(self, item: object) -> bool: + key = self._revision_key(item) + if key is None: + return False + return any( + isinstance(queued_item, VADAudio) and queued_item.mode == "final" and self._revision_key(queued_item) == key + for queued_item in self.queue_in.queue + ) + + def _revision_key(self, item: object) -> tuple[str, int] | None: + turn_id = getattr(item, "turn_id", None) + turn_revision = getattr(item, "turn_revision", None) + if not isinstance(turn_id, str) or not isinstance(turn_revision, int): + return None + return (turn_id, turn_revision) + + def _completed_final_revisions(self) -> OrderedDict[tuple[str, int], None]: + if not hasattr(self, "_completed_final_revision_keys"): + self._completed_final_revision_keys: OrderedDict[tuple[str, int], None] = OrderedDict() + return self._completed_final_revision_keys + + def _is_completed_final_revision(self, item: object) -> bool: + key = self._revision_key(item) + return key is not None and key in self._completed_final_revisions() + + def _mark_completed_final_revision(self, output: Transcription) -> None: + key = self._revision_key(output) + if key is None: + return + completed = self._completed_final_revisions() + completed[key] = None + completed.move_to_end(key) + while len(completed) > self._MAX_COMPLETED_FINAL_REVISIONS: + completed.popitem(last=False) + + def on_session_end(self) -> None: + if hasattr(self, "_completed_final_revision_keys"): + self._completed_final_revision_keys.clear() diff --git a/speech_to_speech/STT/faster_whisper_handler.py b/speech_to_speech/STT/faster_whisper_handler.py new file mode 100644 index 0000000..291e214 --- /dev/null +++ b/speech_to_speech/STT/faster_whisper_handler.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import logging +import os +from typing import Any, Iterator + +from faster_whisper import WhisperModel +from rich.console import Console + +from speech_to_speech.pipeline.handler_types import STTIn, STTOut +from speech_to_speech.pipeline.messages import Transcription +from speech_to_speech.STT.base_stt_handler import BaseSTTHandler + +console = Console() + +logger = logging.getLogger(__name__) + + +class FasterWhisperSTTHandler(BaseSTTHandler): + """ + Handles the Speech To Text generation using a Whisper model. + """ + + def setup( + self, + model_name: str = "tiny.en", + device: str = "auto", + compute_type: str = "auto", + gen_kwargs: dict[str, Any] = {}, + ) -> None: + self.gen_kwargs = self.adapt_gen_kwargs(gen_kwargs) + + os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE" + self.model = WhisperModel(model_name, device=device, compute_type=compute_type) + + def process(self, vad_audio: STTIn) -> Iterator[STTOut]: + logger.debug("infering faster whisper...") + + segments, info = self.model.transcribe(vad_audio.audio, **self.gen_kwargs) + output_text = [] + + for segment in segments: + logger.debug("[%.2fs -> %.2fs] %s" % (segment.start, segment.end, segment.text)) + output_text.append(segment.text) + + pred_text = " ".join(output_text).strip() + + logger.debug("finished whisper inference") + if pred_text: + console.print(f"[yellow]USER: {pred_text}") + + yield Transcription( + text=pred_text, + turn_id=vad_audio.turn_id, + turn_revision=vad_audio.turn_revision, + speech_stopped_at_s=vad_audio.created_at_s, + ) + else: + logger.debug("no text detected. skipping...") + + def cleanup(self) -> None: + print("Stopping FasterWhisperSTTHandler") + del self.model + + def adapt_gen_kwargs(self, gen_kwargs: dict[str, Any]) -> dict[str, Any]: + gen_kwargs["without_timestamps"] = not gen_kwargs.pop("return_timestamps", True) + + return gen_kwargs diff --git a/speech_to_speech/STT/lightning_whisper_mlx_handler.py b/speech_to_speech/STT/lightning_whisper_mlx_handler.py new file mode 100644 index 0000000..296ec31 --- /dev/null +++ b/speech_to_speech/STT/lightning_whisper_mlx_handler.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import logging +from typing import Any, Iterator, Optional + +import numpy as np +import torch +from lightning_whisper_mlx import LightningWhisperMLX +from rich.console import Console + +from speech_to_speech.pipeline.handler_types import STTIn, STTOut +from speech_to_speech.pipeline.messages import Transcription +from speech_to_speech.STT.base_stt_handler import BaseSTTHandler + +logger = logging.getLogger(__name__) + +console = Console() + +SUPPORTED_LANGUAGES = [ + "en", + "fr", + "es", + "zh", + "ja", + "ko", + "hi", + "de", + "pt", + "pl", + "it", + "nl", +] + + +class LightningWhisperSTTHandler(BaseSTTHandler): + """ + Handles the Speech To Text generation using a Whisper model. + """ + + def setup( + self, + model_name: str = "distil-large-v3", + device: str = "mps", + torch_dtype: str = "float16", + compile_mode: Optional[str] = None, + language: Optional[str] = None, + gen_kwargs: dict[str, Any] = {}, + ) -> None: + if len(model_name.split("/")) > 1: + model_name = model_name.split("/")[-1] + self.device = device + self.model = LightningWhisperMLX(model=model_name, batch_size=6, quant=None) + self.start_language = language + self.last_language = language + + self.warmup() + + def warmup(self) -> None: + logger.info(f"Warming up {self.__class__.__name__}") + + # 2 warmup steps for no compile or compile mode with CUDA graphs capture + n_steps = 1 + dummy_input = np.array([0] * 512) + + for _ in range(n_steps): + _ = self.model.transcribe(dummy_input)["text"].strip() + + def process(self, vad_audio: STTIn) -> Iterator[STTOut]: + logger.debug("infering whisper...") + + audio = vad_audio.audio + if self.start_language != "auto": + transcription_dict = self.model.transcribe(audio, language=self.start_language) + else: + transcription_dict = self.model.transcribe(audio) + language_code = transcription_dict["language"] + if language_code not in SUPPORTED_LANGUAGES: + logger.warning(f"Whisper detected unsupported language: {language_code}") + if self.last_language in SUPPORTED_LANGUAGES: # reprocess with the last language + transcription_dict = self.model.transcribe(audio, language=self.last_language) + else: + transcription_dict = {"text": "", "language": "en"} + else: + self.last_language = language_code + + pred_text = transcription_dict["text"].strip() + language_code = transcription_dict["language"] + torch.mps.empty_cache() + + logger.debug("finished whisper inference") + console.print(f"[yellow]USER: {pred_text}") + logger.debug(f"Language Code Whisper: {language_code}") + + if self.start_language == "auto": + language_code += "-auto" + + yield Transcription( + text=pred_text, + language_code=language_code, + turn_id=vad_audio.turn_id, + turn_revision=vad_audio.turn_revision, + speech_stopped_at_s=vad_audio.created_at_s, + ) diff --git a/speech_to_speech/STT/mlx_audio_whisper_handler.py b/speech_to_speech/STT/mlx_audio_whisper_handler.py new file mode 100644 index 0000000..4dadc54 --- /dev/null +++ b/speech_to_speech/STT/mlx_audio_whisper_handler.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import logging +from typing import Any, Iterator, Optional + +import numpy as np +from rich.console import Console + +from speech_to_speech.pipeline.handler_types import STTIn, STTOut +from speech_to_speech.pipeline.messages import Transcription +from speech_to_speech.STT.base_stt_handler import BaseSTTHandler + +logger = logging.getLogger(__name__) + +console = Console() + +SUPPORTED_LANGUAGES = [ + "en", + "fr", + "es", + "zh", + "ja", + "ko", + "hi", + "de", + "pt", + "pl", + "it", + "nl", +] + + +class MLXAudioWhisperSTTHandler(BaseSTTHandler): + """ + Handles the Speech To Text generation using MLX Audio's Whisper implementation. + Optimized for Apple Silicon using the MLX framework. + """ + + def setup( + self, + model_name: str = "mlx-community/whisper-large-v3-turbo", + language: Optional[str] = None, + gen_kwargs: dict[str, Any] = {}, + ) -> None: + from mlx_audio.stt.generate import load_model + from transformers import WhisperProcessor + + self.model_name = model_name + self.start_language = language + self.last_language = language + self.gen_kwargs = gen_kwargs + + # Load the model directly + logger.info(f"Loading model {model_name}...") + self.model = load_model(model_name) + + # Check if processor was loaded, if not, load it manually from original model + if self.model._processor is None: + logger.info("Processor not found in MLX model, loading from original Whisper model...") + # Map MLX model names to their original Whisper counterparts + processor_model_map = { + "mlx-community/whisper-large-v3-turbo": "openai/whisper-large-v3", + "mlx-community/whisper-large-v3": "openai/whisper-large-v3", + "mlx-community/whisper-medium": "openai/whisper-medium", + "mlx-community/whisper-small": "openai/whisper-small", + "mlx-community/whisper-base": "openai/whisper-base", + "mlx-community/whisper-tiny": "openai/whisper-tiny", + } + + # Get the appropriate processor model name + processor_model = processor_model_map.get(model_name, "openai/whisper-large-v3") + logger.info(f"Loading processor from {processor_model}...") + + try: + self.model._processor = WhisperProcessor.from_pretrained(processor_model) + logger.info("Processor loaded successfully") + except Exception as e: + logger.error(f"Failed to load processor: {e}") + raise + + logger.info(f"Model {model_name} loaded successfully") + + self.warmup() + + def warmup(self) -> None: + logger.info(f"Warming up {self.__class__.__name__}") + + # Warmup with a dummy input + dummy_audio = np.zeros(16000, dtype=np.float32) + + try: + # Pre-warm the model by running a transcription + _ = self.model.generate(dummy_audio, verbose=False) + logger.info("Model warmed up and ready") + except Exception as e: + logger.warning(f"Warmup failed: {e}") + + def process(self, vad_audio: STTIn) -> Iterator[STTOut]: + logger.debug("inferring mlx-audio whisper...") + + assert isinstance(vad_audio.audio, np.ndarray), "Audio must be a numpy array" + audio_input = vad_audio.audio.astype(np.float32) + + # Prepare generation kwargs - only pass valid parameters + gen_kwargs = {} + + # Add language if specified + if self.start_language and self.start_language != "auto": + gen_kwargs["language"] = self.start_language + + try: + # Generate transcription directly using model.generate + result = self.model.generate(audio_input, verbose=False, **gen_kwargs) + + # Extract text from result + pred_text = result.text.strip() if hasattr(result, "text") else str(result).strip() + + # Try to detect language from result if available + if hasattr(result, "language"): + language_code = result.language + elif self.start_language and self.start_language != "auto": + language_code = self.start_language + else: + # Default to last known language or English + language_code = self.last_language if self.last_language else "en" + + # Validate language code + if language_code not in SUPPORTED_LANGUAGES: + logger.warning(f"Detected unsupported language: {language_code}") + if self.last_language in SUPPORTED_LANGUAGES: + language_code = self.last_language + else: + language_code = "en" + else: + self.last_language = language_code + + except Exception as e: + logger.error(f"MLX Audio Whisper inference failed: {e}") + pred_text = "" + language_code = self.last_language if self.last_language else "en" + + logger.debug("finished mlx-audio whisper inference") + console.print(f"[yellow]USER: {pred_text}") + logger.debug(f"Language Code: {language_code}") + + if self.start_language == "auto": + language_code += "-auto" + + yield Transcription( + text=pred_text, + language_code=language_code, + turn_id=vad_audio.turn_id, + turn_revision=vad_audio.turn_revision, + speech_stopped_at_s=vad_audio.created_at_s, + ) diff --git a/speech_to_speech/STT/paraformer_handler.py b/speech_to_speech/STT/paraformer_handler.py new file mode 100644 index 0000000..2d8c5f5 --- /dev/null +++ b/speech_to_speech/STT/paraformer_handler.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import logging +from typing import Any, Iterator + +import numpy as np +import torch +from rich.console import Console + +from speech_to_speech.pipeline.handler_types import STTIn, STTOut +from speech_to_speech.pipeline.messages import PartialTranscription, Transcription +from speech_to_speech.STT.base_stt_handler import BaseSTTHandler + +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +console = Console() + + +class ParaformerSTTHandler(BaseSTTHandler): + """ + Handles the Speech To Text generation using a Paraformer model. + The default for this model is set to Chinese. + This model was contributed by @wuhongsheng. + """ + + def setup( + self, + model_name: str = "paraformer-zh", + device: str = "cuda", + gen_kwargs: dict[str, Any] = {}, + ) -> None: + print(model_name) + if len(model_name.split("/")) > 1: + model_name = model_name.split("/")[-1] + self.device = device + try: + from funasr import AutoModel + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "Paraformer STT requires the optional 'paraformer' extra. " + "Install it with `pip install speech-to-speech[paraformer]`." + ) from exc + self.model = AutoModel(model=model_name, device=device) + self.warmup() + + def warmup(self) -> None: + logger.info(f"Warming up {self.__class__.__name__}") + + # 2 warmup steps for no compile or compile mode with CUDA graphs capture + n_steps = 1 + dummy_input = np.array([0] * 512, dtype=np.float32) + for _ in range(n_steps): + _ = self.model.generate(dummy_input)[0]["text"].strip().replace(" ", "") + + def process(self, vad_audio: STTIn) -> Iterator[STTOut]: + logger.debug("infering paraformer...") + + pred_text = self.model.generate(vad_audio.audio)[0]["text"].strip().replace(" ", "") + torch.mps.empty_cache() + + logger.debug("finished paraformer inference") + console.print(f"[yellow]USER: {pred_text}") + + if vad_audio.mode == "progressive": + yield PartialTranscription( + text=pred_text, + turn_id=vad_audio.turn_id, + turn_revision=vad_audio.turn_revision, + ) + else: + yield Transcription( + text=pred_text, + turn_id=vad_audio.turn_id, + turn_revision=vad_audio.turn_revision, + speech_stopped_at_s=vad_audio.created_at_s, + ) diff --git a/speech_to_speech/STT/parakeet_tdt_handler.py b/speech_to_speech/STT/parakeet_tdt_handler.py new file mode 100644 index 0000000..989e94c --- /dev/null +++ b/speech_to_speech/STT/parakeet_tdt_handler.py @@ -0,0 +1,648 @@ +""" +Parakeet TDT Speech-to-Text Handler + +Supports NVIDIA Parakeet TDT model for high-quality multilingual ASR. +- On Apple Silicon (MPS): Uses mlx-audio with mlx-community/parakeet-tdt-0.6b-v3 +- On CUDA/CPU: Uses nano-parakeet (pure PyTorch) with nvidia/parakeet-tdt-0.6b-v3 + +Model supports 25 European languages with automatic language detection. +""" + +from __future__ import annotations + +import logging +from contextlib import contextmanager +from sys import platform +from threading import Lock +from time import perf_counter +from typing import Any, Iterator, Optional + +import numpy as np +from rich.console import Console +from rich.text import Text + +from speech_to_speech.pipeline.handler_types import STTIn, STTOut +from speech_to_speech.pipeline.messages import PartialTranscription, Transcription +from speech_to_speech.STT.base_stt_handler import BaseSTTHandler +from speech_to_speech.STT.smart_progressive_streaming import PartialTranscription as ProgressiveStreamPartial +from speech_to_speech.utils.mlx_lock import MLXLockContext + +try: + from lingua import Language, LanguageDetectorBuilder + + LINGUA_AVAILABLE = True +except ImportError: + LINGUA_AVAILABLE = False + +logger = logging.getLogger(__name__) +console = Console() + +# Parakeet TDT v3 supports 25 European languages +SUPPORTED_LANGUAGES = [ + "en", + "de", + "fr", + "es", + "it", + "pt", + "nl", + "pl", + "ru", + "uk", + "cs", + "sk", + "hu", + "ro", + "bg", + "hr", + "sl", + "sr", + "da", + "no", + "sv", + "fi", + "et", + "lv", + "lt", +] + +# Lingua uses "nb" (Bokmål) for Norwegian instead of "no" +_LINGUA_CODE_MAP = {"no": "nb"} + +if LINGUA_AVAILABLE: + _lingua_iso_to_code = { + lang.iso_code_639_1.name.lower(): lang for lang in Language.all() if lang.iso_code_639_1 is not None + } + _lingua_languages = [ + _lingua_iso_to_code[_LINGUA_CODE_MAP.get(code, code)] + for code in SUPPORTED_LANGUAGES + if _LINGUA_CODE_MAP.get(code, code) in _lingua_iso_to_code + ] + + def _build_lingua_detector(): + # Preloading can take multiple seconds on some hardware, including the + # deployed server. Pay that cost at startup instead of on the first user + # request, where it would look like slow STT. + return LanguageDetectorBuilder.from_languages(*_lingua_languages).with_preloaded_language_models().build() + + _lingua_detector = _build_lingua_detector() + + +class ParakeetTDTSTTHandler(BaseSTTHandler): + """ + Handles Speech-to-Text using NVIDIA Parakeet TDT model. + + On Apple Silicon (MPS): Uses mlx-audio with the MLX-converted model. + On CUDA/CPU: Uses nano-parakeet (pure PyTorch) for NeMo-free inference. + + Parakeet TDT 0.6B v3 is a 600M parameter multilingual ASR model + supporting 25 European languages with automatic language detection. + """ + + def setup( + self, + model_name: Optional[str] = None, + device: str = "auto", + compute_type: str = "float16", + language: Optional[str] = None, + gen_kwargs: dict[str, Any] = {}, + enable_live_transcription: bool = False, + live_transcription_update_interval: float = 0.5, + ) -> None: + """ + Initialize the Parakeet TDT model. + + Args: + model_name: Model identifier. Defaults are: + - MPS: "mlx-community/parakeet-tdt-0.6b-v3" + - CUDA/CPU: "nvidia/parakeet-tdt-0.6b-v3" + device: Device to use ("auto", "cuda", "mps", "cpu") + compute_type: Compute precision ("float16", "float32") + language: Target language code (optional, model auto-detects) + gen_kwargs: Additional generation kwargs + """ + self.gen_kwargs = gen_kwargs + self.start_language = language + self.last_language = language if language else "en" + self.enable_live_transcription = enable_live_transcription + self.live_transcription_update_interval = live_transcription_update_interval + self.compute_lock = Lock() + self.sample_rate = 16000 + + # Determine device + if device == "auto": + if platform == "darwin": + self.device = "mps" + else: + import torch + + self.device = "cuda" if torch.cuda.is_available() else "cpu" + else: + self.device = device + + # Set default model based on device + if model_name is None: + if self.device == "mps": + model_name = "mlx-community/parakeet-tdt-0.6b-v3" + else: + model_name = "nvidia/parakeet-tdt-0.6b-v3" + + self.model_name = model_name + self.compute_type = compute_type + + logger.info(f"Loading Parakeet TDT model: {model_name} on {self.device}") + + if self.device == "mps": + self._setup_mlx(model_name) + else: + self._setup_nano_parakeet(model_name) + + # Setup streaming handler if live transcription is enabled + self.streaming_handler = None + if self.enable_live_transcription: + from speech_to_speech.STT.smart_progressive_streaming import ( + SmartProgressiveStreamingHandler, + ) + + self.streaming_handler = SmartProgressiveStreamingHandler( + self.model, + emission_interval=self.live_transcription_update_interval, + max_window_size=15.0, + sentence_buffer=2.0, + ) + self.processing_final = False # Track if we're processing final audio + logger.info(f"Live transcription enabled for Parakeet TDT ({self.backend})") + self._live_transcription_active = False + self._live_turn_key: tuple[str | None, int | None] | None = None + + self.warmup() + + def _setup_mlx(self, model_name: str) -> None: + """Setup for Apple Silicon using mlx-audio.""" + try: + from mlx_audio.stt.generate import load_model + + self.backend = "mlx" + self.model = load_model(model_name) + logger.info("MLX Audio Parakeet model loaded successfully") + except ImportError as e: + raise ImportError( + "mlx-audio is required for Parakeet TDT on Apple Silicon. Install with: pip install mlx-audio" + ) from e + + def _setup_nano_parakeet(self, model_name: str) -> None: + """Setup for CUDA/CPU using nano-parakeet.""" + try: + import torch + from nano_parakeet import from_pretrained + + self.backend = "nano_parakeet" + + if self.device == "cuda" and not torch.cuda.is_available(): + logger.warning("CUDA requested but not available. Falling back to CPU for nano-parakeet.") + self.device = "cpu" + + self.model = from_pretrained(model_name=model_name, device=self.device) + + logger.info(f"nano-parakeet model loaded successfully on {self.device}") + except ImportError as e: + raise ImportError( + "nano-parakeet is required for Parakeet TDT on CUDA/CPU. Install with: pip install nano-parakeet" + ) from e + + def warmup(self) -> None: + """Warm up the model with a dummy input.""" + logger.info(f"Warming up {self.__class__.__name__}") + + # Create 1 second of silence at 16kHz + dummy_audio = np.zeros(16000, dtype=np.float32) + + try: + if self.backend == "mlx": + import mlx.core as mx + + # Convert to mx.array and call decode_chunk directly + audio_mx = mx.array(dummy_audio, dtype=mx.float32) + _ = self.model.decode_chunk(audio_mx, verbose=False) + elif self.backend == "nano_parakeet": + _ = self.model.transcribe(dummy_audio) + else: + _ = self.model.transcribe([dummy_audio], batch_size=1, verbose=False) + + logger.info("Model warmed up and ready") + except Exception as e: + logger.warning(f"Warmup failed: {e}") + + def process(self, vad_audio: STTIn) -> Iterator[STTOut]: + """ + Process audio and generate transcription. + + Yields: + :class:`PartialTranscription` or :class:`Transcription` + """ + process_start_s = perf_counter() + is_progressive = vad_audio.mode == "progressive" + audio_input = vad_audio.audio + + # Ensure audio is float32 numpy array + if not isinstance(audio_input, np.ndarray): + audio_input = np.array(audio_input, dtype=np.float32) + else: + audio_input = audio_input.astype(np.float32) + audio_duration_s = len(audio_input) / getattr(self, "sample_rate", 16000) + item_age_s = self._item_age_s(vad_audio) + + self._prepare_live_transcription_turn(vad_audio.turn_id, vad_audio.turn_revision) + + # Handle progressive updates: yield tagged partial for TranscriptionNotifier + if self.enable_live_transcription and is_progressive: + # Ignore progressive updates if we're already processing final audio + if self.processing_final: + logger.debug("Skipping stale progressive update (final audio already received)") + return + + # Try to acquire lock with short timeout - skip if busy + lock_scope_start_s = perf_counter() + with self._compute_lock_context(handler_name="ParakeetSTT-Progressive", timeout=0.01) as acquired: + if acquired: + try: + inference_start_s = perf_counter() + progressive_text = self._show_progressive_transcription(audio_input) + inference_s = perf_counter() - inference_start_s + if inference_s >= 0.25: + logger.info( + "Parakeet progressive STT timing turn=%s rev=%s audio=%.3fs age=%.3fs " + "lock_scope=%.3fs inference=%.3fs chars=%d", + vad_audio.turn_id, + vad_audio.turn_revision, + audio_duration_s, + item_age_s, + perf_counter() - lock_scope_start_s, + inference_s, + len(progressive_text), + ) + if progressive_text: + yield PartialTranscription( + text=progressive_text, + turn_id=vad_audio.turn_id, + turn_revision=vad_audio.turn_revision, + ) + return + except Exception as e: + logger.debug(f"Progressive transcription failed: {e}") + else: + logger.debug("Skipping progressive update (compute busy)") + return + + # Handle final transcription (send to LLM) + logger.info( + "Parakeet final STT start turn=%s rev=%s audio=%.3fs age=%.3fs", + vad_audio.turn_id, + vad_audio.turn_revision, + audio_duration_s, + item_age_s, + ) + inference_s = 0.0 + lock_scope_s = 0.0 + try: + if self.enable_live_transcription: + # Mark that we're processing final audio (ignore stale progressive updates) + self.processing_final = True + + # Acquire lock with longer timeout for final transcription + lock_scope_start_s = perf_counter() + with self._compute_lock_context(handler_name="ParakeetSTT-Final", timeout=5.0) as acquired: + lock_scope_s = perf_counter() - lock_scope_start_s + if not acquired: + logger.error("Failed to acquire compute lock for final transcription") + pred_text = "" + language_code = self.last_language + else: + inference_start_s = perf_counter() + if self.backend == "mlx": + pred_text, language_code = self._process_mlx_final(audio_input) + else: + pred_text, language_code = self._process_nano_parakeet(audio_input) + inference_s = perf_counter() - inference_start_s + lock_scope_s = perf_counter() - lock_scope_start_s + + # Validate and update language + if language_code and language_code in SUPPORTED_LANGUAGES: + self.last_language = language_code + else: + language_code = self.last_language + + except Exception as e: + logger.error(f"Parakeet TDT inference failed: {e}") + pred_text = "" + language_code = self.last_language + + total_s = perf_counter() - process_start_s + logger.info( + "Parakeet final STT done turn=%s rev=%s total=%.3fs lock_scope=%.3fs inference=%.3fs chars=%d", + vad_audio.turn_id, + vad_audio.turn_revision, + total_s, + lock_scope_s, + inference_s, + len(pred_text), + ) + logger.debug("Finished Parakeet TDT inference") + self._clear_live_transcription_line() + if pred_text.strip(): + console.print(f"[yellow]USER: {pred_text.strip()}") + if language_code: + console.print(f"[dim]Language: {language_code}[/dim]") + + # Reset per-utterance live transcription state only after final STT + # completes. The streaming handler carries fixed sentence timing within + # an utterance, and stale timing must not leak into the next turn. + if self.enable_live_transcription: + self.processing_final = False + self._reset_live_transcription_state(clear_turn=True) + + yield Transcription( + text=pred_text, + language_code=language_code, + turn_id=vad_audio.turn_id, + turn_revision=vad_audio.turn_revision, + speech_stopped_at_s=vad_audio.created_at_s, + ) + + @property + def timing_log_level(self) -> int: + return logging.INFO + + def should_log_timing(self, output: STTOut) -> bool: + return isinstance(output, Transcription) and self.last_time > self.min_time_to_debug + + def _detect_language_from_text(self, text: str) -> Optional[str]: + """ + Detect language from transcribed text using lingua-py. + + Args: + text: Transcribed text string + + Returns: + Detected language code or None if detection fails + """ + if not LINGUA_AVAILABLE: + logger.warning("lingua-py not available, cannot detect language from text") + return None + + # Skip very short utterances where language ID is still too noisy. + if not text or len(text.strip()) < 20: + return None + + detected = _lingua_detector.detect_language_of(text) + if detected is None: + return None + + code = detected.iso_code_639_1.name.lower() + # Map back lingua-specific codes to our supported codes + return {v: k for k, v in _LINGUA_CODE_MAP.items()}.get(code, code) + + @contextmanager + def _compute_lock_context(self, handler_name: str, timeout: float) -> Iterator[bool]: + if self.backend == "mlx": + with MLXLockContext(handler_name=handler_name, timeout=timeout) as acquired: + yield acquired + return + + lock_start_s = perf_counter() + acquired = self.compute_lock.acquire(timeout=timeout) + wait_s = perf_counter() - lock_start_s + hold_start_s: float | None = None + if acquired: + if wait_s >= 0.25: + logger.info("%s: compute lock acquired after %.2fs", handler_name, wait_s) + else: + logger.debug("%s: compute lock acquired after %.3fs", handler_name, wait_s) + hold_start_s = perf_counter() + else: + logger.warning("%s: Failed to acquire compute lock after %.3fs (timeout=%s)", handler_name, wait_s, timeout) + try: + yield acquired + finally: + if acquired: + assert hold_start_s is not None + self.compute_lock.release() + hold_s = perf_counter() - hold_start_s + if hold_s >= 0.25: + logger.info("%s: compute lock released after holding %.2fs", handler_name, hold_s) + else: + logger.debug("%s: compute lock released after holding %.3fs", handler_name, hold_s) + + def _show_progressive_transcription(self, audio_input: np.ndarray) -> str: + """Run progressive transcription, print to console, and return the text.""" + result = self.streaming_handler.transcribe_incremental(audio_input) + rich_text = Text() + if result.fixed_text: + rich_text.append("Live: ", style="dim") + rich_text.append(result.fixed_text, style="yellow") + if result.active_text: + rich_text.append(" ", style="dim") + + if result.active_text: + if not result.fixed_text: + rich_text.append("Live: ", style="dim") + rich_text.append(result.active_text, style="cyan dim") + + progressive_text = self._build_progressive_text(result) + if progressive_text: + self._print_live_transcription(rich_text, progressive_text) + + return progressive_text + + def _print_live_transcription(self, rich_text: Text, progressive_text: str) -> None: + is_terminal = bool(getattr(console, "is_terminal", False)) + if is_terminal: + self._write_live_control("\r\x1b[2K") + if rich_text: + console.print(self._truncate_live_transcription(rich_text), end="") + else: + fallback = Text("Live: ", style="dim") + fallback.append(progressive_text, style="cyan dim") + console.print(self._truncate_live_transcription(fallback), end="") + self._write_live_control("\r") + self._live_transcription_active = True + return + + if rich_text: + console.print(rich_text) + else: + console.print(f"[dim]Live: [/dim]{progressive_text}") + + def _clear_live_transcription_line(self) -> None: + if not getattr(self, "_live_transcription_active", False): + return + self._write_live_control("\r\x1b[2K") + self._live_transcription_active = False + + def _write_live_control(self, sequence: str) -> None: + file = getattr(console, "file", None) + if file is None: + return + file.write(sequence) + file.flush() + + def _truncate_live_transcription(self, text: Text) -> Text: + text = text.copy() + width = getattr(console, "width", 80) + try: + max_width = max(1, int(width) - 1) + except (TypeError, ValueError): + max_width = 79 + text.truncate(max_width, overflow="ellipsis") + return text + + def _prepare_live_transcription_turn(self, turn_id: str | None, turn_revision: int | None) -> None: + if not self.enable_live_transcription: + return + turn_key = (turn_id, turn_revision) + if getattr(self, "_live_turn_key", None) == turn_key: + return + self._reset_live_transcription_state(clear_turn=False) + self._live_turn_key = turn_key + + def _reset_live_transcription_state(self, clear_turn: bool) -> None: + self._clear_live_transcription_line() + streaming_handler = getattr(self, "streaming_handler", None) + if streaming_handler is not None: + streaming_handler.reset() + if clear_turn: + self._live_turn_key = None + + def _build_progressive_text(self, result: ProgressiveStreamPartial) -> str: + parts = [] + if result.fixed_text: + parts.append(result.fixed_text.strip()) + if result.active_text: + parts.append(result.active_text.strip()) + return " ".join(part for part in parts if part).strip() + + def _process_mlx_final(self, audio_input: np.ndarray) -> tuple[str, str]: + """Process final audio using MLX backend with streaming handler.""" + # If we have fixed sentences from progressive updates, only transcribe the new part + if ( + self.streaming_handler is not None + and hasattr(self.streaming_handler, "fixed_sentences") + and self.streaming_handler.fixed_sentences + ): + self._clear_live_transcription_line() + + # Get fixed text from previous progressive updates + fixed_text = " ".join(self.streaming_handler.fixed_sentences).strip() + + # Calculate where fixed part ends in audio + fixed_end_time = self.streaming_handler.fixed_end_time + sample_rate = 16000 + fixed_end_sample = int(fixed_end_time * sample_rate) + if fixed_end_sample > len(audio_input): + logger.warning( + "Ignoring stale progressive fixed text: fixed_end_sample=%d exceeds final audio samples=%d", + fixed_end_sample, + len(audio_input), + ) + pred_text, language_code = self._process_mlx(audio_input) + return pred_text, language_code + + # Only transcribe the part after fixed sentences + if fixed_end_sample < len(audio_input): + remaining_audio = audio_input[fixed_end_sample:] + + import mlx.core as mx + + audio_mx = mx.array(remaining_audio, dtype=mx.float32) + result = self.model.decode_chunk(audio_mx, verbose=False) + + if hasattr(result, "text"): + new_text = result.text.strip() + else: + new_text = str(result).strip() + + # Combine fixed + new + pred_text = f"{fixed_text} {new_text}".strip() if new_text else fixed_text + else: + # All audio already transcribed in progressive updates + pred_text = fixed_text + + # Reset streaming handler for next utterance + self.streaming_handler.reset() + else: + # No progressive updates, transcribe everything + pred_text, language_code = self._process_mlx(audio_input) + return pred_text, language_code + + # Determine language + if self.start_language and self.start_language != "auto": + language_code = self.start_language + else: + detected_lang = self._detect_language_from_text(pred_text) + if detected_lang: + language_code = detected_lang + else: + language_code = self.last_language + + return pred_text, language_code + + def _process_mlx(self, audio_input: np.ndarray) -> tuple[str, str]: + """Process audio using MLX backend.""" + import mlx.core as mx + + # Convert numpy array to mx.array + audio_mx = mx.array(audio_input, dtype=mx.float32) + + # Call decode_chunk directly with the audio array + result = self.model.decode_chunk(audio_mx, verbose=False) + + # Extract text from result + if hasattr(result, "text"): + pred_text = result.text.strip() + else: + pred_text = str(result).strip() + + # Determine language: + # 1. Use fixed language if specified by user + # 2. Try to detect from transcribed text using langdetect + # 3. Fall back to last known language + if self.start_language and self.start_language != "auto": + language_code = self.start_language + else: + # Detect language from transcribed text + detected_lang = self._detect_language_from_text(pred_text) + if detected_lang: + language_code = detected_lang + else: + language_code = self.last_language + + return pred_text, language_code + + def _process_nano_parakeet(self, audio_input: np.ndarray) -> tuple[str, str]: + """Process audio using nano-parakeet backend.""" + pred_text = self.model.transcribe(audio_input).strip() + + if self.start_language and self.start_language != "auto": + language_code = self.start_language + else: + detected_lang = self._detect_language_from_text(pred_text) + if detected_lang: + language_code = detected_lang + else: + language_code = self.last_language + + return pred_text, language_code + + def cleanup(self) -> None: + """Clean up model resources.""" + logger.info(f"Cleaning up {self.__class__.__name__}") + if hasattr(self, "model"): + del self.model + + def on_session_end(self) -> None: + super().on_session_end() + self.last_language = self.start_language if self.start_language else "en" + if self.enable_live_transcription: + self.processing_final = False + self._reset_live_transcription_state(clear_turn=True) + logger.debug("Parakeet TDT session state reset") diff --git a/speech_to_speech/STT/smart_progressive_streaming.py b/speech_to_speech/STT/smart_progressive_streaming.py new file mode 100644 index 0000000..61e197f --- /dev/null +++ b/speech_to_speech/STT/smart_progressive_streaming.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +""" +Smart Progressive Streaming Handler + +Provides frequent partial transcriptions (every 500ms) with: +- Growing window up to 15s for accuracy +- Sentence-boundary-aware window sliding for audio > 15s +- Fixed sentences + active transcription +""" + +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any, Generator + +import numpy as np + + +@dataclass +class PartialTranscription: + """Result from progressive streaming.""" + + fixed_text: str # Sentences that won't change + active_text: str # Current partial transcription + timestamp: float # Current position in audio + is_final: bool # True if this is the last update + + +class SmartProgressiveStreamingHandler: + """ + Smart progressive streaming with sentence-aware window management. + + Strategy: + 1. Emit partial transcriptions every 500ms + 2. Use growing window (up to 15s) for better accuracy + 3. When audio > 15s, slide window using sentence boundaries: + - Keep completed sentences as "fixed" + - Only re-transcribe the "active" portion + """ + + def __init__( + self, + model: Any, + emission_interval: float = 0.5, + max_window_size: float = 15.0, + sentence_buffer: float = 2.0, + ) -> None: + """ + Args: + model: Parakeet model with sentence alignment + emission_interval: Emit partial transcription every N seconds (default 500ms) + max_window_size: Maximum window size before sliding (default 15s) + sentence_buffer: Keep last N seconds of sentences in active window (default 2s) + """ + self.model = model + self.emission_interval = emission_interval + self.max_window_size = max_window_size + self.sentence_buffer = sentence_buffer + self.sample_rate = 16000 + + # State for incremental streaming + self.reset() + + def reset(self) -> None: + """Reset state for new streaming session.""" + self.fixed_sentences: list[str] = [] + self.fixed_end_time: float = 0.0 + self.last_transcribed_length: int = 0 + + def _decode_window(self, audio_window: np.ndarray) -> Any: + """ + Decode an audio window and return an object with: + - text: full transcript for the window + - sentences: list of objects with .text and .end (seconds) + """ + if hasattr(self.model, "decode_chunk"): + import mlx.core as mx + + audio_mx = mx.array(audio_window, dtype=mx.float32) + return self.model.decode_chunk(audio_mx, verbose=False) + + # nano-parakeet: use timestamps from transcribe + result = self.model.transcribe(audio_window, timestamps=True) + segments = result.timestamp.get("segment", []) if hasattr(result, "timestamp") else [] + sentences = [SimpleNamespace(text=seg.get("segment", "").strip(), end=seg.get("end", 0.0)) for seg in segments] + return SimpleNamespace(text=getattr(result, "text", ""), sentences=sentences) + + def transcribe_incremental(self, audio: np.ndarray) -> PartialTranscription: + """ + Transcribe audio incrementally (for live streaming). + + Call this repeatedly with growing audio buffer. + Returns a single PartialTranscription for current state. + """ + # Skip if not enough new audio + current_length = len(audio) + if current_length < self.sample_rate * 0.5: # Need at least 500ms + return PartialTranscription( + fixed_text=" ".join(self.fixed_sentences), + active_text="", + timestamp=current_length / self.sample_rate, + is_final=False, + ) + + # Skip if no new audio since last transcription + if current_length == self.last_transcribed_length: + return PartialTranscription( + fixed_text=" ".join(self.fixed_sentences), + active_text="", + timestamp=current_length / self.sample_rate, + is_final=False, + ) + + self.last_transcribed_length = current_length + + # Extract window for transcription (from last fixed sentence to end) + window_start_samples = int(self.fixed_end_time * self.sample_rate) + audio_window = audio[window_start_samples:] + + # Transcribe current window + result = self._decode_window(audio_window) + + # Check if window exceeds max_window_size + window_duration = len(audio_window) / self.sample_rate + + if window_duration >= self.max_window_size and len(result.sentences) > 1: + # Window is too large - fix some sentences + cutoff_time = window_duration - self.sentence_buffer + + # Find sentences to fix + new_fixed_sentences = [] + new_fixed_end_time = self.fixed_end_time + + for sentence in result.sentences: + sentence_abs_time = self.fixed_end_time + sentence.end + + if sentence.end < cutoff_time: + # Fix this sentence + new_fixed_sentences.append(sentence.text.strip()) + new_fixed_end_time = sentence_abs_time + else: + break + + if new_fixed_sentences: + self.fixed_sentences.extend(new_fixed_sentences) + self.fixed_end_time = new_fixed_end_time + + # Re-transcribe from new fixed point + window_start_samples = int(self.fixed_end_time * self.sample_rate) + audio_window = audio[window_start_samples:] + result = self._decode_window(audio_window) + + # Build output + fixed_text = " ".join(self.fixed_sentences) + active_text = result.text.strip() + timestamp = len(audio) / self.sample_rate + + return PartialTranscription(fixed_text=fixed_text, active_text=active_text, timestamp=timestamp, is_final=False) + + def transcribe_progressive(self, audio: np.ndarray) -> Generator[PartialTranscription, None, None]: + """ + Transcribe audio with smart progressive emissions. + + Yields PartialTranscription with: + - fixed_text: Completed sentences (won't change) + - active_text: Current partial transcription + - timestamp: Current position + - is_final: True for last update + """ + position = 0 # Start of current window (in samples) + fixed_sentences = [] # List of completed sentence texts + fixed_end_time = 0.0 # End time of last fixed sentence + + while position < len(audio): + # Determine current window end + remaining = (len(audio) - position) / self.sample_rate + + if remaining <= self.emission_interval: + # Last chunk - process everything remaining + window_end = len(audio) + is_final = True + else: + # Regular chunk + window_end = min(len(audio), position + int(self.emission_interval * self.sample_rate)) + is_final = False + + # Extract window for transcription + # Window includes: fixed_end_time to current position + # (we re-transcribe from last fixed sentence to get better context) + window_start_samples = int(fixed_end_time * self.sample_rate) + audio_window = audio[window_start_samples:window_end] + + # Transcribe current window + result = self._decode_window(audio_window) + + # Check if window exceeds max_window_size + window_duration = (window_end - window_start_samples) / self.sample_rate + + if window_duration >= self.max_window_size and len(result.sentences) > 1: + # Window is too large - need to fix some sentences + # Strategy: Keep sentences up to (max_window - sentence_buffer) as fixed + cutoff_time = window_duration - self.sentence_buffer + + # Find sentences that are completed and before cutoff + new_fixed_sentences = [] + new_fixed_end_time = fixed_end_time + + for sentence in result.sentences: + sentence_abs_time = fixed_end_time + sentence.end + + if sentence.end < cutoff_time: + # This sentence is complete and old enough to fix + new_fixed_sentences.append(sentence.text.strip()) + new_fixed_end_time = sentence_abs_time + else: + # Stop here - keep remaining sentences as active + break + + if new_fixed_sentences: + fixed_sentences.extend(new_fixed_sentences) + fixed_end_time = new_fixed_end_time + + # Re-transcribe from new fixed_end_time + window_start_samples = int(fixed_end_time * self.sample_rate) + audio_window = audio[window_start_samples:window_end] + result = self._decode_window(audio_window) + + # Build output + fixed_text = " ".join(fixed_sentences) + active_text = result.text.strip() + timestamp = window_end / self.sample_rate + + yield PartialTranscription( + fixed_text=fixed_text, active_text=active_text, timestamp=timestamp, is_final=is_final + ) + + # Move to next position + position = window_end + + if is_final: + break + + +def demo_smart_progressive() -> None: + """Demonstrate smart progressive streaming.""" + import time + + import soundfile as sf + from mlx_audio.stt.generate import load_model + + print("=" * 80) + print("SMART PROGRESSIVE STREAMING DEMO") + print("=" * 80) + print("\nFeatures:") + print(" • Partial updates every 500ms") + print(" • Growing window up to 15s") + print(" • Sentence-aware window sliding for long audio") + print() + + # Load model + print("Loading model...") + model = load_model("mlx-community/parakeet-tdt-0.6b-v3") + + # Load audio + audio, sr = sf.read("reachy-voice-test.wav") + if len(audio.shape) > 1: + audio = audio.mean(axis=1) + if sr != 16000: + from scipy import signal + + audio = signal.resample(audio, int(len(audio) * 16000 / sr)) + audio = audio.astype(np.float32) + + # Test cases + test_cases = [ + (audio, "Short (5.8s)"), + (np.concatenate([audio] * 3), "Medium (17.5s) - exceeds 15s window"), + (np.concatenate([audio] * 5), "Long (29.1s) - multiple sentence fixings"), + ] + + for test_audio, label in test_cases: + duration = len(test_audio) / 16000 + + print(f"\n{'=' * 80}") + print(f"TEST: {label} - Duration: {duration:.1f}s") + print("=" * 80) + + handler = SmartProgressiveStreamingHandler( + model, + emission_interval=0.5, # 500ms updates + max_window_size=15.0, # Max 15s window + sentence_buffer=2.0, # Keep 2s of sentences in active window + ) + + print("\nProgressive transcriptions (showing every 4th update for readability):") + print() + + update_count = 0 + start_time = time.perf_counter() + + for result in handler.transcribe_progressive(test_audio): + update_count += 1 + + # Show every other update (every 1s) to keep output manageable + if update_count % 2 == 0 or result.is_final: + elapsed = time.perf_counter() - start_time + marker = "FINAL" if result.is_final else f"Update {update_count}" + + print(f"[{result.timestamp:5.1f}s | {elapsed:.3f}s elapsed] {marker}:") + + if result.fixed_text: + print(f" Fixed: {result.fixed_text[:70]}...") + print(f" Active: {result.active_text[:70]}...") + print() + + total_time = time.perf_counter() - start_time + print(f"Total updates: {update_count}") + print(f"Total time: {total_time:.3f}s ({total_time / duration:.3f}s per audio second)") + + print("\n" + "=" * 80) + print("KEY INSIGHTS") + print("=" * 80) + print(""" +1. Frequent updates (500ms): + - User sees transcription building in real-time + - Low latency feel + +2. Growing window (up to 15s): + - Better accuracy with more context + - No arbitrary chunking for short audio + +3. Sentence-aware sliding (for audio > 15s): + - Completed sentences become "fixed" + - Only active portion re-transcribed + - Maintains accuracy while managing window size + +4. Performance: + - Processing happens DURING user speaking + - Final result available immediately when user stops + """) + + +if __name__ == "__main__": + demo_smart_progressive() diff --git a/speech_to_speech/STT/transcription_notifier.py b/speech_to_speech/STT/transcription_notifier.py new file mode 100644 index 0000000..dd75f9a --- /dev/null +++ b/speech_to_speech/STT/transcription_notifier.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import logging +from queue import Queue +from threading import Event +from typing import Iterator, Union + +from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig +from speech_to_speech.baseHandler import BaseHandler +from speech_to_speech.LLM.chat import make_user_message +from speech_to_speech.pipeline.events import PartialTranscriptionEvent, TranscriptionCompletedEvent +from speech_to_speech.pipeline.handler_types import LLMIn, STTOut +from speech_to_speech.pipeline.messages import GenerateResponseRequest, PartialTranscription, Transcription +from speech_to_speech.pipeline.queue_types import TextEventItem + +logger = logging.getLogger(__name__) + + +class TranscriptionNotifier(BaseHandler[STTOut, Union[STTOut, LLMIn]]): + """Sits between STT and LLM. + + For **realtime mode** (no ``runtime_config``): emits transcription events + on ``text_output_queue`` for protocol translation but yields nothing -- the + ``RealtimeService`` builds ``GenerateResponseRequest`` directly. + + For **legacy mode** (``runtime_config`` provided): appends the user + message to ``runtime_config.chat`` and yields a + ``GenerateResponseRequest`` so the LLM handler receives a uniform input + type regardless of pipeline mode. + """ + + def setup( + self, + text_output_queue: Queue[TextEventItem] | None = None, + runtime_config: RuntimeConfig | None = None, + should_listen: Event | None = None, + ) -> None: + self.text_output_queue = text_output_queue + self.runtime_config = runtime_config + self.should_listen = should_listen + + def process(self, transcription: STTOut) -> Iterator[Union[STTOut, LLMIn]]: + if isinstance(transcription, PartialTranscription): + if self.text_output_queue and transcription.text: + self.text_output_queue.put( + PartialTranscriptionEvent( + delta=str(transcription.text), + turn_id=transcription.turn_id, + turn_revision=transcription.turn_revision, + ) + ) + logger.debug("Partial transcription: %s", str(transcription.text)[:80]) + return + + if isinstance(transcription, Transcription): + text = transcription.text + language_code = transcription.language_code + turn_id = transcription.turn_id + turn_revision = transcription.turn_revision + speech_stopped_at_s = transcription.speech_stopped_at_s + else: + text = transcription + language_code = None + turn_id = None + turn_revision = None + speech_stopped_at_s = None + + transcript = str(text) + # Always close the client-visible transcription item. Empty final STT + # results should not trigger the LLM, but clients may already have + # received partial deltas and still need a completed event. + if self.text_output_queue is not None: + self.text_output_queue.put( + TranscriptionCompletedEvent( + transcript=transcript, + language_code=language_code, + turn_id=turn_id, + turn_revision=turn_revision, + speech_stopped_at_s=speech_stopped_at_s, + ) + ) + + if not transcript: + logger.debug("Transcription completed with empty transcript") + if self.should_listen is not None: + self.should_listen.set() + logger.debug("Empty transcription completed; listening re-enabled") + return + + if language_code: + logger.info("Transcription completed (language=%s): %s", language_code, transcript) + else: + logger.info("Transcription completed: %s", transcript) + + if self.runtime_config is not None: + self.runtime_config.chat.add_item(make_user_message(transcript)) + yield GenerateResponseRequest( + runtime_config=self.runtime_config, + language_code=language_code, + turn_id=turn_id, + turn_revision=turn_revision, + speech_stopped_at_s=speech_stopped_at_s, + ) diff --git a/speech_to_speech/STT/whisper_stt_handler.py b/speech_to_speech/STT/whisper_stt_handler.py new file mode 100644 index 0000000..ae1905e --- /dev/null +++ b/speech_to_speech/STT/whisper_stt_handler.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import logging +from copy import copy +from typing import Any, Iterator, Optional + +import numpy as np +import torch +from rich.console import Console +from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor + +from speech_to_speech.pipeline.handler_types import STTIn, STTOut +from speech_to_speech.pipeline.messages import Transcription +from speech_to_speech.STT.base_stt_handler import BaseSTTHandler + +logger = logging.getLogger(__name__) +console = Console() + +SUPPORTED_LANGUAGES = [ + "en", + "fr", + "es", + "zh", + "ja", + "ko", + "hi", + "de", + "pt", + "pl", + "it", + "nl", +] + + +class WhisperSTTHandler(BaseSTTHandler): + """ + Handles the Speech To Text generation using a Whisper model. + """ + + def setup( + self, + model_name: str = "distil-whisper/distil-large-v3", + device: str = "cuda", + torch_dtype: str = "float16", + compile_mode: Optional[str] = None, + language: Optional[str] = None, + gen_kwargs: dict[str, Any] = {}, + ) -> None: + self.device = device + self.torch_dtype = getattr(torch, torch_dtype) + self.compile_mode = compile_mode + self.gen_kwargs = gen_kwargs + self.start_language = language + self.last_language = language if language != "auto" else None + if self.last_language is not None: + self.gen_kwargs["language"] = self.last_language + + self.processor = AutoProcessor.from_pretrained(model_name) + self.model = AutoModelForSpeechSeq2Seq.from_pretrained( + model_name, + torch_dtype=self.torch_dtype, + ).to(device) + + # compile + if self.compile_mode: + self.model.generation_config.cache_implementation = "static" + self.model.forward = torch.compile(self.model.forward, mode=self.compile_mode, fullgraph=True) + self.warmup() + + def prepare_model_inputs(self, audio: np.ndarray) -> torch.Tensor: + input_features = self.processor(audio, sampling_rate=16000, return_tensors="pt").input_features + input_features = input_features.to(self.device, dtype=self.torch_dtype) + + return input_features + + def warmup(self) -> None: + logger.info(f"Warming up {self.__class__.__name__}") + + # 2 warmup steps for no compile or compile mode with CUDA graphs capture + n_steps = 1 if self.compile_mode == "default" else 2 + dummy_input = torch.randn( + (1, self.model.config.num_mel_bins, 3000), + dtype=self.torch_dtype, + device=self.device, + ) + if self.compile_mode not in (None, "default"): + # generating more tokens than previously will trigger CUDA graphs capture + # one should warmup with a number of generated tokens above max tokens targeted for subsequent generation + # hence, having min_new_tokens < max_new_tokens in the future doesn't make sense + warmup_gen_kwargs = { + "min_new_tokens": self.gen_kwargs["max_new_tokens"], # Yes, assign max_new_tokens to min_new_tokens + "max_new_tokens": self.gen_kwargs["max_new_tokens"], + **self.gen_kwargs, + } + else: + warmup_gen_kwargs = self.gen_kwargs + + if self.device == "cuda": + start_event = torch.cuda.Event(enable_timing=True) + end_event = torch.cuda.Event(enable_timing=True) + torch.cuda.synchronize() + start_event.record() + + for _ in range(n_steps): + _ = self.model.generate(dummy_input, **warmup_gen_kwargs) + + if self.device == "cuda": + end_event.record() + torch.cuda.synchronize() + + logger.info( + f"{self.__class__.__name__}: warmed up! time: {start_event.elapsed_time(end_event) * 1e-3:.3f} s" + ) + + def process(self, vad_audio: STTIn) -> Iterator[STTOut]: + logger.debug("infering whisper...") + + input_features = self.prepare_model_inputs(vad_audio.audio) + pred_ids = self.model.generate(input_features, **self.gen_kwargs) + language_code = self.processor.tokenizer.decode(pred_ids[0, 1])[2:-2] # remove "<|" and "|>" + + if language_code not in SUPPORTED_LANGUAGES: # reprocess with the last language + logger.warning("Whisper detected unsupported language: %s", language_code) + gen_kwargs = copy(self.gen_kwargs) + gen_kwargs["language"] = self.last_language + language_code = self.last_language + pred_ids = self.model.generate(input_features, **gen_kwargs) + else: + self.last_language = language_code + + pred_text = self.processor.batch_decode(pred_ids, skip_special_tokens=True, decode_with_timestamps=False)[0] + language_code = self.processor.tokenizer.decode(pred_ids[0, 1])[2:-2] # remove "<|" and "|>" + + logger.debug("finished whisper inference") + console.print(f"[yellow]USER: {pred_text}") + logger.debug(f"Language Code Whisper: {language_code}") + + if self.start_language == "auto": + language_code += "-auto" + + yield Transcription( + text=pred_text, + language_code=language_code, + turn_id=vad_audio.turn_id, + turn_revision=vad_audio.turn_revision, + speech_stopped_at_s=vad_audio.created_at_s, + ) diff --git a/speech_to_speech/TTS/README.md b/speech_to_speech/TTS/README.md new file mode 100644 index 0000000..33686d4 --- /dev/null +++ b/speech_to_speech/TTS/README.md @@ -0,0 +1,155 @@ +# TTS Summary + +## Available TTS modes (`--tts`) + +Runtime-supported values in `s2s_pipeline.py`: + +- `chatTTS` → `chatTTS_handler.py` +- `facebookMMS` → `facebookmms_handler.py` +- `pocket` → `pocket_tts_handler.py` +- `kokoro` → `kokoro_handler.py` +- `qwen3` → `qwen3_tts_handler.py` + +Deprecated TTS implementations, including MeloTTS, live in [`../../../archive/TTS`](../../../archive/TTS) and are no longer wired into `s2s_pipeline.py`. + +## Usage + +### 1) ChatTTS (`--tts chatTTS`) + +Primary args prefix: `--chat_tts_*` + +```bash +python s2s_pipeline.py \ + --tts chatTTS \ + --chat_tts_device cuda \ + --chat_tts_stream true \ + --chat_tts_chunk_size 512 +``` + +### 2) Facebook MMS (`--tts facebookMMS`) + +Primary args prefix: `--facebook_mms_*` plus `--tts_language` + +```bash +python s2s_pipeline.py \ + --tts facebookMMS \ + --facebook_mms_device cuda \ + --tts_language en +``` + +This handler maps STT language codes (e.g. `en`, `fr`, `es`) to MMS model suffixes (e.g. `eng`, `fra`, `spa`) and reloads the model on language changes. + +### 3) Pocket TTS (`--tts pocket`) + +Primary args prefix: `--pocket_tts_*` + +```bash +python s2s_pipeline.py \ + --tts pocket \ + --pocket_tts_voice jean \ + --pocket_tts_device cpu \ + --pocket_tts_sample_rate 16000 +``` + +Available preset voices include: +`alba`, `marius`, `javert`, `jean`, `fantine`, `cosette`, `eponine`, `azelma`. + +### 4) Kokoro (`--tts kokoro`) + +Primary args prefix: `--kokoro_*` + +```bash +python s2s_pipeline.py \ + --tts kokoro \ + --kokoro_device auto \ + --kokoro_voice bm_fable \ + --kokoro_lang_code b +``` + +Behavior: +- Uses MLX backend on Apple Silicon (`mlx-community/Kokoro-82M-bf16`) +- Uses native kokoro pipeline otherwise (`hexgrad/Kokoro-82M`) +- Can auto-switch voice/language based on STT language code mapping + +### 5) Qwen3-TTS (`--tts qwen3`) + +Primary args prefix: `--qwen3_tts_*` + +```bash +python s2s_pipeline.py \ + --tts qwen3 \ + --qwen3_tts_model_name Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \ + --qwen3_tts_device cuda \ + --qwen3_tts_backend ggml \ + --qwen3_tts_speaker Aiden \ + --qwen3_tts_language auto \ + --qwen3_tts_non_streaming_mode True +``` + +Behavior: +- Uses `faster-qwen3-tts` on non-macOS platforms, defaulting to the GGML backend. Pass `--qwen3_tts_backend torch` to use the CUDA-graphs backend instead. +- Uses `mlx-audio` on Apple Silicon and auto-maps `Qwen/...` model IDs to `mlx-community/...`, defaulting to the `6bit` MLX variant unless the model name already pins a suffix. +- Supports MLX quantization overrides on Apple Silicon via `--qwen3_tts_mlx_quantization bf16|4bit|6bit|8bit`. +- Keeps the existing voice-clone/custom-voice/voice-design handler flow intact. +- Defaults to the CustomVoice model with speaker `Aiden`, so no reference audio is required. Voice-clone/base models can still use `--qwen3_tts_ref_audio`. + +Install notes for Linux GGML: +- The default PyPI `qwentts-cpp-python` wheel targets CUDA 12.8. +- If that wheel does not match your CUDA runtime, install one of the Hugging Face wheelhouse builds before installing `speech-to-speech`. + +```bash +pip install "qwentts-cpp-python==0.3.1+cu130" \ + -f https://huggingface.co/datasets/andito/qwentts-cpp-python-wheels/tree/main/whl/cu130 +pip install speech-to-speech +``` + +Available wheelhouse directories include `cu124`, `cu128`, `cu130`, and `cpu`. + +Example for Apple Silicon using the default 6-bit MLX variant: + +```bash +python s2s_pipeline.py \ + --tts qwen3 \ + --qwen3_tts_model_name Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \ + --qwen3_tts_speaker Aiden +``` + +You can override the default and select `bf16`, `4bit`, or `8bit` explicitly: + +```bash +python s2s_pipeline.py \ + --tts qwen3 \ + --qwen3_tts_model_name Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \ + --qwen3_tts_mlx_quantization 4bit \ + --qwen3_tts_speaker Aiden +``` + +To benchmark the Apple Silicon MLX variants side by side: + +```bash +.venv/bin/python benchmark_tts.py \ + --handlers qwen3 \ + --iterations 3 \ + --qwen3_mlx_quantizations bf16 4bit 6bit 8bit +``` + +This will run separate benchmark entries for `qwen3[bf16]`, `qwen3[4bit]`, `qwen3[6bit]`, and `qwen3[8bit]`. + +## Setup + +### Low-latency GPU setup + +```bash +python s2s_pipeline.py \ + --stt whisper \ + --tts pocket +``` + +### Apple Silicon setup + +```bash +python s2s_pipeline.py \ + --local_mac_optimal_settings +``` + +`--tts pocket` and `--tts kokoro` are also valid options on macOS. diff --git a/speech_to_speech/TTS/__init__.py b/speech_to_speech/TTS/__init__.py new file mode 100644 index 0000000..2f028e2 --- /dev/null +++ b/speech_to_speech/TTS/__init__.py @@ -0,0 +1 @@ +# TTS module diff --git a/speech_to_speech/TTS/chatTTS_handler.py b/speech_to_speech/TTS/chatTTS_handler.py new file mode 100644 index 0000000..500752d --- /dev/null +++ b/speech_to_speech/TTS/chatTTS_handler.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import logging +from threading import Event +from typing import Any, Iterator + +import ChatTTS +import librosa +import numpy as np +import torch +from rich.console import Console + +from speech_to_speech.baseHandler import BaseHandler +from speech_to_speech.pipeline.cancel_scope import CancelScope +from speech_to_speech.pipeline.handler_types import TTSIn, TTSOut +from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, EndOfResponse +from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker + +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) + +logger = logging.getLogger(__name__) + +console = Console() + + +class ChatTTSHandler(BaseHandler[TTSIn, TTSOut]): + def setup( + self, + should_listen: Event, + device: str = "cuda", + gen_kwargs: dict[str, Any] = {}, # Unused + stream: bool = True, + chunk_size: int = 512, + cancel_scope: CancelScope | None = None, + speculative_turns: SpeculativeTurnTracker | None = None, + ) -> None: + self.should_listen = should_listen + self.cancel_scope = cancel_scope + self.speculative_turns = speculative_turns + self.device = device + self.model = ChatTTS.Chat() + self.model.load(compile=False) # Doesn't work for me with True + self.chunk_size = chunk_size + self.stream = stream + rnd_spk_emb = self.model.sample_random_speaker() + self.params_infer_code = ChatTTS.Chat.InferCodeParams( + spk_emb=rnd_spk_emb, + ) + self.warmup() + + def warmup(self) -> None: + logger.info(f"Warming up {self.__class__.__name__}") + _ = self.model.infer("text") + + def process(self, tts_input: TTSIn) -> Iterator[TTSOut]: + speculative_turns = getattr(self, "speculative_turns", None) + if isinstance(tts_input, EndOfResponse): + if speculative_turns and not speculative_turns.is_latest_after_reopen_grace( + tts_input.turn_id, + tts_input.turn_revision, + ): + return + yield AUDIO_RESPONSE_DONE + return + + if speculative_turns and not speculative_turns.is_latest_after_reopen_grace( + tts_input.turn_id, + tts_input.turn_revision, + ): + logger.debug("Dropping stale TTS input for turn=%s rev=%s", tts_input.turn_id, tts_input.turn_revision) + return + if speculative_turns: + speculative_turns.commit(tts_input.turn_id, tts_input.turn_revision) + + text = tts_input.text + + _cancel_gen = self.cancel_scope.generation if self.cancel_scope else None + console.print(f"[green]ASSISTANT: {text}") + if self.device == "mps": + import time + + start = time.time() + torch.mps.synchronize() # Waits for all kernels in all streams on the MPS device to complete. + torch.mps.empty_cache() # Frees all memory allocated by the MPS device. + _ = time.time() - start # Removing this line makes it fail more often. I'm looking into it. + + wavs_gen = self.model.infer(text, params_infer_code=self.params_infer_code, stream=self.stream) + + if self.stream: + wavs = [np.array([])] + for gen in wavs_gen: + if ( + _cancel_gen is not None + and self.cancel_scope is not None + and self.cancel_scope.is_stale(_cancel_gen) + ): + logger.info("TTS generation cancelled (interruption)") + return + if gen[0] is None or len(gen[0]) == 0: + return + audio_chunk = librosa.resample(gen[0], orig_sr=24000, target_sr=16000) + audio_chunk = (audio_chunk * 32768).astype(np.int16)[0] + while len(audio_chunk) > self.chunk_size: + yield audio_chunk[: self.chunk_size] # Return the first chunk_size samples of the audio data + audio_chunk = audio_chunk[self.chunk_size :] # Remove the samples that have already been returned + yield np.pad(audio_chunk, (0, self.chunk_size - len(audio_chunk))) + else: + wavs = wavs_gen + if len(wavs[0]) == 0: + return + audio_chunk = librosa.resample(wavs[0], orig_sr=24000, target_sr=16000) + audio_chunk = (audio_chunk * 32768).astype(np.int16) + for i in range(0, len(audio_chunk), self.chunk_size): + yield np.pad( + audio_chunk[i : i + self.chunk_size], + (0, self.chunk_size - len(audio_chunk[i : i + self.chunk_size])), + ) diff --git a/speech_to_speech/TTS/facebookmms_handler.py b/speech_to_speech/TTS/facebookmms_handler.py new file mode 100644 index 0000000..d5430af --- /dev/null +++ b/speech_to_speech/TTS/facebookmms_handler.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import logging +from threading import Event +from typing import Any, Iterator + +import librosa +import numpy as np +import torch +from rich.console import Console +from transformers import AutoTokenizer, VitsModel + +from speech_to_speech.baseHandler import BaseHandler +from speech_to_speech.pipeline.cancel_scope import CancelScope +from speech_to_speech.pipeline.handler_types import TTSIn, TTSOut +from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, EndOfResponse +from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker + +logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.DEBUG) + +logger = logging.getLogger(__name__) + +console = Console() + +WHISPER_LANGUAGE_TO_FACEBOOK_LANGUAGE = { + "en": "eng", # English + "fr": "fra", # French + "es": "spa", # Spanish + "ko": "kor", # Korean + "hi": "hin", # Hindi + "ar": "ara", # Arabic + "hy": "hyw", # Armenian + "az": "azb", # Azerbaijani + "bu": "bul", # Bulgarian + "ca": "cat", # Catalan + "nl": "nld", # Dutch + "fi": "fin", # Finnish + "de": "deu", # German + "el": "ell", # Greek + "he": "heb", # Hebrew + "hu": "hun", # Hungarian + "is": "isl", # Icelandic + "id": "ind", # Indonesian + "ka": "kan", # Kannada + "kk": "kaz", # Kazakh + "lv": "lav", # Latvian + "zl": "zlm", # Malay + "ma": "mar", # Marathi + "fa": "fas", # Persian + "po": "pol", # Polish + "pt": "por", # Portuguese + "ro": "ron", # Romanian + "ru": "rus", # Russian + "sw": "swh", # Swahili + "sv": "swe", # Swedish + "tg": "tgl", # Tagalog + "ta": "tam", # Tamil + "th": "tha", # Thai + "tu": "tur", # Turkish + "uk": "ukr", # Ukrainian + "ur": "urd", # Urdu + "vi": "vie", # Vietnamese + "cy": "cym", # Welsh +} + + +class FacebookMMSTTSHandler(BaseHandler[TTSIn, TTSOut]): + def setup( + self, + should_listen: Event, + device: str = "cuda", + torch_dtype: str = "float32", + language: str = "en", + stream: bool = True, + chunk_size: int = 512, + cancel_scope: CancelScope | None = None, + speculative_turns: SpeculativeTurnTracker | None = None, + **kwargs: Any, + ) -> None: + self.should_listen = should_listen + self.cancel_scope = cancel_scope + self.speculative_turns = speculative_turns + self.device = device + self.torch_dtype = getattr(torch, torch_dtype) + self.stream = stream + self.chunk_size = chunk_size + self.language = language + + self._initial_language = self.language + self.load_model(self.language) + self.warmup() + + def load_model(self, language_code: str) -> None: + try: + model_name = f"facebook/mms-tts-{WHISPER_LANGUAGE_TO_FACEBOOK_LANGUAGE[language_code]}" + logger.info(f"Loading model: {model_name}") + self.model = VitsModel.from_pretrained(model_name).to(self.device) # type: ignore[arg-type] + self.tokenizer = AutoTokenizer.from_pretrained(model_name) + self.language = language_code + except KeyError: + logger.warning(f"Unsupported language: {language_code}. Falling back to English.") + self.load_model("en") + + def warmup(self) -> None: + logger.info(f"Warming up {self.__class__.__name__}") + self.generate_audio("Hello, this is a test") + + def generate_audio(self, text: str) -> torch.Tensor | None: + if not text: + logger.warning("Received empty text input") + return None + + try: + logger.debug(f"Tokenizing text: {text}") + logger.debug(f"Current language: {self.language}") + logger.debug(f"Tokenizer: {self.tokenizer}") + + inputs = self.tokenizer(text, return_tensors="pt", padding=True, truncation=True) + input_ids = inputs.input_ids.to(self.device).long() + attention_mask = inputs.attention_mask.to(self.device) + + logger.debug(f"Input IDs shape: {input_ids.shape}, dtype: {input_ids.dtype}") + logger.debug(f"Input IDs: {input_ids}") + + if input_ids.numel() == 0: + logger.error("Input IDs tensor is empty") + return None + + with torch.no_grad(): + output = self.model(input_ids=input_ids, attention_mask=attention_mask) + + logger.debug(f"Output waveform shape: {output.waveform.shape}") + return output.waveform + except Exception as e: + logger.error(f"Error in generate_audio: {str(e)}") + logger.exception("Full traceback:") + return None + + def process(self, tts_input: TTSIn) -> Iterator[TTSOut]: + speculative_turns = getattr(self, "speculative_turns", None) + if isinstance(tts_input, EndOfResponse): + if speculative_turns and not speculative_turns.is_latest_after_reopen_grace( + tts_input.turn_id, + tts_input.turn_revision, + ): + return + yield AUDIO_RESPONSE_DONE + return + + if speculative_turns and not speculative_turns.is_latest_after_reopen_grace( + tts_input.turn_id, + tts_input.turn_revision, + ): + logger.debug("Dropping stale TTS input for turn=%s rev=%s", tts_input.turn_id, tts_input.turn_revision) + return + if speculative_turns: + speculative_turns.commit(tts_input.turn_id, tts_input.turn_revision) + + gen = self.cancel_scope.generation if self.cancel_scope else None + language_code = tts_input.language_code + text = tts_input.text + + console.print(f"[green]ASSISTANT: {text}") + logger.debug(f"Processing text: {text}") + logger.debug(f"Language code: {language_code}") + + if language_code is not None and self.language != language_code: + try: + logger.info(f"Switching language from {self.language} to {language_code}") + self.load_model(language_code) + except KeyError: + console.print( + f"[red]Language {language_code} not supported by Facebook MMS. Using {self.language} instead." + ) + logger.warning(f"Unsupported language: {language_code}") + + audio_output = self.generate_audio(text) + + if audio_output is None or audio_output.numel() == 0: + logger.warning("No audio output generated") + return + + audio_numpy = audio_output.cpu().numpy().squeeze() + logger.debug(f"Raw audio shape: {audio_numpy.shape}, dtype: {audio_numpy.dtype}") + + audio_resampled = librosa.resample(audio_numpy, orig_sr=self.model.config.sampling_rate, target_sr=16000) + logger.debug(f"Resampled audio shape: {audio_resampled.shape}, dtype: {audio_resampled.dtype}") + + audio_int16 = (audio_resampled * 32768).astype(np.int16) + logger.debug(f"Final audio shape: {audio_int16.shape}, dtype: {audio_int16.dtype}") + + if self.stream: + for i in range(0, len(audio_int16), self.chunk_size): + if gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen): + logger.info("TTS generation cancelled (interruption)") + return + chunk = audio_int16[i : i + self.chunk_size] + yield np.pad(chunk, (0, self.chunk_size - len(chunk))) + else: + for i in range(0, len(audio_int16), self.chunk_size): + if gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen): + logger.info("TTS generation cancelled (interruption)") + return + yield np.pad( + audio_int16[i : i + self.chunk_size], + (0, self.chunk_size - len(audio_int16[i : i + self.chunk_size])), + ) + + def on_session_end(self) -> None: + if self.language != self._initial_language: + self.load_model(self._initial_language) + logger.debug("Facebook MMS TTS session state reset") diff --git a/speech_to_speech/TTS/kokoro_handler.py b/speech_to_speech/TTS/kokoro_handler.py new file mode 100644 index 0000000..a93b7a4 --- /dev/null +++ b/speech_to_speech/TTS/kokoro_handler.py @@ -0,0 +1,418 @@ +""" +Kokoro TTS Handler + +Supports NVIDIA Kokoro TTS model for high-quality multilingual speech synthesis. +- On Apple Silicon (MPS): Uses mlx-audio with mlx-community/Kokoro-82M-bf16 +- On CUDA/CPU: Uses native kokoro library with hexgrad/Kokoro-82M + +Model supports 8 languages with multiple voices per language. +""" + +from __future__ import annotations + +import logging +from sys import platform +from threading import Event +from typing import Any, Iterator, Optional + +import numpy as np +from rich.console import Console + +from speech_to_speech.baseHandler import BaseHandler +from speech_to_speech.pipeline.cancel_scope import CancelScope +from speech_to_speech.pipeline.handler_types import TTSIn, TTSOut +from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, EndOfResponse +from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker +from speech_to_speech.utils.mlx_lock import MLXLockContext + +logger = logging.getLogger(__name__) +console = Console() + +# Language code mapping from Whisper/langdetect language codes to Kokoro lang codes +WHISPER_LANGUAGE_TO_KOKORO_LANG = { + "en": "b", # British English + "ja": "j", # Japanese + "zh": "z", # Chinese + "fr": "f", # French + "es": "e", # Spanish + "it": "i", # Italian + "pt": "p", # Portuguese + "hi": "h", # Hindi + # Additional European languages from Parakeet - map to closest Kokoro language + "de": "b", # German -> British English (no German voice available) + "nl": "b", # Dutch -> British English + "pl": "b", # Polish -> British English + "ru": "b", # Russian -> British English + "uk": "b", # Ukrainian -> British English +} + +# Default voices for each Kokoro language code +# These are native voices that sound natural for each language +# Voice naming: first letter = language, second letter = gender (f=female, m=male) +# Available voices per language: +# a (American): af_alloy, af_aoede, af_bella, af_heart, af_jessica, af_kore, af_nicole, af_nova, af_river, af_sarah, af_sky +# am_adam, am_echo, am_eric, am_fenrir, am_liam, am_michael, am_onyx, am_puck, am_santa +# b (British): bf_alice, bf_emma, bf_isabella, bf_lily, bm_daniel, bm_fable, bm_george, bm_lewis +# e (Spanish): ef_dora, em_alex, em_santa +# f (French): ff_siwis +# h (Hindi): hf_alpha, hf_beta, hm_omega, hm_psi +# i (Italian): if_sara, im_nicola +# j (Japanese): jf_alpha, jf_gongitsune, jf_nezumi, jf_tebukuro, jm_kumo +# p (Portuguese): pf_dora, pm_alex, pm_santa +# z (Chinese): zf_xiaobei, zf_xiaoni, zf_xiaoxiao, zf_xiaoyi, zm_yunjian, zm_yunxi, zm_yunxia, zm_yunyang +KOKORO_LANG_DEFAULT_VOICES = { + "a": "af_heart", # American English female + "b": "bm_fable", # British English male + "e": "ef_dora", # Spanish female + "f": "ff_siwis", # French female + "h": "hf_alpha", # Hindi female + "i": "if_sara", # Italian female + "j": "jf_alpha", # Japanese female + "p": "pf_dora", # Portuguese female + "z": "zf_xiaobei", # Chinese female +} + + +class KokoroTTSHandler(BaseHandler[TTSIn, TTSOut]): + """ + Handles Text-to-Speech using Kokoro TTS model. + + On Apple Silicon (MPS): Uses mlx-audio with the MLX-converted model. + On CUDA/CPU: Uses native kokoro library for optimal performance. + + Kokoro 82M is a 82M parameter multilingual TTS model + supporting 8 languages with multiple voices per language. + """ + + def setup( + self, + should_listen: Event, + model_name: Optional[str] = None, + device: str = "auto", + voice: str = "bm_fable", + lang_code: str = "b", + speed: float = 1.0, + blocksize: int = 512, + gen_kwargs: dict[str, Any] | None = None, + cancel_scope: CancelScope | None = None, + speculative_turns: SpeculativeTurnTracker | None = None, + ) -> None: + """ + Initialize the Kokoro TTS model. + + Args: + model_name: Model identifier. Defaults are: + - MPS: "mlx-community/Kokoro-82M-bf16" + - CUDA/CPU: "hexgrad/Kokoro-82M" + device: Device to use ("auto", "cuda", "mps", "cpu") + voice: Voice identifier (e.g. "bm_fable") + lang_code: Language code (e.g. "b" for British English) + speed: Speech speed multiplier + blocksize: Audio chunk size for streaming + gen_kwargs: Unused, for pipeline compatibility + """ + self.should_listen = should_listen + self.voice = voice + self.lang_code = lang_code + self.speed = speed + self.blocksize = blocksize + self.cancel_scope = cancel_scope + self.speculative_turns = speculative_turns + + # Determine device + if device == "auto": + if platform == "darwin": + self.device = "mps" + else: + import torch + + self.device = "cuda" if torch.cuda.is_available() else "cpu" + else: + self.device = device + + # Set default model based on device + if model_name is None: + if self.device == "mps": + model_name = "mlx-community/Kokoro-82M-bf16" + else: + model_name = "hexgrad/Kokoro-82M" + + self.model_name = model_name + + logger.info(f"Loading Kokoro model: {model_name} on {self.device}") + + if self.device == "mps": + self._setup_mlx(model_name) + else: + self._setup_kokoro(model_name) + + self._initial_voice = self.voice + self._initial_lang_code = self.lang_code + + self.warmup() + + def _setup_mlx(self, model_name: str) -> None: + """Setup for Apple Silicon using mlx-audio.""" + try: + from mlx_audio.tts.utils import load_model + + self.backend = "mlx" + self.model = load_model(model_name) + logger.info("MLX Audio Kokoro model loaded successfully") + + # Get or create the pipeline for our language and preload the voice + # This avoids the voice being reloaded on every generate() call + self._pipeline = self.model._get_pipeline(self.lang_code) + self._voice_tensor = self._pipeline.load_voice(self.voice) + logger.info(f"Preloaded voice: {self.voice}") + + # Preload voices for common languages to avoid download delays during inference + self._preload_multilingual_voices() + except ImportError as e: + message = str(e) + if "misaki" in message or "espeakng_loader" in message: + raise ImportError( + "Kokoro TTS on Apple Silicon requires additional mlx-audio TTS dependencies. " + f"Missing dependency: {message}. " + "Install with: pip install misaki espeakng-loader" + ) from e + raise ImportError( + "mlx-audio is required for Kokoro TTS on Apple Silicon. Install with: pip install mlx-audio" + ) from e + + def _setup_kokoro(self, model_name: str) -> None: + """Setup for CUDA/CPU using native kokoro library.""" + try: + from kokoro import KPipeline + + self.backend = "kokoro" + self.pipeline = KPipeline(lang_code=self.lang_code) + logger.info("Native Kokoro pipeline loaded successfully") + except ImportError as e: + raise ImportError( + "kokoro is required for Kokoro TTS on CUDA/CPU. " + "Install with: pip install kokoro>=0.9.2 soundfile\n" + "Also ensure espeak-ng is installed: apt-get install espeak-ng (Linux) or brew install espeak-ng (macOS)" + ) from e + + def _preload_multilingual_voices(self) -> None: + """Preload voices for common languages to avoid download delays during inference.""" + # Only preload a few commonly used language voices to avoid excessive startup time + # Users speaking other languages will experience a one-time download delay + # preload_langs = ["a", "b", "e", "f", "h", "i", "j", "p", "z"] # English, Spanish, French, Hindi, Italian, Japanese, Portuguese + preload_langs = ["a", "e", "f"] # English, Spanish, French + + for lang_code in preload_langs: + voice = KOKORO_LANG_DEFAULT_VOICES.get(lang_code) + if voice: + try: + logger.info(f"Preloading voice for language '{lang_code}': {voice}") + pipeline = self.model._get_pipeline(lang_code) + pipeline.load_voice(voice) + except Exception as e: + logger.warning(f"Failed to preload voice {voice}: {e}") + + def warmup(self) -> None: + """Warm up the model with a dummy inference.""" + logger.info(f"Warming up {self.__class__.__name__}") + + # Run a short dummy inference to warm up the model + if self.backend == "mlx": + for _ in self.model.generate( + text="Hello", + voice=self.voice, + speed=self.speed, + lang_code=self.lang_code, + ): + pass + else: + for _ in self.pipeline("Hello", voice=self.voice, speed=self.speed): + pass + + logger.info(f"{self.__class__.__name__} warmed up") + + def process(self, tts_input: TTSIn) -> Iterator[TTSOut]: + """ + Process text input and generate audio output. + + Yields: + Audio chunks as numpy int16 arrays + """ + speculative_turns = getattr(self, "speculative_turns", None) + if isinstance(tts_input, EndOfResponse): + if speculative_turns and not speculative_turns.is_latest_after_reopen_grace( + tts_input.turn_id, + tts_input.turn_revision, + ): + return + yield AUDIO_RESPONSE_DONE + return + + if speculative_turns and not speculative_turns.is_latest_after_reopen_grace( + tts_input.turn_id, + tts_input.turn_revision, + ): + logger.debug("Dropping stale TTS input for turn=%s rev=%s", tts_input.turn_id, tts_input.turn_revision) + return + if speculative_turns: + speculative_turns.commit(tts_input.turn_id, tts_input.turn_revision) + + runtime_config = tts_input.runtime_config + response = tts_input.response + language_code = tts_input.language_code + text = tts_input.text + + voice: Optional[str] = None + if response and response.audio and response.audio.output and response.audio.output.voice: + voice = str(response.audio.output.voice) + if not voice and runtime_config: + audio_cfg = runtime_config.session.audio + audio_output = audio_cfg.output if audio_cfg is not None else None + voice = str(audio_output.voice) if audio_output is not None and audio_output.voice else None + if voice: + self.voice = voice + + if self.backend == "mlx": + yield from self._process_mlx(text, language_code) + else: + yield from self._process_kokoro(text, language_code) + + def _process_mlx(self, llm_sentence: str, language_code: Optional[str] = None) -> Iterator[np.ndarray]: + """Process using MLX backend with Apple Silicon optimizations.""" + from scipy.signal import resample_poly + + gen = self.cancel_scope.generation if self.cancel_scope else None + with MLXLockContext(handler_name="KokoroTTS", timeout=10.0): + if language_code is not None: + new_lang_code = WHISPER_LANGUAGE_TO_KOKORO_LANG.get(language_code, self.lang_code) + if new_lang_code != self.lang_code: + new_voice = KOKORO_LANG_DEFAULT_VOICES.get(new_lang_code, self.voice) + logger.info( + f"Language change detected: {self.lang_code} -> {new_lang_code}, voice: {self.voice} -> {new_voice}" + ) + try: + new_pipeline = self.model._get_pipeline(new_lang_code) + new_voice_tensor = new_pipeline.load_voice(new_voice) + self.lang_code = new_lang_code + self.voice = new_voice + self._pipeline = new_pipeline + self._voice_tensor = new_voice_tensor + except Exception as e: + logger.warning( + f"Failed to switch language/voice: {e}. Keeping current language: {self.lang_code}" + ) + + console.print(f"[green]ASSISTANT: {llm_sentence}") + + # Generate audio using the preloaded pipeline directly + # This avoids the voice reload that happens in model.generate() + for result in self._pipeline( + text=llm_sentence, + voice=self.voice, + speed=self.speed, + ): + if result.audio is None: + continue + # result.audio is an mx.array with shape (1, samples), convert to numpy and squeeze + audio = np.array(result.audio, dtype=np.float32).squeeze(0) + + # Trim silence from start and end of audio + # Kokoro generates ~250ms of silence at the start and variable silence at the end + threshold = 0.01 + abs_audio = np.abs(audio) + # Find first and last samples above threshold + above_threshold = abs_audio > threshold + if np.any(above_threshold): + start_idx = int(np.argmax(above_threshold)) + end_idx = len(audio) - int(np.argmax(above_threshold[::-1])) + # Add small padding (5ms = 120 samples at 24kHz) to avoid cutting speech + padding = 120 + start_idx = max(0, start_idx - padding) + end_idx = min(len(audio), end_idx + padding) + audio = audio[start_idx:end_idx] + + # Kokoro outputs at 24kHz, resample to 16kHz for the pipeline + # Using scipy's polyphase resampling (fast and high quality) + # 16000/24000 = 2/3, so up=2, down=3 + audio = resample_poly(audio, up=2, down=3) + + # Convert to int16 format + audio = (audio * 32768).astype(np.int16) + + # Yield audio in fixed-size chunks + for i in range(0, len(audio), self.blocksize): + if gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen): + logger.info("TTS generation cancelled (interruption)") + return + chunk = audio[i : i + self.blocksize] + # Pad the last chunk if necessary + if len(chunk) < self.blocksize: + chunk = np.pad(chunk, (0, self.blocksize - len(chunk))) + logger.debug(f"TTS yielding audio chunk: {len(chunk)} samples") + yield chunk + + def _process_kokoro(self, llm_sentence: str, language_code: Optional[str] = None) -> Iterator[np.ndarray]: + """Process using native kokoro library.""" + from scipy.signal import resample_poly + + gen = self.cancel_scope.generation if self.cancel_scope else None + if language_code is not None: + new_lang_code = WHISPER_LANGUAGE_TO_KOKORO_LANG.get(language_code, self.lang_code) + if new_lang_code != self.lang_code: + new_voice = KOKORO_LANG_DEFAULT_VOICES.get(new_lang_code, self.voice) + logger.info( + f"Language change detected: {self.lang_code} -> {new_lang_code}, voice: {self.voice} -> {new_voice}" + ) + self.lang_code = new_lang_code + self.voice = new_voice + from kokoro import KPipeline + + self.pipeline = KPipeline(lang_code=self.lang_code) + + console.print(f"[green]ASSISTANT: {llm_sentence}") + + # Generate audio using Kokoro + # The pipeline yields tuples of (graphemes, phonemes, audio) + for gs, ps, audio in self.pipeline(llm_sentence, voice=self.voice, speed=self.speed): + if audio is None: + continue + + # Ensure audio is numpy array + if not isinstance(audio, np.ndarray): + audio = np.array(audio, dtype=np.float32) + else: + audio = audio.astype(np.float32) + + # Kokoro outputs at 24kHz, resample to 16kHz for the pipeline + # Using scipy's polyphase resampling (fast and high quality) + # 16000/24000 = 2/3, so up=2, down=3 + audio = resample_poly(audio, up=2, down=3) + + # Convert to int16 format + audio = (audio * 32768).astype(np.int16) + + # Yield audio in fixed-size chunks + for i in range(0, len(audio), self.blocksize): + if gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen): + logger.info("TTS generation cancelled (interruption)") + return + chunk = audio[i : i + self.blocksize] + # Pad the last chunk if necessary + if len(chunk) < self.blocksize: + chunk = np.pad(chunk, (0, self.blocksize - len(chunk))) + yield chunk + + def on_session_end(self) -> None: + self.voice = self._initial_voice + self.lang_code = self._initial_lang_code + if self.backend == "mlx": + try: + self._pipeline = self.model._get_pipeline(self.lang_code) + self._voice_tensor = self._pipeline.load_voice(self.voice) + except Exception as e: + logger.warning(f"Failed to restore initial voice/language on session end: {e}") + else: + from kokoro import KPipeline + + self.pipeline = KPipeline(lang_code=self.lang_code) + logger.debug("Kokoro TTS session state reset") diff --git a/speech_to_speech/TTS/pocket_tts_handler.py b/speech_to_speech/TTS/pocket_tts_handler.py new file mode 100644 index 0000000..a0121e9 --- /dev/null +++ b/speech_to_speech/TTS/pocket_tts_handler.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import logging +from threading import Event +from time import perf_counter +from typing import Any, Iterator + +import numpy as np +from rich.console import Console + +from speech_to_speech.baseHandler import BaseHandler +from speech_to_speech.pipeline.cancel_scope import CancelScope +from speech_to_speech.pipeline.handler_types import TTSIn, TTSOut +from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, EndOfResponse +from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker + +logger = logging.getLogger(__name__) +console = Console() + + +class PocketTTSHandler(BaseHandler[TTSIn, TTSOut]): + """ + Handler for Pocket TTS model from Kyutai Labs. + Supports streaming audio generation with voice cloning. + """ + + def setup( + self, + should_listen: Event, + device: str = "cpu", + voice: str = "alba", # Default voice from catalog + sample_rate: int = 16000, # Match the pipeline's audio output (LocalAudioStreamer uses 16kHz) + blocksize: int = 512, + max_tokens: int = 50, + gen_kwargs: dict[str, Any] | None = None, # For compatibility with pipeline, not used + cancel_scope: CancelScope | None = None, + speculative_turns: SpeculativeTurnTracker | None = None, + ) -> None: + """ + Initialize Pocket TTS handler. + + Args: + should_listen: Event to control when to start listening again + device: Device to run model on ('cpu', 'cuda', 'mps') + voice: Voice to use. Can be: + - A preset name: 'alba', 'marius', 'javert', 'jean', 'fantine', 'cosette', 'eponine', 'azelma' + - A local audio file path + - A Hugging Face path like "hf://kyutai/tts-voices/..." + sample_rate: Output sample rate (pocket-tts generates at 24kHz and will be resampled to this rate). Default 16kHz matches the pipeline's audio output. + blocksize: Size of audio blocks to yield + max_tokens: Maximum tokens to generate + """ + self.should_listen = should_listen + self.cancel_scope = cancel_scope + self.speculative_turns = speculative_turns + self.device = device + self.voice = voice + self.sample_rate = sample_rate + self.blocksize = blocksize + self.max_tokens = max_tokens + + # Suppress verbose logging from pocket_tts library + logging.getLogger("pocket_tts").setLevel(logging.WARNING) + logging.getLogger("pocket_tts.models.tts_model").setLevel(logging.WARNING) + logging.getLogger("pocket_tts.utils.utils").setLevel(logging.WARNING) + + # Import and load model + from pocket_tts import TTSModel + + logger.info("Loading Pocket TTS model") + self.model = TTSModel.load_model() + + # Move model to specified device + if device == "cuda": + self.model = self.model.cuda() + elif device == "mps": + self.model = self.model.to("mps") + elif device != "cpu": + self.model = self.model.to(device) + + logger.info(f"Pocket TTS model moved to {device}") + + # Load voice state + logger.info(f"Loading voice: {voice}") + self.voice_state = self.model.get_state_for_audio_prompt(voice) + + logger.info(f"Pocket TTS model sample rate: {self.model.sample_rate}") + + @property + def min_time_to_debug(self) -> float: + """ + Override to suppress logging for individual audio chunks. + Pocket TTS yields many small chunks (~10-20ms each), which would flood logs. + Only log if a chunk takes unusually long (>100ms). + """ + return 0.1 # 100ms threshold + + def process(self, tts_input: TTSIn) -> Iterator[TTSOut]: + speculative_turns = getattr(self, "speculative_turns", None) + if isinstance(tts_input, EndOfResponse): + if speculative_turns and not speculative_turns.is_latest_after_reopen_grace( + tts_input.turn_id, + tts_input.turn_revision, + ): + return + yield AUDIO_RESPONSE_DONE + return + + if speculative_turns and not speculative_turns.is_latest_after_reopen_grace( + tts_input.turn_id, + tts_input.turn_revision, + ): + logger.debug("Dropping stale TTS input for turn=%s rev=%s", tts_input.turn_id, tts_input.turn_revision) + return + if speculative_turns: + speculative_turns.commit(tts_input.turn_id, tts_input.turn_revision) + + gen = self.cancel_scope.generation if self.cancel_scope else None + language_code = tts_input.language_code + text = tts_input.text + logger.debug(f"Received language code: {language_code}") + + console.print(f"[green]ASSISTANT: {text}") + + # Generate audio stream + logger.debug(f"Generating audio for: {text[:50]}...") + + pipeline_start = perf_counter() + first_chunk = True + + # Calculate target chunk size in original sample rate + # We need enough samples so that after resampling we get blocksize samples + needs_resampling = self.model.sample_rate != self.sample_rate + if needs_resampling: + from scipy.signal import resample_poly + + # Target chunk size in original sample rate (before resampling) + resample_ratio = self.model.sample_rate / self.sample_rate + target_chunk_size = int(self.blocksize * resample_ratio) + # Calculate up/down factors for polyphase resampling + # For 24kHz -> 16kHz: 16000/24000 = 2/3 + from math import gcd + + g = gcd(self.sample_rate, self.model.sample_rate) + self._resample_up = self.sample_rate // g + self._resample_down = self.model.sample_rate // g + else: + target_chunk_size = self.blocksize + + # Buffer to accumulate audio until we have enough for one block + audio_buffer = [] + buffer_size = 0 + + for audio_chunk in self.model.generate_audio_stream( + self.voice_state, + text, + max_tokens=self.max_tokens, + copy_state=True, # Don't modify the original voice state + ): + if gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen): + logger.info("TTS generation cancelled (interruption)") + return + if first_chunk: + logger.debug(f"Time to first audio: {perf_counter() - pipeline_start:.3f}s") + first_chunk = False + + # Convert from torch tensor to numpy and add to buffer + audio_np = audio_chunk.cpu().numpy() + audio_buffer.append(audio_np) + buffer_size += len(audio_np) + + # Process buffer when we have enough samples for at least one block + while buffer_size >= target_chunk_size: + # Concatenate buffer + concatenated = np.concatenate(audio_buffer) + + # Extract enough samples for one block + chunk_to_process = concatenated[:target_chunk_size] + remainder = concatenated[target_chunk_size:] + + # Resample this chunk if needed + if needs_resampling: + chunk_resampled = resample_poly( + chunk_to_process, + up=self._resample_up, + down=self._resample_down, + ) + else: + chunk_resampled = chunk_to_process + + # Convert to int16 format expected by audio output + audio_int16 = (chunk_resampled * 32768).astype(np.int16) + + # Ensure exact blocksize (pad or trim if resampling caused slight size differences) + if len(audio_int16) < self.blocksize: + audio_int16 = np.pad(audio_int16, (0, self.blocksize - len(audio_int16))) + elif len(audio_int16) > self.blocksize: + audio_int16 = audio_int16[: self.blocksize] + + yield audio_int16 + + # Update buffer with remainder + audio_buffer = [remainder] if len(remainder) > 0 else [] + buffer_size = len(remainder) + + # Process any remaining audio in buffer + if audio_buffer and buffer_size > 0: + concatenated = np.concatenate(audio_buffer) + + # Resample the remainder + if needs_resampling: + concatenated = resample_poly( + concatenated, + up=self._resample_up, + down=self._resample_down, + ) + + # Convert to int16 + audio_int16 = (concatenated * 32768).astype(np.int16) + + # Yield in blocks + for i in range(0, len(audio_int16), self.blocksize): + chunk = audio_int16[i : i + self.blocksize] + # Pad last chunk if needed + if len(chunk) < self.blocksize: + chunk = np.pad(chunk, (0, self.blocksize - len(chunk))) + yield chunk diff --git a/speech_to_speech/TTS/qwen3_tts_handler.py b/speech_to_speech/TTS/qwen3_tts_handler.py new file mode 100644 index 0000000..e71d6b0 --- /dev/null +++ b/speech_to_speech/TTS/qwen3_tts_handler.py @@ -0,0 +1,903 @@ +""" +Qwen3 TTS Handler + +- On Apple Silicon: Uses mlx-audio with MLX-converted Qwen3-TTS models. +- On CUDA/CPU: Uses faster-qwen3-tts for low-latency streaming. +""" + +from __future__ import annotations + +import logging +import math +import re +import tempfile +import unicodedata +from collections.abc import Callable +from pathlib import Path +from sys import platform +from threading import Event +from time import perf_counter +from typing import Any, Iterator, Optional + +import numpy as np +import torch +from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams +from rich.console import Console + +from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig +from speech_to_speech.baseHandler import BaseHandler +from speech_to_speech.pipeline.cancel_scope import CancelScope +from speech_to_speech.pipeline.control import SESSION_END, is_control_message +from speech_to_speech.pipeline.handler_types import TTSIn, TTSOut +from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, PIPELINE_END, EndOfResponse, TTSInput +from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker +from speech_to_speech.utils.mlx_lock import MLXLockContext + +logger = logging.getLogger(__name__) +console = Console() + +DEFAULT_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" +DEFAULT_MLX_MODEL = "mlx-community/Qwen3-TTS-12Hz-1.7B-CustomVoice-6bit" +DEFAULT_REF_TEXT = "I'm confused why some people have super short timelines, yet at the same time are bullish on scaling up reinforcement learning atop LLMs. If we're actually close to a human-like learner, then this whole approach of training on verifiable outcomes." +DEFAULT_FASTER_STREAMING_CHUNK_SIZE = 8 +DEFAULT_MLX_STREAMING_CHUNK_SIZE = 4 +DEFAULT_QWEN3_TTS_MAX_NEW_TOKENS = 1536 +MIN_QWEN3_TTS_UTTERANCE_TOKENS = 360 +VALID_MLX_QUANTIZATION_SUFFIXES = ("bf16", "4bit", "6bit", "8bit") +VALID_FASTER_BACKENDS = ("ggml", "torch") +MLX_STREAMING_TOKENS_PER_SECOND = 12.5 +PIPELINE_SR = 16000 +ESTIMATED_QWEN3_WORDS_PER_SECOND = 2.6 +ESTIMATED_QWEN3_CHARS_PER_SECOND = 14.0 +QWEN3_TOKEN_SAFETY_MARGIN = 1.35 +QWEN3_BASE_PROMPT_SECONDS = 1.0 +QWEN3_PUNCTUATION_PAUSE_SECONDS = 0.5 +QWEN3_LANGUAGE_ALIASES = { + "zh": "chinese", + "zh-cn": "chinese", + "zh-hans": "chinese", + "zh-hant": "chinese", + "cmn": "chinese", + "en": "english", + "en-us": "english", + "en-gb": "english", + "ja": "japanese", + "jp": "japanese", + "ko": "korean", + "kr": "korean", + "de": "german", + "fr": "french", + "ru": "russian", + "pt": "portuguese", + "pt-br": "portuguese", + "pt-pt": "portuguese", + "es": "spanish", + "it": "italian", +} + + +class Qwen3TTSHandler(BaseHandler[TTSIn, TTSOut]): + """ + Handles Text-to-Speech using Qwen3-TTS. + + Backend selection: + - Apple Silicon (Darwin): mlx-audio + - Other platforms: faster-qwen3-tts + + Supports three generation modes depending on the loaded model: + - Voice cloning (ref_audio + ref_text) + - Custom voice (preset speakers) + - Voice design (instruct prompt) + """ + + def setup( + self, + should_listen: Event, + model_name: str = DEFAULT_MODEL, + device: str = "cuda", + dtype: str | torch.dtype = "auto", + attn_implementation: str = "eager", + backend: str = "ggml", + ref_audio: str | Path | None = None, + ref_text: str = DEFAULT_REF_TEXT, + language: str = "auto", + speaker: Optional[str] = "Aiden", + instruct: Optional[str] = None, + xvec_only: bool = False, + parity_mode: bool = False, + non_streaming_mode: bool | None = True, + mlx_quantization: Optional[str] = None, + streaming_chunk_size: int | None = None, + max_new_tokens: int = DEFAULT_QWEN3_TTS_MAX_NEW_TOKENS, + blocksize: int = 512, + gen_kwargs: dict[str, Any] | None = None, + cancel_scope: CancelScope | None = None, + speculative_turns: SpeculativeTurnTracker | None = None, + ) -> None: + self.cancel_scope = cancel_scope + self.speculative_turns = speculative_turns + self.should_listen = should_listen + self.requested_device = device + self.ref_audio = ref_audio + self.ref_text = ref_text + self.language = self._normalize_language(language) + self.speaker = speaker + self.instruct = instruct + self.xvec_only = xvec_only + self.parity_mode = parity_mode + self.non_streaming_mode = non_streaming_mode + self.faster_backend = self._normalize_faster_backend(backend) + self.mlx_quantization = self._normalize_mlx_quantization(mlx_quantization) + self.max_new_tokens = max_new_tokens + self.blocksize = blocksize + self.dtype: torch.dtype | None | str = None + self.gen_kwargs = gen_kwargs or {} + self._mlx_ref_audio_cache: dict[str, Any] = {} + self._mlx_temp_ref_audio_files: set[str] = set() + + self.backend = "mlx" if platform == "darwin" else "faster_qwen3_tts" + self.streaming_chunk_size = self._resolve_streaming_chunk_size(streaming_chunk_size) + + if self.backend == "mlx": + self.device = "mps" + self.model_name = self._resolve_mlx_model_name(model_name) + logger.info(f"Loading Qwen3-TTS model: {self.model_name} via mlx-audio on Apple Silicon") + if self.non_streaming_mode is not None: + logger.debug( + "qwen3_tts_non_streaming_mode=%s is ignored on Apple Silicon because " + "mlx-audio does not expose non_streaming_mode yet.", + self.non_streaming_mode, + ) + model_quantization = self._model_name_quantization_suffix(self.model_name) + if model_quantization and model_quantization != "bf16": + logger.info( + "Using MLX quantized Qwen3-TTS variant: %s", + model_quantization, + ) + self._setup_mlx(self.model_name) + else: + self.device = device + self.model_name = model_name + logger.info( + "Loading Qwen3-TTS model: %s via faster-qwen3-tts (%s backend)", + self.model_name, + self.faster_backend, + ) + self._setup_faster( + model_name=self.model_name, + dtype=dtype, + attn_implementation=attn_implementation, + backend=self.faster_backend, + ) + + logger.info( + "Using Qwen3-TTS streaming chunk size %d (~%.0fms audio per chunk) on %s", + self.streaming_chunk_size, + self.streaming_chunk_size / MLX_STREAMING_TOKENS_PER_SECOND * 1000, + self.backend, + ) + + self._initial_speaker = self.speaker + self._initial_ref_audio = self.ref_audio + + self.warmup() + + def _setup_faster(self, model_name: str, dtype: Any, attn_implementation: str, backend: str) -> None: + try: + import torch + except ImportError as e: + raise ImportError("torch is required. Install with: pip install torch") from e + + if dtype == "auto": + self.dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16 + elif isinstance(dtype, str): + self.dtype = getattr(torch, dtype) + else: + self.dtype = dtype + + try: + from faster_qwen3_tts import FasterQwen3TTS + except ImportError as e: + raise ImportError( + "faster-qwen3-tts is required for Qwen3 TTS on non-macOS platforms. " + "Install with: pip install 'faster-qwen3-tts[ggml]'" + ) from e + + self.model = FasterQwen3TTS.from_pretrained( + model_name, + device=self.device, + dtype=self.dtype, + attn_implementation=attn_implementation, + backend=backend, + ) + logger.info("Qwen3-TTS model loaded") + + def _setup_mlx(self, model_name: str) -> None: + try: + from mlx_audio.tts.utils import load_model + + self.model = load_model(model_name) + except ImportError as e: + message = str(e) + if any( + dep in message + for dep in ( + "misaki", + "spacy", + "phonemizer", + "espeakng_loader", + ) + ): + raise ImportError( + "Qwen3-TTS on Apple Silicon requires mlx-audio and its TTS dependencies. " + f"Missing dependency: {message}. " + "Install with: pip install mlx-audio misaki spacy phonemizer-fork espeakng-loader" + ) from e + raise ImportError( + "mlx-audio is required for Qwen3 TTS on Apple Silicon. Install with: pip install mlx-audio" + ) from e + + logger.info("MLX Audio Qwen3-TTS model loaded") + + def _normalize_faster_backend(self, backend: Any) -> str: + value = str(backend or "ggml").strip().lower() + if value not in VALID_FASTER_BACKENDS: + raise ValueError( + f"Unsupported qwen3_tts_backend value {backend!r}. Supported values: {', '.join(VALID_FASTER_BACKENDS)}" + ) + return value + + def _normalize_mlx_quantization(self, mlx_quantization: Any) -> Optional[str]: + if mlx_quantization is None: + return None + + value = str(mlx_quantization).strip().lower() + if value in ("", "none", "default"): + return None + if value not in VALID_MLX_QUANTIZATION_SUFFIXES: + raise ValueError( + "Unsupported qwen3_tts_mlx_quantization value " + f"{mlx_quantization!r}. Supported values: {', '.join(VALID_MLX_QUANTIZATION_SUFFIXES)}" + ) + return value + + def _apply_mlx_quantization_suffix(self, model_name: str) -> str: + if self.mlx_quantization is None: + return model_name + + desired_suffix = f"-{self.mlx_quantization}" + for suffix in VALID_MLX_QUANTIZATION_SUFFIXES: + current_suffix = f"-{suffix}" + if model_name.endswith(current_suffix): + return model_name[: -len(current_suffix)] + desired_suffix + + return f"{model_name}{desired_suffix}" + + def _model_name_quantization_suffix(self, model_name: str) -> Optional[str]: + if not model_name: + return None + + for suffix in VALID_MLX_QUANTIZATION_SUFFIXES: + if model_name.endswith(f"-{suffix}"): + return suffix + + return None + + def _resolve_mlx_model_name(self, model_name: str) -> str: + if not model_name: + return self._apply_mlx_quantization_suffix(DEFAULT_MLX_MODEL) + if model_name.startswith("mlx-community/"): + if self.mlx_quantization is None and self._model_name_quantization_suffix(model_name) is None: + return f"{model_name}-6bit" + return self._apply_mlx_quantization_suffix(model_name) + if model_name.startswith("Qwen/"): + mapped = model_name.replace("Qwen/", "mlx-community/", 1) + if self._model_name_quantization_suffix(mapped) is None: + if self.mlx_quantization is None: + return f"{mapped}-6bit" + mapped = f"{mapped}-bf16" + return self._apply_mlx_quantization_suffix(mapped) + return model_name + + def _resolve_streaming_chunk_size(self, streaming_chunk_size: int | None) -> int: + if streaming_chunk_size is not None: + return max(1, int(streaming_chunk_size)) + if self.backend == "mlx": + return DEFAULT_MLX_STREAMING_CHUNK_SIZE + return DEFAULT_FASTER_STREAMING_CHUNK_SIZE + + def _normalize_language(self, language: str | None) -> str: + if language is None: + return "auto" + normalized = str(language).strip().replace("_", "-").lower() + if not normalized: + return "auto" + return QWEN3_LANGUAGE_ALIASES.get(normalized, normalized) + + def _infer_model_type_from_name(self) -> str: + name = (self.model_name or "").lower() + if "voicedesign" in name: + return "voice_design" + if "customvoice" in name: + return "custom_voice" + return "base" + + def _resolve_audio_path(self, audio: Any) -> Path | None: + if not isinstance(audio, (str, Path)) or not audio: + return None + + candidate = Path(audio).expanduser() + repo_root = Path(__file__).resolve().parents[1] + search_paths = [] + + if candidate.is_absolute(): + search_paths.append(candidate) + else: + search_paths.append(Path.cwd() / candidate) + search_paths.append(repo_root / candidate) + + seen = set() + for path in search_paths: + normalized = str(path) + if normalized in seen: + continue + seen.add(normalized) + if path.exists(): + return path.resolve() + + return None + + def _prepare_mlx_ref_audio(self, ref_audio: Any) -> Any: + if self.backend != "mlx" or ref_audio is None: + return ref_audio + + if not isinstance(ref_audio, (str, Path)): + return ref_audio + + resolved_path = self._resolve_audio_path(ref_audio) + if resolved_path is None: + raise FileNotFoundError( + "Qwen3-TTS on Apple Silicon requires qwen3_tts_ref_audio to point to " + f"a readable audio file. Got: {ref_audio!r}" + ) + + cache_key = str(resolved_path) + cached_path = self._mlx_ref_audio_cache.get(cache_key) + if cached_path and Path(cached_path).exists(): + return cached_path + + try: + import soundfile as sf + from scipy.signal import resample_poly + + waveform, sample_rate = sf.read(str(resolved_path), always_2d=False, dtype="float32") + waveform = np.asarray(waveform, dtype=np.float32) + if waveform.ndim > 1: + waveform = waveform.mean(axis=1) + target_sample_rate = getattr(self.model, "sample_rate", 24000) + if sample_rate != target_sample_rate: + gcd = np.gcd(int(sample_rate), int(target_sample_rate)) + waveform = resample_poly( + waveform, + up=int(target_sample_rate) // gcd, + down=int(sample_rate) // gcd, + ) + sample_rate = target_sample_rate + + with tempfile.NamedTemporaryFile( + prefix="qwen3_ref_", + suffix=".wav", + delete=False, + ) as temp_file: + normalized_path = temp_file.name + + sf.write( + normalized_path, + waveform, + sample_rate, + format="WAV", + subtype="PCM_16", + ) + except Exception as e: + raise RuntimeError(f"Failed to normalize Qwen3-TTS reference audio {resolved_path}: {e}") from e + + self._mlx_ref_audio_cache[cache_key] = normalized_path + self._mlx_temp_ref_audio_files.add(normalized_path) + return normalized_path + + def _apply_session_voice_override( + self, + model_type: str, + runtime_config: RuntimeConfig | None = None, + response: RealtimeResponseCreateParams | None = None, + ) -> None: + session_voice: Optional[str] = None + if response and response.audio and response.audio.output: + resp_voice = response.audio.output.voice + session_voice = str(resp_voice) if resp_voice else None + if not session_voice and runtime_config is not None: + audio = runtime_config.session.audio + output = audio.output if audio is not None else None + sess_voice = output.voice if output is not None else None + session_voice = str(sess_voice) if sess_voice else None + if not session_voice: + return + + if model_type == "custom_voice": + supported_speakers = self._supported_speakers() + if supported_speakers is not None: + speakers_by_lower = {speaker.lower(): speaker for speaker in supported_speakers} + speaker = speakers_by_lower.get(session_voice.lower()) + if speaker is None: + supported_text = ", ".join(sorted(supported_speakers, key=str.lower)) or "unknown" + logger.warning( + "Ignoring Qwen3-TTS session voice override %r because it is not a supported " + "CustomVoice speaker. Supported speakers: %s", + session_voice, + supported_text, + ) + return + session_voice = speaker + + self.speaker = session_voice + self.ref_audio = None + return + + if self._resolve_audio_path(session_voice) is not None: + self.ref_audio = session_voice + return + + logger.warning( + "Ignoring Qwen3-TTS session voice override because it is not an audio file path: %r", + session_voice, + ) + + def warmup(self) -> None: + logger.info(f"Warming up {self.__class__.__name__}") + + if self.backend == "faster_qwen3_tts": + if self.parity_mode: + logger.info("Qwen3-TTS parity mode enabled: skipping backend warmup") + else: + try: + self.model.warmup(prefill_len=100) + except Exception as e: + logger.warning("Qwen3-TTS backend warmup failed: %s", e) + + try: + for _ in self._warmup_process("Hello, this is a warmup."): + pass + logger.info(f"{self.__class__.__name__} warmed up") + except Exception as e: + logger.warning(f"Warmup generation failed: {e}") + + def _model_type(self) -> str: + if self.backend == "mlx": + config = getattr(self.model, "config", None) + return getattr(config, "tts_model_type", None) or self._infer_model_type_from_name() + + inner = getattr(getattr(self.model, "model", None), "model", None) + return getattr(inner, "tts_model_type", None) or self._infer_model_type_from_name() + + def _supported_speakers(self) -> list[str] | None: + for candidate in ( + self.model, + getattr(self.model, "model", None), + getattr(getattr(self.model, "model", None), "model", None), + ): + get_speakers = getattr(candidate, "get_supported_speakers", None) + if callable(get_speakers): + speakers = get_speakers() + if speakers is None: + return None + return [str(speaker) for speaker in speakers if speaker] + return None + + def _resolve_speaker(self) -> Optional[str]: + if self.speaker: + return self.speaker + + for candidate in ( + self.model, + getattr(getattr(self.model, "model", None), "model", None), + ): + get_speakers = getattr(candidate, "get_supported_speakers", None) + if callable(get_speakers): + speakers = list(get_speakers() or []) + if speakers: + return speakers[0] + + return None + + def _to_int16(self, audio: np.ndarray) -> np.ndarray: + return np.clip(audio * 32768, -32768, 32767).astype(np.int16) + + def _estimate_max_new_tokens(self, text: Optional[str]) -> int: + text = (text or "").strip() + chunk_size = max(1, int(getattr(self, "streaming_chunk_size", 1))) + configured_cap = max(1, int(getattr(self, "max_new_tokens", DEFAULT_QWEN3_TTS_MAX_NEW_TOKENS))) + + if not text: + return min(configured_cap, MIN_QWEN3_TTS_UTTERANCE_TOKENS) + + word_count = len(re.findall(r"\w+", text, flags=re.UNICODE)) + char_count = len(re.sub(r"\s+", "", text)) + word_seconds = word_count / ESTIMATED_QWEN3_WORDS_PER_SECOND if word_count else 0.0 + char_seconds = char_count / ESTIMATED_QWEN3_CHARS_PER_SECOND if char_count else 0.0 + punctuation_count = sum(unicodedata.category(ch).startswith("P") for ch in text) + punctuation_seconds = punctuation_count * QWEN3_PUNCTUATION_PAUSE_SECONDS + estimated_seconds = max(word_seconds, char_seconds) + punctuation_seconds + QWEN3_BASE_PROMPT_SECONDS + estimated_tokens = math.ceil(estimated_seconds * MLX_STREAMING_TOKENS_PER_SECOND * QWEN3_TOKEN_SAFETY_MARGIN) + aligned_tokens = max( + chunk_size, + math.ceil(estimated_tokens / chunk_size) * chunk_size, + ) + requested_tokens = max(MIN_QWEN3_TTS_UTTERANCE_TOKENS, aligned_tokens) + resolved_tokens = min(configured_cap, requested_tokens) + + if resolved_tokens < requested_tokens: + logger.warning( + "Qwen3-TTS estimated %d codec tokens for a %d-character utterance, " + "but max_new_tokens is capped at %d; output may still truncate.", + requested_tokens, + len(text), + configured_cap, + ) + + logger.debug( + "Qwen3-TTS using max_new_tokens=%d for utterance with %d words and %d chars", + resolved_tokens, + word_count, + char_count, + ) + return resolved_tokens + + def _warmup_process(self, llm_sentence: str) -> Iterator[bytes | np.ndarray]: + model_type = self._model_type() + if self.ref_audio: + yield from self._process_voice_clone(llm_sentence) + elif model_type == "custom_voice": + yield from self._process_custom_voice(llm_sentence) + elif model_type == "voice_design": + yield from self._process_voice_design(llm_sentence) + else: + raise ValueError( + "Qwen3-TTS Base model requires ref_audio for voice cloning. " + "Provide qwen3_tts_ref_audio or use a CustomVoice/VoiceDesign model." + ) + + def _resample_to_pipeline_sr(self, audio: np.ndarray, sr: int) -> np.ndarray: + if sr == PIPELINE_SR: + return audio + from scipy.signal import resample_poly + + gcd = np.gcd(PIPELINE_SR, sr) + return resample_poly(audio, up=PIPELINE_SR // gcd, down=sr // gcd) + + def _prepare_audio_chunk(self, item: Any) -> tuple[np.ndarray | None, int | None]: + if isinstance(item, tuple): + audio_chunk, sr, _timing = item + return np.asarray(audio_chunk, dtype=np.float32), sr + + audio = getattr(item, "audio", None) + if audio is None: + return None, None + + audio_chunk = np.asarray(audio, dtype=np.float32).squeeze() + sr = getattr(item, "sample_rate", None) or PIPELINE_SR + return audio_chunk, sr + + def _stream(self, gen: Any, label: str) -> Iterator[bytes | np.ndarray]: + """Common streaming loop: log TTFA and RTF, yield int16 chunks.""" + cancel_gen = self.cancel_scope.generation if self.cancel_scope else None + start = perf_counter() + total_samples = 0 + first_chunk = True + found_speech = False + leftover = np.array([], dtype=np.int16) + + for item in gen: + if cancel_gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(cancel_gen): + logger.info("TTS generation cancelled (interruption)") + return + + audio_chunk, sr = self._prepare_audio_chunk(item) + if audio_chunk is None or sr is None or audio_chunk.size == 0: + continue + + if first_chunk: + logger.info(f"Qwen3-TTS TTFA: {perf_counter() - start:.2f}s ({label})") + first_chunk = False + + audio_chunk = self._resample_to_pipeline_sr(audio_chunk, sr) + audio_chunk = self._to_int16(audio_chunk) + + # Trim the initial silent ramp-up, but keep enough preroll to avoid + # shaving soft initial phonemes at the start of the utterance. + if not found_speech: + threshold = int(32768 * 0.01) + above = np.abs(audio_chunk) > threshold + if not np.any(above): + continue + start_idx = max(0, int(np.argmax(above)) - int(PIPELINE_SR * 0.040)) + audio_chunk = audio_chunk[start_idx:] + found_speech = True + + audio_chunk = np.concatenate([leftover, audio_chunk]) + + n = (len(audio_chunk) // self.blocksize) * self.blocksize + for i in range(0, n, self.blocksize): + yield audio_chunk[i : i + self.blocksize] + total_samples += self.blocksize + leftover = audio_chunk[n:] + + if len(leftover) > 0: + chunk = np.pad(leftover, (0, self.blocksize - len(leftover))) + yield chunk + total_samples += len(leftover) + + generation_time = perf_counter() - start + audio_duration = total_samples / PIPELINE_SR + rtf = audio_duration / generation_time if generation_time > 0 else 0 + logger.info( + f"Qwen3-TTS generated {audio_duration:.2f}s audio in {generation_time:.2f}s (RTF: {rtf:.2f}, {label})" + ) + + def _coalesce_pending_tts_input(self, current_input: TTSInput) -> tuple[str, Optional[str], bool]: + """Combine already-queued text chunks before the next TTS synthesis call.""" + if not hasattr(self.queue_in, "mutex") or not hasattr(self.queue_in, "queue"): + return current_input.text, current_input.language_code, False + + text = current_input.text + language_code = current_input.language_code + + parts = [text.strip()] if text and text.strip() else [] + saw_end_of_response = False + + with self.queue_in.mutex: + while self.queue_in.queue: + next_item = self.queue_in.queue[0] + if is_control_message(next_item, SESSION_END.kind): + break + if isinstance(next_item, bytes) and next_item == PIPELINE_END: + break + if isinstance(next_item, EndOfResponse): + saw_end_of_response = True + break + if not isinstance(next_item, TTSInput): + break + if current_input.turn_id != next_item.turn_id or current_input.turn_revision != next_item.turn_revision: + break + if ( + language_code is not None + and next_item.language_code is not None + and next_item.language_code != language_code + ): + break + + self.queue_in.queue.popleft() + if next_item.text.strip(): + parts.append(next_item.text.strip()) + if language_code is None: + language_code = next_item.language_code + + combined_text = " ".join(parts).strip() + return combined_text, language_code, saw_end_of_response + + def process(self, tts_input: TTSIn) -> Iterator[TTSOut]: + speculative_turns = getattr(self, "speculative_turns", None) + if isinstance(tts_input, EndOfResponse): + if speculative_turns and not speculative_turns.is_latest_after_reopen_grace( + tts_input.turn_id, + tts_input.turn_revision, + ): + return + yield AUDIO_RESPONSE_DONE + return + + if speculative_turns and not speculative_turns.is_latest_after_reopen_grace( + tts_input.turn_id, + tts_input.turn_revision, + ): + logger.debug("Dropping stale TTS input for turn=%s rev=%s", tts_input.turn_id, tts_input.turn_revision) + return + if speculative_turns: + speculative_turns.commit(tts_input.turn_id, tts_input.turn_revision) + + runtime_config = tts_input.runtime_config + response = tts_input.response + + coalesced_text, _language_code, _saw_end_of_response = self._coalesce_pending_tts_input(tts_input) + + text = coalesced_text or "Hello." + + model_type = self._model_type() + self._apply_session_voice_override(model_type, runtime_config, response) + + console.print(f"[green]ASSISTANT: {text}") + + try: + if self.ref_audio: + audio_iter = self._process_voice_clone(text) + elif model_type == "custom_voice": + audio_iter = self._process_custom_voice(text) + elif model_type == "voice_design": + audio_iter = self._process_voice_design(text) + else: + raise ValueError( + "Qwen3-TTS Base model requires ref_audio for voice cloning. " + "Provide qwen3_tts_ref_audio or use a CustomVoice/VoiceDesign model." + ) + first_audio = True + for audio_chunk in audio_iter: + if first_audio: + self._log_first_audio_latency(tts_input) + first_audio = False + yield audio_chunk + except Exception as e: + logger.error(f"Error during Qwen3-TTS generation: {e}", exc_info=True) + + def _log_first_audio_latency(self, tts_input: TTSInput) -> None: + if tts_input.speech_stopped_at_s is None: + return + latency_s = perf_counter() - tts_input.speech_stopped_at_s + if latency_s < 0: + return + logger.info( + "Last speech detected to first speech out: %.3fs (turn=%s rev=%s)", + latency_s, + tts_input.turn_id, + tts_input.turn_revision, + ) + + def _mlx_streaming_interval(self) -> float: + return max(1, self.streaming_chunk_size) / MLX_STREAMING_TOKENS_PER_SECOND + + def _mlx_stream_kwargs(self, max_tokens: int) -> dict[str, Any]: + return { + "max_tokens": max_tokens, + "verbose": False, + "stream": True, + "streaming_interval": self._mlx_streaming_interval(), + **self.gen_kwargs, + } + + def _stream_mlx_generation( + self, + generation_fn: Callable, + label: str, + max_tokens: int, + **generation_kwargs: Any, + ) -> Iterator[bytes | np.ndarray]: + with MLXLockContext(handler_name="Qwen3TTS", timeout=10.0) as acquired: + if not acquired: + raise TimeoutError("Timed out waiting for MLX lock") + yield from self._stream( + generation_fn( + **self._mlx_stream_kwargs(max_tokens=max_tokens), + **generation_kwargs, + ), + label=label, + ) + + def _process_voice_clone(self, text: str) -> Iterator[bytes | np.ndarray]: + utterance_max_new_tokens = self._estimate_max_new_tokens(text) + if self.backend == "mlx": + if self.xvec_only: + logger.warning("mlx-audio Qwen3-TTS does not support xvec_only; ignoring it") + if self.parity_mode: + logger.info("Qwen3-TTS parity mode is CUDA-specific and is ignored on mlx-audio") + + yield from self._stream_mlx_generation( + self.model.generate, + label="voice_clone_mlx", + max_tokens=utterance_max_new_tokens, + text=text, + ref_audio=self._prepare_mlx_ref_audio(self.ref_audio), + ref_text=self.ref_text, + lang_code=self.language, + ) + return + + yield from self._stream( + self.model.generate_voice_clone_streaming( + text=text, + language=self.language, + ref_audio=self.ref_audio, + ref_text=self.ref_text, + xvec_only=self.xvec_only, + chunk_size=self.streaming_chunk_size, + max_new_tokens=utterance_max_new_tokens, + parity_mode=self.parity_mode, + non_streaming_mode=self.non_streaming_mode, + ), + label="voice_clone_parity" if self.parity_mode else "voice_clone", + ) + + def _process_custom_voice(self, text: str) -> Iterator[bytes | np.ndarray]: + utterance_max_new_tokens = self._estimate_max_new_tokens(text) + speaker = self._resolve_speaker() + if not speaker: + raise ValueError( + "CustomVoice generation requires a speaker. " + "Set qwen3_tts_speaker or use a voice-clone model with ref_audio." + ) + + if self.backend == "mlx": + yield from self._stream_mlx_generation( + self.model.generate_custom_voice, + label="custom_voice_mlx", + max_tokens=utterance_max_new_tokens, + text=text, + speaker=speaker, + language=self.language, + instruct=self.instruct, + ) + return + + yield from self._stream( + self.model.generate_custom_voice_streaming( + text=text, + speaker=speaker, + language=self.language, + instruct=self.instruct, + chunk_size=self.streaming_chunk_size, + max_new_tokens=utterance_max_new_tokens, + non_streaming_mode=self.non_streaming_mode, + ), + label="custom_voice", + ) + + def _process_voice_design(self, text: str) -> Iterator[bytes | np.ndarray]: + utterance_max_new_tokens = self._estimate_max_new_tokens(text) + if self.backend == "mlx": + yield from self._stream_mlx_generation( + self.model.generate_voice_design, + label="voice_design_mlx", + max_tokens=utterance_max_new_tokens, + text=text, + instruct=self.instruct, + language=self.language, + ) + return + + yield from self._stream( + self.model.generate_voice_design_streaming( + text=text, + instruct=self.instruct, + language=self.language, + chunk_size=self.streaming_chunk_size, + max_new_tokens=utterance_max_new_tokens, + non_streaming_mode=self.non_streaming_mode, + ), + label="voice_design", + ) + + def on_session_end(self) -> None: + self.speaker = self._initial_speaker + self.ref_audio = self._initial_ref_audio + logger.debug("Qwen3-TTS session state reset") + + def cleanup(self) -> None: + try: + del self.model + for path in list(getattr(self, "_mlx_temp_ref_audio_files", set())): + try: + Path(path).unlink(missing_ok=True) + except Exception: + pass + if self.backend == "mlx": + try: + import mlx.core as mx + + mx.clear_cache() + except Exception: + pass + else: + import torch + + if torch.cuda.is_available(): + torch.cuda.empty_cache() + logger.info("Qwen3-TTS handler cleaned up") + except Exception as e: + logger.warning(f"Cleanup error: {e}") diff --git a/speech_to_speech/TTS/ref_audio.wav b/speech_to_speech/TTS/ref_audio.wav new file mode 100644 index 0000000..ba7e9a9 Binary files /dev/null and b/speech_to_speech/TTS/ref_audio.wav differ diff --git a/speech_to_speech/VAD/__init__.py b/speech_to_speech/VAD/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/speech_to_speech/VAD/__init__.py @@ -0,0 +1 @@ + diff --git a/speech_to_speech/VAD/vad_handler.py b/speech_to_speech/VAD/vad_handler.py new file mode 100644 index 0000000..e59c65d --- /dev/null +++ b/speech_to_speech/VAD/vad_handler.py @@ -0,0 +1,855 @@ +from __future__ import annotations + +import logging +import time +from collections.abc import Iterator +from dataclasses import dataclass +from queue import Queue +from threading import Event +from typing import Any, TypeAlias + +import numpy as np +import torch + +from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig +from speech_to_speech.baseHandler import BaseHandler +from speech_to_speech.pipeline.events import SpeechStartedEvent, SpeechStoppedEvent +from speech_to_speech.pipeline.handler_types import VADIn, VADOut +from speech_to_speech.pipeline.messages import VADAudio +from speech_to_speech.pipeline.queue_types import TextEventItem +from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker +from speech_to_speech.utils.utils import int2float +from speech_to_speech.VAD.vad_iterator import VADIterator + +logger = logging.getLogger(__name__) + +VADInput: TypeAlias = bytes | tuple[bytes, RuntimeConfig] + + +@dataclass +class _PendingShortSegment: + audio: np.ndarray + active_ms: float + start_ms: int + end_ms: int + + +# Fragments with less active speech than this are treated as noise and never +# held for stitching, so sub-threshold bursts cannot sum past min_speech_ms +# and fire a false barge-in. +_SHORT_SEGMENT_MIN_FRAGMENT_MS = 100 + + +# Optional import for audio enhancement +try: + from df.enhance import enhance, init_df + + HAS_DF = True +except (ImportError, ModuleNotFoundError) as e: + HAS_DF = False + logger.warning(f"DeepFilterNet not available for audio enhancement: {e}") + + +class VADHandler(BaseHandler[VADIn, VADOut]): + """ + Handles voice activity detection. When voice activity is detected, audio will be accumulated until the end of speech is detected and then passed + to the following part. + """ + + def setup( + self, + should_listen: Event, + thresh: float = 0.6, + sample_rate: int = 16000, + min_silence_ms: int = 64, + min_speech_ms: int = 384, + min_speech_continuation_ms: int = 192, + max_speech_ms: float = float("inf"), + speech_pad_ms: int = 30, + audio_enhancement: bool = False, + enable_realtime_transcription: bool = False, + realtime_processing_pause: float = 0.5, + text_output_queue: Queue[TextEventItem] | None = None, + speculative_turns: SpeculativeTurnTracker | None = None, + speculative_reopen_ms: int = 1000, + unanswered_reopen_ms: int = 7000, + short_segment_merge_ms: int = 0, + ) -> None: + self.should_listen = should_listen + self.sample_rate = sample_rate + self.min_silence_ms = min_silence_ms + self.min_speech_ms = min_speech_ms + self.min_speech_continuation_ms = self._resolve_min_speech_continuation_ms( + self.min_speech_ms, + min_speech_continuation_ms, + ) + self.max_speech_ms = max_speech_ms + self.enable_realtime_transcription = enable_realtime_transcription + self.realtime_processing_pause = realtime_processing_pause + self.text_output_queue = text_output_queue + self.speculative_turns = speculative_turns + self.speculative_reopen_ms = speculative_reopen_ms + self.unanswered_reopen_ms = max(self.speculative_reopen_ms, unanswered_reopen_ms) + self.short_segment_merge_ms = max(0, short_segment_merge_ms) + self._last_turn_detection: dict | None = None + self.model, _ = torch.hub.load( + "snakers4/silero-vad", + "silero_vad", + trust_repo=True, + skip_validation=True, + ) + self.iterator = VADIterator( + self.model, + threshold=thresh, + sampling_rate=sample_rate, + min_silence_duration_ms=min_silence_ms, + speech_pad_ms=speech_pad_ms, + ) + self.audio_enhancement = audio_enhancement + if audio_enhancement: + if not HAS_DF: + logger.error( + "Audio enhancement requested but DeepFilterNet is not available. Disabling audio enhancement." + ) + self.audio_enhancement = False + else: + self.enhanced_model, self.df_state, _ = init_df() + + # State for progressive audio release + self.last_process_time: float = 0.0 + + # Cumulative sample counter for audio_start_ms / audio_end_ms + self._total_samples: int = 0 + + # Throttled logging state (summary once per second) + self._last_log_time = 0.0 + self._log_chunks = 0 + self._log_speech_starts = 0 + self._log_speech_ends = 0 + self._log_progressive_yields = 0 + self._speech_started_emitted = False + self._turn_counter = 0 + self._current_turn_id: str | None = None + self._current_turn_revision: int | None = None + self._speculative_audio_prefix: np.ndarray | None = None + self._last_final_wall_time: float | None = None + self._last_final_audio_ms: int | None = None + self._pending_reopen_candidate: tuple[str, int, int] | None = None + self._pending_short_segment: _PendingShortSegment | None = None + + @property + def _audio_ms(self) -> int: + """Cumulative audio received so far, in milliseconds.""" + return int(self._total_samples / self.sample_rate * 1000) + + def _apply_runtime_turn_detection(self, runtime_config: RuntimeConfig | None = None) -> None: + """Check RuntimeConfig for turn_detection changes and apply them.""" + audio = runtime_config.session.audio if runtime_config else None + audio_input = audio.input if audio is not None else None + if not runtime_config or not audio_input or not audio_input.turn_detection: + return + td_raw = audio_input.turn_detection + + # Convert Pydantic models (e.g. OpenAI SDK ServerVad) to dict; + # plain dicts pass through unchanged. + if hasattr(td_raw, "model_dump"): + td = td_raw.model_dump(exclude_none=True) + elif isinstance(td_raw, dict): + td = td_raw + else: + logger.warning(f"Unexpected turn_detection type: {type(td_raw)}") + return + + # Compare normalized snapshot (identity on td_raw vs stored dict was wrong after first apply). + if td == self._last_turn_detection: + return + + self._last_turn_detection = dict(td) + + if "threshold" in td: + self.iterator.threshold = td["threshold"] + logger.info(f"VAD threshold updated to {td['threshold']}") + if "silence_duration_ms" in td: + self.iterator.min_silence_samples = self.sample_rate * td["silence_duration_ms"] / 1000 + logger.info(f"VAD silence duration updated to {td['silence_duration_ms']}ms") + + def _start_new_turn(self) -> tuple[str, int]: + self._cancel_pending_reopen() + self._turn_counter += 1 + self._current_turn_id = f"turn_{self._turn_counter}" + self._current_turn_revision = 0 + self._speculative_audio_prefix = None + self._last_final_wall_time = None + self._last_final_audio_ms = None + if self.speculative_turns: + self.speculative_turns.observe(self._current_turn_id, self._current_turn_revision) + return self._current_turn_id, self._current_turn_revision + + def _speech_buffer_duration_ms(self) -> float: + if not hasattr(self.iterator, "speech_buffer"): + return 0.0 + buffer_samples = sum(len(t) for t in self.iterator.speech_buffer()) + return buffer_samples / self.sample_rate * 1000 + + def _current_active_speech_duration_ms(self) -> float: + active_speech_samples = getattr(self.iterator, "active_speech_samples", 0) + return active_speech_samples / self.sample_rate * 1000 + + def _last_utterance_active_speech_duration_ms(self) -> float: + active_speech_samples = getattr(self.iterator, "last_utterance_active_speech_samples", 0) + return active_speech_samples / self.sample_rate * 1000 + + @staticmethod + def _resolve_min_speech_continuation_ms(min_speech_ms: int, min_speech_continuation_ms: int) -> int: + if min_speech_continuation_ms <= 0: + return min_speech_ms + return min( + min_speech_ms, + max(_SHORT_SEGMENT_MIN_FRAGMENT_MS, min_speech_continuation_ms), + ) + + def _uses_realtime_turn_handling(self) -> bool: + return self.enable_realtime_transcription or self.speculative_turns is not None + + def _active_speech_min_ms(self, start_ms: int) -> float: + """Duration hysteresis for speech that continues a reopenable turn.""" + if self._pending_reopen_candidate is not None or self._should_reopen_current_turn(start_ms): + return self.min_speech_continuation_ms + return self.min_speech_ms + + def _should_reopen_current_turn(self, audio_start_ms: int) -> bool: + if not self._uses_realtime_turn_handling(): + return False + if self._current_turn_id is None or self._current_turn_revision is None or self._last_final_audio_ms is None: + return False + + is_committed = self.speculative_turns is not None and self.speculative_turns.is_committed( + self._current_turn_id, + self._current_turn_revision, + ) + if is_committed: + return False + + # Elapsed is measured on the audio clock, so the window only advances + # while the client streams audio (continuous capture behaves like wall + # time; push-to-talk style gaps freeze it). + elapsed_ms = max(0, audio_start_ms - self._last_final_audio_ms) + + # Within the short grace window, any uncommitted turn may reopen. + # Beyond it, an unanswered turn (no assistant output committed yet) + # remains reopenable up to the unanswered_reopen_ms sanity cap, so a + # user pause longer than speculative_reopen_ms does not orphan a turn + # the assistant has not replied to. The cap also bounds the + # empty-transcript case, where no request is queued and the turn would + # otherwise never commit. + reopen_limit_ms = self.speculative_reopen_ms + if self.speculative_turns is not None: + reopen_limit_ms = self.unanswered_reopen_ms + return elapsed_ms <= reopen_limit_ms + + def _begin_pending_reopen_if_needed(self, audio_start_ms: int) -> None: + if self._pending_reopen_candidate is not None or not self._should_reopen_current_turn(audio_start_ms): + return + if self.speculative_turns is None: + return + candidate_revision = self.speculative_turns.begin_reopen_candidate( + self._current_turn_id, + self._current_turn_revision, + ) + if candidate_revision is None or self._current_turn_id is None or self._current_turn_revision is None: + return + self._pending_reopen_candidate = ( + self._current_turn_id, + self._current_turn_revision, + candidate_revision, + ) + logger.info( + "VAD: pending reopen candidate for speculative turn %s revision %d", + self._current_turn_id, + candidate_revision, + ) + + def _cancel_pending_reopen(self) -> None: + if self._pending_reopen_candidate is None: + return + turn_id, _base_revision, candidate_revision = self._pending_reopen_candidate + if self.speculative_turns: + self.speculative_turns.cancel_reopen_candidate(turn_id, candidate_revision) + self._pending_reopen_candidate = None + + def _confirm_pending_reopen(self) -> tuple[str, int, bool] | None: + if self._pending_reopen_candidate is None: + return None + turn_id, base_revision, candidate_revision = self._pending_reopen_candidate + self._pending_reopen_candidate = None + if self.speculative_turns and not self.speculative_turns.confirm_reopen_candidate( + turn_id, + base_revision, + candidate_revision, + ): + return None + self._current_turn_id = turn_id + self._current_turn_revision = candidate_revision + logger.info("VAD: reopened speculative turn %s revision %d", turn_id, candidate_revision) + return turn_id, candidate_revision, True + + def _reopen_current_turn(self) -> tuple[str, int, bool] | None: + if self._current_turn_id is None or self._current_turn_revision is None: + return None + + turn_id = self._current_turn_id + base_revision = self._current_turn_revision + if self.speculative_turns is not None: + candidate_revision = self.speculative_turns.begin_reopen_candidate(turn_id, base_revision) + if candidate_revision is None or not self.speculative_turns.confirm_reopen_candidate( + turn_id, + base_revision, + candidate_revision, + ): + return None + else: + candidate_revision = base_revision + 1 + + self._current_turn_id = turn_id + self._current_turn_revision = candidate_revision + logger.info("VAD: reopened speculative turn %s revision %d", turn_id, candidate_revision) + return turn_id, candidate_revision, True + + def _ensure_turn_for_speech_start(self, audio_start_ms: int) -> tuple[str, int, bool]: + if ( + self._speech_started_emitted + and self._current_turn_id is not None + and self._current_turn_revision is not None + ): + return self._current_turn_id, self._current_turn_revision, False + + confirmed_reopen = self._confirm_pending_reopen() + if confirmed_reopen is not None: + return confirmed_reopen + + reopened = False + if self._should_reopen_current_turn(audio_start_ms): + reopened_turn = self._reopen_current_turn() + if reopened_turn is not None: + return reopened_turn + + self._start_new_turn() + + if self._current_turn_id is None or self._current_turn_revision is None: + raise RuntimeError("VAD failed to allocate turn metadata") + if self.speculative_turns: + self.speculative_turns.observe(self._current_turn_id, self._current_turn_revision) + return self._current_turn_id, self._current_turn_revision, reopened + + def _current_turn_metadata(self) -> tuple[str | None, int | None]: + return self._current_turn_id, self._current_turn_revision + + def _combined_turn_audio(self, current_segment: np.ndarray) -> np.ndarray: + if self._speculative_audio_prefix is None: + return current_segment + return np.concatenate((self._speculative_audio_prefix, current_segment)) + + def _short_segment_merge_window_ms(self) -> int: + return int(getattr(self, "short_segment_merge_ms", 0)) + + def _segment_duration_ms(self, segment: np.ndarray) -> float: + return len(segment) / self.sample_rate * 1000 + + def _segment_start_ms(self, segment: np.ndarray, end_ms: int) -> int: + return max(0, end_ms - int(self._segment_duration_ms(segment))) + + def _short_segment_gap_ms(self, start_ms: int) -> float: + if self._pending_short_segment is None: + return float("inf") + return max(0, start_ms - self._pending_short_segment.end_ms) + + def _can_merge_pending_short_segment(self, start_ms: int) -> bool: + return ( + self._pending_short_segment is not None + and self._short_segment_merge_window_ms() > 0 + and self._short_segment_gap_ms(start_ms) <= self._short_segment_merge_window_ms() + ) + + def _effective_active_speech_for_start(self, start_ms: int, active_ms: float) -> tuple[int, float]: + # A live fragment below the noise floor never counts the held segment + # toward the speech-start threshold, mirroring the finalization path. + if active_ms < _SHORT_SEGMENT_MIN_FRAGMENT_MS: + return start_ms, active_ms + if not self._can_merge_pending_short_segment(start_ms): + return start_ms, active_ms + assert self._pending_short_segment is not None + return self._pending_short_segment.start_ms, self._pending_short_segment.active_ms + active_ms + + def _merge_pending_short_segment( + self, + segment: np.ndarray, + active_ms: float, + end_ms: int, + ) -> tuple[np.ndarray, float, int, bool]: + start_ms = self._segment_start_ms(segment, end_ms) + if not self._can_merge_pending_short_segment(start_ms): + self._discard_expired_pending_short_segment(start_ms) + return segment, active_ms, start_ms, False + + pending = self._pending_short_segment + assert pending is not None + # Reinsert the silence between the two segments so the stitched audio + # keeps its acoustic gap and its length matches the audio-clock span. + gap_samples = int(self._short_segment_gap_ms(start_ms) * self.sample_rate / 1000) + self._pending_short_segment = None + parts = [pending.audio] + if gap_samples > 0: + parts.append(np.zeros(gap_samples, dtype=segment.dtype)) + parts.append(segment) + merged = np.concatenate(parts) + return merged, pending.active_ms + active_ms, pending.start_ms, True + + def _hold_short_segment(self, segment: np.ndarray, active_ms: float, start_ms: int, end_ms: int) -> None: + self._pending_short_segment = _PendingShortSegment( + audio=segment, + active_ms=active_ms, + start_ms=start_ms, + end_ms=end_ms, + ) + logger.info( + "VAD: holding short segment=%.0fms active=%.0fms (active_min=%sms, merge_max=%sms)", + self._segment_duration_ms(segment), + active_ms, + self.min_speech_ms, + self._short_segment_merge_window_ms(), + ) + + def _discard_pending_short_segment(self, reason: str = "expired") -> None: + pending = self._pending_short_segment + if pending is None: + return + self._pending_short_segment = None + logger.info( + "VAD: discarding held short segment=%.0fms active=%.0fms (%s, active_min=%sms)", + self._segment_duration_ms(pending.audio), + pending.active_ms, + reason, + self.min_speech_ms, + ) + + def _discard_expired_pending_short_segment(self, next_start_ms: int | None = None) -> None: + pending = self._pending_short_segment + if pending is None or self._short_segment_merge_window_ms() <= 0: + return + reference_ms = self._audio_ms if next_start_ms is None else next_start_ms + gap_ms = max(0, reference_ms - pending.end_ms) + if gap_ms > self._short_segment_merge_window_ms(): + self._discard_pending_short_segment("merge window elapsed") + + def before_emit_output(self, output: VADOut) -> None: + if isinstance(output, VADAudio): + self._drop_superseded_vad_audio(output) + + def _drop_superseded_vad_audio(self, latest: VADAudio) -> int: + if not hasattr(self.queue_out, "mutex") or not hasattr(self.queue_out, "queue"): + return 0 + + dropped = 0 + with self.queue_out.mutex: + kept: list[Any] = [] + while self.queue_out.queue: + queued_item = self.queue_out.queue.popleft() + if isinstance(queued_item, VADAudio) and self._vad_audio_is_superseded( + queued_item, + latest, + ): + dropped += 1 + else: + kept.append(queued_item) + self.queue_out.queue.extend(kept) + if dropped: + self.queue_out.not_full.notify_all() + + if dropped: + logger.debug( + "VAD: dropped %d superseded audio chunk(s) before enqueueing turn=%s rev=%s mode=%s", + dropped, + latest.turn_id, + latest.turn_revision, + latest.mode, + ) + return dropped + + def _vad_audio_is_superseded(self, queued_item: VADAudio, latest: VADAudio) -> bool: + if queued_item.turn_id is None or queued_item.turn_revision is None: + return False + if self.speculative_turns is not None and not self.speculative_turns.is_latest( + queued_item.turn_id, + queued_item.turn_revision, + ): + return True + return ( + queued_item.mode == "progressive" + and queued_item.turn_id == latest.turn_id + and queued_item.turn_revision == latest.turn_revision + ) + + def process(self, audio_chunk: VADIn) -> Iterator[VADOut]: + runtime_config = None + if isinstance(audio_chunk, tuple): + audio_chunk, runtime_config = audio_chunk + self._apply_runtime_turn_detection(runtime_config) + + if not self.should_listen.is_set(): + return + + # Normal listening mode + self._log_chunks += 1 + audio_int16 = np.frombuffer(audio_chunk, dtype=np.int16) + self._total_samples += len(audio_int16) + audio_float32 = int2float(audio_int16) + + vad_output = self.iterator(torch.from_numpy(audio_float32)) + + # Deferred speech_started: only emit once active VAD speech reaches the valid speech threshold. + is_triggered_now = self.iterator.triggered + if is_triggered_now and not self._speech_started_emitted: + segment_samples = sum(len(t) for t in self.iterator.buffer) + segment_duration_ms = segment_samples / self.sample_rate * 1000 + active_speech_duration_ms = self._current_active_speech_duration_ms() + speech_buffer_duration_ms = self._speech_buffer_duration_ms() + start_ms = max(0, self._audio_ms - int(speech_buffer_duration_ms)) + effective_start_ms, effective_active_speech_duration_ms = self._effective_active_speech_for_start( + start_ms, + active_speech_duration_ms, + ) + self._begin_pending_reopen_if_needed(effective_start_ms) + active_speech_min_ms = self._active_speech_min_ms(effective_start_ms) + if effective_active_speech_duration_ms >= active_speech_min_ms: + turn_id, turn_revision, reopened = self._ensure_turn_for_speech_start(effective_start_ms) + self._speech_started_emitted = True + self._log_speech_starts += 1 + logger.info( + "Speech started (confirmed, active=%.0fms, min=%.0fms, segment=%.0fms, turn=%s rev=%s)", + effective_active_speech_duration_ms, + active_speech_min_ms, + segment_duration_ms, + turn_id, + turn_revision, + ) + if self.text_output_queue: + self.text_output_queue.put( + SpeechStartedEvent( + audio_start_ms=effective_start_ms, + turn_id=turn_id, + turn_revision=turn_revision, + reopened=reopened, + ) + ) + elif not is_triggered_now and vad_output is None: + self._discard_expired_pending_short_segment() + + # Log a summary once per second instead of every chunk + now = time.time() + if now - self._last_log_time >= 1.0: + state = "SPEAKING" if is_triggered_now else "silent" + logger.debug( + f"VAD: {self._log_chunks} chunks/s | {state} | " + f"starts={self._log_speech_starts} ends={self._log_speech_ends} progressive={self._log_progressive_yields}" + ) + self._log_chunks = 0 + self._log_speech_starts = 0 + self._log_speech_ends = 0 + self._log_progressive_yields = 0 + self._last_log_time = now + + if self._uses_realtime_turn_handling(): + # Realtime mode keeps turns reopenable; live transcription additionally + # emits progressive audio chunks while speaking. + yield from self._process_realtime(vad_output) + else: + # Original mode: yield only when speech ends + yield from self._process_normal(vad_output) + + def _process_realtime(self, vad_output: list[torch.Tensor] | None) -> Iterator[VADOut]: + """Process with real-time progressive audio release.""" + # Check if we're currently in a speech segment. + if self.enable_realtime_transcription and hasattr(self.iterator, "buffer") and len(self.iterator.buffer) > 0: + current_time = time.time() + duration_ms = self._speech_buffer_duration_ms() + progressive_pause = self._progressive_processing_pause(duration_ms) + + # Yield accumulated audio periodically while speaking + if (current_time - self.last_process_time) >= progressive_pause: + array = torch.cat(self.iterator.speech_buffer()).cpu().numpy() + duration_ms = len(array) / self.sample_rate * 1000 + active_speech_duration_ms = self._current_active_speech_duration_ms() + start_ms = max(0, self._audio_ms - int(duration_ms)) + + if active_speech_duration_ms >= self._active_speech_min_ms(start_ms): + self._log_progressive_yields += 1 + logger.debug( + "VAD: yielding progressive audio (segment=%.0fms, active=%.0fms, interval=%.2fs)", + duration_ms, + active_speech_duration_ms, + progressive_pause, + ) + turn_id, turn_revision = self._current_turn_metadata() + yield VADAudio( + audio=self._combined_turn_audio(array), + mode="progressive", + turn_id=turn_id, + turn_revision=turn_revision, + ) + self.last_process_time = current_time + + # Handle end of speech + if vad_output is not None: + if len(vad_output) == 0: + logger.info("VAD: phantom trigger (empty buffer), closing speech pair") + if self._speech_started_emitted and self.text_output_queue: + turn_id, turn_revision = self._current_turn_metadata() + self.text_output_queue.put( + SpeechStoppedEvent( + audio_end_ms=self._audio_ms, + turn_id=turn_id, + turn_revision=turn_revision, + ) + ) + if not self._speech_started_emitted: + self._cancel_pending_reopen() + self._speech_started_emitted = False + self._discard_expired_pending_short_segment() + return + + array = torch.cat(vad_output).cpu().numpy() + end_ms = self._audio_ms + raw_active_ms = self._last_utterance_active_speech_duration_ms() + active_speech_duration_ms = raw_active_ms + stitched_short_segment = False + # Fragments below the noise floor never merge with or replace a + # held segment; the pending segment's own expiry handles it. + if raw_active_ms >= _SHORT_SEGMENT_MIN_FRAGMENT_MS: + array, active_speech_duration_ms, start_ms, stitched_short_segment = self._merge_pending_short_segment( + array, + active_speech_duration_ms, + end_ms, + ) + else: + start_ms = self._segment_start_ms(array, end_ms) + duration_ms = self._segment_duration_ms(array) + min_active_ms = 0.0 if self._speech_started_emitted else self._active_speech_min_ms(start_ms) + + if active_speech_duration_ms < min_active_ms or duration_ms > self.max_speech_ms: + if ( + self._short_segment_merge_window_ms() > 0 + and raw_active_ms >= _SHORT_SEGMENT_MIN_FRAGMENT_MS + and active_speech_duration_ms < min_active_ms + and duration_ms <= self.max_speech_ms + ): + self._hold_short_segment(array, active_speech_duration_ms, start_ms, end_ms) + else: + logger.info( + "VAD: discarding segment=%.0fms active=%.0fms (active_min=%sms, segment_max=%sms)", + duration_ms, + active_speech_duration_ms, + min_active_ms, + self.max_speech_ms, + ) + if self._speech_started_emitted and self.text_output_queue: + turn_id, turn_revision = self._current_turn_metadata() + self.text_output_queue.put( + SpeechStoppedEvent( + audio_end_ms=self._audio_ms, + turn_id=turn_id, + turn_revision=turn_revision, + ) + ) + if not self._speech_started_emitted: + self._cancel_pending_reopen() + self._speech_started_emitted = False + else: + if stitched_short_segment: + logger.info( + "VAD: stitched short segment(s) into segment=%.0fms active=%.0fms", + duration_ms, + active_speech_duration_ms, + ) + if not self._speech_started_emitted: + turn_id, turn_revision, reopened = self._ensure_turn_for_speech_start(start_ms) + if self.text_output_queue: + self.text_output_queue.put( + SpeechStartedEvent( + audio_start_ms=start_ms, + turn_id=turn_id, + turn_revision=turn_revision, + reopened=reopened, + interrupt_response=False, + ) + ) + else: + turn_id, turn_revision = self._current_turn_metadata() + self._log_speech_ends += 1 + logger.info( + "Speech soft-ended (segment=%.0fms, active=%.0fms, turn=%s rev=%s)", + duration_ms, + active_speech_duration_ms, + turn_id, + turn_revision, + ) + if self.audio_enhancement: + array = self._apply_audio_enhancement(array) + output_array = self._combined_turn_audio(array) + combined_duration_s = len(output_array) / self.sample_rate + if self.text_output_queue: + self.text_output_queue.put( + SpeechStoppedEvent( + duration_s=combined_duration_s, + audio_end_ms=end_ms, + turn_id=turn_id, + turn_revision=turn_revision, + ) + ) + self._speculative_audio_prefix = output_array + self._last_final_wall_time = time.time() + self._last_final_audio_ms = end_ms + if self.speculative_turns: + # The grace window only delays response commits; reopen + # eligibility for unanswered turns is extended separately + # via unanswered_reopen_ms in _should_reopen_current_turn. + self.speculative_turns.start_reopen_grace( + turn_id, + turn_revision, + self.speculative_reopen_ms / 1000.0, + ) + else: + self.should_listen.clear() + yield VADAudio(audio=output_array, mode="final", turn_id=turn_id, turn_revision=turn_revision) + self.last_process_time = 0.0 + self._speech_started_emitted = False + + def _progressive_processing_pause(self, duration_ms: float) -> float: + base_pause = max(0.0, self.realtime_processing_pause) + duration_s = duration_ms / 1000.0 + if duration_s < 8.0: + multiplier = 1.0 + elif duration_s < 15.0: + multiplier = 2.0 + elif duration_s < 30.0: + multiplier = 4.0 + else: + multiplier = 6.0 + return min(base_pause * multiplier, 2.0) + + def _process_normal(self, vad_output: list[torch.Tensor] | None) -> Iterator[VADOut]: + """Original processing: yield only when speech ends.""" + if vad_output is not None: + if len(vad_output) == 0: + logger.info("VAD: phantom trigger (empty buffer), closing speech pair") + if self._speech_started_emitted and self.text_output_queue: + self.text_output_queue.put(SpeechStoppedEvent(audio_end_ms=self._audio_ms)) + self._speech_started_emitted = False + self._discard_expired_pending_short_segment() + return + + array = torch.cat(vad_output).cpu().numpy() + end_ms = self._audio_ms + raw_active_ms = self._last_utterance_active_speech_duration_ms() + active_speech_duration_ms = raw_active_ms + stitched_short_segment = False + # Fragments below the noise floor never merge with or replace a + # held segment; the pending segment's own expiry handles it. + if raw_active_ms >= _SHORT_SEGMENT_MIN_FRAGMENT_MS: + array, active_speech_duration_ms, start_ms, stitched_short_segment = self._merge_pending_short_segment( + array, + active_speech_duration_ms, + end_ms, + ) + else: + start_ms = self._segment_start_ms(array, end_ms) + duration_ms = self._segment_duration_ms(array) + min_active_ms = 0.0 if self._speech_started_emitted else self._active_speech_min_ms(start_ms) + if active_speech_duration_ms < min_active_ms or duration_ms > self.max_speech_ms: + if ( + self._short_segment_merge_window_ms() > 0 + and raw_active_ms >= _SHORT_SEGMENT_MIN_FRAGMENT_MS + and active_speech_duration_ms < min_active_ms + and duration_ms <= self.max_speech_ms + ): + self._hold_short_segment(array, active_speech_duration_ms, start_ms, end_ms) + else: + logger.info( + "VAD: discarding segment=%.0fms active=%.0fms (active_min=%sms, segment_max=%sms)", + duration_ms, + active_speech_duration_ms, + min_active_ms, + self.max_speech_ms, + ) + if self._speech_started_emitted and self.text_output_queue: + self.text_output_queue.put(SpeechStoppedEvent(audio_end_ms=self._audio_ms)) + self._speech_started_emitted = False + else: + if stitched_short_segment: + logger.info( + "VAD: stitched short segment(s) into segment=%.0fms active=%.0fms", + duration_ms, + active_speech_duration_ms, + ) + if not self._speech_started_emitted and self.text_output_queue: + self.text_output_queue.put( + SpeechStartedEvent( + audio_start_ms=start_ms, + interrupt_response=False, + ) + ) + self._log_speech_ends += 1 + self.should_listen.clear() + logger.info(f"Speech ended ({duration_ms:.0f}ms), stop listening") + if self.text_output_queue: + self.text_output_queue.put(SpeechStoppedEvent(duration_s=duration_ms / 1000.0, audio_end_ms=end_ms)) + if self.audio_enhancement: + array = self._apply_audio_enhancement(array) + yield VADAudio(audio=array) + self._speech_started_emitted = False + + def _apply_audio_enhancement(self, array: np.ndarray) -> np.ndarray: + """Apply audio enhancement if enabled.""" + import torchaudio + + if self.sample_rate != self.df_state.sr(): + audio_float32 = torchaudio.functional.resample( + torch.from_numpy(array), + orig_freq=self.sample_rate, + new_freq=self.df_state.sr(), + ) + enhanced = enhance( + self.enhanced_model, + self.df_state, + audio_float32.unsqueeze(0), + ) + enhanced = torchaudio.functional.resample( + enhanced, + orig_freq=self.df_state.sr(), + new_freq=self.sample_rate, + ) + else: + enhanced = enhance(self.enhanced_model, self.df_state, torch.from_numpy(array)) + return enhanced.numpy().squeeze() + + def on_session_end(self): + self.iterator.reset_states() + self._pending_short_segment = None + self.iterator.buffer = [] + self.last_process_time = 0.0 + self._total_samples = 0 + self._speech_started_emitted = False + self._turn_counter = 0 + self._current_turn_id = None + self._current_turn_revision = None + self._speculative_audio_prefix = None + self._last_final_wall_time = None + self._last_final_audio_ms = None + self._pending_reopen_candidate = None + if self.speculative_turns: + self.speculative_turns.reset() + self.should_listen.set() + logger.debug("VAD session state reset") + + @property + def min_time_to_debug(self) -> float: + return 0.00001 diff --git a/speech_to_speech/VAD/vad_iterator.py b/speech_to_speech/VAD/vad_iterator.py new file mode 100644 index 0000000..6ac58fa --- /dev/null +++ b/speech_to_speech/VAD/vad_iterator.py @@ -0,0 +1,170 @@ +from collections import deque + +import torch + + +class VADIterator: + def __init__( + self, + model, + threshold: float = 0.5, + sampling_rate: int = 16000, + min_silence_duration_ms: int = 300, + speech_pad_ms: int = 30, + ) -> None: + """ + Mainly taken from https://github.com/snakers4/silero-vad + Class for stream imitation + + Parameters + ---------- + model: preloaded .jit/.onnx silero VAD model + + threshold: float (default - 0.5) + Speech threshold. Silero VAD outputs speech probabilities for each audio chunk, probabilities ABOVE this value are considered as SPEECH. + It is better to tune this parameter for each dataset separately, but "lazy" 0.5 is pretty good for most datasets. + + sampling_rate: int (default - 16000) + Currently silero VAD models support 8000 and 16000 sample rates + + min_silence_duration_ms: int (default - 300 milliseconds) + In the end of each speech chunk wait for min_silence_duration_ms before separating it + + speech_pad_ms: int (default - 30 milliseconds) + Retain up to speech_pad_ms of audio before VAD triggers and prepend it + to the detected speech chunk + """ + + self.model = model + self.threshold = threshold + self.sampling_rate = sampling_rate + self.is_speaking = False + self.buffer: list[torch.Tensor] = [] + self.prefix_buffer: list[torch.Tensor] = [] + self.active_speech_samples = 0 + self.last_utterance_active_speech_samples = 0 + self._pre_speech_buffer: deque[torch.Tensor] = deque() + self._pre_speech_samples = 0 + + if sampling_rate not in [8000, 16000]: + raise ValueError("VADIterator does not support sampling rates other than [8000, 16000]") + + self.min_silence_samples = int(sampling_rate * min_silence_duration_ms / 1000) + self.speech_pad_samples = int(sampling_rate * speech_pad_ms / 1000) + self.reset_states() + + def reset_states(self) -> None: + self.model.reset_states() + self.triggered = False + self.temp_end = 0 + self.current_sample = 0 + self.buffer = [] + self.prefix_buffer = [] + self.active_speech_samples = 0 + self.last_utterance_active_speech_samples = 0 + self._pre_speech_buffer.clear() + self._pre_speech_samples = 0 + + def _num_samples(self, chunk: torch.Tensor) -> int: + return len(chunk[0]) if chunk.dim() == 2 else len(chunk) + + def _trim_pre_speech_buffer(self) -> None: + while ( + self.speech_pad_samples > 0 + and self._pre_speech_buffer + and self._pre_speech_samples > self.speech_pad_samples + ): + first = self._pre_speech_buffer[0] + first_samples = self._num_samples(first) + excess = self._pre_speech_samples - self.speech_pad_samples + + if excess >= first_samples: + self._pre_speech_buffer.popleft() + self._pre_speech_samples -= first_samples + continue + + if first.dim() == 2: + self._pre_speech_buffer[0] = first[:, excess:] + else: + self._pre_speech_buffer[0] = first[excess:] + self._pre_speech_samples -= excess + + def _remember_pre_speech(self, chunk: torch.Tensor) -> None: + if self.speech_pad_samples <= 0: + self._pre_speech_buffer.clear() + self._pre_speech_samples = 0 + return + + self._pre_speech_buffer.append(chunk) + self._pre_speech_samples += self._num_samples(chunk) + self._trim_pre_speech_buffer() + + def _speech_buffer(self) -> list[torch.Tensor]: + if not self.prefix_buffer: + return list(self.buffer) + return [*self.prefix_buffer, *self.buffer] + + def speech_buffer(self) -> list[torch.Tensor]: + return self._speech_buffer() + + @torch.no_grad() + def __call__(self, x: torch.Tensor) -> list[torch.Tensor] | None: + """ + x: torch.Tensor + audio chunk (see examples in repo) + + return_seconds: bool (default - False) + whether return timestamps in seconds (default - samples) + """ + + if not torch.is_tensor(x): + try: + x = torch.Tensor(x) + except Exception: + raise TypeError("Audio cannot be casted to tensor. Cast it manually") + + window_size_samples = len(x[0]) if x.dim() == 2 else len(x) + self.current_sample += window_size_samples + + speech_prob = self.model(x, self.sampling_rate).item() + + if (speech_prob >= self.threshold) and not self.triggered: + self.triggered = True + self.prefix_buffer = list(self._pre_speech_buffer) + self._pre_speech_buffer.clear() + self._pre_speech_samples = 0 + self.buffer.append(x) + self.active_speech_samples = window_size_samples + self.last_utterance_active_speech_samples = 0 + return None + + if not self.triggered: + self._remember_pre_speech(x) + return None + + if self.triggered: + self.buffer.append(x) + if speech_prob >= self.threshold - 0.15: + self.active_speech_samples += window_size_samples + if self.temp_end: + self.temp_end = 0 + return None + + if speech_prob < self.threshold - 0.15: + if not self.temp_end: + self.temp_end = self.current_sample + if self.current_sample - self.temp_end < self.min_silence_samples: + return None + + # End of speech: keep the final low-confidence chunks that were + # observed before VAD decided the utterance was done. + self.temp_end = 0 + self.triggered = False + spoken_utterance = self.speech_buffer() + self.last_utterance_active_speech_samples = self.active_speech_samples + self.active_speech_samples = 0 + self.buffer = [] + self.prefix_buffer = [] + return spoken_utterance + + return None diff --git a/speech_to_speech/__init__.py b/speech_to_speech/__init__.py new file mode 100644 index 0000000..e6a4201 --- /dev/null +++ b/speech_to_speech/__init__.py @@ -0,0 +1,3 @@ +"""speech-to-speech: low-latency speech-to-speech pipeline.""" + +__version__ = "0.2.11" diff --git a/speech_to_speech/api/__init__.py b/speech_to_speech/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/speech_to_speech/api/openai_realtime/README.md b/speech_to_speech/api/openai_realtime/README.md new file mode 100644 index 0000000..10d91ca --- /dev/null +++ b/speech_to_speech/api/openai_realtime/README.md @@ -0,0 +1,216 @@ +## Realtime Engine -- High-Level Architecture + +The server runs as a handler thread inside the existing pipeline. A FastAPI/uvicorn instance serves the WebSocket endpoint at `/v1/realtime`, while the same queue-driven handler chain (VAD, STT, LLM, TTS) processes audio in parallel threads. + +```mermaid +flowchart LR + subgraph client [Client] + WS["WebSocket /v1/realtime"] + end + + subgraph server [Realtime Server] + Router["WebSocket Router"] + Service["RealtimeService"] + Config["RuntimeConfig"] + end + + subgraph pipeline [Pipeline Threads] + VAD["VAD"] + STT["STT"] + TN["TranscriptionNotifier"] + LLM["LLM"] + Proc["LMOutputProcessor"] + TTS["TTS"] + end + + WS -- "client events (JSON)" --> Router + Router -- "parse / dispatch" --> Service + Service -- "PCM chunks" --> VAD + Service -- "session config" --> Config + Config -. "read by" .-> VAD & LLM & TTS + VAD -- "speech segments" --> STT + STT -- "transcript" --> TN + TN -- "transcript + notify" --> LLM + LLM -- "text + tools" --> Proc + Proc -- "clean text" --> TTS + TTS -- "PCM audio" --> Router + Proc -- "assistant_text / tools" --> Router + VAD -- "speech_started/stopped" --> Router + TN -- "transcription events" --> Router + Router -- "server events (JSON)" --> WS +``` + +**Key flow:** + +1. **Inbound audio**: Client sends `input_audio_buffer.append` with base64 PCM. `RealtimeService` decodes, resamples to 16 kHz, splits into 512-sample chunks, and puts them on the `recv_audio_chunks_queue` for VAD. +2. **Speech detection**: VAD detects speech boundaries and emits `speech_started` / `speech_stopped` events on the `text_output_queue`. Full utterance audio goes to STT. +3. **Transcription**: STT output passes through `TranscriptionNotifier`, which taps the transcript for `transcription.delta` / `transcription.completed` events before forwarding to the LLM. +4. **Generation**: The LLM generates text (and optional tool calls). `LMOutputProcessor` splits the output: clean text goes to TTS, and `assistant_text` + tool call dicts go to the `text_output_queue`. +5. **Outbound audio**: TTS writes PCM chunks to `send_audio_chunks_queue`. The router's async `_send_loop` drains both queues, encoding PCM as `response.output_audio.delta` events and translating internal messages into protocol events. +6. **Session config**: `session.update` events deep-merge into `RuntimeConfig`, which is a shared Pydantic model read by VAD (turn detection thresholds), LLM (instructions, tools), and TTS (voice) at processing time. + +--- + +## Supported OpenAI Realtime Events + +### Client -> Server + +| Event | Description | +|---|---| +| `input_audio_buffer.append` | Stream base64 PCM audio. Decoded, resampled to 16 kHz, and chunked for the VAD. | +| `session.update` | Deep-merge session config (instructions, tools, voice, turn detection, audio format). | +| `conversation.item.create` | Inject `input_text` or `function_call_output` into the LLM context without triggering generation. | +| `response.create` | Trigger LLM generation. Supports per-response `instructions` and `tool_choice` overrides. | +| `response.cancel` | Cancel the in-progress response and re-enable listening. | + +### Server -> Client + +| Event | Description | +|---|---| +| `session.created` | Sent on connection with current session config. | +| `error` | Protocol errors (`session_limit_reached`, `unknown_or_invalid_event`, `invalid_session_type`, `conversation_already_has_active_response`, etc.) | +| `input_audio_buffer.speech_started` | VAD detected user speech. | +| `input_audio_buffer.speech_stopped` | End of user speech segment. | +| `conversation.item.created` | Acknowledges injected `input_text` from `conversation.item.create`. | +| `conversation.item.input_audio_transcription.delta` | Streaming partial transcript (when live transcription is enabled). | +| `conversation.item.input_audio_transcription.completed` | Final transcript for the user turn (with duration usage). | +| `response.created` | Emitted on the first outbound audio chunk (response is `in_progress`). | +| `response.output_audio.delta` | Base64 PCM audio chunk from TTS. | +| `response.output_audio.done` | Audio stream complete for the current output item. | +| `response.output_audio_transcript.done` | Full assistant text transcript for the turn. | +| `response.function_call_arguments.done` | Tool call with `call_id`, `name`, and JSON `arguments`. | +| `response.done` | Response finished (`completed`, `cancelled` with reason `turn_detected` or `client_cancelled`). | + +--- + +## Tool Calling Design + +Tool calling works through two distinct paths depending on the LLM backend, but both converge to the same wire protocol for the client. + +### Local LLM path (`LanguageModelHandler` -- transformers / mlx-lm) + +Tools defined in `session.update` are converted to `FunctionTool` objects. Each tool's JSON Schema `parameters` are turned into a Python `inspect.Signature` (via `signature_from_schema`), and `to_code_prompt()` renders a human-readable `def name(...): """docstring"""` block. + +These tool signatures are injected into the system prompt using a Jinja2 template (`tool_prompt.py`) that instructs the model to wrap tool calls in `...` delimiters: + +``` + +function_name(arg_name_1=value1, arg_name_2='string_value') + +``` + +After generation, `_extract_tools` uses a regex to find `` blocks, then `extract_function_calls_from_text` parses each `name(kwargs)` call and validates it against registered tools. Valid calls become `ResponseFunctionToolCall` dicts with generated `call_id`s. + +### OpenAI API path (`ResponsesApiModelHandler`) + +Tools are passed natively as the `tools=` parameter to `client.responses.create`. The API returns structured `function_call` items directly -- no prompt engineering or regex parsing needed. Per-response `tool_choice` overrides from `response.create` are supported. + +### Common output path + +Both handlers yield `(text, language_code, tools)` tuples. `LMOutputProcessor` forwards clean text to TTS and puts `{"type": "assistant_text", "text": ..., "tools": [...]}` on the `text_output_queue`. The router's `_send_loop` translates these into: +- `response.output_audio_transcript.done` for the text +- `response.function_call_arguments.done` for each tool call + +### Tool result flow + +1. Client executes the tool and sends `conversation.item.create` with `type: "function_call_output"` and `output: ""` +2. `RealtimeService` appends the tool output to the chat context and emits `conversation.item.created`; this does not trigger generation. +3. If the tool result needs to be spoken to the user, such as camera/search/data results, the client sends `response.create` to trigger follow-up generation. +4. For fire-and-forget robot actions such as dance, emotion, head movement, stop, or idle tools, the client can stop after `conversation.item.created`; the assistant should already have spoken the natural lead-in before the tool call. + +--- + +## Interruption Handling + +Barge-in (user speaks while the assistant is playing audio) is handled cooperatively between the VAD, the `_send_loop`, and the LLM/TTS handlers via a shared `CancelScope` object (`cancel_scope.py`). + +### CancelScope design + +`CancelScope` replaces the old two-signal pattern (`cancel_response` Event + `discard_stale_output` boolean) with a single object that manages: + +- **Generation counter** (`cancel_scope.generation`): pipeline threads (LLM, TTS) capture the current generation at the start of each response and check `cancel_scope.is_stale(gen)` on every streaming token. When `cancel()` is called, the generation increments and all prior generations become stale -- no timing games required. +- **Discard flag** (`cancel_scope.discarding`): set by `cancel()`, checked by the async `_send_loop` to drop output from superseded generations that arrives between `cancel()` and `response_done()`. Cleared by `response_done(generation)` (only when the sentinel's generation matches the discarded or current one -- sentinels from unrelated older generations are ignored), by `new_response()` on an explicit `response.create`, or by `reset()` on session claim/release. + +Pipeline output is **generation-tagged**: `AudioOutput` chunks and `AssistantTextEvent`s carry a `cancel_generation` field stamped by the handler that produced them. The send loop's `_generation_is_discardable` drops an item if its generation is stale, or if `discarding` is set and the item is not from the current generation. Output from the *current* generation always passes through, so a fresh response is never swallowed by a lingering discard window (e.g. a superseded speculative turn whose TTS never emitted a `__RESPONSE_DONE__` sentinel). + +```mermaid +sequenceDiagram + participant User + participant VAD + participant SendLoop as _send_loop + participant LLM + participant TTS + participant Client + + Note over TTS,Client: Response active or pending (in_response / response_pending) + User->>VAD: speaks + VAD->>SendLoop: speech_started on text_output_queue + SendLoop->>Client: input_audio_buffer.speech_started + SendLoop->>Client: response.done (status=cancelled, reason=turn_detected) + SendLoop->>SendLoop: cancel_scope.cancel() (gen++ & discarding=True) + SendLoop->>SendLoop: flush output_queue + text_output_queue + SendLoop->>SendLoop: response_playing.clear() + LLM->>LLM: is_stale(gen) → True, aborts generation + TTS->>TTS: is_stale(gen) → True, aborts generation + TTS->>SendLoop: __RESPONSE_DONE__ (tagged with gen) + SendLoop->>SendLoop: cancel_scope.response_done(gen) (discarding=False) + Note over VAD,Client: Pipeline is now processing the new user utterance +``` + +**Step by step:** + +1. **VAD detects speech**: puts a `SpeechStartedEvent` on `text_output_queue`. +2. **`_send_loop` processes text events first** (priority over audio): translates `speech_started` into protocol events. If an active response was in progress, `RealtimeService.dispatch_pipeline_event` emits `response.output_audio.done` + `response.done` with `status="cancelled"` and `reason="turn_detected"`. +3. **Cancel + queue flush**: if a response is active (`in_response`) *or* pending (`response_pending` -- accepted `response.create`, no audio yet), and interrupts are enabled (see step 4), the send loop calls `cancel_scope.cancel()` (increments generation, enables discard), clears `response_pending`, drains `output_queue` (preserving `__RESPONSE_DONE__` sentinels) and `text_output_queue` (preserving user-side events: `speech_stopped`, partial/completed transcriptions, token usage), then clears `response_playing`. +4. **Interrupt gating**: the cancel only fires if the `SpeechStartedEvent.interrupt_response` flag is set *and* the session config allows it (`turn_detection.interrupt_response`, read via `RuntimeConfig.interrupt_response_enabled`, default true). When disabled, user speech during a response is transcribed but the response keeps playing. +5. **LLM/TTS cancellation**: handlers capture `gen = cancel_scope.generation` at the start of each response and check `cancel_scope.is_stale(gen)` on every streaming token, aborting early when stale. +6. **Discard guard**: while `cancel_scope.discarding` is True, the send loop drops audio chunks and assistant text whose `cancel_generation` is not current (see `_generation_is_discardable` above). The guard clears when a `__RESPONSE_DONE__` with a matching generation arrives (via `cancel_scope.response_done(gen)`), or when an explicit `response.create` starts a new response (`cancel_scope.new_response()`). +7. **Client-initiated cancel**: `response.cancel` calls `cancel_scope.cancel()` (only if a response was active), flushes both queues with the same preservation rules, triggers `finish_response(status="cancelled", reason="client_cancelled")`, re-enables `should_listen`, and clears `response_playing`. +8. **Spurious cancel safety**: if no response is active, `cancel_scope.cancel()` is not called, preventing the discard guard from being set without a `__RESPONSE_DONE__` to clear it. + +--- + +## Testing + +### Local LLM with Transformers + +```bash +uv run speech-to-speech \ + --mode realtime \ + --stt parakeet-tdt \ + --llm_backend transformers \ + --tts kokoro \ + --model_name "Qwen/Qwen3-4B-Instruct-2507" \ + --llm_device mps \ + --llm_torch_dtype float16 \ + --enable_live_transcription +``` + +### Local LLM with MLX-LM + +```bash +uv run speech-to-speech \ + --mode realtime \ + --stt parakeet-tdt \ + --llm_backend mlx-lm \ + --tts kokoro \ + --model_name "mlx-community/Qwen3-4B-Instruct-2507-bf16" \ + --llm_device mps \ + --llm_torch_dtype float16 \ + --enable_live_transcription +``` + +### Remote LLM with OpenAI-compatible API + +```bash +uv run speech-to-speech \ + --mode realtime \ + --stt parakeet-tdt \ + --llm_backend responses-api \ + --tts kokoro \ + --model_name "openai/gpt-oss-20b:groq" \ + --responses_api_base_url "https://router.huggingface.co/v1" \ + --responses_api_api_key "$HF_TOKEN" \ + --responses_api_stream \ + --enable_live_transcription +``` diff --git a/speech_to_speech/api/openai_realtime/__init__.py b/speech_to_speech/api/openai_realtime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/speech_to_speech/api/openai_realtime/delay_fillers.py b/speech_to_speech/api/openai_realtime/delay_fillers.py new file mode 100644 index 0000000..75cefab --- /dev/null +++ b/speech_to_speech/api/openai_realtime/delay_fillers.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from speech_to_speech.pipeline.messages import TTSInput + +logger = logging.getLogger(__name__) + +_TRUE_VALUES = {"1", "true", "yes", "on"} + + +@dataclass(slots=True) +class DelayFillerConfig: + enabled: bool + threshold_s: float + short_phrases: list[str] + full_phrases: list[str] + + +def _split_env_list(name: str) -> list[str]: + raw = os.environ.get(name, "").strip() + return [part.strip() for part in raw.split("|") if part.strip()] + + +def _env_flag(name: str, *, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in _TRUE_VALUES + + +def load_delay_filler_config() -> DelayFillerConfig: + short_phrases = _split_env_list("TTS_DELAY_FILLERS_SHORT_ES") + full_phrases = _split_env_list("TTS_DELAY_FILLERS_FULL_ES") + + # Backward compatibility with the old single-list variable. + if not short_phrases and not full_phrases: + full_phrases = _split_env_list("TTS_DELAY_FILLERS_ES") + + enabled = _env_flag("TTS_DELAY_FILLERS_ENABLED", default=bool(short_phrases or full_phrases)) + threshold_ms_raw = os.environ.get("TTS_DELAY_FILLERS_THRESHOLD_MS", "1200").strip() or "1200" + try: + threshold_ms = max(0, int(threshold_ms_raw)) + except ValueError: + logger.warning("Invalid TTS_DELAY_FILLERS_THRESHOLD_MS=%r; using 1200", threshold_ms_raw) + threshold_ms = 1200 + return DelayFillerConfig( + enabled=enabled and bool(short_phrases or full_phrases), + threshold_s=threshold_ms / 1000.0, + short_phrases=short_phrases, + full_phrases=full_phrases, + ) + + +def _chunk_to_bytes(chunk: Any) -> bytes: + audio = getattr(chunk, "audio", chunk) + if isinstance(audio, np.ndarray) or hasattr(audio, "tobytes"): + return audio.tobytes() + if isinstance(audio, bytes): + return audio + raise TypeError(f"Unsupported delay filler audio chunk type: {type(audio)!r}") + + +def pregenerate_delay_filler_clips(tts_handler: Any, phrases: list[str]) -> list[bytes]: + clips: list[bytes] = [] + for phrase in phrases: + try: + clip = b"".join( + _chunk_to_bytes(chunk) + for chunk in tts_handler.process(TTSInput(text=phrase, language_code="es")) + ) + except Exception: + logger.exception("Failed to pregenerate TTS delay filler for phrase %r", phrase) + continue + if not clip: + logger.warning("Discarding empty TTS delay filler for phrase %r", phrase) + continue + clips.append(clip) + return clips diff --git a/speech_to_speech/api/openai_realtime/handlers/__init__.py b/speech_to_speech/api/openai_realtime/handlers/__init__.py new file mode 100644 index 0000000..a8d90db --- /dev/null +++ b/speech_to_speech/api/openai_realtime/handlers/__init__.py @@ -0,0 +1,11 @@ +from speech_to_speech.api.openai_realtime.handlers.audio import AudioHandler +from speech_to_speech.api.openai_realtime.handlers.conversation import ConversationHandler +from speech_to_speech.api.openai_realtime.handlers.response import ResponseHandler +from speech_to_speech.api.openai_realtime.handlers.session import SessionHandler + +__all__ = [ + "AudioHandler", + "ConversationHandler", + "ResponseHandler", + "SessionHandler", +] diff --git a/speech_to_speech/api/openai_realtime/handlers/audio.py b/speech_to_speech/api/openai_realtime/handlers/audio.py new file mode 100644 index 0000000..2888363 --- /dev/null +++ b/speech_to_speech/api/openai_realtime/handlers/audio.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import base64 +import logging +from typing import TYPE_CHECKING + +from openai.types.realtime import ( + InputAudioBufferAppendEvent, + InputAudioBufferSpeechStartedEvent, + InputAudioBufferSpeechStoppedEvent, + RealtimeErrorEvent, + ResponseAudioDeltaEvent, + ResponseCreatedEvent, +) + +from speech_to_speech.api.openai_realtime.handlers.base import RealtimeBaseHandler +from speech_to_speech.api.openai_realtime.utils import resample +from speech_to_speech.pipeline.events import SpeechStartedEvent, SpeechStoppedEvent + +if TYPE_CHECKING: + from speech_to_speech.api.openai_realtime.service import ServerEvent + +logger = logging.getLogger(__name__) + +PIPELINE_SAMPLE_RATE = 16000 +CHUNK_SAMPLES = 512 +BYTES_PER_SAMPLE = 2 +CHUNK_SIZE_BYTES = CHUNK_SAMPLES * BYTES_PER_SAMPLE + + +class AudioHandler(RealtimeBaseHandler): + """Owns inbound audio decoding/chunking and outbound audio encoding.""" + + def _start_input_item(self, conn_id: str, *, preserve_active_response: bool = False) -> str: + response = self._service.response + st = self._state(conn_id) + if not preserve_active_response: + item_id = response._start_item(conn_id) + else: + response_item_id = st.current_item_id + response_content_index = st.content_index + item_id = response._start_item(conn_id) + st.current_item_id = response_item_id + st.content_index = response_content_index + st.input_content_index = 0 + return item_id + + def handle_audio_append(self, conn_id: str, event: InputAudioBufferAppendEvent) -> list[bytes]: + """Decode base64 audio, resample to pipeline rate, and split into 512-sample PCM16 chunks for the VAD.""" + try: + pcm_bytes = base64.b64decode(event.audio) + except Exception as e: + logger.error(f"Base64 decode error: {e}") + return [] + + st = self._state(conn_id) + + audio_cfg = st.runtime_config.session.audio + if audio_cfg is not None and audio_cfg.input is not None: + client_in_rate = getattr(audio_cfg.input.format, "rate", None) or PIPELINE_SAMPLE_RATE + else: + client_in_rate = PIPELINE_SAMPLE_RATE + pcm_bytes = resample(pcm_bytes, client_in_rate, PIPELINE_SAMPLE_RATE) + + pcm_bytes = st.audio_remainder + pcm_bytes + + chunks = [] + for i in range(0, len(pcm_bytes), CHUNK_SIZE_BYTES): + chunk = pcm_bytes[i : i + CHUNK_SIZE_BYTES] + if len(chunk) == CHUNK_SIZE_BYTES: + chunks.append(chunk) + else: + st.audio_remainder = chunk + break + else: + st.audio_remainder = b"" + + if chunks: + st.audio_buffer_has_data = True + return chunks + + def handle_audio_commit(self, conn_id: str) -> RealtimeErrorEvent | None: + """Commit the audio buffer. Returns an error if no audio was appended.""" + st = self._state(conn_id) + if not st.audio_buffer_has_data: + return self.make_error( + message="Input audio buffer is empty, nothing to commit.", + _type="input_audio_buffer_commit_empty", + ) + st.audio_buffer_has_data = False + logger.debug("Audio buffer committed") + return None + + # ── Pipeline event handlers ──────────────────── + + def on_speech_started(self, conn_id: str, event: SpeechStartedEvent) -> list[ServerEvent]: + """Handle VAD speech_started: cancel active response if interrupts enabled, start new input item.""" + response = self._service.response + events: list[ServerEvent] = [] + st = self._state(conn_id) + if st.in_response and event.interrupt_response and st.runtime_config.interrupt_response_enabled: + events.extend(response.finish_response(conn_id, status="cancelled", reason="turn_detected")) + is_reopen = bool(event.reopened and event.turn_id is not None and event.turn_id == st.speculative_turn_id) + preserve_active_response = st.in_response + if is_reopen: + input_item_id = st.speculative_input_item_id + if input_item_id is None: + input_item_id = self._start_input_item( + conn_id, + preserve_active_response=preserve_active_response, + ) + st.speculative_input_item_id = input_item_id + elif not preserve_active_response: + st.current_item_id = input_item_id + st.content_index = 0 + st.input_audio_duration_s = 0.0 + st.input_content_index = 0 + else: + input_item_id = self._start_input_item( + conn_id, + preserve_active_response=preserve_active_response, + ) + st.speculative_input_item_id = input_item_id + st.response_usage.turns += 1 + st.speculative_turn_id = event.turn_id + st.speculative_turn_revision = event.turn_revision + st.last_item_id = input_item_id + events.append( + InputAudioBufferSpeechStartedEvent( + type="input_audio_buffer.speech_started", + event_id=self._next_event_id(), + audio_start_ms=event.audio_start_ms, + item_id=input_item_id, + ) + ) + return events + + def on_speech_stopped(self, conn_id: str, event: SpeechStoppedEvent) -> list[ServerEvent]: + """Handle VAD speech_stopped: record duration and emit stopped event.""" + if event.duration_s: + self._state(conn_id).input_audio_duration_s = event.duration_s + return [ + InputAudioBufferSpeechStoppedEvent( + type="input_audio_buffer.speech_stopped", + event_id=self._next_event_id(), + audio_end_ms=event.audio_end_ms, + item_id=self._input_item_id(conn_id), + ) + ] + + # ── Outbound audio encoding ────────────────── + + def encode_audio_chunk(self, conn_id: str, audio: bytes) -> list[ServerEvent]: + """Encode a raw PCM audio chunk, emitting ResponseCreated on the first chunk. + + When ``handle_response_create`` already allocated the response, + ``current_response_id`` is set and no duplicate event is emitted. + For the implicit-response path (VAD -> STT -> LLM -> TTS, no + ``response.create``), ``current_response_id`` is still ``None`` + and the event is emitted here on the first audio chunk. + """ + response = self._service.response + st = self._state(conn_id) + + events: list[ServerEvent] = [] + need_created = st.current_response_id is None + resp_id, item_id = response._ensure_response(conn_id) + if need_created: + events.append( + ResponseCreatedEvent( + type="response.created", + event_id=self._next_event_id(), + response=response._build_response(conn_id, "in_progress"), + ) + ) + rp = st.current_response_params + client_out_rate = None + if rp and rp.audio and rp.audio.output and rp.audio.output.format: + client_out_rate = getattr(rp.audio.output.format, "rate", None) + if client_out_rate is None: + audio_cfg = st.runtime_config.session.audio + if audio_cfg is not None and audio_cfg.output is not None: + client_out_rate = getattr(audio_cfg.output.format, "rate", None) or PIPELINE_SAMPLE_RATE + else: + client_out_rate = PIPELINE_SAMPLE_RATE + audio = resample(audio, PIPELINE_SAMPLE_RATE, client_out_rate) + b64 = base64.b64encode(audio).decode("ascii") + events.append( + ResponseAudioDeltaEvent( + type="response.output_audio.delta", + event_id=self._next_event_id(), + content_index=response._next_content_index(conn_id), + delta=b64, + item_id=item_id, + output_index=0, + response_id=resp_id, + ) + ) + return events diff --git a/speech_to_speech/api/openai_realtime/handlers/base.py b/speech_to_speech/api/openai_realtime/handlers/base.py new file mode 100644 index 0000000..abc9083 --- /dev/null +++ b/speech_to_speech/api/openai_realtime/handlers/base.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import logging +from queue import Queue +from threading import Event as ThreadingEvent +from typing import TYPE_CHECKING + +from openai.types.realtime import RealtimeErrorEvent + +from speech_to_speech.utils.utils import _generate_id + +if TYPE_CHECKING: + from speech_to_speech.api.openai_realtime.service import ConnState, RealtimeService + +logger = logging.getLogger(__name__) + + +class RealtimeBaseHandler: + """Shared base for domain handlers. + + Provides conn_id-keyed accessors for per-connection state, config, + and queues. Each connection owns its own ``RuntimeConfig`` via + ``ConnState``. + """ + + def __init__(self, service: RealtimeService) -> None: + self._service = service + + # ── conn_id-keyed accessors ── + + def _queue(self, conn_id: str) -> Queue | None: + return self._service.text_prompt_queue + + def _should_listen(self, conn_id: str) -> ThreadingEvent | None: + return self._service.should_listen + + # ── shared helpers ──────────────────────────────── + + def _state(self, conn_id: str) -> ConnState: + return self._service._state(conn_id) + + def _input_item_id(self, conn_id: str) -> str: + st = self._state(conn_id) + return st.speculative_input_item_id or self._service.response._current_item_id(conn_id) + + def _next_input_content_index(self, conn_id: str) -> int: + st = self._state(conn_id) + idx = st.input_content_index + st.input_content_index += 1 + return idx + + @staticmethod + def _next_event_id() -> str: + return _generate_id("event") + + def make_error(self, message: str, _type: str) -> RealtimeErrorEvent: + return self._service.make_error(message, _type) diff --git a/speech_to_speech/api/openai_realtime/handlers/conversation.py b/speech_to_speech/api/openai_realtime/handlers/conversation.py new file mode 100644 index 0000000..38279e1 --- /dev/null +++ b/speech_to_speech/api/openai_realtime/handlers/conversation.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from openai.types.realtime import ( + ConversationItem, + ConversationItemCreatedEvent, + ConversationItemCreateEvent, + ConversationItemInputAudioTranscriptionCompletedEvent, + ConversationItemInputAudioTranscriptionDeltaEvent, +) +from openai.types.realtime.conversation_item_input_audio_transcription_completed_event import ( + UsageTranscriptTextUsageDuration, +) + +from speech_to_speech.api.openai_realtime.handlers.base import RealtimeBaseHandler +from speech_to_speech.LLM.chat import ChatItemError, add_supported_item +from speech_to_speech.pipeline.events import PartialTranscriptionEvent, TranscriptionCompletedEvent + +if TYPE_CHECKING: + from speech_to_speech.api.openai_realtime.service import ServerEvent + +logger = logging.getLogger(__name__) + + +class ConversationHandler(RealtimeBaseHandler): + """Owns conversation item injection and pipeline-to-protocol translation.""" + + def handle_conversation_item_create( + self, + conn_id: str, + event: ConversationItemCreateEvent, + ) -> list[ServerEvent]: + """Inject a text message or function-call output into the LLM context. + + Items are added to the LLM chat context but do NOT trigger response + generation on their own. A subsequent ``response.create`` event is + required to trigger the model. + + While a response is generating, the item is *deferred*: applying it now + would race the LLM handler's end-of-turn chat write-back, which runs on + the pipeline thread (e.g. a ``function_call_output`` arriving before its + ``function_call`` is recorded, or an image stripped before the next + turn reads it). Deferred items are flushed, in order, once the response + completes — see :meth:`flush_deferred_items`. + """ + st = self._state(conn_id) + if st.in_response: + st.deferred_items.append(event.item) + logger.debug("Deferred conversation item until the active response completes") + return [] + return self._apply_item(conn_id, event.item) + + def _apply_item(self, conn_id: str, item: ConversationItem) -> list[ServerEvent]: + """Add one item to the chat and build its ``conversation.item.created``.""" + try: + self._append_item(conn_id, item) + except ChatItemError as exc: + return [self.make_error(str(exc), "invalid_conversation_item")] + + if not item: + return [] + st = self._state(conn_id) + event = ConversationItemCreatedEvent( + type="conversation.item.created", + event_id=self._next_event_id(), + previous_item_id=st.last_item_id, + item=item, + ) + st.last_item_id = item.id + return [event] + + def flush_deferred_items(self, conn_id: str) -> list[ServerEvent]: + """Apply items buffered during a response, in arrival order. + + Called at response completion (after the generation's own write-back), + so a ``function_call_output`` pairs with its now-recorded ``function_call`` + and an image survives the just-finished response's ``strip_images``. + """ + st = self._state(conn_id) + if not st.deferred_items: + return [] + items = st.deferred_items + st.deferred_items = [] + events: list[ServerEvent] = [] + for item in items: + events.extend(self._apply_item(conn_id, item)) + return events + + def _append_item(self, conn_id: str, item: ConversationItem) -> None: + """Narrow ``ConversationItem`` to ``SupportedItem`` and delegate to ``Chat.add_item``. + + Raises :class:`ChatItemError` on validation failure or unsupported type. + """ + add_supported_item(self._state(conn_id).runtime_config.chat, item) + + # ── Pipeline event handlers ──────────────────── + + def on_partial_transcription(self, conn_id: str, event: PartialTranscriptionEvent) -> list[ServerEvent]: + """Handle partial_transcription: emit transcription delta event.""" + return [ + ConversationItemInputAudioTranscriptionDeltaEvent( + type="conversation.item.input_audio_transcription.delta", + event_id=self._next_event_id(), + content_index=self._next_input_content_index(conn_id), + item_id=self._input_item_id(conn_id), + delta=event.delta, + ) + ] + + def on_transcription_completed(self, conn_id: str, event: TranscriptionCompletedEvent) -> list[ServerEvent]: + """Handle transcription_completed: accumulate duration and emit completed event.""" + st = self._state(conn_id) + st.response_usage.audio_duration_s += st.input_audio_duration_s + return [ + ConversationItemInputAudioTranscriptionCompletedEvent( + type="conversation.item.input_audio_transcription.completed", + event_id=self._next_event_id(), + content_index=0, + item_id=self._input_item_id(conn_id), + transcript=event.transcript, + usage=UsageTranscriptTextUsageDuration( + seconds=st.input_audio_duration_s, + type="duration", + ), + ) + ] diff --git a/speech_to_speech/api/openai_realtime/handlers/response.py b/speech_to_speech/api/openai_realtime/handlers/response.py new file mode 100644 index 0000000..7e24ab9 --- /dev/null +++ b/speech_to_speech/api/openai_realtime/handlers/response.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import logging +from time import perf_counter +from typing import TYPE_CHECKING, Optional + +from openai.types.realtime import ( + RealtimeResponse, + ResponseAudioDoneEvent, + ResponseAudioTranscriptDoneEvent, + ResponseCreatedEvent, + ResponseCreateEvent, + ResponseDoneEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, +) +from openai.types.realtime.realtime_response import Audio, AudioOutput +from openai.types.realtime.realtime_response_status import RealtimeResponseStatus +from openai.types.realtime.realtime_response_usage import RealtimeResponseUsage + +from speech_to_speech.api.openai_realtime.handlers.base import RealtimeBaseHandler +from speech_to_speech.LLM.chat import ChatItemError +from speech_to_speech.pipeline.events import AssistantTextEvent +from speech_to_speech.pipeline.messages import GenerateResponseRequest +from speech_to_speech.utils.utils import _generate_id, is_out_of_band, response_wants_audio + +if TYPE_CHECKING: + from speech_to_speech.api.openai_realtime.service import ServerEvent, _ResponseStatus, _StatusReason + +logger = logging.getLogger(__name__) + + +class ResponseHandler(RealtimeBaseHandler): + """Owns the response lifecycle: create, cancel, finish, and ID management.""" + + # ── ID / state helpers ──────────────────────── + + def _ensure_response(self, conn_id: str) -> tuple[str, str]: + """Ensure a response and output item exist, creating them if needed.""" + st = self._state(conn_id) + if st.current_response_id is None: + st.current_response_id = _generate_id("resp") + self._start_item(conn_id) + st.in_response = True + st.response_pending = False + return st.current_response_id, self._current_item_id(conn_id) + + def _end_response(self, conn_id: str, status: _ResponseStatus = "completed") -> None: + st = self._state(conn_id) + if status == "cancelled": + st.response_usage.responses_cancelled += 1 + else: + st.response_usage.responses_completed += 1 + self._service.total_usage += st.response_usage + logger.info( + "Response done (status=%s) — this response: input_tokens=%d, output_tokens=%d, audio=%.2fs" + " | cumulative: input_tokens=%d, output_tokens=%d, audio=%.2fs", + status, + st.response_usage.input_tokens, + st.response_usage.output_tokens, + st.response_usage.audio_duration_s, + self._service.total_usage.input_tokens, + self._service.total_usage.output_tokens, + self._service.total_usage.audio_duration_s, + ) + st.response_usage.reset() + st.current_response_id = None + st.current_item_id = None + st.content_index = 0 + st.in_response = False + st.response_pending = False + st.current_response_params = None + st.pending_output_text_parts = [] + st.response_audio_wait_started_at_s = None + st.delay_filler_sent = False + + def _start_item(self, conn_id: str) -> str: + """Generate a new item ID, reset content index, and store it.""" + st = self._state(conn_id) + item_id = _generate_id("item") + st.current_item_id = item_id + st.content_index = 0 + st.input_audio_duration_s = 0.0 + return item_id + + def _current_item_id(self, conn_id: str) -> str: + return self._state(conn_id).current_item_id or self._start_item(conn_id) + + def _next_content_index(self, conn_id: str) -> int: + """Return the current content index and advance it.""" + st = self._state(conn_id) + idx = st.content_index + st.content_index += 1 + return idx + + def _build_response( + self, + conn_id: str, + status: _ResponseStatus, + reason: _StatusReason | None = None, + ) -> RealtimeResponse: + """Build a fully-populated RealtimeResponse from the current connection state.""" + st = self._state(conn_id) + status_details = None + if reason or status in ("completed", "cancelled", "incomplete", "failed"): + status_details = RealtimeResponseStatus(type=status, reason=reason) # type: ignore[arg-type] + + rp = st.current_response_params + metadata = rp.metadata if rp and rp.metadata else None + + voice: Optional[str] = None + if rp and rp.audio and rp.audio.output and rp.audio.output.voice: + voice = str(rp.audio.output.voice) + if not voice: + audio_cfg = st.runtime_config.session.audio + audio_output = audio_cfg.output if audio_cfg is not None else None + voice = str(audio_output.voice) if audio_output is not None and audio_output.voice else None + + # Out-of-band responses are not threaded into any conversation: report a null id. + conversation_id = None if is_out_of_band(rp) else st.conversation_id + + return RealtimeResponse( + id=st.current_response_id, + object="realtime.response", + status=status, + status_details=status_details, + audio=Audio(output=AudioOutput(voice=str(voice) if voice else None)), # type: ignore[arg-type] + conversation_id=conversation_id, + metadata=metadata, + usage=RealtimeResponseUsage( + input_tokens=st.response_usage.input_tokens, + output_tokens=st.response_usage.output_tokens, + total_tokens=st.response_usage.input_tokens + st.response_usage.output_tokens, + ), + ) + + # ── Public handlers ─────────────────────────── + + def handle_response_create(self, conn_id: str, event: ResponseCreateEvent) -> ServerEvent | None: + """Trigger a response. + + Returns a ``ResponseCreatedEvent`` on success, a ``RealtimeErrorEvent`` + on failure, or ``None`` if there is no text_prompt_queue. + """ + st = self._state(conn_id) + if event.response: + if event.response.tool_choice and not isinstance(event.response.tool_choice, str): + return self.make_error( + message="Only string tool_choice values are supported for now (auto, required, none).", + _type="tool_choice_not_supported", + ) + if st.in_response: + return self.make_error( + message="Cannot create response while another response is in progress.", + _type="conversation_already_has_active_response", + ) + + out_of_band = is_out_of_band(event.response) + + # In-band: response.input items are added to the default conversation here so + # they appear in history. Out-of-band: leave the default conversation untouched — + # the input rides along on the request and seeds a throwaway chat in the LM. + if not out_of_band and event.response and event.response.input: + for input_item in event.response.input: + try: + self._service.conversation._append_item(conn_id, input_item) + except ChatItemError as exc: + return self.make_error(message=str(exc), _type="invalid_input_item") + + st.in_response = True + st.response_pending = False + + st.current_response_params = event.response + st.current_response_id = _generate_id("resp") + self._start_item(conn_id) + if response_wants_audio(event.response): + st.response_audio_wait_started_at_s = perf_counter() + st.delay_filler_sent = False + else: + st.response_audio_wait_started_at_s = None + st.delay_filler_sent = False + + cfg = st.runtime_config + queue = self._queue(conn_id) + if queue: + # Out-of-band responses carry no turn identity: a null turn_id makes every + # speculative-turn staleness gate treat them as always-latest, so a new user + # turn mid-generation can never silently drop their output. + queue.put( + GenerateResponseRequest( + runtime_config=cfg, + response=event.response, + turn_id=None if out_of_band else st.speculative_user_turn_id, + turn_revision=None if out_of_band else st.speculative_user_turn_revision, + speech_stopped_at_s=None if out_of_band else st.speculative_user_speech_stopped_at_s, + ) + ) + logger.debug("response.create received, LLM generation triggered") + return ResponseCreatedEvent( + type="response.created", + event_id=self._next_event_id(), + response=self._build_response(conn_id, "in_progress"), + ) + + def handle_response_cancel(self, conn_id: str) -> list[ServerEvent]: + """Cancel the in-progress response and re-enable listening.""" + events = self.finish_response(conn_id, status="cancelled", reason="client_cancelled") + should_listen = self._should_listen(conn_id) + if should_listen: + should_listen.set() + logger.info("Response cancelled, listening re-enabled") + return events + + def finish_response( + self, + conn_id: str, + status: _ResponseStatus = "completed", + reason: _StatusReason | None = None, + ) -> list[ServerEvent]: + """Close the current response (audio/text done + response done). + + Audio responses emit ``response.output_audio.done`` for any terminal + status. Text-only responses emit a single ``response.output_text.done`` + carrying the full streamed text, but only on ``status="completed"`` — + a cancelled or failed text response sends no audio, so it just closes + with ``response.done``. + """ + st = self._state(conn_id) + events: list[ServerEvent] = [] + if st.in_response: + resp_id, item_id = self._ensure_response(conn_id) + if response_wants_audio(st.current_response_params): + events.append( + ResponseAudioDoneEvent( + type="response.output_audio.done", + event_id=self._next_event_id(), + content_index=0, + item_id=item_id, + output_index=0, + response_id=resp_id, + ) + ) + elif status == "completed" and st.pending_output_text_parts: + events.append( + ResponseTextDoneEvent( + type="response.output_text.done", + event_id=self._next_event_id(), + content_index=0, + item_id=item_id, + output_index=0, + response_id=resp_id, + text="".join(st.pending_output_text_parts), + ) + ) + events.append( + ResponseDoneEvent( + type="response.done", + event_id=self._next_event_id(), + response=self._build_response(conn_id, status, reason), + ) + ) + self._end_response(conn_id, status) + # Apply any client items that arrived mid-generation now that in_response + # is cleared and the generation's own write-back has landed. Done outside + # the in_response guard so a stray terminal call still drains the buffer. + events.extend(self._service.conversation.flush_deferred_items(conn_id)) + return events + + # ── Pipeline event handlers ─────────────────── + + def on_assistant_text( + self, + conn_id: str, + event: AssistantTextEvent, + *, + wait_for_pending_reopen: bool = True, + ) -> list[ServerEvent] | None: + """Handle assistant_text: emit transcript and/or tool-call events.""" + if self._service.speculative_turns: + commit_result: bool | None + if wait_for_pending_reopen: + commit_result = self._service.speculative_turns.commit_if_latest_after_reopen_grace( + event.turn_id, + event.turn_revision, + ) + else: + commit_result = self._service.speculative_turns.try_commit_if_latest_after_reopen_grace( + event.turn_id, + event.turn_revision, + ) + if commit_result is None: + return None + if not commit_result: + logger.debug("Dropping stale assistant text for turn=%s rev=%s", event.turn_id, event.turn_revision) + return [] + st = self._state(conn_id) + events: list[ServerEvent] = [] + resp_id, item_id = self._ensure_response(conn_id) + st.last_item_id = item_id + output_idx = 0 + if event.text: + if response_wants_audio(st.current_response_params): + events.append( + ResponseAudioTranscriptDoneEvent( + type="response.output_audio_transcript.done", + event_id=self._next_event_id(), + content_index=0, + item_id=item_id, + output_index=output_idx, + response_id=resp_id, + transcript=event.text, + ) + ) + else: + # Stream the delta now; the matching response.output_text.done is + # emitted once at close in finish_response, carrying the per-chunk + # parts collected here, space-joined ("" when there were none). + st.pending_output_text_parts.append(event.text) + events.append( + ResponseTextDeltaEvent( + type="response.output_text.delta", + event_id=self._next_event_id(), + content_index=0, + item_id=item_id, + output_index=output_idx, + response_id=resp_id, + delta=event.text, + ) + ) + output_idx += 1 + if event.tools: + st.response_usage.tool_calls += len(event.tools) + for tool in event.tools: + events.append( + ResponseFunctionCallArgumentsDoneEvent( + type="response.function_call_arguments.done", + event_id=self._next_event_id(), + call_id=tool.call_id, + name=tool.name, + arguments=tool.arguments, + item_id=item_id, + output_index=output_idx, + response_id=resp_id, + ) + ) + output_idx += 1 + return events diff --git a/speech_to_speech/api/openai_realtime/handlers/session.py b/speech_to_speech/api/openai_realtime/handlers/session.py new file mode 100644 index 0000000..7b7e425 --- /dev/null +++ b/speech_to_speech/api/openai_realtime/handlers/session.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import logging +from typing import Optional + +from openai.types.realtime import ( + RealtimeErrorEvent, + SessionCreatedEvent, + SessionUpdateEvent, +) +from openai.types.realtime.realtime_transcription_session_create_request import ( + RealtimeTranscriptionSessionCreateRequest, +) + +from speech_to_speech.api.openai_realtime.handlers.base import RealtimeBaseHandler + +logger = logging.getLogger(__name__) + + +class SessionHandler(RealtimeBaseHandler): + """Owns session lifecycle: config updates and session.created events.""" + + def handle_session_update(self, conn_id: str, event: SessionUpdateEvent) -> Optional[RealtimeErrorEvent]: + """Apply session config changes. + + Only ``RealtimeSessionCreateRequest`` sessions are accepted; + ``RealtimeTranscriptionSessionCreateRequest`` sessions not yet supported. + Incoming fields are deep-merged into the existing session so that + partial updates preserve previously-set values. + """ + s = event.session + + # Forza il bypass della voce inviata dal browser + if hasattr(s, "voice") and s.voice is not None: + s.voice = None + elif isinstance(s, dict) and "voice" in s: + s["voice"] = None + + if s is None: + return None + + if isinstance(s, RealtimeTranscriptionSessionCreateRequest): + return self.make_error( + message="Only 'realtime' session type is supported; transcription sessions are not.", + _type="invalid_session_type", + ) + + model = getattr(s, "model", None) + if model is not None: + logger.info(f"Session model set to: {model}") + + cfg = self._state(conn_id).runtime_config + current = cfg.session + if current is None: + cfg.session = s + else: + cfg.apply_session_update(s) + logger.info("Session configuration updated") + return None + + def build_session_created(self, conn_id: str) -> SessionCreatedEvent: + """Build a SessionCreatedEvent populated with the current config.""" + cfg = self._state(conn_id).runtime_config + session = cfg.session + return SessionCreatedEvent( + type="session.created", + event_id=self._next_event_id(), + session=session, + ) diff --git a/speech_to_speech/api/openai_realtime/pipeline_unit.py b/speech_to_speech/api/openai_realtime/pipeline_unit.py new file mode 100644 index 0000000..86e8829 --- /dev/null +++ b/speech_to_speech/api/openai_realtime/pipeline_unit.py @@ -0,0 +1,70 @@ +import asyncio +from queue import Queue +from threading import Event +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from speech_to_speech.api.openai_realtime.service import RealtimeService +from speech_to_speech.pipeline.cancel_scope import CancelScope + + +class SessionState(BaseModel): + """Per-websocket ephemeral state. + + Created when the route handler claims a PipelineUnit on `ws.accept()`; dropped + when the websocket disconnects. Holding the websocket reference, the service + session id, and any send-loop scratch (pending_output_item) here ensures these + fields share one lifecycle — a stale value can't outlive its session. + + `drained` is set by the send loop when SESSION_END travels through the handler + chain back to the output queue; the route handler awaits it before clearing + `PipelineUnit.session`, so a new client cannot claim the unit until in-flight + work from this session has fully reset. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + websocket: Any + session_id: str = "" + pending_output_item: Any = None + drained: asyncio.Event = Field(default_factory=asyncio.Event) + # Wall-clock time when the ws disconnected (route handler released its claim). + # `None` while the client is still active. Used by /v1/pool to surface stuck + # units (handlers haven't finished propagating SESSION_END). + released_at: Optional[float] = None + # Wall-clock time when the drain wait gave up and quarantined the unit + # (SESSION_END_QUARANTINE_TIMEOUT_S elapsed). The unit stays unclaimable — + # its handlers may still emit this session's output — until SESSION_END + # actually drains. Reported as "stuck" by /v1/pool. + quarantined_at: Optional[float] = None + + +class PipelineUnit(BaseModel): + """One isolated realtime pipeline. + + Each unit owns its queues, events, RealtimeService, and the chain of handler + instances (VAD, STT, transcription notifier, LM, LM output processor, TTS). + Lives inside the pool managed by RealtimeServer; the websocket route handler + claims a free unit (`session is None`) on `accept` and releases it on disconnect + by setting `session` back to None. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + index: int + service: RealtimeService + cancel_scope: CancelScope + should_listen: Event + response_playing: Event + input_queue: Queue + output_queue: Queue + text_output_queue: Queue + text_prompt_queue: Queue + handlers: list[Any] + delay_filler_threshold_s: float = 0.0 + delay_filler_short_clips: list[bytes] = Field(default_factory=list) + delay_filler_full_clips: list[bytes] = Field(default_factory=list) + http_tts_reserved: bool = False + + session: Optional[SessionState] = None diff --git a/speech_to_speech/api/openai_realtime/runtime_config.py b/speech_to_speech/api/openai_realtime/runtime_config.py new file mode 100644 index 0000000..f79b4ad --- /dev/null +++ b/speech_to_speech/api/openai_realtime/runtime_config.py @@ -0,0 +1,81 @@ +from openai.types.realtime import RealtimeSessionCreateRequest +from openai.types.realtime.realtime_audio_config import RealtimeAudioConfig +from openai.types.realtime.realtime_audio_config_input import RealtimeAudioConfigInput +from openai.types.realtime.realtime_audio_config_output import RealtimeAudioConfigOutput +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from speech_to_speech.LLM.chat import Chat + + +def _apply_update(current: BaseModel, update: BaseModel) -> None: + """Apply explicitly-set fields from *update* onto *current* in-place, + recursing into nested BaseModel children so partial nested updates + don't overwrite unset fields. + + Only fields present in update.model_fields_set (i.e. actually + sent by the client) are considered. + """ + for field_name in update.model_fields_set: + new_val = getattr(update, field_name) + old_val = getattr(current, field_name, None) + if isinstance(new_val, BaseModel) and isinstance(old_val, BaseModel): + _apply_update(old_val, new_val) + else: + setattr(current, field_name, new_val) + + +class RuntimeConfig(BaseModel): + """ + Shared mutable configuration written by the RealtimeService on + session.update and read by pipeline handlers (VAD, LLM, TTS) during + processing. Python's GIL makes simple attribute reads/writes atomic, + so no explicit locking is needed for primitive values. + + The canonical state lives in 'session' (a full + 'RealtimeSessionCreateRequest'). + """ + + model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True) + + chat: Chat = Field(default_factory=lambda: Chat(10)) + session: RealtimeSessionCreateRequest = Field( + default_factory=lambda: RealtimeSessionCreateRequest(type="realtime"), + validate_default=True, + ) + + @field_validator("session", mode="after") + @classmethod + def _ensure_audio_structure(cls, v: RealtimeSessionCreateRequest) -> RealtimeSessionCreateRequest: + """Guarantee 'audio.input' and 'audio.output' are never None.""" + if v.audio is None: + v.audio = RealtimeAudioConfig() + if v.audio.input is None: + v.audio.input = RealtimeAudioConfigInput() + if v.audio.output is None: + v.audio.output = RealtimeAudioConfigOutput() + return v + + @property + def interrupt_response_enabled(self) -> bool: + """Whether barge-in should cancel an active response. + + Reads 'turn_detection.interrupt_response' from the session config, + handling both Pydantic models ('ServerVad') and plain dicts. + Defaults to 'True' (OpenAI API default). + """ + assert self.session.audio is not None and self.session.audio.input is not None + td = self.session.audio.input.turn_detection + if td is None: + return True + if hasattr(td, "interrupt_response"): + val = td.interrupt_response + elif isinstance(td, dict): + val = td.get("interrupt_response", True) + else: + return True + return val if val is not None else True + + def apply_session_update(self, update: RealtimeSessionCreateRequest) -> None: + """Merge non-None, explicitly-set fields from 'update' into the + current 'session', preserving any fields not present in the update.""" + _apply_update(self.session, update) diff --git a/speech_to_speech/api/openai_realtime/server.py b/speech_to_speech/api/openai_realtime/server.py new file mode 100644 index 0000000..4b3b5de --- /dev/null +++ b/speech_to_speech/api/openai_realtime/server.py @@ -0,0 +1,61 @@ +import logging +import threading +from threading import Event + +import uvicorn + +from speech_to_speech.api.openai_realtime.pipeline_unit import PipelineUnit +from speech_to_speech.api.openai_realtime.websocket_router import create_app + +logger = logging.getLogger(__name__) + + +class RealtimeServer: + """ + Pipeline handler for the OpenAI Realtime API mode. + + Owns a pool of isolated PipelineUnits and a single uvicorn server. + The websocket route claims the next free unit on each accept and releases + it on disconnect; once all units are in use, further connections are rejected. + """ + + def __init__( + self, + stop_event: Event, + pool: list[PipelineUnit], + host: str = "0.0.0.0", + port: int = 8765, + ) -> None: + if not pool: + raise ValueError("RealtimeServer requires at least one PipelineUnit in the pool") + self.stop_event = stop_event + self.pool = pool + self.host = host + self.port = port + + def run(self) -> None: + """Start the FastAPI/uvicorn server (called from a ThreadManager thread).""" + app = create_app(pool=self.pool, stop_event=self.stop_event) + + logger.info( + f"OpenAI Realtime API starting on ws://{self.host}:{self.port}/v1/realtime (pool size {len(self.pool)})" + ) + + config = uvicorn.Config( + app, + host=self.host, + port=self.port, + log_level="info", + ) + server = uvicorn.Server(config) + + server.install_signal_handlers = lambda: None # type: ignore[attr-defined] + + def _watch_stop() -> None: + self.stop_event.wait() + server.should_exit = True + + watcher = threading.Thread(target=_watch_stop, daemon=True) + watcher.start() + + server.run() diff --git a/speech_to_speech/api/openai_realtime/service.py b/speech_to_speech/api/openai_realtime/service.py new file mode 100644 index 0000000..98567aa --- /dev/null +++ b/speech_to_speech/api/openai_realtime/service.py @@ -0,0 +1,560 @@ +import logging +from collections.abc import Mapping +from queue import Queue +from threading import Event as ThreadingEvent +from time import perf_counter +from typing import Any, Callable, Literal, Optional, TypeVar, Union + +from openai.types.realtime import ( + ConversationItem, + ConversationItemCreatedEvent, + ConversationItemCreateEvent, + ConversationItemInputAudioTranscriptionCompletedEvent, + ConversationItemInputAudioTranscriptionDeltaEvent, + InputAudioBufferAppendEvent, + InputAudioBufferSpeechStartedEvent, + InputAudioBufferSpeechStoppedEvent, + RealtimeError, + RealtimeErrorEvent, + ResponseAudioDeltaEvent, + ResponseAudioDoneEvent, + ResponseAudioTranscriptDoneEvent, + ResponseCancelEvent, + ResponseCreatedEvent, + ResponseCreateEvent, + ResponseDoneEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, + SessionCreatedEvent, + SessionUpdateEvent, +) +from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from speech_to_speech.api.openai_realtime.handlers import ( + AudioHandler, + ConversationHandler, + ResponseHandler, + SessionHandler, +) +from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig +from speech_to_speech.LLM.chat import Chat, make_user_message +from speech_to_speech.pipeline.events import ( + AssistantTextEvent, + PartialTranscriptionEvent, + PipelineEvent, + ResponseFailedEvent, + SpeechStartedEvent, + SpeechStoppedEvent, + TokenUsageEvent, + TranscriptionCompletedEvent, +) +from speech_to_speech.pipeline.messages import GenerateResponseRequest +from speech_to_speech.pipeline.queue_types import TextPromptItem +from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker +from speech_to_speech.utils.utils import _generate_id + +logger = logging.getLogger(__name__) + +PIPELINE_SAMPLE_RATE = 16000 +CHUNK_SAMPLES = 512 +BYTES_PER_SAMPLE = 2 +CHUNK_SIZE_BYTES = CHUNK_SAMPLES * BYTES_PER_SAMPLE + +_ResponseStatus = Literal["completed", "cancelled", "failed", "incomplete", "in_progress"] +_StatusReason = Literal["turn_detected", "client_cancelled", "max_output_tokens", "content_filter"] + +_EVENT_TYPE_TO_MODEL: dict[str, type[BaseModel]] = { + "input_audio_buffer.append": InputAudioBufferAppendEvent, + "session.update": SessionUpdateEvent, + "conversation.item.create": ConversationItemCreateEvent, + "response.create": ResponseCreateEvent, + "response.cancel": ResponseCancelEvent, +} + +ClientEvent = Union[ + InputAudioBufferAppendEvent, + SessionUpdateEvent, + ConversationItemCreateEvent, + ResponseCreateEvent, + ResponseCancelEvent, +] + +ServerEvent = Union[ + SessionCreatedEvent, + RealtimeErrorEvent, + InputAudioBufferSpeechStartedEvent, + InputAudioBufferSpeechStoppedEvent, + ConversationItemCreatedEvent, + ConversationItemInputAudioTranscriptionDeltaEvent, + ConversationItemInputAudioTranscriptionCompletedEvent, + ResponseCreatedEvent, + ResponseDoneEvent, + ResponseAudioDeltaEvent, + ResponseAudioDoneEvent, + ResponseAudioTranscriptDoneEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, +] + +RealtimeEvent = Union[ClientEvent, ServerEvent] + + +_UsageMetricsT = TypeVar("_UsageMetricsT", bound="UsageMetrics") + + +class UsageMetrics(BaseModel): + """Per-response usage counters. + + Supports ``+=`` for rolling per-response metrics into a global total + and ``reset()`` for clearing per-response state after rollup. + """ + + input_tokens: int = 0 + output_tokens: int = 0 + audio_duration_s: float = 0.0 + responses_completed: int = 0 + responses_cancelled: int = 0 + tool_calls: int = 0 + turns: int = 0 + + def __iadd__(self: _UsageMetricsT, other: "UsageMetrics") -> _UsageMetricsT: + for field in UsageMetrics.model_fields: + setattr(self, field, getattr(self, field) + getattr(other, field)) + return self + + def reset(self) -> None: + for field, info in UsageMetrics.model_fields.items(): + setattr(self, field, info.default) + + +class GlobalUsageMetrics(UsageMetrics): + """Server-wide metrics that extend per-response counters with + connection and error tracking.""" + + connections: int = 0 + # connection duration in seconds. + # latency tts, llm, vad, stt (mean, max, p90) + errors_by_type: dict[str, int] = Field(default_factory=dict) + + def record_error(self, error_type: str) -> None: + self.errors_by_type[error_type] = self.errors_by_type.get(error_type, 0) + 1 + + @property + def total_errors(self) -> int: + return sum(self.errors_by_type.values()) + + +class ConnState(BaseModel): + """Per-connection mutable state, including all protocol-level IDs.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + session_id: str = Field(default_factory=lambda: _generate_id("session")) + conversation_id: str = Field(default_factory=lambda: _generate_id("conv")) + runtime_config: RuntimeConfig = Field(default_factory=RuntimeConfig) + in_response: bool = False + response_pending: bool = False + audio_buffer_has_data: bool = False + audio_remainder: bytes = b"" + current_response_id: Optional[str] = None + current_item_id: Optional[str] = None + content_index: int = 0 + input_content_index: int = 0 + input_audio_duration_s: float = 0.0 + last_item_id: Optional[str] = None + current_response_params: RealtimeResponseCreateParams | None = None + pending_output_text_parts: list[str] = Field(default_factory=list) + response_usage: UsageMetrics = Field(default_factory=UsageMetrics) + response_audio_wait_started_at_s: float | None = None + delay_filler_sent: bool = False + delay_filler_cursor: int = 0 + delay_filler_last_kind: str | None = None + delay_filler_last_index: int | None = None + speculative_turn_id: Optional[str] = None + speculative_turn_revision: Optional[int] = None + speculative_user_turn_id: Optional[str] = None + speculative_user_turn_revision: Optional[int] = None + speculative_user_speech_stopped_at_s: Optional[float] = None + speculative_user_item_id: Optional[str] = None + speculative_input_item_id: Optional[str] = None + speculative_audio_duration_s: float = 0.0 + # Client conversation.item.create items that arrived while a response was + # generating. Applying them mid-generation races the LLM handler's chat + # write-back (cross-thread), so they are buffered here and flushed in order + # once the response completes. See ConversationHandler.flush_deferred_items. + deferred_items: list[ConversationItem] = Field(default_factory=list) + + +class RealtimeService: + """Translates between OpenAI Realtime protocol events and internal pipeline messages. + + One instance is shared across all WebSocket connections. Per-connection + state (response lifecycle, audio buffer) is tracked internally by + connection id. + """ + + def __init__( + self, + text_prompt_queue: Queue[TextPromptItem] | None = None, + should_listen: ThreadingEvent | None = None, + chat_size: int = 10, + speculative_turns: SpeculativeTurnTracker | None = None, + ) -> None: + self.text_prompt_queue = text_prompt_queue + self.should_listen = should_listen + self._chat_size = chat_size + self.speculative_turns = speculative_turns + self._conns: dict[str, ConnState] = {} + self.total_usage = GlobalUsageMetrics() + + self.audio = AudioHandler(self) + self.session = SessionHandler(self) + self.response = ResponseHandler(self) + self.conversation = ConversationHandler(self) + + self._pipeline_dispatch: dict[type[PipelineEvent], Callable[..., list[ServerEvent]]] = { + SpeechStartedEvent: self.audio.on_speech_started, + SpeechStoppedEvent: self.audio.on_speech_stopped, + TokenUsageEvent: self._on_token_usage, + PartialTranscriptionEvent: self.conversation.on_partial_transcription, + TranscriptionCompletedEvent: self._on_transcription_completed, + ResponseFailedEvent: self._on_response_failed, + } + + # ── Connection lifecycle ───────────────────── + + def register(self) -> str: + """Register a new connection and return its session_id.""" + if self.speculative_turns: + self.speculative_turns.reset() + state = ConnState(runtime_config=RuntimeConfig(chat=Chat(self._chat_size))) + self._conns[state.session_id] = state + self.total_usage.connections += 1 + return state.session_id + + def unregister(self, conn_id: str) -> None: + st = self._conns.pop(conn_id, None) + if st is not None: + # Suppress any in-flight compaction splice so a daemon worker can't + # mutate a Chat tied to a closed session, and don't make further + # billable LLM calls on its behalf once the splice is suppressed. + st.runtime_config.chat.close() + self.total_usage += st.response_usage + logger.info( + "Session %s unregistered — cumulative: input_tokens=%d, output_tokens=%d, audio=%.2fs", + conn_id, + self.total_usage.input_tokens, + self.total_usage.output_tokens, + self.total_usage.audio_duration_s, + ) + + def _state(self, conn_id: str) -> ConnState: + return self._conns[conn_id] + + @property + def connection_ids(self) -> list[str]: + return list(self._conns) + + # ── Client event parsing ───────────────────── + + @staticmethod + def _next_event_id() -> str: + return _generate_id("event") + + def parse_client_event(self, raw: Mapping[str, object]) -> Optional[ClientEvent]: + raw_type = raw.get("type") + event_type: Optional[str] = raw_type if isinstance(raw_type, str) else None + if event_type is None: + logger.warning("Client event missing 'type' field") + return None + model_cls = _EVENT_TYPE_TO_MODEL.get(event_type) + if model_cls is None: + logger.warning(f"Unknown client event type: {event_type}") + return None + try: + return model_cls.model_validate(raw) # type: ignore[return-value] + except ValidationError as e: + # ── Retrocompatibilità session.update ───────────────────────────────── + # Il formato standard OpenAI Realtime GA annida instructions/tools/tool_choice + # DENTRO "session": { ... }. Per tollerare anche client che li mandano + # alla RADICE (es. bug prima della Fase 2 MCP), ricostruiamo qui il payload + # corretto e riproviamo la validazione. Funziona SOLO per session.update. + if event_type == "session.update": + try: + raw_dict = dict(raw) if not isinstance(raw, dict) else raw + session_inner = raw_dict.get("session") + if isinstance(session_inner, dict): + normalized_inner = dict(session_inner) + else: + normalized_inner = {} + # Promuovi campi noti dalla root → inner se mancanti (fallback flat) + SESSION_KNOWN_TOP_LEVEL = ( + "instructions", + "tools", + "tool_choice", + "model", + "voice", + "modalities", + "input_audio_format", + "output_audio_format", + "input_audio_transcription", + "turn_detection", + "input_audio_noise_reduction", + ) + promoted = 0 + for key in SESSION_KNOWN_TOP_LEVEL: + if key in raw_dict and key not in normalized_inner: + normalized_inner[key] = raw_dict[key] + promoted += 1 + if promoted > 0 or not isinstance(session_inner, dict): + rebuilt = {"type": "session.update", "session": normalized_inner} + logger.info( + "session.update parser fallback: promoted %d flat root keys → nested session wrapper. Re-validating.", + promoted, + ) + return model_cls.model_validate(rebuilt) # type: ignore[return-value] + except Exception as fallback_err: + logger.error( + "session.update fallback validation also failed: %s", + fallback_err, + ) + logger.error(f"Invalid {event_type} payload: {e}") + return None + + # ── Client event handlers ──────────────────── + + def build_session_created(self, conn_id: str) -> SessionCreatedEvent: + return self.session.build_session_created(conn_id) + + def handle_session_update(self, conn_id: str, event: SessionUpdateEvent) -> Optional[RealtimeErrorEvent]: + return self.session.handle_session_update(conn_id, event) + + def handle_audio_append(self, conn_id: str, event: InputAudioBufferAppendEvent) -> list[bytes]: + return self.audio.handle_audio_append(conn_id, event) + + def handle_audio_commit(self, conn_id: str) -> RealtimeErrorEvent | None: + return self.audio.handle_audio_commit(conn_id) + + def encode_audio_chunk(self, conn_id: str, audio: bytes) -> list[ServerEvent]: + return self.audio.encode_audio_chunk(conn_id, audio) + + def handle_response_create(self, conn_id: str, event: ResponseCreateEvent) -> ServerEvent | None: + return self.response.handle_response_create(conn_id, event) + + def handle_response_cancel(self, conn_id: str) -> list[ServerEvent]: + return self.response.handle_response_cancel(conn_id) + + def finish_response( + self, + conn_id: str, + status: _ResponseStatus = "completed", + reason: _StatusReason | None = None, + ) -> list[ServerEvent]: + return self.response.finish_response(conn_id, status, reason) + + def handle_conversation_item_create(self, conn_id: str, event: ConversationItemCreateEvent) -> list[ServerEvent]: + return self.conversation.handle_conversation_item_create(conn_id, event) + + def dispatch_pipeline_event(self, conn_id: str, event: PipelineEvent) -> list[ServerEvent]: + """Route a pipeline text_output_queue event to the appropriate handler.""" + events = self._dispatch_pipeline_event(conn_id, event, wait_for_pending_reopen=True) + return [] if events is None else events + + def try_dispatch_pipeline_event(self, conn_id: str, event: PipelineEvent) -> list[ServerEvent] | None: + """Non-blocking dispatch. + + Returns ``None`` when dispatch must be retried after a speculative + reopen candidate resolves. + """ + return self._dispatch_pipeline_event(conn_id, event, wait_for_pending_reopen=False) + + def should_defer_pipeline_event(self, event: PipelineEvent) -> bool: + if self.speculative_turns is None or not isinstance(event, (AssistantTextEvent, TokenUsageEvent)): + return False + return self.speculative_turns.has_pending_reopen_or_grace( + getattr(event, "turn_id", None), + getattr(event, "turn_revision", None), + ) + + def _dispatch_pipeline_event( + self, + conn_id: str, + event: PipelineEvent, + *, + wait_for_pending_reopen: bool, + ) -> list[ServerEvent] | None: + is_stale = self._is_stale_turn_event(event, wait_for_pending_reopen=wait_for_pending_reopen) + if is_stale is None: + return None + if is_stale: + logger.info( + "Ignoring stale %s for turn=%s rev=%s", + event.type, + getattr(event, "turn_id", None), + getattr(event, "turn_revision", None), + ) + return [] + + self._observe_turn_event(event) + if isinstance(event, AssistantTextEvent): + return self.response.on_assistant_text( + conn_id, + event, + wait_for_pending_reopen=wait_for_pending_reopen, + ) + handler = self._pipeline_dispatch.get(type(event)) + if handler is None: + logger.debug("Unhandled pipeline event type: %s", type(event).__name__) + return [] + return handler(conn_id, event) + + def _is_stale_turn_event(self, event: PipelineEvent, *, wait_for_pending_reopen: bool = True) -> bool | None: + if self.speculative_turns is None: + return False + if not isinstance( + event, + (PartialTranscriptionEvent, TranscriptionCompletedEvent, AssistantTextEvent, TokenUsageEvent), + ): + return False + turn_id = getattr(event, "turn_id", None) + turn_revision = getattr(event, "turn_revision", None) + if isinstance(event, (AssistantTextEvent, TokenUsageEvent)): + is_latest: bool | None + if wait_for_pending_reopen: + is_latest = self.speculative_turns.is_latest_after_reopen_grace(turn_id, turn_revision) + else: + is_latest = self.speculative_turns.try_is_latest_after_reopen_grace(turn_id, turn_revision) + if is_latest is None: + return None + return not is_latest + return not self.speculative_turns.is_latest(turn_id, turn_revision) + + def _observe_turn_event(self, event: PipelineEvent) -> None: + if self.speculative_turns is None: + return + self.speculative_turns.observe( + getattr(event, "turn_id", None), + getattr(event, "turn_revision", None), + ) + + # ── STT → LM bridge ──────────────────────────── + + def _on_transcription_completed(self, conn_id: str, event: TranscriptionCompletedEvent) -> list[ServerEvent]: + """Handle a final STT transcription: emit protocol event, append to chat, trigger LM.""" + st = self._state(conn_id) + same_speculative_turn = event.turn_id is not None and event.turn_id == st.speculative_user_turn_id + if same_speculative_turn: + st.response_usage.audio_duration_s -= st.speculative_audio_duration_s + else: + st.speculative_audio_duration_s = 0.0 + + events = self.conversation.on_transcription_completed(conn_id, event) + if event.turn_id is not None: + st.speculative_audio_duration_s = st.input_audio_duration_s + + cfg = st.runtime_config + transcript = event.transcript + if transcript: + if same_speculative_turn and st.speculative_user_item_id: + replaced = cfg.chat.replace_user_message_text(st.speculative_user_item_id, transcript) + if not replaced: + item = cfg.chat.add_item(make_user_message(transcript)) + st.speculative_user_item_id = item.id + else: + item = cfg.chat.add_item(make_user_message(transcript)) + st.speculative_user_item_id = item.id + elif same_speculative_turn and st.speculative_user_item_id: + cfg.chat.remove_user_message(st.speculative_user_item_id) + st.speculative_user_item_id = None + elif event.turn_id is not None and event.turn_id != st.speculative_user_turn_id: + st.speculative_user_item_id = None + + if event.turn_id is not None: + st.speculative_user_turn_id = event.turn_id + st.speculative_user_turn_revision = event.turn_revision + st.speculative_user_speech_stopped_at_s = event.speech_stopped_at_s + + queue = self.text_prompt_queue + if queue and transcript: + st.response_pending = True + st.response_audio_wait_started_at_s = perf_counter() + st.delay_filler_sent = False + queue.put( + GenerateResponseRequest( + runtime_config=cfg, + language_code=event.language_code, + turn_id=event.turn_id, + turn_revision=event.turn_revision, + speech_stopped_at_s=event.speech_stopped_at_s, + ) + ) + + return events + + # ── Metrics ──────────────────────────────────── + + def _on_token_usage(self, conn_id: str, event: TokenUsageEvent) -> list[ServerEvent]: + """Accumulate input/output token counts on the connection's usage metrics.""" + if self.speculative_turns and not self.speculative_turns.is_latest( + event.turn_id, + event.turn_revision, + ): + logger.debug("Dropping stale token usage for turn=%s rev=%s", event.turn_id, event.turn_revision) + return [] + st = self._state(conn_id) + st.response_usage.input_tokens += event.input_tokens + st.response_usage.output_tokens += event.output_tokens + logger.info( + "Token usage (response): input=%d, output=%d", + st.response_usage.input_tokens, + st.response_usage.output_tokens, + ) + return [] + + def _on_response_failed(self, conn_id: str, event: ResponseFailedEvent) -> list[ServerEvent]: + """Surface the failure to the client and close the response as ``failed``. + + Emitted when generation failed (e.g. invalid out-of-band input, or the + provider rejecting an empty context). A top-level ``error`` event carries + the human-readable reason — ``response.done.status_details.error`` only + has code/type, no message — then ``finish_response`` closes the slot. + + Idempotent: gated on an active response, and ``finish_response`` is itself + a no-op once the slot is closed, so a later EndOfResponse-driven close does + nothing. + """ + logger.info("Response failed: %s", event.message) + if not self._state(conn_id).in_response: + return [] + events: list[ServerEvent] = [self.make_error(event.message, "response_failed")] + events.extend(self.response.finish_response(conn_id, status="failed")) + return events + + def get_usage(self) -> dict[str, Any]: + """Return cumulative usage metrics across all completed responses.""" + data = self.total_usage.model_dump() + data["total_tokens"] = data["input_tokens"] + data["output_tokens"] + data["total_errors"] = self.total_usage.total_errors + return data + + # ── Error ─────────────────────────────────── + + def make_error(self, message: str, _type: str) -> RealtimeErrorEvent: + self.total_usage.record_error(_type) + return build_error_event(message, _type) + + +def build_error_event(message: str, error_type: str) -> RealtimeErrorEvent: + """Construct a RealtimeErrorEvent without touching any service-instance state. + + Used by the websocket route handler on pool rejection, where no unit's + service should be charged with the error in its usage metrics. + """ + return RealtimeErrorEvent( + type="error", + error=RealtimeError(message=message, type=error_type), + event_id=_generate_id("event"), + ) diff --git a/speech_to_speech/api/openai_realtime/utils.py b/speech_to_speech/api/openai_realtime/utils.py new file mode 100644 index 0000000..369b4e1 --- /dev/null +++ b/speech_to_speech/api/openai_realtime/utils.py @@ -0,0 +1,12 @@ +import numpy as np +from scipy.signal import resample_poly + + +def resample(audio_int16: bytes, from_rate: int, to_rate: int) -> bytes: + """Resample int16 PCM audio between sample rates using polyphase filtering.""" + if from_rate == to_rate: + return audio_int16 + samples = np.frombuffer(audio_int16, dtype=np.int16).astype(np.float32) / 32768.0 + gcd = np.gcd(to_rate, from_rate) + resampled = resample_poly(samples, up=to_rate // gcd, down=from_rate // gcd) + return np.clip(resampled * 32768, -32768, 32767).astype(np.int16).tobytes() diff --git a/speech_to_speech/api/openai_realtime/websocket_router.py b/speech_to_speech/api/openai_realtime/websocket_router.py new file mode 100644 index 0000000..d032640 --- /dev/null +++ b/speech_to_speech/api/openai_realtime/websocket_router.py @@ -0,0 +1,915 @@ +import asyncio +import io +import logging +import random +import shutil +import subprocess +import time +import uuid +import wave +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from queue import Empty, Queue +from threading import Event as ThreadingEvent +from typing import Any, Callable, Literal, TypeVar + +import numpy as np +from fastapi import FastAPI, HTTPException, Response, WebSocket, WebSocketDisconnect +from openai.types.realtime import ( + ConversationItemCreateEvent, + InputAudioBufferAppendEvent, + InputAudioBufferCommitEvent, + ResponseCancelEvent, + ResponseCreateEvent, + SessionUpdateEvent, +) +from pydantic import BaseModel, ConfigDict, Field +from starlette.websockets import WebSocketState + +from speech_to_speech.api.openai_realtime.pipeline_unit import PipelineUnit, SessionState +from speech_to_speech.api.openai_realtime.service import ServerEvent, build_error_event +from speech_to_speech.pipeline.control import SESSION_END, PipelineControlMessage, is_control_message +from speech_to_speech.pipeline.events import ( + AssistantTextEvent, + PartialTranscriptionEvent, + PipelineEvent, + SpeechStartedEvent, + SpeechStoppedEvent, + TokenUsageEvent, + TranscriptionCompletedEvent, +) +from speech_to_speech.pipeline.log_context import pipeline_log_ctx +from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, PIPELINE_END, AudioOutput, TTSInput +from speech_to_speech.utils.utils import response_wants_audio + +logger = logging.getLogger(__name__) +MAX_AUDIO_BATCH_BYTES = 6400 +HTTP_TTS_SAMPLE_RATE = 16000 +# How long the release path waits for SESSION_END to propagate through the +# handler chain back to output_queue before warning that the unit is stuck. +# Tests monkeypatch this to a small value since their fixtures usually skip +# the real handler chain. +SESSION_END_DRAIN_TIMEOUT_S = 10.0 +# Past this, the unit is quarantined: its service session is unregistered +# (closing the chat so late handler output can't mutate or bill it), but the +# unit stays unclaimable. Releasing it instead would let a new client claim a +# unit whose handlers may still emit the previous session's output (e.g. a +# transcript, which carries no session identity and would be appended to the +# new session's conversation) — a cross-session leak. If SESSION_END does +# eventually drain, the chain has proven itself clean and the unit returns to +# the pool; a dead handler keeps it quarantined forever, visible in /v1/pool +# as "stuck". +SESSION_END_QUARANTINE_TIMEOUT_S = 180.0 +QItem = TypeVar("QItem") + + +class SpeechSynthesisRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + input: str = Field(min_length=1, description="Text to synthesize") + model: str | None = Field(default=None, description="Reserved for OpenAI-compatible clients") + voice: str | None = Field(default=None, description="Reserved for future voice override support") + response_format: Literal["wav", "mp3"] | None = Field( + default=None, + description="Desired output audio format", + ) + format: Literal["wav", "mp3"] | None = Field( + default=None, + alias="format", + description="Alias for response_format", + ) + language: str | None = Field(default=None, description="Optional target language hint") + + def resolved_format(self) -> Literal["wav", "mp3"]: + return self.response_format or self.format or "wav" + + +async def _send_event(ws: WebSocket, event: ServerEvent) -> None: + # Skip cleanly when the ws is already closing/closed — happens during Ctrl-C + # shutdown, where the lifespan starts closing sockets while the route handler + # or send loop is still in flight pushing events. + if ws.application_state != WebSocketState.CONNECTED: + return + try: + await ws.send_json(event.model_dump()) + except WebSocketDisconnect: + logger.debug("Skipped event: ws disconnected mid-send") + except RuntimeError as e: + # Race: ws closed between the state check above and the send. Starlette + # raises a plain RuntimeError("Unexpected ASGI message 'websocket.send' + # after sending 'websocket.close' ...") — harmless during shutdown. + msg = str(e) + if "websocket.close" in msg or "websocket.disconnect" in msg or "response already completed" in msg: + logger.debug(f"Skipped event: ws already closed ({msg})") + else: + logger.error(f"Failed to send event to client: {e}") + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to send event to client: {e}") + + +async def _send_events(ws: WebSocket, events: list[ServerEvent]) -> None: + for event in events: + await _send_event(ws, event) + + +def _keep_audio_sentinel(item: Any) -> bool: + # SESSION_END must survive barge-in flushes of output_queue: dropping it + # would leave the release path waiting forever for the drain signal. + return _is_audio_done(item) or is_control_message(item, SESSION_END.kind) + + +def _keep_user_text_event(item: Any) -> bool: + return isinstance( + item, + (SpeechStoppedEvent, PartialTranscriptionEvent, TranscriptionCompletedEvent, TokenUsageEvent), + ) + + +def _audio_payload(item: Any) -> Any: + return item.audio if isinstance(item, AudioOutput) else item + + +def _audio_generation(item: Any) -> int | None: + return item.cancel_generation if isinstance(item, AudioOutput) else None + + +def _flush_queue(q: Queue[QItem], *, preserve: Callable[[QItem], bool] | None = None) -> None: + """Drain a queue, optionally preserving items matching *preserve*. + + Preserved items are re-inserted at the **front** of the queue + (atomically under the queue's mutex) so they are processed before + anything a pipeline thread may have enqueued during the drain. + """ + preserved: list[QItem] = [] + while True: + try: + item = q.get_nowait() + if preserve and preserve(item): + preserved.append(item) + except Empty: + break + if preserved: + with q.mutex: + for item in reversed(preserved): + q.queue.appendleft(item) + q.not_empty.notify(len(preserved)) + + +async def _drain_pending_response_events( + ws: WebSocket | None, + unit: PipelineUnit, + session_id: str | None, +) -> None: + if session_id is None: + return + + preserved: list[Any] = [] + drained_assistant = 0 + drained_usage = 0 + drain_assistant_events = True + try: + while True: + try: + item = unit.text_output_queue.get_nowait() + except Empty: + break + # Usage is accounting-only, so keep the old whole-queue drain behavior. + # Assistant events are client-visible response output and stop at the + # first non-response boundary to preserve normal text-event ordering. + if isinstance(item, TokenUsageEvent): + unit.service.dispatch_pipeline_event(session_id, item) + drained_usage += 1 + elif drain_assistant_events and isinstance(item, AssistantTextEvent): + drained_assistant += 1 + if _generation_is_discardable(unit, item.cancel_generation): + continue + events = unit.service.dispatch_pipeline_event(session_id, item) + if ws is not None and events: + await _send_events(ws, events) + else: + preserved.append(item) + drain_assistant_events = False + finally: + if preserved: + with unit.text_output_queue.mutex: + for item in reversed(preserved): + unit.text_output_queue.queue.appendleft(item) + unit.text_output_queue.not_empty.notify(len(preserved)) + + if drained_assistant or drained_usage: + logger.debug( + "Pipeline %d: drained %d assistant event(s) and %d token usage event(s) before response completion", + unit.index, + drained_assistant, + drained_usage, + ) + + +def _clean_unit(unit: PipelineUnit, preserve: Callable[[Any], bool] | None = None) -> None: + """Cancel in-flight work and flush queues for a single pipeline unit. + + All four pipeline queues are drained — input audio, transcript-to-LM, + LM-to-TTS output, and the text-event side channel — so pending work from + a released session cannot be picked up by handlers and leak into the next + session that claims this unit. SESSION_END is enqueued by the route + handler *after* this returns to serve as the soft reset signal for + stateful handlers. + """ + unit.cancel_scope.cancel() + _flush_queue(unit.input_queue) + _flush_queue(unit.text_prompt_queue) + _flush_queue(unit.output_queue, preserve=preserve) + _flush_queue(unit.text_output_queue, preserve=preserve) + unit.response_playing.clear() + unit.cancel_scope.reset() + unit.should_listen.set() + + +def _to_audio_bytes(chunk: Any) -> bytes: + chunk = _audio_payload(chunk) + if isinstance(chunk, PipelineControlMessage): + raise TypeError(f"unexpected control message on audio output queue: {chunk!r}") + if isinstance(chunk, np.ndarray) or hasattr(chunk, "tobytes"): + return chunk.tobytes() + return chunk + + +def _is_audio_done(item: Any) -> bool: + payload = _audio_payload(item) + return isinstance(payload, bytes) and payload == AUDIO_RESPONSE_DONE + + +def _is_pipeline_end(item: Any) -> bool: + payload = _audio_payload(item) + return isinstance(payload, bytes) and payload == PIPELINE_END + + +def _generation_is_discardable(unit: PipelineUnit, generation: int | None) -> bool: + """Whether output tagged with *generation* should be dropped. + + A generation is discardable if it has been superseded (``is_stale``) or if the + cancel scope is in its post-cancel discard window and this is not the current + live generation. Shared by audio and assistant-text so the two paths stay in + lockstep: dropping text whenever ``discarding`` is set (without this generation + check) silently swallows the transcript of a fresh response when ``discarding`` + lingers — e.g. a superseded speculative turn whose TTS never emitted an + AUDIO_RESPONSE_DONE sentinel, so response_done() never cleared the flag. + """ + if generation is not None and unit.cancel_scope.is_stale(generation): + return True + if unit.cancel_scope.discarding and generation != unit.cancel_scope.generation: + return True + return False + + +def _should_discard_audio(unit: PipelineUnit, item: Any) -> bool: + return _generation_is_discardable(unit, _audio_generation(item)) + + +def _mark_audio_started(unit: PipelineUnit, session_id: str | None) -> None: + if session_id is None: + return + st = unit.service._state(session_id) + st.response_pending = False + st.response_audio_wait_started_at_s = None + + +def _get_qwen_tts_handler(unit: PipelineUnit) -> Any | None: + for handler in unit.handlers: + if handler.__class__.__name__ == "Qwen3TTSHandler": + return handler + return None + + +def _encode_wav_bytes(pcm_bytes: bytes, sample_rate: int = HTTP_TTS_SAMPLE_RATE) -> bytes: + buffer = io.BytesIO() + with wave.open(buffer, "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(sample_rate) + wav_file.writeframes(pcm_bytes) + return buffer.getvalue() + + +def _encode_mp3_bytes(pcm_bytes: bytes, sample_rate: int = HTTP_TTS_SAMPLE_RATE) -> bytes: + ffmpeg_path = shutil.which("ffmpeg") + if ffmpeg_path is None: + raise RuntimeError("ffmpeg is required to return MP3 audio") + + result = subprocess.run( + [ + ffmpeg_path, + "-hide_banner", + "-loglevel", + "error", + "-f", + "s16le", + "-ar", + str(sample_rate), + "-ac", + "1", + "-i", + "pipe:0", + "-f", + "mp3", + "-codec:a", + "libmp3lame", + "pipe:1", + ], + input=pcm_bytes, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + stderr = result.stderr.decode("utf-8", errors="ignore").strip() + raise RuntimeError(f"ffmpeg mp3 encoding failed: {stderr or 'unknown error'}") + return result.stdout + + +def _collect_http_tts_pcm(unit: PipelineUnit, request: SpeechSynthesisRequest) -> bytes: + handler = _get_qwen_tts_handler(unit) + if handler is None: + raise RuntimeError("Qwen3-TTS handler not configured on the reserved pipeline unit") + + _clean_unit(unit) + tts_input = TTSInput( + text=request.input.strip(), + language_code=request.language, + turn_id=f"http-tts-{uuid.uuid4().hex}", + turn_revision=0, + ) + + chunks: list[np.ndarray] = [] + for chunk in handler.process(tts_input): + payload = chunk.audio if isinstance(chunk, AudioOutput) else chunk + if isinstance(payload, bytes): + if payload in (AUDIO_RESPONSE_DONE, PIPELINE_END): + continue + chunks.append(np.frombuffer(payload, dtype=np.int16).copy()) + continue + + array = np.asarray(payload, dtype=np.int16).reshape(-1) + if array.size: + chunks.append(array.copy()) + + _clean_unit(unit) + if not chunks: + raise RuntimeError("Qwen3-TTS produced no audio") + return np.concatenate(chunks).astype(np.int16, copy=False).tobytes() + + +async def _maybe_send_delay_filler(ws: WebSocket | None, unit: PipelineUnit, session_id: str | None) -> bool: + if ( + ws is None + or session_id is None + or unit.delay_filler_threshold_s <= 0 + or not (unit.delay_filler_short_clips or unit.delay_filler_full_clips) + ): + return False + st = unit.service._state(session_id) + started_at_s = st.response_audio_wait_started_at_s + if started_at_s is None or st.delay_filler_sent or unit.response_playing.is_set(): + return False + if not (st.response_pending or st.in_response): + return False + if not response_wants_audio(st.current_response_params): + return False + if time.perf_counter() - started_at_s < unit.delay_filler_threshold_s: + return False + + filler_groups: list[tuple[str, list[bytes], float]] = [] + if unit.delay_filler_short_clips: + filler_groups.append(("short", unit.delay_filler_short_clips, 0.65)) + if unit.delay_filler_full_clips: + filler_groups.append(("full", unit.delay_filler_full_clips, 0.35)) + + if len(filler_groups) == 1: + filler_kind, filler_clips, _ = filler_groups[0] + else: + filler_kind, filler_clips, _ = random.choices( + filler_groups, + weights=[group[2] for group in filler_groups], + k=1, + )[0] + + if len(filler_clips) == 1: + filler_index = 0 + else: + candidate_indexes = list(range(len(filler_clips))) + if st.delay_filler_last_kind == filler_kind and st.delay_filler_last_index in candidate_indexes: + candidate_indexes.remove(st.delay_filler_last_index) + filler_index = random.choice(candidate_indexes) + + clip = filler_clips[filler_index] + st.delay_filler_cursor += 1 + st.delay_filler_last_kind = filler_kind + st.delay_filler_last_index = filler_index + st.delay_filler_sent = True + _mark_audio_started(unit, session_id) + + for offset in range(0, len(clip), MAX_AUDIO_BATCH_BYTES): + batch = clip[offset : offset + MAX_AUDIO_BATCH_BYTES] + if batch: + await _send_events(ws, unit.service.encode_audio_chunk(session_id, batch)) + + unit.response_playing.set() + unit.should_listen.set() + logger.info("Pipeline %d: sent delay filler clip", unit.index) + return True + + +def _safe_unregister(unit: PipelineUnit, session_id: str) -> None: + try: + unit.service.unregister(session_id) + except Exception: + logger.exception(f"Pipeline {unit.index}: unregister failed for session {session_id}") + + +async def _release_unit_after_drain(unit: PipelineUnit, session: Any, session_id: str) -> None: + """Wait for SESSION_END to propagate, then release the unit. + + Runs in its own asyncio task so the route handler's finally block can return + immediately. The unit stays unavailable for new claims (unit.session != None) + until SESSION_END travels all the way through the handler chain back to + output_queue — observed by the send loop, which sets session.drained. + + Past SESSION_END_QUARANTINE_TIMEOUT_S (a wedged or dead handler thread) the + unit is quarantined, NOT released: still-running handlers could emit the old + session's output (transcripts carry no session identity) into whichever + session claimed the unit next, and a dead handler would make the unit accept + clients it can never serve. The session is unregistered right away so late + output can't mutate or bill the closed conversation; the unit itself only + returns to the pool if SESSION_END eventually drains, proving the chain is + clean. Operators can spot quarantined units in `/v1/pool` (state "stuck"). + """ + elapsed = 0.0 + warned = False + try: + while not session.drained.is_set(): + await asyncio.sleep(0.05) + elapsed += 0.05 + if not warned and elapsed >= SESSION_END_DRAIN_TIMEOUT_S: + logger.warning( + f"Pipeline {unit.index}: SESSION_END not drained after {elapsed:.1f}s — " + f"unit will remain unavailable until handlers finish (session {session_id})" + ) + warned = True + if session.quarantined_at is None and elapsed >= SESSION_END_QUARANTINE_TIMEOUT_S: + session.quarantined_at = time.monotonic() + _safe_unregister(unit, session_id) + logger.error( + f"Pipeline {unit.index}: SESSION_END still not drained after {elapsed:.0f}s — " + f"quarantining unit until the handler chain drains (session {session_id})" + ) + finally: + # Runs when the drain completed (chain proven clean) or the task is + # cancelled at shutdown. Release unconditionally: even if unregister + # raises, the unit must not stay claimed forever. + try: + _safe_unregister(unit, session_id) + finally: + unit.session = None + recovered = " after quarantine" if session.quarantined_at is not None else "" + logger.info(f"Pipeline {unit.index} released{recovered} (session {session_id} ended)") + + +def create_app(pool: list[PipelineUnit], stop_event: ThreadingEvent) -> FastAPI: + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + # One send loop per pipeline unit; each polls its own queues and forwards + # to the websocket currently attached via unit.session. + send_tasks = [asyncio.create_task(_send_loop_for(unit)) for unit in pool] + yield + for task in send_tasks: + task.cancel() + for task in send_tasks: + try: + await task + except asyncio.CancelledError: + pass + for unit in pool: + sess = unit.session + if sess is not None: + try: + await sess.websocket.close() + except Exception: + pass + + app = FastAPI(lifespan=lifespan) + + # Strong references to in-flight drain-and-release tasks (asyncio only + # holds tasks weakly); each task removes itself on completion. + release_tasks: set[asyncio.Task[None]] = set() + + def _claim_unit(ws: WebSocket) -> PipelineUnit | None: + """Atomically (between asyncio yield points) reserve the first idle unit. + + Creates a placeholder SessionState that the caller fills in with the + session_id after RealtimeService.register(). + """ + for unit in pool: + if unit.session is None and not unit.http_tts_reserved: + unit.session = SessionState(websocket=ws) + return unit + return None + + def _reserve_http_tts_unit() -> PipelineUnit | None: + for unit in pool: + if unit.session is None and not unit.http_tts_reserved and _get_qwen_tts_handler(unit) is not None: + unit.http_tts_reserved = True + return unit + return None + + def _has_qwen_tts_capacity() -> bool: + return any(_get_qwen_tts_handler(unit) is not None for unit in pool) + + @app.post("/v1/audio/speech") + async def speech_endpoint(request: SpeechSynthesisRequest) -> Response: + if not _has_qwen_tts_capacity(): + raise HTTPException( + status_code=501, + detail="Qwen TTS is not configured on this server instance.", + ) + + unit = _reserve_http_tts_unit() + if unit is None: + raise HTTPException( + status_code=503, + detail="All Qwen TTS pipeline slots are currently busy.", + ) + + try: + pcm_bytes = await asyncio.to_thread(_collect_http_tts_pcm, unit, request) + fmt = request.resolved_format() + if fmt == "mp3": + payload = await asyncio.to_thread(_encode_mp3_bytes, pcm_bytes, HTTP_TTS_SAMPLE_RATE) + media_type = "audio/mpeg" + extension = "mp3" + else: + payload = _encode_wav_bytes(pcm_bytes, HTTP_TTS_SAMPLE_RATE) + media_type = "audio/wav" + extension = "wav" + except HTTPException: + raise + except Exception as e: + logger.error("HTTP speech synthesis failed on pipeline %d: %s", unit.index, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"TTS synthesis failed: {e}") from e + finally: + unit.http_tts_reserved = False + + return Response( + content=payload, + media_type=media_type, + headers={ + "Content-Disposition": f'inline; filename="speech.{extension}"', + "X-Audio-Sample-Rate": str(HTTP_TTS_SAMPLE_RATE), + }, + ) + + @app.websocket("/v1/realtime") + async def realtime_endpoint(ws: WebSocket) -> None: + await ws.accept() + + unit = _claim_unit(ws) + if unit is None: + logger.warning(f"Rejected connection: all {len(pool)} pipeline slots in use") + # Stateless error event — rejection is not chargeable to any unit's usage metrics. + await _send_event( + ws, + build_error_event( + f"All {len(pool)} session slots are in use. Disconnect an existing client first.", + error_type="session_limit_reached", + ), + ) + await ws.close(code=1008, reason="All session slots are in use") + return + + pipeline_log_ctx.set(unit.index) + # _claim_unit guarantees unit.session is not None for the returned unit. + assert unit.session is not None + # Everything after the claim runs inside try so the finally below always + # releases the unit, even if session setup fails. + session_id = "" + try: + session_id = unit.service.register() + unit.session.session_id = session_id + logger.info(f"Client connected to pipeline {unit.index} (session {session_id})") + + # Defensive: drain edge queues and reset events so stale data from a + # previous session that survived SESSION_END propagation doesn't leak. + _clean_unit(unit) + + await _send_event(ws, unit.service.build_session_created(session_id)) + + while not stop_event.is_set(): + try: + raw = await asyncio.wait_for(ws.receive_json(), timeout=0.1) + except asyncio.TimeoutError: + continue + + event = unit.service.parse_client_event(raw) + if event is None: + await _send_event( + ws, + unit.service.make_error( + f"Unknown or invalid event: {raw.get('type')}", "unknown_or_invalid_event" + ), + ) + continue + + if isinstance(event, InputAudioBufferAppendEvent): + chunks = unit.service.handle_audio_append(session_id, event) + rt_cfg = unit.service._state(session_id).runtime_config + for chunk in chunks: + unit.input_queue.put((chunk, rt_cfg)) + + elif isinstance(event, InputAudioBufferCommitEvent): + err = unit.service.handle_audio_commit(session_id) + if err: + await _send_event(ws, err) + + elif isinstance(event, SessionUpdateEvent): + err = unit.service.handle_session_update(session_id, event) + if err: + await _send_event(ws, err) + + elif isinstance(event, ConversationItemCreateEvent): + events = unit.service.handle_conversation_item_create(session_id, event) + if events: + await _send_events(ws, events) + + elif isinstance(event, ResponseCreateEvent): + result = unit.service.handle_response_create(session_id, event) + if result: + if result.type != "error": + unit.cancel_scope.new_response() + await _send_event(ws, result) + + elif isinstance(event, ResponseCancelEvent): + was_active = unit.service._state(session_id).in_response + if was_active: + unit.cancel_scope.cancel() + _flush_queue(unit.output_queue, preserve=_keep_audio_sentinel) + _flush_queue(unit.text_output_queue, preserve=_keep_user_text_event) + events = unit.service.handle_response_cancel(session_id) + if events: + await _send_events(ws, events) + unit.response_playing.clear() + + except WebSocketDisconnect: + logger.info(f"Client {session_id} disconnected from pipeline {unit.index}") + except Exception as e: + logger.error(f"Client {session_id} on pipeline {unit.index} error: {type(e).__name__}: {e}", exc_info=True) + finally: + # Hold the session reference: the send loop's snapshot will still resolve + # to this object until we clear unit.session, so any handler output that + # arrives during the drain window is sent to the now-closed ws (silently + # dropped) instead of leaking to whichever client claims this unit next. + old_session = unit.session + if old_session is not None: + old_session.released_at = time.monotonic() + _clean_unit(unit) + # Tag SESSION_END with this session's id so that, after a force + # release, a late arrival can't satisfy the next session's drain. + unit.input_queue.put(PipelineControlMessage(SESSION_END.kind, session_id=session_id)) + # Spawn the drain-and-release as a separate task so the route handler's + # finally returns immediately. Awaiting here is unreliable: after + # WebSocketDisconnect propagates, subsequent awaits in the same task + # can be skipped/cancelled by Starlette's runner and never resume. + # asyncio holds tasks weakly — keep a reference until it completes. + task = asyncio.create_task(_release_unit_after_drain(unit, old_session, session_id)) + release_tasks.add(task) + task.add_done_callback(release_tasks.discard) + + @app.get("/v1/usage") + async def usage_endpoint() -> dict[str, Any]: + # Aggregate usage across the pool. Numeric fields sum; dict fields (e.g. + # errors_by_type) merge with numeric leaves summed too, so per-unit error + # counts don't get dropped by the first-unit's value. + def _merge(into: dict[str, Any], src: dict[str, Any]) -> None: + for k, v in src.items(): + if isinstance(v, (int, float)): + into[k] = into.get(k, 0) + v + elif isinstance(v, dict): + sub = into.setdefault(k, {}) + if isinstance(sub, dict): + _merge(sub, v) + else: + into.setdefault(k, v) + + total: dict[str, Any] = {} + for unit in pool: + _merge(total, unit.service.get_usage()) + return total + + @app.get("/v1/pool") + async def pool_endpoint() -> dict[str, Any]: + now = time.monotonic() + + def _state(u: PipelineUnit) -> dict[str, Any]: + s = u.session + if s is None and u.http_tts_reserved: + return {"index": u.index, "state": "http_tts", "session_id": None} + if s is None: + return {"index": u.index, "state": "idle", "session_id": None} + if s.released_at is None: + return {"index": u.index, "state": "active", "session_id": s.session_id} + # Drain wait gave up (quarantine timeout): the unit stays occupied + # until SESSION_END actually drains — possibly forever if a handler + # thread died. Surfaced distinctly so operators can act on it. + if s.quarantined_at is not None: + return { + "index": u.index, + "state": "stuck", + "session_id": s.session_id, + "draining_for_s": round(now - s.released_at, 2), + "stuck_for_s": round(now - s.quarantined_at, 2), + } + # released by client but SESSION_END hasn't drained yet → unit + # is still occupied; surface elapsed time so operators can spot + # stuck handlers. + return { + "index": u.index, + "state": "draining", + "session_id": s.session_id, + "draining_for_s": round(now - s.released_at, 2), + } + + return { + "size": len(pool), + "in_use": sum(1 for u in pool if u.session is not None), + "units": [_state(u) for u in pool], + } + + async def _send_loop_for(unit: PipelineUnit) -> None: + """Per-pipeline send loop. Polls this unit's output queues and forwards + to the websocket currently attached via unit.session. + + Per-session scratch (pending_output_item) lives on SessionState, so it + disappears together with the websocket when the session is released — + no stale sentinel can leak into the next claim. + """ + pipeline_log_ctx.set(unit.index) + while not stop_event.is_set(): + try: + # Snapshot the session once per iteration; if the route releases the + # unit mid-iteration, we continue against the prior snapshot which is + # consistent (its websocket is still valid until ws.close() returns). + session = unit.session + ws = session.websocket if session is not None else None + session_id = session.session_id if session is not None else None + + # Text events first (speech_started cancels active response). + try: + text_msg = unit.text_output_queue.get_nowait() + is_speech_start = isinstance(text_msg, SpeechStartedEvent) + + was_in_response = False + was_response_pending = False + if is_speech_start and session_id: + st = unit.service._state(session_id) + was_in_response = st.in_response + was_response_pending = st.response_pending + + if isinstance(text_msg, AssistantTextEvent) and _generation_is_discardable( + unit, text_msg.cancel_generation + ): + pass + elif ws is not None and isinstance(text_msg, PipelineEvent) and session_id: + events = unit.service.dispatch_pipeline_event(session_id, text_msg) + if events: + await _send_events(ws, events) + + if is_speech_start and (was_in_response or was_response_pending): + active_cfg = unit.service._state(session_id).runtime_config if session_id else None + if text_msg.interrupt_response and ( + active_cfg is None or active_cfg.interrupt_response_enabled + ): + unit.cancel_scope.cancel() + if session_id: + unit.service._state(session_id).response_pending = False + _flush_queue(unit.output_queue, preserve=_keep_audio_sentinel) + _flush_queue(unit.text_output_queue, preserve=_keep_user_text_event) + if unit.response_playing.is_set(): + unit.response_playing.clear() + logger.info( + "Pipeline %d: speech during %s: cancelled, queue flushed", + unit.index, + "response" if was_in_response else "pending response", + ) + else: + logger.info( + f"Pipeline {unit.index}: speech during response: interrupt_response disabled, ignoring" + ) + except Empty: + pass + + if ( + session is not None + and session.pending_output_item is None + and unit.output_queue.empty() + and await _maybe_send_delay_filler(ws, unit, session_id) + ): + await asyncio.sleep(0.01) + continue + + try: + if session is not None and session.pending_output_item is not None: + audio_chunk = session.pending_output_item + session.pending_output_item = None + else: + audio_chunk = unit.output_queue.get_nowait() + + if _is_pipeline_end(audio_chunk): + await _drain_pending_response_events(ws, unit, session_id) + if ws is not None and session_id: + await _send_events(ws, unit.service.finish_response(session_id)) + break + + if _is_audio_done(audio_chunk): + audio_generation = _audio_generation(audio_chunk) + if audio_generation is not None and unit.cancel_scope.is_stale(audio_generation): + if session_id: + unit.service._state(session_id).response_pending = False + unit.cancel_scope.response_done(audio_generation) + unit.should_listen.set() + logger.info(f"Pipeline {unit.index}: stale response complete, listening re-enabled") + continue + await _drain_pending_response_events(ws, unit, session_id) + if ws is not None and session_id: + await _send_events(ws, unit.service.finish_response(session_id)) + if session_id: + unit.service._state(session_id).response_pending = False + unit.response_playing.clear() + unit.cancel_scope.response_done(audio_generation) + unit.should_listen.set() + logger.info(f"Pipeline {unit.index}: response complete, listening re-enabled") + continue + + # SESSION_END travels from input_queue through every handler to + # output_queue. Observing it here means the chain has fully reset; + # signal the release path so it can clear unit.session. A tag from + # another session means the emitting session was force-released — + # its late SESSION_END must not stand in for this session's drain. + if is_control_message(audio_chunk, SESSION_END.kind): + chunk_session_id = getattr(audio_chunk, "session_id", None) + if session is not None and chunk_session_id in (None, session.session_id): + session.drained.set() + logger.debug(f"Pipeline {unit.index}: SESSION_END drained") + continue + + if is_control_message(audio_chunk): + continue + + if _should_discard_audio(unit, audio_chunk): + continue + + audio_chunk = _to_audio_bytes(audio_chunk) + + audio_batch = bytearray(audio_chunk) + while len(audio_batch) < MAX_AUDIO_BATCH_BYTES: + try: + next_chunk = unit.output_queue.get_nowait() + except Empty: + break + + if ( + _is_pipeline_end(next_chunk) + or _is_audio_done(next_chunk) + or is_control_message(next_chunk, SESSION_END.kind) + ): + # Only stash if we still have a session; otherwise drop it. + if session is not None: + session.pending_output_item = next_chunk + break + + if _should_discard_audio(unit, next_chunk): + continue + + next_audio = _to_audio_bytes(next_chunk) + if len(audio_batch) + len(next_audio) > MAX_AUDIO_BATCH_BYTES: + if session is not None: + session.pending_output_item = next_chunk + break + audio_batch.extend(next_audio) + + if not unit.response_playing.is_set(): + unit.response_playing.set() + unit.should_listen.set() + _mark_audio_started(unit, session_id) + + if ws is not None and session_id: + await _send_events(ws, unit.service.encode_audio_chunk(session_id, bytes(audio_batch))) + except Empty: + pass + + await asyncio.sleep(0.01) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Pipeline {unit.index} send loop error: {e}") + await asyncio.sleep(0.1) + + return app diff --git a/speech_to_speech/arguments_classes/__init__.py b/speech_to_speech/arguments_classes/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/speech_to_speech/arguments_classes/__init__.py @@ -0,0 +1 @@ + diff --git a/speech_to_speech/arguments_classes/chat_completions_language_model_arguments.py b/speech_to_speech/arguments_classes/chat_completions_language_model_arguments.py new file mode 100644 index 0000000..ad57942 --- /dev/null +++ b/speech_to_speech/arguments_classes/chat_completions_language_model_arguments.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field +from typing import Optional + +from speech_to_speech.arguments_classes.responses_api_language_model_arguments import ( + ResponsesApiLanguageModelHandlerArguments, +) + + +@dataclass +class ChatCompletionsLanguageModelHandlerArguments(ResponsesApiLanguageModelHandlerArguments): + """Arguments for the ``chat-completions`` LLM backend. + + Inherits the OpenAI-compatible connection fields from the Responses-API + arguments (``responses_api_base_url`` / ``responses_api_api_key`` / + ``responses_api_stream`` / ``responses_api_disable_thinking``) so the same + CLI flags and launcher env vars drive both backends, and adds the + Chat-Completions-only ``reasoning_effort`` knob. + """ + + responses_api_reasoning_effort: Optional[str] = field( + default=None, + metadata={ + "help": "Provider-specific reasoning level sent as extra_body={'reasoning_effort': } on the " + "Chat Completions request. Use to disable reasoning on providers where " + "chat_template_kwargs.enable_thinking is ignored (e.g. 'none' / 'low'). When unset, falls back to " + "the disable_thinking behaviour (chat_template_kwargs.enable_thinking=false). Default is None." + }, + ) diff --git a/speech_to_speech/arguments_classes/chat_tts_arguments.py b/speech_to_speech/arguments_classes/chat_tts_arguments.py new file mode 100644 index 0000000..9390c1d --- /dev/null +++ b/speech_to_speech/arguments_classes/chat_tts_arguments.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass, field + + +@dataclass +class ChatTTSHandlerArguments: + chat_tts_stream: bool = field( + default=True, + metadata={"help": "The tts mode is stream Default is 'stream'."}, + ) + chat_tts_device: str = field( + default="cuda", + metadata={"help": "The device to be used for speech synthesis. Default is 'cuda'."}, + ) + chat_tts_chunk_size: int = field( + default=512, + metadata={ + "help": "Sets the size of the audio data chunk processed per cycle, balancing playback latency and CPU load.. Default is 512。." + }, + ) diff --git a/speech_to_speech/arguments_classes/facebookmms_tts_arguments.py b/speech_to_speech/arguments_classes/facebookmms_tts_arguments.py new file mode 100644 index 0000000..86f4977 --- /dev/null +++ b/speech_to_speech/arguments_classes/facebookmms_tts_arguments.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field + + +@dataclass +class FacebookMMSTTSHandlerArguments: + facebook_mms_model_name: str = field( + default="facebook/mms-tts-eng", + metadata={"help": "The model name to use. Default is 'facebook/mms-tts-eng'."}, + ) + tts_language: str = field( + default="en", + metadata={"help": "The language code for the TTS model. Default is 'en' for English."}, + ) + facebook_mms_device: str = field( + default="cuda", + metadata={"help": "The device to use for the TTS model. Default is 'cuda'."}, + ) + facebook_mms_torch_dtype: str = field( + default="float32", + metadata={"help": "The torch data type to use for the TTS model. Default is 'float32'."}, + ) diff --git a/speech_to_speech/arguments_classes/faster_whisper_stt_arguments.py b/speech_to_speech/arguments_classes/faster_whisper_stt_arguments.py new file mode 100644 index 0000000..5aa6286 --- /dev/null +++ b/speech_to_speech/arguments_classes/faster_whisper_stt_arguments.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass, field + + +@dataclass +class FasterWhisperSTTHandlerArguments: + faster_whisper_stt_model_name: str = field( + default="tiny.en", + metadata={ + "help": ( + "The pretrained Faster Whisper model to use.\n" + " One of ('tiny', 'tiny.en', 'base', 'base.en', 'small', 'small.en', " + "'distil-small.en', 'medium', 'medium.en', 'distil-medium.en', 'large-v1', 'large-v2', " + "'large-v3', 'large', 'distil-large-v2', 'distil-large-v3').\n" + " Default is 'small'." + ) + }, + ) + faster_whisper_stt_device: str = field( + default="auto", + metadata={ + "help": """The device type on which the model will run. + One of ('cpu', 'cuda', 'auto'). + Default is 'auto'.""" + }, + ) + faster_whisper_stt_compute_type: str = field( + default="auto", + metadata={ + "help": """The data type to use for computation. + One of ('default', 'auto', 'int8', 'int8_float32', 'int8_float16', 'int8_bfloat16', 'int16', 'float16', 'float32', 'bfloat16') + Default is 'auto'. + Refer to 'https://opennmt.net/CTranslate2/quantization.html#quantize-on-model-loading'""" + }, + ) + faster_whisper_stt_gen_max_new_tokens: int = field( + default=128, + metadata={"help": "The maximum number of new tokens to generate. Default is 128."}, + ) + faster_whisper_stt_gen_beam_size: int = field( + default=1, + metadata={"help": "The number of beams for beam search. Default is 1, implying greedy decoding."}, + ) + faster_whisper_stt_gen_return_timestamps: bool = field( + default=False, + metadata={"help": "Whether to return timestamps with transcriptions. Default is False."}, + ) + faster_whisper_stt_gen_task: str = field( + default="transcribe", + metadata={"help": "The task to perform, typically 'transcribe' for transcription. Default is 'transcribe'."}, + ) + faster_whisper_stt_gen_language: str = field( + default="en", + metadata={"help": "The language of the speech to transcribe. Default is 'en' for English."}, + ) diff --git a/speech_to_speech/arguments_classes/kokoro_tts_arguments.py b/speech_to_speech/arguments_classes/kokoro_tts_arguments.py new file mode 100644 index 0000000..78f95cb --- /dev/null +++ b/speech_to_speech/arguments_classes/kokoro_tts_arguments.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class KokoroTTSHandlerArguments: + kokoro_model_name: Optional[str] = field( + default=None, + metadata={ + "help": "The Kokoro TTS model to use. Auto-selects based on device: 'mlx-community/Kokoro-82M-bf16' for MPS, 'hexgrad/Kokoro-82M' for CUDA/CPU." + }, + ) + kokoro_device: str = field( + default="auto", + metadata={ + "help": "The device to run Kokoro TTS on. Options: 'auto', 'cuda', 'cpu', 'mps'. Default is 'auto' (MPS on Mac, CUDA on GPU systems)." + }, + ) + kokoro_voice: str = field( + default="bm_fable", + metadata={ + "help": "The voice to use for synthesis. See VOICES.md in the Kokoro repo for options. Default is 'bm_fable' (British male)." + }, + ) + kokoro_lang_code: str = field( + default="b", + metadata={ + "help": "Language code: 'a' for American English, 'b' for British English, 'j' for Japanese, etc. Default is 'b'." + }, + ) + kokoro_speed: float = field( + default=1.0, + metadata={"help": "Speech speed multiplier. Values > 1.0 speed up, < 1.0 slow down. Default is 1.0."}, + ) + kokoro_blocksize: int = field( + default=512, + metadata={"help": "The audio chunk size in samples for streaming output. Default is 512."}, + ) diff --git a/speech_to_speech/arguments_classes/language_model_arguments.py b/speech_to_speech/arguments_classes/language_model_arguments.py new file mode 100644 index 0000000..d076b4f --- /dev/null +++ b/speech_to_speech/arguments_classes/language_model_arguments.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass, field + +from speech_to_speech.arguments_classes.language_model_base_arguments import LanguageModelBaseArguments + + +@dataclass +class LanguageModelHandlerArguments(LanguageModelBaseArguments): + llm_device: str = field( + default="cuda", + metadata={"help": "The device type on which the model will run. Default is 'cuda' for GPU acceleration."}, + ) + llm_torch_dtype: str = field( + default="float16", + metadata={ + "help": "The PyTorch data type for the model and input tensors. One of `float32` (full-precision), `float16` or `bfloat16` (both half-precision)." + }, + ) + llm_gen_max_new_tokens: int = field( + default=1024, + metadata={"help": "Maximum number of new tokens to generate in a single completion. Default is 1024."}, + ) + llm_gen_min_new_tokens: int = field( + default=0, + metadata={"help": "Minimum number of new tokens to generate in a single completion. Default is 0."}, + ) + llm_gen_temperature: float = field( + default=0.0, + metadata={ + "help": "Controls the randomness of the output. Set to 0.0 for deterministic (repeatable) outputs. Default is 0.0." + }, + ) + llm_gen_do_sample: bool = field( + default=False, + metadata={"help": "Whether to use sampling; set this to False for deterministic outputs. Default is False."}, + ) + llm_is_vlm: bool = field( + default=False, + metadata={ + "help": "Set to True when using a Vision Language Model (VLM) that accepts image inputs. " + "Loads AutoProcessor + AutoModelForImageTextToText instead of the default text-only model. " + "Default is False." + }, + ) diff --git a/speech_to_speech/arguments_classes/language_model_base_arguments.py b/speech_to_speech/arguments_classes/language_model_base_arguments.py new file mode 100644 index 0000000..6b23e5e --- /dev/null +++ b/speech_to_speech/arguments_classes/language_model_base_arguments.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass, field + + +@dataclass +class LanguageModelBaseArguments: + model_name: str = field( + default="Qwen/Qwen3-4B-Instruct-2507", + metadata={"help": "The pretrained language model to use."}, + ) + user_role: str = field( + default="user", + metadata={"help": "Role assigned to the user in the chat context. Default is 'user'."}, + ) + init_chat_role: str = field( + default="system", + metadata={"help": "Initial role for setting up the chat context. Default is 'system'."}, + ) + init_chat_prompt: str = field( + default="You are a helpful and friendly AI assistant. You are polite, respectful, and aim to provide concise responses of less than 20 words.", + metadata={"help": "The initial chat prompt to establish context for the language model."}, + ) + chat_size: int = field( + default=30, + metadata={"help": "Number of interactions assistant-user to keep for the chat."}, + ) + stream_batch_sentences: int = field( + default=3, + metadata={ + "help": "Number of sentences to accumulate before yielding a batch during streaming. " + "Set to 1 for sentence-by-sentence streaming. Default is 3." + }, + ) + enable_lang_prompt: bool = field( + default=False, + metadata={ + "help": "When True, append a user message instructing the model to reply in the detected/selected " + "language (e.g. 'Please reply to my message in French.'). Default is False." + }, + ) + compact_history: bool = field( + default=True, + metadata={ + "help": "When True, summarize older turns in the background once the chat exceeds chat_size, " + "instead of synchronously evicting them. Adds an extra LLM call per compaction. Default is True." + }, + ) diff --git a/speech_to_speech/arguments_classes/mlx_audio_whisper_arguments.py b/speech_to_speech/arguments_classes/mlx_audio_whisper_arguments.py new file mode 100644 index 0000000..ad64ebc --- /dev/null +++ b/speech_to_speech/arguments_classes/mlx_audio_whisper_arguments.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass, field + + +@dataclass +class MLXAudioWhisperSTTHandlerArguments: + mlx_audio_whisper_model_name: str = field( + default="mlx-community/whisper-large-v3-turbo", + metadata={ + "help": "The pretrained MLX Audio Whisper model to use. Default is 'mlx-community/whisper-large-v3-turbo'." + }, + ) + mlx_audio_whisper_gen_kwargs: dict = field( + default_factory=dict, + metadata={"help": "Additional generation kwargs to pass to the model. Default is an empty dict."}, + ) diff --git a/speech_to_speech/arguments_classes/module_arguments.py b/speech_to_speech/arguments_classes/module_arguments.py new file mode 100644 index 0000000..e2beaeb --- /dev/null +++ b/speech_to_speech/arguments_classes/module_arguments.py @@ -0,0 +1,72 @@ +from dataclasses import dataclass, field +from typing import Literal, Optional + + +@dataclass +class ModuleArguments: + device: Optional[str] = field( + default=None, + metadata={"help": "If specified, overrides the device for all handlers."}, + ) + mode: Optional[Literal["local", "socket", "websocket", "realtime"]] = field( + default="realtime", + metadata={ + "help": "The mode to run the pipeline in. Either 'local', 'socket', 'websocket', or 'realtime'. Default is 'realtime'." + }, + ) + local_mac_optimal_settings: bool = field( + default=False, + metadata={ + "help": "If specified, sets the optimal settings for Mac OS. Sets Parakeet TDT for STT, MLX LM for language model, and Qwen3-TTS for TTS, with MPS device and local mode." + }, + ) + stt: Optional[ + Literal["whisper", "whisper-mlx", "mlx-audio-whisper", "faster-whisper", "parakeet-tdt", "paraformer"] + ] = field( + default="parakeet-tdt", + metadata={ + "help": "The STT to use. Either 'whisper', 'whisper-mlx', 'mlx-audio-whisper', 'faster-whisper', 'parakeet-tdt', or 'paraformer'. Default is 'parakeet-tdt'." + }, + ) + llm_backend: Optional[Literal["transformers", "mlx-lm", "responses-api", "chat-completions"]] = field( + default="responses-api", + metadata={ + "help": "The LLM backend to use. Either 'transformers', 'mlx-lm', 'responses-api', or " + "'chat-completions' (OpenAI-compatible /v1/chat/completions). Default is 'responses-api'." + }, + ) + tts: Optional[Literal["chatTTS", "facebookMMS", "pocket", "kokoro", "qwen3"]] = field( + default="qwen3", + metadata={ + "help": "The TTS to use. Either 'chatTTS', 'facebookMMS', 'pocket', 'kokoro', or 'qwen3'. Default is 'qwen3'." + }, + ) + log_level: str = field( + default="info", + metadata={"help": "Provide logging level. Example --log_level debug, default=info."}, + ) + enable_live_transcription: bool = field( + default=True, + metadata={ + "help": "Enable live transcription display while user is speaking (works with parakeet-tdt). Default is true." + }, + ) + live_transcription_update_interval: float = field( + default=0.5, + metadata={"help": "Update interval for live transcription in seconds (default: 0.5s = 500ms)"}, + ) + live_transcription_min_silence_ms: int = field( + default=500, + metadata={ + "help": "Minimum silence duration (ms) before ending speech when live transcription is enabled (default: 500ms)" + }, + ) + num_pipelines: int = field( + default=1, + metadata={ + "help": "Number of isolated realtime pipelines in the pool. One uvicorn server listens on " + "--ws_port and routes each incoming websocket to the next free pipeline (each has its own " + "VAD/STT/LM/TTS handlers and conversation state). Max concurrent websocket sessions equals " + "num_pipelines; further connections are rejected. Only valid for --mode realtime. Default is 1." + }, + ) diff --git a/speech_to_speech/arguments_classes/paraformer_stt_arguments.py b/speech_to_speech/arguments_classes/paraformer_stt_arguments.py new file mode 100644 index 0000000..0fdfe4f --- /dev/null +++ b/speech_to_speech/arguments_classes/paraformer_stt_arguments.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass, field + + +@dataclass +class ParaformerSTTHandlerArguments: + paraformer_stt_model_name: str = field( + default="paraformer-zh", + metadata={ + "help": "The pretrained model to use. Default is 'paraformer-zh'. Can be choose from https://github.com/modelscope/FunASR" + }, + ) + paraformer_stt_device: str = field( + default="cuda", + metadata={"help": "The device type on which the model will run. Default is 'cuda' for GPU acceleration."}, + ) diff --git a/speech_to_speech/arguments_classes/parakeet_tdt_arguments.py b/speech_to_speech/arguments_classes/parakeet_tdt_arguments.py new file mode 100644 index 0000000..13e367f --- /dev/null +++ b/speech_to_speech/arguments_classes/parakeet_tdt_arguments.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class ParakeetTDTSTTHandlerArguments: + """ + Arguments for the Parakeet TDT Speech-to-Text handler. + + Parakeet TDT 0.6B v3 is a 600M parameter multilingual ASR model from NVIDIA. + - On MPS (Apple Silicon): Uses mlx-audio with mlx-community/parakeet-tdt-0.6b-v3 + - On CUDA/CPU: Uses nano-parakeet (pure PyTorch) with nvidia/parakeet-tdt-0.6b-v3 + """ + + parakeet_tdt_model_name: Optional[str] = field( + default=None, + metadata={ + "help": "The Parakeet TDT model to use. Defaults to 'mlx-community/parakeet-tdt-0.6b-v3' " + "for MPS or 'nvidia/parakeet-tdt-0.6b-v3' for CUDA/CPU." + }, + ) + parakeet_tdt_device: str = field( + default="auto", + metadata={ + "help": "Device to run the model on. 'auto' will use MPS on macOS and CUDA otherwise. " + "Options: 'auto', 'cuda', 'mps', 'cpu'. Default is 'auto'." + }, + ) + parakeet_tdt_compute_type: str = field( + default="float16", + metadata={"help": "Compute type for the model. Options: 'float16', 'float32'. Default is 'float16'."}, + ) + parakeet_tdt_language: Optional[str] = field( + default=None, + metadata={ + "help": "Target language code for transcription. If not specified, the model will " + "auto-detect the language. Supports 25 European languages." + }, + ) diff --git a/speech_to_speech/arguments_classes/pocket_tts_arguments.py b/speech_to_speech/arguments_classes/pocket_tts_arguments.py new file mode 100644 index 0000000..fcd0bcd --- /dev/null +++ b/speech_to_speech/arguments_classes/pocket_tts_arguments.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass, field + + +@dataclass +class PocketTTSHandlerArguments: + pocket_tts_device: str = field( + default="cpu", + metadata={ + "help": "The device type on which the Pocket TTS model will run. Options: 'cpu', 'cuda', 'mps'. Default is 'cpu'." + }, + ) + pocket_tts_voice: str = field( + default="jean", + metadata={ + "help": "Voice to use for Pocket TTS. Can be a preset name ('alba', 'marius', 'javert', 'jean', 'fantine', 'cosette', 'eponine', 'azelma'), a local audio file path, or a HuggingFace path like 'hf://kyutai/tts-voices/...'. Default is 'jean'." + }, + ) + pocket_tts_sample_rate: int = field( + default=16000, + metadata={ + "help": "Output sample rate in Hz for Pocket TTS. Pocket TTS uses 24kHz internally but will be resampled to this rate to match the audio output. Default is 16000 to match the pipeline's audio streamer." + }, + ) + pocket_tts_blocksize: int = field( + default=512, + metadata={"help": "Size of audio blocks to yield for streaming in Pocket TTS. Default is 512."}, + ) + pocket_tts_max_tokens: int = field( + default=50, + metadata={"help": "Maximum number of tokens to generate per sentence in Pocket TTS. Default is 50."}, + ) diff --git a/speech_to_speech/arguments_classes/qwen3_tts_arguments.py b/speech_to_speech/arguments_classes/qwen3_tts_arguments.py new file mode 100644 index 0000000..b857d17 --- /dev/null +++ b/speech_to_speech/arguments_classes/qwen3_tts_arguments.py @@ -0,0 +1,100 @@ +from dataclasses import dataclass, field +from typing import Literal, Optional + + +@dataclass +class Qwen3TTSHandlerArguments: + qwen3_tts_model_name: str = field( + default="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", + metadata={ + "help": "The Qwen3-TTS model to use (HuggingFace Hub ID or local path). Default is 'Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice'. On Apple Silicon, Qwen/* model IDs are auto-mapped to the corresponding mlx-community/* model when possible, defaulting to the 6bit MLX variant unless the model name already pins a specific suffix." + }, + ) + qwen3_tts_device: str = field( + default="cuda", + metadata={ + "help": "Preferred device for Qwen3-TTS. Options: 'cuda', 'cpu', 'mps', 'auto'. Default is 'cuda'. On Apple Silicon the mlx-audio backend is selected automatically." + }, + ) + qwen3_tts_dtype: str = field( + default="auto", + metadata={ + "help": "Data type for inference. Options: 'auto', 'float16', 'bfloat16', 'float32'. Default is 'auto'." + }, + ) + qwen3_tts_attn_implementation: str = field( + default="eager", + metadata={ + "help": "Attention implementation. Options: 'eager', 'flash_attention_2', 'sdpa'. Use 'eager' on Jetson. Default is 'eager'." + }, + ) + qwen3_tts_backend: Literal["ggml", "torch"] = field( + default="ggml", + metadata={ + "help": "faster-qwen3-tts backend on non-macOS platforms. Options: 'ggml' or 'torch'. Default is 'ggml'. On Apple Silicon, mlx-audio is selected automatically and this option is ignored." + }, + ) + qwen3_tts_ref_audio: Optional[str] = field( + default=None, + metadata={ + "help": "Optional path to reference audio file for voice cloning. Leave unset when using a CustomVoice model." + }, + ) + qwen3_tts_ref_text: str = field( + default="I'm confused why some people have super short timelines, yet at the same time are bullish on scaling up reinforcement learning atop LLMs. If we're actually close to a human-like learner, then this whole approach of training on verifiable outcomes.", + metadata={"help": "Transcription of the reference audio for voice cloning."}, + ) + qwen3_tts_speaker: Optional[str] = field( + default="Aiden", + metadata={ + "help": "Speaker name for CustomVoice models. Default is 'Aiden'. If not provided, the first supported speaker is used when available." + }, + ) + qwen3_tts_instruct: Optional[str] = field( + default=None, + metadata={"help": "Instruction text for VoiceDesign models (optional, required for voice design)."}, + ) + qwen3_tts_xvec_only: bool = field( + default=False, + metadata={ + "help": "Use x-vector only voice cloning mode (recommended for cleaner starts and language switching). Default is False." + }, + ) + qwen3_tts_parity_mode: bool = field( + default=False, + metadata={"help": "Disable CUDA-graph streaming path and use parity mode for stability. Default is False."}, + ) + qwen3_tts_non_streaming_mode: Optional[bool] = field( + default=True, + metadata={ + "help": "Optional override for Qwen3-TTS text prefill behavior. Default is true, which pre-fills the full target text before decode on faster-qwen3-tts. Currently ignored on Apple Silicon because mlx-audio does not expose this yet." + }, + ) + qwen3_tts_mlx_quantization: Optional[str] = field( + default="6bit", + metadata={ + "help": "Optional MLX quantization override on Apple Silicon. Supported values: 'bf16', '4bit', '6bit', '8bit'. Default is '6bit'." + }, + ) + qwen3_tts_language: str = field( + default="auto", + metadata={"help": "Target language for synthesis. Default is 'auto'."}, + ) + qwen3_tts_streaming_chunk_size: Optional[int] = field( + default=None, + metadata={ + "help": "Codec steps per streaming chunk. If unset, the handler uses a backend-specific default: 8 on faster-qwen3-tts and 4 on mlx-audio." + }, + ) + qwen3_tts_max_new_tokens: int = field( + default=1536, + metadata={ + "help": "Upper cap for Qwen3-TTS codec tokens. The handler estimates a per-utterance budget from the text and clamps it to this ceiling (~12 tokens per second of audio). Raise this above 1536 if you want to allow longer utterances." + }, + ) + qwen3_tts_blocksize: int = field( + default=512, + metadata={ + "help": "Audio chunk size in samples for streaming output. Must match LocalAudioStreamer blocksize. Default is 512." + }, + ) diff --git a/speech_to_speech/arguments_classes/responses_api_language_model_arguments.py b/speech_to_speech/arguments_classes/responses_api_language_model_arguments.py new file mode 100644 index 0000000..d96663c --- /dev/null +++ b/speech_to_speech/arguments_classes/responses_api_language_model_arguments.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass, field +from typing import Optional + +from speech_to_speech.arguments_classes.language_model_base_arguments import LanguageModelBaseArguments + + +@dataclass +class ResponsesApiLanguageModelHandlerArguments(LanguageModelBaseArguments): + model_name: str = field( + default="gpt-5.4-mini", + metadata={"help": "The model to use with the OpenAI-compatible API. Default is 'gpt-5.4-mini'."}, + ) + responses_api_api_key: Optional[str] = field( + default=None, + metadata={"help": "API key used to authenticate access to the OpenAI-compatible API. Default is None."}, + ) + responses_api_base_url: Optional[str] = field( + default=None, + metadata={"help": "Base URL for the OpenAI-compatible API endpoint. Default is None (uses OpenAI)."}, + ) + responses_api_stream: bool = field( + default=True, + metadata={ + "help": "The stream parameter typically indicates whether data should be transmitted in a continuous flow rather" + " than in a single, complete response, often used for handling large or real-time data.Default is True" + }, + ) + responses_api_disable_thinking: bool = field( + default=True, + metadata={ + "help": "Disable provider-side thinking/reasoning when supported by the OpenAI-compatible backend. " + "For Together Qwen3.5 models this sends chat_template_kwargs.enable_thinking=false." + }, + ) diff --git a/speech_to_speech/arguments_classes/socket_receiver_arguments.py b/speech_to_speech/arguments_classes/socket_receiver_arguments.py new file mode 100644 index 0000000..e69655c --- /dev/null +++ b/speech_to_speech/arguments_classes/socket_receiver_arguments.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field + + +@dataclass +class SocketReceiverArguments: + recv_host: str = field( + default="localhost", + metadata={ + "help": "The host IP ddress for the socket connection. Default is '0.0.0.0' which binds to all " + "available interfaces on the host machine." + }, + ) + recv_port: int = field( + default=12345, + metadata={"help": "The port number on which the socket server listens. Default is 12346."}, + ) + chunk_size: int = field( + default=1024, + metadata={"help": "The size of each data chunk to be sent or received over the socket. Default is 1024 bytes."}, + ) diff --git a/speech_to_speech/arguments_classes/socket_sender_arguments.py b/speech_to_speech/arguments_classes/socket_sender_arguments.py new file mode 100644 index 0000000..e0011d4 --- /dev/null +++ b/speech_to_speech/arguments_classes/socket_sender_arguments.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass, field + + +@dataclass +class SocketSenderArguments: + send_host: str = field( + default="localhost", + metadata={ + "help": "The host IP address for the socket connection. Default is '0.0.0.0' which binds to all " + "available interfaces on the host machine." + }, + ) + send_port: int = field( + default=12346, + metadata={"help": "The port number on which the socket server listens. Default is 12346."}, + ) diff --git a/speech_to_speech/arguments_classes/vad_arguments.py b/speech_to_speech/arguments_classes/vad_arguments.py new file mode 100644 index 0000000..cd36378 --- /dev/null +++ b/speech_to_speech/arguments_classes/vad_arguments.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass, field + + +@dataclass +class VADHandlerArguments: + thresh: float = field( + default=0.6, + metadata={ + "help": "The threshold value for voice activity detection (VAD). Values typically range from 0 to 1, with higher values requiring higher confidence in speech detection." + }, + ) + sample_rate: int = field( + default=16000, + metadata={ + "help": "The sample rate of the audio in Hertz. Default is 16000 Hz, which is a common setting for voice audio." + }, + ) + min_silence_ms: int = field( + default=64, + metadata={ + "help": "Minimum length of silence intervals to be used for segmenting speech. Measured in milliseconds. Default is 64 ms." + }, + ) + min_speech_ms: int = field( + default=384, + metadata={ + "help": "Minimum length of speech segments to be considered valid speech. Measured in milliseconds. Default is 384 ms." + }, + ) + min_speech_continuation_ms: int = field( + default=192, + metadata={ + "help": "Hysteresis threshold (ms of active speech) for accepting speech that continues a reopenable turn (soft-ended, uncommitted, within the reopen window). Set to 0 to disable the split and use min_speech_ms. Clamped to [100, min_speech_ms]. New turns and barge-ins always require min_speech_ms. Default and recommended: 192 with min_speech_ms 384." + }, + ) + max_speech_ms: float = field( + default=float("inf"), + metadata={ + "help": "Maximum length of continuous speech before forcing a split. Default is infinite, allowing for uninterrupted speech segments." + }, + ) + speech_pad_ms: int = field( + default=500, + metadata={ + "help": "Amount of audio retained before VAD triggers and prepended to detected speech segments. Once speech is detected, audio continues to be kept until VAD declares the segment done. Measured in milliseconds. Default is 500 ms." + }, + ) + audio_enhancement: bool = field( + default=False, + metadata={ + "help": "improves sound quality by applying techniques like noise reduction, equalization, and echo cancellation. Default is False." + }, + ) + enable_realtime_transcription: bool = field( + default=False, + metadata={"help": "Enable progressive audio release for live transcription during speech. Default is False."}, + ) + realtime_processing_pause: float = field( + default=0.5, + metadata={ + "help": "Interval (in seconds) for releasing progressive audio chunks during speech. Default is 0.5s." + }, + ) + speculative_reopen_ms: int = field( + default=1000, + metadata={ + "help": "In realtime mode, keep a soft-ended turn reopenable for this many milliseconds unless a response commits it." + }, + ) + unanswered_reopen_ms: int = field( + default=7000, + metadata={ + "help": "Sanity cap (ms) for reopening a soft-ended speculative turn that has not yet been answered by any assistant output. While a turn is uncommitted, resumed speech within this window reopens the same turn instead of starting a new one. Has no effect below speculative_reopen_ms." + }, + ) + short_segment_merge_ms: int = field( + default=0, + metadata={ + "help": "When greater than 0, adjacent VAD segments below min_speech_ms are held and stitched for this many milliseconds before being discarded. Fragments shorter than 100 ms of active speech are never held. Useful with very low min_silence_ms values." + }, + ) diff --git a/speech_to_speech/arguments_classes/websocket_streamer_arguments.py b/speech_to_speech/arguments_classes/websocket_streamer_arguments.py new file mode 100644 index 0000000..09a57b5 --- /dev/null +++ b/speech_to_speech/arguments_classes/websocket_streamer_arguments.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass, field + + +@dataclass +class WebSocketStreamerArguments: + ws_host: str = field( + default="0.0.0.0", + metadata={ + "help": "The host IP address for the WebSocket server. Default is '0.0.0.0' which binds to all " + "available interfaces on the host machine." + }, + ) + ws_port: int = field( + default=8765, + metadata={"help": "The port number on which the WebSocket server listens. Default is 8765."}, + ) diff --git a/speech_to_speech/arguments_classes/whisper_stt_arguments.py b/speech_to_speech/arguments_classes/whisper_stt_arguments.py new file mode 100644 index 0000000..617ae04 --- /dev/null +++ b/speech_to_speech/arguments_classes/whisper_stt_arguments.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class WhisperSTTHandlerArguments: + stt_model_name: str = field( + default="distil-whisper/distil-large-v3", + metadata={"help": "The pretrained Whisper model to use. Default is 'distil-whisper/distil-large-v3'."}, + ) + stt_device: str = field( + default="cuda", + metadata={"help": "The device type on which the model will run. Default is 'cuda' for GPU acceleration."}, + ) + stt_torch_dtype: str = field( + default="float16", + metadata={ + "help": "The PyTorch data type for the model and input tensors. One of `float32` (full-precision), `float16` or `bfloat16` (both half-precision)." + }, + ) + stt_compile_mode: Optional[str] = field( + default=None, + metadata={ + "help": "Compile mode for torch compile. Either 'default', 'reduce-overhead' and 'max-autotune'. Default is None (no compilation)" + }, + ) + stt_gen_max_new_tokens: int = field( + default=128, + metadata={"help": "The maximum number of new tokens to generate. Default is 128."}, + ) + stt_gen_num_beams: int = field( + default=1, + metadata={"help": "The number of beams for beam search. Default is 1, implying greedy decoding."}, + ) + stt_gen_return_timestamps: bool = field( + default=False, + metadata={"help": "Whether to return timestamps with transcriptions. Default is False."}, + ) + stt_gen_task: str = field( + default="transcribe", + metadata={"help": "The task to perform, typically 'transcribe' for transcription. Default is 'transcribe'."}, + ) + language: Optional[str] = field( + default="en", + metadata={ + "help": """The language for the conversation. + Choose between 'en' (english), 'fr' (french), 'es' (spanish), + 'zh' (chinese), 'ko' (korean), 'ja' (japanese), 'hi' (hindi) or 'None'. + If using 'auto', the language is automatically detected and can + change during the conversation. Default is 'en'.""" + }, + ) diff --git a/speech_to_speech/baseHandler.py b/speech_to_speech/baseHandler.py new file mode 100644 index 0000000..f965852 --- /dev/null +++ b/speech_to_speech/baseHandler.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +# ruff: noqa: I001 + +import logging +from queue import Empty, Queue +from threading import Event +from time import perf_counter +from typing import Any, Generic, Iterator, TypeVar, cast + +import numpy as np + +from speech_to_speech.pipeline.control import PipelineControlMessage, is_control_message, SESSION_END +from speech_to_speech.pipeline.log_context import pipeline_log_ctx +from speech_to_speech.pipeline.messages import PIPELINE_END, AudioOutput, EndOfResponse + +logger = logging.getLogger(__name__) + +InT = TypeVar("InT") +OutT = TypeVar("OutT") + + +class BaseHandler(Generic[InT, OutT]): + """ + Base class for pipeline parts. Each part of the pipeline has an input and an output queue. + The `setup` method along with `setup_args` and `setup_kwargs` can be used to address the specific requirements of the implemented pipeline part. + To stop a handler properly, set the stop_event and, to avoid queue deadlocks, place PIPELINE_END in the input queue. + Objects placed in the input queue will be processed by the `process` method, and the yielded results will be placed in the output queue. + `SESSION_END` is a soft control message used to reset per-session state without stopping the handler thread. + The cleanup method handles stopping the handler, and PIPELINE_END is placed in the output queue. + """ + + def __init__( + self, + stop_event: Event, + queue_in: Queue[InT | PipelineControlMessage | bytes], + queue_out: Queue[OutT | PipelineControlMessage | bytes], + setup_args: tuple[Any, ...] = (), + setup_kwargs: dict[str, Any] = {}, + ) -> None: + self.stop_event = stop_event + self.queue_in = queue_in + self.queue_out = queue_out + self.pipeline_index: int | None = None + self.setup(*setup_args, **setup_kwargs) + self._times: list[float] = [] + + def setup(self, *arg: Any, **kwargs: Any) -> None: + pass + + def process(self, input: InT) -> Iterator[OutT]: + raise NotImplementedError + + def should_process_input(self, item: InT) -> bool: + cancel_scope = getattr(self, "cancel_scope", None) + cancel_generation = getattr(item, "cancel_generation", None) + if ( + cancel_scope is not None + and cancel_generation is not None + and not isinstance(item, EndOfResponse) + and cancel_scope.is_stale(cancel_generation) + ): + logger.debug( + "%s: dropping stale input for cancel generation %s", + self.__class__.__name__, + cancel_generation, + ) + return False + return True + + def should_emit_output(self, output: OutT) -> bool: + return True + + def before_emit_output(self, output: OutT) -> None: + pass + + def output_for_queue(self, output: OutT, source_input: InT) -> OutT | AudioOutput: + cancel_generation = getattr(source_input, "cancel_generation", None) + if cancel_generation is not None and (isinstance(output, bytes) or hasattr(output, "tobytes")): + audio = cast(bytes | np.ndarray, output) + return AudioOutput(audio=audio, cancel_generation=cancel_generation) + return output + + def run(self) -> None: + if self.pipeline_index is not None: + pipeline_log_ctx.set(self.pipeline_index) + logger.debug(f"{self.__class__.__name__}: Handler thread started") + while not self.stop_event.is_set(): + try: + # Use timeout to check stop_event periodically + item = self.queue_in.get(timeout=0.1) + except Empty: + continue + + if isinstance(item, PipelineControlMessage) and is_control_message(item, SESSION_END.kind): + logger.debug(f"{self.__class__.__name__}: session end received") + try: + self.on_session_end() + except Exception as e: + logger.error( + f"{self.__class__.__name__}: Error in on_session_end(): {type(e).__name__}: {e}", + exc_info=True, + ) + self.queue_out.put(item) + continue + + if isinstance(item, bytes) and item == PIPELINE_END: + # sentinel signal to avoid queue deadlock + logger.debug("Stopping thread") + break + + if isinstance(item, PipelineControlMessage): + logger.warning("%s: unexpected control message kind: %s", self.__class__.__name__, item.kind) + continue + + typed_item = cast(InT, item) + if not self.should_process_input(typed_item): + continue + + start_time = perf_counter() + try: + for output in self.process(typed_item): + if not self.should_emit_output(output): + start_time = perf_counter() + continue + self._times.append(perf_counter() - start_time) + if self.should_log_timing(output): + logger.log(self.timing_log_level, "%s: %.3f s", self.__class__.__name__, self.last_time) + self.before_emit_output(output) + queued_output = cast( + OutT | PipelineControlMessage | bytes, + self.output_for_queue(output, typed_item), + ) + self.queue_out.put(queued_output) + start_time = perf_counter() + except Exception as e: + logger.error(f"{self.__class__.__name__}: Error in process(): {type(e).__name__}: {e}", exc_info=True) + + self.cleanup() + self.queue_out.put(PIPELINE_END) + + @property + def last_time(self) -> float: + return self._times[-1] + + @property + def min_time_to_debug(self) -> float: + return 0.001 + + @property + def timing_log_level(self) -> int: + return logging.DEBUG + + def should_log_timing(self, output: OutT) -> bool: + return self.last_time > self.min_time_to_debug + + def cleanup(self) -> None: + pass + + def on_session_end(self) -> None: + pass diff --git a/speech_to_speech/connections/__init__.py b/speech_to_speech/connections/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/speech_to_speech/connections/__init__.py @@ -0,0 +1 @@ + diff --git a/speech_to_speech/connections/local_audio_streamer.py b/speech_to_speech/connections/local_audio_streamer.py new file mode 100644 index 0000000..2ed2837 --- /dev/null +++ b/speech_to_speech/connections/local_audio_streamer.py @@ -0,0 +1,71 @@ +import logging +import threading +import time +from queue import Queue + +import numpy as np +import sounddevice as sd + +from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE +from speech_to_speech.pipeline.queue_types import AudioInItem, AudioOutItem + +logger = logging.getLogger(__name__) + + +class LocalAudioStreamer: + def __init__( + self, + input_queue: Queue[AudioInItem], + output_queue: Queue[AudioOutItem], + should_listen: threading.Event, + list_play_chunk_size: int = 512, + ) -> None: + self.list_play_chunk_size = list_play_chunk_size + + self.stop_event = threading.Event() + self.input_queue = input_queue + self.output_queue = output_queue + self.should_listen = should_listen + + def run(self) -> None: + # Pre-generate a static dither buffer (±1 LSB, -96 dB) to keep the + # audio sink active without calling numpy inside the real-time callback. + dither = np.random.randint(-1, 2, size=(self.list_play_chunk_size, 1), dtype=np.int16) + + def callback(indata: np.ndarray, outdata: np.ndarray, frames: int, time: float, status: str) -> None: + # During shutdown, just output silence + if self.stop_event.is_set(): + outdata[:] = 0 * outdata + return + + if self.output_queue.empty(): + pcm = np.ascontiguousarray(indata, dtype=np.int16) + self.input_queue.put(pcm.tobytes()) + outdata[:] = dither + else: + try: + audio_chunk = self.output_queue.get_nowait() + if isinstance(audio_chunk, np.ndarray): + outdata[:] = audio_chunk[:, np.newaxis] + elif audio_chunk == AUDIO_RESPONSE_DONE: + self.should_listen.set() + logger.debug("Response complete, listening re-enabled") + outdata[:] = 0 * outdata + else: + outdata[:] = 0 * outdata + except Exception: + outdata[:] = 0 * outdata + + logger.debug("Available devices:") + logger.debug(sd.query_devices()) + with sd.Stream( + samplerate=16000, + dtype="int16", + channels=1, + callback=callback, + blocksize=self.list_play_chunk_size, + ): + logger.info("Starting local audio stream") + while not self.stop_event.is_set(): + time.sleep(0.001) + print("Stopping recording") diff --git a/speech_to_speech/connections/socket_receiver.py b/speech_to_speech/connections/socket_receiver.py new file mode 100644 index 0000000..21d7563 --- /dev/null +++ b/speech_to_speech/connections/socket_receiver.py @@ -0,0 +1,85 @@ +import logging +import socket +import time +from queue import Queue +from threading import Event + +from rich.console import Console + +from speech_to_speech.pipeline.messages import PIPELINE_END +from speech_to_speech.pipeline.queue_types import AudioInItem + +logger = logging.getLogger(__name__) + +console = Console() + +# If should_listen stays cleared for longer than this, something in the +# pipeline probably failed (LLM exception, TTS crash, etc.). Re-enable +# listening so the user isn't permanently locked out. +SHOULD_LISTEN_TIMEOUT_S = 30.0 + + +class SocketReceiver: + """ + Handles reception of the audio packets from the client. + """ + + def __init__( + self, + stop_event: Event, + queue_out: Queue[AudioInItem], + should_listen: Event, + host: str = "0.0.0.0", + port: int = 12345, + chunk_size: int = 1024, + ) -> None: + self.stop_event = stop_event + self.queue_out = queue_out + self.should_listen = should_listen + self.chunk_size = chunk_size + self.host = host + self.port = port + + def receive_full_chunk(self, conn: socket.socket, chunk_size: int) -> bytes | None: + data = b"" + while len(data) < chunk_size: + packet = conn.recv(chunk_size - len(data)) + if not packet: + # connection closed + return None + data += packet + return data + + def run(self) -> None: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socket.bind((self.host, self.port)) + self.socket.listen(1) + logger.info("Receiver waiting to be connected...") + self.conn, _ = self.socket.accept() + logger.info("receiver connected") + + self.should_listen.set() + listen_cleared_at = None + while not self.stop_event.is_set(): + audio_chunk = self.receive_full_chunk(self.conn, self.chunk_size) + if audio_chunk is None: + # connection closed + self.queue_out.put(PIPELINE_END) + break + if self.should_listen.is_set(): + self.queue_out.put(audio_chunk) + listen_cleared_at = None + else: + # Track how long should_listen has been cleared + if listen_cleared_at is None: + listen_cleared_at = time.monotonic() + elif time.monotonic() - listen_cleared_at > SHOULD_LISTEN_TIMEOUT_S: + logger.warning( + "should_listen has been cleared for %.0fs — pipeline may be stuck, re-enabling listening", + SHOULD_LISTEN_TIMEOUT_S, + ) + self.should_listen.set() + listen_cleared_at = None + self.conn.close() + logger.info("Receiver closed") diff --git a/speech_to_speech/connections/socket_sender.py b/speech_to_speech/connections/socket_sender.py new file mode 100644 index 0000000..c2b465b --- /dev/null +++ b/speech_to_speech/connections/socket_sender.py @@ -0,0 +1,69 @@ +import logging +import socket +from queue import Empty, Queue +from threading import Event + +import numpy as np +from rich.console import Console + +from speech_to_speech.pipeline.control import PipelineControlMessage +from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, PIPELINE_END +from speech_to_speech.pipeline.queue_types import AudioOutItem + +logger = logging.getLogger(__name__) + +console = Console() + + +class SocketSender: + """ + Handles sending generated audio packets to the clients. + """ + + def __init__( + self, + stop_event: Event, + queue_in: Queue[AudioOutItem], + should_listen: Event, + host: str = "0.0.0.0", + port: int = 12346, + ) -> None: + self.stop_event = stop_event + self.queue_in = queue_in + self.should_listen = should_listen + self.host = host + self.port = port + + def run(self) -> None: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socket.bind((self.host, self.port)) + self.socket.listen(1) + logger.info("Sender waiting to be connected...") + self.conn, _ = self.socket.accept() + logger.info("sender connected") + + while not self.stop_event.is_set(): + try: + audio_chunk = self.queue_in.get(timeout=0.5) + except Empty: + continue + if isinstance(audio_chunk, PipelineControlMessage): + continue + if isinstance(audio_chunk, bytes) and audio_chunk == AUDIO_RESPONSE_DONE: + self.should_listen.set() + continue + payload: bytes + if isinstance(audio_chunk, bytes): + payload = audio_chunk + elif isinstance(audio_chunk, np.ndarray): + payload = audio_chunk.tobytes() + elif hasattr(audio_chunk, "tobytes"): + payload = audio_chunk.tobytes() + else: + continue + self.conn.sendall(payload) + if isinstance(audio_chunk, bytes) and audio_chunk == PIPELINE_END: + break + self.conn.close() + logger.info("Sender closed") diff --git a/speech_to_speech/connections/websocket_streamer.py b/speech_to_speech/connections/websocket_streamer.py new file mode 100644 index 0000000..a126fd9 --- /dev/null +++ b/speech_to_speech/connections/websocket_streamer.py @@ -0,0 +1,238 @@ +import asyncio +import json +import logging +from queue import Empty, Queue +from threading import Event +from typing import Any + +import numpy as np +from websockets.asyncio.server import ServerConnection + +from speech_to_speech.pipeline.control import SESSION_END, PipelineControlMessage, is_control_message +from speech_to_speech.pipeline.events import PipelineEvent +from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, PIPELINE_END +from speech_to_speech.pipeline.queue_types import AudioInItem, AudioOutItem, TextEventItem + +logger = logging.getLogger(__name__) + + +class WebSocketStreamer: + """ + Handles bidirectional audio streaming over WebSocket. + + Receives audio from clients and puts it in the input_queue. + Sends audio from the output_queue to clients. + Sends text messages (transcripts/tools) from text_output_queue to clients. + """ + + def __init__( + self, + stop_event: Event, + input_queue: Queue[AudioInItem], + output_queue: Queue[AudioOutItem], + should_listen: Event, + text_output_queue: Queue[TextEventItem] | None = None, + host: str = "0.0.0.0", + port: int = 8765, + ) -> None: + self.stop_event = stop_event + self.input_queue = input_queue # clients -> VAD + self.output_queue = output_queue # TTS -> clients + self.text_output_queue = text_output_queue # Text messages -> clients + self.should_listen = should_listen + self.host = host + self.port = port + self.clients: set[ServerConnection] = set() + self.loop: asyncio.AbstractEventLoop | None = None + self.server: Any = None + + def run(self) -> None: + """Run the WebSocket server (called from a thread).""" + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + try: + self.loop.run_until_complete(self._run_server()) + except Exception as e: + logger.error(f"WebSocket server error: {e}") + finally: + self.loop.close() + + async def _run_server(self) -> None: + """Main async server loop.""" + import websockets + + logger.info(f"WebSocket server starting on ws://{self.host}:{self.port}") + + self.server = await websockets.serve( + self._handle_client, + self.host, + self.port, + ) + + logger.info("WebSocket server ready, waiting for connections...") + + # Start the sender task + sender_task = asyncio.create_task(self._send_loop()) + + # Wait until stop_event is set + while not self.stop_event.is_set(): + await asyncio.sleep(0.1) + + # Cleanup + sender_task.cancel() + try: + await sender_task + except asyncio.CancelledError: + pass + + # Close all clients + for client in list(self.clients): + try: + await client.close() + except Exception: + pass + + self.server.close() + await self.server.wait_closed() + logger.info("WebSocket server closed") + + async def _handle_client(self, websocket: ServerConnection) -> None: + """Handle a single WebSocket client connection.""" + client_id = id(websocket) + logger.info(f"Client {client_id} connected") + self.clients.add(websocket) + recv_buffer = bytearray() + + # Enable listening when first client connects + if len(self.clients) == 1: + # Drain edge queues so stale data from a previous session doesn't + # leak into the new one (SESSION_END may not have flushed everything). + for q in (self.output_queue, self.text_output_queue): + if q is not None: + while not q.empty(): + try: + q.get_nowait() + except Empty: + break + self.should_listen.set() + logger.debug("Listening enabled, edge queues drained (should_listen.set())") + + try: + logger.debug(f"Client {client_id}: Starting message receive loop") + async for message in websocket: + if isinstance(message, bytes): + logger.debug(f"Client {client_id}: Received {len(message)} bytes of audio") + if self.should_listen.is_set(): + # Split into 512-sample (1024 bytes) chunks for VAD. + # Keep a per-client remainder buffer so no samples are dropped + # when WebSocket frame boundaries are not aligned. + chunk_size_bytes = 512 * 2 # 512 samples * 2 bytes per int16 + recv_buffer.extend(message) + num_chunks = 0 + while len(recv_buffer) >= chunk_size_bytes: + chunk = bytes(recv_buffer[:chunk_size_bytes]) + del recv_buffer[:chunk_size_bytes] + self.input_queue.put(chunk) + num_chunks += 1 + logger.debug(f"Client {client_id}: Queued {num_chunks} chunks for processing") + else: + logger.debug(f"Client {client_id}: Skipping audio (should_listen not set)") + + except Exception as e: + logger.error(f"Client {client_id} error: {type(e).__name__}: {e}", exc_info=True) + finally: + self.clients.discard(websocket) + logger.info(f"Client {client_id} disconnected (finally block)") + + if len(self.clients) == 0: + logger.debug("Last WebSocket client disconnected, ending session") + self.input_queue.put(SESSION_END) + + async def _send_loop(self) -> None: + """Send audio and text from queues to all connected clients.""" + # Buffer audio until we have at least 100ms worth (3200 bytes = 1600 samples at 16kHz int16) + MIN_AUDIO_BYTES = 3200 + audio_buffer = bytearray() + + while not self.stop_event.is_set(): + try: + # Check for audio + try: + audio_chunk = self.output_queue.get_nowait() + if isinstance(audio_chunk, bytes) and audio_chunk == PIPELINE_END: + if audio_buffer and self.clients: + data = bytes(audio_buffer) + audio_buffer.clear() + await asyncio.gather( + *[client.send(data) for client in self.clients], + return_exceptions=True, + ) + break + if isinstance(audio_chunk, bytes) and audio_chunk == AUDIO_RESPONSE_DONE: + if audio_buffer and self.clients: + data = bytes(audio_buffer) + audio_buffer.clear() + await asyncio.gather( + *[client.send(data) for client in self.clients], + return_exceptions=True, + ) + self.should_listen.set() + logger.debug("Response complete, listening re-enabled") + continue + if is_control_message(audio_chunk, SESSION_END.kind): + audio_buffer.clear() + continue + + if isinstance(audio_chunk, PipelineControlMessage): + continue + + if self.clients: + chunk_bytes: bytes + if isinstance(audio_chunk, bytes): + chunk_bytes = audio_chunk + elif isinstance(audio_chunk, np.ndarray): + chunk_bytes = audio_chunk.tobytes() + elif hasattr(audio_chunk, "tobytes"): + chunk_bytes = audio_chunk.tobytes() + else: + continue + audio_buffer.extend(chunk_bytes) + + if len(audio_buffer) >= MIN_AUDIO_BYTES: + data = bytes(audio_buffer) + audio_buffer.clear() + logger.debug(f"Sending {len(data)} bytes of audio to {len(self.clients)} client(s)") + await asyncio.gather( + *[client.send(data) for client in self.clients], return_exceptions=True + ) + except Empty: + # Flush any buffered audio when queue is empty + if audio_buffer and self.clients: + data = bytes(audio_buffer) + audio_buffer.clear() + logger.debug(f"Flushing {len(data)} bytes of audio to {len(self.clients)} client(s)") + await asyncio.gather(*[client.send(data) for client in self.clients], return_exceptions=True) + + # Check for text/tool messages + if self.text_output_queue: + try: + text_message = self.text_output_queue.get_nowait() + if self.clients: + if isinstance(text_message, PipelineEvent): + payload = text_message.model_dump() + await asyncio.gather( + *[client.send(json.dumps(payload)) for client in self.clients], + return_exceptions=True, + ) + elif isinstance(text_message, (PipelineControlMessage, bytes)): + continue + except Empty: + pass + + await asyncio.sleep(0.01) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Send loop error: {e}") diff --git a/speech_to_speech/pipeline/__init__.py b/speech_to_speech/pipeline/__init__.py new file mode 100644 index 0000000..dd723d5 --- /dev/null +++ b/speech_to_speech/pipeline/__init__.py @@ -0,0 +1,55 @@ +from speech_to_speech.pipeline.cancel_scope import CancelScope +from speech_to_speech.pipeline.control import ( + SESSION_END, + ControlKind, + PipelineControlMessage, + is_control_message, +) +from speech_to_speech.pipeline.events import ( + AssistantTextEvent, + PartialTranscriptionEvent, + PipelineEvent, + SpeechStartedEvent, + SpeechStoppedEvent, + TokenUsageEvent, + TranscriptionCompletedEvent, +) +from speech_to_speech.pipeline.messages import ( + AUDIO_RESPONSE_DONE, + PIPELINE_END, + EndOfResponse, + GenerateResponseRequest, + LLMResponseChunk, + PartialTranscription, + PipelineMessage, + TokenUsage, + Transcription, + TTSInput, + VADAudio, +) + +__all__ = [ + "AUDIO_RESPONSE_DONE", + "AssistantTextEvent", + "CancelScope", + "ControlKind", + "EndOfResponse", + "GenerateResponseRequest", + "LLMResponseChunk", + "PIPELINE_END", + "PartialTranscription", + "PartialTranscriptionEvent", + "PipelineControlMessage", + "PipelineEvent", + "PipelineMessage", + "SESSION_END", + "SpeechStartedEvent", + "SpeechStoppedEvent", + "TTSInput", + "TokenUsage", + "TokenUsageEvent", + "Transcription", + "TranscriptionCompletedEvent", + "VADAudio", + "is_control_message", +] diff --git a/speech_to_speech/pipeline/cancel_scope.py b/speech_to_speech/pipeline/cancel_scope.py new file mode 100644 index 0000000..d731ac1 --- /dev/null +++ b/speech_to_speech/pipeline/cancel_scope.py @@ -0,0 +1,64 @@ +class CancelScope: + """Unified cancellation signal for the speech-to-speech pipeline. + + Uses a generation counter so pipeline threads (LLM, TTS) can detect + cancellation without brief-pulse timing games, and an internal + ``discarding`` flag so the async send loop can drop stale output. + + Thread safety: one writer (the asyncio router thread) and multiple + readers (pipeline handler threads). Python's GIL makes int/bool + reads and writes atomic at the bytecode level, so no lock is needed. + """ + + def __init__(self) -> None: + self._gen: int = 0 + self._discarding: bool = False + self._discarded_generation: int | None = None + + @property + def generation(self) -> int: + """Current generation number. Pipeline threads capture this at + the start of each response and compare with ``is_stale``.""" + return self._gen + + def cancel(self) -> None: + """Cancel the current response. + + Increments the generation (so pipeline threads see their captured + generation as stale) and enables the send-loop discard guard. + """ + # prevent overflow... after 4 billion generations, we'll wrap around xD... + self._discarded_generation = self._gen + self._gen = (self._gen + 1) & 0xFFFFFFFF + self._discarding = True + + def response_done(self, generation: int | None = None) -> None: + """Pipeline acknowledged completion. Clears the discard guard.""" + if ( + generation is not None + and self._discarded_generation is not None + and generation not in {self._discarded_generation, self._gen} + ): + return + self._discarding = False + self._discarded_generation = None + + def new_response(self) -> None: + """An explicit ``response.create`` starts a new response. + Clears the discard guard.""" + self._discarding = False + self._discarded_generation = None + + def is_stale(self, gen: int) -> bool: + """Return True if *gen* has been superseded by a ``cancel`` call.""" + return gen != self._gen + + @property + def discarding(self) -> bool: + """Whether the send loop should silently drop stale output.""" + return self._discarding + + def reset(self) -> None: + """Clear discard state (e.g. on new session connect).""" + self._discarding = False + self._discarded_generation = None diff --git a/speech_to_speech/pipeline/control.py b/speech_to_speech/pipeline/control.py new file mode 100644 index 0000000..ce8ed3b --- /dev/null +++ b/speech_to_speech/pipeline/control.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class ControlKind(str, Enum): + """Strongly-typed kinds for :class:`PipelineControlMessage`.""" + + SESSION_END = "session_end" + + +@dataclass(frozen=True) +class PipelineControlMessage: + kind: ControlKind + # Session that enqueued the message, when known. Lets the pooled realtime + # send loop ignore a SESSION_END from a force-released session so it can't + # satisfy the drain wait of the session that claimed the unit afterwards. + session_id: str | None = None + + +SESSION_END = PipelineControlMessage(ControlKind.SESSION_END) + + +def is_control_message(message: object, kind: ControlKind | None = None) -> bool: + return isinstance(message, PipelineControlMessage) and (kind is None or message.kind == kind) diff --git a/speech_to_speech/pipeline/events.py b/speech_to_speech/pipeline/events.py new file mode 100644 index 0000000..7512f8a --- /dev/null +++ b/speech_to_speech/pipeline/events.py @@ -0,0 +1,99 @@ +"""Typed events flowing on ``text_output_queue``. + +These are internal pipeline events produced by VAD, TranscriptionNotifier, and +LMOutputProcessor, consumed by the realtime ``WebSocketRouter`` send-loop and +``RealtimeService.dispatch_pipeline_event``. They replace the raw ``dict`` +literals that were previously put on the queue. +""" + +from __future__ import annotations + +from typing import Literal, Optional + +from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall +from pydantic import BaseModel, Field + + +class PipelineEvent(BaseModel): + """Base for all text_output_queue events. + + The ``type`` field mirrors the former dict ``"type"`` key and acts as a + Pydantic discriminator. + """ + + type: str + + +# ── VAD events ──────────────────────────────────────────────────────── + + +class SpeechStartedEvent(PipelineEvent): + type: Literal["speech_started"] = "speech_started" + audio_start_ms: int = 0 + turn_id: str | None = None + turn_revision: int | None = None + reopened: bool = False + interrupt_response: bool = Field(default=True, exclude=True) + + +class SpeechStoppedEvent(PipelineEvent): + type: Literal["speech_stopped"] = "speech_stopped" + duration_s: float = 0.0 + audio_end_ms: int = 0 + turn_id: str | None = None + turn_revision: int | None = None + + +# ── Transcription events (TranscriptionNotifier) ───────────────────── + + +class PartialTranscriptionEvent(PipelineEvent): + type: Literal["partial_transcription"] = "partial_transcription" + delta: str + turn_id: str | None = None + turn_revision: int | None = None + + +class TranscriptionCompletedEvent(PipelineEvent): + type: Literal["transcription_completed"] = "transcription_completed" + transcript: str + language_code: Optional[str] = None + turn_id: str | None = None + turn_revision: int | None = None + speech_stopped_at_s: float | None = Field(default=None, exclude=True) + + +# ── LLM output events (LMOutputProcessor) ──────────────────────────── + + +class AssistantTextEvent(PipelineEvent): + type: Literal["assistant_text"] = "assistant_text" + text: str + tools: list[ResponseFunctionToolCall] = Field(default_factory=list) + turn_id: str | None = None + turn_revision: int | None = None + # Response generation that produced this text, mirroring AudioOutput. Lets the + # send loop discard stale assistant text by the same generation-aware rule as + # audio, instead of blanket-dropping while cancel_scope.discarding is set. + cancel_generation: int | None = None + + +class TokenUsageEvent(PipelineEvent): + type: Literal["token_usage"] = "token_usage" + input_tokens: int = 0 + output_tokens: int = 0 + turn_id: str | None = None + turn_revision: int | None = None + + +class ResponseFailedEvent(PipelineEvent): + """Signals that a response could not be generated (e.g. invalid out-of-band input). + + Dispatched to the service so it can close the in-progress response with + ``status="failed"`` instead of the usual ``completed``. + """ + + type: Literal["response_failed"] = "response_failed" + message: str = "" + turn_id: str | None = None + turn_revision: int | None = None diff --git a/speech_to_speech/pipeline/handler_types.py b/speech_to_speech/pipeline/handler_types.py new file mode 100644 index 0000000..8462a85 --- /dev/null +++ b/speech_to_speech/pipeline/handler_types.py @@ -0,0 +1,44 @@ +"""Convenience `TypeAlias` definitions for pipeline handler generics. + +These aliases keep handler declarations readable (e.g. `BaseHandler[STTIn, STTOut]`) +without hiding what actually flows between stages (see `pipeline/messages.py` and +`pipeline/queue_types.py` for the full story, including control + sentinel items). +""" + +from __future__ import annotations + +# ruff: noqa: I001 + +from typing import TypeAlias + +import numpy as np + +from speech_to_speech.pipeline.messages import ( + AudioOutput, + EndOfResponse, + GenerateResponseRequest, + LLMResponseChunk, + PartialTranscription, + TTSInput, + TokenUsage, + Transcription, + VADAudio, +) + +from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig + +# ── VAD stage ───────────────────────────────────────────────────────── +VADIn: TypeAlias = bytes | tuple[bytes, RuntimeConfig] +VADOut: TypeAlias = VADAudio + +# ── STT stage ───────────────────────────────────────────────────────── +STTIn: TypeAlias = VADAudio +STTOut: TypeAlias = PartialTranscription | Transcription + +# ── LLM stage ───────────────────────────────────────────────────────── +LLMIn: TypeAlias = GenerateResponseRequest +LLMOut: TypeAlias = LLMResponseChunk | TokenUsage | EndOfResponse + +# ── TTS stage ───────────────────────────────────────────────────────── +TTSIn: TypeAlias = TTSInput | EndOfResponse +TTSOut: TypeAlias = bytes | np.ndarray | AudioOutput diff --git a/speech_to_speech/pipeline/log_context.py b/speech_to_speech/pipeline/log_context.py new file mode 100644 index 0000000..f9c5501 --- /dev/null +++ b/speech_to_speech/pipeline/log_context.py @@ -0,0 +1,25 @@ +"""Per-pipeline logging context. + +Each isolated pipeline unit sets the contextvar at thread / asyncio-task entry, +and every log record emitted from that thread/task is automatically tagged with +a `[pipeline N] ` prefix via PipelineLogFilter. Non-pipeline-bound code emits +records with an empty prefix. + +Works for both threading (handler threads) and asyncio (websocket route, send loops) +because contextvars are per-thread and per-task. +""" + +import contextvars +import logging +from typing import Optional + +pipeline_log_ctx: contextvars.ContextVar[Optional[int]] = contextvars.ContextVar("pipeline_index", default=None) + + +class PipelineLogFilter(logging.Filter): + """Inject `pipeline_prefix` into every LogRecord based on the current contextvar.""" + + def filter(self, record: logging.LogRecord) -> bool: + idx = pipeline_log_ctx.get() + record.pipeline_prefix = f"[pipeline {idx}] " if idx is not None else "" + return True diff --git a/speech_to_speech/pipeline/messages.py b/speech_to_speech/pipeline/messages.py new file mode 100644 index 0000000..5c259be --- /dev/null +++ b/speech_to_speech/pipeline/messages.py @@ -0,0 +1,172 @@ +"""Single source of truth for inter-component pipeline messages. + +Typed :class:`PipelineMessage` subclasses replace the ad-hoc tuples that +previously flowed between STT, LLM, LMOutputProcessor and TTS stages. +Binary sentinels carried on the audio/output queue are plain ``bytes`` +constants. +""" + +from __future__ import annotations + +from time import perf_counter +from typing import Final, Literal, Optional, TypeAlias + +import numpy as np +from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams +from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall +from pydantic import BaseModel, ConfigDict, Field + +from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig + +# ── Base class ──────────────────────────────────────────────────────── + + +class PipelineMessage(BaseModel): + """Base for all typed pipeline messages. + + The ``tag`` field acts as a Pydantic discriminator so a ``Union`` of + subtypes can be validated from raw dicts when needed. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + tag: str + + +# ── VAD → STT ───────────────────────────────────────────────────────── + + +class VADAudio(PipelineMessage): + """Audio segment from VAD, with optional mode for realtime transcription.""" + + tag: Literal["vad_audio"] = "vad_audio" + audio: np.ndarray + mode: Literal["progressive", "final"] | None = None + turn_id: str | None = None + turn_revision: int | None = None + created_at_s: float = Field(default_factory=perf_counter) + + +# ── STT → TranscriptionNotifier → LLM ──────────────────────────────── + + +class PartialTranscription(PipelineMessage): + """Live partial transcription (consumed by TranscriptionNotifier, not forwarded to LLM).""" + + tag: Literal["partial_transcription"] = "partial_transcription" + text: str + turn_id: str | None = None + turn_revision: int | None = None + + +class Transcription(PipelineMessage): + """Final transcription result.""" + + tag: Literal["transcription"] = "transcription" + text: str + language_code: Optional[str] = None + turn_id: str | None = None + turn_revision: int | None = None + speech_stopped_at_s: float | None = None + + +# ── LLM → LMOutputProcessor ────────────────────────────────────────── + + +class LLMResponseChunk(PipelineMessage): + """One sentence/chunk of the LLM response.""" + + tag: Literal["llm_response_chunk"] = "llm_response_chunk" + text: str + language_code: Optional[str] = None + tools: list[ResponseFunctionToolCall] = Field(default_factory=list) + runtime_config: RuntimeConfig | None = None + response: RealtimeResponseCreateParams | None = None + turn_id: str | None = None + turn_revision: int | None = None + speech_stopped_at_s: float | None = None + cancel_generation: int | None = None + + +class TokenUsage(PipelineMessage): + """Token count report (side-channel, not forwarded to TTS).""" + + tag: Literal["token_usage"] = "token_usage" + input_tokens: int + output_tokens: int + turn_id: str | None = None + turn_revision: int | None = None + + +class EndOfResponse(PipelineMessage): + """Sentinel marking the end of a response. + + ``error`` is set when generation could not start (e.g. an out-of-band + response whose ``input`` failed validation); the output processor turns it + into a ``response.done(status="failed")`` while still closing the response + normally for pipeline cleanup. + """ + + tag: Literal["end_of_response"] = "end_of_response" + turn_id: str | None = None + turn_revision: int | None = None + cancel_generation: int | None = None + error: str | None = None + + +# ── LMOutputProcessor → TTS ────────────────────────────────────────── + + +class TTSInput(PipelineMessage): + """Text to synthesize with per-response context.""" + + tag: Literal["tts_input"] = "tts_input" + text: str + language_code: Optional[str] = None + runtime_config: RuntimeConfig | None = None + response: RealtimeResponseCreateParams | None = None + turn_id: str | None = None + turn_revision: int | None = None + speech_stopped_at_s: float | None = None + cancel_generation: int | None = None + + +class AudioOutput(PipelineMessage): + """Audio queue item tagged with the response generation that produced it.""" + + tag: Literal["audio_output"] = "audio_output" + audio: bytes | np.ndarray + cancel_generation: int | None = None + + +# ── Realtime service → LLM ──────────────────────────────────────────── + + +class GenerateResponseRequest(PipelineMessage): + """Triggers LLM generation for a realtime session. + + Carries everything the LM handler needs to produce a response so it + never has to reach back into shared objects. ``runtime_config`` + holds the per-connection session config *and* the conversation chat; + ``response`` carries per-response overrides from ``response.create``. + Downstream handlers resolve each attribute by preferring the + per-response value over the session default. + """ + + tag: Literal["generate_response"] = "generate_response" + runtime_config: RuntimeConfig + response: RealtimeResponseCreateParams | None = None + language_code: Optional[str] = None + turn_id: str | None = None + turn_revision: int | None = None + speech_stopped_at_s: float | None = None + + +# ── Binary sentinels (audio/output queue) ───────────────────────────── + +AUDIO_RESPONSE_DONE: Final[bytes] = b"__RESPONSE_DONE__" +PIPELINE_END: Final[bytes] = b"END" + +PipelineEndSentinel: TypeAlias = Literal[b"END"] +AudioResponseDoneSentinel: TypeAlias = Literal[b"__RESPONSE_DONE__"] +SentinelMessage: TypeAlias = PipelineEndSentinel | AudioResponseDoneSentinel diff --git a/speech_to_speech/pipeline/queue_types.py b/speech_to_speech/pipeline/queue_types.py new file mode 100644 index 0000000..27bc27a --- /dev/null +++ b/speech_to_speech/pipeline/queue_types.py @@ -0,0 +1,46 @@ +"""Typed aliases for items flowing through pipeline queues. + +This module centralizes queue payload unions so the rest of the codebase can +reference a small set of stable types instead of repeating large unions. +""" + +from __future__ import annotations + +# ruff: noqa: I001 + +from typing import TypeAlias + +import numpy as np + +from speech_to_speech.pipeline.control import PipelineControlMessage +from speech_to_speech.pipeline.events import PipelineEvent +from speech_to_speech.pipeline.handler_types import LLMIn, LLMOut, STTOut, TTSIn, VADIn, VADOut +from speech_to_speech.pipeline.messages import AudioOutput + +# Use plain ``bytes`` for sentinel values on queues (``PIPELINE_END``, ``AUDIO_RESPONSE_DONE``). +# ``Queue`` is invariant; ``Literal[b"END"]`` is not accepted where ``bytes`` is required. +PipelineInternalItem: TypeAlias = PipelineControlMessage | bytes + +# Audio chunks coming from IO (socket/websocket/mic) into VAD. +AudioInItem: TypeAlias = VADIn | PipelineControlMessage + +# Audio segments flowing from VAD to STT. +VADOutItem: TypeAlias = VADOut | PipelineInternalItem + +# STT output (partial + final) flowing to TranscriptionNotifier. +STTOutItem: TypeAlias = STTOut | PipelineInternalItem + +# Text prompts flowing into LLM. +TextPromptItem: TypeAlias = LLMIn | PipelineInternalItem + +# LLM outputs flowing into LMOutputProcessor. +LMOutItem: TypeAlias = LLMOut | PipelineInternalItem + +# Inputs flowing into TTS. +TTSInItem: TypeAlias = TTSIn | PipelineInternalItem + +# Audio outputs flowing to speakers / client (includes sentinels as bytes). +AudioOutItem: TypeAlias = bytes | np.ndarray | AudioOutput | PipelineControlMessage + +# Side-channel text events sent to websocket/realtime clients. +TextEventItem: TypeAlias = PipelineEvent | PipelineInternalItem diff --git a/speech_to_speech/pipeline/speculative_turns.py b/speech_to_speech/pipeline/speculative_turns.py new file mode 100644 index 0000000..3f0ad0b --- /dev/null +++ b/speech_to_speech/pipeline/speculative_turns.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +import logging +import time +from collections import OrderedDict +from dataclasses import dataclass +from threading import Condition + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _PendingReopen: + base_revision: int + candidate_revision: int + + +@dataclass(frozen=True) +class _ReopenGrace: + revision: int + deadline: float + + +class SpeculativeTurnTracker: + """Thread-safe revision tracker for raw-audio speculative turns.""" + + _PENDING_REOPEN_WAIT_TIMEOUT_S = 2.0 + _MAX_TRACKED_TURNS = 2048 + + def __init__(self, max_tracked_turns: int = _MAX_TRACKED_TURNS) -> None: + self._condition = Condition() + self._max_tracked_turns = max_tracked_turns + self._latest_revision: OrderedDict[str, int] = OrderedDict() + self._committed_revision: dict[str, int] = {} + self._pending_reopen: dict[str, _PendingReopen] = {} + self._reopen_grace: dict[str, _ReopenGrace] = {} + + def observe(self, turn_id: str | None, revision: int | None) -> None: + if turn_id is None or revision is None: + return + with self._condition: + current = self._latest_revision.get(turn_id, -1) + if revision > current: + self._latest_revision[turn_id] = revision + self._latest_revision.move_to_end(turn_id) + self._prune_tracked_turns() + logger.debug("Observed speculative turn %s revision %d", turn_id, revision) + self._condition.notify_all() + + def is_latest(self, turn_id: str | None, revision: int | None) -> bool: + if turn_id is None or revision is None: + return True + with self._condition: + return self._latest_revision.get(turn_id, revision) == revision + + def is_latest_after_pending_reopen(self, turn_id: str | None, revision: int | None) -> bool: + if turn_id is None or revision is None: + return True + with self._condition: + self._wait_for_pending_reopen_locked(turn_id, revision, self._PENDING_REOPEN_WAIT_TIMEOUT_S) + return self._latest_revision.get(turn_id, revision) == revision + + def try_is_latest_after_pending_reopen(self, turn_id: str | None, revision: int | None) -> bool | None: + """Non-blocking variant of ``is_latest_after_pending_reopen``. + + Returns ``None`` when a matching reopen candidate is still pending and + the caller should retry after it resolves. + """ + if turn_id is None or revision is None: + return True + with self._condition: + if self._has_pending_reopen_locked(turn_id, revision): + return None + return self._latest_revision.get(turn_id, revision) == revision + + def is_latest_after_reopen_grace(self, turn_id: str | None, revision: int | None) -> bool: + if turn_id is None or revision is None: + return True + with self._condition: + self._wait_for_reopen_gate_locked(turn_id, revision) + return self._latest_revision.get(turn_id, revision) == revision + + def try_is_latest_after_reopen_grace(self, turn_id: str | None, revision: int | None) -> bool | None: + if turn_id is None or revision is None: + return True + with self._condition: + if ( + self._has_pending_reopen_locked(turn_id, revision) + or self._reopen_grace_remaining_locked( + turn_id, + revision, + ) + > 0 + ): + return None + return self._latest_revision.get(turn_id, revision) == revision + + def commit_if_latest_after_pending_reopen(self, turn_id: str | None, revision: int | None) -> bool: + if turn_id is None or revision is None: + return True + with self._condition: + self._wait_for_pending_reopen_locked(turn_id, revision, self._PENDING_REOPEN_WAIT_TIMEOUT_S) + latest = self._latest_revision.get(turn_id, revision) + if revision != latest: + return False + self._committed_revision[turn_id] = revision + logger.debug("Committed speculative turn %s revision %d", turn_id, revision) + self._condition.notify_all() + return True + + def commit_if_latest_after_reopen_grace(self, turn_id: str | None, revision: int | None) -> bool: + if turn_id is None or revision is None: + return True + with self._condition: + self._wait_for_reopen_gate_locked(turn_id, revision) + latest = self._latest_revision.get(turn_id, revision) + if revision != latest: + return False + self._committed_revision[turn_id] = revision + logger.debug("Committed speculative turn %s revision %d", turn_id, revision) + self._condition.notify_all() + return True + + def try_commit_if_latest_after_pending_reopen(self, turn_id: str | None, revision: int | None) -> bool | None: + """Non-blocking variant of ``commit_if_latest_after_pending_reopen``. + + Returns ``None`` when a matching reopen candidate is still pending and + the caller should retry after it resolves. + """ + if turn_id is None or revision is None: + return True + with self._condition: + if self._has_pending_reopen_locked(turn_id, revision): + return None + latest = self._latest_revision.get(turn_id, revision) + if revision != latest: + return False + self._committed_revision[turn_id] = revision + logger.debug("Committed speculative turn %s revision %d", turn_id, revision) + self._condition.notify_all() + return True + + def try_commit_if_latest_after_reopen_grace(self, turn_id: str | None, revision: int | None) -> bool | None: + if turn_id is None or revision is None: + return True + with self._condition: + if ( + self._has_pending_reopen_locked(turn_id, revision) + or self._reopen_grace_remaining_locked( + turn_id, + revision, + ) + > 0 + ): + return None + latest = self._latest_revision.get(turn_id, revision) + if revision != latest: + return False + self._committed_revision[turn_id] = revision + logger.debug("Committed speculative turn %s revision %d", turn_id, revision) + self._condition.notify_all() + return True + + def has_pending_reopen(self, turn_id: str | None, revision: int | None) -> bool: + if turn_id is None or revision is None: + return False + with self._condition: + return self._has_pending_reopen_locked(turn_id, revision) + + def has_pending_reopen_or_grace(self, turn_id: str | None, revision: int | None) -> bool: + if turn_id is None or revision is None: + return False + with self._condition: + return ( + self._has_pending_reopen_locked(turn_id, revision) + or self._reopen_grace_remaining_locked( + turn_id, + revision, + ) + > 0 + ) + + def start_reopen_grace(self, turn_id: str | None, revision: int | None, grace_s: float) -> None: + if turn_id is None or revision is None or grace_s <= 0: + return + with self._condition: + if self._latest_revision.get(turn_id, revision) != revision: + return + if self._committed_revision.get(turn_id, -1) >= revision: + return + deadline = time.monotonic() + grace_s + existing = self._reopen_grace.get(turn_id) + if existing is None or existing.revision != revision or deadline > existing.deadline: + self._reopen_grace[turn_id] = _ReopenGrace(revision=revision, deadline=deadline) + logger.debug( + "Started speculative reopen grace for turn %s revision %d: %.0fms", + turn_id, + revision, + grace_s * 1000, + ) + self._condition.notify_all() + + def is_latest_after_stability_window( + self, + turn_id: str | None, + revision: int | None, + settle_s: float, + ) -> bool: + if turn_id is None or revision is None: + return True + if settle_s <= 0: + return self.is_latest_after_pending_reopen(turn_id, revision) + with self._condition: + deadline = time.monotonic() + settle_s + while self._latest_revision.get(turn_id, revision) == revision: + if self._has_pending_reopen_locked(turn_id, revision): + break + remaining = deadline - time.monotonic() + if remaining <= 0: + break + self._condition.wait(remaining) + self._wait_for_pending_reopen_locked(turn_id, revision, self._PENDING_REOPEN_WAIT_TIMEOUT_S) + return self._latest_revision.get(turn_id, revision) == revision + + def commit(self, turn_id: str | None, revision: int | None) -> None: + if turn_id is None or revision is None: + return + with self._condition: + pending = self._pending_reopen.get(turn_id) + if pending is not None and pending.base_revision == revision: + logger.debug( + "Deferring speculative turn %s revision %d commit while reopen is pending", turn_id, revision + ) + return + latest = self._latest_revision.get(turn_id, revision) + if revision == latest: + self._committed_revision[turn_id] = revision + logger.debug("Committed speculative turn %s revision %d", turn_id, revision) + self._condition.notify_all() + + def is_committed(self, turn_id: str | None, revision: int | None = None) -> bool: + if turn_id is None: + return False + with self._condition: + committed = self._committed_revision.get(turn_id) + if committed is None: + return False + return revision is None or committed >= revision + + def begin_reopen_candidate(self, turn_id: str | None, revision: int | None) -> int | None: + if turn_id is None or revision is None: + return None + with self._condition: + if self._committed_revision.get(turn_id, -1) >= revision: + return None + if self._latest_revision.get(turn_id, revision) != revision: + return None + + pending = self._pending_reopen.get(turn_id) + if pending is not None: + if pending.base_revision == revision: + return pending.candidate_revision + return None + + candidate_revision = revision + 1 + self._pending_reopen[turn_id] = _PendingReopen( + base_revision=revision, + candidate_revision=candidate_revision, + ) + logger.debug( + "Started speculative reopen candidate for turn %s revision %d -> %d", + turn_id, + revision, + candidate_revision, + ) + self._condition.notify_all() + return candidate_revision + + def confirm_reopen_candidate( + self, + turn_id: str | None, + base_revision: int | None, + candidate_revision: int | None, + ) -> bool: + if turn_id is None or base_revision is None or candidate_revision is None: + return False + with self._condition: + pending = self._pending_reopen.get(turn_id) + if ( + pending is None + or pending.base_revision != base_revision + or pending.candidate_revision != candidate_revision + ): + return False + if self._committed_revision.get(turn_id, -1) >= base_revision: + del self._pending_reopen[turn_id] + self._prune_tracked_turns() + self._condition.notify_all() + return False + if self._latest_revision.get(turn_id, base_revision) != base_revision: + del self._pending_reopen[turn_id] + self._prune_tracked_turns() + self._condition.notify_all() + return False + + self._latest_revision[turn_id] = candidate_revision + self._latest_revision.move_to_end(turn_id) + del self._pending_reopen[turn_id] + self._prune_tracked_turns() + logger.debug( + "Confirmed speculative reopen candidate for turn %s revision %d", + turn_id, + candidate_revision, + ) + self._condition.notify_all() + return True + + def cancel_reopen_candidate(self, turn_id: str | None, candidate_revision: int | None = None) -> None: + if turn_id is None: + return + with self._condition: + pending = self._pending_reopen.get(turn_id) + if pending is None: + return + if candidate_revision is not None and pending.candidate_revision != candidate_revision: + return + del self._pending_reopen[turn_id] + self._prune_tracked_turns() + logger.debug("Cancelled speculative reopen candidate for turn %s", turn_id) + self._condition.notify_all() + + def wait_for_pending_reopen( + self, + turn_id: str | None, + revision: int | None, + timeout_s: float = _PENDING_REOPEN_WAIT_TIMEOUT_S, + ) -> None: + if turn_id is None or revision is None: + return + with self._condition: + self._wait_for_pending_reopen_locked(turn_id, revision, timeout_s) + + def _has_pending_reopen_locked(self, turn_id: str, revision: int) -> bool: + pending = self._pending_reopen.get(turn_id) + return pending is not None and pending.base_revision == revision + + def _reopen_grace_remaining_locked(self, turn_id: str, revision: int) -> float: + grace = self._reopen_grace.get(turn_id) + if grace is None or grace.revision != revision: + return 0.0 + if self._latest_revision.get(turn_id, revision) != revision: + del self._reopen_grace[turn_id] + return 0.0 + remaining = grace.deadline - time.monotonic() + if remaining <= 0: + del self._reopen_grace[turn_id] + self._prune_tracked_turns() + return 0.0 + return remaining + + def _wait_for_reopen_gate_locked(self, turn_id: str, revision: int) -> None: + while self._latest_revision.get(turn_id, revision) == revision: + self._wait_for_pending_reopen_locked(turn_id, revision, self._PENDING_REOPEN_WAIT_TIMEOUT_S) + if self._latest_revision.get(turn_id, revision) != revision: + return + remaining = self._reopen_grace_remaining_locked(turn_id, revision) + if remaining <= 0: + return + logger.debug("Waiting for speculative reopen grace turn=%s rev=%s", turn_id, revision) + self._condition.wait(remaining) + + def _wait_for_pending_reopen_locked(self, turn_id: str, revision: int, timeout_s: float) -> None: + deadline = time.monotonic() + timeout_s + pending = self._pending_reopen.get(turn_id) + if pending is None or pending.base_revision != revision: + return + logger.debug("Waiting for pending speculative reopen turn=%s rev=%s", turn_id, revision) + while pending is not None and pending.base_revision == revision: + remaining = deadline - time.monotonic() + if remaining <= 0: + logger.warning("Timed out waiting for pending speculative reopen turn=%s rev=%s", turn_id, revision) + if self._pending_reopen.get(turn_id) == pending: + del self._pending_reopen[turn_id] + self._prune_tracked_turns() + self._condition.notify_all() + return + self._condition.wait(remaining) + pending = self._pending_reopen.get(turn_id) + + def _prune_tracked_turns(self) -> None: + if self._max_tracked_turns <= 0: + return + + self._drop_expired_reopen_graces_locked() + prunable_turn_ids = [ + turn_id + for turn_id in self._latest_revision + if turn_id not in self._pending_reopen and turn_id not in self._reopen_grace + ] + while len(prunable_turn_ids) > self._max_tracked_turns: + turn_id = prunable_turn_ids.pop(0) + self._latest_revision.pop(turn_id, None) + self._committed_revision.pop(turn_id, None) + self._reopen_grace.pop(turn_id, None) + + def _drop_expired_reopen_graces_locked(self) -> None: + now = time.monotonic() + for turn_id, grace in list(self._reopen_grace.items()): + if self._latest_revision.get(turn_id, grace.revision) != grace.revision or grace.deadline <= now: + del self._reopen_grace[turn_id] + + def reset(self) -> None: + with self._condition: + self._latest_revision.clear() + self._committed_revision.clear() + self._pending_reopen.clear() + self._reopen_grace.clear() + self._condition.notify_all() diff --git a/speech_to_speech/s2s_pipeline.py b/speech_to_speech/s2s_pipeline.py new file mode 100644 index 0000000..613ec8f --- /dev/null +++ b/speech_to_speech/s2s_pipeline.py @@ -0,0 +1,1094 @@ +import argparse +import json +import logging +import os +import signal +import sys +from copy import copy, deepcopy +from dataclasses import dataclass +from pathlib import Path +from queue import Queue +from sys import platform +from threading import Event +from types import FrameType +from typing import Any, Optional + +import nltk +import torch +from openai.types.realtime import RealtimeSessionCreateRequest +from rich.console import Console +from transformers import HfArgumentParser + +from speech_to_speech.api.openai_realtime.pipeline_unit import PipelineUnit +from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig +from speech_to_speech.arguments_classes.chat_completions_language_model_arguments import ( + ChatCompletionsLanguageModelHandlerArguments, +) +from speech_to_speech.arguments_classes.chat_tts_arguments import ChatTTSHandlerArguments +from speech_to_speech.arguments_classes.facebookmms_tts_arguments import FacebookMMSTTSHandlerArguments +from speech_to_speech.arguments_classes.faster_whisper_stt_arguments import ( + FasterWhisperSTTHandlerArguments, +) +from speech_to_speech.arguments_classes.kokoro_tts_arguments import KokoroTTSHandlerArguments +from speech_to_speech.arguments_classes.language_model_arguments import LanguageModelHandlerArguments +from speech_to_speech.arguments_classes.mlx_audio_whisper_arguments import ( + MLXAudioWhisperSTTHandlerArguments, +) +from speech_to_speech.arguments_classes.module_arguments import ModuleArguments +from speech_to_speech.arguments_classes.paraformer_stt_arguments import ParaformerSTTHandlerArguments +from speech_to_speech.arguments_classes.parakeet_tdt_arguments import ( + ParakeetTDTSTTHandlerArguments, +) +from speech_to_speech.arguments_classes.pocket_tts_arguments import PocketTTSHandlerArguments +from speech_to_speech.arguments_classes.qwen3_tts_arguments import Qwen3TTSHandlerArguments +from speech_to_speech.arguments_classes.responses_api_language_model_arguments import ( + ResponsesApiLanguageModelHandlerArguments, +) +from speech_to_speech.arguments_classes.socket_receiver_arguments import SocketReceiverArguments +from speech_to_speech.arguments_classes.socket_sender_arguments import SocketSenderArguments +from speech_to_speech.arguments_classes.vad_arguments import VADHandlerArguments +from speech_to_speech.arguments_classes.websocket_streamer_arguments import WebSocketStreamerArguments +from speech_to_speech.arguments_classes.whisper_stt_arguments import WhisperSTTHandlerArguments +from speech_to_speech.baseHandler import BaseHandler +from speech_to_speech.LLM.chat import Chat +from speech_to_speech.pipeline.cancel_scope import CancelScope +from speech_to_speech.pipeline.handler_types import LLMIn, LLMOut, STTIn, STTOut, TTSIn, TTSOut +from speech_to_speech.pipeline.queue_types import ( + AudioInItem, + AudioOutItem, + LMOutItem, + STTOutItem, + TextEventItem, + TextPromptItem, + TTSInItem, + VADOutItem, +) +from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker +from speech_to_speech.STT.transcription_notifier import TranscriptionNotifier +from speech_to_speech.utils.thread_manager import ThreadManager +from speech_to_speech.VAD.vad_handler import VADHandler + +# Ensure that the necessary NLTK resources are available +try: + nltk.data.find("tokenizers/punkt_tab") +except (LookupError, OSError): + nltk.download("punkt_tab") +try: + nltk.data.find("tokenizers/averaged_perceptron_tagger_eng") +except (LookupError, OSError): + nltk.download("averaged_perceptron_tagger_eng") + +# caching allows ~50% compilation time reduction +# see https://docs.google.com/document/d/1y5CRfMLdwEoF1nTk9q8qEu1mgMUuUtvhklPKJ2emLU8/edit#heading=h.o2asbxsrp1ma +CURRENT_DIR = Path(__file__).resolve().parent +os.environ["TORCHINDUCTOR_CACHE_DIR"] = os.path.join(CURRENT_DIR, "tmp") + +console = Console() +logger = logging.getLogger(__name__) +logging.getLogger("numba").setLevel(logging.WARNING) # quiet down numba logs + + +@dataclass +class ParsedArguments: + module_kwargs: ModuleArguments + socket_receiver_kwargs: SocketReceiverArguments + socket_sender_kwargs: SocketSenderArguments + websocket_streamer_kwargs: WebSocketStreamerArguments + vad_handler_kwargs: VADHandlerArguments + whisper_stt_handler_kwargs: WhisperSTTHandlerArguments + paraformer_stt_handler_kwargs: ParaformerSTTHandlerArguments + faster_whisper_stt_handler_kwargs: FasterWhisperSTTHandlerArguments + mlx_audio_whisper_stt_handler_kwargs: MLXAudioWhisperSTTHandlerArguments + parakeet_tdt_stt_handler_kwargs: ParakeetTDTSTTHandlerArguments + language_model_handler_kwargs: LanguageModelHandlerArguments + responses_api_language_model_handler_kwargs: ResponsesApiLanguageModelHandlerArguments + chat_tts_handler_kwargs: ChatTTSHandlerArguments + facebook_mms_tts_handler_kwargs: FacebookMMSTTSHandlerArguments + pocket_tts_handler_kwargs: PocketTTSHandlerArguments + kokoro_tts_handler_kwargs: KokoroTTSHandlerArguments + qwen3_tts_handler_kwargs: Qwen3TTSHandlerArguments + + +def rename_args(args: Any, prefix: str) -> None: + """ + Rename arguments by removing the prefix and prepares the gen_kwargs. + """ + gen_kwargs = {} + for key in copy(args.__dict__): + if key.startswith(prefix): + value = args.__dict__.pop(key) + new_key = key[len(prefix) + 1 :] # Remove prefix and underscore + if new_key.startswith("gen_"): + gen_kwargs[new_key[4:]] = value # Remove 'gen_' and add to dict + else: + args.__dict__[new_key] = value + + args.__dict__["gen_kwargs"] = gen_kwargs + + +def parse_arguments() -> ParsedArguments: + # Pre-parse to determine which LM backend is selected, so only one of the + # mutually exclusive LM argument classes is registered with HfArgumentParser + # (avoids duplicate field names from the shared LanguageModelBaseArguments base). + # chat-completions reuses the responses-api connection fields via subclassing. + _backend_lm_class = { + "responses-api": ResponsesApiLanguageModelHandlerArguments, + "chat-completions": ChatCompletionsLanguageModelHandlerArguments, + } + _is_json = len(sys.argv) == 2 and sys.argv[1].endswith(".json") + if _is_json: + with open(sys.argv[1]) as _f: + _backend = json.load(_f).get("llm_backend") + else: + _pre = argparse.ArgumentParser(add_help=False) + _pre.add_argument("--llm_backend", default="responses-api") + _backend = _pre.parse_known_args()[0].llm_backend + + _lm_class = _backend_lm_class.get(_backend, LanguageModelHandlerArguments) + logger.debug("LLM backend pre-parse: backend=%s, registering %s", _backend, _lm_class.__name__) + + parser = HfArgumentParser( + ( # type: ignore[arg-type] + ModuleArguments, + SocketReceiverArguments, + SocketSenderArguments, + WebSocketStreamerArguments, + VADHandlerArguments, + WhisperSTTHandlerArguments, + ParaformerSTTHandlerArguments, + FasterWhisperSTTHandlerArguments, + MLXAudioWhisperSTTHandlerArguments, + ParakeetTDTSTTHandlerArguments, + _lm_class, + ChatTTSHandlerArguments, + FacebookMMSTTSHandlerArguments, + PocketTTSHandlerArguments, + KokoroTTSHandlerArguments, + Qwen3TTSHandlerArguments, + ) + ) + + if _is_json: + parsed = parser.parse_json_file(json_file=os.path.abspath(sys.argv[1]), allow_extra_keys=True) + else: + parsed = parser.parse_args_into_dataclasses() + + # Build a {type: instance} lookup so field assignment is order-independent. + by_type: dict[type, Any] = {type(obj): obj for obj in parsed} + logger.debug("Parsed %d argument classes: %s", len(by_type), [t.__name__ for t in by_type]) + + return ParsedArguments( + module_kwargs=by_type[ModuleArguments], + socket_receiver_kwargs=by_type[SocketReceiverArguments], + socket_sender_kwargs=by_type[SocketSenderArguments], + websocket_streamer_kwargs=by_type[WebSocketStreamerArguments], + vad_handler_kwargs=by_type[VADHandlerArguments], + whisper_stt_handler_kwargs=by_type[WhisperSTTHandlerArguments], + paraformer_stt_handler_kwargs=by_type[ParaformerSTTHandlerArguments], + faster_whisper_stt_handler_kwargs=by_type[FasterWhisperSTTHandlerArguments], + mlx_audio_whisper_stt_handler_kwargs=by_type[MLXAudioWhisperSTTHandlerArguments], + parakeet_tdt_stt_handler_kwargs=by_type[ParakeetTDTSTTHandlerArguments], + language_model_handler_kwargs=by_type.get(LanguageModelHandlerArguments, LanguageModelHandlerArguments()), + # The OpenAI-compatible slot holds whichever class was registered: + # ChatCompletions... (a subclass) for chat-completions, else ResponsesApi.... + responses_api_language_model_handler_kwargs=by_type.get( + ChatCompletionsLanguageModelHandlerArguments, + by_type.get(ResponsesApiLanguageModelHandlerArguments, ResponsesApiLanguageModelHandlerArguments()), + ), + chat_tts_handler_kwargs=by_type[ChatTTSHandlerArguments], + facebook_mms_tts_handler_kwargs=by_type[FacebookMMSTTSHandlerArguments], + pocket_tts_handler_kwargs=by_type[PocketTTSHandlerArguments], + kokoro_tts_handler_kwargs=by_type[KokoroTTSHandlerArguments], + qwen3_tts_handler_kwargs=by_type[Qwen3TTSHandlerArguments], + ) + + +def setup_logger(log_level: str) -> None: + global logger + from speech_to_speech.pipeline.log_context import PipelineLogFilter + + logging.basicConfig( + level=log_level.upper(), + format="%(asctime)s - %(pipeline_prefix)s%(name)s - %(levelname)s - %(message)s", + ) + # Attach the filter to every existing handler so each LogRecord gets a + # `pipeline_prefix` attribute (matching the format string above). + pipeline_filter = PipelineLogFilter() + for h in logging.getLogger().handlers: + h.addFilter(pipeline_filter) + + logger = logging.getLogger(__name__) + + # torch compile logs + if log_level == "debug": + torch._logging.set_logs(graph_breaks=True, recompiles=True, cudagraphs=True) + + +MLX_DEFAULT_LM_MODEL = "mlx-community/Qwen3-4B-Instruct-2507-bf16" +TRANSFORMERS_DEFAULT_LM_MODEL = "Qwen/Qwen3-4B-Instruct-2507" + + +def optimal_mac_settings(mac_optimal_settings: bool, *handler_kwargs: Any) -> None: + if mac_optimal_settings: + for kwargs in handler_kwargs: + if hasattr(kwargs, "device"): + kwargs.device = "mps" + if hasattr(kwargs, "mode"): + kwargs.mode = "local" + if hasattr(kwargs, "stt"): + kwargs.stt = "parakeet-tdt" + if hasattr(kwargs, "llm_backend"): + kwargs.llm_backend = "mlx-lm" + if hasattr(kwargs, "tts"): + kwargs.tts = "qwen3" + if hasattr(kwargs, "model_name"): + if kwargs.model_name == TRANSFORMERS_DEFAULT_LM_MODEL: + kwargs.model_name = MLX_DEFAULT_LM_MODEL + + +def check_mac_settings(module_kwargs: ModuleArguments) -> None: + if platform == "darwin": + if module_kwargs.device == "cuda": + raise ValueError("Cannot use CUDA on macOS. Please set the device to 'cpu' or 'mps'.") + if module_kwargs.llm_backend != "mlx-lm": + logger.warning( + "For macOS users, it is recommended to use mlx-lm. You can activate it by passing --llm_backend mlx-lm." + ) + if module_kwargs.tts not in ("pocket", "kokoro", "qwen3"): + logger.warning( + "For macOS users, it is recommended to use qwen3 for TTS (pocket and kokoro are also valid options)." + ) + + +def overwrite_device_argument(common_device: Optional[str], *handler_kwargs: Any) -> None: + if common_device: + for kwargs in handler_kwargs: + if hasattr(kwargs, "llm_device"): + kwargs.llm_device = common_device + if hasattr(kwargs, "tts_device"): + kwargs.tts_device = common_device + if hasattr(kwargs, "stt_device"): + kwargs.stt_device = common_device + if hasattr(kwargs, "paraformer_stt_device"): + kwargs.paraformer_stt_device = common_device + if hasattr(kwargs, "facebook_mms_device"): + kwargs.facebook_mms_device = common_device + if hasattr(kwargs, "qwen3_tts_device"): + kwargs.qwen3_tts_device = common_device + + +def prepare_module_args(module_kwargs: ModuleArguments, *handler_kwargs: Any) -> None: + optimal_mac_settings(module_kwargs.local_mac_optimal_settings, module_kwargs, *handler_kwargs) + if module_kwargs.tts is None: + module_kwargs.tts = "qwen3" + if platform == "darwin": + check_mac_settings(module_kwargs) + overwrite_device_argument(module_kwargs.device, *handler_kwargs) + + +def prepare_all_args( + module_kwargs: ModuleArguments, + whisper_stt_handler_kwargs: WhisperSTTHandlerArguments, + paraformer_stt_handler_kwargs: ParaformerSTTHandlerArguments, + faster_whisper_stt_handler_kwargs: FasterWhisperSTTHandlerArguments, + mlx_audio_whisper_stt_handler_kwargs: MLXAudioWhisperSTTHandlerArguments, + parakeet_tdt_stt_handler_kwargs: ParakeetTDTSTTHandlerArguments, + language_model_handler_kwargs: LanguageModelHandlerArguments, + responses_api_language_model_handler_kwargs: ResponsesApiLanguageModelHandlerArguments, + chat_tts_handler_kwargs: ChatTTSHandlerArguments, + facebook_mms_tts_handler_kwargs: FacebookMMSTTSHandlerArguments, + pocket_tts_handler_kwargs: PocketTTSHandlerArguments, + kokoro_tts_handler_kwargs: KokoroTTSHandlerArguments, + qwen3_tts_handler_kwargs: Qwen3TTSHandlerArguments, +) -> None: + prepare_module_args( + module_kwargs, + whisper_stt_handler_kwargs, + faster_whisper_stt_handler_kwargs, + paraformer_stt_handler_kwargs, + mlx_audio_whisper_stt_handler_kwargs, + parakeet_tdt_stt_handler_kwargs, + language_model_handler_kwargs, + responses_api_language_model_handler_kwargs, + chat_tts_handler_kwargs, + facebook_mms_tts_handler_kwargs, + pocket_tts_handler_kwargs, + kokoro_tts_handler_kwargs, + qwen3_tts_handler_kwargs, + ) + + rename_args(whisper_stt_handler_kwargs, "stt") + rename_args(faster_whisper_stt_handler_kwargs, "faster_whisper_stt") + rename_args(paraformer_stt_handler_kwargs, "paraformer_stt") + rename_args(mlx_audio_whisper_stt_handler_kwargs, "mlx_audio_whisper") + rename_args(parakeet_tdt_stt_handler_kwargs, "parakeet_tdt") + rename_args(language_model_handler_kwargs, "llm") + rename_args(responses_api_language_model_handler_kwargs, "responses_api") + rename_args(chat_tts_handler_kwargs, "chat_tts") + rename_args(facebook_mms_tts_handler_kwargs, "facebook_mms") + rename_args(pocket_tts_handler_kwargs, "pocket_tts") + rename_args(kokoro_tts_handler_kwargs, "kokoro") + rename_args(qwen3_tts_handler_kwargs, "qwen3_tts") + + +def initialize_queues_and_events() -> dict[str, Any]: + return { + "stop_event": Event(), + "should_listen": Event(), + "response_playing": Event(), + "cancel_scope": CancelScope(), + "recv_audio_chunks_queue": Queue[AudioInItem](), + "send_audio_chunks_queue": Queue[AudioOutItem](), + "spoken_prompt_queue": Queue[VADOutItem](), + "stt_output_queue": Queue[STTOutItem](), + "text_prompt_queue": Queue[TextPromptItem](), + "lm_response_queue": Queue[LMOutItem](), + "lm_processed_queue": Queue[TTSInItem](), # NEW: LLM -> LM processor -> TTS + "text_output_queue": Queue[TextEventItem](), # NEW: for text messages to WebSocket + } + + +def _build_pipeline_handlers( + *, + stop_event: Event, + should_listen: Event, + recv_audio_chunks_queue: Queue[AudioInItem], + spoken_prompt_queue: Queue[VADOutItem], + stt_output_queue: Queue[STTOutItem], + text_prompt_queue: Queue[TextPromptItem], + lm_response_queue: Queue[LMOutItem], + lm_processed_queue: Queue[TTSInItem], + send_audio_chunks_queue: Queue[AudioOutItem], + text_output_queue: Optional[Queue[TextEventItem]], + transcription_notifier_setup: dict[str, Any], + module_kwargs: ModuleArguments, + vad_handler_kwargs: VADHandlerArguments, + whisper_stt_handler_kwargs: WhisperSTTHandlerArguments, + faster_whisper_stt_handler_kwargs: FasterWhisperSTTHandlerArguments, + paraformer_stt_handler_kwargs: ParaformerSTTHandlerArguments, + mlx_audio_whisper_stt_handler_kwargs: MLXAudioWhisperSTTHandlerArguments, + parakeet_tdt_stt_handler_kwargs: ParakeetTDTSTTHandlerArguments, + language_model_handler_kwargs: LanguageModelHandlerArguments, + responses_api_language_model_handler_kwargs: ResponsesApiLanguageModelHandlerArguments, + chat_tts_handler_kwargs: ChatTTSHandlerArguments, + facebook_mms_tts_handler_kwargs: FacebookMMSTTSHandlerArguments, + pocket_tts_handler_kwargs: PocketTTSHandlerArguments, + kokoro_tts_handler_kwargs: KokoroTTSHandlerArguments, + qwen3_tts_handler_kwargs: Qwen3TTSHandlerArguments, + speculative_turns: SpeculativeTurnTracker | None = None, +) -> list[Any]: + """Build the shared handler chain: VAD → STT → TranscriptionNotifier → LM → LMOutputProcessor → TTS. + + Callers own the queues, events, and any per-mode mutations to handler kwargs (cancel_scope, + text_output_queue, live transcription flags). `transcription_notifier_setup` differs by mode: + realtime mode passes a service-driven setup; non-realtime modes inject a RuntimeConfig. + """ + from speech_to_speech.LLM.lm_output_processor import LMOutputProcessor + + vad = VADHandler( + stop_event, + queue_in=recv_audio_chunks_queue, + queue_out=spoken_prompt_queue, + setup_args=(should_listen,), + setup_kwargs=vars(vad_handler_kwargs), + ) + + transcription_notifier = TranscriptionNotifier( + stop_event, + queue_in=stt_output_queue, + queue_out=text_prompt_queue, # type: ignore[arg-type] + setup_kwargs=transcription_notifier_setup, + ) + + stt = get_stt_handler( + module_kwargs, + stop_event, + spoken_prompt_queue, + stt_output_queue, + speculative_turns, + whisper_stt_handler_kwargs, + faster_whisper_stt_handler_kwargs, + paraformer_stt_handler_kwargs, + mlx_audio_whisper_stt_handler_kwargs, + parakeet_tdt_stt_handler_kwargs, + ) + + lm = get_llm_handler( + module_kwargs, + stop_event, + text_prompt_queue, + lm_response_queue, + language_model_handler_kwargs, + responses_api_language_model_handler_kwargs, + ) + + lm_processor = LMOutputProcessor( + stop_event, + queue_in=lm_response_queue, + queue_out=lm_processed_queue, + setup_kwargs={"text_output_queue": text_output_queue, "speculative_turns": speculative_turns}, + ) + + tts = get_tts_handler( + module_kwargs, + stop_event, + lm_processed_queue, + send_audio_chunks_queue, + should_listen, + chat_tts_handler_kwargs, + facebook_mms_tts_handler_kwargs, + pocket_tts_handler_kwargs, + kokoro_tts_handler_kwargs, + qwen3_tts_handler_kwargs, + ) + + return [vad, stt, transcription_notifier, lm, lm_processor, tts] + + +def _build_realtime_pipeline_unit( + *, + index: int, + stop_event: Event, + module_kwargs: ModuleArguments, + vad_handler_kwargs: VADHandlerArguments, + whisper_stt_handler_kwargs: WhisperSTTHandlerArguments, + faster_whisper_stt_handler_kwargs: FasterWhisperSTTHandlerArguments, + paraformer_stt_handler_kwargs: ParaformerSTTHandlerArguments, + mlx_audio_whisper_stt_handler_kwargs: MLXAudioWhisperSTTHandlerArguments, + parakeet_tdt_stt_handler_kwargs: ParakeetTDTSTTHandlerArguments, + language_model_handler_kwargs: LanguageModelHandlerArguments, + responses_api_language_model_handler_kwargs: ResponsesApiLanguageModelHandlerArguments, + chat_tts_handler_kwargs: ChatTTSHandlerArguments, + facebook_mms_tts_handler_kwargs: FacebookMMSTTSHandlerArguments, + pocket_tts_handler_kwargs: PocketTTSHandlerArguments, + kokoro_tts_handler_kwargs: KokoroTTSHandlerArguments, + qwen3_tts_handler_kwargs: Qwen3TTSHandlerArguments, +) -> "PipelineUnit": + """Build one isolated realtime pipeline (own queues, events, service, handlers). + + Returns a PipelineUnit that lives inside RealtimeServer's pool. Handler instances + are returned in `unit.handlers`; the caller threads them via ThreadManager. No uvicorn + is created here — the single server is owned by RealtimeServer. + """ + from speech_to_speech.api.openai_realtime.service import RealtimeService + from speech_to_speech.api.openai_realtime.delay_fillers import ( + load_delay_filler_config, + pregenerate_delay_filler_clips, + ) + + # Per-unit deep copies so mutations (cancel_scope, text_output_queue) don't bleed across pipelines. + vad_kw = deepcopy(vad_handler_kwargs) + whisper_kw = deepcopy(whisper_stt_handler_kwargs) + faster_whisper_kw = deepcopy(faster_whisper_stt_handler_kwargs) + paraformer_kw = deepcopy(paraformer_stt_handler_kwargs) + mlx_audio_whisper_kw = deepcopy(mlx_audio_whisper_stt_handler_kwargs) + parakeet_kw = deepcopy(parakeet_tdt_stt_handler_kwargs) + lm_kw = deepcopy(language_model_handler_kwargs) + responses_api_kw = deepcopy(responses_api_language_model_handler_kwargs) + chat_tts_kw = deepcopy(chat_tts_handler_kwargs) + facebook_mms_kw = deepcopy(facebook_mms_tts_handler_kwargs) + pocket_tts_kw = deepcopy(pocket_tts_handler_kwargs) + kokoro_tts_kw = deepcopy(kokoro_tts_handler_kwargs) + qwen3_tts_kw = deepcopy(qwen3_tts_handler_kwargs) + + should_listen = Event() + response_playing = Event() + cancel_scope = CancelScope() + speculative_turns = SpeculativeTurnTracker() + recv_audio_chunks_queue: Queue[AudioInItem] = Queue() + send_audio_chunks_queue: Queue[AudioOutItem] = Queue() + spoken_prompt_queue: Queue[VADOutItem] = Queue() + stt_output_queue: Queue[STTOutItem] = Queue() + text_prompt_queue: Queue[TextPromptItem] = Queue() + lm_response_queue: Queue[LMOutItem] = Queue() + lm_processed_queue: Queue[TTSInItem] = Queue() + text_output_queue: Queue[TextEventItem] = Queue() + + vars(vad_kw)["text_output_queue"] = text_output_queue + vars(vad_kw)["speculative_turns"] = speculative_turns + for kw in ( + lm_kw, + responses_api_kw, + kokoro_tts_kw, + qwen3_tts_kw, + pocket_tts_kw, + chat_tts_kw, + facebook_mms_kw, + ): + vars(kw)["cancel_scope"] = cancel_scope + vars(kw)["speculative_turns"] = speculative_turns + + if module_kwargs.llm_backend in ("responses-api", "chat-completions"): + chat_size = vars(responses_api_kw).get("chat_size", 10) + else: + chat_size = vars(lm_kw).get("chat_size", 10) + + service = RealtimeService( + text_prompt_queue=text_prompt_queue, + should_listen=should_listen, + chat_size=chat_size, + speculative_turns=speculative_turns, + ) + + if module_kwargs.enable_live_transcription: + vad_kw.enable_realtime_transcription = True + vad_kw.realtime_processing_pause = module_kwargs.live_transcription_update_interval + + handlers = _build_pipeline_handlers( + stop_event=stop_event, + should_listen=should_listen, + recv_audio_chunks_queue=recv_audio_chunks_queue, + spoken_prompt_queue=spoken_prompt_queue, + stt_output_queue=stt_output_queue, + text_prompt_queue=text_prompt_queue, + lm_response_queue=lm_response_queue, + lm_processed_queue=lm_processed_queue, + send_audio_chunks_queue=send_audio_chunks_queue, + text_output_queue=text_output_queue, + transcription_notifier_setup={ + "text_output_queue": text_output_queue, + "should_listen": should_listen, + }, + module_kwargs=module_kwargs, + vad_handler_kwargs=vad_kw, + whisper_stt_handler_kwargs=whisper_kw, + faster_whisper_stt_handler_kwargs=faster_whisper_kw, + paraformer_stt_handler_kwargs=paraformer_kw, + mlx_audio_whisper_stt_handler_kwargs=mlx_audio_whisper_kw, + parakeet_tdt_stt_handler_kwargs=parakeet_kw, + language_model_handler_kwargs=lm_kw, + responses_api_language_model_handler_kwargs=responses_api_kw, + chat_tts_handler_kwargs=chat_tts_kw, + facebook_mms_tts_handler_kwargs=facebook_mms_kw, + pocket_tts_handler_kwargs=pocket_tts_kw, + kokoro_tts_handler_kwargs=kokoro_tts_kw, + qwen3_tts_handler_kwargs=qwen3_tts_kw, + speculative_turns=speculative_turns, + ) + for h in handlers: + h.pipeline_index = index + + delay_filler_cfg = load_delay_filler_config() + delay_filler_short_clips: list[bytes] = [] + delay_filler_full_clips: list[bytes] = [] + delay_filler_threshold_s = 0.0 + if delay_filler_cfg.enabled: + delay_filler_short_clips = pregenerate_delay_filler_clips(handlers[-1], delay_filler_cfg.short_phrases) + delay_filler_full_clips = pregenerate_delay_filler_clips(handlers[-1], delay_filler_cfg.full_phrases) + if delay_filler_short_clips or delay_filler_full_clips: + delay_filler_threshold_s = delay_filler_cfg.threshold_s + + return PipelineUnit( + index=index, + service=service, + cancel_scope=cancel_scope, + should_listen=should_listen, + response_playing=response_playing, + input_queue=recv_audio_chunks_queue, + output_queue=send_audio_chunks_queue, + text_output_queue=text_output_queue, + text_prompt_queue=text_prompt_queue, + handlers=handlers, + delay_filler_threshold_s=delay_filler_threshold_s, + delay_filler_short_clips=delay_filler_short_clips, + delay_filler_full_clips=delay_filler_full_clips, + ) + + +def build_pipeline( + module_kwargs: ModuleArguments, + socket_receiver_kwargs: SocketReceiverArguments, + socket_sender_kwargs: SocketSenderArguments, + websocket_streamer_kwargs: WebSocketStreamerArguments, + vad_handler_kwargs: VADHandlerArguments, + whisper_stt_handler_kwargs: WhisperSTTHandlerArguments, + faster_whisper_stt_handler_kwargs: FasterWhisperSTTHandlerArguments, + paraformer_stt_handler_kwargs: ParaformerSTTHandlerArguments, + mlx_audio_whisper_stt_handler_kwargs: MLXAudioWhisperSTTHandlerArguments, + parakeet_tdt_stt_handler_kwargs: ParakeetTDTSTTHandlerArguments, + language_model_handler_kwargs: LanguageModelHandlerArguments, + responses_api_language_model_handler_kwargs: ResponsesApiLanguageModelHandlerArguments, + chat_tts_handler_kwargs: ChatTTSHandlerArguments, + facebook_mms_tts_handler_kwargs: FacebookMMSTTSHandlerArguments, + pocket_tts_handler_kwargs: PocketTTSHandlerArguments, + kokoro_tts_handler_kwargs: KokoroTTSHandlerArguments, + qwen3_tts_handler_kwargs: Qwen3TTSHandlerArguments, + queues_and_events: dict[str, Any], +) -> ThreadManager: + stop_event = queues_and_events["stop_event"] + should_listen = queues_and_events["should_listen"] + recv_audio_chunks_queue = queues_and_events["recv_audio_chunks_queue"] + send_audio_chunks_queue = queues_and_events["send_audio_chunks_queue"] + spoken_prompt_queue = queues_and_events["spoken_prompt_queue"] + stt_output_queue = queues_and_events["stt_output_queue"] + text_prompt_queue = queues_and_events["text_prompt_queue"] + lm_response_queue = queues_and_events["lm_response_queue"] + lm_processed_queue = queues_and_events["lm_processed_queue"] + text_output_queue = ( + None # Only set for websocket/realtime modes; kept None otherwise to avoid unbounded queue growth + ) + + comms_handlers: list[Any] = [] + if module_kwargs.mode == "local": + from speech_to_speech.connections.local_audio_streamer import LocalAudioStreamer + + local_audio_streamer = LocalAudioStreamer( + input_queue=recv_audio_chunks_queue, + output_queue=send_audio_chunks_queue, + should_listen=should_listen, + ) + comms_handlers = [local_audio_streamer] + should_listen.set() + elif module_kwargs.mode == "websocket": + from speech_to_speech.connections.websocket_streamer import WebSocketStreamer + + text_output_queue = queues_and_events["text_output_queue"] + websocket_streamer = WebSocketStreamer( + stop_event, + input_queue=recv_audio_chunks_queue, + output_queue=send_audio_chunks_queue, + should_listen=should_listen, + text_output_queue=text_output_queue, + host=websocket_streamer_kwargs.ws_host, + port=websocket_streamer_kwargs.ws_port, + ) + comms_handlers = [websocket_streamer] + elif module_kwargs.mode == "realtime": + from speech_to_speech.api.openai_realtime.server import RealtimeServer + + pool_size = max(1, module_kwargs.num_pipelines) + pool = [ + _build_realtime_pipeline_unit( + index=i, + stop_event=stop_event, + module_kwargs=module_kwargs, + vad_handler_kwargs=vad_handler_kwargs, + whisper_stt_handler_kwargs=whisper_stt_handler_kwargs, + faster_whisper_stt_handler_kwargs=faster_whisper_stt_handler_kwargs, + paraformer_stt_handler_kwargs=paraformer_stt_handler_kwargs, + mlx_audio_whisper_stt_handler_kwargs=mlx_audio_whisper_stt_handler_kwargs, + parakeet_tdt_stt_handler_kwargs=parakeet_tdt_stt_handler_kwargs, + language_model_handler_kwargs=language_model_handler_kwargs, + responses_api_language_model_handler_kwargs=responses_api_language_model_handler_kwargs, + chat_tts_handler_kwargs=chat_tts_handler_kwargs, + facebook_mms_tts_handler_kwargs=facebook_mms_tts_handler_kwargs, + pocket_tts_handler_kwargs=pocket_tts_handler_kwargs, + kokoro_tts_handler_kwargs=kokoro_tts_handler_kwargs, + qwen3_tts_handler_kwargs=qwen3_tts_handler_kwargs, + ) + for i in range(pool_size) + ] + + realtime_server = RealtimeServer( + stop_event=stop_event, + pool=pool, + host=websocket_streamer_kwargs.ws_host, + port=websocket_streamer_kwargs.ws_port, + ) + + all_handlers: list[Any] = [realtime_server] + for unit in pool: + all_handlers.extend(unit.handlers) + return ThreadManager(all_handlers) + else: + from speech_to_speech.connections.socket_receiver import SocketReceiver + from speech_to_speech.connections.socket_sender import SocketSender + + comms_handlers = [ + SocketReceiver( + stop_event, + recv_audio_chunks_queue, + should_listen, + host=socket_receiver_kwargs.recv_host, + port=socket_receiver_kwargs.recv_port, + chunk_size=socket_receiver_kwargs.chunk_size, + ), + SocketSender( + stop_event, + send_audio_chunks_queue, + should_listen, + host=socket_sender_kwargs.send_host, + port=socket_sender_kwargs.send_port, + ), + ] + + # Set VAD realtime transcription parameters from module_kwargs + if module_kwargs.enable_live_transcription: + vad_handler_kwargs.enable_realtime_transcription = True + vad_handler_kwargs.realtime_processing_pause = module_kwargs.live_transcription_update_interval + + if module_kwargs.llm_backend in ("responses-api", "chat-completions"): + _lm_vars = vars(responses_api_language_model_handler_kwargs) + else: + _lm_vars = vars(language_model_handler_kwargs) + transcription_notifier_setup: dict[str, Any] = { + "text_output_queue": text_output_queue, + "should_listen": should_listen, + "runtime_config": RuntimeConfig( + chat=Chat(_lm_vars.get("chat_size", 30)), + session=RealtimeSessionCreateRequest( + type="realtime", + instructions=_lm_vars.get("init_chat_prompt"), + ), + ), + } + + pipeline_handlers = _build_pipeline_handlers( + stop_event=stop_event, + should_listen=should_listen, + recv_audio_chunks_queue=recv_audio_chunks_queue, + spoken_prompt_queue=spoken_prompt_queue, + stt_output_queue=stt_output_queue, + text_prompt_queue=text_prompt_queue, + lm_response_queue=lm_response_queue, + lm_processed_queue=lm_processed_queue, + send_audio_chunks_queue=send_audio_chunks_queue, + text_output_queue=text_output_queue, + transcription_notifier_setup=transcription_notifier_setup, + module_kwargs=module_kwargs, + vad_handler_kwargs=vad_handler_kwargs, + whisper_stt_handler_kwargs=whisper_stt_handler_kwargs, + faster_whisper_stt_handler_kwargs=faster_whisper_stt_handler_kwargs, + paraformer_stt_handler_kwargs=paraformer_stt_handler_kwargs, + mlx_audio_whisper_stt_handler_kwargs=mlx_audio_whisper_stt_handler_kwargs, + parakeet_tdt_stt_handler_kwargs=parakeet_tdt_stt_handler_kwargs, + language_model_handler_kwargs=language_model_handler_kwargs, + responses_api_language_model_handler_kwargs=responses_api_language_model_handler_kwargs, + chat_tts_handler_kwargs=chat_tts_handler_kwargs, + facebook_mms_tts_handler_kwargs=facebook_mms_tts_handler_kwargs, + pocket_tts_handler_kwargs=pocket_tts_handler_kwargs, + kokoro_tts_handler_kwargs=kokoro_tts_handler_kwargs, + qwen3_tts_handler_kwargs=qwen3_tts_handler_kwargs, + ) + + return ThreadManager([*comms_handlers, *pipeline_handlers]) + + +def get_stt_handler( + module_kwargs: ModuleArguments, + stop_event: Event, + spoken_prompt_queue: Queue[VADOutItem], + text_prompt_queue: Queue[STTOutItem], + speculative_turns: SpeculativeTurnTracker | None, + whisper_stt_handler_kwargs: WhisperSTTHandlerArguments, + faster_whisper_stt_handler_kwargs: FasterWhisperSTTHandlerArguments, + paraformer_stt_handler_kwargs: ParaformerSTTHandlerArguments, + mlx_audio_whisper_stt_handler_kwargs: MLXAudioWhisperSTTHandlerArguments, + parakeet_tdt_stt_handler_kwargs: ParakeetTDTSTTHandlerArguments, +) -> BaseHandler[STTIn, STTOut]: + from speech_to_speech.STT.base_stt_handler import BaseSTTHandler + + def with_speculative_turns(handler: BaseSTTHandler) -> BaseSTTHandler: + if speculative_turns is not None: + handler.speculative_turns = speculative_turns + return handler + + if module_kwargs.stt == "whisper": + from speech_to_speech.STT.whisper_stt_handler import WhisperSTTHandler + + return with_speculative_turns( + WhisperSTTHandler( + stop_event, + queue_in=spoken_prompt_queue, + queue_out=text_prompt_queue, + setup_kwargs=vars(whisper_stt_handler_kwargs), + ) + ) + elif module_kwargs.stt == "whisper-mlx": + from speech_to_speech.STT.lightning_whisper_mlx_handler import LightningWhisperSTTHandler + + return with_speculative_turns( + LightningWhisperSTTHandler( + stop_event, + queue_in=spoken_prompt_queue, + queue_out=text_prompt_queue, + setup_kwargs=vars(whisper_stt_handler_kwargs), + ) + ) + elif module_kwargs.stt == "mlx-audio-whisper": + from speech_to_speech.STT.mlx_audio_whisper_handler import MLXAudioWhisperSTTHandler + + # Merge MLX Audio Whisper kwargs with shared language parameter from Whisper kwargs + setup_kwargs = {**vars(mlx_audio_whisper_stt_handler_kwargs), "language": whisper_stt_handler_kwargs.language} + return with_speculative_turns( + MLXAudioWhisperSTTHandler( + stop_event, + queue_in=spoken_prompt_queue, + queue_out=text_prompt_queue, + setup_kwargs=setup_kwargs, + ) + ) + elif module_kwargs.stt == "paraformer": + from speech_to_speech.STT.paraformer_handler import ParaformerSTTHandler + + return with_speculative_turns( + ParaformerSTTHandler( + stop_event, + queue_in=spoken_prompt_queue, + queue_out=text_prompt_queue, + setup_kwargs=vars(paraformer_stt_handler_kwargs), + ) + ) + elif module_kwargs.stt == "faster-whisper": + from speech_to_speech.STT.faster_whisper_handler import FasterWhisperSTTHandler + + return with_speculative_turns( + FasterWhisperSTTHandler( + stop_event, + queue_in=spoken_prompt_queue, + queue_out=text_prompt_queue, + setup_kwargs=vars(faster_whisper_stt_handler_kwargs), + ) + ) + elif module_kwargs.stt == "parakeet-tdt": + from speech_to_speech.STT.parakeet_tdt_handler import ParakeetTDTSTTHandler + + # Add live transcription parameters to setup_kwargs + setup_kwargs = { + **vars(parakeet_tdt_stt_handler_kwargs), + "enable_live_transcription": module_kwargs.enable_live_transcription, + "live_transcription_update_interval": module_kwargs.live_transcription_update_interval, + } + + return with_speculative_turns( + ParakeetTDTSTTHandler( + stop_event, + queue_in=spoken_prompt_queue, + queue_out=text_prompt_queue, + setup_kwargs=setup_kwargs, + ) + ) + else: + raise ValueError( + "The STT should be either whisper, whisper-mlx, mlx-audio-whisper, faster-whisper, parakeet-tdt, or paraformer." + ) + + +def get_llm_handler( + module_kwargs: ModuleArguments, + stop_event: Event, + text_prompt_queue: Queue[TextPromptItem], + lm_response_queue: Queue[LMOutItem], + language_model_handler_kwargs: LanguageModelHandlerArguments, + responses_api_language_model_handler_kwargs: ResponsesApiLanguageModelHandlerArguments, +) -> BaseHandler[LLMIn, LLMOut]: + if module_kwargs.llm_backend == "responses-api": + from speech_to_speech.LLM.responses_api_language_model import ResponsesApiModelHandler + + return ResponsesApiModelHandler( + stop_event, + queue_in=text_prompt_queue, + queue_out=lm_response_queue, + setup_kwargs=vars(responses_api_language_model_handler_kwargs), + ) + + if module_kwargs.llm_backend == "chat-completions": + from speech_to_speech.LLM.chat_completions_language_model import ChatCompletionsApiModelHandler + + # Reuses the responses-api argument class (identical fields: model_name, + # base_url, api_key, stream, disable_thinking, ...). + return ChatCompletionsApiModelHandler( + stop_event, + queue_in=text_prompt_queue, + queue_out=lm_response_queue, + setup_kwargs=vars(responses_api_language_model_handler_kwargs), + ) + + if module_kwargs.llm_backend in ("transformers", "mlx-lm"): + lm_kwargs = vars(language_model_handler_kwargs) + is_vlm = lm_kwargs.pop("is_vlm", False) + if module_kwargs.llm_backend == "mlx-lm": + lm_kwargs["backend"] = "mlx" + + if is_vlm: + from speech_to_speech.LLM.language_model import VisionLanguageModelHandler + + return VisionLanguageModelHandler( + stop_event, + queue_in=text_prompt_queue, + queue_out=lm_response_queue, + setup_kwargs=lm_kwargs, + ) + from speech_to_speech.LLM.language_model import LanguageModelHandler + + return LanguageModelHandler( + stop_event, + queue_in=text_prompt_queue, + queue_out=lm_response_queue, + setup_kwargs=lm_kwargs, + ) + + raise ValueError("The LLM should be either transformers, mlx-lm, responses-api or chat-completions") + + +def get_tts_handler( + module_kwargs: ModuleArguments, + stop_event: Event, + lm_response_queue: Queue[TTSInItem], + send_audio_chunks_queue: Queue[AudioOutItem], + should_listen: Event, + chat_tts_handler_kwargs: ChatTTSHandlerArguments, + facebook_mms_tts_handler_kwargs: FacebookMMSTTSHandlerArguments, + pocket_tts_handler_kwargs: PocketTTSHandlerArguments, + kokoro_tts_handler_kwargs: KokoroTTSHandlerArguments, + qwen3_tts_handler_kwargs: Qwen3TTSHandlerArguments, +) -> BaseHandler[TTSIn, TTSOut]: + if module_kwargs.tts == "chatTTS": + try: + from speech_to_speech.TTS.chatTTS_handler import ChatTTSHandler + except (ImportError, RuntimeError) as e: + logger.error('Error importing ChatTTSHandler. Install it with `pip install "speech-to-speech[chattts]"`.') + raise e + return ChatTTSHandler( + stop_event, + queue_in=lm_response_queue, + queue_out=send_audio_chunks_queue, + setup_args=(should_listen,), + setup_kwargs=vars(chat_tts_handler_kwargs), + ) + elif module_kwargs.tts == "facebookMMS": + from speech_to_speech.TTS.facebookmms_handler import FacebookMMSTTSHandler + + return FacebookMMSTTSHandler( + stop_event, + queue_in=lm_response_queue, + queue_out=send_audio_chunks_queue, + setup_args=(should_listen,), + setup_kwargs=vars(facebook_mms_tts_handler_kwargs), + ) + elif module_kwargs.tts == "pocket": + try: + from speech_to_speech.TTS.pocket_tts_handler import PocketTTSHandler + except ImportError as e: + raise ImportError( + 'Pocket TTS is optional. Install it with `pip install "speech-to-speech[pocket]"`.' + ) from e + + return PocketTTSHandler( + stop_event, + queue_in=lm_response_queue, + queue_out=send_audio_chunks_queue, + setup_args=(should_listen,), + setup_kwargs=vars(pocket_tts_handler_kwargs), + ) + elif module_kwargs.tts == "kokoro": + try: + from speech_to_speech.TTS.kokoro_handler import KokoroTTSHandler + except ImportError as e: + raise ImportError('Kokoro is optional. Install it with `pip install "speech-to-speech[kokoro]"`.') from e + + return KokoroTTSHandler( + stop_event, + queue_in=lm_response_queue, + queue_out=send_audio_chunks_queue, + setup_args=(should_listen,), + setup_kwargs=vars(kokoro_tts_handler_kwargs), + ) + elif module_kwargs.tts == "qwen3": + from speech_to_speech.TTS.qwen3_tts_handler import Qwen3TTSHandler + + return Qwen3TTSHandler( + stop_event, + queue_in=lm_response_queue, + queue_out=send_audio_chunks_queue, + setup_args=(should_listen,), + setup_kwargs=vars(qwen3_tts_handler_kwargs), + ) + else: + raise ValueError("The TTS should be either chatTTS, facebookMMS, pocket, kokoro, or qwen3") + + +def main() -> None: + args = parse_arguments() + + setup_logger(args.module_kwargs.log_level) + + if args.module_kwargs.num_pipelines < 1: + raise ValueError(f"--num_pipelines must be >= 1, got {args.module_kwargs.num_pipelines}") + + prepare_all_args( + args.module_kwargs, + args.whisper_stt_handler_kwargs, + args.paraformer_stt_handler_kwargs, + args.faster_whisper_stt_handler_kwargs, + args.mlx_audio_whisper_stt_handler_kwargs, + args.parakeet_tdt_stt_handler_kwargs, + args.language_model_handler_kwargs, + args.responses_api_language_model_handler_kwargs, + args.chat_tts_handler_kwargs, + args.facebook_mms_tts_handler_kwargs, + args.pocket_tts_handler_kwargs, + args.kokoro_tts_handler_kwargs, + args.qwen3_tts_handler_kwargs, + ) + + # Validate after prepare_all_args(): --local_mac_optimal_settings mutates + # module_kwargs.mode to "local", so checking before would let + # --local_mac_optimal_settings --num_pipelines 2 sneak past this guard. + if args.module_kwargs.num_pipelines > 1 and args.module_kwargs.mode != "realtime": + raise ValueError( + f"--num_pipelines > 1 is only supported with --mode realtime " + f"(got mode={args.module_kwargs.mode!r}, num_pipelines={args.module_kwargs.num_pipelines})" + ) + + # On Apple Silicon, all MLX inference serializes through a global lock (utils/mlx_lock.py). + # The progressive STT path uses a short timeout and drops work under contention, producing + # a flood of warnings without affecting final transcripts. With a pool, pre-emptively turn + # it off so logs stay readable; the final STT path is unaffected. Non-darwin platforms + # don't share this lock, so leave their live transcription alone. + if args.module_kwargs.num_pipelines > 1 and platform == "darwin" and args.module_kwargs.enable_live_transcription: + logger.info( + "MLX contention: --num_pipelines=%d > 1 on Apple Silicon → disabling live transcription " + "(progressive STT contends on the global MLX lock)", + args.module_kwargs.num_pipelines, + ) + args.module_kwargs.enable_live_transcription = False + + queues_and_events = initialize_queues_and_events() + + pipeline_manager = build_pipeline( + args.module_kwargs, + args.socket_receiver_kwargs, + args.socket_sender_kwargs, + args.websocket_streamer_kwargs, + args.vad_handler_kwargs, + args.whisper_stt_handler_kwargs, + args.faster_whisper_stt_handler_kwargs, + args.paraformer_stt_handler_kwargs, + args.mlx_audio_whisper_stt_handler_kwargs, + args.parakeet_tdt_stt_handler_kwargs, + args.language_model_handler_kwargs, + args.responses_api_language_model_handler_kwargs, + args.chat_tts_handler_kwargs, + args.facebook_mms_tts_handler_kwargs, + args.pocket_tts_handler_kwargs, + args.kokoro_tts_handler_kwargs, + args.qwen3_tts_handler_kwargs, + queues_and_events, + ) + + # Set up graceful shutdown handler + shutdown_requested = [False] # Use list for nonlocal mutation + + def signal_handler(_sig: int, _frame: Optional[FrameType]) -> None: + if not shutdown_requested[0]: + shutdown_requested[0] = True + console.print("\n[yellow]Shutting down gracefully...[/yellow]") + pipeline_manager.stop() + console.print("[green]✓ Pipeline stopped successfully[/green]") + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + try: + pipeline_manager.start() + pipeline_manager.wait() + except KeyboardInterrupt: + if not shutdown_requested[0]: + console.print("\n[yellow]Shutting down gracefully...[/yellow]") + pipeline_manager.stop() + console.print("[green]✓ Pipeline stopped successfully[/green]") + + +if __name__ == "__main__": + main() diff --git a/speech_to_speech/utils/__init__.py b/speech_to_speech/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/speech_to_speech/utils/mlx_lock.py b/speech_to_speech/utils/mlx_lock.py new file mode 100644 index 0000000..37cb666 --- /dev/null +++ b/speech_to_speech/utils/mlx_lock.py @@ -0,0 +1,171 @@ +""" +Global MLX lock to prevent concurrent Metal/MLX model access. + +MLX models (STT, LLM, TTS) cannot be used concurrently from multiple threads +on Apple Silicon due to Metal command buffer limitations. This module provides +a global lock that all MLX handlers should acquire before using their models. +""" + +import logging +import types +from threading import Lock, RLock, current_thread, get_ident +from time import perf_counter +from typing import Literal + +logger = logging.getLogger(__name__) + +# Global reentrant lock for MLX model access +# Using RLock (reentrant lock) so the same thread can acquire it multiple times +_mlx_lock = RLock() +_mlx_lock_state = Lock() +_lock_owner_ident: int | None = None +_lock_owner_thread: str | None = None +_lock_owner_handler: str | None = None +_lock_acquired_at: float | None = None +_lock_depth = 0 + + +def _owner_snapshot(now: float | None = None) -> str: + if _lock_owner_ident is None: + return "none" + elapsed = "" + if _lock_acquired_at is not None: + elapsed = f" for {(now or perf_counter()) - _lock_acquired_at:.3f}s" + return f"{_lock_owner_handler} on {_lock_owner_thread}{elapsed}" + + +def _record_lock_acquired(handler_name: str) -> int: + global _lock_acquired_at, _lock_depth, _lock_owner_handler, _lock_owner_ident, _lock_owner_thread + ident = get_ident() + thread_name = current_thread().name + now = perf_counter() + with _mlx_lock_state: + if _lock_owner_ident == ident: + _lock_depth += 1 + else: + _lock_owner_ident = ident + _lock_owner_thread = thread_name + _lock_owner_handler = handler_name + _lock_acquired_at = now + _lock_depth = 1 + return _lock_depth + + +def _record_lock_released(handler_name: str) -> tuple[int, float | None]: + global _lock_acquired_at, _lock_depth, _lock_owner_handler, _lock_owner_ident, _lock_owner_thread + ident = get_ident() + now = perf_counter() + with _mlx_lock_state: + if _lock_owner_ident != ident: + logger.warning( + "%s: MLX lock release requested by non-owner thread; owner=%s", + handler_name, + _owner_snapshot(now), + ) + return _lock_depth, None + + hold_s = None if _lock_acquired_at is None else now - _lock_acquired_at + _lock_depth -= 1 + depth = _lock_depth + if _lock_depth <= 0: + _lock_owner_ident = None + _lock_owner_thread = None + _lock_owner_handler = None + _lock_acquired_at = None + _lock_depth = 0 + return depth, hold_s + + +def acquire_mlx_lock(timeout: float | None = None, handler_name: str = "Unknown") -> bool: + """ + Acquire the global MLX lock. + + Args: + timeout: Optional timeout in seconds + handler_name: Name of the handler acquiring the lock (for logging) + + Returns: + True if lock was acquired, False if timeout occurred + """ + with _mlx_lock_state: + owner_before = _owner_snapshot() + logger.debug("%s: Attempting to acquire MLX lock (owner=%s)", handler_name, owner_before) + start = perf_counter() + acquired = _mlx_lock.acquire(timeout=timeout) if timeout else _mlx_lock.acquire(blocking=True) + wait_s = perf_counter() - start + + if acquired: + depth = _record_lock_acquired(handler_name) + if wait_s >= 0.25: + logger.info( + "%s: MLX lock acquired after %.2fs (previous_owner=%s, depth=%d)", + handler_name, + wait_s, + owner_before, + depth, + ) + else: + logger.debug( + "%s: MLX lock acquired after %.3fs (previous_owner=%s, depth=%d)", + handler_name, + wait_s, + owner_before, + depth, + ) + else: + with _mlx_lock_state: + owner_after = _owner_snapshot() + logger.warning( + "%s: Failed to acquire MLX lock after %.3fs (timeout=%s, owner=%s)", + handler_name, + wait_s, + timeout, + owner_after, + ) + + return acquired + + +def release_mlx_lock(handler_name: str = "Unknown") -> None: + """ + Release the global MLX lock. + + Args: + handler_name: Name of the handler releasing the lock (for logging) + """ + try: + depth, hold_s = _record_lock_released(handler_name) + _mlx_lock.release() + if hold_s is not None and depth == 0 and hold_s >= 0.25: + logger.info("%s: MLX lock released after holding %.2fs", handler_name, hold_s) + else: + logger.debug( + "%s: MLX lock released%s", + handler_name, + "" if hold_s is None else f" after holding {hold_s:.3f}s (depth={depth})", + ) + except RuntimeError as e: + logger.error(f"{handler_name}: Failed to release MLX lock: {e}") + + +class MLXLockContext: + """Context manager for MLX lock.""" + + def __init__(self, handler_name: str = "Unknown", timeout: float | None = None) -> None: + self.handler_name = handler_name + self.timeout = timeout + self.acquired = False + + def __enter__(self) -> bool: + self.acquired = acquire_mlx_lock(timeout=self.timeout, handler_name=self.handler_name) + return self.acquired + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> Literal[False]: + if self.acquired: + release_mlx_lock(handler_name=self.handler_name) + return False # Do not suppress exceptions diff --git a/speech_to_speech/utils/thread_manager.py b/speech_to_speech/utils/thread_manager.py new file mode 100644 index 0000000..e12a1b7 --- /dev/null +++ b/speech_to_speech/utils/thread_manager.py @@ -0,0 +1,39 @@ +import logging +import threading +from collections.abc import Sequence +from typing import Any + +logger = logging.getLogger(__name__) + + +class ThreadManager: + """ + Manages multiple threads used to execute given handler tasks. + """ + + def __init__(self, handlers: Sequence[Any]) -> None: + self.handlers = handlers + self.threads: list[threading.Thread] = [] + + def start(self) -> None: + for handler in self.handlers: + thread = threading.Thread(target=handler.run) + thread.daemon = False # Ensure threads are waited for on shutdown + self.threads.append(thread) + thread.start() + + def wait(self) -> None: + for thread in self.threads: + thread.join() + + def stop(self) -> None: + # Signal all handlers to stop + for handler in self.handlers: + handler.stop_event.set() + + # Wait for all threads to finish with timeout + for i, thread in enumerate(self.threads): + if thread.is_alive(): + thread.join(timeout=5.0) + if thread.is_alive(): + logger.warning(f"Thread {i} ({thread.name}) did not terminate within timeout") diff --git a/speech_to_speech/utils/utils.py b/speech_to_speech/utils/utils.py new file mode 100644 index 0000000..29e112a --- /dev/null +++ b/speech_to_speech/utils/utils.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams + + +def response_wants_audio(response: RealtimeResponseCreateParams | None) -> bool: + """Whether a response should produce audio (and audio events) vs. text only. + + Mirrors the OpenAI realtime semantics for ``output_modalities``: an absent + value (``None``) or an empty list, or an explicit ``"audio"`` entry means + audio; a non-empty list without ``"audio"`` (e.g. ``["text"]``) means text + only. + """ + if response is None: + return True + mods = response.output_modalities + return not mods or "audio" in mods + + +def is_out_of_band(response: RealtimeResponseCreateParams | None) -> bool: + """Whether a response is *out-of-band* (``conversation="none"``). + + Out-of-band responses are generated against a temporary context and are + never threaded into the default conversation: their ``input`` (if any) + seeds a throwaway chat, their assistant output is not committed back, and + their ``conversation_id`` is reported as ``null``. Any other ``conversation`` + value (``"auto"``, ``None``, or an arbitrary id) is treated as in-band. + """ + return response is not None and response.conversation == "none" + + +def next_power_of_2(x: int) -> int: + return 1 if x == 0 else 2 ** (x - 1).bit_length() + + +def _generate_id(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex}" + + +def int2float(sound: np.ndarray) -> np.ndarray: + """ + Taken from https://github.com/snakers4/silero-vad + """ + + abs_max = np.abs(sound).max() + sound = sound.astype("float32") + if abs_max > 0: + sound *= 1 / 32768 + sound = sound.squeeze() # depends on the use case + return sound diff --git a/start_ui.sh b/start_ui.sh new file mode 100755 index 0000000..78f771f --- /dev/null +++ b/start_ui.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV_DIR="$ROOT_DIR/.venv" +REQ_FILE="$ROOT_DIR/demo/requirements.txt" +UI_PORT="${UI_PORT:-8420}" +if [ -f "$ROOT_DIR/.env" ]; then + set -a + source "$ROOT_DIR/.env" + set +a +fi +S2S_PROXY_TARGET="${S2S_PROXY_TARGET:-ws://ai-server:12345/v1/realtime}" + +if ! command -v python3 >/dev/null 2>&1; then + echo "Errore: python3 non trovato." + exit 1 +fi + +if [ ! -d "$VENV_DIR" ]; then + echo "Creazione ambiente virtuale in .venv..." + python3 -m venv "$VENV_DIR" +fi + +source "$VENV_DIR/bin/activate" + +if [ ! -f "$REQ_FILE" ]; then + echo "Errore: file requirements non trovato: $REQ_FILE" + exit 1 +fi + +if ! python -c "import fastapi, uvicorn, httpx, huggingface_hub, dotenv" >/dev/null 2>&1; then + echo "Installazione dipendenze da demo/requirements.txt..." + python -m pip install -r "$REQ_FILE" +fi + +export S2S_PROXY_TARGET + +echo "💻 Avvio dell'interfaccia Demo UI (Porta $UI_PORT)..." +echo "🔌 Proxy realtime verso $S2S_PROXY_TARGET" +echo "💡 Premi Ctrl+C in questo terminale per fermarla." +echo "----------------------------------------------------" + +cd "$ROOT_DIR" +PYTHONPATH=demo python -m uvicorn demo.server:app --host 0.0.0.0 --port "$UI_PORT" --reload