diff --git a/Makefile b/Makefile index b84a2b2..5a8a086 100644 --- a/Makefile +++ b/Makefile @@ -43,9 +43,11 @@ test-patch: build ! $(LGS) patch validate --input testdata/synths/invalid-stack.lg ! $(LGS) patch validate --input testdata/synths/invalid-routing.lg -test-controls: +test-controls: build $(GO) test ./internal/patch ./internal/audio ./internal/lisp cd third_party/sointu && $(GO) test ./vm + 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-nrepl: $(GO) test ./internal/nrepl diff --git a/cmd/lgs/main.go b/cmd/lgs/main.go index a9f43ff..8c00a3b 100644 --- a/cmd/lgs/main.go +++ b/cmd/lgs/main.go @@ -83,6 +83,7 @@ func renderCommand(args []string) int { report := fs.String("report", "", "analysis JSON output") 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") _, level, _ := common(fs) if fs.Parse(args) != nil { return 2 @@ -140,6 +141,12 @@ func renderCommand(args []string) int { return 5 } } + if *controlTrace != "" { + if err = writeJSON(*controlTrace, a.Engine.ControlTrace()); 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/controls.md b/docs/controls.md new file mode 100644 index 0000000..2663dea --- /dev/null +++ b/docs/controls.md @@ -0,0 +1,66 @@ +# Named controls + +Synth parameters are declared in `defsynth` options and referenced from settable/modulatable Sointu unit parameters with `param`: + +```clojure +(defsynth controlled-tone + {:voices 4 + :params {:level {:default 32 :min 0 :max 128 :scope :instrument} + :velocity {:default 100 :min 0 :max 127 :scope :voice}}} + (oscillator {:type :sine + :gain (param :velocity)}) + (out {:gain (param :level)})) +``` + +Optional descriptor keys are `:min`, `:max`, `:scope`, `:smoothing`, `:curve`, `:units`, and `:doc`. Defaults are 0, 128, `:instrument`, 0, and `:linear`. Values and ranges must be finite; defaults must be in range. Supported scopes are `:instrument` and `:voice`. + +A reference can apply a fixed transform: + +```clojure +(param :tone {:scale 0.5 :offset 32 :clamp true}) +``` + +One name may be referenced by multiple units. References compile to generation-specific immutable VM operand bindings, while user APIs retain symbolic synth/parameter IDs. + +## Direct controls + +```clojure +(ctl :controlled-tone :level 80) +(ctl :controlled-tone {:level 80}) +(ctl :controlled-tone :level 96 {:at 4}) +(ctl :controlled-tone :level 200 {:clamp true}) +``` + +`:at` is an absolute beat. Events split render blocks and apply at the exact frame. Values outside the declared range are errors unless `:clamp true` is explicit. NaN and infinity are always rejected. An ordinary control event changes persistent Go VM operands and never recompiles or calls `Synth.Update`. + +Current audio-thread-acknowledged values and descriptors are available with: + +```clojure +(control-value :controlled-tone :level) +(controls :controlled-tone) +(reset-control! :controlled-tone :level) +``` + +Reset removes the explicit override and restores the declared/compiled default. + +## Voice controls and per-note values + +Voice parameters require a live note handle: + +```clojure +(def note (play :controlled-tone :a4 + {:dur 2 :params {:velocity 110}})) +(ctl note :velocity 72) +(control-value note :velocity) +(reset-control! note :velocity) +``` + +Per-note values are validated before scheduling and are applied after trigger setup but before that frame's first rendered sample. Reused voices clear old local overrides. Every command carries the note handle ID and symbolic instrument/parameter; the audio engine checks current voice ownership, so stale handles cannot alter a replacement note. + +At one frame the scheduler orders releases, instrument controls, triggers, note-local controls, then stop operations. Sequence order breaks ties within one category. + +## Tracing + +`lgs render --control-trace FILE` writes scheduled/applied frames, symbolic parameter, value, generation, target, and result. Block sizes 64 through 1024 produce identical control traces and audio in the control fixtures. + +Structured error prefixes include `unknown-control`, `control-scope-mismatch`, `control-out-of-range`, `invalid-control-value`, `stale-control-target`, `control-binding-missing`, and `control-queue-full`. diff --git a/examples/audio/README.md b/examples/audio/README.md index 216cadf..25c9da9 100644 --- a/examples/audio/README.md +++ b/examples/audio/README.md @@ -1,3 +1,4 @@ # Validation audio - `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. diff --git a/examples/audio/issue-8-exact-control-step.mp3 b/examples/audio/issue-8-exact-control-step.mp3 new file mode 100644 index 0000000..2fb3e9d Binary files /dev/null and b/examples/audio/issue-8-exact-control-step.mp3 differ diff --git a/internal/audio/control_trace.go b/internal/audio/control_trace.go new file mode 100644 index 0000000..55f0f18 --- /dev/null +++ b/internal/audio/control_trace.go @@ -0,0 +1,25 @@ +package audio + +import ( + "strings" + + "github.com/example/letgo-sointu/internal/clock" + "github.com/example/letgo-sointu/internal/scheduler" +) + +type ControlTrace struct { + SampleRate int `json:"sample_rate"` + Events []scheduler.TraceEvent `json:"events"` +} + +func (e *Engine) ControlTrace() ControlTrace { + e.traceMu.Lock() + defer e.traceMu.Unlock() + result := ControlTrace{SampleRate: clock.SampleRate, Events: make([]scheduler.TraceEvent, 0)} + for _, event := range e.trace { + if strings.HasPrefix(event.Kind, "set-control") { + result.Events = append(result.Events, event) + } + } + return result +} diff --git a/internal/audio/controlled_synth.go b/internal/audio/controlled_synth.go index 79d6ee3..5421817 100644 --- a/internal/audio/controlled_synth.go +++ b/internal/audio/controlled_synth.go @@ -62,6 +62,10 @@ func transformedControl(binding patchmodel.ControlBinding, value float64) (float func (e *Engine) SetInstrumentControl(instrument patchmodel.InstrumentID, parameter patchmodel.ParameterID, value float64) error { e.renderMu.Lock() defer e.renderMu.Unlock() + return e.setInstrumentControlLocked(instrument, parameter, value) +} + +func (e *Engine) setInstrumentControlLocked(instrument patchmodel.InstrumentID, parameter patchmodel.ParameterID, value float64) error { key := controlKey{instrument, parameter} bindings := e.controls.bindings[key] if len(bindings) == 0 { @@ -90,6 +94,10 @@ func (e *Engine) SetInstrumentControl(instrument patchmodel.InstrumentID, parame func (e *Engine) SetVoiceControl(voice int, instrument patchmodel.InstrumentID, parameter patchmodel.ParameterID, value float64) error { e.renderMu.Lock() defer e.renderMu.Unlock() + return e.setVoiceControlLocked(voice, instrument, parameter, value) +} + +func (e *Engine) setVoiceControlLocked(voice int, instrument patchmodel.InstrumentID, parameter patchmodel.ParameterID, value float64) error { key := controlKey{instrument, parameter} bindings := e.controls.bindings[key] if len(bindings) == 0 { @@ -118,6 +126,48 @@ func (e *Engine) SetVoiceControl(voice int, instrument patchmodel.InstrumentID, return nil } +func (e *Engine) resetInstrumentControlLocked(instrument patchmodel.InstrumentID, parameter patchmodel.ParameterID) error { + key := controlKey{instrument, parameter} + bindings := e.controls.bindings[key] + if len(bindings) == 0 { + return fmt.Errorf("unknown-control: synth :%s has no parameter :%s", instrument, parameter) + } + vmSynth, ok := e.synth.(controlledSynth) + if !ok { + return fmt.Errorf("control-binding-missing: active synth does not support persistent controls") + } + for _, binding := range bindings { + if binding.Scope != patchmodel.ScopeInstrument { + return fmt.Errorf("control-scope-mismatch: :%s is :%s scoped", parameter, binding.Scope) + } + vmSynth.ClearInstrumentControl(binding.Operand) + } + delete(e.controls.explicit, key) + return nil +} + +func (e *Engine) resetVoiceControlLocked(voice int, instrument patchmodel.InstrumentID, parameter patchmodel.ParameterID) error { + key := controlKey{instrument, parameter} + bindings := e.controls.bindings[key] + if len(bindings) == 0 { + return fmt.Errorf("unknown-control: synth :%s has no parameter :%s", instrument, parameter) + } + vmSynth, ok := e.synth.(controlledSynth) + if !ok { + return fmt.Errorf("control-binding-missing: active synth does not support persistent controls") + } + for _, binding := range bindings { + if binding.Scope != patchmodel.ScopeVoice { + return fmt.Errorf("control-scope-mismatch: :%s is :%s scoped", parameter, binding.Scope) + } + vmSynth.ClearControl(voice, binding.Operand) + } + if values := e.controls.voiceValue[voice]; values != nil { + delete(values, key) + } + return nil +} + func (e *Engine) ControlValue(instrument patchmodel.InstrumentID, parameter patchmodel.ParameterID) (float64, patchmodel.ControlScope, error) { e.renderMu.Lock() defer e.renderMu.Unlock() @@ -132,6 +182,17 @@ func (e *Engine) ControlValue(instrument patchmodel.InstrumentID, parameter patc return bindings[0].Default, bindings[0].Scope, nil } +func (e *Engine) VoiceControlValue(voice int, instrument patchmodel.InstrumentID, parameter patchmodel.ParameterID) (float64, bool) { + e.renderMu.Lock() + defer e.renderMu.Unlock() + values := e.controls.voiceValue[voice] + if values == nil { + return 0, false + } + value, ok := values[controlKey{instrument, parameter}] + return value, ok +} + func (e *Engine) ControlBindings() []patchmodel.ControlBinding { e.renderMu.Lock() defer e.renderMu.Unlock() diff --git a/internal/audio/engine.go b/internal/audio/engine.go index 0a15def..4e1d1ff 100644 --- a/internal/audio/engine.go +++ b/internal/audio/engine.go @@ -15,13 +15,15 @@ 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"` + 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"` } type Engine struct { @@ -42,6 +44,8 @@ type Engine struct { late atomic.Uint64 dropped atomic.Uint64 maxRender atomic.Int64 + controlApplied atomic.Uint64 + controlRejected atomic.Uint64 controls *controlState } @@ -146,7 +150,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 { + if ev.Kind == scheduler.EventTrigger || ev.Kind == scheduler.EventRelease || (ev.Kind == scheduler.EventSetControl && ev.HandleID != 0) { definition, exists := e.layout[ev.Instrument] if !exists || ev.VoiceOffset < 0 || ev.VoiceOffset >= definition.Voices { e.dropped.Add(1) @@ -170,6 +174,33 @@ func (e *Engine) apply(ev scheduler.Event, at clock.FrameIndex) { e.synth.Release(voice) 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 { + e.dropped.Add(1) + e.controlRejected.Add(1) + 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) + } + if err != nil { + e.dropped.Add(1) + e.controlRejected.Add(1) + e.recordEvent(ev, at, voice, "set-control-failed") + return + } + e.controlApplied.Add(1) case scheduler.EventStopAll: for i, id := range e.owners { if id != 0 { @@ -184,7 +215,7 @@ func (e *Engine) apply(ev scheduler.Event, at clock.FrameIndex) { func (e *Engine) recordEvent(ev scheduler.Event, at clock.FrameIndex, voice int, kind string) { e.traceMu.Lock() if len(e.trace) < cap(e.trace) { - e.trace = append(e.trace, scheduler.TraceEvent{ID: ev.ID, Kind: kind, Instrument: string(ev.Instrument), Voice: voice, Note: ev.Note, ScheduledFrame: uint64(ev.Frame), AppliedFrame: uint64(at)}) + e.trace = append(e.trace, scheduler.TraceEvent{ID: ev.ID, Kind: kind, Instrument: string(ev.Instrument), Voice: voice, Note: ev.Note, Parameter: ev.Parameter, Value: ev.Value, Generation: ev.Generation, ScheduledFrame: uint64(ev.Frame), AppliedFrame: uint64(at)}) } else { e.dropped.Add(1) } @@ -198,5 +229,5 @@ func (e *Engine) Trace(block int) scheduler.Trace { } func (e *Engine) Stats(a *instruments.Allocator) Stats { 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())} + 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()} } diff --git a/internal/instruments/voices.go b/internal/instruments/voices.go index ff9e125..b6a161d 100644 --- a/internal/instruments/voices.go +++ b/internal/instruments/voices.go @@ -106,6 +106,20 @@ func (a *Allocator) Release(id uint64, at uint64) (NoteHandle, bool) { delete(a.handles, id) return r.handle, true } +func (a *Allocator) Handle(id uint64, at uint64) (NoteHandle, bool) { + a.mu.Lock() + defer a.mu.Unlock() + v, ok := a.handles[id] + if !ok { + return NoteHandle{}, false + } + r := a.voices[v] + if !r.valid || r.handle.EventID != id || r.handle.EndFrame <= at { + return NoteHandle{}, false + } + return r.handle, true +} + func (a *Allocator) Valid(id uint64, at uint64) bool { a.mu.Lock() defer a.mu.Unlock() diff --git a/internal/lisp/control_bindings.go b/internal/lisp/control_bindings.go new file mode 100644 index 0000000..fbf02c6 --- /dev/null +++ b/internal/lisp/control_bindings.go @@ -0,0 +1,256 @@ +package lisp + +import ( + "fmt" + "math" + "sort" + + "github.com/example/letgo-sointu/internal/instruments" + patchmodel "github.com/example/letgo-sointu/internal/patch" + "github.com/example/letgo-sointu/internal/scheduler" + "github.com/nooga/let-go/pkg/vm" +) + +type controlTarget struct { + instrument instruments.InstrumentID + handle *instruments.NoteHandle +} + +func (r *Runtime) installControlBindings() error { + bindings := map[string]func([]vm.Value) (vm.Value, error){ + "ctl": r.ctlFn, "control-value": r.controlValueFn, "controls": r.controlsFn, "reset-control!": r.resetControlFn, + } + 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) parseControlTarget(value vm.Value, at uint64) (controlTarget, error) { + if handleMap, ok := value.(*vm.PersistentMap); ok { + id, idOK := handleMap.ValueAt(vm.Keyword("id")).(vm.Int) + if idOK { + handle, valid := r.allocator.Handle(uint64(id), at) + if !valid { + return controlTarget{}, fmt.Errorf("stale-control-target: note handle is no longer active") + } + return controlTarget{instrument: handle.Instrument, handle: &handle}, nil + } + } + instrument, err := r.instrumentValue(value) + return controlTarget{instrument: instrument}, err +} + +func (r *Runtime) controlDescriptor(target controlTarget, parameter patchmodel.ParameterID) (patchmodel.SynthParameter, error) { + definition, ok := r.patchRegistry.Definition(target.instrument) + if !ok { + return patchmodel.SynthParameter{}, fmt.Errorf("unknown instrument :%s", target.instrument) + } + descriptor, ok := definition.Parameters[parameter] + if !ok { + return patchmodel.SynthParameter{}, fmt.Errorf("unknown-control: synth :%s has no parameter :%s", target.instrument, parameter) + } + if target.handle == nil && descriptor.Scope != patchmodel.ScopeInstrument { + return patchmodel.SynthParameter{}, fmt.Errorf("control-scope-mismatch: :%s is voice scoped and requires a note handle", parameter) + } + if target.handle != nil && descriptor.Scope != patchmodel.ScopeVoice { + return patchmodel.SynthParameter{}, fmt.Errorf("control-scope-mismatch: :%s is instrument scoped", parameter) + } + return descriptor, nil +} + +func (r *Runtime) ctlFn(args []vm.Value) (vm.Value, error) { + if len(args) < 2 || len(args) > 4 { + return vm.NIL, fmt.Errorf("ctl expects target, parameter/value or controls map, and optional options") + } + if controls, ok := args[1].(*vm.PersistentMap); ok { + if len(args) > 3 { + return vm.NIL, fmt.Errorf("ctl map form expects target, controls map, and optional options") + } + options := vm.Value(vm.EmptyPersistentMap) + if len(args) == 3 { + options = args[2] + } + entries, err := mapEntries(controls) + if err != nil { + return vm.NIL, err + } + names := make([]string, 0, len(entries)) + for name := range entries { + names = append(names, name) + } + sort.Strings(names) + results := []vm.Value{} + for _, name := range names { + result, err := r.ctlFn([]vm.Value{args[0], vm.Keyword(name), entries[name], options}) + if err != nil { + return vm.NIL, err + } + results = append(results, result) + } + return vm.NewPersistentVector(results), nil + } + if len(args) < 3 { + return vm.NIL, fmt.Errorf("ctl expects a value") + } + parameterName, err := keywordName(args[1], "control parameter") + if err != nil { + return vm.NIL, err + } + value, err := numValue(args[2]) + if err != nil || math.IsNaN(value) || math.IsInf(value, 0) { + return vm.NIL, fmt.Errorf("invalid-control-value: control value must be finite") + } + frame := r.engine.Frame() + clamp := false + if len(args) == 4 { + options, err := mapEntries(args[3]) + if err != nil { + return vm.NIL, fmt.Errorf("ctl options: %w", err) + } + if at := options["at"]; at != nil { + beat, err := beatValue(at) + if err != nil || beat.Sign() < 0 { + return vm.NIL, fmt.Errorf("ctl :at must be a nonnegative beat") + } + frame, err = r.transport.FrameAt(beat) + if err != nil { + return vm.NIL, err + } + } + if option := options["clamp"]; option != nil { + flag, ok := option.(vm.Boolean) + if !ok { + return vm.NIL, fmt.Errorf("ctl :clamp must be boolean") + } + clamp = bool(flag) + } + } + 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 + } + if target.handle != nil && uint64(frame) >= target.handle.EndFrame { + return vm.NIL, fmt.Errorf("stale-control-target: scheduled control is not before the note end frame") + } + if value < descriptor.Minimum || value > descriptor.Maximum { + if !clamp { + return vm.NIL, fmt.Errorf("control-out-of-range: :%s requires %g..%g, got %g", parameterName, descriptor.Minimum, descriptor.Maximum, value) + } + value = min(descriptor.Maximum, max(descriptor.Minimum, value)) + } + event := scheduler.Event{Frame: frame, Kind: scheduler.EventSetControl, Instrument: target.instrument, Parameter: parameterName, Value: value, Generation: uint64(r.engine.PatchGeneration())} + if target.handle != nil { + event.Voice = target.handle.Voice + event.VoiceOffset = r.voiceOffset(*target.handle) + event.HandleID = target.handle.EventID + event.Generation = target.handle.Generation + } + added, err := r.queue.Add(event) + if err != nil { + return vm.NIL, fmt.Errorf("control-queue-full: %w", err) + } + return mapOf(vm.Keyword("control-event"), vm.Int(added.ID), vm.Keyword("target"), vm.Keyword(target.instrument), vm.Keyword("parameter"), vm.Keyword(parameterName), vm.Keyword("value"), vm.Float(value), vm.Keyword("scheduled-frame"), vm.Int(frame)), nil +} + +func (r *Runtime) voiceOffset(handle instruments.NoteHandle) int { + for _, definition := range r.provider.Instruments() { + if definition.ID == handle.Instrument { + return int(handle.Voice - definition.FirstVoice) + } + } + return int(handle.Voice) +} + +func (r *Runtime) controlValueFn(args []vm.Value) (vm.Value, error) { + if len(args) != 2 { + return vm.NIL, fmt.Errorf("control-value expects target and parameter") + } + parameterName, err := keywordName(args[1], "control parameter") + if err != nil { + return vm.NIL, err + } + 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 + } + value := descriptor.Default + source := "default" + if target.handle == nil { + value, _, err = r.engine.ControlValue(target.instrument, patchmodel.ParameterID(parameterName)) + if err != nil { + return vm.NIL, err + } + if value != descriptor.Default { + source = "explicit" + } + } else if explicit, ok := r.engine.VoiceControlValue(int(target.handle.Voice), target.instrument, patchmodel.ParameterID(parameterName)); ok { + value, source = explicit, "explicit" + } + return mapOf(vm.Keyword("value"), vm.Float(value), vm.Keyword("target"), vm.Keyword(target.instrument), vm.Keyword("parameter"), vm.Keyword(parameterName), vm.Keyword("frame"), vm.Int(r.engine.Frame()), vm.Keyword("generation"), vm.Int(r.engine.PatchGeneration()), vm.Keyword("source"), vm.Keyword(source)), nil +} + +func (r *Runtime) controlsFn(args []vm.Value) (vm.Value, error) { + if len(args) != 1 { + return vm.NIL, fmt.Errorf("controls expects one synth") + } + instrument, err := r.instrumentValue(args[0]) + if err != nil { + return vm.NIL, err + } + definition, _ := r.patchRegistry.Definition(instrument) + ids := make([]string, 0, len(definition.Parameters)) + for id := range definition.Parameters { + ids = append(ids, string(id)) + } + sort.Strings(ids) + values := make([]vm.Value, 0, len(ids)) + for _, rawID := range ids { + descriptor := definition.Parameters[patchmodel.ParameterID(rawID)] + current := vm.Value(vm.NIL) + if descriptor.Scope == patchmodel.ScopeInstrument { + value, _, _ := r.engine.ControlValue(instrument, descriptor.ID) + current = vm.Float(value) + } + values = append(values, mapOf(vm.Keyword("id"), vm.Keyword(rawID), vm.Keyword("default"), vm.Float(descriptor.Default), vm.Keyword("min"), vm.Float(descriptor.Minimum), vm.Keyword("max"), vm.Float(descriptor.Maximum), vm.Keyword("scope"), vm.Keyword(descriptor.Scope), vm.Keyword("current"), current, vm.Keyword("smoothing"), vm.Float(descriptor.Smoothing))) + } + return vm.NewPersistentVector(values), nil +} + +func (r *Runtime) resetControlFn(args []vm.Value) (vm.Value, error) { + if len(args) != 2 { + return vm.NIL, fmt.Errorf("reset-control! expects target and parameter") + } + parameterName, err := keywordName(args[1], "control parameter") + if err != nil { + return vm.NIL, err + } + target, err := r.parseControlTarget(args[0], uint64(r.engine.Frame())) + if err != nil { + return vm.NIL, err + } + if _, err = r.controlDescriptor(target, patchmodel.ParameterID(parameterName)); err != nil { + return vm.NIL, err + } + event := scheduler.Event{Frame: r.engine.Frame(), Kind: scheduler.EventSetControl, Instrument: target.instrument, Parameter: parameterName, Reset: true, 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("control-queue-full: %w", err) + } + return mapOf(vm.Keyword("control-event"), vm.Int(added.ID), vm.Keyword("reset"), vm.TRUE, vm.Keyword("scheduled-frame"), vm.Int(event.Frame)), nil +} diff --git a/internal/lisp/control_integration_test.go b/internal/lisp/control_integration_test.go index 697f45c..d238606 100644 --- a/internal/lisp/control_integration_test.go +++ b/internal/lisp/control_integration_test.go @@ -83,6 +83,89 @@ func TestPersistentControlChangesAudioWithoutPatchUpdate(t *testing.T) { } } +func TestCtlIsExactFrameAndBlockSizeInvariant(t *testing.T) { + render := func(block int) ([][2]float32, audio.ControlTrace) { + 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 :params {:velocity 80}})`); err != nil { + t.Fatal(err) + } + result, err := a.Lisp.Eval(`(ctl :controlled-tone :level 96 {:at 1})`) + if err != nil || !strings.Contains(result.String(), ":scheduled-frame 22050") { + t.Fatalf("ctl result=%s err=%v", result, err) + } + buffer, err := audio.RenderOffline(a.Engine, 44100, block) + if err != nil { + t.Fatal(err) + } + return buffer, a.Engine.ControlTrace() + } + baseline, trace := render(64) + if len(trace.Events) != 2 { // note-local velocity and instrument level + t.Fatalf("control trace: %#v", trace.Events) + } + if trace.Events[0].Parameter != "velocity" || trace.Events[0].AppliedFrame != 0 || trace.Events[1].Parameter != "level" || trace.Events[1].AppliedFrame != 22050 { + t.Fatalf("control frames: %#v", trace.Events) + } + for _, block := range []int{128, 256, 512, 1024} { + candidate, candidateTrace := render(block) + if len(candidate) != len(baseline) || len(candidateTrace.Events) != len(trace.Events) { + t.Fatalf("block %d shape mismatch", block) + } + for index := range baseline { + if baseline[index] != candidate[index] { + t.Fatalf("block %d differs at frame %d", block, index) + } + } + } +} + +func TestCtlInspectionResetAndStaleHandle(t *testing.T) { + 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(`(ctl :controlled-tone {:level 80})`); err != nil { + t.Fatal(err) + } + if _, err = audio.RenderOffline(a.Engine, 1, 1); err != nil { + t.Fatal(err) + } + value, err := a.Lisp.Eval(`(control-value :controlled-tone :level)`) + if err != nil || !strings.Contains(value.String(), ":value 80") { + t.Fatalf("control-value=%s err=%v", value, err) + } + if _, err = a.Lisp.Eval(`(reset-control! :controlled-tone :level)`); err != nil { + t.Fatal(err) + } + if _, err = audio.RenderOffline(a.Engine, 1, 1); err != nil { + t.Fatal(err) + } + value, _ = a.Lisp.Eval(`(control-value :controlled-tone :level)`) + if !strings.Contains(value.String(), ":value 32") || !strings.Contains(value.String(), ":source :default") { + t.Fatalf("reset value=%s", value) + } + if _, err = a.Lisp.Eval(`(def short-note (play :controlled-tone :a4 {:dur 1/100 :params {:velocity 90}}))`); err != nil { + t.Fatal(err) + } + if _, err = audio.RenderOffline(a.Engine, 1000, 64); err != nil { + t.Fatal(err) + } + if _, err = a.Lisp.Eval(`(ctl short-note :velocity 70)`); err == nil || !strings.Contains(err.Error(), "stale-control-target") { + t.Fatalf("stale handle error=%v", err) + } +} + func TestParameterValidationErrorsAreStructured(t *testing.T) { a, err := app.New(io.Discard, io.Discard) if err != nil { diff --git a/internal/lisp/patch_bindings.go b/internal/lisp/patch_bindings.go index 2f73d56..432c19c 100644 --- a/internal/lisp/patch_bindings.go +++ b/internal/lisp/patch_bindings.go @@ -757,7 +757,11 @@ func (r *Runtime) synthInfoFn(args []vm.Value) (vm.Value, error) { return vm.NIL, fmt.Errorf("unknown synth :%s", id) } spec, _ := r.patchRegistry.Definition(id) - return mapOf(vm.Keyword("id"), vm.Keyword(id), vm.Keyword("voices"), vm.Int(in.NumVoices), vm.Keyword("instrument-index"), vm.Int(in.Index), vm.Keyword("first-voice"), vm.Int(in.FirstVoice), vm.Keyword("unit-count"), vm.Int(len(spec.Units)), vm.Keyword("generation"), vm.Int(snapshot.Generation), vm.Keyword("fingerprint"), vm.String(in.Fingerprint), vm.Keyword("source"), sourceToVM(spec.Metadata.Source), vm.Keyword("units"), instrumentToVM(spec).(*vm.PersistentMap).ValueAt(vm.Keyword("units"))), nil + parameters, err := r.controlsFn([]vm.Value{vm.Keyword(id)}) + if err != nil { + return vm.NIL, err + } + return mapOf(vm.Keyword("id"), vm.Keyword(id), vm.Keyword("voices"), vm.Int(in.NumVoices), vm.Keyword("instrument-index"), vm.Int(in.Index), vm.Keyword("first-voice"), vm.Int(in.FirstVoice), vm.Keyword("unit-count"), vm.Int(len(spec.Units)), vm.Keyword("generation"), vm.Int(snapshot.Generation), vm.Keyword("fingerprint"), vm.String(in.Fingerprint), vm.Keyword("source"), sourceToVM(spec.Metadata.Source), vm.Keyword("parameters"), parameters, vm.Keyword("units"), instrumentToVM(spec).(*vm.PersistentMap).ValueAt(vm.Keyword("units"))), nil } func (r *Runtime) removeSynthFn(args []vm.Value) (vm.Value, error) { if len(args) != 1 { diff --git a/internal/lisp/runtime.go b/internal/lisp/runtime.go index 745d2cc..15a01b7 100644 --- a/internal/lisp/runtime.go +++ b/internal/lisp/runtime.go @@ -6,6 +6,7 @@ import ( "io" "math" "math/big" + "sort" "strings" "sync" @@ -91,6 +92,9 @@ func New(engine *audio.Engine, t *clock.Transport, q *scheduler.Scheduler, a *in if err := r.installPatchBindings(); err != nil { return nil, fmt.Errorf("install patch DSL: %w", err) } + if err := r.installControlBindings(); err != nil { + return nil, fmt.Errorf("install control API: %w", err) + } return r, nil } func (r *Runtime) Eval(src string) (vm.Value, error) { @@ -330,6 +334,7 @@ func (r *Runtime) play(args []vm.Value) (vm.Value, error) { start = *r.atBeat } var dur *clock.Beat + noteParameters := map[patchmodel.ParameterID]float64{} if len(args) == 3 { opts, ok := args[2].(*vm.PersistentMap) if !ok { @@ -352,6 +357,34 @@ func (r *Runtime) play(args []vm.Value) (vm.Value, error) { } dur = &b } + if v := opts.ValueAt(vm.Keyword("params")); v != vm.NIL { + entries, e := mapEntries(v) + if e != nil { + return vm.NIL, fmt.Errorf("play :params: %w", e) + } + definition, exists := r.patchRegistry.Definition(id) + if !exists { + return vm.NIL, fmt.Errorf("unknown instrument :%s", id) + } + for rawID, rawValue := range entries { + parameterID := patchmodel.ParameterID(rawID) + descriptor, exists := definition.Parameters[parameterID] + if !exists { + return vm.NIL, fmt.Errorf("unknown-control: synth :%s has no parameter :%s", id, parameterID) + } + if descriptor.Scope != patchmodel.ScopeVoice { + return vm.NIL, fmt.Errorf("control-scope-mismatch: play :params parameter :%s is instrument scoped", parameterID) + } + value, e := numValue(rawValue) + if e != nil || math.IsNaN(value) || math.IsInf(value, 0) { + return vm.NIL, fmt.Errorf("invalid-control-value: play :params :%s must be finite", parameterID) + } + if value < descriptor.Minimum || value > descriptor.Maximum { + return vm.NIL, fmt.Errorf("control-out-of-range: :%s requires %g..%g, got %g", parameterID, descriptor.Minimum, descriptor.Maximum, value) + } + noteParameters[parameterID] = value + } + } } if start.Sign() < 0 { return vm.NIL, fmt.Errorf("scheduled beat cannot be negative") @@ -390,6 +423,19 @@ func (r *Runtime) play(args []vm.Value) (vm.Value, error) { if _, err = r.queue.Add(r.noteEvent(startFrame, scheduler.EventTrigger, h)); err != nil { return vm.NIL, err } + parameterIDs := make([]string, 0, len(noteParameters)) + for parameterID := range noteParameters { + parameterIDs = append(parameterIDs, string(parameterID)) + } + sort.Strings(parameterIDs) + for _, rawID := range parameterIDs { + event := r.noteEvent(startFrame, scheduler.EventSetControl, h) + event.Parameter = rawID + event.Value = noteParameters[patchmodel.ParameterID(rawID)] + if _, err = r.queue.Add(event); err != nil { + return vm.NIL, fmt.Errorf("control-queue-full: %w", err) + } + } if dur != nil { if _, err = r.queue.Add(r.noteEvent(endFrame, scheduler.EventRelease, h)); err != nil { return vm.NIL, err diff --git a/internal/scheduler/event.go b/internal/scheduler/event.go index b8c4398..6dbdc40 100644 --- a/internal/scheduler/event.go +++ b/internal/scheduler/event.go @@ -12,6 +12,7 @@ const ( EventRelease EventSetTempo EventStopAll + EventSetControl ) func (k EventKind) String() string { @@ -24,6 +25,8 @@ func (k EventKind) String() string { return "tempo" case EventStopAll: return "stop-all" + case EventSetControl: + return "set-control" default: return "unknown" } @@ -41,16 +44,22 @@ type Event struct { Note uint8 Tempo float64 HandleID uint64 + Parameter string + Value float64 + Reset bool } type TraceEvent struct { - ID uint64 `json:"id"` - Kind string `json:"kind"` - Instrument string `json:"instrument,omitempty"` - Voice int `json:"voice"` - Note uint8 `json:"note,omitempty"` - ScheduledFrame uint64 `json:"scheduled_frame"` - AppliedFrame uint64 `json:"applied_frame"` + ID uint64 `json:"id"` + Kind string `json:"kind"` + Instrument string `json:"instrument,omitempty"` + Voice int `json:"voice"` + Note uint8 `json:"note,omitempty"` + Parameter string `json:"parameter,omitempty"` + Value float64 `json:"value,omitempty"` + Generation uint64 `json:"generation,omitempty"` + ScheduledFrame uint64 `json:"scheduled_frame"` + AppliedFrame uint64 `json:"applied_frame"` } type Trace struct { diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 23e24bd..72d7992 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -13,8 +13,30 @@ func (h eventHeap) Less(i, j int) bool { if h[i].Frame != h[j].Frame { return h[i].Frame < h[j].Frame } + left, right := eventPriority(h[i]), eventPriority(h[j]) + if left != right { + return left < right + } return h[i].Sequence < h[j].Sequence } +func eventPriority(event Event) int { + switch event.Kind { + case EventRelease: + return 0 + case EventSetControl: + if event.HandleID == 0 { + return 1 + } + return 3 + case EventTrigger: + return 2 + case EventStopAll: + return 4 + default: + return 2 + } +} + func (h eventHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } func (h *eventHeap) Push(x any) { *h = append(*h, x.(Event)) } func (h *eventHeap) Pop() any { old := *h; n := len(old); x := old[n-1]; *h = old[:n-1]; return x } diff --git a/testdata/controls/exact-step.lg b/testdata/controls/exact-step.lg new file mode 100644 index 0000000..85b9f36 --- /dev/null +++ b/testdata/controls/exact-step.lg @@ -0,0 +1,9 @@ +(defsynth control-step + {:voices 2 + :params {:level {:default 24 :min 0 :max 128 :scope :instrument} + :velocity {:default 100 :min 0 :max 127 :scope :voice}}} + (oscillator {:type :sine :gain (param :velocity)}) + (out {:gain (param :level)})) + +(play :control-step :a4 {:at 0 :dur 4 :params {:velocity 96}}) +(ctl :control-step :level 96 {:at 1})