Better voices

This commit is contained in:
Andras Schmelczer 2026-07-15 22:42:27 +01:00
parent df02ffd9c0
commit 362b96b8ee
3 changed files with 145 additions and 21 deletions

View file

@ -14,11 +14,13 @@
# bootstrap step; you supply real account credentials.
#
# Usage:
# ./render.sh # local stack, English homepage landscape + portrait
# ./render.sh --prod # prod, English homepage landscape + portrait
# ./render.sh # local stack, EVERY video (see the set below)
# ./render.sh --prod # prod, every video
# ./render.sh --target prod # same as --prod
# VIDEO_STORYBOARD_SET=ads ./render.sh --prod # render social ads instead
# VIDEO_STORYBOARD_SET=demo ./render.sh --prod # render every homepage locale
# VIDEO_STORYBOARD_SET=homepage-en ./render.sh # just the English homepage cut
# VIDEO_STORYBOARD_SET=ads ./render.sh --prod # just the social ads
# VIDEO_STORYBOARD_SET=demo ./render.sh --prod # just the homepage locales
# VIDEO_STORYBOARD_SET=twins ./render.sh --prod # just the cheaper-twin ads
# ./render.sh --fresh-auth # force re-auth even if cache is fresh
# ./render.sh --resume # preserve completed recordings and continue
# ./render.sh --no-encode # stop at WebM, skip MP4 encode
@ -37,6 +39,17 @@
set -euo pipefail
# -- storyboard set -----------------------------------------------------------
# A bare ./render.sh means "re-render the videos", so it renders all of them:
# every ad, every cheaper-twin ad, and every homepage locale in both aspects.
# It used to default to the English homepage pair alone, which left everything
# else to drift until someone remembered to set this by hand. Narrow it with
# VIDEO_STORYBOARD_SET (the sets are enumerated in src/storyboard.ts, which
# rejects an unknown name rather than guessing). Nothing here is cheap: this is
# 23 storyboards, so expect a long run and reach for a narrower set when you
# only changed one video.
export VIDEO_STORYBOARD_SET="${VIDEO_STORYBOARD_SET:-all}"
# -- target -------------------------------------------------------------------
TARGET="${TARGET:-local}"
parsed_args=()

View file

@ -92,7 +92,28 @@ const DESKTOP_MAP_ZOOM_MS = 2800;
const SHEET_DOWN = 0.12;
const SHEET_UP = 0.5;
type RecordingLocale = 'en' | 'de' | 'zh' | 'hi';
/**
* Sampling shared by every narration track, homepage recordings and ads alike.
* It lives in one place because there is no reason for the two sets to drift:
* the seed is the only knob that legitimately differs between them, so it stays
* out of here (it is per-set, and re-rolling it with CONSECUTIVE values is the
* documented escape from a rambling take).
*
* cfgWeight 1 tightens the delivery: the same cue reads ~15% shorter than at
* Chatterbox's default 0.5, and the runner sizes each cue's wall-time to the
* measured audio, so the cuts shorten to match rather than desync.
*
* These values feed synth.py's cache fingerprint, so changing any of them
* re-synthesizes every cue of every storyboard on the next render.
*/
const NARRATION_SAMPLING = {
temperature: 0.8,
exaggeration: 0.6,
cfgWeight: 1,
speedFactor: 1,
} as const;
type RecordingLocale = 'en' | 'fr' | 'de' | 'zh' | 'hi' | 'hu';
interface RecordingLocalization {
name: string;
@ -165,6 +186,38 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
outro: 'Stop paying for the name. Find the value.',
},
},
fr: {
name: 'recording-fr',
appLanguage: 'fr',
ttsLanguage: 'fr',
voice: 'French-F.wav',
promptText:
'Appartement à moins de £600k, 35 min du centre de Londres, bonnes écoles, faible criminalité, rues calmes',
travelTimeLabel: 'Centre de Londres',
exportButtonTitle: 'Exporter vers Excel',
exportConfirmLabel: 'Exporter',
closeDrawerLabel: 'Fermer le tiroir',
colourMapTitle: 'Colorer la carte',
brand: {
name: 'Perfect Postcode',
tagline: 'Trouvez le code postal sous-estimé.',
url: BRAND_URL,
},
cues: {
hook: "La réputation d'un quartier est déjà dans le prix. Sa vraie valeur, non.",
brief:
'Alors commencez par lessentiel. Budget, trajet, écoles, et même le calme de la rue.',
search:
'Une demande, et lAngleterre se réduit aux codes postaux qui valent votre argent.',
commute: 'Resserrez le trajet, et il nen reste que quelques-uns, en quelques secondes.',
streets: 'Au niveau de la rue, les meilleures adresses ressortent delles-mêmes.',
open:
'Ouvrez-en un, et il montre ses preuves. Prix de vente, écoles, criminalité, bruit, internet.',
shortlist:
'Gardez les meilleures affaires, exportez-les, et allez voir sur place.',
outro: 'Ne payez plus pour le nom. Trouvez la valeur.',
},
},
de: {
name: 'recording-de',
appLanguage: 'de',
@ -256,6 +309,43 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
outro: 'Stop paying for the name. Find the value.',
},
},
hu: {
name: 'recording-hu',
appLanguage: 'hu',
// Hungarian UI, English narration, exactly like `hi` above. Not a
// compromise we chose: the multilingual checkpoint speaks 23 languages
// and Hungarian is not one of them, so an accented English read is the
// only way to voice this cut. `en` therefore keeps it on the original
// checkpoint, and Hungarian-F.wav carries the accent.
ttsLanguage: 'en',
voice: 'Hungarian-F.wav',
// English on screen too, matching `hi`: the typed brief is read aloud by
// the English VO, so a Hungarian brief would drift from the narration.
promptText: 'Flat under £600k, 35 min to central London, good schools, low crime, quiet streets',
travelTimeLabel: 'Central London',
exportButtonTitle: 'Exportálás Excelbe',
exportConfirmLabel: 'Exportálás',
closeDrawerLabel: 'Panel bezárása',
colourMapTitle: 'Térkép színezése',
brand: {
name: 'Perfect Postcode',
tagline: 'Find the hidden-gem postcode.',
url: BRAND_URL,
},
cues: {
hook: "A postcode's reputation is priced in. Its value isn't.",
brief:
'So start with the brief. Budget, commute, schools, even how quiet the street is.',
search: 'One brief, and England narrows to the postcodes worth your money.',
commute: 'Tighten the commute, and the keepers narrow further in seconds.',
streets: 'Down at street level, the strongest streets start to stand out.',
open:
'Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.',
shortlist:
'Keep the best-value few, export them, and scout where it actually counts.',
outro: 'Stop paying for the name. Find the value.',
},
},
};
/**
@ -560,10 +650,7 @@ function createRecordingStoryboard(
voice: {
voice: copy.voice,
language: copy.ttsLanguage,
temperature: 0.8,
exaggeration: 0.5,
cfgWeight: 0.5,
speedFactor: 1,
...NARRATION_SAMPLING,
seed: 42,
},
content: {
@ -617,7 +704,7 @@ function createRecordingStoryboard(
};
}
const RECORDING_LOCALES: readonly RecordingLocale[] = ['en', 'de', 'zh', 'hi'];
const RECORDING_LOCALES: readonly RecordingLocale[] = ['en', 'fr', 'de', 'zh', 'hi', 'hu'];
const RECORDING_FORM_FACTORS: readonly FormFactor[] = ['desktop', 'mobile'];
const ENGLISH_HOMEPAGE_STORYBOARDS: Storyboard[] = RECORDING_FORM_FACTORS.map((formFactor) =>
@ -714,10 +801,7 @@ const AD_BRAND = {
const AD_VOICE = {
voice: 'Southern-M.wav',
language: 'en',
temperature: 0.8,
exaggeration: 0.5,
cfgWeight: 0.5,
speedFactor: 1,
...NARRATION_SAMPLING,
seed: 87,
};
@ -1550,6 +1634,10 @@ function createTwinConfig(pair: TwinPair): DemoAdStoryboardConfig {
const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = TWIN_PAIRS.map(createTwinConfig);
const TWIN_AD_STORYBOARDS = TWIN_AD_CONFIGS.map(createDemoAdStoryboard);
// render.sh exports `all`, so this fallback is only what a bare `node
// dist/record.js` gets. The two defaults differ on purpose: render.sh is the
// "re-render the videos" entry point, while an ad-hoc node run that surprised
// you with 23 recordings would be hostile.
const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'homepage-en';
export const storyboards: Storyboard[] = (() => {
@ -1558,13 +1646,23 @@ export const storyboards: Storyboard[] = (() => {
return ENGLISH_HOMEPAGE_STORYBOARDS;
case 'demo':
return DEMO_STORYBOARDS;
case 'all':
return [...AD_STORYBOARDS, ...DEMO_STORYBOARDS];
case 'ads':
return AD_STORYBOARDS;
case 'twins':
return TWIN_AD_STORYBOARDS;
case 'ads':
case 'all':
// Every publishable slug, twins included. `all` is what render.sh runs by
// default, so a set left out here is one that quietly stops being
// re-rendered and rots in frontend/public/video.
return [...AD_STORYBOARDS, ...TWIN_AD_STORYBOARDS, ...DEMO_STORYBOARDS];
default:
return AD_STORYBOARDS;
// Never fall back to a set. This used to default to the ads, so a typo'd
// VIDEO_STORYBOARD_SET spent a long render producing the wrong videos and
// said nothing.
throw new Error(
`Unknown VIDEO_STORYBOARD_SET "${STORYBOARD_SET}". ` +
'Known: homepage-en, demo, ads, twins, all.'
);
}
})();

View file

@ -47,6 +47,7 @@ import urllib.request
import wave
from dataclasses import dataclass
from pathlib import Path
from typing import Any
DEFAULT_SERVER = "http://host.docker.internal:8004"
@ -163,7 +164,7 @@ def _post(url: str, payload: dict, timeout: float) -> bytes:
return response.read()
def _get_json(url: str, timeout: float) -> dict:
def _get_json(url: str, timeout: float) -> Any:
with urllib.request.urlopen(url, timeout=timeout) as response:
return json.loads(response.read())
@ -438,14 +439,26 @@ def check_voice_exists(server: str, voice: str) -> None:
/tts does reject an unknown predefined voice, but this names the mistake
and lists the alternatives instead of surfacing a server stack trace.
Ask /get_predefined_voices, NOT /v1/audio/voices. The latter looks right but
answers a different, wider list (it also carries reference clips and the
bundled sample pack) and it lags behind uploads: on 2026-07-15 it omitted
French-F.wav and Hungarian-F.wav while /tts synthesized both happily. So it
fails a perfectly good voice and blames the storyboard. The filenames from
/get_predefined_voices are exactly the `predefined_voice_id` vocabulary.
"""
try:
voices = _get_json(f"{server}/v1/audio/voices", INFO_TIMEOUT_S).get("voices", [])
listed = _get_json(f"{server}/get_predefined_voices", INFO_TIMEOUT_S)
except (urllib.error.URLError, OSError, json.JSONDecodeError):
return # Non-fatal: /tts still validates the name.
voices = sorted(
str(entry["filename"])
for entry in listed
if isinstance(entry, dict) and entry.get("filename")
)
if voices and voice not in voices:
raise SystemExit(
f"[synth] unknown predefined voice {voice!r}. Available: {', '.join(sorted(voices))}"
f"[synth] unknown predefined voice {voice!r}. Available: {', '.join(voices)}"
)