An LFO belongs to the DEVICE and not to a channel. There are two of them against four voices, and a patch carries LFO settings the way it carries everything else - so whichever patch was loaded last owns both of them, for every voice at once. The warning's trill is a saw LFO on the pitch. The patches on channel three, the bang and the latch, carry an LFO that is switched off, and they load at the moment they are used. So the first landing, docking or crash of a run took the trill away and left a plain tone, and it was right again next time the machine started. Correct until something unrelated plays is the worst shape a fault can have. The same thing had already happened silently at startup: the instruments were set up in order, so the warning's LFO settings, written last, sat on top of the rumble's and the rumble never had its own at all. So nothing is set up once any more. Each sound loads its patch immediately before its note - forty-odd writes at a moment already making a sound - and is then whatever its patch says, whatever played before it. Measured after a landing: 1056, 660, 516 hertz, then up to 1698 and down again. Two sweeps of the saw, which is the trill. What it does not fix, because it cannot: two sounds overlapping still share the LFOs, so a warning going off mid-burn re-tunes the rumble for as long as it lasts. With two between four that is the device. Three checks at the device level, where the trap can be stated exactly: a routed LFO bends a pitch (184 Hz against the 262 the note asked for), another channel's patch takes it away (272, the note itself), and saying it again gets it back (184). Written up in the Programming Manual beside the LFO mode, since the next program to want two sounds will meet it too.
587 lines
27 KiB
Bash
Executable File
587 lines
27 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 the device, and a patch carries LFO settings ----
|
|
#
|
|
# Which is a trap with teeth, and it bit a game before it was written down here. Four voices
|
|
# share two LFOs, so a patch loaded onto channel ONE re-tunes what channel NOUGHT hears - and
|
|
# a program that sets its instruments up once at startup gets whichever of them was written
|
|
# last, for all of them.
|
|
#
|
|
# The symptom is 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. Between the first and
|
|
# second, a patch is dropped on channel one that switches the LFO off; between the second and
|
|
# third, channel nought's own settings are written again.
|
|
{ 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 its own settings said again, which is the fix
|
|
param 0x60 0x01; param 0x61 2; param 0x62 40; param 0x63 0x01
|
|
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 the note comes back unbent.
|
|
[ "$(( SECOND - FIRST ))" -gt 40 ] \
|
|
&& result ok "and another channel's patch takes it away" "$FIRST became $SECOND, which is the note itself" \
|
|
|| result no "and another channel's patch takes it away" "$FIRST then $SECOND, so it was not shared"
|
|
# And said again, it is bent exactly as before, because a retriggered LFO starts where it did.
|
|
[ "$FIRST" = "$THIRD" ] \
|
|
&& result ok "and saying it again gets it back" "$SECOND became $THIRD again" \
|
|
|| result no "and saying it again gets it back" "$SECOND then $THIRD"
|
|
|
|
echo
|
|
if [ "$FAIL" -eq 0 ]; then
|
|
echo "All $PASS sound checks passed."
|
|
exit 0
|
|
fi
|
|
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
|
|
exit 1
|