// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "fmt" "log/slog" "time" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/speech" ) type speechChangeResult struct { err error done func(error) } func same_speech_selection(a, b config.Speech) bool { return a.Driver == b.Driver && a.Module == b.Module && a.Language == b.Language && a.Voice == b.Voice } func copy_speech_selection(target *config.Speech, source config.Speech) { target.Module, target.Language, target.Voice = source.Module, source.Language, source.Voice } func prepare_speech(selected config.Speech) (speech.Transport, error) { return speech.Prepare(selected.Driver, speech_options(selected)) } func speech_options(selected config.Speech) speech.Options { return speech.Options{Rate: selected.Rate, Pitch: &selected.Pitch, Volume: &selected.Volume, Module: selected.Module, Language: selected.Language, Voice: selected.Voice, Hardware: speech.HardwareOptions{Device: selected.HardwareDevice, BaudRate: selected.HardwareBaudRate}} } func (a *application) replace_speech(prepared speech.Transport) error { if service, ok := a.voice.Speaker.(interface{ Replace(speech.Transport) error }); ok { return service.Replace(prepared) } prepared.Close() return fmt.Errorf("speech service does not support live selection") } // Only the result callback mutates reader state. Preparing a silent candidate // and committing it through the speech worker never blocks the input loop. func (a *application) change_speech(selected config.Speech, done func(error)) { if a.speechPending || a.saver.busy { done(fmt.Errorf("settings are busy")) return } if !a.settings.speechAvailable { done(fmt.Errorf("speech was disabled at startup")) return } a.speechPending = true go func() { prepared, err := a.prepareSpeech(selected) if err == nil { err = a.replace_speech(prepared) } select { case a.speechResults <- speechChangeResult{err: err, done: done}: case <-a.ctx.Done(): } }() } func speech_choice(source string) bool { return source == "speech-module" || source == "speech-language" || source == "speech-voice" } func (a *application) speech_result(result speechChangeResult) { a.speechPending = false result.done(result.err) // Reapply numeric overrides made while the silent candidate was prepared. a.voice.Configure(speech_parameters(a.settings.live)) } type savedSpeechError struct{ err error } func (e *savedSpeechError) Error() string { return "settings saved; speech selection was not applied: " + e.err.Error() } func (e *savedSpeechError) Unwrap() error { return e.err } func settings_committed(err error) bool { var saved *savedSpeechError return err == nil || errors.As(err, &saved) } func open_speech(ctx context.Context, selected config.Speech) (speech.Transport, config.Speech, error) { options := speech_options(selected) if selected.Module == "" && selected.Language == "" && selected.Voice == "" { client, err := speech.Open_driver_context(ctx, selected.Driver, options) return client, selected, err } client, err := speech.Prepare_context(ctx, selected.Driver, options) if err == nil { return client, selected, nil } slog.Warn("saved speech selection unavailable; using server default", "error", err, "at", time.Now()) selected.Module, selected.Language, selected.Voice = "", "", "" options.Module, options.Language, options.Voice = "", "", "" client, err = speech.Open_driver_context(ctx, selected.Driver, options) return client, selected, err }