From 4eebfd9f48adabb2a0e39e556c2ae523fffe27c0 Mon Sep 17 00:00:00 2001 From: qm210 Date: Fri, 22 Nov 2024 02:38:57 +0100 Subject: [PATCH 1/5] feat: input midi velocity into a separate track (includes many structural changes) --- CHANGELOG.md | 7 +- cmd/sointu-track/main.go | 2 +- tracker/bool.go | 13 ++-- tracker/derived.go | 45 +++++++++++-- tracker/gioui/buttons.go | 17 ++++- tracker/gioui/iconcache.go | 3 + tracker/gioui/label.go | 14 +++- tracker/gioui/menu.go | 21 +++--- tracker/gioui/note_editor.go | 94 ++++++++++++++++++--------- tracker/gioui/numericupdown.go | 17 +++++ tracker/gioui/songpanel.go | 5 +- tracker/gioui/theme.go | 16 ++++- tracker/gioui/tracker.go | 51 +++------------ tracker/gioui/unit_editor.go | 9 ++- tracker/gomidi/midi.go | 114 ++++++++++++++++++++++----------- tracker/int.go | 1 + tracker/list.go | 2 +- tracker/model.go | 32 ++++++++- tracker/model_test.go | 9 ++- tracker/optional_int.go | 81 +++++++++++++++++++++++ tracker/player.go | 104 ++++++++++++++++++++++-------- tracker/table.go | 4 +- tracker/types/optional.go | 47 ++++++++++++++ 23 files changed, 531 insertions(+), 177 deletions(-) create mode 100644 tracker/optional_int.go create mode 100644 tracker/types/optional.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d57c9b71..bdbc75cd 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 794a6fa7..2bd6d5fe 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 3c32b5ae..254b8ead 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 95a4a3c4..d2596718 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/buttons.go b/tracker/gioui/buttons.go index 024ab10c..11d337de 100644 --- a/tracker/gioui/buttons.go +++ b/tracker/gioui/buttons.go @@ -44,6 +44,14 @@ type ( TipArea component.TipArea Bool tracker.Bool } + + MenuClickable struct { + Clickable Clickable + menu Menu + Selected tracker.OptionalInt + TipArea component.TipArea + Tooltip component.Tooltip + } ) func NewActionClickable(a tracker.Action) *ActionClickable { @@ -136,7 +144,10 @@ func ToggleButton(gtx C, th *material.Theme, b *BoolClickable, text string) Butt ret := Button(th, &b.Clickable, text) ret.Background = transparent ret.Inset = layout.UniformInset(unit.Dp(6)) - if b.Bool.Value() { + if !b.Bool.Enabled() { + ret.Color = disabledTextColor + ret.Background = transparent + } else if b.Bool.Value() { ret.Color = th.Palette.ContrastFg ret.Background = th.Palette.Fg } else { @@ -287,6 +298,7 @@ type ButtonStyle struct { Inset layout.Inset Button *Clickable shaper *text.Shaper + Hidden bool } type ButtonLayoutStyle struct { @@ -351,6 +363,9 @@ func (b ButtonStyle) Layout(gtx layout.Context) layout.Dimensions { CornerRadius: b.CornerRadius, Button: b.Button, }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + if b.Hidden { + return layout.Dimensions{} + } return b.Inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { colMacro := op.Record(gtx.Ops) paint.ColorOp{Color: b.Color}.Add(gtx.Ops) diff --git a/tracker/gioui/iconcache.go b/tracker/gioui/iconcache.go index b79eadb7..f663a2a2 100644 --- a/tracker/gioui/iconcache.go +++ b/tracker/gioui/iconcache.go @@ -10,6 +10,9 @@ var iconCache = map[*byte]*widget.Icon{} // widgetForIcon returns a widget for IconVG data, but caching the results func widgetForIcon(icon []byte) *widget.Icon { + if icon == nil { + return nil + } if widget, ok := iconCache[&icon[0]]; ok { return widget } diff --git a/tracker/gioui/label.go b/tracker/gioui/label.go index 1c2081a1..5e789935 100644 --- a/tracker/gioui/label.go +++ b/tracker/gioui/label.go @@ -46,5 +46,17 @@ func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions { } func Label(str string, color color.NRGBA, shaper *text.Shaper) layout.Widget { - return LabelStyle{Text: str, Color: color, ShadeColor: black, Font: labelDefaultFont, FontSize: labelDefaultFontSize, Alignment: layout.W, Shaper: shaper}.Layout + return SizedLabel(str, color, shaper, labelDefaultFontSize) +} + +func SizedLabel(str string, color color.NRGBA, shaper *text.Shaper, fontSize unit.Sp) layout.Widget { + return LabelStyle{ + Text: str, + Color: color, + ShadeColor: black, + Font: labelDefaultFont, + FontSize: fontSize, + Alignment: layout.W, + Shaper: shaper, + }.Layout } diff --git a/tracker/gioui/menu.go b/tracker/gioui/menu.go index 3597d31e..7c552b92 100644 --- a/tracker/gioui/menu.go +++ b/tracker/gioui/menu.go @@ -12,8 +12,6 @@ import ( "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" - "gioui.org/widget" - "gioui.org/widget/material" "github.com/vsariola/sointu/tracker" ) @@ -103,12 +101,12 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D { } icon := widgetForIcon(item.IconBytes) iconColor := m.IconColor - if !item.Doer.Allowed() { - iconColor = mediumEmphasisTextColor - } iconInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(6)} textLabel := LabelStyle{Text: item.Text, FontSize: m.FontSize, Color: m.TextColor, Shaper: m.Shaper} if !item.Doer.Allowed() { + // note: might be a bug in gioui, but for iconColor = mediumEmphasisTextColor + // this does not render the icon at all. other colors seem to work fine. + iconColor = disabledTextColor textLabel.Color = mediumEmphasisTextColor } shortcutLabel := LabelStyle{Text: item.ShortcutText, FontSize: m.FontSize, Color: m.ShortCutColor, Shaper: m.Shaper} @@ -116,13 +114,18 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D { dims := layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { return iconInset.Layout(gtx, func(gtx C) D { - p := gtx.Dp(unit.Dp(m.IconSize)) + p := gtx.Dp(m.IconSize) gtx.Constraints.Min = image.Pt(p, p) + if icon == nil { + return D{Size: gtx.Constraints.Min} + } return icon.Layout(gtx, iconColor) }) }), layout.Rigid(textLabel.Layout), - layout.Flexed(1, func(gtx C) D { return D{Size: image.Pt(gtx.Constraints.Max.X, 1)} }), + layout.Flexed(1, func(gtx C) D { + return D{Size: image.Pt(gtx.Constraints.Max.X, 1)} + }), layout.Rigid(func(gtx C) D { return shortcutInset.Layout(gtx, shortcutLabel.Layout) }), @@ -168,14 +171,14 @@ func PopupMenu(menu *Menu, shaper *text.Shaper) MenuStyle { } } -func (tr *Tracker) layoutMenu(gtx C, title string, clickable *widget.Clickable, menu *Menu, width unit.Dp, items ...MenuItem) layout.Widget { +func (tr *Tracker) layoutMenu(gtx C, title string, clickable *Clickable, menu *Menu, width unit.Dp, items ...MenuItem) layout.Widget { for clickable.Clicked(gtx) { menu.Visible = true } m := PopupMenu(menu, tr.Theme.Shaper) return func(gtx C) D { defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() - titleBtn := material.Button(tr.Theme, clickable, title) + titleBtn := Button(tr.Theme, clickable, title) titleBtn.Color = white titleBtn.Background = transparent titleBtn.CornerRadius = unit.Dp(0) diff --git a/tracker/gioui/note_editor.go b/tracker/gioui/note_editor.go index 00068782..2a2249ed 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(), @@ -158,14 +161,12 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D { deleteTrackBtnStyle := ActionIcon(gtx, t.Theme, te.DeleteTrackBtn, icons.ActionDelete, te.deleteTrackHint) splitTrackBtnStyle := ActionIcon(gtx, t.Theme, te.SplitTrackBtn, icons.CommunicationCallSplit, te.splitTrackHint) newTrackBtnStyle := ActionIcon(gtx, t.Theme, te.NewTrackBtn, icons.ContentAdd, te.addTrackHint) - in := layout.UniformInset(unit.Dp(1)) - voiceUpDown := func(gtx C) D { - numStyle := NumericUpDown(t.Theme, te.TrackVoices, "Number of voices for this track") - return in.Layout(gtx, numStyle.Layout) - } + voiceUpDown := NumericUpDownPadded(t.Theme, te.TrackVoices, "Number of voices for this track", 1) 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,16 +177,62 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D { layout.Rigid(effectBtnStyle.Layout), layout.Rigid(uniqueBtnStyle.Layout), layout.Rigid(Label(" Voices:", white, t.Theme.Shaper)), - layout.Rigid(voiceUpDown), + layout.Rigid(voiceUpDown.Layout), 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{ @@ -287,17 +334,20 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { c = cursorColor } if hasTrackMidiIn { - c = cursorForTrackMidiInColor + c = trackMidiInCurrentColor } - te.paintColumnCell(gtx, x, t, c) + te.paintColumnCell(gtx, x, t, c, hasTrackMidiIn) } // draw the corresponding "fake cursors" for instrument-track-groups (for polyphony) - if hasTrackMidiIn { + if hasTrackMidiIn && y == cursor.Y { for _, trackIndex := range t.Model.TracksWithSameInstrumentAsCurrent() { - if x == trackIndex && y == cursor.Y { - te.paintColumnCell(gtx, x, t, cursorNeighborForTrackMidiInColor) + if x == trackIndex { + te.paintColumnCell(gtx, x, t, trackMidiInAdditionalColor, hasTrackMidiIn) } } + if t.Model.TrackForMidiVelIn().Equals(x) { + te.paintColumnCell(gtx, x, t, trackMidiVelInColor, hasTrackMidiIn) + } } // draw the pattern marker @@ -334,10 +384,10 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { return table.Layout(gtx) } -func (te *NoteEditor) paintColumnCell(gtx C, x int, t *Tracker, c color.NRGBA) { +func (te *NoteEditor) paintColumnCell(gtx C, x int, t *Tracker, c color.NRGBA, ignoreEffect bool) { cw := gtx.Constraints.Min.X cx := 0 - if t.Model.Notes().Effect(x) { + if t.Model.Notes().Effect(x) && !ignoreEffect { cw /= 2 if t.Model.Notes().LowNibble() { cx += cw @@ -406,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/numericupdown.go b/tracker/gioui/numericupdown.go index 3b788bcf..b1a86cdc 100644 --- a/tracker/gioui/numericupdown.go +++ b/tracker/gioui/numericupdown.go @@ -48,7 +48,9 @@ type NumericUpDownStyle struct { Tooltip component.Tooltip Width unit.Dp Height unit.Dp + Padding unit.Dp shaper text.Shaper + Hidden bool } func NewNumberInput(v tracker.Int) *NumberInput { @@ -56,6 +58,10 @@ func NewNumberInput(v tracker.Int) *NumberInput { } func NumericUpDown(th *material.Theme, number *NumberInput, tooltip string) NumericUpDownStyle { + return NumericUpDownPadded(th, number, tooltip, 0) +} + +func NumericUpDownPadded(th *material.Theme, number *NumberInput, tooltip string, padding int) NumericUpDownStyle { bgColor := th.Palette.Fg bgColor.R /= 4 bgColor.G /= 4 @@ -74,11 +80,22 @@ func NumericUpDown(th *material.Theme, number *NumberInput, tooltip string) Nume Tooltip: Tooltip(th, tooltip), Width: unit.Dp(70), Height: unit.Dp(20), + Padding: unit.Dp(padding), shaper: *th.Shaper, } } func (s *NumericUpDownStyle) Layout(gtx C) D { + if s.Hidden { + return D{} + } + if s.Padding <= 0 { + return s.layoutWithTooltip(gtx) + } + return layout.UniformInset(s.Padding).Layout(gtx, s.layoutWithTooltip) +} + +func (s *NumericUpDownStyle) layoutWithTooltip(gtx C) D { if s.Tooltip.Text.Text != "" { return s.NumberInput.tipArea.Layout(gtx, s.Tooltip, s.actualLayout) } diff --git a/tracker/gioui/songpanel.go b/tracker/gioui/songpanel.go index b3ddac8b..e08fa7f2 100644 --- a/tracker/gioui/songpanel.go +++ b/tracker/gioui/songpanel.go @@ -7,14 +7,13 @@ import ( "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" - "gioui.org/widget" "github.com/vsariola/sointu/tracker" "github.com/vsariola/sointu/version" "golang.org/x/exp/shiny/materialdesign/icons" ) type SongPanel struct { - MenuBar []widget.Clickable + MenuBar []Clickable Menus []Menu BPM *NumberInput RowsPerPattern *NumberInput @@ -57,7 +56,7 @@ type SongPanel struct { func NewSongPanel(model *tracker.Model) *SongPanel { ret := &SongPanel{ - MenuBar: make([]widget.Clickable, 3), + MenuBar: make([]Clickable, 3), Menus: make([]Menu, 3), BPM: NewNumberInput(model.BPM().Int()), RowsPerPattern: NewNumberInput(model.RowsPerPattern().Int()), diff --git a/tracker/gioui/theme.go b/tracker/gioui/theme.go index 1df08c7c..9beffc00 100644 --- a/tracker/gioui/theme.go +++ b/tracker/gioui/theme.go @@ -60,8 +60,10 @@ var activeLightSurfaceColor = color.NRGBA{R: 45, G: 45, B: 45, A: 255} var cursorColor = color.NRGBA{R: 100, G: 140, B: 255, A: 48} var selectionColor = color.NRGBA{R: 100, G: 140, B: 255, A: 12} var inactiveSelectionColor = color.NRGBA{R: 140, G: 140, B: 140, A: 16} -var cursorForTrackMidiInColor = color.NRGBA{R: 255, G: 100, B: 140, A: 48} -var cursorNeighborForTrackMidiInColor = color.NRGBA{R: 255, G: 100, B: 140, A: 24} + +var trackMidiInCurrentColor = color.NRGBA{R: 255, G: 100, B: 140, A: 48} +var trackMidiInAdditionalColor = withScaledAlpha(trackMidiInCurrentColor, 0.7) +var trackMidiVelInColor = withScaledAlpha(trackMidiInCurrentColor, 0.3) var errorColor = color.NRGBA{R: 207, G: 102, B: 121, A: 255} @@ -75,3 +77,13 @@ var dialogBgColor = color.NRGBA{R: 0, G: 0, B: 0, A: 224} var paramIsSendTargetColor = color.NRGBA{R: 120, G: 120, B: 210, A: 255} var paramValueInvalidColor = color.NRGBA{R: 120, G: 120, B: 120, A: 190} + +func withScaledAlpha(c color.NRGBA, factor float32) color.NRGBA { + A := factor * float32(c.A) + return color.NRGBA{ + R: c.R, + G: c.G, + B: c.B, + A: uint8(A), + } +} diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index f104924e..97c08278 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), @@ -103,7 +101,10 @@ func (t *Tracker) Main() { titleFooter := "" w := new(app.Window) w.Option(app.Title("Sointu Tracker")) - w.Option(app.Size(unit.Dp(800), unit.Dp(600))) + w.Option( + app.Size(unit.Dp(800), unit.Dp(600)), + app.Fullscreen.Option(), + ) t.InstrumentEditor.Focus() recoveryTicker := time.NewTicker(time.Second * 30) t.Explorer = explorer.NewExplorer(w) @@ -302,45 +303,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/gioui/unit_editor.go b/tracker/gioui/unit_editor.go index bd65463d..3ff8ed79 100644 --- a/tracker/gioui/unit_editor.go +++ b/tracker/gioui/unit_editor.go @@ -32,7 +32,7 @@ type UnitEditor struct { CopyUnitBtn *TipClickable ClearUnitBtn *ActionClickable DisableUnitBtn *BoolClickable - SelectTypeBtn *widget.Clickable + SelectTypeBtn *Clickable commentEditor *Editor caser cases.Caser @@ -47,7 +47,7 @@ func NewUnitEditor(m *tracker.Model) *UnitEditor { ClearUnitBtn: NewActionClickable(m.ClearUnit()), DisableUnitBtn: NewBoolClickable(m.UnitDisabled().Bool()), CopyUnitBtn: new(TipClickable), - SelectTypeBtn: new(widget.Clickable), + SelectTypeBtn: new(Clickable), commentEditor: NewEditor(widget.Editor{SingleLine: true, Submit: true}), sliderList: NewDragList(m.Params().List(), layout.Vertical), searchList: NewDragList(m.SearchResults().List(), layout.Vertical), @@ -236,9 +236,9 @@ func (pe *UnitEditor) command(e key.Event, t *Tracker) { type ParameterWidget struct { floatWidget widget.Float boolWidget widget.Bool - instrBtn widget.Clickable + instrBtn Clickable instrMenu Menu - unitBtn widget.Clickable + unitBtn Clickable unitMenu Menu Parameter tracker.Parameter tipArea component.TipArea @@ -332,7 +332,6 @@ func (p ParameterStyle) Layout(gtx C) D { gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40)) instrItems := make([]MenuItem, p.tracker.Instruments().Count()) for i := range instrItems { - i := i name, _, _, _ := p.tracker.Instruments().Item(i) instrItems[i].Text = name instrItems[i].IconBytes = icons.NavigationChevronRight diff --git a/tracker/gomidi/midi.go b/tracker/gomidi/midi.go index d2053816..dc320e1a 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) HasDeviceOpen() bool { - return c.currentIn != nil && c.currentIn.IsOpen() +func (c *RTMIDIContext) Constraints() tracker.PlayerProcessConstraints { + return c.currentConstraints } -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 bb085649..fb819e99 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 ee13c4aa..d9701178 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 89dbf19b..31a7c7f0 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 8e41bfce..c3046878 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 new file mode 100644 index 00000000..8d21cb94 --- /dev/null +++ b/tracker/optional_int.go @@ -0,0 +1,81 @@ +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, + // but don't necessarily have to. + OptionalInt struct { + optionalIntData + } + + optionalIntData interface { + Unpack() (int, bool) + Value() int + Range() intRange + + setValue(int) + unsetValue() + change(kind string) func() + } + + TrackMidiVelIn Model +) + +func (v OptionalInt) Set(value int, present bool) (ok bool) { + if !present { + v.unsetValue() + return true + } + // TODO: can we deduplicate this by referencing Int{...}.Set(value) ? + r := v.Range() + if v.Equals(value, present) || value < r.Min || value > r.Max { + return false + } + defer v.change("Set")() + v.setValue(value) + return true +} + +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 e018bb6d..452c04db 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 82b78d2c..e119ee3a 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 diff --git a/tracker/types/optional.go b/tracker/types/optional.go new file mode 100644 index 00000000..7b366908 --- /dev/null +++ b/tracker/types/optional.go @@ -0,0 +1,47 @@ +package types + +type ( + // OptionalInteger is the simple struct, not to be confused with tracker.OptionalInt. + // It implements the tracker.optionalIntData interface, without needing to know so. + OptionalInteger struct { + value int + exists bool + } +) + +func NewOptionalInteger(value int, exists bool) OptionalInteger { + return OptionalInteger{value, exists} +} + +func NewOptionalIntegerOf(value int) OptionalInteger { + return OptionalInteger{ + value: value, + exists: true, + } +} + +func NewEmptyOptionalInteger() OptionalInteger { + // could also just use OptionalInteger{} + return OptionalInteger{ + exists: false, + } +} + +func (i OptionalInteger) Unpack() (int, bool) { + return i.value, i.exists +} + +func (i OptionalInteger) Value() int { + if !i.exists { + panic("Access value of empty OptionalInteger") + } + return i.value +} + +func (i OptionalInteger) Empty() bool { + return !i.exists +} + +func (i OptionalInteger) Equals(value int) bool { + return i.exists && i.value == value +} From 0534ec4e85a2ad717b1afe409c319cafb26fcb1c Mon Sep 17 00:00:00 2001 From: qm210 Date: Fri, 22 Nov 2024 13:20:35 +0100 Subject: [PATCH 2/5] fix --- cmd/sointu-vsti/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/sointu-vsti/main.go b/cmd/sointu-vsti/main.go index 3f449033..a08fc7d4 100644 --- a/cmd/sointu-vsti/main.go +++ b/cmd/sointu-vsti/main.go @@ -109,7 +109,7 @@ 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] } From ad690c7697afff7a214b025f65f3775fc46e71e0 Mon Sep 17 00:00:00 2001 From: qm210 Date: Fri, 22 Nov 2024 13:48:25 +0100 Subject: [PATCH 3/5] feat: UI work to facilitate future improvements in midi-into-track-input --- tracker/gioui/buttons.go | 17 +++++++++++- tracker/gioui/iconcache.go | 3 +++ tracker/gioui/label.go | 14 +++++++++- tracker/gioui/menu.go | 21 ++++++++------- tracker/gioui/note_editor.go | 22 +++++++--------- tracker/gioui/numericupdown.go | 17 ++++++++++++ tracker/gioui/songpanel.go | 5 ++-- tracker/gioui/theme.go | 16 ++++++++++-- tracker/gioui/tracker.go | 18 ++++++++----- tracker/gioui/unit_editor.go | 9 +++---- tracker/optional_int.go | 42 ++++++++++++++++++++++++++++++ tracker/types/optional.go | 47 ++++++++++++++++++++++++++++++++++ 12 files changed, 191 insertions(+), 40 deletions(-) create mode 100644 tracker/optional_int.go create mode 100644 tracker/types/optional.go diff --git a/tracker/gioui/buttons.go b/tracker/gioui/buttons.go index 024ab10c..11d337de 100644 --- a/tracker/gioui/buttons.go +++ b/tracker/gioui/buttons.go @@ -44,6 +44,14 @@ type ( TipArea component.TipArea Bool tracker.Bool } + + MenuClickable struct { + Clickable Clickable + menu Menu + Selected tracker.OptionalInt + TipArea component.TipArea + Tooltip component.Tooltip + } ) func NewActionClickable(a tracker.Action) *ActionClickable { @@ -136,7 +144,10 @@ func ToggleButton(gtx C, th *material.Theme, b *BoolClickable, text string) Butt ret := Button(th, &b.Clickable, text) ret.Background = transparent ret.Inset = layout.UniformInset(unit.Dp(6)) - if b.Bool.Value() { + if !b.Bool.Enabled() { + ret.Color = disabledTextColor + ret.Background = transparent + } else if b.Bool.Value() { ret.Color = th.Palette.ContrastFg ret.Background = th.Palette.Fg } else { @@ -287,6 +298,7 @@ type ButtonStyle struct { Inset layout.Inset Button *Clickable shaper *text.Shaper + Hidden bool } type ButtonLayoutStyle struct { @@ -351,6 +363,9 @@ func (b ButtonStyle) Layout(gtx layout.Context) layout.Dimensions { CornerRadius: b.CornerRadius, Button: b.Button, }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + if b.Hidden { + return layout.Dimensions{} + } return b.Inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { colMacro := op.Record(gtx.Ops) paint.ColorOp{Color: b.Color}.Add(gtx.Ops) diff --git a/tracker/gioui/iconcache.go b/tracker/gioui/iconcache.go index b79eadb7..f663a2a2 100644 --- a/tracker/gioui/iconcache.go +++ b/tracker/gioui/iconcache.go @@ -10,6 +10,9 @@ var iconCache = map[*byte]*widget.Icon{} // widgetForIcon returns a widget for IconVG data, but caching the results func widgetForIcon(icon []byte) *widget.Icon { + if icon == nil { + return nil + } if widget, ok := iconCache[&icon[0]]; ok { return widget } diff --git a/tracker/gioui/label.go b/tracker/gioui/label.go index 1c2081a1..5e789935 100644 --- a/tracker/gioui/label.go +++ b/tracker/gioui/label.go @@ -46,5 +46,17 @@ func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions { } func Label(str string, color color.NRGBA, shaper *text.Shaper) layout.Widget { - return LabelStyle{Text: str, Color: color, ShadeColor: black, Font: labelDefaultFont, FontSize: labelDefaultFontSize, Alignment: layout.W, Shaper: shaper}.Layout + return SizedLabel(str, color, shaper, labelDefaultFontSize) +} + +func SizedLabel(str string, color color.NRGBA, shaper *text.Shaper, fontSize unit.Sp) layout.Widget { + return LabelStyle{ + Text: str, + Color: color, + ShadeColor: black, + Font: labelDefaultFont, + FontSize: fontSize, + Alignment: layout.W, + Shaper: shaper, + }.Layout } diff --git a/tracker/gioui/menu.go b/tracker/gioui/menu.go index 3597d31e..7c552b92 100644 --- a/tracker/gioui/menu.go +++ b/tracker/gioui/menu.go @@ -12,8 +12,6 @@ import ( "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" - "gioui.org/widget" - "gioui.org/widget/material" "github.com/vsariola/sointu/tracker" ) @@ -103,12 +101,12 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D { } icon := widgetForIcon(item.IconBytes) iconColor := m.IconColor - if !item.Doer.Allowed() { - iconColor = mediumEmphasisTextColor - } iconInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(6)} textLabel := LabelStyle{Text: item.Text, FontSize: m.FontSize, Color: m.TextColor, Shaper: m.Shaper} if !item.Doer.Allowed() { + // note: might be a bug in gioui, but for iconColor = mediumEmphasisTextColor + // this does not render the icon at all. other colors seem to work fine. + iconColor = disabledTextColor textLabel.Color = mediumEmphasisTextColor } shortcutLabel := LabelStyle{Text: item.ShortcutText, FontSize: m.FontSize, Color: m.ShortCutColor, Shaper: m.Shaper} @@ -116,13 +114,18 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D { dims := layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { return iconInset.Layout(gtx, func(gtx C) D { - p := gtx.Dp(unit.Dp(m.IconSize)) + p := gtx.Dp(m.IconSize) gtx.Constraints.Min = image.Pt(p, p) + if icon == nil { + return D{Size: gtx.Constraints.Min} + } return icon.Layout(gtx, iconColor) }) }), layout.Rigid(textLabel.Layout), - layout.Flexed(1, func(gtx C) D { return D{Size: image.Pt(gtx.Constraints.Max.X, 1)} }), + layout.Flexed(1, func(gtx C) D { + return D{Size: image.Pt(gtx.Constraints.Max.X, 1)} + }), layout.Rigid(func(gtx C) D { return shortcutInset.Layout(gtx, shortcutLabel.Layout) }), @@ -168,14 +171,14 @@ func PopupMenu(menu *Menu, shaper *text.Shaper) MenuStyle { } } -func (tr *Tracker) layoutMenu(gtx C, title string, clickable *widget.Clickable, menu *Menu, width unit.Dp, items ...MenuItem) layout.Widget { +func (tr *Tracker) layoutMenu(gtx C, title string, clickable *Clickable, menu *Menu, width unit.Dp, items ...MenuItem) layout.Widget { for clickable.Clicked(gtx) { menu.Visible = true } m := PopupMenu(menu, tr.Theme.Shaper) return func(gtx C) D { defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() - titleBtn := material.Button(tr.Theme, clickable, title) + titleBtn := Button(tr.Theme, clickable, title) titleBtn.Color = white titleBtn.Background = transparent titleBtn.CornerRadius = unit.Dp(0) diff --git a/tracker/gioui/note_editor.go b/tracker/gioui/note_editor.go index 00068782..764b5440 100644 --- a/tracker/gioui/note_editor.go +++ b/tracker/gioui/note_editor.go @@ -158,11 +158,7 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D { deleteTrackBtnStyle := ActionIcon(gtx, t.Theme, te.DeleteTrackBtn, icons.ActionDelete, te.deleteTrackHint) splitTrackBtnStyle := ActionIcon(gtx, t.Theme, te.SplitTrackBtn, icons.CommunicationCallSplit, te.splitTrackHint) newTrackBtnStyle := ActionIcon(gtx, t.Theme, te.NewTrackBtn, icons.ContentAdd, te.addTrackHint) - in := layout.UniformInset(unit.Dp(1)) - voiceUpDown := func(gtx C) D { - numStyle := NumericUpDown(t.Theme, te.TrackVoices, "Number of voices for this track") - return in.Layout(gtx, numStyle.Layout) - } + voiceUpDown := NumericUpDownPadded(t.Theme, te.TrackVoices, "Number of voices for this track", 1) 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") @@ -176,7 +172,7 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D { layout.Rigid(effectBtnStyle.Layout), layout.Rigid(uniqueBtnStyle.Layout), layout.Rigid(Label(" Voices:", white, t.Theme.Shaper)), - layout.Rigid(voiceUpDown), + layout.Rigid(voiceUpDown.Layout), layout.Rigid(splitTrackBtnStyle.Layout), layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Rigid(midiInBtnStyle.Layout), @@ -287,15 +283,15 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { c = cursorColor } if hasTrackMidiIn { - c = cursorForTrackMidiInColor + c = trackMidiInCurrentColor } - te.paintColumnCell(gtx, x, t, c) + te.paintColumnCell(gtx, x, t, c, hasTrackMidiIn) } // draw the corresponding "fake cursors" for instrument-track-groups (for polyphony) - if hasTrackMidiIn { + if hasTrackMidiIn && y == cursor.Y { for _, trackIndex := range t.Model.TracksWithSameInstrumentAsCurrent() { - if x == trackIndex && y == cursor.Y { - te.paintColumnCell(gtx, x, t, cursorNeighborForTrackMidiInColor) + if x == trackIndex { + te.paintColumnCell(gtx, x, t, trackMidiInAdditionalColor, hasTrackMidiIn) } } } @@ -334,10 +330,10 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { return table.Layout(gtx) } -func (te *NoteEditor) paintColumnCell(gtx C, x int, t *Tracker, c color.NRGBA) { +func (te *NoteEditor) paintColumnCell(gtx C, x int, t *Tracker, c color.NRGBA, ignoreEffect bool) { cw := gtx.Constraints.Min.X cx := 0 - if t.Model.Notes().Effect(x) { + if t.Model.Notes().Effect(x) && !ignoreEffect { cw /= 2 if t.Model.Notes().LowNibble() { cx += cw diff --git a/tracker/gioui/numericupdown.go b/tracker/gioui/numericupdown.go index 3b788bcf..b1a86cdc 100644 --- a/tracker/gioui/numericupdown.go +++ b/tracker/gioui/numericupdown.go @@ -48,7 +48,9 @@ type NumericUpDownStyle struct { Tooltip component.Tooltip Width unit.Dp Height unit.Dp + Padding unit.Dp shaper text.Shaper + Hidden bool } func NewNumberInput(v tracker.Int) *NumberInput { @@ -56,6 +58,10 @@ func NewNumberInput(v tracker.Int) *NumberInput { } func NumericUpDown(th *material.Theme, number *NumberInput, tooltip string) NumericUpDownStyle { + return NumericUpDownPadded(th, number, tooltip, 0) +} + +func NumericUpDownPadded(th *material.Theme, number *NumberInput, tooltip string, padding int) NumericUpDownStyle { bgColor := th.Palette.Fg bgColor.R /= 4 bgColor.G /= 4 @@ -74,11 +80,22 @@ func NumericUpDown(th *material.Theme, number *NumberInput, tooltip string) Nume Tooltip: Tooltip(th, tooltip), Width: unit.Dp(70), Height: unit.Dp(20), + Padding: unit.Dp(padding), shaper: *th.Shaper, } } func (s *NumericUpDownStyle) Layout(gtx C) D { + if s.Hidden { + return D{} + } + if s.Padding <= 0 { + return s.layoutWithTooltip(gtx) + } + return layout.UniformInset(s.Padding).Layout(gtx, s.layoutWithTooltip) +} + +func (s *NumericUpDownStyle) layoutWithTooltip(gtx C) D { if s.Tooltip.Text.Text != "" { return s.NumberInput.tipArea.Layout(gtx, s.Tooltip, s.actualLayout) } diff --git a/tracker/gioui/songpanel.go b/tracker/gioui/songpanel.go index b3ddac8b..e08fa7f2 100644 --- a/tracker/gioui/songpanel.go +++ b/tracker/gioui/songpanel.go @@ -7,14 +7,13 @@ import ( "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" - "gioui.org/widget" "github.com/vsariola/sointu/tracker" "github.com/vsariola/sointu/version" "golang.org/x/exp/shiny/materialdesign/icons" ) type SongPanel struct { - MenuBar []widget.Clickable + MenuBar []Clickable Menus []Menu BPM *NumberInput RowsPerPattern *NumberInput @@ -57,7 +56,7 @@ type SongPanel struct { func NewSongPanel(model *tracker.Model) *SongPanel { ret := &SongPanel{ - MenuBar: make([]widget.Clickable, 3), + MenuBar: make([]Clickable, 3), Menus: make([]Menu, 3), BPM: NewNumberInput(model.BPM().Int()), RowsPerPattern: NewNumberInput(model.RowsPerPattern().Int()), diff --git a/tracker/gioui/theme.go b/tracker/gioui/theme.go index 1df08c7c..9beffc00 100644 --- a/tracker/gioui/theme.go +++ b/tracker/gioui/theme.go @@ -60,8 +60,10 @@ var activeLightSurfaceColor = color.NRGBA{R: 45, G: 45, B: 45, A: 255} var cursorColor = color.NRGBA{R: 100, G: 140, B: 255, A: 48} var selectionColor = color.NRGBA{R: 100, G: 140, B: 255, A: 12} var inactiveSelectionColor = color.NRGBA{R: 140, G: 140, B: 140, A: 16} -var cursorForTrackMidiInColor = color.NRGBA{R: 255, G: 100, B: 140, A: 48} -var cursorNeighborForTrackMidiInColor = color.NRGBA{R: 255, G: 100, B: 140, A: 24} + +var trackMidiInCurrentColor = color.NRGBA{R: 255, G: 100, B: 140, A: 48} +var trackMidiInAdditionalColor = withScaledAlpha(trackMidiInCurrentColor, 0.7) +var trackMidiVelInColor = withScaledAlpha(trackMidiInCurrentColor, 0.3) var errorColor = color.NRGBA{R: 207, G: 102, B: 121, A: 255} @@ -75,3 +77,13 @@ var dialogBgColor = color.NRGBA{R: 0, G: 0, B: 0, A: 224} var paramIsSendTargetColor = color.NRGBA{R: 120, G: 120, B: 210, A: 255} var paramValueInvalidColor = color.NRGBA{R: 120, G: 120, B: 120, A: 190} + +func withScaledAlpha(c color.NRGBA, factor float32) color.NRGBA { + A := factor * float32(c.A) + return color.NRGBA{ + R: c.R, + G: c.G, + B: c.B, + A: uint8(A), + } +} diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index f104924e..b6f540eb 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -101,9 +101,7 @@ func NewTracker(model *tracker.Model) *Tracker { func (t *Tracker) Main() { titleFooter := "" - w := new(app.Window) - w.Option(app.Title("Sointu Tracker")) - w.Option(app.Size(unit.Dp(800), unit.Dp(600))) + w := NewWindow() t.InstrumentEditor.Focus() recoveryTicker := time.NewTicker(time.Second * 30) t.Explorer = explorer.NewExplorer(w) @@ -127,9 +125,7 @@ func (t *Tracker) Main() { } if !t.Quitted() { // TODO: uh oh, there's no way of canceling the destroyevent in gioui? so we create a new window just to show the dialog - w = new(app.Window) - w.Option(app.Title("Sointu Tracker")) - w.Option(app.Size(unit.Dp(800), unit.Dp(600))) + w = NewWindow() t.Explorer = explorer.NewExplorer(w) go eventLoop(w, events, acks) } @@ -165,6 +161,16 @@ func (t *Tracker) Main() { t.quitWG.Done() } +func NewWindow() *app.Window { + w := new(app.Window) + w.Option(app.Title("Sointu Tracker")) + w.Option( + app.Size(unit.Dp(800), unit.Dp(600)), + app.Fullscreen.Option(), + ) + return w +} + func eventLoop(w *app.Window, events chan<- event.Event, acks <-chan struct{}) { // Iterate window events, sending each to the old event loop and waiting for // a signal that processing is complete before iterating again. diff --git a/tracker/gioui/unit_editor.go b/tracker/gioui/unit_editor.go index bd65463d..3ff8ed79 100644 --- a/tracker/gioui/unit_editor.go +++ b/tracker/gioui/unit_editor.go @@ -32,7 +32,7 @@ type UnitEditor struct { CopyUnitBtn *TipClickable ClearUnitBtn *ActionClickable DisableUnitBtn *BoolClickable - SelectTypeBtn *widget.Clickable + SelectTypeBtn *Clickable commentEditor *Editor caser cases.Caser @@ -47,7 +47,7 @@ func NewUnitEditor(m *tracker.Model) *UnitEditor { ClearUnitBtn: NewActionClickable(m.ClearUnit()), DisableUnitBtn: NewBoolClickable(m.UnitDisabled().Bool()), CopyUnitBtn: new(TipClickable), - SelectTypeBtn: new(widget.Clickable), + SelectTypeBtn: new(Clickable), commentEditor: NewEditor(widget.Editor{SingleLine: true, Submit: true}), sliderList: NewDragList(m.Params().List(), layout.Vertical), searchList: NewDragList(m.SearchResults().List(), layout.Vertical), @@ -236,9 +236,9 @@ func (pe *UnitEditor) command(e key.Event, t *Tracker) { type ParameterWidget struct { floatWidget widget.Float boolWidget widget.Bool - instrBtn widget.Clickable + instrBtn Clickable instrMenu Menu - unitBtn widget.Clickable + unitBtn Clickable unitMenu Menu Parameter tracker.Parameter tipArea component.TipArea @@ -332,7 +332,6 @@ func (p ParameterStyle) Layout(gtx C) D { gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40)) instrItems := make([]MenuItem, p.tracker.Instruments().Count()) for i := range instrItems { - i := i name, _, _, _ := p.tracker.Instruments().Item(i) instrItems[i].Text = name instrItems[i].IconBytes = icons.NavigationChevronRight diff --git a/tracker/optional_int.go b/tracker/optional_int.go new file mode 100644 index 00000000..dbdfaa36 --- /dev/null +++ b/tracker/optional_int.go @@ -0,0 +1,42 @@ +package tracker + +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, + // but don't necessarily have to. + OptionalInt struct { + optionalIntData + } + + optionalIntData interface { + Unpack() (int, bool) + Value() int + Range() intRange + + setValue(int) + unsetValue() + change(kind string) func() + } + + TrackMidiVelIn Model +) + +func (v OptionalInt) Set(value int, present bool) (ok bool) { + if !present { + v.unsetValue() + return true + } + // TODO: can we deduplicate this by referencing Int{...}.Set(value) ? + r := v.Range() + if v.Equals(value, present) || value < r.Min || value > r.Max { + return false + } + defer v.change("Set")() + v.setValue(value) + return true +} + +func (v OptionalInt) Equals(value int, present bool) bool { + oldValue, oldPresent := v.Unpack() + return value == oldValue && present == oldPresent +} diff --git a/tracker/types/optional.go b/tracker/types/optional.go new file mode 100644 index 00000000..7b366908 --- /dev/null +++ b/tracker/types/optional.go @@ -0,0 +1,47 @@ +package types + +type ( + // OptionalInteger is the simple struct, not to be confused with tracker.OptionalInt. + // It implements the tracker.optionalIntData interface, without needing to know so. + OptionalInteger struct { + value int + exists bool + } +) + +func NewOptionalInteger(value int, exists bool) OptionalInteger { + return OptionalInteger{value, exists} +} + +func NewOptionalIntegerOf(value int) OptionalInteger { + return OptionalInteger{ + value: value, + exists: true, + } +} + +func NewEmptyOptionalInteger() OptionalInteger { + // could also just use OptionalInteger{} + return OptionalInteger{ + exists: false, + } +} + +func (i OptionalInteger) Unpack() (int, bool) { + return i.value, i.exists +} + +func (i OptionalInteger) Value() int { + if !i.exists { + panic("Access value of empty OptionalInteger") + } + return i.value +} + +func (i OptionalInteger) Empty() bool { + return !i.exists +} + +func (i OptionalInteger) Equals(value int) bool { + return i.exists && i.value == value +} From 49a259cf83564edb00584f5e88655b2ee4f32dbe Mon Sep 17 00:00:00 2001 From: qm210 Date: Fri, 22 Nov 2024 02:38:57 +0100 Subject: [PATCH 4/5] feat: input midi velocity into a separate track (includes many structural changes) --- CHANGELOG.md | 7 ++- cmd/sointu-track/main.go | 2 +- tracker/bool.go | 13 ++-- tracker/derived.go | 45 ++++++++++++-- tracker/gioui/note_editor.go | 72 ++++++++++++++++------ tracker/gioui/tracker.go | 46 ++------------ tracker/gomidi/midi.go | 114 +++++++++++++++++++++++------------ tracker/int.go | 1 + tracker/list.go | 2 +- tracker/model.go | 32 +++++++++- tracker/model_test.go | 9 ++- tracker/optional_int.go | 39 ++++++++++++ tracker/player.go | 104 ++++++++++++++++++++++++-------- tracker/table.go | 4 +- 14 files changed, 348 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d57c9b71..bdbc75cd 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 794a6fa7..2bd6d5fe 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 3c32b5ae..254b8ead 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 95a4a3c4..d2596718 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 764b5440..2a2249ed 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 b6f540eb..74b164f6 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 d2053816..dc320e1a 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) HasDeviceOpen() bool { - return c.currentIn != nil && c.currentIn.IsOpen() +func (c *RTMIDIContext) Constraints() tracker.PlayerProcessConstraints { + return c.currentConstraints } -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 bb085649..fb819e99 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 ee13c4aa..d9701178 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 89dbf19b..31a7c7f0 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 8e41bfce..c3046878 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 dbdfaa36..8d21cb94 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 e018bb6d..452c04db 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 82b78d2c..e119ee3a 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 From 20a1769c83c9187af8d7d033c28f550529868dbc Mon Sep 17 00:00:00 2001 From: qm210 Date: Fri, 22 Nov 2024 13:20:35 +0100 Subject: [PATCH 5/5] fix --- cmd/sointu-vsti/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/sointu-vsti/main.go b/cmd/sointu-vsti/main.go index 3f449033..a08fc7d4 100644 --- a/cmd/sointu-vsti/main.go +++ b/cmd/sointu-vsti/main.go @@ -109,7 +109,7 @@ 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] }