summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/video-audio-recording.el158
-rw-r--r--tests/fixtures/pactl-sinks-verbose-muted.txt30
-rw-r--r--tests/fixtures/pactl-sinks-verbose-normal.txt30
-rw-r--r--tests/test-video-audio-recording--get-available-sinks.el80
-rw-r--r--tests/test-video-audio-recording--parse-pactl-sinks-verbose.el93
-rw-r--r--tests/test-video-audio-recording-quick-setup.el182
-rw-r--r--tests/test-video-audio-recording-validate-system-audio.el3
7 files changed, 490 insertions, 86 deletions
diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el
index 2bf35166..d96b42f6 100644
--- a/modules/video-audio-recording.el
+++ b/modules/video-audio-recording.el
@@ -29,11 +29,12 @@
;;
;; Quick Start
;; ===========
-;; 1. Press C-; r a (start/stop audio recording)
-;; 2. First time: you'll be prompted for device setup
-;; 3. Choose "Bluetooth Headset" (or your device)
-;; 4. Recording starts - you'll see 󰍬 in your modeline
-;; 5. Press C-; r a again to stop (🔴 disappears)
+;; 1. Press C-; r s to run quick setup
+;; 2. Pick a microphone from the list
+;; 3. Pick an audio output — 󰕾 marks sinks with active audio
+;; 4. Press C-; r a to start/stop audio recording
+;; 5. Recording starts - you'll see 󰍬 in your modeline
+;; 6. Press C-; r a again to stop (🔴 disappears)
;;
;; Device Setup (First Time Only)
;; ===============================
@@ -43,8 +44,9 @@
;; Manual device selection:
;;
;; C-; r s (cj/recording-quick-setup) - RECOMMENDED
-;; Quick setup: pick a mic, system audio is auto-detected.
-;; Works for any recording scenario.
+;; Two-step setup: pick a mic, then pick an audio output (sink).
+;; Sinks show 󰕾/󰖁 icons indicating active/inactive audio streams.
+;; Active sinks are sorted to the top for easy selection.
;;
;; C-; r S (cj/recording-select-devices) - ADVANCED
;; Manual selection: choose mic and monitor separately.
@@ -315,6 +317,63 @@ than the raw device name."
(push (cons name (or desc name)) mics))))
(nreverse mics)))
+(defun cj/recording--parse-pactl-sinks-verbose (output)
+ "Parse verbose `pactl list sinks' OUTPUT into structured list.
+Returns list of (name description mute state) tuples.
+OUTPUT should be the full output of `pactl list sinks'."
+ (let ((sinks nil)
+ (current-name nil)
+ (current-desc nil)
+ (current-mute nil)
+ (current-state nil))
+ (dolist (line (split-string output "\n"))
+ (cond
+ ((string-match "^Sink #" line)
+ ;; Save previous sink if complete
+ (when current-name
+ (push (list current-name current-desc current-mute current-state)
+ sinks))
+ (setq current-name nil current-desc nil
+ current-mute nil current-state nil))
+ ((string-match "^\\s-+Name:\\s-+\\(.+\\)" line)
+ (setq current-name (match-string 1 line)))
+ ((string-match "^\\s-+Description:\\s-+\\(.+\\)" line)
+ (setq current-desc (match-string 1 line)))
+ ((string-match "^\\s-+Mute:\\s-+\\(.+\\)" line)
+ (setq current-mute (match-string 1 line)))
+ ((string-match "^\\s-+State:\\s-+\\(.+\\)" line)
+ (setq current-state (match-string 1 line)))))
+ ;; Don't forget the last sink
+ (when current-name
+ (push (list current-name current-desc current-mute current-state)
+ sinks))
+ (nreverse sinks)))
+
+(defun cj/recording--get-available-sinks ()
+ "Return available audio sinks as (name . description) alist.
+Filters out muted sinks. Uses the friendly description from
+PulseAudio (e.g. \"JDS Labs Element IV Analog Stereo\")."
+ (let* ((output (shell-command-to-string "pactl list sinks 2>/dev/null"))
+ (sinks (cj/recording--parse-pactl-sinks-verbose output))
+ (result nil))
+ (dolist (sink sinks)
+ (let ((name (nth 0 sink))
+ (desc (nth 1 sink))
+ (mute (nth 2 sink)))
+ (when (not (equal mute "yes"))
+ (push (cons name (or desc name)) result))))
+ (nreverse result)))
+
+(defun cj/recording--sink-active-p (sink-name)
+ "Return non-nil if SINK-NAME has active audio streams.
+Resolves the sink name to its index via `pactl list sinks short',
+then checks `pactl list sink-inputs' for connected streams."
+ (let* ((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")))
+ (and sink-index
+ (cj/recording--sink-has-active-audio-p sink-index sink-inputs))))
+
;;; ============================================================
;;; Device Selection UI
;;; ============================================================
@@ -458,38 +517,69 @@ since recording needs both to capture your voice and system audio."
(nreverse result)))
(defun cj/recording-quick-setup ()
- "Quick device setup for recording.
-Shows available microphones and lets you pick one. System audio is
-automatically captured from the default audio output's monitor source,
-so it records whatever you hear (music, calls, system sounds)
-regardless of which output device is active.
+ "Quick device setup for recording — two-step mic + sink selection.
+Step 1: Pick a microphone from available unmuted sources.
+Step 2: Pick an audio output (sink) to monitor. Sinks with active
+audio streams are marked with 󰕾 (green) and sorted to the top;
+inactive sinks show 󰖁 (dim). The chosen sink's .monitor source
+is set as the system audio device.
This approach is portable across systems — plug in a new mic, run this
command, and it appears in the list. No hardware-specific configuration
needed."
(interactive)
+ ;; Step 1: Mic selection
(let* ((mics (cj/recording--get-available-mics))
- (monitor (cj/recording--get-default-sink-monitor))
- (choices (mapcar (lambda (mic)
- (cons (cdr mic) (car mic)))
- mics)))
- (if (null choices)
+ (mic-choices (mapcar (lambda (mic)
+ (cons (cdr mic) (car mic)))
+ mics)))
+ (if (null mic-choices)
(user-error "No microphones found. Is a mic plugged in and unmuted?")
- (let* ((choices-with-cancel (append choices '(("Cancel" . nil))))
- (choice (completing-read "Select microphone: "
- (lambda (string pred action)
- (if (eq action 'metadata)
- '(metadata (display-sort-function . identity))
- (complete-with-action action choices-with-cancel string pred)))
- nil t))
- (mic-device (cdr (assoc choice choices-with-cancel))))
+ (let* ((mic-choices-with-cancel (append mic-choices '(("Cancel" . nil))))
+ (mic-choice (completing-read "Select microphone: "
+ (lambda (string pred action)
+ (if (eq action 'metadata)
+ '(metadata (display-sort-function . identity))
+ (complete-with-action action mic-choices-with-cancel string pred)))
+ nil t))
+ (mic-device (cdr (assoc mic-choice mic-choices-with-cancel))))
(if (null mic-device)
(user-error "Device setup cancelled")
- (setq cj/recording-mic-device mic-device)
- (setq cj/recording-system-device monitor)
- (message "Recording ready!\n Mic: %s\n System audio: %s (default output monitor)"
- choice
- (file-name-nondirectory monitor)))))))
+ ;; Step 2: Sink selection
+ (let* ((sinks (cj/recording--get-available-sinks))
+ (sink-labels
+ (mapcar
+ (lambda (sink)
+ (let* ((name (car sink))
+ (desc (cdr sink))
+ (active (cj/recording--sink-active-p name))
+ (icon (if active "󰕾" "󰖁"))
+ (face (if active '(:foreground "#50fa7b") '(:foreground "#6272a4")))
+ (label (concat (propertize icon 'face face) " " desc)))
+ (list label name active)))
+ sinks))
+ ;; Sort active sinks to top
+ (sorted-labels (sort sink-labels
+ (lambda (a b)
+ (and (nth 2 a) (not (nth 2 b))))))
+ (sink-alist (mapcar (lambda (entry)
+ (cons (nth 0 entry) (nth 1 entry)))
+ sorted-labels))
+ (sink-alist-with-cancel (append sink-alist '(("Cancel" . nil))))
+ (sink-choice (completing-read "Select audio output to monitor: "
+ (lambda (string pred action)
+ (if (eq action 'metadata)
+ '(metadata (display-sort-function . identity))
+ (complete-with-action action sink-alist-with-cancel string pred)))
+ nil t))
+ (sink-device (cdr (assoc sink-choice sink-alist-with-cancel))))
+ (if (null sink-device)
+ (user-error "Device setup cancelled")
+ (setq cj/recording-mic-device mic-device)
+ (setq cj/recording-system-device (concat sink-device ".monitor"))
+ (message "Recording ready!\n Mic: %s\n System audio: %s.monitor"
+ mic-choice
+ (file-name-nondirectory sink-device)))))))))
;;; ============================================================
;;; Device Testing
@@ -659,13 +749,11 @@ is currently playing."
(has-audio (and sink-index
(cj/recording--sink-has-active-audio-p sink-index sink-inputs))))
(unless has-audio
- (message "Warning: No audio detected on %s — other participants may not be recorded"
+ (message "Warning: No audio connected to %s. Run C-; r s to check devices"
sink-name)
(cj/log-silently
- (concat "No audio playing through %s.\n"
- "If you're in a meeting, the other participants may not be recorded.\n"
- " C-; r w show which device your call is using\n"
- " C-; r s switch devices")
+ (concat "No audio connected to %s. "
+ "Run C-; r s to see active streams and switch devices")
sink-name))))))
;;; ============================================================
diff --git a/tests/fixtures/pactl-sinks-verbose-muted.txt b/tests/fixtures/pactl-sinks-verbose-muted.txt
new file mode 100644
index 00000000..fcfb7246
--- /dev/null
+++ b/tests/fixtures/pactl-sinks-verbose-muted.txt
@@ -0,0 +1,30 @@
+Sink #65
+ State: RUNNING
+ Name: alsa_output.usb-JDS_Labs-00.analog-stereo
+ Description: JDS Labs Element IV Analog Stereo
+ Driver: PipeWire
+ Sample Specification: s32le 2ch 48000Hz
+ Channel Map: front-left,front-right
+ Mute: no
+ Volume: front-left: 32768 / 50% / -18.06 dB, front-right: 32768 / 50% / -18.06 dB
+ balance 0.00
+Sink #73
+ State: SUSPENDED
+ Name: alsa_output.usb-Shure_MV7-00.analog-stereo
+ Description: Shure MV7+ Analog Stereo
+ Driver: PipeWire
+ Sample Specification: s32le 2ch 48000Hz
+ Channel Map: front-left,front-right
+ Mute: yes
+ Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB
+ balance 0.00
+Sink #81
+ State: SUSPENDED
+ Name: alsa_output.pci-0000_0e_00.4.analog-stereo
+ Description: Ryzen HD Audio Controller Analog Stereo
+ Driver: PipeWire
+ Sample Specification: s32le 2ch 48000Hz
+ Channel Map: front-left,front-right
+ Mute: no
+ Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
+ balance 0.00
diff --git a/tests/fixtures/pactl-sinks-verbose-normal.txt b/tests/fixtures/pactl-sinks-verbose-normal.txt
new file mode 100644
index 00000000..8c093568
--- /dev/null
+++ b/tests/fixtures/pactl-sinks-verbose-normal.txt
@@ -0,0 +1,30 @@
+Sink #65
+ State: RUNNING
+ Name: alsa_output.usb-JDS_Labs-00.analog-stereo
+ Description: JDS Labs Element IV Analog Stereo
+ Driver: PipeWire
+ Sample Specification: s32le 2ch 48000Hz
+ Channel Map: front-left,front-right
+ Mute: no
+ Volume: front-left: 32768 / 50% / -18.06 dB, front-right: 32768 / 50% / -18.06 dB
+ balance 0.00
+Sink #73
+ State: SUSPENDED
+ Name: alsa_output.usb-Shure_MV7-00.analog-stereo
+ Description: Shure MV7+ Analog Stereo
+ Driver: PipeWire
+ Sample Specification: s32le 2ch 48000Hz
+ Channel Map: front-left,front-right
+ Mute: no
+ Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
+ balance 0.00
+Sink #81
+ State: SUSPENDED
+ Name: alsa_output.pci-0000_0e_00.4.analog-stereo
+ Description: Ryzen HD Audio Controller Analog Stereo
+ Driver: PipeWire
+ Sample Specification: s32le 2ch 48000Hz
+ Channel Map: front-left,front-right
+ Mute: no
+ Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
+ balance 0.00
diff --git a/tests/test-video-audio-recording--get-available-sinks.el b/tests/test-video-audio-recording--get-available-sinks.el
new file mode 100644
index 00000000..540c4f0f
--- /dev/null
+++ b/tests/test-video-audio-recording--get-available-sinks.el
@@ -0,0 +1,80 @@
+;;; test-video-audio-recording--get-available-sinks.el --- Tests for sink discovery -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording--get-available-sinks.
+;; Verifies that available sinks are discovered correctly:
+;; - Muted sinks are excluded
+;; - Friendly descriptions from PulseAudio are used
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'video-audio-recording)
+
+;;; Helper
+
+(defun test-sinks--make-pactl-output (sinks)
+ "Build fake `pactl list sinks' output from SINKS.
+Each sink is (name description mute state)."
+ (mapconcat (lambda (s)
+ (format "Sink #1\n\tState: %s\n\tName: %s\n\tDescription: %s\n\tMute: %s\n"
+ (nth 3 s) (nth 0 s) (nth 1 s) (nth 2 s)))
+ sinks ""))
+
+;;; Normal Cases
+
+(ert-deftest test-get-available-sinks-normal-filters-muted ()
+ "Test that muted sinks are excluded from sink list."
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd)
+ (test-sinks--make-pactl-output
+ '(("active-sink" "Active Sink" "no" "RUNNING")
+ ("muted-sink" "Muted Sink" "yes" "SUSPENDED"))))))
+ (let ((sinks (cj/recording--get-available-sinks)))
+ (should (= 1 (length sinks)))
+ (should (equal "active-sink" (car (car sinks)))))))
+
+(ert-deftest test-get-available-sinks-normal-uses-descriptions ()
+ "Test that friendly descriptions are returned as cdr."
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd)
+ (test-sinks--make-pactl-output
+ '(("raw-sink-name" "Friendly Sink Name" "no" "IDLE"))))))
+ (let ((sinks (cj/recording--get-available-sinks)))
+ (should (equal "Friendly Sink Name" (cdr (car sinks)))))))
+
+(ert-deftest test-get-available-sinks-normal-multiple-sinks ()
+ "Test that multiple non-muted sinks are returned."
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd)
+ (test-sinks--make-pactl-output
+ '(("sink-a" "JDS Labs" "no" "RUNNING")
+ ("sink-b" "Shure MV7+" "no" "SUSPENDED")
+ ("muted-sink" "Muted" "yes" "SUSPENDED"))))))
+ (let ((sinks (cj/recording--get-available-sinks)))
+ (should (= 2 (length sinks))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-get-available-sinks-boundary-empty-output ()
+ "Test that empty pactl output returns empty list."
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) "")))
+ (should (null (cj/recording--get-available-sinks)))))
+
+(ert-deftest test-get-available-sinks-boundary-all-muted ()
+ "Test that if all sinks are muted, returns empty list."
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd)
+ (test-sinks--make-pactl-output
+ '(("muted-a" "Sink A" "yes" "SUSPENDED")
+ ("muted-b" "Sink B" "yes" "SUSPENDED"))))))
+ (should (null (cj/recording--get-available-sinks)))))
+
+(provide 'test-video-audio-recording--get-available-sinks)
+;;; test-video-audio-recording--get-available-sinks.el ends here
diff --git a/tests/test-video-audio-recording--parse-pactl-sinks-verbose.el b/tests/test-video-audio-recording--parse-pactl-sinks-verbose.el
new file mode 100644
index 00000000..8a2cba2d
--- /dev/null
+++ b/tests/test-video-audio-recording--parse-pactl-sinks-verbose.el
@@ -0,0 +1,93 @@
+;;; test-video-audio-recording--parse-pactl-sinks-verbose.el --- Tests for verbose pactl sinks parser -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording--parse-pactl-sinks-verbose.
+;; Parses the verbose output of `pactl list sinks' into structured tuples
+;; of (name description mute state).
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'video-audio-recording)
+
+;;; Helper
+
+(defvar test-sinks--dir
+ (file-name-directory (or load-file-name
+ (locate-library "test-video-audio-recording--parse-pactl-sinks-verbose")))
+ "Directory containing this test file.")
+
+(defun test-sinks--fixture (filename)
+ "Read fixture FILENAME from tests/fixtures/ directory."
+ (let ((path (expand-file-name (concat "fixtures/" filename) test-sinks--dir)))
+ (with-temp-buffer
+ (insert-file-contents path)
+ (buffer-string))))
+
+;;; Normal Cases
+
+(ert-deftest test-parse-pactl-sinks-verbose-normal-multiple-sinks ()
+ "Test parsing multiple sink entries from fixture."
+ (let* ((output (test-sinks--fixture "pactl-sinks-verbose-normal.txt"))
+ (result (cj/recording--parse-pactl-sinks-verbose output)))
+ (should (= 3 (length result)))
+ (should (equal "alsa_output.usb-JDS_Labs-00.analog-stereo" (nth 0 (nth 0 result))))
+ (should (equal "JDS Labs Element IV Analog Stereo" (nth 1 (nth 0 result))))
+ (should (equal "no" (nth 2 (nth 0 result))))
+ (should (equal "RUNNING" (nth 3 (nth 0 result))))))
+
+(ert-deftest test-parse-pactl-sinks-verbose-normal-single-sink ()
+ "Test parsing a single sink entry."
+ (let* ((output "Sink #65\n\tState: SUSPENDED\n\tName: alsa_output.usb-JDS-00.analog-stereo\n\tDescription: JDS Labs Element IV\n\tMute: no\n")
+ (result (cj/recording--parse-pactl-sinks-verbose output)))
+ (should (= 1 (length result)))
+ (should (equal "alsa_output.usb-JDS-00.analog-stereo" (nth 0 (car result))))
+ (should (equal "JDS Labs Element IV" (nth 1 (car result))))
+ (should (equal "no" (nth 2 (car result))))
+ (should (equal "SUSPENDED" (nth 3 (car result))))))
+
+(ert-deftest test-parse-pactl-sinks-verbose-normal-muted-sink ()
+ "Test that muted sinks are parsed (filtering is done by caller)."
+ (let* ((output (test-sinks--fixture "pactl-sinks-verbose-muted.txt"))
+ (result (cj/recording--parse-pactl-sinks-verbose output)))
+ (should (= 3 (length result)))
+ ;; Second sink is muted
+ (should (equal "yes" (nth 2 (nth 1 result))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-parse-pactl-sinks-verbose-boundary-empty-input ()
+ "Test that empty input returns empty list."
+ (should (null (cj/recording--parse-pactl-sinks-verbose ""))))
+
+(ert-deftest test-parse-pactl-sinks-verbose-boundary-extra-fields ()
+ "Test that extra fields between sinks are ignored."
+ (let* ((output (concat "Sink #65\n\tState: IDLE\n\tName: sink-a\n\tDescription: Sink A\n\tMute: no\n"
+ "\tDriver: PipeWire\n\tSample Specification: s16le 2ch 48000Hz\n"
+ "Sink #66\n\tState: SUSPENDED\n\tName: sink-b\n\tDescription: Sink B\n\tMute: no\n"))
+ (result (cj/recording--parse-pactl-sinks-verbose output)))
+ (should (= 2 (length result)))
+ (should (equal "sink-a" (nth 0 (car result))))
+ (should (equal "sink-b" (nth 0 (cadr result))))))
+
+(ert-deftest test-parse-pactl-sinks-verbose-boundary-description-with-parens ()
+ "Test descriptions containing parentheses are captured fully."
+ (let* ((output "Sink #91\n\tState: SUSPENDED\n\tName: hdmi-output\n\tDescription: Radeon (HDMI 2) Output\n\tMute: no\n")
+ (result (cj/recording--parse-pactl-sinks-verbose output)))
+ (should (equal "Radeon (HDMI 2) Output" (nth 1 (car result))))))
+
+;;; Error Cases
+
+(ert-deftest test-parse-pactl-sinks-verbose-error-malformed-no-name ()
+ "Test that sink entries without Name field are skipped."
+ (let* ((output "Sink #65\n\tState: SUSPENDED\n\tDescription: Orphan\n\tMute: no\n")
+ (result (cj/recording--parse-pactl-sinks-verbose output)))
+ (should (null result))))
+
+(provide 'test-video-audio-recording--parse-pactl-sinks-verbose)
+;;; test-video-audio-recording--parse-pactl-sinks-verbose.el ends here
diff --git a/tests/test-video-audio-recording-quick-setup.el b/tests/test-video-audio-recording-quick-setup.el
index 8b46b9b0..1b2e6e53 100644
--- a/tests/test-video-audio-recording-quick-setup.el
+++ b/tests/test-video-audio-recording-quick-setup.el
@@ -2,8 +2,10 @@
;;; Commentary:
;; Unit tests for cj/recording-quick-setup function.
-;; The quick setup shows available mics and auto-selects the default
-;; sink's monitor for system audio capture.
+;; The quick setup is a two-step flow:
+;; Step 1: Pick a microphone
+;; Step 2: Pick an audio output (sink) with active/inactive indicators
+;; The chosen sink's .monitor source is set as the system audio device.
;;; Code:
@@ -34,26 +36,32 @@
"Test that selecting a mic sets cj/recording-mic-device."
(test-quick-setup-setup)
(unwind-protect
- (cl-letf (((symbol-function 'cj/recording--get-available-mics)
- (lambda () '(("jabra-input" . "Jabra SPEAK 510 Mono")
- ("builtin-input" . "Built-in Analog"))))
- ((symbol-function 'cj/recording--get-default-sink-monitor)
- (lambda () "jds-labs.monitor"))
- ((symbol-function 'completing-read)
- (lambda (_prompt table &rest _args)
- (car (all-completions "" table)))))
- (cj/recording-quick-setup)
- (should (equal "jabra-input" cj/recording-mic-device)))
+ (let ((call-count 0))
+ (cl-letf (((symbol-function 'cj/recording--get-available-mics)
+ (lambda () '(("jabra-input" . "Jabra SPEAK 510 Mono")
+ ("builtin-input" . "Built-in Analog"))))
+ ((symbol-function 'cj/recording--get-available-sinks)
+ (lambda () '(("jds-labs" . "JDS Labs Element IV"))))
+ ((symbol-function 'cj/recording--sink-active-p)
+ (lambda (_name) nil))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt table &rest _args)
+ (setq call-count (1+ call-count))
+ (car (all-completions "" table)))))
+ (cj/recording-quick-setup)
+ (should (equal "jabra-input" cj/recording-mic-device))))
(test-quick-setup-teardown)))
-(ert-deftest test-video-audio-recording-quick-setup-normal-sets-system-to-default-monitor ()
- "Test that system device is set to the default sink's monitor."
+(ert-deftest test-video-audio-recording-quick-setup-normal-sets-system-to-sink-monitor ()
+ "Test that system device is set to the chosen sink's .monitor."
(test-quick-setup-setup)
(unwind-protect
(cl-letf (((symbol-function 'cj/recording--get-available-mics)
(lambda () '(("jabra-input" . "Jabra SPEAK 510 Mono"))))
- ((symbol-function 'cj/recording--get-default-sink-monitor)
- (lambda () "alsa_output.usb-JDS_Labs-00.analog-stereo.monitor"))
+ ((symbol-function 'cj/recording--get-available-sinks)
+ (lambda () '(("alsa_output.usb-JDS_Labs-00.analog-stereo" . "JDS Labs Element IV"))))
+ ((symbol-function 'cj/recording--sink-active-p)
+ (lambda (_name) nil))
((symbol-function 'completing-read)
(lambda (_prompt table &rest _args)
(car (all-completions "" table)))))
@@ -62,26 +70,78 @@
cj/recording-system-device)))
(test-quick-setup-teardown)))
-(ert-deftest test-video-audio-recording-quick-setup-normal-presents-descriptions ()
- "Test that completing-read receives friendly descriptions and Cancel option."
+(ert-deftest test-video-audio-recording-quick-setup-normal-two-completing-reads ()
+ "Test that completing-read is called twice (mic + sink)."
(test-quick-setup-setup)
(unwind-protect
- (let ((presented-candidates nil))
+ (let ((call-count 0))
(cl-letf (((symbol-function 'cj/recording--get-available-mics)
- (lambda () '(("raw-device-1" . "Jabra SPEAK 510 Mono")
- ("raw-device-2" . "Built-in Analog"))))
- ((symbol-function 'cj/recording--get-default-sink-monitor)
- (lambda () "default.monitor"))
+ (lambda () '(("mic-1" . "Mic One"))))
+ ((symbol-function 'cj/recording--get-available-sinks)
+ (lambda () '(("sink-1" . "Sink One"))))
+ ((symbol-function 'cj/recording--sink-active-p)
+ (lambda (_name) nil))
((symbol-function 'completing-read)
(lambda (_prompt table &rest _args)
- (setq presented-candidates (all-completions "" table))
- (car presented-candidates))))
+ (setq call-count (1+ call-count))
+ (car (all-completions "" table)))))
(cj/recording-quick-setup)
- ;; Candidates should have friendly descriptions
- (should (member "Jabra SPEAK 510 Mono" presented-candidates))
- (should (member "Built-in Analog" presented-candidates))
- ;; Cancel option should be present
- (should (member "Cancel" presented-candidates))))
+ (should (= 2 call-count))))
+ (test-quick-setup-teardown)))
+
+(ert-deftest test-video-audio-recording-quick-setup-normal-active-sink-indicator ()
+ "Test that active sinks get the active icon in their label."
+ (test-quick-setup-setup)
+ (unwind-protect
+ (let ((sink-candidates nil)
+ (call-count 0))
+ (cl-letf (((symbol-function 'cj/recording--get-available-mics)
+ (lambda () '(("mic-1" . "Mic One"))))
+ ((symbol-function 'cj/recording--get-available-sinks)
+ (lambda () '(("active-sink" . "Active Sink")
+ ("inactive-sink" . "Inactive Sink"))))
+ ((symbol-function 'cj/recording--sink-active-p)
+ (lambda (name) (equal name "active-sink")))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt table &rest _args)
+ (setq call-count (1+ call-count))
+ (let ((candidates (all-completions "" table)))
+ (when (= call-count 2)
+ (setq sink-candidates candidates))
+ (car candidates)))))
+ (cj/recording-quick-setup)
+ ;; Active sink should have 󰕾 icon (substring-no-properties strips faces but keeps text)
+ (should (cl-some (lambda (c) (and (string-match-p "Active Sink" c)
+ (string-match-p "󰕾" c)))
+ sink-candidates))
+ ;; Inactive sink should have 󰖁 icon
+ (should (cl-some (lambda (c) (and (string-match-p "Inactive Sink" c)
+ (string-match-p "󰖁" c)))
+ sink-candidates))))
+ (test-quick-setup-teardown)))
+
+(ert-deftest test-video-audio-recording-quick-setup-normal-active-sorted-first ()
+ "Test that active sinks are sorted to the top of the list."
+ (test-quick-setup-setup)
+ (unwind-protect
+ (let ((sink-candidates nil))
+ (cl-letf (((symbol-function 'cj/recording--get-available-mics)
+ (lambda () '(("mic-1" . "Mic One"))))
+ ((symbol-function 'cj/recording--get-available-sinks)
+ (lambda () '(("inactive-sink" . "Inactive Sink")
+ ("active-sink" . "Active Sink"))))
+ ((symbol-function 'cj/recording--sink-active-p)
+ (lambda (name) (equal name "active-sink")))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt table &rest _args)
+ (let ((candidates (all-completions "" table)))
+ (when (cl-some (lambda (c) (string-match-p "Sink" c)) candidates)
+ (setq sink-candidates candidates))
+ (car candidates)))))
+ (cj/recording-quick-setup)
+ ;; First non-Cancel candidate should be the active sink
+ (let ((first-sink (car sink-candidates)))
+ (should (string-match-p "Active Sink" first-sink)))))
(test-quick-setup-teardown)))
(ert-deftest test-video-audio-recording-quick-setup-normal-confirmation-message ()
@@ -91,8 +151,10 @@
(let ((message-text nil))
(cl-letf (((symbol-function 'cj/recording--get-available-mics)
(lambda () '(("jabra-input" . "Jabra SPEAK 510 Mono"))))
- ((symbol-function 'cj/recording--get-default-sink-monitor)
- (lambda () "jds-labs.analog-stereo.monitor"))
+ ((symbol-function 'cj/recording--get-available-sinks)
+ (lambda () '(("jds-labs.analog-stereo" . "JDS Labs Element IV"))))
+ ((symbol-function 'cj/recording--sink-active-p)
+ (lambda (_name) nil))
((symbol-function 'completing-read)
(lambda (_prompt table &rest _args)
(car (all-completions "" table))))
@@ -101,8 +163,7 @@
(setq message-text (apply #'format fmt args)))))
(cj/recording-quick-setup)
(should (string-match-p "Recording ready" message-text))
- (should (string-match-p "Jabra SPEAK 510 Mono" message-text))
- (should (string-match-p "default output monitor" message-text))))
+ (should (string-match-p ".monitor" message-text))))
(test-quick-setup-teardown)))
;;; Boundary Cases
@@ -111,30 +172,34 @@
"Test that with only one mic, it still presents selection."
(test-quick-setup-setup)
(unwind-protect
- (let ((read-called nil))
+ (let ((read-called 0))
(cl-letf (((symbol-function 'cj/recording--get-available-mics)
(lambda () '(("sole-mic" . "Only Mic Available"))))
- ((symbol-function 'cj/recording--get-default-sink-monitor)
- (lambda () "default.monitor"))
+ ((symbol-function 'cj/recording--get-available-sinks)
+ (lambda () '(("sole-sink" . "Only Sink"))))
+ ((symbol-function 'cj/recording--sink-active-p)
+ (lambda (_name) nil))
((symbol-function 'completing-read)
(lambda (_prompt table &rest _args)
- (setq read-called t)
+ (setq read-called (1+ read-called))
(car (all-completions "" table)))))
(cj/recording-quick-setup)
- (should read-called)
+ (should (= 2 read-called))
(should (equal "sole-mic" cj/recording-mic-device))))
(test-quick-setup-teardown)))
;;; Error Cases
-(ert-deftest test-video-audio-recording-quick-setup-error-cancel-selected ()
- "Test that selecting Cancel signals user-error and does not set devices."
+(ert-deftest test-video-audio-recording-quick-setup-error-cancel-mic ()
+ "Test that cancelling mic selection signals user-error and does not set devices."
(test-quick-setup-setup)
(unwind-protect
(cl-letf (((symbol-function 'cj/recording--get-available-mics)
(lambda () '(("jabra-input" . "Jabra SPEAK 510 Mono"))))
- ((symbol-function 'cj/recording--get-default-sink-monitor)
- (lambda () "default.monitor"))
+ ((symbol-function 'cj/recording--get-available-sinks)
+ (lambda () '(("sink-1" . "Sink One"))))
+ ((symbol-function 'cj/recording--sink-active-p)
+ (lambda (_name) nil))
((symbol-function 'completing-read)
(lambda (_prompt _choices &rest _args)
"Cancel")))
@@ -143,14 +208,35 @@
(should (null cj/recording-system-device)))
(test-quick-setup-teardown)))
+(ert-deftest test-video-audio-recording-quick-setup-error-cancel-sink ()
+ "Test that cancelling sink selection signals user-error."
+ (test-quick-setup-setup)
+ (unwind-protect
+ (let ((call-count 0))
+ (cl-letf (((symbol-function 'cj/recording--get-available-mics)
+ (lambda () '(("jabra-input" . "Jabra SPEAK 510 Mono"))))
+ ((symbol-function 'cj/recording--get-available-sinks)
+ (lambda () '(("sink-1" . "Sink One"))))
+ ((symbol-function 'cj/recording--sink-active-p)
+ (lambda (_name) nil))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt table &rest _args)
+ (setq call-count (1+ call-count))
+ (if (= call-count 1)
+ ;; First call: select mic
+ (car (all-completions "" table))
+ ;; Second call: cancel sink
+ "Cancel"))))
+ (should-error (cj/recording-quick-setup) :type 'user-error)
+ (should (null cj/recording-system-device))))
+ (test-quick-setup-teardown)))
+
(ert-deftest test-video-audio-recording-quick-setup-error-no-mics ()
"Test that function signals error when no mics are found."
(test-quick-setup-setup)
(unwind-protect
(cl-letf (((symbol-function 'cj/recording--get-available-mics)
- (lambda () nil))
- ((symbol-function 'cj/recording--get-default-sink-monitor)
- (lambda () "default.monitor")))
+ (lambda () nil)))
(should-error (cj/recording-quick-setup) :type 'user-error))
(test-quick-setup-teardown)))
@@ -159,9 +245,7 @@
(test-quick-setup-setup)
(unwind-protect
(cl-letf (((symbol-function 'cj/recording--get-available-mics)
- (lambda () nil))
- ((symbol-function 'cj/recording--get-default-sink-monitor)
- (lambda () "default.monitor")))
+ (lambda () nil)))
(condition-case err
(cj/recording-quick-setup)
(user-error
diff --git a/tests/test-video-audio-recording-validate-system-audio.el b/tests/test-video-audio-recording-validate-system-audio.el
index fd5eafb3..a8aaed12 100644
--- a/tests/test-video-audio-recording-validate-system-audio.el
+++ b/tests/test-video-audio-recording-validate-system-audio.el
@@ -127,10 +127,9 @@
(setq logged (apply #'format fmt args)))))
(cj/recording--validate-system-audio)
;; Echo area should show the warning
- (should (cl-some (lambda (m) (string-match-p "No audio detected" m)) messages))
+ (should (cl-some (lambda (m) (string-match-p "No audio connected" m)) messages))
;; Messages buffer should have diagnostic steps
(should logged)
- (should (string-match-p "C-; r w" logged))
(should (string-match-p "C-; r s" logged))))
(test-validate-teardown)))