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
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions cmd/lgs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
64 changes: 64 additions & 0 deletions docs/automation.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions examples/audio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Binary file added examples/audio/issue-9-linear-automation.mp3
Binary file not shown.
243 changes: 243 additions & 0 deletions internal/audio/automation.go
Original file line number Diff line number Diff line change
@@ -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...)}
}
42 changes: 42 additions & 0 deletions internal/audio/automation_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading