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 - 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 - Direct Midi Input: The note editor has a button to allow entering notes
supported if there are tracks available. ([#170][i170]) 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 - 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

@ -64,7 +64,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

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

View File

@ -2,9 +2,10 @@ package tracker
import ( import (
"fmt" "fmt"
"github.com/vsariola/sointu"
"iter" "iter"
"slices" "slices"
"github.com/vsariola/sointu"
) )
/* /*
@ -115,17 +116,17 @@ func (m *Model) PatternUnique(t, p int) bool {
// public getters with further model information // public getters with further model information
func (m *Model) TracksWithSameInstrumentAsCurrent() []int { func (m *Model) TracksWithSameInstrumentAsCurrent() []int {
currentTrack := m.d.Cursor.Track d, ok := m.currentDerivedForTrack()
if currentTrack > len(m.derived.forTrack) { if !ok {
return nil return nil
} }
return m.derived.forTrack[currentTrack].tracksWithSameInstrument return d.tracksWithSameInstrument
} }
func (m *Model) CountNextTracksForCurrentInstrument() int { func (m *Model) CountNextTracksForCurrentInstrument() int {
currentTrack := m.d.Cursor.Track currentTrack := m.d.Cursor.Track
count := 0 count := 0
for t := range m.TracksWithSameInstrumentAsCurrent() { for _, t := range m.TracksWithSameInstrumentAsCurrent() {
if t > currentTrack { if t > currentTrack {
count++ count++
} }
@ -133,6 +134,32 @@ func (m *Model) CountNextTracksForCurrentInstrument() int {
return count 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 // init / update methods
func (m *Model) initDerivedData() { func (m *Model) initDerivedData() {
@ -165,6 +192,7 @@ func (m *Model) updateDerivedScoreData() {
}, },
) )
} }
m.updatePlayerConstraints()
} }
func (m *Model) updateDerivedPatchData() { 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... // internals...
func (m *Model) collectSendSources(unit sointu.Unit, paramName string) iter.Seq[sendSourceData] { func (m *Model) collectSendSources(unit sointu.Unit, paramName string) iter.Seq[sendSourceData] {

View File

@ -2,6 +2,7 @@ package gioui
import ( import (
"fmt" "fmt"
"gioui.org/x/component"
"image" "image"
"image/color" "image/color"
"strconv" "strconv"
@ -64,6 +65,7 @@ type NoteEditor struct {
EffectBtn *BoolClickable EffectBtn *BoolClickable
UniqueBtn *BoolClickable UniqueBtn *BoolClickable
TrackMidiInBtn *BoolClickable TrackMidiInBtn *BoolClickable
TrackForMidiVelIn *MenuClickable
scrollTable *ScrollTable scrollTable *ScrollTable
eventFilters []event.Filter eventFilters []event.Filter
@ -88,6 +90,7 @@ func NewNoteEditor(model *tracker.Model) *NoteEditor {
EffectBtn: NewBoolClickable(model.Effect().Bool()), EffectBtn: NewBoolClickable(model.Effect().Bool()),
UniqueBtn: NewBoolClickable(model.UniquePatterns().Bool()), UniqueBtn: NewBoolClickable(model.UniquePatterns().Bool()),
TrackMidiInBtn: NewBoolClickable(model.TrackMidiIn().Bool()), TrackMidiInBtn: NewBoolClickable(model.TrackMidiIn().Bool()),
TrackForMidiVelIn: &MenuClickable{Selected: model.TrackForMidiVelIn().OptionalInt()},
scrollTable: NewScrollTable( scrollTable: NewScrollTable(
model.Notes().Table(), model.Notes().Table(),
model.Tracks().List(), model.Tracks().List(),
@ -162,6 +165,8 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D {
effectBtnStyle := ToggleButton(gtx, t.Theme, te.EffectBtn, "Hex") effectBtnStyle := ToggleButton(gtx, t.Theme, te.EffectBtn, "Hex")
uniqueBtnStyle := ToggleIcon(gtx, t.Theme, te.UniqueBtn, icons.ToggleStarBorder, icons.ToggleStar, te.uniqueOffTip, te.uniqueOnTip) uniqueBtnStyle := ToggleIcon(gtx, t.Theme, te.UniqueBtn, icons.ToggleStarBorder, icons.ToggleStar, te.uniqueOffTip, te.uniqueOnTip)
midiInBtnStyle := ToggleButton(gtx, t.Theme, te.TrackMidiInBtn, "MIDI") 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, 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(func(gtx C) D { return layout.Dimensions{Size: image.Pt(gtx.Dp(unit.Dp(12)), 0)} }),
layout.Rigid(addSemitoneBtnStyle.Layout), layout.Rigid(addSemitoneBtnStyle.Layout),
@ -176,12 +181,58 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D {
layout.Rigid(splitTrackBtnStyle.Layout), layout.Rigid(splitTrackBtnStyle.Layout),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(midiInBtnStyle.Layout), layout.Rigid(midiInBtnStyle.Layout),
layout.Rigid(trackForMidiVelInSelector),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(deleteTrackBtnStyle.Layout), layout.Rigid(deleteTrackBtnStyle.Layout),
layout.Rigid(newTrackBtnStyle.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 const baseNote = 24
var notes = []string{ var notes = []string{
@ -294,6 +345,9 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
te.paintColumnCell(gtx, x, t, trackMidiInAdditionalColor, hasTrackMidiIn) te.paintColumnCell(gtx, x, t, trackMidiInAdditionalColor, hasTrackMidiIn)
} }
} }
if t.Model.TrackForMidiVelIn().Equals(x) {
te.paintColumnCell(gtx, x, t, trackMidiVelInColor, hasTrackMidiIn)
}
} }
// draw the pattern marker // 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) 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 BottomHorizontalSplit *Split
VerticalSplit *Split VerticalSplit *Split
KeyPlaying map[key.Name]tracker.NoteID KeyPlaying map[key.Name]tracker.NoteID
MidiNotePlaying []byte
PopupAlert *PopupAlert PopupAlert *PopupAlert
SaveChangesDialog *Dialog SaveChangesDialog *Dialog
@ -78,7 +77,6 @@ func NewTracker(model *tracker.Model) *Tracker {
VerticalSplit: &Split{Axis: layout.Vertical}, VerticalSplit: &Split{Axis: layout.Vertical},
KeyPlaying: make(map[key.Name]tracker.NoteID), 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),
@ -308,45 +306,9 @@ func (t *Tracker) layoutTop(gtx layout.Context) layout.Dimensions {
) )
} }
/// Event Handling (for UI updates when playing etc.) func (t *Tracker) HasAnyMidiInput() bool {
for _ = range t.Model.MIDI.InputDevices {
func (t *Tracker) ProcessMessage(msg interface{}) { return true
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:]...,
)
}
} }
return false
} }

View File

@ -13,13 +13,19 @@ import (
type ( type (
RTMIDIContext struct { RTMIDIContext struct {
driver *rtmididrv.Driver driver *rtmididrv.Driver
currentIn drivers.In currentIn drivers.In
events chan timestampedMsg inputDevices []RTMIDIDevice
eventsBuf []timestampedMsg devicesInitialized bool
eventIndex int events chan timestampedMsg
startFrame int eventsBuf []timestampedMsg
startFrameSet bool 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 { RTMIDIDevice struct {
@ -34,6 +40,22 @@ type (
) )
func (m *RTMIDIContext) InputDevices(yield func(tracker.MIDIDevice) bool) { 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 { if m.driver == nil {
return return
} }
@ -43,10 +65,12 @@ func (m *RTMIDIContext) InputDevices(yield func(tracker.MIDIDevice) bool) {
} }
for i := 0; i < len(ins); i++ { for i := 0; i < len(ins); i++ {
device := RTMIDIDevice{context: m, in: ins[i]} device := RTMIDIDevice{context: m, in: ins[i]}
m.inputDevices = append(m.inputDevices, device)
if !yield(device) { if !yield(device) {
break break
} }
} }
m.devicesInitialized = true
} }
// Open the driver. // Open the driver.
@ -87,6 +111,37 @@ func (d RTMIDIDevice) String() string {
return d.in.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) { func (m *RTMIDIContext) HandleMessage(msg midi.Message, timestampms int32) {
select { select {
case m.events <- timestampedMsg{frame: int(int64(timestampms) * 44100 / 1000), msg: msg}: // if the channel is full, just drop the message 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] m := c.eventsBuf[c.eventIndex]
f := m.frame - c.startFrame f := m.frame - c.startFrame
c.eventIndex++ c.eventIndex++
if m.msg.GetNoteOn(&channel, &key, &velocity) { isNoteOn := m.msg.GetNoteOn(&channel, &key, &velocity)
return tracker.MIDINoteEvent{Frame: f, On: true, Channel: int(channel), Note: key}, true isNoteOff := !isNoteOn && m.msg.GetNoteOff(&channel, &key, &velocity)
} else if m.msg.GetNoteOff(&channel, &key, &velocity) { if isNoteOn || isNoteOff {
return tracker.MIDINoteEvent{Frame: f, On: false, Channel: int(channel), Note: key}, true return tracker.MIDINoteEvent{
Frame: f,
On: isNoteOn,
Channel: int(channel),
Note: key,
Velocity: velocity,
}, true
} }
} }
c.eventIndex = len(c.eventsBuf) + 1 c.eventIndex = len(c.eventsBuf) + 1
@ -155,33 +216,10 @@ func (c *RTMIDIContext) BPM() (bpm float64, ok bool) {
return 0, false return 0, false
} }
func (c *RTMIDIContext) Close() { func (c *RTMIDIContext) Constraints() tracker.PlayerProcessConstraints {
if c.driver == nil { return c.currentConstraints
return
}
if c.currentIn != nil && c.currentIn.IsOpen() {
c.currentIn.Close()
}
c.driver.Close()
} }
func (c *RTMIDIContext) HasDeviceOpen() bool { func (c *RTMIDIContext) SetPlayerConstraints(constraints tracker.PlayerProcessConstraints) {
return c.currentIn != nil && c.currentIn.IsOpen() c.currentConstraints = constraints
}
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)
}
} }

View File

@ -45,6 +45,7 @@ func (v Int) Add(delta int) (ok bool) {
func (v Int) Set(value int) (ok bool) { func (v Int) Set(value int) (ok bool) {
r := v.Range() r := v.Range()
value = v.Range().Clamp(value) 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 { if value == v.Value() || value < r.Min || value > r.Max {
return false return false
} }

View File

@ -428,7 +428,7 @@ func (v *Tracks) Selected2() int {
} }
func (v *Tracks) SetSelected(value 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) { func (v *Tracks) SetSelected2(value int) {

View File

@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"github.com/vsariola/sointu" "github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker/types"
"github.com/vsariola/sointu/vm" "github.com/vsariola/sointu/vm"
) )
@ -79,8 +80,9 @@ type (
broker *Broker broker *Broker
MIDI MIDIContext MIDI MIDIContext
trackMidiIn bool trackMidiIn bool
trackForMidiVelIn types.OptionalInteger
} }
// Cursor identifies a row and a track in a song score. // Cursor identifies a row and a track in a song score.
@ -131,6 +133,8 @@ type (
InputDevices(yield func(MIDIDevice) bool) InputDevices(yield func(MIDIDevice) bool)
Close() Close()
HasDeviceOpen() bool HasDeviceOpen() bool
SetPlayerConstraints(PlayerProcessConstraints)
} }
MIDIDevice interface { MIDIDevice interface {
@ -383,6 +387,8 @@ 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)
case TrackInput:
m.applyTrackInput(e)
default: default:
} }
} }
@ -390,6 +396,11 @@ func (m *Model) ProcessMsg(msg MsgToModel) {
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) 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) { func (m *Model) TrackNoteOn(track int, note byte) (id NoteID) {
id = NoteID{IsInstr: false, Track: track, Note: note, model: m} id = NoteID{IsInstr: false, Track: track, Note: note, model: m}
trySend(m.broker.ToPlayer, any(NoteOnMsg{id})) trySend(m.broker.ToPlayer, any(NoteOnMsg{id}))
@ -560,3 +571,20 @@ func clamp(a, min, max int) int {
} }
return a 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" "testing"
"github.com/vsariola/sointu/tracker" "github.com/vsariola/sointu/tracker"
"github.com/vsariola/sointu/tracker/types"
"github.com/vsariola/sointu/vm" "github.com/vsariola/sointu/vm"
) )
@ -23,6 +24,12 @@ func (NullContext) BPM() (bpm float64, ok bool) {
return 0, false 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) InputDevices(yield func(tracker.MIDIDevice) bool) {}
func (NullContext) HasDeviceOpen() bool { return false } func (NullContext) HasDeviceOpen() bool { return false }
@ -277,7 +284,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,5 +1,7 @@
package tracker package tracker
import "github.com/vsariola/sointu/tracker/types"
type ( type (
// OptionalInt tries to follow the same convention as e.g. Int{...} or Bool{...} // 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, // 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() oldValue, oldPresent := v.Unpack()
return value == oldValue && present == oldPresent 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 ( import (
"fmt" "fmt"
"math" "math"
"slices"
"github.com/vsariola/sointu" "github.com/vsariola/sointu"
"github.com/vsariola/sointu/vm" "github.com/vsariola/sointu/vm"
@ -24,8 +25,9 @@ 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 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
trackInput TrackInput // for events that are played when not recording
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
@ -37,21 +39,32 @@ type (
NextEvent(frame int) (event MIDINoteEvent, ok bool) NextEvent(frame int) (event MIDINoteEvent, ok bool)
FinishBlock(frame int) FinishBlock(frame int)
BPM() (bpm float64, ok bool) BPM() (bpm float64, ok bool)
Constraints() PlayerProcessConstraints
} }
EventProcessor interface { PlayerProcessConstraints struct {
ProcessMessage(msg interface{}) IsConstrained bool
ProcessEvent(event MIDINoteEvent) MaxPolyphony int
InstrumentIndex int
} }
// MIDINoteEvent is a MIDI event triggering or releasing a note. In // MIDINoteEvent is a MIDI event triggering or releasing a note. In
// processing, the Frame is relative to the start of the current buffer. 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. // a Recording, the Frame is relative to the start of the recording.
MIDINoteEvent struct { MIDINoteEvent struct {
Frame int Frame int
On bool On bool
Channel int Channel int
Note byte 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 // 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)
constraints := context.Constraints()
_ = constraints
frame := 0 frame := 0
midi, midiOk := context.NextEvent(frame) 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) midiTotalFrame.Frame = p.recording.TotalFrames - len(buffer)
p.recording.Events = append(p.recording.Events, midiTotalFrame) p.recording.Events = append(p.recording.Events, midiTotalFrame)
} }
if midi.On { p.handleMidiInput(midi, constraints)
p.triggerInstrument(midi.Channel, midi.Note)
} else {
p.releaseInstrument(midi.Channel, midi.Note)
}
if ui != nil {
ui.ProcessEvent(midi)
}
midi, midiOk = context.NextEvent(frame) midi, midiOk = context.NextEvent(frame)
} }
framesUntilMidi := len(buffer) 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) 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() { func (p *Player) advanceRow() {
if p.song.Score.Length == 0 || p.song.Score.RowsPerPattern == 0 { if p.song.Score.Length == 0 || p.song.Score.RowsPerPattern == 0 {
return return
@ -220,7 +271,7 @@ func (p *Player) advanceRow() {
p.rowtime = 0 p.rowtime = 0
} }
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 {
@ -295,9 +346,6 @@ loop:
default: default:
// ignore unknown messages // ignore unknown messages
} }
if uiProcessor != nil {
uiProcessor.ProcessMessage(msg)
}
default: default:
break loop 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 // 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{}) { 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) triggerInstrument(instrument int, note byte) {

View File

@ -173,7 +173,7 @@ func (m *Order) Cursor2() Point {
} }
func (m *Order) SetCursor(p 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) y := max(min(p.Y, m.d.Song.Score.Length-1), 0)
if y != m.d.Cursor.OrderRow { if y != m.d.Cursor.OrderRow {
m.follow = false m.follow = false
@ -385,7 +385,7 @@ func (m *Notes) Cursor2() Point {
} }
func (v *Notes) SetCursor(p 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}) newPos := v.d.Song.Score.Clamp(sointu.SongPos{PatternRow: p.Y})
if newPos != v.d.Cursor.SongPos { if newPos != v.d.Cursor.SongPos {
v.follow = false v.follow = false