From a43fbed29e76d2a5c22bc6b0bd444fde7750c7bd Mon Sep 17 00:00:00 2001 From: Srikanth Muppandam Date: Wed, 14 Oct 2026 12:37:57 +0530 Subject: [PATCH 1/2] audio: add bounded sample-progress capture helpers Separate PipeWire startup waiting from delivered WAV sample duration. Bound idle progress and total capture time, preserve explicit deadlines, and stop only the owned recorder with signal-safe cleanup. Reuse WAV header parsing without requiring Python, retain failure context for stdout replay, and reject failed recorder status even when partial samples validate. AudioRecord integration follows in a separate dependent commit. Signed-off-by: Srikanth Muppandam --- Runner/utils/audio_common.sh | 403 ++++++++++++++++++++++++++++++++--- 1 file changed, 379 insertions(+), 24 deletions(-) diff --git a/Runner/utils/audio_common.sh b/Runner/utils/audio_common.sh index bf4727c9..2c427746 100755 --- a/Runner/utils/audio_common.sh +++ b/Runner/utils/audio_common.sh @@ -953,6 +953,38 @@ pwctl_inspect_safe() { audio_exec_with_timeout 2s wpctl inspect "$id" 2>/dev/null } +# Resolve a discovered wpctl source ID to a pw-record target in this session. +# Argument: global node ID. Stdout is one node.name or positive object.serial. +# Return 1 on failed inspection, a non-source node or missing target properties. +# Never substitute the global ID, a description or the session default. +pw_source_target_safe() { + psts_id="$1" + if ! psts_inspect="$(pwctl_inspect_safe "$psts_id")"; then + printf 'Cannot inspect selected PipeWire source ID %s\n' "$psts_id" >&2 + return 1 + fi + if ! printf '%s\n' "$psts_inspect" | awk -F '"' ' + $1 ~ /^[[:space:]*]*media[.]class[[:space:]]*=[[:space:]]*$/ { source = $2 } + NF == 3 && $1 ~ /^[[:space:]*]*node[.]name[[:space:]]*=[[:space:]]*$/ { name = $2 } + $1 ~ /^[[:space:]*]*object[.]serial[[:space:]]*=[[:space:]]*$/ { serial = $2 } + END { + if (source != "Audio/Source") { + exit 1 + } + if (name != "" && name != "auto" && name !~ /^[0-9]+$/ && name !~ /\\/) { + print name + } else if (serial ~ /^[0-9]+$/ && serial + 0 > 0) { + print serial + } else { + exit 1 + } + } + '; then + printf 'Selected PipeWire source ID %s has no usable node.name or object.serial\n' "$psts_id" >&2 + return 1 + fi +} + # ---------- PipeWire: sinks (playback) ---------- # Return success only for a physical PipeWire audio sink. Dummy, null, monitor, # and loopback sinks can accept streams without producing hardware audio. @@ -7628,6 +7660,301 @@ audio_run_with_timeout_as_test_user() { return $? } +# Stop and reap one recorder child owned by the calling shell. +# Argument: child PID. No stdout, diagnostics go to stderr. Return 1 if SIGKILL +# was needed, 2 if the child exited before TERM could be sent, otherwise 0. +# pw-record may return 1 on SIGTERM even when it finalized a valid WAV. +audio_record_stop_child() { + arsc_pid="$1" + arsc_forced=0 + if ! kill -TERM "$arsc_pid" 2>/dev/null; then + arsc_rc=0 + wait "$arsc_pid" 2>/dev/null || arsc_rc=$? + printf 'AUDIO_CAPTURE recorder_exit_before_stop_rc=%s\n' "$arsc_rc" >&2 + return 2 + fi + arsc_waited=0 + while kill -0 "$arsc_pid" 2>/dev/null && [ "$arsc_waited" -lt 3 ]; do + sleep 1 + arsc_waited=$((arsc_waited + 1)) + done + if kill -0 "$arsc_pid" 2>/dev/null; then + arsc_forced=1 + kill -KILL "$arsc_pid" 2>/dev/null || true + fi + wait "$arsc_pid" 2>/dev/null || true + return "$arsc_forced" +} + +# Monitor a targeted PipeWire capture in the same user session as its child. +# Arguments: WAV path, requested seconds, startup grace, explicit timeout (0=auto), +# optionally --pipewire-links, followed by the recorder command and arguments. +# Logs go to stderr. Optional pw-link output goes to .links while +# recording, after the first payload. The diagnostic child is killed/reaped +# within two polling intervals or when capture ends, without extending deadlines. +# Auto mode allows requested+grace for startup, then requested+grace for capture. +# Idle progress is bounded by max(grace, 3). Explicit timeout caps the whole +# capture, never triggers a retry, and does not reduce the requested WAV length. +# Return 0 only after collecting the requested PCM duration and a graceful stop. +# Return 1 for startup, stall, deadline, format or child errors. Always reap the +# owned child, allowing at most three additional seconds for WAV finalization. +# Temporarily forwards termination signals to that child, preserving caller +# traps on return and leaving its EXIT cleanup installed throughout. +audio_record_with_progress() { + arwp_file="$1" + arwp_seconds="$(audio_parse_secs "$2" 2>/dev/null || echo 0)" + arwp_grace="$3" + arwp_limit="$(audio_parse_secs "$4" 2>/dev/null)" || { + printf '%s\n' 'AUDIO_CAPTURE status=FAIL reason=invalid-capture-timeout' >&2 + return 1 + } + shift 4 + arwp_links=0 + if [ "${1:-}" = "--pipewire-links" ]; then + arwp_links=1 + shift + fi + + if ! is_unsigned_number "$arwp_seconds" || [ "$arwp_seconds" -eq 0 ] || + ! is_unsigned_number "$arwp_grace" || ! is_unsigned_number "$arwp_limit" || + [ "$#" -eq 0 ]; then + printf '%s\n' 'AUDIO_CAPTURE status=FAIL reason=invalid-capture-budget' >&2 + return 1 + fi + for arwp_tool in dd od awk; do + if ! command -v "$arwp_tool" >/dev/null 2>&1; then + printf 'AUDIO_CAPTURE status=FAIL reason=missing-tool tool=%s\n' "$arwp_tool" >&2 + return 1 + fi + done + + arwp_window=$((arwp_seconds + arwp_grace)) + arwp_idle_limit="$arwp_grace" + if [ "$arwp_idle_limit" -lt 3 ]; then + arwp_idle_limit=3 + fi + arwp_start="$(get_monotonic_seconds)" + arwp_deadline=$((arwp_start + 2 * arwp_window)) + if [ "$arwp_limit" -gt 0 ]; then + arwp_deadline=$((arwp_start + arwp_limit)) + fi + arwp_first=0 + arwp_last="$arwp_start" + arwp_payload=0 + arwp_previous=0 + arwp_meta="" + arwp_reason=recorder-exited + arwp_pid="" + arwp_links_pid="" + arwp_links_start=0 + if [ "$arwp_links" -eq 1 ]; then + if ! printf '%s\n' 'AUDIO_LINKS status=not-started reason=no-payload-yet' >"${arwp_file}.links"; then + printf '%s\n' 'AUDIO_LINKS status=unavailable reason=output-not-writable' >&2 + arwp_links=0 + fi + fi + arwp_saved_traps="$(trap)" + trap 'if [ -n "$arwp_links_pid" ]; then + audio_record_finish_link_snapshot "$arwp_links_pid" "${arwp_file}.links" + fi + if [ -n "$arwp_pid" ]; then + audio_record_stop_child "$arwp_pid" || true + fi + exit 1' HUP INT TERM + printf 'AUDIO_CAPTURE requested=%ss startup_limit=%ss idle_limit=%ss total_limit=%ss\n' \ + "$arwp_seconds" "$arwp_window" "$arwp_idle_limit" "$((arwp_deadline - arwp_start))" >&2 + "$@" & + arwp_pid=$! + + while kill -0 "$arwp_pid" 2>/dev/null; do + arwp_now="$(get_monotonic_seconds)" + if [ -n "$arwp_links_pid" ] && + { ! kill -0 "$arwp_links_pid" 2>/dev/null || + [ "$((arwp_now - arwp_links_start))" -ge 2 ]; }; then + audio_record_finish_link_snapshot "$arwp_links_pid" "${arwp_file}.links" + arwp_links_pid="" + fi + if [ "$arwp_now" -gt "$arwp_deadline" ]; then + arwp_reason=capture-deadline + break + fi + if [ -z "$arwp_meta" ]; then + arwp_meta="$(audio_wav_header_metadata "$arwp_file" 2>/dev/null || true)" + if [ -n "$arwp_meta" ]; then + IFS='|' read -r arwp_offset arwp_declared arwp_format arwp_channels \ + arwp_rate arwp_byte_rate arwp_align arwp_bits <&2 + fi + fi + if [ -n "$arwp_meta" ]; then + arwp_size="$(file_size_bytes "$arwp_file" 2>/dev/null || echo 0)" + # Header inspection and file reads take time too. Do not credit samples + # against a stale timestamp or extend the startup deadline on a busy DUT. + arwp_now="$(get_monotonic_seconds)" + if [ "$arwp_now" -gt "$arwp_deadline" ]; then + arwp_reason=capture-deadline + break + fi + if [ "$arwp_first" -eq 0 ] && + [ "$((arwp_now - arwp_start))" -gt "$arwp_window" ]; then + arwp_reason=startup-no-payload + break + fi + arwp_payload=$((arwp_size - arwp_offset)) + if [ "$arwp_payload" -lt 0 ]; then + arwp_payload=0 + fi + arwp_payload=$((arwp_payload - arwp_payload % arwp_align)) + if [ "$arwp_payload" -gt "$arwp_previous" ]; then + if [ "$arwp_first" -eq 0 ]; then + arwp_first=1 + printf 'AUDIO_CAPTURE first_payload_after=%ss\n' "$((arwp_now - arwp_start))" >&2 + if [ "$arwp_limit" -eq 0 ]; then + arwp_deadline=$((arwp_now + arwp_window)) + fi + if [ "$arwp_links" -eq 1 ]; then + printf 'AUDIO_LINKS recorder_pid=%s phase=first-payload\n' "$arwp_pid" >"${arwp_file}.links" + if command -v pw-link >/dev/null 2>&1; then + pw-link -l -I >>"${arwp_file}.links" 2>&1 & + arwp_links_pid=$! + arwp_links_start="$arwp_now" + else + printf '%s\n' 'AUDIO_LINKS status=unavailable reason=pw-link-missing' >>"${arwp_file}.links" + fi + fi + fi + arwp_last="$arwp_now" + printf 'AUDIO_CAPTURE elapsed=%ss payload_bytes=%s goal_bytes=%s\n' \ + "$((arwp_now - arwp_start))" "$arwp_payload" "$arwp_goal" >&2 + fi + arwp_previous="$arwp_payload" + if [ "$arwp_payload" -ge "$arwp_goal" ]; then + arwp_reason=requested-duration-collected + break + fi + fi + if [ "$arwp_now" -ge "$arwp_deadline" ]; then + arwp_reason=capture-deadline + break + fi + if [ "$arwp_first" -eq 0 ] && + [ "$((arwp_now - arwp_start))" -ge "$arwp_window" ]; then + arwp_reason=startup-no-payload + break + fi + if [ "$arwp_first" -eq 1 ] && + [ "$((arwp_now - arwp_last))" -ge "$arwp_idle_limit" ]; then + arwp_reason=payload-stalled + break + fi + sleep 1 + done + + if [ -n "$arwp_links_pid" ]; then + audio_record_finish_link_snapshot "$arwp_links_pid" "${arwp_file}.links" + arwp_links_pid="" + fi + if [ "$arwp_reason" = "recorder-exited" ]; then + arwp_rc=0 + wait "$arwp_pid" || arwp_rc=$? + printf 'AUDIO_CAPTURE recorder_rc=%s\n' "$arwp_rc" >&2 + else + arwp_stop_rc=0 + audio_record_stop_child "$arwp_pid" || arwp_stop_rc=$? + case "$arwp_stop_rc" in + 0) + ;; + 1) + arwp_reason=recorder-required-sigkill + ;; + *) + arwp_reason=recorder-exited-before-stop + ;; + esac + fi + arwp_pid="" + arwp_status=FAIL + if [ "$arwp_reason" = "requested-duration-collected" ]; then + arwp_status=COMPLETE + fi + printf 'AUDIO_CAPTURE status=%s reason=%s elapsed=%ss payload_bytes=%s\n' \ + "$arwp_status" "$arwp_reason" \ + "$(($(get_monotonic_seconds) - arwp_start))" "$arwp_payload" >&2 + trap - HUP INT TERM + # This is the shell's own trap snapshot, never command or DUT-provided text. + eval "$arwp_saved_traps" + [ "$arwp_status" = "COMPLETE" ] +} + +# Finish only the owned, read-only pw-link child. Arguments: PID, output path. +# Reap completed children, kill a still-running snapshot without a grace wait. +# Append completion/truncation status to the artifact, return 0 regardless of +# diagnostic failure. No stdout and no effect on the recording verdict. +audio_record_finish_link_snapshot() { + arfls_pid="$1" + arfls_out="$2" + arfls_status=complete + if kill -0 "$arfls_pid" 2>/dev/null; then + kill -KILL "$arfls_pid" 2>/dev/null || true + arfls_status=truncated + fi + arfls_rc=0 + wait "$arfls_pid" 2>/dev/null || arfls_rc=$? + if [ "$arfls_status" = "complete" ] && [ "$arfls_rc" -ne 0 ]; then + arfls_status=failed + fi + printf 'AUDIO_LINKS status=%s rc=%s\n' "$arfls_status" "$arfls_rc" >>"$arfls_out" 2>/dev/null || true + return 0 +} + +# Preserve bounded failure context beside the recorder log and replay to stdout. +# Arguments: recorder log, backend, selected source. No backend state changes. +# Return 0, diagnostic command failures are retained but do not change verdicts. +audio_record_failure_diagnostics() { + arfd_log="$1" + arfd_backend="$2" + arfd_source="$3" + arfd_out="${arfd_log}.diagnostics" + { + printf 'backend=%s target=%s\n' "$arfd_backend" "$arfd_source" + printf '%s\n' '---- recorder log (first 40 and last 120 lines) ----' + head -n 40 "$arfd_log" 2>/dev/null || true + tail -n 120 "$arfd_log" 2>/dev/null || true + if [ "$arfd_backend" = "pipewire" ]; then + printf '%s\n' '---- wpctl status ----' + audio_run_with_timeout_as_test_user --require-session 3s wpctl status || true + if [ -n "$arfd_source" ]; then + printf '%s\n' '---- selected source ----' + audio_run_with_timeout_as_test_user --require-session 3s wpctl inspect "$arfd_source" || true + fi + fi + } >"$arfd_out" 2>&1 + log_file_with_label AUDIO-RECORD-FAILURE "$arfd_out" 240 + if [ "$arfd_backend" = "pipewire" ] && [ -f "${record_out:-}.links" ]; then + log_file_with_label AUDIO-RECORD-LIVE-LINKS "${record_out}.links" 160 + fi + return 0 +} + # AudioRecord root-orchestrator helpers # # Insert this block into Runner/utils/audio_common.sh after @@ -7711,6 +8038,9 @@ audio_record_set_capture_paths() { # Remove the previous scratch WAV without pre-creating a root-owned file. audio_record_reset_capture_output() { rm -f "$record_user_out" + if [ "${AUDIO_BACKEND:-}" = "pipewire" ]; then + rm -f "${record_user_out}.links" + fi } # Return the current scratch WAV size. @@ -7726,6 +8056,17 @@ audio_record_promote_capture_output() { return 0 fi + if [ "${AUDIO_BACKEND:-}" = "pipewire" ]; then + rm -f "${record_out}.links" + if [ -f "${record_user_out}.links" ]; then + if ! mv "${record_user_out}.links" "${record_out}.links" || + ! chown 0:0 "${record_out}.links" || + ! chmod 0644 "${record_out}.links"; then + log_warn "Failed to preserve live PipeWire links: ${record_out}.links" + fi + fi + fi + rm -f "$record_out" if [ -f "$record_user_out" ]; then @@ -8491,30 +8832,16 @@ 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. -audio_validate_recorded_wav_od() { - avrwo_file="$1" - avrwo_source_kind="$2" - 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}" - +# Read bounded WAV header metadata, including headers not finalized yet. +# Output: offset|declared-bytes|format|channels|rate|byte-rate|block-align|bits. +# This inventories the header only, final WAV/content validation is separate. +audio_wav_header_metadata() { command -v od >/dev/null 2>&1 || return 1 command -v dd >/dev/null 2>&1 || return 1 - [ -s "$avrwo_file" ] || return 1 + [ -s "$1" ] || return 1 - avrwo_meta="$({ - dd if="$avrwo_file" bs=1 count=1048576 2>/dev/null | + { + dd if="$1" bs=4096 count=256 2>/dev/null | od -An -v -tu1 } | awk ' function u16(i) { @@ -8574,7 +8901,28 @@ audio_validate_recorded_wav_od() { exit 1 } - ' 2>/dev/null)" || return 1 + ' 2>/dev/null +} + +# 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. +audio_validate_recorded_wav_od() { + avrwo_file="$1" + avrwo_source_kind="$2" + 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}" + + avrwo_meta="$(audio_wav_header_metadata "$avrwo_file")" || return 1 IFS='|' read -r \ avrwo_offset \ @@ -9060,9 +9408,16 @@ audio_validate_recording_result() { 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" - AUDIO_VALIDATION_SUMMARY="${AUDIO_VALIDATION_SUMMARY} recorder_rc=$avrr_rc recorder_status=failed" + # Valid samples do not make a failed or stalled recorder successful. + AUDIO_WAV_VALIDATION_SUMMARY="$(printf '%s\n' "$AUDIO_WAV_VALIDATION_SUMMARY" | + sed 's/status=PASS/status=FAIL/; s/reason=[^ ]*/reason=recorder-failed/') recorder_rc=$avrr_rc recorder_status=failed" + AUDIO_VALIDATION_SUMMARY="$(printf '%s\n' "$AUDIO_VALIDATION_SUMMARY" | + sed 's/status=PASS/status=FAIL/; s/reason=[^ ]*/reason=recorder-failed/') recorder_rc=$avrr_rc recorder_status=failed" 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 ;; esac From 7cee3cb9e62d270c80698dc6ac70715ecd20acf1 Mon Sep 17 00:00:00 2001 From: Srikanth Muppandam Date: Wed, 14 Oct 2026 12:37:58 +0530 Subject: [PATCH 2/2] AudioRecord: target discovered sources and validate capture progress Use the shared progress monitor in config discovery and legacy matrix modes. Select the discovered PipeWire target directly instead of relying on the session default, and remove size-only virtual-input fallbacks that can mask the requested route failure. Keep native ALSA and PulseAudio capture commands unchanged. Replay bounded recorder and source diagnostics on failure and document timing, timeout and cleanup behavior. Depends on the preceding shared audio helper commit. Revert this integration before reverting those helpers. Signed-off-by: Srikanth Muppandam --- .../Multimedia/Audio/AudioRecord/Read_me.md | 65 +++++- .../Multimedia/Audio/AudioRecord/run.sh | 201 ++++-------------- 2 files changed, 95 insertions(+), 171 deletions(-) diff --git a/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md b/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md index 824cb1bf..402b0bb8 100644 --- a/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md +++ b/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md @@ -67,7 +67,7 @@ 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`, `awk`, `dd`, `od`, `stat` - Daemon: `pipewire` or `pulseaudio` must be running On Debian, Ubuntu, and CentOS, the selected backend controls client package @@ -253,7 +253,7 @@ DURATIONS Recording durations: short, medium, long (legacy mode only) RECORD_SECONDS Number of seconds to record (e.g., 5s, 10s) 30s LOOPS Number of recording loops 1 TIMEOUT Explicit timeout override (e.g., 15s, 0=automatic) 0 -AUDIO_RECORD_START_GRACE Startup headroom added to the automatic watchdog 5 +AUDIO_RECORD_START_GRACE Startup/progress headroom in seconds 5 STRICT Strict mode (0=disabled, 1=enabled, fail on any error) 0 DMESG_SCAN Scan dmesg for errors after recording 1 VERBOSE Enable verbose logging 0 @@ -272,7 +272,7 @@ Option Description --durations Recording durations: short, medium, long (legacy mode only) --loops Number of recording loops --timeout Override the automatic duration-based watchdog ---start-grace Recorder startup headroom, default: 5 +--start-grace Startup/progress headroom, default: 5 --strict [0|1] Enable strict mode (0=disabled, 1=enabled) --no-dmesg Disable dmesg scan --res-suffix Suffix for unique result file and log directory (e.g., "Config01" generates AudioRecord_Config01.res and results/AudioRecord_Config01/) @@ -284,12 +284,59 @@ Option Description ### Recording watchdog and clock handling -When `TIMEOUT=0`, AudioRecord uses an automatic watchdog for recorder commands. -The watchdog duration is the requested recording duration plus -`AUDIO_RECORD_START_GRACE`, which defaults to five seconds. The additional time -allows PipeWire or PulseAudio to establish the stream without reducing the WAV -duration being validated. An explicit `--timeout` value overrides the automatic -watchdog and does not receive startup grace. +PipeWire records directly from the discovered source using `pw-record --target`. +The selected `wpctl` global ID is resolved in the audio-user session to +`node.name`, or a positive `object.serial` when the name is unavailable. +Global IDs and serials are different identifiers. `AUDIO_RECORD_TARGET` logs +both the discovery ID and the actual recorder target. Failed inspection or +missing target properties produce FAIL rather than recording the default input. +It does not change the session's default microphone, restart the server after a +capture failure, or substitute an unrelated virtual ALSA input. Source discovery +before capture retains its existing automatic-backend policy. + +With `TIMEOUT=0`, PipeWire allows requested duration plus +`AUDIO_RECORD_START_GRACE` (default five seconds) for the first WAV payload. +Once payload arrives, a separate window of the same length permits collection +of the requested audio duration. For a five-second capture with default grace, +startup is bounded to ten seconds and capture to another ten seconds. A healthy +stream stops as soon as five seconds of PCM have arrived. No payload progress +for `max(start-grace, 3)` seconds fails the capture. Slow trickle progress cannot +extend the capture window. These limits include file-buffering delays. + +An explicit `--timeout` / `TIMEOUT` replaces the total capture deadline, without +extra startup grace or retries. Startup and idle checks still apply. It does not +lower PipeWire's requested sample duration. Set it high enough for startup and +the requested capture. Termination allows up to three additional seconds to +finalize the WAV, then kills and reaps only the test-owned recorder. Forced kill, +early recorder exit, startup failure, stall, and incomplete capture are FAIL. +Final WAV format, duration and content validation remains mandatory. This +monitor uses image-provided shell tools and does not require Python or systemd. + +ALSA and PulseAudio retain their existing capture, timeout and retry behavior. +Their automatic watchdog remains requested duration plus startup grace. + +`AUDIO_CAPTURE` records the parsed WAV format, first-payload delay, byte progress +and termination reason in the per-case recorder log. On any failed loop, a +bounded head/tail of that log and, for PipeWire, bounded `wpctl status` and +selected-source inspection are saved to `.log.diagnostics` and replayed to +stdout. The complete recorder log and WAV remain in the existing case directory. +Diagnostics never restart services or alter the verdict. + +When the image provides `pw-link`, a read-only `pw-link -l -I` snapshot starts +at the first PCM payload while the recorder is alive. It is stopped within two +polling intervals or when capture ends, without extending the capture deadline. +The snapshot is retained as `.wav.links`, including in desktop-user mode, +and replayed to stdout on failure (up to 160 lines). Missing tools, unsupported +options, and interrupted snapshots are recorded without changing PASS/FAIL. +No-payload captures retain a `not-started` marker. Live links help distinguish +the requested node from the node that actually feeds the stream. + +For example, validate LeMans Config09 using runtime microphone discovery: + +```sh +./run.sh --backend pipewire --source mic --config-name record_config9 --record-seconds 5s --loops 1 +./run.sh --backend pipewire --source mic --config-name record_config9 --record-seconds 5s --start-grace 10 --timeout 25s --loops 1 +``` Watchdog deadlines and elapsed-time reporting use monotonic uptime rather than the system wall clock. RTC or NTP corrections during recording therefore do not diff --git a/Runner/suites/Multimedia/Audio/AudioRecord/run.sh b/Runner/suites/Multimedia/Audio/AudioRecord/run.sh index bedfc9a2..97b322e7 100755 --- a/Runner/suites/Multimedia/Audio/AudioRecord/run.sh +++ b/Runner/suites/Multimedia/Audio/AudioRecord/run.sh @@ -161,7 +161,7 @@ Usage: $0 [options] --runtime-dir PATH # Override XDG_RUNTIME_DIR for minimal ramdisk mode --loops N --timeout SECS Override the automatic duration-based watchdog - --start-grace SECS Recorder startup headroom, default: 5 + --start-grace SECS Startup/progress headroom, default: 5 --strict [0|1] --no-dmesg --junit FILE.xml @@ -406,7 +406,10 @@ for audio_required_helper in \ audio_prepare_desktop_audio_environment \ audio_run_as_test_user \ audio_run_helper_as_test_user \ - audio_run_with_timeout_as_test_user + audio_run_with_timeout_as_test_user \ + pw_source_target_safe \ + audio_record_with_progress \ + audio_record_failure_diagnostics do if ! command -v "$audio_required_helper" >/dev/null 2>&1; then log_fail "$TESTNAME FAIL - required helper is unavailable: $audio_required_helper" @@ -859,7 +862,7 @@ elif [ "$AUDIO_BACKEND" = "pulseaudio" ]; then fi fi -# ----- Route source (set default; recorder uses default source) ----- +# ----- Discover the capture source on the selected backend ----- SRC_ID="" MANAGED_ROUTE_RECORD="" case "$AUDIO_BACKEND" in @@ -1067,12 +1070,21 @@ if [ "$AUDIO_BACKEND" = "alsa" ] && [ "$SRC_CHOICE" != "null" ]; then fi # ---- Routing log / defaults per backend ---- +PW_RECORD_TARGET="" if [ "$AUDIO_BACKEND" = "pipewire" ]; then if [ -n "$SRC_ID" ]; then + if ! PW_RECORD_TARGET="$( + audio_run_helper_as_test_user --require-session pw_source_target_safe "$SRC_ID" + )" || [ -z "$PW_RECORD_TARGET" ]; then + log_fail "$TESTNAME FAIL - cannot resolve selected PipeWire source ID '$SRC_ID' to a recorder target, verify wpctl inspect exposes Audio/Source and node.name or object.serial in the audio session" + echo "$RESULT_TESTNAME FAIL" >"$RES_FILE" + exit 1 + fi SRC_LABEL="$(audio_run_helper_as_test_user --require-session pw_source_label_safe "$SRC_ID")" - audio_run_helper_as_test_user --require-session pw_set_default_source "$SRC_ID" >/dev/null 2>&1 || true + # The explicit pw-record target avoids changing the session's default input. [ -z "$SRC_LABEL" ] && SRC_LABEL="unknown" log_info "Routing to source: id/name=$SRC_ID label='$SRC_LABEL' choice=$SRC_CHOICE" + log_info "AUDIO_RECORD_TARGET source_id='$SRC_ID' recorder_target='$PW_RECORD_TARGET'" else SRC_LABEL="default" log_info "Routing to source: id/name=default label='default' choice=$SRC_CHOICE" @@ -1153,7 +1165,11 @@ dur_s="$(duration_to_secs "$TIMEOUT" 2>/dev/null || echo 0)" if [ "$dur_s" -gt 0 ] 2>/dev/null; then log_info "Watchdog/timeout: explicit override=${TIMEOUT}" else - log_info "Watchdog/timeout: automatic per case, requested duration plus start_grace=${AUDIO_RECORD_START_GRACE}s" + if [ "$AUDIO_BACKEND" = "pipewire" ]; then + log_info "Watchdog/timeout: sample-progress capture, separate startup and capture windows of requested duration plus start_grace=${AUDIO_RECORD_START_GRACE}s" + else + log_info "Watchdog/timeout: automatic per case, requested duration plus start_grace=${AUDIO_RECORD_START_GRACE}s" + fi fi # JUnit init (optional) @@ -1203,8 +1219,8 @@ auto_secs_for() { } # Validate the final capture selected by the existing backend/retry flow. -# Size remains useful only for deciding whether compatibility fallbacks should -# run; PASS requires a structurally valid WAV with acceptable sample content. +# Legacy backend fallbacks use size only as a retry hint. PipeWire requires +# sample-progress completion, and every backend must pass final WAV validation. audio_record_validate_final_output() { recording_valid=0 validation_expected_duration="$secs" @@ -1214,7 +1230,8 @@ audio_record_validate_final_output() { [ -n "$validation_requested_seconds" ] || validation_requested_seconds=0 [ -n "$validation_timeout_seconds" ] || validation_timeout_seconds=0 - if [ "$validation_timeout_seconds" -gt 0 ] 2>/dev/null && + if [ "$AUDIO_BACKEND" != "pipewire" ] && + [ "$validation_timeout_seconds" -gt 0 ] 2>/dev/null && { [ "$validation_requested_seconds" -le 0 ] 2>/dev/null || [ "$validation_timeout_seconds" -lt "$validation_requested_seconds" ] 2>/dev/null; }; then validation_expected_duration="$effective_timeout" @@ -1347,84 +1364,14 @@ if [ "$USE_CONFIG_DISCOVERY" = "true" ]; then validation_channels="$channels" if [ "$AUDIO_BACKEND" = "pipewire" ]; then - log_info "[$case_name] exec: pw-record -v --rate=$rate --channels=$channels \"$record_user_out\"" - audio_run_with_timeout_as_test_user --require-session "$effective_timeout" pw-record -v --rate="$rate" --channels="$channels" "$record_user_out" >> "$logf" 2>&1 + log_info "[$case_name] exec: pw-record -v --rate=$rate --channels=$channels --target \"$PW_RECORD_TARGET\" \"$record_user_out\"" + audio_run_helper_as_test_user --require-session \ + audio_record_with_progress \ + "$record_user_out" "$secs" "$AUDIO_RECORD_START_GRACE" "$TIMEOUT" \ + --pipewire-links \ + pw-record -v --rate="$rate" --channels="$channels" --target "$PW_RECORD_TARGET" "$record_user_out" >> "$logf" 2>&1 rc=$? bytes="$(audio_record_capture_size)" - - # Restart/bootstrap only for an unexpected pw-record failure when the - # PipeWire control plane is also unresponsive. Watchdog termination is - # expected and must proceed to WAV validation without restarting. - record_command_failed=0 - - case "$rc" in - 0|124|137|143) - ;; - *) - record_command_failed=1 - ;; - esac - - if [ "$record_command_failed" -eq 1 ] && - ! audio_run_helper_as_test_user \ - --require-session \ - audio_pw_ctl_ok 2>/dev/null; then - if [ "$SYSTEMD_AVAILABLE" -eq 1 ] && [ "${AUDIO_SYSTEMD_MANAGED:-0}" -eq 1 ]; then - log_warn "[$case_name] pw-record rc=$rc and wpctl not responsive - restarting and retrying once" - audio_record_restart_backend_best_effort "$AUDIO_BACKEND" >/dev/null 2>&1 || true - audio_run_helper_as_test_user --require-session audio_wait_audio_ready 20 "$AUDIO_BACKEND" >/dev/null 2>&1 || true - else - log_warn "[$case_name] pw-record rc=$rc and wpctl not responsive - attempting bootstrap and retrying once" - audio_record_bootstrap_backend_if_needed >/dev/null 2>&1 || true - audio_record_set_recovered_backend_management - fi - - audio_record_set_capture_paths "$case_name" - audio_record_reset_capture_output - log_info "[$case_name] retry: pw-record -v --rate=$rate --channels=$channels \"$record_user_out\"" - audio_run_with_timeout_as_test_user --require-session "$effective_timeout" pw-record -v --rate="$rate" --channels="$channels" "$record_user_out" >> "$logf" 2>&1 - rc=$? - bytes="$(audio_record_capture_size)" - fi - - # A non-trivial file skips compatibility fallbacks. Content and - # recorder status are validated after the backend flow completes. - if [ "${bytes:-0}" -gt 1024 ] 2>/dev/null; then - : - else - # Only if output is tiny/empty do we try a virtual PCM - if command -v arecord >/dev/null 2>&1; then - pcm="$(audio_run_helper_as_test_user alsa_pick_virtual_pcm || true)" - if [ -n "$pcm" ]; then - secs_int="$(audio_parse_secs "$secs" 2>/dev/null || echo 0)" - [ -z "$secs_int" ] && secs_int=0 - audio_record_reset_capture_output - log_info "[$case_name] fallback: arecord -D $pcm -f S16_LE -r $rate -c $channels -d $secs_int \"$record_user_out\"" - audio_run_with_timeout_as_test_user "$effective_timeout" \ - arecord -D "$pcm" -f S16_LE -r "$rate" -c "$channels" -d "$secs_int" "$record_user_out" >> "$logf" 2>&1 - rc=$? - bytes="$(audio_record_capture_size)" - fi - fi - - # As a last resort, retry pw-record with --target (only if we have a source id) - retry_target=0 - if [ "$rc" -ne 0 ]; then - retry_target=1 - else - if [ "${bytes:-0}" -le 1024 ] 2>/dev/null; then - retry_target=1 - fi - fi - if [ "$retry_target" -eq 1 ] && [ -n "$SRC_ID" ]; then - audio_record_reset_capture_output - log_info "[$case_name] exec: pw-record -v --rate=$rate --channels=$channels --target \"$SRC_ID\" \"$record_user_out\"" - audio_run_with_timeout_as_test_user --require-session "$effective_timeout" pw-record -v --rate="$rate" --channels="$channels" --target "$SRC_ID" "$record_user_out" >> "$logf" 2>&1 - rc=$? - bytes="$(audio_record_capture_size)" - fi - fi - else if [ "$AUDIO_BACKEND" = "alsa" ]; then secs_int="$(audio_parse_secs "$secs" 2>/dev/null || echo 0)" @@ -1583,6 +1530,7 @@ if [ "$USE_CONFIG_DISCOVERY" = "true" ]; then ok_runs=$((ok_runs + 1)) else log_fail "[$case_name] loop $i FAILED (rc=$rc, ${last_elapsed}s, bytes=${bytes:-0}, validation='${AUDIO_WAV_VALIDATION_SUMMARY:-unavailable}') - see $logf" + audio_record_failure_diagnostics "$logf" "$AUDIO_BACKEND" "$SRC_ID" fi i=$((i + 1)) @@ -1665,86 +1613,14 @@ else validation_channels=0 if [ "$AUDIO_BACKEND" = "pipewire" ]; then - log_info "[$case_name] exec: pw-record -v \"$record_user_out\"" - audio_run_with_timeout_as_test_user --require-session "$effective_timeout" pw-record -v "$record_user_out" >> "$logf" 2>&1 + log_info "[$case_name] exec: pw-record -v --target \"$PW_RECORD_TARGET\" \"$record_user_out\"" + audio_run_helper_as_test_user --require-session \ + audio_record_with_progress \ + "$record_user_out" "$secs" "$AUDIO_RECORD_START_GRACE" "$TIMEOUT" \ + --pipewire-links \ + pw-record -v --target "$PW_RECORD_TARGET" "$record_user_out" >> "$logf" 2>&1 rc=$? bytes="$(audio_record_capture_size)" - - # Restart/bootstrap only for an unexpected pw-record failure when the - # PipeWire control plane is also unresponsive. Watchdog termination is - # expected and must proceed to WAV validation without restarting. - record_command_failed=0 - - case "$rc" in - 0|124|137|143) - ;; - *) - record_command_failed=1 - ;; - esac - - if [ "$record_command_failed" -eq 1 ] && - ! audio_run_helper_as_test_user \ - --require-session \ - audio_pw_ctl_ok 2>/dev/null; then - if [ "$SYSTEMD_AVAILABLE" -eq 1 ] && [ "${AUDIO_SYSTEMD_MANAGED:-0}" -eq 1 ]; then - log_warn "[$case_name] pw-record rc=$rc and wpctl not responsive - restarting and retrying once" - audio_record_restart_backend_best_effort "$AUDIO_BACKEND" >/dev/null 2>&1 || true - audio_run_helper_as_test_user --require-session audio_wait_audio_ready 20 "$AUDIO_BACKEND" >/dev/null 2>&1 || true - else - log_warn "[$case_name] pw-record rc=$rc and wpctl not responsive - attempting bootstrap and retrying once" - audio_record_bootstrap_backend_if_needed >/dev/null 2>&1 || true - audio_record_set_recovered_backend_management - fi - - audio_record_set_capture_paths "$case_name" - audio_record_reset_capture_output - log_info "[$case_name] retry: pw-record -v \"$record_user_out\"" - audio_run_with_timeout_as_test_user --require-session "$effective_timeout" pw-record -v "$record_user_out" >> "$logf" 2>&1 - rc=$? - bytes="$(audio_record_capture_size)" - fi - - # A non-trivial file skips compatibility fallbacks. Content and - # recorder status are validated after the backend flow completes. - if [ "${bytes:-0}" -gt 1024 ] 2>/dev/null; then - : - else - # Only if output is tiny/empty do we try a virtual PCM - if command -v arecord >/dev/null 2>&1; then - pcm="$(audio_run_helper_as_test_user alsa_pick_virtual_pcm || true)" - if [ -n "$pcm" ]; then - secs_int="$(audio_parse_secs "$secs" 2>/dev/null || echo 0)" - [ -z "$secs_int" ] && secs_int=0 - audio_record_reset_capture_output - log_info "[$case_name] fallback: arecord -D $pcm -f S16_LE -r 48000 -c 2 -d $secs_int \"$record_user_out\"" - audio_run_with_timeout_as_test_user "$effective_timeout" \ - arecord -D "$pcm" -f S16_LE -r 48000 -c 2 -d "$secs_int" "$record_user_out" >> "$logf" 2>&1 - rc=$? - bytes="$(audio_record_capture_size)" - validation_rate=48000 - validation_channels=2 - fi - fi - - # As a last resort, retry pw-record with --target (only if we have a source id) - retry_target=0 - if [ "$rc" -ne 0 ]; then - retry_target=1 - else - if [ "${bytes:-0}" -le 1024 ] 2>/dev/null; then - retry_target=1 - fi - fi - if [ "$retry_target" -eq 1 ] && [ -n "$SRC_ID" ]; then - audio_record_reset_capture_output - log_info "[$case_name] exec: pw-record -v --target \"$SRC_ID\" \"$record_user_out\"" - audio_run_with_timeout_as_test_user --require-session "$effective_timeout" pw-record -v --target "$SRC_ID" "$record_user_out" >> "$logf" 2>&1 - rc=$? - bytes="$(audio_record_capture_size)" - fi - fi - else if [ "$AUDIO_BACKEND" = "alsa" ]; then secs_int="$(audio_parse_secs "$secs" 2>/dev/null || echo 0)" @@ -1887,6 +1763,7 @@ else ok_runs=$((ok_runs + 1)) else log_fail "[$case_name] loop $i FAILED (rc=$rc, ${last_elapsed}s, bytes=${bytes:-0}, validation='${AUDIO_WAV_VALIDATION_SUMMARY:-unavailable}') - see $logf" + audio_record_failure_diagnostics "$logf" "$AUDIO_BACKEND" "$SRC_ID" fi i=$((i + 1))