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"
"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)
}

View File

@ -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

View File

@ -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,

View File

@ -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:

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 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

View File

@ -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 {

View File

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