From cd26beca42a2d3bc6882433d496af559bea2f1d4 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 9 Sep 2026 14:33:21 +0200 Subject: [PATCH] refactor(osc): support held-note parameter updates without phase reset Add Oscillator::setParams() which updates detune, pan, and gain for already-initialised sub-voices without resetting their phases. This preserves phase continuity when unison or spread is LFO-modulated at control rate. noteOn() now just seeds phases and delegates to setParams(). Precompute panL/panR in SubVoice instead of calling cos/sin per sample. Add paramsValid and cachedParams to skip redundant work. Replace SynthVoice's lastUnisonA/B tracking with a single oscillatorsNeedNoteOn flag: noteOn sets it, render clears it after the first block. Add SubLevel and NoiseLevel to the per-voice modulation targets so they can be modulated like other parameters. --- Source/Oscillator.cpp | 92 +++++++++++++++++++++++++++---------------- Source/Oscillator.h | 11 ++++-- Source/SynthVoice.cpp | 29 ++++++++++---- Source/SynthVoice.h | 2 +- 4 files changed, 88 insertions(+), 46 deletions(-) diff --git a/Source/Oscillator.cpp b/Source/Oscillator.cpp index d82efde..9861eb8 100644 --- a/Source/Oscillator.cpp +++ b/Source/Oscillator.cpp @@ -6,48 +6,77 @@ namespace serum void Oscillator::reset() { for (auto& v : voices) - { - v.phase = 0.0; - v.detuneRatio = 1.0; - v.pan = 0.0f; - v.level = 1.0f; - } + v = SubVoice {}; activeUnison = 1; + initializedUnison = 0; + paramsValid = false; } void Oscillator::noteOn (double freqHz, const OscParams& p, juce::uint32 seed) { jassert (freqHz > 0.0); rng.setSeed (seed); + initializedUnison = 0; + paramsValid = false; + setParams (p); +} +void Oscillator::setParams (const OscParams& p) noexcept +{ const int uni = juce::jlimit (1, kMaxUnison, p.unison); - activeUnison = uni; + const bool countChanged = ! paramsValid || uni != activeUnison; + const bool detuneChanged = countChanged || p.detune != cachedParams.detune; + const bool panChanged = countChanged || p.pan != cachedParams.pan || p.spread != cachedParams.spread; + if (! detuneChanged && ! panChanged) + return; const double basePhase = (double) p.phase * kTwoPi; - - for (int v = 0; v < uni; ++v) + for (int v = initializedUnison; v < uni; ++v) { - // Even phase spacing prevents cancellation across unison voices. + // Even phase spacing distributes the initial unison phases. double offset = (uni > 1) ? ((double) v / (double) uni) * kTwoPi : 0.0; double random = rng.nextFloat() * (double) p.randPhase * kTwoPi; voices[(size_t) v].phase = basePhase + offset + random; - - // Detune: linear spread in cents, 0..50 cents at full depth. - double detuneCents = 0.0; - if (uni > 1) - detuneCents = (double) p.detune * 50.0 * ((double) (v - (uni - 1) / 2.0) / (double) ((uni - 1) / 2.0)); - voices[(size_t) v].detuneRatio = std::pow (2.0, detuneCents / 1200.0); - - // Stereo spread. - float panPos = (uni > 1) ? ((float) v / (float) (uni - 1) - 0.5f) * 2.0f * p.spread : 0.0f; - voices[(size_t) v].pan = panPos; - - // Gain scaling with a centre emphasis for odd unison counts. - float lvl = 1.0f / std::sqrt ((float) uni); - if ((uni & 1) && v == uni / 2) - lvl *= 1.3f; - voices[(size_t) v].level = lvl; } + initializedUnison = juce::jmax (initializedUnison, uni); + + const float unisonLevel = 1.0f / std::sqrt ((float) uni); + for (int v = 0; v < uni; ++v) + { + auto& sv = voices[(size_t) v]; + if (detuneChanged) + { + // Detune: linear spread in cents, 0..50 cents at full depth. + double detuneCents = 0.0; + if (uni > 1) + detuneCents = (double) p.detune * 50.0 * ((double) (v - (uni - 1) / 2.0) / (double) ((uni - 1) / 2.0)); + sv.detuneRatio = std::pow (2.0, detuneCents / 1200.0); + } + + if (panChanged) + { + // Stereo spread. + float panPos = (uni > 1) ? ((float) v / (float) (uni - 1) - 0.5f) * 2.0f * p.spread : 0.0f; + panPos = juce::jlimit (-1.0f, 1.0f, panPos + p.pan); + + // Constant-power pan. + const float panAngle = (panPos + 1.0f) * 0.5f * juce::MathConstants::halfPi; + sv.panL = std::cos (panAngle); + sv.panR = std::sin (panAngle); + } + + if (countChanged) + { + // Gain scaling with a centre emphasis for odd unison counts. + float lvl = unisonLevel; + if ((uni & 1) && v == uni / 2) + lvl *= 1.3f; + sv.level = lvl; + } + } + activeUnison = uni; + cachedParams = p; + paramsValid = true; } float Oscillator::warpPhase (float phase, const OscParams& p) const noexcept @@ -96,7 +125,7 @@ void Oscillator::processAdd (const Wavetable& wt, const OscParams& p, double fre if (! p.enabled || p.level <= 0.0f || freqHz <= 0.0) return; - const int uni = juce::jlimit (1, kMaxUnison, p.unison); + const int uni = activeUnison; const float framePos = p.wtPos * 255.0f; const double phaseInc = kTwoPi * freqHz / sr; @@ -116,14 +145,9 @@ void Oscillator::processAdd (const Wavetable& wt, const OscParams& p, double fre float sample = wt.readSafe (framePos, warpPhase (phase01, p)); sample = warpSample (sample, p); - // Constant-power pan. - const float panAngle = (sv.pan + 1.0f) * 0.5f * 1.5707963267948966f; - const float panL = std::cos (panAngle); - const float panR = std::sin (panAngle); - const float gain = sv.level * p.level; - accL += sample * gain * panL; - accR += sample * gain * panR; + accL += sample * gain * sv.panL; + accR += sample * gain * sv.panR; } outL += accL; diff --git a/Source/Oscillator.h b/Source/Oscillator.h index 8fc17a6..ffdfa69 100644 --- a/Source/Oscillator.h +++ b/Source/Oscillator.h @@ -41,8 +41,9 @@ public: void prepare (double sampleRate) { sr = sampleRate; reset(); } void reset(); - // (Re)configure unison sub-voices: phase offsets, detune, pan and gain. + // Initialise a fresh note's unison sub-voices: phase offsets, detune, pan and gain. void noteOn (double freqHz, const OscParams& p, juce::uint32 seed); + void setParams (const OscParams& p) noexcept; // Accumulate this oscillator's contribution into outL/outR. void processAdd (const Wavetable& wt, const OscParams& p, double freqHz, @@ -55,16 +56,20 @@ private: { double phase = 0.0; double detuneRatio = 1.0; - float pan = 0.0f; + float panL = 0.70710678f; + float panR = 0.70710678f; float level = 1.0f; }; std::array voices; int activeUnison = 1; + int initializedUnison = 0; + bool paramsValid = false; + OscParams cachedParams; double sr = 44100.0; juce::Random rng; - static constexpr double kTwoPi = 6.28318530717958647692; + static constexpr double kTwoPi = juce::MathConstants::twoPi; float warpPhase (float phase, const OscParams& p) const noexcept; float warpSample (float sample, const OscParams& p) const noexcept; diff --git a/Source/SynthVoice.cpp b/Source/SynthVoice.cpp index c491b21..d83dc3b 100644 --- a/Source/SynthVoice.cpp +++ b/Source/SynthVoice.cpp @@ -35,7 +35,8 @@ void SynthVoice::reset() velocity = 0.0f; baseFreq = 0.0; active = released = false; - lastUnisonA = lastUnisonB = 1; + noteId = 0; + oscillatorsNeedNoteOn = false; noteRandom = 0.5f; scratch.clear(); } @@ -48,7 +49,8 @@ void SynthVoice::noteOn (int noteNumber, float velocity01, double freqHz, juce:: active = true; released = false; seed = noteSeed; - lastUnisonA = lastUnisonB = 0; // force unison reconfigure on first render + noteId = noteSeed; + oscillatorsNeedNoteOn = true; // initialise fresh oscillator phases on first render juce::Random rng (noteSeed); noteRandom = rng.nextFloat(); @@ -151,10 +153,19 @@ void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderC const double freqA = bent * std::pow (2.0, (double) a.coarse / 12.0 + (double) a.fine / 1200.0); const double freqB = bent * std::pow (2.0, (double) b.coarse / 12.0 + (double) b.fine / 1200.0); - // Reconfigure unison only when the integer count changes (avoids phase reset - // on every block when unison is LFO-modulated at control rate). - if (a.unison != lastUnisonA) { oscA.noteOn (freqA, a, seed); lastUnisonA = a.unison; } - if (b.unison != lastUnisonB) { oscB.noteOn (freqB, b, seed + 1); lastUnisonB = b.unison; } + // Initialise phases only for a fresh note; update held-note unison parameters + // without resetting existing phases when modulated at control rate. + if (oscillatorsNeedNoteOn) + { + oscA.noteOn (freqA, a, seed); + oscB.noteOn (freqB, b, seed + 1); + oscillatorsNeedNoteOn = false; + } + else + { + oscA.setParams (a); + oscB.setParams (b); + } // 7. Modulated filter parameters. FilterBankParams fb = ctx.filters; @@ -177,6 +188,8 @@ void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderC const Wavetable& wtA = ctx.wavetables->getTable (a.wave); const Wavetable& wtB = ctx.wavetables->getTable (b.wave); const double subMult = (ctx.subOct == -2) ? 0.25 : (ctx.subOct == -1) ? 0.5 : 1.0; + const float subLevel = clampF (ctx.subLevel + mod[(int) ModTarget::SubLevel], 0.0f, 1.0f); + const float noiseLevel = clampF (ctx.noiseLevel + mod[(int) ModTarget::NoiseLevel], 0.0f, 1.0f); for (int i = 0; i < numSamples; ++i) { @@ -186,9 +199,9 @@ void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderC float mono = 0.0f; if (ctx.subOn) - sub.processAdd (bent * subMult, ctx.subShape, ctx.subLevel, mono); + sub.processAdd (bent * subMult, ctx.subShape, subLevel, mono); if (ctx.noiseOn) - noise.processAdd (ctx.noiseType, ctx.noiseLevel, mono); + noise.processAdd (ctx.noiseType, noiseLevel, mono); l += mono; r += mono; diff --git a/Source/SynthVoice.h b/Source/SynthVoice.h index 9b9d919..98e3782 100644 --- a/Source/SynthVoice.h +++ b/Source/SynthVoice.h @@ -87,7 +87,7 @@ private: bool released = false; juce::uint64 noteId = 0; - int lastUnisonA = 1, lastUnisonB = 1; + bool oscillatorsNeedNoteOn = false; juce::uint32 seed = 0; float noteRandom = 0.5f;