Fix voice
This commit is contained in:
parent
e9e47fd811
commit
f047e50989
9 changed files with 109 additions and 65 deletions
BIN
frontend/public/video/recording-mobile.jpg
(Stored with Git LFS)
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)
BIN
frontend/public/video/recording-mobile.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -1,26 +1,26 @@
|
|||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
00:00:50.871 --> 00:00:53.991
|
||||
00:00:43.822 --> 00:00:46.942
|
||||
Stop paying for the name. Find the value.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/recording.jpg
(Stored with Git LFS)
BIN
frontend/public/video/recording.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/recording.mp4
(Stored with Git LFS)
BIN
frontend/public/video/recording.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -1,26 +1,26 @@
|
|||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
00:00:46.861 --> 00:00:49.981
|
||||
00:00:44.190 --> 00:00:47.310
|
||||
Stop paying for the name. Find the value.
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const ROUTES = [
|
|||
output: 'pricing/index.html',
|
||||
title: 'Perfect Postcode pricing - Lifetime property search map access',
|
||||
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',
|
||||
|
|
@ -105,19 +105,19 @@ const ROUTES = [
|
|||
output: 'privacy-security/index.html',
|
||||
title: 'Perfect Postcode privacy and security - Saved searches and account data',
|
||||
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',
|
||||
output: 'terms/index.html',
|
||||
title: 'Terms of Service | Perfect Postcode',
|
||||
title: 'Terms of service - Perfect Postcode',
|
||||
description:
|
||||
'The terms that govern your use of Perfect Postcode, including lifetime access, acceptable use, data accuracy, payments and refunds.',
|
||||
},
|
||||
{
|
||||
path: '/privacy',
|
||||
output: 'privacy/index.html',
|
||||
title: 'Privacy Policy | Perfect Postcode',
|
||||
title: 'Privacy policy - Perfect Postcode',
|
||||
description:
|
||||
'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?',
|
||||
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?',
|
||||
|
|
@ -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.',
|
||||
},
|
||||
{
|
||||
question: 'Should I use this before or after checking Rightmove?',
|
||||
question: 'Does Perfect Postcode replace Rightmove, Zoopla and OnTheMarket?',
|
||||
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.',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ type FormFactor = 'desktop' | 'mobile';
|
|||
* real data). On 9:16 cuts the bottom sheet gets dragged out of the way
|
||||
* whenever the map is the story.
|
||||
* 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.
|
||||
* 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
|
||||
// cap of 20 emptied the city centre and left the zoom with nothing
|
||||
// to land on.
|
||||
'Serious crime (avg/yr)': [0, 40],
|
||||
'Serious crime (/yr, 7y)': [0, 40],
|
||||
[SCHOOL_GOOD_PRIMARY]: [1, 10],
|
||||
'Noise (dB)': [0, 65],
|
||||
// ≥30 Mbps, not ≥100: FTTP coverage gaps make a 100 floor empty
|
||||
|
|
@ -888,7 +888,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
filters: {
|
||||
'Property type': ['Flats/Maisonettes'],
|
||||
'Estimated current price': [0, 600000],
|
||||
'Serious crime (avg/yr)': [0, 35],
|
||||
'Serious crime (/yr, 7y)': [0, 35],
|
||||
'Noise (dB)': [0, 60],
|
||||
},
|
||||
travelTimeFilters: [
|
||||
|
|
@ -1038,7 +1038,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
promptText: 'Quiet London street under 55 decibels, low crime',
|
||||
filters: {
|
||||
'Noise (dB)': [0, 55],
|
||||
'Serious crime (avg/yr)': [0, 35],
|
||||
'Serious crime (/yr, 7y)': [0, 35],
|
||||
},
|
||||
initialZoom: 10.6,
|
||||
posterTimeS: 7,
|
||||
|
|
@ -1076,7 +1076,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
filters: {
|
||||
'Estimated current price': [0, 350000],
|
||||
[SCHOOL_GOOD_PRIMARY]: [2, 10],
|
||||
'Serious crime (avg/yr)': [0, 30],
|
||||
'Serious crime (/yr, 7y)': [0, 30],
|
||||
},
|
||||
initialZoom: 11.0,
|
||||
posterTimeS: 13,
|
||||
|
|
@ -1194,7 +1194,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
promptText: 'Under £500k, 35 min to central London, low crime, good schools',
|
||||
filters: {
|
||||
'Estimated current price': [0, 500000],
|
||||
'Serious crime (avg/yr)': [0, 35],
|
||||
'Serious crime (/yr, 7y)': [0, 35],
|
||||
[SCHOOL_GOOD_PRIMARY]: [1, 10],
|
||||
},
|
||||
travelTimeFilters: [
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ Run from the ``video/`` directory:
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
|
|
@ -64,6 +65,25 @@ def _safe_load_json(path: Path) -> object | 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:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
|
|
@ -120,6 +140,7 @@ def cached_index_matches(
|
|||
design_model: str,
|
||||
clone_model: str,
|
||||
reference_audio: str,
|
||||
reference_hash: str,
|
||||
seed: int,
|
||||
temperature: 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.
|
||||
|
||||
Compared fields: ``cueIndex``, ``text``, ``gapBeforeMs`` plus the synth
|
||||
settings (``instruct``, ``language``, reference text, models, ``seed``,
|
||||
``temperature``, ``top_p``).
|
||||
settings (``instruct``, ``language``, reference text, models, the hash of
|
||||
the reference WAV the cues were cloned from, ``seed``, ``temperature``,
|
||||
``top_p``).
|
||||
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():
|
||||
return False
|
||||
|
|
@ -146,6 +168,8 @@ def cached_index_matches(
|
|||
return False
|
||||
if cached.get("referenceAudio", "") != reference_audio:
|
||||
return False
|
||||
if cached.get("referenceHash", "") != reference_hash:
|
||||
return False
|
||||
if int(cached.get("seed", -1)) != seed:
|
||||
return False
|
||||
if float(cached.get("temperature", -1)) != temperature:
|
||||
|
|
@ -177,6 +201,7 @@ def load_reusable_items(
|
|||
design_model: str,
|
||||
clone_model: str,
|
||||
reference_audio: str,
|
||||
reference_hash: str,
|
||||
seed: int,
|
||||
temperature: float,
|
||||
top_p: float,
|
||||
|
|
@ -184,7 +209,10 @@ def load_reusable_items(
|
|||
"""Return cue-indexed cached items that match the current synth settings.
|
||||
|
||||
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():
|
||||
return {}
|
||||
|
|
@ -200,6 +228,8 @@ def load_reusable_items(
|
|||
return {}
|
||||
if cached.get("referenceAudio", "") != reference_audio:
|
||||
return {}
|
||||
if cached.get("referenceHash", "") != reference_hash:
|
||||
return {}
|
||||
if int(cached.get("seed", -1)) != seed:
|
||||
return {}
|
||||
if float(cached.get("temperature", -1)) != temperature:
|
||||
|
|
@ -343,13 +373,44 @@ def main() -> int:
|
|||
|
||||
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
|
||||
# texts and same gapBeforeMs values in the same order, AND same synth
|
||||
# settings (instruct/language/reference/model/seed/temperature/top_p).
|
||||
# Saves ~30s of GPU time when iterating on activity timing without
|
||||
# changing narration or persona.
|
||||
# settings (instruct/language/reference text + reference-WAV hash/model/
|
||||
# seed/temperature/top_p). Saves ~30s of GPU time when iterating on
|
||||
# activity timing without changing narration or persona.
|
||||
if cached_index_matches(
|
||||
audio_dir / "index.json",
|
||||
index_path,
|
||||
cues,
|
||||
instruct,
|
||||
language,
|
||||
|
|
@ -357,6 +418,7 @@ def main() -> int:
|
|||
args.design_model,
|
||||
args.clone_model,
|
||||
reference_audio_cache_key,
|
||||
reference_hash,
|
||||
seed,
|
||||
temperature,
|
||||
top_p,
|
||||
|
|
@ -368,25 +430,6 @@ def main() -> int:
|
|||
return 0
|
||||
|
||||
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(
|
||||
f"[synth] cloning {len(texts)} cues from reference (x_vector_only)",
|
||||
flush=True,
|
||||
|
|
@ -403,11 +446,11 @@ def main() -> int:
|
|||
"cloneModel": args.clone_model,
|
||||
"referenceAudio": reference_audio_cache_key,
|
||||
"referenceText": ref_text,
|
||||
"referenceHash": reference_hash,
|
||||
"seed": seed,
|
||||
"temperature": temperature,
|
||||
"topP": top_p,
|
||||
}
|
||||
index_path = audio_dir / "index.json"
|
||||
reusable = load_reusable_items(
|
||||
index_path,
|
||||
cues,
|
||||
|
|
@ -417,6 +460,7 @@ def main() -> int:
|
|||
args.design_model,
|
||||
args.clone_model,
|
||||
reference_audio_cache_key,
|
||||
reference_hash,
|
||||
seed,
|
||||
temperature,
|
||||
top_p,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue