feat: input midi velocity into a separate track (includes many structural changes)

This commit is contained in:
qm210 2024-11-22 02:38:57 +01:00
parent ad690c7697
commit 49a259cf83
14 changed files with 348 additions and 142 deletions

View File

@ -16,8 +16,11 @@ 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])
- Direct Midi Input: The note editor has a button to allow entering notes
by MIDI (i.e. while not recording). ([#170][i170])
- Polyphony is supported if there are tracks available.
- The velocity of the last MIDI input note can be sent to another track
(selector next to the MIDI button in the note editor), optionally.
- Units can have comments, to make it easier to distinguish between units of
same type within an instrument. These comments are also shown when choosing
the send target. ([#114][i114])

View File

@ -64,7 +64,7 @@ func main() {
trackerUi := gioui.NewTracker(model)
audioCloser := audioContext.Play(func(buf sointu.AudioBuffer) error {
player.Process(buf, midiContext, trackerUi)
player.Process(buf, midiContext)
return nil
})

View File

@ -112,12 +112,15 @@ func (m *Follow) Value() bool { return m.follow }
func (m *Follow) setValue(val bool) { m.follow = val }
func (m *Follow) Enabled() bool { return true }
// TrackMidiIn (Midi Input for notes in the tracks)
// Midi Input for notes in the tracks
func (m *TrackMidiIn) Bool() Bool { return Bool{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) Bool() Bool { return Bool{m} }
func (m *TrackMidiIn) Value() bool { return m.trackMidiIn }
func (m *TrackMidiIn) setValue(val bool) {
m.trackMidiIn = val
((*Model)(m)).updatePlayerConstraints()
}
func (m *TrackMidiIn) Enabled() bool { return m.MIDI.HasDeviceOpen() }
// Effect methods

View File

@ -2,9 +2,10 @@ package tracker
import (
"fmt"
"github.com/vsariola/sointu"
"iter"
"slices"
"github.com/vsariola/sointu"
)
/*
@ -115,17 +116,17 @@ func (m *Model) PatternUnique(t, p int) bool {
// public getters with further model information
func (m *Model) TracksWithSameInstrumentAsCurrent() []int {
currentTrack := m.d.Cursor.Track
if currentTrack > len(m.derived.forTrack) {
d, ok := m.currentDerivedForTrack()
if !ok {
return nil
}
return m.derived.forTrack[currentTrack].tracksWithSameInstrument
return d.tracksWithSameInstrument
}
func (m *Model) CountNextTracksForCurrentInstrument() int {
currentTrack := m.d.Cursor.Track
count := 0
for t := range m.TracksWithSameInstrumentAsCurrent() {
for _, t := range m.TracksWithSameInstrumentAsCurrent() {
if t > currentTrack {
count++
}
@ -133,6 +134,32 @@ func (m *Model) CountNextTracksForCurrentInstrument() int {
return count
}
func (m *Model) CanUseTrackForMidiVelInput(trackIndex int) bool {
// makes no sense to record velocity into tracks where notes get recorded
tracksForMidiNoteInput := m.TracksWithSameInstrumentAsCurrent()
return !slices.Contains(tracksForMidiNoteInput, trackIndex)
}
func (m *Model) CurrentPlayerConstraints() PlayerProcessConstraints {
d, ok := m.currentDerivedForTrack()
if !ok {
return PlayerProcessConstraints{IsConstrained: false}
}
return PlayerProcessConstraints{
IsConstrained: m.trackMidiIn,
MaxPolyphony: len(d.tracksWithSameInstrument),
InstrumentIndex: d.instrumentRange[0],
}
}
func (m *Model) currentDerivedForTrack() (derivedForTrack, bool) {
currentTrack := m.d.Cursor.Track
if currentTrack > len(m.derived.forTrack) {
return derivedForTrack{}, false
}
return m.derived.forTrack[currentTrack], true
}
// init / update methods
func (m *Model) initDerivedData() {
@ -165,6 +192,7 @@ func (m *Model) updateDerivedScoreData() {
},
)
}
m.updatePlayerConstraints()
}
func (m *Model) updateDerivedPatchData() {
@ -194,6 +222,13 @@ func (m *Model) updateDerivedParameterData(unit sointu.Unit) {
}
}
// updatePlayerConstraints() is different from the other derived methods,
// it needs to be called after any model change that could affect the player.
// for this, it reads derivedForTrack, which is why it lives here for now.
func (m *Model) updatePlayerConstraints() {
m.MIDI.SetPlayerConstraints(m.CurrentPlayerConstraints())
}
// internals...
func (m *Model) collectSendSources(unit sointu.Unit, paramName string) iter.Seq[sendSourceData] {

View File

@ -2,6 +2,7 @@ package gioui
import (
"fmt"
"gioui.org/x/component"
"image"
"image/color"
"strconv"
@ -64,6 +65,7 @@ type NoteEditor struct {
EffectBtn *BoolClickable
UniqueBtn *BoolClickable
TrackMidiInBtn *BoolClickable
TrackForMidiVelIn *MenuClickable
scrollTable *ScrollTable
eventFilters []event.Filter
@ -88,6 +90,7 @@ func NewNoteEditor(model *tracker.Model) *NoteEditor {
EffectBtn: NewBoolClickable(model.Effect().Bool()),
UniqueBtn: NewBoolClickable(model.UniquePatterns().Bool()),
TrackMidiInBtn: NewBoolClickable(model.TrackMidiIn().Bool()),
TrackForMidiVelIn: &MenuClickable{Selected: model.TrackForMidiVelIn().OptionalInt()},
scrollTable: NewScrollTable(
model.Notes().Table(),
model.Tracks().List(),
@ -162,6 +165,8 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D {
effectBtnStyle := ToggleButton(gtx, t.Theme, te.EffectBtn, "Hex")
uniqueBtnStyle := ToggleIcon(gtx, t.Theme, te.UniqueBtn, icons.ToggleStarBorder, icons.ToggleStar, te.uniqueOffTip, te.uniqueOnTip)
midiInBtnStyle := ToggleButton(gtx, t.Theme, te.TrackMidiInBtn, "MIDI")
midiInBtnStyle.Hidden = !t.HasAnyMidiInput()
trackForMidiVelInSelector := te.layoutMidiVelInTrackSelector(t, " vel:")
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D { return layout.Dimensions{Size: image.Pt(gtx.Dp(unit.Dp(12)), 0)} }),
layout.Rigid(addSemitoneBtnStyle.Layout),
@ -176,12 +181,58 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D {
layout.Rigid(splitTrackBtnStyle.Layout),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(midiInBtnStyle.Layout),
layout.Rigid(trackForMidiVelInSelector),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(deleteTrackBtnStyle.Layout),
layout.Rigid(newTrackBtnStyle.Layout))
})
}
func (te *NoteEditor) layoutMidiVelInTrackSelector(t *Tracker, label string) func(gtx C) D {
if !t.HasAnyMidiInput() {
return layout.Spacer{}.Layout
}
tracks := t.Model.Tracks().List()
trackItems := make([]MenuItem, tracks.Count()+1)
trackForMidiVelIn := t.Model.TrackForMidiVelIn()
offText := "\u2014off\u2014"
currentText := offText
for i := range trackItems {
trackItems[i] = MenuItem{
Text: offText,
Doer: tracker.Check(
func() { trackForMidiVelIn.OptionalInt().Set(i-1, i > 0) },
func() bool { return t.Model.CanUseTrackForMidiVelInput(i - 1) },
),
}
if i > 0 {
trackItems[i].Text = fmt.Sprintf("%d %s", i-1, t.Model.TrackTitle(i-1))
}
if trackForMidiVelIn.OptionalInt().Equals(i-1, i > 0) {
trackItems[i].IconBytes = icons.NavigationChevronRight
if trackForMidiVelIn.IsValid() {
currentText = trackItems[i].Text
}
}
}
return func(gtx C) D {
tooltip := component.PlatformTooltip(t.Theme, "Record MIDI VEL into chosen track. This can not be one of the selected tracks (where MIDI Notes go).")
return te.TrackForMidiVelIn.TipArea.Layout(gtx, tooltip, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(SizedLabel(label, white, t.Theme.Shaper, unit.Sp(12))),
layout.Rigid(t.layoutMenu(gtx,
currentText,
&te.TrackForMidiVelIn.Clickable,
&te.TrackForMidiVelIn.menu,
unit.Dp(200),
trackItems...,
)),
)
})
}
}
const baseNote = 24
var notes = []string{
@ -294,6 +345,9 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
te.paintColumnCell(gtx, x, t, trackMidiInAdditionalColor, hasTrackMidiIn)
}
}
if t.Model.TrackForMidiVelIn().Equals(x) {
te.paintColumnCell(gtx, x, t, trackMidiVelInColor, hasTrackMidiIn)
}
}
// draw the pattern marker
@ -402,21 +456,3 @@ func (te *NoteEditor) finishNoteInsert(t *Tracker, note byte, keyName key.Name)
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

@ -35,7 +35,6 @@ type (
BottomHorizontalSplit *Split
VerticalSplit *Split
KeyPlaying map[key.Name]tracker.NoteID
MidiNotePlaying []byte
PopupAlert *PopupAlert
SaveChangesDialog *Dialog
@ -78,7 +77,6 @@ func NewTracker(model *tracker.Model) *Tracker {
VerticalSplit: &Split{Axis: layout.Vertical},
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),
@ -308,45 +306,9 @@ 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:]...,
)
}
func (t *Tracker) HasAnyMidiInput() bool {
for _ = range t.Model.MIDI.InputDevices {
return true
}
return false
}

View File

@ -13,13 +13,19 @@ import (
type (
RTMIDIContext struct {
driver *rtmididrv.Driver
currentIn drivers.In
events chan timestampedMsg
eventsBuf []timestampedMsg
eventIndex int
startFrame int
startFrameSet bool
driver *rtmididrv.Driver
currentIn drivers.In
inputDevices []RTMIDIDevice
devicesInitialized bool
events chan timestampedMsg
eventsBuf []timestampedMsg
eventIndex int
startFrame int
startFrameSet bool
// qm210: this is my current solution for passing model information to the player
// I do not completely love this, but improve at your own peril.
currentConstraints tracker.PlayerProcessConstraints
}
RTMIDIDevice struct {
@ -34,6 +40,22 @@ type (
)
func (m *RTMIDIContext) InputDevices(yield func(tracker.MIDIDevice) bool) {
if m.devicesInitialized {
m.yieldCachedInputDevices(yield)
} else {
m.initInputDevices(yield)
}
}
func (m *RTMIDIContext) yieldCachedInputDevices(yield func(tracker.MIDIDevice) bool) {
for _, device := range m.inputDevices {
if !yield(device) {
break
}
}
}
func (m *RTMIDIContext) initInputDevices(yield func(tracker.MIDIDevice) bool) {
if m.driver == nil {
return
}
@ -43,10 +65,12 @@ func (m *RTMIDIContext) InputDevices(yield func(tracker.MIDIDevice) bool) {
}
for i := 0; i < len(ins); i++ {
device := RTMIDIDevice{context: m, in: ins[i]}
m.inputDevices = append(m.inputDevices, device)
if !yield(device) {
break
}
}
m.devicesInitialized = true
}
// Open the driver.
@ -87,6 +111,37 @@ func (d RTMIDIDevice) String() string {
return d.in.String()
}
func (c *RTMIDIContext) Close() {
if c.driver == nil {
return
}
if c.currentIn != nil && c.currentIn.IsOpen() {
c.currentIn.Close()
}
c.driver.Close()
}
func (c *RTMIDIContext) HasDeviceOpen() bool {
return c.currentIn != nil && c.currentIn.IsOpen()
}
func (c *RTMIDIContext) TryToOpenBy(namePrefix string, takeFirst bool) {
if namePrefix == "" && !takeFirst {
return
}
for input := range c.InputDevices {
if takeFirst || strings.HasPrefix(input.String(), namePrefix) {
input.Open()
return
}
}
if takeFirst {
fmt.Errorf("Could not find any MIDI Input.\n")
} else {
fmt.Errorf("Could not find any default MIDI Input starting with \"%s\".\n", namePrefix)
}
}
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
@ -124,10 +179,16 @@ F:
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
isNoteOn := m.msg.GetNoteOn(&channel, &key, &velocity)
isNoteOff := !isNoteOn && m.msg.GetNoteOff(&channel, &key, &velocity)
if isNoteOn || isNoteOff {
return tracker.MIDINoteEvent{
Frame: f,
On: isNoteOn,
Channel: int(channel),
Note: key,
Velocity: velocity,
}, true
}
}
c.eventIndex = len(c.eventsBuf) + 1
@ -155,33 +216,10 @@ func (c *RTMIDIContext) BPM() (bpm float64, ok bool) {
return 0, false
}
func (c *RTMIDIContext) Close() {
if c.driver == nil {
return
}
if c.currentIn != nil && c.currentIn.IsOpen() {
c.currentIn.Close()
}
c.driver.Close()
func (c *RTMIDIContext) Constraints() tracker.PlayerProcessConstraints {
return c.currentConstraints
}
func (c *RTMIDIContext) HasDeviceOpen() bool {
return c.currentIn != nil && c.currentIn.IsOpen()
}
func (c *RTMIDIContext) TryToOpenBy(namePrefix string, takeFirst bool) {
if namePrefix == "" && !takeFirst {
return
}
for input := range c.InputDevices {
if takeFirst || strings.HasPrefix(input.String(), namePrefix) {
input.Open()
return
}
}
if takeFirst {
fmt.Errorf("Could not find any MIDI Input.\n")
} else {
fmt.Errorf("Could not find any default MIDI Input starting with \"%s\".\n", namePrefix)
}
func (c *RTMIDIContext) SetPlayerConstraints(constraints tracker.PlayerProcessConstraints) {
c.currentConstraints = constraints
}

View File

@ -45,6 +45,7 @@ func (v Int) Add(delta int) (ok bool) {
func (v Int) Set(value int) (ok bool) {
r := v.Range()
value = v.Range().Clamp(value)
// qm210: Question: how can the Min/Max checks even be true after the preceding Clamp() ?
if value == v.Value() || value < r.Min || value > r.Max {
return false
}

View File

@ -428,7 +428,7 @@ func (v *Tracks) Selected2() int {
}
func (v *Tracks) SetSelected(value int) {
v.d.Cursor.Track = max(min(value, v.Count()-1), 0)
(*Model)(v).ChangeTrack(value)
}
func (v *Tracks) SetSelected2(value int) {

View File

@ -8,6 +8,7 @@ import (
"path/filepath"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker/types"
"github.com/vsariola/sointu/vm"
)
@ -79,8 +80,9 @@ type (
broker *Broker
MIDI MIDIContext
trackMidiIn bool
MIDI MIDIContext
trackMidiIn bool
trackForMidiVelIn types.OptionalInteger
}
// Cursor identifies a row and a track in a song score.
@ -131,6 +133,8 @@ type (
InputDevices(yield func(MIDIDevice) bool)
Close()
HasDeviceOpen() bool
SetPlayerConstraints(PlayerProcessConstraints)
}
MIDIDevice interface {
@ -383,6 +387,8 @@ func (m *Model) ProcessMsg(msg MsgToModel) {
m.playing = e.bool
case *sointu.AudioBuffer:
m.signalAnalyzer.ProcessAudioBuffer(e)
case TrackInput:
m.applyTrackInput(e)
default:
}
}
@ -390,6 +396,11 @@ func (m *Model) ProcessMsg(msg MsgToModel) {
func (m *Model) SignalAnalyzer() *ScopeModel { return m.signalAnalyzer }
func (m *Model) Broker() *Broker { return m.broker }
func (m *Model) ChangeTrack(track int) {
m.d.Cursor.Track = max(min(track, len(m.d.Song.Score.Tracks)-1), 0)
m.updatePlayerConstraints()
}
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}))
@ -560,3 +571,20 @@ func clamp(a, min, max int) int {
}
return a
}
func (m *Model) applyTrackInput(trackInput TrackInput) {
c := Point{m.d.Cursor.Track, m.d.Cursor.SongPos.PatternRow}
availableIndices := m.CountNextTracksForCurrentInstrument()
for i, note := range trackInput.Notes {
m.Notes().SetValue(c, note)
if i >= availableIndices {
// only use same-instruments-tracks to the right of the c
break
}
c.X++
}
if velTrackIndex, useVel := m.trackForMidiVelIn.Unpack(); useVel {
c.X = velTrackIndex
m.Notes().SetValue(c, trackInput.Velocity)
}
}

View File

@ -8,6 +8,7 @@ import (
"testing"
"github.com/vsariola/sointu/tracker"
"github.com/vsariola/sointu/tracker/types"
"github.com/vsariola/sointu/vm"
)
@ -23,6 +24,12 @@ func (NullContext) BPM() (bpm float64, ok bool) {
return 0, false
}
func (NullContext) MaxPolyphony() types.OptionalInteger {
return types.NewEmptyOptionalInteger()
}
func (NullContext) SetMaxPolyphony(v types.OptionalInteger) {}
func (NullContext) InputDevices(yield func(tracker.MIDIDevice) bool) {}
func (NullContext) HasDeviceOpen() bool { return false }
@ -277,7 +284,7 @@ func FuzzModel(f *testing.F) {
break loop
default:
ctx := NullContext{}
player.Process(buf, ctx, nil)
player.Process(buf, ctx)
}
}
}()

View File

@ -1,5 +1,7 @@
package tracker
import "github.com/vsariola/sointu/tracker/types"
type (
// OptionalInt tries to follow the same convention as e.g. Int{...} or Bool{...}
// Do not confuse with types.OptionalInteger, which you might use as a model,
@ -40,3 +42,40 @@ func (v OptionalInt) Equals(value int, present bool) bool {
oldValue, oldPresent := v.Unpack()
return value == oldValue && present == oldPresent
}
// Model methods
func (m *Model) TrackForMidiVelIn() *TrackMidiVelIn { return (*TrackMidiVelIn)(m) }
// TrackForMidiVelIn - to record Velocity in the track with given number (-1 = off)
func (m *TrackMidiVelIn) OptionalInt() OptionalInt { return OptionalInt{m} }
func (m *TrackMidiVelIn) Range() intRange { return intRange{0, len(m.d.Song.Score.Tracks) - 1} }
func (m *TrackMidiVelIn) change(string) func() { return func() {} }
func (m *TrackMidiVelIn) setValue(val int) {
m.trackForMidiVelIn = types.NewOptionalInteger(val, val >= 0)
}
func (m *TrackMidiVelIn) unsetValue() {
m.trackForMidiVelIn = types.NewEmptyOptionalInteger()
}
func (m *TrackMidiVelIn) Unpack() (int, bool) {
return m.trackForMidiVelIn.Unpack()
}
func (m *TrackMidiVelIn) Value() int {
return m.trackForMidiVelIn.Value()
}
func (m *TrackMidiVelIn) IsValid() bool {
if m.trackForMidiVelIn.Empty() {
return true
}
return (*Model)(m).CanUseTrackForMidiVelInput(m.trackForMidiVelIn.Value())
}
func (m *TrackMidiVelIn) Equals(value int) bool {
return m.trackForMidiVelIn.Equals(value)
}

View File

@ -3,6 +3,7 @@ package tracker
import (
"fmt"
"math"
"slices"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/vm"
@ -24,8 +25,9 @@ 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
recState recState // is the recording off; are we waiting for a note; or are we recording
recording Recording // the recorded MIDI events and BPM
trackInput TrackInput // for events that are played when not recording
synther sointu.Synther // the synther used to create new synths
broker *Broker // the broker used to communicate with different parts of the tracker
@ -37,21 +39,32 @@ type (
NextEvent(frame int) (event MIDINoteEvent, ok bool)
FinishBlock(frame int)
BPM() (bpm float64, ok bool)
Constraints() PlayerProcessConstraints
}
EventProcessor interface {
ProcessMessage(msg interface{})
ProcessEvent(event MIDINoteEvent)
PlayerProcessConstraints struct {
IsConstrained bool
MaxPolyphony int
InstrumentIndex int
}
// MIDINoteEvent is a MIDI event triggering or releasing a note. In
// processing, the Frame is relative to the start of the current buffer. In
// a Recording, the Frame is relative to the start of the recording.
MIDINoteEvent struct {
Frame int
On bool
Channel int
Note byte
Frame int
On bool
Channel int
Note byte
Velocity byte
}
// TrackInput is used for the midi-into-track-input, when not recording
// For now, there is only one Velocity for all Notes. This might evolve.
TrackInput struct {
Notes []byte
Velocity byte
}
)
@ -85,8 +98,10 @@ 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)
func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext) {
p.processMessages(context)
constraints := context.Constraints()
_ = constraints
frame := 0
midi, midiOk := context.NextEvent(frame)
@ -106,15 +121,7 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext
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)
}
p.handleMidiInput(midi, constraints)
midi, midiOk = context.NextEvent(frame)
}
framesUntilMidi := len(buffer)
@ -184,6 +191,50 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext
p.SendAlert("PlayerCrash", fmt.Sprintf("synth did not fill the audio buffer even with %d render calls", numRenderTries), Error)
}
func (p *Player) handleMidiInput(midi MIDINoteEvent, constraints PlayerProcessConstraints) {
instrIndex := midi.Channel
if constraints.IsConstrained {
instrIndex = constraints.InstrumentIndex
}
if midi.On {
p.triggerInstrument(instrIndex, midi.Note)
if p.addTrackInput(midi, constraints) {
trySend(p.broker.ToModel, MsgToModel{Data: p.trackInput})
}
} else {
p.releaseInstrument(instrIndex, midi.Note)
p.removeTrackInput(midi)
}
}
func (p *Player) addTrackInput(midi MIDINoteEvent, c PlayerProcessConstraints) (changed bool) {
if c.IsConstrained {
if len(p.trackInput.Notes) == c.MaxPolyphony {
return false
} else if len(p.trackInput.Notes) > c.MaxPolyphony {
p.trackInput.Notes = p.trackInput.Notes[:c.MaxPolyphony]
return true
}
}
if slices.Contains(p.trackInput.Notes, midi.Note) {
return false
}
p.trackInput.Notes = append(p.trackInput.Notes, midi.Note)
p.trackInput.Velocity = midi.Velocity
return true
}
func (p *Player) removeTrackInput(midi MIDINoteEvent) {
for i, n := range p.trackInput.Notes {
if n == midi.Note {
p.trackInput.Notes = append(
p.trackInput.Notes[:i],
p.trackInput.Notes[i+1:]...,
)
}
}
}
func (p *Player) advanceRow() {
if p.song.Score.Length == 0 || p.song.Score.RowsPerPattern == 0 {
return
@ -220,7 +271,7 @@ func (p *Player) advanceRow() {
p.rowtime = 0
}
func (p *Player) processMessages(context PlayerProcessContext, uiProcessor EventProcessor) {
func (p *Player) processMessages(context PlayerProcessContext) {
loop:
for { // process new message
select {
@ -295,9 +346,6 @@ loop:
default:
// ignore unknown messages
}
if uiProcessor != nil {
uiProcessor.ProcessMessage(msg)
}
default:
break loop
}
@ -346,7 +394,13 @@ func (p *Player) compileOrUpdateSynth() {
// all sendTargets from player are always non-blocking, to ensure that the player thread cannot end up in a dead-lock
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) {

View File

@ -173,7 +173,7 @@ func (m *Order) Cursor2() Point {
}
func (m *Order) SetCursor(p Point) {
m.d.Cursor.Track = max(min(p.X, len(m.d.Song.Score.Tracks)-1), 0)
(*Model)(m).ChangeTrack(p.X)
y := max(min(p.Y, m.d.Song.Score.Length-1), 0)
if y != m.d.Cursor.OrderRow {
m.follow = false
@ -385,7 +385,7 @@ func (m *Notes) Cursor2() Point {
}
func (v *Notes) SetCursor(p Point) {
v.d.Cursor.Track = max(min(p.X, len(v.d.Song.Score.Tracks)-1), 0)
(*Model)(v).ChangeTrack(p.X)
newPos := v.d.Song.Score.Clamp(sointu.SongPos{PatternRow: p.Y})
if newPos != v.d.Cursor.SongPos {
v.follow = false