Skip to content

Commit 4169b57

Browse files
Tokclaude
andcommitted
feat(sample): SF2 LFO pitch modulation — modLfoToPitch + vibLfoToPitch
V1 wires the pitch targets only; modLfoToFilterFc / modLfoToVolume / modulation envelope are deferred follow-ups. SF2 loader parses 6 generators (delayModLFO=21, freqModLFO=22, modLfoToPitch=5, plus the matching vibLfo trio 23/24/6) into SfzRegion fields with the standard SF2 unit conversions (absolute cents → Hz, timecents → seconds, cents passthrough). SampleInstrument audio thread gains a RegionLfos Copy block in TriggerShape + per-slot state (phase + delay countdown for each LFO). Both LFOs advance independently per sample, emit a sin- wave cents offset (only after their delay elapsed), and sum into a rate factor via the small-angle approximation (1 + cents · ln(2)/1200) the Mellotron flutter path uses. sf2_loader.rs crossed the 1000-line cap during this ship — extracted the GEN_ constants + Generators struct + absorb() into a sibling sf2_generators.rs (1105 → 874 lines). Defaults in audio path are inert (0 cents depth → no audible LFO) so SF2 patches without these generators are bit-identical to V1. +4 tests; suite 2301 → 2305. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2acb081 commit 4169b57

8 files changed

Lines changed: 623 additions & 182 deletions

File tree

PLAN.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ Pick from the top down; each row is one focused commit.
1313

1414
### SF2 follow-up
1515

16-
- [ ] **Modulator / LFO generators**. Volume envelope + filter
17-
shipped. Remaining: modulation envelope (modEnvToPitch /
18-
modEnvToFilterFc), modulation LFO (modLfoToPitch /
19-
modLfoToFilterFc / modLfoToVolume), vibrato LFO
20-
(vibLfoToPitch).
16+
- [ ] **Modulator / LFO generators — remaining targets**. Pitch
17+
targets shipped (modLfoToPitch + vibLfoToPitch). Remaining:
18+
modLfoToFilterFc, modLfoToVolume, modulation envelope
19+
(modEnvToPitch / modEnvToFilterFc). Each requires per-slot
20+
state + per-sample apply in the SampleInstrument voice; mod
21+
envelope also needs the AHDSR state machine (5 timecents fields:
22+
delay/attack/hold/decay/release + sustain attenuation).
2123

2224
---
2325

docs/features.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,61 @@ A detailed log of what's built.
44

55
---
66

7+
### SF2 LFO pitch modulation — modLfoToPitch + vibLfoToPitch
8+
9+
The SF2 loader and SampleInstrument audio thread now honour the
10+
per-region pitch-LFO generators (modLfoToPitch=5,
11+
vibLfoToPitch=6) along with their timing (delay + frequency).
12+
SF2 patches that ship sustained / vocal / string samples with
13+
natural breath / vibrato now wobble per spec instead of holding
14+
dead-flat pitch.
15+
16+
V1 scope is **pitch targets only** — modLfoToFilterFc /
17+
modLfoToVolume / modEnvToPitch / modEnvToFilterFc are deferred
18+
to a follow-up (PLAN entry).
19+
20+
**SF2 loader.** Six new generator opcodes parsed:
21+
`delayModLFO=21` (timecents), `freqModLFO=22` (absolute cents,
22+
`hz = 8.176 × 2^(cents/1200)`), `modLfoToPitch=5` (cents),
23+
plus the matching vibrato-LFO trio (23, 24, 6). All round-trip
24+
via `Generators.absorb()`; `build_region` emits the converted
25+
Hz / seconds / cents into the new SfzRegion fields.
26+
27+
**SfzRegion fields.** `mod_lfo_freq_hz`, `mod_lfo_delay_s`,
28+
`mod_lfo_to_pitch_cents`, `vib_lfo_freq_hz`, `vib_lfo_delay_s`,
29+
`vib_lfo_to_pitch_cents`. Defaults: 8.176 Hz (SF2 spec
30+
default), 0 s delay, 0 cents depth — so regions without these
31+
generators are bit-identical to pre-LFO behaviour.
32+
33+
**Audio thread.** New `RegionLfos` Copy struct + per-slot
34+
state in `SampleInstrumentSlot`:
35+
- `mod_lfo_phase`, `mod_lfo_delay_remain_s`
36+
- `vib_lfo_phase`, `vib_lfo_delay_remain_s`
37+
38+
`fire_slot` resets phases + seeds delays from the region. In
39+
`process_slot`, after the existing rate calc + Mellotron flutter,
40+
each LFO advances its phase, decrements its delay, and emits a
41+
sin-wave cents offset (only after delay elapsed). Both depths
42+
sum into a single rate factor via the small-angle approximation
43+
`1 + cents · ln(2)/1200` (same trick the Mellotron flutter
44+
path uses; accurate to <0.01 % within the ±1200 cents clamp
45+
applied at trigger time).
46+
47+
**Tests.** +4: SF2 loader's `Generators::absorb` round-trips
48+
all 6 LFO + sample-modes opcodes (2 tests); audio-thread
49+
behaviour — modLfoToPitch perturbs the rendered output vs a
50+
zero-depth baseline; vibLfoToPitch's delay generator suppresses
51+
modulation early in the trace. Suite **2301 → 2305**.
52+
53+
This entry partially closes the SF2 wishlist line in PLAN.md
54+
(pitch targets shipped; filter / volume / mod envelope deferred).
55+
56+
Files: `src/audio/sf2_loader.rs`, `src/state/sfz.rs`,
57+
`src/audio/dsp/sample_instrument.rs`,
58+
`src/tests/sample_instrument_sfz_tests.rs`.
59+
60+
---
61+
762
### SF2 sample loop modes — per-region loop honour
863

964
The SF2 loader and SampleInstrument audio thread now honour per-

src/audio/dsp/sample_instrument.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

src/audio/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod audio_load;
77
pub mod dsp;
88
pub mod gr_levels;
99
pub mod onset;
10+
pub(crate) mod sf2_generators;
1011
pub mod sf2_loader;
1112
pub mod sfz_loader;
1213
pub mod spectrum;

0 commit comments

Comments
 (0)