diff --git a/apps/mobile/src/features/settings/VoiceModelSection.tsx b/apps/mobile/src/features/settings/VoiceModelSection.tsx index 0bee9748b19cb..0b2a7da5e5422 100644 --- a/apps/mobile/src/features/settings/VoiceModelSection.tsx +++ b/apps/mobile/src/features/settings/VoiceModelSection.tsx @@ -148,7 +148,7 @@ function ModelStats(props: { readonly model: EnvironmentSpeechModel; readonly la "speed", <> - {data.secondsPer30s.accelerated} s + {Number(data.secondsPer30s.accelerated.toFixed(2))} s {" "} / 30 s , diff --git a/apps/server/scripts/speech-catalog.test.ts b/apps/server/scripts/speech-catalog.test.ts index 2a68c80f5fed0..1ec8fe99b705d 100644 --- a/apps/server/scripts/speech-catalog.test.ts +++ b/apps/server/scripts/speech-catalog.test.ts @@ -2,6 +2,8 @@ import * as NodeFS from "node:fs"; import { catalog } from "@handy-computer/models"; +import { recommendSpeechModels } from "@t3tools/shared/speech"; +import policy from "../../../packages/shared/src/speechRecommendationPolicy.json" with { type: "json" }; import { expect, it } from "vite-plus/test"; import manifest from "../package.json" with { type: "json" }; @@ -15,6 +17,8 @@ const generate = (models = catalog.models) => ); it("reproduces the bundled registry byte for byte without upstream requests", async () => { + const { fastCpuMaxWaitSeconds, ...methodology } = catalog.methodology; + expect(policy).toEqual({ ...methodology, fastMaxWaitSeconds: fastCpuMaxWaitSeconds }); expect(NodeFS.readFileSync(new URL("../src/speech/catalog.json", import.meta.url), "utf8")).toBe( await renderSpeechCatalog( catalog.data, @@ -53,18 +57,40 @@ it("reproduces the bundled registry byte for byte without upstream requests", as { ...catalog.data, models: [parakeet], - results: { [parakeet.id]: { accuracy: {}, performance } }, + results: { [parakeet.id]: { accuracy: { en: { error: 3.99 } }, performance } }, }, "0.2.3", "0.4.1", ); expect(result.models[0]?.benchmark.secondsPer30s).toEqual({ - cpu: performance.cpu ? 15 : null, - accelerated: performance.accelerated ? 3 : null, + cpu: performance.cpu ? 30 / performance.cpu : null, + accelerated: performance.accelerated ? 30 / performance.accelerated : null, }); } }); +it("preserves Handy's accelerated balanced pick after filtering T3's offered candidates", () => { + const offered = generate().models; + const sources = catalog.models.filter((model) => + offered.some(({ id }) => id === model.repository), + ); + for (const languages of [["en"], ["de"], ["ja"], ["en", "de"], ["en", "fr"], ["hi", "mr"]]) { + const expected = catalog + .recommendModels(sources, languages) + .find((pick) => pick.status !== "unbenchmarked" && pick.roles.includes("best")); + expect( + recommendSpeechModels(offered, languages, "accelerated").keys().next().value, + languages.join(","), + ).toBe(expected?.model.repository); + } + const excluded = { ...catalog.models[0]!, license: "cc-by-nc-4.0" }; + const allowed = { ...catalog.models[1]!, languages: ["en"] }; + const filtered = generate([excluded, allowed]).models; + expect([...recommendSpeechModels(filtered, ["en"], "accelerated").keys()]).toEqual([ + allowed.repository, + ]); +}); + it("filters restricted licenses, unsupported roles, engines, architectures and files", () => { const model = catalog.models[0]!; for (const override of [ diff --git a/apps/server/scripts/speech-catalog.ts b/apps/server/scripts/speech-catalog.ts index 5e8a6f8d12fe8..3af49ceee034d 100644 --- a/apps/server/scripts/speech-catalog.ts +++ b/apps/server/scripts/speech-catalog.ts @@ -31,7 +31,7 @@ const ASR_FAMILIES = new Set([ "medasr", ]); -// Preserve Handy's onboarding badges; curation alone also includes specialist models. +// Older clients rank and pick setup models by this flag; current clients use language recommendations. const RECOMMENDED = new Set([ "parakeet-unified-en-0.6b", "nemotron-3.5-asr-streaming-0.6b", @@ -81,9 +81,8 @@ export function generateSpeechCatalog( ids.add(model.repository); filenames.add(file.filename); const languages = [...new Set(model.languages.map(canonicalLanguage))]; - const performance = data.results[model.id]?.performance; - const secondsPer30s = (realtimeFactor: number | undefined) => - realtimeFactor ? Number((BENCHMARK_DICTATION_SECONDS / realtimeFactor).toFixed(2)) : null; + const realtimeFactor = data.results[model.id]?.performance.accelerated; + const cpuFactor = data.results[model.id]?.performance.cpu; const accuracy = Object.fromEntries( languages.flatMap((language) => { const cell = data.results[model.id]?.accuracy[languageIdentity(language)]; @@ -112,8 +111,8 @@ export function generateSpeechCatalog( benchmark: { accuracy, secondsPer30s: { - cpu: secondsPer30s(performance?.cpu), - accelerated: secondsPer30s(performance?.accelerated), + cpu: cpuFactor ? BENCHMARK_DICTATION_SECONDS / cpuFactor : null, + accelerated: realtimeFactor ? BENCHMARK_DICTATION_SECONDS / realtimeFactor : null, }, }, recommended: RECOMMENDED.has(model.id), diff --git a/apps/server/scripts/sync-speech-catalog.ts b/apps/server/scripts/sync-speech-catalog.ts index 1a809502dc450..d9309ae989041 100644 --- a/apps/server/scripts/sync-speech-catalog.ts +++ b/apps/server/scripts/sync-speech-catalog.ts @@ -5,6 +5,7 @@ import * as NodeModule from "node:module"; import * as NodePath from "node:path"; import { catalog } from "@handy-computer/models"; +import { format } from "vite-plus/fmt"; import * as Schema from "effect/Schema"; import manifest from "../package.json" with { type: "json" }; @@ -24,12 +25,26 @@ const expected = await renderSpeechCatalog( sourceManifest.version, manifest.dependencies["transcribe-cpp"], ); +const policyTarget = new URL( + "../../../packages/shared/src/speechRecommendationPolicy.json", + import.meta.url, +); +const { fastCpuMaxWaitSeconds, ...methodology } = catalog.methodology; +const policy = await format( + "speechRecommendationPolicy.json", + JSON.stringify({ ...methodology, fastMaxWaitSeconds: fastCpuMaxWaitSeconds }), +); +if (policy.errors.length > 0) throw new Error("Could not format speech recommendation policy."); if (process.argv.includes("--check")) { - if (NodeFS.readFileSync(target, "utf8") !== expected) { + if ( + NodeFS.readFileSync(target, "utf8") !== expected || + NodeFS.readFileSync(policyTarget, "utf8") !== policy.code + ) { throw new Error("Speech catalog differs; run vp run speech:catalog:sync in apps/server."); } console.log("Bundled speech catalog matches the pinned source."); } else { NodeFS.writeFileSync(target, expected); + NodeFS.writeFileSync(policyTarget, policy.code); console.log(`Generated speech catalog from @handy-computer/models@${sourceManifest.version}.`); } diff --git a/apps/server/src/speech/catalog.json b/apps/server/src/speech/catalog.json index 2a2175ca1863a..782211e0ec16a 100644 --- a/apps/server/src/speech/catalog.json +++ b/apps/server/src/speech/catalog.json @@ -15,7 +15,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "A-", "errorPercent": 3.99 } }, - "secondsPer30s": { "cpu": 2.15, "accelerated": 1.19 } + "secondsPer30s": { "cpu": 2.149767108563239, "accelerated": 1.1867088607594936 } }, "recommended": true, "supportsStreaming": true, @@ -92,7 +92,7 @@ "ro": { "grade": "D", "errorPercent": 28.28 }, "et": { "grade": "F", "errorPercent": 31.84 } }, - "secondsPer30s": { "cpu": 2.83, "accelerated": 1.75 } + "secondsPer30s": { "cpu": 2.8328611898016995, "accelerated": 1.7508024511234315 } }, "recommended": true, "supportsStreaming": true, @@ -116,7 +116,7 @@ "es": { "grade": "B", "errorPercent": 6.54 }, "fr": { "grade": "B-", "errorPercent": 8.53 } }, - "secondsPer30s": { "cpu": 1.38, "accelerated": 0.91 } + "secondsPer30s": { "cpu": 1.3805798435342844, "accelerated": 0.9067553271875473 } }, "recommended": true, "supportsStreaming": false, @@ -165,7 +165,7 @@ "vi": { "grade": "B", "errorPercent": 7.39 }, "ko": { "grade": "B", "errorPercent": 6.57 } }, - "secondsPer30s": { "cpu": 6.96, "accelerated": 3.71 } + "secondsPer30s": { "cpu": 6.960556844547565, "accelerated": 3.7059913526868433 } }, "recommended": true, "supportsStreaming": false, @@ -367,7 +367,7 @@ "yo": { "grade": "F", "errorPercent": 109 }, "zh": { "grade": "C", "errorPercent": 13.13 } }, - "secondsPer30s": { "cpu": 15.58, "accelerated": 7.34 } + "secondsPer30s": { "cpu": 15.584415584415584, "accelerated": 7.343941248470013 } }, "recommended": true, "supportsStreaming": false, @@ -400,7 +400,7 @@ "hi": { "grade": "C", "errorPercent": 14.44 }, "ko": { "grade": "B+", "errorPercent": 5.27 } }, - "secondsPer30s": { "cpu": 51.28, "accelerated": 39.47 } + "secondsPer30s": { "cpu": 51.282051282051285, "accelerated": 39.473684210526315 } }, "recommended": false, "supportsStreaming": true, @@ -471,7 +471,7 @@ "el": { "grade": "F", "errorPercent": 32.25 }, "mt": { "grade": "C-", "errorPercent": 17.01 } }, - "secondsPer30s": { "cpu": 2.26, "accelerated": 1.3 } + "secondsPer30s": { "cpu": 2.2573363431151243, "accelerated": 1.3043478260869565 } }, "recommended": false, "supportsStreaming": false, @@ -542,7 +542,7 @@ "sv": { "grade": "C", "errorPercent": 15.25 }, "uk": { "grade": "B", "errorPercent": 6.84 } }, - "secondsPer30s": { "cpu": 2.2, "accelerated": 1.2 } + "secondsPer30s": { "cpu": 2.1969974368363236, "accelerated": 1.196888090963495 } }, "recommended": false, "supportsStreaming": false, @@ -561,7 +561,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "A-", "errorPercent": 4.11 } }, - "secondsPer30s": { "cpu": 2.1, "accelerated": 1.09 } + "secondsPer30s": { "cpu": 2.102312543798178, "accelerated": 1.0934937124111537 } }, "recommended": false, "supportsStreaming": false, @@ -642,7 +642,7 @@ "hu": { "grade": "F", "errorPercent": 56.24 }, "mk": { "grade": "F", "errorPercent": 35.09 } }, - "secondsPer30s": { "cpu": 5.67, "accelerated": 3.09 } + "secondsPer30s": { "cpu": 5.665722379603399, "accelerated": 3.091190108191654 } }, "recommended": false, "supportsStreaming": false, @@ -725,7 +725,7 @@ "sl": { "grade": "F", "errorPercent": 75.22 }, "sv": { "grade": "F", "errorPercent": 75.36 } }, - "secondsPer30s": { "cpu": 5.48, "accelerated": 3.4 } + "secondsPer30s": { "cpu": 5.484460694698355, "accelerated": 3.3955857385398978 } }, "recommended": false, "supportsStreaming": false, @@ -796,7 +796,7 @@ "ru": { "grade": "B", "errorPercent": 7.83 }, "uk": { "grade": "C+", "errorPercent": 10.58 } }, - "secondsPer30s": { "cpu": 3.6, "accelerated": 2.12 } + "secondsPer30s": { "cpu": 3.603603603603604, "accelerated": 2.1231422505307855 } }, "recommended": false, "supportsStreaming": false, @@ -815,7 +815,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "A", "errorPercent": 3.58 } }, - "secondsPer30s": { "cpu": 11.95, "accelerated": 7.55 } + "secondsPer30s": { "cpu": 11.952191235059763, "accelerated": 7.547169811320755 } }, "recommended": false, "supportsStreaming": false, @@ -837,7 +837,7 @@ "en": { "grade": "A-", "errorPercent": 4.88 }, "ar": { "grade": "C+", "errorPercent": 11.06 } }, - "secondsPer30s": { "cpu": 6.96, "accelerated": 3.61 } + "secondsPer30s": { "cpu": 6.960556844547565, "accelerated": 3.6122817579771223 } }, "recommended": false, "supportsStreaming": false, @@ -860,7 +860,7 @@ "en": { "grade": "B+", "errorPercent": 5.49 }, "ja": { "grade": "B-", "errorPercent": 8.5 } }, - "secondsPer30s": { "cpu": 3.78, "accelerated": 2.8 } + "secondsPer30s": { "cpu": 3.7759597230962867, "accelerated": 2.7958993476234855 } }, "recommended": false, "supportsStreaming": false, @@ -879,7 +879,7 @@ "languages": ["ru"], "benchmark": { "accuracy": { "ru": { "grade": "B+", "errorPercent": 5.53 } }, - "secondsPer30s": { "cpu": 2.62, "accelerated": 0.85 } + "secondsPer30s": { "cpu": 2.618943692710607, "accelerated": 0.8480565371024735 } }, "recommended": false, "supportsStreaming": false, @@ -898,7 +898,7 @@ "languages": ["ru"], "benchmark": { "accuracy": { "ru": { "grade": "B+", "errorPercent": 5.35 } }, - "secondsPer30s": { "cpu": 3.81, "accelerated": 2 } + "secondsPer30s": { "cpu": 3.8095238095238093, "accelerated": 1.9973368841544608 } }, "recommended": false, "supportsStreaming": false, @@ -917,7 +917,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "A-", "errorPercent": 4.61 } }, - "secondsPer30s": { "cpu": 1.74, "accelerated": 1.31 } + "secondsPer30s": { "cpu": 1.7401392111368912, "accelerated": 1.312048983162038 } }, "recommended": false, "supportsStreaming": false, @@ -943,7 +943,7 @@ "pt": { "grade": "B-", "errorPercent": 9.8 }, "ja": { "grade": "B", "errorPercent": 6.3 } }, - "secondsPer30s": { "cpu": 10.81, "accelerated": 6.5 } + "secondsPer30s": { "cpu": 10.81081081081081, "accelerated": 6.500541711809317 } }, "recommended": false, "supportsStreaming": false, @@ -962,7 +962,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "F", "errorPercent": 37.48 } }, - "secondsPer30s": { "cpu": 1.16, "accelerated": 0.42 } + "secondsPer30s": { "cpu": 1.1598685482312006, "accelerated": 0.4209935447656469 } }, "recommended": false, "supportsStreaming": false, @@ -981,7 +981,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "B+", "errorPercent": 6.11 } }, - "secondsPer30s": { "cpu": 0.6, "accelerated": 0.39 } + "secondsPer30s": { "cpu": 0.5996402158704777, "accelerated": 0.39238767902687854 } }, "recommended": false, "supportsStreaming": false, @@ -1000,7 +1000,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "B", "errorPercent": 6.52 } }, - "secondsPer30s": { "cpu": 2.24, "accelerated": 1.14 } + "secondsPer30s": { "cpu": 2.236302646291465, "accelerated": 1.1387360030366294 } }, "recommended": false, "supportsStreaming": true, @@ -1081,7 +1081,7 @@ "hu": { "grade": "F", "errorPercent": 32.84 }, "mk": { "grade": "C-", "errorPercent": 18.22 } }, - "secondsPer30s": { "cpu": 12.15, "accelerated": 7.34 } + "secondsPer30s": { "cpu": 12.14574898785425, "accelerated": 7.343941248470013 } }, "recommended": false, "supportsStreaming": false, @@ -1106,7 +1106,7 @@ "ja": { "grade": "B", "errorPercent": 7.63 }, "ko": { "grade": "B-", "errorPercent": 8.27 } }, - "secondsPer30s": { "cpu": 1.52, "accelerated": 1.08 } + "secondsPer30s": { "cpu": 1.5201418799087916, "accelerated": 1.0834236186348862 } }, "recommended": false, "supportsStreaming": false, @@ -1134,7 +1134,7 @@ "nl": { "grade": "B", "errorPercent": 6.57 }, "hi": { "grade": "B-", "errorPercent": 8.93 } }, - "secondsPer30s": { "cpu": 53.57, "accelerated": 27.65 } + "secondsPer30s": { "cpu": 53.57142857142857, "accelerated": 27.649769585253456 } }, "recommended": false, "supportsStreaming": false, @@ -1336,7 +1336,7 @@ "yo": { "grade": "F", "errorPercent": 118.5 }, "zh": { "grade": "D", "errorPercent": 23.06 } }, - "secondsPer30s": { "cpu": 5.12, "accelerated": 2.57 } + "secondsPer30s": { "cpu": 5.1194539249146755, "accelerated": 2.5651988029072252 } }, "recommended": false, "supportsStreaming": false, @@ -1540,7 +1540,7 @@ "yue": { "grade": "F", "errorPercent": 34.62 }, "zh": { "grade": "B-", "errorPercent": 8.5 } }, - "secondsPer30s": { "cpu": 20.98, "accelerated": 9.69 } + "secondsPer30s": { "cpu": 20.97902097902098, "accelerated": 9.693053311793214 } }, "recommended": false, "supportsStreaming": false, @@ -1562,7 +1562,7 @@ "zh": { "grade": "B-", "errorPercent": 8.1 }, "en": { "grade": "A-", "errorPercent": 4.14 } }, - "secondsPer30s": { "cpu": 31.25, "accelerated": 14.85 } + "secondsPer30s": { "cpu": 31.25, "accelerated": 14.851485148514852 } }, "recommended": false, "supportsStreaming": false, @@ -1766,7 +1766,7 @@ "yue": { "grade": "D", "errorPercent": 22.06 }, "zh": { "grade": "B-", "errorPercent": 7.98 } }, - "secondsPer30s": { "cpu": 30.93, "accelerated": 15.04 } + "secondsPer30s": { "cpu": 30.927835051546392, "accelerated": 15.037593984962406 } }, "recommended": false, "supportsStreaming": false, @@ -1790,7 +1790,7 @@ "es": { "grade": "B", "errorPercent": 6.73 }, "fr": { "grade": "B", "errorPercent": 7.22 } }, - "secondsPer30s": { "cpu": 3.39, "accelerated": 1.94 } + "secondsPer30s": { "cpu": 3.3898305084745766, "accelerated": 1.937984496124031 } }, "recommended": false, "supportsStreaming": false, @@ -1809,7 +1809,7 @@ "languages": ["ru"], "benchmark": { "accuracy": { "ru": { "grade": "B-", "errorPercent": 8.42 } }, - "secondsPer30s": { "cpu": 2.54, "accelerated": 0.75 } + "secondsPer30s": { "cpu": 2.5369978858350954, "accelerated": 0.7543374402816192 } }, "recommended": false, "supportsStreaming": false, @@ -1828,7 +1828,7 @@ "languages": ["ru"], "benchmark": { "accuracy": { "ru": { "grade": "B-", "errorPercent": 8.07 } }, - "secondsPer30s": { "cpu": 2.9, "accelerated": 1.12 } + "secondsPer30s": { "cpu": 2.9013539651837523, "accelerated": 1.1219147344801796 } }, "recommended": false, "supportsStreaming": false, @@ -1854,7 +1854,7 @@ "pt": { "grade": "B-", "errorPercent": 9.6 }, "ja": { "grade": "B", "errorPercent": 6.44 } }, - "secondsPer30s": { "cpu": 10.89, "accelerated": 6.47 } + "secondsPer30s": { "cpu": 10.88929219600726, "accelerated": 6.472491909385114 } }, "recommended": false, "supportsStreaming": false, @@ -1879,7 +1879,7 @@ "es": { "grade": "A-", "errorPercent": 4.08 }, "pt": { "grade": "B+", "errorPercent": 5.57 } }, - "secondsPer30s": { "cpu": 13.99, "accelerated": 7.51 } + "secondsPer30s": { "cpu": 13.986013986013987, "accelerated": 7.509386733416771 } }, "recommended": false, "supportsStreaming": false, @@ -1904,7 +1904,7 @@ "es": { "grade": "B", "errorPercent": 6.53 }, "pt": { "grade": "C+", "errorPercent": 10.61 } }, - "secondsPer30s": { "cpu": 11.54, "accelerated": 6.46 } + "secondsPer30s": { "cpu": 11.538461538461538, "accelerated": 6.458557588805167 } }, "recommended": false, "supportsStreaming": false, @@ -1923,7 +1923,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "C+", "errorPercent": 12.25 } }, - "secondsPer30s": { "cpu": 1.28, "accelerated": 0.84 } + "secondsPer30s": { "cpu": 1.277139208173691, "accelerated": 0.8429334082607474 } }, "recommended": false, "supportsStreaming": false, @@ -1942,7 +1942,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "B", "errorPercent": 7.87 } }, - "secondsPer30s": { "cpu": 3.1, "accelerated": 2.1 } + "secondsPer30s": { "cpu": 3.103983445421625, "accelerated": 2.100840336134454 } }, "recommended": false, "supportsStreaming": true, @@ -1961,7 +1961,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "B-", "errorPercent": 8.55 } }, - "secondsPer30s": { "cpu": 2.22, "accelerated": 1.38 } + "secondsPer30s": { "cpu": 2.2164758034724787, "accelerated": 1.3780431786862655 } }, "recommended": false, "supportsStreaming": true, @@ -1980,7 +1980,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "C-", "errorPercent": 18.18 } }, - "secondsPer30s": { "cpu": 0.53, "accelerated": 0.51 } + "secondsPer30s": { "cpu": 0.5331437711036076, "accelerated": 0.5071851225697379 } }, "recommended": false, "supportsStreaming": true, @@ -1999,7 +1999,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "C", "errorPercent": 14.13 } }, - "secondsPer30s": { "cpu": 0.73, "accelerated": 0.5 } + "secondsPer30s": { "cpu": 0.7260406582768635, "accelerated": 0.4964421644878372 } }, "recommended": false, "supportsStreaming": false, @@ -2021,7 +2021,7 @@ "en": { "grade": "B+", "errorPercent": 5.13 }, "zh": { "grade": "B-", "errorPercent": 9.23 } }, - "secondsPer30s": { "cpu": 19.11, "accelerated": 9.79 } + "secondsPer30s": { "cpu": 19.108280254777068, "accelerated": 9.787928221859707 } }, "recommended": false, "supportsStreaming": false, @@ -2040,7 +2040,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "B", "errorPercent": 6.43 } }, - "secondsPer30s": { "cpu": 2.21, "accelerated": 1.11 } + "secondsPer30s": { "cpu": 2.210759027266028, "accelerated": 1.1051759071652238 } }, "recommended": false, "supportsStreaming": true, @@ -2111,7 +2111,7 @@ "sv": { "grade": "C", "errorPercent": 13.26 }, "uk": { "grade": "B", "errorPercent": 6.53 } }, - "secondsPer30s": { "cpu": 2.2, "accelerated": 1.2 } + "secondsPer30s": { "cpu": 2.2026431718061676, "accelerated": 1.1954572624028692 } }, "recommended": false, "supportsStreaming": false, @@ -2130,7 +2130,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "B+", "errorPercent": 5.53 } }, - "secondsPer30s": { "cpu": 1.98, "accelerated": 1.05 } + "secondsPer30s": { "cpu": 1.9788918205804749, "accelerated": 1.048951048951049 } }, "recommended": false, "supportsStreaming": false, @@ -2149,7 +2149,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "B+", "errorPercent": 5.61 } }, - "secondsPer30s": { "cpu": 3.36, "accelerated": 1.79 } + "secondsPer30s": { "cpu": 3.3613445378151257, "accelerated": 1.7873100983020553 } }, "recommended": false, "supportsStreaming": false, @@ -2220,7 +2220,7 @@ "sv": { "grade": "C-", "errorPercent": 16.42 }, "uk": { "grade": "B-", "errorPercent": 8.11 } }, - "secondsPer30s": { "cpu": 2.2, "accelerated": 1.2 } + "secondsPer30s": { "cpu": 2.2002200220022003, "accelerated": 1.2014417300760913 } }, "recommended": false, "supportsStreaming": false, @@ -2239,7 +2239,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "A-", "errorPercent": 4.57 } }, - "secondsPer30s": { "cpu": 2.02, "accelerated": 1.11 } + "secondsPer30s": { "cpu": 2.0208824520040416, "accelerated": 1.107011070110701 } }, "recommended": false, "supportsStreaming": false, @@ -2258,7 +2258,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "A-", "errorPercent": 4.45 } }, - "secondsPer30s": { "cpu": 3.49, "accelerated": 1.87 } + "secondsPer30s": { "cpu": 3.492433061699651, "accelerated": 1.8650917003419334 } }, "recommended": false, "supportsStreaming": false, @@ -2277,7 +2277,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "A-", "errorPercent": 4.68 } }, - "secondsPer30s": { "cpu": 3.22, "accelerated": 1.76 } + "secondsPer30s": { "cpu": 3.2206119162640903, "accelerated": 1.7605633802816902 } }, "recommended": false, "supportsStreaming": false, @@ -2296,7 +2296,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "A-", "errorPercent": 4.24 } }, - "secondsPer30s": { "cpu": 3.41, "accelerated": 1.82 } + "secondsPer30s": { "cpu": 3.4071550255536627, "accelerated": 1.8242626938279112 } }, "recommended": false, "supportsStreaming": false, @@ -2526,7 +2526,7 @@ "yo": { "grade": "F", "errorPercent": 103.28 }, "zh": { "grade": "F", "errorPercent": 36.21 } }, - "secondsPer30s": { "cpu": 1.55, "accelerated": 1.03 } + "secondsPer30s": { "cpu": 1.5519917227108124, "accelerated": 1.033947957952783 } }, "recommended": false, "supportsStreaming": false, @@ -2545,7 +2545,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "B", "errorPercent": 7.6 } }, - "secondsPer30s": { "cpu": 1.46, "accelerated": 0.94 } + "secondsPer30s": { "cpu": 1.4563106796116503, "accelerated": 0.9370607527721381 } }, "recommended": false, "supportsStreaming": false, @@ -2747,7 +2747,7 @@ "yo": { "grade": "F", "errorPercent": 113.72 }, "zh": { "grade": "C-", "errorPercent": 19.32 } }, - "secondsPer30s": { "cpu": 31.25, "accelerated": 14.96 } + "secondsPer30s": { "cpu": 31.25, "accelerated": 14.962593516209477 } }, "recommended": false, "supportsStreaming": false, @@ -2949,7 +2949,7 @@ "yo": { "grade": "F", "errorPercent": 96 }, "zh": { "grade": "C", "errorPercent": 15.39 } }, - "secondsPer30s": { "cpu": 31.25, "accelerated": 14.93 } + "secondsPer30s": { "cpu": 31.25, "accelerated": 14.92537313432836 } }, "recommended": false, "supportsStreaming": false, @@ -2968,7 +2968,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "A-", "errorPercent": 4.88 } }, - "secondsPer30s": { "cpu": 14.53, "accelerated": 6.81 } + "secondsPer30s": { "cpu": 14.527845036319613, "accelerated": 6.81044267877412 } }, "recommended": false, "supportsStreaming": false, @@ -2987,7 +2987,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "B+", "errorPercent": 6.14 } }, - "secondsPer30s": { "cpu": 4.79, "accelerated": 2.4 } + "secondsPer30s": { "cpu": 4.7923322683706076, "accelerated": 2.404809619238477 } }, "recommended": false, "supportsStreaming": false, @@ -3189,7 +3189,7 @@ "yo": { "grade": "F", "errorPercent": 102.29 }, "zh": { "grade": "F", "errorPercent": 41.91 } }, - "secondsPer30s": { "cpu": 0.75, "accelerated": 0.7 } + "secondsPer30s": { "cpu": 0.7546220601182241, "accelerated": 0.696217219772569 } }, "recommended": false, "supportsStreaming": false, @@ -3208,7 +3208,7 @@ "languages": ["en"], "benchmark": { "accuracy": { "en": { "grade": "C+", "errorPercent": 10.72 } }, - "secondsPer30s": { "cpu": 0.73, "accelerated": 0.63 } + "secondsPer30s": { "cpu": 0.727802037845706, "accelerated": 0.6318449873631002 } }, "recommended": false, "supportsStreaming": false, diff --git a/apps/web/src/components/settings/SpeechModelComparison.tsx b/apps/web/src/components/settings/SpeechModelComparison.tsx index 4842c688b36dd..724de8d68d343 100644 --- a/apps/web/src/components/settings/SpeechModelComparison.tsx +++ b/apps/web/src/components/settings/SpeechModelComparison.tsx @@ -51,12 +51,12 @@ export function ModelBenchmarks({ }: { model: EnvironmentSpeechModel; languages: readonly string[]; - tier: SpeechBenchmarkTier; + tier: SpeechBenchmarkTier | undefined; /** Plain facts, such as language count and size, shown after the scores. */ facts: readonly string[]; }) { const data = model.benchmark; - const seconds = data?.secondsPer30s[tier]; + const seconds = tier === undefined ? undefined : data?.secondsPer30s[tier]; const visible = !data ? [] : languages.length @@ -82,18 +82,23 @@ export function ModelBenchmarks({ ); })} - - Speed:{" "} - {tier === "cpu" && data && data.secondsPer30s.cpu === undefined ? ( - "update server" - ) : seconds == null ? ( - "not benchmarked" - ) : ( - <> - {seconds} s / 30 s - - )} - + {data ? ( + + Speed:{" "} + {seconds === undefined ? ( + "update server for this benchmark" + ) : seconds === null ? ( + "not benchmarked" + ) : ( + <> + + {Number(seconds.toFixed(2))} s + {" "} + / 30 s + + )} + + ) : null} {facts.map((fact) => ( {fact} ))} diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx index 98558bc26b4be..dde4538e3f4cd 100644 --- a/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx @@ -147,7 +147,7 @@ it.each([{ cpu: null, accelerated: 2.1 }, { cpu: 5, accelerated: null }, { accel secondsPer30s, }, }; - const cpuBenchmark = `Speed: ${secondsPer30s.cpu === undefined ? "update server" : secondsPer30s.cpu === null ? "not benchmarked" : `${secondsPer30s.cpu} s / 30 s`}`; + const cpuBenchmark = `Speed: ${secondsPer30s.cpu === undefined ? "update server for this benchmark" : secondsPer30s.cpu === null ? "not benchmarked" : `${secondsPer30s.cpu} s / 30 s`}`; const gpuBenchmark = `Speed: ${secondsPer30s.accelerated === null ? "not benchmarked" : `${secondsPer30s.accelerated} s / 30 s`}`; const fetch = globalThis.fetch; using _network = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { @@ -231,9 +231,9 @@ it("tests the selected model after download and preserves its transcript across supportsStreaming: false, supportsTranslation: false, acceleration: "cpu", + benchmarkTier: "cpu", modelUnloadTimeout: "never", gpuDevices: [], - benchmarkTier: "cpu", } satisfies EnvironmentSpeechStatus; let installed = false; const fetch = globalThis.fetch; @@ -265,7 +265,7 @@ it("tests the selected model after download and preserves its transcript across en: { grade: "A-", errorPercent: 4 }, fr: { grade: "B+", errorPercent: 5.5 }, }, - secondsPer30s: { cpu: 5, accelerated: 2.1 }, + secondsPer30s: { accelerated: 2.1, cpu: 5 }, }, } : {}), @@ -361,81 +361,128 @@ it("tests the selected model after download and preserves its transcript across ); }); -it("loads the models of a relay-connected destination", async () => { - const target = new RelayConnectionTarget({ - environmentId: EnvironmentId.make("voice-relay"), - label: "MacBook Pro", - }); - const prepared = { - environmentId: target.environmentId, - label: target.label, - httpBaseUrl: window.location.origin, - socketUrl: "ws://localhost", - httpAuthorization: { - _tag: "Dpop" as const, - accessToken: "stale-access-token", - expiresAtEpochMs: 4_102_444_800_000, - }, - target, - }; - const model: EnvironmentSpeechModel = { - id: "tiny", - name: "Tiny", - description: "", - size: 100, - languages: ["en"], - accuracy: 1, - speed: 1, - recommended: false, - supportsStreaming: false, - supportsTranslation: false, - supportsLanguageDetection: false, - active: false, - state: "downloadable", - }; - const status = { - supported: true, - state: "missing-model", - modelId: model.id, - model: model.name, - size: model.size, - supportsStreaming: false, - supportsTranslation: false, - acceleration: "cpu", - modelUnloadTimeout: "never", - gpuDevices: [], - benchmarkTier: "cpu", - } satisfies EnvironmentSpeechStatus; - const authorizations: Array = []; - const fetch = globalThis.fetch; - using _network = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { - const url = new URL(input instanceof Request ? input.url : input, window.location.href); - if (url.pathname === "/api/voice/status") { - authorizations.push(new Headers(init?.headers).get("authorization")); - return Promise.resolve(Response.json(status)); +it.each([ + { tier: "cpu", legacy: false }, + { tier: "accelerated", legacy: false }, + { tier: "cpu", legacy: true }, +] as const)( + "loads relay models using the server tier $tier (legacy: $legacy)", + async ({ tier, legacy }) => { + const target = new RelayConnectionTarget({ + environmentId: EnvironmentId.make("voice-relay"), + label: "MacBook Pro", + }); + const prepared = { + environmentId: target.environmentId, + label: target.label, + httpBaseUrl: window.location.origin, + socketUrl: "ws://localhost", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "stale-access-token", + expiresAtEpochMs: 4_102_444_800_000, + }, + target, + }; + const model = { + id: "gpu", + name: "GPU pick", + description: "", + size: 100, + languages: ["en"], + benchmark: { + accuracy: { en: { grade: "A", errorPercent: 4 } }, + secondsPer30s: { cpu: 20, accelerated: 1 }, + }, + accuracy: 1, + speed: 1, + recommended: false, + supportsStreaming: false, + supportsTranslation: false, + supportsLanguageDetection: false, + active: false, + state: "downloadable", + } satisfies EnvironmentSpeechModel; + const status = { + supported: true, + state: "missing-model", + modelId: model.id, + model: model.name, + size: model.size, + supportsStreaming: false, + supportsTranslation: false, + acceleration: "auto", + benchmarkTier: tier, + modelUnloadTimeout: "never", + gpuDevices: [{ id: "remote-gpu", name: "Remote GPU" }], + } satisfies EnvironmentSpeechStatus; + const cpuModel = { + ...model, + id: "cpu", + name: "CPU pick", + benchmark: { + accuracy: { en: { grade: "B+", errorPercent: 6 } }, + secondsPer30s: { cpu: 1, accelerated: 8 }, + }, + }; + const { benchmarkTier: _tier, ...legacyStatus } = status; + const models = [model, cpuModel]; + const authorizations: Array = []; + const fetch = globalThis.fetch; + using _network = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + const url = new URL(input instanceof Request ? input.url : input, window.location.href); + if (url.pathname === "/api/voice/status") { + authorizations.push(new Headers(init?.headers).get("authorization")); + return Promise.resolve( + Response.json(legacy ? { ...legacyStatus, acceleration: "cpu", gpuDevices: [] } : status), + ); + } + if (url.pathname === "/api/voice/models") + return Promise.resolve( + Response.json({ + models: legacy + ? models.map(({ benchmark, ...entry }) => ({ + ...entry, + benchmark: { + accuracy: benchmark.accuracy, + secondsPer30s: benchmark.secondsPer30s.accelerated, + }, + })) + : models, + }), + ); + return fetch(input, init); + }); + using notify = vi.spyOn(toastManager, "add").mockReturnValue("test"); + // Restored even when an assertion fails, unlike `withImplementation`. + using _destination = vi.mocked(useVoiceTranscriptionDestination).mockImplementation(() => ({ + environmentId: target.environmentId, + label: target.label, + grantId: "test", + prepared, + })); + await using screen = await render( + {}}> + + , + ); + await vi.waitFor(() => expect(authorizations.length + notify.mock.calls.length).not.toBe(0)); + expect(notify).not.toHaveBeenCalled(); + expect(authorizations).toEqual(["DPoP relay-access-token"]); + const region = screen.getByRole("region", { name: "Transcription models" }); + await vi.waitFor(() => expect(region.element().textContent).toContain("CPU pick")); + if (legacy) { + expect(region.element().textContent).toContain("update server for this benchmark"); + expect(region.element().textContent).not.toContain("Recommended"); + } else { + const winner = tier === "cpu" ? "CPU pick" : "GPU pick"; + await vi.waitFor(() => + expect(region.element().firstElementChild?.textContent).toContain(winner + "Recommended"), + ); + expect(region.element().textContent).toContain("Speed: 1 s / 30 s"); } - if (url.pathname === "/api/voice/models") - return Promise.resolve(Response.json({ models: [model] })); - return fetch(input, init); - }); - using notify = vi.spyOn(toastManager, "add").mockReturnValue("test"); - // Restored even when an assertion fails, unlike `withImplementation`. - using _destination = vi.mocked(useVoiceTranscriptionDestination).mockImplementation(() => ({ - environmentId: target.environmentId, - label: target.label, - grantId: "test", - prepared, - })); - await using screen = await render( - {}}> - - , - ); - await vi.waitFor(() => expect(authorizations.length + notify.mock.calls.length).not.toBe(0)); - expect(notify).not.toHaveBeenCalled(); - expect(authorizations).toEqual(["DPoP relay-access-token"]); - await expect.element(screen.getByText("Tiny", { exact: true })).toBeVisible(); -}); + }, +); it("recovers from a failed download progress poll without notifying, but reports a failed initial load", async () => { const target = new PrimaryConnectionTarget({ @@ -469,9 +516,9 @@ it("recovers from a failed download progress poll without notifying, but reports supportsStreaming: false, supportsTranslation: false, acceleration: "cpu", + benchmarkTier: "cpu", modelUnloadTimeout: "never", gpuDevices: [], - benchmarkTier: "cpu", }; const unavailable = () => new Response(null, { status: 503 }); const readModels = vi diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.tsx index 66cacf6eb7ee9..aaacdb5c755a2 100644 --- a/apps/web/src/components/settings/VoiceSettingsPanel.tsx +++ b/apps/web/src/components/settings/VoiceSettingsPanel.tsx @@ -30,7 +30,11 @@ import { } from "@t3tools/contracts"; import { createModelSelection, pickModelSelection } from "@t3tools/shared/model"; import { resolveSpeechPostProcessingModelSelection } from "@t3tools/shared/serverSettings"; -import { cloudSpeechLanguages } from "@t3tools/shared/speech"; +import { + cloudSpeechLanguages, + recommendSpeechModels, + type SpeechRecommendationRole, +} from "@t3tools/shared/speech"; import { ArrowDownUpIcon, AudioLinesIcon, @@ -176,7 +180,8 @@ const spellingsOf = (words: SpeechCustomWords) => type ModelCardProps = { readonly model: EnvironmentSpeechModel; readonly comparisonLanguages: readonly string[]; - readonly benchmarkTier: SpeechBenchmarkTier; + readonly recommendation: SpeechRecommendationRole | undefined; + readonly benchmarkTier: SpeechBenchmarkTier | undefined; readonly busy: boolean; readonly canCancel: boolean; readonly onDownload: () => void; @@ -217,11 +222,23 @@ function ModelCard(props: ModelCardProps) {
{model.name} - {model.recommended ? ( + {props.recommendation ? ( } - label="Recommended" - tooltip="A good default pick for the languages it covers." + label={ + props.recommendation === "recommended" + ? "Recommended" + : props.recommendation === "faster" + ? "Faster" + : "More accurate" + } + tooltip={ + props.recommendation === "recommended" + ? "Balanced accuracy and speed for your selected languages on Handy’s reference benchmarks." + : props.recommendation === "faster" + ? "Quicker than Recommended on this reference tier, with useful accuracy." + : "Higher accuracy with a longer reference processing budget for your selected languages." + } /> ) : null} {model.supportsStreaming ? ( @@ -848,14 +865,20 @@ function SpeechModelBrowser(props: { selectedLanguages.length === 0 ? "All languages" : selectedLanguages.map(languageLabel).join(", "); - const sortedModels = filterSpeechModels(models, { - search, - languages: selectedLanguages, - downloadedOnly, - sort, - accuracyLanguage, - benchmarkTier: status?.supported ? status.benchmarkTier : "accelerated", - }); + const benchmarkTier = status?.supported ? status.benchmarkTier : undefined; + const recommendations = recommendSpeechModels(models, selectedLanguages, benchmarkTier); + const sortedModels = filterSpeechModels( + models, + { + search, + languages: selectedLanguages, + downloadedOnly, + sort, + accuracyLanguage, + benchmarkTier, + }, + recommendations, + ); const activeModel = models.find((model) => model.active); const renderedModels = activeModel && !sortedModels.includes(activeModel) @@ -875,7 +898,7 @@ function SpeechModelBrowser(props: {
@@ -995,6 +1018,7 @@ function SpeechModelBrowser(props: { busy: controlsDisabled, canCancel, comparisonLanguages: selectedLanguages, + recommendation: recommendations.get(model.id), benchmarkTier: status.benchmarkTier, onDownload: () => void run( diff --git a/apps/web/src/components/settings/speechModelBrowser.test.ts b/apps/web/src/components/settings/speechModelBrowser.test.ts index 890d43cee048b..9a3f969b60da9 100644 --- a/apps/web/src/components/settings/speechModelBrowser.test.ts +++ b/apps/web/src/components/settings/speechModelBrowser.test.ts @@ -1,5 +1,6 @@ import type { EnvironmentSpeechModel } from "@t3tools/contracts"; import { expect, it } from "vite-plus/test"; +import { recommendSpeechModels } from "@t3tools/shared/speech"; import { filterSpeechModels } from "./speechModelBrowser"; @@ -31,9 +32,9 @@ const models: readonly EnvironmentSpeechModel[] = [ benchmark: { accuracy: { en: { grade: "A", errorPercent: 3.8 }, - fr: { grade: "B", errorPercent: 8 }, + fr: { grade: "B", errorPercent: 7 }, }, - secondsPer30s: { cpu: 2, accelerated: 2 }, + secondsPer30s: { accelerated: 2, cpu: 1 }, }, }, { @@ -47,7 +48,7 @@ const models: readonly EnvironmentSpeechModel[] = [ en: { grade: "A", errorPercent: 3.2 }, fr: { grade: "B", errorPercent: 9 }, }, - secondsPer30s: { cpu: 1, accelerated: 1 }, + secondsPer30s: { accelerated: 1, cpu: 3 }, }, }, { @@ -58,7 +59,7 @@ const models: readonly EnvironmentSpeechModel[] = [ active: false, state: "verifying", languages: ["en"], - benchmark: { accuracy: {}, secondsPer30s: { cpu: null, accelerated: null } }, + benchmark: { accuracy: {}, secondsPer30s: { accelerated: null, cpu: null } }, }, ]; @@ -100,3 +101,58 @@ it("combines text, all selected languages, and completed downloads without retai ).toEqual(["fast"]); expect(filterSpeechModels(models, { ...options, search: "small" })).toEqual([]); }); + +it("ranks language recommendations before the rest without changing explicit sorts or search badges", () => { + const catalog = models.map((model) => ({ + ...model, + active: false, + state: "downloadable" as const, + })); + const options = { + search: "", + languages: ["en"], + downloadedOnly: false, + sort: "recommended", + accuracyLanguage: "en", + benchmarkTier: "accelerated", + } as const; + expect(filterSpeechModels(catalog, options).map(({ id }) => id)).toEqual([ + "fast", + "active", + "small", + "pending", + ]); + expect( + filterSpeechModels(catalog, { ...options, languages: ["en", "fr"] }).map(({ id }) => id), + ).toEqual(["small", "fast", "active"]); + expect(filterSpeechModels(catalog, { ...options, languages: [] }).map(({ id }) => id)).toEqual( + catalog.map(({ id }) => id), + ); + expect(filterSpeechModels(catalog, { ...options, sort: "size" }).map(({ id }) => id)).toEqual([ + "small", + "fast", + "active", + "pending", + ]); + expect( + filterSpeechModels(catalog, { ...options, sort: "speed", benchmarkTier: "cpu" }).map( + ({ id }) => id, + ), + ).toEqual(["small", "fast", "active", "pending"]); + expect( + filterSpeechModels(catalog, { ...options, sort: "accuracy", languages: ["en", "fr"] }).map( + ({ id }) => id, + ), + ).toEqual(["fast", "small", "active"]); + expect( + filterSpeechModels(catalog, { ...options, sort: "speed", languages: ["en", "fr"] }).map( + ({ id }) => id, + ), + ).toEqual(["fast", "small", "active"]); + expect(filterSpeechModels(catalog, { ...options, search: "small" }).map(({ id }) => id)).toEqual([ + "small", + ]); + expect( + recommendSpeechModels(catalog, options.languages, options.benchmarkTier).has("small"), + ).toBe(false); +}); diff --git a/apps/web/src/components/settings/speechModelBrowser.ts b/apps/web/src/components/settings/speechModelBrowser.ts index e08fcde0ae949..f0acb6a45e1c2 100644 --- a/apps/web/src/components/settings/speechModelBrowser.ts +++ b/apps/web/src/components/settings/speechModelBrowser.ts @@ -1,4 +1,5 @@ import type { EnvironmentSpeechModel, SpeechBenchmarkTier } from "@t3tools/contracts"; +import { recommendSpeechModels } from "@t3tools/shared/speech"; export type SpeechModelSort = "recommended" | "size" | "accuracy" | "speed"; @@ -6,7 +7,7 @@ const modelSortOrder = (model: EnvironmentSpeechModel) => { if (model.active) return 0; if (model.state === "installed") return 1; if (model.state === "downloading" || model.state === "verifying") return 2; - return model.recommended ? 3 : 4; + return 3; }; export function filterSpeechModels( @@ -17,10 +18,12 @@ export function filterSpeechModels( readonly downloadedOnly: boolean; readonly sort: SpeechModelSort; readonly accuracyLanguage: string; - readonly benchmarkTier: SpeechBenchmarkTier; + readonly benchmarkTier: SpeechBenchmarkTier | undefined; }, + recommendations = recommendSpeechModels(models, options.languages, options.benchmarkTier), ) { const query = options.search.trim().toLocaleLowerCase(); + const ranks = new Map([...recommendations.keys()].map((id, index) => [id, index])); const score = (model: EnvironmentSpeechModel) => { switch (options.sort) { case "size": @@ -28,9 +31,11 @@ export function filterSpeechModels( case "accuracy": return model.benchmark?.accuracy[options.accuracyLanguage]?.errorPercent ?? Infinity; case "speed": - return model.benchmark?.secondsPer30s[options.benchmarkTier] ?? Infinity; + return options.benchmarkTier === undefined + ? Infinity + : (model.benchmark?.secondsPer30s[options.benchmarkTier] ?? Infinity); case "recommended": - return modelSortOrder(model); + return modelSortOrder(model) * 4 + (ranks.get(model.id) ?? 3); } }; return models diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx index b5418bcb409da..63614c4d09773 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx +++ b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx @@ -40,6 +40,7 @@ const mocks = vi.hoisted(() => ({ primaryEnvironmentId: "primary-env", statusRequests: [] as string[], statusUnavailable: false, + statusTier: "accelerated" as "cpu" | "accelerated", modelState: "ready" as "ready" | "missing-model", statusModelId: "test-model", models: [] as EnvironmentSpeechModel[], @@ -128,9 +129,9 @@ vi.mock("@t3tools/client-runtime/voice-input", async (importOriginal) => ({ supportsStreaming: false, supportsTranslation: false, acceleration: "auto", + benchmarkTier: mocks.statusTier, modelUnloadTimeout: "min_15", gpuDevices: [], - benchmarkTier: "cpu", }; }), downloadEnvironmentSpeechModel: (_prepared: PreparedConnection, modelId: string) => @@ -145,10 +146,14 @@ vi.mock("@t3tools/client-runtime/voice-input", async (importOriginal) => ({ mocks.statusModelId = modelId; return { models: mocks.models }; }), - getEnvironmentSpeechModels: vi.fn(() => + getEnvironmentSpeechModels: vi.fn((_prepared: PreparedConnection, includeBenchmarks = false) => Effect.sync(() => { if (mocks.catalogOffline) throw new Error("Catalog offline"); - return { models: mocks.models }; + return { + models: mocks.models.map(({ benchmark, ...model }) => + includeBenchmarks ? { ...model, ...(benchmark ? { benchmark } : {}) } : model, + ), + }; }), ), postProcessEnvironmentTranscript: ( @@ -219,6 +224,7 @@ function setup(options: { readonly destination?: string | null } = {}) { mocks.statusRequests = []; mocks.statusUnavailable = false; mocks.modelState = "ready"; + mocks.statusTier = "accelerated"; mocks.statusModelId = "test-model"; mocks.models = []; mocks.catalogOffline = false; @@ -412,6 +418,7 @@ function catalogModel( id: string, languages: readonly string[], recommended: boolean, + errorPercent = 4, ): EnvironmentSpeechModel { return { id, @@ -422,6 +429,12 @@ function catalogModel( accuracy: 0, speed: 0, recommended, + benchmark: { + accuracy: Object.fromEntries( + languages.map((language) => [language, { grade: "A", errorPercent }]), + ), + secondsPer30s: { accelerated: 2, cpu: 3 }, + }, supportsStreaming: false, supportsLanguageDetection: languages.length > 1, supportsTranslation: false, @@ -431,26 +444,30 @@ function catalogModel( } it.each([ - ["the browser prefers German", ["de-DE"], undefined, "auto"], - ["the desktop exposes no locale methods", ["de-DE"], {}, "auto"], + ["the browser prefers German", ["de-DE"], undefined, "auto", "accelerated"], + ["the remote environment uses CPU", ["de-DE"], undefined, "auto", "cpu"], + ["the desktop exposes no locale methods", ["de-DE"], {}, "auto", "accelerated"], [ "an older preload exposes only the OS locale", ["en-US"], { getSystemLocale: () => "de-DE" }, "auto", + "accelerated", ], [ "the desktop OS prefers German", ["en-US"], { getPreferredSystemLanguages: () => ["de-DE"] }, "auto", + "accelerated", ], - ["German is the chosen Voice language", ["en-US"], undefined, "de"], + ["German is the chosen Voice language", ["en-US"], undefined, "de", "accelerated"], ])( "offers and downloads a German-capable model when %s", - async (_, languages, bridge, speechLanguage) => { + async (_, languages, bridge, speechLanguage, tier) => { setup({ destination: "env-a" }); mocks.settings = { speechLanguage }; + mocks.statusTier = tier as "cpu" | "accelerated"; using _languages = vi.spyOn(navigator, "languages", "get").mockReturnValue(languages); using _bridge = stubGlobal("desktopBridge", bridge); const englishDefault = "handy-computer/parakeet-unified-en-0.6b-gguf"; @@ -459,16 +476,24 @@ it.each([ mocks.statusModelId = englishDefault; mocks.models = [ { ...catalogModel(englishDefault, ["en"], true), active: true }, - catalogModel(german, ["en", "de", "fr"], true), catalogModel("handy-computer/canary-180m-flash-gguf", ["en", "de", "es", "fr"], true), + { + ...catalogModel(german, ["en", "de", "fr"], false, 2), + benchmark: { + accuracy: { de: { grade: "A", errorPercent: 2 } }, + secondsPer30s: { cpu: 20, accelerated: 2 }, + }, + }, ]; mocks.download.mockResolvedValue(undefined); await using _screen = await render(); await act(() => mocks.voice.start()); expect(mocks.voice.setup.open).toBe(true); await act(() => mocks.voice.setup.download()); - expect(mocks.download).toHaveBeenCalledExactlyOnceWith(german); - expect(mocks.voice.setup.model?.id).toBe(german); + const expected = tier === "cpu" ? "handy-computer/canary-180m-flash-gguf" : german; + expect(mocks.download).toHaveBeenCalledExactlyOnceWith(expected); + expect(mocks.voice.setup.model?.id).toBe(expected); + expect(mocks.voice.setup.model?.recommended).toBe(true); }, ); @@ -528,6 +553,7 @@ it.each([true, false])( mocks.models.push(catalogModel("german", ["de"], true)); // Another client can finish setup while the catalog is offline. mocks.modelState = "ready"; + mocks.statusTier = "accelerated"; await userEvent.click(retry); await expect.element(wizard.getByRole("heading", { name: "german" })).toBeVisible(); await expect.element(download).toBeEnabled(); @@ -562,6 +588,7 @@ it.each([ mocks.download.mockImplementation(async () => { mocks.models = [mocks.models[0]!, { ...mocks.models[1]!, state: landedState }]; mocks.modelState = "ready"; + mocks.statusTier = "accelerated"; }); await using _screen = await render(); await act(() => mocks.voice.start()); diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.ts b/apps/web/src/speech/useEnvironmentSpeechInput.ts index 94dd7cfb8b4cf..c3390024cf6fd 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.ts +++ b/apps/web/src/speech/useEnvironmentSpeechInput.ts @@ -21,6 +21,7 @@ import type { SpeechPostProcessingOptions, SpeechStreamText, SpeechTranscriptionOptions, + SpeechBenchmarkTier, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { chooseSetupSpeechModel, resolveSpeechVocabulary } from "@t3tools/shared/speech"; @@ -379,7 +380,11 @@ export function useEnvironmentSpeechInput(input: HookInput) { .then((result) => { if (disposed) return; const model = result.models.find((candidate) => candidate.id === setupModelId) ?? null; - if (model) setSetup((current) => ({ ...current, model })); + if (model) + setSetup((current) => ({ + ...current, + model: { ...model, recommended: current.model?.recommended ?? false }, + })); }) .catch(() => undefined) .finally(() => { @@ -424,20 +429,26 @@ export function useEnvironmentSpeechInput(input: HookInput) { }); const loadSetupCatalog = useStableCallback( - async (connection: PreparedConnection, selectedModelId: string, request: number) => { + async ( + connection: PreparedConnection, + selectedModelId: string, + benchmarkTier: SpeechBenchmarkTier, + request: number, + ) => { setSetup({ ...INITIAL_SETUP, open: true }); - const catalog = await runEnvironmentSpeech(getEnvironmentSpeechModels(connection)).catch( - () => null, - ); + const catalog = await runEnvironmentSpeech( + getEnvironmentSpeechModels(connection, true), + ).catch(() => null); if (request !== startRequestRef.current) return; const choice = chooseSetupSpeechModel(catalog?.models ?? [], { selectedModelId, intent: latestRef.current.clientSettings.speechLanguage, preferred: preferredSpeechLanguages(), + benchmarkTier, }); setSetup((current) => ({ ...current, - model: choice?.model ?? null, + model: choice ? { ...choice.model, recommended: choice.recommended } : null, language: choice?.language ?? null, catalogFailed: catalog === null, error: choice @@ -489,7 +500,7 @@ export function useEnvironmentSpeechInput(input: HookInput) { setStatus({ prepared, value: latestStatus }); setQueuedStart(null); if (latestStatus.supported && latestStatus.state === "missing-model") { - return loadSetupCatalog(prepared, latestStatus.modelId, request); + return loadSetupCatalog(prepared, latestStatus.modelId, latestStatus.benchmarkTier, request); } if (!latestStatus.supported) { toastManager.add({ @@ -584,7 +595,13 @@ export function useEnvironmentSpeechInput(input: HookInput) { setOpen: (open: boolean) => setSetup((current) => ({ ...current, open })), retryCatalog: setup.open && setup.catalogFailed && prepared && currentStatus?.supported - ? () => loadSetupCatalog(prepared, currentStatus.modelId, ++startRequestRef.current) + ? () => + loadSetupCatalog( + prepared, + currentStatus.modelId, + currentStatus.benchmarkTier, + ++startRequestRef.current, + ) : null, download: downloadSetupModel, cancelDownload: cancelSetupDownload, diff --git a/docs/user/voice-input.md b/docs/user/voice-input.md index 592e110c896cc..76a6112b4bedb 100644 --- a/docs/user/voice-input.md +++ b/docs/user/voice-input.md @@ -66,8 +66,10 @@ format, such as Core ML, ONNX, or MLX builds of the same model, can’t be used. Select the languages you dictate in to compare models that support all of them. Accuracy shows how often the model got words or characters wrong in each -language; speed is the time to transcribe 30 seconds on a reference GPU. Choose -**All languages** to browse the full catalog. Filtering does not switch your model. +language; speed is the time to transcribe 30 seconds on a reference CPU or GPU, +matching the selected environment's transcription backend. Recommendations use +the same reference tier for every selected language. Choose **All languages** to +browse without a language preference. Filtering does not switch your model. **Advanced** controls GPU or CPU use and when an idle model is unloaded. With hardware set to **Auto**, a recording that fails on the GPU is retried once on that same diff --git a/packages/shared/src/speech.test.ts b/packages/shared/src/speech.test.ts index 6d1829fd67a91..3d02b3d08ce48 100644 --- a/packages/shared/src/speech.test.ts +++ b/packages/shared/src/speech.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_SPEECH_TRANSCRIPTION_OPTIONS, SPEECH_MAX_OPTIONS_BYTES } from " import { chooseSetupSpeechModel, + recommendSpeechModels, decodeSpeechPcmRequest, encodeSpeechPcmRequest, resolveSpeechVocabulary, @@ -79,7 +80,18 @@ describe("chooseSetupSpeechModel", () => { languages: string[], supportsLanguageDetection: boolean, recommended: boolean, - ) => ({ id, languages, supportsLanguageDetection, recommended }); + ) => ({ + id, + languages, + supportsLanguageDetection, + recommended, + benchmark: { + accuracy: Object.fromEntries( + languages.map((language) => [language, { errorPercent: recommended ? 4 : 10 }]), + ), + secondsPer30s: { accelerated: 2, cpu: 3 }, + }, + }); const catalog = [ model("unrecommended", ["en", "de", "es"], true, false), model("english", ["en"], false, true), @@ -146,7 +158,173 @@ describe("chooseSetupSpeechModel", () => { undefined, ], ] as const)("%s", (_, selectedModelId, intent, preferred, expected) => { - const choice = chooseSetupSpeechModel(catalog, { selectedModelId, intent, preferred }); + const choice = chooseSetupSpeechModel(catalog, { + selectedModelId, + intent, + preferred, + benchmarkTier: "accelerated", + }); expect(choice && { id: choice.model.id, language: choice.language }).toEqual(expected); }); + + it("keeps suitable selections and preference order, and falls back safely without measurements", () => { + const best = { + ...catalog[0]!, + benchmark: { + accuracy: { de: { errorPercent: 1 } }, + secondsPer30s: { accelerated: 1, cpu: 2 }, + }, + }; + expect( + chooseSetupSpeechModel([...catalog, { ...best, id: "best" }], { + benchmarkTier: "accelerated", + selectedModelId: "gone", + intent: "auto", + preferred: ["de", "en"], + }), + ).toMatchObject({ model: { id: "best" }, language: "de", recommended: true }); + expect( + chooseSetupSpeechModel([...catalog, { ...best, id: "best" }], { + benchmarkTier: "accelerated", + selectedModelId: "detecting", + intent: "auto", + preferred: ["de", "en"], + }), + ).toMatchObject({ model: { id: "detecting" }, language: "de", recommended: false }); + const unmeasured = catalog.map(({ benchmark: _benchmark, ...model }) => model); + expect( + chooseSetupSpeechModel(unmeasured, { + benchmarkTier: "accelerated", + selectedModelId: "gone", + intent: "auto", + preferred: ["de", "en"], + }), + ).toMatchObject({ model: { id: "unrecommended" }, language: "de", recommended: false }); + expect( + chooseSetupSpeechModel([], { + benchmarkTier: "accelerated", + selectedModelId: "gone", + intent: "auto", + preferred: [], + }), + ).toBeUndefined(); + }); +}); + +describe("recommendSpeechModels", () => { + const model = ( + id: string, + errors: Record, + wait: number | null, + cpu: number | null, + ) => ({ + id, + languages: Object.keys(errors), + benchmark: { + accuracy: Object.fromEntries( + Object.entries(errors).map(([language, errorPercent]) => [language, { errorPercent }]), + ), + secondsPer30s: { accelerated: wait, cpu }, + }, + }); + const models = [ + model("balanced", { en: 4, de: 16 }, 2, 7), + model("fast", { en: 7, de: 7 }, 1, 3), + model("accurate", { en: 2, de: 2 }, 8, 20), + model("incomplete", { en: 1 }, 1, 1), + ]; + + it("balances all requested languages and assigns each model its first role", () => { + expect([...recommendSpeechModels(models.slice(0, 3), ["en"], "accelerated")]).toEqual([ + ["balanced", "recommended"], + ["fast", "faster"], + ["accurate", "accurate"], + ]); + expect([...recommendSpeechModels(models, ["en", "de", "de"], "accelerated")]).toEqual([ + ["fast", "recommended"], + ["accurate", "accurate"], + ]); + expect([...recommendSpeechModels(models.slice(0, 3), ["en"], "cpu")]).toEqual([ + ["fast", "recommended"], + ["balanced", "accurate"], + ]); + expect([...recommendSpeechModels(models, ["en", "de"], "cpu")]).toEqual([ + ["fast", "recommended"], + ]); + }); + + it("offers alternatives only when they improve on the balanced pick on the same tier", () => { + expect([ + ...recommendSpeechModels( + [model("cpu-fast-gpu-slow", { en: 3 }, 4, 1), model("balanced", { en: 3.2 }, 1, 8)], + ["en"], + "accelerated", + ), + ]).toEqual([ + ["balanced", "recommended"], + ["cpu-fast-gpu-slow", "accurate"], + ]); + expect([ + ...recommendSpeechModels( + [model("equal-accuracy", { en: 4 }, 8, 20), model("balanced", { en: 4 }, 2, 20)], + ["en"], + "accelerated", + ), + ]).toEqual([["balanced", "recommended"]]); + expect([ + ...recommendSpeechModels( + [model("balanced", { en: 4 }, 12, 20), model("outside-budget", { en: 2 }, 30, 40)], + ["en"], + "accelerated", + ), + ]).toEqual([["balanced", "recommended"]]); + }); + + it("does not invent measurements, preferences, or a model covering missing languages", () => { + expect(recommendSpeechModels(models, [], "accelerated").size).toBe(0); + expect(recommendSpeechModels(models, ["ja"], "accelerated").size).toBe(0); + expect( + recommendSpeechModels( + [ + model("missing-speed", { en: 1, de: 1 }, null, 1), + { ...model("missing-accuracy", { en: 1 }, 1, 1), languages: ["en", "de"] }, + ], + ["en", "de"], + "accelerated", + ).size, + ).toBe(0); + expect([ + ...recommendSpeechModels([model("no-cpu", { en: 1 }, 1, null)], ["en"], "accelerated"), + ]).toEqual([["no-cpu", "recommended"]]); + expect(recommendSpeechModels([model("no-cpu", { en: 1 }, 1, null)], ["en"], "cpu").size).toBe( + 0, + ); + expect([ + ...recommendSpeechModels([model("cpu-only", { en: 1 }, null, 2)], ["en"], "cpu"), + ]).toEqual([["cpu-only", "recommended"]]); + }); + + it("prefers useful accuracy before speed and relaxes the wait budget before quality", () => { + expect([ + ...recommendSpeechModels( + [model("poor", { en: 25 }, 1, 1), model("slow", { en: 4 }, 12, 20)], + ["en"], + "accelerated", + ), + ]).toEqual([["slow", "recommended"]]); + expect([ + ...recommendSpeechModels( + [model("worse", { en: 30, de: 40 }, 1, 1), model("least-poor", { en: 29, de: 30 }, 20, 40)], + ["en", "de"], + "accelerated", + ), + ]).toEqual([["least-poor", "recommended"]]); + expect([ + ...recommendSpeechModels( + [model("zero", { en: 0, de: 0 }, 1, 1)], + ["en", "de"], + "accelerated", + ), + ]).toEqual([["zero", "recommended"]]); + }); }); diff --git a/packages/shared/src/speech.ts b/packages/shared/src/speech.ts index c5ed9ab14ea0a..94c2eb316faae 100644 --- a/packages/shared/src/speech.ts +++ b/packages/shared/src/speech.ts @@ -3,13 +3,102 @@ import { type CloudSpeechProvider, SPEECH_MAX_OPTIONS_BYTES, type SpeechCustomWords, + type SpeechBenchmarkTier, SpeechTranscriptionOptions, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import recommendationPolicy from "./speechRecommendationPolicy.json" with { type: "json" }; const decodeOptions = Schema.decodeUnknownSync(Schema.fromJsonString(SpeechTranscriptionOptions)); const encodeOptions = Schema.encodeSync(Schema.fromJsonString(SpeechTranscriptionOptions)); +export type SpeechRecommendationRole = "recommended" | "faster" | "accurate"; +type RecommendationModel = { + readonly id: string; + readonly languages: readonly string[]; + readonly benchmark?: { + readonly accuracy: Readonly>; + readonly secondsPer30s: { + readonly cpu?: number | null; + readonly accelerated: number | null; + }; + }; +}; + +/** Reference accuracy/speed picks; no preference or incomplete measurements get no badge. */ +export function recommendSpeechModels( + models: readonly RecommendationModel[], + languages: readonly string[], + tier: SpeechBenchmarkTier | undefined, +) { + const wanted = [...new Set(languages)]; + const roles = new Map(); + if (wanted.length === 0 || tier === undefined) return roles; + const policy = recommendationPolicy; + const scored = models.flatMap((model) => { + if (!wanted.every((language) => model.languages.includes(language))) return []; + const errors = wanted.map((language) => model.benchmark?.accuracy[language]?.errorPercent); + const wait = model.benchmark?.secondsPer30s[tier]; + if ( + wait == null || + !Number.isFinite(wait) || + wait < 0 || + errors.some((error) => error === undefined || !Number.isFinite(error) || error < 0) + ) + return []; + const measured = errors as number[]; + const error = Math.exp( + measured.reduce((sum, value) => sum + Math.log(value), 0) / measured.length, + ); + return [ + { + id: model.id, + error, + worst: Math.max(...measured), + wait, + cost: error + policy.waitWeight * Math.max(0, wait - policy.freeWaitSeconds), + }, + ]; + }); + if (scored.length === 0) return roles; + const useful = scored.filter((model) => model.worst < policy.maxLanguageErrorPercent); + const pool = useful.length ? useful : scored; + const lowest = (items: typeof scored, score: (item: (typeof scored)[number]) => number) => + items.reduce<(typeof scored)[number] | undefined>( + (best, item) => (best === undefined || score(item) < score(best) ? item : best), + undefined, + ); + const withinBudget = pool.filter((model) => model.wait <= policy.overallMaxWaitSeconds); + const best = ( + useful.length + ? lowest(withinBudget.length ? withinBudget : pool, (model) => model.cost) + : lowest(pool, (model) => model.worst + model.error / 1000) + )!; + const quicker = pool.filter((model) => model.wait < best.wait); + const fast = useful.length + ? (lowest( + quicker.filter((model) => model.wait <= policy.fastMaxWaitSeconds), + (model) => model.error, + ) ?? lowest(quicker, (model) => model.wait + model.error / 1000)) + : undefined; + const accurate = useful.length + ? lowest( + pool.filter( + (model) => model.error < best.error && model.wait <= policy.accurateMaxWaitSeconds, + ), + (model) => model.error, + ) + : undefined; + for (const [model, role] of [ + [best, "recommended"], + [fast, "faster"], + [accurate, "accurate"], + ] as const) { + if (model && !roles.has(model.id)) roles.set(model.id, role); + } + return roles; +} + export function cloudSpeechDisclosure(provider: CloudSpeechProvider, environment: string) { return provider === "openrouter" ? `After you stop, ${environment} sends the recording to OpenRouter. OpenRouter chooses who runs your model. T3 requests zero data retention; your OpenRouter account may allow safety retention.` @@ -37,16 +126,13 @@ export function effectiveSpeechLanguage( } /** - * The model first-run setup offers: the selected one, else a recommended one, else any that the + * The model first-run setup offers: the selected one, else the benchmark pick, else any that the * server would really transcribe the person's language with. Without a language hint, * offer only the selected model, without a recommendation. */ export function chooseSetupSpeechModel< - Model extends { - readonly id: string; - readonly languages: readonly string[]; + Model extends RecommendationModel & { readonly supportsLanguageDetection: boolean; - readonly recommended: boolean; }, >( models: readonly Model[], @@ -54,22 +140,26 @@ export function chooseSetupSpeechModel< readonly selectedModelId: string; readonly intent: string; readonly preferred: readonly string[]; + readonly benchmarkTier: SpeechBenchmarkTier | undefined; }, ) { if (input.intent === "auto" && input.preferred.length === 0) { const model = models.find(({ id }) => id === input.selectedModelId); - return model && { model, language: null }; + return model && { model, language: null, recommended: false }; } for (const language of input.intent === "auto" ? input.preferred : [input.intent]) { const capable = models.filter((model) => { const effective = effectiveSpeechLanguage(model, input.intent); return effective === language || (effective === "auto" && model.languages.includes(language)); }); + const recommendedId = recommendSpeechModels(capable, [language], input.benchmarkTier) + .keys() + .next().value; const model = capable.find(({ id }) => id === input.selectedModelId) ?? - capable.find(({ recommended }) => recommended) ?? + capable.find(({ id }) => id === recommendedId) ?? capable[0]; - if (model) return { model, language }; + if (model) return { model, language, recommended: model.id === recommendedId }; } return undefined; } diff --git a/packages/shared/src/speechRecommendationPolicy.json b/packages/shared/src/speechRecommendationPolicy.json new file mode 100644 index 0000000000000..108de86b43830 --- /dev/null +++ b/packages/shared/src/speechRecommendationPolicy.json @@ -0,0 +1,11 @@ +{ + "dictationSeconds": 30, + "freeWaitSeconds": 3, + "waitWeight": 0.5, + "overallMaxWaitSeconds": 5, + "maxLanguageErrorPercent": 20, + "noteMinErrorPercent": 15, + "experimentalMaxErrorPercent": 30, + "accurateMaxWaitSeconds": 10, + "fastMaxWaitSeconds": 4 +}