14 KiB
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 |
| Voice UI (browser) | Orba, mic, transcriptioni, audio playback, pannelli | index.html + main.js |
| Reasoning hook | Inoltra transcript utente a un motore di ragionamento esterno (NODE_RED o OpenAI) | server.py /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
.envCon
.envattuale che haS2S_PROXY_TARGET=ws://ai-server:12345/v1/realtime:✅ Sì, TUTTO l'AUDIO utente (PCM16 16kHz mono, eventi
input_audio_buffer.append) arriva aws://ai-server:12345/v1/realtime— ma non direttamente: passa SEMPRE per il tunnel 1:1 WebSocket diserver.py(_client_to_upstream), che copia byte-per-byte senza modifiche.✅ Allo stesso modo l'AUDIO risposta (TTS,
response.output_audio.delta) ritorna daai-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.comQuesti 3 flussi REST sono aggiuntivi e non alterano minimamente il tunnel audio: browser ↔ server.py ↔
ai-server:12345rimane il percorso esclusivo per voce e risposta.
3. Cose importanti di server.py
File → ruoli
/→ StaticFastStaticFilessudemo/(index.html + assets)@app.middleware httpno_cache_static_assets →Cache-Control: no-storeper.js/.html/.css/.mjs/.wasmper 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) → cuore del reasoning. Due modalità, scelta da envS2S_REASONING_TYPE:- Modalità A — NODE_RED: inoltra
POST {NODE_RED_URL}/api/reasoningtimeout 30s. Accettalog_only=true+answer=""(Fase 1). - Modalità B — OPENAI: chiamata diretta
POST $OPENAI_BASE_URL/chat/completions(Fasi successive).
- Modalità A — NODE_RED: inoltra
@app.websocket /v1/realtime(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 → browsersession_ctx = {last_user_transcript, last_item_id}dizionario mutabile condiviso per accoppiare input↔output
async def _forward_agent_text_to_nodered(L837-L873) → helper fire-and-forget. Tutti errori →logger.debug(non spezza il tunnel WS)._upstream_to_clienthook eventi (L1305-L1500): per ogni evento output agente avviaasyncio.create_task(_forward_agent_text_to_nodered(...))→ non blocca lo streaming audio.- Sidecar Python locali: auth.py (Aquachile profile+user+phone), limiter.py (rate limiter).
Ordine di chiamata in un turno vocale
- Browser clicca orb →
ws /v1/realtime→realtime_proxy()accetta - Browser manda
session.update→input_audio_buffer.appendin loop - Upstream genera
conversation.item.input_audio_transcription.completed→ server cattura →session_ctx["last_user_transcript"] = transcript _upstream_to_clientvede transcript STT completo → server invoca in paralleloPOST /api/reasoningcon quel transcript- Upstream manda
response.output_audio.deltaaudio chunks → tunnelati diretti al browser → playback nativo - 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 - Audio finisce → turno chiuso
4. Cose importanti del lato client (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_REDe Node-RED tornalog_only=true→ il client non tocca nulla. ZerocancelActiveResponse, zerospeechSynthesis.speak, zeroconversation.item.createmanuali. La voce è quella del backend S2S nativo. - Cache busting triplo:
index.htmlmeta tag →Cache-Control no-storemiddleware → query-string?v=20260825003su ogni import.
5. Node-RED (Fase 1 — LOG_ONLY)
Risiede in 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 — sample-rate input 16kHz PCM mono, calcolo RMS per VAD visuale.
- worklets/audio-playback.js — riproduzione audio 24kHz server side.
Nota: i worklets vengono caricati via
AudioWorklet.addModule(), che usa script-context non-ESM. Non aggiungereexport functiona 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 :root + DESIGN.md |
| Aggiungere un pannello in UI | index.html markup + main.js render + nuovo modulo in ui/ |
| Aggiungere un endpoint REST | server.py, dopo le route esistenti |
| Nuova logica Node-RED Fase 2 | 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 |