// LevelNormalizer — offline preset loudness calibration tool. // // Renders every factory preset through the real DSP chain (voice + FX) at a // fixed master gain of 1.0, measures the resulting peak, and computes the // "masterLevel" value that brings the peak to the target. Used to keep all // factory presets at a consistent loudness. // // ./level_normalizer dry run (prints a table, changes nothing) // ./level_normalizer --apply rewrites masterLevel in PresetManager.h // // Note: matches presets to masterLevel entries by insertion order — every // preset must contain exactly one "masterLevel" parameter. #include #include #include "../Source/PresetManager.h" #include "../Source/DSP/Voice.h" #include "../Source/DSP/Distortion.h" #include "../Source/DSP/Compressor.h" #include "../Source/DSP/Limiter.h" #include "../Source/DSP/Delay.h" #include "../Source/DSP/Reverb.h" #include #include #include #include #include #include namespace { constexpr double sampleRate = 44100.0; constexpr int blockSize = 512; constexpr double holdSeconds = 3.0; // typical held-note reference length constexpr double tailSeconds = 5.0; constexpr float targetPeak = 0.8f; constexpr float targetRms = 0.15f; float param(const Preset& p, const char* id, float def) { auto it = p.params.find(juce::String(id)); return it != p.params.end() ? it->second : def; } float paramInt(const Preset& p, const char* id, int def) { return static_cast(static_cast(param(p, id, static_cast(def)))); } struct Result { float peak = 0.0f; float rms = 0.0f; float masterLevel = 0.7f; }; void renderPreset(const Preset& preset, Result& out) { juce::Synthesiser synth; synth.setCurrentPlaybackSampleRate(sampleRate); synth.addSound(new SubtractiveSound()); synth.addVoice(new SubtractiveVoice()); auto* voice = dynamic_cast(synth.getVoice(0)); voice->setParameters( static_cast(static_cast(param(preset, "osc1Wave", 1))), static_cast(static_cast(param(preset, "osc2Wave", 2))), param(preset, "osc1Oct", 0.0f), param(preset, "osc2Oct", -1.0f), param(preset, "osc1Semi", 0.0f), param(preset, "osc2Semi", 0.0f), param(preset, "osc1Fine", 0.0f), param(preset, "osc2Fine", 7.0f), param(preset, "osc1Level", 0.7f), param(preset, "osc2Level", 0.7f), param(preset, "phaseOffset", 0.5f), param(preset, "filterCutoff", 8000.0f), param(preset, "filterRes", 0.3f), static_cast(static_cast(param(preset, "filterType", 0))), param(preset, "filterEnvAmt", 0.0f), param(preset, "keyTrack", 0.5f), param(preset, "envAttack", 0.01f), param(preset, "envDecay", 0.3f), param(preset, "envSustain", 0.7f), param(preset, "envRelease", 0.5f), param(preset, "fEnvAttack", 0.01f), param(preset, "fEnvDecay", 0.3f), param(preset, "fEnvSustain", 0.5f), param(preset, "fEnvRelease", 0.5f), param(preset, "pan", 0.0f), param(preset, "drive", 1.5f), 1.0f, // outputLevel (master gain applied separately, see processBlock) param(preset, "lfo1Rate", 2.0f), param(preset, "lfo1Depth", 0.0f), static_cast(paramInt(preset, "lfo1Shape", 0)), static_cast(paramInt(preset, "lfo1Dest", 0)), param(preset, "lfo2Rate", 2.0f), param(preset, "lfo2Depth", 0.0f), static_cast(paramInt(preset, "lfo2Shape", 0)), static_cast(paramInt(preset, "lfo2Dest", 0))); Distortion distortion; Compression compression; Limiter limiter; Delay delay; ReverbFX reverb; distortion.prepare(sampleRate); compression.prepare(sampleRate); limiter.prepare(sampleRate); delay.prepare(sampleRate); reverb.prepare(sampleRate); distortion.setParameters( static_cast(static_cast(param(preset, "distType", 0))), param(preset, "distAmount", 0.0f), param(preset, "distSymmetry", 0.0f), param(preset, "distTone", 1.0f)); compression.setParameters( param(preset, "compThreshold", -20.0f), param(preset, "compRatio", 4.0f), param(preset, "compAttack", 10.0f), param(preset, "compRelease", 100.0f), param(preset, "compMakeup", 0.0f)); limiter.setParameters(1.0f, 0.9f, 50.0f); // plugin defaults (presets don't override) delay.setParameters(param(preset, "delayTime", 200.0f), param(preset, "delayFeedback", 0.0f), param(preset, "delayMix", 0.0f), param(preset, "delayPingPong", 0.0f) > 0.5f); reverb.setParameters(param(preset, "reverbSize", 0.5f), param(preset, "reverbDamping", 0.5f), 0.8f, param(preset, "reverbMix", 0.0f)); const float autoPanRate = param(preset, "autoPanRate", 2.0f); const float autoPanDepth = param(preset, "autoPanDepth", 0.0f); float autoPanPhase = 0.0f; constexpr float twoPi = 6.2831853f; juce::AudioBuffer buffer(2, blockSize); std::vector leftSamples, rightSamples; const size_t capacity = static_cast((holdSeconds + tailSeconds) * sampleRate); leftSamples.reserve(capacity); rightSamples.reserve(capacity); auto renderAndApply = [&](double seconds) { int total = static_cast(seconds * sampleRate); int done = 0; while (done < total) { const int n = std::min(blockSize, total - done); buffer.clear(); juce::MidiBuffer midi; synth.renderNextBlock(buffer, midi, 0, n); float* l = buffer.getWritePointer(0); float* r = buffer.getWritePointer(1); for (int i = 0; i < n; ++i) { float left = l[i]; float right = r[i]; left = distortion.process(left); right = distortion.process(right); left = compression.process(left); right = compression.process(right); left = limiter.process(left); right = limiter.process(right); if (autoPanDepth > 0.001f) { const float panLfo = std::sin(autoPanPhase * twoPi); const float leftGain = 1.0f - autoPanDepth * 0.5f * (1.0f + panLfo); const float rightGain = 1.0f - autoPanDepth * 0.5f * (1.0f - panLfo); left *= leftGain; right *= rightGain; autoPanPhase += autoPanRate / static_cast(sampleRate); if (autoPanPhase >= 1.0f) autoPanPhase -= 1.0f; } delay.process(left, right); l[i] = left; r[i] = right; } reverb.process(l, r, n); for (int i = 0; i < n; ++i) { leftSamples.push_back(l[i]); rightSamples.push_back(r[i]); } done += n; } }; synth.noteOn(1, 60, 1.0f); renderAndApply(holdSeconds); synth.noteOff(1, 60, 0.0f, true); renderAndApply(tailSeconds); float peak = 0.0f; double sumSquares = 0.0; for (size_t i = 0; i < leftSamples.size(); ++i) { const float mix = (leftSamples[i] + rightSamples[i]) * 0.5f; peak = std::max(peak, std::abs(mix)); sumSquares += static_cast(mix) * mix; } out.peak = peak; out.rms = static_cast(std::sqrt(sumSquares / static_cast(leftSamples.size()))); // masterGain = masterLevel^2 (perceptual taper in processBlock). // Pick the more conservative of the peak and RMS targets so loud presets // are reined in on both fronts and nothing clips. float masterLevel = 0.7f; if (peak > 1.0e-4f) { const float gainToPeak = targetPeak / peak; const float gainToRms = out.rms > 1.0e-4f ? targetRms / out.rms : 1.0f; const float gain = std::min(gainToPeak, gainToRms); masterLevel = std::sqrt(std::min(gain, 1.0f)); } out.masterLevel = juce::jlimit(0.0f, 1.0f, masterLevel); } std::string formatValue(float v) { char buf[32]; std::snprintf(buf, sizeof(buf), "%.3f", v); std::string s(buf); if (s.find('.') != std::string::npos) { while (!s.empty() && s.back() == '0') s.pop_back(); if (!s.empty() && s.back() == '.') s.pop_back(); } if (s.empty() || s == "-") return "0.0"; if (s.find('.') == std::string::npos) s += ".0"; // keep a decimal point return s; } bool isNumberChar(char c) { return (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E'; } int applyToHeader(const std::vector& results) { const char* path = "Source/PresetManager.h"; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "could not open %s\n", path); return 1; } std::stringstream ss; ss << in.rdbuf(); std::string content = ss.str(); const std::string marker = "\"masterLevel\""; size_t pos = 0; size_t idx = 0; while ((pos = content.find(marker, pos)) != std::string::npos) { const size_t comma = content.find(',', pos); if (comma == std::string::npos) break; size_t numStart = content.find_first_not_of(" \t", comma + 1); if (numStart == std::string::npos) break; size_t numEnd = numStart; while (numEnd < content.size() && isNumberChar(content[numEnd])) ++numEnd; const bool hadF = numEnd < content.size() && (content[numEnd] == 'f' || content[numEnd] == 'F'); const std::string token = content.substr(numStart, numEnd - numStart); float oldVal = 0.0f; try { oldVal = std::stof(token); } catch (...) {} const float newVal = idx < results.size() ? results[idx].masterLevel : oldVal; std::string replacement = formatValue(newVal); if (hadF) replacement += "f"; if (std::abs(oldVal - newVal) > 1.0e-4f) { std::printf(" %s : %s -> %s\n", marker.c_str(), token.c_str(), replacement.c_str()); } const size_t replaceEnd = numEnd + (hadF ? 1 : 0); content.replace(numStart, replaceEnd - numStart, replacement); pos = numStart + replacement.size(); ++idx; } if (idx != results.size()) { std::fprintf(stderr, "preset/masterLevel count mismatch (%zu presets, %zu entries) — aborting.\n", results.size(), idx); return 1; } std::ofstream out(path, std::ios::binary | std::ios::trunc); if (!out) { std::fprintf(stderr, "could not write %s\n", path); return 1; } out << content; return 0; } } // namespace int main(int argc, char** argv) { const bool apply = argc > 1 && std::string(argv[1]) == "--apply"; PresetManager manager; const auto& presets = manager.getPresets(); std::vector results; results.reserve(presets.size()); std::printf("%-22s %8s %8s %8s %8s\n", "PRESET", "PEAK", "RMS", "MASTER", "NEW"); std::printf("%-22s %8s %8s %8s %8s\n", "------", "----", "---", "------", "---"); float minNew = 1.0f, maxNew = 0.0f; for (const auto& preset : presets) { Result res; renderPreset(preset, res); results.push_back(res); const float oldMaster = param(preset, "masterLevel", 0.7f); minNew = std::min(minNew, res.masterLevel); maxNew = std::max(maxNew, res.masterLevel); std::printf("%-22s %8.3f %8.3f %8.3f %8.3f\n", preset.name.toRawUTF8(), res.peak, res.rms, oldMaster, res.masterLevel); } std::printf("\n%zu presets measured. new masterLevel range: %.3f .. %.3f (peak %.2f, rms %.2f)\n", presets.size(), minNew, maxNew, targetPeak, targetRms); if (apply) { std::printf("\napplying changes to Source/PresetManager.h:\n"); return applyToHeader(results); } std::printf("\nrun with --apply to write the NEW column into PresetManager.h\n"); return 0; }