diff options
Diffstat (limited to 'modules/video-audio-recording-capture.el')
| -rw-r--r-- | modules/video-audio-recording-capture.el | 394 |
1 files changed, 394 insertions, 0 deletions
diff --git a/modules/video-audio-recording-capture.el b/modules/video-audio-recording-capture.el new file mode 100644 index 000000000..069975bc3 --- /dev/null +++ b/modules/video-audio-recording-capture.el @@ -0,0 +1,394 @@ +;;; video-audio-recording-capture.el --- ffmpeg capture engine and process lifecycle -*- lexical-binding: t; coding: utf-8; -*- + +;; Author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; Layer: 4 (Optional). +;; Category: O/S. +;; Load shape: library. +;; Top-level side effects: none (defuns only). +;; Runtime requires: subr-x, system-lib, video-audio-recording-devices. +;; Direct test load: yes (requires video-audio-recording-devices explicitly). +;; +;; Capture engine for video-audio-recording: ffmpeg / wf-recorder command +;; construction, the recording process lifecycle (sentinel, graceful +;; producer-first shutdown, exit polling), the modeline indicator, +;; dependency checks, device acquisition and validation, and the start/stop +;; entry points. Builds on the devices layer for discovery and selection. +;; +;; Configuration and the recording process-handle variables are owned by +;; the top video-audio-recording module; they are forward-declared here so +;; the engine reads and updates them without a back-require onto the top +;; module. + +;;; Code: + +(require 'subr-x) +(require 'system-lib) ;; provides cj/log-silently +(require 'video-audio-recording-devices) + +;; Configuration and process-handle state owned by the top module; +;; declared special here so the engine reads and updates them without a +;; back-require onto video-audio-recording.el. +(defvar cj/recording-mic-boost) +(defvar cj/recording-system-volume) +(defvar cj/recording-mic-device) +(defvar cj/recording-system-device) +(defvar cj/video-recording-ffmpeg-process) +(defvar cj/audio-recording-ffmpeg-process) + +;;; 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)) + +;;; Device Acquisition and Validation + +(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)))))) + +(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--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)))))) + +;;; ffmpeg Command Construction + +(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))) + +;;; Start Recording + +(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 + +(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.")))) + +(provide 'video-audio-recording-capture) +;;; video-audio-recording-capture.el ends here |
