Problem
Surge XT patches vary widely in perceived loudness, and the only volume control today is global — set-surge-volume.sh / PatchLoader.set_volume() in patch_browser_ui.py send the same /param/a/amp/volume and /param/b/amp/volume value regardless of which patch is loaded. Switching patches means re-adjusting volume by ear.
Approach: static per-patch calibration, not real-time normalization
Measure each patch's loudness once (offline), store a gain offset per patch, and apply that offset via OSC when the patch loads. A real-time auto-gain/limiter on the output bus was considered and rejected: this is an MPE controller, and continuous loudness correction would fight the touch dynamics (velocity, pressure) that MPE performance depends on. Static calibration only levels the baseline loudness between patches — it doesn't touch expression within a patch.
Measurement standard
Use LUFS (EBU R128 / ITU-R BS.1770) rather than a custom RMS/peak metric — it's the established loudness-matching standard (same one streaming platforms use) and ffmpeg implements it natively via the loudnorm filter. ffmpeg-normalize (Python CLI wrapper around loudnorm) or rsgain (ReplayGain-style batch scanning) are the off-the-shelf tools to reach for — no custom DSP needed for the measurement itself.
These tools normalize audio files, not synth patches, so a patch still has to be rendered to a wav before it can be measured.
What to render per patch
A single fixed note isn't representative: Surge patches commonly map velocity, channel pressure (aftertouch), and sometimes release velocity to volume or filter cutoff, so the same patch can vary by 10+ dB across its own playable range — more from pressure than from pitch register. Rather than rendering a full velocity × pressure matrix, render one performance gesture per patch and take the integrated LUFS over the whole capture:
- Strike a mid-range note at a set velocity
- Hold the note, ramping channel pressure low → max (single sweep, not up-down)
- Release
This covers attack, sustained aftertouch dynamics, and release in one render/measure pass per patch. Trade-off: it won't tell you which phase of the gesture is driving a loudness outlier, only the integrated result — acceptable for a "roughly matched baseline" goal, not forensic loudness matching.
Open question: what to normalize to
The gesture render for a given patch spans a loudness range (quiet at low pressure, loud at max pressure). Two options, undecided:
- Normalize to the mid-loudness point — feels natural at rest, but patches with a wide pressure-to-volume range can still clip at max pressure.
- Normalize to the max-loudness point — guarantees no clipping, but patches with a wide range stay quiet at rest relative to others.
Where this plugs in
- Calibration (offline, one-time per patch, re-run when patches are added): a script renders the performance gesture through Surge headlessly, measures integrated LUFS, and writes a gain offset per patch to a lookup file keyed by patch path/name.
- Runtime:
PatchLoader.load_patch() in patch_browser_ui.py looks up the offset for the patch being loaded and sends it via /param/a/amp/volume / /param/b/amp/volume over OSC (port 53280) — the same mechanism set-surge-volume.sh already uses, just scaled per-patch instead of set globally.
Status
Design only — nothing implemented yet.
Problem
Surge XT patches vary widely in perceived loudness, and the only volume control today is global —
set-surge-volume.sh/PatchLoader.set_volume()inpatch_browser_ui.pysend the same/param/a/amp/volumeand/param/b/amp/volumevalue regardless of which patch is loaded. Switching patches means re-adjusting volume by ear.Approach: static per-patch calibration, not real-time normalization
Measure each patch's loudness once (offline), store a gain offset per patch, and apply that offset via OSC when the patch loads. A real-time auto-gain/limiter on the output bus was considered and rejected: this is an MPE controller, and continuous loudness correction would fight the touch dynamics (velocity, pressure) that MPE performance depends on. Static calibration only levels the baseline loudness between patches — it doesn't touch expression within a patch.
Measurement standard
Use LUFS (EBU R128 / ITU-R BS.1770) rather than a custom RMS/peak metric — it's the established loudness-matching standard (same one streaming platforms use) and ffmpeg implements it natively via the
loudnormfilter.ffmpeg-normalize(Python CLI wrapper aroundloudnorm) orrsgain(ReplayGain-style batch scanning) are the off-the-shelf tools to reach for — no custom DSP needed for the measurement itself.These tools normalize audio files, not synth patches, so a patch still has to be rendered to a wav before it can be measured.
What to render per patch
A single fixed note isn't representative: Surge patches commonly map velocity, channel pressure (aftertouch), and sometimes release velocity to volume or filter cutoff, so the same patch can vary by 10+ dB across its own playable range — more from pressure than from pitch register. Rather than rendering a full velocity × pressure matrix, render one performance gesture per patch and take the integrated LUFS over the whole capture:
This covers attack, sustained aftertouch dynamics, and release in one render/measure pass per patch. Trade-off: it won't tell you which phase of the gesture is driving a loudness outlier, only the integrated result — acceptable for a "roughly matched baseline" goal, not forensic loudness matching.
Open question: what to normalize to
The gesture render for a given patch spans a loudness range (quiet at low pressure, loud at max pressure). Two options, undecided:
Where this plugs in
PatchLoader.load_patch()inpatch_browser_ui.pylooks up the offset for the patch being loaded and sends it via/param/a/amp/volume//param/b/amp/volumeover OSC (port 53280) — the same mechanismset-surge-volume.shalready uses, just scaled per-patch instead of set globally.Status
Design only — nothing implemented yet.