# 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 |