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
2 changes: 2 additions & 0 deletions docs/patch-dsl.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ The macro installs the candidate and binds `soft-lead` to a printable synth desc

Reevaluation retains the first-registration position. A changed aggregate increments the generation exactly once. Byte-identical normalized definitions are elided and return `:changed false`. Failed construction, stack analysis, routing, upstream compilation, or update leaves the prior registry generation and var value intact.

Sointu envelopes consume note-off events and produce release tails. If a synth has no enabled `envelope`, govertone instead hard-mutes its per-voice `out`, `outaux`, and `aux` gains at note-off; the next trigger restores them before applying note parameters. Thus both `:dur` and `release` stop a bare oscillator. Add an `envelope` and combine it with the signal when an audible release tail is wanted.

## Low-level API

Constructors are available unqualified in `music.core` and qualified in `music.patch`:
Expand Down
57 changes: 57 additions & 0 deletions internal/audio/controlled_synth.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,63 @@ func newControlState(compiled *patchmodel.CompiledPatch) *controlState {
return state
}

func newHardReleaseOperands(compiled *patchmodel.CompiledPatch) map[patchmodel.InstrumentID][]int {
result := map[patchmodel.InstrumentID][]int{}
if compiled == nil {
return result
}
for id, instrument := range compiled.Layout.Instruments {
if len(instrument.HardReleaseOperands) > 0 {
result[id] = append([]int(nil), instrument.HardReleaseOperands...)
}
}
return result
}

func (e *Engine) initializeHardReleaseControls() {
vmSynth, ok := e.synth.(controlledSynth)
if !ok {
return
}
for id, operands := range e.hardReleaseOperands {
definition, exists := e.layout[id]
if !exists {
continue
}
first := int(definition.FirstVoice)
for voice := first; voice < first+definition.Voices; voice++ {
for _, operand := range operands {
_ = vmSynth.SetControl(voice, operand, 0)
}
}
}
}

func (e *Engine) releaseVoice(voice int, instrument patchmodel.InstrumentID) {
e.synth.Release(voice)
vmSynth, ok := e.synth.(controlledSynth)
if !ok {
return
}
// Sointu note-off is consumed by envelope units. A synth without an
// envelope needs an output-gain override so a released oscillator does not
// run forever. Trigger clears this per-voice override before note controls
// are applied, making the voice reusable.
for _, operand := range e.hardReleaseOperands[instrument] {
_ = vmSynth.SetControl(voice, operand, 0)
}
}

func (e *Engine) instrumentAtVoice(voice int) patchmodel.InstrumentID {
for id, definition := range e.layout {
first := int(definition.FirstVoice)
if voice >= first && voice < first+definition.Voices {
return id
}
}
return ""
}

func transformedControl(binding patchmodel.ControlBinding, value float64) (float32, error) {
if math.IsNaN(value) || math.IsInf(value, 0) {
return 0, fmt.Errorf("invalid-control-value: control value must be finite")
Expand Down
54 changes: 28 additions & 26 deletions internal/audio/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,27 +29,28 @@ type Stats struct {
}

type Engine struct {
synth sointu.Synth
scheduler *scheduler.Scheduler
frame atomic.Uint64
owners [32]uint64
layout map[instruments.InstrumentID]instruments.Definition
renderMu sync.Mutex
patchGeneration atomic.Uint64
patchFingerprint atomic.Value
patchRequest atomic.Uint64
patchTraceMu sync.Mutex
patchTrace []PatchUpdateTrace
traceMu sync.Mutex
closeOnce sync.Once
trace []scheduler.TraceEvent
late atomic.Uint64
dropped atomic.Uint64
maxRender atomic.Int64
controlApplied atomic.Uint64
controlRejected atomic.Uint64
controls *controlState
automation *automationState
synth sointu.Synth
scheduler *scheduler.Scheduler
frame atomic.Uint64
owners [32]uint64
layout map[instruments.InstrumentID]instruments.Definition
renderMu sync.Mutex
patchGeneration atomic.Uint64
patchFingerprint atomic.Value
patchRequest atomic.Uint64
patchTraceMu sync.Mutex
patchTrace []PatchUpdateTrace
traceMu sync.Mutex
closeOnce sync.Once
trace []scheduler.TraceEvent
late atomic.Uint64
dropped atomic.Uint64
maxRender atomic.Int64
controlApplied atomic.Uint64
controlRejected atomic.Uint64
controls *controlState
hardReleaseOperands map[instruments.InstrumentID][]int
automation *automationState
}

func NewEngine(provider instruments.PatchProvider, q *scheduler.Scheduler, bpm float64) (*Engine, error) {
Expand All @@ -63,10 +64,11 @@ 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), automation: newAutomationState()}
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), hardReleaseOperands: newHardReleaseOperands(compiled), automation: newAutomationState()}
for _, definition := range provider.Instruments() {
e.layout[definition.ID] = definition
}
e.initializeHardReleaseControls()
e.patchGeneration.Store(1)
e.patchFingerprint.Store(provider.Fingerprint())
return e, nil
Expand Down Expand Up @@ -187,7 +189,7 @@ func (e *Engine) apply(ev scheduler.Event, at clock.FrameIndex) {
e.owners[voice] = ev.HandleID
case scheduler.EventRelease:
if e.owners[voice] == ev.HandleID {
e.synth.Release(voice)
e.releaseVoice(voice, ev.Instrument)
e.owners[voice] = 0
}
case scheduler.EventSetControl:
Expand Down Expand Up @@ -225,10 +227,10 @@ func (e *Engine) apply(ev scheduler.Event, at clock.FrameIndex) {
return
}
case scheduler.EventStopAll:
for i, id := range e.owners {
for voiceIndex, id := range e.owners {
if id != 0 {
e.synth.Release(i)
e.owners[i] = 0
e.releaseVoice(voiceIndex, e.instrumentAtVoice(voiceIndex))
e.owners[voiceIndex] = 0
}
}
}
Expand Down
8 changes: 5 additions & 3 deletions internal/audio/patch_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,16 @@ 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.hardReleaseOperands = newHardReleaseOperands(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}
}
for i, owner := range e.owners {
e.initializeHardReleaseControls()
for voice, owner := range e.owners {
if owner != 0 {
e.synth.Release(i)
e.owners[i] = 0
e.releaseVoice(voice, e.instrumentAtVoice(voice))
e.owners[voice] = 0
invalidated++
}
}
Expand Down
68 changes: 68 additions & 0 deletions internal/lisp/control_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (

"github.com/example/letgo-sointu/internal/app"
"github.com/example/letgo-sointu/internal/audio"
"github.com/example/letgo-sointu/internal/clock"
patchmodel "github.com/example/letgo-sointu/internal/patch"
"github.com/nooga/let-go/pkg/vm"
)

const controlledTone = `(defsynth controlled-tone
Expand Down Expand Up @@ -45,6 +47,72 @@ func TestNamedParametersCompileToDeterministicBindings(t *testing.T) {
}
}

func TestDefsynthWithoutEnvelopeReleasesAndReusesVoice(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(`(play :controlled-tone :e5 {:at 0 :dur 1})`); err != nil {
t.Fatal(err)
}
if _, err = a.Lisp.Eval(`(play :controlled-tone :e5 {:at 3/2 :dur 1/2})`); err != nil {
t.Fatal(err)
}
buffer, err := audio.RenderOffline(a.Engine, clock.SampleRate, 257)
if err != nil {
t.Fatal(err)
}
if got := controlRMS(buffer[1000:21000]); got < 0.001 {
t.Fatalf("first note RMS = %g, want audible signal", got)
}
if got := controlRMS(buffer[22051:32000]); got != 0 {
t.Fatalf("released note RMS = %g, want silence", got)
}
if got := controlRMS(buffer[34000:43000]); got < 0.001 {
t.Fatalf("reused voice RMS = %g, want audible signal", got)
}
trace := a.Engine.Trace(257)
if len(trace.Events) < 2 || trace.Events[1].Kind != "release" || trace.Events[1].AppliedFrame != 22050 {
t.Fatalf("release trace = %#v", trace.Events)
}
}

func TestExplicitReleaseMutesDefsynthWithoutEnvelope(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(`(def held-note (play :controlled-tone :e5))`); err != nil {
t.Fatal(err)
}
before, err := audio.RenderOffline(a.Engine, 4096, 256)
if err != nil {
t.Fatal(err)
}
if got := controlRMS(before); got < 0.001 {
t.Fatalf("held note RMS = %g, want audible signal", got)
}
released, err := a.Lisp.Eval(`(release held-note)`)
if err != nil || released != vm.TRUE {
t.Fatalf("release = %v, err = %v", released, err)
}
after, err := audio.RenderOffline(a.Engine, 4096, 256)
if err != nil {
t.Fatal(err)
}
if got := controlRMS(after); got != 0 {
t.Fatalf("explicitly released note RMS = %g, want silence", got)
}
}

func TestPersistentControlChangesAudioWithoutPatchUpdate(t *testing.T) {
a, err := app.New(io.Discard, io.Discard)
if err != nil {
Expand Down
38 changes: 38 additions & 0 deletions internal/patch/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,22 @@ func (c *Compiler) Compile(input PatchSpec) (*CompiledPatch, error) {
return nil, &CompileError{[]Diagnostic{diag("patch-compile-failed", "", 0, "", "", fmt.Sprintf("Sointu rejected patch: %v", err))}}
}
for instrumentIndex, instrument := range normalized.Instruments {
compiledInstrument := result.Layout.Instruments[instrument.ID]
if !hasEnabledEnvelope(instrument) {
for unitIndex, unit := range instrument.Units {
if unit.Disabled {
continue
}
for _, parameter := range outputGainParameters(unit.Type) {
operand, ok := bytecode.ParameterOperands[sointuvm.ParameterAddress{Instrument: instrumentIndex, Unit: unitIndex, Parameter: parameter}]
if ok {
compiledInstrument.HardReleaseOperands = append(compiledInstrument.HardReleaseOperands, operand)
}
}
}
}
result.Layout.Instruments[instrument.ID] = compiledInstrument

for unitIndex, unit := range instrument.Units {
names := make([]string, 0, len(unit.ControlBindings))
for name := range unit.ControlBindings {
Expand Down Expand Up @@ -495,6 +511,28 @@ func controllableParameter(unitType UnitType, name string) bool {
return false
}

func hasEnabledEnvelope(instrument InstrumentSpec) bool {
for _, unit := range instrument.Units {
if unit.Type == "envelope" && !unit.Disabled {
return true
}
}
return false
}

func outputGainParameters(unitType UnitType) []string {
switch unitType {
case "out":
return []string{"gain"}
case "outaux":
return []string{"outgain", "auxgain"}
case "aux":
return []string{"gain"}
default:
return nil
}
}

func hasErrors(ds []Diagnostic) bool {
for _, d := range ds {
if d.Severity == SeverityError {
Expand Down
16 changes: 16 additions & 0 deletions internal/patch/compiler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ func TestCompileMinimalAndLayout(t *testing.T) {
t.Fatalf("defaults not normalized: %#v", compiled.Patch[0].Units[1])
}
}

func TestCompileHardReleaseOperandsOnlyWithoutEnvelope(t *testing.T) {
compiler := NewCompiler()
ungated, _ := NewInstrument("ungated", 1, mustUnit(t, "oscillator", nil), mustUnit(t, "out", nil))
gated := minimal(t, "gated", "sine", 1)
compiled, err := compiler.Compile(PatchSpec{Instruments: []InstrumentSpec{ungated, gated}})
if err != nil {
t.Fatal(err)
}
if got := compiled.Layout.Instruments["ungated"].HardReleaseOperands; len(got) != 1 {
t.Fatalf("ungated hard-release operands = %v, want one output gain", got)
}
if got := compiled.Layout.Instruments["gated"].HardReleaseOperands; len(got) != 0 {
t.Fatalf("enveloped synth has hard-release operands %v", got)
}
}
func TestCompilerDiagnostics(t *testing.T) {
c := NewCompiler()
cases := []struct {
Expand Down
13 changes: 7 additions & 6 deletions internal/patch/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,13 @@ type UnitSpec struct {
}

type CompiledInstrument struct {
ID InstrumentID `json:"id"`
Index int `json:"index"`
FirstVoice int `json:"first_voice"`
NumVoices int `json:"voices"`
UnitIDs map[UnitID]int `json:"unit_ids"`
Fingerprint string `json:"fingerprint"`
ID InstrumentID `json:"id"`
Index int `json:"index"`
FirstVoice int `json:"first_voice"`
NumVoices int `json:"voices"`
UnitIDs map[UnitID]int `json:"unit_ids"`
Fingerprint string `json:"fingerprint"`
HardReleaseOperands []int `json:"-"`
}
type InstrumentLayout struct {
Instruments map[InstrumentID]CompiledInstrument `json:"instruments"`
Expand Down
1 change: 1 addition & 0 deletions internal/patch/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ func cloneLayout(in InstrumentLayout) InstrumentLayout {
out := InstrumentLayout{TotalVoices: in.TotalVoices, OrderedIDs: append([]InstrumentID(nil), in.OrderedIDs...), Instruments: make(map[InstrumentID]CompiledInstrument, len(in.Instruments))}
for id, v := range in.Instruments {
v.UnitIDs = cloneUnitIDs(v.UnitIDs)
v.HardReleaseOperands = append([]int(nil), v.HardReleaseOperands...)
out.Instruments[id] = v
}
return out
Expand Down
Loading