diff --git a/CHANGELOG.md b/CHANGELOG.md index 54da48d..03decdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added +- Mute and solo toggles for instruments ([#168][i168]) - Compressor displays threshold and invgain in dB - Dragging mouse to select rectangles in the tables - The standalone tracker can open a MIDI port for receiving MIDI notes @@ -265,4 +266,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). [i158]: https://github.com/vsariola/sointu/issues/158 [i160]: https://github.com/vsariola/sointu/issues/160 [i162]: https://github.com/vsariola/sointu/issues/162 -[i166]: https://github.com/vsariola/sointu/issues/166 \ No newline at end of file +[i166]: https://github.com/vsariola/sointu/issues/166 +[i168]: https://github.com/vsariola/sointu/issues/168 \ No newline at end of file diff --git a/patch.go b/patch.go index a51c0f2..cca1fbd 100644 --- a/patch.go +++ b/patch.go @@ -18,6 +18,7 @@ type ( Comment string `yaml:",omitempty"` NumVoices int Units []Unit + Mute bool `yaml:",omitempty"` // Mute is only used in the tracker for soloing/muting instruments; the compiled player ignores this field } // Unit is e.g. a filter, oscillator, envelope and its parameters @@ -352,7 +353,7 @@ func (instr *Instrument) Copy() Instrument { for i, u := range instr.Units { units[i] = u.Copy() } - return Instrument{Name: instr.Name, Comment: instr.Comment, NumVoices: instr.NumVoices, Units: units} + return Instrument{Name: instr.Name, Comment: instr.Comment, NumVoices: instr.NumVoices, Units: units, Mute: instr.Mute} } // Copy makes a deep copy of a Patch. diff --git a/tracker/bool.go b/tracker/bool.go index f050e20..b663cd3 100644 --- a/tracker/bool.go +++ b/tracker/bool.go @@ -22,6 +22,8 @@ type ( UnitDisabled Model LoopToggle Model UniquePatterns Model + Mute Model + Solo Model ) func (v Bool) Toggle() { @@ -47,6 +49,8 @@ func (m *Model) UnitSearching() *UnitSearching { return (*UnitSearching)(m) func (m *Model) UnitDisabled() *UnitDisabled { return (*UnitDisabled)(m) } func (m *Model) LoopToggle() *LoopToggle { return (*LoopToggle)(m) } func (m *Model) UniquePatterns() *UniquePatterns { return (*UniquePatterns)(m) } +func (m *Model) Mute() *Mute { return (*Mute)(m) } +func (m *Model) Solo() *Solo { return (*Solo)(m) } // Panic methods @@ -194,3 +198,53 @@ func (m *UniquePatterns) Bool() Bool { return Bool{m} } func (m *UniquePatterns) Value() bool { return m.uniquePatterns } func (m *UniquePatterns) setValue(val bool) { m.uniquePatterns = val } func (m *UniquePatterns) Enabled() bool { return true } + +// Mute methods + +func (m *Mute) Bool() Bool { return Bool{m} } +func (m *Mute) Value() bool { + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return false + } + return m.d.Song.Patch[m.d.InstrIndex].Mute +} +func (m *Mute) setValue(val bool) { + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return + } + defer (*Model)(m).change("Mute", PatchChange, MinorChange)() + m.d.Song.Patch[m.d.InstrIndex].Mute = val +} +func (m *Mute) Enabled() bool { return m.d.InstrIndex >= 0 && m.d.InstrIndex < len(m.d.Song.Patch) } + +// Solo methods + +func (m *Solo) Bool() Bool { return Bool{m} } +func (m *Solo) Value() bool { + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return false + } + for i := range m.d.Song.Patch { + if i == m.d.InstrIndex { + continue + } + if !m.d.Song.Patch[i].Mute { + return false + } + } + return !m.d.Song.Patch[m.d.InstrIndex].Mute +} +func (m *Solo) setValue(val bool) { + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return + } + defer (*Model)(m).change("Solo", PatchChange, MinorChange)() + for i := range m.d.Song.Patch { + if i == m.d.InstrIndex { + continue + } + m.d.Song.Patch[i].Mute = val + } + m.d.Song.Patch[m.d.InstrIndex].Mute = false +} +func (m *Solo) Enabled() bool { return m.d.InstrIndex >= 0 && m.d.InstrIndex < len(m.d.Song.Patch) } diff --git a/tracker/gioui/instrument_editor.go b/tracker/gioui/instrument_editor.go index 3dfea45..df06017 100644 --- a/tracker/gioui/instrument_editor.go +++ b/tracker/gioui/instrument_editor.go @@ -33,6 +33,8 @@ type InstrumentEditor struct { addUnitBtn *ActionClickable presetMenuBtn *TipClickable commentExpandBtn *BoolClickable + soloBtn *BoolClickable + muteBtn *BoolClickable commentEditor *Editor commentString tracker.String nameEditor *Editor @@ -51,6 +53,8 @@ type InstrumentEditor struct { expandCommentHint string collapseCommentHint string deleteInstrumentHint string + muteHint, unmuteHint string + soloHint, unsoloHint string } func NewInstrumentEditor(model *tracker.Model) *InstrumentEditor { @@ -64,6 +68,8 @@ func NewInstrumentEditor(model *tracker.Model) *InstrumentEditor { addUnitBtn: NewActionClickable(model.AddUnit(false)), commentExpandBtn: NewBoolClickable(model.CommentExpanded().Bool()), presetMenuBtn: new(TipClickable), + soloBtn: NewBoolClickable(model.Solo().Bool()), + muteBtn: NewBoolClickable(model.Mute().Bool()), commentEditor: NewEditor(widget.Editor{}), nameEditor: NewEditor(widget.Editor{SingleLine: true, Submit: true, Alignment: text.Middle}), searchEditor: NewEditor(widget.Editor{SingleLine: true, Submit: true, Alignment: text.Start}), @@ -85,6 +91,10 @@ func NewInstrumentEditor(model *tracker.Model) *InstrumentEditor { ret.expandCommentHint = makeHint("Expand comment", " (%s)", "CommentExpandedToggle") ret.collapseCommentHint = makeHint("Collapse comment", " (%s)", "CommentExpandedToggle") ret.deleteInstrumentHint = makeHint("Delete\ninstrument", "\n(%s)", "DeleteInstrument") + ret.muteHint = makeHint("Mute", " (%s)", "MuteToggle") + ret.unmuteHint = makeHint("Unmute", " (%s)", "MuteToggle") + ret.soloHint = makeHint("Solo", " (%s)", "SoloToggle") + ret.unsoloHint = makeHint("Unsolo", " (%s)", "SoloToggle") return ret } @@ -163,6 +173,8 @@ func (ie *InstrumentEditor) layoutInstrumentHeader(gtx C, t *Tracker) D { saveInstrumentBtnStyle := TipIcon(t.Theme, ie.saveInstrumentBtn, icons.ContentSave, "Save instrument") loadInstrumentBtnStyle := TipIcon(t.Theme, ie.loadInstrumentBtn, icons.FileFolderOpen, "Load instrument") deleteInstrumentBtnStyle := ActionIcon(gtx, t.Theme, ie.deleteInstrumentBtn, icons.ActionDelete, ie.deleteInstrumentHint) + soloBtnStyle := ToggleIcon(gtx, t.Theme, ie.soloBtn, icons.SocialGroup, icons.SocialPerson, ie.soloHint, ie.unsoloHint) + muteBtnStyle := ToggleIcon(gtx, t.Theme, ie.muteBtn, icons.AVVolumeUp, icons.AVVolumeOff, ie.muteHint, ie.unmuteHint) m := PopupMenu(&ie.presetMenu, t.Theme.Shaper) @@ -199,6 +211,8 @@ func (ie *InstrumentEditor) layoutInstrumentHeader(gtx C, t *Tracker) D { }), layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Rigid(commentExpandBtnStyle.Layout), + layout.Rigid(soloBtnStyle.Layout), + layout.Rigid(muteBtnStyle.Layout), layout.Rigid(func(gtx C) D { //defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() dims := presetMenuBtnStyle.Layout(gtx) @@ -249,7 +263,7 @@ func (ie *InstrumentEditor) layoutInstrumentList(gtx C, t *Tracker) D { gtx.Constraints.Min.X = gtx.Dp(unit.Dp(30)) grabhandle := LabelStyle{Text: strconv.Itoa(i + 1), ShadeColor: black, Color: mediumEmphasisTextColor, FontSize: unit.Sp(10), Alignment: layout.Center, Shaper: t.Theme.Shaper} label := func(gtx C) D { - name, level, ok := (*tracker.Instruments)(t.Model).Item(i) + name, level, mute, ok := (*tracker.Instruments)(t.Model).Item(i) if !ok { labelStyle := LabelStyle{Text: "", ShadeColor: black, Color: white, FontSize: unit.Sp(12), Shaper: t.Theme.Shaper} return layout.Center.Layout(gtx, labelStyle.Layout) @@ -266,6 +280,10 @@ func (ie *InstrumentEditor) layoutInstrumentList(gtx C, t *Tracker) D { style.HintColor = instrumentNameHintColor style.TextSize = unit.Sp(12) style.Font = labelDefaultFont + if mute { + style.Color = disabledTextColor + style.Font.Style = font.Italic + } dims := layout.Center.Layout(gtx, func(gtx C) D { defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() return style.Layout(gtx) @@ -277,6 +295,10 @@ func (ie *InstrumentEditor) layoutInstrumentList(gtx C, t *Tracker) D { name = "Instr" } labelStyle := LabelStyle{Text: name, ShadeColor: black, Color: color, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.Theme.Shaper} + if mute { + labelStyle.Color = disabledTextColor + labelStyle.Font.Style = font.Italic + } return layout.Center.Layout(gtx, labelStyle.Layout) } return layout.Inset{Left: unit.Dp(6), Right: unit.Dp(6), Top: unit.Dp(4)}.Layout(gtx, func(gtx C) D { diff --git a/tracker/gioui/keybindings.yml b/tracker/gioui/keybindings.yml index f8732f8..d570721 100644 --- a/tracker/gioui/keybindings.yml +++ b/tracker/gioui/keybindings.yml @@ -8,6 +8,8 @@ - {key: "L", shortcut: true, action: "LoopToggle"} - {key: "N", shortcut: true, action: "NewSong"} - {key: "S", shortcut: true, action: "SaveSong"} +- {key: "M", shortcut: true, action: "MuteToggle"} +- {key: ",", shortcut: true, action: "SoloToggle"} - {key: "O", shortcut: true, action: "OpenSong"} - {key: "I", shortcut: true, shift: true, action: "DeleteInstrument"} - {key: "I", shortcut: true, action: "AddInstrument"} diff --git a/tracker/gioui/keyevent.go b/tracker/gioui/keyevent.go index 737e66b..cdce2f1 100644 --- a/tracker/gioui/keyevent.go +++ b/tracker/gioui/keyevent.go @@ -222,6 +222,10 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) { t.LoopToggle().Bool().Toggle() case "UniquePatternsToggle": t.UniquePatterns().Bool().Toggle() + case "MuteToggle": + t.Mute().Bool().Toggle() + case "SoloToggle": + t.Solo().Bool().Toggle() // Integers case "InstrumentVoicesAdd": t.Model.InstrumentVoices().Int().Add(1) diff --git a/tracker/gioui/unit_editor.go b/tracker/gioui/unit_editor.go index 1bb279e..2e0de2f 100644 --- a/tracker/gioui/unit_editor.go +++ b/tracker/gioui/unit_editor.go @@ -319,7 +319,7 @@ func (p ParameterStyle) Layout(gtx C) D { instrItems := make([]MenuItem, p.tracker.Instruments().Count()) for i := range instrItems { i := i - name, _, _ := p.tracker.Instruments().Item(i) + name, _, _, _ := p.tracker.Instruments().Item(i) instrItems[i].Text = name instrItems[i].IconBytes = icons.NavigationChevronRight instrItems[i].Doer = tracker.Allow(func() { diff --git a/tracker/list.go b/tracker/list.go index a39d65c..a970101 100644 --- a/tracker/list.go +++ b/tracker/list.go @@ -168,11 +168,12 @@ func (v *Instruments) List() List { return List{v} } -func (v *Instruments) Item(i int) (name string, maxLevel float32, ok bool) { +func (v *Instruments) Item(i int) (name string, maxLevel float32, mute bool, ok bool) { if i < 0 || i >= len(v.d.Song.Patch) { - return "", 0, false + return "", 0, false, false } name = v.d.Song.Patch[i].Name + mute = v.d.Song.Patch[i].Mute start := v.d.Song.Patch.FirstVoiceForInstrument(i) end := start + v.d.Song.Patch[i].NumVoices if end >= vm.MAX_VOICES { diff --git a/tracker/player.go b/tracker/player.go index f444632..b512af6 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -331,6 +331,15 @@ func (p *Player) compileOrUpdateSynth() { return } } + voice := 0 + for _, instr := range p.song.Patch { + if instr.Mute { + for j := 0; j < instr.NumVoices; j++ { + p.synth.Release(voice + j) + } + } + voice += instr.NumVoices + } } // all sends from player are always non-blocking, to ensure that the player thread cannot end up in a dead-lock @@ -387,11 +396,13 @@ func (p *Player) trigger(voiceStart, voiceEnd int, note byte, ID int) { age = p.voices[i].samplesSinceEvent } } + instrIndex, err := p.song.Patch.InstrumentForVoice(oldestVoice) + if err != nil || p.song.Patch[instrIndex].Mute { + return + } p.voices[oldestVoice] = voice{noteID: ID, sustain: true, samplesSinceEvent: 0} p.voiceLevels[oldestVoice] = 1.0 - if p.synth != nil { - p.synth.Trigger(oldestVoice, note) - } + p.synth.Trigger(oldestVoice, note) } func (p *Player) release(ID int) {