diff --git a/internal/app/application.go b/internal/app/application.go index 13fc5122..dc3b7f13 100644 --- a/internal/app/application.go +++ b/internal/app/application.go @@ -59,6 +59,7 @@ type application struct { overrideLoads chan func() speechChoices chan preferences.Discovery prepareSpeech func(config.Speech) (speech.Transport, error) + preview speechPreview notificationSound notificationSoundGate notificationHistory *notifications.Client @@ -106,8 +107,10 @@ func new_application(ctx context.Context, source Source, keyboards Keyboards, vo a.ui = new_internal_ui(ctx, keyboards, a.voice) a.ui.gridSettings = func() config.InternalUI { return a.settings.live.InternalUI } a.ui.previewSpelling = a.preview_spelling + a.ui.previewSpeech, a.ui.restoreSpeech = a.preview_menu_speech, a.restore_speech_preview a.ui.discoverChoices, a.ui.saveSettings = options.SettingsChoices, a.save_menu_settings a.speechResults = make(chan speechChangeResult, 1) + a.preview.results = make(chan speechPreviewResult, 1) a.overrideLoads = make(chan func(), 1) a.speechChoices = make(chan preferences.Discovery, 1) a.prepareSpeech = prepare_speech diff --git a/internal/app/event_loop.go b/internal/app/event_loop.go index 3941883b..8d41529c 100644 --- a/internal/app/event_loop.go +++ b/internal/app/event_loop.go @@ -139,6 +139,8 @@ func (a *application) loop(ticks <-chan time.Time) error { a.quick.discovery = discovery case loaded := <-a.overrideLoads: loaded() + case result := <-a.preview.results: + a.speech_preview_result(result) case result := <-a.speechResults: a.speech_result(result) if len(a.overrides) != 0 || a.overrideCurrent != nil { diff --git a/internal/app/fenrir_menu.go b/internal/app/fenrir_menu.go index bccbb426..ea2d90a6 100644 --- a/internal/app/fenrir_menu.go +++ b/internal/app/fenrir_menu.go @@ -53,6 +53,15 @@ func (ui *internalUI) open_settings_context(settings config.Settings, path strin ui.previewSpelling(draft) } } + if restore := ui.restoreSpeech; restore != nil { + restoreSpelling := ui.onClosed + ui.onClosed = func() { + if restoreSpelling != nil { + restoreSpelling() + } + restore() + } + } previewChanged := ui.changed speechOverride := ui.speechOverride go func() { @@ -108,10 +117,20 @@ func (ui *internalUI) open_settings_context(settings config.Settings, path strin pending := map[string]bool{} var tested config.Speech var testSequence uint64 + previewSpeech := func(session *interaction.Session) { + if ui.previewSpeech == nil { + return + } + if draft, err := readSettings(session, doc.Settings); err == nil { + ui.previewSpeech(draft.Presentation.Speech) + } + } changed := func(session *interaction.Session) { if previewChanged != nil { previewChanged(session) } + // Discovery below can replace dependent voice choices first. + defer previewSpeech(session) if ui.cancelTest != nil { draft, err := readSettings(session, doc.Settings) if err != nil || draft.Presentation.Speech != tested { @@ -148,6 +167,7 @@ func (ui *internalUI) open_settings_context(settings config.Settings, path strin } discovery = result discovery.Refresh_catalog(session, previous) + previewSpeech(session) if ui.catalog != nil { ui.catalog(result) } diff --git a/internal/app/internal_ui.go b/internal/app/internal_ui.go index 43dadec1..5f92487b 100644 --- a/internal/app/internal_ui.go +++ b/internal/app/internal_ui.go @@ -55,6 +55,8 @@ type internalUI struct { onClosed func() gridSettings func() config.InternalUI previewSpelling func(config.Reading) + previewSpeech func(config.Speech) + restoreSpeech func() speechOverride *config.Speech testSpeech func(context.Context, config.Speech, config.Speech) error cancelTest context.CancelFunc diff --git a/internal/app/runtime_settings.go b/internal/app/runtime_settings.go index c767b7f4..c23c156c 100644 --- a/internal/app/runtime_settings.go +++ b/internal/app/runtime_settings.go @@ -208,14 +208,23 @@ func (a *application) save_menu_settings(doc *config.Document, draft config.Sett a.ui.failed("Could not save settings: {0}", fmt.Errorf("speech selection in progress")) return false } + if a.after_speech_preview(func() { + if !a.save_menu_settings(doc, draft, done) { + done(fmt.Errorf("settings save in progress")) + } + }) { + return true + } previousDraft := a.settings.saveDraft liveSpeech, draftSpeech := a.settings.live.Presentation.Speech, draft.Presentation.Speech sameDevice := draftSpeech.Driver == liveSpeech.Driver && draftSpeech.HardwareDevice == liveSpeech.HardwareDevice && draftSpeech.HardwareBaudRate == liveSpeech.HardwareBaudRate profileSelected := draft.VoiceProfiles.Matching(config.Primary_voice(draft)) != "" speechApplied := a.settings.speechAvailable && (sameDevice || profileSelected) && !same_speech_selection(draftSpeech, liveSpeech) + // The menu preview may already be speaking with this selection. + previewed := a.preview.active && same_speech_selection(a.preview.selection, draftSpeech) if !a.saver.start(a.ctx, draft, func() error { var prepared speech.Transport - if speechApplied { + if speechApplied && !previewed { var err error prepared, err = a.prepareSpeech(draft.Presentation.Speech) if err != nil { @@ -281,6 +290,11 @@ func (a *application) save_menu_settings(doc *config.Document, draft config.Sett a.settings.resetBase = draft // RESET reflects disk, not later overrides. a.quick.changed = newerQuick } + // A committed save owns the voice now; otherwise the form stays + // open with its preview. Either way the live settings decide. + if settings_committed(err) { + a.restore_speech_preview() + } done(err) } return true diff --git a/internal/app/speech_preview.go b/internal/app/speech_preview.go new file mode 100644 index 00000000..457c1043 --- /dev/null +++ b/internal/app/speech_preview.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "log/slog" + "time" + + "git.stormux.org/storm/fenrir/internal/config" + "git.stormux.org/storm/fenrir/internal/speech" +) + +// speechPreview lets the Fenrir Menu speak with its unsaved voice. The event +// loop owns every field. One candidate is prepared at a time; newer form +// values replace the queued one. Closing the menu, or committing a save, +// returns the transport to the live settings. +type speechPreview struct { + active bool + busy bool + selection config.Speech + parameters speech.Parameters + queued *config.Speech + idle []func() + results chan speechPreviewResult +} + +type speechPreviewResult struct { + selected config.Speech + err error +} + +func previewable_selection(driverID string) bool { + driver, err := speech.Find_driver(driverID) + return err == nil && !driver.Hardware && driver.Voices != nil +} + +// preview_menu_speech applies unsaved menu values to the running voice. +// Hardware synthesizers keep their explicit voice test instead. +func (a *application) preview_menu_speech(selected config.Speech) { + live := a.settings.live + if !a.settings.speechAvailable || selected.Driver != live.Presentation.Speech.Driver { + return + } + parameters := speech.Parameters{Rate: selected.Rate, Pitch: selected.Pitch, Volume: selected.Volume} + if parameters.Validate() != nil { + return + } + preview := &a.preview + if !preview.active { + preview.active = true + preview.selection = live.Presentation.Speech + preview.parameters = speech_parameters(live) + } + if parameters != preview.parameters { + preview.parameters = parameters + a.voice.Configure(parameters) + } + if previewable_selection(selected.Driver) { + a.preview_selection(selected) + } +} + +func (a *application) preview_selection(selected config.Speech) { + preview := &a.preview + if preview.busy { + preview.queued = &selected + return + } + if same_speech_selection(selected, preview.selection) { + return + } + preview.busy = true + go func() { + prepared, err := a.prepareSpeech(selected) + if err == nil { + err = a.replace_speech(prepared) + } + select { + case preview.results <- speechPreviewResult{selected: selected, err: err}: + case <-a.ctx.Done(): + } + }() +} + +func (a *application) speech_preview_result(result speechPreviewResult) { + preview := &a.preview + preview.busy = false + if result.err == nil { + preview.selection = result.selected + } else { + slog.Warn("speech preview unavailable", "error", result.err, "at", time.Now()) + } + if queued := preview.queued; queued != nil { + preview.queued = nil + a.preview_selection(*queued) + } + if preview.busy { + return + } + if same_speech_selection(preview.selection, a.settings.live.Presentation.Speech) && + preview.parameters == speech_parameters(a.settings.live) { + preview.active = false + } + idle := preview.idle + preview.idle = nil + for _, run := range idle { + run() + } +} + +// restore_speech_preview returns the running voice to the live settings. +func (a *application) restore_speech_preview() { + preview := &a.preview + if !preview.active { + return + } + live := a.settings.live + if parameters := speech_parameters(live); parameters != preview.parameters { + preview.parameters = parameters + a.voice.Configure(parameters) + } + if previewable_selection(preview.selection.Driver) { + a.preview_selection(live.Presentation.Speech) + } + if !preview.busy && same_speech_selection(preview.selection, live.Presentation.Speech) { + preview.active, preview.queued = false, nil + } +} + +// after_speech_preview runs work once no preview candidate is in flight, so a +// save's own transport replacement cannot be overtaken by an older preview. +func (a *application) after_speech_preview(run func()) bool { + if !a.preview.busy { + return false + } + a.preview.idle = append(a.preview.idle, run) + return true +} diff --git a/internal/app/speech_preview_test.go b/internal/app/speech_preview_test.go new file mode 100644 index 00000000..3a28709b --- /dev/null +++ b/internal/app/speech_preview_test.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "testing" + "time" + + "git.stormux.org/storm/fenrir/internal/config" + "git.stormux.org/storm/fenrir/internal/interaction" + "git.stormux.org/storm/fenrir/internal/speech" +) + +func preview_application(t *testing.T) (*application, *selectingVoice, string, chan config.Speech) { + t.Helper() + a, voice, path := selection_application(t, true) + prepared := make(chan config.Speech, 8) + a.prepareSpeech = func(selected config.Speech) (speech.Transport, error) { + prepared <- selected + return &preparedVoice{}, nil + } + return a, voice, path, prepared +} + +func finish_preview(t *testing.T, a *application, voice *selectingVoice) { + t.Helper() + select { + case result := <-a.preview.results: + if result.err == nil { + (<-voice.replaced).Close() + } + a.speech_preview_result(result) + case <-time.After(time.Second): + t.Fatal("speech preview stalled") + } +} + +func last_parameters(t *testing.T, voice *selectingVoice) speech.Parameters { + t.Helper() + if len(voice.parameters) == 0 { + t.Fatal("no speech parameters were configured") + } + return voice.parameters[len(voice.parameters)-1] +} + +func TestMenuSpeechPreviewAppliesUnsavedVoiceAndRestoresOnClose(t *testing.T) { + a, voice, _, prepared := preview_application(t) + live := a.settings.live.Presentation.Speech + selected := live + selected.Module, selected.Rate = "fixture", 40 + a.preview_menu_speech(selected) + if got := last_parameters(t, voice); got.Rate != 40 { + t.Fatalf("rate preview = %+v", got) + } + if got := <-prepared; got.Module != "fixture" { + t.Fatalf("prepared module = %q", got.Module) + } + // A newer form value waits for the candidate in flight. + newer := selected + newer.Module = "second" + a.preview_menu_speech(newer) + finish_preview(t, a, voice) + if got := <-prepared; got.Module != "second" { + t.Fatalf("queued module = %q", got.Module) + } + finish_preview(t, a, voice) + if a.preview.selection.Module != "second" || a.settings.live.Presentation.Speech.Module != live.Module { + t.Fatal("preview changed live settings", a.preview.selection, a.settings.live.Presentation.Speech) + } + a.restore_speech_preview() + if got := last_parameters(t, voice); got != speech_parameters(a.settings.live) { + t.Fatalf("restored parameters = %+v", got) + } + if got := <-prepared; got.Module != live.Module { + t.Fatalf("restored module = %q", got.Module) + } + finish_preview(t, a, voice) + if a.preview.active { + t.Fatal("preview still active after restore") + } +} + +func TestMenuSaveWaitsForPreviewAndKeepsPreviewedVoice(t *testing.T) { + a, voice, path, prepared := preview_application(t) + selected := a.settings.live.Presentation.Speech + selected.Module = "fixture" + a.preview_menu_speech(selected) + <-prepared + doc, err := config.Open_document(path, a.settings.live) + if err != nil { + t.Fatal(err) + } + draft := doc.Settings + draft.Presentation.Speech.Module = "fixture" + var outcome error + if !a.save_menu_settings(doc, draft, func(err error) { outcome = err }) { + t.Fatal("save not admitted") + } + if a.saver.busy { + t.Fatal("save overtook the preview in flight") + } + finish_preview(t, a, voice) + if !a.saver.busy { + t.Fatal("deferred save did not start") + } + finish_persistence(t, a, false) + if outcome != nil || a.settings.live.Presentation.Speech.Module != "fixture" || a.preview.active { + t.Fatal("save did not commit the previewed voice", outcome, a.settings.live.Presentation.Speech, a.preview) + } + select { + case extra := <-prepared: + t.Fatal("save reconnected the previewed voice", extra) + default: + } +} + +func TestHardwareSelectionIsNotPreviewed(t *testing.T) { + a, voice, _, prepared := preview_application(t) + selected := a.settings.live.Presentation.Speech + selected.Driver, selected.Rate = "dectalk", 30 + a.preview_menu_speech(selected) + if len(voice.parameters) != 0 || a.preview.active { + t.Fatal("a different driver changed the running voice") + } + select { + case got := <-prepared: + t.Fatal("hardware selection was prepared", got) + default: + } +} + +func TestMenuRateChangeIsHeardAndEscapeRestoresIt(t *testing.T) { + a, voice := persistence_application(t, true) + live := speech_parameters(a.settings.live) + a.open_settings() + receive_ui(t, a.ui) + a.ui.handle(interaction.Key{Name: "tab"}) + a.ui.handle(interaction.Key{Name: "tab"}) + a.ui.handle(interaction.Key{Name: "right"}) + if len(voice.parameters) == 0 || voice.parameters[len(voice.parameters)-1].Rate != live.Rate+1 { + t.Fatal("menu rate change was not previewed", voice.parameters, live) + } + a.ui.handle(interaction.Key{Name: "escape"}) + if got := voice.parameters[len(voice.parameters)-1]; got != live || a.preview.active { + t.Fatal("escape did not restore the live voice", got, live) + } +}