tfrere HF Staff Cursor commited on
Commit
bae29a2
Β·
1 Parent(s): 5ceda23

feat: minimal voice conversation app on top of HF speech-to-speech (WebRTC)

Browse files

Single-page static app that opens a WebRTC voice session against a
self-hosted speech-to-speech load balancer. POSTs `<lb>/session`,
rewrites the returned `connect_url` from `wss://` to `https://`, and
POSTs an SDP offer with `Content-Type: application/sdp`. Audio
streams over the media tracks; events on the `oai-events` data channel
using the OpenAI Realtime JSON protocol.

- vanilla JS + JSDoc, no build step, no node_modules, no proxy
- 9 Qwen3-TTS voices (Aiden, Ryan, Dylan, Eric, Ono_Anna, Serena, Sohee, Uncle_Fu, Vivian)
- forked from `reachy_mini_minimal_conversation`, stripped of all
robot-specific bits (SDK, head wobbler, antennas, move player,
robot picker, OAuth HF, transport pill)

Backend requirement: the compute serving `<lb>` must run the
`feat/webrtc-transport` branch of huggingface/speech-to-speech, which
defines `POST /v1/realtime` with `application/sdp` (lines 360-452 of
`websocket_router.py`). On `main` the same path is WS-only and replies
404 to the SDP POST.

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (6) hide show
  1. .gitignore +9 -0
  2. README.md +90 -5
  3. index.html +157 -17
  4. main.js +399 -0
  5. s2s-client.js +516 -0
  6. style.css +1064 -18
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ *.log
3
+ .env
4
+ .env.local
5
+ .env.*.local
6
+ .idea
7
+ .vscode/*
8
+ !.vscode/extensions.json
9
+ node_modules
README.md CHANGED
@@ -1,10 +1,95 @@
1
  ---
2
- title: Minimal Conversation App S2s Backend
3
- emoji: πŸ”₯
4
- colorFrom: blue
5
- colorTo: yellow
6
  sdk: static
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Minimal Conversation (S2S backend)
3
+ emoji: πŸŽ™οΈ
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: static
7
  pinned: false
8
+ short_description: Voice chat over WebRTC against a HF speech-to-speech
9
  ---
10
 
11
+ # Minimal Conversation App (S2S backend)
12
+
13
+ Tiny browser app that opens a **WebRTC** voice session with a self-hosted Hugging Face
14
+ [speech-to-speech](https://github.com/huggingface/speech-to-speech) backend. No OpenAI key,
15
+ no robot, no OAuth, no proxy. The browser talks **directly** to the backend.
16
+
17
+ ## How it works
18
+
19
+ 1. App POSTs `<lb_url>/session` (empty JSON body).
20
+ 2. The load balancer picks a ready compute (round-robin) and returns:
21
+ ```json
22
+ {
23
+ "session_id": "...",
24
+ "websocket_url": "wss://<compute>/v1/realtime",
25
+ "connect_url": "wss://<compute>/v1/realtime?session_token=<JWT>",
26
+ "session_token": "<JWT>",
27
+ "pending_timeout_s": 60
28
+ }
29
+ ```
30
+ 3. App rewrites `connect_url` from `wss://` to `https://` and POSTs an SDP offer
31
+ to it (`Content-Type: application/sdp`). The compute, running aiortc, answers
32
+ `201 application/sdp` with the SDP answer.
33
+ 4. WebRTC peer connection opens. Audio streams over the media tracks (the browser
34
+ sends Opus 48 kHz, the backend resamples to 16 kHz for STT and back for TTS).
35
+ Events flow over a data channel labelled **`oai-events`** using the OpenAI
36
+ Realtime JSON protocol (`session.update`, `response.audio_transcript.delta`,
37
+ `conversation.item.input_audio_transcription.delta`, ...).
38
+
39
+ The backend exposes one concurrent session per compute; the LB pins a session to
40
+ a specific compute via a signed `session_token`.
41
+
42
+ ## Backend requirement
43
+
44
+ This app talks to the WebRTC route `POST /v1/realtime` defined in
45
+ [`websocket_router.py`](https://github.com/huggingface/speech-to-speech/blob/feat/webrtc-transport/src/speech_to_speech/api/openai_realtime/websocket_router.py).
46
+ That route lives on the **`feat/webrtc-transport`** branch. If the compute is
47
+ deployed from `main` it will reply 404 on the SDP POST. Verify with:
48
+
49
+ ```bash
50
+ LB="https://<your-lb>.endpoints.huggingface.cloud"
51
+ curl -X POST "$LB/session" -H "Content-Type: application/json" -d '{}'
52
+ # -> { "connect_url": "wss://<compute>/v1/realtime?session_token=..." }
53
+
54
+ # Rewrite wss -> https, POST a SDP offer:
55
+ curl -X POST "https://<compute>/v1/realtime?session_token=..." \
56
+ -H "Content-Type: application/sdp" --data-binary @offer.sdp
57
+ # expect 201 application/sdp; if 404, the compute is on the wrong branch.
58
+ ```
59
+
60
+ ## Run locally
61
+
62
+ This is a pure static app. No build step, no node_modules. Any HTTP server works:
63
+
64
+ ```bash
65
+ cd minimal-conversation-app-s2s-backend
66
+ python3 -m http.server 5173
67
+ # or: npx --yes http-server -p 5173 -c-1
68
+ ```
69
+
70
+ Then open http://localhost:5173/, click the orb, allow the mic, talk.
71
+
72
+ > Browsers require **HTTPS or `localhost`** for `getUserMedia()`. `127.0.0.1` works,
73
+ > as does `localhost`. Plain `http://192.168.x.y:5173` does NOT (mic blocked).
74
+
75
+ ## Settings (stored in `localStorage`)
76
+
77
+ | Key | What |
78
+ |-----|------|
79
+ | Load balancer URL | Base URL of your S2S deployment. App POSTs `<lb>/session`. |
80
+ | Voice | Qwen3-TTS speaker name (Aiden, Ryan, Dylan, Eric, Ono_Anna, Serena, Sohee, Uncle_Fu, Vivian) |
81
+ | Instructions | System prompt sent in `session.update` once the data channel opens |
82
+
83
+ ## Files
84
+
85
+ | File | Role |
86
+ |------|------|
87
+ | `index.html` | Single page, orb + settings modal |
88
+ | `main.js` | State machine, settings, mic capture, output audio piping |
89
+ | `s2s-client.js` | WebRTC handshake against the LB + compute |
90
+ | `style.css` | Orb animations, layout, dark theme |
91
+
92
+ ## Credits
93
+
94
+ - Backend: [huggingface/speech-to-speech](https://github.com/huggingface/speech-to-speech) on `feat/webrtc-transport`
95
+ - UI forked from `reachy_mini_minimal_conversation` (Pollen Robotics Γ— Hugging Face), stripped of all robot-specific bits
index.html CHANGED
@@ -1,19 +1,159 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
+ <title>Minimal Conversation Β· S2S backend</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <link rel="stylesheet" href="style.css" />
14
+ </head>
15
+ <!--
16
+ `booting` disables transitions/animations until the first paint commits.
17
+ Without this the orb visibly fades/scales in on the very first frame
18
+ because `state-idle` and the generic `.ind` defaults differ. The
19
+ bootstrap script at the end of main.js strips the class after one rAF.
20
+ -->
21
+ <body class="booting">
22
+ <div id="app">
23
+ <header class="topbar">
24
+ <div class="brand">
25
+ <span>Minimal Conversation Β· S2S backend</span>
26
+ </div>
27
+ <div class="topbar-right">
28
+ <button id="settings-btn" class="icon-btn" title="Settings" aria-label="Settings">
29
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .66.39 1.25 1 1.51H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
30
+ </button>
31
+ </div>
32
+ </header>
33
+
34
+ <main class="stage">
35
+ <div class="orb-wrap">
36
+ <button id="mic-btn" class="side-btn" type="button" aria-label="Mute" title="Mute" aria-hidden="true">
37
+ <svg class="mic-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
38
+ <svg class="mic-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M9 5a3 3 0 0 1 6 0v4"/><path d="M9 10v1a3 3 0 0 0 5.1 2.1"/><path d="M19 10a7 7 0 0 1-1.24 3.97"/><path d="M5 10a7 7 0 0 0 11 5.67"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
39
+ </button>
40
+
41
+ <button
42
+ id="main-circle"
43
+ class="circle state-idle"
44
+ type="button"
45
+ aria-label="Start voice conversation"
46
+ >
47
+ <span class="circle-glow" aria-hidden="true"></span>
48
+ <span class="circle-ring" aria-hidden="true"></span>
49
+ <span class="circle-ring-outer" aria-hidden="true"></span>
50
+ <span class="circle-core">
51
+ <span class="circle-indicator" aria-hidden="true">
52
+ <svg class="ind ind-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
53
+ <rect x="9" y="2" width="6" height="12" rx="3" fill="currentColor" stroke="none"/>
54
+ <path d="M5 10a7 7 0 0 0 14 0"/>
55
+ <line x1="12" y1="19" x2="12" y2="22"/>
56
+ <line x1="8" y1="22" x2="16" y2="22"/>
57
+ </svg>
58
+ <svg class="ind ind-error" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="13"/><line x1="12" y1="16" x2="12" y2="16"/></svg>
59
+ <span class="ind ind-spinner"></span>
60
+ <span class="ind ind-thinking">
61
+ <span class="dot"></span>
62
+ <span class="dot"></span>
63
+ <span class="dot"></span>
64
+ </span>
65
+ <span class="ind ind-bars">
66
+ <span class="bar"></span>
67
+ <span class="bar"></span>
68
+ <span class="bar"></span>
69
+ <span class="bar"></span>
70
+ <span class="bar"></span>
71
+ </span>
72
+ <svg class="ind ind-voice" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
73
+ <path d="M3 10v4a1 1 0 0 0 1 1h3l5 4V5L7 9H4a1 1 0 0 0-1 1z" fill="currentColor" stroke="none"/>
74
+ <path class="wave wave-1" d="M16 8a5 5 0 0 1 0 8"/>
75
+ <path class="wave wave-2" d="M19 5a9 9 0 0 1 0 14"/>
76
+ </svg>
77
+ </span>
78
+ </span>
79
+ </button>
80
+
81
+ <button id="stop-btn" class="side-btn" type="button" aria-label="End" title="End" aria-hidden="true">
82
+ <svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
83
+ </button>
84
+ </div>
85
+
86
+ <p id="circle-caption" class="circle-caption" role="status">Tap to start</p>
87
+ </main>
88
+
89
+ <footer class="footer">
90
+ <span>
91
+ Powered by
92
+ <a href="https://github.com/huggingface/speech-to-speech" target="_blank" rel="noopener">huggingface/speech-to-speech</a>
93
+ (WebRTC transport)
94
+ </span>
95
+ </footer>
96
+ </div>
97
+
98
+ <dialog id="settings-modal" class="modal">
99
+ <form method="dialog" class="modal-content">
100
+ <header class="modal-header">
101
+ <h2>Settings</h2>
102
+ <button class="icon-btn" value="close" aria-label="Close">
103
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
104
+ </button>
105
+ </header>
106
+
107
+ <div class="tab-panels">
108
+ <section class="tab-panel active" role="tabpanel">
109
+ <label class="field">
110
+ <span>Load balancer URL</span>
111
+ <input id="lb-url" type="url" autocomplete="off" spellcheck="false" placeholder="https://...endpoints.huggingface.cloud" />
112
+ <small>
113
+ Base URL of the Hugging Face speech-to-speech load balancer.
114
+ The app POSTs <code>&lt;lb&gt;/session</code>, then a WebRTC SDP offer
115
+ to the <code>connect_url</code> the LB returns.
116
+ </small>
117
+ </label>
118
+
119
+ <div class="field-row">
120
+ <label class="field">
121
+ <span>Voice</span>
122
+ <select id="voice">
123
+ <option value="Aiden" selected>Aiden</option>
124
+ <option value="Ryan">Ryan</option>
125
+ <option value="Dylan">Dylan</option>
126
+ <option value="Eric">Eric</option>
127
+ <option value="Ono_Anna">Ono_Anna</option>
128
+ <option value="Serena">Serena</option>
129
+ <option value="Sohee">Sohee</option>
130
+ <option value="Uncle_Fu">Uncle_Fu</option>
131
+ <option value="Vivian">Vivian</option>
132
+ </select>
133
+ </label>
134
+ </div>
135
+
136
+ <label class="field">
137
+ <span>Instructions</span>
138
+ <textarea id="instructions" rows="5" placeholder="You are a friendly voice assistant..."></textarea>
139
+ <small>System prompt sent in <code>session.update</code> when the data channel opens.</small>
140
+ </label>
141
+
142
+ <div class="field">
143
+ <button id="restart-conversation" type="button" class="btn primary wide" disabled>
144
+ Restart conversation with these settings
145
+ </button>
146
+ <small id="restart-hint">Connect first, then come back here to apply live changes.</small>
147
+ </div>
148
+ </section>
149
+ </div>
150
+
151
+ <footer class="modal-footer">
152
+ <button id="settings-save" type="submit" class="btn primary" value="save">Save</button>
153
+ </footer>
154
+ </form>
155
+ </dialog>
156
+
157
+ <script type="module" src="main.js"></script>
158
+ </body>
159
  </html>
main.js ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /**
3
+ * Minimal voice conversation app, talking to a Hugging Face speech-to-speech
4
+ * backend over WebRTC.
5
+ *
6
+ * Click the orb -> we ask for the mic, POST a session on the LB, post our
7
+ * SDP offer to the routed compute, set up the data channel and the audio
8
+ * tracks. The orb visually reflects the live state (idle, connecting,
9
+ * listening, user-speaking, processing, ai-speaking).
10
+ *
11
+ * @typedef {"idle" | "connecting" | "listening" | "user-speaking" | "processing" | "ai-speaking" | "error"} AppState
12
+ */
13
+
14
+ import { S2sRealtimeClient } from "./s2s-client.js";
15
+
16
+ const DEFAULT_LB_URL = "https://mewklqcmavs5gkid.us-east-1.aws.endpoints.huggingface.cloud";
17
+ const DEFAULT_VOICE = "Aiden";
18
+ const DEFAULT_INSTRUCTIONS =
19
+ "You are a friendly voice assistant. " +
20
+ "Keep replies short, warm, and spoken. Avoid long monologues.";
21
+
22
+ const STORAGE_KEYS = {
23
+ lbUrl: "s2s.lbUrl",
24
+ voice: "s2s.voice",
25
+ instructions: "s2s.instructions",
26
+ };
27
+
28
+ // ─── Settings (localStorage) ────────────────────────────────────────────
29
+
30
+ /**
31
+ * @typedef {Object} Settings
32
+ * @property {string} lbUrl
33
+ * @property {string} voice
34
+ * @property {string} instructions
35
+ *
36
+ * @returns {Settings}
37
+ */
38
+ function loadSettings() {
39
+ return {
40
+ lbUrl: localStorage.getItem(STORAGE_KEYS.lbUrl) || DEFAULT_LB_URL,
41
+ voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE,
42
+ instructions: localStorage.getItem(STORAGE_KEYS.instructions) || DEFAULT_INSTRUCTIONS,
43
+ };
44
+ }
45
+
46
+ /** @param {Settings} s */
47
+ function saveSettings(s) {
48
+ localStorage.setItem(STORAGE_KEYS.lbUrl, s.lbUrl);
49
+ localStorage.setItem(STORAGE_KEYS.voice, s.voice);
50
+ localStorage.setItem(STORAGE_KEYS.instructions, s.instructions);
51
+ }
52
+
53
+ // ─── State views ────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * @typedef {Object} StateView
57
+ * @property {string} caption
58
+ * @property {boolean} disabled
59
+ *
60
+ * @type {Record<AppState, StateView>}
61
+ */
62
+ const STATE_VIEWS = {
63
+ idle: { caption: "Tap to start", disabled: false },
64
+ connecting: { caption: "Connecting", disabled: true },
65
+ listening: { caption: "", disabled: false },
66
+ "user-speaking": { caption: "", disabled: false },
67
+ processing: { caption: "", disabled: false },
68
+ "ai-speaking": { caption: "", disabled: false },
69
+ error: { caption: "Tap to retry", disabled: false },
70
+ };
71
+
72
+ /** @type {Record<AppState, string>} */
73
+ const STATE_CLASS = {
74
+ idle: "state-idle",
75
+ connecting: "state-connecting",
76
+ listening: "state-listening",
77
+ "user-speaking": "state-user-speaking",
78
+ processing: "state-processing",
79
+ "ai-speaking": "state-ai-speaking",
80
+ error: "state-error",
81
+ };
82
+
83
+ /** @type {ReadonlySet<AppState>} */
84
+ const LIVE_STATES = new Set(["listening", "user-speaking", "processing", "ai-speaking"]);
85
+
86
+ // ─── DOM refs ───────────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * @template {HTMLElement} T
90
+ * @param {string} selector
91
+ * @returns {T}
92
+ */
93
+ function $(selector) {
94
+ const el = document.querySelector(selector);
95
+ if (!el) throw new Error(`Missing element: ${selector}`);
96
+ return /** @type {T} */ (el);
97
+ }
98
+
99
+ /** @type {HTMLButtonElement} */
100
+ const circleBtn = $("#main-circle");
101
+ /** @type {HTMLParagraphElement} */
102
+ const circleCaption = $("#circle-caption");
103
+ /** @type {HTMLElement} */
104
+ const orbWrap = $(".orb-wrap");
105
+ /** @type {HTMLButtonElement} */
106
+ const micBtn = $("#mic-btn");
107
+ /** @type {HTMLButtonElement} */
108
+ const stopBtn = $("#stop-btn");
109
+
110
+ /** @type {HTMLButtonElement} */
111
+ const settingsBtn = $("#settings-btn");
112
+ /** @type {HTMLDialogElement} */
113
+ const settingsModal = $("#settings-modal");
114
+ /** @type {HTMLInputElement} */
115
+ const inputLbUrl = $("#lb-url");
116
+ /** @type {HTMLSelectElement} */
117
+ const inputVoice = $("#voice");
118
+ /** @type {HTMLTextAreaElement} */
119
+ const inputInstructions = $("#instructions");
120
+ /** @type {HTMLButtonElement} */
121
+ const restartBtn = $("#restart-conversation");
122
+ /** @type {HTMLElement} */
123
+ const restartHint = $("#restart-hint");
124
+ const settingsForm = /** @type {HTMLFormElement} */ (settingsModal.querySelector("form"));
125
+
126
+ // ─── Runtime state ──────────────────────────────────────────────────────
127
+
128
+ /** @type {AppState} */
129
+ let currentState = "idle";
130
+ let settings = loadSettings();
131
+
132
+ /** @type {S2sRealtimeClient | null} */
133
+ let client = null;
134
+ /** @type {MediaStream | null} */
135
+ let micStream = null;
136
+ /** @type {HTMLAudioElement | null} */
137
+ let outputSink = null;
138
+ let micMuted = false;
139
+
140
+ // ─── UI plumbing ─────────────────���──────────────────────────────────────
141
+
142
+ /** @param {AppState} next */
143
+ function setState(next) {
144
+ currentState = next;
145
+ const view = STATE_VIEWS[next];
146
+ circleBtn.disabled = view.disabled;
147
+ circleBtn.className = `circle ${STATE_CLASS[next]}`;
148
+ if (next !== "error") setCaption(view.caption);
149
+
150
+ const live = LIVE_STATES.has(next);
151
+ orbWrap.classList.toggle("live", live);
152
+ micBtn.setAttribute("aria-hidden", live ? "false" : "true");
153
+ stopBtn.setAttribute("aria-hidden", live ? "false" : "true");
154
+ micBtn.tabIndex = live ? 0 : -1;
155
+ stopBtn.tabIndex = live ? 0 : -1;
156
+
157
+ updateRestartAvailability();
158
+ }
159
+
160
+ function updateRestartAvailability() {
161
+ const live = LIVE_STATES.has(currentState);
162
+ restartBtn.disabled = !live;
163
+ restartHint.hidden = live;
164
+ }
165
+
166
+ /**
167
+ * @param {string} text
168
+ * @param {"" | "error" | "muted"} [kind]
169
+ */
170
+ function setCaption(text, kind = "") {
171
+ const trimmed = text.trim();
172
+ circleCaption.textContent = trimmed;
173
+ circleCaption.className = `circle-caption${kind ? ` ${kind}` : ""}${trimmed ? "" : " empty"}`;
174
+ }
175
+
176
+ // ─── Settings modal ─────────────────────────────────────────────────────
177
+
178
+ function openSettings() {
179
+ inputLbUrl.value = settings.lbUrl;
180
+ inputVoice.value = settings.voice;
181
+ inputInstructions.value = settings.instructions;
182
+ updateRestartAvailability();
183
+ settingsModal.showModal();
184
+ }
185
+
186
+ settingsBtn.addEventListener("click", openSettings);
187
+
188
+ settingsForm.addEventListener("submit", (event) => {
189
+ const submitter = /** @type {HTMLButtonElement | null} */ ((/** @type {SubmitEvent} */ (event)).submitter);
190
+ if (submitter?.value !== "save") return;
191
+
192
+ settings = {
193
+ lbUrl: inputLbUrl.value.trim() || DEFAULT_LB_URL,
194
+ voice: inputVoice.value || DEFAULT_VOICE,
195
+ instructions: inputInstructions.value.trim() || DEFAULT_INSTRUCTIONS,
196
+ };
197
+ saveSettings(settings);
198
+
199
+ // If we have a live session, push the new voice/instructions on the wire
200
+ // without tearing the peer connection down.
201
+ if (client && LIVE_STATES.has(currentState)) {
202
+ client.updateSession({ voice: settings.voice, instructions: settings.instructions });
203
+ }
204
+ });
205
+
206
+ restartBtn.addEventListener("click", async () => {
207
+ if (!LIVE_STATES.has(currentState)) return;
208
+ settings = {
209
+ lbUrl: inputLbUrl.value.trim() || DEFAULT_LB_URL,
210
+ voice: inputVoice.value || DEFAULT_VOICE,
211
+ instructions: inputInstructions.value.trim() || DEFAULT_INSTRUCTIONS,
212
+ };
213
+ saveSettings(settings);
214
+ settingsModal.close();
215
+ try {
216
+ await teardown();
217
+ await doStart();
218
+ } catch (err) {
219
+ onFatalError(err);
220
+ }
221
+ });
222
+
223
+ // ─── Click the orb ──────────────────────────────────────────────────────
224
+
225
+ circleBtn.addEventListener("click", async () => {
226
+ try {
227
+ if (currentState === "idle" || currentState === "error") {
228
+ await doStart();
229
+ }
230
+ } catch (err) {
231
+ onFatalError(err);
232
+ }
233
+ });
234
+
235
+ micBtn.addEventListener("click", () => {
236
+ if (!micStream) return;
237
+ micMuted = !micMuted;
238
+ for (const track of micStream.getAudioTracks()) {
239
+ track.enabled = !micMuted;
240
+ }
241
+ micBtn.classList.toggle("muted", micMuted);
242
+ micBtn.setAttribute("aria-label", micMuted ? "Unmute" : "Mute");
243
+ micBtn.title = micMuted ? "Unmute" : "Mute";
244
+ });
245
+
246
+ stopBtn.addEventListener("click", async () => {
247
+ await teardown();
248
+ });
249
+
250
+ // ─── High-level flow ────────────────────────────────────────────────────
251
+
252
+ async function doStart() {
253
+ setState("connecting");
254
+ setCaption("Asking for mic…", "muted");
255
+ try {
256
+ micStream = await navigator.mediaDevices.getUserMedia({
257
+ audio: {
258
+ echoCancellation: true,
259
+ noiseSuppression: true,
260
+ autoGainControl: true,
261
+ },
262
+ });
263
+ } catch (err) {
264
+ throw new Error(
265
+ `Microphone access denied${err instanceof Error ? `: ${err.message}` : ""}`,
266
+ );
267
+ }
268
+
269
+ const inputTrack = micStream.getAudioTracks()[0];
270
+ if (!inputTrack) throw new Error("No microphone track available");
271
+
272
+ const c = new S2sRealtimeClient({
273
+ loadBalancerUrl: settings.lbUrl,
274
+ voice: settings.voice,
275
+ instructions: settings.instructions,
276
+ inputTrack,
277
+ });
278
+ client = c;
279
+
280
+ c.addEventListener("status", (e) => {
281
+ const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
282
+ onClientStatus(detail.status);
283
+ });
284
+ c.addEventListener("outputTrack", (e) => {
285
+ const detail = /** @type {CustomEvent<{ track: MediaStreamTrack }>} */ (e).detail;
286
+ attachOutputTrack(detail.track);
287
+ });
288
+ c.addEventListener("transcript", (e) => {
289
+ const detail = /** @type {CustomEvent<{ role: "user" | "assistant"; text: string; partial: boolean }>} */ (
290
+ e
291
+ ).detail;
292
+ console.log(`[transcript:${detail.role}${detail.partial ? "Β·delta" : ""}] ${detail.text}`);
293
+ });
294
+ c.addEventListener("error", (e) => {
295
+ const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
296
+ onFatalError(detail.error);
297
+ });
298
+ c.addEventListener("session", (e) => {
299
+ const detail = /** @type {CustomEvent<{ info: { sessionId: string } }>} */ (e).detail;
300
+ console.log("[s2s] session created:", detail.info.sessionId);
301
+ });
302
+
303
+ await c.connect();
304
+ }
305
+
306
+ /** @param {string} status */
307
+ function onClientStatus(status) {
308
+ switch (status) {
309
+ case "creating-session":
310
+ case "connecting":
311
+ setState("connecting");
312
+ break;
313
+ case "connected":
314
+ setState("listening");
315
+ break;
316
+ case "user-speaking":
317
+ setState("user-speaking");
318
+ break;
319
+ case "processing":
320
+ setState("processing");
321
+ break;
322
+ case "ai-speaking":
323
+ setState("ai-speaking");
324
+ break;
325
+ case "closed":
326
+ // teardown() will move us to idle
327
+ break;
328
+ case "error":
329
+ setState("error");
330
+ break;
331
+ }
332
+ }
333
+
334
+ /** @param {MediaStreamTrack} track */
335
+ function attachOutputTrack(track) {
336
+ if (!outputSink) {
337
+ outputSink = document.createElement("audio");
338
+ outputSink.autoplay = true;
339
+ outputSink.setAttribute("playsinline", "true");
340
+ document.body.appendChild(outputSink);
341
+ }
342
+ const stream = new MediaStream([track]);
343
+ outputSink.srcObject = stream;
344
+ outputSink.play().catch((err) => {
345
+ console.warn("[main] audio playback blocked:", err);
346
+ });
347
+ }
348
+
349
+ async function teardown() {
350
+ if (client) {
351
+ try {
352
+ await client.close();
353
+ } catch (err) {
354
+ console.warn("[main] error closing client:", err);
355
+ }
356
+ client = null;
357
+ }
358
+ if (micStream) {
359
+ for (const track of micStream.getTracks()) track.stop();
360
+ micStream = null;
361
+ }
362
+ if (outputSink) {
363
+ outputSink.srcObject = null;
364
+ outputSink.remove();
365
+ outputSink = null;
366
+ }
367
+ micMuted = false;
368
+ micBtn.classList.remove("muted");
369
+ setState("idle");
370
+ }
371
+
372
+ /** @param {unknown} err */
373
+ function onFatalError(err) {
374
+ console.error("[main] fatal:", err);
375
+ setState("error");
376
+ const message = err instanceof Error ? err.message : String(err);
377
+ setCaption(truncateError(message), "error");
378
+ // Best-effort cleanup; ignore errors thrown during cleanup itself.
379
+ void teardown().catch(() => {
380
+ setState("error");
381
+ setCaption(truncateError(message), "error");
382
+ });
383
+ }
384
+
385
+ /** @param {string} text */
386
+ function truncateError(text) {
387
+ if (text.length <= 90) return text;
388
+ return text.slice(0, 87) + "…";
389
+ }
390
+
391
+ // ─── Bootstrap ──────────────────────────────────────────────────────────
392
+
393
+ setState("idle");
394
+
395
+ // Strip the .booting class after one rAF so the orb's first paint is
396
+ // committed without a fade/scale flash.
397
+ requestAnimationFrame(() => {
398
+ document.body.classList.remove("booting");
399
+ });
s2s-client.js ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /**
3
+ * Minimal WebRTC client for the Hugging Face speech-to-speech load balancer.
4
+ *
5
+ * Two-step handshake:
6
+ *
7
+ * 1. POST `<lb_url>/session` -> JSON `{ connect_url: wss://<compute>/v1/realtime?session_token=<JWT>, ... }`
8
+ * 2. POST that connect_url (after rewriting `wss://` -> `https://`) with
9
+ * `Content-Type: application/sdp` and the SDP offer body. The backend
10
+ * (aiortc) answers `201 application/sdp` with the SDP answer.
11
+ *
12
+ * Once the peer connection is up:
13
+ *
14
+ * - Audio is bidirectional on the media tracks (the browser captures the
15
+ * user's mic and sends Opus 48 kHz; the backend resamples both ways
16
+ * internally to feed the 16 kHz STT pipeline and the 16 kHz TTS output).
17
+ * - JSON events flow on a data channel labelled `oai-events`, using the
18
+ * OpenAI Realtime protocol (session.update, response.audio_transcript.delta,
19
+ * conversation.item.input_audio_transcription.delta, ...).
20
+ *
21
+ * References:
22
+ * https://github.com/huggingface/speech-to-speech (feat/webrtc-transport branch)
23
+ * https://platform.openai.com/docs/guides/realtime-webrtc
24
+ *
25
+ * @typedef {"idle" | "creating-session" | "connecting" | "connected" |
26
+ * "user-speaking" | "processing" | "ai-speaking" | "closed" | "error"
27
+ * } S2sStatus
28
+ *
29
+ * @typedef {Object} S2sSessionInfo
30
+ * @property {string} sessionId
31
+ * @property {string} connectUrl
32
+ * @property {string} websocketUrl
33
+ * @property {string} sessionToken
34
+ * @property {number} pendingTimeoutS
35
+ *
36
+ * @typedef {Object} S2sOptions
37
+ * @property {string} loadBalancerUrl Base URL of the LB (e.g. `https://...endpoints.huggingface.cloud`)
38
+ * @property {string} voice Qwen3-TTS speaker name (Aiden, Ryan, Dylan, Eric, Ono_Anna, Serena, Sohee, Uncle_Fu, Vivian)
39
+ * @property {string} instructions System prompt sent in `session.update`
40
+ * @property {MediaStreamTrack} inputTrack
41
+ *
42
+ * @typedef {Object} TranscriptEvent
43
+ * @property {"user" | "assistant"} role
44
+ * @property {string} text
45
+ * @property {boolean} partial
46
+ */
47
+
48
+ const DEFAULT_PLAYOUT_DELAY_S = 0.15;
49
+
50
+ // Opus fmtp knobs forced via SDP munging. Inline FEC trades a few bytes for
51
+ // loss resilience; DTX off keeps comfort-noise frames flowing so the server's
52
+ // VAD never thinks we silently dropped.
53
+ const OPUS_FMTP_OVERRIDES = {
54
+ minptime: "10",
55
+ useinbandfec: "1",
56
+ usedtx: "0",
57
+ stereo: "0",
58
+ "sprop-stereo": "0",
59
+ };
60
+
61
+ const ICE_DISCONNECT_GRACE_MS = 5_000;
62
+
63
+ export class S2sRealtimeClient extends EventTarget {
64
+ /** @param {S2sOptions} options */
65
+ constructor(options) {
66
+ super();
67
+ /** @type {S2sOptions} */
68
+ this.options = options;
69
+ /** @type {RTCPeerConnection | null} */
70
+ this._pc = null;
71
+ /** @type {RTCDataChannel | null} */
72
+ this._dc = null;
73
+ /** @type {S2sStatus} */
74
+ this._status = "idle";
75
+ /** @type {number | null} */
76
+ this._iceGraceTimer = null;
77
+ /** @type {(() => void) | null} */
78
+ this._pendingVisibilityHandler = null;
79
+ }
80
+
81
+ get status() {
82
+ return this._status;
83
+ }
84
+
85
+ /** @param {S2sStatus} status */
86
+ _setStatus(status) {
87
+ if (this._status === status) return;
88
+ this._status = status;
89
+ this.dispatchEvent(new CustomEvent("status", { detail: { status } }));
90
+ }
91
+
92
+ _markAudible() {
93
+ if (this._status === "ai-speaking") return;
94
+ if (this._status === "closed" || this._status === "error") return;
95
+ this._setStatus("ai-speaking");
96
+ }
97
+
98
+ /**
99
+ * Full handshake:
100
+ * 1. Create a session on the load balancer
101
+ * 2. Open the WebRTC peer connection against the routed compute endpoint
102
+ * 3. Wait for the SDP answer + setRemoteDescription
103
+ *
104
+ * Resolves once the SDP exchange completes; the data channel and the
105
+ * `session.update` payload are sent asynchronously when the channel opens.
106
+ *
107
+ * @returns {Promise<void>}
108
+ */
109
+ async connect() {
110
+ if (this._pc) throw new Error("Already connected");
111
+ this._setStatus("creating-session");
112
+
113
+ const session = await this._createSession();
114
+ this.dispatchEvent(new CustomEvent("session", { detail: { info: session } }));
115
+
116
+ this._setStatus("connecting");
117
+
118
+ const pc = new RTCPeerConnection({
119
+ iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
120
+ });
121
+ this._pc = pc;
122
+
123
+ pc.addTransceiver(this.options.inputTrack, {
124
+ direction: "sendrecv",
125
+ streams: [new MediaStream([this.options.inputTrack])],
126
+ });
127
+
128
+ pc.ontrack = (event) => {
129
+ const [stream] = event.streams;
130
+ const track = event.track.kind === "audio" ? event.track : stream?.getAudioTracks()[0];
131
+ if (!track) return;
132
+ try {
133
+ // Non-standard but widely shipped: ask the browser to buffer a bit
134
+ // of playout to absorb jitter spikes on flaky links.
135
+ /** @type {any} */ (event.receiver).playoutDelayHint = DEFAULT_PLAYOUT_DELAY_S;
136
+ } catch {
137
+ // older browsers ignore the hint
138
+ }
139
+ this.dispatchEvent(new CustomEvent("outputTrack", { detail: { track } }));
140
+ };
141
+
142
+ pc.oniceconnectionstatechange = () => {
143
+ const s = pc.iceConnectionState;
144
+ if (s === "connected" || s === "completed") {
145
+ this._clearIceGrace();
146
+ return;
147
+ }
148
+ if (s === "failed") {
149
+ this._clearIceGrace();
150
+ this.dispatchEvent(new CustomEvent("error", { detail: { error: new Error("ICE failed") } }));
151
+ this._setStatus("error");
152
+ return;
153
+ }
154
+ if (s === "disconnected") {
155
+ if (this._iceGraceTimer !== null) return;
156
+ if (typeof document !== "undefined" && document.hidden) {
157
+ this._armIceGraceOnVisibility();
158
+ return;
159
+ }
160
+ this._scheduleIceGrace();
161
+ }
162
+ };
163
+
164
+ const dc = pc.createDataChannel("oai-events");
165
+ this._dc = dc;
166
+
167
+ dc.addEventListener("open", () => {
168
+ this._setStatus("connected");
169
+ this._sendEvent({
170
+ type: "session.update",
171
+ session: {
172
+ modalities: ["audio", "text"],
173
+ voice: this.options.voice,
174
+ instructions: this.options.instructions,
175
+ input_audio_transcription: { model: "whisper-1" },
176
+ turn_detection: {
177
+ type: "server_vad",
178
+ threshold: 0.5,
179
+ prefix_padding_ms: 300,
180
+ silence_duration_ms: 500,
181
+ create_response: true,
182
+ interrupt_response: true,
183
+ },
184
+ },
185
+ });
186
+ });
187
+
188
+ dc.addEventListener("message", (event) => this._handleEvent(event.data));
189
+ dc.addEventListener("error", (event) => {
190
+ console.error("[s2s] data channel error:", event);
191
+ });
192
+
193
+ console.log("[s2s] createOffer…");
194
+ const rawOffer = await pc.createOffer();
195
+ const offerSdp = patchOpusFmtp(rawOffer.sdp ?? "");
196
+ console.log("[s2s] setLocalDescription (offer, %d chars)", offerSdp.length);
197
+ await pc.setLocalDescription({ type: "offer", sdp: offerSdp });
198
+
199
+ const httpUrl = wssToHttps(session.connectUrl);
200
+ console.log("[s2s] POST SDP offer ->", httpUrl);
201
+
202
+ let response;
203
+ try {
204
+ response = await fetch(httpUrl, {
205
+ method: "POST",
206
+ headers: { "Content-Type": "application/sdp" },
207
+ body: offerSdp,
208
+ });
209
+ } catch (err) {
210
+ const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
211
+ throw new Error(`SDP exchange fetch failed (${detail})`);
212
+ }
213
+ console.log("[s2s] SDP exchange status=%d", response.status);
214
+
215
+ if (!response.ok) {
216
+ const text = await response.text().catch(() => "");
217
+ throw new Error(`SDP handshake failed (${response.status}): ${text}`);
218
+ }
219
+
220
+ const answerSdp = patchOpusFmtp(await response.text());
221
+ await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
222
+ console.log("[s2s] SDP exchange complete");
223
+ }
224
+
225
+ /**
226
+ * @returns {Promise<S2sSessionInfo>}
227
+ */
228
+ async _createSession() {
229
+ const url = `${trimTrailingSlash(this.options.loadBalancerUrl)}/session`;
230
+ console.log("[s2s] POST", url);
231
+ const response = await fetch(url, {
232
+ method: "POST",
233
+ headers: { "Content-Type": "application/json" },
234
+ body: "{}",
235
+ });
236
+ if (!response.ok) {
237
+ const text = await response.text().catch(() => "");
238
+ throw new Error(`/session failed (${response.status}): ${text}`);
239
+ }
240
+ const json = await response.json();
241
+ return {
242
+ sessionId: json.session_id,
243
+ connectUrl: json.connect_url,
244
+ websocketUrl: json.websocket_url,
245
+ sessionToken: json.session_token,
246
+ pendingTimeoutS: json.pending_timeout_s,
247
+ };
248
+ }
249
+
250
+ _clearIceGrace() {
251
+ if (this._iceGraceTimer !== null) {
252
+ clearTimeout(this._iceGraceTimer);
253
+ this._iceGraceTimer = null;
254
+ }
255
+ if (this._pendingVisibilityHandler) {
256
+ document.removeEventListener("visibilitychange", this._pendingVisibilityHandler);
257
+ this._pendingVisibilityHandler = null;
258
+ }
259
+ }
260
+
261
+ _scheduleIceGrace() {
262
+ if (this._iceGraceTimer !== null) return;
263
+ console.warn("[s2s] ICE disconnected, grace=%dms", ICE_DISCONNECT_GRACE_MS);
264
+ this._iceGraceTimer = window.setTimeout(() => {
265
+ this._iceGraceTimer = null;
266
+ if (!this._pc) return;
267
+ const still = this._pc.iceConnectionState;
268
+ if (still === "disconnected" || still === "failed") {
269
+ this.dispatchEvent(
270
+ new CustomEvent("error", {
271
+ detail: { error: new Error(`ICE stuck in '${still}' for > ${ICE_DISCONNECT_GRACE_MS}ms`) },
272
+ }),
273
+ );
274
+ this._setStatus("error");
275
+ }
276
+ }, ICE_DISCONNECT_GRACE_MS);
277
+ }
278
+
279
+ _armIceGraceOnVisibility() {
280
+ if (this._pendingVisibilityHandler) return;
281
+ const handler = () => {
282
+ if (document.hidden) return;
283
+ document.removeEventListener("visibilitychange", handler);
284
+ this._pendingVisibilityHandler = null;
285
+ if (!this._pc) return;
286
+ const s = this._pc.iceConnectionState;
287
+ if (s === "connected" || s === "completed") return;
288
+ if (s === "failed") {
289
+ this.dispatchEvent(new CustomEvent("error", { detail: { error: new Error("ICE failed") } }));
290
+ this._setStatus("error");
291
+ return;
292
+ }
293
+ this._scheduleIceGrace();
294
+ };
295
+ document.addEventListener("visibilitychange", handler);
296
+ this._pendingVisibilityHandler = handler;
297
+ }
298
+
299
+ /** @param {string} raw */
300
+ _handleEvent(raw) {
301
+ /** @type {{ type?: string; [k: string]: any }} */
302
+ let event;
303
+ try {
304
+ event = JSON.parse(raw);
305
+ } catch {
306
+ return;
307
+ }
308
+
309
+ if (typeof event.type === "string") {
310
+ console.debug("[s2s] event:", event.type);
311
+ }
312
+
313
+ switch (event.type) {
314
+ case "input_audio_buffer.speech_started":
315
+ this._setStatus("user-speaking");
316
+ break;
317
+
318
+ case "input_audio_buffer.speech_stopped":
319
+ if (this._status === "user-speaking") {
320
+ this._setStatus("processing");
321
+ }
322
+ break;
323
+
324
+ case "response.audio.delta":
325
+ case "response.output_audio.delta":
326
+ this._markAudible();
327
+ break;
328
+
329
+ case "response.content_part.added": {
330
+ const part = event.part;
331
+ if (part?.type === "audio" || part?.type === "output_audio") {
332
+ this._markAudible();
333
+ }
334
+ break;
335
+ }
336
+
337
+ case "response.created":
338
+ case "response.output_item.added":
339
+ if (this._status === "connected" || this._status === "user-speaking") {
340
+ this._setStatus("processing");
341
+ }
342
+ break;
343
+
344
+ case "response.done":
345
+ case "response.cancelled":
346
+ if (this._status === "ai-speaking" || this._status === "processing") {
347
+ this._setStatus("connected");
348
+ }
349
+ break;
350
+
351
+ case "conversation.item.input_audio_transcription.delta": {
352
+ const delta = typeof event.delta === "string" ? event.delta : "";
353
+ if (delta) {
354
+ this.dispatchEvent(
355
+ new CustomEvent("transcript", {
356
+ detail: /** @type {TranscriptEvent} */ ({ role: "user", text: delta, partial: true }),
357
+ }),
358
+ );
359
+ }
360
+ break;
361
+ }
362
+
363
+ case "conversation.item.input_audio_transcription.completed": {
364
+ const transcript = typeof event.transcript === "string" ? event.transcript : "";
365
+ if (transcript) {
366
+ this.dispatchEvent(
367
+ new CustomEvent("transcript", {
368
+ detail: /** @type {TranscriptEvent} */ ({ role: "user", text: transcript, partial: false }),
369
+ }),
370
+ );
371
+ }
372
+ break;
373
+ }
374
+
375
+ case "response.audio_transcript.delta":
376
+ case "response.output_audio_transcript.delta": {
377
+ this._markAudible();
378
+ const delta = typeof event.delta === "string" ? event.delta : "";
379
+ if (delta) {
380
+ this.dispatchEvent(
381
+ new CustomEvent("transcript", {
382
+ detail: /** @type {TranscriptEvent} */ ({ role: "assistant", text: delta, partial: true }),
383
+ }),
384
+ );
385
+ }
386
+ break;
387
+ }
388
+
389
+ case "response.audio_transcript.done":
390
+ case "response.output_audio_transcript.done": {
391
+ const transcript = typeof event.transcript === "string" ? event.transcript : "";
392
+ if (transcript) {
393
+ this.dispatchEvent(
394
+ new CustomEvent("transcript", {
395
+ detail: /** @type {TranscriptEvent} */ ({ role: "assistant", text: transcript, partial: false }),
396
+ }),
397
+ );
398
+ }
399
+ break;
400
+ }
401
+
402
+ case "error": {
403
+ const err = event.error;
404
+ this.dispatchEvent(
405
+ new CustomEvent("error", { detail: { error: new Error(err?.message ?? "Server error") } }),
406
+ );
407
+ break;
408
+ }
409
+ }
410
+ }
411
+
412
+ /** @param {Record<string, unknown>} event */
413
+ _sendEvent(event) {
414
+ if (!this._dc || this._dc.readyState !== "open") return;
415
+ this._dc.send(JSON.stringify(event));
416
+ }
417
+
418
+ /** Update the live session (voice / instructions / etc.) without reconnecting. */
419
+ /** @param {{ voice?: string; instructions?: string }} patch */
420
+ updateSession(patch) {
421
+ /** @type {Record<string, unknown>} */
422
+ const session = { modalities: ["audio", "text"] };
423
+ if (patch.voice) session.voice = patch.voice;
424
+ if (patch.instructions) session.instructions = patch.instructions;
425
+ this._sendEvent({ type: "session.update", session });
426
+ }
427
+
428
+ /** @returns {Promise<void>} */
429
+ async close() {
430
+ this._clearIceGrace();
431
+ try {
432
+ this._dc?.close();
433
+ } catch {
434
+ // ignored
435
+ }
436
+ this._dc = null;
437
+ try {
438
+ this._pc?.close();
439
+ } catch {
440
+ // ignored
441
+ }
442
+ this._pc = null;
443
+ this._setStatus("closed");
444
+ }
445
+ }
446
+
447
+ /** @param {string} url */
448
+ function trimTrailingSlash(url) {
449
+ return url.endsWith("/") ? url.slice(0, -1) : url;
450
+ }
451
+
452
+ /** @param {string} url */
453
+ function wssToHttps(url) {
454
+ if (url.startsWith("wss://")) return "https://" + url.slice("wss://".length);
455
+ if (url.startsWith("ws://")) return "http://" + url.slice("ws://".length);
456
+ return url;
457
+ }
458
+
459
+ /**
460
+ * Rewrite each `a=fmtp:<pt> ...` line for Opus payload types so it carries
461
+ * our preferred parameters (FEC on, DTX off). Missing fmtp lines are
462
+ * synthesized next to the matching `a=rtpmap`.
463
+ *
464
+ * @param {string} sdp
465
+ * @returns {string}
466
+ */
467
+ function patchOpusFmtp(sdp) {
468
+ if (!sdp) return sdp;
469
+
470
+ const rtpmapRe = /^a=rtpmap:(\d+)\s+opus\/48000/gim;
471
+ /** @type {string[]} */
472
+ const payloadTypes = [];
473
+ let m;
474
+ while ((m = rtpmapRe.exec(sdp)) !== null) {
475
+ payloadTypes.push(m[1]);
476
+ }
477
+ if (payloadTypes.length === 0) return sdp;
478
+
479
+ let patched = sdp;
480
+ for (const pt of payloadTypes) {
481
+ const fmtpLineRe = new RegExp(`^a=fmtp:${pt}\\s+([^\\r\\n]*)`, "m");
482
+ const existing = fmtpLineRe.exec(patched);
483
+ if (existing) {
484
+ const merged = mergeFmtpParams(existing[1], OPUS_FMTP_OVERRIDES);
485
+ patched = patched.replace(fmtpLineRe, `a=fmtp:${pt} ${merged}`);
486
+ } else {
487
+ const merged = mergeFmtpParams("", OPUS_FMTP_OVERRIDES);
488
+ const rtpmapLineRe = new RegExp(`(^a=rtpmap:${pt}\\s+opus/48000[^\\r\\n]*)`, "m");
489
+ patched = patched.replace(rtpmapLineRe, `$1\r\na=fmtp:${pt} ${merged}`);
490
+ }
491
+ }
492
+ return patched;
493
+ }
494
+
495
+ /**
496
+ * @param {string} existing
497
+ * @param {Record<string, string>} overrides
498
+ * @returns {string}
499
+ */
500
+ function mergeFmtpParams(existing, overrides) {
501
+ /** @type {Map<string, string>} */
502
+ const params = new Map();
503
+ for (const chunk of existing.split(";")) {
504
+ const kv = chunk.trim();
505
+ if (!kv) continue;
506
+ const eq = kv.indexOf("=");
507
+ if (eq < 0) params.set(kv, "");
508
+ else params.set(kv.slice(0, eq).trim(), kv.slice(eq + 1).trim());
509
+ }
510
+ for (const [k, v] of Object.entries(overrides)) {
511
+ params.set(k, v);
512
+ }
513
+ return Array.from(params.entries())
514
+ .map(([k, v]) => (v ? `${k}=${v}` : k))
515
+ .join(";");
516
+ }
style.css CHANGED
@@ -1,28 +1,1074 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  }
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  }
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
 
28
  }
 
1
+ :root {
2
+ --bg: #0a0b10;
3
+ --bg-elev: #13151c;
4
+ --bg-elev-2: #1b1e29;
5
+ --border: rgba(255, 255, 255, 0.08);
6
+ --border-strong: rgba(255, 255, 255, 0.16);
7
+ --text: #f5f6fa;
8
+ --text-dim: rgba(245, 246, 250, 0.65);
9
+ --text-faint: rgba(245, 246, 250, 0.42);
10
+
11
+ --accent: #8b7dff;
12
+ --accent-2: #22d3ee;
13
+ --listening: #22d3ee;
14
+ --speaking: #8b7dff;
15
+ --processing: #f59e0b;
16
+ --error: #ff6a75;
17
+ --success: #34d399;
18
+
19
+ /* Smoothed mic RMS in [0..1], updated every frame from JS while a session
20
+ * is active. Used by audio-reactive circle states. */
21
+ --audio-level: 0;
22
+ /* Five log-spaced frequency bands extracted from the mic analyser;
23
+ * drive each bar's height independently for a real "spectrum" feel. */
24
+ --bar0: 0;
25
+ --bar1: 0;
26
+ --bar2: 0;
27
+ --bar3: 0;
28
+ --bar4: 0;
29
+
30
+ --radius-sm: 8px;
31
+ --radius-md: 14px;
32
+ --radius-lg: 22px;
33
+
34
+ --shadow-soft: 0 10px 40px rgba(0, 0, 0, 0.35);
35
+
36
+ color-scheme: dark;
37
+ }
38
+
39
+ * {
40
+ box-sizing: border-box;
41
+ }
42
+
43
+ html,
44
  body {
45
+ margin: 0;
46
+ padding: 0;
47
+ height: 100%;
48
+ }
49
+
50
+ body {
51
+ font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
52
+ background: radial-gradient(ellipse at 50% 20%, #1a1c28 0%, var(--bg) 60%);
53
+ color: var(--text);
54
+ min-height: 100vh;
55
+ overflow-x: hidden;
56
+ -webkit-font-smoothing: antialiased;
57
+ }
58
+
59
+ button {
60
+ font-family: inherit;
61
+ }
62
+
63
+ a {
64
+ color: var(--text);
65
+ text-decoration: none;
66
+ border-bottom: 1px solid var(--border-strong);
67
+ }
68
+ a:hover {
69
+ border-bottom-color: var(--text);
70
+ }
71
+
72
+ #app {
73
+ display: grid;
74
+ grid-template-rows: auto 1fr auto;
75
+ min-height: 100vh;
76
+ }
77
+
78
+ .hidden {
79
+ display: none !important;
80
+ }
81
+
82
+ /* ─── Topbar ──────────────────────────────────────────────────────────── */
83
+
84
+ /* Topbar is just a floating row of controls over the stage - no
85
+ * separator line, no background. Same story for the footer. Keeps the
86
+ * app feeling like one continuous canvas. */
87
+ .topbar {
88
+ display: flex;
89
+ align-items: center;
90
+ justify-content: space-between;
91
+ padding: 18px 28px;
92
+ }
93
+
94
+ .brand {
95
+ display: flex;
96
+ align-items: center;
97
+ gap: 10px;
98
+ font-weight: 600;
99
+ font-size: 14px;
100
+ letter-spacing: 0.01em;
101
+ color: var(--text-dim);
102
+ }
103
+
104
+ .brand .brand-logo {
105
+ width: 26px;
106
+ height: 26px;
107
+ object-fit: contain;
108
+ flex: none;
109
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.45));
110
+ transition: transform 0.2s ease;
111
+ }
112
+ .brand .brand-logo:hover {
113
+ transform: translateY(-1px) rotate(-2deg);
114
+ }
115
+
116
+ /* Narrow viewports (typically the mobile shell iframe and phone-sized
117
+ browser windows): the full product name pushes the topbar's right
118
+ cluster off-screen. Keep the logo as the brand cue and drop the
119
+ wordmark - the orb already gives enough context. */
120
+ @media (max-width: 600px) {
121
+ .brand > span {
122
+ display: none;
123
+ }
124
+ }
125
+
126
+ .topbar-right {
127
+ display: flex;
128
+ align-items: center;
129
+ gap: 10px;
130
+ }
131
+
132
+ /* The HF user pill: compact avatar + handle. Rendered as a flex row so
133
+ * the avatar stays aligned with the text baseline even when the text
134
+ * wraps on narrow viewports. */
135
+ .hf-user {
136
+ display: inline-flex;
137
+ align-items: center;
138
+ gap: 8px;
139
+ font-size: 13px;
140
+ color: var(--text-dim);
141
+ padding: 4px 10px 4px 4px;
142
+ border-radius: 999px;
143
+ background: var(--bg-elev);
144
+ border: 1px solid var(--border);
145
+ line-height: 1;
146
+ }
147
+
148
+ .hf-avatar {
149
+ width: 24px;
150
+ height: 24px;
151
+ border-radius: 50%;
152
+ object-fit: cover;
153
+ background: color-mix(in srgb, var(--accent) 25%, var(--bg-elev-2));
154
+ /* Hidden until an actual URL loads (see `setHfAvatar` in main.ts).
155
+ * Keeps the initial login flash from showing a broken image icon. */
156
+ opacity: 0;
157
+ transition: opacity 0.25s ease;
158
+ flex: none;
159
+ }
160
+ .hf-avatar.loaded {
161
+ opacity: 1;
162
+ }
163
+
164
+ .hf-user-name {
165
+ white-space: nowrap;
166
+ }
167
+
168
+ /* ─── Transport pill ──────────────────────────────────────────────
169
+ * Surface the actual network path WebRTC picked for the robot peer
170
+ * connection (LAN, direct-through-NAT, or TURN-relayed). Lets the
171
+ * user spot at a glance when audio is going through the internet
172
+ * instead of staying on-prem.
173
+ */
174
+ .transport-pill {
175
+ display: inline-flex;
176
+ align-items: center;
177
+ gap: 6px;
178
+ font-size: 12px;
179
+ font-weight: 500;
180
+ letter-spacing: 0.02em;
181
+ padding: 5px 10px;
182
+ border-radius: 999px;
183
+ background: var(--bg-elev);
184
+ border: 1px solid var(--border);
185
+ color: var(--text-dim);
186
+ user-select: none;
187
+ transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
188
+ }
189
+
190
+ .transport-pill .transport-dot {
191
+ width: 7px;
192
+ height: 7px;
193
+ border-radius: 50%;
194
+ background: currentColor;
195
+ box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 25%, transparent);
196
+ flex: none;
197
+ }
198
+
199
+ /* Bitrate readout sits after the kind label. Dimmer + monospace so the
200
+ * kind (LAN / Direct / Relayed) stays the primary info and the numbers
201
+ * don't wiggle the layout as digits change. */
202
+ .transport-pill .transport-bitrate {
203
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
204
+ font-size: 11px;
205
+ font-weight: 500;
206
+ color: color-mix(in srgb, currentColor 75%, var(--text-dim));
207
+ opacity: 0.85;
208
+ padding-left: 6px;
209
+ border-left: 1px solid color-mix(in srgb, currentColor 25%, transparent);
210
+ }
211
+ .transport-pill .transport-bitrate:empty {
212
+ display: none;
213
+ }
214
+
215
+ .transport-pill.transport-checking {
216
+ color: var(--text-dim);
217
+ }
218
+ .transport-pill.transport-checking .transport-dot {
219
+ animation: transport-blink 1.2s ease-in-out infinite;
220
+ }
221
+
222
+ .transport-pill.transport-lan {
223
+ color: #4ade80;
224
+ border-color: color-mix(in srgb, #4ade80 35%, var(--border));
225
+ background: color-mix(in srgb, #4ade80 10%, var(--bg-elev));
226
+ }
227
+
228
+ .transport-pill.transport-direct {
229
+ color: #60a5fa;
230
+ border-color: color-mix(in srgb, #60a5fa 35%, var(--border));
231
+ background: color-mix(in srgb, #60a5fa 10%, var(--bg-elev));
232
+ }
233
+
234
+ .transport-pill.transport-relay {
235
+ color: #fbbf24;
236
+ border-color: color-mix(in srgb, #fbbf24 35%, var(--border));
237
+ background: color-mix(in srgb, #fbbf24 10%, var(--bg-elev));
238
+ }
239
+
240
+ @keyframes transport-blink {
241
+ 0%, 100% { opacity: 1; }
242
+ 50% { opacity: 0.35; }
243
+ }
244
+
245
+ .icon-btn {
246
+ background: var(--bg-elev);
247
+ border: 1px solid var(--border);
248
+ color: var(--text-dim);
249
+ width: 36px;
250
+ height: 36px;
251
+ border-radius: var(--radius-sm);
252
+ display: inline-flex;
253
+ align-items: center;
254
+ justify-content: center;
255
+ cursor: pointer;
256
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
257
+ }
258
+ .icon-btn:hover {
259
+ background: var(--bg-elev-2);
260
+ color: var(--text);
261
+ border-color: var(--border-strong);
262
+ }
263
+
264
+ /* ─── Stage ───────────────────────────────────────────────────────────── */
265
+
266
+ .stage {
267
+ display: flex;
268
+ flex-direction: column;
269
+ align-items: center;
270
+ justify-content: center;
271
+ padding: 24px 24px 32px;
272
+ gap: 20px;
273
+ }
274
+
275
+ /* ─── Central circle ──────────────────────────────────────────────────── */
276
+
277
+ /* Wraps the orb and its two side controls so they stay aligned on one row. */
278
+ .orb-wrap {
279
+ position: relative;
280
+ display: flex;
281
+ align-items: center;
282
+ justify-content: center;
283
+ gap: clamp(18px, 3vw, 32px);
284
+ }
285
+
286
+ .circle {
287
+ position: relative;
288
+ width: clamp(220px, 38vw, 320px);
289
+ aspect-ratio: 1 / 1;
290
+ border-radius: 50%;
291
+ border: none;
292
+ background: transparent;
293
+ padding: 0;
294
+ cursor: pointer;
295
+ outline: none;
296
+ display: grid;
297
+ place-items: center;
298
+ color: var(--glow, var(--accent));
299
+ transition: transform 0.18s ease, filter 0.18s ease;
300
+ -webkit-tap-highlight-color: transparent;
301
+ }
302
+ .circle:hover {
303
+ filter: brightness(1.08);
304
+ }
305
+ .circle:active {
306
+ transform: scale(0.97);
307
+ filter: brightness(0.92);
308
+ }
309
+ .circle:focus-visible .circle-core {
310
+ outline: 2px solid var(--glow, var(--accent));
311
+ outline-offset: 6px;
312
+ }
313
+ .circle[disabled] {
314
+ cursor: default;
315
+ opacity: 0.75;
316
+ }
317
+
318
+ .circle-glow {
319
+ position: absolute;
320
+ inset: 0;
321
+ border-radius: 50%;
322
+ background: radial-gradient(circle at center, var(--glow, var(--accent)) 0%, transparent 65%);
323
+ filter: blur(28px);
324
+ opacity: 0.5;
325
+ transform: scale(1);
326
+ transition: opacity 0.25s, background 0.25s, transform 1.4s ease-in-out;
327
+ pointer-events: none;
328
+ }
329
+
330
+ /* Two nested circular rings: the inner tracks the core edge, the outer
331
+ * expands / fades to convey "audio radiating out" during speaking. */
332
+ .circle-ring,
333
+ .circle-ring-outer {
334
+ position: absolute;
335
+ top: 50%;
336
+ left: 50%;
337
+ border-radius: 50%;
338
+ transform: translate(-50%, -50%);
339
+ pointer-events: none;
340
+ transition: opacity 0.4s ease, border-color 0.4s ease, transform 0.3s ease;
341
+ }
342
+ .circle-ring {
343
+ width: 82%;
344
+ height: 82%;
345
+ border: 1.5px solid color-mix(in srgb, var(--glow, var(--accent)) 35%, transparent);
346
+ opacity: 0.35;
347
+ }
348
+ .circle-ring-outer {
349
+ width: 94%;
350
+ height: 94%;
351
+ border: 1px solid color-mix(in srgb, var(--glow, var(--accent)) 22%, transparent);
352
+ opacity: 0;
353
+ }
354
+
355
+ .circle-core {
356
+ position: relative;
357
+ width: 72%;
358
+ height: 72%;
359
+ border-radius: 50%;
360
+ background: radial-gradient(
361
+ circle at 35% 28%,
362
+ color-mix(in srgb, var(--glow, var(--accent)) 28%, transparent),
363
+ color-mix(in srgb, var(--glow, var(--accent)) 10%, transparent) 55%,
364
+ color-mix(in srgb, var(--glow, var(--accent)) 5%, transparent)
365
+ );
366
+ border: 2px solid color-mix(in srgb, var(--glow, var(--accent)) 40%, transparent);
367
+ display: grid;
368
+ place-items: center;
369
+ box-shadow:
370
+ 0 0 32px color-mix(in srgb, var(--glow, var(--accent)) 22%, transparent),
371
+ inset 0 0 28px color-mix(in srgb, var(--glow, var(--accent)) 18%, transparent);
372
+ transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease, transform 0.4s ease;
373
+ }
374
+
375
+ /* Indicator slot: a single SVG / spinner / bar group is visible at a time,
376
+ * driven by the state class on `.circle`. */
377
+ .circle-indicator {
378
+ position: relative;
379
+ width: 44%;
380
+ height: 44%;
381
+ display: grid;
382
+ place-items: center;
383
+ color: var(--glow, var(--accent));
384
+ }
385
+ .circle-indicator > .ind {
386
+ grid-area: 1 / 1;
387
+ opacity: 0;
388
+ transform: scale(0.85);
389
+ transition: opacity 0.25s ease, transform 0.25s ease;
390
+ pointer-events: none;
391
+ }
392
+ .circle-indicator > svg.ind {
393
+ width: 60%;
394
+ height: 60%;
395
+ }
396
+
397
+ /* Spinner: a rotating ring gap, CSS-only.
398
+ *
399
+ * Note: the base `.circle-indicator > .ind` rule forces `transform:
400
+ * scale(.85)` / `scale(1)` on every indicator to drive the show/hide
401
+ * transition. If we only rotate here, the browser has to interpolate
402
+ * between `scale(1)` and `rotate(360deg)` (two different transform
403
+ * functions), which produces a broken, barely-moving animation. So we
404
+ * include the scale explicitly in the keyframes and bump specificity
405
+ * with `!important` so the spinner always wins over the state rule. */
406
+ .ind-spinner {
407
+ width: 48%;
408
+ height: 48%;
409
+ border: 3px solid currentColor;
410
+ border-right-color: transparent;
411
+ border-radius: 50%;
412
+ opacity: 0;
413
+ animation: ind-spin 0.9s linear infinite;
414
+ }
415
+
416
+ /* Thinking dots: 3 soft pulsing dots while the model is composing a
417
+ * response. Apple-style cadence: each dot scales up and brightens in
418
+ * turn, staggered by ~160 ms. Per-dot animation is on the child, so
419
+ * the parent's scale(.85 β†’ 1) show/hide transform composes cleanly. */
420
+ .ind-thinking {
421
+ display: flex;
422
+ align-items: center;
423
+ justify-content: center;
424
+ gap: 8px;
425
+ width: 60%;
426
+ height: 60%;
427
+ }
428
+ .ind-thinking .dot {
429
+ width: 10px;
430
+ height: 10px;
431
+ border-radius: 50%;
432
+ background: currentColor;
433
+ opacity: 0.3;
434
+ animation: thinking-dot 1.25s ease-in-out infinite;
435
+ }
436
+ .ind-thinking .dot:nth-child(1) { animation-delay: 0s; }
437
+ .ind-thinking .dot:nth-child(2) { animation-delay: 0.16s; }
438
+ .ind-thinking .dot:nth-child(3) { animation-delay: 0.32s; }
439
+
440
+ /* Bars: 5 vertical pills driven by --bar0..--bar4 CSS vars. */
441
+ .ind-bars {
442
+ display: flex;
443
+ align-items: center;
444
+ gap: 5px;
445
+ height: 42%;
446
+ }
447
+ .ind-bars .bar {
448
+ width: 4px;
449
+ min-height: 4px;
450
+ border-radius: 3px;
451
+ background: currentColor;
452
+ opacity: 0.7;
453
+ --h: var(--bar0, 0);
454
+ height: calc(4px + var(--h) * 36px);
455
+ transition: height 0.08s ease-out, opacity 0.08s ease-out;
456
+ }
457
+ .ind-bars .bar:nth-child(1) { --h: var(--bar0); }
458
+ .ind-bars .bar:nth-child(2) { --h: var(--bar1); }
459
+ .ind-bars .bar:nth-child(3) { --h: var(--bar2); }
460
+ .ind-bars .bar:nth-child(4) { --h: var(--bar3); }
461
+ .ind-bars .bar:nth-child(5) { --h: var(--bar4); }
462
+ .ind-bars .bar {
463
+ opacity: calc(0.55 + 0.45 * var(--h));
464
+ }
465
+
466
+ /* Active indicator per state.
467
+ *
468
+ * Note: `ai-speaking` deliberately has NO indicator here. The orb
469
+ * itself becomes the indicator by pulsing on Reachy's voice (see the
470
+ * `--ai-audio-level` rules further down), which is a lot clearer and
471
+ * less confusing than reusing the mic-bars (which viewers would read
472
+ * as "you are speaking"). */
473
+ .state-signed-out .ind-connect,
474
+ .state-authenticated .ind-mic,
475
+ .state-ready .ind-mic,
476
+ .state-connecting .ind-spinner,
477
+ .state-connected .ind-spinner,
478
+ .state-auto-selecting .ind-spinner,
479
+ .state-starting .ind-spinner,
480
+ .state-processing .ind-thinking,
481
+ .state-listening .ind-bars,
482
+ .state-user-speaking .ind-bars,
483
+ .state-ai-speaking .ind-voice,
484
+ .state-error .ind-error {
485
+ opacity: 1;
486
+ transform: scale(1);
487
+ }
488
+
489
+ /* AI speaking indicator: speaker + two sound waves.
490
+ *
491
+ * Each wave pulses outward (opacity + stroke grow) with a quarter-
492
+ * beat offset so it reads as sound radiating out. Kept as a pure
493
+ * CSS animation so the icon is always visually alive even between
494
+ * syllables when --ai-audio-level momentarily dips. */
495
+ .ind-voice .wave {
496
+ transform-origin: 50% 50%;
497
+ animation: voice-wave 1.35s ease-out infinite;
498
+ }
499
+ .ind-voice .wave-1 { animation-delay: 0s; }
500
+ .ind-voice .wave-2 { animation-delay: 0.35s; }
501
+
502
+ /* Idle indicators: a chain-link "connect" glyph for the signed-out
503
+ * step (invites the user to authenticate with HF) and a microphone
504
+ * once a session is ready. Both picked up by the generic
505
+ * `.circle-indicator > svg.ind` sizing rule (60% Γ— 60% of the slot)
506
+ * and rely on per-state opacity transitions for show / hide. */
507
+ .ind-connect,
508
+ .ind-mic {
509
+ color: color-mix(in srgb, var(--glow, var(--accent)) 85%, white);
510
+ }
511
+
512
+ /* ─── Caption below the circle ────────────────────────────────────────── */
513
+
514
+ /* The caption under the orb is meant to whisper, not shout: micro-label
515
+ * vibe, uppercase, letter-spaced, muted. Only appears for actionable /
516
+ * transitional states (see STATE_VIEWS). During a live conversation the
517
+ * orb alone carries the state so we collapse this row entirely. */
518
+ .circle-caption {
519
+ margin: 0;
520
+ font-size: 11px;
521
+ font-weight: 600;
522
+ letter-spacing: 0.16em;
523
+ text-transform: uppercase;
524
+ color: var(--text-faint);
525
+ min-height: 1.2em;
526
+ text-align: center;
527
+ opacity: 0.75;
528
+ transition: opacity 0.25s ease, color 0.25s ease, transform 0.25s ease,
529
+ min-height 0.25s ease;
530
+ }
531
+ .circle-caption.empty {
532
+ opacity: 0;
533
+ min-height: 0;
534
+ transform: translateY(-4px);
535
+ pointer-events: none;
536
+ }
537
+ .circle-caption.muted {
538
+ color: var(--text-faint);
539
+ opacity: 0.65;
540
+ }
541
+ .circle-caption.error {
542
+ color: var(--error);
543
+ opacity: 1;
544
+ letter-spacing: 0.08em;
545
  }
546
 
547
+ /* ─── Tool-call toaster ───────────────────────────────────────────────── */
548
+
549
+ /* A small, non-interactive pill that appears below the circle when the
550
+ * model invokes a tool (move_head, play_move). Sits just under the
551
+ * state caption, collapses to zero height when no toast is active. */
552
+ .tool-toast {
553
+ display: inline-flex;
554
+ align-items: center;
555
+ gap: 8px;
556
+ margin-top: 10px;
557
+ padding: 6px 12px;
558
+ border-radius: 999px;
559
+ border: 1px solid var(--border-strong);
560
+ background: color-mix(in srgb, var(--bg-elev-2) 78%, transparent);
561
+ color: var(--text-dim);
562
+ font-size: 12px;
563
+ font-weight: 500;
564
+ letter-spacing: 0.01em;
565
+ line-height: 1;
566
+ white-space: nowrap;
567
+ max-width: 80vw;
568
+ overflow: hidden;
569
+ text-overflow: ellipsis;
570
+
571
+ opacity: 0;
572
+ transform: translateY(-4px) scale(0.96);
573
+ pointer-events: none;
574
+ transition: opacity 0.22s ease, transform 0.22s ease;
575
+ }
576
+ .tool-toast.visible {
577
+ opacity: 1;
578
+ transform: translateY(0) scale(1);
579
+ }
580
+ .tool-toast-icon {
581
+ width: 14px;
582
+ height: 14px;
583
+ flex: none;
584
+ color: color-mix(in srgb, var(--accent) 80%, white);
585
+ animation: tool-toast-spin 3.2s linear infinite;
586
+ animation-play-state: paused;
587
+ }
588
+ .tool-toast.visible .tool-toast-icon {
589
+ animation-play-state: running;
590
+ }
591
+ .tool-toast-text {
592
+ display: inline-block;
593
+ overflow: hidden;
594
+ text-overflow: ellipsis;
595
+ }
596
+ @keyframes tool-toast-spin {
597
+ from { transform: rotate(0deg); }
598
+ to { transform: rotate(360deg); }
599
+ }
600
+
601
+ /* ─── Side controls (mic / stop) ──────────────────────────────────────── */
602
+
603
+ .side-btn {
604
+ flex: none;
605
+ width: 52px;
606
+ height: 52px;
607
+ border-radius: 50%;
608
+ border: 1px solid var(--border-strong);
609
+ /* Brighter background so the buttons actually stand out against the
610
+ * deep-blue stage gradient; previous var(--bg-elev) was too close to
611
+ * the page bg to be readable. */
612
+ background: color-mix(in srgb, var(--bg-elev-2) 80%, #2a2e3c);
613
+ color: var(--text);
614
+ display: inline-flex;
615
+ align-items: center;
616
+ justify-content: center;
617
+ cursor: pointer;
618
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35),
619
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
620
+ /* Hidden by default: take up no space until the session is live. The
621
+ * `width: 0` collapse keeps the orb centered on the idle screen. */
622
+ opacity: 0;
623
+ transform: scale(0.55);
624
+ width: 0;
625
+ padding: 0;
626
+ pointer-events: none;
627
+ overflow: hidden;
628
+ transition: opacity 0.25s ease, transform 0.25s ease, width 0.25s ease,
629
+ background 0.15s, color 0.15s, border-color 0.15s;
630
+ }
631
+ .side-btn:hover {
632
+ background: color-mix(in srgb, var(--bg-elev-2) 60%, #323746);
633
+ border-color: var(--text-dim);
634
+ }
635
+ .side-btn svg {
636
+ width: 22px;
637
+ height: 22px;
638
+ flex: none;
639
+ }
640
+ .side-btn .mic-off { display: none; }
641
+ .side-btn.muted {
642
+ color: #fff;
643
+ border-color: var(--error);
644
+ background: color-mix(in srgb, var(--error) 70%, #1a0e13);
645
+ }
646
+ .side-btn.muted .mic-on { display: none; }
647
+ .side-btn.muted .mic-off { display: block; }
648
+
649
+ /* Stop button: subtle warm tint so "end" reads as destructive. */
650
+ #stop-btn:hover {
651
+ color: #fff;
652
+ border-color: color-mix(in srgb, var(--error) 60%, var(--border-strong));
653
+ background: color-mix(in srgb, var(--error) 25%, var(--bg-elev-2));
654
+ }
655
+
656
+ /* Reveal when the session is live: buttons flank the orb on a flex row. */
657
+ .orb-wrap.live .side-btn {
658
+ opacity: 1;
659
+ transform: scale(1);
660
+ width: 52px;
661
+ pointer-events: auto;
662
+ }
663
+
664
+ /* Disable every transition / animation during the first paint so the
665
+ * orb doesn't fade-and-scale in when the page loads. `main.ts` removes
666
+ * the class after one animation frame. */
667
+ body.booting,
668
+ body.booting *,
669
+ body.booting *::before,
670
+ body.booting *::after {
671
+ transition: none !important;
672
+ animation-duration: 0s !important;
673
+ animation-delay: 0s !important;
674
+ }
675
+
676
+ /* ─── Circle animation keyframes ──────────────────────────────────────── */
677
+
678
+ /* Slow, subtle breathing for "warm idle" states. */
679
+ @keyframes breathe {
680
+ 0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.4; }
681
+ 50% { transform: translate(-50%, -50%) scale(1.06); opacity: 0.15; }
682
+ }
683
+
684
+ /* Outer ring expanding and fading - conveys "I am producing audio". */
685
+ @keyframes ring-out {
686
+ 0% { transform: translate(-50%, -50%) scale(1); opacity: 0.35; }
687
+ 100% { transform: translate(-50%, -50%) scale(1.18); opacity: 0; }
688
+ }
689
+
690
+ /* Soft inner scale for the core while talking. */
691
+ @keyframes core-breathe {
692
+ 0%, 100% { transform: scale(1); }
693
+ 50% { transform: scale(1.04); }
694
+ }
695
+
696
+ /* Subtle glow throb used for "thinking" β€” dimmer than speaking. */
697
+ @keyframes thinking {
698
+ 0%, 100% { transform: scale(1); opacity: 0.7; }
699
+ 50% { transform: scale(0.96); opacity: 0.45; }
700
+ }
701
+
702
+ /* Individual dot pulse for the 3-dot processing indicator. */
703
+ @keyframes thinking-dot {
704
+ 0%, 60%, 100% { transform: scale(0.7); opacity: 0.3; }
705
+ 30% { transform: scale(1.15); opacity: 1; }
706
+ }
707
+
708
+ /* Sound-wave pulse: arc fades in, scales up slightly, fades out.
709
+ * The transform-origin is the speaker's center (roughly x=12), so
710
+ * the waves feel like they're emanating from the cone. */
711
+ @keyframes voice-wave {
712
+ 0% { opacity: 0; transform: scale(0.7); }
713
+ 30% { opacity: 1; transform: scale(1); }
714
+ 70% { opacity: 0.2; transform: scale(1.12); }
715
+ 100% { opacity: 0; transform: scale(0.7); }
716
+ }
717
+
718
+ @keyframes ind-spin {
719
+ /* Scale kept at 1 so we don't fight with the indicator's base
720
+ * show/hide transform (see `.ind-spinner` for the rationale). */
721
+ from { transform: scale(1) rotate(0deg); }
722
+ to { transform: scale(1) rotate(360deg); }
723
+ }
724
+
725
+ /* ─── State-specific colors ──────────────────────────────────────────── */
726
+
727
+ .circle.state-signed-out { --glow: #8b7dff; }
728
+ .circle.state-authenticated,
729
+ .circle.state-ready { --glow: #34d399; }
730
+ .circle.state-connecting,
731
+ .circle.state-connected,
732
+ .circle.state-auto-selecting,
733
+ .circle.state-starting { --glow: #facc15; }
734
+ .circle.state-listening,
735
+ .circle.state-user-speaking { --glow: var(--listening); }
736
+ .circle.state-processing { --glow: var(--processing); }
737
+ .circle.state-ai-speaking { --glow: var(--speaking); }
738
+ .circle.state-error { --glow: var(--error); }
739
+
740
+ /* Idle / ready: gentle breathing of the inner ring. Kept out of the
741
+ * `signed-out` state so the very first paint on page load stays quiet
742
+ * (the orb now shows the Reachy head silhouette, no need to also pulse). */
743
+ .circle.state-authenticated .circle-ring,
744
+ .circle.state-ready .circle-ring {
745
+ animation: breathe 2.4s ease-in-out infinite;
746
+ }
747
+
748
+ /* Connecting flows: subtle glow throb so the orb feels thoughtful
749
+ * while the session is being negotiated. Kept off `processing` on
750
+ * purpose - the 3 thinking dots already pulse, and layering another
751
+ * throb on top competes with them for attention. */
752
+ .circle.state-connecting .circle-core,
753
+ .circle.state-connected .circle-core,
754
+ .circle.state-auto-selecting .circle-core,
755
+ .circle.state-starting .circle-core {
756
+ animation: thinking 1.4s ease-in-out infinite;
757
+ }
758
+
759
+ /* User is speaking: the mic RMS drives scale + opacity via --audio-level. */
760
+ .circle.state-user-speaking .circle-ring {
761
+ animation: none;
762
+ opacity: calc(0.25 + 0.55 * var(--audio-level));
763
+ transform: translate(-50%, -50%) scale(calc(1 + 0.08 * var(--audio-level)));
764
+ transition: transform 0.08s linear, opacity 0.08s linear;
765
+ }
766
+ .circle.state-user-speaking .circle-ring-outer {
767
+ opacity: calc(0.1 + 0.35 * var(--audio-level));
768
+ transform: translate(-50%, -50%) scale(calc(1 + 0.12 * var(--audio-level)));
769
+ transition: transform 0.08s linear, opacity 0.08s linear;
770
+ }
771
+ .circle.state-listening .circle-ring {
772
+ animation: breathe 2s ease-in-out infinite;
773
+ }
774
+
775
+ /* Assistant is speaking: the whole orb breathes in sync with Reachy's
776
+ * voice instead of running a fixed timer. `--ai-audio-level` (0-1) is
777
+ * updated at display rate by AiLevelMonitor from the OpenAI output
778
+ * track, so every syllable visibly moves the core + outer ring. This
779
+ * reads instantly as "the orb is the voice" and completely avoids the
780
+ * ambiguity of bars-vs-mic the user flagged. */
781
+ .circle.state-ai-speaking .circle-core {
782
+ animation: none;
783
+ transform: scale(calc(1 + 0.09 * var(--ai-audio-level, 0)));
784
+ transition: transform 0.08s linear;
785
+ }
786
+ .circle.state-ai-speaking .circle-ring {
787
+ animation: none;
788
+ opacity: calc(0.3 + 0.5 * var(--ai-audio-level, 0));
789
+ transform: translate(-50%, -50%) scale(calc(1 + 0.05 * var(--ai-audio-level, 0)));
790
+ transition: transform 0.08s linear, opacity 0.08s linear;
791
+ }
792
+ .circle.state-ai-speaking .circle-ring-outer {
793
+ animation: none;
794
+ opacity: calc(0.15 + 0.55 * var(--ai-audio-level, 0));
795
+ transform: translate(-50%, -50%) scale(calc(1 + 0.18 * var(--ai-audio-level, 0)));
796
+ transition: transform 0.08s linear, opacity 0.08s linear;
797
+ }
798
+ .circle.state-ai-speaking .circle-glow {
799
+ opacity: calc(0.35 + 0.45 * var(--ai-audio-level, 0));
800
+ transition: opacity 0.08s linear;
801
+ }
802
+
803
+ /* ─── Robot picker ────────────────────────────────────────────────────── */
804
+
805
+ .robot-picker {
806
+ width: min(420px, 92vw);
807
+ background: var(--bg-elev);
808
+ border: 1px solid var(--border);
809
+ border-radius: var(--radius-md);
810
+ padding: 14px 16px;
811
+ box-shadow: var(--shadow-soft);
812
+ }
813
+
814
+ .picker-title {
815
+ margin: 0 0 10px;
816
+ font-size: 11px;
817
+ font-weight: 600;
818
+ letter-spacing: 0.1em;
819
+ text-transform: uppercase;
820
+ color: var(--text-faint);
821
+ }
822
+
823
+ .robot-list {
824
+ display: flex;
825
+ flex-direction: column;
826
+ gap: 8px;
827
+ }
828
+
829
+ .robot-card {
830
+ display: flex;
831
+ justify-content: space-between;
832
+ align-items: center;
833
+ padding: 12px 14px;
834
+ border: 1px solid var(--border);
835
+ border-radius: var(--radius-sm);
836
+ background: var(--bg-elev-2);
837
+ cursor: pointer;
838
+ transition: border-color 0.15s, background 0.15s;
839
+ }
840
+ .robot-card:hover {
841
+ border-color: var(--border-strong);
842
+ }
843
+ .robot-card.selected {
844
+ border-color: var(--accent);
845
+ background: rgba(139, 125, 255, 0.12);
846
  }
847
 
848
+ .robot-card .name {
849
+ font-weight: 600;
850
+ font-size: 14px;
 
 
851
  }
852
+ .robot-card .id {
853
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
854
+ font-size: 12px;
855
+ color: var(--text-faint);
856
+ }
857
+
858
+ .robot-empty {
859
+ padding: 14px;
860
+ text-align: center;
861
+ color: var(--text-faint);
862
+ font-size: 13px;
863
+ }
864
+
865
+ /* ─── Footer ──────────────────────────────────────────────────────────── */
866
 
867
+ .footer {
868
+ padding: 12px 28px 18px;
869
+ font-size: 11px;
870
+ letter-spacing: 0.02em;
871
+ color: var(--text-faint);
872
+ display: flex;
873
+ justify-content: center;
874
+ opacity: 0.55;
875
+ transition: opacity 0.25s ease;
876
+ }
877
+ .footer:hover {
878
+ opacity: 0.9;
879
+ }
880
+ .footer a {
881
+ color: inherit;
882
+ text-decoration: none;
883
+ border-bottom: 1px dotted currentColor;
884
+ }
885
+ .footer a:hover {
886
+ color: var(--text-dim);
887
+ }
888
+
889
+ /* ─── Modal ───────────────────────────────────────────────────────────── */
890
+
891
+ .modal {
892
+ border: 1px solid var(--border);
893
+ border-radius: var(--radius-md);
894
+ padding: 0;
895
+ background: var(--bg-elev);
896
+ color: var(--text);
897
+ width: min(440px, 92vw);
898
+ box-shadow: var(--shadow-soft);
899
+ }
900
+ .modal::backdrop {
901
+ background: rgba(8, 9, 13, 0.65);
902
+ backdrop-filter: blur(4px);
903
+ }
904
+
905
+ .modal-content {
906
+ display: flex;
907
+ flex-direction: column;
908
+ gap: 16px;
909
+ padding: 22px 24px 20px;
910
+ }
911
+
912
+ .modal-header {
913
+ display: flex;
914
+ align-items: center;
915
+ justify-content: space-between;
916
+ margin-bottom: 4px;
917
+ }
918
+ .modal-header h2 {
919
+ margin: 0;
920
+ font-size: 16px;
921
+ font-weight: 600;
922
+ letter-spacing: 0.02em;
923
+ }
924
+
925
+ .field {
926
+ display: flex;
927
+ flex-direction: column;
928
+ gap: 6px;
929
+ font-size: 13px;
930
+ font-weight: 500;
931
+ color: var(--text-dim);
932
+ }
933
+ .field > span {
934
+ color: var(--text);
935
+ font-weight: 600;
936
+ font-size: 12px;
937
+ letter-spacing: 0.04em;
938
+ text-transform: uppercase;
939
+ }
940
+ .field input,
941
+ .field select,
942
+ .field textarea {
943
+ font-family: inherit;
944
+ font-size: 14px;
945
+ color: var(--text);
946
+ background: var(--bg);
947
+ border: 1px solid var(--border);
948
+ border-radius: var(--radius-sm);
949
+ padding: 10px 12px;
950
+ outline: none;
951
+ transition: border-color 0.15s;
952
+ resize: vertical;
953
+ }
954
+ .field textarea {
955
+ min-height: 84px;
956
+ }
957
+ .field input:focus,
958
+ .field select:focus,
959
+ .field textarea:focus {
960
+ border-color: var(--accent);
961
+ }
962
+ .field small {
963
+ color: var(--text-faint);
964
+ font-size: 12px;
965
+ line-height: 1.4;
966
+ }
967
+ .field small code {
968
+ background: var(--bg);
969
+ padding: 1px 5px;
970
+ border-radius: 4px;
971
+ border: 1px solid var(--border);
972
+ }
973
+
974
+ .modal-footer {
975
+ display: flex;
976
+ justify-content: space-between;
977
+ gap: 12px;
978
+ padding-top: 6px;
979
+ }
980
+
981
+ .btn {
982
+ padding: 10px 16px;
983
+ border-radius: var(--radius-sm);
984
+ border: 1px solid var(--border-strong);
985
+ background: var(--bg-elev-2);
986
+ color: var(--text);
987
+ font-weight: 600;
988
+ font-size: 13px;
989
+ cursor: pointer;
990
+ transition: background 0.15s, border-color 0.15s, transform 0.05s;
991
+ }
992
+ .btn:hover {
993
+ border-color: var(--text);
994
+ }
995
+ .btn:active {
996
+ transform: translateY(1px);
997
+ }
998
+ .btn.primary {
999
+ background: var(--accent);
1000
+ border-color: var(--accent);
1001
+ color: #0a0b10;
1002
+ }
1003
+ .btn.primary:hover {
1004
+ background: #9d92ff;
1005
+ }
1006
+ .btn.ghost {
1007
+ background: transparent;
1008
+ border-color: var(--border);
1009
+ color: var(--text-dim);
1010
+ }
1011
+ .btn.wide {
1012
+ width: 100%;
1013
+ padding: 12px 16px;
1014
+ }
1015
+ .btn[disabled] {
1016
+ opacity: 0.45;
1017
+ cursor: not-allowed;
1018
+ }
1019
+ .btn[disabled]:hover {
1020
+ border-color: var(--border-strong);
1021
+ }
1022
+
1023
+ /* ─── Settings tabs ───────────────────────────────────────────────────── */
1024
+
1025
+ .tabs {
1026
+ display: flex;
1027
+ gap: 4px;
1028
+ padding: 4px;
1029
+ background: var(--bg);
1030
+ border: 1px solid var(--border);
1031
+ border-radius: var(--radius-sm);
1032
+ }
1033
+ .tab {
1034
+ flex: 1;
1035
+ padding: 8px 12px;
1036
+ border: none;
1037
+ background: transparent;
1038
+ color: var(--text-dim);
1039
+ font-family: inherit;
1040
+ font-size: 13px;
1041
+ font-weight: 600;
1042
+ letter-spacing: 0.02em;
1043
+ border-radius: calc(var(--radius-sm) - 3px);
1044
+ cursor: pointer;
1045
+ transition: background 0.15s, color 0.15s;
1046
+ }
1047
+ .tab:hover {
1048
+ color: var(--text);
1049
+ }
1050
+ .tab.active {
1051
+ background: var(--bg-elev-2);
1052
+ color: var(--text);
1053
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
1054
+ }
1055
+
1056
+ .tab-panels {
1057
+ display: flex;
1058
+ flex-direction: column;
1059
+ }
1060
+ .tab-panel {
1061
+ display: flex;
1062
+ flex-direction: column;
1063
+ gap: 16px;
1064
+ }
1065
+ .tab-panel[hidden] {
1066
+ display: none;
1067
  }
1068
 
1069
+ /* Horizontal layout for Voice + Model. */
1070
+ .field-row {
1071
+ display: grid;
1072
+ grid-template-columns: 1fr 1fr;
1073
+ gap: 12px;
1074
  }