Phase 2 of the Wine/Proton stage (decisions 3, 4, 16, 17, 19 in docs/testing/wine-proton.md): replace the Wine manager's startup snapshot with a live settings source read once per poll, so accepted global settings take effect with no reader restart. The source publishes only the active/global profile's Wine values; per-application profile switching never changes the governing list. A newly listed running program restarts its helper once in the same mode; removals and clearing the global switch never downgrade a running helper.
288 lines
8.9 KiB
Go
288 lines
8.9 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/speech"
|
|
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
|
)
|
|
|
|
// Use the settings worker's bounded mailbox, keeping discovery, disk writes,
|
|
// and speech connection replacement off the input and focus paths.
|
|
func (worker *preferencesWorker) SpeechCommand(command input.Command) {
|
|
worker.TrySpeechCommand(command)
|
|
}
|
|
|
|
func (worker *preferencesWorker) TrySpeechCommand(command input.Command) bool {
|
|
select {
|
|
case worker.speechCommands <- command:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (worker *preferencesWorker) announceSpeechSetting(message string) {
|
|
if worker.announce != nil {
|
|
worker.announce(message)
|
|
}
|
|
}
|
|
|
|
func (worker *preferencesWorker) handleSpeechCommand(ctx context.Context, command input.Command) {
|
|
if command == input.CycleProgressBarOutput {
|
|
worker.cycle_progress_output()
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
|
defer cancel()
|
|
if command == input.SaveSpeechSettings {
|
|
store, err := config.NewStore(worker.globalSettings.Path)
|
|
if err == nil {
|
|
err = store.Save(worker.globalSettings)
|
|
}
|
|
if err != nil {
|
|
worker.logger.Warn("save speech settings", "error", err)
|
|
worker.announceSpeechSetting("Speech settings could not be saved")
|
|
} else {
|
|
worker.announceSpeechSetting("Speech settings saved")
|
|
}
|
|
return
|
|
}
|
|
worker.sync_extensions(ctx)
|
|
quickSettings := worker.quick_settings()
|
|
settingIndex := worker.speechSetting
|
|
if worker.quickSettingKey != "" {
|
|
settingIndex = 4
|
|
found := false
|
|
for index, setting := range quickSettings {
|
|
if setting.key == worker.quickSettingKey {
|
|
settingIndex = 5 + index
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found && (command == input.IncreaseSpeechSetting || command == input.DecreaseSpeechSetting) {
|
|
worker.quickSettingKey, worker.speechSetting = "", 4
|
|
worker.announceSpeechSetting("The selected speech setting is no longer available")
|
|
return
|
|
}
|
|
}
|
|
switch command {
|
|
case input.SelectPreviousSpeechSetting:
|
|
settingIndex = max(0, settingIndex-1)
|
|
case input.SelectNextSpeechSetting:
|
|
settingIndex = min(4+len(quickSettings), settingIndex+1)
|
|
}
|
|
worker.quickSettingKey = ""
|
|
if settingIndex >= 5 {
|
|
worker.quickSettingKey = quickSettings[settingIndex-5].key
|
|
} else {
|
|
worker.speechSetting = settingIndex
|
|
}
|
|
next := worker.effective_settings(worker.settings)
|
|
adjust := command == input.IncreaseSpeechSetting || command == input.DecreaseSpeechSetting
|
|
delta := 1
|
|
if command == input.DecreaseSpeechSetting {
|
|
delta = -1
|
|
}
|
|
if settingIndex >= 5 {
|
|
worker.handle_extension_quick(ctx, quickSettings[settingIndex-5], adjust, delta)
|
|
return
|
|
}
|
|
message := ""
|
|
switch worker.speechSetting {
|
|
case 0, 1, 2:
|
|
names := []string{"Rate", "Pitch", "Volume"}
|
|
fields := []**float64{&next.DefaultVoice.Rate, &next.DefaultVoice.AveragePitch, &next.DefaultVoice.Gain}
|
|
defaults := []float64{50, 5, 9}
|
|
limits := []float64{99, 9, 9}
|
|
steps := []float64{1, 0.1, 0.1}
|
|
field := fields[worker.speechSetting]
|
|
value := defaults[worker.speechSetting]
|
|
if *field != nil {
|
|
value = math.Round(**field*10) / 10
|
|
}
|
|
if adjust {
|
|
value = math.Round((value+float64(delta)*steps[worker.speechSetting])*10) / 10
|
|
value = math.Max(0, math.Min(limits[worker.speechSetting], value))
|
|
*field = &value
|
|
}
|
|
message = fmt.Sprintf("%s %g", names[worker.speechSetting], value)
|
|
if *field == nil {
|
|
message = names[worker.speechSetting] + " server default"
|
|
}
|
|
case 3, 4:
|
|
if next.SpeechServerFactory == "hardwarefactory" {
|
|
if worker.speechSetting == 4 {
|
|
worker.announceSpeechSetting("Hardware speech has no selectable voices")
|
|
return
|
|
}
|
|
modules := []string{"litetalk", "doubletalk", "tripletalk", "dectalk"}
|
|
if adjust {
|
|
for index, module := range modules {
|
|
if module == next.SpeechOutputModule {
|
|
next.SpeechOutputModule = modules[max(0, min(len(modules)-1, index+delta))]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
message = "Hardware synthesizer " + next.SpeechOutputModule
|
|
break
|
|
}
|
|
if worker.catalog == nil {
|
|
catalog, err := worker.discover(ctx, worker.socket)
|
|
if err != nil {
|
|
worker.announceSpeechSetting("Speech settings unavailable")
|
|
return
|
|
}
|
|
worker.catalog = catalog
|
|
}
|
|
if worker.speechSetting == 3 {
|
|
modules := []string{}
|
|
for module := range worker.catalog {
|
|
if module != "" {
|
|
modules = append(modules, module)
|
|
}
|
|
}
|
|
sort.Strings(modules)
|
|
if len(modules) == 0 {
|
|
worker.announceSpeechSetting("No speech-dispatcher modules available")
|
|
return
|
|
}
|
|
if adjust {
|
|
index := 0
|
|
for i, module := range modules {
|
|
if module == next.SpeechOutputModule {
|
|
index = i
|
|
break
|
|
}
|
|
}
|
|
// An unspecified module is a server default, not a known list position.
|
|
if next.SpeechOutputModule != "" {
|
|
index = max(0, min(len(modules)-1, index+delta))
|
|
}
|
|
next.SpeechOutputModule = modules[index]
|
|
next.DefaultVoice.Family = nil
|
|
if voices := quickSpeechVoices(next.SpeechOutputModule, worker.catalog[next.SpeechOutputModule]); len(voices) > 0 {
|
|
next.DefaultVoice.Family = &config.VoiceFamily{Name: voices[0].Name, Lang: voices[0].Language, Variant: voices[0].Variant}
|
|
}
|
|
}
|
|
name := next.SpeechOutputModule
|
|
if name == "" {
|
|
name = "server default"
|
|
}
|
|
message = "Speech-dispatcher module " + name
|
|
} else {
|
|
voices := quickSpeechVoices(next.SpeechOutputModule, worker.catalog[next.SpeechOutputModule])
|
|
if len(voices) == 0 {
|
|
worker.announceSpeechSetting("No speech-dispatcher voices available")
|
|
return
|
|
}
|
|
name := "server default"
|
|
if family := next.DefaultVoice.Family; family != nil && family.Name != "" {
|
|
name = family.Name
|
|
}
|
|
if adjust {
|
|
index := 0
|
|
for i, voice := range voices {
|
|
if voice.Name == name {
|
|
index = max(0, min(len(voices)-1, i+delta))
|
|
break
|
|
}
|
|
}
|
|
voice := voices[index]
|
|
next.DefaultVoice.Family = &config.VoiceFamily{Name: voice.Name, Lang: voice.Language, Variant: voice.Variant}
|
|
name = voice.Name
|
|
}
|
|
message = "Speech-dispatcher voice " + name
|
|
}
|
|
}
|
|
if adjust {
|
|
err := config.Validate(next)
|
|
if err == nil {
|
|
err = worker.updateSpeech(ctx, next)
|
|
}
|
|
if err != nil {
|
|
worker.logger.Warn("adjust speech setting", "error", err)
|
|
worker.catalog = nil
|
|
worker.announceSpeechSetting("Speech settings were not applied")
|
|
return
|
|
}
|
|
if worker.speechOverride != nil {
|
|
value := tentaclewire.SpeechFromSettings(next)
|
|
worker.speechOverride = &value
|
|
} else {
|
|
worker.quickSpeech.settings = next
|
|
worker.quickSpeech.adjusted[worker.speechSetting] = true
|
|
global := copyQuickSpeechSetting(worker.globalSettings, next, worker.speechSetting)
|
|
for application, settings := range worker.appSettings {
|
|
worker.appSettings[application] = config.RebaseApplicationSettings(worker.globalSettings, global, settings)
|
|
}
|
|
worker.setGlobalSettings(global)
|
|
worker.settings = next
|
|
}
|
|
if worker.live != nil {
|
|
worker.live.Store(next)
|
|
}
|
|
}
|
|
worker.announceSpeechSetting(message)
|
|
}
|
|
|
|
// The preferences worker owns these session-only choices. Apply them after
|
|
// resolving an application profile so window switches cannot undo a shortcut.
|
|
// Explicit preferences Apply or reader exit ends this override; saving does not.
|
|
type quickSpeechSettings struct {
|
|
settings config.Settings
|
|
adjusted [5]bool
|
|
}
|
|
|
|
func (quick quickSpeechSettings) apply(settings config.Settings) config.Settings {
|
|
for index, adjusted := range quick.adjusted {
|
|
if adjusted {
|
|
settings = copyQuickSpeechSetting(settings, quick.settings, index)
|
|
}
|
|
}
|
|
return settings
|
|
}
|
|
|
|
func copyQuickSpeechSetting(target, source config.Settings, index int) config.Settings {
|
|
switch index {
|
|
case 0:
|
|
target.DefaultVoice.Rate = source.DefaultVoice.Rate
|
|
case 1:
|
|
target.DefaultVoice.AveragePitch = source.DefaultVoice.AveragePitch
|
|
case 2:
|
|
target.DefaultVoice.Gain = source.DefaultVoice.Gain
|
|
case 3, 4:
|
|
// A voice belongs to its synthesizer, and a hardware synthesizer also
|
|
// needs its transport. Keep this selection valid across app profiles.
|
|
target.SpeechServerFactory = source.SpeechServerFactory
|
|
target.SpeechOutputModule = source.SpeechOutputModule
|
|
target.HardwareSpeechDevice = source.HardwareSpeechDevice
|
|
target.HardwareSpeechBaudRate = source.HardwareSpeechBaudRate
|
|
target.DefaultVoice.Family = source.DefaultVoice.Family
|
|
}
|
|
return target
|
|
}
|
|
|
|
// Keep eSpeak NG's language/variant cross product manageable in quick settings.
|
|
// Prefer an exact dialect, then a language-only voice if that dialect is absent.
|
|
// Unknown languages retain the catalog so the user can still select a voice.
|
|
func quickSpeechVoices(module string, voices []speech.Voice) []speech.Voice {
|
|
if !strings.Contains(strings.ToLower(module), "espeak-ng") {
|
|
return voices
|
|
}
|
|
return speech.LocaleVoices(voices)
|
|
}
|