diff --git a/.env.example b/.env.example index 4a1ebd4..d313126 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index d8ab507..af84b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Models (large binary files) models/ +piper-data/ *.onnx *.bin diff --git a/Dockerfile b/Dockerfile index daa285d..713ccab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Makefile b/Makefile index 3f530ee..07478df 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 5a680ea..b673741 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: {} diff --git a/src/mcspeak/audio.py b/src/mcspeak/audio.py index 3a1356a..4b240b1 100644 --- a/src/mcspeak/audio.py +++ b/src/mcspeak/audio.py @@ -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.""" diff --git a/src/mcspeak/engines/piper.py b/src/mcspeak/engines/piper.py index dffcca5..a8deb6a 100644 --- a/src/mcspeak/engines/piper.py +++ b/src/mcspeak/engines/piper.py @@ -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 diff --git a/src/mcspeak/server.py b/src/mcspeak/server.py index 65bc17e..0351f3b 100644 --- a/src/mcspeak/server.py +++ b/src/mcspeak/server.py @@ -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, } diff --git a/src/mcspeak/settings.py b/src/mcspeak/settings.py index 7cb1861..177b1b8 100644 --- a/src/mcspeak/settings.py +++ b/src/mcspeak/settings.py @@ -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")