From 5ff636ce9954f7d5733af4b774ad70d52752ed9a Mon Sep 17 00:00:00 2001 From: norbot7865 Date: Wed, 15 Jul 2026 08:31:59 +0000 Subject: [PATCH] Add smoothing and sample-accurate automation --- Makefile | 7 +- cmd/lgs/main.go | 7 + docs/automation.md | 64 +++++ examples/audio/README.md | 1 + examples/audio/issue-9-linear-automation.mp3 | Bin 0 -> 9956 bytes internal/audio/automation.go | 243 +++++++++++++++++++ internal/audio/automation_test.go | 42 ++++ internal/audio/engine.go | 75 ++++-- internal/audio/patch_update.go | 1 + internal/lisp/automation_bindings.go | 219 +++++++++++++++++ internal/lisp/automation_integration_test.go | 136 +++++++++++ internal/lisp/runtime.go | 3 + internal/nrepl/server_test.go | 26 +- internal/patch/compiler.go | 2 +- internal/patch/model.go | 1 + internal/scheduler/event.go | 39 +-- internal/scheduler/scheduler.go | 6 +- testdata/automation/linear-ramp.lg | 8 + 18 files changed, 836 insertions(+), 44 deletions(-) create mode 100644 docs/automation.md create mode 100644 examples/audio/issue-9-linear-automation.mp3 create mode 100644 internal/audio/automation.go create mode 100644 internal/audio/automation_test.go create mode 100644 internal/lisp/automation_bindings.go create mode 100644 internal/lisp/automation_integration_test.go create mode 100644 testdata/automation/linear-ramp.lg diff --git a/Makefile b/Makefile index 5a8a086..ac1c55b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ GO ?= go LGS := ./out/lgs -.PHONY: bootstrap build test test-race test-audio test-patch test-controls test-nrepl lint doctor render-fixtures analyze-fixtures acceptance clean +.PHONY: bootstrap build test test-race test-audio test-patch test-controls test-automation test-nrepl lint doctor render-fixtures analyze-fixtures acceptance clean bootstrap: ./scripts/bootstrap-fedora.sh @@ -49,6 +49,11 @@ test-controls: build mkdir -p out/fixtures $(LGS) render --input testdata/controls/exact-step.lg --output out/fixtures/control-step.wav --duration 2s --tail 0s --control-trace out/fixtures/control-step.json +test-automation: build + $(GO) test ./internal/audio ./internal/lisp + mkdir -p out/fixtures + $(LGS) render --input testdata/automation/linear-ramp.lg --output out/fixtures/automation-ramp.wav --duration 2s --tail 0s --automation-trace out/fixtures/automation-ramp.json + test-nrepl: $(GO) test ./internal/nrepl diff --git a/cmd/lgs/main.go b/cmd/lgs/main.go index 8c00a3b..27ba034 100644 --- a/cmd/lgs/main.go +++ b/cmd/lgs/main.go @@ -84,6 +84,7 @@ func renderCommand(args []string) int { trace := fs.String("event-trace", "", "event trace JSON output") patchTrace := fs.String("patch-trace", "", "patch update trace JSON output") controlTrace := fs.String("control-trace", "", "control event trace JSON output") + automationTrace := fs.String("automation-trace", "", "automation lane trace JSON output") _, level, _ := common(fs) if fs.Parse(args) != nil { return 2 @@ -147,6 +148,12 @@ func renderCommand(args []string) int { return 5 } } + if *automationTrace != "" { + if err = writeJSON(*automationTrace, a.Engine.AutomationTrace()); err != nil { + fmt.Fprintln(os.Stderr, err) + return 5 + } + } if *report != "" { w := &analysis.WAV{SampleRate: clock.SampleRate, Channels: 2, Format: 3, Bits: 32, Samples: buf} r, e := analysis.Analyze(w) diff --git a/docs/automation.md b/docs/automation.md new file mode 100644 index 0000000..78568de --- /dev/null +++ b/docs/automation.md @@ -0,0 +1,64 @@ +# Smoothing and automation + +Automation uses the same symbolic targets, persistent controlled VM, and exact-frame scheduler as `ctl`. Curves are built into Go; arbitrary Lisp callbacks never execute in the render path. + +## Smoothing + +Declare linear smoothing in seconds on a parameter: + +```clojure +:level {:default 32 :min 0 :max 128 :smoothing 0.01} +``` + +A direct control event preserves the old value at its scheduled frame and reaches the target after `round(seconds × 44100)` frames. Progress is monotonic, has no overshoot, and is evaluated independently of callback size. Zero smoothing is an exact step. + +## Ramps + +```clojure +(ramp :lead :cutoff 96 {:at 4 :dur 8 :curve :linear}) +(ramp :lead :cutoff 32 96 {:at 4 :dur 8 :curve :smoothstep}) +``` + +The first form reads the latest audio-thread-acknowledged value as its start. `:at` is an absolute beat and defaults to the current transport position. `:dur` is required and positive. + +Required curves: + +- `:linear`: `start + (end-start) × t` +- `:exponential`: `start × (end/start)^t`; both endpoints must be positive +- `:smoothstep`: uses `3t² - 2t³` +- `:hold`: retains the start until the final frame + +The start value is exact at `start-frame`, and the end is exact at `end-frame`. Values are evaluated once per sample while a lane is active. Endpoints must be finite and within the declared parameter range. + +Voice-scoped ramps accept a live note handle and must end before that note's reserved end frame. + +## Point automation + +```clojure +(automate :lead :cutoff + [{:beat 0 :value 32} + {:beat 2 :value 96 :curve :linear} + {:beat 6 :value 48 :curve :smoothstep}]) +``` + +Points are compiled control-side into adjacent ramp segments. Beats must strictly increase. A request is limited to 4096 points. + +## Cancellation and overlap + +```clojure +(def lane (ramp :lead :cutoff 32 96 {:dur 8})) +(cancel-automation! lane) +(cancel-automation! lane {:at 4}) +``` + +Cancellation retains the curve's exact value at the cancellation frame. Starting another lane for the same target replaces the prior authoritative lane at the newer lane's start frame; lanes are never implicitly summed. The engine bounds active lanes to 4096. + +## Tracing and determinism + +```bash +lgs render ... --automation-trace out/automation.json +``` + +The trace records start/end frames, curve, replacement, cancellation, and completion. Fixtures run at block sizes 64, 128, 256, 512, and 1024 and require sample-identical output and identical traces. + +Structured errors include `invalid-automation-duration`, `invalid-automation-curve`, `invalid-exponential-range`, `automation-limit-exceeded`, and `stale-automation-target`. diff --git a/examples/audio/README.md b/examples/audio/README.md index 25c9da9..1aafd30 100644 --- a/examples/audio/README.md +++ b/examples/audio/README.md @@ -2,3 +2,4 @@ - `issue-15-nrepl-a4-validation.mp3` — two-second offline render triggered by a raw nREPL `(play :sine :a4 {:at 0 :dur 1})` request. Generated by `TestNREPLPlayRendersAudio` using the real Sointu engine, then converted from float WAV with `ffmpeg -codec:a libmp3lame -q:a 4`. - `issue-8-exact-control-step.mp3` — `testdata/controls/exact-step.lg`; note-local velocity applies at frame 0 and instrument gain steps from 24 to 96 at frame 22050. Converted from the `make test-controls` float WAV with the same ffmpeg settings. +- `issue-9-linear-automation.mp3` — `testdata/automation/linear-ramp.lg`; a sample-accurate linear gain ramp from 20 to 100 over frames 0–44100. Converted from the `make test-automation` float WAV. diff --git a/examples/audio/issue-9-linear-automation.mp3 b/examples/audio/issue-9-linear-automation.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..de0c7c97618bef4159ec603373b53a08f3d00a68 GIT binary patch literal 9956 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 0 { + if event.Reset { + return e.resetVoiceControlLocked(voice, event.Instrument, target.key.parameter) + } + return e.setVoiceControlLocked(voice, event.Instrument, target.key.parameter, event.Value) + } + if event.Reset { + return e.resetInstrumentControlLocked(event.Instrument, target.key.parameter) + } + return e.setInstrumentControlLocked(event.Instrument, target.key.parameter, event.Value) + } + start := bindings[0].Default + if target.voice >= 0 { + if value, ok := e.controls.voiceValue[target.voice][target.key]; ok { + start = value + } + } else if value, ok := e.controls.explicit[target.key]; ok { + start = value + } + e.automation.smoothing[target] = &valueLane{target: target, startFrame: uint64(at), endFrame: uint64(at) + frames, startValue: start, endValue: event.Value, curve: "linear", kind: "smoothing"} + return nil +} + +func (e *Engine) startAutomation(event scheduler.Event, at clock.FrameIndex) error { + if len(e.automation.lanes) >= maxActiveAutomation { + return fmt.Errorf("automation-limit-exceeded") + } + target := e.automationTargetFor(event) + if len(e.controls.bindings[target.key]) == 0 { + return fmt.Errorf("stale-automation-target") + } + id := event.AutomationID + if id == 0 { + id = event.ID + } + if e.automation.cancelled[id] { + delete(e.automation.cancelled, id) + e.automation.record(AutomationTraceEvent{ID: id, Kind: "cancelled-before-start", Instrument: string(target.key.instrument), Parameter: string(target.key.parameter), Voice: target.voice, Frame: uint64(at)}) + return nil + } + if oldID, ok := e.automation.authoritative[target]; ok { + delete(e.automation.lanes, oldID) + e.automation.record(AutomationTraceEvent{ID: oldID, Kind: "replaced", Instrument: string(target.key.instrument), Parameter: string(target.key.parameter), Voice: target.voice, Frame: uint64(at)}) + } + lane := &valueLane{id: id, target: target, startFrame: uint64(at), endFrame: uint64(event.EndFrame), startValue: event.StartValue, endValue: event.EndValue, curve: event.Curve, kind: "automation"} + e.automation.lanes[id] = lane + e.automation.authoritative[target] = id + if len(e.automation.lanes) > e.automation.highWater { + e.automation.highWater = len(e.automation.lanes) + } + e.automation.record(AutomationTraceEvent{ID: id, Kind: "start", Instrument: string(target.key.instrument), Parameter: string(target.key.parameter), Voice: target.voice, StartFrame: lane.startFrame, EndFrame: lane.endFrame, Curve: lane.curve}) + return nil +} + +func (e *Engine) cancelAutomation(id uint64, at clock.FrameIndex) bool { + lane, ok := e.automation.lanes[id] + if !ok { + if len(e.automation.cancelled) >= maxActiveAutomation { + return false + } + e.automation.cancelled[id] = true + e.automation.cancellations++ + return true + } + // advanceControls has already produced the preceding sample. Evaluate and + // retain the exact cancellation-frame value before removing authority. + value, _ := laneValue(lane, uint64(at)) + _ = e.setTargetValue(lane.target, value) + delete(e.automation.lanes, id) + delete(e.automation.authoritative, lane.target) + e.automation.cancellations++ + e.automation.record(AutomationTraceEvent{ID: id, Kind: "cancel", Instrument: string(lane.target.key.instrument), Parameter: string(lane.target.key.parameter), Voice: lane.target.voice, Frame: uint64(at)}) + return true +} + +func laneValue(lane *valueLane, frame uint64) (float64, bool) { + if frame <= lane.startFrame { + return lane.startValue, false + } + if frame >= lane.endFrame { + return lane.endValue, true + } + position := float64(frame-lane.startFrame) / float64(lane.endFrame-lane.startFrame) + value, _ := EvaluateAutomationCurve(lane.curve, lane.startValue, lane.endValue, position) + return value, false +} + +func (e *Engine) setTargetValue(target automationTarget, value float64) error { + if target.voice >= 0 { + return e.setVoiceControlLocked(target.voice, target.key.instrument, target.key.parameter, value) + } + return e.setInstrumentControlLocked(target.key.instrument, target.key.parameter, value) +} + +func (e *Engine) advanceControls(frame uint64) { + for target, lane := range e.automation.smoothing { + if _, automated := e.automation.authoritative[target]; automated { + continue + } + value, done := laneValue(lane, frame) + _ = e.setTargetValue(target, value) + if done { + delete(e.automation.smoothing, target) + } + } + ids := make([]uint64, 0, len(e.automation.lanes)) + for id := range e.automation.lanes { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + for _, id := range ids { + lane := e.automation.lanes[id] + value, done := laneValue(lane, frame) + _ = e.setTargetValue(lane.target, value) + if done { + delete(e.automation.lanes, id) + delete(e.automation.authoritative, lane.target) + e.automation.record(AutomationTraceEvent{ID: id, Kind: "complete", Instrument: string(lane.target.key.instrument), Parameter: string(lane.target.key.parameter), Voice: lane.target.voice, Frame: frame}) + } + } +} + +func (a *automationState) active() bool { return len(a.smoothing) > 0 || len(a.lanes) > 0 } +func (a *automationState) record(event AutomationTraceEvent) { + a.traceMu.Lock() + if len(a.trace) < cap(a.trace) { + a.trace = append(a.trace, event) + } + a.traceMu.Unlock() +} + +func (e *Engine) AutomationTrace() AutomationTrace { + e.automation.traceMu.Lock() + defer e.automation.traceMu.Unlock() + return AutomationTrace{SampleRate: clock.SampleRate, Events: append([]AutomationTraceEvent(nil), e.automation.trace...)} +} diff --git a/internal/audio/automation_test.go b/internal/audio/automation_test.go new file mode 100644 index 0000000..89e277f --- /dev/null +++ b/internal/audio/automation_test.go @@ -0,0 +1,42 @@ +package audio + +import ( + "math" + "testing" +) + +func TestAutomationCurvesExactEndpoints(t *testing.T) { + curves := []string{"linear", "exponential", "smoothstep", "hold"} + for _, curve := range curves { + start, err := EvaluateAutomationCurve(curve, 2, 8, 0) + if err != nil || start != 2 { + t.Fatalf("%s start=%g err=%v", curve, start, err) + } + end, err := EvaluateAutomationCurve(curve, 2, 8, 1) + if err != nil || end != 8 { + t.Fatalf("%s end=%g err=%v", curve, end, err) + } + } + linear, _ := EvaluateAutomationCurve("linear", 2, 8, .5) + if linear != 5 { + t.Fatalf("linear midpoint %g", linear) + } + exponential, _ := EvaluateAutomationCurve("exponential", 2, 8, .5) + if math.Abs(exponential-4) > 1e-12 { + t.Fatalf("exponential midpoint %g", exponential) + } + smooth, _ := EvaluateAutomationCurve("smoothstep", 2, 8, .5) + if smooth != 5 { + t.Fatalf("smoothstep midpoint %g", smooth) + } + hold, _ := EvaluateAutomationCurve("hold", 2, 8, .999) + if hold != 2 { + t.Fatalf("hold intermediate %g", hold) + } + if _, err := EvaluateAutomationCurve("exponential", 0, 8, .5); err == nil { + t.Fatal("invalid exponential endpoint accepted") + } + if _, err := EvaluateAutomationCurve("custom", 2, 8, .5); err == nil { + t.Fatal("custom curve accepted") + } +} diff --git a/internal/audio/engine.go b/internal/audio/engine.go index 4e1d1ff..6e557ff 100644 --- a/internal/audio/engine.go +++ b/internal/audio/engine.go @@ -15,15 +15,17 @@ import ( ) type Stats struct { - FramesRendered uint64 `json:"frames_rendered"` - MaxSchedulerDepth int `json:"maximum_scheduler_queue_depth"` - ActiveVoiceHighWater int `json:"active_voice_high_water_mark"` - Underruns uint64 `json:"render_underruns"` - LateEvents uint64 `json:"late_events"` - DroppedEvents uint64 `json:"dropped_events"` - MaxRenderDuration time.Duration `json:"maximum_render_block_duration"` - ControlEventsApplied uint64 `json:"control_events_applied"` - ControlEventsRejected uint64 `json:"control_events_rejected"` + FramesRendered uint64 `json:"frames_rendered"` + MaxSchedulerDepth int `json:"maximum_scheduler_queue_depth"` + ActiveVoiceHighWater int `json:"active_voice_high_water_mark"` + Underruns uint64 `json:"render_underruns"` + LateEvents uint64 `json:"late_events"` + DroppedEvents uint64 `json:"dropped_events"` + MaxRenderDuration time.Duration `json:"maximum_render_block_duration"` + ControlEventsApplied uint64 `json:"control_events_applied"` + ControlEventsRejected uint64 `json:"control_events_rejected"` + ActiveAutomationHighWater int `json:"active_automation_high_water_mark"` + AutomationCancellations uint64 `json:"automation_cancellations"` } type Engine struct { @@ -47,6 +49,7 @@ type Engine struct { controlApplied atomic.Uint64 controlRejected atomic.Uint64 controls *controlState + automation *automationState } func NewEngine(provider instruments.PatchProvider, q *scheduler.Scheduler, bpm float64) (*Engine, error) { @@ -60,7 +63,7 @@ func NewEngine(provider instruments.PatchProvider, q *scheduler.Scheduler, bpm f }); ok { compiled = source.Compiled() } - e := &Engine{synth: s, scheduler: q, trace: make([]scheduler.TraceEvent, 0, 65536), patchTrace: make([]PatchUpdateTrace, 0, 128), layout: map[instruments.InstrumentID]instruments.Definition{}, controls: newControlState(compiled)} + e := &Engine{synth: s, scheduler: q, trace: make([]scheduler.TraceEvent, 0, 65536), patchTrace: make([]PatchUpdateTrace, 0, 128), layout: map[instruments.InstrumentID]instruments.Definition{}, controls: newControlState(compiled), automation: newAutomationState()} for _, definition := range provider.Instruments() { e.layout[definition.ID] = definition } @@ -79,7 +82,20 @@ func (e *Engine) Close() { } func (e *Engine) Frame() clock.FrameIndex { return clock.FrameIndex(e.frame.Load()) } -func (e *Engine) render(dst sointu.AudioBuffer) error { +func (e *Engine) render(dst sointu.AudioBuffer, start uint64) error { + if e.automation.active() { + for index := range dst { + e.advanceControls(start + uint64(index)) + n, _, err := e.synth.Render(dst[index:index+1], 1) + if err != nil { + return err + } + if n != 1 { + return fmt.Errorf("Sointu made no render progress") + } + } + return nil + } for len(dst) > 0 { n, _, err := e.synth.Render(dst, len(dst)) if err != nil { @@ -125,7 +141,7 @@ func (e *Engine) RenderBlock(dst sointu.AudioBuffer) error { } if uint64(ev.Frame) > cursor { n := int(uint64(ev.Frame) - cursor) - if err := e.render(dst[offset : offset+n]); err != nil { + if err := e.render(dst[offset:offset+n], cursor); err != nil { return err } offset += n @@ -141,7 +157,7 @@ func (e *Engine) RenderBlock(dst sointu.AudioBuffer) error { } } if offset < len(dst) { - if err := e.render(dst[offset:]); err != nil { + if err := e.render(dst[offset:], cursor); err != nil { return err } } @@ -150,7 +166,7 @@ func (e *Engine) RenderBlock(dst sointu.AudioBuffer) error { } func (e *Engine) apply(ev scheduler.Event, at clock.FrameIndex) { voice := int(ev.Voice) - if ev.Kind == scheduler.EventTrigger || ev.Kind == scheduler.EventRelease || (ev.Kind == scheduler.EventSetControl && ev.HandleID != 0) { + if ev.Kind == scheduler.EventTrigger || ev.Kind == scheduler.EventRelease || ((ev.Kind == scheduler.EventSetControl || ev.Kind == scheduler.EventStartAutomation) && ev.HandleID != 0) { definition, exists := e.layout[ev.Instrument] if !exists || ev.VoiceOffset < 0 || ev.VoiceOffset >= definition.Voices { e.dropped.Add(1) @@ -175,7 +191,6 @@ func (e *Engine) apply(ev scheduler.Event, at clock.FrameIndex) { e.owners[voice] = 0 } case scheduler.EventSetControl: - parameter := patchmodel.ParameterID(ev.Parameter) var err error if ev.HandleID != 0 { if e.owners[voice] != ev.HandleID { @@ -184,16 +199,8 @@ func (e *Engine) apply(ev scheduler.Event, at clock.FrameIndex) { e.recordEvent(ev, at, voice, "set-control-stale") return } - if ev.Reset { - err = e.resetVoiceControlLocked(voice, ev.Instrument, parameter) - } else { - err = e.setVoiceControlLocked(voice, ev.Instrument, parameter, ev.Value) - } - } else if ev.Reset { - err = e.resetInstrumentControlLocked(ev.Instrument, parameter) - } else { - err = e.setInstrumentControlLocked(ev.Instrument, parameter, ev.Value) } + err = e.applyScheduledControl(ev, at, voice) if err != nil { e.dropped.Add(1) e.controlRejected.Add(1) @@ -201,6 +208,22 @@ func (e *Engine) apply(ev scheduler.Event, at clock.FrameIndex) { return } e.controlApplied.Add(1) + case scheduler.EventStartAutomation: + if ev.HandleID != 0 && e.owners[voice] != ev.HandleID { + e.dropped.Add(1) + e.recordEvent(ev, at, voice, "start-automation-stale") + return + } + if err := e.startAutomation(ev, at); err != nil { + e.dropped.Add(1) + e.recordEvent(ev, at, voice, "start-automation-failed") + return + } + case scheduler.EventCancelAutomation: + if !e.cancelAutomation(ev.AutomationID, at) { + e.recordEvent(ev, at, voice, "cancel-automation-inactive") + return + } case scheduler.EventStopAll: for i, id := range e.owners { if id != 0 { @@ -228,6 +251,8 @@ func (e *Engine) Trace(block int) scheduler.Trace { return scheduler.Trace{SampleRate: clock.SampleRate, BlockSize: block, Events: events} } func (e *Engine) Stats(a *instruments.Allocator) Stats { + e.renderMu.Lock() + defer e.renderMu.Unlock() m, o := e.scheduler.Stats() - return Stats{FramesRendered: e.frame.Load(), MaxSchedulerDepth: m, ActiveVoiceHighWater: a.HighWater(), LateEvents: e.late.Load(), DroppedEvents: o + e.dropped.Load(), MaxRenderDuration: time.Duration(e.maxRender.Load()), ControlEventsApplied: e.controlApplied.Load(), ControlEventsRejected: e.controlRejected.Load()} + return Stats{FramesRendered: e.frame.Load(), MaxSchedulerDepth: m, ActiveVoiceHighWater: a.HighWater(), LateEvents: e.late.Load(), DroppedEvents: o + e.dropped.Load(), MaxRenderDuration: time.Duration(e.maxRender.Load()), ControlEventsApplied: e.controlApplied.Load(), ControlEventsRejected: e.controlRejected.Load(), ActiveAutomationHighWater: e.automation.highWater, AutomationCancellations: e.automation.cancellations} } diff --git a/internal/audio/patch_update.go b/internal/audio/patch_update.go index 014ac4b..411ad5c 100644 --- a/internal/audio/patch_update.go +++ b/internal/audio/patch_update.go @@ -66,6 +66,7 @@ func (e *Engine) UpdatePatch(compiled *patchmodel.CompiledPatch, bpm float64, ch } e.layout = make(map[patchmodel.InstrumentID]instruments.Definition, len(compiled.Layout.Instruments)) e.controls = newControlState(compiled) + e.automation = newAutomationState() for id, instrument := range compiled.Layout.Instruments { e.layout[id] = instruments.Definition{ID: id, FirstVoice: instruments.VoiceID(instrument.FirstVoice), Voices: instrument.NumVoices} } diff --git a/internal/lisp/automation_bindings.go b/internal/lisp/automation_bindings.go new file mode 100644 index 0000000..7e120bf --- /dev/null +++ b/internal/lisp/automation_bindings.go @@ -0,0 +1,219 @@ +package lisp + +import ( + "fmt" + "math" + + "github.com/example/letgo-sointu/internal/clock" + patchmodel "github.com/example/letgo-sointu/internal/patch" + "github.com/example/letgo-sointu/internal/scheduler" + "github.com/nooga/let-go/pkg/vm" +) + +func (r *Runtime) installAutomationBindings() error { + bindings := map[string]func([]vm.Value) (vm.Value, error){ + "ramp": r.rampFn, "automate": r.automateFn, "cancel-automation!": r.cancelAutomationFn, + } + for name, function := range bindings { + native, err := vm.NativeFnType.Wrap(function) + if err != nil { + return err + } + r.lg.Def(name, native) + } + return nil +} + +func (r *Runtime) rampFn(args []vm.Value) (vm.Value, error) { + if len(args) != 4 && len(args) != 5 { + return vm.NIL, fmt.Errorf("ramp expects target, parameter, [start], end, options") + } + parameterName, err := keywordName(args[1], "automation parameter") + if err != nil { + return vm.NIL, err + } + optionsIndex := len(args) - 1 + options, err := mapEntries(args[optionsIndex]) + if err != nil { + return vm.NIL, fmt.Errorf("ramp options: %w", err) + } + startBeat := r.transport.BeatAt(r.engine.Frame()) + if value := options["at"]; value != nil { + startBeat, err = beatValue(value) + if err != nil || startBeat.Sign() < 0 { + return vm.NIL, fmt.Errorf("ramp :at must be a nonnegative beat") + } + } + durationValue := options["dur"] + if durationValue == nil { + return vm.NIL, fmt.Errorf("invalid-automation-duration: ramp requires :dur") + } + duration, err := beatValue(durationValue) + if err != nil || duration.Sign() <= 0 { + return vm.NIL, fmt.Errorf("invalid-automation-duration: ramp :dur must be positive") + } + endBeat, err := startBeat.Add(duration) + if err != nil { + return vm.NIL, err + } + startFrame, err := r.transport.FrameAt(startBeat) + if err != nil { + return vm.NIL, err + } + endFrame, err := r.transport.FrameAt(endBeat) + if err != nil || endFrame <= startFrame { + return vm.NIL, fmt.Errorf("invalid-automation-duration: duration resolves to no frames") + } + curve := "linear" + if value := options["curve"]; value != nil { + curve, err = keywordName(value, "automation curve") + if err != nil { + return vm.NIL, err + } + } + if curve != "linear" && curve != "exponential" && curve != "smoothstep" && curve != "hold" { + return vm.NIL, fmt.Errorf("invalid-automation-curve: :%s", curve) + } + target, err := r.parseControlTarget(args[0], uint64(r.engine.Frame())) + if err != nil { + return vm.NIL, err + } + descriptor, err := r.controlDescriptor(target, patchmodel.ParameterID(parameterName)) + if err != nil { + return vm.NIL, err + } + endValueIndex := 2 + if len(args) == 5 { + endValueIndex = 3 + } + endValue, err := numValue(args[endValueIndex]) + if err != nil || math.IsNaN(endValue) || math.IsInf(endValue, 0) { + return vm.NIL, fmt.Errorf("invalid-control-value: automation endpoint must be finite") + } + startValue := descriptor.Default + if len(args) == 5 { + startValue, err = numValue(args[2]) + if err != nil || math.IsNaN(startValue) || math.IsInf(startValue, 0) { + return vm.NIL, fmt.Errorf("invalid-control-value: automation endpoint must be finite") + } + } else if target.handle == nil { + startValue, _, err = r.engine.ControlValue(target.instrument, patchmodel.ParameterID(parameterName)) + if err != nil { + return vm.NIL, err + } + } else if value, ok := r.engine.VoiceControlValue(int(target.handle.Voice), target.instrument, patchmodel.ParameterID(parameterName)); ok { + startValue = value + } + if startValue < descriptor.Minimum || startValue > descriptor.Maximum || endValue < descriptor.Minimum || endValue > descriptor.Maximum { + return vm.NIL, fmt.Errorf("control-out-of-range: automation :%s endpoints require %g..%g", parameterName, descriptor.Minimum, descriptor.Maximum) + } + if curve == "exponential" && (startValue <= 0 || endValue <= 0) { + return vm.NIL, fmt.Errorf("invalid-exponential-range: endpoints must be positive") + } + if target.handle != nil && uint64(endFrame) > target.handle.EndFrame { + return vm.NIL, fmt.Errorf("stale-automation-target: automation extends beyond note end") + } + event := scheduler.Event{Frame: startFrame, EndFrame: endFrame, Kind: scheduler.EventStartAutomation, Instrument: target.instrument, Parameter: parameterName, StartValue: startValue, EndValue: endValue, Curve: curve, Generation: uint64(r.engine.PatchGeneration())} + if target.handle != nil { + event.Voice, event.VoiceOffset, event.HandleID, event.Generation = target.handle.Voice, r.voiceOffset(*target.handle), target.handle.EventID, target.handle.Generation + } + added, err := r.queue.Add(event) + if err != nil { + return vm.NIL, fmt.Errorf("automation-limit-exceeded: %w", err) + } + return mapOf(vm.Keyword("automation"), vm.Int(added.ID), vm.Keyword("target"), vm.Keyword(target.instrument), vm.Keyword("parameter"), vm.Keyword(parameterName), vm.Keyword("start-frame"), vm.Int(startFrame), vm.Keyword("end-frame"), vm.Int(endFrame), vm.Keyword("curve"), vm.Keyword(curve)), nil +} + +func (r *Runtime) cancelAutomationFn(args []vm.Value) (vm.Value, error) { + if len(args) < 1 || len(args) > 2 { + return vm.NIL, fmt.Errorf("cancel-automation! expects handle and optional options") + } + entries, err := mapEntries(args[0]) + if err != nil { + return vm.NIL, fmt.Errorf("automation handle: %w", err) + } + id, ok := entries["automation"].(vm.Int) + if !ok || id <= 0 { + return vm.NIL, fmt.Errorf("automation handle requires positive :automation") + } + frame := r.engine.Frame() + if len(args) == 2 { + options, err := mapEntries(args[1]) + if err != nil { + return vm.NIL, err + } + if value := options["at"]; value != nil { + beat, err := beatValue(value) + if err != nil || beat.Sign() < 0 { + return vm.NIL, fmt.Errorf("cancel-automation! :at must be nonnegative") + } + frame, err = r.transport.FrameAt(beat) + if err != nil { + return vm.NIL, err + } + } + } + added, err := r.queue.Add(scheduler.Event{Frame: frame, Kind: scheduler.EventCancelAutomation, AutomationID: uint64(id)}) + if err != nil { + return vm.NIL, fmt.Errorf("automation-limit-exceeded: %w", err) + } + return mapOf(vm.Keyword("automation"), id, vm.Keyword("cancel-event"), vm.Int(added.ID), vm.Keyword("scheduled-frame"), vm.Int(frame)), nil +} + +func (r *Runtime) automateFn(args []vm.Value) (vm.Value, error) { + if len(args) != 3 { + return vm.NIL, fmt.Errorf("automate expects target, parameter, and points") + } + points, ok := args[2].(vm.Sequable) + if !ok { + return vm.NIL, fmt.Errorf("automate points must be a collection") + } + type point struct { + beat clock.Beat + value vm.Value + curve string + } + parsed := []point{} + for sequence := points.Seq(); sequence != nil && sequence != vm.EmptyList; sequence = sequence.Next() { + entries, err := mapEntries(sequence.First()) + if err != nil { + return vm.NIL, err + } + rawBeat := entries["beat"] + if rawBeat == nil || entries["value"] == nil { + return vm.NIL, fmt.Errorf("invalid automation point") + } + beat, err := beatValue(rawBeat) + if err != nil || beat.Sign() < 0 { + return vm.NIL, fmt.Errorf("invalid automation point") + } + curve := "linear" + if rawCurve := entries["curve"]; rawCurve != nil { + curve, err = keywordName(rawCurve, "automation point curve") + if err != nil { + return vm.NIL, err + } + } + parsed = append(parsed, point{beat, entries["value"], curve}) + if len(parsed) > 4096 { + return vm.NIL, fmt.Errorf("automation-limit-exceeded: too many points") + } + } + if len(parsed) < 2 { + return vm.NIL, fmt.Errorf("automate requires at least two points") + } + results := make([]vm.Value, 0, len(parsed)-1) + for index := 0; index < len(parsed)-1; index++ { + duration, err := parsed[index+1].beat.Sub(parsed[index].beat) + if err != nil || duration.Sign() <= 0 { + return vm.NIL, fmt.Errorf("invalid-automation-duration: points must increase") + } + options := mapOf(vm.Keyword("at"), beatVM(parsed[index].beat), vm.Keyword("dur"), beatVM(duration), vm.Keyword("curve"), vm.Keyword(parsed[index+1].curve)) + result, err := r.rampFn([]vm.Value{args[0], args[1], parsed[index].value, parsed[index+1].value, options}) + if err != nil { + return vm.NIL, err + } + results = append(results, result) + } + return vm.NewPersistentVector(results), nil +} diff --git a/internal/lisp/automation_integration_test.go b/internal/lisp/automation_integration_test.go new file mode 100644 index 0000000..73a5766 --- /dev/null +++ b/internal/lisp/automation_integration_test.go @@ -0,0 +1,136 @@ +package lisp_test + +import ( + "io" + "strings" + "testing" + + "github.com/example/letgo-sointu/internal/app" + "github.com/example/letgo-sointu/internal/audio" +) + +func TestRampEndpointsCancellationOverlapAndBlockInvariance(t *testing.T) { + render := func(block int) ([][2]float32, audio.AutomationTrace) { + a, err := app.New(io.Discard, io.Discard) + if err != nil { + t.Fatal(err) + } + defer a.Close() + if _, err = a.Lisp.Eval(controlledTone); err != nil { + t.Fatal(err) + } + if _, err = a.Lisp.Eval(`(play :controlled-tone :a4 {:at 0 :dur 4})`); err != nil { + t.Fatal(err) + } + result, err := a.Lisp.Eval(`(ramp :controlled-tone :level 32 96 {:at 0 :dur 1 :curve :smoothstep})`) + if err != nil || !strings.Contains(result.String(), ":end-frame 22050") { + t.Fatalf("ramp=%s err=%v", result, err) + } + buffer, err := audio.RenderOffline(a.Engine, 44100, block) + if err != nil { + t.Fatal(err) + } + value, _, err := a.Engine.ControlValue("controlled-tone", "level") + if err != nil || value != 96 { + t.Fatalf("endpoint value=%g err=%v", value, err) + } + return buffer, a.Engine.AutomationTrace() + } + baseline, trace := render(64) + if len(trace.Events) != 2 || trace.Events[0].Kind != "start" || trace.Events[0].StartFrame != 0 || trace.Events[0].EndFrame != 22050 || trace.Events[1].Kind != "complete" || trace.Events[1].Frame != 22050 { + t.Fatalf("automation trace %#v", trace.Events) + } + for _, block := range []int{128, 256, 512, 1024} { + candidate, candidateTrace := render(block) + if len(candidateTrace.Events) != len(trace.Events) { + t.Fatalf("block %d trace mismatch", block) + } + for index := range baseline { + if baseline[index] != candidate[index] { + t.Fatalf("block %d differs at frame %d", block, index) + } + } + } + + a, err := app.New(io.Discard, io.Discard) + if err != nil { + t.Fatal(err) + } + defer a.Close() + if _, err = a.Lisp.Eval(controlledTone); err != nil { + t.Fatal(err) + } + if _, err = a.Lisp.Eval(`(def lane (ramp :controlled-tone :level 32 96 {:at 0 :dur 2}))`); err != nil { + t.Fatal(err) + } + if _, err = a.Lisp.Eval(`(cancel-automation! lane {:at 1/2})`); err != nil { + t.Fatal(err) + } + if _, err = audio.RenderOffline(a.Engine, 22051, 257); err != nil { + t.Fatal(err) + } + value, _, _ := a.Engine.ControlValue("controlled-tone", "level") + if value != 48 { // quarter of a 32 -> 96 two-beat ramp + t.Fatalf("cancellation value=%g want 48", value) + } + cancelTrace := a.Engine.AutomationTrace() + if len(cancelTrace.Events) != 2 || cancelTrace.Events[1].Kind != "cancel" || cancelTrace.Events[1].Frame != 11025 { + t.Fatalf("cancel trace %#v", cancelTrace.Events) + } + if _, err = a.Lisp.Eval(`(ramp :controlled-tone :level 48 80 {:dur 1})`); err != nil { + t.Fatal(err) + } + if _, err = a.Lisp.Eval(`(ramp :controlled-tone :level 48 64 {:dur 1 :curve :hold})`); err != nil { + t.Fatal(err) + } + if _, err = audio.RenderOffline(a.Engine, 1, 1); err != nil { + t.Fatal(err) + } + overlapTrace := a.Engine.AutomationTrace().Events + foundReplacement := false + for _, event := range overlapTrace { + foundReplacement = foundReplacement || event.Kind == "replaced" + } + if !foundReplacement { + t.Fatalf("overlap did not replace prior lane: %#v", overlapTrace) + } +} + +func TestSmoothingAndAutomationValidation(t *testing.T) { + a, err := app.New(io.Discard, io.Discard) + if err != nil { + t.Fatal(err) + } + defer a.Close() + definition := `(defsynth smooth-tone {:voices 1 :params {:level {:default 32 :min 0 :max 128 :smoothing 0.01}}} + (oscillator {:type :sine}) (out {:gain (param :level)}))` + if _, err = a.Lisp.Eval(definition); err != nil { + t.Fatal(err) + } + if _, err = a.Lisp.Eval(`(ctl :smooth-tone :level 96)`); err != nil { + t.Fatal(err) + } + if _, err = audio.RenderOffline(a.Engine, 1, 1); err != nil { + t.Fatal(err) + } + start, _, _ := a.Engine.ControlValue("smooth-tone", "level") + if start != 32 { + t.Fatalf("smoothing start=%g", start) + } + if _, err = audio.RenderOffline(a.Engine, 441, 64); err != nil { + t.Fatal(err) + } + end, _, _ := a.Engine.ControlValue("smooth-tone", "level") + if end != 96 { + t.Fatalf("smoothing end=%g", end) + } + for _, form := range []string{ + `(ramp :smooth-tone :level 96 {:dur 0})`, + `(ramp :smooth-tone :level 0 96 {:dur 1 :curve :exponential})`, + `(ramp :smooth-tone :level 96 {:dur 1 :curve :custom})`, + } { + if _, err = a.Lisp.Eval(form); err == nil { + t.Fatalf("invalid automation accepted: %s", form) + } + } +} diff --git a/internal/lisp/runtime.go b/internal/lisp/runtime.go index 15a01b7..5de5518 100644 --- a/internal/lisp/runtime.go +++ b/internal/lisp/runtime.go @@ -95,6 +95,9 @@ func New(engine *audio.Engine, t *clock.Transport, q *scheduler.Scheduler, a *in if err := r.installControlBindings(); err != nil { return nil, fmt.Errorf("install control API: %w", err) } + if err := r.installAutomationBindings(); err != nil { + return nil, fmt.Errorf("install automation API: %w", err) + } return r, nil } func (r *Runtime) Eval(src string) (vm.Value, error) { diff --git a/internal/nrepl/server_test.go b/internal/nrepl/server_test.go index cbb362e..a0cce62 100644 --- a/internal/nrepl/server_test.go +++ b/internal/nrepl/server_test.go @@ -129,9 +129,9 @@ func TestEvalSessionNamespaceAndMusicAPI(t *testing.T) { t.Fatalf("session namespace did not persist: %#v", responses) } - definition := `(defsynth remote-tone {:voices 2} + definition := `(defsynth remote-tone {:voices 2 :params {:level {:default 32 :min 0 :max 128}}} (oscillator {:type :sine}) - (out {:gain 72}))` + (out {:gain (param :level)}))` responses = c.request(t, map[string]any{"op": "load-file", "id": "load-1", "session": sessionID, "file": "(in-ns 'music.core)\n" + definition}) if errText := responseField(responses, "err"); errText != "" { t.Fatalf("remote defsynth: %s", errText) @@ -143,6 +143,28 @@ func TestEvalSessionNamespaceAndMusicAPI(t *testing.T) { if !found { t.Fatal("nREPL evaluation did not reach process-global music API") } + responses = c.request(t, map[string]any{"op": "eval", "id": "ctl-1", "session": sessionID, "code": `(ctl :remote-tone :level 90)`}) + if errText := responseField(responses, "err"); errText != "" { + t.Fatalf("remote ctl: %s", errText) + } + if _, renderErr := audio.RenderOffline(a.Engine, 1, 1); renderErr != nil { + t.Fatal(renderErr) + } + trace := a.Engine.ControlTrace() + if len(trace.Events) != 1 || trace.Events[0].Parameter != "level" || trace.Events[0].Value != 90 { + t.Fatalf("remote ctl did not reach scheduler: %#v", trace.Events) + } + responses = c.request(t, map[string]any{"op": "eval", "id": "ramp-1", "session": sessionID, "code": `(ramp :remote-tone :level 90 40 {:dur 1 :curve :linear})`}) + if errText := responseField(responses, "err"); errText != "" { + t.Fatalf("remote ramp: %s", errText) + } + if _, renderErr := audio.RenderOffline(a.Engine, 22051, 256); renderErr != nil { + t.Fatal(renderErr) + } + automationTrace := a.Engine.AutomationTrace() + if len(automationTrace.Events) != 2 || automationTrace.Events[0].Kind != "start" || automationTrace.Events[1].Kind != "complete" { + t.Fatalf("remote ramp did not reach automation evaluator: %#v", automationTrace.Events) + } } func TestNREPLPlayRendersAudio(t *testing.T) { diff --git a/internal/patch/compiler.go b/internal/patch/compiler.go index f9214db..a25d65c 100644 --- a/internal/patch/compiler.go +++ b/internal/patch/compiler.go @@ -155,7 +155,7 @@ func (c *Compiler) Compile(input PatchSpec) (*CompiledPatch, error) { if !ok { return nil, &CompileError{[]Diagnostic{diag("control-binding-missing", instrument.ID, unitIndex, unit.ID, name, "Sointu control operand was not generated")}} } - result.Bindings = append(result.Bindings, ControlBinding{Index: len(result.Bindings), InstrumentID: instrument.ID, ParameterID: reference.Parameter, UnitID: unit.ID, UnitIndex: unitIndex, UnitParameter: name, Operand: operand, Scope: descriptor.Scope, Minimum: descriptor.Minimum, Maximum: descriptor.Maximum, Default: descriptor.Default, Transform: reference.Transform}) + result.Bindings = append(result.Bindings, ControlBinding{Index: len(result.Bindings), InstrumentID: instrument.ID, ParameterID: reference.Parameter, UnitID: unit.ID, UnitIndex: unitIndex, UnitParameter: name, Operand: operand, Scope: descriptor.Scope, Minimum: descriptor.Minimum, Maximum: descriptor.Maximum, Default: descriptor.Default, Smoothing: descriptor.Smoothing, Transform: reference.Transform}) } } } diff --git a/internal/patch/model.go b/internal/patch/model.go index fe9852a..20ee5c1 100644 --- a/internal/patch/model.go +++ b/internal/patch/model.go @@ -160,6 +160,7 @@ type ControlBinding struct { Minimum float64 `json:"min"` Maximum float64 `json:"max"` Default float64 `json:"default"` + Smoothing float64 `json:"smoothing"` Transform ParameterTransform `json:"transform"` } diff --git a/internal/scheduler/event.go b/internal/scheduler/event.go index 6dbdc40..e854bab 100644 --- a/internal/scheduler/event.go +++ b/internal/scheduler/event.go @@ -13,6 +13,8 @@ const ( EventSetTempo EventStopAll EventSetControl + EventStartAutomation + EventCancelAutomation ) func (k EventKind) String() string { @@ -27,26 +29,35 @@ func (k EventKind) String() string { return "stop-all" case EventSetControl: return "set-control" + case EventStartAutomation: + return "start-automation" + case EventCancelAutomation: + return "cancel-automation" default: return "unknown" } } type Event struct { - ID uint64 - Frame clock.FrameIndex - Sequence uint64 - Kind EventKind - Instrument instruments.InstrumentID - Voice instruments.VoiceID // generation-specific voice at scheduling time (diagnostics) - VoiceOffset int // stable offset within the symbolic instrument - Generation uint64 - Note uint8 - Tempo float64 - HandleID uint64 - Parameter string - Value float64 - Reset bool + ID uint64 + Frame clock.FrameIndex + Sequence uint64 + Kind EventKind + Instrument instruments.InstrumentID + Voice instruments.VoiceID // generation-specific voice at scheduling time (diagnostics) + VoiceOffset int // stable offset within the symbolic instrument + Generation uint64 + Note uint8 + Tempo float64 + HandleID uint64 + Parameter string + Value float64 + Reset bool + AutomationID uint64 + EndFrame clock.FrameIndex + StartValue float64 + EndValue float64 + Curve string } type TraceEvent struct { diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 72d7992..24a5a7b 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -30,8 +30,12 @@ func eventPriority(event Event) int { return 3 case EventTrigger: return 2 - case EventStopAll: + case EventCancelAutomation: + return 3 + case EventStartAutomation: return 4 + case EventStopAll: + return 5 default: return 2 } diff --git a/testdata/automation/linear-ramp.lg b/testdata/automation/linear-ramp.lg new file mode 100644 index 0000000..aff27dd --- /dev/null +++ b/testdata/automation/linear-ramp.lg @@ -0,0 +1,8 @@ +(defsynth automation-tone + {:voices 2 + :params {:level {:default 20 :min 1 :max 128}}} + (oscillator {:type :sine}) + (out {:gain (param :level)})) + +(play :automation-tone :a4 {:at 0 :dur 4}) +(ramp :automation-tone :level 20 100 {:at 0 :dur 2 :curve :linear})