Fix voice

This commit is contained in:
Andras Schmelczer 2026-06-27 10:44:57 +01:00
parent e9e47fd811
commit f047e50989
9 changed files with 109 additions and 65 deletions

BIN
frontend/public/video/recording-mobile.jpg (Stored with Git LFS)

Binary file not shown.

BIN
frontend/public/video/recording-mobile.mp4 (Stored with Git LFS)

Binary file not shown.

View file

@ -1,26 +1,26 @@
WEBVTT WEBVTT
00:00:00.240 --> 00:00:05.200 00:00:00.263 --> 00:00:04.663
A postcode's reputation is priced in. Its value isn't. A postcode's reputation is priced in. Its value isn't.
00:00:05.550 --> 00:00:12.110 00:00:05.013 --> 00:00:11.413
So start with the brief. Budget, commute, schools, even how quiet the street is. So start with the brief. Budget, commute, schools, even how quiet the street is.
00:00:12.480 --> 00:00:17.200 00:00:11.783 --> 00:00:16.023
One brief, and England narrows to the postcodes worth your money. One brief, and England narrows to the postcodes worth your money.
00:00:17.750 --> 00:00:22.630 00:00:16.573 --> 00:00:20.813
Tighten the commute, and the keepers narrow further in seconds. Tighten the commute, and the keepers narrow further in seconds.
00:00:23.230 --> 00:00:29.150 00:00:21.413 --> 00:00:25.733
Down at street level, the strongest streets start to stand out. Down at street level, the strongest streets start to stand out.
00:00:29.600 --> 00:00:37.920 00:00:26.418 --> 00:00:35.218
Open one, and it shows its work. Sold prices, schools, crime, noise, broadband. Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.
00:00:44.031 --> 00:00:49.871 00:00:37.418 --> 00:00:42.698
Keep the best-value few, export them, and scout where it actually counts. Keep the best-value few, export them, and scout where it actually counts.
00:00:50.871 --> 00:00:53.991 00:00:43.822 --> 00:00:46.942
Stop paying for the name. Find the value. Stop paying for the name. Find the value.

BIN
frontend/public/video/recording.jpg (Stored with Git LFS)

Binary file not shown.

BIN
frontend/public/video/recording.mp4 (Stored with Git LFS)

Binary file not shown.

View file

@ -1,26 +1,26 @@
WEBVTT WEBVTT
00:00:00.241 --> 00:00:05.201 00:00:00.249 --> 00:00:04.649
A postcode's reputation is priced in. Its value isn't. A postcode's reputation is priced in. Its value isn't.
00:00:05.551 --> 00:00:12.111 00:00:04.999 --> 00:00:11.399
So start with the brief. Budget, commute, schools, even how quiet the street is. So start with the brief. Budget, commute, schools, even how quiet the street is.
00:00:12.481 --> 00:00:17.201 00:00:11.769 --> 00:00:16.009
One brief, and England narrows to the postcodes worth your money. One brief, and England narrows to the postcodes worth your money.
00:00:17.751 --> 00:00:22.631 00:00:16.559 --> 00:00:20.799
Tighten the commute, and the keepers narrow further in seconds. Tighten the commute, and the keepers narrow further in seconds.
00:00:23.231 --> 00:00:29.151 00:00:21.399 --> 00:00:25.719
Down at street level, the strongest streets start to stand out. Down at street level, the strongest streets start to stand out.
00:00:29.601 --> 00:00:37.921 00:00:27.010 --> 00:00:35.810
Open one, and it shows its work. Sold prices, schools, crime, noise, broadband. Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.
00:00:40.021 --> 00:00:45.861 00:00:37.910 --> 00:00:43.190
Keep the best-value few, export them, and scout where it actually counts. Keep the best-value few, export them, and scout where it actually counts.
00:00:46.861 --> 00:00:49.981 00:00:44.190 --> 00:00:47.310
Stop paying for the name. Find the value. Stop paying for the name. Find the value.

View file

@ -28,7 +28,7 @@ const ROUTES = [
output: 'pricing/index.html', output: 'pricing/index.html',
title: 'Perfect Postcode pricing - Lifetime property search map access', title: 'Perfect Postcode pricing - Lifetime property search map access',
description: description:
'Get lifetime access to the postcode property search map for England, including filters, saved searches, exports, and future data updates.', 'Get lifetime access to the postcode property search map for England, including filters, saved searches, exports and future data updates.',
}, },
{ {
path: '/property-price-map', path: '/property-price-map',
@ -105,19 +105,19 @@ const ROUTES = [
output: 'privacy-security/index.html', output: 'privacy-security/index.html',
title: 'Perfect Postcode privacy and security - Saved searches and account data', title: 'Perfect Postcode privacy and security - Saved searches and account data',
description: description:
'Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.', 'Learn how Perfect Postcode keeps your saved searches and account data private and secure, and what it does and does not store.',
}, },
{ {
path: '/terms', path: '/terms',
output: 'terms/index.html', output: 'terms/index.html',
title: 'Terms of Service | Perfect Postcode', title: 'Terms of service - Perfect Postcode',
description: description:
'The terms that govern your use of Perfect Postcode, including lifetime access, acceptable use, data accuracy, payments and refunds.', 'The terms that govern your use of Perfect Postcode, including lifetime access, acceptable use, data accuracy, payments and refunds.',
}, },
{ {
path: '/privacy', path: '/privacy',
output: 'privacy/index.html', output: 'privacy/index.html',
title: 'Privacy Policy | Perfect Postcode', title: 'Privacy policy - Perfect Postcode',
description: description:
'How Perfect Postcode collects, uses and protects your data: account details, payments, saved searches, AI queries, analytics and your UK GDPR rights.', 'How Perfect Postcode collects, uses and protects your data: account details, payments, saved searches, AI queries, analytics and your UK GDPR rights.',
}, },
@ -132,7 +132,7 @@ const FAQ_SCHEMA_ITEMS = [
{ {
question: 'What should I do when my search returns too many or too few areas?', question: 'What should I do when my search returns too many or too few areas?',
answer: answer:
'Start with hard limits, then colour the map by a trade-off such as price per sqm, road noise, school score, or commute time. If the map gets too narrow, relax one slider and you can see exactly which compromise opens up more options.', 'Start with hard limits, then colour the map by a trade-off such as price per sqm, road noise, school score, or commute time. If the map gets too narrow, relax one slider to see exactly which compromise opens up more options.',
}, },
{ {
question: 'How are the travel times calculated?', question: 'How are the travel times calculated?',
@ -145,9 +145,9 @@ const FAQ_SCHEMA_ITEMS = [
'The estimate starts with the last HM Land Registry sale price, adjusts it to current-market terms using repeat-sales modelling and fallback models, then blends that result with a nearest-neighbour estimate from nearby, recently sold, same-type homes.', 'The estimate starts with the last HM Land Registry sale price, adjusts it to current-market terms using repeat-sales modelling and fallback models, then blends that result with a nearest-neighbour estimate from nearby, recently sold, same-type homes.',
}, },
{ {
question: 'Should I use this before or after checking Rightmove?', question: 'Does Perfect Postcode replace Rightmove, Zoopla and OnTheMarket?',
answer: answer:
'Use Perfect Postcode before and alongside listing portals. Rightmove, Zoopla, and OnTheMarket are still where you check live availability, photos, agent contact, viewings, and alerts.', 'No. Perfect Postcode helps you choose the right postcode from area data; the listing portals are still where you check live availability, photos, agent contact, viewings and alerts.',
}, },
]; ];

View file

@ -28,7 +28,7 @@ type FormFactor = 'desktop' | 'mobile';
* real data). On 9:16 cuts the bottom sheet gets dragged out of the way * real data). On 9:16 cuts the bottom sheet gets dragged out of the way
* whenever the map is the story. * whenever the map is the story.
* 4. Filter names MUST match live /api/features exactly (e.g. * 4. Filter names MUST match live /api/features exactly (e.g.
* "Serious crime (avg/yr)", "Distance to nearest amenity (Waitrose) * "Serious crime (/yr, 7y)", "Distance to nearest amenity (Waitrose)
* (km)") wrong names silently no-op and the map never changes. * (km)") wrong names silently no-op and the map never changes.
* preflight.ts validates every stubbed name against the live API. * preflight.ts validates every stubbed name against the live API.
* *
@ -592,7 +592,7 @@ function createRecordingStoryboard(
// Loose enough to keep the central-London map richly populated — a // Loose enough to keep the central-London map richly populated — a
// cap of 20 emptied the city centre and left the zoom with nothing // cap of 20 emptied the city centre and left the zoom with nothing
// to land on. // to land on.
'Serious crime (avg/yr)': [0, 40], 'Serious crime (/yr, 7y)': [0, 40],
[SCHOOL_GOOD_PRIMARY]: [1, 10], [SCHOOL_GOOD_PRIMARY]: [1, 10],
'Noise (dB)': [0, 65], 'Noise (dB)': [0, 65],
// ≥30 Mbps, not ≥100: FTTP coverage gaps make a 100 floor empty // ≥30 Mbps, not ≥100: FTTP coverage gaps make a 100 floor empty
@ -888,7 +888,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
filters: { filters: {
'Property type': ['Flats/Maisonettes'], 'Property type': ['Flats/Maisonettes'],
'Estimated current price': [0, 600000], 'Estimated current price': [0, 600000],
'Serious crime (avg/yr)': [0, 35], 'Serious crime (/yr, 7y)': [0, 35],
'Noise (dB)': [0, 60], 'Noise (dB)': [0, 60],
}, },
travelTimeFilters: [ travelTimeFilters: [
@ -1038,7 +1038,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
promptText: 'Quiet London street under 55 decibels, low crime', promptText: 'Quiet London street under 55 decibels, low crime',
filters: { filters: {
'Noise (dB)': [0, 55], 'Noise (dB)': [0, 55],
'Serious crime (avg/yr)': [0, 35], 'Serious crime (/yr, 7y)': [0, 35],
}, },
initialZoom: 10.6, initialZoom: 10.6,
posterTimeS: 7, posterTimeS: 7,
@ -1076,7 +1076,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
filters: { filters: {
'Estimated current price': [0, 350000], 'Estimated current price': [0, 350000],
[SCHOOL_GOOD_PRIMARY]: [2, 10], [SCHOOL_GOOD_PRIMARY]: [2, 10],
'Serious crime (avg/yr)': [0, 30], 'Serious crime (/yr, 7y)': [0, 30],
}, },
initialZoom: 11.0, initialZoom: 11.0,
posterTimeS: 13, posterTimeS: 13,
@ -1194,7 +1194,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
promptText: 'Under £500k, 35 min to central London, low crime, good schools', promptText: 'Under £500k, 35 min to central London, low crime, good schools',
filters: { filters: {
'Estimated current price': [0, 500000], 'Estimated current price': [0, 500000],
'Serious crime (avg/yr)': [0, 35], 'Serious crime (/yr, 7y)': [0, 35],
[SCHOOL_GOOD_PRIMARY]: [1, 10], [SCHOOL_GOOD_PRIMARY]: [1, 10],
}, },
travelTimeFilters: [ travelTimeFilters: [

View file

@ -28,6 +28,7 @@ Run from the ``video/`` directory:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib
import json import json
import os import os
import random import random
@ -64,6 +65,25 @@ def _safe_load_json(path: Path) -> object | None:
return None return None
def _file_sha256(path: Path) -> str:
"""Content hash of a file, used to pin cached cues to a reference WAV.
The cue cache keys off the *settings* that produced the reference
(instruct/seed/), but a re-mint of VoiceDesign or render.sh copying a
different storyboard's reference into this audio dir — can swap the actual
reference waveform out from under those settings. Cloning some cues from
reference A and others from reference B yields two audibly different
speakers in one video. Hashing the bytes of the reference that was
actually used closes that gap: any change to the reference invalidates
every cue, so all cues in a render share one timbre.
"""
h = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument( parser.add_argument(
@ -120,6 +140,7 @@ def cached_index_matches(
design_model: str, design_model: str,
clone_model: str, clone_model: str,
reference_audio: str, reference_audio: str,
reference_hash: str,
seed: int, seed: int,
temperature: float, temperature: float,
top_p: float, top_p: float,
@ -127,10 +148,11 @@ def cached_index_matches(
"""Return True iff index_path's cue list lines up with `cues` 1:1. """Return True iff index_path's cue list lines up with `cues` 1:1.
Compared fields: ``cueIndex``, ``text``, ``gapBeforeMs`` plus the synth Compared fields: ``cueIndex``, ``text``, ``gapBeforeMs`` plus the synth
settings (``instruct``, ``language``, reference text, models, ``seed``, settings (``instruct``, ``language``, reference text, models, the hash of
``temperature``, ``top_p``). the reference WAV the cues were cloned from, ``seed``, ``temperature``,
``top_p``).
All cue WAV files must also exist on disk. Mismatched length, reordered All cue WAV files must also exist on disk. Mismatched length, reordered
cues, or a missing WAV invalidate the cache. cues, a swapped reference waveform, or a missing WAV invalidate the cache.
""" """
if not index_path.exists(): if not index_path.exists():
return False return False
@ -146,6 +168,8 @@ def cached_index_matches(
return False return False
if cached.get("referenceAudio", "") != reference_audio: if cached.get("referenceAudio", "") != reference_audio:
return False return False
if cached.get("referenceHash", "") != reference_hash:
return False
if int(cached.get("seed", -1)) != seed: if int(cached.get("seed", -1)) != seed:
return False return False
if float(cached.get("temperature", -1)) != temperature: if float(cached.get("temperature", -1)) != temperature:
@ -177,6 +201,7 @@ def load_reusable_items(
design_model: str, design_model: str,
clone_model: str, clone_model: str,
reference_audio: str, reference_audio: str,
reference_hash: str,
seed: int, seed: int,
temperature: float, temperature: float,
top_p: float, top_p: float,
@ -184,7 +209,10 @@ def load_reusable_items(
"""Return cue-indexed cached items that match the current synth settings. """Return cue-indexed cached items that match the current synth settings.
Unlike ``cached_index_matches`` this accepts a partial index, so a long Unlike ``cached_index_matches`` this accepts a partial index, so a long
CPU synthesis run can be resumed cue-by-cue after an interruption. CPU synthesis run can be resumed cue-by-cue after an interruption. The
reference-hash gate is what stops a re-mint from leaving some cues cloned
from the previous reference (a second voice) while only the edited cues
regenerate from the new one.
""" """
if not index_path.exists(): if not index_path.exists():
return {} return {}
@ -200,6 +228,8 @@ def load_reusable_items(
return {} return {}
if cached.get("referenceAudio", "") != reference_audio: if cached.get("referenceAudio", "") != reference_audio:
return {} return {}
if cached.get("referenceHash", "") != reference_hash:
return {}
if int(cached.get("seed", -1)) != seed: if int(cached.get("seed", -1)) != seed:
return {} return {}
if float(cached.get("temperature", -1)) != temperature: if float(cached.get("temperature", -1)) != temperature:
@ -343,13 +373,44 @@ def main() -> int:
audio_dir.mkdir(parents=True, exist_ok=True) audio_dir.mkdir(parents=True, exist_ok=True)
print(f"[synth] [{args.storyboard}] persona: {instruct}", flush=True)
print(
f"[synth] [{args.storyboard}] sampling: temperature={temperature} top_p={top_p} seed={seed} language={language}",
flush=True,
)
# Resolve the reference FIRST, before the cache check, so every downstream
# decision keys off the exact bytes the cues are cloned from. Two-stage
# generation:
# 1. VoiceDesign mints a single reference clip in the target persona
# (or the user supplies one via --reference-audio).
# 2. Base + generate_voice_clone(x_vector_only_mode=True) conditions
# every cue on the reference's speaker embedding.
# Without (2), batched generation drifts timbre across cues — a persona
# prompt anchors style but not identity, so each batch item picks its
# own voice. The reference WAV is cached so subsequent runs only load
# the clone model (saves ~20s + 3.4 GB of disk download); when it is
# cached, _resolve_reference returns without loading any model, so doing
# this ahead of the skip check is cheap.
ref_wav_path, ref_text = _resolve_reference(
args, audio_dir, instruct, language, reference_text, seed, temperature, top_p
)
# Pin the cue cache to this exact reference waveform. A re-mint (or a
# reference copied in from another storyboard by render.sh) changes these
# bytes; without this gate the cue cache would keep cloned-from-the-old-
# reference WAVs alongside freshly regenerated ones — two voices in one
# video. See _file_sha256.
reference_hash = _file_sha256(ref_wav_path)
index_path = audio_dir / "index.json"
# Skip generation when the existing audio matches the script — same cue # Skip generation when the existing audio matches the script — same cue
# texts and same gapBeforeMs values in the same order, AND same synth # texts and same gapBeforeMs values in the same order, AND same synth
# settings (instruct/language/reference/model/seed/temperature/top_p). # settings (instruct/language/reference text + reference-WAV hash/model/
# Saves ~30s of GPU time when iterating on activity timing without # seed/temperature/top_p). Saves ~30s of GPU time when iterating on
# changing narration or persona. # activity timing without changing narration or persona.
if cached_index_matches( if cached_index_matches(
audio_dir / "index.json", index_path,
cues, cues,
instruct, instruct,
language, language,
@ -357,6 +418,7 @@ def main() -> int:
args.design_model, args.design_model,
args.clone_model, args.clone_model,
reference_audio_cache_key, reference_audio_cache_key,
reference_hash,
seed, seed,
temperature, temperature,
top_p, top_p,
@ -368,25 +430,6 @@ def main() -> int:
return 0 return 0
texts = [c["text"].strip() for c in cues] texts = [c["text"].strip() for c in cues]
print(f"[synth] [{args.storyboard}] persona: {instruct}", flush=True)
print(
f"[synth] [{args.storyboard}] sampling: temperature={temperature} top_p={top_p} seed={seed} language={language}",
flush=True,
)
# Two-stage generation:
# 1. VoiceDesign mints a single reference clip in the target persona
# (or the user supplies one via --reference-audio).
# 2. Base + generate_voice_clone(x_vector_only_mode=True) conditions
# every cue on the reference's speaker embedding.
# Without (2), batched generation drifts timbre across cues — a persona
# prompt anchors style but not identity, so each batch item picks its
# own voice. The reference WAV is cached so subsequent runs only load
# the clone model (saves ~20s + 3.4 GB of disk download).
ref_wav_path, ref_text = _resolve_reference(
args, audio_dir, instruct, language, reference_text, seed, temperature, top_p
)
print( print(
f"[synth] cloning {len(texts)} cues from reference (x_vector_only)", f"[synth] cloning {len(texts)} cues from reference (x_vector_only)",
flush=True, flush=True,
@ -403,11 +446,11 @@ def main() -> int:
"cloneModel": args.clone_model, "cloneModel": args.clone_model,
"referenceAudio": reference_audio_cache_key, "referenceAudio": reference_audio_cache_key,
"referenceText": ref_text, "referenceText": ref_text,
"referenceHash": reference_hash,
"seed": seed, "seed": seed,
"temperature": temperature, "temperature": temperature,
"topP": top_p, "topP": top_p,
} }
index_path = audio_dir / "index.json"
reusable = load_reusable_items( reusable = load_reusable_items(
index_path, index_path,
cues, cues,
@ -417,6 +460,7 @@ def main() -> int:
args.design_model, args.design_model,
args.clone_model, args.clone_model,
reference_audio_cache_key, reference_audio_cache_key,
reference_hash,
seed, seed,
temperature, temperature,
top_p, top_p,