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.
This commit is contained in:
2026-09-09 14:33:21 +02:00
parent 40dc0d4105
commit cd26beca42
4 changed files with 88 additions and 46 deletions
+58 -34
View File
@@ -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<float>::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;