fix: serialize all audio (no overlap) + GUI shows daemon's TTS, not its own
Some checks failed
Rust CI / cargo test (jarvis-core) (push) Has been cancelled
Rust CI / cargo clippy (push) Has been cancelled
Rust CI / cargo check (workspace) (push) Has been cancelled

## 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:
Bossiara13 2026-05-24 23:35:46 +03:00
parent 73fc404ec7
commit 8ff87f3096
3 changed files with 227 additions and 21 deletions

View file

@ -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}
&nbsp;·&nbsp; TTS: <strong>{activeBackends.tts}</strong>
&nbsp;·&nbsp; {t('settings-profile') || 'Profile'}: <strong>{activeBackends.profile}</strong>
{#if $daemonHealth}
LLM: <strong>{$daemonHealth.llm_backend}</strong>
{#if $daemonHealth.llm_model}({$daemonHealth.llm_model}){/if}
&nbsp;·&nbsp; TTS: <strong>{$daemonHealth.tts_backend}</strong>
&nbsp;·&nbsp; {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}
&nbsp;·&nbsp; TTS: <strong>{activeBackends.tts}</strong>
&nbsp;·&nbsp; {t('settings-profile') || 'Profile'}: <strong>{activeBackends.profile}</strong>
<Badge color="orange" variant="filled" size="xs">демон не отвечает — показано из GUI</Badge>
{/if}
</Text>
</Alert>
<Space h="md" />