---
title: Narrator voice
date: 2026-04-07T10:54:39Z
modified: 2026-05-01T13:47:35Z
permalink: "https://dgw.ltd/2026/04/07/narrator-voice/"
type: post
status: publish
excerpt: I saw this post from Ben Goldacre a while back about enabling people to make radio shows. So I built it.
wpid: 822
categories:
  - AI
  - Music
featured_image: "https://dgw.ltd/wp-content/uploads/2026/04/narrator-voice-og.png"
featured_image_alt: Speak clearly
timestamp: 2026-05-01T13:47:35Z
tags:
  - AI
  - Music
---

I saw this post from Ben Goldacre a while back, and loved the idea.

> I wish that Spotify or similar would let people make radio shows, where they play music and talk in between. Since you've paid to access the music, it would be nice to have people share and curate, but with rambling humanity in the chat, not just playlists. This is the narrowcasting of my dreams.
> 
> — [Ben Goldacre (@bengoldacre.bsky.social)](https://bsky.app/profile/did:plc:ejewc4y7vvsbig3yszf2vrek?ref_src=embed) [2026-02-28T22:47:35.459Z](https://bsky.app/profile/did:plc:ejewc4y7vvsbig3yszf2vrek/post/3mfxclymlrc2o?ref_src=embed)



So, as with many things these days – with AI tools – we can try things out quickly and get an MVP together for projects that would have been too distracting and effort-intensive otherwise.

So, I’ve built something: [narrated.site](https://narrated.site) is a tool for making narrated episodes – voice recordings interleaved with music references, like a personal radio show mentioned in Ben’s post. It publishes to the AT Protocol (the network behind Bluesky) and has no backend server.

As I saw in another article, this is a nice mental model for understanding this method of publishing / consuming content.

> One way to think about federated social networks is just as huge, structured databases of stuff people post — all the links, selfies, jokes, videos, whatever, it all goes into the database. Most social apps so far have chosen to present that database as a dense Twitter-style timeline.
> 
> The Verge – [Flipboard just launched Surf, its new social app and feed reader](https://www.theverge.com/tech/905929/flipboard-surf-fediverse-launch)

This post is about why that architecture works, and why ATProto makes it possible. The implementation is a little clunky, but I think the setup is potentially really interesting.

## The setup

The entire app is a static site: [11ty](https://www.11ty.dev) (Build Awesome) and [Web Awesome](https://webawesome.com) generates HTML/CSS, [Alpine.js](https://alpinejs.dev) handles interactivity.

[narrated.site](https://narrated.site) doesn’t host any content. There’s no media storage, no user database, no uploaded files sitting on a server. The site serves a handful of static HTML pages that act as transactional interfaces – the compose page is a form that writes to your PDS (e.g. bsky.app), the player page is a viewer that reads from it, the show page is a listing that queries it. Every page is a thin client over someone else’s data store.

When you publish an episode, your browser talks directly to your AT Protocol Personal Data Server (PDS) – the same server that holds your Bluesky posts, follows, and likes. It authenticates with an [app password](https://bsky.app/settings/app-passwords), uploads voice audio as blobs, and creates a record. The static site is just the UI layer. The hosting cost is negligible – it’s serving the same small set of HTML, JS, and CSS files regardless of how many episodes exist or how many people listen.

I decided to use an existing and emergent schema: [Standard.site](https://standard.site), which is what this site uses to publish to Bluesky via : [https://bsky.app/profile/dgw.ltd](https://bsky.app/profile/dgw.ltd)

> BTW TDD BDDI was listening to an excellent podcast from The Economist recently (Boss Class) around vibe coding and one thing really stood out. One or the theories / reasons why Software…https://dgw.ltd/2026/04/02/btw-tdd-bdd/
> 
> — [ (@dgw.ltd)](https://bsky.app/profile/did:plc:svkyjirwpd7ts4qgnzoqfcc2?ref_src=embed) [2026-04-02T10:02:44.000Z](https://bsky.app/profile/did:plc:svkyjirwpd7ts4qgnzoqfcc2/post/3miixaoww5cho?ref_src=embed)



## Why this works on ATProto

Three properties of the protocol make this viable:

**1. Fail-open validation**

ATProto PDSes accept record types they don’t recognise. When narrated.site writes a `site.narrated.episode` inside a `site.standard.document`, the PDS doesn’t reject it for being unknown – it stores it as-is. No registration, no approval process, no waiting for server-side schema updates.

This means anyone can invent a new data format and start publishing it immediately. The PDS is a general-purpose data store, not a gatekeeper.

**2. Browser-accessible CORS**

The reference PDS implementation ships with permissive CORS headers. Public endpoints (reading records, fetching blobs) are open. Authenticated endpoints (creating records, uploading blobs) accept cross-origin requests.

This is a deliberate design choice – the PDS is meant to be a user-controlled data server, and browser-direct access is a first-class use case. It’s what makes “no backend” possible: the browser authenticates, talks directly to the PDS, and the static site just serves the UI.

**3. Content-addressed blobs**

Voice audio is uploaded as blobs via `com.atproto.repo.uploadBlob`. The PDS returns a content-addressed reference (a CID) which goes into the episode record. The record never contains audio bytes – just the pointer.

Anyone can fetch the blob later from the author’s PDS using the public `com.atproto.sync.getBlob` endpoint. No auth needed, deterministic URL. The player page does exactly this.

## What lives where



| Stage | Storage | What’s there |
| --- | --- | --- |
| Drafting | Browser IndexedDB | Voice blobs + episode metadata |
| Published | Author’s PDS | Voice blobs (by CID) + episode record |
| Playing | Fetched from PDS | Blobs streamed to `<audio>` elements |

The episode record on the PDS is a `site.standard.document` – an open format from [standard.site](https://standard.site) that any compatible client can read. The narrated-specific content (segments, mood coordinates) sits inside the document’s `content` union. A standard-aware client sees a valid document; a narrated-aware player gets the full experience.

## Security model

The app stores your handle in localStorage (it’s public information – in every AT URI and profile). Your app password is never persisted – it’s held in memory for the current session only. Each publish creates a short-lived `accessJwt` that’s discarded after use.

There’s no backend that could leak credentials, no database of user tokens, no server to compromise. The attack surface is “a static site that makes fetch calls.”

App passwords are revocable and scoped – they can’t change your account password or email. If you’re worried, revoke it from bsky.app after each session.

## Limitations

**10 voice segments, 5MB each.** Episodes are capped at 10 voice segments (roughly 30-40 minutes of speech) and 10 media references. Each voice blob maxes out at 5MB, well under the PDS’s 50MB blob limit. Only audio MIME types are accepted on upload.

**No seamless music playback.** Voice segments auto-advance through the episode, but when play-all reaches a Spotify or YouTube embed, it highlights it and waits – the listener clicks play on the embed themselves.

**No discoverability.** There’s no index, no feed, no search. Episodes exist on individual PDSes and you find them via direct links or the Bluesky cross-post.

**The PDS is your CDN.** Voice audio is served directly from `com.atproto.sync.getBlob`. Fine for small audiences, but if an episode went viral the PDS would be serving a lot of 5MB blobs. The protocol’s relay and CDN layers are the intended answer, but they’re not fully built out yet.

## What I like about it

The thing that feels right is how little there is. No server to maintain, no database migrations, no auth system to build, no infrastructure costs beyond static hosting. The user’s PDS handles storage, authentication, and serving. The lexicon handles the data contract. The static site handles the UI.

Oh, and I added a mood pad – inspired by a service I used to love called [Musicovery](https://en.wikipedia.org/wiki/Musicovery) – that lets you place any song on a two-axis grid: Energetic/Calm versus Dark/Positive.

If [narrated.site](https://narrated.site) disappeared tomorrow, every published episode would still be on its author’s PDS, addressable by AT URI, readable by any client that implements the lexicon. The data outlives the app.

That’s the promise of publishing on a protocol instead of a platform, and it actually delivers here – not because of any cleverness on my part, but because ATProto was designed for exactly this kind of thing.

I created a test episode here: [https://narrated.site/show/?user=narrator77.bsky.social](https://narrated.site/show/?user=narrator77.bsky.social)