diff options
Diffstat (limited to 'modules/video-audio-recording.el')
| -rw-r--r-- | modules/video-audio-recording.el | 831 |
1 files changed, 21 insertions, 810 deletions
diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el index 4c934ef17..10c108541 100644 --- a/modules/video-audio-recording.el +++ b/modules/video-audio-recording.el @@ -6,108 +6,29 @@ ;; Layer: 4 (Optional). ;; Category: O/D/S. ;; Load shape: eager. -;; Eager reason: none; registers a recording keymap, but device probing should -;; run only on command (command-loaded target). -;; Top-level side effects: defines cj/record-map and conditionally registers it -;; under C-; r. -;; Runtime requires: system-lib, keybindings. -;; Direct test load: yes (requires keybindings explicitly). +;; Eager reason: none; records only on command, but registers C-; r at load. +;; Top-level side effects: defines cj/record-map and registers it when possible. +;; Runtime requires: system-lib, keybindings, video-audio-recording-devices, +;; video-audio-recording-capture. +;; Direct test load: yes. ;; -;; Desktop video and audio recording from within Emacs using ffmpeg. -;; Records from both microphone and system audio simultaneously, which -;; makes it suitable for capturing meetings, presentations, and desktop activity. -;; -;; Architecture: -;; - Audio recordings use ffmpeg directly with PulseAudio inputs → M4A/AAC -;; - Video recordings differ by display server: -;; - X11: ffmpeg with x11grab + PulseAudio → MKV -;; - Wayland: wf-recorder piped to ffmpeg for audio mixing → MKV -;; (wf-recorder captures the compositor, ffmpeg mixes in audio) -;; -;; Process lifecycle: -;; - Start: `start-process-shell-command` creates a shell running the -;; ffmpeg (or wf-recorder|ffmpeg) pipeline. Process ref is stored in -;; `cj/video-recording-ffmpeg-process' or `cj/audio-recording-ffmpeg-process'. -;; - Stop: SIGINT is sent to the shell's process group so all pipeline -;; children (wf-recorder, ffmpeg) receive it. We then poll until the -;; process actually exits, giving ffmpeg time to finalize the container. -;; - Cleanup: A process sentinel auto-clears the process variable and -;; updates the modeline if the process dies unexpectedly. -;; -;; Note: video-recordings-dir and audio-recordings-dir are defined -;; (and directory created) in user-constants.el -;; -;; Quick Start -;; =========== -;; 1. Press C-; r s to run quick setup -;; 2. Pick a microphone from the list -;; 3. Pick an audio output — [in use] shows which apps are playing -;; 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 -;; ============ -;; C-; r a automatically prompts for device selection on first use. -;; Device selection lasts for the current Emacs session only. -;; -;; Manual device selection: -;; -;; C-; r s (cj/recording-quick-setup) - RECOMMENDED -;; Two-step setup: pick a mic, then pick an audio output to capture. -;; Both steps show status: [in use], [ready], [available], [muted]. -;; Audio outputs also show which apps are playing through them. -;; Sorted: in use → ready → available → muted. -;; -;; C-; r S (cj/recording-select-devices) - ADVANCED -;; Manual selection: choose mic and monitor separately. -;; Use when you need different devices for input/output. -;; -;; C-; r d (cj/recording-list-devices) -;; List all available audio devices and current configuration. -;; -;; C-; r w (cj/recording-show-active-audio) - DIAGNOSTIC TOOL -;; Show which apps are currently playing audio and through which device. -;; Use this DURING a phone call to see if the call audio is going through -;; the device you think it is. Helps diagnose "missing one side" issues. -;; -;; Pre-Recording Validation -;; ======================== -;; Every time you start a recording, the system audio device is -;; validated automatically: -;; 1. If the configured monitor device no longer exists (e.g. -;; USB DAC unplugged), it's auto-updated to the current -;; default sink's monitor. -;; 2. If no audio is currently playing through the monitored sink, -;; a warning is shown in the echo area. Recording proceeds -;; without interruption — run C-; r s to see active streams. -;; -;; Testing Devices Before Important Recordings -;; ============================================ -;; Always test devices before important recordings: -;; -;; C-; r t b (cj/recording-test-both) - RECOMMENDED -;; Guided test: mic only, monitor only, then both together. -;; Catches hardware issues before they ruin recordings! -;; -;; C-; r t m (cj/recording-test-mic) -;; Quick 5-second mic test with playback. -;; -;; C-; r t s (cj/recording-test-monitor) -;; Quick 5-second system audio test with playback. -;; -;; To adjust volumes: -;; - Use =M-x cj/recording-adjust-volumes= (or your keybinding =r l=) -;; - Or customize permanently: =M-x customize-group RET cj-recording RET= -;; - Or in your config: -;; #+begin_src emacs-lisp -;; (setq cj/recording-mic-boost 1.5) ; 50% louder -;; (setq cj/recording-system-volume 0.7) ; 30% quieter +;; Starts and stops ffmpeg-backed audio/video recordings from Emacs. Audio +;; captures microphone plus system monitor; video uses x11grab on X11 and +;; wf-recorder piped into ffmpeg on Wayland. ;; +;; This is the public face of the module: it owns configuration and the +;; recording process-handle state, the device-diagnostic and device-test +;; commands, the toggle commands, and the C-; r keymap. PulseAudio +;; discovery lives in video-audio-recording-devices and the ffmpeg capture +;; engine in video-audio-recording-capture, both required here. Every +;; public name is unchanged so existing callers and tests keep working. + ;;; Code: (require 'system-lib) (require 'keybindings) ;; provides cj/custom-keymap +(require 'video-audio-recording-devices) +(require 'video-audio-recording-capture) ;;; ============================================================ ;;; Configuration Variables @@ -141,7 +62,8 @@ If nil, will auto-detect on first use.") ;; These hold the Emacs process objects for running recordings. ;; The process is the shell that runs the ffmpeg (or wf-recorder|ffmpeg) -;; pipeline. When non-nil, a recording is in progress. +;; pipeline. When non-nil, a recording is in progress. The capture engine +;; reads and clears them; the toggle commands below read them. (defvar cj/video-recording-ffmpeg-process nil "Emacs process object for the active video recording shell, or nil.") @@ -150,203 +72,7 @@ If nil, will auto-detect on first use.") "Emacs process object for the active audio recording shell, or nil.") ;;; ============================================================ -;;; Modeline Indicator -;;; ============================================================ - -(defun cj/recording-modeline-indicator () - "Return modeline string showing active recordings. -Shows 🎤 (microphone) for audio, 🎬 (clapper board) for video. -Checks if process is actually alive, not just if variable is set." - (let ((audio-active (and cj/audio-recording-ffmpeg-process - (process-live-p cj/audio-recording-ffmpeg-process))) - (video-active (and cj/video-recording-ffmpeg-process - (process-live-p cj/video-recording-ffmpeg-process)))) - (cond - ((and audio-active video-active) " 🎤🎬 ") - (audio-active " 🎤 ") - (video-active " 🎬 ") - (t "")))) - -;;; ============================================================ -;;; Process Lifecycle (Sentinel and Graceful Shutdown) -;;; ============================================================ - -(defun cj/recording-process-sentinel (process event) - "Sentinel for recording processes — handles unexpected exits. -PROCESS is the ffmpeg shell process, EVENT describes what happened. -This is called by Emacs when the process changes state (exits, is killed, etc.). -It clears the process variable and updates the modeline so the recording indicator -disappears even if the recording crashes or is killed externally." - (when (memq (process-status process) '(exit signal)) - (cond - ((eq process cj/audio-recording-ffmpeg-process) - (setq cj/audio-recording-ffmpeg-process nil) - (message "Audio recording stopped: %s" (string-trim event))) - ((eq process cj/video-recording-ffmpeg-process) - (setq cj/video-recording-ffmpeg-process nil) - (message "Video recording stopped: %s" (string-trim event)))) - (force-mode-line-update t))) - -(defun cj/recording--wait-for-exit (process timeout-secs) - "Wait for PROCESS to exit, polling until done or TIMEOUT-SECS elapsed. -Returns t if the process exited within the timeout, nil if it timed out. - -This replaces fixed `sit-for' delays with an actual check that ffmpeg has -finished writing its output file. Container finalization (writing index -tables, flushing buffers) can take several seconds for large recordings, -so a fixed 0.5s wait was causing zero-byte output files." - (let ((deadline (+ (float-time) timeout-secs))) - (while (and (process-live-p process) - (< (float-time) deadline)) - (accept-process-output process 0.1)) - (not (process-live-p process)))) - -;;; ============================================================ -;;; Dependency Checks -;;; ============================================================ - -(defun cj/recording-check-ffmpeg () - "Check if ffmpeg is available. Error if not found." - (unless (executable-find "ffmpeg") - (user-error "Ffmpeg not found. Install with: sudo pacman -S ffmpeg") - nil) - t) - -(defun cj/recording--wayland-p () - "Return non-nil if running under Wayland." - (string= (getenv "XDG_SESSION_TYPE") "wayland")) - -(defun cj/recording--check-wf-recorder () - "Check if wf-recorder is available (needed for Wayland video capture)." - (if (executable-find "wf-recorder") - t - (user-error "wf-recorder not found. Install with: sudo pacman -S wf-recorder") - nil)) - -;;; ============================================================ -;;; PulseAudio Device Discovery -;;; ============================================================ -;; -;; Audio devices are discovered via `pactl list sources short'. -;; Two types of sources matter: -;; - Input sources (microphones): capture your voice -;; - Monitor sources (*.monitor): capture system audio output -;; These tap into what's playing through speakers/headphones, -;; which is how we capture system audio (music, calls, etc.). -;; -;; Device selection is required before first recording. The quick -;; setup (C-; r s) groups hardware devices and lets you pick one -;; device to use for both mic and monitor — ideal for headsets. - -(defun cj/recording--parse-pactl-output (output) - "Parse pactl sources OUTPUT into structured list. -Returns list of (device-name driver state) tuples. -Extracted as a separate function for testability." - (let ((sources nil)) - (dolist (line (split-string output "\n" t)) - (when (string-match "^[0-9]+\t\\([^\t]+\\)\t\\([^\t]+\\)\t\\([^\t]+\\)\t\\([^\t]+\\)" line) - (let ((device (match-string 1 line)) - (driver (match-string 2 line)) - (state (match-string 4 line))) - (push (list device driver state) sources)))) - (nreverse sources))) - -(defun cj/recording-parse-sources () - "Parse pactl sources output into structured list. -Returns list of (device-name driver state) tuples." - (cj/recording--parse-pactl-output - (shell-command-to-string "pactl list sources short 2>/dev/null"))) - -(defun cj/recording-friendly-state (state) - "Convert technical STATE name to user-friendly label. -STATE is the raw state from pactl (SUSPENDED, RUNNING, IDLE, etc.)." - (pcase state - ("SUSPENDED" "Ready") - ("RUNNING" "Active") - ("IDLE" "Ready") - (_ state))) - -(defun cj/recording--get-default-sink-monitor () - "Return the PulseAudio monitor source for the default audio output. -The monitor source captures whatever is playing through the default sink -(music, calls, system sounds, etc.). This is the correct device -for capturing \"what I hear\" regardless of which output hardware is active." - (let ((default-sink (string-trim - (shell-command-to-string - "pactl get-default-sink 2>/dev/null")))) - (if (string-empty-p default-sink) - (user-error "No default audio output found. Is PulseAudio/PipeWire running?") - (concat default-sink ".monitor")))) - -(defun cj/recording--parse-pactl-verbose (output record-type) - "Parse verbose pactl OUTPUT into structured list. -RECORD-TYPE is \"Source\" or \"Sink\" — the record header in pactl output. -Returns list of (name description mute state) tuples." - (let ((entries nil) - (header-re (concat "^" record-type " #")) - (current-name nil) - (current-desc nil) - (current-mute nil) - (current-state nil)) - (dolist (line (split-string output "\n")) - (cond - ((string-match-p header-re line) - (when current-name - (push (list current-name current-desc current-mute current-state) - entries)) - (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))))) - (when current-name - (push (list current-name current-desc current-mute current-state) - entries)) - (nreverse entries))) - -(defun cj/recording--get-available-mics () - "Return available microphone sources as list of (name description state mute). -Filters out monitor sources but includes muted devices (shown with -a [muted] label in the UI). Uses the friendly description from -PulseAudio (e.g. \"Jabra SPEAK 510 Mono\") rather than the raw -device name. State is the PulseAudio state string (RUNNING, IDLE, -or SUSPENDED). Mute is \"yes\" or \"no\"." - (let* ((output (shell-command-to-string "pactl list sources 2>/dev/null")) - (sources (cj/recording--parse-pactl-verbose output "Source")) - (mics nil)) - (dolist (source sources) - (let ((name (nth 0 source)) - (desc (nth 1 source)) - (mute (nth 2 source)) - (state (nth 3 source))) - (when (not (string-match-p "\\.monitor$" name)) - (push (list name (or desc name) state mute) mics)))) - (nreverse mics))) - -(defun cj/recording--get-available-sinks () - "Return available audio sinks as list of (name description state mute). -Includes muted sinks (shown with a [muted] label in the UI). Uses -the friendly description from PulseAudio (e.g. \"JDS Labs Element IV -Analog Stereo\"). State is the PulseAudio state string (RUNNING, -IDLE, or SUSPENDED). Mute is \"yes\" or \"no\"." - (let* ((output (shell-command-to-string "pactl list sinks 2>/dev/null")) - (sinks (cj/recording--parse-pactl-verbose output "Sink")) - (result nil)) - (dolist (sink sinks) - (let ((name (nth 0 sink)) - (desc (nth 1 sink)) - (mute (nth 2 sink)) - (state (nth 3 sink))) - (push (list name (or desc name) state mute) result))) - (nreverse result))) - -;;; ============================================================ -;;; Device Selection UI +;;; Device Diagnostics and Selection Commands ;;; ============================================================ (defun cj/recording-list-devices () @@ -407,26 +133,6 @@ identify which device the phone app is actually using for output." (switch-to-buffer-other-window "*Active Audio Playback*") (message "Showing active audio playback. Press 'g' to refresh, 'q' to quit."))) -(defun cj/recording-select-device (prompt device-type) - "Interactively select an audio device. -PROMPT is shown to user. DEVICE-TYPE is \\='mic or \\='monitor for filtering. -Monitor devices end in .monitor (they tap system audio output). -Returns selected device name or nil." - (let* ((sources (cj/recording-parse-sources)) - (filtered (if (eq device-type 'monitor) - (seq-filter (lambda (s) (string-match-p "\\.monitor$" (car s))) sources) - (seq-filter (lambda (s) (not (string-match-p "\\.monitor$" (car s)))) sources))) - (choices (mapcar (lambda (s) - (let ((device (nth 0 s)) - (_driver (nth 1 s)) - (_state (nth 2 s)) - (friendly-state (cj/recording-friendly-state (nth 2 s)))) - (cons (format "%-10s %s" friendly-state device) device))) - filtered))) - (if choices - (cdr (assoc (completing-read prompt choices nil t) choices)) - (user-error "No %s devices found" (if (eq device-type 'monitor) "monitor" "input"))))) - (defun cj/recording-select-devices () "Interactively select microphone and system audio devices separately. Sets `cj/recording-mic-device' and `cj/recording-system-device'." @@ -439,191 +145,9 @@ Sets `cj/recording-mic-device' and `cj/recording-system-device'." cj/recording-mic-device cj/recording-system-device)) -(defun cj/recording-group-devices-by-hardware () - "Group audio sources by physical hardware device. -Returns alist of (friendly-name . (mic-source . monitor-source)). -Only includes devices that have BOTH a mic and a monitor source, -since recording needs both to capture your voice and system audio." - (let ((sources (cj/recording-parse-sources)) - (devices (make-hash-table :test 'equal)) - (result nil)) - ;; Group sources by base device name (hardware identifier) - (dolist (source sources) - (let* ((device (nth 0 source)) - ;; Extract hardware ID — the unique part identifying the physical device. - ;; Different device types use different naming conventions in PulseAudio. - (base-name (cond - ;; USB devices: extract usb-XXXXX-XX part - ((string-match "\\.\\(usb-[^.]+\\-[0-9]+\\)\\." device) - (match-string 1 device)) - ;; Built-in (PCI) devices: extract pci-XXXXX part - ((string-match "\\.\\(pci-[^.]+\\)\\." device) - (match-string 1 device)) - ;; Bluetooth devices: extract and normalize MAC address - ;; (input uses colons, output uses underscores) - ((string-match "bluez_\\(?:input\\|output\\)\\.\\([^.]+\\)" device) - (replace-regexp-in-string "_" ":" (match-string 1 device))) - (t device))) - (is-monitor (string-match-p "\\.monitor$" device)) - (device-entry (gethash base-name devices))) - (unless device-entry - (setf device-entry (cons nil nil)) - (puthash base-name device-entry devices)) - (if is-monitor - (setcdr device-entry device) - (setcar device-entry device)))) - - ;; Convert hash table to alist with user-friendly names - (maphash (lambda (base-name pair) - (when (and (car pair) (cdr pair)) - (let ((friendly-name - (cond - ((string-match-p "usb.*[Jj]abra" base-name) "Jabra SPEAK 510 USB") - ((string-match-p "^usb-" base-name) "USB Audio Device") - ((string-match-p "^pci-" base-name) "Built-in Audio") - ((string-match-p "^[0-9A-Fa-f:]+$" base-name) "Bluetooth Headset") - (t base-name)))) - (push (cons friendly-name pair) result)))) - devices) - (nreverse result))) - -(defun cj/recording--device-sort-key (state muted) - "Return a numeric sort key for a device with STATE and MUTED flag. -Lower values sort first: RUNNING (0) → IDLE (1) → SUSPENDED (2) → muted (3)." - (if (equal muted "yes") - 3 - (pcase (upcase (or state "")) - ("RUNNING" 0) - ("IDLE" 1) - (_ 2)))) - -(defun cj/recording--device-status-label (state muted) - "Return a human-readable status label for a device. -MUTED is \"yes\" or \"no\". STATE is the PulseAudio state string." - (if (equal muted "yes") - "[muted]" - (pcase (upcase (or state "")) - ("RUNNING" "[in use]") - ("IDLE" "[ready]") - (_ "[available]")))) - -(defun cj/recording--label-devices (devices) - "Build labeled (label . name) alist from DEVICES for `completing-read'. -DEVICES is a list of (name description state mute) as returned by -`cj/recording--get-available-mics' or `cj/recording--get-available-sinks'. -Labels are formatted as \"Description [in use]\" etc. -Sorted: in use → ready → available → muted." - (let* ((labeled (mapcar - (lambda (dev) - (let* ((name (nth 0 dev)) - (desc (nth 1 dev)) - (state (nth 2 dev)) - (muted (nth 3 dev)) - (label (concat desc " " - (cj/recording--device-status-label state muted)))) - (list label name (cj/recording--device-sort-key state muted)))) - devices)) - (sorted (sort labeled (lambda (a b) (< (nth 2 a) (nth 2 b)))))) - (mapcar (lambda (entry) (cons (nth 0 entry) (nth 1 entry))) sorted))) - -(defun cj/recording--get-sink-apps () - "Return alist mapping sink index to list of application names. -Parses `pactl list sink-inputs' to find which apps are playing -audio through each sink." - (let ((output (shell-command-to-string "pactl list sink-inputs 2>/dev/null")) - (apps (make-hash-table :test 'equal)) - (current-sink nil)) - (dolist (line (split-string output "\n")) - (cond - ((string-match "^Sink Input #" line) - (setq current-sink nil)) - ((string-match "^[ \t]+Sink:[ \t]+\\([0-9]+\\)" line) - (setq current-sink (match-string 1 line))) - ((and current-sink - (string-match "application\\.name = \"\\([^\"]+\\)\"" line)) - (let ((existing (gethash current-sink apps))) - (unless (member (match-string 1 line) existing) - (puthash current-sink - (append existing (list (match-string 1 line))) - apps)))))) - ;; Convert hash to alist - (let ((result nil)) - (maphash (lambda (k v) (push (cons k v) result)) apps) - result))) - -(defun cj/recording--label-sinks (sinks) - "Build labeled (label . name) alist from SINKS for `completing-read'. -Like `cj/recording--label-devices' but also appends application names -for sinks with active audio streams. E.g. \"JDS Labs [in use] (Firefox)\"." - (let* ((sink-apps (cj/recording--get-sink-apps)) - (sinks-short (shell-command-to-string "pactl list sinks short 2>/dev/null")) - (labeled - (mapcar - (lambda (dev) - (let* ((name (nth 0 dev)) - (desc (nth 1 dev)) - (state (nth 2 dev)) - (muted (nth 3 dev)) - (index (cj/recording--get-sink-index name sinks-short)) - (apps (and index (cdr (assoc index sink-apps)))) - (status (cj/recording--device-status-label state muted)) - (app-str (if apps (concat " (" (string-join apps ", ") ")") "")) - (label (concat desc " " status app-str))) - (list label name (cj/recording--device-sort-key state muted)))) - sinks)) - (sorted (sort labeled (lambda (a b) (< (nth 2 a) (nth 2 b)))))) - (mapcar (lambda (entry) (cons (nth 0 entry) (nth 1 entry))) sorted))) - -(defun cj/recording--select-from-labeled (prompt entries) - "Prompt user with PROMPT to select from labeled ENTRIES. -ENTRIES is an alist of (label . device-name). Appends a Cancel option. -Returns the selected device name, or signals user-error if cancelled." - (let* ((alist (append entries '(("Cancel" . nil)))) - (choice (completing-read prompt - (lambda (string pred action) - (if (eq action 'metadata) - '(metadata (display-sort-function . identity)) - (complete-with-action action alist string pred))) - nil t)) - (device (cdr (assoc choice alist)))) - (unless device - (user-error "Device setup cancelled")) - device)) - -(defun cj/recording-quick-setup () - "Quick device setup for recording — two-step mic + sink selection. -Step 1: Pick a microphone. Each mic shows its status: - [in use] = an app is actively using this mic - [ready] = recently used, still open - [available] = no app has this mic open - [muted] = device is muted in PulseAudio -Step 2: Pick an audio output to capture. Same status labels, plus -application names for outputs with active streams (e.g. \"Firefox\"). -Devices are sorted: in use → ready → available → muted. -The chosen output'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) - (let* ((mic-entries (cj/recording--label-devices (cj/recording--get-available-mics)))) - (unless mic-entries - (user-error "No microphones found. Is a mic connected?")) - (let ((mic-device (cj/recording--select-from-labeled "Select microphone: " mic-entries)) - (sink-entries (cj/recording--label-sinks (cj/recording--get-available-sinks)))) - (let ((sink-device (cj/recording--select-from-labeled "Select audio output to capture: " sink-entries))) - (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" - (car (rassoc mic-device mic-entries)) - (file-name-nondirectory sink-device)))))) - ;;; ============================================================ ;;; Device Testing ;;; ============================================================ -;; -;; These functions record short clips and play them back so you can -;; verify hardware works BEFORE an important recording. (defun cj/recording--test-device (device prefix prompt-action) "Record 5 seconds from DEVICE and play it back. @@ -697,91 +221,6 @@ Run this before important recordings to verify everything works." (message "Device testing complete. If you heard audio in all tests, recording will work!")) ;;; ============================================================ -;;; Device Validation -;;; ============================================================ - -(defun cj/recording-get-devices () - "Get audio devices, prompting user if not already configured. -Returns (mic-device . system-device) cons cell. -If devices aren't set, goes straight into quick setup (mic selection)." - (unless (and cj/recording-mic-device cj/recording-system-device) - (cj/recording-quick-setup)) - (unless (and cj/recording-mic-device cj/recording-system-device) - (user-error "Audio devices not configured. Run C-; r s (quick setup) or C-; r S (manual select)")) - (cj/recording--validate-system-audio) - (cons cj/recording-mic-device cj/recording-system-device)) - -(defun cj/recording--source-exists-p (source-name pactl-output) - "Return non-nil if SOURCE-NAME exists in PACTL-OUTPUT. -PACTL-OUTPUT should be the output of `pactl list sources short'." - (let ((found nil)) - (dolist (line (split-string pactl-output "\n" t)) - (when (string-match "^[0-9]+\t\\([^\t]+\\)\t" line) - (when (equal source-name (match-string 1 line)) - (setq found t)))) - found)) - -(defun cj/recording--get-sink-index (sink-name sinks-output) - "Return the numeric index of SINK-NAME from SINKS-OUTPUT. -SINKS-OUTPUT should be the output of `pactl list sinks short'. -Returns the index as a string, or nil if not found." - (let ((index nil)) - (dolist (line (split-string sinks-output "\n" t)) - (when (string-match "^\\([0-9]+\\)\t\\([^\t]+\\)\t" line) - (when (equal sink-name (match-string 2 line)) - (setq index (match-string 1 line))))) - index)) - -(defun cj/recording--sink-has-active-audio-p (sink-index pactl-output) - "Return non-nil if SINK-INDEX has active audio streams. -PACTL-OUTPUT should be the output of `pactl list sink-inputs'. -SINK-INDEX is the numeric sink index as a string." - (let ((found nil) - (lines (split-string pactl-output "\n"))) - (dolist (line lines) - (when (string-match "^[ \t]+Sink:[ \t]+\\([0-9]+\\)" line) - (when (equal sink-index (match-string 1 line)) - (setq found t)))) - found)) - -(defun cj/recording--validate-system-audio () - "Validate that the configured system audio device will capture audio. -Checks two things: -1. Does the configured device still exist as a PulseAudio source? -2. Is anything currently playing through the monitored sink? - -Auto-fixes stale devices by falling back to the default sink's monitor. -Warns (but doesn't block) if no audio is currently playing. -Respects the user's explicit sink choice from quick-setup." - (when cj/recording-system-device - (let* ((sources-output (shell-command-to-string "pactl list sources short 2>/dev/null")) - (current-default (cj/recording--get-default-sink-monitor)) - (device-exists (cj/recording--source-exists-p - cj/recording-system-device sources-output))) - ;; Check 1: Device no longer exists — auto-update - (unless device-exists - (let ((old cj/recording-system-device)) - (setq cj/recording-system-device current-default) - (message "System audio device updated: %s → %s (old device no longer exists)" - old current-default))) - ;; Check 2: No active audio on the monitored sink — warn - (let* ((sink-name (if (string-suffix-p ".monitor" cj/recording-system-device) - (substring cj/recording-system-device 0 -8) - cj/recording-system-device)) - (sinks-output (shell-command-to-string "pactl list sinks short 2>/dev/null")) - (sink-index (cj/recording--get-sink-index sink-name sinks-output)) - (sink-inputs (shell-command-to-string "pactl list sink-inputs 2>/dev/null")) - (has-audio (and sink-index - (cj/recording--sink-has-active-audio-p sink-index sink-inputs)))) - (unless has-audio - (message "Warning: No audio connected to %s. Run C-; r s to check devices" - sink-name) - (cj/log-silently - (concat "No audio connected to %s. " - "Run C-; r s to see active streams and switch devices") - sink-name)))))) - -;;; ============================================================ ;;; Toggle Commands (User-Facing) ;;; ============================================================ @@ -824,235 +263,6 @@ Otherwise use the default location in `audio-recordings-dir'." (cj/ffmpeg-record-audio directory)))) ;;; ============================================================ -;;; Start Recording -;;; ============================================================ - -(defun cj/recording--build-video-command (mic-device system-device filename on-wayland) - "Build the shell command string for video recording. -MIC-DEVICE and SYSTEM-DEVICE are PulseAudio device names. -FILENAME is the output .mkv path. ON-WAYLAND selects the capture method. - -On Wayland: wf-recorder captures screen as H.264 in matroska container, -piped to ffmpeg which adds mic + system audio, then writes the final MKV. - -On X11: ffmpeg captures screen directly via x11grab with PulseAudio audio." - (if on-wayland - (progn - (cj/recording--check-wf-recorder) - (format (concat "wf-recorder -y -c libx264 -m matroska -f /dev/stdout 2>/dev/null | " - "ffmpeg -i pipe:0 " - "-f pulse -i %s " - "-f pulse -i %s " - "-filter_complex \"[1:a]volume=%.1f[mic];[2:a]volume=%.1f[sys];[mic][sys]amerge=inputs=2[out]\" " - "-map 0:v -map \"[out]\" " - "-c:v copy " - "%s") - (shell-quote-argument mic-device) - (shell-quote-argument system-device) - cj/recording-mic-boost - cj/recording-system-volume - (shell-quote-argument filename))) - (format (concat "ffmpeg -framerate 30 -f x11grab -i :0.0+ " - "-f pulse -i %s " - "-ac 1 " - "-f pulse -i %s " - "-ac 2 " - "-filter_complex \"[1:a]volume=%.1f[mic];[2:a]volume=%.1f[sys];[mic][sys]amerge=inputs=2[out]\" " - "-map 0:v -map \"[out]\" " - "%s") - (shell-quote-argument mic-device) - (shell-quote-argument system-device) - cj/recording-mic-boost - cj/recording-system-volume - (shell-quote-argument filename)))) - -(defun cj/recording--build-audio-command (mic-device system-device filename) - "Build the ffmpeg shell command string for audio-only recording. -MIC-DEVICE and SYSTEM-DEVICE are PulseAudio device names. FILENAME is -the output .m4a path. Mixes mic + system monitor into a single AAC file." - (format (concat "ffmpeg " - "-f pulse -i %s " ; Input 0: microphone - "-f pulse -i %s " ; Input 1: system audio monitor - "-filter_complex \"" - "[0:a]volume=%.1f[mic];" - "[1:a]volume=%.1f[sys];" - "[mic][sys]amix=inputs=2:duration=longest[out]\" " - "-map \"[out]\" " - "-c:a aac " - "-b:a 64k " - "%s") - (shell-quote-argument mic-device) - (shell-quote-argument system-device) - cj/recording-mic-boost - cj/recording-system-volume - (shell-quote-argument filename))) - -(defun cj/ffmpeg-record-video (directory) - "Start a video recording, saving output to DIRECTORY. -Uses wf-recorder on Wayland, x11grab on X11." - (cj/recording-check-ffmpeg) - (unless cj/video-recording-ffmpeg-process - ;; On Wayland, kill any orphan wf-recorder processes left over from - ;; previous crashes. Without this, old wf-recorders hold the compositor - ;; capture and new ones fail silently. This one stays a broad by-name - ;; kill on purpose: the orphans' launching shells are already dead, so - ;; there is no live PID to scope to. The stop path, by contrast, scopes - ;; to our own shell's child (see cj/recording--interrupt-child-wf-recorder). - (when (cj/recording--wayland-p) - (call-process "pkill" nil nil nil "-INT" "wf-recorder") - (sit-for 0.1)) - (let* ((devices (cj/recording-get-devices)) - (mic-device (car devices)) - (system-device (cdr devices)) - (location (expand-file-name directory)) - (name (format-time-string "%Y-%m-%d-%H-%M-%S")) - (filename (expand-file-name (concat name ".mkv") location)) - (on-wayland (cj/recording--wayland-p)) - (record-command (cj/recording--build-video-command - mic-device system-device filename on-wayland))) - (setq cj/video-recording-ffmpeg-process - (start-process-shell-command "ffmpeg-video-recording" - "*ffmpeg-video-recording*" - record-command)) - (set-process-query-on-exit-flag cj/video-recording-ffmpeg-process nil) - (set-process-sentinel cj/video-recording-ffmpeg-process #'cj/recording-process-sentinel) - (force-mode-line-update t) - (message "Started video recording to %s (%s, mic: %.1fx, system: %.1fx)." - filename - (if on-wayland "Wayland/wf-recorder" "X11") - cj/recording-mic-boost cj/recording-system-volume)))) - -(defun cj/ffmpeg-record-audio (directory) - "Start an audio recording, saving output to DIRECTORY. -Records from microphone and system audio monitor (configured device), -mixing them together into a single M4A/AAC file. - -The filter graph mixes two PulseAudio inputs: - [mic] → volume boost → amerge → AAC encoder → .m4a - [sys] → volume boost ↗" - (cj/recording-check-ffmpeg) - (unless cj/audio-recording-ffmpeg-process - (let* ((devices (cj/recording-get-devices)) - (mic-device (car devices)) - (system-device (cdr devices)) - (location (expand-file-name directory)) - (name (format-time-string "%Y-%m-%d-%H-%M-%S")) - (filename (expand-file-name (concat name ".m4a") location)) - (ffmpeg-command - (cj/recording--build-audio-command mic-device system-device filename))) - (message "Recording from mic: %s + ALL system outputs" mic-device) - (cj/log-silently "Audio recording ffmpeg command: %s" ffmpeg-command) - (setq cj/audio-recording-ffmpeg-process - (start-process-shell-command "ffmpeg-audio-recording" - "*ffmpeg-audio-recording*" - ffmpeg-command)) - (set-process-query-on-exit-flag cj/audio-recording-ffmpeg-process nil) - (set-process-sentinel cj/audio-recording-ffmpeg-process #'cj/recording-process-sentinel) - (force-mode-line-update t) - (message "Started recording to %s (mic: %.1fx, all system audio: %.1fx)" - filename cj/recording-mic-boost cj/recording-system-volume)))) - -;;; ============================================================ -;;; Stop Recording -;;; ============================================================ -;; -;; Stopping a recording requires careful process management, especially -;; on Wayland where we have a two-process pipeline (wf-recorder | ffmpeg). -;; -;; Wayland shutdown order (CRITICAL — order matters!): -;; 1. Kill wf-recorder first (the producer). This closes the pipe -;; to ffmpeg, giving ffmpeg a clean EOF on its video input. -;; 2. Signal the process group with SIGINT so ffmpeg begins its -;; graceful shutdown (flushing audio, writing container metadata). -;; 3. Wait for the shell/ffmpeg to actually exit. MKV container -;; finalization (index tables, seek entries) can take several -;; seconds. A fixed `sit-for' is insufficient. -;; 4. Kill any remaining wf-recorder as a safety net. -;; -;; Why producer-first matters: In a `wf-recorder | ffmpeg` pipeline, -;; sending SIGINT to all processes simultaneously causes ffmpeg to -;; abort mid-stream (no clean EOF on pipe:0). The result is no output -;; file at all. Killing the producer first lets ffmpeg see EOF, start -;; its orderly shutdown, and then SIGINT reinforces "stop now." -;; -;; X11 shutdown: simpler — ffmpeg is the only process, so we just -;; send SIGINT to the process group and wait. - -(defun cj/recording--interrupt-child-wf-recorder (shell-pid) - "Send SIGINT to the wf-recorder child of SHELL-PID, if any. -Scopes the producer-first stop to the wf-recorder this module launched -\(a child of our recording shell) via `pkill -P', instead of killing -every wf-recorder on the system by name. Does nothing when SHELL-PID -is nil (the shell already exited, so there is no child to signal)." - (when shell-pid - (call-process "pkill" nil nil nil - "-INT" "-P" (number-to-string shell-pid) "wf-recorder"))) - -(defun cj/video-recording-stop () - "Stop the video recording, waiting for ffmpeg to finalize the file. -On Wayland, kills wf-recorder first so ffmpeg gets a clean EOF on its -video input pipe, then signals the process group. Waits up to 5 seconds -for ffmpeg to write container metadata before giving up." - (interactive) - (if (not cj/video-recording-ffmpeg-process) - (message "No video recording in progress.") - (let ((proc cj/video-recording-ffmpeg-process)) - ;; On Wayland, kill the producer (wf-recorder) FIRST so ffmpeg sees - ;; a clean EOF on pipe:0. This triggers ffmpeg's orderly shutdown: - ;; drain remaining frames, write container metadata, close file. - ;; Without this, simultaneous SIGINT to both causes ffmpeg to abort - ;; without creating a file. - (when (cj/recording--wayland-p) - (cj/recording--interrupt-child-wf-recorder (process-id proc)) - (sit-for 0.3)) ; Brief pause for pipe to close - ;; Now send SIGINT to the process group. On Wayland, this reaches - ;; ffmpeg (which is already shutting down from the pipe EOF) and - ;; reinforces the stop. On X11, this is the primary shutdown signal. - (let ((pid (process-id proc))) - (when pid - (signal-process (- pid) 2))) ; 2 = SIGINT - ;; Wait for ffmpeg to finalize the container. MKV files need index - ;; tables written at the end — without this wait, the file is truncated. - (let ((exited (cj/recording--wait-for-exit proc 5))) - (unless exited - (message "Warning: recording process did not exit within 5 seconds"))) - ;; Safety net: signal our own straggler wf-recorder on Wayland. - ;; If the shell already exited, process-id returns nil and this is - ;; a no-op (the child is already gone with it). - (when (cj/recording--wayland-p) - (cj/recording--interrupt-child-wf-recorder (process-id proc))) - ;; The sentinel handles clearing cj/video-recording-ffmpeg-process - ;; and updating the modeline. If the process already exited during - ;; our wait, the sentinel has already fired. If not, force cleanup. - (when (eq cj/video-recording-ffmpeg-process proc) - (setq cj/video-recording-ffmpeg-process nil) - (force-mode-line-update t)) - (message "Stopped video recording.")))) - -(defun cj/audio-recording-stop () - "Stop the audio recording, waiting for ffmpeg to finalize the file. -Sends SIGINT to the process group and waits up to 3 seconds for ffmpeg -to flush audio frames and write the M4A container trailer." - (interactive) - (if (not cj/audio-recording-ffmpeg-process) - (message "No audio recording in progress.") - (let ((proc cj/audio-recording-ffmpeg-process)) - ;; Send SIGINT to the process group (see video-recording-stop for details) - (let ((pid (process-id proc))) - (when pid - (signal-process (- pid) 2))) - ;; M4A finalization is faster than MKV, but still needs time to write - ;; the AAC trailer and flush the output buffer. - (let ((exited (cj/recording--wait-for-exit proc 3))) - (unless exited - (message "Warning: recording process did not exit within 3 seconds"))) - ;; Fallback cleanup if sentinel hasn't fired yet - (when (eq cj/audio-recording-ffmpeg-process proc) - (setq cj/audio-recording-ffmpeg-process nil) - (force-mode-line-update t)) - (message "Stopped audio recording.")))) - -;;; ============================================================ ;;; Volume Adjustment ;;; ============================================================ @@ -1105,5 +315,6 @@ Changes take effect on the next recording (not the current one)." "C-; r t s" "test system audio" "C-; r t b" "test both (guided)")) + (provide 'video-audio-recording) ;;; video-audio-recording.el ends here. |
