Compare commits

...

2 commits

Author SHA1 Message Date
f1601257c7 Language and logout bug
Some checks failed
CI / Check (push) Failing after 6m56s
Build and publish Docker image / build-and-push (push) Successful in 7m26s
2026-06-27 10:45:48 +01:00
f047e50989 Fix voice 2026-06-27 10:44:57 +01:00
21 changed files with 396 additions and 187 deletions

1
.gitignore vendored
View file

@ -27,3 +27,4 @@ video/auth.*
r5-java/tmp
property-data
property-data-snapshot
property-data-snapshot2

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
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)

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
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.

View file

@ -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.',
},
];

View file

@ -759,6 +759,11 @@ export default function App() {
initialTravelTime={mapUrlState.travelTime}
initialPostcode={mapUrlState.postcode}
shareCode={mapUrlState.share}
onSessionRejected={() => {
// Server rejected our session mid-use: re-validate. If it's gone, refreshAuth
// clears it, the user drops, the cap falls, and filters re-clamp (useFilters).
refreshAuth().catch(() => {});
}}
user={user}
onLoginClick={() => openAuthModal('login')}
onRegisterClick={() => openAuthModal('register')}

View file

@ -2,8 +2,6 @@
* Legal documents are maintained in English only; the English text is the
* authoritative version (a localized notice says so on the page). Keeping
* legal copy out of the i18n catalogues avoids meaning drift in translation.
*
* TODO before launch: confirm the operator/legal-entity details below.
*/
export interface LegalSection {
@ -28,7 +26,7 @@ export const TERMS: LegalDoc = {
'The terms that govern your use of Perfect Postcode, including lifetime access, acceptable use, data accuracy, payments and refunds.',
lastUpdated: '10 June 2026',
intro: [
`These terms govern your use of perfect-postcode.co.uk ("Perfect Postcode", "the service", "we", "us"). By creating an account or purchasing access you agree to them. If you have any questions, contact ${SUPPORT_EMAIL}.`,
`These terms govern your use of perfect-postcode.co.uk (“Perfect Postcode”, “the service”, “we”, “us”), a service operated by András Schmelczer as a sole trader. By creating an account or purchasing access you agree to them. If you have any questions, contact ${SUPPORT_EMAIL}; a postal address for service is available on request.`,
],
sections: [
{
@ -48,7 +46,7 @@ export const TERMS: LegalDoc = {
{
heading: '3. Free access and lifetime access',
paragraphs: [
'Free users can explore the full map of England with every feature, applying up to three filters at a time. Lifetime access is a one-time payment that removes the filter limit and gives your account ongoing access — every postcode, every filter, unlimited — for as long as the service runs. It is not a subscription, and routine data updates are included.',
'Free users can explore the full map of England with every feature, applying up to three filters at a time. Lifetime access is a one-time payment that removes the filter limit and gives your account unlimited access to every postcode and filter for as long as the service runs. It is not a subscription, and routine data updates are included.',
'Lifetime access is personal and non-transferable, and is for personal, non-commercial property research. If you would like to use Perfect Postcode commercially (for example in lettings, relocation or research services), contact us first.',
],
},
@ -66,13 +64,13 @@ export const TERMS: LegalDoc = {
heading: '5. Data accuracy',
paragraphs: [
'The maps and figures are built from public datasets (HM Land Registry, EPC register, ONS, Ofsted, DfT, police.uk and others) combined with modelling and estimation. Sources can be incomplete, out of date or wrong at the level of an individual property, and our estimates — including estimated current prices — are statistical indications, not valuations.',
'Always verify anything that matters in person and through professional advice (surveys, solicitors, mortgage advisers) before making offers or financial decisions. We provide the service "as is" and do not warrant that any figure is accurate, complete or current.',
'Always verify anything that matters in person and through professional advice (surveys, solicitors, mortgage advisers) before making offers or financial decisions. We provide the service “as is” and do not warrant that any figure is accurate, complete or current.',
],
},
{
heading: '6. Payments and refunds',
paragraphs: [
'Payments are processed by Stripe; we never see or store your card details. Prices are shown in pounds sterling at checkout. Early-access pricing tiers can change as tiers fill; the price shown at the moment you pay is the price you get.',
'Payments are processed by Stripe; we never see or store your card details. Prices are shown in pounds sterling at checkout. Early-access prices can change as tiers fill; the price shown at the moment you pay is the price you get.',
`If Perfect Postcode is not for you, email ${SUPPORT_EMAIL} within 14 days of purchase and we will refund you in full.`,
],
},
@ -91,7 +89,7 @@ export const TERMS: LegalDoc = {
{
heading: '9. Changes to the service or these terms',
paragraphs: [
'We are a small product that improves continuously; features and data sources may change. We may update these terms, and will note the date of the latest revision above. If a change is material we will flag it on the site or by email. Continued use after a change means you accept the updated terms.',
'Perfect Postcode is a small product, improved continually; features and data sources may change. We may update these terms, and will note the date of the latest revision above. If a change is material we will flag it on the site or by email. Continued use after a change means you accept the updated terms.',
],
},
{
@ -109,7 +107,7 @@ export const PRIVACY: LegalDoc = {
'How Perfect Postcode collects, uses and protects your data: account details, payments, saved searches, AI queries, analytics and your UK GDPR rights.',
lastUpdated: '10 June 2026',
intro: [
`This policy explains what personal data Perfect Postcode ("we", "us") collects, why, and your rights over it. We handle personal data under UK data-protection law (UK GDPR and the Data Protection Act 2018). Contact: ${SUPPORT_EMAIL}.`,
`This policy explains what personal data Perfect Postcode (“we”, “us”) collects, why, and your rights over it. Perfect Postcode is operated by András Schmelczer as a sole trader, who is the data controller for your personal data. We handle personal data under UK data-protection law (UK GDPR and the Data Protection Act 2018). Contact: ${SUPPORT_EMAIL}; a postal address is available on request.`,
],
sections: [
{

View file

@ -106,6 +106,7 @@ export default function MapPage({
initialTravelTime,
initialPostcode,
shareCode,
onSessionRejected,
user,
onLoginClick,
onRegisterClick,
@ -293,6 +294,7 @@ export default function MapPage({
filterRange,
travelTimeEntries: entries,
shareCode,
onSessionRejected,
});
// Read the zoom through a ref inside handleAiFilterSubmit so panning/zooming
@ -765,6 +767,20 @@ export default function MapPage({
}
}, [filtersUnlimited, lockFilterAdds, effectiveFilterCap, filters, entries]);
// When the cap tightens (logout / session invalidated / license lapse), useFilters
// re-clamps the feature filters, but travel-time entries — which count toward the same
// server cap — live here. Trim them to the cap too so the combined request can't exceed
// it and blank the map. Read via ref so this only fires on a cap change, not on every
// travel edit (those are already gated by handleAddTravelTimeEntry).
const entriesRef = useRef(entries);
entriesRef.current = entries;
useEffect(() => {
if (filtersUnlimited) return;
if (entriesRef.current.length > effectiveFilterCap) {
handleSetEntries(entriesRef.current.slice(0, effectiveFilterCap));
}
}, [effectiveFilterCap, filtersUnlimited, handleSetEntries]);
const handleUpgradeClick = useCallback(() => {
onNavigateTo('pricing');
}, [onNavigateTo]);

View file

@ -46,6 +46,9 @@ export interface MapPageProps {
initialTravelTime?: TravelTimeInitial;
initialPostcode?: string;
shareCode?: string;
/** Re-validate auth when the server rejects a data request with the filter cap while
* the client still believes it's signed in (a client/server session desync). */
onSessionRejected?: () => void;
user?: { id: string; subscription: string; isAdmin?: boolean; canSeeListings?: boolean } | null;
onLoginClick: () => void;
onRegisterClick: () => void;

View file

@ -377,6 +377,67 @@ describe('useFilters', () => {
expect(Object.keys(result.current.filters).sort()).toEqual(['a', 'b', 'c']);
});
it('recovers genuine filters lost to a cold-load slice once features arrive', () => {
const knownFeatures: FeatureMeta[] = ['a', 'b', 'c'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
// Cold load: a link with stale keys ordered before genuine ones, over budget. At
// mount features=[] so the init-clamp can't drop unknowns and the blind slice keeps
// the first 3 keys (2 stale + 1 genuine). Once features load, the genuine filters
// must be recovered, not left as just the one that happened to survive the slice.
const initialFilters = {
stale1: [0, 100] as [number, number],
stale2: [0, 100] as [number, number],
a: [0, 100] as [number, number],
b: [0, 100] as [number, number],
c: [0, 100] as [number, number],
};
const { result, rerender } = renderHook(
(props: { features: FeatureMeta[] }) =>
useFilters({ initialFilters, features: props.features, filterLimit: 3 }),
{ initialProps: { features: [] as FeatureMeta[] } }
);
// Cold mount: blind slice keeps the first 3 keys, including stale ones.
expect(Object.keys(result.current.filters)).toEqual(['stale1', 'stale2', 'a']);
// Features arrive: re-derive from source → the 3 genuine filters, stale dropped.
rerender({ features: knownFeatures });
expect(Object.keys(result.current.filters).sort()).toEqual(['a', 'b', 'c']);
});
it('trims filters down to the new cap when the cap tightens (logout / session loss)', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
const initialFilters = {
a: [0, 100] as [number, number],
b: [0, 100] as [number, number],
c: [0, 100] as [number, number],
d: [0, 100] as [number, number],
e: [0, 100] as [number, number],
};
const { result, rerender } = renderHook(
(props: { filterLimit: number }) =>
useFilters({ initialFilters, features: sixFeatures, filterLimit: props.filterLimit }),
{ initialProps: { filterLimit: 5 } }
);
// Registered: 5 filters fit the cap.
expect(Object.keys(result.current.filters)).toHaveLength(5);
// Cap drops to 3 (logout, or a server-invalidated session): filters trim to 3 so the
// next data request doesn't exceed the server cap and blank the map.
rerender({ filterLimit: 3 });
expect(Object.keys(result.current.filters)).toHaveLength(3);
});
});
describe('countEffectiveFilters', () => {

View file

@ -142,6 +142,29 @@ export function countEffectiveFilters(filters: FeatureFilters, features: Feature
return Object.keys(dropUnknownFilters(normalizeFilters(filters), features)).length;
}
/** Slice a filter set down to `budget` keys (keeping the first N), or return it
* unchanged (same reference) when within budget or uncapped. */
function clampToBudget(filters: FeatureFilters, budget: number | null): FeatureFilters {
if (budget == null || Object.keys(filters).length <= budget) return filters;
return Object.fromEntries(Object.entries(filters).slice(0, budget));
}
/** Shallow value-equality for two filter sets (same keys, same range/enum values).
* Lets effects skip a `setFilters` when nothing actually changed, avoiding a refetch. */
function filtersEqual(a: FeatureFilters, b: FeatureFilters): boolean {
const aKeys = Object.keys(a);
if (aKeys.length !== Object.keys(b).length) return false;
for (const key of aKeys) {
const av = a[key];
const bv = b[key];
if (!bv || av.length !== bv.length) return false;
for (let i = 0; i < av.length; i++) {
if (av[i] !== bv[i]) return false;
}
}
return true;
}
function getNextNumericKeyId(
filters: FeatureFilters,
getId: (name: string) => string | null
@ -167,19 +190,20 @@ export function useFilters({
onFilterLimitReached,
}: UseFiltersOptions) {
const initialFiltersRef = useRef<FeatureFilters | null>(null);
// The original normalized initial set, kept so the prune effect can re-derive the
// clamped set from source once features load (see below). Captured before clamping.
const originalNormalizedRef = useRef<FeatureFilters | null>(null);
if (!initialFiltersRef.current) {
// Clamp an incoming filter set (e.g. from a bookmarked or shared URL) to the
// user's remaining budget — the cap minus slots already taken by travel-time
// filters — so a non-paying user never starts above the cap, which the server
// would reject (400), blanking the map on first load. Unknown keys are dropped
// first WHEN the feature list is already loaded (in-app navigation); on a cold load
// features arrive after this runs, so the prune effect below removes them then.
const normalized = dropUnknownFilters(normalizeFilters(initialFilters), features);
// features arrive after this runs, so the prune effect below re-derives from source.
const normalizedOriginal = normalizeFilters(initialFilters);
originalNormalizedRef.current = normalizedOriginal;
const budget = filterLimit != null ? Math.max(0, filterLimit - reservedFilterSlots) : null;
initialFiltersRef.current =
budget != null && Object.keys(normalized).length > budget
? Object.fromEntries(Object.entries(normalized).slice(0, budget))
: normalized;
initialFiltersRef.current = clampToBudget(dropUnknownFilters(normalizedOriginal, features), budget);
}
const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);
@ -239,11 +263,30 @@ export function useFilters({
return null;
}, [viewFeature, activeFeature, dragValue, filters]);
const initialReconcileDoneRef = useRef(false);
useEffect(() => {
if (features.length === 0) return;
const knownFeatures = new Set(features.map((feature) => feature.name));
setFilters((prev) => dropUnknownFilters(prev, features));
if (!initialReconcileDoneRef.current) {
initialReconcileDoneRef.current = true;
// First time the feature list is known: re-derive the clamped set from the
// ORIGINAL filters. On a cold load the init-clamp ran before unknown keys could be
// dropped, so its blind slice may have kept stale keys over genuine ones;
// recomputing from source recovers any real filters the slice discarded. Skip the
// setFilters when nothing changed (the common case) so it doesn't trigger a refetch.
const budget =
filterLimitRef.current != null
? Math.max(0, filterLimitRef.current - reservedSlotsRef.current)
: null;
const reconciled = clampToBudget(
dropUnknownFilters(originalNormalizedRef.current ?? {}, features),
budget
);
setFilters((prev) => (filtersEqual(prev, reconciled) ? prev : reconciled));
} else {
setFilters((prev) => dropUnknownFilters(prev, features));
}
setPinnedFeature((prev) => {
if (!prev) return prev;
return knownFeatures.has(getBackendFeatureName(prev)) ? prev : null;
@ -254,6 +297,16 @@ export function useFilters({
});
}, [features]);
// If the cap tightens (the user logs out, or a session is invalidated server-side and
// useAuth drops the user), trim the current filters down to the new budget so the next
// data request doesn't exceed the server cap and blank the map. Widening is a no-op,
// and clampToBudget returns the same reference when already within budget (no refetch).
useEffect(() => {
if (filterLimit == null) return;
const budget = Math.max(0, filterLimit - reservedSlotsRef.current);
setFilters((prev) => clampToBudget(prev, budget));
}, [filterLimit]);
const handleAddFilter = useCallback(
(name: string) => {
const meta = features.find((f) => f.name === name);

View file

@ -28,6 +28,24 @@ import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { type TravelTimeEntry } from './useTravelTime';
import { buildTravelParam as serializeTravelParam } from '../lib/travel-params';
import pb from '../lib/pocketbase';
/** When a data request is rejected with the filter cap while we still hold a token, the
* server didn't accept our session (deleted/banned account, rotated key) and applied the
* lower anonymous cap. Surface that so the caller can re-validate auth which, if the
* session is gone, drops the user, lowers the cap, and re-clamps the filters, recovering
* instead of looping on an empty map. A logged-in client only ever sends its own cap,
* so a filter-cap rejection while authed is a reliable desync signal. */
async function detectSessionRejection(
res: Response,
onSessionRejected?: () => void
): Promise<void> {
if (res.status !== 400 || !onSessionRejected || !pb.authStore.isValid) return;
const body = await res.clone().json().catch(() => null);
if (body && (body as { error?: string }).error === 'filter_limit') {
onSessionRejected();
}
}
/** Return the p-th percentile (0100) from a sorted typed array via linear interpolation. */
function percentile(sorted: Float64Array, p: number): number {
@ -53,6 +71,9 @@ interface UseMapDataOptions {
/** Legacy share-link code from the URL; still echoed on data fetches but no
* longer grants any special access (the region lock was removed). */
shareCode?: string;
/** Called when the server rejects a data request with the filter cap while the client
* still holds a (locally-valid) token i.e. a client/server session desync. */
onSessionRejected?: () => void;
}
function getFiniteNumber(value: unknown): number | null {
@ -82,7 +103,11 @@ export function useMapData({
filterRange = null,
travelTimeEntries,
shareCode,
onSessionRejected,
}: UseMapDataOptions) {
// Held in a ref so the data-fetch effect's identity doesn't churn on this callback.
const onSessionRejectedRef = useRef(onSessionRejected);
onSessionRejectedRef.current = onSessionRejected;
const [rawData, setRawData] = useState<HexagonData[]>([]);
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
const [resolution, setResolution] = useState<number>(8);
@ -389,6 +414,7 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
await detectSessionRejection(res, onSessionRejectedRef.current);
assertOk(res, 'postcodes');
if (requestKey !== latestDataRequestKeyRef.current) return;
const json: { features: PostcodeFeature[] } = await res.json();
@ -417,6 +443,7 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
await detectSessionRejection(res, onSessionRejectedRef.current);
assertOk(res, 'hexagons');
if (requestKey !== latestDataRequestKeyRef.current) return;
const json: ApiResponse = await res.json();

View file

@ -24,8 +24,8 @@ const en = {
copy: 'Copy',
copied: 'Copied!',
copiedToClipboard: 'Copied to clipboard',
loading: 'Loading...',
loadMore: 'Load More',
loading: 'Loading',
loadMore: 'Load more',
remaining: '{{count}} remaining',
search: 'Search',
all: 'All',
@ -52,7 +52,7 @@ const en = {
places: 'places',
noData: 'No data',
allLow: 'All low',
connectingToServer: 'Connecting to server...',
connectingToServer: 'Connecting to server',
closePane: 'Close pane',
yes: 'Yes',
no: 'No',
@ -66,15 +66,15 @@ const en = {
dashboard: 'Dashboard',
learn: 'Learn',
pricing: 'Pricing',
inviteFriends: 'Invite Friends',
inviteFriends: 'Invite friends',
saved: 'Saved',
logIn: 'Log in',
createAccount: 'Create account',
sharing: 'Sharing...',
sharing: 'Sharing',
exportLabel: 'Export',
exporting: 'Exporting...',
exporting: 'Exporting',
exportToExcel: 'Export to Excel',
exportReady: 'Export ready. Your download should start.',
exportReady: 'Export ready. Your download will start automatically.',
exportFailed: 'Export failed.',
exportTimedOut: 'Export timed out. Try again.',
exportUnavailable: 'The map is still loading. Try again in a moment.',
@ -89,7 +89,8 @@ const en = {
modeFilters: 'Postcodes matching filters',
modeFiltersHint: 'Export every postcode visible on the map that matches your current filters.',
modeList: 'List of postcodes',
modeListHint: 'Add your own postcodes one by one — spacing and casing are fixed for you.',
modeListHint:
'Add your own postcodes one by one — we tidy up the spacing and capitalisation for you.',
listLabel: 'Postcodes',
listPlaceholder: 'e.g. SW1A 1AA',
addRow: 'Add postcode',
@ -600,7 +601,7 @@ const en = {
resetPassword: 'Reset password',
valueProp: 'Save searches, bookmark properties, and build a shortlist of areas that fit.',
dashboardActionsValueProp:
'Create a free account to save, share and export your searches — free, no card needed.',
'Create a free account to save, share and export your searches — no card needed.',
continueWithGoogle: 'Continue with Google',
email: 'Email',
emailPlaceholder: 'you@example.com',
@ -609,7 +610,7 @@ const en = {
passwordPlaceholderLogin: 'Your password',
forgotPassword: 'Forgot password?',
resetSent: 'Check your email for a reset link.',
pleaseWait: 'Please wait...',
pleaseWait: 'Please wait',
sendResetLink: 'Send reset link',
backToLogin: 'Back to login',
registerConsent:
@ -623,13 +624,13 @@ const en = {
// ── Upgrade Modal ──────────────────────────────────
upgrade: {
// Registered (logged-in, non-paying) users — the lifetime upsell.
title: 'Unlock unlimited filters',
title: 'Stack unlimited filters',
description:
"You've used all 5 filters on your free account. Get lifetime access to stack unlimited filters across every postcode and neighbourhood in England. One payment, forever.",
'Youve used all 5 filters on your free account. Get lifetime access to stack unlimited filters across every postcode and neighbourhood in England. One payment, forever.',
// Anonymous users — create a free account first.
titleRegister: 'Create a free account',
descriptionRegister:
'Free accounts unlock up to 5 filters plus save, share and export — no card needed. Want unlimited filters across all of England? Go lifetime.',
'Free accounts include up to 5 filters plus save, share and export — no card needed. Want unlimited filters across all of England? Go lifetime.',
descriptionSharedRegister:
'This is a shared search. Create a free account to build your own with up to 5 filters and save, share & export — or go lifetime for unlimited.',
registerFree: 'Create free account',
@ -638,10 +639,10 @@ const en = {
free: 'Free',
freeForEarly: 'Free for early adopters. No credit card required.',
oneTimePayment: 'One-time payment. Lifetime access.',
redirecting: 'Redirecting...',
redirecting: 'Redirecting',
claimFreeAccess: 'Claim free access',
upgradeFor: 'Upgrade for {{price}}',
registerAndUpgrade: 'Register & Upgrade',
registerAndUpgrade: 'Create account & upgrade',
alreadyHaveAccount: 'Already have an account? Log in',
continueFree: 'Keep exploring for now',
checkoutFailed: 'Checkout failed',
@ -649,13 +650,13 @@ const en = {
// ── Save Search Modal ──────────────────────────────
saveSearch: {
title: 'Save Search',
title: 'Save search',
saved: 'Search saved',
savedSuccess: 'Your search has been saved successfully.',
savedSuccess: 'Your search has been saved.',
viewSavedSearches: 'View saved searches',
name: 'Name',
namePlaceholder: 'My search',
saving: 'Saving...',
saving: 'Saving',
},
// ── License Success ────────────────────────────────
@ -676,7 +677,7 @@ const en = {
// ── Filters ────────────────────────────────────────
filters: {
activeFilters: 'Active Filters',
activeFilters: 'Active filters',
addFilter: 'Add filters',
findingPerfectPostcode: 'Finding the Perfect Postcode',
addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria',
@ -688,7 +689,7 @@ const en = {
registerSubPrompt: 'Free — no card needed. Unlimited filters with lifetime access.',
registerCta: 'Create free account',
chooseFilters: 'Click Add to filter. The small buttons show data details or colour the map.',
searchFeatures: 'Search features...',
searchFeatures: 'Search features',
noMatchingFeatures: 'No matching features',
tryDifferentSearch: 'Try a different search term',
allFeaturesActive: 'All features are active',
@ -697,7 +698,7 @@ const en = {
aboutData: 'About this data',
aboutDataShort: 'About',
colourMap: 'Colour map',
colourMapShort: 'Colour map',
colourMapShort: 'Colour',
clearColourMap: 'Clear map colour',
addFilterAction: 'Add',
addFilterLabel: 'Add filter',
@ -708,8 +709,8 @@ const en = {
clearAllSavePrompt: 'Would you like to save your current filters before clearing?',
clearAllUpdatePrompt:
'Update <strong>{{name}}</strong> with your current filters before clearing?',
saveAndClear: 'Save & Clear',
updateAndClear: 'Update & Clear',
saveAndClear: 'Save & clear',
updateAndClear: 'Update & clear',
clearWithoutSaving: 'Clear without saving',
clearWithoutUpdating: 'Clear without updating',
filtersOut: 'filters out {{value}}',
@ -755,9 +756,9 @@ const en = {
// ── Travel Time ────────────────────────────────────
travel: {
travelTime: 'Travel Time ({{mode}})',
travelTime: 'Travel time ({{mode}})',
maxTime: 'Max time',
selectDestination: 'Select destination...',
selectDestination: 'Select destination',
bestCase: 'Best case',
bestCaseTitle: 'Best case travel time',
bestCaseDesc:
@ -765,21 +766,21 @@ const en = {
noChange: 'No change',
noChangeTitle: 'No-change journeys only',
noChangeDesc:
'Restricts to journeys with <strong>no transfers</strong> —walk, board one transit service, then walk to the destination. Useful when you want a single straight-through commute.',
'Restricts to journeys with <strong>no transfers</strong> — walk, board one transit service, then walk to the destination. Useful when you want a single straight-through commute.',
oneChange: 'One change',
oneChangeTitle: 'One-change journeys',
oneChangeDesc:
'Allows journeys with <strong>at most one transfer</strong> —so a single change between two transit services is fine, but no more. A middle ground between no-change and unrestricted journeys.',
'Allows journeys with <strong>at most one transfer</strong> — so a single change between two transit services is fine, but no more. A middle ground between no-change and unrestricted journeys.',
noBuses: 'No buses',
noBusesTitle: 'Excluding buses',
noBusesDesc:
'Drops bus services from the allowed transit modes —<strong>rail, tube, tram and ferry only</strong>. Helpful for filtering to journeys that avoid traffic delays.',
'Drops bus services from the allowed transit modes — <strong>rail, tube, tram and ferry only</strong>. Helpful for filtering to journeys that avoid traffic delays.',
previewOnMap: 'Preview on map',
stopPreviewing: 'Stop previewing',
removeTravelTime: 'Remove travel time',
addTravelTime: 'Add {{mode}} travel time',
clearDestination: 'Clear destination',
typeToFilter: 'Type to filter...',
typeToFilter: 'Type to filter',
noDestinations: 'No destinations found',
modeCar: 'Car',
modeBicycle: 'Bicycle',
@ -811,10 +812,10 @@ const en = {
example1: 'Same schools, cheaper postcode',
example2: 'Best value within 30 minutes of work',
example3: 'Underpriced areas near good primaries',
analysing: 'Analysing your query...',
searchingDestinations: 'Searching for destinations...',
generatingFilters: 'Generating filters...',
refiningResults: 'Refining results...',
analysing: 'Analysing your query',
searchingDestinations: 'Searching for destinations',
generatingFilters: 'Generating filters',
refiningResults: 'Refining results',
weeklyLimitReached:
'Youve reached the weekly AI usage limit. Itll reset automatically next week.',
generateFailed: 'Failed to generate filters',
@ -858,7 +859,7 @@ const en = {
// ── Properties Pane ────────────────────────────────
propertyCard: {
unknownAddress: 'Unknown Address',
unknownAddress: 'Unknown address',
estValue: 'Est. value:',
type: 'Type:',
builtForm: 'Built form:',
@ -881,7 +882,7 @@ const en = {
tenureOwnerOccupied: 'Owner-occupied',
tenureRentedPrivate: 'Privately rented',
tenureRentedSocial: 'Social housing',
searchPlaceholder: 'Search by address or postcode...',
searchPlaceholder: 'Search by address or postcode',
propertyData: 'Property Data',
propertyDataDesc:
'Prices come from HM Land Registry (what buyers actually paid). Floor area, energy ratings, construction year, and tenure come from official EPC surveys. Both sources are matched by address within each postcode.',
@ -901,20 +902,20 @@ const en = {
noFiltersAffectStats: 'No active filters; stats cover all properties in this area.',
filteredStatsEmpty: 'Filtered stats are empty',
showAllStatsHint:
'{{count}} properties exist before filters. Switch to all properties to inspect this area.',
'{{count}} properties here without filters. Switch to all properties to inspect this area.',
showAllStatsFallback:
'Switch to all properties to inspect this area without the active filters.',
showAllStats: 'Show all properties',
closestStations: 'Closest stations',
noNearbyStations: 'No train or tube stations within 2km',
closestBlockingFilters: 'Closest changes to include this area',
closestBlockingFilters: 'Smallest filter changes to include this area',
lowerMinTo: 'Lower minimum to {{value}}',
raiseMaxTo: 'Raise maximum to {{value}}',
allowCategory: 'Allow {{value}}',
missingFilterValue: 'No value for this filter; remove it',
noFilterDataShort: 'No data',
travelTo: 'Travel to {{destination}}',
viewProperties: 'View {{count}} Properties',
viewProperties: 'View {{count}} properties',
viewPropertiesShort: 'View properties',
priceHistory: 'Price History',
journeysFrom: 'Journey times for {{label}}',
@ -950,7 +951,7 @@ const en = {
// ── Street View ────────────────────────────────────
streetView: {
title: 'Street View',
openLarge: 'Open Street View larger',
openLarge: 'Expand Street View',
expandedTitle: 'Expanded Street View',
},
@ -960,7 +961,7 @@ const en = {
pointsOfInterest: 'Points of Interest',
poiDescription:
'Sourced from OpenStreetMap, NaPTAN, and GEOLYTIX Grocery Retail Points. Covers transport stops, shops, chain supermarkets, restaurants, healthcare, leisure, and more.',
searchCategories: 'Search categories...',
searchCategories: 'Search categories',
dataSourceInfo: 'Data source info',
},
@ -974,7 +975,7 @@ const en = {
// ── Location Search ────────────────────────────────
locationSearch: {
placeholder: 'Search places or postcodes...',
placeholder: 'Search places or postcodes',
noResults: 'No matching places or postcodes',
postcodeNotFound: 'Postcode not found',
lookupFailed: 'Lookup failed',
@ -1019,7 +1020,7 @@ const en = {
showcaseNearby: '{{value}} nearby',
showcasePoliticalVoteShare: 'Political vote share',
showcaseGe2024: '2024 GE',
showcaseLotsMore: '...and lots more',
showcaseLotsMore: 'and lots more',
showcaseMinutes: '{{count}} min',
showcaseSendShortlist: 'Send the shortlist',
showcaseDownloadXlsx: 'Download .xlsx',
@ -1055,18 +1056,18 @@ const en = {
showcaseStep3Stat4Label: 'Broadband',
showcaseStep3Stat4Value: '1 Gbps available',
showcaseStep3Stat5Label: 'Primary schools',
showcaseStep3Stat5Value: '3 outstanding within 1 mile',
showcaseStep3Stat5Value: '3 Outstanding within 1 mile',
showcaseStep4Tab: 'Scout',
showcaseStep4Title: 'Scout it out yourself',
showcaseStep4Body:
'Take three grounded starting points into the real world. Walk the streets, test the commute, and compare viewings with context.',
'Take your top three postcodes into the real world. Walk the streets, test the commute, and compare viewings with context.',
showcaseStep4FileName: 'areas-to-scout.xlsx',
showcaseStep4ExportLabel: 'Export to Excel',
showcaseStep4ColPostcode: 'Postcode',
showcaseStep4ColScore: 'Fit',
showcaseStep4ColCommute: 'Commute',
showcaseStep4ColPrice: 'Median sold',
showcaseStep4Conclusion: 'You can start your journey from here.',
showcaseStep4Conclusion: 'Start scouting from here.',
statProperties: 'historical sales',
statFilters: 'combinable filters',
statEvery: 'Every',
@ -1077,7 +1078,7 @@ const en = {
priceStripSpots: '{{count}} spot left at this price.',
priceStripSpotsPlural: '{{count}} spots left at this price.',
priceStripCta: 'See pricing',
ourPhilosophy: 'Reputation is priced in. Value isnt yet',
ourPhilosophy: 'Reputation is priced in. Value isnt yet.',
philosophyP1:
'A postcodes price is partly its fundamentals and partly its reputation. People pay up for names they know, while the postcode one street over — same schools, same commute — sits quietly cheaper because nobody has bid it up. Spotting that gap means cross-checking sold prices, Ofsted, crime, noise and floor areas tab by tab.',
philosophyP2:
@ -1147,7 +1148,7 @@ const en = {
filled: 'Filled',
openDashboard: 'Open dashboard',
getStarted: 'Get started',
getStartedPrice: 'Get started - {{price}}',
getStartedPrice: 'Get started {{price}}',
noCreditCard: 'No credit card required',
moneyBack: '14-day money-back guarantee.',
@ -1155,7 +1156,7 @@ const en = {
upcoming: 'Upcoming',
failedToLoad: 'Failed to load pricing. Please try again later.',
feat1: '40+ filters and 200+ data fields',
feat2: 'Every postcode searchable from your needs',
feat2: 'Every postcode searched against your needs',
feat3: 'Unlimited map exploration, saved searches and exports',
feat4: '13M historical transactions and price context',
feat5: 'Commute, schools, crime, noise, broadband and more',
@ -1169,9 +1170,9 @@ const en = {
articles: 'Articles',
support: 'Support',
dataSourcesIntro:
'This application combines {{count}} open datasets covering property prices, energy performance, transport, demographics, crime, environment, and more.',
'Perfect Postcode combines {{count}} open datasets covering property prices, energy performance, transport, demographics, crime, environment, and more.',
faqIntro:
'Whether youre narrowing a first-time buyer search, checking an unfamiliar postcode, or building a viewing shortlist, heres how Perfect Postcode helps you work out where to look.',
'Narrowing a first-time buyer search, checking an unfamiliar postcode, or building a viewing shortlist? Heres how Perfect Postcode helps you work out where to look.',
articlesIntro:
'Browse the public guides for property search, commute, schools, postcode checks, regional comparisons, data coverage, methodology, and privacy.',
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
@ -1181,7 +1182,7 @@ const en = {
'Short clips from our social channels — each one shows a single search in action, from quiet streets to school catchments and commute times.',
video01Title: 'Find the cheaper twin',
video01Desc:
'Type your whole house brief in one plain sentence and watch every matching postcode in England sort by value, surfacing the cheaper twin nobody bid up.',
'Type your whole house brief in one plain sentence and watch every matching postcode in England sort by value, surfacing the cheaper twin everyone overlooked.',
video02Title: 'The 20-minute commute map',
video02Desc:
'Colour London by commute to the centre, prune to a 20-minute ride, and watch identical journeys split into the names everyone knows and quieter postcodes nobody bid up.',
@ -1190,7 +1191,7 @@ const en = {
'Tap any postcode and a panel opens with its sold prices, school catchments, crime and Street View — so you can tell whether you are paying for value or just a reputation.',
video04Title: 'The overlooked quiet street',
video04Desc:
'Famous postcodes price in their reputation, but a listing photo stays silent on noise. Filter by decibels to find the genuinely quiet street one over that nobody bid up.',
'Famous postcodes price in their reputation, but a listing photo stays silent on noise. Filter by decibels to find the genuinely quiet street one over that never paid the premium.',
video05Title: 'The catchment without the premium',
video05Desc:
'A Leeds family search for a good primary, low crime and a budget under £350k surfaces the quietly cheaper streets that share the very same catchment.',
@ -1290,24 +1291,24 @@ const en = {
dsRentalName: 'Private Rental Market Statistics',
dsRentalOrigin: 'ONS / Valuation Office Agency',
dsRentalUse:
'Median monthly private rental prices by local authority and bedroom category (Oct 2022 - Sep 2023). Joined to properties via local authority district code and estimated bedroom count.',
'Median monthly private rental prices by local authority and bedroom category (Oct 2022 Sep 2023). Joined to properties via local authority district code and estimated bedroom count.',
dsElectionName: '2024 General Election Results',
dsElectionOrigin: 'UK Parliament',
dsElectionUse:
'Candidate-level results for the July 2024 UK General Election. Aggregated to constituency level: voter turnout (%) and party vote shares (%). Joined to properties via the parliamentary constituency code (pcon) from the NSPL postcode lookup.',
// FAQ section titles
faqFindingTitle: 'Where To Look',
faqFindingTitle: 'Where to Look',
faqCommuteTitle: 'Travel Times',
faqBudgetTitle: 'Estimated Prices',
faqSafetyTitle: 'Safety and Neighbourhood',
faqFamiliesTitle: 'Families and Schools',
faqEnvironmentTitle: 'Environment and Quality of Life',
faqDueDiligenceTitle: 'What To Check',
faqDueDiligenceTitle: 'What to Check',
faqPrivacyTitle: 'Privacy',
faqWhyTitle: 'Why Perfect Postcode',
faqPricingTitle: 'Access',
faqTipsTitle: 'Map Tips',
faqBehindDataTitle: 'Behind The Data',
faqBehindDataTitle: 'Behind the Data',
// FAQ items — Finding Your Area
faqFinding1Q: 'Where should I look once the obvious areas are too expensive?',
faqFinding1A:
@ -1338,7 +1339,7 @@ const en = {
// FAQ items — Safety and Neighbourhood
faqSafety1Q: 'What type of crime is common around this postcode?',
faqSafety1A:
'Crime is broken down by type, including violence, burglary, robbery, vehicle crime, antisocial behaviour, shoplifting, drugs, and public order. You can focus on the risks that matter to you instead of relying on one vague safety score.',
'Crime is broken down by type, including violence, burglary, robbery, vehicle crime, anti-social behaviour, shoplifting, drugs, and public order. You can focus on the risks that matter to you instead of relying on one vague safety score.',
faqSafety2Q: 'What should I check before viewing an unfamiliar street?',
faqSafety2A:
'Check crime, road noise, broadband, parks, food shops, schools, and commute before you book. Listing photos are useful, but they shouldnt be the first time you learn what the street is like.',
@ -1419,15 +1420,15 @@ const en = {
faqBehindData3A:
'Police-recorded street-level crime is published at LSOA level — small neighbourhood areas of about 1,500 residents. Every postcode inside the same LSOA inherits the same yearly totals, so a quiet residential street and a high street one block over can show identical figures if they fall on the same side of the boundary. Per-capita rates can also look unusually high in postcodes covering hospitals, university campuses or industrial estates, because those areas record incidents normally but have very few residents on paper to divide the count across.',
faqBehindData4Q:
'What does a "school catchments" count mean — can my child attend those schools?',
'What does a “school catchments” count mean — can my child attend those schools?',
faqBehindData4A:
'Each count is the number of Good or Outstanding state schools whose modelled historical catchment area covers the postcode. We simulate how Englands distance-based admissions allocate places: children (Census 2021) apply to nearby schools, trading distance against Ofsted rating, and an oversubscribed school admits its closest applicants until full — its catchment radius is the distance of the last child admitted, the same "last distance offered" councils publish, and the model is calibrated against hundreds of those published figures. It estimates where pupils plausibly get a place; it is not an official admission area. Faith and selective admissions, sibling priority and yearly boundary changes are not modelled, so always confirm catchments and admission rules with the school or local authority before relying on them for a decision.',
faqBehindData5Q: 'Why does a postcode show "Gigabit" when not every home has fibre?',
'Each count is the number of Good or Outstanding state schools whose modelled historical catchment area covers the postcode. We simulate how Englands distance-based admissions allocate places: children (Census 2021) apply to nearby schools, trading distance against Ofsted rating, and an oversubscribed school admits its closest applicants until full — its catchment radius is the distance of the last child admitted, the same “last distance offered” councils publish, and the model is calibrated against hundreds of those published figures. It estimates where pupils plausibly get a place; it is not an official admission area. Faith and selective admissions, sibling priority and yearly boundary changes are not modelled, so always confirm catchments and admission rules with the school or local authority before relying on them for a decision.',
faqBehindData5Q: 'Why does a postcode show “Gigabit” when not every home has fibre?',
faqBehindData5A:
'Broadband coverage from Ofcom Connected Nations is reported per postcode as the percentage of premises that can get each speed tier. We display the highest tier with any availability, so a postcode where even one home can reach Gigabit reads "Gigabit available". It is the right answer for "is full-fibre on this street at all?", but does not guarantee every flat in a block can be ordered today. Always verify with the providers for your specific address before signing.',
'Broadband coverage from Ofcom Connected Nations is reported per postcode as the percentage of premises that can get each speed tier. We display the highest tier with any availability, so a postcode where even one home can reach Gigabit reads “Gigabit available”. It is the right answer for “is full-fibre on this street at all?”, but does not guarantee every flat in a block can be ordered today. Always verify with the providers for your specific address before signing.',
faqBehindData6Q: 'Why dont the public-transport times change for evenings or weekends?',
faqBehindData6A:
'Transit times are computed once per destination for a Tuesday morning departure window (07:3008:30) using full GTFS timetables. The "normal" figure is the median journey in that window, and "best case" is the 5th percentile. Off-peak, late-night and weekend services are not modelled, so a postcode that only has a peak-only bus can still look transit-good on the map. Treat the numbers as a weekday commute proxy rather than an all-day estimate.',
'Transit times are computed once per destination for a Tuesday morning departure window (07:3008:30) using full GTFS timetables. The “normal” figure is the median journey in that window, and “best case” is the 5th percentile. Off-peak, late-night and weekend services are not modelled, so a postcode that only has a peak-only bus can still look transit-good on the map. Treat the numbers as a weekday commute proxy rather than an all-day estimate.',
},
// ── Account Page ───────────────────────────────────
@ -1443,7 +1444,7 @@ const en = {
noShareLinksYet: 'No shared links yet',
copyShareLink: 'Copy shared link',
clicksLabel: 'clicks',
fetchShareLinksError: 'Failed to fetch share links',
fetchShareLinksError: 'Failed to fetch shared links',
updateNewsletterError: 'Failed to update newsletter',
},
@ -1454,11 +1455,11 @@ const en = {
noSavedSearchesDesc:
'Save your filters and map view so you can pick up exactly where you left off.',
clickToRename: 'Click to rename',
notesPlaceholder: 'Jot down your thoughts...',
notesPlaceholder: 'Jot down your thoughts',
deleteSearch: 'Delete search',
deleteSearchConfirm: 'Are you sure you want to delete this saved search? This cant be undone.',
isBeingUpdated: '<strong>{{name}}</strong> is being updated',
updating: 'Updating...',
updating: 'Updating',
loadFailed: 'Failed to load searches',
saveFailed: 'Failed to save search',
deleteFailed: 'Failed to delete search',
@ -1502,13 +1503,13 @@ const en = {
inviteAlreadyUsedDesc: 'This invite link has already been redeemed.',
invalidInviteLink: 'Invalid invite link',
invalidInviteLinkDesc: 'This invite link is invalid or has expired.',
licenseActivated: 'License activated!',
licenseActivated: 'Licence activated!',
fullAccessGranted: 'You now have full access to Perfect Postcode.',
activating: 'Activating...',
activateLicense: 'Activate license',
activating: 'Activating',
activateLicense: 'Activate licence',
claimDiscount: 'Claim discount',
registerToClaim: 'Register to claim',
youAlreadyHaveLicense: 'You already have a license',
youAlreadyHaveLicense: 'You already have a licence',
accountHasFullAccess: 'Your account already has full access.',
failedToValidate: 'Failed to validate invite link',
redeemFailed: 'Failed to redeem invite',
@ -1547,22 +1548,22 @@ const en = {
crimeHotspots: {
label: 'Crime hotspots',
detail:
'Client-side heatmap of street-level crimes published by police.uk over the most recent months. Police.uk coordinates are anonymised snap-to-grid points, not exact offence locations, so the heatmap should be read as an approximation of relative density rather than a precise map of incidents.',
'A heatmap of street-level crimes published by police.uk over the most recent months. Police.uk coordinates are anonymised snap-to-grid points, not exact offence locations, so the heatmap should be read as an approximation of relative density rather than a precise map of incidents.',
},
treesOutsideWoodlands: {
label: 'Trees & woodland',
detail:
'Forest Research Trees Outside Woodland (TOW) v1 canopy polygons — lone trees and groups of trees — unioned with National Forest Inventory (NFI) woodland blocks (≥0.5 ha) that TOW deliberately excludes. Together they cover both street trees and actual woods. Polygon opacity scales with canopy area.',
'Forest Research Trees Outside Woodland (TOW) v1 canopy polygons — lone trees and groups of trees — combined with National Forest Inventory (NFI) woodland blocks (≥0.5 ha) that TOW deliberately excludes. Together they cover both street trees and actual woods. Polygon opacity scales with canopy area.',
},
propertyBorders: {
label: 'Property borders',
detail:
'HM Land Registry INSPIRE Index Polygons — the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are "general boundaries" for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 8590% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.',
'HM Land Registry INSPIRE Index Polygons — the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are “general boundaries” for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 8590% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.',
},
newDevelopments: {
label: 'New developments',
detail:
'A forward-looking pipeline of new housing. Blue markers mark sites on the statutory MHCLG Brownfield Land registers — each carrying an estimated net-dwelling capacity and planning-permission status — together with Homes England Land Hub disposal sites. These show where new homes are planned, often years before they appear in EPC or sale records. Dwelling figures are capacity estimates, not commitments, and a site on a register is an opportunity rather than a guarantee of construction. Licensed under the Open Government Licence v3.0.',
'A pipeline of new housing. Blue markers show sites on the statutory MHCLG Brownfield Land registers — each carrying an estimated net-dwelling capacity and planning-permission status — together with Homes England Land Hub disposal sites. These show where new homes are planned, often years before they appear in EPC or sale records. Dwelling figures are capacity estimates, not commitments, and a site on a register is an opportunity rather than a guarantee of construction. Licensed under the Open Government Licence v3.0.',
},
},
@ -1599,7 +1600,7 @@ const en = {
gender: 'Gender',
pupilsLabel: 'Pupils',
pupils: 'pupils',
freeMeal: 'Free meal',
freeMeal: 'Free meals',
ofsted: 'Ofsted',
sixthForm: 'Sixth form',
religion: 'Religion',
@ -1639,7 +1640,7 @@ const en = {
'Set your budget, commute limit, school quality, crime threshold, noise tolerance, broadband needs, or whatever matters to you. Only matching areas stay lit. Use Colour to shade the map by any feature.',
step2Title: 'Or just describe it',
step2Content:
'Type what you want in plain English, like "quiet area near good schools under £400k", and well set up the filters for you.',
'Type what you want in plain English, like “quiet area near good schools under £400k”, and well set up the filters for you.',
step3Title: 'Explore whats out there',
step3Content:
'Pan and zoom across England. Click any coloured area to see why it matches: crime, schools, prices, broadband, noise, and more.',
@ -1653,7 +1654,7 @@ const en = {
'Toggle schools, shops, stations, parks, and restaurants on the map to see whats within reach.',
step7Title: 'Layer on more context',
step7Content:
'Toggle overlays like crime hotspots, noise, tree cover, property boundaries, and new-build sites on the map, and switch the basemap to suit.',
'Toggle overlays like crime hotspots, noise, tree cover, property boundaries, and new-build sites on the map, and switch the base map to suit.',
},
// ── Server-derived values ──────────────────────────

View file

@ -30,14 +30,14 @@ export const OVERLAYS: OverlayDefinition[] = [
label: 'Crime hotspots',
description: 'Approximate police.uk street-crime heatmap',
detail:
'Client-side heatmap of street-level crimes published by police.uk over the most recent months. Police.uk coordinates are anonymised snap-to-grid points, not exact offence locations, so the heatmap should be read as an approximation of relative density rather than a precise map of incidents.',
'A heatmap of street-level crimes published by police.uk over the most recent months. Police.uk coordinates are anonymised snap-to-grid points, not exact offence locations, so the heatmap should be read as an approximation of relative density rather than a precise map of incidents.',
},
{
id: 'trees-outside-woodlands',
label: 'Trees & woodland',
description: 'Tree canopy and woodland polygons',
detail:
'Forest Research Trees Outside Woodland (TOW) v1 canopy polygons — lone trees and groups of trees — unioned with National Forest Inventory (NFI) woodland blocks (≥0.5 ha) that TOW deliberately excludes. Together they cover both street trees and actual woods. Polygon opacity scales with canopy area.',
'Forest Research Trees Outside Woodland (TOW) v1 canopy polygons — lone trees and groups of trees — combined with National Forest Inventory (NFI) woodland blocks (≥0.5 ha) that TOW deliberately excludes. Together they cover both street trees and actual woods. Polygon opacity scales with canopy area.',
defaultEnabled: true,
},
{
@ -45,7 +45,7 @@ export const OVERLAYS: OverlayDefinition[] = [
label: 'Property borders',
description: 'Individual freehold property/land-parcel boundaries',
detail:
'HM Land Registry INSPIRE Index Polygons — the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are "general boundaries" for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 8590% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.',
'HM Land Registry INSPIRE Index Polygons — the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are “general boundaries” for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 8590% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.',
defaultEnabled: true,
},
{
@ -53,7 +53,7 @@ export const OVERLAYS: OverlayDefinition[] = [
label: 'New developments',
description: 'Planned new-home sites (brownfield register + Homes England)',
detail:
'A forward-looking pipeline of new housing. Blue markers mark sites on the statutory MHCLG Brownfield Land registers — each carrying an estimated net-dwelling capacity and planning-permission status — together with Homes England Land Hub disposal sites. These show where new homes are planned, often years before they appear in EPC or sale records. Dwelling figures are capacity estimates, not commitments, and a site on a register is an opportunity rather than a guarantee of construction. Licensed under the Open Government Licence v3.0.',
'A pipeline of new housing. Blue markers show sites on the statutory MHCLG Brownfield Land registers — each carrying an estimated net-dwelling capacity and planning-permission status — together with Homes England Land Hub disposal sites. These show where new homes are planned, often years before they appear in EPC or sale records. Dwelling figures are capacity estimates, not commitments, and a site on a register is an opportunity rather than a guarantee of construction. Licensed under the Open Government Licence v3.0.',
defaultEnabled: true,
},
];

View file

@ -122,9 +122,9 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
],
faq: [
{
question: 'Is this a replacement for Rightmove or Zoopla?',
question: 'How is Perfect Postcode different from a property listings site?',
answer:
'No, it does a different job. Listing portals show whats for sale today; Perfect Postcode shows where the value is across Englands postcodes.',
'It does a different job. Listing portals show whats for sale today; Perfect Postcode shows where the value is across Englands postcodes.',
},
{
question: 'Can I compare price with schools or commute time?',
@ -178,7 +178,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
},
{
title: 'Keep trade-offs visible',
body: 'When there are too many or too few matches, adjust one constraint at a time and see exactly which postcodes reappear. That makes compromises explicit instead of relying on guesswork.',
body: 'With too many or too few matches, the map shows exactly which postcodes you gain or lose as you change each filter, so every compromise is a deliberate choice.',
},
],
methodology: [
@ -195,7 +195,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
{
question: 'Can I save a postcode property search?',
answer:
'Yes. Licensed users can save searches and return to them later. Saved searches are designed for shortlists and comparison notes.',
'Yes. Signed-in users can save searches and return to them later. Saved searches are designed for shortlists and comparison notes.',
},
{
question: 'Can I search without knowing the area?',
@ -205,7 +205,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
{
question: 'Are the results live property listings?',
answer:
'No. The tool compares postcode data and historical/contextual property signals. You still need listing portals for current availability.',
'No. The tool compares postcode data and historical, contextual property signals rather than live listings; check current availability separately.',
},
],
relatedLinks: [
@ -249,7 +249,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
},
{
title: 'Balance journey time with the rest of the move',
body: 'A fast commute only helps if the area also fits your budget, housing needs, school preferences, safety threshold, broadband requirement, and tolerance for road noise.',
body: 'Once the reachable postcodes are in view, rank them on budget, space, schools, and local context so the shortest journey isnt the only thing driving the decision.',
},
],
methodology: [
@ -291,8 +291,8 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
},
'school-property-search': {
path: '/school-property-search',
eyebrow: 'Schools and property search',
title: 'Find property search areas with schools and family trade-offs in view',
eyebrow: 'School property search',
title: 'Find areas to search with school quality and family trade-offs in view',
metaTitle: 'School property search - Compare postcodes for family moves',
metaDescription:
'Compare nearby schools, property size, prices, parks, safety, commute and local amenities before building a viewing shortlist.',
@ -319,8 +319,8 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
body: 'Perfect Postcode helps you compare nearby school data with the other practical constraints that shape a family move: space, price, commute, parks, safety, and local services.',
},
{
title: 'Check catchments before making decisions',
body: 'Admissions rules and catchment boundaries can change. Use postcode-level school data to find promising areas, then verify current admissions details with the school or local authority.',
title: 'What the school data shows',
body: 'The map compares nearby Ofsted ratings and distance to schools, so you can weigh education against price, space, and commute as you build a shortlist.',
},
],
methodology: [
@ -386,7 +386,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
],
sections: [
{
title: 'Useful before and alongside listing portals',
title: 'See past the listing photos',
body: 'Listing photos rarely tell you enough about the surrounding street. Perfect Postcode gives you an evidence-led postcode check before you commit time to a viewing.',
},
{
@ -487,7 +487,7 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
'manchester-property-search': {
path: '/property-search/manchester',
eyebrow: 'Regional guide',
title: 'How to compare Manchester postcodes for a property search',
title: 'How to compare Manchester postcodes before a property search',
metaTitle: 'Manchester property search - Compare postcodes before viewing',
metaDescription:
'Compare Manchester-area postcodes by budget, commute, property type, schools, broadband, crime, noise and amenities before booking viewings.',
@ -668,8 +668,8 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
body: 'After filtering, colour the remaining map by one signal at a time: price per square metre, road noise, school context, commute time, broadband, or crime. This makes trade-offs easier to discuss.',
},
{
title: 'Measure whats working',
body: 'Use Search Console and analytics to track which public pages are indexed, which queries produce impressions, and which pages convert visitors into dashboard exploration. Review Core Web Vitals after every substantial frontend change.',
title: 'Validate before you commit',
body: 'Once the map narrows to a few postcodes, confirm what matters in person: visit the streets, check current listings and sold-price comparables, and verify schools, transport, and legal details against current official sources.',
},
],
faq: [
@ -710,31 +710,31 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
metaDescription:
'Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.',
intro:
'Property research can reveal personal priorities, budgets, and locations. The product keeps public SEO pages separate from account-only areas and marks private dashboard/account routes as noindex.',
'Property research can reveal personal priorities, budgets, and the areas youre considering. Your dashboard, account, and saved searches stay private to your account, and are never made public or searchable.',
sections: [
{
title: 'Public pages and private areas are separated',
body: 'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.',
title: 'Your account stays separate from the public site',
body: 'The guides, methodology, and support pages are public. Everything tied to your account — your dashboard, saved searches, and invitations — is kept out of public view and out of search engines.',
},
{
title: 'Saved search data is account-scoped',
body: 'Saved searches and shared links are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.',
title: 'Saved searches belong to your account',
body: 'Your saved searches stay in your account. Any link you choose to share works only for the people you send it to, and is never listed publicly or indexed by search engines.',
},
{
title: 'Search measurement without exposing private data',
body: 'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldnt become indexable landing pages.',
title: 'We measure usage without exposing your searches',
body: 'We improve the product using aggregated analytics on public pages only. The specific postcodes, filters, and searches in your account are never published or turned into pages anyone else can find.',
},
],
faq: [
{
question: 'Are saved searches listed in the sitemap?',
question: 'Can anyone else see my saved searches?',
answer:
'No. Public SEO pages are listed; account and saved-search routes are intentionally excluded.',
'No. Your saved searches stay in your account and are never listed publicly or indexed by search engines. A search is only visible to someone else if you deliberately create a share link and send it to them.',
},
{
question: 'Can private dashboard URLs appear in search?',
question: 'Will my dashboard or account pages show up in Google?',
answer:
'They shouldnt be indexed. The server marks private routes noindex and the sitemap only lists public pages.',
'No. Account and dashboard pages are blocked from search engines, so only the public guides and information pages can appear in search results.',
},
],
relatedLinks: [

View file

@ -212,7 +212,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
high: 98.0,
},
step: 0.1,
description: "Average storey height from the EPC survey",
description: "Average floor-to-ceiling height from the EPC survey",
detail: "Average internal floor-to-ceiling height in metres as recorded during the Energy Performance Certificate assessment. Calculated by dividing the total internal volume by the total floor area.",
source: "epc",
prefix: "",
@ -937,7 +937,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
step: 1.0,
description: "Maximum broadband download speed available at the postcode",
detail: "Maximum fixed broadband download speed available from any provider, from Ofcom Connected Nations 2025. Represents theoretical maximum, not achieved speeds. 10 Mbps = basic, 30 = superfast, 100+ = ultrafast, 1000 = gigabit. Null where no availability data is published.",
detail: "Maximum fixed broadband download speed available from any provider, from Ofcom Connected Nations 2025. Represents theoretical maximum, not achieved speeds. 10 Mbps = basic, 30 = superfast, 100+ = ultrafast, 1000 = gigabit. Blank where no availability data is published.",
source: "broadband",
prefix: "",
suffix: " Mbps",

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
* 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: [

View file

@ -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,