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:
1 parent
c018f5fd29
commit
0e50de4c76
1047 files changed
+58364
-63747
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user