Download frontend/src/ui/components/TtsEngineSection.jsx from ruslanmv/HomePilot: direct link, hf CLI and curl.
- Browser
- Download file 9.69 kB
-
https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/components/TtsEngineSection.jsx
- Command line
-
hf download hf://spaces/ruslanmv/HomePilot/frontend/src/ui/components/TtsEngineSection.jsx
-
curl -L -o TtsEngineSection.jsx https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/components/TtsEngineSection.jsx
9.69 kB
| /** | |
| * 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 (<div className="border-t border-white/5 pt-3"> | |
| <div className="text-[11px] uppercase tracking-wider text-white/40 mb-2 font-semibold"> | |
| TTS Engine | |
| </div> | |
| <div className="space-y-3"> | |
| <div> | |
| <label className="block text-[10px] text-white/50 mb-2">Engine</label> | |
| <select className="w-full bg-black border border-white/10 rounded-lg px-3 py-2 text-xs text-white" value={activeId} onChange={(e) => setActiveTtsEngine(e.target.value)}> | |
| {providers.map((p) => (<option key={p.id} value={p.id}> | |
| {p.displayName} | |
| </option>))} | |
| </select> | |
| {active?.id === 'piper-wasm' && (<div className="mt-1 text-[10px] text-white/40 leading-relaxed"> | |
| 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. | |
| </div>)} | |
| </div> | |
| {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 (<div key={resolved.key}> | |
| <label className="block text-[10px] text-white/50 mb-2"> | |
| {resolved.label} | |
| </label> | |
| <select className="w-full bg-black border border-white/10 rounded-lg px-3 py-2 text-xs text-white" value={String(value)} onChange={(e) => onChangeField(resolved.key, e.target.value)}> | |
| {resolved.options.map((o) => (<option key={o.value} value={o.value}> | |
| {o.label} | |
| </option>))} | |
| </select> | |
| {resolved.description ? (<div className="mt-1 text-[10px] text-white/40">{resolved.description}</div>) : null} | |
| </div>); | |
| } | |
| if (resolved.kind === 'range') { | |
| return (<div key={resolved.key}> | |
| <div className="flex items-center justify-between"> | |
| <label className="text-[10px] text-white/50">{resolved.label}</label> | |
| <span className="text-[10px] text-white/60 font-mono"> | |
| {typeof value === 'number' ? value.toFixed(2) : value} | |
| </span> | |
| </div> | |
| <input type="range" min={resolved.min} max={resolved.max} step={resolved.step} value={typeof value === 'number' ? value : resolved.defaultValue} onChange={(e) => onChangeField(resolved.key, Number(e.target.value))} className="w-full accent-cyan-400"/> | |
| </div>); | |
| } | |
| // toggle | |
| return (<label key={resolved.key} className="flex items-center gap-2 cursor-pointer"> | |
| <input type="checkbox" checked={Boolean(value)} onChange={(e) => onChangeField(resolved.key, e.target.checked)} className="w-4 h-4 rounded"/> | |
| <span className="text-xs text-white">{resolved.label}</span> | |
| </label>); | |
| })) : !active ? (<div className="text-[10px] text-white/40"> | |
| No TTS engine available in this environment. | |
| </div>) : null} | |
| {active && !isDefaultEngine && !active.capabilities.pitch && (<div className="text-[10px] text-white/40 italic"> | |
| The {active.displayName.split(' (')[0]} engine does not support a pitch control in | |
| playback. The Creator Studio export applies pitch post-synthesis via ffmpeg. | |
| </div>)} | |
| {/* 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 && (<div className="flex items-center gap-2 pt-1"> | |
| <button type="button" onClick={onTest} className="text-[11px] px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/15 border border-white/10 text-white/80" aria-pressed={testing}> | |
| {testing ? 'Stop' : 'Test voice'} | |
| </button> | |
| <span className="text-[10px] text-white/40"> | |
| Hello, this is a preview of your selected voice. | |
| </span> | |
| </div>)} | |
| {testError ? (<div className="text-[10px] text-red-300/80">{testError}</div>) : null} | |
| </div> | |
| </div>); | |
| } | |