From e9fee040d78ab28364f285f26705f8190b400e86 Mon Sep 17 00:00:00 2001 From: Srikanth Muppandam Date: Tue, 6 Oct 2026 23:41:28 +0530 Subject: [PATCH 1/2] audio: recover clients for selected backend Signed-off-by: Srikanth Muppandam --- Runner/config/pkg_command_map.conf | 19 +++++ .../Multimedia/Audio/AudioPlayback/Read_me.md | 18 +++-- .../Multimedia/Audio/AudioPlayback/run.sh | 59 ++++++++++++++- .../Multimedia/Audio/AudioRecord/Read_me.md | 15 ++-- .../Multimedia/Audio/AudioRecord/run.sh | 25 ++++++- .../README_Audio_Card_Registration.md | 7 +- .../Audio/Audio_Card_Registration/run.sh | 12 +++ Runner/utils/audio_common.sh | 73 +++++++++++++++++-- 8 files changed, 204 insertions(+), 24 deletions(-) diff --git a/Runner/config/pkg_command_map.conf b/Runner/config/pkg_command_map.conf index 945f2c00..246212e2 100755 --- a/Runner/config/pkg_command_map.conf +++ b/Runner/config/pkg_command_map.conf @@ -450,6 +450,17 @@ debian:package-set:audio-base=alsa-utils pipewire-bin wireplumber pulseaudio-uti ubuntu:package-set:audio-base=alsa-utils ubuntu:package-set:audio-desktop=pipewire pipewire-pulse wireplumber pipewire-bin pulseaudio-utils +# Complete playback and recording clients for a selected backend. +debian:package-set:audio-client-pipewire=pipewire-bin wireplumber +ubuntu:package-set:audio-client-pipewire=pipewire-bin wireplumber +centos:package-set:audio-client-pipewire=pipewire-utils wireplumber +debian:package-set:audio-client-alsa=alsa-utils +ubuntu:package-set:audio-client-alsa=alsa-utils +centos:package-set:audio-client-alsa=alsa-utils +debian:package-set:audio-client-pulseaudio=pulseaudio-utils +ubuntu:package-set:audio-client-pulseaudio=pulseaudio-utils +centos:package-set:audio-client-pulseaudio=pulseaudio-utils + # Debian Qualcomm AudioReach overlay stack. # # This package set is complete by itself because overlay mode checks only @@ -473,6 +484,14 @@ apt:parecord=pulseaudio-utils rpm:pw-play=pipewire-utils rpm:pw-record=pipewire-utils rpm:pw-cat=pipewire-utils +rpm:wpctl=wireplumber +rpm:aplay=alsa-utils +rpm:arecord=alsa-utils +rpm:amixer=alsa-utils +rpm:alsaucm=alsa-utils +rpm:pactl=pulseaudio-utils +rpm:paplay=pulseaudio-utils +rpm:parecord=pulseaudio-utils # --------------------------------------------------------------------------- # Qualcomm Sensors runtime and test packages. diff --git a/Runner/suites/Multimedia/Audio/AudioPlayback/Read_me.md b/Runner/suites/Multimedia/Audio/AudioPlayback/Read_me.md index 3e87395e..e4f45faf 100644 --- a/Runner/suites/Multimedia/Audio/AudioPlayback/Read_me.md +++ b/Runner/suites/Multimedia/Audio/AudioPlayback/Read_me.md @@ -67,6 +67,11 @@ the Yocto package flow. - Common tools: `pgrep`, `timeout`, `grep`, `tar`, and either `curl` or `wget` - Daemon: `pipewire` or `pulseaudio` must be running +On Debian, Ubuntu, and CentOS, the selected backend controls client package +recovery. PipeWire ensures `pw-play`, `pw-record`, and `wpctl`; ALSA ensures +`aplay` and `arecord`; PulseAudio ensures `paplay`, `parecord`, and `pactl`. +Yocto continues to use image-provided clients. + ### Ubuntu package preparation When run as root, the suite uses the shared package provider to install missing @@ -105,14 +110,13 @@ audioreach-dkms audioreach-pal audioreach-pipewire-plugin ``` If the DKMS package changes, the suite requests a reboot before validation. -Base mode continues to use the components provided by the CentOS image. +In base mode, CentOS recovers only the complete client set for the selected +backend from the configured distribution repositories. -Backend client checks are read-only after package preparation. PipeWire uses -`pw-play` when available and falls back to `pw-cat --playback`. A missing -playback client does not start an additional package-manager transaction. -Automatic backend selection falls back to an available ALSA path. An explicitly -requested backend skips with the missing image-package prerequisite before any -audio-clip download begins. +Backend client checks run after package recovery. PipeWire uses `pw-play` when +available and falls back to `pw-cat --playback`. Automatic backend selection +falls back to an available ALSA path. Package-recovery failure is reported as +FAIL before any audio-clip download begins. ```sh # Let the suite detect a server or desktop Ubuntu image diff --git a/Runner/suites/Multimedia/Audio/AudioPlayback/run.sh b/Runner/suites/Multimedia/Audio/AudioPlayback/run.sh index 16f14a4c..7427fc69 100755 --- a/Runner/suites/Multimedia/Audio/AudioPlayback/run.sh +++ b/Runner/suites/Multimedia/Audio/AudioPlayback/run.sh @@ -647,6 +647,28 @@ fi log_info "Args: backend=${AUDIO_BACKEND:-auto} sink=$SINK_CHOICE overlay=$AUDIO_OVERLAY_REQUESTED volume=$AUDIO_PLAYBACK_VOLUME loops=$LOOPS timeout=$TIMEOUT formats='$FORMATS' durations='$DURATIONS' strict=$STRICT dmesg=$DMESG_SCAN extract=$EXTRACT_AUDIO_ASSETS network_download=$ENABLE_NETWORK_DOWNLOAD clips_path=${AUDIO_CLIPS_BASE_DIR:-default} bootstrap=$AUDIO_BOOTSTRAP_MODE runtime_dir=${AUDIO_RUNTIME_DIR:-auto}" +if ! command -v audio_prepare_backend_client_packages >/dev/null 2>&1; then + log_fail "$TESTNAME FAIL - required helper is unavailable: audio_prepare_backend_client_packages" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 +fi + +if [ -n "$AUDIO_BACKEND_REQUESTED" ] && + ! audio_prepare_backend_client_packages "$AUDIO_BACKEND_REQUESTED"; then + log_fail "$TESTNAME FAIL - failed to prepare $AUDIO_BACKEND_REQUESTED playback and recording clients" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 +fi + +# Refresh client discovery after package preparation. +AUDIO_PIPEWIRE_PLAY_COMMAND="" +if command -v pw-play >/dev/null 2>&1; then + AUDIO_PIPEWIRE_PLAY_COMMAND="pw-play" +elif command -v pw-cat >/dev/null 2>&1 && + pw-cat --help 2>&1 | grep -q -- '--playback'; then + AUDIO_PIPEWIRE_PLAY_COMMAND="pw-cat" +fi + # Reject an unusable requested backend before network setup or clip download. # In automatic mode, continue when any supported image-provided playback # client is available because backend selection may fall back later. @@ -802,6 +824,22 @@ fi log_info "Using backend: $AUDIO_BACKEND" +if ! audio_prepare_backend_client_packages "$AUDIO_BACKEND"; then + log_fail "$TESTNAME FAIL - failed to prepare $AUDIO_BACKEND playback and recording clients" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 +fi + +if [ "$AUDIO_BACKEND" = "pipewire" ]; then + AUDIO_PIPEWIRE_PLAY_COMMAND="" + if command -v pw-play >/dev/null 2>&1; then + AUDIO_PIPEWIRE_PLAY_COMMAND="pw-play" + elif command -v pw-cat >/dev/null 2>&1 && + pw-cat --help 2>&1 | grep -q -- '--playback'; then + AUDIO_PIPEWIRE_PLAY_COMMAND="pw-cat" + fi +fi + backend_ok=0 if [ "$AUDIO_BACKEND" = "alsa" ]; then if audio_playback_probe_alsa_with_recovery; then @@ -873,9 +911,24 @@ if [ "$backend_ok" -ne 1 ]; then fi # Dependencies per backend -# Package preparation has already completed above. Keep these late backend -# checks read-only so an automatically detected backend cannot trigger an -# unrelated package transaction when its playback client is absent. +# If backend readiness selected a fallback, prepare that backend's complete +# playback and recording client set before validating its commands. +if ! audio_prepare_backend_client_packages "$AUDIO_BACKEND"; then + log_fail "$TESTNAME FAIL - failed to prepare fallback $AUDIO_BACKEND playback and recording clients" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 +fi + +if [ "$AUDIO_BACKEND" = "pipewire" ]; then + AUDIO_PIPEWIRE_PLAY_COMMAND="" + if command -v pw-play >/dev/null 2>&1; then + AUDIO_PIPEWIRE_PLAY_COMMAND="pw-play" + elif command -v pw-cat >/dev/null 2>&1 && + pw-cat --help 2>&1 | grep -q -- '--playback'; then + AUDIO_PIPEWIRE_PLAY_COMMAND="pw-cat" + fi +fi + case "$AUDIO_BACKEND" in pipewire) if [ -z "$AUDIO_PIPEWIRE_PLAY_COMMAND" ]; then diff --git a/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md b/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md index 30f8d373..50ff24fa 100644 --- a/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md +++ b/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md @@ -58,9 +58,14 @@ Ensure the following components are present in the target Yocto build: - PipeWire: `pw-record`, `wpctl` - PulseAudio: `parecord`, `pactl` - ALSA: `arecord` -- Common tools: `pgrep`, `timeout`, `grep`, `sed` +- Common tools: `pgrep`, `timeout`, `grep`, `sed` - Daemon: `pipewire` or `pulseaudio` must be running +On Debian, Ubuntu, and CentOS, the selected backend controls client package +recovery. PipeWire ensures `pw-play`, `pw-record`, and `wpctl`; ALSA ensures +`aplay` and `arecord`; PulseAudio ensures `paplay`, `parecord`, and `pactl`. +Yocto continues to use image-provided clients. + ## Backend and Route Selection When no backend is requested, the suite uses automatic selection. A real PipeWire audio source uses `pw-record`, and a real PulseAudio source uses `parecord`. Camera, dummy, null, monitor, and loopback PipeWire nodes are not accepted as microphone sources. @@ -87,10 +92,10 @@ installs `audioreach-dkms`, `audioreach-pal`, and `audioreach-pipewire-plugin`. If the DKMS package changes, reboot before rerunning validation. Yocto remains image-provided and non-installing. -Backend client checks are read-only after package preparation. A missing -`pw-record`, `wpctl`, `parecord`, `pactl`, or `arecord` does not start an -additional package-manager transaction and instead produces a clean SKIP for -the unavailable recording path. +Backend client checks run after recovery of the selected backend's complete +playback and recording client package set. Package recovery failure is reported +as FAIL. A client that remains unavailable after successful recovery produces +a clean SKIP for the unavailable recording path. For overlay builds using audioreach kernel modules, the test automatically: - Detects the overlay build configuration diff --git a/Runner/suites/Multimedia/Audio/AudioRecord/run.sh b/Runner/suites/Multimedia/Audio/AudioRecord/run.sh index 91c1c0ad..0d52afae 100755 --- a/Runner/suites/Multimedia/Audio/AudioRecord/run.sh +++ b/Runner/suites/Multimedia/Audio/AudioRecord/run.sh @@ -591,6 +591,18 @@ fi log_info "Using backend: $AUDIO_BACKEND" +if ! command -v audio_prepare_backend_client_packages >/dev/null 2>&1; then + log_fail "$TESTNAME FAIL - required helper is unavailable: audio_prepare_backend_client_packages" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 +fi + +if ! audio_prepare_backend_client_packages "$AUDIO_BACKEND"; then + log_fail "$TESTNAME FAIL - failed to prepare $AUDIO_BACKEND playback and recording clients" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 +fi + backend_ok=0 if [ "$AUDIO_BACKEND" = "alsa" ]; then if [ "$ALSA_CAPTURE_PROBED" -eq 1 ]; then @@ -655,8 +667,8 @@ if [ "$backend_ok" -ne 1 ]; then exit 0 fi -# Package preparation has already completed above. Keep backend-client checks -# read-only so automatic backend discovery cannot trigger package installation. +# Package recovery above installs the complete client set for the selected +# backend. These checks verify that the expected commands are now available. case "$AUDIO_BACKEND" in pipewire) if ! command -v wpctl >/dev/null 2>&1 || @@ -889,7 +901,14 @@ else # ALSA log_info "Routing to source: name='$SRC_LABEL' choice=$SRC_CHOICE" fi -# If fallback changed backend, ensure deps are present (non-fatal → SKIP) +# If route discovery changed the backend, prepare that backend's complete +# playback and recording client set before validating its commands. +if ! audio_prepare_backend_client_packages "$AUDIO_BACKEND"; then + log_fail "$TESTNAME FAIL - failed to prepare fallback $AUDIO_BACKEND playback and recording clients" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 +fi + case "$AUDIO_BACKEND" in pipewire) if ! command -v wpctl >/dev/null 2>&1 || diff --git a/Runner/suites/Multimedia/Audio/Audio_Card_Registration/README_Audio_Card_Registration.md b/Runner/suites/Multimedia/Audio/Audio_Card_Registration/README_Audio_Card_Registration.md index 452e4ab3..b5e3e7e6 100644 --- a/Runner/suites/Multimedia/Audio/Audio_Card_Registration/README_Audio_Card_Registration.md +++ b/Runner/suites/Multimedia/Audio/Audio_Card_Registration/README_Audio_Card_Registration.md @@ -25,6 +25,10 @@ Common helper dependency: Runner/utils/audio_common.sh ``` +On Debian, Ubuntu, and CentOS, the suite ensures the complete ALSA client +package set before card validation. This provides both `aplay` and `arecord` +from `alsa-utils`. Yocto continues to use image-provided clients. + ## What This Test Validates The test checks: @@ -114,7 +118,8 @@ The test reports `FAIL` when: On CentOS Stream 10, `--overlay` prepares EPEL and the Qualcomm aarch64 and noarch RPM repositories, refreshes DNF metadata, and installs `audioreach-dkms`, `audioreach-pal`, and `audioreach-pipewire-plugin`. The -default mode continues to use image-provided audio components. +default mode ensures the distribution ALSA client package set before +validation. ```sh ./run.sh [options] diff --git a/Runner/suites/Multimedia/Audio/Audio_Card_Registration/run.sh b/Runner/suites/Multimedia/Audio/Audio_Card_Registration/run.sh index 032dd3b4..7e68955c 100755 --- a/Runner/suites/Multimedia/Audio/Audio_Card_Registration/run.sh +++ b/Runner/suites/Multimedia/Audio/Audio_Card_Registration/run.sh @@ -180,6 +180,18 @@ case "$audio_prepare_rc" in ;; esac +if ! command -v audio_prepare_backend_client_packages >/dev/null 2>&1; then + log_fail "$TESTNAME FAIL: required helper is unavailable: audio_prepare_backend_client_packages" + echo "$TESTNAME FAIL" > "$RES_FILE" + exit 1 +fi + +if ! audio_prepare_backend_client_packages alsa; then + log_fail "$TESTNAME FAIL: failed to prepare ALSA playback and recording clients" + echo "$TESTNAME FAIL" > "$RES_FILE" + exit 1 +fi + # Prepare only the Debian Audio account and group membership. This ALSA-only # testcase does not require a systemd user manager and does not re-execute the # complete runner as debian. diff --git a/Runner/utils/audio_common.sh b/Runner/utils/audio_common.sh index 894c2d3b..defe114b 100755 --- a/Runner/utils/audio_common.sh +++ b/Runner/utils/audio_common.sh @@ -19,14 +19,14 @@ have_cmd() { # ---------- Backend detection & daemon checks ---------- detect_audio_backend() { - if pgrep -x pipewire >/dev/null 2>&1 && command -v wpctl >/dev/null 2>&1; then + if pgrep -x pipewire >/dev/null 2>&1; then echo pipewire; return 0 fi - if pgrep -x pulseaudio >/dev/null 2>&1 && command -v pactl >/dev/null 2>&1; then + if pgrep -x pulseaudio >/dev/null 2>&1; then echo pulseaudio; return 0 fi # Accept pipewire-pulse shim as PulseAudio - if pgrep -x pipewire-pulse >/dev/null 2>&1 && command -v pactl >/dev/null 2>&1; then + if pgrep -x pipewire-pulse >/dev/null 2>&1; then echo pulseaudio; return 0 fi echo "" @@ -5011,7 +5011,7 @@ audio_prepare_audioreach_udev_rule() { # Debian overlay - ensure audio-base and Debian AudioReach package sets # Ubuntu server - ensure ALSA utilities # Ubuntu desktop - ensure ALSA, PipeWire, PipeWire-Pulse, and WirePlumber -# CentOS base - use image-provided components +# CentOS base - defer client recovery until backend selection # CentOS overlay - ensure the documented Qualcomm AudioReach RPM set # other distros - no-op until their package mappings are verified # @@ -5147,7 +5147,7 @@ audio_prepare_test_packages() { if [ "$atp_os_id" = "centos" ] && [ "$atp_overlay_requested" -eq 0 ]; then - log_info "CentOS base Audio mode uses image-provided components" + log_info "CentOS base Audio mode defers client package recovery until backend selection" return 0 fi @@ -5305,6 +5305,69 @@ audio_prepare_test_packages() { return 0 } +# Ensure complete playback and recording clients for one selected backend. +# Debian, Ubuntu, and CentOS use exact package-set mappings. Yocto and other +# image-managed environments retain their preinstalled userspace. +audio_prepare_backend_client_packages() { + apbcp_backend="$1" + + case "$apbcp_backend" in + pipewire|pulseaudio|alsa) + apbcp_package_set="audio-client-$apbcp_backend" + ;; + *) + log_fail "Unsupported Audio backend for package preparation: $apbcp_backend" + return 1 + ;; + esac + + if command -v pkg_detect_os_id >/dev/null 2>&1; then + apbcp_os_id="$(pkg_detect_os_id 2>/dev/null || echo unknown)" + else + apbcp_os_id="$( + sed -n 's/^ID=//p' /etc/os-release 2>/dev/null | + sed -n '1p' | + sed 's/^"//;s/"$//' | + tr '[:upper:]' '[:lower:]' + )" + fi + + [ -n "$apbcp_os_id" ] || apbcp_os_id="unknown" + + case "$apbcp_os_id" in + qcom-distro|poky|openembedded|oe) + log_info "Native image uses preinstalled $apbcp_backend Audio clients" + return 0 + ;; + debian|ubuntu|centos) + ;; + *) + log_info "Audio backend client package recovery is not enabled, os=$apbcp_os_id backend=$apbcp_backend" + return 0 + ;; + esac + + if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then + log_fail "Audio backend client package preparation must run as root, os=$apbcp_os_id backend=$apbcp_backend" + return 1 + fi + + if ! command -v pkg_ensure_required_package_set_present >/dev/null 2>&1; then + log_fail "Required package-set helper is unavailable" + return 1 + fi + + log_info "Ensuring complete Audio backend client package set, os=$apbcp_os_id backend=$apbcp_backend set=$apbcp_package_set" + + if ! pkg_ensure_required_package_set_present "$apbcp_package_set"; then + log_fail "Failed to ensure Audio backend client package set, os=$apbcp_os_id backend=$apbcp_backend set=$apbcp_package_set" + return 1 + fi + + log_pass "Audio backend client package set is ready, os=$apbcp_os_id backend=$apbcp_backend" + return 0 +} + # Prepare the Debian Audio test user and optional systemd user manager. # # Unlike audio_prepare_debian_audio_test_user(), this function does not: From cb3b92dac4ac3775f21e9d8bfc6b0e9a32584252 Mon Sep 17 00:00:00 2001 From: Srikanth Muppandam Date: Wed, 7 Oct 2026 10:00:34 +0530 Subject: [PATCH 2/2] audio: add loopback signal validation Signed-off-by: Srikanth Muppandam --- .../Audio/AudioLoopback/AudioLoopback.yaml | 21 + .../Multimedia/Audio/AudioLoopback/README.md | 68 ++ .../Multimedia/Audio/AudioLoopback/run.sh | 634 ++++++++++++ .../Audio/AudioPlayback/AudioPlayback.yaml | 2 +- .../Multimedia/Audio/AudioPlayback/Read_me.md | 32 +- .../Multimedia/Audio/AudioPlayback/run.sh | 187 +++- .../Multimedia/Audio/AudioRecord/Read_me.md | 21 +- .../Multimedia/Audio/AudioRecord/run.sh | 91 +- .../README_Audio_Card_Registration.md | 4 + .../Audio/Audio_Card_Registration/run.sh | 18 +- Runner/utils/audio_common.sh | 975 ++++++++++++++---- Runner/utils/audio_wav_validate.py | 150 ++- Runner/utils/functestlib.sh | 8 +- 13 files changed, 1903 insertions(+), 308 deletions(-) create mode 100644 Runner/suites/Multimedia/Audio/AudioLoopback/AudioLoopback.yaml create mode 100644 Runner/suites/Multimedia/Audio/AudioLoopback/README.md create mode 100755 Runner/suites/Multimedia/Audio/AudioLoopback/run.sh diff --git a/Runner/suites/Multimedia/Audio/AudioLoopback/AudioLoopback.yaml b/Runner/suites/Multimedia/Audio/AudioLoopback/AudioLoopback.yaml new file mode 100644 index 00000000..ad15b0c8 --- /dev/null +++ b/Runner/suites/Multimedia/Audio/AudioLoopback/AudioLoopback.yaml @@ -0,0 +1,21 @@ +metadata: + name: audio-loopback + format: "Lava-Test Test Definition 1.0" + description: "ALSA playback-to-capture loopback validation" + os: + - linux + scope: + - functional + +params: + DURATION: 5 + PLAYBACK_DEVICE: "" + CAPTURE_DEVICE: "" + DMESG_SCAN: 1 + +run: + steps: + - REPO_PATH=$PWD + - cd Runner/suites/Multimedia/Audio/AudioLoopback/ + - ./run.sh --duration "${DURATION}" --playback-device "${PLAYBACK_DEVICE}" --capture-device "${CAPTURE_DEVICE}" --dmesg-scan "${DMESG_SCAN}" || true + - $REPO_PATH/Runner/utils/send-to-lava.sh AudioLoopback.res diff --git a/Runner/suites/Multimedia/Audio/AudioLoopback/README.md b/Runner/suites/Multimedia/Audio/AudioLoopback/README.md new file mode 100644 index 00000000..cd77ae19 --- /dev/null +++ b/Runner/suites/Multimedia/Audio/AudioLoopback/README.md @@ -0,0 +1,68 @@ +# AudioLoopback + +`AudioLoopback` validates simultaneous playback and capture without downloading +media. It generates a deterministic 48 kHz, unsigned 8-bit stereo WAV, starts +capture, plays the reference, and validates the resulting WAV. + +Automatic selection uses an active PipeWire or PulseAudio session and its +physical speaker and microphone routes. PipeWire requires `wpctl`, `pw-record`, +and `pw-play` or `pw-cat`. PulseAudio requires `pactl`, `parecord`, and `paplay`. +If no managed backend is usable, the suite falls back to image-provided `aplay` +and `arecord`. The suite never installs runtime packages. + +On Debian and CentOS, a root invocation prepares the regular desktop audio user +and re-launches the complete test as that user. Automatic selection also +prepares the user's systemd session so PipeWire and PulseAudio control and data +paths match the working desktop route. Yocto and Ubuntu keep their existing +execution behavior. + +## Fixture requirement + +The selected output must reach the selected input through an acoustic or +electrical fixture. Automatic selection prefers the active managed backend and +its physical speaker and microphone endpoints. Supplying either device option +selects direct ALSA mode, where the unspecified side is discovered. Explicit +ALSA devices are recommended for fixtures that require fixed PCM endpoints: + +```sh +./run.sh \ + --playback-device plughw:0,0 \ + --capture-device plughw:0,1 \ + --duration 5 +``` + +When no backend or device inventory exists, the suite reports SKIP with the +missing prerequisite. When a managed backend is active but its physical routes +are unusable and direct ALSA fallback also fails, the suite reports FAIL so the +image or routing regression remains visible. An all-zero capture reports FAIL. +The default policy does not claim waveform correlation when the input contains +some other non-zero signal, so retained metrics and the fixture setup remain +important evidence. + +## Result policy + +CI-blocking validation is intentionally limited to execution failure and basic +WAV integrity: corrupt or empty files, header-only payloads, all-zero audio, and +materially short captures fail. RMS, peak, clipping, digital-silence runs, DC +offset, large sample transitions, and silent-channel counts are emitted in +`AUDIO_VALIDATION` records as diagnostic metrics. They do not fail the default +policy. + +## Options + +- `--duration SECONDS` selects 2 to 30 seconds. +- `--playback-device DEVICE` overrides playback discovery. +- `--capture-device DEVICE` overrides capture discovery and probes a supported + format on that exact device. +- `--dmesg-scan 0|1` controls retained kernel-log evidence. +- `--no-dmesg` disables the kernel-log scan. + +Artifacts are retained below `results/AudioLoopback/run--/`. + +## Yocto CI + +`AudioLoopback.yaml` runs the default five-second test using automatic backend +and endpoint discovery. A CI device still needs an acoustic or electrical path +from the selected playback endpoint to the selected capture endpoint. Set +`PLAYBACK_DEVICE` and `CAPTURE_DEVICE` in the job parameters when direct ALSA +selection is required by the fixture. diff --git a/Runner/suites/Multimedia/Audio/AudioLoopback/run.sh b/Runner/suites/Multimedia/Audio/AudioLoopback/run.sh new file mode 100755 index 00000000..290bdd50 --- /dev/null +++ b/Runner/suites/Multimedia/Audio/AudioLoopback/run.sh @@ -0,0 +1,634 @@ +#!/bin/sh +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# SPDX-License-Identifier: BSD-3-Clause + +# ---------- Repo env + helpers ---------- +SCRIPT_DIR="$( + cd "$(dirname "$0")" || exit 1 + pwd +)" +INIT_ENV="" +SEARCH="$SCRIPT_DIR" + +while [ "$SEARCH" != "/" ]; do + if [ -f "$SEARCH/init_env" ]; then + INIT_ENV="$SEARCH/init_env" + break + fi + SEARCH=$(dirname "$SEARCH") +done + +if [ -z "$INIT_ENV" ]; then + echo "[ERROR] Could not find init_env (starting at $SCRIPT_DIR)" >&2 + exit 1 +fi + +# Only source once (idempotent) +# NOTE: We intentionally **do not export** any new vars. They stay local to this shell. +if [ -z "${__INIT_ENV_LOADED:-}" ]; then + # shellcheck disable=SC1090 + . "$INIT_ENV" + __INIT_ENV_LOADED=1 +fi + +# shellcheck disable=SC1090 +. "$INIT_ENV" +# shellcheck disable=SC1091 +. "$TOOLS/functestlib.sh" +# shellcheck disable=SC1091 +. "$TOOLS/audio_common.sh" + +TESTNAME="AudioLoopback" +RES_FILE="$SCRIPT_DIR/${TESTNAME}.res" + +DURATION=5 +PLAYBACK_DEVICE="" +CAPTURE_DEVICE="" +DMESG_SCAN="${DMESG_SCAN:-1}" +ARG_ERROR="" +LOGDIR="${LOGDIR:-}" +RECORDER_PID="" +AUDIO_BACKEND="" +PLAYBACK_TARGET="" +CAPTURE_TARGET="" +PLAYBACK_CLIENT="" +CAPTURE_CLIENT="" +MANAGED_BACKEND_FAILURE="" + +# Print the audio loopback CLI and its environment-variable equivalents. +usage() { + cat </dev/null 2>&1 || true + wait "$RECORDER_PID" >/dev/null 2>&1 || true + RECORDER_PID="" + fi +} + +parse_args "$@" +parse_rc=$? + +test_result_init "$TESTNAME" "$RES_FILE" || exit 1 + +if [ "$parse_rc" -ne 0 ]; then + test_result_record "FAIL" \ + "$TESTNAME FAIL - malformed command line, $ARG_ERROR" + test_result_finish +fi + +case "$DURATION" in + ''|*[!0-9]*) + test_result_record "FAIL" \ + "$TESTNAME FAIL - invalid duration '$DURATION', expected an integer from 2 to 30" + test_result_finish + ;; +esac + +if [ "$DURATION" -lt 2 ] || [ "$DURATION" -gt 30 ]; then + test_result_record "FAIL" \ + "$TESTNAME FAIL - duration '$DURATION' is outside the supported 2 to 30 second range" + test_result_finish +fi + +case "$DMESG_SCAN" in + 0|1) + ;; + *) + test_result_record "FAIL" \ + "$TESTNAME FAIL - invalid dmesg selection '$DMESG_SCAN', expected 0 or 1" + test_result_finish + ;; +esac + +if [ -z "$LOGDIR" ]; then + RUN_STAMP="$(date -u '+%Y%m%d-%H%M%S' 2>/dev/null || printf '%s' unknown)" + LOGDIR="$SCRIPT_DIR/results/$TESTNAME/run-${RUN_STAMP}-$$" +fi + +if ! mkdir -p "$LOGDIR"; then + test_result_record "FAIL" \ + "$TESTNAME FAIL - cannot create retained evidence directory $LOGDIR" + test_result_finish +fi + +if ! command -v audio_prepare_desktop_audio_test_user >/dev/null 2>&1; then + test_result_record "FAIL" \ + "$TESTNAME FAIL - required helper is unavailable: audio_prepare_desktop_audio_test_user" + test_result_finish +fi + +audio_loopback_user_manager_required=0 +if [ -z "$PLAYBACK_DEVICE" ] && + [ -z "$CAPTURE_DEVICE" ] && + command -v systemctl >/dev/null 2>&1 && + [ -d /run/systemd/system ] && + { command -v pw-play >/dev/null 2>&1 || + command -v pw-cat >/dev/null 2>&1 || + command -v pw-record >/dev/null 2>&1 || + command -v paplay >/dev/null 2>&1 || + command -v parecord >/dev/null 2>&1; }; then + audio_loopback_user_manager_required=1 +fi + +if ! audio_prepare_desktop_audio_test_user \ + "$SCRIPT_DIR/run.sh" \ + "$RES_FILE" \ + "$LOGDIR" \ + "$audio_loopback_user_manager_required" \ + "$@"; then + test_result_record "FAIL" \ + "$TESTNAME FAIL - desktop Audio user launch failed" + test_result_finish +fi + +if command -v pkg_detect_os_id >/dev/null 2>&1; then + audio_loopback_os_id="$(pkg_detect_os_id 2>/dev/null || echo unknown)" +else + audio_loopback_os_id="unknown" +fi + +AUDIO_USE_DESKTOP_SESSION=0 +if [ "$audio_loopback_os_id" = "ubuntu" ] && + [ "$(id -u 2>/dev/null || echo 1)" -eq 0 ]; then + AUDIO_USE_DESKTOP_SESSION=1 +fi +export AUDIO_USE_DESKTOP_SESSION + +AUDIO_ALSA_PLAYBACK_PROBE_LOG="$LOGDIR/alsa_playback_probe.log" +export AUDIO_ALSA_PLAYBACK_PROBE_LOG +: >"$AUDIO_ALSA_PLAYBACK_PROBE_LOG" + +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +trap audio_loopback_cleanup EXIT + +missing_commands="" +if ! command -v awk >/dev/null 2>&1; then + missing_commands="$missing_commands awk" +fi + +if ! command -v python3 >/dev/null 2>&1; then + for required_command in dd od; do + if ! command -v "$required_command" >/dev/null 2>&1; then + missing_commands="$missing_commands $required_command" + fi + done +fi + +if [ -n "$missing_commands" ]; then + test_result_record "SKIP" \ + "$TESTNAME SKIP - required image-provided audio utilities are unavailable, missing=$missing_commands" + test_result_finish +fi + +log_info "Starting $TESTNAME, duration=${DURATION}s playback_device=${PLAYBACK_DEVICE:-auto} capture_device=${CAPTURE_DEVICE:-auto}" +log_info "Retaining loopback artifacts in $LOGDIR" + +if [ -n "$PLAYBACK_DEVICE" ] || [ -n "$CAPTURE_DEVICE" ]; then + AUDIO_BACKEND="alsa" +else + AUDIO_BACKEND="$( + audio_run_helper_as_test_user \ + --require-session \ + detect_audio_backend 2>/dev/null || true + )" +fi + +case "$AUDIO_BACKEND" in + pipewire) + if command -v pw-play >/dev/null 2>&1; then + PLAYBACK_CLIENT="pw-play" + elif command -v pw-cat >/dev/null 2>&1 && + pw-cat --help 2>&1 | grep -q -- '--playback'; then + PLAYBACK_CLIENT="pw-cat" + fi + + if command -v pw-record >/dev/null 2>&1; then + CAPTURE_CLIENT="pw-record" + fi + + if [ -z "$PLAYBACK_CLIENT" ] || + [ -z "$CAPTURE_CLIENT" ] || + ! command -v wpctl >/dev/null 2>&1; then + MANAGED_BACKEND_FAILURE="PipeWire is active but its playback, recording, or control client is unavailable" + AUDIO_BACKEND="" + else + PLAYBACK_TARGET="$( + audio_run_helper_as_test_user \ + --require-session \ + pw_default_speakers 2>/dev/null || true + )" + CAPTURE_TARGET="$( + audio_run_helper_as_test_user \ + --require-session \ + pw_default_mic 2>/dev/null || true + )" + + if [ -z "$PLAYBACK_TARGET" ] || [ -z "$CAPTURE_TARGET" ]; then + MANAGED_BACKEND_FAILURE="PipeWire is active but a physical speaker sink or microphone source was not discovered" + AUDIO_BACKEND="" + fi + fi + ;; + pulseaudio) + if command -v paplay >/dev/null 2>&1 && + command -v parecord >/dev/null 2>&1 && + command -v pactl >/dev/null 2>&1; then + PLAYBACK_CLIENT="paplay" + CAPTURE_CLIENT="parecord" + PLAYBACK_TARGET="$( + audio_run_helper_as_test_user \ + --require-session \ + pa_default_speakers 2>/dev/null || true + )" + CAPTURE_TARGET="$( + audio_run_helper_as_test_user \ + --require-session \ + pa_default_mic 2>/dev/null || true + )" + + if [ -z "$PLAYBACK_TARGET" ] || [ -z "$CAPTURE_TARGET" ]; then + MANAGED_BACKEND_FAILURE="PulseAudio is active but a speaker sink or microphone source was not discovered" + AUDIO_BACKEND="" + fi + else + MANAGED_BACKEND_FAILURE="PulseAudio is active but paplay, parecord, or pactl is unavailable" + AUDIO_BACKEND="" + fi + ;; + alsa|"") + ;; + *) + MANAGED_BACKEND_FAILURE="unsupported detected audio backend '$AUDIO_BACKEND'" + AUDIO_BACKEND="" + ;; +esac + +if [ -z "$AUDIO_BACKEND" ]; then + if [ -n "$MANAGED_BACKEND_FAILURE" ]; then + log_warn "$TESTNAME: $MANAGED_BACKEND_FAILURE, probing direct ALSA loopback paths" + fi + + if command -v aplay >/dev/null 2>&1 && + command -v arecord >/dev/null 2>&1 && + audio_playback_probe_alsa_with_recovery; then + AUDIO_BACKEND="alsa" + fi +fi + +if [ -z "$AUDIO_BACKEND" ]; then + if [ -n "$MANAGED_BACKEND_FAILURE" ]; then + log_file_with_label \ + "ALSA-PROBE" "$AUDIO_ALSA_PLAYBACK_PROBE_LOG" 40 + test_result_record "FAIL" \ + "$TESTNAME FAIL - $MANAGED_BACKEND_FAILURE and no direct ALSA loopback path is usable, verify sound-card registration, topology, UCM, mixer routing, and image audio clients" + else + test_result_record "SKIP" \ + "$TESTNAME SKIP - no active managed audio backend or usable direct ALSA loopback path was discovered" + fi + test_result_finish +fi + +if [ "$AUDIO_BACKEND" = "alsa" ]; then + if ! command -v aplay >/dev/null 2>&1 || + ! command -v arecord >/dev/null 2>&1; then + test_result_record "SKIP" \ + "$TESTNAME SKIP - direct ALSA loopback requires image-provided aplay and arecord utilities" + test_result_finish + fi + + if [ -z "$PLAYBACK_DEVICE" ]; then + if [ -z "${AUDIO_ALSA_PLAYBACK_DEVICE:-}" ] && + ! audio_playback_probe_alsa_with_recovery; then + if [ -n "$MANAGED_BACKEND_FAILURE" ]; then + log_file_with_label \ + "ALSA-PROBE" "$AUDIO_ALSA_PLAYBACK_PROBE_LOG" 40 + test_result_record "FAIL" \ + "$TESTNAME FAIL - $MANAGED_BACKEND_FAILURE and no direct ALSA playback device could be opened, verify sound-card registration, topology, UCM, mixer routing, and image audio clients" + else + test_result_record "SKIP" \ + "$TESTNAME SKIP - no managed audio backend or direct ALSA playback device is available" + fi + test_result_finish + fi + PLAYBACK_DEVICE="$AUDIO_ALSA_PLAYBACK_DEVICE" + fi + + requested_capture_device="$CAPTURE_DEVICE" + if ! audio_record_probe_alsa_capture_profile "$requested_capture_device"; then + if [ -n "$MANAGED_BACKEND_FAILURE" ]; then + test_result_record "FAIL" \ + "$TESTNAME FAIL - $MANAGED_BACKEND_FAILURE and no direct ALSA capture profile could be opened, reason=${AUDIO_ALSA_CAPTURE_REASON:-unknown}, verify the microphone route and image audio clients" + else + test_result_record "SKIP" \ + "$TESTNAME SKIP - no ALSA capture profile could be opened, device=${requested_capture_device:-auto} reason=${AUDIO_ALSA_CAPTURE_REASON:-unknown}, connect a loopback fixture or pass --capture-device" + fi + test_result_finish + fi + + CAPTURE_DEVICE="$AUDIO_ALSA_CAPTURE_DEVICE" + PLAYBACK_TARGET="$PLAYBACK_DEVICE" + CAPTURE_TARGET="$CAPTURE_DEVICE" + PLAYBACK_CLIENT="aplay" + CAPTURE_CLIENT="arecord" + capture_format="$AUDIO_ALSA_CAPTURE_FORMAT" + capture_rate="$AUDIO_ALSA_CAPTURE_RATE" + capture_channels="$AUDIO_ALSA_CAPTURE_CHANNELS" +else + capture_format="S16_LE" + capture_rate=48000 + capture_channels=2 + + case "$AUDIO_BACKEND" in + pipewire) + audio_run_helper_as_test_user \ + --require-session \ + pw_set_default_sink "$PLAYBACK_TARGET" >/dev/null 2>&1 || + log_warn "Could not set PipeWire default sink id=$PLAYBACK_TARGET" + audio_run_helper_as_test_user \ + --require-session \ + pw_set_default_source "$CAPTURE_TARGET" >/dev/null 2>&1 || + log_warn "Could not set PipeWire default source id=$CAPTURE_TARGET" + audio_run_with_timeout_as_test_user \ + --require-session \ + 3s \ + wpctl set-mute "$PLAYBACK_TARGET" 0 >/dev/null 2>&1 || + log_warn "Could not unmute PipeWire sink id=$PLAYBACK_TARGET" + ;; + pulseaudio) + audio_run_helper_as_test_user \ + --require-session \ + pa_set_default_sink "$PLAYBACK_TARGET" >/dev/null 2>&1 || true + audio_run_helper_as_test_user \ + --require-session \ + pa_set_default_source "$CAPTURE_TARGET" >/dev/null 2>&1 || true + ;; + esac +fi + +log_info "Using audio backend: $AUDIO_BACKEND" +log_info "Loopback route: playback=$PLAYBACK_TARGET capture=$CAPTURE_TARGET" + +reference_wav="$LOGDIR/reference_48KHz_${DURATION}s_8b_2ch.wav" +reference_log="$LOGDIR/reference_validation.log" +capture_wav="$LOGDIR/loopback_capture.wav" +capture_log="$LOGDIR/capture.log" +playback_log="$LOGDIR/playback.log" + +if ! audio_generate_u8_stereo_wav \ + "$reference_wav" \ + "$DURATION" \ + 48000 \ + 1000 2>"$LOGDIR/reference_generation.log"; then + test_result_record "FAIL" \ + "$TESTNAME FAIL - deterministic playback reference generation failed, artifact=$LOGDIR/reference_generation.log" + test_result_finish +fi + +if ! audio_validate_wav_file \ + "$reference_wav" \ + mic \ + 48000 \ + 2 \ + 8 \ + "$DURATION" \ + "$reference_log" \ + playback; then + test_result_record "FAIL" \ + "$TESTNAME FAIL - generated reference failed basic-integrity validation, validation='${AUDIO_VALIDATION_SUMMARY:-unavailable}', artifact=$reference_log" + test_result_finish +fi + +test_result_record "PASS" \ + "Generated and validated a deterministic local playback reference, validation='$AUDIO_VALIDATION_SUMMARY'" + +capture_duration=$((DURATION + 2)) +capture_timeout=$((capture_duration + 10)) +playback_timeout=$((DURATION + 10)) +rm -f "$capture_wav" + +if [ "$AUDIO_USE_DESKTOP_SESSION" -eq 1 ] && + [ "$(id -u 2>/dev/null || echo 1)" -eq 0 ]; then + audio_loopback_command_user="$( + audio_find_desktop_audio_user 2>/dev/null || true + )" + + if [ -z "$audio_loopback_command_user" ] || + ! : >"$capture_wav" || + ! chown "$audio_loopback_command_user" "$capture_wav" || + ! chmod 0644 "$capture_wav"; then + test_result_record "FAIL" \ + "$TESTNAME FAIL - cannot prepare loopback capture artifact for the active Ubuntu audio user, artifact=$capture_wav" + test_result_finish + fi +fi + +log_info "Starting capture before playback, backend=$AUDIO_BACKEND target=$CAPTURE_TARGET format=$capture_format rate=$capture_rate channels=$capture_channels duration=${capture_duration}s" +case "$AUDIO_BACKEND" in + pipewire) + capture_watchdog="${capture_duration}s" + audio_run_with_timeout_as_test_user \ + --require-session \ + "$capture_watchdog" \ + env LC_ALL=C \ + pw-record \ + -v \ + --rate="$capture_rate" \ + --channels="$capture_channels" \ + --target "$CAPTURE_TARGET" \ + "$capture_wav" >"$capture_log" 2>&1 & + ;; + pulseaudio) + capture_watchdog="${capture_duration}s" + audio_run_with_timeout_as_test_user \ + --require-session \ + "$capture_watchdog" \ + env LC_ALL=C \ + parecord \ + --rate="$capture_rate" \ + --channels="$capture_channels" \ + --file-format=wav \ + --device="$CAPTURE_TARGET" \ + "$capture_wav" >"$capture_log" 2>&1 & + ;; + alsa) + capture_watchdog="${capture_timeout}s" + audio_run_with_timeout_as_test_user "$capture_watchdog" \ + env LC_ALL=C \ + arecord \ + -q \ + -D "$CAPTURE_TARGET" \ + -f "$capture_format" \ + -r "$capture_rate" \ + -c "$capture_channels" \ + -d "$capture_duration" \ + "$capture_wav" >"$capture_log" 2>&1 & + ;; +esac +RECORDER_PID=$! + +sleep 1 + +log_info "Playing deterministic reference while capture is active, backend=$AUDIO_BACKEND target=$PLAYBACK_TARGET" +case "$AUDIO_BACKEND" in + pipewire) + if [ "$PLAYBACK_CLIENT" = "pw-cat" ]; then + audio_run_with_timeout_as_test_user \ + --require-session \ + "${playback_timeout}s" \ + env LC_ALL=C \ + pw-cat \ + --playback \ + -v \ + "$reference_wav" >"$playback_log" 2>&1 + else + audio_run_with_timeout_as_test_user \ + --require-session \ + "${playback_timeout}s" \ + env LC_ALL=C \ + pw-play \ + -v \ + "$reference_wav" >"$playback_log" 2>&1 + fi + ;; + pulseaudio) + audio_run_with_timeout_as_test_user \ + --require-session \ + "${playback_timeout}s" \ + env LC_ALL=C \ + paplay \ + --device="$PLAYBACK_TARGET" \ + "$reference_wav" >"$playback_log" 2>&1 + ;; + alsa) + audio_run_with_timeout_as_test_user "${playback_timeout}s" \ + env LC_ALL=C \ + aplay \ + -q \ + -D "$PLAYBACK_TARGET" \ + "$reference_wav" >"$playback_log" 2>&1 + ;; +esac +playback_rc=$? + +wait "$RECORDER_PID" +capture_rc=$? +RECORDER_PID="" + +if [ "$playback_rc" -eq 0 ]; then + test_result_record "PASS" \ + "Deterministic reference playback completed, backend=$AUDIO_BACKEND target=$PLAYBACK_TARGET artifact=$playback_log" +else + test_result_record "FAIL" \ + "$TESTNAME FAIL - deterministic reference playback failed, backend=$AUDIO_BACKEND target=$PLAYBACK_TARGET rc=$playback_rc artifact=$playback_log" +fi + +AUDIO_RECORD_STRICT_SIGNAL=0 +export AUDIO_RECORD_STRICT_SIGNAL + +if audio_validate_recording_result \ + "$capture_wav" \ + mic \ + "$capture_rate" \ + "$capture_channels" \ + "$capture_duration" \ + "$capture_rc" \ + "$capture_watchdog" \ + "$capture_log" \ + loopback; then + log_info "AUDIO_VALIDATION scope=loopback policy=basic-integrity status=PASS backend=$AUDIO_BACKEND playback_target=$PLAYBACK_TARGET capture_target=$CAPTURE_TARGET playback_rc=$playback_rc capture_rc=$capture_rc" + test_result_record "PASS" \ + "Loopback capture contains a valid non-zero signal, validation='$AUDIO_VALIDATION_SUMMARY' artifact=$capture_wav" +else + log_info "AUDIO_VALIDATION scope=loopback policy=basic-integrity status=FAIL backend=$AUDIO_BACKEND playback_target=$PLAYBACK_TARGET capture_target=$CAPTURE_TARGET playback_rc=$playback_rc capture_rc=$capture_rc" + test_result_record "FAIL" \ + "$TESTNAME FAIL - loopback capture is corrupt, empty, all-zero, or materially short, validation='${AUDIO_VALIDATION_SUMMARY:-unavailable}', verify the acoustic or electrical fixture, artifact=$capture_log" +fi + +if [ "$DMESG_SCAN" -eq 1 ]; then + scan_dmesg_errors \ + "$LOGDIR" \ + 'snd|asoc|audio|lpass|q6|codec|xrun|underrun|overrun' \ + 'dummy regulator|supply [^ ]+ not found|using dummy regulator|probe deferred' || true + + if [ -s "$LOGDIR/dmesg_errors.log" ]; then + log_warn "Audio-related kernel errors were captured, artifact=$LOGDIR/dmesg_errors.log" + fi +fi + +test_result_finish diff --git a/Runner/suites/Multimedia/Audio/AudioPlayback/AudioPlayback.yaml b/Runner/suites/Multimedia/Audio/AudioPlayback/AudioPlayback.yaml index cffd3d44..ce4b9667 100644 --- a/Runner/suites/Multimedia/Audio/AudioPlayback/AudioPlayback.yaml +++ b/Runner/suites/Multimedia/Audio/AudioPlayback/AudioPlayback.yaml @@ -15,7 +15,7 @@ params: FORMATS: "wav" # Audio formats: e.g. wav, default: wav DURATIONS: "" # Playback durations: short, medium, long (legacy mode only, not used in config discovery mode), default: unset LOOPS: 1 # Number of playback loops, default: 1 - TIMEOUT: "10s" # Playback timeout per loop (e.g., 15s, 0=none), default: 10s + TIMEOUT: "10s" # Playback timeout per loop (0=automatic for discovered clips), default: 10s STRICT: 0 # Enable strict mode (fail on any error), default: 0 DMESG_SCAN: 1 # Scan dmesg for errors after playback, default: 1 VERBOSE: 0 # Enable verbose logging, default: 0 diff --git a/Runner/suites/Multimedia/Audio/AudioPlayback/Read_me.md b/Runner/suites/Multimedia/Audio/AudioPlayback/Read_me.md index e4f45faf..078f97c8 100644 --- a/Runner/suites/Multimedia/Audio/AudioPlayback/Read_me.md +++ b/Runner/suites/Multimedia/Audio/AudioPlayback/Read_me.md @@ -18,9 +18,18 @@ This suite automates the validation of audio playback capabilities on Qualcomm L - Unique result file suffixes prevent file collisions in parallel test runs - Unique testcase IDs prevent LAVA testcase ID collisions - Enables running multiple AudioPlayback configurations simultaneously in CI -- Plays audio clips with configurable format, duration, and loop count +- Plays audio clips with configurable format, duration, and loop count +- Validates every WAV before playback and emits machine-readable + `AUDIO_VALIDATION` records for both clip integrity and playback execution - **Network operations are optional**: By default, no network connection is attempted. Use `--enable-network-download` to enable downloading missing audio files -- Automatically downloads and extracts audio assets if missing +- Automatically downloads and extracts audio assets if missing + +Playback clip validation blocks only on basic integrity failures: corrupt or +empty WAV files, header-only payloads, all-zero audio, materially short clips, +or filename metadata that disagrees with the WAV header. RMS, peak, clipping, +digital-silence runs, DC offset, large sample transitions, and silent-channel +counts are reported as diagnostic `AUDIO_VALIDATION` metrics and do not fail the +default policy. - Validates playback using multiple evidence sources: - PipeWire/PulseAudio streaming state - ALSA and ASoC runtime status @@ -118,6 +127,11 @@ available and falls back to `pw-cat --playback`. Automatic backend selection falls back to an available ALSA path. Package-recovery failure is reported as FAIL before any audio-clip download begins. +When launched by root on Debian or CentOS, the suite discovers and prepares the +regular desktop audio user, then runs PipeWire, PulseAudio, UCM, mixer, and PCM +operations in that user's context. Yocto retains its native execution model, +and Ubuntu continues to use its dynamically discovered desktop session. + ```sh # Let the suite detect a server or desktop Ubuntu image ./run.sh --clip-name "playback_config1 playback_config7" @@ -135,12 +149,12 @@ AUDIO_PACKAGE_PROFILE=desktop AUDIO_PACKAGE_UPDATE=1 \ When no backend is requested, the suite uses automatic selection. A physical PipeWire audio sink uses `pw-play` or `pw-cat --playback`, and a physical PulseAudio sink uses `paplay`. For the `speakers` route, a speaker endpoint takes precedence over headphones, then other physical outputs. Dummy, null, monitor, and loopback PipeWire sinks are not accepted as speaker routes. -If automatic selection finds no physical managed speaker sink, the suite probes direct ALSA playback. It selects an ALSA card and PCM from the available device inventory, applies only mixer controls exposed by that card, and runs `aplay -D `. This supports the Shikra primary-MI2S, secondary-TDM, and codec-direct route capabilities without selecting a form factor or assuming card `0`. +If automatic selection finds no physical managed speaker sink, the suite probes direct ALSA playback. It selects an ALSA card and PCM from the available device inventory, applies only mixer controls exposed by that card, and runs `aplay -D `. This supports the Shikra primary-MI2S, secondary-TDM, and codec-direct route capabilities without selecting a form factor or assuming card `0`. When the audio remoteproc preflight proves that audio is applicable, failure of both managed-sink discovery and direct ALSA probing is reported as FAIL so the image or runtime regression remains tracked. The same absence remains SKIP only when runtime preflight found no applicable audio subsystem. An explicit backend request is never replaced: -- `--backend pipewire` or `AUDIO_BACKEND=pipewire` runs `pw-play` or `pw-cat --playback` and skips if the `pipewire-utils` playback client or a physical PipeWire speaker sink is absent. -- `--backend pulseaudio` or `AUDIO_BACKEND=pulseaudio` runs `paplay` only and skips if no matching sink is available. +- `--backend pipewire` or `AUDIO_BACKEND=pipewire` runs `pw-play` or `pw-cat --playback`. A missing optional client can skip, but a ready backend with no requested physical sink fails. +- `--backend pulseaudio` or `AUDIO_BACKEND=pulseaudio` runs `paplay` only. A ready backend with no requested sink fails. - `--backend alsa` or `AUDIO_BACKEND=alsa` runs `aplay` with the discovered ALSA route. ## Audio Remoteproc Preflight @@ -302,7 +316,7 @@ CLIP_FILTER Filter clips by pattern (e.g., "48KHz" or "16b" or "2ch FORMATS Audio formats: e.g. wav wav DURATIONS Playback durations: short, medium, long (legacy mode only) "" LOOPS Number of playback loops 1 -TIMEOUT Playback timeout per loop (e.g., 15s, 0=none) "10s" +TIMEOUT Playback timeout per loop (0=automatic for discovered clips) "10s" STRICT Enable strict mode (fail on any error) 0 DMESG_SCAN Scan dmesg for errors after playback 1 VERBOSE Enable verbose logging 0 @@ -329,7 +343,7 @@ Option Description --formats Audio formats (space/comma separated): e.g. wav --durations Playback durations: short, medium, long (legacy mode only) --loops Number of playback loops ---timeout Playback timeout per loop (e.g., 15s) +--timeout Playback timeout per loop; 0 uses clip duration plus five seconds in discovery mode --strict Enable strict mode --no-dmesg Disable dmesg scan --no-extract-assets Disable asset extraction entirely (skips all asset operations) @@ -434,7 +448,7 @@ sh-5.3# ./run.sh --clip-name "playback_config1" --res-suffix "Config01" --audio- [INFO] 2026-01-22 17:46:33 - ---------------- Starting AudioPlayback ---------------- [INFO] 2026-01-22 17:46:33 - Using clip discovery mode [INFO] 2026-01-22 17:46:33 - Discovered 1 clips to test -[INFO] 2026-01-22 17:46:33 - [play_16KHz_16b_2ch] Clip duration: 30s (timeout threshold: 29s) +[INFO] 2026-01-22 17:46:33 - [play_16KHz_16b_2ch] Clip duration: 30s (minimum successful runtime: 29s) [PASS] 2026-01-22 17:47:04 - [play_16KHz_16b_2ch] loop 1 OK (rc=0, 30s) [PASS] 2026-01-22 17:47:04 - AudioPlayback PASS @@ -483,7 +497,7 @@ Results: - Results are stored in: results/AudioPlayback/ (or results/AudioPlayback_/ when using --res-suffix) - Summary result file: AudioPlayback.res (or AudioPlayback_.res when using --res-suffix) - JUnit XML (if enabled): .xml -- Diagnostic logs: dmesg snapshots, mixer dumps, playback logs per test case +- Diagnostic logs: dmesg snapshots, mixer dumps, playback logs per test case, and `alsa_playback_probe.log` containing the selected direct-ALSA candidate, probe command, exit status, and `aplay` error output - **Note**: When using --res-suffix, both result files AND log directories are unique per invocation, preventing log collisions in CI/LAVA workflows diff --git a/Runner/suites/Multimedia/Audio/AudioPlayback/run.sh b/Runner/suites/Multimedia/Audio/AudioPlayback/run.sh index 7427fc69..b5a4ac56 100755 --- a/Runner/suites/Multimedia/Audio/AudioPlayback/run.sh +++ b/Runner/suites/Multimedia/Audio/AudioPlayback/run.sh @@ -125,8 +125,9 @@ AUDIO_STARTED_PIDS="" AUDIO_CREATED_RUNTIME_DIR=0 AUDIO_SYSTEMD_MANAGED=0 AUDIO_ALSA_PLAYBACK_DEVICE="" +AUDIO_ALSA_PLAYBACK_PROBE_LOG="" AUDIO_PIPEWIRE_PLAY_COMMAND="" -export AUDIO_BOOTSTRAP_MODE AUDIO_RUNTIME_DIR AUDIO_STARTED_PIDS AUDIO_CREATED_RUNTIME_DIR MINIMAL_RAMDISK_MODE AUDIO_SYSTEMD_MANAGED AUDIO_ALSA_PLAYBACK_DEVICE +export AUDIO_BOOTSTRAP_MODE AUDIO_RUNTIME_DIR AUDIO_STARTED_PIDS AUDIO_CREATED_RUNTIME_DIR MINIMAL_RAMDISK_MODE AUDIO_SYSTEMD_MANAGED AUDIO_ALSA_PLAYBACK_DEVICE AUDIO_ALSA_PLAYBACK_PROBE_LOG # New clip-based testing options CLIP_NAMES="" # Explicit clip names to test (e.g., "play_48KHz_16b_2ch play_8KHz_8b_1ch") @@ -151,7 +152,7 @@ Usage: $0 [options] --sink {speakers|null} --overlay On Debian or CentOS, ensure the Qualcomm AudioReach package set before - playback. Debian also prepares its PipeWire runtime. Without this flag, + playback. Both prepare the regular-user PipeWire runtime. Without this flag, use the native/base audio stack. qcom-distro/Yocto remains unchanged. --formats "wav" # Legacy matrix mode only --durations "short|short medium" # Legacy matrix mode only (not recommended for new tests) @@ -161,7 +162,7 @@ Usage: $0 [options] --res-suffix SUFFIX # Suffix for unique result file (e.g., "Config1") # Generates AudioPlayback_SUFFIX.res instead of AudioPlayback.res --loops N - --timeout SECS # set 0 to disable watchdog + --timeout SECS # 0 uses clip duration plus grace in discovery mode --enable-network-download --audio-clips-path PATH # Custom location for audio clips (CI use) --audio-bootstrap {auto|true|false} @@ -204,12 +205,12 @@ Testing Modes: EOF_USAGE } -# Keep Debian service recovery inside the prepared user manager. Preserve the +# Keep desktop service recovery inside the prepared user manager. Preserve the # existing broad best-effort recovery only for native/Yocto execution. audio_playback_restart_backend_best_effort() { aprbbe_backend="$1" - if [ "$AUDIO_PLAYBACK_DEBIAN_ROOT_MODE" -ne 1 ]; then + if [ "$AUDIO_PLAYBACK_DESKTOP_ROOT_MODE" -ne 1 ]; then audio_restart_services_best_effort return $? fi @@ -229,10 +230,10 @@ audio_playback_restart_backend_best_effort() { esac } -# On Debian, manual daemon bootstrap is replaced by one user-service recovery +# On desktop user-mode systems, manual daemon bootstrap is replaced by one user-service recovery # attempt. Native and minimal Yocto images retain their existing bootstrap path. audio_playback_bootstrap_backend_if_needed() { - if [ "$AUDIO_PLAYBACK_DEBIAN_ROOT_MODE" -ne 1 ]; then + if [ "$AUDIO_PLAYBACK_DESKTOP_ROOT_MODE" -ne 1 ]; then audio_bootstrap_backend_if_needed return $? fi @@ -271,7 +272,7 @@ else fi # Package and udev preparation remain privileged and run exactly once. The -# complete runner remains the root orchestrator on Debian. +# complete runner remains the root orchestrator on Debian and CentOS. if ! command -v audio_prepare_test_packages >/dev/null 2>&1; then log_fail "$TESTNAME FAIL - required helper is unavailable: audio_prepare_test_packages" echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" @@ -310,6 +311,14 @@ if ! : >"$LOGDIR/summary.txt"; then exit 1 fi +AUDIO_ALSA_PLAYBACK_PROBE_LOG="$LOGDIR/alsa_playback_probe.log" +export AUDIO_ALSA_PLAYBACK_PROBE_LOG +if ! : >"$AUDIO_ALSA_PLAYBACK_PROBE_LOG"; then + log_fail "$TESTNAME FAIL - failed to initialize ALSA playback probe evidence: $AUDIO_ALSA_PLAYBACK_PROBE_LOG" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 +fi + while [ $# -gt 0 ]; do case "$1" in --overlay) @@ -438,7 +447,7 @@ elif command -v pw-cat >/dev/null 2>&1 && AUDIO_PIPEWIRE_PLAY_COMMAND="pw-cat" fi -# Prepare only the Debian user capabilities required by the selected mode. +# Prepare only the desktop user capabilities required by the selected mode. # Explicit base ALSA playback needs group membership but no systemd user # manager. Overlay and managed backends require the user session. AUDIO_PLAYBACK_USER_MANAGER_REQUIRED=1 @@ -447,20 +456,20 @@ if [ "$AUDIO_OVERLAY_REQUESTED" -eq 0 ] && AUDIO_PLAYBACK_USER_MANAGER_REQUIRED=0 fi -if ! command -v audio_prepare_debian_audio_environment >/dev/null 2>&1; then - log_fail "$TESTNAME FAIL - required helper is unavailable: audio_prepare_debian_audio_environment" +if ! command -v audio_prepare_desktop_audio_environment >/dev/null 2>&1; then + log_fail "$TESTNAME FAIL - required helper is unavailable: audio_prepare_desktop_audio_environment" echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" exit 1 fi -if ! audio_prepare_debian_audio_environment \ +if ! audio_prepare_desktop_audio_environment \ "$AUDIO_PLAYBACK_USER_MANAGER_REQUIRED"; then - log_fail "$TESTNAME FAIL - Debian Audio environment preparation failed" + log_fail "$TESTNAME FAIL - desktop Audio environment preparation failed" echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" exit 1 fi -AUDIO_PLAYBACK_DEBIAN_ROOT_MODE=0 +AUDIO_PLAYBACK_DESKTOP_ROOT_MODE=0 if command -v pkg_detect_os_id >/dev/null 2>&1; then AUDIO_PLAYBACK_OS_ID="$(pkg_detect_os_id 2>/dev/null || echo unknown)" else @@ -472,9 +481,11 @@ else )" fi -if [ "$AUDIO_PLAYBACK_OS_ID" = "debian" ]; then - AUDIO_PLAYBACK_DEBIAN_ROOT_MODE=1 -fi +case "$AUDIO_PLAYBACK_OS_ID" in + debian|centos) + AUDIO_PLAYBACK_DESKTOP_ROOT_MODE=1 + ;; +esac # Desktop Ubuntu images run PipeWire and the PulseAudio compatibility server in # the logged-in user's session. The test itself is often launched by root, so @@ -487,7 +498,7 @@ if [ "$AUDIO_PLAYBACK_OS_ID" = "ubuntu" ] && AUDIO_USE_DESKTOP_SESSION=1 fi -export AUDIO_PLAYBACK_DEBIAN_ROOT_MODE AUDIO_USE_DESKTOP_SESSION +export AUDIO_PLAYBACK_DESKTOP_ROOT_MODE AUDIO_USE_DESKTOP_SESSION # Auto-enable network download if WiFi credentials provided if [ -n "$SSID" ] && [ -n "$PASSWORD" ]; then @@ -495,8 +506,9 @@ if [ -n "$SSID" ] && [ -n "$PASSWORD" ]; then ENABLE_NETWORK_DOWNLOAD=true fi -# Debian overlay runtime preparation runs from the root orchestrator after -# normal CLI parsing. Only its device and PipeWire probes execute as debian. +# Desktop overlay runtime preparation runs from the root orchestrator after +# normal CLI parsing. Only its device and PipeWire probes execute as the +# prepared regular user. if [ "$AUDIO_OVERLAY_REQUESTED" -eq 1 ]; then if ! command -v audio_prepare_overlay_runtime >/dev/null 2>&1; then log_fail "$TESTNAME FAIL - required helper is unavailable: audio_prepare_overlay_runtime" @@ -522,8 +534,8 @@ if [ "$AUDIO_OVERLAY_REQUESTED" -eq 1 ]; then ;; esac elif [ "$SYSTEMD_AVAILABLE" -eq 1 ] && - [ "$AUDIO_PLAYBACK_DEBIAN_ROOT_MODE" -ne 1 ]; then - # Preserve the existing native/Yocto validation path. Debian base mode uses + [ "$AUDIO_PLAYBACK_DESKTOP_ROOT_MODE" -ne 1 ]; then + # Preserve the existing native/Yocto validation path. Desktop base mode uses # command-level user probes during backend discovery instead. if ! setup_overlay_audio_environment; then log_warn "Existing overlay audio environment validation failed, continuing with backend recovery flow" @@ -872,13 +884,13 @@ fi if [ "$backend_ok" -ne 1 ] && [ -z "$AUDIO_BACKEND_REQUESTED" ] && [ "$AUDIO_BACKEND" != "alsa" ]; then - if [ "$AUDIO_PLAYBACK_DEBIAN_ROOT_MODE" -eq 1 ]; then + if [ "$AUDIO_PLAYBACK_DESKTOP_ROOT_MODE" -eq 1 ]; then log_warn "$TESTNAME: backend not available ($AUDIO_BACKEND) - attempting user-service recovery" else log_warn "$TESTNAME: backend not available ($AUDIO_BACKEND) - attempting manual bootstrap" fi if audio_playback_bootstrap_backend_if_needed; then - if [ "$AUDIO_PLAYBACK_DEBIAN_ROOT_MODE" -eq 1 ]; then + if [ "$AUDIO_PLAYBACK_DESKTOP_ROOT_MODE" -eq 1 ]; then AUDIO_SYSTEMD_MANAGED=1 else AUDIO_SYSTEMD_MANAGED=0 @@ -1076,14 +1088,30 @@ then SINK_ID="$AUDIO_ALSA_PLAYBACK_DEVICE" log_warn "$TESTNAME: falling back to direct ALSA playback device: $SINK_ID" else - log_skip "$TESTNAME SKIP - no physical $AUDIO_BACKEND speaker sink and ALSA playback probe failed" + if [ "$audio_remoteproc_rc" -eq 0 ] 2>/dev/null; then + log_warn "$TESTNAME: ALSA playback probe evidence follows, artifact=$AUDIO_ALSA_PLAYBACK_PROBE_LOG" + log_file_with_label \ + "ALSA-PROBE" "$AUDIO_ALSA_PLAYBACK_PROBE_LOG" 40 + log_fail "$TESTNAME FAIL - audio runtime is applicable but no physical $AUDIO_BACKEND speaker sink was discovered and the direct ALSA playback probe failed, verify sound-card registration, topology, UCM, mixer routing, and image audio packages" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 + fi + + log_skip "$TESTNAME SKIP - no physical $AUDIO_BACKEND speaker sink or direct ALSA playback path was discovered, audio remoteproc preflight was not applicable" echo "$RESULT_TESTNAME SKIP" >"$RES_FILE" exit 0 fi fi if [ -z "$SINK_ID" ]; then - log_skip "$TESTNAME SKIP - requested sink '$SINK_CHOICE' not found for $AUDIO_BACKEND" + if [ -n "$AUDIO_BACKEND_REQUESTED" ] || + [ "$audio_remoteproc_rc" -eq 0 ] 2>/dev/null; then + log_fail "$TESTNAME FAIL - requested sink '$SINK_CHOICE' is unavailable for ready backend '$AUDIO_BACKEND', verify the selected backend route and image audio configuration" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 + fi + + log_skip "$TESTNAME SKIP - no applicable sink '$SINK_CHOICE' was discovered for backend '$AUDIO_BACKEND'" echo "$RESULT_TESTNAME SKIP" >"$RES_FILE" exit 0 fi @@ -1127,6 +1155,7 @@ if [ -z "$dur_s" ]; then dur_s=0 fi +playback_timeout_grace=5 min_ok=0 if [ "$dur_s" -gt 0 ] 2>/dev/null; then min_ok=$((dur_s - 1)) @@ -1135,7 +1164,7 @@ if [ "$dur_s" -gt 0 ] 2>/dev/null; then fi log_info "Watchdog/timeout: ${TIMEOUT}" else - log_info "Watchdog/timeout: disabled (no timeout)" + log_info "Watchdog/timeout: automatic per discovered clip, duration plus ${playback_timeout_grace}s grace" fi # ------------- Test Execution (Matrix or Clip Discovery) ------------- @@ -1185,12 +1214,28 @@ if [ "$USE_CLIP_DISCOVERY" = "true" ]; then # Resolve full path clip_path="$clips_dir/$clip_file" + total=$((total + 1)) + logf="$LOGDIR/${case_name}.log" + : > "$logf" + export AUDIO_LOGCTX="$logf" - # Validate clip file + # Playback assets are test inputs. Corrupt, empty, all-zero, materially + # short, or metadata-mismatched WAV files must not produce a playback PASS. if ! validate_clip_file "$clip_path"; then - log_skip "[$case_name] SKIP: Invalid clip file: $clip_path" - echo "$case_name SKIP (invalid file)" >> "$LOGDIR/summary.txt" - skip=$((skip + 1)) + log_info \ + "AUDIO_VALIDATION scope=playback policy=basic-integrity status=FAIL reason=clip-file-unavailable-or-empty case=$case_name file=$clip_path" + log_fail "[$case_name] invalid playback clip: $clip_path" + echo "$case_name FAIL (invalid file)" >> "$LOGDIR/summary.txt" + fail=$((fail + 1)) + suite_rc=1 + continue + fi + + if ! audio_validate_playback_wav "$clip_path" "$logf"; then + log_fail "[$case_name] playback clip failed basic-integrity validation, validation='${AUDIO_VALIDATION_SUMMARY:-unavailable}'" + echo "$case_name FAIL (clip validation)" >> "$LOGDIR/summary.txt" + fail=$((fail + 1)) + suite_rc=1 continue fi @@ -1203,18 +1248,13 @@ if [ "$USE_CLIP_DISCOVERY" = "true" ]; then if [ "$clip_min_ok" -lt 1 ]; then clip_min_ok=1 fi - log_info "[$case_name] Clip duration: ${clip_duration}s (timeout threshold: ${clip_min_ok}s)" + log_info "[$case_name] Clip duration: ${clip_duration}s (minimum successful runtime: ${clip_min_ok}s)" else # Fallback to global timeout values if duration cannot be parsed clip_dur_s="$dur_s" clip_min_ok="$min_ok" fi - total=$((total + 1)) - logf="$LOGDIR/${case_name}.log" - : > "$logf" - export AUDIO_LOGCTX="$logf" - CLIP_BYTES="$(file_size_bytes "$clip_path" 2>/dev/null || echo 0)" log_info "[$case_name] Using clip: $clip_file (${CLIP_BYTES} bytes)" @@ -1233,12 +1273,13 @@ if [ "$USE_CLIP_DISCOVERY" = "true" ]; then log_info "[$case_name] loop $i/$LOOPS start=$iso clip=$clip_file backend=$AUDIO_BACKEND $loop_hdr" - # Determine effective timeout: use clip duration when TIMEOUT is disabled + # Bound automatic playback beyond the expected clip duration so normal + # client teardown is not misclassified as a timeout. effective_timeout="$TIMEOUT" if [ "$TIMEOUT" = "0" ] || [ "$TIMEOUT" = "" ]; then if [ "$clip_duration" -gt 0 ] 2>/dev/null; then - effective_timeout="$clip_duration" - log_info "[$case_name] Using clip duration as timeout: ${effective_timeout}s" + effective_timeout=$((clip_duration + playback_timeout_grace)) + log_info "[$case_name] Using automatic watchdog: timeout=${effective_timeout}s clip_duration=${clip_duration}s grace=${playback_timeout_grace}s" fi fi @@ -1283,9 +1324,20 @@ if [ "$USE_CLIP_DISCOVERY" = "true" ]; then alsa_ev="$(audio_evidence_alsa_running_any || echo 0)" asoc_ev="$(audio_evidence_asoc_path_on || echo 0)" pwlog_ev="$(audio_run_helper_as_test_user --require-session audio_evidence_pw_log_seen || echo 0)" - if [ "$AUDIO_BACKEND" = "pulseaudio" ] || [ "$AUDIO_BACKEND" = "alsa" ]; then - pwlog_ev=0 - fi + case "$AUDIO_BACKEND" in + pipewire) + pa_ev=0 + ;; + pulseaudio) + pw_ev=0 + pwlog_ev=0 + ;; + alsa) + pw_ev=0 + pa_ev=0 + pwlog_ev=0 + ;; + esac # Fast teardown fallback if [ "$alsa_ev" -eq 0 ]; then @@ -1305,15 +1357,19 @@ if [ "$USE_CLIP_DISCOVERY" = "true" ]; then # Determine result (use clip-specific timeout thresholds) if [ "$rc" -eq 0 ]; then + log_info "AUDIO_VALIDATION scope=playback-runtime policy=execution-evidence status=PASS case=$case_name backend=$AUDIO_BACKEND rc=0 elapsed_s=$last_elapsed" log_pass "[$case_name] loop $i OK (rc=0, ${last_elapsed}s)" ok_runs=$((ok_runs + 1)) elif [ "$rc" -eq 124 ] && [ "$clip_dur_s" -gt 0 ] 2>/dev/null && [ "$last_elapsed" -ge "$clip_min_ok" ]; then - log_warn "[$case_name] TIMEOUT ($TIMEOUT) - PASS (ran ~${last_elapsed}s, expected ${clip_duration}s)" + log_info "AUDIO_VALIDATION scope=playback-runtime policy=execution-evidence status=PASS case=$case_name backend=$AUDIO_BACKEND rc=$rc elapsed_s=$last_elapsed reason=expected-watchdog" + log_info "[$case_name] Watchdog reached after sufficient playback, timeout=${effective_timeout}s elapsed=${last_elapsed}s expected=${clip_duration}s" ok_runs=$((ok_runs + 1)) elif [ "$rc" -ne 0 ] && { [ "$pw_ev" -eq 1 ] || [ "$pa_ev" -eq 1 ] || [ "$alsa_ev" -eq 1 ] || [ "$asoc_ev" -eq 1 ]; }; then + log_info "AUDIO_VALIDATION scope=playback-runtime policy=execution-evidence status=PASS case=$case_name backend=$AUDIO_BACKEND rc=$rc elapsed_s=$last_elapsed reason=runtime-stream-evidence" log_warn "[$case_name] nonzero rc=$rc but evidence indicates playback - PASS" ok_runs=$((ok_runs + 1)) else + log_info "AUDIO_VALIDATION scope=playback-runtime policy=execution-evidence status=FAIL case=$case_name backend=$AUDIO_BACKEND rc=$rc elapsed_s=$last_elapsed" log_fail "[$case_name] loop $i FAILED (rc=$rc, ${last_elapsed}s) - see $logf" fi @@ -1363,12 +1419,32 @@ else else log_info "[$case_name] Hint: Run with --enable-network-download to download clips" fi + log_info \ + "AUDIO_VALIDATION scope=playback policy=basic-integrity status=SKIP reason=clip-unavailable case=$case_name file=$clip" echo "$case_name SKIP (clip unavailable)" >> "$LOGDIR/summary.txt" skip=$((skip + 1)) continue fi fi + if ! validate_clip_file "$clip"; then + log_info \ + "AUDIO_VALIDATION scope=playback policy=basic-integrity status=FAIL reason=clip-file-unavailable-or-empty case=$case_name file=$clip" + log_fail "[$case_name] invalid playback clip: $clip" + echo "$case_name FAIL (invalid file)" >> "$LOGDIR/summary.txt" + fail=$((fail + 1)) + suite_rc=1 + continue + fi + + if ! audio_validate_playback_wav "$clip" "$logf"; then + log_fail "[$case_name] playback clip failed basic-integrity validation, validation='${AUDIO_VALIDATION_SUMMARY:-unavailable}'" + echo "$case_name FAIL (clip validation)" >> "$LOGDIR/summary.txt" + fail=$((fail + 1)) + suite_rc=1 + continue + fi + i=1 ok_runs=0 last_elapsed=0 @@ -1425,9 +1501,20 @@ else alsa_ev="$(audio_evidence_alsa_running_any || echo 0)" asoc_ev="$(audio_evidence_asoc_path_on || echo 0)" pwlog_ev="$(audio_run_helper_as_test_user --require-session audio_evidence_pw_log_seen || echo 0)" - if [ "$AUDIO_BACKEND" = "pulseaudio" ] || [ "$AUDIO_BACKEND" = "alsa" ]; then - pwlog_ev=0 - fi + case "$AUDIO_BACKEND" in + pipewire) + pa_ev=0 + ;; + pulseaudio) + pw_ev=0 + pwlog_ev=0 + ;; + alsa) + pw_ev=0 + pa_ev=0 + pwlog_ev=0 + ;; + esac # Fast teardown fallback: if user-space stream was active, trust ALSA/ASoC too. if [ "$alsa_ev" -eq 0 ]; then @@ -1446,15 +1533,19 @@ else log_info "[$case_name] evidence: pw_streaming=$pw_ev pa_streaming=$pa_ev alsa_running=$alsa_ev asoc_path_on=$asoc_ev pw_log=$pwlog_ev" if [ "$rc" -eq 0 ]; then + log_info "AUDIO_VALIDATION scope=playback-runtime policy=execution-evidence status=PASS case=$case_name backend=$AUDIO_BACKEND rc=0 elapsed_s=$last_elapsed" log_pass "[$case_name] loop $i OK (rc=0, ${last_elapsed}s)" ok_runs=$((ok_runs + 1)) elif [ "$rc" -eq 124 ] && [ "$dur_s" -gt 0 ] 2>/dev/null && [ "$last_elapsed" -ge "$min_ok" ]; then - log_warn "[$case_name] TIMEOUT ($TIMEOUT) - PASS (ran ~${last_elapsed}s)" + log_info "AUDIO_VALIDATION scope=playback-runtime policy=execution-evidence status=PASS case=$case_name backend=$AUDIO_BACKEND rc=$rc elapsed_s=$last_elapsed reason=expected-watchdog" + log_info "[$case_name] Watchdog reached after sufficient playback, timeout=${TIMEOUT} elapsed=${last_elapsed}s" ok_runs=$((ok_runs + 1)) elif [ "$rc" -ne 0 ] && { [ "$pw_ev" -eq 1 ] || [ "$pa_ev" -eq 1 ] || [ "$alsa_ev" -eq 1 ] || [ "$asoc_ev" -eq 1 ]; }; then + log_info "AUDIO_VALIDATION scope=playback-runtime policy=execution-evidence status=PASS case=$case_name backend=$AUDIO_BACKEND rc=$rc elapsed_s=$last_elapsed reason=runtime-stream-evidence" log_warn "[$case_name] nonzero rc=$rc but evidence indicates playback - PASS" ok_runs=$((ok_runs + 1)) else + log_info "AUDIO_VALIDATION scope=playback-runtime policy=execution-evidence status=FAIL case=$case_name backend=$AUDIO_BACKEND rc=$rc elapsed_s=$last_elapsed" log_fail "[$case_name] loop $i FAILED (rc=$rc, ${last_elapsed}s) - see $logf" fi diff --git a/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md b/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md index 50ff24fa..f259fc48 100644 --- a/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md +++ b/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md @@ -3,6 +3,13 @@ ## Overview This suite automates the validation of audio recording capabilities on Qualcomm Linux-based platforms. It supports PipeWire, PulseAudio, and direct ALSA backends with robust evidence-based PASS/FAIL logic, asset management, and diagnostic logging. + +The default CI policy blocks corrupt or empty WAV files, header-only payloads, +all-zero audio, materially short captures, and explicitly requested format +mismatches. RMS, peak, clipping, digital-silence runs, DC offset, large sample +transitions, and silent-channel counts are emitted as diagnostic +`AUDIO_VALIDATION` metrics. These metrics do not fail the default policy unless +strict signal validation is explicitly enabled. ## Features @@ -66,16 +73,20 @@ recovery. PipeWire ensures `pw-play`, `pw-record`, and `wpctl`; ALSA ensures `aplay` and `arecord`; PulseAudio ensures `paplay`, `parecord`, and `pactl`. Yocto continues to use image-provided clients. +When launched by root on Debian or CentOS, the suite discovers and prepares the +regular desktop audio user, then runs PipeWire, PulseAudio, UCM, mixer, and PCM +operations in that user's context. Yocto retains its native execution model. + ## Backend and Route Selection When no backend is requested, the suite uses automatic selection. A real PipeWire audio source uses `pw-record`, and a real PulseAudio source uses `parecord`. Camera, dummy, null, monitor, and loopback PipeWire nodes are not accepted as microphone sources. -If automatic selection finds no physical managed microphone source, the suite probes direct ALSA capture. It selects a card and PCM from the available device inventory, applies only mixer controls exposed by that card, and runs `arecord -D `. This discovers the VA-DMIC capture route from its controls without selecting a form factor or assuming card `0`. +If automatic selection finds no physical managed microphone source, the suite probes direct ALSA capture. It selects a card and PCM from the available device inventory, applies only mixer controls exposed by that card, and runs `arecord -D `. This discovers the VA-DMIC capture route from its controls without selecting a form factor or assuming card `0`. When audio remoteproc preflight proves that audio is applicable, failure of both managed-source discovery and direct ALSA probing is reported as FAIL so the image or runtime regression remains tracked. The same absence remains SKIP only when runtime preflight found no applicable audio subsystem. An explicit backend request is never replaced: -- `--backend pipewire` or `AUDIO_BACKEND=pipewire` runs `pw-record` only and skips if PipeWire has no physical microphone source. -- `--backend pulseaudio` or `AUDIO_BACKEND=pulseaudio` runs `parecord` only and skips if no matching source is available. +- `--backend pipewire` or `AUDIO_BACKEND=pipewire` runs `pw-record` only and fails if the requested physical microphone source is unavailable. +- `--backend pulseaudio` or `AUDIO_BACKEND=pulseaudio` runs `parecord` only and fails if the requested source is unavailable. - `--backend alsa` or `AUDIO_BACKEND=alsa` runs `arecord` with the discovered ALSA route. ## Audio Remoteproc Preflight @@ -287,10 +298,10 @@ sh-5.3# ./run.sh --config-name "record_config1" [INFO] 2026-01-02 12:00:46 - [record_8KHz_1ch] Using config: record_config1 (rate=8000Hz channels=1) [INFO] 2026-01-02 12:00:46 - [record_8KHz_1ch] loop 1/1 start=2026-01-02T12:00:46Z rate=8000Hz channels=1 backend=pipewire source=mic(45) [INFO] 2026-01-02 12:00:46 - [record_8KHz_1ch] exec: pw-record -v --rate=8000 --channels=1 "results/AudioRecord/record_8KHz_1ch.wav" -[WARN] 2026-01-02 12:01:16 - [record_8KHz_1ch] nonzero rc=124 but recording looks valid (bytes=482634) - PASS +[INFO] 2026-01-02 12:01:16 - Recorder ended through expected watchdog timeout rc=124, validated WAV payload is accepted [INFO] 2026-01-02 12:01:16 - [record_8KHz_1ch] evidence: pw_streaming=1 pa_streaming=0 alsa_running=1 asoc_path_on=1 bytes=482634 pw_log=1 [PASS] 2026-01-02 12:01:16 - [record_8KHz_1ch] loop 1 OK (rc=0, 30s, bytes=482634) -[INFO] 2026-01-02 12:01:16 - No relevant, non-benign errors for modules [results/AudioRecord] in recent dmesg. +[INFO] 2026-01-02 12:01:16 - No relevant, non-benign errors for modules [snd|asoc|audio|lpass|q6|codec|xrun|underrun|overrun|pipewire|pulseaudio] in recent dmesg. [INFO] 2026-01-02 12:01:16 - Summary: total=1 pass=1 fail=0 skip=0 [PASS] 2026-01-02 12:01:16 - AudioRecord PASS ``` diff --git a/Runner/suites/Multimedia/Audio/AudioRecord/run.sh b/Runner/suites/Multimedia/Audio/AudioRecord/run.sh index 0d52afae..6362855e 100755 --- a/Runner/suites/Multimedia/Audio/AudioRecord/run.sh +++ b/Runner/suites/Multimedia/Audio/AudioRecord/run.sh @@ -53,7 +53,7 @@ RES_SUFFIX="" # Optional suffix for unique result files (e.g., "Config1") # Pre-parse the options required by privileged Audio preparation without # consuming the original argument list. The complete original "$@" must remain -# available for the root-to-debian re-exec performed below. +# available for privileged desktop-audio preparation performed below. AUDIO_OVERLAY_REQUESTED=0 AUDIO_EARLY_HELP_REQUESTED=0 AUDIO_EARLY_EXPECT="" @@ -171,7 +171,7 @@ Examples: EOF } -# Keep Debian service recovery inside the prepared user manager. Preserve the +# Keep desktop service recovery inside the prepared user manager. Preserve the # existing broad best-effort recovery only for native/Yocto execution. # --help must not install packages, change user groups, create output files, or # start a systemd user manager. @@ -192,7 +192,7 @@ else fi # Package and udev preparation remain privileged. The complete runner stays -# the root orchestrator on Debian. +# the root orchestrator on Debian and CentOS. if ! command -v audio_prepare_test_packages >/dev/null 2>&1; then log_fail "$TESTNAME FAIL - required helper is unavailable: audio_prepare_test_packages" echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" @@ -225,7 +225,7 @@ if ! mkdir -p "$LOGDIR"; then exit 1 fi -# The Debian Audio user needs traverse-only access to reach its dedicated +# The desktop Audio user needs traverse-only access to reach its dedicated # scratch capture directory. Final logs and WAV artifacts remain root-owned. if ! chmod 0755 "$LOGDIR"; then log_fail "$TESTNAME FAIL - failed to set log directory traversal mode: $LOGDIR" @@ -356,7 +356,7 @@ case "$AUDIO_RECORD_STRICT_SIGNAL" in esac export AUDIO_RECORD_STRICT_SIGNAL -# Prepare only the Debian user capabilities required by the selected mode. +# Prepare only the desktop user capabilities required by the selected mode. # Explicit base ALSA capture needs group membership but no systemd user # manager. Overlay and managed backends require the user session. AUDIO_RECORD_USER_MANAGER_REQUIRED=1 @@ -366,7 +366,7 @@ if [ "$AUDIO_OVERLAY_REQUESTED" -eq 0 ] && fi for audio_required_helper in \ - audio_prepare_debian_audio_environment \ + audio_prepare_desktop_audio_environment \ audio_run_as_test_user \ audio_run_helper_as_test_user \ audio_run_with_timeout_as_test_user @@ -378,14 +378,14 @@ do fi done -if ! audio_prepare_debian_audio_environment \ +if ! audio_prepare_desktop_audio_environment \ "$AUDIO_RECORD_USER_MANAGER_REQUIRED"; then - log_fail "$TESTNAME FAIL - Debian Audio environment preparation failed" + log_fail "$TESTNAME FAIL - desktop Audio environment preparation failed" echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" exit 1 fi -AUDIO_RECORD_DEBIAN_ROOT_MODE=0 +AUDIO_RECORD_DESKTOP_ROOT_MODE=0 if command -v pkg_detect_os_id >/dev/null 2>&1; then AUDIO_RECORD_OS_ID="$(pkg_detect_os_id 2>/dev/null || echo unknown)" else @@ -397,18 +397,20 @@ else )" fi -if [ "$AUDIO_RECORD_OS_ID" = "debian" ]; then - AUDIO_RECORD_DEBIAN_ROOT_MODE=1 -fi +case "$AUDIO_RECORD_OS_ID" in + debian|centos) + AUDIO_RECORD_DESKTOP_ROOT_MODE=1 + ;; +esac -export AUDIO_RECORD_DEBIAN_ROOT_MODE +export AUDIO_RECORD_DESKTOP_ROOT_MODE -# Only this scratch directory is writable by the Debian Audio user. Root keeps +# Only this scratch directory is writable by the desktop Audio user. Root keeps # ownership of the final LOGDIR, logs, summary, JUnit data, and promoted WAVs. AUDIO_RECORD_USER_CAPTURE_DIR="$LOGDIR/.user-capture" export AUDIO_RECORD_USER_CAPTURE_DIR -if [ "$AUDIO_RECORD_DEBIAN_ROOT_MODE" -eq 1 ]; then +if [ "$AUDIO_RECORD_DESKTOP_ROOT_MODE" -eq 1 ]; then rm -rf "$AUDIO_RECORD_USER_CAPTURE_DIR" if ! mkdir -p "$AUDIO_RECORD_USER_CAPTURE_DIR"; then @@ -417,7 +419,7 @@ if [ "$AUDIO_RECORD_DEBIAN_ROOT_MODE" -eq 1 ]; then exit 1 fi - if ! chown "${AUDIO_TEST_USER:-debian}:audio" \ + if ! chown "$AUDIO_TEST_USER:audio" \ "$AUDIO_RECORD_USER_CAPTURE_DIR"; then log_fail "$TESTNAME FAIL - failed to assign Audio user capture directory" echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" @@ -430,11 +432,12 @@ if [ "$AUDIO_RECORD_DEBIAN_ROOT_MODE" -eq 1 ]; then exit 1 fi - log_pass "Prepared Debian Audio capture workspace: $AUDIO_RECORD_USER_CAPTURE_DIR" + log_pass "Prepared desktop Audio capture workspace: $AUDIO_RECORD_USER_CAPTURE_DIR" fi -# Debian overlay runtime preparation runs from the root orchestrator after -# normal CLI parsing. Only its device and PipeWire probes execute as debian. +# Desktop overlay runtime preparation runs from the root orchestrator after +# normal CLI parsing. Only its device and PipeWire probes execute as the +# prepared regular user. if [ "$AUDIO_OVERLAY_REQUESTED" -eq 1 ]; then if ! command -v audio_prepare_overlay_runtime >/dev/null 2>&1; then log_fail "$TESTNAME FAIL - required helper is unavailable: audio_prepare_overlay_runtime" @@ -460,8 +463,8 @@ if [ "$AUDIO_OVERLAY_REQUESTED" -eq 1 ]; then ;; esac elif [ "$SYSTEMD_AVAILABLE" -eq 1 ] && - [ "$AUDIO_RECORD_DEBIAN_ROOT_MODE" -ne 1 ]; then - # Preserve the existing native/Yocto validation path. Debian base mode uses + [ "$AUDIO_RECORD_DESKTOP_ROOT_MODE" -ne 1 ]; then + # Preserve the existing native/Yocto validation path. Desktop base mode uses # command-level user probes during backend discovery instead. if ! setup_overlay_audio_environment; then log_warn "Existing overlay audio environment validation failed, continuing with backend recovery flow" @@ -582,6 +585,12 @@ if [ -z "$AUDIO_BACKEND" ]; then export AUDIO_SYSTEMD_MANAGED log_warn "$TESTNAME: no managed audio backend running - using direct ALSA capture path" else + if [ "$audio_remoteproc_rc" -eq 0 ] 2>/dev/null; then + log_fail "$TESTNAME FAIL - audio runtime is applicable but no managed recording backend or direct ALSA capture path is usable, reason=${AUDIO_ALSA_CAPTURE_REASON:-capture path unavailable}, verify sound-card registration, topology, UCM, mixer routing, and image audio packages" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 + fi + log_skip "$TESTNAME SKIP - no audio backend running and ALSA capture probe failed: ${AUDIO_ALSA_CAPTURE_REASON:-capture path unavailable}" echo "$RESULT_TESTNAME SKIP" > "$RES_FILE" exit 0 @@ -658,11 +667,18 @@ if [ "$backend_ok" -ne 1 ] && [ "$AUDIO_BACKEND" != "alsa" ]; then fi if [ "$backend_ok" -ne 1 ]; then - if [ "$AUDIO_BACKEND" = "alsa" ] || [ "$ALSA_CAPTURE_PROBED" -eq 1 ]; then - log_skip "$TESTNAME SKIP - ALSA capture path unavailable: ${AUDIO_ALSA_CAPTURE_REASON:-capture device could not be opened}" - else - log_skip "$TESTNAME SKIP - backend not available: $AUDIO_BACKEND" + if [ -n "$AUDIO_BACKEND_REQUESTED" ] || + [ "$audio_remoteproc_rc" -eq 0 ] 2>/dev/null; then + if [ "$AUDIO_BACKEND" = "alsa" ] || [ "$ALSA_CAPTURE_PROBED" -eq 1 ]; then + log_fail "$TESTNAME FAIL - audio runtime is applicable but the ALSA capture path is unusable, reason=${AUDIO_ALSA_CAPTURE_REASON:-capture device could not be opened}" + else + log_fail "$TESTNAME FAIL - requested or applicable recording backend is unavailable: $AUDIO_BACKEND" + fi + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 fi + + log_skip "$TESTNAME SKIP - no applicable recording backend or ALSA capture path was discovered" echo "$RESULT_TESTNAME SKIP" > "$RES_FILE" exit 0 fi @@ -784,6 +800,12 @@ if [ -z "$SRC_ID" ] && [ -z "$AUDIO_BACKEND_REQUESTED" ] && log_warn "$TESTNAME: falling back to direct ALSA capture device: $SRC_ID" else + if [ "$audio_remoteproc_rc" -eq 0 ] 2>/dev/null; then + log_fail "$TESTNAME FAIL - audio runtime is applicable but no physical PipeWire microphone source or direct ALSA capture path is usable, reason=${AUDIO_ALSA_CAPTURE_REASON:-capture path unavailable}, verify sound-card registration, topology, UCM, mixer routing, and image audio packages" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 + fi + log_skip "$TESTNAME SKIP - no real capture source available, PipeWire mic source missing and ALSA capture probe failed: ${AUDIO_ALSA_CAPTURE_REASON:-capture path unavailable}" echo "$RESULT_TESTNAME SKIP" > "$RES_FILE" exit 0 @@ -833,13 +855,13 @@ if [ -z "$SRC_ID" ] && [ -z "$AUDIO_BACKEND_REQUESTED" ] && fi if [ -z "$SRC_ID" ]; then - log_skip "$TESTNAME SKIP - requested source '$SRC_CHOICE' not available on any backend (${BACKENDS_TO_TRY:-unknown})" - echo "$RESULT_TESTNAME SKIP" > "$RES_FILE" - exit 0 -fi + if [ -n "$AUDIO_BACKEND_REQUESTED" ] || + [ "$audio_remoteproc_rc" -eq 0 ] 2>/dev/null; then + log_fail "$TESTNAME FAIL - requested source '$SRC_CHOICE' is unavailable on ready audio backends (${BACKENDS_TO_TRY:-unknown}), verify the capture route and image audio configuration" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 + fi -# Only skip if no source AND not on PipeWire. -if [ -z "$SRC_ID" ] && [ "$AUDIO_BACKEND" != "pipewire" ]; then log_skip "$TESTNAME SKIP - requested source '$SRC_CHOICE' not available on any backend (${BACKENDS_TO_TRY:-unknown})" echo "$RESULT_TESTNAME SKIP" > "$RES_FILE" exit 0 @@ -873,6 +895,13 @@ if [ "$AUDIO_BACKEND" = "alsa" ]; then SRC_ID="$cand" log_info "ALSA auto-pick: using $SRC_ID" else + if [ -n "$AUDIO_BACKEND_REQUESTED" ] || + [ "$audio_remoteproc_rc" -eq 0 ] 2>/dev/null; then + log_fail "$TESTNAME FAIL - audio runtime is applicable but no valid ALSA capture device was discovered, verify sound-card registration and the capture PCM configuration" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 + fi + log_skip "$TESTNAME SKIP - no valid ALSA capture device found" echo "$RESULT_TESTNAME SKIP" > "$RES_FILE" exit 0 diff --git a/Runner/suites/Multimedia/Audio/Audio_Card_Registration/README_Audio_Card_Registration.md b/Runner/suites/Multimedia/Audio/Audio_Card_Registration/README_Audio_Card_Registration.md index b5e3e7e6..517021b9 100644 --- a/Runner/suites/Multimedia/Audio/Audio_Card_Registration/README_Audio_Card_Registration.md +++ b/Runner/suites/Multimedia/Audio/Audio_Card_Registration/README_Audio_Card_Registration.md @@ -29,6 +29,10 @@ On Debian, Ubuntu, and CentOS, the suite ensures the complete ALSA client package set before card validation. This provides both `aplay` and `arecord` from `alsa-utils`. Yocto continues to use image-provided clients. +When invoked by root on Debian or CentOS, ALSA inventory and device-node checks +run as the prepared regular audio user. This validates the same access context +used by the functional playback and recording suites. + ## What This Test Validates The test checks: diff --git a/Runner/suites/Multimedia/Audio/Audio_Card_Registration/run.sh b/Runner/suites/Multimedia/Audio/Audio_Card_Registration/run.sh index 7e68955c..f6c18148 100755 --- a/Runner/suites/Multimedia/Audio/Audio_Card_Registration/run.sh +++ b/Runner/suites/Multimedia/Audio/Audio_Card_Registration/run.sh @@ -150,8 +150,8 @@ if [ "$AUDIO_EARLY_HELP_REQUESTED" -eq 1 ]; then fi # Resolve absolute output paths. The complete runner remains the root -# orchestrator on Debian, so logs, results, inventory, and dmesg evidence stay -# root-owned. +# orchestrator on Debian and CentOS, so logs, results, inventory, and dmesg +# evidence stay root-owned. RES_FILE="$SCRIPT_DIR/$TESTNAME.res" LOGDIR="$SCRIPT_DIR/results/$TESTNAME" @@ -192,17 +192,17 @@ if ! audio_prepare_backend_client_packages alsa; then exit 1 fi -# Prepare only the Debian Audio account and group membership. This ALSA-only +# Prepare only the desktop Audio account and group membership. This ALSA-only # testcase does not require a systemd user manager and does not re-execute the -# complete runner as debian. -if ! command -v audio_prepare_debian_audio_environment >/dev/null 2>&1; then - log_fail "$TESTNAME FAIL: required helper is unavailable: audio_prepare_debian_audio_environment" +# complete runner as the regular user. +if ! command -v audio_prepare_desktop_audio_environment >/dev/null 2>&1; then + log_fail "$TESTNAME FAIL: required helper is unavailable: audio_prepare_desktop_audio_environment" echo "$TESTNAME FAIL" > "$RES_FILE" exit 1 fi -if ! audio_prepare_debian_audio_environment 0; then - log_fail "$TESTNAME FAIL: Debian Audio environment preparation failed" +if ! audio_prepare_desktop_audio_environment 0; then + log_fail "$TESTNAME FAIL: desktop Audio environment preparation failed" echo "$TESTNAME FAIL" > "$RES_FILE" exit 1 fi @@ -398,7 +398,7 @@ fi # This testcase intentionally performs no PipeWire/PulseAudio runtime setup. # Do not call audio_prepare_overlay_runtime(), audio_restart_pipewire_service(), # or audio_restart_services_best_effort() here. Only the ALSA node validation -# helpers below are executed as the Debian Audio user. +# helpers below are executed as the prepared desktop Audio user. log_info "--------------------------------------------------------------------------" log_info "------------------- Starting $TESTNAME Testcase --------------------------" diff --git a/Runner/utils/audio_common.sh b/Runner/utils/audio_common.sh index defe114b..67c9fbba 100755 --- a/Runner/utils/audio_common.sh +++ b/Runner/utils/audio_common.sh @@ -341,9 +341,18 @@ audio_ensure_clip_ready() { } # ---------- dmesg + mixer dumps ---------- +# scan_audio_dmesg +# Capture an audio-focused kernel snapshot in the supplied evidence directory. +# Produces no machine-readable stdout and always returns success after the scan. scan_audio_dmesg() { - outdir="$1"; mods='snd|audio|pipewire|pulseaudio'; excl='dummy regulator|EEXIST|probe deferred' - scan_dmesg_errors "$mods" "$outdir" "$excl" || true + sad_output_dir="$1" + sad_modules='snd|asoc|audio|lpass|q6|codec|xrun|underrun|overrun|pipewire|pulseaudio' + sad_exclusions='dummy regulator|EEXIST|probe deferred' + + scan_dmesg_errors \ + "$sad_output_dir" \ + "$sad_modules" \ + "$sad_exclusions" || true } dump_mixers() { @@ -479,12 +488,12 @@ audio_restart_services_best_effort() { # Run PipeWire systemctl operations in the correct service scope. # -# Debian: +# Debian and CentOS: # Root orchestrator: # runuser -u debian -- systemctl --user ... # # Temporary migration compatibility: -# Existing runners that are still fully re-executed as the Debian Audio +# Existing runners that are still fully re-executed as the desktop Audio # user call systemctl --user directly. # # Yocto/qcom-distro/other: @@ -528,7 +537,7 @@ audio_pipewire_systemctl() { [ -n "$aps_os_id" ] || aps_os_id="unknown" case "$aps_os_id" in - debian) + debian|centos) ;; *) # Preserve existing native/Yocto system-service behavior. @@ -538,7 +547,7 @@ audio_pipewire_systemctl() { esac ########################################################################### - # Preferred Debian path: root orchestrator, command-level user execution + # Preferred desktop path: root orchestrator, command-level user execution ########################################################################### aps_current_uid="$(id -u 2>/dev/null || echo 1)" @@ -564,17 +573,22 @@ audio_pipewire_systemctl() { 1:*|*:1) ;; *) - log_fail "Debian PipeWire systemctl operation was invoked outside the prepared Audio user context" - log_fail "Run audio_prepare_debian_audio_environment before invoking PipeWire service operations" + log_fail "PipeWire systemctl operation was invoked outside the prepared Audio user context" + log_fail "Run audio_prepare_desktop_audio_environment before invoking PipeWire service operations" return 1 ;; esac - aps_expected_user="${AUDIO_TEST_USER:-debian}" + aps_expected_user="${AUDIO_TEST_USER:-}" aps_current_user="$(id -un 2>/dev/null || echo unknown)" + + if [ -z "$aps_expected_user" ]; then + log_fail "The prepared Audio test user is unavailable" + return 1 + fi if [ "$aps_current_user" != "$aps_expected_user" ]; then - log_fail "Unexpected Debian PipeWire service user: current=$aps_current_user expected=$aps_expected_user" + log_fail "Unexpected PipeWire service user: current=$aps_current_user expected=$aps_expected_user" return 1 fi @@ -604,7 +618,7 @@ audio_pipewire_systemctl() { # Prefer the invoking suite directory for test-owned scratch files. PWD covers # helpers re-sourced in a child shell where SCRIPT_DIR is not exported, while -# AUDIO_RECORD_USER_CAPTURE_DIR provides the prepared Debian-user workspace. +# AUDIO_RECORD_USER_CAPTURE_DIR provides the prepared desktop-user workspace. audio_scratch_dir() { for asd_candidate in \ "${SCRIPT_DIR:-}" \ @@ -3048,6 +3062,108 @@ audio_alsa_set_control_if_present() { cset "iface=MIXER,name=$aascip_name" "$aascip_value" >/dev/null 2>&1 } +# audio_write_little_endian +# Write an unsigned integer as binary little-endian bytes to stdout. +audio_write_little_endian() { + awle_value="$1" + awle_count="$2" + awle_index=0 + + while [ "$awle_index" -lt "$awle_count" ]; do + awle_byte=$((awle_value % 256)) + awle_escape="\\$(printf '%03o' "$awle_byte")" + printf '%b' "$awle_escape" + awle_value=$((awle_value / 256)) + awle_index=$((awle_index + 1)) + done +} + +# audio_generate_u8_stereo_wav [rate] [frequency] +# Generate a deterministic unsigned 8-bit stereo square-wave WAV. +audio_generate_u8_stereo_wav() { + agusw_output="$1" + agusw_seconds="$2" + agusw_rate="${3:-48000}" + agusw_frequency="${4:-1000}" + + if [ -z "$agusw_output" ] || + ! is_unsigned_number "$agusw_seconds" || + ! is_unsigned_number "$agusw_rate" || + ! is_unsigned_number "$agusw_frequency" || + [ "$agusw_seconds" -le 0 ] || + [ "$agusw_rate" -le 0 ] || + [ "$agusw_frequency" -le 0 ]; then + printf '%s\n' 'invalid WAV output, duration, rate, or frequency' >&2 + return 1 + fi + + agusw_half_period=$((agusw_rate / (agusw_frequency * 2))) + if [ "$agusw_half_period" -le 0 ]; then + printf '%s\n' \ + "tone frequency $agusw_frequency is too high for rate $agusw_rate" >&2 + return 1 + fi + + agusw_frames=$((agusw_seconds * agusw_rate)) + agusw_data_bytes=$((agusw_frames * 2)) + agusw_riff_bytes=$((36 + agusw_data_bytes)) + agusw_byte_rate=$((agusw_rate * 2)) + + rm -f "$agusw_output" + if ! { + printf 'RIFF' + audio_write_little_endian "$agusw_riff_bytes" 4 + printf 'WAVEfmt ' + audio_write_little_endian 16 4 + audio_write_little_endian 1 2 + audio_write_little_endian 2 2 + audio_write_little_endian "$agusw_rate" 4 + audio_write_little_endian "$agusw_byte_rate" 4 + audio_write_little_endian 2 2 + audio_write_little_endian 8 2 + printf 'data' + audio_write_little_endian "$agusw_data_bytes" 4 + audio_exec_with_timeout 15s env LC_ALL=C awk \ + -v frames="$agusw_frames" \ + -v half_period="$agusw_half_period" ' + BEGIN { + cycle_frames = half_period * 2 + cycle = "" + for (frame = 0; frame < cycle_frames; frame++) { + value = frame < half_period ? 80 : 176 + cycle = cycle sprintf("%c%c", value, value) + } + + cycles = int(frames / cycle_frames) + for (cycle_index = 0; cycle_index < cycles; cycle_index++) { + printf "%s", cycle + } + + remaining = frames - (cycles * cycle_frames) + for (frame = 0; frame < remaining; frame++) { + value = frame < half_period ? 80 : 176 + printf "%c%c", value, value + } + } + ' + } >"$agusw_output"; then + rm -f "$agusw_output" + printf '%s\n' "could not generate WAV reference: $agusw_output" >&2 + return 1 + fi + + agusw_expected_bytes=$((44 + agusw_data_bytes)) + agusw_actual_bytes="$(file_size_bytes "$agusw_output" 2>/dev/null || echo 0)" + if [ "$agusw_actual_bytes" -ne "$agusw_expected_bytes" ] 2>/dev/null; then + rm -f "$agusw_output" + printf '%s\n' \ + "WAV reference size mismatch: expected=$agusw_expected_bytes observed=${agusw_actual_bytes:-0}" >&2 + return 1 + fi + + return 0 +} + # List unique ALSA card indexes from procfs and the playback/capture inventories. audio_alsa_card_indexes() { { @@ -3296,23 +3412,43 @@ audio_playback_pick_alsa_sink() { audio_playback_alsa_probe() { ap_probe_dev="$(audio_playback_pick_alsa_sink)" if [ -z "$ap_probe_dev" ]; then + printf '%s\n' \ + '[ALSA-PROBE] no playback candidate was discovered from aplay inventory' >&2 return 1 fi ap_probe_card="$(audio_alsa_device_card "$ap_probe_dev")" + ap_probe_profile="generic" + if [ -n "$ap_probe_card" ]; then + ap_probe_profile="$(audio_alsa_playback_profile "$ap_probe_card")" + fi + + printf '%s\n' \ + "[ALSA-PROBE] candidate=$ap_probe_dev card=${ap_probe_card:-unknown} profile=$ap_probe_profile" >&2 audio_playback_alsa_prepare "$ap_probe_card" >/dev/null 2>&1 || true - if audio_exec_with_timeout 5s aplay -D "$ap_probe_dev" -t raw -f S16_LE -r 48000 -c 2 -d 1 /dev/zero >/dev/null 2>&1; then + printf '%s\n' \ + "[ALSA-PROBE] exec: aplay -D $ap_probe_dev -t raw -f S16_LE -r 48000 -c 2 -d 1 /dev/zero" >&2 + audio_exec_with_timeout 5s \ + aplay -D "$ap_probe_dev" -t raw -f S16_LE -r 48000 -c 2 -d 1 \ + /dev/zero >/dev/null + ap_probe_rc=$? + + if [ "$ap_probe_rc" -eq 0 ]; then + printf '%s\n' \ + "[ALSA-PROBE] playback candidate opened successfully: $ap_probe_dev" >&2 AUDIO_ALSA_PLAYBACK_DEVICE="$ap_probe_dev" export AUDIO_ALSA_PLAYBACK_DEVICE return 0 fi + printf '%s\n' \ + "[ALSA-PROBE] playback candidate failed: device=$ap_probe_dev exit_status=$ap_probe_rc" >&2 return 1 } # Print the successfully probed ALSA playback device for propagation from a -# Debian Audio-user subprocess back into the root orchestrator. +# desktop Audio-user subprocess back into the root orchestrator. audio_playback_alsa_probe_device() { if ! audio_playback_alsa_probe; then return 1 @@ -3341,8 +3477,16 @@ audio_alsa_playback_inventory_available() { # Probe direct ALSA playback in the prepared user context. When no playback PCM # exists, root may start one fully provisioned offline audio DSP and retry. audio_playback_probe_alsa_with_recovery() { + appawr_log="${AUDIO_ALSA_PLAYBACK_PROBE_LOG:-/dev/stderr}" + if [ "$appawr_log" != "/dev/stderr" ]; then + printf '\n%s\n' \ + "[ALSA-PROBE] attempt started at $(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + >>"$appawr_log" + fi + appawr_output="$( - audio_run_helper_as_test_user audio_playback_alsa_probe_device 2>/dev/null + audio_run_helper_as_test_user audio_playback_alsa_probe_device \ + 2>>"$appawr_log" )" appawr_rc=$? @@ -3356,6 +3500,9 @@ audio_playback_probe_alsa_with_recovery() { audio_run_helper_as_test_user audio_playback_pick_alsa_sink 2>/dev/null || true )" if [ -n "$appawr_candidate" ]; then + printf '%s\n' \ + "[ALSA-PROBE] candidate remains present after failed open: $appawr_candidate" \ + >>"$appawr_log" return 1 fi @@ -3368,9 +3515,16 @@ audio_playback_probe_alsa_with_recovery() { export AUDIO_DSP_RECOVERY_ATTEMPTED if ! audio_recover_offline_dsp; then + printf '%s\n' \ + "[ALSA-PROBE] DSP recovery was not performed: ${AUDIO_DSP_RECOVERY_REASON:-not applicable}" \ + >>"$appawr_log" return 1 fi + printf '%s\n' \ + '[ALSA-PROBE] offline audio DSP was started, retrying playback discovery' \ + >>"$appawr_log" + appawr_wait=0 while [ "$appawr_wait" -lt 10 ]; do if audio_alsa_playback_inventory_available; then @@ -3382,7 +3536,8 @@ audio_playback_probe_alsa_with_recovery() { done appawr_output="$( - audio_run_helper_as_test_user audio_playback_alsa_probe_device 2>/dev/null + audio_run_helper_as_test_user audio_playback_alsa_probe_device \ + 2>>"$appawr_log" )" appawr_rc=$? @@ -3538,7 +3693,11 @@ audio_record_alsa_capture_probe() { return 1 } +# audio_probe_alsa_capture_profile [device] +# Probe a supported capture format on one explicit device or discovered devices. +# shellcheck disable=SC2120 audio_probe_alsa_capture_profile() { + requested_capture_device="${1:-}" # shellcheck disable=SC2034 AUDIO_ALSA_CAPTURE_DEVICE="" # shellcheck disable=SC2034 @@ -3576,38 +3735,42 @@ audio_probe_alsa_capture_profile() { } probe_devices="" - cand="$(alsa_pick_capture 2>/dev/null || true)" - if [ -n "$cand" ]; then - probe_devices="$cand" - case "$cand" in - hw:*) - probe_devices="$probe_devices plughw:${cand#hw:}" - ;; - plughw:*) - probe_devices="$probe_devices hw:${cand#plughw:}" - ;; - esac - fi + if [ -n "$requested_capture_device" ]; then + probe_devices="$requested_capture_device" + else + cand="$(alsa_pick_capture 2>/dev/null || true)" + if [ -n "$cand" ]; then + probe_devices="$cand" + case "$cand" in + hw:*) + probe_devices="$probe_devices plughw:${cand#hw:}" + ;; + plughw:*) + probe_devices="$probe_devices hw:${cand#plughw:}" + ;; + esac + fi - extra_devices="$(sed -n 's/^\([0-9][0-9]*\)-\([0-9][0-9]*\):.*capture.*/hw:\1,\2/p' /proc/asound/pcm 2>/dev/null)" - if [ -n "$extra_devices" ]; then - for dev in $extra_devices; do - seen=0 - for existing in $probe_devices; do - if [ "$existing" = "$dev" ]; then - seen=1 - break + extra_devices="$(sed -n 's/^\([0-9][0-9]*\)-\([0-9][0-9]*\):.*capture.*/hw:\1,\2/p' /proc/asound/pcm 2>/dev/null)" + if [ -n "$extra_devices" ]; then + for dev in $extra_devices; do + seen=0 + for existing in $probe_devices; do + if [ "$existing" = "$dev" ]; then + seen=1 + break + fi + done + if [ "$seen" -eq 0 ]; then + probe_devices="$probe_devices $dev" + case "$dev" in + hw:*) + probe_devices="$probe_devices plughw:${dev#hw:}" + ;; + esac fi done - if [ "$seen" -eq 0 ]; then - probe_devices="$probe_devices $dev" - case "$dev" in - hw:*) - probe_devices="$probe_devices plughw:${dev#hw:}" - ;; - esac - fi - done + fi fi if [ -z "$probe_devices" ]; then @@ -4316,26 +4479,26 @@ audio_validate_dma_heap_access() { return 1 } -# Prepare and validate the Debian AudioReach overlay runtime. +# Prepare and validate the Debian or CentOS AudioReach overlay runtime. # # The preferred execution model keeps the test runner as root and executes only -# user-session or unprivileged device-access commands as the Debian Audio user. +# user-session or unprivileged device-access commands as the desktop Audio user. # # Args: # $1 - 0 for base Audio mode # 1 when --overlay was explicitly requested # -# Prerequisite for the preferred Debian root path: -# audio_prepare_debian_audio_environment 1 +# Prerequisite for the preferred desktop root path: +# audio_prepare_desktop_audio_environment 1 # # Platform behavior: -# Debian overlay: -# - validate dma-heap access as the Debian Audio user -# - validate ALSA node access as the Debian Audio user -# - validate PipeWire through the Debian user session +# Debian and CentOS overlay: +# - validate dma-heap access as the desktop Audio user +# - validate ALSA node access as the desktop Audio user +# - validate PipeWire through the desktop user session # - restart PipeWire only when required # -# Debian base: +# Debian and CentOS base: # - no-op # # Yocto/qcom-distro/other: @@ -4411,7 +4574,7 @@ audio_prepare_overlay_runtime() { [ -n "$apor_os_id" ] || apor_os_id="unknown" case "$apor_os_id" in - debian) + debian|centos) ;; *) # Preserve native/Yocto behavior. @@ -4456,12 +4619,12 @@ audio_prepare_overlay_runtime() { [ -z "${AUDIO_TEST_UID:-}" ] || [ -z "${AUDIO_TEST_RUNTIME_DIR:-}" ] || [ -z "${AUDIO_TEST_DBUS_ADDRESS:-}" ]; then - log_fail "Debian Audio environment has not been prepared" - log_fail "Call audio_prepare_debian_audio_environment 1 before overlay runtime preparation" + log_fail "Desktop Audio environment has not been prepared" + log_fail "Call audio_prepare_desktop_audio_environment 1 before overlay runtime preparation" return 1 fi - log_info "Preparing Debian AudioReach runtime from root orchestration" + log_info "Preparing desktop AudioReach runtime from root orchestration" else # Temporary compatibility until Playback and Record stop re-executing the # complete runner as the Debian user. @@ -4475,7 +4638,7 @@ audio_prepare_overlay_runtime() { ;; esac - log_info "Preparing Debian AudioReach runtime in temporary re-executed-user mode" + log_info "Preparing desktop AudioReach runtime in temporary re-executed-user mode" fi ########################################################################### @@ -4502,7 +4665,7 @@ audio_prepare_overlay_runtime() { fi ########################################################################### - # Validate dma-heap access as the Debian Audio user + # Validate dma-heap access as the desktop Audio user ########################################################################### if [ "$apor_user_command_mode" = "root-wrapper" ]; then @@ -4527,14 +4690,14 @@ audio_prepare_overlay_runtime() { fi if [ "$apor_dma_rc" -ne 0 ]; then - log_fail "Debian Audio user cannot read and write /dev/dma_heap/system" + log_fail "Desktop Audio user cannot read and write /dev/dma_heap/system" return 1 fi - log_pass "/dev/dma_heap/system is accessible to the Debian Audio user" + log_pass "/dev/dma_heap/system is accessible to the desktop Audio user" ########################################################################### - # Validate ALSA nodes as the Debian Audio user + # Validate ALSA nodes as the desktop Audio user ########################################################################### if [ ! -d /dev/snd ]; then @@ -4580,14 +4743,14 @@ audio_prepare_overlay_runtime() { case "$apor_alsa_rc" in 0) - log_pass "ALSA device nodes are accessible to the Debian Audio user" + log_pass "ALSA device nodes are accessible to the desktop Audio user" ;; 2) log_fail "No ALSA control or PCM device nodes were found under /dev/snd" return 1 ;; *) - log_fail "ALSA control and PCM nodes are not accessible to the Debian Audio user" + log_fail "ALSA control and PCM nodes are not accessible to the desktop Audio user" if command -v stat >/dev/null 2>&1; then for apor_snd_node in /dev/snd/*; do @@ -4685,7 +4848,7 @@ audio_prepare_overlay_runtime() { fi if ! audio_restart_pipewire_service "1/1"; then - log_fail "Failed to restart the Debian PipeWire user service" + log_fail "Failed to restart the desktop PipeWire user service" return 1 fi @@ -4723,7 +4886,7 @@ audio_prepare_overlay_runtime() { if [ "$apor_pipewire_service_ready" -eq 1 ] && [ "$apor_pipewire_control_ready" -eq 1 ]; then - log_pass "Debian AudioReach runtime is ready" + log_pass "Desktop AudioReach runtime is ready" return 0 fi @@ -5305,9 +5468,12 @@ audio_prepare_test_packages() { return 0 } -# Ensure complete playback and recording clients for one selected backend. -# Debian, Ubuntu, and CentOS use exact package-set mappings. Yocto and other -# image-managed environments retain their preinstalled userspace. +# audio_prepare_backend_client_packages +# Ensure the complete playback and recording client set for one selected +# backend. Debian, Ubuntu, and CentOS use exact package-set mappings. Yocto and +# other image-managed environments retain their preinstalled userspace. +# Successful preparation is cached per backend for the current runner process. +# Produces no machine-readable stdout. Returns 0 when ready and 1 on failure. audio_prepare_backend_client_packages() { apbcp_backend="$1" @@ -5321,6 +5487,12 @@ audio_prepare_backend_client_packages() { ;; esac + case " ${AUDIO_PREPARED_CLIENT_BACKENDS:-} " in + *" $apbcp_backend "*) + return 0 + ;; + esac + if command -v pkg_detect_os_id >/dev/null 2>&1; then apbcp_os_id="$(pkg_detect_os_id 2>/dev/null || echo unknown)" else @@ -5337,12 +5509,14 @@ audio_prepare_backend_client_packages() { case "$apbcp_os_id" in qcom-distro|poky|openembedded|oe) log_info "Native image uses preinstalled $apbcp_backend Audio clients" + AUDIO_PREPARED_CLIENT_BACKENDS="${AUDIO_PREPARED_CLIENT_BACKENDS:+$AUDIO_PREPARED_CLIENT_BACKENDS }$apbcp_backend" return 0 ;; debian|ubuntu|centos) ;; *) log_info "Audio backend client package recovery is not enabled, os=$apbcp_os_id backend=$apbcp_backend" + AUDIO_PREPARED_CLIENT_BACKENDS="${AUDIO_PREPARED_CLIENT_BACKENDS:+$AUDIO_PREPARED_CLIENT_BACKENDS }$apbcp_backend" return 0 ;; esac @@ -5365,10 +5539,143 @@ audio_prepare_backend_client_packages() { fi log_pass "Audio backend client package set is ready, os=$apbcp_os_id backend=$apbcp_backend" + AUDIO_PREPARED_CLIENT_BACKENDS="${AUDIO_PREPARED_CLIENT_BACKENDS:+$AUDIO_PREPARED_CLIENT_BACKENDS }$apbcp_backend" return 0 } -# Prepare the Debian Audio test user and optional systemd user manager. +# Return success when a user account is suitable for desktop Audio execution. +audio_user_is_regular() { + auir_user="$1" + + if [ -z "$auir_user" ] || [ "$auir_user" = "root" ]; then + return 1 + fi + + if ! id "$auir_user" >/dev/null 2>&1; then + return 1 + fi + + if command -v getent >/dev/null 2>&1; then + auir_entry="$(getent passwd "$auir_user" 2>/dev/null | sed -n '1p')" + else + auir_entry="$( + awk -F: -v requested_user="$auir_user" \ + '$1 == requested_user { print; exit }' \ + /etc/passwd 2>/dev/null + )" + fi + + auir_uid="$(printf '%s\n' "$auir_entry" | awk -F: 'NR == 1 { print $3 }')" + auir_shell="$(printf '%s\n' "$auir_entry" | awk -F: 'NR == 1 { print $7 }')" + + case "$auir_uid" in + ''|*[!0-9]*) + return 1 + ;; + esac + + if [ "$auir_uid" -eq 0 ] || [ "$auir_uid" -ge 65534 ]; then + return 1 + fi + + case "$auir_shell" in + */false|*/nologin) + return 1 + ;; + esac + + return 0 +} + +# Print the regular user that owns the active desktop session. If no active +# session is visible, use the only regular login account on the image. +# Diagnostics must remain on stderr because callers use command substitution. +audio_find_regular_session_user() { + afrsu_user="${AUDIO_TEST_USER:-}" + afrsu_sessions="" + afrsu_runtime_dir="" + afrsu_uid="" + afrsu_candidates="" + afrsu_count=0 + + if audio_user_is_regular "$afrsu_user"; then + printf '%s\n' "$afrsu_user" + return 0 + fi + + if command -v loginctl >/dev/null 2>&1; then + afrsu_sessions="$( + loginctl list-sessions --no-legend 2>/dev/null | + awk '{ print $1 }' + )" + + for afrsu_session in $afrsu_sessions; do + afrsu_active="$( + loginctl show-session "$afrsu_session" -p Active --value 2>/dev/null + )" + [ "$afrsu_active" = "yes" ] || continue + + afrsu_user="$( + loginctl show-session "$afrsu_session" -p Name --value 2>/dev/null + )" + if audio_user_is_regular "$afrsu_user"; then + printf '%s\n' "$afrsu_user" + return 0 + fi + done + fi + + for afrsu_runtime_dir in /run/user/[0-9]*; do + [ -d "$afrsu_runtime_dir" ] || continue + + afrsu_uid="${afrsu_runtime_dir##*/}" + case "$afrsu_uid" in + ''|*[!0-9]*) + continue + ;; + esac + + if command -v getent >/dev/null 2>&1; then + afrsu_user="$( + getent passwd "$afrsu_uid" 2>/dev/null | + awk -F: 'NR == 1 { print $1 }' + )" + else + afrsu_user="$( + awk -F: -v requested_uid="$afrsu_uid" \ + '$3 == requested_uid { print $1; exit }' \ + /etc/passwd 2>/dev/null + )" + fi + + if audio_user_is_regular "$afrsu_user"; then + printf '%s\n' "$afrsu_user" + return 0 + fi + done + + if command -v getent >/dev/null 2>&1; then + afrsu_candidates="$( + getent passwd 2>/dev/null | + awk -F: '$3 >= 1000 && $3 < 65534 && $7 !~ /(false|nologin)$/ { print $1 }' + )" + else + afrsu_candidates="$( + awk -F: '$3 >= 1000 && $3 < 65534 && $7 !~ /(false|nologin)$/ { print $1 }' \ + /etc/passwd 2>/dev/null + )" + fi + + afrsu_count="$(printf '%s\n' "$afrsu_candidates" | sed '/^$/d' | wc -l | tr -d '[:space:]')" + if [ "$afrsu_count" -eq 1 ] 2>/dev/null; then + printf '%s\n' "$afrsu_candidates" + return 0 + fi + + return 1 +} + +# Prepare a Debian or CentOS Audio test user and optional systemd user manager. # # Unlike audio_prepare_debian_audio_test_user(), this function does not: # - re-execute the complete runner @@ -5377,14 +5684,15 @@ audio_prepare_backend_client_packages() { # - invoke runuser # # The calling test remains the root orchestrator. Individual audio commands -# will be executed as the Debian Audio user through audio_run_as_test_user(). +# will be executed as the prepared desktop Audio user through +# audio_run_as_test_user(). # # Args: # $1 - 1 when a PipeWire systemd user manager is required # 0 for ALSA-only operations # # Platform behavior: -# Debian: +# Debian and CentOS: # - require root orchestration # - ensure AUDIO_TEST_USER belongs to audio # - optionally start or refresh user@UID.service @@ -5393,7 +5701,7 @@ audio_prepare_backend_client_packages() { # Yocto/qcom-distro/other: # - strict no-op # -# Environment exported on Debian: +# Environment exported on applicable desktop distributions: # AUDIO_TEST_USER # AUDIO_TEST_UID # AUDIO_TEST_HOME @@ -5404,7 +5712,7 @@ audio_prepare_backend_client_packages() { # Returns: # 0 - preparation completed or not applicable # 1 - preparation failed -audio_prepare_debian_audio_environment() { +audio_prepare_desktop_audio_environment() { apdae_need_user_manager="${1:-0}" case "$apdae_need_user_manager" in @@ -5437,7 +5745,7 @@ audio_prepare_debian_audio_environment() { [ -n "$apdae_os_id" ] || apdae_os_id="unknown" case "$apdae_os_id" in - debian) + debian|centos) ;; qcom-distro|poky|openembedded|oe) return 0 @@ -5452,24 +5760,44 @@ audio_prepare_debian_audio_environment() { ########################################################################### if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then - log_fail "Debian Audio environment preparation must run as root" + log_fail "Desktop Audio environment preparation must run as root" return 1 fi - apdae_user="${AUDIO_TEST_USER:-debian}" + case "$apdae_os_id" in + debian) + apdae_default_user="debian" + ;; + centos) + apdae_default_user="$(audio_find_regular_session_user 2>/dev/null || true)" + ;; + esac + + apdae_user="${AUDIO_TEST_USER:-$apdae_default_user}" + + if [ -z "$apdae_user" ]; then + log_fail "No regular desktop Audio user could be discovered on os=$apdae_os_id" + log_fail "Start a regular user session before running the Audio test" + return 1 + fi if ! id "$apdae_user" >/dev/null 2>&1; then - log_fail "Debian Audio test user does not exist: $apdae_user" + log_fail "Desktop Audio test user does not exist: $apdae_user" + return 1 + fi + + if ! audio_user_is_regular "$apdae_user"; then + log_fail "Desktop Audio test user is not a regular login account: $apdae_user" return 1 fi if command -v getent >/dev/null 2>&1; then if ! getent group audio >/dev/null 2>&1; then - log_fail "Required Debian group does not exist: audio" + log_fail "Required desktop Audio group does not exist: audio" return 1 fi elif ! grep -q '^audio:' /etc/group 2>/dev/null; then - log_fail "Required Debian group does not exist: audio" + log_fail "Required desktop Audio group does not exist: audio" return 1 fi @@ -5489,7 +5817,7 @@ audio_prepare_debian_audio_environment() { return 1 fi - log_info "Adding Debian Audio test user to audio group: $apdae_user" + log_info "Adding desktop Audio test user to audio group: $apdae_user" if ! usermod -aG audio "$apdae_user"; then log_fail "Failed to add $apdae_user to the audio group" @@ -5561,7 +5889,7 @@ audio_prepare_debian_audio_environment() { if systemctl is-active --quiet "$apdae_user_unit"; then if [ "$apdae_group_changed" -eq 1 ]; then - log_info "Restarting Debian user manager after audio-group update: $apdae_user_unit" + log_info "Restarting desktop user manager after audio-group update: $apdae_user_unit" if command -v audio_exec_with_timeout >/dev/null 2>&1; then audio_exec_with_timeout \ @@ -5574,14 +5902,14 @@ audio_prepare_debian_audio_environment() { fi if [ "$apdae_systemctl_rc" -ne 0 ]; then - log_fail "Failed to restart Debian user manager: $apdae_user_unit" + log_fail "Failed to restart desktop user manager: $apdae_user_unit" return 1 fi else - log_pass "Debian user manager is already active: $apdae_user_unit" + log_pass "Desktop user manager is already active: $apdae_user_unit" fi else - log_info "Starting Debian user manager: $apdae_user_unit" + log_info "Starting desktop user manager: $apdae_user_unit" systemctl reset-failed \ "$apdae_user_unit" \ @@ -5598,7 +5926,7 @@ audio_prepare_debian_audio_environment() { fi if [ "$apdae_systemctl_rc" -ne 0 ]; then - log_fail "Failed to start Debian user manager: $apdae_user_unit" + log_fail "Failed to start desktop user manager: $apdae_user_unit" systemctl status \ "$apdae_user_unit" \ @@ -5622,7 +5950,7 @@ audio_prepare_debian_audio_environment() { ;; esac - log_info "Waiting for Debian user runtime and D-Bus, timeout=${apdae_wait_timeout}s" + log_info "Waiting for desktop user runtime and D-Bus, timeout=${apdae_wait_timeout}s" while [ "$apdae_wait" -lt "$apdae_wait_timeout" ]; do if [ -d "$apdae_runtime_dir" ] && @@ -5635,16 +5963,16 @@ audio_prepare_debian_audio_environment() { done if [ ! -d "$apdae_runtime_dir" ]; then - log_fail "Debian user runtime directory is unavailable: $apdae_runtime_dir" + log_fail "Desktop user runtime directory is unavailable: $apdae_runtime_dir" return 1 fi if [ ! -S "$apdae_runtime_dir/bus" ]; then - log_fail "Debian user D-Bus is unavailable: $apdae_runtime_dir/bus" + log_fail "Desktop user D-Bus is unavailable: $apdae_runtime_dir/bus" return 1 fi - log_pass "Debian user runtime is ready: $apdae_runtime_dir" + log_pass "Desktop user runtime is ready: $apdae_runtime_dir" fi ########################################################################### @@ -5665,14 +5993,20 @@ audio_prepare_debian_audio_environment() { export AUDIO_TEST_DBUS_ADDRESS export AUDIO_SYSTEMCTL_USER_SCOPE - log_pass "Debian Audio environment prepared: user=$apdae_user uid=$apdae_uid" + log_pass "Desktop Audio environment prepared: os=$apdae_os_id user=$apdae_user uid=$apdae_uid" return 0 } -# Execute one Audio command as the configured Debian Audio user. +# Compatibility wrapper for existing callers while suites migrate to the +# distribution-neutral helper name. +audio_prepare_debian_audio_environment() { + audio_prepare_desktop_audio_environment "$@" +} + +# Execute one Audio command as the configured desktop Audio user. # # The main test runner remains the root orchestrator. Only the supplied command -# is executed as the Debian user. +# is executed as the prepared user. # # Usage: # audio_run_as_test_user command [args...] @@ -5749,7 +6083,7 @@ audio_find_desktop_audio_user() { } # Platform behavior: -# Debian: +# Debian and CentOS: # - require the caller to be root # - verify the configured Audio user and audio-group membership # - execute only the supplied command through runuser @@ -5761,8 +6095,8 @@ audio_find_desktop_audio_user() { # Yocto/qcom-distro/other: # - execute the command directly as the current user # -# Prerequisite on Debian: -# audio_prepare_debian_audio_environment must run before this helper. +# Prerequisite on Debian and CentOS: +# audio_prepare_desktop_audio_environment must run before this helper. # # Returns: # The exact exit status of the supplied command. @@ -5805,7 +6139,7 @@ audio_run_as_test_user() { [ -n "$aratu_os_id" ] || aratu_os_id="unknown" case "$aratu_os_id" in - debian) + debian|centos) aratu_require_audio_group=1 ;; ubuntu) @@ -5824,8 +6158,14 @@ audio_run_as_test_user() { ;; esac + if [ "${AUDIO_TEST_USER_REEXEC:-0}" -eq 1 ] && + [ "$(id -un 2>/dev/null || echo unknown)" = "${AUDIO_TEST_USER:-}" ]; then + "$@" + return $? + fi + ########################################################################### - # Debian root orchestration validation + # Desktop root orchestration validation ########################################################################### if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then @@ -5845,7 +6185,11 @@ audio_run_as_test_user() { return 1 fi else - aratu_user="${AUDIO_TEST_USER:-debian}" + aratu_user="${AUDIO_TEST_USER:-}" + if [ -z "$aratu_user" ]; then + log_fail "The prepared Audio test user is unavailable" + return 1 + fi fi if ! id "$aratu_user" >/dev/null 2>&1; then @@ -5857,8 +6201,8 @@ audio_run_as_test_user() { ! id -nG "$aratu_user" 2>/dev/null | tr ' ' '\n' | grep -qx audio; then - log_fail "Debian Audio test user is not a member of audio: $aratu_user" - log_fail "Call audio_prepare_debian_audio_environment before running user commands" + log_fail "Desktop Audio test user is not a member of audio: $aratu_user" + log_fail "Call audio_prepare_desktop_audio_environment before running user commands" return 1 fi @@ -5925,18 +6269,18 @@ audio_run_as_test_user() { if [ "$aratu_require_session" -eq 1 ]; then if [ ! -d "$aratu_runtime_dir" ]; then - log_fail "Debian Audio user runtime directory is unavailable: $aratu_runtime_dir" + log_fail "Desktop Audio user runtime directory is unavailable: $aratu_runtime_dir" return 1 fi if [ ! -S "$aratu_runtime_dir/bus" ]; then - log_fail "Debian Audio user D-Bus socket is unavailable: $aratu_runtime_dir/bus" + log_fail "Desktop Audio user D-Bus socket is unavailable: $aratu_runtime_dir/bus" return 1 fi fi ########################################################################### - # Execute only the requested command as the Debian Audio user + # Execute only the requested command as the desktop Audio user ########################################################################### case "${VERBOSE:-0}" in @@ -5967,7 +6311,7 @@ audio_run_as_test_user() { return $? } -# Execute one existing audio_common.sh helper as the Debian Audio user. +# Execute one existing audio_common.sh helper as the desktop Audio user. # # This preserves helper/library reuse while keeping the main test runner as the # root orchestrator. On native/Yocto systems, the already-sourced helper is @@ -6016,7 +6360,7 @@ audio_run_helper_as_test_user() { [ -n "$arhatu_os_id" ] || arhatu_os_id="unknown" case "$arhatu_os_id" in - debian) + debian|centos) ;; ubuntu) if [ "${AUDIO_USE_DESKTOP_SESSION:-0}" -ne 1 ] || @@ -6089,7 +6433,7 @@ audio_run_helper_as_test_user() { return $? } -# Execute one command through audio_exec_with_timeout as the Debian Audio user. +# Execute one command through audio_exec_with_timeout as the desktop Audio user. # Root opens any surrounding redirection before this helper is called, so test # logs and result files remain root-owned. # @@ -6136,13 +6480,13 @@ audio_run_with_timeout_as_test_user() { # audio_run_with_timeout_as_test_user(). # # These helpers are intentionally shared because they implement backend recovery, -# Debian-user capture workspace handling, mixer collection, and ALSA profile +# desktop-user capture workspace handling, mixer collection, and ALSA profile # propagation. They do not parse AudioRecord CLI options or emit final results. audio_record_restart_backend_best_effort() { arbbe_backend="$1" - if [ "${AUDIO_RECORD_DEBIAN_ROOT_MODE:-0}" -ne 1 ]; then + if [ "${AUDIO_RECORD_DESKTOP_ROOT_MODE:-0}" -ne 1 ]; then audio_restart_services_best_effort return $? fi @@ -6162,10 +6506,10 @@ audio_record_restart_backend_best_effort() { esac } -# On Debian, manual daemon bootstrap is replaced by one user-service recovery +# On desktop user-mode systems, manual daemon bootstrap is replaced by one user-service recovery # attempt. Native and minimal Yocto images retain their existing bootstrap path. audio_record_bootstrap_backend_if_needed() { - if [ "${AUDIO_RECORD_DEBIAN_ROOT_MODE:-0}" -ne 1 ]; then + if [ "${AUDIO_RECORD_DESKTOP_ROOT_MODE:-0}" -ne 1 ]; then audio_bootstrap_backend_if_needed return $? fi @@ -6185,10 +6529,10 @@ audio_record_bootstrap_backend_if_needed() { esac } -# Record whether a recovered backend is managed by the Debian user manager or +# Record whether a recovered backend is managed by the desktop user manager or # by the native/minimal-image bootstrap path. audio_record_set_recovered_backend_management() { - if [ "${AUDIO_RECORD_DEBIAN_ROOT_MODE:-0}" -eq 1 ]; then + if [ "${AUDIO_RECORD_DESKTOP_ROOT_MODE:-0}" -eq 1 ]; then AUDIO_SYSTEMD_MANAGED=1 else AUDIO_SYSTEMD_MANAGED=0 @@ -6197,13 +6541,13 @@ audio_record_set_recovered_backend_management() { export AUDIO_SYSTEMD_MANAGED } -# Resolve the root-owned final WAV and the Debian-user scratch WAV for one case. +# Resolve the root-owned final WAV and the desktop-user scratch WAV for one case. audio_record_set_capture_paths() { arcsp_case_name="$1" record_out="$LOGDIR/${arcsp_case_name}.wav" - if [ "${AUDIO_RECORD_DEBIAN_ROOT_MODE:-0}" -eq 1 ]; then + if [ "${AUDIO_RECORD_DESKTOP_ROOT_MODE:-0}" -eq 1 ]; then record_user_out="$AUDIO_RECORD_USER_CAPTURE_DIR/${arcsp_case_name}.wav" else record_user_out="$record_out" @@ -6220,7 +6564,7 @@ audio_record_capture_size() { file_size_bytes "$record_user_out" 2>/dev/null || echo 0 } -# Move the Debian-user scratch WAV into the root-owned final results directory. +# Move the desktop-user scratch WAV into the root-owned final results directory. # When no scratch file was produced, create an empty final file so the existing # WAV validator can report the backend failure consistently. audio_record_promote_capture_output() { @@ -6259,7 +6603,7 @@ audio_record_promote_capture_output() { audio_record_dump_mixers() { ardm_out="$1" - if [ "${AUDIO_RECORD_DEBIAN_ROOT_MODE:-0}" -ne 1 ]; then + if [ "${AUDIO_RECORD_DESKTOP_ROOT_MODE:-0}" -ne 1 ]; then dump_mixers "$ardm_out" return $? fi @@ -6302,8 +6646,10 @@ audio_record_dump_mixers() { # Run one capture probe in the correct user context and copy its profile into # the root orchestrator. audio_record_probe_alsa_capture_profile_once() { - if [ "${AUDIO_RECORD_DEBIAN_ROOT_MODE:-0}" -ne 1 ]; then - audio_probe_alsa_capture_profile + arpacpo_requested_device="${1:-}" + + if [ "${AUDIO_RECORD_DESKTOP_ROOT_MODE:-0}" -ne 1 ]; then + audio_probe_alsa_capture_profile "$arpacpo_requested_device" return $? fi @@ -6315,7 +6661,7 @@ audio_record_probe_alsa_capture_profile_once() { . "$TOOLS/functestlib.sh" . "$TOOLS/audio_common.sh" - audio_probe_alsa_capture_profile >/dev/null 2>&1 + audio_probe_alsa_capture_profile "$1" >/dev/null 2>&1 probe_rc=$? printf "%s\n" \ @@ -6326,7 +6672,9 @@ audio_record_probe_alsa_capture_profile_once() { "${AUDIO_ALSA_CAPTURE_REASON:-}" exit "$probe_rc" - ' + ' \ + sh \ + "$arpacpo_requested_device" )" arpacp_rc=$? @@ -6363,7 +6711,9 @@ audio_record_probe_alsa_capture_profile_once() { # Retry capture discovery after a narrowly guarded audio-DSP startup. Recovery # is attempted once and only when ALSA exposes no capture PCM at all. audio_record_probe_alsa_capture_profile() { - if audio_record_probe_alsa_capture_profile_once; then + arpacp_requested_device="${1:-}" + + if audio_record_probe_alsa_capture_profile_once "$arpacp_requested_device"; then return 0 fi @@ -6395,7 +6745,7 @@ audio_record_probe_alsa_capture_profile() { arpacp_wait=$((arpacp_wait + 1)) done - if audio_record_probe_alsa_capture_profile_once; then + if audio_record_probe_alsa_capture_profile_once "$arpacp_requested_device"; then return 0 fi @@ -6408,8 +6758,8 @@ audio_record_probe_alsa_capture_profile() { return 1 } -# Prepare the Debian Audio test user and re-execute the current test as that -# user. +# Prepare the Debian or CentOS Audio test user and re-execute the current test +# as that user. # # Package preparation must complete successfully before calling this function. # This helper does not install packages. @@ -6423,7 +6773,7 @@ audio_record_probe_alsa_capture_profile() { # $5... - original run.sh arguments # # Platform behavior: -# Debian: +# Debian and CentOS: # - initial process must run as root # - ensures AUDIO_TEST_USER belongs to the audio group # - prepares test-owned result/output paths @@ -6435,7 +6785,8 @@ audio_record_probe_alsa_capture_profile() { # # Environment: # AUDIO_TEST_USER -# Debian test user; default: debian +# Desktop Audio test user. Debian defaults to `debian`; CentOS discovers the +# active regular user dynamically. # # AUDIO_TEST_USER_REEXEC # Internal recursion guard. @@ -6447,13 +6798,13 @@ audio_record_probe_alsa_capture_profile() { # Selects systemctl --user for PipeWire service operations. # # Returns: -# 0 - not applicable or already executing as the prepared Debian user +# 0 - not applicable or already executing as the prepared desktop Audio user # 1 - user, output-path, or user-manager preparation failed # # A successful root-to-user transition uses exec and therefore does not return. -audio_prepare_debian_audio_test_user() { +audio_prepare_desktop_audio_test_user() { if [ "$#" -lt 4 ]; then - log_fail "audio_prepare_debian_audio_test_user requires runner, result file, log directory, and user-manager mode" + log_fail "audio_prepare_desktop_audio_test_user requires runner, result file, log directory, and user-manager mode" return 1 fi @@ -6485,14 +6836,14 @@ audio_prepare_debian_audio_test_user() { [ -n "$apdatu_os_id" ] || apdatu_os_id="unknown" case "$apdatu_os_id" in - debian) + debian|centos) ;; qcom-distro|poky|openembedded|oe) # Preserve all native-image execution behavior. return 0 ;; *) - # User switching is intentionally limited to verified Debian images. + # Preserve existing execution on Ubuntu and native/minimal images. return 0 ;; esac @@ -6506,12 +6857,21 @@ audio_prepare_debian_audio_test_user() { ;; esac - apdatu_user="${AUDIO_TEST_USER:-debian}" + case "$apdatu_os_id" in + debian) + apdatu_default_user="debian" + ;; + centos) + apdatu_default_user="$(audio_find_regular_session_user 2>/dev/null || true)" + ;; + esac + + apdatu_user="${AUDIO_TEST_USER:-$apdatu_default_user}" apdatu_current_uid="$(id -u 2>/dev/null || echo 1)" apdatu_current_user="$(id -un 2>/dev/null || echo unknown)" ########################################################################### - # Re-executed Debian child + # Re-executed desktop Audio child ########################################################################### if [ "${AUDIO_TEST_USER_REEXEC:-0}" -eq 1 ]; then @@ -6523,14 +6883,14 @@ audio_prepare_debian_audio_test_user() { if ! id -nG "$apdatu_user" 2>/dev/null | tr ' ' '\n' | grep -qx audio; then - log_fail "Debian Audio test user is not a member of audio: $apdatu_user" + log_fail "Desktop Audio test user is not a member of audio: $apdatu_user" return 1 fi apdatu_uid="$(id -u "$apdatu_user" 2>/dev/null || true)" if [ -z "$apdatu_uid" ]; then - log_fail "Unable to resolve uid for Debian Audio test user: $apdatu_user" + log_fail "Unable to resolve uid for desktop Audio test user: $apdatu_user" return 1 fi @@ -6546,26 +6906,26 @@ audio_prepare_debian_audio_test_user() { if [ "$apdatu_need_user_manager" -eq 1 ]; then if [ ! -d "$XDG_RUNTIME_DIR" ]; then - log_fail "Debian user runtime directory is missing: $XDG_RUNTIME_DIR" + log_fail "Desktop user runtime directory is missing: $XDG_RUNTIME_DIR" return 1 fi if [ ! -S "$XDG_RUNTIME_DIR/bus" ]; then - log_fail "Debian user D-Bus is unavailable: $XDG_RUNTIME_DIR/bus" + log_fail "Desktop user D-Bus is unavailable: $XDG_RUNTIME_DIR/bus" return 1 fi fi - log_pass "Audio test is running as Debian user: user=$apdatu_user uid=$apdatu_uid" + log_pass "Audio test is running as desktop user: os=$apdatu_os_id user=$apdatu_user uid=$apdatu_uid" return 0 fi ########################################################################### - # Initial Debian process must be root + # Initial desktop-distribution process must be root ########################################################################### if [ "$apdatu_current_uid" -ne 0 ]; then - log_fail "Debian Audio tests must initially run as root" + log_fail "Desktop Audio tests must initially run as root, os=$apdatu_os_id" log_fail "Package preparation and Audio user setup require root privileges" return 1 fi @@ -6581,17 +6941,22 @@ audio_prepare_debian_audio_test_user() { fi if ! id "$apdatu_user" >/dev/null 2>&1; then - log_fail "Debian Audio test user does not exist: $apdatu_user" + log_fail "Desktop Audio test user does not exist: $apdatu_user" + return 1 + fi + + if ! audio_user_is_regular "$apdatu_user"; then + log_fail "Desktop Audio test user is not a regular login account: $apdatu_user" return 1 fi if command -v getent >/dev/null 2>&1; then if ! getent group audio >/dev/null 2>&1; then - log_fail "Required Debian group does not exist: audio" + log_fail "Required desktop Audio group does not exist: audio" return 1 fi elif ! grep -q '^audio:' /etc/group 2>/dev/null; then - log_fail "Required Debian group does not exist: audio" + log_fail "Required desktop Audio group does not exist: audio" return 1 fi @@ -6611,7 +6976,7 @@ audio_prepare_debian_audio_test_user() { return 1 fi - log_info "Adding Debian Audio test user to audio group: $apdatu_user" + log_info "Adding desktop Audio test user to audio group: $apdatu_user" if ! usermod -aG audio "$apdatu_user"; then log_fail "Failed to add $apdatu_user to the audio group" @@ -6633,7 +6998,7 @@ audio_prepare_debian_audio_test_user() { apdatu_uid="$(id -u "$apdatu_user" 2>/dev/null || true)" if [ -z "$apdatu_uid" ]; then - log_fail "Unable to resolve uid for Debian Audio test user: $apdatu_user" + log_fail "Unable to resolve uid for desktop Audio test user: $apdatu_user" return 1 fi @@ -6757,7 +7122,7 @@ audio_prepare_debian_audio_test_user() { if [ "$apdatu_group_changed" -eq 1 ]; then # The existing user manager was started before the audio-group update. # Restart it so PipeWire inherits the new supplementary group. - log_info "Restarting Debian user manager after audio-group update: $apdatu_user_unit" + log_info "Restarting desktop user manager after audio-group update: $apdatu_user_unit" if command -v audio_exec_with_timeout >/dev/null 2>&1; then audio_exec_with_timeout \ @@ -6770,14 +7135,14 @@ audio_prepare_debian_audio_test_user() { fi if [ "$apdatu_systemctl_rc" -ne 0 ]; then - log_fail "Failed to restart Debian user manager: $apdatu_user_unit" + log_fail "Failed to restart desktop user manager: $apdatu_user_unit" return 1 fi else - log_pass "Debian user manager is already active: $apdatu_user_unit" + log_pass "Desktop user manager is already active: $apdatu_user_unit" fi else - log_info "Starting Debian user manager: $apdatu_user_unit" + log_info "Starting desktop user manager: $apdatu_user_unit" # Clear a previous failed state before attempting a new start. systemctl reset-failed "$apdatu_user_unit" 2>/dev/null || true @@ -6793,7 +7158,7 @@ audio_prepare_debian_audio_test_user() { fi if [ "$apdatu_systemctl_rc" -ne 0 ]; then - log_fail "Failed to start Debian user manager: $apdatu_user_unit" + log_fail "Failed to start desktop user manager: $apdatu_user_unit" systemctl status \ "$apdatu_user_unit" \ @@ -6819,7 +7184,7 @@ audio_prepare_debian_audio_test_user() { ;; esac - log_info "Waiting for Debian user runtime and D-Bus, timeout=${apdatu_wait_timeout}s" + log_info "Waiting for desktop user runtime and D-Bus, timeout=${apdatu_wait_timeout}s" while [ "$apdatu_wait" -lt "$apdatu_wait_timeout" ]; do if [ -d "$apdatu_runtime_dir" ] && @@ -6832,23 +7197,23 @@ audio_prepare_debian_audio_test_user() { done if [ ! -d "$apdatu_runtime_dir" ]; then - log_fail "Debian user runtime directory is unavailable: $apdatu_runtime_dir" + log_fail "Desktop user runtime directory is unavailable: $apdatu_runtime_dir" return 1 fi if [ ! -S "$apdatu_bus_path" ]; then - log_fail "Debian user D-Bus is unavailable: $apdatu_bus_path" + log_fail "Desktop user D-Bus is unavailable: $apdatu_bus_path" return 1 fi - log_pass "Debian user runtime is ready: $apdatu_runtime_dir" + log_pass "Desktop user runtime is ready: $apdatu_runtime_dir" else apdatu_runtime_dir="/run/user/$apdatu_uid" apdatu_bus_path="$apdatu_runtime_dir/bus" fi ########################################################################### - # Re-execute the test as the Debian Audio user + # Re-execute the test as the desktop Audio user ########################################################################### if ! command -v runuser >/dev/null 2>&1; then @@ -6862,6 +7227,16 @@ audio_prepare_debian_audio_test_user() { AUDIO_PACKAGE_PREPARED=1 AUDIO_SYSTEMCTL_USER_SCOPE=1 AUDIO_TEST_USER_MANAGER_REQUIRED="$apdatu_need_user_manager" + TEST_RESULT_FILE_PREPARED=1 + + # Keep the stdout/stderr capture opened by the privileged launcher. The + # unprivileged child inherits those descriptors and must not create another + # suite-directory log. Unlinking the FIFO is safe while both ends remain + # open and prevents leaving a stale /tmp entry after exec replaces this shell. + apdatu_stdout_active="${__RUN_STDOUT_ACTIVE:-}" + if [ -n "$apdatu_stdout_active" ] && [ -n "${PIPE:-}" ]; then + rm -f "$PIPE" 2>/dev/null || true + fi export AUDIO_TEST_USER export AUDIO_TEST_UID @@ -6869,6 +7244,7 @@ audio_prepare_debian_audio_test_user() { export AUDIO_PACKAGE_PREPARED export AUDIO_SYSTEMCTL_USER_SCOPE export AUDIO_TEST_USER_MANAGER_REQUIRED + export TEST_RESULT_FILE_PREPARED log_info "Re-executing Audio test as user=$apdatu_user uid=$apdatu_uid" @@ -6895,10 +7271,18 @@ audio_prepare_debian_audio_test_user() { AUDIO_TEST_USER_MANAGER_REQUIRED="$apdatu_need_user_manager" \ AUDIO_OVERLAY_PACKAGES_CHANGED="${AUDIO_OVERLAY_PACKAGES_CHANGED:-0}" \ AUDIO_OVERLAY_REBOOT_REQUIRED="${AUDIO_OVERLAY_REBOOT_REQUIRED:-0}" \ + TEST_RESULT_FILE_PREPARED=1 \ + __RUN_STDOUT_ACTIVE="$apdatu_stdout_active" \ + LOGDIR="$apdatu_log_dir" \ "$apdatu_script" \ "$@" } +# Compatibility wrapper for callers using the original Debian-specific name. +audio_prepare_debian_audio_test_user() { + audio_prepare_desktop_audio_test_user "$@" +} + # Minimal WAV fallback for images without Python. It validates RIFF/WAVE/fmt/data, # requested rate/channels, bounded duration, and signed PCM sample activity. # WAVE_FORMAT_EXTENSIBLE PCM is supported. Float WAV validation requires Python. @@ -6908,10 +7292,14 @@ audio_validate_recorded_wav_od() { avrwo_rate="$3" avrwo_channels="$4" avrwo_expected_seconds="$5" + avrwo_expected_bits="${6:-0}" + avrwo_scope="${7:-record}" avrwo_analyze_bytes="${AUDIO_RECORD_ANALYZE_BYTES:-4194304}" avrwo_min_active="${AUDIO_RECORD_MIN_ACTIVE_SAMPLES:-100}" avrwo_min_distinct="${AUDIO_RECORD_MIN_DISTINCT_SAMPLES:-4}" avrwo_threshold_lsb="${AUDIO_RECORD_SAMPLE_THRESHOLD_LSB:-${AUDIO_RECORD_SAMPLE_THRESHOLD:-8}}" + avrwo_strict_signal="${AUDIO_RECORD_STRICT_SIGNAL:-0}" + avrwo_min_rms_dbfs="${AUDIO_RECORD_MIN_RMS_DBFS:--60}" command -v od >/dev/null 2>&1 || return 1 command -v dd >/dev/null 2>&1 || return 1 @@ -7006,8 +7394,18 @@ EOF ;; esac - [ "$avrwo_actual_rate" = "$avrwo_rate" ] || return 1 - [ "$avrwo_actual_channels" = "$avrwo_channels" ] || return 1 + if [ "$avrwo_rate" -gt 0 ] 2>/dev/null && + [ "$avrwo_actual_rate" != "$avrwo_rate" ]; then + return 1 + fi + if [ "$avrwo_channels" -gt 0 ] 2>/dev/null && + [ "$avrwo_actual_channels" != "$avrwo_channels" ]; then + return 1 + fi + if [ "$avrwo_expected_bits" -gt 0 ] 2>/dev/null && + [ "$avrwo_bits" != "$avrwo_expected_bits" ]; then + return 1 + fi [ "$avrwo_byte_rate" -gt 0 ] 2>/dev/null || return 1 [ "$avrwo_block_align" -gt 0 ] 2>/dev/null || return 1 [ $((avrwo_block_align % avrwo_actual_channels)) -eq 0 ] 2>/dev/null || return 1 @@ -7042,8 +7440,10 @@ EOF fi if [ "$avrwo_source_kind" = "null" ]; then - AUDIO_WAV_VALIDATION_SUMMARY="AUDIO_WAV_VALIDATION status=PASS reason=valid-null-source-recording validator=od payload_bytes=$avrwo_usable" - export AUDIO_WAV_VALIDATION_SUMMARY + avrwo_fields="status=PASS reason=valid-null-source-recording validator=od payload_bytes=$avrwo_usable" + AUDIO_WAV_VALIDATION_SUMMARY="AUDIO_WAV_VALIDATION $avrwo_fields" + AUDIO_VALIDATION_SUMMARY="AUDIO_VALIDATION scope=$avrwo_scope policy=basic-integrity $avrwo_fields" + export AUDIO_WAV_VALIDATION_SUMMARY AUDIO_VALIDATION_SUMMARY return 0 fi @@ -7066,7 +7466,11 @@ EOF -v bits="$avrwo_bits" \ -v min_active="$avrwo_min_active" \ -v min_distinct="$avrwo_min_distinct" \ - -v threshold_lsb="$avrwo_threshold_lsb" ' + -v threshold_lsb="$avrwo_threshold_lsb" \ + -v strict_signal="$avrwo_strict_signal" \ + -v min_rms_dbfs="$avrwo_min_rms_dbfs" \ + -v channels="$avrwo_actual_channels" \ + -v rate="$avrwo_actual_rate" ' function abs_value(value) { return value < 0 ? -value : value } @@ -7078,14 +7482,37 @@ EOF function consume_sample(raw, sign_limit, full_range, threshold, key) { sign_limit = power2(bits - 1) full_range = power2(bits) - if (raw >= sign_limit) raw -= full_range + if (bits == 8) { + raw -= 128 + } else if (raw >= sign_limit) { + raw -= full_range + } samples++ if (raw != 0) nonzero_samples++ + normalized = raw / sign_limit + sum_values += normalized + sum_squares += normalized * normalized + if (abs_value(normalized) >= 0.999) clipped_samples++ + threshold = (threshold_lsb / 32768.0) * sign_limit if (threshold < 0) threshold = 0 - if (abs_value(raw) > threshold) active_samples++ + channel = ((samples - 1) % channels) + 1 + if (abs_value(raw) > threshold) { + active_samples++ + channel_active[channel]++ + if (silence_samples >= minimum_silence_samples) silence_runs++ + if (silence_samples > longest_silence_samples) longest_silence_samples = silence_samples + silence_samples = 0 + } else { + silence_samples++ + } + + if (channel in previous && abs_value(normalized - previous[channel]) >= 0.8) { + glitch_candidates++ + } + previous[channel] = normalized if (distinct_samples < 256) { key = sprintf("%.0f", raw) @@ -7097,9 +7524,12 @@ EOF } BEGIN { bytes_per_sample = bits / 8 + minimum_silence_samples = int(rate * channels * 0.05) + if (minimum_silence_samples < 1) minimum_silence_samples = 1 } { for (i = 1; i <= NF; i++) { + if ($i != 0) payload_nonzero_bytes++ sample_bytes[++sample_index] = $i if (sample_index == bytes_per_sample) { raw = 0 @@ -7115,41 +7545,74 @@ EOF } } END { - printf "%d|%d|%d|%d\n", samples, nonzero_samples, active_samples, distinct_samples - if (samples <= 0 || nonzero_samples <= 0 || active_samples < min_active || distinct_samples < min_distinct) { + rms = samples > 0 ? sqrt(sum_squares / samples) : 0 + rms_dbfs = rms > 0 ? 20 * log(rms) / log(10) : -999 + dc_offset = samples > 0 ? sum_values / samples : 0 + if (silence_samples >= minimum_silence_samples) silence_runs++ + if (silence_samples > longest_silence_samples) longest_silence_samples = silence_samples + silent_channels = 0 + for (channel = 1; channel <= channels; channel++) { + if (channel_active[channel] == 0) silent_channels++ + } + longest_silence_ms = rate > 0 && channels > 0 ? (1000 * longest_silence_samples / (rate * channels)) : 0 + printf "%d|%d|%d|%d|%d|%.2f|%.8f|%d|%d|%.2f|%d|%d\n", samples, payload_nonzero_bytes, nonzero_samples, active_samples, distinct_samples, rms_dbfs, dc_offset, clipped_samples, silence_runs, longest_silence_ms, glitch_candidates, silent_channels + if (samples <= 0 || payload_nonzero_bytes <= 0 || nonzero_samples <= 0) { exit 1 } + if (strict_signal == 1 && rms_dbfs < min_rms_dbfs) exit 1 } - ')" || return 1 + ')" + avrwo_stats_rc=$? IFS='|' read -r \ avrwo_samples \ + avrwo_nonzero_storage_bytes \ avrwo_nonzero_samples \ avrwo_active_samples \ - avrwo_distinct_samples < +# +# Validate WAV structure and basic signal integrity. Advanced signal metrics +# remain diagnostic unless strict-signal mode is explicitly enabled. +audio_validate_wav_file() { avrw_file="$1" avrw_source_kind="${2:-mic}" avrw_rate="${3:-0}" avrw_channels="${4:-0}" - avrw_expected_seconds="${5:-0}" - avrw_log="${6:-}" + avrw_bits="${5:-0}" + avrw_expected_seconds="${6:-0}" + avrw_log="${7:-}" + avrw_scope="${8:-record}" avrw_validator="${AUDIO_WAV_VALIDATOR:-${TOOLS:-}/audio_wav_validate.py}" AUDIO_WAV_VALIDATION_SUMMARY="" - export AUDIO_WAV_VALIDATION_SUMMARY + AUDIO_VALIDATION_SUMMARY="" + export AUDIO_WAV_VALIDATION_SUMMARY AUDIO_VALIDATION_SUMMARY if command -v python3 >/dev/null 2>&1 && [ -r "$avrw_validator" ]; then avrw_output="$(python3 "$avrw_validator" \ @@ -7157,6 +7620,7 @@ audio_validate_recorded_wav() { --source-kind "$avrw_source_kind" \ --expect-rate "$avrw_rate" \ --expect-channels "$avrw_channels" \ + --expect-bits "$avrw_bits" \ --expected-seconds "$avrw_expected_seconds" \ --analyze-bytes "${AUDIO_RECORD_ANALYZE_BYTES:-4194304}" \ --min-active-samples "${AUDIO_RECORD_MIN_ACTIVE_SAMPLES:-100}" \ @@ -7165,6 +7629,7 @@ audio_validate_recorded_wav() { --min-duration-ratio "${AUDIO_RECORD_MIN_DURATION_RATIO:-0.70}" \ --strict-signal "${AUDIO_RECORD_STRICT_SIGNAL:-0}" \ --min-rms-dbfs "${AUDIO_RECORD_MIN_RMS_DBFS:--60}" \ + --validation-scope "$avrw_scope" \ 2>&1)" avrw_rc=$? @@ -7174,8 +7639,23 @@ audio_validate_recorded_wav() { [ -n "$avrw_line" ] && log_info "$avrw_line" done - AUDIO_WAV_VALIDATION_SUMMARY="$(printf '%s\n' "$avrw_output" | tail -n 1)" - export AUDIO_WAV_VALIDATION_SUMMARY + AUDIO_WAV_VALIDATION_SUMMARY="$( + printf '%s\n' "$avrw_output" | + sed -n '/^AUDIO_WAV_VALIDATION /p' | + tail -n 1 + )" + AUDIO_VALIDATION_SUMMARY="$( + printf '%s\n' "$avrw_output" | + sed -n '/^AUDIO_VALIDATION /p' | + tail -n 1 + )" + if [ -z "$AUDIO_WAV_VALIDATION_SUMMARY" ]; then + AUDIO_WAV_VALIDATION_SUMMARY="AUDIO_WAV_VALIDATION status=ERROR reason=validator-output-missing" + fi + if [ -z "$AUDIO_VALIDATION_SUMMARY" ]; then + AUDIO_VALIDATION_SUMMARY="AUDIO_VALIDATION scope=$avrw_scope policy=basic-integrity status=FAIL reason=validator-output-missing" + fi + export AUDIO_WAV_VALIDATION_SUMMARY AUDIO_VALIDATION_SUMMARY [ "$avrw_rc" -eq 0 ] return $? fi @@ -7186,19 +7666,121 @@ audio_validate_recorded_wav() { "$avrw_source_kind" \ "$avrw_rate" \ "$avrw_channels" \ - "$avrw_expected_seconds"; then - [ -n "$avrw_log" ] && printf '%s\n' "$AUDIO_WAV_VALIDATION_SUMMARY" >>"$avrw_log" + "$avrw_expected_seconds" \ + "$avrw_bits" \ + "$avrw_scope"; then + if [ -n "$avrw_log" ]; then + printf '%s\n%s\n' \ + "$AUDIO_WAV_VALIDATION_SUMMARY" \ + "$AUDIO_VALIDATION_SUMMARY" >>"$avrw_log" + fi log_info "$AUDIO_WAV_VALIDATION_SUMMARY" + log_info "$AUDIO_VALIDATION_SUMMARY" return 0 fi - AUDIO_WAV_VALIDATION_SUMMARY="AUDIO_WAV_VALIDATION status=FAIL reason=od-fallback-validation-failed" - export AUDIO_WAV_VALIDATION_SUMMARY - [ -n "$avrw_log" ] && printf '%s\n' "$AUDIO_WAV_VALIDATION_SUMMARY" >>"$avrw_log" + if [ -z "$AUDIO_WAV_VALIDATION_SUMMARY" ]; then + AUDIO_WAV_VALIDATION_SUMMARY="AUDIO_WAV_VALIDATION status=FAIL reason=od-fallback-validation-failed" + fi + if [ -z "$AUDIO_VALIDATION_SUMMARY" ]; then + AUDIO_VALIDATION_SUMMARY="AUDIO_VALIDATION scope=$avrw_scope policy=basic-integrity status=FAIL reason=od-fallback-validation-failed" + fi + export AUDIO_WAV_VALIDATION_SUMMARY AUDIO_VALIDATION_SUMMARY + if [ -n "$avrw_log" ]; then + printf '%s\n%s\n' \ + "$AUDIO_WAV_VALIDATION_SUMMARY" \ + "$AUDIO_VALIDATION_SUMMARY" >>"$avrw_log" + fi log_fail "$AUDIO_WAV_VALIDATION_SUMMARY" + log_fail "$AUDIO_VALIDATION_SUMMARY" return 1 } +# audio_validate_recorded_wav [source-kind] [rate] [channels] +# [seconds] [log-file] [scope] +# Validate one recorded WAV using the shared basic-integrity policy. The +# validation scope defaults to record and is preserved in emitted evidence. +audio_validate_recorded_wav() { + audio_validate_wav_file \ + "$1" \ + "${2:-mic}" \ + "${3:-0}" \ + "${4:-0}" \ + 0 \ + "${5:-0}" \ + "${6:-}" \ + "${7:-record}" +} + +# audio_validate_playback_wav [log-file] +# Validate a playback asset and emit AUDIO_VALIDATION scope=playback evidence. +audio_validate_playback_wav() { + avpw_file="$1" + avpw_log="${2:-}" + avpw_name="$(basename "$avpw_file")" + avpw_rate=0 + avpw_channels=0 + avpw_bits=0 + avpw_seconds="$(extract_clip_duration "$avpw_name" 2>/dev/null || echo 0)" + avpw_metadata="$(parse_clip_metadata "$avpw_name" 2>/dev/null || true)" + + if [ -n "$avpw_metadata" ]; then + # shellcheck disable=SC2086 # Intentional parsing of helper key=value output. + set -- $avpw_metadata + avpw_rate_label="${1#rate=}" + avpw_bits="${2#bits=}" + avpw_channels="${3#channels=}" + avpw_bits="${avpw_bits%b}" + avpw_channels="${avpw_channels%ch}" + + case "$avpw_rate_label" in + 8KHz) avpw_rate=8000 ;; + 16KHz) avpw_rate=16000 ;; + 22.05KHz) avpw_rate=22050 ;; + 24KHz) avpw_rate=24000 ;; + 32KHz) avpw_rate=32000 ;; + 44.1KHz) avpw_rate=44100 ;; + 48KHz) avpw_rate=48000 ;; + 88.2KHz) avpw_rate=88200 ;; + 96KHz) avpw_rate=96000 ;; + 176.4KHz) avpw_rate=176400 ;; + 192KHz) avpw_rate=192000 ;; + 352.8KHz) avpw_rate=352800 ;; + 384KHz) avpw_rate=384000 ;; + *) avpw_rate=0 ;; + esac + fi + + avpw_had_strict=0 + avpw_saved_strict="" + if [ "${AUDIO_RECORD_STRICT_SIGNAL+x}" = x ]; then + avpw_had_strict=1 + avpw_saved_strict="$AUDIO_RECORD_STRICT_SIGNAL" + fi + AUDIO_RECORD_STRICT_SIGNAL=0 + export AUDIO_RECORD_STRICT_SIGNAL + + audio_validate_wav_file \ + "$avpw_file" \ + mic \ + "$avpw_rate" \ + "$avpw_channels" \ + "$avpw_bits" \ + "$avpw_seconds" \ + "$avpw_log" \ + playback + avpw_rc=$? + + if [ "$avpw_had_strict" -eq 1 ]; then + AUDIO_RECORD_STRICT_SIGNAL="$avpw_saved_strict" + export AUDIO_RECORD_STRICT_SIGNAL + else + unset AUDIO_RECORD_STRICT_SIGNAL + fi + + return "$avpw_rc" +} + # Validate one recorder result using the existing timeout and file-size helpers # plus the WAV payload validator above. # @@ -7211,6 +7793,7 @@ audio_validate_recorded_wav() { # $6 recorder return code # $7 watchdog timeout passed to audio_exec_with_timeout # $8 optional log file +# $9 validation scope, default: record # # Return: # 0 - recorder status is acceptable and WAV validation passed @@ -7224,12 +7807,19 @@ audio_validate_recording_result() { avrr_rc="${6:-1}" avrr_timeout="${7:-0}" avrr_log="${8:-}" + avrr_scope="${9:-record}" avrr_bytes="$(file_size_bytes "$avrr_file" 2>/dev/null || echo 0)" if [ "${avrr_bytes:-0}" -le 44 ] 2>/dev/null; then - AUDIO_WAV_VALIDATION_SUMMARY="AUDIO_WAV_VALIDATION status=FAIL reason=file-empty-or-header-only file_bytes=${avrr_bytes:-0}" - export AUDIO_WAV_VALIDATION_SUMMARY - [ -n "$avrr_log" ] && printf '%s\n' "$AUDIO_WAV_VALIDATION_SUMMARY" >>"$avrr_log" + avrr_fields="status=FAIL reason=file-empty-or-header-only file_bytes=${avrr_bytes:-0}" + AUDIO_WAV_VALIDATION_SUMMARY="AUDIO_WAV_VALIDATION $avrr_fields" + AUDIO_VALIDATION_SUMMARY="AUDIO_VALIDATION scope=$avrr_scope policy=basic-integrity $avrr_fields" + export AUDIO_WAV_VALIDATION_SUMMARY AUDIO_VALIDATION_SUMMARY + if [ -n "$avrr_log" ]; then + printf '%s\n%s\n' \ + "$AUDIO_WAV_VALIDATION_SUMMARY" \ + "$AUDIO_VALIDATION_SUMMARY" >>"$avrr_log" + fi return 1 fi @@ -7242,7 +7832,8 @@ audio_validate_recording_result() { "$avrr_rate" \ "$avrr_channels" \ "$avrr_expected_seconds" \ - "$avrr_log"; then + "$avrr_log" \ + "$avrr_scope"; then return 1 fi @@ -7253,14 +7844,16 @@ audio_validate_recording_result() { avrr_timeout_seconds="$(audio_parse_secs "$avrr_timeout" 2>/dev/null || echo 0)" if [ -z "$avrr_timeout_seconds" ] || [ "$avrr_timeout_seconds" -le 0 ] 2>/dev/null; then AUDIO_WAV_VALIDATION_SUMMARY="${AUDIO_WAV_VALIDATION_SUMMARY} recorder_rc=$avrr_rc recorder_status=unexpected-timeout" - export AUDIO_WAV_VALIDATION_SUMMARY + AUDIO_VALIDATION_SUMMARY="${AUDIO_VALIDATION_SUMMARY} recorder_rc=$avrr_rc recorder_status=unexpected-timeout" + export AUDIO_WAV_VALIDATION_SUMMARY AUDIO_VALIDATION_SUMMARY return 1 fi - log_warn "Recorder ended through expected watchdog timeout rc=$avrr_rc, validated WAV payload is accepted" + log_info "Recorder ended through expected watchdog timeout rc=$avrr_rc, validated WAV payload is accepted" ;; *) AUDIO_WAV_VALIDATION_SUMMARY="${AUDIO_WAV_VALIDATION_SUMMARY} recorder_rc=$avrr_rc recorder_status=failed" - export AUDIO_WAV_VALIDATION_SUMMARY + AUDIO_VALIDATION_SUMMARY="${AUDIO_VALIDATION_SUMMARY} recorder_rc=$avrr_rc recorder_status=failed" + export AUDIO_WAV_VALIDATION_SUMMARY AUDIO_VALIDATION_SUMMARY return 1 ;; esac diff --git a/Runner/utils/audio_wav_validate.py b/Runner/utils/audio_wav_validate.py index 6ecb610d..08c80ad9 100755 --- a/Runner/utils/audio_wav_validate.py +++ b/Runner/utils/audio_wav_validate.py @@ -19,6 +19,8 @@ from dataclasses import dataclass from typing import BinaryIO, Iterable +VALIDATION_SCOPE = "record" + class WavValidationError(Exception): """Raised for malformed or unsupported WAV files.""" @@ -55,10 +57,17 @@ def duration_seconds(self) -> float: @dataclass class SignalStats: samples: int = 0 + nonzero_storage_samples: int = 0 active_samples: int = 0 + clipped_samples: int = 0 + glitch_candidates: int = 0 + silence_runs: int = 0 + longest_silence_frames: int = 0 peak: float = 0.0 + sum_values: float = 0.0 sum_squares: float = 0.0 distinct: set[object] | None = None + channel_active_samples: list[int] | None = None invalid_float_samples: int = 0 def __post_init__(self) -> None: @@ -77,6 +86,18 @@ def active_ratio(self) -> float: return 0.0 return self.active_samples / float(self.samples) + @property + def clipped_ratio(self) -> float: + if self.samples == 0: + return 0.0 + return self.clipped_samples / float(self.samples) + + @property + def dc_offset(self) -> float: + if self.samples == 0: + return 0.0 + return self.sum_values / float(self.samples) + def _read_exact(handle: BinaryIO, count: int) -> bytes: data = handle.read(count) @@ -279,22 +300,40 @@ def _decode_float(sample: bytes, container_bytes: int) -> tuple[float, float]: return value, value -def analyze_signal(path: str, info: WaveInfo, analyze_bytes: int, threshold: float) -> SignalStats: +def analyze_signal( + path: str, + info: WaveInfo, + analyze_bytes: int, + threshold: float, + clipping_threshold: float, + glitch_threshold: float, + dropout_min_ms: float, +) -> SignalStats: stats = SignalStats() + stats.channel_active_samples = [0] * info.channels container_bytes = info.block_align // info.channels ranges = _window_ranges(info.usable_data_bytes, analyze_bytes, info.block_align) + minimum_silence_frames = max( + 1, + int(info.sample_rate * max(0.0, dropout_min_ms) / 1000.0), + ) with open(path, "rb") as handle: for relative_offset, length in ranges: + previous_samples: list[float | None] = [None] * info.channels + current_silence_frames = 0 handle.seek(info.data_offset + relative_offset) payload = handle.read(length) payload = payload[: len(payload) - (len(payload) % info.block_align)] for frame_offset in range(0, len(payload), info.block_align): frame = payload[frame_offset : frame_offset + info.block_align] + frame_active = False for channel in range(info.channels): start = channel * container_bytes sample = frame[start : start + container_bytes] + if any(sample): + stats.nonzero_storage_samples += 1 if info.format_code == 1: normalized, distinct_value = _decode_pcm( @@ -312,14 +351,43 @@ def analyze_signal(path: str, info: WaveInfo, analyze_bytes: int, threshold: flo magnitude = abs(normalized) stats.samples += 1 + stats.sum_values += normalized stats.sum_squares += normalized * normalized if magnitude > stats.peak: stats.peak = magnitude if magnitude > threshold: stats.active_samples += 1 + stats.channel_active_samples[channel] += 1 + frame_active = True + if magnitude >= clipping_threshold: + stats.clipped_samples += 1 + if ( + previous_samples[channel] is not None + and abs(normalized - previous_samples[channel]) >= glitch_threshold + ): + stats.glitch_candidates += 1 + previous_samples[channel] = normalized if len(stats.distinct) < 256: stats.distinct.add(distinct_value) + if frame_active: + if current_silence_frames >= minimum_silence_frames: + stats.silence_runs += 1 + stats.longest_silence_frames = max( + stats.longest_silence_frames, + current_silence_frames, + ) + current_silence_frames = 0 + else: + current_silence_frames += 1 + + if current_silence_frames >= minimum_silence_frames: + stats.silence_runs += 1 + stats.longest_silence_frames = max( + stats.longest_silence_frames, + current_silence_frames, + ) + return stats @@ -361,11 +429,25 @@ def emit(status: str, reason: str, info: WaveInfo | None, stats: SignalStats | N fields.extend( [ ("analyzed_samples", stats.samples), + ("nonzero_storage_samples", stats.nonzero_storage_samples), ("active_samples", stats.active_samples), ("active_ratio", f"{stats.active_ratio:.8f}"), ("distinct_samples", len(stats.distinct)), ("peak_dbfs", f"{_dbfs(stats.peak):.2f}"), ("rms_dbfs", f"{_dbfs(stats.rms):.2f}"), + ("dc_offset", f"{stats.dc_offset:.8f}"), + ("clipped_samples", stats.clipped_samples), + ("clipped_ratio", f"{stats.clipped_ratio:.8f}"), + ("silence_runs", stats.silence_runs), + ( + "longest_silence_ms", + f"{1000.0 * stats.longest_silence_frames / info.sample_rate:.2f}", + ), + ("glitch_candidates", stats.glitch_candidates), + ( + "silent_channels", + sum(1 for count in stats.channel_active_samples or [] if count == 0), + ), ("invalid_float_samples", stats.invalid_float_samples), ] ) @@ -373,9 +455,17 @@ def emit(status: str, reason: str, info: WaveInfo | None, stats: SignalStats | N warning_list = list(warnings) fields.append(("warnings", ",".join(warning_list) if warning_list else "none")) + legacy_rendered = " ".join(f"{key}={_clean(value)}" for key, value in fields) + validation_status = "FAIL" if status == "ERROR" else status + validation_fields = [("status", validation_status), *fields[1:]] + validation_rendered = " ".join( + f"{key}={_clean(value)}" for key, value in validation_fields + ) + print("AUDIO_WAV_VALIDATION " + legacy_rendered) print( - "AUDIO_WAV_VALIDATION " - + " ".join(f"{key}={_clean(value)}" for key, value in fields) + "AUDIO_VALIDATION " + f"scope={_clean(VALIDATION_SCOPE)} policy=basic-integrity " + + validation_rendered ) @@ -385,6 +475,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--source-kind", choices=("mic", "null"), default="mic") parser.add_argument("--expect-rate", type=int, default=0) parser.add_argument("--expect-channels", type=int, default=0) + parser.add_argument("--expect-bits", type=int, default=0) parser.add_argument("--expected-seconds", type=float, default=0.0) parser.add_argument("--analyze-bytes", type=int, default=4 * 1024 * 1024) parser.add_argument("--min-active-samples", type=int, default=100) @@ -393,11 +484,22 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--min-duration-ratio", type=float, default=0.70) parser.add_argument("--strict-signal", choices=("0", "1"), default="0") parser.add_argument("--min-rms-dbfs", type=float, default=-60.0) + parser.add_argument("--clipping-threshold", type=float, default=0.999) + parser.add_argument("--glitch-threshold", type=float, default=0.80) + parser.add_argument("--dropout-min-ms", type=float, default=50.0) + parser.add_argument( + "--validation-scope", + choices=("record", "playback", "loopback"), + default="record", + ) return parser.parse_args() def main() -> int: + global VALIDATION_SCOPE + args = parse_args() + VALIDATION_SCOPE = args.validation_scope warnings: list[str] = [] try: @@ -426,6 +528,16 @@ def main() -> int: ) return 1 + if args.expect_bits > 0 and info.bits_per_sample != args.expect_bits: + emit( + "FAIL", + f"bits-per-sample-mismatch:{info.bits_per_sample}!={args.expect_bits}", + info, + None, + warnings, + ) + return 1 + if info.declared_data_bytes == 0 and info.available_data_bytes > 0: warnings.append("declared-data-zero-with-payload") elif info.declared_data_bytes not in {0xFFFFFFFF, info.available_data_bytes}: @@ -453,6 +565,9 @@ def main() -> int: info, max(info.block_align, args.analyze_bytes), threshold, + max(0.0, min(1.0, args.clipping_threshold)), + max(0.0, min(2.0, args.glitch_threshold)), + max(0.0, args.dropout_min_ms), ) except (OSError, struct.error, ValueError) as error: emit("ERROR", f"signal-analysis-failed:{error}", info, None, warnings) @@ -466,23 +581,21 @@ def main() -> int: emit("PASS", "valid-null-source-recording", info, stats, warnings) return 0 + if stats.nonzero_storage_samples == 0: + emit("FAIL", "all-zero-audio-payload", info, stats, warnings) + return 1 + if stats.peak == 0.0: emit("FAIL", "digital-silence-all-zero", info, stats, warnings) return 1 if len(stats.distinct) < max(2, args.min_distinct_samples): - emit("FAIL", "constant-or-near-constant-payload", info, stats, warnings) - return 1 + warnings.append("constant-or-near-constant-payload") if stats.active_samples < args.min_active_samples: - emit( - "FAIL", - f"insufficient-active-samples:{stats.active_samples}<{args.min_active_samples}", - info, - stats, - warnings, + warnings.append( + f"low-active-sample-count:{stats.active_samples}<{args.min_active_samples}" ) - return 1 if args.strict_signal == "1" and _dbfs(stats.rms) < args.min_rms_dbfs: emit( @@ -496,6 +609,18 @@ def main() -> int: if _dbfs(stats.rms) < -60.0: warnings.append("very-low-rms") + if stats.clipped_samples > 0: + warnings.append("clipping-observed") + if stats.silence_runs > 0: + warnings.append("digital-silence-runs-observed") + if abs(stats.dc_offset) >= 0.10: + warnings.append("large-dc-offset") + if stats.glitch_candidates > 0: + warnings.append("large-sample-transitions-observed") + if any(count == 0 for count in stats.channel_active_samples or []): + warnings.append("silent-channel-observed") + if stats.invalid_float_samples > 0: + warnings.append("invalid-float-samples-observed") emit("PASS", "signal-activity-present", info, stats, warnings) return 0 @@ -503,4 +628,3 @@ def main() -> int: if __name__ == "__main__": sys.exit(main()) - diff --git a/Runner/utils/functestlib.sh b/Runner/utils/functestlib.sh index 638568c6..b7e8c9f2 100755 --- a/Runner/utils/functestlib.sh +++ b/Runner/utils/functestlib.sh @@ -28,7 +28,13 @@ test_result_init() { return 3 fi - rm -f "$TEST_RESULT_FILE" + if [ "${TEST_RESULT_FILE_PREPARED:-0}" -eq 1 ] && + [ -f "$TEST_RESULT_FILE" ] && + [ ! -L "$TEST_RESULT_FILE" ]; then + : >"$TEST_RESULT_FILE" + else + rm -f "$TEST_RESULT_FILE" + fi } # test_result_record