// 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 . 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() }