// 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 . 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) } }