A track's normalization gain is never computed #102

Closed
opened 2026-08-13 22:59:40 +02:00 by Claude · 1 comment
Collaborator

AudioNormalizationTask (Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs, key AudioNormalization, 24 h) runs ffmpeg's EBU R128 filter over tracks and albums and stores the gain, which clients apply so that a playlist does not jump in volume between albums.

The column is read on our side already — #72 was "a track's normalization gain is not read" and is closed — so what is missing is the thing that computes it. Upstream normalizes per track and per album, skips what it has already done, and has a per-library switch.

Music is a first-class library kind here, so this is in scope rather than an edge; it is filed separately from the umbrella because it is real ffmpeg work rather than a sweep.


Where to start

The read side landed with #72 — find the column there rather than guessing at
it, and check both the item row and the stream row, because upstream stores a
track's and an album's separately.

Upstream: Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
(key AudioNormalization, 24 h), and MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
for how a value reaches a row on ingest. It skips what it has already measured
and has a per-library switch; the measurement is ffmpeg's EBU R128, which
crates/jellyfin-ffmpeg is the crate to spawn from.

How to know it is done

Measure a track both servers can see and diff-db the column — it is a float
produced by the same filter in the same ffmpeg build, so agreement is the check
rather than an approximation. Say what tolerance, if any, was needed and why.

House rules

CLAUDE.md is the short version and these are the parts that bite: tests run
under cargo nextest; upstream's source is the checkout in upstream/ at the
pin
(v12.0-rc5, 6d501ba4) and not the clone beside the repository;
generated files are regenerated rather than edited; a comment says what the code
does, not how it got there; and Dn references stay out of code and comments.
ARCHITECTURE.md decides which crate a thing goes in and which dependency edges
are illegal, DECISIONS.md says why a rule exists.

The reference pair is cargo xtask reference up (and reference provision,
which is idempotent); a run is
cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR,
with diff-db for the two databases. crates/jellyfin-harness/README.md says
what a diff means and what an ignore entry has to carry — every entry needs a
reason and an expiry milestone, and an adds-only history is the smell the risk
table names.

`AudioNormalizationTask` (`Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs`, key `AudioNormalization`, 24 h) runs ffmpeg's EBU R128 filter over tracks and albums and stores the gain, which clients apply so that a playlist does not jump in volume between albums. The column is read on our side already — #72 was "a track's normalization gain is not read" and is closed — so what is missing is the thing that computes it. Upstream normalizes per track and per album, skips what it has already done, and has a per-library switch. Music is a first-class library kind here, so this is in scope rather than an edge; it is filed separately from the umbrella because it is real ffmpeg work rather than a sweep. --- ## Where to start The read side landed with #72 — find the column there rather than guessing at it, and check both the item row and the stream row, because upstream stores a track's and an album's separately. Upstream: `Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs` (key `AudioNormalization`, 24 h), and `MediaBrowser.Providers/MediaInfo/AudioFileProber.cs` for how a value reaches a row on ingest. It skips what it has already measured and has a per-library switch; the measurement is ffmpeg's EBU R128, which `crates/jellyfin-ffmpeg` is the crate to spawn from. ## How to know it is done Measure a track both servers can see and `diff-db` the column — it is a float produced by the same filter in the same ffmpeg build, so agreement is the check rather than an approximation. Say what tolerance, if any, was needed and why. ### House rules `CLAUDE.md` is the short version and these are the parts that bite: tests run under `cargo nextest`; upstream's source is the checkout in `upstream/` **at the pin** (`v12.0-rc5`, `6d501ba4`) and not the clone beside the repository; generated files are regenerated rather than edited; a comment says what the code does, not how it got there; and `Dn` references stay out of code and comments. `ARCHITECTURE.md` decides which crate a thing goes in and which dependency edges are illegal, `DECISIONS.md` says why a rule exists. The reference pair is `cargo xtask reference up` (and `reference provision`, which is idempotent); a run is `cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR`, with `diff-db` for the two databases. `crates/jellyfin-harness/README.md` says what a diff means and what an ignore entry has to carry — every entry needs a reason and an expiry milestone, and an adds-only history is the smell the risk table names.
Author
Collaborator

Done, on main — 968bf7d.

Three rows each — the album and both tracks — identical to the bit, with no tolerance. The value is a C# float widened into a REAL, so the column holds -22.200000762939453; parsing to f32 and widening is load-bearing rather than incidental. Upstream's > 1 batching is kept, which drops a lone measurement rather than writing it, and EnableLUFSScan is written into options.xml so an upstream reading the same directory agrees.

One response-level difference resisted and turned out to be upstream's: immediately after its task ran, upstream omitted AlbumNormalizationGain where we reported it, because its in-memory item cache still held the pre-task album. Restarting upstream makes the two agree exactly. Cache staleness, not a contract.

Done, on `main` — `968bf7d`. Three rows each — the album and both tracks — **identical to the bit, with no tolerance**. The value is a C# `float` widened into a `REAL`, so the column holds `-22.200000762939453`; parsing to `f32` and widening is load-bearing rather than incidental. Upstream's `> 1` batching is kept, which drops a lone measurement rather than writing it, and `EnableLUFSScan` is written into `options.xml` so an upstream reading the same directory agrees. One response-level difference resisted and turned out to be upstream's: immediately after its task ran, upstream omitted `AlbumNormalizationGain` where we reported it, because its in-memory item cache still held the pre-task album. **Restarting upstream makes the two agree exactly.** Cache staleness, not a contract.
Sign in to join this conversation.
No description provided.