diff --git a/README.md b/README.md index 36b0750..31d2d4f 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,20 @@ New features since fork - **A bytecode interpreter written in pure go**. It's slightly slower than the hand-written assembly code by sointu compiler, but with this, the tracker is ultraportable and does not need cgo calls. + - **Using Sointu as a sync-tracker**. Similar to [GNU + Rocket](https://github.com/yupferris/gnurocket), but (ab)using the tracker + we already have for music. We use the Go "rpc" package to send current sync + values from the new "sync" opcode + optionally the current fractional row + the song is on. The syncs are saved every 256th sample (approximately 172 + Hz). For 4k intro development, the idea is to write a debug version of the + intro that merely loads the shader and listens to the RPC messages, and then + draws the shader with those as the uniforms. Then, during the actual 4k + intro, one can get sync the data from Sointu: when using syncs, + su_render_song takes two buffer parameters, one for sound, another for + syncs. These can then be sent to the shader as a uniform float array. A + track with two voices, triggering an instrument with a single envelope and a + slow filter can even be used as a cheap smooth interpolation mechanism, + provided the syncs are added to each other in the shader. Future goals ------------ @@ -327,15 +341,6 @@ Future goals bit flag in the existing filter - Arbitrary envelopes; for easier automation. - **MIDI support for the tracker**. - - **Reintroduce the sync mechanism**. 4klang could export the envelopes of all - instruments at a 256 times lower frequency, with the purpose of using them - as sync data. This feature was removed at some point, but should be - reintroduced at some point. Need to investigate the best way to implement - this; maybe a "sync" opcode that save the current signal from the stack? Or - reusing sends/outs and having special sync output ports, allowing easily - combining multiple signals into one sync. Oh, and we probably should dump - the whole thing also as a texture to the shader; to fly through the song, in - a very literal way. - **Find a solution for denormalized signals**. Denormalized floating point numbers (floating point numbers that are very very small) can result in 100x CPU slow down. We got hit by this already: the damp filters in delay units @@ -347,16 +352,6 @@ Future goals Crazy ideas ----------- - - **Using Sointu as a sync-tracker**. Similar to [GNU - Rocket](https://github.com/yupferris/gnurocket), but (ab)using the tracker - we already have for music. We could define a generic RPC protocol for Sointu - tracker send current sync values and time; one could then write a debug - version of a 4k intro that merely loads the shader and listens to the RPC - messages, and then draws the shader with those as the uniforms. Then, during - the actual 4k intro, just render song, get sync data from Sointu and send as - uniforms to shader. A track with two voices, triggering an instrument with a - single envelope and a slow filter can even be used as a cheap smooth - interpolation mechanism. - **Hack deeper into audio sources from the OS**. Speech synthesis, I'm eyeing at you. diff --git a/cmd/sointu-compile/main.go b/cmd/sointu-compile/main.go index 817de48..4b801fc 100644 --- a/cmd/sointu-compile/main.go +++ b/cmd/sointu-compile/main.go @@ -33,6 +33,7 @@ func main() { list := flag.Bool("l", false, "Do not write files; just list files that would change instead.") stdout := flag.Bool("s", false, "Do not write files; write to standard output instead.") help := flag.Bool("h", false, "Show help.") + rowsync := flag.Bool("r", false, "Write the current fractional row as sync #0") library := flag.Bool("a", false, "Compile Sointu into a library. Input files are not needed.") jsonOut := flag.Bool("j", false, "Output the song as .json file instead of compiling.") yamlOut := flag.Bool("y", false, "Output the song as .yml file instead of compiling.") @@ -53,9 +54,9 @@ func main() { if compile || *library { var err error if *tmplDir != "" { - comp, err = compiler.NewFromTemplates(*targetOs, *targetArch, *output16bit, *tmplDir) + comp, err = compiler.NewFromTemplates(*targetOs, *targetArch, *output16bit, *rowsync, *tmplDir) } else { - comp, err = compiler.New(*targetOs, *targetArch, *output16bit) + comp, err = compiler.New(*targetOs, *targetArch, *output16bit, *rowsync) } if err != nil { fmt.Fprintf(os.Stderr, `error creating compiler: %v`, err) diff --git a/cmd/sointu-nativetrack/main.go b/cmd/sointu-nativetrack/main.go index 9920acd..78c7223 100644 --- a/cmd/sointu-nativetrack/main.go +++ b/cmd/sointu-nativetrack/main.go @@ -17,5 +17,7 @@ func main() { } defer audioContext.Close() synthService := bridge.BridgeService{} - gioui.Main(audioContext, synthService) + // TODO: native track does not support syncing at the moment (which is why + // we pass nil), as the native bridge does not support sync data + gioui.Main(audioContext, synthService, nil) } diff --git a/cmd/sointu-play/main.go b/cmd/sointu-play/main.go index 2a05079..da00820 100644 --- a/cmd/sointu-play/main.go +++ b/cmd/sointu-play/main.go @@ -92,7 +92,7 @@ func main() { synth.Release(i) } } - buffer, err := sointu.Play(synth, song) // render the song to calculate its length + buffer, _, err := sointu.Play(synth, song) // render the song to calculate its length if err != nil { return fmt.Errorf("sointu.Play failed: %v", err) } diff --git a/cmd/sointu-track/main.go b/cmd/sointu-track/main.go index ffbfdb0..433f2bf 100644 --- a/cmd/sointu-track/main.go +++ b/cmd/sointu-track/main.go @@ -1,21 +1,33 @@ package main import ( + "flag" "fmt" "os" "github.com/vsariola/sointu/oto" + "github.com/vsariola/sointu/rpc" "github.com/vsariola/sointu/tracker/gioui" "github.com/vsariola/sointu/vm" ) func main() { + syncAddress := flag.String("address", "", "remote RPC server where to send sync data") + flag.Parse() audioContext, err := oto.NewContext() if err != nil { fmt.Println(err) 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) + } + } synthService := vm.SynthService{} - gioui.Main(audioContext, synthService) + gioui.Main(audioContext, synthService, syncChannel) } diff --git a/patch.go b/patch.go index 5bb901d..5fd14ff 100644 --- a/patch.go +++ b/patch.go @@ -37,6 +37,18 @@ func (p Patch) NumDelayLines() int { return total } +func (p Patch) NumSyncs() int { + total := 0 + for _, instr := range p { + for _, unit := range instr.Units { + if unit.Type == "sync" { + total += instr.NumVoices + } + } + } + return total +} + func (p Patch) FirstVoiceForInstrument(instrIndex int) int { ret := 0 for _, t := range p[:instrIndex] { diff --git a/rpc/rpc.go b/rpc/rpc.go new file mode 100644 index 0000000..a6dad65 --- /dev/null +++ b/rpc/rpc.go @@ -0,0 +1,57 @@ +package rpc + +import ( + "fmt" + "log" + "net" + "net/http" + "net/rpc" +) + +type SyncServer struct { + channel chan []float32 +} + +func (s *SyncServer) Sync(syncData []float32, reply *int) error { + select { + case s.channel <- syncData: + default: + } + return nil +} + +func Receiver() (<-chan []float32, error) { + c := make(chan []float32, 1) + server := &SyncServer{channel: c} + rpc.Register(server) + rpc.HandleHTTP() + l, e := net.Listen("tcp", ":31337") + if e != nil { + log.Fatal("listen error:", e) + return nil, fmt.Errorf("net.listen failed: %v", e) + } + go func() { + defer close(c) + http.Serve(l, nil) + }() + return c, nil +} + +func Sender(serverAddress string) (chan<- []float32, error) { + c := make(chan []float32, 256) + client, err := rpc.DialHTTP("tcp", serverAddress+":31337") + if err != nil { + log.Fatal("dialing:", err) + return nil, fmt.Errorf("rpc.DialHTTP failed: %v", err) + } + go func() { + for msg := range c { + var reply int + err = client.Call("SyncServer.Sync", msg, &reply) + if err != nil { + log.Fatal("SyncServer.Sync error:", err) + } + } + }() + return c, nil +} diff --git a/rpc/rpc_test.go b/rpc/rpc_test.go new file mode 100644 index 0000000..44918f6 --- /dev/null +++ b/rpc/rpc_test.go @@ -0,0 +1,24 @@ +package rpc_test + +import ( + "testing" + + "github.com/vsariola/sointu/rpc" +) + +func TestSendReceive(t *testing.T) { + receiver, err := rpc.Receiver() + if err != nil { + t.Fatalf("rpc.Receiver error: %v", err) + } + sender, err := rpc.Sender("127.0.0.1") + if err != nil { + t.Fatalf("rpc.Sender error: %v", err) + } + value := []float32{42} + sender <- value + valueGot := <-receiver + if valueGot[0] != value[0] { + t.Fatalf("rpc.Sender error: %v", err) + } +} diff --git a/synth.go b/synth.go index 219993b..3093805 100644 --- a/synth.go +++ b/synth.go @@ -7,7 +7,7 @@ import ( ) type Synth interface { - Render(buffer []float32, maxtime int) (int, int, error) + Render(buffer []float32, syncBuffer []float32, maxtime int) (sample int, syncs int, time int, err error) Update(patch Patch) error Trigger(voice int, note byte) Release(voice int) @@ -18,7 +18,7 @@ type SynthService interface { } func Render(synth Synth, buffer []float32) error { - s, _, err := synth.Render(buffer, math.MaxInt32) + s, _, _, err := synth.Render(buffer, nil, math.MaxInt32) if err != nil { return fmt.Errorf("sointu.Render failed: %v", err) } @@ -28,10 +28,10 @@ func Render(synth Synth, buffer []float32) error { return nil } -func Play(synth Synth, song Song) ([]float32, error) { +func Play(synth Synth, song Song) ([]float32, []float32, error) { err := song.Validate() if err != nil { - return nil, err + return nil, nil, err } curVoices := make([]int, len(song.Score.Tracks)) for i := range curVoices { @@ -39,7 +39,10 @@ func Play(synth Synth, song Song) ([]float32, error) { } initialCapacity := song.Score.LengthInRows() * song.SamplesPerRow() * 2 buffer := make([]float32, 0, initialCapacity) + syncBuffer := make([]float32, 0, initialCapacity) rowbuffer := make([]float32, song.SamplesPerRow()*2) + numSyncs := song.Patch.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 @@ -73,16 +76,22 @@ func Play(synth Synth, song Song) ([]float32, error) { } tries := 0 for rowtime := 0; rowtime < song.SamplesPerRow(); { - samples, time, err := synth.Render(rowbuffer, song.SamplesPerRow()-rowtime) + 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 + } if err != nil { - return buffer, fmt.Errorf("render failed: %v", err) + return buffer, syncBuffer, fmt.Errorf("render failed: %v", err) } rowtime += time buffer = append(buffer, rowbuffer[:samples*2]...) + syncBuffer = append(syncBuffer, syncRowBuffer[:syncs]...) if tries > 100 { - return nil, fmt.Errorf("Song speed modulation likely so slow that row never advances; error at pattern %v, row %v", pattern, patternRow) + return nil, nil, fmt.Errorf("Song speed modulation likely so slow that row never advances; error at pattern %v, row %v", pattern, patternRow) } } } - return buffer, nil + return buffer, syncBuffer, nil } diff --git a/templates/amd64-386/flowcontrol.asm b/templates/amd64-386/flowcontrol.asm index 12c4980..6e34e66 100644 --- a/templates/amd64-386/flowcontrol.asm +++ b/templates/amd64-386/flowcontrol.asm @@ -21,3 +21,24 @@ fstp dword [{{.WRK}}] ; save the remainder for future ret {{end}} + + +{{- if or .RowSync (.HasOp "sync")}} +;------------------------------------------------------------------------------- +; SYNC opcode: save the stack top to sync buffer +;------------------------------------------------------------------------------- +{{.Func "su_op_sync" "Opcode"}} +{{- if not .Library}} + ; TODO: syncs are NOPs when compiling as library, should figure out a way to + ; make them work when compiling to use the native track also + mov {{.AX}}, [{{.Stack "GlobalTick"}}] + test al, al + jne su_op_sync_skip + xchg {{.AX}}, [{{.Stack "SyncBufPtr"}}] + fst dword [{{.AX}}] + add {{.AX}}, 4 + xchg {{.AX}}, [{{.Stack "SyncBufPtr"}}] +su_op_sync_skip: +{{- end}} + ret +{{end}} diff --git a/templates/amd64-386/patch.asm b/templates/amd64-386/patch.asm index edcc490..a287292 100644 --- a/templates/amd64-386/patch.asm +++ b/templates/amd64-386/patch.asm @@ -14,6 +14,14 @@ ;------------------------------------------------------------------------------- {{.Func "su_run_vm"}} {{- .PushRegs .CX "DelayWorkSpace" .DX "Synth" .COM "CommandStream" .WRK "Voice" .VAL "ValueStream" | indent 4}} + {{- if .RowSync}} + fild dword [{{.Stack "Sample"}}] + {{.Int .Song.SamplesPerRow | .Prepare | indent 8}} + fidiv dword [{{.Int .Song.SamplesPerRow | .Use}}] + fiadd dword [{{.Stack "Row"}}] + {{.Call "su_op_sync"}} + fstp st0 + {{- end}} su_run_vm_loop: ; loop until all voices done movzx edi, byte [{{.COM}}] ; edi = command byte inc {{.COM}} ; move to next instruction diff --git a/templates/amd64-386/player.asm b/templates/amd64-386/player.asm index de73750..27442b0 100644 --- a/templates/amd64-386/player.asm +++ b/templates/amd64-386/player.asm @@ -14,12 +14,22 @@ su_synth_obj: ; the output buffer. Renders the compile time hard-coded song to the buffer. ; Stack: output_ptr ;------------------------------------------------------------------------------- +{{- if or .RowSync (.HasOp "sync")}} +{{.ExportFunc "su_render_song" "OutputBufPtr" "SyncBufPtr"}} +{{- else}} {{.ExportFunc "su_render_song" "OutputBufPtr"}} +{{- end}} {{- if .Amd64}} {{- if eq .OS "windows"}} {{- .PushRegs "rcx" "OutputBufPtr" "rdi" "NonVolatileRsi" "rsi" "NonVolatile" "rbx" "NonVolatileRbx" "rbp" "NonVolatileRbp" | indent 4}} ; rcx = ptr to buf. rdi,rsi,rbx,rbp nonvolatile + {{- if or .RowSync (.HasOp "sync")}} + {{- .PushRegs "rdx" "SyncBufPtr" | indent 4}} + {{- end}} {{- else}} ; SystemV amd64 ABI, linux mac or hopefully something similar {{- .PushRegs "rdi" "OutputBufPtr" "rbx" "NonVolatileRbx" "rbp" "NonVolatileRbp" | indent 4}} + {{- if or .RowSync (.HasOp "sync")}} + {{- .PushRegs "rsi" "SyncBufPtr" | indent 4}} + {{- end}} {{- end}} {{- else}} {{- .PushRegs | indent 4}} @@ -68,6 +78,9 @@ su_render_sampleloop: ; loop through every sample in the row {{$.Pop $.AX}} {{- end}} {{- if .Amd64}} + {{- if or .RowSync (.HasOp "sync")}} + {{.Pop .AX}} ; pop the sync buf ptr away + {{- end}} {{- if eq .OS "windows"}} ; Windows64 ABI, rdi rsi rbx rbp non-volatile {{- .PopRegs "rcx" "rdi" "rsi" "rbx" "rbp" | indent 4}} @@ -78,8 +91,12 @@ su_render_sampleloop: ; loop through every sample in the row ret {{- else}} {{- .PopRegs | indent 4}} + {{- if or .RowSync (.HasOp "sync")}} + ret 8 + {{- else}} ret 4 {{- end}} + {{- end}} ;------------------------------------------------------------------------------- ; su_update_voices function: polyphonic & chord implementation diff --git a/templates/amd64-386/player.h b/templates/amd64-386/player.h index 23e23a5..a78431a 100644 --- a/templates/amd64-386/player.h +++ b/templates/amd64-386/player.h @@ -13,6 +13,15 @@ #define SU_LENGTH_IN_ROWS (SU_LENGTH_IN_PATTERNS*SU_PATTERN_SIZE) #define SU_SAMPLES_PER_ROW (SU_SAMPLE_RATE*60/(SU_BPM*SU_ROWS_PER_BEAT)) +{{- if or .RowSync (.HasOp "sync")}} +{{- if .RowSync}} +#define SU_NUMSYNCS {{add1 .Song.Patch.NumSyncs}} +{{- else}} +#define SU_NUMSYNCS {{.Song.Patch.NumSyncs}} +{{- end}} +#define SU_SYNCBUFFER_LENGTH ((SU_LENGTH_IN_SAMPLES+255)>>8)*SU_NUMSYNCS +{{- end}} + #include #if UINTPTR_MAX == 0xffffffff #if defined(__clang__) || defined(__GNUC__) @@ -39,7 +48,12 @@ typedef float SUsample; extern "C" { #endif +{{- if or .RowSync (.HasOp "sync")}} +void SU_CALLCONV su_render_song(SUsample *buffer,float *syncBuffer); +#define SU_SYNC +{{- else}} void SU_CALLCONV su_render_song(SUsample *buffer); +{{- end}} {{- if gt (.SampleOffsets | len) 0}} void SU_CALLCONV su_load_gmdls(); #define SU_LOAD_GMDLS diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6617c50..eb0adea 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,10 +1,13 @@ function(regression_test testname) - if(${ARGC} LESS 6) - if(${ARGC} LESS 4) + if(ARGV5) + set(source ${ARGV5}) + add_executable(${testname} ${source} test_renderer.c) + else() + if(ARGV3) + set(source ${ARGV3}.yml) + else() set(source ${testname}.yml) - else() - set(source ${ARGV3}.yml) endif() set(asmfile ${testname}.asm) @@ -19,7 +22,7 @@ function(regression_test testname) add_executable(${testname} test_renderer.c ${asmfile}) target_compile_definitions(${testname} PUBLIC TEST_HEADER=<${testname}.h>) - if (NODE AND WAT2WASM AND NOT ${testname} MATCHES "sample") + if (NODE AND WAT2WASM AND NOT ${testname} MATCHES "sample" AND NOT ${testname} MATCHES "sync") set(wasmfile ${CMAKE_CURRENT_BINARY_DIR}/${testname}.wasm) set(watfile ${CMAKE_CURRENT_BINARY_DIR}/${testname}.wat) set(wasmtarget wasm_${testname}) @@ -29,31 +32,29 @@ function(regression_test testname) DEPENDS sointu-compiler ) add_test(${wasmtarget} ${NODE} ${CMAKE_CURRENT_SOURCE_DIR}/wasm_test_renderer.es6 ${wasmfile} ${CMAKE_CURRENT_SOURCE_DIR}/expected_output/${testname}.raw) - endif() - else() - set(source ${ARGV5}) - add_executable(${testname} ${source} test_renderer.c) + endif() endif() - add_test(${testname} ${testname} ${CMAKE_CURRENT_SOURCE_DIR}/expected_output/${testname}.raw) + if (${testname} MATCHES "sync") + add_test(${testname} ${testname} ${CMAKE_CURRENT_SOURCE_DIR}/expected_output/${testname}.raw ${CMAKE_CURRENT_SOURCE_DIR}/expected_output/${testname}_syncbuf.raw) + else() + add_test(${testname} ${testname} ${CMAKE_CURRENT_SOURCE_DIR}/expected_output/${testname}.raw) + endif() target_link_libraries(${testname} ${HEADERLIB}) target_include_directories(${testname} PUBLIC ${CMAKE_CURRENT_BINARY_DIR}) target_compile_definitions(${testname} PUBLIC TEST_NAME="${testname}") - - if(ARGC GREATER 1) - if (ARGV1) - message("${testname} requires ${ARGV1}") - set_tests_properties(${testname} PROPERTIES FIXTURES_REQUIRED "${ARGV1}") - endif() + + if (ARGV1) + message("${testname} requires ${ARGV1}") + set_tests_properties(${testname} PROPERTIES FIXTURES_REQUIRED "${ARGV1}") + endif() + + if (ARGV2) + message("${testname} setups ${ARGV2}") + set_tests_properties(${testname} PROPERTIES FIXTURES_SETUP "${ARGV2}") endif() - if(ARGC GREATER 2) - if (ARGV2) - message("${testname} setups ${ARGV2}") - set_tests_properties(${testname} PROPERTIES FIXTURES_SETUP "${ARGV2}") - endif() - endif() endfunction(regression_test) regression_test(test_envelope "" ENVELOPE) @@ -157,6 +158,7 @@ regression_test(test_envelope_16bit ENVELOPE "" test_envelope "-i") regression_test(test_polyphony "ENVELOPE;VCO_SINE") regression_test(test_chords "ENVELOPE;VCO_SINE") regression_test(test_speed "ENVELOPE;VCO_SINE") +regression_test(test_sync "ENVELOPE" "" "" "-r") regression_test(test_render_samples ENVELOPE "" "" "" test_render_samples.c) target_link_libraries(test_render_samples ${STATICLIB}) diff --git a/tests/expected_output/test_sync.raw b/tests/expected_output/test_sync.raw new file mode 100644 index 0000000..5b81d2f Binary files /dev/null and b/tests/expected_output/test_sync.raw differ diff --git a/tests/expected_output/test_sync_syncbuf.raw b/tests/expected_output/test_sync_syncbuf.raw new file mode 100644 index 0000000..d7e9033 Binary files /dev/null and b/tests/expected_output/test_sync_syncbuf.raw differ diff --git a/tests/test_renderer.c b/tests/test_renderer.c index 997cc5b..40fe45e 100644 --- a/tests/test_renderer.c +++ b/tests/test_renderer.c @@ -16,6 +16,11 @@ #include TEST_HEADER SUsample buf[SU_BUFFER_LENGTH]; SUsample filebuf[SU_BUFFER_LENGTH]; +#ifdef SU_SYNC +float syncBuf[SU_SYNCBUFFER_LENGTH]; +float fileSyncBuf[SU_BUFFER_LENGTH]; +#endif + int main(int argc, char* argv[]) { FILE* f; @@ -36,7 +41,11 @@ int main(int argc, char* argv[]) { su_load_gmdls(); #endif + #ifdef SU_SYNC + su_render_song(buf, syncBuf); + #else su_render_song(buf); + #endif #if defined (_WIN32) CreateDirectory(actual_output_folder, NULL); @@ -49,6 +58,13 @@ int main(int argc, char* argv[]) { fwrite((void*)buf, sizeof(SUsample), SU_BUFFER_LENGTH, f); fclose(f); + #ifdef SU_SYNC + snprintf(filename, sizeof filename, "%s%s%s", actual_output_folder, test_name, "_syncbuf.raw"); + f = fopen(filename, "wb"); + fwrite((void*)syncBuf, sizeof(float), SU_SYNCBUFFER_LENGTH, f); + fclose(f); + #endif + f = fopen(argv[1], "rb"); if (f == NULL) { @@ -92,6 +108,42 @@ int main(int argc, char* argv[]) { printf("Warning: Sointu rendered almost correct wave, but a small maximum error of %f\n",max_diff); } +#ifdef SU_SYNC + f = fopen(argv[2], "rb"); + + if (f == NULL) { + printf("No expected sync waveform found!\n"); + goto fail; + } + + fseek(f, 0, SEEK_END); + fsize = ftell(f); + fseek(f, 0, SEEK_SET); + + if (SU_SYNCBUFFER_LENGTH * sizeof(float) < fsize) { + printf("Sointu rendered shorter sync wave than expected\n"); + goto fail; + } + + if (SU_SYNCBUFFER_LENGTH * sizeof(float) > fsize) { + printf("Sointu rendered longer sync wave than expected\n"); + goto fail; + } + + fread((void*)fileSyncBuf, fsize, 1, f); + fclose(f); + f = NULL; + + max_diff = 0.0f; + + for (n = 0; n < SU_SYNCBUFFER_LENGTH; n++) { + diff = (float)fabs(syncBuf[n] - fileSyncBuf[n]); + if (diff > 1e-3f || isnan(diff)) { + printf("Sointu rendered different sync wave than expected\n"); + goto fail; + } + } +#endif return 0; fail: diff --git a/tests/test_sync.yml b/tests/test_sync.yml new file mode 100644 index 0000000..92da414 --- /dev/null +++ b/tests/test_sync.yml @@ -0,0 +1,20 @@ +bpm: 100 +rowsperbeat: 4 +score: + rowsperpattern: 16 + length: 2 + tracks: + - numvoices: 1 + order: [0, 0] + patterns: [[64, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]] +patch: + - numvoices: 1 + units: + - type: envelope + parameters: {attack: 64, decay: 64, gain: 128, release: 80, stereo: 0, sustain: 64} + - type: sync + - type: envelope + parameters: {attack: 95, decay: 64, gain: 128, release: 80, stereo: 0, sustain: 64} + - type: sync + - type: out + parameters: {gain: 128, stereo: 1} diff --git a/tracker/defaultsong.go b/tracker/defaultsong.go index 1ea0ef7..eec10b1 100644 --- a/tracker/defaultsong.go +++ b/tracker/defaultsong.go @@ -40,6 +40,7 @@ var defaultUnits = map[string]sointu.Unit{ "speed": {Type: "speed", Parameters: map[string]int{}}, "compressor": {Type: "compressor", Parameters: map[string]int{"stereo": 0, "attack": 64, "release": 64, "invgain": 64, "threshold": 64, "ratio": 64}}, "send": {Type: "send", Parameters: map[string]int{"stereo": 0, "amount": 128, "voice": 0, "unit": 0, "port": 0, "sendpop": 1}}, + "sync": {Type: "sync", Parameters: map[string]int{}}, } var defaultInstrument = sointu.Instrument{ diff --git a/tracker/gioui/run.go b/tracker/gioui/run.go index 0c05990..c6b8caa 100644 --- a/tracker/gioui/run.go +++ b/tracker/gioui/run.go @@ -51,13 +51,13 @@ func (t *Tracker) Run(w *app.Window) error { } } -func Main(audioContext sointu.AudioContext, synthService sointu.SynthService) { +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) + t := New(audioContext, synthService, syncChannel) defer t.Close() if err := t.Run(w); err != nil { fmt.Println(err) diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index be9d967..4bc7b80 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -99,7 +99,7 @@ func (t *Tracker) Close() { t.audioContext.Close() } -func New(audioContext sointu.AudioContext, synthService sointu.SynthService) *Tracker { +func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syncChannel chan<- []float32) *Tracker { t := &Tracker{ Theme: material.NewTheme(gofont.Collection()), audioContext: audioContext, @@ -160,7 +160,7 @@ func New(audioContext sointu.AudioContext, synthService sointu.SynthService) *Tr sprObserver := make(chan int, 16) t.AddSamplesPerRowObserver(sprObserver) audioChannel := make(chan []float32) - t.player = tracker.NewPlayer(synthService, t.playerCloser, patchObserver, scoreObserver, sprObserver, t.refresh, audioChannel, vuBufferObserver) + t.player = tracker.NewPlayer(synthService, t.playerCloser, patchObserver, scoreObserver, sprObserver, t.refresh, syncChannel, audioChannel, vuBufferObserver) audioOut := audioContext.Output() go func() { for buf := range audioChannel { diff --git a/tracker/player.go b/tracker/player.go index ddec090..192b1a2 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -66,13 +66,16 @@ func (p *Player) Enabled() bool { return atomic.LoadInt32(&p.synthNotNil) == 1 } -func NewPlayer(service sointu.SynthService, closer <-chan struct{}, patchs <-chan sointu.Patch, scores <-chan sointu.Score, samplesPerRows <-chan int, posChanged chan<- struct{}, outputs ...chan<- []float32) *Player { +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 @@ -103,6 +106,9 @@ func NewPlayer(service sointu.SynthService, closer <-chan struct{}, patchs <-cha } } } + 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 { @@ -165,17 +171,29 @@ func NewPlayer(service sointu.SynthService, closer <-chan struct{}, patchs <-cha renderTime = math.MaxInt32 } p.mutex.Lock() - rendered, timeAdvanced, err := p.synth.Render(buffer, renderTime) + 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 _, o := range outputs { o <- buffer[:rendered*2] } buffer2, buffer = buffer, buffer2 + syncBuffer2, syncBuffer = syncBuffer, syncBuffer2 } else { rowTime += len(zeros) / 2 for _, o := range outputs { diff --git a/unittype.go b/unittype.go index 818e322..424f11a 100644 --- a/unittype.go +++ b/unittype.go @@ -131,6 +131,7 @@ var UnitTypes = map[string]([]UnitParameter){ "in": []UnitParameter{ {Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}, {Name: "channel", MinValue: 0, MaxValue: 6, CanSet: true, CanModulate: false}}, + "sync": []UnitParameter{}, } var Ports = make(map[string]([]string)) diff --git a/vm/compiler/bridge/bridge.go b/vm/compiler/bridge/bridge.go index 71da140..fb77d5e 100644 --- a/vm/compiler/bridge/bridge.go +++ b/vm/compiler/bridge/bridge.go @@ -70,17 +70,18 @@ func Synth(patch sointu.Patch) (*C.Synth, error) { // 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, maxtime int) (int, int, error) { +func (synth *C.Synth) Render(buffer []float32, syncBuffer []float32, maxtime int) (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, errors.New("RenderTime writes stereo signals, so buffer should have even length") + return -1, -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), int(time), &RenderError{errcode: errcode} + return int(samples), 0, int(time), &RenderError{errcode: errcode} } - return int(samples), int(time), nil + return int(samples), 0, int(time), nil } // Trigger is part of C.Synths' implementation of sointu.Synth interface diff --git a/vm/compiler/bridge/bridge_test.go b/vm/compiler/bridge/bridge_test.go index c4627b1..af01ddc 100644 --- a/vm/compiler/bridge/bridge_test.go +++ b/vm/compiler/bridge/bridge_test.go @@ -44,7 +44,7 @@ func TestOscillatSine(t *testing.T) { if err != nil { t.Fatalf("Compiling patch failed: %v", err) } - buffer, err := sointu.Play(synth, song) + buffer, _, err := sointu.Play(synth, song) if err != nil { t.Fatalf("Render failed: %v", err) } @@ -103,7 +103,7 @@ func TestAllRegressionTests(t *testing.T) { if err != nil { t.Fatalf("Compiling patch failed: %v", err) } - buffer, err := sointu.Play(synth, song) + buffer, _, err := sointu.Play(synth, song) buffer = buffer[:song.Score.LengthInRows()*song.SamplesPerRow()*2] // extend to the nominal length always. if err != nil { t.Fatalf("Play failed: %v", err) diff --git a/vm/compiler/compiler.go b/vm/compiler/compiler.go index f102061..8049c41 100644 --- a/vm/compiler/compiler.go +++ b/vm/compiler/compiler.go @@ -18,10 +18,11 @@ type Compiler struct { OS string Arch string Output16Bit bool + RowSync bool } // New returns a new compiler using the default .asm templates -func New(os string, arch string, output16Bit bool) (*Compiler, error) { +func New(os string, arch string, output16Bit bool, rowsync bool) (*Compiler, error) { _, myname, _, _ := runtime.Caller(0) var subdir string if arch == "386" || arch == "amd64" { @@ -32,17 +33,17 @@ func New(os string, arch string, output16Bit bool) (*Compiler, error) { return nil, fmt.Errorf("compiler.New failed, because only amd64, 386 and wasm archs are supported (targeted architecture was %v)", arch) } templateDir := filepath.Join(path.Dir(myname), "..", "..", "templates", subdir) - compiler, err := NewFromTemplates(os, arch, output16Bit, templateDir) + compiler, err := NewFromTemplates(os, arch, output16Bit, rowsync, templateDir) return compiler, err } -func NewFromTemplates(os string, arch string, output16Bit bool, templateDirectory string) (*Compiler, error) { +func NewFromTemplates(os string, arch string, output16Bit bool, rowsync bool, templateDirectory string) (*Compiler, error) { globPtrn := filepath.Join(templateDirectory, "*.*") tmpl, err := template.New("base").Funcs(sprig.TxtFuncMap()).ParseGlob(globPtrn) if err != nil { return nil, fmt.Errorf(`could not create template based on directory "%v": %v`, templateDirectory, err) } - return &Compiler{Template: tmpl, OS: os, Arch: arch, Output16Bit: output16Bit}, nil + return &Compiler{Template: tmpl, OS: os, Arch: arch, RowSync: rowsync, Output16Bit: output16Bit}, nil } func (com *Compiler) Library() (map[string]string, error) { diff --git a/vm/compiler/x86_macros.go b/vm/compiler/x86_macros.go index 88f0656..4175710 100644 --- a/vm/compiler/x86_macros.go +++ b/vm/compiler/x86_macros.go @@ -354,15 +354,25 @@ func (p *X86Macros) FmtStack() string { } func (p *X86Macros) ExportFunc(name string, params ...string) string { - if !p.Amd64 { - reverseParams := make([]string, len(params)) - for i, param := range params { - reverseParams[len(params)-1-i] = param - } - p.Stacklocs = append(reverseParams, "retaddr_"+name) // in 32-bit, we use stdcall and parameters are in the stack - if p.OS == "windows" { - return fmt.Sprintf("%[1]v\nglobal _%[2]v@%[3]v\n_%[2]v@%[3]v:", p.SectText(name), name, len(params)*4) - } + numRegisters := 0 // in 32-bit systems, we use stdcall: everything in stack + switch { + case p.Amd64 && p.OS == "windows": + numRegisters = 4 // 64-bit windows has 4 parameters in registers, rest in stack + case p.Amd64: + numRegisters = 6 // System V ABI has 6 parameters in registers, rest in stack + } + if len(params) > numRegisters { + params = params[numRegisters:] + } else { + params = nil + } + reverseParams := make([]string, len(params)) + for i, param := range params { + reverseParams[len(params)-1-i] = param + } + p.Stacklocs = append(reverseParams, "retaddr_"+name) // in 32-bit, we use stdcall and parameters are in the stack + if !p.Amd64 && p.OS == "windows" { + return fmt.Sprintf("%[1]v\nglobal _%[2]v@%[3]v\n_%[2]v@%[3]v:", p.SectText(name), name, len(params)*4) } if p.OS == "darwin" { return fmt.Sprintf("%[1]v\nglobal _%[2]v\n_%[2]v:", p.SectText(name), name) diff --git a/vm/interpreter.go b/vm/interpreter.go index 3751ea5..a81285e 100644 --- a/vm/interpreter.go +++ b/vm/interpreter.go @@ -115,7 +115,7 @@ func (s *Interpreter) Update(patch sointu.Patch) error { return nil } -func (s *Interpreter) Render(buffer []float32, maxtime int) (samples int, time int, renderError error) { +func (s *Interpreter) Render(buffer []float32, syncBuf []float32, maxtime int) (samples int, syncs int, time int, renderError error) { defer func() { if err := recover(); err != nil { renderError = fmt.Errorf("render panicced: %v", err) @@ -133,6 +133,10 @@ func (s *Interpreter) Render(buffer []float32, maxtime int) (samples int, time i 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:] @@ -152,7 +156,7 @@ func (s *Interpreter) Render(buffer []float32, maxtime int) (samples int, time i } tcount := transformCounts[opNoStereo-1] if len(values) < tcount { - return samples, time, errors.New("value stream ended prematurely") + return samples, syncs, time, errors.New("value stream ended prematurely") } voice := &voices[0] unit := &units[0] @@ -523,16 +527,20 @@ func (s *Interpreter) Render(buffer []float32, maxtime int) (samples int, time i if stereo { stack = append(stack, gain) } + case opSync: + if byte(s.synth.globalTime) == 0 { // every 256 samples + syncBuf[0], syncBuf = float32(stack[l-1]), syncBuf[1:] + } default: - return samples, time, errors.New("invalid / unimplemented opcode") + return samples, syncs, time, errors.New("invalid / unimplemented opcode") } units = units[1:] } if len(stack) < 4 { - return samples, time, errors.New("stack underflow") + return samples, syncs, time, errors.New("stack underflow") } if len(stack) > 4 { - return samples, time, errors.New("stack not empty") + return samples, syncs, time, errors.New("stack not empty") } buffer[0] = synth.outputs[0] buffer[1] = synth.outputs[1] @@ -544,7 +552,7 @@ func (s *Interpreter) Render(buffer []float32, maxtime int) (samples int, time i s.synth.globalTime++ } s.stack = stack[:0] - return samples, time, nil + return samples, syncs, time, nil } func (s *synth) rand() float32 { diff --git a/vm/interpreter_test.go b/vm/interpreter_test.go index 20b97a0..591a8a8 100644 --- a/vm/interpreter_test.go +++ b/vm/interpreter_test.go @@ -45,7 +45,7 @@ func TestAllRegressionTests(t *testing.T) { if err != nil { t.Fatalf("Compiling patch failed: %v", err) } - buffer, err := sointu.Play(synth, song) + buffer, _, err := sointu.Play(synth, song) buffer = buffer[:song.Score.LengthInRows()*song.SamplesPerRow()*2] // extend to the nominal length always. if err != nil { t.Fatalf("Play failed: %v", err) diff --git a/vm/opcodes.go b/vm/opcodes.go index 8d14d10..4677386 100644 --- a/vm/opcodes.go +++ b/vm/opcodes.go @@ -30,7 +30,8 @@ const ( opReceive = 26 opSend = 27 opSpeed = 28 - opXch = 29 + opSync = 29 + opXch = 30 ) -var transformCounts = [...]int{0, 0, 1, 0, 5, 1, 4, 1, 5, 2, 1, 1, 0, 1, 0, 1, 0, 0, 2, 6, 1, 2, 1, 0, 0, 0, 1, 0, 0} +var transformCounts = [...]int{0, 0, 1, 0, 5, 1, 4, 1, 5, 2, 1, 1, 0, 1, 0, 1, 0, 0, 2, 6, 1, 2, 1, 0, 0, 0, 1, 0, 0, 0}