feat(onboarding): refresh the bundled demo voice
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -3,10 +3,11 @@ isn't empty on initial launch. Runs once; skips silently if any
|
||||
profiles already exist.
|
||||
"""
|
||||
|
||||
import filecmp
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import logging
|
||||
|
||||
from core.db import get_db
|
||||
from core.config import VOICES_DIR
|
||||
@@ -23,16 +24,15 @@ DEMO_PROFILE_NAME = "VoiceStudio Demo Voice"
|
||||
# Must match the actual spoken content of backend/assets/samples/demo_voice.wav.
|
||||
# Regenerated by scripts/build_demos.sh — update both files in lockstep.
|
||||
DEMO_REF_TEXT = (
|
||||
"Hi, I'm the VoiceStudio demo voice. Everything you hear me say from now on "
|
||||
"was synthesized on your own machine. No cloud, no account, just you and "
|
||||
"the model."
|
||||
"Hey. I'm the VoiceStudio demo voice. I was made right here, on your "
|
||||
"machine: private, local, and ready whenever you are."
|
||||
)
|
||||
|
||||
|
||||
_DEMO_DESCRIPTION = (
|
||||
"A neutral reference voice bundled with VoiceStudio. Clone it to hear how "
|
||||
"the engine sounds on your machine, then replace it with your own "
|
||||
"recording when you're ready."
|
||||
"An original warm, low cinematic voice bundled with VoiceStudio. Clone it "
|
||||
"to hear how the engine sounds on your machine, then replace it with your "
|
||||
"own recording when you're ready."
|
||||
)
|
||||
|
||||
|
||||
@@ -43,8 +43,14 @@ def _backfill_demo_metadata(conn):
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET description=?, is_demo=1, ref_text=? "
|
||||
"WHERE id=? AND (is_demo=0 OR description='' OR ref_text!=?)",
|
||||
(_DEMO_DESCRIPTION, DEMO_REF_TEXT, DEMO_PROFILE_ID, DEMO_REF_TEXT),
|
||||
"WHERE id=? AND (is_demo=0 OR description!=? OR ref_text!=?)",
|
||||
(
|
||||
_DEMO_DESCRIPTION,
|
||||
DEMO_REF_TEXT,
|
||||
DEMO_PROFILE_ID,
|
||||
_DEMO_DESCRIPTION,
|
||||
DEMO_REF_TEXT,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
@@ -52,11 +58,34 @@ def _backfill_demo_metadata(conn):
|
||||
logger.debug("Demo backfill skipped: %s", e)
|
||||
|
||||
|
||||
def _refresh_demo_audio(conn):
|
||||
"""Keep the canonical demo profile in sync with the bundled render."""
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM voice_profiles WHERE id=? AND is_demo=1",
|
||||
(DEMO_PROFILE_ID,),
|
||||
).fetchone()
|
||||
if not row or not os.path.isfile(_DEMO_AUDIO):
|
||||
return
|
||||
os.makedirs(VOICES_DIR, exist_ok=True)
|
||||
dest = os.path.join(VOICES_DIR, f"{DEMO_PROFILE_ID}.wav")
|
||||
if not os.path.isfile(dest) or not filecmp.cmp(
|
||||
_DEMO_AUDIO, dest, shallow=False
|
||||
):
|
||||
shutil.copy2(_DEMO_AUDIO, dest)
|
||||
logger.info("Refreshed bundled demo voice audio")
|
||||
except Exception as e:
|
||||
# The demo must never make startup fail; a fresh seed below can still
|
||||
# repair it once the schema and data directory are available.
|
||||
logger.debug("Demo audio refresh skipped: %s", e)
|
||||
|
||||
|
||||
def seed_sample_project():
|
||||
"""Create the demo voice profile if no profiles exist yet."""
|
||||
conn = get_db()
|
||||
try:
|
||||
_backfill_demo_metadata(conn)
|
||||
_refresh_demo_audio(conn)
|
||||
count = conn.execute("SELECT COUNT(*) FROM voice_profiles").fetchone()[0]
|
||||
if count > 0:
|
||||
return # Not first run — skip
|
||||
|
||||
@@ -102,7 +102,7 @@ echo "── Voice cloning demo (24kHz mono 16-bit) ─────────
|
||||
# Voice: Samantha (en_US adult female, the macOS default — clean, neutral,
|
||||
# warm). Reference text from the cloning spec.
|
||||
render "Samantha" \
|
||||
"Hi, I'm the VoiceStudio demo voice. Everything you hear me say from now on was synthesized on your own machine. No cloud, no account, just you and the model." \
|
||||
"Hey. I'm the VoiceStudio demo voice. I was made right here, on your machine: private, local, and ready whenever you are." \
|
||||
"${SAMPLES_DIR}/demo_voice.wav" 24000
|
||||
|
||||
# Pre-rendered clone output — same voice, different text. Used when the user
|
||||
|
||||
@@ -51,15 +51,20 @@ sys.path.insert(0, str(BACKEND_DIR))
|
||||
# Cloning demo — must match scripts/build_demos.sh exactly so the manifest
|
||||
# stays in sync with what the bootstrap script produced.
|
||||
CLONE_REF_TEXT = (
|
||||
"Hi, I'm the VoiceStudio demo voice. Everything you hear me say from now on "
|
||||
"was synthesized on your own machine. No cloud, no account, just you and "
|
||||
"the model."
|
||||
"Hey. I'm the VoiceStudio demo voice. I was made right here, on your "
|
||||
"machine: private, local, and ready whenever you are."
|
||||
)
|
||||
CLONE_OUTPUT_TEXT = (
|
||||
"Welcome aboard. I was just a three-second clip a moment ago. Now I can "
|
||||
"say anything you'd like, in your voice or mine."
|
||||
)
|
||||
|
||||
# Original demo identity: a warm, smoky cinematic alto expressed only through
|
||||
# OmniVoice's supported taxonomy. It is deliberately not based on, trained on,
|
||||
# or named after a real performer.
|
||||
CLONE_VOICE_INSTRUCT = "female, young adult, low pitch, american accent"
|
||||
CLONE_RENDER_STEPS = 48
|
||||
|
||||
|
||||
def _git_sha() -> str:
|
||||
try:
|
||||
@@ -103,17 +108,19 @@ def render_cloning(model, args):
|
||||
"""
|
||||
print("── Cloning demo ─────────────────────────────────────")
|
||||
sr = getattr(model, "sampling_rate", 24000)
|
||||
from omnivoice.utils.common import fix_random_seed
|
||||
|
||||
# 1) Reference clip — synthesized with a "neutral female narrator"
|
||||
# instruct so the timbre is the same across re-renders.
|
||||
# 1) Reference clip — synthesized as an original cinematic alto so the
|
||||
# timbre is distinctive while remaining reproducible and rights-safe.
|
||||
out_ref = SAMPLES_DIR / "demo_voice.wav"
|
||||
if args.skip_existing and out_ref.exists():
|
||||
print(f" · skip (exists): {out_ref.name}")
|
||||
else:
|
||||
fix_random_seed(42)
|
||||
audios = model.generate(
|
||||
text=CLONE_REF_TEXT,
|
||||
instruct="female, middle-aged, moderate pitch, american accent",
|
||||
num_step=24,
|
||||
instruct=CLONE_VOICE_INSTRUCT,
|
||||
num_step=CLONE_RENDER_STEPS,
|
||||
)
|
||||
_save_wav(audios[0], sr, out_ref)
|
||||
print(f" ✓ {out_ref.name} ({sr} Hz, omnivoice)")
|
||||
@@ -125,11 +132,12 @@ def render_cloning(model, args):
|
||||
if args.skip_existing and out_clone.exists():
|
||||
print(f" · skip (exists): {out_clone.name}")
|
||||
else:
|
||||
fix_random_seed(42)
|
||||
audios = model.generate(
|
||||
text=CLONE_OUTPUT_TEXT,
|
||||
ref_audio=str(out_ref),
|
||||
ref_text=CLONE_REF_TEXT,
|
||||
num_step=24,
|
||||
num_step=CLONE_RENDER_STEPS,
|
||||
)
|
||||
_save_wav(audios[0], sr, out_clone)
|
||||
print(f" ✓ {out_clone.name} ({sr} Hz, cloned)")
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""First-run onboarding seeds the demo voice profile (#621).
|
||||
|
||||
The demo clip `backend/assets/samples/demo_voice.wav` is a build artifact that
|
||||
was never committed, so it shipped absent from installs — onboarding then logged
|
||||
"Demo audio not found" and seeded nothing, leaving an empty Launchpad. The fix
|
||||
commits the clip (it's un-ignored in .gitignore and bundled via the Tauri
|
||||
`backend` resource). These tests pin that the asset is present + valid and that
|
||||
onboarding actually seeds the demo profile from it.
|
||||
The demo reference and pre-rendered clone are build artifacts. If either ships
|
||||
absent, onboarding is empty or its no-engine preview fails. These tests pin that
|
||||
both assets are present + valid and that onboarding seeds the demo profile.
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
@@ -20,6 +17,10 @@ _WAV = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"backend", "assets", "samples", "demo_voice.wav",
|
||||
)
|
||||
_CLONE_WAV = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"backend", "assets", "samples", "demo_clone_output.wav",
|
||||
)
|
||||
|
||||
|
||||
def test_demo_clip_is_committed_and_valid():
|
||||
@@ -34,6 +35,18 @@ def test_demo_clip_is_committed_and_valid():
|
||||
assert w.getframerate() > 0
|
||||
|
||||
|
||||
def test_demo_clone_preview_is_committed_and_valid():
|
||||
"""The no-engine fallback must ship as real generated audio too."""
|
||||
assert os.path.isfile(_CLONE_WAV), (
|
||||
f"{_CLONE_WAV} is missing — regenerate with "
|
||||
"scripts/render_demos_omnivoice.py --only cloning and commit it."
|
||||
)
|
||||
with wave.open(_CLONE_WAV, "rb") as w:
|
||||
assert w.getnframes() > 0, "demo_clone_output.wav has no audio frames"
|
||||
assert w.getframerate() == 24000
|
||||
assert w.getnchannels() == 1
|
||||
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
@@ -49,16 +62,19 @@ def test_demo_clip_is_not_gitignored():
|
||||
if shutil.which("git") is None:
|
||||
import pytest
|
||||
pytest.skip("git not available")
|
||||
rel = "backend/assets/samples/demo_voice.wav"
|
||||
# `git check-ignore` exits 0 (and echoes the path) when the path IS ignored.
|
||||
res = subprocess.run(
|
||||
["git", "check-ignore", rel], cwd=_ROOT,
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert res.returncode != 0 and not res.stdout.strip(), (
|
||||
f"{rel} is gitignored — the un-ignore allowlist in .gitignore was lost. "
|
||||
"Restore `!backend/assets/samples/*.wav` or the clip drops from builds."
|
||||
)
|
||||
for rel in (
|
||||
"backend/assets/samples/demo_voice.wav",
|
||||
"backend/assets/samples/demo_clone_output.wav",
|
||||
):
|
||||
# `git check-ignore` exits 0 (and echoes the path) when the path IS ignored.
|
||||
res = subprocess.run(
|
||||
["git", "check-ignore", rel], cwd=_ROOT,
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert res.returncode != 0 and not res.stdout.strip(), (
|
||||
f"{rel} is gitignored — the un-ignore allowlist in .gitignore was lost. "
|
||||
"Restore `!backend/assets/samples/*.wav` or the clip drops from builds."
|
||||
)
|
||||
|
||||
|
||||
def test_backend_is_a_bundled_tauri_resource():
|
||||
@@ -127,3 +143,42 @@ def test_seed_is_noop_when_profiles_exist(tmp_path, monkeypatch):
|
||||
n = c.execute("SELECT COUNT(*) FROM voice_profiles").fetchone()[0]
|
||||
c.close()
|
||||
assert n == 1 # unchanged — no demo seeded on a non-empty DB
|
||||
|
||||
|
||||
def test_existing_demo_profile_receives_the_latest_bundled_voice(tmp_path, monkeypatch):
|
||||
db_path = str(tmp_path / "ob.db")
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(_table_sql())
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles (id, name, is_demo, description, ref_text) "
|
||||
"VALUES (?, ?, 1, 'old neutral description', ?)",
|
||||
(
|
||||
onboarding.DEMO_PROFILE_ID,
|
||||
onboarding.DEMO_PROFILE_NAME,
|
||||
onboarding.DEMO_REF_TEXT,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
bundled = tmp_path / "bundled.wav"
|
||||
bundled.write_bytes(b"new omnivoice demo")
|
||||
voices = tmp_path / "voices"
|
||||
voices.mkdir()
|
||||
copied = voices / f"{onboarding.DEMO_PROFILE_ID}.wav"
|
||||
copied.write_bytes(b"old demo")
|
||||
|
||||
monkeypatch.setattr(onboarding, "get_db", lambda: sqlite3.connect(db_path))
|
||||
monkeypatch.setattr(onboarding, "VOICES_DIR", str(voices))
|
||||
monkeypatch.setattr(onboarding, "_DEMO_AUDIO", str(bundled))
|
||||
|
||||
onboarding.seed_sample_project()
|
||||
|
||||
assert copied.read_bytes() == bundled.read_bytes()
|
||||
conn = sqlite3.connect(db_path)
|
||||
description = conn.execute(
|
||||
"SELECT description FROM voice_profiles WHERE id=?",
|
||||
(onboarding.DEMO_PROFILE_ID,),
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
assert description == onboarding._DEMO_DESCRIPTION
|
||||
|
||||
Reference in New Issue
Block a user