#!/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 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 printf ' INIA 0x%02X\n OUTA 0x%02X\n' $(( $2 & 0xFF )) $(( $1 & 0xFF )) } param() { # param - 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 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 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/.raw. run() { # run 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 [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 python3 -c "import sys; a,b,p = (float(x) for x in sys.argv[1:4]); sys.exit(0 if b and abs(a-b) <= b*p/100.0 else 1)" \ "$1" "$2" "$3" } said() { grep -q -- "$2" "$BUILD/$1.out" } echo "Checking what the sound device makes." echo # ---- Nothing, until something asks ---- # # A machine that hummed on its own would make every check below meaningless, and the samples # would still all be there to count. { printf '#Program\nstart:\n'; spinForever; printf '#Vectors\n Boot start\n'; } \ | run quiet 1000000 || exit 1 PEAK="$(measure quiet peak)" [ "$PEAK" = "0" ] \ && result ok "silent until something asks" "every sample is nothing" \ || result no "silent until something asks" "peak was $PEAK" # ---- The sample clock is the machine's clock ---- # # Forty-eight thousand a second against a million cycles. This is the claim the whole file # rests on: if samples came from the host, nothing below would be reproducible. COUNT="$(measure quiet count)" [ "$COUNT" = "48000" ] \ && result ok "samples keep the machine's time" "1,000,000 cycles made exactly 48,000" \ || result no "samples keep the machine's time" "1,000,000 cycles made $COUNT, wanted 48,000" { printf '#Program\nstart:\n'; spinForever; printf '#Vectors\n Boot start\n'; } \ | run quietLonger 3000000 || exit 1 COUNT="$(measure quietLonger count)" [ "$COUNT" = "144000" ] \ && result ok "and keeps it over a longer run" "3,000,000 cycles made exactly 144,000" \ || result no "and keeps it over a longer run" "3,000,000 cycles made $COUNT, wanted 144,000" # ---- A channel arrives able to make a sound ---- # # Writing a note number and hearing that note is the shortest useful thing this device can be # asked to do, and it works from a cold start. soundThing's own defaults do not do this - they # are a patch editor's, where one voice is set up and the rest wait to be copied over - so the # device sets its own power-on state and this is the check that it did. { printf '#Program\nstart:\n'; port 0x41 0x00; port 0x44 60; spinForever printf '#Vectors\n Boot start\n'; } | run bareNote 1000000 || exit 1 PEAK="$(measure bareNote peak)" [ "$PEAK" -gt 1000 ] \ && result ok "a channel arrives ready to sound" "a note and nothing else made $PEAK" \ || result no "a channel arrives ready to sound" "peak was only $PEAK" # ---- And the second oscillator arrives off ---- # # Which is not the same thing as silent, because the two are averaged rather than added: one # that is switched on halves the other whatever its gain. So the plain case is one oscillator, # and asking for two is a thing a program says out loud. { printf '#Program\nstart:\n'; port 0x41 0x00; loud param 0x15 0x01 # oscillator 1, switched on param 0x11 0x00 # and silent port 0x44 60; spinForever; printf '#Vectors\n Boot start\n'; } | run twoOscs 1000000 || exit 1 { printf '#Program\nstart:\n'; port 0x41 0x00; loud; port 0x44 60; spinForever printf '#Vectors\n Boot start\n'; } | run middleC 1000000 || exit 1 BOTH="$(measure twoOscs peak)" ONE="$(measure middleC peak)" near "$BOTH" "$((ONE / 2))" 10 \ && result ok "two oscillators share the level" "$ONE alone, $BOTH with a silent one beside it" \ || result no "two oscillators share the level" "$ONE alone and $BOTH with a second, wanted about half" PEAK="$(measure middleC peak)" [ "$PEAK" -gt 1000 ] \ && result ok "a note makes a sound" "peak $PEAK" \ || result no "a note makes a sound" "peak was only $PEAK" # ---- The note it was asked for ---- # # 60 is middle C, 261.63 Hz. Two per cent is well inside a semitone, which is six. PITCH="$(measure middleC pitch 24000)" near "$PITCH" 261.63 2 \ && result ok "and it is the note asked for" "$PITCH Hz, middle C is 261.63" \ || result no "and it is the note asked for" "$PITCH Hz, wanted 261.63" # ---- Twelve is an octave ---- # # The pitch check above would pass on a device that played one fixed tone. This one would not. { printf '#Program\nstart:\n'; port 0x41 0x00; loud; port 0x44 72; spinForever printf '#Vectors\n Boot start\n'; } | run octaveUp 1000000 || exit 1 OCTAVE="$(measure octaveUp pitch 24000)" near "$OCTAVE" 523.25 2 \ && result ok "twelve notes up is an octave" "$OCTAVE Hz, twice $PITCH" \ || result no "twelve notes up is an octave" "$OCTAVE Hz, wanted 523.25" # ---- The same program makes the same sound ---- # # The point of a clock that is not the host's. Without this every check above is a check on # how busy the machine running the suite happened to be. { printf '#Program\nstart:\n'; port 0x41 0x00; loud; port 0x44 60; spinForever printf '#Vectors\n Boot start\n'; } | run middleCAgain 1000000 || exit 1 cmp -s "$BUILD/middleC.raw" "$BUILD/middleCAgain.raw" \ && result ok "the same program makes the same sound" "two runs, byte for byte" \ || result no "the same program makes the same sound" "the two runs differ" # ---- Gain is a level and not a switch ---- { printf '#Program\nstart:\n'; port 0x41 0x00; param 0x01 0x40; port 0x44 60; spinForever printf '#Vectors\n Boot start\n'; } | run quarterGain 1000000 || exit 1 FULL="$(measure middleC peak)" QUARTER="$(measure quarterGain peak)" near "$QUARTER" "$((FULL / 4))" 20 \ && result ok "gain sets the level" "a quarter of $FULL is $QUARTER" \ || result no "gain sets the level" "a quarter of $FULL came out $QUARTER" # ---- And the device's own volume is over the top of it ---- { printf '#Program\nstart:\n'; port 0x41 0x00; loud; port 0x46 0x00; port 0x44 60 spinForever; printf '#Vectors\n Boot start\n'; } | run noVolume 1000000 || exit 1 PEAK="$(measure noVolume peak)" [ "$PEAK" = "0" ] \ && result ok "volume nothing is silence" "a full note at volume nothing makes nothing" \ || result no "volume nothing is silence" "peak was $PEAK" # ---- A channel is its own voice ---- # # Turn channel 0's gain down, then play on channel 1. Channel 1 should still be at the gain it # arrived with, and anything else would mean the settings are the device's rather than the # channel's - the difference between four voices and one that four things fight over. # # This is deliberately a comparison of LEVELS and not a check that channel 1 is silent. It was # the latter once, and it passed for a year's worth of the wrong reason: channel 1 could not # make a sound at all, so a device with one shared set of settings would have passed it too. { printf '#Program\nstart:\n'; port 0x41 0x00; param 0x01 0x40; port 0x41 0x01; port 0x44 60 spinForever; printf '#Vectors\n Boot start\n'; } | run otherChannel 1000000 || exit 1 QUIETED="$(measure quarterGain peak)" OTHER="$(measure otherChannel peak)" near "$OTHER" "$FULL" 5 \ && result ok "a channel keeps its own settings" "channel 0 turned down to $QUIETED, channel 1 still $OTHER" \ || result no "a channel keeps its own settings" "channel 1 came out $OTHER, channel 0's own gain gives $QUIETED" # ---- All four of them are there ---- { printf '#Program\nstart:\n'; port 0x41 0x03; port 0x44 60; spinForever printf '#Vectors\n Boot start\n'; } | run lastChannel 1000000 || exit 1 PEAK="$(measure lastChannel peak)" [ "$PEAK" -gt 1000 ] \ && result ok "the fourth channel is a channel" "peak $PEAK" \ || result no "the fourth channel is a channel" "peak was only $PEAK" # ---- Asking for a channel that is not there ---- # # It wraps rather than faulting or writing past the end of the voices. A sound device is a # poor place to stop the machine, and a poor place to corrupt memory. { printf '#Program\nstart:\n'; port 0x41 6 printf ' INA 0x41\n INIB 0d48\n CCF\n ADD\n OUTQ 0x00\n' epilogue; } | run wrapChannel 200000 || exit 1 said wrapChannel "2" \ && result ok "a channel number wraps" "6 selected channel 2" \ || result no "a channel number wraps" "$(cat "$BUILD/wrapChannel.out")" # ---- The registers read back ---- # # So a handler can save the selection and put it back, which it has to, since an interrupt in # the middle of a patch load would otherwise land the rest of the patch on another channel. { printf '#Program\nstart:\n'; port 0x42 0x2A printf ' INA 0x42\n OUTA 0x00\n'; epilogue; } | run readBack 200000 || exit 1 said readBack '\*' \ && result ok "the selectors read back" "0x2A came back as itself" \ || result no "the selectors read back" "$(od -c "$BUILD/readBack.out" | head -1)" # ---- Status: nothing is sounding until something is ---- { printf '#Program\nstart:\n'; sounding Before port 0x41 0x00; loud; port 0x44 60; sounding After; epilogue; } \ | run statusBit 500000 || exit 1 said statusBit "NY" \ && result ok "status says what is sounding" "nothing before the note, something after" \ || result no "status says what is sounding" "said $(cat "$BUILD/statusBit.out")" # ---- Not even a sustain of nothing ends a note ---- # # THE TRAP, and it caught the person writing this device before it caught anybody else. A # sustain of nothing is silent, and silence looks exactly like a finished note, so the obvious # way to play a note and wait for it is to give it no sustain and watch the status bit. It # never comes down: the voice is holding at nothing, which is a thing a held key does. # # There is one rule and this is the check that there are not two. A NOTE SOUNDS UNTIL IT IS # GATED OFF. What the envelope is doing does not enter into it. { printf '#Program\nstart:\n'; port 0x41 0x00 param 0x20 0x00 # attack: none param 0x21 40 # decay: short param 0x22 0x00 # sustain: nothing at all port 0x44 60 printf 'waitOut:\n INA 0x40\n INIB 0x01\n AND\n BNQ waitOut\n' printf ' INIA 0d90\n OUTA 0x00\n' # 'Z', reached only if the bit came down epilogue; } | run silentSustain 3000000 || exit 1 said silentSustain "Z" \ && result no "silence is not the end of a note" "the bit came down without a gate off" \ || result ok "silence is not the end of a note" "silent for three million cycles and still sounding" # And the sound really did go quiet, so the check above is about the status bit rather than # about an envelope that never decayed. PEAK="$(measure silentSustain peak 24000)" [ "$PEAK" -lt 100 ] \ && result ok "even though there is nothing to hear" "faded to $PEAK while still sounding" \ || result no "even though there is nothing to hear" "still $PEAK, it never decayed" # ---- A note that is held is not one ---- # # The trap the manual warns about: a note with sustain sounds until the gate is dropped, so a # program that waits for it waits for ever. Here the wait is bounded by the cycle limit, and # the marker not being printed is the whole point. { printf '#Program\nstart:\n'; port 0x41 0x00; loud param 0x22 0xFF # sustain: all of it port 0x44 60 printf 'held:\n INA 0x40\n INIB 0x01\n AND\n BNQ held\n' printf ' INIA 0d90\n OUTA 0x00\n' epilogue; } | run heldNote 3000000 || exit 1 said heldNote "Z" \ && result no "a held note keeps sounding" "the status bit came down on its own" \ || result ok "a held note keeps sounding" "still sounding after three million cycles" # ---- And dropping the gate is what ends it ---- { printf '#Program\nstart:\n'; port 0x41 0x00; loud param 0x22 0xFF # sustain: all of it param 0x23 20 # release: short port 0x44 60 pause A 100 port 0x45 0x00 # let go printf 'released:\n INA 0x40\n INIB 0x01\n AND\n BNQ released\n' printf ' INIA 0d90\n OUTA 0x00\n' epilogue; } | run gateOff 3000000 || exit 1 said gateOff "Z" \ && result ok "dropping the gate ends it" "the note released and the bit came down" \ || result no "dropping the gate ends it" "it was still sounding at the cycle limit" # ---- The level can be shaped by nothing at all ---- # # soundThing welded the first envelope to the output, so an envelope spent on a filter sweep # still had to be shaped like something worth hearing. Setting 0x50 to nothing is what unwelds # it. The check is that an amplitude envelope which decays to silence immediately does NOT # silence a channel whose level nothing shapes. { printf '#Program\nstart:\n'; port 0x41 0x00; loud param 0x20 0x00 # attack: none param 0x21 20 # decay: very short param 0x22 0x00 # sustain: nothing, so the envelope is at zero param 0x50 0x00 # and nothing shapes the level port 0x44 60 spinForever; printf '#Vectors\n Boot start\n'; } | run levelSource 1000000 || exit 1 LATE="$(measure levelSource peak 36000)" [ "$LATE" -gt 1000 ] \ && result ok "the level can be shaped by nothing" "still $LATE long after the envelope let go" \ || result no "the level can be shaped by nothing" "peak $LATE, the envelope silenced it anyway" # And the same patch with the envelope back on the level is silent by then, which is what # makes the check above about the setting rather than about the envelope not working. { printf '#Program\nstart:\n'; port 0x41 0x00; loud param 0x20 0x00; param 0x21 20; param 0x22 0x00 param 0x50 1 # the amplitude envelope, which is the normal case port 0x44 60 spinForever; printf '#Vectors\n Boot start\n'; } | run levelEnvelope 1000000 || exit 1 LATE="$(measure levelEnvelope peak 36000)" [ "$LATE" -lt 100 ] \ && result ok "and by the envelope, which is the normal case" "faded to $LATE" \ || result no "and by the envelope, which is the normal case" "still $LATE, it never faded" echo if [ "$FAIL" -eq 0 ]; then echo "All $PASS sound checks passed." exit 0 fi echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}" exit 1