Fix oscillator/synth phase wraparound drift

Replace conditional-subtraction phase wrapping with a single stable wrap:
- OscillatorNode: `(phase + freq_mod/sr).rem_euclid(1.0)` — also wraps correctly
  when FM drives freq_mod negative (the old `if >= 1.0` wouldn't).
- SynthVoice: `(phase + frequency/sr).fract()`.
Repeated `if phase >= 1.0 { phase -= 1.0 }` accumulates f32 rounding error over
long-held notes, drifting the timbre.
This commit is contained in:
Skyler Lehmkuhl 2026-07-02 01:15:41 -04:00
parent 4f58edf436
commit 77eb2c2d33
2 changed files with 9 additions and 10 deletions

View File

@ -164,11 +164,11 @@ impl AudioNode for OscillatorNode {
output[frame * 2] = sample; // Left
output[frame * 2 + 1] = sample; // Right
// Update phase once per frame
self.phase += freq_mod / sample_rate_f32;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
// Update phase once per frame. `rem_euclid(1.0)` gives a numerically stable
// wraparound into [0, 1): repeated conditional subtraction accumulates f32
// rounding error over long-held notes (timbre drift), and FM can drive
// `freq_mod` negative, which a one-sided `if >= 1.0` wouldn't wrap at all.
self.phase = (self.phase + freq_mod / sample_rate_f32).rem_euclid(1.0);
}
}

View File

@ -113,11 +113,10 @@ impl SynthVoice {
// Simple sine wave
let sample = (self.phase * 2.0 * PI).sin() * (self.velocity as f32 / 127.0) * 0.3;
// Update phase
self.phase += self.frequency / sample_rate;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
// Update phase. Use `.fract()` for a numerically stable wraparound: repeated
// conditional subtraction accumulates f32 rounding error over long-held notes,
// which drifts the timbre.
self.phase = (self.phase + self.frequency / sample_rate).fract();
self.age += 1;