Audio normalization and BGM/SFX balance auditioning for game developers.
Game audio comes from everywhere — asset packs, freesound, commissioned work — and is almost never normalized relative to itself. The result is the familiar problem where one explosion blows out the mix and the next is inaudible under the music.
SonusPar scans a project directory, classifies each file as BGM or SFX, normalizes each category to its own loudness target, and lets you audition BGM and SFX together, mixed live, so you can judge relative volume the way a player will actually hear it.
| Layer | Choice | Reason |
|---|---|---|
| Language | Python 3.14 | Fast iteration for a tool whose bottleneck is file management and UI, not audio latency |
| Audio I/O | soundfile (libsndfile 1.2.2) |
Reads and writes WAV, Ogg Vorbis, FLAC and MP3 with no external binary — no ffmpeg dependency |
| Playback | sounddevice (PortAudio) |
Callback-based output stream; mixing N voices is a numpy add, giving exact control over per-voice gain, looping and mid-stream BGM swapping |
| DSP | numpy + scipy |
K-weighting filters, polyphase oversampling for true peak |
| GUI | PySide6 (Qt 6, LGPL) |
Native file dialogs, mature widgets, ships as a stable-ABI wheel |
The deciding factor was write support. Normalization has to rewrite .mp3
and .ogg in place, and libsndfile does that natively. The alternatives each
required an external toolchain that would have to be installed and shipped:
Electron/Node has no built-in audio encoders, and JUCE needs a full C++ build
environment.
EBU R128 / ITU-R BS.1770-4 integrated loudness (LUFS) — the same family Audacity uses for its Loudness Normalization effect.
Peak normalization is the wrong tool for this job. A compressed synth pad and a snappy footstep can share an identical peak level and still differ by 15 dB in perceived loudness, which is exactly the mismatch you are trying to fix. K-weighting plus gating models perceived loudness instead.
| Setting | Default | Configurable |
|---|---|---|
| BGM target | −16 LUFS | project.json |
| SFX target | −12 LUFS | project.json |
| True-peak ceiling | −1 dBTP | project.json |
| BGM/SFX duration threshold | 30 s | project.json |
| Clip protection | cap_gain |
cap_gain or limiter |
SFX sit 4 LU hotter than BGM on purpose: music should hold a consistent bed, while effects need to cut through it.
Filter coefficients are re-derived per sample rate. BS.1770-4 tabulates biquad coefficients only for 48 kHz. Reusing those at 44.1 kHz — a common bug — shifts both filter corners by about 9% and biases every measurement. The analog prototype is bilinear-transformed at the actual rate instead, so 22.05 / 44.1 / 48 / 96 kHz all measure the same signal identically. A test asserts that the derived coefficients reproduce the standard's published 48 kHz table to 1e-6.
Mono is treated as dual-mono by default. BS.1770 measures the channels it is
given, so a mono file measures 3.01 LU quieter than a stereo file of the same
sound. In-game that mono asset is played through a centered panner and comes out
of both speakers, so treating it as true mono would hand every mono SFX 3 dB of
extra gain and make them too loud. Set mono_mode="true_mono" for
standards-strict behavior.
Clips shorter than 400 ms use an ungated measurement. Gated loudness is
mathematically undefined below one 400 ms block, and a game SFX library is full
of 80–250 ms one-shots, so this path is hit constantly rather than being an edge
case. Zero-padding to 400 ms — the tempting alternative — is wrong: the padding
is silence, which drags the mean square down and reports the clip quieter than it
is. The sidecar records measurement_mode: "ungated_short" so the choice is
visible.
Clipping protection. True peak is measured on the 4× oversampled signal, per the standard, because a file whose sample peak sits at −0.1 dBFS can still overshoot full scale between samples and distort. Two modes:
cap_gain(default) — reduce the gain so the true peak lands exactly on the ceiling. Transparent, preserves dynamics. The file ends up quieter than target, and the sidecar recordsgain_capped: true.limiter— keep the full gain and hold the overshoot down with a lookahead soft limiter. Hits the target, changes the transient. Often what you want for percussive SFX.
Every normalization pass decodes the pristine backup, never the current on-disk file.
- Re-normalizing to a new target is exact, rather than compounding a gain that was already applied.
- MP3 and Ogg never accumulate generation loss across runs — there is always exactly one re-encode from the original.
- "Revert to original" is a file copy, so it is genuinely one click.
your-project/
├── .sonus_par/
│ ├── project.json committed — settings + path index
│ ├── .gitignore committed — ignores backups/ and tmp/
│ └── backups/ NOT committed — pristine originals
│ └── sfx/hit.wav
├── sfx/
│ ├── hit.wav the asset, overwritten in place
│ └── hit.wav.spar committed — per-file config
Per-file sidecars rather than one shared database: two developers normalizing different assets never touch the same lines, so there is nothing to merge.
Each sidecar stores two fingerprints. original proves the backup is still
the true source; current is what the tool last wrote, and is what change
detection compares against. Comparing against original would make every
normalized file report as "updated" forever.
They are full copies of every asset, so committing them roughly doubles the repository. The trade-off is that a fresh clone has sidecars pointing at backups that do not exist. SonusPar handles that:
- If the working file still matches
original, the backup is silently recreated from it. - If it does not, the original is genuinely unrecoverable, and
normalizerefuses rather than quietly compounding gain on an already-normalized file.
Use sonuspar init --backup-dir <path> to keep backups outside the repo
entirely.
Git-ignoring the backups is the default, not a requirement. A project that
does not use git, or that deliberately wants its backups committed, can opt out
with sonuspar init --no-gitignore, tick the box in first-time setup, or flip
it any time under Settings → Git-ignore the backup folder. SonusPar only
ever writes or removes a .sonus_par/.gitignore it wrote itself — a hand-edited
one is never touched.
Normalization overwrites in place — same path, same filename, never
delete-and-recreate. Unity .meta and Godot .import files are keyed by path,
and the GUID lives in the meta file rather than the audio file, so they survive
untouched. No engine integration is needed beyond that guarantee.
The write itself goes to .sonus_par/tmp/ and is then moved into place with an
atomic os.replace, so a crash or a full disk can never leave a half-written
asset, and the engine's watcher never sees a stray temp file inside Assets/.
Loop points are preserved. libsndfile silently discards chunks it does not
understand on write, which for game audio means WAV smpl loop points would be
destroyed. Those are extracted before the write and re-injected afterwards. Ogg
Vorbis LOOPSTART/LOOPLENGTH comments cannot be restored without rewriting the
Ogg page CRCs, so they are recorded in the sidecar and reported as a warning with
their values rather than lost silently.
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -e ".[gui,dev]"Requires Python 3.11+. Developed and tested on 3.14.
python -m sonus_par # GUI, home screen
python -m sonus_par path/to/assets # GUI, straight into that folder
sonuspar --help # CLIsonuspar init <dir> [--bgm-threshold 30] [--bgm-target -16] [--sfx-target -12] [--no-gitignore]
sonuspar scan <dir> [--json] [--apply-moves] [--adopt-new]
sonuspar check <dir> [--json] [--tolerance 0.5]
sonuspar normalize <dir> [--all | --paths F...] [--mode cap_gain|limiter] [--dry-run]
sonuspar revert <dir> [--all | --paths F...]
sonuspar reset <dir> [--force] # restore all, delete sidecars, remove .sonus_par
sonuspar trim <dir> --paths F... [--threshold -60]
sonuspar info <dir> [--paths F...]
sonuspar devices <dir> # list audio outputs
sonuspar play <dir> --bgm theme.ogg --sfx hit.wav coin.wav
play layers one-shot SFX over a looping BGM from the terminal — the same
engine the GUI uses, which is why it exists: an audio callback is far easier to
debug outside a GUI event loop.
Exit codes: 0 ok · 1 error · 2 usage · 3 check failed · 4 changes found ·
5 no project · 6 partial failure.
check is the CI gate. It fails when a tracked asset drifts from its category
target — but a file whose gain was capped at the ceiling counts as passing,
since it physically cannot reach the target without clipping. Without that rule,
any transient-heavy SFX library keeps CI permanently red.
--json output is never translated; it uses stable English identifiers so
pipelines parse the same way in any locale.
Two findings worth recording, because both are invisible until they bite:
libsndfile 1.2.2 crashes on long one-shot Ogg writes. Handing it a whole
buffer in a single sf.write() call overflows the stack inside the Vorbis
encoder somewhere above ~15 seconds of stereo audio — and BGM is always longer
than that. WAV, MP3 and FLAC are unaffected. All writes therefore go through
sf.SoundFile in 64 K-frame chunks, which fixes it and bounds peak memory as a
side effect. See _write_chunked in sonus_par/core/audiofile.py.
PortAudio's default device on Windows is usually wrong. It defaults to MME (100–200 ms latency, with its own resampler), and the same physical output is enumerated once per host API — so "first WASAPI device" is frequently an S/PDIF port with nothing plugged into it. Device selection carries the system default device's name across to the preferred host API rather than trusting indices, which do not correspond between APIs.
QT_QPA_PLATFORM=offscreen pytest # 203 tests, no audio device neededThe loudness implementation is pinned from three independent directions: the
coefficients it derives at 48 kHz must reproduce the table published in
BS.1770-4, a steady tone must match the closed-form value computed from the
filter's own response, and 12 varied signals must agree with pyloudnorm within
0.1 LU. The mixer is a pure function tested without any device, and the engine
is driven through an injectable fake stream, so the whole suite runs in CI.
CC0 1.0 Universal (public domain).