This commit is contained in:
Armin 2026-08-15 15:36:28 +02:00
commit 42cf8432bf
57 changed files with 5782 additions and 0 deletions

349
Source/AlgorithmPresets.h Normal file
View file

@ -0,0 +1,349 @@
#pragma once
// ============================================================
// AlgorithmPresets.h FDN Reverb Acoustic Data Library
// 7 algorithms x 10 bands of RT60 / EDT / D50 / C50 / C80
// Bands: 31.25 / 62.5 / 125 / 250 / 500 / 1k / 2k / 4k / 8k / 16k Hz
// ============================================================
#include <array>
namespace FDNReverb {
static constexpr int NUM_BANDS = 10;
static constexpr int NUM_ALGORITHMS = 7;
// Octave-band centre frequencies (Hz)
static constexpr std::array<float, NUM_BANDS> BAND_FREQ = {
31.25f, 62.5f, 125.0f, 250.0f, 500.0f,
1000.0f, 2000.0f, 4000.0f, 8000.0f, 16000.0f
};
struct AcousticData {
std::array<float, NUM_BANDS> rt60; // RT60 (s)
std::array<float, NUM_BANDS> edt; // EDT (s)
std::array<float, NUM_BANDS> d50; // Definition 0-1
std::array<float, NUM_BANDS> c50; // Clarity 50 ms (dB)
std::array<float, NUM_BANDS> c80; // Clarity 80 ms (dB)
};
// -----------------------------------------------------------------------------
// PresetDefaults: when a preset is selected load parameter
// -----------------------------------------------------------------------------
// when a preset is selected, these APVTS parameters are set automatically:
// so the distinctive sound of the preset can be experienced immediately.
// the user can still fine-tune every parameter afterwards.
//
// * v1.1.0 added : saturation
// the saturation harmonic intensity that brings out each preset's character.
// Room1/Room2: subtle (kept clean, restrained warmth)
// Hall1/Hall2: moderate (emphasizes a rich ring)
// Plate: moderate (brings out the metallic character)
// Spring: high (doubles the distinctive metallic character)
// Goldfoil: moderate (balanced)
//
// design rationale:
// - decayTime is the preset's native mid-band (500 Hz) RT60,
// giving decayScale = 1.0 so the source RT curve is reproduced exactly
// - other parameters are empirical values based on each preset's physical traits
// -----------------------------------------------------------------------------
struct PresetDefaults {
float roomSize; // 0.5 ~ 2.0
float decayTime; // seconds ( source preset mid-band RT60)
float hfDamp; // 0.0 ~ 1.0
float lfAbsorb; // 0.0 ~ 1.0
float diffusion; // 0.0 ~ 1.0
float modAmount; // 0.0 ~ 1.0
float modRate; // 0.1 ~ 10 Hz
float erLevel; // 0.0 ~ 1.0
float saturation; // * new in v1.1 : 0.0 ~ 1.0 ( harmonics intensity )
};
// -----------------------------------------------------------------------------
// preset default values
// -----------------------------------------------------------------------------
// index corresponds to algorithmIndex (0=Room1, 1=Room2, ..., 6=Goldfoil)
//
// saturation value rationale:
// - Room1/2 (0.15f/0.20f): natural, clean space; harmonics kept subtle.
// - Hall1/2 (0.30f/0.35f): adds warmth to the rich reverb.
// - Plate (0.40f): metal-plate resonance; moderate harmonics.
// - Spring (0.55f): distinctive metallic character is the core; stronger harmonics highlight it.
// - Goldfoil (0.35f): balanced, between plate and spring.
// -----------------------------------------------------------------------------
static constexpr std::array<PresetDefaults, 7> PRESET_DEFAULTS = { {
// Room1: small live room; decayTime = rt60[4] = 0.21 s
// keep the natural sense of space; saturation minimal (0.00f)
{ 0.85f, 0.21f, 0.55f, 0.45f, 0.55f, 0.20f, 0.40f, 0.70f, 0.00f },
// Room2: medium live room; decayTime = rt60[4] = 1.38 s
// slightly wider than Room1, add a little warmth (0.05f)
{ 1.00f, 1.38f, 0.50f, 0.40f, 0.60f, 0.25f, 0.45f, 0.65f, 0.05f },
// Hall1: medium hall; decayTime = rt60[4] = 1.89 s
// bring out the hall's richness with moderate harmonics (0.10f)
{ 1.30f, 1.89f, 0.45f, 0.35f, 0.70f, 0.30f, 0.30f, 0.60f, 0.10f },
// Hall2: large hall; decayTime = rt60[4] = 2.08 s
// add harmonics for the grandeur of the large space (0.10f)
{ 1.50f, 2.08f, 0.50f, 0.30f, 0.75f, 0.30f, 0.30f, 0.55f, 0.10f },
// Plate: plate reverb (metal plate); decayTime = rt60[4] = 1.1431 s
// the metal-plate resonance is the core; moderate harmonics bring out its character (0.15f)
{ 0.70f, 1.14f, 0.65f, 0.55f, 0.85f, 0.15f, 0.50f, 0.20f, 0.15f },
// Spring: spring decayTime = rt60[4] = 2.9252 s
// distinctive metallic character spring essence . stronger harmonics character (0.20f)
{ 0.50f, 2.93f, 0.70f, 0.60f, 0.65f, 0.26f, 0.33f, 0.10f, 0.20f },
// Goldfoil: gold foil decayTime = rt60[4] = 2.0642 s
// plate spring in between . balanced (0.18f)
{ 0.95f, 2.06f, 0.55f, 0.40f, 0.80f, 0.35f, 0.45f, 0.30f, 0.18f }
} };
// -----------------------------------------------------------------------------
// ERPattern: preset ISM (Image Source Method) ER
// -----------------------------------------------------------------------------
// Allen-Berkley 1979 ISM theory , preset space characteristics
// 12 early reflections .
//
// (delayMs, gain) :
// - delayMs: input delay time ( ms )
// - gain: amplitude (0.0 ~ 1.0)
//
// design principles :
// - distance inverse-square law : gain prop 1/distance prop 1/delayMs
// - wall absorption coefficient ( reflection about -3dB ~ -6dB)
// - physical reflection : floor -> wall -> ceiling -> reflection
//
// Plate/Spring/Goldfoil space ER
// (numTaps = 0). preset ER processing bypass .
// -----------------------------------------------------------------------------
static constexpr int MAX_ER_TAPS = 12;
struct ERTap {
float delayMs;
float gain;
};
struct ERPattern {
int numTaps; // 0 ER bypass
std::array<ERTap, MAX_ER_TAPS> taps;
};
// preset ER
static constexpr std::array<ERPattern, 7> PRESET_ER_PATTERNS = { {
// -------------------------------------------------------------
// Room1: small live room ( about 40 m^3)
// early reflections density , near-field wall surface reflection strong
// -------------------------------------------------------------
{ 12, {{
{ 5.2f, 0.65f }, // floor 1 reflection
{ 8.7f, 0.58f }, // side wall 1 reflection
{ 12.4f, 0.52f }, // side wall 1 reflection
{ 15.8f, 0.46f }, // ceiling 1 reflection
{ 19.3f, 0.42f }, // rear wall 1 reflection
{ 24.1f, 0.36f }, // floor + wall 2 reflection
{ 28.6f, 0.32f }, // wall + ceiling 2 reflection
{ 33.5f, 0.28f }, // 2 reflection
{ 38.9f, 0.24f }, // 2 reflection
{ 45.2f, 0.20f }, // 3 reflection group
{ 52.8f, 0.16f }, // 3 reflection group
{ 62.4f, 0.13f } // 3 reflection group
}}},
// -------------------------------------------------------------
// Room2: medium live room ( about 100 m^3)
// reflection times wide range , ER smooth
// -------------------------------------------------------------
{ 12, {{
{ 7.5f, 0.62f },
{ 12.3f, 0.55f },
{ 17.1f, 0.49f },
{ 22.8f, 0.43f },
{ 28.5f, 0.38f },
{ 34.2f, 0.33f },
{ 41.6f, 0.28f },
{ 49.3f, 0.24f },
{ 57.8f, 0.20f },
{ 67.5f, 0.17f },
{ 78.2f, 0.14f },
{ 90.6f, 0.11f }
}}},
// -------------------------------------------------------------
// Hall1: medium hall ( about 2000 m^3)
// side wall reflection dominant , initial delay longer
// -------------------------------------------------------------
{ 12, {{
{ 12.0f, 0.58f }, // floor 1 reflection
{ 18.5f, 0.52f }, // side wall 1 reflection
{ 25.7f, 0.47f }, // ceiling 1 reflection
{ 33.4f, 0.42f }, // side wall 1 reflection
{ 42.1f, 0.38f }, // rear wall 1 reflection
{ 51.6f, 0.33f }, // 2 reflection
{ 62.3f, 0.29f }, // 2 reflection
{ 73.9f, 0.25f }, // 2 reflection
{ 86.5f, 0.21f }, // 3 reflection
{ 99.8f, 0.18f }, // 3 reflection
{ 113.4f, 0.15f }, // 3 reflection
{ 128.7f, 0.12f } // diffuse reflection
}}},
// -------------------------------------------------------------
// Hall2: large hall ( about 12000 m^3)
// reflection times further , initial delay large
// -------------------------------------------------------------
{ 12, {{
{ 16.5f, 0.55f },
{ 24.8f, 0.50f },
{ 33.7f, 0.45f },
{ 43.5f, 0.40f },
{ 54.2f, 0.36f },
{ 65.8f, 0.32f },
{ 78.4f, 0.28f },
{ 92.1f, 0.24f },
{ 107.3f, 0.21f },
{ 123.6f, 0.18f },
{ 141.2f, 0.15f },
{ 160.5f, 0.12f }
}}},
// -------------------------------------------------------------
// Plate: space (ER bypass )
// -------------------------------------------------------------
{ 0, {{}} },
// -------------------------------------------------------------
// Spring: space (ER bypass )
// -------------------------------------------------------------
{ 0, {{}} },
// -------------------------------------------------------------
// Goldfoil: space (ER bypass )
// -------------------------------------------------------------
{ 0, {{}} }
} };
struct AlgorithmPreset {
const char* name;
const char* description;
float volumeM3; // estimated room volume (0 = non-room)
AcousticData acoustics;
};
// -----------------------------------------------------------------------------
// ROOM 1 : OpenAIR Measured Room
// -----------------------------------------------------------------------------
static constexpr AlgorithmPreset PRESET_ROOM1 = {
"ROOM1", "Real Room 1 (OpenAIR)", 40.0f,
{
// RT60 (s) 31 62 125 250 500 1k 2k 4k 8k 16k
{{ 0.8f, 0.57f, 0.28f, 0.27f, 0.21f, 0.19f, 0.21f, 0.22f, 0.21f, 0.18f }},
// EDT (s)
{{ 0.98f, 0.73f, 0.34f, 0.22f, 0.22f, 0.22f, 0.22f, 0.22f, 0.22f, 0.22f }},
// D50
{{ 0.26f, 0.46f, 0.92f, 0.94f, 0.98f, 0.97f, 0.97f, 0.97f, 0.96f, 0.98f }},
// C50 (dB)
{{ -4.65f, -0.74f, 10.63f, 11.92f, 16.19f, 15.36f, 14.75f, 14.48f, 13.44f, 17.54f }},
// C80 (dB)
{{ -2.33f, 5.82f, 16.5f, 20.03f, 23.87f, 26.0f, 23.59f, 22.54f, 22.92f, 27.99f }}
}
};
// -----------------------------------------------------------------------------
// ROOM 2 : OpenAIR Measured Room
// -----------------------------------------------------------------------------
static constexpr AlgorithmPreset PRESET_ROOM2 = {
"ROOM2", "Real Room 2 (OpenAIR)", 100.0f,
{
{{ 4.46f, 1.53f, 1.65f, 1.59f, 1.38f, 0.94f, 0.93f, 0.87f, 0.67f, 1.54f }},
{{ 1.59f, 1.33f, 1.07f, 1.07f, 1.2f, 1.07f, 0.95f, 0.82f, 0.69f, 0.43f }},
{{ 0.2f, 0.3f, 0.39f, 0.56f, 0.43f, 0.53f, 0.52f, 0.57f, 0.68f, 0.86f }},
{{ -5.99f, -3.59f, -1.96f, 1.05f, -1.16f, 0.51f, 0.41f, 1.21f, 3.23f, 7.73f }},
{{ 0.74f, 0.26f, 3.76f, 3.18f, 1.94f, 3.67f, 4.06f, 5.35f, 7.25f, 12.52f }}
}
};
// -----------------------------------------------------------------------------
// HALL 1 : OpenAIR Measured Hall
// -----------------------------------------------------------------------------
static constexpr AlgorithmPreset PRESET_HALL1 = {
"HALL1", "Real Hall 1 (OpenAIR)", 2000.0f,
{
{{ 3.55f, 2.28f, 2.18f, 2.05f, 1.89f, 1.86f, 1.69f, 1.28f, 0.9f, 5.53f }},
{{ 2.72f, 1.95f, 1.7f, 2.21f, 2.08f, 2.08f, 1.82f, 1.44f, 1.06f, 0.42f }},
{{ 0.06f, 0.19f, 0.25f, 0.12f, 0.15f, 0.19f, 0.2f, 0.33f, 0.41f, 0.83f }},
{{ -12.08f, -6.4f, -4.76f, -8.49f, -7.57f, -6.27f, -6.11f, -3.16f, -1.62f, 6.9f }},
{{ -7.02f, -2.45f, -0.66f, -3.54f, -2.86f, -2.84f, -2.37f, 0.46f, 2.63f, 11.94f }}
}
};
// -----------------------------------------------------------------------------
// HALL 2 : OpenAIR Measured Hall
// -----------------------------------------------------------------------------
static constexpr AlgorithmPreset PRESET_HALL2 = {
"HALL2", "Real Hall 2 (OpenAIR)", 12000.0f,
{
{{ 2.15f, 1.48f, 1.63f, 1.91f, 2.08f, 2.09f, 1.82f, 1.6f, 1.18f, 1.11f }},
{{ 1.83f, 1.45f, 1.45f, 2.34f, 2.22f, 1.96f, 1.83f, 1.7f, 1.45f, 1.19f }},
{{ 0.08f, 0.2f, 0.4f, 0.16f, 0.21f, 0.29f, 0.37f, 0.3f, 0.33f, 0.44f }},
{{ -10.33f, -6.01f, -1.69f, -7.33f, -5.82f, -3.83f, -2.34f, -3.73f, -3.08f, -1.f }},
{{ -2.99f, -3.18f, -0.69f, -3.02f, -3.05f, -0.92f, -0.36f, -0.77f, 0.13f, 2.59f }}
}
};
// -----------------------------------------------------------------------------
// PLATE : Studio Nord Bremen EMT-Style
// -----------------------------------------------------------------------------
static constexpr AlgorithmPreset PRESET_PLATE = {
"PLATE", "Vintage Plate (Studio Nord Bremen)", 0.0f,
{
{{ 4.6257f, 2.4647f, 1.6639f, 1.6039f, 1.1431f, 0.8664f, 0.6561f, 0.4921f, 0.3153f, 0.1973f }},
{{ 4.0113f, 2.1374f, 1.4429f, 1.3909f, 1.1926f, 0.8981f, 0.6476f, 0.5082f, 0.3132f, 0.2154f }},
{{ 0.2421f, 0.3389f, 0.4357f, 0.4841f, 0.3966f, 0.5246f, 0.5914f, 0.7155f, 0.8915f, 0.9695f }},
{{ -0.795f, -0.4236f, -0.286f, -0.2757f, -1.8231f, 0.4282f, 1.6063f, 4.0053f, 9.1464f, 15.0212f }},
{{ 5.9205f, 3.1547f, 2.1296f, 2.0529f, 1.1228f, 3.8402f, 5.8356f, 8.7699f, 14.5235f, 23.4324f }}
}
};
// -----------------------------------------------------------------------------
// SPRING : Studio Nord Bremen Vintage Spring
// -----------------------------------------------------------------------------
static constexpr AlgorithmPreset PRESET_SPRING = {
"SPRING", "Vintage Spring (Studio Nord Bremen)", 0.0f,
{
{{ 10.3635f, 5.522f, 3.7278f, 3.5934f, 2.9252f, 2.7681f, 2.0397f, 2.0373f, 2.1111f, 0.9319f }},
{{ 4.9277f, 2.6256f, 1.7725f, 1.7086f, 1.5756f, 1.875f, 1.3502f, 1.3897f, 1.759f, 0.4177f }},
{{ 0.1261f, 0.1765f, 0.227f, 0.2522f, 0.2939f, 0.1999f, 0.3321f, 0.3724f, 0.3551f, 0.9202f }},
{{ -13.6143f, -7.2541f, -4.8971f, -4.7206f, -3.806f, -6.0225f, -3.0348f, -2.2663f, -2.591f, 10.6169f }},
{{ -3.0172f, -1.6077f, -1.0853f, -1.0462f, -1.6692f, -2.0212f, 0.4169f, 0.5432f, 0.0761f, 13.5636f }}
}
};
// -----------------------------------------------------------------------------
// GOLD FOIL : Studio Nord Bremen Foil Reverb
// -----------------------------------------------------------------------------
static constexpr AlgorithmPreset PRESET_GOLDFOIL = {
"GOLDFOIL", "Gold Foil (Studio Nord Bremen)", 0.0f,
{
{{ 6.3421f, 3.3793f, 2.2813f, 2.1991f, 2.0642f, 2.255f, 2.17f, 1.4604f, 0.8313f, 0.4339f }},
{{ 5.8718f, 3.1287f, 2.1121f, 2.036f, 1.8796f, 2.2713f, 2.0286f, 1.4122f, 0.7486f, 0.3458f }},
{{ 0.1209f, 0.1693f, 0.2177f, 0.2419f, 0.2894f, 0.2261f, 0.2938f, 0.4516f, 0.6304f, 0.8808f }},
{{ -14.3077f, -7.6236f, -5.1466f, -4.9611f, -3.9018f, -5.3429f, -3.8094f, -0.8434f, 2.3195f, 8.6843f }},
{{ -2.5178f, -1.3416f, -0.9057f, -0.873f, -1.4987f, -1.424f, -1.4095f, 1.7577f, 5.4677f, 12.9829f }}
}
};
// -----------------------------------------------------------------------------
// Master table
// -----------------------------------------------------------------------------
static constexpr std::array<const AlgorithmPreset*, NUM_ALGORITHMS> ALL_PRESETS = { {
&PRESET_ROOM1,
&PRESET_ROOM2,
&PRESET_HALL1,
&PRESET_HALL2,
&PRESET_PLATE,
&PRESET_SPRING,
&PRESET_GOLDFOIL
} };
} // namespace FDNReverb

5
Source/BuildInfo.h.in Normal file
View file

@ -0,0 +1,5 @@
#pragma once
#define AMBIVALENCE_GIT_BRANCH "@AMBIVALENCE_GIT_BRANCH@"
#define AMBIVALENCE_GIT_COMMIT "@AMBIVALENCE_GIT_COMMIT@"
#define AMBIVALENCE_GIT_DIRTY @AMBIVALENCE_GIT_DIRTY@

View file

@ -0,0 +1,168 @@
#include "AcousticMetrics.h"
#include <algorithm>
#include <cmath>
namespace FDNReverb {
void AcousticMetrics::prepare(double sr, float windowMs) {
sampleRate = sr;
analysisWindowMs = windowMs;
// sample rate time sample count
samples50ms = static_cast<int>(0.050 * sr);
samples80ms = static_cast<int>(0.080 * sr);
analysisWindowSamples = static_cast<int>(windowMs * 0.001 * sr);
// read from the history buffer size analysis +
size_t bufferSize = static_cast<size_t>(analysisWindowSamples + samples80ms + 64);
energyHistory.assign(bufferSize, 0.0f);
reset();
}
void AcousticMetrics::reset() noexcept {
std::fill(energyHistory.begin(), energyHistory.end(), 0.0f);
historyWritePos = 0;
recent50msEnergy = 0.0;
recent80msEnergy = 0.0;
totalEnergy = 0.0;
energyPeak = 0.0f;
energyPeakPos = 0;
updateCounter = 0;
d50.store(0.0f, std::memory_order_relaxed);
c50.store(0.0f, std::memory_order_relaxed);
c80.store(0.0f, std::memory_order_relaxed);
edt.store(0.0f, std::memory_order_relaxed);
}
void AcousticMetrics::processSample(float sample) noexcept {
if (energyHistory.empty()) return;
const int bufferSize = static_cast<int>(energyHistory.size());
// current sample energy ( squared )
float currentEnergy = sample * sample;
// read from the history buffer
energyHistory[historyWritePos] = currentEnergy;
// update the running sums (50 ms / 80 ms / full window)
// add the current sample, subtract the value from 50 ms ago
const int read50Pos = (historyWritePos - samples50ms + bufferSize) % bufferSize;
const int read80Pos = (historyWritePos - samples80ms + bufferSize) % bufferSize;
const int readWindowPos = (historyWritePos - analysisWindowSamples + bufferSize) % bufferSize;
recent50msEnergy += currentEnergy - energyHistory[read50Pos];
recent80msEnergy += currentEnergy - energyHistory[read80Pos];
totalEnergy += currentEnergy - energyHistory[readWindowPos];
// peak detection (EDT estimate )
if (currentEnergy > energyPeak) {
energyPeak = currentEnergy;
energyPeakPos = historyWritePos;
}
//
historyWritePos = (historyWritePos + 1) % bufferSize;
// value stable ( cumulative value 0 )
if (recent50msEnergy < 0.0) recent50msEnergy = 0.0;
if (recent80msEnergy < 0.0) recent80msEnergy = 0.0;
if (totalEnergy < 0.0) totalEnergy = 0.0;
// value interval update
if (++updateCounter >= kUpdateInterval) {
updateMetrics();
updateCounter = 0;
}
}
void AcousticMetrics::updateMetrics() noexcept {
// 50ms energy
double energy50ToInf = totalEnergy - recent50msEnergy;
if (energy50ToInf < 1e-12) energy50ToInf = 1e-12;
// 80ms energy
double energy80ToInf = totalEnergy - recent80msEnergy;
if (energy80ToInf < 1e-12) energy80ToInf = 1e-12;
// entire energy ( minimum value clipping )
double totalSafe = std::max(1e-12, totalEnergy);
// -- D50 compute (0~1 ) --
float d50val = static_cast<float>(recent50msEnergy / totalSafe);
d50val = std::min(1.0f, std::max(0.0f, d50val));
d50.store(d50val, std::memory_order_relaxed);
// -- C50 compute (dB) --
float c50val = static_cast<float>(10.0 * std::log10(recent50msEnergy / energy50ToInf));
c50val = std::min(60.0f, std::max(-60.0f, c50val));
c50.store(c50val, std::memory_order_relaxed);
// -- C80 compute (dB) --
float c80val = static_cast<float>(10.0 * std::log10(recent80msEnergy / energy80ToInf));
c80val = std::min(60.0f, std::max(-60.0f, c80val));
c80.store(c80val, std::memory_order_relaxed);
// -- EDT estimate (running) --
// after the peak, the time until energy falls to 1/10 (-10 dB decay)
// * exact EDT needs offline IR analysis; here we estimate from the peak decay time
float edtVal = 0.0f;
if (energyPeak > 1e-9f) {
// analysis peak
// scan from the peak sample until the energy reaches 1/10
const int bufferSize = static_cast<int>(energyHistory.size());
int searchStart = energyPeakPos;
float threshold = energyPeak * 0.1f; // 10dB decay
int decaySamples = 0;
for (int i = 1; i < analysisWindowSamples; ++i) {
int pos = (searchStart + i) % bufferSize;
if (energyHistory[pos] < threshold) {
decaySamples = i;
break;
}
}
edtVal = static_cast<float>(decaySamples) / static_cast<float>(sampleRate) * 6.0f;
// * 10 dB decay time x 6 ~= EDT (60 dB decay correction)
}
edt.store(edtVal, std::memory_order_relaxed);
}
// -----------------------------------------------------------------------------
// drawing: get instantaneous energy at a past time offset
// -----------------------------------------------------------------------------
// secondsAgo: how many seconds in the past to look up
// returns: the energy value at that time (squared)
//
// reads the history buffer directly for the GUI.
// out of range returns 0.
// -----------------------------------------------------------------------------
float AcousticMetrics::getEnergyAtTimeOffset(float secondsAgo) const noexcept {
if (energyHistory.empty()) return 0.0f;
const int bufferSize = static_cast<int>(energyHistory.size());
int offsetSamples = static_cast<int>(secondsAgo * static_cast<float>(sampleRate));
// clamp to range
if (offsetSamples < 0) offsetSamples = 0;
if (offsetSamples >= analysisWindowSamples) return 0.0f;
// read from the history buffer
int readPos = (historyWritePos - 1 - offsetSamples + bufferSize) % bufferSize;
return energyHistory[readPos];
}
// -----------------------------------------------------------------------------
// input activity detection: energy over the last 50 ms
// -----------------------------------------------------------------------------
// used by the GUI to hide the "measured line" when inactive.
// threshold: -60 dBFS (1e-6) energy
// -----------------------------------------------------------------------------
bool AcousticMetrics::isActive() const noexcept {
// energy over the last 50 ms determines activity
constexpr double kActivityThreshold = 1e-6; // -60dBFS
return recent50msEnergy > kActivityThreshold;
}
} // namespace FDNReverb

View file

@ -0,0 +1,104 @@
#pragma once
#include "DSPConstants.h"
#include <array>
#include <atomic>
#include <vector> // <- 1 row added
namespace FDNReverb {
// -----------------------------------------------------------------------------
// AcousticMetrics class
// -----------------------------------------------------------------------------
// Computes real-time acoustic metrics (D50, C50, C80, EDT).
//
// Principle:
// Accumulate the squared energy of the input signal in a ring buffer,
// then compare it against the energy from 50 ms / 80 ms ago,
// and compute the D50 / C50 / C80 values.
//
// Sample-rate support:
// times (ms) are converted to sample counts,
// so 44.1 kHz through 192 kHz are supported automatically.
//
// CPU:
// O(1) per-sample computation (energy accumulation)
// CPU overhead: below ~0.5%
// -----------------------------------------------------------------------------
class AcousticMetrics {
public:
AcousticMetrics() = default;
// -- initialize --
// sampleRate: sample rate (Hz)
// analysisWindowMs: analysis window (ms). 2000 ms (2 s)
void prepare(double sampleRate, float analysisWindowMs = 2000.0f);
// -- per-sample state update --
// sample: current Wet signal sample (mono)
void processSample(float sample) noexcept;
// -- value getters --
// ranges:
// D50: 0.0 ~ 1.0 ( 0.3~0.9)
// C50: -10 ~ +30 dB
// C80: -10 ~ +30 dB
// EDT: 0.0 ~ 5.0 (s)
float getD50() const noexcept { return d50.load(std::memory_order_relaxed); }
float getC50() const noexcept { return c50.load(std::memory_order_relaxed); }
float getC80() const noexcept { return c80.load(std::memory_order_relaxed); }
float getEDT() const noexcept { return edt.load(std::memory_order_relaxed); }
// --- added: expose energy history for drawing ---
// get the instantaneous energy (squared) at a time offset in the past
float getEnergyAtTimeOffset(float secondsAgo) const noexcept;
// input activity detection (energy over the last 50 ms)
bool isActive() const noexcept;
// -- reset --
void reset() noexcept;
private:
// -- compute --
void updateMetrics() noexcept;
// -- parameter --
double sampleRate{ 48000.0 };
float analysisWindowMs{ 2000.0f };
// 50ms / 80ms sample count ( sample rate depends on )
int samples50ms{ 2400 }; // @ 48kHz
int samples80ms{ 3840 }; // @ 48kHz
int analysisWindowSamples{ 96000 }; // 2000ms @ 48kHz
// -- buffer --
// energy history ( squared value )
std::vector<float> energyHistory;
int historyWritePos{ 0 };
// -- cumulative energy value --
// 50ms cumulative energy ( time )
double recent50msEnergy{ 0.0 };
// 80ms cumulative energy ( time )
double recent80msEnergy{ 0.0 };
// entire cumulative energy
double totalEnergy{ 0.0 };
// EDT : energy decay tracking
float energyPeak{ 0.0f };
int energyPeakPos{ 0 };
// -- output value (atomic for thread safety) --
std::atomic<float> d50{ 0.0f };
std::atomic<float> c50{ 0.0f };
std::atomic<float> c80{ 0.0f };
std::atomic<float> edt{ 0.0f };
// -- update --
// sample compute ,
// sample interval update
int updateCounter{ 0 };
static constexpr int kUpdateInterval = 1024; // about 21ms @ 48kHz
};
} // namespace FDNReverb

View file

@ -0,0 +1,103 @@
#include "BiquadFilters.h"
#include "MagnitudeResponseFitter.h"
#include <JuceHeader.h>
#include <cmath>
#include <algorithm>
namespace FDNReverb {
namespace FilterDesign {
static float tanPi(float f, double fs) noexcept {
return std::tan(juce::MathConstants<float>::pi * (float)(f / fs));
}
BiquadCoeffs lowShelf(float fcHz, float gainDB, double sampleRate) {
float A = std::pow(10.f, gainDB / 40.f);
float K = tanPi(fcHz, sampleRate);
BiquadCoeffs c;
if (gainDB >= 0.f) {
float norm = 1.f / (1.f + K);
c.b0 = (1.f + A * K) * norm;
c.b1 = (A * K - 1.f) * norm;
c.b2 = 0.f;
c.a1 = (K - 1.f) * norm;
c.a2 = 0.f;
}
else {
c.b0 = (1.f + K / A) / (1.f + K);
c.b1 = (K / A - 1.f) / (1.f + K);
c.b2 = 0.f;
c.a1 = (K - 1.f) / (1.f + K);
c.a2 = 0.f;
}
return c;
}
BiquadCoeffs highShelf(float fcHz, float gainDB, double sampleRate) {
float A = std::pow(10.f, gainDB / 40.f);
float K = tanPi(fcHz, sampleRate);
BiquadCoeffs c;
if (gainDB >= 0.f) {
float norm = 1.f / (1.f + K);
c.b0 = (A + K) * norm;
c.b1 = (K - A) * norm;
c.b2 = 0.f;
c.a1 = (K - 1.f) * norm;
c.a2 = 0.f;
}
else {
float norm = 1.f / (1.f + K);
c.b0 = (1.f + A * K) * norm;
c.b1 = (A * K - 1.f) * norm;
c.b2 = 0.f;
c.a1 = (K - 1.f) * norm;
c.a2 = 0.f;
}
return c;
}
BiquadCoeffs peak(float fcHz, float gainDB, float Q, double sampleRate) {
float A = std::pow(10.f, gainDB / 40.f);
float w0 = 2.f * juce::MathConstants<float>::pi * fcHz / (float)sampleRate;
float alpha = std::sin(w0) / (2.f * Q);
float cos0 = std::cos(w0);
BiquadCoeffs c;
c.a1 = 2.f * cos0 / (1.f + alpha / A);
c.a2 = (1.f - alpha / A) / (1.f + alpha / A);
c.b0 = (1.f + alpha * A) / (1.f + alpha / A);
c.b1 = -2.f * cos0 / (1.f + alpha / A);
c.b2 = (1.f - alpha * A) / (1.f + alpha / A);
return c;
}
BiquadCoeffs highPass1st(float fcHz, double sampleRate) {
float K = tanPi(fcHz, sampleRate);
float n = 1.f + K;
BiquadCoeffs c;
c.b0 = 1.f / n; c.b1 = -1.f / n; c.b2 = 0.f;
c.a1 = (K - 1.f) / n; c.a2 = 0.f;
return c;
}
// -------------------------------------------------------------------------
// designAbsorption: MagnitudeResponseFitter
// -------------------------------------------------------------------------
// keeps the existing (UniversalEngine) helper functions,
// preserving the internal Stage-1 MRF behavior.
//
// old implementation : gain + Low/High cascade
// new implementation : Jot orthogonalizing 1 filter + LF/HF correction
// -------------------------------------------------------------------------
std::array<BiquadCoeffs, ABSO_STAGES> designAbsorption(
int delaySamples, double sampleRate,
const std::array<float, NUM_BANDS>& rt60,
float hfDamping, float lfAbsorption)
{
// MagnitudeResponseFitter processing
auto result = MagnitudeResponseFitter::design(
delaySamples, sampleRate, rt60, hfDamping, lfAbsorption);
return result.coeffs;
}
} // namespace FilterDesign
} // namespace FDNReverb

View file

@ -0,0 +1,43 @@
#pragma once
#include "DSPConstants.h"
#include "../AlgorithmPresets.h"
#include <array>
namespace FDNReverb {
// -----------------------------------------------------------------------------
// Biquad helpers (Direct Form II Transposed - most robust)
// -----------------------------------------------------------------------------
struct BiquadCoeffs {
float b0{ 1.f }, b1{ 0.f }, b2{ 0.f };
float a1{ 0.f }, a2{ 0.f };
};
struct BiquadState {
float s1{ 0.f }, s2{ 0.f };
inline float tick(float x, const BiquadCoeffs& c) noexcept {
float y = c.b0 * x + s1;
s1 = c.b1 * x - c.a1 * y + s2;
s2 = c.b2 * x - c.a2 * y;
return y;
}
void reset() noexcept { s1 = s2 = 0.f; }
};
// -----------------------------------------------------------------------------
// Filter design utilities
// -----------------------------------------------------------------------------
namespace FilterDesign {
BiquadCoeffs lowShelf(float fcHz, float gainDB, double sampleRate);
BiquadCoeffs highShelf(float fcHz, float gainDB, double sampleRate);
BiquadCoeffs peak(float fcHz, float gainDB, float Q, double sampleRate);
BiquadCoeffs highPass1st(float fcHz, double sampleRate);
BiquadCoeffs allpass1st(float fcHz, double sampleRate);
// Design absorption filter cascade for delay lines
// : function internal MagnitudeResponseFitter
std::array<BiquadCoeffs, ABSO_STAGES> designAbsorption(
int delaySamples, double sampleRate,
const std::array<float, NUM_BANDS>& rt60,
float hfDamping, float lfAbsorption);
}
} // namespace FDNReverb

26
Source/DSP/DSPConstants.h Normal file
View file

@ -0,0 +1,26 @@
#pragma once
#include <array>
namespace FDNReverb {
// -- Compile-time constants ----------------------------------------------------
static constexpr int FDN_N = 8; // FDN order (channels; legacy definition kept for reference)
static constexpr int SAPF_STAGES = 3; // allpass stages per delay line
static constexpr int ABSO_STAGES = 3; // Stage 1: Jot first-order + LF/HF correction
static constexpr int ER_TAPS = 16; // early-reflection FIR taps
// Stage 2 (Valimaki-Liski cumulative GEQ) stages:
// 10: 10-band GEQ (interaction matrix + WLS)
//
// important design notes:
// - the mid-band gain (midGain) of GEQ band 0 is absorbed into the b0/b1/b2 coefficients;
// no separate DC gain stage is needed to avoid DC coloration,
// just a single gain.
// - LF Absorption / HF Damping are applied directly as GEQ target dB,
// fully independent of each other.
// - targets are clamped to 0 dB or below, mathematically guaranteeing loop gain <= 1.
static constexpr int ABSO_STAGES_S2 = 10;
// Mutually-prime base delays (samples @ 48 kHz), log-distributed 30-130 ms
static constexpr std::array<int, FDN_N> BASE_PRIMES_48K = {
1451, 1693, 1979, 2311, 2683, 3067, 3491, 3923
};
} // namespace FDNReverb

134
Source/DSP/DelayMemory.h Normal file
View file

@ -0,0 +1,134 @@
#pragma once
#include <vector>
#include <cmath>
#include <algorithm>
#include <cstdint>
namespace FDNReverb {
// -----------------------------------------------------------------------------
// memory pool (Single-Large Buffer)
// -----------------------------------------------------------------------------
class DelayMemoryPool {
public:
void allocate(size_t totalSamples) {
buffer.assign(totalSamples, 0.0f);
allocOffset = 0;
}
// pointer sized up to the next power of two (also outputs an index mask)
float* requestMemory(size_t samplesNeeded, int& outMask) {
size_t powerOfTwoSize = 1;
while (powerOfTwoSize < samplesNeeded) powerOfTwoSize *= 2;
if (allocOffset + powerOfTwoSize > buffer.size()) return nullptr;
float* ptr = buffer.data() + allocOffset;
outMask = static_cast<int>(powerOfTwoSize - 1);
allocOffset += powerOfTwoSize;
return ptr;
}
void clear() { std::fill(buffer.begin(), buffer.end(), 0.0f); }
private:
std::vector<float> buffer;
size_t allocOffset{ 0 };
};
// -----------------------------------------------------------------------------
// interpolation
// -----------------------------------------------------------------------------
class LinearDelayLine {
public:
void init(float* memory, int bitmask) {
buffer = memory;
mask = bitmask;
writeIndex = 0;
}
// linear interpolation ( high band natural Air Absorption )
inline float read(float delayInSamples) const noexcept {
int id = static_cast<int>(delayInSamples);
float frac = delayInSamples - static_cast<float>(id);
// bitwise ops undefined behavior completely , uint32_t
uint32_t uWrite = static_cast<uint32_t>(writeIndex);
uint32_t uId = static_cast<uint32_t>(id);
uint32_t uMask = static_cast<uint32_t>(mask);
int readIdx1 = static_cast<int>((uWrite - uId) & uMask);
int readIdx2 = static_cast<int>((uWrite - uId - 1) & uMask);
return buffer[readIdx1] + frac * (buffer[readIdx2] - buffer[readIdx1]);
}
inline void write(float input) noexcept {
buffer[writeIndex] = input;
writeIndex = (writeIndex + 1) & mask;
}
private:
float* buffer{ nullptr };
int mask{ 0 };
int writeIndex{ 0 };
};
// -----------------------------------------------------------------------------
// Thiran allpass interpolation (preserves the phase response)
// linear interpolation would dull high-band decay (sinc(pi*f) rolloff), so use a Thiran allpass
// which keeps |H(w)| = 1, preserving high-band clarity in the FDN feedback loops.
// -----------------------------------------------------------------------------
class ThiranDelayLine {
public:
void init(float* memory, int bitmask) {
buffer = memory;
mask = bitmask;
writeIndex = 0;
thiranX1 = 0.0f;
thiranY1 = 0.0f;
}
void resetState() noexcept {
thiranX1 = 0.0f;
thiranY1 = 0.0f;
}
// Thiran first-order allpass: y[n] = a*x[n] + x[n-1] - a*y[n-1]
// a = (1-D)/(1+D), D = fractional delay
inline float read(float delayInSamples) noexcept {
int id = static_cast<int>(delayInSamples);
float frac = delayInSamples - static_cast<float>(id);
// clamp below to avoid instability as frac->0, a->1
frac = std::max(frac, 0.1f);
const float a = (1.0f - frac) / (1.0f + frac);
uint32_t uWrite = static_cast<uint32_t>(writeIndex);
uint32_t uId = static_cast<uint32_t>(id);
uint32_t uMask = static_cast<uint32_t>(mask);
float xn = buffer[static_cast<int>((uWrite - uId) & uMask)];
float yn = a * xn + thiranX1 - a * thiranY1;
thiranX1 = xn;
thiranY1 = yn;
return yn;
}
inline void write(float input) noexcept {
buffer[writeIndex] = input;
writeIndex = (writeIndex + 1) & mask;
}
private:
float* buffer{ nullptr };
int mask{ 0 };
int writeIndex{ 0 };
float thiranX1{ 0.0f };
float thiranY1{ 0.0f };
};
} // namespace FDNReverb

View file

@ -0,0 +1,66 @@
#include "EarlyReflections.h"
namespace FDNReverb {
void EarlyReflections::prepare(const juce::dsp::ProcessSpec& spec) {
int maxSamples = (int)(0.7 * spec.sampleRate) + 8;
juce::dsp::ProcessSpec mono = spec;
mono.numChannels = 1;
buf.prepare(mono);
buf.setMaximumDelayInSamples(maxSamples);
erHPCoeffs = FilterDesign::highPass1st(80.f, spec.sampleRate);
float K = std::tan(juce::MathConstants<float>::pi * 6000.f / (float)spec.sampleRate);
erLPCoeffs.b0 = K / (1.f + K);
erLPCoeffs.b1 = erLPCoeffs.b0;
erLPCoeffs.b2 = 0.f;
erLPCoeffs.a1 = (K - 1.f) / (K + 1.f);
erLPCoeffs.a2 = 0.f;
}
void EarlyReflections::buildTaps(const AlgorithmPreset& preset, float roomSizeScale, double sampleRate) {
float erEnergy50 = preset.acoustics.d50[4];
float V = preset.volumeM3 > 0.f ? preset.volumeM3 : 10.f;
float mixTimeMs = std::min(0.0117f * V + 50.1f, 150.f);
float span = mixTimeMs * roomSizeScale;
for (int i = 0; i < ER_TAPS; ++i) {
float t01 = static_cast<float>(i + 1) / static_cast<float>(ER_TAPS);
float delMs = span * std::pow(t01, 1.5f);
taps[i].delaySamples = delMs * 0.001f * (float)sampleRate;
float rt60m = preset.acoustics.rt60[4];
float amp = std::exp(-6.9f * delMs * 0.001f / rt60m);
float factor = (i < ER_TAPS / 2) ? std::sqrt(erEnergy50) : std::sqrt(1.f - erEnergy50);
amp *= factor * std::sqrt(2.f / ER_TAPS);
float pan = (i % 3 == 0) ? -0.707f : ((i % 3 == 1) ? 0.707f : 0.0f);
taps[i].gainL = amp * std::sqrt(0.5f - 0.5f * pan);
taps[i].gainR = amp * std::sqrt(0.5f + 0.5f * pan);
}
}
void EarlyReflections::setPreDelay(float ms, double sampleRate) noexcept {
preDelaySamples = juce::roundToInt(ms * 0.001 * sampleRate);
}
std::pair<float, float> EarlyReflections::tick(float mono) noexcept {
buf.pushSample(0, mono);
float L = 0.f, R = 0.f;
for (const auto& t : taps) {
float d = buf.popSample(0, t.delaySamples + preDelaySamples, false);
L += t.gainL * d;
R += t.gainR * d;
}
L = erHPL.tick(L, erHPCoeffs);
R = erHPR.tick(R, erHPCoeffs);
return { L, R };
}
void EarlyReflections::reset() noexcept {
buf.reset();
erHPL.reset(); erHPR.reset();
erLPL.reset(); erLPR.reset();
}
} // namespace FDNReverb

View file

@ -0,0 +1,32 @@
#pragma once
#include <JuceHeader.h>
#include "DSPConstants.h"
#include "BiquadFilters.h"
namespace FDNReverb {
struct ERTap {
float delaySamples{ 0.f };
float gainL{ 0.f };
float gainR{ 0.f };
};
class EarlyReflections {
public:
void prepare(const juce::dsp::ProcessSpec& spec);
void buildTaps(const AlgorithmPreset& preset, float roomSizeScale, double sampleRate);
void setPreDelay(float ms, double sampleRate) noexcept;
std::pair<float, float> tick(float mono) noexcept;
void reset() noexcept;
private:
juce::dsp::DelayLine<float, juce::dsp::DelayLineInterpolationTypes::Lagrange3rd> buf;
std::array<ERTap, ER_TAPS> taps;
int preDelaySamples{ 0 };
BiquadCoeffs erHPCoeffs, erLPCoeffs;
BiquadState erHPL, erHPR, erLPL, erLPR;
};
} // namespace FDNReverb

View file

@ -0,0 +1,388 @@
#include "MagnitudeResponseFitter.h"
#include <JuceHeader.h>
#include <cmath>
#include <algorithm>
#include <complex>
namespace FDNReverb {
// -----------------------------------------------------------------------------
// static
// -----------------------------------------------------------------------------
std::array<std::array<double, NUM_BANDS>, NUM_BANDS> MagnitudeResponseFitter::cachedB;
std::array<std::array<double, NUM_BANDS>, NUM_BANDS> MagnitudeResponseFitter::cachedBtWB;
std::array<double, NUM_BANDS> MagnitudeResponseFitter::cachedW;
double MagnitudeResponseFitter::cachedSampleRate = 0.0;
bool MagnitudeResponseFitter::cacheValid = false;
// -----------------------------------------------------------------------------
// band Q value ( band : Q ~ sqrt2 / (2^(1/2) - 2^(-1/2)) ~ 1.414)
// -----------------------------------------------------------------------------
static const std::array<float, NUM_BANDS> kBandQs = {
1.7f, // 31.25 Hz (: Q rise )
1.414f, // 62.5 Hz
1.414f, // 125 Hz
1.414f, // 250 Hz
1.414f, // 500 Hz
1.414f, // 1 kHz
1.414f, // 2 kHz
1.414f, // 4 kHz
1.414f, // 8 kHz
1.7f // 16 kHz (: Q rise )
};
const std::array<float, NUM_BANDS>& MagnitudeResponseFitter::getBandQs() noexcept {
return kBandQs;
}
// -----------------------------------------------------------------------------
// Stage 1 ( existing )
// -----------------------------------------------------------------------------
float MagnitudeResponseFitter::t60ToLoopGain(float t60Seconds, int delaySamples, double sampleRate) noexcept {
float t60Safe = std::max(0.01f, t60Seconds);
float exponent = -3.0f * static_cast<float>(delaySamples) / (static_cast<float>(sampleRate) * t60Safe);
return std::pow(10.0f, exponent);
}
float MagnitudeResponseFitter::computeJotPole(float gDC, float alphaRatio) noexcept {
float alphaSafe = juce::jlimit(0.05f, 20.0f, alphaRatio);
float gDCSafe = juce::jlimit(1e-6f, 0.99999f, gDC);
constexpr float kLn10Over4 = 0.5756462732485f;
float log10g = std::log10(gDCSafe);
float alphaSqInv = 1.0f / (alphaSafe * alphaSafe);
float pole = kLn10Over4 * log10g * (1.0f - alphaSqInv);
return juce::jlimit(-0.98f, 0.98f, pole);
}
BiquadCoeffs MagnitudeResponseFitter::orthogonalizedFirstOrderToBiquad(float gain, float pole) noexcept {
BiquadCoeffs c;
c.b0 = gain * (1.0f - pole);
c.b1 = 0.0f;
c.b2 = 0.0f;
c.a1 = -pole;
c.a2 = 0.0f;
return c;
}
float MagnitudeResponseFitter::getT60AtDC(const std::array<float, NUM_BANDS>& rt60) noexcept {
return (rt60[0] + rt60[1]) * 0.5f;
}
float MagnitudeResponseFitter::getT60AtNyquist(const std::array<float, NUM_BANDS>& rt60, double sampleRate) noexcept {
if (sampleRate <= 50000.0) {
return rt60[9];
}
else {
return (rt60[8] + rt60[9]) * 0.5f;
}
}
// -----------------------------------------------------------------------------
// Stage 1 main design function ( existing )
// -----------------------------------------------------------------------------
MagnitudeResponseFitter::DesignResult MagnitudeResponseFitter::design(
int delaySamples,
double sampleRate,
const std::array<float, NUM_BANDS>& rt60,
float hfDamping,
float lfAbsorption)
{
DesignResult result;
float t60DC = std::max(0.01f, getT60AtDC(rt60));
float t60Nyq = std::max(0.01f, getT60AtNyquist(rt60, sampleRate));
float gDC = t60ToLoopGain(t60DC, delaySamples, sampleRate);
float gNyq = t60ToLoopGain(t60Nyq, delaySamples, sampleRate);
float alpha = t60Nyq / t60DC;
float pole = computeJotPole(gDC, alpha);
result.coeffs[0] = orthogonalizedFirstOrderToBiquad(gDC, pole);
float lfShelfDB = -lfAbsorption * 3.0f;
result.coeffs[1] = FilterDesign::lowShelf(150.0f, lfShelfDB, sampleRate);
float hfShelfDB = -hfDamping * 6.0f;
result.coeffs[2] = FilterDesign::highShelf(4000.0f, hfShelfDB, sampleRate);
result.dcGain = gDC;
result.nyquistGain = gNyq;
result.pole = pole;
return result;
}
// -----------------------------------------------------------------------------
// Stage 2 : Biquad peak filter
// -----------------------------------------------------------------------------
BiquadCoeffs MagnitudeResponseFitter::designSymmetricPeakBiquad(
float fcHz, float gainDB, float Q, double sampleRate) noexcept
{
float fcSafe = juce::jlimit(10.0f, static_cast<float>(sampleRate) * 0.49f, fcHz);
float A = std::pow(10.0f, gainDB / 40.0f);
float w0 = 2.0f * juce::MathConstants<float>::pi * fcSafe / static_cast<float>(sampleRate);
float cosW0 = std::cos(w0);
float sinW0 = std::sin(w0);
float alpha = sinW0 / (2.0f * std::max(0.1f, Q));
float a0 = 1.0f + alpha / A;
BiquadCoeffs c;
c.b0 = (1.0f + alpha * A) / a0;
c.b1 = -2.0f * cosW0 / a0;
c.b2 = (1.0f - alpha * A) / a0;
c.a1 = -2.0f * cosW0 / a0;
c.a2 = (1.0f - alpha / A) / a0;
return c;
}
// -----------------------------------------------------------------------------
// Stage 2 : Biquad magnitude response (dB) compute
// -----------------------------------------------------------------------------
float MagnitudeResponseFitter::biquadMagnitudeDB(
const BiquadCoeffs& c, float fEval, double sampleRate) noexcept
{
double w = 2.0 * juce::MathConstants<double>::pi * fEval / sampleRate;
double cosW = std::cos(w);
double sinW = std::sin(w);
double cos2W = std::cos(2.0 * w);
double sin2W = std::sin(2.0 * w);
double bRe = c.b0 + c.b1 * cosW + c.b2 * cos2W;
double bIm = -c.b1 * sinW - c.b2 * sin2W;
double aRe = 1.0 + c.a1 * cosW + c.a2 * cos2W;
double aIm = -c.a1 * sinW - c.a2 * sin2W;
double bMag2 = bRe * bRe + bIm * bIm;
double aMag2 = aRe * aRe + aIm * aIm;
double mag2 = bMag2 / std::max(1e-30, aMag2);
return static_cast<float>(10.0 * std::log10(std::max(1e-30, mag2)));
}
// -----------------------------------------------------------------------------
// Stage 2 : 10x10 LDLT decomposition solver
// -----------------------------------------------------------------------------
void MagnitudeResponseFitter::solveLDLT10(
const std::array<std::array<double, NUM_BANDS>, NUM_BANDS>& A,
const std::array<double, NUM_BANDS>& b,
std::array<double, NUM_BANDS>& x) noexcept
{
constexpr int N = NUM_BANDS;
double L[N][N] = { 0 };
double D[N] = { 0 };
for (int i = 0; i < N; ++i) L[i][i] = 1.0;
for (int j = 0; j < N; ++j) {
double sum = A[j][j];
for (int k = 0; k < j; ++k) {
sum -= L[j][k] * L[j][k] * D[k];
}
D[j] = sum;
if (std::abs(D[j]) < 1e-12) {
D[j] = (D[j] < 0.0 ? -1e-12 : 1e-12);
}
for (int i = j + 1; i < N; ++i) {
double s = A[i][j];
for (int k = 0; k < j; ++k) {
s -= L[i][k] * L[j][k] * D[k];
}
L[i][j] = s / D[j];
}
}
double z[N];
for (int i = 0; i < N; ++i) {
double s = b[i];
for (int k = 0; k < i; ++k) s -= L[i][k] * z[k];
z[i] = s;
}
double y[N];
for (int i = 0; i < N; ++i) y[i] = z[i] / D[i];
for (int i = N - 1; i >= 0; --i) {
double s = y[i];
for (int k = i + 1; k < N; ++k) s -= L[k][i] * x[k];
x[i] = s;
}
}
// -----------------------------------------------------------------------------
// Stage 2 : Biquad coefficient linear gain absorption
// -----------------------------------------------------------------------------
// H(z) = (b0 + b1.z^{-1} + b2.z^{-2}) / (1 + a1.z^{-1} + a2.z^{-2})
//
// frequency amplitude linearGain , (b0, b1, b2) linearGain
// . mathematically independent DC color apply completely .
BiquadCoeffs MagnitudeResponseFitter::absorbGainIntoBiquad(
const BiquadCoeffs& c, float linearGain) noexcept
{
BiquadCoeffs result = c;
result.b0 *= linearGain;
result.b1 *= linearGain;
result.b2 *= linearGain;
return result;
}
// -----------------------------------------------------------------------------
// Stage 2: Interaction Matrix before compute
// -----------------------------------------------------------------------------
void MagnitudeResponseFitter::precomputeInteractionMatrix(double sampleRate) {
if (cacheValid && std::abs(cachedSampleRate - sampleRate) < 0.5) {
return;
}
constexpr int N = NUM_BANDS;
constexpr float kProbeGainDB = 1.0f;
for (int j = 0; j < N; ++j) {
BiquadCoeffs c = designSymmetricPeakBiquad(
BAND_FREQ[j], kProbeGainDB, kBandQs[j], sampleRate);
for (int i = 0; i < N; ++i) {
float dB = biquadMagnitudeDB(c, BAND_FREQ[i], sampleRate);
cachedB[i][j] = static_cast<double>(dB);
}
}
const std::array<double, NUM_BANDS> weights = {
0.5, // 31.25 Hz
0.7, // 62.5 Hz
0.85, // 125 Hz
1.0, // 250 Hz
1.0, // 500 Hz
1.0, // 1 kHz
1.0, // 2 kHz
1.0, // 4 kHz
0.85, // 8 kHz
0.6 // 16 kHz
};
for (int i = 0; i < N; ++i) cachedW[i] = weights[i];
for (int i = 0; i < N; ++i) {
for (int j = 0; j < N; ++j) {
double s = 0.0;
for (int k = 0; k < N; ++k) {
s += cachedB[k][i] * cachedW[k] * cachedB[k][j];
}
cachedBtWB[i][j] = s;
}
}
constexpr double kRidge = 1e-4;
for (int i = 0; i < N; ++i) cachedBtWB[i][i] += kRidge;
cachedSampleRate = sampleRate;
cacheValid = true;
}
// -----------------------------------------------------------------------------
// Stage 2c: main design function ( fix )
// -----------------------------------------------------------------------------
// :
// 1. band target dB compute (T60 dB )
// t[i] = -60 . m / (fs . T60[i])
// 2. LF/HF correction target dB directly
// 3. target dB 0 below clamp -> loop gain <= 1 guarantee
// 4. mid-band gain midGain (band 4 = 500Hz)
// midGain = 10^(midDb/20)
// 5. dB WLS
// g_cmd = (B^T.W.B)^(-1).B^T.W.t_residual
// 6. g_cmd[j] dB Biquad coefficient
// 7. band 0 coefficient midGain absorption
// -> independent DC color apply not needed
MagnitudeResponseFitter::DesignResultStage2 MagnitudeResponseFitter::designStage2(
int delaySamples,
double sampleRate,
const std::array<float, NUM_BANDS>& rt60,
float hfDamping,
float lfAbsorption)
{
precomputeInteractionMatrix(sampleRate);
DesignResultStage2 result;
constexpr int N = NUM_BANDS;
const float fs = static_cast<float>(sampleRate);
const float m = static_cast<float>(delaySamples);
// -- Step 1: band loop 1 gain dB target --
std::array<float, NUM_BANDS> targetDb;
for (int i = 0; i < N; ++i) {
float t60Safe = std::max(0.01f, rt60[i]);
targetDb[i] = -60.0f * m / (fs * t60Safe);
}
// -- Step 2: LF/HF correction target dB --
// LF Absorption: low band (31Hz, 62Hz, 125Hz) added decay
// lfAbsorption=0 -> correction , =1 -> -3dB added decay
targetDb[0] += -lfAbsorption * 3.0f;
targetDb[1] += -lfAbsorption * 2.5f;
targetDb[2] += -lfAbsorption * 1.5f;
// HF Damping: high band (4kHz, 8kHz, 16kHz) added decay
// hfDamping=0 -> correction , =1 -> -6dB added decay
targetDb[7] += -hfDamping * 3.0f;
targetDb[8] += -hfDamping * 5.0f;
targetDb[9] += -hfDamping * 6.0f;
// -- Step 3: target dB 0 below clamp --
// loop gain <= 1 mathematically guarantee safe
for (int i = 0; i < N; ++i) {
targetDb[i] = std::min(targetDb[i], 0.0f);
// decay precision influence below (-60dB/loop)
targetDb[i] = std::max(targetDb[i], -60.0f);
result.targetDb[i] = targetDb[i];
}
// -- Step 4: mid-band gain midGain (band 4 = 500Hz) --
float midDb = targetDb[4];
float midGainLinear = std::pow(10.0f, midDb / 20.0f);
result.midGainAbsorbed = midGainLinear;
// dB: mid-band deviation (GEQ frequency response )
std::array<double, NUM_BANDS> residualDb;
for (int i = 0; i < N; ++i) {
residualDb[i] = static_cast<double>(targetDb[i] - midDb);
}
// -- Step 5: WLS GEQ coefficient --
std::array<double, NUM_BANDS> rhs;
for (int j = 0; j < N; ++j) {
double s = 0.0;
for (int k = 0; k < N; ++k) {
s += cachedB[k][j] * cachedW[k] * residualDb[k];
}
rhs[j] = s;
}
std::array<double, NUM_BANDS> gCmd;
solveLDLT10(cachedBtWB, rhs, gCmd);
// -- Step 6: g_cmd[j] dB Biquad coefficient --
// safe range clamp (+/-18 dB )
for (int j = 0; j < N; ++j) {
float gDb = static_cast<float>(juce::jlimit(-18.0, 18.0, gCmd[j]));
result.commandDb[j] = gDb;
result.geqStages[j] = designSymmetricPeakBiquad(
BAND_FREQ[j], gDb, kBandQs[j], sampleRate);
}
// -- Step 7: band 0 coefficient midGain absorption --
// independent DC color apply not needed ,
// filter cascade entire loop gain exact WLS .
result.geqStages[0] = absorbGainIntoBiquad(result.geqStages[0], midGainLinear);
return result;
}
} // namespace FDNReverb

View file

@ -0,0 +1,130 @@
#pragma once
#include "DSPConstants.h"
#include "BiquadFilters.h"
#include "../AlgorithmPresets.h"
#include <array>
namespace FDNReverb {
// -----------------------------------------------------------------------------
// MagnitudeResponseFitter
// -----------------------------------------------------------------------------
// designs the 10-band RT60 absorption filters for the FDN.
//
// design modes :
// Stage 1 (Jot first-order orthogonalizing):
// Jot-Chaigne (AES Preprint 3030, 1991) first-order orthogonalizing filters.
// matched at DC and Nyquist with 2 design points.
//
// Stage 2c (Valimaki-Liski cumulative GEQ):
// Valimaki & Liski (IEEE SPL 2017) Interaction Matrix + WLS
// exact fit across the 10 bands.
//
// safety guarantee :
// - targets are clamped to 0 dB or below -> loop gain <= 1 is guaranteed
// - band 0 midGain and b0/b1/b2 are absorbed into the applied filter
// - LF/HF corrections are independent GEQ targets in dB
//
// important :
// - per-band decay in dB: -60*m / (fs*T60)
// avoids the "2 kHz T60 assumption" of Schlecht-Habets (DAFx-17)
// - design runs offline (message thread); the resulting Biquad coefficients
// are used on the audio thread
// -----------------------------------------------------------------------------
class MagnitudeResponseFitter {
public:
enum class DesignMode {
Stage1_Jot1stOrder, // Jot first-order orthogonalizing (2 pts: DC/Nyquist)
Stage2_BiquadGEQ // Valimaki-Liski cumulative GEQ (exact at 10 bands)
};
// -------------------------------------------------------------------------
// Stage 1 design result (existing)
// -------------------------------------------------------------------------
// ABSO_STAGES = 3 Biquads:
// coeffs[0] = gain (Jot first-order orthogonalizing filter, Biquad form)
// coeffs[1] = low-band correction (Low Shelf, LF Absorption)
// coeffs[2] = high-band correction (High Shelf, HF Damping)
struct DesignResult {
std::array<BiquadCoeffs, ABSO_STAGES> coeffs;
float dcGain{ 1.0f };
float nyquistGain{ 1.0f };
float pole{ 0.0f };
};
// -------------------------------------------------------------------------
// Stage 2c design result
// -------------------------------------------------------------------------
// 10-band GEQ:
// geqStages[0] = band 0 (31.25 Hz), midGain absorbed into the coefficient
// geqStages[1..9] = bands 1-9 (62.5 Hz - 16 kHz), GEQ
//
// filter chain: geqStages[0] -> geqStages[1] -> ... -> geqStages[9]
// no separate midGain stage is needed (absorbed into band 0).
struct DesignResultStage2 {
std::array<BiquadCoeffs, NUM_BANDS> geqStages; // 10-band GEQ
// visualization
std::array<float, NUM_BANDS> targetDb; // per-band target dB (after clamping)
std::array<float, NUM_BANDS> commandDb; // WLS-solved command dB
float midGainAbsorbed{ 1.0f }; // midGain absorbed into band 0
};
// -------------------------------------------------------------------------
// Stage 1 design function (existing)
// -------------------------------------------------------------------------
static DesignResult design(
int delaySamples,
double sampleRate,
const std::array<float, NUM_BANDS>& rt60,
float hfDamping,
float lfAbsorption);
// -------------------------------------------------------------------------
// Stage 2c design function
// -------------------------------------------------------------------------
static DesignResultStage2 designStage2(
int delaySamples,
double sampleRate,
const std::array<float, NUM_BANDS>& rt60,
float hfDamping,
float lfAbsorption);
// -------------------------------------------------------------------------
// precompute the interaction matrix once (per sample rate)
// -------------------------------------------------------------------------
static void precomputeInteractionMatrix(double sampleRate);
static double getCachedSampleRate() noexcept { return cachedSampleRate; }
private:
// -- Stage 1 --
static float t60ToLoopGain(float t60Seconds, int delaySamples, double sampleRate) noexcept;
static float computeJotPole(float gDC, float alphaRatio) noexcept;
static BiquadCoeffs orthogonalizedFirstOrderToBiquad(float gain, float pole) noexcept;
static float getT60AtDC(const std::array<float, NUM_BANDS>& rt60) noexcept;
static float getT60AtNyquist(const std::array<float, NUM_BANDS>& rt60, double sampleRate) noexcept;
// -- Stage 2 --
static BiquadCoeffs designSymmetricPeakBiquad(
float fcHz, float gainDB, float Q, double sampleRate) noexcept;
static const std::array<float, NUM_BANDS>& getBandFreqs() noexcept { return BAND_FREQ; }
static const std::array<float, NUM_BANDS>& getBandQs() noexcept;
static float biquadMagnitudeDB(const BiquadCoeffs& c, float fEval, double sampleRate) noexcept;
static void solveLDLT10(
const std::array<std::array<double, NUM_BANDS>, NUM_BANDS>& A,
const std::array<double, NUM_BANDS>& b,
std::array<double, NUM_BANDS>& x) noexcept;
// absorb the entire DC gain of the Biquad (b0, b1, b2) into a gain
// so an independent DC gain can be applied to the filter mathematically
static BiquadCoeffs absorbGainIntoBiquad(const BiquadCoeffs& c, float linearGain) noexcept;
// -- Stage 2 static --
static std::array<std::array<double, NUM_BANDS>, NUM_BANDS> cachedB;
static std::array<std::array<double, NUM_BANDS>, NUM_BANDS> cachedBtWB;
static std::array<double, NUM_BANDS> cachedW;
static double cachedSampleRate;
static bool cacheValid;
};
} // namespace FDNReverb

148
Source/DSP/OutputEQ.h Normal file
View file

@ -0,0 +1,148 @@
#pragma once
#include <cmath>
#include <algorithm>
namespace FDNReverb {
// -----------------------------------------------------------------------------
// OutputEQ: Wet output stage Lo/Hi Cut (Linkwitz-Riley 12dB/oct)
// -----------------------------------------------------------------------------
// design rationale:
// - 1 IIR (6dB/oct) x 2 cascade = 12dB/oct
// - Linkwitz-Riley topology: 2nd-order phase alignment
// - keeps the reverb sounding musical
//
// filter equation (1 IIR):
// HPF: y[n] = R . (y[n-1] + x[n] - x[n-1])
// LPF: y[n] = (1 - R) . x[n] + R . y[n-1]
// where R = exp(-2pi.fc/fs)
//
// real-time safety :
// - no allocation at all
// - per-sample cost: HPF 8 ops + LPF 6 ops (L/R combined)
// - coefficients updated per block (no zipper noise, no SmoothedValue needed)
//
// bypass :
// - Lo Cut below 20 Hz -> HPF fully bypassed
// - Hi Cut above 20 kHz -> LPF fully bypassed
// both bypasses are per-block coefficient updates, so CPU use is trivial.
// -----------------------------------------------------------------------------
class OutputEQ {
public:
OutputEQ() = default;
void prepare(double sampleRate) noexcept {
fs = sampleRate;
reset();
setLoCutHz(20.0f);
setHiCutHz(20000.0f);
}
void reset() noexcept {
// HPF state (two stages per channel, L/R)
hpfX1_L_1 = hpfY1_L_1 = 0.0f;
hpfX1_L_2 = hpfY1_L_2 = 0.0f;
hpfX1_R_1 = hpfY1_R_1 = 0.0f;
hpfX1_R_2 = hpfY1_R_2 = 0.0f;
// LPF state (two stages per channel, L/R)
lpfY1_L_1 = 0.0f;
lpfY1_L_2 = 0.0f;
lpfY1_R_1 = 0.0f;
lpfY1_R_2 = 0.0f;
}
// --- parameter setters (called per block) ---
void setLoCutHz(float fcHz) noexcept {
currentLoCutHz = fcHz;
// bypass below 20 Hz (skip R computation)
if (fcHz <= 20.0f) {
loCutActive = false;
return;
}
loCutActive = true;
constexpr float twoPi = 6.28318530718f;
const float clamped = std::clamp(fcHz, 20.0f, 500.0f);
loCutR = std::exp(-twoPi * clamped / static_cast<float>(fs));
}
void setHiCutHz(float fcHz) noexcept {
currentHiCutHz = fcHz;
// bypass above 20 kHz
const float nyquist = static_cast<float>(fs) * 0.45f;
const float clamped = std::clamp(fcHz, 1000.0f, std::min(20000.0f, nyquist));
if (fcHz >= 20000.0f) {
hiCutActive = false;
return;
}
hiCutActive = true;
constexpr float twoPi = 6.28318530718f;
hiCutR = std::exp(-twoPi * clamped / static_cast<float>(fs));
}
// --- per-sample processing (L/R interleaved) ---
inline void process(float& l, float& r) noexcept {
// -- Lo Cut: 1 HPF x 2 cascade --
if (loCutActive) {
// L stage 1
const float l_in = l;
const float l_1 = loCutR * (hpfY1_L_1 + l_in - hpfX1_L_1);
hpfX1_L_1 = l_in;
hpfY1_L_1 = l_1;
// L stage 2
const float l_2 = loCutR * (hpfY1_L_2 + l_1 - hpfX1_L_2);
hpfX1_L_2 = l_1;
hpfY1_L_2 = l_2;
l = l_2;
// R stage 1
const float r_in = r;
const float r_1 = loCutR * (hpfY1_R_1 + r_in - hpfX1_R_1);
hpfX1_R_1 = r_in;
hpfY1_R_1 = r_1;
// R stage 2
const float r_2 = loCutR * (hpfY1_R_2 + r_1 - hpfX1_R_2);
hpfX1_R_2 = r_1;
hpfY1_R_2 = r_2;
r = r_2;
}
// -- Hi Cut: 1 LPF x 2 cascade --
if (hiCutActive) {
const float oneMinusR = 1.0f - hiCutR;
// L stage 1
lpfY1_L_1 = oneMinusR * l + hiCutR * lpfY1_L_1;
// L stage 2
lpfY1_L_2 = oneMinusR * lpfY1_L_1 + hiCutR * lpfY1_L_2;
l = lpfY1_L_2;
// R stage 1
lpfY1_R_1 = oneMinusR * r + hiCutR * lpfY1_R_1;
// R stage 2
lpfY1_R_2 = oneMinusR * lpfY1_R_1 + hiCutR * lpfY1_R_2;
r = lpfY1_R_2;
}
}
float getCurrentLoCutHz() const noexcept { return currentLoCutHz; }
float getCurrentHiCutHz() const noexcept { return currentHiCutHz; }
private:
double fs{ 48000.0 };
// -- Lo Cut (HPF) --
bool loCutActive{ false };
float loCutR{ 0.0f };
float currentLoCutHz{ 20.0f };
float hpfX1_L_1{}, hpfY1_L_1{}, hpfX1_L_2{}, hpfY1_L_2{};
float hpfX1_R_1{}, hpfY1_R_1{}, hpfX1_R_2{}, hpfY1_R_2{};
// -- Hi Cut (LPF) --
bool hiCutActive{ false };
float hiCutR{ 0.0f };
float currentHiCutHz{ 20000.0f };
float lpfY1_L_1{}, lpfY1_L_2{};
float lpfY1_R_1{}, lpfY1_R_2{};
};
} // namespace FDNReverb

View file

@ -0,0 +1,79 @@
#pragma once
#include <cmath>
#include <algorithm>
namespace FDNReverb {
// -----------------------------------------------------------------------------
// OutputLimiter: safe output stage (true-peak limiter)
// -----------------------------------------------------------------------------
// design rationale :
// - parameter values are chosen conservatively for safety
// - Threshold = -0.5 dBFS (~0.944): suppresses peaks before the DAW limiter
// - Look-ahead: introduces plugin latency
// - Attack: 0.5 ms (peak-based)
// - Release: 50 ms (prevents unnatural pumping)
//
// real-time safety :
// - allocation: once in prepare(), never in processBlock
// - per-sample gain: one comparison against targetGain, SIMD-friendly
// - floating-point math: no branches or transcendental functions
//
// - layout: output stage of UniversalEngine::processBlock()
// (after Dry/Wet mix, before the stereo output)
// -----------------------------------------------------------------------------
class OutputLimiter {
public:
OutputLimiter() = default;
// --- sample-rate dependent coefficient computation ---
void prepare(double sampleRate) noexcept {
fs = sampleRate;
// 1 path filter coefficient : y[n] = y[n-1] + coeff * (x[n] - y[n-1])
// coeff = 1 - exp(-T / tau) where T = 1/fs, tau = time constant
attackCoeff = 1.0f - std::exp(-1.0f / (static_cast<float>(fs) * 0.0005f)); // 0.5ms
releaseCoeff = 1.0f - std::exp(-1.0f / (static_cast<float>(fs) * 0.050f)); // 50ms
reset();
}
void reset() noexcept {
currentGain = 1.0f;
}
// --- per-sample processing (called from the audio thread) ---
inline void process(float& l, float& r) noexcept {
// peak detection (max of L/R levels)
const float absL = std::abs(l);
const float absR = std::abs(r);
const float peak = std::max(absL, absR);
// Threshold: -0.5 dBFS ~ 0.944
// compute target gain from the signal
constexpr float threshold = 0.944f;
// target gain :
// peak <= threshold -> 1.0 (no reduction needed)
// peak > threshold -> threshold/peak (pull signal to threshold)
const float targetGain = (peak > threshold) ? (threshold / peak) : 1.0f;
// Attack/Release envelope
// when targetGain < currentGain (gain must decrease): attack
// when targetGain > currentGain (gain recovers): release
// so peaks are suppressed smoothly
const float coeff = (targetGain < currentGain) ? attackCoeff : releaseCoeff;
currentGain += (targetGain - currentGain) * coeff;
// apply the same gain to L/R to preserve the stereo image
l *= currentGain;
r *= currentGain;
}
private:
double fs{ 44100.0 };
float attackCoeff{ 0.0f };
float releaseCoeff{ 0.0f };
float currentGain{ 1.0f };
};
} // namespace FDNReverb

21
Source/DSP/SAPFStage.cpp Normal file
View file

@ -0,0 +1,21 @@
#include "SAPFStage.h"
namespace FDNReverb {
void SAPFStage::prepare(const juce::dsp::ProcessSpec& spec, int delayTargetSamples) {
M = delayTargetSamples;
dl.prepare(spec);
dl.setMaximumDelayInSamples(M + 4);
dl.setDelay(static_cast<float>(M));
}
float SAPFStage::tick(float x) noexcept {
float d = dl.popSample(0);
float w = x + gain * d;
dl.pushSample(0, w);
return d - gain * w;
}
void SAPFStage::reset() noexcept { dl.reset(); }
} // namespace FDNReverb

19
Source/DSP/SAPFStage.h Normal file
View file

@ -0,0 +1,19 @@
#pragma once
#include <JuceHeader.h>
namespace FDNReverb {
class SAPFStage {
public:
void prepare(const juce::dsp::ProcessSpec& spec, int delayTargetSamples);
void setGain(float g) noexcept { gain = juce::jlimit(0.3f, 0.72f, g); }
float tick(float x) noexcept;
void reset() noexcept;
private:
juce::dsp::DelayLine<float, juce::dsp::DelayLineInterpolationTypes::Thiran> dl;
float gain{ 0.618f };
int M{ 0 };
};
} // namespace FDNReverb

197
Source/DSP/Saturator.h Normal file
View file

@ -0,0 +1,197 @@
#pragma once
#include <cmath>
#include <algorithm>
namespace FDNReverb {
enum class SaturationMode {
Warm = 0,
Tape = 1,
Tube = 2,
Hard = 3
};
class Saturator {
public:
Saturator() = default;
void reset() noexcept {
prevInput = 0.0f;
switch (currentMode) {
case SaturationMode::Warm: prevF = 1.0f; break;
case SaturationMode::Tape: prevF = 0.0f; break;
case SaturationMode::Tube: prevF = 1.0f; break;
case SaturationMode::Hard: prevF = 0.0f; break;
}
}
void setMode(SaturationMode mode) noexcept {
if (mode != currentMode) {
currentMode = mode;
reset();
}
}
void setMode(int modeIndex) noexcept {
setMode(static_cast<SaturationMode>(std::clamp(modeIndex, 0, 3)));
}
// -------------------------------------------------------------------------
// * Step B fix: only the drive curve changed; ADAA structure fully preserved
// -------------------------------------------------------------------------
// drive = 1 + amount^3 x 1.0 ( maximum 2.0) -> 1 + amount^2 x 2.5 ( maximum 3.5)
//
// amount | old drive | new drive | effect
// -------|----------|----------|--------------------
// 0.30 | 1.027 | 1.225 | + about 7.5dB stronger
// 0.50 | 1.125 | 1.625 | + about 3.2dB stronger
// 0.70 | 1.343 | 2.225 | + about 4.4dB stronger
// 1.00 | 2.000 | 3.500 | + about 4.9dB stronger
//
// -> plugin 24 harmonics visualization
// -------------------------------------------------------------------------
void setAmount(float amount) noexcept {
amount = std::clamp(amount, 0.0f, 1.0f);
currentAmount = amount;
// * Step B: amount^2 x 2.5 stronger
drive = 1.0f + amount * amount * 2.5f;
wetMix = amount * amount * 0.7f;
dryMix = 1.0f - amount * 0.25f;
}
inline float processSample(float input) noexcept {
if (currentAmount < 1e-4f) return input;
const float dryInput = input;
const float driven = input * drive;
float saturated = 0.0f;
switch (currentMode) {
case SaturationMode::Warm: saturated = processWarm(driven); break;
case SaturationMode::Tape: saturated = processTape(driven); break;
case SaturationMode::Tube: saturated = processTube(driven); break;
case SaturationMode::Hard: saturated = processHard(driven); break;
}
saturated /= drive;
return dryInput * dryMix + saturated * wetMix;
}
private:
// --- Warm: Vicanek x/sqrt(1+x^2) + ADAA 1 ---
inline float processWarm(float x) noexcept {
const float F_x = std::sqrt(1.0f + x * x);
const float dx = x - prevInput;
float y;
constexpr float kTol = 1e-5f;
if (std::abs(dx) < kTol) {
const float xAvg = (x + prevInput) * 0.5f;
y = xAvg / std::sqrt(1.0f + xAvg * xAvg);
}
else {
y = (F_x - prevF) / dx;
}
prevInput = x;
prevF = F_x;
return y;
}
// --- Tape: Pade x(27+x^2)/(27+9x^2) (ADAA intentional ) ---
inline float processTape(float x) noexcept {
if (x > 3.0f) { prevInput = x; return 1.0f; }
if (x < -3.0f) { prevInput = x; return -1.0f; }
const float xsq = x * x;
prevInput = x;
return x * (27.0f + xsq) / (27.0f + 9.0f * xsq);
}
// -------------------------------------------------------------------------
// Tube: asymmetric ADAA + * Step B: kNeg 1.5 -> 2.0
// -------------------------------------------------------------------------
// positive side : f(x) = x/sqrt(1+x^2) F(x) = sqrt(1+x^2)
// negative side : f(x) = x/sqrt(1+(kNeg.x)^2) F(x) = (1/kNeg^2)sqrt(1+(kNeg.x)^2) + fShift
//
// C^1 : x=0 F_pos(0) = F_neg(0) = 1 fShift design
// F_pos(0) = sqrt1 = 1
// F_neg(0) = (1/kNeg^2).sqrt1 + fShift = 1
// -> fShift = 1 - 1/kNeg^2
//
// kNeg=2.0 case : fShift = 1 - 0.25 = 0.75
//
// kNeg stronger effect :
// 2 -> waveform asymmetric
// -> harmonics (2f, 4f) plugin visualization
// -------------------------------------------------------------------------
inline float processTube(float x) noexcept {
// * Step B: kNeg = 1.5f -> 2.0f
constexpr float kNeg = 2.0f;
constexpr float kNeg2 = kNeg * kNeg; // 4.0f
constexpr float invKneg2 = 1.0f / kNeg2; // 0.25f
constexpr float fShift = 1.0f - invKneg2; // 0.75f
float F_x;
if (x >= 0.0f) {
F_x = std::sqrt(1.0f + x * x);
}
else {
const float kx = kNeg * x;
F_x = invKneg2 * std::sqrt(1.0f + kx * kx) + fShift;
}
const float dx = x - prevInput;
const bool signChanged = (x >= 0.0f) != (prevInput >= 0.0f);
float y;
constexpr float kTol = 1e-5f;
if (std::abs(dx) < kTol || signChanged) {
// input -> directly
if (x >= 0.0f) {
y = x / std::sqrt(1.0f + x * x);
}
else {
const float kx = kNeg * x;
y = x / std::sqrt(1.0f + kx * kx);
}
}
else {
y = (F_x - prevF) / dx;
}
prevInput = x;
prevF = F_x;
return y;
}
// --- Hard: clipping + ADAA 1 ---
inline float processHard(float x) noexcept {
float F_x;
if (x > 1.0f) F_x = x - 0.5f;
else if (x < -1.0f) F_x = -x - 0.5f;
else F_x = x * x * 0.5f;
const float dx = x - prevInput;
float y;
constexpr float kTol = 1e-5f;
if (std::abs(dx) < kTol) {
y = std::clamp(x, -1.0f, 1.0f);
}
else {
y = (F_x - prevF) / dx;
}
prevInput = x;
prevF = F_x;
return y;
}
float prevInput{ 0.0f };
float prevF{ 1.0f };
SaturationMode currentMode{ SaturationMode::Warm };
float currentAmount{ 0.0f };
float drive{ 1.0f };
float wetMix{ 0.0f };
float dryMix{ 1.0f };
};
} // namespace FDNReverb

View file

@ -0,0 +1,663 @@
#include "UniversalEngine.h"
namespace FDNReverb {
namespace {
static bool isMathPrime(int n) noexcept {
if (n < 2) return false;
if (n == 2) return true;
if (n % 2 == 0) return false;
for (int i = 3; i * i <= n; i += 2)
if (n % i == 0) return false;
return true;
}
static int findNearestUniquePrime(int target,
const std::array<int, 16>& usedPrimes,
int usedCount) noexcept {
target = std::max(target, 2);
for (int offset = 0; offset < 100000; ++offset) {
int hi = target + offset;
if (isMathPrime(hi)) {
bool used = false;
for (int k = 0; k < usedCount; ++k)
if (usedPrimes[k] == hi) { used = true; break; }
if (!used) return hi;
}
int lo = target - offset;
if (offset > 0 && lo >= 2 && isMathPrime(lo)) {
bool used = false;
for (int k = 0; k < usedCount; ++k)
if (usedPrimes[k] == lo) { used = true; break; }
if (!used) return lo;
}
}
return target;
}
} // anonymous namespace
UniversalEngine::UniversalEngine() {
fbVec.fill(0.0f);
constexpr float phi = 1.6180339887f;
for (int i = 0; i < FDN_ORDER; ++i) {
lfos[i].state = 12345u + static_cast<uint32_t>(i) * 9876u;
lfos[i].smoothed = 0.0f;
const float angle = static_cast<float>(i) * phi;
const float frac = angle - std::floor(angle);
lfos[i].rateMultiplier = 0.80f + frac * 0.40f;
// * LFO: noise LFO offset
const float cAngle = static_cast<float>(i + 5) * phi;
chorusLFOs[i].phase = cAngle - std::floor(cAngle);
const float cRateAngle = static_cast<float>(i + 11) * phi;
chorusLFOs[i].rateScale = 0.30f + (cRateAngle - std::floor(cRateAngle)) * 0.50f;
}
}
void UniversalEngine::prepare(double sampleRate, int /*maxBlockSize*/) {
fs = sampleRate;
#if AMBIVALENCE_USE_STAGE2_ABSORPTION
MagnitudeResponseFitter::precomputeInteractionMatrix(sampleRate);
#endif
auto getPow2 = [](size_t s) -> size_t {
size_t p = 1;
while (p < s) p *= 2;
return p;
};
size_t totalMemoryNeeded =
getPow2(static_cast<size_t>(fs * 0.5)) // * preDelay (max 500ms)
+ getPow2(static_cast<size_t>(fs * 1.0))
+ getPow2(static_cast<size_t>(fs * 0.05)) * 4
+ getPow2(static_cast<size_t>(fs * 0.5)) * FDN_ORDER
+ getPow2(static_cast<size_t>(fs * 0.05)) * FDN_ORDER * SERIAL_APF_STAGES;
memoryPool.allocate(totalMemoryNeeded);
int mask = 0;
float* ptr = nullptr;
// * PreDelay (max 500ms)
ptr = memoryPool.requestMemory(static_cast<size_t>(fs * 0.5), mask);
preDelayLine.init(ptr, mask);
ptr = memoryPool.requestMemory(static_cast<size_t>(fs * 1.0), mask);
erDelay.init(ptr, mask);
for (int i = 0; i < 4; ++i) {
ptr = memoryPool.requestMemory(static_cast<size_t>(fs * 0.05), mask);
inputDiffusers[i].init(ptr, mask);
}
for (int i = 0; i < FDN_ORDER; ++i) {
ptr = memoryPool.requestMemory(static_cast<size_t>(fs * 0.5), mask);
fdnDelays[i].init(ptr, mask);
for (int s = 0; s < SERIAL_APF_STAGES; ++s) {
ptr = memoryPool.requestMemory(static_cast<size_t>(fs * 0.05), mask);
nestedAllpassDelays[i][s].init(ptr, mask);
}
}
acousticMetrics.prepare(sampleRate, 2000.0f);
currentERTapCount = 0;
currentERDelaySamples.fill(0.0f);
currentERGains.fill(0.0f);
outputLimiter.prepare(sampleRate);
outputEQ.prepare(sampleRate);
duckingAttackCoeff = 1.0f - std::exp(-1.0f / (static_cast<float>(fs) * 0.010f));
duckingReleaseCoeff = 1.0f - std::exp(-1.0f / (static_cast<float>(fs) * 0.200f));
duckingEnvelope = 0.0f;
// * DC coefficient : fc ~ 5Hz 1HPF
dcBlockerCoeff = 1.0f - (6.28318530718f * 5.0f / static_cast<float>(fs));
dcX1.fill(0.0f);
dcY1.fill(0.0f);
// * Soft-knee: RMS envelope coefficient (~3ms)
fdnRmsEnv.fill(0.0f);
rmsCoeff = 1.0f - std::exp(-1.0f / (static_cast<float>(fs) * 0.003f));
reset();
}
void UniversalEngine::reset() {
memoryPool.clear();
fbVec.fill(0.0f);
#if AMBIVALENCE_USE_STAGE2_ABSORPTION
for (auto& lineFilters : absorptionFiltersS2)
for (auto& f : lineFilters) f.reset();
#else
for (auto& f : absorptionFilters) f.reset();
#endif
acousticMetrics.reset();
saturatorL.reset();
saturatorR.reset();
outputLimiter.reset();
outputEQ.reset();
duckingEnvelope = 0.0f;
dcX1.fill(0.0f);
dcY1.fill(0.0f);
fdnRmsEnv.fill(0.0f);
for (auto& dl : fdnDelays) dl.resetState(); // * Thiran allpass state
for (auto& lfo : lfos) lfo.smoothed = 0.0f;
}
void UniversalEngine::setParams(const DSPParams& p) {
activeParams = p;
switch (p.algorithmIndex) {
case 0: case 1: currentTopology = ReverbTopology::Room; break;
case 2: case 3: currentTopology = ReverbTopology::Hall; break;
case 4: currentTopology = ReverbTopology::Plate; break;
case 5: currentTopology = ReverbTopology::Spring; break;
case 6: currentTopology = ReverbTopology::Goldfoil; break;
}
const float attMs = juce::jmax(0.1f, p.duckingAttackMs);
const float relMs = juce::jmax(0.1f, p.duckingRelMs);
duckingAttackCoeff = 1.0f - std::exp(-1.0f / (static_cast<float>(fs) * attMs * 0.001f));
duckingReleaseCoeff = 1.0f - std::exp(-1.0f / (static_cast<float>(fs) * relMs * 0.001f));
// * PreDelay: ms -> sample count
preDelaySamples = p.preDelayMs * 0.001f * static_cast<float>(fs);
outputEQ.setLoCutHz(p.loCutHz);
outputEQ.setHiCutHz(p.hiCutHz);
updateTopologyAndRouting();
}
void UniversalEngine::calculatePrimePowerDelays() {
const float fsf = static_cast<float>(fs);
const float sizeCoeff = juce::jlimit(0.5f, 2.0f, activeParams.roomSizeScale + 1.0f);
const float minDelayMs = 15.0f + sizeCoeff * 7.5f;
const float maxDelayMs = 50.0f + sizeCoeff * 75.0f;
const int minDelaySamples = std::max(11, static_cast<int>(minDelayMs * 0.001f * fsf));
const int maxDelaySamples = static_cast<int>(maxDelayMs * 0.001f * fsf);
const float logMin = std::log(static_cast<float>(minDelaySamples));
const float logMax = std::log(static_cast<float>(maxDelaySamples));
std::array<int, FDN_ORDER> usedPrimes;
usedPrimes.fill(0);
for (int i = 0; i < FDN_ORDER; ++i) {
const float t = static_cast<float>(i) / static_cast<float>(FDN_ORDER - 1);
const float logTgt = logMin + t * (logMax - logMin);
const int target = static_cast<int>(std::round(std::exp(logTgt)));
const int prime = findNearestUniquePrime(target, usedPrimes, i);
usedPrimes[i] = prime;
fdnBaseDelaySamples[i] = static_cast<float>(prime);
}
}
void UniversalEngine::updateTopologyAndRouting() {
calculatePrimePowerDelays();
auto& preset = *ALL_PRESETS[activeParams.algorithmIndex];
std::array<float, NUM_BANDS> scaledRT60 = preset.acoustics.rt60;
for (auto& v : scaledRT60) v *= activeParams.decayScale;
// -------------------------------------------------------------------------
// * 2) fix : proMode always Tilt / band apply
// -------------------------------------------------------------------------
// old implementation : if (activeParams.proMode) { ... }
// when ProMode is OFF, the Tilt / band coefficients were not applied,
// so the RT60 graph kept the preset's original curve.
//
// new implementation: always apply; the coefficients default to 1.0f,
// so changing them scales the RT60 graph,
// and reset to 1.0f when loadPresetDefaults() is called.
//
// -------------------------------------------------------------------------
scaledRT60[0] *= activeParams.tiltLow;
scaledRT60[1] *= activeParams.tiltLow;
scaledRT60[2] *= activeParams.tiltLow;
scaledRT60[3] *= activeParams.tiltMid;
scaledRT60[4] *= activeParams.tiltMid;
scaledRT60[5] *= activeParams.tiltMid;
scaledRT60[6] *= activeParams.tiltMid;
scaledRT60[7] *= activeParams.tiltHigh;
scaledRT60[8] *= activeParams.tiltHigh;
scaledRT60[9] *= activeParams.tiltHigh;
for (int b = 0; b < NUM_BANDS; ++b)
scaledRT60[b] *= activeParams.rtBands[b];
#if AMBIVALENCE_USE_STAGE2_ABSORPTION
std::array<float, NUM_BANDS> targetDbAccum;
targetDbAccum.fill(0.0f);
for (int i = 0; i < FDN_ORDER; ++i) {
auto s2 = MagnitudeResponseFitter::designStage2(
static_cast<int>(fdnBaseDelaySamples[i]), fs, scaledRT60,
activeParams.hfDamping, activeParams.lfAbsorption);
for (int b = 0; b < NUM_BANDS; ++b) {
currentAbsorptionCoeffsS2[i][b] = s2.geqStages[b];
targetDbAccum[b] += s2.targetDb[b];
}
}
const float representativeDelay = fdnBaseDelaySamples[FDN_ORDER / 2];
for (int b = 0; b < NUM_BANDS; ++b) {
const float avgTargetDb = targetDbAccum[b] / static_cast<float>(FDN_ORDER);
if (avgTargetDb < -0.001f) {
effectiveRT60[b] = -60.0f * representativeDelay
/ (static_cast<float>(fs) * avgTargetDb);
}
else {
effectiveRT60[b] = scaledRT60[b];
}
effectiveRT60[b] = juce::jlimit(0.05f, 30.0f, effectiveRT60[b]);
}
#else
effectiveRT60 = scaledRT60;
for (int i = 0; i < FDN_ORDER; ++i) {
auto absoStages = FilterDesign::designAbsorption(
static_cast<int>(fdnBaseDelaySamples[i]), fs, scaledRT60,
activeParams.hfDamping, activeParams.lfAbsorption);
currentAbsorptionCoeffs[i] = absoStages[0];
}
#endif
// -------------------------------------------------------------------------
// * EDT fix : band average LF/HF correction
// -------------------------------------------------------------------------
// old implementation : effectiveRT60[4] (500Hz) band use
// -> HF Damping high band below EDT
// -> LF Absorption low band below EDT
//
// new implementation : mid-band band (125Hz~4kHz = band 2~7) average value use
// -> band LF/HF correction influence
// -> (31Hz, 63Hz, 8kHz, 16kHz) ( psychoacoustically EDT
// , value unstable )
// -------------------------------------------------------------------------
float rt60Mid = 0.0f;
for (int b = 2; b <= 7; ++b)
rt60Mid += effectiveRT60[b];
rt60Mid = std::max(0.1f, rt60Mid / 6.0f);
// -------------------------------------------------------------------------
// * metallic sound (1): Decay depends on saturation
// -------------------------------------------------------------------------
// each FDN loop pass runs processMicroSaturation(), and reverberation
// nonlinear distortion accumulates in the reverb and shifts the filter
// response, producing metallic ringing.
//
// policy: not applied below a 2.0 s mid-band RT60 average, scaled between 2.0 s and 6.0 s,
// and fully bypassed above 6.0 s.
// -------------------------------------------------------------------------
microSatBlend = juce::jlimit(0.0f, 1.0f, 1.0f - (rt60Mid - 2.0f) / 4.0f);
// -------------------------------------------------------------------------
// * metallic sound (2): Decay depends on modulation
// -------------------------------------------------------------------------
// longer reverb tails require deeper modulation at the filter peaks.
// as used by Lexicon / Strymon.
//
// * modulation depth (scaled down for short reverbs)
// RT60 <= 1.0 s -> 1.0x (min)
// RT60 = 3.0s -> 2.0x
// RT60 >= 5.0 s -> 3.0x (max)
// -------------------------------------------------------------------------
modDepthScale = 1.0f + juce::jlimit(0.0f, 2.0f, (rt60Mid - 1.0f) * 0.5f);
constexpr float baseDB = 16.0f;
float decayCompDB = 7.0f * std::log10(rt60Mid);
static constexpr std::array<float, 7> algorithmOffsetDB = {
+0.8f, +0.9f, +0.5f, +0.5f, +1.5f, +0.6f, +0.6f
};
float algoOffset = algorithmOffsetDB[juce::jlimit(0, 6, activeParams.algorithmIndex)];
switch (currentTopology) {
case ReverbTopology::Room:
bypassER = false; bypassInputDiffusers = false;
apfGain = 0.3f; diffusionSensitivity = 1.0f;
break;
case ReverbTopology::Hall:
bypassER = false; bypassInputDiffusers = false;
apfGain = 0.618f; diffusionSensitivity = 1.0f;
break;
case ReverbTopology::Plate:
bypassER = true; bypassInputDiffusers = false;
apfGain = 0.7f; diffusionSensitivity = 0.7f;
break;
case ReverbTopology::Spring:
bypassER = true; bypassInputDiffusers = false;
apfGain = 0.5f; diffusionSensitivity = 0.5f;
break;
case ReverbTopology::Goldfoil:
bypassER = true; bypassInputDiffusers = false;
apfGain = 0.75f; diffusionSensitivity = 0.8f;
break;
}
const auto& erPattern = PRESET_ER_PATTERNS[
juce::jlimit(0, 6, activeParams.algorithmIndex)];
currentERTapCount = erPattern.numTaps;
float erSizeScale = 0.5f + activeParams.roomSizeScale;
for (int i = 0; i < erPattern.numTaps; ++i) {
currentERDelaySamples[i] = erPattern.taps[i].delayMs * 0.001f
* static_cast<float>(fs) * erSizeScale;
currentERGains[i] = erPattern.taps[i].gain;
}
if (erPattern.numTaps == 0) bypassER = true;
float edtCoeff = 0.7f;
switch (currentTopology) {
case ReverbTopology::Room: edtCoeff = 0.70f; break;
case ReverbTopology::Hall: edtCoeff = 0.95f; break;
case ReverbTopology::Plate: edtCoeff = 0.60f; break;
case ReverbTopology::Spring: edtCoeff = 0.50f; break;
case ReverbTopology::Goldfoil: edtCoeff = 0.85f; break;
}
theoreticalEDT = rt60Mid * edtCoeff;
float satMultiplier = 1.0f;
switch (currentTopology) {
case ReverbTopology::Room: satMultiplier = 0.90f; break;
case ReverbTopology::Hall: satMultiplier = 0.93f; break;
case ReverbTopology::Plate: satMultiplier = 1.00f; break;
case ReverbTopology::Spring: satMultiplier = 1.05f; break;
case ReverbTopology::Goldfoil: satMultiplier = 1.02f; break;
}
float effectiveSatAmount = juce::jlimit(0.0f, 1.0f,
activeParams.saturation * satMultiplier);
saturatorL.setAmount(effectiveSatAmount);
saturatorR.setAmount(effectiveSatAmount);
saturatorL.setMode(activeParams.satTypeIdx);
saturatorR.setMode(activeParams.satTypeIdx);
lateMakeupGainLinear = juce::Decibels::decibelsToGain(baseDB + decayCompDB + algoOffset);
}
inline void UniversalEngine::fastWalshHadamardTransform(
std::array<float, 16>& v) noexcept
{
for (int h = 1; h < 16; h *= 2) {
for (int i = 0; i < 16; i += h * 2) {
for (int j = i; j < i + h; ++j) {
float x = v[j], y = v[j + h];
v[j] = x + y;
v[j + h] = x - y;
}
}
}
for (int i = 0; i < 16; ++i) v[i] *= 0.25f;
}
inline void UniversalEngine::applySignFlipping(
std::array<float, 16>& v) noexcept
{
static constexpr std::array<float, 16> flip = {
1.f, -1.f, 1.f, -1.f, -1.f, 1.f, -1.f, 1.f,
1.f, 1.f, -1.f, -1.f, -1.f, -1.f, 1.f, 1.f
};
for (int i = 0; i < 16; ++i) v[i] *= flip[i];
}
void UniversalEngine::processBlock(const float* inL, const float* inR,
float* outL, float* outR,
int numSamples) noexcept
{
// * CPU: fs float (processBlock throughout use )
const float fsf = static_cast<float>(fs);
// * modulation : squared curve + coefficient suppress
// modAmount^2 low band gradually , 0.001f entire
// : modAmt=0.5 -> 48smp(1ms) / : modAmt=0.5 -> 12smp(0.25ms)
const float modAmtCurved = activeParams.modAmount * activeParams.modAmount;
const float depthSamples = modAmtCurved * 0.001f * fsf * modDepthScale;
const float wetGain = juce::Decibels::decibelsToGain(activeParams.wetDB);
const float stereoWidth = activeParams.stereoWidth;
const float erLevel = activeParams.erLevel;
const float lateLevel = activeParams.lateLevel;
const bool erSolo = activeParams.erSolo;
const float duckThreshLin = juce::Decibels::decibelsToGain(activeParams.duckingThreshDB);
const float duckAmountDB = activeParams.duckingAmount;
const float effectiveDiffusion = activeParams.diffusion * diffusionSensitivity;
const float diffuserGain = 0.25f + effectiveDiffusion * 0.55f;
const float effectiveApfGain = apfGain * (0.60f + effectiveDiffusion * 0.40f);
const float sideBoost = stereoWidth * 1.5f;
const float erLeakage = (1.0f - stereoWidth) * 0.7f;
// * CPU: apfGainStage loop -> before compute
const float apfGainStage = effectiveApfGain * 0.78f;
// * CPU: freqModScale before compute (16ch)
std::array<float, FDN_ORDER> freqModScales;
constexpr float invFdnM1 = 1.0f / static_cast<float>(FDN_ORDER - 1);
for (int i = 0; i < FDN_ORDER; ++i)
freqModScales[i] = 0.5f + (1.0f - static_cast<float>(i) * invFdnM1) * 1.0f;
// * CPU: input diffuser time before compute
std::array<float, 4> diffuserDelaySmp;
for (int i = 0; i < 4; ++i)
diffuserDelaySmp[i] = (3.0f + i * 2.0f) * 0.001f * fsf;
// * CPU: Allpass before compute (16ch x 3)
constexpr float apfBaseMs[SERIAL_APF_STAGES] = { 1.5f, 2.3f, 3.7f };
constexpr float apfSpreadMs[SERIAL_APF_STAGES] = { 0.30f, 0.37f, 0.47f };
constexpr float apfModFrac[SERIAL_APF_STAGES] = { 0.15f, 0.10f, 0.07f };
const float msToSmp = 0.001f * fsf;
std::array<std::array<float, SERIAL_APF_STAGES>, FDN_ORDER> apfBaseDelaySmp;
for (int i = 0; i < FDN_ORDER; ++i)
for (int s = 0; s < SERIAL_APF_STAGES; ++s)
apfBaseDelaySmp[i][s] = (apfBaseMs[s] + i * apfSpreadMs[s]) * msToSmp;
// * CPU: ER tapGain * 0.5f before compute
std::array<float, MAX_ER_TAPS> erTapGainsHalf;
for (int t = 0; t < currentERTapCount; ++t)
erTapGainsHalf[t] = currentERGains[t] * 0.5f;
// * CPU: soft-knee threshold squared before compute (sqrt avoid )
constexpr float compThresh = 0.35f;
constexpr float compThreshSq = compThresh * compThresh;
std::array<float, FDN_ORDER> lfoCoeffs;
{
constexpr float twoPi = 6.28318530718f;
for (int i = 0; i < FDN_ORDER; ++i) {
const float fc = activeParams.modRate * lfos[i].rateMultiplier;
lfoCoeffs[i] = juce::jlimit(0.0001f, 0.9999f,
1.0f - std::exp(-twoPi * fc / fsf));
// * LFO update
chorusLFOs[i].phaseInc = activeParams.modRate * chorusLFOs[i].rateScale / fsf;
}
}
for (int n = 0; n < numSamples; ++n) {
const float leftIn = inL[n];
const float rightIn = inR[n];
const float midIn = (leftIn + rightIn) * 0.5f;
const float sideIn = (leftIn - rightIn) * 0.5f;
float erOutL = 0.0f, erOutR = 0.0f;
// * PreDelay: dry time
// ERFDN input .
// dry attack after ,
// clarity (D50/C50) significantly above .
preDelayLine.write(midIn);
const float delayedMid = (preDelaySamples > 0.5f)
? preDelayLine.read(preDelaySamples)
: midIn;
const float inputPeak = juce::jmax(std::abs(leftIn), std::abs(rightIn));
const float envCoeff = (inputPeak > duckingEnvelope)
? duckingAttackCoeff : duckingReleaseCoeff;
duckingEnvelope += (inputPeak - duckingEnvelope) * envCoeff;
float duckGainLinear = 1.0f;
if (duckAmountDB > 0.001f && duckingEnvelope > duckThreshLin) {
const float envDB = 20.0f * std::log10(juce::jmax(duckingEnvelope, 1e-6f));
const float overDB = envDB - activeParams.duckingThreshDB;
const float gainRedDB = -juce::jmin(overDB, duckAmountDB);
duckGainLinear = juce::Decibels::decibelsToGain(gainRedDB);
}
float fdnInputMid = delayedMid;
if (!bypassInputDiffusers) {
for (int i = 0; i < 4; ++i) {
float d = inputDiffusers[i].read(diffuserDelaySmp[i]);
float w = fdnInputMid + diffuserGain * d;
inputDiffusers[i].write(w);
fdnInputMid = d - diffuserGain * w;
}
}
if (!bypassER) {
erDelay.write(delayedMid);
float erTotalL = 0.0f, erTotalR = 0.0f;
for (int t = 0; t < currentERTapCount; ++t) {
const float tapValue = erDelay.read(currentERDelaySamples[t]);
const float tapGain = erTapGainsHalf[t];
const float tg = tapValue * tapGain;
const float tgLeak = tg * erLeakage;
if (t % 2 == 0) {
erTotalL += tg;
erTotalR += tgLeak;
}
else {
erTotalR += tg;
erTotalL += tgLeak;
}
}
erOutL = erTotalL;
erOutR = erTotalR;
}
// * ER -> Late: feed the ER output into the FDN input
// the early reflections are wall-surface reflections that seed the Late Reverb,
// making the ER-to-Late transition natural and smooth.
if (!bypassER) {
fdnInputMid += (erOutL + erOutR) * 0.5f * 0.15f;
}
std::array<float, 16> currentFb = fbVec;
fastWalshHadamardTransform(currentFb);
applySignFlipping(currentFb);
float fdnOutL = 0.0f, fdnOutR = 0.0f;
std::array<float, 16> nextFb;
for (int i = 0; i < FDN_ORDER; ++i) {
const float lfoVal = lfos[i].tick(lfoCoeffs[i]);
// * modulation: sine-wave LFO + noise LFO
// noise = random (suppresses metallic ringing)
// chorus = smoothly accumulated (rich tail)
const float chorusVal = chorusLFOs[i].tick();
const float combinedLfo = lfoVal + chorusVal * 0.6f;
// * frequency-dependent modulation: high bands modulate less than low bands
const float freqModScale = freqModScales[i];
const float delaySmp = fdnBaseDelaySamples[i]
+ combinedLfo * depthSamples * freqModScale;
float d = fdnDelays[i].read(delaySmp);
#if AMBIVALENCE_USE_STAGE2_ABSORPTION
for (int s = 0; s < ABSO_STAGES_S2; ++s)
d = absorptionFiltersS2[i][s].tick(d, currentAbsorptionCoeffsS2[i][s]);
#else
d = absorptionFilters[i].tick(d, currentAbsorptionCoeffs[i]);
#endif
// * metallic sound (3): DC blocker (1st-order HPF, fc ~ 5 Hz)
// saturation in the FDN loop absorption filters can
// accumulate DC; blocking it prevents low-band asymmetric distortion.
{
const float dcIn = d;
const float dcOut = dcIn - dcX1[i] + dcBlockerCoeff * dcY1[i];
dcX1[i] = dcIn;
dcY1[i] = dcOut;
d = dcOut;
}
// * soft-knee compression (in the FDN feedback loop)
// an RMS envelope over the threshold triggers compression.
// * CPU: sqrt only runs above threshold (compare on squared values)
{
fdnRmsEnv[i] += (d * d - fdnRmsEnv[i]) * rmsCoeff;
if (fdnRmsEnv[i] > compThreshSq) {
const float env = std::sqrt(fdnRmsEnv[i]);
const float over = env - compThresh;
d *= compThresh / (compThresh + over * 0.65f);
}
}
// * metallic sound (1): Decay depends on saturation
// microSatBlend=1.0 -> applied (into the reverb loop)
// microSatBlend=0.0 -> fully bypassed
if (microSatBlend > 0.001f) {
const float sat = processMicroSaturation(d);
d = d + (sat - d) * microSatBlend;
}
// * 3 nested allpass filters (echo density)
// * CPU: apfGainStage precomputed per block
float apfOut = d;
{
for (int s = 0; s < SERIAL_APF_STAGES; ++s) {
const float apfModDepth = depthSamples * apfModFrac[s];
const float apfDelaySmp = apfBaseDelaySmp[i][s]
+ combinedLfo * apfModDepth * freqModScale;
float apfD = nestedAllpassDelays[i][s].read(apfDelaySmp);
float apfW = apfOut + apfGainStage * apfD;
nestedAllpassDelays[i][s].write(apfW);
apfOut = apfD - apfGainStage * apfW;
}
}
nextFb[i] = apfOut;
const float sideForCh = (i % 2 == 0 ? +sideIn : -sideIn) * sideBoost;
const float fdnInputForThisCh = (fdnInputMid + sideForCh) * 0.25f;
fdnDelays[i].write(fdnInputForThisCh + currentFb[i]);
const float crossLeak = 1.0f - stereoWidth;
if (i % 2 == 0) {
fdnOutL += apfOut;
fdnOutR += apfOut * crossLeak;
}
else {
fdnOutR += apfOut;
fdnOutL += apfOut * crossLeak;
}
}
fdnOutL *= 0.125f;
fdnOutR *= 0.125f;
fbVec = nextFb;
const float erMixL = bypassER ? 0.0f : erOutL * erLevel;
const float erMixR = bypassER ? 0.0f : erOutR * erLevel;
const float lateMixL = fdnOutL * lateMakeupGainLinear * lateLevel;
const float lateMixR = fdnOutR * lateMakeupGainLinear * lateLevel;
acousticMetrics.processSample((lateMixL + lateMixR) * 0.5f);
float satL = saturatorL.processSample(lateMixL);
float satR = saturatorR.processSample(lateMixR);
if (erSolo) { satL = 0.0f; satR = 0.0f; }
float wetL = erMixL + satL;
float wetR = erMixR + satR;
outputEQ.process(wetL, wetR);
const float finalWetGain = wetGain * duckGainLinear;
outL[n] = wetL * finalWetGain;
outR[n] = wetR * finalWetGain;
outputLimiter.process(outL[n], outR[n]);
}
}
} // namespace FDNReverb

View file

@ -0,0 +1,174 @@
#pragma once
#include "DelayMemory.h"
#include "BiquadFilters.h"
#include "MagnitudeResponseFitter.h"
#include "AcousticMetrics.h"
#include "Saturator.h"
#include "OutputLimiter.h"
#include "OutputEQ.h"
#include "../PluginParameters.h"
#include <array>
#include <cmath>
#define AMBIVALENCE_USE_STAGE2_ABSORPTION 1
namespace FDNReverb {
enum class ReverbTopology { Room, Hall, Plate, Spring, Goldfoil };
// -----------------------------------------------------------------------------
// BandlimitedNoiseLFO: color noise + 1 IIR LPF
// -----------------------------------------------------------------------------
struct BandlimitedNoiseLFO {
uint32_t state{ 12345u };
float smoothed{ 0.0f };
float rateMultiplier{ 1.0f };
inline float nextNoise() noexcept {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
return static_cast<float>(state) * 2.3283064365386963e-10f * 2.0f - 1.0f;
}
inline float tick(float lpfCoeff) noexcept {
smoothed += (nextNoise() - smoothed) * lpfCoeff;
return smoothed;
}
};
// -----------------------------------------------------------------------------
// ChorusLFO: sine-wave phase (modulation)
// -----------------------------------------------------------------------------
struct ChorusLFO {
float phase{ 0.0f };
float phaseInc{ 0.0f };
float rateScale{ 1.0f }; // per-channel rate coefficient (multiplier)
// * CPU: std::sin() replaced by a parabolic approximation (max error ~0.06%, 5-10x faster)
inline float tick() noexcept {
phase += phaseInc;
if (phase >= 1.0f) phase -= 1.0f;
// Parabolic sine: phase [0,1) -> sin(2pi.phase)
const float x = phase < 0.5f ? phase : phase - 1.0f;
const float para = 16.0f * x * (0.5f - std::abs(x));
return para * (0.775f + 0.225f * std::abs(para));
}
};
class UniversalEngine {
public:
UniversalEngine();
void prepare(double sampleRate, int maxBlockSize);
void reset();
void setParams(const DSPParams& p);
void processBlock(const float* inL, const float* inR,
float* outL, float* outR, int numSamples) noexcept;
std::array<float, NUM_BANDS> getEffectiveRT60() const noexcept { return effectiveRT60; }
float getD50() const noexcept { return acousticMetrics.getD50(); }
float getC50() const noexcept { return acousticMetrics.getC50(); }
float getC80() const noexcept { return acousticMetrics.getC80(); }
float getEDT() const noexcept { return theoreticalEDT; }
const AcousticMetrics& getAcousticMetrics() const noexcept { return acousticMetrics; }
int getERTapCount() const noexcept { return currentERTapCount; }
float getERTapDelaySamples(int index) const noexcept {
return (index >= 0 && index < currentERTapCount) ? currentERDelaySamples[index] : 0.0f;
}
float getERTapGain(int index) const noexcept {
return (index >= 0 && index < currentERTapCount) ? currentERGains[index] : 0.0f;
}
double getSampleRate() const noexcept { return fs; }
bool isERBypassed() const noexcept { return bypassER; }
private:
void updateTopologyAndRouting();
void calculatePrimePowerDelays();
inline void fastWalshHadamardTransform(std::array<float, 16>& v) noexcept;
inline void applySignFlipping(std::array<float, 16>& v) noexcept;
// --- FDN loop saturation ---
inline static float processMicroSaturation(float x) noexcept {
constexpr float kInScale = 0.15f;
constexpr float kOutScale = 1.0f / kInScale;
const float xs = x * kInScale;
if (xs > 3.0f) return kOutScale;
if (xs < -3.0f) return -kOutScale;
const float xsq = xs * xs;
return (xs * (27.0f + xsq) / (27.0f + 9.0f * xsq)) * kOutScale;
}
DelayMemoryPool memoryPool;
double fs{ 48000.0 };
DSPParams activeParams;
ReverbTopology currentTopology{ ReverbTopology::Room };
static constexpr int FDN_ORDER = 16;
static constexpr int SERIAL_APF_STAGES = 3; // * Allpass stages
// * PreDelay (max 500 ms)
LinearDelayLine preDelayLine;
float preDelaySamples{ 0.0f };
LinearDelayLine erDelay;
std::array<float, 16> erTaps;
std::array<LinearDelayLine, 4> inputDiffusers;
std::array<ThiranDelayLine, FDN_ORDER> fdnDelays; // * Thiran allpass interpolation
std::array<std::array<LinearDelayLine, SERIAL_APF_STAGES>, FDN_ORDER> nestedAllpassDelays;
int currentERTapCount{ 0 };
std::array<float, MAX_ER_TAPS> currentERDelaySamples;
std::array<float, MAX_ER_TAPS> currentERGains;
OutputLimiter outputLimiter;
OutputEQ outputEQ; // * Phase 5 added
float duckingEnvelope{ 0.0f };
float duckingAttackCoeff{ 0.0f };
float duckingReleaseCoeff{ 0.0f };
#if AMBIVALENCE_USE_STAGE2_ABSORPTION
std::array<std::array<BiquadState, ABSO_STAGES_S2>, FDN_ORDER> absorptionFiltersS2;
std::array<std::array<BiquadCoeffs, ABSO_STAGES_S2>, FDN_ORDER> currentAbsorptionCoeffsS2;
#else
std::array<BiquadState, FDN_ORDER> absorptionFilters;
std::array<BiquadCoeffs, FDN_ORDER> currentAbsorptionCoeffs;
#endif
std::array<BandlimitedNoiseLFO, FDN_ORDER> lfos;
std::array<ChorusLFO, FDN_ORDER> chorusLFOs; // * modulation
std::array<float, FDN_ORDER> fdnBaseDelaySamples;
std::array<float, FDN_ORDER> fbVec;
float apfGain{ 0.618f };
bool bypassER{ false };
bool bypassInputDiffusers{ false }; // * new: default false
float lateMixScale{ 1.0f };
float lateMakeupGainLinear{ 1.0f };
// * Phase 5 addition: Diffusion
float diffusionSensitivity{ 1.0f };
// * metallic sound: DecayTime depends on parameters
float microSatBlend{ 1.0f }; // FDN loop saturation blend (0 = bypass, 1 = full)
float modDepthScale{ 1.0f }; // modulation depth scale (increases with Decay time)
// * DC: prevent DC accumulation in the FDN loop
std::array<float, FDN_ORDER> dcX1;
std::array<float, FDN_ORDER> dcY1;
float dcBlockerCoeff{ 0.999f };
// * soft-knee compression: in the FDN feedback loop
std::array<float, FDN_ORDER> fdnRmsEnv;
float rmsCoeff{ 0.002f };
std::array<float, NUM_BANDS> effectiveRT60;
float theoreticalEDT{ 0.0f };
AcousticMetrics acousticMetrics;
Saturator saturatorL;
Saturator saturatorR;
};
} // namespace FDNReverb

View file

@ -0,0 +1,354 @@
#include "AmbivalenceUI.h"
#include "../PluginProcessor.h"
// --- AmbivalenceLookAndFeel ---------------------------------------------
AmbivalenceLookAndFeel::AmbivalenceLookAndFeel()
{
setColour(juce::Slider::backgroundColourId, AmbivalenceColors::ArcTrack);
setColour(juce::Slider::thumbColourId, AmbivalenceColors::Accent);
setColour(juce::Slider::trackColourId, AmbivalenceColors::ArcFill);
setColour(juce::Label::textColourId, AmbivalenceColors::TextSecondary);
setColour(juce::ComboBox::backgroundColourId, AmbivalenceColors::Surface);
setColour(juce::ComboBox::textColourId, AmbivalenceColors::TextPrimary);
setColour(juce::ComboBox::outlineColourId, AmbivalenceColors::Border);
mainFont = juce::Font(juce::FontOptions("Helvetica Neue", 11.f, juce::Font::plain));
}
void AmbivalenceLookAndFeel::drawRotarySlider(juce::Graphics& g,
int x, int y, int w, int h,
float sliderPos, float startAngle, float endAngle, juce::Slider&)
{
auto b = juce::Rectangle<float>((float)x, (float)y, (float)w, (float)h).reduced(4.f);
float cx = b.getCentreX(), cy = b.getCentreY();
float r = juce::jmin(b.getWidth(), b.getHeight()) * 0.45f;
float th = r * 0.22f;
juce::Path track;
track.addCentredArc(cx, cy, r, r, 0.f, startAngle, endAngle, true);
g.setColour(AmbivalenceColors::ArcTrack);
g.strokePath(track, juce::PathStrokeType(th,
juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
float angle = startAngle + sliderPos * (endAngle - startAngle);
juce::Path fill;
fill.addCentredArc(cx, cy, r, r, 0.f, startAngle, angle, true);
juce::ColourGradient grad(AmbivalenceColors::AccentBlue, cx - r, cy,
AmbivalenceColors::Accent, cx + r, cy, false);
g.setGradientFill(grad);
g.strokePath(fill, juce::PathStrokeType(th,
juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
g.setColour(AmbivalenceColors::Panel);
g.fillEllipse(cx - r * 0.28f, cy - r * 0.28f, r * 0.56f, r * 0.56f);
float ix = cx + r * 0.6f * std::sin(angle);
float iy = cy - r * 0.6f * std::cos(angle);
g.setColour(AmbivalenceColors::TextPrimary);
g.drawLine(cx, cy, ix, iy, 2.f);
}
void AmbivalenceLookAndFeel::drawLinearSlider(juce::Graphics& g,
int x, int y, int w, int h,
float sliderPos, float, float, juce::Slider::SliderStyle, juce::Slider&)
{
auto b = juce::Rectangle<int>(x, y, w, h).toFloat();
float ty = b.getCentreY() - 2.f;
g.setColour(AmbivalenceColors::ArcTrack);
g.fillRoundedRectangle(b.getX(), ty, b.getWidth(), 4.f, 2.f);
g.setColour(AmbivalenceColors::Accent);
g.fillRoundedRectangle(b.getX(), ty, sliderPos - b.getX(), 4.f, 2.f);
float r = 7.f;
g.setColour(AmbivalenceColors::TextPrimary);
g.fillEllipse(sliderPos - r, b.getCentreY() - r, r * 2.f, r * 2.f);
}
void AmbivalenceLookAndFeel::drawComboBox(juce::Graphics& g,
int w, int h, bool isDown, int, int, int, int, juce::ComboBox&)
{
auto b = juce::Rectangle<int>(0, 0, w, h).toFloat();
g.setColour(isDown ? AmbivalenceColors::Panel : AmbivalenceColors::Surface);
g.fillRoundedRectangle(b, 3.f);
g.setColour(AmbivalenceColors::Border);
g.drawRoundedRectangle(b.reduced(0.5f), 3.f, 1.f);
juce::Path arrow;
arrow.addTriangle(w - 16.f, h * 0.5f - 3.f,
w - 8.f, h * 0.5f - 3.f,
w - 12.f, h * 0.5f + 3.f);
g.setColour(AmbivalenceColors::TextSecondary);
g.fillPath(arrow);
}
void AmbivalenceLookAndFeel::positionComboBoxText(juce::ComboBox& box, juce::Label& label) {
label.setBounds(6, 1, box.getWidth() - 22, box.getHeight() - 2);
label.setFont(getComboBoxFont(box));
}
juce::Font AmbivalenceLookAndFeel::getLabelFont(juce::Label&) { return mainFont.withHeight(10.f); }
juce::Font AmbivalenceLookAndFeel::getComboBoxFont(juce::ComboBox&) { return mainFont.withHeight(11.f); }
void AmbivalenceLookAndFeel::drawGroupComponentOutline(juce::Graphics& g,
int w, int h, const juce::String& text,
const juce::Justification&, juce::GroupComponent&)
{
float textH = 12.f, indent = 8.f, yOff = textH * 0.5f;
juce::Path p;
p.startNewSubPath(indent + 4.f, yOff); p.lineTo(indent, yOff);
p.lineTo(indent, (float)h - 1.f);
p.lineTo((float)w - indent, (float)h - 1.f);
p.lineTo((float)w - indent, yOff);
// * redraw after changes
juce::GlyphArrangement ga;
ga.addLineOfText(mainFont.withHeight(textH), text, 0.f, 0.f);
float tw = ga.getBoundingBox(0, -1, true).getWidth() + 6.f;
p.lineTo(indent + 14.f + tw, yOff);
g.setColour(AmbivalenceColors::Border);
g.strokePath(p, juce::PathStrokeType(1.f));
g.setColour(AmbivalenceColors::TextSecondary);
g.setFont(mainFont.withHeight(textH).boldened());
g.drawText(text, (int)(indent + 14.f), 0, (int)tw, (int)textH,
juce::Justification::centredLeft);
}
// --- RT60Visualizer --------------------------------------------------
RT60Visualizer::RT60Visualizer() {
displayRT60.fill(1.0f);
startTimerHz(30);
}
RT60Visualizer::~RT60Visualizer() { stopTimer(); }
void RT60Visualizer::timerCallback() {
if (!processor) return;
auto live = processor->getRT60ForDisplay();
for (int i = 0; i < FDNReverb::NUM_BANDS; ++i)
displayRT60[i] += 0.25f * (live[i] - displayRT60[i]);
// * dynamic Y axis above : current maximum RT60 value x 1.3 smoothly
float maxVal = *std::max_element(displayRT60.begin(), displayRT60.end());
float targetMax = std::max(MAX_RT60_DISPLAY_FLOOR, maxVal * 1.3f);
// exponential ( rise quickly , fall gentle -> frequently )
float smoothFactor = (targetMax > dynamicMaxRT60) ? 0.15f : 0.03f;
dynamicMaxRT60 += smoothFactor * (targetMax - dynamicMaxRT60);
repaint();
}
void RT60Visualizer::paint(juce::Graphics& g)
{
auto b = getLocalBounds().toFloat().reduced(2.f);
float W = b.getWidth(), H = b.getHeight();
float x0 = b.getX(), y0 = b.getY();
g.setColour(AmbivalenceColors::Surface);
g.fillRoundedRectangle(b, 4.f);
g.setColour(AmbivalenceColors::Border);
g.drawRoundedRectangle(b.reduced(0.5f), 4.f, 1.f);
// * dynamic Y axis
float logMin = std::log10(MIN_RT60_DISPLAY);
float logMax = std::log10(dynamicMaxRT60);
// grid value dynamic Y axis
// fixed value dynamicMaxRT60 below drawing
static constexpr float kAllGridVals[] = {
0.1f, 0.3f, 0.5f, 1.0f, 2.0f, 4.0f,
8.0f, 12.0f, 16.0f, 20.0f
};
// grid
g.setColour(AmbivalenceColors::Separator);
for (float v : kAllGridVals) {
if (v > dynamicMaxRT60 * 1.05f) break;
float ny = 1.f - (std::log10(v) - logMin) / (logMax - logMin);
g.drawHorizontalLine((int)(y0 + ny * H), x0 + 36.f, x0 + W - 4.f);
}
// frequency label (X axis )
g.setFont(8.5f);
g.setColour(AmbivalenceColors::TextSecondary);
static const char* fLbls[] = {
"31","63","125","250","500","1k","2k","4k","8k","16k"
};
for (int i = 0; i < FDNReverb::NUM_BANDS; ++i) {
float px = x0 + 36.f + (float)i / (FDNReverb::NUM_BANDS - 1) * (W - 40.f);
g.drawText(fLbls[i], (int)(px - 12.f), (int)(y0 + H - 14.f),
24, 13, juce::Justification::centred);
}
// seconds label (Y axis ) - dynamic
for (float v : kAllGridVals) {
if (v > dynamicMaxRT60 * 1.05f) break;
float ny = 1.f - (std::log10(v) - logMin) / (logMax - logMin);
float py = y0 + ny * H;
juce::String lbl = (v < 1.f)
? juce::String(v, 1) + "s"
: (v < 10.f ? juce::String(v, 1) : juce::String((int)v)) + "s";
g.drawText(lbl, (int)(x0 + 2.f), (int)(py - 7.f), 32, 14,
juce::Justification::centredLeft);
}
auto plotCurve = [&](const std::array<float, FDNReverb::NUM_BANDS>& rt60,
juce::Colour col, float thick)
{
juce::Path path;
bool first = true;
for (int i = 0; i < FDNReverb::NUM_BANDS; ++i) {
float v = std::clamp(rt60[i], MIN_RT60_DISPLAY, dynamicMaxRT60);
float ny = 1.f - (std::log10(v) - logMin) / (logMax - logMin);
float px = x0 + 36.f + (float)i / (FDNReverb::NUM_BANDS - 1) * (W - 40.f);
float py = y0 + ny * H;
if (first) { path.startNewSubPath(px, py); first = false; }
else path.lineTo(px, py);
}
g.setColour(col);
g.strokePath(path, juce::PathStrokeType(thick,
juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
for (int i = 0; i < FDNReverb::NUM_BANDS; ++i) {
float v = std::clamp(rt60[i], MIN_RT60_DISPLAY, dynamicMaxRT60);
float ny = 1.f - (std::log10(v) - logMin) / (logMax - logMin);
float px = x0 + 36.f + (float)i / (FDNReverb::NUM_BANDS - 1) * (W - 40.f);
float py = y0 + ny * H;
g.fillEllipse(px - 3.f, py - 3.f, 6.f, 6.f);
}
};
// source preset curve (from the selected algorithm)
if (processor) {
int algo = (int)*processor->apvts.getRawParameterValue("algorithm");
auto& preset = *FDNReverb::ALL_PRESETS[
juce::jlimit(0, FDNReverb::NUM_ALGORITHMS - 1, algo)];
plotCurve(preset.acoustics.rt60,
AmbivalenceColors::TextSecondary.withAlpha(0.5f), 1.f);
}
// current RT60 curve (measured)
plotCurve(displayRT60, AmbivalenceColors::Accent, 2.f);
// top right title
g.setColour(AmbivalenceColors::TextSecondary);
g.setFont(9.f);
g.drawText("RT60 (s) per band",
(int)x0 + 36, (int)y0 + 3, (int)W - 40, 12,
juce::Justification::right);
}
// --- VUMeter ---------------------------------------------------------
VUMeter::VUMeter(const juce::String& lbl, Side s) : label(lbl), side(s) {}
void VUMeter::paint(juce::Graphics& g)
{
auto b = getLocalBounds().toFloat().reduced(1.f);
g.setColour(AmbivalenceColors::Surface);
g.fillRoundedRectangle(b, 3.f);
float bx = b.getX() + 22.f, bw = b.getWidth() - 22.f;
auto bar = [&](float y, float level) {
float n = juce::jlimit(0.f, 1.f, juce::jmap(
juce::Decibels::gainToDecibels(level + 1e-9f), -60.f, 0.f, 0.f, 1.f));
g.setColour(AmbivalenceColors::ArcTrack);
g.fillRoundedRectangle(bx, y, bw, 7.f, 2.f);
juce::ColourGradient gr(AmbivalenceColors::AccentBlue, bx, y,
AmbivalenceColors::Accent, bx + bw, y, false);
g.setGradientFill(gr);
g.fillRoundedRectangle(bx, y, bw * n, 7.f, 2.f);
};
bar(b.getY() + 2.f, levelL);
bar(b.getY() + 11.f, levelR);
g.setColour(AmbivalenceColors::TextSecondary);
g.setFont(8.f);
g.drawText(label, (int)b.getX(), (int)b.getY(), 20, (int)b.getHeight(),
juce::Justification::centredLeft);
}
// --- ArcKnob ---------------------------------------------------------
void ArcKnob::build(juce::AudioProcessorValueTreeState& apvts,
const juce::String& paramID,
const juce::String& labelText,
juce::Component* parent,
AmbivalenceLookAndFeel& laf)
{
slider.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag);
slider.setTextBoxStyle(juce::Slider::TextBoxBelow, false, 62, 14);
slider.setLookAndFeel(&laf);
slider.setColour(juce::Slider::textBoxTextColourId,
AmbivalenceColors::TextSecondary);
slider.setColour(juce::Slider::textBoxOutlineColourId,
juce::Colours::transparentBlack);
parent->addAndMakeVisible(slider);
label.setText(labelText, juce::dontSendNotification);
label.setJustificationType(juce::Justification::centred);
label.setFont(juce::Font(juce::FontOptions(9.f)));
label.setColour(juce::Label::textColourId, AmbivalenceColors::TextSecondary);
parent->addAndMakeVisible(label);
// * after changes
attachment.reset(
new juce::AudioProcessorValueTreeState::SliderAttachment(
apvts, paramID, slider));
}
// --- AlgorithmSelector -----------------------------------------------
AlgorithmSelector::AlgorithmSelector(juce::AudioProcessorValueTreeState& a)
: apvts(a)
{
static const char* names[] = {
"ROOM1","ROOM2","HALL1","HALL2","PLATE","SPRING","GOLDFOIL"
};
for (int i = 0; i < FDNReverb::NUM_ALGORITHMS; ++i) {
buttons[i].setButtonText(names[i]);
addAndMakeVisible(buttons[i]);
int idx = i;
buttons[i].onClick = [this, idx] {
if (auto* param = apvts.getParameter("algorithm"))
param->setValueNotifyingHost(
(float)idx / (float)(FDNReverb::NUM_ALGORITHMS - 1));
};
}
apvts.addParameterListener("algorithm", this);
currentAlgo = juce::roundToInt(
*apvts.getRawParameterValue("algorithm")
* (FDNReverb::NUM_ALGORITHMS - 1));
}
AlgorithmSelector::~AlgorithmSelector() {
apvts.removeParameterListener("algorithm", this);
}
void AlgorithmSelector::parameterChanged(const juce::String&, float newVal) {
int newAlgo = juce::jlimit(0, FDNReverb::NUM_ALGORITHMS - 1,
juce::roundToInt(newVal));
juce::MessageManager::callAsync([this, newAlgo] {
currentAlgo = newAlgo;
updateButtonColors();
});
}
void AlgorithmSelector::updateButtonColors() {
for (int i = 0; i < FDNReverb::NUM_ALGORITHMS; ++i) {
bool on = (i == currentAlgo);
buttons[i].setColour(juce::TextButton::buttonColourId,
on ? AmbivalenceColors::Accent : AmbivalenceColors::Surface);
buttons[i].setColour(juce::TextButton::textColourOffId,
on ? AmbivalenceColors::Background : AmbivalenceColors::TextSecondary);
buttons[i].repaint();
}
}
void AlgorithmSelector::paint(juce::Graphics& g) {
g.setColour(AmbivalenceColors::Surface);
g.fillRoundedRectangle(getLocalBounds().toFloat(), 4.f);
}
void AlgorithmSelector::resized() {
auto area = getLocalBounds().reduced(2);
int btnW = area.getWidth() / FDNReverb::NUM_ALGORITHMS;
for (int i = 0; i < FDNReverb::NUM_ALGORITHMS; ++i)
buttons[i].setBounds(area.getX() + i * btnW, area.getY(),
btnW - 1, area.getHeight());
updateButtonColors();
}

110
Source/GUI/AmbivalenceUI.h Normal file
View file

@ -0,0 +1,110 @@
#pragma once
#include <JuceHeader.h>
#include "../AlgorithmPresets.h"
class FDNReverbAudioProcessor;
// --- Ambivalence Design System ------------------------------------------
namespace AmbivalenceColors {
const juce::Colour Background{ 0xFF1A1A1A };
const juce::Colour Surface{ 0xFF242424 };
const juce::Colour Panel{ 0xFF2C2C2C };
const juce::Colour Border{ 0xFF3C3C3C };
const juce::Colour Accent{ 0xFFFF6B00 };
const juce::Colour AccentBlue{ 0xFF4090FF };
const juce::Colour TextPrimary{ 0xFFE8E8E8 };
const juce::Colour TextSecondary{ 0xFF888888 };
const juce::Colour ArcTrack{ 0xFF3A3A3A };
const juce::Colour ArcFill{ 0xFFFF6B00 };
const juce::Colour Separator{ 0xFF383838 };
}
// --- Ambivalence LookAndFeel --------------------------------------------
class AmbivalenceLookAndFeel : public juce::LookAndFeel_V4
{
public:
AmbivalenceLookAndFeel();
void drawRotarySlider(juce::Graphics&, int x, int y, int w, int h,
float sliderPos, float startAngle, float endAngle,
juce::Slider&) override;
void drawLinearSlider(juce::Graphics&, int x, int y, int w, int h,
float sliderPos, float, float,
juce::Slider::SliderStyle, juce::Slider&) override;
void drawComboBox(juce::Graphics&, int w, int h, bool isDown,
int, int, int, int, juce::ComboBox&) override;
void positionComboBoxText(juce::ComboBox&, juce::Label&) override;
juce::Font getLabelFont(juce::Label&) override;
juce::Font getComboBoxFont(juce::ComboBox&) override;
void drawGroupComponentOutline(juce::Graphics&, int w, int h,
const juce::String&, const juce::Justification&,
juce::GroupComponent&) override;
private:
juce::Font mainFont;
};
// --- RT60 Visualizer -------------------------------------------------
class RT60Visualizer : public juce::Component, private juce::Timer
{
public:
RT60Visualizer();
~RT60Visualizer() override;
void setProcessor(FDNReverbAudioProcessor* p) { processor = p; }
void paint(juce::Graphics&) override;
private:
void timerCallback() override;
FDNReverbAudioProcessor* processor{ nullptr };
std::array<float, FDNReverb::NUM_BANDS> displayRT60;
static constexpr float MIN_RT60_DISPLAY = 0.05f;
static constexpr float MAX_RT60_DISPLAY_FLOOR = 4.0f; // Y axis above value
// * dynamic Y axis above : effectiveRT60 maximum value smoothly
float dynamicMaxRT60{ MAX_RT60_DISPLAY_FLOOR };
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(RT60Visualizer)
};
// --- VU Meter --------------------------------------------------------
class VUMeter : public juce::Component
{
public:
enum class Side { Input, Output };
VUMeter(const juce::String& label, Side side);
void paint(juce::Graphics&) override;
void setLevels(float l, float r) noexcept { levelL = l; levelR = r; }
private:
juce::String label;
Side side;
float levelL{ 0.f }, levelR{ 0.f };
};
// --- Labelled Arc Knob -----------------------------------------------
struct ArcKnob {
juce::Slider slider;
juce::Label label;
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> attachment;
void build(juce::AudioProcessorValueTreeState& apvts,
const juce::String& paramID,
const juce::String& labelText,
juce::Component* parent,
AmbivalenceLookAndFeel& laf);
};
// --- Algorithm Selector ----------------------------------------------
class AlgorithmSelector : public juce::Component,
private juce::AudioProcessorValueTreeState::Listener
{
public:
AlgorithmSelector(juce::AudioProcessorValueTreeState& apvts);
~AlgorithmSelector() override;
void paint(juce::Graphics&) override;
void resized() override;
private:
void parameterChanged(const juce::String&, float) override;
void updateButtonColors();
std::array<juce::TextButton, FDNReverb::NUM_ALGORITHMS> buttons;
juce::AudioProcessorValueTreeState& apvts;
int currentAlgo{ 0 };
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AlgorithmSelector)
};

View file

@ -0,0 +1,324 @@
#include "DecayCurveViz.h"
DecayCurveViz::DecayCurveViz() {
cachedERDelayMs.fill(0.0f);
cachedERGains.fill(0.0f);
startTimerHz(15);
}
DecayCurveViz::~DecayCurveViz() {
stopTimer();
}
void DecayCurveViz::timerCallback() {
if (processor == nullptr) return;
const auto& engine = processor->getEngine();
auto rt60 = engine.getEffectiveRT60();
cachedRT60Mid = std::max(0.1f, rt60[4]);
cachedERBypassed = engine.isERBypassed();
cachedERTapCount = engine.getERTapCount();
if (cachedERTapCount > MAX_DISPLAY_TAPS)
cachedERTapCount = MAX_DISPLAY_TAPS;
double sr = engine.getSampleRate();
if (sr < 1.0) sr = 48000.0;
for (int i = 0; i < cachedERTapCount; ++i) {
float delaySamples = engine.getERTapDelaySamples(i);
cachedERDelayMs[i] = delaySamples / static_cast<float>(sr) * 1000.0f;
cachedERGains[i] = engine.getERTapGain(i);
}
repaint();
}
void DecayCurveViz::resized() {}
void DecayCurveViz::paint(juce::Graphics& g)
{
auto bounds = getLocalBounds().toFloat();
if (bounds.getWidth() < 10.0f || bounds.getHeight() < 10.0f) return;
g.fillAll(AmbivalenceColors::Background);
const float topMargin = 10.0f;
const float bottomMargin = 18.0f;
const float leftMargin = 30.0f;
const float rightMargin = 8.0f;
const float plotX = bounds.getX() + leftMargin;
const float plotY = bounds.getY() + topMargin;
const float plotW = bounds.getWidth() - leftMargin - rightMargin;
const float plotH = bounds.getHeight() - topMargin - bottomMargin;
const float maxTimeSec = juce::jlimit(0.5f, 8.0f, cachedRT60Mid * 1.5f);
const float minDB = -60.0f;
const float maxDB = 0.0f;
// -------------------------------------------------------------------------
// time axis
// 0~splitSec -> full width splitRatio expanded (ER zone )
// splitSec~max -> width (Late zone )
// -------------------------------------------------------------------------
constexpr float splitSec = 0.20f; // 200ms expanded
constexpr float splitRatio = 0.30f; // full width 30% ER zone
auto timeToX = [&](float timeSec) -> float {
if (timeSec <= splitSec) {
const float ratio = timeSec / splitSec;
return plotX + ratio * plotW * splitRatio;
}
else {
const float lateRange = maxTimeSec - splitSec;
if (lateRange <= 0.0f) return plotX + plotW;
const float ratio = (timeSec - splitSec) / lateRange;
return plotX + plotW * splitRatio + ratio * plotW * (1.0f - splitRatio);
}
};
auto dbToY = [&](float db) -> float {
const float normalized = (db - minDB) / (maxDB - minDB);
return plotY + (1.0f - normalized) * plotH;
};
// --- ER zone background ---
{
const float erZoneW = plotW * splitRatio;
g.setColour(juce::Colour(0xFF1A2535));
g.fillRect(plotX, plotY, erZoneW, plotH);
}
// --- grid : horizontal (dB) ---
g.setColour(AmbivalenceColors::Separator.withAlpha(0.3f));
for (float db = 0.0f; db >= -60.0f; db -= 20.0f)
g.drawHorizontalLine((int)dbToY(db), plotX, plotX + plotW);
// --- grid : ER zone vertical (ms) ---
{
static const float erGridMs[] = { 20.0f, 50.0f, 100.0f, 150.0f, 200.0f };
g.setColour(AmbivalenceColors::Separator.withAlpha(0.5f));
for (float ms : erGridMs) {
const float t = ms * 0.001f;
if (t >= maxTimeSec) break;
g.drawVerticalLine((int)timeToX(t), plotY, plotY + plotH);
}
}
// --- grid : Late zone vertical (s) ---
float timeStep;
if (maxTimeSec <= 2.0f) timeStep = 0.5f;
else if (maxTimeSec <= 4.0f) timeStep = 1.0f;
else timeStep = 2.0f;
g.setColour(AmbivalenceColors::Separator.withAlpha(0.3f));
for (float t = splitSec + timeStep; t <= maxTimeSec; t += timeStep)
g.drawVerticalLine((int)timeToX(t), plotY, plotY + plotH);
// --- ---
{
const float splitX = timeToX(splitSec);
g.setColour(AmbivalenceColors::Separator.withAlpha(0.9f));
g.drawVerticalLine((int)splitX, plotY, plotY + plotH);
}
// --- axis label ---
g.setFont(juce::Font(juce::FontOptions(8.5f)));
g.setColour(AmbivalenceColors::TextSecondary.withAlpha(0.6f));
for (float db = 0.0f; db >= -60.0f; db -= 20.0f) {
const float y = dbToY(db);
g.drawText(juce::String((int)db) + "dB",
(int)(plotX - leftMargin + 2), (int)(y - 6),
(int)(leftMargin - 4), 12,
juce::Justification::centredRight);
}
// ER zone time label (ms)
{
static const float erGridMs[] = { 20.0f, 50.0f, 100.0f, 150.0f, 200.0f };
for (float ms : erGridMs) {
const float t = ms * 0.001f;
if (t >= maxTimeSec) break;
const float x = timeToX(t);
g.drawText(juce::String((int)ms) + "ms",
(int)(x - 20), (int)(plotY + plotH + 2),
40, 14, juce::Justification::centred);
}
}
// Late zone time label (s)
for (float t = splitSec + timeStep; t <= maxTimeSec; t += timeStep) {
const float x = timeToX(t);
g.drawText(juce::String(t, 1) + "s",
(int)(x - 20), (int)(plotY + plotH + 2),
40, 14, juce::Justification::centred);
}
// -------------------------------------------------------------------------
// Late Reverb decay curve ( 2D gradient )
// -------------------------------------------------------------------------
{
const int numPoints = 80;
juce::Colour orangeColor = AmbivalenceColors::Accent;
juce::Path latePath;
latePath.startNewSubPath(plotX, dbToY(maxDB));
for (int i = 0; i <= numPoints; ++i) {
const float t = (i / static_cast<float>(numPoints)) * maxTimeSec;
const float db = std::max(minDB, -60.0f * t / cachedRT60Mid);
latePath.lineTo(timeToX(t), dbToY(db));
}
latePath.lineTo(timeToX(maxTimeSec), dbToY(minDB));
latePath.lineTo(plotX, dbToY(minDB));
latePath.closeSubPath();
juce::ColourGradient lateGrad(
orangeColor.withAlpha(0.55f), plotX, plotY,
orangeColor.withAlpha(0.0f), plotX + plotW, plotY + plotH,
false);
g.setGradientFill(lateGrad);
g.fillPath(latePath);
juce::Path lateOutline;
lateOutline.startNewSubPath(plotX, dbToY(maxDB));
for (int i = 0; i <= numPoints; ++i) {
const float t = (i / static_cast<float>(numPoints)) * maxTimeSec;
const float db = std::max(minDB, -60.0f * t / cachedRT60Mid);
lateOutline.lineTo(timeToX(t), dbToY(db));
}
g.setColour(orangeColor.withAlpha(0.75f));
g.strokePath(lateOutline, juce::PathStrokeType(1.5f,
juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
}
// -------------------------------------------------------------------------
// ER drawing (* )
// : vertical line 2px + marker
// : wide 5px + marker + envelope fill
// -------------------------------------------------------------------------
if (!cachedERBypassed && cachedERTapCount > 0) {
const juce::Colour blueColor = juce::Colour::fromRGB(80, 160, 230);
// -- ER envelope fill region --
if (cachedERTapCount >= 2) {
juce::Path erFill;
bool started = false;
float lastX = plotX;
for (int t = 0; t < cachedERTapCount; ++t) {
const float timeSec = cachedERDelayMs[t] * 0.001f;
if (timeSec > maxTimeSec) continue;
float gainDB = (cachedERGains[t] > 1e-6f)
? juce::Decibels::gainToDecibels(cachedERGains[t]) : minDB;
gainDB = juce::jlimit(minDB, maxDB, gainDB);
const float x = timeToX(timeSec);
const float y = dbToY(gainDB);
if (!started) {
erFill.startNewSubPath(plotX, dbToY(minDB));
erFill.lineTo(x, y);
started = true;
}
else {
erFill.lineTo(x, y);
}
lastX = x;
}
if (started) {
erFill.lineTo(lastX, dbToY(minDB));
erFill.closeSubPath();
juce::ColourGradient erAreaGrad(
blueColor.withAlpha(0.20f), plotX, plotY,
blueColor.withAlpha(0.03f), plotX + plotW * splitRatio, plotY + plotH,
false);
g.setGradientFill(erAreaGrad);
g.fillPath(erFill);
}
}
// -- : wide + marker --
for (int t = 0; t < cachedERTapCount; ++t) {
const float timeSec = cachedERDelayMs[t] * 0.001f;
if (timeSec > maxTimeSec) continue;
float gainDB = (cachedERGains[t] > 1e-6f)
? juce::Decibels::gainToDecibels(cachedERGains[t]) : minDB;
gainDB = juce::jlimit(minDB, maxDB, gainDB);
const float x = timeToX(timeSec);
const float yTop = dbToY(gainDB);
const float yBottom = dbToY(minDB);
const float barW = 5.0f;
juce::ColourGradient tapGrad(
blueColor.withAlpha(0.90f), x, yTop,
blueColor.withAlpha(0.10f), x, yBottom,
false);
g.setGradientFill(tapGrad);
g.fillRect(x - barW * 0.5f, yTop, barW, yBottom - yTop);
// diamond marker
g.setColour(blueColor);
juce::Path diamond;
diamond.startNewSubPath(x, yTop - 5.0f);
diamond.lineTo(x + 4.0f, yTop);
diamond.lineTo(x, yTop + 3.0f);
diamond.lineTo(x - 4.0f, yTop);
diamond.closeSubPath();
g.fillPath(diamond);
}
// -- ER envelope outline --
if (cachedERTapCount >= 2) {
juce::Path erOutline;
bool started = false;
for (int t = 0; t < cachedERTapCount; ++t) {
const float timeSec = cachedERDelayMs[t] * 0.001f;
if (timeSec > maxTimeSec) continue;
float gainDB = (cachedERGains[t] > 1e-6f)
? juce::Decibels::gainToDecibels(cachedERGains[t]) : minDB;
gainDB = juce::jlimit(minDB, maxDB, gainDB);
const float x = timeToX(timeSec);
const float y = dbToY(gainDB);
if (!started) { erOutline.startNewSubPath(x, y); started = true; }
else erOutline.lineTo(x, y);
}
g.setColour(blueColor.withAlpha(0.65f));
g.strokePath(erOutline, juce::PathStrokeType(1.5f,
juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
}
}
// --- zone label ---
g.setFont(juce::Font(juce::FontOptions(
"Helvetica Neue", 8.0f, juce::Font::bold)));
g.setColour(juce::Colour::fromRGB(80, 160, 230).withAlpha(0.9f));
g.drawText("ER",
(int)(plotX + 4), (int)(plotY + 2),
30, 12, juce::Justification::centredLeft);
{
const float splitX = timeToX(splitSec);
g.setColour(AmbivalenceColors::Accent.withAlpha(0.9f));
g.drawText("LATE",
(int)(splitX + 6), (int)(plotY + 2),
40, 12, juce::Justification::centredLeft);
}
g.setFont(juce::Font(juce::FontOptions(7.5f)));
g.setColour(AmbivalenceColors::TextSecondary.withAlpha(0.4f));
g.drawText("0-200ms (x2)",
(int)(plotX + 2), (int)(plotY + plotH - 14),
(int)(plotW * splitRatio - 4), 12,
juce::Justification::centredLeft);
}

View file

@ -0,0 +1,42 @@
#pragma once
#include <JuceHeader.h>
#include "../PluginProcessor.h"
#include "AmbivalenceUI.h"
class DecayCurveViz : public juce::Component, private juce::Timer {
public:
DecayCurveViz();
~DecayCurveViz() override;
void setProcessor(FDNReverbAudioProcessor* p) noexcept { processor = p; }
void paint(juce::Graphics& g) override;
void resized() override;
private:
void timerCallback() override;
// --- time axis -----------------------------------------------
// time axis :
// 0~splitSec : plotW x splitRatio width expanded
// splitSec~max: width
// ER(0~200ms) 2
float timeToX(float timeSec, float plotX, float plotW,
float maxTimeSec) const noexcept;
FDNReverbAudioProcessor* processor{ nullptr };
float cachedRT60Mid{ 1.0f };
int cachedERTapCount{ 0 };
bool cachedERBypassed{ false };
static constexpr int MAX_DISPLAY_TAPS = 12;
std::array<float, MAX_DISPLAY_TAPS> cachedERDelayMs;
std::array<float, MAX_DISPLAY_TAPS> cachedERGains;
// --- time axis setting ---
// splitSec below time splitRatio width expanded
static constexpr float splitSec = 0.20f; // 0~200ms expanded
static constexpr float splitRatio = 0.30f; // full width 30% ER zone
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(DecayCurveViz)
};

690
Source/PluginEditor.cpp Normal file
View file

@ -0,0 +1,690 @@
#include "PluginProcessor.h"
#include "PluginEditor.h"
#include "BuildInfo.h"
static constexpr int Y_HEADER = 8;
static constexpr int Y_ALGO = 48;
static constexpr int Y_SLABEL1 = 86;
static constexpr int Y_ROW1 = 104;
static constexpr int Y_SLABEL2 = 204;
static constexpr int Y_ROW2 = 222;
static constexpr int Y_SEP = 322;
static constexpr int Y_VIZ = 326;
static constexpr int SEC_TIME = 8;
static constexpr int SEC_FREQUENCY = 254;
static constexpr int SEC_DIFFUSION = 418;
static constexpr int SEC_STEREO = 664;
static constexpr int SEC_CHARACTER = 746;
static constexpr int SEP_TF = 245;
static constexpr int SEP_FD = 409;
static constexpr int SEP_DS = 655;
static constexpr int SEP_SC = 737;
// -----------------------------------------------------------------------------
// constructor
// -----------------------------------------------------------------------------
FDNReverbEditor::FDNReverbEditor(FDNReverbAudioProcessor& p)
: AudioProcessorEditor(&p),
audioProcessor(p),
algoSelector(p.apvts),
vuIn("IN", VUMeter::Side::Input),
vuOut("OUT", VUMeter::Side::Output)
{
setLookAndFeel(&laf);
setSize(W, H);
// -- Title --
titleLabel.setText("AMBIVALENCE 1.1", juce::dontSendNotification);
titleLabel.setFont(juce::Font(juce::FontOptions(
"Helvetica Neue", 14.f, juce::Font::bold)));
titleLabel.setColour(juce::Label::textColourId, AmbivalenceColors::TextPrimary);
addAndMakeVisible(titleLabel);
addAndMakeVisible(algoSelector);
auto BK = [&](ArcKnob& k, const char* id, const char* lbl) {
k.build(p.apvts, id, lbl, this, laf);
};
BK(kPreDelay, "predelay", "PRE-DELAY");
BK(kRoomSize, "roomsize", "ROOM SIZE");
BK(kDecay, "decaytime", "DECAY");
BK(kHFDamp, "hfdamping", "HF DAMP");
BK(kLFAbsorb, "lfabsorption", "LF ABSORB");
BK(kDiffusion, "diffusion", "DIFFUSION");
BK(kModAmt, "modamount", "MOD AMT");
BK(kModRate, "modrate", "MOD RATE");
BK(kStereoW, "stereowidth", "WIDTH");
BK(kERLevel, "erlevel", "ER LEVEL");
BK(kSaturation, "saturation", "SATURATE");
BK(kWet, "wetlevel", "WET");
BK(kDry, "drylevel", "DRY");
BK(kDuckAmt, "duckamount", "AMOUNT");
BK(kDuckThr, "duckthresh", "THRESH");
BK(kDuckAtt, "duckattack", "ATTACK");
BK(kDuckRel, "duckrelease", "RELEASE");
BK(kLoCutNorm, "locut", "LO CUT");
BK(kHiCutNorm, "hicut", "HI CUT");
// -- ProMode button --
proModeButton.setButtonText("PRO");
proModeButton.setClickingTogglesState(true);
proModeButton.setColour(juce::TextButton::buttonOnColourId, AmbivalenceColors::Accent);
proModeButton.setColour(juce::TextButton::buttonColourId, AmbivalenceColors::Surface);
proModeButton.setColour(juce::TextButton::textColourOnId, AmbivalenceColors::Background);
proModeButton.setColour(juce::TextButton::textColourOffId, AmbivalenceColors::TextSecondary);
addAndMakeVisible(proModeButton);
proModeAttachment.reset(
new juce::AudioProcessorValueTreeState::ButtonAttachment(
p.apvts, "promode", proModeButton));
// -- ER SOLO button --
erSoloButton.setButtonText("ER SOLO");
erSoloButton.setClickingTogglesState(true);
erSoloButton.setColour(juce::TextButton::buttonOnColourId, AmbivalenceColors::AccentBlue);
erSoloButton.setColour(juce::TextButton::buttonColourId, AmbivalenceColors::Surface);
erSoloButton.setColour(juce::TextButton::textColourOnId, AmbivalenceColors::Background);
erSoloButton.setColour(juce::TextButton::textColourOffId, AmbivalenceColors::TextSecondary);
addAndMakeVisible(erSoloButton);
erSoloAttachment.reset(
new juce::AudioProcessorValueTreeState::ButtonAttachment(
p.apvts, "ersolo", erSoloButton));
// -- ProMode: RT60 band --
static const char* rtBandIDs[] = {
"rtband0","rtband1","rtband2","rtband3","rtband4",
"rtband5","rtband6","rtband7","rtband8","rtband9"
};
static const char* rtBandLbls[] = {
"31Hz","63Hz","125Hz","250Hz","500Hz",
"1kHz","2kHz","4kHz","8kHz","16kHz"
};
for (int i = 0; i < 10; ++i)
kRTBands[i].build(p.apvts, rtBandIDs[i], rtBandLbls[i], this, laf);
// -- ProMode: SatType --
satTypeLabel.setText("SAT TYPE", juce::dontSendNotification);
satTypeLabel.setFont(juce::Font(juce::FontOptions(9.f)));
satTypeLabel.setColour(juce::Label::textColourId, AmbivalenceColors::TextSecondary);
satTypeLabel.setJustificationType(juce::Justification::centred);
addAndMakeVisible(satTypeLabel);
satTypeCombo.addItemList({ "Warm","Tape","Tube","Hard" }, 1);
satTypeCombo.setLookAndFeel(&laf);
addAndMakeVisible(satTypeCombo);
satTypeAttachment.reset(
new juce::AudioProcessorValueTreeState::ComboBoxAttachment(
p.apvts, "sattype", satTypeCombo));
// -- ProMode: Tilt EQ + Output EQ --
BK(kTiltLow, "tiltlow", "TILT LOW");
BK(kTiltMid, "tiltmid", "TILT MID");
BK(kTiltHigh, "tilthigh", "TILT HIGH");
BK(kLoCutPro, "locut", "LO CUT");
BK(kHiCutPro, "hicut", "HI CUT");
// -------------------------------------------------------------------------
// preset UI
// -------------------------------------------------------------------------
presetManager = std::make_unique<PresetManager>(p);
// < PREV
presetPrevButton.setButtonText("<");
presetPrevButton.setColour(juce::TextButton::buttonColourId, AmbivalenceColors::Surface);
presetPrevButton.setColour(juce::TextButton::textColourOffId, AmbivalenceColors::TextPrimary);
addAndMakeVisible(presetPrevButton);
presetPrevButton.onClick = [this] {
presetManager->loadPrevPreset();
};
// preset name combo
presetCombo.setLookAndFeel(&laf);
addAndMakeVisible(presetCombo);
presetCombo.onChange = [this] {
int idx = presetCombo.getSelectedItemIndex();
auto names = presetManager->getPresetNames();
if (idx >= 0 && idx < names.size())
presetManager->loadPreset(names[idx]);
};
// > NEXT
presetNextButton.setButtonText(">");
presetNextButton.setColour(juce::TextButton::buttonColourId, AmbivalenceColors::Surface);
presetNextButton.setColour(juce::TextButton::textColourOffId, AmbivalenceColors::TextPrimary);
addAndMakeVisible(presetNextButton);
presetNextButton.onClick = [this] {
presetManager->loadNextPreset();
};
// SAVE
presetSaveButton.setButtonText("SAVE");
presetSaveButton.setColour(juce::TextButton::buttonColourId,
AmbivalenceColors::Accent.withAlpha(0.75f));
presetSaveButton.setColour(juce::TextButton::textColourOffId, AmbivalenceColors::Background);
addAndMakeVisible(presetSaveButton);
presetSaveButton.onClick = [this] { savePresetWithDialog(); };
// --- after changes ---
// LOAD
presetLoadButton.setButtonText("LOAD");
presetLoadButton.setColour(juce::TextButton::buttonColourId,
AmbivalenceColors::AccentBlue.withAlpha(0.75f));
presetLoadButton.setColour(juce::TextButton::textColourOffId,
AmbivalenceColors::Background);
addAndMakeVisible(presetLoadButton);
presetLoadButton.onClick = [this] {
auto names = presetManager->getPresetNames();
int idx = presetCombo.getSelectedItemIndex();
if (idx >= 0 && idx < names.size())
presetManager->loadPreset(names[idx]);
};
// DELETE
presetDeleteButton.setButtonText("DELETE");
presetDeleteButton.setColour(juce::TextButton::buttonColourId, AmbivalenceColors::Surface);
presetDeleteButton.setColour(juce::TextButton::textColourOffId, AmbivalenceColors::TextSecondary);
addAndMakeVisible(presetDeleteButton);
presetDeleteButton.onClick = [this] { deleteCurrentPreset(); };
// callback setup
// in the constructor callback setup
presetManager->onPresetListChanged = [this] { refreshPresetCombo(); };
// * fix : whenever the preset name changes Processor notify
presetManager->onPresetLoaded = [this](const juce::String& name) {
audioProcessor.setLastSavedPresetName(name);
refreshPresetCombo();
};
// -- Visualizers --
rt60Viz.setProcessor(&p);
decayCurveViz.setProcessor(&p);
addAndMakeVisible(rt60Viz);
addAndMakeVisible(decayCurveViz);
addAndMakeVisible(vuIn);
addAndMakeVisible(vuOut);
// -- AcousticMetrics --
labelMetricsTitle.setText("ACOUSTICS", juce::dontSendNotification);
labelMetricsTitle.setFont(juce::Font(juce::FontOptions(
"Helvetica Neue", 8.5f, juce::Font::bold)));
labelMetricsTitle.setColour(juce::Label::textColourId,
AmbivalenceColors::Accent.withAlpha(0.75f));
labelMetricsTitle.setJustificationType(juce::Justification::centredLeft);
addAndMakeVisible(labelMetricsTitle);
auto setupCaption = [this](juce::Label& label, const juce::String& text) {
label.setText(text, juce::dontSendNotification);
label.setFont(juce::Font(juce::FontOptions(
"Helvetica Neue", 8.0f, juce::Font::plain)));
label.setColour(juce::Label::textColourId,
AmbivalenceColors::TextSecondary.withAlpha(0.85f));
label.setJustificationType(juce::Justification::centredRight);
addAndMakeVisible(label);
};
auto setupValue = [this](juce::Label& label) {
label.setText("--", juce::dontSendNotification);
label.setFont(juce::Font(juce::FontOptions(
"Helvetica Neue", 9.5f, juce::Font::bold)));
label.setColour(juce::Label::textColourId, AmbivalenceColors::TextPrimary);
label.setJustificationType(juce::Justification::centredLeft);
addAndMakeVisible(label);
};
setupCaption(labelD50Caption, "D50:");
setupCaption(labelC50Caption, "C50:");
setupCaption(labelC80Caption, "C80:");
setupCaption(labelEDTCaption, "EDT:");
setupValue(labelD50Value);
setupValue(labelC50Value);
setupValue(labelC80Value);
setupValue(labelEDTValue);
// -- Git build info --
juce::String gitInfo = "Git: " + juce::String(AMBIVALENCE_GIT_BRANCH) + " @ " +
juce::String(AMBIVALENCE_GIT_COMMIT);
#if AMBIVALENCE_GIT_DIRTY
gitInfo += " (dirty)";
#endif
gitInfo += " | Build: " + juce::String(__DATE__) + " " + juce::String(__TIME__) + " (local)";
statusLabel.setText(gitInfo, juce::dontSendNotification);
statusLabel.setFont(juce::Font(juce::FontOptions(
"Helvetica Neue", 8.5f, juce::Font::plain)));
statusLabel.setColour(juce::Label::textColourId,
AmbivalenceColors::TextSecondary.withAlpha(0.6f));
statusLabel.setJustificationType(juce::Justification::centred);
addAndMakeVisible(statusLabel);
// --- after changes ---
refreshPresetCombo(); // added: initialize the combo at startup
updatePanelVisibility();
startTimerHz(60);
}
// -----------------------------------------------------------------------------
// destructor
// -----------------------------------------------------------------------------
FDNReverbEditor::~FDNReverbEditor() {
stopTimer();
setLookAndFeel(nullptr);
satTypeCombo.setLookAndFeel(nullptr);
presetCombo.setLookAndFeel(nullptr);
}
// -----------------------------------------------------------------------------
// timerCallback
// -----------------------------------------------------------------------------
void FDNReverbEditor::timerCallback()
{
vuIn.setLevels(audioProcessor.getInputRMSL(),
audioProcessor.getInputRMSR());
vuOut.setLevels(audioProcessor.getOutputRMSL(),
audioProcessor.getOutputRMSR());
vuIn.repaint();
vuOut.repaint();
static int metricsCounter = 0;
if (++metricsCounter >= 2) {
metricsCounter = 0;
labelD50Value.setText(
juce::String(audioProcessor.getD50() * 100.0f, 1) + "%",
juce::dontSendNotification);
labelC50Value.setText(
juce::String(audioProcessor.getC50(), 1) + "dB",
juce::dontSendNotification);
labelC80Value.setText(
juce::String(audioProcessor.getC80(), 1) + "dB",
juce::dontSendNotification);
labelEDTValue.setText(
juce::String(audioProcessor.getEDT(), 2) + "s",
juce::dontSendNotification);
}
bool newProMode = (*audioProcessor.apvts.getRawParameterValue("promode") > 0.5f);
if (newProMode != isProMode) {
isProMode = newProMode;
updatePanelVisibility();
resized();
repaint();
}
}
// -----------------------------------------------------------------------------
// updatePanelVisibility
// -----------------------------------------------------------------------------
void FDNReverbEditor::updatePanelVisibility()
{
auto setKnob = [](ArcKnob& k, bool vis) {
k.slider.setVisible(vis);
k.label.setVisible(vis);
};
const bool showNormal = !isProMode;
const bool showPro = isProMode;
setKnob(kPreDelay, showNormal);
setKnob(kRoomSize, showNormal);
setKnob(kDecay, showNormal);
setKnob(kHFDamp, showNormal);
setKnob(kLFAbsorb, showNormal);
setKnob(kDiffusion, showNormal);
setKnob(kModAmt, showNormal);
setKnob(kModRate, showNormal);
setKnob(kStereoW, showNormal);
setKnob(kERLevel, showNormal);
setKnob(kSaturation, showNormal);
setKnob(kWet, showNormal);
setKnob(kDry, showNormal);
setKnob(kDuckAmt, showNormal);
setKnob(kDuckThr, showNormal);
setKnob(kDuckAtt, showNormal);
setKnob(kDuckRel, showNormal);
setKnob(kLoCutNorm, showNormal);
setKnob(kHiCutNorm, showNormal);
for (auto& k : kRTBands) setKnob(k, showPro);
satTypeLabel.setVisible(showPro);
satTypeCombo.setVisible(showPro);
setKnob(kTiltLow, showPro);
setKnob(kTiltMid, showPro);
setKnob(kTiltHigh, showPro);
setKnob(kLoCutPro, showPro);
setKnob(kHiCutPro, showPro);
// preset UI always visible
presetPrevButton.setVisible(true);
presetCombo.setVisible(true);
presetNextButton.setVisible(true);
// --- after changes ---
presetSaveButton.setVisible(true);
presetLoadButton.setVisible(true); // * added
presetDeleteButton.setVisible(true);
}
// -----------------------------------------------------------------------------
// resized
// -----------------------------------------------------------------------------
void FDNReverbEditor::resized()
{
titleLabel.setBounds(PAD, Y_HEADER, 180, 32);
proModeButton.setBounds(196, Y_HEADER + 5, 52, 22);
erSoloButton.setBounds(256, Y_HEADER + 5, 72, 22);
vuIn.setBounds(W - 220, Y_HEADER + 2, 96, 28);
vuOut.setBounds(W - 120, Y_HEADER + 2, 96, 28);
algoSelector.setBounds(PAD, Y_ALGO, W - PAD * 2, 30);
auto place1 = [&](ArcKnob& k, int& x, int y) {
k.label.setBounds(x, y, KNOB_W, KNOB_LBL_H);
k.slider.setBounds(x, y + KNOB_LBL_H, KNOB_W, KNOB_H);
x += KNOB_W + ROW1_GAP;
};
auto place2 = [&](ArcKnob& k, int& x, int y) {
k.label.setBounds(x, y, KNOB_W, KNOB_LBL_H);
k.slider.setBounds(x, y + KNOB_LBL_H, KNOB_W, KNOB_H);
x += KNOB_W + PAD;
};
if (!isProMode) {
// -- Row 1 --
int kx = PAD;
place1(kPreDelay, kx, Y_ROW1);
place1(kRoomSize, kx, Y_ROW1);
place1(kDecay, kx, Y_ROW1);
place1(kHFDamp, kx, Y_ROW1);
place1(kLFAbsorb, kx, Y_ROW1);
place1(kDiffusion, kx, Y_ROW1);
place1(kModAmt, kx, Y_ROW1);
place1(kModRate, kx, Y_ROW1);
place1(kStereoW, kx, Y_ROW1);
place1(kERLevel, kx, Y_ROW1);
place1(kSaturation, kx, Y_ROW1);
// -- Row 2: MIX | OUT EQ | DUCKING --
kx = PAD;
place2(kWet, kx, Y_ROW2);
place2(kDry, kx, Y_ROW2);
kx += 16;
place2(kLoCutNorm, kx, Y_ROW2);
place2(kHiCutNorm, kx, Y_ROW2);
kx += 16;
place2(kDuckAmt, kx, Y_ROW2);
place2(kDuckThr, kx, Y_ROW2);
place2(kDuckAtt, kx, Y_ROW2);
place2(kDuckRel, kx, Y_ROW2);
}
else {
// -- ProMode row 1 --
int kx = PAD;
for (int i = 0; i < 10; ++i)
place1(kRTBands[i], kx, Y_ROW1);
// -- ProMode row 2 --
int kx2 = PAD;
satTypeLabel.setBounds(kx2, Y_SLABEL2, KNOB_W, KNOB_LBL_H);
satTypeCombo.setBounds(kx2, Y_SLABEL2 + KNOB_LBL_H + 2, KNOB_W + PAD, 24);
kx2 += KNOB_W + PAD + PAD + 8;
place2(kTiltLow, kx2, Y_ROW2);
place2(kTiltMid, kx2, Y_ROW2);
place2(kTiltHigh, kx2, Y_ROW2);
kx2 += 16;
place2(kLoCutPro, kx2, Y_ROW2);
place2(kHiCutPro, kx2, Y_ROW2);
}
// -------------------------------------------------------------------------
// preset UI (always, mode-independent)
// -------------------------------------------------------------------------
// layout (PRESET_PANEL_X=632 starting from ):
//
// top row Y=Y_ROW2: [<(26)] gap4 [combo(154)] gap4 [>(26)] -> right edge 848
// bottom row Y=Y_ROW2+34:[SAVE(104)] gap8 [DELETE(104)] -> right edge 848
//
// separator vertical line : PRESET_PANEL_X - 9 = 623
// -------------------------------------------------------------------------
{
const int px = PRESET_PANEL_X;
const int btnH = 26;
// top row
presetPrevButton.setBounds(px, Y_ROW2, 26, btnH);
presetCombo.setBounds(px + 30, Y_ROW2, 154, btnH);
presetNextButton.setBounds(px + 188, Y_ROW2, 26, btnH);
// --- after changes ---
// bottom row : [SAVE(68)] [LOAD(68)] [DELETE(68)]
presetSaveButton.setBounds(px, Y_ROW2 + 34, 68, btnH);
presetLoadButton.setBounds(px + 72, Y_ROW2 + 34, 68, btnH);
presetDeleteButton.setBounds(px + 144, Y_ROW2 + 34, 68, btnH);
}
// -- Visualizers --
const int vizTotalH = H - Y_VIZ - STATUS_H - PAD;
const int rt60Height = vizTotalH / 2 - 2;
const int decayHeight = vizTotalH / 2 - 2;
const int decayY = Y_VIZ + rt60Height + 4;
rt60Viz.setBounds(PAD, Y_VIZ, W - PAD * 2, rt60Height);
decayCurveViz.setBounds(PAD, decayY, W - PAD * 2, decayHeight);
// -- AcousticMetrics --
const int metricsRight = W - PAD - 8;
const int metricsW = 280;
const int metricsLeft = metricsRight - metricsW;
const int metricsTop = decayY + 6;
const int metricsRowH = 14;
const int metricsRow1Y = metricsTop + 14;
const int metricsRow2Y = metricsRow1Y + metricsRowH;
const int captionW = 28;
const int valueW = 52;
const int colSpacing = 8;
const int colW = captionW + valueW;
labelMetricsTitle.setBounds(metricsLeft, metricsTop, 80, 12);
labelD50Caption.setBounds(metricsLeft, metricsRow1Y, captionW, metricsRowH);
labelD50Value.setBounds(metricsLeft + captionW, metricsRow1Y, valueW, metricsRowH);
labelC50Caption.setBounds(metricsLeft + colW + colSpacing, metricsRow1Y, captionW, metricsRowH);
labelC50Value.setBounds(metricsLeft + colW + colSpacing + captionW, metricsRow1Y, valueW, metricsRowH);
labelC80Caption.setBounds(metricsLeft, metricsRow2Y, captionW, metricsRowH);
labelC80Value.setBounds(metricsLeft + captionW, metricsRow2Y, valueW, metricsRowH);
labelEDTCaption.setBounds(metricsLeft + colW + colSpacing, metricsRow2Y, captionW, metricsRowH);
labelEDTValue.setBounds(metricsLeft + colW + colSpacing + captionW, metricsRow2Y, valueW, metricsRowH);
// -- Git build-info status bar --
statusLabel.setBounds(PAD, H - STATUS_H, W - PAD * 2, STATUS_H);
}
// -----------------------------------------------------------------------------
// paint
// -----------------------------------------------------------------------------
void FDNReverbEditor::paint(juce::Graphics& g)
{
g.fillAll(AmbivalenceColors::Background);
juce::ColourGradient grad(
AmbivalenceColors::Surface.withAlpha(0.12f), 0.f, 0.f,
AmbivalenceColors::Background, 0.f, (float)H, false);
g.setGradientFill(grad);
g.fillAll();
g.setFont(juce::Font(juce::FontOptions(8.f)));
g.setColour(AmbivalenceColors::TextSecondary.withAlpha(0.35f));
g.drawText("16ch FDN | SAPF | ISM-ER | 44.1-192kHz",
PAD + 336, Y_HEADER + 10, W / 2, 12,
juce::Justification::centredLeft);
g.setColour(AmbivalenceColors::Separator);
g.drawHorizontalLine(Y_SEP, (float)PAD, (float)(W - PAD));
g.setFont(juce::Font(juce::FontOptions(
"Helvetica Neue", 8.5f, juce::Font::bold)));
auto sl = [&](int x, int y, const char* t) {
g.drawText(t, x, y, 200, 14, juce::Justification::centredLeft);
};
if (!isProMode) {
// -- Row 1 separator --
g.setColour(AmbivalenceColors::Separator);
g.drawVerticalLine(SEP_TF, (float)Y_SLABEL1, (float)(Y_ROW1 + UNIT_H));
g.drawVerticalLine(SEP_FD, (float)Y_SLABEL1, (float)(Y_ROW1 + UNIT_H));
g.drawVerticalLine(SEP_DS, (float)Y_SLABEL1, (float)(Y_ROW1 + UNIT_H));
g.drawVerticalLine(SEP_SC, (float)Y_SLABEL1, (float)(Y_ROW1 + UNIT_H));
// -- Row 2 separator --
const int row2_outeq_x = PAD + 2 * (KNOB_W + PAD) + 16;
const int row2_duck_x = row2_outeq_x + 2 * (KNOB_W + PAD) + 16;
g.drawVerticalLine(row2_outeq_x - 9, (float)Y_SLABEL2, (float)(Y_ROW2 + UNIT_H));
g.drawVerticalLine(row2_duck_x - 9, (float)Y_SLABEL2, (float)(Y_ROW2 + UNIT_H));
// -- section --
g.setColour(AmbivalenceColors::Accent.withAlpha(0.75f));
sl(SEC_TIME, Y_SLABEL1, "TIME");
sl(SEC_FREQUENCY, Y_SLABEL1, "FREQUENCY");
sl(SEC_DIFFUSION, Y_SLABEL1, "DIFFUSION");
sl(SEC_STEREO, Y_SLABEL1, "STEREO");
sl(SEC_CHARACTER, Y_SLABEL1, "CHARACTER");
sl(PAD, Y_SLABEL2, "MIX");
sl(row2_outeq_x, Y_SLABEL2, "OUT EQ");
sl(row2_duck_x, Y_SLABEL2, "DUCKING");
}
else {
// -- ProMode --
g.setColour(AmbivalenceColors::Accent.withAlpha(0.75f));
sl(PAD, Y_SLABEL1, "RT60 PER BAND");
g.setColour(AmbivalenceColors::Separator.withAlpha(0.5f));
g.drawHorizontalLine(Y_SLABEL2 - 4, (float)PAD, (float)(W - PAD));
const int tilt_x = PAD + KNOB_W + PAD + PAD + 8;
const int outeq_x = tilt_x + 3 * (KNOB_W + PAD) + 16;
g.setColour(AmbivalenceColors::Separator);
g.drawVerticalLine(outeq_x - 9, (float)Y_SLABEL2, (float)(Y_ROW2 + UNIT_H));
g.setColour(AmbivalenceColors::Accent.withAlpha(0.75f));
sl(tilt_x, Y_SLABEL2, "TILT EQ");
sl(outeq_x, Y_SLABEL2, "OUT EQ");
}
// -------------------------------------------------------------------------
// preset section (always, mode-independent)
// -------------------------------------------------------------------------
g.setColour(AmbivalenceColors::Separator);
g.drawVerticalLine(PRESET_PANEL_X - 9,
(float)Y_SLABEL2, (float)(Y_ROW2 + UNIT_H));
g.setColour(AmbivalenceColors::Accent.withAlpha(0.75f));
sl(PRESET_PANEL_X, Y_SLABEL2, "PRESET");
// -- Git build-info status bar divider --
g.setColour(AmbivalenceColors::Separator);
g.drawHorizontalLine(H - STATUS_H - 1, (float)PAD, (float)(W - PAD));
}
// -----------------------------------------------------------------------------
// Preset UI helpers
// -----------------------------------------------------------------------------
void FDNReverbEditor::refreshPresetCombo()
{
presetCombo.clear(juce::dontSendNotification);
auto names = presetManager->getPresetNames();
// ---------------------------------------------------------------------
// * fix: restore the preset name saved in the Processor at startup
// ---------------------------------------------------------------------
// when the editor is closed the PresetManager is destroyed,
// so currentPresetName becomes empty.
// the name persisted in the Processor as lastSavedPresetName is
// re-set in the new PresetManager,
// so the combo box selection is restored correctly.
// ---------------------------------------------------------------------
if (presetManager->getCurrentPresetName().isEmpty()) {
auto saved = audioProcessor.getLastSavedPresetName();
if (saved.isNotEmpty())
presetManager->setCurrentPresetName(saved);
}
if (names.isEmpty()) {
presetCombo.addItem("-- No Presets --", 1);
presetCombo.setSelectedItemIndex(0, juce::dontSendNotification);
presetDeleteButton.setEnabled(false);
presetLoadButton.setEnabled(false);
presetPrevButton.setEnabled(false);
presetNextButton.setEnabled(false);
return;
}
for (int i = 0; i < names.size(); ++i)
presetCombo.addItem(names[i], i + 1);
int idx = presetManager->getCurrentPresetIndex();
if (idx >= 0)
presetCombo.setSelectedItemIndex(idx, juce::dontSendNotification);
else
presetCombo.setSelectedItemIndex(0, juce::dontSendNotification);
presetDeleteButton.setEnabled(true);
presetLoadButton.setEnabled(true);
presetPrevButton.setEnabled(names.size() > 1);
presetNextButton.setEnabled(names.size() > 1);
}
void FDNReverbEditor::savePresetWithDialog()
{
// ---------------------------------------------------------------------
// AlertWindow preset name input dialog
// uses enterModalState(async callback): no runModalLoop() needed
// SafePointer handles the case where the editor is destroyed first
// ---------------------------------------------------------------------
auto* dialog = new juce::AlertWindow(
"Save Preset",
"Enter a name for this preset:",
juce::MessageBoxIconType::NoIcon);
dialog->addTextEditor("name", presetManager->getCurrentPresetName());
dialog->addButton("Save", 1, juce::KeyPress(juce::KeyPress::returnKey));
dialog->addButton("Cancel", 0, juce::KeyPress(juce::KeyPress::escapeKey));
juce::Component::SafePointer<FDNReverbEditor> safeThis(this);
dialog->enterModalState(
true,
juce::ModalCallbackFunction::create(
[safeThis, dialog](int result) {
if (safeThis != nullptr && result == 1) {
auto name = dialog->getTextEditorContents("name").trim();
if (name.isNotEmpty())
safeThis->presetManager->savePreset(name);
}
}),
true // deleteWhenDismissed
);
}
void FDNReverbEditor::deleteCurrentPreset()
{
auto name = presetManager->getCurrentPresetName();
if (name.isEmpty()) return;
auto* dialog = new juce::AlertWindow(
"Delete Preset",
"Delete \"" + name + "\"?",
juce::MessageBoxIconType::WarningIcon);
dialog->addButton("Delete", 1);
dialog->addButton("Cancel", 0, juce::KeyPress(juce::KeyPress::escapeKey));
juce::Component::SafePointer<FDNReverbEditor> safeThis(this);
dialog->enterModalState(
true,
juce::ModalCallbackFunction::create(
[safeThis, name](int result) {
if (safeThis != nullptr && result == 1)
safeThis->presetManager->deletePreset(name);
}),
true
);
}

97
Source/PluginEditor.h Normal file
View file

@ -0,0 +1,97 @@
#pragma once
#include <JuceHeader.h>
#include "PluginProcessor.h"
#include "PresetManager.h"
#include "GUI/AmbivalenceUI.h"
#include "GUI/DecayCurveViz.h"
class FDNReverbEditor : public juce::AudioProcessorEditor,
private juce::Timer
{
public:
explicit FDNReverbEditor(FDNReverbAudioProcessor&);
~FDNReverbEditor() override;
void paint(juce::Graphics&) override;
void resized() override;
private:
void timerCallback() override;
void updatePanelVisibility();
// --- Preset UI helpers ---
void refreshPresetCombo();
void savePresetWithDialog();
void deleteCurrentPreset();
FDNReverbAudioProcessor& audioProcessor;
AmbivalenceLookAndFeel laf;
// --- common ---
AlgorithmSelector algoSelector;
RT60Visualizer rt60Viz;
DecayCurveViz decayCurveViz;
VUMeter vuIn, vuOut;
juce::Label titleLabel;
juce::Label labelMetricsTitle;
juce::Label labelD50Caption, labelD50Value;
juce::Label labelC50Caption, labelC50Value;
juce::Label labelC80Caption, labelC80Value;
juce::Label labelEDTCaption, labelEDTValue;
juce::Label statusLabel;
juce::TextButton proModeButton;
juce::TextButton erSoloButton;
std::unique_ptr<juce::AudioProcessorValueTreeState::ButtonAttachment> proModeAttachment;
std::unique_ptr<juce::AudioProcessorValueTreeState::ButtonAttachment> erSoloAttachment;
bool isProMode{ false };
// --- Normal Mode ---
ArcKnob kPreDelay, kRoomSize, kDecay;
ArcKnob kHFDamp, kLFAbsorb;
ArcKnob kDiffusion, kModAmt, kModRate;
ArcKnob kStereoW;
ArcKnob kERLevel, kSaturation;
ArcKnob kWet, kDry;
ArcKnob kDuckAmt, kDuckThr, kDuckAtt, kDuckRel;
ArcKnob kLoCutNorm, kHiCutNorm;
// --- ProMode panel ---
std::array<ArcKnob, 10> kRTBands;
juce::Label satTypeLabel;
juce::ComboBox satTypeCombo;
std::unique_ptr<juce::AudioProcessorValueTreeState::ComboBoxAttachment> satTypeAttachment;
ArcKnob kTiltLow, kTiltMid, kTiltHigh;
ArcKnob kLoCutPro, kHiCutPro;
// --- preset UI ---
std::unique_ptr<PresetManager> presetManager;
juce::TextButton presetPrevButton;
juce::ComboBox presetCombo;
juce::TextButton presetNextButton;
// existing presetSaveButton / presetDeleteButton next to added
juce::TextButton presetSaveButton;
juce::TextButton presetLoadButton; // * added
juce::TextButton presetDeleteButton;
// --- layout constants ---
static constexpr int W = 900;
static constexpr int H = 540;
static constexpr int PAD = 8;
static constexpr int KNOB_W = 64;
static constexpr int KNOB_H = 72;
static constexpr int KNOB_LBL_H = 14;
static constexpr int UNIT_H = 88;
static constexpr int ROW1_GAP = 18;
static constexpr int STATUS_H = 16;
// Fixed X of the preset panel
// DUCKING end (616) + gap(16) = 632
static constexpr int PRESET_PANEL_X = 632;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(FDNReverbEditor)
};

View file

@ -0,0 +1,87 @@
#include "PluginParameters.h"
namespace FDNReverb {
juce::AudioProcessorValueTreeState::ParameterLayout ParameterHelper::createLayout()
{
std::vector<std::unique_ptr<juce::RangedAudioParameter>> params;
auto addFloat = [&](const juce::String& id,
const juce::String& name,
float min, float max, float def,
float skew = 1.0f,
const juce::String& label = "")
{
params.push_back(std::make_unique<juce::AudioParameterFloat>(
id, name,
juce::NormalisableRange<float>(min, max, 0.01f, skew),
def,
juce::AudioParameterFloatAttributes().withLabel(label)));
};
params.push_back(std::make_unique<juce::AudioParameterChoice>(
ParamID::Algorithm, "Algorithm",
juce::StringArray{ "ROOM1","ROOM2","HALL1","HALL2","PLATE","SPRING","GOLDFOIL" }, 0));
addFloat(ParamID::PreDelay, "Pre-Delay", 0.0f, 500.0f, 10.0f, 1.0f, "ms");
addFloat(ParamID::RoomSize, "Room Size", 0.3f, 2.0f, 1.0f);
addFloat(ParamID::DecayTime, "Decay Time", 0.1f, 20.0f, 1.5f, 0.35f, "s");
addFloat(ParamID::HFDamping, "HF Damping", 0.0f, 1.0f, 0.0f);
addFloat(ParamID::LFAbsorption, "LF Absorption", 0.0f, 1.0f, 0.0f);
addFloat(ParamID::Diffusion, "Diffusion", 0.0f, 1.0f, 0.7f);
addFloat(ParamID::ModAmount, "Mod Amount", 0.0f, 1.0f, 0.25f);
addFloat(ParamID::ModRate, "Mod Rate", 0.05f, 2.0f, 0.5f, 1.0f, "Hz");
addFloat(ParamID::StereoWidth, "Stereo Width", 0.0f, 1.0f, 0.8f);
addFloat(ParamID::ERLevel, "ER Level", 0.0f, 1.0f, 0.6f);
addFloat(ParamID::Saturation, "Saturation", 0.0f, 1.0f, 0.0f);
params.push_back(std::make_unique<juce::AudioParameterChoice>(
ParamID::SatType, "Sat Type",
juce::StringArray{ "Warm","Tape","Tube","Hard" },
0,
juce::AudioParameterChoiceAttributes().withAutomatable(false)));
// * Step B: Wet -6dB / Dry 0dB change
// internal offset -3dB (PluginProcessor.cpp)
// effective Wet displayed value -3dB .
// Wet=-6dB -> effective -9dB, Wet=0dB -> effective -3dB
addFloat(ParamID::WetLevel, "Wet", -60.0f, 0.0f, -4.0f, 1.0f, "dB");
addFloat(ParamID::DryLevel, "Dry", -60.0f, 0.0f, 0.0f, 1.0f, "dB");
addFloat(ParamID::DuckAmount, "Ducking", 0.0f, 20.0f, 0.0f, 1.0f, "dB");
addFloat(ParamID::DuckAttack, "Duck Attack", 0.5f, 100.0f, 10.0f, 0.4f, "ms");
addFloat(ParamID::DuckRelease, "Duck Release", 10.0f, 2000.0f, 200.0f, 0.4f, "ms");
addFloat(ParamID::DuckThresh, "Duck Thresh", -60.0f, 0.0f, -20.0f, 1.0f, "dB");
params.push_back(std::make_unique<juce::AudioParameterBool>(
ParamID::ERSolo, "ER Solo", false,
juce::AudioParameterBoolAttributes().withAutomatable(false)));
params.push_back(std::make_unique<juce::AudioParameterBool>(
ParamID::ProMode, "Pro Mode", false,
juce::AudioParameterBoolAttributes().withAutomatable(false)));
addFloat(ParamID::TiltLow, "Tilt Low", 0.5f, 2.0f, 1.0f);
addFloat(ParamID::TiltMid, "Tilt Mid", 0.5f, 2.0f, 1.0f);
addFloat(ParamID::TiltHigh, "Tilt High", 0.5f, 2.0f, 1.0f);
addFloat(ParamID::RTBand0, "RT 31Hz", 0.5f, 2.0f, 1.0f);
addFloat(ParamID::RTBand1, "RT 62Hz", 0.5f, 2.0f, 1.0f);
addFloat(ParamID::RTBand2, "RT 125Hz", 0.5f, 2.0f, 1.0f);
addFloat(ParamID::RTBand3, "RT 250Hz", 0.5f, 2.0f, 1.0f);
addFloat(ParamID::RTBand4, "RT 500Hz", 0.5f, 2.0f, 1.0f);
addFloat(ParamID::RTBand5, "RT 1kHz", 0.5f, 2.0f, 1.0f);
addFloat(ParamID::RTBand6, "RT 2kHz", 0.5f, 2.0f, 1.0f);
addFloat(ParamID::RTBand7, "RT 4kHz", 0.5f, 2.0f, 1.0f);
addFloat(ParamID::RTBand8, "RT 8kHz", 0.5f, 2.0f, 1.0f);
addFloat(ParamID::RTBand9, "RT 16kHz", 0.5f, 2.0f, 1.0f);
addFloat(ParamID::LoCut, "Lo Cut", 20.0f, 500.0f, 20.0f, 0.3f, "Hz");
addFloat(ParamID::HiCut, "Hi Cut", 1000.0f, 20000.0f, 20000.0f, 0.3f, "Hz");
return { params.begin(), params.end() };
}
} // namespace FDNReverb

117
Source/PluginParameters.h Normal file
View file

@ -0,0 +1,117 @@
#pragma once
#include <JuceHeader.h>
#include <array>
namespace FDNReverb {
namespace ParamID {
inline const juce::String Algorithm = "algorithm";
inline const juce::String PreDelay = "predelay";
inline const juce::String RoomSize = "roomsize";
inline const juce::String DecayTime = "decaytime";
inline const juce::String HFDamping = "hfdamping";
inline const juce::String LFAbsorption = "lfabsorption";
inline const juce::String Diffusion = "diffusion";
inline const juce::String ModAmount = "modamount";
inline const juce::String ModRate = "modrate";
inline const juce::String StereoWidth = "stereowidth";
inline const juce::String ERLevel = "erlevel";
inline const juce::String Saturation = "saturation";
inline const juce::String SatType = "sattype";
inline const juce::String WetLevel = "wetlevel";
inline const juce::String DryLevel = "drylevel";
inline const juce::String DuckAmount = "duckamount";
inline const juce::String DuckAttack = "duckattack";
inline const juce::String DuckRelease = "duckrelease";
inline const juce::String DuckThresh = "duckthresh";
inline const juce::String ERSolo = "ersolo";
inline const juce::String ProMode = "promode";
inline const juce::String TiltLow = "tiltlow";
inline const juce::String TiltMid = "tiltmid";
inline const juce::String TiltHigh = "tilthigh";
inline const juce::String RTBand0 = "rtband0";
inline const juce::String RTBand1 = "rtband1";
inline const juce::String RTBand2 = "rtband2";
inline const juce::String RTBand3 = "rtband3";
inline const juce::String RTBand4 = "rtband4";
inline const juce::String RTBand5 = "rtband5";
inline const juce::String RTBand6 = "rtband6";
inline const juce::String RTBand7 = "rtband7";
inline const juce::String RTBand8 = "rtband8";
inline const juce::String RTBand9 = "rtband9";
inline const juce::String LoCut = "locut";
inline const juce::String HiCut = "hicut";
}
struct DSPParams {
int algorithmIndex{ 0 };
float decayScale{ 1.0f };
float roomSizeScale{ 1.0f };
float hfDamping{ 0.0f };
float lfAbsorption{ 0.0f };
float diffusion{ 0.70f };
float preDelayMs{ 10.0f };
float modAmount{ 0.25f };
float modRate{ 0.5f };
float stereoWidth{ 0.80f };
float erLevel{ 0.6f };
float lateLevel{ 1.0f };
// * Step B: Wet -4dB / Dry 0dB
float wetDB{ -4.0f };
float dryDB{ 0.0f };
float saturation{ 0.0f };
int satTypeIdx{ 0 };
float duckingAmount{ 0.0f };
float duckingAttackMs{ 10.0f };
float duckingRelMs{ 200.0f };
float duckingThreshDB{ -20.0f };
bool erSolo{ false };
bool proMode{ false };
float tiltLow{ 1.0f };
float tiltMid{ 1.0f };
float tiltHigh{ 1.0f };
std::array<float, 10> rtBands{ { 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
1.0f, 1.0f, 1.0f, 1.0f, 1.0f } };
float loCutHz{ 20.0f };
float hiCutHz{ 20000.0f };
bool operator==(const DSPParams& o) const noexcept {
return algorithmIndex == o.algorithmIndex
&& decayScale == o.decayScale
&& roomSizeScale == o.roomSizeScale
&& hfDamping == o.hfDamping
&& lfAbsorption == o.lfAbsorption
&& diffusion == o.diffusion
&& preDelayMs == o.preDelayMs
&& modAmount == o.modAmount
&& modRate == o.modRate
&& stereoWidth == o.stereoWidth
&& erLevel == o.erLevel
&& lateLevel == o.lateLevel
&& wetDB == o.wetDB
&& dryDB == o.dryDB
&& saturation == o.saturation
&& satTypeIdx == o.satTypeIdx
&& duckingAmount == o.duckingAmount
&& duckingAttackMs == o.duckingAttackMs
&& duckingRelMs == o.duckingRelMs
&& duckingThreshDB == o.duckingThreshDB
&& erSolo == o.erSolo
&& proMode == o.proMode
&& tiltLow == o.tiltLow
&& tiltMid == o.tiltMid
&& tiltHigh == o.tiltHigh
&& rtBands == o.rtBands
&& loCutHz == o.loCutHz
&& hiCutHz == o.hiCutHz;
}
bool operator!=(const DSPParams& o) const noexcept { return !(*this == o); }
};
class ParameterHelper {
public:
static juce::AudioProcessorValueTreeState::ParameterLayout createLayout();
};
} // namespace FDNReverb

223
Source/PluginProcessor.cpp Normal file
View file

@ -0,0 +1,223 @@
#include "PluginProcessor.h"
#include "PluginEditor.h"
using namespace FDNReverb;
// -----------------------------------------------------------------------------
// * Step A: Wet internal offset
// -----------------------------------------------------------------------------
// the UI shows -60 to 0 dB, but the effective Wet maximum is -3 dB.
// reason: at Wet=0 dB, combined with the FDN makeup gain the OutputLimiter
// would engage constantly and clip. -3 dB of headroom is required.
// implementation: rather than adding -3 dB (= 0.708x) to the APVTS value,
// multiply after Decibels::decibelsToGain() - numerically safer.
// -----------------------------------------------------------------------------
static constexpr float kWetInternalOffsetDB = -1.0f;
FDNReverbAudioProcessor::FDNReverbAudioProcessor()
: AudioProcessor(BusesProperties()
.withInput("Input", juce::AudioChannelSet::stereo(), true)
.withOutput("Output", juce::AudioChannelSet::stereo(), true)),
apvts(*this, nullptr, "FDNReverbState", ParameterHelper::createLayout())
{
}
void FDNReverbAudioProcessor::prepareToPlay(double sampleRate, int samplesPerBlock)
{
int osIdx = 0;
oversampler = std::make_unique<juce::dsp::Oversampling<float>>(
2, osIdx,
juce::dsp::Oversampling<float>::filterHalfBandPolyphaseIIR, true);
oversampler->initProcessing(static_cast<size_t>(samplesPerBlock));
engine.prepare(sampleRate, samplesPerBlock);
wetBuffer.setSize(2, samplesPerBlock);
smoothWetGain.reset(sampleRate, 0.05);
smoothDryGain.reset(sampleRate, 0.05);
lastSampleRate = sampleRate;
paramsNeedUpdate = true;
}
void FDNReverbAudioProcessor::updateEngineParams()
{
int currentAlgo = (int)*apvts.getRawParameterValue(ParamID::Algorithm);
if (currentAlgo != lastAlgorithmIndex) {
if (lastAlgorithmIndex >= 0)
loadPresetDefaults(currentAlgo);
lastAlgorithmIndex = currentAlgo;
paramsNeedUpdate = true;
}
DSPParams p;
p.algorithmIndex = (int)*apvts.getRawParameterValue(ParamID::Algorithm);
p.preDelayMs = *apvts.getRawParameterValue(ParamID::PreDelay);
p.roomSizeScale = *apvts.getRawParameterValue(ParamID::RoomSize) - 0.5f;
p.decayScale = *apvts.getRawParameterValue(ParamID::DecayTime)
/ ALL_PRESETS[p.algorithmIndex]->acoustics.rt60[4];
p.hfDamping = *apvts.getRawParameterValue(ParamID::HFDamping);
p.lfAbsorption = *apvts.getRawParameterValue(ParamID::LFAbsorption);
p.diffusion = *apvts.getRawParameterValue(ParamID::Diffusion);
p.modAmount = *apvts.getRawParameterValue(ParamID::ModAmount);
p.modRate = *apvts.getRawParameterValue(ParamID::ModRate);
p.stereoWidth = *apvts.getRawParameterValue(ParamID::StereoWidth);
p.erLevel = *apvts.getRawParameterValue(ParamID::ERLevel);
p.saturation = *apvts.getRawParameterValue(ParamID::Saturation);
p.wetDB = *apvts.getRawParameterValue(ParamID::WetLevel);
p.dryDB = *apvts.getRawParameterValue(ParamID::DryLevel);
p.duckingAmount = *apvts.getRawParameterValue(ParamID::DuckAmount);
p.duckingAttackMs = *apvts.getRawParameterValue(ParamID::DuckAttack);
p.duckingRelMs = *apvts.getRawParameterValue(ParamID::DuckRelease);
p.duckingThreshDB = *apvts.getRawParameterValue(ParamID::DuckThresh);
p.satTypeIdx = (int)*apvts.getRawParameterValue(ParamID::SatType);
p.erSolo = (*apvts.getRawParameterValue(ParamID::ERSolo)) > 0.5f;
p.proMode = (*apvts.getRawParameterValue(ParamID::ProMode)) > 0.5f;
p.tiltLow = *apvts.getRawParameterValue(ParamID::TiltLow);
p.tiltMid = *apvts.getRawParameterValue(ParamID::TiltMid);
p.tiltHigh = *apvts.getRawParameterValue(ParamID::TiltHigh);
p.rtBands[0] = *apvts.getRawParameterValue(ParamID::RTBand0);
p.rtBands[1] = *apvts.getRawParameterValue(ParamID::RTBand1);
p.rtBands[2] = *apvts.getRawParameterValue(ParamID::RTBand2);
p.rtBands[3] = *apvts.getRawParameterValue(ParamID::RTBand3);
p.rtBands[4] = *apvts.getRawParameterValue(ParamID::RTBand4);
p.rtBands[5] = *apvts.getRawParameterValue(ParamID::RTBand5);
p.rtBands[6] = *apvts.getRawParameterValue(ParamID::RTBand6);
p.rtBands[7] = *apvts.getRawParameterValue(ParamID::RTBand7);
p.rtBands[8] = *apvts.getRawParameterValue(ParamID::RTBand8);
p.rtBands[9] = *apvts.getRawParameterValue(ParamID::RTBand9);
p.loCutHz = *apvts.getRawParameterValue(ParamID::LoCut);
p.hiCutHz = *apvts.getRawParameterValue(ParamID::HiCut);
// * Step A: Wet internal -3dB offset apply
// -60~0dB, effective value -63~-3dB .
smoothWetGain.setTargetValue(
juce::Decibels::decibelsToGain(p.wetDB + kWetInternalOffsetDB));
smoothDryGain.setTargetValue(juce::Decibels::decibelsToGain(p.dryDB));
if (paramsNeedUpdate || p != lastSentParams) {
engine.setParams(p);
lastSentParams = p;
paramsNeedUpdate = false;
}
}
void FDNReverbAudioProcessor::processBlock(
juce::AudioBuffer<float>& buffer, juce::MidiBuffer&)
{
juce::ScopedNoDenormals noDenormals;
updateEngineParams();
inputRMS_L.store(buffer.getRMSLevel(0, 0, buffer.getNumSamples()));
inputRMS_R.store(buffer.getRMSLevel(1, 0, buffer.getNumSamples()));
juce::dsp::AudioBlock<float> block(buffer);
auto osBlock = oversampler->processSamplesUp(block);
int numSamples = static_cast<int>(osBlock.getNumSamples());
wetBuffer.setSize(2, numSamples, false, false, true);
engine.processBlock(osBlock.getChannelPointer(0), osBlock.getChannelPointer(1),
wetBuffer.getWritePointer(0), wetBuffer.getWritePointer(1),
numSamples);
for (int i = 0; i < numSamples; ++i) {
float w = smoothWetGain.getNextValue();
float d = smoothDryGain.getNextValue();
osBlock.setSample(0, i, osBlock.getSample(0, i) * d
+ wetBuffer.getSample(0, i) * w);
osBlock.setSample(1, i, osBlock.getSample(1, i) * d
+ wetBuffer.getSample(1, i) * w);
}
oversampler->processSamplesDown(block);
outputRMS_L.store(buffer.getRMSLevel(0, 0, buffer.getNumSamples()));
outputRMS_R.store(buffer.getRMSLevel(1, 0, buffer.getNumSamples()));
}
void FDNReverbAudioProcessor::getStateInformation(juce::MemoryBlock& d) {
auto state = apvts.copyState();
// * fix: save the current preset name in the ValueTree
// when the editor exists, get the name from the PresetManager
// the editor is not held directly by the AudioProcessor,
// so a preset-name field managed by the Processor was added.
if (lastSavedPresetName.isNotEmpty())
state.setProperty("currentPresetName", lastSavedPresetName, nullptr);
std::unique_ptr<juce::XmlElement> xml(state.createXml());
copyXmlToBinary(*xml, d);
}
void FDNReverbAudioProcessor::setStateInformation(const void* d, int s) {
std::unique_ptr<juce::XmlElement> xml(getXmlFromBinary(d, s));
if (xml && xml->hasTagName(apvts.state.getType())) {
auto tree = juce::ValueTree::fromXml(*xml);
// * fix : preset name restore
lastSavedPresetName = tree.getProperty("currentPresetName", "").toString();
apvts.replaceState(tree);
paramsNeedUpdate = true;
}
}
juce::AudioProcessorEditor* FDNReverbAudioProcessor::createEditor() {
return new FDNReverbEditor(*this);
}
void FDNReverbAudioProcessor::loadPresetDefaults(int algorithmIndex)
{
if (algorithmIndex < 0 || algorithmIndex >= 7) return;
const auto& def = PRESET_DEFAULTS[algorithmIndex];
auto setParam = [this](const juce::String& paramID, float value) {
if (auto* param = apvts.getParameter(paramID)) {
param->setValueNotifyingHost(param->convertTo0to1(value));
}
};
setParam(ParamID::RoomSize, def.roomSize);
setParam(ParamID::DecayTime, def.decayTime);
// * Step A: HF Damping / LF Absorption always 0
// AlgorithmPresets.h def.hfDamp / def.lfAbsorb .
// reason : after " preset RT60 curve "
// correct . intentional correction
// before correction .
setParam(ParamID::HFDamping, 0.0f);
setParam(ParamID::LFAbsorption, 0.0f);
setParam(ParamID::Diffusion, def.diffusion);
setParam(ParamID::ModAmount, def.modAmount);
setParam(ParamID::ModRate, def.modRate);
setParam(ParamID::ERLevel, def.erLevel);
setParam(ParamID::Saturation, def.saturation);
setParam(ParamID::RTBand0, 1.0f);
setParam(ParamID::RTBand1, 1.0f);
setParam(ParamID::RTBand2, 1.0f);
setParam(ParamID::RTBand3, 1.0f);
setParam(ParamID::RTBand4, 1.0f);
setParam(ParamID::RTBand5, 1.0f);
setParam(ParamID::RTBand6, 1.0f);
setParam(ParamID::RTBand7, 1.0f);
setParam(ParamID::RTBand8, 1.0f);
setParam(ParamID::RTBand9, 1.0f);
setParam(ParamID::TiltLow, 1.0f);
setParam(ParamID::TiltMid, 1.0f);
setParam(ParamID::TiltHigh, 1.0f);
paramsNeedUpdate = true;
}
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() {
return new FDNReverbAudioProcessor();
}

91
Source/PluginProcessor.h Normal file
View file

@ -0,0 +1,91 @@
#pragma once
#include <JuceHeader.h>
#include "DSP/UniversalEngine.h"
#include "PluginParameters.h"
class FDNReverbAudioProcessor : public juce::AudioProcessor
{
public:
FDNReverbAudioProcessor();
void prepareToPlay(double sampleRate, int samplesPerBlock) override;
void releaseResources() override { engine.reset(); }
void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
void processBlockBypassed(juce::AudioBuffer<float>&, juce::MidiBuffer&) override {}
juce::AudioProcessorEditor* createEditor() override;
bool hasEditor() const override { return true; }
const juce::String getName() const override { return "Ambivalence1.1"; }
double getTailLengthSeconds() const override { return 20.0; }
bool acceptsMidi() const override { return false; }
bool producesMidi() const override { return false; }
bool isMidiEffect() const override { return false; }
int getNumPrograms() override { return 1; }
int getCurrentProgram() override { return 0; }
void setCurrentProgram(int) override {}
const juce::String getProgramName(int) override { return {}; }
void changeProgramName(int, const juce::String&) override {}
void getStateInformation(juce::MemoryBlock& destData) override;
void setStateInformation(const void* data, int sizeInBytes) override;
juce::AudioProcessorValueTreeState apvts;
std::array<float, FDNReverb::NUM_BANDS> getRT60ForDisplay() const noexcept {
return engine.getEffectiveRT60();
}
float getInputRMSL() const noexcept { return inputRMS_L.load(); }
float getInputRMSR() const noexcept { return inputRMS_R.load(); }
float getOutputRMSL() const noexcept { return outputRMS_L.load(); }
float getOutputRMSR() const noexcept { return outputRMS_R.load(); }
float getD50() const noexcept { return engine.getD50(); }
float getC50() const noexcept { return engine.getC50(); }
float getC80() const noexcept { return engine.getC80(); }
float getEDT() const noexcept { return engine.getEDT(); }
const FDNReverb::UniversalEngine& getEngine() const noexcept { return engine; }
void loadPresetDefaults(int algorithmIndex);
private:
void updateEngineParams();
FDNReverb::UniversalEngine engine;
// --- dirty flag: skip setParams() when no parameter changed ---
// processBlock() calls updateEngineParams() every buffer,
// but the designStage2() x 16 WLS computation is skipped when nothing changed
FDNReverb::DSPParams lastSentParams;
bool paramsNeedUpdate{ true }; // the first run
int lastAlgorithmIndex{ -1 };
std::unique_ptr<juce::dsp::Oversampling<float>> oversampler;
juce::AudioBuffer<float> wetBuffer;
juce::SmoothedValue<float> smoothWetGain, smoothDryGain;
std::atomic<float> inputRMS_L{ 0.f }, inputRMS_R{ 0.f };
std::atomic<float> outputRMS_L{ 0.f }, outputRMS_R{ 0.f };
double lastSampleRate{ 0.0 };
// * added : for session saving preset name
// PresetManager editor ,
// Processor preset name save support .
juce::String lastSavedPresetName;
public:
// editor call preset name Processor notify
void setLastSavedPresetName(const juce::String& name) noexcept {
lastSavedPresetName = name;
}
juce::String getLastSavedPresetName() const noexcept {
return lastSavedPresetName;
}
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(FDNReverbAudioProcessor)
};

134
Source/PresetManager.cpp Normal file
View file

@ -0,0 +1,134 @@
#include "PresetManager.h"
#include "PluginProcessor.h"
PresetManager::PresetManager(FDNReverbAudioProcessor& p)
: processor(p)
{
refreshPresetList();
}
// -----------------------------------------------------------------------------
// filesystem
// -----------------------------------------------------------------------------
juce::File PresetManager::getPresetsFolder() const
{
auto folder = juce::File::getSpecialLocation(
juce::File::userDocumentsDirectory)
.getChildFile(kSubFolder);
if (!folder.exists())
folder.createDirectory();
return folder;
}
juce::File PresetManager::getPresetFile(const juce::String& name) const
{
return getPresetsFolder().getChildFile(name + kExtension);
}
void PresetManager::refreshPresetList()
{
presetNames.clear();
auto files = getPresetsFolder().findChildFiles(
juce::File::findFiles, false,
juce::String("*") + kExtension);
files.sort();
for (const auto& f : files)
presetNames.add(f.getFileNameWithoutExtension());
}
// -----------------------------------------------------------------------------
// save
// -----------------------------------------------------------------------------
// reuses PluginProcessor::getStateInformation() directly,
// so no PluginProcessor changes are needed.
// -----------------------------------------------------------------------------
// --- after changes ---
bool PresetManager::savePreset(const juce::String& name)
{
if (name.isEmpty()) return false;
// * fix: notify the Processor of the name before getStateInformation()
// so getStateInformation() writes the correct name into the ValueTree
processor.setLastSavedPresetName(name);
juce::MemoryBlock data;
processor.getStateInformation(data);
auto file = getPresetFile(name);
if (!file.replaceWithData(data.getData(), data.getSize()))
return false;
currentPresetName = name;
refreshPresetList();
if (onPresetListChanged) onPresetListChanged();
if (onPresetLoaded) onPresetLoaded(name);
return true;
}
// -----------------------------------------------------------------------------
// load
// -----------------------------------------------------------------------------
// reuses PluginProcessor::setStateInformation() directly.
// setStateInformation() sets paramsNeedUpdate=true, so
// the next processBlock() sends the new parameters to the engine.
// -----------------------------------------------------------------------------
bool PresetManager::loadPreset(const juce::String& name)
{
auto file = getPresetFile(name);
if (!file.exists()) return false;
juce::MemoryBlock data;
if (!file.loadFileAsData(data)) return false;
processor.setStateInformation(data.getData(), static_cast<int>(data.getSize()));
// * after loading a preset always normal view (Normal Mode)
if (auto* param = processor.apvts.getParameter("promode"))
param->setValueNotifyingHost(0.0f);
currentPresetName = name;
if (onPresetLoaded) onPresetLoaded(name);
return true;
}
// -----------------------------------------------------------------------------
// delete
// -----------------------------------------------------------------------------
bool PresetManager::deletePreset(const juce::String& name)
{
auto file = getPresetFile(name);
if (!file.exists()) return false;
if (!file.deleteFile()) return false;
if (currentPresetName == name)
currentPresetName.clear();
refreshPresetList();
if (onPresetListChanged) onPresetListChanged();
return true;
}
// -----------------------------------------------------------------------------
// navigation
// -----------------------------------------------------------------------------
int PresetManager::getCurrentPresetIndex() const noexcept
{
return presetNames.indexOf(currentPresetName);
}
void PresetManager::loadPrevPreset()
{
if (presetNames.isEmpty()) return;
int idx = getCurrentPresetIndex();
if (idx <= 0)
idx = presetNames.size();
loadPreset(presetNames[idx - 1]);
}
void PresetManager::loadNextPreset()
{
if (presetNames.isEmpty()) return;
int idx = getCurrentPresetIndex();
if (idx < 0 || idx >= presetNames.size() - 1)
idx = -1;
loadPreset(presetNames[idx + 1]);
}

59
Source/PresetManager.h Normal file
View file

@ -0,0 +1,59 @@
#pragma once
#include <JuceHeader.h>
class FDNReverbAudioProcessor;
// -----------------------------------------------------------------------------
// PresetManager
// -----------------------------------------------------------------------------
// preset file manage .
//
// save location : ~/Documents/Ambivalence/Presets/*.ambpreset
// : APVTS getStateInformation/setStateInformation
// -> existing save fully reused , minimizes added code
//
// real-time safety :
// - file I/O all message thread (UI) assumed to be called
// - processBlock not involved at all
// -----------------------------------------------------------------------------
class PresetManager
{
public:
explicit PresetManager(FDNReverbAudioProcessor& processor);
// --- preset operations ---
bool savePreset(const juce::String& name);
bool loadPreset(const juce::String& name);
bool deletePreset(const juce::String& name);
// --- navigation ---
void loadPrevPreset();
void loadNextPreset();
// --- state access ---
juce::StringArray getPresetNames() const noexcept { return presetNames; }
// --- after changes ---
juce::String getCurrentPresetName() const noexcept { return currentPresetName; }
void setCurrentPresetName(const juce::String& name) noexcept { currentPresetName = name; } // * added
int getCurrentPresetIndex() const noexcept;
bool hasPresets() const noexcept { return !presetNames.isEmpty(); }
// --- folder access ---
juce::File getPresetsFolder() const;
// --- UI update callback ---
std::function<void()> onPresetListChanged;
std::function<void(const juce::String&)> onPresetLoaded;
private:
void refreshPresetList();
juce::File getPresetFile(const juce::String& name) const;
FDNReverbAudioProcessor& processor;
juce::StringArray presetNames;
juce::String currentPresetName;
static constexpr const char* kExtension = ".ambpreset";
static constexpr const char* kSubFolder = "Ambivalence/Presets";
};