152 lines
4.2 KiB
Go
152 lines
4.2 KiB
Go
// Fenrir console screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package sound
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"slices"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/fenrir/internal/presentation"
|
|
)
|
|
|
|
type fakePCM struct {
|
|
writeSamples func([]float32) (int, error)
|
|
drainResult func() error
|
|
received []float32
|
|
preparations, drains, closes int
|
|
}
|
|
|
|
func (p *fakePCM) write(samples []float32) (int, error) {
|
|
if p.writeSamples != nil {
|
|
return p.writeSamples(samples)
|
|
}
|
|
p.received = append(p.received, samples...)
|
|
return len(samples), nil
|
|
}
|
|
func (p *fakePCM) prepare() error { p.preparations++; return nil }
|
|
func (p *fakePCM) drain() error {
|
|
p.drains++
|
|
if p.drainResult != nil {
|
|
return p.drainResult()
|
|
}
|
|
return nil
|
|
}
|
|
func (p *fakePCM) close() { p.closes++ }
|
|
|
|
func TestPCMShortWritesAndUnderrunRecoveryPreserveSampleOrder(t *testing.T) {
|
|
s := &Service{done: make(chan struct{})}
|
|
pcm := &fakePCM{}
|
|
calls := 0
|
|
pcm.writeSamples = func(samples []float32) (int, error) {
|
|
calls++
|
|
switch calls {
|
|
case 1:
|
|
return 0, syscall.EPIPE
|
|
case 2:
|
|
return 0, syscall.EAGAIN
|
|
case 3:
|
|
return 0, syscall.ESTRPIPE
|
|
case 4:
|
|
return 0, syscall.EINTR
|
|
}
|
|
pcm.received = append(pcm.received, samples[:2]...)
|
|
return 2, nil
|
|
}
|
|
samples := []float32{1, -1, .5, -.5, .25, -.25}
|
|
if err := s.write_pcm(pcm, samples); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !slices.Equal(pcm.received, samples) || pcm.preparations != 2 {
|
|
t.Fatal(pcm.received, pcm.preparations)
|
|
}
|
|
}
|
|
|
|
func TestPCMStallsAndDrainRespectCancellation(t *testing.T) {
|
|
s := &Service{done: make(chan struct{})}
|
|
pcm := &fakePCM{writeSamples: func([]float32) (int, error) { return 0, syscall.EAGAIN }, drainResult: func() error { return syscall.EAGAIN }}
|
|
if err := s.write_pcm(pcm, []float32{1, 1}); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatal("write did not expire", err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := s.drain_pcm(ctx, pcm); !errors.Is(err, context.Canceled) {
|
|
t.Fatal("drain did not cancel", err)
|
|
}
|
|
close(s.done)
|
|
if err := s.drain_pcm(context.Background(), pcm); !errors.Is(err, context.Canceled) {
|
|
t.Fatal("close did not cancel drain", err)
|
|
}
|
|
}
|
|
|
|
func TestPCMShutdownUsesSharedCueVolumeAndDrainsBeforeClose(t *testing.T) {
|
|
s := &Service{clips: map[presentation.Cue]*clip{presentation.Stopped: {samples: []float32{.8, -.8, .4, -.4}}}, done: make(chan struct{}), errors: make(chan error, 1)}
|
|
s.Set_volume(.5)
|
|
pcm := &fakePCM{}
|
|
s.start_pcm(pcm)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := s.Finish(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Finish waits for the worker's completed drain, making its samples readable.
|
|
if pcm.drains != 1 || pcm.closes != 0 || !slices.Equal(pcm.received[:4], []float32{.4, -.4, .2, -.2}) {
|
|
t.Fatal(pcm)
|
|
}
|
|
s.Close()
|
|
s.Close()
|
|
if pcm.closes != 1 {
|
|
t.Fatal("PCM closed incorrectly", pcm.closes)
|
|
}
|
|
}
|
|
|
|
func TestPCMFailureDisablesOutputWithoutBlockingAdmission(t *testing.T) {
|
|
s := &Service{done: make(chan struct{}), errors: make(chan error, 1)}
|
|
pcm := &fakePCM{writeSamples: func([]float32) (int, error) { return 0, syscall.ENODEV }}
|
|
s.start_pcm(pcm)
|
|
select {
|
|
case err := <-s.Errors():
|
|
if !errors.Is(err, syscall.ENODEV) {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("PCM failure was not reported")
|
|
}
|
|
s.Close()
|
|
if pcm.closes != 1 {
|
|
t.Fatal(pcm.closes)
|
|
}
|
|
}
|
|
|
|
func TestALSANullSinkLifecycleWithoutHardwareOrDesktopAudio(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()
|
|
if !s.Play(presentation.Ready) || !s.Play_progress(50) {
|
|
t.Fatal("shared audio admission failed")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if err := s.Finish(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestMissingPackKeepsGeneratedTonesOnALSANull(t *testing.T) {
|
|
s, err := Open([]string{t.TempDir()}, Options{Driver: "alsa", Device: "null", Volume: .7})
|
|
if s == nil || err == nil {
|
|
t.Fatal(s, err)
|
|
}
|
|
defer s.Close()
|
|
if s.Play(presentation.Ready) || !s.Play_progress(50) {
|
|
t.Fatal("missing cue or generated tone admission")
|
|
}
|
|
}
|