diff --git a/scripts/test-ai-model-status-polling.mjs b/scripts/test-ai-model-status-polling.mjs new file mode 100644 index 00000000..e20149e2 --- /dev/null +++ b/scripts/test-ai-model-status-polling.mjs @@ -0,0 +1,277 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const FIVE_SECOND_TICKS = 5; + +const { + applyAiModelStatusPatch, + createEmptyAiModelStatusSnapshot, + fetchAiModelStatusPollPatch, +} = await importTs(path.resolve('src/renderer/src/settings/aiModelStatusPolling.ts')); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function makeWhisperCppStatus(overrides = {}) { + return { + state: 'downloaded', + modelName: 'base', + path: '/models/ggml-base.bin', + bytesDownloaded: 1024, + totalBytes: 1024, + ...overrides, + }; +} + +function makeParakeetStatus(overrides = {}) { + return { + state: 'downloaded', + modelName: 'parakeet-tdt-0.6b-v3', + path: '/models/parakeet', + progress: 1, + ...overrides, + }; +} + +function makeQwen3Status(overrides = {}) { + return { + state: 'downloaded', + modelName: 'qwen3-asr', + path: '/models/qwen3', + progress: 1, + ...overrides, + }; +} + +function makeSnapshot(overrides = {}) { + return { + whisperCpp: makeWhisperCppStatus(overrides.whisperCpp), + parakeet: makeParakeetStatus(overrides.parakeet), + qwen3: makeQwen3Status(overrides.qwen3), + }; +} + +function createStaticFetchers(snapshot) { + const calls = { + whisperCpp: 0, + parakeet: 0, + qwen3: 0, + }; + + return { + calls, + fetchers: { + async whisperCpp() { + calls.whisperCpp += 1; + return clone(snapshot.whisperCpp); + }, + async parakeet() { + calls.parakeet += 1; + return clone(snapshot.parakeet); + }, + async qwen3() { + calls.qwen3 += 1; + return clone(snapshot.qwen3); + }, + }, + }; +} + +function createFixedPollingHarness(initialSnapshot) { + let current = initialSnapshot; + let stateUpdates = 0; + let profilerCommits = 0; + + function applyPatch(patch) { + const next = applyAiModelStatusPatch(current, patch); + if (next === current) return false; + + current = next; + stateUpdates += 1; + profilerCommits += 1; + return true; + } + + return { + async poll(fetchers) { + const patch = await fetchAiModelStatusPollPatch(fetchers); + applyPatch(patch); + }, + applyPatch, + get current() { + return current; + }, + get metrics() { + return { stateUpdates, profilerCommits }; + }, + }; +} + +async function simulateLegacyIndependentPolling(fetchers, ticks) { + let stateUpdates = 0; + let profilerCommits = 0; + + for (let tick = 0; tick < ticks; tick += 1) { + await fetchers.whisperCpp(); + stateUpdates += 1; + profilerCommits += 1; + + await fetchers.parakeet(); + stateUpdates += 1; + profilerCommits += 1; + + await fetchers.qwen3(); + stateUpdates += 1; + profilerCommits += 1; + } + + return { stateUpdates, profilerCommits }; +} + +test('AI model status polling baseline performs three unchanged state writes per second', async () => { + const snapshot = makeSnapshot(); + const { fetchers, calls } = createStaticFetchers(snapshot); + + const metrics = await simulateLegacyIndependentPolling(fetchers, FIVE_SECOND_TICKS); + + console.log( + `[ai-status baseline] ticks=${FIVE_SECOND_TICKS} stateUpdates=${metrics.stateUpdates} profilerCommits=${metrics.profilerCommits}` + ); + assert.deepEqual(calls, { whisperCpp: 5, parakeet: 5, qwen3: 5 }); + assert.deepEqual(metrics, { stateUpdates: 15, profilerCommits: 15 }); +}); + +test('coalesced AI model status polling skips commits when statuses are unchanged', async () => { + const snapshot = makeSnapshot(); + const { fetchers, calls } = createStaticFetchers(snapshot); + const harness = createFixedPollingHarness(clone(snapshot)); + + for (let tick = 0; tick < FIVE_SECOND_TICKS; tick += 1) { + await harness.poll(fetchers); + } + + console.log( + `[ai-status coalesced unchanged] ticks=${FIVE_SECOND_TICKS} stateUpdates=${harness.metrics.stateUpdates} profilerCommits=${harness.metrics.profilerCommits}` + ); + assert.deepEqual(calls, { whisperCpp: 5, parakeet: 5, qwen3: 5 }); + assert.deepEqual(harness.metrics, { stateUpdates: 0, profilerCommits: 0 }); +}); + +test('coalesced AI model status polling commits once per tick when all statuses change', async () => { + let tickValue = 0; + const calls = { + whisperCpp: 0, + parakeet: 0, + qwen3: 0, + }; + const harness = createFixedPollingHarness(makeSnapshot({ + whisperCpp: { bytesDownloaded: 0, totalBytes: 100 }, + parakeet: { progress: 0 }, + qwen3: { progress: 0 }, + })); + const fetchers = { + async whisperCpp() { + calls.whisperCpp += 1; + return makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: tickValue, + totalBytes: 100, + }); + }, + async parakeet() { + calls.parakeet += 1; + return makeParakeetStatus({ + state: 'downloading', + progress: tickValue / FIVE_SECOND_TICKS, + }); + }, + async qwen3() { + calls.qwen3 += 1; + return makeQwen3Status({ + state: 'downloading', + progress: tickValue / FIVE_SECOND_TICKS, + }); + }, + }; + + for (let tick = 1; tick <= FIVE_SECOND_TICKS; tick += 1) { + tickValue = tick; + await harness.poll(fetchers); + } + + console.log( + `[ai-status coalesced changing] ticks=${FIVE_SECOND_TICKS} stateUpdates=${harness.metrics.stateUpdates} profilerCommits=${harness.metrics.profilerCommits}` + ); + assert.deepEqual(calls, { whisperCpp: 5, parakeet: 5, qwen3: 5 }); + assert.deepEqual(harness.metrics, { stateUpdates: 5, profilerCommits: 5 }); + assert.equal(harness.current.whisperCpp.bytesDownloaded, FIVE_SECOND_TICKS); + assert.equal(harness.current.parakeet.progress, 1); + assert.equal(harness.current.qwen3.progress, 1); +}); + +test('download progress status patches still commit when progress fields change', () => { + const harness = createFixedPollingHarness(createEmptyAiModelStatusSnapshot()); + + assert.equal(harness.applyPatch({ + whisperCpp: makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: 10, + totalBytes: 100, + }), + }), true); + assert.equal(harness.applyPatch({ + whisperCpp: makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: 10, + totalBytes: 100, + }), + }), false); + assert.equal(harness.applyPatch({ + whisperCpp: makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: 25, + totalBytes: 100, + }), + }), true); + assert.equal(harness.applyPatch({ + parakeet: makeParakeetStatus({ state: 'downloading', progress: 0.25 }), + }), true); + assert.equal(harness.applyPatch({ + qwen3: makeQwen3Status({ state: 'downloading', progress: 0.5 }), + }), true); + + assert.deepEqual(harness.metrics, { stateUpdates: 4, profilerCommits: 4 }); + assert.equal(harness.current.whisperCpp.bytesDownloaded, 25); + assert.equal(harness.current.parakeet.progress, 0.25); + assert.equal(harness.current.qwen3.progress, 0.5); +}); + +test('coalesced poll starts all status requests before awaiting any one result', async () => { + const started = []; + const resolvers = []; + const fetchers = { + whisperCpp: () => new Promise((resolve) => { + started.push('whisperCpp'); + resolvers.push(() => resolve(makeWhisperCppStatus())); + }), + parakeet: () => new Promise((resolve) => { + started.push('parakeet'); + resolvers.push(() => resolve(makeParakeetStatus())); + }), + qwen3: () => new Promise((resolve) => { + started.push('qwen3'); + resolvers.push(() => resolve(makeQwen3Status())); + }), + }; + + const patchPromise = fetchAiModelStatusPollPatch(fetchers); + + assert.deepEqual(started, ['whisperCpp', 'parakeet', 'qwen3']); + for (const resolve of resolvers) resolve(); + assert.deepEqual(await patchPromise, makeSnapshot()); +}); diff --git a/src/renderer/src/settings/AITab.tsx b/src/renderer/src/settings/AITab.tsx index c27befdd..ab8d1784 100644 --- a/src/renderer/src/settings/AITab.tsx +++ b/src/renderer/src/settings/AITab.tsx @@ -28,9 +28,6 @@ import type { AISettings, EdgeTtsVoice, ElevenLabsVoice, - WhisperCppModelStatus, - ParakeetModelStatus, - Qwen3ModelStatus, } from '../../types/electron'; import { useI18n } from '../i18n'; import { @@ -38,6 +35,14 @@ import { getCachedElevenLabsVoices, setCachedElevenLabsVoices, } from '../utils/voice-cache'; +import { + AI_MODEL_STATUS_POLL_INTERVAL_MS, + applyAiModelStatusPatch, + createEmptyAiModelStatusSnapshot, + fetchAiModelStatusPollPatch, + type AiModelStatusPatch, + type AiModelStatusSnapshot, +} from './aiModelStatusPolling'; const getProviderOptions = (t: (key: string) => string) => [ { id: 'openai' as const, label: t('settings.ai.llm.provider.openai'), description: t('settings.ai.llm.providerDescriptions.openai') }, @@ -269,11 +274,15 @@ const AITab: React.FC = () => { const [elevenLabsVoices, setElevenLabsVoices] = useState([]); const [elevenLabsVoicesLoading, setElevenLabsVoicesLoading] = useState(false); const [elevenLabsVoicesError, setElevenLabsVoicesError] = useState(null); - const [whisperCppModelStatus, setWhisperCppModelStatus] = useState(null); + const [aiModelStatuses, setAiModelStatuses] = useState(() => createEmptyAiModelStatusSnapshot()); + const aiModelStatusesRef = useRef(aiModelStatuses); + const { + whisperCpp: whisperCppModelStatus, + parakeet: parakeetModelStatus, + qwen3: qwen3ModelStatus, + } = aiModelStatuses; const [whisperCppModelLoading, setWhisperCppModelLoading] = useState(false); - const [parakeetModelStatus, setParakeetModelStatus] = useState(null); const [parakeetModelLoading, setParakeetModelLoading] = useState(false); - const [qwen3ModelStatus, setQwen3ModelStatus] = useState(null); const [qwen3ModelLoading, setQwen3ModelLoading] = useState(false); const [whisperCustomMode, setWhisperCustomMode] = useState(false); const whisperSpeakToggleHotkey = (settings?.commandHotkeys || {})[WHISPER_SPEAK_TOGGLE_COMMAND_ID] ?? ''; @@ -292,6 +301,10 @@ const AITab: React.FC = () => { settingsRef.current = settings; }, [settings]); + useEffect(() => { + aiModelStatusesRef.current = aiModelStatuses; + }, [aiModelStatuses]); + const fetchLmStudioModels = useCallback((baseUrl: string) => { if (lmStudioFetchTimerRef.current) clearTimeout(lmStudioFetchTimerRef.current); lmStudioFetchTimerRef.current = setTimeout(() => { @@ -405,15 +418,31 @@ const AITab: React.FC = () => { setTimeout(() => setSaveStatus('idle'), 1600); }; + const applyAiModelStatuses = useCallback((patch: AiModelStatusPatch): boolean => { + const current = aiModelStatusesRef.current; + const next = applyAiModelStatusPatch(current, patch); + if (next === current) return false; + + aiModelStatusesRef.current = next; + setAiModelStatuses(next); + return true; + }, []); + + const fetchAiModelStatusPatch = useCallback(() => fetchAiModelStatusPollPatch({ + whisperCpp: () => window.electron.whisperCppModelStatus(), + parakeet: () => window.electron.parakeetModelStatus(), + qwen3: () => window.electron.qwen3ModelStatus(), + }), []); + const refreshWhisperCppModelStatus = useCallback(async () => { try { const status = await window.electron.whisperCppModelStatus(); - setWhisperCppModelStatus(status); + applyAiModelStatuses({ whisperCpp: status }); return status; } catch { return null; } - }, []); + }, [applyAiModelStatuses]); useEffect(() => { if (activeTab !== 'whisper') return; @@ -421,9 +450,10 @@ const AITab: React.FC = () => { let timer: number | null = null; const tick = async () => { - await refreshWhisperCppModelStatus(); + const patch = await fetchAiModelStatusPatch(); if (cancelled) return; - timer = window.setTimeout(() => { void tick(); }, 1000); + applyAiModelStatuses(patch); + timer = window.setTimeout(() => { void tick(); }, AI_MODEL_STATUS_POLL_INTERVAL_MS); }; void tick(); @@ -431,99 +461,63 @@ const AITab: React.FC = () => { cancelled = true; if (timer !== null) window.clearTimeout(timer); }; - }, [activeTab, refreshWhisperCppModelStatus]); + }, [activeTab, applyAiModelStatuses, fetchAiModelStatusPatch]); const handleWhisperCppDownload = useCallback(async () => { setWhisperCppModelLoading(true); try { const status = await window.electron.whisperCppDownloadModel(); - setWhisperCppModelStatus(status); + applyAiModelStatuses({ whisperCpp: status }); } catch { void refreshWhisperCppModelStatus(); } finally { setWhisperCppModelLoading(false); } - }, [refreshWhisperCppModelStatus]); + }, [applyAiModelStatuses, refreshWhisperCppModelStatus]); const refreshParakeetModelStatus = useCallback(async () => { try { const status = await window.electron.parakeetModelStatus(); - setParakeetModelStatus(status); + applyAiModelStatuses({ parakeet: status }); return status; } catch { return null; } - }, []); - - useEffect(() => { - if (activeTab !== 'whisper') return; - let cancelled = false; - let timer: number | null = null; - - const tick = async () => { - await refreshParakeetModelStatus(); - if (cancelled) return; - timer = window.setTimeout(() => { void tick(); }, 1000); - }; - - void tick(); - return () => { - cancelled = true; - if (timer !== null) window.clearTimeout(timer); - }; - }, [activeTab, refreshParakeetModelStatus]); + }, [applyAiModelStatuses]); const handleParakeetDownload = useCallback(async () => { setParakeetModelLoading(true); try { const status = await window.electron.parakeetDownloadModel(); - setParakeetModelStatus(status); + applyAiModelStatuses({ parakeet: status }); } catch { void refreshParakeetModelStatus(); } finally { setParakeetModelLoading(false); } - }, [refreshParakeetModelStatus]); + }, [applyAiModelStatuses, refreshParakeetModelStatus]); const refreshQwen3ModelStatus = useCallback(async () => { try { const status = await window.electron.qwen3ModelStatus(); - setQwen3ModelStatus(status); + applyAiModelStatuses({ qwen3: status }); return status; } catch { return null; } - }, []); - - useEffect(() => { - if (activeTab !== 'whisper') return; - let cancelled = false; - let timer: number | null = null; - - const tick = async () => { - await refreshQwen3ModelStatus(); - if (cancelled) return; - timer = window.setTimeout(() => { void tick(); }, 1000); - }; - - void tick(); - return () => { - cancelled = true; - if (timer !== null) window.clearTimeout(timer); - }; - }, [activeTab, refreshQwen3ModelStatus]); + }, [applyAiModelStatuses]); const handleQwen3Download = useCallback(async () => { setQwen3ModelLoading(true); try { const status = await window.electron.qwen3DownloadModel(); - setQwen3ModelStatus(status); + applyAiModelStatuses({ qwen3: status }); } catch { void refreshQwen3ModelStatus(); } finally { setQwen3ModelLoading(false); } - }, [refreshQwen3ModelStatus]); + }, [applyAiModelStatuses, refreshQwen3ModelStatus]); const maybeSelectOllamaDefaultModel = useCallback((availableNames: string[], preferredName?: string) => { const currentSettings = settingsRef.current; diff --git a/src/renderer/src/settings/aiModelStatusPolling.ts b/src/renderer/src/settings/aiModelStatusPolling.ts new file mode 100644 index 00000000..387ad2cd --- /dev/null +++ b/src/renderer/src/settings/aiModelStatusPolling.ts @@ -0,0 +1,110 @@ +import type { + ParakeetModelStatus, + Qwen3ModelStatus, + WhisperCppModelStatus, +} from '../../types/electron'; + +export const AI_MODEL_STATUS_POLL_INTERVAL_MS = 1000; + +export type AiModelStatusSnapshot = { + whisperCpp: WhisperCppModelStatus | null; + parakeet: ParakeetModelStatus | null; + qwen3: Qwen3ModelStatus | null; +}; + +export type AiModelStatusPatch = Partial; + +export type AiModelStatusFetchers = { + whisperCpp: () => Promise; + parakeet: () => Promise; + qwen3: () => Promise; +}; + +export function createEmptyAiModelStatusSnapshot(): AiModelStatusSnapshot { + return { + whisperCpp: null, + parakeet: null, + qwen3: null, + }; +} + +function sameOptionalString(left?: string, right?: string): boolean { + return (left || '') === (right || ''); +} + +export function areWhisperCppModelStatusesEqual( + left: WhisperCppModelStatus | null, + right: WhisperCppModelStatus | null +): boolean { + if (left === right) return true; + if (!left || !right) return false; + + return left.state === right.state + && left.modelName === right.modelName + && left.path === right.path + && left.bytesDownloaded === right.bytesDownloaded + && left.totalBytes === right.totalBytes + && sameOptionalString(left.error, right.error); +} + +export function areParakeetModelStatusesEqual( + left: ParakeetModelStatus | null, + right: ParakeetModelStatus | null +): boolean { + if (left === right) return true; + if (!left || !right) return false; + + return left.state === right.state + && left.modelName === right.modelName + && left.path === right.path + && left.progress === right.progress + && sameOptionalString(left.error, right.error); +} + +export function areQwen3ModelStatusesEqual( + left: Qwen3ModelStatus | null, + right: Qwen3ModelStatus | null +): boolean { + if (left === right) return true; + if (!left || !right) return false; + + return left.state === right.state + && left.modelName === right.modelName + && left.path === right.path + && left.progress === right.progress + && sameOptionalString(left.error, right.error); +} + +export function areAiModelStatusSnapshotsEqual( + left: AiModelStatusSnapshot, + right: AiModelStatusSnapshot +): boolean { + return areWhisperCppModelStatusesEqual(left.whisperCpp, right.whisperCpp) + && areParakeetModelStatusesEqual(left.parakeet, right.parakeet) + && areQwen3ModelStatusesEqual(left.qwen3, right.qwen3); +} + +export function applyAiModelStatusPatch( + current: AiModelStatusSnapshot, + patch: AiModelStatusPatch +): AiModelStatusSnapshot { + const next: AiModelStatusSnapshot = { + whisperCpp: patch.whisperCpp === undefined ? current.whisperCpp : patch.whisperCpp, + parakeet: patch.parakeet === undefined ? current.parakeet : patch.parakeet, + qwen3: patch.qwen3 === undefined ? current.qwen3 : patch.qwen3, + }; + + return areAiModelStatusSnapshotsEqual(current, next) ? current : next; +} + +export async function fetchAiModelStatusPollPatch( + fetchers: AiModelStatusFetchers +): Promise { + const [whisperCpp, parakeet, qwen3] = await Promise.all([ + fetchers.whisperCpp().catch(() => undefined), + fetchers.parakeet().catch(() => undefined), + fetchers.qwen3().catch(() => undefined), + ]); + + return { whisperCpp, parakeet, qwen3 }; +}