From 604005862defd3841dd20e5472d20058b69f3495 Mon Sep 17 00:00:00 2001 From: danljungstrom Date: Mon, 19 Oct 2026 22:09:42 +0200 Subject: [PATCH 1/4] fix(voice): show model pack verification and start large files first Port of #608 to v0.3 through the shared installer core. The Kokoro manifest lists its two large files last, so with 6 transfer slots they downloaded almost alone after the small files, then sat at 100% while the core re-read and hashed them with no progress shown. - installModelPackWithHost schedules files largest-first. - ModelPackCoreProgress reports verified bytes, including kept bytes rehashed on a range resume. - The model row shows a Verifying phase with verified bytes and files once every byte has arrived, with strings in every locale. - MeterBar glides linearly over durationMs.slow so throttled transfer reports blend into continuous motion; the bar is keyed by phase so it does not glide backward between phases. Co-Authored-By: Claude Opus 5.5 --- .../sources/components/ui/lists/MeterBar.tsx | 7 ++- apps/ui/sources/text/translations/ca.ts | 4 ++ apps/ui/sources/text/translations/de.ts | 4 ++ apps/ui/sources/text/translations/en.ts | 4 ++ apps/ui/sources/text/translations/es.ts | 4 ++ apps/ui/sources/text/translations/fr.ts | 4 ++ apps/ui/sources/text/translations/it.ts | 4 ++ apps/ui/sources/text/translations/ja.ts | 4 ++ apps/ui/sources/text/translations/pl.ts | 4 ++ apps/ui/sources/text/translations/pt.ts | 4 ++ apps/ui/sources/text/translations/ru.ts | 4 ++ apps/ui/sources/text/translations/zh-Hans.ts | 4 ++ .../text/translations/zh-HantOverrides.ts | 4 ++ .../voice/downloads/downloadProgress.spec.ts | 7 ++- .../voice/downloads/downloadProgress.ts | 14 +++-- .../modelPack/modelPackTransferRows.test.tsx | 24 +++++++++ .../modelPack/modelPackTransferRows.tsx | 53 +++++++++++++++---- .../src/installerCore.test.ts | 39 ++++++++++++++ .../voice-modelpacks/src/installerCore.ts | 31 +++++++---- 19 files changed, 194 insertions(+), 29 deletions(-) create mode 100644 apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.test.tsx diff --git a/apps/ui/sources/components/ui/lists/MeterBar.tsx b/apps/ui/sources/components/ui/lists/MeterBar.tsx index 1eb1ad098b4..ebe19f11a2c 100644 --- a/apps/ui/sources/components/ui/lists/MeterBar.tsx +++ b/apps/ui/sources/components/ui/lists/MeterBar.tsx @@ -60,9 +60,12 @@ function AnimatedMeterFill(props: Readonly<{ percentage: number; color: string; value.setValue(fraction); return; } + // Transfers report progress every ~100ms and each report restarts this animation. A decelerating + // curve then lurches and stalls on every report; a linear glide spanning a few reports reads as + // one continuous motion. const animation = Animated.timing(value, { - toValue: fraction, duration: motionTokens.durationMs.fast, - easing: motionTokens.easing.standard, useNativeDriver: Platform.OS !== 'web', + toValue: fraction, duration: motionTokens.durationMs.slow, + easing: motionTokens.easing.linear, useNativeDriver: Platform.OS !== 'web', }); animation.start(); return () => animation.stop(); diff --git a/apps/ui/sources/text/translations/ca.ts b/apps/ui/sources/text/translations/ca.ts index a59c2f6d4a9..b99401ea62c 100644 --- a/apps/ui/sources/text/translations/ca.ts +++ b/apps/ui/sources/text/translations/ca.ts @@ -11927,6 +11927,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'fitxer' : 'fitxers'}`, progressAccessibilityLabel: "Progrés de la descàrrega", + verifyingDetail: "Verificant", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Verificat ${verified} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'fitxer' : 'fitxers'}`, + verifyingAccessibilityLabel: "Progrés de la verificació", failedDetail: "Ha fallat", failedSubtitle: "No s'ha pogut preparar el model. Toca per tornar-ho a provar.", failedResumableSubtitle: ({ size }: { size: string }) => `La descàrrega no s'ha completat. Es conserven ${size}; toca per reprendre-la.`, diff --git a/apps/ui/sources/text/translations/de.ts b/apps/ui/sources/text/translations/de.ts index 7b17184a9f3..5897c7ea5fa 100644 --- a/apps/ui/sources/text/translations/de.ts +++ b/apps/ui/sources/text/translations/de.ts @@ -12932,6 +12932,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} von ${total} · ${filesDone} von ${filesTotal} ${filesTotal === 1 ? 'Datei' : 'Dateien'}`, progressAccessibilityLabel: "Download-Fortschritt", + verifyingDetail: "Wird geprüft", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `${verified} von ${total} geprüft · ${filesDone} von ${filesTotal} ${filesTotal === 1 ? 'Datei' : 'Dateien'}`, + verifyingAccessibilityLabel: "Prüffortschritt", failedDetail: "Fehlgeschlagen", failedSubtitle: "Das Modell konnte nicht vorbereitet werden. Tippe, um es erneut zu versuchen.", failedResumableSubtitle: ({ size }: { size: string }) => `Der Download wurde nicht abgeschlossen. ${size} bleiben erhalten – tippe zum Fortsetzen.`, diff --git a/apps/ui/sources/text/translations/en.ts b/apps/ui/sources/text/translations/en.ts index 049e6063f69..5e7a7fc9a16 100644 --- a/apps/ui/sources/text/translations/en.ts +++ b/apps/ui/sources/text/translations/en.ts @@ -15707,6 +15707,10 @@ const enSettingsVoice = { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} of ${total} · ${filesDone} of ${filesTotal} ${filesTotal === 1 ? 'file' : 'files'}`, progressAccessibilityLabel: "Download progress", + verifyingDetail: "Verifying", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Verified ${verified} of ${total} · ${filesDone} of ${filesTotal} ${filesTotal === 1 ? 'file' : 'files'}`, + verifyingAccessibilityLabel: "Verification progress", failedDetail: "Failed", failedSubtitle: "Couldn't get the model ready. Tap to try again.", failedResumableSubtitle: ({ size }: { size: string }) => `Download didn't finish. ${size} is saved, so tap to resume.`, diff --git a/apps/ui/sources/text/translations/es.ts b/apps/ui/sources/text/translations/es.ts index 1bf1d1ac708..34fcbebf380 100644 --- a/apps/ui/sources/text/translations/es.ts +++ b/apps/ui/sources/text/translations/es.ts @@ -12840,6 +12840,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'archivo' : 'archivos'}`, progressAccessibilityLabel: "Progreso de la descarga", + verifyingDetail: "Verificando", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Verificado ${verified} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'archivo' : 'archivos'}`, + verifyingAccessibilityLabel: "Progreso de la verificación", failedDetail: "Falló", failedSubtitle: "No se pudo preparar el modelo. Toca para volver a intentarlo.", failedResumableSubtitle: ({ size }: { size: string }) => `La descarga no terminó. Se conservan ${size}; toca para reanudarla.`, diff --git a/apps/ui/sources/text/translations/fr.ts b/apps/ui/sources/text/translations/fr.ts index 5506e2bb234..35e45d3be8a 100644 --- a/apps/ui/sources/text/translations/fr.ts +++ b/apps/ui/sources/text/translations/fr.ts @@ -12712,6 +12712,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} sur ${total} · ${filesDone} ${filesDone <= 1 ? 'fichier' : 'fichiers'} sur ${filesTotal}`, progressAccessibilityLabel: "Progression du téléchargement", + verifyingDetail: "Vérification", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `${verified} sur ${total} vérifiés · ${filesDone} ${filesDone <= 1 ? 'fichier' : 'fichiers'} sur ${filesTotal}`, + verifyingAccessibilityLabel: "Progression de la vérification", failedDetail: "Échec", failedSubtitle: "Impossible de préparer le modèle. Touche pour réessayer.", failedResumableSubtitle: ({ size }: { size: string }) => `Le téléchargement n'est pas terminé. ${size} sont conservés ; touche pour reprendre.`, diff --git a/apps/ui/sources/text/translations/it.ts b/apps/ui/sources/text/translations/it.ts index c6e42a2469d..7f33c829b51 100644 --- a/apps/ui/sources/text/translations/it.ts +++ b/apps/ui/sources/text/translations/it.ts @@ -13059,6 +13059,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} di ${total} · ${filesDone} di ${filesTotal} file`, progressAccessibilityLabel: "Avanzamento del download", + verifyingDetail: "Verifica in corso", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Verificati ${verified} di ${total} · ${filesDone} di ${filesTotal} file`, + verifyingAccessibilityLabel: "Avanzamento della verifica", failedDetail: "Non riuscito", failedSubtitle: "Impossibile preparare il modello. Tocca per riprovare.", failedResumableSubtitle: ({ size }: { size: string }) => `Il download non è stato completato. ${size} sono stati conservati: tocca per riprendere.`, diff --git a/apps/ui/sources/text/translations/ja.ts b/apps/ui/sources/text/translations/ja.ts index 894a5bdb4b5..455481bec29 100644 --- a/apps/ui/sources/text/translations/ja.ts +++ b/apps/ui/sources/text/translations/ja.ts @@ -13122,6 +13122,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} / ${total} · ${filesDone} / ${filesTotal} ファイル`, progressAccessibilityLabel: "ダウンロードの進行状況", + verifyingDetail: "検証中", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `検証済み ${verified} / ${total} · ${filesDone} / ${filesTotal} ファイル`, + verifyingAccessibilityLabel: "検証の進行状況", failedDetail: "失敗", failedSubtitle: "モデルを準備できませんでした。タップしてもう一度お試しください。", failedResumableSubtitle: ({ size }: { size: string }) => `ダウンロードが完了しませんでした。${size} は保存されています。タップして再開できます。`, diff --git a/apps/ui/sources/text/translations/pl.ts b/apps/ui/sources/text/translations/pl.ts index ccd0149bdee..9c22fa20dbb 100644 --- a/apps/ui/sources/text/translations/pl.ts +++ b/apps/ui/sources/text/translations/pl.ts @@ -12672,6 +12672,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} z ${total} · ${filesDone} z ${filesTotal} ${filesTotal === 1 ? 'pliku' : 'plików'}`, progressAccessibilityLabel: "Postęp pobierania", + verifyingDetail: "Weryfikacja", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Zweryfikowano ${verified} z ${total} · ${filesDone} z ${filesTotal} ${filesTotal === 1 ? 'pliku' : 'plików'}`, + verifyingAccessibilityLabel: "Postęp weryfikacji", failedDetail: "Niepowodzenie", failedSubtitle: "Nie udało się przygotować modelu. Dotknij, aby spróbować ponownie.", failedResumableSubtitle: ({ size }: { size: string }) => `Pobieranie nie zostało ukończone. Zachowano ${size} — dotknij, aby wznowić.`, diff --git a/apps/ui/sources/text/translations/pt.ts b/apps/ui/sources/text/translations/pt.ts index c883661b586..98de408049f 100644 --- a/apps/ui/sources/text/translations/pt.ts +++ b/apps/ui/sources/text/translations/pt.ts @@ -12957,6 +12957,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'arquivo' : 'arquivos'}`, progressAccessibilityLabel: "Progresso do download", + verifyingDetail: "Verificando", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Verificado ${verified} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'arquivo' : 'arquivos'}`, + verifyingAccessibilityLabel: "Progresso da verificação", failedDetail: "Falhou", failedSubtitle: "Não foi possível preparar o modelo. Toque para tentar novamente.", failedResumableSubtitle: ({ size }: { size: string }) => `O download não terminou. ${size} foram mantidos; toque para retomar.`, diff --git a/apps/ui/sources/text/translations/ru.ts b/apps/ui/sources/text/translations/ru.ts index 68a4e3f7e8a..a04a160ecbc 100644 --- a/apps/ui/sources/text/translations/ru.ts +++ b/apps/ui/sources/text/translations/ru.ts @@ -12682,6 +12682,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} из ${total} · ${filesDone} из ${filesTotal} ${plural({ count: filesTotal, one: 'файла', few: 'файлов', many: 'файлов' })}`, progressAccessibilityLabel: "Ход загрузки", + verifyingDetail: "Проверка", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Проверено ${verified} из ${total} · ${filesDone} из ${filesTotal} ${plural({ count: filesTotal, one: 'файла', few: 'файлов', many: 'файлов' })}`, + verifyingAccessibilityLabel: "Ход проверки", failedDetail: "Ошибка", failedSubtitle: "Не удалось подготовить модель. Нажмите, чтобы повторить.", failedResumableSubtitle: ({ size }: { size: string }) => `Загрузка не завершилась. ${size} сохранено — нажмите, чтобы продолжить.`, diff --git a/apps/ui/sources/text/translations/zh-Hans.ts b/apps/ui/sources/text/translations/zh-Hans.ts index f68d4283799..03b0d4cb591 100644 --- a/apps/ui/sources/text/translations/zh-Hans.ts +++ b/apps/ui/sources/text/translations/zh-Hans.ts @@ -12404,6 +12404,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} / ${total} · ${filesDone} / ${filesTotal} 个文件`, progressAccessibilityLabel: "下载进度", + verifyingDetail: "正在校验", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `已校验 ${verified} / ${total} · ${filesDone} / ${filesTotal} 个文件`, + verifyingAccessibilityLabel: "校验进度", failedDetail: "失败", failedSubtitle: "无法准备模型。轻点以重试。", failedResumableSubtitle: ({ size }: { size: string }) => `下载未完成。已保留 ${size},轻点即可继续。`, diff --git a/apps/ui/sources/text/translations/zh-HantOverrides.ts b/apps/ui/sources/text/translations/zh-HantOverrides.ts index 4849a79ceb8..89259a42b3e 100644 --- a/apps/ui/sources/text/translations/zh-HantOverrides.ts +++ b/apps/ui/sources/text/translations/zh-HantOverrides.ts @@ -10471,6 +10471,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} / ${total} · ${filesDone} / ${filesTotal} 個檔案`, progressAccessibilityLabel: "下載進度", + verifyingDetail: "正在驗證", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `已驗證 ${verified} / ${total} · ${filesDone} / ${filesTotal} 個檔案`, + verifyingAccessibilityLabel: "驗證進度", failedDetail: "失敗", failedSubtitle: "無法準備模型。輕點以重試。", failedResumableSubtitle: ({ size }: { size: string }) => `下載未完成。已保留 ${size},輕點即可繼續。`, diff --git a/apps/ui/sources/voice/downloads/downloadProgress.spec.ts b/apps/ui/sources/voice/downloads/downloadProgress.spec.ts index a3cba23de4e..467960c2803 100644 --- a/apps/ui/sources/voice/downloads/downloadProgress.spec.ts +++ b/apps/ui/sources/voice/downloads/downloadProgress.spec.ts @@ -4,11 +4,11 @@ import { formatDownloadProgressDetail, parseDownloadProgress } from './downloadP describe('parseDownloadProgress', () => { it('parses number fractions as percent', () => { - expect(parseDownloadProgress(0.25)).toEqual({ percent: 25, label: null, bytes: null, files: null }); + expect(parseDownloadProgress(0.25)).toEqual({ percent: 25, label: null, bytes: null, files: null, verifiedBytes: null }); }); it('parses percent numbers directly', () => { - expect(parseDownloadProgress(42)).toEqual({ percent: 42, label: null, bytes: null, files: null }); + expect(parseDownloadProgress(42)).toEqual({ percent: 42, label: null, bytes: null, files: null, verifiedBytes: null }); }); it('parses loaded/total into percent and byte counts', () => { @@ -17,6 +17,7 @@ describe('parseDownloadProgress', () => { label: null, bytes: { loaded: 50, total: 200 }, files: null, + verifiedBytes: null, }); }); @@ -38,6 +39,7 @@ describe('parseDownloadProgress', () => { label: 'model_quantized.onnx', bytes: null, files: null, + verifiedBytes: null, }); }); @@ -47,6 +49,7 @@ describe('parseDownloadProgress', () => { label: 'af_heart.bin', bytes: null, files: null, + verifiedBytes: null, }); }); }); diff --git a/apps/ui/sources/voice/downloads/downloadProgress.ts b/apps/ui/sources/voice/downloads/downloadProgress.ts index ed302d4a4ee..2e741557faf 100644 --- a/apps/ui/sources/voice/downloads/downloadProgress.ts +++ b/apps/ui/sources/voice/downloads/downloadProgress.ts @@ -5,6 +5,8 @@ export type DownloadProgressInfo = { bytes: { loaded: number; total: number } | null; /** Completed and total files, when the source reports a multi-file transfer. */ files: { done: number; total: number } | null; + /** Bytes already checked against their expected hash, when the source verifies what it downloads. */ + verifiedBytes: number | null; }; function clampPercent(x: number): number { @@ -35,10 +37,10 @@ export function parseDownloadProgress(progress: unknown): DownloadProgressInfo { if (typeof progress === 'number' && Number.isFinite(progress)) { const isFraction = progress >= 0 && progress <= 1; const percent = clampPercent(Math.floor((isFraction ? progress * 100 : progress) as number)); - return { percent, label: null, bytes: null, files: null }; + return { percent, label: null, bytes: null, files: null, verifiedBytes: null }; } - if (!progress || typeof progress !== 'object') return { percent: null, label: null, bytes: null, files: null }; + if (!progress || typeof progress !== 'object') return { percent: null, label: null, bytes: null, files: null, verifiedBytes: null }; const obj = progress as Record; @@ -58,17 +60,19 @@ export function parseDownloadProgress(progress: unknown): DownloadProgressInfo { const total = obj?.total; if (isFiniteNumber(loaded) && isFiniteNumber(total) && total > 0) { const percent = clampPercent(Math.floor((loaded / total) * 100)); - return { percent, label, bytes: { loaded: Math.min(Math.max(0, loaded), total), total }, files }; + const verified = obj?.verified; + const verifiedBytes = isFiniteNumber(verified) ? Math.min(Math.max(0, verified), total) : null; + return { percent, label, bytes: { loaded: Math.min(Math.max(0, loaded), total), total }, files, verifiedBytes }; } const rawProgress = obj?.progress; if (isFiniteNumber(rawProgress)) { const isFraction = rawProgress >= 0 && rawProgress <= 1; const percent = clampPercent(Math.floor((isFraction ? rawProgress * 100 : rawProgress) as number)); - return { percent, label, bytes: null, files }; + return { percent, label, bytes: null, files, verifiedBytes: null }; } - return { percent: null, label, bytes: null, files }; + return { percent: null, label, bytes: null, files, verifiedBytes: null }; } export function formatDownloadProgressDetail(progress: unknown, opts?: { prefix?: string }): string { diff --git a/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.test.tsx b/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.test.tsx new file mode 100644 index 00000000000..1e227c27a04 --- /dev/null +++ b/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('react-native-unistyles', async () => (await import('@/dev/testkit/mocks/unistyles')).createUnistylesMock()); + +const { resolveModelPackTransferRow } = await import('./modelPackTransferRows'); +const { t } = await import('@/text'); + +describe('resolveModelPackTransferRow', () => { + it('reports a transfer by its downloaded share', () => { + const row = resolveModelPackTransferRow({ + status: 'downloading', partialBytes: 0, + progress: { loaded: 4, total: 8, verified: 0, filesDone: 0, filesTotal: 2 }, + }); + expect(row?.detail).toBe('50%'); + }); + + it('shows verification once every byte has arrived but files are still being checked', () => { + const row = resolveModelPackTransferRow({ + status: 'downloading', partialBytes: 0, + progress: { loaded: 8, total: 8, verified: 2, filesDone: 1, filesTotal: 2 }, + }); + expect(row?.detail).toBe(t('settingsVoice.local.modelPackDownload.verifyingDetail')); + }); +}); diff --git a/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx b/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx index 3a5ffe52fee..f7da71d6e5c 100644 --- a/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx +++ b/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx @@ -38,32 +38,63 @@ export function readPartialDownloadBytes(summary: Readonly<{ installed: boolean; return summary.partialBytes ?? 0; } -function ModelPackProgressBar(props: Readonly<{ progress: number }>) { +function ModelPackProgressBar(props: Readonly<{ progress: number; accessibilityLabel: string }>) { const { theme } = useUnistyles(); return ; + progressAccessibilityLabel={props.accessibilityLabel} />; +} + +function progressSubtitle( + phase: 'download' | 'verify', + fraction: number, + accessibilityLabel: string, + line: string, +): React.ReactNode { + // Item renders the string with its own subtitle typography below the bar. + return ( + <> + + {/* The bar's meaning changes between phases; a fresh bar starts at the new value instead of + gliding backward from a full download bar to the verification still in progress. */} + + + {line} + + ); } function describeDownloading(progress: unknown): ModelPackRowPresentation { const info = parseDownloadProgress(progress); const detail = info.percent != null ? `${info.percent}%` : t('settingsVoice.local.kokoro.modelStatus.downloading'); if (!info.bytes) return { detail, subtitle: null }; - const loaded = formatStorageBytes(info.bytes.loaded); const total = formatStorageBytes(info.bytes.total); + // Every byte has arrived but files are still being hashed: large files take a while to check, so show + // that work instead of a download stuck at 100%. + if (info.files && info.verifiedBytes != null && info.bytes.loaded >= info.bytes.total && info.files.done < info.files.total) { + return { + detail: t('settingsVoice.local.modelPackDownload.verifyingDetail'), + subtitle: progressSubtitle( + 'verify', + info.verifiedBytes / info.bytes.total, + t('settingsVoice.local.modelPackDownload.verifyingAccessibilityLabel'), + t('settingsVoice.local.modelPackDownload.verifyingProgress', { + verified: formatStorageBytes(info.verifiedBytes), total, filesDone: info.files.done, filesTotal: info.files.total, + }), + ), + }; + } + const loaded = formatStorageBytes(info.bytes.loaded); const line = info.files ? t('settingsVoice.local.modelPackDownload.progressWithFiles', { loaded, total, filesDone: info.files.done, filesTotal: info.files.total }) : t('settingsVoice.local.modelPackDownload.progress', { loaded, total }); return { detail, - // Item renders the string with its own subtitle typography below the bar. - subtitle: ( - <> - - - - {line} - + subtitle: progressSubtitle( + 'download', + info.bytes.loaded / info.bytes.total, + t('settingsVoice.local.modelPackDownload.progressAccessibilityLabel'), + line, ), }; } diff --git a/packages/voice-modelpacks/src/installerCore.test.ts b/packages/voice-modelpacks/src/installerCore.test.ts index 3c689e6e2b2..64bdc88973f 100644 --- a/packages/voice-modelpacks/src/installerCore.test.ts +++ b/packages/voice-modelpacks/src/installerCore.test.ts @@ -710,6 +710,45 @@ describe('model-pack transfer intents from PR 560', () => { expect(attempts).toBe(1); }); + it('reports verification progress after the transfer has delivered every byte', async () => { + const memory = createMemoryModelPackInstallerHost(); + const bytes = new Uint8Array([1, 2, 3, 4, 5, 6]); + const url = 'https://example.com/model.onnx'; + memory.serveFile(url, bytes, { chunks: [bytes.slice(0, 2), bytes.slice(2, 4), bytes.slice(4)] }); + const progress: Array<{ loaded: number; verified: number; filesDone: number }> = []; + await installModelPackWithHost({ + host: { ...memory.host, openDownload: async (request) => { + // Native hosts finish the transfer before the core reads the file back to verify it. + request.onProgress?.(bytes.length); + return memory.host.openDownload(request); + } }, + packId: 'example', manifest: manifestFor([{ path: 'model.onnx', url, sha256: sha256Hex(bytes), sizeBytes: bytes.length }]), + signal: new AbortController().signal, + onProgress: (p) => { progress.push({ loaded: p.loaded, verified: p.verified, filesDone: p.filesDone }); }, + }); + expect(progress.some((p) => p.loaded === bytes.length && p.verified > 0 && p.verified < bytes.length && p.filesDone === 0)).toBe(true); + expect(progress.at(-1)).toEqual({ loaded: bytes.length, verified: bytes.length, filesDone: 1 }); + }); + + it('starts the largest files first so they transfer alongside the small ones', async () => { + const memory = createMemoryModelPackInstallerHost(); + const small = new Uint8Array([1]); + const medium = new Uint8Array(16).fill(2); + const large = new Uint8Array(64).fill(3); + const smallFiles = Array.from({ length: 8 }, (_, index) => ({ + path: `espeak-ng-data/f${index}`, url: `https://example.com/f${index}`, sha256: sha256Hex(small), sizeBytes: small.length, + })); + for (const entry of smallFiles) memory.serveFile(entry.url, small); + memory.serveFile('https://example.com/large', large); + memory.serveFile('https://example.com/medium', medium); + await runInstall(memory, manifestFor([ + ...smallFiles, + { path: 'model.onnx', url: 'https://example.com/large', sha256: sha256Hex(large), sizeBytes: large.length }, + { path: 'voices.bin', url: 'https://example.com/medium', sha256: sha256Hex(medium), sizeBytes: medium.length }, + ])); + expect(memory.requestedUrls().slice(0, 2)).toEqual(['https://example.com/large', 'https://example.com/medium']); + }); + it('reports file counts only after the file has verified', async () => { const memory = createMemoryModelPackInstallerHost(); const bytes = new Uint8Array([1, 2, 3]); diff --git a/packages/voice-modelpacks/src/installerCore.ts b/packages/voice-modelpacks/src/installerCore.ts index d57682a84ef..05b671b0c34 100644 --- a/packages/voice-modelpacks/src/installerCore.ts +++ b/packages/voice-modelpacks/src/installerCore.ts @@ -54,6 +54,8 @@ export type ModelPackCoreProgress = Readonly<{ loaded: number; total: number; file?: string; + /** Bytes already checked against their manifest digest; trails `loaded` while transferred files are read back. */ + verified: number; filesDone: number; filesTotal: number; }>; @@ -294,7 +296,8 @@ async function downloadFileResumable(opts: { staging: ModelPackStagingHandle; entry: ModelPackManifest['files'][number]; signal: AbortSignal; - onFileProgress: (loadedForFile: number) => void; + /** `verifiedForFile` is set once bytes are hashed; a restarted pass reports it from 0 again. */ + onFileProgress: (loadedForFile: number, verifiedForFile?: number) => void; urlPolicy?: ModelPackUrlPolicy; transferUrl?: string; }): Promise { @@ -319,7 +322,7 @@ async function downloadFileResumable(opts: { throw new ModelPackInstallError('model_pack_size_mismatch'); } hasher.update(chunk); - opts.onFileProgress(streamed); + opts.onFileProgress(streamed, streamed); throwIfAborted(signal); }); throwIfAborted(signal); @@ -412,7 +415,7 @@ async function consumeStream(opts: { startOffset: number; expectedSize: number; signal: AbortSignal; - onFileProgress: (loadedForFile: number) => void; + onFileProgress: (loadedForFile: number, verifiedForFile?: number) => void; finalize: (finalLoaded: number) => Promise; }): Promise { let loaded = opts.startOffset; @@ -429,7 +432,7 @@ async function consumeStream(opts: { } opts.hasher.update(chunk); await opts.staging.appendDownloadedChunk(opts.entry.path, chunk); - opts.onFileProgress(loaded); + opts.onFileProgress(loaded, loaded); } await opts.finalize(loaded); } @@ -508,10 +511,14 @@ export async function installModelPackWithHost(opts: { opts.signal.addEventListener('abort', forwardAbort, { once: true }); if (opts.signal.aborted) pack.abort(); let next = 0; + let verifiedTotal = 0; let filesDone = 0; const failures: unknown[] = []; + // Largest first: a pack's few big files dominate its transfer and verification time, so they start + // at once while the remaining workers drain the many small files, instead of starting alone at the end. + const files = [...opts.manifest.files].sort((left, right) => right.sizeBytes - left.sizeBytes); const report = (file: string) => opts.onProgress?.({ - loaded: loadedTotal, total, file, filesDone, filesTotal: opts.manifest.files.length, + loaded: loadedTotal, total, file, verified: verifiedTotal, filesDone, filesTotal: files.length, }); const installFile = async (entry: ModelPackManifest['files'][number]) => { const prior = priorFiles.get(entry.path); @@ -522,6 +529,7 @@ export async function installModelPackWithHost(opts: { if (await staging.partialByteLength(entry.path) !== entry.sizeBytes) await staging.resetPartial(entry.path); } let loadedThisFile = 0; + let verifiedThisFile = 0; for (let attempt = 0; ; attempt += 1) { try { let transferUrl = entry.url; @@ -532,10 +540,14 @@ export async function installModelPackWithHost(opts: { } await downloadFileResumable({ host: opts.host, packId, staging, entry, signal: pack.signal, urlPolicy: opts.urlPolicy, transferUrl, - onFileProgress: (loadedForFile) => { + onFileProgress: (loadedForFile, verifiedForFile) => { const nextLoaded = Math.max(loadedThisFile, loadedForFile); loadedTotal += nextLoaded - loadedThisFile; loadedThisFile = nextLoaded; + if (verifiedForFile !== undefined) { + verifiedTotal += verifiedForFile - verifiedThisFile; + verifiedThisFile = verifiedForFile; + } report(entry.path); }, }); @@ -549,12 +561,13 @@ export async function installModelPackWithHost(opts: { } } loadedTotal += Math.max(0, entry.sizeBytes - loadedThisFile); + verifiedTotal += entry.sizeBytes - verifiedThisFile; filesDone += 1; report(entry.path); }; const worker = async () => { - while (!pack.signal.aborted && next < opts.manifest.files.length) { - const entry = opts.manifest.files[next++]!; + while (!pack.signal.aborted && next < files.length) { + const entry = files[next++]!; try { await installFile(entry); } catch (error) { failures.push(error); pack.abort(); @@ -562,7 +575,7 @@ export async function installModelPackWithHost(opts: { } }; try { - await Promise.all(Array.from({ length: Math.min(MAX_PARALLEL_FILE_DOWNLOADS, opts.manifest.files.length) }, worker)); + await Promise.all(Array.from({ length: Math.min(MAX_PARALLEL_FILE_DOWNLOADS, files.length) }, worker)); } finally { opts.signal.removeEventListener('abort', forwardAbort); } From 6d1942f9813074d118729e2df8fc2435cf6e3d3f Mon Sep 17 00:00:00 2001 From: danljungstrom Date: Mon, 19 Oct 2026 22:14:10 +0200 Subject: [PATCH 2/4] fix(voice): keep model pack verification progress from sliding back on retry A transient failure mid-transfer retries the file, and the retry rehashes the kept prefix from zero. The verified count followed that rehash, so the Verifying bar jumped backward. Clamp it per file like loaded bytes. Co-Authored-By: Claude Opus 5.5 --- .../src/installerCore.test.ts | 33 +++++++++++++++++++ .../voice-modelpacks/src/installerCore.ts | 8 ++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/voice-modelpacks/src/installerCore.test.ts b/packages/voice-modelpacks/src/installerCore.test.ts index 64bdc88973f..6c8781e96c5 100644 --- a/packages/voice-modelpacks/src/installerCore.test.ts +++ b/packages/voice-modelpacks/src/installerCore.test.ts @@ -730,6 +730,39 @@ describe('model-pack transfer intents from PR 560', () => { expect(progress.at(-1)).toEqual({ loaded: bytes.length, verified: bytes.length, filesDone: 1 }); }); + it('keeps verification progress monotonic when a retry rehashes the kept prefix', async () => { + const memory = createMemoryModelPackInstallerHost(); + const bytes = new Uint8Array([1, 2, 3, 4, 5, 6]); + const url = 'https://example.com/model.onnx'; + memory.serveFile(url, bytes); + let opened = 0; + const verified: number[] = []; + await installModelPackWithHost({ + host: { ...memory.host, beginStaging: async (packId, plan) => { + const handle = await memory.host.beginStaging(packId, plan); + // Real hosts read the kept prefix back in bounded chunks rather than in one buffer. + return { ...handle, streamPartial: (filePath, onChunk) => handle.streamPartial(filePath, async (chunk) => { + for (let offset = 0; offset < chunk.byteLength; offset += 1) await onChunk(chunk.slice(offset, offset + 1)); + }) }; + }, openDownload: async (request) => { + opened += 1; + if (opened > 1) return memory.host.openDownload({ ...request, url }); + const chunks = [bytes.slice(0, 2), bytes.slice(2, 4)]; + return { contentLength: bytes.length, isPartial: false, read: async () => { + const chunk = chunks.shift(); + if (!chunk) throw new Error('model_pack_download_failed:503'); + return chunk; + } }; + } }, + packId: 'example', manifest: manifestFor([{ path: 'model.onnx', url, sha256: sha256Hex(bytes), sizeBytes: bytes.length }]), + signal: new AbortController().signal, + onProgress: (p) => { verified.push(p.verified); }, + }); + expect(opened).toBe(2); + expect(verified).toEqual([...verified].sort((a, b) => a - b)); + expect(verified.at(-1)).toBe(bytes.length); + }); + it('starts the largest files first so they transfer alongside the small ones', async () => { const memory = createMemoryModelPackInstallerHost(); const small = new Uint8Array([1]); diff --git a/packages/voice-modelpacks/src/installerCore.ts b/packages/voice-modelpacks/src/installerCore.ts index 05b671b0c34..ad140365b56 100644 --- a/packages/voice-modelpacks/src/installerCore.ts +++ b/packages/voice-modelpacks/src/installerCore.ts @@ -544,10 +544,10 @@ export async function installModelPackWithHost(opts: { const nextLoaded = Math.max(loadedThisFile, loadedForFile); loadedTotal += nextLoaded - loadedThisFile; loadedThisFile = nextLoaded; - if (verifiedForFile !== undefined) { - verifiedTotal += verifiedForFile - verifiedThisFile; - verifiedThisFile = verifiedForFile; - } + // A retry rehashes the kept prefix from zero; keep the bar from sliding back. + const nextVerified = Math.max(verifiedThisFile, verifiedForFile ?? 0); + verifiedTotal += nextVerified - verifiedThisFile; + verifiedThisFile = nextVerified; report(entry.path); }, }); From 92c77f5c53aecbd96faacf18d1a4aa41457b1641 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 20 Oct 2026 11:51:13 +0200 Subject: [PATCH 3/4] fix(voice): preserve verification progress through native preparation Forward the shared installer progress object through Kokoro preparation and withdraw rejected existing bytes before downloading their replacement. Keep healthy retry progress monotonic at the shared installer owner. Add regressions through the real native preparation and shared installer paths, cover verification parsing, and describe the development verification phase. The rejected-byte correction preserves the behavior introduced in PR #608. Co-authored-by: danljungstrom --- .../content/docs/voice/local-providers.mdx | 3 +- .../voice/downloads/downloadProgress.spec.ts | 8 ++++ .../synthesizeKokoroWav.native.spec.ts | 40 ++++++++++++++++++- .../runtime/synthesizeKokoroWav.native.ts | 4 +- .../voice/modelPacks/installer.native.ts | 6 +-- .../src/installerCore.test.ts | 25 ++++++++++++ .../voice-modelpacks/src/installerCore.ts | 13 ++++++ 7 files changed, 91 insertions(+), 8 deletions(-) diff --git a/apps/docs/content/docs/voice/local-providers.mdx b/apps/docs/content/docs/voice/local-providers.mdx index a882871fac7..67dd7dbbe4e 100644 --- a/apps/docs/content/docs/voice/local-providers.mdx +++ b/apps/docs/content/docs/voice/local-providers.mdx @@ -465,7 +465,8 @@ From settings, users can: - remove downloaded assets to free storage In 0.3 development builds, native downloads show transferred bytes and verified -file counts. Stopping or interrupting a download keeps resumable files; retry to +file counts. Once all bytes have arrived, any remaining integrity checks appear +as **Verifying** with checked-byte progress. Stopping or interrupting a download keeps resumable files; retry to continue, or use the remove action to discard the unfinished download and free storage. Updates reuse unchanged files after verifying them and keep the installed pack in place while downloading. diff --git a/apps/ui/sources/voice/downloads/downloadProgress.spec.ts b/apps/ui/sources/voice/downloads/downloadProgress.spec.ts index 467960c2803..8c8a0cc7aec 100644 --- a/apps/ui/sources/voice/downloads/downloadProgress.spec.ts +++ b/apps/ui/sources/voice/downloads/downloadProgress.spec.ts @@ -28,6 +28,14 @@ describe('parseDownloadProgress', () => { }); }); + it('parses verified bytes within the transfer size', () => { + expect(parseDownloadProgress({ loaded: 200, total: 200, verified: 150 }).verifiedBytes).toBe(150); + expect(parseDownloadProgress({ loaded: 200, total: 200, verified: 900 }).verifiedBytes).toBe(200); + expect(parseDownloadProgress({ loaded: 200, total: 200, verified: -1 }).verifiedBytes).toBe(0); + expect(parseDownloadProgress({ loaded: 200, total: 200, verified: Number.NaN }).verifiedBytes).toBeNull(); + expect(parseDownloadProgress({ loaded: 200, total: 200 }).verifiedBytes).toBeNull(); + }); + it('ignores file counts that cannot describe a transfer', () => { expect(parseDownloadProgress({ loaded: 1, total: 2, filesDone: 0, filesTotal: 0 }).files).toBeNull(); expect(parseDownloadProgress({ loaded: 1, total: 2, filesDone: Number.NaN, filesTotal: 4 }).files).toBeNull(); diff --git a/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.native.spec.ts b/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.native.spec.ts index c32c7a8362e..831b24bac83 100644 --- a/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.native.spec.ts +++ b/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.native.spec.ts @@ -80,6 +80,42 @@ async function createNativeSentenceFixture(text: string, controller = new AbortC } describe('synthesizeKokoroWav (native)', () => { + it('preserves verification and file progress through native Kokoro preparation', async () => { + const { fs: memoryFs } = createMemFs(); + class File extends memoryFs.File { + override open() { + const handle = super.open(); + return { ...handle, readBytes: (length: number) => handle.readBytes(Math.min(length, 2)) }; + } + } + const fs = { ...memoryFs, File, Paths: { ...memoryFs.Paths, cache: 'file:///tmp/' } }; + const bytes = new Uint8Array([1, 2, 3, 4, 5, 6]); + const packId = 'kokoro-verification-progress'; + const manifest: ModelPackManifest = { + packId, kind: 'tts_sherpa', model: 'kokoro', version: 'v1', + files: [{ path: 'model.onnx', url: 'https://example.com/model.onnx', sizeBytes: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex') }], + }; + const fetchImpl: typeof fetch = async input => new URL(String(input)).pathname.endsWith('manifest.json') + ? Response.json(manifest) : new Response(bytes); + const progress: unknown[] = []; + await prepareKokoroTts({ assetSetId: packId, timeoutMs: null, signal: new AbortController().signal, + onProgress: event => progress.push(event) }, { + fs, + resolveManifestUrl: () => 'https://example.com/manifest.json', + // Bind only the filesystem/network boundaries; preparation, installation, + // shared-core verification and progress forwarding remain the real path. + ensureInstalled: (options, overrides) => ensureModelPackInstalled(options, + { ...overrides, fs, fetch: fetchImpl, download: createMemNativeDownload(fs, fetchImpl) }), + kokoroNativeModule: { initialize: async () => {}, listVoices: async () => [], + synthesizeToWavFile: async () => ({ wavPath: 'file:///unused.wav', sampleRate: 24_000 }), cancel: async () => {} }, + }); + expect(progress).toContainEqual(expect.objectContaining({ + loaded: bytes.length, total: bytes.length, verified: 2, filesDone: 0, filesTotal: 1, + })); + expect(progress.at(-1)).toMatchObject({ verified: bytes.length, filesDone: 1, filesTotal: 1 }); + }); + it('streams one native WAV per sentence, in order, through the installed runtime', async () => { const { stream, native, wavs } = await createNativeSentenceFixture('Alpha. Beta. Gamma.'); const out: string[] = []; @@ -878,7 +914,9 @@ describe('synthesizeKokoroWav (native)', () => { }, ); - expect(onProgress).toHaveBeenCalledWith({ loaded: 1, total: 4, file: 'onnx/model_quantized.onnx' }); + expect(onProgress).toHaveBeenCalledWith({ + loaded: 1, total: 4, file: 'onnx/model_quantized.onnx', verified: 0, filesDone: 0, filesTotal: 1, + }); expect(files.get('file:///docs/happier/voice/modelPacks/kokoro-test/onnx/model_quantized.onnx')).toEqual(bytes); } finally { vi.unstubAllGlobals(); diff --git a/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.native.ts b/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.native.ts index 578e14f9ae4..183c7fe6850 100644 --- a/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.native.ts +++ b/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.native.ts @@ -421,9 +421,7 @@ export async function prepareKokoroTts( manifestUrl, timeoutMs: opts.timeoutMs, signal: opts.signal, - onProgress: (p) => { - opts.onProgress?.({ loaded: p.loaded, total: p.total, file: (p as any)?.file }); - }, + onProgress: opts.onProgress, }, { fs: fs as any }, ); diff --git a/apps/ui/sources/voice/modelPacks/installer.native.ts b/apps/ui/sources/voice/modelPacks/installer.native.ts index b3e6b858205..5ac077b8bb9 100644 --- a/apps/ui/sources/voice/modelPacks/installer.native.ts +++ b/apps/ui/sources/voice/modelPacks/installer.native.ts @@ -1,6 +1,6 @@ import { parseModelPackManifest, type ModelPackManifest } from '@happier-dev/protocol/voice/modelPacks/manifest'; import { getOptionalHappierSherpaNativeModule } from '@happier-dev/sherpa-native'; -import { installModelPackWithHost, type ModelPackPromotionPriorInstallV1 } from '@happier-dev/voice-modelpacks'; +import { installModelPackWithHost, type ModelPackCoreProgress, type ModelPackPromotionPriorInstallV1 } from '@happier-dev/voice-modelpacks'; import { uriToFilePath } from '@/platform/fileUri'; import { forgetSpeakerCountForAssetsDir } from '@/voice/kokoro/runtime/kokoroSpeakerCountCache'; @@ -73,7 +73,7 @@ async function installViaHost(opts: { signal: AbortSignal; priorInstall: ModelPackPromotionPriorInstallV1; priorManifest?: ModelPackManifest; - onProgress?: (p: { loaded: number; total: number; file?: string }) => void; + onProgress?: (p: ModelPackCoreProgress) => void; }): Promise<{ packDirUri: string; manifest: ModelPackManifest }> { const host = createExpoModelPackInstallerHost({ fs: opts.fs, @@ -99,7 +99,7 @@ export async function ensureModelPackInstalled( manifestUrl: string | null; timeoutMs: number | null; signal: AbortSignal; - onProgress?: (p: { loaded: number; total: number; file?: string }) => void; + onProgress?: (p: ModelPackCoreProgress) => void; updatePolicy?: UpdatePolicy; }, overrides: InstallerOverrides = {}, diff --git a/packages/voice-modelpacks/src/installerCore.test.ts b/packages/voice-modelpacks/src/installerCore.test.ts index 6c8781e96c5..d1933af38bd 100644 --- a/packages/voice-modelpacks/src/installerCore.test.ts +++ b/packages/voice-modelpacks/src/installerCore.test.ts @@ -43,6 +43,31 @@ function runInstall( } describe('installModelPackWithHost (shared core)', () => { + it('withdraws rejected existing bytes before downloading their replacement', async () => { + const bytes = new Uint8Array([1, 2, 3, 4, 5, 6]); + const memory = createMemoryModelPackInstallerHost(); + const url = 'https://example.com/model.onnx'; + const manifest = manifestFor([ + { path: 'model.onnx', url, sha256: sha256Hex(bytes), sizeBytes: bytes.length }, + ]); + memory.seedPartialDownload('example/model.onnx', new Uint8Array(bytes.length).fill(9), deriveModelPackStagingPlan(manifest).key); + memory.serveFile(url, bytes, { chunks: [bytes.slice(0, 2), bytes.slice(2)] }); + const progress: unknown[] = []; + let replacementStartedAt: unknown; + await installModelPackWithHost({ + host: { ...memory.host, openDownload: async request => { + replacementStartedAt = progress.at(-1); + return memory.host.openDownload(request); + } }, + packId: manifest.packId, manifest, signal: new AbortController().signal, + onProgress: event => progress.push(event), + }); + expect(replacementStartedAt).toMatchObject({ loaded: 0, verified: 0, filesDone: 0 }); + expect(progress).toContainEqual(expect.objectContaining({ loaded: 2, verified: 2, filesDone: 0 })); + expect(progress.at(-1)).toMatchObject({ loaded: bytes.length, verified: bytes.length, filesDone: 1 }); + expect(memory.read('example/model.onnx')).toEqual(bytes); + }); + it.each([0, 2])('keeps progress monotonic when transport completes before verification with prefix %i', async (prefixLength) => { const bytes = new Uint8Array([1, 2, 3, 4]); const host = createMemoryModelPackInstallerHost(); diff --git a/packages/voice-modelpacks/src/installerCore.ts b/packages/voice-modelpacks/src/installerCore.ts index ad140365b56..0d0bedd8c07 100644 --- a/packages/voice-modelpacks/src/installerCore.ts +++ b/packages/voice-modelpacks/src/installerCore.ts @@ -298,6 +298,8 @@ async function downloadFileResumable(opts: { signal: AbortSignal; /** `verifiedForFile` is set once bytes are hashed; a restarted pass reports it from 0 again. */ onFileProgress: (loadedForFile: number, verifiedForFile?: number) => void; + /** Previously reported existing bytes were discarded and must no longer contribute to progress. */ + onExistingRejected: () => void; urlPolicy?: ModelPackUrlPolicy; transferUrl?: string; }): Promise { @@ -328,6 +330,7 @@ async function downloadFileResumable(opts: { throwIfAborted(signal); if (reported !== streamed || streamed !== existing) { await staging.resetPartial(entry.path); + opts.onExistingRejected(); return downloadFileResumable({ ...opts, onFileProgress: opts.onFileProgress }); } else { loaded = streamed; @@ -336,6 +339,7 @@ async function downloadFileResumable(opts: { // Cancelling verification does not invalidate the existing resumable prefix. throwIfAborted(signal); await staging.resetPartial(entry.path); + opts.onExistingRejected(); if (error instanceof ModelPackInstallError) { throw error; } @@ -344,6 +348,7 @@ async function downloadFileResumable(opts: { } else if (existing > expectedSize) { // Corrupt/overlong partial — start over. await staging.resetPartial(entry.path); + opts.onExistingRejected(); } // Already complete? Verify hash and skip the network entirely. @@ -353,6 +358,7 @@ async function downloadFileResumable(opts: { return; } await staging.resetPartial(entry.path); + opts.onExistingRejected(); // Fall through to a clean re-download. return downloadFileResumable({ ...opts, onFileProgress: opts.onFileProgress }); } @@ -540,6 +546,13 @@ export async function installModelPackWithHost(opts: { } await downloadFileResumable({ host: opts.host, packId, staging, entry, signal: pack.signal, urlPolicy: opts.urlPolicy, transferUrl, + onExistingRejected: () => { + loadedTotal -= loadedThisFile; + verifiedTotal -= verifiedThisFile; + loadedThisFile = 0; + verifiedThisFile = 0; + report(entry.path); + }, onFileProgress: (loadedForFile, verifiedForFile) => { const nextLoaded = Math.max(loadedThisFile, loadedForFile); loadedTotal += nextLoaded - loadedThisFile; From 38f32335476ca9c75847cb482f0076c2e8481db6 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 20 Oct 2026 13:35:16 +0200 Subject: [PATCH 4/4] fix(voice): restart verification when a server ignores Range Reset checked-byte progress when a full response replaces the hashed resume prefix. Preserve completed native transport progress and keep whole-file rejection accounting at the same shared installer owner. Strengthen the existing ignored-Range regression and use a noun for the Spanish verification progress label. --- apps/ui/sources/text/translations/es.ts | 2 +- .../src/installerCore.test.ts | 23 ++++++++++++++++--- .../voice-modelpacks/src/installerCore.ts | 21 +++++++++-------- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/ui/sources/text/translations/es.ts b/apps/ui/sources/text/translations/es.ts index 34fcbebf380..991ee6f36ee 100644 --- a/apps/ui/sources/text/translations/es.ts +++ b/apps/ui/sources/text/translations/es.ts @@ -12842,7 +12842,7 @@ settingsSession: { progressAccessibilityLabel: "Progreso de la descarga", verifyingDetail: "Verificando", verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => - `Verificado ${verified} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'archivo' : 'archivos'}`, + `Verificación: ${verified} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'archivo' : 'archivos'}`, verifyingAccessibilityLabel: "Progreso de la verificación", failedDetail: "Falló", failedSubtitle: "No se pudo preparar el modelo. Toca para volver a intentarlo.", diff --git a/packages/voice-modelpacks/src/installerCore.test.ts b/packages/voice-modelpacks/src/installerCore.test.ts index d1933af38bd..fa785a62877 100644 --- a/packages/voice-modelpacks/src/installerCore.test.ts +++ b/packages/voice-modelpacks/src/installerCore.test.ts @@ -78,21 +78,38 @@ describe('installModelPackWithHost (shared core)', () => { if (prefixLength) host.seedPartialDownload('example/model.onnx', bytes.slice(0, prefixLength), deriveModelPackStagingPlan(manifest).key); const baseHost = host.host; const loaded: number[] = []; + const progress: Array<{ loaded: number; verified: number }> = []; + let beforeReplacementRead: { loaded: number; verified: number } | undefined; await installModelPackWithHost({ host: { ...baseHost, openDownload: async (request) => { request.onProgress?.(bytes.length); // The server ignores Range and sends a complete replacement response. - return baseHost.openDownload({ ...request, rangeStart: undefined }); + const stream = await baseHost.openDownload({ ...request, rangeStart: undefined }); + let firstRead = true; + return { ...stream, read: async () => { + if (firstRead) { + firstRead = false; + beforeReplacementRead = progress.at(-1); + } + return stream.read(); + } }; }, }, packId: manifest.packId, manifest, signal: new AbortController().signal, - onProgress: (progress) => { loaded.push(progress.loaded); expect(progress.total).toBe(bytes.length); }, + onProgress: (event) => { + loaded.push(event.loaded); + progress.push({ loaded: event.loaded, verified: event.verified }); + expect(event.total).toBe(bytes.length); + }, }); + // A full native transfer already arrived, but its replacement hash starts + // at zero after the Range prefix and its hasher are discarded. + expect(beforeReplacementRead).toEqual({ loaded: bytes.length, verified: 0 }); expect(loaded[prefixLength ? 1 : 0]).toBe(bytes.length); expect(loaded.every((value, index) => value <= bytes.length && value >= (loaded[index - 1] ?? 0))).toBe(true); - expect(loaded.at(-1)).toBe(bytes.length); + expect(progress.at(-1)).toEqual({ loaded: bytes.length, verified: bytes.length }); expect(host.read('example/model.onnx')).toEqual(bytes); }); diff --git a/packages/voice-modelpacks/src/installerCore.ts b/packages/voice-modelpacks/src/installerCore.ts index 0d0bedd8c07..821dd5074e7 100644 --- a/packages/voice-modelpacks/src/installerCore.ts +++ b/packages/voice-modelpacks/src/installerCore.ts @@ -298,8 +298,8 @@ async function downloadFileResumable(opts: { signal: AbortSignal; /** `verifiedForFile` is set once bytes are hashed; a restarted pass reports it from 0 again. */ onFileProgress: (loadedForFile: number, verifiedForFile?: number) => void; - /** Previously reported existing bytes were discarded and must no longer contribute to progress. */ - onExistingRejected: () => void; + /** Withdraw discarded file bytes, or only hash progress when a full response replaces a Range prefix. */ + onProgressReset: (scope: 'file' | 'verification') => void; urlPolicy?: ModelPackUrlPolicy; transferUrl?: string; }): Promise { @@ -330,7 +330,7 @@ async function downloadFileResumable(opts: { throwIfAborted(signal); if (reported !== streamed || streamed !== existing) { await staging.resetPartial(entry.path); - opts.onExistingRejected(); + opts.onProgressReset('file'); return downloadFileResumable({ ...opts, onFileProgress: opts.onFileProgress }); } else { loaded = streamed; @@ -339,7 +339,7 @@ async function downloadFileResumable(opts: { // Cancelling verification does not invalidate the existing resumable prefix. throwIfAborted(signal); await staging.resetPartial(entry.path); - opts.onExistingRejected(); + opts.onProgressReset('file'); if (error instanceof ModelPackInstallError) { throw error; } @@ -348,7 +348,7 @@ async function downloadFileResumable(opts: { } else if (existing > expectedSize) { // Corrupt/overlong partial — start over. await staging.resetPartial(entry.path); - opts.onExistingRejected(); + opts.onProgressReset('file'); } // Already complete? Verify hash and skip the network entirely. @@ -358,7 +358,7 @@ async function downloadFileResumable(opts: { return; } await staging.resetPartial(entry.path); - opts.onExistingRejected(); + opts.onProgressReset('file'); // Fall through to a clean re-download. return downloadFileResumable({ ...opts, onFileProgress: opts.onFileProgress }); } @@ -384,6 +384,7 @@ async function downloadFileResumable(opts: { await staging.resetPartial(entry.path); loaded = 0; hasher = host.createHasher(); + opts.onProgressReset('verification'); } await consumeStream({ @@ -546,10 +547,12 @@ export async function installModelPackWithHost(opts: { } await downloadFileResumable({ host: opts.host, packId, staging, entry, signal: pack.signal, urlPolicy: opts.urlPolicy, transferUrl, - onExistingRejected: () => { - loadedTotal -= loadedThisFile; + onProgressReset: (scope) => { + if (scope === 'file') { + loadedTotal -= loadedThisFile; + loadedThisFile = 0; + } verifiedTotal -= verifiedThisFile; - loadedThisFile = 0; verifiedThisFile = 0; report(entry.path); },