refactor(go): Move everything from go4k to root package sointu

This commit is contained in:
Veikko Sariola
2020-12-16 21:35:53 +02:00
parent d0bd877b3f
commit 224b8dcb70
34 changed files with 293 additions and 294 deletions

25
tracker/defaultsong.go Normal file
View File

@ -0,0 +1,25 @@
package tracker
import "github.com/vsariola/sointu"
var defaultSong = sointu.Song{
BPM: 100,
Patterns: [][]byte{
{64, 0, 68, 0, 32, 0, 0, 0, 75, 0, 78, 0, 0, 0, 0, 0},
{0, 0, 64, 0, 68, 0, 32, 0, 0, 0, 75, 0, 78, 0, 0, 0},
},
Tracks: []sointu.Track{
{NumVoices: 1, Sequence: []byte{0}},
{NumVoices: 1, Sequence: []byte{1}},
},
Patch: sointu.Patch{
Instruments: []sointu.Instrument{{NumVoices: 2, Units: []sointu.Unit{
{Type: "envelope", Parameters: map[string]int{"stereo": 0, "attack": 32, "decay": 32, "sustain": 64, "release": 64, "gain": 128}},
{Type: "oscillator", Parameters: map[string]int{"stereo": 0, "transpose": 64, "detune": 64, "phase": 0, "color": 96, "shape": 64, "gain": 128, "type": sointu.Sine}},
{Type: "mulp", Parameters: map[string]int{"stereo": 0}},
{Type: "envelope", Parameters: map[string]int{"stereo": 0, "attack": 32, "decay": 32, "sustain": 64, "release": 64, "gain": 128}},
{Type: "oscillator", Parameters: map[string]int{"stereo": 0, "transpose": 72, "detune": 64, "phase": 64, "color": 64, "shape": 96, "gain": 128, "type": sointu.Sine}},
{Type: "mulp", Parameters: map[string]int{"stereo": 0}},
{Type: "out", Parameters: map[string]int{"stereo": 1, "gain": 128}},
}}}},
}

115
tracker/keyevent.go Normal file
View File

@ -0,0 +1,115 @@
package tracker
import (
"gioui.org/io/key"
"os"
"strconv"
)
var noteMap = map[string]byte{
"Z": 0,
"S": 1,
"X": 2,
"D": 3,
"C": 4,
"V": 5,
"G": 6,
"B": 7,
"H": 8,
"N": 9,
"J": 10,
"M": 11,
"Q": 12,
"2": 13,
"W": 14,
"3": 15,
"E": 16,
"R": 17,
"5": 18,
"T": 19,
"6": 20,
"Y": 21,
"7": 22,
"U": 23,
}
// KeyEvent handles incoming key events and returns true if repaint is needed.
func (t *Tracker) KeyEvent(e key.Event) bool {
if e.State == key.Press {
if t.CursorColumn == 0 {
if val, ok := noteMap[e.Name]; ok {
t.NotePressed(val)
return true
}
} else {
if iv, err := strconv.ParseInt(e.Name, 16, 8); err == nil {
t.NumberPressed(byte(iv))
return true
}
}
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
case key.NameDownArrow:
t.CursorRow = (t.CursorRow + 1) % t.song.PatternRows()
return true
case key.NameLeftArrow:
if t.CursorColumn == 0 {
t.ActiveTrack = (t.ActiveTrack + len(t.song.Tracks) - 1) % len(t.song.Tracks)
t.CursorColumn = 2
} else {
t.CursorColumn--
}
return true
case key.NameRightArrow:
if t.CursorColumn == 2 {
t.ActiveTrack = (t.ActiveTrack + 1) % len(t.song.Tracks)
t.CursorColumn = 0
} else {
t.CursorColumn++
}
return true
case key.NameTab:
if e.Modifiers.Contain(key.ModShift) {
t.ActiveTrack = (t.ActiveTrack + len(t.song.Tracks) - 1) % len(t.song.Tracks)
} else {
t.ActiveTrack = (t.ActiveTrack + 1) % len(t.song.Tracks)
}
t.CursorColumn = 0
return true
}
}
return false
}
// setCurrent sets the (note) value in current pattern under cursor to iv
func (t *Tracker) setCurrent(iv byte) {
t.song.Patterns[t.song.Tracks[t.ActiveTrack].Sequence[t.DisplayPattern]][t.CursorRow] = iv
}
// getCurrent returns the current (note) value in current pattern under the cursor
func (t *Tracker) getCurrent() byte {
return t.song.Patterns[t.song.Tracks[t.ActiveTrack].Sequence[t.DisplayPattern]][t.CursorRow]
}
// NotePressed handles incoming key presses while in the note column
func (t *Tracker) NotePressed(val byte) {
t.setCurrent(getNoteValue(t.CurrentOctave, val))
}
// NumberPressed handles incoming presses while in either of the hex number columns
func (t *Tracker) NumberPressed(iv byte) {
val := t.getCurrent()
if t.CursorColumn == 1 {
val = ((iv & 0xF) << 4) | (val & 0xF)
} else if t.CursorColumn == 2 {
val = (val & 0xF0) | (iv & 0xF)
}
t.setCurrent(val)
}

47
tracker/label.go Normal file
View File

@ -0,0 +1,47 @@
package tracker
import (
"gioui.org/f32"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/widget"
"image"
"image/color"
)
type LabelStyle struct {
Text string
Color color.RGBA
ShadeColor color.RGBA
}
func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
defer op.Push(gtx.Ops).Pop()
paint.ColorOp{Color: l.ShadeColor}.Add(gtx.Ops)
op.Offset(f32.Pt(2, 2)).Add(gtx.Ops)
dims := widget.Label{
Alignment: text.Start,
MaxLines: 1,
}.Layout(gtx, textShaper, labelFont, labelFontSize, l.Text)
return layout.Dimensions{
Size: dims.Size.Add(image.Pt(2, 2)),
Baseline: dims.Baseline,
}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
paint.ColorOp{Color: l.Color}.Add(gtx.Ops)
return widget.Label{
Alignment: text.Start,
MaxLines: 1,
}.Layout(gtx, textShaper, labelFont, labelFontSize, l.Text)
}),
)
}
func Label(text string, color color.RGBA) layout.Widget {
return LabelStyle{Text: text, Color: color, ShadeColor: black}.Layout
}

37
tracker/layout.go Normal file
View File

@ -0,0 +1,37 @@
package tracker
import (
"gioui.org/layout"
)
func (t *Tracker) Layout(gtx layout.Context) {
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(t.layoutControls),
layout.Flexed(1, Lowered(t.layoutTracker)),
)
}
func (t *Tracker) layoutTracker(gtx layout.Context) layout.Dimensions {
flexTracks := make([]layout.FlexChild, len(t.song.Tracks))
for i, trk := range t.song.Tracks {
flexTracks[i] = layout.Rigid(Lowered(t.layoutTrack(
t.song.Patterns[trk.Sequence[t.DisplayPattern]],
t.ActiveTrack == i,
t.CursorRow,
t.CursorColumn,
int(t.PlayRow),
)))
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
flexTracks...,
)
}
func (t *Tracker) layoutControls(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.Y = 400
gtx.Constraints.Max.Y = 400
return layout.Stack{Alignment: layout.NW}.Layout(gtx,
layout.Expanded(t.QuitButton.Layout),
layout.Stacked(Raised(Label("Hello", white))),
)
}

35
tracker/music.go Normal file
View File

@ -0,0 +1,35 @@
package tracker
import "fmt"
const baseNote = 20
var notes = []string{
"C-",
"C#",
"D-",
"D#",
"E-",
"F-",
"F#",
"G-",
"G#",
"A-",
"A#",
"B-",
}
// valueAsNote returns the textual representation of a note value
func valueAsNote(val byte) string {
octave := (val - baseNote) / 12
oNote := (val - baseNote) % 12
if octave < 0 || oNote < 0 || octave > 10 {
return "..."
}
return fmt.Sprintf("%s%d", notes[oNote], octave)
}
// noteValue return the note value for a particular note and octave combination
func getNoteValue(octave, note byte) byte {
return baseNote + (octave * 12) + note
}

53
tracker/panels.go Normal file
View File

@ -0,0 +1,53 @@
package tracker
import (
"gioui.org/f32"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"image"
"image/color"
)
func Raised(w layout.Widget) layout.Widget {
return Beveled(w, panelColor, panelLightColor, panelShadeColor)
}
func Lowered(w layout.Widget) layout.Widget {
return Beveled(w, panelColor, panelShadeColor, panelLightColor)
}
func Beveled(w layout.Widget, base, light, shade color.RGBA) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, light, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, 1)).Op())
paint.FillShape(gtx.Ops, light, clip.Rect(image.Rect(0, 0, 1, gtx.Constraints.Max.Y)).Op())
paint.FillShape(gtx.Ops, base, clip.Rect(image.Rect(1, 1, gtx.Constraints.Max.X-1, gtx.Constraints.Max.Y-1)).Op())
paint.FillShape(gtx.Ops, shade, clip.Rect(image.Rect(0, gtx.Constraints.Max.Y-1, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op())
paint.FillShape(gtx.Ops, shade, clip.Rect(image.Rect(gtx.Constraints.Max.X-1, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op())
stack := op.Push(gtx.Ops)
mcs := gtx.Constraints
mcs.Max.X -= 2
if mcs.Max.X < 0 {
mcs.Max.X = 0
}
if mcs.Min.X > mcs.Max.X {
mcs.Min.X = mcs.Max.X
}
mcs.Max.Y -= 2
if mcs.Max.Y < 0 {
mcs.Max.Y = 0
}
if mcs.Min.Y > mcs.Max.Y {
mcs.Min.Y = mcs.Max.Y
}
op.Offset(f32.Pt(1, 1)).Add(gtx.Ops)
gtx.Constraints = mcs
dims := w(gtx)
stack.Pop()
return layout.Dimensions{
Size: dims.Size.Add(image.Point{X: 2, Y: 2}),
Baseline: dims.Baseline + 1,
}
}
}

37
tracker/run.go Normal file
View File

@ -0,0 +1,37 @@
package tracker
import (
"gioui.org/app"
"gioui.org/io/key"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"os"
)
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:
return e.Err
case key.Event:
if t.KeyEvent(e) {
w.Invalidate()
}
case system.FrameEvent:
gtx := layout.NewContext(&ops, e)
if t.QuitButton.Clicked() {
os.Exit(0)
}
t.Layout(gtx)
e.Frame(gtx.Ops)
}
}
}
}

89
tracker/sequencer.go Normal file
View File

@ -0,0 +1,89 @@
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(closer chan struct{}) {
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 <-closer:
return
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())
}
}

35
tracker/theme.go Normal file
View File

@ -0,0 +1,35 @@
package tracker
import (
"gioui.org/font/gofont"
"gioui.org/text"
"gioui.org/unit"
"image/color"
)
var fontCollection []text.FontFace = gofont.Collection()
var textShaper = text.NewCache(fontCollection)
var neutral = color.RGBA{R: 73, G: 117, B: 130, A: 255}
var light = color.RGBA{R: 138, G: 219, B: 243, A: 255}
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
var panelLightColor = light
var labelFont = fontCollection[6].Font
var labelFontSize = unit.Px(18)
var activeTrackColor = color.RGBA{0, 0, 50, 255}
var inactiveTrackColor = black
var trackerFont = fontCollection[6].Font
var trackerFontSize = unit.Px(16)
var trackerTextColor = white
var trackerActiveTextColor = yellow
var trackerPlayColor = red

63
tracker/track.go Normal file
View File

@ -0,0 +1,63 @@
package tracker
import (
"fmt"
"gioui.org/f32"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/widget"
"image"
"strings"
)
const trackRowHeight = 16
const trackWidth = 100
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
if active {
paint.FillShape(gtx.Ops, activeTrackColor, clip.Rect{
Max: gtx.Constraints.Max,
}.Op())
} else {
paint.FillShape(gtx.Ops, inactiveTrackColor, clip.Rect{
Max: gtx.Constraints.Max,
}.Op())
}
defer op.Push(gtx.Ops).Pop()
clip.Rect{Max: gtx.Constraints.Max}.Add(gtx.Ops)
op.Offset(f32.Pt(0, float32(gtx.Constraints.Max.Y/2)-trackRowHeight)).Add(gtx.Ops)
paint.FillShape(gtx.Ops, panelColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, trackRowHeight)}.Op())
if active {
switch cursorCol {
case 0:
paint.FillShape(gtx.Ops, panelShadeColor, clip.Rect{Max: image.Pt(36, trackRowHeight)}.Op())
case 1, 2:
s := op.Push(gtx.Ops)
op.Offset(f32.Pt(trackWidth/2+float32(cursorCol-1)*10, 0)).Add(gtx.Ops)
paint.FillShape(gtx.Ops, panelShadeColor, clip.Rect{Max: image.Pt(10, trackRowHeight)}.Op())
s.Pop()
}
}
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 {
paint.ColorOp{Color: trackerTextColor}.Add(gtx.Ops)
}
widget.Label{}.Layout(gtx, textShaper, trackerFont, trackerFontSize, valueAsNote(c))
op.Offset(f32.Pt(trackWidth/2, 0)).Add(gtx.Ops)
widget.Label{}.Layout(gtx, textShaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", c)))
op.Offset(f32.Pt(-trackWidth/2, trackRowHeight)).Add(gtx.Ops)
}
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}

67
tracker/tracker.go Normal file
View File

@ -0,0 +1,67 @@
package tracker
import (
"fmt"
"gioui.org/widget"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/audio"
"github.com/vsariola/sointu/bridge"
)
type Tracker struct {
QuitButton *widget.Clickable
song sointu.Song
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 sointu.Synth
playBuffer []float32
closer chan struct{}
}
func (t *Tracker) LoadSong(song sointu.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.Printf("error loading synth: %v\n", err)
t.synth = nil
} else {
t.synth = synth
}
return nil
}
func (t *Tracker) Close() {
t.player.Close()
t.closer <- struct{}{}
}
func New(player audio.Player) *Tracker {
t := &Tracker{
QuitButton: new(widget.Clickable),
CurrentOctave: 4,
player: player,
setPlaying: make(chan bool),
rowJump: make(chan int),
patternJump: make(chan int),
ticked: make(chan struct{}),
closer: make(chan struct{}),
}
go t.sequencerLoop(t.closer)
if err := t.LoadSong(defaultSong); err != nil {
panic(fmt.Errorf("cannot load default song: %w", err))
}
return t
}