From c3ec61b3b48b482a40553c906db2d80c543064c5 Mon Sep 17 00:00:00 2001 From: Zach Guo Date: Fri, 10 Jul 2026 14:47:02 +0800 Subject: [PATCH 1/5] feat(voice): pin remote voice + reach Cloudflare fish-speech [spec 02] Make the remote fish-speech backend usable against the hosted, Cloudflare- fronted endpoint and give it a stable voice: - Named User-Agent: urllib's default "Python-urllib/*" UA is 403'd by the server's bot rule; send "murmur" instead. - MURMUR_TTS_SEED pins the sampled timbre (fish-speech has no preset voices, so each /v1/tts call otherwise samples a new voice). Threaded config -> build_voice -> provider -> payload, mirroring reference_id/api_key. - Robustness (from review): a non-numeric seed warns + degrades to unpinned instead of aborting every Config() (incl. spark/stub); the endpoint URL is stripped so a CRLF/whitespace .env value cannot corrupt the host. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/spec02/02-voice-provider.md | 15 ++++++-- src/murmur/app.py | 1 + src/murmur/config.py | 22 ++++++++++++ src/murmur/voice/__init__.py | 2 ++ src/murmur/voice/remote.py | 25 +++++++++++--- tests/test_voice_remote.py | 57 +++++++++++++++++++++++++++++++ 6 files changed, 114 insertions(+), 8 deletions(-) diff --git a/specs/spec02/02-voice-provider.md b/specs/spec02/02-voice-provider.md index 03a33af..5e3fe4f 100644 --- a/specs/spec02/02-voice-provider.md +++ b/specs/spec02/02-voice-provider.md @@ -155,8 +155,13 @@ 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_provider` to `spark`. + voice), `MURMUR_TTS_API_KEY` (optional bearer), `MURMUR_TTS_SEED` (optional). + Switch back to local by setting `voice_provider` to `spark`. +- **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 +169,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..6789a6b 100644 --- a/src/murmur/app.py +++ b/src/murmur/app.py @@ -44,6 +44,7 @@ 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, ) player = build_engine(ffmpeg=config.ffmpeg_cmd) brain = build_brain(config.brain_provider, model=config.model) diff --git a/src/murmur/config.py b/src/murmur/config.py index f7c0563..a545ac1 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,10 @@ 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()) @classmethod def default(cls) -> "Config": diff --git a/src/murmur/voice/__init__.py b/src/murmur/voice/__init__.py index e5946c1..327f8f3 100644 --- a/src/murmur/voice/__init__.py +++ b/src/murmur/voice/__init__.py @@ -31,6 +31,7 @@ def build_voice( tts_url: str = "", tts_reference_id: str = "", tts_api_key: str = "", + tts_seed: int | None = None, ) -> VoiceProvider: """Construct the configured VoiceProvider adapter (spec 02 §3.5 hot-swap). @@ -51,6 +52,7 @@ def build_voice( tts_url, reference_id=tts_reference_id or None, api_key=tts_api_key or None, + seed=tts_seed, ) 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..6bf3590 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 endpoint (opuslab's hosted fish-speech) 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,15 @@ def __init__( *, reference_id: str | None = None, api_key: str | None = None, + seed: int | 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._timeout = timeout self._dir = Path(tempfile.mkdtemp(prefix="murmur-remote-")) self._counter = 0 @@ -77,7 +90,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,7 +110,7 @@ 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}" req = urllib.request.Request( # noqa: S310 - fixed http(s) TTS endpoint diff --git a/tests/test_voice_remote.py b/tests/test_voice_remote.py index 700adee..385b6ee 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,38 @@ 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 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. Capture the outgoing Request, no network. + prov = RemoteVoiceProvider("http://box:8080") + seen: dict[str, object] = {} + + 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["ua"] = req.get_header("User-agent") + return _Resp() + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + prov._post({"text": "hi"}) + ua = seen["ua"] + assert ua and "urllib" not in str(ua).lower() + + # --- build_voice wiring (config switch, no code edit) --------------------- # @@ -99,6 +137,25 @@ 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") c = Config.default() assert c.tts_url == "http://box:8080" assert c.tts_reference_id == "spk1" + assert c.tts_seed == 42 + + +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 From ee6286d2f97bf35067ff0e0f24dcec3d055245da Mon Sep 17 00:00:00 2001 From: Zach Guo Date: Fri, 10 Jul 2026 14:47:16 +0800 Subject: [PATCH 2/5] chore(dev): make dev-remote target + gitignore .env - `make dev-remote` loads a gitignored .env and runs the dev flow forcing the off-machine HTTP voice; when no endpoint is configured it exits early with a helpful .env template (before the slow install/preflight). - .gitignore: .env (machine-local endpoint config, never committed). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 +++ Makefile | 23 ++++++++++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 39d8896..e3e07da 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ graphify-out/ # Local dev session artifacts (make dev): the tailed diagnostics log .dev/ + +# Local-only env (endpoints/keys for `set -a; source .env; set +a; make dev`) +.env diff --git a/Makefile b/Makefile index 22faa94..c3aa3a1 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ # 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 (interactive) +# make dev-remote # same, but the off-machine HTTP TTS (loads .env) +# 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 +24,13 @@ else PREFLIGHT_ARGS := --voice $(VOICE) endif -.PHONY: help dev logs preflight setup-music install +.PHONY: help dev dev-remote logs preflight setup-music install help: @echo "murmur dev:" @echo " make dev install deps, preflight, then launch the app" @echo " (diagnostics -> $(DEV_LOG))" + @echo " make dev-remote same, but off-machine HTTP TTS (loads .env; WARP on)" @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 +46,21 @@ install: preflight: @uv run python scripts/dev_preflight.py $(PREFLIGHT_ARGS) +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.opuslab.ai"; \ + 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 ""; \ From 2782acc33782f2cf898fe5635fa9dc5442e0598a Mon Sep 17 00:00:00 2001 From: Zach Guo Date: Fri, 10 Jul 2026 15:37:56 +0800 Subject: [PATCH 3/5] chore(voice): scrub internal endpoint hostname from source [spec 02] Replace the real Cloudflare-fronted host with a placeholder in the dev-remote template and drop the operator name from a code comment; the actual endpoint lives only in the gitignored .env. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 2 +- src/murmur/voice/remote.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index c3aa3a1..7ce9547 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,7 @@ dev-remote: 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.opuslab.ai"; \ + 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; \ diff --git a/src/murmur/voice/remote.py b/src/murmur/voice/remote.py index 6bf3590..4e9b4cc 100644 --- a/src/murmur/voice/remote.py +++ b/src/murmur/voice/remote.py @@ -28,8 +28,8 @@ _log = get_log("voice") _SYNTH_TIMEOUT = 120.0 -# A named UA: a Cloudflare-fronted endpoint (opuslab's hosted fish-speech) blocks -# urllib's default "Python-urllib/*" UA with a 403 bot rule; any non-bot UA passes. +# 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" From 719417590111463f93ca96fffb09449fffa8b2b8 Mon Sep 17 00:00:00 2001 From: Zach Guo Date: Mon, 13 Jul 2026 17:06:07 +0800 Subject: [PATCH 4/5] feat(voice): configurable remote TTS model + CLI backend switching [spec 02] Make the remote voice switchable without editing the env, and enable hosted models (e.g. fish.audio S2.1 Pro) selected via an HTTP `model` header: - MURMUR_TTS_MODEL / --tts-model -> the `model` request header (empty = none, which self-hosted fish-speech ignores; "s2.1-pro-free" selects fish.audio's free hosted model). Threaded config -> build_voice -> provider -> _post. - CLI overrides --tts-url / --tts-model / --tts-reference win over env, so switching among the local sidecar, a self-hosted server, and a hosted API is one command (--voice already flips local <-> remote). API key stays env-only. - Extract _apply_overrides() so the CLI -> config layering is unit-tested. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/spec02/02-voice-provider.md | 14 +++++++-- src/murmur/app.py | 48 ++++++++++++++++++++++++++---- src/murmur/config.py | 4 +++ src/murmur/voice/__init__.py | 2 ++ src/murmur/voice/remote.py | 4 +++ tests/test_config_and_factories.py | 31 ++++++++++++++++++- tests/test_voice_remote.py | 42 +++++++++++++++++++++----- 7 files changed, 128 insertions(+), 17 deletions(-) diff --git a/specs/spec02/02-voice-provider.md b/specs/spec02/02-voice-provider.md index 5e3fe4f..e1b3a28 100644 --- a/specs/spec02/02-voice-provider.md +++ b/specs/spec02/02-voice-provider.md @@ -155,8 +155,18 @@ 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), `MURMUR_TTS_SEED` (optional). - Switch back to local by setting `voice_provider` to `spark`. + 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 diff --git a/src/murmur/app.py b/src/murmur/app.py index 6789a6b..2ef9d27 100644 --- a/src/murmur/app.py +++ b/src/murmur/app.py @@ -45,6 +45,7 @@ async def _run(config: Config, *, max_segments: int | None) -> None: 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) @@ -175,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", @@ -197,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: @@ -211,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 a545ac1..0bee7fa 100644 --- a/src/murmur/config.py +++ b/src/murmur/config.py @@ -95,6 +95,10 @@ class Config: # 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 327f8f3..9e34270 100644 --- a/src/murmur/voice/__init__.py +++ b/src/murmur/voice/__init__.py @@ -32,6 +32,7 @@ def build_voice( 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). @@ -53,6 +54,7 @@ def build_voice( 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 4e9b4cc..41df5d7 100644 --- a/src/murmur/voice/remote.py +++ b/src/murmur/voice/remote.py @@ -69,6 +69,7 @@ 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: # strip() first: a .env value with trailing whitespace / CRLF would @@ -77,6 +78,7 @@ def __init__( 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 @@ -113,6 +115,8 @@ def _post(self, payload: dict[str, object]) -> bytes: 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 385b6ee..04f4778 100644 --- a/tests/test_voice_remote.py +++ b/tests/test_voice_remote.py @@ -92,11 +92,10 @@ def test_url_strips_surrounding_whitespace_and_crlf(): assert RemoteVoiceProvider("http://box:8080/\r\n")._url == "http://box:8080/v1/tts" -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. Capture the outgoing Request, no network. - prov = RemoteVoiceProvider("http://box:8080") - seen: dict[str, object] = {} +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: @@ -109,13 +108,33 @@ def __exit__(self, *a) -> None: return None def fake_urlopen(req, timeout): # noqa: ANN001 - seen["ua"] = req.get_header("User-agent") + seen["headers"] = dict(req.header_items()) return _Resp() monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) prov._post({"text": "hi"}) - ua = seen["ua"] - assert ua and "urllib" not in str(ua).lower() + 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) --------------------- # @@ -138,10 +157,12 @@ 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): @@ -159,3 +180,8 @@ def test_config_bad_seed_is_ignored_not_fatal(monkeypatch): 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" From d917df24e90de4031bbeefb02e65f8b767cf5e31 Mon Sep 17 00:00:00 2001 From: Zach Guo Date: Mon, 13 Jul 2026 18:49:12 +0800 Subject: [PATCH 5/5] chore(dev): dev-fishaudio / dev-opuslab targets + .env* ignore Select the remote backend by make target instead of hand-editing one .env: - .env.fishaudio / .env.opuslab (gitignored) hold each backend's config; `make dev-fishaudio` / `make dev-opuslab` copy the chosen one to .env and run the remote voice. `make dev-remote` still runs whatever .env currently holds. - .gitignore: ignore all .env* (the generated .env + the per-backend files). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 5 +++-- Makefile | 27 +++++++++++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index e3e07da..2d48c76 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,6 @@ graphify-out/ # Local dev session artifacts (make dev): the tailed diagnostics log .dev/ -# Local-only env (endpoints/keys for `set -a; source .env; set +a; make dev`) -.env +# 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 7ce9547..885888c 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +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 dev-remote # same, but the off-machine HTTP TTS (loads .env) -# 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, @@ -24,13 +25,15 @@ else PREFLIGHT_ARGS := --voice $(VOICE) endif -.PHONY: help dev dev-remote 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-remote same, but off-machine HTTP TTS (loads .env; WARP on)" + @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)" @@ -46,6 +49,18 @@ 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