Reset master to testing content
This commit is contained in:
1 parent
0f2587b719
commit
42afa2a56c
1483 files changed
+145308
-938069
No files matched your search
@@ -0,0 +1,301 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/speech"
|
||||
)
|
||||
|
||||
type configuredVoice interface {
|
||||
Speaker
|
||||
SpeakCharacter(context.Context, string) error
|
||||
SpeakEcho(context.Context, string) error
|
||||
SpeakAndWait(context.Context, string, func()) error
|
||||
SpeakVoice(context.Context, string, string) error
|
||||
SpeakVoiceAndWait(context.Context, string, string, func()) error
|
||||
SpeakEchoCharacter(context.Context, string) error
|
||||
}
|
||||
|
||||
// Keep backend replacement on the preferences worker. Input cancellation can
|
||||
// still reach the active backend while its replacement connects.
|
||||
type configuredSpeaker struct {
|
||||
mu sync.RWMutex
|
||||
update sync.Mutex
|
||||
active configuredVoice
|
||||
socket string
|
||||
settings config.Settings
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newConfiguredSpeaker(socket string, settings config.Settings) *configuredSpeaker {
|
||||
return &configuredSpeaker{socket: socket, settings: settings}
|
||||
}
|
||||
func (speaker *configuredSpeaker) Connect(ctx context.Context) error {
|
||||
return speaker.UpdateConfiguration(ctx, speaker.settings)
|
||||
}
|
||||
func (speaker *configuredSpeaker) UpdateConfiguration(ctx context.Context, settings config.Settings) error {
|
||||
if !speaker.update.TryLock() {
|
||||
return errors.New("speech settings update already in progress")
|
||||
}
|
||||
defer speaker.update.Unlock()
|
||||
if err := config.Validate(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
speaker.mu.RLock()
|
||||
current, closed := speaker.active, speaker.closed
|
||||
speaker.mu.RUnlock()
|
||||
if closed {
|
||||
return errors.New("speech is closed")
|
||||
}
|
||||
// Reuse the existing descriptor when only serial voice parameters change.
|
||||
if hardware, ok := current.(*hardwareSpeaker); ok && settings.SpeechServerFactory == "hardwarefactory" &&
|
||||
hardware.device == settings.HardwareSpeechDevice && hardware.baud == settings.HardwareSpeechBaudRate &&
|
||||
hardware.synth == settings.SpeechOutputModule {
|
||||
if err := hardware.Cancel(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
hardware.mu.Lock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
hardware.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
hardware.settings = settings
|
||||
hardware.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if software, ok := current.(*echoSpeaker); ok && settings.SpeechServerFactory != "hardwarefactory" {
|
||||
return software.UpdateConfiguration(ctx, settings)
|
||||
}
|
||||
var candidate configuredVoice
|
||||
if settings.SpeechServerFactory == "hardwarefactory" {
|
||||
candidate = &hardwareSpeaker{device: settings.HardwareSpeechDevice, baud: settings.HardwareSpeechBaudRate,
|
||||
synth: settings.SpeechOutputModule, settings: settings}
|
||||
} else {
|
||||
candidate = newEchoSpeaker(speaker.socket, settings)
|
||||
}
|
||||
if err := candidate.Connect(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
speaker.mu.Lock()
|
||||
if speaker.closed || ctx.Err() != nil {
|
||||
speaker.mu.Unlock()
|
||||
candidate.Close(context.Background())
|
||||
return errors.New("speech configuration was canceled")
|
||||
}
|
||||
if current != nil {
|
||||
if err := current.Cancel(ctx); err != nil {
|
||||
speaker.mu.Unlock()
|
||||
candidate.Close(context.Background())
|
||||
return err
|
||||
}
|
||||
}
|
||||
speaker.active = candidate
|
||||
speaker.settings = settings
|
||||
speaker.mu.Unlock()
|
||||
// The new backend is committed. A retired connection's cleanup error must
|
||||
// not report this successful swap as a rejected preferences transaction.
|
||||
if current != nil {
|
||||
_ = current.Close(context.Background())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (speaker *configuredSpeaker) voice() (configuredVoice, error) {
|
||||
speaker.mu.RLock()
|
||||
defer speaker.mu.RUnlock()
|
||||
if speaker.active == nil || speaker.closed {
|
||||
return nil, errors.New("speech is not connected")
|
||||
}
|
||||
return speaker.active, nil
|
||||
}
|
||||
func (speaker *configuredSpeaker) Speak(ctx context.Context, text string) error {
|
||||
voice, err := speaker.voice()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return voice.Speak(ctx, text)
|
||||
}
|
||||
func (speaker *configuredSpeaker) SpeakEcho(ctx context.Context, text string) error {
|
||||
voice, err := speaker.voice()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return voice.SpeakEcho(ctx, text)
|
||||
}
|
||||
func (speaker *configuredSpeaker) SpeakVoice(ctx context.Context, text, kind string) error {
|
||||
voice, err := speaker.voice()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return voice.SpeakVoice(ctx, text, kind)
|
||||
}
|
||||
func (speaker *configuredSpeaker) SpeakVoiceAndWait(ctx context.Context, text, kind string, began func()) error {
|
||||
voice, err := speaker.voice()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return voice.SpeakVoiceAndWait(ctx, text, kind, began)
|
||||
}
|
||||
func (speaker *configuredSpeaker) SpeakEchoCharacter(ctx context.Context, text string) error {
|
||||
voice, err := speaker.voice()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return voice.SpeakEchoCharacter(ctx, text)
|
||||
}
|
||||
func (speaker *configuredSpeaker) SpeakCharacter(ctx context.Context, text string) error {
|
||||
voice, err := speaker.voice()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return voice.SpeakCharacter(ctx, text)
|
||||
}
|
||||
func (speaker *configuredSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error {
|
||||
voice, err := speaker.voice()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return voice.SpeakAndWait(ctx, text, began)
|
||||
}
|
||||
|
||||
func (speaker *configuredSpeaker) speak_uninterruptible(ctx context.Context, text string) error {
|
||||
voice, err := speaker.voice()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader, ok := voice.(interface {
|
||||
speak_uninterruptible(context.Context, string) error
|
||||
})
|
||||
if !ok {
|
||||
return errors.New("speech backend does not report utterance completion")
|
||||
}
|
||||
return reader.speak_uninterruptible(ctx, text)
|
||||
}
|
||||
func (speaker *configuredSpeaker) Cancel(ctx context.Context) error {
|
||||
speaker.mu.RLock()
|
||||
voice := speaker.active
|
||||
speaker.mu.RUnlock()
|
||||
if voice == nil {
|
||||
return nil
|
||||
}
|
||||
return voice.Cancel(ctx)
|
||||
}
|
||||
func (speaker *configuredSpeaker) Close(ctx context.Context) error {
|
||||
speaker.update.Lock()
|
||||
defer speaker.update.Unlock()
|
||||
speaker.mu.Lock()
|
||||
voice := speaker.active
|
||||
speaker.active, speaker.closed = nil, true
|
||||
speaker.mu.Unlock()
|
||||
if voice == nil {
|
||||
return nil
|
||||
}
|
||||
return voice.Close(ctx)
|
||||
}
|
||||
|
||||
type hardwareSpeaker struct {
|
||||
mu sync.RWMutex
|
||||
device, synth string
|
||||
baud int
|
||||
client *speech.Hardware
|
||||
settings config.Settings
|
||||
}
|
||||
|
||||
func (speaker *hardwareSpeaker) Connect(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := speech.NewHardware(speaker.device, speaker.synth, speaker.baud)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
speaker.client = client
|
||||
return nil
|
||||
}
|
||||
func (speaker *hardwareSpeaker) Speak(ctx context.Context, text string) error {
|
||||
return speaker.speak(ctx, text, false, "default")
|
||||
}
|
||||
func (speaker *hardwareSpeaker) SpeakEcho(ctx context.Context, text string) error {
|
||||
return speaker.speak(ctx, text, true, "default")
|
||||
}
|
||||
func (speaker *hardwareSpeaker) SpeakCharacter(ctx context.Context, text string) error {
|
||||
return speaker.speak_character(ctx, text, false)
|
||||
}
|
||||
func (speaker *hardwareSpeaker) SpeakEchoCharacter(ctx context.Context, text string) error {
|
||||
return speaker.speak_character(ctx, text, true)
|
||||
}
|
||||
func (speaker *hardwareSpeaker) speak_character(ctx context.Context, text string, echo bool) error {
|
||||
r, size := utf8.DecodeRuneInString(text)
|
||||
kind := "default"
|
||||
if size == len(text) && unicode.IsUpper(r) {
|
||||
kind = "uppercase"
|
||||
}
|
||||
return speaker.speak(ctx, text, echo, kind)
|
||||
}
|
||||
func (speaker *hardwareSpeaker) SpeakVoice(ctx context.Context, text, kind string) error {
|
||||
if kind == "echo" {
|
||||
return speaker.SpeakEcho(ctx, text)
|
||||
}
|
||||
if kind == "uppercase-echo" {
|
||||
return speaker.speak(ctx, text, true, "uppercase")
|
||||
}
|
||||
return speaker.speak(ctx, text, false, kind)
|
||||
}
|
||||
func (speaker *hardwareSpeaker) speak(ctx context.Context, text string, echo bool, kind string) error {
|
||||
speaker.mu.RLock()
|
||||
defer speaker.mu.RUnlock()
|
||||
settings := speaker.settings
|
||||
voice := config.EffectiveVoice(settings, "default")
|
||||
if echo && settings.UseCustomEchoVoice {
|
||||
if value := settings.EchoVoice.Rate; value != nil {
|
||||
voice.Rate = value
|
||||
}
|
||||
if value := settings.EchoVoice.AveragePitch; value != nil {
|
||||
voice.AveragePitch = value
|
||||
}
|
||||
if value := settings.EchoVoice.Gain; value != nil {
|
||||
voice.Gain = value
|
||||
}
|
||||
}
|
||||
settings.DefaultVoice = voice
|
||||
voice = config.EffectiveVoice(settings, kind)
|
||||
// Hardware has a single serial engine; echo uses that same synth with its
|
||||
// custom numeric voice settings. Restore the default values each utterance.
|
||||
if voice.Rate == nil {
|
||||
value := 50.0
|
||||
voice.Rate = &value
|
||||
}
|
||||
if voice.AveragePitch == nil {
|
||||
value := 5.0
|
||||
voice.AveragePitch = &value
|
||||
}
|
||||
if voice.Gain == nil {
|
||||
value := 5.0
|
||||
voice.Gain = &value
|
||||
}
|
||||
return speaker.client.Speak(ctx, text, speech.HardwareVoice{Rate: voice.Rate, Pitch: voice.AveragePitch, Volume: voice.Gain})
|
||||
}
|
||||
func (speaker *hardwareSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error {
|
||||
return speaker.SpeakVoiceAndWait(ctx, text, "default", began)
|
||||
}
|
||||
func (speaker *hardwareSpeaker) SpeakVoiceAndWait(ctx context.Context, text, kind string, began func()) error {
|
||||
// These protocols provide no utterance-completion event. Match Python's
|
||||
// queued Say All behavior; progress denotes transmission, not audible end.
|
||||
if err := speaker.SpeakVoice(ctx, text, kind); err != nil {
|
||||
return err
|
||||
}
|
||||
if began != nil {
|
||||
began()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (speaker *hardwareSpeaker) Cancel(ctx context.Context) error { return speaker.client.Cancel(ctx) }
|
||||
func (speaker *hardwareSpeaker) Close(ctx context.Context) error { return speaker.client.Close(ctx) }
|
||||
Reference in new issue
Block a user