mirror of
https://github.com/vsariola/sointu.git
synced 2025-06-04 01:28:45 -04:00
Merge branch 'draft/go-tracker'
This commit is contained in:
commit
05899fc185
5
go.mod
5
go.mod
@ -1,3 +1,8 @@
|
|||||||
module github.com/vsariola/sointu
|
module github.com/vsariola/sointu
|
||||||
|
|
||||||
go 1.15
|
go 1.15
|
||||||
|
|
||||||
|
require (
|
||||||
|
gioui.org v0.0.0-20201106195654-dbc0796d0207
|
||||||
|
github.com/hajimehoshi/oto v0.6.6
|
||||||
|
)
|
||||||
|
22
go4k/audio/convertbuffer.go
Normal file
22
go4k/audio/convertbuffer.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FloatBufferTo16BitLE is a naive helper method to convert []float32 buffers to
|
||||||
|
// 16-bit little-endian integer buffers.
|
||||||
|
// TODO: optimize/refactor this, current is far from the best solution
|
||||||
|
func FloatBufferTo16BitLE(buff []float32) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for i, v := range buff {
|
||||||
|
uv := int16(v * math.MaxInt16)
|
||||||
|
if err := binary.Write(&buf, binary.LittleEndian, uv); err != nil {
|
||||||
|
return nil, fmt.Errorf("error converting buffer (@ %v, value %v) to bytes: %w", i, v, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
48
go4k/audio/convertbuffer_test.go
Normal file
48
go4k/audio/convertbuffer_test.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFloatBufferToBytes(t *testing.T) {
|
||||||
|
floats := []float32{0, 0.000489128, 0, 0.0019555532, 0, 0.0043964, 0, 0.007806882, 0, 0.012180306, 0, 0.017508084, 0, 0.023779746, 0, 0.030982954, 0, 0.039103523, 0, 0.04812544, 0, 0.05803088, 0, 0.068800256, 0, 0.08041221, 0, 0.09284368, 0, 0.10606992, 0, 0.120064534, 0, 0.13479951, 0, 0.1502453, 0, 0.16637078, 0, 0.18314338, 0, 0.20052913, 0, 0.21849263, 0, 0.23699719, 0, 0.2560048, 0, 0.27547634, 0, 0.29537144, 0, 0.31564865, 0, 0.33626547, 0, 0.35717854, 0, 0.37834346, 0, 0.39971504, 0, 0.4212474, 0, 0.4428938, 0, 0.46460703, 0, 0.48633927, 0, 0.50804216, 0, 0.52966696, 0, 0.5511646, 0, 0.57248586, 0, 0.5935812, 0, 0.6144009, 0, 0.63489544, 0, 0.6550152, 0, 0.67471063, 0, 0.6939326, 0, 0.712632, 0, 0.7307603, 0, 0.7482692, 0, 0.7651111, 0, 0.7812389}
|
||||||
|
bytes := []byte{0x0, 0x0, 0x10, 0x0, 0x0, 0x0, 0x40, 0x0, 0x0, 0x0, 0x90, 0x0, 0x0, 0x0, 0xff, 0x0, 0x0, 0x0, 0x8f, 0x1, 0x0, 0x0, 0x3d, 0x2, 0x0, 0x0, 0xb, 0x3, 0x0, 0x0, 0xf7, 0x3, 0x0, 0x0, 0x1, 0x5, 0x0, 0x0, 0x28, 0x6, 0x0, 0x0, 0x6d, 0x7, 0x0, 0x0, 0xce, 0x8, 0x0, 0x0, 0x4a, 0xa, 0x0, 0x0, 0xe2, 0xb, 0x0, 0x0, 0x93, 0xd, 0x0, 0x0, 0x5e, 0xf, 0x0, 0x0, 0x40, 0x11, 0x0, 0x0, 0x3b, 0x13, 0x0, 0x0, 0x4b, 0x15, 0x0, 0x0, 0x71, 0x17, 0x0, 0x0, 0xaa, 0x19, 0x0, 0x0, 0xf7, 0x1b, 0x0, 0x0, 0x55, 0x1e, 0x0, 0x0, 0xc4, 0x20, 0x0, 0x0, 0x42, 0x23, 0x0, 0x0, 0xce, 0x25, 0x0, 0x0, 0x66, 0x28, 0x0, 0x0, 0xa, 0x2b, 0x0, 0x0, 0xb7, 0x2d, 0x0, 0x0, 0x6d, 0x30, 0x0, 0x0, 0x29, 0x33, 0x0, 0x0, 0xeb, 0x35, 0x0, 0x0, 0xb0, 0x38, 0x0, 0x0, 0x77, 0x3b, 0x0, 0x0, 0x3f, 0x3e, 0x0, 0x0, 0x7, 0x41, 0x0, 0x0, 0xcb, 0x43, 0x0, 0x0, 0x8c, 0x46, 0x0, 0x0, 0x46, 0x49, 0x0, 0x0, 0xf9, 0x4b, 0x0, 0x0, 0xa4, 0x4e, 0x0, 0x0, 0x43, 0x51, 0x0, 0x0, 0xd6, 0x53, 0x0, 0x0, 0x5c, 0x56, 0x0, 0x0, 0xd2, 0x58, 0x0, 0x0, 0x36, 0x5b, 0x0, 0x0, 0x88, 0x5d, 0x0, 0x0, 0xc6, 0x5f, 0x0, 0x0, 0xee, 0x61, 0x0, 0x0, 0xfe, 0x63}
|
||||||
|
converted, err := FloatBufferTo16BitLE(floats)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FloatBufferTo16BitLE threw an error")
|
||||||
|
}
|
||||||
|
for i, v := range converted {
|
||||||
|
if bytes[i] != v {
|
||||||
|
t.Fail()
|
||||||
|
t.Errorf("Unexpected conversion output byte %x (expected %x) at position %v", v, bytes[i], i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(converted, bytes) {
|
||||||
|
t.Fatalf("Unexpected conversion output from FloatBufferTo16BitLE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFloatBufferToBytesLimits(t *testing.T) {
|
||||||
|
floats := []float32{0, 1, -1, 0.999, -0.999}
|
||||||
|
bytes := []byte{
|
||||||
|
0x0, 0x0,
|
||||||
|
0xFF, 0x7F, // float 1 = 0x7FFF = 0111111111111111
|
||||||
|
0x01, 0x80, // float -1 = 0x8001 = 1000000000000001
|
||||||
|
0xDE, 0x7F, // float 0.999 = 0x7FDE = 0111111111011110
|
||||||
|
0x22, 0x80, // float -0.999 = 0x8022 = 1000000000100010
|
||||||
|
}
|
||||||
|
converted, err := FloatBufferTo16BitLE(floats)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FloatBufferTo16BitLE threw an error")
|
||||||
|
}
|
||||||
|
for i, v := range converted {
|
||||||
|
if bytes[i] != v {
|
||||||
|
t.Fail()
|
||||||
|
t.Errorf("Unexpected conversion output byte %x (expected %x) at position %v", v, bytes[i], i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(converted, bytes) {
|
||||||
|
t.Fatalf("Unexpected conversion output from FloatBufferTo16BitLE")
|
||||||
|
}
|
||||||
|
}
|
50
go4k/audio/oto/otoplayer.go
Normal file
50
go4k/audio/oto/otoplayer.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package oto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/hajimehoshi/oto"
|
||||||
|
"github.com/vsariola/sointu/go4k/audio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OtoPlayer wraps github.com/hajimehoshi/oto to play sointu-style float32[] audio
|
||||||
|
type OtoPlayer struct {
|
||||||
|
context *oto.Context
|
||||||
|
player *oto.Player
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play implements the audio.Player interface for OtoPlayer
|
||||||
|
func (o *OtoPlayer) Play(floatBuffer []float32) (err error) {
|
||||||
|
if byteBuffer, err := audio.FloatBufferTo16BitLE(floatBuffer); err != nil {
|
||||||
|
return fmt.Errorf("cannot convert buffer to bytes: %w", err)
|
||||||
|
} else if _, err := o.player.Write(byteBuffer); err != nil {
|
||||||
|
return fmt.Errorf("cannot write to player: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close disposes of resources
|
||||||
|
func (o *OtoPlayer) Close() error {
|
||||||
|
if err := o.player.Close(); err != nil {
|
||||||
|
return fmt.Errorf("cannot close player: %w", err)
|
||||||
|
}
|
||||||
|
if err := o.context.Close(); err != nil {
|
||||||
|
return fmt.Errorf("cannot close oto context: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const otoBufferSize = 8192
|
||||||
|
|
||||||
|
// NewPlayer creates and initializes a new OtoPlayer
|
||||||
|
func NewPlayer() (*OtoPlayer, error) {
|
||||||
|
context, err := oto.NewContext(44100, 2, 2, otoBufferSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot create oto context: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
player := context.NewPlayer()
|
||||||
|
return &OtoPlayer{
|
||||||
|
context: context,
|
||||||
|
player: player,
|
||||||
|
}, nil
|
||||||
|
}
|
6
go4k/audio/player.go
Normal file
6
go4k/audio/player.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
type Player interface {
|
||||||
|
Play(buffer []float32) (err error)
|
||||||
|
Close() error
|
||||||
|
}
|
91
go4k/cmd/sointu-player/main.go
Normal file
91
go4k/cmd/sointu-player/main.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"github.com/vsariola/sointu/go4k"
|
||||||
|
"github.com/vsariola/sointu/go4k/audio"
|
||||||
|
"github.com/vsariola/sointu/go4k/audio/oto"
|
||||||
|
"github.com/vsariola/sointu/go4k/bridge"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// parse flags
|
||||||
|
quiet := flag.Bool("quiet", false, "no sound output")
|
||||||
|
out := flag.String("out", "", "write output to file")
|
||||||
|
help := flag.Bool("h", false, "show help")
|
||||||
|
flag.Usage = printUsage
|
||||||
|
flag.Parse()
|
||||||
|
if flag.NArg() == 0 || *help {
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// read input song
|
||||||
|
var song go4k.Song
|
||||||
|
if bytes, err := ioutil.ReadFile(flag.Arg(0)); err != nil {
|
||||||
|
fmt.Printf("Cannot read song file: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
} else if err := json.Unmarshal(bytes, &song); err != nil {
|
||||||
|
fmt.Printf("Cannot unmarshal song file: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set up synth
|
||||||
|
synth, err := bridge.Synth(song.Patch)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Cannot create synth: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// render the actual data for the entire song
|
||||||
|
fmt.Print("Rendering.. ")
|
||||||
|
buff, err := go4k.Play(synth, song)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error rendering with go4k: %v\n", err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Rendered %v samples.\n", len(buff))
|
||||||
|
}
|
||||||
|
|
||||||
|
// play output if not in quiet mode
|
||||||
|
if !*quiet {
|
||||||
|
fmt.Print("Playing.. ")
|
||||||
|
player, err := oto.NewPlayer()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error creating oto player: %v\n", err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer player.Close()
|
||||||
|
if err := player.Play(buff); err != nil {
|
||||||
|
fmt.Printf("Error playing: %v\n", err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Played.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// write output to file if output given
|
||||||
|
if out != nil && *out != "" {
|
||||||
|
fmt.Printf("Writing output to %v.. ", *out)
|
||||||
|
if bbuffer, err := audio.FloatBufferTo16BitLE(buff); err != nil {
|
||||||
|
fmt.Printf("Error converting buffer: %v\n", err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
} else if err := ioutil.WriteFile(*out, bbuffer, os.ModePerm); err != nil {
|
||||||
|
fmt.Printf("Error writing: %v\n", err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Wrote %v bytes.\n", len(bbuffer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("All done.")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printUsage() {
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: %s [OPTIONS] [SONG FILE] [OUTPUT FILE]\n", os.Args[0])
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
33
go4k/cmd/sointu-tracker/main.go
Normal file
33
go4k/cmd/sointu-tracker/main.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
t := tracker.New(plr)
|
||||||
|
defer t.Close()
|
||||||
|
if err := t.Run(w); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
app.Main()
|
||||||
|
}
|
51
go4k/song_json_test.go
Normal file
51
go4k/song_json_test.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package go4k_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/vsariola/sointu/go4k"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const expectedMarshaled = "{\"BPM\":100,\"Patterns\":[\"QABEACAAAABLAE4AAAAAAA==\"],\"Tracks\":[{\"NumVoices\":1,\"Sequence\":\"AA==\"}],\"SongLength\":0,\"Patch\":[{\"NumVoices\":1,\"Units\":[{\"Type\":\"envelope\",\"Stereo\":false,\"Parameters\":{\"attack\":32,\"decay\":32,\"gain\":128,\"release\":64,\"sustain\":64}},{\"Type\":\"oscillator\",\"Stereo\":false,\"Parameters\":{\"color\":96,\"detune\":64,\"flags\":64,\"gain\":128,\"phase\":0,\"shape\":64,\"transpose\":64}},{\"Type\":\"mulp\",\"Stereo\":false,\"Parameters\":{}},{\"Type\":\"envelope\",\"Stereo\":false,\"Parameters\":{\"attack\":32,\"decay\":32,\"gain\":128,\"release\":64,\"sustain\":64}},{\"Type\":\"oscillator\",\"Stereo\":false,\"Parameters\":{\"color\":64,\"detune\":64,\"flags\":64,\"gain\":128,\"phase\":64,\"shape\":96,\"transpose\":72}},{\"Type\":\"mulp\",\"Stereo\":false,\"Parameters\":{}},{\"Type\":\"out\",\"Stereo\":true,\"Parameters\":{\"gain\":128}}]}]}"
|
||||||
|
|
||||||
|
var testSong = go4k.Song{
|
||||||
|
BPM: 100,
|
||||||
|
Patterns: [][]byte{{64, 0, 68, 0, 32, 0, 0, 0, 75, 0, 78, 0, 0, 0, 0, 0}},
|
||||||
|
Tracks: []go4k.Track{
|
||||||
|
{NumVoices: 1, Sequence: []byte{0}},
|
||||||
|
},
|
||||||
|
SongLength: 0,
|
||||||
|
Patch: go4k.Patch{
|
||||||
|
go4k.Instrument{NumVoices: 1, Units: []go4k.Unit{
|
||||||
|
{"envelope", false, map[string]int{"attack": 32, "decay": 32, "sustain": 64, "release": 64, "gain": 128}},
|
||||||
|
{"oscillator", false, map[string]int{"transpose": 64, "detune": 64, "phase": 0, "color": 96, "shape": 64, "gain": 128, "flags": 0x40}},
|
||||||
|
{"mulp", false, map[string]int{}},
|
||||||
|
{"envelope", false, map[string]int{"attack": 32, "decay": 32, "sustain": 64, "release": 64, "gain": 128}},
|
||||||
|
{"oscillator", false, map[string]int{"transpose": 72, "detune": 64, "phase": 64, "color": 64, "shape": 96, "gain": 128, "flags": 0x40}},
|
||||||
|
{"mulp", false, map[string]int{}},
|
||||||
|
{"out", true, map[string]int{"gain": 128}},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSongMarshalJSON(t *testing.T) {
|
||||||
|
songbytes, err := json.Marshal(testSong)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cannot marshal song: %v", err)
|
||||||
|
}
|
||||||
|
if string(songbytes) != expectedMarshaled {
|
||||||
|
t.Fatalf("expectedMarshaled song to unexpected result, got %v, expected %v", string(songbytes), expectedMarshaled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSongUnmarshalJSON(t *testing.T) {
|
||||||
|
var song go4k.Song
|
||||||
|
err := json.Unmarshal([]byte(expectedMarshaled), &song)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cannot unmarshal song: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(song, testSong) {
|
||||||
|
t.Fatalf("unmarshaled song to unexpected result, got %#v, expected %#v", song, testSong)
|
||||||
|
}
|
||||||
|
}
|
27
go4k/tracker/defaultsong.go
Normal file
27
go4k/tracker/defaultsong.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package tracker
|
||||||
|
|
||||||
|
import "github.com/vsariola/sointu/go4k"
|
||||||
|
|
||||||
|
var defaultSong = go4k.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: []go4k.Track{
|
||||||
|
{NumVoices: 1, Sequence: []byte{0}},
|
||||||
|
{NumVoices: 1, Sequence: []byte{1}},
|
||||||
|
},
|
||||||
|
SongLength: 0,
|
||||||
|
Patch: go4k.Patch{
|
||||||
|
go4k.Instrument{NumVoices: 2, Units: []go4k.Unit{
|
||||||
|
{"envelope", false, map[string]int{"attack": 32, "decay": 32, "sustain": 64, "release": 64, "gain": 128}},
|
||||||
|
{"oscillator", false, map[string]int{"transpose": 64, "detune": 64, "phase": 0, "color": 96, "shape": 64, "gain": 128, "flags": 0x40}},
|
||||||
|
{"mulp", false, map[string]int{}},
|
||||||
|
{"envelope", false, map[string]int{"attack": 32, "decay": 32, "sustain": 64, "release": 64, "gain": 128}},
|
||||||
|
{"oscillator", false, map[string]int{"transpose": 72, "detune": 64, "phase": 64, "color": 64, "shape": 96, "gain": 128, "flags": 0x40}},
|
||||||
|
{"mulp", false, map[string]int{}},
|
||||||
|
{"out", true, map[string]int{"gain": 128}},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
115
go4k/tracker/keyevent.go
Normal file
115
go4k/tracker/keyevent.go
Normal 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
go4k/tracker/label.go
Normal file
47
go4k/tracker/label.go
Normal 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
go4k/tracker/layout.go
Normal file
37
go4k/tracker/layout.go
Normal 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
go4k/tracker/music.go
Normal file
35
go4k/tracker/music.go
Normal 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
go4k/tracker/panels.go
Normal file
53
go4k/tracker/panels.go
Normal 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
go4k/tracker/run.go
Normal file
37
go4k/tracker/run.go
Normal 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
go4k/tracker/sequencer.go
Normal file
89
go4k/tracker/sequencer.go
Normal 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
go4k/tracker/theme.go
Normal file
35
go4k/tracker/theme.go
Normal 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
go4k/tracker/track.go
Normal file
63
go4k/tracker/track.go
Normal 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
go4k/tracker/tracker.go
Normal file
67
go4k/tracker/tracker.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
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 {
|
||||||
|
QuitButton *widget.Clickable
|
||||||
|
song go4k.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 go4k.Synth
|
||||||
|
playBuffer []float32
|
||||||
|
closer chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user