Files
cthulhu/internal/app/remote_control.go

337 lines
10 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"math"
"reflect"
"strings"
"time"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"github.com/godbus/dbus/v5"
)
// The names are the Python service's public names. Each mapped field is read
// from the live snapshot and applied by the preferences worker.
var remoteFields = map[string]string{
"SpeechManager.CapitalizationStyle": "CapitalizationStyle",
"SpeechManager.SpeakDescription": "SpeakDescription",
"SpeechManager.SpeakRowInGuiTable": "Reading.ReadFullRowInGUITable",
"TypingEchoManager.KeyEchoEnabled": "EnableKeyEcho",
"TypingEchoManager.CharacterEchoEnabled": "EnableEchoByCharacter",
"TypingEchoManager.WordEchoEnabled": "EnableEchoByWord",
"TypingEchoManager.SentenceEchoEnabled": "EnableEchoBySentence",
"TypingEchoManager.AlphabeticKeysEnabled": "EnableAlphabeticKeys",
"TypingEchoManager.NumericKeysEnabled": "EnableNumericKeys",
"TypingEchoManager.PunctuationKeysEnabled": "EnablePunctuationKeys",
"TypingEchoManager.SpaceEnabled": "EnableSpace",
"TypingEchoManager.ModifierKeysEnabled": "EnableModifierKeys",
"TypingEchoManager.FunctionKeysEnabled": "EnableFunctionKeys",
"TypingEchoManager.ActionKeysEnabled": "EnableActionKeys",
"TypingEchoManager.NavigationKeysEnabled": "EnableNavigationKeys",
"TypingEchoManager.DiacriticalKeysEnabled": "EnableDiacriticalKeys",
}
func remoteField(settings *config.Settings, path string) reflect.Value {
value := reflect.ValueOf(settings).Elem()
for _, part := range strings.Split(path, ".") {
if value.Kind() != reflect.Struct {
return reflect.Value{}
}
value = value.FieldByName(part)
if !value.IsValid() {
return value
}
}
return value
}
func remoteValue(settings config.Settings, module, property string) (dbus.Variant, bool) {
if module == "SpeechManager" {
switch property {
case "CurrentServer":
if !settings.EnableSpeech {
return dbus.MakeVariant(""), true
}
return dbus.MakeVariant("speechdispatcher"), true
case "AvailableServers":
return dbus.MakeVariant([]string{"speechdispatcher"}), true
case "CurrentSynthesizer":
return dbus.MakeVariant(settings.SpeechOutputModule), true
case "CurrentVoice":
if settings.DefaultVoice.Family != nil {
return dbus.MakeVariant(settings.DefaultVoice.Family.Name), true
}
return dbus.MakeVariant(""), true
case "Rate":
if settings.DefaultVoice.Rate != nil {
return dbus.MakeVariant(int32(*settings.DefaultVoice.Rate)), true
}
return dbus.MakeVariant(int32(50)), true
case "Pitch":
if settings.DefaultVoice.AveragePitch != nil {
return dbus.MakeVariant(*settings.DefaultVoice.AveragePitch), true
}
return dbus.MakeVariant(float64(5)), true
case "Volume":
if settings.DefaultVoice.Gain != nil {
return dbus.MakeVariant(*settings.DefaultVoice.Gain), true
}
return dbus.MakeVariant(float64(10)), true
case "SpeechIsEnabled":
return dbus.MakeVariant(settings.EnableSpeech), true
case "PunctuationLevel":
return dbus.MakeVariant([]string{"all", "most", "some", "none"}[settings.VerbalizePunctuationStyle]), true
}
}
path, ok := remoteFields[module+"."+property]
if !ok {
return dbus.Variant{}, false
}
field := remoteField(&settings, path)
if !field.IsValid() {
return dbus.Variant{}, false
}
return dbus.MakeVariant(field.Interface()), true
}
type remoteSettingsChange struct {
module, property string
command string
value dbus.Variant
result chan bool
expires time.Time
}
func (worker *preferencesWorker) RunRemoteCommand(command string) bool {
change := remoteSettingsChange{module: "SpeechManager", command: command, result: make(chan bool, 1), expires: time.Now().Add(2 * time.Second)}
select {
case worker.remoteChanges <- change:
default:
return false
}
select {
case accepted := <-change.result:
return accepted
case <-worker.done:
return false
case <-time.After(2 * time.Second):
return false
}
}
func (worker *preferencesWorker) SetRemote(module, property string, value dbus.Variant) bool {
change := remoteSettingsChange{module: module, property: property, value: value, result: make(chan bool, 1), expires: time.Now().Add(2 * time.Second)}
select {
case worker.remoteChanges <- change:
default:
return false
}
select {
case accepted := <-change.result:
return accepted
case <-worker.done:
return false
case <-time.After(2 * time.Second):
return false
}
}
func (worker *preferencesWorker) applyRemoteChange(ctx context.Context, change remoteSettingsChange) bool {
if time.Now().After(change.expires) {
return false
}
next := worker.settings
if change.command != "" {
if !adjustRemoteSpeech(&next, change.command) {
return false
}
} else {
if !setRemoteValue(&next, change.module, change.property, change.value) {
return false
}
}
applyCtx, cancel := context.WithDeadline(ctx, change.expires)
defer cancel()
return worker.applyNext(applyCtx, next, false, false).Accepted
}
func adjustRemoteSpeech(settings *config.Settings, command string) bool {
var field **float64
var defaultValue, step, upper float64
delta := float64(1)
switch command {
case "DecreaseRate", "IncreaseRate":
field = &settings.DefaultVoice.Rate
defaultValue, step, upper = 50, 1, 99
case "DecreasePitch", "IncreasePitch":
field = &settings.DefaultVoice.AveragePitch
defaultValue, step, upper = 5, 0.1, 9
case "DecreaseVolume", "IncreaseVolume":
field = &settings.DefaultVoice.Gain
defaultValue, step, upper = 9, 0.1, 9
default:
return false
}
if strings.HasPrefix(command, "Decrease") {
delta = -1
}
value := defaultValue
if *field != nil {
value = **field
}
value = math.Round((value+delta*step)*10) / 10
value = math.Max(0, math.Min(upper, value))
*field = &value
return true
}
func setRemoteValue(settings *config.Settings, module, property string, value dbus.Variant) bool {
key := module + "." + property
if module == "SpeechManager" {
switch property {
case "Rate", "Pitch", "Volume":
var number float64
if property == "Rate" {
v, ok := value.Value().(int32)
if !ok || v < 0 || v > 99 {
return false
}
number = float64(v)
} else {
v, ok := value.Value().(float64)
if !ok {
return false
}
number = v
}
switch property {
case "Rate":
settings.DefaultVoice.Rate = &number
case "Pitch":
settings.DefaultVoice.AveragePitch = &number
case "Volume":
settings.DefaultVoice.Gain = &number
}
return true
case "CurrentSynthesizer":
v, ok := value.Value().(string)
if !ok || len(v) > 256 {
return false
}
settings.SpeechOutputModule = v
settings.DefaultVoice.Family = nil
return true
case "CurrentVoice":
v, ok := value.Value().(string)
if !ok || len(v) > 256 {
return false
}
if settings.DefaultVoice.Family == nil {
settings.DefaultVoice.Family = &config.VoiceFamily{}
} else {
family := *settings.DefaultVoice.Family
settings.DefaultVoice.Family = &family
}
settings.DefaultVoice.Family.Name = v
return true
case "PunctuationLevel":
v, ok := value.Value().(string)
if !ok {
return false
}
for i, name := range []string{"all", "most", "some", "none"} {
if strings.EqualFold(v, name) {
settings.VerbalizePunctuationStyle = i
return true
}
}
return false
}
}
path, ok := remoteFields[key]
if !ok {
return false
}
field := remoteField(settings, path)
if !field.IsValid() || !field.CanSet() {
return false
}
newValue := reflect.ValueOf(value.Value())
if !newValue.IsValid() || newValue.Type() != field.Type() {
return false
}
field.Set(newValue)
return true
}
func (presenter remoteSpeech) RemoteGet(module, property string) (dbus.Variant, bool) {
if presenter.live == nil {
return dbus.Variant{}, false
}
if module == "SpeechManager" && property == "SpeechIsEnabled" {
return dbus.MakeVariant(presenter.speech != nil && presenter.live.Load().EnableSpeech), true
}
if module == "SpeechManager" && presenter.speech == nil && (property == "CurrentServer" || property == "CurrentSynthesizer" || property == "CurrentVoice") {
return dbus.MakeVariant(""), true
}
return remoteValue(presenter.live.Load(), module, property)
}
func (presenter remoteSpeech) RemoteModulesAvailable() bool {
return presenter.live != nil && presenter.preferences != nil
}
func (presenter remoteSpeech) RemoteSet(module, property string, value dbus.Variant) bool {
return presenter.preferences != nil && presenter.preferences.SetRemote(module, property, value)
}
var remoteSpeechCommands = map[string]input.Command{
"SelectPreviousSpeechSetting": input.SelectPreviousSpeechSetting,
"SelectNextSpeechSetting": input.SelectNextSpeechSetting,
"DecreaseCurrentSpeechSetting": input.DecreaseSpeechSetting,
"IncreaseCurrentSpeechSetting": input.IncreaseSpeechSetting,
"SaveSpeechSettings": input.SaveSpeechSettings,
}
func (presenter remoteSpeech) RemoteCommand(module, command string, notify bool) bool {
if module == "SpeechManager" {
if command == "InterruptSpeech" {
return presenter.CancelSpeech()
}
if strings.HasPrefix(command, "Decrease") || strings.HasPrefix(command, "Increase") {
if presenter.preferences == nil {
return false
}
accepted := presenter.preferences.RunRemoteCommand(command)
if accepted && notify {
if message, ok := map[string]string{"DecreaseRate": "Speech slower", "IncreaseRate": "Speech faster", "DecreasePitch": "Speech lower", "IncreasePitch": "Speech higher", "DecreaseVolume": "Speech softer", "IncreaseVolume": "Speech louder"}[command]; ok {
presenter.PresentMessage(message)
}
}
return accepted
}
if mapped, ok := remoteSpeechCommands[command]; ok && presenter.preferences != nil {
return presenter.preferences.TrySpeechCommand(mapped)
}
}
if module == "TypingEchoManager" && command == "CycleKeyEcho" && presenter.live != nil {
current := presenter.live.Load().EnableKeyEcho
accepted := presenter.RemoteSet(module, "KeyEchoEnabled", dbus.MakeVariant(!current))
if accepted && notify {
if current {
presenter.PresentMessage("Key echo disabled")
} else {
presenter.PresentMessage("Key echo enabled")
}
}
return accepted
}
return false
}