voiceswitch / test_server.py
Jaykay73's picture
Upload 7 files
4e181c9 verified
Raw
History Blame Contribute Delete
10.6 kB
#!/usr/bin/env python3
"""
VoiceSwitch Test Server
========================
Minimal HTTP API server for testing the KWS model from a browser.
Loads the trained model and serves predictions via REST endpoint.
Usage:
python test_server.py # Start on port 8080
python test_server.py --port 9000 # Custom port
python test_server.py --cors # Enable CORS for all origins
Endpoints:
POST /predict - Send audio, get prediction back
GET /health - Server health check
GET / - Serves the test UI page
"""
import sys
import json
import wave
import io
import argparse
from pathlib import Path
import numpy as np
# Ensure project root in path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import os
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
import tensorflow as tf
import yaml
from edge.inference import EdgeMFCC
# === Configuration ===
MODEL_PATH = "models/dscnn_checkpoint.keras" # Bilingual Yoruba+English model
CONFIG_PATH = "edge/config.yaml"
SAMPLE_RATE = 16000
WINDOW_SAMPLES = 16000 # 1 second
class VoiceSwitchPredictor:
"""Loads model and MFCC extractor, runs inference on audio."""
def __init__(self, model_path=MODEL_PATH, config_path=CONFIG_PATH):
print(f"Loading model: {model_path}")
self.model = tf.keras.models.load_model(model_path)
self.model.trainable = False
print(f" Params: {self.model.count_params():,}")
with open(config_path) as f:
self.config = yaml.safe_load(f)
self.labels = self.config["model"]["labels"]
self.mfcc = EdgeMFCC(self.config)
# Warm up with dummy data
dummy = np.random.randn(WINDOW_SAMPLES).astype(np.float32) * 0.001
self.predict(dummy)
print(" Model loaded and ready.")
def predict(self, audio):
"""
Run inference on raw audio samples.
Args:
audio: numpy array of float32 samples in [-1, 1], any length
Returns:
dict with label, confidence, and all probabilities
"""
# Convert to float32
audio = audio.astype(np.float32)
# Normalize if int16 range
if np.max(np.abs(audio)) > 1.5:
audio = audio / 32767.0
# Pad or trim to exactly 1 second
if len(audio) < WINDOW_SAMPLES:
audio = np.pad(audio, (0, WINDOW_SAMPLES - len(audio)))
elif len(audio) > WINDOW_SAMPLES:
# Take the center 1 second
start = (len(audio) - WINDOW_SAMPLES) // 2
audio = audio[start:start + WINDOW_SAMPLES]
# Extract MFCC features
features = self.mfcc.extract(audio)
features = np.expand_dims(features, axis=0)
# Run inference
logits = self.model(features, training=False)
probs = tf.nn.softmax(logits, axis=-1).numpy()[0]
class_id = int(np.argmax(probs))
confidence = float(probs[class_id])
return {
"class_id": class_id,
"label": self.labels[class_id] if class_id < len(self.labels) else "unknown",
"confidence": round(confidence, 4),
"probabilities": {
label: round(float(prob), 4)
for label, prob in zip(self.labels, probs)
},
"rms_energy": round(float(np.sqrt(np.mean(audio ** 2))), 6),
"duration_samples": len(audio),
}
def create_app(predictor):
"""Create a minimal WSGI app using Python's built-in http.server."""
from http.server import HTTPServer, BaseHTTPRequestHandler
UI_HTML = (Path(__file__).resolve().parent / "test_ui.html").read_text(encoding="utf-8")
class RequestHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
# Quieter logging
if "/predict" in str(args):
print(f" [API] {args[0]}")
def do_GET(self):
if self.path == "/" or self.path == "/index.html":
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(UI_HTML.encode("utf-8"))
elif self.path == "/health":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
resp = json.dumps({
"status": "ok",
"model": str(Path(MODEL_PATH).name),
"labels": predictor.labels,
"params": predictor.model.count_params(),
})
self.wfile.write(resp.encode("utf-8"))
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
if self.path == "/predict":
try:
content_length = int(self.headers.get("Content-Length", 0))
content_type = self.headers.get("Content-Type", "")
if "multipart/form-data" in content_type:
# Parse multipart form data with audio file
audio_data = self._parse_multipart(content_type, self.rfile.read(content_length))
else:
# Raw WAV bytes
audio_data = self.rfile.read(content_length)
audio_data = self._decode_wav(audio_data)
if audio_data is None or len(audio_data) < 100:
self._send_error(400, "Invalid audio data")
return
result = predictor.predict(audio_data)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(json.dumps(result).encode("utf-8"))
except Exception as e:
print(f" [ERROR] {e}")
self._send_error(500, str(e))
else:
self.send_response(404)
self.end_headers()
def do_OPTIONS(self):
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def _parse_multipart(self, content_type, body):
"""Simple multipart parser for audio file upload."""
# Find boundary
boundary = None
for part in content_type.split(";"):
part = part.strip()
if part.startswith("boundary="):
boundary = part.split("=", 1)[1].strip('"')
break
if not boundary:
return None
boundary_bytes = boundary.encode("utf-8")
parts = body.split(b"--" + boundary_bytes)
for part in parts:
if b"Content-Disposition" in part:
# Find the double CRLF separating headers from body
header_end = part.find(b"\r\n\r\n")
if header_end == -1:
continue
body_start = header_end + 4
raw_audio = part[body_start:]
# Strip trailing boundary
end = raw_audio.rfind(b"\r\n")
if end > 0:
raw_audio = raw_audio[:end]
return self._decode_wav(raw_audio)
return None
def _decode_wav(self, data):
"""Decode WAV bytes to numpy float32 array."""
try:
with wave.open(io.BytesIO(data)) as w:
n_frames = w.getnframes()
n_channels = w.getnchannels()
sample_width = w.getsampwidth()
framerate = w.getframerate()
raw = w.readframes(n_frames)
fmt = {1: "b", 2: "h", 4: "i"}[sample_width]
audio = np.frombuffer(raw, dtype=f"<i{sample_width}")
if n_channels > 1:
audio = audio.reshape(-1, n_channels)
audio = audio.mean(axis=1)
audio = audio.astype(np.float32) / (2 ** (8 * sample_width - 1))
# Resample if needed
if framerate != SAMPLE_RATE:
from scipy.signal import resample_poly
import math
g = math.gcd(framerate, SAMPLE_RATE)
audio = resample_poly(
audio.astype(np.float64),
SAMPLE_RATE // g,
framerate // g,
).astype(np.float32)
return audio
except Exception as e:
print(f" [WAV decode error] {e}")
return None
def _send_error(self, code, message):
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(json.dumps({"error": message}).encode("utf-8"))
return HTTPServer, RequestHandler
def main():
parser = argparse.ArgumentParser(description="VoiceSwitch Test Server")
parser.add_argument("--port", type=int, default=8080, help="Server port (default: 8080)")
parser.add_argument("--model", type=str, default=MODEL_PATH, help="Path to model file")
args = parser.parse_args()
print("=" * 60)
print("VoiceSwitch Test Server")
print("=" * 60)
predictor = VoiceSwitchPredictor(model_path=args.model)
HTTPServer, Handler = create_app(predictor)
server = HTTPServer(("0.0.0.0", args.port), Handler)
print(f"\n Server running at: http://localhost:{args.port}")
print(f" Open that URL in your browser to test the model.")
print(f" Press Ctrl+C to stop.\n")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down...")
server.shutdown()
if __name__ == "__main__":
main()