From a95500ef675252f539039017f23a8074c01d3fb3 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 7 Jun 2026 13:10:51 +0200 Subject: [PATCH] Voice MCP Server: STT (Moonshine Tiny) + TTS (KittenTTS Nano) --- Dockerfile | 25 +++++++++ voice_server.py | 138 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 Dockerfile create mode 100644 voice_server.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a81c174 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libsndfile1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install CPU-only torch first to prevent CUDA download +RUN pip install --no-cache-dir \ + --extra-index-url https://download.pytorch.org/whl/cpu \ + torch==2.6.0 \ + && pip install --no-cache-dir mcp httpx + +# Install Moonshine (STT) +RUN pip install --no-cache-dir moonshine-voice + +# Install KittenTTS (TTS) — CPU-only +RUN pip install --no-cache-dir \ + https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl + +COPY voice_server.py . + +EXPOSE 8000 +CMD ["python", "voice_server.py"] diff --git a/voice_server.py b/voice_server.py new file mode 100644 index 0000000..bde82f2 --- /dev/null +++ b/voice_server.py @@ -0,0 +1,138 @@ +#!/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)