After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.

This commit is contained in:
Storm Dragon committed 2026-10-27 20:30:55 -04:00
1 parent c018f5fd29
commit 0e50de4c76
1047 files changed
+58364 -63747

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
// 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 plays preloaded cues through one persistent in-process stream.
package sound
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/jfreymuth/pulse"
"git.stormux.org/storm/fenrir/internal/presentation"
)
type Service struct {
clips map[presentation.Cue]*clip
pending atomic.Pointer[clip]
tones [101]*clip
indentationTones [101]*clip
progress atomic.Pointer[clip]
cancelProgress atomic.Bool
progressing bool // audio callback owns this flag
startup atomic.Pointer[clip]
closed atomic.Bool
finishing atomic.Bool
volume atomic.Uint32 // float32 bits shared with the audio callback
current *clip // audio callback owns current and offset
offset int
starting bool // audio callback keeps the startup cue through its last sample
closeBackend func()
drainBackend func(context.Context) error
errors chan error
done chan struct{}
workers sync.WaitGroup
}
// Open may return a usable service plus warnings for missing individual cues.
// Availability failures are nonfatal to speech and keyboard operation.
func Open(roots []string, options Options) (*Service, error) {
if err := Validate_selection(options.Driver, options.Device); err != nil {
return nil, err
}
volume := options.Volume
if math.IsNaN(volume) || math.IsInf(volume, 0) || volume < 0 || volume > 1 {
return nil, fmt.Errorf("sound volume must be between 0.0 and 1.0")
}
clips, warning := load_theme(roots)
// Generated tones remain available even without an installed sound pack.
s := &Service{clips: clips, tones: progress_clips(), indentationTones: indentation_clips(), errors: make(chan error, 1), done: make(chan struct{})}
s.Set_volume(volume)
driver, _ := Find_driver(options.Driver)
if err := driver.open(s, options.Device); err != nil {
return nil, errors.Join(warning, err)
}
return s, warning
}
func (s *Service) Errors() <-chan error { return s.errors }
// Set_volume applies a validated 0..1 gain to the next audio buffer, including
// any cue or progress tone already playing. Cached samples remain unchanged.
func (s *Service) Set_volume(volume float64) {
s.volume.Store(math.Float32bits(float32(volume)))
}
func Server(explicit, sudoUID string) string {
if explicit != "" {
return explicit
}
if uid, err := strconv.ParseUint(sudoUID, 10, 32); err == nil {
return fmt.Sprintf("unix:/run/user/%d/pulse/native", uid)
}
return ""
}
// Play only replaces a mailbox pointer. Startup has its own slot so initial
// screen feedback cannot replace it. Other cues retain only the latest request.
// Admission never waits for audio or touches the filesystem.
func (s *Service) Play(cue presentation.Cue) bool {
if s == nil || s.closed.Load() || s.finishing.Load() {
return false
}
if item := s.clips[cue]; item != nil {
if cue == presentation.Ready {
s.startup.Store(item)
} else {
s.pending.Store(item)
}
return true
}
return false
}
func (s *Service) read_samples(samples []float32) (int, error) {
clear(samples)
if s.closed.Load() {
return len(samples), nil
}
if s.cancelProgress.Swap(false) && s.progressing {
s.current, s.progressing = nil, false
}
if s.finishing.Load() {
s.progress.Store(nil)
s.startup.Store(nil)
s.starting = false
} else if next := s.startup.Swap(nil); next != nil {
s.current, s.offset, s.starting, s.progressing = next, 0, true, false
}
if !s.starting {
if next := s.pending.Swap(nil); next != nil {
s.current, s.offset, s.progressing = next, 0, false
}
}
if s.current == nil && !s.finishing.Load() {
if next := s.progress.Swap(nil); next != nil {
s.current, s.offset, s.progressing = next, 0, true
}
}
if s.current != nil && s.offset == len(s.current.samples) && s.current.finished != nil {
close(s.current.finished)
s.current = nil
return 0, pulse.EndOfData
}
if s.current != nil {
count := copy(samples, s.current.samples[s.offset:])
volume := math.Float32frombits(s.volume.Load())
if s.progressing {
volume *= progressGain
}
for index := range count {
samples[index] *= volume
}
s.offset += count
if s.offset == len(s.current.samples) && s.current.finished == nil {
s.current = nil
s.starting, s.progressing = false, false
}
}
return len(samples), nil
}
// Finish is called after input/review admission stops and keyboards are
// released. Wait for the final cue's samples to be submitted, then drain the
// server before Close destroys the stream. Waiting is bounded by the clip's
// duration plus one second even when callbacks stop arriving.
func (s *Service) Finish(ctx context.Context) error {
if s == nil || s.closed.Load() || s.finishing.Swap(true) {
return nil
}
s.Clear_progress()
item := s.clips[presentation.Stopped]
if item == nil || math.Float32frombits(s.volume.Load()) == 0 {
return nil
}
ctx, cancel := context.WithTimeout(ctx, time.Second+time.Duration(len(item.samples))*time.Second/(sampleRate*2))
defer cancel()
final := &clip{samples: item.samples, finished: make(chan struct{})}
s.pending.Store(final)
select {
case <-final.finished:
if s.drainBackend != nil {
return s.drainBackend(ctx)
}
return nil
case <-s.done:
return nil
case err := <-s.errors:
return err
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) Close() {
if s == nil || s.closed.Swap(true) {
return
}
s.pending.Store(nil)
s.Clear_progress()
s.startup.Store(nil)
if s.done != nil {
close(s.done)
}
s.workers.Wait()
if s.closeBackend != nil {
s.closeBackend()
}
}