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

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

View File

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

View File

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

View File

@ -120,9 +120,11 @@ 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 *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

View File

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

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)
}
}
}
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
n := noteAsValue(t.OctaveNumberInput.Int.Value(), val-12)
t.KeyNoteMap.Press(e.Name, tracker.NoteEvent{Channel: instr, Note: 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:]...,
)
}
}
}

View File

@ -21,22 +21,13 @@ type (
RTMIDIContext struct {
driver *rtmididrv.Driver
currentIn drivers.In
events chan timestampedMsg
eventsBuf []timestampedMsg
eventIndex int
startFrame int
startFrameSet bool
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
}

View File

@ -83,7 +83,6 @@ type (
broker *Broker
MIDI MIDIContext
trackMidiIn bool
}
// 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()

View File

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

View File

@ -1,8 +1,10 @@
package tracker
import (
"cmp"
"fmt"
"math"
"slices"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/vm"
@ -24,51 +26,48 @@ 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)
}
// 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
// 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
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
@ -77,6 +76,7 @@ func NewPlayer(broker *Broker, synther sointu.Synther) *Player {
return &Player{
broker: broker,
synther: synther,
frameDeltas: make(map[any]int64),
}
}
@ -85,71 +85,42 @@ 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
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)
}
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)
}
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)
} else {
mx := framesUntilMidi
if timeUntilRowAdvance < mx {
mx = timeUntilRowAdvance
}
for i := 0; i < mx; i++ {
buffer[i] = [2]float32{}
}
rendered = mx
timeAdvanced = mx
}
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 {
rendered = min(framesUntilEvent, timeUntilRowAdvance)
timeAdvanced = rendered
clear(buffer[:rendered])
}
bufPtr := p.broker.GetAudioBuffer() // borrow a buffer from the broker
*bufPtr = append(*bufPtr, buffer[:rendered]...)
@ -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
}

View File

@ -8,42 +8,77 @@ import (
"github.com/vsariola/sointu"
)
type Recording struct {
type (
Recording struct {
BPM float64 // vsts allow bpms as floats so for accurate reconstruction, keep it as float for recording
Events []MIDINoteEvent
TotalFrames int
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)
}

View File

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