add arpeggiator, rename presets, move spectrum analyzer into waveform

This commit is contained in:
Armin 2026-08-13 00:38:10 +02:00
commit 3331b4cc16
9 changed files with 1126 additions and 395 deletions

102
AGENTS.md
View file

@ -86,7 +86,7 @@ Osc1 + Osc2 → Amplitude Envelope × Velocity → Filter (cutoff modulated by F
- `processBlock()`: clears buffer, calls `updateVoiceParameters()`, then `synth.renderNextBlock()`
- State save/restore via XML binary copy
### Parameters (27 total)
### Parameters (32 total)
| ID | Name | Range | Default |
|---------------------|------------------|--------------------|---------|
@ -117,6 +117,11 @@ Osc1 + Osc2 → Amplitude Envelope × Velocity → Filter (cutoff modulated by F
| pan | Pan | -1..1 | 0 |
| drive | Drive | 1..5 (log) | 1.5 |
| masterLevel | Master Level | 0..1 | 0.8 |
| arpEnabled | Arp On | Choice(2) Off/On | 0 (Off) |
| arpPattern | Arp Pattern | Choice(7) | 0 (Up) |
| arpOctaves | Arp Octaves | Choice(3) 1/2/3 | 1 (2) |
| arpDirection | Arp Direction | Choice(2) Up/Down | 0 (Up) |
| arpRate | Arp Rate | Choice(6) 1/16..2 | 1 (1/8) |
## UI Architecture
@ -137,22 +142,31 @@ ChromaFlockEditor (juce::AudioProcessorEditor)
### Layout Dimensions (base coordinates, 1× scale)
- **baseWidth**: 1660
- **baseHeight**: 480 (needs to increase to ~620 for visualizers)
- **Section Y origin**: 56px (below header line at y=48)
- **Knob size**: 112×112
- **Knob spacing**: 10px
- **Label height**: 18px
- **Combo box height**: 48px
- **baseHeight**: 1028
- **Header**: y 0..80 (logo + preset buttons + transpose + scale)
- **Knob size**: 112×112 (main rows), 80×80 (FX/LFO rows)
- **Visualizer row**: y=746, h=128 (VU meter, waveform w/ spectrum overlay, arpeggiator)
- **Piano roll**: y=884, h=136
### Section Layout (from paint/drawSection calls)
| Section | X | Y | W | H |
|------------|------|-----|------|------|
| OSC 1 | 10 | 52 | 498 | 200 |
| OSC 2 | 520 | 52 | 620 | 200 |
| FILTER | 1152 | 52 | 498 | 200 |
| AMP ENV | 10 | 262 | 498 | 150 |
| FILTER ENV | 520 | 262 | 498 | 150 |
| MASTER | 1030 | 262 | 376 | 150 |
|--------------|------|-----|------|------|
| OSC 1 | 10 | 80 | 498 | 200 |
| OSC 2 | 518 | 80 | 624 | 200 |
| FILTER | 1152 | 80 | 498 | 200 |
| AMP ENV | 10 | 290 | 563 | 160 |
| FILTER ENV | 583 | 290 | 502 | 160 |
| MASTER | 1095 | 290| 555 | 160 |
| DISTORTION | 10 | 460 | 510 | 115 |
| COMPRESSION | 528 | 460 | 468 | 115 |
| PANNING | 1004 | 460 | 268 | 115 |
| LIMITER | 1280 | 460 | 370 | 115 |
| LFO 1 | 10 | 586 | 540 | 150 |
| LFO 2 | 560 | 586 | 530 | 150 |
| DELAY | 1015 | 586 | 375 | 150 |
| REVERB | 1395 | 586 | 255 | 150 |
| ARPEGGIATOR | 860 | 746 | 790 | 128 |
| PIANO ROLL | 10 | 884 | 1640 | 136 |
### Look-and-Feel (KnobLookAndFeel)
- Inherits `juce::LookAndFeel_V4`
@ -166,8 +180,9 @@ ChromaFlockEditor (juce::AudioProcessorEditor)
- Knob labels: `0xff888888`, 10pt font
- Text boxes: `0xffcccccc` text on `0xff1a1a1a` background
### ComboBox LAF (not yet implemented — currently uses default V4 look)
- Needs custom 3D LAF similar to knobs (gradient, shadow, highlight)
### ComboBox LAF (implemented — `ComboBoxLookAndFeel`)
- Custom 3D LAF (gradient spotlight, drop shadow, gold arrow, highlighted popup items)
- `drawPopupMenuItem` highlights the selected entry persistently and inverts text color on hover
### Header
- Title: "CHROMAFLOCK" at (20, 8), 22pt bold gold
@ -178,47 +193,28 @@ ChromaFlockEditor (juce::AudioProcessorEditor)
- Scale label at (1520, 10), 42×20
- Scale combo at (1566, 8), 80×24
## Planned / In-Progress Work
## Implemented Features
### Filter Crash Fix (HIGH PRIORITY)
**Problem**: Single `Filter filter` in `SubtractiveVoice` is called for both L and R in sequence (lines 87-88 of Voice.h). The filter's internal state (`stage[4]`) is shared/corrupted between channels, causing instability when filter env amount > 0.
### Filter Crash Fix
`SubtractiveVoice` now has separate `filter` (L) and `filterR` (R) instances; `startNote()` prepares both. Resolves the state-sharing crash when filter env amount > 0.
**Fix**: Add `Filter filterR` member. In `renderNextBlock()`:
- Process left channel through `filter`
- Process right channel through `filterR`
- In `startNote()`, call `filter.prepare(sr)` AND `filterR.prepare(sr)`
### Arpeggiator
- New class `DSP/Arpeggiator.h` — tempo-synced, pure note-logic (no audio). Tracks held notes (press order for the As Played pattern), builds a pitch pool from held notes × octave range (13, clamped to MIDI range), and sequences it per pattern.
- Patterns: Up, Down, UpDown, DownUp, Random, As Played, Chord.
- `arpDirection` (Up/Down) controls whether octave copies extend above or below the root; sequential patterns always order by ascending pitch.
- `arpRate` step divisions (beats): 1/16, 1/8, 1/4, 1/2, 1, 2 — tick period = `rateBeats * 60 / bpm`.
- Routing (`PluginProcessor.cpp::processBlock`): when `arpEnabled`, MIDI note events feed the arp instead of the synth; the arp drives `synth.noteOn/noteOff` (with transpose) on each tick via `arpSampleCount` accumulation. Piano-roll `noteOn/noteOff` route through the same logic. When disabled, `arp.reset()` stops any sounding arp note and notes play normally.
- UI: bottom-right section at (860, 746) titled ARPEGGIATOR with 5 combos (ON, PATTERN, OCTAVES, DIR, RATE).
### Scope Buffer for Visualizers (HIGH PRIORITY)
**Need to add to `ChromaFlockProcessor`**:
- `std::array<float, 1024> scopeBuffer` — ring buffer for waveform data
- `std::atomic<int> scopeWritePos` — write position index
- Write to buffer in `processBlock()` after `synth.renderNextBlock()`, interleaving L+R samples
- Add getter method: `const float* getScopeData() const`
- Add `juce::juce_dsp` to CMakeLists.txt for FFT
### Visualizers
- `WaveformDisplay` (bottom-left, 60×746, 790×128) draws the scope ring buffer waveform AND a semi-transparent `juce::dsp::FFT` spectrum overlay behind it (30 fps). Requires `juce::juce_dsp` linked and `#include <juce_dsp/juce_dsp.h>`.
- Standalone `SpectrumAnalyzer` component was removed.
### Visualizer Components (HIGH PRIORITY)
Two new classes to add to `PluginEditor.h`:
**WaveformDisplay** (juce::Component)
- Reads from processor scope buffer
- Draws waveform as line graph on dark background
- Timer callback at 30fps
- Positioned at bottom-left of UI (~190px tall, ~830px wide)
**SpectrumDisplay** (juce::Component)
- Uses `juce::dsp::FFT` to compute spectrum from scope buffer
- Draws vertical bars with HSV color gradient (cool→warm based on frequency)
- Timer callback at 30fps
- Positioned at bottom-right of UI (~190px tall, ~830px wide)
### Height Expansion
- `baseHeight` must increase from 480 to ~620
- Bottom sections (AMP ENV, FILTER ENV, MASTER) at y=262 currently end at y=412
- Visualizers fill the remaining space (y=412 to y=612)
- Editor window resized accordingly
### ComboBox 3D LAF (MEDIUM PRIORITY)
Custom look-and-feel for combo boxes matching the 3D knob aesthetic.
### Preset Level Normalization (`Tools/LevelNormalizer.cpp`)
- Standalone JUCE console tool that renders every preset offline (C4, 8s, 44.1 kHz) and derives a new `masterLevel` for consistent loudness: peak normalized to 0.80, RMS-normalized to 0.15, final value = `min(gainPeak, gainRms) * current`, clamped to 0..1.
- Build target: `cmake --build build --target level_normalizer`; run `./build/level_normalizer --apply` to rewrite the `masterLevel` entries in `Source/PresetManager.h` (reports a count mismatch and aborts if preset order/count ever drifts).
- Applied 2026: all 136 presets re-balanced; `masterLevel` now ranges 0.325..1.0. Sustained/washy presets (heavy reverb/delay) get the largest cuts; quiet transient presets clamp at 1.0.
- Caveat: noise-based presets render with slight run-to-run variance (`juce::Random` reseeds per process), so re-running can shift borderline values by a few percent.
## Common Pitfalls & Gotchas

View file

@ -98,3 +98,15 @@ target_link_libraries(ChromaFlock PRIVATE
juce::juce_gui_basics
juce::juce_gui_extra
)
# Offline preset-loudness calibration tool (not built by default).
# Usage: ./level_normalizer (dry run)
# ./level_normalizer --apply (write new masterLevels into PresetManager.h)
add_executable(level_normalizer EXCLUDE_FROM_ALL Tools/LevelNormalizer.cpp)
target_compile_definitions(level_normalizer PUBLIC
JUCE_APPLICATION_NAME=LevelNormalizer
JUCE_APPLICATION_VERSION=1.0.0
)
target_link_libraries(level_normalizer PRIVATE
juce::juce_audio_processors
)

225
Source/DSP/Arpeggiator.h Normal file
View file

@ -0,0 +1,225 @@
#pragma once
#include <vector>
#include <algorithm>
#include <cstdint>
// Tempo-synced arpeggiator. Tracks held notes and, on each clock step,
// produces the notes to play for the selected pattern / octave range /
// direction. Pure note logic — timing is driven from outside (the audio
// thread) via step().
class Arpeggiator {
public:
enum Pattern {
Up = 0,
Down,
UpDown,
DownUp,
Random,
AsPlayed,
Chord,
numPatterns
};
enum Direction {
DirectionUp = 0,
DirectionDown
};
struct NotePitch {
int note;
float velocity;
};
void setParameters(bool enabled, int pattern, int octaves, int direction,
double rateBeats, double bpm) {
this->enabled = enabled;
this->pattern = pattern < 0 ? Up : (pattern >= numPatterns ? Chord : pattern);
this->octaves = octaves < 1 ? 1 : (octaves > 3 ? 3 : octaves);
this->direction = (direction == DirectionDown) ? DirectionDown : DirectionUp;
this->rateBeats = rateBeats;
this->bpm = bpm > 0.0 ? bpm : 120.0;
}
bool isEnabled() const { return enabled; }
double getTickSeconds() const {
return rateBeats * 60.0 / bpm;
}
void noteOn(int note, float velocity) {
if (note < 0 || note > 127) return;
if (std::find(heldNotes.begin(), heldNotes.end(), note) != heldNotes.end())
return;
bool wasIdle = heldNotes.empty();
heldNotes.push_back(note);
velocities[note] = velocity;
// A fresh key press should sound immediately instead of waiting for
// the next host-synced tick.
if (wasIdle)
needsImmediateStep = true;
}
void noteOff(int note) {
auto it = std::find(heldNotes.begin(), heldNotes.end(), note);
if (it != heldNotes.end())
heldNotes.erase(it);
}
bool hasHeldNotes() const { return !heldNotes.empty(); }
// Returns true if the next step should fire right now (a fresh key press
// requested an immediate trigger) and clears the request.
bool shouldTriggerNow() {
bool v = needsImmediateStep;
needsImmediateStep = false;
return v;
}
// Clears held notes and step state. Notes that were sounding when reset
// was called are returned so the caller can issue note-offs.
void reset(std::vector<int>& notesToStop) {
notesToStop.swap(currentNotes);
heldNotes.clear();
stepIndex = 0;
needsImmediateStep = false;
}
// Advances one step. Notes that should stop go into `notesToStop`
// (usually the note(s) from the previous step); notes that should start
// go into `notesToPlay`.
void step(std::vector<int>& notesToStop, std::vector<NotePitch>& notesToPlay) {
notesToStop.clear();
notesToPlay.clear();
if (!enabled) return;
if (heldNotes.empty()) {
notesToStop = currentNotes;
currentNotes.clear();
stepIndex = 0;
return;
}
std::vector<NotePitch> pool;
buildPool(pool);
if (pool.empty()) {
notesToStop = currentNotes;
currentNotes.clear();
return;
}
if (pattern == Chord) {
notesToStop = currentNotes;
notesToPlay = pool;
currentNotes.clear();
for (const auto& np : pool)
currentNotes.push_back(np.note);
return;
}
auto order = buildOrder(pool);
if (order.empty()) {
notesToStop = currentNotes;
currentNotes.clear();
return;
}
int idx = stepIndex % static_cast<int>(order.size());
++stepIndex;
const NotePitch& np = order[idx];
notesToStop = currentNotes;
currentNotes.clear();
currentNotes.push_back(np.note);
notesToPlay.push_back(np);
}
private:
void buildPool(std::vector<NotePitch>& pool) const {
bool used[128] = {};
auto addPitch = [&](int pitch, float velocity) {
pitch = pitch < 0 ? 0 : (pitch > 127 ? 127 : pitch);
if (used[pitch]) return;
used[pitch] = true;
pool.push_back({pitch, velocity});
};
if (pattern == AsPlayed) {
for (int n : heldNotes) {
for (int k = 0; k < octaves; ++k) {
int off = (direction == DirectionUp ? 12 * k : -12 * k);
addPitch(n + off, velocities[n]);
}
}
} else {
std::vector<int> sorted(heldNotes.begin(), heldNotes.end());
std::sort(sorted.begin(), sorted.end());
for (int n : sorted) {
for (int k = 0; k < octaves; ++k) {
int off = (direction == DirectionUp ? 12 * k : -12 * k);
addPitch(n + off, velocities[n]);
}
}
}
}
std::vector<NotePitch> buildOrder(const std::vector<NotePitch>& pool) {
// Sequential patterns play by ascending pitch regardless of the
// octave direction; As Played keeps the key-press order.
std::vector<NotePitch> seq(pool);
std::sort(seq.begin(), seq.end(),
[](const NotePitch& a, const NotePitch& b) { return a.note < b.note; });
int n = static_cast<int>(seq.size());
std::vector<NotePitch> order;
auto push = [&](int i) { order.push_back(seq[i]); };
switch (pattern) {
case Down:
for (int i = n - 1; i >= 0; --i) push(i);
break;
case UpDown:
for (int i = 0; i < n; ++i) push(i);
for (int i = n - 2; i >= 1; --i) push(i);
break;
case DownUp:
for (int i = n - 1; i >= 0; --i) push(i);
for (int i = 1; i < n - 1; ++i) push(i);
break;
case Random:
order = seq;
for (int i = n - 1; i > 0; --i) {
int j = static_cast<int>(randomState % static_cast<uint32_t>(i + 1));
std::swap(order[i], order[j]);
randomState = randomState * 1664525u + 1013904223u;
}
break;
case AsPlayed:
order = pool;
break;
case Chord:
case Up:
default:
for (int i = 0; i < n; ++i) push(i);
break;
}
return order;
}
bool enabled = false;
int pattern = Up;
int octaves = 1;
int direction = DirectionUp;
double rateBeats = 0.5;
double bpm = 120.0;
std::vector<int> heldNotes;
float velocities[128] = {};
std::vector<int> currentNotes;
int stepIndex = 0;
bool needsImmediateStep = false;
uint32_t randomState = 0x12345678u;
};

View file

@ -197,7 +197,7 @@ void VuMeter::paint(juce::Graphics& g) {
g.drawText("VU", bounds.withBottom(bounds.getY() + 22.0f).translated(0, 6), juce::Justification::centred);
}
// --- Waveform Display ---
// --- Waveform Display (with semi-transparent spectrum overlay) ---
void WaveformDisplay::paint(juce::Graphics& g) {
auto bounds = getLocalBounds().toFloat().reduced(0.5f);
@ -209,6 +209,116 @@ void WaveformDisplay::paint(juce::Graphics& g) {
g.setColour(juce::Colour(0xff333333));
g.drawRoundedRectangle(bounds, 9.0f, 1.0f);
// ---- Spectrum overlay (half-transparent, drawn behind the waveform) ----
if (fft != nullptr) {
std::array<float, ChromaFlockProcessor::fftSize * 2> fftData{};
int writePos = processor.fftWritePos.load(std::memory_order_acquire);
int fftSize = ChromaFlockProcessor::fftSize;
for (int i = 0; i < fftSize; ++i) {
int idx = (writePos + i) % fftSize;
fftData[i] = processor.fftInput[idx];
}
for (int i = 0; i < fftSize; ++i) {
float window = 0.5f - 0.5f * std::cos(2.0f * 3.14159265f * static_cast<float>(i) / static_cast<float>(fftSize));
fftData[i] *= window;
}
fft->performFrequencyOnlyForwardTransform(fftData.data());
int numPoints = 128;
float w = bounds.getWidth();
float h = bounds.getHeight() - 22.0f;
float bottom = bounds.getBottom() - 2.0f;
int maxBin = fftSize / 4;
float sampleRate = static_cast<float>(processor.getSampleRate());
float binHz = sampleRate / static_cast<float>(fftSize);
const float dbFloor = -48.0f;
// Log-frequency mapping so the low end (sub bass) spreads across the
// display instead of bunching up in the leftmost ~10%.
const float fLow = 20.0f;
const float fHigh = static_cast<float>(maxBin) * binHz;
const float logRange = std::log(fHigh / fLow);
if (specSmooth.size() != static_cast<size_t>(numPoints + 1))
specSmooth.assign(numPoints + 1, 0.0f);
std::vector<float> mags(numPoints + 1);
for (int i = 0; i <= numPoints; ++i) {
float t = static_cast<float>(i) / static_cast<float>(numPoints);
float tN = juce::jmin(t + 1.0f / static_cast<float>(numPoints), 1.0f);
float fL = fLow * std::exp(logRange * t);
float fN = fLow * std::exp(logRange * tN);
int binStart = juce::jmax(1, static_cast<int>(fL / binHz));
int binEnd = static_cast<int>(fN / binHz) + 1;
if (binEnd <= binStart) binEnd = binStart + 1;
if (binEnd > maxBin) binEnd = maxBin;
float mag = 0.0f;
int count = 0;
for (int b = binStart; b < binEnd; ++b) {
mag += fftData[b];
++count;
}
mag = count > 0 ? mag / static_cast<float>(count) : 0.0f;
// dB scale: 0 dB reference ≈ full-scale sine peak, floor at dbFloor.
float lin = mag / static_cast<float>(fftSize) * 4.0f;
float db = 20.0f * std::log10(lin + 1.0e-6f);
mags[i] = juce::jlimit(0.0f, 1.0f, (db - dbFloor) / -dbFloor);
}
// Spatial smoothing between adjacent points (rolling-hill look).
std::vector<float> blurred = mags;
for (int pass = 0; pass < 2; ++pass) {
for (int i = 0; i <= numPoints; ++i) {
float a = mags[static_cast<size_t>(juce::jmax(0, i - 1))];
float c = mags[static_cast<size_t>(juce::jmin(numPoints, i + 1))];
blurred[static_cast<size_t>(i)] = (a + 2.0f * mags[static_cast<size_t>(i)] + c) * 0.25f;
}
mags = blurred;
}
// Time smoothing (EMA) so the curve glides instead of jumping.
// Asymmetric: fast attack, slower fall — a released note's spectrum
// decays away instead of being held up.
const float emaUp = 0.7f;
const float emaDown = 0.6f;
for (int i = 0; i <= numPoints; ++i) {
float& s = specSmooth[static_cast<size_t>(i)];
float m = mags[static_cast<size_t>(i)];
float coeff = m > s ? emaUp : emaDown;
s = coeff * s + (1.0f - coeff) * m;
}
juce::Path specPath;
specPath.startNewSubPath(bounds.getX(), bottom);
for (int i = 0; i <= numPoints; ++i) {
float x = bounds.getX() + (static_cast<float>(i) / static_cast<float>(numPoints)) * w;
float y = bottom - specSmooth[static_cast<size_t>(i)] * h;
specPath.lineTo(x, y);
}
specPath.lineTo(bounds.getRight(), bottom);
specPath.closeSubPath();
{
juce::Graphics::ScopedSaveState saved(g);
juce::Path clipPath;
clipPath.addRoundedRectangle(bounds, 9.0f);
g.reduceClipRegion(clipPath);
juce::ColourGradient specGrad(juce::Colour(0xff7b94b5).withAlpha(0.5f), 0.0f, bottom,
juce::Colour(0xff2a3a4a).withAlpha(0.4f), 0.0f, bottom - h, false);
g.setGradientFill(specGrad);
g.fillPath(specPath);
g.setColour(juce::Colour(0xff9db8d8).withAlpha(0.55f));
g.strokePath(specPath, juce::PathStrokeType(1.2f));
}
}
g.setColour(juce::Colour(0xff333333));
g.drawLine(bounds.getX(), bounds.getCentreY(), bounds.getRight(), bounds.getCentreY(), 1.0f);
@ -245,12 +355,12 @@ void WaveformDisplay::paint(juce::Graphics& g) {
clipPath.addRoundedRectangle(bounds, 9.0f);
g.reduceClipRegion(clipPath);
juce::ColourGradient waveGrad(juce::Colour(0xffc59c07).withAlpha(0.5f), 0.0f, bounds.getY(),
juce::Colour(0xff3d2e02).withAlpha(0.4f), 0.0f, bounds.getBottom(), false);
juce::ColourGradient waveGrad(juce::Colour(0xff8fa35a).withAlpha(0.5f), 0.0f, bounds.getY(),
juce::Colour(0xff2e3a18).withAlpha(0.4f), 0.0f, bounds.getBottom(), false);
g.setGradientFill(waveGrad);
g.fillPath(filledPath);
g.setColour(juce::Colour(0xffc59c07).withAlpha(0.9f));
g.setColour(juce::Colour(0xff8fa35a).withAlpha(0.9f));
g.strokePath(wavePath, juce::PathStrokeType(1.5f));
}
@ -259,102 +369,6 @@ void WaveformDisplay::paint(juce::Graphics& g) {
g.drawText("WAVE", bounds.withBottom(bounds.getY() + 22.0f).translated(0, 6), juce::Justification::centred);
}
// --- Spectrum Analyzer ---
void SpectrumAnalyzer::paint(juce::Graphics& g) {
auto bounds = getLocalBounds().toFloat().reduced(0.7f);
juce::ColourGradient bgGrad(juce::Colour(0xBB222222), bounds.getCentreX(), bounds.getY(),
juce::Colour(0xBB111111), bounds.getCentreX(), bounds.getBottom(), false);
g.setGradientFill(bgGrad);
g.fillRoundedRectangle(bounds, 9.0f);
g.setColour(juce::Colour(0xff333333));
g.drawRoundedRectangle(bounds, 9.0f, 1.0f);
std::array<float, ChromaFlockProcessor::fftSize * 2> fftData{};
int writePos = processor.fftWritePos.load(std::memory_order_acquire);
int fftSize = ChromaFlockProcessor::fftSize;
for (int i = 0; i < fftSize; ++i) {
int idx = (writePos + i) % fftSize;
fftData[i] = processor.fftInput[idx];
}
for (int i = 0; i < fftSize; ++i) {
float window = 0.5f - 0.5f * std::cos(2.0f * 3.14159265f * static_cast<float>(i) / static_cast<float>(fftSize));
fftData[i] *= window;
}
fft->performFrequencyOnlyForwardTransform(fftData.data());
int numPoints = 128;
float w = bounds.getWidth();
float h = bounds.getHeight() - 22.0f;
float bottom = bounds.getBottom() - 2.0f;
int maxBin = fftSize / 4;
float sampleRate = static_cast<float>(processor.getSampleRate());
float binHz = sampleRate / static_cast<float>(fftSize);
float lowCut = 80.0f;
float lowPass = 250.0f;
juce::Graphics::ScopedSaveState savedClip(g);
juce::Path clipPath;
clipPath.addRoundedRectangle(bounds, 9.0f);
g.reduceClipRegion(clipPath);
std::vector<float> mags(numPoints + 1);
for (int i = 0; i <= numPoints; ++i) {
float t = static_cast<float>(i) / static_cast<float>(numPoints);
int binStart = static_cast<int>(std::pow(t, 2.0f) * static_cast<float>(maxBin));
int binEnd = static_cast<int>(std::pow(t + 1.0f / static_cast<float>(numPoints), 2.0f) * static_cast<float>(maxBin));
if (binEnd <= binStart) binEnd = binStart + 1;
if (binEnd > maxBin) binEnd = maxBin;
float mag = 0.0f;
int count = 0;
for (int b = binStart; b < binEnd; ++b) {
mag += fftData[b];
++count;
}
mag = count > 0 ? mag / static_cast<float>(count) : 0.0f;
mag = mag / static_cast<float>(fftSize);
mag = std::sqrt(mag) * 6.0f;
float centerHz = static_cast<float>((binStart + binEnd) / 2) * binHz;
float rolloff = juce::jlimit(0.0f, 1.0f, (centerHz - lowCut) / (lowPass - lowCut));
mag *= rolloff;
mags[i] = juce::jlimit(0.0f, 1.0f, mag);
}
juce::Path wavePath;
wavePath.startNewSubPath(bounds.getX(), bottom);
juce::Path strokePath;
strokePath.startNewSubPath(bounds.getX(), bottom - mags[0] * h);
for (int i = 0; i <= numPoints; ++i) {
float t = static_cast<float>(i) / static_cast<float>(numPoints);
float x = bounds.getX() + t * w;
float y = bottom - mags[i] * h;
wavePath.lineTo(x, y);
if (i > 0) strokePath.lineTo(x, y);
}
wavePath.lineTo(bounds.getRight(), bottom);
wavePath.closeSubPath();
juce::ColourGradient fillGrad(juce::Colour(0xffc59c07).withAlpha(0.35f), 0.0f, bottom,
juce::Colour(0xffc59c07).withAlpha(0.02f), 0.0f, bottom - h, false);
g.setGradientFill(fillGrad);
g.fillPath(wavePath);
g.setColour(juce::Colour(0xffc59c07).withAlpha(0.9f));
g.strokePath(strokePath, juce::PathStrokeType(1.5f));
g.setColour(juce::Colour(0xffccaa44));
g.setFont(juce::Font(juce::FontOptions(13.0f).withStyle("Bold")));
g.drawText("SPECTRUM", bounds.withBottom(bounds.getY() + 22.0f).translated(0, 6), juce::Justification::centred);
}
// --- Patch LCD (amber dot-matrix) ---
namespace {
struct Glyph { char c; unsigned char rows[7]; };
@ -476,7 +490,7 @@ void MidiLed::paint(juce::Graphics& g) {
}
MainContentComponent::MainContentComponent(ChromaFlockProcessor& p)
: processorRef(p), vuMeter(p), waveformDisplay(p), spectrumAnalyzer(p), pianoRoll(p), midiLed(p) {
: processorRef(p), vuMeter(p), waveformDisplay(p), pianoRoll(p), midiLed(p) {
auto setupParam = [&](juce::Slider& knob, std::unique_ptr<SliderAttachment>& attach,
const juce::String& paramId, const juce::String& name) {
@ -667,9 +681,29 @@ MainContentComponent::MainContentComponent(ChromaFlockProcessor& p)
addAndMakeVisible(vuMeter);
addAndMakeVisible(waveformDisplay);
addAndMakeVisible(spectrumAnalyzer);
addAndMakeVisible(pianoRoll);
// Arpeggiator section
auto setupArpLabel = [&](juce::Label& label, const juce::String& text) {
label.setText(text, juce::dontSendNotification);
label.setFont(juce::Font(juce::FontOptions(11.0f).withStyle("Bold")));
label.setColour(juce::Label::textColourId, juce::Colour(0xff888888));
label.setJustificationType(juce::Justification::centred);
addAndMakeVisible(label);
};
setupArpLabel(arpEnabledLabel, "ON");
setupArpLabel(arpPatternLabel, "PATTERN");
setupArpLabel(arpOctavesLabel, "OCTAVES");
setupArpLabel(arpDirectionLabel, "DIR");
setupArpLabel(arpRateLabel, "RATE");
setupCB(arpEnabledBox, arpEnabledAttach, "arpEnabled", {"Off", "On"});
setupCB(arpPatternBox, arpPatternAttach, "arpPattern",
{"Up", "Down", "UpDown", "DownUp", "Random", "As Played", "Chord"});
setupCB(arpOctavesBox, arpOctavesAttach, "arpOctaves", {"1", "2", "3"});
setupCB(arpDirectionBox, arpDirectionAttach, "arpDirection", {"Up", "Down"});
setupCB(arpRateBox, arpRateAttach, "arpRate", {"1/16", "1/8", "1/4", "1/2", "1", "2"});
setupCombo(uiScaleBox);
uiScaleBox.addItem("100%", 1);
uiScaleBox.addItem("125%", 2);
@ -1050,6 +1084,9 @@ void MainContentComponent::paint(juce::Graphics& g) {
// FX2 sub-sections
drawSubSection(1015, 586, 375, 150, "DELAY", 4);
drawSubSection(1395, 586, 255, 150, "REVERB", 4);
// Arpeggiator section (replaces the removed spectrum analyzer)
drawSubSection(860, 746, 790, 128, "ARPEGGIATOR", 6);
}
void MainContentComponent::resized() {
@ -1242,7 +1279,39 @@ void MainContentComponent::resized() {
int vizH = 128;
vuMeter.setBounds(10, vizY, 40, vizH);
waveformDisplay.setBounds(60, vizY, 790, vizH);
spectrumAnalyzer.setBounds(860, vizY, 790, vizH);
// Arpeggiator controls (right of the waveform display)
{
int arpX = 860, arpY = vizY, arpW = 790;
int comboH = 28;
int labelH = 16;
int labelY = arpY + 26;
int comboY = labelY + labelH + 8;
int widths[] = {90, 170, 90, 90, 110};
int gap = 22;
int totalW = widths[0] + widths[1] + widths[2] + widths[3] + widths[4] + gap * 4;
int x = arpX + (arpW - totalW) / 2;
arpEnabledLabel.setBounds(x, labelY, widths[0], labelH);
arpEnabledBox.setBounds(x, comboY, widths[0], comboH);
x += widths[0] + gap;
arpPatternLabel.setBounds(x, labelY, widths[1], labelH);
arpPatternBox.setBounds(x, comboY, widths[1], comboH);
x += widths[1] + gap;
arpOctavesLabel.setBounds(x, labelY, widths[2], labelH);
arpOctavesBox.setBounds(x, comboY, widths[2], comboH);
x += widths[2] + gap;
arpDirectionLabel.setBounds(x, labelY, widths[3], labelH);
arpDirectionBox.setBounds(x, comboY, widths[3], comboH);
x += widths[3] + gap;
arpRateLabel.setBounds(x, labelY, widths[4], labelH);
arpRateBox.setBounds(x, comboY, widths[4], comboH);
}
// Piano roll
pianoRoll.setBounds(10, 884, 1640, 136);

View file

@ -46,16 +46,7 @@ private:
class WaveformDisplay : public juce::Component, public juce::Timer {
public:
explicit WaveformDisplay(ChromaFlockProcessor& p) : processor(p) { startTimerHz(30); }
void paint(juce::Graphics& g) override;
void timerCallback() override { repaint(); }
private:
ChromaFlockProcessor& processor;
};
class SpectrumAnalyzer : public juce::Component, public juce::Timer {
public:
explicit SpectrumAnalyzer(ChromaFlockProcessor& p) : processor(p) {
explicit WaveformDisplay(ChromaFlockProcessor& p) : processor(p) {
fft = std::make_unique<juce::dsp::FFT>(ChromaFlockProcessor::fftOrder);
startTimerHz(30);
}
@ -64,6 +55,7 @@ public:
private:
ChromaFlockProcessor& processor;
std::unique_ptr<juce::dsp::FFT> fft;
std::vector<float> specSmooth;
};
class PatchLCD : public juce::Component {
@ -143,7 +135,6 @@ private:
VuMeter vuMeter;
WaveformDisplay waveformDisplay;
SpectrumAnalyzer spectrumAnalyzer;
PianoRollComponent pianoRoll;
juce::Label osc1Label, osc2Label, filterLabel, envLabel, fEnvLabel, globalLabel;
@ -185,6 +176,10 @@ private:
juce::ComboBox delaySyncBox;
KnobSlider reverbSizeKnob, reverbDampKnob, reverbMixKnob;
// Arpeggiator controls
juce::Label arpEnabledLabel, arpPatternLabel, arpOctavesLabel, arpDirectionLabel, arpRateLabel;
juce::ComboBox arpEnabledBox, arpPatternBox, arpOctavesBox, arpDirectionBox, arpRateBox;
// Preset menu
juce::TextButton presetButton{"PRESET"};
PatchLCD patchLCD;
@ -227,6 +222,10 @@ private:
std::unique_ptr<SliderAttachment> delayPingPongAttach;
std::unique_ptr<SliderAttachment> reverbSizeAttach, reverbDampAttach, reverbMixAttach;
// Arpeggiator attachments
std::unique_ptr<ComboBoxAttachment> arpEnabledAttach, arpPatternAttach, arpOctavesAttach;
std::unique_ptr<ComboBoxAttachment> arpDirectionAttach, arpRateAttach;
void setupLabel(juce::Label& label, const juce::String& text);
void setupKnob(juce::Slider& knob, const juce::String& name);
void setupCombo(juce::ComboBox& combo);

View file

@ -274,6 +274,23 @@ juce::AudioProcessorValueTreeState::ParameterLayout ChromaFlockProcessor::create
layout.add(std::make_unique<juce::AudioParameterFloat>(
juce::ParameterID{"reverbMix", 1}, "Reverb Mix", zeroOne, 0.2f));
// ARPEGGIATOR
layout.add(std::make_unique<juce::AudioParameterChoice>(
juce::ParameterID{"arpEnabled", 1}, "Arp On",
juce::StringArray{"Off", "On"}, 0));
layout.add(std::make_unique<juce::AudioParameterChoice>(
juce::ParameterID{"arpPattern", 1}, "Arp Pattern",
juce::StringArray{"Up", "Down", "UpDown", "DownUp", "Random", "As Played", "Chord"}, 0));
layout.add(std::make_unique<juce::AudioParameterChoice>(
juce::ParameterID{"arpOctaves", 1}, "Arp Octaves",
juce::StringArray{"1", "2", "3"}, 1));
layout.add(std::make_unique<juce::AudioParameterChoice>(
juce::ParameterID{"arpDirection", 1}, "Arp Direction",
juce::StringArray{"Up", "Down"}, 0));
layout.add(std::make_unique<juce::AudioParameterChoice>(
juce::ParameterID{"arpRate", 1}, "Arp Rate",
juce::StringArray{"1/16", "1/8", "1/4", "1/2", "1", "2"}, 1));
return layout;
}
@ -286,6 +303,9 @@ void ChromaFlockProcessor::prepareToPlay(double sampleRate, int samplesPerBlock)
delay.prepare(sampleRate);
reverbFX.prepare(sampleRate);
autoPanPhase = 0.0f;
arpSampleCount = 0.0;
std::vector<int> arpStop;
arp.reset(arpStop);
updateVoiceParameters();
}
@ -383,20 +403,86 @@ void ChromaFlockProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::
buffer.clear();
updateVoiceParameters();
for (const auto metadata : midiMessages)
if (metadata.getMessage().isNoteOn())
activeNotes[metadata.getMessage().getNoteNumber()].store(true, std::memory_order_relaxed);
else if (metadata.getMessage().isNoteOff())
activeNotes[metadata.getMessage().getNoteNumber()].store(false, std::memory_order_relaxed);
auto getRaw = [&](const juce::String& id) -> float {
auto* p = apvts.getRawParameterValue(id);
return p != nullptr ? p->load() : 0.0f;
};
bool arpOn = getRaw("arpEnabled") > 0.5f;
if (arpOn) {
static const double arpRateBeats[] = {0.25, 0.5, 1.0, 2.0, 4.0, 8.0};
int rateIdx = juce::jlimit(0, 5, juce::roundToInt(getRaw("arpRate")));
arp.setParameters(true,
juce::roundToInt(getRaw("arpPattern")),
juce::roundToInt(getRaw("arpOctaves")) + 1,
juce::roundToInt(getRaw("arpDirection")),
arpRateBeats[rateIdx],
currentBpm);
} else {
arp.setParameters(false, 0, 1, 0, 0.5, currentBpm);
}
int transpose = getTransposeSemitones();
juce::MidiBuffer transposed;
for (const auto metadata : midiMessages) {
auto msg = metadata.getMessage();
if (msg.isNoteOn() || msg.isNoteOff())
msg.setNoteNumber(juce::jlimit(0, 127, msg.getNoteNumber() + transpose));
if (msg.isNoteOn()) {
int note = msg.getNoteNumber();
activeNotes[note].store(true, std::memory_order_relaxed);
if (arpOn)
arp.noteOn(note, msg.getFloatVelocity());
else {
msg.setNoteNumber(juce::jlimit(0, 127, note + transpose));
transposed.addEvent(msg, metadata.samplePosition);
}
} else if (msg.isNoteOff()) {
int note = msg.getNoteNumber();
activeNotes[note].store(false, std::memory_order_relaxed);
if (arpOn)
arp.noteOff(note);
else {
msg.setNoteNumber(juce::jlimit(0, 127, note + transpose));
transposed.addEvent(msg, metadata.samplePosition);
}
} else {
transposed.addEvent(msg, metadata.samplePosition);
}
}
if (arpOn) {
// A freshly pressed key triggers the first note immediately; the
// clock then starts from zero so the subsequent ticks stay synced.
if (arp.shouldTriggerNow()) {
std::vector<int> stopNotes;
std::vector<Arpeggiator::NotePitch> playNotes;
arp.step(stopNotes, playNotes);
for (int n : stopNotes)
synth.noteOff(1, juce::jlimit(0, 127, n + transpose), 0.0f, true);
for (const auto& np : playNotes)
synth.noteOn(1, juce::jlimit(0, 127, np.note + transpose), np.velocity);
arpSampleCount = 0.0;
}
double tickSamples = arp.getTickSeconds() * currentSampleRate;
if (tickSamples < 1.0) tickSamples = 1.0;
arpSampleCount += buffer.getNumSamples();
while (arpSampleCount >= tickSamples) {
arpSampleCount -= tickSamples;
std::vector<int> stopNotes;
std::vector<Arpeggiator::NotePitch> playNotes;
arp.step(stopNotes, playNotes);
for (int n : stopNotes)
synth.noteOff(1, juce::jlimit(0, 127, n + transpose), 0.0f, true);
for (const auto& np : playNotes)
synth.noteOn(1, juce::jlimit(0, 127, np.note + transpose), np.velocity);
}
} else {
arpSampleCount = 0.0;
std::vector<int> stopNotes;
arp.reset(stopNotes);
for (int n : stopNotes)
synth.noteOff(1, juce::jlimit(0, 127, n + transpose), 0.0f, true);
}
synth.renderNextBlock(buffer, transposed, 0, buffer.getNumSamples());

View file

@ -1,8 +1,8 @@
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include <juce_audio_basics/juce_audio_basics.h>
#include <juce_dsp/juce_dsp.h>
#include "DSP/Voice.h"
#include "DSP/Arpeggiator.h"
#include "DSP/Distortion.h"
#include "DSP/Compressor.h"
#include "DSP/Limiter.h"
@ -53,16 +53,29 @@ public:
std::array<float, fftSize> fftInput{};
std::atomic<int> fftWritePos{0};
Arpeggiator arp;
float getRmsLevel() const { return rmsLevel.load(); }
bool isArpEnabled() const {
auto* p = apvts.getRawParameterValue("arpEnabled");
return p != nullptr && p->load() > 0.5f;
}
void noteOn(int midiNote, float velocity) {
if (midiNote >= 0 && midiNote < 128)
activeNotes[midiNote].store(true, std::memory_order_relaxed);
if (isArpEnabled())
arp.noteOn(midiNote, velocity);
else
synth.noteOn(1, juce::jlimit(0, 127, midiNote + getTransposeSemitones()), velocity);
}
void noteOff(int midiNote) {
if (midiNote >= 0 && midiNote < 128)
activeNotes[midiNote].store(false, std::memory_order_relaxed);
if (isArpEnabled())
arp.noteOff(midiNote);
else
synth.noteOff(1, juce::jlimit(0, 127, midiNote + getTransposeSemitones()), 0.0f, true);
}
bool isNoteActive(int midiNote) const {
@ -102,6 +115,7 @@ private:
double currentSampleRate = 44100.0;
double currentBpm = 120.0;
double arpSampleCount = 0.0;
std::atomic<float> rmsLevel{0.0f};
std::array<std::atomic<bool>, 128> activeNotes{};

File diff suppressed because it is too large Load diff

330
Tools/LevelNormalizer.cpp Normal file
View file

@ -0,0 +1,330 @@
// 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 <juce_core/juce_core.h>
#include <juce_audio_processors/juce_audio_processors.h>
#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 <cmath>
#include <cstdio>
#include <string>
#include <vector>
#include <fstream>
#include <sstream>
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<float>(static_cast<int>(param(p, id, static_cast<float>(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<SubtractiveVoice*>(synth.getVoice(0));
voice->setParameters(
static_cast<Waveform>(static_cast<int>(param(preset, "osc1Wave", 1))),
static_cast<Waveform>(static_cast<int>(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<FilterType>(static_cast<int>(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<int>(paramInt(preset, "lfo1Shape", 0)),
static_cast<int>(paramInt(preset, "lfo1Dest", 0)),
param(preset, "lfo2Rate", 2.0f),
param(preset, "lfo2Depth", 0.0f),
static_cast<int>(paramInt(preset, "lfo2Shape", 0)),
static_cast<int>(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<DistortionType>(static_cast<int>(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<float> buffer(2, blockSize);
std::vector<float> leftSamples, rightSamples;
const size_t capacity = static_cast<size_t>((holdSeconds + tailSeconds) * sampleRate);
leftSamples.reserve(capacity);
rightSamples.reserve(capacity);
auto renderAndApply = [&](double seconds) {
int total = static_cast<int>(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<float>(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<double>(mix) * mix;
}
out.peak = peak;
out.rms = static_cast<float>(std::sqrt(sumSquares / static_cast<double>(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<Result>& 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<Result> 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;
}