176 lines
6.6 KiB
Bash
Executable file
176 lines
6.6 KiB
Bash
Executable file
#!/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/<example>.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
|
|
label=verify-dos-audio
|
|
|
|
[ -f "$grader" ] || { echo "$label: missing $grader" >&2; exit 2; }
|
|
[ -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
|
|
pactl info >/dev/null 2>&1 || { echo "$label: no PulseAudio/PipeWire server to record from" >&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.
|
|
[ -n "$sinkModule" ] && pactl unload-module "$sinkModule" 2>/dev/null
|
|
if [ -n "${DOS_AUDIO_KEEP:-}" ]; then
|
|
echo "$label: kept $work" >&2
|
|
else
|
|
rm -rf "$work"
|
|
fi
|
|
return 0
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
sinkName="joeyDosGate$$"
|
|
sinkModule=$(pactl load-module module-null-sink \
|
|
sink_name="$sinkName" \
|
|
sink_properties=device.description="$sinkName" 2>/dev/null) || 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
|