Play loads a patch per channel before a note is played: an oboe for the
melody, strings under it, a square wave for the bass and a kalimba for
the arpeggio. It reads a count and that many parameter and value pairs -
the format SoundPatch writes - and understands nothing else about them,
which keeps SoundPatch the only thing that knows what soundThing's JSON
means.
FOUR PATCHES CAN BE UP AT ONCE, and that is the whole rung. It is the
first use of d361ea1: before it the LFOs belonged to the whole device, so
whichever patch loaded last owned them for every voice. Kalimba has LFO 0
switched off and the other three have it on, so on the old device this
piece would have played all four parts with the arpeggio's setting -
which is exactly the fault that stopped Lunar Porter's low fuel warning
trilling after the first landing of a run.
The eight patches are the user's, brought over from soundThing; four are
used here and the rest are a palette for the next piece.
The test now asks BOTH VOICES FOR NOTE 60 and gets an octave, because
their patches differ in one parameter and the lower is loaded first. A
device where a patch was global would have the second overwrite the first
and the two would answer in unison. Verified with break.sh: loading both
patches onto one channel flips the octave onto the wrong voice and the
ratio inverts to 0.499.
The test patches carry a release, and that was measured rather than
assumed: without one the first voice's note was still fading into the
second voice's window, which read 634 hertz - not a note, not an octave,
and a reminder that a crossing counter given two notes answers with
neither.
Play.sbx went 379 -> 740 bytes, which is four patches of 47 pairs each,
and is why ten recordings moved. makedisks.sh needed the Sounds include
path that Lander's line already had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
712 lines
34 KiB
Bash
Executable File
712 lines
34 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"
|
|
|
|
# ---- 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 1700000 < "$ROOT/Programs/testPrograms/fourVoiceTest.asm" || exit 1
|
|
|
|
COUNT="$(measure fourVoices count)"
|
|
near "$COUNT" 72000 0.1 \
|
|
&& result ok "twelve ticks of four voices" "$COUNT samples, and 12 x 125,000 cycles is 72,000" \
|
|
|| result no "twelve ticks of four voices" "$COUNT samples, wanted about 72,000"
|
|
|
|
FIRST="$(measure fourVoices pitch 2000 22000)"
|
|
near "$FIRST" 261.6 2 \
|
|
&& result ok "one voice plays while the other rests" "$FIRST hertz, and middle C is 261.6" \
|
|
|| result no "one voice plays while the other rests" "$FIRST hertz, wanted middle C alone"
|
|
|
|
SECOND="$(measure fourVoices pitch 26000 46000)"
|
|
near "$SECOND" 523.3 2 \
|
|
&& result ok "and then they change places" "$SECOND hertz, an octave up, with the first one silent" \
|
|
|| result no "and then they change places" "$SECOND hertz, wanted the octave alone"
|
|
|
|
# ---- 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"
|
|
|
|
# Both voices sounding is louder than either of them alone. Against the QUIETER of the two so
|
|
# that the check cannot be passed by one voice getting louder, and by a margin well under the
|
|
# two-fold a clean sum would give, because two notes are not in phase and do not simply add.
|
|
ALONE0="$(measure fourVoices peak 2000 22000)"
|
|
ALONE1="$(measure fourVoices peak 26000 46000)"
|
|
TOGETHER="$(measure fourVoices peak 50000 70000)"
|
|
QUIETER="$ALONE0"
|
|
[ "$ALONE1" -lt "$QUIETER" ] && QUIETER="$ALONE1"
|
|
[ "$TOGETHER" -gt "$(( QUIETER * 14 / 10 ))" ] \
|
|
&& result ok "and then both at once" "peak $TOGETHER against $ALONE0 and $ALONE1 alone" \
|
|
|| result no "and then both at once" "peak $TOGETHER, which is not above either of $ALONE0 and $ALONE1"
|
|
|
|
echo
|
|
if [ "$FAIL" -eq 0 ]; then
|
|
echo "All $PASS sound checks passed."
|
|
exit 0
|
|
fi
|
|
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
|
|
exit 1
|