Sergey Kopanev: you sleep — agents ship

Go Back
NBP Follow-ups · Part 3

Same .bin, Different File


I bumped whisper-rs from 0.12 to 0.16 to pick up a Metal fix.

Build passed. Tests passed. Recording started. Transcription returned an error I had never seen.

Failed to load Whisper model: invalid model file

The model file was the same file that had worked thirty minutes earlier. Same path. Same ggml-base.bin. Same SHA on disk.

What changed was the parser reading the first four bytes.

What NBP Is

NBP is a macOS app that records and transcribes locally. Whisper runs on-device, model files sit on disk, audio never leaves the machine. The first article covers the why.

whisper-rs is the Rust binding around whisper.cpp. It is the load-bearing dependency for the entire transcription path. When it stops loading models, NBP is a recording tool with no output.

Why I Touched the Version

The post-recording transcription was throwing a Metal-related error on Apple Silicon under high load. Issue thread on whisper-rs pointed at a fix in 0.14 and a refinement in 0.16.

Cargo.toml:

- whisper-rs = { version = "0.12", features = ["metal"] }
+ whisper-rs = { version = "0.16", features = ["metal"] }

cargo build ran clean. The API surface I was using had moved, but the compiler led me through the rename:

  • state.full_n_segments() used to return Result<i32, _>. Now it returns i32 directly.
  • state.full_get_segment_text(i) is gone. The new path is state.get_segment(i) returning an Option<Segment>, then seg.to_str_lossy() for the text.
  • state.full_n_tokens(i) and state.full_get_token_id(i, t) are gone too.
let n_segments = state.full_n_segments();
for i in 0..n_segments {
    if let Some(seg) = state.get_segment(i) {
        if let Ok(seg_text) = seg.to_str_lossy() {
            transcript.push_str(&seg_text);
        }
    }
}

The diff was thirty lines. The compiler signed off. The mental model was “API got cleaner, same library underneath.”

That mental model was wrong.

The First Recording Stopped Working

I hit record on a test file. Inference fired. Three seconds later:

[whisper] loading model...
[whisper] error: failed to read model: bad magic
Failed to load Whisper model: invalid model file

The same ggml-base.bin had loaded under 0.12 minutes earlier. The file on disk had not changed.

I checked the model file size. Unchanged. SHA256. Unchanged. File mode. Unchanged.

The library underneath had changed what it expected the file to be.

Magic Bytes Tell the Story

whisper.cpp model files are versioned in their first four bytes — the “magic number” that identifies the format.

xxd -l 4 ~/.cache/nbp/models/ggml-base.bin
00000000: 6767 6a74                                gjt.

ggjt — the magic for the older GGML format that whisper.cpp used through mid-2024.

The model I had on disk was an older GGML file. whisper.cpp upstream had migrated to a newer format with magic ggla, then ggml, then GGUF, in successive jumps. whisper-rs 0.12 still bundled a whisper.cpp that accepted the old ggjt. whisper-rs 0.16 bundled a newer whisper.cpp that did not.

Same .bin file extension. Different binary format. No version bump in the filename. No deprecation warning at the model download URL.

The model was downloaded by NBP itself, eight months prior, against a hardcoded URL on huggingface that pointed at the format that was current then. The URL still served. The bytes had drifted.

Detecting Bad Models Before Inference

Inference is expensive. Whisper loading a model takes 1–3 seconds on this hardware. Failing inside WhisperContext::new_with_params at second 2 is a bad UX — the user sees a spinner, then a generic error.

The fix landed in two parts.

First, a magic-byte check at startup, before the user can hit record:

fn check_model_magic(path: &Path) -> Result<ModelFormat, String> {
    let mut file = File::open(path).map_err(|e| e.to_string())?;
    let mut magic = [0u8; 4];
    file.read_exact(&mut magic).map_err(|e| e.to_string())?;
    match &magic {
        b"GGUF" => Ok(ModelFormat::Gguf),
        b"ggml" => Ok(ModelFormat::Ggml),
        b"ggjt" => Err("Old ggjt format, please re-download model".to_string()),
        b"ggla" => Err("Intermediate ggla format, please re-download model".to_string()),
        _ => Err(format!("Unknown model magic: {:02x?}", magic)),
    }
}

Four bytes. One file open. Verdict before anything is loaded.

Second, the download URL got a version pin and a checksum manifest. The model fetcher now downloads against a known SHA, validates the magic on arrival, and stores both the file and a small JSON next to it recording which whisper-rs version downloaded it.

{
  "model": "ggml-base.bin",
  "magic": "GGUF",
  "downloaded_by": "whisper-rs-0.16",
  "downloaded_at": "2026-03-26T12:14:33Z",
  "sha256": "..."
}

If the running whisper-rs version stops matching the magic the file was downloaded against, the app prompts a re-download instead of trying to load and crashing.

What This Cost

Two hours of confused diffing. Two more drafting the magic-byte detector. One more on the download manifest.

A user-facing migration: every existing user with an older NBP install has a model file that the new version refuses to load. The first launch after upgrade shows a re-download prompt. 70MB for base. 1.5GB if they had large-v3. Not free, not invisible, but recoverable.

The alternative — keeping whisper-rs pinned at 0.12 — was a known Metal bug under load. The migration was cheaper than the bug.

The Hidden Lesson

The compiler tells you about API changes. It does not tell you about format changes in adjacent vendored binaries.

whisper-rs is not just a Rust API over a C library. It is also implicitly an opinion about which model files load. That opinion lives downstream of the version number and is invisible to cargo.

A clean compile on a dependency bump is not enough evidence that runtime will behave the same. For libraries that consume external file formats — model files, video containers, archive formats, anything binary — the format compatibility is part of the version contract that is not in the type signatures.

A magic-byte check on every external file the app loads is forty lines of Rust and the difference between a hard crash and a clear error.

Takeaway

When the compiler accepts a dependency bump, that is not the green light. That is permission to start running the app.

Read the first four bytes of every external file. The format silently rotates more often than the version contract suggests.


Next: The CLIs Are Already Installed.