;;; video-audio-recording.el --- Video and Audio Recording -*- lexical-binding: t; coding: utf-8; -*- ;; author: Craig Jennings ;; ;;; Commentary: ;; Use ffmpeg to record desktop video or just audio. ;; Records audio from both microphone and system audio (for calls/meetings). ;; Audio recordings use M4A/AAC format for best compatibility. ;; ;; Note: video-recordings-dir and audio-recordings-dir are defined ;; (and directory created) in user-constants.el ;; ;; 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 🔴Audio in your modeline ;; 5. Press C-; r a again to stop (🔴 disappears) ;; ;; Device Setup (First Time Only) ;; =============================== ;; C-; r a automatically prompts for device selection on first use. ;; Device selection persists across Emacs sessions. ;; ;; Manual device selection: ;; ;; C-; r c (cj/recording-quick-setup-for-calls) - RECOMMENDED ;; Quick setup: picks one device for both mic and monitor. ;; Perfect for calls, meetings, or when using headset. ;; ;; 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. ;; ;; Testing Devices Before Important Recordings ;; ============================================ ;; Always test devices before important meetings/calls: ;; ;; 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 ;; ;;; Code: ;; Forward declarations (eval-when-compile (defvar video-recordings-dir)) (eval-when-compile (defvar audio-recordings-dir)) (defvar cj/custom-keymap) (defvar cj/recording-mic-boost 2.0 "Volume multiplier for microphone in recordings. 1.0 = normal volume, 2.0 = double volume (+6dB), 0.5 = half volume (-6dB).") (defvar cj/recording-system-volume 0.5 "Volume multiplier for system audio in recordings. 1.0 = normal volume, 2.0 = double volume (+6dB), 0.5 = half volume (-6dB).") (defvar cj/recording-mic-device nil "PulseAudio device name for microphone input. If nil, will auto-detect on first use.") (defvar cj/recording-system-device nil "PulseAudio device name for system audio monitor. If nil, will auto-detect on first use.") (defvar cj/video-recording-ffmpeg-process nil "Variable to store the process of the ffmpeg video recording.") (defvar cj/audio-recording-ffmpeg-process nil "Variable to store the process of the ffmpeg audio recording.") ;; Modeline recording indicator (defun cj/recording-modeline-indicator () "Return modeline string showing active recordings. Shows 🔴 when recording (audio and/or 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) " 🔴A+V ") (audio-active " 🔴Audio ") (video-active " 🔴Video ") (t "")))) (defun cj/recording-process-sentinel (process event) "Sentinel for recording processes to clean up and update modeline. PROCESS is the ffmpeg process, EVENT describes what happened." (when (memq (process-status process) '(exit signal)) ;; Process ended - clear the variable (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 modeline update (force-mode-line-update t))) (defun cj/recording-check-ffmpeg () "Check if ffmpeg is available. Return t if found, nil otherwise." (unless (executable-find "ffmpeg") (user-error "Ffmpeg not found. Install with: sudo pacman -S ffmpeg") nil) t) ;; Auto-detection functions removed - they were unreliable and preferred built-in ;; audio over Bluetooth/USB devices. Use explicit device selection instead: ;; - C-; r c (cj/recording-quick-setup-for-calls) - recommended for most use cases ;; - C-; r s (cj/recording-select-devices) - manual selection of mic + monitor (defun cj/recording--parse-pactl-output (output) "Internal parser for pactl sources output. Takes OUTPUT string. Returns list of (device-name driver state) tuples. Extracted for testing without shell command execution." (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))) ; fallback to original if unknown (defun cj/recording-list-devices () "Show all available audio sources in a readable format. Opens a buffer showing devices with their states." (interactive) (let ((sources (cj/recording-parse-sources))) (with-current-buffer (get-buffer-create "*Recording Devices*") (erase-buffer) (insert "Available Audio Sources\n") (insert "========================\n\n") (insert "Note: 'Ready' devices are available and will activate when recording starts.\n\n") (insert "Current Configuration:\n") (insert (format " Microphone: %s\n" (or cj/recording-mic-device "Not set"))) (insert (format " System Audio: %s\n\n" (or cj/recording-system-device "Not set"))) (insert "Available Devices:\n\n") (if sources (dolist (source sources) (let ((device (nth 0 source)) (driver (nth 1 source)) (state (nth 2 source)) (friendly-state (cj/recording-friendly-state (nth 2 source)))) (insert (format "%-10s [%s]\n" friendly-state driver)) (insert (format " %s\n\n" device)))) (insert " No audio sources found. Is PulseAudio/PipeWire running?\n")) (goto-char (point-min)) (special-mode)) (switch-to-buffer-other-window "*Recording Devices*"))) (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. 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. Sets cj/recording-mic-device and cj/recording-system-device." (interactive) (setq cj/recording-mic-device (cj/recording-select-device "Select microphone device: " 'mic)) (setq cj/recording-system-device (cj/recording-select-device "Select system audio monitor: " 'monitor)) (message "Devices set - Mic: %s, System: %s" cj/recording-mic-device cj/recording-system-device)) (defun cj/recording-group-devices-by-hardware () "Group audio sources by hardware device. Returns alist of (device-name . (mic-source . monitor-source))." (let ((sources (cj/recording-parse-sources)) (devices (make-hash-table :test 'equal)) (result nil)) ;; Group sources by base device name (dolist (source sources) (let* ((device (nth 0 source)) (driver (nth 1 source)) ;; Extract hardware ID (the unique part that identifies the physical device) (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 - normalize to colons) ((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)) ;; Store mic or monitor in the pair (if is-monitor (setcdr device-entry device) (setcar device-entry device)))) ;; Convert hash table to alist with friendly names (maphash (lambda (base-name pair) (when (and (car pair) (cdr pair)) ; Only include if we have both mic and monitor (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 Laptop 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-quick-setup-for-calls () "Quick setup for recording call/meetings. Detects available audio devices and lets you pick one device to use for both microphone (your voice) and monitor (remote person + sound effects). Perfect for recording video calls, phone calls, or presentations." (interactive) (let* ((grouped-devices (cj/recording-group-devices-by-hardware)) (choices (mapcar #'car grouped-devices))) (if (null choices) (user-error "No complete audio devices found (need both mic and monitor)") (let* ((choice (completing-read "Which device are you using for the call? " choices nil t)) (device-pair (cdr (assoc choice grouped-devices))) (mic (car device-pair)) (monitor (cdr device-pair))) (setq cj/recording-mic-device mic) (setq cj/recording-system-device monitor) (message "Call recording ready! Using: %s\n Mic: %s\n Monitor: %s" choice (file-name-nondirectory mic) (file-name-nondirectory monitor)))))) (defun cj/recording-test-mic () "Test microphone by recording 5 seconds and playing it back. Records from configured mic device, saves to temp file, plays back. Useful for verifying mic hardware works before important recordings." (interactive) (unless cj/recording-mic-device (user-error "No microphone configured. Run C-; r c first")) (let* ((temp-file (make-temp-file "mic-test-" nil ".wav")) (duration 5)) (message "Recording from mic for %d seconds... SPEAK NOW!" duration) (shell-command (format "ffmpeg -f pulse -i %s -t %d -y %s 2>/dev/null" (shell-quote-argument cj/recording-mic-device) duration (shell-quote-argument temp-file))) (message "Playing back recording...") (shell-command (format "ffplay -autoexit -nodisp %s 2>/dev/null &" (shell-quote-argument temp-file))) (message "Mic test complete. Temp file: %s" temp-file))) (defun cj/recording-test-monitor () "Test system audio monitor by recording 5 seconds and playing it back. Records from configured monitor device (system audio output). Play some audio/video during test. Useful for verifying you can capture conference call audio, YouTube, etc." (interactive) (unless cj/recording-system-device (user-error "No system monitor configured. Run C-; r c first")) (let* ((temp-file (make-temp-file "monitor-test-" nil ".wav")) (duration 5)) (message "Recording system audio for %d seconds... PLAY SOMETHING NOW!" duration) (shell-command (format "ffmpeg -f pulse -i %s -t %d -y %s 2>/dev/null" (shell-quote-argument cj/recording-system-device) duration (shell-quote-argument temp-file))) (message "Playing back recording...") (shell-command (format "ffplay -autoexit -nodisp %s 2>/dev/null &" (shell-quote-argument temp-file))) (message "Monitor test complete. Temp file: %s" temp-file))) (defun cj/recording-test-both () "Test both mic and monitor together with guided prompts. This simulates a real recording scenario: 1. Tests mic only (speak into it) 2. Tests monitor only (play audio/video) 3. Tests both together (speak while audio plays) Run this before important recordings to verify everything works!" (interactive) (unless (and cj/recording-mic-device cj/recording-system-device) (user-error "Devices not configured. Run C-; r c first")) (when (y-or-n-p "Test 1: Record from MICROPHONE only (5 sec). Ready? ") (cj/recording-test-mic) (sit-for 6)) ; Wait for playback (when (y-or-n-p "Test 2: Record from SYSTEM AUDIO only (5 sec). Start playing audio/video, then press y: ") (cj/recording-test-monitor) (sit-for 6)) ; Wait for playback (when (y-or-n-p "Test 3: Record BOTH mic + system audio (5 sec). Speak while audio plays, then press y: ") (let* ((temp-file (make-temp-file "both-test-" nil ".wav")) (duration 5)) (message "Recording BOTH for %d seconds... SPEAK + PLAY AUDIO NOW!" duration) (shell-command (format "ffmpeg -f pulse -i %s -f pulse -i %s -filter_complex \"[0:a]volume=%.1f[mic];[1:a]volume=%.1f[sys];[mic][sys]amix=inputs=2:duration=longest\" -t %d -y %s 2>/dev/null" (shell-quote-argument cj/recording-mic-device) (shell-quote-argument cj/recording-system-device) cj/recording-mic-boost cj/recording-system-volume duration (shell-quote-argument temp-file))) (message "Playing back recording...") (shell-command (format "ffplay -autoexit -nodisp %s 2>/dev/null &" (shell-quote-argument temp-file))) (sit-for 6) (message "All tests complete! Temp file: %s" temp-file))) (message "Device testing complete. If you heard audio in all tests, recording will work!")) (defun cj/recording-get-devices () "Get audio devices, prompting user if not already configured. Returns (mic-device . system-device) or nil on error." ;; If devices not set, prompt user to select them (unless (and cj/recording-mic-device cj/recording-system-device) (if (y-or-n-p "Audio devices not configured. Use quick setup for calls? ") (cj/recording-quick-setup-for-calls) (cj/recording-select-devices))) ;; Final validation (unless (and cj/recording-mic-device cj/recording-system-device) (user-error "Audio devices not configured. Run C-; r c (quick setup) or C-; r s (manual select)")) (cons cj/recording-mic-device cj/recording-system-device)) (defun cj/video-recording-toggle (arg) "Toggle video recording: start if not recording, stop if recording. On first use (or when devices not configured), runs quick setup (C-; r c). With prefix ARG, prompt for recording location. Otherwise use the default location in `video-recordings-dir'." (interactive "P") (if cj/video-recording-ffmpeg-process ;; Recording in progress - stop it (cj/video-recording-stop) ;; Not recording - start it (let* ((location (if arg (read-directory-name "Enter recording location: ") video-recordings-dir)) (directory (file-name-directory location))) (unless (file-directory-p directory) (make-directory directory t)) (cj/ffmpeg-record-video location)))) (defun cj/audio-recording-toggle (arg) "Toggle audio recording: start if not recording, stop if recording. On first use (or when devices not configured), runs quick setup (C-; r c). With prefix ARG, prompt for recording location. Otherwise use the default location in `audio-recordings-dir'." (interactive "P") (if cj/audio-recording-ffmpeg-process ;; Recording in progress - stop it (cj/audio-recording-stop) ;; Not recording - start it (let* ((location (if arg (read-directory-name "Enter recording location: ") audio-recordings-dir)) (directory (file-name-directory location))) (unless (file-directory-p directory) (make-directory directory t)) (cj/ffmpeg-record-audio location)))) (defun cj/ffmpeg-record-video (directory) "Start an ffmpeg video recording. Save output to DIRECTORY." (cj/recording-check-ffmpeg) (unless cj/video-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 ".mkv") location)) (ffmpeg-command (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") mic-device system-device cj/recording-mic-boost cj/recording-system-volume filename))) ;; start the recording (setq cj/video-recording-ffmpeg-process (start-process-shell-command "ffmpeg-video-recording" "*ffmpeg-video-recording*" ffmpeg-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 (mic: %.1fx, system: %.1fx)." filename cj/recording-mic-boost cj/recording-system-volume)))) (defun cj/ffmpeg-record-audio (directory) "Start an ffmpeg audio recording. Save output to DIRECTORY." (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 (format (concat "ffmpeg " "-f pulse -i %s " "-ac 1 " "-f pulse -i %s " "-ac 1 " "-filter_complex \"[0:a]volume=%.1f[mic];[1:a]volume=%.1f[sys];[mic][sys]amerge=inputs=2[out];[out]pan=mono|c0=0.5*c0+0.5*c1\" " "-c:a aac " "-b:a 64k " "%s") mic-device system-device cj/recording-mic-boost cj/recording-system-volume filename))) ;; start the recording (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 audio recording to %s (mic: %.1fx, system: %.1fx)." filename cj/recording-mic-boost cj/recording-system-volume)))) (defun cj/video-recording-stop () "Stop the ffmpeg video recording process." (interactive) (if cj/video-recording-ffmpeg-process (progn ;; Use interrupt-process to send SIGINT (graceful termination) (interrupt-process cj/video-recording-ffmpeg-process) ;; Give ffmpeg a moment to finalize the file (sit-for 0.2) (setq cj/video-recording-ffmpeg-process nil) (force-mode-line-update t) (message "Stopped video recording.")) (message "No video recording in progress."))) (defun cj/audio-recording-stop () "Stop the ffmpeg audio recording process." (interactive) (if cj/audio-recording-ffmpeg-process (progn ;; Use interrupt-process to send SIGINT (graceful termination) (interrupt-process cj/audio-recording-ffmpeg-process) ;; Give ffmpeg a moment to finalize the file (sit-for 0.2) (setq cj/audio-recording-ffmpeg-process nil) (force-mode-line-update t) (message "Stopped audio recording.")) (message "No audio recording in progress."))) (defun cj/recording-adjust-volumes () "Interactively adjust recording volume levels." (interactive) (let ((mic (read-number "Microphone boost (1.0 = normal, 2.0 = double): " cj/recording-mic-boost)) (sys (read-number "System audio level (1.0 = normal, 0.5 = half): " cj/recording-system-volume))) (setq cj/recording-mic-boost mic) (setq cj/recording-system-volume sys) (message "Recording levels updated - Mic: %.1fx, System: %.1fx" mic sys))) ;; Recording operations prefix and keymap (defvar cj/record-map (let ((map (make-sparse-keymap))) (define-key map (kbd "v") #'cj/video-recording-toggle) (define-key map (kbd "a") #'cj/audio-recording-toggle) (define-key map (kbd "l") #'cj/recording-adjust-volumes) (define-key map (kbd "d") #'cj/recording-list-devices) (define-key map (kbd "s") #'cj/recording-select-devices) (define-key map (kbd "c") #'cj/recording-quick-setup-for-calls) (define-key map (kbd "t m") #'cj/recording-test-mic) (define-key map (kbd "t s") #'cj/recording-test-monitor) (define-key map (kbd "t b") #'cj/recording-test-both) map) "Keymap for video/audio recording operations.") (keymap-set cj/custom-keymap "r" cj/record-map) (with-eval-after-load 'which-key (which-key-add-key-based-replacements "C-; r" "recording menu" "C-; r v" "toggle video recording" "C-; r a" "toggle audio recording" "C-; r l" "adjust levels" "C-; r d" "list devices" "C-; r s" "select devices" "C-; r c" "quick setup for calls" "C-; r t" "test devices" "C-; r t m" "test microphone" "C-; r t s" "test system audio" "C-; r t b" "test both (guided)")) (provide 'video-audio-recording) ;;; video-audio-recording.el ends here.