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

La semaine du 9 au 15 mars

| french

lundi 9 mars

Il faisait très beau et le soleil brillait. Je me suis assise sur le porche et j'ai réécrit mon journal et mes notes sur l'IA en français.

Après l'école, ma fille n'a pas voulu aller à son cours de gymnastique parce qu'elle avait mal au ventre. Elle est restée un petit moment, puis nous sommes allées au parc avec le réchaud de camping, des guimauves, et des biscuits au chocolat pour faire des s-mores. J'ai envoyé des messages à ses amies, mais je n'ai pas reçu de réponse. Néanmoins, si personne ne peut venir, nous pouvons toujours nous en préparer. Par coïncidence, personne n'a reçu mon message à temps, mais toutes ses amies nous ont trouvées. J'ai donné des guimauves aux filles et au grand-père d'une amie de ma fille. Nous les avons fait griller sur des brochettes. On s'est régalés. C'était une fête d'anniversaire inattendue, parce que ses amies étaient tombées malades juste avant la fête planifiée le mois précédent.

Après un dîner de burgers et de frites, nous avons cousu ensemble. Ma fille et moi avons travaillé sur la pochette Pokémon et mon mari a réparé un sac d'épicerie.

La bosse près du piercing de ma fille a commencé à saigner et suppurer. Normalement, elle dormait sur le dos, mais elle n'a pas pu contrôler sa position pendant son sommeil et de temps en temps, elle dormait probablement sur le côté. Je l'ai nettoyée avec une solution saline.

mardi 10 mars

Ma fille était de mauvaise humeur parce que l'école avait une remplaçante et qu'elle avait quelques douleurs. Elle n'a pas voulu participer en classe l'après-midi.

J'avais un rendez-vous avec mon tuteur, pendant lequel j'ai pratiqué ma prononciation à l'aide de mes notes sur l'IA. J'ai mis les mots que je prononce mal en gras. Après le rendez-vous, j'ai écrit des fonctions pour extraire les mots gras avec leurs contextes et les enregistrer dans mes notes au format Org Mode pour les revoir. Ma prochaine étape est de rendre plus facile l'écoute des mots enregistrés.

J'ai aussi travaillé sur mon serveur de synthèse vocale qui est compatible avec speechd. Kokoro TTS est trop lent pour un usage général, mais sa qualité est meilleure que celle d'espeak, donc je veux l'utiliser pour les textes longs pour lesquels une brève pause avant le début n'est pas un problème. Le serveur Kokoro FastAPI utilise l'interface de synthèse vocale d'OpenAI, donc si je l'implémente pour Kokoro, les autres services comme OpenAI fonctionnent aussi.

Ma fille s'est endormie sur le canapé. Elle n'a pas voulu être portée à l'étage.

mercredi 11 mars

Ma fille s'est plainte de quelques symptômes, ma pauvre chérie. Elle a mal à la tête, au ventre et à un genou. Elle ne dort pas mieux… Elle ne va pas mieux. Elle semble traverser une période difficile. Je ne m'attends pas à grand-chose aujourd'hui.

J'ai participé à la réunion virtuelle OrgMeetup. J'ai présenté mes fonctions pour mettre un lien vers le fichier audio et l'écouter, mettre un lien automatique à partir de mes favoris, et télécharger et convertir les éléments de mes notes partagées avec mon tuteur sur Google Docs. J'ai aussi envoyé un correctif pour l'opération « sentence-at-point » dans Org Mode. J'ai travaillé davantage sur mon serveur speechd-ai qui est capable de se connecter aux serveurs compatibles avec le service de synthèse vocale d'OpenAI, mais ça ne fonctionne pas encore complètement.

Ma fille a raté la première partie de son cours à cause de problèmes de santé, mais elle a rejoint le cours à temps pour obtenir un score parfait au test de français. Elle a aussi travaillé le piano pendant le cours de musique. Elle était très fière de ses accomplissements. Elle s'est amusée à essayer quelques expressions en français. « Je suis une pomme de terre de canapé » dit-elle. Ce n'est pas l'expression idiomatique. ( Mon tuteur dit qu'il n'utilise ni cette expression ni « une patate de canapé. » Il pense que « pantouflarde » est peut-être mieux. ) Mais c'est bien qu'elle joue et lance des idées.

Après le dîner, ma fille et moi avons fait une sortie pour activer le PokéStop dans le coin. Très brève, mais au moins, elle a marché.

J'ai imprimé ses devoirs parce qu'elle préfère travailler sur papier plutôt que sur l'écran. Je l'ai aussi aidée à rassembler quelques informations pour son projet d'affiche.

J'ai trouvé que la reconnaissance vocale était utile quand ma fille a voulu un câlin pendant qu'elle faisait autre chose. Elle dit souvent, « Tu es toute chaude. » Mes bras sont trop courts pour taper pendant un câlin. Eh bien, je peux lui donner un câlin tandis que je saisis mes pensées, grâce à la reconnaissance vocale. Elle est curieuse de l'IA, donc de temps en temps, j'utilise la reconnaissance pour interroger l'IA ensemble.

Ma fille a essayé de demander à l'IA de corriger des bugs dans l'histoire interactive sur des farces de Pokémon. Elle était censée suivre le temps pendant l'aventure, mais les totaux étaient erronés. Je suis ravie de voir qu'elle remarque des erreurs et explique à l'IA les changements qu'elle veut.

jeudi 12 mars

Ma fille a voulu acheter une nouvelle boîte à lunch qui ne permet pas aux liquides de se mélanger, parce que nos boîtes actuelles ont de petits trous sous les cloisons et ses craquelins étaient tous mous de temps en temps. Malheureusement, je l'ai emmenée à l'ancienne adresse du magasin, qui a déjà fermé. Elle devra attendre une autre promenade.

Je me suis perdue dans les détails du travail sur le serveur de synthèse vocale qui est compatible avec speechd.

J'ai créé des fonctions pour rassembler mes tentatives de virelangues dans plusieurs fichiers.

vendredi 13 mars

Elle est venue se blottir contre moi toute la nuit. Elle a accaparé toutes les couvertures. Néanmoins, je l'aime encore.

Mon tuteur m'a donné de nouveaux virelangues pour travailler sur ma prononciation.

  • Mon oncle peint un grand pont blanc.
    {mɔ̃n ˈɔ̃kl pˈɛ̃ œ̃ ɡʁˈɑ̃ pˈɔ̃ blˈɑ̃.}
  • Un singe malin prend un bon raisin rond.
    {œ̃ sˈɛ̃ʒ malˈɛ̃ pʁˈɑ̃t œ̃ bˈɔ̃ ʁɛzˈɛ̃ ʁˈɔ̃.}
  • Dans le vent du matin, mon chien sent un bon parfum.
    {dɑ̃ lə vˈɑ̃ dy matˈɛ̃, mɔ̃ ʃjˈɛ̃ sˈɑ̃ œ̃ bˈɔ̃ paʁfˈœ̃.}
  • Le soin du roi consiste à joindre chaque coin du royaume.
    {lə swˈɛ̃ dy ʁwˈa kɔ̃sˈist a ʒwˈɛ̃dʁ ʃak kwˈɛ̃ dy ʁwajˈom.}
  • Dans un coin du bois, le roi voit trois points noirs.
    {dɑ̃z œ̃ kwˈɛ̃ dy bwˈa, lə ʁwˈa vwˈa tʁwˈa pwˌɛ̃ nwˈaʁ.}
  • Le feu de ce vieux four chauffe peu.
    {lə fˈø də sə vjˈø fˈuʁ ʃˈof pˈø.}
  • Deux peureux veulent un peu de feu.
    {dˈø pøʁˈø vˈœlt œ̃ pø də fˈø.}
  • Deux vieux bœufs veulent du beurre.
    {dˈø vjˈø bˈø vˈœl dy bˈœʁ.}
  • Elle aimait marcher près de la rivière.
    {ɛl ɛmˈɛ maʁʃˈe pʁɛ də la ʁivjˈɛʁ.}
  • Je vais essayer de réparer la fenêtre.
    {ʒə vˈɛz esɛjˈe də ʁepaʁˈe la fənˈɛtʁ.}
  • Le bébé préfère le lait frais.
    {lə bebˈe pʁefˈɛʁ lə lˈɛ fʁˈɛ.}
  • Charlotte cherche ses chaussures dans la chambre.
    {ʃaʁlˈɔt ʃˈɛʁʃ se ʃosˈyʁ dɑ̃ la ʃˈɑ̃bʁ.}
  • Un chasseur sachant chasser sans son chien est-il un bon chasseur ?
    {œ̃ ʃasˈœʁ saʃˈɑ̃ ʃasˈe sɑ̃ sɔ̃ ʃjˈɛ̃ ɛtil œ̃ bˈɔ̃ ʃasˈœʁ ?}
  • Le journaliste voyage en janvier au Japon.
    {lə ʒuʁnalˈist vwajˈaʒ ɑ̃ ʒɑ̃vjˈe o ʒapˈɔ̃.}
  • Georges joue du jazz dans un grand bar.
    {ʒˈɔʁʒ ʒˈu dy ʤˈaz dɑ̃z œ̃ ɡʁˈɑ̃ bˈaʁ.}
  • Un jeune joueur joue dans le grand gymnase.
    {œ̃ ʒˈøn ʒwˈœʁ ʒˈu dɑ̃ lə ɡʁˈɑ̃ ʒimnˈaz.}
  • Le compagnon du montagnard soigne un agneau.
    {lə kɔ̃panjˈɔ̃ dy mɔ̃tanjˈaʁ swˈaɲ œ̃n anjˈo.}
  • La cigogne soigne l’agneau dans la campagne.
    {la siɡˈɔɲ swˈaɲ lanjˈo dɑ̃ la kɑ̃pˈaɲ.}
  • La grenouille fouille les feuilles dans la broussaille.
    {la ɡʁənˈuj fˈuj le fˈœj dɑ̃ la bʁusˈaj.}
  • La vieille abeille travaille dans la broussaille.
    {la vjˈɛj abˈɛj tʁavˈaj dɑ̃ la bʁusˈaj.}

J'ai ajouté une version grasse de la police de caractères Open Sans sur mon site, qui aide à remarquer le contraste entre les mots gras et les mots normaux.

J'ai besoin de corriger un petit bogue dans mon correctif pour Org Mode.

Ma fille m'a aidée à déneiger le trottoir et la terrasse en bois. La neige était lourde à cause de la pluie verglaçante.

Après le dîner, ma fille et moi avons cousu ensemble. Elle a voulu fabriquer un petit sac à remplir de riz et de lavande, comme sa peluche d'axolotl chauffante. J'ai aussi continué à coudre le sac Pokémon.

samedi 14 mars

Ma fille a voulu m'aider à préparer des crêpes pour le petit-déjeuner. Elle a réussi à préparer des crêpes toute seule la fois précédente. Mais les deux premières crêpes ont collé à la poêle. Je me demande si ce n'était pas à cause de l'ajout de lait supplémentaire pour finir le carton de lait, et s'il vaut mieux que nous suivions un peu plus la recette la prochaine fois. J'ai essayé de gratter les morceaux collés avec la spatule à crêpes en bois, mais une partie était bien collée. Elle m'a demandé si elle pouvait l'essayer aussi. J'ai dit que non parce que j'ai enlevé tous les morceaux faciles et j'ai voulu ajouter de l'eau pour ramollir le reste. J'étais stressée car je devais aussi attendre un appel du médecin à propos des symptômes de ma fille. Elle n'a pas voulu écouter « non ». Elle est devenue très grincheuse parce qu'elle a eu l'impression que je l'avais critiquée. Elle est partie furieuse et elle était fâchée contre moi toute la journée, sauf quelques brefs moments. Je lui ai écrit un message pour lui présenter mes excuses. Avec le recul, peut-être que j'aurais mieux fait de la laisser essayer la prochaine fois. Mais c'est aussi important d'apprendre que si nous cuisinons ensemble, de temps en temps, il faut que je dise « non » ou « pas pour le moment. » De toute façon, elle s'est déridée le soir.

J'ai analysé les enregistrements du rendez-vous d'hier. Mon code pour chercher des correspondances approximatives entre la liste des phrases et la transcription était très utile.

(subed-record-extract-all-approximately-matching-phrases
   phrases
   "/home/sacha/sync/recordings/2026-03-13-raphael.json"
   "/home/sacha/proj/french/analysis/virelangues/2026-03-13-raphael-script.vtt")
(my-subed-record-analyze-file-with-azure
  (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-all")
File ID Comments All Acc Flu Comp Conf  
▶️ 1 X: pont 83 94 79 86 86 Mon oncle peint un grand pont blanc. {pont}
▶️ 2 X: peint 92 94 89 100 87 Mon oncle peint un grand pont blanc. {peint}
▶️ 3 X: pont 93 99 90 100 86 Mon oncle peint un grand pont blanc. {pont}
▶️ 4 X: raisin 76 82 70 88 87 Un singe malin prend un bon raisin rond. {raisin}
▶️ 5 C'est mieux 68 75 80 62 87 Un singe malin prend un bon raisin rond.
▶️ 6 X: parfum 75 92 62 100 89 Dans le vent du matin, mon chien sent un bon parfum. {parfum}
▶️ 7 X: parfum 71 99 53 100 89 Dans le vent du matin, mon chien sent un bon parfum. {parfum}
▶️ 8 Ouais, c'est ça 83 94 78 91 89 Dans le vent du matin, mon chien sent un bon parfum.
▶️ 9 ok 75 86 63 100 89 Le soin du roi consiste à joindre chaque coin du royaume.
▶️ 10 Ouais, c'est bien 80 94 72 91 88 Dans un coin du bois, le roi voit trois points noirs.
▶️ 11 Ouais, c'est ça, parfait 83 94 74 100 88 Dans un coin du bois, le roi voit trois points noirs.
▶️ 12 Mm hmm 95 94 94 100 84 Le feu de ce vieux four chauffe peu.
▶️ 13 Ouais, parfait 90 92 87 100 86 Le feu de ce vieux four chauffe peu.
▶️ 14   82 93 78 86 84 Deux peureux veulent un peu de feu.
▶️ 15 Ouais 77 85 88 71 86 Deux peureux veulent un peu de feu.
▶️ 16 X: bœufs 84 84 91 83 86 Deux vieux bœufs veulent du beurre. {bœufs}
▶️ 17   77 78 75 83 85 Deux vieux bœufs veulent du beurre.
▶️ 18 Ouais, parfait 92 94 89 100 89 Elle aimait marcher près de la rivière.
▶️ 19 Ok, c'est bien 93 98 89 100 90 Je vais essayer de réparer la fenêtre.
▶️ 20 X: le bébé 75 86 70 83 85 Le bébé préfère le lait frais. {le bébé}
▶️ 21 Ouais, c'est bien 88 94 82 100 88 Le bébé préfère le lait frais.
▶️ 22 Okay 83 87 76 100 89 Le bébé préfère le lait frais.
▶️ 23 X: cherche 74 77 81 71 88 Charlotte cherche ses chaussures dans la chambre. {cherche}
▶️ 24   77 92 70 86 90 Charlotte cherche ses chaussures dans la chambre.
▶️ 25 Voila, c'est ça 88 95 83 100 88 Un chasseur sachant chasser sans son chien est-il un bon chasseur ?
▶️ 26 Tu est forte 81 77 94 82 88 Un chasseur sachant chasser sans son chien est-il un bon chasseur ?
▶️ 27 Oui 92 95 93 91 89 Un chasseur sachant chasser sans son chien est-il un bon chasseur ?
▶️ 28 Okay 91 90 94 91 88 Un chasseur sachant chasser sans son chien est-il un bon chasseur ?
▶️ 29 X: au Japon 76 85 86 71 87 Le journaliste voyage en janvier au Japon. {au Japon}
▶️ 30 X: en janvier 92 89 95 100 92 Le journaliste voyage en janvier au Japon. {en janvier}
▶️ 31 Ouais 91 88 92 100 91 Le journaliste voyage en janvier au Japon.
▶️ 32 X: jazz 90 93 93 88 88 Georges joue du jazz dans un grand bar. {jazz}
▶️ 33 X: dans un 84 85 83 88 85 Georges joue du jazz dans un grand bar. {dans un}
▶️ 34 C'est bien (X: dans un) 91 88 94 100 88 Georges joue du jazz dans un grand bar. {dans un}
▶️ 35 X: dans le grand gymnase 87 86 92 88 88 Un jeune joueur joue dans le grand gymnase. {dans le grand gymnase}
▶️ 36 C'est bien 88 87 94 88 85 Un jeune joueur joue dans le grand gymnase.
▶️ 37   77 84 68 100 89 Le compagnon du montagnard soigne un agneau.
▶️ 38 Ouais, c'est ça 85 93 78 100 89 Le compagnon du montagnard soigne un agneau.
▶️ 39   95 94 96 100 91 Le compagnon du montagnard soigne un agneau.
▶️ 40 X: cigogne 74 81 77 71 89 La cigogne soigne l’agneau dans la campagne. {cigogne}
▶️ 41   85 88 84 86 89 La cigogne soigne l’agneau dans la campagne.
▶️ 42   69 76 83 62 87 La grenouille fouille les feuilles dans la broussaille.
▶️ 43 grenouille 71 80 68 75 86 La grenouille fouille les feuilles dans la broussaille.

J'ai aussi ajouté les dernières tentatives à l'article « Comparing pronunciation recordings across time. »

Je pense que c'est mieux que de lire mon journal à voix haute pendant le rendez-vous parce que les phrases me permettent de me concentrer sur les sons difficiles, et mon nouveau code m'aide à suivre ma progression au fil des sessions. Ça signifie que mon journal contient peut-être des erreurs, mais ce n'est pas un problème. Selon ce long fil sur les IA sur Hacker News, c'est mieux d'être humain malgré mes erreurs.

J'ai modifié « subed-waveform » et « subed-record » pour afficher les étendues audio que je coupe. Si j'ajoute une fonction pour me permettre de faire glisser le curseur sur la forme d'onde pour créer ou ajuster la directive de coupe, je pense que ce sera très pratique.

J'ai ajouté des raccourcis clavier à mon tableau d'enregistrements de virelangues en français. Maintenant, je peux naviguer vers l'enregistrement suivant ou vers l'enregistrement précédent dans la même phrase ou entre les phrases. Je peux aussi sauter entre les enregistrements de la même phrase avec les chiffres 1 à 9, ce qui facilite tellement la comparaison entre deux versions.

Mon mari a retrouvé un peu plus de son énergie, donc il a fabriqué une machine à espresso en jouet que ma fille réclamait depuis longtemps au lieu d'en acheter une sur eBay pour environ 90 dollars. Il a utilisé du carton et du bois pour la construire. La machine en jouet était merveilleuse. Ma fille était très heureuse.

dimanche 15 mars

J'ai écrit du code JavaScript pour jouer un enregistrement en boucle avec une pause de deux fois sa longueur dans mes notes sur les virelangues. Ça facilite la pratique sur mon téléphone. En plus de mes extraits du rendez-vous précédent avec mon tuteur, j'ai aussi ajouté des références audio qui sont générées par les synthèses vocales de Google Traduction, de Kokoro, et d'Azure. Je préfère celles de Google Traduction au début parce qu'elles sont plus lentes, mais je pense que je peux configurer les autres services pour parler à la même vitesse. Je les ai utilisées pour travailler sur ma prononciation. Ma prochaine étape est d'inclure les phonèmes pour aider à remarquer les différences entre les voyelles.

J'ai préparé des crêpes épaisses pour le petit-déjeuner. Ma fille m'a aidée avec certaines étapes de la préparation.

Nous avons travaillé sur la machine à espresso en carton. Ma fille et moi avons utilisé le petit ordinateur micro:bit pour faire fonctionner des boutons, jouer des sons, et afficher les nombres et les animations en utilisant MicroPython. J'ai commencé avec l'interface Web, mais Ampy est mieux pour téléverser le code sur le micro:bit parce que je peux tout faire sans clics.

Ma fille a choisi cinq boutons et elle a dessiné des animations pour chaque commande :

  • Moudre
  • Eau
  • Lait
  • Vapeur
  • Café

Je suis particulièrement fière que le bouton pour l'eau simule le chauffage de l'eau en affichant la progression sur un afficheur à quatre chiffres à sept segments ( bien sûr plus rapidement qu'en vrai ), suivi d'une animation. Le logiciel simule aussi le refroidissement de l'eau après un certain temps. Le micro:bit a un thermomètre, donc si ma fille le veut, nous pouvons changer le logiciel pour utiliser la vraie température ambiante.

J'ai découpé des ouvertures dans le tableau de bord en carton et j'ai utilisé de la colle chaude pour coller les éléments. Mon mari a utilisé deux aimants pour coller le tableau de bord au châssis de la machine. Ça marche ! Ma fille s'est très bien amusée en préparant du café pour nous.

Voici le code : https://github.com/wjyoung65/toy_espresso_machine

Je veux essayer d'ajouter un module MP3 et une petite enceinte pour jouer un son de meilleure qualité. Ma fille a enregistré quelques sons de la préparation du café comme le bruit de l'eau qui coule ou qui bouillonne.

Mon mari a dépoussiéré un vieux petit ordinateur Arduino avec lequel son autre fille et lui avaient commencé un projet il y a plusieurs années. Il a réussi à diffuser des sons dans les écouteurs. Si nous pouvons nous connecter au micro:bit, la machine à espresso en jouet peut diffuser les sons que ma fille a enregistrés. J'ai hâte de l'essayer.

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 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
(my-lang-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"))
 "clip"
 #'my-lang-subed-record-get-last-attempt
 #'my-lang-subed-record-cell-info
 t
 )
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
 (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.

The code

Code for summarizing the segments
(defun my-lang-subed-record-cell-info (item file-index file sub)
  (let* ((sound-file (expand-file-name (format "%s-%s-%d.opus"
                                               prefix
                                               (my-transform-html-slugify item)
                                               (1+ file-index))))
         (score (car (split-string
                      (or
                       (subed-record-get-directive "#+SCORE" (elt sub 4)) "")
                      ";")))
         (note (replace-regexp-in-string
                (concat "^" (regexp-quote (cdr file))
                        "\\(: \\)?")
                ""
                (or (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))
    (org-link-make-string
     (concat "audio:" sound-file "?icon=t"
             (format "&source=%s&source-start=%s" (car file) (elt sub 1))
             (format "&title=%s"
                     (url-hexify-string
                      (if (string= note "")
                          (cdr file)
                        (concat (cdr file) ": " note)))))
     (concat
      "▶️"
      (if score (format " %s" score) "")
      (if (string= note "") "" "*")))))

(defun my-lang-subed-record-get-last-attempt (item file)
  "Return the last subtitle matching ITEM in FILE."
  (car
   (last
    (seq-remove
     (lambda (o) (string-match "#\\+SKIP" (or (elt o 4) "")))
     (learn-lang-subed-record-collect-matching-subtitles
      item
      (list file)
      nil
      nil
      'my-subed-simplify)))))

(defun my-lang-summarize-segments (items files prefix attempt-fn cell-fn &optional always-create)
  (cons
   (append
    (seq-map 'cdr files)
    (list "Text"))
   (seq-map
    (lambda (item)
      (append
       (seq-map-indexed
        (lambda (file file-index)
          (let* ((sub (funcall attempt-fn item file)))
            (if sub
                (funcall cell-fn item file-index file sub)
              "")))
        files)
       (list item)))
    items)))

(defun my-subed-record-analyze-file-with-azure (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)))

Some code for doing this stuff is in sachac/learn-lang on Codeberg.

View Org source for this post

Small steps towards using OpenAI-compatible text-to-speech services with speechd-el or emacspeak

| emacs

Speech synthesis has come a long way since I first tried out Emacspeak in 2002. Kokoro TTS and Piper offer more natural-sounding voices now, although the initial delay in loading the models and generating speech mean that they aren't quite ready to completely replace espeak, which is faster but more robotic. I've been using the Kokoro FastAPI through my own functions for working with various speech systems. I wanted to see if I could get Kokoro and other OpenAI-compatible text-to-speech services to work with either speechd-el or Emacspeak just in case I could take advantage of the rich functionality either provides for speech-synthesized Emacs use. speechd-el is easier to layer on top of an existing Emacs if you only want occasional speech, while emacspeak voice-enables many packages to an extent beyond speaking simply what's on the screen.

Speech synthesis is particularly helpful when I'm learning French because I can use it as a reference for what a paragraph or sentence should sound like. It's not perfect. Sometimes it uses liaisons that my tutor and Google Translate don't use. But it's a decent enough starting point. I also used it before to read out IRC mentions and compile notifications so that I could hear them even if I was paying attention to a different activity.

Here's a demonstration of speechd reading out the following lines using the code I've just uploaded to https://codeberg.org/sachac/speechd-ai:

  • The quick brown fox jumps over the lazy dog.
  • Now let's set the language to French so we can read the next line.
  • Bonjour, je m'appelle Emacs.

Screencast showing speechd-el

There's about a 2-second delay between the command and the start of the audio for the sentence.

Note that speechd-speak-read-sentence fails in some cases where (forward-sentence 1) isn't the same place as (backward-sentence 1) (forward-sentence 1), which can happen when you're in an Org Mode list. I've submitted a patch upstream.

Aside from that, speechd-speak-set-language, speechd-speak-read-paragraph and speechd-speak-read-region are also useful commands. I think the latency makes this best-suited for reading paragraphs, or for shadowing sentences for language learning.

I'm still trying to figure out how to get speechd-speak to work as smoothly as I'd like. I think I've got it set up so that the server falls back to espeak for short texts so that it can handle words or characters better, and uses the specified server for longer ones. I'd like to get to the point where it can handle all the things that speechd usually does, like saying lines as I navigate through them or giving me feedback as I'm typing. Maybe it can use espeak for fast feedback character by character and word by word, and then use Kokoro TTS for the full sentence when I finish. Then it will be possible to use it to type things without looking at the screen.

After putting this together, I still find myself leaning towards my own functions because they make it easy to see the generated speech output to a file, which is handy for saving reference audio that I can play on my phone and for making replays almost instant. That could also be useful for pre-generating the next paragraph to make it flow more smoothly. Still, it was interesting making something that is compatible with existing protocols and libraries.

Posting it in case anyone else wants to use it as a starting point. The repository also contains the starting point for an Emacspeak-compatible speech server. See See speechd-ai/README.org for more details.

https://codeberg.org/sachac/speechd-ai

View Org source for this post