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.
|
# bootstrap step; you supply real account credentials.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./render.sh # local stack, English homepage landscape + portrait
|
# ./render.sh # local stack, EVERY video (see the set below)
|
||||||
# ./render.sh --prod # prod, English homepage landscape + portrait
|
# ./render.sh --prod # prod, every video
|
||||||
# ./render.sh --target prod # same as --prod
|
# ./render.sh --target prod # same as --prod
|
||||||
# VIDEO_STORYBOARD_SET=ads ./render.sh --prod # render social ads instead
|
# VIDEO_STORYBOARD_SET=homepage-en ./render.sh # just the English homepage cut
|
||||||
# VIDEO_STORYBOARD_SET=demo ./render.sh --prod # render every homepage locale
|
# 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 --fresh-auth # force re-auth even if cache is fresh
|
||||||
# ./render.sh --resume # preserve completed recordings and continue
|
# ./render.sh --resume # preserve completed recordings and continue
|
||||||
# ./render.sh --no-encode # stop at WebM, skip MP4 encode
|
# ./render.sh --no-encode # stop at WebM, skip MP4 encode
|
||||||
|
|
@ -37,6 +39,17 @@
|
||||||
|
|
||||||
set -euo pipefail
|
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="${TARGET:-local}"
|
TARGET="${TARGET:-local}"
|
||||||
parsed_args=()
|
parsed_args=()
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,28 @@ const DESKTOP_MAP_ZOOM_MS = 2800;
|
||||||
const SHEET_DOWN = 0.12;
|
const SHEET_DOWN = 0.12;
|
||||||
const SHEET_UP = 0.5;
|
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 {
|
interface RecordingLocalization {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -165,6 +186,38 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
outro: 'Stop paying for the name. Find the value.',
|
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: {
|
de: {
|
||||||
name: 'recording-de',
|
name: 'recording-de',
|
||||||
appLanguage: 'de',
|
appLanguage: 'de',
|
||||||
|
|
@ -256,6 +309,43 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
outro: 'Stop paying for the name. Find the value.',
|
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: {
|
||||||
voice: copy.voice,
|
voice: copy.voice,
|
||||||
language: copy.ttsLanguage,
|
language: copy.ttsLanguage,
|
||||||
temperature: 0.8,
|
...NARRATION_SAMPLING,
|
||||||
exaggeration: 0.5,
|
|
||||||
cfgWeight: 0.5,
|
|
||||||
speedFactor: 1,
|
|
||||||
seed: 42,
|
seed: 42,
|
||||||
},
|
},
|
||||||
content: {
|
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 RECORDING_FORM_FACTORS: readonly FormFactor[] = ['desktop', 'mobile'];
|
||||||
|
|
||||||
const ENGLISH_HOMEPAGE_STORYBOARDS: Storyboard[] = RECORDING_FORM_FACTORS.map((formFactor) =>
|
const ENGLISH_HOMEPAGE_STORYBOARDS: Storyboard[] = RECORDING_FORM_FACTORS.map((formFactor) =>
|
||||||
|
|
@ -714,10 +801,7 @@ const AD_BRAND = {
|
||||||
const AD_VOICE = {
|
const AD_VOICE = {
|
||||||
voice: 'Southern-M.wav',
|
voice: 'Southern-M.wav',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
temperature: 0.8,
|
...NARRATION_SAMPLING,
|
||||||
exaggeration: 0.5,
|
|
||||||
cfgWeight: 0.5,
|
|
||||||
speedFactor: 1,
|
|
||||||
seed: 87,
|
seed: 87,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1550,6 +1634,10 @@ function createTwinConfig(pair: TwinPair): DemoAdStoryboardConfig {
|
||||||
const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = TWIN_PAIRS.map(createTwinConfig);
|
const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = TWIN_PAIRS.map(createTwinConfig);
|
||||||
const TWIN_AD_STORYBOARDS = TWIN_AD_CONFIGS.map(createDemoAdStoryboard);
|
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';
|
const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'homepage-en';
|
||||||
|
|
||||||
export const storyboards: Storyboard[] = (() => {
|
export const storyboards: Storyboard[] = (() => {
|
||||||
|
|
@ -1558,13 +1646,23 @@ export const storyboards: Storyboard[] = (() => {
|
||||||
return ENGLISH_HOMEPAGE_STORYBOARDS;
|
return ENGLISH_HOMEPAGE_STORYBOARDS;
|
||||||
case 'demo':
|
case 'demo':
|
||||||
return DEMO_STORYBOARDS;
|
return DEMO_STORYBOARDS;
|
||||||
case 'all':
|
case 'ads':
|
||||||
return [...AD_STORYBOARDS, ...DEMO_STORYBOARDS];
|
return AD_STORYBOARDS;
|
||||||
case 'twins':
|
case 'twins':
|
||||||
return TWIN_AD_STORYBOARDS;
|
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:
|
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
|
import wave
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
DEFAULT_SERVER = "http://host.docker.internal:8004"
|
DEFAULT_SERVER = "http://host.docker.internal:8004"
|
||||||
|
|
||||||
|
|
@ -163,7 +164,7 @@ def _post(url: str, payload: dict, timeout: float) -> bytes:
|
||||||
return response.read()
|
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:
|
with urllib.request.urlopen(url, timeout=timeout) as response:
|
||||||
return json.loads(response.read())
|
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
|
/tts does reject an unknown predefined voice, but this names the mistake
|
||||||
and lists the alternatives instead of surfacing a server stack trace.
|
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:
|
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):
|
except (urllib.error.URLError, OSError, json.JSONDecodeError):
|
||||||
return # Non-fatal: /tts still validates the name.
|
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:
|
if voices and voice not in voices:
|
||||||
raise SystemExit(
|
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