Upload 10 files
Browse files- Dockerfile +60 -0
- README.md +138 -0
- app.js +336 -0
- app.py +280 -0
- audio-capture-worklet.js +71 -0
- index.html +335 -0
- models.py +218 -0
- pipeline.py +231 -0
- requirements.txt +20 -0
- 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 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
|