From 9544a130abfba13a5eb8a6a5ac7788e6d3b29ad6 Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Mon, 2 Nov 2020 16:36:54 +0200 Subject: [PATCH 01/14] feat(song): add basic JSON marshaling/unmarshaling tests to make sure everything gets marshalled properly TBD: should the various []bytes be marshaled in a different way? Go defaults to base64-encoded strings but creating just plain JSON arrays with bytes could be more friendly to both humans and computers. --- go4k/song_json_test.go | 51 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 go4k/song_json_test.go diff --git a/go4k/song_json_test.go b/go4k/song_json_test.go new file mode 100644 index 0000000..80217d3 --- /dev/null +++ b/go4k/song_json_test.go @@ -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) + } +} From fa772ddd77bdbb60c018ef99a7fa650122f63f1a Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Sat, 7 Nov 2020 19:22:59 +0200 Subject: [PATCH 02/14] feat(go/audio): implement basic audio output with oto splitting implementation into a separate package to potentially allow for other sorts of output, too. --- go.mod | 2 ++ go4k/audio/convertbuffer.go | 22 +++++++++++++ go4k/audio/convertbuffer_test.go | 48 ++++++++++++++++++++++++++++ go4k/audio/oto/otoplayer.go | 54 ++++++++++++++++++++++++++++++++ go4k/audio/player.go | 6 ++++ 5 files changed, 132 insertions(+) create mode 100644 go4k/audio/convertbuffer.go create mode 100644 go4k/audio/convertbuffer_test.go create mode 100644 go4k/audio/oto/otoplayer.go create mode 100644 go4k/audio/player.go diff --git a/go.mod b/go.mod index bb4f14c..acd2e12 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/vsariola/sointu go 1.15 + +require github.com/hajimehoshi/oto v0.6.6 diff --git a/go4k/audio/convertbuffer.go b/go4k/audio/convertbuffer.go new file mode 100644 index 0000000..e63f91e --- /dev/null +++ b/go4k/audio/convertbuffer.go @@ -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 +} diff --git a/go4k/audio/convertbuffer_test.go b/go4k/audio/convertbuffer_test.go new file mode 100644 index 0000000..77578a0 --- /dev/null +++ b/go4k/audio/convertbuffer_test.go @@ -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") + } +} diff --git a/go4k/audio/oto/otoplayer.go b/go4k/audio/oto/otoplayer.go new file mode 100644 index 0000000..b2a2e76 --- /dev/null +++ b/go4k/audio/oto/otoplayer.go @@ -0,0 +1,54 @@ +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("error writing to player: %w", err) + } else if _, err := o.player.Write(byteBuffer); err != nil { + return fmt.Errorf("error writing to player: %w", err) + } else { + fmt.Printf("%#v\n", floatBuffer[0:100]) + fmt.Printf("%#v\n", byteBuffer[0:200]) + } + + return nil +} + +// Close disposes of resources +func (o *OtoPlayer) Close() error { + if err := o.player.Close(); err != nil { + return fmt.Errorf("error closing player: %w", err) + } + if err := o.context.Close(); err != nil { + return fmt.Errorf("error closing 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 +} diff --git a/go4k/audio/player.go b/go4k/audio/player.go new file mode 100644 index 0000000..e5203ee --- /dev/null +++ b/go4k/audio/player.go @@ -0,0 +1,6 @@ +package audio + +type Player interface { + Play(buffer []float32) (err error) + Close() +} From 551a7cb6c089552a90db3599203357e19e8ad006 Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Sat, 7 Nov 2020 19:34:46 +0200 Subject: [PATCH 03/14] feat(sointu-player): implement a basic commandline tool to play songs --- go4k/cmd/sointu-player/main.go | 91 ++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 go4k/cmd/sointu-player/main.go diff --git a/go4k/cmd/sointu-player/main.go b/go4k/cmd/sointu-player/main.go new file mode 100644 index 0000000..d5df4d1 --- /dev/null +++ b/go4k/cmd/sointu-player/main.go @@ -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() +} From 6e141f36c74dbaec3fc9233561a4c489578bba53 Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Sat, 7 Nov 2020 19:37:33 +0200 Subject: [PATCH 04/14] fix(go/audio/oto): clean up forgotten debug prints --- go4k/audio/oto/otoplayer.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/go4k/audio/oto/otoplayer.go b/go4k/audio/oto/otoplayer.go index b2a2e76..dbb6d59 100644 --- a/go4k/audio/oto/otoplayer.go +++ b/go4k/audio/oto/otoplayer.go @@ -18,11 +18,7 @@ func (o *OtoPlayer) Play(floatBuffer []float32) (err error) { return fmt.Errorf("error writing to player: %w", err) } else if _, err := o.player.Write(byteBuffer); err != nil { return fmt.Errorf("error writing to player: %w", err) - } else { - fmt.Printf("%#v\n", floatBuffer[0:100]) - fmt.Printf("%#v\n", byteBuffer[0:200]) } - return nil } @@ -45,8 +41,8 @@ func NewPlayer() (*OtoPlayer, error) { if err != nil { return nil, fmt.Errorf("cannot create oto context: %w", err) } - player := context.NewPlayer() + player := context.NewPlayer() return &OtoPlayer{ context: context, player: player, From 5eb7cef8898b719450f18f7021cb82a36b60fe7f Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Sat, 7 Nov 2020 19:38:58 +0200 Subject: [PATCH 05/14] style(go/audio/oto): change error messages to be consistent --- go4k/audio/oto/otoplayer.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/go4k/audio/oto/otoplayer.go b/go4k/audio/oto/otoplayer.go index dbb6d59..577ff0a 100644 --- a/go4k/audio/oto/otoplayer.go +++ b/go4k/audio/oto/otoplayer.go @@ -15,9 +15,9 @@ type OtoPlayer struct { // 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("error writing to player: %w", err) + return fmt.Errorf("cannot convert buffer to bytes: %w", err) } else if _, err := o.player.Write(byteBuffer); err != nil { - return fmt.Errorf("error writing to player: %w", err) + return fmt.Errorf("cannot write to player: %w", err) } return nil } @@ -25,10 +25,10 @@ func (o *OtoPlayer) Play(floatBuffer []float32) (err error) { // Close disposes of resources func (o *OtoPlayer) Close() error { if err := o.player.Close(); err != nil { - return fmt.Errorf("error closing player: %w", err) + return fmt.Errorf("cannot close player: %w", err) } if err := o.context.Close(); err != nil { - return fmt.Errorf("error closing oto context: %w", err) + return fmt.Errorf("cannot close oto context: %w", err) } return nil } From 64fe28a2400790b0d8d26729e3ac3e5d43dd6c1b Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Sat, 7 Nov 2020 19:50:37 +0200 Subject: [PATCH 06/14] feat(tracker): create initial tracker skeleton using Gio --- go.mod | 5 ++++- go4k/cmd/sointu-tracker/main.go | 24 ++++++++++++++++++++++ go4k/tracker/keyevent.go | 17 ++++++++++++++++ go4k/tracker/layout.go | 7 +++++++ go4k/tracker/run.go | 35 +++++++++++++++++++++++++++++++++ go4k/tracker/tracker.go | 13 ++++++++++++ 6 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 go4k/cmd/sointu-tracker/main.go create mode 100644 go4k/tracker/keyevent.go create mode 100644 go4k/tracker/layout.go create mode 100644 go4k/tracker/run.go create mode 100644 go4k/tracker/tracker.go diff --git a/go.mod b/go.mod index acd2e12..5efb099 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module github.com/vsariola/sointu go 1.15 -require github.com/hajimehoshi/oto v0.6.6 +require ( + gioui.org v0.0.0-20201106195654-dbc0796d0207 + github.com/hajimehoshi/oto v0.6.6 +) diff --git a/go4k/cmd/sointu-tracker/main.go b/go4k/cmd/sointu-tracker/main.go new file mode 100644 index 0000000..1cc2284 --- /dev/null +++ b/go4k/cmd/sointu-tracker/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "gioui.org/app" + "gioui.org/unit" + "github.com/vsariola/sointu/go4k/tracker" + "os" +) + +func main() { + 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 { + fmt.Println(err) + os.Exit(1) + } + os.Exit(0) + }() + app.Main() +} diff --git a/go4k/tracker/keyevent.go b/go4k/tracker/keyevent.go new file mode 100644 index 0000000..d302bef --- /dev/null +++ b/go4k/tracker/keyevent.go @@ -0,0 +1,17 @@ +package tracker + +import ( + "gioui.org/io/key" + "os" +) + +// 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 { + switch e.Name { + case key.NameEscape: + os.Exit(0) + } + } + return false +} diff --git a/go4k/tracker/layout.go b/go4k/tracker/layout.go new file mode 100644 index 0000000..b7ef134 --- /dev/null +++ b/go4k/tracker/layout.go @@ -0,0 +1,7 @@ +package tracker + +import "gioui.org/layout" + +func (t *Tracker) Layout(gtx layout.Context) { + t.QuitButton.Layout(gtx) +} diff --git a/go4k/tracker/run.go b/go4k/tracker/run.go new file mode 100644 index 0000000..6e4dab0 --- /dev/null +++ b/go4k/tracker/run.go @@ -0,0 +1,35 @@ +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 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) + } + } + } + +} diff --git a/go4k/tracker/tracker.go b/go4k/tracker/tracker.go new file mode 100644 index 0000000..736e680 --- /dev/null +++ b/go4k/tracker/tracker.go @@ -0,0 +1,13 @@ +package tracker + +import "gioui.org/widget" + +type Tracker struct { + QuitButton *widget.Clickable +} + +func New() *Tracker { + return &Tracker{ + QuitButton: new(widget.Clickable), + } +} From 90c3536f3ee93b387c7485b4cf0bca191a107f91 Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Sun, 8 Nov 2020 01:20:53 +0200 Subject: [PATCH 07/14] feat(tracker): implement some basic styled ui building blocks --- go4k/tracker/label.go | 47 +++++++++++++++++++++++++++++++++++ go4k/tracker/layout.go | 14 +++++++++-- go4k/tracker/panels.go | 56 ++++++++++++++++++++++++++++++++++++++++++ go4k/tracker/theme.go | 24 ++++++++++++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 go4k/tracker/label.go create mode 100644 go4k/tracker/panels.go create mode 100644 go4k/tracker/theme.go diff --git a/go4k/tracker/label.go b/go4k/tracker/label.go new file mode 100644 index 0000000..1edbf20 --- /dev/null +++ b/go4k/tracker/label.go @@ -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 +} diff --git a/go4k/tracker/layout.go b/go4k/tracker/layout.go index b7ef134..dc4d1b8 100644 --- a/go4k/tracker/layout.go +++ b/go4k/tracker/layout.go @@ -1,7 +1,17 @@ package tracker -import "gioui.org/layout" +import ( + "gioui.org/layout" + "gioui.org/op/paint" +) func (t *Tracker) Layout(gtx layout.Context) { - t.QuitButton.Layout(gtx) + layout.Stack{Alignment: layout.NW}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + paint.Fill(gtx.Ops, black) + return layout.Dimensions{Size: gtx.Constraints.Max} + }), + layout.Expanded(t.QuitButton.Layout), + layout.Stacked(Raised(Label("Hello", white))), + ) } diff --git a/go4k/tracker/panels.go b/go4k/tracker/panels.go new file mode 100644 index 0000000..da45f43 --- /dev/null +++ b/go4k/tracker/panels.go @@ -0,0 +1,56 @@ +package tracker + +import ( + "fmt" + "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 { + fmt.Println("BR", gtx.Constraints) + 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()) + fmt.Println("drawing sub..", gtx.Constraints) + 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, + } + } +} diff --git a/go4k/tracker/theme.go b/go4k/tracker/theme.go new file mode 100644 index 0000000..8b0bcc1 --- /dev/null +++ b/go4k/tracker/theme.go @@ -0,0 +1,24 @@ +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 panelColor = neutral +var panelShadeColor = dark +var panelLightColor = light + +var labelFont = fontCollection[6].Font +var labelFontSize = unit.Px(18) From 77949bdc17372c411c9a54da15188874056cd990 Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Sun, 8 Nov 2020 02:24:27 +0200 Subject: [PATCH 08/14] feat(tracker): implement basic track display --- go4k/tracker/defaultsong.go | 27 +++++++++++++++++ go4k/tracker/layout.go | 31 +++++++++++++++---- go4k/tracker/music.go | 29 ++++++++++++++++++ go4k/tracker/panels.go | 3 -- go4k/tracker/theme.go | 9 ++++++ go4k/tracker/track.go | 60 +++++++++++++++++++++++++++++++++++++ go4k/tracker/tracker.go | 13 ++++++-- 7 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 go4k/tracker/defaultsong.go create mode 100644 go4k/tracker/music.go create mode 100644 go4k/tracker/track.go diff --git a/go4k/tracker/defaultsong.go b/go4k/tracker/defaultsong.go new file mode 100644 index 0000000..ec4de66 --- /dev/null +++ b/go4k/tracker/defaultsong.go @@ -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}}, + }}, + }, +} diff --git a/go4k/tracker/layout.go b/go4k/tracker/layout.go index dc4d1b8..fdf66fa 100644 --- a/go4k/tracker/layout.go +++ b/go4k/tracker/layout.go @@ -2,15 +2,34 @@ package tracker import ( "gioui.org/layout" - "gioui.org/op/paint" ) func (t *Tracker) Layout(gtx layout.Context) { - layout.Stack{Alignment: layout.NW}.Layout(gtx, - layout.Expanded(func(gtx layout.Context) layout.Dimensions { - paint.Fill(gtx.Ops, black) - return layout.Dimensions{Size: gtx.Constraints.Max} - }), + 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, + ))) + } + 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))), ) diff --git a/go4k/tracker/music.go b/go4k/tracker/music.go new file mode 100644 index 0000000..4a30d6b --- /dev/null +++ b/go4k/tracker/music.go @@ -0,0 +1,29 @@ +package tracker + +import "fmt" + +const baseNote = 20 + +var notes = []string{ + "C-", + "C#", + "D-", + "D#", + "E-", + "F-", + "F#", + "G-", + "G#", + "A-", + "A#", + "B-", +} + +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) +} diff --git a/go4k/tracker/panels.go b/go4k/tracker/panels.go index da45f43..3e2b079 100644 --- a/go4k/tracker/panels.go +++ b/go4k/tracker/panels.go @@ -1,7 +1,6 @@ package tracker import ( - "fmt" "gioui.org/f32" "gioui.org/layout" "gioui.org/op" @@ -21,13 +20,11 @@ func Lowered(w layout.Widget) layout.Widget { func Beveled(w layout.Widget, base, light, shade color.RGBA) layout.Widget { return func(gtx layout.Context) layout.Dimensions { - fmt.Println("BR", gtx.Constraints) 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()) - fmt.Println("drawing sub..", gtx.Constraints) stack := op.Push(gtx.Ops) mcs := gtx.Constraints mcs.Max.X -= 2 diff --git a/go4k/tracker/theme.go b/go4k/tracker/theme.go index 8b0bcc1..d3ca2df 100644 --- a/go4k/tracker/theme.go +++ b/go4k/tracker/theme.go @@ -15,6 +15,7 @@ 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 panelColor = neutral var panelShadeColor = dark @@ -22,3 +23,11 @@ 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 diff --git a/go4k/tracker/track.go b/go4k/tracker/track.go new file mode 100644 index 0000000..03139cf --- /dev/null +++ b/go4k/tracker/track.go @@ -0,0 +1,60 @@ +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 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 == 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} + } +} diff --git a/go4k/tracker/tracker.go b/go4k/tracker/tracker.go index 736e680..83eb644 100644 --- a/go4k/tracker/tracker.go +++ b/go4k/tracker/tracker.go @@ -1,13 +1,22 @@ package tracker -import "gioui.org/widget" +import ( + "gioui.org/widget" + "github.com/vsariola/sointu/go4k" +) type Tracker struct { - QuitButton *widget.Clickable + QuitButton *widget.Clickable + song go4k.Song + CursorRow int + CursorColumn int + DisplayPattern int + ActiveTrack int } func New() *Tracker { return &Tracker{ QuitButton: new(widget.Clickable), + song: defaultSong, } } From 9b6249a1a7a57357cdba4f5466f900e5c43e881b Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Sun, 8 Nov 2020 02:36:24 +0200 Subject: [PATCH 09/14] feat(tracker): implement basic tracker keys --- go4k/tracker/keyevent.go | 95 ++++++++++++++++++++++++++++++++++++++++ go4k/tracker/music.go | 6 +++ go4k/tracker/tracker.go | 6 ++- 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/go4k/tracker/keyevent.go b/go4k/tracker/keyevent.go index d302bef..c69309f 100644 --- a/go4k/tracker/keyevent.go +++ b/go4k/tracker/keyevent.go @@ -3,15 +3,110 @@ 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 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) +} diff --git a/go4k/tracker/music.go b/go4k/tracker/music.go index 4a30d6b..c16b2c0 100644 --- a/go4k/tracker/music.go +++ b/go4k/tracker/music.go @@ -19,6 +19,7 @@ var notes = []string{ "B-", } +// valueAsNote returns the textual representation of a note value func valueAsNote(val byte) string { octave := (val - baseNote) / 12 oNote := (val - baseNote) % 12 @@ -27,3 +28,8 @@ func valueAsNote(val byte) string { } 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 +} diff --git a/go4k/tracker/tracker.go b/go4k/tracker/tracker.go index 83eb644..e0037ce 100644 --- a/go4k/tracker/tracker.go +++ b/go4k/tracker/tracker.go @@ -12,11 +12,13 @@ type Tracker struct { CursorColumn int DisplayPattern int ActiveTrack int + CurrentOctave byte } func New() *Tracker { return &Tracker{ - QuitButton: new(widget.Clickable), - song: defaultSong, + QuitButton: new(widget.Clickable), + CurrentOctave: 4, + song: defaultSong, } } From 7a434f69fd9dd98a4a5c5b893c47fcbbf6f19027 Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Sun, 8 Nov 2020 02:41:42 +0200 Subject: [PATCH 10/14] fix(go/audio): fix audio.Player interface to have erroring Close() not sure if this is actually required, though? shouldn't we just try to close and disregard errors? --- go4k/audio/player.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go4k/audio/player.go b/go4k/audio/player.go index e5203ee..56a28b8 100644 --- a/go4k/audio/player.go +++ b/go4k/audio/player.go @@ -2,5 +2,5 @@ package audio type Player interface { Play(buffer []float32) (err error) - Close() + Close() error } From 175bbb7743f3df9bdfb9e75c52c3eeb49457d859 Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Sun, 8 Nov 2020 02:48:52 +0200 Subject: [PATCH 11/14] fix(tracker/track): re-enable clipping of tracks --- go4k/tracker/track.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go4k/tracker/track.go b/go4k/tracker/track.go index 03139cf..0e0cf32 100644 --- a/go4k/tracker/track.go +++ b/go4k/tracker/track.go @@ -29,7 +29,7 @@ func (t *Tracker) layoutTrack(notes []byte, active bool, cursorRow, cursorCol in }.Op()) } defer op.Push(gtx.Ops).Pop() - // clip.Rect{Max:gtx.Constraints.Max}.Add(gtx.Ops) + 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 { From 5e45e4f1f4e9a934d0a1ad2d1886f5f2b8766117 Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Sun, 8 Nov 2020 04:17:21 +0200 Subject: [PATCH 12/14] 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 --- go4k/cmd/sointu-tracker/main.go | 9 +++- go4k/tracker/keyevent.go | 3 ++ go4k/tracker/layout.go | 1 + go4k/tracker/run.go | 2 + go4k/tracker/sequencer.go | 87 +++++++++++++++++++++++++++++++++ go4k/tracker/theme.go | 2 + go4k/tracker/track.go | 5 +- go4k/tracker/tracker.go | 42 ++++++++++++++-- 8 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 go4k/tracker/sequencer.go 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 } From d30388a09a24606fff4f628e1bc229c85014633f Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Sun, 8 Nov 2020 04:22:00 +0200 Subject: [PATCH 13/14] fix(tracker): fix invalid println --- go4k/tracker/tracker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go4k/tracker/tracker.go b/go4k/tracker/tracker.go index 7955d6d..d8eea35 100644 --- a/go4k/tracker/tracker.go +++ b/go4k/tracker/tracker.go @@ -34,7 +34,7 @@ func (t *Tracker) LoadSong(song go4k.Song) error { } t.song = song if synth, err := bridge.Synth(song.Patch); err != nil { - fmt.Println("error loading synth: %v", err) + fmt.Printf("error loading synth: %v\n", err) t.synth = nil } else { t.synth = synth From b1ac141ea5e766ad611fad84991974b874c58787 Mon Sep 17 00:00:00 2001 From: Matias Lahti Date: Sun, 8 Nov 2020 04:27:52 +0200 Subject: [PATCH 14/14] fix(tracker/sequencer): add a way to exit the sequencer loop --- go4k/cmd/sointu-tracker/main.go | 4 +++- go4k/tracker/sequencer.go | 4 +++- go4k/tracker/tracker.go | 9 ++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/go4k/cmd/sointu-tracker/main.go b/go4k/cmd/sointu-tracker/main.go index 904c796..fe6c843 100644 --- a/go4k/cmd/sointu-tracker/main.go +++ b/go4k/cmd/sointu-tracker/main.go @@ -21,7 +21,9 @@ func main() { app.Size(unit.Dp(800), unit.Dp(600)), app.Title("Sointu Tracker"), ) - if err := tracker.New(plr).Run(w); err != nil { + t := tracker.New(plr) + defer t.Close() + if err := t.Run(w); err != nil { fmt.Println(err) os.Exit(1) } diff --git a/go4k/tracker/sequencer.go b/go4k/tracker/sequencer.go index 2998dab..ec67367 100644 --- a/go4k/tracker/sequencer.go +++ b/go4k/tracker/sequencer.go @@ -12,7 +12,7 @@ func (t *Tracker) TogglePlay() { } // sequencerLoop is the main goroutine that handles the playing logic -func (t *Tracker) sequencerLoop() { +func (t *Tracker) sequencerLoop(closer chan struct{}) { playing := false rowTime := (time.Second * 60) / time.Duration(4*t.song.BPM) tick := make(<-chan time.Time) @@ -46,6 +46,8 @@ func (t *Tracker) sequencerLoop() { 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 { diff --git a/go4k/tracker/tracker.go b/go4k/tracker/tracker.go index d8eea35..e025081 100644 --- a/go4k/tracker/tracker.go +++ b/go4k/tracker/tracker.go @@ -26,6 +26,7 @@ type Tracker struct { player audio.Player synth go4k.Synth playBuffer []float32 + closer chan struct{} } func (t *Tracker) LoadSong(song go4k.Song) error { @@ -42,6 +43,11 @@ func (t *Tracker) LoadSong(song go4k.Song) error { return nil } +func (t *Tracker) Close() { + t.player.Close() + t.closer <- struct{}{} +} + func New(player audio.Player) *Tracker { t := &Tracker{ QuitButton: new(widget.Clickable), @@ -51,8 +57,9 @@ func New(player audio.Player) *Tracker { rowJump: make(chan int), patternJump: make(chan int), ticked: make(chan struct{}), + closer: make(chan struct{}), } - go t.sequencerLoop() + go t.sequencerLoop(t.closer) if err := t.LoadSong(defaultSong); err != nil { panic(fmt.Errorf("cannot load default song: %w", err)) }