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 0000000..de0c7c9 Binary files /dev/null and b/examples/audio/issue-9-linear-automation.mp3 differ diff --git a/internal/audio/automation.go b/internal/audio/automation.go new file mode 100644 index 0000000..0e63fef --- /dev/null +++ b/internal/audio/automation.go @@ -0,0 +1,243 @@ +package audio + +import ( + "fmt" + "math" + "sort" + "sync" + + "github.com/example/letgo-sointu/internal/clock" + patchmodel "github.com/example/letgo-sointu/internal/patch" + "github.com/example/letgo-sointu/internal/scheduler" +) + +const maxActiveAutomation = 4096 + +type automationTarget struct { + key controlKey + voice int // -1 means instrument scope +} + +type valueLane struct { + id uint64 + target automationTarget + startFrame uint64 + endFrame uint64 + startValue float64 + endValue float64 + curve string + kind string + cancelledAtFrame uint64 +} + +type AutomationTraceEvent struct { + ID uint64 `json:"id"` + Kind string `json:"kind"` + Instrument string `json:"instrument"` + Parameter string `json:"parameter"` + Voice int `json:"voice,omitempty"` + StartFrame uint64 `json:"start_frame"` + EndFrame uint64 `json:"end_frame"` + Curve string `json:"curve,omitempty"` + Frame uint64 `json:"frame,omitempty"` +} + +type AutomationTrace struct { + SampleRate int `json:"sample_rate"` + Events []AutomationTraceEvent `json:"events"` +} + +type automationState struct { + smoothing map[automationTarget]*valueLane + lanes map[uint64]*valueLane + authoritative map[automationTarget]uint64 + cancelled map[uint64]bool + traceMu sync.Mutex + trace []AutomationTraceEvent + highWater int + cancellations uint64 +} + +func newAutomationState() *automationState { + return &automationState{smoothing: map[automationTarget]*valueLane{}, lanes: map[uint64]*valueLane{}, authoritative: map[automationTarget]uint64{}, cancelled: map[uint64]bool{}, trace: make([]AutomationTraceEvent, 0, 1024)} +} + +func EvaluateAutomationCurve(curve string, start, end, position float64) (float64, error) { + position = min(1, max(0, position)) + switch curve { + case "linear": + return start + (end-start)*position, nil + case "exponential": + if start <= 0 || end <= 0 { + return 0, fmt.Errorf("invalid-exponential-range: endpoints must be positive") + } + return start * math.Pow(end/start, position), nil + case "smoothstep": + s := position * position * (3 - 2*position) + return start + (end-start)*s, nil + case "hold": + if position < 1 { + return start, nil + } + return end, nil + default: + return 0, fmt.Errorf("invalid-automation-curve: %s", curve) + } +} + +func (e *Engine) automationTargetFor(event scheduler.Event) automationTarget { + voice := -1 + if event.HandleID != 0 { + voice = int(event.Voice) + if definition, ok := e.layout[event.Instrument]; ok { + voice = int(definition.FirstVoice) + event.VoiceOffset + } + } + return automationTarget{key: controlKey{event.Instrument, patchmodel.ParameterID(event.Parameter)}, voice: voice} +} + +func (e *Engine) applyScheduledControl(event scheduler.Event, at clock.FrameIndex, voice int) error { + target := e.automationTargetFor(event) + bindings := e.controls.bindings[target.key] + if len(bindings) == 0 { + return fmt.Errorf("unknown-control") + } + frames := uint64(math.Round(bindings[0].Smoothing * clock.SampleRate)) + if event.Reset || frames == 0 { + delete(e.automation.smoothing, target) + if target.voice >= 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})