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
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
|
;;; video-audio-recording.el --- Video and Audio Recording -*- lexical-binding: t; coding: utf-8; -*-
;; author: Craig Jennings <c@cjennings.net>
;;
;;; Commentary:
;;
;; Desktop video and audio recording from within Emacs using ffmpeg.
;; Records from both microphone and system audio simultaneously, which
;; makes it suitable for capturing meetings, presentations, and desktop activity.
;;
;; Architecture:
;; - Audio recordings use ffmpeg directly with PulseAudio inputs → M4A/AAC
;; - Video recordings differ by display server:
;; - X11: ffmpeg with x11grab + PulseAudio → MKV
;; - Wayland: wf-recorder piped to ffmpeg for audio mixing → MKV
;; (wf-recorder captures the compositor, ffmpeg mixes in audio)
;;
;; Process lifecycle:
;; - Start: `start-process-shell-command` creates a shell running the
;; ffmpeg (or wf-recorder|ffmpeg) pipeline. Process ref is stored in
;; `cj/video-recording-ffmpeg-process' or `cj/audio-recording-ffmpeg-process'.
;; - Stop: SIGINT is sent to the shell's process group so all pipeline
;; children (wf-recorder, ffmpeg) receive it. We then poll until the
;; process actually exits, giving ffmpeg time to finalize the container.
;; - Cleanup: A process sentinel auto-clears the process variable and
;; updates the modeline if the process dies unexpectedly.
;;
;; Note: video-recordings-dir and audio-recordings-dir are defined
;; (and directory created) in user-constants.el
;;
;; Quick Start
;; ===========
;; 1. Press C-; r 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 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 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
;; Manual selection: choose mic and monitor separately.
;; Use when you need different devices for input/output.
;;
;; C-; r d (cj/recording-list-devices)
;; List all available audio devices and current configuration.
;;
;; C-; r w (cj/recording-show-active-audio) - DIAGNOSTIC TOOL
;; Show which apps are currently playing audio and through which device.
;; Use this DURING a phone call to see if the call audio is going through
;; the device you think it is. Helps diagnose "missing one side" issues.
;;
;; Testing Devices Before Important Recordings
;; ============================================
;; Always test devices before important recordings:
;;
;; C-; r t b (cj/recording-test-both) - RECOMMENDED
;; Guided test: mic only, monitor only, then both together.
;; Catches hardware issues before they ruin recordings!
;;
;; C-; r t m (cj/recording-test-mic)
;; Quick 5-second mic test with playback.
;;
;; C-; r t s (cj/recording-test-monitor)
;; Quick 5-second system audio test with playback.
;;
;; To adjust volumes:
;; - Use =M-x cj/recording-adjust-volumes= (or your keybinding =r l=)
;; - Or customize permanently: =M-x customize-group RET cj-recording RET=
;; - Or in your config:
;; #+begin_src emacs-lisp
;; (setq cj/recording-mic-boost 1.5) ; 50% louder
;; (setq cj/recording-system-volume 0.7) ; 30% quieter
;;
;;; Code:
(require 'system-lib)
;;; ============================================================
;;; 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)
(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 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 amerge filter reduces level, so 2.0x compensates.")
(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.")
;;; ============================================================
;;; 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
"Emacs process object for the active video recording shell, or nil.")
(defvar cj/audio-recording-ffmpeg-process nil
"Emacs process object for the active audio recording shell, or nil.")
;;; ============================================================
;;; Modeline Indicator
;;; ============================================================
(defun cj/recording-modeline-indicator ()
"Return modeline string showing active recordings.
Shows (microphone) for audio, (camcorder) for video.
Checks if process is actually alive, not just if variable is set."
(let ((audio-active (and cj/audio-recording-ffmpeg-process
(process-live-p cj/audio-recording-ffmpeg-process)))
(video-active (and cj/video-recording-ffmpeg-process
(process-live-p cj/video-recording-ffmpeg-process))))
(cond
((and audio-active video-active) " ")
(audio-active " ")
(video-active " ")
(t ""))))
;;; ============================================================
;;; Process Lifecycle (Sentinel and Graceful Shutdown)
;;; ============================================================
(defun cj/recording-process-sentinel (process event)
"Sentinel for recording processes — handles unexpected exits.
PROCESS is the ffmpeg shell process, EVENT describes what happened.
This is called by Emacs when the process changes state (exits, is killed, etc.).
It clears the process variable and updates the modeline so the recording indicator
disappears even if the recording crashes or is killed externally."
(when (memq (process-status process) '(exit signal))
(cond
((eq process cj/audio-recording-ffmpeg-process)
(setq cj/audio-recording-ffmpeg-process nil)
(message "Audio recording stopped: %s" (string-trim event)))
((eq process cj/video-recording-ffmpeg-process)
(setq cj/video-recording-ffmpeg-process nil)
(message "Video recording stopped: %s" (string-trim event))))
(force-mode-line-update t)))
(defun cj/recording--wait-for-exit (process timeout-secs)
"Wait for PROCESS to exit, polling until done or TIMEOUT-SECS elapsed.
Returns t if the process exited within the timeout, nil if it timed out.
This replaces fixed `sit-for' delays with an actual check that ffmpeg has
finished writing its output file. Container finalization (writing index
tables, flushing buffers) can take several seconds for large recordings,
so a fixed 0.5s wait was causing zero-byte output files."
(let ((deadline (+ (float-time) timeout-secs)))
(while (and (process-live-p process)
(< (float-time) deadline))
(accept-process-output process 0.1))
(not (process-live-p process))))
;;; ============================================================
;;; Dependency Checks
;;; ============================================================
(defun cj/recording-check-ffmpeg ()
"Check if ffmpeg is available. Error if not found."
(unless (executable-find "ffmpeg")
(user-error "Ffmpeg not found. Install with: sudo pacman -S ffmpeg")
nil)
t)
(defun cj/recording--wayland-p ()
"Return non-nil if running under Wayland."
(string= (getenv "XDG_SESSION_TYPE") "wayland"))
(defun cj/recording--check-wf-recorder ()
"Check if wf-recorder is available (needed for Wayland video capture)."
(if (executable-find "wf-recorder")
t
(user-error "wf-recorder not found. Install with: sudo pacman -S wf-recorder")
nil))
;;; ============================================================
;;; PulseAudio Device Discovery
;;; ============================================================
;;
;; Audio devices are discovered via `pactl list sources short'.
;; Two types of sources matter:
;; - Input sources (microphones): capture your voice
;; - Monitor sources (*.monitor): capture system audio output
;; These tap into what's playing through speakers/headphones,
;; which is how we capture system audio (music, calls, etc.).
;;
;; Device selection is required before first recording. The quick
;; setup (C-; r s) groups hardware devices and lets you pick one
;; device to use for both mic and monitor — ideal for headsets.
(defun cj/recording--parse-pactl-output (output)
"Parse pactl sources OUTPUT into structured list.
Returns list of (device-name driver state) tuples.
Extracted as a separate function for testability."
(let ((sources nil))
(dolist (line (split-string output "\n" t))
(when (string-match "^[0-9]+\t\\([^\t]+\\)\t\\([^\t]+\\)\t\\([^\t]+\\)\t\\([^\t]+\\)" line)
(let ((device (match-string 1 line))
(driver (match-string 2 line))
(state (match-string 4 line)))
(push (list device driver state) sources))))
(nreverse sources)))
(defun cj/recording-parse-sources ()
"Parse pactl sources output into structured list.
Returns list of (device-name driver state) tuples."
(cj/recording--parse-pactl-output
(shell-command-to-string "pactl list sources short 2>/dev/null")))
(defun cj/recording-friendly-state (state)
"Convert technical STATE name to user-friendly label.
STATE is the raw state from pactl (SUSPENDED, RUNNING, IDLE, etc.)."
(pcase state
("SUSPENDED" "Ready")
("RUNNING" "Active")
("IDLE" "Ready")
(_ state)))
(defun cj/recording--get-default-sink-monitor ()
"Return the PulseAudio monitor source for the default audio output.
The monitor source captures whatever is playing through the default sink
(music, calls, system sounds, etc.). This is the correct device
for capturing \"what I hear\" regardless of which output hardware is active."
(let ((default-sink (string-trim
(shell-command-to-string
"pactl get-default-sink 2>/dev/null"))))
(if (string-empty-p default-sink)
(user-error "No default audio output found. Is PulseAudio/PipeWire running?")
(concat default-sink ".monitor"))))
(defun cj/recording--parse-pactl-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.
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-show-active-audio ()
"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*")
(erase-buffer)
(insert "Active Audio Playback (Updated: " (format-time-string "%H:%M:%S") ")\n")
(insert "======================================================\n\n")
(insert "This shows which applications are CURRENTLY playing audio and through which device.\n")
(insert "If you're on a phone call, you should see the phone app listed here.\n")
(insert "The 'Sink' line shows which output device it's using.\n\n")
(if (string-match-p "Sink Input" output)
(progn
(insert output)
(insert "\n\nTIP: The '.monitor' device corresponding to the 'Sink' above is what\n")
(insert "you need to select for system audio to capture the other person's voice.\n\n")
(insert "For example, if Sink is 'alsa_output.usb...Jabra...analog-stereo',\n")
(insert "then you need 'alsa_output.usb...Jabra...analog-stereo.monitor'\n"))
(insert "No active audio playback detected.\n\n")
(insert "This means no applications are currently playing audio.\n")
(insert "If you're on a phone call and see this, the phone app might be:\n")
(insert " 1. Using a different audio system (not PulseAudio/PipeWire)\n")
(insert " 2. Using a Bluetooth device directly (bypassing system audio)\n")
(insert " 3. Not actually playing audio (check if you can hear the other person)\n"))
(goto-char (point-min))
(special-mode))
(switch-to-buffer-other-window "*Active Audio Playback*")
(message "Showing active audio playback. Press 'g' to refresh, 'q' to quit.")))
(defun cj/recording-select-device (prompt device-type)
"Interactively select an audio device.
PROMPT is shown to user. DEVICE-TYPE is \\='mic or \\='monitor for filtering.
Monitor devices end in .monitor (they tap system audio output).
Returns selected device name or nil."
(let* ((sources (cj/recording-parse-sources))
(filtered (if (eq device-type 'monitor)
(seq-filter (lambda (s) (string-match-p "\\.monitor$" (car s))) sources)
(seq-filter (lambda (s) (not (string-match-p "\\.monitor$" (car s)))) sources)))
(choices (mapcar (lambda (s)
(let ((device (nth 0 s))
(_driver (nth 1 s))
(_state (nth 2 s))
(friendly-state (cj/recording-friendly-state (nth 2 s))))
(cons (format "%-10s %s" friendly-state device) device)))
filtered)))
(if choices
(cdr (assoc (completing-read prompt choices nil t) choices))
(user-error "No %s devices found" (if (eq device-type 'monitor) "monitor" "input")))))
(defun cj/recording-select-devices ()
"Interactively select microphone and system audio devices separately.
Sets `cj/recording-mic-device' and `cj/recording-system-device'."
(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 physical hardware device.
Returns alist of (friendly-name . (mic-source . monitor-source)).
Only includes devices that have BOTH a mic and a monitor source,
since recording needs both to capture your voice and system audio."
(let ((sources (cj/recording-parse-sources))
(devices (make-hash-table :test 'equal))
(result nil))
;; Group sources by base device name (hardware identifier)
(dolist (source sources)
(let* ((device (nth 0 source))
;; Extract hardware ID — the unique part identifying the physical device.
;; Different device types use different naming conventions in PulseAudio.
(base-name (cond
;; USB devices: extract usb-XXXXX-XX part
((string-match "\\.\\(usb-[^.]+\\-[0-9]+\\)\\." device)
(match-string 1 device))
;; Built-in (PCI) devices: extract pci-XXXXX part
((string-match "\\.\\(pci-[^.]+\\)\\." device)
(match-string 1 device))
;; Bluetooth devices: extract and normalize MAC address
;; (input uses colons, output uses underscores)
((string-match "bluez_\\(?:input\\|output\\)\\.\\([^.]+\\)" device)
(replace-regexp-in-string "_" ":" (match-string 1 device)))
(t device)))
(is-monitor (string-match-p "\\.monitor$" device))
(device-entry (gethash base-name devices)))
(unless device-entry
(setf device-entry (cons nil nil))
(puthash base-name device-entry devices))
(if is-monitor
(setcdr device-entry device)
(setcar device-entry device))))
;; Convert hash table to alist with user-friendly names
(maphash (lambda (base-name pair)
(when (and (car pair) (cdr pair))
(let ((friendly-name
(cond
((string-match-p "usb.*[Jj]abra" base-name) "Jabra SPEAK 510 USB")
((string-match-p "^usb-" base-name) "USB Audio Device")
((string-match-p "^pci-" base-name) "Built-in Audio")
((string-match-p "^[0-9A-Fa-f:]+$" base-name) "Bluetooth Headset")
(t base-name))))
(push (cons friendly-name pair) result))))
devices)
(nreverse result)))
(defun cj/recording-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* ((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 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."
(interactive)
(unless cj/recording-mic-device
(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)
(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.
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 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)
(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 ()
"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 s first"))
(when (y-or-n-p "Test 1: Record from MICROPHONE only (5 sec). Ready? ")
(cj/recording-test-mic)
(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))
(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!"))
;;; ============================================================
;;; Device Validation
;;; ============================================================
(defun cj/recording-get-devices ()
"Get audio devices, prompting user if not already configured.
Returns (mic-device . system-device) cons cell.
If devices aren't set, goes straight into quick setup (mic selection)."
(unless (and cj/recording-mic-device cj/recording-system-device)
(cj/recording-quick-setup))
(unless (and cj/recording-mic-device cj/recording-system-device)
(user-error "Audio devices not configured. Run C-; r s (quick setup) or C-; r S (manual select)"))
(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 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
(cj/video-recording-stop)
(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 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
(cj/audio-recording-stop)
(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))))
;;; ============================================================
;;; Start Recording
;;; ============================================================
(defun cj/ffmpeg-record-video (directory)
"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 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))
(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
(if on-wayland
(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 "
"-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)))
;; X11: ffmpeg captures screen directly via x11grab
(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))))
(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
(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 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)
(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 audio recording, waiting for ffmpeg to finalize the file.
Sends SIGINT to the process group and waits up to 3 seconds for ffmpeg
to flush audio frames and write the M4A container trailer."
(interactive)
(if (not cj/audio-recording-ffmpeg-process)
(message "No audio recording in progress.")
(let ((proc cj/audio-recording-ffmpeg-process))
;; Send SIGINT to the process group (see video-recording-stop for details)
(let ((pid (process-id proc)))
(when pid
(signal-process (- pid) 2)))
;; M4A finalization is faster than MKV, but still needs time to write
;; the AAC trailer and flush the output buffer.
(let ((exited (cj/recording--wait-for-exit proc 3)))
(unless exited
(message "Warning: recording process did not exit within 3 seconds")))
;; Fallback cleanup if sentinel hasn't fired yet
(when (eq cj/audio-recording-ffmpeg-process proc)
(setq cj/audio-recording-ffmpeg-process nil)
(force-mode-line-update t))
(message "Stopped audio recording."))))
;;; ============================================================
;;; Volume Adjustment
;;; ============================================================
(defun cj/recording-adjust-volumes ()
"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)))
;;; ============================================================
;;; 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)
(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 under C-; r.")
;; 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))
(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 w" "what's playing (diagnostics)"
"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"
"C-; r t b" "test both (guided)"))
(provide 'video-audio-recording)
;;; video-audio-recording.el ends here.
|