From bb1b297b75e195b391f489045da5a36bb4c4e8e7 Mon Sep 17 00:00:00 2001 From: ysdede Date: Sat, 21 Feb 2026 18:35:39 +0300 Subject: [PATCH 1/2] feat: add runtime backend/quantization/stride and wasm thread controls --- src/App.tsx | 61 +++++++++++- src/components/SettingsPanel.tsx | 85 ++++++++++++++++- src/lib/transcription/ModelManager.ts | 95 +++++++++++++++---- .../TranscriptionWorkerClient.ts | 48 ++++++++-- src/lib/transcription/transcription.worker.ts | 7 +- src/lib/transcription/types.ts | 14 ++- src/stores/appStore.ts | 19 ++++ src/utils/settingsStorage.test.ts | 14 ++- src/utils/settingsStorage.ts | 28 +++++- vite.config.js | 4 +- 10 files changed, 336 insertions(+), 39 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d4f6b38..5678886 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -224,16 +224,36 @@ const clampWidgetPosition = (position: { x: number; y: number }): { x: number; y }; }; +const getMaxHardwareThreads = (): number => { + if (typeof navigator === 'undefined' || !Number.isFinite(navigator.hardwareConcurrency)) { + return 4; + } + return Math.max(1, Math.floor(navigator.hardwareConcurrency)); +}; + +const clampWasmThreadsForDevice = (value: number): number => + Math.max(1, Math.min(getMaxHardwareThreads(), Math.floor(value))); + const App: Component = () => { const persistedSettings = loadSettingsFromStorage(); const persistedGeneral = persistedSettings.general; const persistedAudio = persistedSettings.audio; - const persistedModelId = persistedSettings.model?.selectedModelId; + const persistedModel = persistedSettings.model; + const persistedModelId = persistedModel?.selectedModelId; const persistedUi = persistedSettings.ui; if (persistedModelId && MODELS.some((model) => model.id === persistedModelId)) { appStore.setSelectedModelId(persistedModelId); } + if (persistedModel?.backend !== undefined) { + appStore.setModelBackendMode(persistedModel.backend); + } + if (persistedModel?.encoderQuant !== undefined) { + appStore.setEncoderQuant(persistedModel.encoderQuant); + } + if (persistedModel?.decoderQuant !== undefined) { + appStore.setDecoderQuant(persistedModel.decoderQuant); + } if (persistedGeneral?.energyThreshold !== undefined) { appStore.setEnergyThreshold(persistedGeneral.energyThreshold); } @@ -249,6 +269,12 @@ const App: Component = () => { if (persistedGeneral?.streamingWindow !== undefined) { appStore.setStreamingWindow(persistedGeneral.streamingWindow); } + if (persistedGeneral?.frameStride !== undefined) { + appStore.setFrameStride(persistedGeneral.frameStride); + } + if (persistedGeneral?.wasmThreads !== undefined) { + appStore.setWasmThreads(clampWasmThreadsForDevice(persistedGeneral.wasmThreads)); + } if (persistedUi?.debugPanel?.visible !== undefined) { appStore.setShowDebugPanel(persistedUi.debugPanel.visible); } @@ -297,6 +323,18 @@ const App: Component = () => { } }); + // Keep quantization presets aligned with the backend mode (parakeet.js demo behavior). + createEffect(() => { + const backendMode = appStore.modelBackendMode(); + if (backendMode.startsWith('webgpu')) { + if (appStore.encoderQuant() !== 'fp32') appStore.setEncoderQuant('fp32'); + if (appStore.decoderQuant() !== 'int8') appStore.setDecoderQuant('int8'); + } else { + if (appStore.encoderQuant() !== 'int8') appStore.setEncoderQuant('int8'); + if (appStore.decoderQuant() !== 'int8') appStore.setDecoderQuant('int8'); + } + }); + let dragStart = { x: 0, y: 0 }; let posStart = { x: 0, y: 0 }; @@ -364,9 +402,14 @@ const App: Component = () => { v4InferenceIntervalMs: appStore.v4InferenceIntervalMs(), v4SilenceFlushSec: appStore.v4SilenceFlushSec(), streamingWindow: appStore.streamingWindow(), + frameStride: appStore.frameStride(), + wasmThreads: appStore.wasmThreads(), }, model: { selectedModelId: appStore.selectedModelId(), + backend: appStore.modelBackendMode(), + encoderQuant: appStore.encoderQuant(), + decoderQuant: appStore.decoderQuant(), }, audio: { selectedDeviceId, @@ -425,6 +468,7 @@ const App: Component = () => { appStore.setModelProgress(p.progress); appStore.setModelMessage(p.message || ''); if (p.file) appStore.setModelFile(p.file); + if (p.backend) appStore.setBackend(p.backend); }; workerClient.onModelStateChange = (s) => { @@ -642,6 +686,7 @@ const App: Component = () => { features: features.features, T: features.T, melBins: features.melBins, + frameStride: appStore.frameStride(), timeOffset, endTime: window.endFrame / 16000, segmentId: `v4_${Date.now()}`, @@ -1040,11 +1085,16 @@ const App: Component = () => { const loadSelectedModel = async () => { if (!workerClient) return; - if (appStore.modelState() === 'ready') return; if (appStore.modelState() === 'loading') return; setShowContextPanel(true); try { - await workerClient.initModel(appStore.selectedModelId()); + await workerClient.initModel({ + modelId: appStore.selectedModelId(), + cpuThreads: appStore.wasmThreads(), + backend: appStore.modelBackendMode(), + encoderQuant: appStore.encoderQuant(), + decoderQuant: appStore.decoderQuant(), + }); } catch (e) { console.error('Failed to load model:', e); appStore.setModelState('error'); @@ -1078,7 +1128,10 @@ const App: Component = () => { if (!workerClient) return; setShowContextPanel(true); try { - await workerClient.initLocalModel(files); + await workerClient.initLocalModel(files, { + cpuThreads: appStore.wasmThreads(), + backend: appStore.modelBackendMode(), + }); } catch (e) { console.error('Failed to load local model:', e); } diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index c4cd1ea..f7f94f6 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -8,6 +8,13 @@ const formatInterval = (ms: number) => { return `${ms}ms`; }; +const getMaxHardwareThreads = () => { + if (typeof navigator === 'undefined' || !Number.isFinite(navigator.hardwareConcurrency)) { + return 4; + } + return Math.max(1, Math.floor(navigator.hardwareConcurrency)); +}; + /** Visible section preset for the embeddable settings content. */ export type SettingsPanelSection = 'full' | 'audio' | 'model'; @@ -34,6 +41,7 @@ export interface SettingsContentProps { export const SettingsContent: Component = (props) => { const isV4 = () => appStore.transcriptionMode() === 'v4-utterance'; const isV3 = () => appStore.transcriptionMode() === 'v3-streaming'; + const maxWasmThreads = () => getMaxHardwareThreads(); const expandUp = () => props.expandUp?.() ?? false; const section = () => props.section ?? 'full'; @@ -65,11 +73,11 @@ export const SettingsContent: Component = (props) => {