## 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.