dot-emacs/local-lisp/whisper-posframe.el
2026-10-16 23:45:31 +02:00

273 lines
8.6 KiB
EmacsLisp

;;; whisper-posframe.el --- Record speech into a posframe -*- lexical-binding: t; -*-
(require 'whisper)
(require 'posframe)
(defgroup whisper-posframe nil
"Record speech into a temporary posframe buffer."
:group 'whisper)
(defcustom whisper-posframe-buffer-name "*whisper-capture*"
"Name of the temporary Whisper capture buffer."
:type 'string
:group 'whisper-posframe)
(defcustom whisper-posframe-width 80
"Width of the Whisper posframe in columns."
:type 'integer
:group 'whisper-posframe)
(defcustom whisper-posframe-height 12
"Height of the Whisper posframe in lines."
:type 'integer
:group 'whisper-posframe)
(defvar whisper-posframe--parent-frame nil)
(defvar whisper-posframe-mode-map
(let ((map (make-sparse-keymap)))
;; Finish recording and begin transcription.
(define-key map (kbd "C-c C-c") #'whisper-posframe-finish)
;; Cancel recording/transcription without copying anything.
(define-key map (kbd "C-c C-k") #'whisper-posframe-cancel)
;; Make C-g and Escape dismiss the capture.
(define-key map (kbd "C-g") #'whisper-posframe-cancel)
(define-key map (kbd "<escape>") #'whisper-posframe-cancel)
map)
"Keymap for `whisper-posframe-mode'.")
(defvar-local whisper-posframe--status-timer nil)
(defun whisper-posframe--recording-p ()
"Return non-nil when Whisper is recording."
(process-live-p whisper--recording-process))
(defun whisper-posframe--transcribing-p ()
"Return non-nil when Whisper is transcribing."
(process-live-p whisper--transcribing-process))
(defvar whisper-posframe--capture-buffer nil)
(defun whisper-posframe--set-status (status)
"Display STATUS at the beginning of the capture buffer."
(when (derived-mode-p 'whisper-posframe-mode)
(let ((inhibit-read-only t))
(save-excursion
(goto-char (point-min))
;; Remove the previous status line.
(when (looking-at "^\\[Whisper:.*\\]\n")
(delete-region (point)
(line-beginning-position 2)))
;; Insert the new status line as actual displayed buffer text.
(insert
(propertize
(format "[Whisper: %s]\n" status)
'face 'font-lock-keyword-face
'read-only t
'rear-nonsticky '(read-only face)))))))
(defun whisper-posframe--update-status ()
"Update the status line according to Whisper's current process."
(when (buffer-live-p (current-buffer))
(cond
((whisper-posframe--recording-p)
(whisper-posframe--set-status
"recording — C-c C-c to finish, C-g or Esc to cancel"))
((whisper-posframe--transcribing-p)
(whisper-posframe--set-status
"transcribing…"))
(t
;; This can occur briefly while whisper.el is compiling, checking
;; its model, or transitioning between recording and transcription.
(whisper-posframe--set-status
"preparing Whisper…")))))
(defun whisper-posframe--stop-status-timer ()
"Stop the status update timer."
(when (timerp whisper-posframe--status-timer)
(cancel-timer whisper-posframe--status-timer)
(setq whisper-posframe--status-timer nil)))
(defun whisper-posframe--copy-and-close ()
"Copy only the transcription and close the capture posframe."
(let ((buffer whisper-posframe--capture-buffer))
(when (buffer-live-p buffer)
(with-current-buffer buffer
(whisper-posframe--stop-status-timer)
(let ((inhibit-read-only t)
text)
;; Remove the status line from the actual capture buffer.
(save-excursion
(goto-char (point-min))
(when (looking-at "^\\[Whisper:.*\\]\n")
(delete-region (point)
(line-beginning-position 2))))
;; Extract only the remaining transcription.
(setq text
(string-trim
(buffer-substring-no-properties
(point-min)
(point-max))))
(when (> (length text) 0)
(kill-new text)
(message "Whisper transcription copied to the kill ring")))))
;; Clear Whisper's asynchronous insertion target.
(set-marker whisper--marker nil)
(setq whisper--point-buffer nil)
;; Delete the posframe and the known capture buffer.
(ignore-errors
(posframe-delete-frame whisper-posframe-buffer-name))
(when (buffer-live-p buffer)
(kill-buffer buffer))
(setq whisper-posframe--capture-buffer nil)))
(define-derived-mode whisper-posframe-mode text-mode "Whisper"
"Major mode for a temporary Whisper capture buffer."
(use-local-map whisper-posframe-mode-map)
(setq-local whisper-insert-text-at-point t)
(setq-local whisper-display-transcription-buffer nil)
(whisper-posframe--set-status "preparing Whisper…")
;; This hook runs after whisper.el has inserted the final transcription.
(add-hook 'whisper-after-insert-hook
#'whisper-posframe--copy-and-close
nil
t))
(defun whisper-posframe--delete ()
"Delete the Whisper posframe, if it exists."
(when (buffer-live-p (get-buffer whisper-posframe-buffer-name))
(ignore-errors
(posframe-delete-frame whisper-posframe-buffer-name))))
(defun whisper-posframe-finish ()
"Stop recording and begin Whisper transcription."
(interactive)
(cond
((whisper-posframe--recording-p)
(whisper-posframe--set-status "transcribing…")
;; whisper-run toggles recording off and starts transcription.
(whisper-run))
((whisper-posframe--transcribing-p)
(message "Whisper is already transcribing"))
(t
(message "Whisper is not currently recording"))))
(defun whisper-posframe-cancel ()
"Cancel the active Whisper operation and close the posframe."
(interactive)
(when (or (whisper-recording-p)
(whisper-transcribing-p))
;; `whisper-run' interrupts recording or cancels transcription,
;; depending on Whisper's current state.
(whisper-run))
(whisper-posframe--stop-status-timer)
(set-marker whisper--marker nil)
(setq whisper--point-buffer nil)
(whisper-posframe--delete)
(let ((buffer (current-buffer)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))
(message "Whisper recording canceled"))
(defun whisper-posframe--focus (buffer frame)
"Focus FRAME and select its window displaying BUFFER."
(when (frame-live-p frame)
(select-frame-set-input-focus frame)
(when-let ((window (get-buffer-window buffer frame)))
(select-window window))
(with-current-buffer buffer
(goto-char (point-max)))))
(defun whisper-posframe--start-whisper (buffer)
"Start Whisper with BUFFER as its explicit insertion target."
(with-current-buffer buffer
(setq-local whisper-insert-text-at-point t)
;; These are the variables whisper.el uses asynchronously when
;; inserting the completed transcription.
(setq whisper--point-buffer buffer)
(set-marker whisper--marker (point) buffer)
(whisper-run)))
;;;###autoload
(defun whisper-posframe-record ()
"Open a posframe and start recording with Whisper."
(interactive)
(require 'whisper)
(require 'posframe)
(when (or (whisper-recording-p)
(whisper-transcribing-p))
(user-error "Whisper is already busy"))
(let ((buffer (get-buffer-create whisper-posframe-buffer-name)))
(setq whisper-posframe--capture-buffer buffer)
(setq whisper-posframe--parent-frame (selected-frame))
(with-current-buffer buffer
(erase-buffer)
(whisper-posframe-mode)
;; Refresh the visible status every 200 ms.
(setq-local whisper-posframe--status-timer
(run-at-time
0
0.2
(lambda (buffer)
(when (buffer-live-p buffer)
(with-current-buffer buffer
(whisper-posframe--update-status))))
buffer)))
;; `:accept-focus t' ensures that C-g, Escape, and the other local
;; keybindings are received by the posframe.
(let ((posframe
(posframe-show
buffer
:parent-frame (selected-frame)
:position (point)
:poshandler #'posframe-poshandler-frame-center
:width whisper-posframe-width
:height whisper-posframe-height
:internal-border-color "gray50"
:border-width 2
:accept-focus t)))
;; Focus after posframe-show has created and displayed the child frame.
(run-at-time
0 nil
(lambda ()
(whisper-posframe--focus buffer posframe))))
;; Start recording. `whisper-run' records into the current buffer.
(whisper-posframe--start-whisper buffer)))
(provide 'whisper-posframe)
;;; whisper-posframe.el ends here