File size: 10,454 Bytes
3019178 74c42e9 1f28297 3019178 1f28297 3019178 13de6b4 1f28297 3019178 0d1ecd8 1f28297 0d1ecd8 1f28297 0d1ecd8 1f28297 0d1ecd8 1f28297 0d1ecd8 1f28297 1cfad64 0d1ecd8 1cfad64 98c4bb7 7b861ae 1cfad64 1f28297 1cfad64 0d1ecd8 1f28297 7b861ae 1f28297 1cfad64 1f28297 0d1ecd8 1f28297 0d1ecd8 1f28297 0d1ecd8 1f28297 0d1ecd8 1f28297 0d1ecd8 1f28297 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 | ---
title: Hugging Face Voice - Inkling
emoji: ποΈ
colorFrom: indigo
colorTo: purple
sdk: docker
app_port: 7860
pinned: false
short_description: Talk to Inkling with faster-qwen3tts voice replies
hf_oauth: true
---
# Realtime Voice Demo β Inkling
Browser voice-chat UI for the
[huggingface/speech-to-speech](https://github.com/huggingface/speech-to-speech)
backend, speaking the OpenAI Realtime **GA** protocol over **WebSocket**
(the deployed Space path) or **WebRTC** (env-pinned direct deployments only).
## How it works
1. App POSTs `<lb_url>/session` (empty JSON body).
2. The LB picks a ready compute (round-robin) and returns:
```json
{
"session_id": "...",
"websocket_url": "wss://<compute>/v1/realtime",
"connect_url": "wss://<compute>/v1/realtime?session_token=<JWT>",
"session_token": "<JWT>",
"pending_timeout_s": 60
}
```
3. App opens a WebSocket **directly** on `connect_url` (no rewrite to
`https://`; unlike the WebRTC client which POSTs an SDP offer).
4. Server pushes `session.created` on connect. Client replies with
`session.update` (OpenAI Realtime **GA** schema: `session.audio.input`,
`session.audio.output`, `session.output_modalities`).
5. Client streams mic audio as PCM16 16 kHz mono base64 chunks
(`input_audio_buffer.append`, one frame every ~40 ms).
6. Server pushes `response.output_audio.delta` (PCM16 24 kHz mono base64)
and transcript deltas.
The backend exposes one concurrent session per compute (same as WebRTC
mode); the LB pins the session via a signed `session_token`.
## WebRTC transport
With `SPEECH_TO_SPEECH_URL` set, **Settings β Transport** offers WebRTC as an
alternative to WebSocket. The browser sends its SDP offer through the
same-origin `/api/calls` proxy; negotiated audio and the OpenAI Realtime event
data channel then flow directly between browser and backend.
The proxy forwards only to the env-pinned URL, never to a client-supplied
target. This is why user-typed URLs and load-balancer mode remain WebSocket-only.
Set `RTC_ICE_SERVERS` on this app and `SPEECH_TO_SPEECH_ICE_SERVERS` on the
backend when remote connections need STUN/TURN.
The WebSocket-only noise gate is hidden during WebRTC calls. Camera snapshots
are also re-encoded to fit the data-channel message limit.
## Backend requirement
The backend must be installed with the `webrtc` extra to serve
`POST /v1/realtime/calls`; otherwise WebRTC responds with 501. The WebSocket
route remains `/v1/realtime`.
Smoke-test from the shell:
```bash
LB="https://kaa1l6rplzb1gg3y.us-east-1.aws.endpoints.huggingface.cloud"
curl -X POST "$LB/session" -H "Content-Type: application/json" -d '{}'
# -> { "connect_url": "wss://<compute>/v1/realtime?session_token=..." }
# Feed connect_url into a wscat / websocat and you should get a
# session.created event back immediately.
```
## Tools
The assistant can call two tools mid-conversation (toggle them from the **Tools**
button, top-right):
- **Web search** β Google results via Serper.dev, proxied server-side so the key
never reaches the browser. Set `SERPER_API_KEY` as a Space secret. Without it,
the tool is disabled unless the user pastes their own key in the Tools panel.
- **Camera** β while enabled, a live self-view shows bottom-left; when the model
calls the tool, the current frame is sent to the vision-language model so it can
see what you're showing it.
## Connecting to a backend
Three modes, picked by env (`/api/config` tells the client which one is active):
- **`SPEECH_TO_SPEECH_URL` env** β highest priority. The browser connects
**directly** to this realtime WebSocket URL; it's shown read-only in Settings.
Setting it disables the load-balancer logic entirely (no `/api/session` proxy,
no queue, no metering, no sign-in). Unlike the LB address it is not a secret.
This is the only mode in which WebRTC can be selected.
- **`LOAD_BALANCER_URL` env** β the original flow: the browser POSTs the
same-origin `/api/session` proxy, the server forwards to the LB, and the
browser dials the per-session compute URL the LB hands back. The LB address
never reaches the browser; the Settings URL field is hidden. When a visitor
signs in with Hugging Face, the proxy also forwards their OAuth access token
to the LB through `X-Reachy-Mini-Authorization`, matching the Reachy Mini
client. Anonymous requests carry no token, and the token is never returned to
browser JavaScript. Set `REQUIRE_LOGIN=true` to reject anonymous session
allocation and prompt visitors to sign in before starting.
- **Neither** β **Settings β Speech-to-speech server URL**: paste a full
`connect_url` (`wss://host/v1/realtime?...`) or a bare host like `localhost:8080`
(the app adds `/v1/realtime`), and the browser connects to it directly.
| `SPEECH_TO_SPEECH_URL` | `LOAD_BALANCER_URL` | `SPACE_ID` | Connection | URL field | Transport | Metering |
|:---:|:---:|:---:|---|---|---|---|
| β
| any | any | direct β pinned URL | visible, locked | WS or WebRTC | off |
| β | β
| β
| LB proxy | hidden | WS only | **on** |
| β | β
| β | LB proxy | hidden | WS only | off |
| β | β | any | direct β user URL | editable | WS only | off |
**Settings β Restart** reconnects with the current voice, instructions and URL.
## Usage limits
Conversation time is metered per UTC day by sign-in tier (see `limiter.py` /
`auth.py`), but **only on the deployed Space** β metering turns on only when BOTH
`LOAD_BALANCER_URL` and `SPACE_ID` (injected automatically by the HF Space
runtime) are present. Running locally β even with `LOAD_BALANCER_URL` exported β
leaves the app unmetered. Tunable via env:
| Env | Default | What |
|-----|---------|------|
| `LIMIT_ANON_SEC` | `300` | Daily seconds for anonymous visitors (5 min) |
| `LIMIT_FREE_SEC` | `600` | Daily seconds for signed-in non-PRO users (10 min) |
| `REQUIRE_LOGIN` | unset | Set to `true` to require HF sign-in before allocating or claiming a session |
| `UNLIMITED_ORGS` | _(adds to defaults)_ | Extra HF org names whose members get **unlimited** usage, like PRO |
| `USAGE_HASH_SECRET` | _(random)_ | HMAC secret for hashing identity keys + signing the anon cookie |
PRO members are always unlimited. Members of `cerebras`, `HuggingFaceM4`,
`smolagents`, and `pollen-robotics` are unlimited out of the box (shown as
"Team", not "PRO"); set `UNLIMITED_ORGS=my-team` to add more. Matched
case-insensitively against the user's organisations from HF OAuth.
## Run locally
The app is now a small FastAPI server (it serves the front-end *and* the search
proxy from one container).
```bash
pip install -r requirements.txt
export SERPER_API_KEY=... # optional; web search is disabled without it
export SPEECH_TO_SPEECH_URL=... # optional; pin a direct s2s server URL (overrides the LB)
export LOAD_BALANCER_URL=... # optional; session-proxy flow (set a URL in Settings otherwise)
uvicorn server:app --reload --port 7860
# or, matching production: docker build -t s2s . && docker run -p 7860:7860 -e SERPER_API_KEY=... -e LOAD_BALANCER_URL=... s2s
```
Then open <http://localhost:7860/>, click the orb, allow the mic, talk.
> Browsers require **HTTPS or `localhost`** for `getUserMedia()` (mic + camera).
> `127.0.0.1` and `localhost` both work; plain `http://192.168.x.y` does NOT.
## Settings (stored in `localStorage`)
| Key | What |
|-----|------|
| Speech-to-speech server URL | Direct realtime WebSocket URL (hidden/locked when deployment-owned) |
| Transport | WebSocket (default) or WebRTC; selectable only with an env-pinned URL |
| Microphone | Input device for capture. Applies on the next conversation / Restart. |
| Speakers | Output device for assistant audio. Chrome/Edge can switch live; other browsers keep the system default. |
| Voice | Qwen3-TTS speaker name (Aiden, Ryan, Dylan, Eric, Ono_Anna, Serena, Sohee, Uncle_Fu, Vivian) |
| Instructions | System prompt sent in `session.update` once the connection opens |
LocalStorage keys are namespaced `s2s.ws.*`, plus `s2s.transport` and
`s2s.audio.inputId` / `s2s.audio.outputId`.
## Files
| File | Role |
|------|------|
| `index.html` | Single page, orb + settings modal (identical UI to the WebRTC app) |
| `main.js` | State machine, settings, tools, camera, noise-gate UI wiring |
| `ui/chat.js` | `ChatView`: history panel, ephemeral bubbles, transcript/tool streaming |
| `ui/account.js` | `Account`: HF login chip + popover, daily-limit modal |
| `ui/dom.js` | Shared helpers: `$`, `escHtml`, `truncateError`, `DEBUG` |
| `auth.py` | HF OAuth + per-request identity (tier, hashed keys) |
| `limiter.py` | SQLite per-day talk-time budget (chunked server-clock reservation) |
| `ws/s2s-ws-client.js` | WebSocket handshake + OpenAI Realtime GA protocol |
| `rtc/s2s-rtc-client.js` | WebRTC sibling: SDP via `/api/calls`, events over data channel, track audio |
| `ws/codec.js` | base64 <-> PCM helpers + transcript extraction (pure) |
| `ws/orb-visualizer.js` | `OrbVisualiser`: FFT bands -> orb CSS custom properties |
| `worklets/mic-capture.js` | AudioWorklet: 48 kHz Float32 -> 16 kHz Int16 PCM, posts ~40 ms chunks |
| `worklets/audio-playback.js` | AudioWorklet: 24 kHz Float32 ring buffer -> 48 kHz, linear interp, fade in/out |
| `style.css` | Orb animations, layout, dark theme (verbatim from the WebRTC app) |
## Audio pipeline notes
- **Input**: `getUserMedia({ echoCancellation, noiseSuppression, autoGainControl })`
feeds the `mic-capture` worklet at the `AudioContext` rate. The worklet
resamples to 16 kHz (boxcar lowpass + decimation on the 48 -> 16 fast
path, linear interpolation fallback for odd rates) and packs Int16 LE.
- **Output**: `response.output_audio.delta` decodes to Int16 -> Float32
and is posted to the `audio-playback` worklet. The worklet maintains a
per-context ring buffer, linearly interpolates 24 -> 48, and applies
short 32-frame fades on entry/exit to suppress clicks.
- **Barge-in**: when the server VAD detects user speech mid-response
(`input_audio_buffer.speech_started` while `ai-speaking`), the client
posts `{ kind: "clear" }` to the playback worklet to wipe the queue
immediately. The server itself cancels the in-flight response.
## Credits
- Backend: [huggingface/speech-to-speech](https://github.com/huggingface/speech-to-speech)
- UI verbatim from `amir-tfrere/minimal-conversation-app-s2s-backend` (Pollen Robotics Γ Hugging Face)
|