feat: minimal voice conversation app on top of HF speech-to-speech (WebRTC)
Browse filesSingle-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>
- .gitignore +9 -0
- README.md +90 -5
- index.html +157 -17
- main.js +399 -0
- s2s-client.js +516 -0
- style.css +1064 -18
|
@@ -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
|
|
@@ -1,10 +1,95 @@
|
|
| 1 |
---
|
| 2 |
-
title: Minimal Conversation
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -1,19 +1,159 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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><lb>/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>
|
|
@@ -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 |
+
});
|
|
@@ -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 |
+
}
|
|
@@ -1,28 +1,1074 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
body {
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
}
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
margin-bottom: 10px;
|
| 15 |
-
margin-top: 5px;
|
| 16 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
.
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|