From e022368a8d640aca9e5700249d70bacf948cfa7a Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 9 Jul 2026 13:48:06 -0500 Subject: fix: play the chime through an external player when one is available Emacs' play-sound-file opens an ALSA device directly, so it fails with "No usable sound device driver found" wherever ALSA's default PCM doesn't resolve. That's every PipeWire system where nothing points pcm.!default at the sound server. It also blocks Emacs until the clip finishes, and on macOS it isn't compiled in at all. chime now hands the file to the first of pw-play, paplay, afplay or aplay found on exec-path, spawned asynchronously. I put aplay last because it speaks raw ALSA and shares play-sound-file's failure mode, so falling back to it from a broken default PCM would buy nothing. With no external player installed, play-sound-file still runs, now taking chime-sound-device as its DEVICE argument. Two new defcustoms: chime-sound-player (auto, emacs, or a command name) and chime-sound-device. Both default to today's behavior for anyone whose sound already works. A player that spawns and then exits non-zero is reported through a process sentinel rather than failing silently. The sentinel doesn't fall back to play-sound-file, which would reintroduce the blocking ALSA call this change avoids. I pinned the notify tests to emacs so they mock one function and never spawn a real player. --- chime.el | 126 ++++++++++++++++++++++++++++++++++++++++++--- tests/test-chime-notify.el | 13 +++-- 2 files changed, 128 insertions(+), 11 deletions(-) diff --git a/chime.el b/chime.el index df05d30..823057c 100644 --- a/chime.el +++ b/chime.el @@ -725,6 +725,59 @@ supported by your system." :type '(choice (const :tag "No sound" nil) (file :tag "Sound file path"))) +(defcustom chime-sound-player 'auto + "How `chime-sound-file' is played. + +Emacs' built-in `play-sound-file' opens an ALSA device directly and +blocks until the clip finishes. On a system whose ALSA `default' PCM +does not resolve -- common under PipeWire, where nothing points +`pcm.!default' at the sound server -- it fails outright with \"No usable +sound device driver found\". An external player avoids both problems: it +speaks to the sound server and it runs asynchronously. + +The value is one of: + +`auto' Use the first available player from + `chime--sound-player-candidates', falling back to + `play-sound-file' when none is installed. The default. +`emacs' Always use `play-sound-file'. +STRING Name or path of an external player command. It is invoked as + \"COMMAND SOUND-FILE\". Falls back to `play-sound-file' when + the command is not on `exec-path'." + :package-version '(chime . "0.8.0") + :group 'chime + :type '(choice (const :tag "Auto-detect an external player" auto) + (const :tag "Emacs' built-in play-sound-file" emacs) + (string :tag "External player command")) + :set (lambda (symbol value) + (unless (or (memq value '(auto emacs)) (stringp value)) + (user-error "%s must be `auto', `emacs', or a command name, got: %S" + symbol value)) + (set-default symbol value))) + +(defcustom chime-sound-device nil + "Sound device passed to Emacs' built-in `play-sound-file'. + +Only consulted when `play-sound-file' is used -- that is, when +`chime-sound-player' is `emacs', or when it is `auto' and no external +player was found. nil lets the system choose, which on Linux means the +ALSA `default' PCM. + +Set this when `default' is unusable but a named device works. Under +PipeWire that is usually \"pipewire\" or \"pulse\"; test a candidate with + + (play-sound-file \"/path/to/chime.wav\" nil \"pipewire\") + +before setting it here." + :package-version '(chime . "0.8.0") + :group 'chime + :type '(choice (const :tag "System default" nil) + (string :tag "Device name")) + :set (lambda (symbol value) + (unless (or (null value) (stringp value)) + (user-error "%s must be nil or a string, got: %S" symbol value)) + (set-default symbol value))) + (defcustom chime-startup-delay 10 "Seconds to wait before first event check after chime-mode is enabled. This delay allows org-agenda-files and related infrastructure to finish @@ -1838,6 +1891,71 @@ list of internal event alists." (chime--apply-exclude-filters) (-map 'chime--gather-info)))) +;;;; Sound Playback + +(defconst chime--sound-player-candidates + '("pw-play" "paplay" "afplay" "aplay") + "External player commands tried, in order, when `chime-sound-player' is `auto'. +Each is invoked as \"COMMAND SOUND-FILE\". Sound-server players come +first: they respect the user's default sink and per-application volume. +`aplay' is last because it talks to ALSA directly and so shares +`play-sound-file''s failure mode on a system with no usable `default' +PCM. `afplay' is the macOS player.") + +(defun chime--find-sound-player () + "Return the external player command to use, or nil to use `play-sound-file'. +Honors `chime-sound-player': `emacs' selects no player, a string selects +that command when it is installed, and `auto' picks the first installed +candidate from `chime--sound-player-candidates'." + (cond + ((eq chime-sound-player 'emacs) nil) + ((stringp chime-sound-player) + (and (executable-find chime-sound-player) chime-sound-player)) + (t (cl-find-if #'executable-find chime--sound-player-candidates)))) + +(defun chime--play-sound-external (player file) + "Play FILE by spawning PLAYER asynchronously. +Return the process on success, or nil if the spawn failed. Playback +itself is not awaited, so a clip never blocks the notification. + +Because the clip is not awaited, a player that starts and *then* fails -- +an unsupported file, or a device that turns out to be unusable -- can +only be caught after the fact. A sentinel reports that non-zero exit, so +the failure lands in *Messages* rather than vanishing into silence." + (condition-case nil + (let ((process (start-process "chime-sound" nil player file))) + (set-process-query-on-exit-flag process nil) + (set-process-sentinel + process + (lambda (proc _event) + (when (memq (process-status proc) '(exit signal)) + (let ((status (process-exit-status proc))) + (unless (zerop status) + (message "chime: Failed to play sound: %s exited with status %s" + player status)))))) + process) + (error nil))) + +(defun chime--play-sound () + "Play `chime-sound-file', when one is configured and present. +Prefer an external player and fall back to Emacs' `play-sound-file'. +A playback failure is reported but never signalled -- losing the sound +must not cost the user the visual notification. + +Return the player process when an external player was used, t when Emacs +played the file, and nil when nothing was played." + (when (and chime-sound-file (file-exists-p chime-sound-file)) + (let* ((player (chime--find-sound-player)) + (process (and player + (chime--play-sound-external player chime-sound-file)))) + (or process + (condition-case err + (progn (play-sound-file chime-sound-file nil chime-sound-device) t) + (error + (message "chime: Failed to play sound: %s" + (error-message-string err)) + nil)))))) + ;;;; Notification Dispatch (defun chime--notify (msg-severity) @@ -1847,13 +1965,7 @@ notification text and SEVERITY is one of high, medium, or low." (let* ((event-msg (if (consp msg-severity) (car msg-severity) msg-severity)) (severity (if (consp msg-severity) (cdr msg-severity) 'medium))) ;; Play sound if a file is configured (set chime-sound-file to nil to disable) - (when chime-sound-file - (condition-case err - (when (file-exists-p chime-sound-file) - (play-sound-file chime-sound-file)) - (error - (message "chime: Failed to play sound: %s" - (error-message-string err))))) + (chime--play-sound) ;; Show visual notification (apply 'alert event-msg diff --git a/tests/test-chime-notify.el b/tests/test-chime-notify.el index 0b77993..822bf00 100644 --- a/tests/test-chime-notify.el +++ b/tests/test-chime-notify.el @@ -36,7 +36,12 @@ (setq chime-notification-icon nil) (setq chime-extra-alert-plist nil) ;; Use a simple test path for sound file - (setq chime-sound-file "/tmp/test-chime.wav")) + (setq chime-sound-file "/tmp/test-chime.wav") + ;; Route sound through `play-sound-file' so these tests mock one function + ;; and never spawn a real external player. The player-selection contract + ;; itself is covered in test-chime-sound.el. + (setq chime-sound-player 'emacs) + (setq chime-sound-device nil)) (defun test-chime-notify-teardown () "Teardown function run after each test." @@ -57,7 +62,7 @@ ((symbol-function 'file-exists-p) (lambda (file) t)) ;; Mock play-sound-file to track if called ((symbol-function 'play-sound-file) - (lambda (file) + (lambda (&rest _) (setq sound-played t))) ;; Mock alert to track if called ((symbol-function 'alert) @@ -146,7 +151,7 @@ ;; Mock file-exists-p to return nil ((symbol-function 'file-exists-p) (lambda (file) nil)) ((symbol-function 'play-sound-file) - (lambda (file) (setq sound-played t))) + (lambda (&rest _) (setq sound-played t))) ((symbol-function 'alert) (lambda (msg &rest args) (setq alert-called t)))) (chime--notify "Test Event") @@ -169,7 +174,7 @@ ((symbol-function 'file-exists-p) (lambda (file) t)) ;; Mock play-sound-file to throw error ((symbol-function 'play-sound-file) - (lambda (file) (error "Sound playback failed"))) + (lambda (&rest _) (error "Sound playback failed"))) ((symbol-function 'alert) (lambda (msg &rest args) (setq alert-called t)))) ;; Should not throw error -- cgit v1.2.3