From f3185171984fda33bc3be70fd502a9e67ff15e9b Mon Sep 17 00:00:00 2001 From: norbot7865 Date: Wed, 15 Jul 2026 16:52:43 +0000 Subject: [PATCH] Fix release for defsynths without envelopes --- docs/patch-dsl.md | 2 + internal/audio/controlled_synth.go | 57 +++++++++++++++++++ internal/audio/engine.go | 54 +++++++++--------- internal/audio/patch_update.go | 8 ++- internal/lisp/control_integration_test.go | 68 +++++++++++++++++++++++ internal/patch/compiler.go | 38 +++++++++++++ internal/patch/compiler_test.go | 16 ++++++ internal/patch/model.go | 13 +++-- internal/patch/registry.go | 1 + 9 files changed, 222 insertions(+), 35 deletions(-) diff --git a/docs/patch-dsl.md b/docs/patch-dsl.md index b7333be..6658ae8 100644 --- a/docs/patch-dsl.md +++ b/docs/patch-dsl.md @@ -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`: diff --git a/internal/audio/controlled_synth.go b/internal/audio/controlled_synth.go index 5421817..46884f4 100644 --- a/internal/audio/controlled_synth.go +++ b/internal/audio/controlled_synth.go @@ -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") diff --git a/internal/audio/engine.go b/internal/audio/engine.go index 6e557ff..21e8061 100644 --- a/internal/audio/engine.go +++ b/internal/audio/engine.go @@ -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) { @@ -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 @@ -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: @@ -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 } } } diff --git a/internal/audio/patch_update.go b/internal/audio/patch_update.go index 411ad5c..e187713 100644 --- a/internal/audio/patch_update.go +++ b/internal/audio/patch_update.go @@ -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++ } } diff --git a/internal/lisp/control_integration_test.go b/internal/lisp/control_integration_test.go index d238606..a004996 100644 --- a/internal/lisp/control_integration_test.go +++ b/internal/lisp/control_integration_test.go @@ -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 @@ -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 { diff --git a/internal/patch/compiler.go b/internal/patch/compiler.go index a25d65c..db1e9bc 100644 --- a/internal/patch/compiler.go +++ b/internal/patch/compiler.go @@ -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 { @@ -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 { diff --git a/internal/patch/compiler_test.go b/internal/patch/compiler_test.go index 64f9057..ef60212 100644 --- a/internal/patch/compiler_test.go +++ b/internal/patch/compiler_test.go @@ -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 { diff --git a/internal/patch/model.go b/internal/patch/model.go index 20ee5c1..d42cd8c 100644 --- a/internal/patch/model.go +++ b/internal/patch/model.go @@ -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"` diff --git a/internal/patch/registry.go b/internal/patch/registry.go index da34956..ba5fe38 100644 --- a/internal/patch/registry.go +++ b/internal/patch/registry.go @@ -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