#!/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-amiga-audio.sh - Amiga audio gate, graded on recorded sound. # # Paula cannot be tapped register-by-register here: fs-uae has no trace # facility and no debugger this harness can drive, so unlike the IIgs and # ST gates (which read the DOC and the YM2149 directly) this one records # what actually came out of the emulator and grades that. # # Capture path: fs-uae plays through OpenAL, OpenAL is pointed at # PulseAudio with ALSOFT_DRIVERS=pulse, and PULSE_SINK sends this ONE # process to a null sink created for the run -- the host's default sink is # never touched, so the gate is safe to run on a desktop that is playing # something else. parec then records that sink's monitor. # # 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 and "did the SFX play" becomes a # judgement call, while against silence it is unambiguous. Coexistence is # still covered, because the music has to come back afterwards on the same # hardware. Measured margins: music ~5700, paused ~240, sfx ~1170. # # 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. # # --fullscreen=1 and --initial_input_grab=1 are REQUIRED under a WM-less # Xvfb: SDL only accepts keyboard input once its window has focus, no # window manager is there to grant it, and xdotool's XSendEvent route is # ignored by SDL. A fullscreen SDL window takes focus itself, after which # plain XTEST typing lands. Same reasoning as verify-amiga-input.sh. # # Usage: scripts/verify-amiga-audio.sh [example] # example binary name in build/amiga/bin, default Audio # Env: AMIGA_AUDIO_KEEP set to keep the work dir and the WAV # Requires: fs-uae, Xvfb, xdotool, parec/pactl (PulseAudio or PipeWire), # the Kickstart (install.sh install emulator:amiga), # build/amiga/bin/ set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) source "$repo/scripts/lib/joeyEnv.sh" joeyNeed FSUAE fsuae source "$repo/scripts/lib/amigaGate.sh" amigaMachine prog=${1:-Audio} binDir=$repo/build/amiga/bin grader=$(joeyTool audioGate) || exit 1 pulseLib=$repo/scripts/pulseRecordLib.sh label=verify-amiga-audio [ -f "$pulseLib" ] || { echo "$label: missing $pulseLib" >&2; exit 2; } # shellcheck source=scripts/pulseRecordLib.sh . "$pulseLib" 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; } if [ -d "$binDir/$prog" ]; then progDir=$binDir/$prog elif [ -f "$binDir/$prog" ]; then progDir="" else echo "$label: $binDir/$prog not built (run 'make amiga')" >&2 exit 2 fi work=$(mktemp -d -t joeylib-amaudio.XXXXXX) sinkModule="" display=":$((90 + RANDOM % 9))" xvfbPid="" fsPid="" parecPid="" cleanup() { [ -n "$parecPid" ] && kill "$parecPid" 2>/dev/null joeyStop "$fsPid" [ -n "$xvfbPid" ] && kill "$xvfbPid" 2>/dev/null # Always unload the sink: leaving null sinks behind on the user's # audio server is the one side effect this gate could have. pulseUnloadSink "$sinkModule" if [ -n "${AMIGA_AUDIO_KEEP:-}" ]; then echo "$label: kept $work" >&2 else rm -rf "$work" fi return 0 } trap cleanup EXIT # Stage the Amiga volume the way joey-run does. mkdir -p "$work/hd/s" if [ -n "$progDir" ]; then cp -r "$progDir/." "$work/hd/" else find "$binDir" -maxdepth 1 -type f -executable -exec cp -t "$work/hd/" {} + [ -d "$binDir/DATA" ] && cp -r "$binDir/DATA" "$work/hd/" fi printf 'stack 32768\n:%s\n' "$prog" > "$work/hd/s/startup-sequence" sinkName="joeyAudioGate$$" sinkModule=$(pulseCreateSink "$sinkName") || sinkModule="" [ -n "$sinkModule" ] || { echo "$label: could not create a null sink to record from" >&2; exit 2; } Xvfb "$display" -screen 0 800x600x24 -nolisten tcp >/dev/null 2>&1 & xvfbPid=$! sleep 1 # --fast_memory=0 --slow_memory=0 keeps this a bare A500, the port's floor. DISPLAY="$display" PULSE_SINK="$sinkName" ALSOFT_DRIVERS=pulse SDL_AUDIODRIVER=pulse \ "$FSUAE" \ "${AMIGA_MACHINE_ARGS[@]}" \ --hard_drive_0="$work/hd" \ --hard_drive_0_label=JOEYLIB \ --fullscreen=1 \ --initial_input_grab=1 \ --fade_out_duration=0 \ --fast_memory=0 \ --slow_memory=0 \ > "$work/fsuae.log" 2>&1 & fsPid=$! # Let Kickstart boot and the startup-sequence reach the program. sleep 12 if ! kill -0 "$fsPid" 2>/dev/null; then echo "$label: FAIL - fs-uae exited before the program started" >&2 tail -5 "$work/fsuae.log" >&2 exit 1 fi parec --device="$sinkName.monitor" --format=s16le --rate=44100 --channels=2 \ --file-format=wav "$work/out.wav" > "$work/parec.log" 2>&1 & parecPid=$! win=$(DISPLAY="$display" xdotool search --name -- "FS-UAE" | head -1) if [ -z "$win" ]; then echo "$label: FAIL - no FS-UAE window to send keys to" >&2 exit 1 fi 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 kill "$parecPid" 2>/dev/null parecPid="" sleep 1 [ -s "$work/out.wav" ] || { echo "$label: FAIL - recorded no audio at all" >&2; exit 1; } # Thresholds sit far from the measured values (music ~5700, paused ~240, # sfx ~1170) so ordinary mixing jitter cannot flip the verdict. "$grader" phases "$label" "$work/out.wav" \ "music:1:5:2000:" \ "paused:8:13::800" \ "sfx:16:22:800:" \ "resumed:27:32:2000:" status=$? [ -n "${AMIGA_AUDIO_KEEP:-}" ] && cp "$work/out.wav" "$repo/build/amiga/headless-audio.wav" 2>/dev/null exit $status