diff options
| -rw-r--r-- | modules/video-audio-recording.el | 158 | ||||
| -rw-r--r-- | tests/fixtures/pactl-sinks-verbose-muted.txt | 30 | ||||
| -rw-r--r-- | tests/fixtures/pactl-sinks-verbose-normal.txt | 30 | ||||
| -rw-r--r-- | tests/test-video-audio-recording--get-available-sinks.el | 80 | ||||
| -rw-r--r-- | tests/test-video-audio-recording--parse-pactl-sinks-verbose.el | 93 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-quick-setup.el | 182 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-validate-system-audio.el | 3 |
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))) |
