package library import ( "bufio" "context" "errors" "fmt" "io" "log/slog" "math" "os/exec" "regexp" "strconv" "strings" "time" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // Loudness analysis (M464 #4995). // // Every track is measured with ffmpeg's EBU R128 filter, which reports the // ITU-R BS.1770 values loudness normalization works from: // // integrated loudness the gated, K-weighted average loudness of the whole // track, in LUFS. A client levels a track by playing it // at (target - integrated) dB. // true peak the highest inter-sample peak, in dBTP. It bounds how // far a quiet track can be raised before it clips. // loudness range how much the loudness moves within the track. // // Tags are not trusted: ReplayGain values in the wild are written against four // different reference levels, and most files have none. // // The filter also logs the loudness of every 400 ms gating block (one every // 100 ms). Those are kept as a histogram, because an album's loudness is the // gated loudness of every block on the album, not an average of its tracks' // values. With the histograms stored, album loudness is exact and needs no // second decode (#4996). // loudnessVersion stamps how a track_loudness row was measured. Bump it when // the measurement changes (the filter's options, the histogram's bins) and the // backfill measures every row below it again. const loudnessVersion int16 = 1 // Unlike a fingerprint, the analysis decodes the whole file, so a fixed // deadline would either cut off a long mix or be useless for a three-minute // song. The deadline is a base plus the track's length at loudnessMinSpeed: // a decode slower than that is a stall, not a big file. const ( loudnessBaseTimeout = 2 * time.Minute loudnessMinSpeed = 4 ) // errLoudnessTimeout marks an analysis that ran out of time: a fact about the // mount, not about the file. var errLoudnessTimeout = errors.New("loudness analysis timed out") // The block histogram's bins: 0.1 LU wide (the precision ffmpeg prints) from // the -70 LUFS absolute gate up to +10 LUFS. Blocks below the gate do not take // part in gating at all, so they are not counted; anything above the top bin, // which only clipped noise reaches, is counted in it. const ( loudnessHistFloor = -70.0 loudnessHistPerLU = 10 loudnessHistBins = 800 loudnessStderrTail = 20 // lines kept for an error message ) // histogramCrossCheckLU is how far the loudness recomputed from the histogram // may stray from ffmpeg's own figure before it is logged. They agree to a few // hundredths when the log means what the parser assumes, so a larger gap says // ffmpeg's output has changed underneath us. const histogramCrossCheckLU = 0.3 // ebur128Args measures the file's first audio stream. // // peak=true asks for true peak (4x oversampled) rather than sample peak, which // misses the inter-sample overs a boosted track would clip on. dualmono=true // measures a mono file as if played on both speakers of a stereo pair, which is // how it is heard; measured as one channel it would read 3 LU quiet and be // boosted too far. framelog=info makes the per-block lines print at the log // level asked for here, independent of ffmpeg's default. func ebur128Args(path string) []string { return []string{ "-hide_banner", "-nostdin", "-nostats", "-loglevel", "info", "-i", path, "-map", "0:a:0", "-af", "ebur128=peak=true:dualmono=true:framelog=info", "-f", "null", "-", } } // loudnessTimeout is the deadline for a track of durationMs. An unknown length // (0) gets the base alone, which covers an ordinary song. func loudnessTimeout(durationMs int32) time.Duration { return loudnessBaseTimeout + time.Duration(max(durationMs, 0))*time.Millisecond/loudnessMinSpeed } // loudnessResult is one attempt at measuring a track. type loudnessResult struct { // integratedLUFS is nil when no block passed the gates: silence, or a // file shorter than one 400 ms block. integratedLUFS *float32 truePeakDBTP *float32 rangeLU *float32 hist blockHistogram err error } // inconclusive reports whether the attempt failed for a reason that says // nothing about the file. Such a result is never stored: stamped at the current // version it would read as "this file cannot be measured", and the backfill // would never try it again. func (r loudnessResult) inconclusive() bool { return isInconclusive(r.err) || errors.Is(r.err, errLoudnessTimeout) } // blockHistogram counts gating blocks per 0.1 LU bin, trimmed to the occupied // range: counts[i] is the number of blocks measuring // loudnessHistFloor + (start+i)/loudnessHistPerLU LUFS. type blockHistogram struct { start int16 counts []int32 } func (h blockHistogram) empty() bool { return len(h.counts) == 0 } // gatedLoudness applies BS.1770's two gates to the histogram and returns the // integrated loudness of what passes. Every counted block is already above the // absolute gate; the relative gate drops blocks more than 10 LU below the // loudness of the blocks that passed it. ok is false when nothing passes. // // The same function measures an album, over the sum of its tracks' // histograms (#4996). func (h blockHistogram) gatedLoudness() (lufs float64, ok bool) { energy := func(i int) float64 { l := loudnessHistFloor + float64(int(h.start)+i)/loudnessHistPerLU return math.Pow(10, (l+0.691)/10) } mean := func(threshold float64) (float64, bool) { var sum, n float64 for i, c := range h.counts { if c == 0 { continue } if loudnessHistFloor+float64(int(h.start)+i)/loudnessHistPerLU < threshold { continue } sum += float64(c) * energy(i) n += float64(c) } if n == 0 { return 0, false } return sum / n, true } ungated, ok := mean(math.Inf(-1)) if !ok { return 0, false } relative := -0.691 + 10*math.Log10(ungated) - 10 gated, ok := mean(relative) if !ok { return 0, false } return -0.691 + 10*math.Log10(gated), true } // computeLoudness measures the file at path. durationMs sets the deadline. func computeLoudness(ctx context.Context, path string, durationMs int32) loudnessResult { timeout := loudnessTimeout(durationMs) runCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() cmd := exec.CommandContext(runCtx, "ffmpeg", ebur128Args(path)...) cmd.WaitDelay = fingerprintWaitDelay stderr, err := cmd.StderrPipe() if err != nil { return loudnessResult{err: fmt.Errorf("ffmpeg: %w", err)} } if err := cmd.Start(); err != nil { return loudnessResult{err: fmt.Errorf("ffmpeg: %w", err)} } // The per-block log runs to ten lines a second of audio, about 12 MB for a // two-hour mix, so it is parsed as it streams rather than buffered. p := newEbur128Parser() p.consume(stderr) waitErr := cmd.Wait() switch { case ctx.Err() != nil: // The caller gave up. Report that rather than the kill it caused, so it // is never mistaken for a verdict on the file. return loudnessResult{err: fmt.Errorf("ffmpeg: %w", ctx.Err())} case errors.Is(runCtx.Err(), context.DeadlineExceeded): return loudnessResult{err: fmt.Errorf("ffmpeg: no result within %s: %w", timeout, errLoudnessTimeout)} case waitErr != nil: var exitErr *exec.ExitError if errors.As(waitErr, &exitErr) { return loudnessResult{err: fmt.Errorf("ffmpeg exited %d: %s", exitErr.ExitCode(), p.tail())} } return loudnessResult{err: fmt.Errorf("ffmpeg: %w", waitErr)} } return p.result() } var ( // A per-block line: "[Parsed_ebur128_0 @ 0x…] t: 2.49998 TARGET:-23 LUFS // M: -31.1 S:-120.7 I: -31.1 LUFS ...". M is the 400 ms block just ended. ebur128BlockRe = regexp.MustCompile(`\bt:\s*\S+\s+TARGET:.*?\bM:\s*(-?(?:\d+(?:\.\d+)?|inf))`) // The summary's values, each on a line of its own after "Summary:". ebur128SummaryRe = regexp.MustCompile(`^(I|LRA|Peak):\s+(-?(?:\d+(?:\.\d+)?|inf))\s+(?:LUFS|LU|dBFS)$`) ) // ebur128Parser reads the filter's log: the per-block lines into the // histogram, and the closing summary. type ebur128Parser struct { bins [loudnessHistBins]int32 blocks int inSummary bool summary map[string]string lastLines []string } func newEbur128Parser() *ebur128Parser { return &ebur128Parser{summary: map[string]string{}} } func (p *ebur128Parser) consume(r io.Reader) { sc := bufio.NewScanner(r) sc.Buffer(make([]byte, 0, 4096), 64*1024) for sc.Scan() { p.line(sc.Text()) } // A line past the buffer (not something ffmpeg prints) stops the scanner; // drain the rest so ffmpeg is never blocked writing to a full pipe. _, _ = io.Copy(io.Discard, r) } func (p *ebur128Parser) line(raw string) { line := strings.TrimSpace(strings.TrimRight(raw, "\r")) if line == "" { return } if len(p.lastLines) == loudnessStderrTail { p.lastLines = p.lastLines[1:] } p.lastLines = append(p.lastLines, line) if strings.HasSuffix(line, "Summary:") { p.inSummary = true return } if p.inSummary { if m := ebur128SummaryRe.FindStringSubmatch(line); m != nil { p.summary[m[1]] = m[2] } return } m := ebur128BlockRe.FindStringSubmatch(line) if m == nil { return } v, err := strconv.ParseFloat(m[1], 64) if err != nil || v < loudnessHistFloor { // -inf, or below the absolute gate: such a block takes no part in // gating. return } bin := int(math.Round((v - loudnessHistFloor) * loudnessHistPerLU)) p.bins[min(bin, loudnessHistBins-1)]++ p.blocks++ } func (p *ebur128Parser) tail() string { return strings.Join(p.lastLines, " | ") } func (p *ebur128Parser) histogram() blockHistogram { if p.blocks == 0 { return blockHistogram{} } first, last := 0, loudnessHistBins-1 for p.bins[first] == 0 { first++ } for p.bins[last] == 0 { last-- } counts := make([]int32, last-first+1) copy(counts, p.bins[first:last+1]) return blockHistogram{start: int16(first), counts: counts} } // result turns a clean exit into a measurement. A run with no summary means // ffmpeg decoded nothing it could measure, which is a verdict on the file. func (p *ebur128Parser) result() loudnessResult { integrated, ok := p.summary["I"] if !ok { return loudnessResult{err: fmt.Errorf("ffmpeg printed no loudness summary: %s", p.tail())} } r := loudnessResult{ hist: p.histogram(), truePeakDBTP: parseLoudnessValue(p.summary["Peak"]), rangeLU: parseLoudnessValue(p.summary["LRA"]), } // ffmpeg reports -70.0 when no block passed the gate. The empty histogram // says the same thing directly. if !r.hist.empty() { r.integratedLUFS = parseLoudnessValue(integrated) } if r.integratedLUFS == nil { r.rangeLU = nil } return r } // parseLoudnessValue reads one summary figure; -inf and anything unreadable // come back nil. func parseLoudnessValue(s string) *float32 { v, err := strconv.ParseFloat(s, 32) if err != nil || math.IsInf(v, 0) || math.IsNaN(v) { return nil } f := float32(v) return &f } // loudnessOutcome is what storeLoudness did with one attempt. type loudnessOutcome int const ( loudnessMeasured loudnessOutcome = iota // integrated loudness stored loudnessSilent // read fine; no block above the gate loudnessUnreadable // ffmpeg could not decode the file loudnessInconclusive // nothing stored; worth trying again loudnessStoreFailed // the write itself failed ) // storeLoudness records one attempt. It never fails its caller: an unmeasured // track simply plays without a gain adjustment. // // An inconclusive attempt leaves any existing row alone. The scan deletes a // row when its file changes, so a row still here describes these bytes, and a // value from an older method is a better gain than none until it is redone. func storeLoudness( ctx context.Context, q *dbq.Queries, logger *slog.Logger, trackID pgtype.UUID, path string, r loudnessResult, ) loudnessOutcome { if r.err != nil { logger.Warn("loudness: analysis failed", "path", path, "err", r.err) if r.inconclusive() { return loudnessInconclusive } } params := dbq.UpsertTrackLoudnessParams{ TrackID: trackID, Unreadable: r.err != nil, AnalysisVersion: loudnessVersion, } outcome := loudnessUnreadable if r.err == nil { outcome = loudnessSilent params.IntegratedLufs = r.integratedLUFS params.TruePeakDbtp = r.truePeakDBTP params.LoudnessRangeLu = r.rangeLU if !r.hist.empty() { start := r.hist.start params.BlockHistStart = &start params.BlockHist = r.hist.counts } if r.integratedLUFS != nil { outcome = loudnessMeasured if got, ok := r.hist.gatedLoudness(); ok && math.Abs(got-float64(*r.integratedLUFS)) > histogramCrossCheckLU { // Stored regardless: ffmpeg's own figure is the track's // loudness. But album loudness is computed from the // histogram, and this says it would be wrong. logger.Warn("loudness: block histogram disagrees with ffmpeg's integrated loudness", "path", path, "ffmpeg_lufs", *r.integratedLUFS, "histogram_lufs", got) } } } if err := q.UpsertTrackLoudness(ctx, params); err != nil { logger.Warn("loudness: storing measurement failed", "path", path, "err", err) return loudnessStoreFailed } return outcome }