From f2ef57a8451b340b85527aac2e5238d89e87cf72 Mon Sep 17 00:00:00 2001 From: "5684185+vsariola@users.noreply.github.com" <5684185+vsariola@users.noreply.github.com> Date: Sat, 31 Jan 2026 23:18:14 +0200 Subject: [PATCH] feat(tracker): ability to bind MIDI controllers to parameters Closes #152 --- CHANGELOG.md | 5 + cmd/sointu-vsti/main.go | 11 +- tracker/broker.go | 67 +++++----- tracker/gioui/keybindings.go | 6 + tracker/gioui/keybindings.yml | 3 + tracker/gioui/keyboard.go | 4 +- tracker/gioui/note_editor.go | 2 +- tracker/gioui/song_panel.go | 8 +- tracker/gioui/theme.yml | 2 +- tracker/gioui/tracker.go | 6 +- tracker/gomidi/midi.go | 9 +- tracker/history.go | 1 + tracker/midi.go | 225 +++++++++++++++++++++++++++++++++- tracker/model.go | 7 ++ tracker/player.go | 10 +- 15 files changed, 311 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b200de..1b0e99f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added +- Ability to bind MIDI controllers to specific parameters. The MIDI menu has the + options to bind/unbind parameters. When the user starts binding a parameter, + Sointu waits for the next MIDI Control Change event and binds the currently + selected parameter to that controller. ([#152][i152]) - Plot the envelope shape on top of the oscilloscope when the envelope unit is selected. - Spectrum analyzer showing the spectrum. When the user has a filter or belleq @@ -389,6 +393,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). [i149]: https://github.com/vsariola/sointu/issues/149 [i150]: https://github.com/vsariola/sointu/issues/150 [i151]: https://github.com/vsariola/sointu/issues/151 +[i152]: https://github.com/vsariola/sointu/issues/152 [i153]: https://github.com/vsariola/sointu/issues/153 [i154]: https://github.com/vsariola/sointu/issues/154 [i155]: https://github.com/vsariola/sointu/issues/155 diff --git a/cmd/sointu-vsti/main.go b/cmd/sointu-vsti/main.go index 1a5d871..358b32e 100644 --- a/cmd/sointu-vsti/main.go +++ b/cmd/sointu-vsti/main.go @@ -97,12 +97,19 @@ func init() { for i := 0; i < events.NumEvents(); i++ { switch ev := events.Event(i).(type) { case *vst2.MIDIEvent: - if ev.Data[0] >= 0x80 && ev.Data[0] <= 0x9F { + 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.MIDIChannel(), any(trackerEvent)) + 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)) } } } diff --git a/tracker/broker.go b/tracker/broker.go index 1efe1aa..02dbbe8 100644 --- a/tracker/broker.go +++ b/tracker/broker.go @@ -2,7 +2,6 @@ package tracker import ( "sync" - "sync/atomic" "time" "github.com/vsariola/sointu" @@ -33,24 +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 + 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 - CloseDetector chan struct{} - CloseGUI chan struct{} - CloseSpecAn chan struct{} + CloseDetector chan struct{} + CloseGUI chan struct{} + CloseSpecAn chan struct{} + CloseMIDIRouter chan struct{} - FinishedGUI chan struct{} - FinishedDetector chan struct{} - FinishedSpecAn chan struct{} - - // mIDIEventsToGUI is true if all MIDI events should be sent to the GUI, - // for inputting notes to tracks. If false, they should be sent to the - // player instead. - mIDIEventsToGUI atomic.Bool + FinishedGUI chan struct{} + FinishedDetector chan struct{} + FinishedSpecAn chan struct{} + FinishedMIDIRouter chan struct{} bufferPool sync.Pool spectrumPool sync.Pool @@ -114,29 +111,25 @@ 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), - ToSpecAn: make(chan MsgToSpecAn, 1024), - CloseDetector: make(chan struct{}, 1), - CloseGUI: make(chan struct{}, 1), - CloseSpecAn: make(chan struct{}, 1), - FinishedGUI: make(chan struct{}), - FinishedDetector: make(chan struct{}), - FinishedSpecAn: 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), + 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{} }}, } } -func (b *Broker) MIDIChannel() chan<- any { - if b.mIDIEventsToGUI.Load() { - return b.ToGUI - } - return b.ToPlayer -} - // GetAudioBuffer returns an audio buffer from the buffer pool. The buffer is // guaranteed to be empty. After using the buffer, it should be returned to the // pool with PutAudioBuffer. diff --git a/tracker/gioui/keybindings.go b/tracker/gioui/keybindings.go index cc6fce5..a0da69c 100644 --- a/tracker/gioui/keybindings.go +++ b/tracker/gioui/keybindings.go @@ -287,6 +287,12 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) { t.MIDI().Refresh().Do() case "ToggleMIDIInputtingNotes": t.MIDI().InputtingNotes().Toggle() + case "ToggleMIDIBinding": + t.MIDI().Binding().Toggle() + case "MIDIUnbind": + t.MIDI().Unbind().Do() + case "MIDIUnbindAll": + t.MIDI().UnbindAll().Do() default: if len(action) > 4 && action[:4] == "Note" { val, err := strconv.Atoi(string(action[4:])) diff --git a/tracker/gioui/keybindings.yml b/tracker/gioui/keybindings.yml index 4b0281d..714e481 100644 --- a/tracker/gioui/keybindings.yml +++ b/tracker/gioui/keybindings.yml @@ -28,6 +28,9 @@ - { key: "E", shortcut: true, action: "InstrEnlargedToggle" } - { key: "K", shortcut: true, action: "LinkInstrTrackToggle" } - { key: "W", shortcut: true, action: "Quit" } +- { key: "B", shortcut: true, action: "ToggleMIDIBinding" } +- { key: "U", shortcut: true, action: "MIDIUnbind" } +- { key: "U", shortcut: true, shift: true, action: "MIDIUnbindAll" } - { key: "Space", action: "PlayingToggleUnfollow" } - { key: "Space", shift: true, action: "PlayingToggleFollow" } - { key: "F1", action: "OrderEditorFocus" } diff --git a/tracker/gioui/keyboard.go b/tracker/gioui/keyboard.go index 3acc2a2..33b8c8a 100644 --- a/tracker/gioui/keyboard.go +++ b/tracker/gioui/keyboard.go @@ -32,7 +32,7 @@ func (t *Keyboard[T]) Press(key T, ev tracker.NoteEvent) { ev.Source = t // set the source to this keyboard ev.On = true ev.Timestamp = t.now() - if tracker.TrySend(t.broker.ToPlayer, any(ev)) { + if tracker.TrySend(t.broker.ToPlayer, any(&ev)) { t.pressed[key] = ev } } @@ -42,7 +42,7 @@ func (t *Keyboard[T]) Release(key T) { if ev, ok := t.pressed[key]; ok { ev.Timestamp = t.now() ev.On = false // the pressed contains the event we need to send to release the note - tracker.TrySend(t.broker.ToPlayer, any(ev)) + tracker.TrySend(t.broker.ToPlayer, any(&ev)) delete(t.pressed, key) } } diff --git a/tracker/gioui/note_editor.go b/tracker/gioui/note_editor.go index 6b1a20f..980ea42 100644 --- a/tracker/gioui/note_editor.go +++ b/tracker/gioui/note_editor.go @@ -144,7 +144,7 @@ func (te *NoteEditor) Layout(gtx layout.Context) layout.Dimensions { } copy(t.noteEvents, t.noteEvents[1:]) t.noteEvents = t.noteEvents[:len(t.noteEvents)-1] - tracker.TrySend(t.Broker().ToPlayer, any(ev)) + tracker.TrySend(t.Broker().ToPlayer, any(&ev)) } defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() diff --git a/tracker/gioui/song_panel.go b/tracker/gioui/song_panel.go index af22dbc..5249277 100644 --- a/tracker/gioui/song_panel.go +++ b/tracker/gioui/song_panel.go @@ -508,9 +508,13 @@ func (t *MenuBar) Layout(gtx C) D { midiBtn := MenuBtn(&t.MenuStates[2], &t.Clickables[2], "MIDI") midiFC := layout.Rigid(func(gtx C) D { return midiBtn.Layout(gtx, - ActionMenuChild(tr.MIDI().Refresh(), "Refresh port list", keyActionMap["MIDIRefresh"], icons.NavigationRefresh), - BoolMenuChild(tr.MIDI().InputtingNotes(), "Use for note input", keyActionMap["ToggleMIDIInputtingNotes"], icons.NavigationCheck), + BoolMenuChild(tr.MIDI().Binding(), "Bind to controller", keyActionMap["ToggleMIDIBinding"], icons.NavigationCheck), + ActionMenuChild(tr.MIDI().Unbind(), "Unbind", keyActionMap["MIDIUnbind"], icons.ImageLeakRemove), + ActionMenuChild(tr.MIDI().UnbindAll(), "Unbind all", keyActionMap["MIDIUnbindAll"], icons.ImageLeakRemove), DividerMenuChild(), + BoolMenuChild(tr.MIDI().InputtingNotes(), "Input notes", keyActionMap["ToggleMIDIInputtingNotes"], icons.NavigationCheck), + DividerMenuChild(), + ActionMenuChild(tr.MIDI().Refresh(), "Refresh", keyActionMap["MIDIRefresh"], icons.NavigationRefresh), IntMenuChild(tr.MIDI().Input(), icons.NavigationCheck), ) }) diff --git a/tracker/gioui/theme.yml b/tracker/gioui/theme.yml index 4867a10..0c4e53c 100644 --- a/tracker/gioui/theme.yml +++ b/tracker/gioui/theme.yml @@ -156,7 +156,7 @@ menu: shortcut: { textsize: 16, color: *mediumemphasis, shadowcolor: *black } hover: { r: 100, g: 140, b: 255, a: 48 } disabled: *disabled - width: 200 + width: 240 height: 300 preset: text: { textsize: 16, color: *highemphasis, shadowcolor: *black } diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index 40758d3..cccde61 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -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.NoteEvent: + t.noteEvents = append(t.noteEvents, *e) case tracker.MsgToGUI: switch e.Kind { case tracker.GUIMessageCenterOnRow: @@ -274,7 +274,7 @@ func (t *Tracker) Layout(gtx layout.Context) { ev.Source = t copy(t.noteEvents, t.noteEvents[1:]) t.noteEvents = t.noteEvents[:len(t.noteEvents)-1] - tracker.TrySend(t.Broker().ToPlayer, any(ev)) + tracker.TrySend(t.Broker().ToPlayer, any(&ev)) } } diff --git a/tracker/gomidi/midi.go b/tracker/gomidi/midi.go index c0dc57e..12e648f 100644 --- a/tracker/gomidi/midi.go +++ b/tracker/gomidi/midi.go @@ -79,12 +79,15 @@ func (m RTMIDIInputDevice) Open() error { } func (m *RTMIDIInputDevice) handleMessage(msg midi.Message, timestampms int32) { - var channel, key, velocity uint8 + 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.MIDIChannel(), any(ev)) + 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.MIDIChannel(), any(ev)) + 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/history.go b/tracker/history.go index 5cc8744..a67ea9b 100644 --- a/tracker/history.go +++ b/tracker/history.go @@ -57,6 +57,7 @@ func (m *historyRedo) Do() { func (m *HistoryModel) MarshalRecovery() []byte { out, err := json.Marshal(m.d) if err != nil { + (*Model)(m).Alerts().Add(fmt.Sprintf("Could not marshal recovery data: %s", err.Error()), Error) return nil } if m.d.RecoveryFilePath != "" { diff --git a/tracker/midi.go b/tracker/midi.go index 8ec27ed..4d73ac3 100644 --- a/tracker/midi.go +++ b/tracker/midi.go @@ -1,6 +1,7 @@ package tracker import ( + "encoding/json" "fmt" ) @@ -10,6 +11,9 @@ func (m *Model) MIDI() *MIDIModel { return (*MIDIModel)(m) } type ( midiState struct { + noteEventsToGui bool + binding bool + currentInput MIDIInputDevice context MIDIContext inputs []MIDIInputDevice @@ -127,8 +131,225 @@ func (m *MIDIModel) InputtingNotes() Bool { return MakeBool((*midiInputtingNotes type midiInputtingNotes Model -func (m *midiInputtingNotes) Value() bool { return m.broker.mIDIEventsToGUI.Load() } -func (m *midiInputtingNotes) SetValue(val bool) { m.broker.mIDIEventsToGUI.Store(val) } +func (m *midiInputtingNotes) Value() bool { return m.midi.noteEventsToGui } +func (m *midiInputtingNotes) SetValue(val bool) { + m.midi.noteEventsToGui = val + TrySend(m.broker.ToMIDIRouter, any(setNoteEventsToGUI(val))) +} + +type setNoteEventsToGUI bool + +func runMIDIRouter(broker *Broker) { + noteEventsToGUI := 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}) + } + } + } +} + +// Binding returns a Bool controlling whether the next received MIDI controller +// event is used to bind a parameter. +func (m *MIDIModel) Binding() Bool { return MakeBool((*midiBinding)(m)) } + +type midiBinding MIDIModel + +func (m *midiBinding) Value() bool { return m.midi.binding } +func (m *midiBinding) SetValue(val bool) { + m.midi.binding = val + if val { + (*Model)(m).Alerts().Add("Move a MIDI controller to bind it to the selected parameter", Info) + } +} + +// Unbind removes the MIDI binding for the currently selected parameter. +func (m *MIDIModel) Unbind() Action { return MakeAction((*midiUnbind)(m)) } + +type midiUnbind MIDIModel + +func (m *midiUnbind) Enabled() bool { + p, ok := (*MIDIModel)(m).selectedParam() + if !ok { + return false + } + _, ok = m.d.MIDIBindings.GetControl(p) + return ok +} +func (m *midiUnbind) Do() { + p, _ := (*MIDIModel)(m).selectedParam() + m.d.MIDIBindings.UnlinkParam(p) + (*Model)(m).Alerts().Add("Removed MIDI controller bindings for the selected parameter", Info) +} + +// UnbindAll removes all MIDI bindings. +func (m *MIDIModel) UnbindAll() Action { return MakeAction((*midiUnbindAll)(m)) } + +type midiUnbindAll MIDIModel + +func (m *midiUnbindAll) Enabled() bool { return len(m.d.MIDIBindings.ControlBindings) > 0 } +func (m *midiUnbindAll) Do() { + m.d.MIDIBindings = MIDIBindings{} + (*Model)(m).Alerts().Add("Removed all MIDI controller bindings", Info) +} + +func (m *MIDIModel) selectedParam() (MIDIParam, bool) { + point := (*Model)(m).Params().Table().Cursor() + item := (*Model)(m).Params().Item(point) + if _, ok := item.vtable.(*namedParameter); !ok { + return MIDIParam{}, false + } + r := item.Range() + value := MIDIParam{ + Id: item.unit.ID, + Param: item.up.Name, + Min: r.Min, + Max: r.Max, + } + return value, true +} + +func (m *MIDIModel) handleControlEvent(e ControlChange) { + key := MIDIControl{Channel: e.Channel, Control: e.Control} + if m.midi.binding { + m.midi.binding = false + value, ok := m.selectedParam() + if !ok { + (*Model)(m).Alerts().Add("Cannot bind MIDI controller to this parameter type", Warning) + return + } + m.d.MIDIBindings.Link(key, value) + (*Model)(m).Alerts().Add(fmt.Sprintf("Bound MIDI CC %d on channel %d to %s", key.Control, key.Channel+1, value.Param), Info) + } + t, ok := m.d.MIDIBindings.GetParam(key) + if !ok { + return + } + i, u, err := m.d.Song.Patch.FindUnit(t.Id) + if err != nil { + return + } + newVal := (e.Value*(t.Max-t.Min)+63)/127 + t.Min + if m.d.Song.Patch[i].Units[u].Parameters[t.Param] == newVal { + return + } + defer (*Model)(m).change("MIDIControlChange", PatchChange, MinorChange)() + m.d.Song.Patch[i].Units[u].Parameters[t.Param] = newVal +} + +type ( + // Two-way map between MIDI controls and parameters that makes sure only one control channel is linked to only one parameter and vice versa. + MIDIBindings struct { + ControlBindings map[MIDIControl]MIDIParam + ParamBindings map[MIDIParam]MIDIControl + } + + MIDIParam struct { + Id int + Param string + Min, Max int + } + + MIDIControl struct{ Channel, Control int } + + midiControlParam struct { + Control MIDIControl + Param MIDIParam + } +) + +// marshal as slice of bindings cause json doesn't support marshaling maps with +// struct keys +func (t *MIDIBindings) UnmarshalJSON(data []byte) error { + var bindings []midiControlParam + err := json.Unmarshal(data, &bindings) + if err != nil { + return err + } + for _, b := range bindings { + t.Link(b.Control, b.Param) + } + return nil +} + +func (t MIDIBindings) MarshalJSON() ([]byte, error) { + var bindings []midiControlParam + for k, v := range t.ControlBindings { + bindings = append(bindings, midiControlParam{Control: k, Param: v}) + } + return json.Marshal(bindings) +} + +func (t *MIDIBindings) GetParam(m MIDIControl) (MIDIParam, bool) { + if t.ControlBindings == nil { + return MIDIParam{}, false + } + p, ok := t.ControlBindings[m] + return p, ok +} + +func (t *MIDIBindings) GetControl(p MIDIParam) (MIDIControl, bool) { + if t.ParamBindings == nil { + return MIDIControl{}, false + } + c, ok := t.ParamBindings[p] + return c, ok +} + +func (t *MIDIBindings) Link(m MIDIControl, p MIDIParam) { + if t.ControlBindings == nil { + t.ControlBindings = make(map[MIDIControl]MIDIParam) + } + if t.ParamBindings == nil { + t.ParamBindings = make(map[MIDIParam]MIDIControl) + } + if p, ok := t.ControlBindings[m]; ok { + delete(t.ParamBindings, p) + } + if m, ok := t.ParamBindings[p]; ok { + delete(t.ControlBindings, m) + } + t.ControlBindings[m] = p + t.ParamBindings[p] = m +} + +func (t *MIDIBindings) UnlinkParam(p MIDIParam) { + if t.ParamBindings == nil { + return + } + if c, ok := t.ParamBindings[p]; ok { + delete(t.ParamBindings, p) + delete(t.ControlBindings, c) + } +} + +func (t *MIDIBindings) Copy() MIDIBindings { + ret := MIDIBindings{ + ControlBindings: make(map[MIDIControl]MIDIParam, len(t.ControlBindings)), + ParamBindings: make(map[MIDIParam]MIDIControl, len(t.ParamBindings)), + } + for k, v := range t.ControlBindings { + ret.ControlBindings[k] = v + } + for k, v := range t.ParamBindings { + ret.ParamBindings[k] = v + } + return ret +} // NullMIDIContext is a mockup MIDIContext if you don't want to create a real // one. diff --git a/tracker/model.go b/tracker/model.go index 3605df3..1cc7b46 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -36,6 +36,7 @@ type ( SendSource int InstrumentTab InstrumentTab PresetSearchString string + MIDIBindings MIDIBindings } Model struct { @@ -202,14 +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) return m } func (m *Model) Close() { TrySend(m.broker.CloseDetector, struct{}{}) TrySend(m.broker.CloseSpecAn, struct{}{}) + TrySend(m.broker.CloseMIDIRouter, struct{}{}) TimeoutReceive(m.broker.FinishedDetector, 3*time.Second) TimeoutReceive(m.broker.FinishedSpecAn, 3*time.Second) + TimeoutReceive(m.broker.FinishedMIDIRouter, 3*time.Second) } // RequestQuit asks the tracker to quit, showing a dialog if there are unsaved @@ -385,6 +389,8 @@ func (m *Model) ProcessMsg(msg MsgToModel) { case *Spectrum: m.broker.PutSpectrum(m.spectrum) m.spectrum = e + case *ControlChange: + m.MIDI().handleControlEvent(*e) } } @@ -393,6 +399,7 @@ func (m *Model) Broker() *Broker { return m.broker } func (d *modelData) Copy() modelData { ret := *d ret.Song = d.Song.Copy() + ret.MIDIBindings = d.MIDIBindings.Copy() return ret } diff --git a/tracker/player.go b/tracker/player.go index 54eb944..edb0368 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -69,6 +69,12 @@ type ( playerTimestamp int64 // the timestamp of the event, adjusted to the player's clock, used to sort events } + + ControlChange struct { + Channel int + Control int + Value int + } ) type ( @@ -274,8 +280,8 @@ loop: } } TrySend(p.broker.ToModel, MsgToModel{Reset: true}) - case NoteEvent: - p.events = append(p.events, m) + case *NoteEvent: + p.events = append(p.events, *m) case RecordingMsg: if m.bool { p.recording = Recording{State: RecordingWaitingForNote}