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