From 77b27257fead7caa45bfc5b44dc54095fb8872bd Mon Sep 17 00:00:00 2001 From: "5684185+vsariola@users.noreply.github.com" <5684185+vsariola@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:20:01 +0200 Subject: [PATCH] fix(tracker): Player routes MIDImsgs so always handled in same block --- cmd/sointu-vsti/main.go | 15 +---- tracker/broker.go | 60 +++++++++---------- tracker/gioui/note_editor.go | 18 +++--- tracker/gioui/tracker.go | 24 ++++---- tracker/gomidi/midi.go | 28 ++++----- tracker/midi.go | 112 ++++++++++++++++++++++++++--------- tracker/model.go | 12 ++-- tracker/player.go | 18 ++++++ 8 files changed, 176 insertions(+), 111 deletions(-) diff --git a/cmd/sointu-vsti/main.go b/cmd/sointu-vsti/main.go index 358b32e..3fd9adb 100644 --- a/cmd/sointu-vsti/main.go +++ b/cmd/sointu-vsti/main.go @@ -97,19 +97,8 @@ func init() { for i := 0; i < events.NumEvents(); i++ { switch ev := events.Event(i).(type) { case *vst2.MIDIEvent: - switch { - case ev.Data[0] >= 0x80 && ev.Data[0] <= 0x9F: - channel := ev.Data[0] & 0x0F - note := ev.Data[1] - on := ev.Data[0] >= 0x90 - trackerEvent := tracker.NoteEvent{Timestamp: int64(ev.DeltaFrames) + totalFrames, On: on, Channel: int(channel), Note: note, Source: &context} - tracker.TrySend(broker.ToMIDIRouter, any(&trackerEvent)) - case ev.Data[0] >= 0xB0 && ev.Data[0] <= 0xBF: - channel := ev.Data[0] & 0x0F - controller := ev.Data[1] - value := ev.Data[2] - trackerEvent := tracker.ControlChange{Channel: int(channel), Control: int(controller), Value: int(value)} - tracker.TrySend(broker.ToMIDIRouter, any(&trackerEvent)) + if (ev.Data[0] >= 0x80 && ev.Data[0] <= 0x9F) || (ev.Data[0] >= 0xB0 && ev.Data[0] <= 0xBF) { + player.EmitMIDIMsg(&tracker.MIDIMessage{Timestamp: int64(ev.DeltaFrames) + totalFrames, Data: ev.Data, Source: &context}) } } } diff --git a/tracker/broker.go b/tracker/broker.go index 02dbbe8..2bc15ec 100644 --- a/tracker/broker.go +++ b/tracker/broker.go @@ -32,22 +32,22 @@ type ( // case <-time.After(3 * time.Second): // } Broker struct { - ToModel chan MsgToModel - ToPlayer chan any // TODO: consider using a sum type here, for a bit more type safety. See: https://www.jerf.org/iri/post/2917/ - ToDetector chan MsgToDetector - ToGUI chan any - ToSpecAn chan MsgToSpecAn - ToMIDIRouter chan any + ToModel chan MsgToModel + ToPlayer chan any // TODO: consider using a sum type here, for a bit more type safety. See: https://www.jerf.org/iri/post/2917/ + ToDetector chan MsgToDetector + ToGUI chan any + ToSpecAn chan MsgToSpecAn + ToMIDIHandler chan any - CloseDetector chan struct{} - CloseGUI chan struct{} - CloseSpecAn chan struct{} - CloseMIDIRouter chan struct{} + CloseDetector chan struct{} + CloseGUI chan struct{} + CloseSpecAn chan struct{} + CloseMIDIHandler chan struct{} - FinishedGUI chan struct{} - FinishedDetector chan struct{} - FinishedSpecAn chan struct{} - FinishedMIDIRouter chan struct{} + FinishedGUI chan struct{} + FinishedDetector chan struct{} + FinishedSpecAn chan struct{} + FinishedMIDIHandler chan struct{} bufferPool sync.Pool spectrumPool sync.Pool @@ -111,22 +111,22 @@ const ( func NewBroker() *Broker { return &Broker{ - ToPlayer: make(chan any, 1024), - ToModel: make(chan MsgToModel, 1024), - ToDetector: make(chan MsgToDetector, 1024), - ToGUI: make(chan any, 1024), - ToMIDIRouter: make(chan any, 1024), - ToSpecAn: make(chan MsgToSpecAn, 1024), - CloseDetector: make(chan struct{}, 1), - CloseGUI: make(chan struct{}, 1), - CloseSpecAn: make(chan struct{}, 1), - CloseMIDIRouter: make(chan struct{}, 1), - FinishedGUI: make(chan struct{}), - FinishedDetector: make(chan struct{}), - FinishedSpecAn: make(chan struct{}), - FinishedMIDIRouter: make(chan struct{}), - bufferPool: sync.Pool{New: func() any { return &sointu.AudioBuffer{} }}, - spectrumPool: sync.Pool{New: func() any { return &Spectrum{} }}, + ToPlayer: make(chan any, 1024), + ToModel: make(chan MsgToModel, 1024), + ToDetector: make(chan MsgToDetector, 1024), + ToGUI: make(chan any, 1024), + ToMIDIHandler: make(chan any, 1024), + ToSpecAn: make(chan MsgToSpecAn, 1024), + CloseDetector: make(chan struct{}, 1), + CloseGUI: make(chan struct{}, 1), + CloseSpecAn: make(chan struct{}, 1), + CloseMIDIHandler: make(chan struct{}, 1), + FinishedGUI: make(chan struct{}), + FinishedDetector: make(chan struct{}), + FinishedSpecAn: make(chan struct{}), + FinishedMIDIHandler: make(chan struct{}), + bufferPool: sync.Pool{New: func() any { return &sointu.AudioBuffer{} }}, + spectrumPool: sync.Pool{New: func() any { return &Spectrum{} }}, } } diff --git a/tracker/gioui/note_editor.go b/tracker/gioui/note_editor.go index 980ea42..feee14e 100644 --- a/tracker/gioui/note_editor.go +++ b/tracker/gioui/note_editor.go @@ -134,16 +134,20 @@ func (te *NoteEditor) Layout(gtx layout.Context) layout.Dimensions { } } - for gtx.Focused(te.scrollTable) && len(t.noteEvents) > 0 { - ev := t.noteEvents[0] - ev.IsTrack = true - ev.Channel = t.Model.Note().Cursor().X - ev.Source = te + for gtx.Focused(te.scrollTable) && len(t.midiMsgs) > 0 { + ev := tracker.NoteEvent{ + Timestamp: t.midiMsgs[0].Timestamp, + Note: t.midiMsgs[0].Data[1], + On: t.midiMsgs[0].Data[0]&0xF0 != 0x80, + IsTrack: true, + Channel: t.Model.Note().Cursor().X, + Source: t.midiMsgs[0].Source, + } if ev.On { t.Model.Note().Input(ev.Note) } - copy(t.noteEvents, t.noteEvents[1:]) - t.noteEvents = t.noteEvents[:len(t.noteEvents)-1] + copy(t.midiMsgs, t.midiMsgs[1:]) + t.midiMsgs = t.midiMsgs[:len(t.midiMsgs)-1] tracker.TrySend(t.Broker().ToPlayer, any(&ev)) } diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index cccde61..e44d0e7 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -51,7 +51,7 @@ type ( SongPanel *SongPanel filePathString tracker.String - noteEvents []tracker.NoteEvent + midiMsgs []*tracker.MIDIMessage preferences Preferences @@ -145,8 +145,8 @@ func (t *Tracker) Main() { select { case e := <-t.Broker().ToGUI: switch e := e.(type) { - case *tracker.NoteEvent: - t.noteEvents = append(t.noteEvents, *e) + case *tracker.MIDIMessage: + t.midiMsgs = append(t.midiMsgs, e) case tracker.MsgToGUI: switch e.Kind { case tracker.GUIMessageCenterOnRow: @@ -267,13 +267,17 @@ func (t *Tracker) Layout(gtx layout.Context) { } } // if no-one else handled the note events, we handle them here - for len(t.noteEvents) > 0 { - ev := t.noteEvents[0] - ev.IsTrack = false - ev.Channel = t.Model.Instrument().List().Selected() - ev.Source = t - copy(t.noteEvents, t.noteEvents[1:]) - t.noteEvents = t.noteEvents[:len(t.noteEvents)-1] + for len(t.midiMsgs) > 0 { + ev := tracker.NoteEvent{ + Timestamp: t.midiMsgs[0].Timestamp, + Note: t.midiMsgs[0].Data[1], + On: t.midiMsgs[0].Data[0]&0xF0 != 0x80, + IsTrack: false, + Channel: t.Model.Instrument().List().Selected(), + Source: t.midiMsgs[0].Source, + } + copy(t.midiMsgs, t.midiMsgs[1:]) + t.midiMsgs = t.midiMsgs[:len(t.midiMsgs)-1] tracker.TrySend(t.Broker().ToPlayer, any(&ev)) } } diff --git a/tracker/gomidi/midi.go b/tracker/gomidi/midi.go index 12e648f..a919173 100644 --- a/tracker/gomidi/midi.go +++ b/tracker/gomidi/midi.go @@ -46,7 +46,7 @@ func (m *RTMIDIContext) Inputs(yield func(input tracker.MIDIInputDevice) bool) { } for _, in := range ins { r := RTMIDIInputDevice{In: in, broker: m.broker} - if !yield(r) { + if !yield(&r) { break } } @@ -67,27 +67,21 @@ func (c *RTMIDIContext) Support() tracker.MIDISupport { } // Open an input device and starting the listener. -func (m RTMIDIInputDevice) Open() error { +func (m *RTMIDIInputDevice) Open(h func(msg *tracker.MIDIMessage)) error { if err := m.In.Open(); err != nil { return fmt.Errorf("opening MIDI input failed: %w", err) } - if _, err := midi.ListenTo(m.In, m.handleMessage); err != nil { + q := func(msg midi.Message, timestampms int32) { + if len(msg.Bytes()) == 0 || len(msg.Bytes()) > 3 { + return + } + t := tracker.MIDIMessage{Timestamp: int64(timestampms) * 441 / 10, Source: m} + copy(t.Data[:], msg.Bytes()) + h(&t) + } + if _, err := midi.ListenTo(m.In, q); err != nil { m.In.Close() return fmt.Errorf("listening to MIDI input failed: %w", err) } return nil } - -func (m *RTMIDIInputDevice) handleMessage(msg midi.Message, timestampms int32) { - var channel, key, velocity, controller, value uint8 - if msg.GetNoteOn(&channel, &key, &velocity) { - ev := tracker.NoteEvent{Timestamp: int64(timestampms) * 441 / 10, On: true, Channel: int(channel), Note: key, Source: m} - tracker.TrySend(m.broker.ToMIDIRouter, any(&ev)) - } else if msg.GetNoteOff(&channel, &key, &velocity) { - ev := tracker.NoteEvent{Timestamp: int64(timestampms) * 441 / 10, On: false, Channel: int(channel), Note: key, Source: m} - tracker.TrySend(m.broker.ToMIDIRouter, any(&ev)) - } else if msg.GetControlChange(&channel, &controller, &value) { - ev := tracker.ControlChange{Channel: int(channel), Control: int(controller), Value: int(value)} - tracker.TrySend(m.broker.ToMIDIRouter, any(&ev)) - } -} diff --git a/tracker/midi.go b/tracker/midi.go index 23c6950..d05fb1d 100644 --- a/tracker/midi.go +++ b/tracker/midi.go @@ -11,8 +11,8 @@ func (m *Model) MIDI() *MIDIModel { return (*MIDIModel)(m) } type ( midiState struct { - noteEventsToGui bool - binding bool + binding bool + router midiRouter currentInput MIDIInputDevice context MIDIContext @@ -26,7 +26,7 @@ type ( } MIDIInputDevice interface { - Open() error + Open(func(msg *MIDIMessage)) error Close() error IsOpen() bool String() string @@ -56,7 +56,10 @@ func (m *midiRefresh) Do() { if m.midi.currentInput != nil && i.String() == m.midi.currentInput.String() { m.midi.currentInput.Close() m.midi.currentInput = nil - if err := i.Open(); err != nil { + handler := func(msg *MIDIMessage) { + TrySend(m.broker.ToMIDIHandler, any(msg)) + } + if err := i.Open(handler); err != nil { (*Model)(m).Alerts().Add(fmt.Sprintf("Failed to reopen MIDI input port: %s", err.Error()), Error) continue } @@ -96,7 +99,10 @@ func (m *midiInputDevices) SetValue(val int) bool { return true } newInput := m.midi.inputs[val-1] - if err := newInput.Open(); err != nil { + handler := func(msg *MIDIMessage) { + TrySend(m.broker.ToMIDIHandler, any(msg)) + } + if err := newInput.Open(handler); err != nil { (*Model)(m).Alerts().Add(fmt.Sprintf("Failed to open MIDI input port: %s", err.Error()), Error) return false } @@ -131,34 +137,82 @@ func (m *MIDIModel) InputtingNotes() Bool { return MakeBool((*midiInputtingNotes type midiInputtingNotes Model -func (m *midiInputtingNotes) Value() bool { return m.midi.noteEventsToGui } +func (m *midiInputtingNotes) Value() bool { return m.midi.router.sendNoteEventsToGUI } func (m *midiInputtingNotes) SetValue(val bool) { - m.midi.noteEventsToGui = val - TrySend(m.broker.ToMIDIRouter, any(setNoteEventsToGUI(val))) + m.midi.router.sendNoteEventsToGUI = val + TrySend(m.broker.ToMIDIHandler, any(m.midi.router)) + TrySend(m.broker.ToPlayer, any(m.midi.router)) } -type setNoteEventsToGUI bool +// MIDIMessage represents a MIDI message received from a MIDI input port or VST +// host. +type MIDIMessage struct { + Timestamp int64 // in samples (at 44100 Hz) + Data [3]byte + Source any // tag to identify the source of the message; any unique pointer will do +} -func runMIDIRouter(broker *Broker) { - noteEventsToGUI := false +func (m *MIDIMessage) isNoteOff() bool { return m.Data[0]&0xF0 == 0x80 } +func (m *MIDIMessage) isNoteOn() bool { return m.Data[0]&0xF0 == 0x90 } +func (m *MIDIMessage) isControlChange() bool { return m.Data[0]&0xF0 == 0xB0 } + +func (m *MIDIMessage) getNoteOn() (channel, note, velocity byte, ok bool) { + if !m.isNoteOn() { + return 0, 0, 0, false + } + return m.Data[0] & 0x0F, m.Data[1], m.Data[2], true +} + +func (m *MIDIMessage) getNoteOff() (channel, note, velocity byte, ok bool) { + if !m.isNoteOff() { + return 0, 0, 0, false + } + return m.Data[0] & 0x0F, m.Data[1], m.Data[2], true +} + +func (m *MIDIMessage) getControlChange() (channel, controller, value byte, ok bool) { + if !m.isControlChange() { + return 0, 0, 0, false + } + return m.Data[0] & 0x0F, m.Data[1], m.Data[2], true +} + +// midiRouter encompasses all the necessary information where MIDIMessages +// should be forwarded. MIDIHandler and Player have their own copies of the +// midiRouter so that the messages don't have to pass through other goroutines +// to be routed. Model has also a copy to display a gui to modify it. +type midiRouter struct { + sendNoteEventsToGUI bool +} + +func (r *midiRouter) route(b *Broker, msg *MIDIMessage) (ok bool) { + switch { + case msg.isNoteOn() || msg.isNoteOff(): + if r.sendNoteEventsToGUI { + return TrySend(b.ToGUI, any(msg)) + } else { + return TrySend(b.ToPlayer, any(msg)) + } + case msg.isControlChange(): + return TrySend(b.ToModel, MsgToModel{Data: msg}) + } + return false +} + +func runMIDIHandler(b *Broker) { + router := midiRouter{sendNoteEventsToGUI: false} for { select { - case <-broker.CloseMIDIRouter: - close(broker.FinishedMIDIRouter) - return - case msg := <-broker.ToMIDIRouter: - switch m := msg.(type) { - case setNoteEventsToGUI: - noteEventsToGUI = bool(m) - case *NoteEvent: - if noteEventsToGUI { - TrySend(broker.ToGUI, msg) - continue - } - TrySend(broker.ToPlayer, msg) - case *ControlChange: - TrySend(broker.ToModel, MsgToModel{Data: msg}) + case v := <-b.ToMIDIHandler: + switch msg := v.(type) { + case *MIDIMessage: + router.route(b, msg) + case midiRouter: + router = msg } + case <-b.CloseMIDIHandler: + close(b.FinishedMIDIHandler) + return } } } @@ -223,8 +277,8 @@ func (m *MIDIModel) selectedParam() (MIDIParam, bool) { return value, true } -func (m *MIDIModel) handleControlEvent(e ControlChange) { - key := MIDIControl{Channel: e.Channel, Control: e.Control} +func (m *MIDIModel) handleControlEvent(channel, control, value int) { + key := MIDIControl{Channel: channel, Control: control} if m.midi.binding { m.midi.binding = false value, ok := m.selectedParam() @@ -246,7 +300,7 @@ func (m *MIDIModel) handleControlEvent(e ControlChange) { // +62 is chose so that the center position of a typical MIDI controller, // which is 64, maps to 64 of a 0..128 range Sointu parameter. From there // on, 65 maps to 66 and, importantly, 127 maps to 128. - newVal := (e.Value*(t.Max-t.Min)+62)/127 + t.Min + newVal := (value*(t.Max-t.Min)+62)/127 + t.Min if m.d.Song.Patch[i].Units[u].Parameters[t.Param] == newVal { return } diff --git a/tracker/model.go b/tracker/model.go index 1cc7b46..954b29c 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -203,17 +203,17 @@ func NewModel(broker *Broker, synthers []sointu.Synther, midiContext MIDIContext m.Play().setSynther(0, false) go runDetector(broker) go runSpecAnalyzer(broker) - go runMIDIRouter(broker) + go runMIDIHandler(broker) return m } func (m *Model) Close() { TrySend(m.broker.CloseDetector, struct{}{}) TrySend(m.broker.CloseSpecAn, struct{}{}) - TrySend(m.broker.CloseMIDIRouter, struct{}{}) + TrySend(m.broker.CloseMIDIHandler, struct{}{}) TimeoutReceive(m.broker.FinishedDetector, 3*time.Second) TimeoutReceive(m.broker.FinishedSpecAn, 3*time.Second) - TimeoutReceive(m.broker.FinishedMIDIRouter, 3*time.Second) + TimeoutReceive(m.broker.FinishedMIDIHandler, 3*time.Second) } // RequestQuit asks the tracker to quit, showing a dialog if there are unsaved @@ -389,8 +389,10 @@ func (m *Model) ProcessMsg(msg MsgToModel) { case *Spectrum: m.broker.PutSpectrum(m.spectrum) m.spectrum = e - case *ControlChange: - m.MIDI().handleControlEvent(*e) + case *MIDIMessage: + if channel, control, value, ok := e.getControlChange(); ok { + m.MIDI().handleControlEvent(int(channel), int(control), int(value)) + } } } diff --git a/tracker/player.go b/tracker/player.go index edb0368..4c98fd1 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -30,6 +30,8 @@ type ( frameDeltas map[any]int64 // Player.frame (approx.)= event.Timestamp + frameDeltas[event.Source] events NoteEventList + midiRouter midiRouter + status PlayerStatus // the part of the Player state that is communicated to the model to visualize what Player is doing synther sointu.Synther // the synther used to create new synths @@ -180,6 +182,8 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext p.SendAlert("PlayerCrash", fmt.Sprintf("synth did not fill the audio buffer even with %d render calls", numRenderTries), Error) } +func (p *Player) EmitMIDIMsg(msg *MIDIMessage) bool { return p.midiRouter.route(p.broker, msg) } + func (p *Player) destroySynth() { if p.synth != nil { p.synth.Close() @@ -282,6 +286,20 @@ loop: TrySend(p.broker.ToModel, MsgToModel{Reset: true}) case *NoteEvent: p.events = append(p.events, *m) + case *MIDIMessage: + // In future, here we should map midi channels to various + // instruments, possible mapping the velocity to another + // instrument as well + if m.Data[0] >= 0x80 && m.Data[0] <= 0x9F { + p.events = append(p.events, NoteEvent{ + Timestamp: m.Timestamp, + Channel: int(m.Data[0] & 0x0F), + Note: m.Data[1], + On: m.Data[0] >= 0x90, + Source: m.Source}) + } + case midiRouter: + p.midiRouter = m case RecordingMsg: if m.bool { p.recording = Recording{State: RecordingWaitingForNote}