Files

105 lines
3.7 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>.
// 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)
}