From 77eb2c2d33ee2097eb54da7a08067f8b8dd2e934 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 2 Jul 2026 01:15:41 -0400 Subject: [PATCH] Fix oscillator/synth phase wraparound drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- daw-backend/src/audio/node_graph/nodes/oscillator.rs | 10 +++++----- daw-backend/src/effects/synth.rs | 9 ++++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/daw-backend/src/audio/node_graph/nodes/oscillator.rs b/daw-backend/src/audio/node_graph/nodes/oscillator.rs index 243a7bf..769a2b9 100644 --- a/daw-backend/src/audio/node_graph/nodes/oscillator.rs +++ b/daw-backend/src/audio/node_graph/nodes/oscillator.rs @@ -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); } } diff --git a/daw-backend/src/effects/synth.rs b/daw-backend/src/effects/synth.rs index 06e6cea..82a77b9 100644 --- a/daw-backend/src/effects/synth.rs +++ b/daw-backend/src/effects/synth.rs @@ -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;