// 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 . // Tone synthesis adapted from Cthulhu (Storm Dragon and Angus Kola). package sound import "math" // A small gain lift for progress tones, independent of normal cue volume. const progressGain = 1.03 * 1.02 // Precompute integer percentages before opening the stream. Admission and the // audio callback only move pointers; fractional progress rounds to the nearest // percent. The cache is bounded to 101 short clips (about 1.6 MiB). func progress_clips() [101]*clip { var clips [101]*clip for percentage := range clips { clips[percentage] = progress_tone(float64(percentage)) } return clips } // Match Cthulhu's NVDA-style tones: 110 * 2^(percent/25) Hz, clipped sine, // 40 ms padded to an integer cycle, 7000/32768 amplitude per channel. Generate // at its 44100 Hz rate, then interpolate onto our shared 48000 Hz stream. func progress_tone(percentage float64) *clip { return progress_tone_shape(percentage, false) } // The triangle variant shares progress's pitch, duration and resampling. func progress_tone_shape(percentage float64, triangle bool) *clip { const sourceRate = 44100 frequency := float32(110 * math.Pow(2, percentage/25)) cycleLength := float64(float32(sourceRate) / frequency) samplesPerCycle := int(cycleLength) count := sourceRate * 40 / 1000 count += samplesPerCycle - count%samplesPerCycle source := make([]float32, count) for index := range source { phase := float64(index) * 2 * math.Pi / cycleLength wave := min(1.0, max(-1.0, math.Sin(phase)*2)) if triangle { wave = 2 / math.Pi * math.Asin(math.Sin(phase)) } value := int16(wave * 7000) source[index] = float32(value) / 32768 } frames := (count*sampleRate + sourceRate - 1) / sourceRate samples := make([]float32, frames*2) for frame := range frames { position := float64(frame) * sourceRate / sampleRate left := min(int(position), count-1) right := min(left+1, count-1) value := source[left] + (source[right]-source[left])*float32(position-math.Floor(position)) samples[frame*2], samples[frame*2+1] = value, value } return &clip{samples: samples} } // Play_progress coalesces progress separately from ordinary cues. Progress // never replaces startup/review sounds and never cuts a progress tone short. func (s *Service) Play_progress(percentage float64) bool { if s == nil || s.closed.Load() || s.finishing.Load() || math.IsNaN(percentage) || math.IsInf(percentage, 0) || percentage < 0 || percentage > 100 { return false } item := s.tones[int(math.Round(percentage))] if item == nil { return false } s.progress.Store(item) return true } // Discard_progress drops waiting tones after passive redraws or prompts while // letting the short tone already playing finish. func (s *Service) Discard_progress() { if s != nil { s.progress.Store(nil) } } // Clear_progress drops pending/playing tones at input, menu capture, and // session handoff without touching any ordinary sound cue. func (s *Service) Clear_progress() { if s == nil { return } s.progress.Store(nil) s.cancelProgress.Store(true) }