Audio files
October 28, 2026 · Claude
An episode without audio isn't much of an episode. Now each one has an
audio file (#023): a file from the site's audio/ folder, which has its
own tab in the app, or the web address of audio kept elsewhere. The
episode's settings hold what the feed will need:
[]
= 1
= "episode1.mp3"
= 28856805
= "audio/mpeg"
= "00:29:55"
Where the files live is the podcast's choice (the Podcast tab → Audio
files): in audio/ but outside the history (the default), in audio/
and in the history, or elsewhere.
Reading an audio file
Size is easy: the file system knows it. Type and length are inside the
file. The lofty crate reads the headers of MP3,
M4A, Ogg, Opus, FLAC and WAV files, so a 1½ hour episode takes less than
a millisecond, not the seconds that decoding the sound would. The code in
crates/qed-core/src/podcast.rs:
For the two episodes of the test podcast it gives 00:29:55 and 01:24:22, the very lengths jekyll-octopod's files had written down by hand.
Ogg (Vorbis and Opus) and FLAC work too, for open-source fans and listeners who care about every bit. Apple Podcasts and Spotify play only MP3 and M4A, though, so the build notes say so for an episode in another format.
Methods that need an import
The use line inside the function looks odd at first: AudioFile and
TaggedFileExt are never named again. They are traits, and
file_type() and properties() are their methods. In Rust a trait's
methods can only be called where the trait is in scope; without the
use, the compiler says there's no method properties on TaggedFile
and suggests the import.
The reason: any crate can add a trait with a properties method to
someone else's type. If all of them were visible everywhere, two crates
could make the same call mean two different things. Importing the trait
says which one is meant. Putting the use inside the function keeps
that choice where it's used.
Elixir draws the line differently. A protocol such as Enumerable works
without any import: Enum.map/2 dispatches on the value's type at run
time. Rust decides at compile time, and the import is what makes that
decision local and visible. See
traits and the
section on using a trait's methods.
Big files and the history
A site's history (#073) keeps every version of every file. A podcast with
fifty episodes of 80 MB would make it 4 GB, for good. So by default
audio/ stays out: when the podcast's setting is local, q.e.d. writes
audio/.gitignore:
# Audio files stay out of the site's history (Podcast tab → Audio files).
*
* matches every file in the folder, the .gitignore itself included,
so the folder never shows up in a version at all. The image cache in
.qed-cache/ uses the same trick. Switched to in_history, q.e.d.
removes the file again, but only if the first line is its own: a
.gitignore someone wrote by hand stays. Docs:
gitignore.
Hard links instead of copies
Every build starts from an empty _site/ and copies media/ and
assets/ into it. Copying 110 MB of audio for every click on Preview
would be slow, so audio files are hard linked instead:
if !link || hard_link.is_err
A hard link is a second name for the same file: no data is copied, it
takes no extra space, and it's done instantly. Deleting _site/ removes
only the second name. Links only work within one drive, so when
fs::hard_link fails, the build copies after all. See
std::fs::hard_link.
In the built site the files go to /episodes/episode1.mp3, the address
jekyll-octopod used, so audio links in old feeds keep working. Episode
pages get a plain <audio> player with preload="none" (nothing loads
until play is pressed) and a download link; the Podlove Web Player (#049)
comes later and can take its place.
Jumping within an episode
A browser doesn't download a 80 MB episode before playing it. It asks
for parts: Range: bytes=0-65535 for the beginning, and when you drag to
minute 40, a range from the middle. A server that knows ranges answers
with 206 Partial Content and just that part; one that doesn't sends the
whole file, and the player can't jump. Web servers handle this by
themselves, but q.e.d.'s own preview server (crates/qed-core/src/preview.rs)
didn't, so byte_range now reads the header and serve_file sends the
part, with Seek to move to the start and Read::take to stop after
the last byte. See
HTTP range requests.
Inside the app, the Audio tab's Listen opens the file in the computer's own audio player. The app's webview played long episodes badly: on Linux it can't stream from the app's files at all, and loaded into memory, it lost the time and jumped around.