summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-02-26 17:46:11 -0600
committerCraig Jennings <c@cjennings.net>2026-02-26 17:46:11 -0600
commit46af687f2444754657000116178eeb80addd5a52 (patch)
tree8ccbf758ad9ce58fedd149dec06508b1b860c7bd
parentc43805f86a6f6b87a3f75ab8ece2610905344ec9 (diff)
feat(recording): show sinks with active audio indicators in quick-setup
Quick-setup (C-; r s) is now a two-step flow: pick a mic, then pick an audio output sink. Sinks display 󰕾/󰖁 icons with green/dim coloring to indicate which have active audio streams, with active sinks sorted to the top. The chosen sink's .monitor is set as the system audio device. This replaces the old auto-default-sink approach, letting users see where audio is actually going and pick the right sink in one command.
-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)))