feat(tracker): rework the MIDI input and note event handling

This commit is contained in:
5684185+vsariola@users.noreply.github.com
2025-06-03 20:03:22 +03:00
parent 7ef868a434
commit 283fbc1171
19 changed files with 428 additions and 500 deletions

View File

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

View File

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

View File

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

58
tracker/gioui/keyboard.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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