Files
SplitBit-Emulator/Tests/sound.sh
T
AnachronautandClaude Opus 5 4e61158b11 The splash stops sounding before it ends, so listen earlier
The check asked whether music was still playing four seconds in, and the
score it listens to ends its every voice on a rest - twelve ticks of
silence that hold the logo up while the voices decay. So it was measuring
exactly the quiet the score asks for and calling a perfect tune a failure.

Three seconds instead, which is still twice the second and a half the
silent path holds the logo for, and which is what the check is actually
about: that this is the tune and not the hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 13:22:56 -04:00

979 lines
49 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.score"
local said
said="$("$ROOT/TuneC" -I "$ROOT/Tests/build/.diskwork" "$BUILD/bad.score" \
"$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.score"
SAID="$("$ROOT/TuneC" "$BUILD/cascade.score" "$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
# ---- A game plays its own music ----
#
# The first customer for the player outside the program it was extracted from. Lander includes
# Libraries/player.asm, reads /splash.tune off the disk, and plays it over a studio line before
# the world is built - owning the timer and all four channels while it does, because nothing
# else is happening yet.
#
# IT POLLS THE TIMER RATHER THAN BEING INTERRUPTED BY IT. Lander has no vector segment and
# waits for the screen by reading port 0x30, so it waits for a beat by reading port 0x50 - and
# brings no handler that would have to be taken away before the game starts.
#
# No key is pressed here, which is the point: every other Lander test skips the splash with a
# space, so this is the only one that hears it.
if [ ! -f "$ROOT/Tests/build/disks/cosmos.img" ]; then
result no "a game plays its own music" "cosmos.img is missing"
else
python3 -c "
open('$BUILD/splash.keys','wb').write(b'Lander\n' + b'\x00'*900)
open('$BUILD/still.pad','wb').write(b'\x00' * 20000)"
timeout 90 "$EMU" --fast --cycles 40000000 --keyboard "$BUILD/splash.keys" \
--pad "$BUILD/still.pad" --sound "$BUILD/splash.raw" \
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
"$BUILD/cosmos.bin" > "$BUILD/splash.out" 2>&1 || true
# What is checked is that music is still playing well after a splash that failed to load
# would have given up: the silent path holds the logo for ninety frames, a second and a
# half, and then the game starts. So the late window is at three seconds, twice that.
#
# NOT LATER, because the tune stops sounding before it ends. Its last sequence on every
# voice is a rest, which is what holds the logo up while the voices decay - so a window
# past three and three quarter seconds is measuring the silence the score asks for and
# would fail on a tune that played perfectly.
read -r EARLY LATE <<EOT
$(python3 - "$BUILD/splash.raw" <<'PY2'
import struct, sys
try:
d = open(sys.argv[1], "rb").read()
except OSError:
print("0 0"); raise SystemExit
n = len(d) // 2
v = struct.unpack("<%dh" % n, d)
def rms(a, b):
w = v[a:b]
return int((sum(x*x for x in w) / len(w)) ** 0.5) if w else 0
print("%d %d" % (rms(48000, 96000), rms(144000, 168000)))
PY2
)
EOT
[ "${EARLY:-0}" -gt 500 ] && [ "${LATE:-0}" -gt 500 ] \
&& result ok "a game plays its own music" "still sounding at three seconds, so it is the tune and not the silent hold" \
|| result no "a game plays its own music" "RMS was $EARLY at one second and $LATE at three"
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