#!/usr/bin/env python3 """Voice MCP Server — STT via Moonshine, TTS via KittenTTS. HTTP transport for Hermes MCP client. Tools: voice_stt(file_path, language) → transcription text voice_tts(text, voice, speed) → path to generated .wav voice_pipeline(audio_path) → full STT → TTS roundtrip """ import hashlib import os import tempfile from pathlib import Path from mcp.server.fastmcp import FastMCP app = FastMCP("voice") # ── Lazy model loading (RAM-friendly) ──────────────────── _stt = None _tts = None _model_dir = Path(os.environ.get("MODEL_DIR", "/models")) def get_stt(): global _stt if _stt is None: from moonshine_voice import Transcriber, ModelArch from moonshine_voice.download import get_model_for_language model_path, model_arch = get_model_for_language( "en", ModelArch.TINY, cache_root=str(_model_dir) ) _stt = Transcriber(model_path=model_path, model_arch=model_arch) return _stt def get_tts(): global _tts if _tts is None: from kittentts import KittenTTS _tts = KittenTTS( "KittenML/kitten-tts-nano-0.8-int8", cache_dir=str(_model_dir), ) return _tts # ── Tools ──────────────────────────────────────────────── @app.tool() async def voice_stt(file_path: str, language: str = "auto") -> str: """Transcribe speech audio to text using Moonshine. Args: file_path: Absolute path to audio file (.wav) language: Language code or 'auto' (default: auto) Returns: Transcribed text """ from moonshine_voice.utils import load_wav_file stt = get_stt() audio_data, sample_rate = load_wav_file(file_path) result = stt.transcribe_without_streaming(audio_data, sample_rate) return " ".join(l.text for l in result.lines) @app.tool() async def voice_tts( text: str, voice: str = "Jasper", speed: float = 1.0, ) -> str: """Convert text to speech using KittenTTS. Args: text: Text to speak voice: One of Bella, Jasper, Luna, Bruno, Rosie, Hugo, Kiki, Leo speed: Speech speed (0.5-2.0, default 1.0) Returns: Absolute path to generated .wav file """ tts = get_tts() # Stable filename based on content hash h = hashlib.sha256(f"{text}_{voice}_{speed}".encode()).hexdigest()[:12] out_path = os.path.join(tempfile.gettempdir(), f"tts_{h}.wav") tts.generate_to_file(text, out_path, voice=voice, speed=speed) return out_path @app.tool() async def voice_pipeline(audio_path: str, tts_voice: str = "Jasper") -> str: """Full voice pipeline: STT → echo back as TTS. Useful for testing the roundtrip. Args: audio_path: Path to input audio file tts_voice: Voice for TTS response Returns: JSON with transcribed text and path to response audio """ import json # STT from moonshine_voice.utils import load_wav_file stt = get_stt() audio_data, sample_rate = load_wav_file(audio_path) result = stt.transcribe_without_streaming(audio_data, sample_rate) text = " ".join(l.text for l in result.lines) # TTS tts = get_tts() h = hashlib.sha256(text.encode()).hexdigest()[:12] out_path = os.path.join(tempfile.gettempdir(), f"tts_{h}.wav") tts.generate_to_file(text, out_path, voice=tts_voice) return json.dumps({ "transcription": text, "response_audio": out_path, }) @app.tool() async def voice_available_voices() -> str: """List available TTS voices.""" tts = get_tts() return str(tts.available_voices) # ── Entry Point ────────────────────────────────────────── if __name__ == "__main__": import sys, uvicorn port = int(os.environ.get("PORT", "8000")) print(f"Voice MCP starting on port {port}...", file=sys.stderr) uvicorn.run(app.streamable_http_app(), host="0.0.0.0", port=port)