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
+327
View File
@@ -0,0 +1,327 @@
; Play.asm
; Four voices on one clock, which is what music is and one channel cannot be.
; Written by Anachronaut
;
; ---- One tick, four cursors ----
;
; The sound device has four channels and no idea when. The timer has a period and no idea
; what. This is the thing between them: on every tick it walks four voices, and each voice
; counts down the note it is holding and reads the next one when the count runs out.
;
; THE VOICES SHARE NOTHING BUT THE TICK. Each keeps its own place in its own track and its
; own count of how much longer the current note lasts, so a voice playing whole notes and a
; voice playing eighths cost the same and never have to know about each other. That is why
; the tick is the smallest subdivision in the piece rather than a note length: it is the
; only unit all four can agree on.
;
; ---- What a track is ----
;
; Pairs of bytes: what to play, then how many ticks it lasts.
;
; 0x01 to 0x7F a MIDI note. 60 is middle C and every 12 is an octave.
; 0x00 a rest - the ticks pass with nothing sounding
; 0xFF the track is over
;
; MIDI notes only reach 127, so the top of the byte was free and neither of those two had to
; be invented. Examples/tune.asm spent zero on its end marker and so had no way to write a
; rest at all, which one voice can just about live with and four cannot: voices do not all
; play at once, and the silences are what makes them separate parts rather than a chord.
;
; 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.
;
; ---- Where the state lives ----
;
; Four bytes a voice: two of cursor, one of count, one of live. The cursor is two bytes and
; first because that is what LDD and STD move - a pointer through a pointer, which is the
; whole reason this can be a loop over four voices instead of the same code written out four
; times.
;
; The counts start at one rather than zero, so the first pass through the loop runs every
; voice's count out and fetches its first event. Nothing has to be special-cased for the
; beginning, and the piece starts on the tick rather than one after it.
#Include services.asm
#Program
#Base 0x5000
start:
; ---- A sound to play it with ----
;
; The same on all four channels, which is exactly what M3 replaces: a patch per voice, out
; of the format SoundPatch already writes. Until then they are four of the same instrument,
; which is enough to hear four parts and not enough to enjoy them.
RSTA
CALL setUpChannel
INIA 0d1
CALL setUpChannel
INIA 0d2
CALL setUpChannel
INIA 0d3
CALL setUpChannel
INIA 0x60 ; Room over the top for four voices at once.
OUTA 0x46
; ---- The beat ----
;
; 125,000 cycles is a sixteenth note at 120 beats a minute, which is the smallest thing
; this piece divides a beat into. The period goes in before the control byte, because
; writing control with the run bit set is what loads it.
INIA 0x01
OUTA 0x52
INIA 0xE8
OUTA 0x53
INIA 0x48
OUTA 0x54
INIA 0x07
OUTA 0x51 ; Run, repeat, interrupt.
SIF
; ---- The voices, pointed at their tracks ----
;
; The cursors are written here rather than assembled into the table, because a label used
; as data does not come out as its address.
SETD.1 Voice0
SETD.0 Track0
CALL startVoice
SETD.1 Voice1
SETD.0 Track1
CALL startVoice
SETD.1 Voice2
SETD.0 Track2
CALL startVoice
SETD.1 Voice3
SETD.0 Track3
CALL startVoice
INIA 0d4
SETD.1 Playing
STA.1 ; Four tracks still running.
; ---- The loop ----
;
; Step every voice, see whether any of them is still going, and then sleep. Stepping before
; waiting is what makes the first note sound on the first tick rather than the second.
tickLoop:
SETD.1 Voice0
RSTA
CALL stepVoice
SETD.1 Voice1
INIA 0d1
CALL stepVoice
SETD.1 Voice2
INIA 0d2
CALL stepVoice
SETD.1 Voice3
INIA 0d3
CALL stepVoice
SETD.1 Playing
LDA.1
BRA finished
WAIT ; Nothing at all until the timer says a tick has gone by.
BRI tickLoop
finished:
; Let the last note ring rather than cutting it off, then give the timer back - stopped
; before the handler goes away, because an interrupt with nothing to catch it is a fault.
INIB 0d8
lastRing:
WAIT
DECB
BNB lastRing
CIF
RSTA
OUTA 0x51
SWI osExit
; ---- One voice, one tick ----
;
; A is the channel and DP1 is the voice's four bytes. Both survive the CALL that got here,
; which is what lets the caller say which voice it means in two instructions.
stepVoice:
SETD.3 ThisChannel
STA.3 ; A is wanted for other things between here and using it.
DPUP.1 0d3
LDA.1 ; Live?
DPDN.1 0d3
BRA stepDone
DPUP.1 0d2
LDA.1
DECA
STA.1 ; One tick less of whatever is sounding.
DPDN.1 0d2
BNA stepDone ; Still holding it.
; ---- The count ran out, so this is a boundary ----
;
; Select the channel FIRST. Every sound port below writes to whichever channel was last
; named, so a voice that forgot would be playing somebody else's part.
LDA.3 ; DP3 has held the channel since the top of this routine.
OUTA 0x41
RSTA
OUTA 0x45 ; Let go of the note that just ended.
LDD.0.1 ; DP0 is now this voice's place in its track.
LDA.0
INIB 0xFF
XOR ; XOR answers in Q and leaves A holding the note.
BRQ stepEnded
INCD.0
LDB.0 ; How many ticks it lasts.
INCD.0
STD.0.1 ; The cursor, moved past this event.
DPUP.1 0d2
STB.1 ; And the count it will be held for.
; A is still the note. Zero is a rest, which is a duration with nothing started.
BRA stepDone
OUTA 0x44 ; Writing the note is what starts it.
stepDone:
RET
stepEnded:
; The gate is already down, so the last note is fading. Mark the voice finished and say so.
DPUP.1 0d3
RSTA
STA.1
SETD.3 Playing
LDA.3
DECA
STA.3
RET
; DP1 is a voice and DP0 its track. Sets the cursor, and a count of one so that the first
; pass through the loop fetches the first event.
startVoice:
STD.0.1
DPUP.1 0d2
INIA 0d1
STA.1 ; Count: one tick, which runs out immediately.
INCD.1
STA.1 ; Live.
RET
; A plucked sound, on the channel named by A.
setUpChannel:
OUTA 0x41
RSTA
OUTA 0x42
INIA 0d2
OUTA 0x43 ; A saw, which has harmonics enough to hear a filter on.
INIA 0x01
OUTA 0x42
INIA 0xFF
OUTA 0x43 ; Oscillator 0 at full gain
INIA 0x05
OUTA 0x42
INIA 0x01
OUTA 0x43 ; and switched on, which is a separate write and the one that
; matters: a gain on an oscillator that is off does nothing.
INIA 0x20
OUTA 0x42
INIA 0d8
OUTA 0x43 ; Almost no attack
INIA 0x21
OUTA 0x42
INIA 0d140
OUTA 0x43 ; a long decay
INIA 0x22
OUTA 0x42
INIA 0d60
OUTA 0x43 ; held quietly rather than at nothing, so a long note is still
; there when the next one starts
INIA 0x23
OUTA 0x42
INIA 0d40
OUTA 0x43 ; and a short release when the gate drops.
RET
; The tick has nothing to do: the loop above is the player and WAIT only needs something to
; have happened. A handler still has to exist, because an interrupt with nothing installed to
; catch it is a fault. Taking it is what brings the line down - a program that POLLED the
; timer would have to read 0x50 instead.
tick:
RETI
#Data
#Base 0x3000
; Four bytes a voice: cursor high, cursor low, count, live.
Voice0:
0x00 0x00 0x00 0x00
Voice1:
0x00 0x00 0x00 0x00
Voice2:
0x00 0x00 0x00 0x00
Voice3:
0x00 0x00 0x00 0x00
Playing:
0x00
ThisChannel:
0x00
; ---- Four bars of C, F, G, C ----
;
; A bar is sixteen ticks. The parts move at four different rates on purpose: this is the
; thing one channel cannot do, so it is the thing worth playing.
; The melody, in quarters and halves.
Track0:
0d64 0d4 ; E
0d67 0d4 ; G
0d72 0d8 ; C, held
0d65 0d4 ; F
0d69 0d4 ; A
0d72 0d8 ; C
0d62 0d4 ; D
0d67 0d4 ; G
0d71 0d8 ; B
0d72 0d16 ; and home
0xFF
; A second part underneath it, in halves.
Track1:
0d60 0d8 ; C
0d64 0d8 ; E
0d57 0d8 ; A
0d60 0d8 ; C
0d59 0d8 ; B
0d62 0d8 ; D
0d64 0d16 ; E
0xFF
; The bass, one note to a bar.
Track2:
0d48 0d16 ; C
0d41 0d16 ; F
0d43 0d16 ; G
0d48 0d16 ; C
0xFF
; And an arpeggio in eighths, which is the part that proves the others are not waiting for it.
Track3:
0d72 0d2 0d76 0d2 0d79 0d2 0d76 0d2
0d72 0d2 0d76 0d2 0d79 0d2 0d76 0d2
0d77 0d2 0d81 0d2 0d84 0d2 0d81 0d2
0d77 0d2 0d81 0d2 0d84 0d2 0d81 0d2
0d79 0d2 0d83 0d2 0d86 0d2 0d83 0d2
0d79 0d2 0d83 0d2 0d86 0d2 0d83 0d2
0d00 0d16 ; and out, so the last bar is the other three
0xFF
#Vectors
Boot start
Device 0x50 tick
+1
View File
@@ -720,6 +720,7 @@ from every assembly file in it. Several are old programs written for the bare ma
| Life | Conway's Game of Life, which had to be taught to stop, since a program that never ends takes the shell with it. Polls the console between generations. |
| Snake | A game. Draws a whole screen with cursor addressing and steers with single keys, asking the console once a frame and never waiting. |
| Keys | The console interrupting rather than being asked. The only one that brings a vector of its own, which is what the version two format exists for. |
| Play | Four voices on one clock, which is what music is and one channel cannot be. The timer keeps a tick and 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 the parts move at four different rates and share nothing but the beat. A track is pairs of bytes, what to play and how many ticks it lasts: 1 to 127 is a MIDI note, zero is a rest, and 255 ends it - MIDI stops at 127, so neither of those had to be invented. A note's duration is its whole life and the gate goes down when the count runs out, which means a gap between two notes is written as a rest rather than invented by the player out of some fraction it decided on. The tune is assembled in for now; reading one from a file is what makes it a player rather than a program with one song in it. It spends over ninety nine per cent of its time asleep, because a beat is something to be woken by rather than counted up to. |
| Say | Prints whatever it was told, which is the shortest thing that shows osArgument working. |
| Reboot | Starts the machine again, in 45 bytes. Writes a port rather than asking the system, because a reset has to work when the system does not. |
| Once | Asks the loader to start something else on the next start, and only that one, in 569 bytes. |
+181
View File
@@ -0,0 +1,181 @@
; Four voices on one tick, staggered so that each can be heard on its own.
;
; M2 of the music player. What is being checked is that the voices share the tick and NOTHING
; ELSE: each 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.
;
; Twelve ticks of 125,000 cycles, which is a sixteenth note at 120 beats a minute:
;
; ticks 0-3 voice 0 plays middle C voice 1 rests
; ticks 4-7 voice 0 rests voice 1 plays the C above it
; ticks 8-11 voice 0 plays middle C voice 1 plays the C above it
;
; So the first two windows have one voice in them and can be measured for pitch, and the third
; has both and can be measured for level. Two voices sounding at once cannot be asked their
; pitch - a crossing counter given two notes answers with neither.
;
; THIS IS DELIBERATELY NOT Apps/Play.asm. It shares the design and not the code, and it is
; smaller: no track ever ends here, so there is no live flag and no count of what is still
; playing. What it has is the part worth pinning - a cursor and a countdown per voice, moved
; with LDD and STD.
;
; Written by Anachronaut
#Program
start:
RSTA
CALL setUpChannel
INIA 0d1
CALL setUpChannel
INIA 0x60
OUTA 0x46
; 125,000 cycles a tick. The period before the control byte, because writing control with
; the run bit set is what loads it.
INIA 0x01
OUTA 0x52
INIA 0xE8
OUTA 0x53
INIA 0x48
OUTA 0x54
INIA 0x07
OUTA 0x51
SIF
SETD.1 Voice0
SETD.0 Track0
CALL startVoice
SETD.1 Voice1
SETD.0 Track1
CALL startVoice
; Twelve ticks. B survives the calls below, because a CALL saves it.
INIB 0d12
tickLoop:
SETD.1 Voice0
RSTA
CALL stepVoice
SETD.1 Voice1
INIA 0d1
CALL stepVoice
DECB
BRB lastTick
WAIT
BRI tickLoop
lastTick:
; The twelfth step started a note and nothing has waited for it yet. Without this the last
; tick is never rendered, and a window measuring it runs off the end of the samples.
WAIT
done:
CIF
RSTA
OUTA 0x51
HALT
; A is the channel and DP1 the voice's four bytes: cursor high, cursor low, count, spare.
; Both survive the CALL that got here.
stepVoice:
SETD.3 ThisChannel
STA.3
DPUP.1 0d2
LDA.1
DECA
STA.1 ; One tick less of whatever is sounding.
DPDN.1 0d2
BNA stepDone
; The count ran out. Select the channel first: every sound port writes to whichever channel
; was named last, so a voice that forgot would be playing somebody else's part.
LDA.3
OUTA 0x41
RSTA
OUTA 0x45 ; Let go of the note that just ended.
LDD.0.1 ; DP0 is this voice's place in its track.
LDA.0
INCD.0
LDB.0 ; How many ticks it lasts.
INCD.0
STD.0.1
DPUP.1 0d2
STB.1
; A is still the note. Zero is a rest: the ticks pass with nothing started.
BRA stepDone
OUTA 0x44
stepDone:
RET
; DP1 a voice, DP0 its track. A count of one, so the first pass fetches the first event.
startVoice:
STD.0.1
DPUP.1 0d2
INIA 0d1
STA.1
RET
; Held at full and released quickly, so a note lasts exactly its ticks and the boundary
; between two of them is something a counter can find.
setUpChannel:
OUTA 0x41
RSTA
OUTA 0x42
INIA 0d2
OUTA 0x43 ; Saw
INIA 0x01
OUTA 0x42
INIA 0xFF
OUTA 0x43
INIA 0x05
OUTA 0x42
INIA 0x01
OUTA 0x43 ; Oscillator 0, on and loud.
INIA 0x20
OUTA 0x42
RSTA
OUTA 0x43 ; No attack
INIA 0x21
OUTA 0x42
RSTA
OUTA 0x43 ; no decay
INIA 0x22
OUTA 0x42
INIA 0xFF
OUTA 0x43 ; and held at full.
INIA 0x23
OUTA 0x42
INIA 0d5
OUTA 0x43
RET
tick:
RETI
#Data
Voice0:
0x00 0x00 0x00 0x00
Voice1:
0x00 0x00 0x00 0x00
ThisChannel:
0x00
; Middle C, then out of the way, then back - against the octave above doing the opposite.
Track0:
0d60 0d4
0d00 0d4
0d60 0d4
0d00 0xFF
Track1:
0d00 0d4
0d72 0d4
0d72 0d4
0d00 0xFF
#Vectors
Boot start
Device 0x50 tick
+2 -1
View File
@@ -32,6 +32,7 @@ vars.script 50
blocks.script 343
loops.script 272
tune.sbx 318
Play.sbx 446
notes.txt 21
Apps <dir>
hi.script 121
@@ -43,7 +44,7 @@ outer.script 376
inner.script 44
loop.script 35
crossed.txt 560
30 files, 1 directory
31 files, 1 directory
> exit
halted
Execution halted.
+2 -1
View File
@@ -22,6 +22,7 @@ vars.script 50
blocks.script 343
loops.script 272
tune.sbx 318
Play.sbx 446
notes.txt 21
Apps <dir>
hi.script 121
@@ -32,7 +33,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
29 files, 1 directory
30 files, 1 directory
> drive 1
> dir
other.txt 28
+2 -1
View File
@@ -39,6 +39,7 @@ vars.script 50
blocks.script 343
loops.script 272
tune.sbx 318
Play.sbx 446
notes.txt 21
Apps <dir>
hi.script 121
@@ -49,7 +50,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
29 files, 1 directory
30 files, 1 directory
> exit
halted
Execution halted.
+2 -1
View File
@@ -29,6 +29,7 @@ vars.script 50
blocks.script 343
loops.script 272
tune.sbx 318
Play.sbx 446
notes.txt 21
Apps <dir>
hi.script 121
@@ -39,7 +40,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
29 files, 1 directory
30 files, 1 directory
> exit
halted
Execution halted.
+2 -1
View File
@@ -29,6 +29,7 @@ vars.script 50
blocks.script 343
loops.script 272
tune.sbx 318
Play.sbx 446
notes.txt 21
Apps <dir>
hi.script 121
@@ -39,7 +40,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
29 files, 1 directory
30 files, 1 directory
> exit
halted
Execution halted.
+2 -1
View File
@@ -115,6 +115,7 @@ vars.script 50
blocks.script 343
loops.script 272
tune.sbx 318
Play.sbx 446
notes.txt 21
Apps <dir>
hi.script 121
@@ -125,7 +126,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
29 files, 1 directory
30 files, 1 directory
> exit
halted
Execution halted.
+2 -1
View File
@@ -34,6 +34,7 @@ vars.script 50
blocks.script 343
loops.script 272
tune.sbx 318
Play.sbx 446
notes.txt 21
Apps <dir>
hi.script 121
@@ -44,7 +45,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
29 files, 1 directory
30 files, 1 directory
> exit
halted
Execution halted.
+2 -1
View File
@@ -22,6 +22,7 @@ vars.script 50
blocks.script 343
loops.script 272
tune.sbx 318
Play.sbx 446
notes.txt 21
Apps <dir>
hi.script 121
@@ -32,7 +33,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
29 files, 1 directory
30 files, 1 directory
> load
load what?
> load nosuch.sbx
+2 -1
View File
@@ -20,6 +20,7 @@ vars.script 50
blocks.script 343
loops.script 272
tune.sbx 318
Play.sbx 446
notes.txt 21
Apps <dir>
hi.script 121
@@ -30,7 +31,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
29 files, 1 directory
30 files, 1 directory
> load Say.sbx
loaded, starting at 5000
> run the disk took its time
+2 -1
View File
@@ -29,6 +29,7 @@ vars.script 50
blocks.script 343
loops.script 272
tune.sbx 318
Play.sbx 446
notes.txt 21
Apps <dir>
hi.script 121
@@ -39,7 +40,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
29 files, 1 directory
30 files, 1 directory
> exit
halted
Execution halted.
+5 -1
View File
@@ -175,11 +175,15 @@ printf '#! script\nset colour red\nif same $colour red\n echo it is red\n if s
printf '#! script\nfor a in 1 2\n for b in x y\n echo $a$b\n end\nend\nset n go\nwhile same $n go\n echo round\n set n stop\nend\nfor c in p q\n if same $c p\n echo only p\n end\nend\nif same 1 2\n for d in never\n echo not this\n end\nend\nfor e in\n echo no words\nend\necho finished\n' > loops.script
"$TOOL" put "$DISKS/cosmos.img" loops.script >/dev/null
# tune.sbx, which used to be a boot image and is now a program like any other. It brings a
# vector of its own - the screen's, so that it has a beat to play to - which makes it the
# vector of its own - the timer's, so that it has a beat to play to - which makes it the
# second thing here that the version two format exists for.
"$ROOT/Assembler" -I "$ROOT/Programs/CosmOS/Source" \
"$ROOT/Programs/Examples/tune.asm" -o "$WORK/tune.sbx" >/dev/null
"$TOOL" put "$DISKS/cosmos.img" "$WORK/tune.sbx" >/dev/null
# And Play, which is the same beat carrying four voices instead of one.
"$ROOT/Assembler" -I "$ROOT/Programs/CosmOS/Source" \
"$ROOT/Programs/CosmOS/Apps/Play.asm" -o "$WORK/Play.sbx" >/dev/null
"$TOOL" put "$DISKS/cosmos.img" "$WORK/Play.sbx" >/dev/null
printf 'this is not a program' > notes.txt
"$TOOL" put "$DISKS/cosmos.img" notes.txt >/dev/null
+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."