sointu

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 5, 2025 License: MIT Imports: 10 Imported by: 0

README

Sointu

Tests Binaries

A cross-architecture and cross-platform modular software synthesizer for small intros, forked from 4klang. Targetable architectures include 386, amd64, and WebAssembly; targetable platforms include Windows, Mac, Linux (and related) + browser.

  • User manual is in the Wiki
  • Discussions is for asking help, sharing patches/instruments and brainstorming ideas
  • Issues is for reporting bugs

Installation

You can either:

  1. Download the latest build from the master branch from the actions (find workflow "Binaries" and scroll down for .zip files containing the artifacts. Note: You have to be logged into Github to download artifacts!

or

  1. Download the prebuilt release binaries from the releases. Then just run one of the executables or, in the case of the VST plugins library files, copy them wherever you keep you VST2 plugins.

The pre 1.0 version tags are mostly for reference: no backwards compatibility will be guaranteed while upgrading to a newer version. Backwards compatibility will be attempted from 1.0 onwards.

Uninstallation: Sointu stores recovery data in OS-specific folders e.g. AppData/Roaming/Sointu on Windows. For clean uninstall, delete also this folder. See here where to find those folders on other platforms.

Summary

Sointu is work-in-progress. It is a fork and an evolution of 4klang, a modular software synthesizer intended to easily produce music for 4k intros — small executables with a maximum filesize of 4096 bytes containing realtime audio and visuals. Like 4klang, the sound is produced by a virtual machine that executes small bytecode to produce the audio; however, by now the internal virtual machine has been heavily rewritten and extended. It is actually extended so much that you will never fit all the features at the same time in a 4k intro, but a fairly capable 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 that runs as either VSTi or stand-alone app for composing music, written in go. 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. For browsers, the resulting .wat can be compiled by wat2wasm.

This is how the current prototype app looks like:

Screenshot of the tracker

Building

Various aspects of the project have different tool dependencies, which are listed below.

Sointu-track

This is the stand-alone version of the synth-tracker. Sointu-track uses the gioui for the GUI and oto for the audio, so the portability is currently limited by these.

Prerequisites
  • go
  • If you want to also use the x86 assembly written synthesizer, to test that the patch also works once compiled:
    • Follow the instructions to build the x86 native virtual machine before building the tracker.
    • cgo compatible compiler e.g. gcc. On windows, you best bet is MinGW. We use the tdm-gcc. The compiler can be in PATH or you can use the environment variable CC to help go find the compiler.
    • Setting environment variable CGO_ENABLED=1 is a good idea, because if it is not set and go fails to find the compiler, go just excludes all files with import "C" from the build, resulting in lots of errors about missing types.
Running
go run cmd/sointu-track/main.go
Building an executable
go build -o sointu-track.exe cmd/sointu-track/main.go

On other platforms than Windows, replace -o sointu-track.exe with -o sointu-track.

If you want to include the x86 native virtual machine, add -tags=native to all the commands e.g.

go build -o sointu-track.exe -tags=native cmd/sointu-track/main.go
Sointu-vsti

This is the VST instrument plugin version of the tracker, compiled into a dynamically linked library and ran inside a VST host.

Prerequisites
  • go
  • cgo compatible compiler e.g. gcc. On windows, you best bet is MinGW. We use the tdm-gcc. The compiler can be in PATH or you can use the environment variable CC to help go find the compiler.
  • Setting environment variable CGO_ENABLED=1 is a good idea, because if it is not set and go fails to find the compiler, go just excludes all files with import "C" from the build, resulting in lots of errors about missing types.
  • If you want to build the VSTI with the native x86 assembly written synthesizer:
Building
go build -buildmode=c-shared -tags=plugin -o sointu-vsti.dll .\cmd\sointu-vsti\

On other platforms than Windows, replace -o sointu-vsti.dll appropriately e.g. -o sointu-vsti.so; so far, the VST instrument has been built & tested on Windows and Linux.

Notice the -tags=plugin build tag definition. This is required by the vst2 library; otherwise, you will get a lot of build errors.

Add -tags=native,plugin to use the x86 native virtual machine instead of the virtual machine written in Go.

Sointu-compile

The command line interface to it is sointu-compile and the actual code resides in the compiler package, which is an ordinary go package with no other tool dependencies.

Running
go run cmd/sointu-compile/main.go
Building an executable
go build -o sointu-compile.exe cmd/sointu-compile/main.go

On other platforms than Windows, replace -o sointu-compile.exe with -o sointu-compile.

Usage

The compiler can then be used to compile a .yml song into .asm and .h files. For example:

sointu-compile -arch=386 tests/test_chords.yml
nasm -f win32 test_chords.asm

WebAssembly example:

sointu-compile -arch=wasm tests/test_chords.yml
wat2wasm test_chords.wat

If you are looking for an easy way to compile an executable from a Sointu song (e.g. for a executable music compo), take a look at NR4's Python-based tool for it.

Examples

The folder examples/code contains usage examples for Sointu with winmm and dsound playback under Windows and asound playback under Unix. Source code is available in C and x86 assembly (win32, elf32 and elf64 versions).

To build the examples, use ninja examples.

If you want to target smaller executable sizes, using a compressing linker like Crinkler on Windows is recommended.

The linux examples use ALSA and need libasound2-dev (or libasound2-dev:386) installed. The 386 version also needs pipewire-alsa:386 installed, which is not there by default.

Native virtual machine

The native bridge allows Go to call the Sointu compiled x86 native virtual machine, through cgo, instead of using the Go written bytecode interpreter. With the latest Go compiler, the native virtual machine is actually slower than the Go-written one, but importantly, the native virtual machine allows you to test that the patch also works within the stack limits of the x87 virtual machine, which is the VM used in the compiled intros. In the tracker/VSTi, you can switch between the native synth and the Go synth under the CPU panel in the Song settings.

Before you can actually run it, you need to build the bridge using CMake (thus, this will not work with go get).

Prerequisites

The last point is because the command line player and the tracker use cgo to interface with the synth core, which is compiled into a library. The cgo bridge resides in the package bridge.

Building

Assuming you are using ninja:

mkdir build
cd build
cmake .. -GNinja
ninja sointu

you must build the library inside a directory called 'build' at the root of the project. This is because the path where cgo looks for the library is hard coded to point to build/ in the go files.

Running ninja sointu only builds the static library that Go needs. This is a lot faster than building all the CTests.

You and now run all the Go tests, even the ones that test the native bridge. From the project root folder, run:

go test ./...

Play a song from the command line:

go run -tags=native cmd/sointu-play/main.go tests/test_chords.yml

⚠ 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.

If you are using Yasm instead of Nasm, and you are using MinGW: Yasm 1.3.0 (currently still the latest stable release) and GNU linker do not play nicely along, trashing the BSS layout. The linker had placed our synth object overlapping with DLL call addresses; very funny stuff to debug. See here and the fix here. Since Nasm is nowadays under BSD license, there is absolutely no reason to use Yasm. However, if you do, use a newer nightly build of Yasm that includes the fix.

Tests

There are regression tests that are built as executables, testing that they work the same way when you would link them in an intro.

Prerequisites
  • go
  • CMake with CTest
  • nasm
  • Your favorite CMake compatible c-compiler & build tool. Results have been obtained using Visual Studio 2019, gcc&make on linux, MinGW&mingw32-make, and ninja&AppleClang.
Building and running

Assuming you are using ninja:

mkdir build
cd build
cmake .. -GNinja
ninja
ninja test

Note that this builds 64-bit binaries on 64-bit Windows. To build 32-bit binaries on 64-bit Windows, replace in above:

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.

WebAssembly tests

These are automatically invoked by CTest if node and wat2wasm 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 .asm. It works by inputting the song data to the excellent go text/template package, effectively working as a preprocessor. This allows quite powerful combination: we can handcraft the assembly code to keep the entropy as low as possible, yet we can call arbitrary go functions as "macros". The templates are here and the compiler lives here.
  • Tracker. Written in go. Can run either as a stand-alone app or a vsti plugin.
  • Supports 32 and 64 bit builds. The 64-bit version is done with minimal changes to get it work, using template macros to change the lines between 32-bit and 64-bit modes. Mostly, it's as easy as writing {{.AX}} instead of eax; the macro {{.AX}} compiles to eax in 32-bit and rax in 64-bit.
  • Supports compiling into WebAssembly. This is a complete reimplementation of the core, written in WebAssembly text format (.wat).
  • Supports Windows, Linux and MacOS. On all three 64-bit platforms, all tests are passing. Additionally, all tests are passing on windows 32.
  • Per instrument polyphonism. An instrument has the possibility to have any number of voices, meaning that multiple voices can reuse the same opcodes. So, you can have a single instrument with three voices, and three tracks that use this instrument, to make chords. See here for an example and here for the implementation. The maximum total number of voices is 32: you can have 32 monophonic instruments or any combination of polyphonic instruments adding up to 32.
  • Any number of voices per track. A single track can trigger more than one voice. At every note, a new voice from the assigned voices is triggered and the previous released. Combined with the previous, you can have a single track trigger 3 voices and all these three voices use the same instrument, useful to do polyphonic arpeggios (see here). Not only that, a track can even trigger voices of different instruments, alternating between these two; maybe useful for example as an easy way to alternate between an open and a closed hihat.
  • Reasonably easily extensible. Instead of %ifdef hell, the primary extension mechanism is through new opcodes for the virtual machine. Only the opcodes actually used in a song are compiled into the virtual machine. The goal is to try to write the code so that if two similar opcodes are used, the common code in both is reused by moving it to a function. Macro and linker magic ensure that also helper functions are only compiled in if they are actually used.
  • Songs are YAML files. These markup files are simple data files, describing the tracks, patterns and patch structure (see here for an example). The sointu-compile then reads these files and compiles them into .asm code. This has the nice implication that, in future, there will be no need for a binary format to save patches, nor should you need to commit .o or .asm to repo: just put the .yml in the repo and automate the .yml -> .asm -> .o steps using sointu-compile & nasm.
  • Harmonized support for stereo signals. Every opcode supports a stereo variant: the stereo bit is hidden in the least significant bit of the command stream and passed in carry to the opcode. This has several nice advantages: 1) the opcodes that don't need any parameters do not need an entire byte in the value stream to define whether it is stereo; 2) stereo variants of opcodes can be implemented rather efficiently; in some cases, the extra cost of stereo variant is only 5 bytes (uncompressed). 3) Since stereo opcodes usually follow stereo opcodes (and mono opcodes follow mono opcodes), the stereo bits of the command bytes will be highly correlated and if crinkler or any other modeling compressor is doing its job, that should make them highly predictable i.e. highly compressable.
  • Test-driven development. Given that 4klang was already a mature project, the first thing actually implemented was a set of regression tests to avoid breaking everything beyond any hope of repair. Done, using go test (runs the .yml regression tests through the library) and CTest (compiles each .yml into executable and ensures that when run like this, the test case produces identical output). The tests are also ran in the cloud using github actions.
  • Arbitrary signal routing. SEND (used to be called FST in 4klang) opcode normally sends the signal as a modulation to another opcode. But with the new RECEIVE opcode, you just receive the plain signal there. So you can connect signals in an arbitrary way. Actually, 4klang could already do this but in a very awkward way: it had FLD (load value) opcode that could be modulated; FLD 0 with modulation basically achieved what RECEIVE does, except that RECEIVE can also handle stereo signals. Additionally, we have OUTAUX, AUX and IN opcodes, which route the signals through global main or aux ports, more closer to how 4klang does. But this time we have 8 mono ports / 4 stereo ports, so even this method of routing is unlikely to run out of ports in small intros.
  • Pattern length does not have to be a power of 2.
  • Sample-based oscillators, with samples imported from gm.dls. The gm.dls is available from system folder only on Windows, but the non-native tracker looks for it also in the current folder, so should you somehow magically get hold of gm.dls on Linux or Mac, you can drop it in the same folder with the tracker. See this example, and this go generate program parses the gm.dls file and dumps the sample offsets from it.
  • Unison oscillators. Multiple copies of the oscillator running slightly detuned and added up to together. Great for trance leads (supersaw). Unison of up to 4, or 8 if you make stereo unison oscillator and add up both left and right channels. See this example.
  • Compiling as a library. The API is very rudimentary, a single function render, and between calls, the user is responsible for manipulating the synth state in a similar way as the actual player does (e.g. triggering/ releasing voices etc.)
  • Calling Sointu as a library from Go language. The Go API is slighty more sane than the low-level library API, offering more Go-like experience.
  • A bytecode interpreter written in pure go. With the latest Go compiler, it's slightly faster hand-written one using x87 opcodes. With this, the tracker is ultraportable and does not need cgo calls.

Design philosophy

  • Make sure the assembly code is readable after compiling: it should have liberally comments in the outputted .asm file. This allows humans to study the outputted code and figure out more easily if there's still way to squeeze out instructions from the code.
  • Instead of prematurely adding %ifdef toggles to optimize away unused features, start with the most advanced featureset and see if you can implement it in a generalized way. For example, all the modulations are now added into the values when they are converted from integers, in a standardized way. This got rid of most of the %ifdefs in 4klang. Also, with no %ifdefs cluttering the view, many opportunities to shave away instructions became apparent. Also, by making the most advanced synth cheaply available to the scene, we promote better music in future 4ks :)
  • Size first, speed second. Speed will only considered if the situation becomes untolerable.
  • Benchmark optimizations. Compression results are sometimes slightly nonintuitive so alternative implementations should always be benchmarked e.g. by compiling and linking a real-world song with one of the examples and observing how the optimizations affect the byte size.

Background and history

4klang development was started in 2007 by Dominik Ries (gopher) and Paul Kraus (pOWL) of Alcatraz. The write-up will still be helpful for anyone looking to understand how 4klang and Sointu use the FPU stack to manipulate the signals. Since then, 4klang has been used in countless of scene productions and people use it even today.

However, 4klang seems not to be actively developed anymore and polyphonism was implemented only in a rather limited way (you could have exactly 2 voices per instrument if you enable it). Also, reading through the code, I spotted several avenues to squeeze away more bytes. These observations triggered project Sointu. That, and I just wanted to learn x86 assembly, and needed a real-world project to work on.

What's with the name

"Sointu" means a chord, in Finnish; a reference to the polyphonic capabilities of the synth. I assume we have all learned by now what "klang" means in German, so I thought it would fun to learn some Finnish for a change. And there's enough klangs already.

Prods using Sointu

Contributing

Pull requests / suggestions / issues welcome, through Github! Or just DM me on Discord (see contact information below).

License

Distributed under the MIT License. See LICENSE for more information.

Contact

Veikko Sariola - pestis_bc on Demoscene discord - [email protected]

Project Link: https://github.com/vsariola/sointu

Credits

The original 4klang: Dominik Ries (gopher/Alcatraz) & Paul Kraus (pOWL/Alcatraz) ❤

Sointu: Veikko Sariola (pestis/bC!), Apollo/bC!, NR4/Team210, PoroCYon, kendfss, anticore, qm210, reaby

Documentation

Index

Constants

View Source
const (
	Sine   = iota
	Trisaw = iota
	Pulse  = iota
	Gate   = iota
	Sample = iota
)

When unit.Type = "oscillator", its unit.Parameter["Type"] tells the type of the oscillator. There is five different oscillator types, so these consts just enumerate them.

Variables

View Source
var License string
View Source
var Ports = make(map[string]([]string))

Ports is static map allowing quickly finding the parameters of a unit that can be modulated. This is populated based on the UnitTypes list during init(). Thus, should be immutable, but Go not supporting that, then this will have to suffice: DO NOT EVER CHANGE THIS MAP.

View Source
var UnitNames []string

UnitNames is a list of all the names of units, sorted alphabetically.

View Source
var UnitTypes = map[string]([]UnitParameter){
	"add":      []UnitParameter{{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
	"addp":     []UnitParameter{{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
	"pop":      []UnitParameter{{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
	"loadnote": []UnitParameter{{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
	"mul":      []UnitParameter{{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
	"mulp":     []UnitParameter{{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
	"push":     []UnitParameter{{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
	"xch":      []UnitParameter{{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
	"distort": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "drive", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true}},
	"hold": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "holdfreq", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
	"crush": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "resolution", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(24 * float64(v) / 128), "bits" }}},
	"gain": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
	"invgain": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "invgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(128/float64(v)), 'g', 3, 64), "dB" }}},
	"dbgain": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "decibels", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(40 * (float64(v)/64 - 1)), "dB" }}},
	"filter": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "frequency", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: filterFrequencyDispFunc},
		{Name: "resonance", MinValue: 0, Neutral: 128, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) {
			return strconv.FormatFloat(toDecibel(128/float64(v)), 'g', 3, 64), "Q dB"
		}},
		{Name: "lowpass", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "bandpass", MinValue: -1, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "highpass", MinValue: -1, MaxValue: 1, CanSet: true, CanModulate: false}},
	"clip": []UnitParameter{{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
	"pan": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "panning", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true}},
	"delay": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "pregain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
		{Name: "dry", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
		{Name: "feedback", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
		{Name: "damp", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
		{Name: "notetracking", MinValue: 0, MaxValue: 2, CanSet: true, CanModulate: false, DisplayFunc: arrDispFunc(noteTrackingNames[:])},
		{Name: "delaytime", MinValue: 0, MaxValue: -1, CanSet: false, CanModulate: true}},
	"compressor": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "attack", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: compressorTimeDispFunc},
		{Name: "release", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: compressorTimeDispFunc},
		{Name: "invgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) {
			return strconv.FormatFloat(toDecibel(128/float64(v)), 'g', 3, 64), "dB"
		}},
		{Name: "threshold", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) {
			return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB"
		}},
		{Name: "ratio", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(1 - float64(v)/128), "" }}},
	"speed": []UnitParameter{},
	"out": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
	"outaux": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "outgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }},
		{Name: "auxgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
	"aux": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }},
		{Name: "channel", MinValue: 0, MaxValue: 6, CanSet: true, CanModulate: false, DisplayFunc: arrDispFunc(channelNames[:])}},
	"send": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "amount", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(float64(v)/64 - 1), "" }},
		{Name: "voice", MinValue: 0, MaxValue: 32, CanSet: true, CanModulate: false, DisplayFunc: sendVoiceDispFunc},
		{Name: "target", MinValue: 0, MaxValue: math.MaxInt32, CanSet: true, CanModulate: false},
		{Name: "port", MinValue: 0, MaxValue: 7, CanSet: true, CanModulate: false},
		{Name: "sendpop", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
	"envelope": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "attack", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return engineeringTime(math.Pow(2, 24*float64(v)/128) / 44100) }},
		{Name: "decay", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return engineeringTime(math.Pow(2, 24*float64(v)/128) / 44100) }},
		{Name: "sustain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }},
		{Name: "release", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return engineeringTime(math.Pow(2, 24*float64(v)/128) / 44100) }},
		{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
	"noise": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "shape", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true},
		{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
	"oscillator": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "transpose", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: oscillatorTransposeDispFunc},
		{Name: "detune", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(float64(v-64) / 64), "st" }},
		{Name: "phase", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) {
			return strconv.FormatFloat(float64(v)/128*360, 'f', 1, 64), "°"
		}},
		{Name: "color", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
		{Name: "shape", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true},
		{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }},
		{Name: "frequency", MinValue: 0, MaxValue: -1, CanSet: false, CanModulate: true},
		{Name: "type", MinValue: int(Sine), MaxValue: int(Sample), CanSet: true, CanModulate: false, DisplayFunc: arrDispFunc(oscTypes[:])},
		{Name: "lfo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "unison", MinValue: 0, MaxValue: 3, CanSet: true, CanModulate: false},
		{Name: "samplestart", MinValue: 0, MaxValue: 1720329, CanSet: true, CanModulate: false},
		{Name: "loopstart", MinValue: 0, MaxValue: 65535, CanSet: true, CanModulate: false},
		{Name: "looplength", MinValue: 0, MaxValue: 65535, CanSet: true, CanModulate: false}},
	"loadval": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "value", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(float64(v)/64 - 1), "" }}},
	"receive": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "left", MinValue: 0, MaxValue: -1, CanSet: false, CanModulate: true},
		{Name: "right", MinValue: 0, MaxValue: -1, CanSet: false, CanModulate: true}},
	"in": []UnitParameter{
		{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
		{Name: "channel", MinValue: 0, MaxValue: 6, CanSet: true, CanModulate: false, DisplayFunc: arrDispFunc(channelNames[:])}},
	"sync": []UnitParameter{},
}

UnitTypes documents all the available unit types and if they support stereo variant and what parameters they take.

Functions

func TotalVoices added in v0.5.0

func TotalVoices[T any, S ~[]T, P NumVoicerPointer[T]](slice S) (ret int)

TotalVoices returns the total number of voices used in the slice; summing the GetNumVoices of every element

Types

type AudioBuffer added in v0.3.0

type AudioBuffer [][2]float32

AudioBuffer is a buffer of stereo audio samples of variable length, each sample represented by [2]float32. [0] is left channel, [1] is right

func Play

func Play(synther Synther, song Song, progress func(float32)) (AudioBuffer, error)

Play plays the Song by first compiling the patch with the given Synther, returning the stereo audio buffer as a result (and possible errors).

func (AudioBuffer) Fill added in v0.3.0

func (buffer AudioBuffer) Fill(synth Synth) error

Fill fills the AudioBuffer using a Synth, disregarding all syncs and time limits. Note that this will change the state of the Synth.

func (AudioBuffer) Raw added in v0.3.0

func (buffer AudioBuffer) Raw(pcm16 bool) ([]byte, error)

Raw converts an AudioBuffer into a raw audio file, returned as a []byte array.

If pcm16 is set to true, the samples will be 16-bit signed integers; otherwise the samples will be 32-bit floats

func (AudioBuffer) Source added in v0.5.0

func (b AudioBuffer) Source() AudioSource

func (AudioBuffer) Wav added in v0.3.0

func (buffer AudioBuffer) Wav(pcm16 bool) ([]byte, error)

Wav converts an AudioBuffer into a valid WAV-file, returned as a []byte array.

If pcm16 is set to true, the samples in the WAV-file will be 16-bit signed integers; otherwise the samples will be 32-bit floats

type AudioContext

type AudioContext interface {
	Play(r AudioSource) CloserWaiter
}

AudioContext represents the low-level audio drivers. There should be at most one AudioContext at a time. The interface is implemented at least by oto.OtoContext, but in future we could also mock it.

AudioContext is used to play one or more AudioSources. Playing can be stopped by closing the returned io.Closer.

type AudioSource added in v0.5.0

type AudioSource func(buf AudioBuffer) error

AudioSource is an function for reading audio samples into an AudioBuffer. Returns error if the buffer is not filled.

type BufferSource added in v0.5.0

type BufferSource struct {
	// contains filtered or unexported fields
}

func (*BufferSource) ReadAudio added in v0.5.0

func (a *BufferSource) ReadAudio(buf AudioBuffer) error

ReadAudio reads audio samples from an AudioSource into an AudioBuffer. Returns an error when the buffer is fully consumed.

type CloserWaiter added in v0.5.0

type CloserWaiter interface {
	io.Closer
	Wait()
}

type Instrument

type Instrument struct {
	Name      string `yaml:",omitempty"`
	Comment   string `yaml:",omitempty"`
	NumVoices int
	Units     []Unit
	Mute      bool `yaml:",omitempty"` // Mute is only used in the tracker for soloing/muting instruments; the compiled player ignores this field
}

Instrument includes a list of units consisting of the instrument, and the number of polyphonic voices for this instrument

func Read4klangInstrument added in v0.2.0

func Read4klangInstrument(r io.Reader) (instr Instrument, err error)

Read4klangInstrument reads a 4klang instrument (a file usually with .4ki extension) from r and returns an Instrument, making best attempt to convert 4ki file to a sointu Instrument. It returns an error if the file is malformed or if the 4ki file version is not supported.

func (*Instrument) Copy

func (instr *Instrument) Copy() Instrument

Copy makes a deep copy of an Instrument

func (*Instrument) GetNumVoices added in v0.5.0

func (i *Instrument) GetNumVoices() int

Implement the counter interface

func (*Instrument) SetNumVoices added in v0.5.0

func (i *Instrument) SetNumVoices(count int)

type NumVoicer added in v0.5.0

type NumVoicer interface {
	GetNumVoices() int
	SetNumVoices(count int)
}

NumVoicer is used for slices where elements have NumVoices, of which there are two: Tracks and Instruments.

type NumVoicerPointer added in v0.5.0

type NumVoicerPointer[M any] interface {
	*M
	NumVoicer
}

NumVoicerPointer is a helper interface for type constraints, as SetNumVoices needs to be defined with a pointer receiver to be able to actually modify the value.

type Order added in v0.2.0

type Order []int

Order is the pattern order for a track, in practice just a slice of integers, but provides convenience functions that return -1 values for indices out of bounds of the array, and functions to increase the size of the slice only by necessary amount when a new item is added, filling the unused slots with -1s.

func (Order) Get added in v0.2.0

func (s Order) Get(index int) int

Get returns the value at index; or -1 is the index is out of range

func (*Order) Set added in v0.2.0

func (s *Order) Set(index, value int)

Set sets the value at index; appending -1s until the slice is long enough.

type ParamMap added in v0.5.0

type ParamMap map[string]int

func (*ParamMap) UnmarshalYAML added in v0.5.0

func (a *ParamMap) UnmarshalYAML(value *yaml.Node) error

type Patch

type Patch []Instrument

Patch is simply a list of instruments used in a song

func Read4klangPatch added in v0.2.0

func Read4klangPatch(r io.Reader) (patch Patch, err error)

Read4klangPatch reads a 4klang patch (a file usually with .4kp extension) from r and returns a Patch, making best attempt to convert 4klang file to a sointu Patch. It returns an error if the file is malformed or if the 4kp file version is not supported.

func (Patch) Copy

func (p Patch) Copy() Patch

Copy makes a deep copy of a Patch.

func (Patch) FindUnit added in v0.3.0

func (p Patch) FindUnit(id int) (instrIndex int, unitIndex int, err error)

FindUnit searches the instrument index and unit index for a unit with the given id. Two units should never have the same id, but if they do, then the first match is returned. Id 0 is interpreted as "no id", thus searching for id 0 returns an error. Error is also returned if the searched id is not found. FindUnit considers disabled units as non-existent.

func (Patch) FirstVoiceForInstrument

func (p Patch) FirstVoiceForInstrument(instrIndex int) int

FirstVoiceForInstrument returns the index of the first voice of given instrument. For example, if the Patch has three instruments (0, 1 and 2), with 1, 3, 2 voices, respectively, then FirstVoiceForInstrument(0) returns 0, FirstVoiceForInstrument(1) returns 1 and FirstVoiceForInstrument(2) returns 4. Essentially computes just the cumulative sum.

func (Patch) InstrumentForVoice

func (p Patch) InstrumentForVoice(voice int) (int, error)

InstrumentForVoice returns the instrument number for the given voice index. For example, if the Patch has three instruments (0, 1 and 2), with 1, 3, 2 voices, respectively, then InstrumentForVoice(0) returns 0, InstrumentForVoice(1) returns 1 and InstrumentForVoice(3) returns 1.

func (Patch) NumDelayLines

func (p Patch) NumDelayLines() int

NumDelayLines return the total number of delay lines used in the patch; summing the number of delay lines of every delay unit in every instrument

func (Patch) NumSyncs

func (p Patch) NumSyncs() int

NumSyns return the total number of sync outputs used in the patch; summing the number of sync outputs of every sync unit in every instrument

func (Patch) NumVoices

func (p Patch) NumVoices() int

NumVoices returns the total number of voices used in the patch; summing the voices of every instrument

type Pattern added in v0.2.0

type Pattern []byte

Pattern represents a single pattern of note, in practice just a slice of bytes, but provides convenience functions that return 1 values (hold) for indices out of bounds of the array, and functions to increase the size of the slice only by necessary amount when a new item is added, filling the unused slots with 1s.

func (Pattern) Get added in v0.2.0

func (s Pattern) Get(index int) byte

Get returns the value at index; or 1 is the index is out of range

func (*Pattern) Set added in v0.2.0

func (s *Pattern) Set(index int, value byte)

Set sets the value at index; appending 1s until the slice is long enough.

type Score

type Score struct {
	Tracks         []Track
	RowsPerPattern int // number of rows in each pattern
	Length         int // length of the song, in number of patterns
}

Score represents the arrangement of notes in a song; just a list of tracks and RowsPerPattern and Length (in patterns) to know the desired length of a song in rows. If any of the tracks is too short, all the notes outside the range should be just considered as holding the last note.

func (*Score) Clamp added in v0.4.0

func (s *Score) Clamp(songPos SongPos) SongPos

func (Score) Copy

func (l Score) Copy() Score

Copy makes a deep copy of a Score.

func (Score) FirstVoiceForTrack

func (l Score) FirstVoiceForTrack(track int) int

FirstVoiceForTrack returns the index of the first voice of given track. For example, if the Score has three tracks (0, 1 and 2), with 1, 3, 2 voices, respectively, then FirstVoiceForTrack(0) returns 0, FirstVoiceForTrack(1) returns 1 and FirstVoiceForTrack(2) returns 4. Essentially computes just the cumulative sum.

func (Score) LengthInRows

func (l Score) LengthInRows() int

LengthInRows returns just RowsPerPattern * Length, as the length is the length in the number of patterns.

func (Score) NumVoices

func (l Score) NumVoices() int

NumVoices returns the total number of voices used in the Score; summing the voices of every track

func (*Score) SongPos added in v0.4.0

func (s *Score) SongPos(songRow int) SongPos

func (*Score) SongRow added in v0.4.0

func (s *Score) SongRow(songPos SongPos) int

func (*Score) Wrap added in v0.4.0

func (s *Score) Wrap(songPos SongPos) SongPos

type Song

type Song struct {
	BPM         int
	RowsPerBeat int
	Score       Score
	Patch       Patch
}

Song includes a Score (the arrangement of notes in the song in one or more tracks) and a Patch (the list of one or more instruments). Additionally, BPM and RowsPerBeat fields set how fast the song should be played. Currently, BPM is an integer as it offers already quite much granularity for controlling the playback speed, but this could be changed to a floating point in future if finer adjustments are necessary.

func (*Song) Copy

func (s *Song) Copy() Song

Copy makes a deep copy of a Score.

func (*Song) SamplesPerRow

func (s *Song) SamplesPerRow() int

Assuming 44100 Hz playback speed, return the number of samples of each row of the song.

func (*Song) Validate

func (s *Song) Validate() error

Validate checks if the Song looks like a valid song: BPM > 0, one or more tracks, score uses less than or equal number of voices than patch. Not used much so we could probably get rid of this function.

type SongPos added in v0.4.0

type SongPos struct {
	OrderRow   int
	PatternRow int
}

SongPos represents a position in a song, in terms of order row and pattern row. The order row is the index of the pattern in the order list, and the pattern row is the index of the row in the pattern.

type StackUse added in v0.5.0

type StackUse struct {
	Inputs     [][]int // Inputs documents which inputs contribute to which outputs. len(Inputs) is the number of inputs. Each input can contribute to multiple outputs, so its a slice.
	Modifies   []bool  // Modifies documents which of the (mixed) inputs are actually modified by the unit
	NumOutputs int     // NumOutputs is the number of outputs produced by the unit. This is used to determine how many outputs are needed for the unit.
}

StackUse documents how a unit will affect the signal stack.

type Synth

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. Normally, 1 sample = 1 unit of time, but speed
	// modulations may change this. It returns the number of samples filled (in
	// stereo samples i.e. number of elements of AudioBuffer filled), the
	// number of sync outputs written, the number of time steps time advanced,
	// and a possible error.
	Render(buffer AudioBuffer, 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
	// delaylines should keep their content. Every change in the Patch triggers
	// an Update and if the Patch would be started fresh every time, it would
	// lead to very choppy audio.
	Update(patch Patch, bpm int) error

	// Trigger triggers a note for a given voice. Called between synth.Renders.
	Trigger(voice int, note byte)

	// Release releases the currently playing note for a given voice. Called
	// between synth.Renders.
	Release(voice int)
}

Synth represents a state of a synthesizer, compiled from a Patch.

type Synther added in v0.3.0

type Synther interface {
	Name() string // Name of the synther, e.g. "Go" or "Native"
	Synth(patch Patch, bpm int) (Synth, error)
}

Synther compiles a given Patch into a Synth, throwing errors if the Patch is malformed.

type Track

type Track struct {
	// NumVoices is the number of voices this track triggers, cycling through
	// the voices. When this track triggers a new voice, the previous should be
	// released.
	NumVoices int

	// Effect hints the GUI if this is more of an effect track than a note
	// track: if true, e.g. the GUI can display the values as hexadecimals
	// instead of note values.
	Effect bool `yaml:",omitempty"`

	// Order is a list telling which pattern comes in which order in the song in
	// this track.
	Order Order `yaml:",flow"`

	// Patterns is a list of Patterns for this track.
	Patterns []Pattern `yaml:",flow"`
}

Track represents the patterns and orderlist for each track. Note that each track has its own patterns, so one track cannot use another tracks patterns. This makes the data more intuitive to humans, as the reusing of patterns over tracks is a rather rare occurence. However, the compiler will put all the patterns in one global table (identical patterns only appearing once), to optimize the runtime code.

func (*Track) Copy

func (t *Track) Copy() Track

Copy makes a deep copy of a Track.

func (*Track) GetNumVoices added in v0.5.0

func (t *Track) GetNumVoices() int

func (Track) Note added in v0.4.0

func (s Track) Note(pos SongPos) byte

func (*Track) SetNote added in v0.4.0

func (s *Track) SetNote(pos SongPos, note byte, uniquePatterns bool)

SetNote sets the note at the given position. If uniquePatterns is true, the pattern is copied to a new pattern if the pattern is used by more than one order row.

func (*Track) SetNumVoices added in v0.5.0

func (t *Track) SetNumVoices(c int)

type Unit

type Unit struct {
	// Type is the type of the unit, e.g. "add","oscillator" or "envelope".
	// Always in lowercase. "" type should be ignored, no invalid types should
	// be used.
	Type string `yaml:",omitempty"`

	// ID should be a unique ID for this unit, used by SEND units to target
	// specific units. ID = 0 means that no ID has been given to a unit and thus
	// cannot be targeted by SENDs. When possible, units that are not targeted
	// by any SENDs should be cleaned from having IDs, e.g. to keep the exported
	// data clean.
	ID int `yaml:",omitempty"`

	// Parameters is a map[string]int of parameters of a unit. For example, for
	// an oscillator, unit.Type == "oscillator" and unit.Parameters["attack"]
	// could be 64. Most parameters are either limites to 0 and 1 (e.g. stereo
	// parameters) or between 0 and 128, inclusive.
	Parameters ParamMap `yaml:",flow"`

	// VarArgs is a list containing the variable number arguments that some
	// units require, most notably the DELAY units. For example, for a DELAY
	// unit, VarArgs is the delaytimes, in samples, of the different delaylines
	// in the unit.
	VarArgs []int `yaml:",flow,omitempty"`

	// Disabled is a flag that can be set to true to disable the unit.
	// Disabled units are considered to be not present in the patch.
	Disabled bool `yaml:",omitempty"`

	// Comment is a free-form comment about the unit that can be displayed
	// instead of/besides the type of the unit in the GUI, to make it easier
	// to track what the unit is doing & to make it easier to target sends.
	Comment string `yaml:",omitempty"`
}

Unit is e.g. a filter, oscillator, envelope and its parameters

func (*Unit) Copy

func (u *Unit) Copy() Unit

Copy makes a deep copy of a unit.

func (*Unit) StackChange

func (u *Unit) StackChange() int

StackChange returns how this unit will affect the signal stack. "pop" and "addp" and such will consume the topmost signal, and thus return -1 (or -2, if the unit is a stereo unit). On the other hand, "oscillator" and "envelope" will produce a signal, and thus return 1 (or 2, if the unit is a stereo unit). Effects that just change the topmost signal and will not change the number of signals on the stack and thus return 0.

func (*Unit) StackNeed

func (u *Unit) StackNeed() int

StackNeed returns the number of signals that should be on the stack before this unit is executed. Used to prevent stack underflow. Units producing signals do not care what is on the stack before and will return 0.

func (*Unit) StackUse added in v0.5.0

func (u *Unit) StackUse() StackUse

type UnitParameter

type UnitParameter struct {
	Name        string // thould be found with this name in the Unit.Parameters map
	MinValue    int    // minimum value of the parameter, inclusive
	MaxValue    int    // maximum value of the parameter, inclusive
	Neutral     int    // neutral value of the parameter
	CanSet      bool   // if this parameter can be set before hand i.e. through the gui
	CanModulate bool   // if this parameter can be modulated i.e. has a port number in "send" unit
	DisplayFunc UnitParameterDisplayFunc
}

UnitParameter documents one parameter that an unit takes

func FindParamForModulationPort added in v0.5.0

func FindParamForModulationPort(unitName string, index int) (up UnitParameter, upIndex int, ok bool)

type UnitParameterDisplayFunc added in v0.5.0

type UnitParameterDisplayFunc func(int) (value string, unit string)

Directories

Path Synopsis
cmd
sointu-compile command
sointu-play command
sointu-track command
Package tracker contains the data model for the Sointu tracker GUI.
Package tracker contains the data model for the Sointu tracker GUI.
vm
Package vm implements a virtual machine based synthesizer that runs Sointu bytecode, and methods to convert patches into bytecode.
Package vm implements a virtual machine based synthesizer that runs Sointu bytecode, and methods to convert patches into bytecode.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL