299 lines
9.2 KiB
Go
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)
|
|
}
|
|
}
|