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

+370
View File
@@ -0,0 +1,370 @@
// 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 speech
import (
"errors"
"strings"
"sync"
"time"
"git.stormux.org/storm/fenrir/internal/diagnostic"
)
type Transport interface {
// Say receives prepared text; backends must disable additional punctuation
// naming so the shared punctuation processor's names are spoken once.
Say(string) error
Configure(Parameters) error
Set_pitch(int) error
Cancel() error
Close()
}
type Service struct {
transport Transport
transportMu sync.RWMutex
replacements []replacement
mu sync.Mutex
dispatch sync.Mutex
queue []utterance
parameters *Parameters
generation, canceled uint64
muted, closed bool
wake, cancelWake chan struct{}
done chan struct{}
errors chan error
workers sync.WaitGroup
}
type replacement struct {
transport Transport
done chan error
}
// Replace commits an already prepared client on the serialized speech worker.
// Mute, cancellation generations and pending numeric adjustments survive.
// Ownership transfers to the service even if shutdown races the request.
func (s *Service) Replace(transport Transport) error {
request := replacement{transport: transport, done: make(chan error, 1)}
s.mu.Lock()
if s.closed {
s.mu.Unlock()
transport.Close()
return errors.New("speech service closed")
}
s.replacements = append(s.replacements, request)
signal(s.wake)
s.mu.Unlock()
return <-request.done
}
type utterance struct {
text string
queuedAt time.Time
pitch *int
parameters *Parameters
}
func New(transport Transport) *Service {
s := &Service{transport: transport, wake: make(chan struct{}, 1), cancelWake: make(chan struct{}, 1), done: make(chan struct{}), errors: make(chan error, 1)}
s.workers.Add(2)
go s.deliver()
go s.cancel()
return s
}
func signal(wake chan struct{}) {
select {
case wake <- struct{}{}:
default:
}
}
func (s *Service) Errors() <-chan error { return s.errors }
// Configure coalesces adjustments without dropping them on speech interruption
// or mute. Delivery applies the latest values before admitting more speech.
func (s *Service) Configure(parameters Parameters) {
if err := parameters.Validate(); err != nil {
s.fail(err)
return
}
s.mu.Lock()
defer s.mu.Unlock()
if !s.closed {
s.parameters = &parameters
signal(s.wake)
}
}
func (s *Service) Say(text string) {
s.enqueue(utterance{text: text})
}
// Say_with_parameters previews one utterance without changing saved or live
// defaults. The serialized worker restores its latest normal parameters.
func (s *Service) Say_with_parameters(text string, parameters Parameters) {
if err := parameters.Validate(); err != nil {
s.fail(err)
return
}
s.enqueue(utterance{text: text, parameters: &parameters})
}
// Say_with_pitch scopes the override and restoration to one queued request.
// Interruption discards both together; configuration never leaks into later speech.
func (s *Service) Say_with_pitch(text string, pitch int) {
if pitch < -100 || pitch > 100 {
s.fail(errors.New("speech pitch must be between -100 and 100"))
return
}
s.enqueue(utterance{text: text, pitch: &pitch})
}
func (s *Service) enqueue(item utterance) {
if strings.TrimSpace(item.text) == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed || s.muted {
return
}
item.queuedAt = time.Now()
s.queue = append(s.queue, item)
signal(s.wake)
}
func (s *Service) Interrupt() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
s.interrupt()
}
func (s *Service) interrupt() {
clear(s.queue)
s.queue = nil
s.generation++
signal(s.cancelWake)
}
func (s *Service) Toggle_mute() bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return s.muted
}
s.muted = !s.muted
if s.muted {
s.interrupt()
}
return s.muted
}
func (s *Service) fail(err error) {
if err != nil {
select {
case s.errors <- err:
default:
}
}
}
func (s *Service) deliver() {
defer s.workers.Done()
normalPitch := 0
var currentParameters *Parameters
for {
select {
case <-s.done:
return
case <-s.wake:
}
for {
s.mu.Lock()
if !s.closed && s.generation == s.canceled && s.parameters != nil {
parameters := *s.parameters
currentParameters = &parameters
normalPitch = parameters.Pitch
s.parameters = nil
s.dispatch.Lock()
s.mu.Unlock()
err := s.transport.Configure(parameters)
s.dispatch.Unlock()
if err != nil {
s.fail(err)
return
}
continue
}
if !s.closed && s.generation == s.canceled && len(s.replacements) > 0 {
request := s.replacements[0]
s.replacements = s.replacements[1:]
s.dispatch.Lock()
s.mu.Unlock()
var err error
if currentParameters != nil {
err = request.transport.Configure(*currentParameters)
}
s.mu.Lock()
if s.closed {
err = errors.New("speech service closed")
}
if err == nil {
s.transportMu.Lock()
old := s.transport
s.transport = request.transport
s.transportMu.Unlock()
s.mu.Unlock()
old.Close()
} else {
s.mu.Unlock()
request.transport.Close()
}
s.dispatch.Unlock()
request.done <- err
continue
}
if s.closed || s.muted || s.generation != s.canceled || len(s.queue) == 0 {
s.mu.Unlock()
break
}
item := s.queue[0]
s.queue[0] = utterance{}
s.queue = s.queue[1:]
// Claim dispatch before releasing admission state. An interrupt
// must account for this send even if it hasn't reached the socket.
s.dispatch.Lock()
generation := s.generation
s.mu.Unlock()
diagnostic.Timing("speech queue wait", item.queuedAt, "generation", generation)
started := time.Now()
err := s.say(item, normalPitch, currentParameters)
diagnostic.Timing("speech send", started, "generation", generation)
s.dispatch.Unlock()
if err != nil {
s.fail(err)
return
}
}
}
}
func (s *Service) say(item utterance, normalPitch int, normalParameters *Parameters) error {
if item.parameters != nil {
if normalParameters == nil {
return errors.New("speech parameters unavailable for preview")
}
if err := s.transport.Configure(*item.parameters); err != nil {
return errors.Join(err, s.transport.Configure(*normalParameters))
}
err := s.transport.Say(item.text)
return errors.Join(err, s.transport.Configure(*normalParameters))
}
if item.pitch == nil {
return s.transport.Say(item.text)
}
if err := s.transport.Set_pitch(*item.pitch); err != nil {
return err
}
err := s.transport.Say(item.text)
return errors.Join(err, s.transport.Set_pitch(normalPitch))
}
func (s *Service) cancel_transport() error {
s.transportMu.RLock()
defer s.transportMu.RUnlock()
return s.transport.Cancel()
}
func (s *Service) cancel() {
defer s.workers.Done()
for {
select {
case <-s.done:
return
case <-s.cancelWake:
}
for {
s.mu.Lock()
generation := s.generation
closed := s.closed
settled := s.canceled == generation
s.mu.Unlock()
if closed {
return
}
if settled {
break
}
started := time.Now()
if !s.dispatch.TryLock() {
// Stop audible speech even while a stale send is blocked. A
// second cancel after that send finishes closes the late-send race.
if err := s.cancel_transport(); err != nil {
s.fail(err)
return
}
s.dispatch.Lock()
}
// With dispatch held, no send can race this cancel. The usual
// idle-send path needs only this one acknowledgement.
err := s.cancel_transport()
diagnostic.Timing("speech cancel barrier", started, "generation", generation)
if err != nil {
s.dispatch.Unlock()
s.fail(err)
return
}
s.mu.Lock()
// Every intervening interrupt is covered: fresh sends have been
// gated throughout. Coalesce them instead of canceling again.
s.canceled = s.generation
s.mu.Unlock()
s.dispatch.Unlock()
signal(s.wake)
break
}
}
}
func (s *Service) Close() {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
s.queue = nil
close(s.done)
pending := s.replacements
s.replacements = nil
s.mu.Unlock()
for _, request := range pending {
request.transport.Close()
request.done <- errors.New("speech service closed")
}
s.transportMu.RLock()
s.transport.Close()
s.transportMu.RUnlock()
s.workers.Wait()
}