From cd700ed954d0b56e25e590f3b5fea0866263e1ab Mon Sep 17 00:00:00 2001 From: "5684185+vsariola@users.noreply.github.com" <5684185+vsariola@users.noreply.github.com> Date: Tue, 9 May 2023 11:24:49 +0300 Subject: [PATCH] feat!: implement vsti, along with various refactorings and api changes for it The RPC and sync library mechanisms were removed for now; they never really worked and contained several obvious bugs. Need to consider if syncs are useful at all during the compose time, or just used during intro. --- .gitignore | 2 + README.md | 99 +++-- cmd/default_service_native.go | 7 + cmd/default_service_other.go | 7 + cmd/sointu-nativetrack/main.go | 23 - cmd/sointu-play/main.go | 2 +- cmd/sointu-track/main.go | 45 +- cmd/sointu-vsti/main.go | 104 +++++ go.mod | 28 +- go.sum | 16 +- rpc/rpc.go | 57 --- rpc/rpc_test.go | 24 -- synth.go | 30 +- tracker/gioui/files.go | 5 +- tracker/gioui/instrumenteditor.go | 60 ++- tracker/gioui/keyevent.go | 44 +- tracker/gioui/layout.go | 12 +- tracker/gioui/ordereditor.go | 15 +- tracker/gioui/rowmarkers.go | 4 +- tracker/gioui/run.go | 82 ---- tracker/gioui/songpanel.go | 47 ++- tracker/gioui/trackeditor.go | 25 +- tracker/gioui/tracker.go | 122 ++++-- tracker/gioui/tracker_not_plugin.go | 15 + tracker/gioui/tracker_plugin.go | 10 + tracker/model.go | 199 ++++++--- tracker/note_id.go | 19 + tracker/player.go | 603 ++++++++++++++++----------- tracker/recording.go | 145 +++++++ tracker/{vuanalyzer.go => volume.go} | 52 +-- vm/compiler/bridge/bridge.go | 28 +- vm/compiler/bridge/bridge_test.go | 4 +- vm/interpreter.go | 20 +- vm/interpreter_test.go | 5 +- 34 files changed, 1210 insertions(+), 750 deletions(-) create mode 100644 cmd/default_service_native.go create mode 100644 cmd/default_service_other.go delete mode 100644 cmd/sointu-nativetrack/main.go create mode 100644 cmd/sointu-vsti/main.go delete mode 100644 rpc/rpc.go delete mode 100644 rpc/rpc_test.go delete mode 100644 tracker/gioui/run.go create mode 100644 tracker/gioui/tracker_not_plugin.go create mode 100644 tracker/gioui/tracker_plugin.go create mode 100644 tracker/note_id.go create mode 100644 tracker/recording.go rename tracker/{vuanalyzer.go => volume.go} (60%) diff --git a/.gitignore b/.gitignore index 8d56046..35b5637 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ out/ .cache/ actual_output/ **/__debug_bin +*.exe +*.dll diff --git a/README.md b/README.md index a9cfabd..30d9572 100644 --- a/README.md +++ b/README.md @@ -24,20 +24,17 @@ synthesis engine can already be fitted in 600 bytes (386, compressed), with another few hundred bytes for the patch and pattern data. Sointu consists of two core elements: -- A cross-platform synth-tracker app for composing music, written in - [go](https://golang.org/). The app is still heavily work in progress. The app - exports the projects as .yml files. There are two versions of the app: - [cmd/sointu-track/](sointu-track), using a plain Go VM bytecode interpreter, - and [cmd/sointu-nativetrack/](sointu-nativetrack), using cgo to bridge calls - to the Sointu compiled VM. The former should be highly portable, the latter - currently works only on x86/amd64 platforms. +- A cross-platform synth-tracker that runs as either VSTi or stand-alone + app for composing music, written in [go](https://golang.org/). The app + is still heavily work in progress. The app exports the projects as + .yml files. - A compiler, likewise written in go, which can be invoked from the command line to compile these .yml files into .asm or .wat code. For x86/amd64, the resulting .asm can be then compiled by [nasm](https://www.nasm.us/) or [yasm](https://yasm.tortall.net). For browsers, the resulting .wat can be compiled by [wat2wasm](https://github.com/WebAssembly/wabt). -This is how the current prototype tracker looks like: +This is how the current prototype app looks like: ![Screenshot of the tracker](screenshot.png) @@ -49,8 +46,7 @@ listed below. ### Sointu-track -This version of the tracker is the version that uses the bytecode interpreter -written in plain Go. Running the tracker: +This is the stand-alone version of the synth-tracker. Running the tracker: ``` go run cmd/sointu-track/main.go @@ -59,24 +55,40 @@ go run cmd/sointu-track/main.go Building the tracker: ``` -go build -o sointu-track cmd/sointu-track/main.go +go build -o sointu-track.exe cmd/sointu-track/main.go ``` -On windows, replace `-o sointu-track` with `-o sointu-track.exe`. +On other platforms than Windows, replace `-o sointu-track.exe` with +`-o sointu-track`. + +Add `-tags=native` to use the [x86 native virtual machine](#native-virtual-machine) +instead of the virtual machine written in Go. Sointu-track uses the [gioui](https://gioui.org/) for the GUI and [oto](https://github.com/hajimehoshi/oto) for the audio, so the portability is currently limited by these. -> :warning: Unlike the x86/amd64 VM compiled by Sointu, the Go written VM -> bytecode interpreter uses a software stack. Thus, unlike x87 FPU stack, it is -> not limited to 8 items. If you intent to compile the patch to x86/amd64 -> targets, make sure not to use too much stack. Keeping at most 5 signals in the -> stack is presumably fine (reserving 3 for the temporary variables of the -> opcodes). In future, the app should give warnings if the user is about to -> exceed the capabilities of a target platform. +### Sointu-vsti -### Compiler +This is the VST instrument plugin version of the tracker, compiled into +a dynamically linked library and ran inside a VST host. Building the VST +plugin: + +``` +go build -buildmode=c-shared -tags=plugin -o sointu-vsti.dll .\cmd\sointu-vsti\ +``` + +On other platforms than Windows, replace `-o sointu-track.dll` +appropriately e.g. `-o sointu-track.so`; however, the VST instrument is +completely untested on all other platforms than Windows at the moment. + +Notice the `-tags=plugin` build tag definition. This is required by the [vst2 library](https://github.com/pipelined/vst2); +otherwise, you will get a lot of build errors. + +Add `-tags=native,plugin` to use the [x86 native virtual machine](#native-virtual-machine) +instead of the virtual machine written in Go. + +### Sointu-compile The command line interface to it is [sointu-compile](cmd/sointu-compile/main.go) and the actual code resides in the [compiler](vm/compiler/) package, which is an @@ -91,10 +103,11 @@ go run cmd/sointu-compile/main.go Building the compiler: ``` -go build -o sointu-compile cmd/sointu-compile/main.go +go build -o sointu-compile.exe cmd/sointu-compile/main.go ``` -On windows, replace `-o sointu-compile` with `-o sointu-compile.exe`. +On other platforms than Windows, replace `-o sointu-compile-exe` with +`-o sointu-compile`. The compiler can then be used to compile a .yml song into .asm and .h files. For example: @@ -111,7 +124,7 @@ sointu-compile -o . -arch=wasm tests/test_chords.yml wat2wasm --enable-bulk-memory test_chords.wat ``` -### Building and running the tests as executables +### Tests Building the [regression tests](tests/) as executables (testing that they work the same way when you would link them in an intro) requires: @@ -142,14 +155,13 @@ cmake .. -DCMAKE_C_FLAGS="-m32" -DCMAKE_ASM_NASM_OBJECT_FORMAT="win32" -GNinja Another example: on Visual Studio 2019 Community, just open the folder, choose either Debug or Release and either x86 or x64 build, and hit build all. -### Native bridge & sointu-nativetrack +### Native virtual machine -The native bridge allows Go to call the sointu compiled virtual machine, through -cgo, instead of using the Go written bytecode interpreter. It's likely slightly -faster than the interpreter. The version of the tracker that uses the native -bridge is [sointu-nativetrack](cmd/sointu-nativetrack/). Before you can actually -run it, you need to build the bridge using CMake (thus, the nativetrack does not -work with go get) +The native bridge allows Go to call the sointu compiled x86 native +virtual machine, through cgo, instead of using the Go written bytecode +interpreter. It's likely slightly faster than the interpreter. Before +you can actually run it, you need to build the bridge using CMake (thus, +***this will not work with go get***) Building the native bridge requires: - [go](https://golang.org/) @@ -191,14 +203,26 @@ go test ./... Play a song from the command line: ``` -go run cmd/sointu-play/main.go tests/test_chords.yml +go run -tags=native cmd/sointu-play/main.go tests/test_chords.yml ``` Run the tracker using the native bridge ``` -go run cmd/sointu-nativetrack/main.go +go run -tags=native cmd/sointu-track/main.go ``` +``` +go build -buildmode=c-shared -tags=plugin,native -o sointu-vsti.dll .\cmd\sointu-vsti\ +``` + +> :warning: Unlike the x86/amd64 VM compiled by Sointu, the Go written VM +> bytecode interpreter uses a software stack. Thus, unlike x87 FPU stack, it is +> not limited to 8 items. If you intent to compile the patch to x86/amd64 +> targets, make sure not to use too much stack. Keeping at most 5 signals in the +> stack is presumably fine (reserving 3 for the temporary variables of the +> opcodes). In future, the app should give warnings if the user is about to +> exceed the capabilities of a target platform. + > :warning: **If you are using MinGW and Yasm**: Yasm 1.3.0 (currently still the > latest stable release) and GNU linker do not play nicely along, trashing the > BSS layout. See @@ -209,16 +233,17 @@ go run cmd/sointu-nativetrack/main.go > our synth object overlapping with DLL call addresses; very funny stuff to > debug. -> :warning: The sointu-nativetrack cannot be used with the syncs at the moment. -> For syncs, use the Go VM (sointu-track). +> :warning: The native virtual machine cannot be output syncs at the +> moment. For syncs, use the Go virtual machine. -### Building and running the WebAssembly tests +### WebAssembly tests These are automatically invoked by CTest if [node](https://nodejs.org) and [wat2wasm](https://github.com/WebAssembly/wabt) are found in the path. New features since fork ----------------------- + - **New units**. For example: bit-crusher, gain, inverse gain, clip, modulate bpm (proper triplets!), compressor (can be used for side-chaining). - **Compiler**. Written in go. The input is a .yml file and the output is an @@ -415,7 +440,9 @@ so I thought it would fun to learn some Finnish for a change. And Prods using Sointu ------------------ -[Adam](https://github.com/vsariola/adam) by brainlez Coders! - My first test-driving of Sointu. Some ideas how to integrate Sointu to the build chain. +[Adam](https://github.com/vsariola/adam) by brainlez Coders! - My first +test-driving of Sointu. Some ideas how to integrate Sointu to the build +chain. Credits ------- diff --git a/cmd/default_service_native.go b/cmd/default_service_native.go new file mode 100644 index 0000000..ca25034 --- /dev/null +++ b/cmd/default_service_native.go @@ -0,0 +1,7 @@ +//go:build native + +package cmd + +import "github.com/vsariola/sointu/vm/compiler/bridge" + +var DefaultService = bridge.BridgeService{} diff --git a/cmd/default_service_other.go b/cmd/default_service_other.go new file mode 100644 index 0000000..7415688 --- /dev/null +++ b/cmd/default_service_other.go @@ -0,0 +1,7 @@ +//go:build !native + +package cmd + +import "github.com/vsariola/sointu/vm" + +var DefaultService = vm.SynthService{} diff --git a/cmd/sointu-nativetrack/main.go b/cmd/sointu-nativetrack/main.go deleted file mode 100644 index 78c7223..0000000 --- a/cmd/sointu-nativetrack/main.go +++ /dev/null @@ -1,23 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/vsariola/sointu/oto" - "github.com/vsariola/sointu/tracker/gioui" - "github.com/vsariola/sointu/vm/compiler/bridge" -) - -func main() { - audioContext, err := oto.NewContext() - if err != nil { - fmt.Println(err) - os.Exit(1) - } - defer audioContext.Close() - synthService := bridge.BridgeService{} - // TODO: native track does not support syncing at the moment (which is why - // we pass nil), as the native bridge does not support sync data - gioui.Main(audioContext, synthService, nil) -} diff --git a/cmd/sointu-play/main.go b/cmd/sointu-play/main.go index 7118470..018e779 100644 --- a/cmd/sointu-play/main.go +++ b/cmd/sointu-play/main.go @@ -88,7 +88,7 @@ func main() { return fmt.Errorf("the song could not be parsed as .json (%v) or .yml (%v)", errJSON, errYaml) } } - buffer, _, err := sointu.Play(bridge.BridgeService{}, song, !*unreleased) // render the song to calculate its length + buffer, err := sointu.Play(bridge.BridgeService{}, song, !*unreleased) // render the song to calculate its length if err != nil { return fmt.Errorf("sointu.Play failed: %v", err) } diff --git a/cmd/sointu-track/main.go b/cmd/sointu-track/main.go index 433f2bf..403016e 100644 --- a/cmd/sointu-track/main.go +++ b/cmd/sointu-track/main.go @@ -5,14 +5,25 @@ import ( "fmt" "os" + "gioui.org/app" + "github.com/vsariola/sointu/cmd" "github.com/vsariola/sointu/oto" - "github.com/vsariola/sointu/rpc" + "github.com/vsariola/sointu/tracker" "github.com/vsariola/sointu/tracker/gioui" - "github.com/vsariola/sointu/vm" ) +type NullContext struct { +} + +func (NullContext) NextEvent() (event tracker.PlayerProcessEvent, ok bool) { + return tracker.PlayerProcessEvent{}, false +} + +func (NullContext) BPM() (bpm float64, ok bool) { + return 0, false +} + func main() { - syncAddress := flag.String("address", "", "remote RPC server where to send sync data") flag.Parse() audioContext, err := oto.NewContext() if err != nil { @@ -20,14 +31,24 @@ func main() { os.Exit(1) } defer audioContext.Close() - var syncChannel chan<- []float32 - if *syncAddress != "" { - syncChannel, err = rpc.Sender(*syncAddress) - if err != nil { - fmt.Println(err) - os.Exit(1) + modelMessages := make(chan interface{}, 1024) + playerMessages := make(chan tracker.PlayerMessage, 1024) + model := tracker.NewModel(modelMessages, playerMessages) + player := tracker.NewPlayer(cmd.DefaultService, playerMessages, modelMessages) + tracker := gioui.NewTracker(model, cmd.DefaultService) + output := audioContext.Output() + defer output.Close() + go func() { + buf := make([]float32, 2048) + ctx := NullContext{} + for { + player.Process(buf, ctx) + output.WriteAudio(buf) } - } - synthService := vm.SynthService{} - gioui.Main(audioContext, synthService, syncChannel) + }() + go func() { + tracker.Main() + os.Exit(0) + }() + app.Main() } diff --git a/cmd/sointu-vsti/main.go b/cmd/sointu-vsti/main.go new file mode 100644 index 0000000..c4893b5 --- /dev/null +++ b/cmd/sointu-vsti/main.go @@ -0,0 +1,104 @@ +//go:build plugin + +package main + +import ( + "github.com/vsariola/sointu/cmd" + "github.com/vsariola/sointu/tracker" + "github.com/vsariola/sointu/tracker/gioui" + "pipelined.dev/audio/vst2" +) + +type VSTIProcessContext struct { + events []vst2.MIDIEvent + host vst2.Host +} + +func (c *VSTIProcessContext) NextEvent() (event tracker.PlayerProcessEvent, ok bool) { + var ev vst2.MIDIEvent + for len(c.events) > 0 { + ev, c.events = c.events[0], c.events[1:] + switch { + case ev.Data[0] >= 0x80 && ev.Data[0] < 0x90: + channel := ev.Data[0] - 0x80 + note := ev.Data[1] + return tracker.PlayerProcessEvent{Frame: int(ev.DeltaFrames), On: false, Channel: int(channel), Note: note}, true + case ev.Data[0] >= 0x90 && ev.Data[0] < 0xA0: + channel := ev.Data[0] - 0x90 + note := ev.Data[1] + return tracker.PlayerProcessEvent{Frame: int(ev.DeltaFrames), On: true, Channel: int(channel), Note: note}, true + default: + // ignore all other MIDI messages + } + } + return tracker.PlayerProcessEvent{}, false +} + +func (c *VSTIProcessContext) BPM() (bpm float64, ok bool) { + timeInfo := c.host.GetTimeInfo() + return timeInfo.Tempo, true +} + +func init() { + var ( + uniqueID = [4]byte{'S', 'n', 't', 'u'} + version = int32(100) + ) + vst2.PluginAllocator = func(h vst2.Host) (vst2.Plugin, vst2.Dispatcher) { + modelMessages := make(chan interface{}, 1024) + playerMessages := make(chan tracker.PlayerMessage, 1024) + model := tracker.NewModel(modelMessages, playerMessages) + player := tracker.NewPlayer(cmd.DefaultService, playerMessages, modelMessages) + tracker := gioui.NewTracker(model, cmd.DefaultService) + tracker.SetInstrEnlarged(true) // start the vsti with the instrument editor enlarged + go tracker.Main() + context := VSTIProcessContext{make([]vst2.MIDIEvent, 100), h} + buf := make([]float32, 2048) + return vst2.Plugin{ + UniqueID: uniqueID, + Version: version, + InputChannels: 0, + OutputChannels: 2, + Name: "Sointu", + Vendor: "vsariola/sointu", + Category: vst2.PluginCategorySynth, + Flags: vst2.PluginIsSynth, + ProcessFloatFunc: func(in, out vst2.FloatBuffer) { + left := out.Channel(0) + right := out.Channel(1) + if len(buf) < out.Frames*2 { + buf = append(buf, make([]float32, out.Frames*2-len(buf))...) + } + buf = buf[:out.Frames*2] + player.Process(buf, &context) + for i := 0; i < out.Frames; i++ { + left[i], right[i] = buf[i*2], buf[i*2+1] + } + context.events = context.events[:0] + }, + }, vst2.Dispatcher{ + CanDoFunc: func(pcds vst2.PluginCanDoString) vst2.CanDoResponse { + switch pcds { + case vst2.PluginCanReceiveEvents, vst2.PluginCanReceiveMIDIEvent, vst2.PluginCanReceiveTimeInfo: + return vst2.YesCanDo + } + return vst2.NoCanDo + }, + ProcessEventsFunc: func(ev *vst2.EventsPtr) { + for i := 0; i < ev.NumEvents(); i++ { + a := ev.Event(i) + switch v := a.(type) { + case *vst2.MIDIEvent: + context.events = append(context.events, *v) + } + } + }, + CloseFunc: func() { + tracker.Quit(true) + }, + } + + } +} + +func main() {} diff --git a/go.mod b/go.mod index d62ec0a..074afc1 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,32 @@ module github.com/vsariola/sointu -go 1.15 +go 1.17 require ( gioui.org v0.0.0-20210410094005-495c69018772 gioui.org/x v0.0.0-20210419013052-6db76265c4e1 - github.com/Masterminds/goutils v1.1.0 // indirect - github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig v2.22.0+incompatible - github.com/google/uuid v1.1.2 // indirect github.com/hajimehoshi/oto v0.6.6 - github.com/huandu/xstrings v1.3.2 // indirect - github.com/imdario/mergo v0.3.11 // indirect - github.com/mitchellh/copystructure v1.0.0 // indirect - github.com/stretchr/testify v1.6.1 // indirect golang.org/x/exp v0.0.0-20201229011636-eab1b5eb1a03 gopkg.in/yaml.v2 v2.3.0 gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 + pipelined.dev/audio/vst2 v0.10.1-0.20230513073541-08ee2a4520cb +) + +require ( + github.com/Masterminds/goutils v1.1.0 // indirect + github.com/Masterminds/semver v1.5.0 // indirect + github.com/google/uuid v1.1.2 // indirect + github.com/huandu/xstrings v1.3.2 // indirect + github.com/imdario/mergo v0.3.11 // indirect + github.com/mitchellh/copystructure v1.0.0 // indirect + github.com/mitchellh/reflectwalk v1.0.0 // indirect + github.com/stretchr/testify v1.6.1 // indirect + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 // indirect + golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 // indirect + golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f // indirect + golang.org/x/sys v0.0.0-20210304124612-50617c2ba197 // indirect + golang.org/x/text v0.3.4 // indirect + pipelined.dev/pipe v0.11.0 // indirect + pipelined.dev/signal v0.10.0 // indirect ) diff --git a/go.sum b/go.sum index dbf96eb..9447d71 100644 --- a/go.sum +++ b/go.sum @@ -34,25 +34,21 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529 h1:iMGN4xG0cnqj3t+zOM8wUB0BiPKHEwSxEZCvzcbZuvk= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= -golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3 h1:n9HxLrNxWWtEb1cA950nuEEj3QnKbtsCJ6KjcgisNUs= golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= golang.org/x/exp v0.0.0-20201229011636-eab1b5eb1a03 h1:XlAInxBYX5nBofPaY51uv/x9xmRgZGr/lDOsePd2AcE= golang.org/x/exp v0.0.0-20201229011636-eab1b5eb1a03/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34= golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM= golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f h1:kgfVkAEEQXXQ0qc6dH7n6y37NAYmTFmz0YRwrRjgxKw= golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= @@ -71,7 +67,6 @@ golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210304124612-50617c2ba197 h1:7+SpRyhoo46QjKkYInQXpcfxx3TYFEYkn131lwGE9/0= golang.org/x/sys v0.0.0-20210304124612-50617c2ba197/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -90,3 +85,14 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +pipelined.dev/audio/vst2 v0.10.0 h1:exmszZmQ2KZeqfXnxqrQ2UsLexaz9B5WZHE2Kbbd/mg= +pipelined.dev/audio/vst2 v0.10.0/go.mod h1:wETLxsbBPftj6t4iVBCXvH/Xgd27ZgIC4hNnHDYNuz8= +pipelined.dev/audio/vst2 v0.10.1-0.20230501210043-10b5e35ddc62 h1:cDF8OupcD4iSldxY1j5kbMsXtBzrQ9ODJM7tnCzyul8= +pipelined.dev/audio/vst2 v0.10.1-0.20230501210043-10b5e35ddc62/go.mod h1:wETLxsbBPftj6t4iVBCXvH/Xgd27ZgIC4hNnHDYNuz8= +pipelined.dev/audio/vst2 v0.10.1-0.20230513073541-08ee2a4520cb h1:KupwowSEQavEdUQfZc3TKGzls96Dax1eva/0sBfdqHQ= +pipelined.dev/audio/vst2 v0.10.1-0.20230513073541-08ee2a4520cb/go.mod h1:wETLxsbBPftj6t4iVBCXvH/Xgd27ZgIC4hNnHDYNuz8= +pipelined.dev/pipe v0.10.0/go.mod h1:aIt+NPlW0QLYByqYniG77lTxSvl7OtCNLws/m+Xz5ww= +pipelined.dev/pipe v0.11.0 h1:yRrbntKdqw/nbFqkz9dPaSHBoM7pK1LRHHDqdBJuqtc= +pipelined.dev/pipe v0.11.0/go.mod h1:aIt+NPlW0QLYByqYniG77lTxSvl7OtCNLws/m+Xz5ww= +pipelined.dev/signal v0.10.0 h1:7O1bdYHG6MeXYthNKsXB++jx2UkUPiicwE/MMwdgYRc= +pipelined.dev/signal v0.10.0/go.mod h1:wi0YlA20+rinS9o+7IMZHH3/YsO3jkahHNLSCCfaEA0= diff --git a/rpc/rpc.go b/rpc/rpc.go deleted file mode 100644 index a6dad65..0000000 --- a/rpc/rpc.go +++ /dev/null @@ -1,57 +0,0 @@ -package rpc - -import ( - "fmt" - "log" - "net" - "net/http" - "net/rpc" -) - -type SyncServer struct { - channel chan []float32 -} - -func (s *SyncServer) Sync(syncData []float32, reply *int) error { - select { - case s.channel <- syncData: - default: - } - return nil -} - -func Receiver() (<-chan []float32, error) { - c := make(chan []float32, 1) - server := &SyncServer{channel: c} - rpc.Register(server) - rpc.HandleHTTP() - l, e := net.Listen("tcp", ":31337") - if e != nil { - log.Fatal("listen error:", e) - return nil, fmt.Errorf("net.listen failed: %v", e) - } - go func() { - defer close(c) - http.Serve(l, nil) - }() - return c, nil -} - -func Sender(serverAddress string) (chan<- []float32, error) { - c := make(chan []float32, 256) - client, err := rpc.DialHTTP("tcp", serverAddress+":31337") - if err != nil { - log.Fatal("dialing:", err) - return nil, fmt.Errorf("rpc.DialHTTP failed: %v", err) - } - go func() { - for msg := range c { - var reply int - err = client.Call("SyncServer.Sync", msg, &reply) - if err != nil { - log.Fatal("SyncServer.Sync error:", err) - } - } - }() - return c, nil -} diff --git a/rpc/rpc_test.go b/rpc/rpc_test.go deleted file mode 100644 index 44918f6..0000000 --- a/rpc/rpc_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package rpc_test - -import ( - "testing" - - "github.com/vsariola/sointu/rpc" -) - -func TestSendReceive(t *testing.T) { - receiver, err := rpc.Receiver() - if err != nil { - t.Fatalf("rpc.Receiver error: %v", err) - } - sender, err := rpc.Sender("127.0.0.1") - if err != nil { - t.Fatalf("rpc.Sender error: %v", err) - } - value := []float32{42} - sender <- value - valueGot := <-receiver - if valueGot[0] != value[0] { - t.Fatalf("rpc.Sender error: %v", err) - } -} diff --git a/synth.go b/synth.go index 8578bee..3fcb5f2 100644 --- a/synth.go +++ b/synth.go @@ -10,13 +10,12 @@ import ( type Synth interface { // Render tries to fill a stereo signal buffer with sound from the // synthesizer, until either the buffer is full or a given number of - // timesteps is advanced. In the process, it also fills the syncbuffer with - // the values output by sync units. Normally, 1 sample = 1 unit of time, but + // timesteps is advanced. Normally, 1 sample = 1 unit of time, but // speed modulations may change this. It returns the number of samples // filled (! in stereo samples, so the buffer will have 2 * sample floats), // the number of sync outputs written, the number of time steps time // advanced, and a possible error. - Render(buffer []float32, syncBuffer []float32, maxtime int) (sample int, syncs int, time int, err error) + Render(buffer []float32, maxtime int) (sample int, time int, err error) // Update recompiles a patch, but should maintain as much as possible of its // state as reasonable. For example, filters should keep their state and @@ -42,7 +41,7 @@ type SynthService interface { // Render fills an stereo audio buffer using a Synth, disregarding all syncs and // time limits. func Render(synth Synth, buffer []float32) error { - s, _, _, err := synth.Render(buffer, nil, math.MaxInt32) + s, _, err := synth.Render(buffer, math.MaxInt32) if err != nil { return fmt.Errorf("sointu.Render failed: %v", err) } @@ -58,14 +57,14 @@ func Render(synth Synth, buffer []float32) error { // created. The default behaviour during runtime rendering is to leave them // playing, meaning that envelopes start attacking right away unless an explicit // note release is put to every track. -func Play(synthService SynthService, song Song, release bool) ([]float32, []float32, error) { +func Play(synthService SynthService, song Song, release bool) ([]float32, error) { err := song.Validate() if err != nil { - return nil, nil, err + return nil, err } synth, err := synthService.Compile(song.Patch) if err != nil { - return nil, nil, fmt.Errorf("sointu.Play failed: %v", err) + return nil, fmt.Errorf("sointu.Play failed: %v", err) } if release { for i := 0; i < 32; i++ { @@ -79,9 +78,6 @@ func Play(synthService SynthService, song Song, release bool) ([]float32, []floa initialCapacity := song.Score.LengthInRows() * song.SamplesPerRow() * 2 buffer := make([]float32, 0, initialCapacity) rowbuffer := make([]float32, song.SamplesPerRow()*2) - numSyncs := song.Patch.NumSyncs() - syncBuffer := make([]float32, 0, (song.Score.LengthInRows()*song.SamplesPerRow()+255)/256*(1+numSyncs)) - syncRowBuffer := make([]float32, ((song.SamplesPerRow()+255)/256)*(1+numSyncs)) for row := 0; row < song.Score.LengthInRows(); row++ { patternRow := row % song.Score.RowsPerPattern pattern := row / song.Score.RowsPerPattern @@ -115,22 +111,16 @@ func Play(synthService SynthService, song Song, release bool) ([]float32, []floa } tries := 0 for rowtime := 0; rowtime < song.SamplesPerRow(); { - samples, syncs, time, err := synth.Render(rowbuffer, syncRowBuffer, song.SamplesPerRow()-rowtime) - for i := 0; i < syncs; i++ { - t := syncRowBuffer[i*(1+numSyncs)] - t = (t+float32(rowtime))/(float32(song.SamplesPerRow())) + float32(row) - syncRowBuffer[i*(1+numSyncs)] = t - } + samples, time, err := synth.Render(rowbuffer, song.SamplesPerRow()-rowtime) if err != nil { - return buffer, syncBuffer, fmt.Errorf("render failed: %v", err) + return buffer, fmt.Errorf("render failed: %v", err) } rowtime += time buffer = append(buffer, rowbuffer[:samples*2]...) - syncBuffer = append(syncBuffer, syncRowBuffer[:syncs*(1+numSyncs)]...) if tries > 100 { - return nil, nil, fmt.Errorf("Song speed modulation likely so slow that row never advances; error at pattern %v, row %v", pattern, patternRow) + return nil, fmt.Errorf("Song speed modulation likely so slow that row never advances; error at pattern %v, row %v", pattern, patternRow) } } } - return buffer, syncBuffer, nil + return buffer, nil } diff --git a/tracker/gioui/files.go b/tracker/gioui/files.go index 3670757..3a12a33 100644 --- a/tracker/gioui/files.go +++ b/tracker/gioui/files.go @@ -10,7 +10,6 @@ import ( "path/filepath" "time" - "gioui.org/app" "gopkg.in/yaml.v3" "github.com/vsariola/sointu" @@ -84,7 +83,6 @@ func (t *Tracker) loadSong(filename string) { } t.SetSong(song) t.SetFilePath(filename) - t.window.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", filename))) t.ClearUndoHistory() t.SetChangedSinceSave(false) } @@ -107,7 +105,6 @@ func (t *Tracker) saveSong(filename string) bool { } ioutil.WriteFile(filename, contents, 0644) t.SetFilePath(filename) - t.window.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", filename))) t.SetChangedSinceSave(false) return true } @@ -117,7 +114,7 @@ func (t *Tracker) exportWav(filename string, pcm16 bool) { if extension == "" { filename = filename + ".wav" } - data, _, err := sointu.Play(t.synthService, t.Song(), true) // render the song to calculate its length + data, err := sointu.Play(t.synthService, t.Song(), true) // render the song to calculate its length if err != nil { t.Alert.Update(fmt.Sprintf("Error rendering the song during export: %v", err), Error, time.Second*3) return diff --git a/tracker/gioui/instrumenteditor.go b/tracker/gioui/instrumenteditor.go index fca6dc2..b84f6f7 100644 --- a/tracker/gioui/instrumenteditor.go +++ b/tracker/gioui/instrumenteditor.go @@ -4,7 +4,6 @@ import ( "fmt" "image" "image/color" - "math" "strconv" "strings" "time" @@ -20,12 +19,14 @@ import ( "gioui.org/widget/material" "gioui.org/x/eventx" "github.com/vsariola/sointu/tracker" + "github.com/vsariola/sointu/vm" "golang.org/x/exp/shiny/materialdesign/icons" "gopkg.in/yaml.v3" ) type InstrumentEditor struct { newInstrumentBtn *widget.Clickable + enlargeBtn *widget.Clickable deleteInstrumentBtn *widget.Clickable copyInstrumentBtn *widget.Clickable saveInstrumentBtn *widget.Clickable @@ -45,11 +46,13 @@ type InstrumentEditor struct { tag bool wasFocused bool commentExpanded bool + voiceStates [vm.MAX_VOICES]float32 } func NewInstrumentEditor() *InstrumentEditor { return &InstrumentEditor{ newInstrumentBtn: new(widget.Clickable), + enlargeBtn: new(widget.Clickable), deleteInstrumentBtn: new(widget.Clickable), copyInstrumentBtn: new(widget.Clickable), saveInstrumentBtn: new(widget.Clickable), @@ -97,10 +100,31 @@ func (ie *InstrumentEditor) Layout(gtx C, t *Tracker) D { pointer.InputOp{Tag: &ie.tag, Types: pointer.Press, }.Add(gtx.Ops) + + var icon []byte + if t.InstrEnlarged() { + icon = icons.NavigationFullscreenExit + } else { + icon = icons.NavigationFullscreen + } + fullscreenBtnStyle := IconButton(t.Theme, ie.enlargeBtn, icon, true) + for ie.enlargeBtn.Clicked() { + t.SetInstrEnlarged(!t.InstrEnlarged()) + } for ie.newInstrumentBtn.Clicked() { t.AddInstrument(true) } - btnStyle := IconButton(t.Theme, ie.newInstrumentBtn, icons.ContentAdd, t.CanAddInstrument()) + octave := func(gtx C) D { + in := layout.UniformInset(unit.Dp(1)) + t.OctaveNumberInput.Value = t.Octave() + numStyle := NumericUpDown(t.Theme, t.OctaveNumberInput, 0, 9) + gtx.Constraints.Min.Y = gtx.Px(unit.Dp(20)) + gtx.Constraints.Min.X = gtx.Px(unit.Dp(70)) + dims := in.Layout(gtx, numStyle.Layout) + t.SetOctave(t.OctaveNumberInput.Value) + return dims + } + newBtnStyle := IconButton(t.Theme, ie.newInstrumentBtn, icons.ContentAdd, t.CanAddInstrument()) ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { return layout.Flex{}.Layout( @@ -116,7 +140,19 @@ func (ie *InstrumentEditor) Layout(gtx C, t *Tracker) D { ) }), layout.Rigid(func(gtx C) D { - return layout.E.Layout(gtx, btnStyle.Layout) + inset := layout.UniformInset(unit.Dp(6)) + return inset.Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Rigid(Label("OCT:", white)), + layout.Rigid(octave), + ) + }) + }), + layout.Rigid(func(gtx C) D { + return layout.E.Layout(gtx, fullscreenBtnStyle.Layout) + }), + layout.Rigid(func(gtx C) D { + return layout.E.Layout(gtx, newBtnStyle.Layout) }), ) }), @@ -205,10 +241,12 @@ func (ie *InstrumentEditor) layoutInstrumentHeader(gtx C, t *Tracker) D { t.Alert.Update("Instrument copied to clipboard", Notify, time.Second*3) } } - for ie.deleteInstrumentBtn.Clicked() && t.ModalDialog == nil { - dialogStyle := ConfirmDialog(t.Theme, ie.confirmInstrDelete, "Are you sure you want to delete this instrument?") - ie.confirmInstrDelete.Visible = true - t.ModalDialog = dialogStyle.Layout + for ie.deleteInstrumentBtn.Clicked() { + if t.CanDeleteInstrument() { + dialogStyle := ConfirmDialog(t.Theme, ie.confirmInstrDelete, "Are you sure you want to delete this instrument?") + ie.confirmInstrDelete.Visible = true + t.ModalDialog = dialogStyle.Layout + } } for ie.confirmInstrDelete.BtnOk.Clicked() { t.DeleteInstrument(false) @@ -236,14 +274,10 @@ func (ie *InstrumentEditor) layoutInstrumentNames(gtx C, t *Tracker) D { grabhandle.Text = ":::" } label := func(gtx C) D { - c := 0.0 + c := float32(0.0) voice := t.Song().Patch.FirstVoiceForInstrument(i) for j := 0; j < t.Song().Patch[i].NumVoices; j++ { - released, event := t.player.VoiceState(voice) - vc := math.Exp(-float64(event)/15000) * .5 - if !released { - vc += .5 - } + vc := ie.voiceStates[voice] if c < vc { c = vc } diff --git a/tracker/gioui/keyevent.go b/tracker/gioui/keyevent.go index a934cc1..6526938 100644 --- a/tracker/gioui/keyevent.go +++ b/tracker/gioui/keyevent.go @@ -3,6 +3,7 @@ package gioui import ( "time" + "gioui.org/app" "gioui.org/io/key" "github.com/vsariola/sointu/tracker" "gopkg.in/yaml.v3" @@ -44,7 +45,7 @@ var noteMap = map[string]int{ } // KeyEvent handles incoming key events and returns true if repaint is needed. -func (t *Tracker) KeyEvent(e key.Event) bool { +func (t *Tracker) KeyEvent(e key.Event, window *app.Window) bool { if e.State == key.Press { if t.OpenSongDialog.Visible || t.SaveSongDialog.Visible || @@ -58,14 +59,14 @@ func (t *Tracker) KeyEvent(e key.Event) bool { if e.Modifiers.Contain(key.ModShortcut) { contents, err := yaml.Marshal(t.Song()) if err == nil { - t.window.WriteClipboard(string(contents)) + window.WriteClipboard(string(contents)) t.Alert.Update("Song copied to clipboard", Notify, time.Second*3) } return true } case "V": if e.Modifiers.Contain(key.ModShortcut) { - t.window.ReadClipboard() + window.ReadClipboard() return true } case "Z": @@ -111,7 +112,7 @@ func (t *Tracker) KeyEvent(e key.Event) bool { if t.OrderEditor.Focused() { startRow.Row = 0 } - t.player.Play(startRow) + t.PlayFromPosition(startRow) return true case "F6": t.SetNoteTracking(false) @@ -119,19 +120,18 @@ func (t *Tracker) KeyEvent(e key.Event) bool { if t.OrderEditor.Focused() { startRow.Row = 0 } - t.player.Play(startRow) + t.PlayFromPosition(startRow) return true case "F8": - t.player.Stop() + t.SetPlaying(false) return true case "Space": - _, playing := t.player.Position() - if !playing { + if !t.Playing() && !t.InstrEnlarged() { t.SetNoteTracking(!e.Modifiers.Contain(key.ModShortcut)) startRow := t.Cursor().SongRow - t.player.Play(startRow) + t.PlayFromPosition(startRow) } else { - t.player.Stop() + t.SetPlaying(false) } case `\`, `<`, `>`: if e.Modifiers.Contain(key.ModShift) { @@ -147,7 +147,11 @@ func (t *Tracker) KeyEvent(e key.Event) bool { case t.TrackEditor.Focused(): t.OrderEditor.Focus() case t.InstrumentEditor.Focused(): - t.TrackEditor.Focus() + if t.InstrEnlarged() { + t.InstrumentEditor.paramEditor.Focus() + } else { + t.TrackEditor.Focus() + } default: t.InstrumentEditor.Focus() } @@ -160,7 +164,11 @@ func (t *Tracker) KeyEvent(e key.Event) bool { case t.InstrumentEditor.Focused(): t.InstrumentEditor.paramEditor.Focus() default: - t.OrderEditor.Focus() + if t.InstrEnlarged() { + t.InstrumentEditor.Focus() + } else { + t.OrderEditor.Focus() + } } } } @@ -188,18 +196,18 @@ func (t *Tracker) JammingPressed(e key.Event) { if _, ok := t.KeyPlaying[e.Name]; !ok { n := tracker.NoteAsValue(t.OctaveNumberInput.Value, val) instr := t.InstrIndex() - start := t.Song().Patch.FirstVoiceForInstrument(instr) - end := start + t.Instrument().NumVoices - t.KeyPlaying[e.Name] = t.player.Trigger(start, end, n) + noteID := tracker.NoteIDInstr(instr, n) + t.NoteOn(noteID) + t.KeyPlaying[e.Name] = noteID } } } func (t *Tracker) JammingReleased(e key.Event) { - if ID, ok := t.KeyPlaying[e.Name]; ok { - t.player.Release(ID) + if noteID, ok := t.KeyPlaying[e.Name]; ok { + t.NoteOff(noteID) delete(t.KeyPlaying, e.Name) - if _, playing := t.player.Position(); t.TrackEditor.focused && playing && t.Note() == 1 && t.NoteTracking() { + if t.TrackEditor.focused && t.Playing() && t.Note() == 1 && t.NoteTracking() { t.SetNote(0) } } diff --git a/tracker/gioui/layout.go b/tracker/gioui/layout.go index 235b272..a97bf57 100644 --- a/tracker/gioui/layout.go +++ b/tracker/gioui/layout.go @@ -4,7 +4,6 @@ import ( "fmt" "image" - "gioui.org/app" "gioui.org/layout" "gioui.org/op/clip" "gioui.org/op/paint" @@ -15,9 +14,13 @@ type D = layout.Dimensions func (t *Tracker) Layout(gtx layout.Context) { paint.FillShape(gtx.Ops, backgroundColor, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op()) - t.VerticalSplit.Layout(gtx, - t.layoutTop, - t.layoutBottom) + if t.InstrEnlarged() { + t.layoutTop(gtx) + } else { + t.VerticalSplit.Layout(gtx, + t.layoutTop, + t.layoutBottom) + } t.Alert.Layout(gtx) dstyle := ConfirmDialog(t.Theme, t.ConfirmSongDialog, "Do you want to save your changes to the song? Your changes will be lost if you don't save them.") dstyle.ShowAlt = true @@ -114,7 +117,6 @@ func (t *Tracker) NewSong(forced bool) { } t.ResetSong() t.SetFilePath("") - t.window.Option(app.Title("Sointu Tracker")) t.ClearUndoHistory() t.SetChangedSinceSave(false) } diff --git a/tracker/gioui/ordereditor.go b/tracker/gioui/ordereditor.go index b0acf16..cac8062 100644 --- a/tracker/gioui/ordereditor.go +++ b/tracker/gioui/ordereditor.go @@ -73,20 +73,19 @@ func (oe *OrderEditor) doLayout(gtx C, t *Tracker) D { t.DeleteOrderRow(e.Name == key.NameDeleteForward) } else { t.DeletePatternSelection() - if !(t.NoteTracking() && t.player.Playing()) && t.Step.Value > 0 { + if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 { t.SetCursor(t.Cursor().AddPatterns(1)) t.SetSelectionCorner(t.Cursor()) } } case "Space": - _, playing := t.player.Position() - if !playing { + if !t.Playing() { t.SetNoteTracking(!e.Modifiers.Contain(key.ModShortcut)) startRow := t.Cursor().SongRow startRow.Row = 0 - t.player.Play(startRow) + t.PlayFromPosition(startRow) } else { - t.player.Stop() + t.SetPlaying(false) } case key.NameReturn: t.AddOrderRow(!e.Modifiers.Contain(key.ModShortcut)) @@ -143,14 +142,14 @@ func (oe *OrderEditor) doLayout(gtx C, t *Tracker) D { } if iv, err := strconv.Atoi(e.Name); err == nil { t.SetCurrentPattern(iv) - if !(t.NoteTracking() && t.player.Playing()) && t.Step.Value > 0 { + if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 { t.SetCursor(t.Cursor().AddPatterns(1)) t.SetSelectionCorner(t.Cursor()) } } if b := int(e.Name[0]) - 'A'; len(e.Name) == 1 && b >= 0 && b < 26 { t.SetCurrentPattern(b + 10) - if !(t.NoteTracking() && t.player.Playing()) && t.Step.Value > 0 { + if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 { t.SetCursor(t.Cursor().AddPatterns(1)) t.SetSelectionCorner(t.Cursor()) } @@ -202,7 +201,7 @@ func (oe *OrderEditor) doLayout(gtx C, t *Tracker) D { gtx.Constraints.Max.Y -= patternCellHeight gtx.Constraints.Min.Y -= patternCellHeight element := func(gtx C, j int) D { - if playPos, ok := t.player.Position(); ok && j == playPos.Pattern { + if playPos := t.PlayPosition(); t.Playing() && j == playPos.Pattern { paint.FillShape(gtx.Ops, patternPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, patternCellHeight)}.Op()) } paint.ColorOp{Color: rowMarkerPatternTextColor}.Add(gtx.Ops) diff --git a/tracker/gioui/rowmarkers.go b/tracker/gioui/rowmarkers.go index 4901d95..765fc8e 100644 --- a/tracker/gioui/rowmarkers.go +++ b/tracker/gioui/rowmarkers.go @@ -24,7 +24,7 @@ func (t *Tracker) layoutRowMarkers(gtx C) D { clip.Rect{Max: gtx.Constraints.Max}.Add(gtx.Ops) op.Offset(f32.Pt(0, float32(gtx.Constraints.Max.Y-trackRowHeight)/2)).Add(gtx.Ops) cursorSongRow := t.Cursor().Pattern*t.Song().Score.RowsPerPattern + t.Cursor().Row - playPos, playing := t.player.Position() + playPos := t.PlayPosition() playSongRow := playPos.Pattern*t.Song().Score.RowsPerPattern + playPos.Row op.Offset(f32.Pt(0, (-1*trackRowHeight)*float32(cursorSongRow))).Add(gtx.Ops) beatMarkerDensity := t.Song().RowsPerBeat @@ -39,7 +39,7 @@ func (t *Tracker) layoutRowMarkers(gtx C) D { } else if mod(songRow, beatMarkerDensity) == 0 { paint.FillShape(gtx.Ops, oneBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, trackRowHeight)}.Op()) } - if playing && songRow == playSongRow { + if t.Playing() && songRow == playSongRow { paint.FillShape(gtx.Ops, trackerPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, trackRowHeight)}.Op()) } if j == 0 { diff --git a/tracker/gioui/run.go b/tracker/gioui/run.go deleted file mode 100644 index 232f5f1..0000000 --- a/tracker/gioui/run.go +++ /dev/null @@ -1,82 +0,0 @@ -package gioui - -import ( - "fmt" - "os" - "time" - - "gioui.org/app" - "gioui.org/io/clipboard" - "gioui.org/io/key" - "gioui.org/io/system" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/unit" - "github.com/vsariola/sointu" -) - -func (t *Tracker) Run(w *app.Window) error { - var ops op.Ops - for { - if pos, playing := t.player.Position(); t.NoteTracking() && playing { - cursor := t.Cursor() - cursor.SongRow = pos - t.SetCursor(cursor) - t.SetSelectionCorner(cursor) - } - select { - case <-t.refresh: - w.Invalidate() - case v := <-t.volumeChan: - t.lastVolume = v - w.Invalidate() - case e := <-t.errorChannel: - t.Alert.Update(e.Error(), Error, time.Second*5) - w.Invalidate() - case e := <-w.Events(): - switch e := e.(type) { - case system.DestroyEvent: - if !t.Quit(false) { - // TODO: uh oh, there's no way of canceling the destroyevent in gioui? so we create a new window just to show the dialog - w = app.NewWindow( - app.Size(unit.Dp(800), unit.Dp(600)), - app.Title("Sointu Tracker"), - ) - } - case key.Event: - if t.KeyEvent(e) { - w.Invalidate() - } - case clipboard.Event: - err := t.UnmarshalContent([]byte(e.Text)) - if err == nil { - w.Invalidate() - } - case system.FrameEvent: - gtx := layout.NewContext(&ops, e) - t.Layout(gtx) - e.Frame(gtx.Ops) - } - } - if t.quitted { - return nil - } - } -} - -func Main(audioContext sointu.AudioContext, synthService sointu.SynthService, syncChannel chan<- []float32) { - go func() { - w := app.NewWindow( - app.Size(unit.Dp(800), unit.Dp(600)), - app.Title("Sointu Tracker"), - ) - t := New(audioContext, synthService, syncChannel, w) - defer t.Close() - if err := t.Run(w); err != nil { - fmt.Println(err) - os.Exit(1) - } - os.Exit(0) - }() - app.Main() -} diff --git a/tracker/gioui/songpanel.go b/tracker/gioui/songpanel.go index 3820bb7..ca69b0c 100644 --- a/tracker/gioui/songpanel.go +++ b/tracker/gioui/songpanel.go @@ -3,7 +3,6 @@ package gioui import ( "image" "math" - "runtime" "time" "gioui.org/f32" @@ -19,6 +18,22 @@ import ( "gopkg.in/yaml.v3" ) +const shortcutKey = "Ctrl+" + +var fileMenuItems []MenuItem = []MenuItem{ + {IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: shortcutKey + "N"}, + {IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O"}, + {IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S"}, + {IconBytes: icons.ContentSave, Text: "Save Song As..."}, + {IconBytes: icons.ImageAudiotrack, Text: "Export Wav..."}, +} + +func init() { + if CAN_QUIT { + fileMenuItems = append(fileMenuItems, MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit"}) + } +} + func (t *Tracker) layoutSongPanel(gtx C) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(t.layoutMenuBar), @@ -87,18 +102,9 @@ func (t *Tracker) layoutMenuBar(gtx C) D { clickedItem, hasClicked = t.Menus[1].Clicked() } - shortcutKey := "Ctrl+" - if runtime.GOOS == "darwin" { - shortcutKey = "Cmd+" - } return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Rigid(t.layoutMenu("File", &t.MenuBar[0], &t.Menus[0], unit.Dp(200), - MenuItem{IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: shortcutKey + "N"}, - MenuItem{IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O"}, - MenuItem{IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S"}, - MenuItem{IconBytes: icons.ContentSave, Text: "Save Song As..."}, - MenuItem{IconBytes: icons.ImageAudiotrack, Text: "Export Wav..."}, - MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit"}, + fileMenuItems..., )), layout.Rigid(t.layoutMenu("Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(200), MenuItem{IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: shortcutKey + "Z", Disabled: !t.CanUndo()}, @@ -116,14 +122,25 @@ func (t *Tracker) layoutSongOptions(gtx C) D { in := layout.UniformInset(unit.Dp(1)) var panicBtnStyle material.ButtonStyle - if t.player.Enabled() { + if !t.Panic() { panicBtnStyle = LowEmphasisButton(t.Theme, t.PanicBtn, "Panic") } else { panicBtnStyle = HighEmphasisButton(t.Theme, t.PanicBtn, "Panic") } for t.PanicBtn.Clicked() { - t.player.Disable() + t.SetPanic(!t.Panic()) + } + + var recordBtnStyle material.ButtonStyle + if !t.Recording() { + recordBtnStyle = LowEmphasisButton(t.Theme, t.RecordBtn, "Record") + } else { + recordBtnStyle = HighEmphasisButton(t.Theme, t.RecordBtn, "Record") + } + + for t.RecordBtn.Clicked() { + t.SetRecording(!t.Recording()) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -200,6 +217,10 @@ func (t *Tracker) layoutSongOptions(gtx C) D { gtx.Constraints.Min = image.Pt(0, 0) return panicBtnStyle.Layout(gtx) }), + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min = image.Pt(0, 0) + return recordBtnStyle.Layout(gtx) + }), layout.Rigid(VuMeter{Volume: t.lastVolume, Range: 100}.Layout), ) } diff --git a/tracker/gioui/trackeditor.go b/tracker/gioui/trackeditor.go index 71e1498..fb692e3 100644 --- a/tracker/gioui/trackeditor.go +++ b/tracker/gioui/trackeditor.go @@ -76,7 +76,7 @@ func (te *TrackEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions switch e.Name { case key.NameDeleteForward, key.NameDeleteBackward: t.DeleteSelection() - if !(t.NoteTracking() && t.player.Playing()) && t.Step.Value > 0 { + if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 { t.SetCursor(t.Cursor().AddRows(t.Step.Value)) t.SetSelectionCorner(t.Cursor()) } @@ -159,14 +159,14 @@ func (te *TrackEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions t.SetNote(n) step = true trk := t.Cursor().Track - start := t.Song().Score.FirstVoiceForTrack(trk) - end := start + t.Song().Score.Tracks[trk].NumVoices - t.KeyPlaying[e.Name] = t.player.Trigger(start, end, n) + noteID := tracker.NoteIDTrack(trk, n) + t.NoteOn(noteID) + t.KeyPlaying[e.Name] = noteID } } } } - if step && !(t.NoteTracking() && t.player.Playing()) && t.Step.Value > 0 { + if step && !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 { t.SetCursor(t.Cursor().AddRows(t.Step.Value)) t.SetSelectionCorner(t.Cursor()) } @@ -208,7 +208,7 @@ func (te *TrackEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions for te.NoteOffBtn.Clicked() { t.SetNote(0) - if !(t.NoteTracking() && t.player.Playing()) && t.Step.Value > 0 { + if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 { t.SetCursor(t.Cursor().AddRows(t.Step.Value)) t.SetSelectionCorner(t.Cursor()) } @@ -230,18 +230,9 @@ func (te *TrackEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions noteOffBtnStyle := LowEmphasisButton(t.Theme, te.NoteOffBtn, "Note Off") deleteTrackBtnStyle := IconButton(t.Theme, te.DeleteTrackBtn, icons.ActionDelete, t.CanDeleteTrack()) newTrackBtnStyle := IconButton(t.Theme, te.NewTrackBtn, icons.ContentAdd, t.CanAddTrack()) - in := layout.UniformInset(unit.Dp(1)) - octave := func(gtx C) D { - t.OctaveNumberInput.Value = t.Octave() - numStyle := NumericUpDown(t.Theme, t.OctaveNumberInput, 0, 9) - gtx.Constraints.Min.Y = gtx.Px(unit.Dp(20)) - gtx.Constraints.Min.X = gtx.Px(unit.Dp(70)) - dims := in.Layout(gtx, numStyle.Layout) - t.SetOctave(t.OctaveNumberInput.Value) - return dims - } n := t.Song().Score.Tracks[t.Cursor().Track].NumVoices te.TrackVoices.Value = n + in := layout.UniformInset(unit.Dp(1)) voiceUpDown := func(gtx C) D { numStyle := NumericUpDown(t.Theme, te.TrackVoices, 1, t.MaxTrackVoices()) gtx.Constraints.Min.Y = gtx.Px(unit.Dp(20)) @@ -251,8 +242,6 @@ func (te *TrackEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions t.TrackHexCheckBox.Value = t.Song().Score.Tracks[t.Cursor().Track].Effect hexCheckBoxStyle := material.CheckBox(t.Theme, t.TrackHexCheckBox, "Hex") dims := layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(Label("OCT:", white)), - layout.Rigid(octave), layout.Rigid(func(gtx C) D { return layout.Dimensions{Size: image.Pt(gtx.Px(unit.Dp(12)), 0)} }), layout.Rigid(addSemitoneBtnStyle.Layout), layout.Rigid(subtractSemitoneBtnStyle.Layout), diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index c02e3b1..8453da0 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -4,10 +4,16 @@ import ( "encoding/json" "errors" "fmt" + "time" "gioui.org/app" "gioui.org/font/gofont" + "gioui.org/io/clipboard" + "gioui.org/io/key" + "gioui.org/io/system" "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "github.com/vsariola/sointu" @@ -33,12 +39,13 @@ type Tracker struct { InstrumentVoices *NumberInput SongLength *NumberInput PanicBtn *widget.Clickable + RecordBtn *widget.Clickable AddUnitBtn *widget.Clickable TrackHexCheckBox *widget.Bool TopHorizontalSplit *Split BottomHorizontalSplit *Split VerticalSplit *Split - KeyPlaying map[string]uint32 + KeyPlaying map[string]tracker.NoteID Alert Alert ConfirmSongDialog *Dialog WaveTypeDialog *Dialog @@ -48,22 +55,17 @@ type Tracker struct { SaveInstrumentDialog *FileDialog ExportWavDialog *FileDialog ConfirmSongActionType int - window *app.Window ModalDialog layout.Widget InstrumentEditor *InstrumentEditor OrderEditor *OrderEditor TrackEditor *TrackEditor lastVolume tracker.Volume - volumeChan chan tracker.Volume wavFilePath string - player *tracker.Player refresh chan struct{} - playerCloser chan struct{} errorChannel chan error quitted bool - audioContext sointu.AudioContext synthService sointu.SynthService *tracker.Model @@ -94,15 +96,9 @@ func (t *Tracker) UnmarshalContent(bytes []byte) error { return errors.New("was able to unmarshal a song, but the bpm was 0") } -func (t *Tracker) Close() { - t.playerCloser <- struct{}{} - t.audioContext.Close() -} - -func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syncChannel chan<- []float32, window *app.Window) *Tracker { +func NewTracker(model *tracker.Model, synthService sointu.SynthService) *Tracker { t := &Tracker{ Theme: material.NewTheme(gofont.Collection()), - audioContext: audioContext, BPM: new(NumberInput), OctaveNumberInput: &NumberInput{Value: 4}, SongLength: new(NumberInput), @@ -112,6 +108,7 @@ func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syn InstrumentVoices: new(NumberInput), PanicBtn: new(widget.Clickable), + RecordBtn: new(widget.Clickable), TrackHexCheckBox: new(widget.Bool), Menus: make([]Menu, 2), MenuBar: make([]widget.Clickable, 2), @@ -121,9 +118,7 @@ func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syn BottomHorizontalSplit: &Split{Ratio: -.6}, VerticalSplit: &Split{Axis: layout.Vertical}, - KeyPlaying: make(map[string]uint32), - volumeChan: make(chan tracker.Volume, 1), - playerCloser: make(chan struct{}), + KeyPlaying: make(map[string]tracker.NoteID), ConfirmSongDialog: new(Dialog), WaveTypeDialog: new(Dialog), OpenSongDialog: NewFileDialog(), @@ -136,40 +131,85 @@ func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syn ExportWavDialog: NewFileDialog(), errorChannel: make(chan error, 32), - window: window, synthService: synthService, + Model: model, } - t.Model = tracker.NewModel() - vuBufferObserver := make(chan []float32) - go tracker.VuAnalyzer(0.3, 1e-4, 1, -100, 20, vuBufferObserver, t.volumeChan, t.errorChannel) t.Theme.Palette.Fg = primaryColor t.Theme.Palette.ContrastFg = black t.TrackEditor.Focus() t.SetOctave(4) - patchObserver := make(chan sointu.Patch, 16) - t.AddPatchObserver(patchObserver) - scoreObserver := make(chan sointu.Score, 16) - t.AddScoreObserver(scoreObserver) - sprObserver := make(chan int, 16) - t.AddSamplesPerRowObserver(sprObserver) - audioChannel := make(chan []float32) - t.player = tracker.NewPlayer(synthService, t.playerCloser, patchObserver, scoreObserver, sprObserver, t.refresh, syncChannel, audioChannel, vuBufferObserver) - audioOut := audioContext.Output() - go func() { - for buf := range audioChannel { - audioOut.WriteAudio(buf) - } - }() t.ResetSong() return t } -func (t *Tracker) Quit(forced bool) bool { - if !forced && t.ChangedSinceSave() { - t.ConfirmSongActionType = ConfirmQuit - t.ConfirmSongDialog.Visible = true - return false +func (t *Tracker) Main() { + titleFooter := "" + w := app.NewWindow( + app.Size(unit.Dp(800), unit.Dp(600)), + app.Title("Sointu Tracker"), + ) + var ops op.Ops +mainloop: + for { + if pos, playing := t.PlayPosition(), t.Playing(); t.NoteTracking() && playing { + cursor := t.Cursor() + cursor.SongRow = pos + t.SetCursor(cursor) + t.SetSelectionCorner(cursor) + } + if titleFooter != t.FilePath() { + titleFooter = t.FilePath() + if titleFooter != "" { + w.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", titleFooter))) + } else { + w.Option(app.Title(fmt.Sprintf("Sointu Tracker"))) + } + } + select { + case <-t.refresh: + w.Invalidate() + case e := <-t.errorChannel: + t.Alert.Update(e.Error(), Error, time.Second*5) + w.Invalidate() + case e := <-t.PlayerMessages: + if err, ok := e.Inner.(tracker.PlayerCrashMessage); ok { + t.Alert.Update(err.Error(), Error, time.Second*3) + } + if err, ok := e.Inner.(tracker.PlayerVolumeErrorMessage); ok { + t.Alert.Update(err.Error(), Warning, time.Second*3) + } + t.lastVolume = e.Volume + t.InstrumentEditor.voiceStates = e.VoiceStates + t.ProcessPlayerMessage(e) + w.Invalidate() + case e := <-w.Events(): + switch e := e.(type) { + case system.DestroyEvent: + if !t.Quit(false) { + // TODO: uh oh, there's no way of canceling the destroyevent in gioui? so we create a new window just to show the dialog + w = app.NewWindow( + app.Size(unit.Dp(800), unit.Dp(600)), + app.Title("Sointu Tracker"), + ) + } + case key.Event: + if t.KeyEvent(e, w) { + w.Invalidate() + } + case clipboard.Event: + err := t.UnmarshalContent([]byte(e.Text)) + if err == nil { + w.Invalidate() + } + case system.FrameEvent: + gtx := layout.NewContext(&ops, e) + t.Layout(gtx) + e.Frame(gtx.Ops) + } + } + if t.quitted { + break mainloop + } } - t.quitted = true - return true + w.Close() } diff --git a/tracker/gioui/tracker_not_plugin.go b/tracker/gioui/tracker_not_plugin.go new file mode 100644 index 0000000..c3cd670 --- /dev/null +++ b/tracker/gioui/tracker_not_plugin.go @@ -0,0 +1,15 @@ +//go:build !plugin + +package gioui + +const CAN_QUIT = true + +func (t *Tracker) Quit(forced bool) bool { + if !forced && t.ChangedSinceSave() { + t.ConfirmSongActionType = ConfirmQuit + t.ConfirmSongDialog.Visible = true + return false + } + t.quitted = true + return true +} diff --git a/tracker/gioui/tracker_plugin.go b/tracker/gioui/tracker_plugin.go new file mode 100644 index 0000000..3b71828 --- /dev/null +++ b/tracker/gioui/tracker_plugin.go @@ -0,0 +1,10 @@ +//go:build plugin + +package gioui + +const CAN_QUIT = false + +func (t *Tracker) Quit(forced bool) bool { + t.quitted = forced + return forced +} diff --git a/tracker/model.go b/tracker/model.go index 9402e60..98150f5 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -16,32 +16,75 @@ import ( // Go does not have immutable slices, so there's no efficient way to guarantee // accidental mutations in the song. But at least the value members are // protected. -type Model struct { - song sointu.Song - selectionCorner SongPoint - cursor SongPoint - lowNibble bool - instrIndex int - unitIndex int - paramIndex int - octave int - noteTracking bool - usedIDs map[int]bool - maxID int - filePath string - changedSinceSave bool - patternUseCount [][]int +// It is owned by the GUI thread (goroutine), while the player is owned by +// by the audioprocessing thread. They communicate using the two channels +type ( + Model struct { + song sointu.Song + selectionCorner SongPoint + cursor SongPoint + lowNibble bool + instrIndex int + unitIndex int + paramIndex int + octave int + noteTracking bool + usedIDs map[int]bool + maxID int + filePath string + changedSinceSave bool + patternUseCount [][]int + panic bool + playing bool + recording bool + playPosition SongRow + instrEnlarged bool - prevUndoType string - undoSkipCounter int - undoStack []sointu.Song - redoStack []sointu.Song + prevUndoType string + undoSkipCounter int + undoStack []sointu.Song + redoStack []sointu.Song - samplesPerRowObservers []chan<- int - patchObservers []chan<- sointu.Patch - scoreObservers []chan<- sointu.Score - playingObservers []chan<- bool -} + PlayerMessages <-chan PlayerMessage + modelMessages chan<- interface{} + } + + ModelPatchChangedMessage struct { + sointu.Patch + } + + ModelScoreChangedMessage struct { + sointu.Score + } + + ModelPlayingChangedMessage struct { + bool + } + + ModelPlayFromPositionMessage struct { + SongRow + } + + ModelSamplesPerRowChangedMessage struct { + int + } + + ModelPanicMessage struct { + bool + } + + ModelRecordingMessage struct { + bool + } + + ModelNoteOnMessage struct { + id NoteID + } + + ModelNoteOffMessage struct { + id NoteID + } +) type Parameter struct { Type ParameterType @@ -63,8 +106,10 @@ const ( const maxUndo = 256 -func NewModel() *Model { +func NewModel(modelMessages chan<- interface{}, playerMessages <-chan PlayerMessage) *Model { ret := new(Model) + ret.modelMessages = modelMessages + ret.PlayerMessages = playerMessages ret.setSongNoUndo(defaultSong.Copy()) return ret } @@ -110,6 +155,19 @@ func (m *Model) SetOctave(value int) bool { return true } +func (m *Model) ProcessPlayerMessage(msg PlayerMessage) { + m.playPosition = msg.SongRow + switch e := msg.Inner.(type) { + case PlayerCrashMessage: + m.panic = true + case PlayerRecordedMessage: + song := RecordingToSong(m.song.Patch, m.song.RowsPerBeat, m.song.Score.RowsPerPattern, e) + m.SetSong(song) + m.instrEnlarged = false + default: + } +} + func (m *Model) SetInstrument(instrument sointu.Instrument) bool { if len(instrument.Units) == 0 { return false @@ -300,6 +358,29 @@ func (m *Model) AddInstrument(after bool) { m.notifyPatchChange() } +func (m *Model) NoteOn(id NoteID) { + m.modelMessages <- ModelNoteOnMessage{id} +} + +func (m *Model) NoteOff(id NoteID) { + m.modelMessages <- ModelNoteOffMessage{id} +} + +func (m *Model) Playing() bool { + return m.playing +} + +func (m *Model) SetPlaying(val bool) { + if m.playing != val { + m.playing = val + m.modelMessages <- ModelPlayingChangedMessage{val} + } +} + +func (m *Model) PlayPosition() SongRow { + return m.playPosition +} + func (m *Model) CanAddInstrument() bool { return m.song.Patch.NumVoices() < 32 } @@ -452,6 +533,42 @@ func (m *Model) AdjustPatternNumber(delta int, swap bool) { m.notifyScoreChange() } +func (m *Model) SetRecording(val bool) { + if m.recording != val { + m.recording = val + m.instrEnlarged = val + m.modelMessages <- ModelRecordingMessage{val} + } +} + +func (m *Model) Recording() bool { + return m.recording +} + +func (m *Model) SetPanic(val bool) { + if m.panic != val { + m.panic = val + m.modelMessages <- ModelPanicMessage{val} + } +} + +func (m *Model) Panic() bool { + return m.panic +} + +func (m *Model) SetInstrEnlarged(val bool) { + m.instrEnlarged = val +} + +func (m *Model) InstrEnlarged() bool { + return m.instrEnlarged +} + +func (m *Model) PlayFromPosition(sr SongRow) { + m.playing = true + m.modelMessages <- ModelPlayFromPositionMessage{sr} +} + func (m *Model) SetCurrentPattern(pat int) { m.saveUndo("SetCurrentPattern", 0) m.song.Score.Tracks[m.cursor.Track].Order.Set(m.cursor.Pattern, pat) @@ -1085,22 +1202,6 @@ func (m *Model) SetParam(value int) { m.notifyPatchChange() } -func (m *Model) AddPatchObserver(observer chan<- sointu.Patch) { - m.patchObservers = append(m.patchObservers, observer) -} - -func (m *Model) AddScoreObserver(observer chan<- sointu.Score) { - m.scoreObservers = append(m.scoreObservers, observer) -} - -func (m *Model) AddSamplesPerRowObserver(observer chan<- int) { - m.samplesPerRowObservers = append(m.samplesPerRowObservers, observer) -} - -func (m *Model) AddPlayingObserver(observer chan<- bool) { - m.playingObservers = append(m.playingObservers, observer) -} - func (m *Model) setSongNoUndo(song sointu.Song) { m.song = song m.usedIDs = make(map[int]bool) @@ -1123,20 +1224,24 @@ func (m *Model) setSongNoUndo(song sointu.Song) { } func (m *Model) notifyPatchChange() { - for _, channel := range m.patchObservers { - channel <- m.song.Patch.Copy() + m.panic = false + select { + case m.modelMessages <- ModelPatchChangedMessage{m.song.Patch.Copy()}: + default: } } func (m *Model) notifyScoreChange() { - for _, channel := range m.scoreObservers { - channel <- m.song.Score.Copy() + select { + case m.modelMessages <- ModelScoreChangedMessage{m.song.Score.Copy()}: + default: } } func (m *Model) notifySamplesPerRowChange() { - for _, channel := range m.samplesPerRowObservers { - channel <- m.song.SamplesPerRow() + select { + case m.modelMessages <- ModelSamplesPerRowChangedMessage{m.song.SamplesPerRow()}: + default: } } diff --git a/tracker/note_id.go b/tracker/note_id.go new file mode 100644 index 0000000..68b568d --- /dev/null +++ b/tracker/note_id.go @@ -0,0 +1,19 @@ +package tracker + +// Describes a note triggered either a track or an instrument +// If Go had union or Either types, this would be it, but in absence +// those, this uses a boolean to define if the instrument is defined or the track +type NoteID struct { + IsInstr bool + Instr int + Track int + Note byte +} + +func NoteIDInstr(instr int, note byte) NoteID { + return NoteID{IsInstr: true, Instr: instr, Note: note} +} + +func NoteIDTrack(track int, note byte) NoteID { + return NoteID{IsInstr: false, Track: track, Note: note} +} diff --git a/tracker/player.go b/tracker/player.go index 2bcfe02..e1b9095 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -1,268 +1,364 @@ package tracker import ( + "fmt" "math" - "sync" - "sync/atomic" "github.com/vsariola/sointu" + "github.com/vsariola/sointu/vm" ) -type Player struct { - packedPos uint64 +type ( + Player struct { + voiceNoteID []int + voiceReleased []bool + synth sointu.Synth + patch sointu.Patch + score sointu.Score + playing bool + rowtime int + position SongRow + samplesSinceEvent []int + samplesPerRow int + volume Volume + voiceStates [vm.MAX_VOICES]float32 - playCmds chan uint64 + recording bool + recordingNoteArrived bool + recordingFrames int + recordingEvents []PlayerProcessEvent - mutex sync.Mutex - runningID uint32 - voiceNoteID []uint32 - voiceReleased []int32 - synth sointu.Synth - patch sointu.Patch - samplesSinceEvent []int32 - - synthNotNil int32 -} - -type voiceNote struct { - voice int - note byte -} - -// Position returns the current play position (song row), and a bool indicating -// if the player is currently playing. The function is threadsafe. -func (p *Player) Position() (SongRow, bool) { - packedPos := atomic.LoadUint64(&p.packedPos) - if packedPos == math.MaxUint64 { // stopped - return SongRow{}, false + synthService sointu.SynthService + playerMessages chan<- PlayerMessage + modelMessages <-chan interface{} } - return unpackPosition(packedPos), true -} -func (p *Player) Playing() bool { - packedPos := atomic.LoadUint64(&p.packedPos) - if packedPos == math.MaxUint64 { // stopped - return false + PlayerProcessContext interface { + NextEvent() (event PlayerProcessEvent, ok bool) + BPM() (bpm float64, ok bool) } - return true -} -func (p *Player) Play(position SongRow) { - position.Row-- // we'll advance this very shortly - p.playCmds <- packPosition(position) -} - -func (p *Player) Stop() { - p.playCmds <- math.MaxUint64 -} - -func (p *Player) Disable() { - p.mutex.Lock() - p.synth = nil - atomic.StoreInt32(&p.synthNotNil, 0) - p.mutex.Unlock() -} - -func (p *Player) Enabled() bool { - return atomic.LoadInt32(&p.synthNotNil) == 1 -} - -func (p *Player) VoiceState(voice int) (bool, int) { - if voice >= len(p.samplesSinceEvent) || voice >= len(p.voiceReleased) { - return true, math.MaxInt32 + PlayerProcessEvent struct { + Frame int + On bool + Channel int + Note byte } - return atomic.LoadInt32(&p.voiceReleased[voice]) == 1, int(atomic.LoadInt32(&p.samplesSinceEvent[voice])) -} -func NewPlayer(service sointu.SynthService, closer <-chan struct{}, patchs <-chan sointu.Patch, scores <-chan sointu.Score, samplesPerRows <-chan int, posChanged chan<- struct{}, syncOutput chan<- []float32, outputs ...chan<- []float32) *Player { - p := &Player{playCmds: make(chan uint64, 16)} - go func() { - var score sointu.Score - buffer := make([]float32, 2048) - buffer2 := make([]float32, 2048) - zeros := make([]float32, 2048) - totalSyncs := 1 // just the beat - syncBuffer := make([]float32, (2048+255)/256*totalSyncs) - syncBuffer2 := make([]float32, (2048+255)/256*totalSyncs) - rowTime := 0 - samplesPerRow := math.MaxInt32 - var trackIDs []uint32 - atomic.StoreUint64(&p.packedPos, math.MaxUint64) - for { - select { - case <-closer: - for _, o := range outputs { - close(o) - } - return - case patch := <-patchs: - p.mutex.Lock() - p.patch = patch - if p.synth != nil { - err := p.synth.Update(patch) - if err != nil { - p.synth = nil - atomic.StoreInt32(&p.synthNotNil, 0) - } - } else { - s, err := service.Compile(patch) - if err == nil { - p.synth = s - atomic.StoreInt32(&p.synthNotNil, 1) - for i := 0; i < 32; i++ { - s.Release(i) - } - } - } - totalSyncs = 1 + p.patch.NumSyncs() - syncBuffer = make([]float32, ((2048+255)/256)*totalSyncs) - syncBuffer2 = make([]float32, ((2048+255)/256)*totalSyncs) - p.mutex.Unlock() - case score = <-scores: - if row, playing := p.Position(); playing { - atomic.StoreUint64(&p.packedPos, packPosition(row.Wrap(score))) - } - case samplesPerRow = <-samplesPerRows: - case packedPos := <-p.playCmds: - atomic.StoreUint64(&p.packedPos, packedPos) - if packedPos == math.MaxUint64 { - p.mutex.Lock() - for _, id := range trackIDs { - p.release(id) - } - p.mutex.Unlock() - } else { - p.mutex.Lock() - for i, t := range score.Tracks { - if !t.Effect && i < len(trackIDs) { // when starting to play from another position, release only non-effect tracks - p.release(trackIDs[i]) - } - } - p.mutex.Unlock() - } - rowTime = math.MaxInt32 - default: - row, playing := p.Position() - if playing && rowTime >= samplesPerRow && score.Length > 0 && score.RowsPerPattern > 0 { - row.Row++ // advance row (this is why we subtracted one in Play()) - row = row.Wrap(score) - atomic.StoreUint64(&p.packedPos, packPosition(row)) - select { - case posChanged <- struct{}{}: - default: - } - p.mutex.Lock() - lastVoice := 0 - for i, t := range score.Tracks { - start := lastVoice - lastVoice = start + t.NumVoices - if row.Pattern < 0 || row.Pattern >= len(t.Order) { - continue - } - o := t.Order[row.Pattern] - if o < 0 || o >= len(t.Patterns) { - continue - } - pat := t.Patterns[o] - if row.Row < 0 || row.Row >= len(pat) { - continue - } - n := pat[row.Row] - for len(trackIDs) <= i { - trackIDs = append(trackIDs, 0) - } - if n != 1 && trackIDs[i] > 0 { - p.release(trackIDs[i]) - } - if n > 1 && p.synth != nil { - trackIDs[i] = p.trigger(start, lastVoice, n) - } - } - p.mutex.Unlock() - rowTime = 0 - } - if p.synth != nil { - renderTime := samplesPerRow - rowTime - if !playing { - renderTime = math.MaxInt32 - } - p.mutex.Lock() - rendered, syncs, timeAdvanced, err := p.synth.Render(buffer, syncBuffer, renderTime) - if err != nil { - p.synth = nil - atomic.StoreInt32(&p.synthNotNil, 0) - } - p.mutex.Unlock() - for i := 0; i < syncs; i++ { - a := syncBuffer[i*totalSyncs] - b := (a+float32(rowTime))/float32(samplesPerRow) + float32(row.Pattern*score.RowsPerPattern+row.Row) - syncBuffer[i*totalSyncs] = b - } - rowTime += timeAdvanced - for window := syncBuffer[:totalSyncs*syncs]; len(window) > 0; window = window[totalSyncs:] { - select { - case syncOutput <- window[:totalSyncs]: - default: - } - } - for i := range p.samplesSinceEvent { - atomic.AddInt32(&p.samplesSinceEvent[i], int32(timeAdvanced)) - } - for _, o := range outputs { - o <- buffer[:rendered*2] - } - buffer2, buffer = buffer, buffer2 - syncBuffer2, syncBuffer = syncBuffer, syncBuffer2 - } else { - rowTime += len(zeros) / 2 - for _, o := range outputs { - o <- zeros - } - } - } - } - }() + PlayerPlayingMessage struct { + bool + } + + PlayerRecordedMessage struct { + BPM float64 // vsts allow bpms as floats so for accurate reconstruction, keep it as float for recording + Events []PlayerProcessEvent + TotalFrames int + } + + // Volume and SongRow are transmitted so frequently that they are treated specially, to avoid boxing. All the + // rest messages can be boxed to interface{} + PlayerMessage struct { + Volume Volume + SongRow SongRow + VoiceStates [vm.MAX_VOICES]float32 + Inner interface{} + } + + PlayerCrashMessage struct { + error + } + + PlayerVolumeErrorMessage struct { + error + } + + voiceNote struct { + voice int + note byte + } + + recordEvent struct { + frame int + } +) + +const NUM_RENDER_TRIES = 10000 + +func NewPlayer(synthService sointu.SynthService, playerMessages chan<- PlayerMessage, modelMessages <-chan interface{}) *Player { + p := &Player{ + playerMessages: playerMessages, + modelMessages: modelMessages, + synthService: synthService, + volume: Volume{Average: [2]float64{1e-9, 1e-9}, Peak: [2]float64{1e-9, 1e-9}}, + } return p } -// Trigger is used to manually play a note on the sequencer when jamming. It is -// thread-safe. It starts to play one of the voice in the range voiceStart -// (inclusive) and voiceEnd (exclusive). It returns a id that can be called to -// release the voice playing the note (in case the voice has not been captured -// by someone else already). -func (p *Player) Trigger(voiceStart, voiceEnd int, note byte) uint32 { - if note <= 1 { - return 0 +func (p *Player) Process(buffer []float32, context PlayerProcessContext) { + p.processMessages(context) + midi, midiOk := context.NextEvent() + frame := 0 + + if p.recording && p.recordingNoteArrived { + p.recordingFrames += len(buffer) / 2 } - p.mutex.Lock() - id := p.trigger(voiceStart, voiceEnd, note) - p.mutex.Unlock() - return id + + oldBuffer := buffer + + for i := 0; i < NUM_RENDER_TRIES; i++ { + for midiOk && frame >= midi.Frame { + if p.recording { + if !p.recordingNoteArrived { + p.recordingFrames = len(buffer) / 2 + p.recordingNoteArrived = true + } + midiTotalFrame := midi + midiTotalFrame.Frame = p.recordingFrames - len(buffer)/2 + p.recordingEvents = append(p.recordingEvents, midiTotalFrame) + } + if midi.On { + p.triggerInstrument(midi.Channel, midi.Note) + } else { + p.releaseInstrument(midi.Channel, midi.Note) + } + midi, midiOk = context.NextEvent() + } + framesUntilMidi := len(buffer) / 2 + if delta := midi.Frame - frame; midiOk && delta < framesUntilMidi { + framesUntilMidi = delta + } + if p.playing && p.rowtime >= p.samplesPerRow { + p.advanceRow() + } + timeUntilRowAdvance := math.MaxInt32 + if p.playing { + timeUntilRowAdvance = p.samplesPerRow - p.rowtime + } + var rendered, timeAdvanced int + var err error + if p.synth != nil { + rendered, timeAdvanced, err = p.synth.Render(buffer[:framesUntilMidi*2], timeUntilRowAdvance) + } else { + mx := framesUntilMidi + if timeUntilRowAdvance < mx { + mx = timeUntilRowAdvance + } + for i := 0; i < mx*2; i++ { + buffer[i] = 0 + } + rendered = mx + timeAdvanced = mx + } + if err != nil { + p.synth = nil + p.trySend(PlayerCrashMessage{fmt.Errorf("synth.Render: %w", err)}) + } + buffer = buffer[rendered*2:] + frame += rendered + p.rowtime += timeAdvanced + for i := range p.samplesSinceEvent { + p.samplesSinceEvent[i] += rendered + } + alpha := float32(math.Exp(-float64(rendered) / 15000)) + for i, released := range p.voiceReleased { + if released { + p.voiceStates[i] *= alpha + } else { + p.voiceStates[i] = (p.voiceStates[i]-0.5)*alpha + 0.5 + } + } + // when the buffer is full, return + if len(buffer) == 0 { + err := p.volume.Analyze(oldBuffer, 0.3, 1e-4, 1, -100, 20) + var msg interface{} + if err != nil { + msg = PlayerVolumeErrorMessage{err} + } + p.trySend(msg) + return + } + } + // we were not able to fill the buffer with NUM_RENDER_TRIES attempts, destroy synth and throw an error + p.synth = nil + p.trySend(PlayerCrashMessage{fmt.Errorf("synth did not fill the audio buffer even with %d render calls", NUM_RENDER_TRIES)}) } -// Release is used to manually release a note on the player when jamming. -// Expects an ID that was previously acquired by calling Trigger. -func (p *Player) Release(ID uint32) { - if ID == 0 { +func (p *Player) advanceRow() { + if p.score.Length == 0 || p.score.RowsPerPattern == 0 { return } - p.mutex.Lock() - p.release(ID) - p.mutex.Unlock() + p.position.Row++ // advance row (this is why we subtracted one in Play()) + p.position = p.position.Wrap(p.score) + p.trySend(nil) // just send volume and song row information + lastVoice := 0 + for i, t := range p.score.Tracks { + start := lastVoice + lastVoice = start + t.NumVoices + if p.position.Pattern < 0 || p.position.Pattern >= len(t.Order) { + continue + } + o := t.Order[p.position.Pattern] + if o < 0 || o >= len(t.Patterns) { + continue + } + pat := t.Patterns[o] + if p.position.Row < 0 || p.position.Row >= len(pat) { + continue + } + n := pat[p.position.Row] + switch { + case n == 0: + p.releaseTrack(i) + case n > 1: + p.triggerTrack(i, n) + default: // n == 1 + } + } + p.rowtime = 0 } -func (p *Player) trigger(voiceStart, voiceEnd int, note byte) uint32 { - if p.synth == nil { - return 0 +func (p *Player) processMessages(context PlayerProcessContext) { +loop: + for { // process new message + select { + case msg := <-p.modelMessages: + switch m := msg.(type) { + case ModelPanicMessage: + if m.bool { + p.synth = nil + } else { + p.compileOrUpdateSynth() + } + case ModelPatchChangedMessage: + p.patch = m.Patch + p.compileOrUpdateSynth() + case ModelScoreChangedMessage: + p.score = m.Score + case ModelPlayingChangedMessage: + p.playing = m.bool + if !p.playing { + for i := range p.score.Tracks { + p.releaseTrack(i) + } + } + case ModelSamplesPerRowChangedMessage: + p.samplesPerRow = m.int + case ModelPlayFromPositionMessage: + p.playing = true + p.position = m.SongRow + p.position.Row-- + p.rowtime = math.MaxInt + for i, t := range p.score.Tracks { + if !t.Effect { + // when starting to play from another position, release only non-effect tracks + p.releaseTrack(i) + } + } + case ModelNoteOnMessage: + if m.id.IsInstr { + p.triggerInstrument(m.id.Instr, m.id.Note) + } else { + p.triggerTrack(m.id.Track, m.id.Note) + } + case ModelNoteOffMessage: + if m.id.IsInstr { + p.releaseInstrument(m.id.Instr, m.id.Note) + } else { + p.releaseTrack(m.id.Track) + } + case ModelRecordingMessage: + if m.bool { + p.recording = true + p.recordingEvents = make([]PlayerProcessEvent, 0) + p.recordingFrames = 0 + p.recordingNoteArrived = false + } else { + if p.recording && len(p.recordingEvents) > 0 { + bpm, ok := context.BPM() + if !ok { + bpm = 120 + } + p.trySend(PlayerRecordedMessage{ + BPM: bpm, + Events: p.recordingEvents, + TotalFrames: p.recordingFrames, + }) + } + p.recording = false + } + default: + // ignore unknown messages + } + default: + break loop + } } - var oldestID uint32 = math.MaxUint32 - p.runningID++ - newID := p.runningID +} + +func (p *Player) compileOrUpdateSynth() { + if p.synth != nil { + err := p.synth.Update(p.patch) + if err != nil { + p.synth = nil + p.trySend(PlayerCrashMessage{fmt.Errorf("synth.Update: %w", err)}) + return + } + } else { + var err error + p.synth, err = p.synthService.Compile(p.patch) + if err != nil { + p.synth = nil + p.trySend(PlayerCrashMessage{fmt.Errorf("synthService.Compile: %w", err)}) + return + } + for i := 0; i < 32; i++ { + p.synth.Release(i) + } + } +} + +// all sends from player are always non-blocking, to ensure that the player thread cannot end up in a dead-lock +func (p *Player) trySend(message interface{}) { + select { + case p.playerMessages <- PlayerMessage{Volume: p.volume, SongRow: p.position, VoiceStates: p.voiceStates, Inner: message}: + default: + } +} + +func (p *Player) triggerInstrument(instrument int, note byte) { + ID := idForInstrumentNote(instrument, note) + p.release(ID) + voiceStart := p.patch.FirstVoiceForInstrument(instrument) + voiceEnd := voiceStart + p.patch[instrument].NumVoices + p.trigger(voiceStart, voiceEnd, note, ID) +} + +func (p *Player) releaseInstrument(instrument int, note byte) { + p.release(idForInstrumentNote(instrument, note)) +} + +func (p *Player) triggerTrack(track int, note byte) { + ID := idForTrack(track) + p.release(ID) + voiceStart := p.score.FirstVoiceForTrack(track) + voiceEnd := voiceStart + p.score.Tracks[track].NumVoices + p.trigger(voiceStart, voiceEnd, note, ID) +} + +func (p *Player) releaseTrack(track int) { + p.release(idForTrack(track)) +} + +func (p *Player) trigger(voiceStart, voiceEnd int, note byte, ID int) { + if p.synth == nil { + return + } + var age int = 0 oldestReleased := false oldestVoice := 0 for i := voiceStart; i < voiceEnd; i++ { for len(p.voiceReleased) <= i { - p.voiceReleased = append(p.voiceReleased, 1) + p.voiceReleased = append(p.voiceReleased, true) } for len(p.samplesSinceEvent) <= i { p.samplesSinceEvent = append(p.samplesSinceEvent, 0) @@ -274,43 +370,44 @@ func (p *Player) trigger(voiceStart, voiceEnd int, note byte) uint32 { // then we prefer to trigger that over a voice that is still playing. in // case two voices are both playing or or both are released, we prefer // the older one - id := p.voiceNoteID[i] - isReleased := atomic.LoadInt32(&p.voiceReleased[i]) == 1 - if id < oldestID && (oldestReleased == isReleased) || (!oldestReleased && isReleased) { + if (p.voiceReleased[i] && !oldestReleased) || + (p.voiceReleased[i] == oldestReleased && p.samplesSinceEvent[i] >= age) { oldestVoice = i - oldestID = id - oldestReleased = isReleased + oldestReleased = p.voiceReleased[i] + age = p.samplesSinceEvent[i] } } - p.voiceNoteID[oldestVoice] = newID - atomic.StoreInt32(&p.voiceReleased[oldestVoice], 0) - atomic.StoreInt32(&p.samplesSinceEvent[oldestVoice], 0) + p.voiceNoteID[oldestVoice] = ID + p.voiceReleased[oldestVoice] = false + p.voiceStates[oldestVoice] = 1.0 + p.samplesSinceEvent[oldestVoice] = 0 if p.synth != nil { p.synth.Trigger(oldestVoice, note) } - return newID } -func (p *Player) release(ID uint32) { +func (p *Player) release(ID int) { if p.synth == nil { return } for i := 0; i < len(p.voiceNoteID); i++ { - if p.voiceNoteID[i] == ID && atomic.LoadInt32(&p.voiceReleased[i]) != 1 { - atomic.StoreInt32(&p.voiceReleased[i], 1) - atomic.StoreInt32(&p.samplesSinceEvent[i], 0) + if p.voiceNoteID[i] == ID && !p.voiceReleased[i] { + p.voiceReleased[i] = true + p.samplesSinceEvent[i] = 0 p.synth.Release(i) return } } } -func packPosition(pos SongRow) uint64 { - return (uint64(uint32(pos.Pattern)) << 32) + uint64(uint32(pos.Row)) +// we need to give voices triggered by different sources a identifier who triggered it +// positive values are for voices triggered by instrument jamming i.e. MIDI message from +// host or pressing key on the keyboard +// negative values are for voices triggered by tracks when playing a song +func idForInstrumentNote(instrument int, note byte) int { + return instrument*256 + int(note) } -func unpackPosition(packedPos uint64) SongRow { - pattern := int(int32(packedPos >> 32)) - row := int(int32(packedPos & 0xFFFFFFFF)) - return SongRow{Pattern: pattern, Row: row} +func idForTrack(track int) int { + return -1 - track } diff --git a/tracker/recording.go b/tracker/recording.go new file mode 100644 index 0000000..8355be3 --- /dev/null +++ b/tracker/recording.go @@ -0,0 +1,145 @@ +package tracker + +import ( + "math" + + "github.com/vsariola/sointu" +) + +type ( + recordingNote struct { + note byte + startRow int + endRow int + } +) + +func RecordingToSong(patch sointu.Patch, rowsPerBeat, rowsPerPattern int, recording PlayerRecordedMessage) sointu.Song { + channelNotes := make([][]recordingNote, 0) + // find the length of each note and assign it to its respective channel + for i, m := range recording.Events { + if !m.On || m.Channel >= len(patch) { + continue + } + endFrame := math.MaxInt + for j := i + 1; j < len(recording.Events); j++ { + if recording.Events[j].Channel == m.Channel && recording.Events[j].Note == m.Note { + endFrame = recording.Events[j].Frame + break + } + } + for len(channelNotes) <= m.Channel { + channelNotes = append(channelNotes, make([]recordingNote, 0)) + } + startRow := frameToRow(recording.BPM, rowsPerBeat, m.Frame) + endRow := frameToRow(recording.BPM, rowsPerBeat, endFrame) + channelNotes[m.Channel] = append(channelNotes[m.Channel], recordingNote{m.Note, startRow, endRow}) + } + //assign notes to tracks, assigning it to left most track that is released + // if none is released, assign it to new track if there's any. otherwise, assign it to the left most track + tracks := make([][][]recordingNote, len(channelNotes)) + for i, c := range channelNotes { + tracks[i] = make([][]recordingNote, 0) + noteloop: + for _, n := range c { + // if a track is release, assign the note to left-most released track + for k, t := range tracks[i] { + if len(t) == 0 || t[len(t)-1].endRow <= n.startRow { + tracks[i][k] = append(t, n) + continue noteloop + } + } + // if there's space for more tracks, create one + if len(tracks[i]) < patch[i].NumVoices { + tracks[i] = append(tracks[i], []recordingNote{n}) + continue noteloop + } + // otherwise, put the note to the track that was triggered longest time ago + oldestIndex := -1 + oldestRow := math.MaxInt + for k, t := range tracks[i] { + if r := t[len(t)-1].startRow; r < oldestRow { + oldestRow = r + oldestIndex = k + } + } + tracks[i][oldestIndex] = append(tracks[i][oldestIndex], n) + } + } + songLengthPatterns := (frameToRow(recording.BPM, rowsPerBeat, recording.TotalFrames) + rowsPerPattern - 1) / rowsPerPattern + songLengthRows := songLengthPatterns * rowsPerPattern + songTracks := make([]sointu.Track, 0) + for i, tg := range tracks { + for j, t := range tg { + // construct flat linear note arrays for tracks + flatPattern := make(sointu.Pattern, songLengthRows) + for k := range flatPattern { + flatPattern[k] = 1 // set all notes as holds at first + } + for _, n := range t { + flatPattern.Set(n.startRow, n.note) + if n.endRow < songLengthRows { + for l := n.startRow + 1; l < n.endRow; l++ { + flatPattern.Set(l, 1) + } + flatPattern.Set(n.endRow, 0) + } else { + for l := n.startRow + 1; l < songLengthRows; l++ { + flatPattern.Set(l, 1) + } + } + } + // calculate number of voices, distributing the total number of voices to the different tracks + numVoices := (patch[i].NumVoices + len(tg) - j - 1) / len(tg) + // construct patterns + order := make(sointu.Order, songLengthPatterns) + patterns := make([]sointu.Pattern, 0) + L: + for k := range order { + p := flatPattern[k*rowsPerPattern : (k+1)*rowsPerPattern] + allHolds := true + for _, n := range p { + if n != 1 { + allHolds = false + break + } + } + if allHolds { + order[k] = -1 + continue L + } + for l, p2 := range patterns { + if testEq(p, p2) { + order[k] = l + continue L + } + } + // make a copy of the slice so they are all independent and don't accidentally expand to same memory + newPat := make(sointu.Pattern, len(p)) + copy(newPat, p) + order[k] = len(patterns) + patterns = append(patterns, newPat) + } + track := sointu.Track{NumVoices: numVoices, Effect: false, Order: order, Patterns: patterns} + songTracks = append(songTracks, track) + } + } + score := sointu.Score{Length: songLengthPatterns, RowsPerPattern: rowsPerPattern, Tracks: songTracks} + return sointu.Song{BPM: int(recording.BPM + 0.5), RowsPerBeat: rowsPerBeat, Score: score, Patch: patch.Copy()} +} + +func frameToRow(BPM float64, rowsPerBeat, frame int) int { + return int(float64(frame)/44100/60*BPM*float64(rowsPerBeat) + 0.5) +} + +func testEq(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/tracker/vuanalyzer.go b/tracker/volume.go similarity index 60% rename from tracker/vuanalyzer.go rename to tracker/volume.go index 222d45e..9649b91 100644 --- a/tracker/vuanalyzer.go +++ b/tracker/volume.go @@ -29,40 +29,34 @@ type Volume struct { // // minVolume is just a hard limit for the vuanalyzer volumes, in decibels, just to // prevent negative infinities for volumes -func VuAnalyzer(tau float64, attack float64, release float64, minVolume float64, maxVolume float64, bc <-chan []float32, vc chan<- Volume, ec chan<- error) { - v := Volume{Average: [2]float64{minVolume, minVolume}, Peak: [2]float64{minVolume, minVolume}} +func (v *Volume) Analyze(buffer []float32, tau float64, attack float64, release float64, minVolume float64, maxVolume float64) error { alpha := 1 - math.Exp(-1.0/(tau*44100)) // from https://en.wikipedia.org/wiki/Exponential_smoothing alphaAttack := 1 - math.Exp(-1.0/(attack*44100)) alphaRelease := 1 - math.Exp(-1.0/(release*44100)) - for buffer := range bc { - for j := 0; j < 2; j++ { - for i := 0; i < len(buffer); i += 2 { - sample2 := float64(buffer[i+j] * buffer[i+j]) - if math.IsNaN(sample2) { - select { - case ec <- errors.New("NaN detected in master output"): - default: - } - continue + var err error + for j := 0; j < 2; j++ { + for i := 0; i < len(buffer); i += 2 { + sample2 := float64(buffer[i+j] * buffer[i+j]) + if math.IsNaN(sample2) { + if err == nil { + err = errors.New("NaN detected in master output") } - dB := 10 * math.Log10(float64(sample2)) - if dB < minVolume || math.IsNaN(dB) { - dB = minVolume - } - if dB > maxVolume { - dB = maxVolume - } - v.Average[j] += (dB - v.Average[j]) * alpha - alphaPeak := alphaAttack - if dB < v.Peak[j] { - alphaPeak = alphaRelease - } - v.Peak[j] += (dB - v.Peak[j]) * alphaPeak + continue } - } - select { - case vc <- v: - default: + dB := 10 * math.Log10(float64(sample2)) + if dB < minVolume || math.IsNaN(dB) { + dB = minVolume + } + if dB > maxVolume { + dB = maxVolume + } + v.Average[j] += (dB - v.Average[j]) * alpha + alphaPeak := alphaAttack + if dB < v.Peak[j] { + alphaPeak = alphaRelease + } + v.Peak[j] += (dB - v.Peak[j]) * alphaPeak } } + return err } diff --git a/vm/compiler/bridge/bridge.go b/vm/compiler/bridge/bridge.go index 6f28f0e..9e939da 100644 --- a/vm/compiler/bridge/bridge.go +++ b/vm/compiler/bridge/bridge.go @@ -59,32 +59,36 @@ func Synth(patch sointu.Patch) (*C.Synth, error) { // Render renders until the buffer is full or the modulated time is reached, whichever // happens first. // Parameters: -// buffer float32 slice to fill with rendered samples. Stereo signal, so -// should have even length. -// maxtime how long nominal time to render in samples. Speed unit might modulate time -// so the actual number of samples rendered depends on the modulation and if -// buffer is full before maxtime is reached. +// +// buffer float32 slice to fill with rendered samples. Stereo signal, so +// should have even length. +// maxtime how long nominal time to render in samples. Speed unit might modulate time +// so the actual number of samples rendered depends on the modulation and if +// buffer is full before maxtime is reached. +// // Returns a tuple (int, int, error), consisting of: -// samples number of samples rendered in the buffer -// time how much the time advanced -// error potential error +// +// samples number of samples rendered in the buffer +// time how much the time advanced +// error potential error +// // In practice, if nsamples = len(buffer)/2, then time <= maxtime. If maxtime was reached // first, then nsamples <= len(buffer)/2 and time >= maxtime. Note that it could happen that // time > maxtime, as it is modulated and the time could advance by 2 or more, so the loop // exit condition would fire when the time is already past maxtime. // Under no conditions, nsamples >= len(buffer)/2 i.e. guaranteed to never overwrite the buffer. -func (synth *C.Synth) Render(buffer []float32, syncBuffer []float32, maxtime int) (int, int, int, error) { +func (synth *C.Synth) Render(buffer []float32, maxtime int) (int, int, error) { // TODO: syncBuffer is not getting passed to cgo; do we want to even try to support the syncing with the native bridge if len(buffer)%1 == 1 { - return -1, -1, -1, errors.New("RenderTime writes stereo signals, so buffer should have even length") + return -1, -1, errors.New("RenderTime writes stereo signals, so buffer should have even length") } samples := C.int(len(buffer) / 2) time := C.int(maxtime) errcode := int(C.su_render(synth, (*C.float)(&buffer[0]), &samples, &time)) if errcode > 0 { - return int(samples), 0, int(time), &RenderError{errcode: errcode} + return int(samples), int(time), &RenderError{errcode: errcode} } - return int(samples), 0, int(time), nil + return int(samples), int(time), nil } // Trigger is part of C.Synths' implementation of sointu.Synth interface diff --git a/vm/compiler/bridge/bridge_test.go b/vm/compiler/bridge/bridge_test.go index 6dedcf4..0ff1839 100644 --- a/vm/compiler/bridge/bridge_test.go +++ b/vm/compiler/bridge/bridge_test.go @@ -40,7 +40,7 @@ func TestOscillatSine(t *testing.T) { }}} tracks := []sointu.Track{{NumVoices: 1, Order: []int{0}, Patterns: []sointu.Pattern{{64, 0, 68, 0, 32, 0, 0, 0, 75, 0, 78, 0, 0, 0, 0, 0}}}} song := sointu.Song{BPM: 100, RowsPerBeat: 4, Score: sointu.Score{RowsPerPattern: 16, Length: 1, Tracks: tracks}, Patch: patch} - buffer, _, err := sointu.Play(bridge.BridgeService{}, song, false) + buffer, err := sointu.Play(bridge.BridgeService{}, song, false) if err != nil { t.Fatalf("Render failed: %v", err) } @@ -95,7 +95,7 @@ func TestAllRegressionTests(t *testing.T) { if err != nil { t.Fatalf("could not parse the .yml file: %v", err) } - buffer, _, err := sointu.Play(bridge.BridgeService{}, song, false) + buffer, err := sointu.Play(bridge.BridgeService{}, song, false) buffer = buffer[:song.Score.LengthInRows()*song.SamplesPerRow()*2] // extend to the nominal length always. if err != nil { t.Fatalf("Play failed: %v", err) diff --git a/vm/interpreter.go b/vm/interpreter.go index 0268804..adb24f4 100644 --- a/vm/interpreter.go +++ b/vm/interpreter.go @@ -115,7 +115,7 @@ func (s *Interpreter) Update(patch sointu.Patch) error { return nil } -func (s *Interpreter) Render(buffer []float32, syncBuf []float32, maxtime int) (samples int, syncs int, time int, renderError error) { +func (s *Interpreter) Render(buffer []float32, maxtime int) (samples int, time int, renderError error) { defer func() { if err := recover(); err != nil { renderError = fmt.Errorf("render panicced: %v", err) @@ -133,10 +133,6 @@ func (s *Interpreter) Render(buffer []float32, syncBuf []float32, maxtime int) ( voicesRemaining := s.bytePatch.NumVoices voices := s.synth.voices[:] units := voices[0].units[:] - if byte(s.synth.globalTime) == 0 { // every 256 samples - syncBuf[0], syncBuf = float32(time), syncBuf[1:] - syncs++ - } for voicesRemaining > 0 { op := commands[0] commands = commands[1:] @@ -156,7 +152,7 @@ func (s *Interpreter) Render(buffer []float32, syncBuf []float32, maxtime int) ( } tcount := transformCounts[opNoStereo-1] if len(values) < tcount { - return samples, syncs, time, errors.New("value stream ended prematurely") + return samples, time, errors.New("value stream ended prematurely") } voice := &voices[0] unit := &units[0] @@ -527,19 +523,17 @@ func (s *Interpreter) Render(buffer []float32, syncBuf []float32, maxtime int) ( stack = append(stack, gain) } case opSync: - if byte(s.synth.globalTime) == 0 { // every 256 samples - syncBuf[0], syncBuf = float32(stack[l-1]), syncBuf[1:] - } + break default: - return samples, syncs, time, errors.New("invalid / unimplemented opcode") + return samples, time, errors.New("invalid / unimplemented opcode") } units = units[1:] } if len(stack) < 4 { - return samples, syncs, time, errors.New("stack underflow") + return samples, time, errors.New("stack underflow") } if len(stack) > 4 { - return samples, syncs, time, errors.New("stack not empty") + return samples, time, errors.New("stack not empty") } buffer[0] = synth.outputs[0] buffer[1] = synth.outputs[1] @@ -551,7 +545,7 @@ func (s *Interpreter) Render(buffer []float32, syncBuf []float32, maxtime int) ( s.synth.globalTime++ } s.stack = stack[:0] - return samples, syncs, time, nil + return samples, time, nil } func (s *synth) rand() float32 { diff --git a/vm/interpreter_test.go b/vm/interpreter_test.go index fb97858..65906ad 100644 --- a/vm/interpreter_test.go +++ b/vm/interpreter_test.go @@ -41,7 +41,7 @@ func TestAllRegressionTests(t *testing.T) { if err != nil { t.Fatalf("could not parse the .yml file: %v", err) } - buffer, syncBuffer, err := sointu.Play(vm.SynthService{}, song, false) + buffer, err := sointu.Play(vm.SynthService{}, song, false) buffer = buffer[:song.Score.LengthInRows()*song.SamplesPerRow()*2] // extend to the nominal length always. if err != nil { t.Fatalf("Play failed: %v", err) @@ -68,9 +68,6 @@ func TestAllRegressionTests(t *testing.T) { } } compareToRawFloat32(t, buffer, testname+".raw") - if strings.Contains(testname, "sync") { - compareToRawFloat32(t, syncBuffer, testname+"_syncbuf.raw") - } }) } }