210 lines
6.0 KiB
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)
|
|
}
|
|
}
|