EYEDOL commited on
Commit
0115b20
·
verified ·
1 Parent(s): 7032678

Upload 10 files

Browse files
Files changed (10) hide show
  1. Dockerfile +60 -0
  2. README.md +138 -0
  3. app.js +336 -0
  4. app.py +280 -0
  5. audio-capture-worklet.js +71 -0
  6. index.html +335 -0
  7. models.py +218 -0
  8. pipeline.py +231 -0
  9. requirements.txt +20 -0
  10. sentence_buffer.py +129 -0
Dockerfile ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # Dockerfile for HuggingFace Spaces (Docker SDK)
3
+ #
4
+ # Hardware target: CPU Space (free tier) or GPU Space (T4-small recommended).
5
+ # Port 7860 is the HF Spaces standard.
6
+ #
7
+ # Build order:
8
+ # 1. System deps (libsndfile for soundfile, git for pip installs)
9
+ # 2. Python packages (cached layer)
10
+ # 3. App code (invalidates only on source changes)
11
+ # 4. Switch to non-root user (HF Spaces requirement)
12
+ # ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ FROM python:3.11-slim
15
+
16
+ WORKDIR /app
17
+
18
+ # ── System dependencies ───────────────────────────────────────────────────── #
19
+ RUN apt-get update && apt-get install -y --no-install-recommends \
20
+ libsndfile1 \
21
+ libgomp1 \
22
+ git \
23
+ && rm -rf /var/lib/apt/lists/*
24
+
25
+ # ── Python dependencies (cached unless requirements.txt changes) ──────────── #
26
+ COPY requirements.txt .
27
+
28
+ # Install CPU-only torch first (saves ~1 GB over default CUDA wheel).
29
+ # Comment these two lines out and use the plain pip install if you have a GPU Space.
30
+ RUN pip install --no-cache-dir \
31
+ torch==2.5.1+cpu torchaudio==2.5.1+cpu \
32
+ --index-url https://download.pytorch.org/whl/cpu
33
+
34
+ RUN pip install --no-cache-dir -r requirements.txt
35
+
36
+ # ── Application code ─────────────────────────────────────────────────────── #
37
+ COPY . .
38
+
39
+ # ── Non-root user for HF Spaces compatibility ─────────────────────────────── #
40
+ RUN useradd -m -u 1000 appuser \
41
+ && chown -R appuser:appuser /app
42
+ USER 1000
43
+
44
+ # ── Model cache lives in the user home so HF Spaces can persist it ────────── #
45
+ ENV HF_HOME=/home/appuser/.cache/huggingface
46
+ ENV TRANSFORMERS_CACHE=/home/appuser/.cache/huggingface/hub
47
+ ENV PORT=7860
48
+
49
+ EXPOSE 7860
50
+
51
+ # ── Healthcheck ───────────────────────────────────────────────────────────── #
52
+ HEALTHCHECK --interval=60s --timeout=10s --start-period=300s \
53
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
54
+
55
+ CMD ["python", "-m", "uvicorn", "app:app", \
56
+ "--host", "0.0.0.0", \
57
+ "--port", "7860", \
58
+ "--log-level", "info", \
59
+ "--ws-ping-interval", "30", \
60
+ "--ws-ping-timeout", "60"]
README.md ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Open-Source Voice Agent
3
+ emoji: 🎤
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: apache-2.0
10
+ ---
11
+
12
+ # 🎤 Open-Source Voice Agent
13
+
14
+ End-to-end English voice agent built entirely from open-source HuggingFace
15
+ models with a **streaming overlap pipeline** — TTS synthesis for sentence N
16
+ starts the moment sentence N is detected in the LLM token stream, while the
17
+ model continues generating sentences N+1, N+2 … in parallel.
18
+
19
+ ---
20
+
21
+ ## Models
22
+
23
+ | Stage | Model | Params | Device |
24
+ |-------|-------|--------|--------|
25
+ | VAD | [silero-vad](https://github.com/snakers4/silero-vad) | 2 MB | CPU |
26
+ | STT | [openai/whisper-base.en](https://huggingface.co/openai/whisper-base.en) | 74 M | GPU / CPU |
27
+ | LLM | [HuggingFaceTB/SmolLM2-1.7B-Instruct](https://huggingface.co/HuggingFaceTB/SmolLM2-1.7B-Instruct) | 1.7 B | GPU / CPU |
28
+ | TTS | [facebook/mms-tts-eng](https://huggingface.co/facebook/mms-tts-eng) | ~430 M | CPU |
29
+
30
+ > **Lighter CPU-only alternative**: swap STT → `whisper-tiny.en` (39 M) and
31
+ > LLM → `SmolLM2-360M-Instruct` (360 M) in `models.py`.
32
+
33
+ ---
34
+
35
+ ## Architecture
36
+
37
+ ```
38
+ Browser mic (16 kHz PCM)
39
+
40
+ ▼ WebSocket binary frames
41
+ Silero VAD ──────────────────────► discard noise/silence
42
+ │ speech segment detected
43
+
44
+ Whisper base.en ──────────────────► transcript text
45
+
46
+
47
+ SmolLM2-1.7B (TextIteratorStreamer)
48
+ │ token stream
49
+
50
+ SentenceBuffer ──► complete sentence
51
+ │ │
52
+ │ MMS-TTS-eng (CPU executor) ← overlap: LLM still generating!
53
+ │ │
54
+ │ PCM bytes ──► ws.send_bytes()
55
+ │ │
56
+ ◄────────────────────┘ repeat until stream ends
57
+
58
+
59
+ Browser AudioQueue ──► AudioContext playback
60
+ ```
61
+
62
+ **Streaming overlap benefit**: while the browser plays sentence 1, the server
63
+ is already synthesising sentence 2. This removes the full TTS latency from the
64
+ inter-sentence gap, giving noticeably more natural turn-taking.
65
+
66
+ ---
67
+
68
+ ## Project structure
69
+
70
+ ```
71
+ sts-pipeline/
72
+ ├── app.py FastAPI server + WebSocket handler
73
+ ├── models.py All model loading & inference (STT/LLM/TTS/VAD)
74
+ ├── pipeline.py Streaming overlap pipeline
75
+ ├── sentence_buffer.py Token-stream → sentence boundary detector
76
+ ├── requirements.txt
77
+ ├── Dockerfile
78
+ └── static/
79
+ ├── index.html Browser UI
80
+ ├── app.js WebSocket client + audio queue
81
+ └── audio-capture-worklet.js Mic capture @ 16 kHz (AudioWorklet)
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Local development
87
+
88
+ ```bash
89
+ # 1. Clone
90
+ git clone https://huggingface.co/spaces/<your-user>/voice-agent
91
+ cd voice-agent
92
+
93
+ # 2. Install (GPU)
94
+ pip install -r requirements.txt
95
+
96
+ # 2b. Install (CPU-only)
97
+ pip install torch==2.5.1+cpu torchaudio==2.5.1+cpu \
98
+ --index-url https://download.pytorch.org/whl/cpu
99
+ pip install -r requirements.txt
100
+
101
+ # 3. Run
102
+ python app.py # or: uvicorn app:app --port 7860
103
+ # Open http://localhost:7860
104
+ ```
105
+
106
+ Models are downloaded automatically on first run (~3 GB total) and cached in
107
+ `~/.cache/huggingface/hub`.
108
+
109
+ ---
110
+
111
+ ## HuggingFace Spaces deployment
112
+
113
+ 1. Create a new Space → **Docker** SDK.
114
+ 2. Push this repo as-is.
115
+ 3. The Space will build the Docker image and serve on port 7860.
116
+ 4. For GPU hardware: remove the CPU-only torch lines in `Dockerfile` and
117
+ uncomment the plain `pip install -r requirements.txt`.
118
+
119
+ ---
120
+
121
+ ## WebSocket protocol reference
122
+
123
+ | Direction | Frame type | Payload |
124
+ |-----------|-----------|---------|
125
+ | Client → Server | binary | PCM int16, mono, 16 kHz, 640 B chunks |
126
+ | Client → Server | text | `{"type":"start"}` or `{"type":"stop"}` |
127
+ | Server → Client | binary | PCM int16, mono, 16 kHz (TTS audio) |
128
+ | Server → Client | text | `{"type":"transcript","text":"..."}` |
129
+ | Server → Client | text | `{"type":"agent_start"}` |
130
+ | Server → Client | text | `{"type":"agent_done","text":"...","latency_ms":000}` |
131
+ | Server → Client | text | `{"type":"error","message":"..."}` |
132
+
133
+ ---
134
+
135
+ ## License
136
+
137
+ Apache 2.0 — all component models carry their own licenses; see their
138
+ respective HuggingFace model cards for terms of use.
app.js ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * app.js
3
+ * ------
4
+ * Browser-side voice agent client.
5
+ *
6
+ * Flow
7
+ * ----
8
+ * getUserMedia ──► AudioWorklet capture (16 kHz int16)
9
+ * │
10
+ * WebSocket.send(binary)
11
+ * │
12
+ * ◄──── Server ─────►
13
+ * │
14
+ * WebSocket.onmessage(binary) ← PCM int16 TTS audio
15
+ * │
16
+ * playback queue ──► AudioContext.playBuffer()
17
+ *
18
+ * States: idle → listening → processing → speaking → idle
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ // ── Config ──────────────────────────────────────────────────────────────── //
24
+ const SERVER_SR = 16_000; // Server sends 16 kHz PCM
25
+ const WS_URL = (() => {
26
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
27
+ return `${proto}//${location.host}/ws/audio`;
28
+ })();
29
+
30
+ // ── State ────────────────────────────────────────────────────────────────── //
31
+ let audioCtx = null;
32
+ let captureNode = null;
33
+ let micStream = null;
34
+ let ws = null;
35
+ let appState = 'idle'; // idle | listening | processing | speaking
36
+
37
+ let audioQueue = []; // Array<AudioBuffer> pending playback
38
+ let isPlaying = false; // Is the drain loop running?
39
+ let conversation = []; // [{role, text}] for UI transcript
40
+
41
+ // ── DOM refs ─────────────────────────────────────────────────────────────── //
42
+ const btnToggle = document.getElementById('btn-toggle');
43
+ const statusDot = document.getElementById('status-dot');
44
+ const statusText = document.getElementById('status-text');
45
+ const canvasEl = document.getElementById('visualiser');
46
+ const ctx2d = canvasEl.getContext('2d');
47
+ const transcriptEl = document.getElementById('transcript');
48
+ const latencyEl = document.getElementById('latency');
49
+
50
+ // ── Entry point ───────────────────────────────────────────────────────────── //
51
+ btnToggle.addEventListener('click', () => {
52
+ if (appState === 'idle') {
53
+ startSession();
54
+ } else {
55
+ stopSession();
56
+ }
57
+ });
58
+
59
+ // ── Session start/stop ───────────────────────────────────────────────────── //
60
+ async function startSession () {
61
+ try {
62
+ await initAudio();
63
+ await initWebSocket();
64
+ setState('listening');
65
+ btnToggle.textContent = '⏹ Stop';
66
+ ws.send(JSON.stringify({ type: 'start' }));
67
+ startVisualiser();
68
+ } catch (err) {
69
+ console.error('startSession:', err);
70
+ setStatus('error', `Mic / WebSocket error: ${err.message}`);
71
+ }
72
+ }
73
+
74
+ function stopSession () {
75
+ if (ws) {
76
+ ws.send(JSON.stringify({ type: 'stop' }));
77
+ ws.close();
78
+ }
79
+ teardownAudio();
80
+ setState('idle');
81
+ btnToggle.textContent = '🎤 Start';
82
+ stopVisualiser();
83
+ }
84
+
85
+ // ── Audio setup ───────────────────────────────────────────────────────────── //
86
+ async function initAudio () {
87
+ // Request mic with constraints that help speech quality
88
+ micStream = await navigator.mediaDevices.getUserMedia({
89
+ audio: {
90
+ channelCount: 1,
91
+ echoCancellation: true,
92
+ noiseSuppression: true,
93
+ autoGainControl: true,
94
+ },
95
+ });
96
+
97
+ audioCtx = new AudioContext();
98
+
99
+ // Load the capture worklet
100
+ await audioCtx.audioWorklet.addModule('/audio-capture-worklet.js');
101
+
102
+ const source = audioCtx.createMediaStreamSource(micStream);
103
+ captureNode = new AudioWorkletNode(audioCtx, 'audio-capture-processor');
104
+
105
+ captureNode.port.onmessage = ({ data }) => {
106
+ if (data.type === 'audio' && ws && ws.readyState === WebSocket.OPEN) {
107
+ ws.send(data.pcm); // ArrayBuffer of int16 PCM
108
+ }
109
+ };
110
+
111
+ source.connect(captureNode);
112
+ // captureNode intentionally NOT connected to destination (no feedback)
113
+ captureNode.port.postMessage({ type: 'start' });
114
+ }
115
+
116
+ function teardownAudio () {
117
+ if (captureNode) {
118
+ captureNode.port.postMessage({ type: 'stop' });
119
+ captureNode.disconnect();
120
+ captureNode = null;
121
+ }
122
+ if (micStream) {
123
+ micStream.getTracks().forEach(t => t.stop());
124
+ micStream = null;
125
+ }
126
+ if (audioCtx) {
127
+ audioCtx.close();
128
+ audioCtx = null;
129
+ }
130
+ audioQueue = [];
131
+ isPlaying = false;
132
+ }
133
+
134
+ // ── WebSocket ───────────────────────────���─────────────────────────────────── //
135
+ function initWebSocket () {
136
+ return new Promise((resolve, reject) => {
137
+ ws = new WebSocket(WS_URL);
138
+ ws.binaryType = 'arraybuffer';
139
+
140
+ ws.onopen = () => { console.log('WS open'); resolve(); };
141
+ ws.onerror = (e) => reject(new Error('WebSocket error'));
142
+ ws.onclose = () => {
143
+ if (appState !== 'idle') stopSession();
144
+ };
145
+
146
+ ws.onmessage = (evt) => {
147
+ if (evt.data instanceof ArrayBuffer) {
148
+ onAudioChunk(evt.data); // Binary: PCM audio from TTS
149
+ } else {
150
+ onControlMessage(JSON.parse(evt.data)); // Text: JSON status
151
+ }
152
+ };
153
+ });
154
+ }
155
+
156
+ // ── Inbound control messages ─────────────────────────────────────────────── //
157
+ function onControlMessage (msg) {
158
+ switch (msg.type) {
159
+
160
+ case 'status':
161
+ setStatus(appState, msg.message);
162
+ break;
163
+
164
+ case 'transcript':
165
+ appendTranscript('user', msg.text);
166
+ setState('processing');
167
+ break;
168
+
169
+ case 'agent_start':
170
+ setState('speaking');
171
+ break;
172
+
173
+ case 'agent_done':
174
+ appendTranscript('agent', msg.text);
175
+ if (msg.latency_ms) {
176
+ latencyEl.textContent = `Latency: ${msg.latency_ms} ms`;
177
+ }
178
+ // State returns to 'listening' when the playback queue drains
179
+ break;
180
+
181
+ case 'interrupted':
182
+ audioQueue = [];
183
+ isPlaying = false;
184
+ setState('listening');
185
+ break;
186
+
187
+ case 'error':
188
+ console.error('Server error:', msg.message);
189
+ setStatus('error', msg.message);
190
+ setState('listening');
191
+ break;
192
+ }
193
+ }
194
+
195
+ // ── Audio playback queue ─────────────────────────────────────────────────── //
196
+ function onAudioChunk (arrayBuffer) {
197
+ if (!audioCtx) return;
198
+
199
+ // PCM int16, mono, 16 kHz → float32 AudioBuffer at AudioContext.sampleRate
200
+ const int16 = new Int16Array(arrayBuffer);
201
+ const float32 = new Float32Array(int16.length);
202
+ for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 32_768;
203
+
204
+ const resampled = resample(float32, SERVER_SR, audioCtx.sampleRate);
205
+ const audioBuf = audioCtx.createBuffer(1, resampled.length, audioCtx.sampleRate);
206
+ audioBuf.copyToChannel(resampled, 0);
207
+
208
+ audioQueue.push(audioBuf);
209
+ if (!isPlaying) drainQueue();
210
+ }
211
+
212
+ function drainQueue () {
213
+ if (audioQueue.length === 0) {
214
+ isPlaying = false;
215
+ if (appState === 'speaking') setState('listening');
216
+ return;
217
+ }
218
+
219
+ isPlaying = true;
220
+ const buf = audioQueue.shift();
221
+ const src = audioCtx.createBufferSource();
222
+ src.buffer = buf;
223
+ src.connect(audioCtx.destination);
224
+ src.onended = drainQueue; // chain next buffer immediately
225
+ src.start();
226
+ }
227
+
228
+ // ── Resampler (linear interpolation) ─────────────────────────────────────── //
229
+ function resample (input, inSR, outSR) {
230
+ if (inSR === outSR) return input;
231
+ const ratio = inSR / outSR;
232
+ const outLen = Math.round(input.length / ratio);
233
+ const output = new Float32Array(outLen);
234
+ for (let i = 0; i < outLen; i++) {
235
+ const src = i * ratio;
236
+ const lo = Math.floor(src);
237
+ const hi = Math.min(lo + 1, input.length - 1);
238
+ const frac = src - lo;
239
+ output[i] = input[lo] + frac * (input[hi] - input[lo]);
240
+ }
241
+ return output;
242
+ }
243
+
244
+ // ── State management ──────────────────────────────────────────────────────── //
245
+ const STATE_LABELS = {
246
+ idle: 'Ready',
247
+ listening: 'Listening…',
248
+ processing: 'Thinking…',
249
+ speaking: 'Speaking…',
250
+ };
251
+
252
+ const STATE_COLORS = {
253
+ idle: '#6b7280',
254
+ listening: '#22c55e',
255
+ processing: '#f59e0b',
256
+ speaking: '#3b82f6',
257
+ };
258
+
259
+ function setState (newState) {
260
+ appState = newState;
261
+ statusDot.style.background = STATE_COLORS[newState] ?? '#6b7280';
262
+ statusText.textContent = STATE_LABELS[newState] ?? newState;
263
+ }
264
+
265
+ function setStatus (_state, message) {
266
+ statusText.textContent = message;
267
+ }
268
+
269
+ // ── Transcript ────────────────────────────────────────────────────────────── //
270
+ function appendTranscript (role, text) {
271
+ conversation.push({ role, text });
272
+ const div = document.createElement('div');
273
+ div.className = `msg msg-${role}`;
274
+ div.innerHTML = `<span class="msg-role">${role === 'user' ? 'You' : 'Agent'}</span><span class="msg-text">${escHtml(text)}</span>`;
275
+ transcriptEl.appendChild(div);
276
+ transcriptEl.scrollTop = transcriptEl.scrollHeight;
277
+ }
278
+
279
+ function escHtml (s) {
280
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
281
+ }
282
+
283
+ // ── Canvas visualiser ─────────────────────────────────────────────────────── //
284
+ let analyser = null;
285
+ let visRAF = null;
286
+
287
+ function startVisualiser () {
288
+ if (!audioCtx || analyser) return;
289
+
290
+ analyser = audioCtx.createAnalyser();
291
+ analyser.fftSize = 256;
292
+ const source = audioCtx.createMediaStreamSource(micStream);
293
+ source.connect(analyser);
294
+
295
+ const dataArr = new Uint8Array(analyser.frequencyBinCount);
296
+ const W = canvasEl.width;
297
+ const H = canvasEl.height;
298
+ const BAR_COUNT = 48;
299
+ const BAR_W = W / BAR_COUNT - 1;
300
+
301
+ function draw () {
302
+ visRAF = requestAnimationFrame(draw);
303
+ analyser.getByteFrequencyData(dataArr);
304
+
305
+ ctx2d.clearRect(0, 0, W, H);
306
+
307
+ const color = STATE_COLORS[appState] ?? '#6b7280';
308
+ ctx2d.fillStyle = color + '99'; // 60% opacity
309
+
310
+ for (let i = 0; i < BAR_COUNT; i++) {
311
+ const binIdx = Math.floor(i * dataArr.length / BAR_COUNT);
312
+ const v = dataArr[binIdx] / 255;
313
+ const barH = Math.max(2, v * H * 0.9);
314
+ const x = i * (BAR_W + 1);
315
+ const y = (H - barH) / 2;
316
+ ctx2d.beginPath();
317
+ ctx2d.roundRect(x, y, BAR_W, barH, 2);
318
+ ctx2d.fill();
319
+ }
320
+ }
321
+ draw();
322
+ }
323
+
324
+ function stopVisualiser () {
325
+ if (visRAF) { cancelAnimationFrame(visRAF); visRAF = null; }
326
+ if (analyser) { analyser.disconnect(); analyser = null; }
327
+ ctx2d.clearRect(0, 0, canvasEl.width, canvasEl.height);
328
+ }
329
+
330
+ // ── Resize canvas ─────────────────────────────────────────────────────────── //
331
+ function resizeCanvas () {
332
+ canvasEl.width = canvasEl.offsetWidth;
333
+ canvasEl.height = canvasEl.offsetHeight;
334
+ }
335
+ window.addEventListener('resize', resizeCanvas);
336
+ resizeCanvas();
app.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py
3
+ ------
4
+ FastAPI server for the open-source streaming voice agent.
5
+
6
+ All project files live in ONE directory — no subdirectories.
7
+
8
+ Endpoints
9
+ ---------
10
+ GET / → index.html
11
+ GET /app.js → app.js
12
+ GET /audio-capture-worklet.js → audio-capture-worklet.js
13
+ GET /health → JSON status
14
+ WS /ws/audio → bidirectional audio stream
15
+
16
+ WebSocket protocol
17
+ ------------------
18
+ Client → Server:
19
+ • Binary frames : PCM int16, mono, 16 kHz, 640-byte chunks (20 ms)
20
+ • Text frames : JSON control {"type": "start"} | {"type": "stop"}
21
+
22
+ Server → Client:
23
+ • Binary frames : PCM int16, mono, 16 kHz (TTS output, variable size)
24
+ • Text frames : JSON status
25
+ {"type": "status", "message": "..."}
26
+ {"type": "transcript", "text": "..."}
27
+ {"type": "agent_start"}
28
+ {"type": "agent_done", "text": "...", "latency_ms": 000}
29
+ {"type": "error", "message": "..."}
30
+ {"type": "interrupted"}
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import json
37
+ import logging
38
+ import os
39
+ import time
40
+ from pathlib import Path
41
+
42
+ import numpy as np
43
+ import torch
44
+ import uvicorn
45
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
46
+ from fastapi.responses import FileResponse, JSONResponse
47
+
48
+ from models import STT_SR, ModelManager
49
+ from pipeline import StreamingPipeline, build_initial_conversation
50
+
51
+ # --------------------------------------------------------------------------- #
52
+ logging.basicConfig(
53
+ level=logging.INFO,
54
+ format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
55
+ datefmt="%H:%M:%S",
56
+ )
57
+ logger = logging.getLogger(__name__)
58
+
59
+ BASE_DIR = Path(__file__).parent # All files live here
60
+
61
+ # --------------------------------------------------------------------------- #
62
+ app = FastAPI(title="Open-Source Voice Agent", version="1.0.0")
63
+
64
+ _models: ModelManager | None = None
65
+ _pipeline: StreamingPipeline | None = None
66
+
67
+
68
+ @app.on_event("startup")
69
+ async def on_startup():
70
+ global _models, _pipeline
71
+ logger.info("=== Loading models — this may take a few minutes on first run ===")
72
+ loop = asyncio.get_event_loop()
73
+ _models = await loop.run_in_executor(None, ModelManager)
74
+ await loop.run_in_executor(None, _models.warm_up)
75
+ _pipeline = StreamingPipeline(_models)
76
+ logger.info("=== Server ready ===")
77
+
78
+
79
+ # --------------------------------------------------------------------------- #
80
+ # Static file routes (flat — all files in same directory as app.py) #
81
+ # --------------------------------------------------------------------------- #
82
+ @app.get("/")
83
+ async def root():
84
+ return FileResponse(str(BASE_DIR / "index.html"))
85
+
86
+ @app.get("/app.js")
87
+ async def serve_appjs():
88
+ return FileResponse(str(BASE_DIR / "app.js"), media_type="application/javascript")
89
+
90
+ @app.get("/audio-capture-worklet.js")
91
+ async def serve_worklet():
92
+ return FileResponse(str(BASE_DIR / "audio-capture-worklet.js"), media_type="application/javascript")
93
+
94
+ @app.get("/health")
95
+ async def health():
96
+ return JSONResponse({
97
+ "status": "ready" if _models else "loading",
98
+ "device": str(_models.device) if _models else "N/A",
99
+ "stt": "whisper-base.en",
100
+ "llm": "SmolLM2-1.7B-Instruct",
101
+ "tts": "mms-tts-eng",
102
+ })
103
+
104
+
105
+ # --------------------------------------------------------------------------- #
106
+ # VAD-based speech segmenter #
107
+ # --------------------------------------------------------------------------- #
108
+ class SpeechSegmenter:
109
+ """
110
+ Wraps Silero VADIterator to accumulate PCM and fire when speech ends.
111
+
112
+ Incoming audio is 640-byte chunks (320 int16 samples = 20 ms at 16 kHz).
113
+ Silero needs exactly 512 float32 samples per call, so we buffer the
114
+ difference and carry any leftover into the next iteration.
115
+ """
116
+
117
+ VAD_CHUNK = 512
118
+ MIN_SPEECH_S = 0.30
119
+
120
+ def __init__(self, models: ModelManager):
121
+ self._vad = models.vad_iter
122
+ self._vad.reset_states()
123
+ self._pre_roll: list[np.ndarray] = []
124
+ self._speech_buf: list[np.ndarray] = []
125
+ self._vad_leftover = np.array([], dtype=np.float32)
126
+ self._speaking = False
127
+
128
+ def feed(self, pcm_bytes: bytes) -> np.ndarray | None:
129
+ audio = (
130
+ np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32_768.0
131
+ )
132
+ self._pre_roll.append(audio)
133
+ if len(self._pre_roll) > 5:
134
+ self._pre_roll.pop(0)
135
+
136
+ if self._speaking:
137
+ self._speech_buf.append(audio)
138
+
139
+ combined = np.concatenate([self._vad_leftover, audio])
140
+ i = 0
141
+ result = None
142
+
143
+ while i + self.VAD_CHUNK <= len(combined):
144
+ chunk = combined[i : i + self.VAD_CHUNK]
145
+ i += self.VAD_CHUNK
146
+ try:
147
+ event = self._vad(
148
+ torch.from_numpy(chunk).float(), return_seconds=False
149
+ )
150
+ except Exception:
151
+ event = None
152
+
153
+ if event:
154
+ if "start" in event and not self._speaking:
155
+ self._speaking = True
156
+ self._speech_buf = list(self._pre_roll)
157
+ elif "end" in event and self._speaking:
158
+ self._speaking = False
159
+ if self._speech_buf:
160
+ speech = np.concatenate(self._speech_buf)
161
+ if len(speech) / STT_SR >= self.MIN_SPEECH_S:
162
+ result = speech
163
+ self._speech_buf = []
164
+
165
+ self._vad_leftover = combined[i:]
166
+ return result
167
+
168
+ def reset(self):
169
+ self._pre_roll = []
170
+ self._speech_buf = []
171
+ self._vad_leftover = np.array([], dtype=np.float32)
172
+ self._speaking = False
173
+ self._vad.reset_states()
174
+
175
+
176
+ # --------------------------------------------------------------------------- #
177
+ # WebSocket handler #
178
+ # --------------------------------------------------------------------------- #
179
+ @app.websocket("/ws/audio")
180
+ async def ws_audio(ws: WebSocket):
181
+ await ws.accept()
182
+ logger.info("Client connected: %s", ws.client)
183
+
184
+ if _models is None or _pipeline is None:
185
+ await ws.send_text(json.dumps({
186
+ "type": "error", "message": "Models still loading, please wait."
187
+ }))
188
+ await ws.close()
189
+ return
190
+
191
+ loop = asyncio.get_event_loop()
192
+ segmenter = SpeechSegmenter(_models)
193
+ conversation = build_initial_conversation()
194
+
195
+ async def _send(obj: dict):
196
+ await ws.send_text(json.dumps(obj))
197
+
198
+ try:
199
+ while True:
200
+ msg = await ws.receive()
201
+
202
+ # ── control frame ─────────────────────────────────────────── #
203
+ if "text" in msg and msg["text"]:
204
+ try:
205
+ ctrl = json.loads(msg["text"])
206
+ except json.JSONDecodeError:
207
+ continue
208
+ if ctrl.get("type") == "start":
209
+ segmenter.reset()
210
+ _models.vad_reset()
211
+ conversation = build_initial_conversation()
212
+ logger.info("Session reset by client.")
213
+ await _send({"type": "status", "message": "Session started."})
214
+ elif ctrl.get("type") == "stop":
215
+ break
216
+ continue
217
+
218
+ # ── audio frame ───────────────────────────────────────────── #
219
+ if "bytes" not in msg or not msg["bytes"]:
220
+ continue
221
+
222
+ speech_audio = segmenter.feed(msg["bytes"])
223
+ if speech_audio is None:
224
+ continue
225
+
226
+ # ── transcribe ────────────────────────────────────────────── #
227
+ logger.info("Speech: %.2f s → transcribing…", len(speech_audio) / STT_SR)
228
+ await _send({"type": "status", "message": "Transcribing…"})
229
+
230
+ transcript: str = await loop.run_in_executor(
231
+ None, _models.transcribe, speech_audio
232
+ )
233
+
234
+ if not transcript or len(transcript.strip()) < 2:
235
+ await _send({"type": "status", "message": "Couldn't hear clearly. Try again."})
236
+ continue
237
+
238
+ logger.info("Transcript: '%s'", transcript)
239
+ await _send({"type": "transcript", "text": transcript})
240
+
241
+ # ── LLM + TTS streaming pipeline ──────────────────────────── #
242
+ conversation.append({"role": "user", "content": transcript})
243
+ await _send({"type": "agent_start"})
244
+
245
+ t0 = time.perf_counter()
246
+ try:
247
+ response_text = await _pipeline.process(conversation, ws, loop)
248
+ except Exception as exc:
249
+ logger.error("Pipeline error: %s", exc, exc_info=True)
250
+ await _send({"type": "error", "message": f"Pipeline error: {exc}"})
251
+ continue
252
+
253
+ latency_ms = int((time.perf_counter() - t0) * 1000)
254
+ conversation.append({"role": "assistant", "content": response_text})
255
+ await _send({"type": "agent_done", "text": response_text, "latency_ms": latency_ms})
256
+ logger.info("Turn done in %d ms.", latency_ms)
257
+
258
+ except WebSocketDisconnect:
259
+ logger.info("Client disconnected: %s", ws.client)
260
+ except Exception as exc:
261
+ logger.error("WS error: %s", exc, exc_info=True)
262
+ try:
263
+ await _send({"type": "error", "message": str(exc)})
264
+ except Exception:
265
+ pass
266
+ finally:
267
+ logger.info("WS session closed: %s", ws.client)
268
+
269
+
270
+ # --------------------------------------------------------------------------- #
271
+ if __name__ == "__main__":
272
+ port = int(os.environ.get("PORT", 7860))
273
+ uvicorn.run(
274
+ "app:app",
275
+ host="0.0.0.0",
276
+ port=port,
277
+ log_level="info",
278
+ ws_ping_interval=30,
279
+ ws_ping_timeout=60,
280
+ )
audio-capture-worklet.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * audio-capture-worklet.js
3
+ * ------------------------
4
+ * Runs in the AudioWorklet thread (dedicated audio thread, zero GC pauses).
5
+ *
6
+ * Responsibilities
7
+ * ----------------
8
+ * 1. Receive float32 PCM frames from the mic at AudioContext.sampleRate
9
+ * (typically 44100 or 48000 Hz).
10
+ * 2. Downsample to TARGET_SR = 16000 Hz using averaged decimation
11
+ * (basic anti-aliasing — good enough for speech).
12
+ * 3. Accumulate until CHUNK_SAMPLES (320 samples = 20 ms at 16 kHz) are
13
+ * ready, then convert to Int16 and post to the main thread.
14
+ *
15
+ * Messages posted to main thread
16
+ * --------------------------------
17
+ * { type: 'audio', pcm: ArrayBuffer } — 640 bytes = 320 int16 samples
18
+ */
19
+
20
+ const TARGET_SR = 16_000;
21
+ const CHUNK_SAMPLES = 320; // 20 ms at 16 kHz → 640 bytes as Int16
22
+
23
+ class AudioCaptureProcessor extends AudioWorkletProcessor {
24
+ constructor () {
25
+ super();
26
+ this._buf = []; // Downsampled float32 accumulator
27
+ this._active = false;
28
+
29
+ this.port.onmessage = ({ data }) => {
30
+ if (data.type === 'start') this._active = true;
31
+ if (data.type === 'stop') this._active = false;
32
+ };
33
+ }
34
+
35
+ process (inputs) {
36
+ if (!this._active) return true;
37
+
38
+ const channel = inputs[0]?.[0];
39
+ if (!channel || channel.length === 0) return true;
40
+
41
+ // ── Downsample: average samples within each output window ───────── //
42
+ const ratio = sampleRate / TARGET_SR; // e.g. 48000/16000 = 3.0
43
+ const outCount = Math.floor(channel.length / ratio);
44
+
45
+ for (let i = 0; i < outCount; i++) {
46
+ const srcStart = i * ratio;
47
+ const srcEnd = srcStart + ratio;
48
+ let sum = 0;
49
+ let n = 0;
50
+ for (let j = Math.floor(srcStart); j < Math.min(Math.ceil(srcEnd), channel.length); j++) {
51
+ sum += channel[j];
52
+ n++;
53
+ }
54
+ this._buf.push(n > 0 ? sum / n : 0);
55
+ }
56
+
57
+ // ── Emit CHUNK_SAMPLES at a time ─────────────────────────────────── //
58
+ while (this._buf.length >= CHUNK_SAMPLES) {
59
+ const slice = this._buf.splice(0, CHUNK_SAMPLES);
60
+ const int16 = new Int16Array(CHUNK_SAMPLES);
61
+ for (let i = 0; i < CHUNK_SAMPLES; i++) {
62
+ int16[i] = Math.max(-32_768, Math.min(32_767, Math.round(slice[i] * 32_767)));
63
+ }
64
+ this.port.postMessage({ type: 'audio', pcm: int16.buffer }, [int16.buffer]);
65
+ }
66
+
67
+ return true; // keep processor alive
68
+ }
69
+ }
70
+
71
+ registerProcessor('audio-capture-processor', AudioCaptureProcessor);
index.html ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"/>
6
+ <title>Open-Source Voice Agent</title>
7
+ <style>
8
+ /* ── Reset & base ────────────────────────────────────────────────────────── */
9
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
10
+
11
+ :root {
12
+ --bg0: #0f1117;
13
+ --bg1: #161b27;
14
+ --bg2: #1e2436;
15
+ --bg3: #252d42;
16
+ --border: #2e3a52;
17
+ --text0: #e8eaf0;
18
+ --text1: #9aa3b8;
19
+ --text2: #5c677e;
20
+ --accent: #4f8ef7;
21
+ --green: #22c55e;
22
+ --amber: #f59e0b;
23
+ --blue: #3b82f6;
24
+ --red: #ef4444;
25
+ --radius: 12px;
26
+ --font: 'Inter', system-ui, -apple-system, sans-serif;
27
+ }
28
+
29
+ body {
30
+ background: var(--bg0);
31
+ color: var(--text0);
32
+ font-family: var(--font);
33
+ min-height: 100vh;
34
+ display: flex;
35
+ flex-direction: column;
36
+ align-items: center;
37
+ }
38
+
39
+ /* ── Layout ──────────────────────────────────────────────────────────────── */
40
+ .page {
41
+ width: 100%;
42
+ max-width: 680px;
43
+ padding: 24px 16px 48px;
44
+ display: flex;
45
+ flex-direction: column;
46
+ gap: 20px;
47
+ }
48
+
49
+ /* ── Header ──────────────────────────────────────────────────────────────── */
50
+ .header { text-align: center; padding: 8px 0 4px; }
51
+ .header h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.3px; }
52
+ .header p { font-size: 13px; color: var(--text1); margin-top: 6px; }
53
+
54
+ .badges {
55
+ display: flex; justify-content: center; gap: 8px; flex-wrap: wrap;
56
+ margin-top: 10px;
57
+ }
58
+ .badge {
59
+ font-size: 11px; font-weight: 500;
60
+ padding: 3px 10px;
61
+ border-radius: 20px;
62
+ border: 1px solid var(--border);
63
+ color: var(--text1);
64
+ background: var(--bg2);
65
+ }
66
+ .badge span { color: var(--accent); }
67
+
68
+ /* ── Card ────────────────────────────────────────────────────────────────── */
69
+ .card {
70
+ background: var(--bg1);
71
+ border: 1px solid var(--border);
72
+ border-radius: var(--radius);
73
+ overflow: hidden;
74
+ }
75
+
76
+ /* ── Visualiser ──────────────────────────────────────────────────────────── */
77
+ .vis-wrap {
78
+ padding: 0;
79
+ background: var(--bg0);
80
+ border-bottom: 1px solid var(--border);
81
+ }
82
+ #visualiser {
83
+ display: block;
84
+ width: 100%;
85
+ height: 90px;
86
+ }
87
+
88
+ /* ── Controls ────────────────────────────────────────────────────────────── */
89
+ .controls {
90
+ display: flex;
91
+ flex-direction: column;
92
+ align-items: center;
93
+ gap: 14px;
94
+ padding: 24px 20px 20px;
95
+ }
96
+
97
+ #btn-toggle {
98
+ width: 120px; height: 120px;
99
+ border-radius: 50%;
100
+ border: 2px solid var(--border);
101
+ background: var(--bg2);
102
+ color: var(--text0);
103
+ font-size: 32px;
104
+ cursor: pointer;
105
+ transition: background .15s, transform .1s, border-color .15s;
106
+ display: flex; align-items: center; justify-content: center;
107
+ flex-direction: column;
108
+ gap: 4px;
109
+ line-height: 1;
110
+ }
111
+ #btn-toggle:hover { background: var(--bg3); border-color: var(--accent); }
112
+ #btn-toggle:active { transform: scale(0.95); }
113
+ #btn-toggle.active {
114
+ background: #1a2440;
115
+ border-color: var(--blue);
116
+ box-shadow: 0 0 0 4px rgba(79,142,247,0.15);
117
+ }
118
+ #btn-label { font-size: 11px; color: var(--text1); font-weight: 500; }
119
+
120
+ /* ── Status bar ──────────────────────────────────────────────────────────── */
121
+ .status-bar {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: 8px;
125
+ padding: 10px 20px;
126
+ background: var(--bg2);
127
+ border-top: 1px solid var(--border);
128
+ justify-content: space-between;
129
+ }
130
+ .status-left { display: flex; align-items: center; gap: 8px; }
131
+ #status-dot {
132
+ width: 8px; height: 8px; border-radius: 50%;
133
+ background: #6b7280;
134
+ transition: background .3s;
135
+ flex-shrink: 0;
136
+ }
137
+ #status-dot.pulse { animation: pulse 1.2s infinite; }
138
+ @keyframes pulse {
139
+ 0%, 100% { opacity: 1; }
140
+ 50% { opacity: 0.4; }
141
+ }
142
+ #status-text { font-size: 13px; color: var(--text1); }
143
+ #latency { font-size: 12px; color: var(--text2); }
144
+
145
+ /* ── Transcript ──────────────────────────────────────────────────────────── */
146
+ .transcript-header {
147
+ padding: 12px 16px 8px;
148
+ font-size: 11px;
149
+ font-weight: 600;
150
+ letter-spacing: .06em;
151
+ text-transform: uppercase;
152
+ color: var(--text2);
153
+ border-bottom: 1px solid var(--border);
154
+ display: flex;
155
+ justify-content: space-between;
156
+ align-items: center;
157
+ }
158
+ #btn-clear {
159
+ font-size: 11px; color: var(--text2);
160
+ background: none; border: none; cursor: pointer;
161
+ padding: 2px 6px; border-radius: 4px;
162
+ }
163
+ #btn-clear:hover { color: var(--text1); background: var(--bg3); }
164
+
165
+ #transcript {
166
+ min-height: 120px;
167
+ max-height: 320px;
168
+ overflow-y: auto;
169
+ padding: 12px 16px;
170
+ display: flex;
171
+ flex-direction: column;
172
+ gap: 10px;
173
+ scroll-behavior: smooth;
174
+ }
175
+ #transcript:empty::before {
176
+ content: 'Conversation will appear here…';
177
+ color: var(--text2);
178
+ font-size: 13px;
179
+ }
180
+
181
+ .msg { display: flex; flex-direction: column; gap: 3px; }
182
+ .msg-role {
183
+ font-size: 10px;
184
+ font-weight: 600;
185
+ letter-spacing: .05em;
186
+ text-transform: uppercase;
187
+ }
188
+ .msg-text { font-size: 14px; line-height: 1.55; color: var(--text0); }
189
+
190
+ .msg-user .msg-role { color: var(--green); }
191
+ .msg-agent .msg-role { color: var(--blue); }
192
+ .msg-user .msg-text { color: var(--text0); }
193
+ .msg-agent .msg-text { color: var(--text0); }
194
+
195
+ /* ── Model info footer ───────────────────────────────────────────────────── */
196
+ .model-grid {
197
+ display: grid;
198
+ grid-template-columns: repeat(3, 1fr);
199
+ gap: 1px;
200
+ background: var(--border);
201
+ border: 1px solid var(--border);
202
+ border-radius: var(--radius);
203
+ overflow: hidden;
204
+ }
205
+ .model-cell {
206
+ background: var(--bg1);
207
+ padding: 12px 14px;
208
+ text-align: center;
209
+ }
210
+ .model-cell .mc-label {
211
+ font-size: 10px;
212
+ font-weight: 600;
213
+ text-transform: uppercase;
214
+ letter-spacing: .06em;
215
+ color: var(--text2);
216
+ margin-bottom: 4px;
217
+ }
218
+ .model-cell .mc-name { font-size: 12px; font-weight: 500; color: var(--text0); }
219
+ .model-cell .mc-size { font-size: 11px; color: var(--text2); margin-top: 2px; }
220
+
221
+ /* ── Scrollbar ───────────────────────────────────────────────────────────── */
222
+ ::-webkit-scrollbar { width: 4px; }
223
+ ::-webkit-scrollbar-track { background: transparent; }
224
+ ::-webkit-scrollbar-thumb { background: var(--bg3); border-radius: 2px; }
225
+ </style>
226
+ </head>
227
+ <body>
228
+ <div class="page">
229
+
230
+ <!-- Header -->
231
+ <div class="header">
232
+ <h1>🎤 Open-Source Voice Agent</h1>
233
+ <p>100% open-source models · streaming overlap pipeline · English</p>
234
+ <div class="badges">
235
+ <div class="badge"><span>STT</span> Whisper base.en</div>
236
+ <div class="badge"><span>LLM</span> SmolLM2-1.7B</div>
237
+ <div class="badge"><span>TTS</span> MMS-TTS-eng</div>
238
+ <div class="badge"><span>VAD</span> Silero</div>
239
+ </div>
240
+ </div>
241
+
242
+ <!-- Voice card -->
243
+ <div class="card">
244
+ <!-- Waveform visualiser -->
245
+ <div class="vis-wrap">
246
+ <canvas id="visualiser"></canvas>
247
+ </div>
248
+
249
+ <!-- Mic button -->
250
+ <div class="controls">
251
+ <button id="btn-toggle" aria-label="Start recording">
252
+ <span id="btn-icon">🎤</span>
253
+ <span id="btn-label">START</span>
254
+ </button>
255
+ </div>
256
+
257
+ <!-- Status bar -->
258
+ <div class="status-bar">
259
+ <div class="status-left">
260
+ <div id="status-dot"></div>
261
+ <span id="status-text">Ready — click Start to begin</span>
262
+ </div>
263
+ <span id="latency"></span>
264
+ </div>
265
+ </div>
266
+
267
+ <!-- Transcript -->
268
+ <div class="card">
269
+ <div class="transcript-header">
270
+ Conversation
271
+ <button id="btn-clear">Clear</button>
272
+ </div>
273
+ <div id="transcript"></div>
274
+ </div>
275
+
276
+ <!-- Model info -->
277
+ <div class="model-grid">
278
+ <div class="model-cell">
279
+ <div class="mc-label">Speech-to-Text</div>
280
+ <div class="mc-name">Whisper base.en</div>
281
+ <div class="mc-size">74 M params · GPU/CPU</div>
282
+ </div>
283
+ <div class="model-cell">
284
+ <div class="mc-label">Language Model</div>
285
+ <div class="mc-name">SmolLM2-1.7B-Instruct</div>
286
+ <div class="mc-size">1.7 B params · GPU/CPU</div>
287
+ </div>
288
+ <div class="model-cell">
289
+ <div class="mc-label">Text-to-Speech</div>
290
+ <div class="mc-name">MMS-TTS-eng</div>
291
+ <div class="mc-size">VITS · 16 kHz · CPU</div>
292
+ </div>
293
+ </div>
294
+
295
+ </div>
296
+
297
+ <script src="/app.js" defer></script>
298
+ <script>
299
+ // Wire up the Clear button after DOM is ready
300
+ document.getElementById('btn-clear').addEventListener('click', () => {
301
+ document.getElementById('transcript').innerHTML = '';
302
+ });
303
+
304
+ // Reflect active state on button
305
+ const _setState_orig = window.setState;
306
+ // We patch setState in app.js via a MutationObserver hack instead:
307
+ const _btnToggle = document.getElementById('btn-toggle');
308
+ const _btnIcon = document.getElementById('btn-icon');
309
+ const _btnLabel = document.getElementById('btn-label');
310
+ const _dot = document.getElementById('status-dot');
311
+
312
+ // Observe #status-dot background changes to mirror active state on button
313
+ const obs = new MutationObserver(() => {
314
+ const bg = _dot.style.background;
315
+ if (bg === '#22c55e' || bg === '#3b82f6') {
316
+ _btnToggle.classList.add('active');
317
+ _btnIcon.textContent = '⏹';
318
+ _btnLabel.textContent = 'STOP';
319
+ } else if (bg === '#f59e0b') {
320
+ _btnToggle.classList.add('active');
321
+ _btnIcon.textContent = '💭';
322
+ _btnLabel.textContent = 'THINKING';
323
+ } else {
324
+ _btnToggle.classList.remove('active');
325
+ _btnIcon.textContent = '🎤';
326
+ _btnLabel.textContent = 'START';
327
+ }
328
+
329
+ // Pulse dot when not idle
330
+ _dot.classList.toggle('pulse', bg !== '#6b7280');
331
+ });
332
+ obs.observe(_dot, { attributes: true, attributeFilter: ['style'] });
333
+ </script>
334
+ </body>
335
+ </html>
models.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models.py
3
+ ---------
4
+ Single source of truth for every model used in the pipeline.
5
+
6
+ Component Model Params Device
7
+ ----------- --------------------------------------- ------- ------
8
+ STT openai/whisper-base.en 74 M GPU/CPU (fp16/fp32)
9
+ LLM HuggingFaceTB/SmolLM2-1.7B-Instruct 1.7 B GPU/CPU (fp16/fp32)
10
+ TTS facebook/mms-tts-eng (VITS) ~430 M CPU (fp32)
11
+ VAD silero-vad 2 MB CPU
12
+
13
+ All models are loaded once at startup. Inference methods are synchronous
14
+ (blocking) — call them from a thread-pool executor inside async handlers.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import time
21
+ from typing import Optional
22
+
23
+ import numpy as np
24
+ import torch
25
+ from transformers import (
26
+ AutoModelForCausalLM,
27
+ AutoTokenizer,
28
+ VitsModel,
29
+ WhisperForConditionalGeneration,
30
+ WhisperProcessor,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # --------------------------------------------------------------------------- #
36
+ # Model IDs (swap here for lighter / heavier variants) #
37
+ # --------------------------------------------------------------------------- #
38
+ STT_MODEL_ID = "openai/whisper-base.en" # 74 M — swap tiny.en for CPU-only
39
+ LLM_MODEL_ID = "HuggingFaceTB/SmolLM2-1.7B-Instruct" # 1.7 B — swap 360M for CPU-only
40
+ TTS_MODEL_ID = "facebook/mms-tts-eng" # VITS, 16 kHz output
41
+
42
+ STT_SR = 16_000 # Whisper expects 16 kHz
43
+ TTS_SR = 16_000 # MMS-TTS-ENG native output rate
44
+ MAX_NEW_TOKENS = 256 # Cap LLM response length for voice interaction
45
+
46
+
47
+ # --------------------------------------------------------------------------- #
48
+ # ModelManager #
49
+ # --------------------------------------------------------------------------- #
50
+ class ModelManager:
51
+ """Load and expose all model inference in one place."""
52
+
53
+ def __init__(self):
54
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
55
+ self.compute_dtype = (
56
+ torch.float16 if self.device == "cuda" else torch.float32
57
+ )
58
+ logger.info("Device: %s | dtype: %s", self.device, self.compute_dtype)
59
+
60
+ # TTS always runs on CPU (saves GPU VRAM for STT + LLM)
61
+ self.tts_device = "cpu"
62
+
63
+ self._load_vad()
64
+ self._load_stt()
65
+ self._load_llm()
66
+ self._load_tts()
67
+ logger.info("✅ All models ready.")
68
+
69
+ # ------------------------------------------------------------------ #
70
+ # VAD #
71
+ # ------------------------------------------------------------------ #
72
+ def _load_vad(self):
73
+ logger.info("Loading VAD (silero-vad) …")
74
+ from silero_vad import VADIterator, load_silero_vad # type: ignore
75
+
76
+ vad_model = load_silero_vad()
77
+ # silence_ms=700 → 700 ms of quiet ends an utterance
78
+ self.vad_iter = VADIterator(
79
+ vad_model,
80
+ sampling_rate=STT_SR,
81
+ threshold=0.50,
82
+ min_silence_duration_ms=700,
83
+ speech_pad_ms=50,
84
+ )
85
+ logger.info("VAD ready.")
86
+
87
+ def vad_reset(self):
88
+ """Reset VAD state between conversations."""
89
+ self.vad_iter.reset_states()
90
+
91
+ # ------------------------------------------------------------------ #
92
+ # STT — Whisper #
93
+ # ------------------------------------------------------------------ #
94
+ def _load_stt(self):
95
+ logger.info("Loading STT: %s …", STT_MODEL_ID)
96
+ self.stt_processor = WhisperProcessor.from_pretrained(STT_MODEL_ID)
97
+ self.stt_model = WhisperForConditionalGeneration.from_pretrained(
98
+ STT_MODEL_ID,
99
+ torch_dtype=self.compute_dtype,
100
+ ).to(self.device)
101
+ self.stt_model.eval()
102
+ logger.info("STT ready on %s.", self.device)
103
+
104
+ def transcribe(self, audio_float32: np.ndarray) -> str:
105
+ """
106
+ Transcribe a 16 kHz float32 numpy array to text.
107
+
108
+ Parameters
109
+ ----------
110
+ audio_float32 : np.ndarray shape (N,), dtype float32, range [-1, 1]
111
+ """
112
+ t0 = time.perf_counter()
113
+
114
+ inputs = self.stt_processor(
115
+ audio_float32,
116
+ sampling_rate=STT_SR,
117
+ return_tensors="pt",
118
+ )
119
+ features = inputs.input_features.to(
120
+ device=self.device, dtype=self.compute_dtype
121
+ )
122
+
123
+ with torch.no_grad():
124
+ ids = self.stt_model.generate(features, language="en", task="transcribe")
125
+
126
+ text = self.stt_processor.batch_decode(ids, skip_special_tokens=True)[0].strip()
127
+ logger.info("STT (%.0f ms): '%s'", (time.perf_counter() - t0) * 1000, text)
128
+ return text
129
+
130
+ # ------------------------------------------------------------------ #
131
+ # LLM — SmolLM2 #
132
+ # ------------------------------------------------------------------ #
133
+ def _load_llm(self):
134
+ logger.info("Loading LLM: %s …", LLM_MODEL_ID)
135
+ self.llm_tokenizer = AutoTokenizer.from_pretrained(LLM_MODEL_ID)
136
+ self.llm = AutoModelForCausalLM.from_pretrained(
137
+ LLM_MODEL_ID,
138
+ torch_dtype=self.compute_dtype,
139
+ ).to(self.device)
140
+ self.llm.eval()
141
+ logger.info("LLM ready on %s.", self.device)
142
+
143
+ def build_prompt(self, messages: list[dict]) -> str:
144
+ """Apply the model's chat template and return a formatted string."""
145
+ return self.llm_tokenizer.apply_chat_template(
146
+ messages,
147
+ tokenize=False,
148
+ add_generation_prompt=True,
149
+ )
150
+
151
+ def tokenize(self, prompt: str) -> dict:
152
+ """Tokenize a prompt string → dict of tensors on self.device."""
153
+ enc = self.llm_tokenizer(prompt, return_tensors="pt")
154
+ return {k: v.to(self.device) for k, v in enc.items()}
155
+
156
+ # ------------------------------------------------------------------ #
157
+ # TTS — MMS-TTS-ENG (VITS) #
158
+ # ------------------------------------------------------------------ #
159
+ def _load_tts(self):
160
+ logger.info("Loading TTS: %s …", TTS_MODEL_ID)
161
+ self.tts_tokenizer = AutoTokenizer.from_pretrained(TTS_MODEL_ID)
162
+ self.tts_model = VitsModel.from_pretrained(TTS_MODEL_ID) # fp32 on CPU
163
+ self.tts_model.eval()
164
+ self.tts_sample_rate: int = self.tts_model.config.sampling_rate # 16 000
165
+ logger.info("TTS ready on CPU (sample_rate=%d Hz).", self.tts_sample_rate)
166
+
167
+ def synthesize(self, text: str) -> Optional[bytes]:
168
+ """
169
+ Synthesize text → raw PCM bytes (int16, mono, 16 kHz).
170
+
171
+ Returns None if text is empty or synthesis fails.
172
+ """
173
+ text = text.strip()
174
+ if not text:
175
+ return None
176
+
177
+ t0 = time.perf_counter()
178
+
179
+ try:
180
+ inputs = self.tts_tokenizer(text, return_tensors="pt")
181
+ with torch.no_grad():
182
+ output = self.tts_model(**inputs)
183
+
184
+ # waveform: (1, T) float32 in roughly [-1, 1]
185
+ wav: np.ndarray = output.waveform.squeeze().cpu().numpy()
186
+
187
+ # Convert float32 → int16 PCM
188
+ wav_int16 = np.clip(wav * 32_767.0, -32_768, 32_767).astype(np.int16)
189
+ pcm_bytes = wav_int16.tobytes()
190
+
191
+ logger.debug(
192
+ "TTS (%.0f ms): %d chars → %d bytes",
193
+ (time.perf_counter() - t0) * 1000,
194
+ len(text),
195
+ len(pcm_bytes),
196
+ )
197
+ return pcm_bytes
198
+
199
+ except Exception as exc:
200
+ logger.error("TTS synthesis failed for %r: %s", text[:60], exc)
201
+ return None
202
+
203
+ # ------------------------------------------------------------------ #
204
+ # Convenience #
205
+ # ------------------------------------------------------------------ #
206
+ def warm_up(self):
207
+ """Run a tiny inference on each model to pre-JIT all kernels."""
208
+ logger.info("Warming up models …")
209
+ silence = np.zeros(STT_SR, dtype=np.float32)
210
+ self.transcribe(silence)
211
+ dummy_msgs = [
212
+ {"role": "system", "content": "You are helpful."},
213
+ {"role": "user", "content": "hi"},
214
+ ]
215
+ # Just tokenize, don't run full generation during warm-up
216
+ self.tokenize(self.build_prompt(dummy_msgs))
217
+ self.synthesize("Hello.")
218
+ logger.info("Warm-up complete.")
pipeline.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline.py
3
+ -----------
4
+ Core streaming overlap pipeline.
5
+
6
+ Flow
7
+ ----
8
+ Conversation history
9
+
10
+
11
+ SmolLM2 (thread) ──tokens──► SentenceBuffer
12
+
13
+ sentence complete?
14
+ │ yes
15
+
16
+ MMS-TTS (executor) ← runs while LLM
17
+ │ keeps generating!
18
+
19
+ ws.send_bytes(pcm)
20
+
21
+ ◄─────────┘ repeat
22
+
23
+ The key insight: TTS synthesis for sentence N starts as soon as sentence N is
24
+ complete — the LLM does NOT wait. By the time the browser finishes playing
25
+ sentence N, sentence N+1 is already synthesised and queued, giving near-zero
26
+ inter-sentence gaps and a much lower perceived total latency.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import logging
33
+ import time
34
+ from threading import Thread
35
+ from typing import AsyncGenerator
36
+
37
+ from fastapi import WebSocket
38
+ from transformers import TextIteratorStreamer # type: ignore
39
+
40
+ from models import MAX_NEW_TOKENS, ModelManager
41
+ from sentence_buffer import SentenceBuffer
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ # Maximum conversation turns kept in context (system + N user/assistant pairs)
46
+ MAX_HISTORY_TURNS = 6
47
+ SYSTEM_PROMPT = (
48
+ "You are a helpful, friendly voice assistant. "
49
+ "Keep every reply concise — two or three short sentences at most. "
50
+ "Speak naturally; avoid bullet points, markdown, or lists."
51
+ )
52
+
53
+
54
+ # --------------------------------------------------------------------------- #
55
+ # Async LLM token stream #
56
+ # --------------------------------------------------------------------------- #
57
+ async def _llm_token_stream(
58
+ models: ModelManager,
59
+ messages: list[dict],
60
+ loop: asyncio.AbstractEventLoop,
61
+ ) -> AsyncGenerator[str, None]:
62
+ """
63
+ Async generator that yields text tokens from SmolLM2 as they are produced.
64
+
65
+ Architecture
66
+ ~~~~~~~~~~~~
67
+ model.generate() runs in gen_thread (CPU/GPU bound).
68
+ TextIteratorStreamer bridges the sync world → async Queue via
69
+ run_coroutine_threadsafe so the FastAPI event loop stays unblocked.
70
+ """
71
+ prompt = models.build_prompt(messages)
72
+ enc = models.tokenize(prompt)
73
+
74
+ streamer = TextIteratorStreamer(
75
+ models.llm_tokenizer,
76
+ skip_prompt=True,
77
+ skip_special_tokens=True,
78
+ timeout=60.0,
79
+ )
80
+
81
+ gen_kwargs = dict(
82
+ input_ids=enc["input_ids"],
83
+ attention_mask=enc.get("attention_mask"),
84
+ streamer=streamer,
85
+ max_new_tokens=MAX_NEW_TOKENS,
86
+ do_sample=True,
87
+ temperature=0.7,
88
+ repetition_penalty=1.1,
89
+ pad_token_id=models.llm_tokenizer.eos_token_id,
90
+ )
91
+
92
+ token_q: asyncio.Queue[str | None] = asyncio.Queue()
93
+
94
+ # ── thread: start generation + drain streamer into async queue ──────── #
95
+ def _generate_and_feed():
96
+ gen_thread = Thread(
97
+ target=models.llm.generate, kwargs=gen_kwargs, daemon=True
98
+ )
99
+ gen_thread.start()
100
+ try:
101
+ for tok in streamer: # blocks until each token is ready
102
+ asyncio.run_coroutine_threadsafe(token_q.put(tok), loop)
103
+ except Exception as exc:
104
+ asyncio.run_coroutine_threadsafe(token_q.put(None), loop)
105
+ logger.error("LLM stream error: %s", exc)
106
+ return
107
+ asyncio.run_coroutine_threadsafe(token_q.put(None), loop) # sentinel
108
+
109
+ feed_thread = Thread(target=_generate_and_feed, daemon=True)
110
+ feed_thread.start()
111
+
112
+ # ── yield tokens from the queue ─────────────────────────────────────── #
113
+ while True:
114
+ tok = await token_q.get()
115
+ if tok is None:
116
+ break
117
+ yield tok
118
+
119
+
120
+ # --------------------------------------------------------------------------- #
121
+ # StreamingPipeline #
122
+ # --------------------------------------------------------------------------- #
123
+ class StreamingPipeline:
124
+ """
125
+ Orchestrates the full LLM → SentenceBuffer → TTS → WebSocket pipeline
126
+ with streaming overlap.
127
+ """
128
+
129
+ def __init__(self, models: ModelManager):
130
+ self.models = models
131
+ self._executor = None # Use default loop executor (ThreadPoolExecutor)
132
+
133
+ async def process(
134
+ self,
135
+ conversation: list[dict],
136
+ ws: WebSocket,
137
+ loop: asyncio.AbstractEventLoop,
138
+ ) -> str:
139
+ """
140
+ Stream an LLM response over `ws` as audio chunks.
141
+
142
+ Returns the full text of the assistant turn (for appending to history).
143
+
144
+ Parameters
145
+ ----------
146
+ conversation : list of {role, content} dicts including the new user turn.
147
+ ws : active WebSocket connection to send PCM bytes to.
148
+ loop : running event loop (passed for thread-safe queue ops).
149
+ """
150
+ # Trim history to avoid context overflow
151
+ conversation = _trim_history(conversation)
152
+
153
+ sentence_buf = SentenceBuffer(min_length=8)
154
+ full_text = ""
155
+ t_start = time.perf_counter()
156
+ ttft_logged = False
157
+
158
+ async for token in _llm_token_stream(self.models, conversation, loop):
159
+ if not ttft_logged:
160
+ ttft_ms = (time.perf_counter() - t_start) * 1000
161
+ logger.info("LLM TTFT: %.0f ms", ttft_ms)
162
+ ttft_logged = True
163
+
164
+ full_text += token
165
+ ready = sentence_buf.add(token)
166
+
167
+ for sentence in ready:
168
+ await self._synth_and_send(sentence, ws)
169
+
170
+ # Flush any partial sentence left in buffer
171
+ tail = sentence_buf.flush()
172
+ if tail:
173
+ await self._synth_and_send(tail, ws)
174
+
175
+ total_ms = (time.perf_counter() - t_start) * 1000
176
+ logger.info(
177
+ "Pipeline done in %.0f ms | response: %d chars", total_ms, len(full_text)
178
+ )
179
+ return full_text.strip()
180
+
181
+ # ------------------------------------------------------------------ #
182
+ async def _synth_and_send(self, sentence: str, ws: WebSocket):
183
+ """
184
+ Synthesise one sentence and stream PCM bytes over the WebSocket.
185
+
186
+ TTS runs in the default thread-pool executor so the event loop
187
+ remains free to handle other coroutines (e.g. interruption frames
188
+ arriving from the client) during synthesis.
189
+ """
190
+ sentence = sentence.strip()
191
+ if not sentence:
192
+ return
193
+
194
+ logger.debug("TTS ← '%s'", sentence[:80])
195
+ t0 = time.perf_counter()
196
+
197
+ pcm: bytes | None = await asyncio.get_event_loop().run_in_executor(
198
+ None, self.models.synthesize, sentence
199
+ )
200
+
201
+ if not pcm:
202
+ return
203
+
204
+ tts_ms = (time.perf_counter() - t0) * 1000
205
+ logger.info("TTS (%.0f ms): %d bytes for '%s…'", tts_ms, len(pcm), sentence[:40])
206
+
207
+ # Send in 4 kB chunks to give the client a smooth receive stream
208
+ CHUNK = 4096
209
+ for i in range(0, len(pcm), CHUNK):
210
+ await ws.send_bytes(pcm[i : i + CHUNK])
211
+
212
+
213
+ # --------------------------------------------------------------------------- #
214
+ # Helpers #
215
+ # --------------------------------------------------------------------------- #
216
+ def _trim_history(messages: list[dict]) -> list[dict]:
217
+ """Keep system message + last MAX_HISTORY_TURNS user/assistant pairs."""
218
+ system = [m for m in messages if m["role"] == "system"]
219
+ turns = [m for m in messages if m["role"] != "system"]
220
+
221
+ # Each "turn" = 1 user + 1 assistant message = 2 items
222
+ max_items = MAX_HISTORY_TURNS * 2
223
+ if len(turns) > max_items:
224
+ turns = turns[-max_items:]
225
+
226
+ return system + turns
227
+
228
+
229
+ def build_initial_conversation() -> list[dict]:
230
+ """Start a fresh conversation with only the system prompt."""
231
+ return [{"role": "system", "content": SYSTEM_PROMPT}]
requirements.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Core framework ──────────────────────────────────────────────────────────
2
+ fastapi==0.115.5
3
+ uvicorn[standard]==0.32.1
4
+ websockets==13.1
5
+ python-multipart==0.0.12
6
+
7
+ # ── HuggingFace / ML ─────────────────────────────────────────────────────────
8
+ transformers==4.47.0
9
+ torch==2.5.1
10
+ torchaudio==2.5.1
11
+ accelerate==1.2.1
12
+ huggingface_hub==0.26.5
13
+ datasets==3.2.0 # used by warm-up only; can be removed if desired
14
+
15
+ # ── VAD ──────────────────────────────────────────────────────────────────────
16
+ silero-vad==5.1.2
17
+
18
+ # ── Audio utilities ──────────────────────────────────────────────────────────
19
+ numpy==1.26.4
20
+ soundfile==0.12.1
sentence_buffer.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ sentence_buffer.py
3
+ ------------------
4
+ Accumulates LLM token stream and emits complete sentences for TTS synthesis.
5
+
6
+ Key design: We must detect REAL sentence boundaries without false-positives on
7
+ abbreviations (Dr., Mr., Inc.), decimal numbers (3.14), ellipses (...) etc.
8
+ Each emitted sentence is immediately handed off to TTS while the LLM continues
9
+ generating — this is the source of the streaming overlap latency savings.
10
+ """
11
+
12
+ import re
13
+ from typing import List
14
+
15
+ # Abbreviations that must NOT trigger sentence split even when followed by a capital letter
16
+ ABBREVIATIONS = {
17
+ "mr", "mrs", "ms", "dr", "prof", "sr", "jr", "rev", "gen", "sgt",
18
+ "cpl", "pvt", "capt", "maj", "col", "lt", "cmdr", "adm",
19
+ "inc", "corp", "ltd", "llc", "co", "dept", "est",
20
+ "jan", "feb", "mar", "apr", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
21
+ "vs", "etc", "approx", "max", "min", "avg", "no", "vol", "fig",
22
+ "st", "ave", "blvd", "rd",
23
+ }
24
+
25
+ # Pattern: a word ending with a period that looks like an abbreviation
26
+ _ABBREV_RE = re.compile(
27
+ r'\b(' + '|'.join(re.escape(a) for a in ABBREVIATIONS) + r')\.$',
28
+ re.IGNORECASE,
29
+ )
30
+
31
+ # Decimal numbers like 3.14 or $19.99 must not split
32
+ _DECIMAL_RE = re.compile(r'\d+\.$')
33
+
34
+ # Sentence-ending punctuation
35
+ _SENTENCE_END_RE = re.compile(r'[.!?]+')
36
+
37
+
38
+ def _is_false_boundary(text_before_dot: str) -> bool:
39
+ """Return True if the period at the end of text_before_dot is NOT a sentence end."""
40
+ stripped = text_before_dot.rstrip()
41
+ if _ABBREV_RE.search(stripped):
42
+ return True
43
+ if _DECIMAL_RE.search(stripped):
44
+ return True
45
+ return False
46
+
47
+
48
+ class SentenceBuffer:
49
+ """
50
+ Feed LLM tokens one at a time; call flush() at stream end.
51
+
52
+ Usage
53
+ -----
54
+ buf = SentenceBuffer(min_length=12)
55
+ for token in llm_stream():
56
+ sentences = buf.add(token)
57
+ for s in sentences:
58
+ audio = tts.synthesize(s) # start immediately
59
+ final = buf.flush()
60
+ if final:
61
+ audio = tts.synthesize(final)
62
+ """
63
+
64
+ def __init__(self, min_length: int = 10):
65
+ """
66
+ min_length : int
67
+ Minimum character count before a boundary is accepted.
68
+ Avoids firing TTS on single-word fragments like "Hi."
69
+ """
70
+ self.min_length = min_length
71
+ self._buf = ""
72
+
73
+ # ------------------------------------------------------------------
74
+ def add(self, token: str) -> List[str]:
75
+ """
76
+ Append a token and return a list of complete sentences ready for TTS.
77
+ Typically returns [] or a one-element list.
78
+ """
79
+ self._buf += token
80
+ return self._extract()
81
+
82
+ def flush(self) -> str:
83
+ """Return whatever is left in the buffer (end of stream)."""
84
+ remaining = self._buf.strip()
85
+ self._buf = ""
86
+ return remaining
87
+
88
+ def reset(self):
89
+ self._buf = ""
90
+
91
+ # ------------------------------------------------------------------
92
+ def _extract(self) -> List[str]:
93
+ sentences = []
94
+
95
+ while True:
96
+ match = _SENTENCE_END_RE.search(self._buf)
97
+ if not match:
98
+ break
99
+
100
+ end_pos = match.end()
101
+ candidate = self._buf[:end_pos].strip()
102
+
103
+ if len(candidate) < self.min_length:
104
+ break # Too short — wait for more tokens
105
+
106
+ # Check for false boundary
107
+ before_punct = self._buf[:match.start()]
108
+ if _is_false_boundary(before_punct):
109
+ # Advance past this dot and keep scanning
110
+ self._buf = self._buf[end_pos:]
111
+ if sentences:
112
+ sentences[-1] += candidate # Merge into previous
113
+ else:
114
+ # Keep in buffer with content that was before
115
+ self._buf = candidate + " " + self._buf
116
+ break
117
+ continue
118
+
119
+ # Real sentence boundary — check there's enough after the punctuation
120
+ after = self._buf[end_pos:].lstrip()
121
+
122
+ # If the very next char is another sentence-ender, keep accumulating
123
+ if after and after[0] in '.!?':
124
+ break
125
+
126
+ sentences.append(candidate)
127
+ self._buf = self._buf[end_pos:].lstrip()
128
+
129
+ return sentences