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;