From a2c88f014246bec0480b5d8999e3e4f05766f41c Mon Sep 17 00:00:00 2001 From: kostazol Date: Wed, 13 May 2026 19:45:03 +0700 Subject: [PATCH] Sync speech recognition settings across devices (#1217) * fix: sync STT settings across devices Persist speech recognition preferences through shared OpenChamber settings so mobile and desktop stay in sync. * fix bot review * fix(voice): sync transcribe-on-stop setting --------- Co-authored-by: Konstantin Zolin Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/lib/desktop.ts | 7 ++ packages/ui/src/lib/persistence.ts | 72 +++++++++++++++++++ packages/ui/src/stores/useConfigStore.ts | 61 ++++++++++++++++ .../server/lib/opencode/settings-helpers.js | 37 ++++++++++ 4 files changed, 177 insertions(+) diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index e80571d9..72255eb0 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -166,6 +166,13 @@ export type DesktopSettings = { responseStyleEnabled?: boolean; responseStylePreset?: 'concise' | 'detailed' | 'mentor' | 'pushback' | 'noFiller' | 'matchEnergy' | 'warmPeer' | 'custom'; responseStyleCustomInstructions?: string; + sttProvider?: 'browser' | 'server'; + sttServerUrl?: string; + sttModel?: string; + sttLanguage?: string; + sttSilenceThresholdDb?: number; + sttSilenceHoldMs?: number; + sttTranscribeOnStop?: boolean; }; type TauriGlobal = { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 1df996bc..509f0e3e 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -96,6 +96,27 @@ const persistToLocalStorage = (settings: DesktopSettings) => { if (typeof settings.mobileKeyboardMode === 'string') { setStoredMobileKeyboardMode(settings.mobileKeyboardMode); } + if (settings.sttProvider === 'browser' || settings.sttProvider === 'server') { + localStorage.setItem('sttProvider', settings.sttProvider); + } + if (typeof settings.sttServerUrl === 'string') { + localStorage.setItem('sttServerUrl', settings.sttServerUrl); + } + if (typeof settings.sttModel === 'string') { + localStorage.setItem('sttModel', settings.sttModel); + } + if (typeof settings.sttLanguage === 'string') { + localStorage.setItem('sttLanguage', settings.sttLanguage); + } + if (typeof settings.sttSilenceThresholdDb === 'number' && Number.isFinite(settings.sttSilenceThresholdDb)) { + localStorage.setItem('sttSilenceThresholdDb', String(settings.sttSilenceThresholdDb)); + } + if (typeof settings.sttSilenceHoldMs === 'number' && Number.isFinite(settings.sttSilenceHoldMs)) { + localStorage.setItem('sttSilenceHoldMs', String(settings.sttSilenceHoldMs)); + } + if (typeof settings.sttTranscribeOnStop === 'boolean') { + localStorage.setItem('sttTranscribeOnStop', String(settings.sttTranscribeOnStop)); + } }; type PersistApi = { @@ -316,6 +337,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { const configStore = typeof window !== 'undefined' ? window.__zustand_config_store__?.getState?.() ?? null : null; + const configStoreApi = typeof window !== 'undefined' + ? window.__zustand_config_store__ ?? null + : null; const queueStore = useMessageQueueStore.getState(); if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) { @@ -474,6 +498,33 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { store.setMobileKeyboardMode(mode); } } + if (configStoreApi && configStore) { + const nextConfigState: Partial = {}; + if ((settings.sttProvider === 'browser' || settings.sttProvider === 'server') && settings.sttProvider !== configStore.sttProvider) { + nextConfigState.sttProvider = settings.sttProvider; + } + if (typeof settings.sttServerUrl === 'string' && settings.sttServerUrl !== configStore.sttServerUrl) { + nextConfigState.sttServerUrl = settings.sttServerUrl; + } + if (typeof settings.sttModel === 'string' && settings.sttModel !== configStore.sttModel) { + nextConfigState.sttModel = settings.sttModel; + } + if (typeof settings.sttLanguage === 'string' && settings.sttLanguage !== configStore.sttLanguage) { + nextConfigState.sttLanguage = settings.sttLanguage; + } + if (typeof settings.sttSilenceThresholdDb === 'number' && Number.isFinite(settings.sttSilenceThresholdDb) && settings.sttSilenceThresholdDb !== configStore.sttSilenceThresholdDb) { + nextConfigState.sttSilenceThresholdDb = settings.sttSilenceThresholdDb; + } + if (typeof settings.sttSilenceHoldMs === 'number' && Number.isFinite(settings.sttSilenceHoldMs) && settings.sttSilenceHoldMs !== configStore.sttSilenceHoldMs) { + nextConfigState.sttSilenceHoldMs = settings.sttSilenceHoldMs; + } + if (typeof settings.sttTranscribeOnStop === 'boolean' && settings.sttTranscribeOnStop !== configStore.sttTranscribeOnStop) { + nextConfigState.sttTranscribeOnStop = settings.sttTranscribeOnStop; + } + if (Object.keys(nextConfigState).length > 0) { + configStoreApi.setState(nextConfigState); + } + } if (Array.isArray(settings.favoriteModels)) { const current = store.favoriteModels; @@ -980,6 +1031,27 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.responseStyleCustomInstructions === 'string') { result.responseStyleCustomInstructions = candidate.responseStyleCustomInstructions; } + if (candidate.sttProvider === 'browser' || candidate.sttProvider === 'server') { + result.sttProvider = candidate.sttProvider; + } + if (typeof candidate.sttServerUrl === 'string') { + result.sttServerUrl = candidate.sttServerUrl.trim(); + } + if (typeof candidate.sttModel === 'string') { + result.sttModel = candidate.sttModel.trim(); + } + if (typeof candidate.sttLanguage === 'string') { + result.sttLanguage = candidate.sttLanguage.trim(); + } + if (typeof candidate.sttSilenceThresholdDb === 'number' && Number.isFinite(candidate.sttSilenceThresholdDb)) { + result.sttSilenceThresholdDb = candidate.sttSilenceThresholdDb; + } + if (typeof candidate.sttSilenceHoldMs === 'number' && Number.isFinite(candidate.sttSilenceHoldMs)) { + result.sttSilenceHoldMs = candidate.sttSilenceHoldMs; + } + if (typeof candidate.sttTranscribeOnStop === 'boolean') { + result.sttTranscribeOnStop = candidate.sttTranscribeOnStop; + } return result; }; diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 68e32ea6..48d5dc2f 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -17,12 +17,30 @@ import { parseModelIdentifier } from "@/lib/modelIdentifier"; const MODELS_DEV_API_URL = "https://models.dev/api.json"; const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata"; +const STT_SILENCE_THRESHOLD_DB_MIN = -100; +const STT_SILENCE_THRESHOLD_DB_MAX = 0; +const STT_SILENCE_HOLD_MS_MIN = 250; +const STT_SILENCE_HOLD_MS_MAX = 10000; const FALLBACK_PROVIDER_ID = "opencode"; const FALLBACK_MODEL_ID = "big-pickle"; const GIT_UTILITY_PROVIDER_ID = "zen"; const GIT_UTILITY_PREFERRED_MODEL_ID = "big-pickle"; +const normalizeSttSilenceThresholdDb = (value: unknown): number | undefined => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined; + } + return Math.max(STT_SILENCE_THRESHOLD_DB_MIN, Math.min(STT_SILENCE_THRESHOLD_DB_MAX, value)); +}; + +const normalizeSttSilenceHoldMs = (value: unknown): number | undefined => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined; + } + return Math.max(STT_SILENCE_HOLD_MS_MIN, Math.min(STT_SILENCE_HOLD_MS_MAX, Math.round(value))); +}; + interface OpenChamberDefaults { defaultModel?: string; defaultVariant?: string; @@ -32,6 +50,12 @@ interface OpenChamberDefaults { defaultFileViewerPreview?: boolean; zenModel?: string; messageStreamTransport?: 'auto' | 'ws' | 'sse'; + sttProvider?: 'browser' | 'server'; + sttServerUrl?: string; + sttModel?: string; + sttLanguage?: string; + sttSilenceThresholdDb?: number; + sttSilenceHoldMs?: number; } const fetchOpenChamberDefaults = async (): Promise => { @@ -53,6 +77,12 @@ const fetchOpenChamberDefaults = async (): Promise => { data?.messageStreamTransport === 'ws' || data?.messageStreamTransport === 'sse' || data?.messageStreamTransport === 'auto' ? data.messageStreamTransport : undefined; + const sttProvider = data?.sttProvider === 'browser' || data?.sttProvider === 'server' ? data.sttProvider : undefined; + const sttServerUrl = typeof data?.sttServerUrl === 'string' ? data.sttServerUrl.trim() : undefined; + const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined; + const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined; + const sttSilenceThresholdDb = normalizeSttSilenceThresholdDb(data?.sttSilenceThresholdDb); + const sttSilenceHoldMs = normalizeSttSilenceHoldMs(data?.sttSilenceHoldMs); return { defaultModel: defaultModel.length > 0 ? defaultModel : undefined, @@ -63,6 +93,12 @@ const fetchOpenChamberDefaults = async (): Promise => { defaultFileViewerPreview, zenModel: zenModel.length > 0 ? zenModel : undefined, messageStreamTransport, + sttProvider, + sttServerUrl, + sttModel, + sttLanguage, + sttSilenceThresholdDb, + sttSilenceHoldMs, }; } } catch { @@ -89,6 +125,12 @@ const fetchOpenChamberDefaults = async (): Promise => { data?.messageStreamTransport === 'ws' || data?.messageStreamTransport === 'sse' || data?.messageStreamTransport === 'auto' ? data.messageStreamTransport : undefined; + const sttProvider = data?.sttProvider === 'browser' || data?.sttProvider === 'server' ? data.sttProvider : undefined; + const sttServerUrl = typeof data?.sttServerUrl === 'string' ? data.sttServerUrl.trim() : undefined; + const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined; + const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined; + const sttSilenceThresholdDb = normalizeSttSilenceThresholdDb(data?.sttSilenceThresholdDb); + const sttSilenceHoldMs = normalizeSttSilenceHoldMs(data?.sttSilenceHoldMs); return { defaultModel: defaultModel.length > 0 ? defaultModel : undefined, @@ -99,6 +141,12 @@ const fetchOpenChamberDefaults = async (): Promise => { defaultFileViewerPreview, zenModel: zenModel.length > 0 ? zenModel : undefined, messageStreamTransport, + sttProvider, + sttServerUrl, + sttModel, + sttLanguage, + sttSilenceThresholdDb, + sttSilenceHoldMs, }; } catch { return {}; @@ -1300,6 +1348,12 @@ export const useConfigStore = create()( settingsDefaultFileViewerPreview: openChamberDefaults.defaultFileViewerPreview ?? false, settingsZenModel: resolvedZenModel, settingsMessageStreamTransport: openChamberDefaults.messageStreamTransport ?? state.settingsMessageStreamTransport ?? 'auto', + sttProvider: openChamberDefaults.sttProvider ?? state.sttProvider, + sttServerUrl: openChamberDefaults.sttServerUrl ?? state.sttServerUrl, + sttModel: openChamberDefaults.sttModel ?? state.sttModel, + sttLanguage: openChamberDefaults.sttLanguage ?? state.sttLanguage, + sttSilenceThresholdDb: openChamberDefaults.sttSilenceThresholdDb ?? state.sttSilenceThresholdDb, + sttSilenceHoldMs: openChamberDefaults.sttSilenceHoldMs ?? state.sttSilenceHoldMs, directoryScoped: { ...state.directoryScoped, [directoryKey]: nextSnapshot, @@ -1837,6 +1891,7 @@ export const useConfigStore = create()( if (typeof window !== 'undefined') { localStorage.setItem('sttProvider', provider); } + updateDesktopSettings({ sttProvider: provider }).catch(() => {}); }, setSttServerUrl: (url: string) => { @@ -1844,6 +1899,7 @@ export const useConfigStore = create()( if (typeof window !== 'undefined') { localStorage.setItem('sttServerUrl', url); } + updateDesktopSettings({ sttServerUrl: url }).catch(() => {}); }, setSttModel: (model: string) => { @@ -1851,6 +1907,7 @@ export const useConfigStore = create()( if (typeof window !== 'undefined') { localStorage.setItem('sttModel', model); } + updateDesktopSettings({ sttModel: model }).catch(() => {}); }, setSttLanguage: (lang: string) => { @@ -1858,6 +1915,7 @@ export const useConfigStore = create()( if (typeof window !== 'undefined') { localStorage.setItem('sttLanguage', lang); } + updateDesktopSettings({ sttLanguage: lang }).catch(() => {}); }, setSttSilenceThresholdDb: (db: number) => { @@ -1865,6 +1923,7 @@ export const useConfigStore = create()( if (typeof window !== 'undefined') { localStorage.setItem('sttSilenceThresholdDb', String(db)); } + updateDesktopSettings({ sttSilenceThresholdDb: db }).catch(() => {}); }, setSttSilenceHoldMs: (ms: number) => { @@ -1872,6 +1931,7 @@ export const useConfigStore = create()( if (typeof window !== 'undefined') { localStorage.setItem('sttSilenceHoldMs', String(ms)); } + updateDesktopSettings({ sttSilenceHoldMs: ms }).catch(() => {}); }, setSttTranscribeOnStop: (enabled: boolean) => { @@ -1879,6 +1939,7 @@ export const useConfigStore = create()( if (typeof window !== 'undefined') { localStorage.setItem('sttTranscribeOnStop', String(enabled)); } + updateDesktopSettings({ sttTranscribeOnStop: enabled }).catch(() => {}); }, setShowMessageTTSButtons: (show: boolean) => { diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index e19b0ee7..158cc955 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -18,6 +18,9 @@ export const createSettingsHelpers = (dependencies) => { } = dependencies; const PWA_APP_NAME_MAX_LENGTH = 64; + const STT_SERVER_URL_MAX_LENGTH = 2048; + const STT_MODEL_MAX_LENGTH = 256; + const STT_LANGUAGE_MAX_LENGTH = 64; const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']); const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']); @@ -599,6 +602,40 @@ export const createSettingsHelpers = (dependencies) => { } } + if (typeof candidate.sttProvider === 'string') { + const provider = candidate.sttProvider.trim(); + if (provider === 'browser' || provider === 'server') { + result.sttProvider = provider; + } + } + if (typeof candidate.sttServerUrl === 'string') { + const trimmed = candidate.sttServerUrl.trim(); + if (trimmed.length <= STT_SERVER_URL_MAX_LENGTH) { + result.sttServerUrl = trimmed; + } + } + if (typeof candidate.sttModel === 'string') { + const trimmed = candidate.sttModel.trim(); + if (trimmed.length <= STT_MODEL_MAX_LENGTH) { + result.sttModel = trimmed; + } + } + if (typeof candidate.sttLanguage === 'string') { + const trimmed = candidate.sttLanguage.trim(); + if (trimmed.length <= STT_LANGUAGE_MAX_LENGTH) { + result.sttLanguage = trimmed; + } + } + if (typeof candidate.sttSilenceThresholdDb === 'number' && Number.isFinite(candidate.sttSilenceThresholdDb)) { + result.sttSilenceThresholdDb = Math.max(-100, Math.min(0, candidate.sttSilenceThresholdDb)); + } + if (typeof candidate.sttSilenceHoldMs === 'number' && Number.isFinite(candidate.sttSilenceHoldMs)) { + result.sttSilenceHoldMs = Math.max(250, Math.min(10000, Math.round(candidate.sttSilenceHoldMs))); + } + if (typeof candidate.sttTranscribeOnStop === 'boolean') { + result.sttTranscribeOnStop = candidate.sttTranscribeOnStop; + } + return result; };