Better voices
This commit is contained in:
parent
df02ffd9c0
commit
362b96b8ee
3 changed files with 145 additions and 21 deletions
|
|
@ -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=()
|
||||
|
|
|
|||
|
|
@ -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 l’essentiel. Budget, trajet, écoles, et même le calme de la rue.',
|
||||
search:
|
||||
'Une demande, et l’Angleterre se réduit aux codes postaux qui valent votre argent.',
|
||||
commute: 'Resserrez le trajet, et il n’en reste que quelques-uns, en quelques secondes.',
|
||||
streets: 'Au niveau de la rue, les meilleures adresses ressortent d’elles-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.'
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue