@@ -33,6 +33,20 @@ pub struct SfzRegionRuntime {
3333 pub samples : Arc < Vec < f32 > > ,
3434}
3535
36+ /// SF2 LFO modulation block — copied per-trigger when the region
37+ /// declares non-zero `mod_lfo_to_pitch_cents` or `vib_lfo_to_pitch_cents`.
38+ /// V1 wires only the pitch targets; filter / volume targets and the
39+ /// SF2 modulation envelope are deferred follow-ups.
40+ #[ derive( Clone , Copy , Debug ) ]
41+ pub ( crate ) struct RegionLfos {
42+ pub ( crate ) mod_freq_hz : f32 ,
43+ pub ( crate ) mod_delay_s : f32 ,
44+ pub ( crate ) mod_to_pitch_cents : f32 ,
45+ pub ( crate ) vib_freq_hz : f32 ,
46+ pub ( crate ) vib_delay_s : f32 ,
47+ pub ( crate ) vib_to_pitch_cents : f32 ,
48+ }
49+
3650/// Polyphony cap. Default per PLAN.md — 8 slots covers chord stabs,
3751/// release-tail overlap, and short stutters without over-allocating
3852/// state. When every slot is busy, the trigger steals the oldest.
@@ -67,6 +81,12 @@ struct TriggerShape {
6781 /// single-WAV / no-region path, which keeps consuming the
6882 /// global knobs so the user's loop window stays live.
6983 region_loop : Option < ( crate :: state:: sfz:: SfzLoopMode , usize , usize ) > ,
84+ /// Per-region LFO config — `(mod_freq_hz, mod_delay_s,
85+ /// mod_to_pitch_cents, vib_freq_hz, vib_delay_s,
86+ /// vib_to_pitch_cents)`. `Some` only when at least one pitch
87+ /// depth is non-zero (no point running an LFO that doesn't
88+ /// modulate anything). V1 wires the pitch targets only.
89+ region_lfos : Option < RegionLfos > ,
7090}
7191
7292/// Convert a cutoff frequency in Hz into the 0..1 knob value the
@@ -134,6 +154,18 @@ struct SampleInstrumentSlot {
134154 region_filter : Option < ( f32 , f32 , u8 ) > ,
135155 /// Per-slot loop override — see `TriggerShape.region_loop`.
136156 region_loop : Option < ( crate :: state:: sfz:: SfzLoopMode , usize , usize ) > ,
157+ /// Per-slot SF2 LFO modulation — see `TriggerShape.region_lfos`.
158+ region_lfos : Option < RegionLfos > ,
159+ /// Mod LFO running phase (0..1). Reset on trigger.
160+ mod_lfo_phase : f32 ,
161+ /// Mod LFO delay countdown in samples. Counts down to 0; the
162+ /// LFO depth is silent until then (the spec's per-LFO delay
163+ /// generator).
164+ mod_lfo_delay_remain_s : f32 ,
165+ /// Vib LFO running phase (0..1).
166+ vib_lfo_phase : f32 ,
167+ /// Vib LFO delay countdown in samples.
168+ vib_lfo_delay_remain_s : f32 ,
137169 /// Monotonic counter set at trigger time — lowest = oldest slot.
138170 age : u64 ,
139171 /// Per-slot SVF state for the per-voice filter. Each slot keeps
@@ -183,6 +215,11 @@ impl SampleInstrumentSlot {
183215 region_adsr : None ,
184216 region_filter : None ,
185217 region_loop : None ,
218+ region_lfos : None ,
219+ mod_lfo_phase : 0.0 ,
220+ mod_lfo_delay_remain_s : 0.0 ,
221+ vib_lfo_phase : 0.0 ,
222+ vib_lfo_delay_remain_s : 0.0 ,
186223 age : 0 ,
187224 filter : Svf :: new ( ) ,
188225 formant : FormantShifter :: new ( ) ,
@@ -447,6 +484,23 @@ impl SampleInstrumentVoice {
447484 }
448485 _ => None ,
449486 } ;
487+ // SF2 LFOs — Some only when at least one pitch
488+ // depth is non-zero. V1 wires the pitch targets only;
489+ // filter / volume / mod-env follow-ups are deferred.
490+ let region_lfos = if r. region . mod_lfo_to_pitch_cents . abs ( ) > 0.5
491+ || r. region . vib_lfo_to_pitch_cents . abs ( ) > 0.5
492+ {
493+ Some ( RegionLfos {
494+ mod_freq_hz : r. region . mod_lfo_freq_hz . clamp ( 0.05 , 20.0 ) ,
495+ mod_delay_s : r. region . mod_lfo_delay_s . clamp ( 0.0 , 5.0 ) ,
496+ mod_to_pitch_cents : r. region . mod_lfo_to_pitch_cents . clamp ( -1200.0 , 1200.0 ) ,
497+ vib_freq_hz : r. region . vib_lfo_freq_hz . clamp ( 0.05 , 20.0 ) ,
498+ vib_delay_s : r. region . vib_lfo_delay_s . clamp ( 0.0 , 5.0 ) ,
499+ vib_to_pitch_cents : r. region . vib_lfo_to_pitch_cents . clamp ( -1200.0 , 1200.0 ) ,
500+ } )
501+ } else {
502+ None
503+ } ;
450504 pending[ count] = Some ( TriggerShape {
451505 samples : r. samples . clone ( ) ,
452506 root_freq : midi_to_hz_tuned ( r. region . pitch_keycenter , tuning) . max ( 20.0 ) ,
@@ -455,6 +509,7 @@ impl SampleInstrumentVoice {
455509 region_adsr,
456510 region_filter,
457511 region_loop,
512+ region_lfos,
458513 } ) ;
459514 count += 1 ;
460515 }
@@ -475,6 +530,7 @@ impl SampleInstrumentVoice {
475530 region_adsr : None ,
476531 region_filter : None ,
477532 region_loop : None ,
533+ region_lfos : None ,
478534 } ;
479535 self . fire_slot ( & shape, accent) ;
480536 }
@@ -494,6 +550,13 @@ impl SampleInstrumentVoice {
494550 slot. region_adsr = shape. region_adsr ;
495551 slot. region_filter = shape. region_filter ;
496552 slot. region_loop = shape. region_loop ;
553+ slot. region_lfos = shape. region_lfos ;
554+ // Reset LFO phases + delay countdowns on every trigger so
555+ // each new note starts the LFO cycle from zero.
556+ slot. mod_lfo_phase = 0.0 ;
557+ slot. vib_lfo_phase = 0.0 ;
558+ slot. mod_lfo_delay_remain_s = shape. region_lfos . map ( |l| l. mod_delay_s ) . unwrap_or ( 0.0 ) ;
559+ slot. vib_lfo_delay_remain_s = shape. region_lfos . map ( |l| l. vib_delay_s ) . unwrap_or ( 0.0 ) ;
497560 slot. pos = 0.0 ;
498561 slot. adsr_stage = AdsrStage :: Attack ;
499562 slot. adsr_value = 0.0 ;
@@ -670,6 +733,55 @@ impl SampleInstrumentVoice {
670733 rate *= flutter_factor;
671734 }
672735
736+ // SF2 LFO pitch modulation — modLfoToPitch + vibLfoToPitch.
737+ // Each LFO has its own delay (silent until elapsed) +
738+ // frequency; the depths sum into a single cents offset that
739+ // converts to a multiplicative rate factor via the same
740+ // `1 + cents · ln(2)/1200` small-angle approximation
741+ // Mellotron uses. Independent state per slot so polyphonic
742+ // notes don't lock-step their wobble.
743+ if let Some ( lfos) = slot. region_lfos {
744+ let dt = 1.0 / sr;
745+ // Mod LFO.
746+ let mod_active = if slot. mod_lfo_delay_remain_s > 0.0 {
747+ slot. mod_lfo_delay_remain_s -= dt;
748+ false
749+ } else {
750+ true
751+ } ;
752+ slot. mod_lfo_phase += lfos. mod_freq_hz * dt;
753+ if slot. mod_lfo_phase >= 1.0 {
754+ slot. mod_lfo_phase -= 1.0 ;
755+ }
756+ let mod_cents = if mod_active {
757+ ( slot. mod_lfo_phase * std:: f32:: consts:: TAU ) . sin ( ) * lfos. mod_to_pitch_cents
758+ } else {
759+ 0.0
760+ } ;
761+ // Vib LFO.
762+ let vib_active = if slot. vib_lfo_delay_remain_s > 0.0 {
763+ slot. vib_lfo_delay_remain_s -= dt;
764+ false
765+ } else {
766+ true
767+ } ;
768+ slot. vib_lfo_phase += lfos. vib_freq_hz * dt;
769+ if slot. vib_lfo_phase >= 1.0 {
770+ slot. vib_lfo_phase -= 1.0 ;
771+ }
772+ let vib_cents = if vib_active {
773+ ( slot. vib_lfo_phase * std:: f32:: consts:: TAU ) . sin ( ) * lfos. vib_to_pitch_cents
774+ } else {
775+ 0.0
776+ } ;
777+ let total_cents = mod_cents + vib_cents;
778+ // ln(2) / 1200 — same Taylor-series approximation used in
779+ // the Mellotron flutter path. Accurate to <0.01 % within
780+ // ±1200 cents (the clamp range applied at trigger time).
781+ let lfo_factor = 1.0 + total_cents * 0.000_577_6 ;
782+ rate *= lfo_factor;
783+ }
784+
673785 // Resolve loop bounds + active flag. Per-region override
674786 // (SFZ / SF2 path) wins when present; falls back to the
675787 // global `sample_loop_*` knobs for the single-WAV path.
0 commit comments