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:
5684185+vsariola@users.noreply.github.com 2023-05-09 11:24:49 +03:00
parent 70080c2b9d
commit cd700ed954
34 changed files with 1210 additions and 750 deletions

2
.gitignore vendored
View File

@ -29,3 +29,5 @@ out/
.cache/
actual_output/
**/__debug_bin
*.exe
*.dll

View File

@ -24,20 +24,17 @@ synthesis engine can already be fitted in 600 bytes (386, compressed), with
another few hundred bytes for the patch and pattern data.
Sointu consists of two core elements:
- A cross-platform synth-tracker app for composing music, written in
[go](https://golang.org/). The app is still heavily work in progress. The app
exports the projects as .yml files. There are two versions of the app:
[cmd/sointu-track/](sointu-track), using a plain Go VM bytecode interpreter,
and [cmd/sointu-nativetrack/](sointu-nativetrack), using cgo to bridge calls
to the Sointu compiled VM. The former should be highly portable, the latter
currently works only on x86/amd64 platforms.
- A cross-platform synth-tracker that runs as either VSTi or stand-alone
app for composing music, written in [go](https://golang.org/). The app
is still heavily work in progress. The app exports the projects as
.yml files.
- A compiler, likewise written in go, which can be invoked from the command line
to compile these .yml files into .asm or .wat code. For x86/amd64, the
resulting .asm can be then compiled by [nasm](https://www.nasm.us/) or
[yasm](https://yasm.tortall.net). For browsers, the resulting .wat can be
compiled by [wat2wasm](https://github.com/WebAssembly/wabt).
This is how the current prototype tracker looks like:
This is how the current prototype app looks like:
![Screenshot of the tracker](screenshot.png)
@ -49,8 +46,7 @@ listed below.
### Sointu-track
This version of the tracker is the version that uses the bytecode interpreter
written in plain Go. Running the tracker:
This is the stand-alone version of the synth-tracker. Running the tracker:
```
go run cmd/sointu-track/main.go
@ -59,24 +55,40 @@ go run cmd/sointu-track/main.go
Building the tracker:
```
go build -o sointu-track cmd/sointu-track/main.go
go build -o sointu-track.exe cmd/sointu-track/main.go
```
On windows, replace `-o sointu-track` with `-o sointu-track.exe`.
On other platforms than Windows, replace `-o sointu-track.exe` with
`-o sointu-track`.
Add `-tags=native` to use the [x86 native virtual machine](#native-virtual-machine)
instead of the virtual machine written in Go.
Sointu-track uses the [gioui](https://gioui.org/) for the GUI and
[oto](https://github.com/hajimehoshi/oto) for the audio, so the portability is
currently limited by these.
> :warning: Unlike the x86/amd64 VM compiled by Sointu, the Go written VM
> bytecode interpreter uses a software stack. Thus, unlike x87 FPU stack, it is
> not limited to 8 items. If you intent to compile the patch to x86/amd64
> targets, make sure not to use too much stack. Keeping at most 5 signals in the
> stack is presumably fine (reserving 3 for the temporary variables of the
> opcodes). In future, the app should give warnings if the user is about to
> exceed the capabilities of a target platform.
### Sointu-vsti
### Compiler
This is the VST instrument plugin version of the tracker, compiled into
a dynamically linked library and ran inside a VST host. Building the VST
plugin:
```
go build -buildmode=c-shared -tags=plugin -o sointu-vsti.dll .\cmd\sointu-vsti\
```
On other platforms than Windows, replace `-o sointu-track.dll`
appropriately e.g. `-o sointu-track.so`; however, the VST instrument is
completely untested on all other platforms than Windows at the moment.
Notice the `-tags=plugin` build tag definition. This is required by the [vst2 library](https://github.com/pipelined/vst2);
otherwise, you will get a lot of build errors.
Add `-tags=native,plugin` to use the [x86 native virtual machine](#native-virtual-machine)
instead of the virtual machine written in Go.
### Sointu-compile
The command line interface to it is [sointu-compile](cmd/sointu-compile/main.go)
and the actual code resides in the [compiler](vm/compiler/) package, which is an
@ -91,10 +103,11 @@ go run cmd/sointu-compile/main.go
Building the compiler:
```
go build -o sointu-compile cmd/sointu-compile/main.go
go build -o sointu-compile.exe cmd/sointu-compile/main.go
```
On windows, replace `-o sointu-compile` with `-o sointu-compile.exe`.
On other platforms than Windows, replace `-o sointu-compile-exe` with
`-o sointu-compile`.
The compiler can then be used to compile a .yml song into .asm and .h files. For
example:
@ -111,7 +124,7 @@ sointu-compile -o . -arch=wasm tests/test_chords.yml
wat2wasm --enable-bulk-memory test_chords.wat
```
### Building and running the tests as executables
### Tests
Building the [regression tests](tests/) as executables (testing that they work
the same way when you would link them in an intro) requires:
@ -142,14 +155,13 @@ cmake .. -DCMAKE_C_FLAGS="-m32" -DCMAKE_ASM_NASM_OBJECT_FORMAT="win32" -GNinja
Another example: on Visual Studio 2019 Community, just open the folder, choose
either Debug or Release and either x86 or x64 build, and hit build all.
### Native bridge & sointu-nativetrack
### Native virtual machine
The native bridge allows Go to call the sointu compiled virtual machine, through
cgo, instead of using the Go written bytecode interpreter. It's likely slightly
faster than the interpreter. The version of the tracker that uses the native
bridge is [sointu-nativetrack](cmd/sointu-nativetrack/). Before you can actually
run it, you need to build the bridge using CMake (thus, the nativetrack does not
work with go get)
The native bridge allows Go to call the sointu compiled x86 native
virtual machine, through cgo, instead of using the Go written bytecode
interpreter. It's likely slightly faster than the interpreter. Before
you can actually run it, you need to build the bridge using CMake (thus,
***this will not work with go get***)
Building the native bridge requires:
- [go](https://golang.org/)
@ -191,14 +203,26 @@ go test ./...
Play a song from the command line:
```
go run cmd/sointu-play/main.go tests/test_chords.yml
go run -tags=native cmd/sointu-play/main.go tests/test_chords.yml
```
Run the tracker using the native bridge
```
go run cmd/sointu-nativetrack/main.go
go run -tags=native cmd/sointu-track/main.go
```
```
go build -buildmode=c-shared -tags=plugin,native -o sointu-vsti.dll .\cmd\sointu-vsti\
```
> :warning: Unlike the x86/amd64 VM compiled by Sointu, the Go written VM
> bytecode interpreter uses a software stack. Thus, unlike x87 FPU stack, it is
> not limited to 8 items. If you intent to compile the patch to x86/amd64
> targets, make sure not to use too much stack. Keeping at most 5 signals in the
> stack is presumably fine (reserving 3 for the temporary variables of the
> opcodes). In future, the app should give warnings if the user is about to
> exceed the capabilities of a target platform.
> :warning: **If you are using MinGW and Yasm**: Yasm 1.3.0 (currently still the
> latest stable release) and GNU linker do not play nicely along, trashing the
> BSS layout. See
@ -209,16 +233,17 @@ go run cmd/sointu-nativetrack/main.go
> our synth object overlapping with DLL call addresses; very funny stuff to
> debug.
> :warning: The sointu-nativetrack cannot be used with the syncs at the moment.
> For syncs, use the Go VM (sointu-track).
> :warning: The native virtual machine cannot be output syncs at the
> moment. For syncs, use the Go virtual machine.
### Building and running the WebAssembly tests
### WebAssembly tests
These are automatically invoked by CTest if [node](https://nodejs.org) and
[wat2wasm](https://github.com/WebAssembly/wabt) are found in the path.
New features since fork
-----------------------
- **New units**. For example: bit-crusher, gain, inverse gain, clip, modulate
bpm (proper triplets!), compressor (can be used for side-chaining).
- **Compiler**. Written in go. The input is a .yml file and the output is an
@ -415,7 +440,9 @@ so I thought it would fun to learn some Finnish for a change. And
Prods using Sointu
------------------
[Adam](https://github.com/vsariola/adam) by brainlez Coders! - My first test-driving of Sointu. Some ideas how to integrate Sointu to the build chain.
[Adam](https://github.com/vsariola/adam) by brainlez Coders! - My first
test-driving of Sointu. Some ideas how to integrate Sointu to the build
chain.
Credits
-------

View File

@ -0,0 +1,7 @@
//go:build native
package cmd
import "github.com/vsariola/sointu/vm/compiler/bridge"
var DefaultService = bridge.BridgeService{}

View File

@ -0,0 +1,7 @@
//go:build !native
package cmd
import "github.com/vsariola/sointu/vm"
var DefaultService = vm.SynthService{}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
View 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
View File

@ -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
View File

@ -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=

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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 {

View File

@ -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()
}

View File

@ -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),
)
}

View File

@ -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),

View File

@ -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()
}

View 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
}

View 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
}

View File

@ -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
View 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}
}

View File

@ -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
View 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
}

View File

@ -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
}

View File

@ -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

View File

@ -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)

View File

@ -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 {

View File

@ -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")
}
})
}
}