Files
SplitBit-Emulator/Tests/sound.sh
T
Anachronaut d361ea1e46 An LFO belongs to its channel, not to the whole device
The two LFOs lived in the Synth, so four channels shared them and whichever patch
loaded last owned them for every voice at once. A sound with its LFO switched off
silenced the trill under a sound that was still playing - which is what made Lunar
Porter's low fuel warning intermittent: the first landing, docking or crash of a run
took its trill away, and it was right again next time the machine started.

The engine fix went upstream to soundThing and has come back. synth.c and synth.h
are re-vendored at 71e3cb2, character for character bar the ASCII transliteration,
and now carry two changes: the LFOs moved into the Voice, and synthSyncVoices
carries a free LFO's cycle down alongside its rate. That second hunk does nothing
here - it only matters to a caller that syncs voices, and this device never does,
because syncing would flatten four channels into one instrument. It is taken so the
vendored file stays identical in both trees, and it is commented as such.

Upstream also found a bug in the original patch, in patchLoad, which is soundThing's
own file and does not travel.

Downstream the LFO parameter groups 0x60 and 0x70 now read the selected channel like
every parameter beside them, so an LFO written to one channel is inaudible on the
other three. Everything else about the device is unchanged.

Lunar Porter keeps loading each patch immediately before its note, but for the
smaller reason that now applies: the bang and the latch share channel three, and a
channel used by two sounds has to be told which of them it is about to be. The
comment that said otherwise, and the manual's warning about sharing, are rewritten
as history rather than as a caveat.

Tests/sound.sh's shared-LFO check is inverted to assert the fixed behaviour, with a
third leg added: after proving another channel's patch leaves this one alone, it
switches this channel's OWN LFO off and requires the pitch to move. Without that,
both checks would pass on a device where writing an LFO did nothing at all. Routing
either group back to voice 0 is caught.

Cost, measured: four channels sounding continuously for 400 seconds of audio takes
5.0 s of wall clock against 4.59 s before, about 9% of total emulator time. Half of
that is wasted on voices that cannot sound, since VOICE_COUNT is 8 and there are
four channels; recovering it would mean diverging the vendored file, which is not
worth it at this price.
2026-09-04 22:20:26 -04:00

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