Categories: geek » emacs

RSS - Atom - Subscribe via email

Using an Emacs Lisp macro to define quick custom Org Mode links to project files; plus URLs and search

| org, emacs, coding
  • [2024-01-12 Fri] Added embark action to copy the exported link URL.
  • [2024-01-11 Thu] Switched to using Github links since Codeberg's down.
  • [2024-01-11 Thu] Updated my-copy-link to just return the link if called from Emacs Lisp. Fix getting the properties.
  • [2024-01-08 Mon] Add tip from Omar about embark-around-action-hooks
  • [2024-01-08 Mon] Simplify code by using consult--grep-position

Summary (882 words): Emacs macros make it easy to define sets of related functions for custom Org links. This makes it easier to link to projects and export or copy the links to the files in the web-based repos. You can also use that information to consult-ripgrep across lots of projects.

I'd like to get better at writing notes while coding and at turning those notes into blog posts and videos. I want to be able to link to files in projects easily with the ability to complete, follow, and export links. For example, [[subed:subed.el]] should become subed.el, which opens the file if I'm in Emacs and exports a link if I'm publishing a post. I've been making custom link types using org-link-set-parameters. I think it's time to make a macro that defines that set of functions for me. Emacs Lisp macros are a great way to write code to write code.

(defvar my-project-web-base-list nil "Local path . web repo URLs for easy linking.")

(defmacro my-org-project-link (type file-path git-url)
  `(progn
     (defun ,(intern (format "my-org-%s-complete" type)) ()
       ,(format "Complete a file from %s." type)
       (concat ,type ":" (completing-read "File: "
                                          (projectile-project-files ,file-path))))
     (defun ,(intern (format "my-org-%s-follow" type)) (link _)
       ,(format "Open a file from %s." type)
       (find-file
        (expand-file-name
         link
         ,file-path)))
     (defun ,(intern (format "my-org-%s-export" type)) (link desc format _)
       "Export link to file."
       (setq desc (or desc link))
       (when ,git-url
         (setq link (concat ,git-url (replace-regexp-in-string "^/" "" link))))
       (pcase format
         ((or 'html '11ty) (format "<a href=\"%s\">%s</a>"
                                   link
                                   (or desc link)))
         ('md (if desc (format "[%s](%s)" desc link)
                (format "<%s>" link)))
         ('latex (format "\\href{%s}{%s}" link desc))
         ('texinfo (format "@uref{%s,%s}" link desc))
         ('ascii (format "%s (%s)" desc link))
         (_ (format "%s (%s)" desc link))))
     (with-eval-after-load 'org
       (org-link-set-parameters
        ,type
        :complete (quote ,(intern (format "my-org-%s-complete" type)))
        :export (quote ,(intern (format "my-org-%s-export" type)))
        :follow (quote ,(intern (format "my-org-%s-follow" type))))
       (cl-pushnew (cons (expand-file-name ,file-path) ,git-url)
                   my-project-web-base-list
                   :test 'equal))))

Then I can define projects this way:

(my-org-project-link "subed"
                     "~/proj/subed/subed/"
                     "https://github.com/sachac/subed/blob/main/subed/"
                     ;; "https://codeberg.org/sachac/subed/src/branch/main/subed/"
                     )
(my-org-project-link "emacsconf-el"
                     "~/proj/emacsconf/lisp/"
                     "https://git.emacsconf.org/emacsconf-el/tree/")
(my-org-project-link "subed-record"
                     "~/proj/subed-record/"
                     "https://github.com/sachac/subed-record/blob/main/"
                     ;; "https://codeberg.org/sachac/subed-record/src/branch/main/"
                     )
(my-org-project-link "compile-media"
                     "~/proj/compile-media/"
                     "https://github.com/sachac/compile-media/blob/main/"
                     ;; "https://codeberg.org/sachac/compile-media/src/branch/main/"
                     )
(my-org-project-link "ox-11ty"
                     "~/proj/ox-11ty/"
                     "https://github.com/sachac/ox-11ty/blob/master/")

And I can complete them with the usual C-c C-l (org-insert-link) process:

completing-custom-links.gif
Figure 1: Completing a custom link with org-insert-link

Sketches are handled by my Org Mode sketch links, but we can add them anyway.

(cl-pushnew (cons (expand-file-name "~/sync/sketches/") "https://sketches.sachachua.com/filename/")
            my-project-web-base-list
            :test 'equal)

I've been really liking being able to refer to various emacsconf-el files by just selecting the link type and completing the filename, so maybe it'll be easier to write about lots of other stuff if I extend that to my other projects.

Quickly search my code

Since my-project-web-base-list is a list of projects I often think about or write about, I can also make something that searches through them. That way, I don't have to care about where my code is.

(defun my-consult-ripgrep-code ()
  (interactive)
  (consult-ripgrep (mapcar 'car my-project-web-base-list)))

I can add .rgignore files in directories to tell ripgrep to ignore things like node_modules or *.json.

I also want to search my Emacs configuration at the same time, although links to my config are handled by my dotemacs link type so I'll leave the URL as nil. This is also the way I can handle other unpublished directories.

(cl-pushnew (cons (expand-file-name "~/sync/emacs/Sacha.org") nil)
            my-project-web-base-list
            :test 'equal)
(cl-pushnew (cons (expand-file-name "~/proj/static-blog/_includes") nil)
            my-project-web-base-list
            :test 'equal)
(cl-pushnew (cons (expand-file-name "~/bin") nil)
            my-project-web-base-list
            :test 'equal)

Actually, let's throw my blog posts and Org files in there as well, since I often have code snippets. If it gets to be too much, I can always have different commands search different things.

(cl-pushnew (cons (expand-file-name "~/proj/static-blog/blog/") "https://sachachua.com/blog/")
            my-project-web-base-list
            :test 'equal)
(cl-pushnew (cons (expand-file-name "~/sync/orgzly") nil)
            my-project-web-base-list
            :test 'equal)
ripgrep-code.gif
Figure 2: Using my-consult-ripgrep-code

I don't have anything bound to M-s c (code) yet, so let's try that.

(keymap-global-set "M-s c" #'my-consult-ripgrep-code)

At some point, it might be fun to get Embark set up so that I can grab a link to something right from the consult-ripgrep interface. In the meantime, I can always jump to it and get the link.

Tip from Omar: embark-around-action-hooks

[2024-01-07 Sun] I modified oantolin's suggestion from the comments to work with consult-ripgrep, since consult-ripgrep gives me consult-grep targets instead of consult-location:

(cl-defun embark-consult--at-location (&rest args &key target type run &allow-other-keys)
  "RUN action at the target location."
  (save-window-excursion
    (save-excursion
      (save-restriction
        (pcase type
          ('consult-location (consult--jump (consult--get-location target)))
          ('org-heading (org-goto-marker-or-bmk (get-text-property 0 'org-marker target)))
          ('consult-grep (consult--jump (consult--grep-position target)))
          ('file (find-file target)))
        (apply run args)))))

(cl-pushnew #'embark-consult--at-location (alist-get 'org-store-link embark-around-action-hooks))

I think I can use it with M-s c to search for the code, then C-. C-c l on the matching line, where C-c l is my regular keybinding for storing links. Thanks, Omar!

In general, I don't want to have to think about where something is on my laptop or where it's published on the Web, I just want to

View org source for this post
This is part of my Emacs configuration.

EmacsConf backstage: making lots of intro videos with subed-record

| emacsconf, subed, emacs

Summary (735 words): Emacs is a handy audio/video editor. subed-record can combine multiple audio files and images to create multiple output videos.

Watch on YouTube

It's nice to feel like you're saying someone's name correctly. We ask EmacsConf speakers to introduce themselves in the first few seconds of their video, but people often forget to do that, so that's okay. We started recording introductions for EmacsConf 2022 so that stream hosts don't have to worry about figuring out pronunciation while they're live. Here's how I used subed-record to turn my recordings into lots of little videos.

First, I generated the title images by using Emacs Lisp to replace text in a template SVG and then using Inkscape to convert the SVG into a PNG. Each image showed information for the previous talk as well as the upcoming talk. (emacsconf-stream-generate-in-between-pages)

emacsconf.svg.png
Figure 1: Sample title image

Then I generated the text for each talk based on the title, the speaker names, pronunciation notes, pronouns, and type of Q&A. Each introduction generally followed the pattern, "Next we have title by speakers. Details about Q&A." (emacsconf-pad-expand-intro and emacsconf-subed-intro-subtitles below)

00:00:00.000 --> 00:00:00.999
#+OUTPUT: sat-open.webm
[[file:/home/sacha/proj/emacsconf/2023/assets/in-between/sat-open.svg.png]]
Next, we have "Saturday opening remarks".

00:00:05.000 --> 00:00:04.999
#+OUTPUT: adventure.webm
[[file:/home/sacha/proj/emacsconf/2023/assets/in-between/adventure.svg.png]]
Next, we have "An Org-Mode based text adventure game for learning the basics of Emacs, inside Emacs, written in Emacs Lisp", by Chung-hong Chan. He will answer questions via Etherpad.

I copied the text into an Org note in my inbox, which Syncthing copied over to the Orgzly Revived app on my Android phone. I used Google Recorder to record the audio. I exported the m4a audio file and a rough transcript, copied them back via Syncthing, and used subed-record to edit the audio into a clean audio file without oopses.

Each intro had a set of captions that started with a NOTE comment. The NOTE comment specified the following:

  • #+AUDIO:: the audio source to use for the timestamped captions that follow
  • [[file:...]]: the title image I generated for each talk. When subed-record-compile-video sees a comment with a link to an image, video, or animated GIF, it takes that visual and uses it for the span of time until the next visual.
  • #+OUTPUT: the file to create.
NOTE #+OUTPUT: hyperdrive.webm
[[file:/home/sacha/proj/emacsconf/2023/assets/in-between/hyperdrive.svg.png]]
#+AUDIO: intros-2023-11-21-cleaned.opus

00:00:15.680 --> 00:00:17.599
Next, we have "hyperdrive.el:

00:00:17.600 --> 00:00:21.879
Peer-to-peer filesystem in Emacs", by Joseph Turner

00:00:21.880 --> 00:00:25.279
and Protesilaos Stavrou (also known as Prot).

00:00:25.280 --> 00:00:27.979
Joseph will answer questions via BigBlueButton,

00:00:27.980 --> 00:00:31.080
and Prot might be able to join depending on the weather.

00:00:31.081 --> 00:00:33.439
You can join using the URL from the talk page

00:00:33.440 --> 00:00:36.320
or ask questions through Etherpad or IRC.

NOTE
#+OUTPUT: steno.webm
[[file:/home/sacha/proj/emacsconf/2023/assets/in-between/steno.svg.png]]
#+AUDIO: intros-2023-11-19-cleaned.opus

00:03:23.260 --> 00:03:25.480
Next, we have "Programming with steno",

00:03:25.481 --> 00:03:27.700
by Daniel Alejandro Tapia.

NOTE
#+AUDIO: intro-2023-11-29-cleaned.opus

00:00:13.620 --> 00:00:16.580
You can ask your questions via Etherpad and IRC.

00:00:16.581 --> 00:00:18.079
We'll send them to the speaker

00:00:18.080 --> 00:00:19.919
and post the answers in the talk page

00:00:19.920 --> 00:00:21.320
after the conference.

I could then call subed-record-compile-video to create the videos for all the intros, or mark a region with C-SPC and then subed-record-compile-video only the intros inside that region.

Sample intro

Using Emacs to edit the audio and compile videos worked out really well because it made it easy to change things.

  • Changing pronunciation or titles: For EmacsConf 2023, I got the recordings sorted out in time for the speakers to correct my pronunciation if they wanted to. Some speakers also changed their talk titles midway. If I wanted to redo an intro, I just had to rerecord that part, run it through my subed-record audio cleaning process, add an #+AUDIO: comment specifying which file I want to take the audio from, paste it into my main intros.vtt, and recompile the video.
  • Cancelling talks: One of the talks got cancelled, so I needed to update the images for the talk before it and the talk after it. I regenerated the title images and recompiled the videos. I didn't even need to figure out which talk needed to be updated - it was easy enough to just recompile all of them.
  • Changing type of Q&A: For example, some speakers needed to switch from answering questions live to answering them after the conference. I could just delete the old instructions, paste in the instructions from elsewhere in my intros.vtt (making sure to set #+AUDIO to the file if it came from a different take), and recompile the video.

And of course, all the videos were captioned. Bonus!

So that's how using Emacs to edit and compile simple videos saved me a lot of time. I don't know how I'd handle this otherwise. 47 video projects that might all need to be updated if, say, I changed the template? Yikes. Much better to work with text. Here are the technical details.

Generating the title images

I used Inkscape to add IDs to our template SVG so that I could edit them with Emacs Lisp. From emacsconf-stream.el:

emacsconf-stream-generate-in-between-pages: Generate the title images.
(defun emacsconf-stream-generate-in-between-pages (&optional info)
  "Generate the title images."
  (interactive)
  (setq info (or emacsconf-schedule-draft (emacsconf-publish-prepare-for-display (emacsconf-filter-talks (or info (emacsconf-get-talk-info))))))
  (let* ((by-track (seq-group-by (lambda (o) (plist-get o :track)) info))
         (dir (expand-file-name "in-between" emacsconf-stream-asset-dir))
         (template (expand-file-name "template.svg" dir)))
    (unless (file-directory-p dir)
      (make-directory dir t))
    (mapc (lambda (track)
            (let (prev)
              (mapc (lambda (talk)
                      (let ((dom (xml-parse-file template)))
                        (mapc (lambda (entry)
                                (let ((prefix (car entry)))
                                  (emacsconf-stream-svg-set-text dom (concat prefix "title")
                                                 (plist-get (cdr entry) :title))
                                  (emacsconf-stream-svg-set-text dom (concat prefix "speakers")
                                                 (plist-get (cdr entry) :speakers))
                                  (emacsconf-stream-svg-set-text dom (concat prefix "url")
                                                 (and (cdr entry) (concat emacsconf-base-url (plist-get (cdr entry) :url))))
                                  (emacsconf-stream-svg-set-text
                                   dom
                                   (concat prefix "qa")
                                   (pcase (plist-get (cdr entry) :q-and-a)
                                     ((rx "live") "Live Q&A after talk")
                                     ((rx "pad") "Etherpad")
                                     ((rx "IRC") "IRC Q&A after talk")
                                     (_ "")))))
                              (list (cons "previous-" prev)
                                    (cons "current-" talk)))
                        (with-temp-file (expand-file-name (concat (plist-get talk :slug) ".svg") dir)
                          (dom-print dom))
                        (shell-command
                         (concat "inkscape --export-type=png -w 1280 -h 720 --export-background-opacity=0 "
                                 (shell-quote-argument (expand-file-name (concat (plist-get talk :slug) ".svg")
                                                                         dir)))))
                      (setq prev talk))
                    (emacsconf-filter-talks (cdr track)))))
          by-track)))

emacsconf-stream-svg-set-text: Update DOM to set the tspan in the element with ID to TEXT.
(defun emacsconf-stream-svg-set-text (dom id text)
  "Update DOM to set the tspan in the element with ID to TEXT.
If the element doesn't have a tspan child, use the element itself."
  (if (or (null text) (string= text ""))
      (let ((node (dom-by-id dom id)))
        (when node
          (dom-set-attribute node 'style "visibility: hidden")
          (dom-set-attribute (dom-child-by-tag node 'tspan) 'style "fill: none; stroke: none")))
    (setq text (svg--encode-text text))
    (let ((node (or (dom-child-by-tag
                     (car (dom-by-id dom id))
                     'tspan)
                    (dom-by-id dom id))))
      (cond
       ((null node)
        (error "Could not find node %s" id))                      ; skip
       ((= (length node) 2)
        (nconc node (list text)))
       (t (setf (elt node 2) text))))))

Generating the script

From emacsconf-pad.el:

emacsconf-pad-expand-intro: Make an intro for TALK.
(defun emacsconf-pad-expand-intro (talk)
  "Make an intro for TALK."
  (cond
   ((null (plist-get talk :speakers))
    (format "Next, we have \"%s\"." (plist-get talk :title)))
   ((plist-get talk :intro-note)
    (plist-get talk :intro-note))
   (t
    (let ((pronoun (pcase (plist-get talk :pronouns)
                     ((rx "she") "She")
                     ((rx "\"ou\"" "Ou"))
                     ((or 'nil "nil" (rx string-start "he") (rx "him")) "He")
                     ((rx "they") "They")
                     (_ (or (plist-get talk :pronouns) "")))))
      (format "Next, we have \"%s\", by %s%s.%s"
              (plist-get talk :title)
              (replace-regexp-in-string ", \\([^,]+\\)$"
                                        ", and \\1"
                                        (plist-get talk :speakers))
              (emacsconf-surround " (" (plist-get talk :pronunciation) ")" "")
              (pcase (plist-get talk :q-and-a)
                ((or 'nil "") "")
                ((rx "after") " You can ask questions via Etherpad and IRC. We'll send them to the speaker, and we'll post the answers on the talk page afterwards.")
                ((rx "live")
                 (format " %s will answer questions via BigBlueButton. You can join using the URL from the talk page or ask questions through Etherpad or IRC."
                         pronoun
                         ))
                ((rx "pad")
                 (format " %s will answer questions via Etherpad."
                         pronoun
                         ))
                ((rx "IRC")
                 (format " %s will answer questions via IRC in the #%s channel."
                         pronoun
                         (plist-get talk :channel)))))))))

And from emacsconf-subed.el:

emacsconf-subed-intro-subtitles: Create the introduction as subtitles.
(defun emacsconf-subed-intro-subtitles ()
  "Create the introduction as subtitles."
  (interactive)
  (subed-auto-insert)
  (let ((emacsconf-publishing-phase 'conference))
    (mapc
     (lambda (sub) (apply #'subed-append-subtitle nil (cdr sub)))
     (seq-map-indexed
      (lambda (talk i)
        (list
         nil
         (* i 5000)
         (1- (* i 5000))
         (format "#+OUTPUT: %s.webm\n[[file:%s]]\n%s"
                 (plist-get talk :slug)
                 (expand-file-name
                  (concat (plist-get talk :slug) ".svg.png")
                  (expand-file-name "in-between" emacsconf-stream-asset-dir))
                 (emacsconf-pad-expand-intro talk))))
      (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))))))

View org source for this post

Quick notes on livestreaming to YouTube with FFmpeg on a Lenovo X230T

| video, youtube, streaming, ffmpeg, yay-emacs

[2024-01-05 Fri]: Updated scripts

Text from the sketch

Quick thoughts on livestreaming

Why:

  • work out loud
  • share tips
  • share more
  • spark conversations
  • (also get questions about things)

Doable with ffmpeg on my X230T:

  • streaming from my laptop
  • lapel mic + system audio,
  • second screen for monitoring

Ideas for next time:

  • Overall notes in Emacs with outline, org-timer timestamped notes; capture to this file
  • Elisp to start/stop the stream → find old code
  • Use the Yeti? Better sound
  • tee to a local recording
  • grab screenshot from SuperNote mirror?

Live streaming info density:

  • High: Emacs News review, package/workflow demo
  • Narrating a blog post to make it a video
  • Categorizing Emacs News, exploring packages
  • Low: Figuring things out

YouTube can do closed captions for livestreams, although accuracy is low. Videos take a while to be ready to download.

Experimenting with working out loud

I wanted to write a report on EmacsConf 2023 so that we could share it with speakers, volunteers, participants, donors, related organizations like the Free Software Foundation, and other communities. I experimented with livestreaming via YouTube while I worked on the conference highlights.

It's a little over an hour long and probably very boring, but it was nice of people to drop by and say hello.

The main parts are:

  • 0:00: reading through other conference reports for inspiration
  • 6:54: writing an overview of the talks
  • 13:10: adding quotes for specific talks
  • 25:00: writing about the overall conference
  • 32:00: squeezing in more highlights
  • 49:00: fiddling with the formatting and the export

It mostly worked out, aside from a brief moment of "uhhh, I'm looking at our private conf.org file on stream". Fortunately, the e-mail addresses that were showed were the public ones.

Technical details

Setup:

  • I set up environment variables and screen resolution:

      # From pacmd list-sources | egrep '^\s+name'
      LAPEL=alsa_input.usb-Jieli_Technology_USB_Composite_Device_433035383239312E-00.mono-fallback #
      YETI=alsa_input.usb-Blue_Microphones_Yeti_Stereo_Microphone_REV8-00.analog-stereo
      SYSTEM=alsa_output.pci-0000_00_1b.0.analog-stereo.monitor
      # MIC=$LAPEL
      # AUDIO_WEIGHTS="1 1"
      MIC=$YETI
      AUDIO_WEIGHTS="0.5 0.5"
      OFFSET=+1920,430
      SIZE=1280x720
      SCREEN=LVDS-1  # from xrandr
      xrandr --output $SCREEN --mode 1280x720
    
  • I switch to a larger size and a light theme. I also turn consult previews off to minimize the risk of leaking data through buffer previews.
    my-emacsconf-prepare-for-screenshots: Set the resolution, change to a light theme, and make the text bigger.
    (defun my-emacsconf-prepare-for-screenshots ()
      (interactive)
      (shell-command "xrandr --output LVDS-1 --mode 1280x720")
      (modus-themes-load-theme 'modus-operandi)
      (my-hl-sexp-update-overlay)
      (set-face-attribute 'default nil :height 170)
      (keycast-mode))
    

Testing:

ffmpeg -f x11grab -video_size $SIZE -i :0.0$OFFSET -y /tmp/test.png; display /tmp/test.png
ffmpeg -f pulse -i $MIC -f pulse -i $SYSTEM -filter_complex amix=inputs=2:weights=$AUDIO_WEIGHTS:duration=longest:normalize=0 -y /tmp/test.mp3; mpv /tmp/test.mp3
DATE=$(date "+%Y-%m-%d-%H-%M-%S")
ffmpeg -f x11grab -framerate 30 -video_size $SIZE -i :0.0$OFFSET -f pulse -i $MIC -f pulse -i $SYSTEM -filter_complex "amix=inputs=2:weights=$AUDIO_WEIGHTS:duration=longest:normalize=0" -c:v libx264 -preset fast -maxrate 690k -bufsize 2000k -g 60 -vf format=yuv420p -c:a aac -b:a 96k -y -flags +global_header "/home/sacha/recordings/$DATE.flv" -f flv

Streaming:

DATE=$(date "+%Y-%m-%d-%H-%M-%S")
ffmpeg -f x11grab -framerate 30 -video_size $SIZE -i :0.0$OFFSET -f pulse -i $MIC -f pulse -i $SYSTEM -filter_complex "amix=inputs=2:weights=$AUDIO_WEIGHTS:duration=longest:normalize=0[audio]" -c:v libx264 -preset fast -maxrate 690k -bufsize 2000k -g 60 -vf format=yuv420p -c:a aac -b:a 96k -y -f tee -map 0:v -map '[audio]' -flags +global_header  "/home/sacha/recordings/$DATE.flv|[f=flv]rtmp://a.rtmp.youtube.com/live2/$YOUTUBE_KEY"

To restore my previous setup:

my-emacsconf-back-to-normal: Go back to a more regular setup.
(defun my-emacsconf-back-to-normal ()
  (interactive)
  (shell-command "xrandr --output LVDS-1 --mode 1366x768")
  (modus-themes-load-theme 'modus-vivendi)
  (my-hl-sexp-update-overlay)
  (set-face-attribute 'default nil :height 115)
  (keycast-mode -1))

Ideas for next steps

I can think of a few workflow tweaks that might be fun:

  • a stream notes buffer on the right side of the screen for context information, timestamped notes to make editing/review easier (maybe using org-timer), etc. I experimented with some streaming-related code in my config, so I can dust that off and see what that's like. I also want to have an org-capture template for it so that I can add notes from anywhere.
  • a quick way to add a screenshot from my Supernote to my Org files

I think I'll try going through an informal presentation or Emacs News as my next livestream experiment, since that's probably higher information density.

View org source for this post

Using consult and org-ql to search my Org Mode agenda files and sort the results to prioritize heading matches

| emacs, org

I want to get better at looking in my Org files for something that I don't exactly remember. I might remember a few words from it but not in order, or I might remember some words from the body, or I might need to fiddle with the keywords until I find it.

I usually use C-u C-c C-w (org-refile with a prefix argument), counting on consult + orderless to let me just put in keywords in any order. This doesn't let me search the body, though.

org-ql seems like a great fit for this. It's fast and flexible, and might be useful for all sorts of queries.

I think by default org-ql matches against all of the text in the entry. You can scope the match to just the heading with a query like heading:your,text. I wanted to see all matches, prioritize heading matches so that they come first. I thought about saving the query by adding advice before org-ql-search and then adding a new comparator function, but that got a bit complicated, so I haven't figured that out yet. It was easier to figure out how to rewrite the query to use heading instead of rifle, do the more constrained query, and then append the other matches that weren't in the heading matches.

Also, I wanted something a little like helm-org-rifle's live previews. I've used helm before, but I was curious about getting it to work with consult.

Here's a quick demo of my-consult-org-ql-agenda-jump, which I've bound to M-s a. The top few tasks have org-ql in the heading, and they're followed by the rest of the matches. I think this might be handy.

my-consult-org-ql-agenda-jump.gif
Figure 1: Screencast of using my-consult-org-ql-agenda-jump
(defun my-consult-org-ql-agenda-jump ()
  "Search agenda files with preview."
  (interactive)
  (let* ((marker (consult--read
                  (consult--dynamic-collection
                   #'my-consult-org-ql-agenda-match)
                  :state (consult--jump-state)
                  :category 'consult-org-heading
                  :prompt "Heading: "
                  :sort nil
                  :lookup #'consult--lookup-candidate))
         (buffer (marker-buffer marker))
         (pos (marker-position marker)))
    ;; based on org-agenda-switch-to
    (unless buffer (user-error "Trying to switch to non-existent buffer"))
    (pop-to-buffer-same-window buffer)
    (goto-char pos)
    (when (derived-mode-p 'org-mode)
      (org-fold-show-context 'agenda)
      (run-hooks 'org-agenda-after-show-hook))))

(defun my-consult-org-ql-agenda-format (o)
  (propertize
   (org-ql-view--format-element o)
   'consult--candidate (org-element-property :org-hd-marker o)))

(defun my-consult-org-ql-agenda-match (string)
  "Return candidates that match STRING.
Sort heading matches first, followed by other matches.
Within those groups, sort by date and priority."
  (let* ((query (org-ql--query-string-to-sexp string))
         (sort '(date reverse priority))
         (heading-query (-tree-map (lambda (x) (if (eq x 'rifle) 'heading x)) query))
         (matched-heading
          (mapcar #'my-consult-org-ql-agenda-format
                  (org-ql-select 'org-agenda-files heading-query
                    :action 'element-with-markers
                    :sort sort)))
         (all-matches
          (mapcar #'my-consult-org-ql-agenda-format
                  (org-ql-select 'org-agenda-files query
                    :action 'element-with-markers
                    :sort sort))))
    (append
     matched-heading
     (seq-difference all-matches matched-heading))))

(use-package org-ql
  :bind ("M-s a" . my-consult-org-ql-agenda-jump))

Along the way, I learned how to use consult to complete using consult--dynamic-collection and add consult--candidate so that I can reuse consult--lookup-candidate and consult--jump-state. Neat!

Someday I'd like to figure out how to add a sorting function and sort by headers without having to reimplement the other sorts. In the meantime, this might be enough to help me get started.

This is part of my Emacs configuration.

Highlight the active modeline using colours from modus-themes

| emacs

I wanted to experiment with for colouring the mode line of the active window ever so slightly different to make it easier to see where the active window is. I usually have global-hl-line-mode turned on, so that highlight is another indicator, but let's see how this tweak feels. I modified the code so that it uses the theme colours from the currently-selected Modus themes, since I trust Prot's colour choices more than I trust mine. Thanks to Irreal for sharing Ignacio's comment!

(defun my-update-active-mode-line-colors ()
  (set-face-attribute
   'mode-line nil
   :foreground (modus-themes-get-color-value 'fg-mode-line-active)
   :background (modus-themes-get-color-value 'bg-blue-subtle)
   :box '(:line-width
          1
          :color
          (modus-themes-get-color-value 'border-mode-line-active))))
(use-package modus-themes
  :hook
  (modus-themes-after-load-theme . my-update-active-mode-line-colors))
dark-mode-line.svg
Figure 1: with dark mode
light-mode-line.svg
Figure 2: with light mode
This is part of my Emacs configuration.

2024-01-01 Emacs news

| emacs, emacs-news

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Hacker News, lobste.rs, kbin, programming.dev, lemmy, communick.news, planet.emacslife.com, YouTube, the Emacs NEWS file, Emacs Calendar, and emacs-devel. Thanks to Andrés Ramírez for emacs-devel links. Do you have an Emacs-related link or announcement? Please e-mail me at sacha@sachachua.com. Thank you!

View org source for this post

Using subed-record in Emacs to edit audio and clean up oopses

| emacs, subed

Finding enough quiet focused time to record audio is a challenge. I often have to re-record segments in order to correct brain hiccups or to restart after interruptions. It's also hard for me to sit still and listen to my recordings looking for mistakes to edit out. I'm not familiar enough with Audacity to zip around with keyboard shortcuts, and I don't like listening to myself again and again in order to find my way around an audio file.

Sure, I could take the transcript, align it with subed-align and Aeneas to get the timestamps, and then use subed-convert to get a CSV (actually a TSV since it uses tabs) that I can import into Audacity as labels, but it still feels a little awkward to navigate. I have to zoom in a lot for the text to be readable.

2023-12-29_10-28-32.png
Figure 1: Audacity labels

So here's a workflow I've been experimenting with for cleaning up my recorded audio.

Just like with my audio braindumps, I use Google Recorder on my phone because I can get the audio file and a rough transcript, and because the microphone on it is better than on my laptop. For narration recordings, I hide in the closet because the clothes muffle echoes. I don't feel as self-conscious there as I might be if I recorded in the kitchen, where my computer usually is. I used to record in Emacs using subed-record by pressing left to redo a segment and right to move on to the next one, but using my phone means I don't have to deal with the computer's noises or get the good mic from downstairs.

I start the recorder on my phone and then switch to my Org file in Orgzly Revived, where I've added my script. I read it as far as I can go. If I want to redo a segment, I say "Oops" and then just redo the last phrase or so.

Screenshot of Google Recorder on my phone
Screenshot_20231229-083047.png

I export the transcript and the M4A audio file using Syncthing, which copies them to my computer. I have a function that copies the latest recording and even sets things up for removing oops segments (my-subed-copy-latest-phone-recording, which calls my-split-oops). If I want to process several files, I can copy them over with my-subed-copy-recording.

my-subed-copy-latest-phone-recording: Copy the latest recording transcript and audio to DESTINATION.
(defun my-subed-copy-latest-phone-recording (destination)
  "Copy the latest recording transcript and audio to DESTINATION."
  (interactive
   (list
    (file-name-directory
     (read-file-name (format "Move %s to: "
                             (file-name-base (my-latest-file my-phone-recording-dir ".txt")))
                     nil nil nil nil #'file-directory-p))))
  (let ((base (file-name-base (my-latest-file my-phone-recording-dir ".txt"))))
    (rename-file (expand-file-name (concat base ".txt") my-phone-recording-dir)
                 destination)
    (rename-file (expand-file-name (concat base ".m4a") my-phone-recording-dir)
                 destination)
    (find-file (expand-file-name (concat base ".txt") destination))
    (save-excursion (my-split-oops))
    (goto-char (point-min))
    (flush-lines "^$")
    (goto-char (point-min))
    (subed-forward-subtitle-id)
    (subed-set-subtitle-comment
     (concat "#+OUTPUT: "
             (file-name-base (buffer-file-name))
             "-cleaned.opus"))))

my-subed-copy-recording
(defun my-subed-copy-recording (filename destination)
  (interactive
   (list
    (buffer-file-name)
    (file-name-directory
     (read-file-name (format "Copy %s to: "
                             (file-name-base (buffer-file-name)))
                     nil nil nil nil #'file-directory-p))))
  (dolist (ext '("m4a" "txt" "json" "vtt"))
    (when (file-exists-p (concat (file-name-sans-extension filename) "." ext))
      (copy-file (concat (file-name-sans-extension filename) "." ext)
                 destination t)))
  (when (get-file-buffer filename)
    (kill-buffer (get-file-buffer filename))
    (dired destination)))

I'll use Aeneas to get the timestamps for each line of text, so a little bit of text processing will let me identify the segments that I want to remove. The way my-split-oops works is that it looks for "oops" in the transcript. Whenever it finds "oops", it adds a newline afterwards. Then it takes the next five words and sees if it can search backward for them within 300 characters. If it finds the words, then that's the start of my repeated segment, and we can add a newline before that. If it doesn't find the words, we try again with four words, then three, then two, then one. I can also manually review the file and see if the oopses are well lined up. When they're detected properly, I should see partially duplicated lines.

I used to record using sub-record by using by. Oops,
I used to record. Oops,
I used to record an emacs using subhead record, by pressing left to reduce segment, and write to move on to the next one.
But using my phone means, I don't have to deal with them. Oops.
But using my phone means, I don't have to deal with the computer's noises or get the good mic from downstairs. I started recorder on my phone

my-split-oops: Look for oops and make it easier to split.
(defun my-split-oops ()
  "Look for oops and make it easier to split."
  (interactive)
  (let ((scan-window 300))
    (while (re-search-forward "oops[,\.]?[ \n]+" nil t)
      (let ((start (min (line-beginning-position) (- (point) scan-window)))
            start-search
            found
            search-for)
        (if (bolp)
            (progn
              (backward-char)
              (setq start (min (line-beginning-position) (- (point) scan-window))))
          (insert "\n"))
        (save-excursion
          (setq start-search (point))
          ;; look for 1..3 words back
          (goto-char
           (or
            (cl-loop
             for n downfrom 4 downto 1
             do
             (save-excursion
               (dotimes (_ n) (forward-word))
               (setq search-for (downcase (string-trim (buffer-substring start-search (point)))))
               (goto-char start-search)
               (when (re-search-backward (regexp-quote search-for) start t)
                 (goto-char (match-beginning 0))
                 (cl-return (point)))))
            (and (call-interactively 'isearch-backward) (point))))
          (insert "\n"))))))

Once the lines are split up, I use subed-align and get a VTT file. The oops segments will be in their own subtitles.

2023-12-29-08-41-33.svg
Figure 2: Subtitles and waveforms

The timestamps still need a bit of tweaking sometimes, so I use subed-waveform-show-current or subed-waveform-show-all. I can use the following bindings:

  • middle-click to play a sample
  • M-left-click to set the start and copy to the previous subtitle
  • left-click to set the start without changing the previous one
  • M-right-click to set the end and copy to the next subtitle
  • right-click to set the end without changing the next one
  • M-j to jump to the current subtitle and play it again in MPV
  • M-J to jump to close to the end of the current subtitle and play it in MPV

I use my-subed-delete-oops to delete the oops segments. I can also just mark them for skipping by calling C-u M-x my-subed-delete-oops instead.

Then I add a #+OUTPUT: filename-cleaned.opus comment under a NOTE near the beginning of the file. This tells subed-record~compile-audio where to put the output.

WEBVTT

NOTE #+SKIP

00:00:00.000 --> 00:00:10.319
Finding enough. Oops.

NOTE
#+OUTPUT: 2023-12-subed-record-cleaned.opus

00:00:10.320 --> 00:00:36.319
Finding enough quiet Focused. Time to record. Audio is a challenge. I often have to re-record segments in order to correct brain hiccups, or to restart after interruptions.

I can test short segments by marking the region with C-SPC and using subed-record-compile-try-flow. This lets me check if the transitions between segments make sense.

When I'm happy with everything, I can use subed-record-compile-audio to extract the segments specified by the start and end times of each subtitle and concatenate them one after the other in the audio file specified by the output. The result should be a clean audio file.

If I need to compile an audio file from several takes, I process each take separately. Once I've adjusted the timestamps and deleted or skipped the oops segments, I add #+AUDIO: input-filename.opus to a NOTE at the beginning of the file. subed-record-insert-audio-source-note makes this easier. Then I copy the file's subtitles into my main file. subed-record-compile-audio will take the audio from whichever file was specified by the #+AUDIO: comment, so I can use audio from different files.

Example VTT segment with multiple audio files
NOTE
#+AUDIO: 2023-11-11-emacsconf.m4a

00:10:55.617 --> 00:10:58.136
Sometimes we send emails one at a time.

NOTE
#+AUDIO: 2023-11-15-emacsconf.m4a

00:10:55.625 --> 00:11:03.539
Like when you let a speaker know that we've received a proposal That's mostly a matter of plugging the talks properties into the right places in the template.

Now I have a clean audio file that corresponds to my script. I can use subed-align on my script to get the timestamps for each line using the cleaned audio. Once I have a subtitle file, I can use emacsconf-subed-split (in emacsconf-subed.el - which I probably should add to subed-mode sometime) to quickly split the captions up to fit the line lengths. Then I redo the timestamps with subed-align and adjust timestamps with subed-waveform-show-current.

So that's how I go from rough recordings with stutters and oopses to a clean audio file with captions based on my script. People can probably edit faster with Audacity wizardry or the AI audio editors that are in vogue these days, but this little workflow gets around my impatience with audio by turning it into (mostly) text, so that's cool. Let's see if I can make more presentations now that I've gotten the audio side figured out!

Links: