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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user