Voice MCP Server: STT (Moonshine Tiny) + TTS (KittenTTS Nano)

This commit is contained in:
Hermes
2026-06-07 13:10:51 +02:00
commit a95500ef67
2 changed files with 163 additions and 0 deletions
+25
View File
@@ -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"]
+138
View File
@@ -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)