| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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()); |
| |
| 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]); |
| |
| const [settings, setSettings] = useState(() => readTtsProviderSettings(activeId)); |
| useEffect(() => { |
| setSettings(readTtsProviderSettings(activeId)); |
| }, [activeId]); |
| |
| |
| |
| |
| |
| const isDefaultEngine = activeId === 'web-speech-api'; |
| const schema = active && !isDefaultEngine ? active.getSettingsSchema() : []; |
| |
| |
| |
| const [testing, setTesting] = useState(false); |
| const [testError, setTestError] = useState(null); |
| const onTest = () => { |
| setTestError(null); |
| if (!active) |
| return; |
| if (testing) { |
| try { |
| active.stop(); |
| } |
| catch { } |
| 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>); |
| } |
|
|