Skip to content

Repository files navigation

Hampstead Heath & Its Village – a walking audio guide

https://hampstead-heath.blankm.workers.dev

A twenty-four-stop loop from Hampstead Underground station, over the top of the Heath to Kenwood and back down past the swimming ponds. 27 tracks, plus the full transcript.

Every track opens with where you should be standing and closes by telling you where to walk next. Nothing auto-advances: between stops you are walking, not listening.

public/ is the published site and the only thing the Worker can see. Everything outside it is source, and is therefore unpublishable by construction rather than by a list of exclusions.

  • public/index.html – the page: map, transcript, a photograph and a play button on every stop
  • public/stops/ – one page per stop, 24 of them, each with its own recording and transcript
  • public/audio/ – the 27 tracks
  • public/images/ – one photograph per track, plus credits.json
  • public/hampstead-heath-walk.gpx, .kml, .csv – the stops and their order, in the three shapes something else will open: a walking app, Google Earth, a spreadsheet
  • public/hampstead-heath-full-walk.m4a – all of it as one continuous file, for offline
  • public/cover.jpg – the artwork, also embedded in every track
  • public/og.jpg – the sharing card, and the favicon set beside it
  • public/feed.xml – the same tracks as a podcast feed
  • public/guide.md – the whole transcript as plain Markdown
  • public/llms.txt, public/robots.txt, public/sitemap.xml – for the readers that are not people
  • map.json – the shape of the Heath, its water and its roads, from OpenStreetMap. Read at build time and inlined as SVG, so it is never served
  • voice.json – which voice actually made the audio that is here
  • build.py – the narration, and everything that is generated from it
  • fetch_images.py – re-fetches the photographs from Wikimedia Commons
  • fetch_map.py – re-fetches the map geometry from OpenStreetMap

Everything under public/ except the photographs, the audio and the map geometry is generated by build.py from one list of stops. None of it is edited by hand.

Narration is synthesised speech. Opening hours were checked in August 2026 and are the first thing that will change.

The voice, and why it is not a Mac voice

The macOS system voices are licensed for personal, non-commercial use only (macOS SLA §2.F), and the same clause forbids publishing or public sharing of their output. That rules them out for anything on a website, commercial or not.

The build uses Amazon Polly's generative engine instead. Commercial use is covered by the AWS customer agreement, and the free tier covers 100,000 generative characters a month for the first year – a little over twice the size of this script, so a rebuild is usually free, but two in a month is not. After that it is $30 per million characters, which is about $1.38 a rebuild.

aws configure                     # once; region eu-west-2
python3 build.py --voices         # the en-GB generative voices
export POLLY_VOICE=Amy            # or Brian; those are the two en-GB ones
python3 build.py --sample         # one track, to hear it before committing
python3 build.py                  # all 27 tracks, then rebuilds the page

The region matters. Polly's generative engine runs in nine regions only, and eu-north-1 is not among them; PL_REGION defaults to eu-west-2, which is both close and, for this walk, the right city. The pace is an SSML prosody tag rather than a parameter, because the generative engine has no speed control of its own.

Three other engines are available. TTS_ENGINE=google is Chirp 3: HD, needing GOOGLE_API_KEY or GOOGLE_ACCESS_TOKEN, an open Cloud billing account and the Text-to-Speech API enabled. TTS_ENGINE=elevenlabs (with ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID) costs about 36,000 credits per rebuild and sounds better; every paid ElevenLabs plan grants commercial rights, and they survive cancelling the plan. TTS_ENGINE=say is the Mac voice, for drafting only. Do not publish it.

--sample names its output after the engine and voice, so you can render the same track on two of them and compare.

The continuous full-walk file is spliced from the finished tracks rather than synthesised again, which halves the credits and guarantees it matches. Voice, engine, region and pace are the PL_* constants at the top of build.py. voice.json records which voice actually made the audio in the repo, and the colophon reads from it, so the page cannot claim a voice it did not use.

Rebuilding

Needs macOS (afconvert), mutagen and boto3; the cover also needs pillow.

python3 build.py            # audio, then the page
python3 build.py --page     # page, stop pages, feed, sitemap, robots, llms.txt
python3 build.py --cover    # redraw cover.jpg and og.jpg
python3 build.py --icons    # redraw the favicon set and the web manifest

The text lives in one place, STOPS in build.py, so the transcript on the page cannot drift out of step with the recording. Change the voice settings at the top and the whole set rebuilds in one pass.

Adding or moving a stop

Stop numbers are assigned from walking order in build.py, not written into each entry, so inserting one renumbers the guide by itself. Three things do need doing by hand: the spoken cross-references in neighbouring walk texts, a coordinate in STOPS_LL in fetch_map.py, and a picture in PICKS in fetch_images.py.

Be aware that inserting a stop changes the index prefix on every audio and image file after it, so the whole set has to be re-rendered and re-fetched before the page will build. Do that on a branch: a half-renumbered main deploys a page whose photographs and audio all 404.

Dated things

A stop can carry an event: something with a date on it happening at that place. The Pergola has one, the Open House Festival's guided tour of it.

It is never spoken. A date cannot go into a recording that will not be re-cut when it passes, so the event text is printed on the page, written into guide.md and emitted as schema.org Event nodes, and the audio ignores it entirely. It is also the one block on the page that uses figures rather than words, for the same reason.

build.py drops each date the morning after it has gone, and the whole block once the last one has, so a rebuild in October publishes no trace of a September tour. Nothing expires without a rebuild, though: the page is static, so a stale notice sits there until the next --page run.

event=dict(name="Pergola Walking Tour", host="Open House Festival",
           url="https://programme.openhouse.org.uk/listings/2106",
           dates=["2026-09-12", "2026-09-19"], start="11:00", end="12:30",
           meet="the Whitestone Pond flagpole, which is stop 8 of this walk",
           body=["...", "..."])

Pictures

Every track has one photograph, chosen to show the thing you are standing in front of rather than the prettiest view of it. All are Creative Commons or public domain, from Wikimedia Commons. Each is credited under the picture with its photographer, licence and source, and images/credits.json carries the same information in full. To change one, edit PICKS in fetch_images.py and re-run it, then python3 build.py --page.

If you reuse these photographs elsewhere, the CC BY-SA terms come with them.

The map

Drawn as inline SVG from OpenStreetMap geometry: no tiles, no libraries, no requests to anybody. That matters because the middle of the Heath has no signal, and a slippy map is the first thing to fail there. Drag or pinch to move it, tap a number to play that stop, and "Where am I" uses the browser's own geolocation – nothing leaves the phone.

The dotted line is the order of the stops, not the path you walk.

Stop coordinates live in STOPS_LL in fetch_map.py. Hand-typed coordinates were out by up to 300 metres, so check any you change against the drawn roads and water rather than against memory. Map data © OpenStreetMap contributors, ODbL.

The route, to take away

The same twenty-four stops leave the site in three notations, written by build.py from STOPS and map.json at the same moment as the page:

  • hampstead-heath-walk.gpx – waypoints and an <rte>, each waypoint linking back to that stop's page. For Garmin, OsmAnd, Gaia, Komoot, Organic Maps.
  • hampstead-heath-walk.kml – the same stops with a style per kind, in the four colours the drawn map uses, plus ExtendedData so Google My Maps builds a table rather than one unsearchable balloon of prose.
  • hampstead-heath-walk.csv – one row per stop: coordinate, category, track, duration, admission, and the URLs of its page and its audio. latitude and longitude are named as such because that is what My Maps looks for.

ROUTE_FORMATS at the top of build.py is the list all three come from, and it is also what the page, the JSON-LD DataDownload nodes and llms.txt enumerate: a format cannot be written and then not offered, or offered and not written. To add a fourth, add a row there and a generator beside gpx().

None of the three is the path you walk. They join the stops in straight lines, same as the dotted line on the map, because the walking directions are prose in the narration and were never a polyline. Turn-by-turn would mean tracing the Heath's own path network, which is a different and much larger job.

Deploying

Deployed from main to Cloudflare Workers with npx wrangler deploy. Pushing to GitHub does not deploy anything.

wrangler.jsonc is generated by build.py, so change the deploy config in the generator at the bottom of build_page, not in the file – editing the file alone lasts until the next --page run. It points at ./public, which is why the repo is laid out this way: an earlier config of "directory": "." published .git, the build scripts and the Cloudflare account cache along with the site.

It also sets html_handling: auto-trailing-slash, which is what serves public/stops/kenwood-house/index.html at /stops/kenwood-house/ – the URL the sitemap, the feed and every internal link use.

Being found

A page nobody can find is a private page. build.py --page writes everything a crawler, a feed reader or a language model needs, from the same STOPS list, so none of it can describe a walk that is not there.

  • A page per stop. public/stops/<slug>/ – 24 of them, each with the recording, the transcript, the photograph, the coordinates and prev/next. One page can only rank for one thing; twenty-four can rank for twenty-four. stops/ is the index, and it is one hop from the home page so the pages are not orphans.
  • Structured data. JSON-LD on every page: TouristTrip with all 24 stops as an ordered ItemList with coordinates, TouristAttraction per stop, AudioObject with the transcript, ImageObject carrying each photographer's credit and licence, FAQPage, BreadcrumbList, and sameAs links to Wikipedia and Wikidata so a machine knows which Hampstead Heath this is.
  • The head. Doctype, lang, canonical, Open Graph and Twitter cards against og.jpg, max-image-preview:large, a favicon set and a manifest. Without the card, a pasted link is a bare grey URL.
  • robots.txt. Everything allowed, explicitly, including the AI crawlers, because several of them read an unanswered question as a no. Also Content-Signal: search=yes, ai-input=yes, ai-train=yes and the sitemap.
  • feed.xml. The 27 tracks as a podcast. Apple, Spotify and Overcast are a search engine each.
  • guide.md and llms.txt. The whole thing as plain text. llms.txt is a convention no crawler has yet promised to read; guide.md is the file that actually gets quoted.

ORIGIN at the top of build.py is the canonical origin – scheme and host – and every absolute URL is built from it, so moving to a real domain is one line. It sits next to SITE, which is the directory those URLs are served from; the two are deliberately not the same name.

Four things are not code and have to be done by hand:

  1. Verify the site in Google Search Console and Bing Webmaster Tools and submit the sitemap. Bing matters more than its market share suggests, because ChatGPT's search grounding leans on Bing's index.
  2. Check that Cloudflare's AI Crawl Control is not blocking OAI-SearchBot and PerplexityBot at the edge, which it does regardless of robots.txt.
  3. Set OWNER_EMAIL in build.py before submitting the feed: Apple Podcasts will not accept it without an owner address.
  4. A custom domain. workers.dev is on the Public Suffix List and carries no accumulated authority, so it is the ceiling on anything else here.

Licence

Two licences, because there are two kinds of thing in here.

  • The code – build.py, fetch_images.py, fetch_map.py – is MIT. See LICENSE.
  • The narration, the transcript and the walk itself are CC BY 4.0. See LICENSE-CONTENT.
  • The photographs are not covered by either. Each belongs to its photographer under its own Creative Commons terms, recorded per file in public/images/credits.json and credited under every picture. If you reuse them, the terms come with them. Map data © OpenStreetMap contributors, ODbL.

About

A free self-guided walking audio guide to Hampstead Heath and its village: 24 stops, 27 tracks, 55 minutes, with the full transcript – generated from one Python file

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages