feat(tracker): hook up audio to tracker, we have liftoff

audio still a bit crackly; should probably decouple actual row ticking and rendering of audio (but how does that work with tempo ops?)

sequencer goroutine is a bit weird, too, should rethink
This commit is contained in:
Matias Lahti 2020-11-08 04:17:21 +02:00
parent 175bbb7743
commit 5e45e4f1f4
8 changed files with 146 additions and 5 deletions

View File

@ -4,17 +4,24 @@ import (
"fmt" "fmt"
"gioui.org/app" "gioui.org/app"
"gioui.org/unit" "gioui.org/unit"
"github.com/vsariola/sointu/go4k/audio/oto"
"github.com/vsariola/sointu/go4k/tracker" "github.com/vsariola/sointu/go4k/tracker"
"os" "os"
) )
func main() { func main() {
plr, err := oto.NewPlayer()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer plr.Close()
go func() { go func() {
w := app.NewWindow( w := app.NewWindow(
app.Size(unit.Dp(800), unit.Dp(600)), app.Size(unit.Dp(800), unit.Dp(600)),
app.Title("Sointu Tracker"), app.Title("Sointu Tracker"),
) )
if err := tracker.New().Run(w); err != nil { if err := tracker.New(plr).Run(w); err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }

View File

@ -50,6 +50,9 @@ func (t *Tracker) KeyEvent(e key.Event) bool {
switch e.Name { switch e.Name {
case key.NameEscape: case key.NameEscape:
os.Exit(0) os.Exit(0)
case "Space":
t.TogglePlay()
return true
case key.NameUpArrow: case key.NameUpArrow:
t.CursorRow = (t.CursorRow + t.song.PatternRows() - 1) % t.song.PatternRows() t.CursorRow = (t.CursorRow + t.song.PatternRows() - 1) % t.song.PatternRows()
return true return true

View File

@ -19,6 +19,7 @@ func (t *Tracker) layoutTracker(gtx layout.Context) layout.Dimensions {
t.ActiveTrack == i, t.ActiveTrack == i,
t.CursorRow, t.CursorRow,
t.CursorColumn, t.CursorColumn,
int(t.PlayRow),
))) )))
} }
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,

View File

@ -13,6 +13,8 @@ func (t *Tracker) Run(w *app.Window) error {
var ops op.Ops var ops op.Ops
for { for {
select { select {
case <-t.ticked:
w.Invalidate()
case e := <-w.Events(): case e := <-w.Events():
switch e := e.(type) { switch e := e.(type) {
case system.DestroyEvent: case system.DestroyEvent:

87
go4k/tracker/sequencer.go Normal file
View File

@ -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())
}
}

View File

@ -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 white = color.RGBA{R: 255, G: 255, B: 255, A: 255}
var black = color.RGBA{R: 0, G: 0, B: 0, 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 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 panelColor = neutral
var panelShadeColor = dark var panelShadeColor = dark
@ -31,3 +32,4 @@ var trackerFont = fontCollection[6].Font
var trackerFontSize = unit.Px(16) var trackerFontSize = unit.Px(16)
var trackerTextColor = white var trackerTextColor = white
var trackerActiveTextColor = yellow var trackerActiveTextColor = yellow
var trackerPlayColor = red

View File

@ -15,7 +15,7 @@ import (
const trackRowHeight = 16 const trackRowHeight = 16
const trackWidth = 100 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 { return func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = trackWidth gtx.Constraints.Min.X = trackWidth
gtx.Constraints.Max.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) op.Offset(f32.Pt(0, (-1*trackRowHeight)*float32(cursorRow))).Add(gtx.Ops)
for i, c := range notes { for i, c := range notes {
if i == playingRow {
paint.FillShape(gtx.Ops, trackerPlayColor, clip.Rect{Max: image.Pt(trackWidth, trackRowHeight)}.Op())
}
if i == cursorRow { if i == cursorRow {
paint.ColorOp{Color: trackerActiveTextColor}.Add(gtx.Ops) paint.ColorOp{Color: trackerActiveTextColor}.Add(gtx.Ops)
} else { } else {

View File

@ -1,8 +1,11 @@
package tracker package tracker
import ( import (
"fmt"
"gioui.org/widget" "gioui.org/widget"
"github.com/vsariola/sointu/go4k" "github.com/vsariola/sointu/go4k"
"github.com/vsariola/sointu/go4k/audio"
"github.com/vsariola/sointu/go4k/bridge"
) )
type Tracker struct { type Tracker struct {
@ -11,14 +14,47 @@ type Tracker struct {
CursorRow int CursorRow int
CursorColumn int CursorColumn int
DisplayPattern int DisplayPattern int
PlayPattern int32
PlayRow int32
ActiveTrack int ActiveTrack int
CurrentOctave byte 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 { func (t *Tracker) LoadSong(song go4k.Song) error {
return &Tracker{ 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), QuitButton: new(widget.Clickable),
CurrentOctave: 4, 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
} }