diff --git a/cmd/sointu-track/main.go b/cmd/sointu-track/main.go index ce17f8b..ac7fd0b 100644 --- a/cmd/sointu-track/main.go +++ b/cmd/sointu-track/main.go @@ -15,19 +15,9 @@ import ( "github.com/vsariola/sointu/oto" "github.com/vsariola/sointu/tracker" "github.com/vsariola/sointu/tracker/gioui" + "github.com/vsariola/sointu/tracker/gomidi" ) -type NullContext struct { -} - -func (NullContext) NextEvent() (event tracker.MIDINoteEvent, ok bool) { - return tracker.MIDINoteEvent{}, false -} - -func (NullContext) BPM() (bpm float64, ok bool) { - return 0, false -} - type PlayerAudioSource struct { *tracker.Player playerProcessContext tracker.PlayerProcessContext @@ -64,6 +54,8 @@ func main() { recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery") } model, player := tracker.NewModelPlayer(cmd.MainSynther, recoveryFile) + model.MIDI = gomidi.CreateContext() + defer model.MIDI.DestroyContext() if a := flag.Args(); len(a) > 0 { f, err := os.Open(a[0]) if err == nil { @@ -72,7 +64,7 @@ func main() { f.Close() } tracker := gioui.NewTracker(model) - audioCloser := audioContext.Play(&PlayerAudioSource{player, NullContext{}}) + audioCloser := audioContext.Play(&PlayerAudioSource{player, model.MIDI}) go func() { tracker.Main() audioCloser.Close() diff --git a/go.mod b/go.mod index 7fdf306..f7fc25c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/vsariola/sointu -go 1.21 +go 1.22.2 require ( gioui.org v0.7.1 @@ -29,6 +29,7 @@ require ( github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.0 // indirect github.com/stretchr/testify v1.6.1 // indirect + gitlab.com/gomidi/midi/v2 v2.2.10 // indirect golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect golang.org/x/image v0.18.0 // indirect diff --git a/go.sum b/go.sum index af20c33..eb907c0 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,13 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gitlab.com/gomidi/midi v1.21.0/go.mod h1:3ohtNOhqoSakkuLG/Li1OI6I3J1c2LErnJF5o/VBq1c= +gitlab.com/gomidi/midi v1.23.7 h1:I6qKoIk9s9dcX+pNf0jC+tziCzJFn82bMpuntRkLeik= +gitlab.com/gomidi/midi v1.23.7/go.mod h1:3ohtNOhqoSakkuLG/Li1OI6I3J1c2LErnJF5o/VBq1c= +gitlab.com/gomidi/midi/v2 v2.2.10 h1:u9D+5TM0vkFWF5DcO6xGKG99ERYqksh6wPj2X2Rx5A8= +gitlab.com/gomidi/midi/v2 v2.2.10/go.mod h1:ENtYaJPOwb2N+y7ihv/L7R4GtWjbknouhIIkMrJ5C0g= +gitlab.com/gomidi/rtmididrv v0.15.0 h1:52Heco8Y3Jjcl4t0yDUVikOxfI8FMF1Zq+qsG++TUeo= +gitlab.com/gomidi/rtmididrv v0.15.0/go.mod h1:p/6IL1LGgj7utcv3wXudsDWiD9spgAdn0O8LDsGIPG0= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= diff --git a/tracker/action.go b/tracker/action.go index 393d441..a5e2e46 100644 --- a/tracker/action.go +++ b/tracker/action.go @@ -1,6 +1,7 @@ package tracker import ( + "fmt" "os" "github.com/vsariola/sointu" @@ -444,6 +445,14 @@ func (m *Model) Cancel() Action { return Allow(func() { m.dialog = NoDialog func (m *Model) Export() Action { return Allow(func() { m.dialog = Export }) } func (m *Model) ExportFloat() Action { return Allow(func() { m.dialog = ExportFloatExplorer }) } func (m *Model) ExportInt16() Action { return Allow(func() { m.dialog = ExportInt16Explorer }) } +func (m *Model) SelectMidiInput(item MIDIDevicer) Action { + return Allow(func() { + if !m.MIDI.OpenInputDevice(item) { + message := fmt.Sprintf("Could not open MIDI device %s\n", item) + m.Alerts().Add(message, Error) + } + }) +} func (m *Model) completeAction(checkSave bool) { if checkSave && m.d.ChangedSinceSave { diff --git a/tracker/gioui/songpanel.go b/tracker/gioui/songpanel.go index ac701ef..e74ecc2 100644 --- a/tracker/gioui/songpanel.go +++ b/tracker/gioui/songpanel.go @@ -48,12 +48,15 @@ type SongPanel struct { followOnHint, followOffHint string panicHint string loopOffHint, loopOnHint string + + // Midi menu items + midiMenuItems []MenuItem } func NewSongPanel(model *tracker.Model) *SongPanel { ret := &SongPanel{ - MenuBar: make([]widget.Clickable, 2), - Menus: make([]Menu, 2), + MenuBar: make([]widget.Clickable, 3), + Menus: make([]Menu, 3), BPM: NewNumberInput(model.BPM().Int()), RowsPerPattern: NewNumberInput(model.RowsPerPattern().Int()), RowsPerBeat: NewNumberInput(model.RowsPerBeat().Int()), @@ -81,6 +84,13 @@ func NewSongPanel(model *tracker.Model) *SongPanel { {IconBytes: icons.ContentRedo, Text: "Redo", ShortcutText: keyActionMap["Redo"], Doer: model.Redo()}, {IconBytes: icons.ImageCrop, Text: "Remove unused data", ShortcutText: keyActionMap["RemoveUnused"], Doer: model.RemoveUnused()}, } + for input := range model.MIDI.ListInputDevices() { + ret.midiMenuItems = append(ret.midiMenuItems, MenuItem{ + IconBytes: icons.ImageControlPoint, + Text: input.String(), + Doer: model.SelectMidiInput(input), + }) + } ret.rewindHint = makeHint("Rewind", "\n(%s)", "PlaySongStartUnfollow") ret.playHint = makeHint("Play", " (%s)", "PlayCurrentPosUnfollow") ret.stopHint = makeHint("Stop", " (%s)", "StopPlaying") @@ -91,6 +101,7 @@ func NewSongPanel(model *tracker.Model) *SongPanel { ret.followOffHint = makeHint("Follow off", " (%s)", "FollowToggle") ret.loopOffHint = makeHint("Loop off", " (%s)", "LoopToggle") ret.loopOnHint = makeHint("Loop on", " (%s)", "LoopToggle") + return ret } @@ -112,6 +123,7 @@ func (t *SongPanel) layoutMenuBar(gtx C, tr *Tracker) D { return layout.Flex{Axis: layout.Horizontal, Alignment: layout.End}.Layout(gtx, layout.Rigid(tr.layoutMenu(gtx, "File", &t.MenuBar[0], &t.Menus[0], unit.Dp(200), t.fileMenuItems...)), layout.Rigid(tr.layoutMenu(gtx, "Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(200), t.editMenuItems...)), + layout.Rigid(tr.layoutMenu(gtx, "MIDI", &t.MenuBar[2], &t.Menus[2], unit.Dp(200), t.midiMenuItems...)), ) } diff --git a/tracker/gomidi/midi.go b/tracker/gomidi/midi.go new file mode 100644 index 0000000..5674c4e --- /dev/null +++ b/tracker/gomidi/midi.go @@ -0,0 +1,115 @@ +package gomidi + +import ( + "fmt" + "time" + + "github.com/vsariola/sointu/tracker" + "gitlab.com/gomidi/midi/v2" + "gitlab.com/gomidi/midi/v2/drivers" + "gitlab.com/gomidi/midi/v2/drivers/rtmididrv" +) + +type ( + MIDIContext struct { + driver *rtmididrv.Driver + inputAvailable bool + driverAvailable bool + currentIn MIDIDevicer + events chan midi.Message + } + MIDIDevicer drivers.In +) + +func (m *MIDIContext) ListInputDevices() <-chan tracker.MIDIDevicer { + + ins, err := m.driver.Ins() + channel := make(chan tracker.MIDIDevicer, len(ins)) + if err != nil { + m.driver.Close() + m.driverAvailable = false + return nil + } + go func() { + for i := 0; i < len(ins); i++ { + channel <- ins[i].(MIDIDevicer) + } + close(channel) + }() + return channel +} + +// Open the driver. +func CreateContext() *MIDIContext { + m := MIDIContext{} + var err error + m.driver, err = rtmididrv.New() + m.driverAvailable = err == nil + if m.driverAvailable { + m.events = make(chan midi.Message) + } + return &m +} + +// Open an input device while closing the currently open if necessary. +func (m *MIDIContext) OpenInputDevice(in tracker.MIDIDevicer) bool { + fmt.Printf("Opening midi device %s\n.", in) + if m.driverAvailable { + if m.currentIn == in { + return false + } + if m.inputAvailable && m.currentIn.IsOpen() { + m.currentIn.Close() + } + m.currentIn = in.(MIDIDevicer) + m.currentIn.Open() + _, err := midi.ListenTo(m.currentIn, m.HandleMessage) + if err != nil { + m.inputAvailable = false + return false + } + } + return true +} + +func (m *MIDIContext) HandleMessage(msg midi.Message, timestampms int32) { + go func() { + m.events <- msg + time.Sleep(time.Nanosecond) + }() +} + +func (c *MIDIContext) NextEvent() (event tracker.MIDINoteEvent, ok bool) { + select { + case msg := <-c.events: + { + var channel uint8 + var velocity uint8 + var key uint8 + var controller uint8 + var value uint8 + if msg.GetNoteOn(&channel, &key, &velocity) { + return tracker.MIDINoteEvent{Frame: 0, On: true, Channel: int(channel), Note: key}, true + } else if msg.GetNoteOff(&channel, &key, &velocity) { + return tracker.MIDINoteEvent{Frame: 0, On: false, Channel: int(channel), Note: key}, true + } else if msg.GetControlChange(&channel, &controller, &value) { + fmt.Printf("CC @ Channel: %d, Controller: %d, Value: %d\n", channel, controller, value) + } else { + fmt.Printf("Unhandled MIDI message: %s\n", msg) + } + } + default: + // Note (@LeStahL): This empty select case is needed to make the implementation non-blocking. + } + return tracker.MIDINoteEvent{}, false +} + +func (c *MIDIContext) BPM() (bpm float64, ok bool) { + return 0, false +} + +func (c *MIDIContext) DestroyContext() { + close(c.events) + c.currentIn.Close() + c.driver.Close() +} diff --git a/tracker/model.go b/tracker/model.go index 625f95d..a8a985f 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -75,6 +75,8 @@ type ( PlayerMessages chan PlayerMsg modelMessages chan<- interface{} + + MIDI MIDIContexter } // Cursor identifies a row and a track in a song score. @@ -120,6 +122,18 @@ type ( ChangeType int Dialog int + + MIDIContexter interface { + ListInputDevices() <-chan MIDIDevicer + OpenInputDevice(item MIDIDevicer) bool + DestroyContext() + BPM() (bpm float64, ok bool) + NextEvent() (event MIDINoteEvent, ok bool) + } + + MIDIDevicer interface { + String() string + } ) const (