Preview voices as you change through settings.
This commit is contained in:
1 parent
c35e60ea30
commit
6168e541cd
7 files changed
+329
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user