summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-02-26 17:08:19 -0600
committerCraig Jennings <c@cjennings.net>2026-02-26 17:08:19 -0600
commit58ef63abd5a9187ee93609f142cb21a933da16c5 (patch)
tree048052e931d218cac1930d736739e11e491b4e7b
parentf473f610b7fccffd3d10d8e81342218cd4ab25fc (diff)
feat(recording): validate system audio device before recording
Add pre-recording validation that catches stale or drifted system audio devices before they cause silent recordings. When the default audio output changes (Bluetooth reconnect, device switch) between setup and recording, the monitor device is auto-updated. Warns if no audio is currently playing through the monitored sink. Co-Authored-By: Craig Jennings <c@cjennings.net>
-rw-r--r--modules/video-audio-recording.el81
-rw-r--r--tests/fixtures/pactl-sink-inputs-active.txt18
-rw-r--r--tests/fixtures/pactl-sink-inputs-different-sink.txt18
-rw-r--r--tests/fixtures/pactl-sink-inputs-empty.txt1
-rw-r--r--tests/test-video-audio-recording--sink-has-active-audio-p.el91
-rw-r--r--tests/test-video-audio-recording--source-exists-p.el67
-rw-r--r--tests/test-video-audio-recording-command-structure.el72
-rw-r--r--tests/test-video-audio-recording-ffmpeg-functions.el32
-rw-r--r--tests/test-video-audio-recording-get-devices.el26
-rw-r--r--tests/test-video-audio-recording-process-cleanup.el43
-rw-r--r--tests/test-video-audio-recording-validate-system-audio.el162
11 files changed, 560 insertions, 51 deletions
diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el
index 5e812881..6ab617ec 100644
--- a/modules/video-audio-recording.el
+++ b/modules/video-audio-recording.el
@@ -574,8 +574,89 @@ If devices aren't set, goes straight into quick setup (mic selection)."
(cj/recording-quick-setup))
(unless (and cj/recording-mic-device cj/recording-system-device)
(user-error "Audio devices not configured. Run C-; r s (quick setup) or C-; r S (manual select)"))
+ (cj/recording--validate-system-audio)
(cons cj/recording-mic-device cj/recording-system-device))
+(defun cj/recording--source-exists-p (source-name pactl-output)
+ "Return non-nil if SOURCE-NAME exists in PACTL-OUTPUT.
+PACTL-OUTPUT should be the output of `pactl list sources short'."
+ (let ((found nil))
+ (dolist (line (split-string pactl-output "\n" t))
+ (when (string-match "^[0-9]+\t\\([^\t]+\\)\t" line)
+ (when (equal source-name (match-string 1 line))
+ (setq found t))))
+ found))
+
+(defun cj/recording--get-sink-index (sink-name sinks-output)
+ "Return the numeric index of SINK-NAME from SINKS-OUTPUT.
+SINKS-OUTPUT should be the output of `pactl list sinks short'.
+Returns the index as a string, or nil if not found."
+ (let ((index nil))
+ (dolist (line (split-string sinks-output "\n" t))
+ (when (string-match "^\\([0-9]+\\)\t\\([^\t]+\\)\t" line)
+ (when (equal sink-name (match-string 2 line))
+ (setq index (match-string 1 line)))))
+ index))
+
+(defun cj/recording--sink-has-active-audio-p (sink-index pactl-output)
+ "Return non-nil if SINK-INDEX has active audio streams.
+PACTL-OUTPUT should be the output of `pactl list sink-inputs'.
+SINK-INDEX is the numeric sink index as a string."
+ (let ((found nil)
+ (lines (split-string pactl-output "\n")))
+ (dolist (line lines)
+ (when (string-match "^[ \t]+Sink:[ \t]+\\([0-9]+\\)" line)
+ (when (equal sink-index (match-string 1 line))
+ (setq found t))))
+ found))
+
+(defun cj/recording--validate-system-audio ()
+ "Validate that the configured system audio device will capture audio.
+Checks three things:
+1. Does the configured device still exist as a PulseAudio source?
+2. Has the default sink drifted from what we're monitoring?
+3. Is anything currently playing through the monitored sink?
+
+Auto-fixes stale/drifted devices. Warns (but doesn't block) if no audio
+is currently playing."
+ (when cj/recording-system-device
+ (let* ((sources-output (shell-command-to-string "pactl list sources short 2>/dev/null"))
+ (current-default (cj/recording--get-default-sink-monitor))
+ (device-exists (cj/recording--source-exists-p
+ cj/recording-system-device sources-output)))
+ ;; Check 1: Device no longer exists — auto-update
+ (unless device-exists
+ (let ((old cj/recording-system-device))
+ (setq cj/recording-system-device current-default)
+ (message "System audio device updated: %s → %s (old device no longer exists)"
+ old current-default)))
+ ;; Check 2: Default sink has drifted — auto-update
+ (when (and device-exists
+ (not (equal cj/recording-system-device current-default)))
+ (let ((old cj/recording-system-device))
+ (setq cj/recording-system-device current-default)
+ (message "System audio device updated: %s → %s (default output changed)"
+ old current-default)))
+ ;; Check 3: No active audio on the monitored sink — warn
+ (let* ((sink-name (if (string-suffix-p ".monitor" cj/recording-system-device)
+ (substring cj/recording-system-device 0 -8)
+ cj/recording-system-device))
+ (sinks-output (shell-command-to-string "pactl list sinks short 2>/dev/null"))
+ (sink-index (cj/recording--get-sink-index sink-name sinks-output))
+ (sink-inputs (shell-command-to-string "pactl list sink-inputs 2>/dev/null"))
+ (has-audio (and sink-index
+ (cj/recording--sink-has-active-audio-p sink-index sink-inputs))))
+ (unless has-audio
+ (unless (y-or-n-p
+ (format (concat "Warning: No audio is playing through %s.\n"
+ "If you're in a meeting, the other participants may not be recorded.\n"
+ "- Check that your call app is using the expected audio output\n"
+ "- Run C-; r w to see which device your call is using\n"
+ "- Run C-; r s to switch devices\n"
+ "Continue anyway? ")
+ sink-name))
+ (user-error "Recording cancelled")))))))
+
;;; ============================================================
;;; Toggle Commands (User-Facing)
;;; ============================================================
diff --git a/tests/fixtures/pactl-sink-inputs-active.txt b/tests/fixtures/pactl-sink-inputs-active.txt
new file mode 100644
index 00000000..d06589e3
--- /dev/null
+++ b/tests/fixtures/pactl-sink-inputs-active.txt
@@ -0,0 +1,18 @@
+Sink Input #42
+ Driver: PipeWire
+ Owner Module: 4294967295
+ Client: 51
+ Sink: 65
+ Sample Specification: float32le 2ch 48000Hz
+ Channel Map: front-left,front-right
+ Format: pcm, format.sample_format = "\"float32le\"" format.rate = "48000" format.channels = "2"
+ Corked: no
+ Mute: no
+ Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
+ balance 0.00
+ Buffer Latency: 0 usec
+ Sink Latency: 0 usec
+ Resample method: PipeWire
+ Properties:
+ media.name = "Playback"
+ application.name = "Firefox"
diff --git a/tests/fixtures/pactl-sink-inputs-different-sink.txt b/tests/fixtures/pactl-sink-inputs-different-sink.txt
new file mode 100644
index 00000000..859010ed
--- /dev/null
+++ b/tests/fixtures/pactl-sink-inputs-different-sink.txt
@@ -0,0 +1,18 @@
+Sink Input #42
+ Driver: PipeWire
+ Owner Module: 4294967295
+ Client: 51
+ Sink: 73
+ Sample Specification: float32le 2ch 48000Hz
+ Channel Map: front-left,front-right
+ Format: pcm, format.sample_format = "\"float32le\"" format.rate = "48000" format.channels = "2"
+ Corked: no
+ Mute: no
+ Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
+ balance 0.00
+ Buffer Latency: 0 usec
+ Sink Latency: 0 usec
+ Resample method: PipeWire
+ Properties:
+ media.name = "Playback"
+ application.name = "Firefox"
diff --git a/tests/fixtures/pactl-sink-inputs-empty.txt b/tests/fixtures/pactl-sink-inputs-empty.txt
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/tests/fixtures/pactl-sink-inputs-empty.txt
@@ -0,0 +1 @@
+
diff --git a/tests/test-video-audio-recording--sink-has-active-audio-p.el b/tests/test-video-audio-recording--sink-has-active-audio-p.el
new file mode 100644
index 00000000..eab0745a
--- /dev/null
+++ b/tests/test-video-audio-recording--sink-has-active-audio-p.el
@@ -0,0 +1,91 @@
+;;; test-video-audio-recording--sink-has-active-audio-p.el --- Tests for cj/recording--sink-has-active-audio-p -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording--sink-has-active-audio-p function.
+;; Tests parsing of pactl sink-inputs output to detect active audio streams.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'video-audio-recording)
+
+;;; Test Fixtures Helper
+
+(defun test-load-fixture (filename)
+ "Load fixture file FILENAME from tests/fixtures directory."
+ (let ((fixture-path (expand-file-name
+ (concat "tests/fixtures/" filename)
+ user-emacs-directory)))
+ (with-temp-buffer
+ (insert-file-contents fixture-path)
+ (buffer-string))))
+
+;;; Normal Cases
+
+(ert-deftest test-sink-has-active-audio-p-normal-active-sink-returns-t ()
+ "Test that active audio on our sink returns non-nil."
+ (let ((output (test-load-fixture "pactl-sink-inputs-active.txt")))
+ (should (cj/recording--sink-has-active-audio-p "65" output))))
+
+(ert-deftest test-sink-has-active-audio-p-normal-different-sink-returns-nil ()
+ "Test that audio on a different sink returns nil."
+ (let ((output (test-load-fixture "pactl-sink-inputs-different-sink.txt")))
+ (should-not (cj/recording--sink-has-active-audio-p "65" output))))
+
+;;; Boundary Cases
+
+(ert-deftest test-sink-has-active-audio-p-boundary-empty-output-returns-nil ()
+ "Test that empty pactl output returns nil."
+ (should-not (cj/recording--sink-has-active-audio-p "65" "")))
+
+(ert-deftest test-sink-has-active-audio-p-boundary-no-sink-inputs-returns-nil ()
+ "Test that output with no sink inputs returns nil."
+ (let ((output (test-load-fixture "pactl-sink-inputs-empty.txt")))
+ (should-not (cj/recording--sink-has-active-audio-p "65" output))))
+
+(ert-deftest test-sink-has-active-audio-p-boundary-multiple-inputs-one-matches ()
+ "Test that multiple sink inputs where one matches returns non-nil."
+ (let ((output (concat "Sink Input #42\n"
+ "\tSink: 73\n"
+ "\tCorked: no\n"
+ "Sink Input #43\n"
+ "\tSink: 65\n"
+ "\tCorked: no\n")))
+ (should (cj/recording--sink-has-active-audio-p "65" output))))
+
+(ert-deftest test-sink-has-active-audio-p-boundary-index-substring-no-false-match ()
+ "Test that sink index 6 does not match sink 65."
+ (let ((output (test-load-fixture "pactl-sink-inputs-active.txt")))
+ (should-not (cj/recording--sink-has-active-audio-p "6" output))))
+
+;;; get-sink-index tests
+
+(ert-deftest test-get-sink-index-normal-returns-index ()
+ "Test that sink name is resolved to its index."
+ (let ((output "65\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ (should (equal "65" (cj/recording--get-sink-index
+ "alsa_output.usb-Jabra-00.analog-stereo" output)))))
+
+(ert-deftest test-get-sink-index-normal-nonexistent-returns-nil ()
+ "Test that non-existent sink name returns nil."
+ (let ((output "65\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ (should-not (cj/recording--get-sink-index "nonexistent-sink" output))))
+
+(ert-deftest test-get-sink-index-boundary-empty-output-returns-nil ()
+ "Test that empty output returns nil."
+ (should-not (cj/recording--get-sink-index "any-sink" "")))
+
+(ert-deftest test-get-sink-index-normal-multiple-sinks ()
+ "Test correct index returned when multiple sinks present."
+ (let ((output (concat "65\talsa_output.pci-0000.hdmi-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ "69\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")))
+ (should (equal "69" (cj/recording--get-sink-index
+ "alsa_output.usb-Jabra-00.analog-stereo" output)))))
+
+(provide 'test-video-audio-recording--sink-has-active-audio-p)
+;;; test-video-audio-recording--sink-has-active-audio-p.el ends here
diff --git a/tests/test-video-audio-recording--source-exists-p.el b/tests/test-video-audio-recording--source-exists-p.el
new file mode 100644
index 00000000..f062ac0f
--- /dev/null
+++ b/tests/test-video-audio-recording--source-exists-p.el
@@ -0,0 +1,67 @@
+;;; test-video-audio-recording--source-exists-p.el --- Tests for cj/recording--source-exists-p -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording--source-exists-p function.
+;; Tests checking whether a PulseAudio source exists in pactl output.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'video-audio-recording)
+
+;;; Test Fixtures Helper
+
+(defun test-load-fixture (filename)
+ "Load fixture file FILENAME from tests/fixtures directory."
+ (let ((fixture-path (expand-file-name
+ (concat "tests/fixtures/" filename)
+ user-emacs-directory)))
+ (with-temp-buffer
+ (insert-file-contents fixture-path)
+ (buffer-string))))
+
+;;; Normal Cases
+
+(ert-deftest test-source-exists-p-normal-existing-device-returns-t ()
+ "Test that an existing device returns non-nil."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (should (cj/recording--source-exists-p
+ "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" output))))
+
+(ert-deftest test-source-exists-p-normal-input-device-returns-t ()
+ "Test that an existing input device returns non-nil."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (should (cj/recording--source-exists-p
+ "alsa_input.pci-0000_00_1f.3.analog-stereo" output))))
+
+(ert-deftest test-source-exists-p-normal-bluetooth-device-returns-t ()
+ "Test that a Bluetooth device returns non-nil."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (should (cj/recording--source-exists-p
+ "bluez_input.00:1B:66:C0:91:6D" output))))
+
+;;; Boundary Cases
+
+(ert-deftest test-source-exists-p-boundary-nonexistent-device-returns-nil ()
+ "Test that a non-existent device returns nil."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (should-not (cj/recording--source-exists-p
+ "nonexistent_device.monitor" output))))
+
+(ert-deftest test-source-exists-p-boundary-empty-output-returns-nil ()
+ "Test that empty pactl output returns nil."
+ (should-not (cj/recording--source-exists-p "any-device" "")))
+
+(ert-deftest test-source-exists-p-boundary-partial-name-no-match ()
+ "Test that partial device name does not match."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (should-not (cj/recording--source-exists-p
+ "alsa_output.pci-0000_00_1f.3.analog-stereo" output))))
+
+(provide 'test-video-audio-recording--source-exists-p)
+;;; test-video-audio-recording--source-exists-p.el ends here
diff --git a/tests/test-video-audio-recording-command-structure.el b/tests/test-video-audio-recording-command-structure.el
index c964b246..f4c24c39 100644
--- a/tests/test-video-audio-recording-command-structure.el
+++ b/tests/test-video-audio-recording-command-structure.el
@@ -52,7 +52,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "wf-recorder" command))))
(test-command-structure-teardown)))
@@ -66,7 +68,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "wf-recorder -y" command))))
(test-command-structure-teardown)))
@@ -80,7 +84,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
;; Should use libx264, not h264
(should (string-match-p "-c libx264" command))
@@ -96,7 +102,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "-m matroska" command))))
(test-command-structure-teardown)))
@@ -110,7 +118,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
;; Should use -f /dev/stdout, not -o -
(should (string-match-p "-f /dev/stdout" command))
@@ -126,7 +136,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
;; These flags don't exist in wf-recorder
(should-not (string-match-p "--no-audio" command))
@@ -142,7 +154,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
;; Should pipe wf-recorder to ffmpeg
(should (string-match-p "wf-recorder.*|.*ffmpeg" command))))
@@ -157,7 +171,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "ffmpeg -i pipe:0" command))))
(test-command-structure-teardown)))
@@ -173,7 +189,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "x11grab" command))))
(test-command-structure-teardown)))
@@ -187,7 +205,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should-not (string-match-p "wf-recorder" command))))
(test-command-structure-teardown)))
@@ -201,7 +221,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "-i :0" command))))
(test-command-structure-teardown)))
@@ -216,7 +238,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-audio" :command '("sleep" "1000")))))
+ (make-process :name "fake-audio" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should (string-match-p "^ffmpeg " command))))
(test-command-structure-teardown)))
@@ -229,7 +253,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-audio" :command '("sleep" "1000")))))
+ (make-process :name "fake-audio" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should (string-match-p "-f pulse" command))))
(test-command-structure-teardown)))
@@ -242,7 +268,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-audio" :command '("sleep" "1000")))))
+ (make-process :name "fake-audio" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should (string-match-p "\\.m4a" command))))
(test-command-structure-teardown)))
@@ -258,7 +286,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "-filter_complex" command))
(should (string-match-p "amerge" command))))
@@ -273,7 +303,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "-map 0:v" command))
(should (string-match-p "-map.*\\[out\\]" command))))
@@ -288,7 +320,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "-c:v copy" command))))
(test-command-structure-teardown)))
@@ -302,7 +336,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "\\.mkv" command))))
(test-command-structure-teardown)))
diff --git a/tests/test-video-audio-recording-ffmpeg-functions.el b/tests/test-video-audio-recording-ffmpeg-functions.el
index a3bac0cf..549aa317 100644
--- a/tests/test-video-audio-recording-ffmpeg-functions.el
+++ b/tests/test-video-audio-recording-ffmpeg-functions.el
@@ -52,7 +52,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer _command)
(setq process-created t)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should process-created)
(should cj/video-recording-ffmpeg-process)))
@@ -69,7 +71,9 @@
((symbol-function 'set-process-sentinel)
(lambda (_proc sentinel)
(should (eq sentinel #'cj/recording-process-sentinel))
- (setq sentinel-attached t))))
+ (setq sentinel-attached t)))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should sentinel-attached)))
(test-ffmpeg-teardown)))
@@ -83,7 +87,9 @@
(lambda (_name _buffer _command)
(make-process :name "fake-video" :command '("sleep" "1000"))))
((symbol-function 'force-mode-line-update)
- (lambda (&optional _all) (setq update-called t))))
+ (lambda (&optional _all) (setq update-called t)))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should update-called)))
(test-ffmpeg-teardown)))
@@ -96,7 +102,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "test-mic-device" command))
(should (string-match-p "test-monitor-device" command))
@@ -114,7 +122,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer _command)
(setq process-created t)
- (make-process :name "fake-audio" :command '("sleep" "1000")))))
+ (make-process :name "fake-audio" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should process-created)
(should cj/audio-recording-ffmpeg-process)))
@@ -131,7 +141,9 @@
((symbol-function 'set-process-sentinel)
(lambda (_proc sentinel)
(should (eq sentinel #'cj/recording-process-sentinel))
- (setq sentinel-attached t))))
+ (setq sentinel-attached t)))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should sentinel-attached)))
(test-ffmpeg-teardown)))
@@ -145,7 +157,9 @@
(lambda (_name _buffer _command)
(make-process :name "fake-audio" :command '("sleep" "1000"))))
((symbol-function 'force-mode-line-update)
- (lambda (&optional _all) (setq update-called t))))
+ (lambda (&optional _all) (setq update-called t)))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should update-called)))
(test-ffmpeg-teardown)))
@@ -158,7 +172,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-audio" :command '("sleep" "1000")))))
+ (make-process :name "fake-audio" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should (string-match-p "\\.m4a" command))))
(test-ffmpeg-teardown)))
diff --git a/tests/test-video-audio-recording-get-devices.el b/tests/test-video-audio-recording-get-devices.el
index 0af02bb3..66adecd1 100644
--- a/tests/test-video-audio-recording-get-devices.el
+++ b/tests/test-video-audio-recording-get-devices.el
@@ -39,10 +39,12 @@
(progn
(setq cj/recording-mic-device "preset-mic")
(setq cj/recording-system-device "preset-monitor")
- (let ((result (cj/recording-get-devices)))
- (should (consp result))
- (should (equal "preset-mic" (car result)))
- (should (equal "preset-monitor" (cdr result)))))
+ (cl-letf (((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
+ (let ((result (cj/recording-get-devices)))
+ (should (consp result))
+ (should (equal "preset-mic" (car result)))
+ (should (equal "preset-monitor" (cdr result))))))
(test-get-devices-teardown)))
(ert-deftest test-video-audio-recording-get-devices-normal-calls-quick-setup ()
@@ -54,7 +56,9 @@
(lambda ()
(setq quick-setup-called t)
(setq cj/recording-mic-device "quick-mic")
- (setq cj/recording-system-device "quick-monitor"))))
+ (setq cj/recording-system-device "quick-monitor")))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/recording-get-devices)
(should quick-setup-called)))
(test-get-devices-teardown)))
@@ -66,7 +70,9 @@
(cl-letf (((symbol-function 'cj/recording-quick-setup)
(lambda ()
(setq cj/recording-mic-device "test-mic")
- (setq cj/recording-system-device "test-monitor"))))
+ (setq cj/recording-system-device "test-monitor")))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(let ((result (cj/recording-get-devices)))
(should (consp result))
(should (equal "test-mic" (car result)))
@@ -87,7 +93,9 @@
(lambda ()
(setq quick-setup-called t)
(setq cj/recording-mic-device "new-mic")
- (setq cj/recording-system-device "new-monitor"))))
+ (setq cj/recording-system-device "new-monitor")))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/recording-get-devices)
(should quick-setup-called))))
(test-get-devices-teardown)))
@@ -104,7 +112,9 @@
(lambda ()
(setq quick-setup-called t)
(setq cj/recording-mic-device "new-mic")
- (setq cj/recording-system-device "new-monitor"))))
+ (setq cj/recording-system-device "new-monitor")))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/recording-get-devices)
(should quick-setup-called))))
(test-get-devices-teardown)))
diff --git a/tests/test-video-audio-recording-process-cleanup.el b/tests/test-video-audio-recording-process-cleanup.el
index d1cd442c..42b5b96d 100644
--- a/tests/test-video-audio-recording-process-cleanup.el
+++ b/tests/test-video-audio-recording-process-cleanup.el
@@ -166,7 +166,9 @@ so ffmpeg sees EOF on its video input pipe and starts finalizing the file."
0))
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer _command)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should pkill-called)
(should (member "-INT" pkill-args))
@@ -186,7 +188,9 @@ so ffmpeg sees EOF on its video input pipe and starts finalizing the file."
0))
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer _command)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should-not pkill-called)))
(test-cleanup-teardown)))
@@ -299,13 +303,15 @@ This is an integration test that requires wf-recorder and Wayland."
(skip-unless (cj/recording--wayland-p))
(test-cleanup-setup)
(unwind-protect
- (let ((initial-count (test-cleanup--count-wf-recorder-processes)))
- (cj/ffmpeg-record-video video-recordings-dir)
- (sit-for 1.0)
- (should (> (test-cleanup--count-wf-recorder-processes) initial-count))
- (cj/video-recording-stop)
- (sit-for 1.0)
- (should (= (test-cleanup--count-wf-recorder-processes) initial-count)))
+ (cl-letf (((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
+ (let ((initial-count (test-cleanup--count-wf-recorder-processes)))
+ (cj/ffmpeg-record-video video-recordings-dir)
+ (sit-for 1.0)
+ (should (> (test-cleanup--count-wf-recorder-processes) initial-count))
+ (cj/video-recording-stop)
+ (sit-for 1.0)
+ (should (= (test-cleanup--count-wf-recorder-processes) initial-count))))
(test-cleanup-teardown)
(ignore-errors (call-process "pkill" nil nil nil "-INT" "wf-recorder"))))
@@ -317,7 +323,8 @@ This is an integration test that requires wf-recorder and Wayland."
(skip-unless (cj/recording--wayland-p))
(test-cleanup-setup)
(unwind-protect
- (progn
+ (cl-letf (((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
;; Create an orphan wf-recorder (simulating a crash)
(start-process "orphan-wf" nil "wf-recorder" "-c" "libx264" "-m" "matroska" "-f" "/dev/null")
(sit-for 0.5)
@@ -342,13 +349,15 @@ This is an integration test that requires wf-recorder and Wayland."
(skip-unless (cj/recording--wayland-p))
(test-cleanup-setup)
(unwind-protect
- (let ((initial-count (test-cleanup--count-wf-recorder-processes)))
- (dotimes (_ 3)
- (cj/ffmpeg-record-video video-recordings-dir)
- (sit-for 0.5)
- (cj/video-recording-stop)
- (sit-for 0.5))
- (should (= (test-cleanup--count-wf-recorder-processes) initial-count)))
+ (cl-letf (((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
+ (let ((initial-count (test-cleanup--count-wf-recorder-processes)))
+ (dotimes (_ 3)
+ (cj/ffmpeg-record-video video-recordings-dir)
+ (sit-for 0.5)
+ (cj/video-recording-stop)
+ (sit-for 0.5))
+ (should (= (test-cleanup--count-wf-recorder-processes) initial-count))))
(test-cleanup-teardown)
(ignore-errors (call-process "pkill" nil nil nil "-INT" "wf-recorder"))))
diff --git a/tests/test-video-audio-recording-validate-system-audio.el b/tests/test-video-audio-recording-validate-system-audio.el
new file mode 100644
index 00000000..ef730ab3
--- /dev/null
+++ b/tests/test-video-audio-recording-validate-system-audio.el
@@ -0,0 +1,162 @@
+;;; test-video-audio-recording-validate-system-audio.el --- Tests for cj/recording--validate-system-audio -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording--validate-system-audio function.
+;; Tests the pre-recording validation that catches stale/drifted system
+;; audio devices before they cause silent recordings.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'video-audio-recording)
+
+;;; Setup and Teardown
+
+(defun test-validate-setup ()
+ "Reset device variables before each test."
+ (setq cj/recording-system-device nil))
+
+(defun test-validate-teardown ()
+ "Clean up device variables after each test."
+ (setq cj/recording-system-device nil))
+
+;;; Normal Cases
+
+(ert-deftest test-validate-system-audio-normal-device-matches-default-no-change ()
+ "Test that no change occurs when device matches current default and audio is active."
+ (test-validate-setup)
+ (unwind-protect
+ (let ((cj/recording-system-device "alsa_output.usb-Jabra-00.analog-stereo.monitor"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (cmd)
+ (cond
+ ((string-match-p "sources short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sinks short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sink-inputs" cmd) "Sink Input #1\n\tSink: 65\n")
+ ((string-match-p "get-default-sink" cmd) "alsa_output.usb-Jabra-00.analog-stereo")
+ (t "")))))
+ (cj/recording--validate-system-audio)
+ (should (equal "alsa_output.usb-Jabra-00.analog-stereo.monitor"
+ cj/recording-system-device))))
+ (test-validate-teardown)))
+
+(ert-deftest test-validate-system-audio-normal-stale-device-auto-updates ()
+ "Test that a stale (non-existent) device is auto-updated to current default."
+ (test-validate-setup)
+ (unwind-protect
+ (let ((cj/recording-system-device "old_disappeared_device.monitor")
+ (messages nil))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (cmd)
+ (cond
+ ((string-match-p "sources short" cmd)
+ ;; Old device NOT in list
+ "65\talsa_output.usb-Jabra-00.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sinks short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sink-inputs" cmd) "Sink Input #1\n\tSink: 65\n")
+ ((string-match-p "get-default-sink" cmd) "alsa_output.usb-Jabra-00.analog-stereo")
+ (t ""))))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (push (apply #'format fmt args) messages))))
+ (cj/recording--validate-system-audio)
+ (should (equal "alsa_output.usb-Jabra-00.analog-stereo.monitor"
+ cj/recording-system-device))
+ (should (cl-some (lambda (m) (string-match-p "no longer exists" m)) messages))))
+ (test-validate-teardown)))
+
+(ert-deftest test-validate-system-audio-normal-drifted-default-auto-updates ()
+ "Test that device is updated when default sink has drifted."
+ (test-validate-setup)
+ (unwind-protect
+ (let ((cj/recording-system-device "alsa_output.pci-0000.hdmi-stereo.monitor")
+ (messages nil))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (cmd)
+ (cond
+ ((string-match-p "sources short" cmd)
+ ;; Old device still exists
+ (concat "65\talsa_output.pci-0000.hdmi-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ "69\talsa_output.usb-Jabra-00.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ ((string-match-p "sinks short" cmd)
+ "69\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sink-inputs" cmd) "Sink Input #1\n\tSink: 69\n")
+ ;; But default has changed to Jabra
+ ((string-match-p "get-default-sink" cmd) "alsa_output.usb-Jabra-00.analog-stereo")
+ (t ""))))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (push (apply #'format fmt args) messages))))
+ (cj/recording--validate-system-audio)
+ (should (equal "alsa_output.usb-Jabra-00.analog-stereo.monitor"
+ cj/recording-system-device))
+ (should (cl-some (lambda (m) (string-match-p "default output changed" m)) messages))))
+ (test-validate-teardown)))
+
+(ert-deftest test-validate-system-audio-normal-no-audio-warns ()
+ "Test that no active audio triggers a y-or-n-p warning."
+ (test-validate-setup)
+ (unwind-protect
+ (let ((cj/recording-system-device "alsa_output.usb-Jabra-00.analog-stereo.monitor")
+ (prompted nil))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (cmd)
+ (cond
+ ((string-match-p "sources short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sinks short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ;; No sink inputs — nothing playing
+ ((string-match-p "sink-inputs" cmd) "")
+ ((string-match-p "get-default-sink" cmd) "alsa_output.usb-Jabra-00.analog-stereo")
+ (t ""))))
+ ((symbol-function 'y-or-n-p)
+ (lambda (prompt)
+ (setq prompted prompt)
+ t))) ; User says yes, continue
+ (cj/recording--validate-system-audio)
+ (should prompted)
+ (should (string-match-p "No audio is playing" prompted))))
+ (test-validate-teardown)))
+
+(ert-deftest test-validate-system-audio-normal-no-audio-user-cancels ()
+ "Test that user declining the warning cancels recording."
+ (test-validate-setup)
+ (unwind-protect
+ (let ((cj/recording-system-device "alsa_output.usb-Jabra-00.analog-stereo.monitor"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (cmd)
+ (cond
+ ((string-match-p "sources short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sinks short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sink-inputs" cmd) "")
+ ((string-match-p "get-default-sink" cmd) "alsa_output.usb-Jabra-00.analog-stereo")
+ (t ""))))
+ ((symbol-function 'y-or-n-p)
+ (lambda (_prompt) nil))) ; User says no
+ (should-error (cj/recording--validate-system-audio) :type 'user-error)))
+ (test-validate-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-validate-system-audio-boundary-nil-device-skips-validation ()
+ "Test that nil system device skips all validation."
+ (let ((cj/recording-system-device nil)
+ (shell-called nil))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) (setq shell-called t) "")))
+ (cj/recording--validate-system-audio)
+ (should-not shell-called))))
+
+(provide 'test-video-audio-recording-validate-system-audio)
+;;; test-video-audio-recording-validate-system-audio.el ends here