Files

299 lines
9.2 KiB
Go

// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package sound
import (
"context"
"errors"
"fmt"
"os"
"runtime"
"slices"
"sync"
"testing"
"time"
"github.com/jfreymuth/pulse"
"git.stormux.org/storm/fenrir/internal/presentation"
)
func TestVolumeScalesSamplesWithoutChangingCachedCues(t *testing.T) {
item := &clip{samples: []float32{0.8, -0.4}}
s := &Service{clips: map[presentation.Cue]*clip{presentation.Ready: item}}
s.Set_volume(0.25)
buffer := make([]float32, 4)
s.Play(presentation.Ready)
s.read_samples(buffer)
if buffer[0] != 0.2 || buffer[1] != -0.1 || buffer[2] != 0 || item.samples[0] != 0.8 {
t.Fatal("volume changed samples or source cue", buffer, item.samples)
}
s.Set_volume(0)
s.Play(presentation.Ready)
s.read_samples(buffer)
if buffer[0] != 0 || buffer[1] != 0 {
t.Fatal("zero volume was audible", buffer)
}
}
func TestVolumeChangesApplyToPlayingCuesAndProgress(t *testing.T) {
for _, kind := range []string{"cue", "startup", "progress"} {
t.Run(kind, func(t *testing.T) {
original := []float32{1, -1, 0.5, -0.5, 1, -1, 0.25, -0.25}
item := &clip{samples: slices.Clone(original)}
s := &Service{clips: map[presentation.Cue]*clip{presentation.Top: item, presentation.Ready: item}}
gain := float32(1)
switch kind {
case "cue":
s.Play(presentation.Top)
case "startup":
s.Play(presentation.Ready)
case "progress":
s.tones[50] = item
s.Play_progress(50)
gain = progressGain
}
buffer := make([]float32, 2)
for index, volume := range []float64{0.7, 0.1, 0, 1} {
s.Set_volume(volume)
s.read_samples(buffer)
for channel, sample := range buffer {
want := original[index*2+channel] * (float32(volume) * gain)
if sample != want {
t.Fatal("playing volume/position", volume, buffer, "want", want)
}
}
}
if !slices.Equal(item.samples, original) {
t.Fatal("volume modified cached samples", item.samples)
}
})
}
}
func TestZeroVolumeSkipsShutdownPlayback(t *testing.T) {
s := &Service{clips: map[presentation.Cue]*clip{presentation.Stopped: {samples: []float32{1, 1}}}}
s.Set_volume(0.7)
s.Set_volume(0)
if err := s.Finish(context.Background()); err != nil || s.pending.Load() != nil {
t.Fatal("zero volume queued shutdown audio", err)
}
}
func TestShutdownSubmitsFinalCueThenDrainsBeforeClose(t *testing.T) {
s := &Service{clips: map[presentation.Cue]*clip{
presentation.Ready: {samples: []float32{0.8, 0.8, 0.8, 0.8, 0.8, 0.8}},
presentation.Stopped: {samples: []float32{0.4, -0.4, 0.8, -0.8}},
}}
s.Set_volume(0.5)
var operations []string
s.drainBackend = func(context.Context) error { operations = append(operations, "drain"); return nil }
s.closeBackend = func() { operations = append(operations, "close") }
s.Play(presentation.Ready)
buffer := make([]float32, 2)
s.read_samples(buffer) // Shutdown must replace even an unfinished startup.
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
result := make(chan error, 1)
go func() { result <- s.Finish(ctx) }()
for s.pending.Load() == nil {
if ctx.Err() != nil {
t.Fatal("shutdown cue never submitted")
}
runtime.Gosched()
}
s.Play(presentation.Ready) // Admission has stopped; must not replace shutdown.
s.read_samples(buffer)
if buffer[0] != 0.2 || buffer[1] != -0.2 {
t.Fatal("wrong shutdown start", buffer)
}
s.read_samples(buffer)
if buffer[0] != 0.4 || buffer[1] != -0.4 {
t.Fatal("shutdown tail lost", buffer)
}
if n, err := s.read_samples(buffer); n != 0 || err != pulse.EndOfData {
t.Fatal("final stream did not terminate", n, err)
}
if err := <-result; err != nil {
t.Fatal(err)
}
if len(operations) != 1 || operations[0] != "drain" {
t.Fatal("closed before draining", operations)
}
s.Close()
if len(operations) != 2 || operations[1] != "close" {
t.Fatal("backend not closed after drain", operations)
}
}
func TestShutdownDoesNotHangWithoutAudioCallbacks(t *testing.T) {
s := &Service{clips: map[presentation.Cue]*clip{presentation.Stopped: {samples: []float32{1, 1}}}}
s.Set_volume(1)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := s.Finish(ctx); !errors.Is(err, context.Canceled) {
t.Fatal("ignored shutdown cancellation", err)
}
s.Close()
}
func TestLatestCueReplacesPendingAndPlayingAudio(t *testing.T) {
s := &Service{clips: map[presentation.Cue]*clip{
presentation.Top: {samples: []float32{1, 2, 3, 4, 5, 6}},
presentation.Bottom: {samples: []float32{7, 8}},
}}
s.Set_volume(1)
buffer := make([]float32, 4)
s.Play(presentation.Top)
s.Play(presentation.Bottom)
s.read_samples(buffer)
if buffer[0] != 7 || buffer[1] != 8 || buffer[2] != 0 {
t.Fatal("stale pending cue", buffer)
}
s.Play(presentation.Top)
s.read_samples(buffer)
if buffer[0] != 1 || buffer[3] != 4 {
t.Fatal("wrong first chunk", buffer)
}
s.Play(presentation.Bottom)
s.read_samples(buffer)
if buffer[0] != 7 || buffer[2] != 0 {
t.Fatal("old playing cue continued", buffer)
}
s.read_samples(buffer)
if buffer[0] != 0 {
t.Fatal("cue replayed", buffer)
}
closed := 0
s.closeBackend = func() { closed++ }
s.Close()
s.Close()
s.Play(presentation.Top)
s.read_samples(buffer)
if buffer[0] != 0 || closed != 1 {
t.Fatal("closed service replayed or closed twice")
}
}
func TestStartupCompletesBeforeLatestScreenCue(t *testing.T) {
s := &Service{clips: map[presentation.Cue]*clip{
presentation.Ready: {samples: []float32{1, 2, 3, 4}},
presentation.TTYChanged: {samples: []float32{5, 6}},
presentation.Blank: {samples: []float32{7, 8}},
presentation.Top: {samples: []float32{9, 10}},
}}
s.Set_volume(1)
s.Play(presentation.Ready)
s.Play(presentation.TTYChanged)
s.Play(presentation.Blank)
buffer := make([]float32, 2)
s.read_samples(buffer)
if buffer[0] != 1 || buffer[1] != 2 {
t.Fatal("initial blank replaced startup", buffer)
}
s.Play(presentation.Top)
s.read_samples(buffer)
if buffer[0] != 3 || buffer[1] != 4 {
t.Fatal("startup tail was interrupted", buffer)
}
s.read_samples(buffer)
if buffer[0] != 9 || buffer[1] != 10 {
t.Fatal("latest cue did not follow startup", buffer)
}
s.read_samples(buffer)
if buffer[0] != 0 || buffer[1] != 0 {
t.Fatal("stale startup cues replayed", buffer)
}
}
func TestRapidCueAdmissionDoesNotWaitForAudio(t *testing.T) {
s := &Service{clips: map[presentation.Cue]*clip{presentation.Blank: {samples: []float32{1, 1}}}}
s.Set_volume(1)
var workers sync.WaitGroup
workers.Go(func() {
for index := range 10000 {
s.Set_volume(float64(index%11) / 10)
s.Play(presentation.Blank)
}
})
buffer := make([]float32, 128)
for range 10000 {
s.read_samples(buffer)
}
workers.Wait()
if allocations := testing.AllocsPerRun(100, func() { s.Play(presentation.Blank) }); allocations != 0 {
t.Fatalf("cue admission allocates: %v", allocations)
}
s.Close()
}
func TestPlayReportsUnavailableCues(t *testing.T) {
var absent *Service
if absent.Play(presentation.Blank) {
t.Fatal("absent player accepted a cue")
}
s := &Service{clips: map[presentation.Cue]*clip{presentation.Blank: {samples: []float32{1, 1}}}}
if s.Play(presentation.Top) || s.pending.Load() != nil {
t.Fatal("missing cue accepted")
}
if !s.Play(presentation.Blank) || s.pending.Load() == nil {
t.Fatal("available cue rejected")
}
s.finishing.Store(true)
if s.Play(presentation.Blank) {
t.Fatal("finishing player accepted a cue")
}
s.finishing.Store(false)
s.Close()
if s.Play(presentation.Blank) {
t.Fatal("closed player accepted a cue")
}
}
func TestSoundServerFollowsExplicitAndSudoSession(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "/run/user/2000")
if got := Server("unix:/tmp/example-pulse", "1000"); got != "unix:/tmp/example-pulse" {
t.Fatal(got)
}
if got := Server("", "1000"); got != "unix:/run/user/1000/pulse/native unix:/tmp/pulse.sock unix:/run/pulse/native" {
t.Fatal(got)
}
for _, uid := range []string{"", "invalid", "../other"} {
if got := Server("", uid); got != "unix:/run/user/2000/pulse/native unix:/tmp/pulse.sock unix:/run/pulse/native" {
t.Fatal(got)
}
}
}
func TestSoundServerWithoutRuntimeDirectoryUsesCurrentUserAndSystemFallback(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
want := fmt.Sprintf("unix:/run/user/%d/pulse/native unix:/tmp/pulse.sock unix:/run/pulse/native", os.Getuid())
if got := Server("", ""); got != want {
t.Fatalf("Server() = %q, want %q", got, want)
}
}
func BenchmarkCueAdmission(b *testing.B) {
s := &Service{clips: map[presentation.Cue]*clip{presentation.Blank: {samples: []float32{1, 1}}}}
s.Set_volume(1)
b.ReportAllocs()
for b.Loop() {
s.Play(presentation.Blank)
}
}