diff --git a/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md b/Runner/suites/Multimedia/Audio/AudioRecord/Read_me.md index 824cb1bf6..402b0bb85 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 bedfc9a24..97b322e78 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)) diff --git a/Runner/utils/audio_common.sh b/Runner/utils/audio_common.sh index bf4727c93..2c427746e 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