The makefile compiled splash.score by name, so intro.score sat beside it turning into nothing - which is exactly the failure the mirror three lines above exists to prevent, and its comment says so: a list in a makefile goes stale the moment somebody adds a file, and what they forgot is invisible until they go looking for it on the machine. Found by the user going looking for it on the machine. Every .score in Programs/Tunes is compiled and put on the disk now, and every patch in Programs/Sounds is converted first so a score can name any of them. The source is mirrored to /Source/Tunes with everything else that was written; the tune goes to the root, where a program looking for one expects it - which is worth writing down, because the two being in different places is the thing that sent somebody hunting. two.score moved to Tests. It is a fixture, it names patches that only exist in the test build, and it was in the music tree only because that is where scores were when it was written - which the game disk build found immediately by failing to compile it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
150 lines
9.9 KiB
Markdown
150 lines
9.9 KiB
Markdown
# The SplitBit Tune Manual
|
|
|
|
A tune is four voices on one clock. This is how you write one, what the compiler refuses, and what the bytes look like for anything that wants to read or write them without going through the compiler.
|
|
|
|
The player is `Play`, which comes with CosmOS. `Play` on its own plays the tune built into it; `Play <file>` plays a tune from a disk.
|
|
|
|
## What A Tune Is:
|
|
|
|
Three tables and one indirection, which is a tracker's shape.
|
|
|
|
**Instruments** are patches, designed in soundThing and converted by SoundPatch. **Sequences** are one voice's phrase: notes, rests, and commands. An **order list** names sequences, one list to a voice, and playing a tune is walking those four lists.
|
|
|
|
That last part is where repetition comes from, and it costs no notation at all. A bass line that plays under four different melodies is written once and named four times. A thirty-two bar piece that reuses four phrases is four phrases and a list.
|
|
|
|
**The four voices share the tick and nothing else.** Each keeps its own place in its own sequence and its own count of how much longer the note it is holding lasts, so a voice playing whole notes and a voice playing sixteenths cost the same and never have to know about each other. Nothing holds them together except that the sequences they play at the same position last the same number of ticks - which is the first thing the compiler checks, because it is the mistake that is hardest to hear and easiest to count.
|
|
|
|
## Writing One:
|
|
|
|
```
|
|
; Four bars, and the bass is the same one under two of them.
|
|
#Tick 0d125000 ; cycles a tick: a sixteenth note at 120 beats a minute
|
|
|
|
#Patch Oboe oboe.patch
|
|
#Patch Strings strings.patch
|
|
|
|
#Voice 0d0 Oboe ; which instrument each voice starts on
|
|
#Voice 0d1 Strings
|
|
|
|
#Sequence Verse
|
|
0d64 0d4 0d67 0d4 0d72 0d8
|
|
|
|
#Sequence Ending
|
|
#Use Strings ; a command, which takes no time at all
|
|
0d72 0d16
|
|
|
|
#Order 0d0
|
|
Verse Verse Ending
|
|
```
|
|
|
|
**`#` is a directive and `;` is a comment**, exactly as in SplitBit assembly and in CosmOS's scripts. One rule across the machine rather than a third dialect.
|
|
|
|
**Numbers are written the way the assembler writes them**: `0d` for decimal and `0x` for hexadecimal. A bare number is refused rather than guessed at, so `#Voice 0` is an error and `#Voice 0d0` is what you meant. There is one way to write a number on this machine and a tune is not the place to introduce a second.
|
|
|
|
Whitespace and line breaks mean nothing. A sequence runs until the next directive that is not `#Use`.
|
|
|
|
## The Directives:
|
|
|
|
| Directive | Means |
|
|
| --- | --- |
|
|
| #Tick \<cycles\> | How long one tick lasts, in the machine's own cycles. From `0d1` to `0d16777215`, which reaches from one cycle to sixteen and a half seconds. |
|
|
| #Patch \<name\> \<file\> | Names an instrument and where its bytes are. The file is what `SoundPatch --blob` writes. |
|
|
| #Voice \<voice\> \<patch\> | Which instrument a voice starts on. Voices are `0d0` to `0d3`. |
|
|
| #Sequence \<name\> | Begins a sequence. What follows, until the next directive, is its events. |
|
|
| #Use \<patch\> | Inside a sequence: play the rest of this voice on that instrument. Takes no time. |
|
|
| #Order \<voice\> | Begins a voice's order list. What follows, until the next directive, is sequence names. |
|
|
|
|
## Inside A Sequence:
|
|
|
|
Pairs: what to play, then how many ticks it lasts.
|
|
|
|
| Written | Means |
|
|
| --- | --- |
|
|
| 0d1 to 0d127 | A MIDI note. 60 is middle C and every 12 is an octave. |
|
|
| 0d0 | A rest. The ticks pass with nothing sounding. |
|
|
| #Use \<patch\> | A command. It happens between notes and consumes no tick. |
|
|
|
|
A duration is from `0d1` to `0d255` ticks.
|
|
|
|
**A note's duration is its whole life.** The gate goes down when the count runs out and the next event begins on the same tick, so a gap between two notes is *written*, as a rest, rather than invented by the player out of some fraction it decided on.
|
|
|
|
**A patch change reshapes whatever is still ringing on that voice.** Nothing can be done about that: a channel has one set of parameters and a note in its release is using them, so deferring the change to the next note would reshape the same tail. It is a fact about the sound device rather than a choice about the format. If it matters, leave a rest long enough for the release - which is the composer's to write and not the player's to guess.
|
|
|
|
## The Tick, And Why It Is In Cycles:
|
|
|
|
Cycles are what everything else on this machine is counted in: it is what the cost model counts and what a frame is measured in, so a tick counting anything else would be a second unit to remember.
|
|
|
|
At a megahertz, a beat at 120 beats a minute is 500,000 cycles. A sixteenth note is 125,000, which is what the examples here use.
|
|
|
|
**Choose a tick that divides what you want to write.** The tick is the smallest subdivision in the piece, because it is the only unit four parts can agree on. A piece with triplets wants a tick that divides by three; a piece with long held notes wants a coarser one, since a duration only reaches 255.
|
|
|
|
Before the timer existed this was not a choice. A screen frame is 16,667 cycles, so a sixteenth note at 120 was seven and a half frames and could not be asked for at all, and `Examples/tune.asm` wrote its arpeggio as seven whole frames - six and a half per cent fast.
|
|
|
|
## What The Compiler Refuses:
|
|
|
|
Everything here is something the player cannot notice for itself, which is why the compiler is the only place it can be caught.
|
|
|
|
| Refused | Because |
|
|
| --- | --- |
|
|
| Sequences at one position lasting different lengths | The voices would come apart, quietly, some bars after the mistake. The machine has no lengths and cannot tell you. |
|
|
| A voice with a part and no instrument | By the time a tune is loaded, "never said" and "instrument nought" are the same byte. Only the compiler ever sees the absence. |
|
|
| A duration of `0d0` | The player counts a duration down and reads the next event at nought, so a count starting there wraps to 255 and holds. It sounds like a hang rather than a mistake. |
|
|
| A name that is not there | The machine has no names in it at all. |
|
|
| A bare number | There is one way to write a number on this machine. |
|
|
| A patch file that is not a patch | A patch is a count and that many pairs. Anything else would write rubbish at the sound device. |
|
|
|
|
Nothing is written when anything is refused, so a failed build cannot leave a half-made tune on the disk.
|
|
|
|
## Building One:
|
|
|
|
```
|
|
./SoundPatch --blob Programs/Sounds/Oboe.json Oboe oboe.patch
|
|
./TuneC [-I <dir>] theme.score theme.tune
|
|
./SplitDisk put mydisk.img theme.tune
|
|
```
|
|
|
|
In this repository, `Programs/Tunes` holds the scores and the build compiles every one of them onto the disk - the source mirrored to `/Source/Tunes` with everything else that was written, and the compiled tune at the root, where a program looking for one expects to find it.
|
|
|
|
**A `.score` is written and a `.tune` is what the machine reads**, the same way a `.asm` is written and a `.sbx` or a `.bin` is what it loads. One is a source and the other is bytes, and giving them different names means never wondering which a file is - or handing a player a source and being told, correctly, that it is not a tune.
|
|
|
|
Then `Play theme.tune` on the machine.
|
|
|
|
Patches are looked for beside the tune first and then on the `-I` path, the way the assembler looks for an include - so a tune can name its instruments by bare name and travel with them.
|
|
|
|
**SoundPatch is the only thing that understands soundThing's JSON.** TuneC embeds the bytes it is given and knows nothing about what a patch means. A second program parsing that format would be a second opinion about it, and the seam between two opinions is where a sound bug can live for a fortnight.
|
|
|
|
## Limits:
|
|
|
|
| Thing | How many |
|
|
| --- | --- |
|
|
| Voices | 4 |
|
|
| Patches | 64 |
|
|
| Sequences | 200. The ceiling is 255, because 0xFF ends an order list. |
|
|
| Events in a sequence | 1024 bytes of them |
|
|
| Entries in an order list | 256 |
|
|
| Ticks in a duration | 255 |
|
|
| Cycles in a tick | 16,777,215 |
|
|
|
|
## The Bytes:
|
|
|
|
For anything that wants to read or write a tune without going through the compiler. `Tests/maketune.py` does exactly that, and the suite checks that it and TuneC agree byte for byte - two implementations of one format, which is the same discipline SplitDisk and `sbfs.asm` are held to, and for the same reason: either alone is only self-consistent.
|
|
|
|
| Offset | Size | Holds |
|
|
| --- | --- | --- |
|
|
| 0 | 4 | `SBTU` |
|
|
| 4 | 1 | Version, 1 |
|
|
| 5 | 3 | The tick in cycles, most significant byte first |
|
|
| 8 | 1 | How many patches |
|
|
| 9 | 1 | How many sequences |
|
|
| 10 | 2 | Offset of the patch table |
|
|
| 12 | 2 | Offset of the sequence table |
|
|
| 14 | 8 | Offset of each voice's order list, four of them |
|
|
| 22 | 4 | The patch index each voice starts on, four of them |
|
|
| 26 | 2 | Reserved, zero |
|
|
|
|
The **patch table** is one two-byte offset per patch, each naming a count and that many parameter and value pairs. The **sequence table** is one two-byte offset per sequence. An **order list** is one-byte sequence indices ending in `0xFF`. A **sequence** is events, ending in `0xFF`, where 1 to 127 is a note and 0 is a rest - each followed by a duration - and 0x80 is a command followed by one byte of patch index.
|
|
|
|
**Everything in a tune is an offset from its first byte**, so loading one is adding the load address to the two tables and pointing four voices at their order lists. No sequence is walked and nothing inside one is an address, which is what makes a malformed tune something that plays wrongly rather than something that takes the loader with it. The magic is checked first, because from there on the loader follows what the offsets name.
|
|
|
|
**An offset of zero means a voice has no part.** That is safe here for a reason worth stating, because the same trick was got wrong once: offset zero is where the magic lives, so no structure can ever begin there. It is impossible by construction rather than by a fact about one program's base address - which is what an earlier version of the order list terminator relied on, and why a boot image based at zero read its own first sequence as the end of the list and played silence.
|