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 - Dragging mouse to select rectangles in the tables
- The standalone tracker can open a MIDI port for receiving MIDI notes - The standalone tracker can open a MIDI port for receiving MIDI notes
([#166][i166]) ([#166][i166])
- The note editor has a button to allow entering notes by MIDI. Polyphony is - The note editor has a button to allow entering notes by MIDI. ([#170][i170])
supported if there are tracks available. ([#170][i170])
- Units can have comments, to make it easier to distinguish between units of - 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 same type within an instrument. These comments are also shown when choosing
the send target. ([#114][i114]) the send target. ([#114][i114])

View File

@ -46,10 +46,10 @@ func main() {
if configDir, err := os.UserConfigDir(); err == nil { if configDir, err := os.UserConfigDir(); err == nil {
recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery") recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery")
} }
midiContext := gomidi.NewContext() broker := tracker.NewBroker()
midiContext := gomidi.NewContext(broker)
defer midiContext.Close() defer midiContext.Close()
midiContext.TryToOpenBy(*defaultMidiInput, *firstMidiInput) midiContext.TryToOpenBy(*defaultMidiInput, *firstMidiInput)
broker := tracker.NewBroker()
model := tracker.NewModel(broker, cmd.MainSynther, midiContext, recoveryFile) model := tracker.NewModel(broker, cmd.MainSynther, midiContext, recoveryFile)
player := tracker.NewPlayer(broker, cmd.MainSynther) player := tracker.NewPlayer(broker, cmd.MainSynther)
detector := tracker.NewDetector(broker) detector := tracker.NewDetector(broker)
@ -65,7 +65,7 @@ func main() {
trackerUi := gioui.NewTracker(model) trackerUi := gioui.NewTracker(model)
audioCloser := audioContext.Play(func(buf sointu.AudioBuffer) error { audioCloser := audioContext.Play(func(buf sointu.AudioBuffer) error {
player.Process(buf, midiContext, trackerUi) player.Process(buf, midiContext)
return nil return nil
}) })

View File

@ -34,28 +34,6 @@ func (m NullMIDIContext) Close() {}
func (m NullMIDIContext) HasDeviceOpen() bool { return false } 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) { func (c *VSTIProcessContext) BPM() (bpm float64, ok bool) {
timeInfo := c.host.GetTimeInfo(vst2.TempoValid) timeInfo := c.host.GetTimeInfo(vst2.TempoValid)
if timeInfo == nil || timeInfo.Flags&vst2.TempoValid == 0 || timeInfo.Tempo == 0 { if timeInfo == nil || timeInfo.Flags&vst2.TempoValid == 0 || timeInfo.Tempo == 0 {
@ -89,8 +67,9 @@ func init() {
// swapped/added etc. // swapped/added etc.
model.LinkInstrTrack().SetValue(false) model.LinkInstrTrack().SetValue(false)
go t.Main() go t.Main()
context := VSTIProcessContext{host: h} context := &VSTIProcessContext{host: h}
buf := make(sointu.AudioBuffer, 1024) buf := make(sointu.AudioBuffer, 1024)
var totalFrames int64 = 0
return vst2.Plugin{ return vst2.Plugin{
UniqueID: PLUGIN_ID, UniqueID: PLUGIN_ID,
Version: version, Version: version,
@ -110,12 +89,11 @@ func init() {
buf = append(buf, make(sointu.AudioBuffer, out.Frames-len(buf))...) buf = append(buf, make(sointu.AudioBuffer, out.Frames-len(buf))...)
} }
buf = buf[:out.Frames] buf = buf[:out.Frames]
player.Process(buf, &context, nil) player.Process(buf, context)
for i := 0; i < out.Frames; i++ { for i := 0; i < out.Frames; i++ {
left[i], right[i] = buf[i][0], buf[i][1] left[i], right[i] = buf[i][0], buf[i][1]
} }
context.events = context.events[:0] // reset buffer, but keep the allocated memory totalFrames += int64(out.Frames)
context.eventIndex = 0
}, },
}, vst2.Dispatcher{ }, vst2.Dispatcher{
CanDoFunc: func(pcds vst2.PluginCanDoString) vst2.CanDoResponse { CanDoFunc: func(pcds vst2.PluginCanDoString) vst2.CanDoResponse {
@ -125,12 +103,17 @@ func init() {
} }
return vst2.NoCanDo return vst2.NoCanDo
}, },
ProcessEventsFunc: func(ev *vst2.EventsPtr) { ProcessEventsFunc: func(events *vst2.EventsPtr) {
for i := 0; i < ev.NumEvents(); i++ { for i := 0; i < events.NumEvents(); i++ {
a := ev.Event(i) switch ev := events.Event(i).(type) {
switch v := a.(type) {
case *vst2.MIDIEvent: 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

@ -119,10 +119,12 @@ func (m *Follow) SetValue(val bool) { m.follow = val }
// TrackMidiIn (Midi Input for notes in the tracks) // TrackMidiIn (Midi Input for notes in the tracks)
func (m *Model) TrackMidiIn() Bool { return MakeBool((*TrackMidiIn)(m)) } func (m *Model) TrackMidiIn() Bool { return MakeBool((*TrackMidiIn)(m)) }
func (m *TrackMidiIn) Value() bool { return m.trackMidiIn } func (m *TrackMidiIn) Value() bool { return m.broker.mIDIEventsToGUI.Load() }
func (m *TrackMidiIn) SetValue(val bool) { m.trackMidiIn = val } func (m *TrackMidiIn) SetValue(val bool) {
func (m *TrackMidiIn) Enabled() bool { return m.MIDI.HasDeviceOpen() } m.broker.mIDIEventsToGUI.Store(val)
}
func (m *TrackMidiIn) Enabled() bool { return true }
// Effect methods // Effect methods

View File

@ -2,6 +2,7 @@ package tracker
import ( import (
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/vsariola/sointu" "github.com/vsariola/sointu"
@ -37,6 +38,7 @@ type (
ToModel chan MsgToModel 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/ 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 ToDetector chan MsgToDetector
ToGUI chan any
CloseDetector chan struct{} CloseDetector chan struct{}
CloseGUI chan struct{} CloseGUI chan struct{}
@ -44,6 +46,11 @@ type (
FinishedGUI chan struct{} FinishedGUI chan struct{}
FinishedDetector 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 bufferPool sync.Pool
} }
@ -79,6 +86,19 @@ type (
Oversampling bool Oversampling bool
HasOversampling bool HasOversampling bool
} }
MsgToGUI struct {
Kind GUIMessageKind
Param int
}
GUIMessageKind int
)
const (
GUIMessageKindNone GUIMessageKind = iota
GUIMessageCenterOnRow
GUIMessageEnsureCursorVisible
) )
func NewBroker() *Broker { func NewBroker() *Broker {
@ -86,6 +106,7 @@ func NewBroker() *Broker {
ToPlayer: make(chan interface{}, 1024), ToPlayer: make(chan interface{}, 1024),
ToModel: make(chan MsgToModel, 1024), ToModel: make(chan MsgToModel, 1024),
ToDetector: make(chan MsgToDetector, 1024), ToDetector: make(chan MsgToDetector, 1024),
ToGUI: make(chan any, 1024),
CloseDetector: make(chan struct{}, 1), CloseDetector: make(chan struct{}, 1),
CloseGUI: make(chan struct{}, 1), CloseGUI: make(chan struct{}, 1),
FinishedGUI: make(chan struct{}), 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 // 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 // guaranteed to be empty. After using the buffer, it should be returned to the
// pool with PutAudioBuffer. // pool with PutAudioBuffer.

View File

@ -27,7 +27,6 @@ type DragList struct {
dragID pointer.ID dragID pointer.ID
tags []bool tags []bool
swapped bool swapped bool
focused bool
requestFocus bool requestFocus bool
} }
@ -57,8 +56,8 @@ func (d *DragList) Focus() {
d.requestFocus = true d.requestFocus = true
} }
func (d *DragList) Focused() bool { func (d *DragList) Focused(gtx C) bool {
return d.focused return gtx.Focused(d)
} }
func (s FilledDragListStyle) LayoutScrollBar(gtx C) 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) { switch ke := event.(type) {
case key.FocusEvent: case key.FocusEvent:
s.dragList.focused = ke.Focus if !ke.Focus {
if !s.dragList.focused {
s.dragList.TrackerList.SetSelected2(s.dragList.TrackerList.Selected()) s.dragList.TrackerList.SetSelected2(s.dragList.TrackerList.Selected())
} }
case key.Event: case key.Event:
if !s.dragList.focused || ke.State != key.Press { if !s.dragList.Focused(gtx) || ke.State != key.Press {
break break
} }
s.dragList.command(gtx, ke) 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 { cursorBg := func(gtx C) D {
var color color.NRGBA var color color.NRGBA
if s.dragList.TrackerList.Selected() == index { if s.dragList.TrackerList.Selected() == index {
if s.dragList.focused { if gtx.Focused(s.dragList) {
color = s.Cursor.Active color = s.Cursor.Active
} else { } else {
color = s.Cursor.Inactive color = s.Cursor.Inactive
} }
} else if between(s.dragList.TrackerList.Selected(), index, s.dragList.TrackerList.Selected2()) { } 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 color = s.Selection.Active
} else { } else {
color = s.Selection.Inactive 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() area.Pop()
if index == s.dragList.TrackerList.Selected() && isMutable { if index == s.dragList.TrackerList.Selected() && isMutable {
for { for {
target := &s.dragList.focused target := &s.dragList.drag
if s.dragList.drag { if s.dragList.drag {
target = nil 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) 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) pointer.CursorGrab.Add(gtx.Ops)
area.Pop() area.Pop()
} }

View File

@ -127,19 +127,19 @@ func (ie *InstrumentEditor) Focus() {
ie.unitDragList.Focus() ie.unitDragList.Focus()
} }
func (ie *InstrumentEditor) Focused() bool { func (ie *InstrumentEditor) Focused(gtx C) bool {
return ie.unitDragList.focused return gtx.Focused(ie.unitDragList)
} }
func (ie *InstrumentEditor) childFocused(gtx C) bool { func (ie *InstrumentEditor) childFocused(gtx C) bool {
return ie.unitEditor.sliderList.Focused() || return ie.unitEditor.sliderList.Focused(gtx) ||
ie.instrumentDragList.Focused() || gtx.Source.Focused(ie.commentEditor) || gtx.Source.Focused(ie.nameEditor) || gtx.Source.Focused(ie.searchEditor) || 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.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) gtx.Source.Focused(ie.deleteInstrumentBtn.Clickable) || gtx.Source.Focused(ie.copyInstrumentBtn.Clickable)
} }
func (ie *InstrumentEditor) Layout(gtx C, t *Tracker) D { 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) 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) 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/clipboard"
"gioui.org/io/key" "gioui.org/io/key"
"github.com/vsariola/sointu/tracker"
"gopkg.in/yaml.v2" "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. // KeyEvent handles incoming key events and returns true if repaint is needed.
func (t *Tracker) KeyEvent(e key.Event, gtx C) { func (t *Tracker) KeyEvent(e key.Event, gtx C) {
if e.State == key.Release { if e.State == key.Release {
t.JammingReleased(e) t.KeyNoteMap.Release(e.Name)
return return
} }
action, ok := keyBindingMap[e] action, ok := keyBindingMap[e]
@ -257,11 +258,11 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) {
t.InstrumentEditor.Focus() t.InstrumentEditor.Focus()
case "FocusPrev": case "FocusPrev":
switch { switch {
case t.OrderEditor.scrollTable.Focused(): case t.OrderEditor.scrollTable.Focused(gtx):
t.InstrumentEditor.unitEditor.sliderList.Focus() t.InstrumentEditor.unitEditor.sliderList.Focus()
case t.TrackEditor.scrollTable.Focused(): case t.TrackEditor.scrollTable.Focused(gtx):
t.OrderEditor.scrollTable.Focus() t.OrderEditor.scrollTable.Focus()
case t.InstrumentEditor.Focused(): case t.InstrumentEditor.Focused(gtx):
if t.InstrumentEditor.enlargeBtn.Bool.Value() { if t.InstrumentEditor.enlargeBtn.Bool.Value() {
t.InstrumentEditor.unitEditor.sliderList.Focus() t.InstrumentEditor.unitEditor.sliderList.Focus()
} else { } else {
@ -272,11 +273,11 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) {
} }
case "FocusNext": case "FocusNext":
switch { switch {
case t.OrderEditor.scrollTable.Focused(): case t.OrderEditor.scrollTable.Focused(gtx):
t.TrackEditor.scrollTable.Focus() t.TrackEditor.scrollTable.Focus()
case t.TrackEditor.scrollTable.Focused(): case t.TrackEditor.scrollTable.Focused(gtx):
t.InstrumentEditor.Focus() t.InstrumentEditor.Focus()
case t.InstrumentEditor.Focused(): case t.InstrumentEditor.Focused(gtx):
t.InstrumentEditor.unitEditor.sliderList.Focus() t.InstrumentEditor.unitEditor.sliderList.Focus()
default: default:
if t.InstrumentEditor.enlargeBtn.Bool.Value() { if t.InstrumentEditor.enlargeBtn.Bool.Value() {
@ -291,26 +292,9 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) {
if err != nil { if err != nil {
break 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 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 { func (te *NoteEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions {
for { for {
e, ok := gtx.Event(te.eventFilters...) 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) { switch e := e.(type) {
case key.Event: case key.Event:
if e.State == key.Release { if e.State == key.Release {
if noteID, ok := t.KeyPlaying[e.Name]; ok { t.KeyNoteMap.Release(e.Name)
noteID.NoteOff()
delete(t.KeyPlaying, e.Name)
}
continue continue
} }
te.command(t, e) 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 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() 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, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D { layout.Rigid(func(gtx C) D {
return te.layoutButtons(gtx, t) 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 { 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") addSemitoneBtnStyle := ActionButton(gtx, t.Theme, &t.Theme.Button.Text, te.AddSemitoneBtn, "+1")
subtractSemitoneBtnStyle := ActionButton(gtx, t.Theme, &t.Theme.Button.Text, te.SubtractSemitoneBtn, "-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") 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} point := tracker.Point{X: x, Y: y}
if drawSelection && selection.Contains(point) { if drawSelection && selection.Contains(point) {
color := t.Theme.Selection.Inactive color := t.Theme.Selection.Inactive
if te.scrollTable.Focused() { if te.scrollTable.Focused(gtx) {
color = t.Theme.Selection.Active 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()) 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 // draw the cursor
if point == cursor { if point == cursor {
c := t.Theme.Cursor.Inactive c := t.Theme.Cursor.Inactive
if te.scrollTable.Focused() { if te.scrollTable.Focused(gtx) {
c = t.Theme.Cursor.Active c = t.Theme.Cursor.Active
} }
if hasTrackMidiIn { if hasTrackMidiIn {
@ -292,14 +306,6 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
} }
te.paintColumnCell(gtx, x, t, c) 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 // draw the pattern marker
rpp := max(t.RowsPerPattern().Value(), 1) rpp := max(t.RowsPerPattern().Value(), 1)
@ -366,9 +372,8 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) {
var n byte var n byte
if t.Model.Notes().Effect(te.scrollTable.Table.Cursor().X) { if t.Model.Notes().Effect(te.scrollTable.Table.Cursor().X) {
if nibbleValue, err := strconv.ParseInt(string(e.Name), 16, 8); err == nil { if nibbleValue, err := strconv.ParseInt(string(e.Name), 16, 8); err == nil {
t.Model.Notes().FillNibble(byte(nibbleValue), t.Model.Notes().LowNibble()) ev := t.Model.Notes().InputNibble(byte(nibbleValue))
n = t.Model.Notes().Value(te.scrollTable.Table.Cursor()) t.KeyNoteMap.Press(e.Name, ev)
te.finishNoteInsert(t, n, e.Name)
} }
} else { } else {
action, ok := keyBindingMap[e] action, ok := keyBindingMap[e]
@ -376,8 +381,8 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) {
return return
} }
if action == "NoteOff" { if action == "NoteOff" {
t.Model.Notes().Table().Fill(0) ev := t.Model.Notes().Input(0)
te.finishNoteInsert(t, 0, "") t.KeyNoteMap.Press(e.Name, ev)
return return
} }
if action[:4] == "Note" { if action[:4] == "Note" {
@ -386,42 +391,8 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) {
return return
} }
n = noteAsValue(t.OctaveNumberInput.Int.Value(), val-12) n = noteAsValue(t.OctaveNumberInput.Int.Value(), val-12)
t.Model.Notes().Table().Fill(int(n)) ev := t.Model.Notes().Input(n)
te.finishNoteInsert(t, n, e.Name) 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} point := tracker.Point{X: x, Y: y}
if selection.Contains(point) { if selection.Contains(point) {
color = t.Theme.Selection.Inactive color = t.Theme.Selection.Inactive
if oe.scrollTable.Focused() { if oe.scrollTable.Focused(gtx) {
color = t.Theme.Selection.Active color = t.Theme.Selection.Active
} }
if point == oe.scrollTable.Table.Cursor() { if point == oe.scrollTable.Table.Cursor() {
color = t.Theme.Cursor.Inactive color = t.Theme.Cursor.Inactive
if oe.scrollTable.Focused() { if oe.scrollTable.Focused(gtx) {
color = t.Theme.Cursor.Active color = t.Theme.Cursor.Active
} }
} }

View File

@ -21,7 +21,6 @@ type ScrollTable struct {
ColTitleList *DragList ColTitleList *DragList
RowTitleList *DragList RowTitleList *DragList
Table tracker.Table Table tracker.Table
focused bool
requestFocus bool requestFocus bool
cursorMoved bool cursorMoved bool
eventFilters []event.Filter eventFilters []event.Filter
@ -94,8 +93,8 @@ func (st *ScrollTable) Focus() {
st.requestFocus = true st.requestFocus = true
} }
func (st *ScrollTable) Focused() bool { func (st *ScrollTable) Focused(gtx C) bool {
return st.focused return gtx.Source.Focused(st)
} }
func (st *ScrollTable) EnsureCursorVisible() { func (st *ScrollTable) EnsureCursorVisible() {
@ -103,8 +102,8 @@ func (st *ScrollTable) EnsureCursorVisible() {
st.RowTitleList.EnsureVisible(st.Table.Cursor().Y) st.RowTitleList.EnsureVisible(st.Table.Cursor().Y)
} }
func (st *ScrollTable) ChildFocused() bool { func (st *ScrollTable) ChildFocused(gtx C) bool {
return st.ColTitleList.Focused() || st.RowTitleList.Focused() 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 { 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)) p := image.Pt(gtx.Dp(s.RowTitleWidth), gtx.Dp(s.ColumnTitleHeight))
s.handleEvents(gtx, p) 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() defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
dims := gtx.Constraints.Max dims := gtx.Constraints.Max
s.layoutColTitles(gtx, p, colTitle, colTitleBg) s.layoutColTitles(gtx, p, colTitle, colTitleBg)
@ -135,8 +134,6 @@ func (s *ScrollTableStyle) handleEvents(gtx layout.Context, p image.Point) {
break break
} }
switch e := e.(type) { switch e := e.(type) {
case key.FocusEvent:
s.ScrollTable.focused = e.Focus
case pointer.Event: case pointer.Event:
switch e.Kind { switch e.Kind {
case pointer.Press: case pointer.Press:

View File

@ -33,8 +33,7 @@ type (
TopHorizontalSplit *Split TopHorizontalSplit *Split
BottomHorizontalSplit *Split BottomHorizontalSplit *Split
VerticalSplit *Split VerticalSplit *Split
KeyPlaying map[key.Name]tracker.NoteID KeyNoteMap Keyboard[key.Name]
MidiNotePlaying []byte
PopupAlert *PopupAlert PopupAlert *PopupAlert
Zoom int Zoom int
@ -50,6 +49,7 @@ type (
SongPanel *SongPanel SongPanel *SongPanel
filePathString tracker.String filePathString tracker.String
noteEvents []tracker.NoteEvent
execChan chan func() execChan chan func()
preferences Preferences preferences Preferences
@ -78,8 +78,6 @@ func NewTracker(model *tracker.Model) *Tracker {
BottomHorizontalSplit: &Split{Ratio: -.6, MinSize1: 180, MinSize2: 180}, BottomHorizontalSplit: &Split{Ratio: -.6, MinSize1: 180, MinSize2: 180},
VerticalSplit: &Split{Axis: layout.Vertical, 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()), SaveChangesDialog: NewDialog(model.SaveSong(), model.DiscardSong(), model.Cancel()),
WaveTypeDialog: NewDialog(model.ExportInt16(), model.ExportFloat(), model.Cancel()), WaveTypeDialog: NewDialog(model.ExportInt16(), model.ExportFloat(), model.Cancel()),
InstrumentEditor: NewInstrumentEditor(model), InstrumentEditor: NewInstrumentEditor(model),
@ -93,6 +91,7 @@ func NewTracker(model *tracker.Model) *Tracker {
filePathString: model.FilePath(), filePathString: model.FilePath(),
} }
t.KeyNoteMap = MakeKeyboard[key.Name](model.Broker())
t.PopupAlert = NewPopupAlert(model.Alerts()) t.PopupAlert = NewPopupAlert(model.Alerts())
var warn error var warn error
if t.Theme, warn = NewTheme(); warn != nil { if t.Theme, warn = NewTheme(); warn != nil {
@ -138,6 +137,19 @@ func (t *Tracker) Main() {
F: F:
for { for {
select { 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: case e := <-t.Broker().ToModel:
t.ProcessMsg(e) t.ProcessMsg(e)
w.Invalidate() w.Invalidate()
@ -158,9 +170,6 @@ func (t *Tracker) Main() {
w.Option(app.Title(titleFromPath(titlePath))) w.Option(app.Title(titleFromPath(titlePath)))
} }
gtx := app.NewContext(&ops, e) gtx := app.NewContext(&ops, e)
if t.Playing().Value() && t.Follow().Value() {
t.TrackEditor.scrollTable.RowTitleList.CenterOn(t.PlaySongRow())
}
t.Layout(gtx, w) t.Layout(gtx, w)
e.Frame(gtx.Ops) e.Frame(gtx.Ops)
if t.Quitted() { if t.Quitted() {
@ -240,7 +249,16 @@ func (t *Tracker) Layout(gtx layout.Context, w *app.Window) {
t.ReadSong(e.Open()) 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) { 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

@ -19,24 +19,15 @@ import (
type ( type (
RTMIDIContext struct { RTMIDIContext struct {
driver *rtmididrv.Driver driver *rtmididrv.Driver
currentIn drivers.In currentIn drivers.In
events chan timestampedMsg broker *tracker.Broker
eventsBuf []timestampedMsg
eventIndex int
startFrame int
startFrameSet bool
} }
RTMIDIDevice struct { RTMIDIDevice struct {
context *RTMIDIContext context *RTMIDIContext
in drivers.In in drivers.In
} }
timestampedMsg struct {
frame int
msg midi.Message
}
) )
func (m *RTMIDIContext) InputDevices(yield func(tracker.MIDIDevice) bool) { 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. // Open the driver.
func NewContext() *RTMIDIContext { func NewContext(broker *tracker.Broker) *RTMIDIContext {
m := RTMIDIContext{events: make(chan timestampedMsg, 1024)} m := RTMIDIContext{broker: broker}
// there's not much we can do if this fails, so just use m.driver = nil to // there's not much we can do if this fails, so just use m.driver = nil to
// indicate no driver available // indicate no driver available
m.driver, _ = rtmididrv.New() m.driver, _ = rtmididrv.New()
@ -94,69 +85,16 @@ func (d RTMIDIDevice) String() string {
} }
func (m *RTMIDIContext) HandleMessage(msg midi.Message, timestampms int32) { func (m *RTMIDIContext) HandleMessage(msg midi.Message, timestampms int32) {
select { var channel, key, velocity uint8
case m.events <- timestampedMsg{frame: int(int64(timestampms) * 44100 / 1000), msg: msg}: // if the channel is full, just drop the message if msg.GetNoteOn(&channel, &key, &velocity) {
default: 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) { func (c *RTMIDIContext) BPM() (bpm float64, ok bool) {
return 0, false return 0, false
} }

View File

@ -82,8 +82,7 @@ type (
broker *Broker broker *Broker
MIDI MIDIContext MIDI MIDIContext
trackMidiIn bool
} }
// Cursor identifies a row and a track in a song score. // 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 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 } IsPlayingMsg struct{ bool }
StartPlayMsg struct{ sointu.SongPos } StartPlayMsg struct{ sointu.SongPos }
BPMMsg struct{ int } BPMMsg struct{ int }
RowsPerBeatMsg struct{ int } RowsPerBeatMsg struct{ int }
PanicMsg struct{ bool } PanicMsg struct{ bool }
RecordingMsg struct{ bool } RecordingMsg struct{ bool }
NoteOnMsg struct{ NoteID }
NoteOffMsg struct{ NoteID }
ChangeSeverity int ChangeSeverity int
ChangeType int ChangeType int
@ -187,7 +172,6 @@ func NewModel(broker *Broker, synther sointu.Synther, midiContext MIDIContext, r
m := new(Model) m := new(Model)
m.synther = synther m.synther = synther
m.MIDI = midiContext m.MIDI = midiContext
m.trackMidiIn = midiContext.HasDeviceOpen()
m.broker = broker m.broker = broker
m.d.Octave = 4 m.d.Octave = 4
m.linkInstrTrack = true m.linkInstrTrack = true
@ -353,6 +337,10 @@ func (m *Model) ProcessMsg(msg MsgToModel) {
if m.playing && m.follow { if m.playing && m.follow {
m.d.Cursor.SongPos = msg.SongPosition m.d.Cursor.SongPos = msg.SongPosition
m.d.Cursor2.SongPos = msg.SongPosition m.d.Cursor2.SongPos = msg.SongPosition
TrySend(m.broker.ToGUI, any(MsgToGUI{
Kind: GUIMessageCenterOnRow,
Param: m.PlaySongRow(),
}))
} }
m.panic = msg.Panic m.panic = msg.Panic
} }
@ -386,29 +374,12 @@ func (m *Model) ProcessMsg(msg MsgToModel) {
m.playing = e.bool m.playing = e.bool
case *sointu.AudioBuffer: case *sointu.AudioBuffer:
m.signalAnalyzer.ProcessAudioBuffer(e) m.signalAnalyzer.ProcessAudioBuffer(e)
default:
} }
} }
func (m *Model) SignalAnalyzer() *ScopeModel { return m.signalAnalyzer } func (m *Model) SignalAnalyzer() *ScopeModel { return m.signalAnalyzer }
func (m *Model) Broker() *Broker { return m.broker } 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 { func (d *modelData) Copy() modelData {
ret := *d ret := *d
ret.Song = d.Song.Copy() ret.Song = d.Song.Copy()

View File

@ -13,10 +13,6 @@ import (
type NullContext struct{} type NullContext struct{}
func (NullContext) NextEvent(frame int) (event tracker.MIDINoteEvent, ok bool) {
return tracker.MIDINoteEvent{}, false
}
func (NullContext) FinishBlock(frame int) {} func (NullContext) FinishBlock(frame int) {}
func (NullContext) BPM() (bpm float64, ok bool) { func (NullContext) BPM() (bpm float64, ok bool) {
@ -277,7 +273,7 @@ func FuzzModel(f *testing.F) {
break loop break loop
default: default:
ctx := NullContext{} ctx := NullContext{}
player.Process(buf, ctx, nil) player.Process(buf, ctx)
} }
} }
}() }()

View File

@ -1,8 +1,10 @@
package tracker package tracker
import ( import (
"cmp"
"fmt" "fmt"
"math" "math"
"slices"
"github.com/vsariola/sointu" "github.com/vsariola/sointu"
"github.com/vsariola/sointu/vm" "github.com/vsariola/sointu/vm"
@ -24,59 +26,57 @@ type (
voices [vm.MAX_VOICES]voice voices [vm.MAX_VOICES]voice
loop Loop 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 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 synther sointu.Synther // the synther used to create new synths
broker *Broker // the broker used to communicate with different parts of the tracker broker *Broker // the broker used to communicate with different parts of the tracker
} }
// PlayerProcessContext is the context given to the player when processing // 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 { PlayerProcessContext interface {
NextEvent(frame int) (event MIDINoteEvent, ok bool)
FinishBlock(frame int)
BPM() (bpm float64, ok bool) BPM() (bpm float64, ok bool)
} }
EventProcessor interface { // NoteEvent describes triggering or releasing of a note. The timestamps are
ProcessMessage(msg interface{}) // in frames, and relative to the clock of the event source. Different
ProcessEvent(event MIDINoteEvent) // 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 playerTimestamp int64 // the timestamp of the event, adjusted to the player's clock, used to sort events
// 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
} }
) )
type ( type (
recState int
voice struct { voice struct {
noteID int triggerEvent NoteEvent // which event triggered this voice, used to release the voice
sustain bool sustain bool
samplesSinceEvent int samplesSinceEvent int
} }
)
const ( NoteEventList []NoteEvent
recStateNone recState = iota
recStateWaitingForNote
recStateRecording
) )
const numRenderTries = 10000 const numRenderTries = 10000
func NewPlayer(broker *Broker, synther sointu.Synther) *Player { func NewPlayer(broker *Broker, synther sointu.Synther) *Player {
return &Player{ return &Player{
broker: broker, broker: broker,
synther: synther, 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 // model. context tells the player which MIDI events happen during the current
// buffer. It is used to trigger and release notes during processing. The // buffer. It is used to trigger and release notes during processing. The
// context is also used to get the current BPM from the host. // context is also used to get the current BPM from the host.
func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext, ui EventProcessor) { func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext) {
p.processMessages(context, ui) p.processMessages(context)
p.events.adjustTimes(p.frameDeltas, p.frame, p.frame+int64(len(buffer)))
frame := 0
midi, midiOk := context.NextEvent(frame)
if p.recState == recStateRecording {
p.recording.TotalFrames += len(buffer)
}
for i := 0; i < numRenderTries; i++ { for i := 0; i < numRenderTries; i++ {
for midiOk && frame >= midi.Frame { for len(p.events) > 0 && p.events[0].playerTimestamp <= p.frame {
if p.recState == recStateWaitingForNote { ev := p.events[0]
p.recording.TotalFrames = len(buffer) copy(p.events, p.events[1:]) // remove processed events
p.recState = recStateRecording p.events = p.events[:len(p.events)-1]
} p.recording.Record(ev, p.frame)
if p.recState == recStateRecording { p.processNoteEvent(ev)
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) framesUntilEvent := len(buffer)
if delta := midi.Frame - frame; midiOk && delta < framesUntilMidi { if len(p.events) > 0 {
framesUntilMidi = delta framesUntilEvent = min(int(p.events[0].playerTimestamp-p.frame), len(buffer))
} }
if p.playing && p.rowtime >= p.song.SamplesPerRow() { if p.playing && p.rowtime >= p.song.SamplesPerRow() {
p.advanceRow() p.advanceRow()
} }
timeUntilRowAdvance := math.MaxInt32 timeUntilRowAdvance := math.MaxInt32
if p.playing { if p.playing {
timeUntilRowAdvance = p.song.SamplesPerRow() - p.rowtime timeUntilRowAdvance = max(p.song.SamplesPerRow()-p.rowtime, 0)
}
if timeUntilRowAdvance < 0 {
timeUntilRowAdvance = 0
} }
var rendered, timeAdvanced int var rendered, timeAdvanced int
var err error var err error
if p.synth != nil { 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 { } else {
mx := framesUntilMidi rendered = min(framesUntilEvent, timeUntilRowAdvance)
if timeUntilRowAdvance < mx { timeAdvanced = rendered
mx = timeUntilRowAdvance clear(buffer[:rendered])
}
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"})
} }
bufPtr := p.broker.GetAudioBuffer() // borrow a buffer from the broker 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) p.broker.PutAudioBuffer(bufPtr)
} }
buffer = buffer[rendered:] buffer = buffer[rendered:]
frame += rendered p.frame += int64(rendered)
p.rowtime += timeAdvanced p.rowtime += timeAdvanced
for i := range p.voices { for i := range p.voices {
p.voices[i].samplesSinceEvent += rendered p.voices[i].samplesSinceEvent += rendered
@ -175,12 +146,12 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext
// when the buffer is full, return // when the buffer is full, return
if len(buffer) == 0 { if len(buffer) == 0 {
p.send(nil) p.send(nil)
context.FinishBlock(frame)
return return
} }
} }
// we were not able to fill the buffer with NUM_RENDER_TRIES attempts, destroy synth and throw an error // we were not able to fill the buffer with NUM_RENDER_TRIES attempts, destroy synth and throw an error
p.synth = nil 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) 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.send(IsPlayingMsg{bool: false})
p.playing = false p.playing = false
for i := range p.song.Score.Tracks { for i := range p.song.Score.Tracks {
p.releaseTrack(i) p.processNoteEvent(NoteEvent{Channel: i, IsTrack: true, Source: p})
} }
return return
} }
p.send(nil) // just send volume and song row information
lastVoice := 0
for i, t := range p.song.Score.Tracks { for i, t := range p.song.Score.Tracks {
start := lastVoice
lastVoice = start + t.NumVoices
n := t.Note(p.songPos) n := t.Note(p.songPos)
switch { switch {
case n == 0: case n == 0:
p.releaseTrack(i) p.processNoteEvent(NoteEvent{Channel: i, IsTrack: true, Source: p, On: false})
case n > 1: case n > 1:
p.triggerTrack(i, n) p.processNoteEvent(NoteEvent{Channel: i, IsTrack: true, Source: p, Note: n, On: true})
default: // n == 1 } // n = 1 means hold so do nothing
}
} }
p.rowtime = 0 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: loop:
for { // process new message for { // process new message
select { select {
@ -246,7 +213,7 @@ loop:
p.playing = bool(m.bool) p.playing = bool(m.bool)
if !p.playing { if !p.playing {
for i := range p.song.Score.Tracks { for i := range p.song.Score.Tracks {
p.releaseTrack(i) p.processNoteEvent(NoteEvent{Channel: i, IsTrack: true, Source: p})
} }
} else { } else {
TrySend(p.broker.ToModel, MsgToModel{Reset: true}) TrySend(p.broker.ToModel, MsgToModel{Reset: true})
@ -265,45 +232,77 @@ loop:
for i, t := range p.song.Score.Tracks { for i, t := range p.song.Score.Tracks {
if !t.Effect { if !t.Effect {
// when starting to play from another position, release only non-effect tracks // 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}) TrySend(p.broker.ToModel, MsgToModel{Reset: true})
case NoteOnMsg: case NoteEvent:
if m.IsInstr { p.events = append(p.events, m)
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 RecordingMsg: case RecordingMsg:
if m.bool { if m.bool {
p.recState = recStateWaitingForNote p.recording = Recording{State: RecordingWaitingForNote}
p.recording = Recording{}
} else { } 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.recording.BPM, _ = context.BPM()
p.send(p.recording) p.send(p.recording)
} }
p.recState = recStateNone p.recording = Recording{} // reset recording
} }
default: default:
// ignore unknown messages // ignore unknown messages
} }
if uiProcessor != nil {
uiProcessor.ProcessMessage(msg)
}
default: default:
break loop 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) { func (p *Player) SendAlert(name, message string, priority AlertPriority) {
p.send(Alert{ p.send(Alert{
Name: name, 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}) 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) { func (p *Player) processNoteEvent(ev NoteEvent) {
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) {
if p.synth == nil { if p.synth == nil {
return 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 var age int = 0
oldestReleased := false oldestReleased := false
oldestVoice := 0 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 { if err != nil || p.song.Patch[instrIndex].Mute {
return 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.voiceLevels[oldestVoice] = 1.0
p.synth.Trigger(oldestVoice, note) p.synth.Trigger(oldestVoice, ev.Note)
TrySend(p.broker.ToModel, MsgToModel{TriggerChannel: instrIndex + 1}) 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" "github.com/vsariola/sointu"
) )
type Recording struct { type (
BPM float64 // vsts allow bpms as floats so for accurate reconstruction, keep it as float for recording Recording struct {
Events []MIDINoteEvent BPM float64 // vsts allow bpms as floats so for accurate reconstruction, keep it as float for recording
TotalFrames int Events NoteEventList
} StartFrame, EndFrame int64
State RecordingState
}
type recordingNote struct { RecordingState int
note byte )
startRow int
endRow 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 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) { func (recording *Recording) Score(patch sointu.Patch, rowsPerBeat, rowsPerPattern int) (sointu.Score, error) {
if rowsPerBeat <= 1 || rowsPerPattern <= 1 { if rowsPerBeat <= 1 || rowsPerPattern <= 1 {
return sointu.Score{}, ErrInvalidRows 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) channelNotes := make([][]recordingNote, 0)
// find the length of each note and assign it to its respective channel // find the length of each note and assign it to its respective channel
for i, m := range recording.Events { for i, m := range recording.Events {
if !m.On || m.Channel >= len(patch) { if !m.On || m.Channel >= len(patch) || m.IsTrack {
continue continue
} }
endFrame := math.MaxInt var endFrame int64 = math.MaxInt64
for j := i + 1; j < len(recording.Events); j++ { for j := i + 1; j < len(recording.Events); j++ {
if recording.Events[j].Channel == m.Channel && recording.Events[j].Note == m.Note { if recording.Events[j].Channel == m.Channel && recording.Events[j].Note == m.Note {
endFrame = recording.Events[j].Frame endFrame = recording.Events[j].playerTimestamp
break break
} }
} }
for len(channelNotes) <= m.Channel { for len(channelNotes) <= m.Channel {
channelNotes = append(channelNotes, make([]recordingNote, 0)) channelNotes = append(channelNotes, make([]recordingNote, 0))
} }
startRow := frameToRow(recording.BPM, rowsPerBeat, m.Frame) startRow := frameToRow(recording.BPM, rowsPerBeat, m.playerTimestamp-recording.StartFrame)
endRow := frameToRow(recording.BPM, rowsPerBeat, endFrame) endRow := frameToRow(recording.BPM, rowsPerBeat, endFrame-recording.StartFrame)
channelNotes[m.Channel] = append(channelNotes[m.Channel], recordingNote{m.Note, startRow, endRow}) 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 //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{}) 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 songLengthRows := songLengthPatterns * rowsPerPattern
songTracks := make([]sointu.Track, 0) songTracks := make([]sointu.Track, 0)
for i, tg := range tracks { for i, tg := range tracks {
@ -148,6 +183,6 @@ func (recording *Recording) Score(patch sointu.Patch, rowsPerBeat, rowsPerPatter
return score, nil 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) return int(float64(frame)/44100/60*BPM*float64(rowsPerBeat) + 0.5)
} }

View File

@ -2,6 +2,7 @@ package tracker
import ( import (
"math" "math"
"time"
"github.com/vsariola/sointu" "github.com/vsariola/sointu"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -437,7 +438,7 @@ func (v *Notes) MoveCursor(dx, dy int) (ok bool) {
} }
func (v *Notes) clear(p Point) { func (v *Notes) clear(p Point) {
v.SetValue(p, 1) v.Input(1)
} }
func (v *Notes) set(p Point, value int) { 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) (*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)() defer v.change("FillNibble", MajorChange)()
rect := Table{v}.Range() rect := Table{v}.Range()
for y := rect.TopLeft.Y; y <= rect.BottomRight.Y; y++ { 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 { if val == 1 {
val = 0 // treat hold also as 0 val = 0 // treat hold also as 0
} }
if lowNibble { if v.d.LowNibble {
val = (val & 0xf0) | byte(value&15) val = (val & 0xf0) | byte(nibble&15)
} else { } else {
val = (val & 0x0f) | byte((value&15)<<4) val = (val & 0x0f) | byte((nibble&15)<<4)
} }
v.SetValue(Point{x, y}, val) 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}
} }