fix: serialize all audio (no overlap) + GUI shows daemon's TTS, not its own
## Issue 1: Voices were overlapping
Root cause was structural, not a single bug: every TTS backend's speak()
was fire-and-forget (`cmd.spawn()` instead of `cmd.status()`) AND cue
WAVs went through a completely SEPARATE audio path (Kira) with no
coordination. Two back-to-back calls — `voices::play_reply()` + a
`tts::speak("Готово")` + an idle banter chime — all started their
audio at the same moment.
Fix: ONE speech worker thread drains a `mpsc::Sender<Job>` channel.
Jobs are `Speak { text, opts }` or `Wav(path)`. The worker:
- forces opts.detached = false so backends BLOCK until audio finishes
- plays WAVs synchronously via PowerShell SoundPlayer
- thus implicitly serialises everything queued behind it
Both call paths now feed the same queue:
- `tts::speak()` → Job::Speak
- `tts::play_wav()` → Job::Wav (used by Piper/Silero for their .wav output)
- `voices::play_random_from_list()` now also calls `tts::play_wav()`
instead of `audio::play_sound()` — that re-routes cue sounds through
the same queue. Trade-off: cue WAVs lose Kira's native low-latency
start (~50ms slower) but gain guaranteed ordering against TTS.
Two new unit tests:
- `speech_queue_serialises_calls`: fires 5 speak() back-to-back with an
80ms-per-call fake backend, asserts gaps between starts are ≥60ms
(i.e. the worker IS waiting). Confirms no overlap.
- `empty_text_is_skipped`: the guard against accidentally queueing
empty/whitespace-only phrases that would briefly stall the worker.
## Issue 2: GUI shows ITS OWN tts state, not the daemon's
User toggled TTS dropdown → "Saved!" banner → "Активный движок" chip
still showed sapi. Reason: `get_active_backends` Tauri command read
THIS process's (GUI's) `tts::backend()`. But the GUI process hardly
ever speaks — the daemon (jarvis-app, separate process) is what
actually replies to voice commands. Two processes, two TTS state
machines, GUI was showing the wrong one.
Fix: settings page now prefers `$daemonHealth` (filled via the
existing WS `query_health` event from the daemon) for the "Активный
движок" chip. Falls back to GUI's local `activeBackends` only when
the daemon is unreachable, and shows an orange "демон не отвечает —
показано из GUI" badge so the user knows the chip is stale.
Plus:
- `applyTtsBackend` / `applyLlmBackend` now call `queryDaemonHealth()`
right after the swap so the chip updates within ~100ms instead of
waiting up to 5s for the next footer poll.
- `switchDaemonTts` / `switchDaemonLlm` returns false when WS isn't
connected. The page now reads that return value and SURFACES a red
notification — previously the swap silently went nowhere if the
daemon wasn't running, which exactly matches the user's "GUI looks
changed but nothing happens" symptom.
## Tests
- 145 → 147 rust core tests (+2 speech queue).
- Frontend rebuilds in 6.16s; release jarvis-app + jarvis-gui green.
This commit is contained in:
parent
73fc404ec7
commit
8ff87f3096
3 changed files with 227 additions and 21 deletions
|
|
@ -8,6 +8,7 @@
|
|||
import {
|
||||
appInfo, assistantVoice, translations, translate,
|
||||
switchDaemonLlm, reloadDaemonLlm, switchDaemonTts,
|
||||
daemonHealth, queryDaemonHealth,
|
||||
} from "@/stores"
|
||||
|
||||
import HDivider from "@/components/elements/HDivider.svelte"
|
||||
|
|
@ -23,7 +24,8 @@
|
|||
Input,
|
||||
InputWrapper,
|
||||
NativeSelect,
|
||||
Switch
|
||||
Switch,
|
||||
Badge,
|
||||
} from "@svelteuidev/core"
|
||||
|
||||
import {
|
||||
|
|
@ -128,9 +130,16 @@
|
|||
// 1. Swap on GUI process (persists to DB).
|
||||
await invoke<string>("set_llm_backend", { name: target })
|
||||
// 2. Tell daemon to swap too, so the running listener uses the
|
||||
// new backend immediately. No-op if daemon not connected.
|
||||
switchDaemonLlm(target as "groq" | "ollama")
|
||||
// new backend immediately. Returns false if WS disconnected
|
||||
// so we can warn the user — otherwise they'd think the swap
|
||||
// silently failed.
|
||||
const daemonAware = switchDaemonLlm(target as "groq" | "ollama")
|
||||
if (!daemonAware) {
|
||||
backendSwapError =
|
||||
"Демон не подключён — LLM в нём переключится при следующем запуске."
|
||||
}
|
||||
await refreshActiveBackends()
|
||||
queryDaemonHealth()
|
||||
} catch (err) {
|
||||
backendSwapError = String(err)
|
||||
} finally {
|
||||
|
|
@ -157,11 +166,20 @@
|
|||
const res = await invoke<TtsSwapResult>("set_tts_backend", { name: target })
|
||||
ttsSwapInfo = res
|
||||
// 2. Tell the running daemon (separate process) to switch too.
|
||||
switchDaemonTts(target || "auto")
|
||||
// 3. Refresh the "Активный движок" chip — without this, the user
|
||||
// saves Piper, sees "saved!", but the active label stays SAPI
|
||||
// because we never re-read the live engine.
|
||||
// sendAction returns false if the WS isn't connected —
|
||||
// surface that so the user knows the daemon won't change.
|
||||
const daemonAware = switchDaemonTts(target || "auto")
|
||||
if (!daemonAware) {
|
||||
backendSwapError =
|
||||
"Демон (jarvis-app) не подключён. Изменение применится при следующем запуске. " +
|
||||
"Запусти Jarvis с главного экрана и повтори, если нужно немедленно."
|
||||
}
|
||||
// 3. Refresh the local GUI snapshot AND re-poll daemon health
|
||||
// so the "Активный движок" chip updates within ~100ms.
|
||||
// Without the daemon re-poll, the chip keeps showing the
|
||||
// OLD daemon-side TTS until the next 5s footer poll.
|
||||
await refreshActiveBackends()
|
||||
queryDaemonHealth()
|
||||
} catch (err) {
|
||||
backendSwapError = String(err)
|
||||
}
|
||||
|
|
@ -412,13 +430,34 @@
|
|||
<Tabs.Tab label={t('settings-ai-backends') || 'AI Backends'} icon={ChatBubble}>
|
||||
<Space h="sm" />
|
||||
|
||||
{#if activeBackends}
|
||||
<!--
|
||||
The user-visible "Активный движок" chip MUST reflect the running
|
||||
daemon (jarvis-app), not this GUI process. Otherwise toggling
|
||||
the TTS dropdown looks like nothing happens — the GUI hardly
|
||||
speaks, so its own backend stays stale.
|
||||
|
||||
Preference order:
|
||||
1. `daemonHealth` store (filled via WS `query_health` event)
|
||||
2. local `activeBackends` (GUI's own state) as fallback when
|
||||
the daemon isn't running
|
||||
Plus a connection badge so the user knows what they're seeing.
|
||||
-->
|
||||
{#if $daemonHealth || activeBackends}
|
||||
<Alert title={t('settings-ai-active') || 'Активный движок'} color="cyan" variant="outline">
|
||||
<Text size="sm" color="gray">
|
||||
LLM: <strong>{activeBackends.llm}</strong>
|
||||
{#if activeBackends.llm_model}({activeBackends.llm_model}){/if}
|
||||
· TTS: <strong>{activeBackends.tts}</strong>
|
||||
· {t('settings-profile') || 'Profile'}: <strong>{activeBackends.profile}</strong>
|
||||
{#if $daemonHealth}
|
||||
LLM: <strong>{$daemonHealth.llm_backend}</strong>
|
||||
{#if $daemonHealth.llm_model}({$daemonHealth.llm_model}){/if}
|
||||
· TTS: <strong>{$daemonHealth.tts_backend}</strong>
|
||||
· {t('settings-profile') || 'Profile'}: <strong>{$daemonHealth.active_profile}</strong>
|
||||
<Badge color="lime" variant="filled" size="xs">демон</Badge>
|
||||
{:else if activeBackends}
|
||||
LLM: <strong>{activeBackends.llm}</strong>
|
||||
{#if activeBackends.llm_model}({activeBackends.llm_model}){/if}
|
||||
· TTS: <strong>{activeBackends.tts}</strong>
|
||||
· {t('settings-profile') || 'Profile'}: <strong>{activeBackends.profile}</strong>
|
||||
<Badge color="orange" variant="filled" size="xs">демон не отвечает — показано из GUI</Badge>
|
||||
{/if}
|
||||
</Text>
|
||||
</Alert>
|
||||
<Space h="md" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue