Skip to content

Commit

Permalink
* feat(tracker/midi) Added rudimentary MIDI controller support to the…
Browse files Browse the repository at this point in the history
… standalone tracker.

feat(tracker): add support for a MIDI controller to the standalone tracker

Closes #132.
  • Loading branch information
LeStahL authored Oct 14, 2024
1 parent 9779bee commit 1aa91e6
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 15 deletions.
16 changes: 4 additions & 12 deletions cmd/sointu-track/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/vsariola/sointu

go 1.21
go 1.22.2

require (
gioui.org v0.7.1
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
9 changes: 9 additions & 0 deletions tracker/action.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tracker

import (
"fmt"
"os"

"github.com/vsariola/sointu"
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 14 additions & 2 deletions tracker/gioui/songpanel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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")
Expand All @@ -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
}

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

Expand Down
115 changes: 115 additions & 0 deletions tracker/gomidi/midi.go
Original file line number Diff line number Diff line change
@@ -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()
}
14 changes: 14 additions & 0 deletions tracker/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ type (

PlayerMessages chan PlayerMsg
modelMessages chan<- interface{}

MIDI MIDIContexter
}

// Cursor identifies a row and a track in a song score.
Expand Down Expand Up @@ -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 (
Expand Down

0 comments on commit 1aa91e6

Please sign in to comment.