Skip to content

Repository files navigation

SonusPar

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.


Stack, and why

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.


Normalization algorithm

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.

Defaults

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.

Implementation notes

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 records gain_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.

The rule that makes it safe: normalize from the backup

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.

Layout it writes

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.

Why backups are git-ignored

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 normalize refuses 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.


Engine metadata (Unity, Godot)

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.


Install

python -m venv .venv
.venv\Scripts\activate          # Windows
pip install -e ".[gui,dev]"

Requires Python 3.11+. Developed and tested on 3.14.


Running it

python -m sonus_par                 # GUI, home screen
python -m sonus_par path/to/assets  # GUI, straight into that folder
sonuspar --help                    # CLI

CLI

sonuspar 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.


Notes from building this

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.

Tests

QT_QPA_PLATFORM=offscreen pytest      # 203 tests, no audio device needed

The 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.

License

CC0 1.0 Universal (public domain).

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages