// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "sync" "time" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/speech" ) // remoteSpeechVoice owns a separate SSIP speaking/control pair. Its cancellation // cannot target local speech, echo or status. This is a backend prerequisite, // not a Remote lease authorizer or a production mailbox owner. type remoteSpeechVoice struct { client *speech.Client ctx context.Context cancel context.CancelFunc once sync.Once closed chan struct{} closeErr error render sync.Mutex // One typed assembly, including metadata I/O. request sync.Mutex renderCancel context.CancelFunc // Protected by request; never backend I/O. } // open_remote_voice is called only by an output worker, never an input callback. // Acquisition neither changes the local configuration nor opens a serial device. // There is at most one handle; a replacement requires its predecessor to close. // ctx bounds acquisition only. A future lease owner must Close on revocation. func (speaker *configuredSpeaker) open_remote_voice(ctx context.Context) (*remoteSpeechVoice, error) { if !speaker.update.TryLock() { return nil, errors.New("speech configuration is busy") } defer speaker.update.Unlock() if err := ctx.Err(); err != nil { return nil, err } speaker.mu.RLock() _, software := speaker.active.(*echoSpeaker) closed, previous := speaker.closed, speaker.remote settings := speechSettingsFromConfig(speaker.settings) speaker.mu.RUnlock() if closed || !software { return nil, errors.New("isolated Remote speech requires a connected software backend") } if previous != nil { select { case <-previous.closed: default: return nil, errors.New("Remote speech voice is already owned") } } client := speech.NewWithSettings(speaker.socket, settings) if err := client.Connect(ctx); err != nil { return nil, err } if err := client.SetBackgroundPriority(ctx); err != nil { _ = client.Close(context.Background()) return nil, err } if err := ctx.Err(); err != nil { _ = client.Close(context.Background()) return nil, err } lifetime, cancel := context.WithCancel(context.Background()) voice := &remoteSpeechVoice{client: client, ctx: lifetime, cancel: cancel, closed: make(chan struct{})} speaker.mu.Lock() speaker.remote = voice speaker.mu.Unlock() return voice, nil } // A successful configuration commit retires the old remote voice. A failed // preparation must leave it usable. Handles never silently adopt new settings. func (speaker *configuredSpeaker) retire_remote_voice(settings config.Settings) { speaker.mu.Lock() speaker.settings = settings remote := speaker.remote speaker.mu.Unlock() if remote != nil { cleanup, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() // The local configuration has committed; cleanup cannot undo that commit. _ = remote.Close(cleanup) } } // SpeakAndWait retains ownership through the backend's END/CANCEL notification. // Both caller cancellation and configuration retirement cancel only this client. // This plain-text primitive deliberately does not interpret relay metadata. func (voice *remoteSpeechVoice) SpeakAndWait(ctx context.Context, text string, began func()) error { if err := voice.ctx.Err(); err != nil { return err } request, cancel := context.WithCancel(voice.ctx) stop := context.AfterFunc(ctx, cancel) defer func() { stop(); cancel() }() if err := ctx.Err(); err != nil { return err } return voice.client.SpeakAndWait(request, text, began) } func (voice *remoteSpeechVoice) Cancel(ctx context.Context) error { voice.request.Lock() cancel := voice.renderCancel if cancel != nil { cancel() } voice.request.Unlock() if cancel != nil { // Typed cancellation retires the whole assembly even while it is // preparing metadata and has no SSIP message to cancel yet. return nil } if voice.ctx.Err() != nil { return nil } return voice.client.Cancel(ctx) } func (voice *remoteSpeechVoice) Close(ctx context.Context) error { voice.once.Do(func() { voice.cancel() voice.closeErr = errors.Join(voice.client.Cancel(ctx), voice.client.Close(ctx)) close(voice.closed) }) return voice.closeErr }