#!/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