Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.<backend>
# by `make dev-fishaudio` / `make dev-opuslab`; never commit any of them.
.env*
40 changes: 36 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)"
Expand All @@ -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 ""; \
Expand Down
23 changes: 21 additions & 2 deletions specs/spec02/02-voice-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,16 +155,35 @@ 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 <id>`. 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:
application/json` (+ `authorization: Bearer` when a key is set); the response
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
Expand Down
49 changes: 43 additions & 6 deletions src/murmur/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand All @@ -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:
Expand All @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions src/murmur/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----------------------------------------------------------
Expand Down Expand Up @@ -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":
Expand Down
4 changes: 4 additions & 0 deletions src/murmur/voice/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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)
Expand Down
29 changes: 24 additions & 5 deletions src/murmur/voice/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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


Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"
)
Expand Down
31 changes: 30 additions & 1 deletion tests/test_config_and_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Loading