Tracker
This is a continuation of the synth, maybe read that page first because a lot of this ties back to that.
This started from a dissatisfaction with the synth. It was great at “signal processing”, it was fun to mess around with, but for practical purposes, it was very cumbersome to actually compose with it, as the tracks tended to get very long, and it was very clumsy at handling notes of different lengths, had no support for note triggers (so no adsr envelopes either - I had to manually create fades and sync them), and a dozen other little tidbits that made it unpleasant for larger projects where I needed content. So, I decided to abandon the intricate commands, and instead spend more effort on track management - and inadverently ended up implementing half an operating system.
Overview
(if this makes no sense, just read the commands list - it’s probably more helpful anyway)
The tracker produces sounds by executing commands from a file one after another, and generating a waveform - same way the synth did. Where it differs is that it does not do much calculation - if you want to play a certain note, that’s one command, and you can’t modify it much afterwards. Instead, it excels in program flow - the track can split into multiple and warp across the program. In practice, the tracker behaves a lot like a non-preemptive operating system scheduler - it supports multiple concurrent tracks, which each have internal variables used for sound synthesis (note length, adsr envelopes, etc), can spawn more tracks, and even have their own call stacks. They each execute commands to modify their state (or that of the tracker), and are preemptied once they play a note (incurring a delay).
For extra confusion, I decided to write the tracks in an assembly-like language, necessitating a compiler. Overall this was quite simple, yet it did involve intermediary bytecode and a symbol lookup table for track labels, and some twiddling around with binary instructions.
Specifications
The tracker runs at most 8 tracks (processes), each with a callstack at most 7 recursions deep. It has 4 registers for adsr envelopes (at the moment unused).
The commands are as follows (given first in “assembly” and then in binary expansion):
nX
:0xxxxxxx
: play note X for default length of current track. X denotes note frequency the same way it did in the synth having an explicit command for this lets us play note values that would otherwise be interpreted as commands.pX
:1000xxxx
: pause for X beats (encoded in hex, and powers of two - ie a value of 3 would pause for 8 note values). maximum possible delay is over 32k note values, or about 40s at the default note length (if using maximum note length, it’s about two weeks).sYX
:1001yyyy xxxxxxxx
: play note X for Y beats (not affecting default length, length encoded as above)lX
:1010xxxx
: set default note length to X (as power of two), in multiples of a tenth of a secondrXYY
:10110xxx yyyyyyyy
: set register to value. X is one ofadsr????
, YY is the hex value to set it to. currently, this does nothing, as envelopes are not yet implemented, and I also don’t have any other registers..X
: n/a : set label X (treated as a literal). meaning, whenever a command references X, it will point to the location in the program given by this command. this does not produce any output, as it only serves as a location for other commands to operate on.jX
:10111000 yyyyyyyy
: jump to label X. this continues running the current track at the specified labelcX
:10111001 yyyyyyyy
: call label X. this works like a jump, except that when that track ends, instead of going mute (as a jump would), this one resumes playing just after the jump. These can be stacked up to 7 times.fX
:10111010 yyyyyyyy
: fork to label X. the current track keeps playing, but a new track is started that plays at the label. up to 8 tracks can play at the same time.oXYY
:10111011 xxxxxxxx yyyyyyyy
: loop label X Y times. it calls label X likecX
would, except instead of returning when the new track finishes, the first Y times it jumps back to the label. XXX this is currently not implementedz
:00000000
: end track- all others treated as note literal (ie as if they were prefixed by an
n
)
The “labeling” used for jumps/calls/forks requires the compilation to be a two-step process. The first one does most of the compilation, yet it does not know where labels are. Instead, the commands refer to the labels by the names they are listed as in the source, and the positions of the labels (as set by the .
command) are kept in a separate table. On the second run, it outputs the codes generated before, substituting in the positions of the labels from the table.