From 69b0b929788b6726a017a3dae678ec95c12c6b0f Mon Sep 17 00:00:00 2001 From: danljungstrom Date: Mon, 19 Oct 2026 09:20:21 +0200 Subject: [PATCH 1/3] fix(ui-voice): start large model pack files first and show verification progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kokoro's native pack lists model.onnx (114 MB) and voices.bin (54 MB) last among 362 files, so they downloaded nearly alone after the small files, then sat at "100% · 360 of 362 files" while being SHA-256 checked in JS with no visible progress. - Schedule pack files largest-first so the big transfers overlap the small ones and their verification overlaps remaining downloads. - Report verified bytes from the chunked hash and show a "Verifying" phase with its own bar once every byte has arrived. - Animate transfer progress bars linearly over a few native progress reports instead of a decelerating curve restarted on every report. 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 ++ apps/ui/sources/text/translations/zh-Hant.ts | 4 ++ .../voice/downloads/downloadProgress.spec.ts | 13 ++++- .../voice/downloads/downloadProgress.ts | 14 ++++-- .../installer.nativeDownload.spec.ts | 9 ++-- .../voice/modelPacks/installer.spec.ts | 47 +++++++++++++++++-- .../modelPacks/installer/download.native.ts | 46 ++++++++++++++---- .../voice/modelPacks/installer/types.ts | 13 ++++- .../LocalNeuralTtsSettings.native.spec.tsx | 22 +++++++++ .../modelPack/modelPackTransferRows.tsx | 44 ++++++++++++----- 21 files changed, 223 insertions(+), 40 deletions(-) diff --git a/apps/ui/sources/components/ui/lists/MeterBar.tsx b/apps/ui/sources/components/ui/lists/MeterBar.tsx index 384bb407df3..041ec97c7f0 100644 --- a/apps/ui/sources/components/ui/lists/MeterBar.tsx +++ b/apps/ui/sources/components/ui/lists/MeterBar.tsx @@ -77,10 +77,13 @@ export const MeterBar = React.memo((props) => { animatedFill.setValue(fill); 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(animatedFill, { toValue: fill, - duration: motionTokens.durationMs.fast, - easing: motionTokens.easing.standard, + duration: motionTokens.durationMs.slow, + easing: motionTokens.easing.linear, useNativeDriver: Platform.OS !== 'web', }); animation.start(); diff --git a/apps/ui/sources/text/translations/ca.ts b/apps/ui/sources/text/translations/ca.ts index 3bc1e24131c..afa3479ae63 100644 --- a/apps/ui/sources/text/translations/ca.ts +++ b/apps/ui/sources/text/translations/ca.ts @@ -8785,6 +8785,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 2c1611eeb94..a523cb27e83 100644 --- a/apps/ui/sources/text/translations/de.ts +++ b/apps/ui/sources/text/translations/de.ts @@ -8853,6 +8853,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 e1cdcda52dc..d2b81db54be 100644 --- a/apps/ui/sources/text/translations/en.ts +++ b/apps/ui/sources/text/translations/en.ts @@ -8885,6 +8885,10 @@ settingsSession: { 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 ab3d084975b..5e9e952adb9 100644 --- a/apps/ui/sources/text/translations/es.ts +++ b/apps/ui/sources/text/translations/es.ts @@ -9336,6 +9336,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 1d9ae62aec3..d63e8c1fddd 100644 --- a/apps/ui/sources/text/translations/fr.ts +++ b/apps/ui/sources/text/translations/fr.ts @@ -8840,6 +8840,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 fcc83d0ce63..39aaa6d21f0 100644 --- a/apps/ui/sources/text/translations/it.ts +++ b/apps/ui/sources/text/translations/it.ts @@ -9657,6 +9657,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 6d75d0079b2..1288c028de5 100644 --- a/apps/ui/sources/text/translations/ja.ts +++ b/apps/ui/sources/text/translations/ja.ts @@ -9564,6 +9564,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 5b3fd95b2fc..6957674cbbd 100644 --- a/apps/ui/sources/text/translations/pl.ts +++ b/apps/ui/sources/text/translations/pl.ts @@ -9334,6 +9334,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 7af655343e5..65023744370 100644 --- a/apps/ui/sources/text/translations/pt.ts +++ b/apps/ui/sources/text/translations/pt.ts @@ -9445,6 +9445,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 51d03987427..b78bc97cc6a 100644 --- a/apps/ui/sources/text/translations/ru.ts +++ b/apps/ui/sources/text/translations/ru.ts @@ -9308,6 +9308,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 786b1ece9f0..a11d11e9022 100644 --- a/apps/ui/sources/text/translations/zh-Hans.ts +++ b/apps/ui/sources/text/translations/zh-Hans.ts @@ -9031,6 +9031,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-Hant.ts b/apps/ui/sources/text/translations/zh-Hant.ts index 8f2befb9941..bd0dbb407cb 100644 --- a/apps/ui/sources/text/translations/zh-Hant.ts +++ b/apps/ui/sources/text/translations/zh-Hant.ts @@ -7644,6 +7644,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..3977c1c5d86 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, }); }); @@ -27,6 +28,12 @@ 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 }).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(); @@ -38,6 +45,7 @@ describe('parseDownloadProgress', () => { label: 'model_quantized.onnx', bytes: null, files: null, + verifiedBytes: null, }); }); @@ -47,6 +55,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/modelPacks/installer.nativeDownload.spec.ts b/apps/ui/sources/voice/modelPacks/installer.nativeDownload.spec.ts index d42949c041e..66542000270 100644 --- a/apps/ui/sources/voice/modelPacks/installer.nativeDownload.spec.ts +++ b/apps/ui/sources/voice/modelPacks/installer.nativeDownload.spec.ts @@ -171,11 +171,12 @@ describe('native model pack file downloads', () => { if (platform === 'ios') expect(harness.nativeOptions[0]?.sessionType).toBe(1); expect(harness.files.get(harness.fileUri)).toEqual(harness.bytes); expect(harness.files.has(harness.metaUri)).toBe(true); - // Bytes arrive while the file transfers; it counts as done only once verified. + // Bytes arrive while the file transfers, then hashing reports verified bytes; it counts as done only once verified. expect(progress).toEqual([ - { loaded: 2, total: 4, filesDone: 0, filesTotal: 1 }, - { loaded: 4, total: 4, filesDone: 0, filesTotal: 1 }, - { loaded: 4, total: 4, filesDone: 1, filesTotal: 1 }, + { loaded: 2, total: 4, verified: 0, filesDone: 0, filesTotal: 1 }, + { loaded: 4, total: 4, verified: 0, filesDone: 0, filesTotal: 1 }, + { loaded: 4, total: 4, verified: 4, filesDone: 0, filesTotal: 1 }, + { loaded: 4, total: 4, verified: 4, filesDone: 1, filesTotal: 1 }, ]); expect(harness.lifecycle).toMatchObject({ subscribed: false, retainedNativeTask: false }); }); diff --git a/apps/ui/sources/voice/modelPacks/installer.spec.ts b/apps/ui/sources/voice/modelPacks/installer.spec.ts index ea4909de85d..5f45cae431b 100644 --- a/apps/ui/sources/voice/modelPacks/installer.spec.ts +++ b/apps/ui/sources/voice/modelPacks/installer.spec.ts @@ -199,6 +199,45 @@ describe('modelPacks installer (native)', () => { expect(Buffer.from(fs.files.get(`${PACK_URI}/downloaded.bin`) ?? []).equals(Buffer.from(downloaded))).toBe(true); }); + it('reports verification progress while a downloaded file is hashed', async () => { + const bytes = multiChunkBytes(3); + const fs = createFs(); + useNativeDownloads(fs, () => bytes); + const progress: Array<{ verified: number; filesDone: number }> = []; + await ensureModelPackInstalled({ + ...installOptions, signal: new AbortController().signal, + onProgress: (p) => progress.push({ verified: p.verified, filesDone: p.filesDone }), + }, { + fs, fetch: fetchManifest(manifest([ + { path: 'model.onnx', url: 'https://example.com/model.onnx', sha256: await sha256Hex(bytes), sizeBytes: bytes.length }, + ])), + }); + expect(progress.some((p) => p.filesDone === 0 && p.verified > 0 && p.verified < bytes.length)).toBe(true); + expect(progress.at(-1)).toEqual({ verified: bytes.length, filesDone: 1 }); + }); + + it('starts the largest files first so they transfer alongside the small ones', async () => { + const small = new Uint8Array([1]); + const medium = new Uint8Array(16).fill(2); + const large = new Uint8Array(64).fill(3); + const byUrl = new Map([['https://example.com/large', large], ['https://example.com/medium', medium]]); + const fs = createFs(); + const requested: string[] = []; + useNativeDownloads(fs, (url) => byUrl.get(url) ?? small, requested); + const smallEntries = await Promise.all(Array.from({ length: 8 }, async (_, i) => ({ + path: `espeak-ng-data/f${i}`, url: `https://example.com/f${i}`, sha256: await sha256Hex(small), sizeBytes: small.length, + }))); + await ensureModelPackInstalled({ ...installOptions, signal: new AbortController().signal }, { + fs, fetch: fetchManifest(manifest([ + ...smallEntries, + { path: 'model.onnx', url: 'https://example.com/large', sha256: await sha256Hex(large), sizeBytes: large.length }, + { path: 'voices.bin', url: 'https://example.com/medium', sha256: await sha256Hex(medium), sizeBytes: medium.length }, + ])), + }); + expect(requested.slice(0, 2)).toEqual(['https://example.com/large', 'https://example.com/medium']); + expect(requested).toHaveLength(10); + }); + it('rejects and removes a downloaded file whose SHA-256 does not match', async () => { const fs = createFs(); useNativeDownloads(fs, () => new Uint8Array([4, 5, 6, 7])); @@ -351,7 +390,7 @@ describe('modelPacks installer (native)', () => { useInstallerFakeClock(); const { bytes, manifest: remote } = await packOf(2); const fs = createFs(); - const progress: Array<{ ratio: number; filesDone: number }> = []; + const progress: Array<{ ratio: number; verified: number; filesDone: number }> = []; // A 500 error page is far larger than a tiny espeak-ng asset. useNativeDownloads(fs, () => bytes, [], { status: (url) => (url === 'https://example.com/f0' ? 500 : 200), @@ -359,12 +398,12 @@ describe('modelPacks installer (native)', () => { }); await settleWithFakeClock(ensureModelPackInstalled({ ...installOptions, signal: new AbortController().signal, - onProgress: (p) => progress.push({ ratio: p.loaded / p.total, filesDone: p.filesDone }), + onProgress: (p) => progress.push({ ratio: p.loaded / p.total, verified: p.verified, filesDone: p.filesDone }), }, { fs, fetch: fetchManifest(remote) })); expect(Math.max(...progress.map((p) => p.ratio))).toBe(1); // A retried attempt re-reporting bytes already counted adds no report of its own. - expect(new Set(progress.map((p) => `${p.ratio}:${p.filesDone}`)).size).toBe(progress.length); - expect(progress.at(-1)).toEqual({ ratio: 1, filesDone: 2 }); + expect(new Set(progress.map((p) => `${p.ratio}:${p.verified}:${p.filesDone}`)).size).toBe(progress.length); + expect(progress.at(-1)).toEqual({ ratio: 1, verified: bytes.length * 2, filesDone: 2 }); }); it('removes update leftovers once a fresh install completes', async () => { diff --git a/apps/ui/sources/voice/modelPacks/installer/download.native.ts b/apps/ui/sources/voice/modelPacks/installer/download.native.ts index 6114d20625a..939eb0f589b 100644 --- a/apps/ui/sources/voice/modelPacks/installer/download.native.ts +++ b/apps/ui/sources/voice/modelPacks/installer/download.native.ts @@ -54,15 +54,22 @@ async function abortableDelay(ms: number, signal: AbortSignal): Promise { throwIfAborted(signal); } -async function sha256FileHex(file: Pick, signal: AbortSignal): Promise { +async function sha256FileHex( + file: Pick, + signal: AbortSignal, + onHashed?: (bytes: number) => void, +): Promise { const hash = sha256.create(); const handle = file.open(); + let hashed = 0; try { while (true) { throwIfAborted(signal); const chunk: Uint8Array = handle.readBytes(HASH_READ_CHUNK_BYTES); if (chunk.length === 0) break; hash.update(chunk); + hashed += chunk.length; + onHashed?.(hashed); // `readBytes` is synchronous; yield between chunks so hashing does not block the JS thread. await new Promise((resolve) => setTimeout(resolve, 0)); } @@ -166,11 +173,13 @@ async function downloadAndVerifyFile(opts: { entry: ModelPackManifest['files'][number]; signal: AbortSignal; onProgress?: (p: Progress) => void; + /** Bytes hashed by the current verification pass; a repeated pass starts again from 0. */ + onVerifyProgress?: (bytes: number) => void; }): Promise { const file = getPackFile(opts.fs, opts.rootDir, opts.entry.path, { createParents: true }); if (file.exists) { try { - const actual = await sha256FileHex(file, opts.signal); + const actual = await sha256FileHex(file, opts.signal, opts.onVerifyProgress); if (actual === opts.entry.sha256.toLowerCase()) return; file.delete(); } catch (error) { @@ -191,7 +200,7 @@ async function downloadAndVerifyFile(opts: { downloader: opts.downloader, url: opts.entry.url, fileUri: file.uri, signal: opts.signal, onProgress: opts.onProgress, }); - const actual = await sha256FileHex(file, opts.signal); + const actual = await sha256FileHex(file, opts.signal, opts.onVerifyProgress); if (actual !== opts.entry.sha256.toLowerCase()) { throw new Error('model_pack_sha256_mismatch'); } @@ -212,11 +221,16 @@ export async function downloadManifestFiles(opts: { signal: AbortSignal; onProgress?: (p: ModelPackDownloadProgress) => void; }): Promise { - const total = opts.manifest.files.reduce((acc, f) => acc + (Number.isFinite(f.sizeBytes) ? f.sizeBytes : 0), 0); - const filesTotal = opts.manifest.files.length; + const sizeOf = (f: ModelPackManifest['files'][number]) => (Number.isFinite(f.sizeBytes) ? f.sizeBytes : 0); + // 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((a, b) => sizeOf(b) - sizeOf(a)); + const total = files.reduce((acc, f) => acc + sizeOf(f), 0); + const filesTotal = files.length; let loadedTotal = 0; + let verifiedTotal = 0; let filesDone = 0; - const report = () => opts.onProgress?.({ loaded: loadedTotal, total, filesDone, filesTotal }); + const report = () => opts.onProgress?.({ loaded: loadedTotal, total, verified: verifiedTotal, filesDone, filesTotal }); throwIfAborted(opts.signal); const downloader = await import('expo-file-system/legacy'); // The first fatal failure stops sibling transfers; the pack then reports that failure, not their aborts. @@ -236,6 +250,13 @@ export async function downloadManifestFiles(opts: { loadedTotal += delta; return true; }; + let verifiedThisFile = 0; + // Unlike transfer bytes, verification restarts from 0 when a stale file is rehashed after redownload. + const setVerified = (verified: number) => { + verifiedTotal += verified - verifiedThisFile; + verifiedThisFile = verified; + }; + const clampToFile = (bytes: number) => (Number.isFinite(f.sizeBytes) ? Math.min(bytes, f.sizeBytes) : bytes); await downloadAndVerifyFile({ downloader, fs: opts.fs, @@ -244,18 +265,23 @@ export async function downloadManifestFiles(opts: { signal: pack.signal, // Native counts error-response bodies too; a failed attempt must not report beyond the asset. onProgress: (p) => { - if (advance(Number.isFinite(f.sizeBytes) ? Math.min(p.loaded, f.sizeBytes) : p.loaded)) report(); + if (advance(clampToFile(p.loaded))) report(); + }, + onVerifyProgress: (bytes) => { + setVerified(clampToFile(bytes)); + report(); }, }); // Verified files count fully, including ones already present from an interrupted install. advance(Number.isFinite(f.sizeBytes) ? f.sizeBytes : loadedThisFile); + setVerified(Number.isFinite(f.sizeBytes) ? f.sizeBytes : verifiedThisFile); filesDone += 1; report(); }; const worker = async () => { - while (!pack.signal.aborted && next < opts.manifest.files.length) { - const f = opts.manifest.files[next++]!; + while (!pack.signal.aborted && next < files.length) { + const f = files[next++]!; try { await installFile(f); } catch (error) { @@ -266,7 +292,7 @@ export async function downloadManifestFiles(opts: { }; try { - const workerCount = Math.min(MAX_PARALLEL_FILE_DOWNLOADS, opts.manifest.files.length); + const workerCount = Math.min(MAX_PARALLEL_FILE_DOWNLOADS, files.length); await Promise.all(Array.from({ length: workerCount }, worker)); } finally { opts.signal.removeEventListener('abort', forwardAbort); diff --git a/apps/ui/sources/voice/modelPacks/installer/types.ts b/apps/ui/sources/voice/modelPacks/installer/types.ts index 633684b3664..5ba91cceaee 100644 --- a/apps/ui/sources/voice/modelPacks/installer/types.ts +++ b/apps/ui/sources/voice/modelPacks/installer/types.ts @@ -25,6 +25,15 @@ export type InstallerOverrides = { export type Progress = { loaded: number; total: number }; -/** Pack-level progress: bytes are clamped to the manifest sizes; verified files count as done. */ -export type ModelPackDownloadProgress = Readonly<{ loaded: number; total: number; filesDone: number; filesTotal: number }>; +/** + * Pack-level progress: bytes are clamped to the manifest sizes; verified files count as done. `verified` counts + * bytes already checked against the manifest hash, which trails `loaded` while downloaded files are hashed. + */ +export type ModelPackDownloadProgress = Readonly<{ + loaded: number; + total: number; + verified: number; + filesDone: number; + filesTotal: number; +}>; diff --git a/apps/ui/sources/voice/settings/panels/localTts/LocalNeuralTtsSettings.native.spec.tsx b/apps/ui/sources/voice/settings/panels/localTts/LocalNeuralTtsSettings.native.spec.tsx index 83f24f2c59b..36099dc1804 100644 --- a/apps/ui/sources/voice/settings/panels/localTts/LocalNeuralTtsSettings.native.spec.tsx +++ b/apps/ui/sources/voice/settings/panels/localTts/LocalNeuralTtsSettings.native.spec.tsx @@ -56,6 +56,28 @@ describe('LocalNeuralTtsSettings through device boundaries', () => { expect([...device.files.keys()].some(path => path.includes('/modelPackUpdates/'))).toBe(false); }); + it('shows a verifying phase once every byte has arrived but files are still being checked', async () => { + seedInstalledPack(packId, 'tts'); + device.fetch.mockImplementation(async () => Response.json(modelPackManifest(packId, 'tts'))); + const { screen, row } = await renderSettings(); + await act(async () => { + await pressTestInstanceAsync(row(t('settingsVoice.local.kokoro.updates.title'))); + await vi.waitFor(() => expect(device.transfers.length).toBe(2)); + }); + await act(async () => { + device.transfers[0]!.report(4, 4); + device.transfers[1]!.report(4, 4); + }); + expect(row(modelTitle()).props.detail).toBe(t('settingsVoice.local.modelPackDownload.verifyingDetail')); + expect(collectText(row(modelTitle()))).toContain(t('settingsVoice.local.modelPackDownload.verifyingProgress', { + verified: '0 B', total: '8 B', filesDone: 0, filesTotal: 2, + })); + expect(screen.findAll(n => n.props.accessibilityRole === 'progressbar')[0]?.props.accessibilityValue) + .toEqual({ min: 0, max: 100, now: 0 }); + await act(async () => { row(modelTitle()).props.rightElement.props.onPress(); }); + await vi.waitFor(() => expect(row(modelTitle()).props.detail).toBe(t('settingsVoice.local.kokoro.modelStatus.ready'))); + }); + it('shows a partial download from an earlier session and discards its actual files after confirmation', async () => { device.files.set(`${voiceFilesRoot}/modelPacks/${packId}/saved.bin`, new Uint8Array(3)); const { row } = await renderSettings(); diff --git a/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx b/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx index e00833ab211..f8267c11359 100644 --- a/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx +++ b/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx @@ -34,32 +34,54 @@ 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(fraction: number, accessibilityLabel: string, line: string): React.ReactNode { + // Item renders the string with its own subtitle typography below the bar. + return ( + <> + + + + {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 = formatByteSize(info.bytes.loaded); const total = formatByteSize(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( + info.verifiedBytes / info.bytes.total, + t('settingsVoice.local.modelPackDownload.verifyingAccessibilityLabel'), + t('settingsVoice.local.modelPackDownload.verifyingProgress', { + verified: formatByteSize(info.verifiedBytes), total, filesDone: info.files.done, filesTotal: info.files.total, + }), + ), + }; + } + const loaded = formatByteSize(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( + info.bytes.loaded / info.bytes.total, + t('settingsVoice.local.modelPackDownload.progressAccessibilityLabel'), + line, ), }; } From 5af69e1b0d8abd3ee08d532f7643d9eb2ca1dea6 Mon Sep 17 00:00:00 2001 From: danljungstrom Date: Mon, 19 Oct 2026 09:28:56 +0200 Subject: [PATCH 2/3] fix(ui-voice): advance model pack progress while checking resumed files A resumed install hashes files left on disk before counting them, so a kept 114 MB model.onnx froze the bar until its whole check finished. Checking a kept file now advances the transfer as it is hashed, and a kept file that fails verification withdraws its bytes before it is downloaded again. Co-Authored-By: Claude Opus 5.5 --- .../voice/modelPacks/installer.spec.ts | 23 +++++++++++++++++++ .../modelPacks/installer/download.native.ts | 21 +++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/ui/sources/voice/modelPacks/installer.spec.ts b/apps/ui/sources/voice/modelPacks/installer.spec.ts index 5f45cae431b..2e335aa7ce0 100644 --- a/apps/ui/sources/voice/modelPacks/installer.spec.ts +++ b/apps/ui/sources/voice/modelPacks/installer.spec.ts @@ -216,6 +216,29 @@ describe('modelPacks installer (native)', () => { expect(progress.at(-1)).toEqual({ verified: bytes.length, filesDone: 1 }); }); + it('advances progress while checking files an interrupted install left on disk', async () => { + const kept = multiChunkBytes(4); + const stale = multiChunkBytes(5); + const fresh = multiChunkBytes(6); + const fs = createFs(new Map([[`${PACK_URI}/kept.bin`, kept], [`${PACK_URI}/stale.bin`, stale]])); + useNativeDownloads(fs, () => fresh); + const progress: Array<{ loaded: number; total: number; filesDone: number }> = []; + await ensureModelPackInstalled({ + ...installOptions, signal: new AbortController().signal, + onProgress: (p) => progress.push({ loaded: p.loaded, total: p.total, filesDone: p.filesDone }), + }, { + fs, fetch: fetchManifest(manifest([ + { path: 'kept.bin', url: 'https://example.com/kept.bin', sha256: await sha256Hex(kept), sizeBytes: kept.length }, + { path: 'stale.bin', url: 'https://example.com/stale.bin', sha256: await sha256Hex(fresh), sizeBytes: fresh.length }, + ])), + }); + // Checking a kept file moves the bar instead of leaving it frozen until the whole file is hashed. + expect(progress.some((p) => p.filesDone === 0 && p.loaded > 0 && p.loaded < kept.length)).toBe(true); + // A rejected file's checked bytes are withdrawn, so its redownload is not counted twice. + expect(progress.every((p) => p.loaded <= p.total)).toBe(true); + expect(progress.at(-1)).toEqual({ loaded: kept.length + fresh.length, total: kept.length + fresh.length, filesDone: 2 }); + }); + it('starts the largest files first so they transfer alongside the small ones', async () => { const small = new Uint8Array([1]); const medium = new Uint8Array(16).fill(2); diff --git a/apps/ui/sources/voice/modelPacks/installer/download.native.ts b/apps/ui/sources/voice/modelPacks/installer/download.native.ts index 939eb0f589b..bd79bb0d417 100644 --- a/apps/ui/sources/voice/modelPacks/installer/download.native.ts +++ b/apps/ui/sources/voice/modelPacks/installer/download.native.ts @@ -174,12 +174,14 @@ async function downloadAndVerifyFile(opts: { signal: AbortSignal; onProgress?: (p: Progress) => void; /** Bytes hashed by the current verification pass; a repeated pass starts again from 0. */ - onVerifyProgress?: (bytes: number) => void; + onVerifyProgress?: (bytes: number, source: 'existing' | 'downloaded') => void; + /** A file left on disk failed verification and is downloaded again. */ + onExistingRejected?: () => void; }): Promise { const file = getPackFile(opts.fs, opts.rootDir, opts.entry.path, { createParents: true }); if (file.exists) { try { - const actual = await sha256FileHex(file, opts.signal, opts.onVerifyProgress); + const actual = await sha256FileHex(file, opts.signal, (bytes) => opts.onVerifyProgress?.(bytes, 'existing')); if (actual === opts.entry.sha256.toLowerCase()) return; file.delete(); } catch (error) { @@ -192,6 +194,7 @@ async function downloadAndVerifyFile(opts: { console.warn('[modelPacks] Failed to remove unverified model pack file', cleanupError); } } + opts.onExistingRejected?.(); } try { @@ -200,7 +203,7 @@ async function downloadAndVerifyFile(opts: { downloader: opts.downloader, url: opts.entry.url, fileUri: file.uri, signal: opts.signal, onProgress: opts.onProgress, }); - const actual = await sha256FileHex(file, opts.signal, opts.onVerifyProgress); + const actual = await sha256FileHex(file, opts.signal, (bytes) => opts.onVerifyProgress?.(bytes, 'downloaded')); if (actual !== opts.entry.sha256.toLowerCase()) { throw new Error('model_pack_sha256_mismatch'); } @@ -267,8 +270,18 @@ export async function downloadManifestFiles(opts: { onProgress: (p) => { if (advance(clampToFile(p.loaded))) report(); }, - onVerifyProgress: (bytes) => { + onVerifyProgress: (bytes, source) => { setVerified(clampToFile(bytes)); + // Bytes an interrupted install left on disk are already present; checking them advances the + // transfer too, so resuming over a large kept file does not freeze the bar while it is hashed. + if (source === 'existing') advance(clampToFile(bytes)); + report(); + }, + onExistingRejected: () => { + // The kept bytes turned out unusable; withdraw them so the redownload is not counted twice. + loadedTotal -= loadedThisFile; + loadedThisFile = 0; + setVerified(0); report(); }, }); From 1b63c5173aec73ed0d9f774e5cf232a4663300cc Mon Sep 17 00:00:00 2001 From: danljungstrom Date: Mon, 19 Oct 2026 09:47:09 +0200 Subject: [PATCH 3/3] fix(ui-voice): start the verification bar fresh instead of gliding backward Switching from the download phase to verification changed what the same animated bar measured, so it glided backward from full. Each phase now gets its own bar that starts at its current value. Co-Authored-By: Claude Opus 5.5 --- .../panels/modelPack/modelPackTransferRows.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx b/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx index f8267c11359..38c7f75c255 100644 --- a/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx +++ b/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx @@ -41,12 +41,19 @@ function ModelPackProgressBar(props: Readonly<{ progress: number; accessibilityL progressAccessibilityLabel={props.accessibilityLabel} />; } -function progressSubtitle(fraction: number, accessibilityLabel: string, line: string): React.ReactNode { +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} @@ -64,6 +71,7 @@ function describeDownloading(progress: unknown): ModelPackRowPresentation { 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', { @@ -79,6 +87,7 @@ function describeDownloading(progress: unknown): ModelPackRowPresentation { return { detail, subtitle: progressSubtitle( + 'download', info.bytes.loaded / info.bytes.total, t('settingsVoice.local.modelPackDownload.progressAccessibilityLabel'), line,