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