From b349474c4dc4274f8a066aea606f8806dbc43d9e Mon Sep 17 00:00:00 2001 From: "5684185+vsariola@users.noreply.github.com" <5684185+vsariola@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:03:44 +0200 Subject: [PATCH] feat: MIDI velocity, keyboard splits, and fixing instrument channel Closes #124 Closes #215 Closes #221 --- CHANGELOG.md | 7 + patch.go | 13 +- tracker/derived.go | 21 +++ tracker/gioui/instrument_properties.go | 55 +++++- tracker/gioui/patch_panel.go | 5 +- tracker/instrument.go | 8 +- tracker/midi.go | 235 +++++++++++++++++++++++++ tracker/model.go | 4 +- tracker/player.go | 42 +++-- tracker/presets.go | 1 + tracker/recording.go | 2 +- 11 files changed, 369 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0e99f..aeb8a30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added +- MIDI velocity, keyboard splitting, forcing specific instrument to use + particular MIDI channel, and ability to transpose the incoming note values. + These settings can be configured under instrument properties. ([#124][i124], + [#215][i215], [#221][i221]) - Ability to bind MIDI controllers to specific parameters. The MIDI menu has the options to bind/unbind parameters. When the user starts binding a parameter, Sointu waits for the next MIDI Control Change event and binds the currently @@ -378,6 +382,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). [i120]: https://github.com/vsariola/sointu/issues/120 [i121]: https://github.com/vsariola/sointu/issues/121 [i122]: https://github.com/vsariola/sointu/issues/122 +[i124]: https://github.com/vsariola/sointu/issues/124 [i125]: https://github.com/vsariola/sointu/issues/125 [i128]: https://github.com/vsariola/sointu/issues/128 [i129]: https://github.com/vsariola/sointu/issues/129 @@ -417,3 +422,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). [i200]: https://github.com/vsariola/sointu/issues/200 [i210]: https://github.com/vsariola/sointu/issues/210 [i211]: https://github.com/vsariola/sointu/issues/211 +[i215]: https://github.com/vsariola/sointu/issues/215 +[i221]: https://github.com/vsariola/sointu/issues/221 diff --git a/patch.go b/patch.go index 370cdda..490455e 100644 --- a/patch.go +++ b/patch.go @@ -24,7 +24,8 @@ type ( // ThreadMaskM1 is a bit mask of which threads are used, minus 1. Minus // 1 is done so that the default value 0 means bit mask 0b0001 i.e. only // thread 1 is rendering the instrument. - ThreadMaskM1 int `yaml:",omitempty"` + ThreadMaskM1 int `yaml:",omitempty"` + MIDI MIDI `yaml:",flow,omitempty"` Units []Unit } @@ -64,6 +65,16 @@ type ( Comment string `yaml:",omitempty"` } + MIDI struct { // contains info on how MIDI events should trigger this instrument; if empty, then the instrument is not triggered by MIDI events + Channel int `yaml:",omitempty"` // 0 means automatically assigned channel, 1-16 means MIDI channel 1-16 + Start int `yaml:",omitempty"` // MIDI note number to start on, 0-127 + End int `yaml:",omitempty"` // MIDI note number to end on, counted backwards from 127, done so that the default number of 0 corresponds to "full keyboard", without any splittings + Transpose int `yaml:",omitempty"` // value to be added to the MIDI note/velocity number, can be negative + Velocity bool `yaml:",omitempty"` // is this instrument triggered by midi event velocity or note + NoRetrigger bool `yaml:",omitempty"` // if true, then this instrument does not retrigger if two consecutive events have the same value + IgnoreNoteOff bool `yaml:",omitempty"` // if true, then this instrument should ignore note off events, i.e. notes never release + } + ParamMap map[string]int // UnitParameter documents one parameter that an unit takes diff --git a/tracker/derived.go b/tracker/derived.go index 103e580..1ee2a8e 100644 --- a/tracker/derived.go +++ b/tracker/derived.go @@ -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 } } diff --git a/tracker/gioui/instrument_properties.go b/tracker/gioui/instrument_properties.go index 3855969..ac6a7cf 100644 --- a/tracker/gioui/instrument_properties.go +++ b/tracker/gioui/instrument_properties.go @@ -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), diff --git a/tracker/gioui/patch_panel.go b/tracker/gioui/patch_panel.go index 671141d..07d4e3e 100644 --- a/tracker/gioui/patch_panel.go +++ b/tracker/gioui/patch_panel.go @@ -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) diff --git a/tracker/instrument.go b/tracker/instrument.go index c315950..334ef9c 100644 --- a/tracker/instrument.go +++ b/tracker/instrument.go @@ -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 } diff --git a/tracker/midi.go b/tracker/midi.go index d05fb1d..ce9d5dd 100644 --- a/tracker/midi.go +++ b/tracker/midi.go @@ -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 { diff --git a/tracker/model.go b/tracker/model.go index 954b29c..3fcbf9a 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -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 diff --git a/tracker/player.go b/tracker/player.go index c75eb09..9641e1c 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -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 diff --git a/tracker/presets.go b/tracker/presets.go index 26c91dc..845181c 100644 --- a/tracker/presets.go +++ b/tracker/presets.go @@ -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 } diff --git a/tracker/recording.go b/tracker/recording.go index 8173d01..0e92283 100644 --- a/tracker/recording.go +++ b/tracker/recording.go @@ -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) } }