105 lines
3.7 KiB
Go
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)
|
|
}
|