1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
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
|