diff --git a/CHANGELOG.md b/CHANGELOG.md index 405c21e..13ab5ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,7 @@ 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]) +- The note editor has a button to allow entering notes by MIDI. ([#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 82a237b..77bcfc8 100644 --- a/cmd/sointu-track/main.go +++ b/cmd/sointu-track/main.go @@ -46,10 +46,10 @@ func main() { if configDir, err := os.UserConfigDir(); err == nil { recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery") } - midiContext := gomidi.NewContext() + broker := tracker.NewBroker() + midiContext := gomidi.NewContext(broker) defer midiContext.Close() midiContext.TryToOpenBy(*defaultMidiInput, *firstMidiInput) - broker := tracker.NewBroker() model := tracker.NewModel(broker, cmd.MainSynther, midiContext, recoveryFile) player := tracker.NewPlayer(broker, cmd.MainSynther) detector := tracker.NewDetector(broker) @@ -65,7 +65,7 @@ func main() { trackerUi := gioui.NewTracker(model) audioCloser := audioContext.Play(func(buf sointu.AudioBuffer) error { - player.Process(buf, midiContext, trackerUi) + player.Process(buf, midiContext) return nil }) diff --git a/cmd/sointu-vsti/main.go b/cmd/sointu-vsti/main.go index 13005d3..988b0a1 100644 --- a/cmd/sointu-vsti/main.go +++ b/cmd/sointu-vsti/main.go @@ -34,28 +34,6 @@ func (m NullMIDIContext) Close() {} func (m NullMIDIContext) HasDeviceOpen() bool { return false } -func (c *VSTIProcessContext) NextEvent(frame int) (event tracker.MIDINoteEvent, ok bool) { - for c.eventIndex < len(c.events) { - ev := c.events[c.eventIndex] - c.eventIndex++ - switch { - case ev.Data[0] >= 0x80 && ev.Data[0] < 0x90: - channel := ev.Data[0] - 0x80 - note := ev.Data[1] - return tracker.MIDINoteEvent{Frame: int(ev.DeltaFrames), On: false, Channel: int(channel), Note: note}, true - case ev.Data[0] >= 0x90 && ev.Data[0] < 0xA0: - channel := ev.Data[0] - 0x90 - note := ev.Data[1] - return tracker.MIDINoteEvent{Frame: int(ev.DeltaFrames), On: true, Channel: int(channel), Note: note}, true - default: - // ignore all other MIDI messages - } - } - return tracker.MIDINoteEvent{}, false -} - -func (c *VSTIProcessContext) FinishBlock(frame int) {} - func (c *VSTIProcessContext) BPM() (bpm float64, ok bool) { timeInfo := c.host.GetTimeInfo(vst2.TempoValid) if timeInfo == nil || timeInfo.Flags&vst2.TempoValid == 0 || timeInfo.Tempo == 0 { @@ -89,8 +67,9 @@ func init() { // swapped/added etc. model.LinkInstrTrack().SetValue(false) go t.Main() - context := VSTIProcessContext{host: h} + context := &VSTIProcessContext{host: h} buf := make(sointu.AudioBuffer, 1024) + var totalFrames int64 = 0 return vst2.Plugin{ UniqueID: PLUGIN_ID, Version: version, @@ -110,12 +89,11 @@ func init() { buf = append(buf, make(sointu.AudioBuffer, out.Frames-len(buf))...) } buf = buf[:out.Frames] - player.Process(buf, &context, nil) + player.Process(buf, context) for i := 0; i < out.Frames; i++ { left[i], right[i] = buf[i][0], buf[i][1] } - context.events = context.events[:0] // reset buffer, but keep the allocated memory - context.eventIndex = 0 + totalFrames += int64(out.Frames) }, }, vst2.Dispatcher{ CanDoFunc: func(pcds vst2.PluginCanDoString) vst2.CanDoResponse { @@ -125,12 +103,17 @@ func init() { } return vst2.NoCanDo }, - ProcessEventsFunc: func(ev *vst2.EventsPtr) { - for i := 0; i < ev.NumEvents(); i++ { - a := ev.Event(i) - switch v := a.(type) { + ProcessEventsFunc: func(events *vst2.EventsPtr) { + for i := 0; i < events.NumEvents(); i++ { + switch ev := events.Event(i).(type) { case *vst2.MIDIEvent: - context.events = append(context.events, *v) + if 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)) + } } } }, diff --git a/tracker/bool.go b/tracker/bool.go index 0a702e6..ec4e8ea 100644 --- a/tracker/bool.go +++ b/tracker/bool.go @@ -119,10 +119,12 @@ func (m *Follow) SetValue(val bool) { m.follow = val } // TrackMidiIn (Midi Input for notes in the tracks) -func (m *Model) TrackMidiIn() Bool { return MakeBool((*TrackMidiIn)(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() } +func (m *Model) TrackMidiIn() Bool { return MakeBool((*TrackMidiIn)(m)) } +func (m *TrackMidiIn) Value() bool { return m.broker.mIDIEventsToGUI.Load() } +func (m *TrackMidiIn) SetValue(val bool) { + m.broker.mIDIEventsToGUI.Store(val) +} +func (m *TrackMidiIn) Enabled() bool { return true } // Effect methods diff --git a/tracker/broker.go b/tracker/broker.go index acab410..0b1f7df 100644 --- a/tracker/broker.go +++ b/tracker/broker.go @@ -2,6 +2,7 @@ package tracker import ( "sync" + "sync/atomic" "time" "github.com/vsariola/sointu" @@ -37,6 +38,7 @@ type ( 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 CloseDetector chan struct{} CloseGUI chan struct{} @@ -44,6 +46,11 @@ type ( FinishedGUI chan struct{} FinishedDetector 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 + bufferPool sync.Pool } @@ -79,6 +86,19 @@ type ( Oversampling bool HasOversampling bool } + + MsgToGUI struct { + Kind GUIMessageKind + Param int + } + + GUIMessageKind int +) + +const ( + GUIMessageKindNone GUIMessageKind = iota + GUIMessageCenterOnRow + GUIMessageEnsureCursorVisible ) func NewBroker() *Broker { @@ -86,6 +106,7 @@ func NewBroker() *Broker { ToPlayer: make(chan interface{}, 1024), ToModel: make(chan MsgToModel, 1024), ToDetector: make(chan MsgToDetector, 1024), + ToGUI: make(chan any, 1024), CloseDetector: make(chan struct{}, 1), CloseGUI: make(chan struct{}, 1), FinishedGUI: make(chan struct{}), @@ -94,6 +115,13 @@ func NewBroker() *Broker { } } +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/draglist.go b/tracker/gioui/draglist.go index 398a6a1..3d33e0a 100644 --- a/tracker/gioui/draglist.go +++ b/tracker/gioui/draglist.go @@ -27,7 +27,6 @@ type DragList struct { dragID pointer.ID tags []bool swapped bool - focused bool requestFocus bool } @@ -57,8 +56,8 @@ func (d *DragList) Focus() { d.requestFocus = true } -func (d *DragList) Focused() bool { - return d.focused +func (d *DragList) Focused(gtx C) bool { + return gtx.Focused(d) } func (s FilledDragListStyle) LayoutScrollBar(gtx C) D { @@ -114,12 +113,11 @@ func (s FilledDragListStyle) Layout(gtx C, element, bg func(gtx C, i int) D) D { } switch ke := event.(type) { case key.FocusEvent: - s.dragList.focused = ke.Focus - if !s.dragList.focused { + if !ke.Focus { s.dragList.TrackerList.SetSelected2(s.dragList.TrackerList.Selected()) } case key.Event: - if !s.dragList.focused || ke.State != key.Press { + if !s.dragList.Focused(gtx) || ke.State != key.Press { break } s.dragList.command(gtx, ke) @@ -141,13 +139,13 @@ func (s FilledDragListStyle) Layout(gtx C, element, bg func(gtx C, i int) D) D { cursorBg := func(gtx C) D { var color color.NRGBA if s.dragList.TrackerList.Selected() == index { - if s.dragList.focused { + if gtx.Focused(s.dragList) { color = s.Cursor.Active } else { color = s.Cursor.Inactive } } else if between(s.dragList.TrackerList.Selected(), index, s.dragList.TrackerList.Selected2()) { - if s.dragList.focused { + if gtx.Focused(s.dragList) { color = s.Selection.Active } else { color = s.Selection.Inactive @@ -193,7 +191,7 @@ func (s FilledDragListStyle) Layout(gtx C, element, bg func(gtx C, i int) D) D { area.Pop() if index == s.dragList.TrackerList.Selected() && isMutable { for { - target := &s.dragList.focused + target := &s.dragList.drag if s.dragList.drag { target = nil } @@ -233,7 +231,7 @@ func (s FilledDragListStyle) Layout(gtx C, element, bg func(gtx C, i int) D) D { } } area := clip.Rect(rect).Push(gtx.Ops) - event.Op(gtx.Ops, &s.dragList.focused) + event.Op(gtx.Ops, &s.dragList.drag) pointer.CursorGrab.Add(gtx.Ops) area.Pop() } diff --git a/tracker/gioui/instrument_editor.go b/tracker/gioui/instrument_editor.go index 6ace43d..beb8d8c 100644 --- a/tracker/gioui/instrument_editor.go +++ b/tracker/gioui/instrument_editor.go @@ -127,19 +127,19 @@ func (ie *InstrumentEditor) Focus() { ie.unitDragList.Focus() } -func (ie *InstrumentEditor) Focused() bool { - return ie.unitDragList.focused +func (ie *InstrumentEditor) Focused(gtx C) bool { + return gtx.Focused(ie.unitDragList) } func (ie *InstrumentEditor) childFocused(gtx C) bool { - return ie.unitEditor.sliderList.Focused() || - ie.instrumentDragList.Focused() || gtx.Source.Focused(ie.commentEditor) || gtx.Source.Focused(ie.nameEditor) || gtx.Source.Focused(ie.searchEditor) || + return ie.unitEditor.sliderList.Focused(gtx) || + ie.instrumentDragList.Focused(gtx) || gtx.Source.Focused(ie.commentEditor) || gtx.Source.Focused(ie.nameEditor) || gtx.Source.Focused(ie.searchEditor) || gtx.Source.Focused(ie.addUnitBtn.Clickable) || gtx.Source.Focused(ie.commentExpandBtn.Clickable) || gtx.Source.Focused(ie.presetMenuBtn.Clickable) || gtx.Source.Focused(ie.deleteInstrumentBtn.Clickable) || gtx.Source.Focused(ie.copyInstrumentBtn.Clickable) } func (ie *InstrumentEditor) Layout(gtx C, t *Tracker) D { - ie.wasFocused = ie.Focused() || ie.childFocused(gtx) + ie.wasFocused = ie.Focused(gtx) || ie.childFocused(gtx) fullscreenBtnStyle := ToggleIcon(gtx, t.Theme, ie.enlargeBtn, icons.NavigationFullscreen, icons.NavigationFullscreenExit, ie.enlargeHint, ie.shrinkHint) linkBtnStyle := ToggleIcon(gtx, t.Theme, ie.linkInstrTrackBtn, icons.NotificationSyncDisabled, icons.NotificationSync, ie.linkDisabledHint, ie.linkEnabledHint) diff --git a/tracker/gioui/keybindings.go b/tracker/gioui/keybindings.go index cda4689..30b6aec 100644 --- a/tracker/gioui/keybindings.go +++ b/tracker/gioui/keybindings.go @@ -8,6 +8,7 @@ import ( "gioui.org/io/clipboard" "gioui.org/io/key" + "github.com/vsariola/sointu/tracker" "gopkg.in/yaml.v2" ) @@ -87,7 +88,7 @@ func makeHint(hint, format, action string) string { // KeyEvent handles incoming key events and returns true if repaint is needed. func (t *Tracker) KeyEvent(e key.Event, gtx C) { if e.State == key.Release { - t.JammingReleased(e) + t.KeyNoteMap.Release(e.Name) return } action, ok := keyBindingMap[e] @@ -257,11 +258,11 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) { t.InstrumentEditor.Focus() case "FocusPrev": switch { - case t.OrderEditor.scrollTable.Focused(): + case t.OrderEditor.scrollTable.Focused(gtx): t.InstrumentEditor.unitEditor.sliderList.Focus() - case t.TrackEditor.scrollTable.Focused(): + case t.TrackEditor.scrollTable.Focused(gtx): t.OrderEditor.scrollTable.Focus() - case t.InstrumentEditor.Focused(): + case t.InstrumentEditor.Focused(gtx): if t.InstrumentEditor.enlargeBtn.Bool.Value() { t.InstrumentEditor.unitEditor.sliderList.Focus() } else { @@ -272,11 +273,11 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) { } case "FocusNext": switch { - case t.OrderEditor.scrollTable.Focused(): + case t.OrderEditor.scrollTable.Focused(gtx): t.TrackEditor.scrollTable.Focus() - case t.TrackEditor.scrollTable.Focused(): + case t.TrackEditor.scrollTable.Focused(gtx): t.InstrumentEditor.Focus() - case t.InstrumentEditor.Focused(): + case t.InstrumentEditor.Focused(gtx): t.InstrumentEditor.unitEditor.sliderList.Focus() default: if t.InstrumentEditor.enlargeBtn.Bool.Value() { @@ -291,26 +292,9 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) { if err != nil { break } - t.JammingPressed(e, val-12) + instr := t.InstrumentEditor.instrumentDragList.TrackerList.Selected() + n := noteAsValue(t.OctaveNumberInput.Int.Value(), val-12) + t.KeyNoteMap.Press(e.Name, tracker.NoteEvent{Channel: instr, Note: n}) } } } - -func (t *Tracker) JammingPressed(e key.Event, val int) byte { - if _, ok := t.KeyPlaying[e.Name]; !ok { - n := noteAsValue(t.OctaveNumberInput.Int.Value(), val) - instr := t.InstrumentEditor.instrumentDragList.TrackerList.Selected() - t.KeyPlaying[e.Name] = t.InstrNoteOn(instr, n) - return n - } - return 0 -} - -func (t *Tracker) JammingReleased(e key.Event) bool { - if noteID, ok := t.KeyPlaying[e.Name]; ok { - noteID.NoteOff() - delete(t.KeyPlaying, e.Name) - return true - } - return false -} diff --git a/tracker/gioui/keyboard.go b/tracker/gioui/keyboard.go new file mode 100644 index 0000000..3acc2a2 --- /dev/null +++ b/tracker/gioui/keyboard.go @@ -0,0 +1,58 @@ +package gioui + +import ( + "time" + + "github.com/vsariola/sointu/tracker" +) + +type ( + // Keyboard is used to associate the keys of a keyboard (e.g. computer or a + // MIDI keyboard) to currently playing notes. You can use any type T to + // identify each key; T should be a comparable type. + Keyboard[T comparable] struct { + broker *tracker.Broker + pressed map[T]tracker.NoteEvent + } +) + +func MakeKeyboard[T comparable](broker *tracker.Broker) Keyboard[T] { + return Keyboard[T]{ + broker: broker, + pressed: make(map[T]tracker.NoteEvent), + } +} + +func (t *Keyboard[T]) Press(key T, ev tracker.NoteEvent) { + if _, ok := t.pressed[key]; ok { + return // already playing a note with this key, do not send a new event + } + t.Release(key) // unset any previous note + if ev.Note > 1 { + ev.Source = t // set the source to this keyboard + ev.On = true + ev.Timestamp = t.now() + if tracker.TrySend(t.broker.ToPlayer, any(ev)) { + t.pressed[key] = ev + } + } +} + +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)) + delete(t.pressed, key) + } +} + +func (t *Keyboard[T]) ReleaseAll() { + for key := range t.pressed { + t.Release(key) + } +} + +func (t *Keyboard[T]) now() int64 { + return time.Now().UnixMilli() * 441 / 10 // convert to 44100Hz frames +} diff --git a/tracker/gioui/note_editor.go b/tracker/gioui/note_editor.go index e458935..5254479 100644 --- a/tracker/gioui/note_editor.go +++ b/tracker/gioui/note_editor.go @@ -114,6 +114,10 @@ func NewNoteEditor(model *tracker.Model) *NoteEditor { return ret } +func (te *NoteEditor) Focused(gtx C) bool { + return te.scrollTable.Focused(gtx) || te.scrollTable.ChildFocused(gtx) +} + func (te *NoteEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions { for { e, ok := gtx.Event(te.eventFilters...) @@ -123,20 +127,30 @@ func (te *NoteEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions { switch e := e.(type) { case key.Event: if e.State == key.Release { - if noteID, ok := t.KeyPlaying[e.Name]; ok { - noteID.NoteOff() - delete(t.KeyPlaying, e.Name) - } + t.KeyNoteMap.Release(e.Name) continue } te.command(t, e) } } + for te.Focused(gtx) && len(t.noteEvents) > 0 { + ev := t.noteEvents[0] + ev.IsTrack = true + ev.Channel = t.Model.Notes().Cursor().X + ev.Source = te + if ev.On { + t.Model.Notes().Input(ev.Note) + } + copy(t.noteEvents, t.noteEvents[1:]) + t.noteEvents = t.noteEvents[:len(t.noteEvents)-1] + tracker.TrySend(t.Broker().ToPlayer, any(ev)) + } + defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() - return Surface{Gray: 24, Focus: te.scrollTable.Focused()}.Layout(gtx, func(gtx C) D { + return Surface{Gray: 24, Focus: te.scrollTable.Focused(gtx)}.Layout(gtx, func(gtx C) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { return te.layoutButtons(gtx, t) @@ -149,7 +163,7 @@ func (te *NoteEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions { } func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D { - return Surface{Gray: 37, Focus: te.scrollTable.Focused() || te.scrollTable.ChildFocused()}.Layout(gtx, func(gtx C) D { + return Surface{Gray: 37, Focus: te.scrollTable.Focused(gtx) || te.scrollTable.ChildFocused(gtx)}.Layout(gtx, func(gtx C) D { addSemitoneBtnStyle := ActionButton(gtx, t.Theme, &t.Theme.Button.Text, te.AddSemitoneBtn, "+1") subtractSemitoneBtnStyle := ActionButton(gtx, t.Theme, &t.Theme.Button.Text, te.SubtractSemitoneBtn, "-1") addOctaveBtnStyle := ActionButton(gtx, t.Theme, &t.Theme.Button.Text, te.AddOctaveBtn, "+12") @@ -276,7 +290,7 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { point := tracker.Point{X: x, Y: y} if drawSelection && selection.Contains(point) { color := t.Theme.Selection.Inactive - if te.scrollTable.Focused() { + if te.scrollTable.Focused(gtx) { color = t.Theme.Selection.Active } paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(0, 0), Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op()) @@ -284,7 +298,7 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { // draw the cursor if point == cursor { c := t.Theme.Cursor.Inactive - if te.scrollTable.Focused() { + if te.scrollTable.Focused(gtx) { c = t.Theme.Cursor.Active } if hasTrackMidiIn { @@ -292,14 +306,6 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { } te.paintColumnCell(gtx, x, t, c) } - // draw the corresponding "fake cursors" for instrument-track-groups (for polyphony) - if hasTrackMidiIn { - for _, trackIndex := range t.Model.TracksWithSameInstrumentAsCurrent() { - if x == trackIndex && y == cursor.Y { - te.paintColumnCell(gtx, x, t, t.Theme.Selection.ActiveAlt) - } - } - } // draw the pattern marker rpp := max(t.RowsPerPattern().Value(), 1) @@ -366,9 +372,8 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) { var n byte if t.Model.Notes().Effect(te.scrollTable.Table.Cursor().X) { 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()) - te.finishNoteInsert(t, n, e.Name) + ev := t.Model.Notes().InputNibble(byte(nibbleValue)) + t.KeyNoteMap.Press(e.Name, ev) } } else { action, ok := keyBindingMap[e] @@ -376,8 +381,8 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) { return } if action == "NoteOff" { - t.Model.Notes().Table().Fill(0) - te.finishNoteInsert(t, 0, "") + ev := t.Model.Notes().Input(0) + t.KeyNoteMap.Press(e.Name, ev) return } if action[:4] == "Note" { @@ -386,42 +391,8 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) { return } n = noteAsValue(t.OctaveNumberInput.Int.Value(), val-12) - t.Model.Notes().Table().Fill(int(n)) - te.finishNoteInsert(t, n, e.Name) + ev := t.Model.Notes().Input(n) + t.KeyNoteMap.Press(e.Name, ev) } } } - -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 keyName == "" { - return - } - if _, ok := t.KeyPlaying[keyName]; !ok { - trk := te.scrollTable.Table.Cursor().X - 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 := 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/order_editor.go b/tracker/gioui/order_editor.go index d093274..4519fd6 100644 --- a/tracker/gioui/order_editor.go +++ b/tracker/gioui/order_editor.go @@ -102,12 +102,12 @@ func (oe *OrderEditor) Layout(gtx C, t *Tracker) D { point := tracker.Point{X: x, Y: y} if selection.Contains(point) { color = t.Theme.Selection.Inactive - if oe.scrollTable.Focused() { + if oe.scrollTable.Focused(gtx) { color = t.Theme.Selection.Active } if point == oe.scrollTable.Table.Cursor() { color = t.Theme.Cursor.Inactive - if oe.scrollTable.Focused() { + if oe.scrollTable.Focused(gtx) { color = t.Theme.Cursor.Active } } diff --git a/tracker/gioui/scroll_table.go b/tracker/gioui/scroll_table.go index e49da2f..f0afdcb 100644 --- a/tracker/gioui/scroll_table.go +++ b/tracker/gioui/scroll_table.go @@ -21,7 +21,6 @@ type ScrollTable struct { ColTitleList *DragList RowTitleList *DragList Table tracker.Table - focused bool requestFocus bool cursorMoved bool eventFilters []event.Filter @@ -94,8 +93,8 @@ func (st *ScrollTable) Focus() { st.requestFocus = true } -func (st *ScrollTable) Focused() bool { - return st.focused +func (st *ScrollTable) Focused(gtx C) bool { + return gtx.Source.Focused(st) } func (st *ScrollTable) EnsureCursorVisible() { @@ -103,8 +102,8 @@ func (st *ScrollTable) EnsureCursorVisible() { st.RowTitleList.EnsureVisible(st.Table.Cursor().Y) } -func (st *ScrollTable) ChildFocused() bool { - return st.ColTitleList.Focused() || st.RowTitleList.Focused() +func (st *ScrollTable) ChildFocused(gtx C) bool { + return st.ColTitleList.Focused(gtx) || st.RowTitleList.Focused(gtx) } func (s ScrollTableStyle) Layout(gtx C, element func(gtx C, x, y int) D, colTitle, rowTitle, colTitleBg, rowTitleBg func(gtx C, i int) D) D { @@ -114,7 +113,7 @@ func (s ScrollTableStyle) Layout(gtx C, element func(gtx C, x, y int) D, colTitl p := image.Pt(gtx.Dp(s.RowTitleWidth), gtx.Dp(s.ColumnTitleHeight)) s.handleEvents(gtx, p) - return Surface{Gray: 24, Focus: s.ScrollTable.Focused() || s.ScrollTable.ChildFocused()}.Layout(gtx, func(gtx C) D { + return Surface{Gray: 24, Focus: s.ScrollTable.Focused(gtx) || s.ScrollTable.ChildFocused(gtx)}.Layout(gtx, func(gtx C) D { defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() dims := gtx.Constraints.Max s.layoutColTitles(gtx, p, colTitle, colTitleBg) @@ -135,8 +134,6 @@ func (s *ScrollTableStyle) handleEvents(gtx layout.Context, p image.Point) { break } switch e := e.(type) { - case key.FocusEvent: - s.ScrollTable.focused = e.Focus case pointer.Event: switch e.Kind { case pointer.Press: diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index d19e6e8..60be672 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -33,8 +33,7 @@ type ( TopHorizontalSplit *Split BottomHorizontalSplit *Split VerticalSplit *Split - KeyPlaying map[key.Name]tracker.NoteID - MidiNotePlaying []byte + KeyNoteMap Keyboard[key.Name] PopupAlert *PopupAlert Zoom int @@ -50,6 +49,7 @@ type ( SongPanel *SongPanel filePathString tracker.String + noteEvents []tracker.NoteEvent execChan chan func() preferences Preferences @@ -78,8 +78,6 @@ func NewTracker(model *tracker.Model) *Tracker { BottomHorizontalSplit: &Split{Ratio: -.6, MinSize1: 180, MinSize2: 180}, VerticalSplit: &Split{Axis: layout.Vertical, MinSize1: 180, MinSize2: 180}, - 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), @@ -93,6 +91,7 @@ func NewTracker(model *tracker.Model) *Tracker { filePathString: model.FilePath(), } + t.KeyNoteMap = MakeKeyboard[key.Name](model.Broker()) t.PopupAlert = NewPopupAlert(model.Alerts()) var warn error if t.Theme, warn = NewTheme(); warn != nil { @@ -138,6 +137,19 @@ func (t *Tracker) Main() { F: for { select { + case e := <-t.Broker().ToGUI: + switch e := e.(type) { + case tracker.NoteEvent: + t.noteEvents = append(t.noteEvents, e) + case tracker.MsgToGUI: + switch e.Kind { + case tracker.GUIMessageCenterOnRow: + t.TrackEditor.scrollTable.RowTitleList.CenterOn(e.Param) + case tracker.GUIMessageEnsureCursorVisible: + t.TrackEditor.scrollTable.EnsureCursorVisible() + } + } + w.Invalidate() case e := <-t.Broker().ToModel: t.ProcessMsg(e) w.Invalidate() @@ -158,9 +170,6 @@ func (t *Tracker) Main() { w.Option(app.Title(titleFromPath(titlePath))) } gtx := app.NewContext(&ops, e) - if t.Playing().Value() && t.Follow().Value() { - t.TrackEditor.scrollTable.RowTitleList.CenterOn(t.PlaySongRow()) - } t.Layout(gtx, w) e.Frame(gtx.Ops) if t.Quitted() { @@ -240,7 +249,16 @@ func (t *Tracker) Layout(gtx layout.Context, w *app.Window) { t.ReadSong(e.Open()) } } - + // if no-one else handled the note events, we handle them here + for len(t.noteEvents) > 0 { + ev := t.noteEvents[0] + ev.IsTrack = false + ev.Channel = t.Model.Instruments().Selected() + ev.Source = t + copy(t.noteEvents, t.noteEvents[1:]) + t.noteEvents = t.noteEvents[:len(t.noteEvents)-1] + tracker.TrySend(t.Broker().ToPlayer, any(ev)) + } } func (t *Tracker) showDialog(gtx C) { @@ -328,46 +346,3 @@ 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 ddc6366..922e715 100644 --- a/tracker/gomidi/midi.go +++ b/tracker/gomidi/midi.go @@ -19,24 +19,15 @@ import ( type ( RTMIDIContext struct { - driver *rtmididrv.Driver - currentIn drivers.In - events chan timestampedMsg - eventsBuf []timestampedMsg - eventIndex int - startFrame int - startFrameSet bool + driver *rtmididrv.Driver + currentIn drivers.In + broker *tracker.Broker } RTMIDIDevice struct { context *RTMIDIContext in drivers.In } - - timestampedMsg struct { - frame int - msg midi.Message - } ) func (m *RTMIDIContext) InputDevices(yield func(tracker.MIDIDevice) bool) { @@ -56,8 +47,8 @@ func (m *RTMIDIContext) InputDevices(yield func(tracker.MIDIDevice) bool) { } // Open the driver. -func NewContext() *RTMIDIContext { - m := RTMIDIContext{events: make(chan timestampedMsg, 1024)} +func NewContext(broker *tracker.Broker) *RTMIDIContext { + m := RTMIDIContext{broker: broker} // there's not much we can do if this fails, so just use m.driver = nil to // indicate no driver available m.driver, _ = rtmididrv.New() @@ -94,69 +85,16 @@ func (d RTMIDIDevice) String() string { } func (m *RTMIDIContext) HandleMessage(msg midi.Message, timestampms int32) { - select { - case m.events <- timestampedMsg{frame: int(int64(timestampms) * 44100 / 1000), msg: msg}: // if the channel is full, just drop the message - default: + var channel, key, velocity 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)) + } 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)) } } -func (c *RTMIDIContext) NextEvent(frame int) (event tracker.MIDINoteEvent, ok bool) { -F: - for { - select { - case msg := <-c.events: - c.eventsBuf = append(c.eventsBuf, msg) - if !c.startFrameSet { - c.startFrame = msg.frame - c.startFrameSet = true - } - default: - break F - } - } - if c.eventIndex > 0 { // an event was consumed, check how badly we need to adjust the timing - delta := frame + c.startFrame - c.eventsBuf[c.eventIndex-1].frame - // delta should never be a negative number, because the renderer does - // not consume an event until current frame is past the frame of the - // event. However, if it's been a while since we consumed event, delta - // may by *positive* i.e. we consume the event too late. So adjust the - // internal clock in that case. - c.startFrame -= delta / 5 // adjust the start frame towards the consumed event - } - for c.eventIndex < len(c.eventsBuf) { - var channel uint8 - var velocity uint8 - var key uint8 - m := c.eventsBuf[c.eventIndex] - f := m.frame - c.startFrame - c.eventIndex++ - if m.msg.GetNoteOn(&channel, &key, &velocity) { - return tracker.MIDINoteEvent{Frame: f, On: true, Channel: int(channel), Note: key}, true - } else if m.msg.GetNoteOff(&channel, &key, &velocity) { - return tracker.MIDINoteEvent{Frame: f, On: false, Channel: int(channel), Note: key}, true - } - } - c.eventIndex = len(c.eventsBuf) + 1 - return tracker.MIDINoteEvent{}, false -} - -func (c *RTMIDIContext) FinishBlock(frame int) { - c.startFrame += frame - if c.eventIndex > 0 { - copy(c.eventsBuf, c.eventsBuf[c.eventIndex-1:]) - c.eventsBuf = c.eventsBuf[:len(c.eventsBuf)-c.eventIndex+1] - if len(c.eventsBuf) > 0 { - // Events were not consumed this round; adjust the start frame - // towards the future events. What this does is that it tries to - // render the events at the same time as they were received here - // delta will be always a negative number - delta := c.startFrame - c.eventsBuf[0].frame - c.startFrame -= delta / 5 - } - } - c.eventIndex = 0 -} - func (c *RTMIDIContext) BPM() (bpm float64, ok bool) { return 0, false } diff --git a/tracker/model.go b/tracker/model.go index e249e57..d70efd3 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -82,8 +82,7 @@ type ( broker *Broker - MIDI MIDIContext - trackMidiIn bool + MIDI MIDIContext } // Cursor identifies a row and a track in a song score. @@ -104,26 +103,12 @@ type ( Continuation func(string) // function to call with the selected file path } - // Describes a note triggered either a track or an instrument - // If Go had union or Either types, this would be it, but in absence - // those, this uses a boolean to define if the instrument is defined or the track - NoteID struct { - IsInstr bool - Instr int - Track int - Note byte - - model *Model - } - IsPlayingMsg struct{ bool } StartPlayMsg struct{ sointu.SongPos } BPMMsg struct{ int } RowsPerBeatMsg struct{ int } PanicMsg struct{ bool } RecordingMsg struct{ bool } - NoteOnMsg struct{ NoteID } - NoteOffMsg struct{ NoteID } ChangeSeverity int ChangeType int @@ -187,7 +172,6 @@ func NewModel(broker *Broker, synther sointu.Synther, midiContext MIDIContext, r m := new(Model) m.synther = synther m.MIDI = midiContext - m.trackMidiIn = midiContext.HasDeviceOpen() m.broker = broker m.d.Octave = 4 m.linkInstrTrack = true @@ -353,6 +337,10 @@ func (m *Model) ProcessMsg(msg MsgToModel) { if m.playing && m.follow { m.d.Cursor.SongPos = msg.SongPosition m.d.Cursor2.SongPos = msg.SongPosition + TrySend(m.broker.ToGUI, any(MsgToGUI{ + Kind: GUIMessageCenterOnRow, + Param: m.PlaySongRow(), + })) } m.panic = msg.Panic } @@ -386,29 +374,12 @@ func (m *Model) ProcessMsg(msg MsgToModel) { m.playing = e.bool case *sointu.AudioBuffer: m.signalAnalyzer.ProcessAudioBuffer(e) - default: } } func (m *Model) SignalAnalyzer() *ScopeModel { return m.signalAnalyzer } func (m *Model) Broker() *Broker { return m.broker } -func (m *Model) TrackNoteOn(track int, note byte) (id NoteID) { - id = NoteID{IsInstr: false, Track: track, Note: note, model: m} - TrySend(m.broker.ToPlayer, any(NoteOnMsg{id})) - return id -} - -func (m *Model) InstrNoteOn(instr int, note byte) (id NoteID) { - id = NoteID{IsInstr: true, Instr: instr, Note: note, model: m} - TrySend(m.broker.ToPlayer, any(NoteOnMsg{id})) - return id -} - -func (n NoteID) NoteOff() { - TrySend(n.model.broker.ToPlayer, any(NoteOffMsg{n})) -} - func (d *modelData) Copy() modelData { ret := *d ret.Song = d.Song.Copy() diff --git a/tracker/model_test.go b/tracker/model_test.go index ca6f03e..510a0f5 100644 --- a/tracker/model_test.go +++ b/tracker/model_test.go @@ -13,10 +13,6 @@ import ( type NullContext struct{} -func (NullContext) NextEvent(frame int) (event tracker.MIDINoteEvent, ok bool) { - return tracker.MIDINoteEvent{}, false -} - func (NullContext) FinishBlock(frame int) {} func (NullContext) BPM() (bpm float64, ok bool) { @@ -277,7 +273,7 @@ func FuzzModel(f *testing.F) { break loop default: ctx := NullContext{} - player.Process(buf, ctx, nil) + player.Process(buf, ctx) } } }() diff --git a/tracker/player.go b/tracker/player.go index 98eb71d..8b1c27e 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -1,8 +1,10 @@ package tracker import ( + "cmp" "fmt" "math" + "slices" "github.com/vsariola/sointu" "github.com/vsariola/sointu/vm" @@ -24,59 +26,57 @@ type ( voices [vm.MAX_VOICES]voice loop Loop - recState recState // is the recording off; are we waiting for a note; or are we recording recording Recording // the recorded MIDI events and BPM + frame int64 // the current player frame, used to time events + frameDeltas map[any]int64 // Player.frame (approx.)= event.Timestamp + frameDeltas[event.Source] + events NoteEventList + synther sointu.Synther // the synther used to create new synths broker *Broker // the broker used to communicate with different parts of the tracker } // PlayerProcessContext is the context given to the player when processing - // audio. It is used to get MIDI events and the current BPM. + // audio. Currently it is only used to get BPM from the VSTI host. PlayerProcessContext interface { - NextEvent(frame int) (event MIDINoteEvent, ok bool) - FinishBlock(frame int) BPM() (bpm float64, ok bool) } - EventProcessor interface { - ProcessMessage(msg interface{}) - ProcessEvent(event MIDINoteEvent) - } + // NoteEvent describes triggering or releasing of a note. The timestamps are + // in frames, and relative to the clock of the event source. Different + // sources can use different clocks. Player tries to adjust the timestamps + // so that each note events would fall inside the current processing block, + // by maintaining an estimate of the delta from the source clock to the + // player clock. + NoteEvent struct { + Timestamp int64 // in frames, relative to whatever clock the source is using + On bool + Channel int + Note byte + IsTrack bool // true if "Channel" means track number, false if it means instrument number + Source any - // 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. - MIDINoteEvent struct { - Frame int - On bool - Channel int - Note byte + playerTimestamp int64 // the timestamp of the event, adjusted to the player's clock, used to sort events } ) type ( - recState int - voice struct { - noteID int + triggerEvent NoteEvent // which event triggered this voice, used to release the voice sustain bool samplesSinceEvent int } -) -const ( - recStateNone recState = iota - recStateWaitingForNote - recStateRecording + NoteEventList []NoteEvent ) const numRenderTries = 10000 func NewPlayer(broker *Broker, synther sointu.Synther) *Player { return &Player{ - broker: broker, - synther: synther, + broker: broker, + synther: synther, + frameDeltas: make(map[any]int64), } } @@ -85,70 +85,41 @@ func NewPlayer(broker *Broker, synther sointu.Synther) *Player { // 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, ui EventProcessor) { - p.processMessages(context, ui) - - frame := 0 - midi, midiOk := context.NextEvent(frame) - - if p.recState == recStateRecording { - p.recording.TotalFrames += len(buffer) - } +func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext) { + p.processMessages(context) + p.events.adjustTimes(p.frameDeltas, p.frame, p.frame+int64(len(buffer))) for i := 0; i < numRenderTries; i++ { - for midiOk && frame >= midi.Frame { - if p.recState == recStateWaitingForNote { - p.recording.TotalFrames = len(buffer) - p.recState = recStateRecording - } - if p.recState == recStateRecording { - midiTotalFrame := midi - midiTotalFrame.Frame = p.recording.TotalFrames - len(buffer) - p.recording.Events = append(p.recording.Events, midiTotalFrame) - } - if midi.On { - p.triggerInstrument(midi.Channel, midi.Note) - } else { - p.releaseInstrument(midi.Channel, midi.Note) - } - if ui != nil { - ui.ProcessEvent(midi) - } - - midi, midiOk = context.NextEvent(frame) + for len(p.events) > 0 && p.events[0].playerTimestamp <= p.frame { + ev := p.events[0] + copy(p.events, p.events[1:]) // remove processed events + p.events = p.events[:len(p.events)-1] + p.recording.Record(ev, p.frame) + p.processNoteEvent(ev) } - framesUntilMidi := len(buffer) - if delta := midi.Frame - frame; midiOk && delta < framesUntilMidi { - framesUntilMidi = delta + framesUntilEvent := len(buffer) + if len(p.events) > 0 { + framesUntilEvent = min(int(p.events[0].playerTimestamp-p.frame), len(buffer)) } if p.playing && p.rowtime >= p.song.SamplesPerRow() { p.advanceRow() } timeUntilRowAdvance := math.MaxInt32 if p.playing { - timeUntilRowAdvance = p.song.SamplesPerRow() - p.rowtime - } - if timeUntilRowAdvance < 0 { - timeUntilRowAdvance = 0 + timeUntilRowAdvance = max(p.song.SamplesPerRow()-p.rowtime, 0) } var rendered, timeAdvanced int var err error if p.synth != nil { - rendered, timeAdvanced, err = p.synth.Render(buffer[:framesUntilMidi], timeUntilRowAdvance) + rendered, timeAdvanced, err = p.synth.Render(buffer[:framesUntilEvent], timeUntilRowAdvance) + if err != nil { + p.synth = nil + p.send(Alert{Message: fmt.Sprintf("synth.Render: %s", err.Error()), Priority: Error, Name: "PlayerCrash"}) + } } else { - mx := framesUntilMidi - if timeUntilRowAdvance < mx { - mx = timeUntilRowAdvance - } - for i := 0; i < mx; i++ { - buffer[i] = [2]float32{} - } - rendered = mx - timeAdvanced = mx - } - if err != nil { - p.synth = nil - p.send(Alert{Message: fmt.Sprintf("synth.Render: %s", err.Error()), Priority: Error, Name: "PlayerCrash"}) + rendered = min(framesUntilEvent, timeUntilRowAdvance) + timeAdvanced = rendered + clear(buffer[:rendered]) } bufPtr := p.broker.GetAudioBuffer() // borrow a buffer from the broker @@ -159,7 +130,7 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext p.broker.PutAudioBuffer(bufPtr) } buffer = buffer[rendered:] - frame += rendered + p.frame += int64(rendered) p.rowtime += timeAdvanced for i := range p.voices { p.voices[i].samplesSinceEvent += rendered @@ -175,12 +146,12 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext // when the buffer is full, return if len(buffer) == 0 { p.send(nil) - context.FinishBlock(frame) return } } // we were not able to fill the buffer with NUM_RENDER_TRIES attempts, destroy synth and throw an error p.synth = nil + p.events = p.events[:0] // clear events, so we don't try to process them again p.SendAlert("PlayerCrash", fmt.Sprintf("synth did not fill the audio buffer even with %d render calls", numRenderTries), Error) } @@ -199,28 +170,24 @@ func (p *Player) advanceRow() { p.send(IsPlayingMsg{bool: false}) p.playing = false for i := range p.song.Score.Tracks { - p.releaseTrack(i) + p.processNoteEvent(NoteEvent{Channel: i, IsTrack: true, Source: p}) } return } - p.send(nil) // just send volume and song row information - lastVoice := 0 for i, t := range p.song.Score.Tracks { - start := lastVoice - lastVoice = start + t.NumVoices n := t.Note(p.songPos) switch { case n == 0: - p.releaseTrack(i) + p.processNoteEvent(NoteEvent{Channel: i, IsTrack: true, Source: p, On: false}) case n > 1: - p.triggerTrack(i, n) - default: // n == 1 - } + p.processNoteEvent(NoteEvent{Channel: i, IsTrack: true, Source: p, Note: n, On: true}) + } // n = 1 means hold so do nothing } p.rowtime = 0 + p.send(nil) // just send volume and song row information } -func (p *Player) processMessages(context PlayerProcessContext, uiProcessor EventProcessor) { +func (p *Player) processMessages(context PlayerProcessContext) { loop: for { // process new message select { @@ -246,7 +213,7 @@ loop: p.playing = bool(m.bool) if !p.playing { for i := range p.song.Score.Tracks { - p.releaseTrack(i) + p.processNoteEvent(NoteEvent{Channel: i, IsTrack: true, Source: p}) } } else { TrySend(p.broker.ToModel, MsgToModel{Reset: true}) @@ -265,45 +232,77 @@ loop: for i, t := range p.song.Score.Tracks { if !t.Effect { // when starting to play from another position, release only non-effect tracks - p.releaseTrack(i) + p.processNoteEvent(NoteEvent{Channel: i, IsTrack: true, Source: p}) } } TrySend(p.broker.ToModel, MsgToModel{Reset: true}) - case NoteOnMsg: - if m.IsInstr { - p.triggerInstrument(m.Instr, m.Note) - } else { - p.triggerTrack(m.Track, m.Note) - } - case NoteOffMsg: - if m.IsInstr { - p.releaseInstrument(m.Instr, m.Note) - } else { - p.releaseTrack(m.Track) - } + case NoteEvent: + p.events = append(p.events, m) case RecordingMsg: if m.bool { - p.recState = recStateWaitingForNote - p.recording = Recording{} + p.recording = Recording{State: RecordingWaitingForNote} } else { - if p.recState == recStateRecording && len(p.recording.Events) > 0 { + if p.recording.State == RecordingStarted && len(p.recording.Events) > 0 { + p.recording.Finish(p.frame, p.frameDeltas) p.recording.BPM, _ = context.BPM() p.send(p.recording) } - p.recState = recStateNone + p.recording = Recording{} // reset recording } default: // ignore unknown messages } - if uiProcessor != nil { - uiProcessor.ProcessMessage(msg) - } default: break loop } } } +func (l NoteEventList) adjustTimes(frameDeltas map[any]int64, minFrame, maxFrame int64) { + // add new sources to the map + for _, ev := range l { + if _, ok := frameDeltas[ev.Source]; !ok { + frameDeltas[ev.Source] = 0 // doesn't matter, we will adjust it immediately after this + } + } + // for each source, calculate the min and max of the frame + for source, delta := range frameDeltas { + var srcMinFrame int64 = math.MaxInt64 + var srcMaxFrame int64 = math.MinInt64 + for _, ev := range l { + if ev.Source != source { + continue + } + if ev.Timestamp < srcMinFrame { + srcMinFrame = ev.Timestamp + } + if ev.Timestamp > srcMaxFrame { + srcMaxFrame = ev.Timestamp + } + } + if srcMinFrame == math.MaxInt64 || srcMaxFrame == math.MinInt64 { + continue // no events for this source in this processing block + } + // "left" is the difference between the left edge of the source's events + // and the left edge of the player clock, calculated using the current frameDelta + left := minFrame - srcMinFrame - delta + right := maxFrame - srcMaxFrame - delta + // we try to adjust the frameDelta so that the source's events are + // within the processing block + positiveAdjust := min(max(left, 0), max(right, 0)) // always a positive value + negativeAdjust := max(min(left, 0), min(right, 0)) // always a negative value + frameDeltas[source] += positiveAdjust + negativeAdjust + } + for i, ev := range l { + l[i].playerTimestamp = ev.Timestamp + frameDeltas[ev.Source] + } + // the events should have been sorted already within each source, but they + // are not necessarily interleaved correctly, so we sort them now + slices.SortFunc(l, func(a, b NoteEvent) int { + return cmp.Compare(a.playerTimestamp, b.playerTimestamp) + }) +} + func (p *Player) SendAlert(name, message string, priority AlertPriority) { p.send(Alert{ Name: name, @@ -349,37 +348,39 @@ func (p *Player) send(message interface{}) { TrySend(p.broker.ToModel, MsgToModel{HasPanicPosLevels: true, Panic: p.synth == nil, SongPosition: p.songPos, VoiceLevels: p.voiceLevels, Data: message}) } -func (p *Player) triggerInstrument(instrument int, note byte) { - ID := idForInstrumentNote(instrument, note) - p.release(ID) - if p.song.Patch == nil || instrument < 0 || instrument >= len(p.song.Patch) { - return - } - voiceStart := p.song.Patch.FirstVoiceForInstrument(instrument) - voiceEnd := voiceStart + p.song.Patch[instrument].NumVoices - p.trigger(voiceStart, voiceEnd, note, ID) -} - -func (p *Player) releaseInstrument(instrument int, note byte) { - p.release(idForInstrumentNote(instrument, note)) -} - -func (p *Player) triggerTrack(track int, note byte) { - ID := idForTrack(track) - p.release(ID) - voiceStart := p.song.Score.FirstVoiceForTrack(track) - voiceEnd := voiceStart + p.song.Score.Tracks[track].NumVoices - p.trigger(voiceStart, voiceEnd, note, ID) -} - -func (p *Player) releaseTrack(track int) { - p.release(idForTrack(track)) -} - -func (p *Player) trigger(voiceStart, voiceEnd int, note byte, ID int) { +func (p *Player) processNoteEvent(ev NoteEvent) { if p.synth == nil { return } + // release previous voice + for i := range p.voices { + if p.voices[i].sustain && + p.voices[i].triggerEvent.Source == ev.Source && + p.voices[i].triggerEvent.Channel == ev.Channel && + p.voices[i].triggerEvent.IsTrack == ev.IsTrack && + (ev.IsTrack || (p.voices[i].triggerEvent.Note == ev.Note)) { // tracks don't match the note number when triggering new event, but instrument events do + p.voices[i].sustain = false + p.voices[i].samplesSinceEvent = 0 + p.synth.Release(i) + } + } + if !ev.On { + return + } + var voiceStart, voiceEnd int + if ev.IsTrack { + if ev.Channel < 0 || ev.Channel >= len(p.song.Score.Tracks) { + return + } + voiceStart = p.song.Score.FirstVoiceForTrack(ev.Channel) + voiceEnd = voiceStart + p.song.Score.Tracks[ev.Channel].NumVoices + } else { + if p.song.Patch == nil || ev.Channel < 0 || ev.Channel >= len(p.song.Patch) { + return + } + voiceStart = p.song.Patch.FirstVoiceForInstrument(ev.Channel) + voiceEnd = voiceStart + p.song.Patch[ev.Channel].NumVoices + } var age int = 0 oldestReleased := false oldestVoice := 0 @@ -399,34 +400,8 @@ func (p *Player) trigger(voiceStart, voiceEnd int, note byte, ID int) { if err != nil || p.song.Patch[instrIndex].Mute { return } - p.voices[oldestVoice] = voice{noteID: ID, sustain: true, samplesSinceEvent: 0} + p.voices[oldestVoice] = voice{triggerEvent: ev, sustain: true, samplesSinceEvent: 0} p.voiceLevels[oldestVoice] = 1.0 - p.synth.Trigger(oldestVoice, note) + p.synth.Trigger(oldestVoice, ev.Note) TrySend(p.broker.ToModel, MsgToModel{TriggerChannel: instrIndex + 1}) } - -func (p *Player) release(ID int) { - if p.synth == nil { - return - } - for i := range p.voices { - if p.voices[i].noteID == ID && p.voices[i].sustain { - p.voices[i].sustain = false - p.voices[i].samplesSinceEvent = 0 - p.synth.Release(i) - return - } - } -} - -// we need to give voices triggered by different sources a identifier who triggered it -// positive values are for voices triggered by instrument jamming i.e. MIDI message from -// host or pressing key on the keyboard -// negative values are for voices triggered by tracks when playing a song -func idForInstrumentNote(instrument int, note byte) int { - return instrument*256 + int(note) -} - -func idForTrack(track int) int { - return -1 - track -} diff --git a/tracker/recording.go b/tracker/recording.go index 8f4b1ba..8173d01 100644 --- a/tracker/recording.go +++ b/tracker/recording.go @@ -8,42 +8,77 @@ import ( "github.com/vsariola/sointu" ) -type Recording struct { - BPM float64 // vsts allow bpms as floats so for accurate reconstruction, keep it as float for recording - Events []MIDINoteEvent - TotalFrames int -} +type ( + Recording struct { + BPM float64 // vsts allow bpms as floats so for accurate reconstruction, keep it as float for recording + Events NoteEventList + StartFrame, EndFrame int64 + State RecordingState + } -type recordingNote struct { - note byte - startRow int - endRow int -} + RecordingState int +) + +const ( + RecordingNone RecordingState = iota + RecordingWaitingForNote + RecordingStarted // StartFrame is set, but EndFrame is not + RecordingFinished // StartFrame and EndFrame are both set, recording is finished +) var ErrInvalidRows = errors.New("rows per beat and rows per pattern must be greater than 1") +var ErrNotFinished = errors.New("the recording was not finished") + +func (r *Recording) Record(ev NoteEvent, frame int64) { + if r.State == RecordingNone || r.State == RecordingFinished { + return + } + if r.State == RecordingWaitingForNote { + r.StartFrame = frame + r.State = RecordingStarted + } + r.Events = append(r.Events, ev) +} + +func (r *Recording) Finish(frame int64, frameDeltas map[any]int64) { + if r.State != RecordingStarted { + return + } + r.State = RecordingFinished + r.EndFrame = frame + r.Events.adjustTimes(frameDeltas, r.StartFrame, r.EndFrame) +} func (recording *Recording) Score(patch sointu.Patch, rowsPerBeat, rowsPerPattern int) (sointu.Score, error) { if rowsPerBeat <= 1 || rowsPerPattern <= 1 { return sointu.Score{}, ErrInvalidRows } + if recording.State != RecordingFinished { + return sointu.Score{}, ErrNotFinished + } + type recordingNote struct { + note byte + startRow int + endRow int + } channelNotes := make([][]recordingNote, 0) // find the length of each note and assign it to its respective channel for i, m := range recording.Events { - if !m.On || m.Channel >= len(patch) { + if !m.On || m.Channel >= len(patch) || m.IsTrack { continue } - endFrame := math.MaxInt + var endFrame int64 = math.MaxInt64 for j := i + 1; j < len(recording.Events); j++ { if recording.Events[j].Channel == m.Channel && recording.Events[j].Note == m.Note { - endFrame = recording.Events[j].Frame + endFrame = recording.Events[j].playerTimestamp break } } for len(channelNotes) <= m.Channel { channelNotes = append(channelNotes, make([]recordingNote, 0)) } - startRow := frameToRow(recording.BPM, rowsPerBeat, m.Frame) - endRow := frameToRow(recording.BPM, rowsPerBeat, endFrame) + startRow := frameToRow(recording.BPM, rowsPerBeat, m.playerTimestamp-recording.StartFrame) + endRow := frameToRow(recording.BPM, rowsPerBeat, endFrame-recording.StartFrame) channelNotes[m.Channel] = append(channelNotes[m.Channel], recordingNote{m.Note, startRow, endRow}) } //assign notes to tracks, assigning it to left most track that is released @@ -83,7 +118,7 @@ func (recording *Recording) Score(patch sointu.Patch, rowsPerBeat, rowsPerPatter tracks[i] = append(tracks[i], []recordingNote{}) } } - songLengthPatterns := (frameToRow(recording.BPM, rowsPerBeat, recording.TotalFrames) + rowsPerPattern - 1) / rowsPerPattern + songLengthPatterns := (frameToRow(recording.BPM, rowsPerBeat, recording.EndFrame-recording.StartFrame) + rowsPerPattern - 1) / rowsPerPattern songLengthRows := songLengthPatterns * rowsPerPattern songTracks := make([]sointu.Track, 0) for i, tg := range tracks { @@ -148,6 +183,6 @@ func (recording *Recording) Score(patch sointu.Patch, rowsPerBeat, rowsPerPatter return score, nil } -func frameToRow(BPM float64, rowsPerBeat, frame int) int { +func frameToRow(BPM float64, rowsPerBeat int, frame int64) int { return int(float64(frame)/44100/60*BPM*float64(rowsPerBeat) + 0.5) } diff --git a/tracker/table.go b/tracker/table.go index 82b78d2..35aa5ff 100644 --- a/tracker/table.go +++ b/tracker/table.go @@ -2,6 +2,7 @@ package tracker import ( "math" + "time" "github.com/vsariola/sointu" "gopkg.in/yaml.v3" @@ -437,7 +438,7 @@ func (v *Notes) MoveCursor(dx, dy int) (ok bool) { } func (v *Notes) clear(p Point) { - v.SetValue(p, 1) + v.Input(1) } func (v *Notes) set(p Point, value int) { @@ -586,7 +587,12 @@ func (m *Notes) SetValue(p Point, val byte) { (*track).SetNote(pos, val, m.uniquePatterns) } -func (v *Notes) FillNibble(value byte, lowNibble bool) { +func (v *Notes) Input(note byte) NoteEvent { + v.Table().Fill(int(note)) + return v.finishInput(note) +} + +func (v *Notes) InputNibble(nibble byte) NoteEvent { defer v.change("FillNibble", MajorChange)() rect := Table{v}.Range() for y := rect.TopLeft.Y; y <= rect.BottomRight.Y; y++ { @@ -595,12 +601,24 @@ func (v *Notes) FillNibble(value byte, lowNibble bool) { if val == 1 { val = 0 // treat hold also as 0 } - if lowNibble { - val = (val & 0xf0) | byte(value&15) + if v.d.LowNibble { + val = (val & 0xf0) | byte(nibble&15) } else { - val = (val & 0x0f) | byte((value&15)<<4) + val = (val & 0x0f) | byte((nibble&15)<<4) } v.SetValue(Point{x, y}, val) } } + return v.finishInput(v.Value(v.Cursor())) +} + +func (v *Notes) finishInput(note byte) NoteEvent { + if step := v.d.Step; step > 0 { + v.Table().MoveCursor(0, step) + v.Table().SetCursor2(v.Table().Cursor()) + } + TrySend(v.broker.ToGUI, any(MsgToGUI{Kind: GUIMessageEnsureCursorVisible, Param: v.Table().Cursor().Y})) + track := v.Cursor().X + ts := time.Now().UnixMilli() * 441 / 10 // convert to 44100Hz frames + return NoteEvent{IsTrack: true, Channel: track, Note: note, On: true, Timestamp: ts} }