mirror of
https://github.com/vsariola/sointu.git
synced 2026-02-08 00:30:18 -05:00
feat: add multithreaded rendering to the tracker side
The compiled player does not support multithreading, but with this, users can already start composing songs with slightly less powerful machines, even when targeting high-end machines. Related to #199
This commit is contained in:
parent
c583156d1b
commit
9b9dc3548f
@ -1,5 +1,9 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type (
|
||||
Bool struct {
|
||||
value BoolValue
|
||||
@ -29,6 +33,10 @@ type (
|
||||
InstrEditor Model
|
||||
InstrPresets Model
|
||||
InstrComment Model
|
||||
Thread1 Model
|
||||
Thread2 Model
|
||||
Thread3 Model
|
||||
Thread4 Model
|
||||
)
|
||||
|
||||
func MakeBool(valueEnabler interface {
|
||||
@ -66,6 +74,78 @@ func (v Bool) Enabled() bool {
|
||||
return v.enabler.Enabled()
|
||||
}
|
||||
|
||||
// Thread methods
|
||||
|
||||
func (m *Model) getThreadsBit(bit int) bool {
|
||||
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
|
||||
return false
|
||||
}
|
||||
mask := m.d.Song.Patch[m.d.InstrIndex].ThreadMaskM1 + 1
|
||||
return mask&(1<<bit) != 0
|
||||
}
|
||||
|
||||
func (m *Model) setThreadsBit(bit int, value bool) {
|
||||
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
|
||||
return
|
||||
}
|
||||
defer (*Model)(m).change("ThreadBitMask", PatchChange, MinorChange)()
|
||||
mask := m.d.Song.Patch[m.d.InstrIndex].ThreadMaskM1 + 1
|
||||
if value {
|
||||
mask |= (1 << bit)
|
||||
} else {
|
||||
mask &^= (1 << bit)
|
||||
}
|
||||
m.d.Song.Patch[m.d.InstrIndex].ThreadMaskM1 = max(mask-1, 0) // -1 would have all threads disabled, so make that 0 i.e. use at least thread 1
|
||||
m.warnAboutCrossThreadSends()
|
||||
m.warnNoMultithreadSupport()
|
||||
}
|
||||
|
||||
func (m *Model) warnAboutCrossThreadSends() {
|
||||
for i, instr := range m.d.Song.Patch {
|
||||
for _, unit := range instr.Units {
|
||||
if unit.Type == "send" {
|
||||
targetID, ok := unit.Parameters["target"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
it, _, err := m.d.Song.Patch.FindUnit(targetID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if instr.ThreadMaskM1 != m.d.Song.Patch[it].ThreadMaskM1 {
|
||||
m.Alerts().AddNamed("CrossThreadSend", fmt.Sprintf("Instrument %d '%s' has a send to instrument %d '%s' but they are not on the same threads, which may cause issues", i+1, instr.Name, it+1, m.d.Song.Patch[it].Name), Warning)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) warnNoMultithreadSupport() {
|
||||
for _, instr := range m.d.Song.Patch {
|
||||
if instr.ThreadMaskM1 > 0 && !m.synthers[m.syntherIndex].SupportsMultithreading() {
|
||||
m.Alerts().AddNamed("NoMultithreadSupport", "The current synth does not support multithreading and the patch was configured to use more than one thread", Warning)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) Thread1() Bool { return MakeEnabledBool((*Thread1)(m)) }
|
||||
func (m *Thread1) Value() bool { return (*Model)(m).getThreadsBit(0) }
|
||||
func (m *Thread1) SetValue(val bool) { (*Model)(m).setThreadsBit(0, val) }
|
||||
|
||||
func (m *Model) Thread2() Bool { return MakeEnabledBool((*Thread2)(m)) }
|
||||
func (m *Thread2) Value() bool { return (*Model)(m).getThreadsBit(1) }
|
||||
func (m *Thread2) SetValue(val bool) { (*Model)(m).setThreadsBit(1, val) }
|
||||
|
||||
func (m *Model) Thread3() Bool { return MakeEnabledBool((*Thread3)(m)) }
|
||||
func (m *Thread3) Value() bool { return (*Model)(m).getThreadsBit(2) }
|
||||
func (m *Thread3) SetValue(val bool) { (*Model)(m).setThreadsBit(2, val) }
|
||||
|
||||
func (m *Model) Thread4() Bool { return MakeEnabledBool((*Thread4)(m)) }
|
||||
func (m *Thread4) Value() bool { return (*Model)(m).getThreadsBit(3) }
|
||||
func (m *Thread4) SetValue(val bool) { (*Model)(m).setThreadsBit(3, val) }
|
||||
|
||||
// Panic methods
|
||||
|
||||
func (m *Model) Panic() Bool { return MakeEnabledBool((*Panic)(m)) }
|
||||
|
||||
@ -19,6 +19,7 @@ type (
|
||||
list *layout.List
|
||||
soloBtn *Clickable
|
||||
muteBtn *Clickable
|
||||
threadBtns [4]*Clickable
|
||||
soloHint string
|
||||
unsoloHint string
|
||||
muteHint string
|
||||
@ -38,6 +39,7 @@ func NewInstrumentProperties() *InstrumentProperties {
|
||||
muteBtn: new(Clickable),
|
||||
voices: NewNumericUpDownState(),
|
||||
splitInstrumentBtn: new(Clickable),
|
||||
threadBtns: [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 {
|
||||
thread1btn := ToggleIconBtn(tr.Thread1(), tr.Theme, ip.threadBtns[0], icons.ImageCropSquare, icons.ImageFilter1, "Do not render instrument on thread 1", "Render instrument on thread 1")
|
||||
thread2btn := ToggleIconBtn(tr.Thread2(), tr.Theme, ip.threadBtns[1], icons.ImageCropSquare, icons.ImageFilter2, "Do not render instrument on thread 2", "Render instrument on thread 2")
|
||||
thread3btn := ToggleIconBtn(tr.Thread3(), tr.Theme, ip.threadBtns[2], icons.ImageCropSquare, icons.ImageFilter3, "Do not render instrument on thread 3", "Render instrument on thread 3")
|
||||
thread4btn := ToggleIconBtn(tr.Thread4(), tr.Theme, ip.threadBtns[3], icons.ImageCropSquare, icons.ImageFilter4, "Do not render instrument on thread 4", "Render instrument on thread 4")
|
||||
|
||||
threadbtnline := func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(thread1btn.Layout),
|
||||
layout.Rigid(thread2btn.Layout),
|
||||
layout.Rigid(thread3btn.Layout),
|
||||
layout.Rigid(thread4btn.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, "Thread", threadbtnline)
|
||||
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")
|
||||
})
|
||||
@ -94,7 +112,7 @@ func (ip *InstrumentProperties) layout(gtx C) D {
|
||||
|
||||
func layoutInstrumentPropertyLine(gtx C, text string, content layout.Widget) D {
|
||||
tr := TrackerFromContext(gtx)
|
||||
gtx.Constraints.Max.X = min(gtx.Dp(unit.Dp(200)), gtx.Constraints.Max.X)
|
||||
gtx.Constraints.Max.X = min(gtx.Dp(300), gtx.Constraints.Max.X)
|
||||
label := Label(tr.Theme, &tr.Theme.InstrumentEditor.Properties.Label, text)
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(layout.Spacer{Width: 6, Height: 36}.Layout),
|
||||
|
||||
@ -4,15 +4,19 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gioui.org/gesture"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"github.com/vsariola/sointu"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
"github.com/vsariola/sointu/version"
|
||||
"github.com/vsariola/sointu/vm"
|
||||
"golang.org/x/exp/shiny/materialdesign/icons"
|
||||
)
|
||||
|
||||
@ -110,10 +114,37 @@ func (t *SongPanel) layoutSongOptions(gtx C) D {
|
||||
}
|
||||
oversamplingBtn := Btn(tr.Theme, &tr.Theme.Button.Text, t.OversamplingBtn, oversamplingTxt, "")
|
||||
|
||||
cpuload := tr.Model.CPULoad()
|
||||
cpuLabel := Label(tr.Theme, &tr.Theme.SongPanel.RowValue, fmt.Sprintf("%.0f %%", cpuload*100))
|
||||
if cpuload >= 1 {
|
||||
cpuLabel.Color = tr.Theme.SongPanel.ErrorColor
|
||||
cpuSmallLabel := func(gtx C) D {
|
||||
var a [vm.MAX_THREADS]sointu.CPULoad
|
||||
c := tr.Model.CPULoad(a[:])
|
||||
load := slices.Max(a[:c])
|
||||
cpuLabel := Label(tr.Theme, &tr.Theme.SongPanel.RowValue, fmt.Sprintf("%d%%", int(load*100+0.5)))
|
||||
if load >= 1 {
|
||||
cpuLabel.Color = tr.Theme.SongPanel.ErrorColor
|
||||
}
|
||||
return cpuLabel.Layout(gtx)
|
||||
}
|
||||
|
||||
cpuEnlargedWidget := func(gtx C) D {
|
||||
var sb strings.Builder
|
||||
var a [vm.MAX_THREADS]sointu.CPULoad
|
||||
c := tr.Model.CPULoad(a[:])
|
||||
high := false
|
||||
for i := range c {
|
||||
if i > 0 {
|
||||
fmt.Fprint(&sb, ", ")
|
||||
}
|
||||
cpuLoad := a[i]
|
||||
fmt.Fprintf(&sb, "%d%%", int(cpuLoad*100+0.5))
|
||||
if cpuLoad >= 1 {
|
||||
high = true
|
||||
}
|
||||
}
|
||||
cpuLabel := Label(tr.Theme, &tr.Theme.SongPanel.RowValue, sb.String())
|
||||
if high {
|
||||
cpuLabel.Color = tr.Theme.SongPanel.ErrorColor
|
||||
}
|
||||
return cpuLabel.Layout(gtx)
|
||||
}
|
||||
|
||||
synthBtn := Btn(tr.Theme, &tr.Theme.Button.Text, t.SynthBtn, tr.Model.SyntherName(), "")
|
||||
@ -150,10 +181,10 @@ func (t *SongPanel) layoutSongOptions(gtx C) D {
|
||||
})
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return t.CPUExpander.Layout(gtx, tr.Theme, "CPU", cpuLabel.Layout,
|
||||
return t.CPUExpander.Layout(gtx, tr.Theme, "CPU", cpuSmallLabel,
|
||||
func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D { return layoutSongOptionRow(gtx, tr.Theme, "Load", cpuLabel.Layout) }),
|
||||
layout.Rigid(func(gtx C) D { return layoutSongOptionRow(gtx, tr.Theme, "Load", cpuEnlargedWidget) }),
|
||||
layout.Rigid(func(gtx C) D { return layoutSongOptionRow(gtx, tr.Theme, "Synth", synthBtn.Layout) }),
|
||||
)
|
||||
},
|
||||
|
||||
@ -397,7 +397,9 @@ func (m *Model) ProcessMsg(msg MsgToModel) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) CPULoad() float64 { return m.playerStatus.CPULoad }
|
||||
func (m *Model) CPULoad(buf []sointu.CPULoad) int {
|
||||
return copy(buf, m.playerStatus.CPULoad[:m.playerStatus.NumThreads])
|
||||
}
|
||||
|
||||
func (m *Model) SignalAnalyzer() *ScopeModel { return m.signalAnalyzer }
|
||||
func (m *Model) Broker() *Broker { return m.broker }
|
||||
|
||||
@ -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
|
||||
NumThreads int
|
||||
CPULoad [vm.MAX_THREADS]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)))
|
||||
|
||||
@ -127,12 +124,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 {
|
||||
@ -164,17 +161,26 @@ 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.NumThreads = p.synth.CPULoad(p.status.CPULoad[:])
|
||||
}
|
||||
p.send(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 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 +233,7 @@ loop:
|
||||
switch m := msg.(type) {
|
||||
case PanicMsg:
|
||||
if m.bool {
|
||||
p.synth = nil
|
||||
p.destroySynth()
|
||||
} else {
|
||||
p.compileOrUpdateSynth()
|
||||
}
|
||||
@ -283,7 +289,7 @@ loop:
|
||||
}
|
||||
case sointu.Synther:
|
||||
p.synther = m
|
||||
p.synth = nil
|
||||
p.destroySynth()
|
||||
p.compileOrUpdateSynth()
|
||||
default:
|
||||
// ignore unknown messages
|
||||
@ -355,7 +361,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 +369,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
|
||||
}
|
||||
@ -441,14 +447,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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user