Add generate_audio file/format params; bring Piper TTS up with es_MX default

generate_audio gains output_path + format params (wav, mp3, ogg, flac,
m4a). Non-wav formats route through ffmpeg (added to the Dockerfile).
Files land under /output/ inside the container, bind-mounted from the
host's TTS_OUTPUT_HOST_DIR (defaults to ~/mcspeak-out). Subdirectories
are auto-created. Path validation rejects writes outside /output/ —
both absolute escapes like /etc/passwd and traversal forms like
../../etc/passwd are canonicalized and checked against the mount root.
Extension auto-corrects to match the requested format.

piper-tts service added to compose using rhasspy/wyoming-piper. Default
voice es_MX-ald-medium is pre-warmed at container start and persists in
./piper-data/ across recreates (~60 MB download on first run). Port
10200 published to localhost so host-side scripts can also reach
Wyoming directly; mcspeak inside the stack uses container DNS. Voice
configurable via TTS_PIPER_VOICE — the same env var threads through to
both wyoming-piper's --voice flag and mcspeak's settings.piper_voice,
so they stay in sync.

PiperEngine.__init__ accepts a default_voice override; settings.piper_voice
threads it from the env. list_engines now reports the configured default
rather than the hardcoded en_US-lessac-medium. speak() / generate_audio()
calls with engine=piper and no explicit voice use the configured one.

mcspeak-internal network changed from internal:true to default-bridge.
internal:true was overkill — the goal was per-stack DNS scoping (which
compose provides automatically via the project-prefixed network name),
not internet isolation. The latter broke piper-tts's HuggingFace voice
download with "Name resolution failure" on first start.

.gitignore: add piper-data/ so the downloaded voice models stay out of
the repo.
This commit is contained in:
Ryan Malloy 2026-06-13 17:35:36 -06:00
parent ec4e1b6093
commit 27b4c4ae29
9 changed files with 185 additions and 10 deletions

View File

@ -1,6 +1,15 @@
# Compose project namespace -- prevents collisions with other stacks
COMPOSE_PROJECT=mcspeak
# Host directory for generate_audio output (output_path / format params).
# Mounted as /output inside the container. Defaults to ~/mcspeak-out when unset.
# TTS_OUTPUT_HOST_DIR=/home/you/audio-out
# Default Piper voice to pre-warm at piper-tts container start. Other voices
# requested via the speak() voice= param are downloaded on demand. Full list:
# https://github.com/rhasspy/piper/blob/master/VOICES.md
# TTS_PIPER_VOICE=es_MX-ald-medium
# Path to Orpheus GGUF model file (required by llama-server service)
# Tip: if you've already pulled it via Ollama, find the blob with:
# ollama show --modelfile orpheus | grep FROM

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
# Models (large binary files)
models/
piper-data/
*.onnx
*.bin

View File

@ -2,9 +2,11 @@ FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
# PipeWire client tools — pw-play connects to the host's PipeWire socket
# pulseaudio-utils provides pactl for media ducking (volume control via PulseAudio compat)
# ffmpeg handles WAV→{mp3,ogg,flac,m4a} conversion for generate_audio's format param
RUN apt-get update && apt-get install -y --no-install-recommends \
pipewire-bin \
pulseaudio-utils \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app

View File

@ -9,11 +9,13 @@ build:
docker compose build
up: build
@mkdir -p $${TTS_OUTPUT_HOST_DIR:-$$HOME/mcspeak-out}
docker compose up -d
@sleep 2
docker compose logs --tail 20
up-with-orpheus: build
@mkdir -p $${TTS_OUTPUT_HOST_DIR:-$$HOME/mcspeak-out}
docker compose --profile with-orpheus up -d
@sleep 2
docker compose logs --tail 20

View File

@ -33,6 +33,9 @@ services:
- /run/user/1000/pipewire-0:/run/user/1000/pipewire-0
# PulseAudio compat socket for media ducking (volume control)
- /run/user/1000/pulse:/run/user/1000/pulse
# Host-visible output dir for generate_audio's output_path/format params.
# Override TTS_OUTPUT_HOST_DIR in .env to use a different host directory.
- ${TTS_OUTPUT_HOST_DIR:-${HOME}/mcspeak-out}:/output
depends_on:
llama-server:
condition: service_healthy
@ -46,6 +49,27 @@ services:
caddy: mctalkbox.l.supported.systems
caddy.reverse_proxy: "{{upstreams 8371}}"
piper-tts:
# Default-on. Wyoming protocol TTS, ~500 MB image + ~60 MB voice download
# on first run (cached in ./piper-data thereafter). Voice is configurable
# via TTS_PIPER_VOICE; the wyoming-piper server will auto-download any
# voice mcspeak requests at speak() time even if it's not the default.
image: rhasspy/wyoming-piper:latest
container_name: piper-tts
restart: unless-stopped
command: --voice ${TTS_PIPER_VOICE:-es_MX-ald-medium}
# Published to localhost so host-side scripts can hit Wyoming directly
# at 127.0.0.1:10200 if needed. mcspeak inside the stack reaches it via
# the container DNS name (TTS_PIPER_HOST=piper-tts above).
ports:
- "127.0.0.1:10200:10200"
volumes:
# Host-visible voice cache so models survive container recreates and
# are inspectable from the host (~/claude/mctalkbox/piper-data/).
- ./piper-data:/data
networks:
- mcspeak-internal
llama-server:
# Opt-in: only starts when `docker compose --profile with-orpheus up`.
# Default `make up` runs kokoro-only without GPU dependencies.
@ -90,7 +114,9 @@ volumes:
networks:
caddy:
external: true
# Private per-stack network for mcspeak ↔ llama-server. Auto-created by
# compose, isolated from other stacks (no DNS leak via shared caddy).
mcspeak-internal:
internal: true
# Private per-stack network for mcspeak ↔ piper-tts ↔ llama-server.
# Auto-created by compose, scoped to this project so other stacks can't
# resolve our service names. NOT marked internal:true because piper-tts
# needs HuggingFace access to download voice models on first run; the
# per-project scoping is what we actually wanted, not internet isolation.
mcspeak-internal: {}

View File

@ -77,6 +77,57 @@ def wav_duration(path: Path) -> float:
return wf.getnframes() / wf.getframerate()
# Supported output formats for convert_audio. WAV is a passthrough copy;
# everything else routes through ffmpeg with format-appropriate codec args.
SUPPORTED_FORMATS = ("wav", "mp3", "ogg", "flac", "m4a")
_FFMPEG_CODEC_ARGS: dict[str, list[str]] = {
"mp3": ["-codec:a", "libmp3lame", "-qscale:a", "2"], # VBR ~190 kbps
"ogg": ["-codec:a", "libvorbis", "-qscale:a", "5"], # VBR ~160 kbps
"flac": ["-codec:a", "flac"], # lossless
"m4a": ["-codec:a", "aac", "-b:a", "192k"], # CBR 192 kbps AAC
}
class ConversionError(RuntimeError):
"""Raised when format conversion fails."""
async def convert_audio(src_wav: Path, dest: Path, fmt: str) -> Path:
"""Convert a source WAV to the requested format at dest.
`fmt == "wav"` shells out to a plain copy (no ffmpeg invocation).
Other formats use ffmpeg with codec args from _FFMPEG_CODEC_ARGS.
Raises ConversionError on ffmpeg non-zero exit or unsupported format.
"""
if fmt not in SUPPORTED_FORMATS:
raise ConversionError(
f"Unsupported format {fmt!r}. Supported: {list(SUPPORTED_FORMATS)}"
)
dest.parent.mkdir(parents=True, exist_ok=True)
if fmt == "wav":
shutil.copyfile(src_wav, dest)
return dest
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-y", "-loglevel", "error", "-i", str(src_wav),
*_FFMPEG_CODEC_ARGS[fmt],
str(dest),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise ConversionError(
f"ffmpeg failed ({proc.returncode}) converting to {fmt}: "
f"{stderr.decode(errors='replace').strip()[:500]}"
)
return dest
class PlaybackError(RuntimeError):
"""Raised when audio playback fails or times out."""

View File

@ -18,9 +18,14 @@ class PiperEngine(TTSEngine):
name = "piper"
default_voice = "en_US-lessac-medium"
def __init__(self, host: str, port: int) -> None:
def __init__(self, host: str, port: int, default_voice: str | None = None) -> None:
self._host = host
self._port = port
# Override the class-level default so list_engines reports the
# configured voice (synthesize() uses self.default_voice as the
# voice= fallback, see below).
if default_voice:
self.default_voice = default_voice
async def synthesize(self, text: str, voice: str | None = None) -> TTSResult:
voice = voice or self.default_voice

View File

@ -12,7 +12,7 @@ from fastmcp import Context, FastMCP
from fastmcp.server.dependencies import CurrentContext
from mcp.types import ToolAnnotations
from .audio import play_audio
from .audio import ConversionError, SUPPORTED_FORMATS, convert_audio, play_audio
from .engines.base import TTSEngine
from .engines.kokoro import KokoroEngine
from .engines.orpheus import OrpheusEngine
@ -24,6 +24,48 @@ from .tones import generate_tones, resolve_tone
from .voice_identity import VoiceIdentityCache, get_project_name, resolve_voice
ENGINE_NAMES = Literal["piper", "kokoro", "orpheus"]
FORMAT_NAMES = Literal["wav", "mp3", "ogg", "flac", "m4a"]
# Container-side bind mount for host-visible saved audio.
# Host path is configured via TTS_OUTPUT_HOST_DIR (default ~/mcspeak-out).
OUTPUT_DIR_CONTAINER = Path("/output")
def _resolve_output_path(output_path: str | None, fmt: str) -> Path:
"""Resolve a user-supplied output_path to an absolute path under /output/.
None auto-named under /output/.
Relative joined under /output/.
Absolute must canonicalize under /output/ (rejects writes elsewhere
and path-traversal escapes via "..").
Extension auto-corrected to match `fmt` if mismatched.
"""
base = OUTPUT_DIR_CONTAINER
if output_path is None:
ts = time.strftime("%Y%m%d-%H%M%S")
return base / f"mcspeak-{ts}.{fmt}"
p = Path(output_path)
full = p if p.is_absolute() else base / p
# Canonicalize without requiring existence (resolve(strict=False) is default).
# Anchor against base.resolve() to also reject e.g. "/output/../etc/passwd".
base_resolved = base.resolve()
full_resolved = full.resolve()
try:
full_resolved.relative_to(base_resolved)
except ValueError:
raise ValueError(
f"output_path must resolve under {base}/ (got {output_path!r}). "
f"Inside the container, {base}/ is bind-mounted from the host's "
f"TTS_OUTPUT_HOST_DIR. Use a relative path or one starting with {base}/."
)
expected_ext = f".{fmt}"
if full_resolved.suffix.lower() != expected_ext:
full_resolved = full_resolved.with_suffix(expected_ext)
return full_resolved
# ---------------------------------------------------------------------------
@ -49,7 +91,10 @@ async def app_lifespan(server: FastMCP):
# --- Build engines ---
engines: dict[str, TTSEngine] = {
"piper": PiperEngine(settings.piper_host, settings.piper_port),
"piper": PiperEngine(
settings.piper_host, settings.piper_port,
default_voice=settings.piper_voice,
),
"kokoro": KokoroEngine(kokoro_model),
"orpheus": OrpheusEngine(settings.orpheus_url),
}
@ -574,18 +619,30 @@ async def generate_audio(
engine: ENGINE_NAMES = "kokoro",
voice: str | None = None,
project: str | None = None,
output_path: str | None = None,
format: FORMAT_NAMES = "wav",
ctx: Context = CurrentContext(),
) -> dict:
"""Synthesize text to a WAV file without playing it.
"""Synthesize text to an audio file without playing it.
Bypasses the speech queue multiple agents can generate simultaneously.
Returns the file path and metadata.
Default behavior (no output_path, format=wav) writes a WAV to the
container's /tmp/mcspeak/ — fast and stable for in-container reads, but
NOT visible from the host. For host-visible output, either pass an
output_path under /output/ or pick a non-wav format; both paths route the
file to /output/ which is bind-mounted from TTS_OUTPUT_HOST_DIR (default
~/mcspeak-out on the host).
Args:
text: Text to synthesize.
engine: TTS engine to use.
voice: Voice name (use list_voices to see options). None = auto-assigned by project.
project: Project name for voice identity (auto-detected from MCP roots if omitted).
output_path: Where to write the file. None = today's behavior for wav, auto-named
under /output/ for non-wav formats. Relative paths join /output/; absolute
paths must resolve under /output/. Extension auto-corrected to match format.
format: Output format wav, mp3, ogg, flac, or m4a. Non-wav formats use ffmpeg.
"""
engines, _, voice_cache, _, _ = _get_state(ctx)
@ -600,12 +657,30 @@ async def generate_audio(
await ctx.info(f"Generating audio with {engine}...")
result = await eng.synthesize(text, voice)
# Custom path or non-wav format → convert + write to /output/. Otherwise
# keep the synthesized WAV at its in-container path (backward compat).
if output_path is not None or format != "wav":
try:
dest = _resolve_output_path(output_path, format)
await ctx.info(f"Writing {format} to {dest}")
await convert_audio(result.audio_path, dest, format)
except (ValueError, ConversionError) as e:
return {
"error": str(e),
"engine": result.engine,
"voice": result.voice,
}
final_path = dest
else:
final_path = result.audio_path
return {
"file": str(result.audio_path),
"file": str(final_path),
"duration_seconds": result.duration_seconds,
"sample_rate": result.sample_rate,
"engine": result.engine,
"voice": result.voice,
"format": format,
}

View File

@ -15,6 +15,10 @@ class Settings(BaseSettings):
# Piper (Wyoming protocol)
piper_host: str = "172.26.0.3"
piper_port: int = 10200
# Default voice used when speak()/generate_audio() picks engine=piper
# without an explicit voice. Mirrors the --voice flag passed to the
# wyoming-piper container in compose so the pre-warmed model matches.
piper_voice: str = "es_MX-ald-medium"
# Kokoro (ONNX)
kokoro_model: Path = Path("models/kokoro/kokoro-v1.0.onnx")