From fa7901c7c64bf24b96c9e3c98e9fddc9b51617d6 Mon Sep 17 00:00:00 2001 From: "5684185+vsariola@users.noreply.github.com" <5684185+vsariola@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:53:08 +0300 Subject: [PATCH] drafting --- audio.go | 20 +++++++++ patch.go | 12 ++--- tracker/bool.go | 33 ++++++++++++-- tracker/gioui/song_panel.go | 24 ++++++++-- tracker/model.go | 3 +- tracker/player.go | 23 +++------- vm/compiler/bridge/native_synth.go | 28 +++++++++--- vm/go_synth.go | 31 +++++++++---- vm/parallel_synth.go | 72 +++++++++++++++++++++++------- 9 files changed, 184 insertions(+), 62 deletions(-) diff --git a/audio.go b/audio.go index 527dc86..d990ba3 100644 --- a/audio.go +++ b/audio.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "math" + "time" ) type ( @@ -65,6 +66,12 @@ type ( // Close disposes the synth, freeing any resources. No other functions should be called after Close. Close() + + // Returns the number of cores the synth is using + NumCores() int + + // Populates the given array with the current CPU load of each core + CPULoad([]CPULoad) } // Synther compiles a given Patch into a Synth, throwing errors if the @@ -74,6 +81,8 @@ type ( Synth(patch Patch, bpm int) (Synth, error) SupportsParallelism() bool } + + CPULoad float32 ) // Play plays the Song by first compiling the patch with the given Synther, @@ -209,6 +218,17 @@ func (buffer AudioBuffer) Raw(pcm16 bool) ([]byte, error) { return buf.Bytes(), nil } +func (p *CPULoad) Update(duration time.Duration, frames int64) { + if frames <= 0 { + return // no frames rendered, so cannot compute CPU load + } + realtime := float64(duration) / 1e9 + songtime := float64(frames) / 44100 + newload := realtime / songtime + alpha := math.Exp(-songtime) // smoothing factor, time constant of 1 second + *p = CPULoad(float64(*p)*alpha + newload*(1-alpha)) +} + func (data AudioBuffer) rawToBuffer(pcm16 bool, buf *bytes.Buffer) error { var err error if pcm16 { diff --git a/patch.go b/patch.go index 26f245e..f08dc6c 100644 --- a/patch.go +++ b/patch.go @@ -16,12 +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 - CoreBitMask uint `yaml:",omitempty"` // CoreBitMask tells which cores this instrument can run on; 0 means all cores + 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 + CoreMaskM1 int `yaml:",omitempty"` // CoreMaskM1 is a bit mask of which cores are used, minus 1. Minus 1 is done so that the default value 0 means bit mask 0b0001 i.e. only core 1 is rendering the instrument. } // Unit is e.g. a filter, oscillator, envelope and its parameters diff --git a/tracker/bool.go b/tracker/bool.go index 6c02b5d..c6f0a7a 100644 --- a/tracker/bool.go +++ b/tracker/bool.go @@ -1,5 +1,7 @@ package tracker +import "fmt" + type ( Bool struct { value BoolValue @@ -76,7 +78,8 @@ 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<= 1 { + var sb strings.Builder + var loadArr [vm.MAX_CORES]sointu.CPULoad + tr.Model.CPULoad(loadArr[:]) + c := min(vm.MAX_CORES, tr.Model.NumCores()) + high := false + for i := range c { + if i > 0 { + // unless this is the first item, add the separator before it. + fmt.Fprint(&sb, ", ") + } + cpuLoad := loadArr[i] + fmt.Fprintf(&sb, "%.0f %%", cpuLoad*100) + if cpuLoad >= 1 { + high = true + } + } + cpuLabel := Label(tr.Theme, &tr.Theme.SongPanel.RowValue, sb.String()) + if high { cpuLabel.Color = tr.Theme.SongPanel.ErrorColor } diff --git a/tracker/model.go b/tracker/model.go index 90264fb..358dd81 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -397,7 +397,8 @@ func (m *Model) ProcessMsg(msg MsgToModel) { } } -func (m *Model) CPULoad() float64 { return m.playerStatus.CPULoad } +func (m *Model) NumCores() int { return m.playerStatus.NumCores } +func (m *Model) CPULoad(buf []sointu.CPULoad) { copy(buf, m.playerStatus.CPULoad[:]) } func (m *Model) SignalAnalyzer() *ScopeModel { return m.signalAnalyzer } func (m *Model) Broker() *Broker { return m.broker } diff --git a/tracker/player.go b/tracker/player.go index 86222ea..2414b6e 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -5,7 +5,6 @@ import ( "fmt" "math" "slices" - "time" "github.com/vsariola/sointu" "github.com/vsariola/sointu/vm" @@ -42,7 +41,8 @@ type ( PlayerStatus struct { SongPos sointu.SongPos // the current position in the score VoiceLevels [vm.MAX_VOICES]float32 // a level that can be used to visualize the volume of each voice - CPULoad float64 // current CPU load of the player, used to adjust the render rate + NumCores int + CPULoad [vm.MAX_CORES]sointu.CPULoad // current CPU load of the player, used to adjust the render rate } // PlayerProcessContext is the context given to the player when processing @@ -97,9 +97,6 @@ func NewPlayer(broker *Broker, synther sointu.Synther) *Player { // buffer. It is used to trigger and release notes during processing. The // context is also used to get the current BPM from the host. func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext) { - startTime := time.Now() - startFrame := p.frame - p.processMessages(context) p.events.adjustTimes(p.frameDeltas, p.frame, p.frame+int64(len(buffer))) @@ -164,7 +161,10 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext } // when the buffer is full, return if len(buffer) == 0 { - p.updateCPULoad(time.Since(startTime), p.frame-startFrame) + if p.synth != nil { + p.status.NumCores = p.synth.NumCores() + p.synth.CPULoad(p.status.CPULoad[:]) + } p.send(nil) return } @@ -448,14 +448,3 @@ func (p *Player) processNoteEvent(ev NoteEvent) { p.synth.Trigger(oldestVoice, ev.Note) TrySend(p.broker.ToModel, MsgToModel{TriggerChannel: instrIndex + 1}) } - -func (p *Player) updateCPULoad(duration time.Duration, frames int64) { - if frames <= 0 { - return // no frames rendered, so cannot compute CPU load - } - realtime := float64(duration) / 1e9 - songtime := float64(frames) / 44100 - newload := realtime / songtime - alpha := math.Exp(-songtime) // smoothing factor, time constant of 1 second - p.status.CPULoad = float64(p.status.CPULoad)*alpha + newload*(1-alpha) -} diff --git a/vm/compiler/bridge/native_synth.go b/vm/compiler/bridge/native_synth.go index 96194cf..ec5b966 100644 --- a/vm/compiler/bridge/native_synth.go +++ b/vm/compiler/bridge/native_synth.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/vsariola/sointu" "github.com/vsariola/sointu/vm" @@ -16,7 +17,10 @@ import ( type NativeSynther struct { } -type NativeSynth C.Synth +type NativeSynth struct { + csynth C.Synth + cpuLoad sointu.CPULoad +} func (s NativeSynther) Name() string { return "Native" } func (s NativeSynther) SupportsParallelism() bool { return false } @@ -46,7 +50,7 @@ func Synth(patch sointu.Patch, bpm int) (*NativeSynth, error) { s.Opcodes[0] = 0 s.NumVoices = 1 s.Polyphony = 0 - return (*NativeSynth)(s), nil + return &NativeSynth{csynth: *s}, nil } for i, v := range comPatch.Opcodes { s.Opcodes[i] = (C.uchar)(v) @@ -65,11 +69,19 @@ func Synth(patch sointu.Patch, bpm int) (*NativeSynth, error) { s.NumVoices = C.uint(comPatch.NumVoices) s.Polyphony = C.uint(comPatch.PolyphonyBitmask) s.RandSeed = 1 - return (*NativeSynth)(s), nil + return &NativeSynth{csynth: *s}, nil } func (s *NativeSynth) Close() {} +func (s *NativeSynth) NumCores() int { return 1 } +func (s *NativeSynth) CPULoad(loads []sointu.CPULoad) { + if len(loads) < 1 { + return + } + loads[0] = s.cpuLoad +} + // Render renders until the buffer is full or the modulated time is reached, whichever // happens first. // Parameters: @@ -92,12 +104,14 @@ func (s *NativeSynth) Close() {} // exit condition would fire when the time is already past maxtime. // Under no conditions, nsamples >= len(buffer)/2 i.e. guaranteed to never overwrite the buffer. func (bridgesynth *NativeSynth) Render(buffer sointu.AudioBuffer, maxtime int) (int, int, error) { - synth := (*C.Synth)(bridgesynth) + synth := &bridgesynth.csynth // TODO: syncBuffer is not getting passed to cgo; do we want to even try to support the syncing with the native bridge if len(buffer)%1 == 1 { return -1, -1, errors.New("RenderTime writes stereo signals, so buffer should have even length") } samples := C.int(len(buffer)) + startTime := time.Now() + defer func() { bridgesynth.cpuLoad.Update(time.Since(startTime), int64(samples)) }() time := C.int(maxtime) errcode := int(C.su_render(synth, (*C.float)(&buffer[0][0]), &samples, &time)) if errcode > 0 { @@ -108,7 +122,7 @@ func (bridgesynth *NativeSynth) Render(buffer sointu.AudioBuffer, maxtime int) ( // Trigger is part of C.Synths' implementation of sointu.Synth interface func (bridgesynth *NativeSynth) Trigger(voice int, note byte) { - s := (*C.Synth)(bridgesynth) + s := &bridgesynth.csynth if voice < 0 || voice >= len(s.SynthWrk.Voices) { return } @@ -119,7 +133,7 @@ func (bridgesynth *NativeSynth) Trigger(voice int, note byte) { // Release is part of C.Synths' implementation of sointu.Synth interface func (bridgesynth *NativeSynth) Release(voice int) { - s := (*C.Synth)(bridgesynth) + s := &bridgesynth.csynth if voice < 0 || voice >= len(s.SynthWrk.Voices) { return } @@ -128,7 +142,7 @@ func (bridgesynth *NativeSynth) Release(voice int) { // Update func (bridgesynth *NativeSynth) Update(patch sointu.Patch, bpm int) error { - s := (*C.Synth)(bridgesynth) + s := &bridgesynth.csynth if n := patch.NumDelayLines(); n > 128 { return fmt.Errorf("native bridge has currently a hard limit of 128 delaylines; patch uses %v", n) } diff --git a/vm/go_synth.go b/vm/go_synth.go index 5989265..268c862 100644 --- a/vm/go_synth.go +++ b/vm/go_synth.go @@ -7,6 +7,7 @@ import ( "math" "os" "path/filepath" + "time" "github.com/vsariola/sointu" ) @@ -27,6 +28,7 @@ type ( stack []float32 state synthState delaylines []delayline + cpuLoad sointu.CPULoad } // GoSynther is a Synther implementation that can converts patches into @@ -118,6 +120,14 @@ func (s *GoSynth) Release(voiceIndex int) { func (s *GoSynth) Close() {} +func (s *GoSynth) NumCores() int { return 1 } +func (s *GoSynth) CPULoad(loads []sointu.CPULoad) { + if len(loads) < 1 { + return + } + loads[0] = s.cpuLoad +} + func (s *GoSynth) Update(patch sointu.Patch, bpm int) error { bytecode, err := NewBytecode(patch, AllFeatures{}, bpm) if err != nil { @@ -146,7 +156,10 @@ func (s *GoSynth) Update(patch sointu.Patch, bpm int) error { return nil } -func (s *GoSynth) Render(buffer sointu.AudioBuffer, maxtime int) (samples int, time int, renderError error) { +func (s *GoSynth) Render(buffer sointu.AudioBuffer, maxtime int) (samples int, renderTime int, renderError error) { + startTime := time.Now() + defer func() { s.cpuLoad.Update(time.Since(startTime), int64(samples)) }() + defer func() { if err := recover(); err != nil { renderError = fmt.Errorf("render panicced: %v", err) @@ -156,7 +169,7 @@ func (s *GoSynth) Render(buffer sointu.AudioBuffer, maxtime int) (samples int, t stack := s.stack[:] stack = append(stack, []float32{0, 0, 0, 0}...) synth := &s.state - for time < maxtime && len(buffer) > 0 { + for renderTime < maxtime && len(buffer) > 0 { opcodesInstr := s.bytecode.Opcodes operandsInstr := s.bytecode.Operands opcodes, operands := opcodesInstr, operandsInstr @@ -185,7 +198,7 @@ func (s *GoSynth) Render(buffer sointu.AudioBuffer, maxtime int) (samples int, t } tcount := transformCounts[opNoStereo-1] if len(operands) < tcount { - return samples, time, errors.New("operand stream ended prematurely") + return samples, renderTime, errors.New("operand stream ended prematurely") } voice := &voices[0] unit := &units[0] @@ -292,7 +305,7 @@ func (s *GoSynth) Render(buffer sointu.AudioBuffer, maxtime int) (samples int, t r := unit.state[0] + float32(math.Exp2(float64(stack[l-1]*2.206896551724138))-1) w := int(r+1.5) - 1 unit.state[0] = r - float32(w) - time += w + renderTime += w stack = stack[:l-1] case opIn: var channel byte @@ -584,26 +597,26 @@ func (s *GoSynth) Render(buffer sointu.AudioBuffer, maxtime int) (samples int, t case opSync: break default: - return samples, time, errors.New("invalid / unimplemented opcode") + return samples, renderTime, errors.New("invalid / unimplemented opcode") } units = units[1:] } if len(stack) < 4 { - return samples, time, errors.New("stack underflow") + return samples, renderTime, errors.New("stack underflow") } if len(stack) > 4 { - return samples, time, errors.New("stack not empty") + return samples, renderTime, errors.New("stack not empty") } buffer[0][0], buffer[0][1] = synth.outputs[0], synth.outputs[1] synth.outputs[0] = 0 synth.outputs[1] = 0 buffer = buffer[1:] samples++ - time++ + renderTime++ s.state.globalTime++ } s.stack = stack[:0] - return samples, time, nil + return samples, renderTime, nil } func (s *synthState) rand() float32 { diff --git a/vm/parallel_synth.go b/vm/parallel_synth.go index 23f13c4..6f1b0e9 100644 --- a/vm/parallel_synth.go +++ b/vm/parallel_synth.go @@ -11,7 +11,7 @@ import ( type ( ParallelSynth struct { - voiceMapping [][]int + voiceMapping voiceMapping synths []sointu.Synth commands chan<- parallelSynthCommand // maxtime results <-chan parallelSynthResult // rendered buffer @@ -24,6 +24,8 @@ type ( name string } + voiceMapping [MAX_CORES][MAX_VOICES]int + parallelSynthCommand struct { core int samples int @@ -38,6 +40,8 @@ type ( } ) +const MAX_CORES = 4 + func MakeParallelSynther(synther sointu.Synther) ParallelSynther { return ParallelSynther{synther: synther, name: "Parallel " + synther.Name()} } @@ -67,16 +71,21 @@ func (s ParallelSynther) Synth(patch sointu.Patch, bpm int) (sointu.Synth, error func (s *ParallelSynth) Update(patch sointu.Patch, bpm int) error { patches, voiceMapping := splitPatchByCores(patch) - s.voiceMapping = voiceMapping + if s.voiceMapping != voiceMapping { + s.voiceMapping = voiceMapping + s.closeSynths() + } for i, p := range patches { if len(s.synths) <= i { synth, err := s.synther.Synth(p, bpm) if err != nil { + s.closeSynths() return err } s.synths = append(s.synths, synth) } else { if err := s.synths[i].Update(p, bpm); err != nil { + s.closeSynths() return err } } @@ -104,9 +113,14 @@ func (s *ParallelSynth) startProcesses() { func (s *ParallelSynth) Close() { close(s.commands) + s.closeSynths() +} + +func (s *ParallelSynth) closeSynths() { for _, synth := range s.synths { synth.Close() } + s.synths = s.synths[:0] } func (s *ParallelSynth) Trigger(voiceIndex int, note byte) { @@ -125,6 +139,23 @@ func (s *ParallelSynth) Release(voiceIndex int) { } } +func (s *ParallelSynth) NumCores() (coreCount int) { + for i := range s.synths { + coreCount += s.synths[i].NumCores() + } + return +} + +func (s *ParallelSynth) CPULoad(loads []sointu.CPULoad) { + for _, synth := range s.synths { + synth.CPULoad(loads) + if len(loads) <= synth.NumCores() { + return + } + loads = loads[synth.NumCores():] + } +} + 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++ { @@ -134,6 +165,9 @@ func (s *ParallelSynth) Render(buffer sointu.AudioBuffer, maxtime int) (samples samples = math.MaxInt time = math.MaxInt for i := 0; i < count; i++ { + // We mix the results as they come, but the order doesn't matter. This + // leads to slight indeterminism in the results, because the order of + // floating point additions can change the least significant bits. result := <-s.results if result.renderError != nil && renderError == nil { renderError = result.renderError @@ -150,28 +184,34 @@ func (s *ParallelSynth) Render(buffer sointu.AudioBuffer, maxtime int) (samples return } -func splitPatchByCores(patch sointu.Patch) ([]sointu.Patch, [][]int) { - maxCores := 1 +func splitPatchByCores(patch sointu.Patch) ([]sointu.Patch, voiceMapping) { + cores := 1 for _, instr := range patch { - maxCores = max(bits.Len(instr.CoreBitMask), maxCores) + cores = max(bits.Len((uint)(instr.CoreMaskM1+1)), cores) } - ret := make([]sointu.Patch, maxCores) - for core := 0; core < maxCores; core++ { - ret[core] = make(sointu.Patch, 0, len(patch)) + cores = min(cores, MAX_CORES) + ret := make([]sointu.Patch, cores) + for c := 0; c < cores; c++ { + ret[c] = 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 + var voicemapping [MAX_CORES][MAX_VOICES]int + for c := 0; c < MAX_CORES; c++ { + for j := 0; j < MAX_VOICES; j++ { + voicemapping[c][j] = -1 } + } + for c := range cores { coreVoice := 0 curVoice := 0 for _, instr := range patch { - if instr.CoreBitMask == 0 || (instr.CoreBitMask&(1<= MAX_VOICES { + break + } + voicemapping[c][curVoice+j] = coreVoice + j } coreVoice += instr.NumVoices }