Skip to content

Measure spectrum bands over the whole file with ffmpeg - #128

Merged
6uclz1 merged 2 commits into
mainfrom
feat/spectrum-band-measurement
Oct 22, 2026
Merged

6uclz1 merged 2 commits into
mainfrom
feat/spectrum-band-measurement

Conversation

@6uclz1

@6uclz1 6uclz1 commented Oct 22, 2026

Copy link
Copy Markdown
Owner

Stacked on #127 (it shares the ffmpeg runner and test fixtures). Merge #127 first; this PR's base then moves to main.

Summary

audio spectrum analyze did not measure the spectrum:

  • It read only the first ~2.7 s of the file (interleaved_mono(limit=min(sr*4, 131072))). On the Phase Trance render, that is just the intro.
  • It sampled each band at three Goertzel frequencies. An 80 Hz tone at −9 dBFS RMS read −27.7 dB.
  • It had no 6–10 kHz band.
  • Profile warnings compared absolute dBFS with baselines tuned to that measurement. A loud master was flagged "hot" in every band, and the master approved by ear in the Phase Trance session tripped sub_hot, presence_low and air_low.

Change

  • Bands: each band is the RMS level (dBFS) of the mono downmix over the whole file, cut brick-wall in the FFT domain by ffmpeg's afftfilt (8192-point window; a test tone reads within 0.3 dB). Biquad high/low-pass slopes were tried first; they sag 4–5 dB inside one-octave bands.
  • Coverage: the bands join with no gaps from 20 Hz to 18 kHz, with a new brilliance_6000_10000_hz band.
  • Warnings judge balance, not loudness: each band relative to the file's full-band level is compared with the profile's expected share.
    • The anime-club-balanced shape models a spectrum whose energy per octave falls 1.5 dB per octave around 1 kHz (−4.5 dB/oct on a per-Hz analyzer). The other profiles keep their old character as offsets from it.
    • The ±6/−8 dB tolerances are unchanged. A file with no signal reports silent.
  • ffmpeg required: the command needs ffmpeg and fails with CONFIG_INVALID without it, like loudness.
  • Shared code: the ffmpeg runner and error moved to audio_analysis/ffmpeg.py, shared with ebur128.py. interleaved_mono was only used here and is removed.
  • Docs: docs/mastering.md describes the bands, the warnings and the ffmpeg requirement.

Public contract impact

  • Additive: a new bands.brilliance_6000_10000_hz key and a new silent warning code.
  • Values change: band levels and which warnings fire.
  • New failure path: CONFIG_INVALID without ffmpeg (also for reference compare, remix mastering analyze and session capture --analyze).
  • The contract snapshot and generated docs are unchanged after regeneration.

Tests

  • tests/test_audio_spectrum_bands.py was written first and failed against the old code. It covers:
    • band coverage and the ffmpeg graph per band
    • -inf on silence
    • a missing RMS reading
    • ffmpeg failure, and CONFIG_INVALID without ffmpeg
    • real ffmpeg: a tone lands in its band, and a tone in the second half of the file is measured
    • every profile has every band
    • warnings are unchanged by ±15–20 dB of level and flag a band 9 dB over its share
  • tests/conftest.py now has fake_ffmpeg, which fakes both loudness and spectrum, and real_ffmpeg, which skips without ffmpeg. These replace the per-file skip markers.

Validation

  • ruff check, ruff format --check, dev_checks: 1399 passed
  • Quality harness: exit 0, 0 failures
  • Snapshot and skill-doc regeneration: no diff

Warnings on real files:

File anime-club club-preview
Phase Trance master (approved by ear) none none
HyperFlip (−3.1 LUFS print) sub_20_60_hz_low sub_20_60_hz_low
Pink noise brilliance_hot, air_hot presence_hot, brilliance_hot, air_hot

Known limits

  • The baselines are a model, not values calibrated on a corpus. Reference comparison stays the better guide for EQ decisions.
  • audio reference compare still compares absolute band deltas, so a level difference reads as a tonal one. Its true-peak check also compares a delta against −0.5 dB. Both are left for a follow-up.
  • A 5-minute file takes about 23 s, mostly decoding PCM in Python for the stereo metrics.

🤖 Generated with Claude Code

audio spectrum analyze read only the first ~2.7 s of a file and sampled
each band at three Goertzel frequencies, with no 6-10 kHz band. An
80 Hz tone at -9 dBFS RMS read -27.7 dB, and a tone in the second half
of a file read -inf.

Each band is now the RMS level of the mono downmix over the whole file,
cut brick-wall in the FFT domain by ffmpeg's afftfilt. The bands join
from 20 Hz to 18 kHz with a new brilliance_6000_10000_hz band. The
command now needs ffmpeg and fails with CONFIG_INVALID without it.

Profile warnings now judge balance, not loudness: each band's level
relative to the file's full-band level is compared with the profile's
expected share. The old absolute baselines were tuned to the broken
measurement; the new ones model a spectrum falling 1.5 dB per octave
in energy per octave, with each profile's old character kept as
offsets. A file with no signal reports `silent`.

The ffmpeg runner and error moved to audio_analysis/ffmpeg.py, shared
with ebur128. Tests use one fake_ffmpeg fixture and a real_ffmpeg
fixture that skips without ffmpeg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@6uclz1
6uclz1 changed the base branch from fix/ebur128-loudness-measurement to main October 22, 2026 15:18
@6uclz1
6uclz1 merged commit 218daf6 into main Oct 22, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant