diff options
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/calendar-sync.el | 12 | ||||
| -rw-r--r-- | modules/video-audio-recording.el | 633 |
2 files changed, 438 insertions, 207 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el index 022aff80..6753e5fe 100644 --- a/modules/calendar-sync.el +++ b/modules/calendar-sync.el @@ -125,6 +125,13 @@ Default: 3 months. This keeps recent history visible in org-agenda.") "Number of months in the future to include when expanding recurring events. Default: 12 months. This provides a full year of future events.") +(defvar calendar-sync-fetch-timeout 120 + "Maximum time in seconds for a calendar fetch to complete. +This is the total time allowed for the entire transfer (connect + download). +Large calendars (thousands of events) may need more time on slow connections. +A separate 10-second connect timeout ensures fast failure when a host is +unreachable.") + ;;; Internal state (defvar calendar-sync--timer nil @@ -1269,7 +1276,10 @@ invoked when the fetch completes, either successfully or with an error." (make-process :name "calendar-sync-curl" :buffer buffer - :command (list "curl" "-s" "-L" "-m" "30" url) + :command (list "curl" "-s" "-L" + "--connect-timeout" "10" + "--max-time" (number-to-string calendar-sync-fetch-timeout) + url) :sentinel (lambda (process event) (when (memq (process-status process) '(exit signal)) diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el index a0e9970b..5e812881 100644 --- a/modules/video-audio-recording.el +++ b/modules/video-audio-recording.el @@ -2,9 +2,27 @@ ;; author: Craig Jennings <c@cjennings.net> ;; ;;; 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. +;; +;; 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 @@ -14,7 +32,7 @@ ;; 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 +;; 4. Recording starts - you'll see σ°¬ in your modeline ;; 5. Press C-; r a again to stop (π΄ disappears) ;; ;; Device Setup (First Time Only) @@ -24,11 +42,11 @@ ;; ;; 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-quick-setup) - RECOMMENDED +;; Quick setup: pick a mic, system audio is auto-detected. +;; Works for any recording scenario. ;; -;; C-; r s (cj/recording-select-devices) - ADVANCED +;; C-; r S (cj/recording-select-devices) - ADVANCED ;; Manual selection: choose mic and monitor separately. ;; Use when you need different devices for input/output. ;; @@ -42,7 +60,7 @@ ;; ;; Testing Devices Before Important Recordings ;; ============================================ -;; Always test devices before important meetings/calls: +;; Always test devices before important recordings: ;; ;; C-; r t b (cj/recording-test-both) - RECOMMENDED ;; Guided test: mic only, monitor only, then both together. @@ -66,7 +84,11 @@ (require 'system-lib) -;; Forward declarations +;;; ============================================================ +;;; Configuration Variables +;;; ============================================================ + +;; Forward declarations for variables defined in user-constants.el (eval-when-compile (defvar video-recordings-dir)) (eval-when-compile (defvar audio-recordings-dir)) (defvar cj/custom-keymap) @@ -78,7 +100,7 @@ (defvar cj/recording-system-volume 2.0 "Volume multiplier for system audio in recordings. 1.0 = normal volume, 2.0 = double volume (+6dB), 0.5 = half volume (-6dB). -Default is 2.0 because the pan filter reduces by 50%, so final level is 1.0x.") +Default is 2.0 because the amerge filter reduces level, so 2.0x compensates.") (defvar cj/recording-mic-device nil "PulseAudio device name for microphone input. @@ -88,32 +110,49 @@ If nil, will auto-detect on first use.") "PulseAudio device name for system audio monitor. If nil, will auto-detect on first use.") +;;; ============================================================ +;;; Internal State +;;; ============================================================ + +;; 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. + (defvar cj/video-recording-ffmpeg-process nil - "Variable to store the process of the ffmpeg video recording.") + "Emacs process object for the active video recording shell, or nil.") (defvar cj/audio-recording-ffmpeg-process nil - "Variable to store the process of the ffmpeg audio recording.") + "Emacs process object for the active audio recording shell, or nil.") + +;;; ============================================================ +;;; Modeline Indicator +;;; ============================================================ -;; Modeline recording indicator (defun cj/recording-modeline-indicator () "Return modeline string showing active recordings. -Shows π΄ when recording (audio and/or video). +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) " π΄A+V ") - (audio-active " π΄Audio ") - (video-active " π΄Video ") + ((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 to clean up and update modeline. -PROCESS is the ffmpeg process, EVENT describes what happened." + "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)) - ;; Process ended - clear the variable (cond ((eq process cj/audio-recording-ffmpeg-process) (setq cj/audio-recording-ffmpeg-process nil) @@ -121,26 +160,63 @@ PROCESS is the ffmpeg process, EVENT describes what happened." ((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--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. -Return t if found, nil otherwise." + "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) -;; 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--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) - "Internal parser for pactl sources output. Takes OUTPUT string. + "Parse pactl sources OUTPUT into structured list. Returns list of (device-name driver state) tuples. -Extracted for testing without shell command execution." +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) @@ -157,13 +233,79 @@ Returns list of (device-name driver state) tuples." (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. + "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 + (_ 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-sources-verbose (output) + "Parse verbose `pactl list sources' OUTPUT into structured list. +Returns list of (name description mute state) tuples. +OUTPUT should be the full output of `pactl list sources'." + (let ((sources nil) + (current-name nil) + (current-desc nil) + (current-mute nil) + (current-state nil)) + (dolist (line (split-string output "\n")) + (cond + ((string-match "^Source #" line) + ;; Save previous source if complete + (when current-name + (push (list current-name current-desc current-mute current-state) + sources)) + (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 source + (when current-name + (push (list current-name current-desc current-mute current-state) + sources)) + (nreverse sources))) + +(defun cj/recording--get-available-mics () + "Return available microphone sources as (name . description) alist. +Filters out monitor sources and muted devices. Uses the friendly +description from PulseAudio (e.g. \"Jabra SPEAK 510 Mono\") rather +than the raw device name." + (let* ((output (shell-command-to-string "pactl list sources 2>/dev/null")) + (sources (cj/recording--parse-pactl-sources-verbose output)) + (mics nil)) + (dolist (source sources) + (let ((name (nth 0 source)) + (desc (nth 1 source)) + (mute (nth 2 source))) + ;; Include non-monitor, non-muted sources + (when (and (not (string-match-p "\\.monitor$" name)) + (not (equal mute "yes"))) + (push (cons name (or desc name)) mics)))) + (nreverse mics))) + +;;; ============================================================ +;;; Device Selection UI +;;; ============================================================ (defun cj/recording-list-devices () "Show all available audio sources in a readable format. @@ -183,7 +325,7 @@ Opens a buffer showing devices with their states." (dolist (source sources) (let ((device (nth 0 source)) (driver (nth 1 source)) - (state (nth 2 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)))) @@ -193,9 +335,9 @@ Opens a buffer showing devices with their states." (switch-to-buffer-other-window "*Recording Devices*"))) (defun cj/recording-show-active-audio () - "Show which audio sinks are currently PLAYING audio in real-time. -Useful for diagnosing why phone call audio isn't being captured - helps identify -which device the phone app is actually using for output." + "Show which audio sinks are currently PLAYING audio. +Useful for diagnosing why phone call audio isn't being captured β helps +identify which device the phone app is actually using for output." (interactive) (let ((output (shell-command-to-string "pactl list sink-inputs"))) (with-current-buffer (get-buffer-create "*Active Audio Playback*") @@ -225,7 +367,8 @@ which device the phone app is actually using for output." (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. +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) @@ -233,8 +376,8 @@ Returns selected device name or nil." (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)) + (_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))) @@ -243,8 +386,8 @@ Returns selected device name or nil." (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." + "Interactively select microphone and system audio devices separately. +Sets `cj/recording-mic-device' and `cj/recording-system-device'." (interactive) (setq cj/recording-mic-device (cj/recording-select-device "Select microphone device: " 'mic)) @@ -255,25 +398,27 @@ Sets cj/recording-mic-device and cj/recording-system-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))." + "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 + ;; Group sources by base device name (hardware identifier) (dolist (source sources) (let* ((device (nth 0 source)) - (driver (nth 1 source)) - ;; Extract hardware ID (the unique part that identifies the physical device) + ;; 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 + ;; 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) + ;; (input uses colons, output uses underscores) ((string-match "bluez_\\(?:input\\|output\\)\\.\\([^.]+\\)" device) (replace-regexp-in-string "_" ":" (match-string 1 device))) (t device))) @@ -282,14 +427,13 @@ Returns alist of (device-name . (mic-source . monitor-source))." (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 + ;; Convert hash table to alist with user-friendly names (maphash (lambda (base-name pair) - (when (and (car pair) (cdr pair)) ; Only include if we have both mic and monitor + (when (and (car pair) (cdr pair)) (let ((friendly-name (cond ((string-match-p "usb.*[Jj]abra" base-name) "Jabra SPEAK 510 USB") @@ -301,35 +445,52 @@ Returns alist of (device-name . (mic-source . monitor-source))." 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." +(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. + +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* ((grouped-devices (cj/recording-group-devices-by-hardware)) - (choices (mapcar #'car grouped-devices))) + (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) - (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)))))) + (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)))) + (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))))))) + +;;; ============================================================ +;;; 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-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." + "Test microphone by recording 5 seconds and playing it back." (interactive) (unless cj/recording-mic-device - (user-error "No microphone configured. Run C-; r c first")) - + (user-error "No microphone configured. Run C-; r s first")) (let* ((temp-file (make-temp-file "mic-test-" nil ".wav")) (duration 5)) (message "Recording from mic for %d seconds... SPEAK NOW!" duration) @@ -345,13 +506,10 @@ Useful for verifying mic hardware works before important recordings." (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." +Play some audio/video during the test so there's something to capture." (interactive) (unless cj/recording-system-device - (user-error "No system monitor configured. Run C-; r c first")) - + (user-error "No system monitor configured. Run C-; r s first")) (let* ((temp-file (make-temp-file "monitor-test-" nil ".wav")) (duration 5)) (message "Recording system audio for %d seconds... PLAY SOMETHING NOW!" duration) @@ -366,24 +524,23 @@ conference call audio, YouTube, etc." (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!" + "Guided test of both mic and monitor together. +Runs three sequential tests: + 1. Mic only β speak into it + 2. Monitor only β play audio/video + 3. 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")) + (user-error "Devices not configured. Run C-; r s 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 + (sit-for 6)) (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 + (sit-for 6)) (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")) @@ -405,31 +562,32 @@ 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) or nil on error." - ;; If devices not set, prompt user to select them +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) - (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 + (cj/recording-quick-setup)) (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)")) - + (user-error "Audio devices not configured. Run C-; r s (quick setup) or C-; r S (manual select)")) (cons cj/recording-mic-device cj/recording-system-device)) +;;; ============================================================ +;;; Toggle Commands (User-Facing) +;;; ============================================================ + (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). +On first use (or when devices not configured), runs quick setup (C-; r s). 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)) @@ -440,14 +598,12 @@ Otherwise use the default location in `video-recordings-dir'." (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). +On first use (or when devices not configured), runs quick setup (C-; r s). 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)) @@ -456,24 +612,24 @@ Otherwise use the default location in `audio-recordings-dir'." (make-directory directory t)) (cj/ffmpeg-record-audio location)))) -(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). -Return t if found, nil otherwise." - (if (executable-find "wf-recorder") - t - (user-error "wf-recorder not found. Install with: sudo pacman -S wf-recorder") - nil)) +;;; ============================================================ +;;; Start Recording +;;; ============================================================ (defun cj/ffmpeg-record-video (directory) - "Start a video recording. Save output to DIRECTORY. -Uses wf-recorder on Wayland, x11grab on X11." + "Start a video recording, saving output to DIRECTORY. +Uses wf-recorder on Wayland, x11grab on X11. + +On Wayland, the pipeline is: + wf-recorder (captures screen β H.264) | ffmpeg (mixes in audio β MKV) + +On X11, ffmpeg handles everything: + ffmpeg (x11grab for screen + PulseAudio for audio β MKV)" (cj/recording-check-ffmpeg) (unless cj/video-recording-ffmpeg-process - ;; On Wayland, kill any orphan wf-recorder processes from previous crashes + ;; 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. (when (cj/recording--wayland-p) (call-process "pkill" nil nil nil "-INT" "wf-recorder") (sit-for 0.1)) @@ -486,10 +642,12 @@ Uses wf-recorder on Wayland, x11grab on X11." (on-wayland (cj/recording--wayland-p)) (record-command (if on-wayland - ;; Wayland: wf-recorder pipes H264 video to ffmpeg for audio mixing - ;; wf-recorder outputs matroska container with H264, ffmpeg adds audio (progn (cj/recording--check-wf-recorder) + ;; Wayland pipeline: wf-recorder captures screen as H.264 in + ;; matroska container, piped to ffmpeg which adds mic + system + ;; audio via PulseAudio, then writes the final MKV. + ;; -c:v copy means ffmpeg passes video through without re-encoding. (format (concat "wf-recorder -y -c libx264 -m matroska -f /dev/stdout 2>/dev/null | " "ffmpeg -i pipe:0 " "-f pulse -i %s " @@ -503,7 +661,7 @@ Uses wf-recorder on Wayland, x11grab on X11." cj/recording-mic-boost cj/recording-system-volume (shell-quote-argument filename))) - ;; X11: use x11grab directly + ;; X11: ffmpeg captures screen directly via x11grab (format (concat "ffmpeg -framerate 30 -f x11grab -i :0.0+ " "-f pulse -i %s " "-ac 1 " @@ -517,7 +675,6 @@ Uses wf-recorder on Wayland, x11grab on X11." 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*" @@ -531,111 +688,175 @@ Uses wf-recorder on Wayland, x11grab on X11." cj/recording-mic-boost cj/recording-system-volume)))) (defun cj/ffmpeg-record-audio (directory) - "Start an ffmpeg audio recording. Save output to DIRECTORY. -Records from microphone and system audio monitor (configured device), mixing them together. -Use C-; r c to configure which device to use - it must match the device your phone call uses." + "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)) - ;; Use the explicitly configured monitor device - ;; This must match the device your phone call/audio is using - (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 " ; Input 0: Microphone (specific device) - "-f pulse -i %s " ; Input 1: System audio monitor - "-filter_complex \"" - "[0:a]volume=%.1f[mic];" ; Apply mic boost - "[1:a]volume=%.1f[sys];" ; Apply system volume - "[mic][sys]amix=inputs=2:duration=longest[out]\" " ; Mix both inputs - "-map \"[out]\" " - "-c:a aac " - "-b:a 64k " - "%s") - mic-device - system-device - cj/recording-mic-boost - cj/recording-system-volume - filename))) - ;; Log the command for debugging - (message "Recording from mic: %s + ALL system outputs" mic-device) - (cj/log-silently "Audio recording ffmpeg command: %s" ffmpeg-command) - ;; 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 recording to %s (mic: %.1fx, all system audio: %.1fx)" - filename cj/recording-mic-boost cj/recording-system-volume)))) + (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 " ; 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") + mic-device + system-device + cj/recording-mic-boost + cj/recording-system-volume + 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/video-recording-stop () - "Stop the ffmpeg video recording process." + "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 cj/video-recording-ffmpeg-process - (progn - ;; On Wayland, we run wf-recorder | ffmpeg pipeline. - ;; SIGINT only reaches ffmpeg, leaving wf-recorder as orphan. - ;; Kill wf-recorder explicitly first, then ffmpeg will exit naturally. - (when (cj/recording--wayland-p) - (call-process "pkill" nil nil nil "-INT" "wf-recorder")) - ;; 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.5) - (setq cj/video-recording-ffmpeg-process nil) - (force-mode-line-update t) - (message "Stopped video recording.")) - (message "No video recording in progress."))) + (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) + (call-process "pkill" nil nil nil "-INT" "wf-recorder") + (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: kill any straggler wf-recorder on Wayland. + (when (cj/recording--wayland-p) + (call-process "pkill" nil nil nil "-INT" "wf-recorder")) + ;; 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 ffmpeg audio recording process." + "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 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."))) + (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 +;;; ============================================================ (defun cj/recording-adjust-volumes () - "Interactively adjust recording volume levels." + "Interactively adjust recording volume levels. +Changes take effect on the next recording (not the current one)." (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 + 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))) + +;;; ============================================================ +;;; Keybindings +;;; ============================================================ + +;; All recording operations are under the C-; r prefix. (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 "w") #'cj/recording-show-active-audio) ; "w" for "what's playing" - (define-key map (kbd "s") #'cj/recording-select-devices) - (define-key map (kbd "c") #'cj/recording-quick-setup-for-calls) + (define-key map (kbd "w") #'cj/recording-show-active-audio) + (define-key map (kbd "s") #'cj/recording-quick-setup) + (define-key map (kbd "S") #'cj/recording-select-devices) (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 for video/audio recording operations under C-; r.") -;; Only set keybinding if cj/custom-keymap is bound (not in batch mode) +;; Only bind keys when running interactively (not in batch/test mode) (when (boundp 'cj/custom-keymap) (keymap-set cj/custom-keymap "r" cj/record-map)) @@ -647,8 +868,8 @@ Use C-; r c to configure which device to use - it must match the device your pho "C-; r l" "adjust levels" "C-; r d" "list devices" "C-; r w" "what's playing (diagnostics)" - "C-; r s" "select devices" - "C-; r c" "quick setup for calls" + "C-; r s" "quick setup" + "C-; r S" "select devices (advanced)" "C-; r t" "test devices" "C-; r t m" "test microphone" "C-; r t s" "test system audio" |
