Queuing multiple transcriptions with whisper.el speech recognition

| audio, speech, emacs

I want to be able to talk out loud and have the ideas go into Emacs. I can do this in a number of different ways:

  1. I briefly demonstrated a step-by-step approach with natrys/whisper.el with a single file. I press a keyboard shortcut to start the recording, another shortcut to stop the recording, and it transcribes it in the background. But the way whisper.el is set up is that if I press the keyboard shortcut to start recording again it will offer to interrupt the transcription process, which is not what I want. I want to just keep talking and have it process results as things come in.
  2. I'm also experimenting with Google Chrome's web speech API to do continuous speech recognition, which I can get into Emacs using a web socket.
  3. What I've just figured out is how to layer a semi-continuous interface for speech recognition on top of whisper.el so that while it's processing in the background, I can just press a keyboard shortcut (I'm using numpad 9 to call my-whisper-continue) to stop the previous recording, queue it for processing, and start the next recording. If I use this keyboard shortcut to separate my thoughts, then Whisper has a much easier time making sense of the whole sentence or paragraph or whatever, instead of trying to use the sliding 30 second context window that many streaming approaches to speech recognition try to use.

Question: Did you fix the keyboard delay you've got while speech catches what you're saying?

Sometimes, when the speed recognition kicks in, my computer gets busy. When my computer gets really busy, it doesn't process my keystrokes in the right order, which is very annoying because then I have to delete the previous word and retype it. I haven't sorted that out yet, but it seems like I probably have to lower the priority on different processes. On the plus side, as I mentioned, if I dictate things instead of typing them, then I don't run into that problem at all.

Also, other notes on delays: The continuous speech recognition via Google Chrome shows up fairly quickly, but it's not very precise, and it doesn't have punctuation. Even if there's a little bit of a delay, as long as I press the my-whisper-continue shortcut after each thought, then I can get that text into my Emacs buffer using the nicer transcription from my selected model. There is going to be a bit of a delay for that one because it gets processed at the end of the thought. Also, I need to start thinking in complete sentences instead of just adding one cause after the other as my brain goes on all of these tangents. I think it's pretty promising. There's the continuous speech recognition via Google Chrome if I don't mind the lower accuracy and lack of punctuation, and I can still get the pretty version on the other side.

Why talk out loud? I liked the Bookclub Tapas presentation that Maddie Sullivan did at EmacsConf 2025. Talking out loud helps me be a lot more verbose about what I'm saying, compared to typing things out or even like having to switch to my notes or interrupting my screen with an Org capture buffer. Of course I want to clean that up for putting into a blog post, but given that my life still sometimes has random interruptions from a kiddo who must have my attention at that very minute, having that kind of record that I can at least try to reread afterwards to reconstruct what I was thinking about sounds like it might be helpful.

Still, making sense out loud is hard. I'm not actually used to talking to people that much now. This is probably a good reason for me to experiment with streaming more. Then I get the practice in talking out loud, there are backup recordings, and people can ask questions when things are unclear.

Of course, sometimes the text doesn't quite make sense because of the speech recognition errors. I can usually figure it out from the context. I save the audio as well so that I can go back and listen to it again if I really need to.

Anyway, here's the code for sending the current recording to whisper in the background and starting another recording. It assumes a lot about how things are set up. For example, I'm only testing this with a local speaches server instead of whisper.cpp. You might need to look at my other speech related configuration blog posts and sections in order to make sense of it.

Code for queuing whisper.el requests to a local server
(defvar my-whisper--queue nil)
(defun my-whisper-continue (&optional arg)
  "Send what we've got so far for transcription and then continue recording.
Call with \\[universal-argument] to signal that we can stop."
  (interactive "P")
  (setq whisper--marker (point-marker) whisper--point-buffer (current-buffer))
  (when (process-live-p whisper--recording-process)
    (add-to-list
     'my-whisper--queue
     (list :file whisper--temp-file
           :buffer
           (format "*result: %s*" (file-name-base whisper--temp-file)))
     t)
    ;; Remove the sentinel; handle results ourselves
    (set-process-sentinel whisper--recording-process
                          (lambda (process event)
                            (my-whisper-process-queue)))
    (interrupt-process whisper--recording-process))
  (unless arg
    (run-hooks 'whisper-before-transcription-hook)
    (whisper--record-audio)))

(defun my-whisper-discard ()
 "Ignore the previous recording."
  (interactive)
  (when (process-live-p whisper--recording-process)
    ;; Remove the sentinel; handle results ourselves
    (set-process-sentinel whisper--recording-process
                          (lambda (process event)
                            (when (file-exists-p whisper--temp-file)
                              (delete-file whisper--temp-file))
                            (my-whisper-process-queue)))
    (interrupt-process whisper--recording-process)))

(defun my-whisper-discard-and-continue ()
 "Ignore the previous recording and continue."
  (interactive)
  (if (process-live-p whisper--recording-process)
      (progn
        ;; Remove the sentinel; handle results ourselves
        (set-process-sentinel whisper--recording-process
                              (lambda (process event)
                                (my-whisper-process-queue)
                                (my-whisper-continue)))
        (interrupt-process whisper--recording-process))
    (my-whisper-continue)))

(defun my-whisper-done ()
  (interactive)
  (when (process-live-p whisper--recording-process)
    (add-to-list
     'my-whisper--queue
     (list :file whisper--temp-file
           :buffer
           (format "*result: %s*" (file-name-base whisper--temp-file)))
     t)
    ;; Remove the sentinel; handle results ourselves
    (set-process-sentinel whisper--recording-process
                          (lambda (process event)
                            (my-whisper-process-queue)))
    (interrupt-process whisper--recording-process)))

(defun my-whisper-process-queue-result ()
  "Process the first part of the queue that already has results."
  (while (plist-get (car my-whisper--queue) :results)
    (let ((o (pop my-whisper--queue)))
      (unless my-whisper-target-markers
        (setq whisper--marker (point-marker)
              whisper--point-buffer (current-buffer)))
      (with-current-buffer (plist-get o :buffer)
        (erase-buffer)
        (insert (plist-get o :results))
        (goto-char (point-min))
        (run-hook-wrapped
         'whisper-after-transcription-hook
         (lambda (f)
           (with-current-buffer (get-buffer (plist-get o :buffer))
             (save-excursion
               (funcall f)))
           nil))))))

(defun my-whisper-process-queue ()
  (let (o)
    (while (setq o (seq-find (lambda (o) (and (plist-get o :file)
                                              (not (plist-get o :process))
                                              (not (plist-get o :results))))
                             my-whisper--queue))
      (let* ((headers (list "Content-Type: multipart/form-data"))
             (params (list (concat "file=@"
                                   (plist-get o :file))
                           "temperature=0.0"
                           "temperature_inc=0.2"
                           "response_format=json"
                           (concat "model=" whisper-model)
                           (concat "language=" whisper-language)))
             (url (format my-whisper-url-format whisper-server-host whisper-server-port))
             (command `("curl" "-s"
                        ,url
                        ,@(mapcan (lambda (h) (list "-H" h)) headers)
                        ,@(mapcan (lambda (p) (list "-F" p)) params))))
        (with-current-buffer (get-buffer-create (plist-get o :buffer))
          (erase-buffer))
        (plist-put
         o :process
         (make-process
          :name "whisper-curl"
          :command command
          :buffer (plist-get o :buffer)
          :coding 'utf-8
          :sentinel
          (lambda (process event)
            (with-current-buffer (process-buffer process)
              (let ((current my-whisper--queue-item))
                (when (and (get-buffer (plist-get current :buffer))
                           (string-equal "finished\n" event))
                  (with-current-buffer (plist-get current :buffer)
                    (goto-char (point-min))
                    (plist-put current :results
                               (or
                                (condition-case nil
                                    (gethash "text" (json-parse-buffer))
                                  (error ""))
                                "(error)"))))))
            (my-whisper-process-queue-result))))
        (plist-put o :command (string-join command " "))
        (with-current-buffer (process-buffer (plist-get o :process))
          (setq-local my-whisper--queue-item o))))))
(defvar-local my-whisper--queue-item nil)

(defun my-whisper-reprocess-queue ()
  (interactive)
  (setq whisper--marker (point-marker) whisper--point-buffer (current-buffer))
  (mapc (lambda (o)
          (when (process-live-p (plist-get o :process))
            (kill-process (plist-get o :process)))
          (when (get-buffer (plist-get o :buffer))
            (kill-buffer (plist-get o :buffer)))
          (plist-put o :process nil)
          (plist-put o :results nil))
        my-whisper--queue)
  (my-whisper-process-queue))

(defun my-whisper-clear-queue ()
  (interactive)
  (mapc (lambda (o)
          (when (process-live-p (plist-get o :process))
            (kill-process (plist-get o :process)))
          (when (get-buffer (plist-get o :buffer))
            (kill-buffer (plist-get o :buffer)))
          (plist-put o :process nil)
          (plist-put o :results nil))
        my-whisper--queue)
  (setq my-whisper--queue nil))

(keymap-global-set "<kp-9>" #'my-whisper-continue)
(keymap-global-set "<kp-8>" #'my-whisper-discard-and-continue)
(keymap-global-set "C-<kp-9>" #'my-whisper-done)
This is part of my Emacs configuration.
View org source for this post

Emacs and whisper.el :Trying out different speech-to-text backends and models

| audio, emacs

I was curious about parakeet because I heard that it was faster than Whisper on the HuggingFace leaderboard. When I installed it and got it running on my laptop (CPU only, no GPU), it seemed like my results were a little faster than whisper.cpp with the large model, but much slower than whisper.cpp with the base model. The base model is decent for quick dictation, so I got curious about other backends and other models.

In order to try natrys/whisper.el with other backends, I needed to work around how whisper.el validates the model names and sends requests to the servers. Here's the quick and dirty code for doing so, in case you want to try it out for yourself.

(defvar my-whisper-url-format "http://%s:%d/transcribe")
(defun my-whisper--transcribe-via-local-server ()
  "Transcribe audio using the local whisper server."
  (message "[-] Transcribing via local server")
  (whisper--setup-mode-line :show 'transcribing)
  (whisper--ensure-server)
  (setq whisper--transcribing-process
        (whisper--process-curl-request
         (format my-whisper-url-format whisper-server-host whisper-server-port)
         (list "Content-Type: multipart/form-data")
         (list (concat "file=@" whisper--temp-file)
               "temperature=0.0"
               "temperature_inc=0.2"
               "response_format=json"
               (concat "model=" whisper-model)
               (concat "language=" whisper-language)))))
(defun my-whisper--check-model-consistency () t)

(with-eval-after-load 'whisper
  (advice-add 'whisper--transcribe-via-local-server :override #'my-whisper--transcribe-via-local-server)
  (advice-add 'whisper--check-model-consistency :override #'my-whisper--check-model-consistency))

Then I have this function for trying things out.

(defun my-test-whisper-api (url &optional args)
  (with-temp-buffer
    (apply #'call-process "curl" nil t nil "-s"
           url
         (append (mapcan
                  (lambda (h) (list "-H" h))
                  (list "Content-Type: multipart/form-data"))
                 (mapcan
                  (lambda (h) (list "-F" h))
                  (list (concat "file=@" whisper--temp-file)
                        "temperature=0.0"
                        "temperature_inc=0.2"
                        "response_format=verbose_json"
                        (concat "language=" whisper-language)))
                 args))
    (message "%s %s" (buffer-string) url)))

Here's the audio file. It is around 10 seconds long. I run the benchmark 3 times and report the average time.

Download

Code for running the benchmarks
(mapcar
 (lambda (group)
   (let ((whisper--temp-file "/home/sacha/recordings/whisper/2026-01-19-14-17-53.wav"))
     ;; warm up the model
     (eval (cadr group))
     (list
      (format "%.3f"
              (/ (car
                  (benchmark-call (lambda () (eval (cadr group))) times))
                 times))
      (car group))))
 '(
   ("parakeet"
    (my-test-whisper-api
     (format "http://%s:%d/v1/audio/transcriptions" whisper-server-host 5092)))
   ("whisper.cpp base-q4_0"
    (my-test-whisper-api
     (format "http://%s:%d/inference" whisper-server-host 8642)))
   ("speaches whisper-base"
    (my-test-whisper-api
     (format "http://%s:%d/v1/audio/transcriptions" whisper-server-host 8001)
     (list "-F" "model=Systran/faster-whisper-base")))
   ("speaches whisper-base.en"
    (my-test-whisper-api
     (format "http://%s:%d/v1/audio/transcriptions" whisper-server-host 8001)
     (list "-F" "model=Systran/faster-whisper-base.en")))
   ("speaches whisper-small"
    (my-test-whisper-api
     (format "http://%s:%d/v1/audio/transcriptions" whisper-server-host 8001)
     (list "-F" "model=Systran/faster-whisper-small")))
   ("speaches whisper-small.en"
    (my-test-whisper-api
     (format "http://%s:%d/v1/audio/transcriptions" whisper-server-host 8001)
     (list "-F" "model=Systran/faster-whisper-small.en")))
   ("speaches lorneluo/whisper-small-ct2-int8"
    (my-test-whisper-api
     (format "http://%s:%d/v1/audio/transcriptions" whisper-server-host 8001)
     (list "-F" "model=lorneluo/whisper-small-ct2-int8")))
   ;; needed export TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD=1
   ("whisperx-server Systran/faster-whisper-small"
    (my-test-whisper-api
     (format "http://%s:%d/transcribe" whisper-server-host 8002)))))

I tried it with:

Looks like speaches + faster-whisper-base is the winner for now. I like how speaches lets me switch models on the fly, so maybe I can use base.en generally and switch to base when I want to try dictating in French. Here's how I've set it up to use the server I just set up.

(setq whisper-server-port 8001 whisper-model "Systran/faster-whisper-base.en"
      my-whisper-url-format "http://%s:%d/v1/audio/transcriptions")

At some point, I'll override whisper--ensure-server so that starting it up is smoother.

Benchmark notes: I have a Lenovo P52 laptop (released 2018) with an Intel Core i7-8850H (6 cores, 12 threads; 2.6 GHz base / 4.3 GHz turbo) with 64GB RAM and an SSD. I haven't figured out how to get the GPU working under Ubuntu yet.

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

2026-01-19 Emacs news

| emacs, emacs-news

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 12 janvier au 18 janvier

| french

Lundi, le douze janvier

Une réflexion sur les points de choix que ma thérapeute a discutés avec moi :

Je pense que l'idée du point de choix concerne le moment où on a un objectif, mais on se rend souvent compte qu'on agit à contre-courant à cause des réactions. Je rencontre parfois ce problème dans ma vie, quand je veux soutenir ma fille à l'autodétermination mais je m'inquiète aussi pour ses devoirs, ses notes, ses interactions avec son enseignant et ainsi de suite.

Ma thérapeute m'a demandé de réfléchir aux situations qui sont difficiles et ce qui m'inquiète.

Je pense que la situation est difficile quand :

  • … ma fille dit qu'elle déteste l'école et elle me demande pourquoi elle doit y aller, par exemple à l'heure du coucher dimanche
  • … ma fille ne travaille pas sur ses devoirs et elle accumule alors un gros tas de tâches. Par exemple, c'est la semaine avant une journée pédagogique que l'enseignant va probablement utiliser pour écrire les bulletins.
  • … ma fille veut passer du temps avec moi mais je suis fatiguée ou surstimulée, ou je veux me concentrer sur d'autres choses, par exemple après quelques heures ensemble lundi. ( En vrai, c'est un problème de riche : ma fille aime bien passer du temps avec moi. )
  • … ma fille est grincheuse parce qu'elle veut jouer avec moi mais je suis occupée, par exemple quand je dois travailler sur l'infolettre de Bike Brigade samedi.
  • … ma fille est fâchée parce que quelqu'un fait une erreur, et elle a du mal à se débrouiller.

Je me demande de temps en temps ce qu'un parent responsable fait. Qu'est-ce qui m'inquiète? Par exemple, sur ses devoirs :

  • Si elle ne fait pas ses devoirs, son enseignant doit lui donner des notes basses. (Mais je ne sais pas, ses camarades ne paraissent pas faire leurs devoirs non plus selon les communiqués fréquents.)
  • … Puis peut-être qu'elle décide que l'école ne vaut pas le coup.
  • … Puis peut-être qu'elle a du mal à s'adapter au système d'éducation ou d'emploi.
    • … Puis peut-être qu'elle se sent en échec, perd la motivation, et elle se laisse porter. (Mais ce n'est pas sous mon contrôle.)
    • … Puis peut-être qu'elle doit trouver sa propre voie dans l'entrepreneuriat. (Mais ce n'est pas grave…)

Ma thérapeute va dire que oui, mais qu'est-ce que ça signifie pour vous ? Hmm… Je veux l'aider du mieux que je peux, sans lui transmettre mes difficultés et défauts, ce qui est amusant parce que l'une de mes difficultés est l'anxiété. Elle n'est pas anxieuse pour ses études… Succès ?

Peut-être que je ne pose pas la bonne question quand je me demande ce qu'un parent responsable fait. Peut-être que je me focalise trop sur les objectifs à court terme. La conformité est un objectif clair et facile à mesurer, mais suis-je sûr que c'est bon pour elle à long terme ? Je veux l'aider à apprendre à trouver ses propres façons de faire les choses qui sont nécessaires, mais je veux aussi l'aider à apprendre à juger ce qui est nécessaire.

Élever des enfants, c'est vraiment apprendre à lâcher prise en permanence.

-— Lors de ma séance avec ma tutrice, nous avons juste pu réviser mes entrées de la dernière semaine à l'exception de la réflexion ci-dessus. Pour une raison quelconque, mes titres n'ont pas été copiés. Je vais corriger des bogues dans mon logiciel pour les exporter plus tard.

Après l'école, ma fille a dit que ses camarades étaient trop bruyants. Heureusement, dans l'école virtuelle, elle peut réduire le volume jusqu'à ce que ce soit tolérable. Parfois j'aimerais que la vraie vie soit comme ça.

J'ai emmené ma fille à son cours de gymnastique. Pendant qu'elle s'entraînait à faire la roue, j'ai réfléchi encore sur mes points de choix.

Ma fille a encore fait la factrice. Elle a distribué des ingrédients pour le souper et d'autres choses partout dans la maison.

À l'heure du coucher, elle est devenue très fâchée parce que nous avions oublié de nettoyer ses oreilles pendant la pause déjeuner. Elle s'est assise contre la porte et m'a crié : « Dégage ! » Ben, je ne peux pas résoudre tous les problèmes. La prochaine fois, je mettrai un minuteur pour nous en souvenir. J'ai offert un câlin au cas où elle en voudrait un. J'ai passé du temps avec mon mari à essayer d'échanger des Pokémon, mais ça n'a pas marché.

Mardi, le treize janvier

Malgré mon petit glaçon qui s'est blotti contre moi toute la nuit comme je l'avais prédit, nous nous sommes levées à une heure raisonnable et nous avons fait notre routine matinale.

Pendant que ma fille participait à l'école virtuelle, j'ai continué à essayer la reconnaissance vocale en continu. J'ai testé SimulStreaming avec un petit modèle et un moyen modèle, mais les résultats étaient pires que ceux de Web Speech API sous Google Chrome. Je suis repassé sur Chrome et j'ai redirigé les résultats vers le salon IRC et vers Etherpad. J'ai testé le routage audio pour la transcription de flux multiples en même temps. C'est prometteur, mais je ne sais pas si mon ordinateur pourra gérer quatre ou cinq réunions en ligne pendant EmacsConf. Je peux le tester en l'essayant avec les vidéos de la dernière conférence.

Je veux présenter le flux de travail pour insérer les résultats de reconnaissance vocale à la position actuelle ou à la tâche active dans Org Mode à la réunion OrgMeetup en ligne demain après-midi. Si nous avons du temps, je peux aussi présenter le flux de travail de la reconnaissance vocale en continu, ce qui est étonnamment utile pour me souvenir de ce que je viens de penser.

Après le déjeuner, j'ai utilisé la reconnaissance vocale (en mode intermittent ou continu) pour préparer ma séance virtuelle avec ma thérapeute. J'ai réfléchi sur des points de choix dans des situations difficiles, mes sentiments, mes pensées, et des actions que je veux faire la prochaine fois. Nous en avons discuté pendant ma séance. Ma thérapeute m'a aussi parlé des distorsions cognitives et du perfectionnisme.

Pour le souper, nous avons préparé des nouilles udon. Après ça, ma fille a regardé Cleopatra in Space, qui est différente des livres mais est toujours amusante.

Mercredi, le quatorze janvier

J'ai présenté mon travail sur l'intégration de la reconnaissance vocale et Org Mode à la réunion OrgMeetup en ligne. J'ai aussi présenté mon expérience de la reconnaissance vocale en continu sous Google Chrome. Un participant m'a demandé si Google Chrome ou Chromium peut faire la reconnaissance vocale en continu en local au lieu de sur le cloud. Il paraît que c'est possible après avoir téléchargé les modèles de langue. L'après-midi, j'ai configuré un raccourci clavier global pour dicter dans d'autres applications via le flux de travail de la reconnaissance vocale sous Emacs.

J'ai aussi travaillé sur l'outil de visualisation et la gestion du routage audio. J'ai créé une fonction pour changer les entrées d'un nœud. J'ai publié un article avec quelques captures d'écran sur mon blog.

Après l'école, ma fille et moi avons fait des muffins à la courgette et au chocolat. Elle a assemblé une collation et me l'a vendue avec quatre muffins. Ça coûte dix tope-làs au total.

Nous avons rejoint l'amie de ma fille et sa troupe de scouts à dix-neuf heures à la patinoire. Il y avait beaucoup de neige, donc les enfants ont aimé déneiger avec des guides de patinage faisant office de Zamboni. Ma fille a eu un petit conflit avec un autre enfant à propos du jeu du marchand et ils ont lancé de la neige, donc on surveillait ça de près.

Ma fille n'avait pas très faim aujourd'hui. Eh, ce n'était pas grave, elle va probablement manger davantage demain.

Jeudi, le quinze janvier

Grâce à l'énorme quantité de neige, l'école a été annulée. Elle était ravie ! Nous avons déneigé les chemins, le trottoir et la terrasse en bois pendant plus d'une heure. Nous avons empilé la neige sur la luge pour traîner jusqu'à la cour arrière, où nous l'avons ajoutée à un énorme tas. Il continuait de neiger. Le voisin dit qu'il va utiliser la mini souffleuse à neige plus tard, et la déneigeuse de la ville passera probablement aussi.

J'ai modifié mon outil d'enregistrement des audios à partir des sous-titres pour générer de la synthèse vocale du texte puis commencer à enregistrer, et assembler les versions finales automatiquement en un seul fichier audio sans faux départs. Ça me plaît. Pour ma prochaine étape, je veux combiner la reconnaissance vocale en continu avec ça alors qu'Emacs dira la phrase, enregistrera mon essai et avancera automatiquement si je prononce de manière assez compréhensible ou répétera si je le prononce trop mal. Idéalement, il surlignera les mots que je prononce mal, mais je ne sais pas encore comment m'y prendre. Je l'ai utilisé pour enregistrer les entrées de la semaine dernière en français.

Un jour, je pourrai les publier sur mon blog avec les textes. Je suis un peu intimidée, mais essayer davantage, c'est apprendre davantage.

Nous avons joué à Pokémon. Ma fille et moi avons découvert une bonne façon de jouer en mode cocooning et très complice : elle s'est blottie contre moi sur le canapé et elle a manipulé la moitié droite de la manette tandis que j'ai manipulé la moitié gauche. Comme ça, nous avons transformé le jeu solo en jeu coopératif, que ma fille aime mieux, et nous étions plus au chaud.

J'ai regardé deux épisodes de Bluey qui sont doublés en français. C'était toujours trop rapide pour comprendre la majorité, mais quelquefois j'ai compris des mots. Je n'ai pas pu configurer correctement ma télévision pour l'affichage du sous-titrage officiel en français, mais j'ai trouvé les sous-titres pour l'épisode Le Vélo en ligne grâce à un fan, ce qui m'a aidée à le comprendre.

Ma fille a encore joué à la factrice. Elle m'a distribué de l'eau chaude, des chaussettes pelucheuses pour moi et des gouttes pour ses yeux. Elle a aussi apporté tous les livres qu'elle avait lus hier soir pour les remplacer par de nouveaux livres.

Vendredi, le seize janvier

Ce matin, j'ai essayé d'aider ma fille avec son devoir d'art. Elle doit créer une histoire sur la fête. Elle ne voulait pas dessiner à la main, alors j'ai suggéré d'utiliser Minecraft pour narrer l'histoire pour que nous puissions prendre des captures d'écran. Malheureusement, Minecraft Bedrock ne marchait pas. Elle est devenue frustrée et ne voulait pas continuer.

J'ai utilisé la reconnaissance vocale en continu pour contrôler l'enregistrement de l'audio. Si Emacs entend quelque chose qui ressemble au sous-titre actuel, il mettra à jour ses horodatages, utilisera la synthèse vocale pour dire le prochain sous-titre et attendra que je le dise. S'il entend quelque chose qui ressemble plus au sous-titre précédent, il retournera le sous-titre précédent et mettra à jour ses horodatages. S'il entend quelque chose qui ressemble plus au prochain sous-titre, il avancera le prochain sous-titre, mettra à jour ses horodatages, avancera encore à celui d'après et le dira. Je pense que je dois modifier le seuil de correspondance approximative parce que la reconnaissance vocale en continu n'est pas très précise. J'ai essayé les résultats intermédiaires de reconnaissance vocale pour l'horodatage initial, mais si l'énoncé était très court, l'horodatage initial est peut-être manquant ou inexact. Il faudra peut-être que j'analyse les silences dans le fichier audio pour déterminer un horodatage initial raisonnable.

Après que ma fille s'est remise de sa frustration, nous avons fait de la luge. C'était amusant, mais il y avait beaucoup de monde, donc nous ne sommes pas restées longtemps. Quand nous sommes rentrées à la maison, elle voulait toujours jouer dehors, donc j'ai déneigé et elle a utilisé son grand bâton pour piler la glace sur le chemin.

Pendant que mon mari et ma fille faisaient les courses pour des bombes de chocolat chaud et d'autres choses, j'ai travaillé comme consultante. En résolvant une tâche que j'avais mise de côté depuis deux mois parce qu'elle n'était pas urgente, j'ai découvert une requête urgente que je dois traiter cette semaine pour un événement qui aura lieu la semaine prochaine. Je ne consulte pas souvent mes courriels professionnels, donc j'ai failli la rater. Ils ont besoin du plan de salle pour des événements. Heureusement, j'ai pu l'accomplir rapidement.

Après avoir fini, j'ai joué avec ma fille. Mon mari a trouvé comment échanger des Pokémon en changeant de cœur d'émulateur. Nous lui avons donné un Abra en échange d'un Rondoudou, et nous avons pu attraper un autre Abra un peu plus tard. Malheureusement, l'Abra que nous lui avons donné n'obéit pas à mon mari. Il se trouve qu'il a besoin d'un Badge d'Arène de plus. Bon, nous sommes allées à Carmin sur Mer. Nous avons obtenu un vélo, exploré le bateau L'Océane, et réussi à battre l'arène. Maintenant nous pouvons utiliser la Coupe pour couper les arbustes et accéder à de nouveaux endroits. Elle n'aime pas s'entraîner, mais ça ne me dérange pas, donc je suis heureuse de faire progresser ses Pokémon.

Samedi, le dix-sept janvier

Pour le petit-déjeuner, j'ai préparé des crêpes. Ma fille a mangé deux crêpes avec du yaourt à la mangue et une crêpe avec de la tartinade aux noisettes.

J'ai utilisé mes fonctions pour transformer, vérifier, et planifier la campagne Mailchimp, et J'ai écrit un article sur ma gestion de la campagne Mailchimp sous Emacs.

J'ai essayé le jeu Pokémon Rouge sur mon mobile. C'était assez facile à comprendre. J'ai été très étonnée quand le jeu m'a demandé comment je m'appelle, parce que Sacha est une des options. Je me suis demandé si le jeu avait analysé mon mobile ou si le jeu est modifié pour utiliser Google Play. Il se trouve que dans la série télévisée Pokémon en français, le protagoniste s'appelle Sacha, qui correspond à Ash dans la série en anglais. Quelle surprise !

Ma fille n'a pas voulu aller à son club nature au parc. Nous avons toujours voulu sortir. Elle était curieuse du jeu Pokémon Go, donc nous l'avons installé et j'ai connecté son mobile à mon point Wi-Fi sur mon mobile. Nous avons attrapé beaucoup de Pokémon en allant faire les courses. Mon mari a aussi apporté une pelle, et nous avons déneigé des trottoirs et des coins des rues dans notre voisinage en alternance pendant que l'autre et ma fille jouaient à Pokémon Go. Ça faisait du bien de faire quelque chose pour aider notre voisinage. (J'ai lu que d'autres gens ont déneigé des pistes cyclables que les déneigeuses ont mal déneigées. Bravo !) C'était des bonnes raisons pour sortir, même s'il y avait de la neige.

Ma fille avait beaucoup d'enthousiasme et elle s'est amusée avec la réalité augmentée. Elle a proposé de faire une promenade après le souper pour attraper encore des Pokémon.

Pour le souper, mon mari et notre fille ont préparé du riz gluant au poulet et aux champignons. Pendant qu'ils cuisinaient, j'ai farmé les Pokémon. Malheureusement, j'ai fait oublier Berceuse à Rondoudou parce que j'avais appuyé sur les boutons trop rapidement sans faire attention quand le jeu m'a demandé si je voulais qu'il apprenne Repos. J'ai restauré la sauvegarde et refait une partie du jeu.

J'ai fait la vaisselle rapidement pour faire une promenade après le souper. Malheureusement, nous avons laissé les mitaines de ma fille au supermarché. Nous allons les chercher demain, ou si nous ne pouvons pas les trouver, nous allons en racheter d'autres aux Stockyards. Malgré le froid, nous avons réussi à explorer un peu plus.

Dimanche, le dix-huit janvier

Tôt le matin, nous sommes allés au supermarché pour chercher les mitaines de notre fille que nous avons accidentellement laissées là hier. Malheureusement, nous n'avons pas pu les trouver. Ce n'était pas grave. Nous avons fait du vélo au Dufferin Mall pour les racheter chez Carters. Bien que les pistes cyclables étaient trop fondues et avaient du verglas, faire du vélo était un bon choix parce que le métro était fermé, donc les navettes étaient très bondées. Heureusement, le magasin avait des mitaines de même style et de même taille, et elles étaient même en solde. Elle a aussi voulu acheter un démêlant pour enfants chez Sephora, et elle a reçu un cadeau gratuit d'anniversaire. Elle a choisi l'option la plus simple.

Nous sommes rentrés et j'ai préparé nos déjeuners au cas où nous aurions faim après le cours de patinage ou l'événement de Pokémon Go auquel nous voulions participer. J'ai emmené ma fille à la patinoire juste à l'heure pour son cours de patinage. On a découvert que son autre amie s'était aussi inscrite au même cours. Elle avait dû attendre parce que sa mère avait pris le mauvais casque, donc je l'ai accompagnée en attendant pendant que sa mère s'était dépêchée jusqu'à ce qu'elle puisse participer au cours avec son propre casque.

Après le cours et un peu de patinage, nous l'avons invitée à la maison pour déjeuner, jouer, et boire du chocolat chaud au lieu de participer à l'événement de Pokémon Go. Elles ne jouaient pas ensemble depuis longtemps. Elles ont joué au petit café et aux marchands, et j'étais la cliente. Pendant qu'elles jouaient aux LEGO, j'ai continué mon propre jeu de Pokémon en français. Après que le père de son amie est venu la chercher, ma fille a voulu sortir pour attraper des Pokémons et essayer de suivre toute une route, donc nous avons fait une promenade. Puis, nous avons combattu. Elle avait attrapé une Lippoutou qui est plus forte que mes Pokémon. Elle a toujours gagné, ce qui lui faisait plaisir.

Après qu'elle a pris une douche avec son nouveau démêlant, je lui ai brossé et tressé les cheveux pendant qu'elle jouait Pokémon Jaune à la télévision. Je pense que nos Pokémon étaient un peu de bas niveau, mais elle a réussi à traverser le tunnel rocheux et elle est arrivée au Centre Pokémon de l'autre côté. Elle a gagné en confiance quand elle a combattu des dresseurs.

À cause de la journée chargée et de nos occupations, nous avons oublié de faire la lessive. Bon, je vais la faire demain.

Notes

  • Prononciation :
    • … et elle a du mal à se débrouiller. (day broo yay)
    • Peut-être que je ne pose pas la bonne question (kes tion) quand je me demande ce qu'un parent responsable fait.
    • … ce qui est étonnamment utile pour me souvenir de ce que je viens de penser. (pehn say)
    • J'ai aussi travaillé sur l'outil (loo teel) de visualisation et la gestion du routage audio.
    • J'ai publié un article (ar tee kleuh) avec quelques captures d'écran sur mon blog.
    • Elle m'a distribué de l'eau chaude, des chaussettes pelucheuses pour moi et des gouttes pour ses yeux. (says yeuh)
    • … si l'énoncé était très court, l'horodatage initial est peut-être manquant (man kahn) ou inexact (ein ex act)
    • Il faudra peut-être que j'analyse les silences (see lehns) dans le fichier audio pour déterminer un horodatage initial raisonnable.
    • Je ne consulte pas souvent mes courriels professionnels, donc j'ai failli (faie yee) la rater.
    • Ils ont besoin du plan de salle (sal) pour des événements.
    • … nous avons déneigé des trottoirs (trawh twoirs) et des coins des rues dans notre voisinage
  • Grammaire et expressions :
    • … le rendez-vous de Pokémon Go auquel nous voulions participer - because participer takes à, also voulions is in subjonctif
    • vers for directing streams towards
    • facile à infinitif - easy to …
    • sans infinitif - without
    • en permanence - all the time
View org source for this post

Emacs: Updating a Mailchimp campaign using a template, sending test e-mails, and scheduling it

| emacs

I'm helping other volunteers get on board with doing the Bike Brigade newsletter. Since not everyone has access to (or the patience for) MailChimp, we've been using Google Docs to draft the newsletter and share it with other people behind the scenes. I've previously written about getting a Google Docs draft ready for Mailchimp via Emacs and Org Mode, which built on my code for transforming HTML clipboard contents to smooth out Mailchimp annoyances: dates, images, comments, colours. Now I've figured out how to update, test, and schedule the MailChimp campaign directly from Emacs so that I don't even have to go into the MailChimp web interface at all. I added those functions to sachac/mailchimp-el.

I used to manually download a ZIP of the Google Docs newsletter draft. I didn't feel like figuring out authentication and Google APIs from Emacs, so I did that in a NodeJS script instead. convert-newsletter.js can either create or download the latest newsletter doc from our Google Shared Drive. (google-api might be helpful if I want to do this in Emacs, not sure.) If I call convert-newsletter.js with the download argument, it unpacks the zip into ~/proj/bike-brigade/temp_newsletter, where my Emacs Lisp function for processing the latest newsletter draft with images can turn it into the HTML to insert into the HTML template I've previously created. I've been thinking about whether I want to move my HTML transformation code to NodeJS as well so that I could run the whole thing from the command-line and possibly have other people run this in the future, or if I should just leave it in Emacs for my convenience.

Updating the campaign through the Mailchimp API means that I don't have to log in, replicate the campaign, click on the code block, and paste in the code. Very nice, no clicks needed. I also use TRAMP to write the HTML to a file on my server (my-bike-brigade-output-file is of the form /ssh:hostname:/path/to/file) so that other volunteers can get a web preview without waiting for the test email.

(defun my-brigade-next-campaign (&optional date)
  (setq date (or date (org-read-date nil nil "+Sun")))
  (seq-find
   (lambda (o)
     (string-match (concat "^" date)
                   (alist-get 'title (alist-get 'settings o))))
   (alist-get 'campaigns (mailchimp-campaigns 5))))

(defvar my-bike-brigade-output-file nil)

(defun my-brigade-download-newsletter-from-google-docs ()
  "Download the newsletter from Google Docs and puts it in ~/proj/bike-brigade/temp_newsletter/."
  (interactive)
  (let ((default-directory "~/proj/bike-brigade"))
    (with-current-buffer (get-buffer-create "*Newsletter*")
      (erase-buffer)
      (display-buffer (current-buffer))
      (call-process "node" nil t t "convert-newsletter.js" "download"))))

(defun my-brigade-create-or-update-campaign ()
  (interactive)
  (let* ((date (org-read-date nil nil "+Sun"))
         (template-name "Bike Brigade weekly update")
         (list-name "Bike Brigade")
         (template-id
          (alist-get
           'id
           (seq-find
            (lambda (o)
              (string= template-name (alist-get 'name o)))
            (alist-get 'templates (mailchimp--request-json "templates")))))
         (list-id (seq-find
                   (lambda (o)
                     (string= list-name
                              (alist-get 'name o)))
                   (alist-get 'lists (mailchimp--request-json "lists"))))
         (campaign (my-brigade-next-campaign date))
         (body `((type . "regular")
                 (recipients (list_id . ,(alist-get 'id list-id)))
                 (settings
                  (title . ,date)
                  (subject_line . "Bike Brigade: Weekly update")
                  (from_name . "Bike Brigade")
                  (reply_to . "info@bikebrigade.ca")
                  (tracking
                   (opens . t)
                   (html_clicks . t))))))
    (unless campaign
      (setq campaign (mailchimp--request-json
                      "/campaigns"
                      :method "POST"
                      :body
                      body)))
    ;; Download the HTML
    (my-brigade-download-newsletter-from-google-docs)
    ;; Upload to Mailchimp
    (mailchimp-campaign-update-from-template
     (alist-get 'id campaign)
     template-id
     (list
      (cons "main_content_area"
            (my-brigade-process-latest-newsletter-draft-with-images
             date))))
    (when my-bike-brigade-output-file
      (with-temp-file my-bike-brigade-output-file
        (insert (alist-get 'html (mailchimp--request-json (format "/campaigns/%s/content" (alist-get 'id campaign)))))))
    (message "%s" "Done!")))

Now to send the test e-mails…

(defvar my-brigade-test-emails nil "Set to a list of e-mail addresses.")
(defun my-brigade-send-test-to-me ()
  (interactive)
  (mailchimp-campaign-send-test-email (my-brigade-next-campaign) user-mail-address))

(defun my-brigade-send-test ()
  (interactive)
  (if my-brigade-test-emails
      (mailchimp-campaign-send-test-email (my-brigade-next-campaign) my-brigade-test-emails)
    (error "Set `my-brigade-test-emails'.")))

And schedule it:

(defun my-brigade-schedule ()
  (interactive)
  (let ((sched (format-time-string "%FT%T%z" (org-read-date t t "+Sun 11:00") t))
        (campaign (my-brigade-next-campaign)))
    (mailchimp-campaign-schedule campaign sched)
    (message "Scheduled %s" (alist-get 'title (alist-get 'settings campaign)))))

Progress, bit by bit! Here's a screenshot showing the Google Docs draft on one side and my web preview in the other:

2026-01-17_13-00-27.png
Figure 1: Google Docs and Mailchimp campaign preview

It'll be even cooler if I can get some of this working via systemd persistent tasks so that they happen automatically, or have some kind of way for the other newsletter volunteers to trigger a rebuild. Anyway, here's https://github.com/sachac/mailchimp-el in case the code is useful for anyone else.

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

Visualizing and managing Pipewire audio graphs from Emacs

| emacs

I want to be able to record, stream, screen share, and do speech recognition, possibly all at the same time. If I just try having those processes read directly from my microphone, I find that the audio skips. I'm on Linux, so it turns out that I can set up Pipewire with a virtual audio cable (loopback device) connecting my microphone to a virtual output (null sink) with some latency (100ms seems good) so that multiple applications listening to the null sink can get the audio packets smoothly.

I was getting a little confused connecting things to other things, though. qpwgraph was helpful for starting to understand how everything was actually connected to each other, and also for manually changing the connections on the fly.

2026-01-13_10-06-59.png
Figure 1: qpwgraph screenshot

Like with other graphical applications, I found myself wondering: could I do this in Emacs instead? I wanted to just focus on a small set of the nodes. For example, I didn't need all of the lines connecting to the volume control apps. I also wanted the ability to focus on whichever nodes were connected to my microphone.

Unsurprisingly, there is a pipewire package in MELPA.

2026-01-14_16-39-37.png
Figure 2: Screenshot of M-x pipewire from the pipewire package

I want to see and manage the connections between devices, though, so I started working on sachac/epwgraph: Emacs Pipewire graph visualization. This is what epwgraph-show looks like with everything in it:

2026-01-14_16-50-39.png
Figure 3: epwgraph-show

Let's call it with C-u, which prompts for a regexp of nodes to focus on and another regexp for nodes to exclude. Then I can ignore the volume control:

2026-01-14_16-51-16.png
Figure 4: Ignoring the volume control

I can focus on just the things that are connected to my microphone:

2026-01-14_16-51-56.png
Figure 5: Focusing on a regular expression

This also lets me disconnect things with d (epwgraph-disconnect-logical-nodes):

2026-01-14_16-52-35.png
Figure 6: Disconnecting a link

and connect them with c (epwgraph-connect-logical-nodes).

2026-01-14_16-52-57.png
Figure 7: Connecting links

I don't have a fancy 5.1 sound systems, so the logic for connecting nodes just maps L and R if possible.

Most of the time I just care about the logical devices instead of the specific left and right channels, but I can toggle the display with t so that I can see specific ports:

2026-01-14_17-17-34.png
Figure 8: Showing specific ports

and I can use C and D to work with specific ports as well.

2026-01-14_18-10-55.png
Figure 9: Connecting specific ports

I usually just want to quickly rewire a node so that it gets its input from a specified device, which I can do with i (epwgraph-rewire-inputs-for-logical-node).

output-2026-01-14-17:30:18.gif
Figure 10: Animated GIF showing how to change the input for a node.

I think this will help me stay sane when I try to scale up my audio configuration to having four or five web conferences going on at the same time, possibly with streaming speech recognition.

Ideas for next steps:

  • I want to be able to set the left/right balance of audio, probably using pactl set-sink-volume <index> left% right%
  • I'd love to be able to click on the graph in order to work with it, like dragging from one box to another in order to create a connection, right-drag to disconnect, or shift-drag to rewire the inputs.

In case this is useful for anyone else:

sachac/epwgraph: Emacs Pipewire graph visualization

View org source for this post

Emacs Lisp: Editing one file twice at the same time

| emacs

@HaraldKi@nrw.social said:

Emacs can do everything. Except the most simple thing ever as I learned after 40 years in which I never needed it: Edit one file twice at the same time.

I can open a new Emacs "window" and re-open the file. But Emacs notices and this and shows the file's buffer in the new window, not a new buffer.

But why? Well, when editing and SVG file, you can switch between the XML and the rendered image with C-c C-c, but I would like to see the XML and the rendered next to each other.😀

You might think this is easy, just use M-x clone-indirect-buffer-other-window. But image-mode adds a wrinkle. It uses text properties to display the image, so even if you have two views of the same buffer thanks to clone-indirect-buffer, C-c C-c will toggle both of them. If we want to edit a file as both text and an SVG at the same time, we need to actually have two separate file buffers.

I started off by looking at how find-file works. From there, I went to find-file-noselect. Normally, find-file-no-select reuses any existing buffers visiting the same file. If it doesn't find any, it calls find-file-noselect-1. That lets me write this short function to jump straight to that step.

(defun my-find-file-always (filename &optional buffer-name)
  (interactive (list (read-file-name "File: ")))
  (setq buffer-name (or (create-file-buffer filename)))
  (let* ((truename (abbreviate-file-name (file-truename filename)))
         (attributes (file-attributes truename))
         (number (file-attribute-file-identifier attributes)))
    (with-current-buffer
        (find-file-noselect-1
         (get-buffer-create buffer-name)
         truename
         t nil truename number)
      (when (called-interactively-p 'any)
        (switch-to-buffer (current-buffer)))
      (current-buffer))))

(defun my-clone-file-other-window ()
  (interactive)
  (display-buffer-other-window (my-find-file-always (buffer-file-name))))

This code unconditionally opens a buffer visiting a file, so you could have multiple buffers, looking at the same file independently. With global-auto-revert-mode, editing the file in one buffer and saving it will result in changes in the other.

I sometimes play around with SVGs, and it might be helpful to be able to experiment with the source code of the SVG while seeing the changes refreshed automatically.

I really like how in Emacs, you can follow the trail of the functions to find out how they actually work.

Screencast demonstrating my-find-file-always

Transcript

00:00:00 The problem: clone-indirect-buffer-other-window and image-mode
@HaraldKi@nrw.social said, "Emacs can do everything except the most simple thing ever, as I learned after 40 years in which I never needed it: edit one file twice at the same time." You might think this is easy, just use M-x clone-indirect-buffer-other-window, but image mode adds a wrinkle. So let's show you how that works. I've got my test SVG here. We can say clone-indirect-buffer-other-window. But if I use C-c C-c, you'll notice that both of the windows change. That's because image mode uses text properties instead of some other kind of display. I mean, it's the same buffer that's being reused for the clone. So that doesn't work.
00:00:48 A quick tour of find-file
What I did was I looked at how find-file works. And then from there, I went to find-file-noselect. So this is find-file over here. If you look at the source code, you'll see how it uses find-file... It's a very short function, actually. It uses find-file-noselect. And find-file-noselect reuses a buffer if it can. Let's show you where we're looking for this. Ah, yes. So here's another buffer here. And what we want to do is we want to open a new file buffer no matter what. The way that find-file-noselect actually works is it calls this find-file-noselect1. And by taking a look at how it figured out the raw file and the true name and the number to send to it, I was able to write this short function, my-find-file-always, and a my-clone-file-other-window.
00:01:46 Demonstration of my-find-file-always
So if I say my-find-file-always, then it will always open that file, even if it's already open elsewhere.
00:01:57 Cloning it into the other window
Let's show you how it works when I clone it in the other window. All right, so if I switch this one to text mode, I can make changes to it. More stuff goes here. And as you can see, that added this over here. I have global-auto-revert mode on, so it just refreshes automatically. So yeah, that's this function.

View org source for this post