/** * Schema-driven Settings section for the active TTS engine. * * This component is deliberately self-contained so SettingsPanel can mount * it with one line and stay agnostic of which engines are installed. * * Behavior: * - Dropdown to pick the active engine (only ``isAvailable()`` ones). * - Below: controls rendered from the active provider's * ``getSettingsSchema()`` — ``select`` / ``range`` / ``toggle``. * - The Web Speech provider's ``voiceId`` schema has a placeholder * options list; we merge in the browser's live voices at render * time so the user sees their actual installed voices. * - Every edit persists to the registry's per-user scoped settings * bucket AND to ``value`` via ``onChangeDraft`` so the existing * Voice Assistant code continues to read ``value.selectedVoice``. * * Purely additive: the existing System Voice select stays untouched * above this component, and this section only appears when the user * opts into it by picking a non-default engine (or leaves it on * ``web-speech-api`` where we render the same controls the old UI did). */ import React, { useEffect, useMemo, useState } from 'react'; import { getActiveTtsEngineId, listTtsProviders, onActiveTtsEngineChange, readTtsProviderSettings, setActiveTtsEngine, writeTtsProviderSettings, } from '../tts'; function _fieldValue(schema, saved) { const v = saved[schema.key]; if (schema.kind === 'range') { return typeof v === 'number' ? v : schema.defaultValue; } if (schema.kind === 'toggle') { return typeof v === 'boolean' ? v : schema.defaultValue; } return typeof v === 'string' ? v : schema.defaultValue; } function _mergeWebSpeechOptions(field, voices) { if (field.kind !== 'select' || field.key !== 'voiceId' || voices.length === 0) { return field; } const live = voices.map((v) => ({ value: v.voiceURI || v.name, label: `${v.name} (${v.lang})`, })); return { ...field, options: [...field.options, ...live] }; } export default function TtsEngineSection({ systemVoices }) { const [activeId, setActiveIdState] = useState(() => getActiveTtsEngineId()); // Re-render when another component swaps the active engine. useEffect(() => onActiveTtsEngineChange((id) => setActiveIdState(id)), []); const providers = useMemo(() => listTtsProviders().filter((p) => p.isAvailable()), [activeId]); const active = useMemo(() => providers.find((p) => p.id === activeId), [providers, activeId]); // Saved settings blob for the active provider. const [settings, setSettings] = useState(() => readTtsProviderSettings(activeId)); useEffect(() => { setSettings(readTtsProviderSettings(activeId)); }, [activeId]); // When the default engine is selected the host panel already renders // a dedicated "Assistant Voice" / "System Voice" dropdown above us // that covers voice + rate + pitch for Web Speech. Rendering our // schema-driven twin on top of it is pure duplication — skip it // unless the user has opted into a non-default engine (Piper etc.). const isDefaultEngine = activeId === 'web-speech-api'; const schema = active && !isDefaultEngine ? active.getSettingsSchema() : []; // Test-voice state: the button speaks through whichever engine is // active right now so the user can hear their pick without leaving // Settings. Mirrors the Preview button in the Creator Studio wizard. const [testing, setTesting] = useState(false); const [testError, setTestError] = useState(null); const onTest = () => { setTestError(null); if (!active) return; if (testing) { try { active.stop(); } catch { /* ignore */ } setTesting(false); return; } setTesting(true); const voiceId = typeof settings.voiceId === 'string' ? settings.voiceId : undefined; const rate = typeof settings.rate === 'number' ? settings.rate : undefined; const pitch = typeof settings.pitch === 'number' ? settings.pitch : undefined; active .speak('Hello, this is a preview of your selected voice.', { voiceId, rate, pitch, onEnd: () => setTesting(false), onError: (err) => { setTestError(String(err?.message || err)); setTesting(false); }, }) .catch((err) => { setTestError(String(err?.message || err)); setTesting(false); }); }; const onChangeField = (key, value) => { const next = { ...settings, [key]: value }; setSettings(next); writeTtsProviderSettings(activeId, { [key]: value }); }; return (
TTS Engine
{active?.id === 'piper-wasm' && (
Piper runs fully in-browser via WebAssembly. First use downloads a voice model (~20 MB, cached). Falls back to the HomePilot mirror when the upstream CDN is unreachable.
)}
{active && !isDefaultEngine ? (schema.map((field) => { const resolved = active.id === 'web-speech-api' && field.kind === 'select' && field.key === 'voiceId' ? _mergeWebSpeechOptions(field, systemVoices ?? (typeof window !== 'undefined' && 'speechSynthesis' in window ? window.speechSynthesis.getVoices() : [])) : field; const value = _fieldValue(resolved, settings); if (resolved.kind === 'select') { return (
{resolved.description ? (
{resolved.description}
) : null}
); } if (resolved.kind === 'range') { return (
{typeof value === 'number' ? value.toFixed(2) : value}
onChangeField(resolved.key, Number(e.target.value))} className="w-full accent-cyan-400"/>
); } // toggle return (); })) : !active ? (
No TTS engine available in this environment.
) : null} {active && !isDefaultEngine && !active.capabilities.pitch && (
The {active.displayName.split(' (')[0]} engine does not support a pitch control in playback. The Creator Studio export applies pitch post-synthesis via ffmpeg.
)} {/* Test voice button — positioned AFTER the voice + rate + pitch controls so users pick their voice first, then preview it (best-practice: action sits at the end of the configuration flow). Always visible; mirrors the "Preview voice" affordance in the Creator Studio export wizard. */} {active && (
Hello, this is a preview of your selected voice.
)} {testError ? (
{testError}
) : null}
); }