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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a310141114
commit
41c098e009
@@ -23,6 +23,12 @@ jobs:
|
||||
test:
|
||||
name: Tests (backend + frontend)
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
# Same restricted-network resilience the smoke matrix already sets. This
|
||||
# job resolves the same direct-URL dependency and had none of it, which
|
||||
# is why it was the one that kept dying (see scripts/uv-sync-retry.sh).
|
||||
UV_HTTP_TIMEOUT: "120"
|
||||
UV_HTTP_RETRIES: "5"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -61,7 +67,14 @@ jobs:
|
||||
# so their tests can exercise the real import path, not the
|
||||
# "package not installed" fallback. Smoke job below stays on bare
|
||||
# `uv sync` because smoke only hits /health + fixture profiles.
|
||||
run: uv sync --all-extras
|
||||
#
|
||||
# Retried because one dependency — 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 3 retries all land inside the same few seconds and
|
||||
# fail together, which has cost otherwise-green runs (#1517, #1518).
|
||||
# Backing off between whole attempts is what actually clears it.
|
||||
run: bash scripts/uv-sync-retry.sh --all-extras
|
||||
|
||||
# HF_HUB_OFFLINE=1 is a recurrence guard, not an optimization: a test
|
||||
# that reaches huggingface.co fails fast and loud instead of silently
|
||||
@@ -71,7 +84,7 @@ jobs:
|
||||
# interactions in tests are stubbed; anything that trips this is a
|
||||
# test-isolation bug.
|
||||
- name: Run pytest
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
run: uv run --no-sync pytest tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
|
||||
@@ -99,7 +112,7 @@ jobs:
|
||||
# longer stubs sys.modules, so mixed sessions with tests/ are safe;
|
||||
# the separate session is kept for cheaper, clearer CI output.
|
||||
- name: Run pytest (backend/tests, isolated)
|
||||
run: uv run pytest backend/tests/ -q --tb=short
|
||||
run: uv run --no-sync pytest backend/tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as tests/
|
||||
|
||||
@@ -367,7 +380,7 @@ jobs:
|
||||
# backend host. The Intel-Mac leg separately pins the documented
|
||||
# unsupported contract: its UI is a remote-backend client only (#889).
|
||||
if: matrix.backend_supported
|
||||
run: uv sync --extra pockettts
|
||||
run: bash scripts/uv-sync-retry.sh --extra pockettts
|
||||
|
||||
- name: Verify the documented Intel Mac contract
|
||||
if: ${{ !matrix.backend_supported }}
|
||||
@@ -392,7 +405,7 @@ jobs:
|
||||
|
||||
- name: Run smoke tests
|
||||
if: matrix.backend_supported
|
||||
run: uv run pytest tests/smoke/ -q --tb=short
|
||||
run: uv run --no-sync pytest tests/smoke/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as the main pytest job
|
||||
HF_HUB_CACHE: ${{ runner.temp }}/pockettts-empty-hf-cache
|
||||
@@ -401,7 +414,7 @@ jobs:
|
||||
# Linux emulation cannot exercise sharing rules or path parsing.
|
||||
- name: Remote-worker artifact paths (Windows)
|
||||
if: runner.os == 'Windows' && matrix.backend_supported
|
||||
run: uv run pytest tests/test_worker_upload_server.py tests/test_worker_server_integrity.py -q --tb=short
|
||||
run: uv run --no-sync pytest tests/test_worker_upload_server.py tests/test_worker_server_integrity.py -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
HF_HUB_CACHE: ${{ runner.temp }}/worker-artifact-empty-hf-cache
|
||||
|
||||
@@ -38,14 +38,14 @@ jobs:
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Install deps
|
||||
run: uv sync
|
||||
run: bash scripts/uv-sync-retry.sh
|
||||
|
||||
- name: Run eval suites (non-gating)
|
||||
continue-on-error: true
|
||||
env:
|
||||
TRANSLATE_BASE_URL: ${{ secrets.EVALS_LLM_BASE_URL }}
|
||||
TRANSLATE_API_KEY: ${{ secrets.EVALS_LLM_API_KEY }}
|
||||
run: uv run python tests/evals/run_evals.py --output eval-report.json
|
||||
run: uv run --no-sync python tests/evals/run_evals.py --output eval-report.json
|
||||
|
||||
- name: Upload report artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -109,10 +109,10 @@ jobs:
|
||||
version: 1.0
|
||||
|
||||
- name: Install Python deps
|
||||
run: uv sync
|
||||
run: bash scripts/uv-sync-retry.sh
|
||||
|
||||
- name: Run pytest
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
run: uv run --no-sync pytest tests/ -q --tb=short
|
||||
|
||||
- name: Cache bun deps
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
- name: pip-audit (Python)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
uv sync
|
||||
bash scripts/uv-sync-retry.sh
|
||||
uv run --with pip-audit pip-audit
|
||||
|
||||
# Pin a floor: `bun audit` was added in bun 1.2.x, so guarantee it exists.
|
||||
|
||||
@@ -158,3 +158,8 @@ tests/probe/reports/
|
||||
# Local architecture/planning scratch (goal docs, review briefs, council
|
||||
# reports). Working notes for whoever is driving a change, not a repo artifact.
|
||||
/remote/
|
||||
|
||||
# Dubbing-demo intermediates. The .mp4/.srt/manifest.json in this directory ARE
|
||||
# committed (they ship with the app); the per-language source WAVs are just the
|
||||
# inputs scripts/render_dub_demo_audio.py hands to scripts/build_dub_demo.sh.
|
||||
backend/assets/samples/demo/dubbing/*.src.wav
|
||||
|
||||
@@ -32,6 +32,8 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516)
|
||||
- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516)
|
||||
- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516)
|
||||
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
|
||||
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:13,720
|
||||
VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear.
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:15,000
|
||||
VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer.
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:16,560
|
||||
VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:13,200
|
||||
VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "0.3.0",
|
||||
"rendered_by": "omnivoice engine + ffmpeg showwaves",
|
||||
"rendered_at": "2026-08-12T19:47:29Z",
|
||||
"license": "MIT (synthetic, no third-party IP)",
|
||||
"source": {
|
||||
"code": "en",
|
||||
"label": "English",
|
||||
"video": "source.mp4",
|
||||
"srt": "source.srt",
|
||||
"script": "VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating."
|
||||
},
|
||||
"dubbed": [
|
||||
{
|
||||
"code": "es",
|
||||
"label": "Español",
|
||||
"video": "dubbed_es.mp4",
|
||||
"srt": "dubbed_es.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear."
|
||||
},
|
||||
{
|
||||
"code": "fr",
|
||||
"label": "Français",
|
||||
"video": "dubbed_fr.mp4",
|
||||
"srt": "dubbed_fr.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer."
|
||||
},
|
||||
{
|
||||
"code": "zh",
|
||||
"label": "中文",
|
||||
"video": "dubbed_zh.mp4",
|
||||
"srt": "dubbed_zh.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。"
|
||||
},
|
||||
{
|
||||
"code": "ja",
|
||||
"label": "日本語",
|
||||
"video": "dubbed_ja.mp4",
|
||||
"srt": "dubbed_ja.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:11,400
|
||||
VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+62
-22
@@ -18,35 +18,62 @@
|
||||
|
||||
set -e
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUT_DIR="${REPO_ROOT}/backend/assets/demo/dubbing"
|
||||
OUT_DIR="${REPO_ROOT}/backend/assets/samples/demo/dubbing"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
# Compatibility: must run on macOS default bash 3.2 (no associative arrays,
|
||||
# no ${var@Q}). We sidestep both by passing scripts as env vars to python3
|
||||
# below. Just guard the basics.
|
||||
if ! command -v ffmpeg >/dev/null || ! command -v say >/dev/null; then
|
||||
echo "ERROR: need both ffmpeg and macOS 'say'." >&2
|
||||
if ! command -v ffmpeg >/dev/null; then
|
||||
echo "ERROR: ffmpeg not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
# `say` is macOS-only, which used to make this script macOS-only and left the
|
||||
# Dub workspace's demo player pointing at files no other platform could build.
|
||||
# scripts/render_dub_demo_audio.py renders the same five tracks with the app's
|
||||
# own engine, anywhere; `say` is now the fallback, not the requirement.
|
||||
HAVE_SAY=0
|
||||
command -v say >/dev/null && HAVE_SAY=1
|
||||
# Check EVERY track, not just the source. Checking one let a run start with
|
||||
# four of five present, reach a missing dubbed track, call `say` — which does
|
||||
# not exist off macOS — and leave a half-built bundle behind.
|
||||
if [ "$HAVE_SAY" = 0 ]; then
|
||||
MISSING=""
|
||||
for stem in source dubbed_es dubbed_fr dubbed_zh dubbed_ja; do
|
||||
[ -f "${OUT_DIR}/${stem}.src.wav" ] || MISSING="${MISSING} ${stem}.src.wav"
|
||||
done
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo "ERROR: no macOS 'say', and these pre-rendered tracks are missing:${MISSING}" >&2
|
||||
echo "Run: python3 scripts/render_dub_demo_audio.py" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if ! command -v python3 >/dev/null; then
|
||||
echo "ERROR: python3 not found — needed to emit manifest.json." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Scripts: source + 4 translations ─────────────────────────────────────
|
||||
# Each is ~20-25 seconds when spoken — short enough to keep the demo snappy,
|
||||
# long enough to show off pacing + accent. All translations preserve meaning;
|
||||
# they were drafted manually so the script is reproducible.
|
||||
# Read from scripts/dub_demo_scripts.json, which render_dub_demo_audio.py reads
|
||||
# too — two copies of the same five paragraphs is one edit away from a source
|
||||
# video whose subtitles say something else.
|
||||
SCRIPTS_JSON="${REPO_ROOT}/scripts/dub_demo_scripts.json"
|
||||
read_script() {
|
||||
SCRIPTS_JSON="$SCRIPTS_JSON" CODE="$1" python3 -c "
|
||||
import json, os, sys
|
||||
spec = json.load(open(os.environ['SCRIPTS_JSON'], encoding='utf-8'))
|
||||
code = os.environ['CODE']
|
||||
entry = spec['source'] if code == 'en' else next(
|
||||
e for e in spec['dubbed'] if e['code'] == code)
|
||||
sys.stdout.write(entry['script'])
|
||||
"
|
||||
}
|
||||
|
||||
EN_SCRIPT="VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating."
|
||||
|
||||
ES_SCRIPT="VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear."
|
||||
|
||||
FR_SCRIPT="VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer."
|
||||
|
||||
ZH_SCRIPT="VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。"
|
||||
|
||||
JA_SCRIPT="VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。"
|
||||
EN_SCRIPT="$(read_script en)"
|
||||
ES_SCRIPT="$(read_script es)"
|
||||
FR_SCRIPT="$(read_script fr)"
|
||||
ZH_SCRIPT="$(read_script zh)"
|
||||
JA_SCRIPT="$(read_script ja)"
|
||||
|
||||
# ── render_lang(code, voice, text) ──────────────────────────────────────
|
||||
# Produces: $OUT_DIR/{source|dubbed_$code}.mp4 + matching .srt
|
||||
@@ -62,9 +89,16 @@ render_lang() {
|
||||
local mp4="${OUT_DIR}/${stem}.mp4"
|
||||
local srt="${OUT_DIR}/${stem}.srt"
|
||||
|
||||
say -v "$voice" -o "$aiff" "$text"
|
||||
ffmpeg -y -loglevel error -i "$aiff" -ar 44100 -ac 1 "$wav"
|
||||
rm -f "$aiff"
|
||||
local prerendered="${OUT_DIR}/${stem}.src.wav"
|
||||
AUDIO_SOURCE="$voice"
|
||||
if [ -f "$prerendered" ]; then
|
||||
AUDIO_SOURCE="omnivoice"
|
||||
ffmpeg -y -loglevel error -i "$prerendered" -ar 44100 -ac 1 "$wav"
|
||||
else
|
||||
say -v "$voice" -o "$aiff" "$text"
|
||||
ffmpeg -y -loglevel error -i "$aiff" -ar 44100 -ac 1 "$wav"
|
||||
rm -f "$aiff"
|
||||
fi
|
||||
|
||||
# Visual: showwaves p2p mode over a dark gradient with a colored line.
|
||||
# `nullsrc` + `geq` would let us make a static gradient backdrop, but
|
||||
@@ -96,9 +130,15 @@ EOF
|
||||
|
||||
local size
|
||||
size=$(du -h "$mp4" | awk '{print $1}')
|
||||
echo " ✓ ${stem}.mp4 ($size, $voice)"
|
||||
echo " ✓ ${stem}.mp4 ($size, ${AUDIO_SOURCE})"
|
||||
}
|
||||
|
||||
if [ -f "${OUT_DIR}/source.src.wav" ]; then
|
||||
RENDERED_BY="omnivoice engine + ffmpeg showwaves"
|
||||
else
|
||||
RENDERED_BY="macOS say + ffmpeg showwaves"
|
||||
fi
|
||||
|
||||
echo "── Source video (English) ────────────────────────────────"
|
||||
render_lang en Samantha "$EN_SCRIPT"
|
||||
|
||||
@@ -113,7 +153,7 @@ echo ""
|
||||
echo "── Manifest ──────────────────────────────────────────────"
|
||||
# bash 3.2 (default on macOS) lacks ${var@Q}; pass scripts as env vars to
|
||||
# Python so escaping of unicode + quotes is handled correctly.
|
||||
OUT_DIR="$OUT_DIR" \
|
||||
OUT_DIR="$OUT_DIR" RENDERED_BY="$RENDERED_BY" \
|
||||
EN_SCRIPT="$EN_SCRIPT" ES_SCRIPT="$ES_SCRIPT" FR_SCRIPT="$FR_SCRIPT" \
|
||||
ZH_SCRIPT="$ZH_SCRIPT" JA_SCRIPT="$JA_SCRIPT" \
|
||||
python3 - <<'PY'
|
||||
@@ -121,8 +161,8 @@ import json, datetime, os
|
||||
out = os.path.join(os.environ["OUT_DIR"], "manifest.json")
|
||||
manifest = {
|
||||
"version": "0.3.0",
|
||||
"rendered_by": "macOS say + ffmpeg showwaves (bootstrap)",
|
||||
"rendered_at": datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"rendered_by": os.environ.get("RENDERED_BY", "macOS say + ffmpeg showwaves"),
|
||||
"rendered_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"license": "MIT (synthetic, no third-party IP)",
|
||||
"source": {
|
||||
"code": "en", "label": "English",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"_comment": "Single source of truth for the dubbing demo. Read by BOTH scripts/build_dub_demo.sh (video + manifest) and scripts/render_dub_demo_audio.py (engine-rendered audio). They used to carry their own copies of these five paragraphs, which is one edit away from a source video whose subtitles say something else.",
|
||||
"voice_instruct": "female, young adult, moderate pitch",
|
||||
"source": {
|
||||
"code": "en",
|
||||
"label": "English",
|
||||
"language": "English",
|
||||
"say_voice": "Samantha",
|
||||
"script": "VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating."
|
||||
},
|
||||
"dubbed": [
|
||||
{
|
||||
"code": "es",
|
||||
"label": "Español",
|
||||
"language": "Spanish",
|
||||
"say_voice": "Mónica",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear."
|
||||
},
|
||||
{
|
||||
"code": "fr",
|
||||
"label": "Français",
|
||||
"language": "French",
|
||||
"say_voice": "Thomas",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer."
|
||||
},
|
||||
{
|
||||
"code": "zh",
|
||||
"label": "中文",
|
||||
"language": "Chinese",
|
||||
"say_voice": "Tingting",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。"
|
||||
},
|
||||
{
|
||||
"code": "ja",
|
||||
"label": "日本語",
|
||||
"language": "Japanese",
|
||||
"say_voice": "Kyoko",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,12 +18,17 @@ What it produces:
|
||||
* backend/assets/samples/voice_design/demo_voice_design_<slug>.wav (7)
|
||||
* Updated manifest with rendered_by="omnivoice@<git_sha>"
|
||||
|
||||
* backend/assets/samples/dictation/*.wav (3 replay scripts)
|
||||
|
||||
Not regenerated by this script:
|
||||
* backend/assets/samples/dictation/*.wav — those need to be human speech
|
||||
or human-quality TTS for the WhisperX replay path to demonstrate real
|
||||
transcription. `say` output is fine; engine TTS is overkill and slow.
|
||||
* backend/assets/demo/dubbing/*.mp4 — see scripts/build_dub_demo.sh.
|
||||
|
||||
Dictation used to be excluded here on the grounds that `say` was good enough
|
||||
and engine TTS was overkill. That reasoning only held on macOS: `say` does not
|
||||
exist on Linux or Windows, so on every other platform the samples simply were
|
||||
never rendered and the replay buttons pointed at 404s. The engine runs
|
||||
everywhere the app does, which makes it the portable answer.
|
||||
|
||||
Reproducibility:
|
||||
* seed=42 fixed across all renders so re-running the script regenerates
|
||||
the same audio byte-for-byte (modulo torch nondeterminism).
|
||||
@@ -43,6 +48,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
BACKEND_DIR = REPO_ROOT / "backend"
|
||||
SAMPLES_DIR = BACKEND_DIR / "assets" / "samples"
|
||||
VOICE_DESIGN_DIR = SAMPLES_DIR / "voice_design"
|
||||
DICTATION_DIR = SAMPLES_DIR / "dictation"
|
||||
|
||||
# Make `backend/` importable so we can pull personalities + the engine.
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
@@ -65,6 +71,37 @@ CLONE_OUTPUT_TEXT = (
|
||||
CLONE_VOICE_INSTRUCT = "female, young adult, low pitch, american accent"
|
||||
CLONE_RENDER_STEPS = 48
|
||||
|
||||
# Dictation replay scripts. `text` MUST match SCRIPTS in
|
||||
# frontend/src/components/DictationDemo.jsx verbatim — the card shows that
|
||||
# string as "what you would say" and then shows what the recogniser heard, so
|
||||
# any drift between the two reads as a transcription error.
|
||||
DICTATION_SCRIPTS = [
|
||||
{
|
||||
"id": "en_conversational",
|
||||
"language": "English",
|
||||
"instruct": "female, young adult, moderate pitch, american accent",
|
||||
"text": (
|
||||
"Schedule a meeting with Pat for Tuesday at three PM and remind me "
|
||||
"to bring the quarterly report."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "en_technical",
|
||||
"language": "English",
|
||||
"instruct": "male, young adult, moderate pitch, american accent",
|
||||
"text": (
|
||||
"Patch the WebGPU shader in renderer.tsx, then bump pnpm to nine "
|
||||
"point fifteen and rerun the Vitest suite."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "fr_reservation",
|
||||
"language": "French",
|
||||
"instruct": "female, young adult, moderate pitch",
|
||||
"text": "Bonjour, je voudrais réserver une table pour deux personnes à vingt heures.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _git_sha() -> str:
|
||||
try:
|
||||
@@ -76,6 +113,51 @@ def _git_sha() -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def watermark_file(path: Path, sample_rate: int, *, context: str) -> None:
|
||||
"""Stamp the provenance watermark into an already-written WAV (#1169).
|
||||
|
||||
These clips ship inside the app and play back to users as VoiceStudio
|
||||
output, so they are synthetic audio leaving the app exactly like a
|
||||
generated clip — they go through `mark_synthetic`, the one chokepoint
|
||||
every producing route uses. `force=True` because a render script runs
|
||||
outside the request path that carries the user's watermark preference.
|
||||
|
||||
It runs on the FILE, after loudness normalization, rather than on the
|
||||
tensor before it: `loudnorm` re-encodes what it is given, and marking
|
||||
first would put the watermark through a gain and true-peak limiter on
|
||||
its way to disk.
|
||||
"""
|
||||
import torch
|
||||
import torchaudio
|
||||
|
||||
from services.watermark import mark_synthetic
|
||||
|
||||
waveform, rate = torchaudio.load(str(path))
|
||||
marked = mark_synthetic(waveform, rate, context=context, force=True)
|
||||
if marked is waveform and os.environ.get("OMNIVOICE_DEMO_ALLOW_UNMARKED") == "1":
|
||||
print(f" ! {path.name} is NOT watermarked (OMNIVOICE_DEMO_ALLOW_UNMARKED=1)")
|
||||
return
|
||||
if marked is waveform:
|
||||
# `mark_synthetic` never raises — it degrades, so generation can't be
|
||||
# broken by watermarking. A RENDER SCRIPT is the one caller where that
|
||||
# is wrong: it exists to produce files a human then commits, and a
|
||||
# warning on a scrolling console is not a gate. Fail, so the unmarked
|
||||
# file cannot be mistaken for a finished asset.
|
||||
raise RuntimeError(
|
||||
f"{path.name} could not be watermarked, so it must not be committed. "
|
||||
"AudioSeal is missing or its weights are not cached on this machine "
|
||||
"(`uv sync --all-extras`, then re-run with the model cache warm). "
|
||||
"Set OMNIVOICE_DEMO_ALLOW_UNMARKED=1 only for a local listen — never "
|
||||
"for a render you intend to commit."
|
||||
)
|
||||
torchaudio.save(
|
||||
str(path),
|
||||
marked.to(torch.float32),
|
||||
rate,
|
||||
encoding="PCM_S", bits_per_sample=16,
|
||||
)
|
||||
|
||||
|
||||
def _save_wav(audio_tensor, sample_rate: int, out_path: Path):
|
||||
"""Save a torch tensor (C, T) or (T,) to a 16-bit PCM WAV."""
|
||||
import torch
|
||||
@@ -87,7 +169,12 @@ def _save_wav(audio_tensor, sample_rate: int, out_path: Path):
|
||||
if audio_tensor.shape[0] > 1:
|
||||
audio_tensor = audio_tensor.mean(dim=0, keepdim=True)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Normalize to safe headroom and clip — matches the `say` output level.
|
||||
# Peak-normalize for headroom only; perceived level is set by the loudness
|
||||
# pass below. Peak alone is not enough on its own: diffusion TTS emits the
|
||||
# occasional single-sample transient, and one click is all it takes to hold
|
||||
# the rest of the clip down — the Helpdesk preset landed at -30 dB RMS
|
||||
# against -17 dB for its neighbours that way, so a preview row played at
|
||||
# wildly different volumes depending on which preset you clicked.
|
||||
peak = audio_tensor.abs().max().item()
|
||||
if peak > 0:
|
||||
audio_tensor = audio_tensor / peak * 0.97
|
||||
@@ -97,6 +184,58 @@ def _save_wav(audio_tensor, sample_rate: int, out_path: Path):
|
||||
sample_rate,
|
||||
encoding="PCM_S", bits_per_sample=16,
|
||||
)
|
||||
_normalize_loudness(out_path, sample_rate)
|
||||
watermark_file(out_path, sample_rate, context=f"demo:{out_path.stem}")
|
||||
|
||||
|
||||
# Preview clips are played back to back in a picker, so they have to sit at the
|
||||
# same perceived level — which peak normalization does not give you: a clip
|
||||
# whose speech is quiet under one loud transient normalizes to the same peak as
|
||||
# a clip that is loud throughout, and plays 12 dB softer. EBU R128 measures
|
||||
# loudness rather than amplitude, and its true-peak ceiling keeps the transient
|
||||
# legal without pulling the body of the clip down with it.
|
||||
#
|
||||
# ffmpeg is already a documented prerequisite of the demo tooling (see
|
||||
# scripts/build_demos.sh). If it is missing, the raw render is still written and
|
||||
# usable — the clips are simply not level-matched, which is a cosmetic loss, not
|
||||
# a broken asset.
|
||||
_LOUDNESS_TARGET_LUFS = -18.0
|
||||
_TRUE_PEAK_CEILING_DBTP = -1.5
|
||||
|
||||
|
||||
def _normalize_loudness(path: Path, sample_rate: int) -> None:
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
if shutil.which("ffmpeg") is None:
|
||||
print(f" ! ffmpeg not found — {path.name} left un-levelled")
|
||||
return
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as handle:
|
||||
tmp = Path(handle.name)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-loglevel", "error", "-i", str(path),
|
||||
"-af",
|
||||
f"loudnorm=I={_LOUDNESS_TARGET_LUFS}:TP={_TRUE_PEAK_CEILING_DBTP}:LRA=11",
|
||||
# `loudnorm` resamples to 192 kHz internally for true-peak
|
||||
# measurement and will happily WRITE at 192 kHz if the output
|
||||
# rate is not pinned — which turned 2.1 MB of previews into
|
||||
# 17.5 MB of identical-sounding audio the first time this ran.
|
||||
"-ar", str(sample_rate),
|
||||
"-c:a", "pcm_s16le", str(tmp),
|
||||
],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0 or not tmp.exists() or tmp.stat().st_size == 0:
|
||||
print(f" ! loudnorm failed for {path.name}: {result.stderr.strip()[:120]}")
|
||||
return
|
||||
# os.replace, not shutil.move: `path` already exists, so move delegates
|
||||
# to os.rename — which raises FileExistsError on Windows and fails the
|
||||
# render there. os.replace overwrites atomically on every platform.
|
||||
os.replace(str(tmp), str(path))
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def render_cloning(model, args):
|
||||
@@ -170,12 +309,37 @@ def render_voice_design(model, args):
|
||||
print(f" ✓ {out.name} ({preset['name']})")
|
||||
|
||||
|
||||
def render_dictation(model, args):
|
||||
"""Render the three dictation replay clips.
|
||||
|
||||
The replay path posts these to /transcribe and shows the recognized text,
|
||||
so the demo works without microphone permission — on a VM, in CI, or before
|
||||
the user has granted access. That makes the clip content load-bearing: it
|
||||
has to be what the card says it is, or the demo shows a mismatch.
|
||||
"""
|
||||
print("\n── Dictation replay clips ───────────────────────────")
|
||||
sr = getattr(model, "sampling_rate", 24000)
|
||||
for script in DICTATION_SCRIPTS:
|
||||
out = DICTATION_DIR / f"{script['id']}.wav"
|
||||
if args.skip_existing and out.exists():
|
||||
print(f" · skip (exists): {out.name}")
|
||||
continue
|
||||
audios = model.generate(
|
||||
text=script["text"],
|
||||
instruct=script["instruct"],
|
||||
language=script["language"],
|
||||
num_step=32,
|
||||
)
|
||||
_save_wav(audios[0], sr, out)
|
||||
print(f" ✓ {out.name} ({script['language']})")
|
||||
|
||||
|
||||
def update_manifest(args):
|
||||
"""Update the existing manifest with rendered_by + rendered_at."""
|
||||
print("\n── Manifest ─────────────────────────────────────────")
|
||||
mpath = SAMPLES_DIR / "demo" / "manifest.json"
|
||||
mpath = SAMPLES_DIR / "demo" / "dubbing" / "manifest.json"
|
||||
if not mpath.exists():
|
||||
print(f" ! manifest not found at {mpath} — run scripts/build_demos.sh first")
|
||||
print(f" ! manifest not found at {mpath} — run scripts/build_dub_demo.sh first")
|
||||
return
|
||||
data = json.loads(mpath.read_text())
|
||||
data["rendered_by"] = f"omnivoice@{_git_sha()}"
|
||||
@@ -191,7 +355,7 @@ def main():
|
||||
help="Don't re-render files that already exist on disk.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--only", choices=["cloning", "design", "manifest"],
|
||||
"--only", choices=["cloning", "design", "dictation", "manifest"],
|
||||
help="Render only a subset (default: all).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
@@ -218,10 +382,12 @@ def main():
|
||||
render_cloning(model, args)
|
||||
if args.only in (None, "design"):
|
||||
render_voice_design(model, args)
|
||||
if args.only in (None, "dictation"):
|
||||
render_dictation(model, args)
|
||||
if args.only in (None, "manifest"):
|
||||
update_manifest(args)
|
||||
|
||||
print("\nDone. Re-run scripts/build_demos.sh to regenerate dictation samples.")
|
||||
print("\nDone. Dubbing videos are built separately — scripts/build_dub_demo.sh.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the dubbing demo's five audio tracks with the VoiceStudio engine.
|
||||
|
||||
`scripts/build_dub_demo.sh` builds the videos, subtitles and manifest, but it
|
||||
got its audio from macOS `say` — so the demo could only be built on a Mac, and
|
||||
on every other platform the Dub workspace's demo player pointed at files that
|
||||
were never generated. The engine runs wherever the app does, which makes it the
|
||||
portable answer, and it has the side benefit of the demo being rendered by the
|
||||
thing it is demonstrating.
|
||||
|
||||
Writes `source.src.wav` + `dubbed_<code>.src.wav` next to where the videos will
|
||||
be built; `build_dub_demo.sh` picks those up automatically and falls back to
|
||||
`say` when they are absent.
|
||||
|
||||
Prerequisites are the same as scripts/render_demos_omnivoice.py: the project
|
||||
venv and cached model weights.
|
||||
|
||||
Usage:
|
||||
python3 scripts/render_dub_demo_audio.py [--skip-existing]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
BACKEND_DIR = REPO_ROOT / "backend"
|
||||
# Must match OUT_DIR in build_dub_demo.sh, which must in turn sit under the
|
||||
# directory main.py mounts at /demo_audio (backend/assets/samples).
|
||||
OUT_DIR = BACKEND_DIR / "assets" / "samples" / "demo" / "dubbing"
|
||||
SCRIPTS_JSON = REPO_ROOT / "scripts" / "dub_demo_scripts.json"
|
||||
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
from render_demos_omnivoice import watermark_file # noqa: E402 — needs sys.path above
|
||||
|
||||
# The videos are built at 44.1 kHz; rendering straight to it saves the shell
|
||||
# script a resample step and keeps every track at one rate.
|
||||
VIDEO_SAMPLE_RATE = 44100
|
||||
|
||||
|
||||
def _render(model, text: str, language: str, instruct: str, out: Path, sample_rate: int) -> None:
|
||||
"""Synthesize one track and write it at the video pipeline's sample rate."""
|
||||
import torch
|
||||
import torchaudio
|
||||
|
||||
audios = model.generate(text=text, instruct=instruct, language=language, num_step=32)
|
||||
audio = audios[0]
|
||||
if audio.dim() == 1:
|
||||
audio = audio.unsqueeze(0)
|
||||
if audio.shape[0] > 1:
|
||||
audio = audio.mean(dim=0, keepdim=True)
|
||||
peak = audio.abs().max().item()
|
||||
if peak > 0:
|
||||
audio = audio / peak * 0.97
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
audio = audio.to(torch.float32).cpu()
|
||||
if sample_rate != VIDEO_SAMPLE_RATE:
|
||||
audio = torchaudio.functional.resample(audio, sample_rate, VIDEO_SAMPLE_RATE)
|
||||
torchaudio.save(
|
||||
str(out), audio, VIDEO_SAMPLE_RATE, encoding="PCM_S", bits_per_sample=16
|
||||
)
|
||||
# Level-match the tracks: a dub demo where switching language also changes
|
||||
# the volume reads as a bug in the dubbing, not in the demo assets. See
|
||||
# render_demos_omnivoice.py for why the output rate has to be pinned.
|
||||
tmp = out.with_suffix(".norm.wav")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-loglevel", "error", "-i", str(out),
|
||||
"-af", "loudnorm=I=-18:TP=-1.5:LRA=11",
|
||||
"-ar", str(VIDEO_SAMPLE_RATE), "-ac", "1",
|
||||
"-c:a", "pcm_s16le", str(tmp),
|
||||
],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode == 0 and tmp.exists() and tmp.stat().st_size:
|
||||
tmp.replace(out)
|
||||
else:
|
||||
tmp.unlink(missing_ok=True)
|
||||
print(f" ! loudnorm skipped for {out.name}: {result.stderr.strip()[:100]}")
|
||||
# These tracks are muxed into a video that ships in the app, so they carry
|
||||
# the same provenance mark as any other synthetic audio the app produces
|
||||
# (#1169). Last step, after loudnorm — see watermark_file.
|
||||
watermark_file(out, VIDEO_SAMPLE_RATE, context=f"demo:dub:{out.stem}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--skip-existing", action="store_true",
|
||||
help="Don't re-render tracks that already exist on disk.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
spec = json.loads(SCRIPTS_JSON.read_text(encoding="utf-8"))
|
||||
instruct = spec["voice_instruct"]
|
||||
tracks = [("source", spec["source"])] + [
|
||||
(f"dubbed_{entry['code']}", entry) for entry in spec["dubbed"]
|
||||
]
|
||||
|
||||
print("Loading VoiceStudio engine (this can take 30-60 s on first run)…")
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
from services.model_manager import get_model
|
||||
|
||||
model = asyncio.run(get_model())
|
||||
except Exception as exc: # noqa: BLE001 - the message is the whole point
|
||||
print(f"\nERROR: Could not load VoiceStudio engine: {exc}")
|
||||
print("Run `uv sync` first; weights download on first synthesis (~5 GB).")
|
||||
sys.exit(1)
|
||||
sample_rate = getattr(model, "sampling_rate", 24000)
|
||||
print("Engine loaded.\n")
|
||||
|
||||
for stem, entry in tracks:
|
||||
out = OUT_DIR / f"{stem}.src.wav"
|
||||
if args.skip_existing and out.exists():
|
||||
print(f" · skip (exists): {out.name}")
|
||||
continue
|
||||
_render(model, entry["script"], entry["language"], instruct, out, sample_rate)
|
||||
print(f" ✓ {out.name} ({entry['label']})")
|
||||
|
||||
print("\nDone. Now run scripts/build_dub_demo.sh to build the videos.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# `uv sync` with backoff between whole attempts.
|
||||
#
|
||||
# One dependency — en-core-web-sm — resolves to a direct GitHub *release* URL
|
||||
# rather than a package index, and github.com intermittently answers
|
||||
#
|
||||
# http2 error: stream error received: refused stream before processing any
|
||||
# application logic
|
||||
#
|
||||
# uv already retries three times, but all three land inside the same ~10
|
||||
# seconds and fail together, so the job dies on a dependency that has nothing
|
||||
# to do with the change under test. On 2026-08-12 this cost four otherwise-green
|
||||
# runs across #1515, #1517 and #1518 in one evening, on three different jobs.
|
||||
#
|
||||
# Backing off between whole attempts is what actually clears it. Every CI job
|
||||
# that syncs goes through here, because the fetch is per-job: hardening only
|
||||
# the job that happened to fail last time just moves the outage to the next one.
|
||||
#
|
||||
# Usage: bash scripts/uv-sync-retry.sh [uv sync args...]
|
||||
set -uo pipefail
|
||||
|
||||
# Three attempts, not more: uv does its OWN retrying underneath (the smoke
|
||||
# matrix sets UV_HTTP_RETRIES=5 with a 120 s timeout), so attempts here
|
||||
# MULTIPLY that budget. Three attempts plus 60 s of backoff keeps the worst
|
||||
# case comfortably inside the jobs' timeout-minutes while still outlasting the
|
||||
# refusals actually seen — which cleared within seconds.
|
||||
ATTEMPTS="${UV_SYNC_ATTEMPTS:-3}"
|
||||
BACKOFFS=(15 45)
|
||||
|
||||
for attempt in $(seq 1 "$ATTEMPTS"); do
|
||||
if uv sync "$@"; then
|
||||
[ "$attempt" -gt 1 ] && echo "uv sync succeeded on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$attempt" -eq "$ATTEMPTS" ]; then
|
||||
echo "::error::uv sync failed $ATTEMPTS times — this is not the flaky fetch" >&2
|
||||
exit 1
|
||||
fi
|
||||
delay="${BACKOFFS[$((attempt - 1))]:-90}"
|
||||
echo "::warning::uv sync failed (attempt $attempt/$ATTEMPTS) — retrying in ${delay}s"
|
||||
sleep "$delay"
|
||||
done
|
||||
exit 1
|
||||
@@ -0,0 +1,129 @@
|
||||
"""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"
|
||||
@@ -81,6 +81,13 @@ _ALLOWED_FILES = {
|
||||
# Demo-audio generation scripts (multilingual TTS sample text)
|
||||
"scripts/build_demos.sh",
|
||||
"scripts/build_dub_demo.sh",
|
||||
"scripts/dub_demo_scripts.json", # the five dub paragraphs + their native language labels
|
||||
# Rendered dubbing-demo bundle: the demo IS a dub into Chinese and
|
||||
# Japanese, so its subtitles and manifest carry that text as data. Not UI
|
||||
# strings — nothing here is translated, it is the content being shown.
|
||||
"backend/assets/samples/demo/dubbing/dubbed_zh.srt",
|
||||
"backend/assets/samples/demo/dubbing/dubbed_ja.srt",
|
||||
"backend/assets/samples/demo/dubbing/manifest.json",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -103,8 +103,16 @@ def test_ci_verifies_intel_mac_as_the_documented_remote_only_host():
|
||||
)
|
||||
assert "label: macOS Intel\n backend_supported: false" in workflow
|
||||
assert "name: Verify the documented Intel Mac contract" in workflow
|
||||
assert "if: matrix.backend_supported\n run: uv sync --extra pockettts" in workflow
|
||||
assert "if: matrix.backend_supported\n run: uv run pytest tests/smoke/" 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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user