mirror of
https://github.com/vsariola/sointu.git
synced 2025-06-04 01:28:45 -04:00
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.
This commit is contained in:
parent
70080c2b9d
commit
cd700ed954
2
.gitignore
vendored
2
.gitignore
vendored
@ -29,3 +29,5 @@ out/
|
||||
.cache/
|
||||
actual_output/
|
||||
**/__debug_bin
|
||||
*.exe
|
||||
*.dll
|
||||
|
99
README.md
99
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:
|
||||
|
||||

|
||||
|
||||
@ -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
|
||||
-------
|
||||
|
7
cmd/default_service_native.go
Normal file
7
cmd/default_service_native.go
Normal file
@ -0,0 +1,7 @@
|
||||
//go:build native
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/vsariola/sointu/vm/compiler/bridge"
|
||||
|
||||
var DefaultService = bridge.BridgeService{}
|
7
cmd/default_service_other.go
Normal file
7
cmd/default_service_other.go
Normal file
@ -0,0 +1,7 @@
|
||||
//go:build !native
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/vsariola/sointu/vm"
|
||||
|
||||
var DefaultService = vm.SynthService{}
|
@ -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)
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
104
cmd/sointu-vsti/main.go
Normal file
104
cmd/sointu-vsti/main.go
Normal file
@ -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() {}
|
28
go.mod
28
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
|
||||
)
|
||||
|
16
go.sum
16
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=
|
||||
|
57
rpc/rpc.go
57
rpc/rpc.go
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
30
synth.go
30
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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
}
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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()
|
||||
}
|
||||
|
15
tracker/gioui/tracker_not_plugin.go
Normal file
15
tracker/gioui/tracker_not_plugin.go
Normal file
@ -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
|
||||
}
|
10
tracker/gioui/tracker_plugin.go
Normal file
10
tracker/gioui/tracker_plugin.go
Normal file
@ -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
|
||||
}
|
199
tracker/model.go
199
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:
|
||||
}
|
||||
}
|
||||
|
||||
|
19
tracker/note_id.go
Normal file
19
tracker/note_id.go
Normal file
@ -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}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
145
tracker/recording.go
Normal file
145
tracker/recording.go
Normal file
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user