feat(library): album loudness from the tracks' summed block histograms (M464 #4996)
release / govulncheck (push) Successful in 27s
release / web (push) Successful in 1m27s
release / go (push) Successful in 1m47s
release / integration (push) Successful in 4m51s
release / android (push) Successful in 6m23s
release / Build signed APK (releases and dev) (push) Successful in 6m25s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m30s
release / Verify release artifacts (tag releases only) (push) Skipped

Album-mode normalization plays a whole album at one gain. That gain comes
from album_loudness (migration 0066): BS.1770's gated loudness over every
block on the album, computed by summing the tracks' stored histograms and
gating the sum. No audio is decoded again. Album true peak is the loudest
track's.

- Recomputed by the loudness worker each tick, after the track pass and
  whether or not analysis is switched on. ListAlbumsNeedingLoudness lists
  albums whose md5 over (present track id, measurement version and time) no
  longer matches the stored digest. One comparison covers every way
  membership changes (scan retag, duplicate merge, delete, missing and
  restored) without hooking each.
- No album value until every present track has a settled measurement, so
  an album's gain doesn't shift mid-listen as the rest is measured. Silent
  and unreadable tracks count as settled.
- Rows for albums with no present track left are dropped.
- The parser and the merge share trimBins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 13:27:02 -04:00
co-authored by Claude Opus 5.5
parent aee4b50bd4
commit c2f81bf8df
10 changed files with 722 additions and 19 deletions
+12 -8
View File
@@ -220,7 +220,6 @@ var (
// histogram, and the closing summary.
type ebur128Parser struct {
bins [loudnessHistBins]int32
blocks int
inSummary bool
summary map[string]string
lastLines []string
@@ -273,7 +272,6 @@ func (p *ebur128Parser) line(raw string) {
}
bin := int(math.Round((v - loudnessHistFloor) * loudnessHistPerLU))
p.bins[min(bin, loudnessHistBins-1)]++
p.blocks++
}
func (p *ebur128Parser) tail() string {
@@ -281,18 +279,24 @@ func (p *ebur128Parser) tail() string {
}
func (p *ebur128Parser) histogram() blockHistogram {
if p.blocks == 0 {
return blockHistogram{}
}
return trimBins(&p.bins)
}
// trimBins turns a full-range bin array into a histogram trimmed to its
// occupied bins; an empty array gives an empty histogram.
func trimBins(bins *[loudnessHistBins]int32) blockHistogram {
first, last := 0, loudnessHistBins-1
for p.bins[first] == 0 {
for first <= last && bins[first] == 0 {
first++
}
for p.bins[last] == 0 {
if first > last {
return blockHistogram{}
}
for bins[last] == 0 {
last--
}
counts := make([]int32, last-first+1)
copy(counts, p.bins[first:last+1])
copy(counts, bins[first:last+1])
return blockHistogram{start: int16(first), counts: counts}
}