diff --git a/CHANGELOG.md b/CHANGELOG.md index 635087a..7df0172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased ### Added +- Ability to loop certain section of the song when playing. The loop can be set + by using the toggle button in the song panel, or by hitting Ctrl+L. + ([#128][i128]) - Disable units temporarily. The disabled units are shown in gray and are not compiled into the patch and are considered for all purposes non-existent. Hitting Ctrl-D disables/re-enables the selected unit(s). The yaml file has @@ -139,5 +142,6 @@ 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 +[i128]: https://github.com/vsariola/sointu/issues/128 [i129]: https://github.com/vsariola/sointu/issues/129 [i130]: https://github.com/vsariola/sointu/issues/130 diff --git a/tracker/bool.go b/tracker/bool.go index bf0936b..b7cb7f6 100644 --- a/tracker/bool.go +++ b/tracker/bool.go @@ -20,6 +20,7 @@ type ( NoteTracking Model UnitSearching Model UnitDisabled Model + LoopToggle Model ) func (v Bool) Toggle() { @@ -43,6 +44,7 @@ func (m *Model) CommentExpanded() *CommentExpanded { return (*CommentExpanded)(m func (m *Model) NoteTracking() *NoteTracking { return (*NoteTracking)(m) } func (m *Model) UnitSearching() *UnitSearching { return (*UnitSearching)(m) } func (m *Model) UnitDisabled() *UnitDisabled { return (*UnitDisabled)(m) } +func (m *Model) LoopToggle() *LoopToggle { return (*LoopToggle)(m) } // Panic methods @@ -161,3 +163,20 @@ func (m *UnitDisabled) Enabled() bool { } return true } + +// LoopToggle methods + +func (m *LoopToggle) Bool() Bool { return Bool{m} } +func (m *LoopToggle) Value() bool { return m.d.Loop.Length > 0 } +func (t *LoopToggle) setValue(val bool) { + m := (*Model)(t) + defer m.change("SetLoopAction", LoopChange, MinorChange)() + if !val { + m.d.Loop = Loop{} + return + } + l := m.OrderRows().List() + a, b := l.listRange() + m.d.Loop = Loop{a, b - a + 1} +} +func (m *LoopToggle) Enabled() bool { return true } diff --git a/tracker/gioui/keyevent.go b/tracker/gioui/keyevent.go index 9276b1b..e83a907 100644 --- a/tracker/gioui/keyevent.go +++ b/tracker/gioui/keyevent.go @@ -69,6 +69,11 @@ func (t *Tracker) KeyEvent(e key.Event, o *op.Ops) { t.Model.UnitDisabled().Bool().Toggle() return } + case "L": + if e.Modifiers.Contain(key.ModShortcut) { + t.Model.LoopToggle().Bool().Toggle() + return + } case "N": if e.Modifiers.Contain(key.ModShortcut) { t.NewSong().Do() diff --git a/tracker/gioui/note_editor.go b/tracker/gioui/note_editor.go index 1c528f6..0371da0 100644 --- a/tracker/gioui/note_editor.go +++ b/tracker/gioui/note_editor.go @@ -202,9 +202,13 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { pat := j / rpp row := j % rpp w := pxPatMarkWidth + pxRowMarkWidth - paint.ColorOp{Color: rowMarkerPatternTextColor}.Add(gtx.Ops) defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop() if row == 0 { + color := rowMarkerPatternTextColor + if l := t.Loop(); pat >= l.Start && pat < l.Start+l.Length { + color = loopMarkerColor + } + paint.ColorOp{Color: color}.Add(gtx.Ops) widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", pat)), op.CallOp{}) } defer op.Offset(image.Pt(pxPatMarkWidth, 0)).Push(gtx.Ops).Pop() diff --git a/tracker/gioui/order_editor.go b/tracker/gioui/order_editor.go index 51a1e09..de2a203 100644 --- a/tracker/gioui/order_editor.go +++ b/tracker/gioui/order_editor.go @@ -81,11 +81,15 @@ func (oe *OrderEditor) Layout(gtx C, t *Tracker) D { } rowTitle := func(gtx C, j int) D { + w := gtx.Dp(unit.Dp(30)) if playPos := t.PlayPosition(); t.SongPanel.PlayingBtn.Bool.Value() && j == playPos.OrderRow { paint.FillShape(gtx.Ops, patternPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, patternCellHeight)}.Op()) } - w := gtx.Dp(unit.Dp(30)) - paint.ColorOp{Color: rowMarkerPatternTextColor}.Add(gtx.Ops) + color := rowMarkerPatternTextColor + if l := t.Loop(); j >= l.Start && j < l.Start+l.Length { + color = loopMarkerColor + } + paint.ColorOp{Color: color}.Add(gtx.Ops) defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop() widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", j)), op.CallOp{}) return D{Size: image.Pt(w, patternCellHeight)} diff --git a/tracker/gioui/songpanel.go b/tracker/gioui/songpanel.go index 4d30361..a06da4a 100644 --- a/tracker/gioui/songpanel.go +++ b/tracker/gioui/songpanel.go @@ -26,6 +26,7 @@ type SongPanel struct { RecordBtn *BoolClickable NoteTracking *BoolClickable PanicBtn *BoolClickable + LoopBtn *BoolClickable // File menu items fileMenuItems []MenuItem @@ -50,6 +51,7 @@ func NewSongPanel(model *tracker.Model) *SongPanel { Step: NewNumberInput(model.Step().Int()), SongLength: NewNumberInput(model.SongLength().Int()), PanicBtn: NewBoolClickable(model.Panic().Bool()), + LoopBtn: NewBoolClickable(model.LoopToggle().Bool()), RecordBtn: NewBoolClickable(model.IsRecording().Bool()), NoteTracking: NewBoolClickable(model.NoteTracking().Bool()), PlayingBtn: NewBoolClickable(model.Playing().Bool()), @@ -106,6 +108,7 @@ func (t *SongPanel) layoutSongOptions(gtx C, tr *Tracker) D { playBtnStyle := ToggleIcon(tr.Theme, t.PlayingBtn, icons.AVPlayArrow, icons.AVStop, "Play (F6 / Space)", "Stop (F6 / Space)") recordBtnStyle := ToggleIcon(tr.Theme, t.RecordBtn, icons.AVFiberManualRecord, icons.AVFiberSmartRecord, "Record (F7)", "Stop (F7)") noteTrackBtnStyle := ToggleIcon(tr.Theme, t.NoteTracking, icons.ActionSpeakerNotesOff, icons.ActionSpeakerNotes, "Follow\nOff\n(F8)", "Follow\nOn\n(F8)") + loopBtnStyle := ToggleIcon(tr.Theme, t.LoopBtn, icons.NavigationArrowForward, icons.AVLoop, "Loop\nOff\n(Ctrl+L)", "Loop\nOn\n(Ctrl+L)") return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { @@ -174,6 +177,7 @@ func (t *SongPanel) layoutSongOptions(gtx C, tr *Tracker) D { layout.Rigid(playBtnStyle.Layout), layout.Rigid(recordBtnStyle.Layout), layout.Rigid(noteTrackBtnStyle.Layout), + layout.Rigid(loopBtnStyle.Layout), ) }), layout.Rigid(panicBtnStyle.Layout), diff --git a/tracker/gioui/theme.go b/tracker/gioui/theme.go index 414823c..c1692d6 100644 --- a/tracker/gioui/theme.go +++ b/tracker/gioui/theme.go @@ -44,6 +44,7 @@ var twoBeatHighlight = color.NRGBA{R: 31, G: 51, B: 53, A: 255} var patternPlayColor = color.NRGBA{R: 55, G: 55, B: 61, A: 255} var patternTextColor = primaryColor var patternCellColor = color.NRGBA{R: 255, G: 255, B: 255, A: 3} +var loopMarkerColor = color.NRGBA{R: 252, G: 186, B: 3, A: 255} var instrumentHoverColor = color.NRGBA{R: 30, G: 31, B: 38, A: 255} var instrumentNameColor = color.NRGBA{R: 255, G: 255, B: 255, A: 255} diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index a7de378..e9fc1b1 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -72,7 +72,7 @@ func NewTracker(model *tracker.Model) *Tracker { OctaveNumberInput: NewNumberInput(model.Octave().Int()), InstrumentVoices: NewNumberInput(model.InstrumentVoices().Int()), - TopHorizontalSplit: &Split{Ratio: -.6}, + TopHorizontalSplit: &Split{Ratio: -.5}, BottomHorizontalSplit: &Split{Ratio: -.6}, VerticalSplit: &Split{Axis: layout.Vertical}, diff --git a/tracker/model.go b/tracker/model.go index dd18599..18f02a8 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -36,6 +36,7 @@ type ( ChangedSinceSave bool RecoveryFilePath string ChangedSinceRecovery bool + Loop Loop } Model struct { @@ -81,6 +82,12 @@ type ( sointu.SongPos } + // Loop identifier the order rows, which are the loop positions + // Length = 0 means no loop is chosen, regardless of start + Loop struct { + Start, Length int + } + Explore struct { IsSave bool // true if this is a save operation, false if open operation IsSong bool // true if this is a song, false if instrument @@ -125,6 +132,7 @@ const ( ScoreChange BPMChange RowsPerBeatChange + LoopChange SongChange ChangeType = PatchChange | ScoreChange | BPMChange | RowsPerBeatChange ) @@ -148,6 +156,7 @@ const maxUndo = 64 func (m *Model) AverageVolume() Volume { return m.avgVolume } func (m *Model) PeakVolume() Volume { return m.peakVolume } func (m *Model) PlayPosition() sointu.SongPos { return m.playPosition } +func (m *Model) Loop() Loop { return m.d.Loop } func (m *Model) PlaySongRow() int { return m.d.Song.Score.SongRow(m.playPosition) } func (m *Model) ChangedSinceSave() bool { return m.d.ChangedSinceSave } func (m *Model) Dialog() Dialog { return m.dialog } @@ -233,6 +242,9 @@ func (m *Model) change(kind string, t ChangeType, severity ChangeSeverity) func( if m.changeType&RowsPerBeatChange != 0 { m.send(RowsPerBeatMsg{m.d.Song.RowsPerBeat}) } + if m.changeType&LoopChange != 0 { + m.send(m.d.Loop) + } m.undoSkipCounter++ var limit int switch m.changeSeverity { @@ -385,6 +397,7 @@ func (m *Model) resetSong() { } m.d.FilePath = "" m.d.ChangedSinceSave = false + m.d.Loop = Loop{} } // send sends a message to the player diff --git a/tracker/player.go b/tracker/player.go index 931aabd..f3a347e 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -24,6 +24,7 @@ type ( peakVolumeMeter VolumeAnalyzer // the volume analyzer used to calculate the peak volume 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 recState recState // is the recording off; are we waiting for a note; or are we recording recording Recording // the recorded MIDI events and BPM @@ -192,6 +193,10 @@ func (p *Player) advanceRow() { return } p.songPos.PatternRow++ // advance row (this is why we subtracted one in Play()) + if p.loop.Length > 0 && p.songPos.PatternRow >= p.song.Score.RowsPerPattern && p.songPos.OrderRow == p.loop.Start+p.loop.Length-1 { + p.songPos.PatternRow = 0 + p.songPos.OrderRow = p.loop.Start + } p.songPos = p.song.Score.Wrap(p.songPos) p.send(nil) // just send volume and song row information lastVoice := 0 @@ -230,6 +235,8 @@ loop: p.compileOrUpdateSynth() case sointu.Score: p.song.Score = m + case Loop: + p.loop = m case IsPlayingMsg: p.playing = bool(m.bool) if !p.playing {