Profpatsch/users/Profpatsch/observations
- episodes/ 151.7 KiB · 4 files
- .gitignore 505 B
- default.nix 2.0 KiB
- episode.go 11.8 KiB
- go.mod 29 B
- listen.go 18.7 KiB
- listen_test.go 5.7 KiB
- main.go 14.0 KiB
- observations-transcripts.5 6.9 KiB
- observations-transcripts.7 23.2 KiB
- observations.1 12.4 KiB
- observations.7 17.8 KiB
- probe.go 7.0 KiB
- render.go 41.7 KiB
- render_test.go 34.6 KiB
- transcript.go 7.7 KiB
- transcript_test.go 7.1 KiB
- transcriptfile.go 17.2 KiB
- transcriptfile_test.go 12.9 KiB
- transcripthtml.go 12.0 KiB
observations(1)
NAME
observations - publish audio readings of other people's texts
SYNOPSIS
observations
add
[--episodes dir]
[--audio dir]
[--out dir]
--source-title title
--source-url url
--source-author author
--source-published date
[--source-site site]
[--source-note note]
[--title title]
[--recorded date]
[--notes text]
[--number n]
[--ffprobe path]
[--ffmpeg path]
recording
observations
render
[--episodes dir]
--out dir
observations
list
[--episodes dir]
observations
transcript
[--format what]
[--ffprobe path]
[--ffmpeg path]
recording
observations
listen
[--episodes dir]
[--audio dir]
[--gemini-key-file path]
[--pad seconds]
[--no-gemini-call]
[--ffmpeg path]
episode from to
observations
convert
[--episodes dir]
[--force]
DESCRIPTION
observations ingests a recording of someone reading a text out loud and renders a static website and podcast feed from it. For what the format is and why the pipeline is shaped this way, see observations(7).
add
Ingests recording, which must be the file as it came off the recorder.
The recording is probed for its duration and byte size (both needed by the feed), the transcript track is extracted and decoded if there is one, the audio is copied to the audio directory as NNN.m4a, and the metadata is written to episodes/NNN.json.
add
records
one
source, which is the common case.
An episode that reads several texts gets the rest by appending them to the
'sources'
array in
episodes/NNN.json
and running
render
again; give it a
--title
at ingest, since such an episode is not named after any one of its texts.
The flags are deliberately not repeatable: zipping parallel flag lists by
position misaligns silently as soon as one source has a
--source-site
and another does not, and quietly attributing a text to the wrong author is a
worse failure than typing six lines of JSON.
--source-title title
Title of the text being read.
Required.
--source-url url
URL of the text being read. Must be http or https. Required.
--source-author author
Who wrote the text. Required.
--source-published date
When the text was published, as YYYY-MM-DD. Required.
--source-site site
The publication the text appeared in, when it differs from the author.
--source-note note
What this text is doing in the episode – "intro only", "the main piece". Only useful for an episode that reads more than one.
--title title
Title of the episode. Defaults to the title of its source, which is what a single-source episode wants. Set this for an episode built around a subject rather than one text.
--recorded date
Date of the recording, as YYYY-MM-DD. This is also the publication date of the episode. Defaults to the recording's modification time.
--notes text
Prose shown on the episode page and included in the feed entry.
--number n
Episode number. Defaults to one past the highest existing episode.
--ffprobe path, --ffmpeg path
Paths to the ffmpeg tools. Default to ffprobe and ffmpeg on
PATH.
render
Writes the site into the --out directory: an index.html, a NNN/index.html per episode, and feed.xml. An episode page carries the player, the sources, the submission form and the episode's transcript, when it has one; the feed carries the summary but not the transcript. What the page does with the transcript, and why, is in observations(7).
Rendering is a pure function of the episodes directory and can be re-run at any time. --out is required.
list
Prints one line per episode: number, recording date, running time, transcript word count and source title.
transcript
Prints the transcript embedded in recording, without ingesting it. Reads the file and writes nothing, so it is safe to point at anything, including an episode already published.
--format what
transcript
The stored format, on standard output. The default.
text
The transcript as prose, for reading or grepping.
streams
What the container holds: duration, audio codec, and every data track with the transcript marked. This is what answers "why is the transcript missing".
--ffprobe path, --ffmpeg path
Paths to the ffmpeg tools, as for add.
Exits non-zero when the recording carries no transcript, so it can gate a script that is about to overwrite the original.
listen
Prints what a speech model hears in one passage of an episode, beside what the source says and what the transcript currently says. It is for the passages a correction pass cannot settle from the text – see observations-transcripts(7), which is where the judgement rules live.
from and to may be written as HH:MM:SS.mmm as the transcript stores them, MM:SS as a player shows them, or as bare seconds. episode may be written 001 or 1.
Three views are printed, none of them authoritative:
GEMINI
What the model heard. It is a second recogniser, wrong in different places than the first.
SOURCE
The source's own wording, when the span falls inside a .quote. Where there is one this settles the passage and the model is not needed.
TRANSCRIPT
What the file says now, printed last and labelled, because it is the thing being checked.
It never reports a timestamp. The model answers to the millisecond and is late by 250 to 450 ms, which is worse than interpolating within a line; a timestamp for splitting a line comes from the per-word data in git history (observations-transcripts(7)).
Reads the episode and its audio and writes nothing. This is the only subcommand that uses the network or wants a credential; add, render and list never reach it.
--gemini-key-file path
File holding a Gemini API key. Without it the key is taken from
GEMINI_API_KEY, and failing that from pass(1). The password store is tried last because it is the one source that can block on a gpg-agent(1) prompt. Consent is not at issue here, unlike observations-inbox(1): the audio is a published episode, not someone else's voice.
--pad seconds
Audio to include either side of the span, default 2. A word at the very edge of a clip is the one most likely to be misheard. The padding is stated in the output, so the extra words are known to be context rather than part of the passage.
--no-gemini-call
Print the source and transcript views only. Needs no key, and answers "what does the article actually say here" on its own.
--ffmpeg path
Path to ffmpeg, used to cut the clip.
convert
Migrates episodes/NNN.transcript.json, the shape the recogniser's protobuf decoded into, to episodes/NNN.transcript, the shape people edit.
This is a one-off: it exists for the episodes ingested before the stored format changed, and is kept because it is the only route back should a conversion ever have to be redone from the JSON in git history. It refuses to overwrite an existing transcript unless given --force, since that transcript has probably been corrected by hand and the JSON has not.
Common flags
--episodes dir
Episode metadata, which lives in git. Default episodes.
--audio dir
Audio files, which do not live in git. Default audio.
--out dir
Where to render the site. For add this is optional and renders after ingesting.
FILES
episodes/NNN.json
One episode: its optional title, the '
sources' array, and the audio's size and duration. A couple of dozen lines, meant to be read and hand-edited; this is the place to add a second source. Committed to git.
episodes/NNN.transcript
The transcript for that episode: one timed line of speech per subtitle-shaped
line, with paragraph breaks and quotations on their own.
In its own file because it is hundreds of lines and would otherwise bury the
metadata above in every diff.
Written by
**add**,
then corrected by hand - raw recogniser output has a mishearing in every
proper noun.
See
observations-transcripts(5)
for the format and
observations-transcripts(7)
for how to correct one.
Committed to git.
audio/NNN.m4a
The recording, byte for byte as it came off the recorder.
Never committed; see
observations(7).
users/Profpatsch/web/observations/
Rendered site, committed and published with the rest of the website.
**Generated - do not edit**.
Change the episode JSON or the transcript for content, or the templates and
the
*styleCSS*,
*replyCSS*
and
*transcriptCSS*
stylesheets in
*render.go*
for layout, then re-run
**render**.
Every generated file says so in a comment at the top.
The page embeds the transcript, so a correction to
*episodes/NNN.transcript*
does not reach the site until it is rendered again.
/var/lib/observations/audio/
Where the audio is served from on the server.
EXAMPLES
Check that a recording still carries its transcript, before anything is done to it:
observations transcript --format streams ~/Downloads/recording.m4a
Ingest a recording and render the site:
observations add \
--source-title "Why We're Dropping Basecamp" \
--source-url https://blogs.library.duke.edu/blog/2023/11/30/why-were-dropping-basecamp/ \
--source-author "Will Sexton" \
--source-site "Duke University Libraries Blog" \
--source-published 2023-11-30 \
--out ../web/observations \
~/Downloads/recording.m4a
An episode built around a subject, reading two texts. Ingest names the first and gives the episode its own title:
observations add \
--title "Commoditizing Your Complement" \
--source-title "Laws of Tech: Commoditize Your Complement" \
--source-url https://gwern.net/complement \
--source-author "Gwern Branwen" \
--source-site gwern.net \
--source-published 2018-03-17 \
--source-note "intro only" \
~/Downloads/recording.m4a
then the second is appended to
'sources'
in
episodes/002.json:
{
"title": "Strategy Letter V: The Economics of Open Source",
"url": "https://www.joelonsoftware.com/2002/06/12/strategy-letter-v/",
"author": "Joel Spolsky",
"published": "2002-06-12",
"site": "Joel on Software",
"note": "the main piece"
}
Publish it. The two halves go by two different routes, because the audio is not in git:
git add users/Profpatsch/observations users/Profpatsch/web/observations
git commit -m "observations: episode 001"
git push legosi-web canon
rsync -av users/Profpatsch/observations/audio/ \
root@legosi:/var/lib/observations/audio/
SEE ALSO
ffmpeg(1), pass(1), observations-transcripts(5), observations(7), observations-transcripts(7)
AUTHORS
Profpatsch
CAVEATS
The audio directory is deliberately outside git, which means it is not backed up by pushing. The recordings are the one part of an episode that cannot be regenerated; keep them somewhere durable.
The feed carries no
itunes:image,
because there is no cover art yet.
The feed is valid and works in podcast clients, but Apple Podcasts will not
accept a directory submission without artwork.
Transcripts are recogniser output as it came out of the recording, and stay that way until someone corrects them by hand. An uncorrected one mishears every proper noun in the episode and punctuates by guessing at intonation; a corrected one has been checked against the sources it quotes. render publishes whichever of the two it finds, with nothing on the page marking which it is, so an episode whose transcript has not been through a correction pass publishes its mishearings under the names of the people it quotes. See observations-transcripts(7) for what correcting one involves and what must not be done to it.
Episode numbers and the URLs derived from them are permanent: they appear in feed GUIDs, and podcast clients remember them. Renumbering a published episode makes clients treat it as a new one.
observations(7)
NAME
observations - an audio format for reading someone else's text out loud, and thinking about it
DESCRIPTION
This page is the reasoning behind observations(1), which is the reference: the flags, the files and the caveats are there, and nothing here is needed to publish an episode. What is here is why the format and the publishing pipeline are shaped the way they are – the constraints that would otherwise be rediscovered by experiment, and the one data-loss trap that is easy to walk into.
WHAT AN OBSERVATION IS
An observation is a recording of one person reading a short text – an essay, a blog post, a piece of documentation – and thinking out loud about it while doing so. It is deliberately not a podcast in the interview or panel sense: there is no guest, no segment structure, and no attempt at production value.
The form is fixed by three properties, each of which has consequences for the software:
The text is someone else's
An episode is always about one or more specific sources, each identified by a URL, a title, an author and a publication date. The sources are the anchor: the episode page links to them and the feed names them. This is why a source has four required fields and not a free-form description, and why the episode title normally carries the source's own title rather than an invented one – the reader should be able to find the thing being read.
Some episodes are built around a subject rather than a single text, and work through several in one sitting: a short one to introduce the idea, then the piece that treats it properly. Such an episode has a title of its own, because neither source's title is an honest name for it, and each source carries a note saying what it is doing there. The single-source case stays the default, and writes no title at all.
The commentary is unscripted
The value of the form is that reactions happen while reading, not afterwards in an edit. That means recordings are long, single-take, and not worth editing. A 30-minute episode is normal, and the recording is the artefact rather than raw material for one.
One voice, recorded on a phone
There is no studio and no multitrack session. The input to the pipeline is whatever the phone produced.
THE ARTEFACT IS THE RECORDING
observations serves the file that came off the recording device, unmodified. It does not transcode, normalise, trim, or re-tag it.
This is not laziness, it is the consequence of the input. The recorder produces HE-AAC mono at 48 kbit/s – already speech-tuned and already small, on the order of 11 MB for half an hour. Any re-encode of that is lossy-to-lossy: it would cost quality to buy compatibility that AAC in an .m4a container does not actually lack, since every podcast client and every browser plays it. Transcoding to MP3 in particular would produce a larger file that sounds worse.
The second reason is the one that matters more, and it is the subject of the next section.
THE TRANSCRIPT ONLY EXISTS ONCE
The recordings this format is built around are made by Google Recorder, which
performs speech recognition on the device as it records.
It stores the result
inside the .m4a,
as an additional
mett
data track with the content type
application/transcription_2,
alongside two further tracks
(audio_tags_2, waveform_1)
that
observations
ignores.
That track is a word-level transcript: for the first episode, 308 timed segments containing 4051 words, each with a start and end offset in milliseconds. It is obtained with
ffmpeg -i rec.m4a -map 0:1 -c copy -f data -
and a small protobuf decoder – no speech recognition, no API, no alignment pass. It is, in effect, free.
It is also unrecoverable. Nothing outside the original file has it. Every ordinary audio operation – transcoding, remuxing, "cleaning up the metadata", running the file through an editor – drops unknown data tracks silently and succeeds. The audio is unharmed, the file plays fine, and the transcript is simply gone, with no error to notice. Re-deriving it later means running a speech recogniser over the archive and accepting a different, worse result.
The rule that follows is the least negotiable thing in this program: add extracts the transcript at ingest time and stores it in the repository, whether or not anything currently displays it. For the first two episodes nothing did: the data was captured anyway, because the moment to capture it is the only moment it exists. The episode pages render it now, which is the argument paying off rather than changing – had it been captured only once there was a use for it, there would have been nothing to render.
add does not refuse a file with no transcription track; it ingests it and says the track is missing. Refusing was tried, on the argument that a missing track usually means the file has already been through a tool that stripped it. The argument is right about the cause and wrong about the remedy: add cannot tell a stripped file from one that never had a transcript, so refusing turns a fact worth knowing into an obstacle in front of every recording that was made some other way – and by the time add runs, whatever damage there was has already been done. Ingesting loses nothing that was not lost before, and an episode without a transcript is still an episode.
The moment the check is worth anything is before the destructive step, not at ingest, and it does not need a recording to be an episode:
observations transcript --format streams recording.m4a
prints the container's data tracks and marks the transcript, and exits non-zero when there is none. That is the check to run before moving, converting or tidying up a recording, which is the moment the track is usually lost.
Storing it in git rather than leaving it in the container is deliberate: it is small, it diffs, it greps, and it survives the audio file being moved, re-hosted or lost. It also makes a transcript view, cross-episode search, or click-to-seek a later rendering decision rather than a re-recording problem. The first and third of those are now on the episode pages, and neither required going back to the audio – which is the argument holding: the seeking was built years after the recording, from a file that was captured before anyone knew what it would be for.
Being in git is also what makes the file safe to edit. What the recogniser produced is unrepeatable only up to the moment it is committed; after that the history holds it, a correction is an ordinary diff, and a bad one is git checkout. The rule that nothing may touch the transcript belongs to the window between the recorder and add, not to the file afterwards – which is just as well, because raw recogniser output is wrong often enough to be worth correcting. See observations-transcripts(7).
It lives in episodes/NNN.transcript, beside the episode rather than inside it. The two were one file at first, and the result was that a thirty-line record of what an episode is sat underneath tens of thousands of lines of timed words: every diff, every git log -p and every attempt to hand-edit a source had to be done through that. The split costs one derived path – the transcript's name comes from the episode's, so there is nothing to keep in sync and no way to point at another episode's transcript – and buys back a metadata file that can be read at a glance.
The stored shape is not the recogniser's shape
What comes out of the container is a flat list of words, each with its own start and end. That is the right shape for a protobuf and the wrong shape for a file someone edits: encoded as JSON it cost 117 bytes and five lines per word, so a half-hour episode was twenty-two thousand lines, and correcting a single word meant finding it in that and then computing an index into a flattened array the nesting hid. Every error in the first correction pass came from that arithmetic – deletions shifting the indices of later edits, paragraph breaks riding on deleted words, one word replacing three.
So the stored format is line-oriented and subtitle-shaped instead: about six words to a line, timestamps in a fixed-width margin, structure on its own lines. Correcting a word is editing a line. The cost is that per-word timings are not kept – interpolating within a line is accurate to about a tenth of a second, which is inside what seeking audio needs, and the exact values remain in git history. See observations-transcripts(5).
THE EPISODE PAGE
The page is the recording plus the text of it, and every decision on it follows from one fact: leaving the page stops the audio, and no browser comes back to a position half an hour into a recording.
So every link off an episode page – the sources, and the → at the end of each quotation – opens in a new tab, which the rest of the site does not do. The arrow's link carries a text fragment taken from the quotation's opening words, so a browser that supports them scrolls to the sentence and highlights it and one that does not opens the article. Those words come from the transcript, which is the second place a correction pass pays off: a misheard quotation matches nothing. Where the speaker reworded the run-up, a .skip moves the fragment past it (observations-transcripts(5)).
Where scripting is available the transcript is also an index into the audio: the line being spoken is highlighted, and clicking a line seeks to its stored start, which is exact rather than interpolated. Seeking is offered only while the recording plays, and the text is styled as clickable only then – a click that started playback would mean a page being read in quiet can be made to speak by accident. Selecting text does not seek, so a passage can be copied out while it plays, and nothing scrolls on its own. Without scripting the text is all still there and each paragraph timestamp is an ordinary link, so a #tms fragment names a passage in a way that can be shared.
The transcript is not in the feed. An entry carries the episode's summary; a corrected mishearing is not news, and should not re-notify every subscriber.
WHY THE AUDIO IS NOT IN GIT
The site is published by pushing a git repository, and it would be simpler for the audio to ride along in the same push. It does not, for two reasons.
First, this repository is published to https://sources.profpatsch.de/Profpatsch/, so anything committed here appears in a public source browser. Audio files are not source.
Second, and less reversibly: git keeps every blob forever, and compressed audio does not delta-compress. At roughly 11 MB an episode, a weekly cadence is about half a gigabyte a year added permanently to a repository that is cloned often and never shrinks. The cost is not the disk, it is that the mistake cannot be undone later without rewriting history.
So the two halves are published by two different mechanisms, and this is the one piece of friction the design accepts deliberately:
Pages and feed
Rendered into users/Profpatsch/web/observations/ and pushed with the rest of the website.
Audio
Copied to /var/lib/observations/audio/ on the server and served from a separate nginx location.
Keeping the audio outside the Nix store is the same argument in a different key: were the site a store path containing the episodes, every new episode would require a system rebuild and a closure copy to publish one HTML file and one audio file. The site is static output, not a system configuration, so it is deployed like content.
WHY A FEED AND NOT AN ACTIVITYPUB ACTOR
The obvious way to be present on the Fediverse is to be an actor, and the
machinery to do that already exists in this repository
(the activitypub-go library).
observations
does not use it.
An actor is a mailbox: once it can be followed, it can be replied to, and replies have to be received, moderated and answered. That is a permanent obligation, and it is a social one rather than a technical one – it cannot be discharged by writing better code. It is not obviously the right one for a format whose whole point is unhurried reading.
A podcast feed has none of that and loses very little. It is subscribable in every podcast client, and it is the standard input to feed-to-Fediverse bridges, so the option of an actor remains open and costs nothing to keep open. The bet is that publishing should not create an inbox until there is a reason to want one.
THE INBOX THAT WAS NOT SUPPOSED TO EXIST
The section above argued against having an inbox, and then the site grew one: a drop zone where a listener can send a recording of their own, served by observations-inbox(1). That is a reversal, and it is worth being precise about what was actually being argued, because most of it still holds.
The objection was never to receiving things. It was to the obligation that a federated actor creates: something followable is something repliable, and a reply in a conversation expects an answer. That expectation cannot be bounded by code, it does not stop when attention runs out, and withdrawing from it after other servers have followed is not something the protocol makes graceful.
A submission slot is a different shape, and the differences are the whole argument:
-
It is one-way. There is no thread, and nothing about sending a recording implies a reply. What arrives is material, not conversation.
-
It is bounded. Five megabytes at a time, fifty in total; when it is full it closes and says so. A federated inbox has no such number.
-
It is switchable. Turning it off breaks nobody's follow and leaves no dangling relationship – the page simply stops offering it. Un-federating an actor is not that.
-
It is quiet. Nothing arrives in public, nothing is addressed to an audience, and there is no timeline for a bad-faith submission to be visible in. The failure mode of a submission is that it is deleted and no one ever knows.
So the standing bet is unchanged: no actor, no federated inbox, and conversation still happens on Mastodon under an account that already exists and is already moderated. What changed is the recognition that "send me a recording" does not need any of that machinery, and that asking listeners to record observations of their own while providing nowhere to put them was the weaker position.
What that costs
An endpoint on the public internet that accepts uploads is found by scanners within days, so the slot is not open: a passphrase spoken at the start of each episode is what unlocks it. Reaching for something only listeners have is deliberate. It cannot be scraped from the page, it costs nothing to change, and the set of people who have heard it is exactly the set of people the invitation is meant for.
Submissions are held, not forwarded. The notification carries an unguessable link rather than the audio, because a recording attached to a mail outlives every deletion, and being able to delete one is the point. There is no listing route for the same reason a token is not a filename: one link exposes one recording, and knowing it says nothing about any other.
Transcription is offered because reading is faster than listening, and it is opt-in because the recording is someone else's voice and sending it to Google is not a decision this program gets to make for them. Unticked means the audio never leaves the machine it arrived on.
A submission is the one place where the rule above – that the artefact is the file that arrived – is bent, and it is worth saying why the exception does not undermine it. A recording made in the page comes from MediaRecorder, which cannot write a duration into a container it is still writing, so the file arrives declaring no length and no player can offer a scrubber for it until it has been played through once. Such a file is remuxed at ingest: the encoded audio is copied into a new container, not decoded, so the samples are the samples that arrived. The reason this is safe here and not for an episode is that the thing an episode would lose to a remux – the transcript track, which exists nowhere else – is a property of Google Recorder files, and a submission has no such track to lose. See observations-inbox(1).
Recording in the page
The slot takes a dropped file, and it also records one: the episode page has a button that captures from the microphone and hands the result to the same file input a dropped file lands in. This is the difference between replying being a thing you do now and a thing you do later with a phone, a file manager and a laptop – which is to say, mostly not at all.
It is strictly an addition.
The button ships hidden and is revealed only where
MediaRecorder
exists, so a browser without it, or without scripting at all, is left with a
plain file input that works everywhere.
The recorded blob goes through the same checks as any other upload, and the
server sniffs the bytes rather than believing what the browser called them.
IDENTIFIERS ARE PERMANENT
Episodes are numbered sequentially and live at /NNN/. Feed GUIDs are derived from that number.
Numbers rather than title slugs because a URL that encodes the source's title cannot be corrected later without breaking every subscriber and every archive that recorded it, and because the source's title is not observations's to fix. Numbers rather than dates because two recordings can share a day, and because the publication date of the episode and the date of the text being read are different things that a date-shaped URL invites confusing.
The title displayed –
001 -- Why We're Dropping Basecamp
– carries both: the number that is stable and the source title that is
searchable.
Retitling an episode is therefore always safe.
SEE ALSO
ffmpeg(1), observations(1), observations-transcripts(5), observations-transcripts(7)
AUTHORS
Profpatsch
observations-transcripts(5)
NAME
observations-transcripts - transcript file format for observations episodes
DESCRIPTION
An episode's transcript lives in
episodes/NNN.transcript,
one file per episode, beside the
episodes/NNN.json
that describes it.
It is a line-oriented text file: a line whose first character is
'.'
is a control line, and every other line is a timed line of speech.
Nothing in the format needs quoting or escaping except a source title. No word a speech recogniser produces contains a space, a quote or a backslash, so a speech line can be split on whitespace and read directly.
Speech lines
HH:MM:SS.mmm HH:MM:SS.mmm text of the line
Two timestamps – when the line starts and when it ends – then two spaces, then the words. Timestamps are offsets into the recording, always twelve characters wide, always with hours and milliseconds even when they are zero. The fixed width is the point: the text of every line begins at the same column, so the file can be read as prose by looking past a margin, and a timestamp can be found by eye without counting.
A line covers exactly the audio its words do: the start is the first word's, the end is the last word's. Lines do not overlap and are in ascending order. Gaps between them are silence.
Control lines
.\" text
A comment, in the roff convention.
.episode NNN
The episode this transcript belongs to.
.recorded YYYY-MM-DD
The day of the recording.
.source id "author "title""
Declares a text the episode reads, and gives it a short id for .quote to refer to. Author and title are Go-quoted strings, because titles contain apostrophes, colons and occasionally quotation marks.
.url id url
The URL of a declared source. It is a line of its own because URLs are long and would otherwise force the .source line past a readable width.
.p
A paragraph break, as the recogniser marked it. It carries no timestamp; the line after it does.
.quote id
Opens a passage read aloud from the named source.
.skip n [-m]
Trims the words a quotation offers as its link into the source text. Written directly after the .quote it belongs to, and only where the default is wrong.
.endquote
Closes it.
A quotation is rendered with a link to the passage it was read from, built as a text fragment from the quotation's own opening words (observations(1)). That works whenever the speaker began reading at the start of a sentence. When they reworded the run-up it does not: the article says "the group from whom the DEI movement drew", the recording says "from which the DEI movement drew", and the opening words match nothing. .skip n drops the first n words before the fragment is taken, and an optional negative second count drops words from the end:
.quote sexton .skip 2 00:34:11.100 00:34:13.500 from which the DEI movement drew its .endquote
Four of episode 001's thirty quotations need one.
It counts words rather than giving replacement text on purpose. Nothing can check either against the article, because rendering does no networking, so a line that could state words the recording does not contain would be unverifiable in both directions; a skip can only ever select words that are already in the file. A skip that runs past the end of the quotation leaves the link pointing at the article, which is what a fragment that matches nothing does anyway.
The id is conventionally the author's surname in lower case, so that marking up a passage by hand requires nothing to be looked up.
Example
.\" observations transcript, episode 001. .episode 001 .recorded 2026-08-28 .source sexton "Will Sexton" "Why We're Dropping Basecamp" .url sexton https://blogs.library.duke.edu/blog/2023/11/30/\ why-were-dropping-basecamp/
.p 00:00:00.150 00:00:02.670 Hey, and welcome to observations. 00:00:04.230 00:00:06.570 Today, I would like to talk. I would 00:00:06.570 00:00:08.490 like to read and then maybe talk a
.p 00:00:41.490 00:00:43.530 And the article starts like this. .quote sexton 00:00:45.510 00:00:47.730 We at Duke University libraries have 00:00:47.730 00:00:49.650 decided to stop using the project .endquote 00:00:52.110 00:00:54.150 I'm not yet done with the intro.
LINE WIDTH
Text aims for 37 characters and may reach 42, which are the subtitle widths the broadcast style guides converge on: long enough not to shred a sentence into scraps, short enough to take in at a glance.
The width is not only cosmetic. Because a line is about six words, and a line's timestamps are exact, the position of a word inside it can be interpolated to about a tenth of a second – which is what makes it safe not to store per-word timings at all. Longer lines would degrade that: at one line per utterance the same interpolation is out by up to three seconds.
WHAT IS NOT STORED
Per-word timings
The recogniser produces a start and end for every word, and this format keeps only the line's.
This is a deliberate loss, taken once. Interpolating within a line is accurate to roughly 120 ms in the median and 420 ms at the ninetieth percentile, which is inside the tolerance of seeking audio by clicking a word – the use the timings exist for.
In the event nothing needs the interpolation. An episode page seeks by the line, to its stored start, which is exact and is where the phrase a reader clicked begins; a listener following the text does not want to land in the middle of a sentence. The estimate above is therefore the argument that a line is a fine enough grain to store, rather than something the site computes.
The exact per-word data remains in git history, in the NNN.transcript.json files this format replaced, so the decision is reversible for as long as the history is.
Utterance boundaries
The recogniser groups words into silence-delimited segments. The format does not name them, because it does not have to: a line never spans a pause, so every one of them survives as a gap between consecutive lines.
Bare and formatted word forms
The recogniser emits each word twice, once bare and once punctuated:
'Hey'
alongside
'Hey,'.
Only the second is kept.
The two never differ except in punctuation and the case of the first letter,
so nothing is lost that could not be recovered by stripping.
SEE ALSO
observations(1), observations(7), observations-transcripts(7)
AUTHORS
Profpatsch
CAVEATS
The format has no way to say that a passage is unintelligible. A correction pass leaves such passages exactly as the recogniser heard them, which means a transcript can contain words nobody said and nothing marks them as suspect. See observations-transcripts(7).
observations-transcripts(7)
NAME
observations-transcripts - correcting the transcript of an episode
DESCRIPTION
Every episode ships with the transcript Google Recorder produced while the recording was being made (observations(7)). It is free, it is word-level, and it is wrong: it mishears every proper noun in the episode, it punctuates by guessing at intonation, and it writes down every "um".
This page is the procedure for correcting one. For the file it produces, see observations-transcripts(5).
It is not automated and is not a subcommand of observations(1). The judgements involved are the whole of the work, and a program that made them automatically would be a program that invented sentences. What follows is therefore a strategy, meant to be carried out with whatever tools are at hand.
THE FILE IS SAFE TO EDIT
observations(7) is emphatic that the transcript exists nowhere but inside the original .m4a, and that ordinary audio tools destroy it silently. That is true, and it is why add extracts it and commits it.
It stops being true the moment the file is in git. Then the history holds it, a correction is a diff that can be read line by line, and a bad correction is git checkout. Edit episodes/NNN.transcript in place.
Two things do have to hold:
-
Commit before correcting. The point of editing in place is that the diff is the review, and that only works when the starting state is committed.
-
Never re-run add over a corrected episode. It rewrites the transcript from the container and would silently restore every mishearing. render never writes to episodes/, so re-rendering is always safe.
START BY ALIGNING AGAINST THE SOURCES
An episode reads someone else's text aloud, so a third of it is quotation whose correct wording is a fetch away. Aligning the transcript against its sources is the first thing to do, before reading a word of it: it marks the quoted passages and finds most of the mishearings in one step.
The method, which needs no special tooling:
- Fetch each URL in the
'
sources' array of episodes/NNN.json and strip it to text. - Normalise both sides to lower-case alphanumerics and split into words.
- Align the two word lists.
Python's
'
difflib.SequenceMatcher', with 'autojunk=False', does this well; the flag matters, because without it common words are discarded as noise and the match falls apart. - Matching runs of six words or more are quotations. Merge runs separated by a small gap: a stumble mid-sentence splits one quotation into two.
- The short
mismatches inside those runs are the mishearings,
with the correct wording sitting beside them in the source.
On episode 001 this matched 62 runs, which merge into 30 quoted passages, 38% of the episode, with boundaries landing exactly where the reading starts
- the line before was
"And the article starts like this".
The two counts are worth keeping apart: it is the merged one that goes in the
file, and a pass that marks up 62 passages has cut every quotation the speaker
stumbled in half.
Merging is not optional, and the gap to merge across is larger than it looks. A first pass on 001 left eleven boundaries wrong in a way no mechanical check sees: the speaker reads a sentence, stumbles, repeats a word or two, and reads on, so the alignment reports two runs and a naive pass writes .endquote/.quote between them - splitting one sentence of the article across a boundary and crediting its second half to nobody. "It plays on a long-standing and" followed by "shameful tendency in the U.S of" is one sentence in the article and was two quotations in the file. Six such splits were joined, and the test is the source, not the recording: if the words on both sides are consecutive in the article, it is one passage however long the speaker paused in the middle of it.
The other boundary error is the same mistake at the ends, and it is worth checking for by machine because the eye slides over it. A quotation whose opening words sit in the paragraph above it, or whose closing words fall into the paragraph below, reads on the page as though the speaker said what he was in fact reading. Nine of episode 001's boundaries were wrong this way, most often by exactly one word: the transcript had "Okay, I'm going. I'm continuing. We" and then opened the quotation at "are not in the habit of running", so the "We" that begins the article's sentence was filed as commentary. Both errors are found the same way - walk outwards from each boundary a word at a time and ask whether the extended run is still consecutive in the source - and the answer says how many words to move, which is why this is worth a script rather than a reading.
Moving a word across a boundary usually means splitting a line, which needs a timestamp the format does not store; take it from git history as above. It also strands single words on lines of their own, and those are the one case where the merge rule in REFLOWING LINES applies cleanly: the split was made for markup reasons, so there is no pause in front of the stranded word and it can go back on the line above.
On the two-source episode 002 alignment alone cannot attribute a passage, and the transcript is wrong wherever it is trusted to. The two sources overlap: Branwen's page quotes Spolsky's article at length, so roughly two fifths of the second text also appears verbatim in the first, and 919 of the episode's words align to both. Every one of them is matched by whichever page the aligner reaches first, which is not evidence of anything.
What settles it is the recording, which says so out loud - "let's switch over to ... the strategy letter" - so the rule is positional: a passage belongs to the text the speaker had
open when he read it, and the switch is a single line number in the file.
Under that rule 002's 27 passages divide four to Branwen and twenty-three to
Spolsky, and the page credits each run once.
Aligning without it would have credited the whole second half of the episode,
Spolsky's article read out nearly end to end, to Branwen.
Run against the uncorrected transcript it surfaced 55 mishearings, including
"base camps"
for
"Basecamp's",
"37 signaled"
for
"37signals",
"case"
for
"ease"
and
"well"
for
"While".
Every one of those had previously been found by reading the transcript and guessing, which took hours and missed several.
WHAT IS ACTUALLY WRONG
Five classes, in the order they are worth fixing.
The counts are from episode 001, 4051 words of a single half-hour recording,
and are typical.
Proper nouns
The largest class and the most damaging, because a misheard name is
unsearchable and looks like ignorance on the reader's part.
The recogniser has no idea who is being discussed, so the same name comes out
differently every time it is said:
"David heinemann, Maya Hansen",
"David heinemeyer, Hansen",
"David Henry anime Hansen",
"Henson"
and
"Hence"
were all one person.
Misheard quotations
Found by the alignment above, along with the correct wording.
Sentences broken at a breath
The recogniser punctuates by intonation, not by syntax, so it ends a sentence
wherever the speaker paused and capitalises the next word to match.
A sentence that took two breaths arrives as two, and the seam is invisible in
the file because every line is short anyway.
The opening of episode 001 came out as four sentences, three of which cannot
stand alone:
about an article published. In 2023, November 30th.
On the Duke University libraries blog. By a person
named Will Sexton.
It is the largest class by count and the easiest to miss: 345 sentences in the
episode, median 9 words against 18 to 33 in the article being read, and 36% of
them opening with a conjunction or a preposition.
Inside a
.quote
it is mechanical, because the source has the right boundaries - 119 words
across 91 lines were restyled from it in one pass, and no word changed.
Outside one it is a judgement call, and the test is whether the second
fragment can stand as a sentence:
"And so a lot of people left"
can, and stays;
"By a person named Will Sexton"
cannot, and is joined to what it belongs to.
Speech is genuinely more fragmentary than prose, so the answer is not to join
everything.
It also survives a pass that thinks it has done it.
Episode 001 needed a second round of 34 joins after the first, because the two
rounds see different things: reading the
file
finds the stop at the end of a line, where the dangling word is right there in
the margin
("DHH was already always very."),
while reading the
page
finds the stop in the middle of a running paragraph, where nothing marks it
but the sentence not making sense
("a higher degree of. I'm thinking about societal issues").
Do both, in that order, and treat the page as the one that decides.
Two mechanical guards are worth having, because the judgement is easy and the
bookkeeping is not.
A join may not cross a
.p:
five candidates in 001 looked like broken sentences and were a paragraph break
and a three-second pause apart, and joining those would have said the speaker
ran on when he had stopped.
And lowercasing the following word is only right when the recogniser
capitalised it -
"Python Ruby Dynamic language hype"
opened a joined sentence and is a name, not a sentence opening, so a blind
pass over 30 joins produced exactly one word that had to be put back.
Question marks the recogniser guessed
67 words in episode 001 ended in a question mark.
Most were not questions -
"Thinking about?",
"coming from?"
- but some were, and the difference is not mechanical.
See
QUESTION MARKS.
Fillers
49
"um"
and
"uh".
A closed list, and it should stay closed:
"like"
and
"so"
occur 66 and 46 times and are mostly doing grammatical work.
THE PROCEDURE
- Commit anything outstanding, so the correction is its own diff.
- Align against the sources, as above. Mark the quotations with .quote and fix the wording the alignment disagrees with.
- Read the rest as prose.
- Correct by editing lines. A word is on a line with its timestamps in front of it; change the word.
- Flag, do not guess. See WHAT NOT TO CORRECT.
- Tidy the line breaks - see REFLOWING LINES.
- Verify, see VERIFYING.
REFLOWING LINES
Lines are filled greedily by
convert,
which is fast, mechanical and slightly stupid: it will strand a single word on
a line of its own where a human would have balanced the two lines.
Episode 001 has 33 such lines out of 751.
No cleverer algorithm is wanted here.
Judging where a line should break is exactly what a careful reader - or a
language model - does well and a line-filler does badly, so it is left as a
manual pass with the following things to look for:
- A line holding one short word, where the line above has room within the 42-character maximum -- and no pause between them. Check the gap before merging: a line never spans a pause (observations-transcripts(5)), so two lines separated by a second of silence must stay two lines however short they are. In episode 001 eleven of the 33 fitted within the width and all eleven were preceded by a gap of a second or more, which is why only two were merged. The stranded word is usually stranded because the speaker stopped.
- A break in the middle of a phrase that the sentence would rather keep together: between an article and its noun, a preposition and its object, a name and its surname.
- A very short line followed by a very long one. Rebalancing the two reads better even when neither was over the limit.
- A break that separates a quotation's opening words from the rest,
which makes the
.quote
boundary harder to see.
Timestamps must be edited to match when words move between lines: a line starts when its first word does and ends when its last word does. Moving a word from the start of one line to the end of the previous one means the previous line's end and the next line's start both change to the boundary between them. Nothing else in the file changes.
Splitting a line needs a timestamp that is not in the file, because the format keeps only the line's own two. Take it from git history, not from the audio: the per-word data the format replaced is still there in NNN.transcript.json, its word sequence is identical to the corrected transcript's as long as no words have been added or removed, and it gives the exact millisecond. This is the reversibility the format was designed around - see observations-transcripts(5).
Do not ask a speech model where a word starts. Gemini will answer, to the millisecond, and be wrong: measured against the recogniser's own data for the same passage of episode 001 it reported words 250-450 ms late, and the bias stayed when the clip was cut at three different offsets, so it is the model's and not the cut's. That is worse than the interpolation the format already does for free, and it is not a constant that can be subtracted - after removing the best offset the residuals were still 180 ms in the median. The same model is useful for words, which is a different question; see WHAT NOT TO CORRECT.
QUESTION MARKS
A guessed question mark is wrong, but it is doing a job: it ends a sentence. Deleting it outright runs two sentences together - "for Team Management And apparently recently"
- so each one needs one of two treatments:
Replace with a full stop
Where the speaker abandoned the thought and started again: "So? Essentially, what dhh" becomes "So. Essentially, what dhh".
Delete
Where the following words continue the same sentence: "how to spend money and? What they're giving money to" becomes "how to spend money and what they're giving money to", which also means lowering the capital the recogniser put on "What".
Neither is applied to "right?", which occurred 18 times and is a real tag question every time.
WHAT NOT TO CORRECT
The failure mode of this work is not leaving a mistake in.
It is producing a fluent sentence the speaker never said, which nothing
downstream can detect and no future reader will question.
Three things are therefore left exactly as recognised:
Anything unintelligible
If the source text does not settle it and the surrounding words do not either,
it stays as it is and goes on a list with its timestamp, for someone who can
play the audio - or for
observations listen,
see
LISTENING TO A PASSAGE.
This is not a formality, and the list is not short: episode 001 produced
sixteen such passages, and the guesses that could have been made instead would
have been wrong in the way that matters.
"Let base cam subscription Labs"
is
"let Basecamp subscription lapse";
"Complicated JavaScript fondant"
is
"no complicated JavaScript framework",
where dropping the
"no"
would have the sentence say the opposite of what was said;
"wolf sheep and shipsies"
is the title of the post being described, which no amount of context supplies
- it is
"Wolves, sheep, and gypsies",
and what settled it was not the audio but the blog the episode is about, which
still has the post on its front page.
When a passage names something, the thing named is a source too.
A plausible substitute -
"moron ethical"
for
"moral/ethical"
- reads as fluent prose while saying something the speaker did not say.
The worst of them were worse than that.
Several passages came out of the recogniser as harmless nonsense while the
words actually spoken named a notorious book and a fascist leader - the
episode is about racism in the software industry, and it says so plainly.
A pass that had guessed at those would have replaced the point of the episode
with something inoffensive and false.
Spoken self-corrections, outside a quotation
He stumbled and fixed it aloud, and the stumble is what happened.
Inside a
.quote
this does not apply, because there the transcript is reproducing someone
else's text and the text is what it is:
"the merger of murder of George Floyd"
and
"inclusive workspaces workplaces"
are both misreadings of the article, and both are written as the article has
them.
The distinction is the whole point of marking the quotations - what the
speaker said and what he was reading from are now different things in the
file, and only the first is a record of a performance.
Jokes that depend on the error
Episode 001 contains
"continued use of big band Basecamp. Sorry, I said bandcamp, which is a"
"completely different thing".
Correcting
"bandcamp"
would delete the joke and leave an apology for nothing.
This is why the proper-noun pass is hand-checked rather than a blind
find-and-replace.
LISTENING TO A PASSAGE
The list of doubtful passages needs ears.
observations listen
supplies a substitute for them: it cuts the passage out of the recording,
asks a speech model what it hears, and prints that beside the source's wording
and the transcript's:
observations listen 001 00:24:49 00:25:02
It takes the API key from the password store itself; a key from anywhere else
goes in
GEMINI_API_KEY
or
--gemini-key-file.
Timestamps may be written as the transcript stores them, as
MM:SS,
or as bare seconds; the flags are in
observations(1).
Nothing is written, so it is safe to run against a published episode.
The model is a second recogniser, not an ear.
It is wrong in different places than the one that produced the transcript,
which is exactly what makes it useful - on episode 001 it settled a dozen
passages that two readings had failed to:
"Ly."
was the tail of
"calmly",
"stickers"
was
"shtick",
"The stage is"
was
"the state is".
It also hears stumbles the first recogniser tidied away, which the format
keeps.
Three rules, each of which was paid for:
- One reading is a suggestion; two that agree are evidence. Asking again with a different --pad gives a second reading from a differently-cut clip. Where the two disagree the passage is left exactly as it is and goes on the list.
- A clip cut wide of the passage is answered confidently and wrongly.
Asked about the wrong six seconds, the model returned
"personnel filtering out"
for
"person Hitlering out"
- fluent, plausible, and not what was said. A wrong reading looks exactly like a right one, so the span is worth checking against the transcript view before the answer is believed.
- Never ask it for a timestamp.
It will give one to the millisecond and be a quarter of a second late; see
REFLOWING LINES,
which also says where a real one comes from.
Where the passage is quotation the model is not needed at all: --no-gemini-call prints the source and transcript views alone, needs no key, and answers the only question that matters there, which is what the article actually says.
VERIFYING
The mechanical checks are worth running because they catch precisely the damage that reading the prose does not:
- observations list loads the episode and reports the new word count, which should have fallen by the number of words deleted and by nothing else.
- Timestamps still ascend, no line ends before it starts, and the first and last are unchanged - the transcript still spans the recording.
- No line's text exceeds 42 characters.
- Every .quote names a declared source and is closed. The parser enforces this, so a transcript that loads is a transcript whose markup is consistent.
- go test ./....
None of that reads the transcript, and the mechanical checks pass just as happily on a pass that has gone wrong. Every defect found in episode 001 after the first pass - two misplaced .quote boundaries and several hundred sentences broken at a breath - survived all five checks above, because none of them changes a word count, a timestamp or a line width.
So then look at it. The transcript is rendered on the episode page, and the layout is where a misplaced .quote becomes obvious - a passage credited to the wrong author, or commentary set as though it were the text being read, is hard to see in the file and hard to miss on the page:
observations render --episodes episodes --out ./tmp/preview
Render into a scratch directory, not into users/Profpatsch/web/observations/, unless the intention is to publish the pass in the same commit.
Then read the page as prose, which is the only check that finds a sentence broken at a breath. In the file every line is short and a stray full stop looks like every other line; on the page the paragraph runs
an article published. In 2023, November 30th. On the Duke University libraries blog.
and the fault is unmissable. Reading the first paragraph and one blockquote is enough to tell whether a pass is finished.
EXAMPLES
The state of episode 001 after one pass, as a sense of scale:
words 4051 -> 3976 (49 fillers, plus merged words)
paragraphs 84 -> 83 (one was only "Um.")
audio span unchanged
That pass took two rounds: everything derivable from the sources first, then a
second round applying sixteen passages that had to be listened to.
Both rounds are the same operation on the same file, and the second is the one
that fixed the worst errors in the episode.
SEE ALSO
observations(1), observations-transcripts(5), observations(7)
AUTHORS
Profpatsch
CAVEATS
A correction pass is only as good as the corrector's access to the audio.
The alignment above fixes everything that is quotation, but the commentary
between the quotations is unscripted and has no source to check against.
There is nothing in the file that records which passages are still doubtful:
the flagged list lives in whatever conversation produced it and is lost
afterwards.
In episode 001 the passages that needed ears included the two most serious
errors in the transcript: in both, the words spoken were an explicit
comparison to fascism, and in both the recogniser produced harmless nonsense
instead.
Neither was recoverable from the source text, and neither looked wrong on the
page.
A pass done without audio therefore produces a transcript that reads cleanly,
is right about every proper noun in the sources, and is still wrong in exactly
the places where being wrong is worst.
That transcript is now published: the episode page renders whatever is in the
file, and nothing on it distinguishes a corrected transcript from raw
recogniser output, or a checked passage from one nobody could make out.
A reader has no way to tell, which is the argument for finishing a pass rather
than shipping it half done.
A speech model closes some of that gap - see
LISTENING TO A PASSAGE
for
observations listen
and the rules for trusting it.
It is a second recogniser, though, and not an ear: it is wrong in different
places rather than in none, and where it and the first one disagree there is
still nobody to settle which is right.
The passages it cannot settle are the same ones as before, and they are the
ones worth asking a human about.