419 lines
19 KiB
Bash
Executable file
419 lines
19 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
|
|
# See LICENSE.md for license information.
|
|
# SPDX-License-Identifier: MIT
|
|
#
|
|
# 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.
|
|
#
|
|
# --machine ste runs the program's STe build (make ste, build/ste) on an
|
|
# STe, where src/atarist/audio.c plays digital sound through the 8-bit DMA
|
|
# sound chip instead of the PSG. Hatari's dmasound trace shows the chip
|
|
# being started and every sample it plays, so the STe checks are:
|
|
#
|
|
# 1. The tracker is alive (as above).
|
|
# 2. The DMA chip ran, as the HAL programs it: stereo at 12517 Hz, play
|
|
# + repeat.
|
|
# 3. It played the SFX: each run pulls a stretch of non-silent samples
|
|
# from the mix buffer, near half scale (the mix's unit for effects).
|
|
# A chip looping over silence (a mix that never reached the buffer,
|
|
# or unsigned samples left unconverted, which play as a DC step and
|
|
# not a wave) fails here.
|
|
# 4. No PWM pump: reg 8 is never written at the pump's rate. The YM is
|
|
# not a DAC on this machine.
|
|
# 5. Channel A is the tracker's while the chip plays: voice 0's period
|
|
# and volume registers (0, 1, 8) are written from the application
|
|
# inside the DMA runs. In the ST build voice 0 stands down for the
|
|
# pump, so this is the collision gone, not merely avoided.
|
|
#
|
|
# Usage: scripts/verify-atarist-audio.sh [--machine st|ste] [example]
|
|
# st (default): the ST build (build/atarist/bin) on a plain ST
|
|
# ste: the STe build (build/ste/bin) on an STe
|
|
# example PRG base name, 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),
|
|
# <example>.PRG in that build's bin (make atarist, make ste)
|
|
set -uo pipefail
|
|
|
|
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
source "$repo/scripts/lib/joeyEnv.sh"
|
|
joeyNeed HATARI hatari
|
|
joeyNeed EMUTOS_IMG emutos
|
|
machine=st
|
|
if [ "${1:-}" = "--machine" ]; then
|
|
machine=${2:-}
|
|
shift 2
|
|
fi
|
|
case $machine in
|
|
st) build=atarist; traceFlags=psg_write; traceKeep='^ym write data' ;;
|
|
ste) build=ste; traceFlags=psg_write,dmasound; traceKeep='^ym write data|^DMA snd (control write|mode write|fifo pull)' ;;
|
|
*) echo "usage: scripts/verify-atarist-audio.sh [--machine st|ste] [example]" >&2; exit 2 ;;
|
|
esac
|
|
prog=${1:-AUDIO}
|
|
taps=${ST_AUDIO_TAPS:-6}
|
|
tos=$EMUTOS_IMG
|
|
binDir=$repo/build/$build/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 $build')" >&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. The
|
|
# dmasound trace keeps only the chip's control and mode writes and the
|
|
# samples it plays (its FIFO refills would double the volume for nothing).
|
|
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy "$HATARI" \
|
|
--machine "$machine" \
|
|
--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 "$traceFlags" \
|
|
--auto "C:\\$prog.PRG" \
|
|
> >(grep --line-buffered -E "$traceKeep" > "$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" "$machine" <<'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
|
|
VOICE_A_REGS = (0, 1, 8) # channel A period + volume == the tracker's voice 0
|
|
|
|
# STe DMA sound: "DMA snd mode write: 0x01 mode=stereo freq=12517 ... pc=22eec"
|
|
DMA_WRITE = re.compile(r'^DMA snd (?:mode|control) write: 0x([0-9a-f]+) .*pc=([0-9a-f]+)')
|
|
DMA_MODE_STEREO_12K = 0x01 # bit 7 clear stereo, rate 1 = 12517 Hz
|
|
DMA_CTRL_PLAY_LOOP = 0x03 # play + repeat
|
|
DMA_UNCONVERTED_SILENCE = -128 # $80, the mixer's unsigned silence, read as signed
|
|
SFX_SAMPLES_MIN = 100 # test.sfx is ~390 non-silent samples at 12517 Hz
|
|
SFX_PEAK_MIN = 32 # it peaks near half scale, the mix's unit (61 measured)
|
|
UNSIGNED_PCT_MAX = 5.0
|
|
|
|
|
|
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])
|
|
machine = sys.argv[5]
|
|
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")
|
|
|
|
|
|
def gradePump(summary):
|
|
"""Plain ST: the PWM pump through channel A's volume, checks 2-4."""
|
|
# 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")
|
|
|
|
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)"
|
|
return summary
|
|
|
|
|
|
def gradeDma(summary):
|
|
"""STe: the DMA chip plays the digital sound and the PSG keeps channel A."""
|
|
modes = []
|
|
controls = []
|
|
runs = []
|
|
run = None
|
|
for line in open(sys.argv[1], errors='ignore'):
|
|
if line.startswith('DMA snd mode write:'):
|
|
m = DMA_WRITE.match(line)
|
|
if m and not m.group(2).startswith('e'):
|
|
modes.append(int(m.group(1), 16))
|
|
elif line.startswith('DMA snd control write: starting'):
|
|
run = {'samples': [], 'ym': {}}
|
|
runs.append(run)
|
|
elif line.startswith('DMA snd control write: stopping'):
|
|
run = None
|
|
elif line.startswith('DMA snd control write:'):
|
|
m = DMA_WRITE.match(line)
|
|
if m and not m.group(2).startswith('e'):
|
|
controls.append(int(m.group(1), 16))
|
|
elif run is None:
|
|
continue
|
|
elif line.startswith('DMA snd fifo pull'):
|
|
v = int(line.split()[-1], 16)
|
|
run['samples'].append(v - 256 if v > 127 else v)
|
|
else:
|
|
m = LINE.match(line)
|
|
if m and not m.group(4).startswith('e'):
|
|
reg = int(m.group(1), 16)
|
|
run['ym'][reg] = run['ym'].get(reg, 0) + 1
|
|
|
|
# 2. The chip ran, programmed as the HAL programs it.
|
|
if not runs:
|
|
fails.append("the DMA sound chip never started: jlAudioPlaySfx did not reach it, so "
|
|
"every digital sound on the STe is silent")
|
|
if not modes or any(m != DMA_MODE_STEREO_12K for m in modes):
|
|
fails.append(f"DMA mode writes {[hex(m) for m in modes]}, want {hex(DMA_MODE_STEREO_12K)} "
|
|
f"(stereo, 12517 Hz) -- the mix is made for that rate and layout")
|
|
if DMA_CTRL_PLAY_LOOP not in controls:
|
|
fails.append(f"DMA control writes {[hex(c) for c in controls]} never set play + repeat "
|
|
f"({hex(DMA_CTRL_PLAY_LOOP)}), so the chip cannot loop over the mix buffer")
|
|
|
|
# 3. Each run played the SFX.
|
|
played = [sum(1 for v in r['samples'] if v != 0) for r in runs]
|
|
peaks = [max((abs(v) for v in r['samples']), default=0) for r in runs]
|
|
pulled = sum(len(r['samples']) for r in runs)
|
|
unsignedSilence = sum(1 for r in runs for v in r['samples'] if v == DMA_UNCONVERTED_SILENCE)
|
|
if runs and (min(played) < SFX_SAMPLES_MIN or min(peaks) < SFX_PEAK_MIN):
|
|
fails.append(f"a DMA run played no SFX: non-silent samples per run {played} (want >= "
|
|
f"{SFX_SAMPLES_MIN}), peaks {peaks} (want >= {SFX_PEAK_MIN}). The mix never "
|
|
f"reached the buffer the chip plays")
|
|
if pulled and 100.0 * unsignedSilence / pulled > UNSIGNED_PCT_MAX:
|
|
fails.append(f"{unsignedSilence} of {pulled} samples are -128: unsigned silence the HAL "
|
|
f"never converted to the chip's signed format, a DC step rather than a wave")
|
|
|
|
# 4. No PWM pump on this machine.
|
|
peak = max((len(full[f].get(REG_VOL_A, ())) for f in frames), default=0)
|
|
if peak >= PUMP_PER_FRAME_MIN:
|
|
fails.append(f"reg 8 was written {peak} times in one frame: the PWM pump ran on a "
|
|
f"machine with DMA sound, so channel A is still the DAC")
|
|
|
|
# 5. The tracker keeps channel A (and B) while the chip plays.
|
|
voiceA = sum(r['ym'].get(reg, 0) for r in runs for reg in VOICE_A_REGS)
|
|
toneB = sum(r['ym'].get(reg, 0) for r in runs for reg in TONE_B_REGS)
|
|
if runs and voiceA == 0:
|
|
fails.append("the tracker wrote none of voice 0's registers (0, 1, 8) while the DMA chip "
|
|
"played: channel A is still being given up to digital sound")
|
|
if runs and toneB == 0:
|
|
fails.append("the tracker stopped writing its own channel B while the DMA chip played, "
|
|
"so the SFX silenced the music instead of mixing with it")
|
|
|
|
return (summary + f"; DMA {len(runs)} runs (expected {taps}), mode "
|
|
f"{','.join(sorted(set(hex(m) for m in modes)))}, non-silent samples/run "
|
|
f"{min(played, default=0)}..{max(played, default=0)}, peak "
|
|
f"{max(peaks, default=0)}, peak reg8/frame {peak}, tracker voice A {voiceA} / "
|
|
f"toneB {toneB} writes during DMA")
|
|
|
|
|
|
summary = f"{prog} tracker baseline {baseTone} toneB/{baseMixer} mixer"
|
|
summary = gradeDma(summary) if machine == 'ste' else gradePump(summary)
|
|
|
|
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
|