#!/usr/bin/env bash # verify-atarist-audio.sh - Atari ST audio gate, read off the YM2149. # # The ST has ONE sound chip and two software layers competing for it, so # this gate traces every PSG register write and grades the competition # rather than listening to the output. Four things are checked: # # 1. The chip-tracker is alive. jlMusicPlay drives the three PSG voices # through reg 0-5 (12-bit tone period) and reg 8/9/A (4-bit volume), # gated by reg 7. Writes must come from the APPLICATION, not TOS -- # the trace carries the pc of every write and TOS lives at $Exxxxx, # which is the discriminator used throughout. # # 2. The digital pump runs at its REAL rate. Sampled sound on the ST is # PWM through channel A's volume nibble: MFP Timer A at 2.4576 MHz / # 200 = 12288 Hz, and src/atarist/audio.c writes reg 8 on every one # of those ticks. Per frame that is 12288/60 = 205 (measured) or # 12288/50 = 246, against the tracker's ONE. The two populations # cannot be confused, so a per-frame count answers both "did the # pump start" and "is the divider right" -- a halved divider would # show ~103 and still sound like something. # # 3. Reg 8 has exactly ONE writer while the pump runs. This is the bug # the gate exists for: the tracker's voice 0 and the PWM pump both # own reg 8, and whoever wrote last wins, so a tracker that keeps # writing it chops the sample into silence. The pc in the trace # makes the writers countable. Each pump run's first and last frame # are excluded: pumpStart and uninstallTimerA legitimately touch # reg 8 from outside the ISR (the latter replays the latched voice). # # 4. They actually OVERLAP. "No collision" is worthless if it was # bought by silencing the music, so the tracker must still be # driving its own channel B inside the pump window. # # Subject: the AUDIO example, which starts the baked tracker song on # entry and fires a digital SFX on SPACE -- precisely the overlap. Note # SPACE is HELD (keydown, wait, keyup) rather than tapped: the demo tests # jlKeyPressed, an edge predicate polled once a frame, and a make/break # delivered between two polls is never seen. A bare `keypress` produces a # run with the tracker alone and no pump at all. # # A program that stops its music while it plays a sample (as a game may # for speech) is a poor subject here despite exercising both layers: # check 4 can never pass on it. # # Usage: scripts/verify-atarist-audio.sh [example] # example PRG base name in build/atarist/bin, default AUDIO # Env: ST_AUDIO_TAPS how many held SPACE presses (default 6) # ST_AUDIO_KEEP set to keep the work dir for inspection # Requires: ./install.sh install emulator:atarist (Hatari, EmuTOS), # build/atarist/bin/.PRG set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) source "$repo/scripts/lib/joeyEnv.sh" joeyNeed HATARI hatari joeyNeed EMUTOS_IMG emutos prog=${1:-AUDIO} taps=${ST_AUDIO_TAPS:-6} tos=$EMUTOS_IMG binDir=$repo/build/atarist/bin # A program that ships in a folder of its own (ID_FOLDER) is found there; # everything else is a bare PRG. if [ -d "$binDir/$prog" ]; then driveDir=$binDir/$prog else driveDir=$binDir fi [ -f "$driveDir/$prog.PRG" ] || { echo "verify-atarist-audio: missing $driveDir/$prog.PRG (run 'make atarist')" >&2; exit 2; } [ -f "$tos" ] || { echo "verify-atarist-audio: missing $tos" >&2; exit 2; } work=$(mktemp -d -t joeylib-staudio.XXXXXX) hatariPid="" cleanup() { # SIGKILL, by PID. Three things are deliberate here: # * SIGKILL, because Hatari IGNORES SIGTERM -- measured: a plain # kill, and `pkill -x hatari`, both leave it running forever. # * by PID, not by name, so a Hatari the user has open survives. # * no `timeout -s KILL` wrapper, because that would make $! the # timeout and killing THAT orphans Hatari. The trap is the only # thing that needs to work, and it covers EXIT/INT/TERM. # The launch uses process substitution so $! is Hatari itself rather # than the filter at the end of a pipe. [ -n "$hatariPid" ] && { kill -KILL "$hatariPid"; wait "$hatariPid"; } 2>/dev/null [ -n "${ST_AUDIO_KEEP:-}" ] || rm -rf "$work" return 0 } trap cleanup EXIT INT TERM [ -n "${ST_AUDIO_KEEP:-}" ] && echo "verify-atarist-audio: work dir $work" >&2 # 4 MB, not the stock 1 MB: a big program's loader may call 1 MB a fatal # asset failure, and this gate is about sound, not about memory. # # The psg_write trace runs ~800 KB/s, so it is filtered as it is produced # -- keeping the raw stream would put hundreds of MB in the work dir. SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy "$HATARI" \ --tos "$tos" \ --harddrive "$driveDir" \ --gemdos-drive C \ --drive-a off --drive-b off \ --sound 44100 \ --memsize 4 \ --fast-forward on \ --cmd-fifo "$work/cmd.fifo" \ --trace psg_write \ --auto "C:\\$prog.PRG" \ > >(grep --line-buffered -E '^ym write data' > "$work/ym.log") 2>&1 & hatariPid=$! # Hatari CREATES the fifo; a write to one with no reader blocks forever, # so every command goes through a timeout. for _ in $(seq 1 25); do [ -p "$work/cmd.fifo" ] && break sleep 1 done if [ ! -p "$work/cmd.fifo" ]; then echo "verify-atarist-audio: FAIL - hatari never created the command fifo" >&2 exit 1 fi send() { timeout 5 bash -c "echo \"\$1\" > \"$work/cmd.fifo\"" _ "$1"; } # Wait for the APPLICATION to write the PSG. TOS writes it too (bell, # floppy seek), and every TOS pc starts with 'e'. waited=0 until grep -qE 'pc=[^e]' "$work/ym.log" 2>/dev/null; do sleep 2 waited=$((waited + 2)) if [ "$waited" -ge 40 ]; then echo "verify-atarist-audio: FAIL - $prog never wrote the PSG in ${waited}s (the tracker never started)" >&2 exit 1 fi if ! kill -0 "$hatariPid" 2>/dev/null; then echo "verify-atarist-audio: FAIL - Hatari exited early" >&2 exit 1 fi done # Let the tracker settle, then snapshot a pump-free baseline. sleep 8 cp "$work/ym.log" "$work/baseline.log" # HOLD space so the once-a-frame jlKeyPressed poll can see the edge. i=0 while [ "$i" -lt "$taps" ]; do send 'hatari-event keydown 57' || true # $39 = SPACE sleep 1 send 'hatari-event keyup 57' || true sleep 1 i=$((i + 1)) done sleep 3 # The braces + wait suppress bash's "Killed" job notice on stderr, which # would otherwise look like a gate error in the output. { kill -KILL "$hatariPid"; wait "$hatariPid"; } 2>/dev/null hatariPid="" sleep 1 [ -s "$work/ym.log" ] || { echo "verify-atarist-audio: FAIL - empty PSG trace" >&2; exit 1; } python3 - "$work/ym.log" "$work/baseline.log" "$prog" "$taps" <<'PY' import re import sys # ym write data reg=0x8 val=0xc video_cyc=8696 60@17 pc=e1197e instr_cycle 16 LINE = re.compile(r'^ym write data reg=0x([0-9a-f]+) val=0x([0-9a-f]+) ' r'video_cyc=(\d+).*pc=([0-9a-f]+)') PUMP_PER_FRAME_MIN = 100 # tracker writes <= 1; the pump writes 205-246 PUMP_RATE_LO = 150 # 12288/60 = 205, 12288/50 = 246. Below this the PUMP_RATE_HI = 280 # Timer A divider is wrong, not the sound. STRAY_FRAME_PCT_MAX = 5.0 # start/stop transitions only; a fighting # tracker touches reg 8 EVERY frame. REG_VOL_A = 8 REG_MIXER = 7 TONE_B_REGS = (2, 3) # channel B period == the tracker's own voice def perFrameWrites(path): """{frame: {reg: [pc, ...]}} for APPLICATION writes only.""" out = {} frame = 0 prev = -1 for line in open(path, errors='ignore'): m = LINE.match(line) if m is None: continue reg = int(m.group(1), 16) cyc = int(m.group(3)) pc = m.group(4) if cyc < prev: # video_cyc restarts every frame frame += 1 prev = cyc if pc.startswith('e'): # TOS, not us continue out.setdefault(frame, {}).setdefault(reg, []).append(pc) return out def countReg(frames, table, regs): return sum(len(table[f].get(r, ())) for f in frames for r in regs) full = perFrameWrites(sys.argv[1]) base = perFrameWrites(sys.argv[2]) prog = sys.argv[3] taps = int(sys.argv[4]) if not full: print("verify-atarist-audio: FAIL - no application PSG writes at all", file=sys.stderr) sys.exit(1) frames = sorted(full) fails = [] # 1. The tracker ran before any SFX overlapped it. baseFrames = sorted(base) baseTone = countReg(baseFrames, base, TONE_B_REGS) baseMixer = countReg(baseFrames, base, (REG_MIXER,)) if baseTone == 0 or baseMixer == 0: fails.append(f"the tracker never played: {baseTone} channel-B period writes and " f"{baseMixer} mixer writes before the SFX. jlMusicPlay did not drive the PSG") # 2. The pump ran, at the right rate. Group pump frames into contiguous runs # so each SFX can be told from the next. runs = [] current = [] for f in frames: if len(full[f].get(REG_VOL_A, ())) >= PUMP_PER_FRAME_MIN: current.append(f) elif current: runs.append(current) current = [] if current: runs.append(current) peak = max((len(full[f].get(REG_VOL_A, ())) for f in frames), default=0) if not runs: fails.append(f"the PWM pump never ran: peak {peak} reg-8 writes in a frame, want " f">= {PUMP_PER_FRAME_MIN}. jlAudioPlaySfx did not start Timer A, so " f"every digital sound on this port is silent") elif not (PUMP_RATE_LO <= peak <= PUMP_RATE_HI): fails.append(f"the pump ran at {peak} reg-8 writes/frame, want {PUMP_RATE_LO}-{PUMP_RATE_HI} " f"(12288 Hz / 50-60 fps). The MFP Timer A divider is wrong, so sampled " f"sound plays at the wrong pitch") summary = f"{prog} tracker baseline {baseTone} toneB/{baseMixer} mixer" if runs: # 3 + 4. Inside each run, minus its first and last frame. interior = [f for r in runs for f in (r[1:-1] if len(r) > 2 else [])] writers = {} for f in interior: for pc in full[f].get(REG_VOL_A, ()): writers[pc] = writers.get(pc, 0) + 1 ranked = sorted(writers.items(), key=lambda kv: -kv[1]) isr = ranked[0][0] if ranked else None strayFrames = sum(1 for f in interior if any(pc != isr for pc in full[f].get(REG_VOL_A, ()))) strayPct = (100.0 * strayFrames / len(interior)) if interior else 0.0 if strayPct > STRAY_FRAME_PCT_MAX: others = ", ".join(f"pc={pc} x{n}" for pc, n in ranked[1:4]) fails.append(f"reg 8 (volume A) has {len(ranked)} writers in {strayPct:.1f}% of the " f"pump's frames: ISR pc={isr} plus {others}. The tracker is overwriting " f"the PWM sample - digital and chiptune are colliding on channel A") toneDuring = countReg(interior, full, TONE_B_REGS) if toneDuring == 0: fails.append("the tracker stopped writing its own channel B while the pump ran, so " "the SFX silenced the music instead of mixing with it") summary += (f"; pump {len(runs)} runs (expected {taps}) peak {peak} reg8/frame, " f"{len(writers)} reg8 writer(s), stray frames {strayPct:.1f}%, " f"tracker toneB during pump {toneDuring}") else: summary += f"; pump never ran (peak {peak} reg8/frame)" print(f"verify-atarist-audio: {summary}") for f in fails: print(f"verify-atarist-audio: {f}", file=sys.stderr) if fails: print("verify-atarist-audio: FAIL", file=sys.stderr) sys.exit(1) print("verify-atarist-audio: PASS") PY