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,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 = ¶meters
|
||||
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: ¶meters})
|
||||
}
|
||||
|
||||
// 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 = ¶meters
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user