dfc9d9ef7fd5b3cda3a00d742551d0339121a49b
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6bb1565dea |
A tune's tick is the tune's own
useTune took the period out of a file's header and then Play wrote its own straight over it, so every tune played at a sixteenth note at 120 beats a minute whatever it asked for. A tune with a #Tick of 0d250000 lasted half as long as it said. Nothing noticed because every fixture in the suite asked for exactly the tick Play had written into itself. A test that agrees with the bug by coincidence is not a test, and the way to find out is a fixture that wants something else - so slow.tune is two.tune with twice the period and nothing else changed, and it has to last twice as long. The period now belongs to whoever supplied the tune: useTune sets it from the header, useBuiltIn sets its own, and the start code writes only the control byte - which has to come after either of them, because writing control with the run bit set is what loads the period. Found while reading Play to see how a splash screen would drive the player, which is a reminder that the second reader of a piece of code is worth more than the first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
bfc46d982e |
Play reads a tune from a file
The loader, and the reason it is small: everything in a tune is an offset from wherever it was put, so taking one means adding the base to two tables and pointing four voices at their order lists. No sequence is walked. Nothing inside one is an address to be found and corrected, which is the difference between a malformed tune that plays wrongly and one that takes the loader with it. "SBTU", version, the tick in cycles, how many patches and sequences, offsets to the two tables, an order list each, and the patch each voice starts on. The magic is checked before anything else, because from there on the loader follows what the offsets name. break.sh shows what that guard is worth: without it, handing Play a PROGRAM runs the machine away until the cycle limit, rather than saying it is not a tune. PatchTable, SequenceTable and VoiceStart became pointers, so the engine does not care whether a tune came out of a file or was assembled in. Play's built-in tune now hands over the same three addresses a loaded one would, in eight lines - which is what keeps the two paths from drifting, and what made this rung change no scheduler code at all. Tests/maketune.py lays the fixture out byte by byte. IT IS NOT THE COMPILER: the sequences and the patches are literal bytes and the only thing computed is where each piece lands. That is the point - the loader is checked by something that does not share its idea of the format, which is the same reason SplitDisk and sbfs.asm share nothing but a specification. Both notes in the fixture are number 60, so the octave between them is a 0x80 command loading the second patch out of the file: the header, the tick, the relocation, an order list and a patch from a file, measured in one go. Play and the tune live on quiet.img rather than cosmos.img, so a fixture does not move ten recordings every time it changes size. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
5d9b39514b |
The player speaks indices, which is the shape a file has to be
Order lists hold one-byte sequence indices and 0x80 holds a one-byte patch index. Two tables, PatchTable and SequenceTable, are the only places an address lives. THAT IS WHAT MAKES A TUNE LOADABLE WITHOUT WALKING IT. Nothing inside a sequence or an order list is an address, so putting one in memory means adding the load address to two arrays and nothing else. The alternative is a loader that parses every sequence looking for addresses to correct, which is a loader a malformed file can walk off a cliff. Doing it now, while the tune is still assembled in, means the file form and the assembled form are the same shape - so reading a tune from a file will change no engine code at all. The whole point of the rung. The patch each voice starts on moved from four calls in a row into VoiceStart, four declared bytes, and loadStartPatches reads them. A starting instrument is state and belongs where state goes: the user's ruling is that a voice with undefined state is an error, prompted by noticing that a program run a second time starts with the memory the first run left, because loading is what initialises and running is not. Order lists also halved in size, which was not the reason but is welcome. Hand-writing the two tables is exactly the tedium the compiler exists to remove - every sequence counted into its place, and moving one means renumbering. Better to feel that here than after a tool has baked the shape in. Verified by rendering: bar for bar the same piece. break.sh confirms the scaling, since an index is doubled to reach a two-byte entry and halving that step lands on the wrong sequence and fails four checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
8029063bdc |
Sequences, order lists, and a command that changes the instrument
The rung between M3 and M4, and the point of doing it before the format: the engine learns the tracker's model with the tune still assembled in, so M4 becomes serialising a thing that exists rather than designing a thing that does not. A voice no longer walks one long track. 0xFF now means THIS SEQUENCE ended, and the voice takes the next address from an order list of its own. That is where repetition comes from, and it costs no notation: the bass plays the same sequence in the first bar and the last and it is written once. Per voice rather than one shared table of four-column rows, because a voice's order cursor is then a pointer it advances by itself - the same LDD and STD move everything else here makes. Four columns is how it reads, not how it is stored. Sequences also carry COMMANDS, which take no tick: the reader acts and reads the next event on the same boundary. One is defined, 0x80, which plays the rest of that voice on another patch, and the other 125 values are left alone. A patch change reshapes whatever is still ringing on the voice and nothing can be done about that - a channel has one set of parameters and a note in its release is using them - so it is a fact about the hardware and the cure is a rest, which is the composer's. The engine moved to Libraries/player.asm rather than being copied into the test a second time, now that it is big enough to drift. Play supplies the tune and the beat; the library supplies the scheduler, the patch loader and a voice's state. Verified by rendering: bar for bar identical across the move. AND THE TEST FOUND A REAL FLAW IN THE FORMAT, which is the whole argument for building the reader first. The order list ended with 0x0000, on the reasoning that no sequence could live below the 0x3000 this program is based at. True of a loaded program, false of a boot image whose data starts at zero - so the first test written against it read its own first sequence as the end of the list and played nothing at all. It is 0xFFFF now, which mirrors the 0xFF ending a sequence and is impossible everywhere: a sequence at 0xFF00 or above has fewer bytes left than it needs. An address is a poor place to hide a flag unless the address is impossible in every program, not just this one. One order list in the test now really ends, because otherwise nothing reached the terminator at all: every voice sat on a long rest and the break went unnoticed. With it, breaking the test reads garbage past the end and the counter sums two notes at 781 hertz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
41d65d6346 |
Play waits for its own sound to finish before handing back
The gates all go down on the tick the tracks end, but a gate down is a note RELEASED and not a note stopped: the oboe and the strings have a release left to run. Rendered and measured, the tail dies eight tenths of a second after the last event, and the ring was eight ticks - one second, which sounds like enough and was two tenths short. A program that exits with sound still in the air leaves nothing able to end it: the program is gone and cannot drop a gate. What that becomes depends on the front end. Behind a window the tail finishes on its own. On a terminal it does not, because emulated time stops while the machine blocks on a key - measured, and the reason the leftover sound came out a snippet per keystroke rather than fading. Twelve ticks, which is a second and a half and covers the measured tail with room over. It is a number about THESE FOUR INSTRUMENTS: a patch with a longer release would want more, and the general answer is for the system to quieten the device when a program stops, the way it puts the screen back. That is not built and is worth deciding on rather than guessing at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
ce1c0517aa |
M3: an instrument for each voice
Play loads a patch per channel before a note is played: an oboe for the
melody, strings under it, a square wave for the bass and a kalimba for
the arpeggio. It reads a count and that many parameter and value pairs -
the format SoundPatch writes - and understands nothing else about them,
which keeps SoundPatch the only thing that knows what soundThing's JSON
means.
FOUR PATCHES CAN BE UP AT ONCE, and that is the whole rung. It is the
first use of
|
||
|
|
1687422619 |
A voice starts pointed at its track, without code to point it
There is no assembler bug. I reported one and was wrong. A label written in the Data Segment does come out as its address, two bytes, most significant first - implemented in populateOutputBuffers, documented in the Assembler Manual, and correct. What misled me was the test I checked it with: the label was the first thing in an unbased Data Segment, so its address really was 0x0000, and I read the right answer as an unfilled placeholder. So Play was doing at run time what the assembler had already offered to do at assembly time. The voice records now carry their track labels directly, which is exactly the shape LDD reads, and nothing relocates on this machine so the address written is the address it will have. That takes out startVoice, its four call sites, and the eight SETDs that fed them: 450 bytes to 379, and the initial state of a voice is now something you can read rather than something you have to follow the code to work out. The comment claiming otherwise is gone from Play.asm, and the same change is made in fourVoiceTest. The music is unchanged - bar by bar the render matches to within one per cent, which is the program loading a shade sooner because it is smaller. Ten recordings moved for the same reason: 446 to 379, and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |
||
|
|
79d1e2639b |
M2: four voices on one clock
Play is the scheduler: one tick, four cursors. Every voice keeps its own place in its own track and its own count of how much longer the note it is holding lasts, so a voice playing whole notes and a voice playing eighths cost the same and never have to know about each other. They share the tick and nothing else. That is what makes the tick the smallest subdivision in the piece rather than a note length - it is the only unit four parts can agree on. A track is pairs of bytes: what to play, then how many ticks it lasts. MIDI notes stop at 127, so the top of the byte was free and neither the rest nor the end marker had to be invented - 0 is a rest and 0xFF ends the track. Examples/tune.asm spent zero on its end marker and so could not write a rest at all, which one voice can live with and four cannot: the silences are what make them separate parts rather than a chord. The state is four bytes a voice, cursor first because that is what LDD and STD move - a pointer through a pointer, which is what lets this be a loop over four voices instead of the same code four times. CALL preserves A and DP0-2, so a caller says which voice it means in two instructions. The piece is four bars of C, F, G, C with the parts moving at four different rates, because that is the thing one channel cannot do. All four channels get the same instrument, which is exactly what M3 replaces. fourVoiceTest staggers two voices so each gets a stretch alone: middle C while the other rests, the octave while the first is silent, then both. The first two are measured for pitch and the third for level, because TWO NOTES CANNOT BE ASKED THEIR PITCH - the crossing counter adds them and answers 785 hertz, which is 262 plus 523 and a fact about nothing. Verified with break.sh: dropping the channel select trips one check, pointing both voices at one cursor trips three. It shares Play's design and not its code, and is smaller - no track ends in it, so there is no live flag and no count of what is still playing. Play.sbx on the disk is why ten recordings moved: one added line each, and the file count with it. Nothing else in them changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW |