Files
SplitBit-Emulator/Tests/sound.sh
T
AnachronautandClaude Opus 5 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
2026-09-06 09:37:08 -04:00

926 lines
46 KiB
Bash
Executable File

#!/usr/bin/env bash
# Checks what the sound device actually makes.
#
# THE SUITE HAS NO SPEAKER, and a sound nothing can hear is a sound nothing checks. So the
# device makes its samples against the machine's clock rather than the host's, and the machine
# can be asked to save them with --sound. Every check below runs a program for a fixed number
# of cycles, saves the samples and measures them - no audio hardware, no timing luck, and the
# same answer every time.
#
# This is the same argument as Tests/video.sh, and it has the same consequence: each check is
# a named claim about one behaviour rather than a comparison against a recorded waveform. A
# recorded waveform would say "it sounds different" and leave which of the oscillator, the
# envelope, the filter, the channel selector or the sample clock broke to be found by ear.
#
# Written by Anachronaut
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD="$ROOT/Tests/build/sound"
ASM="$ROOT/Assembler"
EMU="$ROOT/SplitBit"
for tool in "$ASM" "$EMU"; do
[ -x "$tool" ] || { echo "$(basename "$tool") is not built."; exit 1; }
done
rm -rf "$BUILD"; mkdir -p "$BUILD"
PASS=0
FAIL=0
FAILED_NAMES=()
GREEN=$'\033[32m'; RED=$'\033[31m'; RESET=$'\033[0m'
[ -t 1 ] || { GREEN=""; RED=""; RESET=""; }
result() {
# result <ok|no> <name> <detail>
if [ "$1" = "ok" ]; then
PASS=$((PASS + 1)); printf " [%sok %s] %-40s %s\n" "$GREEN" "$RESET" "$2" "$3"
else
FAIL=$((FAIL + 1)); FAILED_NAMES+=("$2")
printf " [%sFAIL%s] %-40s %s\n" "$RED" "$RESET" "$2" "$3"
fi
}
# ---- Writing to the device from a program ----
port() {
# port <port> <byte>
printf ' INIA 0x%02X\n OUTA 0x%02X\n' $(( $2 & 0xFF )) $(( $1 & 0xFF ))
}
param() {
# param <parameter> <value> - the selector and value pair, which is three writes.
printf ' INIA 0x%02X\n OUTA 0x42\n INIA 0x%02X\n OUTA 0x43\n' \
$(( $1 & 0xFF )) $(( $2 & 0xFF ))
}
# The one write a sounding program cannot skip: an oscillator arrives silent.
loud() {
param 0x01 0xFF
}
# About a tenth of a second each hundred, in cycles: the inner loop is a DECA and a BNA, which
# is four cycles, 256 times round. The tag is so that more than one can sit in a program.
pause() {
# pause <tag> <hundredths>
printf ' INIB 0d%d\npauseOuter%s:\n RSTA\npauseInner%s:\n DECA\n BNA pauseInner%s\n DECB\n BNB pauseOuter%s\n' \
"$2" "$1" "$1" "$1" "$1"
}
# Says whether anything is sounding, as a letter, because the status bit is not a character
# and a test reads the console.
sounding() {
# sounding <tag>
printf ' INA 0x40\n INIB 0x01\n AND\n BRQ quiet%s\n INIA 0d89\n OUTA 0x00\n BRI after%s\nquiet%s:\n INIA 0d78\n OUTA 0x00\nafter%s:\n' \
"$1" "$1" "$1" "$1"
}
# Most programs here never finish on purpose: they set a sound going and then spin, and the
# cycle limit decides how long was recorded. That makes the sample count exact rather than
# dependent on how long the program took to get there.
spinForever() {
printf 'spinEnd:\n BRI spinEnd\n'
}
epilogue() {
printf ' HALT\n#Vectors\n Boot start\n'
}
# Assembles what is on standard input, runs it for a fixed number of cycles, and leaves the
# samples in $BUILD/<name>.raw.
run() {
# run <name> <cycles>
local name="$1"
cat > "$BUILD/$name.asm"
"$ASM" -I "$ROOT/Programs/Libraries" -I "$ROOT/Programs/Sounds" \
"$BUILD/$name.asm" -o "$BUILD/$name.bin" >"$BUILD/$name.log" 2>&1 || {
echo "could not assemble $name"; sed 's/^/ /' "$BUILD/$name.log"; return 1; }
timeout 20 "$EMU" --fast --cycles "$2" --sound "$BUILD/$name.raw" "$BUILD/$name.bin" \
> "$BUILD/$name.out" 2>&1
if [ $? -eq 124 ]; then
echo " $name did not finish within twenty seconds"; return 1
fi
return 0
}
# ---- Reading the samples back ----
#
# Raw signed 16 bit, little endian, which is what --sound writes.
measure() {
# measure <name> <what> [from] [to] - samples are given in samples, not seconds.
python3 - "$BUILD/$1.raw" "$2" "${3:-0}" "${4:-0}" <<'PY'
import struct, sys
data = open(sys.argv[1], "rb").read()
count = len(data) // 2
values = struct.unpack("<%dh" % count, data)
what = sys.argv[2]
start = int(sys.argv[3])
stop = int(sys.argv[4]) or count
window = values[start:stop]
if what == "count":
print(count)
elif what == "peak":
print(max((abs(v) for v in window), default=0))
elif what == "pitch":
# ---- Counted with hysteresis, not on the sign ----
#
# A waveform fading through nothing crosses zero many times on its way, and counting
# every sign change would hear a decaying note as a very high one. So a crossing is only
# counted after the signal has been convincingly on one side: above a tenth of the peak,
# then below minus a tenth.
peak = max((abs(v) for v in window), default=0)
if peak == 0:
print("0.0")
else:
gate = peak // 10
crossings, side = 0, 0
for v in window:
if side <= 0 and v > gate:
side = 1
crossings += 1
elif side >= 0 and v < -gate:
side = -1
crossings += 1
print("%.1f" % (crossings / 2.0 / (len(window) / 48000.0)))
PY
}
# A number against another, within a percentage of it.
near() {
# near <value> <wanted> <percent>
python3 -c "import sys; a,b,p = (float(x) for x in sys.argv[1:4]); sys.exit(0 if b and abs(a-b) <= b*p/100.0 else 1)" \
"$1" "$2" "$3"
}
said() {
grep -q -- "$2" "$BUILD/$1.out"
}
echo "Checking what the sound device makes."
echo
# ---- Nothing, until something asks ----
#
# A machine that hummed on its own would make every check below meaningless, and the samples
# would still all be there to count.
{ printf '#Program\nstart:\n'; spinForever; printf '#Vectors\n Boot start\n'; } \
| run quiet 1000000 || exit 1
PEAK="$(measure quiet peak)"
[ "$PEAK" = "0" ] \
&& result ok "silent until something asks" "every sample is nothing" \
|| result no "silent until something asks" "peak was $PEAK"
# ---- The sample clock is the machine's clock ----
#
# Forty-eight thousand a second against a million cycles. This is the claim the whole file
# rests on: if samples came from the host, nothing below would be reproducible.
COUNT="$(measure quiet count)"
[ "$COUNT" = "48000" ] \
&& result ok "samples keep the machine's time" "1,000,000 cycles made exactly 48,000" \
|| result no "samples keep the machine's time" "1,000,000 cycles made $COUNT, wanted 48,000"
{ printf '#Program\nstart:\n'; spinForever; printf '#Vectors\n Boot start\n'; } \
| run quietLonger 3000000 || exit 1
COUNT="$(measure quietLonger count)"
[ "$COUNT" = "144000" ] \
&& result ok "and keeps it over a longer run" "3,000,000 cycles made exactly 144,000" \
|| result no "and keeps it over a longer run" "3,000,000 cycles made $COUNT, wanted 144,000"
# ---- A channel arrives able to make a sound ----
#
# Writing a note number and hearing that note is the shortest useful thing this device can be
# asked to do, and it works from a cold start. soundThing's own defaults do not do this - they
# are a patch editor's, where one voice is set up and the rest wait to be copied over - so the
# device sets its own power-on state and this is the check that it did.
{ printf '#Program\nstart:\n'; port 0x41 0x00; port 0x44 60; spinForever
printf '#Vectors\n Boot start\n'; } | run bareNote 1000000 || exit 1
PEAK="$(measure bareNote peak)"
[ "$PEAK" -gt 1000 ] \
&& result ok "a channel arrives ready to sound" "a note and nothing else made $PEAK" \
|| result no "a channel arrives ready to sound" "peak was only $PEAK"
# ---- And the second oscillator arrives off ----
#
# Which is not the same thing as silent, because the two are averaged rather than added: one
# that is switched on halves the other whatever its gain. So the plain case is one oscillator,
# and asking for two is a thing a program says out loud.
{ printf '#Program\nstart:\n'; port 0x41 0x00; loud
param 0x15 0x01 # oscillator 1, switched on
param 0x11 0x00 # and silent
port 0x44 60; spinForever; printf '#Vectors\n Boot start\n'; } | run twoOscs 1000000 || exit 1
{ printf '#Program\nstart:\n'; port 0x41 0x00; loud; port 0x44 60; spinForever
printf '#Vectors\n Boot start\n'; } | run middleC 1000000 || exit 1
BOTH="$(measure twoOscs peak)"
ONE="$(measure middleC peak)"
near "$BOTH" "$((ONE / 2))" 10 \
&& result ok "two oscillators share the level" "$ONE alone, $BOTH with a silent one beside it" \
|| result no "two oscillators share the level" "$ONE alone and $BOTH with a second, wanted about half"
PEAK="$(measure middleC peak)"
[ "$PEAK" -gt 1000 ] \
&& result ok "a note makes a sound" "peak $PEAK" \
|| result no "a note makes a sound" "peak was only $PEAK"
# ---- The note it was asked for ----
#
# 60 is middle C, 261.63 Hz. Two per cent is well inside a semitone, which is six.
PITCH="$(measure middleC pitch 24000)"
near "$PITCH" 261.63 2 \
&& result ok "and it is the note asked for" "$PITCH Hz, middle C is 261.63" \
|| result no "and it is the note asked for" "$PITCH Hz, wanted 261.63"
# ---- Twelve is an octave ----
#
# The pitch check above would pass on a device that played one fixed tone. This one would not.
{ printf '#Program\nstart:\n'; port 0x41 0x00; loud; port 0x44 72; spinForever
printf '#Vectors\n Boot start\n'; } | run octaveUp 1000000 || exit 1
OCTAVE="$(measure octaveUp pitch 24000)"
near "$OCTAVE" 523.25 2 \
&& result ok "twelve notes up is an octave" "$OCTAVE Hz, twice $PITCH" \
|| result no "twelve notes up is an octave" "$OCTAVE Hz, wanted 523.25"
# ---- The same program makes the same sound ----
#
# The point of a clock that is not the host's. Without this every check above is a check on
# how busy the machine running the suite happened to be.
{ printf '#Program\nstart:\n'; port 0x41 0x00; loud; port 0x44 60; spinForever
printf '#Vectors\n Boot start\n'; } | run middleCAgain 1000000 || exit 1
cmp -s "$BUILD/middleC.raw" "$BUILD/middleCAgain.raw" \
&& result ok "the same program makes the same sound" "two runs, byte for byte" \
|| result no "the same program makes the same sound" "the two runs differ"
# ---- Gain is a level and not a switch ----
{ printf '#Program\nstart:\n'; port 0x41 0x00; param 0x01 0x40; port 0x44 60; spinForever
printf '#Vectors\n Boot start\n'; } | run quarterGain 1000000 || exit 1
FULL="$(measure middleC peak)"
QUARTER="$(measure quarterGain peak)"
near "$QUARTER" "$((FULL / 4))" 20 \
&& result ok "gain sets the level" "a quarter of $FULL is $QUARTER" \
|| result no "gain sets the level" "a quarter of $FULL came out $QUARTER"
# ---- And the device's own volume is over the top of it ----
{ printf '#Program\nstart:\n'; port 0x41 0x00; loud; port 0x46 0x00; port 0x44 60
spinForever; printf '#Vectors\n Boot start\n'; } | run noVolume 1000000 || exit 1
PEAK="$(measure noVolume peak)"
[ "$PEAK" = "0" ] \
&& result ok "volume nothing is silence" "a full note at volume nothing makes nothing" \
|| result no "volume nothing is silence" "peak was $PEAK"
# ---- A channel is its own voice ----
#
# Turn channel 0's gain down, then play on channel 1. Channel 1 should still be at the gain it
# arrived with, and anything else would mean the settings are the device's rather than the
# channel's - the difference between four voices and one that four things fight over.
#
# This is deliberately a comparison of LEVELS and not a check that channel 1 is silent. It was
# the latter once, and it passed for a year's worth of the wrong reason: channel 1 could not
# make a sound at all, so a device with one shared set of settings would have passed it too.
{ printf '#Program\nstart:\n'; port 0x41 0x00; param 0x01 0x40; port 0x41 0x01; port 0x44 60
spinForever; printf '#Vectors\n Boot start\n'; } | run otherChannel 1000000 || exit 1
QUIETED="$(measure quarterGain peak)"
OTHER="$(measure otherChannel peak)"
near "$OTHER" "$FULL" 5 \
&& result ok "a channel keeps its own settings" "channel 0 turned down to $QUIETED, channel 1 still $OTHER" \
|| result no "a channel keeps its own settings" "channel 1 came out $OTHER, channel 0's own gain gives $QUIETED"
# ---- All four of them are there ----
{ printf '#Program\nstart:\n'; port 0x41 0x03; port 0x44 60; spinForever
printf '#Vectors\n Boot start\n'; } | run lastChannel 1000000 || exit 1
PEAK="$(measure lastChannel peak)"
[ "$PEAK" -gt 1000 ] \
&& result ok "the fourth channel is a channel" "peak $PEAK" \
|| result no "the fourth channel is a channel" "peak was only $PEAK"
# ---- Asking for a channel that is not there ----
#
# It wraps rather than faulting or writing past the end of the voices. A sound device is a
# poor place to stop the machine, and a poor place to corrupt memory.
{ printf '#Program\nstart:\n'; port 0x41 6
printf ' INA 0x41\n INIB 0d48\n CCF\n ADD\n OUTQ 0x00\n'
epilogue; } | run wrapChannel 200000 || exit 1
said wrapChannel "2" \
&& result ok "a channel number wraps" "6 selected channel 2" \
|| result no "a channel number wraps" "$(cat "$BUILD/wrapChannel.out")"
# ---- The registers read back ----
#
# So a handler can save the selection and put it back, which it has to, since an interrupt in
# the middle of a patch load would otherwise land the rest of the patch on another channel.
{ printf '#Program\nstart:\n'; port 0x42 0x2A
printf ' INA 0x42\n OUTA 0x00\n'; epilogue; } | run readBack 200000 || exit 1
said readBack '\*' \
&& result ok "the selectors read back" "0x2A came back as itself" \
|| result no "the selectors read back" "$(od -c "$BUILD/readBack.out" | head -1)"
# ---- Status: nothing is sounding until something is ----
{ printf '#Program\nstart:\n'; sounding Before
port 0x41 0x00; loud; port 0x44 60; sounding After; epilogue; } \
| run statusBit 500000 || exit 1
said statusBit "NY" \
&& result ok "status says what is sounding" "nothing before the note, something after" \
|| result no "status says what is sounding" "said $(cat "$BUILD/statusBit.out")"
# ---- Not even a sustain of nothing ends a note ----
#
# THE TRAP, and it caught the person writing this device before it caught anybody else. A
# sustain of nothing is silent, and silence looks exactly like a finished note, so the obvious
# way to play a note and wait for it is to give it no sustain and watch the status bit. It
# never comes down: the voice is holding at nothing, which is a thing a held key does.
#
# There is one rule and this is the check that there are not two. A NOTE SOUNDS UNTIL IT IS
# GATED OFF. What the envelope is doing does not enter into it.
{ printf '#Program\nstart:\n'; port 0x41 0x00
param 0x20 0x00 # attack: none
param 0x21 40 # decay: short
param 0x22 0x00 # sustain: nothing at all
port 0x44 60
printf 'waitOut:\n INA 0x40\n INIB 0x01\n AND\n BNQ waitOut\n'
printf ' INIA 0d90\n OUTA 0x00\n' # 'Z', reached only if the bit came down
epilogue; } | run silentSustain 3000000 || exit 1
said silentSustain "Z" \
&& result no "silence is not the end of a note" "the bit came down without a gate off" \
|| result ok "silence is not the end of a note" "silent for three million cycles and still sounding"
# And the sound really did go quiet, so the check above is about the status bit rather than
# about an envelope that never decayed.
PEAK="$(measure silentSustain peak 24000)"
[ "$PEAK" -lt 100 ] \
&& result ok "even though there is nothing to hear" "faded to $PEAK while still sounding" \
|| result no "even though there is nothing to hear" "still $PEAK, it never decayed"
# ---- A note that is held is not one ----
#
# The trap the manual warns about: a note with sustain sounds until the gate is dropped, so a
# program that waits for it waits for ever. Here the wait is bounded by the cycle limit, and
# the marker not being printed is the whole point.
{ printf '#Program\nstart:\n'; port 0x41 0x00; loud
param 0x22 0xFF # sustain: all of it
port 0x44 60
printf 'held:\n INA 0x40\n INIB 0x01\n AND\n BNQ held\n'
printf ' INIA 0d90\n OUTA 0x00\n'
epilogue; } | run heldNote 3000000 || exit 1
said heldNote "Z" \
&& result no "a held note keeps sounding" "the status bit came down on its own" \
|| result ok "a held note keeps sounding" "still sounding after three million cycles"
# ---- And dropping the gate is what ends it ----
{ printf '#Program\nstart:\n'; port 0x41 0x00; loud
param 0x22 0xFF # sustain: all of it
param 0x23 20 # release: short
port 0x44 60
pause A 100
port 0x45 0x00 # let go
printf 'released:\n INA 0x40\n INIB 0x01\n AND\n BNQ released\n'
printf ' INIA 0d90\n OUTA 0x00\n'
epilogue; } | run gateOff 3000000 || exit 1
said gateOff "Z" \
&& result ok "dropping the gate ends it" "the note released and the bit came down" \
|| result no "dropping the gate ends it" "it was still sounding at the cycle limit"
# ---- The level can be shaped by nothing at all ----
#
# soundThing welded the first envelope to the output, so an envelope spent on a filter sweep
# still had to be shaped like something worth hearing. Setting 0x50 to nothing is what unwelds
# it. The check is that an amplitude envelope which decays to silence immediately does NOT
# silence a channel whose level nothing shapes.
{ printf '#Program\nstart:\n'; port 0x41 0x00; loud
param 0x20 0x00 # attack: none
param 0x21 20 # decay: very short
param 0x22 0x00 # sustain: nothing, so the envelope is at zero
param 0x50 0x00 # and nothing shapes the level
port 0x44 60
spinForever; printf '#Vectors\n Boot start\n'; } | run levelSource 1000000 || exit 1
LATE="$(measure levelSource peak 36000)"
[ "$LATE" -gt 1000 ] \
&& result ok "the level can be shaped by nothing" "still $LATE long after the envelope let go" \
|| result no "the level can be shaped by nothing" "peak $LATE, the envelope silenced it anyway"
# And the same patch with the envelope back on the level is silent by then, which is what
# makes the check above about the setting rather than about the envelope not working.
{ printf '#Program\nstart:\n'; port 0x41 0x00; loud
param 0x20 0x00; param 0x21 20; param 0x22 0x00
param 0x50 1 # the amplitude envelope, which is the normal case
port 0x44 60
spinForever; printf '#Vectors\n Boot start\n'; } | run levelEnvelope 1000000 || exit 1
LATE="$(measure levelEnvelope peak 36000)"
[ "$LATE" -lt 100 ] \
&& result ok "and by the envelope, which is the normal case" "faded to $LATE" \
|| result no "and by the envelope, which is the normal case" "still $LATE, it never faded"
# ---- A triggered voice ends itself ----
#
# Gated is what a keyboard wants: the sound lasts as long as something holds it, and dropping
# the gate is what starts the release. A GAME is nearly all one-shots - a bang, a pickup, a
# door - and not one of them wants its length decided by how long a note was held.
#
# Neither program below ever drops the gate. The gated one is still sounding at the end of it,
# because a sustain above nothing is a voice waiting for a key that is never coming; the
# triggered one runs its decay to nothing and finishes.
for gate in 0 1; do
{ printf '#Program\nstart:\n'; port 0x41 0x00; loud
param 0x51 $gate # gated or triggered
param 0x20 0x00 # attack: instant
param 0x21 60 # decay: short
param 0x22 128 # sustain: half, which a gated voice will hold at
port 0x44 60; spinForever
printf '#Vectors\n Boot start\n'; } | run gate$gate 3000000 || exit 1
done
HELD="$(measure gate0 peak 96000)"
STRUCK="$(measure gate1 peak 96000)"
[ "$HELD" -gt 1000 ] \
&& result ok "a gated voice waits to be let go of" "still $HELD with nothing holding it" \
|| result no "a gated voice waits to be let go of" "faded to $HELD on its own"
[ "$STRUCK" -lt 100 ] \
&& result ok "and a triggered one ends itself" "down to $STRUCK, with no gate ever dropped" \
|| result no "and a triggered one ends itself" "still $STRUCK, so it is waiting for a key"
# ---- And it is the same one-shot twice ----
#
# The point of the whole thing. A triggered voice re-arms its oscillators, so a hit begins in
# the same place every time; a free LFO does not, and reading it at a different moment makes
# the same drum a different drum. Both halves are needed, which is why this asks for the LFO
# to restart as well.
#
# Two hits, compared SAMPLE FOR SAMPLE. Noise is the hard case and the reason the generator is
# seeded: with rand() this could not have been asked at all.
#
# THE GAP HAS TO OUTLAST THE DECAY. The first version struck the second note while the first
# was still ringing, so what it found and compared as "the second hit" was a point in the
# middle of the first one's tail. A short decay and a quarter second between them, and the two
# are found by looking for sound after silence rather than for sound after an offset.
{ printf '#Program\nstart:\n'; port 0x41 0x00; loud
param 0x00 5 # noise
param 0x51 0x01 # triggered
param 0x20 0x00; param 0x21 40; param 0x22 0x00
param 0x60 0x01 # LFO 0 on,
param 0x62 40 # a slow rate,
param 0x63 0x01 # and it starts over with each voice
param 0x44 0x03 # which opens the filter
param 0x40 0x01; param 0x42 60; param 0x45 200
port 0x44 60; pause one 250
port 0x44 60; spinForever
printf '#Vectors\n Boot start\n'; } | run twice 4000000 || exit 1
SAME="$(python3 - "$BUILD/twice.raw" <<'PY'
import struct, sys
data = open(sys.argv[1], "rb").read()
v = struct.unpack("<%dh" % (len(data) // 2), data)
# Find the two hits by where sound starts, then compare the same span of each.
def onsets(v, floor=200, quiet=2000):
# Sound after silence. A run of quiet samples has to separate two hits, or what is found
# is one hit and a place in the middle of its own tail.
at, run, sounding = [], 0, False
for i, x in enumerate(v):
if abs(x) > floor:
if not sounding:
at.append(i)
sounding = True
run = 0
elif sounding:
run += 1
if run >= quiet:
sounding = False
return at
hits = onsets(v)
if len(hits) < 2:
print("onehit" if hits else "silent"); raise SystemExit
first, second = hits[0], hits[1]
span = min(3000, len(v) - second)
a = v[first:first + span]
b = v[second:second + span]
print("same" if a == b else "differ")
PY
)"
[ "$SAME" = "same" ] \
&& result ok "and two hits are the same hit" "sample for sample, noise and all" \
|| result no "and two hits are the same hit" "$SAME"
# ---- An LFO belongs to its CHANNEL, and a patch carries LFO settings ----
#
# This used to be the other way round, and it was a trap with teeth: four voices shared two
# LFOs, so a patch loaded onto channel ONE re-tuned what channel NOUGHT heard. The symptom was
# the worst kind - a sound that is right until some unrelated thing plays, and right again
# next time the machine starts. In the game it was a warning whose trill vanished after the
# first landing of a run, because the landing's patch carries an LFO switched off.
#
# Three notes on channel nought, all identical in what THEY were told, and the third leg is
# what keeps this check honest. Between the first and second, a patch is dropped on channel
# ONE that switches the LFO off - which must now do nothing here. Between the second and third
# the same thing is written to channel NOUGHT itself - which must still work, or the two
# checks above would pass just as well on a device where writing an LFO does nothing at all.
{ printf '#Program\nstart:\n'; port 0x41 0x00; loud
param 0x00 1 # triangle, so the LFO's work on the pitch is plain
param 0x51 1 # triggered, so each note ends itself
param 0x20 0x00; param 0x21 40; param 0x22 0x00
param 0x08 3 # pitch follows LFO 0
param 0x09 200 # and a long way
param 0x60 0x01; param 0x61 2; param 0x62 40; param 0x63 0x01
port 0x44 60; pause one 250
port 0x41 0x01 # somebody else's instrument, which says the LFO is off
param 0x60 0x00
port 0x41 0x00
port 0x44 60; pause two 250
port 0x41 0x00 # and now its OWN LFO switched off, which must be heard
param 0x60 0x00
port 0x44 60; spinForever
printf '#Vectors\n Boot start\n'; } | run lfoshared 8000000 || exit 1
read -r FIRST SECOND THIRD <<EOT
$(python3 - "$BUILD/lfoshared.raw" <<'PY'
import struct, sys
data = open(sys.argv[1], "rb").read()
v = struct.unpack("<%dh" % (len(data) // 2), data)
# Three notes, found as sound after silence.
at, sounding, quiet = [], False, 0
for i, x in enumerate(v):
if abs(x) > 300:
if not sounding:
at.append(i); sounding = True
quiet = 0
elif sounding:
quiet += 1
if quiet > 3000: sounding = False
if len(at) < 3:
print("0 0 0"); raise SystemExit
# ---- The PITCH of each, not how far it travels inside one ----
#
# The LFO here is slower than the note is long, so within one note it barely moves - what it
# does is hold the pitch somewhere other than where the note asked for. Which is the honest
# thing to measure anyway: a retriggered LFO starts at the same phase every time, so two notes
# that agree had the same LFO and one that disagrees did not.
def hz(start):
w = v[start:start + 3000]
peak = max((abs(x) for x in w), default=0)
if not peak: return 0
gate, cross, side = peak // 10, 0, 0
for x in w:
if side <= 0 and x > gate: side, cross = 1, cross + 1
elif side >= 0 and x < -gate: side, cross = -1, cross + 1
return cross * 48000 // (2 * len(w))
print(hz(at[0]), hz(at[1]), hz(at[2]))
PY
)
EOT
# The note asks for middle C, about 262. With the LFO on it is bent well away from that.
[ "$FIRST" -gt 0 ] && [ "$SECOND" -gt 0 ] && [ "$THIRD" -gt 0 ] \
&& result ok "an LFO bends the pitch it is routed to" "$FIRST hertz against the 262 asked for" \
|| result no "an LFO bends the pitch it is routed to" "$FIRST $SECOND $THIRD"
# Switched off by a patch meant for ANOTHER CHANNEL, and nothing here changes. This is the
# whole point: the two used to share, and a sound could not protect itself from its neighbour.
[ "$FIRST" = "$SECOND" ] \
&& result ok "another channel's patch leaves it alone" "$FIRST hertz before and after" \
|| result no "another channel's patch leaves it alone" "$FIRST became $SECOND, so they are shared"
# Switched off on its OWN channel, and the note comes back unbent - so the check above is
# measuring an LFO that really is reachable, rather than one nothing can write to.
[ "$(( THIRD - FIRST ))" -gt 40 ] \
&& result ok "and its own channel's patch does take it away" "$FIRST became $THIRD, which is the note itself" \
|| result no "and its own channel's patch does take it away" "$FIRST then $THIRD"
# ---- A tune keeps the timer's beat, not the screen's ----
#
# M1 of the music player. The sound device knows how to make a note and nothing about when;
# the timer is the other half. What is checked here is that a note's LENGTH is the period that
# was asked for - which is the one claim a program timing music on frames cannot make.
#
# 125,000 cycles is a sixteenth note at 120 beats a minute, and a frame is 16,667: seven and a
# half of them. It could not be asked for at all until the timer existed, and Examples/tune.asm
# spent years writing its arpeggio as seven whole frames instead, six and a half per cent fast.
#
# THE PROGRAM IS THE PLAYER'S LOOP, deliberately: a note table walked by a pointer, a wait on
# each tick, the gate dropped at the end. Eight notes of one tick each, seven of them middle C
# and the last an octave up, so where the boundary between them falls can be MEASURED rather
# than assumed.
{ printf '#Program\nstart:\n'
port 0x41 0x00
param 0x00 0x02 # Saw, which has edges enough to count
param 0x01 0xFF # at full gain
param 0x05 0x01 # and switched on.
param 0x20 0x00 # No attack, no decay, held at full: a note lasts its whole tick
param 0x21 0x00 # and stops when the gate drops, rather than fading on its own -
param 0x22 0xFF # which is what makes the boundary a thing a counter can find.
param 0x23 0x05
port 0x46 0xC0
port 0x52 0x01
port 0x53 0xE8
port 0x54 0x48 # 0x01E848, which is 125,000 cycles: 6,000 samples.
port 0x51 0x07 # Run, repeat, interrupt.
printf ' SIF\n'
printf ' SETD.0 Notes\nnextNote:\n LDA.0\n BRA done\n OUTA 0x44\n INCD.0\n'
printf ' LDB.0\n INCD.0\nhold:\n WAIT\n DECB\n BNB hold\n'
printf ' RSTA\n OUTA 0x45\n BRI nextNote\ndone:\n CIF\n RSTA\n OUTA 0x51\n HALT\n'
printf 'tick:\n RETI\n'
printf '#Data\nNotes:\n'
printf ' 0d60 0d1\n 0d60 0d1\n 0d60 0d1\n 0d60 0d1\n'
printf ' 0d60 0d1\n 0d60 0d1\n 0d60 0d1\n 0d72 0d1\n 0x00\n'
printf '#Vectors\n Boot start\n Device 0x50 tick\n'; } \
| run timedNotes 1200000 || exit 1
# Eight ticks of 125,000 cycles is 1,000,000, and the machine halts when the table runs out
# rather than when the cycle limit does. So the length of what came out is the beat, counted.
# A TENTH OF A PER CENT, which is forty eight samples. That is not fussiness: the machine
# halts within a few hundred cycles of the last tick, so anything wider than this stops
# measuring the timer and starts measuring nothing in particular.
COUNT="$(measure timedNotes count)"
near "$COUNT" 48000 0.1 \
&& result ok "eight ticks last eight periods" "$COUNT samples, and 8 x 125,000 cycles is 48,000" \
|| result no "eight ticks last eight periods" "$COUNT samples, wanted about 48,000"
# ---- And the notes change where the ticks do ----
#
# The seventh note occupies samples 36,000 to 42,000 and the eighth 42,000 to 48,000, so a
# window inside each should hear one note and not a blend of two.
#
# THIS IS THE ONE THAT FAILS ON THE OLD TIMING. At seven whole frames a tick is 5,600 samples,
# the eighth note starts at 39,200, and the seventh window straddles the boundary: measured, it
# reads 384 Hz, which is neither note and is what a crossing counter says when it is given two.
SEVENTH="$(measure timedNotes pitch 37000 41000)"
EIGHTH="$(measure timedNotes pitch 43000 47000)"
near "$SEVENTH" 261.6 2 \
&& result ok "a note begins where a tick begins" "the seventh note reads $SEVENTH hertz, and middle C is 261.6" \
|| result no "a note begins where a tick begins" "the seventh window reads $SEVENTH hertz, which is not middle C alone"
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 staggers 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: a crossing counter given two answers with neither. So the overlap is checked for
# LEVEL instead, which is a question two notes can answer.
#
# The patches carry a release for the same reason. Without one the first voice's note was
# still fading into the second voice's stretch, and the window that should have read one note
# read 634 hertz - which is not a note, not an octave, and was measured before it was written
# down as an assertion.
run fourVoices 2200000 < "$ROOT/Programs/testPrograms/fourVoiceTest.asm" || exit 1
COUNT="$(measure fourVoices count)"
near "$COUNT" 96000 0.1 \
&& result ok "sixteen ticks of four voices" "$COUNT samples, and 16 x 125,000 cycles is 96,000" \
|| result no "sixteen ticks of four voices" "$COUNT samples, wanted about 96,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"
# ---- And a patch belongs to its channel ----
#
# M3. BOTH TRACKS NAME NOTE 60. The octave between them is in their patches, which differ in
# one parameter, and the lower one is loaded FIRST - so a device where a patch was global
# would have the second overwrite it and both voices would answer in unison.
#
# This is the thing d361ea1 made possible and nothing had used: before it the LFOs belonged to
# the whole device, so whichever patch loaded last owned them for every voice at once.
RATIO="$(python3 -c "import sys; print('%.3f' % (float(sys.argv[1]) / float(sys.argv[2])))" \
"$SECOND" "$FIRST")"
near "$RATIO" 2.0 3 \
&& result ok "a patch belongs to its channel" "both tracks say note 60 and one sounds $RATIO times the other" \
|| result no "a patch belongs to its channel" "the two voices are $RATIO apart, and their patches say two"
# ---- A voice goes on to the next sequence its order list names ----
#
# Sixteen ticks of four bars, and one phrase does nearly all of it: Beat is placed twice by
# voice 0 and twice by voice 1, written once, on two different voices. A voice is a property
# of where a sequence is PLACED and not of the sequence, which is what a per-voice order list
# buys and what a sequence carrying its own channels would have cost.
#
# The three windows above already ran through it - each of them is a different bar of an order
# list rather than a stretch of one long track - so what is left to check is the command.
#
# THE FOURTH BAR IS THE SAME NOTE NUMBER AS THE FIRST. Voice 0's last sequence begins with
# 0x80, which changes its patch mid-piece, and the octave that comes out is the new patch's
# and not the note's. A command takes no tick: the reader acts on it and reads the note behind
# it on the same boundary.
SWITCHED="$(measure fourVoices pitch 74000 94000)"
near "$SWITCHED" 523.3 2 \
&& result ok "a command changes the instrument mid-piece" "$SWITCHED hertz from note 60, which is the patch and not the note" \
|| result no "a command changes the instrument mid-piece" "$SWITCHED hertz, wanted the octave the new patch gives"
# 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"
# ---- The system quietens a note the program that made it cannot ----
#
# A gate is a register on the sound device, and only a program can drop one. A program that
# has stopped cannot: it is gone. So a note left held sustains until something else says
# otherwise, and nothing else did - which means one program could leave the machine sounding
# for as long as it ran, with no way for the person at it to stop it.
#
# CosmOS now lets every voice go when a program hands the machine back, the same way it puts
# the screen and the drive and the vectors back, and from the fault path too - because a
# program that CRASHED is exactly the one that cannot tidy up after itself.
#
# Hum starts a note whose envelope sustains at full and exits while holding it. Pause makes no
# sound and takes a couple of million cycles, and it is there because the machine stops the
# moment the shell runs out of input: a note quietened at that instant would leave no samples
# behind to say whether it had been.
#
# ITS OWN DISK, so that a fixture run by one check does not move the ten recorded tests that
# quote cosmos.img's directory listing.
if [ ! -f "$ROOT/Tests/build/disks/quiet.img" ]; then
result no "the system quietens what a program left sounding" "quiet.img is missing; run Tests/makedisks.sh"
else
"$ASM" -I "$ROOT/Programs/Libraries" -I "$ROOT/Programs/CosmOS/Source" \
-o "$BUILD/cosmos.bin" "$ROOT/Programs/CosmOS/Source/cosmos.asm" >/dev/null 2>&1
printf 'Hum\nPause\nexit\n' > "$BUILD/quiet.in"
timeout 60 "$EMU" --fast --cycles 20000000 --sound "$BUILD/quiet.raw" \
--disk "$ROOT/Tests/build/disks/quiet.img" "$BUILD/cosmos.bin" \
< "$BUILD/quiet.in" > "$BUILD/quiet.out" 2>&1
# ---- PROOF THAT ANYTHING WAS MEASURED AT ALL ----
#
# This check reads a window near the end of the render, and a window past the end of the
# samples is empty, and an empty window's peak is nought - which is indistinguishable from
# silence and passes. It happened: Pause was missing from the disk, the machine halted the
# moment the shell ran out of input, and the check reported a quietened note on a render
# a twentieth of a second long. So the length is asserted before anything is read from it.
SAMPLES="$(measure quiet count)"
[ "$SAMPLES" -gt 150000 ] \
|| result no "the quieting check measured a real run" "only $SAMPLES samples, so the windows below are empty"
# The note has to have been made, or silence afterwards proves nothing at all. This is the
# half that would let the check pass on a machine where the sound device did not work.
STARTED="$(measure quiet peak 0 40000)"
[ "$STARTED" -gt 1000 ] \
&& result ok "a program can leave a note sounding" "peak $STARTED while it held one" \
|| result no "a program can leave a note sounding" "peak $STARTED, so nothing sounded and the rest proves nothing"
# And by the end there is nothing, though the program that started it never let go.
LEFT="$(measure quiet peak 100000 0)"
[ "$LEFT" -lt 200 ] \
&& result ok "the system quietens what a program left sounding" "peak $LEFT after it handed back" \
|| result no "the system quietens what a program left sounding" "peak $LEFT, so it is still ringing"
fi
# ---- A tune read from a file ----
#
# M4's loader. Everything in a tune is an OFFSET from wherever it was put, so loading one is
# adding the base to two tables and pointing four voices at their order lists - no sequence is
# walked and nothing inside one is an address to be found and corrected.
#
# BOTH NOTES ARE NUMBER 60. The octave between them is a 0x80 command in the second sequence,
# loading the second patch out of the file, so what this measures is the whole path at once:
# the header parsed, the tick taken from it, the tables relocated, an order list followed, and
# a patch that came out of a file rather than out of the program.
#
# The fixture is laid out byte by byte by Tests/maketune.py, which is the hex editor rather
# than the compiler - the point being that the loader is tested by something that does not
# share its idea of the format.
if [ ! -f "$ROOT/Tests/build/disks/quiet.img" ]; then
result no "a tune read from a file" "quiet.img is missing; run Tests/makedisks.sh"
else
printf 'Play two.tune\n\n\nexit\n' > "$BUILD/tune.in"
timeout 60 "$EMU" --fast --cycles 6000000 --sound "$BUILD/loaded.raw" \
--disk "$ROOT/Tests/build/disks/quiet.img" "$BUILD/cosmos.bin" \
< "$BUILD/tune.in" > "$BUILD/tune.out" 2>&1
STARTS="$(python3 - "$BUILD/loaded.raw" <<'PY2'
import struct, sys
d = open(sys.argv[1], "rb").read(); n = len(d) // 2
v = struct.unpack("<%dh" % n, d)
print(next((i for i, x in enumerate(v) if abs(x) > 500), -1))
PY2
)"
if [ "$STARTS" -lt 0 ]; then
result no "a tune read from a file" "nothing sounded at all"
else
FIRST="$(measure loaded pitch $(( STARTS + 1000 )) $(( STARTS + 22000 )))"
SECOND="$(measure loaded pitch $(( STARTS + 26000 )) $(( STARTS + 46000 )))"
near "$FIRST" 261.6 2 \
&& result ok "a tune read from a file" "$FIRST hertz, from a header and two tables" \
|| result no "a tune read from a file" "$FIRST hertz, wanted middle C"
near "$SECOND" 523.3 2 \
&& result ok "and a command in it loads a patch from it" "$SECOND hertz from note 60 again" \
|| result no "and a command in it loads a patch from it" "$SECOND hertz, wanted the octave"
fi
# ---- A tune's tick is the tune's ----
#
# slow.tune is two.tune with twice the period and nothing else changed, so it has to last
# twice as long. Play used to write its own tick over the one useTune had just taken out
# of the header, and every tune played at a sixteenth note at 120 whatever it asked for -
# invisible for as long as every fixture asked for exactly that.
printf 'Play slow.tune\n\n\nexit\n' > "$BUILD/slow.in"
timeout 60 "$EMU" --fast --cycles 8000000 --sound "$BUILD/slow.raw" \
--disk "$ROOT/Tests/build/disks/quiet.img" "$BUILD/cosmos.bin" \
< "$BUILD/slow.in" > "$BUILD/slow.out" 2>&1
SPAN="$(python3 - "$BUILD/slow.raw" <<'PY2'
import struct, sys
d = open(sys.argv[1], "rb").read(); n = len(d) // 2
v = struct.unpack("<%dh" % n, d)
loud = [i for i, x in enumerate(v) if abs(x) > 500]
print(loud[-1] - loud[0] if loud else 0)
PY2
)"
near "$SPAN" 96000 2 \
&& result ok "a tune's tick is the tune's own" "$SPAN samples, and 8 ticks of 250,000 cycles is 96,000" \
|| result no "a tune's tick is the tune's own" "$SPAN samples, wanted about 96,000"
# ---- The compiler and the hex editor agree ----
#
# TWO IMPLEMENTATIONS OF ONE FORMAT, which is the discipline SplitDisk and sbfs.asm are
# held to and for the same reason: either alone is only self-consistent. maketune.py lays
# the bytes out by hand and TuneC compiles a written source, and what the player reads is
# right only if both agree about what it should say.
#
# The fixture is what Play was tested against before TuneC existed, so this is also the
# compiler being checked against something that predates it.
if cmp -s "$ROOT/Tests/build/.diskwork/compiled.tune" \
"$ROOT/Tests/build/.diskwork/two.tune"; then
result ok "TuneC writes what the hand-laid fixture does" "byte for byte"
else
result no "TuneC writes what the hand-laid fixture does" \
"$(cmp "$ROOT/Tests/build/.diskwork/compiled.tune" "$ROOT/Tests/build/.diskwork/two.tune" 2>&1 | head -1)"
fi
# ---- And it refuses what the player could not notice ----
#
# Each of these is something a tune can get wrong that the machine cannot tell you about.
# The player has no names, so it cannot say a sequence does not exist; it has no lengths,
# so it cannot say the voices will come apart four bars later; and a duration of nought is
# counted down to 255 and held, which sounds like a hang rather than a mistake.
refuses() {
# refuses <what it should say> <line>...
local want="$1"; shift
printf '%s\n' "$@" > "$BUILD/bad.tune.txt"
local said
said="$("$ROOT/TuneC" -I "$ROOT/Tests/build/.diskwork" "$BUILD/bad.tune.txt" \
"$BUILD/bad.tune" 2>&1)"
if [ -f "$BUILD/bad.tune" ]; then rm -f "$BUILD/bad.tune"; fi
case "$said" in
*"$want"*) result ok "TuneC refuses $want" "and says which line" ;;
*) result no "TuneC refuses $want" "$(echo "$said" | head -1)" ;;
esac
}
refuses "would come apart" \
"#Tick 0d125000" "#Patch A low.patch" "#Voice 0d0 A" "#Voice 0d1 A" \
"#Sequence Four" "0d60 0d4" "#Sequence Eight" "0d60 0d8" \
"#Order 0d0" "Four" "#Order 0d1" "Eight"
refuses "a part and no instrument" \
"#Tick 0d125000" "#Patch A low.patch" "#Sequence S" "0d60 0d4" "#Order 0d0" "S"
refuses "a duration is from" \
"#Tick 0d125000" "#Patch A low.patch" "#Voice 0d0 A" "#Sequence S" "0d60 0d0" \
"#Order 0d0" "S"
# ---- AND IT SAYS IT ONCE ----
#
# One missing patch used to be seven messages: the name went unregistered, so every #Voice
# naming it failed too, and then the check that a voice has an instrument failed for each
# of those. Only the first was worth reading. A compiler that says one thing seven ways
# teaches people to read the last line, which is the one that matters least.
printf '%s\n' "#Tick 0d125000" "#Patch A nosuch.patch" \
"#Voice 0d0 A" "#Voice 0d1 A" "#Voice 0d2 A" \
"#Sequence S" "0d60 0d4" \
"#Order 0d0" "S" "#Order 0d1" "S" "#Order 0d2" "S" > "$BUILD/cascade.tune.txt"
SAID="$("$ROOT/TuneC" "$BUILD/cascade.tune.txt" "$BUILD/cascade.tune" 2>&1 \
| grep -c "cannot find\|no patch of that name\|no instrument")"
[ "$SAID" = "1" ] \
&& result ok "TuneC says a missing patch once" "one message, not one for every use of it" \
|| result no "TuneC says a missing patch once" "$SAID messages for one missing file"
refuses "no sequence of that name" \
"#Tick 0d125000" "#Patch A low.patch" "#Voice 0d0 A" "#Sequence S" "0d60 0d4" \
"#Order 0d0" "Nope"
# ---- And a file that is not a tune is refused ----
#
# The loader reads offsets out of a file and follows what they name, so a file that is not
# one has to be turned away at the magic rather than a few instructions later.
printf 'Play Apps/Hum.sbx\nexit\n' > "$BUILD/notune.in"
timeout 30 "$EMU" --fast --cycles 3000000 --disk "$ROOT/Tests/build/disks/quiet.img" \
"$BUILD/cosmos.bin" < "$BUILD/notune.in" > "$BUILD/notune.out" 2>&1
grep -q "not a tune" "$BUILD/notune.out" \
&& result ok "and a file that is not a tune is refused" "it said so rather than playing it" \
|| result no "and a file that is not a tune is refused" "$(tail -2 "$BUILD/notune.out" | tr '\n' ' ')"
fi
echo
if [ "$FAIL" -eq 0 ]; then
echo "All $PASS sound checks passed."
exit 0
fi
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
exit 1