diff --git a/cmd/sointu-track/main.go b/cmd/sointu-track/main.go index 093e107..5924023 100644 --- a/cmd/sointu-track/main.go +++ b/cmd/sointu-track/main.go @@ -28,6 +28,14 @@ func (NullContext) BPM() (bpm float64, ok bool) { return 0, false } +func (NullContext) Params() (ret tracker.ExtParamArray, ok bool) { + return tracker.ExtParamArray{}, false +} + +func (NullContext) SetParams(params tracker.ExtParamArray) bool { + return false +} + var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`") var memprofile = flag.String("memprofile", "", "write memory profile to `file`") diff --git a/cmd/sointu-vsti/main.go b/cmd/sointu-vsti/main.go index 287877e..638e9e4 100644 --- a/cmd/sointu-vsti/main.go +++ b/cmd/sointu-vsti/main.go @@ -22,6 +22,7 @@ type VSTIProcessContext struct { events []vst2.MIDIEvent eventIndex int host vst2.Host + parameters []*vst2.Parameter } func (c *VSTIProcessContext) NextEvent() (event tracker.MIDINoteEvent, ok bool) { @@ -52,6 +53,28 @@ func (c *VSTIProcessContext) BPM() (bpm float64, ok bool) { return timeInfo.Tempo, true } +func (c *VSTIProcessContext) Params() (ret tracker.ExtParamArray, ok bool) { + for i, p := range c.parameters { + ret[i] = p.Value + } + return ret, true +} + +func (c *VSTIProcessContext) SetParams(val tracker.ExtParamArray) bool { + changed := false + for i, p := range c.parameters { + if p.Value != val[i] { + p.Value = val[i] + p.Name = fmt.Sprintf("P%f", val[i]) + changed = true + } + } + if changed { + c.host.UpdateDisplay() + } + return true +} + func init() { var ( version = int32(100) @@ -74,7 +97,22 @@ func init() { }) } go t.Main() - context := VSTIProcessContext{host: h} + parameters := make([]*vst2.Parameter, 0, tracker.ExtParamCount) + for i := 0; i < tracker.ExtParamCount; i++ { + parameters = append(parameters, + &vst2.Parameter{ + Name: fmt.Sprintf("P%d", i), + NotAutomated: true, + Unit: "foobar", + GetValueFunc: func(value float32) float32 { + return float32(int(value*128 + 1)) + }, + GetValueLabelFunc: func(value float32) string { + return fmt.Sprintf("%d", int(value)) + }, + }) + } + context := VSTIProcessContext{host: h, parameters: parameters} buf := make(sointu.AudioBuffer, 1024) return vst2.Plugin{ UniqueID: PLUGIN_ID, @@ -85,6 +123,7 @@ func init() { Vendor: "vsariola/sointu", Category: vst2.PluginCategorySynth, Flags: vst2.PluginIsSynth, + Parameters: parameters, ProcessFloatFunc: func(in, out vst2.FloatBuffer) { left := out.Channel(0) right := out.Channel(1) diff --git a/go.mod b/go.mod index e9d25e8..e1e887c 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/vsariola/sointu go 1.21 +replace pipelined.dev/audio/vst2 => ../vst2 + require ( gioui.org v0.5.0 gioui.org/x v0.5.0 diff --git a/tracker/action.go b/tracker/action.go index faa6f5b..60a9e1f 100644 --- a/tracker/action.go +++ b/tracker/action.go @@ -176,6 +176,7 @@ func (m *Model) Undo() Action { m.undoStack = m.undoStack[:len(m.undoStack)-1] m.prevUndoKind = "" (*Model)(m).send(m.d.Song.Copy()) + (*Model)(m).send(m.ExtParams()) }, } } @@ -193,6 +194,7 @@ func (m *Model) Redo() Action { m.redoStack = m.redoStack[:len(m.redoStack)-1] m.prevUndoKind = "" (*Model)(m).send(m.d.Song.Copy()) + (*Model)(m).send(m.ExtParams()) }, } } @@ -408,3 +410,30 @@ func (m *Model) completeAction(checkSave bool) { m.dialog = NoDialog } } + +func (m *Model) SetExtLink(index int) Action { + return Action{ + do: func() { + defer m.change("SetExtLink", ExtParamLinkChange, MajorChange)() + p, _ := m.Params().SelectedItem().(NamedParameter) + for i := range p.m.d.ExtParamLinks { + if i == index { + continue + } + if p.m.d.ExtParamLinks[i].UnitID == p.unit.ID && p.m.d.ExtParamLinks[i].ParamName == p.up.Name { + p.m.d.ExtParamLinks[i] = ExtParamLink{} + } + } + if index > -1 { + p.m.d.ExtParamLinks[index] = ExtParamLink{UnitID: p.unit.ID, ParamName: p.up.Name} + } + }, + allowed: func() bool { + if index < -1 || index >= len(m.d.ExtParamLinks) { + return false + } + _, ok := m.Params().SelectedItem().(NamedParameter) + return ok + }, + } +} diff --git a/tracker/gioui/unit_editor.go b/tracker/gioui/unit_editor.go index 5a551dd..0bbc78d 100644 --- a/tracker/gioui/unit_editor.go +++ b/tracker/gioui/unit_editor.go @@ -24,27 +24,41 @@ import ( ) type UnitEditor struct { - sliderList *DragList - searchList *DragList - Parameters []*ParameterWidget - DeleteUnitBtn *ActionClickable - CopyUnitBtn *TipClickable - ClearUnitBtn *ActionClickable - DisableUnitBtn *BoolClickable - SelectTypeBtn *widget.Clickable - caser cases.Caser + sliderList *DragList + searchList *DragList + Parameters []*ParameterWidget + DeleteUnitBtn *ActionClickable + CopyUnitBtn *TipClickable + ClearUnitBtn *ActionClickable + DisableUnitBtn *BoolClickable + SelectTypeBtn *widget.Clickable + ExtLinkMenuBtn *TipClickable + ExtLinkMenuItems []MenuItem + ExtLinkMenu Menu + caser cases.Caser } func NewUnitEditor(m *tracker.Model) *UnitEditor { ret := &UnitEditor{ - DeleteUnitBtn: NewActionClickable(m.DeleteUnit()), - ClearUnitBtn: NewActionClickable(m.ClearUnit()), - DisableUnitBtn: NewBoolClickable(m.UnitDisabled().Bool()), - CopyUnitBtn: new(TipClickable), - SelectTypeBtn: new(widget.Clickable), - sliderList: NewDragList(m.Params().List(), layout.Vertical), - searchList: NewDragList(m.SearchResults().List(), layout.Vertical), + DeleteUnitBtn: NewActionClickable(m.DeleteUnit()), + ClearUnitBtn: NewActionClickable(m.ClearUnit()), + DisableUnitBtn: NewBoolClickable(m.UnitDisabled().Bool()), + CopyUnitBtn: new(TipClickable), + SelectTypeBtn: new(widget.Clickable), + sliderList: NewDragList(m.Params().List(), layout.Vertical), + searchList: NewDragList(m.SearchResults().List(), layout.Vertical), + ExtLinkMenuBtn: new(TipClickable), + ExtLinkMenu: Menu{}, + ExtLinkMenuItems: []MenuItem{{Text: "None", IconBytes: icons.HardwarePhoneLinkOff, Doer: m.SetExtLink(-1)}}, } + for i := 0; i < tracker.ExtParamCount; i++ { + ret.ExtLinkMenuItems = append(ret.ExtLinkMenuItems, MenuItem{ + Text: fmt.Sprintf("P%v", i), + IconBytes: icons.HardwarePhoneLink, + Doer: m.SetExtLink(i), + }) + } + ret.caser = cases.Title(language.English) return ret } @@ -125,6 +139,7 @@ func (pe *UnitEditor) layoutFooter(gtx C, t *Tracker) D { copyUnitBtnStyle := TipIcon(t.Theme, pe.CopyUnitBtn, icons.ContentContentCopy, "Copy unit (Ctrl+C)") deleteUnitBtnStyle := ActionIcon(gtx, t.Theme, pe.DeleteUnitBtn, icons.ActionDelete, "Delete unit (Ctrl+Backspace)") disableUnitBtnStyle := ToggleIcon(gtx, t.Theme, pe.DisableUnitBtn, icons.AVVolumeUp, icons.AVVolumeOff, "Disable unit (Ctrl-D)", "Enable unit (Ctrl-D)") + extLinkMenuBtnStyle := TipIcon(t.Theme, pe.ExtLinkMenuBtn, icons.ContentLink, "Link to external parameter") text := t.Units().SelectedType() if text == "" { text = "Choose unit type" @@ -132,7 +147,21 @@ func (pe *UnitEditor) layoutFooter(gtx C, t *Tracker) D { text = pe.caser.String(text) } hintText := Label(text, white, t.Theme.Shaper) + m := PopupMenu(&pe.ExtLinkMenu, t.Theme.Shaper) + + for pe.ExtLinkMenuBtn.Clickable.Clicked(gtx) { + pe.ExtLinkMenu.Visible = true + } + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + dims := extLinkMenuBtnStyle.Layout(gtx) + op.Offset(image.Pt(0, dims.Size.Y)).Add(gtx.Ops) + gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(300)) + gtx.Constraints.Max.X = gtx.Dp(unit.Dp(180)) + m.Layout(gtx, pe.ExtLinkMenuItems...) + return dims + }), layout.Rigid(deleteUnitBtnStyle.Layout), layout.Rigid(copyUnitBtnStyle.Layout), layout.Rigid(disableUnitBtnStyle.Layout), diff --git a/tracker/model.go b/tracker/model.go index 44a58aa..2d53f52 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "os" "path/filepath" @@ -11,6 +12,8 @@ import ( "github.com/vsariola/sointu/vm" ) +const ExtParamCount = 16 + // Model implements the mutable state for the tracker program GUI. // // Go does not have immutable slices, so there's no efficient way to guarantee @@ -37,6 +40,7 @@ type ( RecoveryFilePath string ChangedSinceRecovery bool Loop Loop + ExtParamLinks [ExtParamCount]ExtParamLink } Model struct { @@ -106,6 +110,16 @@ type ( model *Model } + // ExtParamLink is for linking VST parameters to the patch parameters. There's + // fixed number of parameters in the VST plugin and these are linked to + // particular parameters of a unit. + ExtParamLink struct { + UnitID int + ParamName string + } + + ExtParamArray [ExtParamCount]float32 + IsPlayingMsg struct{ bool } StartPlayMsg struct{ sointu.SongPos } BPMMsg struct{ int } @@ -133,6 +147,7 @@ const ( BPMChange RowsPerBeatChange LoopChange + ExtParamLinkChange SongChange ChangeType = PatchChange | ScoreChange | BPMChange | RowsPerBeatChange ) @@ -247,6 +262,9 @@ func (m *Model) change(kind string, t ChangeType, severity ChangeSeverity) func( if m.changeType&LoopChange != 0 { m.send(m.d.Loop) } + if m.changeType&ExtParamLinkChange != 0 || m.changeType&PatchChange != 0 { + m.send(m.ExtParams()) + } m.undoSkipCounter++ var limit int switch m.changeSeverity { @@ -322,6 +340,7 @@ func (m *Model) UnmarshalRecovery(bytes []byte) { } m.d.ChangedSinceRecovery = false m.send(m.d.Song.Copy()) + m.send(m.ExtParams()) m.send(m.d.Loop) m.updatePatternUseCount() } @@ -355,6 +374,8 @@ func (m *Model) ProcessPlayerMessage(msg PlayerMsg) { m.Alerts().AddAlert(e) case IsPlayingMsg: m.playing = e.bool + case ExtParamArray: + m.SetExtParams(e) default: } } @@ -389,6 +410,50 @@ func (m *Model) Instrument(index int) sointu.Instrument { return m.d.Song.Patch[index].Copy() } +func (m *Model) ExtParams() (ret ExtParamArray) { + for i, l := range m.d.ExtParamLinks { + instrIndex, unitIndex, err := m.d.Song.Patch.FindUnit(l.UnitID) + if err != nil { + continue + } + unit := m.d.Song.Patch[instrIndex].Units[unitIndex] + if up, ok := sointu.UnitTypes[unit.Type]; ok { + for _, p := range up { + if p.Name == l.ParamName && p.CanSet && p.MaxValue > p.MinValue { + ret[i] = float32(unit.Parameters[l.ParamName]-p.MinValue) / float32(p.MaxValue-p.MinValue) + } + } + } + } + return +} + +func (m *Model) SetExtParams(params ExtParamArray) { + defer m.change("SetParamValue", PatchChange, MinorChange)() + changed := false + for i, l := range m.d.ExtParamLinks { + instrIndex, unitIndex, err := m.d.Song.Patch.FindUnit(l.UnitID) + if err != nil { + continue + } + unit := m.d.Song.Patch[instrIndex].Units[unitIndex] + if up, ok := sointu.UnitTypes[unit.Type]; ok { + for _, p := range up { + if p.Name == l.ParamName && p.CanSet && p.MaxValue > p.MinValue { + newVal := int(math.Round(float64(params[i])*float64(p.MaxValue-p.MinValue))) + p.MinValue + if unit.Parameters[l.ParamName] != newVal { + unit.Parameters[l.ParamName] = newVal + changed = true + } + } + } + } + } + if !changed { + m.changeCancel = true + } +} + func (d *modelData) Copy() modelData { ret := *d ret.Song = d.Song.Copy() diff --git a/tracker/model_test.go b/tracker/model_test.go index 706b05d..69ce4d3 100644 --- a/tracker/model_test.go +++ b/tracker/model_test.go @@ -21,6 +21,14 @@ func (NullContext) BPM() (bpm float64, ok bool) { return 0, false } +func (NullContext) Params() (params tracker.ExtParamArray, ok bool) { + return tracker.ExtParamArray{}, false +} + +func (NullContext) SetParams(params tracker.ExtParamArray) bool { + return false +} + type modelFuzzState struct { model *tracker.Model clipboard []byte diff --git a/tracker/player.go b/tracker/player.go index 52b31da..d4c0d46 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -25,6 +25,7 @@ type ( voiceLevels [vm.MAX_VOICES]float32 // a level that can be used to visualize the volume of each voice voices [vm.MAX_VOICES]voice loop Loop + extParamValues ExtParamArray recState recState // is the recording off; are we waiting for a note; or are we recording recording Recording // the recorded MIDI events and BPM @@ -39,6 +40,8 @@ type ( PlayerProcessContext interface { NextEvent() (event MIDINoteEvent, ok bool) BPM() (bpm float64, ok bool) + Params() (params ExtParamArray, ok bool) + SetParams(params ExtParamArray) bool } // MIDINoteEvent is a MIDI event triggering or releasing a note. In @@ -225,6 +228,10 @@ func (p *Player) advanceRow() { } func (p *Player) processMessages(context PlayerProcessContext) { + if value, ok := context.Params(); ok && value != p.extParamValues { + p.extParamValues = value + p.send(p.extParamValues) + } loop: for { // process new message select { @@ -293,6 +300,8 @@ loop: } p.recState = recStateNone } + case ExtParamArray: + context.SetParams(m) default: // ignore unknown messages }