feat(tracker): ability to bind MIDI controllers to parameters

Closes #152
This commit is contained in:
5684185+vsariola@users.noreply.github.com
2026-01-31 23:18:14 +02:00
parent 6e8acc8f9b
commit f2ef57a845
15 changed files with 311 additions and 55 deletions

View File

@ -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

View File

@ -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))
}
}
}

View File

@ -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.

View File

@ -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:]))

View File

@ -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" }

View File

@ -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)
}
}

View File

@ -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()

View File

@ -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),
)
})

View File

@ -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 }

View File

@ -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))
}
}

View File

@ -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))
}
}

View File

@ -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 != "" {

View File

@ -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.

View File

@ -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
}

View File

@ -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}