Files
VoiceStudio/tests/test_pockettts_deferred.py
T
Palash DebnathandClaude Opus 5 41c098e009 feat(demos): ship the demo audio and video the app already advertises (#1517)
* feat(demos): ship the demo audio and video the app already advertises

Every demo asset in the app was a dead link on anything but a Mac.

`personalities.py` has carried a `preview_url` for each of the seven
voice-design presets since they were added; DictationDemo.jsx posts three
bundled WAVs to /transcribe so the feature can be shown without microphone
permission; the Dub workspace reads a manifest and plays a source video plus
four dubbed languages. None of those files were committed, because the tooling
that renders them (scripts/build_demos.sh, scripts/build_dub_demo.sh) hard-
requires macOS `say` — it even carries a `TODO: add espeak-ng path for Linux
contributors`. So the presets returned 404, the replay buttons did nothing, and
the dubbing demo never loaded.

Rendered with VoiceStudio's own engine, which runs wherever the app does:

- 7 voice-design previews (2.2 MB)
- 3 dictation replay clips (1.1 MB) — verified by transcribing them back:
  the conversational and French clips round-trip exactly
- dubbing demo: source + 4 dubbed videos with subtitles and manifest (9.6 MB)

Tooling fixes this turned up:

- build_dub_demo.sh wrote to backend/assets/demo/dubbing, but main.py mounts
  backend/assets/samples at /demo_audio — so the frontend's
  /demo_audio/demo/dubbing/manifest.json could never have resolved even after
  a successful Mac build. Output moved under the mount.
- `say` is now the fallback rather than the requirement: the new
  scripts/render_dub_demo_audio.py renders the five tracks with the engine and
  the shell script picks them up.
- The five demo paragraphs lived in two files. They are now one JSON both read
  — two copies is one edit away from a video whose subtitles disagree with it.
- render_demos_omnivoice.py peak-normalized, which a single-sample transient
  defeats: the Helpdesk preset landed at -30 dB RMS against -17 dB for its
  neighbours, so the preview row played at wildly different volumes. Now EBU
  R128 at -18 LUFS with a -1.5 dBTP ceiling.
- …and pinning the output rate, because loudnorm resamples to 192 kHz
  internally and writes there unless told otherwise, which turned 2.1 MB of
  previews into 17.5 MB of identical-sounding audio.
- update_manifest() looked for a manifest at a path nothing writes, so it
  always printed "not found" and did nothing.
- Dictation is rendered here now too. It was excluded on the grounds that
  `say` was good enough and engine TTS was overkill — true only on macOS.

tests/test_demo_assets_exist.py resolves every advertised URL against the
directory main.py actually mounts, and checks each dubbing subtitle matches the
script its manifest entry claims. A missing static file is not an import error
and not a failing request; nothing would have caught this otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(changelog): stamp the demo-asset entries with their PR ref

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(demos): watermark rendered demo audio, and harden the render scripts

Review findings on #1517:

- Greptile P1: the renderers wrote engine output straight to disk, so a
  re-render shipped demo audio with no provenance mark. These clips play
  back to users as VoiceStudio output — they are synthetic audio leaving
  the app like any other, and now go through mark_synthetic (#1169), the
  one chokepoint every producing route uses. It runs on the file AFTER
  loudnorm, since loudnorm re-encodes what it is handed, and says so
  loudly when marking is unavailable rather than committing an unmarked
  asset. The dubbing renderer shares the same helper.
- CodeRabbit: build_dub_demo.sh checked only source.src.wav before
  deciding it could run without macOS `say`, so a Linux or Windows run
  with four of five tracks present reached a missing one, called `say`,
  and left a half-built bundle. It now requires all five.
- CodeRabbit: shutil.move over an existing path delegates to os.rename,
  which raises FileExistsError on Windows — os.replace overwrites
  atomically everywhere.
- CodeRabbit: the preview test discovered presets in a parametrize
  argument, importing app code at collection time and leaving
  core.personalities in sys.modules for later tests. Discovery moved into
  the test body.

CI: the rendered dub bundle's zh/ja subtitles, its manifest and the
script source are dubbing CONTENT, not UI strings — allowlisted in
test_no_hardcoded_cjk.py with that justification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(demos): a render that cannot be watermarked fails instead of warning

CodeRabbit and Greptile, #1517: mark_synthetic degrades rather than
raising — correct for generation, wrong for a render script, whose whole
job is to produce files a human then commits. A printed warning on a
scrolling console is not a gate, so both scripts exited 0 with unmarked
assets sitting on disk ready to commit. They now raise, with the reason
and the fix; OMNIVOICE_DEMO_ALLOW_UNMARKED=1 stays for a local listen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: stop a flaky dependency fetch from failing green runs

en-core-web-sm resolves to a direct GitHub release URL, and github.com
intermittently answers `http2 error: refused stream before processing
any application logic`. uv's own three retries all land within the same
few seconds and fail together, so the whole job dies on a dependency
that has nothing to do with the change under test — it cost #1518 and
#1517 an otherwise-green run tonight.

Two changes: back off between whole `uv sync` attempts, which is what
actually clears it, and pass --no-sync to the pytest steps. `uv run`
re-resolves the environment before running, so every test step was a
fresh chance to hit the same fetch even though the install step had
already synced — that is exactly how #1518 failed, in the isolated
backend/tests step, with all 5467 tests already passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: one retry seam for every uv sync, not just the job that failed last

en-core-web-sm resolves to a direct GitHub *release* URL rather than a
package index, and github.com intermittently answers `http2 error:
refused stream before processing any application logic`. uv's own
retries all land inside the same ~10 seconds and fail together, so a job
dies on a dependency unrelated to the change under test. Tonight that
cost four otherwise-green runs across #1515, #1517 and #1518 — and the
first fix only covered the Tests job, so the next failure simply moved
to Smoke (Linux), which syncs separately.

The fetch is per-job, so the fix has to be per-job: scripts/uv-sync-retry.sh
backs off between whole attempts (15s, 45s, 90s) and every workflow that
syncs now goes through it — ci.yml (tests + the platform matrix),
release.yml, security.yml, evals.yml. It still fails loudly after four
attempts, so a genuinely broken lockfile is not disguised as a flake.

The Tests job also lacked the UV_HTTP_TIMEOUT / UV_HTTP_RETRIES the smoke
matrix has always set, which is part of why it was the one that kept
dying; it has them now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(ci): pin the Intel-Mac contract by intent, not by command spelling

test_ci_verifies_intel_mac_as_the_documented_remote_only_host asserted
the literal line `run: uv sync --extra pockettts`, so routing every sync
through scripts/uv-sync-retry.sh read as a broken Intel-Mac contract. The
contract it exists to protect is that the pockettts extra installs ONLY
on backend_supported legs — which the regex now pins, while leaving how
the sync is invoked free to change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: keep every uv run out of the resolver, and bound the retry budget

CodeRabbit, #1517:

- `uv run` re-resolves before running, so the smoke suite, the
  worker-artifact tests, the release test run and the eval run were each
  a fresh chance to hit the flaky direct-URL fetch outside the retry
  loop. All of them pass --no-sync now; the environment is already
  synced by the step that owns the retries. security.yml's
  `uv run --with pip-audit` is deliberately left alone — it layers an
  ephemeral package rather than running the project's own tests.
- The retry count multiplied uv's own budget (UV_HTTP_RETRIES=5 with a
  120 s timeout on the smoke matrix). Three attempts and 60 s of total
  backoff outlast the refusals actually observed while staying well
  inside the jobs' timeout-minutes.
- The Intel-Mac contract test pinned the smoke command literally too, so
  --no-sync tripped it exactly like the sync line did. Same fix: assert
  the contract (smoke runs only on backend_supported legs), not its
  spelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:29:19 +00:00

285 lines
10 KiB
Python

"""PocketTTS first-use gate and model-free sidecar integration (#1442)."""
from __future__ import annotations
import re
import sys
import types
import pytest
def _backend_cls():
"""Resolve app code at test runtime; sys.modules-isolating tests may reload it."""
from engines.pockettts import PocketTTSBackend
return PocketTTSBackend
def _workflow_paths(workflows):
return sorted((*workflows.glob("*.yml"), *workflows.glob("*.yaml")))
STUB_SIDECAR = r'''
import base64, json, struct, sys
def send(obj):
body = json.dumps(obj, separators=(",", ":")).encode()
sys.stdout.buffer.write(struct.pack("!I", len(body)) + body)
sys.stdout.buffer.flush()
def recv():
header = sys.stdin.buffer.read(4)
if len(header) != 4:
return None
length = struct.unpack("!I", header)[0]
return json.loads(sys.stdin.buffer.read(length))
send({"op": "ready", "engine": "pockettts", "sample_rate": 24000})
while True:
message = recv()
if message is None or message.get("op") == "shutdown":
break
if message.get("op") == "ping":
send({"op": "pong", "vram_mb": 0.0})
elif message.get("op") == "synthesize":
# Prove engine-specific kwargs cross the real subprocess wire.
assert message.get("language") == "fr"
assert message.get("ref_audio") == "/tmp/reference.wav"
pcm = struct.pack("<4h", 0, 1000, -1000, 0)
send({"op": "progress", "stage": "loading_model", "percent": 50})
send({"op": "audio", "audio_pcm_b64": base64.b64encode(pcm).decode(),
"sample_rate": 24000, "n_samples": 4})
'''
def test_license_gate_fails_closed_then_allows_engine(monkeypatch, mock_settings_store):
monkeypatch.setitem(sys.modules, "pocket_tts", types.ModuleType("pocket_tts"))
mock_settings_store.pop("pockettts", None)
ok, reason = _backend_cls().is_available()
assert ok is False
assert "license not accepted" in reason.lower()
assert "Settings" in reason and "Engines" in reason
mock_settings_store["pockettts"] = True
assert _backend_cls().is_available() == (True, "ready (CPU-only)")
def test_pockettts_is_a_pinned_optional_extra():
import tomllib
from pathlib import Path
project = tomllib.loads(
(Path(__file__).resolve().parents[1] / "pyproject.toml").read_text("utf-8")
)
assert project["project"]["optional-dependencies"]["pockettts"] == [
"pocket-tts==2.1.0 ; sys_platform != 'darwin' or platform_machine != 'x86_64'"
]
def test_pockettts_reports_the_intel_mac_wheel_gap(monkeypatch):
monkeypatch.setattr("engines.pockettts.sys.platform", "darwin")
monkeypatch.setattr("engines.pockettts.platform.machine", lambda: "x86_64")
ok, reason = _backend_cls().is_available()
assert ok is False
assert "Intel Macs" in reason
assert "PyTorch" in reason
def test_direct_construction_rejects_intel_mac(monkeypatch, mock_settings_store):
mock_settings_store["pockettts"] = True
monkeypatch.setattr("engines.pockettts.sys.platform", "darwin")
monkeypatch.setattr("engines.pockettts.platform.machine", lambda: "x86_64")
with pytest.raises(RuntimeError, match="Intel Macs"):
_backend_cls()()
def test_ci_verifies_intel_mac_as_the_documented_remote_only_host():
from pathlib import Path
workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/ci.yml").read_text(
"utf-8"
)
assert "label: macOS Intel\n backend_supported: false" in workflow
assert "name: Verify the documented Intel Mac contract" in workflow
# What must hold is that the pockettts extra is installed ONLY on the
# backend-supported legs — not how the sync is invoked. Pinning the exact
# command made an unrelated CI hardening change (routing every sync through
# scripts/uv-sync-retry.sh) look like a broken Intel-Mac contract.
assert re.search(
r"if: matrix\.backend_supported\n run: .*--extra pockettts", workflow
), "the pockettts extra must be installed only on backend_supported legs"
assert re.search(
r"if: matrix\.backend_supported\n run: uv run .*pytest tests/smoke/", workflow
), "the smoke suite must run only on backend_supported legs"
assert "HF_HUB_CACHE: ${{ runner.temp }}/pockettts-empty-hf-cache" in workflow
@pytest.mark.parametrize(
("ref", "valid"),
[
("v1", True),
("v1.6.3", True),
("0123456789abcdef0123456789abcdef01234567", True),
("latest", False),
("main", False),
("release", False),
("v1-beta", False),
("0123456", False),
],
)
def test_cache_apt_action_pin_policy(ref, valid):
pattern = r"(?:v\d+(?:\.\d+){0,2}|[0-9a-f]{40})"
assert (re.fullmatch(pattern, ref) is not None) is valid
def test_cache_apt_action_is_pinned_in_every_workflow():
from pathlib import Path
workflows = Path(__file__).resolve().parents[1] / ".github/workflows"
paths = _workflow_paths(workflows)
assert paths
for path in paths:
text = path.read_text("utf-8")
refs = re.findall(r"awalsh128/cache-apt-pkgs-action@([^\s#]+)", text)
for ref in refs:
assert re.fullmatch(r"v\d+(?:\.\d+){0,2}|[0-9a-f]{40}", ref), (
path,
ref,
)
def test_workflow_pin_scan_includes_both_yaml_extensions(tmp_path):
(tmp_path / "one.yml").touch()
(tmp_path / "two.yaml").touch()
assert [path.name for path in _workflow_paths(tmp_path)] == ["one.yml", "two.yaml"]
def test_every_locale_discloses_hugging_face_model_access():
import json
from pathlib import Path
locales = Path(__file__).resolve().parents[1] / "frontend/src/i18n/locales"
paths = sorted(locales.glob("*.json"))
assert len(paths) == 21
for path in paths:
strings = json.loads(path.read_text("utf-8"))
footer = strings["license"]["pockettts_footer"]
assert "Hugging Face" in footer, path
@pytest.mark.parametrize(
"reason",
[
"Cannot access gated repo for model kyutai/pocket-tts",
"PocketTTS requires you to share your contact information",
"Kyutai Pocket TTS access agreement has not been accepted",
],
)
def test_gated_weight_failures_are_typed_and_actionable(reason):
from core.failure import build_failure, classify
assert classify(reason) == "POCKETTTS_GATED_WEIGHTS"
failure = build_failure(reason, stage="model_load", include_diagnostic=False)
assert failure["docs_topic"] == "POCKETTTS_GATED_WEIGHTS"
assert "huggingface.co/kyutai/pocket-tts" in failure["hint"]
assert "HF_TOKEN" in failure["hint"]
def test_generic_gated_model_still_uses_existing_pyannote_class():
from core.failure import classify
assert classify("gated model license not accepted") == "PYANNOTE_LICENSE_REQUIRED"
def test_license_gate_fails_closed_when_settings_read_fails(monkeypatch):
monkeypatch.setitem(sys.modules, "pocket_tts", types.ModuleType("pocket_tts"))
from services import settings_store
monkeypatch.setattr(settings_store, "get_license_accepted", lambda _eid: (_ for _ in ()).throw(OSError("db unavailable")))
assert _backend_cls().is_available()[0] is False
def test_direct_backend_construction_cannot_bypass_license(mock_settings_store):
mock_settings_store.pop("pockettts", None)
with pytest.raises(RuntimeError, match="license not accepted"):
_backend_cls()()
def test_cached_backend_stops_synthesis_after_license_revocation(mock_settings_store):
mock_settings_store["pockettts"] = True
backend = _backend_cls()()
try:
mock_settings_store["pockettts"] = False
with pytest.raises(RuntimeError, match="license not accepted"):
backend.generate("must not reach the sidecar")
finally:
backend.shutdown()
def test_queued_synthesis_rechecks_license_after_acquiring_lock(
monkeypatch, mock_settings_store
):
mock_settings_store["pockettts"] = True
backend = _backend_cls()()
class RevokingLock:
def __enter__(self):
mock_settings_store["pockettts"] = False
def __exit__(self, *_args):
return False
backend._lock = RevokingLock()
monkeypatch.setattr(
"services.model_manager.running_on_gpu_pool", lambda: True
)
monkeypatch.setattr(
backend, "_spawn", lambda: pytest.fail("revoked request reached sidecar")
)
with pytest.raises(RuntimeError, match="license not accepted"):
backend.generate("queued before revocation")
def test_stub_sidecar_roundtrip_is_model_free_and_forwards_voice_inputs(
tmp_path, monkeypatch, mock_settings_store
):
mock_settings_store["pockettts"] = True
stub = tmp_path / "pockettts_stub.py"
stub.write_text(STUB_SIDECAR, encoding="utf-8")
backend_cls = _backend_cls()
monkeypatch.setattr(backend_cls, "sidecar_script", classmethod(lambda cls: stub))
# Use this interpreter while retaining the engine's real override surface.
monkeypatch.setattr(backend_cls, "venv_python", classmethod(lambda cls: __import__("pathlib").Path(sys.executable)))
backend = backend_cls()
try:
audio = backend.generate(
"bonjour", language="fr", ref_audio="/tmp/reference.wav"
)
assert tuple(audio.shape) == (1, 4)
assert audio[0, 1].item() == pytest.approx(1000 / 32768.0)
finally:
backend.shutdown()
def test_license_api_accepts_pockettts_and_rejects_unknown(settings_mod, mock_settings_store):
body = settings_mod._LicenseAcceptBody(engine_id=" PocketTTS ", accepted=True)
assert settings_mod.post_license_acceptance(body) == {
"ok": True, "engine_id": "pockettts", "accepted": True
}
assert mock_settings_store["pockettts"] is True
with pytest.raises(Exception) as exc:
settings_mod.post_license_acceptance(
settings_mod._LicenseAcceptBody(engine_id="unknown", accepted=True)
)
assert getattr(exc.value, "status_code", None) == 400
@pytest.fixture
def settings_mod():
import importlib
return importlib.import_module("api.routers.settings")