Menu

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:

[episode]
number = 1
audio = "episode1.mp3"
audio_length = 28856805
audio_type = "audio/mpeg"
duration = "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:

pub fn audio_info(path: &Path) -> Result<AudioInfo, String> {
    use lofty::file::{AudioFile, FileType, TaggedFileExt};

    let file = lofty::read_from_path(path)
        .map_err(|e| format!("{name} isn't an audio file q.e.d. can read ({e})"))?;
    let mime_type = match file.file_type() {
        FileType::Mpeg => "audio/mpeg",
        FileType::Mp4 => "audio/x-m4a",
        // …
        _ => return Err(format!("{name}: podcast apps don't play this kind of audio")),
    };
    Ok(AudioInfo {
        length,
        mime_type: mime_type.to_string(),
        duration: clock(file.properties().duration().as_secs()),
    })
}

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 || fs::hard_link(&source, &target).is_err() {
    fs::copy(&source, &target).map_err(io(&target))?;
}

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.