diff --git a/PluginProcessor.cpp b/PluginProcessor.cpp new file mode 100644 index 0000000..a9ca761 --- /dev/null +++ b/PluginProcessor.cpp @@ -0,0 +1,6027 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" +#include "BinaryData.h" +#include "PianoPhysicsData.h" +#include +#include +#include + +// SIMD headers for vectorization +#if defined(__SSE__) || defined(_M_X64) || defined(_M_IX86) +#include +#include +#define USE_SSE 1 +#endif + +//============================================================================== +// FAST MATH LOOKUP TABLES AND APPROXIMATIONS +// These replace expensive std:: functions in the hot path +//============================================================================== + +namespace FastMath { + +// Lookup table sizes +static constexpr int kSinTableSize = 4096; +static constexpr int kSqrtTableSize = 4096; +static constexpr int kPowTableSize = 1024; + +// Precomputed tables (initialized once) +static float sinTable[kSinTableSize]; +static float sqrtTable[kSqrtTableSize]; // sqrt(x) for x in [0, 4] +static float pow2Table[kPowTableSize]; // 2^x for x in [-4, 4] +static bool tablesInitialized = false; + +inline void initTables() +{ + if (tablesInitialized) return; + + // Sin table: covers [0, 2*PI] + for (int i = 0; i < kSinTableSize; ++i) + { + float phase = (float) i / (float) kSinTableSize * juce::MathConstants::twoPi; + sinTable[i] = std::sin (phase); + } + + // Sqrt table: covers [0, 4] (sufficient for normalized audio) + for (int i = 0; i < kSqrtTableSize; ++i) + { + float x = (float) i / (float) kSqrtTableSize * 4.0f; + sqrtTable[i] = std::sqrt (x); + } + + // Pow2 table: covers 2^x for x in [-4, 4] + for (int i = 0; i < kPowTableSize; ++i) + { + float x = ((float) i / (float) kPowTableSize) * 8.0f - 4.0f; + pow2Table[i] = std::pow (2.0f, x); + } + + tablesInitialized = true; +} + +// Fast sine using lookup table with linear interpolation +inline float fastSin (float phase) +{ + // Wrap phase to [0, 2*PI] + const float twoPi = juce::MathConstants::twoPi; + while (phase < 0.0f) phase += twoPi; + while (phase >= twoPi) phase -= twoPi; + + float idx = phase / twoPi * (float) kSinTableSize; + int i0 = (int) idx; + float frac = idx - (float) i0; + i0 = i0 & (kSinTableSize - 1); + int i1 = (i0 + 1) & (kSinTableSize - 1); + + return sinTable[i0] + frac * (sinTable[i1] - sinTable[i0]); +} + +// Fast sqrt approximation (for values 0-4, good for normalized audio) +inline float fastSqrt (float x) +{ + if (x <= 0.0f) return 0.0f; + if (x >= 4.0f) return std::sqrt (x); // Fallback for out of range + + float idx = x * (float) kSqrtTableSize * 0.25f; + int i0 = (int) idx; + float frac = idx - (float) i0; + i0 = juce::jlimit (0, kSqrtTableSize - 2, i0); + + return sqrtTable[i0] + frac * (sqrtTable[i0 + 1] - sqrtTable[i0]); +} + +// Fast inverse sqrt (Quake-style with one Newton-Raphson iteration) +inline float fastInvSqrt (float x) +{ + union { float f; uint32_t i; } conv; + conv.f = x; + conv.i = 0x5f3759df - (conv.i >> 1); + conv.f *= 1.5f - (x * 0.5f * conv.f * conv.f); + return conv.f; +} + +// Fast pow(2, x) for x in [-4, 4] +inline float fastPow2 (float x) +{ + x = juce::jlimit (-4.0f, 3.99f, x); + float idx = (x + 4.0f) * (float) kPowTableSize * 0.125f; + int i0 = (int) idx; + float frac = idx - (float) i0; + i0 = juce::jlimit (0, kPowTableSize - 2, i0); + + return pow2Table[i0] + frac * (pow2Table[i0 + 1] - pow2Table[i0]); +} + +// Fast pow approximation using log2/exp2 identity: x^y = 2^(y * log2(x)) +// Only accurate for positive x, and limited y range +inline float fastPow (float base, float exp) +{ + if (base <= 0.0f) return 0.0f; + if (exp == 0.0f) return 1.0f; + if (exp == 1.0f) return base; + if (exp == 2.0f) return base * base; + if (exp == 0.5f) return fastSqrt (base); + + // Use actual pow for accuracy in edge cases + return std::pow (base, exp); +} + +// Fast tanh approximation (Pade approximant) +inline float fastTanh (float x) +{ + if (x < -3.0f) return -1.0f; + if (x > 3.0f) return 1.0f; + float x2 = x * x; + return x * (27.0f + x2) / (27.0f + 9.0f * x2); +} + +// Fast exp approximation +inline float fastExp (float x) +{ + x = juce::jlimit (-10.0f, 10.0f, x); + // Schraudolph's approximation + union { float f; int32_t i; } v; + v.i = (int32_t) (12102203.0f * x + 1065353216.0f); + return v.f; +} + +} // namespace FastMath + +// FIX #1: SharedBus struct moved to header file +// FIX #1: Removed thread_local - buses are now owned by FluteSynthAudioProcessor + +static std::array getTemperamentOffsetsByChoice (int choice); +static std::array expandPitchClassOffsets (const std::array& offsets); + +static float mapHammerStiffnessToModel (float stiffnessSi) +{ + const float kMin = 4.0e8f; + const float kMax = 1.0e10f; + const float logMin = std::log10 (kMin); + const float logMax = std::log10 (kMax); + const float logK = std::log10 (juce::jlimit (kMin, kMax, stiffnessSi)); + const float t = (logK - logMin) / (logMax - logMin); + return 200.0f + t * (20000.0f - 200.0f); +} + +static float mapInharmonicityToDispersion (float bCoeff, float baseDispersion) +{ + const float bMin = 0.00018f; + const float bMax = 0.40f; + const float logMin = std::log10 (bMin); + const float logMax = std::log10 (bMax); + const float logB = std::log10 (juce::jlimit (bMin, bMax, bCoeff)); + const float t = (logB - logMin) / (logMax - logMin); + const float scale = 0.25f + 0.85f * t; + return juce::jlimit (0.0f, 0.50f, baseDispersion * scale); +} + +//============================================================================== +// Voice (VA) +FluteVoice::FluteVoice (juce::AudioProcessorValueTreeState& state) : apvts (state) +{ + using FilterType = juce::dsp::StateVariableTPTFilterType; + svf.setType (FilterType::lowpass); + adsr.setSampleRate (44100.0); // updated in prepare() +} + +bool FluteVoice::canPlaySound (juce::SynthesiserSound* s) +{ + return dynamic_cast (s) != nullptr; +} + +void FluteVoice::startNote (int midiNoteNumber, float velocity, juce::SynthesiserSound*, int) +{ + const int midiIdx = juce::jlimit (0, 127, midiNoteNumber); + const float v = juce::jlimit (0.0f, 1.0f, velocity); + // Velocity response: 24dB range with perceptual curve for expressive but audible dynamics + const float velPerceptual = std::sqrt (v); + velocityGain = juce::Decibels::decibelsToGain (juce::jmap (velPerceptual, 0.0f, 1.0f, -18.0f, 0.0f)); + const float cents = juce::jlimit (-100.0f, 100.0f, + pitchCompOffsetCents + pitchCompSlopeCents * ((float) midiIdx - 60.0f) + + noteOffsetsCents[(size_t) midiIdx]); + const double pitchComp = std::pow (2.0, cents / 1200.0); + currentFrequency = juce::MidiMessage::getMidiNoteInHertz (midiIdx) * pitchComp; + setFrequency (currentFrequency); + phase = 0.0f; + updateParams(); + adsr.noteOn(); +} + +void FluteVoice::stopNote (float, bool allowTailOff) +{ + if (allowTailOff) adsr.noteOff(); + else { adsr.reset(); clearCurrentNote(); } +} + +void FluteVoice::renderNextBlock (juce::AudioBuffer& buffer, int startSample, int numSamples) +{ + if (! adsr.isActive()) { clearCurrentNote(); return; } + + updateParams(); + + auto* left = buffer.getWritePointer (0, startSample); + auto* right = (buffer.getNumChannels() > 1) ? buffer.getWritePointer (1, startSample) : nullptr; + + for (int i = 0; i < numSamples; ++i) + { + const float sine = std::sin (phase); + const float saw = 2.0f * (phaseOverPi - std::floor (phaseOverPi + 0.5f)); + const float square = (sine >= 0.0f ? 1.0f : -1.0f); + + float osc = sine * wSine + saw * wSaw + square * wSquare; + + if (preNoiseLin > 0.0f) + osc += preNoiseLin * (randomUniform() * 2.0f - 1.0f); + + float filtered = DebugToggles::kEnableVaFilter ? svf.processSample (0, osc) : osc; + + float env = adsr.getNextSample(); + float y = filtered * env * velocityGain; + + left[i] += y; + if (right) right[i] += y; + + phase += phaseDelta; + if (phase > juce::MathConstants::twoPi) + phase -= juce::MathConstants::twoPi; + phaseOverPi = phase / juce::MathConstants::pi; + } + + if (! adsr.isActive()) clearCurrentNote(); +} + +void FluteVoice::prepare (double sr, int samplesPerBlock, int /*numChannels*/) +{ + sampleRate = sr; + adsr.setSampleRate (sr); + + juce::dsp::ProcessSpec spec; + spec.sampleRate = sr; + spec.maximumBlockSize = (juce::uint32) samplesPerBlock; + spec.numChannels = 1; // voice is mono + svf.reset(); + svf.prepare (spec); + + setFrequency (currentFrequency); +} + +void FluteVoice::setFrequency (double hz) +{ + currentFrequency = hz * masterTuneFactor; + phaseDelta = (float) (juce::MathConstants::twoPi * currentFrequency / sampleRate); +} + +float FluteVoice::randomUniform() +{ + rng = 1664525u * rng + 1013904223u; + return (rng >> 8) * (1.0f / 16777216.0f); +} + +void FluteVoice::updateParams() +{ + float s = apvts.getRawParameterValue (ParamIDs::oscSine)->load(); + float sa = apvts.getRawParameterValue (ParamIDs::oscSaw)->load(); + float sq = apvts.getRawParameterValue (ParamIDs::oscSquare)->load(); + float sum = std::max (0.0001f, s + sa + sq); + wSine = s / sum; wSaw = sa / sum; wSquare = sq / sum; + + juce::ADSR::Parameters p; + p.attack = apvts.getRawParameterValue (ParamIDs::attack)->load(); + p.decay = apvts.getRawParameterValue (ParamIDs::decay)->load(); + p.sustain = apvts.getRawParameterValue (ParamIDs::sustain)->load(); + p.release = apvts.getRawParameterValue (ParamIDs::release)->load(); + adsr.setParameters (p); + + float cut = apvts.getRawParameterValue (ParamIDs::cutoff)->load(); + float res = apvts.getRawParameterValue (ParamIDs::resonance)->load(); + svf.setCutoffFrequency (cut); + svf.setResonance (res); + + if (DebugToggles::kEnableNoiseDb) + { + float nDb = apvts.getRawParameterValue (ParamIDs::noiseDb)->load(); + preNoiseLin = juce::Decibels::decibelsToGain (nDb); + } + else + { + preNoiseLin = 0.0f; + } +} + +//============================================================================== +// PM (with amplitude ADSR) +void WaveguideFlute::prepare (double sr, int blockSize, int numCh) +{ + sampleRate = sr; + (void) blockSize; (void) numCh; + setFrequency (440.0); + noiseGain = 0.02f; + jetFeedback = 0.2f; + dc.reset (sr); + + adsr.setSampleRate (sr); + adsr.setParameters (envParams); +} + +void WaveguideFlute::setFrequency (double hz) +{ + frequency = hz * masterTuneFactor; + double lenSamples = sampleRate / frequency; + int len = (int) juce::jmax (16.0, std::floor (lenSamples + 0.5)); + delay.setSize (1, len + 4); + writePos = 0; + for (int i = 0; i < delay.getNumSamples(); ++i) delay.setSample (0, i, 0.0f); +} + +void WaveguideFlute::setEnvParams (float a, float d, float s, float r) +{ + envParams.attack = juce::jmax (0.0f, a); + envParams.decay = juce::jmax (0.0f, d); + envParams.sustain = s; + envParams.release = juce::jmax (0.0f, r); + baseRelease = envParams.release; + adsr.setParameters (envParams); +} + +void WaveguideFlute::setReleaseScale (float baseR, float scale) +{ + baseRelease = juce::jlimit (0.030f, 7.000f, baseR); + envParams.release = juce::jlimit (0.030f, 7.000f, baseRelease * juce::jlimit (0.2f, 4.0f, scale)); + adsr.setParameters (envParams); +} + +void WaveguideFlute::noteOn (int midi, float vel) +{ + const int midiIdx = juce::jlimit (0, 127, midi); + const float v = juce::jlimit (0.0f, 1.0f, vel); + // Velocity response: 24dB range with perceptual curve for expressive but audible dynamics + const float velPerceptual = std::sqrt (v); + velocityGain = juce::Decibels::decibelsToGain (juce::jmap (velPerceptual, 0.0f, 1.0f, -18.0f, 0.0f)); + const float cents = juce::jlimit (-100.0f, 100.0f, + pitchCompOffsetCents + pitchCompSlopeCents * ((float) midiIdx - 60.0f) + + noteOffsetsCents[(size_t) midiIdx]); + const double pitchComp = std::pow (2.0, cents / 1200.0); + setFrequency (juce::MidiMessage::getMidiNoteInHertz (midiIdx) * pitchComp); + active = true; // run the loop + phase = 0.0f; + adsr.noteOn(); // start amplitude envelope +} + +void WaveguideFlute::noteOff() +{ + // Do not stop immediately; let ADSR release tail the sound. + adsr.noteOff(); +} + +void WaveguideFlute::render (juce::AudioBuffer& buffer, int start, int num) +{ + // If we are neither running nor have an active envelope, nothing to do + if (!active && !adsr.isActive()) + return; + + auto* L = buffer.getWritePointer (0, start); + auto* R = (buffer.getNumChannels() > 1 ? buffer.getWritePointer (1, start) : nullptr); + + for (int i = 0; i < num; ++i) + { + const int len = delay.getNumSamples(); + const int readPos = (writePos + 1) % len; + float y = delay.getSample (0, readPos); + + // Jet nonlinearity (simple tanh) + weak noise excitation + float breath = noiseGain * (randomUniform() * 2.0f - 1.0f); + float jet = std::tanh (y * 1.6f + breath); + + // Feedback + loss + float next = 0.996f * (jet * jetFeedback + y * (1.0f - jetFeedback)); + + // DC-block + if (DebugToggles::kEnablePmDcBlock) + next = dc.process (next); + + // write back + delay.setSample (0, writePos, next); + writePos = (writePos + 1) % len; + + // Amplitude ADSR + float env = adsr.getNextSample(); + float out = next * env * velocityGain; + + L[i] += out; + if (R) R[i] += out; + } + + // If envelope has fully finished, stop running the loop next time + if (!adsr.isActive()) + active = false; +} + +float WaveguideFlute::randomUniform() +{ + rng = 1664525u * rng + 1013904223u; + return (rng >> 8) * (1.0f / 16777216.0f); +} + +//============================================================================== +// pm2 stiff-string +// FIX #1 & #4: Removed static beginSharedBuses - buses now owned by processor and passed to voices + +void Pm2StringBank::prepare (double sr, int blockSize, int numCh) +{ + sampleRate = sr; + adsr.setSampleRate (sr); + adsr.setParameters (envParams); + postLpfEnv.setSampleRate (sr); + postLpfEnv.setParameters (postLpfEnvParams); + active = false; + keyHeld = false; + useReleaseLoopGain = false; + releaseDelaySamples = 0; + noteLifeSamples = 0; + damperDelaySamples = 0; + sustainPedalDown = false; + loopEnergySmoothed = 0.0f; + if (sampleRate > 0.0) + { + pedalChangeSamplesTotal = juce::jmax (1, (int) std::round (0.015 * sampleRate)); + pedalChangeSamplesRemaining = 0; + pedalChangeFade = 1.0f; + const float tauSamples = (float) (0.050 * sampleRate); // ~50ms smoothing + loopEnergySmoothCoeff = tauSamples > 1.0f ? (1.0f - std::exp (-1.0f / tauSamples)) : 1.0f; + } + else + { + pedalChangeSamplesTotal = 0; + pedalChangeSamplesRemaining = 0; + pedalChangeFade = 1.0f; + loopEnergySmoothCoeff = 1.0f; + } + const int maxDelayLen = juce::jmax (8, (int) std::ceil ((sampleRate / 20.0) + 4.0)); + for (auto& s : strings) + { + s.delay.clear(); + s.delay.reserve ((size_t) maxDelayLen); + s.writePos = 0; + s.delaySamples = 0.0; + s.loopGain = 0.999f; + s.baseGain = 1.0f; + s.panGainL = 0.7071f; + s.panGainR = 0.7071f; + s.loopGainSmoothed = 0.999f; + s.damperLossPrev = damper.lossOff; + s.damperSoftenCountdown = 0; + s.damperSoftenState = 0.0f; + s.apStages = 1; + s.dc.reset (sr); + for (auto& ap : s.ap) ap = {}; + s.lpState = 0.0f; + s.lpCoeff = 0.25f; + s.interpAlpha = 0.0f; // Thiran allpass interpolator coefficient + s.interpZ1 = 0.0f; // Thiran allpass interpolator state + s.toneInjectSamplesLeft = 0; + s.toneInjectPhase = 0.0f; + s.toneInjectPhaseDelta = 0.0f; + s.toneInjectGain = 0.0f; + s.hammer = {}; + s.energyGainSmoothed = 1.0f; + s.duplex.buf.clear(); + s.duplex.buf.reserve ((size_t) maxDelayLen); + // Initialize fundamental resonator state (IMPROVEMENT 2) + s.fundResonatorState1 = 0.0f; + s.fundResonatorState2 = 0.0f; + s.fundResonatorCoeff = 0.0f; + s.fundResonatorGain = 0.0f; + } + + juce::dsp::ProcessSpec spec { sr, (juce::uint32) blockSize, (juce::uint32) juce::jmax (1, numCh) }; + noteHpf.reset(); + noteHpf.prepare (spec); + noteHpf.setType (juce::dsp::StateVariableTPTFilterType::highpass); + noteHpf.setResonance (0.707f); + noteHpfNumChannels = (int) spec.numChannels; + auto prepBp = [&spec] (juce::dsp::StateVariableTPTFilter& f) + { + f.reset(); + f.prepare (spec); + f.setType (juce::dsp::StateVariableTPTFilterType::bandpass); + }; + prepBp (couplingBpL); + prepBp (couplingBpR); + prepBp (sympBpL); + prepBp (sympBpR); + updateNoteHpf (currentMidiNote); + lastOutL = 0.0f; + lastOutR = 0.0f; + postLpfStateL = 0.0f; + postLpfStateR = 0.0f; + + // Initialize pink noise state (IMPROVEMENT 1) + pinkNoiseState.fill (0.0f); + pinkNoiseCounter = 0; + + // Initialize body resonance noise state (IMPROVEMENT 3) + bodyNoiseState = 0.0f; + bodyNoiseLp1 = 0.0f; + bodyNoiseLp2 = 0.0f; + bodyNoiseHp = 0.0f; + bodyNoiseRng = 0x12345678; +} + +void Pm2StringBank::setParams (const PresetModel::PmString& p) +{ + params = p; +} + +void Pm2StringBank::setHammerParams (const PresetModel::HammerModel& h) +{ + hammer = h; +} + +void Pm2StringBank::setFeltParams (const PresetModel::FeltModel& f) +{ + if (DebugToggles::kEnableFelt) + { + felt = f; + } + else + { + felt.preload = 0.0f; + felt.stiffness = 1.0f; + felt.hysteresis = 0.0f; + felt.maxAmp = 10.0f; + } +} + +void Pm2StringBank::setDuplexParams (const PresetModel::Duplex& d) +{ + duplex = d; +} + +void Pm2StringBank::setWdfParams (const PresetModel::WdfModel& w) +{ + wdf = w; +} + +void Pm2StringBank::setCouplingParams (const PresetModel::Coupling& c) +{ + couplingGain = juce::jlimit (0.0f, 0.2f, c.gain); + couplingQ = juce::jlimit (0.2f, 5.0f, c.q); + sympGain = juce::jlimit (0.0f, 0.3f, c.sympGain); + sympHighDamp = juce::jlimit (0.0f, 1.0f, c.sympHighDamp); +} + +void Pm2StringBank::setDamperParams (const PresetModel::Damper& d) +{ + damper = d; + float tauSamples = (float) (damper.smoothMs * 0.001 * sampleRate); + damperSmoothCoeff = tauSamples > 1.0f ? (1.0f - std::exp (-1.0f / juce::jmax (1.0f, tauSamples))) : 1.0f; + damperLiftSmoothCoeff = damperSmoothCoeff; + damperSoftenSamples = (int) std::round (damper.softenMs * 0.001 * sampleRate); + damperSoftenA = std::exp (-2.0f * juce::MathConstants::pi * juce::jlimit (40.0f, 8000.0f, damper.softenHz) / (float) juce::jmax (20.0, sampleRate)); +} + +void Pm2StringBank::setDamperLift (float lift) +{ + damperLiftTarget = juce::jlimit (0.0f, 1.0f, lift); +} + +void Pm2StringBank::beginVoiceStealFade (float ms) +{ + const float clampedMs = juce::jlimit (2.0f, 80.0f, ms); + stealFadeSamples = juce::jmax (1, (int) std::round (clampedMs * 0.001f * sampleRate)); + stealFadeRemaining = stealFadeSamples; +} + +void Pm2StringBank::setSoftPedal (bool down, const PresetModel::UnaCorda& una) +{ + softPedalDown = down; + unaCorda = una; +} + +void Pm2StringBank::setSustainPedalDown (bool down) +{ + if (sustainPedalDown != down) + { + if (pedalChangeSamplesTotal <= 0 && sampleRate > 0.0) + pedalChangeSamplesTotal = juce::jmax (1, (int) std::round (0.015 * sampleRate)); + pedalChangeSamplesRemaining = pedalChangeSamplesTotal; + pedalChangeFade = 0.0f; + } + sustainPedalDown = down; + if (! sustainPedalDown && ! keyHeld) + { + useReleaseLoopGain = true; + adsr.noteOff(); + postLpfEnv.noteOff(); + } +} + +void Pm2StringBank::setEnvParams (float attack, float decay, float sustain, float release) +{ + envParams.attack = juce::jmax (0.0f, attack); + envParams.decay = juce::jmax (0.0f, decay); + envParams.sustain = sustain; + envParams.release = juce::jmax (0.0f, release); + baseRelease = envParams.release; + adsr.setParameters (envParams); + + // Store decay for influencing physical model T60 + // Use the provided decay directly (no clamp) to scale T60. + if (envParams.decay > 0.0f) + decayTimeScale = juce::jlimit (0.5f, 1.5f, envParams.decay / 2.0f); + else + decayTimeScale = 0.0f; +} + +void Pm2StringBank::setReleaseScale (float baseR, float scale) +{ + baseRelease = juce::jlimit (0.030f, 7.000f, baseR); + envParams.release = juce::jlimit (0.030f, 7.000f, baseRelease * juce::jlimit (0.2f, 4.0f, scale)); + adsr.setParameters (envParams); +} + +void Pm2StringBank::updateNoteHpf (int midiNoteNumber) +{ + if (sampleRate <= 0.0) + return; + + const float note = (float) juce::jlimit (0, 127, midiNoteNumber); + const float norm = juce::jlimit (0.0f, 1.0f, (note - 21.0f) / (108.0f - 21.0f)); + const float eased = std::pow (norm, 1.5f); + noteHpfCutoff = juce::jlimit (30.0f, 70.0f, 30.0f + eased * (70.0f - 30.0f)); + noteHpf.setCutoffFrequency (noteHpfCutoff); +} + +void Pm2StringBank::resizeString (StringState& s, double samples) +{ + const int len = juce::jmax (8, (int) std::ceil (samples + 4.0)); + s.delay.resize ((size_t) len); + std::fill (s.delay.begin(), s.delay.end(), 0.0f); + s.writePos = 0; + s.delaySamples = samples; + // FIX #5: Removed dc.reset() - resetting the DC blocker on every note-on + // causes clicks when there's residual DC offset being filtered. + // DC blocker state is now only reset in prepare(). + + // Thiran allpass interpolator coefficient for fractional delay + // Formula: alpha = (1 - d) / (1 + d) where d is fractional delay in (0, 1) + const double intPart = std::floor (samples); + double frac = samples - intPart; + // Ensure frac is in valid range for stable allpass (avoid d=0 or d=1) + frac = juce::jlimit (0.1, 0.9, frac); + s.interpAlpha = (float) ((1.0 - frac) / (1.0 + frac)); + s.interpZ1 = 0.0f; // Reset interpolator state +} + +static inline float mixLinear (float a, float b, float t) +{ + return a * (1.0f - t) + b * t; +} + +static inline float softClip (float x, float limit) +{ + if (! DebugToggles::kEnablePm2SoftClip) + return x; + const float safeLimit = juce::jmax (1.0e-6f, limit); + return safeLimit * FastMath::fastTanh (x / safeLimit); +} + +static PresetModel::WdfModel sanitizeWdf (PresetModel::WdfModel w) +{ + auto c = PresetModel::clamp; + auto finiteOr = [] (float v, float fallback) { return std::isfinite (v) ? v : fallback; }; + w.enabled = w.enabled; + w.blend = c (finiteOr (w.blend, 0.0f), 0.0f, 1.0f); + w.loss = c (finiteOr (w.loss, 0.0f), 0.0f, 0.1f); + w.bridgeMass = c (finiteOr (w.bridgeMass, 1.0f), 0.1f, 10.0f); + w.plateStiffness = c (finiteOr (w.plateStiffness, 1.0f), 0.1f, 5.0f); + return w; +} + +// Lightweight WDF-ish burst generator (offline prototype port) +static std::vector buildWdfBurst (double sampleRate, + double baseHz, + float velocity, + int totalExcite, + float loss, + float bridgeMass, + float plateStiffness, + uint32_t& rng) +{ + std::vector out ((size_t) juce::jmax (1, totalExcite), 0.0f); + if (! std::isfinite (sampleRate) || sampleRate <= 0.0 || ! std::isfinite (baseHz) || baseHz <= 0.0f) + return out; + + const double delaySamples = sampleRate / juce::jmax (20.0, baseHz); + const int delayLen = juce::jmax (8, (int) std::ceil (delaySamples)); + std::vector delay ((size_t) delayLen, 0.0f); + int write = 0; + const float frac = (float) (delaySamples - std::floor (delaySamples)); + + const float loopLoss = juce::jlimit (0.0f, 0.1f, loss); + const float loopGain = std::exp (-loopLoss); + float lpState = 0.0f; + const float lpCoeff = 0.25f; + + auto rand01 = [&rng]() -> float + { + rng = 1664525u * rng + 1013904223u; + return (float) ((rng >> 8) * (1.0 / 16777216.0)); // [0..1) + }; + + const float R_h = 1.0f; + const float R_s = 1.0f + loopLoss * 10.0f; + const float R_b = juce::jlimit (0.05f, 10.0f, bridgeMass); + float bx = 0.0f, bv = 0.0f; + const float mass = juce::jmax (0.05f, bridgeMass); + const float stiff = juce::jmax (0.1f, plateStiffness); + const float damp = loopLoss * 20.0f; + + const float preload = 0.08f; + const float stiffness = 2.4f; + const float hysteresis = 0.15f; + const float feltMax = 1.4f; + const float maxDelta = feltMax * 0.5f; + float feltState = 0.0f; + + const int attack = juce::jmax (1, (int) std::round (0.006 * sampleRate)); + const int decay = juce::jmax (1, (int) std::round (0.010 * sampleRate)); + const int release= juce::jmax (1, (int) std::round (0.006 * sampleRate)); + + auto delayRead = [&delay, delayLen](int idx, float fracPart) -> float + { + const int i0 = (idx + delayLen) % delayLen; + const int i1 = (i0 + 1) % delayLen; + return delay[(size_t) i0] * (1.0f - fracPart) + delay[(size_t) i1] * fracPart; + }; + + for (int n = 0; n < (int) out.size(); ++n) + { + float env = 0.0f; + if (n < attack) env = (float) n / (float) attack; + else if (n < attack + decay) + { + const float t = (float) (n - attack) / (float) decay; + env = 1.0f - 0.9f * t; + } + else if (n < attack + decay + release) + { + const float t = (float) (n - attack - decay) / (float) release; + env = juce::jmax (0.0f, 0.1f * (1.0f - t)); + } + + // hammer noise + float noise = (rand01() * 2.0f - 1.0f) * env * std::pow (juce::jlimit (0.0f, 1.0f, velocity), 1.2f); + + // felt-ish shaping + float mag = std::pow (preload + std::abs (noise), stiffness) - std::pow (preload, stiffness); + float shaped = (1.0f - hysteresis) * mag + hysteresis * feltState; + feltState = shaped; + float feltOut = softClip ((float) std::copysign (shaped, noise), feltMax); + float delta = feltOut - feltState; + delta = softClip (delta, maxDelta); + feltOut = feltState + delta; + + float a_h = feltOut * R_h; + float a_s = delayRead (write, frac); + float a_b = bx; // displacement proxy + + const float denom = (1.0f / R_h) + (1.0f / R_s) + (1.0f / R_b); + const float Vj = (a_h / R_h + a_s / R_s + a_b / R_b) / juce::jmax (1.0e-6f, denom); + const float b_h = 2.0f * Vj - a_h; + const float b_s = 2.0f * Vj - a_s; + const float b_b = 2.0f * Vj - a_b; + (void) b_h; // reserved for future refinement + + // bridge integrator (semi-implicit) + float drive = (b_b - a_b) * 0.5f / juce::jmax (1.0e-6f, R_b); + float dt = 1.0f / (float) sampleRate; + float acc = (drive - stiff * bx - damp * bv) / juce::jmax (0.05f, mass); + bv = softClip (bv + dt * acc, 20.0f); + bx = softClip (bx + dt * bv, 5.0f); + + // loop + lpState = lpState + lpCoeff * (b_s - lpState); + float loopSample = lpState * loopGain; + delay[(size_t) write] = loopSample; + write = (write + 1) % delayLen; + + float mixed = mixLinear (b_s * 0.5f + a_s * 0.5f, bx * 0.5f, 0.4f); + if (! std::isfinite (mixed)) + mixed = 0.0f; + out[(size_t) n] = softClip (mixed, 2.0f); + } + + return out; +} + +void Pm2StringBank::noteOn (int midiNoteNumber, float velocity) +{ + keyHeld = true; + keyReleaseSamplesRemaining = 0; + keyOffFadeSamplesRemaining = 0; + pendingNoteOff = false; + minNoteOffRemaining = minNoteDurationSamples; + currentMidiNote = midiNoteNumber; + useReleaseLoopGain = false; + loopEnergySmoothed = 0.0f; + // If this voice was previously stolen, cancel any pending steal fade. + stealFadeRemaining = 0; + stealFadeSamples = 0; + stealInProgress = false; + updateNoteHpf (midiNoteNumber); + damperLiftSmoothed = damperLiftTarget; + + // CPU OPTIMIZATION: String count varies by register for CPU efficiency + // - Treble (>= C7/96): 2 strings (real pianos use 2-3, less difference audible) + // - Bass gradual fade: G2 down to D#2, third string fades from 80% to 0% + // - Deep bass (<= D#2/39): 2 strings only + // - Mid range: Full 3 strings for rich chorus effect + const int trebleSplitNote = 96; // C7 - use 2 strings above this + const int bassFadeStartNote = 43; // G2 - third string at 80% + const int bassFadeEndNote = 36; // C2 - switch to 2 strings at and below this + + // Calculate third string gain scale for bass notes (1.0 = full, 0.0 = muted) + // Gradual fade: G2=0.8, F#2=0.6, F2=0.4, E2=0.2, D#2=0.0, etc. + if (midiNoteNumber >= trebleSplitNote) + { + currentNumStrings = 2; + thirdStringGainScale = 0.0f; + } + else if (midiNoteNumber > bassFadeStartNote) + { + // Above G2: full third string + currentNumStrings = 3; + thirdStringGainScale = 1.0f; + } + else if (midiNoteNumber <= bassFadeEndNote) + { + // C2 and below: use only 2 strings + currentNumStrings = 2; + thirdStringGainScale = 0.0f; + } + else + { + // Gradual fade zone from G2 (43) down to C#2 (37) + // G2=43 -> 0.80, F#2=42 -> 0.60, F2=41 -> 0.40, E2=40 -> 0.20 + // D#2=39 -> 0.0 (effectively 2 strings), D2=38 -> 0.0, C#2=37 -> 0.0 + currentNumStrings = 3; // Keep 3 strings for smooth crossfade + const float fadeRange = (float) (bassFadeStartNote - bassFadeEndNote); // 43-36 = 7 semitones + const float notePos = (float) (midiNoteNumber - bassFadeEndNote); // Position in fade zone + // Scale so G2 (43) = 0.8 and C#2 (37) = ~0.0 + thirdStringGainScale = juce::jlimit (0.0f, 0.8f, (notePos / fadeRange) * 0.8f); + + // If gain is very small, use 2 strings to save CPU + if (thirdStringGainScale < 0.05f) + { + currentNumStrings = 2; + thirdStringGainScale = 0.0f; + } + } + + const int midiIdx = juce::jlimit (0, 127, midiNoteNumber); + const float pitchCents = juce::jlimit (-100.0f, 100.0f, + pitchCompOffsetCents + pitchCompSlopeCents * ((float) midiIdx - 60.0f) + + noteOffsetsCents[(size_t) midiIdx]); + const double pitchComp = std::pow (2.0, pitchCents / 1200.0); + const double baseHz = juce::MidiMessage::getMidiNoteInHertz (midiIdx) * pitchComp * masterTuneFactor; + const float v = juce::jlimit (0.0f, 1.0f, velocity); + if (v < 0.4f) + lowVelSkip = (randomUniform() < 0.25f); + else + lowVelSkip = false; + // Velocity response with moderate dynamic range: + // - 24dB range keeps soft notes audible while still expressive + // - Applied perceptual curve (sqrt) so velocity feels more natural to play + const float velPerceptual = std::sqrt (v); + velocityGain = juce::Decibels::decibelsToGain (juce::jmap (velPerceptual, 0.0f, 1.0f, -18.0f, 0.0f)); + if (PhysicsToggles::kUsePerNotePhysics) + { + const int midiVel = juce::jlimit (0, 127, (int) std::round (v * 127.0f)); + const float hammerVel = PianoPhysics::Velocity::midiToHammerVelocity (midiVel); + const float brightnessTilt = juce::jlimit (0.0f, 1.0f, PianoPhysics::Velocity::getBrightnessTilt (hammerVel)); + const float minHzBase = (float) baseHz * 1.5f; + const float maxHzBase = (float) baseHz * (4.0f + 6.0f * brightnessTilt); + float minHz = juce::jlimit (500.0f, 6000.0f, minHzBase); + float maxHz = juce::jlimit (2500.0f, 16000.0f, maxHzBase); + if (sustainPedalDown) + { + minHz *= 1.45f; + maxHz *= 1.35f; + } + postLpfMinHz = juce::jlimit (500.0f, 8000.0f, minHz); + postLpfMaxHz = juce::jlimit (2500.0f, 18000.0f, maxHz); + + const float t60 = PianoPhysics::StringDecay::getT60 ((float) baseHz, midiNoteNumber); + const float brightDecay = juce::jlimit (0.10f, 1.4f, t60 * 0.05f); + const float brightRelease = juce::jlimit (0.08f, 1.0f, brightDecay * 0.70f); + postLpfEnvParams.attack = 0.003f; + postLpfEnvParams.decay = brightDecay; + float sustain = juce::jlimit (0.08f, 0.35f, 0.12f + 0.18f * brightnessTilt); + if (sustainPedalDown) + sustain = juce::jlimit (0.15f, 0.60f, sustain + 0.14f); + postLpfEnvParams.sustain = sustain; + postLpfEnvParams.release = brightRelease; + postLpfEnv.setParameters (postLpfEnvParams); + } + noteFadeSamplesTotal = juce::jmax (1, (int) std::round (0.001 * sampleRate)); + noteFadeSamplesRemaining = noteFadeSamplesTotal; + postLpfEnv.noteOn(); + postLpfStateL = 0.0f; + postLpfStateR = 0.0f; + const float bpFreq = juce::jlimit (60.0f, 6000.0f, (float) baseHz * 1.1f); + couplingBpL.setCutoffFrequency (bpFreq); + couplingBpR.setCutoffFrequency (bpFreq); + couplingBpL.setResonance (couplingQ); + couplingBpR.setResonance (couplingQ); + sympBpL.setCutoffFrequency (bpFreq); + sympBpR.setCutoffFrequency (bpFreq); + sympBpL.setResonance (juce::jmax (0.3f, couplingQ * 0.8f)); + sympBpR.setResonance (juce::jmax (0.3f, couplingQ * 0.8f)); + // Lift velocity curve: 35% minimum excitation floor ensures soft notes have enough energy + // to properly excite the string model, while velocityGain handles actual loudness dynamics + const float velCurveRaw = std::pow (v, juce::jlimit (0.6f, 2.5f, hammer.gamma)); + const float velCurve = 0.35f + 0.65f * velCurveRaw; // 35% floor, scales to 100% + const float stiffnessScale = juce::jlimit (0.4f, 2.5f, 1.0f + hammer.stiffnessVelScale * (velCurve - 0.5f) * 2.0f); + const float preloadScale = juce::jlimit (0.3f, 2.5f, 1.0f + hammer.preloadVelScale * (velCurve - 0.5f) * 2.0f); + const float toneScale = juce::jlimit (0.5f, 2.0f, 1.0f + hammer.toneVelScale * (velCurve - 0.5f) * 2.0f); + const float toneHzEff = juce::jlimit (hammer.toneMinHz, hammer.toneMaxHz, hammer.toneHz * toneScale); + float contactScale = 1.0f; + if (PhysicsToggles::kUsePerNotePhysics) + { + const float exp = PianoPhysics::Hammer::getExponent (midiNoteNumber); + contactScale = juce::jlimit (0.6f, 1.4f, PianoPhysics::Hammer::getContactDurationScale (v, exp)); + } + const int hammerWindowSamples = juce::jmax (1, (int) std::round (juce::jlimit (5.0f, 45.0f, hammer.attackWindowMs * contactScale) * 0.001 * sampleRate)); + { + const float semisFrom60 = (float) midiNoteNumber - 60.0f; + // FIXED: Allow larger negative slopes for realistic treble attenuation + float slopeDbPerSemi = juce::jlimit (-0.25f, 0.1f, loudnessSlopeDbPerSemi); + slopeDbPerSemi *= 0.95f; // slightly lighter pitch-dependent attenuation + if (sustainPedalDown) + slopeDbPerSemi *= 0.85f; // slightly lighter sustain-dependent reduction + // FIXED: Tighter limits - prevent extreme boosts, allow more attenuation for treble + pitchLoudnessGain = juce::jlimit (0.6f, 1.25f, + juce::Decibels::decibelsToGain (semisFrom60 * slopeDbPerSemi)); + } + + // Frequency-dependent loop loss scalar (no filter in loop) + { + const float noteNorm = juce::jlimit (0.0f, 1.0f, ((float) midiNoteNumber - 21.0f) / (108.0f - 21.0f)); + const float curve = noteNorm * noteNorm; + float maxLoss = 0.004f; // mild high-note darkening without filter in loop + if (PhysicsToggles::kUsePerNotePhysics && sustainPedalDown) + maxLoss = 0.002f; + freqLossScalar = juce::jlimit (0.98f, 1.0f, 1.0f - maxLoss * curve); + // Compensate for higher loop rates so treble notes aren't over-damped. + const float lossComp = juce::jlimit (0.0f, 1.0f, (noteNorm - 0.55f) / 0.45f); + freqLossScalar = mixLinear (freqLossScalar, 1.0f, 0.65f * lossComp); + } + + // Note-dependent stereo width: bass wide, treble narrow + const float noteForWidth = (float) midiNoteNumber; + const float widthPos = juce::jlimit (0.0f, 1.0f, + (noteForWidth - params.stereoWidthNoteLo) + / juce::jmax (1.0f, params.stereoWidthNoteHi - params.stereoWidthNoteLo)); + stereoWidth = mixLinear (params.stereoWidthLow, params.stereoWidthHigh, widthPos); + + // FIXED: Calculate normalization factor for multi-string summing + // Sum the gains of all active strings and normalize so total energy stays consistent + { + float gainSum = 0.0f; + for (int i = 0; i < currentNumStrings; ++i) + gainSum += params.gain[(size_t) i]; + // Use sqrt for energy-based normalization (not amplitude-based) + // This prevents level buildup when multiple strings are summed + const float rawNorm = (gainSum > 0.001f) ? (1.0f / std::sqrt (gainSum)) : 1.0f; + stringGainNorm = mixLinear (1.0f, rawNorm, 0.6f); + } + + for (int i = 0; i < currentNumStrings; ++i) + { + float detune = params.detuneCents[(size_t) i]; + if (softPedalDown) + detune += unaCorda.detuneCents; + double hz = baseHz * std::pow (2.0, detune / 1200.0); + double targetDelay = sampleRate / juce::jmax (20.0, hz); + + // Inharmonicity-driven dispersion for physical realism. + float g = 0.0f; + if (PhysicsToggles::kUsePerNotePhysics) + { + const float bCoeff = PianoPhysics::Inharmonicity::getB (midiNoteNumber); + g = mapInharmonicityToDispersion (bCoeff, params.dispersionAmt); + } + else + { + // Heuristic dispersion curve (legacy). + const float noteNormDisp = juce::jlimit (0.0f, 1.0f, ((float) midiNoteNumber - 21.0f) / (108.0f - 21.0f)); + const float bassInharm = std::pow (juce::jmax (0.0f, 1.0f - noteNormDisp * 1.3f), 1.4f); + const float trebleOnset = 0.72f; // ~C6 + const float trebleInharm = (noteNormDisp > trebleOnset) + ? std::pow ((noteNormDisp - trebleOnset) / (1.0f - trebleOnset), 1.8f) * 0.25f + : 0.0f; + const float inharmCurve = bassInharm + trebleInharm; + g = juce::jlimit (0.0f, 0.40f, params.dispersionAmt * inharmCurve); + } + + // Additional frequency scaling for very low notes (more dispersion needed) + const float freqScale = juce::jlimit (0.7f, 1.2f, 1.0f + 0.2f * (1.0f - juce::jmin (1.0f, (float) hz / 200.0f))); + g *= freqScale; + + if (! DebugToggles::kEnablePm2Dispersion) + g = 0.0f; + if (highPolyMode) + g *= 0.4f; + + // Gentler taper for mid-high frequencies (was too aggressive) + float taper = (float) (1.0 - 0.20 * juce::jlimit (0.0, 1.0, (hz - 600.0) / 2200.0)); + const float noteNormDisp = juce::jlimit (0.0f, 1.0f, ((float) midiNoteNumber - 21.0f) / (108.0f - 21.0f)); + const float dispScale = mixLinear (1.0f, dispersionHighMult, std::pow (noteNormDisp, dispersionPow)); + float gScaled = juce::jlimit (0.0f, 0.50f, g * taper * dispScale); + const float dispTrebleComp = 1.0f - 0.35f * juce::jlimit (0.0f, 1.0f, ((float) midiNoteNumber - 72.0f) / 36.0f); + gScaled *= dispTrebleComp; + if (! DebugToggles::kEnablePm2Dispersion) + gScaled = 0.0f; + + // FIX: Smooth transition of allpass stages instead of hard cutoffs at specific notes + // Previously had abrupt changes at notes 76, 84, 88 causing audible discontinuities + int apStagesEffective = juce::jlimit (1, 4, params.apStages); + // Gradual reduction from note 72 to 96 (C5 to C7) + const float apFadeNorm = juce::jlimit (0.0f, 1.0f, ((float) currentMidiNote - 72.0f) / 24.0f); + if (apFadeNorm > 0.0f) + { + // Smoothly reduce stages: 4->3 by note 80, 3->2 by note 88, 2->1 by note 96 + const float targetStages = 4.0f - apFadeNorm * 3.0f; + apStagesEffective = juce::jmax (1, (int) std::round (targetStages)); + } + if (economyMode && currentMidiNote > 72) + apStagesEffective = juce::jmin (apStagesEffective, 2); + if (economyMode && currentMidiNote > 84) + apStagesEffective = 1; + if (highPolyMode) + apStagesEffective = 1; + if (! DebugToggles::kEnablePm2Dispersion) + apStagesEffective = 0; + + // Improved group delay compensation: + // - Use 0.92 factor (was 0.55) to better match actual allpass delay + // - Add frequency-dependent correction for higher notes + const float baseCompensation = 0.92f; + const float freqCorrection = 1.0f + 0.08f * juce::jlimit (0.0f, 1.0f, (float)(hz - 200.0) / 2000.0f); + float groupDelay = (apStagesEffective > 0 && gScaled > 0.0f) + ? baseCompensation * freqCorrection * (float) apStagesEffective * (1.0f - gScaled) / (1.0f + gScaled) + : 0.0f; + double delaySamples = juce::jmax (8.0, targetDelay - (double) groupDelay); + auto& s = strings[(size_t) i]; + resizeString (s, delaySamples); + // Option 1: Initialize energy limiter - calibration window captures peak during first 100ms + const int calibrationMs = 200; + s.energyCalibSamplesLeft = (int) (sampleRate * calibrationMs / 1000.0); + s.energyCalibComplete = false; + s.energyPeak = 0.0f; + s.energySmoothed = 0.0f; + s.energyGainSmoothed = 1.0f; + + // ==================================================================== + // FREQUENCY-NORMALIZED LOSS MODEL - REALISTIC PIANO SUSTAIN + // A real grand piano has very long sustain times: + // - Low notes (C1-C2): 25-40 seconds + // - Mid notes (C3-C4): 18-25 seconds + // - High notes (C6-C7): 12-18 seconds + // The GUI Decay control scales this via decayTimeScale. + // ==================================================================== + + // Base T60 varies with pitch - lower notes sustain longer (like a real piano) + // Reference: A4 (440 Hz, MIDI 69) gets base T60 of ~22 seconds + const float midiNote = 12.0f * std::log2 ((float) hz / 440.0f) + 69.0f; + const float noteNorm = juce::jlimit (0.0f, 1.0f, (midiNote - 21.0f) / 87.0f); // A0 to C8 + + float pitchBasedT60 = 0.0f; + if (PhysicsToggles::kUsePerNotePhysics) + { + pitchBasedT60 = PianoPhysics::StringDecay::getT60 ((float) hz, midiNoteNumber); + } + else + { + // FIX: Further increased highT60 from 16s to 22s for much longer treble sustain + // Using an even gentler curve (power of 0.5) so mid/high notes keep most of their sustain + // Low notes: ~28s, Mid notes: ~24s, High notes: ~22s (much more even distribution) + const float lowT60 = 28.0f; // Increased from 26 + const float highT60 = 24.0f; // Smaller low/high spread for less pitch-dependent decay + // Use a very gentle curve - high notes should only lose ~20% of low note sustain + const float curvedNoteNorm = std::pow (noteNorm, 0.5f); // Even gentler curve (was 0.7) + pitchBasedT60 = lowT60 - curvedNoteNorm * (lowT60 - highT60); + } + + // Apply loss parameter and GUI decay control + const float effectiveLoss = juce::jmax (0.0025f, params.loss); + const float lossNorm = juce::jlimit (0.0f, 1.0f, (effectiveLoss - 0.0005f) / (0.02f - 0.0005f)); + float lossEffect = 1.0f - 0.3f * lossNorm; // Reduced from 0.4 - loss has less impact now + if (PhysicsToggles::kUsePhysicsDefaults) + lossEffect = 1.0f; + float targetT60_s = pitchBasedT60 * lossEffect * decayTimeScale; + // Global sustain scaler - reduced from 1.50 to shorten note duration slightly + const float globalT60Scale = 1.15f; // Was 1.50f - notes were sustaining too long + targetT60_s *= globalT60Scale; + if (PhysicsToggles::kUsePhysicsDefaults) + targetT60_s = juce::jmax (6.0f, targetT60_s); // Was 12.0f - reduced minimum sustain + // Extra treble sustain to avoid high-note truncation (applies above C5). + if (DebugToggles::kEnablePm2TrebleT60Boost) + { + const float trebleNorm = juce::jlimit (0.0f, 1.0f, ((float) midiNoteNumber - 72.0f) / 36.0f); + const float trebleT60Boost = 1.0f + 1.05f * trebleNorm; // up to +105% at C8 + targetT60_s *= trebleT60Boost; + } + + // Calculate loops per second for this string + const float loopsPerSecond = (float) hz; + + // Total loops during the target T60 period + const float totalLoopsInT60 = targetT60_s * loopsPerSecond; + + // For T60 (60 dB decay), we need: loopGain^totalLoopsInT60 = 10^(-60/20) = 0.001 + // So: loopGain = 0.001^(1/totalLoopsInT60) = exp(ln(0.001)/totalLoopsInT60) + // ln(0.001) ≈ -6.9078 + float loopGainCalc = std::exp (-6.9078f / juce::jmax (1.0f, totalLoopsInT60)); + if (PhysicsToggles::kUsePhysicsDefaults) + loopGainCalc = juce::jlimit (0.98f, 0.99999f, loopGainCalc); + + // FIX: Add a small pitch-dependent boost to loop gain for higher notes + // This compensates for the cumulative effect of per-loop filtering at high frequencies + if (DebugToggles::kEnablePm2TrebleLoopGainBoost) + { + const float trebleBoostStart = 60.0f; // Start boosting above middle C + const float trebleBoostAmount = 0.0001f * juce::jmax (0.0f, midiNote - trebleBoostStart); + loopGainCalc = juce::jlimit (0.95f, 0.99999f, loopGainCalc + trebleBoostAmount); + } + // Extra high-note loop gain to counter per-loop filtering compounding. + if (DebugToggles::kEnablePm2TrebleLoopGainComp) + { + const float loopGainComp = 0.00024f * juce::jlimit (0.0f, 1.0f, (midiNote - 72.0f) / 36.0f); + loopGainCalc = juce::jlimit (0.95f, 0.99999f, loopGainCalc + loopGainComp); + } + + s.loopGainBase = loopGainCalc; + + s.loopGainRelease = juce::jlimit (0.90f, 0.99999f, s.loopGainBase * juce::jlimit (1.0f, 4.0f, releaseExtension)); + s.loopGain = s.loopGainBase; + s.loopGainSmoothed = s.loopGainBase; + s.damperLossPrev = damper.lossOff; + s.damperLossSmoothed = damper.lossOff; + s.damperSoftenCountdown = 0; + s.damperSoftenState = 0.0f; + + // LPF coefficient: frequency-normalized for consistent tone across the keyboard + // FIX: Made filter much gentler for high notes to preserve sustain + // High notes iterate through the loop many more times per second, so aggressive + // filtering compounds quickly and kills the sound + const float refHz = 440.0f; + const float freqRatio = refHz / juce::jmax (20.0f, (float) hz); + // IMPROVEMENT 4: Gentler high-frequency rolloff for more realistic harmonic content + // Real piano strings have a gradual high-frequency decay, not aggressive filtering + // Higher baseLpCoeff = less filtering per sample = more upper harmonics preserved + const float baseLpCoeff = 0.92f; // Increased from 0.88 for more HF content + + // Gentler frequency compensation: high notes need much less filtering + // because they iterate through the loop many more times per second + const float freqCompensation = 1.0f / juce::jmax (0.75f, std::pow (freqRatio, 0.08f)); + + // Velocity-dependent brightness: harder hits = brighter tone (more upper harmonics) + // This simulates how harder hammer strikes excite more high-frequency content + const float velBrightness = 1.0f + 0.025f * (velCurve - 0.5f); + + const float lpTrebleComp = 1.0f + 0.08f * juce::jlimit (0.0f, 1.0f, (midiNote - 72.0f) / 36.0f); + s.lpCoeff = juce::jlimit (0.82f, 0.998f, baseLpCoeff * freqCompensation * velBrightness * lpTrebleComp); + s.lpState = 0.0f; + + // dispersion allpass coefficient scaled by dispersionAmt and pitch + // reuse g + s.apStages = apStagesEffective; + for (int k = 0; k < s.apStages; ++k) { s.ap[(size_t) k].g = gScaled; s.ap[(size_t) k].z1 = 0.0f; } + for (int k = s.apStages; k < 4; ++k) { s.ap[(size_t) k].g = 0.0f; s.ap[(size_t) k].z1 = 0.0f; } + + // Hammer excitation: either continuous interaction or simplified burst + const float refDelaySamples = 100.0f; + const float actualDelaySamples = (float) s.delay.size(); + // FIX: Made high note attenuation much gentler - was pow(..., 0.4) which cut high notes to ~50% + // Now using pow(..., 0.2) so high notes retain ~75% of excitation energy + const float highNoteAtten = std::pow (juce::jmin (1.0f, actualDelaySamples / refDelaySamples), 0.01f); + float exciteGain = juce::jlimit (0.0f, 1.4f, hammer.force * velCurve * highNoteAtten); // Reduced limit from 1.8 + if (PhysicsToggles::kUsePhysicsDefaults) + exciteGain *= 0.85f; // Was 1.2f - reduced to lower overall levels + // Normalize excitation across the keyboard so hard notes land closer in level. + const float noteNormExcite = juce::jlimit (0.0f, 1.0f, ((float) midiNoteNumber - 21.0f) / (108.0f - 21.0f)); + const float exciteNorm = 1.10f - 0.20f * noteNormExcite; // gentle high-note reduction only + exciteGain = juce::jlimit (0.0f, 1.8f, exciteGain * exciteNorm); + // Ensure all notes retain a minimum excitation floor for audible transient + // Reduced from 0.22f base to lower overall levels + const float noteNormFloor = juce::jlimit (0.0f, 1.0f, (midiNoteNumber - 21.0f) / 87.0f); + const float exciteFloor = 0.15f + 0.08f * noteNormFloor; // Was 0.22f + 0.10f + exciteGain = juce::jmax (exciteGain, exciteFloor); + if (PhysicsToggles::kUsePhysicsDefaults) + { + const float toneHz = (float) (sampleRate / juce::jmax (8.0, delaySamples)); + s.toneInjectPhase = 0.0f; + s.toneInjectPhaseDelta = juce::MathConstants::twoPi * toneHz / (float) sampleRate; + s.toneInjectSamplesLeft = (int) std::round (0.015f * sampleRate); // Reduced from 0.020 + s.toneInjectGain = 0.12f * exciteGain; // Reduced from 0.20f + } + + const float attackMs = juce::jlimit (1.0f, 12.0f, hammer.attackMs * contactScale); + const float timeScale = juce::jlimit (0.95f, 1.0f, + std::pow (actualDelaySamples / refDelaySamples, 0.25f)); // gentler treble shortening + const int attackSamples = juce::jmax (1, (int) std::round (attackMs * 0.001 * sampleRate * timeScale)); + const int decaySamples = juce::jmax (1, (int) std::round (0.012 * sampleRate * timeScale)); + const int releaseSamples = juce::jmax (1, (int) std::round (0.005 * sampleRate * timeScale)); + juce::ignoreUnused (releaseSamples); + + const int minExciteSamples = (int) std::round (0.18 * sampleRate); // ensure audible transient on high notes + const int totalExcite = juce::jmax ((int) s.delay.size(), minExciteSamples); + + // IMPROVEMENT 7: Velocity-Dependent Harmonic Content + // Real pianos produce different harmonic spectra at different velocities + // Soft hits: darker tone, fewer upper partials (felt absorbs high frequencies) + // Hard hits: brighter tone, more upper partials (felt compresses, acts harder) + const float velToneBoostBase = 1.0f + 0.6f * velCurve; // up to 60% brighter at ff + const float pitchToneScale = 1.0f + 0.25f * noteNorm; // higher notes naturally brighter + const float trebleToneComp = DebugToggles::kEnablePm2TrebleToneComp + ? (1.0f - 0.45f * juce::jlimit (0.0f, 1.0f, (midiNoteNumber - 72.0f) / 36.0f)) + : 1.0f; + const float velToneBoost = velToneBoostBase * (1.0f - 0.20f * noteNorm); + + // Base tone frequency from preset, scaled by velocity and pitch + const float toneHzScaled = toneHzEff * velToneBoost * pitchToneScale * trebleToneComp; + const float minToneHz = juce::jmax (toneHzScaled, (float) hz * 3.0f); + const float toneHzMax = mixLinear (20000.0f, 12000.0f, juce::jlimit (0.0f, 1.0f, (midiNoteNumber - 72.0f) / 36.0f)); + const float toneHz = juce::jlimit (2000.0f, toneHzMax, minToneHz); + const float theta = 2.0f * juce::MathConstants::pi * toneHz / (float) sampleRate; + const float alpha = std::exp (-theta); + float lpState = 0.0f; + s.feltState = 0.0f; + s.feltLastOut = 0.0f; + s.feltEnvPrev = 0.0f; + float preload = juce::jlimit (0.0f, 1.0f, felt.preload * preloadScale); + float preloadPow = std::pow (preload, juce::jlimit (1.0f, 5.0f, felt.stiffness)); + float stiffness = juce::jlimit (1.0f, 5.0f, felt.stiffness * stiffnessScale); + float hyst = juce::jlimit (0.0f, 0.6f, felt.hysteresis); + float maxAmp = juce::jlimit (0.2f, 4.0f, felt.maxAmp); + float maxDelta = maxAmp * 0.5f; + if (! DebugToggles::kEnablePm2FeltShaping) + { + preload = 0.0f; + preloadPow = 0.0f; + stiffness = 1.0f; + hyst = 0.0f; + maxAmp = 10.0f; + maxDelta = maxAmp * 0.5f; + } + + // FIX: Smooth autoSimplify transition - use gradual blend instead of hard cutoff at 84 + const float simplifyNorm = juce::jlimit (0.0f, 1.0f, ((float) midiNoteNumber - 80.0f) / 16.0f); + const bool autoSimplify = (simplifyNorm > 0.5f) || (v < 0.25f) || highPolyMode; + bool useSimplified = (hammer.simplifiedMode || autoSimplify) && DebugToggles::kEnablePm2SimplifiedBurst; + if (PhysicsToggles::kUsePhysicsDefaults) + useSimplified = false; + // Avoid simplified burst in high notes to reduce squeak/quack. + if (midiNoteNumber >= 72) + useSimplified = false; + // FIX: Previously disabled hammer excitation for notes >= C5 (MIDI 72), which caused + // high notes to sound thin/squeaky and truncated. The hammer-string interaction is + // essential for proper piano timbre at all pitches. + const bool disableExcitation = false; // Enable hammer for all notes + std::vector wdfBurst; + const auto wdfSafe = sanitizeWdf (wdf); + bool allowWdfBurst = true; + if (v < 0.6f) + { + // Skip 1 out of 4 low-velocity notes to reduce CPU in burst generation. + static uint32_t wdfSkipCounter = 0; + allowWdfBurst = ((wdfSkipCounter++ % 4u) != 3u); + } + const bool useWdf = DebugToggles::kEnablePm2WdfBurst + && DebugToggles::kEnablePm2SimplifiedBurst + && ! economyMode && ! highPolyMode + && wdfSafe.enabled && wdfSafe.blend > 1.0e-4f && allowWdfBurst + && midiNoteNumber < 96; // Extended from 84 to reduce split audibility + if (useSimplified && useWdf) + { + wdfBurst = buildWdfBurst (sampleRate, + hz, + juce::jlimit (0.0f, 1.0f, v), + totalExcite, + wdfSafe.loss, + wdfSafe.bridgeMass, + wdfSafe.plateStiffness, + rng); + for (auto& sample : wdfBurst) + { + if (! std::isfinite (sample)) + sample = 0.0f; + sample = juce::jlimit (-2.0f, 2.0f, sample); + } + } + const float wdfBlend = wdfSafe.blend; + const float wdfTrebleAtten = DebugToggles::kEnablePm2WdfTrebleAtten + ? (1.0f - 0.50f * juce::jlimit (0.0f, 1.0f, ((float) midiNoteNumber - 72.0f) / 36.0f)) + : 1.0f; + const int wdfRamp = juce::jmax (1, totalExcite / 4); + + if (useSimplified) + { + if (! DebugToggles::kEnablePm2HammerExcitation) + { + for (auto& sample : s.delay) sample = 0.0f; + } + else + { + int envAttack = juce::jmin (attackSamples, totalExcite / 3); + int envDecay = juce::jmin (decaySamples, totalExcite / 2); + envAttack = juce::jmax (1, (int) std::round (envAttack * 0.5f)); // steeper onset + envDecay = juce::jmax (1, (int) std::round (envDecay * 0.75f)); + const int envRelease = juce::jmax (0, totalExcite - envAttack - envDecay); + + const int delaySize = (int) s.delay.size(); + const int writeCount = juce::jmin (totalExcite, delaySize); + for (int n = 0; n < totalExcite; ++n) + { + float env = 0.0f; + if (n < envAttack) + env = (float) n / juce::jmax (1.0f, (float) envAttack); + else if (n < envAttack + envDecay) + { + const float t = (float) (n - envAttack) / juce::jmax (1.0f, (float) envDecay); + env = 1.0f - 0.85f * t; + } + else if (envRelease > 0) + { + const float t = (float) (n - envAttack - envDecay) / juce::jmax (1.0f, (float) envRelease); + env = juce::jmax (0.0f, 0.15f * (1.0f - t)); + } + + // IMPROVEMENT 1: Pink noise for more realistic spectral density + // Voss-McCartney approximation creates inter-harmonic energy + float whiteNoise = randomUniform() * 2.0f - 1.0f; + + // Update pink noise octave bands at different rates + pinkNoiseCounter++; + if ((pinkNoiseCounter & 0x01) == 0) + pinkNoiseState[0] = randomUniform() * 2.0f - 1.0f; + if ((pinkNoiseCounter & 0x03) == 0) + pinkNoiseState[1] = randomUniform() * 2.0f - 1.0f; + if ((pinkNoiseCounter & 0x0F) == 0) + pinkNoiseState[2] = randomUniform() * 2.0f - 1.0f; + float pinkNoise = (pinkNoiseState[0] + pinkNoiseState[1] + pinkNoiseState[2] + whiteNoise) * 0.25f; + + // Mix white (brightness/attack) and pink (body/warmth) based on velocity and pitch + // Lower velocity and lower notes get more pink noise for warmth + // Higher velocity and higher notes get more white noise for brightness + const float noteNormMix = juce::jlimit (0.0f, 1.0f, ((float) midiNoteNumber - 21.0f) / 87.0f); + const float pinkTrebleBoost = 0.12f * noteNormMix; + const float pinkMix = juce::jlimit (0.25f, 0.75f, + 0.45f - 0.15f * velCurve + 0.08f * (1.0f - noteNormMix) + + pinkTrebleBoost); + float noise = (whiteNoise * (1.0f - pinkMix) + pinkNoise * pinkMix) * env * exciteGain; + + lpState = lpState + (1.0f - alpha) * (noise - lpState); + + float mag = std::pow (preload + std::abs (lpState), stiffness) - preloadPow; + float shaped = (1.0f - hyst) * mag + hyst * s.feltState; + s.feltState = shaped; + + float envDeriv = env - s.feltEnvPrev; + s.feltEnvPrev = env; + float rateBoost = juce::jlimit (0.8f, 1.4f, 1.0f + envDeriv * 2.0f); + + float burst = softClip ((float) std::copysign (shaped * rateBoost, lpState), maxAmp); + float delta = burst - s.feltLastOut; + delta = softClip (delta, maxDelta); + burst = s.feltLastOut + delta; + s.feltLastOut = burst; + + // IMPROVEMENT 9: Add subtle attack transient noise + // Simulates felt compression noise and mechanical "thunk" character + // Most prominent in first few ms of note, filtered to mid-high frequencies + // FIX: Smooth fade-out from note 72 to 84 instead of hard cutoff at 76 + const float transientFade = 1.0f - juce::jlimit (0.0f, 1.0f, ((float) midiNoteNumber - 72.0f) / 12.0f); + if (transientFade > 0.01f && n < envAttack + envDecay / 2) + { + const float transientProgress = (float) n / (float) (envAttack + envDecay / 2); + // Quadratic decay envelope for transient + const float transientEnv = (1.0f - transientProgress) * (1.0f - transientProgress); + + // Generate transient noise scaled by velocity (harder hits = more thunk) + const float transientTrebleAtten = 1.0f - 0.85f * noteNormMix; + float transientNoise = (randomUniform() * 2.0f - 1.0f) * transientEnv * exciteGain * 0.12f * velCurve * transientTrebleAtten; + + // Simple highpass approximation at ~300Hz for "thunk" character + // Using differentiation: hp[n] = x[n] - x[n-1] * (1-coeff) + static thread_local float transientHpState = 0.0f; + if (n == 0) transientHpState = 0.0f; // Reset for each string + const float hpCoeff = 0.92f; // ~300Hz highpass + float hpOut = transientNoise - transientHpState * hpCoeff; + transientHpState = transientNoise; + + // FIX: Apply smooth fade for higher notes + burst += hpOut * transientFade; + } + + if (useWdf && n < (int) wdfBurst.size()) + { + const float ramp = juce::jlimit (0.0f, 1.0f, (float) n / (float) wdfRamp); + const float blend = juce::jlimit (0.0f, 1.0f, wdfBlend * wdfTrebleAtten * ramp); + burst = mixLinear (burst, wdfBurst[(size_t) n], blend); + } + if (n < writeCount) + s.delay[(size_t) n] = burst; + } + if (writeCount < delaySize) + { + for (int n = writeCount; n < delaySize; ++n) + s.delay[(size_t) n] = 0.0f; + } + } + } + else + { + for (auto& sample : s.delay) sample = 0.0f; + if (PhysicsToggles::kUsePhysicsDefaults) + { + const float periodSamples = (float) juce::jmax (4.0, s.delaySamples); + const int seedSamples = juce::jlimit (4, (int) s.delay.size(), + (int) std::round (periodSamples * 2.0f)); + const float seedAmp = 0.08f * exciteGain; // Reduced from 0.12f to lower levels + for (int n = 0; n < seedSamples; ++n) + s.delay[(size_t) n] = std::sin (juce::MathConstants::twoPi + * (float) n / periodSamples) * seedAmp; + } + if (! DebugToggles::kEnablePm2HammerExcitation || disableExcitation) + s.hammer.active = false; + else + s.hammer.active = true; + // FIX: Allow hammer interaction to continue beyond the delay line size + // This gives longer, more natural hammer-string contact especially for higher notes + // The interaction now runs for the full attack window + tail, not limited by delay line + const float pedalLift = juce::jlimit (0.0f, 1.0f, sustainPedalDown ? 1.0f : damperLiftTarget); + const float pedalTailScale = 1.0f + 0.6f * pedalLift; + const int hammerTailSamples = juce::jmax (1, (int) std::round (decaySamples * 4 * pedalTailScale)); // Longer tail when pedal is down + const int minHammerSamples = (int) std::round ((0.20f + 0.06f * pedalLift) * sampleRate); // 200-260ms interaction + s.hammer.samplesLeft = juce::jmax (minHammerSamples, hammerWindowSamples + hammerTailSamples); + s.hammer.samplesTotal = s.hammer.samplesLeft; + s.hammer.samplesElapsed = 0; + s.hammer.pos = 0.0f; + const float strikeBoost = juce::jlimit (0.90f, 1.05f, 0.95f + 0.15f * v); // softer onset to reduce spike + s.hammer.vel = exciteGain * 1.2f * strikeBoost; // Reduced from 1.6f to lower levels + s.hammer.pen = 0.0f; + float hammerMass = hammer.massKg; + float hammerK = hammer.contactStiffness; + float hammerExp = hammer.contactExponent; + float hammerDamping = hammer.contactDamping; + if (PhysicsToggles::kUsePerNotePhysics) + { + hammerMass = PianoPhysics::Hammer::getMass (midiNoteNumber); + hammerK = mapHammerStiffnessToModel (PianoPhysics::Hammer::getStiffness (midiNoteNumber)); + hammerExp = PianoPhysics::Hammer::getExponent (midiNoteNumber); + const float physHyst = PianoPhysics::Hammer::getHysteresis (midiNoteNumber); + hammerDamping = juce::jmap (physHyst, 0.08f, 0.18f, 4.0f, 8.0f); + } + + s.hammer.mass = juce::jlimit (0.005f, 0.08f, hammerMass); + s.hammer.k = juce::jlimit (200.0f, 20000.0f, hammerK * stiffnessScale); + s.hammer.exp = juce::jlimit (1.4f, 4.0f, hammerExp); + s.hammer.damping = juce::jlimit (0.5f, 40.0f, hammerDamping); + s.hammer.preload = juce::jlimit (0.0f, 1.0f, hammer.force * 0.5f + preload * 0.5f); + s.hammer.preload = juce::jlimit (0.0f, 1.0f, s.hammer.preload * preloadScale); + s.hammer.maxPen = juce::jlimit (0.0005f, 0.030f, hammer.maxPenetration); + s.hammer.toneAlpha = juce::jlimit (0.0f, 0.9999f, alpha); + s.hammer.toneState = 0.0f; + // FIX: Increased hammer gain significantly for stronger excitation + // This ensures the initial transient has enough energy to sustain + s.hammer.gain = 0.008f * juce::jlimit (0.9f, 1.25f, 0.9f + 0.35f * v); // Reduced from 0.012 to lower levels + s.hammer.gainSmoothed = DebugToggles::kEnablePm2HammerGainRamp ? 0.0f : s.hammer.gain; + s.hammer.simplified = false; + } + + const bool enableDuplex = (! economyMode) || currentMidiNote <= 100; + const float duplexGainDb = juce::jlimit (-20.0f, -6.0f, duplex.gainDb); + const float duplexGainLin = juce::Decibels::decibelsToGain (duplexGainDb); + // CPU optimisation: Only apply duplex to the first (loudest) string + // This preserves most of the afterlength effect while reducing cost by ~67% for 3-string setups + const bool useDuplex = DebugToggles::kEnablePm2Duplex + && ! economyMode && ! highPolyMode && enableDuplex + && (duplexGainDb > -19.5f) && (i == 0) && ! lowVelSkip; + if (useDuplex) + { + // duplex buffer length ~ afterlength + float ratio = juce::jlimit (1.1f, 4.0f, duplex.ratio); + double duplexDelay = delaySamples / ratio; + const int dlen = juce::jmax (4, (int) std::ceil (duplexDelay)); + s.duplex.buf.resize ((size_t) dlen); + std::fill (s.duplex.buf.begin(), s.duplex.buf.end(), 0.0f); + s.duplex.write = 0; + s.duplex.gain = juce::jlimit (0.0f, 0.5f, duplexGainLin); + // decayMs -> feedback factor + double decayMs = juce::jlimit (10.0, 400.0, (double) duplex.decayMs); + double tauSamples = (decayMs * 0.001) * sampleRate; + double fb = std::exp (-1.0 / juce::jmax (8.0, tauSamples)); + s.duplex.feedback = (float) juce::jlimit (0.0, 0.99, fb); + s.duplex.inputGain = 0.15f; + } + else + { + s.duplex.buf.clear(); + s.duplex.write = 0; + s.duplex.gain = 0.0f; + s.duplex.feedback = 0.0f; + s.duplex.inputGain = 0.0f; + } + + s.baseGain = params.gain[(size_t) i]; + // CPU OPTIMIZATION: Apply bass third string fade for notes below G2 + // This gradually reduces the third string's contribution in the bass register + if (i == 2) + s.baseGain *= thirdStringGainScale; + const float pan = juce::jlimit (-1.0f, 1.0f, params.pan[(size_t) i] * stereoWidth); + s.panGainL = std::sqrt (0.5f * (1.0f - pan)); + s.panGainR = std::sqrt (0.5f * (1.0f + pan)); + + // IMPROVEMENT 2: Initialize fundamental boost lowpass + // Uses lowpass extraction on the output to boost fundamental region + // Only the first string's state is used (for the summed output) + if (i == 0) + { + // Lowpass cutoff at ~1.5x fundamental to capture fundamental region + const float lpCutoffHz = juce::jlimit (40.0f, 800.0f, (float) hz * 1.5f); + + // One-pole lowpass coefficient: alpha = exp(-2*pi*fc/fs) + const float lpAlpha = std::exp (-2.0f * juce::MathConstants::pi * lpCutoffHz / (float) sampleRate); + s.fundResonatorCoeff = lpAlpha; + + // Pitch-dependent gain: boost more in mid-range where fundamental weakness is most noticeable + // Reduce boost for extreme bass (already fundamental-heavy) and extreme treble (short strings) + const float fundBoostNorm = 1.0f - std::abs ((float) midiNoteNumber - 60.0f) / 48.0f; + const float fundBoostClamped = juce::jmax (0.0f, fundBoostNorm); + s.fundResonatorGain = juce::jlimit (0.02f, 0.15f, 0.10f * fundBoostClamped); + + // Reset filter state + s.fundResonatorState1 = 0.0f; + s.fundResonatorState2 = 0.0f; + } + else + { + // Other strings don't use these (only string 0's state is used for output boost) + s.fundResonatorCoeff = 0.0f; + s.fundResonatorGain = 0.0f; + s.fundResonatorState1 = 0.0f; + s.fundResonatorState2 = 0.0f; + } + } + + // Option 5: Initialize anti-swell envelope - slow decay for high notes only + // Decay rate: 0 for notes below E4(64), increasing to ~0.5dB/sec at C8(108) + antiSwellEnv = 1.0f; + if (DebugToggles::kEnablePm2AntiSwell) + { + const float antiSwellNoteNorm = DebugToggles::kEnablePm2AntiSwellTreblePivot + ? juce::jlimit (0.0f, 1.0f, ((float) midiNoteNumber - 72.0f) / 48.0f) + : 0.0f; + // Convert dB/sec to linear decay per sample: lighter damping for high notes + const float dbPerSecMax = 0.15f; + const float dbPerSec = dbPerSecMax * antiSwellNoteNorm * antiSwellNoteNorm; // Quadratic curve + const float dbPerSample = dbPerSec / (float) sampleRate; + antiSwellDecayPerSample = std::pow (10.0f, -dbPerSample / 20.0f); // Convert to linear multiplier + } + else + { + antiSwellDecayPerSample = 1.0f; + } + + adsr.noteOn(); + active = true; +} + +void Pm2StringBank::hardRetrigger (int midiNoteNumber, float velocity) +{ + resetForHardRetrigger(); + noteOn (midiNoteNumber, velocity); +} + +void Pm2StringBank::noteOff() +{ + if (minNoteOffRemaining > 0) + { + pendingNoteOff = true; + return; + } + applyNoteOffInternal(); +} + +void Pm2StringBank::applyNoteOffInternal() +{ + keyHeld = false; + if (sustainPedalDown && ! stealInProgress) + return; // wait for pedal release to start tail decay + + const float releaseMs = PhysicsToggles::kUsePhysicsDefaults ? 0.002f : 0.002f; // 2ms release + keyReleaseSamplesTotal = juce::jmax (1, (int) std::round (releaseMs * sampleRate)); + keyReleaseSamplesRemaining = keyReleaseSamplesTotal; + const float fadeMs = 0.001f; // 1ms fade + keyOffFadeSamplesTotal = juce::jmax (1, (int) std::round (fadeMs * sampleRate)); + keyOffFadeSamplesRemaining = keyOffFadeSamplesTotal; + useReleaseLoopGain = true; + releaseDelaySamples = 0; + damperDelaySamples = PhysicsToggles::kUsePhysicsDefaults ? (int) std::round (0.001f * sampleRate) : 0; // 1ms damper delay - nearly instant + adsr.noteOff(); + postLpfEnv.noteOff(); +} + +void Pm2StringBank::forceSilence() +{ + adsr.reset(); + postLpfEnv.reset(); + active = false; + currentMidiNote = -1; + pedalChangeSamplesRemaining = 0; + pedalChangeFade = 1.0f; + minNoteOffRemaining = 0; + pendingNoteOff = false; + for (auto& s : strings) + { + std::fill (s.delay.begin(), s.delay.end(), 0.0f); + s.lpState = 0.0f; + s.damperSoftenState = 0.0f; + s.damperLossSmoothed = damper.lossOff; + s.dc = {}; + } + lastEnv = 0.0f; + lastOutL = 0.0f; + lastOutR = 0.0f; + postLpfStateL = 0.0f; + postLpfStateR = 0.0f; + loopEnergySmoothed = 0.0f; + // FIX #3: Only reset steal fade if we're NOT in a steal operation. + // When voice stealing, we want the fade to complete to avoid clicks. + if (! stealInProgress) + stealFadeRemaining = 0; + stealInProgress = false; // Clear the flag after use +} + +void Pm2StringBank::resetForHardRetrigger() +{ + adsr.reset(); + postLpfEnv.reset(); + active = false; + keyHeld = false; + useReleaseLoopGain = false; + keyReleaseSamplesRemaining = 0; + keyOffFadeSamplesRemaining = 0; + noteFadeSamplesRemaining = 0; + noteFadeSamplesTotal = 0; + lowVelSkip = false; + minNoteOffRemaining = 0; + pendingNoteOff = false; + stealFadeRemaining = 0; + stealFadeSamples = 0; + stealInProgress = false; + pedalChangeSamplesRemaining = 0; + pedalChangeFade = 1.0f; + antiSwellEnv = 1.0f; + antiSwellDecayPerSample = 1.0f; + loopEnergySmoothed = 0.0f; + lastEnv = 0.0f; + lastOutL = 0.0f; + lastOutR = 0.0f; + postLpfStateL = 0.0f; + postLpfStateR = 0.0f; + for (auto& s : strings) + { + std::fill (s.delay.begin(), s.delay.end(), 0.0f); + s.writePos = 0; + s.lpState = 0.0f; + s.damperSoftenState = 0.0f; + s.damperLossSmoothed = damper.lossOff; + s.damperLossPrev = damper.lossOff; + s.interpZ1 = 0.0f; + s.hammer = {}; + s.duplex.buf.clear(); + s.duplex.write = 0; + s.duplex.feedback = 0.0f; + s.duplex.gain = 0.0f; + s.duplex.inputGain = 0.0f; + if (sampleRate > 0.0) + s.dc.reset (sampleRate); + else + s.dc = {}; + s.energyCalibSamplesLeft = 0; + s.energyCalibComplete = false; + s.energyPeak = 0.0f; + s.energySmoothed = 0.0f; + s.energyGainSmoothed = 1.0f; + } +} + +float Pm2StringBank::randomUniform() +{ + rng = 1664525u * rng + 1013904223u; + return (rng >> 8) * (1.0f / 16777216.0f); +} + +void Pm2StringBank::render (juce::AudioBuffer& buffer, int startSample, int numSamples, int startSampleInBlock) +{ + if (! active && ! adsr.isActive()) + return; + + const float dt = (float) (1.0 / juce::jmax (20.0, sampleRate)); + auto* L = buffer.getWritePointer (0, startSample); + auto* R = buffer.getNumChannels() > 1 ? buffer.getWritePointer (1, startSample) : nullptr; + float blockAbsMax = 0.0f; + const bool undampedSend = keyHeld || sustainPedalDown; + const bool damperEnabled = DebugToggles::kEnablePm2Damper; + const bool couplingEnabled = DebugToggles::kEnableCoupling; + const bool stringFiltersEnabled = DebugToggles::kEnablePm2StringFilters; + + const float couplingGainEff = (economyMode || ! couplingEnabled) ? 0.0f : couplingGain * polyphonyScale; + const float sympGainEff = (economyMode || ! couplingEnabled) ? 0.0f : sympGain * polyphonyScale; + const bool skipBodyNoise = economyMode; + const bool skipDuplex = false; + const float softGainScale = softPedalDown ? juce::jlimit (0.0f, 1.0f, unaCorda.gainScale) : 1.0f; + + for (int i = 0; i < numSamples; ++i) + { + if (minNoteOffRemaining > 0) + { + --minNoteOffRemaining; + if (minNoteOffRemaining == 0 && pendingNoteOff) + { + pendingNoteOff = false; + applyNoteOffInternal(); + } + } + if (pedalChangeSamplesRemaining > 0 && pedalChangeSamplesTotal > 0) + { + float t = 1.0f - (float) pedalChangeSamplesRemaining / (float) pedalChangeSamplesTotal; + t = juce::jlimit (0.0f, 1.0f, t); + pedalChangeFade = t * t * (3.0f - 2.0f * t); + --pedalChangeSamplesRemaining; + } + else + { + pedalChangeFade = 1.0f; + } + + float stealGain = 1.0f; + if (stealFadeRemaining > 0 && stealFadeSamples > 0) + { + stealGain = (float) stealFadeRemaining / (float) stealFadeSamples; + --stealFadeRemaining; + } + + float sumL = 0.0f, sumR = 0.0f; + float loopEnergySample = 0.0f; + for (int sIdx = 0; sIdx < currentNumStrings; ++sIdx) + { + auto& s = strings[(size_t) sIdx]; + if (s.delay.empty()) continue; + + const int len = (int) s.delay.size(); + + float y = 0.0f; + // Use cheaper linear interpolation for economy mode and treble notes + if (economyMode || ! DebugToggles::kEnablePm2FracDelayInterp || currentMidiNote >= 84) + { + // Cheaper linear interpolation + double readPos = (double) s.writePos - s.delaySamples; + while (readPos < 0.0) readPos += (double) len; + while (readPos >= (double) len) readPos -= (double) len; + int i1 = (int) std::floor (readPos); + float frac = (float) (readPos - (double) i1); + int i0 = i1; + int i2 = (i1 + 1) % len; + float x1 = s.delay[(size_t) i0]; + float x2 = s.delay[(size_t) i2]; + y = x1 + frac * (x2 - x1); + } + else + { + // Thiran first-order allpass interpolation for fractional delay + // Much cheaper than Lagrange (1 read + state vs 4 reads + 15 FMAs) + // while maintaining flat magnitude response (ideal for physical modelling) + // + // Formula (transposed direct form II): + // y = alpha * x + z1 + // z1_new = x - alpha * y + // where alpha = (1-d)/(1+d), d = fractional delay + + // Read from integer delay position + const int intDelay = (int) std::floor (s.delaySamples); + int readIdx = s.writePos - intDelay; + while (readIdx < 0) readIdx += len; + const float x = s.delay[(size_t) readIdx]; + + // Apply allpass for fractional delay + const float alpha = s.interpAlpha; + y = alpha * x + s.interpZ1; + s.interpZ1 = x - alpha * y; + } + + // dispersion allpass chain + for (int k = 0; k < s.apStages; ++k) + y = s.ap[(size_t) k].process (y); + + // loop loss (scalar) + frequency-dependent loss via one-pole LP + float damperLossTarget = 1.0f; + if (damperEnabled) + { + // Smooth damper lift changes to avoid zipper noise under rapid pedal motion. + damperLiftSmoothed += damperLiftSmoothCoeff * (damperLiftTarget - damperLiftSmoothed); + // Treat the damper as fully lifted while the key (or sustain pedal) is down. + // When the key is released, ramp the damper transition over a few ms to avoid clicks. + float releaseBlend = 0.0f; + if (keyHeld || sustainPedalDown) + { + releaseBlend = 1.0f; + } + else if (PhysicsToggles::kUsePhysicsDefaults && damperDelaySamples > 0) + { + --damperDelaySamples; + releaseBlend = 1.0f; + } + else if (keyReleaseSamplesRemaining > 0 && keyReleaseSamplesTotal > 0) + { + releaseBlend = (float) keyReleaseSamplesRemaining / (float) keyReleaseSamplesTotal; + --keyReleaseSamplesRemaining; + } + const float damperLiftEffective = damperLiftSmoothed + (1.0f - damperLiftSmoothed) * releaseBlend; + + damperLossTarget = (damperLiftEffective >= 0.999f) + ? damper.lossOff + : mixLinear (damper.lossDamped, damper.lossHalf, damperLiftEffective); + const float damperCoeff = DebugToggles::kEnablePm2ExtraDamperSmoothing + ? (damperSmoothCoeff * 0.5f) + : damperSmoothCoeff; + s.damperLossSmoothed += damperCoeff * (damperLossTarget - s.damperLossSmoothed); + } + else + { + damperLiftSmoothed = 1.0f; + s.damperLossSmoothed = 1.0f; + s.damperLossPrev = 1.0f; + } + if (releaseDelaySamples > 0) + --releaseDelaySamples; + if (releaseDelaySamples == 0 && ! useReleaseLoopGain && ! keyHeld && ! sustainPedalDown) + useReleaseLoopGain = true; + float loopTarget = (useReleaseLoopGain ? s.loopGainRelease : s.loopGainBase) + * (damperEnabled ? s.damperLossSmoothed : 1.0f); + if (DebugToggles::kEnablePm2FreqDependentLoss) + loopTarget *= freqLossScalar; + const float swellScale = PhysicsToggles::kUsePhysicsDefaults ? 0.6f : 1.0f; + if (DebugToggles::kEnablePm2HighNoteLoopDamping) + { + if (sustainPedalDown) + { + // Gentle swell prevention for high notes with pedal down + const float noteNorm = juce::jlimit (0.0f, 1.0f, ((float) currentMidiNote - 68.0f) / 36.0f);//1st value is midi note it starts at 2nd value is notespan of big reduction + const float sustainSwellDamp = 1.0f - (0.0008f * swellScale) * noteNorm; + loopTarget *= sustainSwellDamp; + } + else + { + // Extra high-note damping without sustain to prevent runaway + const float noteNorm = juce::jlimit (0.0f, 1.0f, ((float) currentMidiNote - 84.0f) / 40.0f); // ~C6 to C9 + const float noPedalHighDamp = 1.0f - (0.0020f * swellScale) * noteNorm; + loopTarget *= noPedalHighDamp; + } + } + const float loopCoeff = DebugToggles::kEnablePm2ExtraLoopGainSmoothing + ? (damperSmoothCoeff * 0.5f) + : damperSmoothCoeff; + s.loopGainSmoothed += loopCoeff * (loopTarget - s.loopGainSmoothed); + if (damperEnabled) + { + if (damperLossTarget < s.damperLossPrev - 1.0e-4f) + s.damperSoftenCountdown = damperSoftenSamples; + s.damperLossPrev = damperLossTarget; + } + + y *= s.loopGainSmoothed; + if (stringFiltersEnabled) + { + float lp = s.lpState + s.lpCoeff * (y - s.lpState); + s.lpState = lp; + y = lp; + } + loopEnergySample += y * y; + + // Option 1: Per-string energy limiter to prevent swell + // During calibration window, capture peak energy level + // After calibration, soft-limit to prevent exceeding ~110% of reference + if (! economyMode && DebugToggles::kEnablePm2EnergyLimiter && ! PhysicsToggles::kUsePhysicsDefaults) + { + const float absY = std::abs (y); + // Smooth energy tracking (fast attack, slow release) + const float noteNormHigh = juce::jlimit (0.0f, 1.0f, ((float) currentMidiNote - 76.0f) / 32.0f); // E5 to G7 + const float energyAttack = mixLinear (0.095f, 0.13f, noteNormHigh); // Faster attack for treble + const float energyRelease = 0.001f; // Slow release + const float energyCoeff = (absY > s.energySmoothed) ? energyAttack : energyRelease; + s.energySmoothed += energyCoeff * (absY - s.energySmoothed); + + if (! s.energyCalibComplete) + { + // During calibration: track peak energy + if (s.energySmoothed > s.energyPeak) + s.energyPeak = s.energySmoothed; + --s.energyCalibSamplesLeft; + if (s.energyCalibSamplesLeft <= 0) + { + s.energyCalibComplete = true; + // Add 10% headroom to avoid limiting normal dynamics + const float headroom = mixLinear (1.35f, 1.25f, noteNormHigh); + s.energyPeak *= headroom; + // Minimum floor to avoid division issues on very quiet notes + s.energyPeak = juce::jmax (s.energyPeak, 0.003f); + } + } + else if (s.energyPeak > 0.0f) + { + // After calibration: soft-limit if energy exceeds reference + const float ratio = s.energySmoothed / s.energyPeak; + float targetGain = 1.0f; + if (ratio > 1.0f) + { + // Soft knee limiter: gentle compression above threshold + const float excess = ratio - 1.0f; + const float compRatio = mixLinear (1.15f, 1.45f, noteNormHigh); + targetGain = 1.0f / (1.0f + excess * compRatio); + } + targetGain = juce::jmax (0.6f, targetGain); + const float gainAttack = mixLinear (0.10f, 0.14f, noteNormHigh); + const float gainRelease = 0.006f; + const float gainCoeff = (targetGain < s.energyGainSmoothed) ? gainAttack : gainRelease; + s.energyGainSmoothed += gainCoeff * (targetGain - s.energyGainSmoothed); + y *= s.energyGainSmoothed; + } + } + + if (damperEnabled && s.damperSoftenCountdown > 0) + { + const float softenMix = 1.0f - damperSoftenA; + s.damperSoftenState = s.damperSoftenState + softenMix * (y - s.damperSoftenState); + y = s.damperSoftenState; + --s.damperSoftenCountdown; + } + + // dc block + if (DebugToggles::kEnablePm2DcBlock) + y = s.dc.process (y); + + // Continuous hammer-string interaction (adds excitation during attack) + float excitation = 0.0f; + if (DebugToggles::kEnablePm2HammerExcitation && s.hammer.active && s.hammer.samplesLeft > 0) + { + auto& h = s.hammer; + float pen = h.pos - y; + pen = juce::jlimit (-h.maxPen, h.maxPen, pen); + float contact = juce::jmax (0.0f, pen + h.preload); + // CPU OPTIMIZATION: Use fastPow for hammer force calculation + float force = h.k * FastMath::fastPow (contact, h.exp) - h.damping * h.vel; + force = softClip (force, 1200.0f); // softer clamp to avoid discontinuities + float accel = force / juce::jmax (0.001f, h.mass); + h.vel += accel * dt; + h.pos += h.vel * dt; + h.pen = pen; + float shaped = h.toneState + (1.0f - h.toneAlpha) * (force - h.toneState); + h.toneState = shaped; + // FIX: Scale excitation based on remaining interaction time for smoother decay + // This prevents the abrupt cutoff when samplesLeft reaches 0 + const float fadeStart = 200.0f; // Start fading in last 200 samples + float fadeGain = (h.samplesLeft > fadeStart) ? 1.0f + : (float) h.samplesLeft / fadeStart; + // Slight fade-in to soften the initial spike + const float fadeInSamples = 120.0f; + const float fadeInGain = (h.samplesElapsed < fadeInSamples) + ? (float) h.samplesElapsed / fadeInSamples + : 1.0f; + float hammerGain = h.gain; + if (DebugToggles::kEnablePm2HammerGainRamp) + { + const float gainCoeff = 0.02f; + h.gainSmoothed += gainCoeff * (hammerGain - h.gainSmoothed); + hammerGain = h.gainSmoothed; + } + excitation = softClip (shaped * hammerGain * fadeGain * fadeInGain, 3.0f); + if (PhysicsToggles::kUsePhysicsDefaults && s.toneInjectSamplesLeft > 0) + { + excitation += FastMath::fastSin (s.toneInjectPhase) * s.toneInjectGain; + s.toneInjectPhase += s.toneInjectPhaseDelta; + if (s.toneInjectPhase >= juce::MathConstants::twoPi) + s.toneInjectPhase -= juce::MathConstants::twoPi; + --s.toneInjectSamplesLeft; + } + --h.samplesLeft; + ++h.samplesElapsed; + if (! std::isfinite (h.pos) || ! std::isfinite (h.vel) || h.samplesLeft <= 0 || (h.pos <= 0.0f && h.vel <= 0.0f)) + h.active = false; + } + + // write back + float writeSample = softClip (y + excitation, 4.0f); + s.delay[(size_t) s.writePos] = writeSample; + s.writePos = (s.writePos + 1) % len; + + float mono = y * s.baseGain * softGainScale; + if (PhysicsToggles::kUsePhysicsDefaults && s.toneInjectSamplesLeft > 0) + { + mono += FastMath::fastSin (s.toneInjectPhase) * s.toneInjectGain; + s.toneInjectPhase += s.toneInjectPhaseDelta; + if (s.toneInjectPhase >= juce::MathConstants::twoPi) + s.toneInjectPhase -= juce::MathConstants::twoPi; + --s.toneInjectSamplesLeft; + } + sumL += mono * s.panGainL; + sumR += mono * s.panGainR; + + // duplex tap (short afterlength) + // CPU OPTIMIZATION: Skip for bass notes (less audible in low register) + if (! skipDuplex && ! s.duplex.buf.empty()) + { + float prev = s.duplex.buf[(size_t) s.duplex.write]; + float input = y * s.duplex.inputGain; + s.duplex.buf[(size_t) s.duplex.write] = input + s.duplex.feedback * prev; + s.duplex.write = (s.duplex.write + 1) % (int) s.duplex.buf.size(); + float duplexMono = prev * s.duplex.gain; + sumL += duplexMono * s.panGainL; + sumR += duplexMono * s.panGainR; + } + } + + // IMPROVEMENT 3: Add stochastic body resonance (inter-harmonic fill) + // Simulates soundboard and cabinet broadband resonance that fills gaps between harmonics + // Modulated by loop energy so it follows the note's amplitude envelope + // CPU OPTIMIZATION: Skip for bass notes (less audible in low register) + if (! skipBodyNoise) + { + const float loopEnergy = FastMath::fastSqrt (juce::jmax (0.0f, loopEnergySmoothed)); + const float noiseLevel = loopEnergy * 0.003f; // Reduced from 0.006f to lower noise floor + const bool allowBodyNoise = adsr.isActive() && loopEnergySmoothed > 1.0e-4f; + + if (allowBodyNoise && noiseLevel > 1.0e-6f) + { + // Generate filtered noise (bandpass ~80-2500 Hz range) + bodyNoiseRng = 1664525u * bodyNoiseRng + 1013904223u; + float rawNoise = ((bodyNoiseRng >> 8) * (1.0f / 16777216.0f)) * 2.0f - 1.0f; + + // Two-pole lowpass - reduced cutoff for less HF content + const float lpCoeff = 0.08f; // Was 0.12f - lower cutoff ~2kHz + bodyNoiseLp1 += lpCoeff * (rawNoise - bodyNoiseLp1); + bodyNoiseLp2 += lpCoeff * (bodyNoiseLp1 - bodyNoiseLp2); + + // One-pole highpass at ~80Hz to remove rumble + const float hpCoeff = 0.995f; + float filtered = bodyNoiseLp2 - bodyNoiseHp; + bodyNoiseHp = bodyNoiseLp2 * (1.0f - hpCoeff) + bodyNoiseHp * hpCoeff; + + // Apply energy modulation + float bodyNoise = filtered * noiseLevel; + + // Slight stereo decorrelation for natural width + bodyNoiseRng = 1664525u * bodyNoiseRng + 1013904223u; + float stereoOffset = ((bodyNoiseRng >> 16) * (1.0f / 65536.0f)) * 0.3f; + + sumL += bodyNoise * (0.5f + stereoOffset); + sumR += bodyNoise * (0.5f - stereoOffset); + } + } + + // IMPROVEMENT 2: Output-stage fundamental boost using lowpass extraction + // Extract low frequencies (fundamental region) and mix back for warmer tone + // This is applied to the output sum, NOT inside the waveguide loop + { + // Use the first string's resonator state for the summed output + // Simple one-pole lowpass to extract fundamental region (~200Hz cutoff) + auto& s = strings[0]; + const float lpAlpha = s.fundResonatorCoeff; // Pre-calculated in noteOn based on fundamental + if (s.fundResonatorGain > 0.001f && lpAlpha > 0.0f) + { + // Extract low frequency content + s.fundResonatorState1 += (1.0f - lpAlpha) * (sumL - s.fundResonatorState1); + s.fundResonatorState2 += (1.0f - lpAlpha) * (sumR - s.fundResonatorState2); + + // Mix extracted lows back in for fundamental boost + sumL += s.fundResonatorState1 * s.fundResonatorGain; + sumR += s.fundResonatorState2 * s.fundResonatorGain; + } + } + + loopEnergySmoothed += loopEnergySmoothCoeff * (loopEnergySample - loopEnergySmoothed); + lastEnv = loopEnergySmoothed; + // FIXED: Apply stringGainNorm to prevent level buildup from multi-string summing + float outL = sumL * pitchLoudnessGain * stringGainNorm; + float outR = sumR * pitchLoudnessGain * stringGainNorm; + if (! std::isfinite (outL)) outL = lastOutL * 0.98f; + if (! std::isfinite (outR)) outR = lastOutR * 0.98f; + const bool noteHpfEnabled = DebugToggles::kEnablePm2NoteHpf; + float hpL = noteHpfEnabled ? noteHpf.processSample (0, outL) : outL; + float hpR = noteHpfEnabled ? noteHpf.processSample (noteHpfNumChannels > 1 ? 1 : 0, outR) : outR; + if (DebugToggles::kEnablePm2PostLpfEnv) + { + const float env = postLpfEnv.getNextSample(); + const float cutoff = juce::jlimit (400.0f, 16000.0f, + postLpfMinHz + (postLpfMaxHz - postLpfMinHz) * env); + const float a = std::exp (-2.0f * juce::MathConstants::pi * cutoff + / (float) juce::jmax (20.0, sampleRate)); + postLpfStateL = a * postLpfStateL + (1.0f - a) * hpL; + postLpfStateR = a * postLpfStateR + (1.0f - a) * hpR; + hpL = postLpfStateL; + hpR = postLpfStateR; + } + // FIX #1 & #4: Use instance bus pointers with block-relative indices + // Weak coupling/sympathetic returns from other notes + const int busIdx = startSampleInBlock + i; // Block-relative index for shared buses + const auto coupleIn = (couplingEnabled && couplingBus) ? couplingBus->read (busIdx) : std::make_pair (0.0f, 0.0f); + const auto sympIn = (couplingEnabled && sympBus) ? sympBus->read (busIdx) : std::make_pair (0.0f, 0.0f); + + // Option 2: Reduce coupling and sympathetic return for high notes to prevent swell + // High notes are more prone to energy accumulation from these feedback paths + const float highNoteNorm = DebugToggles::kEnablePm2HighNoteCouplingTilt + ? juce::jlimit (0.0f, 1.0f, ((float) currentMidiNote - 72.0f) / 48.0f) + : 0.0f; + const float coupleTilt = 1.0f - 0.6f * highNoteNorm * highNoteNorm; // Quadratic reduction, lighter and spread wider + const float sympTilt = 1.0f - sympHighDamp * highNoteNorm - 0.25f * highNoteNorm * highNoteNorm; // Extra quadratic term + + const float pedalFade = pedalChangeFade; + const float coupleGain = couplingGainEff * pedalFade; + const float sympGainScaled = sympGainEff * pedalFade; + float coupleL = (coupleGain > 1.0e-4f) ? couplingBpL.processSample (0, coupleIn.first) * coupleGain * coupleTilt : 0.0f; + float coupleR = (coupleGain > 1.0e-4f) ? couplingBpR.processSample (noteHpfNumChannels > 1 ? 1 : 0, coupleIn.second) * coupleGain * coupleTilt : 0.0f; + float sympL = (sympGainScaled > 1.0e-4f) ? sympBpL.processSample (0, sympIn.first) * (sympGainScaled * sympTilt) : 0.0f; + float sympR = (sympGainScaled > 1.0e-4f) ? sympBpR.processSample (noteHpfNumChannels > 1 ? 1 : 0, sympIn.second) * (sympGainScaled * sympTilt) : 0.0f; + hpL += coupleL + sympL; + hpR += coupleR + sympR; + hpL *= stealGain * velocityGain; + hpR *= stealGain * velocityGain; + if (noteFadeSamplesRemaining > 0 && noteFadeSamplesTotal > 0) + { + const float fadeT = 1.0f - (float) noteFadeSamplesRemaining / (float) noteFadeSamplesTotal; + hpL *= fadeT; + hpR *= fadeT; + --noteFadeSamplesRemaining; + } + if (! keyHeld && ! sustainPedalDown && keyOffFadeSamplesRemaining > 0 && keyOffFadeSamplesTotal > 0) + { + const float fadeT = 1.0f - (float) keyOffFadeSamplesRemaining / (float) keyOffFadeSamplesTotal; + hpL *= fadeT; + hpR *= fadeT; + --keyOffFadeSamplesRemaining; + } + + // Option 5: Apply anti-swell envelope - very slow decay for high notes + // This counteracts gradual energy accumulation that causes swell + if (DebugToggles::kEnablePm2AntiSwell) + { + hpL *= antiSwellEnv; + hpR *= antiSwellEnv; + // Decay the envelope (only when key is held to prevent affecting release) + if (keyHeld || sustainPedalDown) + antiSwellEnv *= antiSwellDecayPerSample; + // Floor to prevent envelope from going to zero over very long holds + antiSwellEnv = juce::jmax (antiSwellEnv, 0.9f); + } + + if (R) + { + L[i] += hpL; + R[i] += hpR; + } + else + { + L[i] += 0.5f * (hpL + hpR); + } + lastOutL = hpL; + lastOutR = hpR; + blockAbsMax = std::max (blockAbsMax, std::max (std::abs (hpL), std::abs (hpR))); + // FIX #1 & #4: Use instance bus pointers with block-relative indices + if (couplingEnabled && couplingBus && couplingGainEff > 1.0e-4f) + couplingBus->add (busIdx, hpL * pedalChangeFade, hpR * pedalChangeFade); + if (couplingEnabled && undampedSend && sympBus && ! lowVelSkip && sympGainEff > 1.0e-4f) + sympBus->add (busIdx, hpL * pedalChangeFade, hpR * pedalChangeFade); + + if (stealFadeSamples > 0 && stealFadeRemaining == 0) + { + forceSilence(); + return; + } + } + + noteLifeSamples += numSamples; + // CPU OPTIMIZATION: Very aggressive thresholds for faster voice termination + // Notes that have decayed below audibility are terminated quickly to free CPU + // These thresholds are 4-5x more aggressive than previous values + const float energyThresholdBase = PhysicsToggles::kUsePhysicsDefaults ? 5.0e-4f : 8.0e-4f; + const float blockThresholdBase = PhysicsToggles::kUsePhysicsDefaults ? 2.0e-4f : 3.0e-4f; + const int minLifeBase = PhysicsToggles::kUsePhysicsDefaults ? (int) std::round (0.003 * sampleRate) : 0; // 3ms minimum + const float stopNoteNorm = juce::jlimit (0.0f, 1.0f, ((float) currentMidiNote - 72.0f) / 36.0f); + const float stopScale = 1.0f - 0.85f * stopNoteNorm; // More aggressive scaling for high notes + const float energyThreshold = energyThresholdBase * juce::jmax (0.2f, stopScale); + const float blockThreshold = blockThresholdBase * juce::jmax (0.2f, stopScale); + const int minLife = minLifeBase + (int) std::round (stopNoteNorm * 0.010 * sampleRate); // 10ms extra for high notes + if (! keyHeld && ! sustainPedalDown + && loopEnergySmoothed < energyThreshold && blockAbsMax < blockThreshold + && noteLifeSamples > minLife) + { + adsr.reset(); + active = false; + } +} + +void Pm2Synth::preallocateVoiceForNote (int midiNoteNumber) +{ + juce::ignoreUnused (midiNoteNumber); + + int active = 0; + for (int i = 0; i < getNumVoices(); ++i) + if (auto* v = getVoice (i)) + if (v->isVoiceActive()) + ++active; + + if (active < getNumVoices()) + return; // at least one free voice, no need to steal + + // Voice stealing: pick the quietest voice by loop energy + juce::SynthesiserVoice* best = nullptr; + float bestEnergy = std::numeric_limits::max(); + + for (int i = 0; i < getNumVoices(); ++i) + { + auto* v = getVoice (i); + if (v == nullptr) + continue; + + if (auto* pv = dynamic_cast (v)) + { + float energy = pv->getLoopEnergy(); + if (! std::isfinite (energy)) + energy = std::numeric_limits::max(); + if (energy < bestEnergy) + { + bestEnergy = energy; + best = v; + } + } + } + + if (auto* pv = dynamic_cast (best)) + { + pv->prepareForSteal(); + pv->stopNote (0.0f, true); // allow tail-off for smoother steal + requestDeclick (64); + } +} + +// FIX: Preallocate multiple voices for a chord - ensures all notes in a chord get voices +// without JUCE's internal voice stealing interfering with our age-based priority +void Pm2Synth::preallocateVoicesForChord (int numNotesNeeded) +{ + if (numNotesNeeded <= 0) + return; + + // Count currently active voices + int activeCount = 0; + for (int i = 0; i < getNumVoices(); ++i) + if (auto* v = getVoice (i)) + if (v->isVoiceActive()) + ++activeCount; + + // Calculate how many voices we need to free + const int totalVoices = getNumVoices(); + const int freeVoices = totalVoices - activeCount; + int voicesToSteal = numNotesNeeded - freeVoices; + + // Steal voices one at a time, always picking the oldest + while (voicesToSteal > 0) + { + // Find the quietest note to steal by loop energy + juce::SynthesiserVoice* best = nullptr; + float bestEnergy = std::numeric_limits::max(); + + for (int i = 0; i < getNumVoices(); ++i) + { + auto* v = getVoice (i); + if (v == nullptr || ! v->isVoiceActive()) + continue; + + if (auto* pv = dynamic_cast (v)) + { + float energy = pv->getLoopEnergy(); + if (! std::isfinite (energy)) + energy = std::numeric_limits::max(); + if (energy < bestEnergy) + { + bestEnergy = energy; + best = v; + } + } + } + + if (auto* pv = dynamic_cast (best)) + { + pv->prepareForSteal(); + pv->stopNote (0.0f, true); // allow tail-off for smoother steal + requestDeclick (64); + --voicesToSteal; + } + else + { + // No more voices to steal, break to avoid infinite loop + break; + } + } +} + +bool Pm2Synth::hasActiveVoiceForNote (int midiNoteNumber) const +{ + for (int i = 0; i < getNumVoices(); ++i) + if (auto* v = dynamic_cast (getVoice (i))) + if (v->isActive() && v->getCurrentMidiNote() == midiNoteNumber) + return true; + return false; +} + +bool Pm2Synth::hardRetriggerActiveVoice (int midiNoteNumber, float velocity) +{ + Pm2Voice* best = nullptr; + uint64_t bestAge = 0; + for (int i = 0; i < getNumVoices(); ++i) + if (auto* v = dynamic_cast (getVoice (i))) + if (v->isActive() && v->getCurrentMidiNote() == midiNoteNumber) + { + const uint64_t age = v->getNoteAge(); + if (best == nullptr || age > bestAge) + { + best = v; + bestAge = age; + } + } + if (best == nullptr) + return false; + best->hardRetrigger (midiNoteNumber, velocity); + return true; +} + +//============================================================================== +// Processor ctor +FluteSynthAudioProcessor::FluteSynthAudioProcessor() +#ifndef JucePlugin_PreferredChannelConfigurations + : AudioProcessor (BusesProperties().withOutput ("Output", juce::AudioChannelSet::stereo(), true)) +#endif + , apvts (*this, nullptr, "Params", createParameterLayout()) +{ + // CPU OPTIMIZATION: Reduced polyphony from 18 to 15 voices + for (int i = 0; i < 15; ++i) + synth.addVoice (new FluteVoice (apvts)); + synth.addSound (new SimpleSound()); + + for (int i = 0; i < 15; ++i) + pmSynth.addVoice (new PmVoice()); + pmSynth.addSound (new PmSound()); + + for (int i = 0; i < 15; ++i) + pm2Synth.addVoice (new Pm2Voice()); + pm2Synth.addSound (new Pm2Sound()); + + // FIX #1: Set shared bus pointers on all PM2 voices + pm2Synth.setSharedBuses (&couplingBus, &sympBus); + + applyMasterTuneToVoices(); + loadEmbeddedPreset(); +} + +void FluteSynthAudioProcessor::prepareToPlay (double sr, int samplesPerBlock) +{ + // CPU OPTIMIZATION: Initialize fast math lookup tables (only done once) + FastMath::initTables(); + + lastSampleRate = sr; + prepared = true; + applyMasterTuneToVoices(); + { + const double smoothTimeSec = 0.020; // ~20ms smoothing for click reduction + auto resetSmooth = [sr, smoothTimeSec] (juce::SmoothedValue& s, float value) + { + s.reset (sr, smoothTimeSec); + s.setCurrentAndTargetValue (value); + }; + resetSmooth (pm2GainLinSmoothed, pm2GainLin); + resetSmooth (outputGainLinSmoothed, outputGainLin); + resetSmooth (postCutoffHzSmoothed, postCutoffHz); + resetSmooth (postQSmoothed, postQ); + resetSmooth (postTiltDbSmoothed, postTiltDb); + resetSmooth (outputLpfCutoffSmoothed, outputLpfCutoff); + resetSmooth (outputLpfQSmoothed, outputLpfQ); + resetSmooth (sustainGainLinSmoothed, 1.0f); + resetSmooth (sustainReleaseScaleSmoothed, 1.0f); + resetSmooth (sustainValueSmoothed, 0.0f); + const float noteTerm = brightnessNoteSlopeDb * ((float) lastMidiNote - 60.0f) * (1.0f / 24.0f); + const float initialBrightness = juce::jlimit (-12.0f, brightnessMaxDb, + brightnessBaseDb + lastVelocityNorm * brightnessVelSlopeDb + noteTerm); + resetSmooth (brightnessDbSmoothed, initialBrightness); + brightnessCurrentDb = initialBrightness; + } + + // VA synth + synth.setCurrentPlaybackSampleRate (sr); + for (int i = 0; i < synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (synth.getVoice (i))) + { + v->prepare (sr, samplesPerBlock, getTotalNumOutputChannels()); + v->setNoteOffsets (noteOffsetsCents); + } + + pmSynth.setCurrentPlaybackSampleRate (sr); + for (int i = 0; i < pmSynth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pmSynth.getVoice (i))) + { + v->prepare (sr, samplesPerBlock, getTotalNumOutputChannels()); + v->setNoteOffsets (noteOffsetsCents); + } + + pm2Synth.setCurrentPlaybackSampleRate (sr); + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + { + v->prepare (sr, samplesPerBlock, getTotalNumOutputChannels()); + v->setNoteOffsets (noteOffsetsCents); + } + + // Sync PM/PM2 voice params to current state on prepare + const float a = apvts.getRawParameterValue (ParamIDs::attack)->load(); + const float d = apvts.getRawParameterValue (ParamIDs::decay)->load(); + const float s = apvts.getRawParameterValue (ParamIDs::sustain)->load(); + const float r = apvts.getRawParameterValue (ParamIDs::release)->load(); + baseRelease = r; + for (int i = 0; i < pmSynth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pmSynth.getVoice (i))) + { + v->setEnvParams (a, d, s, r); + v->setReleaseScale (baseRelease, 1.0f); + } + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + { + v->setEnvParams (a, d, s, r); + v->setParams (pmString); + v->setHammerParams (pmHammer); + v->setFeltParams (pmFelt); + v->setDuplexParams (duplexCfg); + v->setWdfParams (wdfCfg); + v->setSoftPedal (softPedalDown, unaCfg); + v->setReleaseScale (baseRelease, 1.0f); + v->setReleaseExtension (releaseExtension); + v->setSustainPedalDown (sustainPedalDown); + v->setDamperParams (damperCfg); + v->setDamperLift (damperLift); + v->setEconomyMode (pm2EconomyMode); + v->setHighPolyMode (pm2EconomyMode); + v->setCouplingParams (couplingCfg); + v->setDispersionCurve (dispersionCfg); + } + + // FIX #1: Ensure shared bus pointers are set on all PM2 voices + pm2Synth.setSharedBuses (&couplingBus, &sympBus); + + // Breath + formants + juce::dsp::ProcessSpec spec; + spec.sampleRate = sr; + spec.maximumBlockSize = (juce::uint32) samplesPerBlock; + spec.numChannels = (juce::uint32) juce::jmax (1, getTotalNumOutputChannels()); + mainSpec = spec; + + auto allocScratch = [] (juce::AudioBuffer& buf, int channels, int samples) + { + buf.setSize (channels, samples, false, false, true); + buf.clear(); + }; + const int scratchCh = (int) spec.numChannels; + allocScratch (hybridVaBuf, scratchCh, samplesPerBlock); + allocScratch (hybridPmBuf, scratchCh, samplesPerBlock); + allocScratch (hybridPm2Buf, scratchCh, samplesPerBlock); + allocScratch (micScratch, scratchCh, samplesPerBlock); + allocScratch (breathScratch, 1, samplesPerBlock); + allocScratch (formantScratch, scratchCh, samplesPerBlock); + allocScratch (pedalScratch, scratchCh, samplesPerBlock); + allocScratch (sympScratch, scratchCh, samplesPerBlock); + allocScratch (modalScratch, scratchCh, samplesPerBlock); + allocScratch (soundboardScratch, scratchCh, samplesPerBlock); + if (DebugToggles::kSoundboardConvolutionDownsample > 1) + { + const int dsSamples = (samplesPerBlock + DebugToggles::kSoundboardConvolutionDownsample - 1) + / DebugToggles::kSoundboardConvolutionDownsample; + allocScratch (soundboardScratchDs, scratchCh, dsSamples); + } + soundboardConvolution.reset(); + soundboardConvolution.prepare (spec); + if (DebugToggles::kSoundboardConvolutionDownsample > 1) + { + juce::dsp::ProcessSpec dsSpec = spec; + dsSpec.sampleRate = spec.sampleRate / (double) DebugToggles::kSoundboardConvolutionDownsample; + dsSpec.maximumBlockSize = (juce::uint32) + ((spec.maximumBlockSize + DebugToggles::kSoundboardConvolutionDownsample - 1) + / DebugToggles::kSoundboardConvolutionDownsample); + soundboardConvolutionDs.reset(); + soundboardConvolutionDs.prepare (dsSpec); + } + soundboardIrDirty = true; + soundboardIrLastT60 = 0.0f; + soundboardIrLastDamp = 0.0f; + postReverb.reset(); + postReverbParamsValid = false; + + breathBp.reset(); breathBp.prepare (spec); + breathBp.setType (juce::dsp::StateVariableTPTFilterType::bandpass); + breathBp.setCutoffFrequency (breathBpFreqStored); + breathBp.setResonance (breathBpQStored); + + for (int i = 0; i < 2; ++i) + { + formant[i].f.reset(); + formant[i].f.prepare (spec); + formant[i].f.setType (juce::dsp::StateVariableTPTFilterType::bandpass); + formant[i].gainLin = 1.0f; + formant[i].enabled = false; + } + + // Post tone controls (per-engine key-tracked LPF cascaded + shared tilt shelves) + auto prepLP = [&spec] (juce::dsp::StateVariableTPTFilter& f) + { + f.reset(); + f.prepare (spec); + f.setType (juce::dsp::StateVariableTPTFilterType::lowpass); + }; + prepLP (postVaLp1); prepLP (postVaLp2); + prepLP (postPmLp1); prepLP (postPmLp2); + prepLP (postPm2Lp1); prepLP (postPm2Lp2); + + tiltLow.reset(); tiltHigh.reset(); + tiltLow.prepare (spec); tiltHigh.prepare (spec); + tiltNumChannels = (int) spec.numChannels; + tiltReady = false; + + auto prepSendHpf = [this] (decltype (pedalSendHpf)& f) + { + f.reset(); + f.prepare (mainSpec); + auto coeffs = juce::dsp::IIR::Coefficients::makeHighPass (mainSpec.sampleRate, + sendHpfCutoff, + 0.707f); + if (f.state == nullptr) + f.state = coeffs; + else + *f.state = *coeffs; + }; + prepSendHpf (pedalSendHpf); + prepSendHpf (sympSendHpf); + prepSendHpf (soundboardSendHpf); + { + soundboardReturnHpf.reset(); + soundboardReturnHpf.prepare (mainSpec); + auto coeffs = juce::dsp::IIR::Coefficients::makeHighPass (mainSpec.sampleRate, + soundboardReturnHpfCutoff, + 0.707f); + if (soundboardReturnHpf.state == nullptr) + soundboardReturnHpf.state = coeffs; + else + *soundboardReturnHpf.state = *coeffs; + } + { + juce::dsp::ProcessSpec monoSpec = mainSpec; + monoSpec.numChannels = 1; + modalSendHpf.reset(); + modalSendHpf.prepare (monoSpec); + modalSendHpf.coefficients = juce::dsp::IIR::Coefficients::makeHighPass (monoSpec.sampleRate, + sendHpfCutoff, + 0.707f); + } + sendHpfNumChannels = (int) mainSpec.numChannels; + + // Final output LPF + outputLpf.reset(); + outputLpf.prepare (spec); + outputLpf.setType (juce::dsp::StateVariableTPTFilterType::lowpass); + outputLpfNumChannels = (int) spec.numChannels; + updateOutputLpf(); + for (auto& f : outputEqFilters) + { + f.reset(); + f.prepare (spec); + } + outputEqNumChannels = (int) spec.numChannels; + updateOutputEq(); + // Output HPF for rumble control + outputHpf.reset(); + outputHpf.prepare (spec); + { + auto coeffs = juce::dsp::IIR::Coefficients::makeHighPass (spec.sampleRate, + outputHpfCutoff, + 0.707f); + if (outputHpf.state == nullptr) + outputHpf.state = coeffs; + else + *outputHpf.state = *coeffs; + } + outputHpfNumChannels = (int) spec.numChannels; + + // Lookahead final limiter + { + const int la = juce::jmax (1, (int) std::round (sr * (limiterLookaheadMs * 0.001f))); + limiterLookaheadSamples = la; + limiterDelayBufferSize = la + samplesPerBlock + 1; + limiterDelayBuffer.setSize ((int) spec.numChannels, limiterDelayBufferSize, false, false, true); + limiterDelayBuffer.clear(); + limiterWritePos = 0; + limiterGain = 1.0f; + auto msToCoeff = [sr] (float ms) + { + const double sec = juce::jmax (0.0001, (double) ms * 0.001); + return (float) std::exp (-1.0 / (sec * sr)); + }; + limiterAttackCoeff = msToCoeff (limiterAttackMs); + limiterReleaseCoeff = msToCoeff (limiterReleaseMs); + setLatencySamples (limiterLookaheadSamples); + } + + prepareBrightnessFilters(); + for (int ch = 0; ch < juce::jmin (2, (int) spec.numChannels); ++ch) + outputDcBlock[(size_t) ch].reset (sr); + outputDcNumChannels = (int) spec.numChannels; + + // Mic mixer setup + updateMicProcessors(); + + // Hammer HP + hammerHP.reset(); + hammerHP.prepare (juce::dsp::ProcessSpec{ sr, (juce::uint32) samplesPerBlock, 1u }); + hammerHP.setType (juce::dsp::StateVariableTPTFilterType::highpass); + hammerHP.setCutoffFrequency (hammerHpHz); + // Recalculate hammer decay coefficient now that sample rate is valid + // (may have been skipped during construction when lastSampleRate was 0) + if (hammerDecaySec > 0.0005f) + { + const double tau = std::max (0.0005, (double) hammerDecaySec); + hammerDecayCoeff = (float) std::exp (-1.0 / (tau * sr)); + } + else + { + hammerDecayCoeff = 0.0f; + } + + // Action noises + auto decayToCoeff = [sr] (float sec) + { + if (sec <= 0.0005f) return 0.0f; + const double tau = std::max (0.0005, (double) sec); + return (float) std::exp (-1.0 / (tau * sr)); + }; + + keyOffHP.reset(); + keyOffHP.prepare (juce::dsp::ProcessSpec{ sr, (juce::uint32) samplesPerBlock, 1u }); + keyOffHP.setType (juce::dsp::StateVariableTPTFilterType::highpass); + keyOffHP.setCutoffFrequency (keyOffHpHz); + keyOffDecayCoeff = decayToCoeff (keyOffDecaySec); + + pedalThumpLP.reset(); + pedalThumpLP.prepare (juce::dsp::ProcessSpec{ sr, (juce::uint32) samplesPerBlock, 1u }); + pedalThumpLP.setType (juce::dsp::StateVariableTPTFilterType::lowpass); + pedalThumpLP.setCutoffFrequency (pedalThumpLpHz); + pedalThumpDecayCoeff = decayToCoeff (pedalThumpDecaySec); + + releaseThumpLP.reset(); + releaseThumpLP.prepare (juce::dsp::ProcessSpec{ sr, (juce::uint32) samplesPerBlock, 1u }); + releaseThumpLP.setType (juce::dsp::StateVariableTPTFilterType::lowpass); + releaseThumpLP.setCutoffFrequency (releaseThumpLpHz); + releaseThudHP.reset(); + releaseThudHP.prepare (juce::dsp::ProcessSpec{ sr, (juce::uint32) samplesPerBlock, 1u }); + releaseThudHP.setType (juce::dsp::StateVariableTPTFilterType::highpass); + releaseThudHP.setCutoffFrequency (releaseThudHpHz); + releaseThumpDecayCoeff = decayToCoeff (releaseThumpDecaySec); + + updateDamperCoeffs(); + + // Soundboard + soundboardReverb.reset(); + soundboardConvolution.reset(); + soundboardConvolutionDs.reset(); + pedalReverb.reset(); + pedalReverbParamsValid = false; + sympParamsValid = false; + soundboardParamsValid = false; + postReverb.reset(); + postReverbParamsValid = false; + modalChannels = juce::jmax (1, juce::jmin (2, getTotalNumOutputChannels())); + const int maxPredelaySamples = (int) std::ceil (sr * 0.020); // pmPredelayMs clamps to 20 ms + predelayCapacitySamples = juce::jmax (256, maxPredelaySamples + samplesPerBlock + 2); + predelayBuf.assign ((size_t) predelayCapacitySamples, 0.0f); + predelayWrite = 0; + modalDirty = true; + + // Apply current APVTS values to runtime state after DSP is prepared. + syncExtendedParamsFromAPVTS(); + updateSoundboardConvolution (true); + + updatePostFiltersForNote (lastMidiNote); +} + +void FluteSynthAudioProcessor::releaseResources() { prepared = false; tiltReady = false; } + +juce::AudioProcessorEditor* FluteSynthAudioProcessor::createEditor() +{ + return new FluteSynthAudioProcessorEditor (*this); +} + +#ifndef JucePlugin_PreferredChannelConfigurations +bool FluteSynthAudioProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const +{ + auto out = layouts.getMainOutputChannelSet(); + return out == juce::AudioChannelSet::mono() || out == juce::AudioChannelSet::stereo(); +} +#endif + +static inline float fastRand01(uint32_t& s) +{ + s = 1664525u * s + 1013904223u; + return (float)((s >> 8) * (1.0 / 16777216.0)); // ~[0..1) +} + +static juce::dsp::IIR::Coefficients::Ptr makeBandPass (double sr, float f, float q) +{ + f = juce::jlimit (60.0f, 5000.0f, f); + q = juce::jlimit (0.7f, 8.0f, q); + return juce::dsp::IIR::Coefficients::makeBandPass (sr, f, q); +} + +void FluteSynthAudioProcessor::updateSoundboardConvolution (bool force) +{ + if (! DebugToggles::kEnableSoundboardConvolution) + return; + if (! prepared || lastSampleRate <= 0.0) + return; + if (! force && ! soundboardIrDirty) + return; + + auto ir = buildSoundboardIr (lastSampleRate, soundboardT60Sec, soundboardDampParam); + const size_t irSamples = (size_t) ir.getNumSamples(); + if (irSamples > 0) + { +#if JUCE_VERSION_MAJOR >= 7 + soundboardConvolution.loadImpulseResponse (std::move (ir), + lastSampleRate, + juce::dsp::Convolution::Stereo::no, + juce::dsp::Convolution::Trim::no, + juce::dsp::Convolution::Normalise::no); +#else + soundboardConvolution.loadImpulseResponse (std::move (ir), + lastSampleRate, + juce::dsp::Convolution::Stereo::no, + juce::dsp::Convolution::Trim::no, + juce::dsp::Convolution::Normalise::no); +#endif + } + if (DebugToggles::kSoundboardConvolutionDownsample > 1) + { + const double dsRate = lastSampleRate / (double) DebugToggles::kSoundboardConvolutionDownsample; + auto irDs = buildSoundboardIr (dsRate, soundboardT60Sec, soundboardDampParam); + if (irDs.getNumSamples() > 0) + { +#if JUCE_VERSION_MAJOR >= 7 + soundboardConvolutionDs.loadImpulseResponse (std::move (irDs), + dsRate, + juce::dsp::Convolution::Stereo::no, + juce::dsp::Convolution::Trim::no, + juce::dsp::Convolution::Normalise::no); +#else + soundboardConvolutionDs.loadImpulseResponse (std::move (irDs), + dsRate, + juce::dsp::Convolution::Stereo::no, + juce::dsp::Convolution::Trim::no, + juce::dsp::Convolution::Normalise::no); +#endif + } + } + soundboardIrLastT60 = soundboardT60Sec; + soundboardIrLastDamp = soundboardDampParam; + soundboardIrDirty = false; +} + +juce::AudioBuffer FluteSynthAudioProcessor::buildSoundboardIr (double sampleRate, float t60Sec, float damp) const +{ + t60Sec = juce::jlimit (0.9f, 2.4f, t60Sec); + damp = juce::jlimit (0.0f, 1.0f, damp); + + const float lengthSec = juce::jlimit (0.50f, 1.60f, 0.50f + 0.45f * t60Sec); + const int numSamples = juce::jmax (64, (int) std::round (lengthSec * sampleRate)); + juce::AudioBuffer ir (1, numSamples); + ir.clear(); + + struct Mode + { + float phase { 0.0f }; + float phaseInc { 0.0f }; + float amp { 0.0f }; + float decay { 0.999f }; + }; + + const int numModes = 48; + const float fMin = 190.0f; + const float fMax = 2200.0f; + const float twoPi = juce::MathConstants::twoPi; + uint32_t seed = 0x1F2E3D4Cu; + + std::vector modes; + modes.reserve ((size_t) numModes); + + for (int i = 0; i < numModes; ++i) + { + float r = (float) (i + 1) / (float) (numModes + 1); + r = r * r * r; // cluster modes toward low-mid for more tonal body + r += (fastRand01 (seed) - 0.5f) * 0.025f; + r = juce::jlimit (0.0f, 1.0f, r); + const float freq = fMin * std::pow (fMax / fMin, r); + const float freqNorm = freq / fMax; + float modeT60 = t60Sec / (1.0f + damp * (0.9f + 3.5f * freqNorm)); + modeT60 = juce::jlimit (0.10f, t60Sec, modeT60); + const double tau = modeT60 / std::log (1000.0); + const float decay = (float) std::exp (-1.0 / (tau * sampleRate)); + const float amp = 0.08f * std::pow (1.0f - r, 1.2f) / std::sqrt (freq / fMin); + const float phase = fastRand01 (seed) * twoPi; + const float phaseInc = twoPi * freq / (float) sampleRate; + modes.push_back ({ phase, phaseInc, amp, decay }); + } + + float noiseEnv = 1.0f; + const float noiseTau = 0.003f + 0.004f * (1.0f - damp); + const float noiseDecay = (float) std::exp (-1.0 / (noiseTau * sampleRate)); + const float noiseAmp = 0.0015f; + const float lpCutoff = juce::jlimit (900.0f, 2400.0f, 2400.0f - damp * 1200.0f); + const float lpA = std::exp (-2.0f * juce::MathConstants::pi * lpCutoff / (float) sampleRate); + float lpState = 0.0f; + + float* out = ir.getWritePointer (0); + for (int n = 0; n < numSamples; ++n) + { + float sum = 0.0f; + for (auto& m : modes) + { + sum += m.amp * std::sin (m.phase); + m.phase += m.phaseInc; + if (m.phase > twoPi) + m.phase -= twoPi; + m.amp *= m.decay; + } + const float noise = (fastRand01 (seed) * 2.0f - 1.0f) * noiseAmp * noiseEnv; + noiseEnv *= noiseDecay; + const float raw = sum + noise; + lpState = (1.0f - lpA) * raw + lpA * lpState; + out[n] = lpState; + } + + // Gentle tail fade to avoid abrupt truncation. + const int fadeSamples = juce::jmax (8, (int) std::round (numSamples * 0.12f)); + for (int i = 0; i < fadeSamples; ++i) + { + const float t = (float) i / (float) (fadeSamples - 1); + const float g = 1.0f - t; + out[numSamples - fadeSamples + i] *= g; + } + + float maxAbs = 0.0f; + for (int i = 0; i < numSamples; ++i) + maxAbs = juce::jmax (maxAbs, std::abs (out[i])); + if (maxAbs > 0.0f) + ir.applyGain (0, numSamples, 0.65f / maxAbs); + + return ir; +} + +void FluteSynthAudioProcessor::applyMasterTuneToVoices() +{ + masterTuneFactor = std::pow (2.0f, masterTuneCents * (1.0f / 1200.0f)); + + for (int i = 0; i < synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (synth.getVoice (i))) + v->setMasterTuneFactor (masterTuneFactor); + + for (int i = 0; i < pmSynth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pmSynth.getVoice (i))) + v->setMasterTune (masterTuneFactor); + + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + v->setMasterTune (masterTuneFactor); +} + +bool FluteSynthAudioProcessor::anyVoiceActive() const +{ + for (int i = 0; i < synth.getNumVoices(); ++i) + if (auto* v = synth.getVoice (i)) + if (v->isVoiceActive()) + return true; + for (int i = 0; i < pmSynth.getNumVoices(); ++i) + if (auto* v = pmSynth.getVoice (i)) + if (v->isVoiceActive()) + return true; + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + if (v->isActive()) + return true; + return false; +} + +void FluteSynthAudioProcessor::syncExtendedParamsFromAPVTS() +{ + auto get = [this] (const char* id) { return apvts.getRawParameterValue (id)->load(); }; + auto getB = [this] (const char* id) { return apvts.getRawParameterValue (id)->load() >= 0.5f; }; + + // Formants + auto setFormant = [this, get, getB] (int idx, + const char* enId, + const char* fId, + const char* qId, + const char* gId) + { + const bool enabled = getB (enId); + const float freq = PresetModel::clamp (get (fId), 300.0f, 12000.0f); + const float q = PresetModel::clamp (get (qId), 0.5f, 6.0f); + const float gDb = PresetModel::clamp (get (gId), -9.0f, 9.0f); + + formant[idx].enabled = enabled; + formant[idx].f.setCutoffFrequency (freq); + formant[idx].f.setResonance (q); + formant[idx].gainLin = juce::Decibels::decibelsToGain (gDb); + }; + + setFormant (0, ParamIDs::formant1Enable, ParamIDs::formant1Freq, ParamIDs::formant1Q, ParamIDs::formant1GainDb); + setFormant (1, ParamIDs::formant2Enable, ParamIDs::formant2Freq, ParamIDs::formant2Q, ParamIDs::formant2GainDb); + + // Amp envelope (sync to voices so GUI changes are audible) + const float a = get (ParamIDs::attack); + const float d = get (ParamIDs::decay); + const float s = get (ParamIDs::sustain); + const float r = get (ParamIDs::release); + baseRelease = r; + const float releaseExtScale = juce::jlimit (0.7f, 2.0f, + juce::jmap (r, 0.03f, 7.0f, 0.7f, 2.0f)); + const float releaseExt = releaseExtension * releaseExtScale; + for (int i = 0; i < pmSynth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pmSynth.getVoice (i))) + v->setEnvParams (a, d, s, r); + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + { + v->setEnvParams (a, d, s, r); + v->setReleaseExtension (releaseExt); + } + + const int velocityChoice = (int) std::round (get (ParamIDs::velocityCurve)); + float velocityScale = 1.0f; + velocityFixed = (velocityChoice == 3); + if (velocityChoice == 0) + velocityScale = 0.90f; + else if (velocityChoice == 2) + velocityScale = 1.15f; + velocityGamma = juce::jlimit (0.1f, 3.0f, velocityGammaBase * velocityScale); + + // Soundboard + soundboardEnabled = getB (ParamIDs::soundboardEnable); + soundboardMix = juce::jlimit (0.0f, 1.0f, get (ParamIDs::soundboardMix)); + soundboardT60Sec = juce::jlimit (1.2f, 2.2f, get (ParamIDs::soundboardT60)); + soundboardDampParam = juce::jlimit (0.0f, 1.0f, get (ParamIDs::soundboardDamp)); + if (std::abs (soundboardIrLastT60 - soundboardT60Sec) > 1.0e-4f + || std::abs (soundboardIrLastDamp - soundboardDampParam) > 1.0e-4f) + soundboardIrDirty = true; + soundboardParams.roomSize = juce::jlimit (0.0f, 1.0f, soundboardT60Sec / 3.0f); + soundboardParams.damping = soundboardDampParam; + soundboardParams.width = 0.6f; + soundboardParams.wetLevel = 1.0f; + soundboardParams.dryLevel = 0.0f; + postRoomMix = juce::jlimit (0.0f, 1.0f, get (ParamIDs::postRoomMix)); + postRoomEnabled = getB (ParamIDs::postRoomEnable); + + // Felt / duplex (pm2) + pmFelt.preload = juce::jlimit (0.0f, 0.6f, get (ParamIDs::feltPreload)); + pmFelt.stiffness = juce::jlimit (1.0f, 5.0f, get (ParamIDs::feltStiffness)); + pmFelt.hysteresis = juce::jlimit (0.0f, 0.6f, get (ParamIDs::feltHysteresis)); + pmFelt.maxAmp = juce::jlimit (0.4f, 4.0f, get (ParamIDs::feltMax)); + + duplexCfg.ratio = juce::jlimit (1.1f, 4.0f, get (ParamIDs::duplexRatio)); + duplexCfg.gainDb = juce::jlimit (-20.0f, -6.0f, get (ParamIDs::duplexGainDb)); + duplexCfg.decayMs = juce::jlimit (10.0f, 400.0f, get (ParamIDs::duplexDecayMs)); + duplexCfg.sympSend = juce::jlimit (0.0f, 1.0f, get (ParamIDs::duplexSympSend)); + duplexCfg.sympMix = juce::jlimit (0.0f, 1.0f, get (ParamIDs::duplexSympMix)); + + pm2GainDb = juce::jlimit (-24.0f, 42.0f, get (ParamIDs::pm2GainDb)); + pm2GainLin = juce::Decibels::decibelsToGain (pm2GainDb); + pm2GainLinSmoothed.setTargetValue (pm2GainLin); + + sympParams.roomSize = juce::jlimit (0.0f, 1.0f, duplexCfg.decayMs / 400.0f); + sympParams.damping = 0.4f; + sympParams.wetLevel = 1.0f; + sympParams.dryLevel = 0.0f; + + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + { + v->setFeltParams (pmFelt); + v->setDuplexParams (duplexCfg); + } + + const int temperamentChoice = (int) std::round (get (ParamIDs::temperament)); + if (temperamentChoice == 0) + noteOffsetsCents = presetNoteOffsetsCents; + else + noteOffsetsCents = expandPitchClassOffsets (getTemperamentOffsetsByChoice (temperamentChoice)); + + for (int i = 0; i < synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (synth.getVoice (i))) + v->setNoteOffsets (noteOffsetsCents); + for (int i = 0; i < pmSynth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pmSynth.getVoice (i))) + v->setNoteOffsets (noteOffsetsCents); + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + v->setNoteOffsets (noteOffsetsCents); + + outputLpfEnabled = getB (ParamIDs::outputLpfEnable); + outputLpfCutoff = get (ParamIDs::outputLpfCutoff); + outputLpfQ = get (ParamIDs::outputLpfQ); + outputLpfCutoffSmoothed.setTargetValue (outputLpfCutoff); + outputLpfQSmoothed.setTargetValue (outputLpfQ); + updateOutputLpf(); +} +void FluteSynthAudioProcessor::updatePostFiltersForNote (int midiNote) +{ + if (midiNote > 0) + lastMidiNote = midiNote; + + updatePostFiltersSmoothed(); +} + +void FluteSynthAudioProcessor::updatePostFiltersSmoothed() +{ + if (lastSampleRate <= 0.0) + return; + + const float note = (float) juce::jlimit (0, 127, lastMidiNote); + const float kt = juce::jlimit (0.0f, 1.0f, postKeytrack); + const float baseCutoff = postCutoffHzSmoothed.getCurrentValue(); + const float baseQ = postQSmoothed.getCurrentValue(); + float cutoff = baseCutoff * std::pow (2.0f, (note - 60.0f) * kt * (1.0f / 12.0f)); + cutoff = juce::jlimit (300.0f, 12000.0f, cutoff); + + auto setLP = [cutoff, q = baseQ] (juce::dsp::StateVariableTPTFilter& f1, + juce::dsp::StateVariableTPTFilter& f2) + { + f1.setCutoffFrequency (cutoff); + f2.setCutoffFrequency (cutoff); + f1.setResonance (q); + f2.setResonance (q); + }; + setLP (postVaLp1, postVaLp2); + setLP (postPmLp1, postPmLp2); + setLP (postPm2Lp1, postPm2Lp2); + + const float tilt = juce::jlimit (-6.0f, 6.0f, postTiltDbSmoothed.getCurrentValue()); + const float pivot = 1000.0f; + const float gHigh = juce::Decibels::decibelsToGain (tilt); + const float gLow = juce::Decibels::decibelsToGain (-tilt); + tiltLow.coefficients = juce::dsp::IIR::Coefficients::makeLowShelf (lastSampleRate, pivot, 0.7071f, gLow); + tiltHigh.coefficients = juce::dsp::IIR::Coefficients::makeHighShelf (lastSampleRate, pivot, 0.7071f, gHigh); + tiltReady = (tiltLow.coefficients != nullptr && tiltHigh.coefficients != nullptr); +} + +void FluteSynthAudioProcessor::updateOutputLpf() +{ + const float cutoff = juce::jlimit (200.0f, 20000.0f, outputLpfCutoffSmoothed.getCurrentValue()); + const float q = juce::jlimit (0.2f, 4.0f, outputLpfQSmoothed.getCurrentValue()); + outputLpf.setCutoffFrequency (cutoff); + outputLpf.setResonance (q); +} + +void FluteSynthAudioProcessor::updateOutputEq() +{ + if (lastSampleRate <= 0.0) + return; + + auto clamp = PresetModel::clamp; + for (size_t i = 0; i < outputEqCfg.bands.size(); ++i) + { + const auto& b = outputEqCfg.bands[i]; + const float freq = clamp (b.freq, 40.0f, 16000.0f); + const float q = clamp (b.q, 0.3f, 6.0f); + const float gainDb = clamp (b.gainDb, -18.0f, 18.0f); + outputEqFilters[i].coefficients = juce::dsp::IIR::Coefficients::makePeakFilter ( + lastSampleRate, freq, q, juce::Decibels::decibelsToGain (gainDb)); + } +} + +void FluteSynthAudioProcessor::prepareBrightnessFilters() +{ + const int numCh = juce::jmax (1, getTotalNumOutputChannels()); + brightnessFilters.resize ((size_t) numCh); + juce::dsp::ProcessSpec spec; + spec.sampleRate = lastSampleRate; + spec.maximumBlockSize = mainSpec.maximumBlockSize; + spec.numChannels = (juce::uint32) numCh; + for (auto& f : brightnessFilters) + { + f.reset(); + f.prepare (spec); + } + brightnessNumChannels = numCh; + updateBrightnessFilters (0.0f); +} + +void FluteSynthAudioProcessor::updateBrightnessFilters (float targetDb) +{ + if (lastSampleRate <= 0.0 || brightnessFilters.empty()) + return; + + const float limitedDb = juce::jlimit (-12.0f, brightnessMaxDb, targetDb); + auto coeff = juce::dsp::IIR::Coefficients::makeHighShelf ( + lastSampleRate, + juce::jlimit (800.0f, 12000.0f, brightnessCutoffHz), + juce::jlimit (0.2f, 4.0f, brightnessQ), + juce::Decibels::decibelsToGain (limitedDb)); + + for (auto& f : brightnessFilters) + f.coefficients = coeff; + brightnessCurrentDb = limitedDb; +} + +void FluteSynthAudioProcessor::updateDamperCoeffs() +{ + if (lastSampleRate <= 0.0) return; + float tauSamples = (float) (damperCfg.smoothMs * 0.001 * lastSampleRate); + damperSmoothCoeff = tauSamples > 1.0f ? (1.0f - std::exp (-1.0f / juce::jmax (1.0f, tauSamples))) : 1.0f; + damperSoftenSamples = (int) std::round (damperCfg.softenMs * 0.001 * lastSampleRate); + damperSoftenA = std::exp (-2.0f * juce::MathConstants::pi * juce::jlimit (40.0f, 8000.0f, damperCfg.softenHz) / (float) juce::jmax (20.0, lastSampleRate)); + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + v->setDamperParams (damperCfg); +} + +void FluteSynthAudioProcessor::updateMicProcessors() +{ + if (! prepared || lastSampleRate <= 0.0) + return; + + micSpec.sampleRate = lastSampleRate; + micSpec.maximumBlockSize = mainSpec.maximumBlockSize; + micSpec.numChannels = (juce::uint32) juce::jmax (1, getTotalNumOutputChannels()); + micMaxDelaySamples = (int) std::round (lastSampleRate * 0.05); // up to ~50 ms + + for (auto& st : micState) + { + for (int ch = 0; ch < 2; ++ch) + { + st.delay[ch].reset(); + st.delay[ch].prepare (micSpec); + st.delay[ch].setMaximumDelayInSamples ((size_t) juce::jmax (8, micMaxDelaySamples)); + st.delay[ch].setDelay (0.0f); + st.lowShelf[ch].reset(); + st.lowShelf[ch].prepare (micSpec); + st.highShelf[ch].reset(); + st.highShelf[ch].prepare (micSpec); + } + st.delaySamples = 0.0f; + st.gainLin = 1.0f; + } + micReady = true; +} + +void FluteSynthAudioProcessor::applyMicMix (juce::AudioBuffer& buffer) +{ + if (! micReady || buffer.getNumChannels() == 0) + return; + + const int numCh = buffer.getNumChannels(); + const int n = buffer.getNumSamples(); + const float sr = (float) lastSampleRate; + if (sr <= 0.0f) + return; + + auto micConfigs = std::array{ micCfg.close, micCfg.player, micCfg.room }; + auto blend = micCfg.blend; + float bsum = blend[0] + blend[1] + blend[2]; + if (bsum <= 1.0e-6f) + blend = { 1.0f, 0.0f, 0.0f }; + else + for (float& b : blend) b /= bsum; + + // Calculate makeup gain to compensate for mic gain attenuation + // Without this, blending mics with negative gain_db values causes overall level loss + float totalLinGain = 0.0f; + for (int micIdx = 0; micIdx < 3; ++micIdx) + { + const float w = blend[(size_t) micIdx]; + if (w > 1.0e-4f) + totalLinGain += juce::Decibels::decibelsToGain (micConfigs[(size_t) micIdx].gainDb) * w; + } + const float micMakeupGain = (totalLinGain > 1.0e-4f) ? (1.0f / totalLinGain) : 1.0f; + + // Fast path: close-only, no delay/EQ + const auto& closeCfg = micConfigs[0]; + if (blend[0] > 0.999f && blend[1] < 1.0e-4f && blend[2] < 1.0e-4f + && std::abs (closeCfg.delayMs) < 1.0e-4f + && std::abs (closeCfg.lowShelfDb) < 1.0e-4f + && std::abs (closeCfg.highShelfDb) < 1.0e-4f) + { + // No makeup needed for close-only (micMakeupGain already accounts for close gain_db) + buffer.applyGain (juce::Decibels::decibelsToGain (closeCfg.gainDb) * micMakeupGain); + return; + } + + if (micScratch.getNumChannels() != numCh || micScratch.getNumSamples() != n) + micScratch.setSize (numCh, n, false, false, true); + micScratch.clear(); + + for (int micIdx = 0; micIdx < 3; ++micIdx) + { + const float w = blend[(size_t) micIdx]; + if (w <= 1.0e-4f) + continue; + + const auto& cfg = micConfigs[(size_t) micIdx]; + auto& st = micState[(size_t) micIdx]; + + st.delaySamples = juce::jlimit (0.0f, (float) micMaxDelaySamples, cfg.delayMs * 0.001f * sr); + for (int ch = 0; ch < juce::jmin (numCh, 2); ++ch) + st.delay[ch].setDelay (st.delaySamples); + + const float lsFreq = juce::jlimit (100.0f, 8000.0f, cfg.shelfFreq); + const float hsFreq = juce::jlimit (500.0f, 12000.0f, cfg.shelfFreq); + auto lsCoeff = juce::dsp::IIR::Coefficients::makeLowShelf (lastSampleRate, lsFreq, 0.7071f, + juce::Decibels::decibelsToGain (cfg.lowShelfDb)); + auto hsCoeff = juce::dsp::IIR::Coefficients::makeHighShelf (lastSampleRate, hsFreq, 0.7071f, + juce::Decibels::decibelsToGain (cfg.highShelfDb)); + for (int ch = 0; ch < juce::jmin (numCh, 2); ++ch) + { + st.lowShelf[ch].coefficients = lsCoeff; + st.highShelf[ch].coefficients = hsCoeff; + } + st.gainLin = juce::Decibels::decibelsToGain (cfg.gainDb) * w; + + for (int ch = 0; ch < numCh; ++ch) + { + const int stateIdx = juce::jmin (ch, 1); + auto* dst = micScratch.getWritePointer (ch); + const auto* src = buffer.getReadPointer (ch); + for (int i = 0; i < n; ++i) + { + float s = st.delay[stateIdx].popSample (0); + st.delay[stateIdx].pushSample (0, src[i]); + if (st.lowShelf[stateIdx].coefficients != nullptr) + s = st.lowShelf[stateIdx].processSample (s); + if (st.highShelf[stateIdx].coefficients != nullptr) + s = st.highShelf[stateIdx].processSample (s); + dst[i] += s * st.gainLin; + } + } + } + + buffer.makeCopyOf (micScratch, true); + + // Apply makeup gain to compensate for mic blend attenuation + buffer.applyGain (micMakeupGain); +} +void FluteSynthAudioProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midi) +{ + juce::ScopedNoDenormals noDenormals; + + // Guard against hosts calling before prepareToPlay. + if (! prepared || lastSampleRate <= 0.0) + { + buffer.clear(); + return; + } + + const int numCh = juce::jmax (1, buffer.getNumChannels()); + const int numSamples = buffer.getNumSamples(); + auto ensureScratch = [numSamples] (juce::AudioBuffer& buf, int channels) + { + if (buf.getNumChannels() != channels || buf.getNumSamples() != numSamples) + buf.setSize (channels, numSamples, false, false, true); + buf.clear(); + }; + const int maxPredelaySamples = (int) std::ceil (lastSampleRate * 0.020); + const int minPredelayCapacity = maxPredelaySamples + numSamples + 2; + if (predelayCapacitySamples < minPredelayCapacity) + { + predelayCapacitySamples = minPredelayCapacity; + predelayBuf.assign ((size_t) predelayCapacitySamples, 0.0f); + predelayWrite = 0; + } + + auto reverbParamsEqual = [] (const juce::Reverb::Parameters& a, const juce::Reverb::Parameters& b) + { + const float eps = 1.0e-4f; + return std::abs (a.roomSize - b.roomSize) < eps + && std::abs (a.damping - b.damping) < eps + && std::abs (a.wetLevel - b.wetLevel) < eps + && std::abs (a.dryLevel - b.dryLevel) < eps + && std::abs (a.width - b.width) < eps + && std::abs (a.freezeMode - b.freezeMode) < eps; + }; + auto resetPostState = [this]() + { + postVaLp1.reset(); postVaLp2.reset(); + postPmLp1.reset(); postPmLp2.reset(); + postPm2Lp1.reset(); postPm2Lp2.reset(); + tiltLow.reset(); tiltHigh.reset(); + outputLpf.reset(); + for (auto& f : outputEqFilters) + f.reset(); + breathBp.reset(); + for (int i = 0; i < 2; ++i) formant[i].f.reset(); + soundboardReverb.reset(); + soundboardConvolution.reset(); + soundboardConvolutionDs.reset(); + soundboardIrDirty = true; + soundboardIrLastT60 = 0.0f; + soundboardIrLastDamp = 0.0f; + pedalReverb.reset(); + sympReverb.reset(); + postReverb.reset(); + soundboardParamsValid = false; + pedalReverbParamsValid = false; + sympParamsValid = false; + postReverbParamsValid = false; + std::fill (predelayBuf.begin(), predelayBuf.end(), 0.0f); + tiltReady = (tiltLow.coefficients != nullptr && tiltHigh.coefficients != nullptr); + }; + + if (pendingStateReset && ! anyVoiceActive()) + { + resetPostState(); + pendingStateReset = false; + } + + if (auto* vol = apvts.getRawParameterValue (ParamIDs::masterVolume)) + masterVolumeLin = juce::jlimit (0.0f, 2.0f, vol->load()); + // Minimum note duration: pitch-dependent, fixed milliseconds (tempo-independent). + // FIX: Drastically reduced minimum durations to allow very short staccato notes + // Previous values (240-960ms) forced notes to play much longer than intended + const double minLowMs = 0.0; // Was 240.0 - no forced minimum + const double minMidLowMs = 0.0; // Was 480.0 - no forced minimum + const double minMidHighMs = 0.0; // Was 720.0 - no forced minimum + const double minHighMs = 0.0; // Was 960.0 - no forced minimum + const int minNoteLow = (int) std::round (minLowMs * 0.001 * lastSampleRate); + const int minNoteMidLow = (int) std::round (minMidLowMs * 0.001 * lastSampleRate); + const int minNoteMidHigh = (int) std::round (minMidHighMs * 0.001 * lastSampleRate); + const int minNoteHigh = (int) std::round (minHighMs * 0.001 * lastSampleRate); + const int split1 = 36; // C2 and below + const int split2 = 48; // C3 and below + const int split3 = DebugToggles::kEnablePm2MinDurationC4Split ? 60 : 127; // C4 split optional + pm2Synth.setMinNoteDurationRanges (minNoteLow, minNoteMidLow, minNoteMidHigh, minNoteHigh, + split1, split2, split3); + + // Apply any pending preset reset on the audio thread to avoid GUI/DSP races. + if (const int pendingPreset = pendingEmbeddedPresetIndex.exchange (-1, std::memory_order_acq_rel); + pendingPreset >= 0) + { + if (! embeddedPresetLoaded.load()) + loadEmbeddedPresetModel(); + + const int numPresets = (int) embeddedPresets.size(); + if (embeddedPresetLoaded.load() && numPresets > 0) + { + const int presetIdx = juce::jlimit (0, numPresets - 1, pendingPreset); + activeEmbeddedPresetIndex.store (presetIdx, std::memory_order_release); + + // Stop current voices to avoid artifacts when parameters jump. + synth.allNotesOff (0, true); + pmSynth.allNotesOff (0, true); + pm2Synth.allNotesOff (0, true); + + applyPresetToParameters (embeddedPresets[(size_t) presetIdx].model); + pendingStateReset = true; + if (! anyVoiceActive()) + { + resetPostState(); + pendingStateReset = false; + } + } + } + + buffer.clear(); + syncExtendedParamsFromAPVTS(); + const float outputGainStart = outputGainLinSmoothed.getCurrentValue(); + if (numSamples > 0) + { + pm2GainLinSmoothed.skip (numSamples); + outputGainLinSmoothed.skip (numSamples); + postCutoffHzSmoothed.skip (numSamples); + postQSmoothed.skip (numSamples); + postTiltDbSmoothed.skip (numSamples); + outputLpfCutoffSmoothed.skip (numSamples); + outputLpfQSmoothed.skip (numSamples); + } + const float outputGainEnd = outputGainLinSmoothed.getCurrentValue(); + pm2GainLin = pm2GainLinSmoothed.getCurrentValue(); + outputGainLin = outputGainEnd; + updatePostFiltersSmoothed(); + updateOutputLpf(); + + // FIX #1 & #4: Clear shared buses once at block start (not per segment) + // This ensures all voices can read/write with consistent block-relative indices + couplingBus.begin (numSamples); + sympBus.begin (numSamples); + + // detect note-on/off (for hammer trigger + filter keytracking + pedal state) and apply velocity curve shaping + struct MidiEvent { juce::MidiMessage msg; int pos { 0 }; }; + std::vector events; + events.reserve ((size_t) midi.getNumEvents()); + std::vector splitPoints; + splitPoints.reserve ((size_t) midi.getNumEvents() + 2); + splitPoints.push_back (0); + splitPoints.push_back (numSamples); + + int noteOnCount = 0; + int noteOffCount = 0; + int cc64Count = 0; + int otherCount = 0; + int firstEventPos = -1; + int lastEventPos = -1; + for (const auto meta : midi) + { + auto m = meta.getMessage(); + if (m.isNoteOn()) + { + // FIX: getVelocity() returns 0-127 (uint8), NOT 0.0-1.0! + // Must normalize by dividing by 127 + float vel = juce::jlimit (0.0f, 1.0f, (float) m.getVelocity() / 127.0f); + if (velocityFixed) + vel = 1.0f; + else + vel = std::pow (vel, juce::jmax (0.1f, velocityGamma)); + vel = juce::jlimit (0.0f, 1.0f, vel); + m = juce::MidiMessage::noteOn (m.getChannel(), m.getNoteNumber(), vel); + ++noteOnCount; + } + else if (m.isNoteOff()) + { + ++noteOffCount; + } + else if (m.isController()) + { + if (m.getControllerNumber() == 64) + ++cc64Count; + else + ++otherCount; + } + else + { + ++otherCount; + } + const int clampedPos = juce::jlimit (0, juce::jmax (0, numSamples - 1), meta.samplePosition); + if (firstEventPos < 0) + firstEventPos = clampedPos; + lastEventPos = clampedPos; + events.push_back (MidiEvent { m, clampedPos }); + splitPoints.push_back (clampedPos); + } + + std::stable_sort (events.begin(), events.end(), + [] (const MidiEvent& a, const MidiEvent& b) { return a.pos < b.pos; }); + std::sort (splitPoints.begin(), splitPoints.end()); + splitPoints.erase (std::unique (splitPoints.begin(), splitPoints.end()), splitPoints.end()); + + auto smoothstep = [] (float t) + { + t = juce::jlimit (0.0f, 1.0f, t); + return t * t * (3.0f - 2.0f * t); + }; + + const float sustainHysteresis = 0.08f; + const float sustainOnThresh = juce::jlimit (0.0f, 1.0f, pedalCfg.sustainThresh); + const float sustainOffThresh = juce::jmax (pedalCfg.halfThresh, sustainOnThresh - sustainHysteresis); + + const bool usePM = (currentEngine == "pm") || (currentEngine == "hybrid"); + const bool usePM2 = (currentEngine == "pm2") || (currentEngine == "hybrid"); + const bool useVA = (currentEngine == "va") || (currentEngine == "hybrid"); + if (currentEngine == "hybrid") + { + if (useVA) ensureScratch (hybridVaBuf, numCh); + if (usePM) ensureScratch (hybridPmBuf, numCh); + if (usePM2) ensureScratch (hybridPm2Buf, numCh); + } + + auto applyPostLpfSegment = [] (juce::AudioBuffer& buf, + juce::dsp::StateVariableTPTFilter& f1, + juce::dsp::StateVariableTPTFilter& f2, + int start, int num) + { + if (num <= 0) + return; + juce::dsp::AudioBlock block (buf); + auto sub = block.getSubBlock ((size_t) start, (size_t) num); + juce::dsp::ProcessContextReplacing ctx (sub); + f1.process (ctx); + f2.process (ctx); + }; + + auto clearSegment = [] (juce::AudioBuffer& buf, int start, int num) + { + for (int ch = 0; ch < buf.getNumChannels(); ++ch) + buf.clear (ch, start, num); + }; + auto applyGainSegment = [] (juce::AudioBuffer& buf, float gain, int start, int num) + { + if (std::abs (gain - 1.0f) < 1.0e-6f || num <= 0) + return; + for (int ch = 0; ch < buf.getNumChannels(); ++ch) + buf.applyGain (ch, start, num, gain); + }; + auto applyGainRampSegment = [] (juce::AudioBuffer& buf, float startGain, float endGain, int start, int num) + { + if (num <= 0) + return; + if (std::abs (startGain - endGain) < 1.0e-6f) + { + if (std::abs (startGain - 1.0f) < 1.0e-6f) + return; + for (int ch = 0; ch < buf.getNumChannels(); ++ch) + buf.applyGain (ch, start, num, startGain); + return; + } + for (int ch = 0; ch < buf.getNumChannels(); ++ch) + buf.applyGainRamp (ch, start, num, startGain, endGain); + }; + auto applySoftClipSegment = [] (juce::AudioBuffer& buf, int start, int num, float drive) + { + if (num <= 0) + return; + const float k = juce::jlimit (0.5f, 3.0f, drive); + const float norm = 1.0f / std::tanh (k); + for (int ch = 0; ch < buf.getNumChannels(); ++ch) + { + auto* x = buf.getWritePointer (ch, start); + for (int i = 0; i < num; ++i) + x[i] = std::tanh (k * x[i]) * norm; + } + }; + auto applyDeclickSegment = [&smoothstep] (juce::AudioBuffer& buf, int start, int num, int declickSamples) + { + if (declickSamples <= 0 || num <= 0) + return; + const int rampLen = juce::jmin (num, declickSamples); + for (int ch = 0; ch < buf.getNumChannels(); ++ch) + { + auto* x = buf.getWritePointer (ch, start); + for (int i = 0; i < rampLen; ++i) + { + const float t = (float) (i + 1) / (float) rampLen; + const float g = smoothstep (t); + x[i] *= g; + } + } + }; + auto applyDeclickOutSegment = [&smoothstep] (juce::AudioBuffer& buf, int start, int num, int declickSamples) + { + if (declickSamples <= 0 || num <= 0) + return; + const int rampLen = juce::jmin (num, declickSamples); + for (int ch = 0; ch < buf.getNumChannels(); ++ch) + { + auto* x = buf.getWritePointer (ch, start); + for (int i = 0; i < rampLen; ++i) + { + const float t = (float) (i + 1) / (float) rampLen; + const float g = smoothstep (1.0f - t); + x[i] *= g; + } + } + }; + + auto countActivePm2Voices = [&]() + { + int active = 0; + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + if (v->isActive()) + ++active; + return active; + }; + + std::size_t eventIdx = 0; + for (size_t s = 0; s + 1 < splitPoints.size(); ++s) + { + const int segStart = splitPoints[s]; + const int segEnd = splitPoints[s + 1]; + const int segLen = segEnd - segStart; + if (segLen <= 0) + continue; + + // CPU optimisation: earlier economy mode activation under high polyphony + const int activeVoicesNow = countActivePm2Voices(); + const bool highPolyMode = activeVoicesNow >= 4; + if (pm2EconomyMode != highPolyMode) + { + pm2EconomyMode = highPolyMode; + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + { + v->setEconomyMode (pm2EconomyMode); + v->setHighPolyMode (pm2EconomyMode); + } + } + { + const int maxVoices = 12; + const float minScale = 0.6f; + float polyScale = 1.0f; + if (activeVoicesNow > 4) + { + const float t = juce::jlimit (0.0f, 1.0f, (float) (activeVoicesNow - 4) / (float) (maxVoices - 4)); + polyScale = 1.0f + t * (minScale - 1.0f); + } + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + v->setPolyphonyScale (polyScale); + } + + bool sustainChanged = false; + bool sustainUpdatePending = false; + float keyOffVelAcc = 0.0f; + float releaseThumpAcc = 0.0f; + bool sawNoteOn = false; + int noteNumber = 0; + float noteOnVel = 0.0f; + juce::MidiBuffer midiSegment; + juce::MidiBuffer midiSegmentPm2; + + while (eventIdx < events.size() && events[eventIdx].pos < segStart) + ++eventIdx; + + // FIX: Count note-ons at this position FIRST, then preallocate all voices at once + // This prevents JUCE's internal voice stealing from interfering with chords + // Also include repeated same-note note-ons so we allow overlap instead of hard retrigger. + { + int noteOnCount = 0; + if (usePM2) + { + size_t peekIdx = eventIdx; + while (peekIdx < events.size() && events[peekIdx].pos == segStart) + { + const auto& msg = events[peekIdx].msg; + if (msg.isNoteOn()) + ++noteOnCount; + ++peekIdx; + } + } + if (noteOnCount > 0) + pm2Synth.preallocateVoicesForChord (noteOnCount); + } + + while (eventIdx < events.size() && events[eventIdx].pos == segStart) + { + const int eventPos = events[eventIdx].pos; + auto m = events[eventIdx].msg; + const bool isAllowedController = m.isController() + && (m.getControllerNumber() == 64 || m.getControllerNumber() == 67); + const bool allowToPass = m.isNoteOn() || m.isNoteOff() || isAllowedController; + if (m.isController()) + { + if (m.getControllerNumber() == 64) // sustain + { + sustainValue = juce::jlimit (0.0f, 1.0f, m.getControllerValue() / 127.0f); + sustainUpdatePending = true; + } + else if (m.getControllerNumber() == 67) // soft / una corda + { + softPedalDown = m.getControllerValue() >= 32; + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + v->setSoftPedal (softPedalDown, unaCfg); + } + } + if (m.isNoteOn()) + { + sawNoteOn = true; + noteNumber = m.getNoteNumber(); + // FIX: getVelocity() returns 0-127, must normalize by dividing by 127 + noteOnVel = juce::jlimit (0.0f, 1.0f, (float) m.getVelocity() / 127.0f); + // Note: preallocateVoicesForChord was already called above for all note-ons + updatePostFiltersForNote (noteNumber); + } + else if (m.isNoteOff()) + { + // FIX: getVelocity() returns 0-127, must normalize by dividing by 127 + float relVel = juce::jlimit (0.0f, 1.0f, (float) m.getVelocity() / 127.0f); + if (keyOffEnabled) + { + float amt = keyOffLevel * (keyOffVelScale ? juce::jlimit (0.2f, 1.0f, relVel) : 1.0f); + keyOffVelAcc = juce::jlimit (0.0f, 4.0f, keyOffVelAcc + amt); + } + if (releaseThumpEnabled && ! sustainPedalDown) + { + float amt = releaseThumpLevel * (keyOffVelScale ? juce::jlimit (0.2f, 1.0f, relVel) : 1.0f); + releaseThumpAcc = juce::jlimit (0.0f, 4.0f, releaseThumpAcc + amt); + } + } + + if (allowToPass) + { + if (usePM2) + midiSegmentPm2.addEvent (m, eventPos); + midiSegment.addEvent (m, eventPos); + } + ++eventIdx; + } + + if (sawNoteOn) + { + lastVelocityNorm = noteOnVel; + if (hammerEnabled) + { + hammerActive = true; + hammerEnv = hammerLevel; + } + } + + if (keyOffVelAcc > 0.0f) + keyOffEnv = juce::jlimit (0.0f, 4.0f, keyOffEnv + keyOffVelAcc); + if (releaseThumpAcc > 0.0f) + releaseThumpEnv = juce::jlimit (0.0f, 4.0f, releaseThumpEnv + releaseThumpAcc); + + float sustainValuePrev = sustainValueSmoothed.getCurrentValue(); + sustainValueSmoothed.setTargetValue (sustainValue); + if (segLen > 0) + sustainValueSmoothed.skip (segLen); + float sustainValueSmooth = sustainValueSmoothed.getCurrentValue(); + if (DebugToggles::kDisableSustainPedal) + { + sustainValue = 0.0f; + sustainValuePrev = 0.0f; + sustainValueSmooth = 0.0f; + sustainValueSmoothed.setTargetValue (0.0f); + sustainPedalDown = false; + } + { + const bool newDown = sustainPedalDown ? (sustainValueSmooth >= sustainOffThresh) + : (sustainValueSmooth >= sustainOnThresh); + if (newDown != sustainPedalDown) + { + sustainPedalDown = newDown; + sustainChanged = true; + const int activeVoices = countActivePm2Voices(); + const int declickSamples = juce::jlimit (96, 384, 96 + activeVoices * 24); + pm2Synth.requestDeclickOut (declickSamples); + pm2Synth.requestDeclick (declickSamples); + if (pedalThumpEnabled) + { + const float changeAmt = std::abs (sustainValueSmooth - sustainValuePrev); + const float scale = juce::jlimit (0.2f, 1.0f, changeAmt > 0.0001f ? changeAmt : (newDown ? sustainValueSmooth : sustainValuePrev)); + pedalThumpEnv = juce::jlimit (0.0f, 4.0f, pedalThumpEnv + pedalThumpLevel * scale); + } + } + else if (sustainUpdatePending && pedalThumpEnabled) + { + const float changeAmt = std::abs (sustainValueSmooth - sustainValuePrev); + const float scale = juce::jlimit (0.1f, 0.6f, changeAmt); + if (scale > 1.0e-4f) + pedalThumpEnv = juce::jlimit (0.0f, 4.0f, pedalThumpEnv + pedalThumpLevel * scale); + } + } + + float damperLiftBlock = damperLift; + if (sustainPedalDown) damperLiftBlock = 1.0f; + else if (sustainValueSmooth <= pedalCfg.halfThresh) damperLiftBlock = 0.0f; + else + { + const float span = juce::jmax (0.001f, pedalCfg.sustainThresh - pedalCfg.halfThresh); + const float t = (sustainValueSmooth - pedalCfg.halfThresh) / span; + damperLiftBlock = smoothstep (t); + } + + const float sustainReleaseScale = juce::jlimit (1.0f, 4.0f, pedalCfg.sustainReleaseScale); + const float releaseScaleTarget = sustainPedalDown ? sustainReleaseScale + : ((sustainValueSmooth >= pedalCfg.halfThresh) ? halfReleaseScale : 1.0f); + sustainReleaseScaleSmoothed.setTargetValue (releaseScaleTarget); + if (segLen > 0) + sustainReleaseScaleSmoothed.skip (segLen); + const float releaseScale = sustainReleaseScaleSmoothed.getCurrentValue(); + for (int i = 0; i < pmSynth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pmSynth.getVoice (i))) + v->setReleaseScale (baseRelease, releaseScale); + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + v->setReleaseScale (baseRelease, releaseScale); + if (sustainChanged) + { + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + v->setSustainPedalDown (sustainPedalDown); + } + damperLift = damperLiftBlock; + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + v->setDamperLift (damperLiftBlock); + + if (currentEngine == "pm") + { + pmSynth.renderNextBlock (buffer, midiSegment, segStart, segLen); + if (DebugToggles::kEnableGlobalFilters) + applyPostLpfSegment (buffer, postPmLp1, postPmLp2, segStart, segLen); + } + else if (currentEngine == "pm2") + { + // FIX #4: Buses are now cleared once at block start, not per segment + pm2Synth.renderNextBlock (buffer, midiSegmentPm2, segStart, segLen); + applyDeclickOutSegment (buffer, segStart, segLen, pm2Synth.consumeDeclickOutSamples()); + applyDeclickSegment (buffer, segStart, segLen, pm2Synth.consumeDeclickSamples()); + applyGainSegment (buffer, pm2GainLin, segStart, segLen); + // FIX #2: Smoothed polyphony compensation to prevent gain jumps + { + int activeVoices = 0; + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + if (v->isActive()) + ++activeVoices; + // Gentle linear compensation: 100% at 1 voice, 90% at 4, 80% at 7, 70% floor at 10+ + const float rawComp = (activeVoices > 1) ? (1.0f - ((float) (activeVoices - 1)) / 140.0f) : 1.0f; + polyCompTarget = juce::jmax (0.95f, rawComp); + // Smooth towards target over ~5-10ms to avoid clicks (closed-form per segment) + if (segLen > 0) + { + const float decay = std::pow (1.0f - polyCompSmoothCoeff, (float) segLen); + polyCompSmoothed = polyCompTarget + (polyCompSmoothed - polyCompTarget) * decay; + } + if (std::abs (polyCompSmoothed - 1.0f) > 1.0e-4f) + applyGainSegment (buffer, polyCompSmoothed, segStart, segLen); + } + { + float targetGain = 1.0f; + if (sustainPedalDown && pedalCfg.sustainGainDb > 0.01f) + { + const float sustainGainLin = juce::Decibels::decibelsToGain (pedalCfg.sustainGainDb); + targetGain = mixLinear (1.0f, sustainGainLin, sustainValueSmooth); + } + sustainGainLinSmoothed.setTargetValue (targetGain); + const float startGain = sustainGainLinSmoothed.getCurrentValue(); + sustainGainLinSmoothed.skip (segLen); + const float endGain = sustainGainLinSmoothed.getCurrentValue(); + if (std::abs (startGain - 1.0f) > 1.0e-4f || std::abs (endGain - 1.0f) > 1.0e-4f) + applyGainRampSegment (buffer, startGain, endGain, segStart, segLen); + } + if (pm2GainDb > 0.01f) + applySoftClipSegment (buffer, segStart, segLen, 1.6f); + if (DebugToggles::kEnableGlobalFilters) + applyPostLpfSegment (buffer, postPm2Lp1, postPm2Lp2, segStart, segLen); + } + else if (currentEngine == "va") + { + synth.renderNextBlock (buffer, midiSegment, segStart, segLen); + if (DebugToggles::kEnableGlobalFilters) + applyPostLpfSegment (buffer, postVaLp1, postVaLp2, segStart, segLen); + } + else // hybrid + { + if (useVA) + { + clearSegment (hybridVaBuf, segStart, segLen); + synth.renderNextBlock (hybridVaBuf, midiSegment, segStart, segLen); + if (DebugToggles::kEnableGlobalFilters) + applyPostLpfSegment (hybridVaBuf, postVaLp1, postVaLp2, segStart, segLen); + } + + if (usePM) + { + clearSegment (hybridPmBuf, segStart, segLen); + pmSynth.renderNextBlock (hybridPmBuf, midiSegment, segStart, segLen); + if (DebugToggles::kEnableGlobalFilters) + applyPostLpfSegment (hybridPmBuf, postPmLp1, postPmLp2, segStart, segLen); + } + + if (usePM2) + { + clearSegment (hybridPm2Buf, segStart, segLen); + // FIX #4: Buses are now cleared once at block start, not per segment + pm2Synth.renderNextBlock (hybridPm2Buf, midiSegmentPm2, segStart, segLen); + applyDeclickOutSegment (hybridPm2Buf, segStart, segLen, pm2Synth.consumeDeclickOutSamples()); + applyDeclickSegment (hybridPm2Buf, segStart, segLen, pm2Synth.consumeDeclickSamples()); + applyGainSegment (hybridPm2Buf, pm2GainLin, segStart, segLen); + // FIX #2: Smoothed polyphony compensation to prevent gain jumps + { + int activeVoices = 0; + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + if (v->isActive()) + ++activeVoices; + // Gentle linear compensation: 100% at 1 voice, 90% at 4, 80% at 7, 70% floor at 10+ + const float rawComp = (activeVoices > 1) ? (1.0f - ((float) (activeVoices - 1)) / 140.0f) : 1.0f; + polyCompTarget = juce::jmax (0.95f, rawComp); + if (segLen > 0) + { + const float decay = std::pow (1.0f - polyCompSmoothCoeff, (float) segLen); + polyCompSmoothed = polyCompTarget + (polyCompSmoothed - polyCompTarget) * decay; + } + if (std::abs (polyCompSmoothed - 1.0f) > 1.0e-4f) + applyGainSegment (hybridPm2Buf, polyCompSmoothed, segStart, segLen); + } + { + float targetGain = 1.0f; + if (sustainPedalDown && pedalCfg.sustainGainDb > 0.01f) + { + const float sustainGainLin = juce::Decibels::decibelsToGain (pedalCfg.sustainGainDb); + targetGain = mixLinear (1.0f, sustainGainLin, sustainValueSmooth); + } + sustainGainLinSmoothed.setTargetValue (targetGain); + const float startGain = sustainGainLinSmoothed.getCurrentValue(); + sustainGainLinSmoothed.skip (segLen); + const float endGain = sustainGainLinSmoothed.getCurrentValue(); + if (std::abs (startGain - 1.0f) > 1.0e-4f || std::abs (endGain - 1.0f) > 1.0e-4f) + applyGainRampSegment (hybridPm2Buf, startGain, endGain, segStart, segLen); + } + if (pm2GainDb > 0.01f) + applySoftClipSegment (hybridPm2Buf, segStart, segLen, 1.6f); + if (DebugToggles::kEnableGlobalFilters) + applyPostLpfSegment (hybridPm2Buf, postPm2Lp1, postPm2Lp2, segStart, segLen); + } + + float wVa = juce::jlimit (0.0f, 1.0f, vaMix); + float wPm = juce::jlimit (0.0f, 1.0f, pmMix); + float wPm2 = juce::jlimit (0.0f, 1.0f, pm2Mix); + float sum = juce::jmax (0.0001f, wVa + wPm + wPm2); + wVa /= sum; wPm /= sum; wPm2 /= sum; + + for (int chOut = 0; chOut < buffer.getNumChannels(); ++chOut) + { + auto* dst = buffer.getWritePointer (chOut, segStart); + auto* vaP = hybridVaBuf.getReadPointer (juce::jmin (chOut, hybridVaBuf.getNumChannels() - 1), segStart); + auto* pmP = hybridPmBuf.getReadPointer (juce::jmin (chOut, hybridPmBuf.getNumChannels() - 1), segStart); + auto* pm2P = hybridPm2Buf.getReadPointer (juce::jmin (chOut, hybridPm2Buf.getNumChannels() - 1), segStart); + for (int i = 0; i < segLen; ++i) + dst[i] += vaP[i] * wVa + pmP[i] * wPm + pm2P[i] * wPm2; + } + } + } + +#if JUCE_DEBUG + static int dbgBlockCounter = 0; + ++dbgBlockCounter; + const int eventsCount = (int) events.size(); + const int segmentsCount = (int) splitPoints.size() - 1; + const bool eventDrift = (eventIdx != events.size()); + const float peak = buffer.getMagnitude (0, buffer.getNumSamples()); + if ((dbgBlockCounter % 200) == 0 || eventsCount > 0 || peak > 1.0e-4f || eventDrift) + { + int activePm2 = 0; + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + if (v->isActive()) + ++activePm2; + + DBG ("[MusPianoVST] blk=" << dbgBlockCounter + << " events=" << eventsCount + << " noteOn=" << noteOnCount + << " noteOff=" << noteOffCount + << " cc64=" << cc64Count + << " other=" << otherCount + << " segments=" << segmentsCount + << " pm2Active=" << activePm2 + << " peak=" << peak + << " firstPos=" << firstEventPos + << " lastPos=" << lastEventPos + << " eventDrift=" << (eventDrift ? "YES" : "no")); + } +#endif + + // Optional shaper applied to VA-only or hybrid paths + if (currentEngine == "va") + { + if (shaperEnabled && shaperDrive > 0.001f) + { + const float k = juce::jlimit (0.01f, 3.0f, shaperDrive * 3.0f); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto* x = buffer.getWritePointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + x[i] = std::tanh (k * x[i]); + } + } + } + else if (currentEngine == "hybrid") + { + if (shaperEnabled && shaperDrive > 0.001f) + { + const float k = juce::jlimit (0.01f, 3.0f, shaperDrive * 3.0f); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto* x = buffer.getWritePointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + x[i] = std::tanh (k * x[i]); + } + } + } + + #if 0 + // --- Hammer transient (additive burst) --- + if (hammerActive && hammerEnv > 1e-6f) + { + const int n = buffer.getNumSamples(); + const int chs = buffer.getNumChannels(); + + for (int i = 0; i < n; ++i) + { + float w = fastRand01(hammerRng) * 2.0f - 1.0f; // white ~[-1,1] + w *= hammerNoise; + + float shaped = DebugToggles::kEnableHammerFilter ? hammerHP.processSample (0, w) : w; + float s = shaped * hammerEnv; + + for (int ch = 0; ch < chs; ++ch) + { + auto* dst = buffer.getWritePointer (ch); + dst[i] += s; + } + + hammerEnv *= hammerDecayCoeff; + if (hammerEnv < 1e-6f) + { + hammerEnv = 0.0f; + hammerActive = false; + break; + } + } + } + #endif + + // --- Key-off noise burst --- + if (keyOffEnabled && keyOffEnv > 1e-6f) + { + const int n = buffer.getNumSamples(); + const int chs = buffer.getNumChannels(); + for (int i = 0; i < n; ++i) + { + float w = fastRand01 (hammerRng) * 2.0f - 1.0f; + float shaped = DebugToggles::kEnableKeyOffFilter ? keyOffHP.processSample (0, w) : w; + float s = shaped * keyOffEnv; + for (int ch = 0; ch < chs; ++ch) + buffer.getWritePointer (ch)[i] += s; + + keyOffEnv *= keyOffDecayCoeff; + if (keyOffEnv < 1e-6f) { keyOffEnv = 0.0f; break; } + } + } + + // --- Release thump (dampers hitting strings) --- + if (releaseThumpEnabled && releaseThumpEnv > 1e-6f) + { + const int n = buffer.getNumSamples(); + const int chs = buffer.getNumChannels(); + for (int i = 0; i < n; ++i) + { + float w = fastRand01 (hammerRng) * 2.0f - 1.0f; + float low = DebugToggles::kEnableReleaseThumpFilter ? releaseThumpLP.processSample (0, w) : w; + float thud = DebugToggles::kEnableReleaseThudFilter ? releaseThudHP.processSample (0, low) : low; + float s = (low * (1.0f - releaseThudMix) + thud * releaseThudMix) * releaseThumpEnv; + for (int ch = 0; ch < chs; ++ch) + buffer.getWritePointer (ch)[i] += s; + + releaseThumpEnv *= releaseThumpDecayCoeff; + if (releaseThumpEnv < 1e-6f) { releaseThumpEnv = 0.0f; break; } + } + } + + // --- Pedal thump (CC64 transitions) --- + if (pedalThumpEnabled && pedalThumpEnv > 1e-6f) + { + const int n = buffer.getNumSamples(); + const int chs = buffer.getNumChannels(); + for (int i = 0; i < n; ++i) + { + float w = fastRand01 (hammerRng) * 2.0f - 1.0f; + float shaped = DebugToggles::kEnablePedalThumpFilter ? pedalThumpLP.processSample (0, w) : w; + float s = shaped * pedalThumpEnv; + for (int ch = 0; ch < chs; ++ch) + buffer.getWritePointer (ch)[i] += s; + + pedalThumpEnv *= pedalThumpDecayCoeff; + if (pedalThumpEnv < 1e-6f) { pedalThumpEnv = 0.0f; break; } + } + } + + // --- Breath noise (post) --- + if (breathEnabled && breathGainLin > 0.0f) + { + ensureScratch (breathScratch, 1); + float* t = breathScratch.getWritePointer (0); + static uint32_t rng = 0xBEEFBEEF; + for (int i = 0; i < buffer.getNumSamples(); ++i) + { + rng = 1664525u * rng + 1013904223u; + t[i] = ((rng >> 8) * (1.0f / 16777216.0f)) * 2.0f - 1.0f; + } + + if (DebugToggles::kEnableBreathFilter) + { + juce::dsp::AudioBlock b (breathScratch); + juce::dsp::ProcessContextReplacing ctx (b); + breathBp.process (ctx); + } + + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto* dst = buffer.getWritePointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + dst[i] += breathGainLin * t[i]; + } + } + + // --- Formants (post) --- PARALLEL RESONANCE PEAKS + // Instead of replacing the signal with bandpass-filtered content, + // we add resonance peaks in parallel to preserve the full spectrum. + if (DebugToggles::kEnableFormant) + { + for (int k = 0; k < 2; ++k) + { + if (! formant[k].enabled) continue; + + // Create a copy for wet/resonance signal + formantScratch.makeCopyOf (buffer, true); + + juce::dsp::AudioBlock block (formantScratch); + juce::dsp::ProcessContextReplacing ctx (block); + formant[k].f.process (ctx); + + // Mix formant resonance back in additively. 0 dB means neutral. + const float peakGain = formant[k].gainLin - 1.0f; + if (std::abs (peakGain) < 1.0e-4f) + continue; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto* dry = buffer.getWritePointer (ch); + const auto* w = formantScratch.getReadPointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + dry[i] += w[i] * peakGain; + } + } + } + + // --- Post tone controls: tilt only (LPFs were applied per-engine pre-sum) --- + if (DebugToggles::kEnableTilt) + { + // If host channel count changed after prepareToPlay, re-prepare tilt filters. + if (buffer.getNumChannels() > 0 && buffer.getNumChannels() != tiltNumChannels) + { + juce::dsp::ProcessSpec spec = mainSpec; + spec.numChannels = (juce::uint32) juce::jmax (1, buffer.getNumChannels()); + tiltLow.reset(); tiltHigh.reset(); + tiltLow.prepare (spec); tiltHigh.prepare (spec); + tiltNumChannels = buffer.getNumChannels(); + tiltReady = false; + updatePostFiltersForNote (lastMidiNote); + auto prepSendHpfDynamic = [this, &spec] (decltype (pedalSendHpf)& f) + { + f.reset(); + f.prepare (spec); + auto coeffs = juce::dsp::IIR::Coefficients::makeHighPass (spec.sampleRate, + sendHpfCutoff, + 0.707f); + if (f.state == nullptr) + f.state = coeffs; + else + *f.state = *coeffs; + }; + prepSendHpfDynamic (pedalSendHpf); + prepSendHpfDynamic (sympSendHpf); + prepSendHpfDynamic (soundboardSendHpf); + { + juce::dsp::ProcessSpec monoSpec = spec; + monoSpec.numChannels = 1; + modalSendHpf.reset(); + modalSendHpf.prepare (monoSpec); + modalSendHpf.coefficients = juce::dsp::IIR::Coefficients::makeHighPass (monoSpec.sampleRate, + sendHpfCutoff, + 0.707f); + } + sendHpfNumChannels = buffer.getNumChannels(); + prepareBrightnessFilters(); + } + + juce::dsp::AudioBlock block (buffer); + juce::dsp::ProcessContextReplacing ctx (block); + if (std::abs (postTiltDbSmoothed.getCurrentValue()) > 1.0e-4f) + { + if (tiltLow.coefficients == nullptr || tiltHigh.coefficients == nullptr) + updatePostFiltersForNote (lastMidiNote); + + if (tiltLow.coefficients != nullptr && tiltHigh.coefficients != nullptr && lastSampleRate > 0.0 && tiltReady) + { + tiltLow.process (ctx); + tiltHigh.process (ctx); + } + } + } + + // --- Velocity-driven brightness shelf --- + if (DebugToggles::kEnableBrightness && brightnessEnabled && ! brightnessFilters.empty()) + { + if (buffer.getNumChannels() != brightnessNumChannels) + prepareBrightnessFilters(); + + const float noteTerm = brightnessNoteSlopeDb * ((float) lastMidiNote - 60.0f) * (1.0f / 24.0f); + float targetDb = brightnessBaseDb + lastVelocityNorm * brightnessVelSlopeDb + noteTerm; + targetDb = juce::jlimit (-12.0f, brightnessMaxDb, targetDb); + brightnessDbSmoothed.setTargetValue (targetDb); + if (buffer.getNumSamples() > 0) + brightnessDbSmoothed.skip (buffer.getNumSamples()); + const float currentDb = brightnessDbSmoothed.getCurrentValue(); + if (std::abs (currentDb - brightnessCurrentDb) > 1.0e-4f) + updateBrightnessFilters (currentDb); + + const int chs = buffer.getNumChannels(); + const int n = buffer.getNumSamples(); + for (int chIdx = 0; chIdx < chs; ++chIdx) + { + auto* x = buffer.getWritePointer (chIdx); + auto& f = brightnessFilters[(size_t) juce::jmin (chIdx, (int) brightnessFilters.size() - 1)]; + for (int i = 0; i < n; ++i) + { + if (f.coefficients != nullptr) + x[i] = f.processSample (x[i]); + } + } + } + + // CPU optimisation: Lowered threshold from 14 to 8 for earlier effect bypass + const bool highPolyProcessing = countActivePm2Voices() >= 6; + + // --- Pedal resonance send/return (subtle body) --- + if (DebugToggles::kEnablePm2PedalResonance && DebugToggles::kEnableReverb + && ! highPolyProcessing && pedalCfg.resonanceMix > 0.0001f && sustainValue >= pedalCfg.halfThresh) + { + const float send = pedalCfg.resonanceSend * sustainValue; + const float mix = pedalCfg.resonanceMix * sustainValue; + if (send > 0.0001f && mix > 0.0001f) + { + pedalScratch.makeCopyOf (buffer, true); + for (int ch = 0; ch < pedalScratch.getNumChannels(); ++ch) + { + auto* x = pedalScratch.getWritePointer (ch); + for (int i = 0; i < pedalScratch.getNumSamples(); ++i) + x[i] *= send; + } + + { + juce::dsp::AudioBlock wetBlock (pedalScratch); + juce::dsp::ProcessContextReplacing wetCtx (wetBlock); + pedalSendHpf.process (wetCtx); + } + + auto p = pedalReverbParams; + p.wetLevel = 1.0f; + p.dryLevel = 0.0f; + p.roomSize = juce::jlimit (0.0f, 0.4f, p.roomSize); // Limit room size for less wash + if (! pedalReverbParamsValid || ! reverbParamsEqual (p, pedalReverbParamsApplied)) + { + pedalReverb.setParameters (p); + pedalReverbParamsApplied = p; + pedalReverbParamsValid = true; + } + + if (pedalScratch.getNumChannels() >= 2) + pedalReverb.processStereo (pedalScratch.getWritePointer (0), pedalScratch.getWritePointer (1), pedalScratch.getNumSamples()); + else + pedalReverb.processMono (pedalScratch.getWritePointer (0), pedalScratch.getNumSamples()); + + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto* dry = buffer.getWritePointer (ch); + auto* ww = pedalScratch.getWritePointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + dry[i] = dry[i] * (1.0f - mix) + ww[i] * mix; + } + } + } + + // --- Sympathetic send/return (undamped strings/pedal) --- + // Allow a scaled amount even without sustain, for controllable sympathetic ringing. + const float sympPedalScale = sustainPedalDown ? sustainValue : duplexCfg.sympNoPedalScale; + if (DebugToggles::kEnableReverb && ! highPolyProcessing && duplexCfg.sympMix > 0.0001f && sympPedalScale > 0.0001f) + { + const float send = juce::jlimit (0.0f, 1.0f, duplexCfg.sympSend) * sympPedalScale; + const float mix = juce::jlimit (0.0f, 1.0f, duplexCfg.sympMix) * sympPedalScale; + if (send > 0.0001f && mix > 0.0001f) + { + sympScratch.makeCopyOf (buffer, true); + for (int ch = 0; ch < sympScratch.getNumChannels(); ++ch) + { + auto* x = sympScratch.getWritePointer (ch); + for (int i = 0; i < sympScratch.getNumSamples(); ++i) + x[i] *= send; + } + + { + juce::dsp::AudioBlock wetBlock (sympScratch); + juce::dsp::ProcessContextReplacing wetCtx (wetBlock); + sympSendHpf.process (wetCtx); + } + + auto p = sympParams; + p.wetLevel = 1.0f; + p.dryLevel = 0.0f; + p.roomSize = juce::jlimit (0.0f, 0.3f, p.roomSize); // Smaller room for less wash + if (! sympParamsValid || ! reverbParamsEqual (p, sympParamsApplied)) + { + sympReverb.setParameters (p); + sympParamsApplied = p; + sympParamsValid = true; + } + + if (sympScratch.getNumChannels() >= 2) + sympReverb.processStereo (sympScratch.getWritePointer (0), sympScratch.getWritePointer (1), sympScratch.getNumSamples()); + else + sympReverb.processMono (sympScratch.getWritePointer (0), sympScratch.getNumSamples()); + + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto* dry = buffer.getWritePointer (ch); + auto* ww = sympScratch.getWritePointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + dry[i] = dry[i] * (1.0f - mix) + ww[i] * mix; + } + } + } + + // --- Modal soundboard (BPF bank + predelay) --- + const bool useModal = DebugToggles::kEnablePm2ModalSoundboard + && (! pmBoardModes.isEmpty()) && (pmBoardMix > 0.0001f) && (pmBoardSend > 0.0001f); + if (useModal) + { + ensureScratch (modalScratch, numCh); + if (modalDirty || (int) modalModes.size() != pmBoardModes.size()) + { + modalModes.clear(); + modalModes.reserve ((size_t) pmBoardModes.size()); + for (const auto& m : pmBoardModes) + { + modalModes.emplace_back(); + auto& mm = modalModes.back(); + auto coeff = makeBandPass (lastSampleRate, m.f, m.q); + for (int ch = 0; ch < modalChannels; ++ch) + { + mm.bp[ch].coefficients = coeff; + mm.bp[ch].reset(); + } + mm.gainLin = juce::Decibels::decibelsToGain (m.gainDb); + } + modalDirty = false; + } + + predelaySamples = juce::jlimit (0, juce::jmax (0, (int) predelayBuf.size() - 2), + (int) std::round (pmPredelayMs * 0.001 * lastSampleRate)); + const int needed = predelaySamples + buffer.getNumSamples() + 2; + if (needed > (int) predelayBuf.size()) + predelaySamples = juce::jlimit (0, juce::jmax (0, (int) predelayBuf.size() - buffer.getNumSamples() - 2), + predelaySamples); + + modalScratch.clear(); + + const float send = juce::jlimit (0.0f, 1.0f, pmBoardSend); + const float mix = juce::jlimit (0.0f, 1.0f, pmBoardMix); + + for (int i = 0; i < buffer.getNumSamples(); ++i) + { + float mono = 0.0f; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + mono += buffer.getReadPointer (ch)[i]; + mono *= (buffer.getNumChannels() > 0) ? (1.0f / (float) buffer.getNumChannels()) : 1.0f; + mono *= send; + mono = modalSendHpf.processSample (mono); + + predelayBuf[(size_t) predelayWrite] = mono; + int readIdx = predelayWrite - predelaySamples; + if (readIdx < 0) readIdx += (int) predelayBuf.size(); + float delayed = predelayBuf[(size_t) readIdx]; + predelayWrite = (predelayWrite + 1) % (int) predelayBuf.size(); + + for (size_t m = 0; m < modalModes.size(); ++m) + { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto* f = (ch < modalChannels) ? &modalModes[m].bp[ch] : &modalModes[m].bp[0]; + if (f->coefficients != nullptr) + { + float w = f->processSample (delayed) * modalModes[m].gainLin; + modalScratch.getWritePointer (ch)[i] += w; + } + } + } + } + + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto* dry = buffer.getWritePointer (ch); + auto* ww = modalScratch.getWritePointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + dry[i] = dry[i] * (1.0f - mix) + ww[i] * mix; + } + } + + // --- Soundboard resonator (procedural IR or JUCE reverb fallback) --- + if (DebugToggles::kEnableReverb && soundboardEnabled && soundboardMix > 0.0001f) + { + soundboardScratch.makeCopyOf (buffer, true); + + { + juce::dsp::AudioBlock wetBlock (soundboardScratch); + juce::dsp::ProcessContextReplacing wetCtx (wetBlock); + soundboardSendHpf.process (wetCtx); + } + + if (DebugToggles::kEnableSoundboardConvolution) + { + updateSoundboardConvolution (false); + if (DebugToggles::kSoundboardConvolutionDownsample > 1) + { + const int dsFactor = DebugToggles::kSoundboardConvolutionDownsample; + const int numSamples = soundboardScratch.getNumSamples(); + const int numCh = soundboardScratch.getNumChannels(); + const int dsSamples = (numSamples + dsFactor - 1) / dsFactor; + + if (soundboardScratchDs.getNumChannels() != numCh + || soundboardScratchDs.getNumSamples() != dsSamples) + soundboardScratchDs.setSize (numCh, dsSamples, false, false, true); + soundboardScratchDs.clear(); + + // Downsample by simple averaging to reduce convolution workload. + for (int ch = 0; ch < numCh; ++ch) + { + const float* src = soundboardScratch.getReadPointer (ch); + float* dst = soundboardScratchDs.getWritePointer (ch); + int di = 0; + for (int i = 0; i < numSamples; i += dsFactor) + { + float sum = 0.0f; + int count = 0; + for (int k = 0; k < dsFactor && i + k < numSamples; ++k) + { + sum += src[i + k]; + ++count; + } + dst[di++] = (count > 0) ? (sum / (float) count) : 0.0f; + } + } + + { + juce::dsp::AudioBlock wetBlock (soundboardScratchDs); + juce::dsp::ProcessContextReplacing wetCtx (wetBlock); + soundboardConvolutionDs.process (wetCtx); + } + + // Upsample with linear interpolation back to full-rate scratch. + for (int ch = 0; ch < numCh; ++ch) + { + const float* src = soundboardScratchDs.getReadPointer (ch); + float* dst = soundboardScratch.getWritePointer (ch); + const int dsCount = soundboardScratchDs.getNumSamples(); + for (int i = 0; i < numSamples; ++i) + { + const int idx = i / dsFactor; + if (idx >= dsCount - 1) + { + dst[i] = src[dsCount - 1]; + } + else if ((i % dsFactor) == 0) + { + dst[i] = src[idx]; + } + else + { + const float frac = (float) (i % dsFactor) / (float) dsFactor; + dst[i] = src[idx] + (src[idx + 1] - src[idx]) * frac; + } + } + } + + { + juce::dsp::AudioBlock wetBlock (soundboardScratch); + juce::dsp::ProcessContextReplacing wetCtx (wetBlock); + soundboardReturnHpf.process (wetCtx); + } + } + else + { + juce::dsp::AudioBlock wetBlock (soundboardScratch); + juce::dsp::ProcessContextReplacing wetCtx (wetBlock); + soundboardConvolution.process (wetCtx); + soundboardReturnHpf.process (wetCtx); + } + } + else + { + auto p = soundboardParams; + p.wetLevel = 1.0f; // wet only + p.dryLevel = 0.0f; // no dry inside + p.roomSize = juce::jlimit (0.0f, 0.5f, p.roomSize); // Limit room size + if (! soundboardParamsValid || ! reverbParamsEqual (p, soundboardParamsApplied)) + { + soundboardReverb.setParameters (p); + soundboardParamsApplied = p; + soundboardParamsValid = true; + } + + if (soundboardScratch.getNumChannels() >= 2) + soundboardReverb.processStereo (soundboardScratch.getWritePointer (0), soundboardScratch.getWritePointer (1), soundboardScratch.getNumSamples()); + else + soundboardReverb.processMono (soundboardScratch.getWritePointer (0), soundboardScratch.getNumSamples()); + { + juce::dsp::AudioBlock wetBlock (soundboardScratch); + juce::dsp::ProcessContextReplacing wetCtx (wetBlock); + soundboardReturnHpf.process (wetCtx); + } + } + + const float mix = juce::jlimit (0.0f, 1.0f, soundboardMix); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto* dry = buffer.getWritePointer (ch); + auto* ww = soundboardScratch.getWritePointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + dry[i] = dry[i] * (1.0f - mix) + ww[i] * mix; + } + } + + // --- Mic perspectives blend (post chain) --- + if (DebugToggles::kEnableMic) + applyMicMix (buffer); + + // --- Optional post room/hall reverb (testing toggle) --- + const float postRoomMixParam = apvts.getRawParameterValue (ParamIDs::postRoomMix)->load(); + const bool postRoomEnableParam = apvts.getRawParameterValue (ParamIDs::postRoomEnable)->load() >= 0.5f; + if (DebugToggles::kEnablePostRoomReverb && postRoomEnableParam && postRoomMixParam > 0.0001f) + { + juce::Reverb::Parameters p; + if (DebugToggles::kPostRoomIsHall) + { + p.roomSize = 0.78f; + p.damping = 0.45f; + p.width = 1.0f; + p.wetLevel = 0.22f * postRoomMixParam; + p.dryLevel = 1.0f; + } + else + { + p.roomSize = 0.42f; + p.damping = 0.35f; + p.width = 0.9f; + p.wetLevel = 0.16f * postRoomMixParam; + p.dryLevel = 1.0f; + } + p.freezeMode = 0.0f; + if (! postReverbParamsValid || ! reverbParamsEqual (p, postReverbParamsApplied)) + { + postReverb.setParameters (p); + postReverbParamsApplied = p; + postReverbParamsValid = true; + } + if (buffer.getNumChannels() >= 2) + postReverb.processStereo (buffer.getWritePointer (0), buffer.getWritePointer (1), buffer.getNumSamples()); + else + postReverb.processMono (buffer.getWritePointer (0), buffer.getNumSamples()); + } + + // --- Final output HPF to remove sub-bass/rumble --- + if (DebugToggles::kEnableGlobalFilters) + { + if (buffer.getNumChannels() > 0 && buffer.getNumChannels() != outputHpfNumChannels) + { + juce::dsp::ProcessSpec spec = mainSpec; + spec.numChannels = (juce::uint32) juce::jmax (1, buffer.getNumChannels()); + outputHpf.reset(); + outputHpf.prepare (spec); + auto coeffs = juce::dsp::IIR::Coefficients::makeHighPass (spec.sampleRate, + outputHpfCutoff, + 0.707f); + if (outputHpf.state == nullptr) + outputHpf.state = coeffs; + else + *outputHpf.state = *coeffs; + outputHpfNumChannels = buffer.getNumChannels(); + } + { + juce::dsp::AudioBlock block (buffer); + juce::dsp::ProcessContextReplacing ctx (block); + outputHpf.process (ctx); + } + } + + // --- Output padding to ease limiter load --- + buffer.applyGain (juce::Decibels::decibelsToGain (-3.0f)); + + // --- Final lookahead limiter (gain riding + delay) --- + if (DebugToggles::kEnableFinalLimiter) + { + const int numCh = buffer.getNumChannels(); + const int numSamples = buffer.getNumSamples(); + const int requiredSize = limiterLookaheadSamples + numSamples + 1; + if (numCh != limiterDelayBuffer.getNumChannels() || requiredSize > limiterDelayBufferSize) + { + limiterDelayBufferSize = requiredSize; + limiterDelayBuffer.setSize (juce::jmax (1, numCh), limiterDelayBufferSize, false, false, true); + limiterDelayBuffer.clear(); + limiterWritePos = 0; + limiterGain = 1.0f; + } + + for (int i = 0; i < numSamples; ++i) + { + float peak = 0.0f; + for (int ch = 0; ch < numCh; ++ch) + { + const float s = buffer.getReadPointer (ch)[i]; + peak = juce::jmax (peak, std::abs (s)); + limiterDelayBuffer.setSample (ch, limiterWritePos, s); + } + + const float desiredGain = (peak > limiterThreshold && peak > 0.0f) ? (limiterThreshold / peak) : 1.0f; + const float coeff = (desiredGain < limiterGain) ? limiterAttackCoeff : limiterReleaseCoeff; + limiterGain = desiredGain + coeff * (limiterGain - desiredGain); + + int readPos = limiterWritePos - limiterLookaheadSamples; + if (readPos < 0) + readPos += limiterDelayBufferSize; + for (int ch = 0; ch < numCh; ++ch) + { + const float delayed = limiterDelayBuffer.getSample (ch, readPos); + buffer.getWritePointer (ch)[i] = delayed * limiterGain; + } + + limiterWritePos = (limiterWritePos + 1) % limiterDelayBufferSize; + } + } + + // --- Final output LPF (post everything) --- + if (buffer.getNumChannels() > 0 && buffer.getNumChannels() != outputLpfNumChannels) + { + juce::dsp::ProcessSpec spec = mainSpec; + spec.numChannels = (juce::uint32) juce::jmax (1, buffer.getNumChannels()); + outputLpf.reset(); + outputLpf.prepare (spec); + outputLpf.setType (juce::dsp::StateVariableTPTFilterType::lowpass); + outputLpfNumChannels = buffer.getNumChannels(); + updateOutputLpf(); + } + + if (DebugToggles::kEnableGlobalFilters && outputLpfEnabled) + { + juce::dsp::AudioBlock block (buffer); + juce::dsp::ProcessContextReplacing ctx (block); + outputLpf.process (ctx); + } + + // --- Output EQ (5-band, post LPF) --- + if (buffer.getNumChannels() > 0 && buffer.getNumChannels() != outputEqNumChannels) + { + juce::dsp::ProcessSpec spec = mainSpec; + spec.numChannels = (juce::uint32) juce::jmax (1, buffer.getNumChannels()); + for (auto& f : outputEqFilters) + { + f.reset(); + f.prepare (spec); + } + outputEqNumChannels = buffer.getNumChannels(); + updateOutputEq(); + } + + if (DebugToggles::kEnableEq && outputEqEnabled) + { + juce::dsp::AudioBlock block (buffer); + for (auto& f : outputEqFilters) + { + juce::dsp::ProcessContextReplacing ctx (block); + f.process (ctx); + } + } + + // --- Final DC blocker (gentle, post EQ) --- + if (DebugToggles::kEnableOutputDcBlock) + { + if (buffer.getNumChannels() > 0 && buffer.getNumChannels() != outputDcNumChannels) + { + const int chs = juce::jmin (2, buffer.getNumChannels()); + for (int ch = 0; ch < chs; ++ch) + outputDcBlock[(size_t) ch].reset (lastSampleRate); + outputDcNumChannels = buffer.getNumChannels(); + } + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto* x = buffer.getWritePointer (ch); + auto& dc = outputDcBlock[(size_t) juce::jmin (ch, 1)]; + for (int i = 0; i < buffer.getNumSamples(); ++i) + x[i] = dc.process (x[i]); + } + } + + // --- Master volume (post everything, host-automatable, not preset-controlled) --- + if (buffer.getNumSamples() > 0 && std::abs (outputGainEnd - outputGainStart) > 1.0e-6f) + buffer.applyGainRamp (0, buffer.getNumSamples(), + masterVolumeLin * outputGainStart, + masterVolumeLin * outputGainEnd); + else + buffer.applyGain (masterVolumeLin * outputGainLin); +} + +//============================== State ========================================= +void FluteSynthAudioProcessor::getStateInformation (juce::MemoryBlock& destData) +{ + auto state = apvts.copyState(); + std::unique_ptr xml (state.createXml()); + copyXmlToBinary (*xml, destData); +} + +void FluteSynthAudioProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + std::unique_ptr xml (getXmlFromBinary (data, sizeInBytes)); + if (xml != nullptr && xml->hasTagName (apvts.state.getType())) + { + apvts.replaceState (juce::ValueTree::fromXml (*xml)); + syncExtendedParamsFromAPVTS(); + } +} + +//============================= Parameters (VA) ================================= +juce::AudioProcessorValueTreeState::ParameterLayout FluteSynthAudioProcessor::createParameterLayout() +{ + std::vector> p; + + auto mk = [] (const juce::String& id, const juce::String& name, + float min, float max, float def, float centreSkew = 0.0f) + { + auto r = juce::NormalisableRange (min, max); + #if JUCE_VERSION_MAJOR >= 7 + if (centreSkew != 0.0f) r.setSkewForCentre (centreSkew); + #else + (void) centreSkew; + #endif + return std::make_unique (juce::ParameterID (id, 1), name, r, def); + }; + auto mkBool = [] (const juce::String& id, const juce::String& name, bool def) + { + return std::make_unique (juce::ParameterID (id, 1), name, def); + }; + + // osc mix + p.push_back (mk (ParamIDs::oscSine, "Sine", 0.0f, 1.0f, 0.7f)); + p.push_back (mk (ParamIDs::oscSaw, "Saw", 0.0f, 1.0f, 0.3f)); + p.push_back (mk (ParamIDs::oscSquare, "Square", 0.0f, 1.0f, 0.0f)); + + // filter + p.push_back (mk (ParamIDs::cutoff, "Cutoff", 100.0f, 8000.0f, 1800.0f, 1000.0f)); + p.push_back (mk (ParamIDs::resonance, "Resonance", 0.1f, 1.5f, 0.7f)); + + // ADSR - Note: For pm2 engine, Decay controls physical string sustain time + p.push_back (mk (ParamIDs::attack, "Attack", 0.001f, 3.000f, 0.010f)); + p.push_back (mk (ParamIDs::decay, "Decay (Sustain)", 0.100f, 9.100f, 2.00f, 2.0f)); + p.push_back (mk (ParamIDs::sustain, "Sustain", 0.00f, 1.00f, 0.00f)); + p.push_back (mk (ParamIDs::release, "Release", 0.030f, 7.000f, 1.00f, 1.0f)); + + // optional breath/noise pre (voice) gain in dB + p.push_back (mk (ParamIDs::noiseDb, "Noise (dB)", -62.0f, -12.0f, -48.0f)); + + // Formants (post) + p.push_back (mkBool (ParamIDs::formant1Enable, "Formant 1 Enable", false)); + p.push_back (mk (ParamIDs::formant1Freq, "Formant 1 Freq", 300.0f, 12000.0f, 1800.0f)); + p.push_back (mk (ParamIDs::formant1Q, "Formant 1 Q", 0.5f, 6.0f, 2.0f)); + p.push_back (mk (ParamIDs::formant1GainDb, "Formant 1 Gain", -9.0f, 9.0f, 0.0f)); + p.push_back (mkBool (ParamIDs::formant2Enable, "Formant 2 Enable", false)); + p.push_back (mk (ParamIDs::formant2Freq, "Formant 2 Freq", 300.0f, 12000.0f, 1800.0f)); + p.push_back (mk (ParamIDs::formant2Q, "Formant 2 Q", 0.5f, 6.0f, 2.0f)); + p.push_back (mk (ParamIDs::formant2GainDb, "Formant 2 Gain", -9.0f, 9.0f, 0.0f)); + + // Soundboard + p.push_back (mkBool (ParamIDs::soundboardEnable, "Soundboard Enable", false)); + p.push_back (mk (ParamIDs::soundboardMix, "Soundboard Mix", 0.0f, 0.20f, 0.02f)); + p.push_back (mk (ParamIDs::soundboardT60, "Soundboard T60", 1.6f, 2.8f, 2.2f)); + p.push_back (mk (ParamIDs::soundboardDamp, "Soundboard Damping", 0.0f, 1.0f, 0.40f)); + p.push_back (mk (ParamIDs::postRoomMix, "Post Room Mix", 0.0f, 1.0f, 1.0f)); + p.push_back (mkBool (ParamIDs::postRoomEnable, "Post Room Enable", true)); + + // Felt/contact + p.push_back (mk (ParamIDs::feltPreload, "Felt Preload", 0.0f, 0.6f, 0.08f)); + p.push_back (mk (ParamIDs::feltStiffness, "Felt Stiffness", 1.0f, 5.0f, 2.4f)); + p.push_back (mk (ParamIDs::feltHysteresis, "Felt Hysteresis", 0.0f, 0.6f, 0.15f)); + p.push_back (mk (ParamIDs::feltMax, "Felt Max", 0.4f, 4.0f, 1.4f)); + + // Duplex + p.push_back (mk (ParamIDs::duplexRatio, "Duplex Ratio", 1.1f, 4.0f, 2.2f)); + p.push_back (mk (ParamIDs::duplexGainDb, "Duplex Gain", -20.0f, -6.0f, -12.0f)); + p.push_back (mk (ParamIDs::duplexDecayMs, "Duplex Decay", 10.0f, 400.0f, 120.0f)); + p.push_back (mk (ParamIDs::duplexSympSend, "Sympathetic Send", 0.0f, 1.0f, 0.15f)); + p.push_back (mk (ParamIDs::duplexSympMix, "Sympathetic Mix", 0.0f, 1.0f, 0.20f)); + + // PM2 gain trim - FIXED: Changed default from +12dB to 0dB to prevent overwhelming output + p.push_back (mk (ParamIDs::pm2GainDb, "PM2 Gain (dB)", -24.0f, 42.0f, 0.0f)); + + // Final output LPF + p.push_back (mkBool (ParamIDs::outputLpfEnable, "Output LPF Enable", false)); + p.push_back (mk (ParamIDs::outputLpfCutoff, "Output LPF Cutoff", 0.0f, 18000.0f, 18000.0f, 4000.0f)); + p.push_back (mk (ParamIDs::outputLpfQ, "Output LPF Q", 0.2f, 2.5f, 0.707f)); + + // Master volume (linear gain) + p.push_back (mk (ParamIDs::masterVolume, "Master Volume", 0.0f, 2.0f, 0.9f, 1.0f)); + p.push_back (std::make_unique ( + juce::ParameterID (ParamIDs::temperament, 1), + "Temperament", + juce::StringArray { "Preset", "12-TET", "Werckmeister", "Kirnberger", "Meantone", "Pythagorean" }, + 0)); + p.push_back (std::make_unique ( + juce::ParameterID (ParamIDs::velocityCurve, 1), + "Velocity Curve", + juce::StringArray { "Light", "Normal", "Heavy", "Fixed" }, + 1)); // Default to "Normal" + + return { p.begin(), p.end() }; +} + +//=========================== JSON preset helpers =============================== +bool FluteSynthAudioProcessor::hasProp (const juce::DynamicObject& o, const juce::Identifier& id) +{ return o.hasProperty (id); } + +float FluteSynthAudioProcessor::getFloatProp (const juce::DynamicObject& o, const juce::Identifier& id, float def) +{ + if (! hasProp (o, id)) return def; + auto v = o.getProperty (id); + if (v.isDouble() || v.isInt()) return (float) v; + return def; +} +bool FluteSynthAudioProcessor::getBoolProp (const juce::DynamicObject& o, const juce::Identifier& id, bool def) +{ + if (! hasProp (o, id)) return def; + auto v = o.getProperty (id); + if (v.isBool()) return (bool) v; + if (v.isInt()) return ((int) v) != 0; + return def; +} +juce::String FluteSynthAudioProcessor::getStringProp (const juce::DynamicObject& o, const juce::Identifier& id, const juce::String& def) +{ + if (! hasProp (o, id)) return def; + auto v = o.getProperty (id); + if (v.isString()) return v.toString(); + return def; +} + +static std::array getTemperamentOffsetsByName (juce::String name) +{ + name = name.trim().toLowerCase(); + if (name == "pythagorean") + return { { 0.0f, 23.46f, 3.91f, 27.37f, 7.82f, -13.69f, 11.73f, -1.96f, 21.50f, 1.96f, 25.46f, 5.87f } }; + if (name == "meantone" || name == "quarter-comma meantone" || name == "quarter comma meantone") + return { { 0.0f, 20.51f, 3.42f, 23.94f, 6.84f, -11.73f, 9.78f, -1.95f, 18.57f, 1.71f, 22.24f, 5.13f } }; + if (name == "werckmeister" || name == "werckmeister iii" || name == "werckmeister3") + return { { 0.0f, 3.91f, 1.96f, 5.87f, -1.96f, 0.0f, 3.91f, -1.96f, 1.96f, -3.91f, 1.96f, -5.87f } }; + if (name == "kirnberger" || name == "kirnberger iii" || name == "kirnberger3") + return { { 0.0f, 3.91f, 1.96f, 5.87f, -1.96f, 0.0f, 3.91f, -1.96f, 1.96f, -3.91f, 1.96f, -5.87f } }; + + return { { 0.0f } }; // 12-TET default +} + +static std::array getTemperamentOffsetsByChoice (int choice) +{ + switch (choice) + { + case 1: return getTemperamentOffsetsByName ("12-TET"); + case 2: return getTemperamentOffsetsByName ("Werckmeister"); + case 3: return getTemperamentOffsetsByName ("Kirnberger"); + case 4: return getTemperamentOffsetsByName ("Meantone"); + case 5: return getTemperamentOffsetsByName ("Pythagorean"); + default: return { { 0.0f } }; + } +} + +static std::array expandPitchClassOffsets (const std::array& offsets) +{ + std::array expanded { { 0.0f } }; + for (int i = 0; i < 128; ++i) + expanded[(size_t) i] = offsets[(size_t) (i % 12)]; + return expanded; +} + +PresetModel FluteSynthAudioProcessor::buildPhysicsPresetModel() const +{ + PresetModel p; + p.engine = "pm2"; + p.engineMixVa = 0.0f; + p.engineMixPm = 0.0f; + p.engineMixPm2 = 1.0f; + + const int refMidi = 60; + p.hammerModel.force = 0.65f; + p.hammerModel.massKg = PianoPhysics::Hammer::getMass (refMidi); + p.hammerModel.contactExponent = PianoPhysics::Hammer::getExponent (refMidi); + p.hammerModel.contactStiffness = mapHammerStiffnessToModel (PianoPhysics::Hammer::getStiffness (refMidi)); + p.hammerModel.contactDamping = juce::jmap (PianoPhysics::Hammer::getHysteresis (refMidi), 0.08f, 0.18f, 4.0f, 8.0f); + + p.feltModel.hysteresis = PianoPhysics::Hammer::getHysteresis (refMidi); + + p.damper.lossDamped = PianoPhysics::Damper::maxDamping; + p.damper.smoothMs = PianoPhysics::Damper::engageTime_s * 1000.0f; + + p.soundboard.enabled = true; + p.soundboard.mix = 0.025f; + const float sbT60 = PianoPhysics::Soundboard::getT60 (200.0f); + p.soundboard.t60_s = juce::jlimit (1.6f, 2.8f, sbT60); + p.soundboard.damp = juce::jlimit (0.0f, 1.0f, PianoPhysics::Soundboard::typicalLossFactor / 0.03f); + + // FIX: Reduced PM2 gain and output gain to prevent hot/clipping output + p.pm2GainDb = -4.0f; // Was 0.0f - reduce per-voice level + p.outputGainDb = -9.0f; // Was -6.0f - additional headroom + p.breathEnabled = false; + p.noiseDb = -62.0f; + p.pedal.sustainGainDb = 3.0f; + p.damper.lossOff = 1.0f; + p.damper.lossHalf = 0.80f; // Was 0.90f - more damping when half-pedal + p.damper.lossDamped = 0.35f; // Was 0.65f - VERY aggressive damping for instant note-off + p.damper.smoothMs = 8.0f; // Fast damper engage time + + // FIX: Very short ADSR release for tight key-tracking + // Notes should stop almost immediately when you release the key + p.release = 0.025f; // Was 0.08f - now 25ms for very tight response + p.releaseExtension = 1.0f; // No extension for tighter response + + // FIX: Enable output LPF to remove high-frequency crackle/noise/aliasing + // Cutoff at 12kHz removes harsh artifacts while preserving musicality + p.outputLpf.enabled = true; + p.outputLpf.cutoff = 12000.0f; // Reduced from 14kHz to catch more aliasing + p.outputLpf.q = 0.707f; // Butterworth response - smooth rolloff + + return p; +} + +PresetModel FluteSynthAudioProcessor::parsePresetJson (const juce::var& v) +{ + PresetModel p; // defaults + if (! v.isObject()) return p; + + auto* obj = v.getDynamicObject(); + auto clamp = PresetModel::clamp; + const int ver = obj->hasProperty ("schema_version") ? (int) obj->getProperty ("schema_version") : 3; + juce::ignoreUnused (ver); + + // top-level + if (hasProp (*obj, "schema_version")) p.schemaVersion = (int) obj->getProperty ("schema_version"); + p.engine = getStringProp (*obj, "engine", p.engine); + p.masterTuneCents = clamp (getFloatProp (*obj, "master_tune_cents", p.masterTuneCents), -200.0f, 200.0f); + p.pitchCompOffsetCents = clamp (getFloatProp (*obj, "pitch_comp_offset_cents", p.pitchCompOffsetCents), -50.0f, 50.0f); + p.pitchCompSlopeCents = clamp (getFloatProp (*obj, "pitch_comp_slope_cents", p.pitchCompSlopeCents), -0.5f, 0.5f); + // FIXED: Extended range to allow larger negative slopes for realistic treble attenuation + p.pm2LoudnessSlopeDbPerSemi = clamp (getFloatProp (*obj, "pm2_loudness_slope_db_per_semi", p.pm2LoudnessSlopeDbPerSemi), -0.10f, 0.1f); + p.noiseDb = clamp (getFloatProp (*obj, "noise_db", p.noiseDb), -62.0f, -12.0f); + p.outputGainDb = clamp (getFloatProp (*obj, "output_gain_db", p.outputGainDb), -24.0f, 12.0f); + p.releaseExtension = clamp (getFloatProp (*obj, "release_extension", p.releaseExtension), 1.0f, 4.0f); + p.velocityGamma = clamp (getFloatProp (*obj, "velocity_curve_gamma", p.velocityGamma), 0.3f, 3.0f); + p.velocityCurve = getStringProp (*obj, "velocity_curve", p.velocityCurve); + + if (auto tv = obj->getProperty ("temperament"); tv.isObject()) + { + if (auto* to = tv.getDynamicObject()) + { + p.temperamentName = getStringProp (*to, "name", p.temperamentName); + if (auto ov = to->getProperty ("offsets_cents"); ov.isArray()) + { + auto* arr = ov.getArray(); + if (arr != nullptr && arr->size() >= 12) + { + for (int i = 0; i < 12; ++i) + p.temperamentOffsetsCents[(size_t) i] = (float) arr->getUnchecked (i); + p.temperamentUseOffsets = true; + } + } + } + } + + if (auto ov = obj->getProperty ("per_note_offsets_cents"); ov.isArray()) + { + auto* arr = ov.getArray(); + if (arr != nullptr && arr->size() > 0) + { + if (arr->size() == 12) + { + for (int i = 0; i < 128; ++i) + p.perNoteOffsetsCents[(size_t) i] = (float) arr->getUnchecked (i % 12); + } + else + { + for (int i = 0; i < 128; ++i) + { + const int idx = juce::jmin (i, arr->size() - 1); + p.perNoteOffsetsCents[(size_t) i] = (float) arr->getUnchecked (idx); + } + } + p.perNoteOffsetsEnabled = true; + } + } + + if (auto bv = obj->getProperty ("brightness"); bv.isObject()) + { + if (auto* bo = bv.getDynamicObject()) + { + p.brightnessEnabled = getBoolProp (*bo, "enabled", p.brightnessEnabled); + p.brightnessBaseDb = clamp (getFloatProp (*bo, "base_db", p.brightnessBaseDb), -12.0f, 12.0f); + p.brightnessVelSlopeDb = clamp (getFloatProp (*bo, "vel_slope_db", p.brightnessVelSlopeDb), -12.0f, 12.0f); + p.brightnessNoteSlopeDb = clamp (getFloatProp (*bo, "note_slope_db", p.brightnessNoteSlopeDb), -12.0f, 12.0f); + p.brightnessMaxDb = clamp (getFloatProp (*bo, "max_db", p.brightnessMaxDb), -12.0f, 18.0f); + p.brightnessCutoffHz = clamp (getFloatProp (*bo, "cutoff_hz", p.brightnessCutoffHz), 800.0f, 12000.0f); + p.brightnessQ = clamp (getFloatProp (*bo, "q", p.brightnessQ), 0.2f, 4.0f); + } + } + + if (auto dv = obj->getProperty ("dispersion_curve"); dv.isObject()) + { + if (auto* d = dv.getDynamicObject()) + { + p.dispersion.highMult = clamp (getFloatProp (*d, "high_mult", p.dispersion.highMult), 0.8f, 2.5f); + p.dispersion.pow = clamp (getFloatProp (*d, "pow", p.dispersion.pow), 0.2f, 4.0f); + } + } + + // optional engine_mix + if (auto mv = obj->getProperty ("engine_mix"); mv.isObject()) + { + if (auto* mm = mv.getDynamicObject()) + { + p.engineMixVa = clamp (getFloatProp (*mm, "va", p.engineMixVa), 0.0f, 1.0f); + p.engineMixPm = clamp (getFloatProp (*mm, "pm", p.engineMixPm), 0.0f, 1.0f); + p.engineMixPm2 = clamp (getFloatProp (*mm, "pm2", p.engineMixPm2), 0.0f, 1.0f); + float s = juce::jmax (0.0001f, p.engineMixVa + p.engineMixPm + p.engineMixPm2); + p.engineMixVa /= s; + p.engineMixPm /= s; + p.engineMixPm2 /= s; + } + } + else + { + if (p.engine == "va") { p.engineMixVa = 1.0f; p.engineMixPm = 0.0f; p.engineMixPm2 = 0.0f; } + else if (p.engine == "pm") { p.engineMixVa = 0.0f; p.engineMixPm = 1.0f; p.engineMixPm2 = 0.0f; } + else if (p.engine == "pm2"){ p.engineMixVa = 0.0f; p.engineMixPm = 0.0f; p.engineMixPm2 = 1.0f; } + else if (p.engine == "hybrid") { p.engineMixVa = 0.5f; p.engineMixPm = 0.5f; p.engineMixPm2 = 0.0f; } + } + + // osc_mix + if (auto mv = obj->getProperty ("osc_mix"); mv.isObject()) + { + if (auto* om = mv.getDynamicObject()) + { + p.oscSine = clamp (getFloatProp (*om, "sine", p.oscSine), 0.0f, 1.0f); + p.oscSaw = clamp (getFloatProp (*om, "saw", p.oscSaw), 0.0f, 1.0f); + p.oscSquare = clamp (getFloatProp (*om, "square", p.oscSquare), 0.0f, 1.0f); + auto sum = std::max (0.0001f, p.oscSine + p.oscSaw + p.oscSquare); + p.oscSine /= sum; p.oscSaw /= sum; p.oscSquare /= sum; + } + } + + // filter + if (auto fv = obj->getProperty ("filter"); fv.isObject()) + { + if (auto* fo = fv.getDynamicObject()) + { + p.cutoff = clamp (getFloatProp (*fo, "cutoff", p.cutoff), 100.0f, 8000.0f); + p.q = clamp (getFloatProp (*fo, "q", p.q), 0.1f, 1.5f); + } + } + + // env + if (auto ev = obj->getProperty ("env"); ev.isObject()) + { + if (auto* eo = ev.getDynamicObject()) + { + // Use the same ranges as the parameter layout so the embedded preset + // loads without being truncated. + p.attack = clamp (getFloatProp (*eo, "attack", p.attack), 0.001f, 3.000f); + p.decay = clamp (getFloatProp (*eo, "decay", p.decay), 0.100f, 9.100f); + p.sustain = clamp (getFloatProp (*eo, "sustain", p.sustain), 0.00f, 1.00f); + p.release = clamp (getFloatProp (*eo, "release", p.release), 0.030f, 7.000f); + } + } + + // shaper + if (auto sv = obj->getProperty ("shaper"); sv.isObject()) + { + if (auto* so = sv.getDynamicObject()) + { + p.shaperEnabled = getBoolProp (*so, "enabled", p.shaperEnabled); + p.shaperDrive = clamp (getFloatProp (*so, "drive", p.shaperDrive), 0.0f, 1.0f); + } + } + + // breath + if (auto bv = obj->getProperty ("breath"); bv.isObject()) + { + if (auto* bo = bv.getDynamicObject()) + { + p.breathEnabled = getBoolProp (*bo, "enabled", p.breathEnabled); + p.breathLevelDb = clamp (getFloatProp (*bo, "level_db", p.breathLevelDb), -60.0f, -20.0f); + p.breathBpFreq = clamp (getFloatProp (*bo, "bp_freq", p.breathBpFreq), 1000.0f, 12000.0f); + p.breathBpQ = clamp (getFloatProp (*bo, "bp_q", p.breathBpQ), 0.4f, 3.0f); + } + } + + // formants + if (auto fv2 = obj->getProperty ("formants"); fv2.isArray()) + { + auto* arr = fv2.getArray(); + for (int i = 0; i < juce::jmin (2, arr->size()); ++i) + { + auto el = arr->getUnchecked (i); + if (! el.isObject()) continue; + auto* fo = el.getDynamicObject(); + p.formants[i].enabled = getBoolProp (*fo, "enabled", p.formants[i].enabled); + p.formants[i].freq = clamp (getFloatProp (*fo, "freq", p.formants[i].freq), 300.0f, 12000.0f); + p.formants[i].q = clamp (getFloatProp (*fo, "q", p.formants[i].q), 0.5f, 6.0f); + p.formants[i].gainDb = clamp (getFloatProp (*fo, "gain_db", p.formants[i].gainDb), -9.0f, +9.0f); + } + } + + // hammer + if (auto hv = obj->getProperty ("hammer"); hv.isObject()) + { + if (auto* ho = hv.getDynamicObject()) + { + p.hammer.enabled = getBoolProp (*ho, "enabled", p.hammer.enabled); + p.hammer.level = clamp (getFloatProp (*ho, "level", p.hammer.level), 0.0f, 1.0f); + p.hammer.decay_s = clamp (getFloatProp (*ho, "decay_s", p.hammer.decay_s), 0.001f, 0.100f); + p.hammer.noise = clamp (getFloatProp (*ho, "noise", p.hammer.noise), 0.0f, 1.0f); + p.hammer.hp_hz = clamp (getFloatProp (*ho, "hp_hz", p.hammer.hp_hz), 200.0f, 12000.0f); + } + } + + // action / mechanical noises + if (auto av = obj->getProperty ("action"); av.isObject()) + { + if (auto* ao = av.getDynamicObject()) + { + p.action.keyOffEnabled = getBoolProp (*ao, "key_off_enabled", p.action.keyOffEnabled); + p.action.keyOffLevel = clamp (getFloatProp (*ao, "key_off_level", p.action.keyOffLevel), 0.0f, 1.0f); + p.action.keyOffDecay_s = clamp (getFloatProp (*ao, "key_off_decay_s", p.action.keyOffDecay_s), 0.001f, 0.200f); + p.action.keyOffVelScale = getBoolProp (*ao, "key_off_vel_scale", p.action.keyOffVelScale); + p.action.keyOffHp_hz = clamp (getFloatProp (*ao, "key_off_hp_hz", p.action.keyOffHp_hz), 200.0f, 12000.0f); + + p.action.pedalEnabled = getBoolProp (*ao, "pedal_enabled", p.action.pedalEnabled); + p.action.pedalLevel = clamp (getFloatProp (*ao, "pedal_level", p.action.pedalLevel), 0.0f, 1.0f); + p.action.pedalDecay_s = clamp (getFloatProp (*ao, "pedal_decay_s", p.action.pedalDecay_s), 0.001f, 0.300f); + p.action.pedalLp_hz = clamp (getFloatProp (*ao, "pedal_lp_hz", p.action.pedalLp_hz), 80.0f, 2000.0f); + + p.action.releaseEnabled = getBoolProp (*ao, "release_enabled", p.action.releaseEnabled); + p.action.releaseLevel = clamp (getFloatProp (*ao, "release_level", p.action.releaseLevel), 0.0f, 1.0f); + p.action.releaseDecay_s = clamp (getFloatProp (*ao, "release_decay_s", p.action.releaseDecay_s), 0.001f, 0.400f); + p.action.releaseLp_hz = clamp (getFloatProp (*ao, "release_lp_hz", p.action.releaseLp_hz), 80.0f, 2000.0f); + p.action.releaseThudMix = clamp (getFloatProp (*ao, "release_thud_mix",p.action.releaseThudMix), 0.0f, 1.0f); + p.action.releaseThudHp_hz = clamp (getFloatProp (*ao, "release_thud_hp_hz", p.action.releaseThudHp_hz), 20.0f, 400.0f); + } + } + + // soundboard + if (auto svb = obj->getProperty ("soundboard"); svb.isObject()) + { + if (auto* so = svb.getDynamicObject()) + { + p.soundboard.enabled = getBoolProp (*so, "enabled", p.soundboard.enabled); + p.soundboard.mix = clamp (getFloatProp (*so, "mix", p.soundboard.mix), 0.0f, 1.0f); + p.soundboard.t60_s = clamp (getFloatProp (*so, "t60_s", p.soundboard.t60_s), 1.6f, 2.8f); + p.soundboard.damp = clamp (getFloatProp (*so, "damp", p.soundboard.damp), 0.0f, 1.0f); + } + } + + // pm_string (pm2 scaffolding) + if (auto psv = obj->getProperty ("pm_string"); psv.isObject()) + { + if (auto* ps = psv.getDynamicObject()) + { + p.pmString.numStrings = juce::jlimit (1, 3, (int) getFloatProp (*ps, "num_strings", (float) p.pmString.numStrings)); + + auto clampDetune = [] (float x) { return PresetModel::clamp (x, -5.0f, 5.0f); }; + auto clampGain = [] (float x) { return PresetModel::clamp (x, 0.0f, 1.0f); }; + + if (auto dv = ps->getProperty ("detune_cents"); dv.isArray()) + { + auto* arr = dv.getArray(); + for (int i = 0; i < juce::jmin ((int) arr->size(), 3); ++i) + p.pmString.detuneCents[(size_t) i] = clampDetune ((float) arr->getUnchecked (i)); + } + + if (auto gv = ps->getProperty ("gain"); gv.isArray()) + { + auto* arr = gv.getArray(); + for (int i = 0; i < juce::jmin ((int) arr->size(), 3); ++i) + p.pmString.gain[(size_t) i] = clampGain ((float) arr->getUnchecked (i)); + } + + if (auto pv = ps->getProperty ("pan"); pv.isArray()) + { + auto* arr = pv.getArray(); + for (int i = 0; i < juce::jmin ((int) arr->size(), 3); ++i) + p.pmString.pan[(size_t) i] = PresetModel::clamp ((float) arr->getUnchecked (i), -1.0f, 1.0f); + } + + p.pmString.stereoWidthLow = clamp (getFloatProp (*ps, "stereo_width_low", p.pmString.stereoWidthLow), 0.0f, 1.5f); + p.pmString.stereoWidthHigh = clamp (getFloatProp (*ps, "stereo_width_high", p.pmString.stereoWidthHigh), 0.0f, 1.5f); + p.pmString.stereoWidthNoteLo = clamp (getFloatProp (*ps, "stereo_width_note_lo", p.pmString.stereoWidthNoteLo), 0.0f, 127.0f); + p.pmString.stereoWidthNoteHi = clamp (getFloatProp (*ps, "stereo_width_note_hi", p.pmString.stereoWidthNoteHi), 0.0f, 127.0f); + + // normalize gain to sum=1 + float gsum = p.pmString.gain[0] + p.pmString.gain[1] + p.pmString.gain[2]; + if (gsum <= 1e-6f) gsum = 1.0f; + for (float& g : p.pmString.gain) g = g / gsum; + for (int i = p.pmString.numStrings; i < 3; ++i) + { + p.pmString.detuneCents[(size_t) i] = 0.0f; + p.pmString.gain[(size_t) i] = 0.0f; + } + + p.pmString.dispersionAmt = clamp (getFloatProp (*ps, "dispersion_amt", p.pmString.dispersionAmt), 0.0f, 1.0f); + p.pmString.apStages = juce::jlimit (1, 4, (int) getFloatProp (*ps, "ap_stages", (float) p.pmString.apStages)); + p.pmString.loss = clamp (getFloatProp (*ps, "loss", p.pmString.loss), 0.0005f, 0.02f); + p.pmString.dcBlockHz = clamp (getFloatProp (*ps, "dc_block_hz", p.pmString.dcBlockHz), 3.0f, 20.0f); + } + } + + // hammer_model (pm2 excitation) + if (auto hv = obj->getProperty ("hammer_model"); hv.isObject()) + { + if (auto* hm = hv.getDynamicObject()) + { + p.hammerModel.force = clamp (getFloatProp (*hm, "force", p.hammerModel.force), 0.0f, 1.0f); + p.hammerModel.toneHz = clamp (getFloatProp (*hm, "tone_hz", p.hammerModel.toneHz), 1500.0f, 6000.0f); + p.hammerModel.attackMs = clamp (getFloatProp (*hm, "attack_ms", p.hammerModel.attackMs), 1.0f, 12.0f); + p.hammerModel.softclip = getBoolProp (*hm, "softclip", p.hammerModel.softclip); + p.hammerModel.gamma = clamp (getFloatProp (*hm, "gamma", p.hammerModel.gamma), 0.6f, 2.5f); + p.hammerModel.massKg = clamp (getFloatProp (*hm, "mass_kg", p.hammerModel.massKg), 0.005f, 0.08f); + p.hammerModel.contactStiffness = clamp (getFloatProp (*hm, "contact_stiffness", p.hammerModel.contactStiffness), 200.0f, 20000.0f); + p.hammerModel.contactExponent = clamp (getFloatProp (*hm, "contact_exponent", p.hammerModel.contactExponent), 1.4f, 4.0f); + p.hammerModel.contactDamping = clamp (getFloatProp (*hm, "contact_damping", p.hammerModel.contactDamping), 0.5f, 40.0f); + p.hammerModel.maxPenetration = clamp (getFloatProp (*hm, "max_penetration", p.hammerModel.maxPenetration), 0.0005f, 0.03f); + p.hammerModel.attackWindowMs = clamp (getFloatProp (*hm, "attack_window_ms", p.hammerModel.attackWindowMs), 1.0f, 20.0f); + p.hammerModel.simplifiedMode = getBoolProp (*hm, "simplified_mode", p.hammerModel.simplifiedMode); + p.hammerModel.stiffnessVelScale = clamp (getFloatProp (*hm, "stiffness_vel_scale", p.hammerModel.stiffnessVelScale), 0.0f, 3.0f); + p.hammerModel.toneVelScale = clamp (getFloatProp (*hm, "tone_vel_scale", p.hammerModel.toneVelScale), 0.0f, 3.0f); + p.hammerModel.preloadVelScale = clamp (getFloatProp (*hm, "preload_vel_scale", p.hammerModel.preloadVelScale), 0.0f, 3.0f); + p.hammerModel.toneMinHz = clamp (getFloatProp (*hm, "tone_min_hz", p.hammerModel.toneMinHz), 800.0f, 12000.0f); + p.hammerModel.toneMaxHz = clamp (getFloatProp (*hm, "tone_max_hz", p.hammerModel.toneMaxHz), 2000.0f, 18000.0f); + } + } + + // felt/contact shaping + if (auto fv = obj->getProperty ("felt"); fv.isObject()) + { + if (auto* fo = fv.getDynamicObject()) + { + p.feltModel.preload = clamp (getFloatProp (*fo, "felt_preload", p.feltModel.preload), 0.0f, 0.6f); + p.feltModel.stiffness = clamp (getFloatProp (*fo, "felt_stiffness", p.feltModel.stiffness), 1.0f, 5.0f); + p.feltModel.hysteresis = clamp (getFloatProp (*fo, "felt_hysteresis", p.feltModel.hysteresis), 0.0f, 0.6f); + p.feltModel.maxAmp = clamp (getFloatProp (*fo, "felt_max", p.feltModel.maxAmp), 0.4f, 4.0f); + } + } + else + { + // also accept top-level felt_* keys for convenience + p.feltModel.preload = clamp (getFloatProp (*obj, "felt_preload", p.feltModel.preload), 0.0f, 0.6f); + p.feltModel.stiffness = clamp (getFloatProp (*obj, "felt_stiffness", p.feltModel.stiffness), 1.0f, 5.0f); + p.feltModel.hysteresis = clamp (getFloatProp (*obj, "felt_hysteresis", p.feltModel.hysteresis), 0.0f, 0.6f); + p.feltModel.maxAmp = clamp (getFloatProp (*obj, "felt_max", p.feltModel.maxAmp), 0.4f, 4.0f); + } + + // WDF/PH blend + if (auto wv = obj->getProperty ("wdf"); wv.isObject()) + { + if (auto* wo = wv.getDynamicObject()) + { + p.wdf.enabled = getBoolProp (*wo, "enabled", p.wdf.enabled); + p.wdf.blend = clamp (getFloatProp (*wo, "blend", p.wdf.blend), 0.0f, 1.0f); + p.wdf.loss = clamp (getFloatProp (*wo, "loss", p.wdf.loss), 0.0f, 0.1f); + p.wdf.bridgeMass = clamp (getFloatProp (*wo, "bridge_mass", p.wdf.bridgeMass), 0.1f, 10.0f); + p.wdf.plateStiffness = clamp (getFloatProp (*wo, "plate_stiffness", p.wdf.plateStiffness), 0.1f, 5.0f); + } + } + if (auto cv = obj->getProperty ("coupling"); cv.isObject()) + { + if (auto* co = cv.getDynamicObject()) + { + p.coupling.gain = clamp (getFloatProp (*co, "gain", p.coupling.gain), 0.0f, 0.2f); + p.coupling.q = clamp (getFloatProp (*co, "q", p.coupling.q), 0.2f, 5.0f); + p.coupling.sympGain = clamp (getFloatProp (*co, "symp_gain", p.coupling.sympGain), 0.0f, 0.3f); + p.coupling.sympHighDamp= clamp (getFloatProp (*co, "symp_high_damp", p.coupling.sympHighDamp), 0.0f, 1.0f); + } + } + + // board_modes (modal body) + if (auto bm = obj->getProperty ("board_modes"); bm.isArray()) + { + p.boardModes.clear(); + auto* arr = bm.getArray(); + const int maxModes = 16; + for (int i = 0; i < juce::jmin (maxModes, (int) arr->size()); ++i) + { + auto el = arr->getUnchecked (i); + if (! el.isObject()) continue; + auto* mo = el.getDynamicObject(); + PresetModel::BoardMode m; + m.f = clamp (getFloatProp (*mo, "f", m.f), 60.0f, 5000.0f); + m.q = clamp (getFloatProp (*mo, "q", m.q), 0.7f, 8.0f); + m.gainDb = clamp (getFloatProp (*mo, "gain_db", m.gainDb), -12.0f, 6.0f); + p.boardModes.add (m); + } + if (p.boardModes.isEmpty()) + { + p.boardModes.add ({ 110.0f, 1.2f, -2.0f }); + p.boardModes.add ({ 250.0f, 1.4f, -1.5f }); + p.boardModes.add ({ 750.0f, 2.0f, -3.0f }); + } + } + if (p.boardModes.isEmpty()) + { + p.boardModes.add ({ 110.0f, 1.2f, -2.0f }); + p.boardModes.add ({ 250.0f, 1.4f, -1.5f }); + p.boardModes.add ({ 750.0f, 2.0f, -3.0f }); + } + + p.boardSend = clamp (getFloatProp (*obj, "board_send", p.boardSend), 0.0f, 1.0f); + p.boardMix = clamp (getFloatProp (*obj, "board_mix", p.boardMix), 0.0f, 1.0f); + + // pm_filter + if (auto pf = obj->getProperty ("pm_filter"); pf.isObject()) + { + if (auto* pfo = pf.getDynamicObject()) + { + p.pmFilter.cutoff = clamp (getFloatProp (*pfo, "cutoff", p.pmFilter.cutoff), 300.0f, 12000.0f); + p.pmFilter.q = clamp (getFloatProp (*pfo, "q", p.pmFilter.q), 0.01f, 1.2f); + p.pmFilter.keytrack = clamp (getFloatProp (*pfo, "keytrack", p.pmFilter.keytrack), 0.0f, 1.0f); + } + } + + if (auto of = obj->getProperty ("output_lpf"); of.isObject()) + { + if (auto* oo = of.getDynamicObject()) + { + p.outputLpf.enabled = getBoolProp (*oo, "enabled", p.outputLpf.enabled); + p.outputLpf.cutoff = clamp (getFloatProp (*oo, "cutoff", p.outputLpf.cutoff), 0.0f, 18000.0f); + p.outputLpf.q = clamp (getFloatProp (*oo, "q", p.outputLpf.q), 0.2f, 2.5f); + } + } + + if (auto prv = obj->getProperty ("post_room"); prv.isObject()) + { + if (auto* pr = prv.getDynamicObject()) + { + p.postRoomMix = clamp (getFloatProp (*pr, "mix", p.postRoomMix), 0.0f, 1.0f); + p.postRoomEnabled = getBoolProp (*pr, "enabled", p.postRoomEnabled); + } + } + + if (auto eqv = obj->getProperty ("eq"); eqv.isObject()) + { + if (auto* eo = eqv.getDynamicObject()) + { + p.outputEq.enabled = getBoolProp (*eo, "enabled", p.outputEq.enabled); + if (auto bv = eo->getProperty ("bands"); bv.isArray()) + { + auto* arr = bv.getArray(); + for (int i = 0; i < juce::jmin (5, (int) arr->size()); ++i) + { + auto el = arr->getUnchecked (i); + if (! el.isObject()) continue; + auto* bo = el.getDynamicObject(); + p.outputEq.bands[(size_t) i].freq = clamp (getFloatProp (*bo, "freq", p.outputEq.bands[(size_t) i].freq), 40.0f, 16000.0f); + p.outputEq.bands[(size_t) i].q = clamp (getFloatProp (*bo, "q", p.outputEq.bands[(size_t) i].q), 0.3f, 6.0f); + p.outputEq.bands[(size_t) i].gainDb = clamp (getFloatProp (*bo, "gain_db", p.outputEq.bands[(size_t) i].gainDb), -18.0f, 18.0f); + } + } + } + } + + p.tiltDb = clamp (getFloatProp (*obj, "tilt_db", p.tiltDb), -6.0f, 6.0f); + p.predelayMs = clamp (getFloatProp (*obj, "predelay_ms", p.predelayMs), 0.0f, 20.0f); + + // Pedal + if (auto ped = obj->getProperty ("pedal"); ped.isObject()) + { + if (auto* po = ped.getDynamicObject()) + { + p.pedal.sustainThresh = clamp (getFloatProp (*po, "sustain_thresh", p.pedal.sustainThresh), 0.0f, 1.0f); + p.pedal.halfThresh = clamp (getFloatProp (*po, "half_thresh", p.pedal.halfThresh), 0.0f, 1.0f); + p.pedal.halfReleaseScale = clamp (getFloatProp (*po, "half_release_scale", p.pedal.halfReleaseScale), 0.5f, 4.0f); + p.pedal.repedalMs = clamp (getFloatProp (*po, "repedal_ms", p.pedal.repedalMs), 10.0f, 400.0f); + p.pedal.resonanceSend = clamp (getFloatProp (*po, "resonance_send", p.pedal.resonanceSend), 0.0f, 1.0f); + p.pedal.resonanceMix = clamp (getFloatProp (*po, "resonance_mix", p.pedal.resonanceMix), 0.0f, 1.0f); + p.pedal.resonanceT60 = clamp (getFloatProp (*po, "resonance_t60", p.pedal.resonanceT60), 0.2f, 4.0f); + p.pedal.sustainReleaseScale= clamp (getFloatProp (*po, "sustain_release_scale", p.pedal.sustainReleaseScale), 1.0f, 4.0f); + p.pedal.sustainGainDb = clamp (getFloatProp (*po, "sustain_gain_db", p.pedal.sustainGainDb), 0.0f, 6.0f); + } + } + + // Damper + if (auto dv = obj->getProperty ("damper"); dv.isObject()) + { + if (auto* d = dv.getDynamicObject()) + { + p.damper.lossDamped = clamp (getFloatProp (*d, "loss_damped", p.damper.lossDamped), 0.5f, 1.0f); + p.damper.lossHalf = clamp (getFloatProp (*d, "loss_half", p.damper.lossHalf), 0.5f, 1.0f); + p.damper.lossOff = clamp (getFloatProp (*d, "loss_off", p.damper.lossOff), 0.8f, 1.2f); + p.damper.smoothMs = clamp (getFloatProp (*d, "smooth_ms", p.damper.smoothMs), 1.0f, 120.0f); + p.damper.softenMs = clamp (getFloatProp (*d, "soften_ms", p.damper.softenMs), 1.0f, 80.0f); + p.damper.softenHz = clamp (getFloatProp (*d, "soften_hz", p.damper.softenHz), 100.0f, 8000.0f); + } + } + + if (auto una = obj->getProperty ("una_corda"); una.isObject()) + { + if (auto* uo = una.getDynamicObject()) + { + p.unaCorda.detuneCents = clamp (getFloatProp (*uo, "detune_cents", p.unaCorda.detuneCents), -12.0f, 12.0f); + p.unaCorda.gainScale = clamp (getFloatProp (*uo, "gain_scale", p.unaCorda.gainScale), 0.3f, 1.0f); + } + } + + if (auto dv = obj->getProperty ("duplex"); dv.isObject()) + { + if (auto* du = dv.getDynamicObject()) + { + p.duplex.ratio = clamp (getFloatProp (*du, "ratio", p.duplex.ratio), 1.1f, 4.0f); + p.duplex.gainDb = clamp (getFloatProp (*du, "gain_db", p.duplex.gainDb), -20.0f, -6.0f); + p.duplex.decayMs = clamp (getFloatProp (*du, "decay_ms", p.duplex.decayMs), 10.0f, 400.0f); + p.duplex.sympSend = clamp (getFloatProp (*du, "symp_send", p.duplex.sympSend), 0.0f, 1.0f); + p.duplex.sympMix = clamp (getFloatProp (*du, "symp_mix", p.duplex.sympMix), 0.0f, 1.0f); + p.duplex.sympNoPedalScale = clamp (getFloatProp (*du, "symp_no_pedal_scale", p.duplex.sympNoPedalScale), 0.0f, 1.0f); + } + } + + // Mic perspectives (optional) + if (auto mv = obj->getProperty ("mics"); mv.isObject()) + { + if (auto* mo = mv.getDynamicObject()) + { + auto parseMic = [&clamp] (const juce::DynamicObject* o, PresetModel::Mic& m) + { + if (o == nullptr) return; + m.gainDb = clamp (getFloatProp (*o, "gain_db", m.gainDb), -24.0f, 12.0f); + m.delayMs = clamp (getFloatProp (*o, "delay_ms", m.delayMs), 0.0f, 30.0f); + m.lowShelfDb = clamp (getFloatProp (*o, "low_shelf_db", m.lowShelfDb), -12.0f, 12.0f); + m.highShelfDb = clamp (getFloatProp (*o, "high_shelf_db",m.highShelfDb),-12.0f, 12.0f); + m.shelfFreq = clamp (getFloatProp (*o, "shelf_freq", m.shelfFreq), 200.0f, 8000.0f); + }; + + if (auto c = mo->getProperty ("close"); c.isObject()) parseMic (c.getDynamicObject(), p.mics.close); + if (auto c = mo->getProperty ("player"); c.isObject()) parseMic (c.getDynamicObject(), p.mics.player); + if (auto c = mo->getProperty ("room"); c.isObject()) parseMic (c.getDynamicObject(), p.mics.room); + + if (auto bv = mo->getProperty ("blend"); bv.isArray()) + { + auto* arr = bv.getArray(); + for (int i = 0; i < juce::jmin (3, (int) arr->size()); ++i) + p.mics.blend[(size_t) i] = clamp ((float) arr->getUnchecked (i), 0.0f, 1.0f); + } + } + } + { + float s = p.mics.blend[0] + p.mics.blend[1] + p.mics.blend[2]; + if (s <= 1.0e-6f) { p.mics.blend = { 1.0f, 0.0f, 0.0f }; } + else { for (float& b : p.mics.blend) b /= s; } + } + + // Optional loudness trim for pm2 path (defaults to +32 dB, clamps for safety) + p.pm2GainDb = clamp (getFloatProp (*obj, "pm2_gain_db", p.pm2GainDb), -24.0f, 42.0f); + + return p; +} + +void FluteSynthAudioProcessor::applyPresetToParameters (const PresetModel& p) +{ + currentEngine = p.engine; + + vaMix = juce::jlimit (0.0f, 1.0f, p.engineMixVa); + pmMix = juce::jlimit (0.0f, 1.0f, p.engineMixPm); + pm2Mix = juce::jlimit (0.0f, 1.0f, p.engineMixPm2); + masterTuneCents = p.masterTuneCents; + pitchCompOffsetCents = p.pitchCompOffsetCents; + pitchCompSlopeCents = p.pitchCompSlopeCents; + { + const auto pcOffsets = p.temperamentUseOffsets ? p.temperamentOffsetsCents + : getTemperamentOffsetsByName (p.temperamentName); + presetNoteOffsetsCents = p.perNoteOffsetsEnabled ? p.perNoteOffsetsCents + : expandPitchClassOffsets (pcOffsets); + noteOffsetsCents = presetNoteOffsetsCents; + } + pm2LoudnessSlopeDbPerSemi = p.pm2LoudnessSlopeDbPerSemi; + velocityGammaBase = p.velocityGamma; + velocityGamma = p.velocityGamma; + velocityCurveName = p.velocityCurve; + if (velocityCurveName == "soft") velocityGamma = 0.9f * velocityGamma; + else if (velocityCurveName == "hard") velocityGamma = 1.15f * velocityGamma; + + brightnessEnabled = p.brightnessEnabled; + brightnessBaseDb = p.brightnessBaseDb; + brightnessVelSlopeDb = p.brightnessVelSlopeDb; + brightnessNoteSlopeDb = p.brightnessNoteSlopeDb; + brightnessMaxDb = p.brightnessMaxDb; + brightnessCutoffHz = p.brightnessCutoffHz; + brightnessQ = p.brightnessQ; + + if (lastSampleRate > 0.0) + { + const float initialDb = brightnessEnabled ? juce::jlimit (-12.0f, brightnessMaxDb, + brightnessBaseDb + lastVelocityNorm * brightnessVelSlopeDb) + : 0.0f; + updateBrightnessFilters (initialDb); + brightnessDbSmoothed.setTargetValue (initialDb); + } + + dispersionCfg = p.dispersion; + applyMasterTuneToVoices(); + + // APVTS (VA) + *apvts.getRawParameterValue (ParamIDs::oscSine) = p.oscSine; + *apvts.getRawParameterValue (ParamIDs::oscSaw) = p.oscSaw; + *apvts.getRawParameterValue (ParamIDs::oscSquare) = p.oscSquare; + + *apvts.getRawParameterValue (ParamIDs::cutoff) = p.cutoff; + *apvts.getRawParameterValue (ParamIDs::resonance) = p.q; + + *apvts.getRawParameterValue (ParamIDs::attack) = p.attack; + *apvts.getRawParameterValue (ParamIDs::decay) = p.decay; + *apvts.getRawParameterValue (ParamIDs::sustain) = p.sustain; + *apvts.getRawParameterValue (ParamIDs::release) = p.release; + baseRelease = p.release; + releaseExtension = p.releaseExtension; + + *apvts.getRawParameterValue (ParamIDs::noiseDb) = p.noiseDb; + outputGainLin = juce::Decibels::decibelsToGain (p.outputGainDb); + outputGainLinSmoothed.setTargetValue (outputGainLin); + + // Extended controls (post / pm2 scaffolding) + *apvts.getRawParameterValue (ParamIDs::formant1Enable) = p.formants[0].enabled ? 1.0f : 0.0f; + *apvts.getRawParameterValue (ParamIDs::formant1Freq) = p.formants[0].freq; + *apvts.getRawParameterValue (ParamIDs::formant1Q) = p.formants[0].q; + *apvts.getRawParameterValue (ParamIDs::formant1GainDb) = p.formants[0].gainDb; + *apvts.getRawParameterValue (ParamIDs::formant2Enable) = p.formants[1].enabled ? 1.0f : 0.0f; + *apvts.getRawParameterValue (ParamIDs::formant2Freq) = p.formants[1].freq; + *apvts.getRawParameterValue (ParamIDs::formant2Q) = p.formants[1].q; + *apvts.getRawParameterValue (ParamIDs::formant2GainDb) = p.formants[1].gainDb; + + *apvts.getRawParameterValue (ParamIDs::soundboardEnable) = p.soundboard.enabled ? 1.0f : 0.0f; + *apvts.getRawParameterValue (ParamIDs::soundboardMix) = p.soundboard.mix; + *apvts.getRawParameterValue (ParamIDs::soundboardT60) = p.soundboard.t60_s; + *apvts.getRawParameterValue (ParamIDs::soundboardDamp) = p.soundboard.damp; + *apvts.getRawParameterValue (ParamIDs::postRoomMix) = p.postRoomMix; + *apvts.getRawParameterValue (ParamIDs::postRoomEnable) = p.postRoomEnabled ? 1.0f : 0.0f; + + *apvts.getRawParameterValue (ParamIDs::feltPreload) = p.feltModel.preload; + *apvts.getRawParameterValue (ParamIDs::feltStiffness) = p.feltModel.stiffness; + *apvts.getRawParameterValue (ParamIDs::feltHysteresis) = p.feltModel.hysteresis; + *apvts.getRawParameterValue (ParamIDs::feltMax) = p.feltModel.maxAmp; + + *apvts.getRawParameterValue (ParamIDs::duplexRatio) = p.duplex.ratio; + *apvts.getRawParameterValue (ParamIDs::duplexGainDb) = p.duplex.gainDb; + *apvts.getRawParameterValue (ParamIDs::duplexDecayMs) = p.duplex.decayMs; + *apvts.getRawParameterValue (ParamIDs::duplexSympSend) = p.duplex.sympSend; + *apvts.getRawParameterValue (ParamIDs::duplexSympMix) = p.duplex.sympMix; + + *apvts.getRawParameterValue (ParamIDs::pm2GainDb) = p.pm2GainDb; + *apvts.getRawParameterValue (ParamIDs::outputLpfEnable) = p.outputLpf.enabled ? 1.0f : 0.0f; + *apvts.getRawParameterValue (ParamIDs::outputLpfCutoff) = p.outputLpf.cutoff; + *apvts.getRawParameterValue (ParamIDs::outputLpfQ) = p.outputLpf.q; + if (auto* v = apvts.getRawParameterValue (ParamIDs::temperament)) + *v = 0.0f; // "Preset" + + // Shaper + shaperEnabled = p.shaperEnabled; + shaperDrive = p.shaperDrive; + + // Breath + breathEnabled = p.breathEnabled; + breathGainLin = juce::Decibels::decibelsToGain (p.breathLevelDb); + breathBpFreqStored = p.breathBpFreq; + breathBpQStored = p.breathBpQ; + breathBp.setCutoffFrequency (breathBpFreqStored); + breathBp.setResonance (breathBpQStored); + + // Formants + for (int i = 0; i < 2; ++i) + { + formant[i].enabled = p.formants[i].enabled; + formant[i].f.setCutoffFrequency (p.formants[i].freq); + formant[i].f.setResonance (p.formants[i].q); + formant[i].gainLin = juce::Decibels::decibelsToGain (p.formants[i].gainDb); + } + + // Hammer + hammerEnabled = p.hammer.enabled; + hammerLevel = p.hammer.level; + hammerNoise = p.hammer.noise; + hammerActive = false; + hammerEnv = 0.0f; + hammerHpHz = p.hammer.hp_hz; + hammerDecaySec = p.hammer.decay_s; // Store for recalculation in prepareToPlay + // Calculate decay coefficient (will be recalculated in prepareToPlay if sample rate was 0) + if (hammerDecaySec <= 0.0005f) hammerDecayCoeff = 0.0f; + else if (lastSampleRate > 0.0) + { + const double tau = std::max (0.0005, (double) hammerDecaySec); + hammerDecayCoeff = (float) std::exp (-1.0 / (tau * lastSampleRate)); + } + // else: leave hammerDecayCoeff at default, will be fixed in prepareToPlay + hammerHP.setCutoffFrequency (hammerHpHz); + + // Action / mechanical noises + keyOffEnabled = p.action.keyOffEnabled; + keyOffVelScale = p.action.keyOffVelScale; + keyOffLevel = p.action.keyOffLevel; + keyOffEnv = 0.0f; + keyOffDecaySec = p.action.keyOffDecay_s; + keyOffHpHz = p.action.keyOffHp_hz; + if (keyOffDecaySec <= 0.0005f) keyOffDecayCoeff = 0.0f; + else if (lastSampleRate > 0.0) + { + const double tau = std::max (0.0005, (double) keyOffDecaySec); + keyOffDecayCoeff = (float) std::exp (-1.0 / (tau * lastSampleRate)); + } + keyOffHP.setCutoffFrequency (keyOffHpHz); + + pedalThumpEnabled = p.action.pedalEnabled; + pedalThumpLevel = p.action.pedalLevel; + pedalThumpEnv = 0.0f; + pedalThumpDecaySec = p.action.pedalDecay_s; + pedalThumpLpHz = p.action.pedalLp_hz; + if (pedalThumpDecaySec <= 0.0005f) pedalThumpDecayCoeff = 0.0f; + else if (lastSampleRate > 0.0) + { + const double tau = std::max (0.0005, (double) pedalThumpDecaySec); + pedalThumpDecayCoeff = (float) std::exp (-1.0 / (tau * lastSampleRate)); + } + pedalThumpLP.setCutoffFrequency (pedalThumpLpHz); + + releaseThumpEnabled = p.action.releaseEnabled; + releaseThumpLevel = p.action.releaseLevel; + releaseThumpEnv = 0.0f; + releaseThumpDecaySec = p.action.releaseDecay_s; + releaseThumpLpHz = p.action.releaseLp_hz; + releaseThudMix = p.action.releaseThudMix; + releaseThudHpHz = p.action.releaseThudHp_hz; + if (releaseThumpDecaySec <= 0.0005f) releaseThumpDecayCoeff = 0.0f; + else if (lastSampleRate > 0.0) + { + const double tau = std::max (0.0005, (double) releaseThumpDecaySec); + releaseThumpDecayCoeff = (float) std::exp (-1.0 / (tau * lastSampleRate)); + } + releaseThumpLP.setCutoffFrequency (releaseThumpLpHz); + releaseThudHP.setCutoffFrequency (releaseThudHpHz); + damperCfg = p.damper; + updateDamperCoeffs(); + + // Soundboard + soundboardEnabled = p.soundboard.enabled; + soundboardMix = p.soundboard.mix; + + soundboardParams = {}; + const float room = juce::jlimit (0.0f, 1.0f, p.soundboard.t60_s / 3.0f); // 0..~3s + soundboardParams.roomSize = room; + soundboardParams.damping = juce::jlimit (0.0f, 1.0f, p.soundboard.damp); + soundboardParams.width = 0.6f; + soundboardParams.wetLevel = 1.0f; // we do wet/dry outside + soundboardParams.dryLevel = 0.0f; + + // NEW: pass the same ADSR to the PM engine so env.* applies there too + for (int i = 0; i < synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (synth.getVoice (i))) + { + v->setPitchComp (pitchCompOffsetCents, pitchCompSlopeCents); + v->setNoteOffsets (noteOffsetsCents); + } + for (int i = 0; i < pmSynth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pmSynth.getVoice (i))) + { + v->setEnvParams (p.attack, p.decay, p.sustain, p.release); + v->setReleaseScale (baseRelease, 1.0f); + v->setPitchComp (pitchCompOffsetCents, pitchCompSlopeCents); + v->setNoteOffsets (noteOffsetsCents); + } + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + { + v->setEnvParams (p.attack, p.decay, p.sustain, p.release); + v->setReleaseScale (baseRelease, 1.0f); + v->setWdfParams (sanitizeWdf (p.wdf)); + v->setPitchComp (pitchCompOffsetCents, pitchCompSlopeCents); + v->setNoteOffsets (noteOffsetsCents); + v->setLoudnessSlope (pm2LoudnessSlopeDbPerSemi); + v->setReleaseExtension (p.releaseExtension); + v->setSustainPedalDown (sustainPedalDown); + v->setDamperParams (p.damper); + v->setDamperLift (damperLift); + v->setCouplingParams (p.coupling); + } + + // pm2 scaffolding (store parsed values; DSP lands in Phase 2) + pmString = p.pmString; + pmHammer = p.hammerModel; + pmFelt = p.feltModel; + wdfCfg = sanitizeWdf (p.wdf); + couplingCfg = p.coupling; + pmBoardModes = p.boardModes; + pmBoardSend = p.boardSend; + pmBoardMix = p.boardMix; + pmToneFilter = p.pmFilter; + pmTiltDb = p.tiltDb; + pmPredelayMs = p.predelayMs; + pedalCfg = p.pedal; + damperCfg = p.damper; + unaCfg = p.unaCorda; + duplexCfg = p.duplex; + micCfg = p.mics; + halfReleaseScale = p.pedal.halfReleaseScale; + pm2GainDb = p.pm2GainDb; + pm2GainLin = juce::Decibels::decibelsToGain (pm2GainDb); + postCutoffHz = p.pmFilter.cutoff; + postQ = p.pmFilter.q; + postKeytrack = p.pmFilter.keytrack; + postTiltDb = p.tiltDb; + outputLpfEnabled = p.outputLpf.enabled && DebugToggles::kEnableOutputLpf; + outputLpfCutoff = p.outputLpf.cutoff; + outputLpfQ = p.outputLpf.q; + postCutoffHzSmoothed.setTargetValue (postCutoffHz); + postQSmoothed.setTargetValue (postQ); + postTiltDbSmoothed.setTargetValue (postTiltDb); + outputLpfCutoffSmoothed.setTargetValue (outputLpfCutoff); + outputLpfQSmoothed.setTargetValue (outputLpfQ); + outputEqEnabled = p.outputEq.enabled; + outputEqCfg = p.outputEq; + if (! prepared || ! anyVoiceActive()) + { + postVaLp1.reset(); postVaLp2.reset(); + postPmLp1.reset(); postPmLp2.reset(); + postPm2Lp1.reset(); postPm2Lp2.reset(); + tiltLow.reset(); tiltHigh.reset(); + outputLpf.reset(); + for (auto& f : outputEqFilters) + f.reset(); + } + else + { + pendingStateReset = true; + } + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + { + v->setParams (p.pmString); + v->setHammerParams (p.hammerModel); + v->setFeltParams (p.feltModel); + v->setDuplexParams (p.duplex); + v->setSoftPedal (softPedalDown, p.unaCorda); + v->setEnvParams (p.attack, p.decay, p.sustain, p.release); + } + modalDirty = true; + // Pedal resonance + if (! prepared || ! anyVoiceActive()) + pedalReverb.reset(); + pedalReverbParams = {}; + pedalReverbParams.roomSize = juce::jlimit (0.0f, 1.0f, p.pedal.resonanceT60 / 3.0f); + pedalReverbParams.damping = 0.4f; + pedalReverbParams.wetLevel = 1.0f; + pedalReverbParams.dryLevel = 0.0f; + + // Sympathetic reverb params (light, short) + if (! prepared || ! anyVoiceActive()) + sympReverb.reset(); + sympParams = {}; + sympParams.roomSize = juce::jlimit (0.0f, 1.0f, p.duplex.decayMs / 400.0f); // rough tie to decay + sympParams.damping = 0.4f; + sympParams.wetLevel = 1.0f; + sympParams.dryLevel = 0.0f; + + // Ensure runtime state matches APVTS (so host automation works) + syncExtendedParamsFromAPVTS(); + + updatePostFiltersForNote (lastMidiNote); + updateOutputLpf(); + updateOutputEq(); + tiltReady = (tiltLow.coefficients != nullptr && tiltHigh.coefficients != nullptr); + updateMicProcessors(); + presetUiSyncPending.store (true, std::memory_order_release); +} + +// Public API used by CLI or GUI reset +bool FluteSynthAudioProcessor::loadEmbeddedPreset () +{ + if (! loadEmbeddedPresetModel()) + return false; + + if (embeddedPresets.empty()) + return false; + + const int presetIdx = juce::jlimit (0, (int) embeddedPresets.size() - 1, + activeEmbeddedPresetIndex.load()); + + // If not yet prepared (constructor/startup), apply immediately. + if (! prepared) + { + activeEmbeddedPresetIndex.store (presetIdx, std::memory_order_release); + applyPresetToParameters (embeddedPresets[(size_t) presetIdx].model); + return true; + } + + // Otherwise, schedule application on the audio thread to avoid GUI/DSP races. + requestEmbeddedPresetApply (presetIdx); + return true; +} + +bool FluteSynthAudioProcessor::loadEmbeddedPresetModel () +{ + // FIX: Always load presets from JSON file, but apply physics-based parameters + // This ensures all presets appear in the dropdown menu + + if (BinaryData::preset_jsonSize <= 0) + { + // Fallback: create single physics preset if no JSON available + if (PhysicsToggles::kUsePhysicsDefaults) + { + embeddedPresetLoaded.store (false, std::memory_order_release); + embeddedPresets.clear(); + EmbeddedPreset ep; + ep.name = "Physics Default"; + ep.model = buildPhysicsPresetModel(); + embeddedPresets.push_back (std::move (ep)); + embeddedPresetLoaded.store (true, std::memory_order_release); + activeEmbeddedPresetIndex.store (0, std::memory_order_release); + return true; + } + return false; + } + + embeddedPresetLoaded.store (false, std::memory_order_release); + embeddedPresets.clear(); + + juce::MemoryInputStream in (BinaryData::preset_json, + (size_t) BinaryData::preset_jsonSize, + false); + auto text = in.readEntireStreamAsString(); + + if (text.startsWith ("```")) + { + text = text.fromFirstOccurrenceOf ("```", false, false); + text = text.fromFirstOccurrenceOf ("\n", false, false); + text = text.upToLastOccurrenceOf ("```", false, false); + } + text = text.replace ("\\_", "_"); + + juce::var v = juce::JSON::parse (text); + if (v.isVoid()) + return false; + + embeddedPresets.clear(); + + auto pushPreset = [this] (const juce::var& presetVar, int idx) + { + juce::String name = "Preset " + juce::String (idx + 1); + if (auto* obj = presetVar.getDynamicObject()) + if (obj->hasProperty ("name") && obj->getProperty ("name").isString()) + name = obj->getProperty ("name").toString(); + + EmbeddedPreset ep; + ep.name = name; + // Parse preset from JSON - the JSON now contains physics-compatible values + ep.model = parsePresetJson (presetVar); + embeddedPresets.push_back (std::move (ep)); + }; + + if (auto* obj = v.getDynamicObject()) + { + if (auto presetsVar = obj->getProperty ("presets"); presetsVar.isArray()) + { + auto* arr = presetsVar.getArray(); + for (int i = 0; i < arr->size(); ++i) + pushPreset (arr->getReference (i), i); + } + else + { + pushPreset (v, 0); + } + } + else if (v.isArray()) + { + auto* arr = v.getArray(); + for (int i = 0; i < arr->size(); ++i) + pushPreset (arr->getReference (i), i); + } + + if (embeddedPresets.empty()) + return false; + + embeddedPresetLoaded.store (true, std::memory_order_release); + activeEmbeddedPresetIndex.store (juce::jlimit (0, (int) embeddedPresets.size() - 1, + activeEmbeddedPresetIndex.load()), + std::memory_order_release); + return true; +} + +void FluteSynthAudioProcessor::resetToEmbeddedPreset() +{ + requestEmbeddedPresetApply (activeEmbeddedPresetIndex.load()); +} + +bool FluteSynthAudioProcessor::loadPresetFromJson (const juce::File& file) +{ + if (PhysicsToggles::kUsePhysicsDefaults) + { + applyPresetToParameters (buildPhysicsPresetModel()); + return true; + } + + if (! file.existsAsFile()) return false; + + juce::var v; + { + juce::FileInputStream in (file); + if (! in.openedOk()) return false; + auto text = in.readEntireStreamAsString(); + + // tolerate common chat artifacts + if (text.startsWith ("```")) + { + text = text.fromFirstOccurrenceOf ("```", false, false); + text = text.fromFirstOccurrenceOf ("\n", false, false); + text = text.upToLastOccurrenceOf ("```", false, false); + } + text = text.replace ("\\_", "_"); + + v = juce::JSON::parse (text); + if (v.isVoid()) return false; + } + + auto model = parsePresetJson (v); + applyPresetToParameters (model); + + return true; +} + +void FluteSynthAudioProcessor::setWdfForTest (const PresetModel::WdfModel& wdfModel) +{ + wdfCfg = sanitizeWdf (wdfModel); + for (int i = 0; i < pm2Synth.getNumVoices(); ++i) + if (auto* v = dynamic_cast (pm2Synth.getVoice (i))) + v->setWdfParams (wdfCfg); +} + +void FluteSynthAudioProcessor::requestEmbeddedPresetApply() +{ + requestEmbeddedPresetApply (activeEmbeddedPresetIndex.load()); +} + +void FluteSynthAudioProcessor::requestEmbeddedPresetApply (int index) +{ + if (! embeddedPresetLoaded.load()) + loadEmbeddedPresetModel(); + + if (embeddedPresets.empty()) + return; + + const int clamped = juce::jlimit (0, (int) embeddedPresets.size() - 1, index); + activeEmbeddedPresetIndex.store (clamped, std::memory_order_release); + + if (! prepared) + { + applyPresetToParameters (embeddedPresets[(size_t) clamped].model); + return; + } + + pendingEmbeddedPresetIndex.store (clamped, std::memory_order_release); +} + +juce::StringArray FluteSynthAudioProcessor::getEmbeddedPresetNames() const +{ + if (! embeddedPresetLoaded.load()) + const_cast (this)->loadEmbeddedPresetModel(); + + juce::StringArray names; + for (const auto& p : embeddedPresets) + names.add (p.name); + return names; +} + +int FluteSynthAudioProcessor::getActiveEmbeddedPresetIndex() const +{ + return activeEmbeddedPresetIndex.load(); +} + +void FluteSynthAudioProcessor::selectEmbeddedPreset (int index) +{ + requestEmbeddedPresetApply (index); +} + +bool FluteSynthAudioProcessor::consumePendingPresetUiSync() +{ + return presetUiSyncPending.exchange (false, std::memory_order_acq_rel); +} + +// Factory function for JUCE wrappers +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new FluteSynthAudioProcessor(); +} diff --git a/PluginProcessor.h b/PluginProcessor.h new file mode 100644 index 0000000..15c00ae --- /dev/null +++ b/PluginProcessor.h @@ -0,0 +1,1458 @@ +#pragma once +#include +#include +#include +#include + +//=========================== Parameter IDs (VA path) =========================== +namespace ParamIDs +{ + static constexpr const char* oscSine = "oscSine"; + static constexpr const char* oscSaw = "oscSaw"; + static constexpr const char* oscSquare = "oscSquare"; + + static constexpr const char* cutoff = "cutoff"; + static constexpr const char* resonance = "resonance"; + + static constexpr const char* attack = "attack"; + static constexpr const char* decay = "decay"; + static constexpr const char* sustain = "sustain"; + static constexpr const char* release = "release"; + + static constexpr const char* noiseDb = "noiseDb"; + + // Extended controls for existing DSP blocks + static constexpr const char* formant1Enable = "formant1Enable"; + static constexpr const char* formant1Freq = "formant1Freq"; + static constexpr const char* formant1Q = "formant1Q"; + static constexpr const char* formant1GainDb = "formant1GainDb"; + static constexpr const char* formant2Enable = "formant2Enable"; + static constexpr const char* formant2Freq = "formant2Freq"; + static constexpr const char* formant2Q = "formant2Q"; + static constexpr const char* formant2GainDb = "formant2GainDb"; + + static constexpr const char* soundboardEnable = "soundboardEnable"; + static constexpr const char* soundboardMix = "soundboardMix"; + static constexpr const char* soundboardT60 = "soundboardT60"; + static constexpr const char* soundboardDamp = "soundboardDamp"; + static constexpr const char* postRoomMix = "postRoomMix"; + static constexpr const char* postRoomEnable = "postRoomEnable"; + + static constexpr const char* feltPreload = "feltPreload"; + static constexpr const char* feltStiffness = "feltStiffness"; + static constexpr const char* feltHysteresis = "feltHysteresis"; + static constexpr const char* feltMax = "feltMax"; + + static constexpr const char* duplexRatio = "duplexRatio"; + static constexpr const char* duplexGainDb = "duplexGainDb"; + static constexpr const char* duplexDecayMs = "duplexDecayMs"; + static constexpr const char* duplexSympSend = "duplexSympSend"; + static constexpr const char* duplexSympMix = "duplexSympMix"; + + static constexpr const char* pm2GainDb = "pm2GainDb"; + + // Final output low-pass + static constexpr const char* outputLpfEnable = "outputLpfEnable"; + static constexpr const char* outputLpfCutoff = "outputLpfCutoff"; + static constexpr const char* outputLpfQ = "outputLpfQ"; + + static constexpr const char* masterVolume = "masterVolume"; + static constexpr const char* temperament = "temperament"; + static constexpr const char* velocityCurve = "velocityCurve"; +} + +//============================= Engine feature toggles ========================== +// Set any of these to false to bypass a specific PM2 or post-processing feature. +namespace DebugToggles +{ + inline constexpr bool kEnablePm2Duplex = true; + inline constexpr bool kEnablePm2PedalResonance = true; + inline constexpr bool kEnablePm2Damper = true; + inline constexpr bool kEnablePm2Dispersion = true; // ENABLED: Essential for piano inharmonicity + inline constexpr bool kEnablePm2WdfBurst = true; //CAN DEACTIVATE WHICH CAUSES CPU REDUCTION + inline constexpr bool kEnablePm2ModalSoundboard= true; //CAN DEACTIVATE WHICH CAUSES CPU REDUCTION + inline constexpr bool kEnablePm2StringFilters = true; + inline constexpr bool kEnablePm2DcBlock = true; + inline constexpr bool kEnablePm2SoftClip = false; + inline constexpr bool kEnablePm2HammerExcitation = true; + inline constexpr bool kEnablePm2FeltShaping = true; // nonlinear felt curve in burst model + inline constexpr bool kEnablePm2SimplifiedBurst= true; // allow simplified burst when preset requests + inline constexpr bool kEnablePm2EnergyLimiter = true; // per-string energy limiter + inline constexpr bool kEnablePm2AntiSwell = true; // anti-swell envelope for high notes + inline constexpr bool kEnablePm2HighNoteLoopDamping = true; // extra loop damping for high notes + inline constexpr bool kEnablePm2HighNoteCouplingTilt = false; // reduce coupling/symp for high notes + inline constexpr bool kEnablePm2MinDurationC4Split = true; // min-note duration split at C4 + inline constexpr bool kEnablePm2TrebleLoopGainBoost = false; // loop-gain boost starting at C4 + inline constexpr bool kEnablePm2TrebleT60Boost = false; // extra sustain boost starting at C5 + inline constexpr bool kEnablePm2TrebleLoopGainComp = false; // extra loop-gain comp starting at C5 + inline constexpr bool kEnablePm2TrebleToneComp = false; // tone shaping starting at C5 + inline constexpr bool kEnablePm2WdfTrebleAtten = false; // WDF treble attenuation starting at C5 + inline constexpr bool kEnablePm2AntiSwellTreblePivot = true; // anti-swell note scaling starting at C5 + inline constexpr bool kEnablePm2NoteHpf = true; // per-note HPF in PM2 + inline constexpr bool kEnablePm2FracDelayInterp= true; // ENABLED: Better pitch accuracy with Thiran interpolation + inline constexpr bool kEnablePm2FreqDependentLoss = true;// per-note loop gain tilt (no filter in loop) + inline constexpr bool kEnablePm2PostLpfEnv = true; // ENABLED: Notes darken over time like real piano + inline constexpr bool kEnablePm2ExtraLoopGainSmoothing = true; + inline constexpr bool kEnablePm2ExtraDamperSmoothing = true; + inline constexpr bool kEnablePm2HammerGainRamp = true; + inline constexpr bool kEnableGlobalFilters = true; + inline constexpr bool kEnableVaFilter = true; + inline constexpr bool kEnablePmDcBlock = true; + inline constexpr bool kEnableBreathFilter = true; + inline constexpr bool kEnableHammerFilter = true; + inline constexpr bool kEnableKeyOffFilter = true; + inline constexpr bool kEnablePedalThumpFilter = true; + inline constexpr bool kEnableReleaseThumpFilter= true; + inline constexpr bool kEnableReleaseThudFilter = true; + inline constexpr bool kEnableTilt = true; + inline constexpr bool kEnableBrightness = true; + inline constexpr bool kEnableFormant = true; + inline constexpr bool kEnableMic = false; //CAN DEACTIVATE WHICH CAUSES CPU REDUCTION + inline constexpr bool kEnableCoupling = true; // ENABLED: Sympathetic string resonance + inline constexpr bool kEnableReverb = true; + inline constexpr bool kEnableNoiseDb = true; + inline constexpr bool kEnableFelt = true; + inline constexpr bool kEnableReleaseExtension = true; + inline constexpr bool kEnableEq = true; + inline constexpr bool kEnableOutputDcBlock = true; + inline constexpr bool kEnableFinalLimiter = true; + inline constexpr bool kEnableSoundboardConvolution = true;// use procedural IR instead of JUCE reverb + inline constexpr int kSoundboardConvolutionDownsample = 2; // 1=full rate, 2=half-rate convolution (4x causes aliasing) + inline constexpr bool kEnablePostRoomReverb = true; // ENABLED: Room ambience for naturalness + inline constexpr bool kPostRoomIsHall = false; // false=room, true=hall + inline constexpr bool kEnableOutputLpf = true; + inline constexpr bool kDisableSustainPedal = false; +} + +//============================= Physics data integration ======================= +namespace PhysicsToggles +{ + // When true, embedded presets are ignored and physics-based defaults are used. + inline constexpr bool kUsePhysicsDefaults = true; + // When true, per-note physics (inharmonicity, hammer params, decay) are used. + inline constexpr bool kUsePerNotePhysics = true; +} + +//============================= Preset model =================================== +struct Formant +{ + bool enabled { false }; + float freq { 1800.0f }; + float q { 2.0f }; + float gainDb { 0.0f }; +}; + +struct PresetModel +{ + struct Hammer + { + bool enabled { false }; + float level { 0.25f }; // 0..1 + float decay_s { 0.015f }; // seconds + float noise { 0.40f }; // 0..1 + float hp_hz { 1800.0f }; + }; + + struct ActionNoise + { + bool keyOffEnabled { false }; + float keyOffLevel { 0.05f }; // 0..1 + float keyOffDecay_s { 0.030f }; + bool keyOffVelScale { true }; + float keyOffHp_hz { 1200.0f }; + + bool pedalEnabled { false }; + float pedalLevel { 0.08f }; + float pedalDecay_s { 0.050f }; + float pedalLp_hz { 600.0f }; + + bool releaseEnabled { false }; + float releaseLevel { 0.10f }; + float releaseDecay_s { 0.060f }; + float releaseLp_hz { 450.0f }; + float releaseThudMix { 0.5f }; // 0..1 + float releaseThudHp_hz { 70.0f }; + }; + + struct Soundboard + { + bool enabled { false }; + float mix { 0.25f }; // 0..1 (increased from 0.20) + float t60_s { 2.0f }; // seconds + float damp { 0.35f }; // 0..1 (reduced from 0.40 for longer decay) + }; + + struct PmString + { + int numStrings { 2 }; // 1..3 + std::array detuneCents{ { 0.0f, 1.5f, 0.0f } }; // +/-5 + std::array gain { { 0.6f, 0.4f, 0.0f } }; // normalized + std::array pan { { -0.25f, 0.0f, 0.25f } }; // -1..1, left..right + float stereoWidthLow { 1.0f }; // width factor in bass + float stereoWidthHigh { 0.35f }; // width factor in treble + float stereoWidthNoteLo { 21.0f }; // MIDI for widthLow (A0) + float stereoWidthNoteHi { 96.0f }; // MIDI for widthHigh (C7-ish) + float dispersionAmt{ 0.15f }; // 0..1 + int apStages { 2 }; // 1..4 + float loss { 0.003f }; // 0.0005..0.02 (lowered from 0.006 for longer sustain) + float dcBlockHz { 8.0f }; // 3..20 + }; + + struct HammerModel + { + float force { 0.5f }; // 0..1 + float toneHz { 2500.0f }; // 1500..6000 + bool softclip { true }; + float attackMs { 8.0f }; // 1..20 (increased from 6) + float gamma { 1.2f }; // velocity curve exponent (1=linear) + // Continuous hammer-string interaction + float massKg { 0.028f }; // effective hammer mass + float contactStiffness { 2600.0f }; // base felt stiffness + float contactExponent { 2.4f }; // felt compression exponent + float contactDamping { 6.0f }; // damping term + float maxPenetration { 0.010f }; // meters (safety clamp) + float attackWindowMs { 15.0f }; // interaction window (increased from 6ms for longer transient) + bool simplifiedMode { false }; // fallback to legacy burst + // Velocity response + float stiffnessVelScale { 0.40f }; // scales stiffness vs velocity + float toneVelScale { 0.35f }; // scales tone cutoff vs velocity + float preloadVelScale { 0.35f }; // scales felt preload vs velocity + float toneMinHz { 1400.0f }; + float toneMaxHz { 12000.0f }; + }; + + struct FeltModel + { + float preload { 0.08f }; // small preload offset before compression + float stiffness { 2.4f }; // exponent for compression curve + float hysteresis { 0.15f }; // bleed from previous contact to mimic rate/return + float maxAmp { 1.4f }; // absolute clamp for safety + }; + + struct BoardMode { float f{110.0f}; float q{1.2f}; float gainDb{-2.0f}; }; + + struct PmFilter { float cutoff{4500.0f}; float q{0.8f}; float keytrack{0.5f}; }; + struct OutputLpf { bool enabled{false}; float cutoff{18000.0f}; float q{0.707f}; }; + struct EqBand { float freq{80.0f}; float q{0.7f}; float gainDb{0.0f}; }; + struct OutputEq + { + bool enabled{ false }; + std::array bands{ { + { 80.0f, 0.7f, 0.0f }, + { 250.0f, 0.9f, 0.0f }, + { 800.0f, 1.0f, 0.0f }, + { 2500.0f, 1.0f, 0.0f }, + { 8000.0f, 0.8f, 0.0f } + } }; + }; + + struct Pedal + { + float sustainThresh { 0.65f }; // CC64 >= thresh => latch sustain + float halfThresh { 0.35f }; // CC64 >= half => lengthen release + float halfReleaseScale { 1.6f }; // release multiplier for half-pedal + float sustainReleaseScale { 1.2f }; // release multiplier when sustain is down + float repedalMs { 120.0f }; // window to catch repedal (info only) + float resonanceSend { 0.25f }; // send into pedal resonance + float resonanceMix { 0.25f }; // mix of pedal resonance return + float resonanceT60 { 1.6f }; // decay of pedal resonance (seconds) + float sustainGainDb { 1.5f }; // post gain boost when sustain is down + }; + + struct Damper + { + float lossDamped { 0.86f }; // multiply loop gain when damper on + float lossHalf { 0.94f }; // multiply loop gain in half-pedal zone + float lossOff { 1.00f }; // multiply loop gain when pedal fully lifted + float smoothMs { 20.0f }; // smoothing for loopGain transitions + float softenMs { 12.0f }; // soften filter duration + float softenHz { 1200.0f }; // soften filter cutoff + }; + + struct UnaCorda + { + float detuneCents { -3.0f }; // applied to pm2 strings when soft pedal down + float gainScale { 0.85f }; // scale per-string gains when soft pedal down + }; + + struct Duplex + { + float ratio { 2.2f }; // duplex partial ratio vs main + float gainDb { -12.0f }; // clamp to [-20..-6] dB + float decayMs { 180.0f }; // decay time for afterlength (increased from 120 for longer tail) + float sympSend { 0.18f }; // sympathetic resonance send (increased from 0.15) + float sympMix { 0.22f }; // sympathetic return mix (increased from 0.20) + float sympNoPedalScale { 0.25f }; // scale sympathetic return when sustain is not down + }; + + struct DispersionCurve + { + float highMult { 1.25f }; // scale dispersion for highest notes + float pow { 1.1f }; // curve steepness vs note norm + }; + + struct Mic + { + float gainDb { 0.0f }; + float delayMs { 0.0f }; + float lowShelfDb { 0.0f }; + float highShelfDb { 0.0f }; + float shelfFreq { 800.0f }; + }; + + struct Mics + { + Mic close {}; + Mic player { 0.0f, 1.5f, 1.0f, -0.5f, 900.0f }; + Mic room { -2.0f, 6.0f, 1.5f, 1.5f, 600.0f }; + std::array blend { { 1.0f, 0.0f, 0.0f } }; + }; + + Mics mics; + DispersionCurve dispersion; + + // Loudness trim for pm2 engine (applied after pm2 render to match other engines) + // FIXED: Changed from +12dB to 0dB to prevent overwhelming output levels + float pm2GainDb { 0.0f }; // unity gain - no boost to avoid overloading + float outputGainDb { 0.0f }; // per-preset output trim (dB) + + int schemaVersion { 4 }; // accept older, but save/apply as v4 + juce::String engine { "va" }; // "va", "pm", "pm2" (placeholder), or "hybrid" + float masterTuneCents { 0.0f }; // global coarse tune in cents + + // Temperament / per-note tuning + juce::String temperamentName { "12-TET" }; + std::array temperamentOffsetsCents { { 0.0f } }; // C..B offsets in cents + bool temperamentUseOffsets { false }; // true if offsets provided in preset + std::array perNoteOffsetsCents { { 0.0f } }; // per MIDI note offsets + bool perNoteOffsetsEnabled { false }; + + // Optional hybrid mix (top-level) + float engineMixVa { 1.0f }; + float engineMixPm { 0.0f }; + float engineMixPm2 { 0.0f }; // placeholder for future pm2 engine + + // --- VA core (driven by APVTS) --- + float oscSine{0.7f}, oscSaw{0.3f}, oscSquare{0.0f}; + float cutoff{1800.0f}, q{0.7f}; + float attack{0.010f}, decay{0.75f}, sustain{0.0f}, release{0.45f}; + float noiseDb{-48.0f}; + float pitchCompOffsetCents{ 0.0f }; // global cents offset + float pitchCompSlopeCents{ 0.0f }; // cents per MIDI note vs 60 + // FIXED: Changed from +0.04 to -0.12 for realistic piano loudness curve + // Negative slope means treble is quieter than bass (like real piano) + float pm2LoudnessSlopeDbPerSemi{ -0.03f }; + float releaseExtension { 1.5f }; // loop gain scale for release tail (increased from 1.0) + // Velocity -> brightness high-shelf control + bool brightnessEnabled { false }; + float brightnessBaseDb { 0.0f }; + float brightnessVelSlopeDb { 4.0f }; // additional dB at vel=1 + float brightnessNoteSlopeDb { 0.0f }; // dB spread low->high notes + float brightnessMaxDb { 6.0f }; + float brightnessCutoffHz { 3200.0f }; + float brightnessQ { 0.707f }; + + // Global velocity curve shaping + float velocityGamma { 1.0f }; // pow(vel, gamma) + juce::String velocityCurve { "linear" }; // optional name from preset + + // --- Harmonic shaper (post-VA pre-shared) --- + bool shaperEnabled{ true }; + float shaperDrive { 0.15f }; // 0..1 + + // --- Breath noise (post) --- + bool breathEnabled{ true }; + float breathLevelDb{ -40.0f }; + float breathBpFreq { 5500.0f }; + float breathBpQ { 1.0f }; + + // --- Formant peaks (post) --- + Formant formants[2]; + + // --- Piano features --- + Hammer hammer; + ActionNoise action; + Damper damper; + Soundboard soundboard; + + // --- pm2 params --- + PmString pmString; + HammerModel hammerModel; + FeltModel feltModel; + struct WdfModel + { + bool enabled { false }; + float blend { 0.0f }; // 0=legacy felt only, 1=WDF only + float loss { 0.01f }; + float bridgeMass { 1.0f }; + float plateStiffness { 1.0f }; + } wdf; + struct Coupling + { + float gain { 0.02f }; // cross-string bleed + float q { 0.7f }; + float sympGain { 0.05f }; // sympathetic send + float sympHighDamp { 0.5f }; // attenuate highs in sympathetic path + } coupling; + juce::Array boardModes; + float boardSend { 0.35f }; + float boardMix { 0.30f }; + PmFilter pmFilter; + OutputLpf outputLpf; + OutputEq outputEq; + float tiltDb { 0.0f }; + float predelayMs { 5.0f }; + float postRoomMix { 1.0f }; // scale for post room/hall reverb + bool postRoomEnabled { true }; + Pedal pedal; + UnaCorda unaCorda; + Duplex duplex; + + static float clamp (float x, float lo, float hi) { return juce::jlimit (lo, hi, x); } +}; + +//============================= Sound & Voice (VA) ============================== +struct SimpleSound : public juce::SynthesiserSound +{ + bool appliesToNote (int) override { return true; } + bool appliesToChannel (int) override { return true; } +}; + +class FluteVoice : public juce::SynthesiserVoice +{ +public: + explicit FluteVoice (juce::AudioProcessorValueTreeState& state); + bool canPlaySound (juce::SynthesiserSound* s) override; + void startNote (int midiNoteNumber, float velocity, juce::SynthesiserSound*, int) override; + void stopNote (float, bool allowTailOff) override; + void pitchWheelMoved (int) override {} + void controllerMoved (int, int) override {} + void renderNextBlock (juce::AudioBuffer& buffer, int startSample, int numSamples) override; + void prepare (double sr, int samplesPerBlock, int /*numChannels*/); + void setMasterTuneFactor (float f) { masterTuneFactor = f; } + void setPitchComp (float offsetCents, float slopeCentsPerNote) + { + pitchCompOffsetCents = offsetCents; + pitchCompSlopeCents = slopeCentsPerNote; + } + void setNoteOffsets (const std::array& offsets) { noteOffsetsCents = offsets; } + +private: + void setFrequency (double hz); + float randomUniform(); + void updateParams(); + + // state + juce::AudioProcessorValueTreeState& apvts; + + double sampleRate { 44100.0 }, currentFrequency { 440.0 }; + float phase { 0.0f }, phaseDelta { 0.0f }, phaseOverPi { 0.0f }; + + juce::ADSR adsr; + juce::dsp::StateVariableTPTFilter svf; + + float wSine { 1.0f }, wSaw { 0.0f }, wSquare { 0.0f }; + float preNoiseLin { 0.0f }; + uint32_t rng { 0x12345678 }; + float masterTuneFactor { 1.0f }; + float velocityGain { 1.0f }; + float pitchCompOffsetCents { 0.0f }; + float pitchCompSlopeCents { 0.0f }; + std::array noteOffsetsCents { { 0.0f } }; +}; + +//=========================== Simple PM (waveguide) ============================= +// Now includes an amplitude ADSR so env.* affects PM as well. +class WaveguideFlute +{ +public: + void prepare (double sr, int blockSize, int numCh); + void setFrequency (double hz); + void noteOn (int midi, float /*vel*/); + void noteOff(); + + // New: set amplitude envelope for PM (called from applyPresetToParameters) + void setEnvParams (float attack, float decay, float sustain, float release); + void setReleaseScale (float baseRelease, float scale); + void setPitchComp (float offsetCents, float slopeCentsPerNote) + { + pitchCompOffsetCents = offsetCents; + pitchCompSlopeCents = slopeCentsPerNote; + } + void setNoteOffsets (const std::array& offsets) { noteOffsetsCents = offsets; } + + // Render the PM block (scaled by ADSR) + void render (juce::AudioBuffer& buffer, int start, int num); + bool isActive() const { return active || adsr.isActive(); } + void setMasterTuneFactor (float f) { masterTuneFactor = f; } + +private: + struct DCBlock + { + void reset (double sr) { a = (float) (1.0 - 2.0 * juce::MathConstants::pi * 5.0 / sr); x1 = y1 = 0.0f; } + float process (float x) { float y = x - x1 + a * y1; x1 = x; y1 = y; return y; } + float a{0.995f}, x1{0}, y1{0}; + }; + + juce::AudioBuffer delay; + int writePos { 0 }; + double sampleRate { 44100.0 }, frequency { 440.0 }; + bool active { false }; // running state (continues through release) + float noiseGain { 0.02f }, jetFeedback { 0.2f }, phase { 0.0f }; + DCBlock dc; + float velocityGain { 1.0f }; + + // New: amplitude envelope + juce::ADSR adsr{}; + juce::ADSR::Parameters envParams{ 0.01f, 0.75f, 0.0f, 0.45f }; + float baseRelease { 0.45f }; + + float randomUniform(); + uint32_t rng { 0xCAFEBABE }; + float masterTuneFactor { 1.0f }; + float pitchCompOffsetCents { 0.0f }; + float pitchCompSlopeCents { 0.0f }; + std::array noteOffsetsCents { { 0.0f } }; +}; + +//=========================== Shared Bus for coupling/sympathetic ============== +// FIX #1: Moved from thread_local to regular shared structure +// FIX #4: Added block-relative indexing support +struct SharedBus +{ + void begin (int n) + { + if ((int) left.size() < n) + { + left.assign ((size_t) n, 0.0f); + right.assign ((size_t) n, 0.0f); + } + else + { + std::fill (left.begin(), left.begin() + n, 0.0f); + std::fill (right.begin(), right.begin() + n, 0.0f); + } + size = n; + } + + void add (int idx, float l, float r) + { + if (idx < 0 || idx >= size) return; + left[(size_t) idx] += l; + right[(size_t) idx] += r; + } + + std::pair read (int idx) const + { + if (idx < 0 || idx >= size) return { 0.0f, 0.0f }; + return { left[(size_t) idx], right[(size_t) idx] }; + } + + int size { 0 }; + std::vector left, right; +}; + +//=========================== pm2 stiff-string (multi-string) ================== +class Pm2StringBank +{ +public: + void prepare (double sr, int blockSize, int numCh); + void setMinNoteDurationSamples (int samples) { minNoteDurationSamples = juce::jmax (0, samples); } + void setEconomyMode (bool enabled) { economyMode = enabled; } + void setPolyphonyScale (float s) { polyphonyScale = juce::jlimit (0.2f, 1.0f, s); } + void setParams (const PresetModel::PmString& params); + void setHammerParams (const PresetModel::HammerModel& h); + void setFeltParams (const PresetModel::FeltModel& f); + void setDuplexParams (const PresetModel::Duplex& d); + void setWdfParams (const PresetModel::WdfModel& wdf); + void setSoftPedal (bool down, const PresetModel::UnaCorda& una); + void setSustainPedalDown (bool down); + void setDamperParams (const PresetModel::Damper& d); + void setDamperLift (float lift); + void beginVoiceStealFade (float ms); + void setEnvParams (float attack, float decay, float sustain, float release); + void setReleaseScale (float baseRelease, float scale); + void setReleaseExtension (float ext) + { + if (DebugToggles::kEnableReleaseExtension) + releaseExtension = juce::jlimit (1.0f, 2.5f, ext); + else + releaseExtension = 1.0f; + } + void setHighPolyMode (bool enabled) { highPolyMode = enabled; } + void setPitchComp (float offsetCents, float slopeCentsPerNote) + { + pitchCompOffsetCents = offsetCents; + pitchCompSlopeCents = slopeCentsPerNote; + } + void setNoteOffsets (const std::array& offsets) { noteOffsetsCents = offsets; } + void setCouplingParams (const PresetModel::Coupling& c); + void updateNoteHpf (int midiNoteNumber); + void setLoudnessSlope (float dbPerSemi) { loudnessSlopeDbPerSemi = dbPerSemi; } + void setMasterTuneFactor (float f) { masterTuneFactor = f; } + void setDispersionCurve (const PresetModel::DispersionCurve& d) + { + dispersionHighMult = d.highMult; + dispersionPow = d.pow; + } + void setLowVelSkip (bool skip) { lowVelSkip = skip; } + + // FIX #1 & #4: Pass shared buses from processor, use block-relative indexing + void setSharedBuses (SharedBus* coupling, SharedBus* symp) + { + couplingBus = coupling; + sympBus = symp; + } + + void noteOn (int midiNoteNumber, float velocity); + void hardRetrigger (int midiNoteNumber, float velocity); + void noteOff(); + // FIX #4: Added startSampleInBlock parameter for block-relative bus indexing + void render (juce::AudioBuffer& buffer, int startSample, int numSamples, int startSampleInBlock); + // FIX #3: Modified to respect steal fade in progress + void forceSilence(); + // FIX #3: Mark that we're about to steal this voice + void markForSteal() { stealInProgress = true; } + bool isActive() const { return active || adsr.isActive(); } + float getEnvLevel() const { return lastEnv; } + bool isKeyHeld() const { return keyHeld; } + bool isSustainPedalDown() const { return sustainPedalDown; } + float getLoopEnergy() const { return loopEnergySmoothed; } + int getCurrentMidiNote() const { return currentMidiNote; } + + // Voice stealing: track note age (higher = newer) + void setNoteAge (uint64_t age) { noteAge = age; } + uint64_t getNoteAge() const { return noteAge; } + +private: + void applyNoteOffInternal(); + void resetForHardRetrigger(); + struct AP1 + { + float z1 { 0.0f }, g { 0.0f }; + float process (float x) + { + float y = -g * x + z1; + z1 = x + g * y; + return y; + } + }; + + struct DCBlock + { + void reset (double sr) { a = (float) (1.0 - 2.0 * juce::MathConstants::pi * 5.0 / sr); x1 = y1 = 0.0f; } + float process (float x) { float y = x - x1 + a * y1; x1 = x; y1 = y; return y; } + float a{0.995f}, x1{0}, y1{0}; + }; + + struct DuplexState + { + std::vector buf; + int write { 0 }; + float feedback { 0.5f }; + float gain { 0.1f }; + float inputGain { 0.15f }; + }; + + struct StringState + { + std::vector delay; + int writePos { 0 }; + double delaySamples { 0.0 }; + float loopGain { 0.999f }; + float baseGain { 1.0f }; + float panGainL { 0.7071f }; + float panGainR { 0.7071f }; + std::array ap; + int apStages { 1 }; + DCBlock dc; + DuplexState duplex; + float loopGainSmoothed { 0.999f }; + float damperLossPrev { 1.0f }; + float damperLossSmoothed { 1.0f }; + int damperSoftenCountdown { 0 }; + float damperSoftenState { 0.0f }; + float loopGainBase { 0.999f }; + float loopGainRelease { 0.999f }; + float feltState { 0.0f }; + float feltLastOut { 0.0f }; + float feltEnvPrev { 0.0f }; + float lpState { 0.0f }; + float lpCoeff { 0.25f }; + // Thiran allpass interpolator state (for fractional delay) + float interpAlpha { 0.0f }; // coefficient: (1-d)/(1+d) where d is fractional delay + float interpZ1 { 0.0f }; // filter state + int toneInjectSamplesLeft { 0 }; + float toneInjectPhase { 0.0f }; + float toneInjectPhaseDelta { 0.0f }; + float toneInjectGain { 0.0f }; + // Option 1: Energy limiter state - captures peak after attack, soft-limits to prevent swell + float energyPeak { 0.0f }; // Reference peak energy captured during calibration + float energySmoothed { 0.0f }; // Smoothed current energy for limiter + float energyGainSmoothed { 1.0f }; // Smoothed limiter gain to avoid abrupt steps + int energyCalibSamplesLeft { 0 }; // Samples remaining in calibration window + bool energyCalibComplete { false }; // True once reference is captured + struct HammerState + { + bool active { false }; + int samplesLeft { 0 }; + int samplesTotal { 0 }; + int samplesElapsed { 0 }; + float pos { 0.0f }; + float vel { 0.0f }; + float pen { 0.0f }; + float mass { 0.028f }; + float k { 2600.0f }; + float exp { 2.4f }; + float damping { 6.0f }; + float preload { 0.08f }; + float maxPen { 0.010f }; + float toneAlpha { 0.0f }; + float toneState { 0.0f }; + float gain { 0.0020f }; + float gainSmoothed { 0.0f }; + bool simplified { false }; + } hammer; + + // Fundamental resonator state - boosts fundamental frequency content + // Compensates for physical modeling's tendency to emphasize upper partials + float fundResonatorState1 { 0.0f }; + float fundResonatorState2 { 0.0f }; + float fundResonatorCoeff { 0.0f }; + float fundResonatorGain { 0.0f }; + }; + + // FIX #5: Modified to not reset DC blocker + void resizeString (StringState& s, double samples); + float randomUniform(); + + double sampleRate { 44100.0 }; + bool active { false }; + PresetModel::PmString params; + PresetModel::HammerModel hammer; + PresetModel::FeltModel felt; + PresetModel::Duplex duplex; + PresetModel::Damper damper; + PresetModel::WdfModel wdf; + PresetModel::UnaCorda unaCorda; + bool softPedalDown { false }; + bool sustainPedalDown { false }; + bool lowVelSkip { false }; + bool keyHeld { false }; + bool useReleaseLoopGain { false }; + int releaseDelaySamples { 0 }; + int noteLifeSamples { 0 }; + int minNoteDurationSamples { 0 }; + int minNoteOffRemaining { 0 }; + bool pendingNoteOff { false }; + int damperDelaySamples { 0 }; + int pedalChangeSamplesTotal { 0 }; + int pedalChangeSamplesRemaining { 0 }; + float pedalChangeFade { 1.0f }; + float damperLiftTarget { 1.0f }; + float damperLiftSmoothed { 1.0f }; + float damperLiftSmoothCoeff { 0.05f }; + int stealFadeSamples { 0 }; + int stealFadeRemaining { 0 }; + // FIX #3: Track if voice steal is in progress + bool stealInProgress { false }; + int keyReleaseSamplesTotal { 0 }; + int keyReleaseSamplesRemaining { 0 }; + int keyOffFadeSamplesTotal { 0 }; + int keyOffFadeSamplesRemaining { 0 }; + float damperSmoothCoeff { 0.05f }; + int damperSoftenSamples { 0 }; + float damperSoftenA { 0.0f }; + float releaseExtension { 1.0f }; + bool highPolyMode { false }; + std::array strings; + juce::ADSR adsr; + juce::ADSR::Parameters envParams { 0.001f, 0.75f, 0.0f, 0.45f }; // no enforced 8 ms ramp; follow GUI/env settings + float baseRelease { 0.45f }; + float decayTimeScale { 1.0f }; // Multiplier for physical T60 based on GUI decay + int currentMidiNote { -1 }; + bool economyMode { false }; + float lastEnv { 0.0f }; + uint32_t rng { 0x1234BEEF }; + + // Pink noise state for stochastic excitation (Voss-McCartney approximation) + // Creates more realistic spectral density between harmonics + std::array pinkNoiseState { { 0.0f, 0.0f, 0.0f } }; + int pinkNoiseCounter { 0 }; + + // Body resonance noise generator - fills gaps between harmonics + // Simulates soundboard and cabinet broadband resonance + float bodyNoiseState { 0.0f }; + float bodyNoiseLp1 { 0.0f }; + float bodyNoiseLp2 { 0.0f }; + float bodyNoiseHp { 0.0f }; + uint32_t bodyNoiseRng { 0x12345678 }; + + float masterTuneFactor { 1.0f }; + float pitchLoudnessGain { 1.0f }; + float pitchCompSamples { 0.0f }; + float pitchCompBase { 0.0f }; + float pitchCompPerHz { 0.0f }; + float pitchCompMax { 0.0f }; + float pitchCompOffsetCents { 0.0f }; + float pitchCompSlopeCents { 0.0f }; + std::array noteOffsetsCents { { 0.0f } }; + float velocityGain { 1.0f }; + // FIXED: Changed from +0.04 to -0.12 for realistic piano loudness curve + float loudnessSlopeDbPerSemi { -0.12f }; + int currentNumStrings { 3 }; + + // CPU OPTIMIZATION: Third string gain scale for gradual bass fade (G2 down to C2) + // 1.0 = full third string, 0.0 = muted third string + float thirdStringGainScale { 1.0f }; + + float stereoWidth { 1.0f }; + // FIXED: Normalization factor for multi-string summing to prevent level buildup + float stringGainNorm { 1.0f }; + float loopEnergySmoothed { 0.0f }; + float loopEnergySmoothCoeff { 0.0f }; + float polyphonyScale { 1.0f }; + // Frequency-dependent loop loss (scalar, no filter in loop) + float freqLossScalar { 1.0f }; + // Post-loop LPF with ADSR (per voice) + juce::ADSR postLpfEnv; + juce::ADSR::Parameters postLpfEnvParams { 0.002f, 0.18f, 0.21f, 0.12f };//Pm2PostLpfEnv envelope values + float postLpfMinHz { 500.0f }; + float postLpfMaxHz { 6000.0f }; + float postLpfStateL { 0.0f }; + float postLpfStateR { 0.0f }; + // Note-dependent HPF to keep high notes free of sub-bass + juce::dsp::StateVariableTPTFilter noteHpf; + float noteHpfCutoff { 30.0f }; + int noteHpfNumChannels { 0 }; + // Sympathetic/coupling filters + juce::dsp::StateVariableTPTFilter couplingBpL, couplingBpR; + juce::dsp::StateVariableTPTFilter sympBpL, sympBpR; + float couplingGain { 0.02f }; + float couplingQ { 0.7f }; + float sympGain { 0.05f }; + float sympHighDamp { 0.5f }; + float dispersionHighMult { 1.0f }; + float dispersionPow { 1.0f }; + int noteFadeSamplesRemaining { 0 }; + int noteFadeSamplesTotal { 0 }; + + // FIX #1: Pointers to shared buses owned by processor + SharedBus* couplingBus { nullptr }; + SharedBus* sympBus { nullptr }; + + // Voice stealing: track note age (higher = newer, 0 = no note) + uint64_t noteAge { 0 }; + + // Option 5: Anti-swell envelope - slow decay for high notes to counter energy accumulation + float antiSwellEnv { 1.0f }; // Current envelope value (starts at 1.0, slowly decays) + float antiSwellDecayPerSample { 0.0f }; // Decay rate (pitch-dependent) + float lastOutL { 0.0f }; + float lastOutR { 0.0f }; +}; + +//=========================== PM/PM2 polyphonic voices ======================== +struct PmSound : public juce::SynthesiserSound +{ + bool appliesToNote (int) override { return true; } + bool appliesToChannel (int) override { return true; } +}; + +class PmVoice : public juce::SynthesiserVoice +{ +public: + void prepare (double sr, int blockSize, int numCh) { pm.prepare (sr, blockSize, numCh); } + void setEnvParams (float a, float d, float s, float r) { pm.setEnvParams (a, d, s, r); } + void setReleaseScale (float baseR, float scale) { baseRelease = baseR; releaseScale = scale; } + void setPitchComp (float offsetCents, float slopeCentsPerNote) { pm.setPitchComp (offsetCents, slopeCentsPerNote); } + void setNoteOffsets (const std::array& offsets) { pm.setNoteOffsets (offsets); } + void setMasterTune (float f) { tuneFactor = f; pm.setMasterTuneFactor (f); } + + bool canPlaySound (juce::SynthesiserSound* s) override { return dynamic_cast (s) != nullptr; } + void startNote (int midiNoteNumber, float velocity, juce::SynthesiserSound*, int) override + { + pm.setReleaseScale (baseRelease, releaseScale); + pm.setMasterTuneFactor (tuneFactor); + pm.noteOn (midiNoteNumber, velocity); + } + void stopNote (float, bool allowTailOff) override + { + pm.setReleaseScale (baseRelease, releaseScale); + pm.noteOff(); + if (! allowTailOff) + clearCurrentNote(); + } + void pitchWheelMoved (int) override {} + void controllerMoved (int, int) override {} + void renderNextBlock (juce::AudioBuffer& buffer, int startSample, int numSamples) override + { + pm.render (buffer, startSample, numSamples); + if (! pm.isActive()) + clearCurrentNote(); + } + +private: + WaveguideFlute pm; + float baseRelease { 0.25f }; + float releaseScale { 1.0f }; + float tuneFactor { 1.0f }; +}; + +struct Pm2Sound : public juce::SynthesiserSound +{ + bool appliesToNote (int) override { return true; } + bool appliesToChannel (int) override { return true; } +}; + +class Pm2Voice : public juce::SynthesiserVoice +{ +public: + void prepare (double sr, int blockSize, int numCh) { pm2.prepare (sr, blockSize, numCh); } + void setMinNoteDurationSamples (int samples) { pm2.setMinNoteDurationSamples (samples); } + void setMinNoteDurationRanges (int lowSamples, int midLowSamples, int midHighSamples, int highSamples, + int split1, int split2, int split3) + { + minDurLow = lowSamples; + minDurMidLow = midLowSamples; + minDurMidHigh = midHighSamples; + minDurHigh = highSamples; + minSplit1 = split1; + minSplit2 = split2; + minSplit3 = split3; + } + void setParams (const PresetModel::PmString& params) { pm2.setParams (params); } + void setHammerParams (const PresetModel::HammerModel& h) { pm2.setHammerParams (h); } + void setFeltParams (const PresetModel::FeltModel& f) { pm2.setFeltParams (f); } + void setDuplexParams (const PresetModel::Duplex& d) { pm2.setDuplexParams (d); } + void setWdfParams (const PresetModel::WdfModel& wdf) { pm2.setWdfParams (wdf); } + void setDamperParams (const PresetModel::Damper& d) { pm2.setDamperParams (d); } + void setDamperLift (float lift) { pm2.setDamperLift (lift); } + void setSoftPedal (bool down, const PresetModel::UnaCorda& una) { pm2.setSoftPedal (down, una); } + void setEnvParams (float a, float d, float s, float r) { pm2.setEnvParams (a, d, s, r); } + void setReleaseScale (float baseR, float scale) { baseRelease = baseR; releaseScale = scale; pm2.setReleaseScale (baseR, scale); } + void setReleaseExtension (float ext) { pm2.setReleaseExtension (ext); } + void setPitchComp (float offsetCents, float slopeCentsPerNote) { pm2.setPitchComp (offsetCents, slopeCentsPerNote); } + void setNoteOffsets (const std::array& offsets) { pm2.setNoteOffsets (offsets); } + void setLoudnessSlope (float dbPerSemi) { pm2.setLoudnessSlope (dbPerSemi); } + void setMasterTune (float f) { tuneFactor = f; pm2.setMasterTuneFactor (f); } + void setSustainPedalDown (bool down) { pm2.setSustainPedalDown (down); } + void setEconomyMode (bool enabled) { pm2.setEconomyMode (enabled); } + void setPolyphonyScale (float s) { pm2.setPolyphonyScale (s); } + void setHighPolyMode (bool enabled) { pm2.setHighPolyMode (enabled); } + void setCouplingParams (const PresetModel::Coupling& c) { pm2.setCouplingParams (c); } + void setDispersionCurve (const PresetModel::DispersionCurve& d) { pm2.setDispersionCurve (d); } + + // FIX #1: Pass shared buses to the string bank + void setSharedBuses (SharedBus* coupling, SharedBus* symp) { pm2.setSharedBuses (coupling, symp); } + + // Voice stealing: set pointer to note age counter (owned by Pm2Synth) + void setNoteAgeCounter (uint64_t* counter) { noteAgeCounter = counter; } + + bool canPlaySound (juce::SynthesiserSound* s) override { return dynamic_cast (s) != nullptr; } + void startNote (int midiNoteNumber, float velocity, juce::SynthesiserSound*, int) override + { + // Assign note age from counter when voice starts (newest notes have highest age) + if (noteAgeCounter != nullptr) + pm2.setNoteAge (++(*noteAgeCounter)); + const int minSamples = (midiNoteNumber < minSplit1) ? minDurLow + : (midiNoteNumber < minSplit2) ? minDurMidLow + : (midiNoteNumber < minSplit3) ? minDurMidHigh + : minDurHigh; + pm2.setMinNoteDurationSamples (minSamples); + + pm2.setReleaseScale (baseRelease, releaseScale); + pm2.setMasterTuneFactor (tuneFactor); + pm2.noteOn (midiNoteNumber, velocity); + } + void hardRetrigger (int midiNoteNumber, float velocity) + { + if (noteAgeCounter != nullptr) + pm2.setNoteAge (++(*noteAgeCounter)); + pm2.setReleaseScale (baseRelease, releaseScale); + pm2.setMasterTuneFactor (tuneFactor); + pm2.hardRetrigger (midiNoteNumber, velocity); + } + // FIX #3: Mark for steal and start a short fade to avoid hard truncation + void prepareForSteal() + { + pm2.markForSteal(); + pm2.beginVoiceStealFade (20.0f); + } + void stopNote (float, bool allowTailOff) override + { + pm2.setReleaseScale (baseRelease, releaseScale); + pm2.noteOff(); + if (! allowTailOff) + clearCurrentNote(); + } + void pitchWheelMoved (int) override {} + void controllerMoved (int, int) override {} + // FIX #4: Pass block-relative start sample + void renderNextBlock (juce::AudioBuffer& buffer, int startSample, int numSamples) override + { + renderWithBlockOffset (buffer, startSample, numSamples, startSample); + } + // New method that accepts block offset for proper bus indexing + void renderWithBlockOffset (juce::AudioBuffer& buffer, int startSample, int numSamples, int startSampleInBlock) + { + pm2.render (buffer, startSample, numSamples, startSampleInBlock); + if (! pm2.isActive()) + clearCurrentNote(); + } + + bool isActive() const { return pm2.isActive(); } + float getEnvLevel() const { return pm2.getEnvLevel(); } + bool isKeyHeld() const { return pm2.isKeyHeld(); } + bool isSustainPedalDown() const { return pm2.isSustainPedalDown(); } + float getLoopEnergy() const { return pm2.getLoopEnergy(); } + int getCurrentMidiNote() const { return pm2.getCurrentMidiNote(); } + + // Voice stealing: track note age + void setNoteAge (uint64_t age) { pm2.setNoteAge (age); } + uint64_t getNoteAge() const { return pm2.getNoteAge(); } + +private: + Pm2StringBank pm2; + float baseRelease { 0.25f }; + float releaseScale { 1.0f }; + float tuneFactor { 1.0f }; + uint64_t* noteAgeCounter { nullptr }; // Pointer to shared counter for voice stealing + int minDurLow { 0 }; + int minDurMidLow { 0 }; + int minDurMidHigh { 0 }; + int minDurHigh { 0 }; + int minSplit1 { 60 }; + int minSplit2 { 84 }; + int minSplit3 { 96 }; +}; + +// Custom pm2 synthesiser with smarter voice steal +class Pm2Synth : public juce::Synthesiser +{ +public: + // Manual pre-steal hook called by processor before forwarding note-ons + void preallocateVoiceForNote (int midiNoteNumber); + // FIX: New method to preallocate multiple voices for chords + void preallocateVoicesForChord (int numNotesNeeded); + bool hasActiveVoiceForNote (int midiNoteNumber) const; + bool hardRetriggerActiveVoice (int midiNoteNumber, float velocity); + void requestDeclick (int samples) { pendingDeclickSamples.store (juce::jmax (samples, pendingDeclickSamples.load())); } + void requestDeclickOut (int samples) { pendingDeclickOutSamples.store (juce::jmax (samples, pendingDeclickOutSamples.load())); } + int consumeDeclickSamples() { return pendingDeclickSamples.exchange (0); } + int consumeDeclickOutSamples() { return pendingDeclickOutSamples.exchange (0); } + + // FIX #1: Set shared buses on all voices + // Also sets up note age counter for voice stealing + void setSharedBuses (SharedBus* coupling, SharedBus* symp) + { + for (int i = 0; i < getNumVoices(); ++i) + if (auto* v = dynamic_cast (getVoice (i))) + { + v->setSharedBuses (coupling, symp); + v->setNoteAgeCounter (¬eCounter); + } + } + void setMinNoteDurationSamples (int samples) + { + for (int i = 0; i < getNumVoices(); ++i) + if (auto* v = dynamic_cast (getVoice (i))) + v->setMinNoteDurationSamples (samples); + } + void setMinNoteDurationRanges (int lowSamples, int midLowSamples, int midHighSamples, int highSamples, + int split1, int split2, int split3) + { + for (int i = 0; i < getNumVoices(); ++i) + if (auto* v = dynamic_cast (getVoice (i))) + v->setMinNoteDurationRanges (lowSamples, midLowSamples, midHighSamples, highSamples, + split1, split2, split3); + } + +private: + std::atomic pendingDeclickSamples { 0 }; + std::atomic pendingDeclickOutSamples { 0 }; + uint64_t noteCounter { 0 }; // Monotonically increasing counter for note age tracking +}; + +//=========================== Main Processor =================================== +class FluteSynthAudioProcessor : public juce::AudioProcessor +{ +public: + FluteSynthAudioProcessor(); + ~FluteSynthAudioProcessor() override = default; + + // AudioProcessor + void prepareToPlay (double, int) override; + void releaseResources() override; + #ifndef JucePlugin_PreferredChannelConfigurations + bool isBusesLayoutSupported (const BusesLayout& layouts) const override; + #endif + void processBlock (juce::AudioBuffer&, juce::MidiBuffer&) override; + + // UI + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override { return true; } + + // Misc + const juce::String getName() const override { return "FluteSynth"; } + bool acceptsMidi() const override { return true; } + bool producesMidi() const override { return false; } + // Report a non-zero tail so hosts render releases/resonance instead of truncating early. + double getTailLengthSeconds() const override { return 12.0; } + + 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&) override; + void setStateInformation (const void*, int) override; + + // Parameters + using APVTS = juce::AudioProcessorValueTreeState; + APVTS& getAPVTS() { return apvts; } + static APVTS::ParameterLayout createParameterLayout(); + + // JSON preset loader for headless/CLI + bool loadPresetFromJson (const juce::File& file); + // Reset to embedded preset (used by GUI reset button) + void resetToEmbeddedPreset(); + // Schedule preset apply on the audio thread (avoids GUI/audio races) + void requestEmbeddedPresetApply(); + void requestEmbeddedPresetApply (int index); + juce::StringArray getEmbeddedPresetNames() const; + int getActiveEmbeddedPresetIndex() const; + void selectEmbeddedPreset (int index); + bool consumePendingPresetUiSync(); + // Test/debug: override WDF parameters without touching the preset JSON + void setWdfForTest (const PresetModel::WdfModel& wdfModel); + +private: + bool loadEmbeddedPreset(); + bool loadEmbeddedPresetModel(); + // --- Helpers for JSON --- + static bool hasProp (const juce::DynamicObject&, const juce::Identifier&); + static float getFloatProp (const juce::DynamicObject&, const juce::Identifier&, float def); + static bool getBoolProp (const juce::DynamicObject&, const juce::Identifier&, bool def); + static juce::String getStringProp (const juce::DynamicObject&, const juce::Identifier&, const juce::String& def); + + PresetModel parsePresetJson (const juce::var& v); + PresetModel buildPhysicsPresetModel() const; + void applyPresetToParameters (const PresetModel& p); + void updatePostFiltersForNote (int midiNote); + void updateOutputLpf(); + void updateOutputEq(); + void updateDamperCoeffs(); + void applyMasterTuneToVoices(); + void syncExtendedParamsFromAPVTS(); + void updateMicProcessors(); + void applyMicMix (juce::AudioBuffer& buffer); + void prepareBrightnessFilters(); + void updateBrightnessFilters (float targetDb); + void updatePostFiltersSmoothed(); + bool anyVoiceActive() const; + void updateSoundboardConvolution (bool force); + juce::AudioBuffer buildSoundboardIr (double sampleRate, float t60Sec, float damp) const; + + // --- Processor state --- + APVTS apvts; + juce::Synthesiser synth; // VA voices + juce::Synthesiser pmSynth; // PM voices + Pm2Synth pm2Synth; // PM2 voices + juce::String currentEngine { "va" }; + bool pm2EconomyMode { true }; // lighter path to reduce CPU + + // Hybrid mixing + float vaMix { 1.0f }; + float pmMix { 0.0f }; + + // Harmonic shaper (tanh) — applied post (shared) + bool shaperEnabled { true }; + float shaperDrive { 0.15f }; + + // Breath noise chain (shared) + bool breathEnabled { true }; + float breathGainLin { 0.0f }; + float breathBpFreqStored { 5500.0f }; + float breathBpQStored { 1.0f }; + juce::dsp::StateVariableTPTFilter breathBp; + + // Two simple formant BPs with makeup gain (shared) + struct BP { juce::dsp::StateVariableTPTFilter f; float gainLin{1.0f}; bool enabled{false}; }; + BP formant[2]; + + // pm2 scaffolding: parsed state only (DSP to be implemented in Phase 2) + PresetModel::PmString pmString {}; + PresetModel::HammerModel pmHammer {}; + PresetModel::FeltModel pmFelt {}; + juce::Array pmBoardModes; + float pmBoardSend { 0.35f }; + float pmBoardMix { 0.30f }; + PresetModel::PmFilter pmToneFilter {}; + float pmTiltDb { 0.0f }; + float pmPredelayMs { 5.0f }; + float pm2Mix { 0.0f }; + PresetModel::Pedal pedalCfg {}; + PresetModel::Damper damperCfg {}; + PresetModel::UnaCorda unaCfg {}; + PresetModel::Duplex duplexCfg {}; + PresetModel::DispersionCurve dispersionCfg {}; + // FIXED: Changed from +12dB to 0dB to prevent overwhelming output levels + float pm2GainDb { 0.0f }; + float pm2GainLin { juce::Decibels::decibelsToGain (pm2GainDb) }; + PresetModel::WdfModel wdfCfg {}; + PresetModel::Coupling couplingCfg {}; + float releaseExtension { 1.0f }; + float pitchCompOffsetCents { 0.0f }; + float pitchCompSlopeCents { 0.0f }; + std::array noteOffsetsCents { { 0.0f } }; + std::array presetNoteOffsetsCents { { 0.0f } }; + // FIXED: Changed from +0.04 to -0.12 for realistic piano loudness curve + float pm2LoudnessSlopeDbPerSemi { -0.03f }; + struct EmbeddedPreset { juce::String name; PresetModel model; }; + std::vector embeddedPresets; + std::atomic embeddedPresetLoaded { false }; + std::atomic activeEmbeddedPresetIndex { 0 }; + std::atomic pendingEmbeddedPresetIndex { -1 }; + std::atomic presetUiSyncPending { false }; + + // Pedal state + bool sustainPedalDown { false }; + float sustainValue { 0.0f }; + bool softPedalDown { false }; + float baseRelease { 0.25f }; + float halfReleaseScale { 1.6f }; + float damperLift { 1.0f }; + float damperSmoothCoeff { 0.05f }; + int damperSoftenSamples { 0 }; + float damperSoftenA { 0.0f }; + juce::Reverb pedalReverb; + juce::Reverb::Parameters pedalReverbParams {}; + juce::Reverb::Parameters pedalReverbParamsApplied {}; + bool pedalReverbParamsValid { false }; + juce::Reverb sympReverb; + juce::Reverb::Parameters sympParams {}; + juce::Reverb::Parameters sympParamsApplied {}; + bool sympParamsValid { false }; + // Shared send HPFs to keep sends/returns free of rumble + using IirHpf = juce::dsp::ProcessorDuplicator, + juce::dsp::IIR::Coefficients>; + IirHpf pedalSendHpf; + IirHpf sympSendHpf; + IirHpf soundboardSendHpf; + IirHpf soundboardReturnHpf; + juce::dsp::IIR::Filter modalSendHpf; + float sendHpfCutoff { 85.0f }; + int sendHpfNumChannels { 0 }; + float soundboardReturnHpfCutoff { 140.0f }; + + // Per-engine post LPFs (key-tracked) + shared tilt + juce::dsp::StateVariableTPTFilter postVaLp1, postVaLp2; + juce::dsp::StateVariableTPTFilter postPmLp1, postPmLp2; + juce::dsp::StateVariableTPTFilter postPm2Lp1, postPm2Lp2; + juce::dsp::IIR::Filter tiltLow, tiltHigh; + float postCutoffHz { 4500.0f }; + float postQ { 0.8f }; + float postKeytrack { 0.5f }; + float postTiltDb { 0.0f }; + int lastMidiNote { 60 }; + float masterVolumeLin { 1.0f }; + float outputGainLin { 1.0f }; + + // Hammer transient (shared, triggered on note-on) + bool hammerEnabled { false }; + float hammerLevel { 0.25f }; + float hammerNoise { 0.40f }; + float hammerEnv { 0.0f }; + float hammerDecayCoeff{ 0.999f }; + float hammerDecaySec { 0.015f }; // Store decay time for recalculation after prepareToPlay + bool hammerActive { false }; + juce::dsp::StateVariableTPTFilter hammerHP; + uint32_t hammerRng { 0xA5A5F00D }; + float hammerHpHz { 1800.0f }; + + // Action / mechanical noises + bool keyOffEnabled { false }; + bool keyOffVelScale { true }; + float keyOffLevel { 0.05f }; + float keyOffEnv { 0.0f }; + float keyOffDecayCoeff { 0.999f }; + float keyOffDecaySec { 0.03f }; + float keyOffHpHz { 1200.0f }; + juce::dsp::StateVariableTPTFilter keyOffHP; + + bool pedalThumpEnabled { false }; + float pedalThumpLevel { 0.08f }; + float pedalThumpEnv { 0.0f }; + float pedalThumpDecayCoeff { 0.999f }; + float pedalThumpDecaySec { 0.05f }; + float pedalThumpLpHz { 600.0f }; + juce::dsp::StateVariableTPTFilter pedalThumpLP; + + bool releaseThumpEnabled{ false }; + float releaseThumpLevel { 0.10f }; + float releaseThumpEnv { 0.0f }; + float releaseThumpDecayCoeff { 0.999f }; + float releaseThumpDecaySec { 0.06f }; + float releaseThumpLpHz { 450.0f }; + float releaseThudMix { 0.5f }; + float releaseThudHpHz { 70.0f }; + juce::dsp::StateVariableTPTFilter releaseThumpLP; + juce::dsp::StateVariableTPTFilter releaseThudHP; + + // Soundboard (shared) + bool soundboardEnabled { false }; + float soundboardMix { 0.20f }; + juce::Reverb::Parameters soundboardParams {}; + juce::Reverb::Parameters soundboardParamsApplied {}; + bool soundboardParamsValid { false }; + juce::Reverb soundboardReverb; + juce::dsp::Convolution soundboardConvolution; + juce::dsp::Convolution soundboardConvolutionDs; + bool soundboardIrDirty { true }; + float soundboardT60Sec { 2.0f }; + float soundboardDampParam { 0.35f }; + float soundboardIrLastT60 { 0.0f }; + float soundboardIrLastDamp { 0.0f }; + struct ModalMode + { + juce::dsp::IIR::Filter bp[2]; + float gainLin { 1.0f }; + }; + std::vector modalModes; + int modalChannels { 2 }; + bool modalDirty { true }; + std::vector predelayBuf; + int predelayWrite { 0 }; + int predelaySamples { 0 }; + int predelayCapacitySamples { 0 }; + juce::dsp::ProcessSpec mainSpec{}; + // Scratch buffers to avoid per-block allocations + juce::AudioBuffer hybridVaBuf, hybridPmBuf, hybridPm2Buf; + juce::AudioBuffer micScratch; + juce::AudioBuffer breathScratch; + juce::AudioBuffer formantScratch; + juce::AudioBuffer pedalScratch; + juce::AudioBuffer sympScratch; + juce::AudioBuffer modalScratch; + juce::AudioBuffer soundboardScratch; + juce::AudioBuffer soundboardScratchDs; + juce::Reverb postReverb; + juce::Reverb::Parameters postReverbParamsApplied {}; + bool postReverbParamsValid { false }; + float postRoomMix { 1.0f }; + bool postRoomEnabled { true }; + + // Final output LPF (post everything) + juce::dsp::StateVariableTPTFilter outputLpf; + bool outputLpfEnabled { false }; + float outputLpfCutoff { 18000.0f }; + float outputLpfQ { 0.707f }; + bool outputEqEnabled { false }; + PresetModel::OutputEq outputEqCfg {}; + std::array, 5> outputEqFilters; + int outputEqNumChannels { 0 }; + juce::SmoothedValue pm2GainLinSmoothed; + juce::SmoothedValue outputGainLinSmoothed; + juce::SmoothedValue postCutoffHzSmoothed; + juce::SmoothedValue postQSmoothed; + juce::SmoothedValue postTiltDbSmoothed; + juce::SmoothedValue outputLpfCutoffSmoothed; + juce::SmoothedValue outputLpfQSmoothed; + juce::SmoothedValue brightnessDbSmoothed; + juce::SmoothedValue sustainGainLinSmoothed; + juce::SmoothedValue sustainReleaseScaleSmoothed; + juce::SmoothedValue sustainValueSmoothed; + bool pendingStateReset { false }; + struct OutputDcBlock + { + void reset (double sr) + { + a = (float) (1.0 - 2.0 * juce::MathConstants::pi * 5.0 / sr); + x1 = 0.0f; + y1 = 0.0f; + } + float process (float x) + { + float y = x - x1 + a * y1; + x1 = x; + y1 = y; + return y; + } + float a { 0.995f }; + float x1 { 0.0f }; + float y1 { 0.0f }; + }; + std::array outputDcBlock; + int outputDcNumChannels { 0 }; + // Final output HPF (post everything) to remove sub-bass/rumble + IirHpf outputHpf; + float outputHpfCutoff { 120.0f };//the point below which the global high-pass removes frequencies + int outputLpfNumChannels { 0 }; + int outputHpfNumChannels { 0 }; + // Lookahead final limiter (post chain) + juce::AudioBuffer limiterDelayBuffer; + int limiterDelayBufferSize { 0 }; + int limiterWritePos { 0 }; + int limiterLookaheadSamples { 0 }; + float limiterGain { 1.0f }; + float limiterAttackCoeff { 0.0f }; + float limiterReleaseCoeff { 0.0f }; + float limiterThreshold { 0.98f }; + float limiterLookaheadMs { 2.0f }; + float limiterAttackMs { 1.0f }; + float limiterReleaseMs { 60.0f }; + // Final brightness shelf (velocity-driven) + bool brightnessEnabled { false }; + float brightnessBaseDb { 0.0f }; + float brightnessVelSlopeDb { 4.0f }; + float brightnessNoteSlopeDb { 0.0f }; + float brightnessMaxDb { 6.0f }; + float brightnessCutoffHz { 3200.0f }; + float brightnessQ { 0.707f }; + float brightnessCurrentDb { 0.0f }; + int brightnessNumChannels { 0 }; + float lastVelocityNorm { 0.8f }; + std::vector> brightnessFilters; + + // Velocity curve shaping + float velocityGammaBase { 1.0f }; + float velocityGamma { 1.0f }; + juce::String velocityCurveName { "linear" }; + bool velocityFixed { false }; + + // Mic mixer (post chain) + struct MicState + { + juce::dsp::DelayLine delay[2]; + juce::dsp::IIR::Filter lowShelf[2]; + juce::dsp::IIR::Filter highShelf[2]; + float delaySamples { 0.0f }; + float gainLin { 1.0f }; + }; + PresetModel::Mics micCfg {}; + std::array micState {}; + juce::dsp::ProcessSpec micSpec {}; + int micMaxDelaySamples { 0 }; + bool micReady { false }; + + double lastSampleRate { 0.0 }; + bool prepared { false }; + int tiltNumChannels { 0 }; + bool tiltReady { false }; + + // Master tuning applied internally (no schema change) + float masterTuneCents { 0.0f }; + float masterTuneFactor { 1.0f }; + + // FIX #1: Shared buses owned by processor (not thread_local) + SharedBus couplingBus; + SharedBus sympBus; + + // FIX #2: Smoothed polyphony compensation to prevent gain jumps + float polyCompSmoothed { 1.0f }; + float polyCompTarget { 1.0f }; + static constexpr float polyCompSmoothCoeff { 0.002f }; // ~5ms smoothing at 44.1kHz + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (FluteSynthAudioProcessor) +}; diff --git a/preset.json b/preset.json new file mode 100644 index 0000000..00992df --- /dev/null +++ b/preset.json @@ -0,0 +1,1102 @@ +{ + "schema_version": 4, + "presets": [ + { + "name": "Classic Grand", + "schema_version": 4, + "engine": "pm2", + "osc_mix": { + "sine": 0.7, + "saw": 0, + "square": 0.24 + }, + "filter": { + "cutoff": 2000, + "q": 0.21 + }, + "env": { + "attack": 0.001, + "decay": 5.9, + "sustain": 0, + "release": 0.025 + }, + "noise_db": -62, + "shaper": { + "enabled": false, + "drive": 0 + }, + "breath": { + "enabled": false, + "level_db": -60, + "bp_freq": 5200, + "bp_q": 1.5 + }, + "formants": [ + { "enabled": true, "freq": 1200, "q": 0.9, "gain_db": -0.5 }, + { "enabled": true, "freq": 2400, "q": 0.9, "gain_db": -0.5 } + ], + "hammer": { + "enabled": false, + "level": 0, + "decay_s": 0.018, + "noise": 0, + "hp_hz": 1200 + }, + "soundboard": { + "enabled": true, + "mix": 0.030, + "t60_s": 2.0, + "damp": 0.5 + }, + "action": { + "key_off_enabled": true, + "key_off_level": 0, + "key_off_decay_s": 0.05, + "key_off_vel_scale": false, + "key_off_hp_hz": 1100, + "pedal_enabled": false, + "pedal_level": 0, + "pedal_decay_s": 0.05, + "pedal_lp_hz": 600, + "release_enabled": true, + "release_level": 0, + "release_decay_s": 0.06, + "release_lp_hz": 450, + "release_thud_mix": 0, + "release_thud_hp_hz": 70 + }, + "pm_string": { + "num_strings": 3, + "detune_cents": [ -0.2, 0, 0.2 ], + "gain": [ 0.33, 0.34, 0.33 ], + "dispersion_amt": 0.02, + "ap_stages": 2, + "loss": 0.00099, + "dc_block_hz": 9.6 + }, + "hammer_model": { + "force": 0.65, + "tone_hz": 3000, + "softclip": true, + "attack_ms": 7.5, + "gamma": 1.35, + "attack_window_ms": 20 + }, + "felt": { + "felt_preload": 0.15, + "felt_stiffness": 2.3, + "felt_hysteresis": 0.14, + "felt_max": 1.7 + }, + "board_modes": [ + { "f": 90, "q": 0.65, "gain_db": -4.5 }, + { "f": 165, "q": 0.8, "gain_db": -2.5 }, + { "f": 270, "q": 0.96, "gain_db": -1.8 }, + { "f": 400, "q": 1.04, "gain_db": -0.8 }, + { "f": 580, "q": 1.25, "gain_db": -0.5 }, + { "f": 900, "q": 1.5, "gain_db": -0.3 }, + { "f": 1400, "q": 1.15, "gain_db": -0.6 }, + { "f": 2200, "q": 0.95, "gain_db": -1.0 }, + { "f": 3500, "q": 0.75, "gain_db": -1.8 }, + { "f": 5500, "q": 0.55, "gain_db": -3.0 } + ], + "board_send": 0.50, + "board_mix": 0.40, + "pm_filter": { + "cutoff": 11800, + "q": 0.045, + "keytrack": 0.75 + }, + "tilt_db": 0.9, + "predelay_ms": 3, + "pedal": { + "sustain_thresh": 0.48, + "half_thresh": 0.28, + "half_release_scale": 1.0, + "repedal_ms": 95, + "resonance_send": 0.2, + "resonance_mix": 0.11, + "resonance_t60": 2.2, + "sustain_release_scale": 1.0, + "sustain_gain_db": 3.0 + }, + "una_corda": { + "detune_cents": 0, + "gain_scale": 0.64 + }, + "duplex": { + "ratio": 2.2, + "gain_db": -11, + "decay_ms": 150, + "symp_send": 0.1, + "symp_mix": 0.0875, + "symp_no_pedal_scale": 0.3 + }, + "wdf": { + "enabled": true, + "blend": 0.5, + "loss": 0.015, + "bridge_mass": 2.2, + "plate_stiffness": 1.2 + }, + "mics": { + "blend": [ 0.75, 0.20, 0.05 ], + "close": { + "gain_db": -1, + "delay_ms": 0.2, + "low_shelf_db": 0, + "high_shelf_db": -0.5, + "shelf_freq": 4800 + }, + "player": { + "gain_db": -10, + "delay_ms": 6, + "low_shelf_db": 0.4, + "high_shelf_db": 0.3, + "shelf_freq": 3200 + }, + "room": { + "gain_db": -16, + "delay_ms": 18, + "low_shelf_db": 0.5, + "high_shelf_db": -0.4, + "shelf_freq": 2000 + } + }, + "release_extension": 1.0, + "output_lpf": { + "enabled": true, + "cutoff": 14000, + "q": 0.707 + }, + "eq": { + "enabled": true, + "bands": [ + { "freq": 60, "q": 0.4, "gain_db": -5.0 }, + { "freq": 200, "q": 0.5, "gain_db": 0.5 }, + { "freq": 900, "q": 0.4, "gain_db": 0.3 }, + { "freq": 3500, "q": 0.35, "gain_db": 2.0 }, + { "freq": 10000, "q": 0.3, "gain_db": 1.5 } + ] + }, + "output_gain_db": -9.0, + "engine_mix": { + "va": 0, + "pm": 0, + "pm2": 1 + }, + "pitch_comp_offset_cents": 0, + "pitch_comp_slope_cents": 0, + "pm2_loudness_slope_db_per_semi": -0.05, + "pm2_gain_db": -4.0, + "velocity_curve_gamma": 1.0, + "velocity_curve": "linear", + "brightness": { + "enabled": true, + "base_db": 0.3, + "vel_slope_db": 0.8, + "note_slope_db": 0.45, + "max_db": 2.7, + "cutoff_hz": 4356.0, + "q": 0.7 + }, + "damper": { + "loss_damped": 0.35, + "loss_half": 0.80, + "loss_off": 1.0, + "smooth_ms": 8, + "soften_ms": 5, + "soften_hz": 1000 + }, + "post_room": { + "mix": 0.05, + "enabled": true + }, + "output_eq": { + "enabled": true, + "bands": [ + { "freq": 60.0, "q": 0.7, "gain_db": -0.8 }, + { "freq": 200.0, "q": 0.9, "gain_db": -0.3 }, + { "freq": 900.0, "q": 1.0, "gain_db": 0.0 }, + { "freq": 3500.0, "q": 1.0, "gain_db": 1.4 }, + { "freq": 10000.0, "q": 0.8, "gain_db": 1.2 } + ] + } + }, + { + "name": "Warm Romantic", + "schema_version": 4, + "engine": "pm2", + "osc_mix": { + "sine": 0.75, + "saw": 0.02, + "square": 0.22 + }, + "filter": { + "cutoff": 1400, + "q": 0.18 + }, + "env": { + "attack": 0.003, + "decay": 6.8, + "sustain": 0, + "release": 0.025 + }, + "noise_db": -62, + "shaper": { + "enabled": false, + "drive": 0 + }, + "breath": { + "enabled": false, + "level_db": -60, + "bp_freq": 4800, + "bp_q": 1.4 + }, + "formants": [ + { "enabled": true, "freq": 1100, "q": 0.85, "gain_db": -0.3 }, + { "enabled": true, "freq": 2200, "q": 0.85, "gain_db": -0.6 } + ], + "hammer": { + "enabled": false, + "level": 0, + "decay_s": 0.022, + "noise": 0, + "hp_hz": 280 + }, + "soundboard": { + "enabled": true, + "mix": 0.045, + "t60_s": 2.4, + "damp": 0.60 + }, + "pm_string": { + "num_strings": 3, + "detune_cents": [ -0.25, 0, 0.25 ], + "gain": [ 0.34, 0.34, 0.32 ], + "dispersion_amt": 0.022, + "ap_stages": 2, + "loss": 0.00095, + "dc_block_hz": 8.5 + }, + "hammer_model": { + "force": 0.60, + "tone_hz": 2600, + "softclip": true, + "attack_ms": 8.5, + "gamma": 1.45, + "attack_window_ms": 22 + }, + "felt": { + "felt_preload": 0.18, + "felt_stiffness": 2.0, + "felt_hysteresis": 0.16, + "felt_max": 1.6 + }, + "board_modes": [ + { "f": 85, "q": 0.70, "gain_db": -4.0 }, + { "f": 160, "q": 0.85, "gain_db": -2.0 }, + { "f": 260, "q": 1.00, "gain_db": -1.5 }, + { "f": 380, "q": 1.10, "gain_db": -0.5 }, + { "f": 560, "q": 1.30, "gain_db": -0.3 }, + { "f": 850, "q": 1.55, "gain_db": -0.2 }, + { "f": 1350, "q": 1.20, "gain_db": -0.8 }, + { "f": 2100, "q": 1.00, "gain_db": -1.5 }, + { "f": 3300, "q": 0.80, "gain_db": -2.5 }, + { "f": 5200, "q": 0.60, "gain_db": -4.0 } + ], + "board_send": 0.55, + "board_mix": 0.52, + "pm_filter": { + "cutoff": 9500, + "q": 0.06, + "keytrack": 0.68 + }, + "tilt_db": 0.4, + "predelay_ms": 4, + "pedal": { + "sustain_thresh": 0.48, + "half_thresh": 0.28, + "half_release_scale": 1.0, + "repedal_ms": 100, + "resonance_send": 0.22, + "resonance_mix": 0.14, + "resonance_t60": 2.6, + "sustain_release_scale": 1.0, + "sustain_gain_db": 3.5 + }, + "una_corda": { + "detune_cents": 0, + "gain_scale": 0.62 + }, + "duplex": { + "ratio": 2.2, + "gain_db": -13, + "decay_ms": 160, + "symp_send": 0.12, + "symp_mix": 0.10, + "symp_no_pedal_scale": 0.32 + }, + "wdf": { + "enabled": true, + "blend": 0.55, + "loss": 0.014, + "bridge_mass": 2.4, + "plate_stiffness": 1.1 + }, + "mics": { + "blend": [ 0.50, 0.35, 0.15 ], + "close": { + "gain_db": -2, + "delay_ms": 0.3, + "low_shelf_db": 0.5, + "high_shelf_db": -2.0, + "shelf_freq": 3800 + }, + "player": { + "gain_db": -8, + "delay_ms": 8, + "low_shelf_db": 0.8, + "high_shelf_db": 0.0, + "shelf_freq": 2800 + }, + "room": { + "gain_db": -12, + "delay_ms": 22, + "low_shelf_db": 1.0, + "high_shelf_db": -0.8, + "shelf_freq": 1600 + } + }, + "release_extension": 1.0, + "output_lpf": { + "enabled": true, + "cutoff": 10500, + "q": 0.60 + }, + "eq": { + "enabled": true, + "bands": [ + { "freq": 55, "q": 0.45, "gain_db": -4.0 }, + { "freq": 180, "q": 0.55, "gain_db": 2.0 }, + { "freq": 800, "q": 0.45, "gain_db": 0.8 }, + { "freq": 3000, "q": 0.40, "gain_db": -0.5 }, + { "freq": 9000, "q": 0.35, "gain_db": -1.5 } + ] + }, + "output_gain_db": -9.0, + "engine_mix": { + "va": 0, + "pm": 0, + "pm2": 1 + }, + "pitch_comp_offset_cents": 0, + "pitch_comp_slope_cents": 0, + "pm2_loudness_slope_db_per_semi": -0.05, + "pm2_gain_db": -4.0, + "velocity_curve_gamma": 1.0, + "velocity_curve": "linear", + "brightness": { + "enabled": true, + "base_db": 0.15, + "vel_slope_db": 0.6, + "note_slope_db": 0.30, + "max_db": 2.0, + "cutoff_hz": 3800.0, + "q": 0.65 + }, + "damper": { + "loss_damped": 0.35, + "loss_half": 0.80, + "loss_off": 1.0, + "smooth_ms": 8, + "soften_ms": 6, + "soften_hz": 900 + }, + "post_room": { + "mix": 0.08, + "enabled": true + }, + "output_eq": { + "enabled": true, + "bands": [ + { "freq": 55.0, "q": 0.7, "gain_db": -0.5 }, + { "freq": 180.0, "q": 0.85, "gain_db": 0.5 }, + { "freq": 800.0, "q": 0.95, "gain_db": 0.3 }, + { "freq": 3000.0, "q": 0.95, "gain_db": 0.0 }, + { "freq": 9000.0, "q": 0.75, "gain_db": -0.5 } + ] + } + }, + { + "name": "Bright Concert", + "schema_version": 4, + "engine": "pm2", + "osc_mix": { + "sine": 0.55, + "saw": 0.18, + "square": 0.27 + }, + "filter": { + "cutoff": 320, + "q": 0.13 + }, + "env": { + "attack": 0.001, + "decay": 6.2, + "sustain": 0, + "release": 0.025 + }, + "noise_db": -62, + "shaper": { + "enabled": false, + "drive": 0 + }, + "breath": { + "enabled": false, + "level_db": -60, + "bp_freq": 5500, + "bp_q": 1.6 + }, + "formants": [ + { "enabled": true, "freq": 1300, "q": 0.95, "gain_db": -0.3 }, + { "enabled": true, "freq": 2600, "q": 0.95, "gain_db": -0.4 } + ], + "hammer": { + "enabled": false, + "level": 0, + "decay_s": 0.016, + "noise": 0, + "hp_hz": 800 + }, + "soundboard": { + "enabled": true, + "mix": 0.020, + "t60_s": 1.8, + "damp": 0.45 + }, + "action": { + "key_off_enabled": true, + "key_off_level": 0, + "key_off_decay_s": 0.045, + "key_off_vel_scale": false, + "key_off_hp_hz": 1200, + "pedal_enabled": false, + "pedal_level": 0, + "pedal_decay_s": 0.05, + "pedal_lp_hz": 600, + "release_enabled": true, + "release_level": 0, + "release_decay_s": 0.055, + "release_lp_hz": 480, + "release_thud_mix": 0, + "release_thud_hp_hz": 75 + }, + "pm_string": { + "num_strings": 3, + "detune_cents": [ -0.18, 0, 0.18 ], + "gain": [ 0.34, 0.34, 0.32 ], + "dispersion_amt": 0.018, + "ap_stages": 2, + "loss": 0.00102, + "dc_block_hz": 10 + }, + "hammer_model": { + "force": 0.70, + "tone_hz": 3800, + "softclip": true, + "attack_ms": 6.5, + "gamma": 1.25, + "attack_window_ms": 18 + }, + "felt": { + "felt_preload": 0.12, + "felt_stiffness": 2.6, + "felt_hysteresis": 0.12, + "felt_max": 1.8 + }, + "board_modes": [ + { "f": 95, "q": 0.60, "gain_db": -5.0 }, + { "f": 170, "q": 0.75, "gain_db": -3.0 }, + { "f": 280, "q": 0.90, "gain_db": -2.0 }, + { "f": 420, "q": 1.00, "gain_db": -1.0 }, + { "f": 600, "q": 1.20, "gain_db": -0.5 }, + { "f": 950, "q": 1.45, "gain_db": 0.0 }, + { "f": 1450, "q": 1.10, "gain_db": -0.3 }, + { "f": 2300, "q": 0.90, "gain_db": -0.6 }, + { "f": 3700, "q": 0.70, "gain_db": -1.2 }, + { "f": 5800, "q": 0.50, "gain_db": -2.0 } + ], + "board_send": 0.45, + "board_mix": 0.32, + "pm_filter": { + "cutoff": 13500, + "q": 0.035, + "keytrack": 0.80 + }, + "tilt_db": 1.4, + "predelay_ms": 2, + "pedal": { + "sustain_thresh": 0.48, + "half_thresh": 0.28, + "half_release_scale": 1.0, + "repedal_ms": 90, + "resonance_send": 0.16, + "resonance_mix": 0.09, + "resonance_t60": 1.9, + "sustain_release_scale": 1.0, + "sustain_gain_db": 2.5 + }, + "velocity_curve_gamma": 1.0, + "velocity_curve": "linear", + "brightness": { + "enabled": true, + "base_db": 0.5, + "vel_slope_db": 1.0, + "note_slope_db": 0.55, + "max_db": 3.2, + "cutoff_hz": 5000.0, + "q": 0.75 + }, + "dispersion_curve": { + "high_mult": 1.25, + "pow": 1.1 + }, + "una_corda": { + "detune_cents": 0, + "gain_scale": 0.68 + }, + "duplex": { + "ratio": 2.2, + "gain_db": -10, + "decay_ms": 140, + "symp_send": 0.14, + "symp_mix": 0.095, + "symp_no_pedal_scale": 0.28 + }, + "wdf": { + "enabled": true, + "blend": 0.45, + "loss": 0.016, + "bridge_mass": 2.0, + "plate_stiffness": 1.3 + }, + "mics": { + "blend": [ 0.90, 0.08, 0.02 ], + "close": { + "gain_db": 0, + "delay_ms": 0.1, + "low_shelf_db": -0.5, + "high_shelf_db": 1.0, + "shelf_freq": 5200 + }, + "player": { + "gain_db": -14, + "delay_ms": 5, + "low_shelf_db": 0.2, + "high_shelf_db": 0.6, + "shelf_freq": 3600 + }, + "room": { + "gain_db": -20, + "delay_ms": 15, + "low_shelf_db": 0.3, + "high_shelf_db": -0.2, + "shelf_freq": 2200 + } + }, + "release_extension": 1.0, + "output_lpf": { + "enabled": true, + "cutoff": 16000, + "q": 0.80 + }, + "eq": { + "enabled": true, + "bands": [ + { "freq": 70, "q": 0.35, "gain_db": -6.5 }, + { "freq": 220, "q": 0.45, "gain_db": -0.5 }, + { "freq": 1000, "q": 0.40, "gain_db": 0.8 }, + { "freq": 4000, "q": 0.30, "gain_db": 3.5 }, + { "freq": 11000, "q": 0.28, "gain_db": 2.5 } + ] + }, + "engine_mix": { + "va": 0, + "pm": 0, + "pm2": 1 + }, + "pitch_comp_offset_cents": 0, + "pitch_comp_slope_cents": 0, + "pm2_loudness_slope_db_per_semi": -0.05, + "pm2_gain_db": -4.0, + "damper": { + "loss_damped": 0.35, + "loss_half": 0.80, + "loss_off": 1.0, + "smooth_ms": 8, + "soften_ms": 4, + "soften_hz": 1100 + }, + "post_room": { + "mix": 0.03, + "enabled": true + }, + "output_eq": { + "enabled": true, + "bands": [ + { "freq": 70.0, "q": 0.7, "gain_db": -1.0 }, + { "freq": 220.0, "q": 0.85, "gain_db": -0.5 }, + { "freq": 1000.0, "q": 1.0, "gain_db": 0.2 }, + { "freq": 4000.0, "q": 1.0, "gain_db": 2.0 }, + { "freq": 11000.0, "q": 0.8, "gain_db": 1.8 } + ] + }, + "output_gain_db": -9.0 + }, + { + "name": "Intimate Studio", + "schema_version": 4, + "engine": "pm2", + "osc_mix": { + "sine": 0.68, + "saw": 0.05, + "square": 0.27 + }, + "filter": { + "cutoff": 1800, + "q": 0.19 + }, + "env": { + "attack": 0.001, + "decay": 5.5, + "sustain": 0, + "release": 0.025 + }, + "noise_db": -62, + "shaper": { + "enabled": false, + "drive": 0 + }, + "breath": { + "enabled": false, + "level_db": -60, + "bp_freq": 5000, + "bp_q": 1.5 + }, + "formants": [ + { "enabled": true, "freq": 1150, "q": 0.88, "gain_db": -0.4 }, + { "enabled": true, "freq": 2300, "q": 0.88, "gain_db": -0.5 } + ], + "hammer": { + "enabled": false, + "level": 0, + "decay_s": 0.017, + "noise": 0, + "hp_hz": 950 + }, + "soundboard": { + "enabled": true, + "mix": 0.012, + "t60_s": 1.6, + "damp": 0.42 + }, + "action": { + "key_off_enabled": true, + "key_off_level": 0, + "key_off_decay_s": 0.04, + "key_off_vel_scale": false, + "key_off_hp_hz": 1150, + "pedal_enabled": false, + "pedal_level": 0, + "pedal_decay_s": 0.045, + "pedal_lp_hz": 550, + "release_enabled": true, + "release_level": 0, + "release_decay_s": 0.05, + "release_lp_hz": 420, + "release_thud_mix": 0, + "release_thud_hp_hz": 65 + }, + "pm_string": { + "num_strings": 3, + "detune_cents": [ -0.15, 0, 0.15 ], + "gain": [ 0.33, 0.34, 0.33 ], + "dispersion_amt": 0.019, + "ap_stages": 2, + "loss": 0.00100, + "dc_block_hz": 9.2 + }, + "hammer_model": { + "force": 0.68, + "tone_hz": 3200, + "softclip": true, + "attack_ms": 7.0, + "gamma": 1.30, + "attack_window_ms": 19 + }, + "felt": { + "felt_preload": 0.14, + "felt_stiffness": 2.4, + "felt_hysteresis": 0.13, + "felt_max": 1.75 + }, + "board_modes": [ + { "f": 92, "q": 0.62, "gain_db": -5.5 }, + { "f": 168, "q": 0.78, "gain_db": -3.5 }, + { "f": 275, "q": 0.94, "gain_db": -2.5 }, + { "f": 410, "q": 1.02, "gain_db": -1.5 }, + { "f": 590, "q": 1.22, "gain_db": -1.0 }, + { "f": 920, "q": 1.48, "gain_db": -0.8 }, + { "f": 1420, "q": 1.12, "gain_db": -1.2 }, + { "f": 2250, "q": 0.92, "gain_db": -1.8 }, + { "f": 3600, "q": 0.72, "gain_db": -2.5 }, + { "f": 5600, "q": 0.52, "gain_db": -3.5 } + ], + "board_send": 0.38, + "board_mix": 0.25, + "pm_filter": { + "cutoff": 12500, + "q": 0.040, + "keytrack": 0.78 + }, + "tilt_db": 1.0, + "predelay_ms": 1, + "pedal": { + "sustain_thresh": 0.48, + "half_thresh": 0.28, + "half_release_scale": 1.0, + "repedal_ms": 85, + "resonance_send": 0.12, + "resonance_mix": 0.06, + "resonance_t60": 1.8, + "sustain_release_scale": 1.0, + "sustain_gain_db": 2.0 + }, + "velocity_curve_gamma": 1.0, + "velocity_curve": "linear", + "brightness": { + "enabled": true, + "base_db": 0.35, + "vel_slope_db": 0.85, + "note_slope_db": 0.48, + "max_db": 2.8, + "cutoff_hz": 4600.0, + "q": 0.72 + }, + "una_corda": { + "detune_cents": 0, + "gain_scale": 0.65 + }, + "duplex": { + "ratio": 2.2, + "gain_db": -14, + "decay_ms": 130, + "symp_send": 0.08, + "symp_mix": 0.05, + "symp_no_pedal_scale": 0.25 + }, + "wdf": { + "enabled": true, + "blend": 0.48, + "loss": 0.015, + "bridge_mass": 2.1, + "plate_stiffness": 1.25 + }, + "mics": { + "blend": [ 1.0, 0.0, 0.0 ], + "close": { + "gain_db": 0, + "delay_ms": 0, + "low_shelf_db": 0, + "high_shelf_db": 0, + "shelf_freq": 4500 + }, + "player": { + "gain_db": -18, + "delay_ms": 4, + "low_shelf_db": 0.3, + "high_shelf_db": 0.4, + "shelf_freq": 3400 + }, + "room": { + "gain_db": -24, + "delay_ms": 12, + "low_shelf_db": 0.4, + "high_shelf_db": -0.3, + "shelf_freq": 2400 + } + }, + "release_extension": 1.0, + "output_lpf": { + "enabled": true, + "cutoff": 15000, + "q": 0.72 + }, + "eq": { + "enabled": true, + "bands": [ + { "freq": 65, "q": 0.38, "gain_db": -5.5 }, + { "freq": 210, "q": 0.48, "gain_db": 0.0 }, + { "freq": 950, "q": 0.42, "gain_db": 0.5 }, + { "freq": 3800, "q": 0.32, "gain_db": 2.8 }, + { "freq": 10500, "q": 0.30, "gain_db": 2.0 } + ] + }, + "engine_mix": { + "va": 0, + "pm": 0, + "pm2": 1 + }, + "pitch_comp_offset_cents": 0, + "pitch_comp_slope_cents": 0, + "pm2_loudness_slope_db_per_semi": -0.05, + "pm2_gain_db": -4.0, + "damper": { + "loss_damped": 0.35, + "loss_half": 0.80, + "loss_off": 1.0, + "smooth_ms": 8, + "soften_ms": 5, + "soften_hz": 1050 + }, + "post_room": { + "mix": 0.015, + "enabled": true + }, + "output_eq": { + "enabled": true, + "bands": [ + { "freq": 65.0, "q": 0.7, "gain_db": -0.6 }, + { "freq": 210.0, "q": 0.88, "gain_db": -0.2 }, + { "freq": 950.0, "q": 1.0, "gain_db": 0.1 }, + { "freq": 3800.0, "q": 1.0, "gain_db": 1.6 }, + { "freq": 10500.0, "q": 0.8, "gain_db": 1.4 } + ] + }, + "output_gain_db": -9.0 + }, + { + "name": "Mellow Jazz", + "schema_version": 4, + "engine": "pm2", + "osc_mix": { + "sine": 0.78, + "saw": 0.0, + "square": 0.22 + }, + "filter": { + "cutoff": 1200, + "q": 0.16 + }, + "env": { + "attack": 0.004, + "decay": 7.2, + "sustain": 0, + "release": 0.025 + }, + "noise_db": -62, + "shaper": { + "enabled": false, + "drive": 0 + }, + "breath": { + "enabled": false, + "level_db": -60, + "bp_freq": 4500, + "bp_q": 1.3 + }, + "formants": [ + { "enabled": true, "freq": 1000, "q": 0.80, "gain_db": -0.2 }, + { "enabled": true, "freq": 2000, "q": 0.80, "gain_db": -0.7 } + ], + "hammer": { + "enabled": false, + "level": 0, + "decay_s": 0.025, + "noise": 0, + "hp_hz": 220 + }, + "soundboard": { + "enabled": true, + "mix": 0.055, + "t60_s": 2.8, + "damp": 0.65 + }, + "action": { + "key_off_enabled": true, + "key_off_level": 0, + "key_off_decay_s": 0.055, + "key_off_vel_scale": false, + "key_off_hp_hz": 1000, + "pedal_enabled": false, + "pedal_level": 0, + "pedal_decay_s": 0.055, + "pedal_lp_hz": 580, + "release_enabled": true, + "release_level": 0, + "release_decay_s": 0.065, + "release_lp_hz": 400, + "release_thud_mix": 0, + "release_thud_hp_hz": 60 + }, + "pm_string": { + "num_strings": 3, + "detune_cents": [ -0.28, 0, 0.28 ], + "gain": [ 0.34, 0.33, 0.33 ], + "dispersion_amt": 0.024, + "ap_stages": 2, + "loss": 0.00092, + "dc_block_hz": 8.0 + }, + "hammer_model": { + "force": 0.55, + "tone_hz": 2200, + "softclip": true, + "attack_ms": 9.5, + "gamma": 1.55, + "attack_window_ms": 24 + }, + "felt": { + "felt_preload": 0.20, + "felt_stiffness": 1.8, + "felt_hysteresis": 0.18, + "felt_max": 1.5 + }, + "board_modes": [ + { "f": 80, "q": 0.75, "gain_db": -3.5 }, + { "f": 150, "q": 0.90, "gain_db": -1.5 }, + { "f": 250, "q": 1.05, "gain_db": -1.0 }, + { "f": 360, "q": 1.15, "gain_db": -0.3 }, + { "f": 540, "q": 1.35, "gain_db": 0.0 }, + { "f": 820, "q": 1.60, "gain_db": 0.0 }, + { "f": 1300, "q": 1.25, "gain_db": -1.0 }, + { "f": 2000, "q": 1.05, "gain_db": -2.0 }, + { "f": 3100, "q": 0.85, "gain_db": -3.5 }, + { "f": 4800, "q": 0.65, "gain_db": -5.0 } + ], + "board_send": 0.60, + "board_mix": 0.58, + "pm_filter": { + "cutoff": 8500, + "q": 0.07, + "keytrack": 0.62 + }, + "tilt_db": 0.0, + "predelay_ms": 5, + "pedal": { + "sustain_thresh": 0.48, + "half_thresh": 0.28, + "half_release_scale": 1.0, + "repedal_ms": 110, + "resonance_send": 0.25, + "resonance_mix": 0.16, + "resonance_t60": 3.0, + "sustain_release_scale": 1.0, + "sustain_gain_db": 4.0 + }, + "velocity_curve_gamma": 1.0, + "velocity_curve": "linear", + "brightness": { + "enabled": true, + "base_db": 0.0, + "vel_slope_db": 0.5, + "note_slope_db": 0.22, + "max_db": 1.6, + "cutoff_hz": 3400.0, + "q": 0.58 + }, + "una_corda": { + "detune_cents": 0, + "gain_scale": 0.60 + }, + "duplex": { + "ratio": 2.2, + "gain_db": -15, + "decay_ms": 180, + "symp_send": 0.14, + "symp_mix": 0.12, + "symp_no_pedal_scale": 0.35 + }, + "wdf": { + "enabled": true, + "blend": 0.60, + "loss": 0.013, + "bridge_mass": 2.6, + "plate_stiffness": 1.0 + }, + "mics": { + "blend": [ 0.35, 0.45, 0.20 ], + "close": { + "gain_db": -3, + "delay_ms": 0.4, + "low_shelf_db": 0.8, + "high_shelf_db": -2.5, + "shelf_freq": 3400 + }, + "player": { + "gain_db": -6, + "delay_ms": 10, + "low_shelf_db": 1.0, + "high_shelf_db": -0.5, + "shelf_freq": 2600 + }, + "room": { + "gain_db": -10, + "delay_ms": 28, + "low_shelf_db": 1.2, + "high_shelf_db": -1.2, + "shelf_freq": 1400 + } + }, + "release_extension": 1.0, + "output_lpf": { + "enabled": true, + "cutoff": 9000, + "q": 0.50 + }, + "eq": { + "enabled": true, + "bands": [ + { "freq": 50, "q": 0.50, "gain_db": -3.0 }, + { "freq": 160, "q": 0.60, "gain_db": 2.5 }, + { "freq": 700, "q": 0.50, "gain_db": 1.2 }, + { "freq": 2500, "q": 0.45, "gain_db": -1.5 }, + { "freq": 8000, "q": 0.40, "gain_db": -3.0 } + ] + }, + "engine_mix": { + "va": 0, + "pm": 0, + "pm2": 1 + }, + "pitch_comp_offset_cents": 0, + "pitch_comp_slope_cents": 0, + "pm2_loudness_slope_db_per_semi": -0.05, + "pm2_gain_db": -4.0, + "damper": { + "loss_damped": 0.35, + "loss_half": 0.80, + "loss_off": 1.0, + "smooth_ms": 10, + "soften_ms": 7, + "soften_hz": 850 + }, + "post_room": { + "mix": 0.10, + "enabled": true + }, + "output_eq": { + "enabled": true, + "bands": [ + { "freq": 50.0, "q": 0.7, "gain_db": -0.3 }, + { "freq": 160.0, "q": 0.8, "gain_db": 0.8 }, + { "freq": 700.0, "q": 0.9, "gain_db": 0.5 }, + { "freq": 2500.0, "q": 0.9, "gain_db": -0.8 }, + { "freq": 8000.0, "q": 0.7, "gain_db": -1.5 } + ] + }, + "output_gain_db": -9.0 + } + ] +}