Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions cmd/lgs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions docs/controls.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions examples/audio/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added examples/audio/issue-8-exact-control-step.mp3
Binary file not shown.
25 changes: 25 additions & 0 deletions internal/audio/control_trace.go
Original file line number Diff line number Diff line change
@@ -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
}
61 changes: 61 additions & 0 deletions internal/audio/controlled_synth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
51 changes: 41 additions & 10 deletions internal/audio/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -42,6 +44,8 @@ type Engine struct {
late atomic.Uint64
dropped atomic.Uint64
maxRender atomic.Int64
controlApplied atomic.Uint64
controlRejected atomic.Uint64
controls *controlState
}

Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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()}
}
14 changes: 14 additions & 0 deletions internal/instruments/voices.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading