joeylib2/scripts/verify-win16-audio.sh
2026-10-13 14:01:46 -05:00

151 lines
5.7 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-win16-audio.sh - Win16 audio gate, graded on recorded sound.
#
# The port mixes in software (src/hosted/audio.c, 22.05 kHz mono, 8-bit out)
# and plays through waveOut, which on Windows 3.11 under DOSBox
# (scripts/lib/win16Gate.sh) is Windows' own Sound Blaster 1.5 driver on
# DOSBox's emulated card. DOSBox plays through SDL into PulseAudio, and
# PULSE_SINK sends this one run to a null sink made for it
# (scripts/pulseRecordLib.sh), so the user's own sound is never touched;
# parec records the sink's monitor and tools/gradeAudioPhases.py grades it
# through the same six phases as the SDL3 and Win32 gates: music, paused,
# SFX into the pause, resumed, the noise sweep alone, the MOD. Windows boots
# before AUDIO plays anything, so the clock starts when the song is first
# heard: the gate records from the start, waits for the first sound, runs
# the keys (held 0.4 s: AUDIO reads jlKeyPressed once a frame) from there,
# and grades the recording from that first sound on.
#
# Usage: scripts/verify-win16-audio.sh
# Env: WIN16_AUDIO_KEEP set to keep the recording as
# build/win16/headless-audio.wav
# Requires: Windows on DOSBox (install.sh install emulator:win16), Xvfb,
# xdotool, parec/pactl, python3; build/win16/bin/AUDIO.EXE
# (make win16).
set -uo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
source "$repo/scripts/lib/joeyEnv.sh"
source "$repo/scripts/lib/win16Gate.sh"
source "$repo/scripts/pulseRecordLib.sh"
label=verify-win16-audio
win16Need
grader=$repo/tools/gradeAudioPhases.py
program=$(joeyExampleProgram audio win16)
[ -f "$program" ] || { echo "$label: build AUDIO.EXE first (make win16)" >&2; exit 2; }
for tool in 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" >&2; exit 2; }
work=$(mktemp -d -t joeylib-win16-audio.XXXXXX)
sinkModule=""
parecPid=""
cleanup() {
[ -n "$parecPid" ] && kill "$parecPid" 2> /dev/null
win16Stop
pulseUnloadSink "$sinkModule"
rm -rf "$work"
return 0
}
trap cleanup EXIT
sinkName="joeyWin16Gate$$"
# A short record latency, so the first sound reaches the file when it is
# played: the clock starts there.
RECORD_LATENCY_MS=250
SOUND_THRESHOLD=256
SOUND_WAIT_SECONDS=60
sinkModule=$(pulseCreateSink "$sinkName") || sinkModule=""
[ -n "$sinkModule" ] || { echo "$label: could not create a null sink to record from" >&2; exit 2; }
# In place: the SFX and the MOD are in the DATA folder beside it.
win16Stage "$work" "$(dirname "$program")" "$(basename "$program")"
parec --latency-msec="$RECORD_LATENCY_MS" --device="$sinkName.monitor" --format=s16le --rate=44100 --channels=2 \
> "$work/out.raw" 2> "$work/parec.log" &
parecPid=$!
WIN16_SOUND=1 PULSE_SINK="$sinkName" SDL_AUDIODRIVER=pulse win16Start "$work"
# The byte offset of the first sound in the recording so far, or nothing.
firstSound() {
python3 - "$work/out.raw" "$SOUND_THRESHOLD" <<'PY'
import array
import sys
data = open(sys.argv[1], "rb").read()
samples = array.array("h", data[: len(data) - len(data) % 4])
for i, v in enumerate(samples):
if abs(v) > int(sys.argv[2]):
print((i // 2) * 4)
break
PY
}
start=""
for ((waited = 0; waited < SOUND_WAIT_SECONDS * 4; waited++)); do
start=$(firstSound)
[ -n "$start" ] && break
sleep 0.25
done
[ -n "$start" ] || { echo "$label: FAIL - no sound within ${SOUND_WAIT_SECONDS}s" >&2; tail -5 "$work/dosbox.log" >&2; exit 1; }
win=$(win16Window) || { echo "$label: FAIL - DOSBox window not found" >&2; exit 1; }
DISPLAY="$WIN16_DISPLAY" xdotool windowactivate --sync "$win" 2> /dev/null || true
hold() { # $1 = key name
DISPLAY="$WIN16_DISPLAY" xdotool keydown --window "$win" "$1"
sleep 0.4
DISPLAY="$WIN16_DISPLAY" xdotool keyup --window "$win" "$1"
}
# The same timeline as verify-sdl3-audio.sh (seconds since the first sound).
sleep 5 # 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 5.6 # 24-30s resumed
hold p # -> paused again
sleep 0.6
hold n # 31-35s noise sweep alone
sleep 4.6
hold m # 36-44s the MOD
sleep 7.6
kill "$parecPid" 2> /dev/null
wait "$parecPid" 2> /dev/null
parecPid=""
# From the first sound on, as a WAV.
python3 - "$work/out.raw" "$start" "$work/out.wav" <<'PY'
import sys
import wave
data = open(sys.argv[1], "rb").read()[int(sys.argv[2]):]
with wave.open(sys.argv[3], "wb") as w:
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(44100)
w.writeframes(data)
PY
# Thresholds sit at about half the measured levels (music ~1520, paused 0,
# sfx ~620, resumed ~1540, noise ~770, mod ~3960; DOSBox's Sound Blaster
# plays about half as loud as Wine's output).
python3 "$grader" "$label" "$work/out.wav" \
"music:1:5:750:" \
"paused:8:13::100" \
"sfx:15:22:300:" \
"resumed:25:29:750:" \
"noise:31.5:34:380:" \
"mod:37:43:2000:"
status=$?
if [ -n "${WIN16_AUDIO_KEEP:-}" ]; then
cp "$work/out.wav" "$repo/build/win16/headless-audio.wav"
fi
exit $status