From 7f0366487047889f0c8620956d93c778ffc628c9 Mon Sep 17 00:00:00 2001 From: "5684185+vsariola@users.noreply.github.com" <5684185+vsariola@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:07:06 +0300 Subject: [PATCH] draft multicore processing --- audio.go | 5 + cmd/synthers.go | 1 + cmd/synthers_native.go | 6 +- patch.go | 29 ++-- song.go | 5 +- tracker/bool.go | 41 ++++++ tracker/gioui/instrument_properties.go | 20 ++- tracker/player.go | 21 ++- vm/compiler/bridge/native_synth.go | 5 +- vm/compiler/bridge/native_synth_test.go | 5 + vm/go_synth.go | 5 +- vm/go_synth_test.go | 2 + vm/parallel_synth.go | 182 ++++++++++++++++++++++++ 13 files changed, 302 insertions(+), 25 deletions(-) create mode 100644 vm/parallel_synth.go diff --git a/audio.go b/audio.go index 4c7f347..527dc86 100644 --- a/audio.go +++ b/audio.go @@ -62,6 +62,9 @@ type ( // Release releases the currently playing note for a given voice. Called // between synth.Renders. Release(voice int) + + // Close disposes the synth, freeing any resources. No other functions should be called after Close. + Close() } // Synther compiles a given Patch into a Synth, throwing errors if the @@ -69,6 +72,7 @@ type ( Synther interface { Name() string // Name of the synther, e.g. "Go" or "Native" Synth(patch Patch, bpm int) (Synth, error) + SupportsParallelism() bool } ) @@ -83,6 +87,7 @@ func Play(synther Synther, song Song, progress func(float32)) (AudioBuffer, erro if err != nil { return nil, fmt.Errorf("sointu.Play failed: %v", err) } + defer synth.Close() curVoices := make([]int, len(song.Score.Tracks)) for i := range curVoices { curVoices[i] = song.Score.FirstVoiceForTrack(i) diff --git a/cmd/synthers.go b/cmd/synthers.go index 1022c1e..a0526ee 100644 --- a/cmd/synthers.go +++ b/cmd/synthers.go @@ -7,4 +7,5 @@ import ( var Synthers = []sointu.Synther{ vm.GoSynther{}, + vm.MakeParallelSynther(vm.GoSynther{}), } diff --git a/cmd/synthers_native.go b/cmd/synthers_native.go index 144ff87..60dce0b 100644 --- a/cmd/synthers_native.go +++ b/cmd/synthers_native.go @@ -2,8 +2,12 @@ package cmd -import "github.com/vsariola/sointu/vm/compiler/bridge" +import ( + "github.com/vsariola/sointu/vm" + "github.com/vsariola/sointu/vm/compiler/bridge" +) func init() { Synthers = append(Synthers, bridge.NativeSynther{}) + Synthers = append(Synthers, vm.MakeParallelSynther(bridge.NativeSynther{})) } diff --git a/patch.go b/patch.go index a7bca4b..26f245e 100644 --- a/patch.go +++ b/patch.go @@ -16,11 +16,12 @@ type ( // Instrument includes a list of units consisting of the instrument, and the number of polyphonic voices for this instrument Instrument struct { - Name string `yaml:",omitempty"` - Comment string `yaml:",omitempty"` - NumVoices int - Units []Unit - Mute bool `yaml:",omitempty"` // Mute is only used in the tracker for soloing/muting instruments; the compiled player ignores this field + Name string `yaml:",omitempty"` + Comment string `yaml:",omitempty"` + NumVoices int + Units []Unit + Mute bool `yaml:",omitempty"` // Mute is only used in the tracker for soloing/muting instruments; the compiled player ignores this field + CoreBitMask uint `yaml:",omitempty"` // CoreBitMask tells which cores this instrument can run on; 0 means all cores } // Unit is e.g. a filter, oscillator, envelope and its parameters @@ -347,13 +348,14 @@ func init() { // Copy makes a deep copy of a unit. func (u *Unit) Copy() Unit { - parameters := make(map[string]int) + ret := *u + ret.Parameters = make(map[string]int, len(u.Parameters)) for k, v := range u.Parameters { - parameters[k] = v + ret.Parameters[k] = v } - varArgs := make([]int, len(u.VarArgs)) - copy(varArgs, u.VarArgs) - return Unit{Type: u.Type, Parameters: parameters, VarArgs: varArgs, ID: u.ID, Disabled: u.Disabled, Comment: u.Comment} + ret.VarArgs = make([]int, len(u.VarArgs)) + copy(ret.VarArgs, u.VarArgs) + return ret } var stackUseSource = [2]StackUse{ @@ -473,11 +475,12 @@ func (u *Unit) StackNeed() int { // Copy makes a deep copy of an Instrument func (instr *Instrument) Copy() Instrument { - units := make([]Unit, len(instr.Units)) + ret := *instr + ret.Units = make([]Unit, len(instr.Units)) for i, u := range instr.Units { - units[i] = u.Copy() + ret.Units[i] = u.Copy() } - return Instrument{Name: instr.Name, Comment: instr.Comment, NumVoices: instr.NumVoices, Units: units, Mute: instr.Mute} + return ret } // Implement the counter interface diff --git a/song.go b/song.go index 2bf81da..06df5f4 100644 --- a/song.go +++ b/song.go @@ -293,7 +293,10 @@ func (l Score) LengthInRows() int { // Copy makes a deep copy of a Score. func (s *Song) Copy() Song { - return Song{BPM: s.BPM, RowsPerBeat: s.RowsPerBeat, Score: s.Score.Copy(), Patch: s.Patch.Copy()} + ret := *s + ret.Score = s.Score.Copy() + ret.Patch = s.Patch.Copy() + return ret } // Assuming 44100 Hz playback speed, return the number of samples of each row of diff --git a/tracker/bool.go b/tracker/bool.go index 46b91b8..6c02b5d 100644 --- a/tracker/bool.go +++ b/tracker/bool.go @@ -29,6 +29,10 @@ type ( InstrEditor Model InstrPresets Model InstrComment Model + Core1 Model + Core2 Model + Core3 Model + Core4 Model ) func MakeBool(valueEnabler interface { @@ -66,6 +70,43 @@ func (v Bool) Enabled() bool { return v.enabler.Enabled() } +// Core methods + +func (m *Model) getCoresBit(bit int) bool { + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return false + } + return m.d.Song.Patch[m.d.InstrIndex].CoreBitMask&(1<= len(m.d.Song.Patch) { + return + } + defer (*Model)(m).change("CoreBitMask", PatchChange, MinorChange)() + if value { + m.d.Song.Patch[m.d.InstrIndex].CoreBitMask |= (1 << bit) + } else { + m.d.Song.Patch[m.d.InstrIndex].CoreBitMask &^= (1 << bit) + } +} + +func (m *Model) Core1() Bool { return MakeEnabledBool((*Core1)(m)) } +func (m *Core1) Value() bool { return (*Model)(m).getCoresBit(0) } +func (m *Core1) SetValue(val bool) { (*Model)(m).setCoresBit(0, val) } + +func (m *Model) Core2() Bool { return MakeEnabledBool((*Core2)(m)) } +func (m *Core2) Value() bool { return (*Model)(m).getCoresBit(1) } +func (m *Core2) SetValue(val bool) { (*Model)(m).setCoresBit(1, val) } + +func (m *Model) Core3() Bool { return MakeEnabledBool((*Core3)(m)) } +func (m *Core3) Value() bool { return (*Model)(m).getCoresBit(2) } +func (m *Core3) SetValue(val bool) { (*Model)(m).setCoresBit(2, val) } + +func (m *Model) Core4() Bool { return MakeEnabledBool((*Core4)(m)) } +func (m *Core4) Value() bool { return (*Model)(m).getCoresBit(3) } +func (m *Core4) SetValue(val bool) { (*Model)(m).setCoresBit(3, val) } + // Panic methods func (m *Model) Panic() Bool { return MakeEnabledBool((*Panic)(m)) } diff --git a/tracker/gioui/instrument_properties.go b/tracker/gioui/instrument_properties.go index d8d964c..9220f40 100644 --- a/tracker/gioui/instrument_properties.go +++ b/tracker/gioui/instrument_properties.go @@ -19,6 +19,7 @@ type ( list *layout.List soloBtn *Clickable muteBtn *Clickable + coreBtns [4]*Clickable soloHint string unsoloHint string muteHint string @@ -38,6 +39,7 @@ func NewInstrumentProperties() *InstrumentProperties { muteBtn: new(Clickable), voices: NewNumericUpDownState(), splitInstrumentBtn: new(Clickable), + coreBtns: [4]*Clickable{new(Clickable), new(Clickable), new(Clickable), new(Clickable)}, } ret.soloHint = makeHint("Solo", " (%s)", "SoloToggle") ret.unsoloHint = makeHint("Unsolo", " (%s)", "SoloToggle") @@ -66,7 +68,21 @@ func (ip *InstrumentProperties) layout(gtx C) D { ) } - return ip.list.Layout(gtx, 9, func(gtx C, index int) D { + core1btn := ToggleIconBtn(tr.Core1(), tr.Theme, ip.coreBtns[0], icons.ImageCropSquare, icons.ImageFilter1, "Do not render instrument on core 1", "Render instrument on core 1") + core2btn := ToggleIconBtn(tr.Core2(), tr.Theme, ip.coreBtns[1], icons.ImageCropSquare, icons.ImageFilter2, "Do not render instrument on core 2", "Render instrument on core 2") + core3btn := ToggleIconBtn(tr.Core3(), tr.Theme, ip.coreBtns[2], icons.ImageCropSquare, icons.ImageFilter3, "Do not render instrument on core 3", "Render instrument on core 3") + core4btn := ToggleIconBtn(tr.Core4(), tr.Theme, ip.coreBtns[3], icons.ImageCropSquare, icons.ImageFilter4, "Do not render instrument on core 4", "Render instrument on core 4") + + corebtnline := func(gtx C) D { + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(core1btn.Layout), + layout.Rigid(core2btn.Layout), + layout.Rigid(core3btn.Layout), + layout.Rigid(core4btn.Layout), + ) + } + + return ip.list.Layout(gtx, 11, func(gtx C, index int) D { switch index { case 0: return layoutInstrumentPropertyLine(gtx, "Name", func(gtx C) D { @@ -81,6 +97,8 @@ func (ip *InstrumentProperties) layout(gtx C) D { soloBtn := ToggleIconBtn(tr.Solo(), tr.Theme, ip.soloBtn, icons.ToggleCheckBoxOutlineBlank, icons.ToggleCheckBox, ip.soloHint, ip.unsoloHint) return layoutInstrumentPropertyLine(gtx, "Solo", soloBtn.Layout) case 8: + return layoutInstrumentPropertyLine(gtx, "Cores", corebtnline) + case 10: return layout.UniformInset(unit.Dp(6)).Layout(gtx, func(gtx C) D { return ip.commentEditor.Layout(gtx, tr.InstrumentComment(), tr.Theme, &tr.Theme.InstrumentEditor.InstrumentComment, "Comment") }) diff --git a/tracker/player.go b/tracker/player.go index 247e62e..86222ea 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -127,12 +127,12 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext if p.synth != nil { rendered, timeAdvanced, err = p.synth.Render(buffer[:framesUntilEvent], timeUntilRowAdvance) if err != nil { - p.synth = nil + p.destroySynth() p.send(Alert{Message: fmt.Sprintf("synth.Render: %s", err.Error()), Priority: Error, Name: "PlayerCrash", Duration: defaultAlertDuration}) } // for performance, we don't check for NaN of every sample, because typically NaNs propagate if rendered > 0 && (isNaN(buffer[0][0]) || isNaN(buffer[0][1]) || isInf(buffer[0][0]) || isInf(buffer[0][1])) { - p.synth = nil + p.destroySynth() p.send(Alert{Message: "Inf or NaN detected in synth output", Priority: Error, Name: "PlayerCrash", Duration: defaultAlertDuration}) } } else { @@ -170,11 +170,18 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext } } // we were not able to fill the buffer with NUM_RENDER_TRIES attempts, destroy synth and throw an error - p.synth = nil + p.destroySynth() p.events = p.events[:0] // clear events, so we don't try to process them again p.SendAlert("PlayerCrash", fmt.Sprintf("synth did not fill the audio buffer even with %d render calls", numRenderTries), Error) } +func (p *Player) destroySynth() { + if p.synth != nil { + p.synth.Close() + p.synth = nil + } +} + func (p *Player) advanceRow() { if p.song.Score.Length == 0 || p.song.Score.RowsPerPattern == 0 { return @@ -227,7 +234,7 @@ loop: switch m := msg.(type) { case PanicMsg: if m.bool { - p.synth = nil + p.destroySynth() } else { p.compileOrUpdateSynth() } @@ -283,7 +290,7 @@ loop: } case sointu.Synther: p.synther = m - p.synth = nil + p.destroySynth() p.compileOrUpdateSynth() default: // ignore unknown messages @@ -355,7 +362,7 @@ func (p *Player) compileOrUpdateSynth() { if p.synth != nil { err := p.synth.Update(p.song.Patch, p.song.BPM) if err != nil { - p.synth = nil + p.destroySynth() p.SendAlert("PlayerCrash", fmt.Sprintf("synth.Update: %v", err), Error) return } @@ -363,7 +370,7 @@ func (p *Player) compileOrUpdateSynth() { var err error p.synth, err = p.synther.Synth(p.song.Patch, p.song.BPM) if err != nil { - p.synth = nil + p.destroySynth() p.SendAlert("PlayerCrash", fmt.Sprintf("synther.Synth: %v", err), Error) return } diff --git a/vm/compiler/bridge/native_synth.go b/vm/compiler/bridge/native_synth.go index 4c3c087..96194cf 100644 --- a/vm/compiler/bridge/native_synth.go +++ b/vm/compiler/bridge/native_synth.go @@ -18,7 +18,8 @@ type NativeSynther struct { type NativeSynth C.Synth -func (s NativeSynther) Name() string { return "Native" } +func (s NativeSynther) Name() string { return "Native" } +func (s NativeSynther) SupportsParallelism() bool { return false } func (s NativeSynther) Synth(patch sointu.Patch, bpm int) (sointu.Synth, error) { synth, err := Synth(patch, bpm) @@ -67,6 +68,8 @@ func Synth(patch sointu.Patch, bpm int) (*NativeSynth, error) { return (*NativeSynth)(s), nil } +func (s *NativeSynth) Close() {} + // Render renders until the buffer is full or the modulated time is reached, whichever // happens first. // Parameters: diff --git a/vm/compiler/bridge/native_synth_test.go b/vm/compiler/bridge/native_synth_test.go index c1398c5..382c201 100644 --- a/vm/compiler/bridge/native_synth_test.go +++ b/vm/compiler/bridge/native_synth_test.go @@ -86,6 +86,7 @@ func TestRenderSamples(t *testing.T) { if err != nil { t.Fatalf("bridge compile error: %v", err) } + defer synth.Close() synth.Trigger(0, 64) buffer := make(sointu.AudioBuffer, su_max_samples) err = buffer[:len(buffer)/2].Fill(synth) @@ -162,6 +163,7 @@ func TestStackUnderflow(t *testing.T) { if err != nil { t.Fatalf("bridge compile error: %v", err) } + defer synth.Close() buffer := make(sointu.AudioBuffer, 1) err = buffer.Fill(synth) if err == nil { @@ -178,6 +180,7 @@ func TestStackBalancing(t *testing.T) { if err != nil { t.Fatalf("bridge compile error: %v", err) } + defer synth.Close() buffer := make(sointu.AudioBuffer, 1) err = buffer.Fill(synth) if err == nil { @@ -211,6 +214,7 @@ func TestStackOverflow(t *testing.T) { if err != nil { t.Fatalf("bridge compile error: %v", err) } + defer synth.Close() buffer := make(sointu.AudioBuffer, 1) err = buffer.Fill(synth) if err == nil { @@ -228,6 +232,7 @@ func TestDivideByZero(t *testing.T) { if err != nil { t.Fatalf("bridge compile error: %v", err) } + defer synth.Close() buffer := make(sointu.AudioBuffer, 1) err = buffer.Fill(synth) if err == nil { diff --git a/vm/go_synth.go b/vm/go_synth.go index 9d0dbfc..5989265 100644 --- a/vm/go_synth.go +++ b/vm/go_synth.go @@ -93,7 +93,8 @@ success: f.Read(su_sample_table[:]) } -func (s GoSynther) Name() string { return "Go" } +func (s GoSynther) Name() string { return "Go" } +func (s GoSynther) SupportsParallelism() bool { return false } func (s GoSynther) Synth(patch sointu.Patch, bpm int) (sointu.Synth, error) { bytecode, err := NewBytecode(patch, AllFeatures{}, bpm) @@ -115,6 +116,8 @@ func (s *GoSynth) Release(voiceIndex int) { s.state.voices[voiceIndex].sustain = false } +func (s *GoSynth) Close() {} + func (s *GoSynth) Update(patch sointu.Patch, bpm int) error { bytecode, err := NewBytecode(patch, AllFeatures{}, bpm) if err != nil { diff --git a/vm/go_synth_test.go b/vm/go_synth_test.go index 49ddd55..d03b204 100644 --- a/vm/go_synth_test.go +++ b/vm/go_synth_test.go @@ -197,6 +197,7 @@ func TestStackUnderflow(t *testing.T) { if err != nil { t.Fatalf("bridge compile error: %v", err) } + defer synth.Close() buffer := make(sointu.AudioBuffer, 1) err = buffer.Fill(synth) if err == nil { @@ -213,6 +214,7 @@ func TestStackBalancing(t *testing.T) { if err != nil { t.Fatalf("bridge compile error: %v", err) } + defer synth.Close() buffer := make(sointu.AudioBuffer, 1) err = buffer.Fill(synth) if err == nil { diff --git a/vm/parallel_synth.go b/vm/parallel_synth.go new file mode 100644 index 0000000..23f13c4 --- /dev/null +++ b/vm/parallel_synth.go @@ -0,0 +1,182 @@ +package vm + +import ( + "math" + "math/bits" + "runtime" + "sync" + + "github.com/vsariola/sointu" +) + +type ( + ParallelSynth struct { + voiceMapping [][]int + synths []sointu.Synth + commands chan<- parallelSynthCommand // maxtime + results <-chan parallelSynthResult // rendered buffer + pool sync.Pool + synther sointu.Synther + } + + ParallelSynther struct { + synther sointu.Synther + name string + } + + parallelSynthCommand struct { + core int + samples int + time int + } + + parallelSynthResult struct { + buffer *sointu.AudioBuffer + samples int + time int + renderError error + } +) + +func MakeParallelSynther(synther sointu.Synther) ParallelSynther { + return ParallelSynther{synther: synther, name: "Parallel " + synther.Name()} +} + +func (s ParallelSynther) Name() string { return s.name } +func (s ParallelSynther) SupportsParallelism() bool { return true } + +func (s ParallelSynther) Synth(patch sointu.Patch, bpm int) (sointu.Synth, error) { + patches, voiceMapping := splitPatchByCores(patch) + synths := make([]sointu.Synth, 0, len(patches)) + for _, p := range patches { + synth, err := s.synther.Synth(p, bpm) + if err != nil { + return nil, err + } + synths = append(synths, synth) + } + ret := &ParallelSynth{ + synths: synths, + voiceMapping: voiceMapping, + pool: sync.Pool{New: func() any { ret := make(sointu.AudioBuffer, 0, 8096); return &ret }}, + } + ret.startProcesses() + ret.synther = s.synther + return ret, nil +} + +func (s *ParallelSynth) Update(patch sointu.Patch, bpm int) error { + patches, voiceMapping := splitPatchByCores(patch) + s.voiceMapping = voiceMapping + for i, p := range patches { + if len(s.synths) <= i { + synth, err := s.synther.Synth(p, bpm) + if err != nil { + return err + } + s.synths = append(s.synths, synth) + } else { + if err := s.synths[i].Update(p, bpm); err != nil { + return err + } + } + } + return nil +} + +func (s *ParallelSynth) startProcesses() { + maxProcs := runtime.GOMAXPROCS(0) + cmdChan := make(chan parallelSynthCommand, maxProcs) + s.commands = cmdChan + resultsChan := make(chan parallelSynthResult, maxProcs) + s.results = resultsChan + for i := 0; i < maxProcs; i++ { + go func(commandCh <-chan parallelSynthCommand, resultCh chan<- parallelSynthResult) { + for cmd := range commandCh { + buffer := s.pool.Get().(*sointu.AudioBuffer) + *buffer = append(*buffer, make(sointu.AudioBuffer, cmd.samples)...) + samples, time, renderError := s.synths[cmd.core].Render(*buffer, cmd.time) + resultCh <- parallelSynthResult{buffer: buffer, samples: samples, time: time, renderError: renderError} + } + }(cmdChan, resultsChan) + } +} + +func (s *ParallelSynth) Close() { + close(s.commands) + for _, synth := range s.synths { + synth.Close() + } +} + +func (s *ParallelSynth) Trigger(voiceIndex int, note byte) { + for core, synth := range s.synths { + if ind := s.voiceMapping[core][voiceIndex]; ind >= 0 { + synth.Trigger(ind, note) + } + } +} + +func (s *ParallelSynth) Release(voiceIndex int) { + for core, synth := range s.synths { + if ind := s.voiceMapping[core][voiceIndex]; ind >= 0 { + synth.Release(ind) + } + } +} + +func (s *ParallelSynth) Render(buffer sointu.AudioBuffer, maxtime int) (samples int, time int, renderError error) { + count := len(s.synths) + for i := 0; i < count; i++ { + s.commands <- parallelSynthCommand{core: i, samples: len(buffer), time: maxtime} + } + clear(buffer) + samples = math.MaxInt + time = math.MaxInt + for i := 0; i < count; i++ { + result := <-s.results + if result.renderError != nil && renderError == nil { + renderError = result.renderError + } + samples = min(samples, result.samples) + time = min(time, result.time) + for j := 0; j < samples; j++ { + buffer[j][0] += (*result.buffer)[j][0] + buffer[j][1] += (*result.buffer)[j][1] + } + *result.buffer = (*result.buffer)[:0] + s.pool.Put(result.buffer) + } + return +} + +func splitPatchByCores(patch sointu.Patch) ([]sointu.Patch, [][]int) { + maxCores := 1 + for _, instr := range patch { + maxCores = max(bits.Len(instr.CoreBitMask), maxCores) + } + ret := make([]sointu.Patch, maxCores) + for core := 0; core < maxCores; core++ { + ret[core] = make(sointu.Patch, 0, len(patch)) + } + voicemapping := make([][]int, maxCores) + for core := range maxCores { + voicemapping[core] = make([]int, patch.NumVoices()) + for j := range voicemapping[core] { + voicemapping[core][j] = -1 + } + coreVoice := 0 + curVoice := 0 + for _, instr := range patch { + if instr.CoreBitMask == 0 || (instr.CoreBitMask&(1<