Categories: geek » emacs

View topic page - RSS - Atom - Subscribe via email
Recommended links:

2026-03-30 Emacs news

| emacs, emacs-news

It's not too late to write about mistakes and misconceptions as part of the Emacs Carnival for March and not too early to think about the theme of "Newbies/Starter Kits" which Cena will be hosting for April. Who knows, maybe those ideas can become part of the newcomers presets. It could be fun to explore something like notes for Emacs beginners and see where you end up.

Also, I'm looking forward to seeing if these tips for reloading Emacs Lisp code can help me avoid little bugs from leftover code.

Enjoy!

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, programming.dev, lemmy.world, lemmy.ml, 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

Emacs Carnival March 2026: Mistakes and learning to reach out

| community, emacs

Mostly-similar versions follow: I started with French, translated it to English, and then tweaked some details. Thanks to Philip Kaludercic for hosting this month's carnival!

In English

The theme for this month's Emacs Carnival is Mistakes and Misconceptions. It’s difficult to pinpoint one thing that is clearly a mistake, but there are certainly things I could do more effectively.

My configuration is very large because I assume my little modifications are only useful to me. They feel too specific, too idiosyncratic. I think people who create libraries or even packages used by lots of other people are awesome. I don't know if I could quite do that myself, though! Even submitting patches upstream and participating in the ensuing discussions sometimes requires more persistence than I have.

The advantage of keeping my changes in my config is that even if I'm unsure, I can try something out, develop a rough prototype, and change my mind if necessary. When I publish them in a library or a package, I feel like I have to polish my ideas. It's hard to stick to just one idea long enough to refine it.

My favorite situation is when I write about my attempt in a post, and it inspires someone else to implement their own version (or even a new library or package). On the other hand, if I learn to share my code, I can help more people, and I can also learn from more people and more conversations.

Many of my modifications are short and easy to copy from my posts, but there are a few collections that depend on other functions, making them difficult to copy. These functions are scattered across several posts on my blog. For example, my functions for learning a language (I'm learning French at the moment) and for controlling Emacs by voice are becoming quite complex. The functions are also exported to my configuration, but the Emacs Lisp file is difficult to navigate if someone wants to copy them. I can extract the code into a file now that Org Mode can tangle to multiple files, but if I spend a little time replacing the "my-" prefix with a library prefix and move them to a repository, people could clone it and download updates. Even if no one uses it, the act of polishing and documenting it will probably be useful to me one day.

So, it's possible that this is a mistake I often make in Emacs: thinking my functions are too idiosyncratic and too rough, so I leave them in my config. If I dedicate time to extracting the code into a library, I might benefit in the long run. I know lots of people are interested in using Emacs for language learning or by voice. There have been so many other libraries and workflows over the years, so I'm sure people are out there. I want to practice learning more with others. To start, I can make sure interested people can follow my progress through RSS feeds or Mastodon, I can respond when people send me messages, and I can collect contact info and send them a message when I post about the subject.

I can write more if I reread the changes in my configuration each week, or if I reread my complete configuration for sections which I haven't yet written about. If I participate in virtual meetups or even livestream, I can find out what interests other people. If I submit patches and create tasks in my Org Mode inbox to track the discussions, I can practice refining my work.

Prot has lowered his coaching prices to €10 /hour. He's quite prolific when it comes to package development, so he can probably help me figure out how to get stuff out of my config and into a form that other people might be able to use. I've been enjoying learning with my French tutor. It might be worth experimenting with spending some money and time to improve my Emacs skills as well. Sure, it's totally just for fun, but I think it's valuable to practice learning with the help of others instead of stumbling around on my own.

There's always more to learn, which is wonderful. So this is not really a mistake, just something that could be good to work on. Onward and upward!

Check out Emacs Carnival March 2026: Mistakes and Misconceptions to see other people's takes on the topic.

En français

Le thème du Carnaval d'Emacs ce mois-ci est « les erreurs et les idées reçues ». C'est difficile d'identifier une chose qui soit clairement une erreur, mais il y a certainement des choses que je ne fais pas efficacement.

Ma configuration est très volumineuse car je pense que mes petites modifications ne sont utiles que pour moi. Elles sont trop spécifiques, trop particulières. J'apprécie ceux qui créent des bibliothèques ou même des paquets que beaucoup d'autres utilisent, mais de mon côté, je ne me sens pas capable de le faire pour l'instant. Même soumettre des correctifs en amont et participer à la discussion qui s'ensuit parfois demande plus de persévérance que je n'en ai.

L'avantage de garder mes modifications dans ma configuration est que, même si je ne suis pas sûre, je peux essayer quelque chose, développer un prototype préliminaire, et changer d'avis si nécessaire. Quand je les publie dans une bibliothèque ou un paquet, j'ai l'impression que je dois peaufiner mes idées. C'est difficile de s'en tenir à une seule idée assez longtemps.

Ma situation préférée est quand je partage mes essais sur mon blog, et qu'ils inspirent une autre personne qui implémentera sa propre version, voire une nouvelle bibliothèque ou un nouveau paquet.

En revanche, si j'apprends à partager mon code, je peux aider plus de personnes, et je peux aussi apprendre de plus de personnes et de plus de conversations.

Beaucoup de mes modifications sont brèves et faciles à copier de mes articles, mais il y a quelques collections qui dépendent d'autres fonctions, ce qui les rend difficiles à copier. Les fonctions sont dispersées dans plusieurs articles sur mon blog. Par exemple, mes fonctions pour apprendre une langue (particulièrement le français) et pour contrôler Emacs par commande vocale deviennent plutôt complexes. Elles sont aussi exportées vers ma configuration, mais le fichier Emacs Lisp est difficile à parcourir si on veut les copier. Je peux extraire le code dans un fichier maintenant que Org Mode peut le tangler vers plusieurs fichiers, mais si je consacre un peu de temps à remplacer le préfixe « my- » par celui de la bibliothèque et à le pousser sur le dépôt, les gens pourraient le cloner et récupérer les mises à jour. Même si personne ne l'utilise, le fait de les peaufiner et de le documenter me sera utile un jour.

Donc il est possible que ce soit une erreur que je commets souvent dans Emacs : je pense que mes fonctions sont trop idiosyncratiques et trop brutes, je les laisse donc dans ma configuration. Mais si je consacre du temps à extraire le code vers une bibliothèque, j'en bénéficierai peut-être à long terme. Je sais que beaucoup de gens sont intéressés par l'utilisation d'Emacs pour apprendre une langue ou pour la commande vocale. Il y a eu de nombreuses autres bibliothèques et flux de travail au fil des ans, donc je suis sûre qu'il y a du monde. Je veux m'entraîner à apprendre auprès de plus de personnes. Pour commencer, je peux m'assurer que les gens intéressés peuvent suivre mon progrès via les flux RSS ou sur Mastodon, je peux répondre quand on m'envoie des messages, et je peux recueillir les coordonnées et leur envoyer un message lorsque je publie un article à ce sujet.

Je peux écrire davantage si je relis les modifications dans ma configuration chaque semaine, ou si je relis ma configuration entière pour les sections dont je n'ai pas encore parlé. Si je participe à des réunions virtuelles ou même si je diffuse en direct, je vais voir ce qui intéresse les autres. Si je soumets des correctifs et crée des tâches dans ma boîte de réception Org Mode pour suivre les discussions, je m'entraîne à affiner mon travail.

Prot a baissé ses tarifs de coaching à 10 euros de l'heure. Il est très prolifique en matière de développement de paquets. J'apprends bien avec mon tuteur en français, donc cela vaut peut-être la peine de consacrer de l'argent et du temps à améliorer mes compétences sur Emacs. Certes, c'est juste pour le plaisir, mais c'est aussi important pour moi de m'entraîner à apprendre avec l'aide des autres au lieu de trébucher toute seule.

J'ai toujours plus de choses à apprendre, ce qui est merveilleux. Ce n'est pas vraiment une erreur, mais plutôt un point à améliorer. En avant !

Consultez Emacs Carnival March 2026: Mistakes and Misconceptions pour d'autres perspectives sur le sujet.

View Org source for this post

Categorizing Emacs News items by voice in Org Mode

| speech, speech-recognition, emacs, org

I'm having fun exploring which things might actually be easier to do by voice than by typing. For example, after I wrote some code to expand yasnippets by voice, I realized that it was easier to:

  1. press my shortcut,
  2. say "okay, define interactive function",
  3. and then press my shortcut again,

than to:

  1. mentally say it,
  2. get the first initials,
  3. type in "dfi",
  4. and press Tab to expand.

Another area where I do this kind of mental translation for keyboard shortcuts is when I categorize dozens of Emacs-related links each week for Emacs News. I used to do this by hand. Then I wrote a function to try to guess the category based on regular expressions (my-emacs-news-guess-category in emacs-news/index.org, which is large). Then I set up a menu that lets me press numbers corresponding to the most frequent categories and use tab completion for the rest. 1 is Emacs Lisp, 2 is Emacs development, 3 is Emacs configuration, 4 is appearance, 5 is navigation, and so on. It's not very efficient, but some of it has at least gotten into muscle memory, which is also part of why it's hard to change the mapping. I don't come across that many links for Emacs development or Spacemacs, and I could probably change them to something else, but… Anyway.

2026-03-23_20-38-33.png
Figure 1: Screenshot of my menu for categorizing links

I wanted to see if I could categorize links by voice instead. I might not always be able to count on being able to type a lot, and it's always fun to experiment with other modes of input. Here's a demonstration showing how Emacs can automatically open the URLs, wait for voice input, and categorize the links using a reasonably close match. The *Messages* buffer displays the recognized output to help with debugging.

Screencast with audio: categorizing links by voice

This is how it works:

  1. It starts an ffmpeg recording process.
  2. It starts Silero voice activity detection.
  3. When it detects that speech has ended, it use curl to send the WAV to an OpenAI-compatible server (in my case, Speaches with the Systran/faster-whisper-base.en model) for transcription, along with a prompt to try to influence the recognition.
  4. It compares the result with the candidates using string-distance for an approximate match. It calls the code to move the current item to the right category, creating the category if needed.

Since this doesn't always result in the right match, I added an Undo command. I also have a Delete command for removing the current item, Scroll Up and Scroll Down, and a way to quit.

Initial thoughts

I used it to categorize lots of links in this week's Emacs News, and I think it's promising. I loved the way my hands didn't have to hover over the number keys or move between those and the characters. Using voice activity detection meant that I could just keep dictating categories instead of pressing keyboard shortcuts or using the foot pedal I recently dusted off. There's a slight delay, of course, but I think it's worth it. If this settles down and becomes a solid part of my workflow, I might even be able to knit or hand-sew while doing this step, or simply do some stretching exercises.

What about using streaming speech recognition? I've written some code to use streaming speech recognition, but the performance wasn't good enough when I tried it on my laptop (Lenovo P52 released in 2018, no configured GPU under Linux). The streaming server dropped audio segments in order to try to catch up. I'd rather have everything transcribed at the level of the model I want, even if I have to wait a little while. I also tried using the Web Speech API in Google Chrome for real-time speech transcription, but it's a little finicky. I'm happy with the performance I get from either manually queueing speech segments or using VAD and then using batch speech recognition with a model that's kept in memory (which is why I use a local server instead of a command-line tool). Come to think of it, I should try this with a higher-quality model like medium or large, just in case the latency turns out to be not that much more for this use case.

What about external voice control systems like Talon Voice or Cursorless? They seem like neat ideas and lots of people use them. I think hacking something into Emacs with full access to its internals could be lots of fun too.

A lot of people have experimented with voice input for Emacs over the years. It could be fun to pick up ideas for commands and grammars. Some examples:

What about automating myself out of this loop? I've considered training a classifier or sending the list to a large language model to categorize links in order to set more reasonable defaults, but I think I'd still want manual control, since the fun is in getting a sense of all the cool things that people are tinkering around with in the Emacs community. I found that with voice control, it was easier for me to say the category than to look for the category it suggested and then say "Okay" to accept the default. If I display the suggested category in a buffer with very large text (and possibly category-specific background colours), then I can quickly glance at it or use my peripheral vision. But yeah, it's probably easier to look at a page and say "Org Mode" than to look at the page, look at the default text, see if it matches Org Mode, and then say okay if it is.

Ideas for next steps

I wonder how to line up several categories. I could probably rattle off a few without waiting for the next one to load, and just pause when I'm not sure. Maybe while there's a reasonably good match within the first 1-3 words, I'll take candidates from the front of the queue. Or I could delimit it with another easily-recognized word, like "next".

I want to make a more synchronous version of this idea so that I can have a speech-enabled drop-in replacement that I can use as my y-or-n-p while still being able to type y or n. This probably involves using sit-for and polling to see if it's done. And then I can use that to play Twenty Questions, but also to do more serious stuff. It would also be nice to have replacements for read-string and completing-read, since those block Emacs until the user enters something.

I might take a side-trip into a conversational interface for M-x doctor and M-x dunnet, because why not. Naturally, it also makes sense to voice-enable agent-shell and gptel interactions.

I'd like to figure out a number- or word-based completion mechanism so that I can control Reddit link replacement as well, since I want to select from a list of links from the page. Maybe something similar to the way voicemacs adds numbers to helm and company or how flexi-choose.el works.

I'm also thinking about how I can shift seamlessly between typing and speaking, like when I want to edit a link title. Maybe I can check if I'm in the minibuffer and what kind of minibuffer I'm in, perhaps like the way Embark does.

It would be really cool to define speech commands by reusing the keymap structure that menus also use. This is how to define a menu in Emacs Lisp:

(easy-menu-define words-menu global-map
  "Menu for word navigation commands."
  '("Words"
     ["Forward word" forward-word]
     ["Backward word" backward-word]))

and this is how to set just one binding:

(keymap-set-after my-menu "<drink>"
  '("Drink" . drink-command) 'eat)

That makes sense to reuse for speech commands. I'd also like to be able to specify aliases while hiding them or collapsing them for a "What can I say" help view… Also, if keymaps work, then maybe minor modes or transient maps could work? This sort of feels like it should be the voice equivalent of a transient map.

The code so far

(defun my-emacs-news-categorize-with-voice (&optional skip-browse)
  (interactive (list current-prefix-arg))
  (unless skip-browse
    (my-spookfox-browse))
  (speech-input-cancel-recording)
  (let ((default (if (fboundp 'my-emacs-news-guess-category) (my-emacs-news-guess-category))))
    (speech-input-from-list
     (if default
         (format "Category (%s): " default)
       "Category: ")
     '(("Org Mode" "Org" "Org Mode")
       "Other"
       "Emacs Lisp"
       "Coding"
       ("Emacs configuration" "Config" "Configuration")
       ("Appearance" "Appearance")
       ("Default" "Okay" "Default")
       "Community"
       "AI"
       "Writing"
       ("Reddit" "Read it" "Reddit")
       "Shells"
       "Navigation"
       "Fun"
       ("Dired" "Directory" "Dir ed")
       ("Mail, news, and chat" "News" "Mail" "Chat")
       "Multimedia"
       "Scroll down"
       "Scroll up"
       "Web"
       "Delete"
       "Skip"
       "Undo"
       ("Quit" "Quit" "Cancel" "All done"))
     (lambda (result text)
       (message "Recognized %s original %s" result text)
       (pcase result
         ("Undo"
          (undo)
          (my-emacs-news-categorize-with-voice t))
         ("Skip"
          (forward-line)
          (my-emacs-news-categorize-with-voice))
         ("Quit"
          (message "All done.")
          (speech-input-cancel-recording))
         ("Reddit"
          (my-emacs-news-replace-reddit-link)
          (my-emacs-news-categorize-with-voice t))
         ("Scroll down"
          (my-spookfox-scroll-down)
          (my-emacs-news-categorize-with-voice t))
         ("Scroll up"
          (my-spookfox-scroll-up)
          (my-emacs-news-categorize-with-voice t))
         ("Delete"
          (delete-line)
          (undo-boundary)
          (my-emacs-news-categorize-with-voice))
         ("Default"
          (my-org-move-current-item-to-category
           (concat default ":"))
          (undo-boundary)
          (my-emacs-news-categorize-with-voice))
         (_
          (my-org-move-current-item-to-category
           (concat result ":"))
          (undo-boundary)
          (my-emacs-news-categorize-with-voice))))
     t)))

It uses Spookfox to control Firefox from Emacs:

(defun my-spookfox-scroll-down ()
  (interactive)
  (spookfox-js-injection-eval-in-active-tab "window.scrollBy(0, document.documentElement.clientHeight);" t))

(defun my-spookfox-scroll-up ()
  (interactive)
  (spookfox-js-injection-eval-in-active-tab "window.scrollBy(0, -document.documentElement.clientHeight);"))

(defun my-spookfox-background-tab (url &rest args)
  "Open URL as a background tab."
  (if spookfox--connected-clients
      (spookfox-tabs--request (cl-first spookfox--connected-clients) "OPEN_TAB" `(:url ,url))
    (browse-url url)))

It also uses these functions for categorizing Org Mode items:

(defun my-org-move-current-item-to-category (category)
    "Move current list item under CATEGORY earlier in the list.
  CATEGORY can be a string or a list of the form (text indent regexp).
  Point should be on the next line to process, even if a new category
  has been inserted."
    (interactive (list (completing-read "Category: " (my-org-get-list-categories))))
    (when category
      (let* ((col (current-column))
             (item (point-at-bol))
             (struct (org-list-struct))
             (category-text (if (stringp category) category (elt category 0)))
             (category-indent (if (stringp category) 2 (+ 2 (elt category 1))))
             (category-regexp (if (stringp category) category (elt category 2)))
             (end (elt (car (last struct)) 6))
             (pos (point))
             s)
        (setq s (org-remove-indentation (buffer-substring-no-properties item (org-list-get-item-end item struct))))
        (save-excursion
          (if (string= category-text "x")
              (org-list-send-item item 'delete struct)
            (goto-char (caar struct))
            (if (re-search-forward (concat "^ *- +" category-regexp) end t)
                (progn
                  ;; needs a patch to ol.el to check if stringp
                  (org-list-send-item item (point-at-bol) struct)
                  (org-move-item-down)
                  (org-indent-item))
              (goto-char end)
              (org-list-insert-item
               (point-at-bol)
               struct (org-list-prevs-alist struct))
              (let ((old-struct (copy-tree struct)))
                (org-list-set-ind (point-at-bol) struct 0)
                (org-list-struct-fix-bul struct (org-list-prevs-alist struct))
                (org-list-struct-apply-struct struct old-struct))
              (goto-char (point-at-eol))
              (insert category-text)
              (org-list-send-item item 'end struct)
              (org-indent-item)
              (org-indent-item))
            (recenter))))))

(defun my-org-guess-list-category (&optional categories)
  (interactive)
  (require 'cl-lib)
  (unless categories
    (setq categories
          (my-helm-org-list-categories-init-candidates)))
  (let* ((beg (line-beginning-position))
         (end (line-end-position))
         (string (buffer-substring-no-properties beg end))
         (found
          (cl-member string
                     categories
                     :test
                     (lambda (string cat-entry)
                       (unless (string= (car cat-entry) "x")
                         (string-match (regexp-quote (downcase (car cat-entry)))
                                       string))))))
    (when (car found)
      (my-org-move-current-item-to-category
       (cdr (car found)))
      t)))

For the speech-input functions, experimental code is at https://codeberg.org/sachac/speech-input .

View Org source for this post

2026-03-23 Emacs news

| emacs, emacs-news

: Removed elecxzy comment-dwim, whoops.

Might be a good opportunity to set up better auto-saves, with buffer-guardian.el inspiring an update to super-save 0.5. Also, there were a couple of interesting experiments embedding Chromium (Reddit) or native macOS views in Emacs (Reddit), and one about embedding Emacs in a webpage (Reddit).

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, programming.dev, lemmy.world, lemmy.ml, 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

2026-03-16 Emacs news

| emacs, emacs-news

Security reminder: If you use kubernetes-el, don't update for now, and you might want to check your installation if you updated it recently. The repo was compromised. (Analysis, Reddit discussion, lobste.rs) If you use Emacs 31, please consider enabling package-review-policy.

There were a number of lively conversations around Emacs Solo (142 comments on HN), Emacs and Vim in the age of AI (52 comments on Reddit, 138 on HN), and agent-shell 0.47 (62 on Reddit). Also, Prot has posted the video and text of his talk Computing in freedom with GNU Emacs (YouTube 42:40, Video with Q&A, more links in the community section).

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, programming.dev, lemmy.world, lemmy.ml, 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

Org Mode: Export HTML, copy files, and serve the results via simple-httpd so that media files work

Posted: - Modified: | emacs, org

: Update Oh, ignore all of this! For some reason, when I export the regular Org Mode way, my media files work. Maybe it was just a weird hiccup!

In Org Mode, when you use "Export to HTML - As HTML file and open", the resulting HTML file is loaded using a file:// URL. This means you can't load any media files. In my post about pronunciation practice, I wanted to test the playback without waiting for my 11ty-based static site generator to churn through the files.

simple-httpd lets you run a web server from Emacs. By default, the httpd-root is ~/public_html and httpd-port is 8085, but you can configure it to be somewhere else. Here I set it up to create a new temporary directory, and to delete that directory afterwards.

(use-package simple-httpd
  :config
  (setq httpd-root (make-temp-file "httpd" t))
  :hook
  (httpd-stop . my-simple-httpd-remove-temporary-root)
  (kill-emacs . httpd-stop))

(defun my-simple-httpd-remove-temporary-root ()
  "Remove `httpd-root' only if it's a temporary directory."
  (when (file-in-directory-p httpd-root temporary-file-directory)
    (delete-directory httpd-root t)))

The following code exports your Org buffer or subtree to a file in that directory, copies all the referenced local files (if they're newer) and updates the links in the HTML, and then serves it via simple-httpd. Note that it just overwrites everything without confirmation, so if you refer to files with the same name, only the last one will be kept.

(with-eval-after-load 'ox
  (org-export-define-derived-backend 'my-html-served 'html
    :menu-entry
    '(?s "Export to HTML and Serve"
         ((?b "Buffer"  my-org-serve--buffer)
          (?s "Subtree" my-org-serve--subtree)))))

(defun my-org-serve--buffer (&optional async _subtreep visible-only body-only ext-plist)
  (my-org-export-and-serve nil))

(defun my-org-serve--subtree (&optional async _subtreep visible-only body-only ext-plist)
  (my-org-export-and-serve t))

;; Based on org-11ty--copy-files-and-replace-links
;; Might be a good idea to use something DOM-based instead
(defun my-html-copy-files-and-replace-links (info &optional destination-dir)
  (let ((file-regexp "\\(?:src\\|href\\|poster\\)=\"\\(\\(file:\\)?.*?\\)\"")
        (destination-dir (or destination-dir (file-name-directory (plist-get info :file-path))))
        file-all-urls file-name beg
        new-file file-re
        unescaped)
    (unless (file-directory-p destination-dir)
      (make-directory destination-dir t))
    (unless (file-directory-p destination-dir)
      (error "%s is not a directory." destination-dir))
    (save-excursion
      (goto-char (point-min))
      (while (re-search-forward file-regexp nil t)
        (setq file-name (or (match-string 1) (match-string 2)))
        (unless (or (string-match "^#" file-name)
                    (get-text-property 0 'changed file-name))
          (setq file-name
                (replace-regexp-in-string
                 "\\?.+" ""
                 (save-match-data (if (string-match "^file:" file-name)
                                      (substring file-name 7)
                                    file-name))))
          (setq unescaped
                (replace-regexp-in-string
                 "%23" "#"
                 file-name))
          (setq new-file (concat
                          (if info (plist-get info :permalink) "")
                          (file-name-nondirectory unescaped)))
          (unless (org-url-p file-name)
            (let ((new-file-name (expand-file-name (file-name-nondirectory unescaped)
                                                   destination-dir)))
              (condition-case err
                  (when (or (not (file-exists-p new-file-name))
                            (file-newer-than-file-p unescaped new-file-name))
                    (copy-file unescaped new-file-name t))
                (error nil))
              (when (file-exists-p new-file-name)
                (save-excursion
                  (goto-char (point-min))
                  (setq file-re (concat "\\(?: src=\"\\| href=\"\\| poster=\"\\)\\(\\(?:file://\\)?" (regexp-quote file-name) "\\)"))
                  (while (re-search-forward file-re nil t)
                    (replace-match
                     (propertize
                      (save-match-data (replace-regexp-in-string "#" "%23" new-file))
                      'changed t)
                     t t nil 1)))))))))))

(defun my-org-export-and-serve (&optional subtreep)
  "Export current org buffer (or subtree if SUBTREEP) to HTML and serve via simple-httpd."
  (interactive "P")
  (require 'simple-httpd)
  (httpd-stop)
  (unless httpd-root (error "Set `httpd-root'."))
  (unless (file-directory-p httpd-root)
    (make-directory httpd-root t))
  (unless (file-directory-p httpd-root)
    (error "%s is not a directory." httpd-root))
  (let* ((out-file (expand-file-name (concat (file-name-base (buffer-file-name)) ".html")
                                     httpd-root))
         (html-file (org-export-to-file 'my-html-served out-file nil subtreep)))
    ;; Copy all the files and rewrite all the links
    (with-temp-file out-file
      (insert-file-contents out-file)
      (my-html-copy-files-and-replace-links
       `(:permalink "/") httpd-root))
    (httpd-start)
    (browse-url (format "http://localhost:%d/%s"
                        httpd-port
                        (file-name-nondirectory html-file)))))

Now I can use C-c C-e (org-export-dispatch), select the subtree with C-s, and use s s to export a subtree to a webserver and have all the media files work. This took 0.46 seconds for my post on pronunciation practice and automatically opens the page in a browser window. In comparison, my 11ty static site generator took 5.18 seconds for a subset of my site (1630 files copied, 214 files generated), and I haven't yet hooked up monitoring it to Emacs, so I have to take an extra step to open the page in the browser when I think it's finished. I think exporting to HTML and serving it with simple-httpd will be much easier for simple cases like this, and then I can export to 11ty once I'm done with the basic checks.

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

Comparing pronunciation recordings across time

Posted: - Modified: | french, emacs, org, subed
  • : Added reconstructions from today and moved more of the code to sachac/learn-lang.
  • : Added updates from today.
  • : Added reference audio for the second set.
  • : I added pronunciation segments for the new set of tongue-twisters I got on Mar 13.
  • : I added a column for Feb 20, the first session with the sentences. I also added keyboard shortcuts (1..n) for playing the audio of the row that the mouse is on.

2026-02-20: First set: Maman peint un grand lapin blanc, etc.

My French tutor gave me a list of sentences to help me practise pronunciation.

I can fuzzy-match these with the word timing JSON from WhisperX, like this.

Extract all approximately matching phrases
(subed-record-extract-all-approximately-matching-phrases
   sentences
   "/home/sacha/sync/recordings/2026-02-20-raphael.json"
   "/home/sacha/proj/french/analysis/virelangues/2026-02-20-raphael-script.vtt")
Sentences
  • Maman peint un grand lapin blanc.
  • Un enfant intelligent mange lentement.
  • Le roi croit voir trois noix.
  • Le témoin voit le chemin loin.
  • Moins de foin au loin ce matin.
  • La laine beige sèche près du collège.
  • La croquette sèche dans l'assiette.
  • Elle mène son frère à l'hôtel.
  • Le verre vert est très clair.
  • Elle aimait manger et rêver.
  • Le jeu bleu me plaît peu.
  • Ce neveu veut un jeu.
  • Le feu bleu est dangereux.
  • Le beurre fond dans le cœur chaud.
  • Les fleurs de ma sœur sentent bon.
  • Le hibou sait où il va.
  • L'homme fort mord la pomme.
  • Le sombre col tombe.
  • L'auto saute au trottoir chaud.
  • Le château d'en haut est beau.
  • Le cœur seul pleure doucement.
  • Tu es sûr du futur ?
  • Trois très grands trains traversent trois trop grandes rues.
  • Je veux deux feux bleus, mais la reine préfère la laine beige.
  • Vincent prend un bain en chantant lentement.
  • La mule sûre court plus vite que le loup fou.
  • Luc a bu du jus sous le pont où coule la boue.
  • Le frère de Robert prépare un rare rôti rouge.
  • La mule court autour du mur où hurle le loup.

Then I can use subed-record to manually tweak them, add notes, and so on. I end up with VTT files like 2026-03-06-raphael-script.vtt. I can assemble the snippets for a session into a single audio file, like this:

I wanted to compare my attempts over time, so I wrote some code to use Org Mode and subed-record to build a table with little audio players that I can use both within Emacs and in the exported HTML. This collects just the last attempts for each sentence during a number of my sessions (both with the tutor and on my own). The score is from the Microsoft Azure pronunciation assessment service. I'm not entirely sure about its validity yet, but I thought I'd add it for fun. * indicates where I've added some notes from my tutor, which should be available as a title attribute on hover. (Someday I'll figure out a mobile-friendly way to do that.)

Calling it with my sentences and files
(learn-lang-subed-record-summarize-segments
 sentences
 '(("/home/sacha/proj/french/analysis/virelangues/2026-02-20-raphael-script.vtt" . "Feb 20")
 ;("~/sync/recordings/processed/2026-02-20-raphael-tongue-twisters.vtt" . "Feb 20")
        ("~/sync/recordings/processed/2026-02-22-virelangues-single.vtt" . "Feb 22")
        ("~/proj/french/recordings/2026-02-26-virelangues-script.vtt" . "Feb 26")
        ("~/proj/french/recordings/2026-02-27-virelangues-script.vtt" . "Feb 27")
        ("~/proj/french/recordings/2026-03-03-virelangues.vtt" . "Mar 3")
        ("/home/sacha/sync/recordings/processed/2026-03-03-raphael-reference-script.vtt" . "Mar 3")
        ("~/proj/french/analysis/virelangues/2026-03-06-raphael-script.vtt" . "Mar 6")
        ("~/proj/french/analysis/virelangues/2026-03-12-virelangues-script.vtt" . "Mar 12"))
 "~/proj/french/analysis/virelangues/clip"
 #'learn-lang-subed-record-get-last-attempt
 )
Feb 20 Feb 22 Feb 26 Feb 27 Mar 3 Mar 3 Mar 6 Mar 12 Text
▶️ 63* ▶️ 96 ▶️ 95 ▶️ 94 ▶️ 83 ▶️ 83* ▶️ 81* ▶️ 88 Maman peint un grand lapin blanc.
▶️ 88* ▶️ 95 ▶️ 99 ▶️ 99 ▶️ 96 ▶️ 89* ▶️ 92* ▶️ 83 Un enfant intelligent mange lentement.
▶️ 84* ▶️ 97 ▶️ 97 ▶️ 96 ▶️ 94 ▶️ 95* ▶️ 98* ▶️ 99 Le roi croit voir trois noix.
▶️ 80* ▶️ 85 ▶️ 77 ▶️ 94 ▶️ 97   ▶️ 92* ▶️ 88 Le témoin voit le chemin loin.
▶️ 72* ▶️ 97 ▶️ 95 ▶️ 77 ▶️ 92   ▶️ 89* ▶️ 86 Moins de foin au loin ce matin.
▶️ 79* ▶️ 95 ▶️ 76 ▶️ 95 ▶️ 76 ▶️ 90* ▶️ 90* ▶️ 79 La laine beige sèche près du collège.
▶️ 67* ▶️ 99 ▶️ 85 ▶️ 81 ▶️ 85 ▶️ 99* ▶️ 97* ▶️ 97 La croquette sèche dans l'assiette.
▶️ 88* ▶️ 99 ▶️ 100 ▶️ 100 ▶️ 98 ▶️ 100* ▶️ 99* ▶️ 100 Elle mène son frère à l'hôtel.
▶️ 77* ▶️ 87 ▶️ 99 ▶️ 93 ▶️ 87   ▶️ 87* ▶️ 99 Le verre vert est très clair.
▶️ 100* ▶️ 94 ▶️ 100 ▶️ 99 ▶️ 99 ▶️ 99* ▶️ 100* ▶️ 100 Elle aimait manger et rêver.
▶️ 78* ▶️ 98 ▶️ 99 ▶️ 98 ▶️ 98 ▶️ 92*   ▶️ 88 Le jeu bleu me plaît peu.
▶️ 78* ▶️ 97 ▶️ 85 ▶️ 95 ▶️ 85     ▶️ 85 Ce neveu veut un jeu.
▶️ 73* ▶️ 95 ▶️ 95 ▶️ 96 ▶️ 97     ▶️ 100 Le feu bleu est dangereux.
▶️ 87* ▶️ 76 ▶️ 65 ▶️ 97 ▶️ 85 ▶️ 74* ▶️ 85* ▶️ 96 Le beurre fond dans le cœur chaud.
▶️ 84* ▶️ 43 ▶️ 85 ▶️ 79 ▶️ 75     ▶️ 98 Les fleurs de ma sœur sentent bon.
▶️ 70* ▶️ 86 ▶️ 79 ▶️ 76 ▶️ 87 ▶️ 84   ▶️ 98 Le hibou sait où il va.
▶️ 92* ▶️ 95 ▶️ 86 ▶️ 92 ▶️ 98 ▶️ 99*   ▶️ 94 L'homme fort mord la pomme.
▶️ 83* ▶️ 73 ▶️ 69 ▶️ 81 ▶️ 60 ▶️ 96*   ▶️ 81 Le sombre col tombe.
▶️ 39* ▶️ 49 ▶️ 69 ▶️ 56 ▶️ 69 ▶️ 96*   ▶️ 94 L'auto saute au trottoir chaud.
▶️ 82 ▶️ 84 ▶️ 85 ▶️ 98 ▶️ 94 ▶️ 96*   ▶️ 99 Le château d'en haut est beau.
▶️ 89 ▶️ 85 ▶️ 75 ▶️ 91 ▶️ 52 ▶️ 75* ▶️ 70* ▶️ 98 Le cœur seul pleure doucement.
▶️ 98*   ▶️ 99 ▶️ 99 ▶️ 95 ▶️ 93* ▶️ 97* ▶️ 99 Tu es sûr du futur ?
    ▶️ 97 ▶️ 93 ▶️ 92 ▶️ 85*   ▶️ 90 Trois très grands trains traversent trois trop grandes rues.
    ▶️ 94 ▶️ 85 ▶️ 97 ▶️ 82*   ▶️ 92 Je veux deux feux bleus, mais la reine préfère la laine beige.
    ▶️ 91 ▶️ 79 ▶️ 87 ▶️ 82*   ▶️ 94 Vincent prend un bain en chantant lentement.
    ▶️ 89 ▶️ 91 ▶️ 91 ▶️ 84*   ▶️ 92 La mule sûre court plus vite que le loup fou.
    ▶️ 91 ▶️ 93 ▶️ 93 ▶️ 92*   ▶️ 96 Luc a bu du jus sous le pont où coule la boue.
    ▶️ 88 ▶️ 71 ▶️ 94 ▶️ 86*   ▶️ 92 Le frère de Robert prépare un rare rôti rouge.
    ▶️ 81 ▶️ 84 ▶️ 88 ▶️ 67*   ▶️ 94 La mule court autour du mur où hurle le loup.

Pronunciation still feels a bit hit or miss. Sometimes I say a sentence and my tutor says "Oui," and then I say it again and he says "Non, non…" The /ʁ/ and /y/ sounds are hard.

I like seeing these compact links in an Org Mode table and being able to play them, thanks to my custom audio link type. It should be pretty easy to write a function that lets me use a keyboard shortcut to play the audio (maybe using the keys 1-9?) so that I can bounce between them for comparison.

If I screen-share from Google Chrome, I can share the tab with audio, so my tutor can listen to things at the same time. Could be fun to compare attempts so that I can try to hear the differences better. Hmm, actually, let's try adding keyboard shortcuts that let me use 1-8, n/p, and f/b to navigate and play audio. Mwahahaha! It works!

2026-03-14: Second set: Mon oncle peint un grand pont blanc, etc.

Update 2026-03-14: My tutor gave me a new set of tongue-twisters. When I'm working on my own, I find it helpful to loop over an audio reference with a bit of silence after it so that I can repeat what I've heard. I have several choices for reference audio:

  • I can generate an audio file using text-to-speech, like a local instance of Kokoro TTS, or a hosted service like Google Translate (via gtts-cli), ElevenLabs, or Microsoft Azure.
  • I can extract a recording of my tutor from one of my sessions.
  • I can extract a recording of myself from one of my tutoring sessions where my tutor said that the pronunciation is alright.

Here I stumble through the tongue-twisters. I've included reference audio from Kokoro, gtts, and ElevenLabs for comparison.

(my-subed-record-analyze-file-with-azure-and-references
 (subed-record-keep-last
  (subed-record-filter-skips
   (subed-parse-file
    "/home/sacha/proj/french/analysis/virelangues/2026-03-13-raphael-script.vtt")))
 "~/proj/french/analysis/virelangues-2026-03-13/2026-03-13-clip")
Gt Kk Az Me ID Comments All Acc Flu Comp Conf  
👂🏼 👂🏼 👂🏼 ▶️ 1 X: pont 93 99 90 100 86 Mon oncle peint un grand pont blanc. {pont}
👂🏼 👂🏼 👂🏼 ▶️ 2 C'est mieux 68 75 80 62 87 Un singe malin prend un bon raisin rond.
👂🏼 👂🏼 👂🏼 ▶️ 3 Ouais, c'est ça 83 94 78 91 89 Dans le vent du matin, mon chien sent un bon parfum.
👂🏼 👂🏼 👂🏼 ▶️ 4 ok 75 86 63 100 89 Le soin du roi consiste à joindre chaque coin du royaume.
👂🏼 👂🏼 👂🏼 ▶️ 5 Ouais, c'est ça, parfait 83 94 74 100 88 Dans un coin du bois, le roi voit trois points noirs.
👂🏼 👂🏼 👂🏼 ▶️ 6 Ouais, parfait 90 92 87 100 86 Le feu de ce vieux four chauffe peu.
👂🏼 👂🏼 👂🏼 ▶️ 7 Ouais 77 85 88 71 86 Deux peureux veulent un peu de feu.
👂🏼 👂🏼 👂🏼 ▶️ 8   77 78 75 83 85 Deux vieux bœufs veulent du beurre.
👂🏼 👂🏼 👂🏼 ▶️ 9 Ouais, parfait 92 94 89 100 89 Elle aimait marcher près de la rivière.
👂🏼 👂🏼 👂🏼 ▶️ 10 Ok, c'est bien 93 98 89 100 90 Je vais essayer de réparer la fenêtre.
👂🏼 👂🏼 👂🏼 ▶️ 11 Okay 83 87 76 100 89 Le bébé préfère le lait frais.
👂🏼 👂🏼 👂🏼 ▶️ 12   77 92 70 86 90 Charlotte cherche ses chaussures dans la chambre.
👂🏼 👂🏼 👂🏼 ▶️ 13 Okay 91 90 94 91 88 Un chasseur sachant chasser sans son chien est-il un bon chasseur ?
👂🏼 👂🏼 👂🏼 ▶️ 14 Ouais 91 88 92 100 91 Le journaliste voyage en janvier au Japon.
👂🏼 👂🏼 👂🏼 ▶️ 15 C'est bien (X: dans un) 91 88 94 100 88 Georges joue du jazz dans un grand bar. {dans un}
👂🏼 👂🏼 👂🏼 ▶️ 16 C'est bien 88 87 94 88 85 Un jeune joueur joue dans le grand gymnase.
👂🏼 👂🏼 👂🏼 ▶️ 17   95 94 96 100 91 Le compagnon du montagnard soigne un agneau.
👂🏼 👂🏼 👂🏼 ▶️ 18   85 88 84 86 89 La cigogne soigne l’agneau dans la campagne.
👂🏼 👂🏼 👂🏼 ▶️ 19 grenouille 71 80 68 75 86 La grenouille fouille les feuilles dans la broussaille.

And comparing clips over time:

(learn-lang-subed-record-summarize-segments
 (split-string (org-file-contents "~/proj/french/analysis/virelangues-2026-03-13/phrases.txt") "\n")
 '(("/home/sacha/proj/french/analysis/virelangues/2026-03-13-raphael-script.vtt" . "Mar 13")
   ("/home/sacha/proj/french/analysis/virelangues-2026-03-13/2026-03-20-raphael-script.vtt" . "Mar 20")
   ("/home/sacha/proj/french/analysis/virelangues-2026-03-13/2026-03-24-raphael-script.vtt" . "Mar 24")
   ("/home/sacha/proj/french/analysis/virelangues-2026-03-13/2026-03-27-script.vtt" . "Mar 27"))
 "~/proj/french/analysis/virelangues-2026-03-13/all-"
 #'learn-lang-subed-record-get-last-attempt)
Mar 13 Mar 20 Mar 24 Mar 27 Text
▶️ 93* ▶️ 89 ▶️ 71* ▶️ 88 Mon oncle peint un grand pont blanc.
▶️ 68* ▶️ 82* ▶️ 90* ▶️ 91 Un singe malin prend un bon raisin rond.
▶️ 83* ▶️ 89* ▶️ 93* ▶️ 95 Dans le vent du matin, mon chien sent un bon parfum.
▶️ 75* ▶️ 84* ▶️ 86* ▶️ 90 Le soin du roi consiste à joindre chaque coin du royaume.
▶️ 83* ▶️ 88* ▶️ 96 ▶️ 97 Dans un coin du bois, le roi voit trois points noirs.
▶️ 90* ▶️ 90* ▶️ 90* ▶️ 99 Le feu de ce vieux four chauffe peu.
▶️ 77* ▶️ 77* ▶️ 84* ▶️ 89 Deux peureux veulent un peu de feu.
▶️ 77 ▶️ 80* ▶️ 71* ▶️ 52 Deux vieux bœufs veulent du beurre.
▶️ 92* ▶️ 88* ▶️ 100* ▶️ 96 Elle aimait marcher près de la rivière.
▶️ 93* ▶️ 87* ▶️ 98* ▶️ 94 Je vais essayer de réparer la fenêtre.
▶️ 83* ▶️ 85* ▶️ 93* ▶️ 97 Le bébé préfère le lait frais.
▶️ 77 ▶️ 78* ▶️ 88* ▶️ 87 Charlotte cherche ses chaussures dans la chambre.
▶️ 91* ▶️ 92* ▶️ 97* ▶️ 92 Un chasseur sachant chasser sans son chien est-il un bon chasseur ?
▶️ 91* ▶️ 84* ▶️ 88* ▶️ 91 Le journaliste voyage en janvier au Japon.
▶️ 91*   ▶️ 79* ▶️ 95 Georges joue du jazz dans un grand bar.
▶️ 88* ▶️ 99* ▶️ 90* ▶️ 96 Un jeune joueur joue dans le grand gymnase.
▶️ 95 ▶️ 95 ▶️ 94* ▶️ 93 Le compagnon du montagnard soigne un agneau.
▶️ 85 ▶️ 63* ▶️ 86* ▶️ 100 La cigogne soigne l'agneau dans la campagne.
▶️ 71* ▶️ 64* ▶️ 79* ▶️ 91 La grenouille fouille les feuilles dans la broussaille.
  ▶️ 66* ▶️ 79* ▶️ 73 La vieille abeille travaille dans la broussaille.
  ▶️ 96* ▶️ 90* ▶️ 95 Le juge Jean juge justement les jolis bijoux de Julie.
    ▶️ 92* ▶️ 93 Ma compagne m'accompagne à la campagne avec une autre compagne.
      ▶️ 82 Une tortue têtue marche dessus sous une pluie continue.
      ▶️ 76 Une mule têtue pousse une roue lourde sur une rue boueuse.
      ▶️ 73 Trois gros rats gris grimpent dans trois grands greniers rugueux.
      ▶️ 84 Un professeur rigoureux corrige un rapport sur une erreur rare.

The code

Code for summarizing the segments
(defun my-subed-record-analyze-file-with-azure-and-references (subtitles prefix &optional always-create)
  (cons
   '("Gt" "Kk" "Az" "Me" "ID" "Comments" "All" "Acc" "Flu" "Comp" "Conf")
   (seq-map-indexed
    (lambda (sub i)
      (let ((sound-file (expand-file-name (format "%s-%02d.opus"
                                                  prefix
                                                  (1+ i))))
            (tts-services
             '(("gtts" . learn-lang-tts-gtts-say)
               ("kokoro" . learn-lang-tts-kokoro-fastapi-say)
               ("azure" . learn-lang-tts-azure-say)))
            tts-files
            (note (subed-record-get-directive "#+NOTE" (elt sub 4))))
        (when (or always-create (not (file-exists-p sound-file)))
          (subed-record-extract-audio-for-current-subtitle-to-file sound-file sub))
        (setq
         tts-files
         (mapcar
          (lambda (row)
            (let ((reference (format "%s-%s-%02d.opus" prefix (car row) (1+ i) )))
              (when (or always-create (not (file-exists-p reference)))
                (funcall (cdr row)
                         (subed-record-simplify (elt sub 3))
                         'sync
                         reference))
              (org-link-make-string
               (concat "audio:" reference "?icon=t&note=" (url-hexify-string (car row)))
               "👂🏼")))
          tts-services))
        (append
         tts-files
         (list
          (org-link-make-string
           (concat "audio:" sound-file "?icon=t"
                   (format "&source-start=%s" (elt sub 1))
                   (if (and note (not (string= note "")))
                       (format "&title=%s"
                               (url-hexify-string note))
                     ""))
           "▶️")
          (format "%d" (1+ i))
          (or note ""))
         (learn-lang-azure-subed-record-parse (elt sub 4))
         (list
          (elt sub 3)))))
    subtitles)))

Jumping to the source again:

(defun my-subed-record-org-clip-view-source ()
  (interactive)
  (let* ((params
          (url-parse-query-string
           (cdr
            (url-path-and-query
             (url-generic-parse-url
              (org-element-property :raw-link (org-element-context))))))))
    (find-file (car (alist-get "source-file" params nil nil #'string=)))
    (subed-jump-to-subtitle-id-at-msecs
     (string-to-number (car (alist-get "source-start" params nil nil #'string=))))))

I've moved the rest of the code to sachac/learn-lang on Codeberg.

View Org source for this post