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
This commit is contained in:
Anachronaut
2026-09-05 15:03:12 -04:00
co-authored by Claude Opus 5
parent 1b251fb290
commit 79d1e2639b
15 changed files with 574 additions and 11 deletions
+40
View File
@@ -644,6 +644,46 @@ near "$EIGHTH" 523.3 2 \
&& result ok "and the one after it is the next note" "$EIGHTH hertz, an octave up as written" \
|| result no "and the one after it is the next note" "$EIGHTH hertz, wanted 523.3"
# ---- Four voices on one tick, and nothing else shared ----
#
# M2 of the music player. One clock, four cursors: each voice keeps its own place in its own
# track and its own count of how long the note it is holding lasts, so one can rest while
# another plays. That is the whole of what a channel cannot do on its own.
#
# The program stagger the two voices deliberately, so each gets a stretch where it is the only
# thing sounding and its pitch can be asked for. TWO NOTES AT ONCE CANNOT BE ASKED THEIR
# PITCH: the crossing counter adds them and answers with neither, which is 785 hertz for the
# third window below - the sum of 262 and 523 and a fact about nothing. So the overlap is
# checked for LEVEL instead, which is a question two notes can answer.
run fourVoices 1700000 < "$ROOT/Programs/testPrograms/fourVoiceTest.asm" || exit 1
COUNT="$(measure fourVoices count)"
near "$COUNT" 72000 0.1 \
&& result ok "twelve ticks of four voices" "$COUNT samples, and 12 x 125,000 cycles is 72,000" \
|| result no "twelve ticks of four voices" "$COUNT samples, wanted about 72,000"
FIRST="$(measure fourVoices pitch 2000 22000)"
near "$FIRST" 261.6 2 \
&& result ok "one voice plays while the other rests" "$FIRST hertz, and middle C is 261.6" \
|| result no "one voice plays while the other rests" "$FIRST hertz, wanted middle C alone"
SECOND="$(measure fourVoices pitch 26000 46000)"
near "$SECOND" 523.3 2 \
&& result ok "and then they change places" "$SECOND hertz, an octave up, with the first one silent" \
|| result no "and then they change places" "$SECOND hertz, wanted the octave alone"
# Both voices sounding is louder than either of them alone. Against the QUIETER of the two so
# that the check cannot be passed by one voice getting louder, and by a margin well under the
# two-fold a clean sum would give, because two notes are not in phase and do not simply add.
ALONE0="$(measure fourVoices peak 2000 22000)"
ALONE1="$(measure fourVoices peak 26000 46000)"
TOGETHER="$(measure fourVoices peak 50000 70000)"
QUIETER="$ALONE0"
[ "$ALONE1" -lt "$QUIETER" ] && QUIETER="$ALONE1"
[ "$TOGETHER" -gt "$(( QUIETER * 14 / 10 ))" ] \
&& result ok "and then both at once" "peak $TOGETHER against $ALONE0 and $ALONE1 alone" \
|| result no "and then both at once" "peak $TOGETHER, which is not above either of $ALONE0 and $ALONE1"
echo
if [ "$FAIL" -eq 0 ]; then
echo "All $PASS sound checks passed."