diff --git a/.gitignore b/.gitignore index 39d8896..2d48c76 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ graphify-out/ # Local dev session artifacts (make dev): the tailed diagnostics log .dev/ + +# Local-only env configs (endpoints/keys) — .env is generated from .env. +# by `make dev-fishaudio` / `make dev-opuslab`; never commit any of them. +.env* diff --git a/Makefile b/Makefile index 22faa94..885888c 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ # murmur dev workflow — one command to install, preflight, and run. # -# make dev # terminal 1: set up, check deps, launch the app (interactive) -# make logs # terminal 2: tail the dev log + memory while it runs +# make dev # terminal 1: set up, check deps, launch the app (local voice) +# make dev-fishaudio # remote voice via fish.audio (.env.fishaudio -> .env) +# make dev-opuslab # remote voice via self-hosted opuslab (.env.opuslab; WARP on) +# make logs # terminal 2: tail the dev log + memory while it runs # # Knobs: VOICE=spark|stub|qwen3|... (real TTS by default) # STUB=1 (full offline: canned brain, silent voice, @@ -23,12 +25,15 @@ else PREFLIGHT_ARGS := --voice $(VOICE) endif -.PHONY: help dev logs preflight setup-music install +.PHONY: help dev dev-remote dev-fishaudio dev-opuslab logs preflight setup-music install help: @echo "murmur dev:" - @echo " make dev install deps, preflight, then launch the app" + @echo " make dev install deps, preflight, then launch the app (local voice)" @echo " (diagnostics -> $(DEV_LOG))" + @echo " make dev-fishaudio remote voice via fish.audio (.env.fishaudio -> .env)" + @echo " make dev-opuslab remote voice via self-hosted opuslab (.env.opuslab; WARP on)" + @echo " make dev-remote remote voice from whatever .env currently holds" @echo " make logs tail the dev log + memory (run in a 2nd terminal)" @echo " INFO timeline by default; DEBUG=1 unmutes the firehose" @echo " (memory is also recorded to $(MEM_LOG) while dev runs)" @@ -44,6 +49,33 @@ install: preflight: @uv run python scripts/dev_preflight.py $(PREFLIGHT_ARGS) +dev-fishaudio: + @# Select the fish.audio backend: copy its config to .env, then run remote. + @test -f .env.fishaudio || { echo "missing .env.fishaudio (fish.audio config)"; exit 1; } + @cp .env.fishaudio .env + @$(MAKE) dev-remote + +dev-opuslab: + @# Select the self-hosted opuslab backend (keep WARP connected). + @test -f .env.opuslab || { echo "missing .env.opuslab (opuslab config)"; exit 1; } + @cp .env.opuslab .env + @$(MAKE) dev-remote + +dev-remote: + @# Load the gitignored .env (MURMUR_TTS_URL / _SEED / …) into the environment, + @# then run the normal dev flow forcing the off-machine HTTP backend. Keep WARP + @# connected — auth to the endpoint is via Cloudflare Access (spec 02 §3.6). + @if [ -f .env ]; then set -a; . ./.env; set +a; fi; \ + if [ -z "$$MURMUR_TTS_URL" ]; then \ + echo "make dev-remote: no TTS endpoint configured."; \ + echo "Create a gitignored .env in this dir with, e.g.:"; \ + echo " MURMUR_TTS_URL=https://fish-speech.example.com"; \ + echo " MURMUR_TTS_SEED=42 # optional: pin the voice"; \ + echo "then re-run 'make dev-remote' (keep WARP connected for Access)."; \ + exit 1; \ + fi; \ + $(MAKE) dev VOICE=remote + dev: install @uv run python scripts/dev_preflight.py $(PREFLIGHT_ARGS) || { \ echo ""; \ diff --git a/specs/spec02/02-voice-provider.md b/specs/spec02/02-voice-provider.md index 03a33af..e1b3a28 100644 --- a/specs/spec02/02-voice-provider.md +++ b/specs/spec02/02-voice-provider.md @@ -155,8 +155,23 @@ so it sits beside `SidecarVoiceProvider` on the **same seam** (`start` / - **Selection & config (no code edits per swap):** `voice_provider="remote"` (or `--voice remote`). Endpoint config comes from env so a URL/key is never hardcoded: `MURMUR_TTS_URL`, `MURMUR_TTS_REFERENCE_ID` (the server-side saved - voice), `MURMUR_TTS_API_KEY` (optional bearer). Switch back to local by setting + voice), `MURMUR_TTS_API_KEY` (optional bearer), `MURMUR_TTS_SEED` (optional), + `MURMUR_TTS_MODEL` (optional — sent as the HTTP `model` header to select a + hosted model, e.g. fish.audio `s2.1-pro-free`; empty → no header, which + self-hosted fish-speech ignores). Switch back to local by setting `voice_provider` to `spark`. +- **Switch from the command line (no env edit):** the same knobs have CLI + overrides that win over env — `--voice` (local `spark` ↔ `remote`), `--tts-url`, + `--tts-model`, `--tts-reference`. So moving between the local sidecar, the + self-hosted server, and a hosted API (fish.audio) is one command, e.g. + `--voice remote --tts-url https://api.fish.audio --tts-model s2.1-pro-free + --tts-reference `. The API key stays env-only (a secret does not belong on + the command line / in the process list). +- **Voice pinning:** fish-speech has no preset voice library — with neither a + `reference_id` nor a fixed `seed`, every `/v1/tts` call samples a fresh timbre + (the voice changes line to line). `MURMUR_TTS_SEED` pins the sampled voice so a + run keeps one consistent voice; a registered `reference_id` is the stronger, + cross-text-stable option once a reference is saved server-side. - **Wire protocol — fish-speech native `/v1/tts`** (the chosen server): `POST` a JSON body (`text`, `reference_id`, `format:"wav"`, `streaming:false`, `normalize:true`, and the sampling defaults) with `content-type: @@ -164,7 +179,11 @@ so it sits beside `SidecarVoiceProvider` on the **same seam** (`start` / body is the complete wav, written to a temp file → `AudioClip(kind="talk")`. The fish server accepts JSON as well as msgpack, so this needs **no extra serialization dependency** — stdlib `json` + `urllib` (the blocking call runs - in `asyncio.to_thread` to keep the seam async). + in `asyncio.to_thread` to keep the seam async). The request sends a **named + `User-Agent`**: a Cloudflare-fronted deployment blocks urllib's default + `Python-urllib/*` UA with a 403 bot rule. When the endpoint sits behind + Cloudflare Access, auth is the operator's concern (a WARP-enrolled host, or a + service token) — not baked into the adapter. - **Protocol is per-adapter, not universal:** this adapter speaks fish-speech's API. A different server (OpenAI-compatible `/v1/audio/speech`, etc.) is another small adapter on the same seam — the seam does not try to be one universal diff --git a/src/murmur/app.py b/src/murmur/app.py index a7eae51..2ef9d27 100644 --- a/src/murmur/app.py +++ b/src/murmur/app.py @@ -44,6 +44,8 @@ async def _run(config: Config, *, max_segments: int | None) -> None: tts_url=config.tts_url, tts_reference_id=config.tts_reference_id, tts_api_key=config.tts_api_key, + tts_seed=config.tts_seed, + tts_model=config.tts_model, ) player = build_engine(ffmpeg=config.ffmpeg_cmd) brain = build_brain(config.brain_provider, model=config.model) @@ -174,6 +176,28 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: "(off-machine HTTP TTS — set MURMUR_TTS_URL; spec 02 §3.6)." ), ) + # Remote-backend overrides (spec 02 §3.6): switch endpoint / hosted model / + # voice from the command line without editing the env, e.g. local -> fish.audio + # --voice remote --tts-url https://api.fish.audio --tts-model s2.1-pro-free + # Only consulted with --voice remote; the API key stays env-only (secret). + p.add_argument( + "--tts-url", + default=None, + metavar="URL", + help="remote TTS endpoint (overrides MURMUR_TTS_URL)", + ) + p.add_argument( + "--tts-model", + default=None, + metavar="NAME", + help="remote 'model' header, e.g. fish.audio 's2.1-pro-free' (overrides MURMUR_TTS_MODEL)", + ) + p.add_argument( + "--tts-reference", + default=None, + metavar="ID", + help="remote voice/reference id (overrides MURMUR_TTS_REFERENCE_ID)", + ) p.add_argument( "--no-music", action="store_true", @@ -196,12 +220,10 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: return p.parse_args(argv) -def main(argv: list[str] | None = None) -> None: - # Dev-only: if $MURMUR_DEV_LOG is set (make dev sets it), stream diagnostics - # to that file for `make logs` to tail. A no-op otherwise (shipping default). - configure_dev_logging() - args = _parse_args(argv) - config = Config.default() +def _apply_overrides(config: Config, args: argparse.Namespace) -> Config: + """Layer CLI flags over the (env-derived) Config — a flag left unset keeps the + config value. This is what lets you switch backend / hosted model / voice from + the command line (e.g. local -> fish.audio) without editing the env.""" if args.persona is not None: config = replace(config, persona_path=args.persona) if args.gap is not None: @@ -210,10 +232,25 @@ def main(argv: list[str] | None = None) -> None: config = replace(config, brain_provider=args.brain) if args.voice is not None: config = replace(config, voice_provider=args.voice) + if args.tts_url is not None: + config = replace(config, tts_url=args.tts_url) + if args.tts_model is not None: + config = replace(config, tts_model=args.tts_model) + if args.tts_reference is not None: + config = replace(config, tts_reference_id=args.tts_reference) if args.no_music: config = replace(config, music_enabled=False) if args.cadence is not None: config = replace(config, cadence_mode=args.cadence) + return config + + +def main(argv: list[str] | None = None) -> None: + # Dev-only: if $MURMUR_DEV_LOG is set (make dev sets it), stream diagnostics + # to that file for `make logs` to tail. A no-op otherwise (shipping default). + configure_dev_logging() + args = _parse_args(argv) + config = _apply_overrides(Config.default(), args) if args.setup_music: try: diff --git a/src/murmur/config.py b/src/murmur/config.py index f7c0563..0bee7fa 100644 --- a/src/murmur/config.py +++ b/src/murmur/config.py @@ -9,12 +9,30 @@ from __future__ import annotations import os +import sys from dataclasses import dataclass, field from pathlib import Path from .prompts import DEFAULT_PERSONA_PATH +def _env_seed() -> int | None: + """Parse ``MURMUR_TTS_SEED`` (empty/unset → None). A non-numeric value is a + misconfiguration, but it only matters to the remote voice — it must not abort + Config construction (and thus every voice, incl. spark/stub). So we warn and + ignore it, falling back to the documented unpinned-voice default.""" + raw = os.environ.get("MURMUR_TTS_SEED", "").strip() + if not raw: + return None + try: + return int(raw) + except ValueError: + print( + f"warning: ignoring non-numeric MURMUR_TTS_SEED={raw!r}", file=sys.stderr + ) + return None + + @dataclass(frozen=True) class Config: # --- persona ----------------------------------------------------------- @@ -73,6 +91,14 @@ class Config: tts_api_key: str = field( default_factory=lambda: os.environ.get("MURMUR_TTS_API_KEY", "") ) + # Fixed sampling seed for the remote voice. fish-speech has no preset voices, + # so without a reference each call samples a fresh timbre; a pinned seed keeps + # one stable voice across lines. Empty = unset (random per call). + tts_seed: int | None = field(default_factory=lambda: _env_seed()) + # HTTP `model` header for the remote backend — selects a hosted model (e.g. + # fish.audio "s2.1-pro-free"). Empty = no header (self-hosted fish-speech + # ignores it). + tts_model: str = field(default_factory=lambda: os.environ.get("MURMUR_TTS_MODEL", "")) @classmethod def default(cls) -> "Config": diff --git a/src/murmur/voice/__init__.py b/src/murmur/voice/__init__.py index e5946c1..9e34270 100644 --- a/src/murmur/voice/__init__.py +++ b/src/murmur/voice/__init__.py @@ -31,6 +31,8 @@ def build_voice( tts_url: str = "", tts_reference_id: str = "", tts_api_key: str = "", + tts_seed: int | None = None, + tts_model: str = "", ) -> VoiceProvider: """Construct the configured VoiceProvider adapter (spec 02 §3.5 hot-swap). @@ -51,6 +53,8 @@ def build_voice( tts_url, reference_id=tts_reference_id or None, api_key=tts_api_key or None, + seed=tts_seed, + model=tts_model or None, ) if name in PROFILES: # spark / qwen3 / chatterbox / dia / voxcpm2 return SidecarVoiceProvider(name) diff --git a/src/murmur/voice/remote.py b/src/murmur/voice/remote.py index b3942a1..41df5d7 100644 --- a/src/murmur/voice/remote.py +++ b/src/murmur/voice/remote.py @@ -28,12 +28,19 @@ _log = get_log("voice") _SYNTH_TIMEOUT = 120.0 +# A named UA: a Cloudflare-fronted fish-speech endpoint blocks urllib's default +# "Python-urllib/*" UA with a 403 bot rule; any non-bot UA passes. +_USER_AGENT = "murmur" -def build_tts_payload(text: str, *, reference_id: str | None) -> dict[str, object]: +def build_tts_payload( + text: str, *, reference_id: str | None, seed: int | None = None +) -> dict[str, object]: """The fish-speech ``/v1/tts`` request body (§3.6): a whole-clip, normalized wav. ``reference_id`` picks the server-side saved voice; omitted → the server - default. Sampling defaults mirror fish-speech's own client.""" + default. ``seed`` pins the sampled timbre (fish-speech has no preset voices, + so without it each call is a new voice). Sampling defaults mirror fish-speech's + own client.""" payload: dict[str, object] = { "text": text, "format": "wav", @@ -47,6 +54,8 @@ def build_tts_payload(text: str, *, reference_id: str | None) -> dict[str, objec } if reference_id: payload["reference_id"] = reference_id + if seed is not None: + payload["seed"] = seed return payload @@ -59,11 +68,17 @@ def __init__( *, reference_id: str | None = None, api_key: str | None = None, + seed: int | None = None, + model: str | None = None, timeout: float = _SYNTH_TIMEOUT, ) -> None: - self._url = base_url.rstrip("/") + "/v1/tts" + # strip() first: a .env value with trailing whitespace / CRLF would + # otherwise survive into the host and corrupt the URL (rstrip('/') leaves \r). + self._url = base_url.strip().rstrip("/") + "/v1/tts" self._reference_id = reference_id or None self._api_key = api_key or None + self._seed = seed + self._model = model or None self._timeout = timeout self._dir = Path(tempfile.mkdtemp(prefix="murmur-remote-")) self._counter = 0 @@ -77,7 +92,9 @@ async def start(self) -> None: _log.event("voice.remote", url=self._url) async def synthesize(self, text: str, *, scenario: str = "broadcast") -> AudioClip: - payload = build_tts_payload(text, reference_id=self._reference_id) + payload = build_tts_payload( + text, reference_id=self._reference_id, seed=self._seed + ) start = time.monotonic() audio = await asyncio.to_thread(self._post, payload) gen_s = time.monotonic() - start @@ -95,9 +112,11 @@ async def aclose(self) -> None: def _post(self, payload: dict[str, object]) -> bytes: body = json.dumps(payload).encode("utf-8") - headers = {"content-type": "application/json"} + headers = {"content-type": "application/json", "user-agent": _USER_AGENT} if self._api_key: headers["authorization"] = f"Bearer {self._api_key}" + if self._model: # e.g. fish.audio 's2.1-pro-free'; self-hosted omits it + headers["model"] = self._model req = urllib.request.Request( # noqa: S310 - fixed http(s) TTS endpoint self._url, data=body, headers=headers, method="POST" ) diff --git a/tests/test_config_and_factories.py b/tests/test_config_and_factories.py index 456ea59..eb1f162 100644 --- a/tests/test_config_and_factories.py +++ b/tests/test_config_and_factories.py @@ -4,7 +4,7 @@ import pytest -from murmur.app import _parse_args +from murmur.app import _apply_overrides, _parse_args from murmur.brain import ClaudeBrain, StubBrain, build_brain from murmur.config import Config from murmur.voice import build_voice @@ -53,3 +53,32 @@ def test_cli_voice_flag_accepts_every_registered_voice(): # build_voice() accepts it. Every registry name + "stub" must parse. for name in ("stub", *PROFILES): assert _parse_args(["--voice", name]).voice == name + + +def test_cli_tts_flags_override_config(): + # --tts-* let you switch the remote endpoint / model / voice from the command + # line (e.g. local -> fish.audio s2.1-pro-free) without editing .env; CLI wins. + args = _parse_args( + [ + "--voice", + "remote", + "--tts-url", + "https://api.fish.audio", + "--tts-model", + "s2.1-pro-free", + "--tts-reference", + "ref123", + ] + ) + cfg = _apply_overrides(Config.default(), args) + assert cfg.voice_provider == "remote" + assert cfg.tts_url == "https://api.fish.audio" + assert cfg.tts_model == "s2.1-pro-free" + assert cfg.tts_reference_id == "ref123" + + +def test_cli_tts_flags_absent_leave_config_untouched(monkeypatch): + # No --tts-* flags -> config keeps its env-derived values (no clobber to ""). + monkeypatch.setenv("MURMUR_TTS_URL", "http://box:8080") + cfg = _apply_overrides(Config.default(), _parse_args([])) + assert cfg.tts_url == "http://box:8080" diff --git a/tests/test_voice_remote.py b/tests/test_voice_remote.py index 700adee..04f4778 100644 --- a/tests/test_voice_remote.py +++ b/tests/test_voice_remote.py @@ -48,6 +48,12 @@ def test_payload_omits_reference_id_when_absent(): assert "reference_id" not in build_tts_payload("hi", reference_id=None) +def test_payload_carries_seed_only_when_set(): + # seed pins the timbre (fish-speech has no presets); omitted -> random voice. + assert build_tts_payload("hi", reference_id=None, seed=42)["seed"] == 42 + assert "seed" not in build_tts_payload("hi", reference_id=None) + + # --- synthesize (stubbed transport, no network) --------------------------- # @@ -80,6 +86,57 @@ def test_url_gets_the_v1_tts_path(): assert RemoteVoiceProvider("http://box:8080/")._url == "http://box:8080/v1/tts" +def test_url_strips_surrounding_whitespace_and_crlf(): + # A .env value edited on Windows carries a trailing \r that must not reach + # the host (rstrip('/') would leave it, corrupting the URL). + assert RemoteVoiceProvider("http://box:8080/\r\n")._url == "http://box:8080/v1/tts" + + +def _post_headers(monkeypatch, prov: RemoteVoiceProvider) -> dict[str, str]: + """Capture the outgoing Request headers of one _post call — no network. + urllib title-cases header keys (``model`` -> ``Model``).""" + seen: dict[str, dict[str, str]] = {} + + class _Resp: + def read(self) -> bytes: + return b"wav" + + def __enter__(self): + return self + + def __exit__(self, *a) -> None: + return None + + def fake_urlopen(req, timeout): # noqa: ANN001 + seen["headers"] = dict(req.header_items()) + return _Resp() + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + prov._post({"text": "hi"}) + return seen["headers"] + + +def test_post_sends_a_named_user_agent(monkeypatch): + # A Cloudflare-fronted server 403s urllib's default "Python-urllib/*" UA; + # _post must send a named one. + h = _post_headers(monkeypatch, RemoteVoiceProvider("http://box:8080")) + assert "urllib" not in h["User-agent"].lower() + + +def test_post_sends_model_header_when_set(monkeypatch): + # The 'model' header selects a hosted model (e.g. fish.audio s2.1-pro-free). + h = _post_headers( + monkeypatch, RemoteVoiceProvider("http://box:8080", model="s2.1-pro-free") + ) + assert h["Model"] == "s2.1-pro-free" + + +def test_post_omits_model_header_when_unset(monkeypatch): + # Self-hosted fish-speech takes no model header — don't send an empty one. + h = _post_headers(monkeypatch, RemoteVoiceProvider("http://box:8080")) + assert "Model" not in h + + # --- build_voice wiring (config switch, no code edit) --------------------- # @@ -99,6 +156,32 @@ def test_build_voice_remote_without_url_raises(): def test_config_reads_tts_env(monkeypatch): monkeypatch.setenv("MURMUR_TTS_URL", "http://box:8080") monkeypatch.setenv("MURMUR_TTS_REFERENCE_ID", "spk1") + monkeypatch.setenv("MURMUR_TTS_SEED", "42") + monkeypatch.setenv("MURMUR_TTS_MODEL", "s2.1-pro-free") c = Config.default() assert c.tts_url == "http://box:8080" assert c.tts_reference_id == "spk1" + assert c.tts_seed == 42 + assert c.tts_model == "s2.1-pro-free" + + +def test_config_seed_unset_is_none(monkeypatch): + monkeypatch.delenv("MURMUR_TTS_SEED", raising=False) + assert Config.default().tts_seed is None + + +def test_config_bad_seed_is_ignored_not_fatal(monkeypatch): + # A non-numeric seed must not abort Config() for every voice (incl. spark) — + # it degrades to the unpinned default, not a startup crash. + monkeypatch.setenv("MURMUR_TTS_SEED", "not-a-number") + assert Config.default().tts_seed is None + + +def test_build_voice_threads_seed_to_provider(): + v = build_voice("remote", tts_url="http://box:8080", tts_seed=42) + assert isinstance(v, RemoteVoiceProvider) and v._seed == 42 + + +def test_build_voice_threads_model_to_provider(): + v = build_voice("remote", tts_url="http://box:8080", tts_model="s2.1-pro-free") + assert isinstance(v, RemoteVoiceProvider) and v._model == "s2.1-pro-free"