mirror of
https://github.com/vsariola/sointu.git
synced 2025-11-13 21:32:53 -05:00
feat(tracker): rework the MIDI input and note event handling
This commit is contained in:
parent
7ef868a434
commit
283fbc1171
@ -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])
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
58
tracker/gioui/keyboard.go
Normal 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
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:]...,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user