#!/usr/bin/env bash # verify-dos-audio.sh - DOS audio gate, graded on recorded sound. # # The DOS HAL drives a Sound Blaster Pro/SB16 with 8237 auto-init DMA fed # by libxmp-lite (src/dos/audio.c). DOSBox 0.74 exposes none of that to a # script -- no register trace, no debugger this harness can drive -- so # unlike the IIgs and ST gates (which read the DOC and the YM2149) this # one records what actually came out and grades it. The Amiga gate works # the same way for the same reason, and both share tools/gradeAudioPhases.py. # # Capture path: DOSBox plays through SDL, SDL is pointed at PulseAudio, # and PULSE_SINK sends this ONE process to a null sink created for the # run, so the host's default sink is never touched and the gate is safe on # a desktop that is playing something else. parec records that sink's # monitor. Note the WAV header is only finalised when parec exits, so it # is stopped and waited for before the file is read. # # What is graded: the AUDIO example is driven through four phases whose # expected loudness differs by a wide margin. # # music the baked tracker song on entry, loud # paused P pressed, near silence # sfx SPACE during the pause, so ANY sound must be the digital SFX # resumed P again, loud again # # Firing the SFX into a paused song is the point: mixed under the music an # 8-bit one-shot barely moves the total, while against silence it is # unambiguous. This also covers the SMIX overlay specifically -- SFX on # DOS mix through libxmp-lite's smix rather than a separate channel, so # "SFX audible while the module player is loaded but paused" is exactly # the path that can break. Measured: music ~3290, paused ~113, sfx ~1341. # # Keys are HELD (keydown, pause, keyup), not tapped: the demo tests # jlKeyPressed, an edge predicate polled once a frame, and a make/break # delivered between two polls is never seen. # # Usage: scripts/verify-dos-audio.sh [example] # example EXE base name in build/dos/bin, default AUDIO # Env: DOS_AUDIO_KEEP set to keep the work dir and the WAV # CYCLES override the conf's 386SX-16 cpu cap # Requires: dosbox, Xvfb, xdotool, parec/pactl (PulseAudio or PipeWire), # python3, build/dos/bin/.EXE set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) prog=${1:-AUDIO} binDir=$repo/build/dos/bin conf=$repo/scripts/dosbox-386sx16.conf grader=$repo/tools/gradeAudioPhases.py pulseLib=$repo/scripts/pulseRecordLib.sh label=verify-dos-audio [ -f "$grader" ] || { echo "$label: missing $grader" >&2; exit 2; } [ -f "$pulseLib" ] || { echo "$label: missing $pulseLib" >&2; exit 2; } # shellcheck source=scripts/pulseRecordLib.sh . "$pulseLib" [ -f "$conf" ] || { echo "$label: missing $conf" >&2; exit 2; } for tool in dosbox Xvfb xdotool parec pactl python3; do command -v "$tool" >/dev/null || { echo "$label: $tool not installed" >&2; exit 2; } done pulseEnsureServer || { echo "$label: no PulseAudio/PipeWire server to record from (tried XDG_RUNTIME_DIR and /run/user/$(id -u)/pulse/native)" >&2; exit 2; } # Space Taxi ships as its own folder; everything else is a bare EXE. if [ -f "$binDir/$prog/$prog.EXE" ]; then mountDir=$binDir/$prog elif [ -f "$binDir/$prog.EXE" ]; then mountDir=$binDir else echo "$label: $binDir/$prog.EXE not built (run 'make dos')" >&2 exit 2 fi work=$(mktemp -d -t joeylib-dosaudio.XXXXXX) sinkModule="" display=":$((90 + RANDOM % 9))" xvfbPid="" dosPid="" parecPid="" cleanup() { [ -n "$parecPid" ] && kill "$parecPid" 2>/dev/null [ -n "$dosPid" ] && kill "$dosPid" 2>/dev/null [ -n "$xvfbPid" ] && kill "$xvfbPid" 2>/dev/null # Always unload the sink: a leaked null sink on the user's audio # server is the one side effect this gate could have. pulseUnloadSink "$sinkModule" if [ -n "${DOS_AUDIO_KEEP:-}" ]; then echo "$label: kept $work" >&2 else rm -rf "$work" fi return 0 } trap cleanup EXIT sinkName="joeyDosGate$$" sinkModule=$(pulseCreateSink "$sinkName") || sinkModule="" [ -n "$sinkModule" ] || { echo "$label: could not create a null sink to record from" >&2; exit 2; } Xvfb "$display" -screen 0 1024x768x24 -nolisten tcp >/dev/null 2>&1 & xvfbPid=$! sleep 1 dosArgs=(-conf "$conf") [ -n "${CYCLES:-}" ] && dosArgs+=(-set "cpu cycles=$CYCLES") dosArgs+=( -c "MOUNT C $mountDir" -c "C:" -c "$prog.EXE" ) DISPLAY="$display" PULSE_SINK="$sinkName" SDL_AUDIODRIVER=pulse \ dosbox "${dosArgs[@]}" > "$work/dosbox.log" 2>&1 & dosPid=$! sleep 12 if ! kill -0 "$dosPid" 2>/dev/null; then echo "$label: FAIL - DOSBox exited before the program started" >&2 tail -5 "$work/dosbox.log" >&2 exit 1 fi win=$(DISPLAY="$display" xdotool search --name -- "DOSBox" | head -1) if [ -z "$win" ]; then echo "$label: FAIL - no DOSBox window to send keys to" >&2 exit 1 fi # The title carries the running program, which is a free check that the # EXE actually launched rather than dropping to a DOS prompt. title=$(DISPLAY="$display" xdotool getwindowname "$win" 2>/dev/null) case "$title" in *"$prog"*) ;; *) echo "$label: FAIL - DOSBox is not running $prog (window title: $title)" >&2 exit 1 ;; esac parec --device="$sinkName.monitor" --format=s16le --rate=44100 --channels=2 \ --file-format=wav "$work/out.wav" > "$work/parec.log" 2>&1 & parecPid=$! DISPLAY="$display" xdotool windowactivate --sync "$win" 2>/dev/null hold() { # $1 = key name DISPLAY="$display" xdotool keydown --window "$win" "$1" sleep 0.4 DISPLAY="$display" xdotool keyup --window "$win" "$1" } # The phase boundaries below are the timestamps handed to the grader, so # the sleeps and the spec must stay in step. sleep 6 # 0-6s music hold p # -> paused sleep 8 # 6-14s paused for _ in 1 2 3; do # 14-24s sfx into silence hold space sleep 1.5 done sleep 4 hold p # -> resumed sleep 9 # 24-33s resumed # parec writes the WAV header lazily; it must exit before the file parses. kill "$parecPid" 2>/dev/null wait "$parecPid" 2>/dev/null parecPid="" [ -s "$work/out.wav" ] || { echo "$label: FAIL - recorded no audio at all" >&2; exit 1; } # Thresholds sit far from the measured values (music ~3290, paused ~113, # sfx ~1341) so ordinary mixing jitter cannot flip the verdict. python3 "$grader" "$label" "$work/out.wav" \ "music:1:5:1500:" \ "paused:8:13::600" \ "sfx:16:22:700:" \ "resumed:27:32:1500:" status=$? [ -n "${DOS_AUDIO_KEEP:-}" ] && cp "$work/out.wav" "$repo/build/dos/headless-audio.wav" 2>/dev/null exit $status