212 lines
5.7 KiB
Go
212 lines
5.7 KiB
Go
// 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)
|
|
}
|
|
}
|