Categories: audio

RSS - Atom - Subscribe via email

subed.el and quoting snippets of YouTube videos from the auto captions

| emacs, audio, subed

I'm preparing for a conversation with linkarzu. It would be fun to quote snippets from some of the other videos he has. I like the way he does a lot of reaction videos that build on what people have shared. He tends to listen to things at 1.25 or 1.5x speed. I'm not sure if I have the patience even for that, so it'd be nice to be able to just quickly skim the text. He also clips a lot of his own videos. It might be interesting to play around with my workflows for listening to snippets and clipping them.

I've grabbed YouTube captions and reformatted them before:

This time around, I want to get the VTT format so that I can possibly use them with subed-record to clip audio or video.

From a YouTube link, I want to run a single command that gets me a VTT file with that as the media source and with the captions. If there are any chapters in the description, it would be lovely to get them too. I can get them from the JSON, yay!

Now I can use M-x sacha-subed-youtube to open the VTT file, which is cached locally, and I can use M-x sacha-subed-youtube-region-as-quote after selecting a section. Well, actually, I have an Embark command s (for "subed") that I can run on a YouTube video link and a selected-mode shortcut for y that I can run when the region is selected. Results:

I Asked Prot If I Should Switch to Emacs

00:09:46: I think the value of Org is that it is in the context of Emacs, which can now do other things. It can be your email client. It can be your agenda. Of course, it can be your programming environment. All these sorts of things, like your RSS feed reader and so on. And in the context of all that, you also have Org. So for me, that's kind of the value. But otherwise, having Org elsewhere, it's great still, because the paradigm of Org, I think, is a portable paradigm.

Org Mode in Neovim Is Finally Here - Full org.nvim Demo

00:00:00: I think that I achieved like 100% of parity with the original Org Mode version on Emacs.

Code for subed + YouTube
(defvar sacha-subed-youtube-dir "~/proj/youtube-captions"
  "Directory with YouTube captions.")
(defvar sacha-subed-youtube-temp-dir "~/Downloads" "Snap installations need a workaround for permission.")
;;;###autoload
(defun sacha-subed-youtube (url)
  "Download the automatic subtitles or check my cache."
  (interactive "MURL: ")
  (let* ((id (sacha-org-yt-id url))
         (existing (car (directory-files sacha-subed-youtube-dir t
                                         (concat "^" (regexp-quote id) ".*\\.vtt\\'")))))
    (if existing
        (progn
          (find-file existing)
          (subed-mpv-play-from-url url))
      ;; Download
      (let ((directory (make-temp-file (expand-file-name "yt-dlp" sacha-subed-youtube-temp-dir) t))
            filename
            json
            match
            chapters
            subtitles)
        (with-current-buffer (get-buffer-create "*yt-dlp*")
          (let ((default-directory directory))
            (call-process
             "yt-dlp" nil t nil
             "--skip-download"
             "--write-info-json"
             "--sub-format"
             "vtt"
             "--write-subs"
             "--write-auto-subs"
             url)))
        (when (null (directory-files directory t "\\.json$"))
          (error "Could not get data."))
        (setq json (json-read-file (car (directory-files directory t "\\.json$"))))
        (setq chapters (alist-get 'chapters json))
        (setq filename (expand-file-name
                        (concat id " - " (alist-get 'title json) ".vtt")
                        sacha-subed-youtube-dir))
        (setq subtitles (subed-parse-file (car (directory-files
                                                directory t
                                                "\\.vtt$"))))
        (delete-directory directory t)
        ;; add the chapters as comments
        (mapc
         (lambda (sub)
           (when (and chapters
                      (>= (elt sub 1)
                          (* (alist-get 'start_time (car chapters)) 1000)))
             (setf (elt sub 4) (alist-get 'title (pop chapters)))))
         subtitles)
        (subed-create-file
         filename
         subtitles
         t)
        (find-file filename)
        (subed-mpv-play-from-url url)))))

(defun sacha-subed-youtube-id ()
  "Return the YouTube ID."
  (or (save-restriction
        (save-excursion
          (goto-char (point-min))
          (subed-forward-subtitle-text)
          (when (subed-record-get-directive "YOUTUBE_URL")
            (sacha-org-yt-id
             (subed-record-get-directive "YOUTUBE_URL")))))
      (car (split-string (file-name-base (buffer-file-name)) " "))))

;; Now we just need a quick way to copy a YouTube URL.
;;;###autoload
(defun sacha-subed-youtube-url (&optional start-msecs end-msecs)
  "Copy YouTube URL for the current timestamp."
  (interactive)
  (when-let*
      ((id (sacha-subed-youtube-id))
       (s
        (if end-msecs
            (format "https://youtu.be/%s?t=%d&end=%d"
                    id
                    (/ (or start-msecs (subed-subtitle-msecs-start))
                       1000)
                    (1+ (/ end-msecs
                           1000)))
          (format "https://youtu.be/%s?t=%d"
                    id
                    (/ (or start-msecs (subed-subtitle-msecs-start))
                       1000)))))
    (when (called-interactively-p 'any)
      (kill-new s)
      (message "%s" s))
    s))

;;;###autoload
(defun sacha-subed-youtube-play ()
  "Play this video from YouTube."
  (interactive)
  (when-let*
      ((id (sacha-subed-youtube-id))
       (s (concat "https://youtu.be/" id)))
    (when (string= subed-mpv-media-file s)
      (subed-mpv-play-from-url s))
    (subed-mpv-jump-to-current-subtitle)
    (subed-mpv-unpause)))

;;;###autoload
(defun sacha-subed-youtube-region-as-quote (beg end &optional label)
  "Format this as a quote."
  (interactive (list (region-beginning) (region-end) (when current-prefix-arg (read-string "Label: "))))
  (let* ((id (sacha-subed-youtube-id))
         (url (sacha-subed-youtube-url
               (save-excursion
                 (goto-char beg)
                 (subed-subtitle-msecs-start))
               (save-excursion
                 (goto-char end)
                 (subed-subtitle-msecs-stop))))
         (base (file-name-base (buffer-file-name)))
         (text (subed-subtitle-list-text (subed-subtitle-list beg end)))
         (s (format
             "%s\n#+begin_quote\n%s: %s\n#+end_quote\n"
             (org-link-make-string
              (concat "yt:" url)
              (or label
                  (and (string-match (format "^%s - \\(.+\\)" (regexp-quote id)) base)
                       (match-string 1 base))
                  (sacha-page-title url)))
             (org-link-make-string
              (concat "vtime:"
                      (subed-msecs-to-timestamp
                       (save-excursion
                         (goto-char beg)
                         (subed-subtitle-msecs-start))
                       t)))
             (with-temp-buffer
               (insert text)
               (let ((fill-column most-positive-fixnum))
                 (fill-region (point-min) (point-max))
                 (string-trim (buffer-string)))))))
      (when (called-interactively-p 'any)
        (kill-new s)
        (message "%s" s))
      s))

Some of this code hasn't been committed to subed yet, but I will commit it sometime this week.

My next steps might be to:

  • cache the video as a whole to make it easier to check the waveforms
  • extract the segment to a standalone video file so that it's easy for me to play back or include in another video
  • see if I can use my voice activity detection and semi-streaming speech recognition to get a VTT of my current OBS recording
This is part of my EmacsConfiguration.
View Org source for this post

Using Emacs to visualize speaking and target words/phrases in real-ish time

| audio, speech-recognition, emacs, learning, visualization
2026-10-07_07-33-03.png
Figure 1: Screenshot showing my list of target phrases and which ones have been recognized

When I'm working with a tutor to improve my French, I sometimes need to push myself to provide more details when I'm sharing and ask more follow-up questions in response to what they're saying, instead of falling back to « D'accord » and « C'est cool. » Visualizing the timeline in real-time lets me see if the turn-taking looks good. I might even need to take a more active role in controlling the conversation if I happen to be meeting a tutor who tends to go on monologues.

When I'm talking out loud on my own for practice, this might also nudge me to reduce the pauses and enrich my vocabulary by practising saying things often enough that they become more automatic.

1. Voice activity detection and speech recognition

This code uses this server vad-file.py, which I generated with Google Gemini. It emits JSONL for voice activity detection events (start/stop) and uses Orukeet to transcribe the speech once the voice activity detection notices it's finished. I also use spacy to look up token information because I want to someday colour the nouns by gender, which might allow me to correct myself in the next sentence instead of hesitating in the previous one.

code for sacha-vad-tutor
;; Report on the balance between my tutor and me
(defvar sacha-vad-tutor-process nil)
(defvar sacha-vad-tutor-dir "~/vendor/orukeet")
(defvar sacha-vad-tutor-command `(,(expand-file-name ".venv/bin/python" sacha-vad-tutor-dir)
                                  "asr.py"
                                  "--spacy-model"
                                  "fr_dep_news_trf"
                                  "-s" "300" "-t"
                                  "VirtualMicSink.monitor=sacha" "alsa_output.usb-AT_AT2040USB_202011110001-00.analog-stereo.monitor=other"))
(defvar sacha-vad-tutor-events nil "List of events as an list of alists (label start stop duration).")
(defvar sacha-vad-tutor-labels '("sacha" "other"))
(defvar sacha-vad-tutor-summary '(("sacha" . 0)
                                  ("other" . 0)))
(defvar sacha-vad-tutor-session-length (* 60 60) "Number of seconds to use for scale.")

(defun sacha-vad-tutor-update-view ()
  "Visualize the last 3 minutes and the overview of the session.
Also keep track of target phrases."
  (interactive)
  (with-current-buffer (get-buffer-create "*tutor stats*")
    (erase-buffer)
    (let* ((total (apply '+ (mapcar 'cdr sacha-vad-tutor-summary)))
           (height 30)
           (width (window-width nil t))
           (max-seconds
            (if sacha-vad-tutor-events
                (apply 'max (mapcar (lambda (o) (alist-get 'end o))
                                    sacha-vad-tutor-events))
              0))
           (threshold 180)              ; last three minutes
           (threshold-seconds (- max-seconds threshold))
           (recent
            (seq-keep
             (lambda (o)
               (when (>= (alist-get 'start o)
                         threshold-seconds)
                 (let-alist o
                   `((label . ,.label)
                     (start . ,(- .start threshold-seconds))
                     (end . ,(- .end threshold-seconds))))))
             (copy-sequence sacha-vad-tutor-events)))
           (recent-total (apply '+ (mapcar (lambda (o)
                                             (-
                                              (alist-get 'end o)
                                              (alist-get 'start o)))
                                           recent))))
      (save-excursion
        (insert
         (format "Last %d minute%s:\n"
                 (/ threshold 60)
                 (if (> (/ threshold 60) 1)
                     "s"
                   ""))
         (mapconcat
          (lambda (o)
            (format "%d%% %s\n"
                    (/ (* 100.0 (cdr o)) recent-total)
                    (car o)))
          (seq-map
           (lambda (group)
             (cons (car group)
                   (apply '+ (mapcar (lambda (o)
                                       (-
                                        (alist-get 'end o)
                                        (alist-get 'start o)))
                                     (cdr group)))))
           (seq-group-by (lambda (o) (alist-get 'label o)) recent)))
         "\n")
        (svg-insert-image
         (sacha-vad-visualize
          sacha-vad-tutor-labels
          recent
          t
          width height))
        (insert
         "\nTotal:\n"

         (mapconcat
          (lambda (o)
            (format "%d%% %s\n"
                    (/ (* 100.0 (cdr o)) total)
                    (car o)))
          sacha-vad-tutor-summary))
        (svg-insert-image
         (sacha-vad-visualize sacha-vad-tutor-labels sacha-vad-tutor-events t
                              width height
                              sacha-vad-tutor-session-length))
        (insert "\n\n"  (sacha-learn-lang-bingo-summary))))))

(defun sacha-vad-handle-end-event (data)
  "Handle the end event."
  (let ((event (list
                (cons 'label (alist-get 'label data))
                (cons 'start (- (alist-get 'timestamp data)
                                (alist-get 'duration data)
                                (or sacha-vad-tutor-start-seconds 0)))
                (cons 'end (- (alist-get 'timestamp data)
                              (or sacha-vad-tutor-start-seconds 0))))))
    (push event sacha-vad-tutor-events)
    (let-alist data
      (incf (alist-get (alist-get 'label data) sacha-vad-tutor-summary 0 nil 'string=)
            .duration))
    (sacha-vad-tutor-update-view)))

(defvar sacha-vad-event-handler-functions '(sacha-vad-speech-process) "Functions to call with the VAD/transcription events.")

(defvar sacha-vad-process-my-speech nil "Non-nil means process my speech through the whisper setup.")
(defun sacha-vad-speech-process (data)
  "Process DATA through my whisper input process if it's my speech.
Add to `sacha-vad-event-handler-functions'."
  (when (and sacha-vad-process-my-speech
             (string= (alist-get 'event data) "final")
             (string= (alist-get 'label data) sacha-vad-my-label))
    (with-current-buffer (get-buffer-create whisper--stdout-buffer-name)
      (erase-buffer)
      (insert (alist-get 'text data))
      (goto-char (point-min)))
    (whisper--handle-transcription-output nil (get-buffer-create whisper--stdout-buffer-name))))

(defun sacha-vad-tutor-filter (proc string)
  "Add and parse."
  (when (buffer-live-p (process-buffer proc))
    ;; split the string by newline?
    (let ((data (with-current-buffer (process-buffer proc)
                  (goto-char (point-max))
                  (save-excursion
                    (insert string))
                  (catch 'waiting
                    (unless (or (eobp) (looking-at "\n*$"))
                      (json-read))))))
      (run-hook-with-args 'sacha-vad-event-handler-functions data)
      (when (string= (alist-get 'event data) "end")
        (sacha-vad-handle-end-event data))
      (when (string= (alist-get 'event data) "final")
        (sacha-vad-handle-text-event data)))))

(defvar sacha-vad-last-speaker nil "Previous speaker.")

(defvar sacha-vad-transcript-buffer nil "If non-nil, add speech to this buffer.")
(defun sacha-vad-add-to-transcript (text)
  "Add TEXT to the transcript buffer."
  (when sacha-vad-transcript-buffer
    (with-current-buffer (get-buffer-create sacha-vad-transcript-buffer)
      (set (make-local-variable 'window-point-insertion-type) t)
      (save-excursion
        (goto-char (point-max))
        (insert text)))))

(defvar sacha-vad-my-label "sacha")
(defun sacha-vad-handle-text-event (data)
  (sacha-vad-add-to-transcript
   (concat
    (if (string= sacha-vad-last-speaker (alist-get 'label data))
        " "
      (concat (if (bobp) "" "\n\n") (alist-get 'label data) ": "))
    (alist-get 'text data)))
  (setq sacha-vad-last-speaker (alist-get 'label data))
  (when (string= (alist-get 'label data) sacha-vad-my-label)
    (sacha-learn-lang-notice-bingo-words (alist-get 'text data))))

(defun sacha-vad-tutor-start ()
  "Start the process."
  (interactive)
  (sacha-vad-tutor-ensure)
  (setq sacha-vad-tutor-summary '(("sacha" . 0)
                                  ("other" . 0))
        sacha-vad-tutor-events nil)
  (with-current-buffer (get-buffer-create "*Transcript*")
    (erase-buffer)
    (display-buffer (current-buffer)))
  (display-buffer (get-buffer-create "*tutor stats*")))

(defun sacha-vad-tutor-ensure ()
  "Start the process if it's not already running."
  (interactive)
  (unless (process-live-p sacha-vad-tutor-process)
    (setq sacha-vad-tutor-process-start-time (current-time))
    (setq sacha-vad-tutor-start-seconds 0)
    (let ((default-directory sacha-vad-tutor-dir))
      (setq sacha-vad-tutor-process
            (make-process
             :name "vad-tutor"
             :command sacha-vad-tutor-command
             :connection-type 'pipe
             :buffer (get-buffer-create "*vad-tutor*")
             :stderr (get-buffer-create "*vad-tutor-err*")
             :filter #'sacha-vad-tutor-filter)))))

(defun sacha-vad-tutor-stop ()
  "Stop the process."
  (interactive)
  (interrupt-process sacha-vad-tutor-process))

;;;###autoload
(defun sacha-js-parse-jsonl-file (file)
  "Parse jsonl as a list"
  (let (results)
    (with-temp-buffer
      (insert-file-contents file)
      (goto-char (point-min))
      (while (not (eobp))
        (condition-case nil
            (push (json-read) results)
          (error (goto-char (point-max))))))
    (nreverse results)))

(defvar sacha-vad-tutor-start-seconds 0 "Number of seconds to consider as the start.")
(defvar sacha-vad-tutor-process-start-time nil "Time that the process started.")

;;;###autoload
(defun sacha-vad-tutor-reset-clock ()
  "Reset the start of the session."
  (interactive)
  (setq sacha-vad-tutor-start-seconds
        (time-to-seconds (time-subtract (current-time)
                                        sacha-vad-tutor-process-start-time)))
  (mapcar (lambda (o) (setcdr o 0)) sacha-vad-tutor-summary)
  (setq sacha-vad-tutor-events nil))

(defun sacha-vad-visualize (labels list &optional output-file width height max-seconds)
  (setq height (or height 25))
  (setq width (or width (window-width nil t)))
  (let* ((svg (svg-create width height))
         (max-seconds (max
                       (or max-seconds 0)
                       (if list
                           (apply 'max (mapcar (lambda (o) (alist-get 'end o))
                                               list))
                         0)))
         (pixels-per-second (/ (* 1.0 width) max-seconds))
         (row-height (/ height (length labels))))
    (dolist (o list)
      (let-alist o
        (let ((y (* row-height (or (seq-position labels .label 'string=) -1))))
          (when (>= y 0)
            (svg-rectangle
             svg
             (* .start pixels-per-second)
             y
             (* (- .end .start) pixels-per-second)
             row-height :fill "green")))))
    (cond
     ((stringp output-file)
      (with-temp-file output-file
        (svg-print svg)))
     (output-file
      svg)
     (t
      (with-current-buffer (get-buffer-create "*vad*")
        (goto-char (point-min))
        (display-buffer (current-buffer))
        (erase-buffer)
        (svg-insert-image svg))))))

(defun sacha-vad-stats (labels list)
  "Return the balance of time talking as (label total percentage)."
  (let* ((max-seconds (apply '+ (mapcar
                                 (lambda (o) (let-alist o (- .end .start)))
                                 list)))
         (durations (mapcar (lambda (o)
                            (cons
                             (car o)
                             (mapcar (lambda (e) (let-alist e (- .end .start)))
                                     (cdr o))))
                          (seq-group-by (lambda (o) (alist-get 'label o))
                                        list))))
    (seq-map
     (lambda (label)
       (let* ((row (cdr (assoc label durations)))
              (total (apply '+ row)))
         `((label . ,label)
           (total . ,total)
           (count . ,(length row))
           (percentage . ,(and total max-seconds (> max-seconds 0) (* 100.0 (/ total max-seconds)))))))
     labels)))

;; (sacha-vad-stats '("sacha" "tutor") (seq-take (sacha-js-parse-jsonl-file "~/proj/speech/output2.log") 5))
;; (sacha-vad-stats '("sacha" "tutor") (sacha-js-parse-jsonl-file "~/proj/speech/output.log"))
;; (sacha-vad-visualize '("sacha" "tutor") (sacha-js-parse-jsonl-file "~/proj/speech/output.log"))
;; (sacha-vad-visualize '("sacha" "tutor") (seq-take (sacha-js-parse-jsonl-file "~/proj/speech/output2.log") 30))

(defun sacha-vad-tutor-group-output (files)
  (let ((grouped-files (seq-group-by
                        (lambda (o)
                          (substring (file-name-base o) 0 10))
                        files)))
    (mapcar
     (lambda (group)
       (let ((start 0))
         (cons (car (cdr group))
               (seq-reduce
                (lambda (prev file)
                  (append prev
                          (let ((max-seconds 0))
                            (prog1 (mapcar (lambda (event)
                                             (incf (alist-get 'start event) start)
                                             (incf (alist-get 'end event) start)
                                             (setq max-seconds (max (alist-get 'end event) max-seconds))
                                             event)
                                           (sacha-js-parse-jsonl-file file))
                              (setq start max-seconds)))))
                (cdr group)
                nil))))
     grouped-files)))

(defun sacha-vad-tutor-make-table (files &optional width height)
  "Make a summary table."
  (let ((labels sacha-vad-tutor-labels)
        (width (or width 500))
        (height (or height 30)))
    ;; combine data as needed
    (append
     '(("Date" "Tutor" "Minutes" "%" "Visualization") hline)
     (seq-map
      (lambda (group)
        (let* ((list (cdr group))
               (base (file-name-base (car group))))
          (if list
              (let-alist (car (sacha-vad-stats labels list))
                (let ((svg-name
                       (expand-file-name
                        (concat (file-name-base (car group)) "-" .label ".svg")
                        "~/proj/french/conversation")))
                  (sacha-vad-visualize
                   (list .label)
                   list
                   svg-name
                   width
                   height)
                  (list
                   (format-time-string "%Y-%m-%d"
                                       (seconds-to-time
                                        (sacha-filename-timestamp (car group))))
                   ;; (org-link-make-string (concat "file:" o) "file")
                   (if (string-match "-conversation-\\([^\\.]+\\)" base)
                       (s-capitalize (substring (match-string 1 base) 0 1))
                     "")

                   (round (/ .total 60))
                   (format "%d%%" .percentage)
                   (org-link-make-string (concat "file:" svg-name)
                                         (concat "file:" svg-name))
                   )))
            (list "?"))))
      (sacha-vad-tutor-group-output files))
     nil)))

2. Noticing bingo phrases

Here's the code that reminds me to use the words or expressions on my to-do list, and then keeps track of how often I've used them, prioritizes the ones that I haven't used yet, and displays these. It can work with either my Whisper dictation workflow or the VAD events server described above.

I keep the list of expressions in file:///home/sacha/proj/french/bingo.txt.

Code to notice when I've used an expression
(defvar sacha-learn-lang-bingo-word-file "~/proj/french/bingo.txt")
(defvar sacha-learn-lang-bingo-words nil "List of words.")
(defvar sacha-learn-lang-bingo-words-hash nil "Data for the counts.")

;;;###autoload
(defun sacha-learn-lang-bingo-words ()
  "Load the list of words from the file."
  (interactive)
  (setq sacha-learn-lang-bingo-words
        (seq-map (lambda (o)
                   (when (= (length o) 1)
                     (setq o (list (car o) 0)))
                   (setq o (append o (list
                                      (string-trim (replace-regexp-in-string
                                                    " *\\[.+?\\] *" " "
                                                    (car o))))))
                   o)
                 (pcsv-parse-file sacha-learn-lang-bingo-word-file)))
  sacha-learn-lang-bingo-words)

;;;###autoload
(defun sacha-learn-lang-bingo-handler (event)
  "Manually register the event."
  (interactive "e")
    (let* ((pos (posn-point (event-end event)))
           (val (get-text-property pos 'bingo)))
      (when val
        (sacha-learn-lang-notice-bingo-words val))))

(defvar-keymap sacha-learn-lang-bingo-map
  "<mouse-1>" #'sacha-learn-lang-bingo-handler)

;;;###autoload
(defun sacha-learn-lang-bingo-summary ()
  "Summarize the words to say."
  (let ((target (seq-filter (lambda (row) (= (elt row 1) 0))
                            sacha-learn-lang-bingo-words))
        (yay (seq-filter (lambda (row) (> (elt row 1) 0))
                         sacha-learn-lang-bingo-words)))
    (concat
     (if target
         (concat "Target: "
                 (mapconcat (lambda (row)
                              (propertize
                               (car row)
                               'keymap sacha-learn-lang-bingo-map
                               'bingo (car row)))
                            target
                            "; ")
                 "\n\n")
       "")
     (if yay
         (concat
          "Yay! "
          (mapconcat (lambda (row)
                       (propertize (format "%s (%s)" (car row) (elt row 1))
                                   'keymap sacha-learn-lang-bingo-map
                                   'bingo (car row)))
                     (seq-filter (lambda (row) (> (elt row 1) 0))
                                 sacha-learn-lang-bingo-words)
                     "; ")
          "\n\n")
       ""))))

;;;###autoload
(defun sacha-learn-lang-notice-bingo-words (text)
  "Look for the bingo words in TEXT and celebrate when I use them.
Add to `whisper-insert-text-at-point'."
  (when text
    (unless sacha-learn-lang-bingo-words (sacha-learn-lang-bingo-words))
    (let ((found (seq-find
                  (lambda (o)
                    (and (elt o 2)
                         (string-match (concat "\\<" (elt o 2)) text)))
                  sacha-learn-lang-bingo-words)))
      (when found
        (incf (elt found 1))
        (sacha-vad-tutor-update-view)
        (with-current-buffer (get-buffer-create "*Bingo*")
          (erase-buffer)
          (insert (sacha-learn-lang-bingo-summary))))))
  text)
This is part of my EmacsConfiguration.
View Org source for this post

Expanding yasnippets by voice in Emacs and other applications

| emacs, audio, speech-recognition

Yasnippet is a template system for Emacs. I want to use it by voice. I'd like to be able to say things like "Okay, define interactive function" and have that expand to a matching snippet in Emacs or other applications. Here's a quick demonstration of expanding simple snippets:

Screencast of expanding snippets by voice in Emacs and in other applications

Transcript
  • 00:00 So I've defined some yasnippets with names that I can say. Here, for example, in this menu, you can see I've got "define interactive function" and "with a buffer that I'll display." And in fundamental mode, I have some other things too. Let's give it a try.
  • 00:19 I press my shortcut. "Okay, define an interactive function." You can see that this is a yasnippet. Tab navigation still works.
  • 00:33 I can say, "OK, with a buffer that I'll display," and it expands that also.
  • 00:45 I can expand snippets in other applications as well, thanks to a global keyboard shortcut.
  • 00:50 Here, for example, I can say, "OK, my email." It inserts my email address.
  • 01:02 Yasnippet definitions can also execute Emacs Lisp. So I can say, "OK, date today," and have that evaluated to the actual date.
  • 01:21 So that's an example of using voice to expand snippets.

This is handled by the following code:

(defun my-whisper-maybe-expand-snippet (text)
  "Add to `whisper-insert-text-at-point'."
  (if (and text
           (string-match
            "^ok\\(?:ay\\)?[,\\.]? \\(.+\\)" text))
    (let* ((name
            (downcase
             (string-trim
              (replace-regexp-in-string "[,\\.]" "" (match-string 1 text)))))
           (matching
            (seq-find (lambda (o)
                        (subed-word-data-compare-normalized-string-distance
                         name
                         (downcase (yas--template-name o))))
                      (yas--all-templates (yas--get-snippet-tables)))))
      (if matching
          (progn
            (if (frame-focus-state)
                (progn
                  (yas-expand-snippet matching)
                  nil)
              ;; In another application
              (with-temp-buffer
                (yas-minor-mode)
                (yas-expand-snippet matching)
                (buffer-string))))
        text))
    text))

This code relies on my fork of whisper.el, which lets me specify a list of functions for whisper-insert-text-at-point. (I haven't asked for upstream review yet because I'm still testing things, and I don't know if it actually works for anyone else yet.) It does approximate matching on the snippet name using a function from subed-word-data.el which just uses string-distance. I could probably duplicate the function in my config, but then I'd have to update it in two places if I come up with more ideas.

The code for inserting into other functions is defined in my-whisper-maybe-type, which is very simple:

(defun my-whisper-maybe-type (text)
  "If Emacs is not the focused app, simulate typing TEXT.
Add this function to `whisper-insert-text-at-point'."
  (when text
    (if (frame-focus-state)
        text
      (make-process :name "xdotool" :command
                    (list "xdotool" "type"
                          text))
      nil)))

Someday I'd like to provide alternative names for snippets. I also want to make it easy to fill in snippet fields by voice. I'd love to be able to answer minibuffer questions from yas-choose-value, yas-completing-read, and other functions by voice too. Could be fun!

Related:

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

Using speech recognition for on-the-fly translations in Emacs and faking in-buffer completion for the results

Posted: - Modified: | audio, speech-recognition, emacs, speech

: I added the recognized text so that I can confirm what was translated. I also moved my-type-with-hint to learn-lang-type-with-hint.

When I'm writing a journal entry in French, I sometimes want to translate a phrase that I can't look up word by word using a dictionary. Instead of switching to a browser, I can use an Emacs function to prompt me for text and either insert or display the translation. The plz library makes HTTP requests slightly neater.

(defun my-lang-en-to-fr (text &optional display-only)
  (interactive (list (read-string "Text: ") current-prefix-arg))
  (let* ((url "https://translation.googleapis.com/language/translate/v2")
         (params `(("key" . ,(getenv "GOOGLE_API_KEY"))
                   ("q" . ,text)
                   ("source" . "en")
                   ("target" . "fr")
                   ("format" . "text")))
         (query-string (mapconcat
                        (lambda (pair)
                          (format "%s=%s"
                                  (url-hexify-string (car pair))
                                  (url-hexify-string (cdr pair))))
                        params
                        "&"))
         (full-url (concat url "?" query-string)))
    (let* ((response (plz 'get full-url :as #'json-read))
           (data (alist-get 'data response))
           (translations (alist-get 'translations data))
           (first-translation (car translations))
           (translated-text (alist-get 'translatedText first-translation)))
      (when (called-interactively-p 'any)
        (if display-only
            (message "%s" translated-text)
          (insert translated-text)))
      translated-text)))

I think it would be even nicer if I could use speech synthesis, so I can keep it a little more separate from my typing thoughts. I want to be able to say "Okay, translate …" or "Okay, … in French" to get a translation. I've been using my fork of natrys/whisper.el for speech recognition in English, and I like it a lot. By adding a function to whisper-after-transcription-hook, I can modify the intermediate results before they're inserted into the buffer.

(defun my-whisper-translate ()
  (goto-char (point-min))
  (let ((case-fold-search t))
    (when (re-search-forward "okay[,\\.]? translate[,\\.]? \\(.+\\)\\|okay[,\\.]? \\(.+?\\) in French" nil t)
      (let* ((s (or (match-string 1) (match-string 2)))
             (translation (save-match-data (my-lang-en-to-fr s))))
        (replace-match
         (propertize translation
                     'type-hint translation
                     'type-original s
                     'help-echo s))))))

(with-eval-after-load 'whisper
  (add-hook 'whisper-after-transcription-hook 'my-whisper-translate 70))

But that's too easy. I want to actually type things myself so that I get more practice. Something like an autocomplete suggestion would be handy as a way of showing me a hint at the cursor. The usual completion-at-point functions are too eager to insert things if there's only one candidate, so we'll just fake it with an overlay. This code works only with my whisper.el fork because it supports using a list of functions for whisper-insert-text-at-point.

(defun my-whisper-maybe-type-with-hints (text)
  "Add this function to `whisper-insert-text-at-point'."
  (let* ((hint (and text (org-find-text-property-in-string 'type-hint text)))
         (original (and text (org-find-text-property-in-string 'type-original text))))
    (if hint
        (progn
          (learn-lang-type-with-hint hint original)
          nil)
      text)))

Here's a demonstration of me saying "Okay, this is a test, in French.":

Screencast of using speech recognition to translate into French and provide a hint when typing

Since we're faking in-buffer completion here, maybe we can still get away with considering this as an entry for Emacs Carnival February 2026: Completion ? =)

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

Using Silero voice activity detection to automatically queue multiple transcriptions with natrys/whisper.el

Posted: - Modified: | audio, speech-recognition, emacs

: Moved code to sachac/speech-input.

I can queue multiple transcriptions with whisper.el so that they get processed sequentially with backup audio. It catches up when I pause to think. Now I want to use Silero voice activity detection to do that kind of segmentation for me automatically.

First, I need a Python server that can print out events when it notices the start or stop of a speech segment. If I print out the timestamps, I might be able to cross-reference it someday with interestingthings. For now, even just paying attention to the end of a segment is enough for what I want to do.

Python script for printing out events
import sounddevice as sd
import numpy as np
import torch
import sys
from datetime import datetime, timedelta

SILENCE_DURATION = 500
SAMPLING_RATE = 16000
CHUNK_SIZE = 512
model, utils = torch.hub.load(repo_or_dir='snakers4/silero-vad',
                              model='silero_vad',
                              force_reload=False)

(get_speech_timestamps, save_audio, read_audio, VADIterator, collect_chunks) = utils
vad_iterator = VADIterator(model, threshold=0.5, min_silence_duration_ms=SILENCE_DURATION)

stream_start_time = None

def format_iso_with_offset(offset_seconds):
    if stream_start_time is None:
        return "PENDING"
    event_time = stream_start_time + timedelta(seconds=offset_seconds)
    return event_time.astimezone().isoformat(timespec='milliseconds')

def audio_callback(indata, frames, time, status):
    global stream_start_time
    if status:
        print(status, file=sys.stderr)
    if stream_start_time is None:
        stream_start_time = datetime.now()
    tensor_input = torch.from_numpy(indata.copy()).flatten()
    speech_dict = vad_iterator(tensor_input, return_seconds=True)
    if speech_dict:
        if "start" in speech_dict:
            print(f"START {format_iso_with_offset(speech_dict['start'])}", flush=True)
        if "end" in speech_dict:
            print(f"END {format_iso_with_offset(speech_dict['end'])}", flush=True)
try:
    with sd.InputStream(samplerate=SAMPLING_RATE,
                        channels=1,
                        callback=audio_callback,
                        blocksize=CHUNK_SIZE):
        while True:
            pass
except KeyboardInterrupt:
    print("\nStopping...")

I've mode the code to speech-input-vad.el, so now I can use speech-input-vad-start to start monitoring speech events.

(use-package speech-input :load-path "~/proj/speech-input/")

Because I added Pulse properties to the process environment, I can easily use epwgraph to rewire the input so that it gets the input from my VirtualMicSink instead of the default system audio device. (Someday I'll figure out how to specify that as the input automatically.)

Then I can start this process from Emacs:

(with-eval-after-load 'speech-input-vad
  (add-hook 'speech-input-vad-on-end-functions #'my-whisper-maybe-continue))

(defun my-whisper-maybe-continue ()
  (when (process-live-p whisper--recording-process)
    (my-whisper-continue)))

Now I can press my shortcut for my-whisper-continue to start the process. As I keep talking, it will continue to record. When I pause for more than a second between sentences, then it will send that chunk to the server for transcription without me having to press another button, while still listening for more speech.

How is this different from the streaming approach that many real-time speech recognition services offer? I think this gives me a bit more visibility into and control of the process. For my personal use, I don't need to have everything processed as quickly as possible, and I'm not trying to replicate live captions. I just want to be able to look back over the last five minutes to try to remember what I was talking about. I usually have a lot of quiet time as I think through my next steps, and it's fine to have it catch up then. I also like that I can save time-stamped audio files for later processing, divided according to the speech segments. Those might be a little bit easier to work with when I get around to compositing them into a video.

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

Queuing multiple transcriptions with whisper.el speech recognition

| audio, speech-recognition, 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")
  (require 'whisper)
  (if arg
      (my-whisper-done)
    (setq whisper--marker (point-marker) whisper--point-buffer (current-buffer))
    (when (process-live-p whisper--recording-process)
      ;; queue only if the last one is not asking for the same file
      (unless
          (string=
           (plist-get
            (car
             (last my-whisper--queue))
            :file)
           whisper--temp-file)
        (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))
    (run-hooks 'whisper-before-transcription-hook)
    (whisper--setup-mode-line :show 'recording)
    (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)))
    (whisper--setup-mode-line :hide 'recording)
    (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)))
      ;; Only works with my fork: https://github.com/sachac/whisper.el/tree/whisper-insert-text-at-point-function
      (whisper--handle-transcription-output nil (plist-get o :buffer)))))

(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, speech-recognition

Update : I'm currently using Orukeet instead of Whisper/Speaches. Experimenting with Orukeet for speech recognition

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 sacha-whisper-url-format "http://%s:%d/transcribe")
;;;###autoload
(defun sacha-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 sacha-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)))))
;;;###autoload
(defun sacha-whisper--check-model-consistency () t)

I moved my server code to speech-input-speaches.el.

(with-eval-after-load 'whisper
  (advice-add 'whisper--transcribe-via-local-server :override #'sacha-whisper--transcribe-via-local-server)
  (advice-add 'whisper--check-model-consistency :override #'sacha-whisper--check-model-consistency)
  ;;(advice-add 'whisper--ensure-server :override #'speech-input-speaches-server-start)
  )

Then I have this function for trying things out.

(defun sacha-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
(let ((times '3))
(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"
    (sacha-test-whisper-api
     (format "http://%s:%d/v1/audio/transcriptions" whisper-server-host 5092)))
   ("whisper.cpp base-q4_0"
    (sacha-test-whisper-api
     (format "http://%s:%d/inference" whisper-server-host 8642)))
   ("speaches whisper-base"
    (sacha-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"
    (sacha-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"
    (sacha-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"
    (sacha-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"
    (sacha-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"
    (sacha-test-whisper-api
     (format "http://%s:%d/transcribe" whisper-server-host 8002)))))
)
3.694 parakeet
2.484 whisper.cpp base-q4_0
1.547 speaches whisper-base
1.425 speaches whisper-base.en
4.076 speaches whisper-small
3.735 speaches whisper-small.en
2.870 speaches lorneluo/whisper-small-ct2-int8
4.537 whisperx-server Systran/faster-whisper-small

All the results above were CPU-only, no GPU acceleration.

I tried it with:

Update: After a lot of rebooting, I figured out how to get the Nvidia P1000 GPU on my Lenovo P52 to work for up to the Whisper medium models. Large models don't fit in the 4GB of VRAM it has. Here are the benchmarking results:

Benchmarking code
(let ((times '3))
(mapcar
 (lambda (group)
   (let ((whisper--temp-file "/tmp/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))
     ))
 '(
   ("speaches whisper-tiny"
    (sacha-test-whisper-api
     (format "http://%s:%d/v1/audio/transcriptions" "localhost" 8000)
     (list "-F" "model=Systran/faster-whisper-tiny.en")))
   ("speaches whisper-base"
    (sacha-test-whisper-api
     (format "http://%s:%d/v1/audio/transcriptions" "localhost" 8000)
     (list "-F" "model=Systran/faster-whisper-base.en")))
   ("speaches whisper-small"
    (sacha-test-whisper-api
     (format "http://%s:%d/v1/audio/transcriptions" "localhost" 8000)
     (list "-F" "model=Systran/faster-whisper-small.en")))
   ("speaches whisper-medium"
    (sacha-test-whisper-api
     (format "http://%s:%d/v1/audio/transcriptions" "localhost" 8000)
     (list "-F" "model=Systran/faster-whisper-medium.en"))))))
0.596 speaches whisper-tiny
0.940 speaches whisper-base
2.909 speaches whisper-small
8.740 speaches whisper-medium

That probably means I can upgrade to using the small model on a regular basis. For large models, I can use the command-line tool, since the overhead of loading the model is probably small compared to the processing time. I could also run two Speaches servers (one on CPU and one on GPU), but I don't think I'll need that level yet.

I like how speaches lets me switch models on the fly, so maybe I can use small.en generally and switch to a different model when I want to try dictating in French. Here's how I've set it up to use the server.

(defvar sacha-speech-input-model-aliases
  '(("small" . "Systran/faster-whisper-small.en")
    ("medium" . "Systran/faster-whisper-medium.en")
    ("base" . "Systran/faster-whisper-base.en")
    ("tiny" . "Systran/faster-whisper-tiny.en")
    ("large" . "Systran/faster-whisper-large-v2")))

(defun sacha-speech-input-set-model (model-name)
  "Change the speech recognition model to MODEL-NAME.
Use `sacha-speech-input-model-aliases' for aliases."
  (interactive (list (speech-input-speaches-read-model-name)))
  (when (assoc-default model-name sacha-speech-input-model-aliases #'string=)
    (setq model-name (assoc-default model-name sacha-speech-input-model-aliases #'string=)))
  (setq whisper-model model-name)
  (setq speech-input-model model-name
        speech-input-transcribe-model model-name))
(setq whisper-model "Systran/faster-whisper-small.en"
      sacha-whisper-url-format "http://%s:%d/v1/audio/transcriptions")

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. It has a soldered-on Nvidia P1000 with 4GB of VRAM.

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