import { For, Show, createEffect, createMemo, createSignal, type Component } from "solid-js" import { Loader2, Mic, Square, Volume2 } from "lucide-solid" import { useConfig, type SpeechSettings } from "../../stores/preferences" import { useI18n } from "../../lib/i18n" import { loadSpeechCapabilities, speechCapabilities, speechCapabilitiesError, speechCapabilitiesLoading } from "../../stores/speech" import { getLogger } from "../../lib/logger" import { useSpeech } from "../../lib/hooks/use-speech" import { getSpeechPlaybackSupport } from "../../lib/speech-playback-support" const log = getLogger("actions") type DraftFields = { apiKey: string baseUrl: string sttModel: string ttsModel: string ttsVoice: string playbackMode: SpeechSettings["playbackMode"] ttsFormat: SpeechSettings["ttsFormat"] } function createDraftFields(speech: SpeechSettings): DraftFields { return { apiKey: "", baseUrl: speech.baseUrl ?? "", sttModel: speech.sttModel, ttsModel: speech.ttsModel, ttsVoice: speech.ttsVoice, playbackMode: speech.playbackMode, ttsFormat: speech.ttsFormat, } } function isDraftEqual(a: DraftFields, b: DraftFields): boolean { return ( a.apiKey === b.apiKey && a.baseUrl === b.baseUrl && a.sttModel === b.sttModel && a.ttsModel === b.ttsModel && a.ttsVoice === b.ttsVoice && a.playbackMode === b.playbackMode && a.ttsFormat === b.ttsFormat ) } export const SpeechSettingsCard: Component = () => { const { t } = useI18n() const { serverSettings, updateSpeechSettings } = useConfig() const initialDrafts = createDraftFields(serverSettings().speech) const [isSaving, setIsSaving] = createSignal(false) const [saveStatus, setSaveStatus] = createSignal<"idle" | "saved" | "error">("saved") const [drafts, setDrafts] = createSignal(initialDrafts) const [apiKeyTouched, setApiKeyTouched] = createSignal(false) const [clearStoredApiKey, setClearStoredApiKey] = createSignal(false) const testSpeech = useSpeech({ id: () => "settings-speech-test", text: () => t("settings.speech.testPlayback.sample"), settingsOverride: () => ({ playbackMode: drafts().playbackMode, ttsFormat: drafts().ttsFormat, }), }) createEffect(() => { const speech = serverSettings().speech const nextDrafts = createDraftFields(speech) if (!isSaving() && !isDirty()) { if (!isDraftEqual(drafts(), nextDrafts)) { setDrafts(nextDrafts) } if (apiKeyTouched()) { setApiKeyTouched(false) } if (clearStoredApiKey()) { setClearStoredApiKey(false) } } }) createEffect(() => { void loadSpeechCapabilities() }) const capabilityLabel = () => { if (speechCapabilitiesLoading()) return t("settings.speech.status.loading") if (speechCapabilitiesError()) return t("settings.speech.status.error") return speechCapabilities()?.configured ? t("settings.speech.status.configured") : t("settings.speech.status.missing") } const updateDraft = (key: keyof DraftFields, value: string) => { setSaveStatus("idle") if (key === "apiKey") { setApiKeyTouched(true) setClearStoredApiKey(false) } setDrafts((current) => ({ ...current, [key]: value })) } const apiKeyDirty = createMemo(() => clearStoredApiKey() || drafts().apiKey.trim().length > 0) const playbackSupport = createMemo(() => getSpeechPlaybackSupport({ playbackMode: drafts().playbackMode, ttsFormat: drafts().ttsFormat, capabilities: speechCapabilities(), }), ) const compatibilityMessage = createMemo(() => { const capabilities = speechCapabilities() if (!capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) { return null } if (drafts().playbackMode === "streaming" && !capabilities.supportsStreamingTts) { return t("settings.speech.compatibility.streamingUnavailable") } if (drafts().playbackMode === "streaming" && !playbackSupport().available) { return t("settings.speech.compatibility.browserStreamingUnavailable") } return t("settings.speech.compatibility.runtimeNote") }) const isDirty = createMemo(() => { const speech = serverSettings().speech const current = drafts() return ( apiKeyDirty() || (current.baseUrl || "") !== (speech.baseUrl || "") || current.sttModel !== speech.sttModel || current.ttsModel !== speech.ttsModel || current.ttsVoice !== speech.ttsVoice || current.playbackMode !== speech.playbackMode || current.ttsFormat !== speech.ttsFormat ) }) const saveStatusLabel = () => { if (isSaving()) return t("settings.speech.save.saving") if (saveStatus() === "saved") return t("settings.speech.save.saved") if (saveStatus() === "error") return t("settings.speech.save.error") return t("settings.speech.save.unsaved") } async function handleSave() { if (!isDirty() || isSaving()) return const current = drafts() setIsSaving(true) setSaveStatus("idle") try { const trimmedApiKey = current.apiKey.trim() await updateSpeechSettings({ ...(clearStoredApiKey() ? { apiKey: null } : trimmedApiKey ? { apiKey: trimmedApiKey } : {}), baseUrl: current.baseUrl.trim() || undefined, sttModel: current.sttModel.trim() || undefined, ttsModel: current.ttsModel.trim() || undefined, ttsVoice: current.ttsVoice.trim() || undefined, playbackMode: current.playbackMode, ttsFormat: current.ttsFormat, }) await loadSpeechCapabilities(true) setDrafts({ apiKey: "", baseUrl: current.baseUrl.trim(), sttModel: current.sttModel.trim() || serverSettings().speech.sttModel, ttsModel: current.ttsModel.trim() || serverSettings().speech.ttsModel, ttsVoice: current.ttsVoice.trim() || serverSettings().speech.ttsVoice, playbackMode: current.playbackMode, ttsFormat: current.ttsFormat, }) setApiKeyTouched(false) setClearStoredApiKey(false) setSaveStatus("saved") } catch (error) { log.error("Failed to save speech settings", error) setSaveStatus("error") } finally { setIsSaving(false) } } return (

{t("settings.speech.title")}

{t("settings.speech.subtitle")}

{t("settings.scope.server")}
{t("settings.speech.provider.title")}
{t("settings.speech.provider.subtitle")}
{t("settings.speech.provider.openaiCompatible")} {capabilityLabel()} {saveStatusLabel()}
updateDraft("apiKey", value)} type="password" placeholder={serverSettings().speech.hasApiKey ? t("settings.speech.apiKey.placeholder") : undefined} />
{clearStoredApiKey() ? t("settings.speech.apiKey.clearPending") : t("settings.speech.apiKey.storedNote")}{" "}
updateDraft("baseUrl", value)} placeholder={t("settings.speech.baseUrl.placeholder")} /> updateDraft("sttModel", value)} /> updateDraft("ttsModel", value)} /> updateDraft("ttsVoice", value)} icon={} /> updateDraft("playbackMode", value as DraftFields["playbackMode"])} options={[ { value: "streaming", label: t("settings.speech.playbackMode.streaming") }, { value: "buffered", label: t("settings.speech.playbackMode.buffered") }, ]} /> updateDraft("ttsFormat", value as DraftFields["ttsFormat"])} options={[ { value: "mp3", label: "MP3" }, { value: "wav", label: "WAV" }, { value: "opus", label: "Opus" }, { value: "aac", label: "AAC" }, ]} />
{t("settings.speech.help")}
{(message) =>
{message()}
}
{t("settings.speech.testPlayback.note")}
) } const Field: Component<{ label: string caption: string value: string type?: string placeholder?: string onInput: (value: string) => void icon?: any }> = (props) => { return (
{props.label}
{props.caption}
{props.icon} props.onInput(event.currentTarget.value)} class="selector-input w-full" placeholder={props.placeholder} />
) } const SelectField: Component<{ label: string caption: string value: string onInput: (value: string) => void options: Array<{ value: string; label: string }> }> = (props) => { return (
{props.label}
{props.caption}
) } export default SpeechSettingsCard