* 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>
130 lines
5.3 KiB
Python
130 lines
5.3 KiB
Python
"""Every demo asset the UI advertises actually ships.
|
|
|
|
This exists because they did not. `personalities.py` has carried a
|
|
``preview_url`` for each of the seven voice-design presets since they were
|
|
added, and the WAVs behind them were never committed — the demo tooling that
|
|
renders them (``scripts/build_demos.sh``) hard-requires macOS ``say``, so on any
|
|
other machine the files simply never appeared. Result: seven preview buttons in
|
|
the voice picker that returned 404, plus three dictation replay clips and the
|
|
whole dubbing demo in the same state.
|
|
|
|
Nothing caught it, because a missing static file is not an import error and not
|
|
a failing request in any test — the app just plays nothing. So the check is
|
|
mechanical and lives here: every path the code hands to the browser is resolved
|
|
against the directory ``main.py`` actually mounts.
|
|
|
|
If this fails after adding a preset, render the assets rather than deleting the
|
|
check:
|
|
|
|
python3 scripts/render_demos_omnivoice.py # voice design + dictation
|
|
python3 scripts/render_dub_demo_audio.py # dubbing audio
|
|
bash scripts/build_dub_demo.sh # dubbing videos + manifest
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
|
|
import pytest
|
|
|
|
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
_BACKEND = os.path.join(_REPO_ROOT, "backend")
|
|
|
|
# The mount point. `main.py`: app.mount("/demo_audio", StaticFiles(directory=…))
|
|
# over backend/assets/samples — so a "/demo_audio/x/y.wav" URL is that file
|
|
# under this directory, and nothing else.
|
|
_DEMO_ROOT = os.path.join(_BACKEND, "assets", "samples")
|
|
|
|
_DICTATION_DEMO = os.path.join(
|
|
_REPO_ROOT, "frontend", "src", "components", "DictationDemo.jsx"
|
|
)
|
|
|
|
|
|
def _resolve(url: str) -> str:
|
|
"""A /demo_audio/... URL → the file on disk it is served from."""
|
|
assert url.startswith("/demo_audio/"), url
|
|
return os.path.join(_DEMO_ROOT, url[len("/demo_audio/") :])
|
|
|
|
|
|
def _personalities():
|
|
import sys
|
|
|
|
if _BACKEND not in sys.path:
|
|
sys.path.insert(0, _BACKEND)
|
|
from core.personalities import PERSONALITIES # noqa: PLC0415
|
|
|
|
return PERSONALITIES
|
|
|
|
|
|
def test_the_demo_mount_points_where_this_test_thinks_it_does():
|
|
"""Pin the mount, so moving it fails here rather than in the browser."""
|
|
main_py = open(os.path.join(_BACKEND, "main.py"), encoding="utf-8").read()
|
|
assert 'os.path.join(os.path.dirname(__file__), "assets", "samples")' in main_py
|
|
assert 'app.mount("/demo_audio"' in main_py
|
|
|
|
|
|
def test_every_voice_design_preview_exists():
|
|
"""Every preset that advertises a preview has the audio to back it.
|
|
|
|
Presets are read inside the test, not in a `parametrize` argument:
|
|
parametrize is evaluated at COLLECTION time, which would import app code
|
|
before any test runs and leave `core.personalities` in `sys.modules` for
|
|
every later test to inherit.
|
|
"""
|
|
presets = [p for p in _personalities() if p.get("preview_url")]
|
|
assert presets, "no voice-design preset advertises a preview"
|
|
for preset in presets:
|
|
path = _resolve(preset["preview_url"])
|
|
assert os.path.isfile(path), (
|
|
f"{preset['id']} advertises {preset['preview_url']} but {path} is missing. "
|
|
"Render it with scripts/render_demos_omnivoice.py --only design."
|
|
)
|
|
assert os.path.getsize(path) > 8000, f"{path} is too small to be real audio"
|
|
|
|
|
|
def _dictation_wavs() -> list[str]:
|
|
"""The replay clips DictationDemo.jsx posts to /transcribe."""
|
|
source = open(_DICTATION_DEMO, encoding="utf-8").read()
|
|
return re.findall(r"wav:\s*'(/demo_audio/[^']+)'", source)
|
|
|
|
|
|
def test_dictation_demo_lists_its_scripts():
|
|
assert len(_dictation_wavs()) == 3, "DictationDemo should offer three scripts"
|
|
|
|
|
|
@pytest.mark.parametrize("url", _dictation_wavs())
|
|
def test_every_dictation_clip_exists(url):
|
|
path = _resolve(url)
|
|
assert os.path.isfile(path), (
|
|
f"DictationDemo replays {url} but {path} is missing. "
|
|
"Render it with scripts/render_demos_omnivoice.py --only dictation."
|
|
)
|
|
assert os.path.getsize(path) > 8000, f"{path} is too small to be real audio"
|
|
|
|
|
|
def test_the_cloning_demo_pair_exists():
|
|
for name in ("demo_voice.wav", "demo_clone_output.wav"):
|
|
assert os.path.isfile(os.path.join(_DEMO_ROOT, name)), name
|
|
|
|
|
|
def test_the_dubbing_demo_manifest_and_every_file_it_names_exist():
|
|
"""The Dub workspace reads this manifest and plays what it lists."""
|
|
manifest_path = _resolve("/demo_audio/demo/dubbing/manifest.json")
|
|
assert os.path.isfile(manifest_path), (
|
|
"The dubbing demo manifest is missing. Build it with "
|
|
"scripts/render_dub_demo_audio.py then scripts/build_dub_demo.sh."
|
|
)
|
|
manifest = json.loads(open(manifest_path, encoding="utf-8").read())
|
|
entries = [manifest["source"], *manifest["dubbed"]]
|
|
assert len(entries) == 5, "one source plus four dubs"
|
|
directory = os.path.dirname(manifest_path)
|
|
for entry in entries:
|
|
for key in ("video", "srt"):
|
|
path = os.path.join(directory, entry[key])
|
|
assert os.path.isfile(path), f"{entry['code']}: {entry[key]} missing"
|
|
# A manifest that names a video whose subtitle says something else is
|
|
# the one failure a viewer cannot tell from a bad dub.
|
|
srt = open(os.path.join(directory, entry["srt"]), encoding="utf-8").read()
|
|
assert entry["script"] in srt, f"{entry['code']}: subtitle does not match the script"
|