Notification sound added. Docs updated.

This commit is contained in:
Storm Dragon committed 2026-11-07 00:27:44 -05:00
1 parent 4c9a267897
commit 81cc630319
29 files changed
+845 -57

No files matched your search

+3 -2
View File
@@ -355,8 +355,9 @@ echo "setting set reading#mode=highlight" | socat - UNIX-CLIENT:/tmp/fenrirscree
Set `remote.enabled = false` to disable remote control. Command and settings Set `remote.enabled = false` to disable remote control. Command and settings
requests can also be enabled separately in the `[remote]` section. requests can also be enabled separately in the `[remote]` section.
`command notify TEXT` speaks a notification and retains it for **Fenrir+N** and `command notify TEXT` plays `notification.wav` when sounds are enabled, waits
**Fenrir+Control+N**. History holds the latest 100 notifications for this reader for the sound to finish, then speaks the notification. It retains the message
for **Fenrir+N** and **Fenrir+Control+N**. History holds the latest 100 notifications for this reader
session, including messages received while speech is muted or temporarily session, including messages received while speech is muted or temporarily
silenced. `command say TEXT` remains self-voicing and does not add history. silenced. `command say TEXT` remains self-voicing and does not add history.
Received times use the current time format in Presentation's Time and date Received times use the current time format in Presentation's Time and date
+3 -3
View File
@@ -4,9 +4,9 @@ These 28 WAVs are derived from Python Fenrir's `config/sound/default` at commit
`c018f5fd294068febe0efb7961b06f87b834c4e8`: `c018f5fd294068febe0efb7961b06f87b834c4e8`:
https://git.stormux.org/storm/fenrir https://git.stormux.org/storm/fenrir
Python Fenrir identifies its license as LGPL v3. Original sound names and Python Fenrir identifies its license as LGPL v3. Original sound content is
content are retained; this copy changes gain, limits peaks, and encodes 24-bit retained. `Announce.wav` is renamed to `notification.wav` for notification
PCM. Normalized for Fenrir in 2026. alerts. This copy changes gain, limits peaks, and encodes 24-bit PCM. Normalized for Fenrir in 2026.
The target is -21.67 LUFS, about 8.89 dB below the original pack's median. The target is -21.67 LUFS, about 8.89 dB below the original pack's median.
This includes a further 10% reduction in perceived loudness (-1.52 dB) after This includes a further 10% reduction in perceived loudness (-1.52 dB) after
+2 -1
View File
@@ -15,7 +15,8 @@
"gain_db": -13.42 "gain_db": -13.42
}, },
{ {
"file": "Announce.wav", "file": "notification.wav",
"source_file": "Announce.wav",
"source_sha256": "021a66d00793de36e21f8905b8bbc3653b765c81e432c02cf588690e10533072", "source_sha256": "021a66d00793de36e21f8905b8bbc3653b765c81e432c02cf588690e10533072",
"before_lufs": -15.11, "before_lufs": -15.11,
"after_lufs": -21.65, "after_lufs": -21.65,
File renamed without changes.
+1
View File
@@ -19,3 +19,4 @@ copy_to_clipboard = "CopyToClipboard.wav"
paste_clipboard = "PasteClipboardOnScreen.wav" paste_clipboard = "PasteClipboardOnScreen.wav"
has_attributes = "has_attribute.wav" has_attributes = "has_attribute.wav"
misspelled = "mispell.wav" misspelled = "mispell.wav"
notification = "notification.wav"
+59 -5
View File
@@ -1,4 +1,4 @@
.\" Automatically generated by Pandoc 3.12.1 .\" Automatically generated by Pandoc 3.13
.\" .\"
.TH "fenrir" "1" "" "" "Fenrir User Manual" .TH "fenrir" "1" "" "" "Fenrir User Manual"
.SH Fenrir User Manual .SH Fenrir User Manual
@@ -59,15 +59,22 @@ It releases keyboard input on graphical virtual terminals and on text
consoles selected in \f[CR]screen.ignored_vts\f[R]. consoles selected in \f[CR]screen.ignored_vts\f[R].
.SS Keyboard layouts and help .SS Keyboard layouts and help
The \f[B]Fenrir key\f[R] is Insert, Super/Windows, or keypad 0 with The \f[B]Fenrir key\f[R] is Insert, Super/Windows, or keypad 0 with
NumLock off. NumLock off in the desktop layout.
The laptop layout uses Caps Lock instead of Super/Windows; Insert and
NumLock\-off keypad 0 also work.
Press Shift+Caps Lock on its own to toggle capitalization in the laptop
layout.
Shifted Fenrir shortcuts do not toggle capitalization.
Hold it while pressing the other keys in a shortcut. Hold it while pressing the other keys in a shortcut.
Bare keypad review keys also require NumLock off. Bare keypad review keys also require NumLock off.
.PP .PP
The desktop layout uses the numeric keypad. The desktop layout uses the numeric keypad.
The laptop layout uses Fenrir chords on the main keyboard. The laptop layout uses Fenrir chords on the main keyboard.
Select a layout with \f[CR]\-\-keyboard\-layout desktop\f[R] or Select a layout with \f[CR]\-\-keyboard\-layout desktop\f[R] or
\f[CR]\-\-keyboard\-layout laptop\f[R], or set \f[CR]\-\-keyboard\-layout laptop\f[R], choose Desktop or Laptop in the
\f[CR]keyboard.layout\f[R] in the settings file. Keyboard tab of the Fenrir Menu, or set \f[CR]keyboard.layout\f[R] in
the settings file.
Menu layout changes take effect on the next launch.
.PP .PP
Press \f[B]Fenrir+H\f[R] or \f[B]Fenrir+F1\f[R] to enter the tutorial. Press \f[B]Fenrir+H\f[R] or \f[B]Fenrir+F1\f[R] to enter the tutorial.
Command shortcuts are announced and described instead of executed. Command shortcuts are announced and described instead of executed.
@@ -225,12 +232,24 @@ Character echo, word echo, and cursor echo can be configured separately.
Punctuation levels are \f[CR]none\f[R], \f[CR]some\f[R], Punctuation levels are \f[CR]none\f[R], \f[CR]some\f[R],
\f[CR]most\f[R], and \f[CR]all\f[R]. \f[CR]most\f[R], and \f[CR]all\f[R].
At \f[CR]some\f[R] and \f[CR]most\f[R], punctuation inside words, URLs, At \f[CR]some\f[R] and \f[CR]most\f[R], punctuation inside words, URLs,
email addresses, and filenames is also named. email addresses, and filenames is also named, without adding sentence
pauses.
Apostrophes within words and single numeric separators such as those in Apostrophes within words and single numeric separators such as those in
\f[CR]3.14\f[R], \f[CR]1,000\f[R], and \f[CR]12:30\f[R] follow the \f[CR]3.14\f[R], \f[CR]1,000\f[R], and \f[CR]12:30\f[R] follow the
selected level. selected level.
Sentence punctuation follows the selected level too. Sentence punctuation follows the selected level too.
Multiple dots in numeric sequences are named, so \f[CR]2026.10.31\f[R]
is read as \(lq2026 dot 10 dot 31\(rq at \f[CR]some\f[R] and
\f[CR]most\f[R].
A percent sign immediately after a digit is named at \f[CR]some\f[R] and
\f[CR]most\f[R], so \f[CR]100%\f[R] is read as \(lq100 percent\(rq.
Character review always names punctuation. Character review always names punctuation.
Repeated\-symbol summaries handle multiple runs in symbol\-only tokens,
including diffstats such as \f[CR]+++++\-\-\-\-\-\f[R] and bracketed
bars such as \f[CR][#####\-\-\-\-\-]\f[R].
Each run must reach the configured minimum length and be selected by the
current punctuation level; shorter runs follow ordinary punctuation
handling.
Capital indicators, emoticons, repeated\-symbol summaries, indentation Capital indicators, emoticons, repeated\-symbol summaries, indentation
feedback, and attribute feedback are configurable in Reading. feedback, and attribute feedback are configurable in Reading.
.PP .PP
@@ -918,6 +937,31 @@ Earlier directories take priority.
A filename such as \f[CR]example__\-__key_y.sh\f[R] assigns the script A filename such as \f[CR]example__\-__key_y.sh\f[R] assigns the script
key plus Y. The script key defaults to Compose and is configured by key plus Y. The script key defaults to Compose and is configured by
\f[CR]general.script_keys\f[R]. \f[CR]general.script_keys\f[R].
The Keyboard tab of the Fenrir Menu has separate \f[B]Fenrir keys\f[R]
and \f[B]Script keys\f[R] checkbox groups for keypad Insert, Insert,
Caps Lock, and Super; Compose is also available for scripts.
Uncheck a key in one group before assigning it to the other.
A physical key cannot serve both roles, including through an alias such
as \f[CR]KEY_META\f[R] and \f[CR]KEY_RIGHTMETA\f[R].
At least one Fenrir key must remain selected.
After \f[B]OK\f[R] saves the changes, modifier assignments apply when
the menu closes and all held keys are released.
Cancel leaves them unchanged.
If X11 cannot acquire the new keys, Fenrir reports the failure and
retains the previous active assignments.
The saved choices remain in the file; reopen the menu to edit or retry
them.
Desktop/laptop layout changes still require a new launch.
.PP
The Fenrir\-key checkboxes save \f[CR]keyboard.fenrir_keys\f[R], which
overrides the layout\(cqs defaults.
When that setting is omitted, the layout supplies defaults and
explicitly configured script keys are excluded from them.
Existing custom script keys remain unchanged when editing the
checkboxes.
Keypad Insert acts as a modifier only with NumLock off.
Shift+Caps Lock toggles capitalization when Caps Lock serves either
role.
Scripts must be executable to run. Scripts must be executable to run.
Fenrir invokes them directly and passes the current account name as the Fenrir invokes them directly and passes the current account name as the
first argument. first argument.
@@ -926,6 +970,16 @@ Commands appear in the tutorial\(cqs script list.
.SS Notifications .SS Notifications
Send \f[CR]command notify TEXT\f[R] to speak and remember a Send \f[CR]command notify TEXT\f[R] to speak and remember a
notification. notification.
When sounds are enabled and their volume is above zero, Fenrir plays the
theme\(cqs \f[CR]notification\f[R] cue (\f[CR]notification.wav\f[R] in
the default theme), waits for the audio output to drain, then speaks the
message.
Sound playback runs outside the input loop.
Missing or failed sound output leaves speech available.
Speech mute or temporary silence keeps the sound and history, with no
spoken message.
Recalling a notification does not replay its sound.
.PP
\f[B]Fenrir+N\f[R] repeats the most recent notification. \f[B]Fenrir+N\f[R] repeats the most recent notification.
\f[B]Fenrir+Control+N\f[R] opens a list of the latest 100 notifications \f[B]Fenrir+Control+N\f[R] opens a list of the latest 100 notifications
for this reader session, newest first. for this reader session, newest first.
+10 -3
View File
@@ -647,9 +647,16 @@ script list.
### Notifications ### Notifications
Send `command notify TEXT` to speak and remember a notification. **Fenrir+N** Send `command notify TEXT` to speak and remember a notification. When sounds
repeats the most recent notification. **Fenrir+Control+N** opens a list of the are enabled and their volume is above zero, Fenrir plays the theme's
latest 100 notifications for this reader session, newest first. Each message `notification` cue (`notification.wav` in the default theme), waits for the
audio output to drain, then speaks the message. Sound playback runs outside
the input loop. Missing or failed sound output leaves speech available.
Speech mute or temporary silence keeps the sound and history, with no spoken
message. Recalling a notification does not replay its sound.
**Fenrir+N** repeats the most recent notification. **Fenrir+Control+N** opens
a list of the latest 100 notifications for this reader session, newest first. Each message
is followed by its received time, formatted using the current Presentation is followed by its received time, formatted using the current Presentation
Time and date preference. For example, `Hello world 01:38 PM` with 12-hour time, Time and date preference. For example, `Hello world 01:38 PM` with 12-hour time,
or `Hello world 13:38` with 24-hour time. or `Hello world 13:38` with 24-hour time.
+60 -20
View File
@@ -47,14 +47,19 @@ terminals and on text consoles selected in screen.ignored_vts.
Keyboard layouts and help Keyboard layouts and help
The Fenrir key is Insert, Super/Windows, or keypad 0 with NumLock off. The Fenrir key is Insert, Super/Windows, or keypad 0 with NumLock off in
Hold it while pressing the other keys in a shortcut. Bare keypad review the desktop layout. The laptop layout uses Caps Lock instead of
keys also require NumLock off. Super/Windows; Insert and NumLock-off keypad 0 also work. Press
Shift+Caps Lock on its own to toggle capitalization in the laptop
layout. Shifted Fenrir shortcuts do not toggle capitalization. Hold it
while pressing the other keys in a shortcut. Bare keypad review keys
also require NumLock off.
The desktop layout uses the numeric keypad. The laptop layout uses The desktop layout uses the numeric keypad. The laptop layout uses
Fenrir chords on the main keyboard. Select a layout with Fenrir chords on the main keyboard. Select a layout with
--keyboard-layout desktop or --keyboard-layout laptop, or set --keyboard-layout desktop or --keyboard-layout laptop, choose Desktop or
keyboard.layout in the settings file. Laptop in the Keyboard tab of the Fenrir Menu, or set keyboard.layout in
the settings file. Menu layout changes take effect on the next launch.
Press Fenrir+H or Fenrir+F1 to enter the tutorial. Command shortcuts are Press Fenrir+H or Fenrir+F1 to enter the tutorial. Command shortcuts are
announced and described instead of executed. Up and Down browse announced and described instead of executed. Up and Down browse
@@ -162,12 +167,19 @@ Speech and automatic reading
Character echo, word echo, and cursor echo can be configured separately. Character echo, word echo, and cursor echo can be configured separately.
Punctuation levels are none, some, most, and all. At some and most, Punctuation levels are none, some, most, and all. At some and most,
punctuation inside words, URLs, email addresses, and filenames is also punctuation inside words, URLs, email addresses, and filenames is also
named. Apostrophes within words and single numeric separators such as named, without adding sentence pauses. Apostrophes within words and
those in 3.14, 1,000, and 12:30 follow the selected level. Sentence single numeric separators such as those in 3.14, 1,000, and 12:30 follow
punctuation follows the selected level too. Character review always the selected level. Sentence punctuation follows the selected level too.
names punctuation. Capital indicators, emoticons, repeated-symbol Multiple dots in numeric sequences are named, so 2026.10.31 is read as
summaries, indentation feedback, and attribute feedback are configurable “2026 dot 10 dot 31” at some and most. A percent sign immediately after
in Reading. a digit is named at some and most, so 100% is read as “100 percent”.
Character review always names punctuation. Repeated-symbol summaries
handle multiple runs in symbol-only tokens, including diffstats such as
+++++----- and bracketed bars such as [#####-----]. Each run must reach
the configured minimum length and be selected by the current punctuation
level; shorter runs follow ordinary punctuation handling. Capital
indicators, emoticons, repeated-symbol summaries, indentation feedback,
and attribute feedback are configurable in Reading.
Fenrir+T announces the time; press twice quickly for the date. Fenrir+T announces the time; press twice quickly for the date.
Fenrir+Shift+V announces the Fenrir version. Fenrir+Shift+V announces the Fenrir version.
@@ -660,20 +672,48 @@ priority.
A filename such as example__-__key_y.sh assigns the script key plus Y. A filename such as example__-__key_y.sh assigns the script key plus Y.
The script key defaults to Compose and is configured by The script key defaults to Compose and is configured by
general.script_keys. Scripts must be executable to run. Fenrir invokes general.script_keys. The Keyboard tab of the Fenrir Menu has separate
them directly and passes the current account name as the first argument. Fenrir keys and Script keys checkbox groups for keypad Insert, Insert,
Commands appear in the tutorial’s script list. Caps Lock, and Super; Compose is also available for scripts. Uncheck a
key in one group before assigning it to the other. A physical key cannot
serve both roles, including through an alias such as KEY_META and
KEY_RIGHTMETA. At least one Fenrir key must remain selected. After OK
saves the changes, modifier assignments apply when the menu closes and
all held keys are released. Cancel leaves them unchanged. If X11 cannot
acquire the new keys, Fenrir reports the failure and retains the
previous active assignments. The saved choices remain in the file;
reopen the menu to edit or retry them. Desktop/laptop layout changes
still require a new launch.
The Fenrir-key checkboxes save keyboard.fenrir_keys, which overrides the
layout’s defaults. When that setting is omitted, the layout supplies
defaults and explicitly configured script keys are excluded from them.
Existing custom script keys remain unchanged when editing the
checkboxes. Keypad Insert acts as a modifier only with NumLock off.
Shift+Caps Lock toggles capitalization when Caps Lock serves either
role. Scripts must be executable to run. Fenrir invokes them directly
and passes the current account name as the first argument. Commands
appear in the tutorial’s script list.
Remote control Remote control
Notifications Notifications
Send command notify TEXT to speak and remember a notification. Fenrir+N Send command notify TEXT to speak and remember a notification. When
repeats the most recent notification. Fenrir+Control+N opens a list of sounds are enabled and their volume is above zero, Fenrir plays the
the latest 100 notifications for this reader session, newest first. Each theme’s notification cue (notification.wav in the default theme), waits
message is followed by its received time, formatted using the current for the audio output to drain, then speaks the message. Sound playback
Presentation Time and date preference. For example, Hello world 01:38 PM runs outside the input loop. Missing or failed sound output leaves
with 12-hour time, or Hello world 13:38 with 24-hour time. speech available. Speech mute or temporary silence keeps the sound and
history, with no spoken message. Recalling a notification does not
replay its sound.
Fenrir+N repeats the most recent notification. Fenrir+Control+N opens a
list of the latest 100 notifications for this reader session, newest
first. Each message is followed by its received time, formatted using
the current Presentation Time and date preference. For example,
Hello world 01:38 PM with 12-hour time, or Hello world 13:38 with
24-hour time.
Use Up/Down to browse, Enter to repeat the selected entry, and Escape or Use Up/Down to browse, Enter to repeat the selected entry, and Escape or
the Close button to leave. Tab and Shift+Tab move between controls. The the Close button to leave. Tab and Shift+Tab move between controls. The
+4
View File
@@ -19,6 +19,8 @@
package app package app
import ( import (
"context"
"git.stormux.org/storm/fenrir/internal/clipboard" "git.stormux.org/storm/fenrir/internal/clipboard"
"git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/config"
@@ -51,6 +53,7 @@ type Keyboards interface {
type Speaker interface { type Speaker interface {
Say(string) Say(string)
Say_after(string, func(context.Context))
Say_with_pitch(text string, pitch int) Say_with_pitch(text string, pitch int)
Configure(speech.Parameters) Configure(speech.Parameters)
Interrupt() Interrupt()
@@ -62,6 +65,7 @@ type Speaker interface {
type SoundPlayer interface { type SoundPlayer interface {
// Play reports whether the cue was accepted, so unavailable cues use speech. // Play reports whether the cue was accepted, so unavailable cues use speech.
Play(presentation.Cue) bool Play(presentation.Cue) bool
Play_wait(context.Context, presentation.Cue) error
Play_progress(float64) bool Play_progress(float64) bool
Play_indentation(int) bool Play_indentation(int) bool
Discard_progress() Discard_progress()
+12
View File
@@ -110,6 +110,11 @@ type fakeSounds struct {
func (f *fakeSounds) Set_volume(volume float64) { f.volume = volume } func (f *fakeSounds) Set_volume(volume float64) { f.volume = volume }
func (f *fakeSounds) Play_wait(_ context.Context, cue presentation.Cue) error {
f.Play(cue)
return nil
}
func (f *fakeSounds) Play(cue presentation.Cue) bool { func (f *fakeSounds) Play(cue presentation.Cue) bool {
if f.missing[cue] { if f.missing[cue] {
return false return false
@@ -523,6 +528,13 @@ func (f *fakeVoice) Say_with_pitch(text string, pitch int) {
f.Say(text) f.Say(text)
} }
func (f *fakeVoice) Say_after(text string, before func(context.Context)) {
if !f.muted {
before(context.Background())
f.Say(text)
}
}
func (f *fakeVoice) Say(text string) { func (f *fakeVoice) Say(text string) {
if !f.muted { if !f.muted {
f.messages = append(f.messages, text) f.messages = append(f.messages, text)
+9 -1
View File
@@ -54,6 +54,14 @@ type echoSpeaker struct {
} }
func (s *echoSpeaker) Say(text string) { func (s *echoSpeaker) Say(text string) {
s.Speaker.Say(s.prepare_text(text))
}
func (s *echoSpeaker) Say_after(text string, before func(context.Context)) {
s.Speaker.Say_after(s.prepare_text(text), before)
}
func (s *echoSpeaker) prepare_text(text string) string {
if text != "" { if text != "" {
s.last = text s.last = text
} }
@@ -65,7 +73,7 @@ func (s *echoSpeaker) Say(text string) {
} }
text = s.punctuation.Process_text(text, s.punctuationLevel, options) text = s.punctuation.Process_text(text, s.punctuationLevel, options)
} }
s.Speaker.Say(text) return text
} }
// Character feedback bypasses text expansion, including punctuation in names. // Character feedback bypasses text expansion, including punctuation in names.
+16
View File
@@ -4,6 +4,8 @@
package app package app
import ( import (
"context"
"log/slog"
"strings" "strings"
"time" "time"
@@ -11,6 +13,7 @@ import (
"git.stormux.org/storm/fenrir/internal/diagnostic" "git.stormux.org/storm/fenrir/internal/diagnostic"
"git.stormux.org/storm/fenrir/internal/i18n" "git.stormux.org/storm/fenrir/internal/i18n"
"git.stormux.org/storm/fenrir/internal/interaction" "git.stormux.org/storm/fenrir/internal/interaction"
"git.stormux.org/storm/fenrir/internal/presentation"
) )
const notificationLimit = 100 const notificationLimit = 100
@@ -33,6 +36,19 @@ func (a *application) notify(text string, received time.Time) {
} }
a.notifications = append(a.notifications, notification{text: text, received: received}) a.notifications = append(a.notifications, notification{text: text, received: received})
diagnostic.Record("notification received", "length", len(text), "retained", len(a.notifications)) diagnostic.Record("notification received", "length", len(text), "retained", len(a.notifications))
if a.settings.live.Presentation.Sound.Enabled && a.settings.live.Presentation.Sound.Volume > 0 && a.cues != nil {
if a.temporarySilence || a.muted {
a.play(presentation.Notification)
return
}
cues := a.cues
a.voice.Say_after(text, func(ctx context.Context) {
if err := cues.Play_wait(ctx, presentation.Notification); err != nil && ctx.Err() == nil {
slog.Warn("notification sound incomplete", "error", err, "at", time.Now())
}
})
return
}
a.present(text, "") a.present(text, "")
} }
+46
View File
@@ -20,8 +20,54 @@ import (
"git.stormux.org/storm/fenrir/internal/control" "git.stormux.org/storm/fenrir/internal/control"
"git.stormux.org/storm/fenrir/internal/input" "git.stormux.org/storm/fenrir/internal/input"
"git.stormux.org/storm/fenrir/internal/interaction" "git.stormux.org/storm/fenrir/internal/interaction"
"git.stormux.org/storm/fenrir/internal/presentation"
) )
func TestNotificationCuePrecedesSpeechAndOnlyPlaysForNewNotifications(t *testing.T) {
for _, mode := range []string{"enabled", "disabled", "zero volume", "missing cue", "unavailable", "muted", "silenced"} {
t.Run(mode, func(t *testing.T) {
a, voice := persistence_application(t, true)
voice.messages = nil
sounds := &fakeSounds{}
a.cues = sounds
switch mode {
case "disabled":
a.settings.live.Presentation.Sound.Enabled = false
case "zero volume":
a.settings.live.Presentation.Sound.Volume = 0
case "missing cue":
sounds.missing = map[presentation.Cue]bool{presentation.Notification: true}
case "unavailable":
a.cues = nil
case "muted":
a.muted, voice.muted = true, true
case "silenced":
a.temporarySilence = true
}
wantCue := mode == "enabled" || mode == "muted" || mode == "silenced"
voice.onSay = func(string) {
if wantCue && !reflect.DeepEqual(sounds.cues, []presentation.Cue{presentation.Notification}) {
t.Fatal("speech arrived before notification cue", sounds.cues)
}
}
if reply := runtime_request(a, "command notify Incoming message"); reply != "" {
t.Fatal(reply)
}
if (len(sounds.cues) == 1) != wantCue || len(a.notifications) != 1 {
t.Fatal("notification cue or history", sounds.cues, a.notifications)
}
if (len(voice.messages) == 1) != (mode != "muted" && mode != "silenced") {
t.Fatal("notification speech policy", voice.messages)
}
a.last_notification()
runtime_request(a, "command say Self-voice")
if (len(sounds.cues) == 1) != wantCue || len(a.notifications) != 1 {
t.Fatal("recall or self-voice replayed notification cue", sounds.cues, a.notifications)
}
})
}
}
func TestNotificationThroughPrivateSocket(t *testing.T) { func TestNotificationThroughPrivateSocket(t *testing.T) {
a, voice := persistence_application(t, true) a, voice := persistence_application(t, true)
voice.messages = nil voice.messages = nil
+8 -7
View File
@@ -45,13 +45,14 @@ import (
// disabledVoice allows sound-only operation without opening a speech driver. // disabledVoice allows sound-only operation without opening a speech driver.
type disabledVoice struct{} type disabledVoice struct{}
func (disabledVoice) Say(string) {} func (disabledVoice) Say(string) {}
func (disabledVoice) Say_with_pitch(string, int) {} func (disabledVoice) Say_after(string, func(context.Context)) {}
func (disabledVoice) Configure(speech.Parameters) {} func (disabledVoice) Say_with_pitch(string, int) {}
func (disabledVoice) Interrupt() {} func (disabledVoice) Configure(speech.Parameters) {}
func (disabledVoice) Toggle_mute() bool { return true } func (disabledVoice) Interrupt() {}
func (disabledVoice) Errors() <-chan error { return nil } func (disabledVoice) Toggle_mute() bool { return true }
func (disabledVoice) Close() {} func (disabledVoice) Errors() <-chan error { return nil }
func (disabledVoice) Close() {}
func Start(ctx context.Context, settings config.Settings, configPath string, bindings input.Layout) error { func Start(ctx context.Context, settings config.Settings, configPath string, bindings input.Layout) error {
return Start_with_ready(ctx, settings, configPath, bindings, nil) return Start_with_ready(ctx, settings, configPath, bindings, nil)
+1
View File
@@ -49,6 +49,7 @@ type Spelling struct {
} }
const ( const (
Notification Cue = "notification"
Ready Cue = "ready" Ready Cue = "ready"
Stopped Cue = "stopped" Stopped Cue = "stopped"
TTYChanged Cue = "tty_changed" TTYChanged Cue = "tty_changed"
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"context"
"errors"
"math"
"time"
"github.com/jfreymuth/pulse"
"git.stormux.org/storm/fenrir/internal/presentation"
)
var errNotificationDrain = errors.New("notification playback needs draining")
type notificationPlayback struct {
ctx context.Context
drained chan struct{}
err error // published by closing drained
}
// Play_wait runs on the speech worker, never the reader's input loop. A
// completion means the output device drained, rather than just received samples.
// Missing, silent or disconnected sound leaves speech available immediately.
func (s *Service) Play_wait(ctx context.Context, cue presentation.Cue) error {
if s == nil || s.closed.Load() || s.finishing.Load() || math.Float32frombits(s.volume.Load()) == 0 {
return nil
}
item := s.clips[cue]
if item == nil || ctx.Err() != nil {
return ctx.Err()
}
// Allow an in-flight startup cue to finish, plus this cue and a drain margin.
sampleCount := len(item.samples)
if startup := s.clips[presentation.Ready]; startup != nil {
sampleCount += len(startup.samples)
}
ctx, cancel := context.WithTimeout(ctx, time.Second+time.Duration(sampleCount)*time.Second/(sampleRate*2))
defer cancel()
request := &notificationPlayback{ctx: ctx, drained: make(chan struct{})}
queued := &clip{samples: item.samples, notification: request}
if !s.notification.CompareAndSwap(nil, queued) {
return errors.New("notification sound already pending")
}
defer s.notification.CompareAndSwap(queued, nil)
select {
case <-request.drained:
return request.err
case <-s.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) drain_notification(request *notificationPlayback, drain func(context.Context) error) error {
ctx, cancel := context.WithTimeout(context.Background(), pcmTimeout)
defer cancel()
request.err = drain(ctx)
close(request.drained)
return request.err
}
// Pulse's protocol reader sends buffer requests through an unbuffered channel.
// A callback blocked for the whole drain would also block its drain reply. Yield
// empty reads at a paced interval so requests are consumed without feeding any
// more samples (including silence) until the server acknowledges the drain.
func (s *Service) read_pulse_samples(samples []float32, drainRequests chan<- *notificationPlayback) (int, error) {
if s.draining != nil {
timer := time.NewTimer(audioPeriod)
defer timer.Stop()
select {
case <-s.draining.drained:
s.draining = nil
case <-s.done:
return 0, pulse.EndOfData
case <-timer.C:
return 0, nil
}
}
count, err := s.read_samples(samples)
if err != errNotificationDrain {
return count, err
}
select {
case drainRequests <- s.draining:
return 0, nil
case <-s.done:
return 0, pulse.EndOfData
}
}
+211
View File
@@ -0,0 +1,211 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"context"
"errors"
"os"
"slices"
"testing"
"time"
"git.stormux.org/storm/fenrir/internal/presentation"
)
func notification_service(t *testing.T) *Service {
t.Helper()
s := &Service{clips: map[presentation.Cue]*clip{
presentation.Notification: {samples: []float32{.1, -.1, .2, -.2}},
presentation.Top: {samples: []float32{.3, -.3}},
presentation.Stopped: {samples: []float32{.4, -.4}},
}, done: make(chan struct{}), errors: make(chan error, 1)}
s.Set_volume(1)
t.Cleanup(s.Close)
return s
}
func queue_notification(t *testing.T, s *Service, ctx context.Context) <-chan error {
t.Helper()
result := make(chan error, 1)
go func() { result <- s.Play_wait(ctx, presentation.Notification) }()
deadline := time.NewTimer(time.Second)
defer deadline.Stop()
tick := time.NewTicker(time.Millisecond)
defer tick.Stop()
for s.notification.Load() == nil {
select {
case err := <-result:
t.Fatal("notification was rejected", err)
case <-deadline.C:
t.Fatal("notification did not queue")
case <-tick.C:
}
}
return result
}
func TestNotificationSoundPreservesTailAndWaitsForDeviceDrain(t *testing.T) {
s := notification_service(t)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
result := queue_notification(t, s, ctx)
buffer := make([]float32, 2)
s.read_samples(buffer)
if !slices.Equal(buffer, []float32{.1, -.1}) {
t.Fatal(buffer)
}
s.Play(presentation.Top)
s.read_samples(buffer)
if !slices.Equal(buffer, []float32{.2, -.2}) {
t.Fatal("cue interrupted notification tail", buffer)
}
if _, err := s.read_samples(buffer); err != errNotificationDrain {
t.Fatal(err)
}
select {
case <-result:
t.Fatal("speech released before drain")
default:
}
if err := s.drain_notification(s.draining, func(context.Context) error {
select {
case <-result:
t.Fatal("speech released during drain")
default:
}
return nil
}); err != nil {
t.Fatal(err)
}
if err := <-result; err != nil {
t.Fatal(err)
}
s.read_samples(buffer)
if !slices.Equal(buffer, []float32{.3, -.3}) {
t.Fatal("ordinary cue did not resume", buffer)
}
}
func TestNotificationCancellationAndCloseReleaseWaiter(t *testing.T) {
s := notification_service(t)
ctx, cancel := context.WithCancel(context.Background())
result := queue_notification(t, s, ctx)
cancel()
if err := <-result; !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if s.notification.Load() != nil {
t.Fatal("canceled cue remained pending")
}
result = queue_notification(t, s, context.Background())
s.Close()
if err := <-result; err != nil {
t.Fatal(err)
}
}
func TestNotificationMissingAndZeroVolumeSkipWaiting(t *testing.T) {
s := notification_service(t)
if err := s.Play_wait(context.Background(), presentation.Blank); err != nil {
t.Fatal(err)
}
s.Set_volume(0)
if err := s.Play_wait(context.Background(), presentation.Notification); err != nil {
t.Fatal(err)
}
if s.notification.Load() != nil {
t.Fatal("silent cue was queued")
}
}
func TestPulseNotificationBoundaryStopsFeedingUntilDrained(t *testing.T) {
s := notification_service(t)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
result := queue_notification(t, s, ctx)
drainRequests := make(chan *notificationPlayback, 1)
buffer := make([]float32, 4)
s.read_pulse_samples(buffer, drainRequests)
if count, err := s.read_pulse_samples(buffer, drainRequests); count != 0 || err != nil {
t.Fatal(count, err)
}
var request *notificationPlayback
select {
case request = <-drainRequests:
case <-ctx.Done():
t.Fatal("Pulse did not request drain")
}
if count, err := s.read_pulse_samples(buffer, drainRequests); count != 0 || err != nil {
t.Fatal("Pulse fed silence before drain", count, err)
}
s.drain_notification(request, func(context.Context) error { return nil })
if count, err := s.read_pulse_samples(buffer, drainRequests); count != len(buffer) || err != nil {
t.Fatal("Pulse did not resume", count, err)
}
if err := <-result; err != nil {
t.Fatal(err)
}
}
func TestPCMNotificationDrainsAndContinuesPlayback(t *testing.T) {
s := notification_service(t)
pcm := &fakePCM{}
s.start_pcm(pcm)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
for range 2 {
if err := s.Play_wait(ctx, presentation.Notification); err != nil {
t.Fatal(err)
}
}
if err := s.Finish(ctx); err != nil {
t.Fatal(err)
}
s.Close()
if pcm.drains != 3 || pcm.preparations != 2 {
t.Fatal(pcm.drains, pcm.preparations)
}
}
func TestALSANullNotificationAndShutdown(t *testing.T) {
s, err := Open([]string{"../../config/sound/default"}, Options{Driver: "alsa", Device: "null", Volume: .7})
if err != nil {
t.Fatal(err)
}
defer s.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for range 2 {
if err := s.Play_wait(ctx, presentation.Notification); err != nil {
t.Fatal(err)
}
}
if err := s.Finish(ctx); err != nil {
t.Fatal(err)
}
}
func TestPrivatePulseNotificationAndShutdown(t *testing.T) {
server := os.Getenv("FENRIR_TEST_PULSE_SERVER")
if server == "" {
t.Skip("run tests/native_notification_sound.py for a private null sink")
}
t.Setenv("PULSE_SERVER", server)
s, err := Open([]string{"../../config/sound/default"}, Options{Driver: "pulse", Device: "default", Volume: .7})
if err != nil {
t.Fatal(err)
}
defer s.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for range 2 {
if err := s.Play_wait(ctx, presentation.Notification); err != nil {
t.Fatal(err)
}
}
if err := s.Finish(ctx); err != nil {
t.Fatal(err)
}
}
+13
View File
@@ -62,6 +62,19 @@ func (s *Service) run_pcm(pcm pcmOutput) error {
case <-tick.C: case <-tick.C:
} }
count, err := s.read_samples(samples) count, err := s.read_samples(samples)
if err == errNotificationDrain {
request := s.draining
s.draining = nil
if err := s.drain_notification(request, func(ctx context.Context) error {
if err := s.drain_pcm(ctx, pcm); err != nil {
return err
}
return pcm.prepare()
}); err != nil {
return err
}
continue
}
if err == pulse.EndOfData { if err == pulse.EndOfData {
ctx, cancel := context.WithTimeout(context.Background(), pcmTimeout) ctx, cancel := context.WithTimeout(context.Background(), pcmTimeout)
defer cancel() defer cancel()
+8
View File
@@ -19,6 +19,7 @@ import (
type soundBackend interface { type soundBackend interface {
Play(presentation.Cue) bool Play(presentation.Cue) bool
Play_wait(context.Context, presentation.Cue) error
Play_progress(float64) bool Play_progress(float64) bool
Play_indentation(int) bool Play_indentation(int) bool
Discard_progress() Discard_progress()
@@ -157,6 +158,13 @@ func (p *Player) Play(cue presentation.Cue) bool {
return false return false
} }
func (p *Player) Play_wait(ctx context.Context, cue presentation.Cue) error {
if connection := p.connection.Load(); connection != nil {
return connection.backend.Play_wait(ctx, cue)
}
return nil
}
func (p *Player) Play_progress(value float64) bool { func (p *Player) Play_progress(value float64) bool {
if connection := p.connection.Load(); connection != nil { if connection := p.connection.Load(); connection != nil {
return connection.backend.Play_progress(value) return connection.backend.Play_progress(value)
+10 -9
View File
@@ -27,15 +27,16 @@ type fakeBackend struct {
func fake_backend() *fakeBackend { func fake_backend() *fakeBackend {
return &fakeBackend{errors: make(chan error, 1), closed: make(chan struct{})} return &fakeBackend{errors: make(chan error, 1), closed: make(chan struct{})}
} }
func (f *fakeBackend) Play(presentation.Cue) bool { f.plays.Add(1); return true } func (f *fakeBackend) Play(presentation.Cue) bool { f.plays.Add(1); return true }
func (f *fakeBackend) Play_progress(float64) bool { return true } func (f *fakeBackend) Play_wait(context.Context, presentation.Cue) error { f.plays.Add(1); return nil }
func (f *fakeBackend) Play_indentation(int) bool { return true } func (f *fakeBackend) Play_progress(float64) bool { return true }
func (f *fakeBackend) Discard_progress() {} func (f *fakeBackend) Play_indentation(int) bool { return true }
func (f *fakeBackend) Clear_progress() {} func (f *fakeBackend) Discard_progress() {}
func (f *fakeBackend) Set_volume(volume float64) { f.volume.Store(math.Float64bits(volume)) } func (f *fakeBackend) Clear_progress() {}
func (f *fakeBackend) Errors() <-chan error { return f.errors } func (f *fakeBackend) Set_volume(volume float64) { f.volume.Store(math.Float64bits(volume)) }
func (f *fakeBackend) Finish(context.Context) error { return nil } func (f *fakeBackend) Errors() <-chan error { return f.errors }
func (f *fakeBackend) Close() { f.once.Do(func() { close(f.closed) }) } func (f *fakeBackend) Finish(context.Context) error { return nil }
func (f *fakeBackend) Close() { f.once.Do(func() { close(f.closed) }) }
func wait_player(t *testing.T, player *Player, backend *fakeBackend) { func wait_player(t *testing.T, player *Player, backend *fakeBackend) {
t.Helper() t.Helper()
+8 -1
View File
@@ -36,7 +36,9 @@ func open_pulse(s *Service, _ string) error {
if err != nil { if err != nil {
return fmt.Errorf("connect sound server: %w", err) return fmt.Errorf("connect sound server: %w", err)
} }
stream, err := client.NewPlayback(pulse.Float32Reader(s.read_samples), pulse.PlaybackStereo, pulse.PlaybackSampleRate(sampleRate), pulse.PlaybackLatency(0.02), pulse.PlaybackMediaName("Fenrir sound cues")) drainRequests := make(chan *notificationPlayback, 1)
read := func(samples []float32) (int, error) { return s.read_pulse_samples(samples, drainRequests) }
stream, err := client.NewPlayback(pulse.Float32Reader(read), pulse.PlaybackStereo, pulse.PlaybackSampleRate(sampleRate), pulse.PlaybackLatency(0.02), pulse.PlaybackMediaName("Fenrir sound cues"))
if err != nil { if err != nil {
client.Close() client.Close()
return err return err
@@ -56,6 +58,11 @@ func open_pulse(s *Service, _ string) error {
select { select {
case <-s.done: case <-s.done:
return return
case request := <-drainRequests:
if err := s.drain_notification(request, s.drainBackend); err != nil {
s.errors <- fmt.Errorf("notification sound drain: %w", err)
return
}
case <-tick.C: case <-tick.C:
if stream.Closed() { if stream.Closed() {
s.errors <- fmt.Errorf("sound server connection closed") s.errors <- fmt.Errorf("sound server connection closed")
+24 -2
View File
@@ -38,6 +38,8 @@ import (
type Service struct { type Service struct {
clips map[presentation.Cue]*clip clips map[presentation.Cue]*clip
pending atomic.Pointer[clip] pending atomic.Pointer[clip]
notification atomic.Pointer[clip]
draining *notificationPlayback // audio callback owns this boundary
tones [101]*clip tones [101]*clip
indentationTones [101]*clip indentationTones [101]*clip
progress atomic.Pointer[clip] progress atomic.Pointer[clip]
@@ -126,6 +128,19 @@ func (s *Service) read_samples(samples []float32) (int, error) {
if s.closed.Load() { if s.closed.Load() {
return len(samples), nil return len(samples), nil
} }
// Notification cues retain their tail despite ordinary cue updates. Their
// boundary drains the device before speech is released by Play_wait.
if s.current != nil && s.current.notification != nil {
request := s.current.notification
if request.ctx.Err() != nil || s.finishing.Load() {
s.offset = len(s.current.samples)
}
if s.offset == len(s.current.samples) {
s.draining, s.current = request, nil
return 0, errNotificationDrain
}
return s.copy_samples(samples)
}
if s.cancelProgress.Swap(false) && s.progressing { if s.cancelProgress.Swap(false) && s.progressing {
s.current, s.progressing = nil, false s.current, s.progressing = nil, false
} }
@@ -137,7 +152,9 @@ func (s *Service) read_samples(samples []float32) (int, error) {
s.current, s.offset, s.starting, s.progressing = next, 0, true, false s.current, s.offset, s.starting, s.progressing = next, 0, true, false
} }
if !s.starting { if !s.starting {
if next := s.pending.Swap(nil); next != nil { if next := s.notification.Swap(nil); next != nil && next.notification.ctx.Err() == nil && !s.finishing.Load() {
s.current, s.offset, s.progressing = next, 0, false
} else if next := s.pending.Swap(nil); next != nil {
s.current, s.offset, s.progressing = next, 0, false s.current, s.offset, s.progressing = next, 0, false
} }
} }
@@ -151,6 +168,10 @@ func (s *Service) read_samples(samples []float32) (int, error) {
s.current = nil s.current = nil
return 0, pulse.EndOfData return 0, pulse.EndOfData
} }
return s.copy_samples(samples)
}
func (s *Service) copy_samples(samples []float32) (int, error) {
if s.current != nil { if s.current != nil {
count := copy(samples, s.current.samples[s.offset:]) count := copy(samples, s.current.samples[s.offset:])
volume := math.Float32frombits(s.volume.Load()) volume := math.Float32frombits(s.volume.Load())
@@ -161,7 +182,7 @@ func (s *Service) read_samples(samples []float32) (int, error) {
samples[index] *= volume samples[index] *= volume
} }
s.offset += count s.offset += count
if s.offset == len(s.current.samples) && s.current.finished == nil { if s.offset == len(s.current.samples) && s.current.finished == nil && s.current.notification == nil {
s.current = nil s.current = nil
s.starting, s.progressing = false, false s.starting, s.progressing = false, false
} }
@@ -206,6 +227,7 @@ func (s *Service) Close() {
return return
} }
s.pending.Store(nil) s.pending.Store(nil)
s.notification.Store(nil)
s.Clear_progress() s.Clear_progress()
s.startup.Store(nil) s.startup.Store(nil)
if s.done != nil { if s.done != nil {
+1
View File
@@ -19,3 +19,4 @@ copy_to_clipboard = "CopyToClipboard.wav"
paste_clipboard = "PasteClipboardOnScreen.wav" paste_clipboard = "PasteClipboardOnScreen.wav"
has_attributes = "has_attribute.wav" has_attributes = "has_attribute.wav"
misspelled = "mispell.wav" misspelled = "mispell.wav"
notification = "notification.wav"
+3 -2
View File
@@ -41,8 +41,9 @@ const sampleRate = 48000
const maxDuration = 10 * time.Second const maxDuration = 10 * time.Second
type clip struct { type clip struct {
samples []float32 samples []float32
finished chan struct{} // only the final shutdown clip has a completion signal finished chan struct{} // shutdown sample submission
notification *notificationPlayback
} }
type iconMap struct { type iconMap struct {
Icons map[presentation.Cue]string `toml:"icons"` Icons map[presentation.Cue]string `toml:"icons"`
+1 -1
View File
@@ -32,7 +32,7 @@ import (
func TestBundledNormalizedPackDecodesAndContainsEveryCue(t *testing.T) { func TestBundledNormalizedPackDecodesAndContainsEveryCue(t *testing.T) {
root := "../../config/sound/default" root := "../../config/sound/default"
clips, err := load_theme([]string{root}) clips, err := load_theme([]string{root})
if err != nil || len(clips) != 19 { if err != nil || len(clips) != 20 {
t.Fatal(len(clips), err) t.Fatal(len(clips), err)
} }
paths, err := filepath.Glob(filepath.Join(root, "*.wav")) paths, err := filepath.Glob(filepath.Join(root, "*.wav"))
+86
View File
@@ -0,0 +1,86 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package speech
import (
"context"
"reflect"
"testing"
"time"
)
func TestSpeechPreludeFinishesBeforeSpeechAndPreservesFIFO(t *testing.T) {
fake := new_recording_transport()
service := New(fake)
defer service.Close()
release := make(chan struct{})
service.Say_after("notification", func(ctx context.Context) {
fake.record("sound:start")
select {
case <-release:
case <-ctx.Done():
}
fake.record("sound:drained")
})
service.Say("following text")
wait_event(t, fake.changed, "sound:start")
fake.mu.Lock()
events := append([]string(nil), fake.events...)
fake.mu.Unlock()
if !reflect.DeepEqual(events, []string{"sound:start"}) {
t.Fatal(events)
}
close(release)
wait_event(t, fake.changed, "sent:following text")
fake.mu.Lock()
defer fake.mu.Unlock()
want := []string{"sound:start", "sound:drained", "say:notification", "sent:notification", "say:following text", "sent:following text"}
if !reflect.DeepEqual(fake.events, want) {
t.Fatal(fake.events)
}
}
func TestSpeechPreludeInterruptMuteAndCloseDiscardDelayedSpeech(t *testing.T) {
for _, action := range []string{"interrupt", "mute", "close"} {
t.Run(action, func(t *testing.T) {
fake := new_recording_transport()
service := New(fake)
defer service.Close()
entered, stopped := make(chan struct{}), make(chan struct{})
service.Say_after("discarded notification", func(ctx context.Context) {
close(entered)
<-ctx.Done()
close(stopped)
})
<-entered
switch action {
case "interrupt":
service.Interrupt()
case "mute":
service.Toggle_mute()
case "close":
service.Close()
}
select {
case <-stopped:
case <-time.After(time.Second):
t.Fatal("sound wait was not canceled")
}
if action == "mute" {
service.Toggle_mute()
}
if action != "close" {
service.Say("fresh review")
wait_event(t, fake.changed, "sent:fresh review")
}
fake.mu.Lock()
defer fake.mu.Unlock()
for _, event := range fake.events {
if event == "say:discarded notification" {
t.Fatal("late notification speech escaped cancellation")
}
}
})
}
}
+1
View File
@@ -72,6 +72,7 @@ func TestRecoveryKeepsAdmissionResponsiveAndDropsOutageSpeech(t *testing.T) {
service.Toggle_mute() service.Toggle_mute()
service.Say_with_pitch("fresh", 60) service.Say_with_pitch("fresh", 60)
wait_event(t, fresh.changed, "sent:fresh") wait_event(t, fresh.changed, "sent:fresh")
wait_event(t, fresh.changed, "pitch:10")
fresh.mu.Lock() fresh.mu.Lock()
joined := strings.Join(fresh.events, "|") joined := strings.Join(fresh.events, "|")
fresh.mu.Unlock() fresh.mu.Unlock()
+30
View File
@@ -55,6 +55,7 @@ type Service struct {
parameters *Parameters parameters *Parameters
generation, canceled uint64 generation, canceled uint64
muted, closed bool muted, closed bool
beforeCancel context.CancelFunc
wake, cancelWake chan struct{} wake, cancelWake chan struct{}
done chan struct{} done chan struct{}
errors chan error errors chan error
@@ -90,6 +91,7 @@ func (s *Service) Replace(transport Transport) error {
type utterance struct { type utterance struct {
text string text string
before func(context.Context)
queuedAt time.Time queuedAt time.Time
pitch *int pitch *int
parameters *Parameters parameters *Parameters
@@ -132,6 +134,12 @@ func (s *Service) Say(text string) {
s.enqueue(utterance{text: text}) s.enqueue(utterance{text: text})
} }
// Say_after keeps a cancellable sound prelude and its speech in one FIFO
// entry. Neither the prelude nor later speech runs on the input dispatcher.
func (s *Service) Say_after(text string, before func(context.Context)) {
s.enqueue(utterance{text: text, before: before})
}
// Say_with_parameters previews one utterance without changing saved or live // Say_with_parameters previews one utterance without changing saved or live
// defaults. The serialized worker restores its latest normal parameters. // defaults. The serialized worker restores its latest normal parameters.
func (s *Service) Say_with_parameters(text string, parameters Parameters) { func (s *Service) Say_with_parameters(text string, parameters Parameters) {
@@ -176,6 +184,9 @@ func (s *Service) Interrupt() {
} }
func (s *Service) interrupt() { func (s *Service) interrupt() {
if s.beforeCancel != nil {
s.beforeCancel()
}
clear(s.queue) clear(s.queue)
s.queue = nil s.queue = nil
s.generation++ s.generation++
@@ -342,10 +353,29 @@ func (s *Service) deliver() {
// must account for this send even if it hasn't reached the socket. // must account for this send even if it hasn't reached the socket.
s.dispatch.Lock() s.dispatch.Lock()
generation := s.generation generation := s.generation
ctx := s.recoveryContext
var cancel context.CancelFunc
if item.before != nil {
ctx, cancel = context.WithCancel(ctx)
}
s.beforeCancel = cancel
s.mu.Unlock() s.mu.Unlock()
diagnostic.Timing("speech queue wait", item.queuedAt, "generation", generation) diagnostic.Timing("speech queue wait", item.queuedAt, "generation", generation)
if item.before != nil {
item.before(ctx)
}
if ctx.Err() != nil {
if cancel != nil {
cancel()
}
s.dispatch.Unlock()
continue
}
started := time.Now() started := time.Now()
err := s.say(item, normalPitch, currentParameters) err := s.say(item, normalPitch, currentParameters)
if cancel != nil {
cancel()
}
diagnostic.Timing("speech send", started, "generation", generation) diagnostic.Timing("speech send", started, "generation", generation)
s.dispatch.Unlock() s.dispatch.Unlock()
if err != nil { if err != nil {
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Exercise Fenrir's Pulse driver against a private PipeWire null sink."""
import json
import os
import shutil
from pathlib import Path
import subprocess
import tempfile
import threading
import time
def main():
repository = Path(__file__).resolve().parents[1]
for executable in ('pipewire', 'pw-link', 'pw-cli', 'pw-dump', 'go'):
if not shutil.which(executable):
raise SystemExit('Missing prerequisite: ' + executable)
with tempfile.TemporaryDirectory(prefix='fenrir-notification-pulse-') as directory:
root = Path(directory)
(root / 'client.conf').write_bytes(Path('/usr/share/pipewire/client.conf').read_bytes())
config = root / 'pipewire.conf'
config.write_text('''
context.properties = { core.daemon = true core.name = fenrir-notification-test support.dbus = false }
context.spa-libs = { audio.convert.* = audioconvert/libspa-audioconvert support.* = support/libspa-support }
context.modules = [
{ name = libpipewire-module-protocol-native }
{ name = libpipewire-module-client-node }
{ name = libpipewire-module-adapter }
{ name = libpipewire-module-metadata }
{ name = libpipewire-module-spa-node-factory }
{ name = libpipewire-module-link-factory }
{ name = libpipewire-module-access args = { access.legacy = true } }
{ name = libpipewire-module-protocol-pulse args = { server.address = [ "unix:SOCKET" ] } }
]
context.objects = [
{ factory = spa-node-factory args = { factory.name = support.node.driver node.name = Dummy-Driver priority.driver = 200000 } }
{ factory = adapter args = {
factory.name = support.null-audio-sink
node.name = notification-null
node.description = "Notification test null sink"
media.class = Audio/Sink
audio.position = [ FL FR ]
adapter.auto-port-config = { mode = dsp monitor = true position = preserve }
} }
]
'''.replace('SOCKET', str(root / 'pulse.sock')))
environment = dict(os.environ, XDG_RUNTIME_DIR=str(root), PIPEWIRE_RUNTIME_DIR=str(root),
PIPEWIRE_REMOTE='fenrir-notification-test', PIPEWIRE_CONFIG_DIR=str(root),
FENRIR_TEST_PULSE_SERVER='unix:' + str(root / 'pulse.sock'))
stop = threading.Event()
with (root / 'server.log').open('w+') as log:
server = subprocess.Popen(['pipewire', '-c', str(config)], env=environment, stdout=log, stderr=log)
def connect_ports():
linked = set()
configured = set()
while not stop.is_set():
dump = subprocess.run(['pw-dump', '-r', 'fenrir-notification-test'], env=environment, capture_output=True, text=True, timeout=2)
nodes = [(o['id'], o.get('info',{}).get('props',{})) for o in json.loads(dump.stdout or '[]') if o['type'].endswith(':Node')]
for nodeId, props in nodes:
if props.get('media.class') == 'Stream/Output/Audio' and nodeId not in configured:
portConfig = '{ direction = Output mode = dsp format = { mediaType = audio mediaSubtype = raw format = F32P rate = 48000 channels = 2 position = [ FL FR ] } }'
result = subprocess.run(['pw-cli', '-r', 'fenrir-notification-test', 'set-param', str(nodeId), 'PortConfig', portConfig], env=environment, capture_output=True, text=True, timeout=2)
if result.returncode:
raise RuntimeError(result.stderr)
configured.add(nodeId)
result = subprocess.run(['pw-link', '-r', 'fenrir-notification-test', '-o'], env=environment,
capture_output=True, text=True, timeout=2)
for line in result.stdout.splitlines():
port = line.strip()
if ':output_' in port:
channel = port.rsplit('_', 1)[-1]
target = 'notification-null:playback_' + channel
pair = (port, target)
if pair not in linked:
link = subprocess.run(['pw-link', '-r', 'fenrir-notification-test', port, target],
env=environment, capture_output=True, timeout=2)
if link.returncode == 0:
linked.add(pair)
stop.wait(.05)
def link_ports():
try:
connect_ports()
except Exception as error:
linkErrors.append(str(error))
stop.set()
linker = None
linkErrors = []
try:
deadline = time.monotonic() + 5
while not (root / 'pulse.sock').exists():
if server.poll() is not None or time.monotonic() >= deadline:
log.seek(0)
raise RuntimeError(log.read())
time.sleep(.02)
linker = threading.Thread(target=link_ports)
linker.start()
result = subprocess.run(['go', 'test', '-race', './internal/sound', '-run', '^TestPrivatePulseNotificationAndShutdown$', '-count=1', '-timeout=20s', '-v'],
env=environment, cwd=repository, capture_output=True, text=True, timeout=45)
print(result.stdout)
print(result.stderr)
if linkErrors:
raise RuntimeError("Private port linking failed: " + "; ".join(linkErrors))
if result.returncode:
log.seek(0)
print(log.read())
raise SystemExit(result.returncode)
finally:
stop.set()
if linker:
linker.join(timeout=5)
server.terminate()
try:
server.wait(timeout=5)
except subprocess.TimeoutExpired:
server.kill()
server.wait()
if __name__ == "__main__":
main()