From 30ed0925369806121b534e44150fa1ead845e318 Mon Sep 17 00:00:00 2001 From: gianni-dalerta Date: Fri, 30 Jan 2026 13:25:12 -0500 Subject: [PATCH] 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. --- .env.example | 20 +++ .gitignore | 13 ++ README.md | 191 ++++++++++++++++++++ requirements.txt | 42 +++++ src/client/index.html | 397 +++++++++++++++++++++++++++++++++++++++++ src/server/__init__.py | 1 + src/server/backend.py | 104 +++++++++++ src/server/main.py | 208 +++++++++++++++++++++ src/server/stt.py | 109 +++++++++++ src/server/tts.py | 97 ++++++++++ src/server/vad.py | 45 +++++ 11 files changed, 1227 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 requirements.txt create mode 100644 src/client/index.html create mode 100644 src/server/__init__.py create mode 100644 src/server/backend.py create mode 100644 src/server/main.py create mode 100644 src/server/stt.py create mode 100644 src/server/tts.py create mode 100644 src/server/vad.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7d3c935 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2088a8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +.env +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.ruff_cache/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..320141a --- /dev/null +++ b/README.md @@ -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 + + +``` + +Or use React: + +```jsx +import { VoiceWidget } from '@openclaw/voice-widget-react'; + + +``` + +## 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)** diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4184aba --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/src/client/index.html b/src/client/index.html new file mode 100644 index 0000000..018bfc5 --- /dev/null +++ b/src/client/index.html @@ -0,0 +1,397 @@ + + + + + + OpenClaw Voice + + + +
+

🎙️ OpenClaw Voice

+

Self-hosted voice AI interface

+ + + +

Ready

+ +
+

Conversation

+
+
+ +
+ +
+ +

+ +
+

Instructions: Hold the button and speak. Release to send.

+

Press Space to toggle recording.

+
+
+ + + + diff --git a/src/server/__init__.py b/src/server/__init__.py new file mode 100644 index 0000000..fd11a0c --- /dev/null +++ b/src/server/__init__.py @@ -0,0 +1 @@ +# OpenClaw Voice Server diff --git a/src/server/backend.py b/src/server/backend.py new file mode 100644 index 0000000..6b2617b --- /dev/null +++ b/src/server/backend.py @@ -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 = [] diff --git a/src/server/main.py b/src/server/main.py new file mode 100644 index 0000000..f312a56 --- /dev/null +++ b/src/server/main.py @@ -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, + ) diff --git a/src/server/stt.py b/src/server/stt.py new file mode 100644 index 0000000..695fb26 --- /dev/null +++ b/src/server/stt.py @@ -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]" diff --git a/src/server/tts.py b/src/server/tts.py new file mode 100644 index 0000000..2ab70bb --- /dev/null +++ b/src/server/tts.py @@ -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) diff --git a/src/server/vad.py b/src/server/vad.py new file mode 100644 index 0000000..cc5a726 --- /dev/null +++ b/src/server/vad.py @@ -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