HomePilot / frontend /src /ui /components /TtsEngineSection.jsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
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>);
}