Initial commit - OpenClaw Voice

Self-hosted browser-based voice interface for AI assistants.

Features:
- FastAPI WebSocket server
- Whisper STT (faster-whisper or openai-whisper)
- Chatterbox TTS (MIT license, ElevenLabs quality)
- OpenAI backend (pluggable)
- Browser voice widget (push-to-talk)
- Mock mode for testing without models

Stack:
- Python 3.10+
- FastAPI + Uvicorn
- WebSocket for real-time audio
- React/vanilla JS client

Tested: Server runs, serves HTML, OpenAI backend connects.
TODO: Install Whisper, Chatterbox for full functionality.
This commit is contained in:
gianni-dalerta
2026-01-30 13:25:12 -05:00
commit 30ed092536
11 changed files with 1227 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# OpenClaw Voice Configuration
# OpenAI API Key (for AI backend)
OPENAI_API_KEY=sk-your-key-here
# Server settings
OPENCLAW_HOST=0.0.0.0
OPENCLAW_PORT=8765
# STT settings
OPENCLAW_STT_MODEL=base # tiny, base, small, medium, large-v3-turbo
OPENCLAW_STT_DEVICE=auto # auto, cpu, cuda, mps
# TTS settings
OPENCLAW_TTS_MODEL=chatterbox
# OPENCLAW_TTS_VOICE=/path/to/voice-sample.wav # For voice cloning
# AI Backend
OPENCLAW_BACKEND_TYPE=openai
OPENCLAW_BACKEND_MODEL=gpt-4o-mini
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
.env
*.egg-info/
dist/
build/
.pytest_cache/
.ruff_cache/
*.log
.DS_Store
+191
View File
@@ -0,0 +1,191 @@
# OpenClaw Voice
**Open-source browser-based voice interface for AI assistants.**
Talk to your AI like you talk to Alexa — but self-hosted, private, and free from subscription fees.
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Python](https://img.shields.io/badge/python-3.10+-green.svg)
## Why?
Voice AI platforms like ElevenLabs Agents ($0.08-0.12/min) and Retell.ai ($0.13-0.31/min) are expensive. OpenClaw Voice runs entirely on your own hardware for ~$0.003/min at scale.
## Features
- 🎙️ **Browser voice widget** — Push-to-talk or hands-free VAD
- 🔊 **Self-hosted STT** — Whisper Large V3 Turbo (runs on Mac/Linux/GPU)
- 🗣️ **Self-hosted TTS** — Chatterbox (MIT license, ElevenLabs quality)
- 🔌 **Pluggable backend** — Connect to any AI (OpenAI, Claude, Clawdbot, etc.)
- 🌐 **WebRTC audio** — Low latency (<500ms end-to-end achievable)
- 🏠 **Fully self-hosted** — Your data stays on your servers
## Quick Start
### Prerequisites
- Python 3.10+
- Node.js 18+ (for client dev)
- CUDA GPU recommended (CPU works but slower)
### Installation
```bash
# Clone the repo
git clone https://github.com/Purple-Horizons/openclaw-voice.git
cd openclaw-voice
# Install Python dependencies
pip install -r requirements.txt
# Download models (first run only)
python scripts/download_models.py
# Start the voice server
python -m src.server.main
# Open http://localhost:8765 in your browser
```
### Docker (Recommended)
```bash
docker compose up
```
## Architecture
```
┌─────────────────┐ WebRTC ┌─────────────────┐
│ Browser │ ◄─────────────► │ Voice Gateway │
│ (Voice Widget) │ Audio/Text │ (Python) │
└─────────────────┘ └────────┬────────┘
┌────────────────────────┼────────────────────────┐
│ │ │
┌─────▼─────┐ ┌───────▼───────┐ ┌──────▼──────┐
│ Whisper │ │ Your AI │ │ Chatterbox │
│ (STT) │ │ Backend │ │ (TTS) │
└───────────┘ └───────────────┘ └─────────────┘
```
## Configuration
```yaml
# config.yaml
stt:
model: "whisper-large-v3-turbo"
device: "cuda" # or "cpu", "mps" (Mac)
tts:
model: "chatterbox"
voice: "default" # or path to voice sample for cloning
backend:
type: "openai" # or "clawdbot", "custom"
url: "https://api.openai.com/v1"
model: "gpt-4o"
server:
host: "0.0.0.0"
port: 8765
ssl: false # Set true + provide certs for production
```
## Supported Models
### Speech-to-Text (STT)
| Model | Speed | Quality | VRAM |
|-------|-------|---------|------|
| Whisper Large V3 Turbo | 216x realtime | Best | ~6GB |
| Distil-Whisper | 6x faster | Good | ~3GB |
| Whisper.cpp (CPU) | Slower | Best | N/A |
### Text-to-Speech (TTS)
| Model | Speed | Quality | Voice Cloning |
|-------|-------|---------|---------------|
| Chatterbox | ~1s | Excellent | 5-second samples |
| Kokoro-82M | <0.3s | Very Good | No |
| XTTS-v2 | ~1s | Excellent | 6-second samples |
## Browser Widget
Embed the voice widget in any webpage:
```html
<script src="https://unpkg.com/@openclaw/voice-widget"></script>
<openclaw-voice server="wss://your-server:8765"></openclaw-voice>
```
Or use React:
```jsx
import { VoiceWidget } from '@openclaw/voice-widget-react';
<VoiceWidget serverUrl="wss://your-server:8765" />
```
## API
### WebSocket Protocol
Connect to `ws://localhost:8765/ws` and send/receive JSON messages:
```javascript
// Start listening
{ "type": "start_listening" }
// Audio data (base64 PCM)
{ "type": "audio", "data": "base64..." }
// Stop listening
{ "type": "stop_listening" }
// Receive transcription
{ "type": "transcript", "text": "Hello world", "final": true }
// Receive AI response audio
{ "type": "audio_response", "data": "base64...", "text": "Hi there!" }
```
## Roadmap
- [x] Basic WebSocket voice gateway
- [x] Whisper STT integration
- [x] Chatterbox TTS integration
- [ ] WebRTC for lower latency
- [ ] Voice Activity Detection (VAD)
- [ ] Streaming responses
- [ ] Voice cloning UI
- [ ] Browser widget npm package
- [ ] React/Vue components
- [ ] Docker GPU support
- [ ] Kubernetes Helm chart
## Cost Comparison
| Platform | Cost/Minute |
|----------|-------------|
| ElevenLabs Conversational AI | $0.08-0.12 |
| Retell.ai | $0.13-0.31 |
| Vapi.ai | $0.05-0.15 |
| **OpenClaw Voice (self-hosted)** | **~$0.003** |
## Contributing
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
## License
MIT License — see [LICENSE](LICENSE).
## Credits
- [Whisper](https://github.com/openai/whisper) — OpenAI
- [Chatterbox](https://github.com/resemble-ai/chatterbox) — Resemble AI
- [Silero VAD](https://github.com/snakers4/silero-vad) — Silero
- Built for [Clawdbot](https://github.com/clawdbot/clawdbot)
---
**Made with 🦀 by [Purple Horizons](https://purplehorizons.io)**
+42
View File
@@ -0,0 +1,42 @@
# Core
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
websockets>=12.0
pydantic>=2.5.0
pydantic-settings>=2.1.0
python-multipart>=0.0.6
# Audio processing
numpy>=1.26.0
soundfile>=0.12.1
librosa>=0.10.1
webrtcvad>=2.0.10
# Speech-to-Text
openai-whisper>=20231117
faster-whisper>=1.0.0
# whisper.cpp via ctypes (optional, for CPU)
# Text-to-Speech
# chatterbox-tts # Install from source for now
torch>=2.1.0
torchaudio>=2.1.0
transformers>=4.36.0
# Voice Activity Detection
silero-vad>=4.0.0
# AI Backend
openai>=1.6.0
httpx>=0.26.0
# Utilities
pyyaml>=6.0.1
python-dotenv>=1.0.0
loguru>=0.7.2
# Dev
pytest>=7.4.0
pytest-asyncio>=0.23.0
black>=23.12.0
ruff>=0.1.0
+397
View File
@@ -0,0 +1,397 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenClaw Voice</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #fff;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
max-width: 600px;
width: 100%;
text-align: center;
}
h1 {
font-size: 2.5rem;
margin-bottom: 10px;
background: linear-gradient(90deg, #ff6b35, #f7c94b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
color: #888;
margin-bottom: 40px;
}
.voice-button {
width: 150px;
height: 150px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #ff6b35 0%, #e55a2b 100%);
color: white;
font-size: 1.2rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 10px 40px rgba(255, 107, 53, 0.3);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 30px;
}
.voice-button:hover {
transform: scale(1.05);
box-shadow: 0 15px 50px rgba(255, 107, 53, 0.4);
}
.voice-button:active,
.voice-button.listening {
transform: scale(0.95);
background: linear-gradient(135deg, #e55a2b 0%, #c44a22 100%);
}
.voice-button.listening {
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 10px 40px rgba(255, 107, 53, 0.3); }
50% { box-shadow: 0 10px 60px rgba(255, 107, 53, 0.6); }
}
.status {
font-size: 1.1rem;
color: #888;
margin-bottom: 20px;
min-height: 30px;
}
.status.active {
color: #ff6b35;
}
.transcript-box {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
text-align: left;
min-height: 100px;
}
.transcript-box h3 {
font-size: 0.9rem;
color: #666;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.transcript-box p {
font-size: 1rem;
line-height: 1.6;
color: #ccc;
}
.transcript-box p.user {
color: #ff6b35;
}
.transcript-box p.assistant {
color: #4fc3f7;
}
.controls {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 20px;
}
.controls button {
padding: 10px 20px;
border: 1px solid #444;
background: transparent;
color: #888;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.controls button:hover {
border-color: #ff6b35;
color: #ff6b35;
}
.error {
color: #ff4444;
margin-top: 20px;
}
.instructions {
margin-top: 40px;
padding: 20px;
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
color: #666;
font-size: 0.9rem;
}
.instructions code {
background: rgba(255, 107, 53, 0.1);
color: #ff6b35;
padding: 2px 6px;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="container">
<h1>🎙️ OpenClaw Voice</h1>
<p class="subtitle">Self-hosted voice AI interface</p>
<button class="voice-button" id="voiceBtn">
Hold to Talk
</button>
<p class="status" id="status">Ready</p>
<div class="transcript-box">
<h3>Conversation</h3>
<div id="transcript"></div>
</div>
<div class="controls">
<button id="clearBtn">Clear History</button>
</div>
<p class="error" id="error"></p>
<div class="instructions">
<p><strong>Instructions:</strong> Hold the button and speak. Release to send.</p>
<p style="margin-top: 10px;">Press <code>Space</code> to toggle recording.</p>
</div>
</div>
<script>
const voiceBtn = document.getElementById('voiceBtn');
const statusEl = document.getElementById('status');
const transcriptEl = document.getElementById('transcript');
const errorEl = document.getElementById('error');
const clearBtn = document.getElementById('clearBtn');
let ws = null;
let mediaRecorder = null;
let audioContext = null;
let isRecording = false;
// Connect to WebSocket
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
setStatus('Connected');
errorEl.textContent = '';
};
ws.onclose = () => {
setStatus('Disconnected - reconnecting...');
setTimeout(connect, 2000);
};
ws.onerror = (e) => {
errorEl.textContent = 'WebSocket error. Is the server running?';
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
handleMessage(msg);
};
}
function handleMessage(msg) {
switch (msg.type) {
case 'listening_started':
setStatus('Listening...', true);
break;
case 'listening_stopped':
setStatus('Processing...');
break;
case 'transcript':
addTranscript('You', msg.text, 'user');
setStatus('Getting response...');
break;
case 'response_text':
addTranscript('AI', msg.text, 'assistant');
break;
case 'audio_response':
playAudio(msg.data, msg.sample_rate);
setStatus('Ready');
break;
case 'pong':
break;
}
}
function setStatus(text, active = false) {
statusEl.textContent = text;
statusEl.className = active ? 'status active' : 'status';
}
function addTranscript(speaker, text, className) {
const p = document.createElement('p');
p.className = className;
p.innerHTML = `<strong>${speaker}:</strong> ${text}`;
transcriptEl.appendChild(p);
transcriptEl.scrollTop = transcriptEl.scrollHeight;
}
// Audio recording
async function startRecording() {
if (isRecording) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
}
});
audioContext = new AudioContext({ sampleRate: 16000 });
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (e) => {
if (isRecording && ws && ws.readyState === WebSocket.OPEN) {
const audioData = e.inputBuffer.getChannelData(0);
const base64 = float32ToBase64(audioData);
ws.send(JSON.stringify({ type: 'audio', data: base64 }));
}
};
source.connect(processor);
processor.connect(audioContext.destination);
isRecording = true;
voiceBtn.classList.add('listening');
voiceBtn.textContent = 'Listening...';
ws.send(JSON.stringify({ type: 'start_listening' }));
} catch (err) {
errorEl.textContent = `Microphone error: ${err.message}`;
}
}
function stopRecording() {
if (!isRecording) return;
isRecording = false;
voiceBtn.classList.remove('listening');
voiceBtn.textContent = 'Hold to Talk';
if (audioContext) {
audioContext.close();
audioContext = null;
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'stop_listening' }));
}
}
// Audio playback
function playAudio(base64Data, sampleRate) {
const audioCtx = new AudioContext({ sampleRate: sampleRate });
const audioData = base64ToFloat32(base64Data);
const buffer = audioCtx.createBuffer(1, audioData.length, sampleRate);
buffer.getChannelData(0).set(audioData);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
source.start();
}
// Utilities
function float32ToBase64(float32Array) {
const bytes = new Uint8Array(float32Array.buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToFloat32(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new Float32Array(bytes.buffer);
}
// Event listeners
voiceBtn.addEventListener('mousedown', startRecording);
voiceBtn.addEventListener('mouseup', stopRecording);
voiceBtn.addEventListener('mouseleave', stopRecording);
voiceBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startRecording(); });
voiceBtn.addEventListener('touchend', stopRecording);
// Spacebar to toggle
document.addEventListener('keydown', (e) => {
if (e.code === 'Space' && !e.repeat) {
e.preventDefault();
if (isRecording) {
stopRecording();
} else {
startRecording();
}
}
});
clearBtn.addEventListener('click', () => {
transcriptEl.innerHTML = '';
});
// Connect on load
connect();
// Keep connection alive
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
# OpenClaw Voice Server
+104
View File
@@ -0,0 +1,104 @@
"""
AI Backend module - connects to OpenAI, Clawdbot, or custom backends.
"""
import asyncio
from typing import Optional, List, Dict
from loguru import logger
class AIBackend:
"""AI backend for processing user messages."""
def __init__(
self,
backend_type: str = "openai",
url: str = "https://api.openai.com/v1",
model: str = "gpt-4o-mini",
api_key: Optional[str] = None,
system_prompt: Optional[str] = None,
):
self.backend_type = backend_type
self.url = url
self.model = model
self.api_key = api_key
self.system_prompt = system_prompt or (
"You are a helpful voice assistant. Keep responses concise and conversational. "
"Aim for 1-2 sentences unless more detail is needed."
)
self.conversation_history: List[Dict] = []
self._client = None
self._setup_client()
def _setup_client(self):
"""Set up the API client."""
if self.backend_type == "openai":
try:
from openai import AsyncOpenAI
self._client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.url if self.url != "https://api.openai.com/v1" else None,
)
logger.info(f"✅ OpenAI client ready (model: {self.model})")
except ImportError:
logger.error("openai package not installed")
elif self.backend_type == "clawdbot":
# TODO: Implement Clawdbot gateway connection
logger.info("Clawdbot backend (not yet implemented)")
else:
logger.warning(f"Unknown backend type: {self.backend_type}")
async def chat(self, user_message: str) -> str:
"""
Send a message and get a response.
Args:
user_message: The user's transcribed speech
Returns:
AI response text
"""
if self.backend_type == "openai" and self._client:
return await self._chat_openai(user_message)
else:
# Fallback echo response
return f"I heard you say: {user_message}"
async def _chat_openai(self, user_message: str) -> str:
"""Chat via OpenAI API."""
# Add user message to history
self.conversation_history.append({
"role": "user",
"content": user_message,
})
# Build messages
messages = [{"role": "system", "content": self.system_prompt}]
messages.extend(self.conversation_history[-10:]) # Last 10 turns
try:
response = await self._client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=150, # Keep responses short for voice
temperature=0.7,
)
assistant_message = response.choices[0].message.content
# Add to history
self.conversation_history.append({
"role": "assistant",
"content": assistant_message,
})
return assistant_message
except Exception as e:
logger.error(f"OpenAI API error: {e}")
return "Sorry, I had trouble processing that. Could you try again?"
def clear_history(self):
"""Clear conversation history."""
self.conversation_history = []
+208
View File
@@ -0,0 +1,208 @@
"""
OpenClaw Voice Server
WebSocket server that handles:
- Audio input from browser
- Speech-to-Text via Whisper
- AI backend communication
- Text-to-Speech via Chatterbox
- Audio streaming back to browser
"""
import asyncio
import base64
import json
import os
from pathlib import Path
from typing import Optional
import numpy as np
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from loguru import logger
from pydantic_settings import BaseSettings
from .stt import WhisperSTT
from .tts import ChatterboxTTS
from .backend import AIBackend
from .vad import VoiceActivityDetector
class Settings(BaseSettings):
"""Server configuration."""
# Server
host: str = "0.0.0.0"
port: int = 8765
# STT
stt_model: str = "base" # tiny, base, small, medium, large-v3-turbo
stt_device: str = "auto" # auto, cpu, cuda, mps
# TTS
tts_model: str = "chatterbox"
tts_voice: Optional[str] = None # Path to voice sample for cloning
# AI Backend
backend_type: str = "openai" # openai, clawdbot, custom
backend_url: str = "https://api.openai.com/v1"
backend_model: str = "gpt-4o-mini"
openai_api_key: Optional[str] = None
# Audio
sample_rate: int = 16000
class Config:
env_prefix = "OPENCLAW_"
env_file = ".env"
settings = Settings()
app = FastAPI(title="OpenClaw Voice", version="0.1.0")
# Global instances (initialized on startup)
stt: Optional[WhisperSTT] = None
tts: Optional[ChatterboxTTS] = None
backend: Optional[AIBackend] = None
vad: Optional[VoiceActivityDetector] = None
@app.on_event("startup")
async def startup():
"""Initialize models on server start."""
global stt, tts, backend, vad
logger.info("Initializing OpenClaw Voice server...")
# Initialize STT
logger.info(f"Loading STT model: {settings.stt_model}")
stt = WhisperSTT(
model_name=settings.stt_model,
device=settings.stt_device,
)
# Initialize TTS
logger.info(f"Loading TTS model: {settings.tts_model}")
tts = ChatterboxTTS(
voice_sample=settings.tts_voice,
)
# Initialize AI backend
logger.info(f"Connecting to backend: {settings.backend_type}")
backend = AIBackend(
backend_type=settings.backend_type,
url=settings.backend_url,
model=settings.backend_model,
api_key=settings.openai_api_key or os.getenv("OPENAI_API_KEY"),
)
# Initialize VAD
logger.info("Loading VAD model")
vad = VoiceActivityDetector()
logger.info("✅ OpenClaw Voice server ready!")
@app.get("/")
async def index():
"""Serve the demo page."""
return FileResponse("src/client/index.html")
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""Handle voice WebSocket connections."""
await websocket.accept()
logger.info("Client connected")
audio_buffer = []
is_listening = False
try:
while True:
data = await websocket.receive_text()
msg = json.loads(data)
if msg["type"] == "start_listening":
is_listening = True
audio_buffer = []
await websocket.send_json({"type": "listening_started"})
logger.debug("Started listening")
elif msg["type"] == "stop_listening":
is_listening = False
if audio_buffer:
# Combine audio chunks
audio_data = np.concatenate(audio_buffer)
# Transcribe
logger.debug("Transcribing audio...")
transcript = await stt.transcribe(audio_data)
await websocket.send_json({
"type": "transcript",
"text": transcript,
"final": True,
})
logger.info(f"Transcript: {transcript}")
if transcript.strip():
# Get AI response
logger.debug("Getting AI response...")
response_text = await backend.chat(transcript)
await websocket.send_json({
"type": "response_text",
"text": response_text,
})
logger.info(f"Response: {response_text}")
# Generate speech
logger.debug("Generating speech...")
audio_response = await tts.synthesize(response_text)
# Send audio back
audio_b64 = base64.b64encode(audio_response.tobytes()).decode()
await websocket.send_json({
"type": "audio_response",
"data": audio_b64,
"sample_rate": 24000, # TTS output rate
"text": response_text,
})
audio_buffer = []
await websocket.send_json({"type": "listening_stopped"})
logger.debug("Stopped listening")
elif msg["type"] == "audio" and is_listening:
# Decode base64 audio
audio_bytes = base64.b64decode(msg["data"])
audio_np = np.frombuffer(audio_bytes, dtype=np.float32)
audio_buffer.append(audio_np)
elif msg["type"] == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
logger.info("Client disconnected")
except Exception as e:
logger.error(f"WebSocket error: {e}")
await websocket.close()
# Serve static files for client
client_dir = Path(__file__).parent.parent / "client"
if client_dir.exists():
app.mount("/static", StaticFiles(directory=str(client_dir)), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"src.server.main:app",
host=settings.host,
port=settings.port,
reload=True,
)
+109
View File
@@ -0,0 +1,109 @@
"""
Speech-to-Text module using Whisper.
"""
import asyncio
from typing import Optional
import numpy as np
from loguru import logger
class WhisperSTT:
"""Whisper-based Speech-to-Text."""
def __init__(
self,
model_name: str = "base",
device: str = "auto",
language: str = "en",
):
self.model_name = model_name
self.device = device
self.language = language
self.model = None
self._backend = "mock"
self._load_model()
def _load_model(self):
"""Load the Whisper model."""
# Try faster-whisper first
try:
from faster_whisper import WhisperModel
if self.device == "auto":
import torch
if torch.cuda.is_available():
self.device = "cuda"
compute_type = "float16"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
self.device = "cpu"
compute_type = "int8"
else:
self.device = "cpu"
compute_type = "int8"
elif self.device == "cuda":
compute_type = "float16"
else:
compute_type = "int8"
logger.info(f"Loading faster-whisper {self.model_name} on {self.device}")
self.model = WhisperModel(
self.model_name,
device=self.device if self.device != "mps" else "cpu",
compute_type=compute_type,
)
self._backend = "faster-whisper"
logger.info("✅ faster-whisper loaded")
return
except ImportError:
logger.warning("faster-whisper not available")
except Exception as e:
logger.warning(f"faster-whisper failed: {e}")
# Try openai-whisper
try:
import whisper
if self.device == "auto":
import torch
self.device = "cuda" if torch.cuda.is_available() else "cpu"
logger.info(f"Loading openai-whisper {self.model_name}")
self.model = whisper.load_model(self.model_name, device=self.device)
self._backend = "openai-whisper"
logger.info("✅ openai-whisper loaded")
return
except ImportError:
logger.warning("openai-whisper not available")
except Exception as e:
logger.warning(f"openai-whisper failed: {e}")
# Mock mode for testing
logger.warning("⚠️ No STT backend - using mock mode")
self._backend = "mock"
async def transcribe(self, audio: np.ndarray) -> str:
"""Transcribe audio to text."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._transcribe_sync, audio)
def _transcribe_sync(self, audio: np.ndarray) -> str:
"""Synchronous transcription."""
if self._backend == "faster-whisper":
segments, info = self.model.transcribe(
audio,
language=self.language,
beam_size=5,
vad_filter=True,
)
return " ".join(segment.text for segment in segments).strip()
elif self._backend == "openai-whisper":
result = self.model.transcribe(audio, language=self.language)
return result["text"].strip()
else:
# Mock mode - return placeholder
logger.debug(f"Mock STT: received {len(audio)} samples")
return "[Mock transcription - install whisper for real STT]"
+97
View File
@@ -0,0 +1,97 @@
"""
Text-to-Speech module using Chatterbox or fallbacks.
"""
import asyncio
from typing import Optional
from pathlib import Path
import numpy as np
from loguru import logger
class ChatterboxTTS:
"""Text-to-Speech using Chatterbox or fallbacks."""
def __init__(
self,
voice_sample: Optional[str] = None,
device: str = "auto",
):
self.voice_sample = voice_sample
self.device = device
self.model = None
self._backend = "mock"
self._load_model()
def _load_model(self):
"""Load the TTS model."""
# Try Chatterbox
try:
from chatterbox.tts import ChatterboxTTS as CBModel
logger.info("Loading Chatterbox TTS...")
self.model = CBModel.from_pretrained(device=self._get_device())
self._backend = "chatterbox"
logger.info("✅ Chatterbox loaded")
return
except ImportError:
logger.warning("Chatterbox not installed")
except Exception as e:
logger.warning(f"Chatterbox failed: {e}")
# Try XTTS
try:
from TTS.api import TTS
logger.info("Loading Coqui XTTS...")
self.model = TTS("tts_models/multilingual/multi-dataset/xtts_v2")
self._backend = "xtts"
logger.info("✅ XTTS loaded")
return
except ImportError:
logger.warning("Coqui TTS not installed")
except Exception as e:
logger.warning(f"XTTS failed: {e}")
# Mock mode
logger.warning("⚠️ No TTS backend - using mock mode (silence)")
self._backend = "mock"
def _get_device(self) -> str:
if self.device != "auto":
return self.device
try:
import torch
if torch.cuda.is_available():
return "cuda"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
except ImportError:
pass
return "cpu"
async def synthesize(self, text: str) -> np.ndarray:
"""Synthesize speech from text."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._synthesize_sync, text)
def _synthesize_sync(self, text: str) -> np.ndarray:
"""Synchronous synthesis."""
if self._backend == "chatterbox":
if self.voice_sample:
audio = self.model.generate(text, audio_prompt=self.voice_sample)
else:
audio = self.model.generate(text)
return audio.cpu().numpy().astype(np.float32)
elif self._backend == "xtts":
if self.voice_sample:
wav = self.model.tts(text=text, speaker_wav=self.voice_sample, language="en")
else:
wav = self.model.tts(text=text, language="en")
return np.array(wav, dtype=np.float32)
else:
# Mock mode - return short silence
logger.debug(f"Mock TTS: '{text[:50]}...'")
# 0.5 seconds of silence at 24kHz
return np.zeros(12000, dtype=np.float32)
+45
View File
@@ -0,0 +1,45 @@
"""
Voice Activity Detection module.
"""
from typing import Optional
import numpy as np
from loguru import logger
class VoiceActivityDetector:
"""Voice Activity Detection."""
def __init__(self, threshold: float = 0.5):
self.threshold = threshold
self.model = None
self._load_model()
def _load_model(self):
"""Load VAD model."""
try:
import torch
model, utils = torch.hub.load(
repo_or_dir='snakers4/silero-vad',
model='silero_vad',
force_reload=False,
)
self.model = model
self._get_speech_timestamps = utils[0]
logger.info("✅ Silero VAD loaded")
except Exception as e:
logger.warning(f"VAD not available: {e}")
self.model = None
def is_speech(self, audio: np.ndarray, sample_rate: int = 16000) -> bool:
"""Check if audio contains speech."""
if self.model is None:
return True # Assume speech if no VAD
try:
import torch
audio_tensor = torch.from_numpy(audio).float()
speech_prob = self.model(audio_tensor, sample_rate).item()
return speech_prob > self.threshold
except Exception as e:
logger.error(f"VAD error: {e}")
return True