Notification sound added. Docs updated.
This commit is contained in:
1 parent
4c9a267897
commit
81cc630319
29 files changed
+845
-57
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 := ¬ificationPlayback{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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user