joeylib2/scripts/verify-dos-audio.sh
2026-10-19 15:46:47 -05:00

187 lines
7.1 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-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 the module player and the SFX overlay (src/dos/audio.c). DOSBox 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 tests/gate/audioGate.c.
#
# 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 finalized 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 overlay specifically -- SFX on DOS mix
# into the module's buffer rather than a separate channel, so "SFX audible
# while the module player is loaded but paused" is exactly the path that
# can break. Measured under DOSBox Staging 0.83: music ~1590, paused ~17,
# sfx ~108 -- the card's mixer holds digital 16 dB under FM (the canonical
# mix, src/dos/audio.c mixerBalance), so an effect is as loud as a voice.
#
# 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 Staging (install.sh install emulator:dos), Xvfb, xdotool, parec/pactl,
# build/dos/bin/<example>.EXE
set -uo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
source "$repo/scripts/lib/joeyEnv.sh"
joeyNeed DOSBOX dosbox
prog=${1:-AUDIO}
binDir=$repo/build/dos/bin
conf=$repo/scripts/dosbox-386sx16.conf
grader=$(joeyTool audioGate) || exit 1
pulseLib=$repo/scripts/pulseRecordLib.sh
label=verify-dos-audio
[ -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 Xvfb xdotool parec pactl; 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; }
# A program that ships in a folder of its own (ID_FOLDER) is found there;
# 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
joeyStop "$dosPid"
[ -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=("${JOEY_DOSBOX_ISOLATE[@]}" --conf "$conf")
[ -n "${CYCLES:-}" ] && dosArgs+=(--set "cpu_cycles=$CYCLES" --set "cpu_cycles_protected=$CYCLES")
dosArgs+=(
-c "MOUNT C $mountDir"
-c "C:"
-c "$prog.EXE"
)
XDG_CONFIG_HOME="$work/xdg" 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=$(joeyFindWindow "$display" "$dosPid")
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 at about half the measured levels (music ~1590, sfx
# ~108; paused ~17 under its ceiling) so ordinary mixing jitter cannot
# flip the verdict.
"$grader" phases "$label" "$work/out.wav" \
"music:1:5:600:" \
"paused:8:13::40" \
"sfx:16:22:54:" \
"resumed:27:32:600:"
status=$?
[ -n "${DOS_AUDIO_KEEP:-}" ] && cp "$work/out.wav" "$repo/build/dos/headless-audio.wav" 2>/dev/null
exit $status