Files
agent-ui/server/whisper_server.py
2026-02-18 12:13:22 -06:00

295 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""
Whisper Server - GPU-accelerated speech-to-text using faster-whisper
WebSocket server that receives audio and returns transcriptions
"""
import asyncio
import json
import sys
import io
import wave
import tempfile
import os
import subprocess
from pathlib import Path
try:
import websockets
from faster_whisper import WhisperModel
except ImportError as e:
print(f"Missing dependency: {e}")
print("Run: pip install faster-whisper websockets")
sys.exit(1)
def convert_audio_to_wav(input_data: bytes, input_format: str = "webm") -> bytes:
"""
Convert audio data to WAV format using ffmpeg.
Uses stdin/stdout pipes so ffmpeg probes the actual data format
instead of relying on file extensions.
"""
try:
result = subprocess.run([
"ffmpeg", "-y",
"-i", "pipe:0", # Read from stdin (auto-detect format)
"-ar", "16000", # Sample rate 16kHz
"-ac", "1", # Mono
"-c:a", "pcm_s16le", # PCM 16-bit little-endian
"-f", "wav", # Output format
"pipe:1" # Write to stdout
], input=input_data, capture_output=True, timeout=30)
if result.returncode != 0:
print(f"[Whisper] ffmpeg error: {result.stderr.decode('utf-8', errors='replace')}")
return None
return result.stdout
except subprocess.TimeoutExpired:
print("[Whisper] ffmpeg conversion timed out")
return None
except FileNotFoundError:
print("[Whisper] ffmpeg not found - please install ffmpeg")
return None
except Exception as e:
print(f"[Whisper] Conversion error: {e}")
return None
# Configuration
HOST = "0.0.0.0" # Listen on all interfaces (needed for Traefik proxy)
PORT = 4104
MODEL_SIZE = "large-v3" # Best standard model for Spanish
DEVICE = "cuda" # cuda or cpu
COMPUTE_TYPE = "float16" # float16 for GPU, int8 for CPU
# Model display name (extract from path if needed)
MODEL_NAME = MODEL_SIZE.split("/")[-1] if "/" in MODEL_SIZE else MODEL_SIZE
# Spanish context prompt to improve accuracy (Honduras Spanish + tech context)
INITIAL_PROMPT = """Transcripción en español hondureño de un desarrollador de software.
Contexto: programación, TypeScript, Vue, Python, comandos de terminal, código.
Vocabulario técnico: servidor, frontend, backend, chunks, WebSocket, transcripción,
componente, función, variable, API, modelo, Whisper, Claude, MCP, configuración.
Expresiones hondureñas: vos, tenés, podés, mirá, pues, verdad, ajá, entonces.
Diminutivos comunes: ahorita, ratito, prontito, despuesito, chiquito, tantito, poquito."""
# Global model instance
model = None
model_loading = False
async def load_model():
"""Load Whisper model (lazy loading on first request)"""
global model, model_loading
if model is not None:
return model
if model_loading:
# Wait for model to finish loading
while model_loading:
await asyncio.sleep(0.1)
return model
model_loading = True
print(f"[Whisper] Loading model '{MODEL_NAME}' on {DEVICE}...")
try:
# Load model - this downloads on first run
model = WhisperModel(
MODEL_SIZE,
device=DEVICE,
compute_type=COMPUTE_TYPE,
download_root=str(Path.home() / ".cache" / "whisper")
)
print(f"[Whisper] Model loaded successfully!")
except Exception as e:
print(f"[Whisper] Error loading model: {e}")
print("[Whisper] Falling back to CPU...")
model = WhisperModel(
MODEL_SIZE,
device="cpu",
compute_type="int8",
download_root=str(Path.home() / ".cache" / "whisper")
)
model_loading = False
return model
def transcribe_audio(audio_data: bytes, language: str = "es", is_webm: bool = True) -> dict:
"""Transcribe audio data using Whisper"""
global model
if model is None:
return {"error": "Model not loaded"}
# Convert WebM to WAV if needed
if is_webm:
wav_data = convert_audio_to_wav(audio_data, "webm")
if wav_data is None:
return {"error": "Failed to convert audio format. Ensure ffmpeg is installed."}
else:
wav_data = audio_data
# Save audio to temp file (faster-whisper needs a file path)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
f.write(wav_data)
temp_path = f.name
try:
# Transcribe with optimized parameters
segments, info = model.transcribe(
temp_path,
language=language,
beam_size=5,
best_of=5, # Number of candidates when sampling
temperature=0.0, # Use greedy decoding (most accurate)
vad_filter=True, # Voice activity detection
vad_parameters=dict(
min_silence_duration_ms=300, # Shorter silence detection
speech_pad_ms=200, # Padding around speech
threshold=0.5 # VAD sensitivity (lower = more sensitive)
),
initial_prompt=INITIAL_PROMPT, # Context for better Spanish transcription
condition_on_previous_text=True, # Use context from previous segments
no_speech_threshold=0.6,
log_prob_threshold=-1.0,
compression_ratio_threshold=2.4,
word_timestamps=False # Faster without word-level timestamps
)
# Collect all segments
text = ""
segments_list = []
for segment in segments:
text += segment.text + " "
segments_list.append({
"start": segment.start,
"end": segment.end,
"text": segment.text
})
return {
"success": True,
"text": text.strip(),
"language": info.language,
"language_probability": info.language_probability,
"duration": info.duration,
"segments": segments_list,
"engine": "whisper-gpu",
"model": MODEL_NAME,
"device": DEVICE
}
except Exception as e:
print(f"[Whisper] Transcription error: {e}")
return {"error": str(e)}
finally:
# Cleanup temp file
try:
os.unlink(temp_path)
except:
pass
async def handle_client(websocket):
"""Handle WebSocket client connection"""
# Ensure model is loaded
await load_model()
# Send ready message
await websocket.send(json.dumps({
"type": "ready",
"model": MODEL_NAME,
"device": DEVICE
}))
try:
async for message in websocket:
if isinstance(message, bytes):
# Binary audio data (likely WebM format from browser)
# Transcribe in thread pool to not block
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: transcribe_audio(message, "es", is_webm=True)
)
await websocket.send(json.dumps({
"type": "transcription",
**result
}))
else:
# JSON command
try:
cmd = json.loads(message)
if cmd.get("type") == "transcribe":
# Audio data sent as base64 (WebM format from browser)
import base64
audio_data = base64.b64decode(cmd.get("audio", ""))
language = cmd.get("language", "es")
is_partial = cmd.get("partial", False)
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: transcribe_audio(audio_data, language, is_webm=True)
)
# Add partial flag to result
if is_partial:
result["partial"] = True
await websocket.send(json.dumps({
"type": "transcription",
**result
}))
elif cmd.get("type") == "ping":
await websocket.send(json.dumps({"type": "pong"}))
elif cmd.get("type") == "status":
await websocket.send(json.dumps({
"type": "status",
"model": MODEL_NAME,
"device": DEVICE,
"ready": model is not None
}))
except json.JSONDecodeError:
await websocket.send(json.dumps({
"type": "error",
"message": "Invalid JSON"
}))
except websockets.exceptions.ConnectionClosed:
pass
except Exception as e:
print(f"[Whisper] Error: {e}")
async def main():
"""Start WebSocket server"""
print(f"[Whisper] Model: {MODEL_NAME} | Device: {DEVICE} | Port: {PORT}")
# Pre-load model
await load_model()
async with websockets.serve(handle_client, HOST, PORT):
print(f"[Whisper] Ready")
await asyncio.Future() # Run forever
if __name__ == "__main__":
# Install websockets if needed
try:
import websockets
except ImportError:
import subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "websockets"])
import websockets
asyncio.run(main())