Files
fenrir/internal/sound/progress_test.go

210 lines
6.0 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"
"crypto/sha256"
"encoding/binary"
"fmt"
"math"
"slices"
"sync"
"testing"
"git.stormux.org/storm/fenrir/internal/presentation"
)
func TestProgressTonePitchDurationStereoAndVolume(t *testing.T) {
for _, percentage := range []float64{0, 25, 50, 75, 100} {
item := progress_tone(percentage)
frames := len(item.samples) / 2
if frames < sampleRate*40/1000 || frames > sampleRate*50/1000 {
t.Fatal("duration", frames)
}
crossings := 0
for frame := range frames {
left, right := item.samples[frame*2], item.samples[frame*2+1]
if left != right || math.Abs(float64(left)) > 7000.0/32768+0.00001 {
t.Fatal("stereo/amplitude", left, right)
}
if frame > 0 && item.samples[(frame-1)*2] <= 0 && left > 0 {
crossings++
}
}
gotHz := float64(crossings) * sampleRate / float64(frames)
wantHz := 110 * math.Pow(2, percentage/25)
if math.Abs(gotHz-wantHz) > 26 {
t.Fatal("pitch", percentage, gotHz, wantHz)
}
}
s := &Service{tones: progress_clips()}
s.Set_volume(.25)
s.Play_progress(50)
buffer := make([]float32, len(s.tones[50].samples))
s.read_samples(buffer)
for index, value := range buffer {
if value != s.tones[50].samples[index]*(float32(.25)*float32(1.0506)) {
t.Fatal("unscaled progress")
}
}
}
func TestProgressPriorityCoalescingAndCancellation(t *testing.T) {
s := &Service{clips: map[presentation.Cue]*clip{
presentation.Ready: {samples: []float32{1, 1, 2, 2}},
presentation.Top: {samples: []float32{3, 3, 4, 4}},
}, tones: [101]*clip{0: {samples: []float32{5, 5, 6, 6}}, 50: {samples: []float32{7, 7, 8, 8}}, 100: {samples: []float32{9, 9}}}}
s.Set_volume(1)
buffer := make([]float32, 2)
read := func(want float32) {
t.Helper()
s.read_samples(buffer)
if math.Abs(float64(buffer[0]-want)) > 0.00001 {
t.Fatal("wrong audio", buffer, want)
}
}
s.Play(presentation.Ready)
s.Play_progress(0)
read(1)
s.Play(presentation.Top)
s.Play_progress(50)
read(2)
read(3)
read(4)
read(7 * 1.0506)
s.Play_progress(0)
s.Play_progress(100)
read(8 * 1.0506)
read(9 * 1.0506)
read(0)
// Ordinary cues can interrupt a tone, and a pending tone survives that cue.
s.Play_progress(50)
read(7 * 1.0506)
s.Play_progress(100)
s.Play(presentation.Top)
read(3)
read(4)
read(9 * 1.0506)
s.Play_progress(50)
read(7 * 1.0506)
s.Play_progress(100)
s.Clear_progress()
read(0)
s.Play(presentation.Top)
read(3)
s.Clear_progress()
read(4)
// Shutdown stops progress admission and discards pending tones.
s.Play_progress(100)
s.Finish(context.Background())
read(0)
if s.Play_progress(50) {
t.Fatal("accepted during shutdown")
}
s.Close()
if s.Play_progress(50) {
t.Fatal("accepted after close")
}
}
func TestProgressAdmissionIsBoundedAndConcurrent(t *testing.T) {
s := &Service{tones: progress_clips()}
s.Set_volume(1)
for _, value := range []float64{-1, 101, math.NaN(), math.Inf(1)} {
if s.Play_progress(value) {
t.Fatal("invalid progress", value)
}
}
s.Play_progress(49.6)
if s.progress.Load() != s.tones[50] {
t.Fatal("fraction rounding")
}
cached := slices.Clone(s.tones[50].samples)
var workers sync.WaitGroup
workers.Go(func() {
for range 10000 {
s.Play_progress(50)
s.Discard_progress()
s.Clear_progress()
s.Play_progress(100)
}
})
buffer := make([]float32, 128)
for range 10000 {
s.read_samples(buffer)
}
workers.Wait()
if !slices.Equal(cached, s.tones[50].samples) {
t.Fatal("mutated cache")
}
if allocations := testing.AllocsPerRun(100, func() { s.Play_progress(50) }); allocations != 0 {
t.Fatal("admission allocates", allocations)
}
if allocations := testing.AllocsPerRun(100, func() { s.read_samples(buffer) }); allocations != 0 {
t.Fatal("callback allocates", allocations)
}
}
func TestDiscardProgressPreservesEntirePlayingTone(t *testing.T) {
s := &Service{tones: progress_clips()}
s.Set_volume(1)
s.Play_progress(50)
tone := s.tones[50].samples
split := len(tone) / 4 * 2 // Keep stereo frames paired.
first := make([]float32, split)
s.read_samples(first)
s.Play_progress(100)
s.Discard_progress()
tail := make([]float32, len(tone)-split)
s.read_samples(tail)
for index, value := range append(first, tail...) {
if value != tone[index]*float32(progressGain) {
t.Fatalf("tone cut short or altered at sample %d: got %v, want %v", index, value, tone[index]*float32(progressGain))
}
}
s.read_samples(tail)
for _, value := range tail {
if value != 0 {
t.Fatal("discarded waiting tone still played")
}
}
// A reset must not prevent the next job from making progress sounds.
s.Play_progress(100)
s.read_samples(tail)
if !slices.ContainsFunc(tail, func(value float32) bool { return value != 0 }) {
t.Fatal("new progress tone was lost after discard")
}
}
func TestProgressSamplesMatchCthulhuReference(t *testing.T) {
// Digest of Cthulhu's unmodified progress_tone_wav for integer 0..100,
// independently resampled from 44100 Hz PCM16 to our 48000 Hz stereo float.
hash := sha256.New()
for _, item := range progress_clips() {
for _, sample := range item.samples {
if err := binary.Write(hash, binary.LittleEndian, sample); err != nil {
t.Fatal(err)
}
}
}
if got := fmt.Sprintf("%x", hash.Sum(nil)); got != "b6e0adff86b6659465ff6942c4651b84e76d855ba6a8b7e3a982a88f8dde4e9b" {
t.Fatal("Cthulhu tone samples differ", got)
}
}