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

This commit is contained in:
qm210 2024-11-22 02:38:57 +01:00
parent 4169356845
commit 4eebfd9f48
23 changed files with 531 additions and 177 deletions

View File

@ -16,8 +16,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Dragging mouse to select rectangles in the tables - Dragging mouse to select rectangles in the tables
- The standalone tracker can open a MIDI port for receiving MIDI notes - The standalone tracker can open a MIDI port for receiving MIDI notes
([#166][i166]) ([#166][i166])
- The note editor has a button to allow entering notes by MIDI. Polyphony is - Direct Midi Input: The note editor has a button to allow entering notes
supported if there are tracks available. ([#170][i170]) by MIDI (i.e. while not recording). ([#170][i170])
- Polyphony is supported if there are tracks available.
- The velocity of the last MIDI input note can be sent to another track
(selector next to the MIDI button in the note editor), optionally.
- Units can have comments, to make it easier to distinguish between units of - Units can have comments, to make it easier to distinguish between units of
same type within an instrument. These comments are also shown when choosing same type within an instrument. These comments are also shown when choosing
the send target. ([#114][i114]) the send target. ([#114][i114])

View File

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

View File

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

View File

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

View File

@ -44,6 +44,14 @@ type (
TipArea component.TipArea TipArea component.TipArea
Bool tracker.Bool Bool tracker.Bool
} }
MenuClickable struct {
Clickable Clickable
menu Menu
Selected tracker.OptionalInt
TipArea component.TipArea
Tooltip component.Tooltip
}
) )
func NewActionClickable(a tracker.Action) *ActionClickable { 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 := Button(th, &b.Clickable, text)
ret.Background = transparent ret.Background = transparent
ret.Inset = layout.UniformInset(unit.Dp(6)) 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.Color = th.Palette.ContrastFg
ret.Background = th.Palette.Fg ret.Background = th.Palette.Fg
} else { } else {
@ -287,6 +298,7 @@ type ButtonStyle struct {
Inset layout.Inset Inset layout.Inset
Button *Clickable Button *Clickable
shaper *text.Shaper shaper *text.Shaper
Hidden bool
} }
type ButtonLayoutStyle struct { type ButtonLayoutStyle struct {
@ -351,6 +363,9 @@ func (b ButtonStyle) Layout(gtx layout.Context) layout.Dimensions {
CornerRadius: b.CornerRadius, CornerRadius: b.CornerRadius,
Button: b.Button, Button: b.Button,
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { }.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 { return b.Inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
colMacro := op.Record(gtx.Ops) colMacro := op.Record(gtx.Ops)
paint.ColorOp{Color: b.Color}.Add(gtx.Ops) paint.ColorOp{Color: b.Color}.Add(gtx.Ops)

View File

@ -10,6 +10,9 @@ var iconCache = map[*byte]*widget.Icon{}
// widgetForIcon returns a widget for IconVG data, but caching the results // widgetForIcon returns a widget for IconVG data, but caching the results
func widgetForIcon(icon []byte) *widget.Icon { func widgetForIcon(icon []byte) *widget.Icon {
if icon == nil {
return nil
}
if widget, ok := iconCache[&icon[0]]; ok { if widget, ok := iconCache[&icon[0]]; ok {
return widget return widget
} }

View File

@ -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 { 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
} }

View File

@ -12,8 +12,6 @@ import (
"gioui.org/op/paint" "gioui.org/op/paint"
"gioui.org/text" "gioui.org/text"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker" "github.com/vsariola/sointu/tracker"
) )
@ -103,12 +101,12 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D {
} }
icon := widgetForIcon(item.IconBytes) icon := widgetForIcon(item.IconBytes)
iconColor := m.IconColor iconColor := m.IconColor
if !item.Doer.Allowed() {
iconColor = mediumEmphasisTextColor
}
iconInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(6)} 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} textLabel := LabelStyle{Text: item.Text, FontSize: m.FontSize, Color: m.TextColor, Shaper: m.Shaper}
if !item.Doer.Allowed() { 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 textLabel.Color = mediumEmphasisTextColor
} }
shortcutLabel := LabelStyle{Text: item.ShortcutText, FontSize: m.FontSize, Color: m.ShortCutColor, Shaper: m.Shaper} 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, dims := layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D { layout.Rigid(func(gtx C) D {
return iconInset.Layout(gtx, 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) gtx.Constraints.Min = image.Pt(p, p)
if icon == nil {
return D{Size: gtx.Constraints.Min}
}
return icon.Layout(gtx, iconColor) return icon.Layout(gtx, iconColor)
}) })
}), }),
layout.Rigid(textLabel.Layout), 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 { layout.Rigid(func(gtx C) D {
return shortcutInset.Layout(gtx, shortcutLabel.Layout) 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) { for clickable.Clicked(gtx) {
menu.Visible = true menu.Visible = true
} }
m := PopupMenu(menu, tr.Theme.Shaper) m := PopupMenu(menu, tr.Theme.Shaper)
return func(gtx C) D { return func(gtx C) D {
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() 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.Color = white
titleBtn.Background = transparent titleBtn.Background = transparent
titleBtn.CornerRadius = unit.Dp(0) titleBtn.CornerRadius = unit.Dp(0)

View File

@ -2,6 +2,7 @@ package gioui
import ( import (
"fmt" "fmt"
"gioui.org/x/component"
"image" "image"
"image/color" "image/color"
"strconv" "strconv"
@ -64,6 +65,7 @@ type NoteEditor struct {
EffectBtn *BoolClickable EffectBtn *BoolClickable
UniqueBtn *BoolClickable UniqueBtn *BoolClickable
TrackMidiInBtn *BoolClickable TrackMidiInBtn *BoolClickable
TrackForMidiVelIn *MenuClickable
scrollTable *ScrollTable scrollTable *ScrollTable
eventFilters []event.Filter eventFilters []event.Filter
@ -88,6 +90,7 @@ func NewNoteEditor(model *tracker.Model) *NoteEditor {
EffectBtn: NewBoolClickable(model.Effect().Bool()), EffectBtn: NewBoolClickable(model.Effect().Bool()),
UniqueBtn: NewBoolClickable(model.UniquePatterns().Bool()), UniqueBtn: NewBoolClickable(model.UniquePatterns().Bool()),
TrackMidiInBtn: NewBoolClickable(model.TrackMidiIn().Bool()), TrackMidiInBtn: NewBoolClickable(model.TrackMidiIn().Bool()),
TrackForMidiVelIn: &MenuClickable{Selected: model.TrackForMidiVelIn().OptionalInt()},
scrollTable: NewScrollTable( scrollTable: NewScrollTable(
model.Notes().Table(), model.Notes().Table(),
model.Tracks().List(), model.Tracks().List(),
@ -158,14 +161,12 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D {
deleteTrackBtnStyle := ActionIcon(gtx, t.Theme, te.DeleteTrackBtn, icons.ActionDelete, te.deleteTrackHint) deleteTrackBtnStyle := ActionIcon(gtx, t.Theme, te.DeleteTrackBtn, icons.ActionDelete, te.deleteTrackHint)
splitTrackBtnStyle := ActionIcon(gtx, t.Theme, te.SplitTrackBtn, icons.CommunicationCallSplit, te.splitTrackHint) splitTrackBtnStyle := ActionIcon(gtx, t.Theme, te.SplitTrackBtn, icons.CommunicationCallSplit, te.splitTrackHint)
newTrackBtnStyle := ActionIcon(gtx, t.Theme, te.NewTrackBtn, icons.ContentAdd, te.addTrackHint) newTrackBtnStyle := ActionIcon(gtx, t.Theme, te.NewTrackBtn, icons.ContentAdd, te.addTrackHint)
in := layout.UniformInset(unit.Dp(1)) voiceUpDown := NumericUpDownPadded(t.Theme, te.TrackVoices, "Number of voices for this track", 1)
voiceUpDown := func(gtx C) D {
numStyle := NumericUpDown(t.Theme, te.TrackVoices, "Number of voices for this track")
return in.Layout(gtx, numStyle.Layout)
}
effectBtnStyle := ToggleButton(gtx, t.Theme, te.EffectBtn, "Hex") effectBtnStyle := ToggleButton(gtx, t.Theme, te.EffectBtn, "Hex")
uniqueBtnStyle := ToggleIcon(gtx, t.Theme, te.UniqueBtn, icons.ToggleStarBorder, icons.ToggleStar, te.uniqueOffTip, te.uniqueOnTip) uniqueBtnStyle := ToggleIcon(gtx, t.Theme, te.UniqueBtn, icons.ToggleStarBorder, icons.ToggleStar, te.uniqueOffTip, te.uniqueOnTip)
midiInBtnStyle := ToggleButton(gtx, t.Theme, te.TrackMidiInBtn, "MIDI") midiInBtnStyle := ToggleButton(gtx, t.Theme, te.TrackMidiInBtn, "MIDI")
midiInBtnStyle.Hidden = !t.HasAnyMidiInput()
trackForMidiVelInSelector := te.layoutMidiVelInTrackSelector(t, " vel:")
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D { return layout.Dimensions{Size: image.Pt(gtx.Dp(unit.Dp(12)), 0)} }), layout.Rigid(func(gtx C) D { return layout.Dimensions{Size: image.Pt(gtx.Dp(unit.Dp(12)), 0)} }),
layout.Rigid(addSemitoneBtnStyle.Layout), layout.Rigid(addSemitoneBtnStyle.Layout),
@ -176,16 +177,62 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D {
layout.Rigid(effectBtnStyle.Layout), layout.Rigid(effectBtnStyle.Layout),
layout.Rigid(uniqueBtnStyle.Layout), layout.Rigid(uniqueBtnStyle.Layout),
layout.Rigid(Label(" Voices:", white, t.Theme.Shaper)), layout.Rigid(Label(" Voices:", white, t.Theme.Shaper)),
layout.Rigid(voiceUpDown), layout.Rigid(voiceUpDown.Layout),
layout.Rigid(splitTrackBtnStyle.Layout), layout.Rigid(splitTrackBtnStyle.Layout),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(midiInBtnStyle.Layout), layout.Rigid(midiInBtnStyle.Layout),
layout.Rigid(trackForMidiVelInSelector),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(deleteTrackBtnStyle.Layout), layout.Rigid(deleteTrackBtnStyle.Layout),
layout.Rigid(newTrackBtnStyle.Layout)) layout.Rigid(newTrackBtnStyle.Layout))
}) })
} }
func (te *NoteEditor) layoutMidiVelInTrackSelector(t *Tracker, label string) func(gtx C) D {
if !t.HasAnyMidiInput() {
return layout.Spacer{}.Layout
}
tracks := t.Model.Tracks().List()
trackItems := make([]MenuItem, tracks.Count()+1)
trackForMidiVelIn := t.Model.TrackForMidiVelIn()
offText := "\u2014off\u2014"
currentText := offText
for i := range trackItems {
trackItems[i] = MenuItem{
Text: offText,
Doer: tracker.Check(
func() { trackForMidiVelIn.OptionalInt().Set(i-1, i > 0) },
func() bool { return t.Model.CanUseTrackForMidiVelInput(i - 1) },
),
}
if i > 0 {
trackItems[i].Text = fmt.Sprintf("%d %s", i-1, t.Model.TrackTitle(i-1))
}
if trackForMidiVelIn.OptionalInt().Equals(i-1, i > 0) {
trackItems[i].IconBytes = icons.NavigationChevronRight
if trackForMidiVelIn.IsValid() {
currentText = trackItems[i].Text
}
}
}
return func(gtx C) D {
tooltip := component.PlatformTooltip(t.Theme, "Record MIDI VEL into chosen track. This can not be one of the selected tracks (where MIDI Notes go).")
return te.TrackForMidiVelIn.TipArea.Layout(gtx, tooltip, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(SizedLabel(label, white, t.Theme.Shaper, unit.Sp(12))),
layout.Rigid(t.layoutMenu(gtx,
currentText,
&te.TrackForMidiVelIn.Clickable,
&te.TrackForMidiVelIn.menu,
unit.Dp(200),
trackItems...,
)),
)
})
}
}
const baseNote = 24 const baseNote = 24
var notes = []string{ var notes = []string{
@ -287,17 +334,20 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
c = cursorColor c = cursorColor
} }
if hasTrackMidiIn { 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) // draw the corresponding "fake cursors" for instrument-track-groups (for polyphony)
if hasTrackMidiIn { if hasTrackMidiIn && y == cursor.Y {
for _, trackIndex := range t.Model.TracksWithSameInstrumentAsCurrent() { for _, trackIndex := range t.Model.TracksWithSameInstrumentAsCurrent() {
if x == trackIndex && y == cursor.Y { if x == trackIndex {
te.paintColumnCell(gtx, x, t, cursorNeighborForTrackMidiInColor) te.paintColumnCell(gtx, x, t, trackMidiInAdditionalColor, hasTrackMidiIn)
} }
} }
if t.Model.TrackForMidiVelIn().Equals(x) {
te.paintColumnCell(gtx, x, t, trackMidiVelInColor, hasTrackMidiIn)
}
} }
// draw the pattern marker // draw the pattern marker
@ -334,10 +384,10 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
return table.Layout(gtx) 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 cw := gtx.Constraints.Min.X
cx := 0 cx := 0
if t.Model.Notes().Effect(x) { if t.Model.Notes().Effect(x) && !ignoreEffect {
cw /= 2 cw /= 2
if t.Model.Notes().LowNibble() { if t.Model.Notes().LowNibble() {
cx += cw cx += cw
@ -406,21 +456,3 @@ func (te *NoteEditor) finishNoteInsert(t *Tracker, note byte, keyName key.Name)
t.KeyPlaying[keyName] = t.TrackNoteOn(trk, note) t.KeyPlaying[keyName] = t.TrackNoteOn(trk, note)
} }
} }
func (te *NoteEditor) HandleMidiInput(t *Tracker) {
inputDeactivated := !t.Model.TrackMidiIn().Value()
if inputDeactivated {
return
}
te.scrollTable.Table.SetCursor2(te.scrollTable.Table.Cursor())
remaining := t.Model.CountNextTracksForCurrentInstrument()
for i, note := range t.MidiNotePlaying {
t.Model.Notes().Table().Set(note)
te.scrollTable.Table.MoveCursor(1, 0)
te.scrollTable.EnsureCursorVisible()
if i >= remaining {
break
}
}
te.scrollTable.Table.SetCursor(te.scrollTable.Table.Cursor2())
}

View File

@ -48,7 +48,9 @@ type NumericUpDownStyle struct {
Tooltip component.Tooltip Tooltip component.Tooltip
Width unit.Dp Width unit.Dp
Height unit.Dp Height unit.Dp
Padding unit.Dp
shaper text.Shaper shaper text.Shaper
Hidden bool
} }
func NewNumberInput(v tracker.Int) *NumberInput { 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 { 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 := th.Palette.Fg
bgColor.R /= 4 bgColor.R /= 4
bgColor.G /= 4 bgColor.G /= 4
@ -74,11 +80,22 @@ func NumericUpDown(th *material.Theme, number *NumberInput, tooltip string) Nume
Tooltip: Tooltip(th, tooltip), Tooltip: Tooltip(th, tooltip),
Width: unit.Dp(70), Width: unit.Dp(70),
Height: unit.Dp(20), Height: unit.Dp(20),
Padding: unit.Dp(padding),
shaper: *th.Shaper, shaper: *th.Shaper,
} }
} }
func (s *NumericUpDownStyle) Layout(gtx C) D { 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 != "" { if s.Tooltip.Text.Text != "" {
return s.NumberInput.tipArea.Layout(gtx, s.Tooltip, s.actualLayout) return s.NumberInput.tipArea.Layout(gtx, s.Tooltip, s.actualLayout)
} }

View File

@ -7,14 +7,13 @@ import (
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/op/paint" "gioui.org/op/paint"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget"
"github.com/vsariola/sointu/tracker" "github.com/vsariola/sointu/tracker"
"github.com/vsariola/sointu/version" "github.com/vsariola/sointu/version"
"golang.org/x/exp/shiny/materialdesign/icons" "golang.org/x/exp/shiny/materialdesign/icons"
) )
type SongPanel struct { type SongPanel struct {
MenuBar []widget.Clickable MenuBar []Clickable
Menus []Menu Menus []Menu
BPM *NumberInput BPM *NumberInput
RowsPerPattern *NumberInput RowsPerPattern *NumberInput
@ -57,7 +56,7 @@ type SongPanel struct {
func NewSongPanel(model *tracker.Model) *SongPanel { func NewSongPanel(model *tracker.Model) *SongPanel {
ret := &SongPanel{ ret := &SongPanel{
MenuBar: make([]widget.Clickable, 3), MenuBar: make([]Clickable, 3),
Menus: make([]Menu, 3), Menus: make([]Menu, 3),
BPM: NewNumberInput(model.BPM().Int()), BPM: NewNumberInput(model.BPM().Int()),
RowsPerPattern: NewNumberInput(model.RowsPerPattern().Int()), RowsPerPattern: NewNumberInput(model.RowsPerPattern().Int()),

View File

@ -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 cursorColor = color.NRGBA{R: 100, G: 140, B: 255, A: 48}
var selectionColor = color.NRGBA{R: 100, G: 140, B: 255, A: 12} 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 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} 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 paramIsSendTargetColor = color.NRGBA{R: 120, G: 120, B: 210, A: 255}
var paramValueInvalidColor = color.NRGBA{R: 120, G: 120, B: 120, A: 190} 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),
}
}

View File

@ -35,7 +35,6 @@ type (
BottomHorizontalSplit *Split BottomHorizontalSplit *Split
VerticalSplit *Split VerticalSplit *Split
KeyPlaying map[key.Name]tracker.NoteID KeyPlaying map[key.Name]tracker.NoteID
MidiNotePlaying []byte
PopupAlert *PopupAlert PopupAlert *PopupAlert
SaveChangesDialog *Dialog SaveChangesDialog *Dialog
@ -78,7 +77,6 @@ func NewTracker(model *tracker.Model) *Tracker {
VerticalSplit: &Split{Axis: layout.Vertical}, VerticalSplit: &Split{Axis: layout.Vertical},
KeyPlaying: make(map[key.Name]tracker.NoteID), KeyPlaying: make(map[key.Name]tracker.NoteID),
MidiNotePlaying: make([]byte, 0, 32),
SaveChangesDialog: NewDialog(model.SaveSong(), model.DiscardSong(), model.Cancel()), SaveChangesDialog: NewDialog(model.SaveSong(), model.DiscardSong(), model.Cancel()),
WaveTypeDialog: NewDialog(model.ExportInt16(), model.ExportFloat(), model.Cancel()), WaveTypeDialog: NewDialog(model.ExportInt16(), model.ExportFloat(), model.Cancel()),
InstrumentEditor: NewInstrumentEditor(model), InstrumentEditor: NewInstrumentEditor(model),
@ -103,7 +101,10 @@ func (t *Tracker) Main() {
titleFooter := "" titleFooter := ""
w := new(app.Window) w := new(app.Window)
w.Option(app.Title("Sointu Tracker")) 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() t.InstrumentEditor.Focus()
recoveryTicker := time.NewTicker(time.Second * 30) recoveryTicker := time.NewTicker(time.Second * 30)
t.Explorer = explorer.NewExplorer(w) 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) HasAnyMidiInput() bool {
for _ = range t.Model.MIDI.InputDevices {
func (t *Tracker) ProcessMessage(msg interface{}) { return true
switch msg.(type) {
case tracker.StartPlayMsg:
fmt.Println("Tracker received StartPlayMsg")
case tracker.RecordingMsg:
fmt.Println("Tracker received RecordingMsg")
default:
break
}
}
func (t *Tracker) ProcessEvent(event tracker.MIDINoteEvent) {
// MIDINoteEvent can be only NoteOn / NoteOff, i.e. its On field
if event.On {
t.addToMidiNotePlaying(event.Note)
} else {
t.removeFromMidiNotePlaying(event.Note)
}
t.TrackEditor.HandleMidiInput(t)
}
func (t *Tracker) addToMidiNotePlaying(note byte) {
for _, n := range t.MidiNotePlaying {
if n == note {
return
}
}
t.MidiNotePlaying = append(t.MidiNotePlaying, note)
}
func (t *Tracker) removeFromMidiNotePlaying(note byte) {
for i, n := range t.MidiNotePlaying {
if n == note {
t.MidiNotePlaying = append(
t.MidiNotePlaying[:i],
t.MidiNotePlaying[i+1:]...,
)
}
} }
return false
} }

View File

@ -32,7 +32,7 @@ type UnitEditor struct {
CopyUnitBtn *TipClickable CopyUnitBtn *TipClickable
ClearUnitBtn *ActionClickable ClearUnitBtn *ActionClickable
DisableUnitBtn *BoolClickable DisableUnitBtn *BoolClickable
SelectTypeBtn *widget.Clickable SelectTypeBtn *Clickable
commentEditor *Editor commentEditor *Editor
caser cases.Caser caser cases.Caser
@ -47,7 +47,7 @@ func NewUnitEditor(m *tracker.Model) *UnitEditor {
ClearUnitBtn: NewActionClickable(m.ClearUnit()), ClearUnitBtn: NewActionClickable(m.ClearUnit()),
DisableUnitBtn: NewBoolClickable(m.UnitDisabled().Bool()), DisableUnitBtn: NewBoolClickable(m.UnitDisabled().Bool()),
CopyUnitBtn: new(TipClickable), CopyUnitBtn: new(TipClickable),
SelectTypeBtn: new(widget.Clickable), SelectTypeBtn: new(Clickable),
commentEditor: NewEditor(widget.Editor{SingleLine: true, Submit: true}), commentEditor: NewEditor(widget.Editor{SingleLine: true, Submit: true}),
sliderList: NewDragList(m.Params().List(), layout.Vertical), sliderList: NewDragList(m.Params().List(), layout.Vertical),
searchList: NewDragList(m.SearchResults().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 { type ParameterWidget struct {
floatWidget widget.Float floatWidget widget.Float
boolWidget widget.Bool boolWidget widget.Bool
instrBtn widget.Clickable instrBtn Clickable
instrMenu Menu instrMenu Menu
unitBtn widget.Clickable unitBtn Clickable
unitMenu Menu unitMenu Menu
Parameter tracker.Parameter Parameter tracker.Parameter
tipArea component.TipArea tipArea component.TipArea
@ -332,7 +332,6 @@ func (p ParameterStyle) Layout(gtx C) D {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40)) gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40))
instrItems := make([]MenuItem, p.tracker.Instruments().Count()) instrItems := make([]MenuItem, p.tracker.Instruments().Count())
for i := range instrItems { for i := range instrItems {
i := i
name, _, _, _ := p.tracker.Instruments().Item(i) name, _, _, _ := p.tracker.Instruments().Item(i)
instrItems[i].Text = name instrItems[i].Text = name
instrItems[i].IconBytes = icons.NavigationChevronRight instrItems[i].IconBytes = icons.NavigationChevronRight

View File

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

View File

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

View File

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

View File

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

View File

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

81
tracker/optional_int.go Normal file
View File

@ -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)
}

View File

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

View File

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

47
tracker/types/optional.go Normal file
View File

@ -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
}