Files
SplitBit-Emulator/Tests/sound.sh
T
AnachronautandClaude Opus 5 d388cd3122 Give the machine a sound device
Four channels on ports 0x40 to 0x4F, each one a whole soundThing voice:
two oscillators, two envelopes, a filter and the routing between them. A
channel keeps its patch between notes, so a program sets an instrument up
once and then plays it.

Six ports rather than forty, because a voice has around forty settings and
four of them would spend more than half the port space on one device.
There is a selector and a value instead: say which channel, say which
setting, write it. That is three writes to change a setting and two to
play a note, which is the right way round - patches are loaded, notes are
played in an inner loop.

Samples come from the machine's clock and not the host's: 48,000 a second
of emulated time, worked out in whole numbers so it never drifts. A
million cycles is exactly 48,000 samples on any host at any speed, which
is what makes a sound something a test can compare. --sound writes them
out, the way --screen writes a picture, for the same reason: the suite has
no speaker.

Tests/sound.sh is 22 checks and found three real defects the first time it
ran, all the same shape - a synthesizer written for a patch editor, wired
up as hardware and inheriting the editor's assumptions:

  - Only one voice had an oscillator switched on, so three of the four
    channels could not make a sound whatever was written to them.
  - That voice's oscillator arrived at full gain and every other one
    arrived at nothing, an asymmetry with no reason behind it.
  - A note with no sustain is silent but not over, so the obvious way to
    wait for a sound to finish waits for ever.

The first two are fixed by the device defining its own power-on state
rather than inheriting synthInit's: every channel arrives able to make a
sound, so writing a note number is the whole of playing a note. The third
was already written into the manual as advice, an hour before the check
existed. The check disagreed with the documentation and the check was
right; the manual now says the one rule, which is that a note sounds until
the gate is dropped.

Programs/Examples/tune.asm plays eight notes, taking its tempo from the
screen's frame interrupt because that is the only regular beat this
machine has. It spends 99.8% of its cycles asleep in WAIT.

Voyager has no speaker yet - this is the device and its tests. Playing the
samples out of the window is the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 20:59:17 -04:00

419 lines
19 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" "$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"
echo
if [ "$FAIL" -eq 0 ]; then
echo "All $PASS sound checks passed."
exit 0
fi
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
exit 1