feat: MIDI velocity, keyboard splits, and fixing instrument channel

Closes #124
Closes #215
Closes #221
This commit is contained in:
5684185+vsariola@users.noreply.github.com
2026-02-07 10:03:44 +02:00
parent 0179b24fd4
commit b349474c4d
11 changed files with 369 additions and 24 deletions

View File

@ -2,6 +2,7 @@ package tracker
import (
"fmt"
"strconv"
"time"
"github.com/vsariola/sointu"
@ -45,6 +46,7 @@ type (
railWidth int
params [][]Parameter
paramsWidth int
title string
}
derivedTrack struct {
@ -72,6 +74,25 @@ func (m *Model) updateDeriveData(changeType ChangeType) {
m.updateParams()
m.updateRails()
m.updateWires()
m.buildInstrumentTitles()
}
}
func (m *Model) buildInstrumentTitles() {
m.midiAssign.update(m.d.Song.Patch)
for i, instr := range m.d.Song.Patch {
if i >= len(m.midiAssign.itoc) || m.midiAssign.itoc[i] == 0 {
m.derived.patch[i].title = "---"
continue
}
t := strconv.Itoa(m.midiAssign.itoc[i])
if instr.MIDI.Velocity {
t = t + " vel"
}
if instr.MIDI.Start > 0 || instr.MIDI.End > 0 {
t = t + fmt.Sprintf(" [%d-%d]", instr.MIDI.Start, 127-instr.MIDI.End)
}
m.derived.patch[i].title = t
}
}

View File

@ -27,6 +27,14 @@ type (
voices *NumericUpDownState
splitInstrumentBtn *Clickable
splitInstrumentHint string
ignoreNoteOff *Clickable
velocity *Clickable
change *Clickable
noteStart *NumericUpDownState
noteEnd *NumericUpDownState
transpose *NumericUpDownState
midiChannel *NumericUpDownState
}
)
@ -40,6 +48,13 @@ func NewInstrumentProperties() *InstrumentProperties {
voices: NewNumericUpDownState(),
splitInstrumentBtn: new(Clickable),
threadBtns: [4]*Clickable{new(Clickable), new(Clickable), new(Clickable), new(Clickable)},
ignoreNoteOff: new(Clickable),
velocity: new(Clickable),
change: new(Clickable),
noteStart: NewNumericUpDownState(),
noteEnd: NewNumericUpDownState(),
transpose: NewNumericUpDownState(),
midiChannel: NewNumericUpDownState(),
}
ret.soloHint = makeHint("Solo", " (%s)", "SoloToggle")
ret.unsoloHint = makeHint("Unsolo", " (%s)", "SoloToggle")
@ -81,8 +96,9 @@ func (ip *InstrumentProperties) layout(gtx C) D {
layout.Rigid(thread4btn.Layout),
)
}
return ip.list.Layout(gtx, 11, func(gtx C, index int) D {
gtx.Constraints.Max.X = min(gtx.Dp(300), gtx.Constraints.Max.X)
gtx.Constraints.Min.X = min(gtx.Constraints.Max.X, gtx.Constraints.Min.X)
return ip.list.Layout(gtx, 18, func(gtx C, index int) D {
switch index {
case 0:
return layoutInstrumentPropertyLine(gtx, "Name", func(gtx C) D {
@ -93,12 +109,42 @@ func (ip *InstrumentProperties) layout(gtx C) D {
case 4:
muteBtn := ToggleIconBtn(tr.Instrument().Mute(), tr.Theme, ip.muteBtn, icons.ToggleCheckBoxOutlineBlank, icons.ToggleCheckBox, ip.muteHint, ip.unmuteHint)
return layoutInstrumentPropertyLine(gtx, "Mute", muteBtn.Layout)
case 6:
case 5:
soloBtn := ToggleIconBtn(tr.Instrument().Solo(), tr.Theme, ip.soloBtn, icons.ToggleCheckBoxOutlineBlank, icons.ToggleCheckBox, ip.soloHint, ip.unsoloHint)
return layoutInstrumentPropertyLine(gtx, "Solo", soloBtn.Layout)
case 8:
case 7:
return layoutInstrumentPropertyLine(gtx, "Thread", threadbtnline)
case 9:
l := Label(tr.Theme, &tr.Theme.InstrumentEditor.Properties.Label, "MIDI")
l.Alignment = text.Middle
return l.Layout(gtx)
case 10:
channelLine := NumUpDown(tr.MIDI().Channel(), tr.Theme, ip.midiChannel, "0 = automatic")
return layoutInstrumentPropertyLine(gtx, "Channel", channelLine.Layout)
case 11:
start := NumUpDown(tr.MIDI().NoteStart(), tr.Theme, ip.noteStart, "Lowest note triggering\nthis instrument")
end := NumUpDown(tr.MIDI().NoteEnd(), tr.Theme, ip.noteEnd, "Highest note triggering\nthis instrument")
noteRangeLine := func(gtx C) D {
return layout.Flex{}.Layout(gtx,
layout.Rigid(start.Layout),
layout.Rigid(layout.Spacer{Width: 6}.Layout),
layout.Rigid(end.Layout),
)
}
return layoutInstrumentPropertyLine(gtx, "Note range", noteRangeLine)
case 12:
transpose := NumUpDown(tr.MIDI().Transpose(), tr.Theme, ip.transpose, "Transpose of the MIDI values")
return layoutInstrumentPropertyLine(gtx, "Transpose", transpose.Layout)
case 13:
velocityBtn := ToggleIconBtn(tr.MIDI().Velocity(), tr.Theme, ip.velocity, icons.ToggleCheckBoxOutlineBlank, icons.ToggleCheckBox, "Instrument triggered by\nMIDI note", "Instrument triggered by\nMIDI velocity")
return layoutInstrumentPropertyLine(gtx, "Velocity", velocityBtn.Layout)
case 14:
retriggerBtn := ToggleIconBtn(tr.MIDI().Change(), tr.Theme, ip.change, icons.ToggleCheckBoxOutlineBlank, icons.ToggleCheckBox, "Every note/velocity retriggers", "Retrigger only when\nnote/velocity changes")
return layoutInstrumentPropertyLine(gtx, "No retrigger", retriggerBtn.Layout)
case 15:
noteOff := ToggleIconBtn(tr.MIDI().IgnoreNoteOff(), tr.Theme, ip.ignoreNoteOff, icons.ToggleCheckBoxOutlineBlank, icons.ToggleCheckBox, "Notes released", "Notes never released")
return layoutInstrumentPropertyLine(gtx, "Ignore note off", noteOff.Layout)
case 17:
return layout.UniformInset(unit.Dp(6)).Layout(gtx, func(gtx C) D {
return ip.commentEditor.Layout(gtx, tr.Instrument().Comment(), tr.Theme, &tr.Theme.InstrumentEditor.InstrumentComment, "Comment")
})
@ -112,7 +158,6 @@ func (ip *InstrumentProperties) layout(gtx C) D {
func layoutInstrumentPropertyLine(gtx C, text string, content layout.Widget) D {
tr := TrackerFromContext(gtx)
gtx.Constraints.Max.X = min(gtx.Dp(300), gtx.Constraints.Max.X)
label := Label(tr.Theme, &tr.Theme.InstrumentEditor.Properties.Label, text)
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(layout.Spacer{Width: 6, Height: 36}.Layout),

View File

@ -5,7 +5,6 @@ import (
"image"
"image/color"
"io"
"strconv"
"gioui.org/io/clipboard"
"gioui.org/io/event"
@ -251,9 +250,9 @@ func (il *InstrumentList) Layout(gtx C) D {
gtx.Constraints.Max.Y = gtx.Dp(36)
gtx.Constraints.Min.Y = gtx.Dp(36)
element := func(gtx C, i int) D {
grabhandle := Label(t.Theme, &t.Theme.InstrumentEditor.InstrumentList.Number, strconv.Itoa(i+1))
name, title, level, mute, ok := t.Instrument().Item(i)
grabhandle := Label(t.Theme, &t.Theme.InstrumentEditor.InstrumentList.Number, title)
label := func(gtx C) D {
name, level, mute, ok := t.Instrument().Item(i)
if !ok {
labelStyle := Label(t.Theme, &t.Theme.InstrumentEditor.InstrumentList.Number, "")
return layout.Center.Layout(gtx, labelStyle.Layout)

View File

@ -75,9 +75,9 @@ func (m *splitInstrument) Do() {
}
// Item returns information about the instrument at a given index.
func (v *InstrModel) Item(i int) (name string, maxLevel float32, mute bool, ok bool) {
func (v *InstrModel) Item(i int) (name, title string, maxLevel float32, mute bool, ok bool) {
if i < 0 || i >= len(v.d.Song.Patch) {
return "", 0, false, false
return "", "", 0, false, false
}
name = v.d.Song.Patch[i].Name
mute = v.d.Song.Patch[i].Mute
@ -93,6 +93,9 @@ func (v *InstrModel) Item(i int) (name string, maxLevel float32, mute bool, ok b
}
}
}
if i >= 0 && i < len(v.derived.patch) {
title = v.derived.patch[i].title
}
ok = true
return
}
@ -478,6 +481,7 @@ success:
}
instrument.NumVoices = clamp(instrument.NumVoices, 1, 32-numVoices)
(*Model)(m).assignUnitIDs(instrument.Units)
instrument.MIDI = m.d.Song.Patch[m.d.InstrIndex].MIDI // keep the MIDI config of the current instrument
m.d.Song.Patch[m.d.InstrIndex] = instrument
return true
}

View File

@ -3,6 +3,8 @@ package tracker
import (
"encoding/json"
"fmt"
"github.com/vsariola/sointu"
)
type MIDIModel Model
@ -144,6 +146,239 @@ func (m *midiInputtingNotes) SetValue(val bool) {
TrySend(m.broker.ToPlayer, any(m.midi.router))
}
// Transpose returns an Int controlling the MIDI transpose value of the
// currently selected instrument.
func (m *MIDIModel) Transpose() Int { return MakeInt((*midiTranspose)(m)) }
type midiTranspose MIDIModel
func (m *midiTranspose) Value() int {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return 0
}
return m.d.Song.Patch[i].MIDI.Transpose
}
func (m *midiTranspose) SetValue(val int) bool {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return false
}
defer (*Model)(m).change("MIDITranspose", PatchChange, MinorChange)()
m.d.Song.Patch[i].MIDI.Transpose = val
return true
}
func (m *midiTranspose) Range() RangeInclusive { return RangeInclusive{-127, 127} }
// NoteStart returns an Int controlling the MIDI note start value of the
// currently selected instrument.
func (m *MIDIModel) NoteStart() Int { return MakeInt((*midiNoteStart)(m)) }
type midiNoteStart MIDIModel
func (m *midiNoteStart) Value() int {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return 0
}
return m.d.Song.Patch[i].MIDI.Start
}
func (m *midiNoteStart) SetValue(val int) bool {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return false
}
defer (*Model)(m).change("MIDINoteStart", PatchChange, MinorChange)()
m.d.Song.Patch[i].MIDI.Start = val
return true
}
func (m *midiNoteStart) Range() RangeInclusive { return RangeInclusive{0, 127} }
// NoteEnd returns an Int controlling the MIDI note end value of the
// currently selected instrument.
func (m *MIDIModel) NoteEnd() Int { return MakeInt((*midiNoteEnd)(m)) }
type midiNoteEnd MIDIModel
func (m *midiNoteEnd) Value() int {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return 0
}
return 127 - m.d.Song.Patch[i].MIDI.End
}
func (m *midiNoteEnd) SetValue(val int) bool {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return false
}
defer (*Model)(m).change("MIDINoteEnd", PatchChange, MinorChange)()
m.d.Song.Patch[i].MIDI.End = 127 - val
return true
}
func (m *midiNoteEnd) Range() RangeInclusive { return RangeInclusive{0, 127} }
// Velocity returns a Bool controlling whether the velocity value from MIDI
// event is used instead of the normal note value
func (m *MIDIModel) Velocity() Bool { return MakeBool((*midiVelocity)(m)) }
type midiVelocity MIDIModel
func (m *midiVelocity) Value() bool {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return false
}
return m.d.Song.Patch[i].MIDI.Velocity
}
func (m *midiVelocity) SetValue(val bool) {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return
}
defer (*Model)(m).change("MIDIVelocity", PatchChange, MinorChange)()
m.d.Song.Patch[i].MIDI.Velocity = val
}
// Change returns a Bool controlling whether only the change in note or velocity value is used
func (m *MIDIModel) Change() Bool { return MakeBool((*midiChange)(m)) }
type midiChange MIDIModel
func (m *midiChange) Value() bool {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return false
}
return m.d.Song.Patch[i].MIDI.NoRetrigger
}
func (m *midiChange) SetValue(val bool) {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return
}
defer (*Model)(m).change("MIDIChange", PatchChange, MinorChange)()
m.d.Song.Patch[i].MIDI.NoRetrigger = val
}
// IgnoreNoteOff returns a Bool controlling whether note off events are ignored
func (m *MIDIModel) IgnoreNoteOff() Bool { return MakeBool((*midiIgnoreNoteOff)(m)) }
type midiIgnoreNoteOff MIDIModel
func (m *midiIgnoreNoteOff) Value() bool {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return false
}
return m.d.Song.Patch[i].MIDI.IgnoreNoteOff
}
func (m *midiIgnoreNoteOff) SetValue(val bool) {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return
}
defer (*Model)(m).change("MIDIIgnoreNoteOff", PatchChange, MinorChange)()
m.d.Song.Patch[i].MIDI.IgnoreNoteOff = val
}
// Channel returns an Int controlling the MIDI channel of the currently selected
// instrument. 0 = automatically selected, 1-16 fixed to specific MIDI channel
func (m *MIDIModel) Channel() Int { return MakeInt((*midiChannel)(m)) }
type midiChannel MIDIModel
func (m *midiChannel) Value() int {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return 0
}
return m.d.Song.Patch[i].MIDI.Channel
}
func (m *midiChannel) SetValue(val int) bool {
i := m.d.InstrIndex
if i < 0 || i >= len(m.d.Song.Patch) {
return false
}
defer (*Model)(m).change("MIDIChannel", PatchChange, MinorChange)()
m.d.Song.Patch[i].MIDI.Channel = val
return true
}
func (m *midiChannel) Range() RangeInclusive { return RangeInclusive{0, 16} }
type (
midiAssigns struct {
ctoi map[midiAssignKey][]midiAssignRange // map to quickly find which instruments to trigger
itoc []int // slice to quickly find which MIDI channel was assigned for a given instrument
}
midiAssignKey struct {
Channel int
Velocity bool
}
midiAssignRange struct {
Start, End byte
Instr int
}
)
const MAX_MIDI_CHANNELS = 16
// update tries to assign MIDI channels to instruments that have MIDI channel 0
// (automatic) in a way that minimizes the number of channels used. It also
// updates the ctoi and itoc maps for quick lookup when routing MIDI messages.
// The algorithm iterates through the instruments and, for those with automatic
// channel, it tries to find the lowest MIDI channel that doesn't have an
// overlapping note range with any of the already assigned instruments with the
// same velocity setting. If it runs out of channels, it leaves the rest of the
// instruments unassigned (channel 0).
func (a *midiAssigns) update(p sointu.Patch) {
for k := range a.ctoi {
a.ctoi[k] = a.ctoi[k][:0] // clear all slices, keeping their allocated memory
}
a.itoc = a.itoc[:0]
for i, instr := range p {
if instr.MIDI.Channel != 0 {
k := midiAssignKey{Channel: instr.MIDI.Channel, Velocity: instr.MIDI.Velocity}
v := midiAssignRange{Start: byte(max(instr.MIDI.Start, 0)), End: byte(min(127-instr.MIDI.End, 127)), Instr: i}
a.ctoi[k] = append(a.ctoi[k], v)
}
}
k := midiAssignKey{Channel: 1, Velocity: false}
outer:
for i, e := range p {
if e.MIDI.Channel > 0 { // already assigned to a specific channel, so skip automatic assignment
a.itoc = append(a.itoc, e.MIDI.Channel)
continue
}
k.Velocity = e.MIDI.Velocity
x := midiAssignRange{Start: byte(max(e.MIDI.Start, 0)), End: byte(min(127-e.MIDI.End, 127)), Instr: i}
inner:
for {
for _, y := range a.ctoi[k] {
if max(x.Start, y.Start) <= min(x.End, y.End) {
if k.Channel >= MAX_MIDI_CHANNELS {
break outer // we've ran out of channels, leave the rest unassigned
}
k.Channel++
continue inner // this channel is already taken for the overlapping range, try next channel
}
}
break // this channel had no overlaps with the already assigned ranges, so we can use it
}
a.ctoi[k] = append(a.ctoi[k], x)
a.itoc = append(a.itoc, k.Channel)
}
}
func (a *midiAssigns) forEach(chn int, vel bool, val byte, cb func(instr int, val byte)) {
k := midiAssignKey{Channel: chn, Velocity: vel}
for _, r := range a.ctoi[k] {
if r.Start <= val && val <= r.End {
cb(r.Instr, val)
}
}
}
// MIDIMessage represents a MIDI message received from a MIDI input port or VST
// host.
type MIDIMessage struct {

View File

@ -90,7 +90,8 @@ type (
broker *Broker
midi midiState
midi midiState
midiAssign midiAssigns
presetData presetData
}
@ -177,6 +178,7 @@ func NewModel(broker *Broker, synthers []sointu.Synther, midiContext MIDIContext
m := new(Model)
m.synthers = synthers
m.midi = midiState{context: midiContext}
m.midiAssign = midiAssigns{ctoi: map[midiAssignKey][]midiAssignRange{}}
m.broker = broker
m.d.Octave = 4
m.linkInstrTrack = true

View File

@ -30,7 +30,9 @@ type (
frameDeltas map[any]int64 // Player.frame (approx.)= event.Timestamp + frameDeltas[event.Source]
events NoteEventList
midiRouter midiRouter
midiRouter midiRouter
midiAssigns midiAssigns
prevVal []byte
status PlayerStatus // the part of the Player state that is communicated to the model to visualize what Player is doing
@ -64,7 +66,7 @@ type (
NoteEvent struct {
Timestamp int64 // in frames, relative to whatever clock the source is using
On bool
Channel int
Channel int // which track or instrument is triggered, depending on IsTrack
Note byte
IsTrack bool // true if "Channel" means track number, false if it means instrument number
Source any
@ -90,6 +92,7 @@ func NewPlayer(broker *Broker, synther sointu.Synther) *Player {
broker: broker,
synther: synther,
frameDeltas: make(map[any]int64),
midiAssigns: midiAssigns{ctoi: map[midiAssignKey][]midiAssignRange{}},
}
}
@ -183,6 +186,7 @@ func (p *Player) destroySynth() {
p.synth.Close()
p.synth = nil
}
p.prevVal = p.prevVal[:0]
}
func (p *Player) advanceRow() {
@ -281,16 +285,31 @@ loop:
case *NoteEvent:
p.events = append(p.events, *m)
case *MIDIMessage:
// In future, here we should map midi channels to various
// instruments, possible mapping the velocity to another
// instrument as well
if m.Data[0] >= 0x80 && m.Data[0] <= 0x9F {
p.events = append(p.events, NoteEvent{
Timestamp: m.Timestamp,
Channel: int(m.Data[0] & 0x0F),
Note: m.Data[1],
On: m.Data[0] >= 0x90,
Source: m.Source})
chn := int(m.Data[0]&0x0F) + 1
note := m.Data[1]
velocity := m.Data[2]
on := m.Data[0] >= 0x90
cb := func(i int, v byte) {
if i < 0 || i >= len(p.song.Patch) {
return
}
instr := p.song.Patch[i]
if instr.MIDI.IgnoreNoteOff && !on {
return // instruments configured to ignore note offs never release
}
n := byte(min(max(int(v)+instr.MIDI.Transpose, 2), 255)) // 0 and 1 have special meaning
if instr.MIDI.NoRetrigger && on && i < len(p.prevVal) && p.prevVal[i] == n {
return // the instrument is configured to respond only to changes in values and there was no change
}
p.events = append(p.events, NoteEvent{Timestamp: m.Timestamp, Channel: i, Note: n, On: on, Source: m.Source})
for len(p.prevVal) <= i {
p.prevVal = append(p.prevVal, 0)
}
p.prevVal[i] = n
}
p.midiAssigns.forEach(chn, false, note, cb) // trigger instruments that are configured to respond to this midi channel's note events
p.midiAssigns.forEach(chn, true, velocity, cb) // trigger instruments that are configured to respond to this midi channel's velocity events
}
case midiRouter:
p.midiRouter = m
@ -401,6 +420,7 @@ func (p *Player) compileOrUpdateSynth() {
}
voice += instr.NumVoices
}
p.midiAssigns.update(p.song.Patch)
}
// all sendTargets from player are always non-blocking, to ensure that the player thread cannot end up in a dead-lock

View File

@ -167,6 +167,7 @@ func (m *presetResultList) SetSelected(i int) {
newInstr := m.presetData.cache.results[i].instr.Copy()
newInstr.NumVoices = clamp(m.d.Song.Patch[m.d.InstrIndex].NumVoices, 1, vm.MAX_VOICES)
(*Model)(m).assignUnitIDs(newInstr.Units)
newInstr.MIDI = m.d.Song.Patch[m.d.InstrIndex].MIDI // keep the MIDI config of the current instrument
m.d.Song.Patch[m.d.InstrIndex] = newInstr
}

View File

@ -175,7 +175,7 @@ func (recording *Recording) Score(patch sointu.Patch, rowsPerBeat, rowsPerPatter
order[k] = len(patterns)
patterns = append(patterns, newPat)
}
track := sointu.Track{NumVoices: numVoices, Effect: false, Order: order, Patterns: patterns}
track := sointu.Track{NumVoices: numVoices, Effect: patch[i].MIDI.Velocity, Order: order, Patterns: patterns}
songTracks = append(songTracks, track)
}
}