feat: midi note input for the tracker

This commit is contained in:
qm210 2024-10-21 22:00:50 +02:00 committed by Veikko Sariola
parent 216cde2365
commit 8dfadacafe
17 changed files with 299 additions and 63 deletions

3
.gitignore vendored
View File

@ -17,8 +17,9 @@ build/
# Project specific # Project specific
old/ old/
# VS Code # IDEs
.vscode/ .vscode/
.idea/
# project specific # project specific
# this is autogenerated from bridge.go.in # this is autogenerated from bridge.go.in

View File

@ -13,6 +13,8 @@ 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 supported if there are tracks available. ([#170][i170])
- 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

@ -10,7 +10,6 @@ import (
"runtime/pprof" "runtime/pprof"
"gioui.org/app" "gioui.org/app"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/cmd" "github.com/vsariola/sointu/cmd"
"github.com/vsariola/sointu/oto" "github.com/vsariola/sointu/oto"
"github.com/vsariola/sointu/tracker" "github.com/vsariola/sointu/tracker"
@ -18,18 +17,10 @@ import (
"github.com/vsariola/sointu/tracker/gomidi" "github.com/vsariola/sointu/tracker/gomidi"
) )
type PlayerAudioSource struct {
*tracker.Player
playerProcessContext tracker.PlayerProcessContext
}
func (p *PlayerAudioSource) ReadAudio(buf sointu.AudioBuffer) error {
p.Player.Process(buf, p.playerProcessContext)
return nil
}
var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`") var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`")
var memprofile = flag.String("memprofile", "", "write memory profile to `file`") var memprofile = flag.String("memprofile", "", "write memory profile to `file`")
var defaultMidiInput = flag.String("midi-input", "", "connect MIDI input to matching device name")
var firstMidiInput = flag.Bool("first-midi-input", false, "connect MIDI input to first device found")
func main() { func main() {
flag.Parse() flag.Parse()
@ -54,8 +45,10 @@ func main() {
recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery") recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery")
} }
midiContext := gomidi.NewContext() midiContext := gomidi.NewContext()
defer midiContext.Close()
midiContext.TryToOpenBy(*defaultMidiInput, *firstMidiInput)
model, player := tracker.NewModelPlayer(cmd.MainSynther, midiContext, recoveryFile) model, player := tracker.NewModelPlayer(cmd.MainSynther, midiContext, recoveryFile)
defer model.MIDI.Close()
if a := flag.Args(); len(a) > 0 { if a := flag.Args(); len(a) > 0 {
f, err := os.Open(a[0]) f, err := os.Open(a[0])
if err == nil { if err == nil {
@ -63,10 +56,13 @@ func main() {
} }
f.Close() f.Close()
} }
tracker := gioui.NewTracker(model)
audioCloser := audioContext.Play(&PlayerAudioSource{player, midiContext}) trackerUi := gioui.NewTracker(model)
processor := tracker.NewProcessor(player, midiContext, trackerUi)
audioCloser := audioContext.Play(processor)
go func() { go func() {
tracker.Main() trackerUi.Main()
audioCloser.Close() audioCloser.Close()
if *cpuprofile != "" { if *cpuprofile != "" {
pprof.StopCPUProfile() pprof.StopCPUProfile()

View File

@ -31,6 +31,8 @@ func (m NullMIDIContext) InputDevices(yield func(tracker.MIDIDevice) bool) {}
func (m NullMIDIContext) Close() {} func (m NullMIDIContext) Close() {}
func (m NullMIDIContext) HasDeviceOpen() bool { return false }
func (c *VSTIProcessContext) NextEvent() (event tracker.MIDINoteEvent, ok bool) { func (c *VSTIProcessContext) NextEvent() (event tracker.MIDINoteEvent, ok bool) {
for c.eventIndex < len(c.events) { for c.eventIndex < len(c.events) {
ev := c.events[c.eventIndex] ev := c.events[c.eventIndex]
@ -101,7 +103,7 @@ func init() {
buf = append(buf, make(sointu.AudioBuffer, out.Frames-len(buf))...) buf = append(buf, make(sointu.AudioBuffer, out.Frames-len(buf))...)
} }
buf = buf[:out.Frames] buf = buf[:out.Frames]
player.Process(buf, &context) player.Process(buf, &context, nil)
for i := 0; i < out.Frames; i++ { for i := 0; i < out.Frames; i++ {
left[i], right[i] = buf[i][0], buf[i][1] left[i], right[i] = buf[i][0], buf[i][1]
} }

30
song.go
View File

@ -2,6 +2,7 @@ package sointu
import ( import (
"errors" "errors"
"iter"
) )
type ( type (
@ -331,3 +332,32 @@ func TotalVoices[T any, S ~[]T, P NumVoicerPointer[T]](slice S) (ret int) {
} }
return return
} }
func (s *Song) InstrumentForTrack(trackIndex int) (int, bool) {
voiceIndex := s.Score.FirstVoiceForTrack(trackIndex)
instrument, err := s.Patch.InstrumentForVoice(voiceIndex)
return instrument, err == nil
}
func (s *Song) AllTracksWithSameInstrument(trackIndex int) iter.Seq[int] {
return func(yield func(int) bool) {
currentInstrument, currentExists := s.InstrumentForTrack(trackIndex)
if !currentExists {
return
}
for i := 0; i < len(s.Score.Tracks); i++ {
instrument, exists := s.InstrumentForTrack(i)
if !exists {
return
}
if instrument != currentInstrument {
continue
}
if !yield(i) {
return
}
}
}
}

View File

@ -475,12 +475,12 @@ func (m *Model) ExportFloat() Action { return Allow(func() { m.dialog = ExportFl
func (m *Model) ExportInt16() Action { return Allow(func() { m.dialog = ExportInt16Explorer }) } func (m *Model) ExportInt16() Action { return Allow(func() { m.dialog = ExportInt16Explorer }) }
func (m *Model) SelectMidiInput(item MIDIDevice) Action { func (m *Model) SelectMidiInput(item MIDIDevice) Action {
return Allow(func() { return Allow(func() {
if err := item.Open(); err != nil { if err := item.Open(); err == nil {
message := fmt.Sprintf("Could not open MIDI device: %s", item)
m.Alerts().Add(message, Error)
} else {
message := fmt.Sprintf("Opened MIDI device: %s", item) message := fmt.Sprintf("Opened MIDI device: %s", item)
m.Alerts().Add(message, Info) m.Alerts().Add(message, Info)
} else {
message := fmt.Sprintf("Could not open MIDI device: %s", item)
m.Alerts().Add(message, Error)
} }
}) })
} }

View File

@ -16,6 +16,7 @@ type (
Playing Model Playing Model
InstrEnlarged Model InstrEnlarged Model
Effect Model Effect Model
TrackMidiIn Model
CommentExpanded Model CommentExpanded Model
Follow Model Follow Model
UnitSearching Model UnitSearching Model
@ -44,6 +45,7 @@ func (m *Model) IsRecording() *IsRecording { return (*IsRecording)(m) }
func (m *Model) Playing() *Playing { return (*Playing)(m) } func (m *Model) Playing() *Playing { return (*Playing)(m) }
func (m *Model) InstrEnlarged() *InstrEnlarged { return (*InstrEnlarged)(m) } func (m *Model) InstrEnlarged() *InstrEnlarged { return (*InstrEnlarged)(m) }
func (m *Model) Effect() *Effect { return (*Effect)(m) } func (m *Model) Effect() *Effect { return (*Effect)(m) }
func (m *Model) TrackMidiIn() *TrackMidiIn { return (*TrackMidiIn)(m) }
func (m *Model) CommentExpanded() *CommentExpanded { return (*CommentExpanded)(m) } func (m *Model) CommentExpanded() *CommentExpanded { return (*CommentExpanded)(m) }
func (m *Model) Follow() *Follow { return (*Follow)(m) } func (m *Model) Follow() *Follow { return (*Follow)(m) }
func (m *Model) UnitSearching() *UnitSearching { return (*UnitSearching)(m) } func (m *Model) UnitSearching() *UnitSearching { return (*UnitSearching)(m) }
@ -110,6 +112,13 @@ 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)
func (m *TrackMidiIn) Bool() Bool { return Bool{m} }
func (m *TrackMidiIn) Value() bool { return m.trackMidiIn }
func (m *TrackMidiIn) setValue(val bool) { m.trackMidiIn = val }
func (m *TrackMidiIn) Enabled() bool { return m.MIDI.HasDeviceOpen() }
// Effect methods // Effect methods
func (m *Effect) Bool() Bool { return Bool{m} } func (m *Effect) Bool() Bool { return Bool{m} }

View File

@ -3,6 +3,7 @@ package gioui
import ( import (
"fmt" "fmt"
"image" "image"
"image/color"
"strconv" "strconv"
"strings" "strings"
@ -62,6 +63,7 @@ type NoteEditor struct {
NoteOffBtn *ActionClickable NoteOffBtn *ActionClickable
EffectBtn *BoolClickable EffectBtn *BoolClickable
UniqueBtn *BoolClickable UniqueBtn *BoolClickable
TrackMidiInBtn *BoolClickable
scrollTable *ScrollTable scrollTable *ScrollTable
eventFilters []event.Filter eventFilters []event.Filter
@ -85,6 +87,7 @@ func NewNoteEditor(model *tracker.Model) *NoteEditor {
NoteOffBtn: NewActionClickable(model.EditNoteOff()), NoteOffBtn: NewActionClickable(model.EditNoteOff()),
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()),
scrollTable: NewScrollTable( scrollTable: NewScrollTable(
model.Notes().Table(), model.Notes().Table(),
model.Tracks().List(), model.Tracks().List(),
@ -162,6 +165,7 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D {
} }
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")
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),
@ -175,6 +179,8 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D {
layout.Rigid(voiceUpDown), layout.Rigid(voiceUpDown),
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.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))
}) })
@ -217,7 +223,13 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
h := gtx.Dp(unit.Dp(trackColTitleHeight)) h := gtx.Dp(unit.Dp(trackColTitleHeight))
title := ((*tracker.Order)(t.Model)).Title(i) title := ((*tracker.Order)(t.Model)).Title(i)
gtx.Constraints = layout.Exact(image.Pt(pxWidth, h)) gtx.Constraints = layout.Exact(image.Pt(pxWidth, h))
LabelStyle{Alignment: layout.N, Text: title, FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: t.Theme.Shaper}.Layout(gtx) LabelStyle{
Alignment: layout.N,
Text: title,
FontSize: unit.Sp(12),
Color: mediumEmphasisTextColor,
Shaper: t.Theme.Shaper,
}.Layout(gtx)
return D{Size: image.Pt(pxWidth, h)} return D{Size: image.Pt(pxWidth, h)}
} }
@ -253,8 +265,10 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
return D{Size: image.Pt(w, pxHeight)} return D{Size: image.Pt(w, pxHeight)}
} }
drawSelection := te.scrollTable.Table.Cursor() != te.scrollTable.Table.Cursor2() cursor := te.scrollTable.Table.Cursor()
drawSelection := cursor != te.scrollTable.Table.Cursor2()
selection := te.scrollTable.Table.Range() selection := te.scrollTable.Table.Range()
hasTrackMidiIn := te.TrackMidiInBtn.Bool.Value()
cell := func(gtx C, x, y int) D { cell := func(gtx C, x, y int) D {
// draw the background, to indicate selection // draw the background, to indicate selection
@ -268,21 +282,25 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
} }
paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(0, 0), Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op()) paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(0, 0), Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op())
// draw the cursor // draw the cursor
if point == te.scrollTable.Table.Cursor() { if point == cursor {
cw := gtx.Constraints.Min.X
cx := 0
if t.Model.Notes().Effect(x) {
cw /= 2
if t.Model.Notes().LowNibble() {
cx += cw
}
}
c := inactiveSelectionColor c := inactiveSelectionColor
if te.scrollTable.Focused() { if te.scrollTable.Focused() {
c = cursorColor c = cursorColor
} }
paint.FillShape(gtx.Ops, c, clip.Rect{Min: image.Pt(cx, 0), Max: image.Pt(cx+cw, gtx.Constraints.Min.Y)}.Op()) if hasTrackMidiIn {
c = cursorForTrackMidiInColor
} }
te.paintColumnCell(gtx, x, t, c)
}
// draw the corresponding "fake cursors" for instrument-track-groups (for polyphony)
if hasTrackMidiIn {
for trackIndex := range ((*tracker.Order)(t.Model)).TrackIndicesForCurrentInstrument() {
if x == trackIndex && y == cursor.Y {
te.paintColumnCell(gtx, x, t, cursorNeighborForTrackMidiInColor)
}
}
}
// draw the pattern marker // draw the pattern marker
rpp := max(t.RowsPerPattern().Value(), 1) rpp := max(t.RowsPerPattern().Value(), 1)
pat := y / rpp pat := y / rpp
@ -317,6 +335,18 @@ 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) {
cw := gtx.Constraints.Min.X
cx := 0
if t.Model.Notes().Effect(x) {
cw /= 2
if t.Model.Notes().LowNibble() {
cx += cw
}
}
paint.FillShape(gtx.Ops, c, clip.Rect{Min: image.Pt(cx, 0), Max: image.Pt(cx+cw, gtx.Constraints.Min.Y)}.Op())
}
func mod(x, d int) int { func mod(x, d int) int {
x = x % d x = x % d
if x >= 0 { if x >= 0 {
@ -338,7 +368,7 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) {
if nibbleValue, err := strconv.ParseInt(string(e.Name), 16, 8); err == nil { if nibbleValue, err := strconv.ParseInt(string(e.Name), 16, 8); err == nil {
t.Model.Notes().FillNibble(byte(nibbleValue), t.Model.Notes().LowNibble()) t.Model.Notes().FillNibble(byte(nibbleValue), t.Model.Notes().LowNibble())
n = t.Model.Notes().Value(te.scrollTable.Table.Cursor()) n = t.Model.Notes().Value(te.scrollTable.Table.Cursor())
goto validNote te.finishNoteInsert(t, n, e.Name)
} }
} else { } else {
action, ok := keyBindingMap[e] action, ok := keyBindingMap[e]
@ -347,11 +377,7 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) {
} }
if action == "NoteOff" { if action == "NoteOff" {
t.Model.Notes().Table().Fill(0) t.Model.Notes().Table().Fill(0)
if step := t.Model.Step().Value(); step > 0 { te.finishNoteInsert(t, 0, "")
te.scrollTable.Table.MoveCursor(0, step)
te.scrollTable.Table.SetCursor2(te.scrollTable.Table.Cursor())
}
te.scrollTable.EnsureCursorVisible()
return return
} }
if action[:4] == "Note" { if action[:4] == "Note" {
@ -361,20 +387,43 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) {
} }
n = noteAsValue(t.OctaveNumberInput.Int.Value(), val-12) n = noteAsValue(t.OctaveNumberInput.Int.Value(), val-12)
t.Model.Notes().Table().Fill(int(n)) t.Model.Notes().Table().Fill(int(n))
goto validNote te.finishNoteInsert(t, n, e.Name)
} }
} }
return }
validNote:
func (te *NoteEditor) finishNoteInsert(t *Tracker, note byte, keyName key.Name) {
if step := t.Model.Step().Value(); step > 0 { if step := t.Model.Step().Value(); step > 0 {
te.scrollTable.Table.MoveCursor(0, step) te.scrollTable.Table.MoveCursor(0, step)
te.scrollTable.Table.SetCursor2(te.scrollTable.Table.Cursor()) te.scrollTable.Table.SetCursor2(te.scrollTable.Table.Cursor())
} }
te.scrollTable.EnsureCursorVisible() te.scrollTable.EnsureCursorVisible()
if _, ok := t.KeyPlaying[e.Name]; !ok {
trk := te.scrollTable.Table.Cursor().X if keyName == "" {
t.KeyPlaying[e.Name] = t.TrackNoteOn(trk, n) return
} }
if _, ok := t.KeyPlaying[keyName]; !ok {
trk := te.scrollTable.Table.Cursor().X
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 := (*tracker.Order)(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

@ -120,11 +120,17 @@ func (t *SongPanel) layoutMenuBar(gtx C, tr *Tracker) D {
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(36)) gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(36))
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36)) gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36))
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.End}.Layout(gtx, menuLayouts := []layout.FlexChild{
layout.Rigid(tr.layoutMenu(gtx, "File", &t.MenuBar[0], &t.Menus[0], unit.Dp(200), t.fileMenuItems...)), layout.Rigid(tr.layoutMenu(gtx, "File", &t.MenuBar[0], &t.Menus[0], unit.Dp(200), t.fileMenuItems...)),
layout.Rigid(tr.layoutMenu(gtx, "Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(200), t.editMenuItems...)), layout.Rigid(tr.layoutMenu(gtx, "Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(200), t.editMenuItems...)),
}
if len(t.midiMenuItems) > 0 {
menuLayouts = append(
menuLayouts,
layout.Rigid(tr.layoutMenu(gtx, "MIDI", &t.MenuBar[2], &t.Menus[2], unit.Dp(200), t.midiMenuItems...)), layout.Rigid(tr.layoutMenu(gtx, "MIDI", &t.MenuBar[2], &t.Menus[2], unit.Dp(200), t.midiMenuItems...)),
) )
}
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.End}.Layout(gtx, menuLayouts...)
} }
func (t *SongPanel) layoutSongOptions(gtx C, tr *Tracker) D { func (t *SongPanel) layoutSongOptions(gtx C, tr *Tracker) D {

View File

@ -60,6 +60,8 @@ 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 errorColor = color.NRGBA{R: 207, G: 102, B: 121, A: 255} var errorColor = color.NRGBA{R: 207, G: 102, B: 121, A: 255}

View File

@ -35,6 +35,7 @@ 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
@ -77,6 +78,7 @@ 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),
@ -306,3 +308,46 @@ func (t *Tracker) layoutTop(gtx layout.Context) layout.Dimensions {
}, },
) )
} }
/// Event Handling (for UI updates when playing etc.)
func (t *Tracker) ProcessMessage(msg interface{}) {
switch msg.(type) {
case tracker.StartPlayMsg:
fmt.Println("Tracker received StartPlayMsg")
case tracker.RecordingMsg:
fmt.Println("Tracker received RecordingMsg")
default:
break
}
}
func (t *Tracker) ProcessEvent(event tracker.MIDINoteEvent) {
// MIDINoteEvent can be only NoteOn / NoteOff, i.e. its On field
if event.On {
t.addToMidiNotePlaying(event.Note)
} else {
t.removeFromMidiNotePlaying(event.Note)
}
t.TrackEditor.HandleMidiInput(t)
}
func (t *Tracker) addToMidiNotePlaying(note byte) {
for _, n := range t.MidiNotePlaying {
if n == note {
return
}
}
t.MidiNotePlaying = append(t.MidiNotePlaying, note)
}
func (t *Tracker) removeFromMidiNotePlaying(note byte) {
for i, n := range t.MidiNotePlaying {
if n == note {
t.MidiNotePlaying = append(
t.MidiNotePlaying[:i],
t.MidiNotePlaying[i+1:]...,
)
}
}
}

View File

@ -8,6 +8,7 @@ import (
"gitlab.com/gomidi/midi/v2" "gitlab.com/gomidi/midi/v2"
"gitlab.com/gomidi/midi/v2/drivers" "gitlab.com/gomidi/midi/v2/drivers"
"gitlab.com/gomidi/midi/v2/drivers/rtmididrv" "gitlab.com/gomidi/midi/v2/drivers/rtmididrv"
"strings"
) )
type ( type (
@ -56,7 +57,7 @@ func (m RTMIDIDevice) Open() error {
if m.context.driver == nil { if m.context.driver == nil {
return errors.New("no driver available") return errors.New("no driver available")
} }
if m.context.currentIn != nil && m.context.currentIn.IsOpen() { if m.context.HasDeviceOpen() {
m.context.currentIn.Close() m.context.currentIn.Close()
} }
m.context.currentIn = m.in m.context.currentIn = m.in
@ -120,3 +121,24 @@ func (c *RTMIDIContext) Close() {
} }
c.driver.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)
}
}

View File

@ -78,9 +78,10 @@ type (
synther sointu.Synther // the synther used to create new synths synther sointu.Synther // the synther used to create new synths
PlayerMessages chan PlayerMsg PlayerMessages chan PlayerMsg
modelMessages chan<- interface{} ModelMessages chan<- interface{}
MIDI MIDIContext MIDI MIDIContext
trackMidiIn bool
} }
// Cursor identifies a row and a track in a song score. // Cursor identifies a row and a track in a song score.
@ -130,6 +131,7 @@ type (
MIDIContext interface { MIDIContext interface {
InputDevices(yield func(MIDIDevice) bool) InputDevices(yield func(MIDIDevice) bool)
Close() Close()
HasDeviceOpen() bool
} }
MIDIDevice interface { MIDIDevice interface {
@ -183,9 +185,10 @@ func NewModelPlayer(synther sointu.Synther, midiContext MIDIContext, recoveryFil
m := new(Model) m := new(Model)
m.synther = synther m.synther = synther
m.MIDI = midiContext m.MIDI = midiContext
m.trackMidiIn = midiContext.HasDeviceOpen()
modelMessages := make(chan interface{}, 1024) modelMessages := make(chan interface{}, 1024)
playerMessages := make(chan PlayerMsg, 1024) playerMessages := make(chan PlayerMsg, 1024)
m.modelMessages = modelMessages m.ModelMessages = modelMessages
m.PlayerMessages = playerMessages m.PlayerMessages = playerMessages
m.d.Octave = 4 m.d.Octave = 4
m.linkInstrTrack = true m.linkInstrTrack = true
@ -421,7 +424,7 @@ func (m *Model) resetSong() {
// send sends a message to the player // send sends a message to the player
func (m *Model) send(message interface{}) { func (m *Model) send(message interface{}) {
m.modelMessages <- message m.ModelMessages <- message
} }
func (m *Model) maxID() int { func (m *Model) maxID() int {

View File

@ -271,7 +271,7 @@ func FuzzModel(f *testing.F) {
break loop break loop
default: default:
ctx := NullContext{} ctx := NullContext{}
player.Process(buf, ctx) player.Process(buf, ctx, nil)
} }
} }
}() }()

View File

@ -41,6 +41,11 @@ type (
BPM() (bpm float64, ok bool) BPM() (bpm float64, ok bool)
} }
EventProcessor interface {
ProcessMessage(msg interface{})
ProcessEvent(event MIDINoteEvent)
}
// 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.
@ -89,9 +94,11 @@ const numRenderTries = 10000
// 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) { func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext, ui EventProcessor) {
p.processMessages(context) p.processMessages(context, ui)
midi, midiOk := context.NextEvent() midi, midiOk := context.NextEvent()
frame := 0 frame := 0
if p.recState == recStateRecording { if p.recState == recStateRecording {
@ -116,6 +123,10 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext
} else { } else {
p.releaseInstrument(midi.Channel, midi.Note) p.releaseInstrument(midi.Channel, midi.Note)
} }
if ui != nil {
ui.ProcessEvent(midi)
}
midi, midiOk = context.NextEvent() midi, midiOk = context.NextEvent()
} }
framesUntilMidi := len(buffer) framesUntilMidi := len(buffer)
@ -224,7 +235,7 @@ func (p *Player) advanceRow() {
p.rowtime = 0 p.rowtime = 0
} }
func (p *Player) processMessages(context PlayerProcessContext) { func (p *Player) processMessages(context PlayerProcessContext, uiProcessor EventProcessor) {
loop: loop:
for { // process new message for { // process new message
select { select {
@ -296,6 +307,9 @@ loop:
default: default:
// ignore unknown messages // ignore unknown messages
} }
if uiProcessor != nil {
uiProcessor.ProcessMessage(msg)
}
default: default:
break loop break loop
} }

20
tracker/processor.go Normal file
View File

@ -0,0 +1,20 @@
package tracker
import (
"github.com/vsariola/sointu"
)
type Processor struct {
*Player
playerProcessContext PlayerProcessContext
uiProcessor EventProcessor
}
func NewProcessor(player *Player, context PlayerProcessContext, uiProcessor EventProcessor) *Processor {
return &Processor{player, context, uiProcessor}
}
func (p *Processor) ReadAudio(buf sointu.AudioBuffer) error {
p.Player.Process(buf, p.playerProcessContext, p.uiProcessor)
return nil
}

View File

@ -1,6 +1,7 @@
package tracker package tracker
import ( import (
"iter"
"math" "math"
"github.com/vsariola/sointu" "github.com/vsariola/sointu"
@ -117,6 +118,13 @@ func (v Table) Clear() {
} }
} }
func (v Table) Set(value byte) {
defer v.change("Set", MajorChange)()
cursor := v.Cursor()
// TODO: might check for visibility
v.set(cursor, int(value))
}
func (v Table) Fill(value int) { func (v Table) Fill(value int) {
defer v.change("Fill", MajorChange)() defer v.change("Fill", MajorChange)()
rect := v.Range() rect := v.Range()
@ -364,12 +372,8 @@ func (e *Order) Title(x int) (title string) {
if x < 0 || x >= len(e.d.Song.Score.Tracks) { if x < 0 || x >= len(e.d.Song.Score.Tracks) {
return return
} }
t := e.d.Song.Score.Tracks[x] firstIndex, lastIndex, err := e.instrumentListFor(x)
firstVoice := e.d.Song.Score.FirstVoiceForTrack(x) if err != nil {
lastVoice := firstVoice + t.NumVoices - 1
firstIndex, err := e.d.Song.Patch.InstrumentForVoice(firstVoice)
lastIndex, err2 := e.d.Song.Patch.InstrumentForVoice(lastVoice)
if err != nil || err2 != nil {
return return
} }
switch diff := lastIndex - firstIndex; diff { switch diff := lastIndex - firstIndex; diff {
@ -397,6 +401,37 @@ func (e *Order) Title(x int) (title string) {
return return
} }
func (e *Order) instrumentListFor(trackIndex int) (int, int, error) {
track := e.d.Song.Score.Tracks[trackIndex]
firstVoice := e.d.Song.Score.FirstVoiceForTrack(trackIndex)
lastVoice := firstVoice + track.NumVoices - 1
firstIndex, err1 := e.d.Song.Patch.InstrumentForVoice(firstVoice)
if err1 != nil {
return trackIndex, trackIndex, err1
}
lastIndex, err2 := e.d.Song.Patch.InstrumentForVoice(lastVoice)
if err2 != nil {
return trackIndex, trackIndex, err2
}
return firstIndex, lastIndex, nil
}
func (e *Order) TrackIndicesForCurrentInstrument() iter.Seq[int] {
currentTrack := e.d.Cursor.Track
return e.d.Song.AllTracksWithSameInstrument(currentTrack)
}
func (e *Order) CountNextTracksForCurrentInstrument() int {
currentTrack := e.d.Cursor.Track
count := 0
for t := range e.TrackIndicesForCurrentInstrument() {
if t > currentTrack {
count++
}
}
return count
}
// NoteTable // NoteTable
func (v *Notes) Table() Table { func (v *Notes) Table() Table {