#!/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" -I "$ROOT/Programs/Libraries" -I "$ROOT/Programs/Sounds" \ "$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" # ---- 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 < 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 2200000 < "$ROOT/Programs/testPrograms/fourVoiceTest.asm" || exit 1 COUNT="$(measure fourVoices count)" near "$COUNT" 96000 0.1 \ && result ok "sixteen ticks of four voices" "$COUNT samples, and 16 x 125,000 cycles is 96,000" \ || result no "sixteen ticks of four voices" "$COUNT samples, wanted about 96,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" # ---- A voice goes on to the next sequence its order list names ---- # # Sixteen ticks of four bars, and one phrase does nearly all of it: Beat is placed twice by # voice 0 and twice by voice 1, written once, on two different voices. A voice is a property # of where a sequence is PLACED and not of the sequence, which is what a per-voice order list # buys and what a sequence carrying its own channels would have cost. # # The three windows above already ran through it - each of them is a different bar of an order # list rather than a stretch of one long track - so what is left to check is the command. # # THE FOURTH BAR IS THE SAME NOTE NUMBER AS THE FIRST. Voice 0's last sequence begins with # 0x80, which changes its patch mid-piece, and the octave that comes out is the new patch's # and not the note's. A command takes no tick: the reader acts on it and reads the note behind # it on the same boundary. SWITCHED="$(measure fourVoices pitch 74000 94000)" near "$SWITCHED" 523.3 2 \ && result ok "a command changes the instrument mid-piece" "$SWITCHED hertz from note 60, which is the patch and not the note" \ || result no "a command changes the instrument mid-piece" "$SWITCHED hertz, wanted the octave the new patch gives" # 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" # ---- The system quietens a note the program that made it cannot ---- # # A gate is a register on the sound device, and only a program can drop one. A program that # has stopped cannot: it is gone. So a note left held sustains until something else says # otherwise, and nothing else did - which means one program could leave the machine sounding # for as long as it ran, with no way for the person at it to stop it. # # CosmOS now lets every voice go when a program hands the machine back, the same way it puts # the screen and the drive and the vectors back, and from the fault path too - because a # program that CRASHED is exactly the one that cannot tidy up after itself. # # Hum starts a note whose envelope sustains at full and exits while holding it. Pause makes no # sound and takes a couple of million cycles, and it is there because the machine stops the # moment the shell runs out of input: a note quietened at that instant would leave no samples # behind to say whether it had been. # # ITS OWN DISK, so that a fixture run by one check does not move the ten recorded tests that # quote cosmos.img's directory listing. if [ ! -f "$ROOT/Tests/build/disks/quiet.img" ]; then result no "the system quietens what a program left sounding" "quiet.img is missing; run Tests/makedisks.sh" else "$ASM" -I "$ROOT/Programs/Libraries" -I "$ROOT/Programs/CosmOS/Source" \ -o "$BUILD/cosmos.bin" "$ROOT/Programs/CosmOS/Source/cosmos.asm" >/dev/null 2>&1 printf 'Hum\nPause\nexit\n' > "$BUILD/quiet.in" timeout 60 "$EMU" --fast --cycles 20000000 --sound "$BUILD/quiet.raw" \ --disk "$ROOT/Tests/build/disks/quiet.img" "$BUILD/cosmos.bin" \ < "$BUILD/quiet.in" > "$BUILD/quiet.out" 2>&1 # ---- PROOF THAT ANYTHING WAS MEASURED AT ALL ---- # # This check reads a window near the end of the render, and a window past the end of the # samples is empty, and an empty window's peak is nought - which is indistinguishable from # silence and passes. It happened: Pause was missing from the disk, the machine halted the # moment the shell ran out of input, and the check reported a quietened note on a render # a twentieth of a second long. So the length is asserted before anything is read from it. SAMPLES="$(measure quiet count)" [ "$SAMPLES" -gt 150000 ] \ || result no "the quieting check measured a real run" "only $SAMPLES samples, so the windows below are empty" # The note has to have been made, or silence afterwards proves nothing at all. This is the # half that would let the check pass on a machine where the sound device did not work. STARTED="$(measure quiet peak 0 40000)" [ "$STARTED" -gt 1000 ] \ && result ok "a program can leave a note sounding" "peak $STARTED while it held one" \ || result no "a program can leave a note sounding" "peak $STARTED, so nothing sounded and the rest proves nothing" # And by the end there is nothing, though the program that started it never let go. LEFT="$(measure quiet peak 100000 0)" [ "$LEFT" -lt 200 ] \ && result ok "the system quietens what a program left sounding" "peak $LEFT after it handed back" \ || result no "the system quietens what a program left sounding" "peak $LEFT, so it is still ringing" fi # ---- A tune read from a file ---- # # M4's loader. Everything in a tune is an OFFSET from wherever it was put, so loading one is # adding the base to two tables and pointing four voices at their order lists - no sequence is # walked and nothing inside one is an address to be found and corrected. # # BOTH NOTES ARE NUMBER 60. The octave between them is a 0x80 command in the second sequence, # loading the second patch out of the file, so what this measures is the whole path at once: # the header parsed, the tick taken from it, the tables relocated, an order list followed, and # a patch that came out of a file rather than out of the program. # # The fixture is laid out byte by byte by Tests/maketune.py, which is the hex editor rather # than the compiler - the point being that the loader is tested by something that does not # share its idea of the format. if [ ! -f "$ROOT/Tests/build/disks/quiet.img" ]; then result no "a tune read from a file" "quiet.img is missing; run Tests/makedisks.sh" else printf 'Play two.tune\n\n\nexit\n' > "$BUILD/tune.in" timeout 60 "$EMU" --fast --cycles 6000000 --sound "$BUILD/loaded.raw" \ --disk "$ROOT/Tests/build/disks/quiet.img" "$BUILD/cosmos.bin" \ < "$BUILD/tune.in" > "$BUILD/tune.out" 2>&1 STARTS="$(python3 - "$BUILD/loaded.raw" <<'PY2' import struct, sys d = open(sys.argv[1], "rb").read(); n = len(d) // 2 v = struct.unpack("<%dh" % n, d) print(next((i for i, x in enumerate(v) if abs(x) > 500), -1)) PY2 )" if [ "$STARTS" -lt 0 ]; then result no "a tune read from a file" "nothing sounded at all" else FIRST="$(measure loaded pitch $(( STARTS + 1000 )) $(( STARTS + 22000 )))" SECOND="$(measure loaded pitch $(( STARTS + 26000 )) $(( STARTS + 46000 )))" near "$FIRST" 261.6 2 \ && result ok "a tune read from a file" "$FIRST hertz, from a header and two tables" \ || result no "a tune read from a file" "$FIRST hertz, wanted middle C" near "$SECOND" 523.3 2 \ && result ok "and a command in it loads a patch from it" "$SECOND hertz from note 60 again" \ || result no "and a command in it loads a patch from it" "$SECOND hertz, wanted the octave" fi # ---- A tune's tick is the tune's ---- # # slow.tune is two.tune with twice the period and nothing else changed, so it has to last # twice as long. Play used to write its own tick over the one useTune had just taken out # of the header, and every tune played at a sixteenth note at 120 whatever it asked for - # invisible for as long as every fixture asked for exactly that. printf 'Play slow.tune\n\n\nexit\n' > "$BUILD/slow.in" timeout 60 "$EMU" --fast --cycles 8000000 --sound "$BUILD/slow.raw" \ --disk "$ROOT/Tests/build/disks/quiet.img" "$BUILD/cosmos.bin" \ < "$BUILD/slow.in" > "$BUILD/slow.out" 2>&1 SPAN="$(python3 - "$BUILD/slow.raw" <<'PY2' import struct, sys d = open(sys.argv[1], "rb").read(); n = len(d) // 2 v = struct.unpack("<%dh" % n, d) loud = [i for i, x in enumerate(v) if abs(x) > 500] print(loud[-1] - loud[0] if loud else 0) PY2 )" near "$SPAN" 96000 2 \ && result ok "a tune's tick is the tune's own" "$SPAN samples, and 8 ticks of 250,000 cycles is 96,000" \ || result no "a tune's tick is the tune's own" "$SPAN samples, wanted about 96,000" # ---- The compiler and the hex editor agree ---- # # TWO IMPLEMENTATIONS OF ONE FORMAT, which is the discipline SplitDisk and sbfs.asm are # held to and for the same reason: either alone is only self-consistent. maketune.py lays # the bytes out by hand and TuneC compiles a written source, and what the player reads is # right only if both agree about what it should say. # # The fixture is what Play was tested against before TuneC existed, so this is also the # compiler being checked against something that predates it. if cmp -s "$ROOT/Tests/build/.diskwork/compiled.tune" \ "$ROOT/Tests/build/.diskwork/two.tune"; then result ok "TuneC writes what the hand-laid fixture does" "byte for byte" else result no "TuneC writes what the hand-laid fixture does" \ "$(cmp "$ROOT/Tests/build/.diskwork/compiled.tune" "$ROOT/Tests/build/.diskwork/two.tune" 2>&1 | head -1)" fi # ---- And it refuses what the player could not notice ---- # # Each of these is something a tune can get wrong that the machine cannot tell you about. # The player has no names, so it cannot say a sequence does not exist; it has no lengths, # so it cannot say the voices will come apart four bars later; and a duration of nought is # counted down to 255 and held, which sounds like a hang rather than a mistake. refuses() { # refuses ... local want="$1"; shift printf '%s\n' "$@" > "$BUILD/bad.tune.txt" local said said="$("$ROOT/TuneC" -I "$ROOT/Tests/build/.diskwork" "$BUILD/bad.tune.txt" \ "$BUILD/bad.tune" 2>&1)" if [ -f "$BUILD/bad.tune" ]; then rm -f "$BUILD/bad.tune"; fi case "$said" in *"$want"*) result ok "TuneC refuses $want" "and says which line" ;; *) result no "TuneC refuses $want" "$(echo "$said" | head -1)" ;; esac } refuses "would come apart" \ "#Tick 0d125000" "#Patch A low.patch" "#Voice 0d0 A" "#Voice 0d1 A" \ "#Sequence Four" "0d60 0d4" "#Sequence Eight" "0d60 0d8" \ "#Order 0d0" "Four" "#Order 0d1" "Eight" refuses "a part and no instrument" \ "#Tick 0d125000" "#Patch A low.patch" "#Sequence S" "0d60 0d4" "#Order 0d0" "S" refuses "a duration is from" \ "#Tick 0d125000" "#Patch A low.patch" "#Voice 0d0 A" "#Sequence S" "0d60 0d0" \ "#Order 0d0" "S" # ---- AND IT SAYS IT ONCE ---- # # One missing patch used to be seven messages: the name went unregistered, so every #Voice # naming it failed too, and then the check that a voice has an instrument failed for each # of those. Only the first was worth reading. A compiler that says one thing seven ways # teaches people to read the last line, which is the one that matters least. printf '%s\n' "#Tick 0d125000" "#Patch A nosuch.patch" \ "#Voice 0d0 A" "#Voice 0d1 A" "#Voice 0d2 A" \ "#Sequence S" "0d60 0d4" \ "#Order 0d0" "S" "#Order 0d1" "S" "#Order 0d2" "S" > "$BUILD/cascade.tune.txt" SAID="$("$ROOT/TuneC" "$BUILD/cascade.tune.txt" "$BUILD/cascade.tune" 2>&1 \ | grep -c "cannot find\|no patch of that name\|no instrument")" [ "$SAID" = "1" ] \ && result ok "TuneC says a missing patch once" "one message, not one for every use of it" \ || result no "TuneC says a missing patch once" "$SAID messages for one missing file" refuses "no sequence of that name" \ "#Tick 0d125000" "#Patch A low.patch" "#Voice 0d0 A" "#Sequence S" "0d60 0d4" \ "#Order 0d0" "Nope" # ---- And a file that is not a tune is refused ---- # # The loader reads offsets out of a file and follows what they name, so a file that is not # one has to be turned away at the magic rather than a few instructions later. printf 'Play Apps/Hum.sbx\nexit\n' > "$BUILD/notune.in" timeout 30 "$EMU" --fast --cycles 3000000 --disk "$ROOT/Tests/build/disks/quiet.img" \ "$BUILD/cosmos.bin" < "$BUILD/notune.in" > "$BUILD/notune.out" 2>&1 grep -q "not a tune" "$BUILD/notune.out" \ && result ok "and a file that is not a tune is refused" "it said so rather than playing it" \ || result no "and a file that is not a tune is refused" "$(tail -2 "$BUILD/notune.out" | tr '\n' ' ')" fi # ---- A game plays its own music ---- # # The first customer for the player outside the program it was extracted from. Lander includes # Libraries/player.asm, reads /splash.tune off the disk, and plays it over a studio line before # the world is built - owning the timer and all four channels while it does, because nothing # else is happening yet. # # IT POLLS THE TIMER RATHER THAN BEING INTERRUPTED BY IT. Lander has no vector segment and # waits for the screen by reading port 0x30, so it waits for a beat by reading port 0x50 - and # brings no handler that would have to be taken away before the game starts. # # No key is pressed here, which is the point: every other Lander test skips the splash with a # space, so this is the only one that hears it. if [ ! -f "$ROOT/Tests/build/disks/cosmos.img" ]; then result no "a game plays its own music" "cosmos.img is missing" else python3 -c " open('$BUILD/splash.keys','wb').write(b'Lander\n' + b'\x00'*900) open('$BUILD/still.pad','wb').write(b'\x00' * 20000)" timeout 90 "$EMU" --fast --cycles 40000000 --keyboard "$BUILD/splash.keys" \ --pad "$BUILD/still.pad" --sound "$BUILD/splash.raw" \ --disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \ "$BUILD/cosmos.bin" > "$BUILD/splash.out" 2>&1 || true # The tune is six seconds of ticks. What is checked is that music is still playing well # after a splash that failed to load would have given up: the silent path holds the logo # for ninety frames, a second and a half, and then the game starts. read -r EARLY LATE <