diff --git a/CHANGELOG.md b/CHANGELOG.md index d57c9b7..bdbc75c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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]) diff --git a/cmd/sointu-track/main.go b/cmd/sointu-track/main.go index 794a6fa..2bd6d5f 100644 --- a/cmd/sointu-track/main.go +++ b/cmd/sointu-track/main.go @@ -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 }) diff --git a/tracker/bool.go b/tracker/bool.go index 3c32b5a..254b8ea 100644 --- a/tracker/bool.go +++ b/tracker/bool.go @@ -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 diff --git a/tracker/derived.go b/tracker/derived.go index 95a4a3c..d259671 100644 --- a/tracker/derived.go +++ b/tracker/derived.go @@ -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] { diff --git a/tracker/gioui/note_editor.go b/tracker/gioui/note_editor.go index 764b544..2a2249e 100644 --- a/tracker/gioui/note_editor.go +++ b/tracker/gioui/note_editor.go @@ -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()) -} diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index b6f540e..74b164f 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -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 } diff --git a/tracker/gomidi/midi.go b/tracker/gomidi/midi.go index d205381..dc320e1 100644 --- a/tracker/gomidi/midi.go +++ b/tracker/gomidi/midi.go @@ -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 } diff --git a/tracker/int.go b/tracker/int.go index bb08564..fb819e9 100644 --- a/tracker/int.go +++ b/tracker/int.go @@ -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 } diff --git a/tracker/list.go b/tracker/list.go index ee13c4a..d970117 100644 --- a/tracker/list.go +++ b/tracker/list.go @@ -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) { diff --git a/tracker/model.go b/tracker/model.go index 89dbf19..31a7c7f 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -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) + } +} diff --git a/tracker/model_test.go b/tracker/model_test.go index 8e41bfc..c304687 100644 --- a/tracker/model_test.go +++ b/tracker/model_test.go @@ -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) } } }() diff --git a/tracker/optional_int.go b/tracker/optional_int.go index dbdfaa3..8d21cb9 100644 --- a/tracker/optional_int.go +++ b/tracker/optional_int.go @@ -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) +} diff --git a/tracker/player.go b/tracker/player.go index e018bb6..452c04d 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -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) { diff --git a/tracker/table.go b/tracker/table.go index 82b78d2..e119ee3 100644 --- a/tracker/table.go +++ b/tracker/table.go @@ -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