From 8dfadacafeef04b970f80c2eef7b45971ed1a9ef Mon Sep 17 00:00:00 2001 From: qm210 Date: Mon, 21 Oct 2024 22:00:50 +0200 Subject: [PATCH] feat: midi note input for the tracker --- .gitignore | 3 +- CHANGELOG.md | 2 + cmd/sointu-track/main.go | 26 +++++----- cmd/sointu-vsti/main.go | 4 +- song.go | 30 ++++++++++++ tracker/action.go | 8 +-- tracker/bool.go | 9 ++++ tracker/gioui/note_editor.go | 95 +++++++++++++++++++++++++++--------- tracker/gioui/songpanel.go | 12 +++-- tracker/gioui/theme.go | 2 + tracker/gioui/tracker.go | 45 +++++++++++++++++ tracker/gomidi/midi.go | 24 ++++++++- tracker/model.go | 11 +++-- tracker/model_test.go | 2 +- tracker/player.go | 20 ++++++-- tracker/processor.go | 20 ++++++++ tracker/table.go | 47 +++++++++++++++--- 17 files changed, 298 insertions(+), 62 deletions(-) create mode 100644 tracker/processor.go diff --git a/.gitignore b/.gitignore index 529a75ce..d20a8e66 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,9 @@ build/ # Project specific old/ -# VS Code +# IDEs .vscode/ +.idea/ # project specific # this is autogenerated from bridge.go.in diff --git a/CHANGELOG.md b/CHANGELOG.md index 5282fb3e..40e09e6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Dragging mouse to select rectangles in the tables - The standalone tracker can open a MIDI port for receiving MIDI notes ([#166][i166]) + - the note editor has a button to allow entering notes by MIDI. + Polyphony is supported if there are tracks available. ([#170][i170]) - Units can have comments, to make it easier to distinguish between units of same type within an instrument. These comments are also shown when choosing the send target. ([#114][i114]) diff --git a/cmd/sointu-track/main.go b/cmd/sointu-track/main.go index 66a6347c..f2d410fd 100644 --- a/cmd/sointu-track/main.go +++ b/cmd/sointu-track/main.go @@ -10,7 +10,6 @@ import ( "runtime/pprof" "gioui.org/app" - "github.com/vsariola/sointu" "github.com/vsariola/sointu/cmd" "github.com/vsariola/sointu/oto" "github.com/vsariola/sointu/tracker" @@ -18,18 +17,10 @@ import ( "github.com/vsariola/sointu/tracker/gomidi" ) -type PlayerAudioSource struct { - *tracker.Player - playerProcessContext tracker.PlayerProcessContext -} - -func (p *PlayerAudioSource) ReadAudio(buf sointu.AudioBuffer) error { - p.Player.Process(buf, p.playerProcessContext) - return nil -} - var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`") var memprofile = flag.String("memprofile", "", "write memory profile to `file`") +var defaultMidiInput = flag.String("midi-input", "", "connect MIDI input to matching device name") +var firstMidiInput = flag.Bool("first-midi-input", false, "connect MIDI input to first device found") func main() { flag.Parse() @@ -54,8 +45,10 @@ func main() { recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery") } midiContext := gomidi.NewContext() + defer midiContext.Close() + midiContext.TryToOpenBy(*defaultMidiInput, *firstMidiInput) model, player := tracker.NewModelPlayer(cmd.MainSynther, midiContext, recoveryFile) - defer model.MIDI.Close() + if a := flag.Args(); len(a) > 0 { f, err := os.Open(a[0]) if err == nil { @@ -63,10 +56,13 @@ func main() { } f.Close() } - tracker := gioui.NewTracker(model) - audioCloser := audioContext.Play(&PlayerAudioSource{player, midiContext}) + + trackerUi := gioui.NewTracker(model) + processor := tracker.NewProcessor(player, midiContext, trackerUi) + audioCloser := audioContext.Play(processor) + go func() { - tracker.Main() + trackerUi.Main() audioCloser.Close() if *cpuprofile != "" { pprof.StopCPUProfile() diff --git a/cmd/sointu-vsti/main.go b/cmd/sointu-vsti/main.go index 8f38aa76..009db674 100644 --- a/cmd/sointu-vsti/main.go +++ b/cmd/sointu-vsti/main.go @@ -31,6 +31,8 @@ func (m NullMIDIContext) InputDevices(yield func(tracker.MIDIDevice) bool) {} func (m NullMIDIContext) Close() {} +func (m NullMIDIContext) HasDeviceOpen() bool { return false } + func (c *VSTIProcessContext) NextEvent() (event tracker.MIDINoteEvent, ok bool) { for c.eventIndex < len(c.events) { ev := c.events[c.eventIndex] @@ -101,7 +103,7 @@ func init() { buf = append(buf, make(sointu.AudioBuffer, out.Frames-len(buf))...) } buf = buf[:out.Frames] - player.Process(buf, &context) + player.Process(buf, &context, nil) for i := 0; i < out.Frames; i++ { left[i], right[i] = buf[i][0], buf[i][1] } diff --git a/song.go b/song.go index 1f33aa18..d89f0fce 100644 --- a/song.go +++ b/song.go @@ -2,6 +2,7 @@ package sointu import ( "errors" + "iter" ) type ( @@ -331,3 +332,32 @@ func TotalVoices[T any, S ~[]T, P NumVoicerPointer[T]](slice S) (ret int) { } return } + +func (s *Song) InstrumentForTrack(trackIndex int) (int, bool) { + voiceIndex := s.Score.FirstVoiceForTrack(trackIndex) + instrument, err := s.Patch.InstrumentForVoice(voiceIndex) + return instrument, err == nil +} + +func (s *Song) AllTracksWithSameInstrument(trackIndex int) iter.Seq[int] { + return func(yield func(int) bool) { + + currentInstrument, currentExists := s.InstrumentForTrack(trackIndex) + if !currentExists { + return + } + + for i := 0; i < len(s.Score.Tracks); i++ { + instrument, exists := s.InstrumentForTrack(i) + if !exists { + return + } + if instrument != currentInstrument { + continue + } + if !yield(i) { + return + } + } + } +} diff --git a/tracker/action.go b/tracker/action.go index e228fafc..da7db8c4 100644 --- a/tracker/action.go +++ b/tracker/action.go @@ -475,12 +475,12 @@ func (m *Model) ExportFloat() Action { return Allow(func() { m.dialog = ExportFl func (m *Model) ExportInt16() Action { return Allow(func() { m.dialog = ExportInt16Explorer }) } func (m *Model) SelectMidiInput(item MIDIDevice) Action { return Allow(func() { - if err := item.Open(); err != nil { - message := fmt.Sprintf("Could not open MIDI device: %s", item) - m.Alerts().Add(message, Error) - } else { + if err := item.Open(); err == nil { message := fmt.Sprintf("Opened MIDI device: %s", item) m.Alerts().Add(message, Info) + } else { + message := fmt.Sprintf("Could not open MIDI device: %s", item) + m.Alerts().Add(message, Error) } }) } diff --git a/tracker/bool.go b/tracker/bool.go index e7d86034..c4072a84 100644 --- a/tracker/bool.go +++ b/tracker/bool.go @@ -16,6 +16,7 @@ type ( Playing Model InstrEnlarged Model Effect Model + TrackMidiIn Model CommentExpanded Model Follow Model UnitSearching Model @@ -44,6 +45,7 @@ func (m *Model) IsRecording() *IsRecording { return (*IsRecording)(m) } func (m *Model) Playing() *Playing { return (*Playing)(m) } func (m *Model) InstrEnlarged() *InstrEnlarged { return (*InstrEnlarged)(m) } func (m *Model) Effect() *Effect { return (*Effect)(m) } +func (m *Model) TrackMidiIn() *TrackMidiIn { return (*TrackMidiIn)(m) } func (m *Model) CommentExpanded() *CommentExpanded { return (*CommentExpanded)(m) } func (m *Model) Follow() *Follow { return (*Follow)(m) } func (m *Model) UnitSearching() *UnitSearching { return (*UnitSearching)(m) } @@ -110,6 +112,13 @@ func (m *Follow) Value() bool { return m.follow } func (m *Follow) setValue(val bool) { m.follow = val } func (m *Follow) Enabled() bool { return true } +// TrackMidiIn (Midi Input for notes in the tracks) + +func (m *TrackMidiIn) Bool() Bool { return Bool{m} } +func (m *TrackMidiIn) Value() bool { return m.trackMidiIn } +func (m *TrackMidiIn) setValue(val bool) { m.trackMidiIn = val } +func (m *TrackMidiIn) Enabled() bool { return m.MIDI.HasDeviceOpen() } + // Effect methods func (m *Effect) Bool() Bool { return Bool{m} } diff --git a/tracker/gioui/note_editor.go b/tracker/gioui/note_editor.go index 2715d77f..94fb9973 100644 --- a/tracker/gioui/note_editor.go +++ b/tracker/gioui/note_editor.go @@ -3,6 +3,7 @@ package gioui import ( "fmt" "image" + "image/color" "strconv" "strings" @@ -62,6 +63,7 @@ type NoteEditor struct { NoteOffBtn *ActionClickable EffectBtn *BoolClickable UniqueBtn *BoolClickable + TrackMidiInBtn *BoolClickable scrollTable *ScrollTable eventFilters []event.Filter @@ -85,6 +87,7 @@ func NewNoteEditor(model *tracker.Model) *NoteEditor { NoteOffBtn: NewActionClickable(model.EditNoteOff()), EffectBtn: NewBoolClickable(model.Effect().Bool()), UniqueBtn: NewBoolClickable(model.UniquePatterns().Bool()), + TrackMidiInBtn: NewBoolClickable(model.TrackMidiIn().Bool()), scrollTable: NewScrollTable( model.Notes().Table(), model.Tracks().List(), @@ -162,6 +165,7 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D { } effectBtnStyle := ToggleButton(gtx, t.Theme, te.EffectBtn, "Hex") uniqueBtnStyle := ToggleIcon(gtx, t.Theme, te.UniqueBtn, icons.ToggleStarBorder, icons.ToggleStar, te.uniqueOffTip, te.uniqueOnTip) + midiInBtnStyle := ToggleButton(gtx, t.Theme, te.TrackMidiInBtn, "MIDI") return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { return layout.Dimensions{Size: image.Pt(gtx.Dp(unit.Dp(12)), 0)} }), layout.Rigid(addSemitoneBtnStyle.Layout), @@ -175,6 +179,8 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D { layout.Rigid(voiceUpDown), layout.Rigid(splitTrackBtnStyle.Layout), layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), + layout.Rigid(midiInBtnStyle.Layout), + layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Rigid(deleteTrackBtnStyle.Layout), layout.Rigid(newTrackBtnStyle.Layout)) }) @@ -217,7 +223,13 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { h := gtx.Dp(unit.Dp(trackColTitleHeight)) title := ((*tracker.Order)(t.Model)).Title(i) gtx.Constraints = layout.Exact(image.Pt(pxWidth, h)) - LabelStyle{Alignment: layout.N, Text: title, FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: t.Theme.Shaper}.Layout(gtx) + LabelStyle{ + Alignment: layout.N, + Text: title, + FontSize: unit.Sp(12), + Color: mediumEmphasisTextColor, + Shaper: t.Theme.Shaper, + }.Layout(gtx) return D{Size: image.Pt(pxWidth, h)} } @@ -253,8 +265,10 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { return D{Size: image.Pt(w, pxHeight)} } - drawSelection := te.scrollTable.Table.Cursor() != te.scrollTable.Table.Cursor2() + cursor := te.scrollTable.Table.Cursor() + drawSelection := cursor != te.scrollTable.Table.Cursor2() selection := te.scrollTable.Table.Range() + hasTrackMidiIn := te.TrackMidiInBtn.Bool.Value() cell := func(gtx C, x, y int) D { // draw the background, to indicate selection @@ -268,21 +282,25 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { } paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(0, 0), Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op()) // draw the cursor - if point == te.scrollTable.Table.Cursor() { - cw := gtx.Constraints.Min.X - cx := 0 - if t.Model.Notes().Effect(x) { - cw /= 2 - if t.Model.Notes().LowNibble() { - cx += cw - } - } + if point == cursor { c := inactiveSelectionColor if te.scrollTable.Focused() { c = cursorColor } - paint.FillShape(gtx.Ops, c, clip.Rect{Min: image.Pt(cx, 0), Max: image.Pt(cx+cw, gtx.Constraints.Min.Y)}.Op()) + if hasTrackMidiIn { + c = cursorForTrackMidiInColor + } + te.paintColumnCell(gtx, x, t, c) + } + // draw the corresponding "fake cursors" for instrument-track-groups (for polyphony) + if hasTrackMidiIn { + for trackIndex := range ((*tracker.Order)(t.Model)).TrackIndicesForCurrentInstrument() { + if x == trackIndex && y == cursor.Y { + te.paintColumnCell(gtx, x, t, cursorNeighborForTrackMidiInColor) + } + } } + // draw the pattern marker rpp := max(t.RowsPerPattern().Value(), 1) pat := y / rpp @@ -317,6 +335,18 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { return table.Layout(gtx) } +func (te *NoteEditor) paintColumnCell(gtx C, x int, t *Tracker, c color.NRGBA) { + cw := gtx.Constraints.Min.X + cx := 0 + if t.Model.Notes().Effect(x) { + cw /= 2 + if t.Model.Notes().LowNibble() { + cx += cw + } + } + paint.FillShape(gtx.Ops, c, clip.Rect{Min: image.Pt(cx, 0), Max: image.Pt(cx+cw, gtx.Constraints.Min.Y)}.Op()) +} + func mod(x, d int) int { x = x % d if x >= 0 { @@ -338,7 +368,7 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) { if nibbleValue, err := strconv.ParseInt(string(e.Name), 16, 8); err == nil { t.Model.Notes().FillNibble(byte(nibbleValue), t.Model.Notes().LowNibble()) n = t.Model.Notes().Value(te.scrollTable.Table.Cursor()) - goto validNote + te.finishNoteInsert(t, n, e.Name) } } else { action, ok := keyBindingMap[e] @@ -347,11 +377,7 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) { } if action == "NoteOff" { t.Model.Notes().Table().Fill(0) - if step := t.Model.Step().Value(); step > 0 { - te.scrollTable.Table.MoveCursor(0, step) - te.scrollTable.Table.SetCursor2(te.scrollTable.Table.Cursor()) - } - te.scrollTable.EnsureCursorVisible() + te.finishNoteInsert(t, 0, "") return } if action[:4] == "Note" { @@ -361,20 +387,43 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) { } n = noteAsValue(t.OctaveNumberInput.Int.Value(), val-12) t.Model.Notes().Table().Fill(int(n)) - goto validNote + te.finishNoteInsert(t, n, e.Name) } } - return -validNote: +} + +func (te *NoteEditor) finishNoteInsert(t *Tracker, note byte, keyName key.Name) { if step := t.Model.Step().Value(); step > 0 { te.scrollTable.Table.MoveCursor(0, step) te.scrollTable.Table.SetCursor2(te.scrollTable.Table.Cursor()) } te.scrollTable.EnsureCursorVisible() - if _, ok := t.KeyPlaying[e.Name]; !ok { + + if keyName == "" { + return + } + if _, ok := t.KeyPlaying[keyName]; !ok { trk := te.scrollTable.Table.Cursor().X - t.KeyPlaying[e.Name] = t.TrackNoteOn(trk, n) + t.KeyPlaying[keyName] = t.TrackNoteOn(trk, note) + } +} + +func (te *NoteEditor) HandleMidiInput(t *Tracker) { + inputDeactivated := !t.Model.TrackMidiIn().Value() + if inputDeactivated { + return + } + te.scrollTable.Table.SetCursor2(te.scrollTable.Table.Cursor()) + remaining := (*tracker.Order)(t.Model).CountNextTracksForCurrentInstrument() + for i, note := range t.MidiNotePlaying { + t.Model.Notes().Table().Set(note) + te.scrollTable.Table.MoveCursor(1, 0) + te.scrollTable.EnsureCursorVisible() + if i >= remaining { + break + } } + te.scrollTable.Table.SetCursor(te.scrollTable.Table.Cursor2()) } /* diff --git a/tracker/gioui/songpanel.go b/tracker/gioui/songpanel.go index 37bbb03e..7a0ef88d 100644 --- a/tracker/gioui/songpanel.go +++ b/tracker/gioui/songpanel.go @@ -120,11 +120,17 @@ func (t *SongPanel) layoutMenuBar(gtx C, tr *Tracker) D { gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(36)) gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36)) - return layout.Flex{Axis: layout.Horizontal, Alignment: layout.End}.Layout(gtx, + menuLayouts := []layout.FlexChild{ 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...)), - ) + } + if len(t.midiMenuItems) > 0 { + menuLayouts = append( + menuLayouts, + layout.Rigid(tr.layoutMenu(gtx, "MIDI", &t.MenuBar[2], &t.Menus[2], unit.Dp(200), t.midiMenuItems...)), + ) + } + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.End}.Layout(gtx, menuLayouts...) } func (t *SongPanel) layoutSongOptions(gtx C, tr *Tracker) D { diff --git a/tracker/gioui/theme.go b/tracker/gioui/theme.go index 7d7a998f..f446bfd9 100644 --- a/tracker/gioui/theme.go +++ b/tracker/gioui/theme.go @@ -60,6 +60,8 @@ var activeLightSurfaceColor = color.NRGBA{R: 45, G: 45, B: 45, A: 255} var cursorColor = color.NRGBA{R: 100, G: 140, B: 255, A: 48} var selectionColor = color.NRGBA{R: 100, G: 140, B: 255, A: 12} var inactiveSelectionColor = color.NRGBA{R: 140, G: 140, B: 140, A: 16} +var cursorForTrackMidiInColor = color.NRGBA{R: 255, G: 100, B: 140, A: 48} +var cursorNeighborForTrackMidiInColor = color.NRGBA{R: 255, G: 100, B: 140, A: 24} var errorColor = color.NRGBA{R: 207, G: 102, B: 121, A: 255} diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index 67d85057..115e14b8 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -35,6 +35,7 @@ type ( BottomHorizontalSplit *Split VerticalSplit *Split KeyPlaying map[key.Name]tracker.NoteID + MidiNotePlaying []byte PopupAlert *PopupAlert SaveChangesDialog *Dialog @@ -77,6 +78,7 @@ func NewTracker(model *tracker.Model) *Tracker { VerticalSplit: &Split{Axis: layout.Vertical}, KeyPlaying: make(map[key.Name]tracker.NoteID), + MidiNotePlaying: make([]byte, 0, 32), SaveChangesDialog: NewDialog(model.SaveSong(), model.DiscardSong(), model.Cancel()), WaveTypeDialog: NewDialog(model.ExportInt16(), model.ExportFloat(), model.Cancel()), InstrumentEditor: NewInstrumentEditor(model), @@ -306,3 +308,46 @@ func (t *Tracker) layoutTop(gtx layout.Context) layout.Dimensions { }, ) } + +/// Event Handling (for UI updates when playing etc.) + +func (t *Tracker) ProcessMessage(msg interface{}) { + switch msg.(type) { + case tracker.StartPlayMsg: + fmt.Println("Tracker received StartPlayMsg") + case tracker.RecordingMsg: + fmt.Println("Tracker received RecordingMsg") + default: + break + } +} + +func (t *Tracker) ProcessEvent(event tracker.MIDINoteEvent) { + // MIDINoteEvent can be only NoteOn / NoteOff, i.e. its On field + if event.On { + t.addToMidiNotePlaying(event.Note) + } else { + t.removeFromMidiNotePlaying(event.Note) + } + t.TrackEditor.HandleMidiInput(t) +} + +func (t *Tracker) addToMidiNotePlaying(note byte) { + for _, n := range t.MidiNotePlaying { + if n == note { + return + } + } + t.MidiNotePlaying = append(t.MidiNotePlaying, note) +} + +func (t *Tracker) removeFromMidiNotePlaying(note byte) { + for i, n := range t.MidiNotePlaying { + if n == note { + t.MidiNotePlaying = append( + t.MidiNotePlaying[:i], + t.MidiNotePlaying[i+1:]..., + ) + } + } +} diff --git a/tracker/gomidi/midi.go b/tracker/gomidi/midi.go index d1547350..225f190a 100644 --- a/tracker/gomidi/midi.go +++ b/tracker/gomidi/midi.go @@ -8,6 +8,7 @@ import ( "gitlab.com/gomidi/midi/v2" "gitlab.com/gomidi/midi/v2/drivers" "gitlab.com/gomidi/midi/v2/drivers/rtmididrv" + "strings" ) type ( @@ -56,7 +57,7 @@ func (m RTMIDIDevice) Open() error { if m.context.driver == nil { return errors.New("no driver available") } - if m.context.currentIn != nil && m.context.currentIn.IsOpen() { + if m.context.HasDeviceOpen() { m.context.currentIn.Close() } m.context.currentIn = m.in @@ -120,3 +121,24 @@ func (c *RTMIDIContext) Close() { } c.driver.Close() } + +func (c *RTMIDIContext) HasDeviceOpen() bool { + return c.currentIn != nil && c.currentIn.IsOpen() +} + +func (c *RTMIDIContext) TryToOpenBy(namePrefix string, takeFirst bool) { + if namePrefix == "" && !takeFirst { + return + } + for input := range c.InputDevices { + if takeFirst || strings.HasPrefix(input.String(), namePrefix) { + input.Open() + return + } + } + if takeFirst { + fmt.Errorf("Could not find any MIDI Input.\n") + } else { + fmt.Errorf("Could not find any default MIDI Input starting with \"%s\".\n", namePrefix) + } +} diff --git a/tracker/model.go b/tracker/model.go index e4f093ce..3612b043 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -78,9 +78,10 @@ type ( synther sointu.Synther // the synther used to create new synths PlayerMessages chan PlayerMsg - modelMessages chan<- interface{} + ModelMessages chan<- interface{} - MIDI MIDIContext + MIDI MIDIContext + trackMidiIn bool } // Cursor identifies a row and a track in a song score. @@ -130,6 +131,7 @@ type ( MIDIContext interface { InputDevices(yield func(MIDIDevice) bool) Close() + HasDeviceOpen() bool } MIDIDevice interface { @@ -183,9 +185,10 @@ func NewModelPlayer(synther sointu.Synther, midiContext MIDIContext, recoveryFil m := new(Model) m.synther = synther m.MIDI = midiContext + m.trackMidiIn = midiContext.HasDeviceOpen() modelMessages := make(chan interface{}, 1024) playerMessages := make(chan PlayerMsg, 1024) - m.modelMessages = modelMessages + m.ModelMessages = modelMessages m.PlayerMessages = playerMessages m.d.Octave = 4 m.linkInstrTrack = true @@ -421,7 +424,7 @@ func (m *Model) resetSong() { // send sends a message to the player func (m *Model) send(message interface{}) { - m.modelMessages <- message + m.ModelMessages <- message } func (m *Model) maxID() int { diff --git a/tracker/model_test.go b/tracker/model_test.go index b42ffdff..886efdf7 100644 --- a/tracker/model_test.go +++ b/tracker/model_test.go @@ -271,7 +271,7 @@ func FuzzModel(f *testing.F) { break loop default: ctx := NullContext{} - player.Process(buf, ctx) + player.Process(buf, ctx, nil) } } }() diff --git a/tracker/player.go b/tracker/player.go index b512af6a..35347698 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -41,6 +41,11 @@ type ( BPM() (bpm float64, ok bool) } + EventProcessor interface { + ProcessMessage(msg interface{}) + ProcessEvent(event MIDINoteEvent) + } + // MIDINoteEvent is a MIDI event triggering or releasing a note. In // processing, the Frame is relative to the start of the current buffer. In // a Recording, the Frame is relative to the start of the recording. @@ -89,9 +94,11 @@ const numRenderTries = 10000 // model. context tells the player which MIDI events happen during the current // buffer. It is used to trigger and release notes during processing. The // context is also used to get the current BPM from the host. -func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext) { - p.processMessages(context) +func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext, ui EventProcessor) { + p.processMessages(context, ui) + midi, midiOk := context.NextEvent() + frame := 0 if p.recState == recStateRecording { @@ -116,6 +123,10 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext } else { p.releaseInstrument(midi.Channel, midi.Note) } + if ui != nil { + ui.ProcessEvent(midi) + } + midi, midiOk = context.NextEvent() } framesUntilMidi := len(buffer) @@ -224,7 +235,7 @@ func (p *Player) advanceRow() { p.rowtime = 0 } -func (p *Player) processMessages(context PlayerProcessContext) { +func (p *Player) processMessages(context PlayerProcessContext, uiProcessor EventProcessor) { loop: for { // process new message select { @@ -296,6 +307,9 @@ loop: default: // ignore unknown messages } + if uiProcessor != nil { + uiProcessor.ProcessMessage(msg) + } default: break loop } diff --git a/tracker/processor.go b/tracker/processor.go new file mode 100644 index 00000000..21c2bf1e --- /dev/null +++ b/tracker/processor.go @@ -0,0 +1,20 @@ +package tracker + +import ( + "github.com/vsariola/sointu" +) + +type Processor struct { + *Player + playerProcessContext PlayerProcessContext + uiProcessor EventProcessor +} + +func NewProcessor(player *Player, context PlayerProcessContext, uiProcessor EventProcessor) *Processor { + return &Processor{player, context, uiProcessor} +} + +func (p *Processor) ReadAudio(buf sointu.AudioBuffer) error { + p.Player.Process(buf, p.playerProcessContext, p.uiProcessor) + return nil +} diff --git a/tracker/table.go b/tracker/table.go index bc30762e..d598d100 100644 --- a/tracker/table.go +++ b/tracker/table.go @@ -1,6 +1,7 @@ package tracker import ( + "iter" "math" "github.com/vsariola/sointu" @@ -117,6 +118,13 @@ func (v Table) Clear() { } } +func (v Table) Set(value byte) { + defer v.change("Set", MajorChange)() + cursor := v.Cursor() + // TODO: might check for visibility + v.set(cursor, int(value)) +} + func (v Table) Fill(value int) { defer v.change("Fill", MajorChange)() rect := v.Range() @@ -364,12 +372,8 @@ func (e *Order) Title(x int) (title string) { if x < 0 || x >= len(e.d.Song.Score.Tracks) { return } - t := e.d.Song.Score.Tracks[x] - firstVoice := e.d.Song.Score.FirstVoiceForTrack(x) - lastVoice := firstVoice + t.NumVoices - 1 - firstIndex, err := e.d.Song.Patch.InstrumentForVoice(firstVoice) - lastIndex, err2 := e.d.Song.Patch.InstrumentForVoice(lastVoice) - if err != nil || err2 != nil { + firstIndex, lastIndex, err := e.instrumentListFor(x) + if err != nil { return } switch diff := lastIndex - firstIndex; diff { @@ -397,6 +401,37 @@ func (e *Order) Title(x int) (title string) { return } +func (e *Order) instrumentListFor(trackIndex int) (int, int, error) { + track := e.d.Song.Score.Tracks[trackIndex] + firstVoice := e.d.Song.Score.FirstVoiceForTrack(trackIndex) + lastVoice := firstVoice + track.NumVoices - 1 + firstIndex, err1 := e.d.Song.Patch.InstrumentForVoice(firstVoice) + if err1 != nil { + return trackIndex, trackIndex, err1 + } + lastIndex, err2 := e.d.Song.Patch.InstrumentForVoice(lastVoice) + if err2 != nil { + return trackIndex, trackIndex, err2 + } + return firstIndex, lastIndex, nil +} + +func (e *Order) TrackIndicesForCurrentInstrument() iter.Seq[int] { + currentTrack := e.d.Cursor.Track + return e.d.Song.AllTracksWithSameInstrument(currentTrack) +} + +func (e *Order) CountNextTracksForCurrentInstrument() int { + currentTrack := e.d.Cursor.Track + count := 0 + for t := range e.TrackIndicesForCurrentInstrument() { + if t > currentTrack { + count++ + } + } + return count +} + // NoteTable func (v *Notes) Table() Table {