diff --git a/go4k/cmd/sointu-tracker/main.go b/go4k/cmd/sointu-tracker/main.go index 1cc2284..904c796 100644 --- a/go4k/cmd/sointu-tracker/main.go +++ b/go4k/cmd/sointu-tracker/main.go @@ -4,17 +4,24 @@ import ( "fmt" "gioui.org/app" "gioui.org/unit" + "github.com/vsariola/sointu/go4k/audio/oto" "github.com/vsariola/sointu/go4k/tracker" "os" ) func main() { + plr, err := oto.NewPlayer() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + defer plr.Close() go func() { w := app.NewWindow( app.Size(unit.Dp(800), unit.Dp(600)), app.Title("Sointu Tracker"), ) - if err := tracker.New().Run(w); err != nil { + if err := tracker.New(plr).Run(w); err != nil { fmt.Println(err) os.Exit(1) } diff --git a/go4k/tracker/keyevent.go b/go4k/tracker/keyevent.go index c69309f..c234216 100644 --- a/go4k/tracker/keyevent.go +++ b/go4k/tracker/keyevent.go @@ -50,6 +50,9 @@ func (t *Tracker) KeyEvent(e key.Event) bool { switch e.Name { case key.NameEscape: os.Exit(0) + case "Space": + t.TogglePlay() + return true case key.NameUpArrow: t.CursorRow = (t.CursorRow + t.song.PatternRows() - 1) % t.song.PatternRows() return true diff --git a/go4k/tracker/layout.go b/go4k/tracker/layout.go index fdf66fa..883c96b 100644 --- a/go4k/tracker/layout.go +++ b/go4k/tracker/layout.go @@ -19,6 +19,7 @@ func (t *Tracker) layoutTracker(gtx layout.Context) layout.Dimensions { t.ActiveTrack == i, t.CursorRow, t.CursorColumn, + int(t.PlayRow), ))) } return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, diff --git a/go4k/tracker/run.go b/go4k/tracker/run.go index 6e4dab0..cfb547e 100644 --- a/go4k/tracker/run.go +++ b/go4k/tracker/run.go @@ -13,6 +13,8 @@ func (t *Tracker) Run(w *app.Window) error { var ops op.Ops for { select { + case <-t.ticked: + w.Invalidate() case e := <-w.Events(): switch e := e.(type) { case system.DestroyEvent: diff --git a/go4k/tracker/sequencer.go b/go4k/tracker/sequencer.go new file mode 100644 index 0000000..2998dab --- /dev/null +++ b/go4k/tracker/sequencer.go @@ -0,0 +1,87 @@ +package tracker + +import ( + "fmt" + "sync/atomic" + "time" +) + +func (t *Tracker) TogglePlay() { + t.Playing = !t.Playing + t.setPlaying <- t.Playing +} + +// sequencerLoop is the main goroutine that handles the playing logic +func (t *Tracker) sequencerLoop() { + playing := false + rowTime := (time.Second * 60) / time.Duration(4*t.song.BPM) + tick := make(<-chan time.Time) + curVoices := make([]int, len(t.song.Tracks)) + for i := range curVoices { + curVoices[i] = t.song.FirstTrackVoice(i) + } + for { + select { + case <-tick: + next := time.Now().Add(rowTime) + pattern := atomic.LoadInt32(&t.PlayPattern) + row := atomic.LoadInt32(&t.PlayRow) + if int(row+1) == t.song.PatternRows() { + if int(pattern+1) == t.song.SequenceLength() { + atomic.StoreInt32(&t.PlayPattern, 0) + } else { + atomic.AddInt32(&t.PlayPattern, 1) + } + atomic.StoreInt32(&t.PlayRow, 0) + } else { + atomic.AddInt32(&t.PlayRow, 1) + } + if playing { + tick = time.After(next.Sub(time.Now())) + } + t.playRow(curVoices) + t.ticked <- struct{}{} + // TODO: maybe refactor the controls to be nicer, somehow? + case rowJump := <-t.rowJump: + atomic.StoreInt32(&t.PlayRow, int32(rowJump)) + case patternJump := <-t.patternJump: + atomic.StoreInt32(&t.PlayPattern, int32(patternJump)) + case playState := <-t.setPlaying: + playing = playState + if playing { + t.playBuffer = make([]float32, t.song.SamplesPerRow()) + tick = time.After(0) + } + } + } +} + +// playRow renders and writes the current row +func (t *Tracker) playRow(curVoices []int) { + pattern := atomic.LoadInt32(&t.PlayPattern) + row := atomic.LoadInt32(&t.PlayRow) + for i, trk := range t.song.Tracks { + patternIndex := trk.Sequence[pattern] + note := t.song.Patterns[patternIndex][row] + if note == 1 { // anything but hold causes an action. + continue // TODO: can hold be actually something else than 1? + } + t.synth.Release(curVoices[i]) + if note > 1 { + curVoices[i]++ + first := t.song.FirstTrackVoice(i) + if curVoices[i] >= first+trk.NumVoices { + curVoices[i] = first + } + t.synth.Trigger(curVoices[i], note) + } + } + buff := make([]float32, t.song.SamplesPerRow()*2) + rendered, timeAdvanced, _ := t.synth.Render(buff, t.song.SamplesPerRow()) + err := t.player.Play(buff) + if err != nil { + fmt.Println("error playing: %w", err) + } else if timeAdvanced != t.song.SamplesPerRow() { + fmt.Println("rendered only", rendered, "/", timeAdvanced, "expected", t.song.SamplesPerRow()) + } +} diff --git a/go4k/tracker/theme.go b/go4k/tracker/theme.go index d3ca2df..812abcd 100644 --- a/go4k/tracker/theme.go +++ b/go4k/tracker/theme.go @@ -16,6 +16,7 @@ var dark = color.RGBA{R: 24, G: 40, B: 44, A: 255} var white = color.RGBA{R: 255, G: 255, B: 255, A: 255} var black = color.RGBA{R: 0, G: 0, B: 0, A: 255} var yellow = color.RGBA{R: 255, G: 255, B: 130, A: 255} +var red = color.RGBA{R: 255, G: 0, B: 0, A: 255} var panelColor = neutral var panelShadeColor = dark @@ -31,3 +32,4 @@ var trackerFont = fontCollection[6].Font var trackerFontSize = unit.Px(16) var trackerTextColor = white var trackerActiveTextColor = yellow +var trackerPlayColor = red diff --git a/go4k/tracker/track.go b/go4k/tracker/track.go index 0e0cf32..4540b11 100644 --- a/go4k/tracker/track.go +++ b/go4k/tracker/track.go @@ -15,7 +15,7 @@ import ( const trackRowHeight = 16 const trackWidth = 100 -func (t *Tracker) layoutTrack(notes []byte, active bool, cursorRow, cursorCol int) layout.Widget { +func (t *Tracker) layoutTrack(notes []byte, active bool, cursorRow, cursorCol, playingRow int) layout.Widget { return func(gtx layout.Context) layout.Dimensions { gtx.Constraints.Min.X = trackWidth gtx.Constraints.Max.X = trackWidth @@ -45,6 +45,9 @@ func (t *Tracker) layoutTrack(notes []byte, active bool, cursorRow, cursorCol in } op.Offset(f32.Pt(0, (-1*trackRowHeight)*float32(cursorRow))).Add(gtx.Ops) for i, c := range notes { + if i == playingRow { + paint.FillShape(gtx.Ops, trackerPlayColor, clip.Rect{Max: image.Pt(trackWidth, trackRowHeight)}.Op()) + } if i == cursorRow { paint.ColorOp{Color: trackerActiveTextColor}.Add(gtx.Ops) } else { diff --git a/go4k/tracker/tracker.go b/go4k/tracker/tracker.go index e0037ce..7955d6d 100644 --- a/go4k/tracker/tracker.go +++ b/go4k/tracker/tracker.go @@ -1,8 +1,11 @@ package tracker import ( + "fmt" "gioui.org/widget" "github.com/vsariola/sointu/go4k" + "github.com/vsariola/sointu/go4k/audio" + "github.com/vsariola/sointu/go4k/bridge" ) type Tracker struct { @@ -11,14 +14,47 @@ type Tracker struct { CursorRow int CursorColumn int DisplayPattern int + PlayPattern int32 + PlayRow int32 ActiveTrack int CurrentOctave byte + Playing bool + ticked chan struct{} + setPlaying chan bool + rowJump chan int + patternJump chan int + player audio.Player + synth go4k.Synth + playBuffer []float32 } -func New() *Tracker { - return &Tracker{ +func (t *Tracker) LoadSong(song go4k.Song) error { + if err := song.Validate(); err != nil { + return fmt.Errorf("invalid song: %w", err) + } + t.song = song + if synth, err := bridge.Synth(song.Patch); err != nil { + fmt.Println("error loading synth: %v", err) + t.synth = nil + } else { + t.synth = synth + } + return nil +} + +func New(player audio.Player) *Tracker { + t := &Tracker{ QuitButton: new(widget.Clickable), CurrentOctave: 4, - song: defaultSong, + player: player, + setPlaying: make(chan bool), + rowJump: make(chan int), + patternJump: make(chan int), + ticked: make(chan struct{}), } + go t.sequencerLoop() + if err := t.LoadSong(defaultSong); err != nil { + panic(fmt.Errorf("cannot load default song: %w", err)) + } + return t }