Skip to content

Commit 05a7879

Browse files
committed
Add scoped LFO modulation for synths and samplers
1 parent 75bf0f6 commit 05a7879

14 files changed

Lines changed: 944 additions & 25 deletions

‎README.md‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,15 @@ disable Python DSP to prevent a fallback. Modulated sample voices still resolve
5555
controls one frame at a time so evaluation stops at source exhaustion. This API
5656
allocates per call and does not establish live callback deadline guarantees.
5757

58-
Decoding remains with the caller. Curved envelopes, named generators, filters,
58+
Named seconds-clock LFOs now drive amplitude and tuning in both instruments.
59+
They support sine, square, and triangle shapes, delay/fade-in, and voice, part,
60+
or instrument ownership as allowed by uFor. Shared sources continue through
61+
silence, and snapshots retain their exact phase anchors. Standalone
62+
`enge.lfo.lfo_samples()` also samples canonical uFor rate/reset event states.
63+
Prepared instrument traces do not yet carry addressed LFO events; their LFO
64+
rates remain authored settings. See the [LFO contract](plan/lfo-numerics.md).
65+
66+
Decoding remains with the caller. Curved envelopes, named envelopes, filters,
5967
layer crossfades, delayed or offset sample starts, event bindings, other
6068
modulation targets, and fade
6169
retirements fail explicitly. The PyTorch backend is not yet implemented.
@@ -98,3 +106,9 @@ waveform, writes WAV artifacts, and verifies lossless FLAC encoding using the
98106
`.pytest_cache/d/audio/synth-demo-native.flac`.
99107
Completed demos are published with Reccy's shared `atomic_output` helper, so a
100108
failed copy preserves the previous demo. Reccy is a development dependency.
109+
110+
Run `uv run pytest test/test_lfo_demo.py` for a two-second vibrato/tremolo demo,
111+
with a sine synth on the left and a sampled harmonic tone on the right. Both
112+
receive a live gain change. Listen to `.pytest_cache/d/audio/lfo-demo-numpy.flac`
113+
or `.pytest_cache/d/audio/lfo-demo-native.flac`; the test compares independent
114+
audio oracles and verifies lossless encoding before publishing either file.

‎plan/engine-execution.md‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,20 @@ The current implementation in `src/enge/synth.py` provides the first dynamic
2121
synth reference: held linear envelopes, explicit routes, minimum hold, phase
2222
synchronization, static tuning, and live amplitude/tuning control routes. It
2323
consumes uFor trigger contexts, retains scoped smoothing trajectories, and
24-
restores them with voice state. Other modulation targets, named generators,
25-
and filters are not implemented. The Rust backend realizes
24+
restores them with voice state. Named seconds-clock LFOs now drive the same
25+
amplitude/tuning routes; other targets, named envelopes, and filters remain
26+
unimplemented. The Rust backend realizes
2627
the same numerical synth profile through explicit `backend="native"` selection;
2728
`"numpy"` remains the default. PyTorch compilation remains future work.
2829
Existing waveform start/length/period behavior remains a regression requirement
2930
during synth consolidation.
3031

31-
`src/enge/sampler.py` now implements the NumPy source-traversal core with linear
32+
`src/enge/sampler.py` implements NumPy and Rust source traversal with linear
3233
interpolation, shared immutable decoded audio, live pitch arrays, fractional
3334
effective releases, and serializable cursor state. `SampleVoiceRenderer` and
3435
`src/enge/sample_instrument.py` now integrate shared envelope timing, scoped
35-
controls, routing, and prepared uFor actions. The Rust sampler is next.
36+
controls, routing, and prepared uFor actions. Both instrument renderers now share
37+
scoped LFO sources under the [LFO numerical contract](lfo-numerics.md).
3638

3739
## Ownership and existing contracts
3840

@@ -238,7 +240,8 @@ is still distinct from another voice's source, even when both use one template.
238240
| Logical gate and retirement | Exact prepared actions; never smoothed with expression controls. Sustain decisions remain in uFor. |
239241
| Envelope durations derived from key/velocity | Latched at onset, following the existing uFor contract. |
240242
| Envelope structure, waveform shape/duty, sample identity, slice/loop bounds, direction, output layout, routing topology | Fixed for the prepared voice definition; no active-voice mutation API in this profile. |
241-
| LFOs, filters, richer automation, and structural transitions | Separate declared extensions with their own dynamic conformance cases before support is enabled. |
243+
| LFOs | Seconds-clock value/weight realization is implemented; addressed instrument rate/reset actions remain a future uFor extension. |
244+
| Filters, richer automation, and structural transitions | Separate declared extensions with their own dynamic conformance cases before support is enabled. |
242245

243246
This is an initial support boundary, not a claim that structural edits can never
244247
be supported. Such edits need explicit state transfer or crossfade semantics.
@@ -517,7 +520,17 @@ Implementation status and remaining order:
517520
Sampler snapshots reject backend mismatches even when silent. Modulated
518521
voices retain scalar Python control resolution to stop at natural exhaustion;
519522
this milestone does not claim real-time callback performance.
520-
5. Further generators, processing, and structural changes one specified feature
523+
5. Named seconds-clock LFOs now render through NumPy and Rust with shared
524+
conformance. They retain uFor's exact phase anchors, rate/reset semantics,
525+
separate activation weight, and scope ownership. Both synth and sampler
526+
consume them through existing amplitude/tuning routes and restore their
527+
source identities. Tests cover silent shared sources, release tails, exact
528+
duty ties, fractional timing, and large clocks. A two-second FLAC regression
529+
demonstrates tremolo, vibrato, and live gain on synth and sampled tones.
530+
The standalone source API accepts canonical LFO event states; prepared
531+
instrument traces still need a portable addressed rate/reset action before
532+
those events can be delivered through `advance`. See [details](lfo-numerics.md).
533+
6. Further generators, processing, and structural changes one specified feature
521534
at a time. Do not introduce a generic DSP graph or host to complete these steps.
522535

523536
### Phase consolidation timing check, 2026-09-16

‎plan/lfo-numerics.md‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# LFO realization
2+
3+
## Supported profile
4+
5+
enge renders uFor's seconds-clock LFOs for both synths and samplers, selecting
6+
NumPy or Rust with the instrument's existing `backend` argument. Sine, square,
7+
and triangle shapes retain the canonical duty-cycle equations, including duty
8+
zero and one. LFO values are bipolar. Delay and fade-in affect a separate
9+
activation weight, never the phase clock.
10+
11+
`enge.lfo.lfo_samples(definition, state, start, frames, sample_rate, backend)`
12+
returns a float64 `(frames, 2)` array containing value and weight. `state` is the
13+
canonical `ufor.lfo.LFOState`. Frames address the same absolute seconds clock as
14+
its rational event anchors. Rendering observes state without re-anchoring it.
15+
Use `ufor.lfo.initial_lfo` and `ufor.lfo.lfo_event` to initialize and apply events;
16+
serialize the canonical state to restore a standalone source.
17+
18+
Beat clocks require a host tempo mapping and remain explicitly unsupported in
19+
this audio profile. Named envelopes and other modulation targets remain outside
20+
this milestone.
21+
22+
## Timing and numerical boundaries
23+
24+
At time `t`, phase is exactly `(state.phase + state.rate * (t-state.at)) mod 1`.
25+
A rate event advances using the previous rate before installing the next one.
26+
It preserves phase and activation age. Zero rate freezes phase. Rate changes are
27+
piecewise constant; continuous rate ramps are not part of uFor's current LFO
28+
contract. An event affects observations at its time and later. For a fractional
29+
event time, render the earlier integer observations with the previous state,
30+
apply the event, then render from the first integer observation at or after it.
31+
32+
Reset policies follow uFor: `free` ignores trigger/transport events, `trigger`
33+
responds to trigger, and `transport` responds to explicit transport events. An
34+
explicit reset always resets phase and activation age, retaining current rate.
35+
A render boundary is never a reset or transport event.
36+
37+
The NumPy reference samples uFor's independent scalar equations. Rust evaluates
38+
waveforms and activation arithmetic in a detached numerical loop with owned
39+
input and output arrays. Python resolves rational phase, wrap/duty boundaries,
40+
and activation boundaries before crossing the binding. Square polarity is
41+
resolved before converting to float. Triangle spans use a normalized coordinate
42+
within the selected branch, avoiding cancellation when duty approaches zero or
43+
one. Complete unobserved cycles are skipped using exact rational reduction.
44+
The numerical kernel receives arrays, without Pydantic or Fraction operations.
45+
46+
Phase continues during delay and fade-in. At delay's endpoint weight is zero
47+
when a fade exists, otherwise one. During fade it increases linearly to one.
48+
Route evaluation applies weight after mapping: addition fades from zero;
49+
multiplication fades from one. Rust bounds final observations to their declared
50+
bipolar/unit domains to contain floating-point roundoff at endpoints.
51+
52+
## Instrument ownership and event boundary
53+
54+
LFO names are local to a sound-setting definition. Repeated bindings to one
55+
named source observe the same state. Voice scope creates a fresh source at the
56+
voice's start frame. Instrument and part sources use frame zero as their clock
57+
origin and continue algebraically through silent intervals. A joining voice does
58+
not send a trigger event to an existing shared source. Part sources are separate
59+
for each part; instrument instances never share state. Sampler slot/group
60+
ownership follows uFor validation; shared sampler LFOs belong in instrument
61+
settings. Instrument and slot modulation still compose per voice before mixing.
62+
63+
Both instrument snapshots include LFO anchors and their owner identities,
64+
alongside existing control ramps and voice-to-source references. A new onset
65+
using an old trigger ID cannot reassign a release tail's voice LFO.
66+
67+
Prepared uFor instrument traces currently contain lifecycle and control actions,
68+
not addressed LFO rate/reset events. Instrument integration therefore realizes
69+
authored LFO settings and voice/shared ownership. Dynamic rate, trigger,
70+
transport, and explicit reset events are supported through the standalone
71+
canonical state API. Exposing those events through prepared instrument traces
72+
requires a later uFor contract extension; this implementation adds no private
73+
trace vocabulary and does not reinterpret control observations as LFO events.
74+
75+
## Acceptance
76+
77+
Shared tests compare float arrays before writing at least one second of 48 kHz
78+
WAV audio, using `atol=1e-10`, `rtol=1e-9`. Coverage includes shapes/duty endpoints,
79+
fractional delay/fade boundaries, irregular partitions, rational rate events,
80+
zero rate, all reset policies, exact duty ties, large frame coordinates, scoped
81+
silent advancement, release tails, reused trigger IDs, and JSON restores.
82+
Native checks disable scalar reference evaluation and reject invalid span layouts.
83+
84+
The two-second listening regression combines vibrato, tremolo, and a live gain
85+
ramp: a sine synth on the left and a sampled harmonic tone on the right. Expected
86+
pitch is integrated independently at higher precision; the sampler oracle uses
87+
linear interpolation of the supplied asset. Both backends publish verified
88+
lossless FLAC through reccy's existing atomic-output helper. This establishes
89+
numerical behavior, not live callback performance.
90+
91+
## Additional work beyond the prompt
92+
93+
None.

‎src/enge/_native.pyi‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ from typing import Literal
22

33
import numpy as np
44

5+
def render_lfo(
6+
waveform: int, phases: np.ndarray, envelope: np.ndarray, frames: int
7+
) -> np.ndarray: ...
58
def render(
69
waveform: int,
710
duty: float,

‎src/enge/lfo.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Sample uFor LFO observations without changing their exact event anchors."""
2+
3+
from fractions import Fraction
4+
from math import ceil
5+
from typing import Literal
6+
7+
import numpy as np
8+
from ufor import lfo
9+
from ufor.oscillator import Waveform
10+
11+
12+
def lfo_samples(
13+
definition: lfo.LFO,
14+
state: lfo.LFOState,
15+
start: int,
16+
frames: int,
17+
sample_rate: int,
18+
backend: Literal["numpy", "native"] = "numpy",
19+
) -> np.ndarray:
20+
"""Return bipolar value and activation weight columns on the seconds clock.
21+
22+
Use uFor's lfo_event to apply rate/reset events before their addressed sample.
23+
Rate changes retain phase and activation age. Rendering does not re-anchor
24+
state, so silent intervals, partitions, and restores share the same timeline.
25+
"""
26+
if definition.clock != "seconds" or sample_rate <= 0 or frames < 0:
27+
raise ValueError(
28+
"LFO rendering requires a seconds clock and valid frame layout"
29+
)
30+
at = Fraction(start, sample_rate)
31+
if at < state.at or at < state.started_at:
32+
raise ValueError("LFO query precedes its current state")
33+
if backend == "numpy":
34+
observations = [
35+
lfo.lfo_at(definition, state, Fraction(start + i, sample_rate))
36+
for i in range(frames)
37+
]
38+
return np.array(
39+
[[v.value, v.weight] for v in observations], dtype=np.float64
40+
).reshape(frames, 2)
41+
if backend != "native":
42+
raise ValueError(f"Unknown LFO backend: {backend}")
43+
from . import _native
44+
45+
# Resolve discontinuities exactly before converting phase arithmetic to float.
46+
# Spans never straddle a wrap or duty edge. This also handles rates above the
47+
# output rate without iterating through unobserved cycles.
48+
phase = lfo.phase_at(state, at)
49+
step = (state.rate / sample_rate) % 1
50+
spans: list[list[float]] = []
51+
first = 0
52+
while first < frames:
53+
rising = phase < definition.duty_cycle
54+
boundary = definition.duty_cycle if rising else Fraction(1)
55+
count = (
56+
min(frames - first, ceil((boundary - phase) / step))
57+
if step
58+
else frames - first
59+
)
60+
origin, increment = phase, step
61+
if definition.waveform == Waveform.triangle:
62+
width = definition.duty_cycle if rising else 1 - definition.duty_cycle
63+
origin = (
64+
phase / width if rising else (phase - definition.duty_cycle) / width
65+
)
66+
increment = step / width if count > 1 else Fraction(0)
67+
spans.append(
68+
[first, first + count, float(origin), float(increment), float(rising)]
69+
)
70+
phase = (phase + count * step) % 1
71+
first += count
72+
age = at - state.started_at - definition.delay
73+
weights: list[list[float]] = [[0, frames, 1, 0, 0, 1, 0]]
74+
delayed = min(frames, max(0, ceil(-age * sample_rate)))
75+
weights.append([0, delayed, 0, 0, 0, 1, 0])
76+
if definition.fade_in:
77+
last = min(frames, ceil((definition.fade_in - age) * sample_rate))
78+
if delayed < last:
79+
weights.append(
80+
[
81+
delayed,
82+
last,
83+
float((age + Fraction(delayed, sample_rate)) / definition.fade_in),
84+
float(1 / (definition.fade_in * sample_rate)),
85+
0,
86+
1,
87+
delayed,
88+
]
89+
)
90+
return _native.render_lfo(
91+
[Waveform.sine, Waveform.square, Waveform.triangle].index(definition.waveform),
92+
np.array(spans, dtype=np.float64).reshape(-1, 5),
93+
np.array(weights, dtype=np.float64),
94+
frames,
95+
)

‎src/enge/sample_instrument.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class SamplerSnapshot(Model, frozen=True):
3636
frame: int
3737
voices: list[SampleVoiceSnapshot]
3838
contexts: list[synth.ControlContext]
39+
lfos: list[synth.LFOSource]
3940
backend: Literal["numpy", "native"]
4041

4142

@@ -133,6 +134,7 @@ def __init__(
133134
definition.sample_rate,
134135
definition.document.body.settings.controls,
135136
[definition.document.body.settings, *definition.settings.values()],
137+
backend,
136138
)
137139

138140
def advance(
@@ -157,6 +159,7 @@ def snapshot(self) -> SamplerSnapshot:
157159
frame=self.frame,
158160
voices=list(self.voices.values()),
159161
contexts=self.controls.contexts,
162+
lfos=self.controls.lfos,
160163
backend=self.backend,
161164
).model_copy(deep=True)
162165

@@ -176,6 +179,7 @@ def restore(self, snapshot: SamplerSnapshot) -> None:
176179
self.frame = snapshot.frame
177180
self.voices = {v.voice_id: v for v in snapshot.voices}
178181
self.controls.contexts = snapshot.contexts
182+
self.controls.lfos = snapshot.lfos
179183

180184
def _apply(self, action: instrument_trace.TraceAction) -> None:
181185
if self.controls.apply(action) or isinstance(
@@ -327,10 +331,7 @@ def _validate_settings(settings: processing.SoundSettings) -> None:
327331
raise synth.EngineError(
328332
"Only sample volume and tuning processing are implemented"
329333
)
330-
if settings.envelopes or settings.lfos:
331-
raise synth.EngineError("Named sample generators are not implemented")
332-
if any(not isinstance(b, processing.ControlBinding) for b in settings.bindings):
333-
raise synth.EngineError("Only control bindings are implemented")
334+
synth.validate_generators(settings)
334335
if any(
335336
p.target.name != "processing"
336337
or p.target.parameter not in ("amplitude", "tuning_cents")

0 commit comments

Comments
 (0)