Preview voices as you change through settings.

This commit is contained in:
Storm Dragon committed 2026-11-15 15:19:32 -05:00
1 parent c35e60ea30
commit 6168e541cd
7 files changed
+329 -1

No files matched your search

+3
View File
@@ -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
+2
View File
@@ -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 {
+20
View File
@@ -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)
}
+2
View File
@@ -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
+15 -1
View File
@@ -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
+139
View File
@@ -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
}
+148
View File
@@ -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)
}
}