494 lines
12 KiB
Go
494 lines
12 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>.
|
|
|
|
package speech
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
|
|
"git.stormux.org/storm/fenrir/internal/audio"
|
|
"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
|
|
transportEpoch uint64
|
|
faults chan transportFailure
|
|
recoveryContext context.Context
|
|
stopRecovery context.CancelFunc
|
|
recovering bool
|
|
replacements []replacement
|
|
mu sync.Mutex
|
|
dispatch sync.Mutex
|
|
queue []utterance
|
|
parameters *Parameters
|
|
generation, canceled uint64
|
|
muted, closed bool
|
|
beforeCancel context.CancelFunc
|
|
wake, cancelWake chan struct{}
|
|
done chan struct{}
|
|
errors chan error
|
|
workers sync.WaitGroup
|
|
}
|
|
|
|
type transportFailure struct {
|
|
epoch uint64
|
|
err error
|
|
}
|
|
|
|
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
|
|
before func(context.Context)
|
|
queuedAt time.Time
|
|
pitch *int
|
|
parameters *Parameters
|
|
}
|
|
|
|
func New(transport Transport) *Service {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s := &Service{recoveryContext: ctx, stopRecovery: cancel, faults: make(chan transportFailure, 1), 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_after keeps a cancellable sound prelude and its speech in one FIFO
|
|
// entry. Neither the prelude nor later speech runs on the input dispatcher.
|
|
func (s *Service) Say_after(text string, before func(context.Context)) {
|
|
s.enqueue(utterance{text: text, before: before})
|
|
}
|
|
|
|
// 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 || s.recovering {
|
|
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() {
|
|
if s.beforeCancel != nil {
|
|
s.beforeCancel()
|
|
}
|
|
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:
|
|
}
|
|
}
|
|
}
|
|
|
|
// Recovery belongs to the delivery worker. Admission and cancellation never
|
|
// wait for a new daemon, and speech from the outage is never replayed.
|
|
func (s *Service) recover_transport(problem error, parameters *Parameters) bool {
|
|
reopener, ok := s.transport.(interface {
|
|
Reopen(context.Context) (Transport, error)
|
|
})
|
|
if !ok || !audio.Temporary(problem) {
|
|
s.fail(problem)
|
|
return false
|
|
}
|
|
s.dispatch.Lock()
|
|
defer s.dispatch.Unlock()
|
|
s.mu.Lock()
|
|
s.recovering = true
|
|
s.queue = nil
|
|
s.mu.Unlock()
|
|
s.transportMu.Lock()
|
|
old := s.transport
|
|
s.transport = nil
|
|
s.transportEpoch++
|
|
s.transportMu.Unlock()
|
|
old.Close()
|
|
slog.Warn("speech connection lost; reconnecting", "error", problem, "at", time.Now())
|
|
ctx, cancel := context.WithTimeout(s.recoveryContext, audio.RecoveryTimeout)
|
|
defer cancel()
|
|
var candidate Transport
|
|
err := audio.Retry(ctx, func(ctx context.Context) error {
|
|
var err error
|
|
candidate, err = reopener.Reopen(ctx)
|
|
if err == nil && parameters != nil {
|
|
if client, ok := candidate.(*Client); ok {
|
|
stop := context.AfterFunc(ctx, client.abort)
|
|
err = candidate.Configure(*parameters)
|
|
stop()
|
|
} else {
|
|
err = candidate.Configure(*parameters)
|
|
}
|
|
}
|
|
if err != nil && candidate != nil {
|
|
candidate.Close()
|
|
candidate = nil
|
|
}
|
|
return err
|
|
}, audio.Temporary)
|
|
err = errors.Join(err, ctx.Err())
|
|
s.mu.Lock()
|
|
if err != nil || s.closed || ctx.Err() != nil {
|
|
closed := s.closed
|
|
s.mu.Unlock()
|
|
if candidate != nil {
|
|
candidate.Close()
|
|
}
|
|
if err != nil && !closed {
|
|
s.fail(errors.Join(errors.New("speech recovery failed"), err))
|
|
}
|
|
return false
|
|
}
|
|
s.transportMu.Lock()
|
|
s.transport = candidate
|
|
s.transportMu.Unlock()
|
|
s.recovering = false
|
|
s.mu.Unlock()
|
|
slog.Info("speech connection restored", "at", time.Now())
|
|
return true
|
|
}
|
|
|
|
func (s *Service) deliver() {
|
|
defer s.workers.Done()
|
|
normalPitch := 0
|
|
var currentParameters *Parameters
|
|
for {
|
|
select {
|
|
case <-s.done:
|
|
return
|
|
case <-s.wake:
|
|
case fault := <-s.faults:
|
|
if fault.epoch == s.transportEpoch && !s.recover_transport(fault.err, currentParameters) {
|
|
return
|
|
}
|
|
}
|
|
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 {
|
|
if !s.recover_transport(err, currentParameters) {
|
|
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.transportEpoch++
|
|
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
|
|
ctx := s.recoveryContext
|
|
var cancel context.CancelFunc
|
|
if item.before != nil {
|
|
ctx, cancel = context.WithCancel(ctx)
|
|
}
|
|
s.beforeCancel = cancel
|
|
s.mu.Unlock()
|
|
diagnostic.Timing("speech queue wait", item.queuedAt, "generation", generation)
|
|
if item.before != nil {
|
|
item.before(ctx)
|
|
}
|
|
if ctx.Err() != nil {
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
s.dispatch.Unlock()
|
|
continue
|
|
}
|
|
started := time.Now()
|
|
err := s.say(item, normalPitch, currentParameters)
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
diagnostic.Timing("speech send", started, "generation", generation)
|
|
s.dispatch.Unlock()
|
|
if err != nil {
|
|
if !s.recover_transport(err, currentParameters) {
|
|
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()
|
|
if s.transport == nil {
|
|
return nil
|
|
}
|
|
err := s.transport.Cancel()
|
|
if err != nil {
|
|
select {
|
|
case s.faults <- transportFailure{epoch: s.transportEpoch, err: err}:
|
|
default:
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
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.
|
|
_ = s.cancel_transport()
|
|
s.dispatch.Lock()
|
|
}
|
|
// With dispatch held, no send can race this cancel. The usual
|
|
// idle-send path needs only this one acknowledgement.
|
|
_ = s.cancel_transport()
|
|
diagnostic.Timing("speech cancel barrier", started, "generation", generation)
|
|
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.stopRecovery()
|
|
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()
|
|
if s.transport != nil {
|
|
s.transport.Close()
|
|
}
|
|
s.transportMu.RUnlock()
|
|
s.workers.Wait()
|
|
}
|