paseo-plugin-genui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (375) hide show
  1. package/LICENSE +202 -0
  2. package/OVERVIEW.md +21 -0
  3. package/README.md +77 -2
  4. package/assets/fonts/Excalifont-OFL.txt +109 -0
  5. package/assets/fonts/Excalifont-Regular-latin.woff2 +0 -0
  6. package/assets/icon.png +0 -0
  7. package/assets/icon.svg +1 -0
  8. package/assets/screenshot-chat.png +0 -0
  9. package/assets/screenshot-gallery.png +0 -0
  10. package/assets/screenshot-playground.png +0 -0
  11. package/client/components/content.tsx +468 -0
  12. package/client/components/data.tsx +967 -0
  13. package/client/components/dev.tsx +998 -0
  14. package/client/components/diagram.tsx +513 -0
  15. package/client/components/inputs.tsx +994 -0
  16. package/client/components/layout.tsx +239 -0
  17. package/client/components/media.tsx +428 -0
  18. package/client/components/project.tsx +641 -0
  19. package/client/components/widgets.tsx +836 -0
  20. package/client/gallery.tsx +449 -0
  21. package/client/live-ui.tsx +525 -0
  22. package/client/loaders.ts +342 -0
  23. package/client/markdown.tsx +172 -0
  24. package/client/node-renderer.tsx +149 -0
  25. package/client/render.tsx +186 -0
  26. package/client/runtime.ts +103 -0
  27. package/client/settings.tsx +57 -0
  28. package/client/web.ts +186 -0
  29. package/gallery/Acceptance-baseline.ui +1 -0
  30. package/gallery/Acceptance-checklist.ui +14 -0
  31. package/gallery/Acceptance-evidence.ui +9 -0
  32. package/gallery/Acceptance.ui +1 -0
  33. package/gallery/Badge.ui +3 -0
  34. package/gallery/Benchmark-context.ui +8 -0
  35. package/gallery/Benchmark-hyperfine.ui +1 -0
  36. package/gallery/Benchmark-runs-list.ui +9 -0
  37. package/gallery/Benchmark-runs.ui +1 -0
  38. package/gallery/Benchmark-vitest.ui +1 -0
  39. package/gallery/Benchmark.ui +9 -0
  40. package/gallery/Board-bb-tasks.ui +1 -0
  41. package/gallery/Board-gh-issues.ui +1 -0
  42. package/gallery/Board-gh-prs.ui +1 -0
  43. package/gallery/Board.ui +12 -0
  44. package/gallery/Button-destructive.ui +4 -0
  45. package/gallery/Button.ui +5 -0
  46. package/gallery/Calendar.ui +15 -0
  47. package/gallery/CalendarHeatmap-short.ui +11 -0
  48. package/gallery/CalendarHeatmap.ui +4 -0
  49. package/gallery/Callout.ui +4 -0
  50. package/gallery/Card.ui +4 -0
  51. package/gallery/Carousel.ui +8 -0
  52. package/gallery/Changes-paths.ui +1 -0
  53. package/gallery/Changes-uncommitted.ui +1 -0
  54. package/gallery/Changes.ui +1 -0
  55. package/gallery/Chart-area.ui +3 -0
  56. package/gallery/Chart-funnel.ui +3 -0
  57. package/gallery/Chart-heatmap.ui +11 -0
  58. package/gallery/Chart-pie.ui +3 -0
  59. package/gallery/Chart-radar.ui +3 -0
  60. package/gallery/Chart-scatter.ui +4 -0
  61. package/gallery/Chart-stacked.ui +5 -0
  62. package/gallery/Chart.ui +3 -0
  63. package/gallery/Checklist.ui +6 -0
  64. package/gallery/Choice.ui +8 -0
  65. package/gallery/Code.ui +3 -0
  66. package/gallery/Commits-limit.ui +1 -0
  67. package/gallery/Commits.ui +1 -0
  68. package/gallery/Compare-conditions.ui +4 -0
  69. package/gallery/Compare-three.ui +7 -0
  70. package/gallery/Compare.ui +4 -0
  71. package/gallery/Coverage-go.ui +1 -0
  72. package/gallery/Coverage-lcov.ui +1 -0
  73. package/gallery/Coverage-python.ui +1 -0
  74. package/gallery/Coverage.ui +1 -0
  75. package/gallery/DateInput.ui +5 -0
  76. package/gallery/Decision-percent.ui +10 -0
  77. package/gallery/Decision.ui +12 -0
  78. package/gallery/Dependencies-audit.ui +1 -0
  79. package/gallery/Dependencies-both.ui +1 -0
  80. package/gallery/Dependencies-list.ui +7 -0
  81. package/gallery/Dependencies-pip.ui +1 -0
  82. package/gallery/Dependencies-pnpm.ui +1 -0
  83. package/gallery/Dependencies.ui +1 -0
  84. package/gallery/Details.ui +4 -0
  85. package/gallery/Diagram-flow.ui +16 -0
  86. package/gallery/Diagram-sequence.ui +15 -0
  87. package/gallery/Diagram.ui +16 -0
  88. package/gallery/Diff.ui +3 -0
  89. package/gallery/Divider.ui +5 -0
  90. package/gallery/EditableTable.ui +9 -0
  91. package/gallery/Entity-list.ui +6 -0
  92. package/gallery/Entity.ui +8 -0
  93. package/gallery/Environments.ui +1 -0
  94. package/gallery/Estimate-points.ui +6 -0
  95. package/gallery/Estimate.ui +8 -0
  96. package/gallery/File.ui +4 -0
  97. package/gallery/Findings-biome.ui +1 -0
  98. package/gallery/Findings-eslint.ui +1 -0
  99. package/gallery/Findings-oxlint.ui +1 -0
  100. package/gallery/Findings-review.ui +1 -0
  101. package/gallery/Findings-ruff.ui +1 -0
  102. package/gallery/Findings-sarif.ui +1 -0
  103. package/gallery/Findings-ship.ui +11 -0
  104. package/gallery/Findings-tsc.ui +1 -0
  105. package/gallery/Findings.ui +7 -0
  106. package/gallery/Flashcards.ui +3 -0
  107. package/gallery/FollowUps.ui +4 -0
  108. package/gallery/Form.ui +8 -0
  109. package/gallery/Gallery-links.ui +8 -0
  110. package/gallery/Gallery-strip.ui +14 -0
  111. package/gallery/Gallery.ui +10 -0
  112. package/gallery/Gauge.ui +7 -0
  113. package/gallery/Grid.ui +8 -0
  114. package/gallery/Hotspots-photo.ui +8 -0
  115. package/gallery/Hotspots.ui +11 -0
  116. package/gallery/Icon.ui +5 -0
  117. package/gallery/Image.ui +4 -0
  118. package/gallery/ImageSearch-aspects.ui +6 -0
  119. package/gallery/ImageSearch.ui +4 -0
  120. package/gallery/Input.ui +5 -0
  121. package/gallery/Json-api.ui +1 -0
  122. package/gallery/Json.ui +8 -0
  123. package/gallery/KeyValue.ui +3 -0
  124. package/gallery/List-dots.ui +8 -0
  125. package/gallery/List.ui +8 -0
  126. package/gallery/ListEditor.ui +5 -0
  127. package/gallery/Logs-jsonl.ui +1 -0
  128. package/gallery/Logs-lines.ui +11 -0
  129. package/gallery/Logs-python.ui +1 -0
  130. package/gallery/Logs.ui +1 -0
  131. package/gallery/Map-city.ui +8 -0
  132. package/gallery/Map.ui +8 -0
  133. package/gallery/Metric.ui +3 -0
  134. package/gallery/OptionCards-multi.ui +10 -0
  135. package/gallery/OptionCards.ui +10 -0
  136. package/gallery/Pipeline-checks.ui +1 -0
  137. package/gallery/Pipeline-passed.ui +1 -0
  138. package/gallery/Pipeline-running.ui +1 -0
  139. package/gallery/Pipeline-stages.ui +13 -0
  140. package/gallery/Pipeline.ui +1 -0
  141. package/gallery/Plan.ui +9 -0
  142. package/gallery/Progress.ui +4 -0
  143. package/gallery/Provenance-deploy.ui +6 -0
  144. package/gallery/Provenance-no-receipt.ui +1 -0
  145. package/gallery/Provenance.ui +1 -0
  146. package/gallery/PullRequest.ui +1 -0
  147. package/gallery/Quiz.ui +6 -0
  148. package/gallery/Radio.ui +5 -0
  149. package/gallery/Rating.ui +5 -0
  150. package/gallery/Roadmap-sprint.ui +8 -0
  151. package/gallery/Roadmap-year.ui +9 -0
  152. package/gallery/Roadmap.ui +12 -0
  153. package/gallery/Row.ui +3 -0
  154. package/gallery/SegmentedBar-status.ui +8 -0
  155. package/gallery/SegmentedBar.ui +8 -0
  156. package/gallery/Select.ui +5 -0
  157. package/gallery/Slider.ui +7 -0
  158. package/gallery/Snippet.ui +3 -0
  159. package/gallery/Sources-chips.ui +4 -0
  160. package/gallery/Sources.ui +8 -0
  161. package/gallery/Sparkline.ui +6 -0
  162. package/gallery/Stack.ui +7 -0
  163. package/gallery/StackTrace-aggregate.ui +1 -0
  164. package/gallery/StackTrace-browser.ui +1 -0
  165. package/gallery/StackTrace-go.ui +1 -0
  166. package/gallery/StackTrace-java.ui +1 -0
  167. package/gallery/StackTrace-python.ui +1 -0
  168. package/gallery/StackTrace-rust.ui +1 -0
  169. package/gallery/StackTrace.ui +1 -0
  170. package/gallery/Status.ui +9 -0
  171. package/gallery/Stepper.ui +5 -0
  172. package/gallery/Steps.ui +8 -0
  173. package/gallery/Swatches-long-names.ui +4 -0
  174. package/gallery/Swatches-picker.ui +5 -0
  175. package/gallery/Swatches.ui +10 -0
  176. package/gallery/Table.ui +7 -0
  177. package/gallery/Tabs.ui +8 -0
  178. package/gallery/Tests-junit.ui +1 -0
  179. package/gallery/Tests-vitest-junit.ui +1 -0
  180. package/gallery/Tests-vitest.ui +1 -0
  181. package/gallery/Tests.ui +9 -0
  182. package/gallery/Text.ui +4 -0
  183. package/gallery/TextArea.ui +5 -0
  184. package/gallery/Threads-ids.ui +1 -0
  185. package/gallery/Threads.ui +1 -0
  186. package/gallery/Timeline.ui +8 -0
  187. package/gallery/Timer.ui +4 -0
  188. package/gallery/Toggle.ui +5 -0
  189. package/gallery/Transcript-text.ui +1 -0
  190. package/gallery/Transcript-untimed.ui +1 -0
  191. package/gallery/Transcript-whisper.ui +1 -0
  192. package/gallery/Transcript.ui +1 -0
  193. package/gallery/Tree.ui +3 -0
  194. package/gallery/Treemap-du.ui +1 -0
  195. package/gallery/Treemap-esbuild.ui +1 -0
  196. package/gallery/Treemap-tree.ui +7 -0
  197. package/gallery/Treemap.ui +18 -0
  198. package/gallery/Walkthrough.ui +9 -0
  199. package/gallery/Waterfall-har.ui +1 -0
  200. package/gallery/Waterfall-trace.ui +1 -0
  201. package/gallery/Waterfall.ui +11 -0
  202. package/gallery/data/acceptance/coil/viewport-lifecycle.json +25 -0
  203. package/gallery/data/acceptance/coil-gate.json +153 -0
  204. package/gallery/data/acceptance/orbit-baseline.json +163 -0
  205. package/gallery/data/acceptance/shots/gate-cell-opened.png +0 -0
  206. package/gallery/data/acceptance/shots/gate-light.png +0 -0
  207. package/gallery/data/acceptance/shots/gate-phone-dark.png +0 -0
  208. package/gallery/data/activity.json +1 -0
  209. package/gallery/data/bb-tasks-orb.json +420 -0
  210. package/gallery/data/biome.json +229 -0
  211. package/gallery/data/compare/status-after.png +0 -0
  212. package/gallery/data/compare/status-before.png +0 -0
  213. package/gallery/data/compare/status-dark-2x.png +0 -0
  214. package/gallery/data/compare/status-rerender.png +0 -0
  215. package/gallery/data/coverage-py.json +1 -0
  216. package/gallery/data/coverage-summary.json +1 -0
  217. package/gallery/data/coverage.out +23 -0
  218. package/gallery/data/deploys.json +1 -0
  219. package/gallery/data/device-runs.json +285 -0
  220. package/gallery/data/du-node-modules.txt +359 -0
  221. package/gallery/data/esbuild-meta.json +292 -0
  222. package/gallery/data/eslint.json +266 -0
  223. package/gallery/data/eslint.sarif +343 -0
  224. package/gallery/data/findings-review.json +76 -0
  225. package/gallery/data/gh-issues-nextjs.json +293 -0
  226. package/gallery/data/gh-pr-checks.json +272 -0
  227. package/gallery/data/gh-prs-nextjs.json +419 -0
  228. package/gallery/data/gh-run-failed.json +1 -0
  229. package/gallery/data/gh-run-passed.json +1 -0
  230. package/gallery/data/gh-run-running.json +1 -0
  231. package/gallery/data/github-release.json +57 -0
  232. package/gallery/data/hyperfine-startup.json +187 -0
  233. package/gallery/data/lcov.info +234 -0
  234. package/gallery/data/logs/media-sync.log +127 -0
  235. package/gallery/data/logs/thumbnailer.jsonl +17 -0
  236. package/gallery/data/logs/vitest-run.log +89 -0
  237. package/gallery/data/npm-audit.json +672 -0
  238. package/gallery/data/npm-outdated.json +179 -0
  239. package/gallery/data/oxlint.json +156 -0
  240. package/gallery/data/pip-outdated.json +68 -0
  241. package/gallery/data/pnpm-outdated.json +58 -0
  242. package/gallery/data/provenance/devicectl-install.json +35 -0
  243. package/gallery/data/provenance/install-timeout.log +4 -0
  244. package/gallery/data/provenance/iphone-install.json +72 -0
  245. package/gallery/data/provenance/iphone-no-receipt.json +57 -0
  246. package/gallery/data/provenance/live-home.jpg +0 -0
  247. package/gallery/data/provenance/live-probe.txt +10 -0
  248. package/gallery/data/provenance/xcodebuild-2811.log +19 -0
  249. package/gallery/data/provenance/xcodebuild-4712.log +19 -0
  250. package/gallery/data/pytest-junit.xml +64 -0
  251. package/gallery/data/python-org-trace.json +35 -0
  252. package/gallery/data/python-org.har +31 -0
  253. package/gallery/data/retry.ts +13 -0
  254. package/gallery/data/ruff.json +261 -0
  255. package/gallery/data/src/durations.py +63 -0
  256. package/gallery/data/src/json-logic.ts +156 -0
  257. package/gallery/data/src/mapMath.ts +135 -0
  258. package/gallery/data/src/semver/bump.go +73 -0
  259. package/gallery/data/traces/browser-error.json +5 -0
  260. package/gallery/data/traces/go/main.go +27 -0
  261. package/gallery/data/traces/go-panic.txt +16 -0
  262. package/gallery/data/traces/java-caused-by.txt +15 -0
  263. package/gallery/data/traces/node-aggregate.txt +30 -0
  264. package/gallery/data/traces/node-cause.txt +21 -0
  265. package/gallery/data/traces/py/deploy.json +5 -0
  266. package/gallery/data/traces/py/deploy.py +17 -0
  267. package/gallery/data/traces/py/settings.py +19 -0
  268. package/gallery/data/traces/python-chain.txt +30 -0
  269. package/gallery/data/traces/rust/main.rs +11 -0
  270. package/gallery/data/traces/rust-panic.txt +16 -0
  271. package/gallery/data/traces/shop/cart.mjs +19 -0
  272. package/gallery/data/traces/shop/pricing.mjs +12 -0
  273. package/gallery/data/traces/shop/status.mjs +9 -0
  274. package/gallery/data/traces/web/app.js +11 -0
  275. package/gallery/data/traces/web/index.html +1 -0
  276. package/gallery/data/traces/web/search.js +10 -0
  277. package/gallery/data/transcript/call-noisy.wav +0 -0
  278. package/gallery/data/transcript/call.json +362 -0
  279. package/gallery/data/transcript/call.wav +0 -0
  280. package/gallery/data/transcript/mlx-whisper-tiny.en-noisy.json +1 -0
  281. package/gallery/data/transcript/whisper-cpp-base.en-noisy.json +658 -0
  282. package/gallery/data/transcript/whisper-cpp-base.en.json +658 -0
  283. package/gallery/data/transcript/whisper-cpp-base.en.srt +16 -0
  284. package/gallery/data/transcript/whisper-cpp-tiny.en-noisy.json +619 -0
  285. package/gallery/data/tsc.txt +4 -0
  286. package/gallery/data/vitest-bench.json +119 -0
  287. package/gallery/data/vitest-junit.xml +133 -0
  288. package/gallery/data/vitest-report.json +612 -0
  289. package/gallery/images/bell.svg +10 -0
  290. package/gallery/images/bike.svg +47 -0
  291. package/gallery/images/footer-planet.webp +0 -0
  292. package/gallery/images/front-light.svg +13 -0
  293. package/gallery/images/golden-hour.webp +0 -0
  294. package/gallery/images/option-flowers.webp +0 -0
  295. package/gallery/images/option-hilltop.webp +0 -0
  296. package/gallery/images/option-meadow.webp +0 -0
  297. package/gallery/images/option-sky.webp +0 -0
  298. package/gallery/images/u-lock.svg +10 -0
  299. package/index.client.tsx +164 -0
  300. package/index.server.ts +216 -0
  301. package/package.json +50 -4
  302. package/paseo-plugin.json +14 -0
  303. package/server/data-source.ts +407 -0
  304. package/server/project.ts +981 -0
  305. package/server/web/address.ts +57 -0
  306. package/server/web/cache.ts +85 -0
  307. package/server/web/commons.ts +126 -0
  308. package/server/web/geocode.ts +40 -0
  309. package/server/web/image.ts +35 -0
  310. package/server/web/lookups.ts +248 -0
  311. package/server/web/net.ts +210 -0
  312. package/shared/core/acceptance.ts +744 -0
  313. package/shared/core/ast.ts +76 -0
  314. package/shared/core/benchmark.ts +743 -0
  315. package/shared/core/catalog.ts +733 -0
  316. package/shared/core/compare.ts +225 -0
  317. package/shared/core/coverage.ts +456 -0
  318. package/shared/core/data.ts +334 -0
  319. package/shared/core/decisions.ts +527 -0
  320. package/shared/core/dependencies.ts +604 -0
  321. package/shared/core/diagnostics.ts +24 -0
  322. package/shared/core/environments.ts +212 -0
  323. package/shared/core/evaluate.ts +737 -0
  324. package/shared/core/fields.ts +66 -0
  325. package/shared/core/findings.ts +1172 -0
  326. package/shared/core/form.ts +69 -0
  327. package/shared/core/index.ts +44 -0
  328. package/shared/core/junit.ts +252 -0
  329. package/shared/core/lexer.ts +275 -0
  330. package/shared/core/logs.ts +496 -0
  331. package/shared/core/markdown.ts +2374 -0
  332. package/shared/core/parser.ts +355 -0
  333. package/shared/core/perf-units.ts +228 -0
  334. package/shared/core/pipeline.ts +679 -0
  335. package/shared/core/project.ts +472 -0
  336. package/shared/core/provenance.ts +725 -0
  337. package/shared/core/stack-trace.ts +718 -0
  338. package/shared/core/streaming.ts +136 -0
  339. package/shared/core/test-results.ts +562 -0
  340. package/shared/core/thread-activity.ts +203 -0
  341. package/shared/core/tracking.ts +887 -0
  342. package/shared/core/transcript.ts +1364 -0
  343. package/shared/core/treemap.ts +446 -0
  344. package/shared/core/types.ts +70 -0
  345. package/shared/core/validate.ts +574 -0
  346. package/shared/core/waterfall.ts +536 -0
  347. package/shared/diagram/flowchart.ts +205 -0
  348. package/shared/diagram/layout.ts +440 -0
  349. package/shared/diagram/markdown.ts +135 -0
  350. package/shared/diagram/sequence.ts +122 -0
  351. package/shared/diagram/zoom.ts +114 -0
  352. package/shared/fallback.ts +120 -0
  353. package/shared/genui.test.ts +122 -0
  354. package/shared/instructions.ts +144 -0
  355. package/shared/logic/calendarMath.ts +130 -0
  356. package/shared/logic/chartMath.ts +289 -0
  357. package/shared/logic/choose-logic.ts +196 -0
  358. package/shared/logic/compare-logic.ts +270 -0
  359. package/shared/logic/content-model.ts +227 -0
  360. package/shared/logic/environments-logic.ts +53 -0
  361. package/shared/logic/explain-logic.ts +315 -0
  362. package/shared/logic/fieldLogic.ts +138 -0
  363. package/shared/logic/gallery-search.ts +71 -0
  364. package/shared/logic/icons.ts +322 -0
  365. package/shared/logic/json-logic.ts +153 -0
  366. package/shared/logic/mapMath.ts +133 -0
  367. package/shared/logic/project-logic.ts +39 -0
  368. package/shared/logic/references-model.ts +201 -0
  369. package/shared/logic/sketch.ts +165 -0
  370. package/shared/logic/supersession.ts +77 -0
  371. package/shared/logic/values.ts +133 -0
  372. package/shared/logic/widget-logic.ts +163 -0
  373. package/shared/rpc.ts +252 -0
  374. package/shared/segment.ts +81 -0
  375. package/shared/settings.ts +12 -0
@@ -0,0 +1,1364 @@
1
+ // Transcripts as Transcript compares them: tracks read from hand-written word
2
+ // lists or common ASR output (openai-whisper / WhisperKit / WhisperX JSON,
3
+ // whisper.cpp JSON, Deepgram, AssemblyAI, a generic {words}, SRT/WebVTT, plain
4
+ // text), normalised into comparable tokens, each hypothesis aligned word by
5
+ // word to the first track (the reference) with a Levenshtein alignment, and
6
+ // scored: substitutions, deletions, insertions, WER, speaker mismatches, and
7
+ // the error regions a reader steps through. Pure; shared by the renderer and
8
+ // the markdown fallback.
9
+ import { objectFields, type Fields } from "./perf-units";
10
+ import type { Value } from "./types";
11
+
12
+ /**
13
+ * How a word's time is known: `word` it has its own start and end; `segment`
14
+ * only the segment (subtitle cue, whisper segment) it sits in is timed, and
15
+ * its start and end are an even split of that window; `none` untimed.
16
+ */
17
+ export type Timing = "word" | "segment" | "none";
18
+
19
+ export interface Word {
20
+ readonly text: string;
21
+ /** Seconds from the start of the recording; null when untimed. */
22
+ readonly start: number | null;
23
+ readonly end: number | null;
24
+ readonly timing: Timing;
25
+ /** The timed window a `segment` word sits in. */
26
+ readonly window: { readonly start: number; readonly end: number } | null;
27
+ readonly speaker: string | null;
28
+ /** 0–1, as the tool reported it (probability, confidence, score). */
29
+ readonly confidence: number | null;
30
+ }
31
+
32
+ /** Where a track came from, for the quiet line under its name. */
33
+ export type TrackSource = "words" | "text" | "subtitles" | "whisper" | "whisper.cpp" | "deepgram" | "assemblyai" | "rows";
34
+
35
+ export interface Track {
36
+ readonly name: string;
37
+ readonly words: readonly Word[];
38
+ readonly source: TrackSource;
39
+ /** Model, language and other facts the file states, joined; null when none. */
40
+ readonly detail: string | null;
41
+ /** The best timing any of its words has. */
42
+ readonly timing: Timing;
43
+ /** Words with an end before their start, or times given in a way we could not read, left untimed. */
44
+ readonly untimed: number;
45
+ }
46
+
47
+ export type ReadTracks = { readonly ok: true; readonly tracks: readonly Track[] } | { readonly ok: false; readonly error: string };
48
+
49
+ /** Words a track may hold; a longer one is refused rather than aligned for minutes. */
50
+ export const MAX_WORDS = 60_000;
51
+
52
+ const TEXT_KEYS = ["punctuated_word", "word", "text", "content", "token"] as const;
53
+ const SPEAKER_KEYS = ["speaker", "speaker_label", "spk", "speakerId", "speaker_id"] as const;
54
+ const CONFIDENCE_KEYS = ["confidence", "probability", "score", "p"] as const;
55
+
56
+ function str(value: Value | undefined): string | null {
57
+ return typeof value === "string" ? value : typeof value === "number" && Number.isFinite(value) ? String(value) : null;
58
+ }
59
+
60
+ function num(value: Value | undefined): number | null {
61
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
62
+ if (typeof value === "string" && /^\s*-?\d+(\.\d+)?\s*$/.test(value)) return Number(value);
63
+ return null;
64
+ }
65
+
66
+ function first(fields: Fields, keys: readonly string[], read: (value: Value | undefined) => string | number | null): string | number | null {
67
+ for (const key of keys) {
68
+ const value = read(fields[key]);
69
+ if (value !== null) return value;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function speakerName(value: string | number | null): string | null {
75
+ if (value === null) return null;
76
+ const text = String(value).trim();
77
+ if (text === "") return null;
78
+ // Deepgram and AssemblyAI number or letter their speakers.
79
+ return /^\d+$/.test(text) || /^[A-Z]$/.test(text) ? `Speaker ${text}` : text;
80
+ }
81
+
82
+ /** A word's own or inherited fields read into a Word; null when it has no text. */
83
+ interface RawWord {
84
+ readonly text: string;
85
+ readonly start: number | null;
86
+ readonly end: number | null;
87
+ readonly speaker: string | null;
88
+ readonly confidence: number | null;
89
+ }
90
+
91
+ function rawWord(item: Value, inherited: string | null): RawWord | null {
92
+ if (typeof item === "string") {
93
+ const text = item.trim();
94
+ return text === "" ? null : { text, start: null, end: null, speaker: inherited, confidence: null };
95
+ }
96
+ const fields = objectFields(item);
97
+ if (fields === null) return null;
98
+ const text = (first(fields, TEXT_KEYS, str) as string | null)?.trim() ?? "";
99
+ if (text === "") return null;
100
+ const start = num(fields.start) ?? num(fields.start_time) ?? num(fields.from);
101
+ const end = num(fields.end) ?? num(fields.end_time) ?? num(fields.to);
102
+ const confidence = first(fields, CONFIDENCE_KEYS, num) as number | null;
103
+ return {
104
+ text,
105
+ start,
106
+ end,
107
+ speaker: speakerName(first(fields, SPEAKER_KEYS, str)) ?? inherited,
108
+ confidence: confidence !== null && confidence >= 0 && confidence <= 1 ? confidence : null,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Times in milliseconds rather than seconds: an explicit unit, else every
114
+ * time an integer and the last at least 1,000 (AssemblyAI, whisper's .tsv,
115
+ * whisper.cpp offsets). Recognisers that report seconds report fractions.
116
+ */
117
+ function inMilliseconds(times: readonly number[], unit: string | null): boolean {
118
+ if (unit !== null) return /^(ms|milliseconds?)$/i.test(unit.trim());
119
+ return times.length > 0 && times.every(Number.isInteger) && Math.max(...times) >= 1000;
120
+ }
121
+
122
+ /** Timed words from raw words, in seconds; words without a usable span come out untimed. */
123
+ function timedWords(raws: readonly RawWord[], unit: string | null): { words: Word[]; untimed: number } {
124
+ const times = raws.flatMap((raw) => [raw.start, raw.end]).filter((time): time is number => time !== null);
125
+ const scale = inMilliseconds(times, unit) ? 1 / 1000 : 1;
126
+ let untimed = 0;
127
+ const words = raws.map((raw): Word => {
128
+ const start = raw.start === null ? null : raw.start * scale;
129
+ const end = raw.end === null ? start : raw.end * scale;
130
+ const usable = start !== null && end !== null && start >= 0 && end >= start;
131
+ if (!usable && (raw.start !== null || raw.end !== null)) untimed++;
132
+ return {
133
+ text: raw.text,
134
+ start: usable ? start : null,
135
+ end: usable ? end : null,
136
+ timing: usable ? "word" : "none",
137
+ window: null,
138
+ speaker: raw.speaker,
139
+ confidence: raw.confidence,
140
+ };
141
+ });
142
+ return { words, untimed };
143
+ }
144
+
145
+ /** Splits a segment's text into words placed evenly (by characters) across its window. */
146
+ function segmentWords(text: string, start: number | null, end: number | null, speaker: string | null): Word[] {
147
+ const pieces = text.split(/\s+/).filter((piece) => piece !== "");
148
+ if (start === null || end === null || !(end >= start) || start < 0) {
149
+ return pieces.map((piece) => ({ text: piece, start: null, end: null, timing: "none", window: null, speaker, confidence: null }));
150
+ }
151
+ if (pieces.length === 1) return [{ text: pieces[0], start, end, timing: "word", window: null, speaker, confidence: null }];
152
+ const total = pieces.reduce((sum, piece) => sum + piece.length + 1, 0);
153
+ let at = 0;
154
+ return pieces.map((piece) => {
155
+ const from = start + ((end - start) * at) / total;
156
+ at += piece.length + 1;
157
+ const to = start + ((end - start) * at) / total;
158
+ return { text: piece, start: from, end: to, timing: "segment", window: { start, end }, speaker, confidence: null };
159
+ });
160
+ }
161
+
162
+ function bestTiming(words: readonly Word[]): Timing {
163
+ if (words.some((word) => word.timing === "word")) return "word";
164
+ if (words.some((word) => word.timing === "segment")) return "segment";
165
+ return "none";
166
+ }
167
+
168
+ /** SRT or WebVTT cues ("00:01.000 --> 00:04.000"), WebVTT `<v Name>` voices as speakers. */
169
+ const CUE_TIME = /^\s*((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})\s*-->\s*((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})/;
170
+
171
+ function cueSeconds(text: string): number {
172
+ const parts = text.replace(",", ".").split(":").map(Number);
173
+ return parts.reduce((sum, part) => sum * 60 + part, 0);
174
+ }
175
+
176
+ function subtitleWords(text: string): Word[] {
177
+ const words: Word[] = [];
178
+ const lines = text.split(/\r?\n/);
179
+ for (let index = 0; index < lines.length; index++) {
180
+ const match = CUE_TIME.exec(lines[index]);
181
+ if (match === null) continue;
182
+ const start = cueSeconds(match[1]);
183
+ const end = cueSeconds(match[2]);
184
+ const body: string[] = [];
185
+ while (index + 1 < lines.length && lines[index + 1].trim() !== "" && !CUE_TIME.test(lines[index + 1])) body.push(lines[++index]);
186
+ let cue = body.join(" ");
187
+ const voice = /<v(?:\.[^\s>]*)?\s+([^>]+)>/.exec(cue);
188
+ cue = cue.replace(/<[^>]*>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&nbsp;/g, " ");
189
+ words.push(...segmentWords(cue, start, end, voice === null ? null : voice[1].trim()));
190
+ }
191
+ return words;
192
+ }
193
+
194
+ interface Content {
195
+ readonly words: Word[];
196
+ readonly source: TrackSource;
197
+ readonly detail: string | null;
198
+ readonly name: string | null;
199
+ readonly untimed: number;
200
+ }
201
+
202
+ function details(...parts: (string | null | undefined)[]): string | null {
203
+ const kept = parts.filter((part): part is string => typeof part === "string" && part.trim() !== "");
204
+ return kept.length === 0 ? null : kept.join(" · ");
205
+ }
206
+
207
+ /** whisper.cpp `-oj`/`-ojf`: transcription[] with ms offsets; tokens (with -ojf) give word times. */
208
+ function whisperCpp(fields: Fields, list: readonly Value[]): Content {
209
+ const words: Word[] = [];
210
+ for (const entry of list) {
211
+ const segment = objectFields(entry);
212
+ if (segment === null) continue;
213
+ const offsets = objectFields(segment.offsets);
214
+ const from = offsets === null ? null : num(offsets.from);
215
+ const to = offsets === null ? null : num(offsets.to);
216
+ const tokens = Array.isArray(segment.tokens) ? segment.tokens : [];
217
+ const built: { text: string; start: number | null; end: number | null; p: number[] }[] = [];
218
+ for (const token of tokens) {
219
+ const item = objectFields(token);
220
+ const text = item === null ? null : str(item.text);
221
+ if (item === null || text === null || /^\[_[A-Z]+_?.*\]$/.test(text.trim()) || text.trim() === "") continue;
222
+ const tokenOffsets = objectFields(item.offsets);
223
+ const start = tokenOffsets === null ? null : num(tokenOffsets.from);
224
+ const end = tokenOffsets === null ? null : num(tokenOffsets.to);
225
+ const p = num(item.p);
226
+ const current = built[built.length - 1];
227
+ if (current === undefined || /^\s/.test(text)) built.push({ text: text.trim(), start, end, p: p === null ? [] : [p] });
228
+ else {
229
+ current.text += text;
230
+ current.end = end ?? current.end;
231
+ if (p !== null) current.p.push(p);
232
+ }
233
+ }
234
+ if (built.length > 0) {
235
+ for (const word of built) {
236
+ const usable = word.start !== null && word.end !== null && word.end >= word.start;
237
+ words.push({
238
+ text: word.text,
239
+ start: usable ? word.start! / 1000 : null,
240
+ end: usable ? word.end! / 1000 : null,
241
+ timing: usable ? "word" : "none",
242
+ window: null,
243
+ speaker: null,
244
+ confidence: word.p.length === 0 ? null : word.p.reduce((sum, p) => sum + p, 0) / word.p.length,
245
+ });
246
+ }
247
+ continue;
248
+ }
249
+ // `-ml 1` prints one word per entry; longer entries are segments.
250
+ const text = str(segment.text) ?? "";
251
+ const speaker = speakerName(str(segment.speaker));
252
+ words.push(...segmentWords(text, from === null ? null : from / 1000, to === null ? null : to / 1000, speaker));
253
+ }
254
+ const params = objectFields(fields.params);
255
+ const model = params === null ? null : str(params.model);
256
+ const result = objectFields(fields.result);
257
+ const name = model === null ? "whisper.cpp" : `whisper.cpp ${model.replace(/^.*[\\/]/, "").replace(/^ggml-/, "").replace(/\.bin$/, "")}`;
258
+ return { words, source: "whisper.cpp", detail: details(result === null ? null : str(result.language)), name, untimed: 0 };
259
+ }
260
+
261
+ /** openai-whisper, WhisperKit, WhisperX, mlx-whisper, faster-whisper dumps: segments[] with words[] when word timestamps were on. */
262
+ function whisperSegments(fields: Fields, list: readonly Value[], unit: string | null): Content {
263
+ const raws: RawWord[] = [];
264
+ const segmentOnly: Word[] = [];
265
+ let timedByWords = false;
266
+ for (const entry of list) {
267
+ const segment = objectFields(entry);
268
+ if (segment === null) continue;
269
+ const speaker = speakerName(first(segment, SPEAKER_KEYS, str));
270
+ if (Array.isArray(segment.words) && segment.words.length > 0) {
271
+ timedByWords = true;
272
+ for (const word of segment.words) {
273
+ const raw = rawWord(word, speaker);
274
+ if (raw !== null) raws.push(raw);
275
+ }
276
+ } else {
277
+ const start = num(segment.start);
278
+ const end = num(segment.end);
279
+ for (const word of segmentWords(str(segment.text) ?? "", start, end, speaker)) {
280
+ segmentOnly.push(word);
281
+ raws.push({ text: word.text, start: null, end: null, speaker, confidence: null });
282
+ }
283
+ }
284
+ }
285
+ if (!timedByWords) {
286
+ const scale = inMilliseconds(segmentOnly.flatMap((word) => (word.window === null ? [] : [word.window.start, word.window.end])), unit) ? 1 / 1000 : 1;
287
+ const words = scale === 1 ? segmentOnly : segmentOnly.map((word) => scaleWord(word, scale));
288
+ return { words, source: "whisper", detail: details(str(fields.language), str(fields.model)), name: str(fields.model), untimed: 0 };
289
+ }
290
+ const { words, untimed } = timedWords(raws, unit);
291
+ return { words, source: "whisper", detail: details(str(fields.language), str(fields.model)), name: str(fields.model), untimed };
292
+ }
293
+
294
+ function scaleWord(word: Word, scale: number): Word {
295
+ return {
296
+ ...word,
297
+ start: word.start === null ? null : word.start * scale,
298
+ end: word.end === null ? null : word.end * scale,
299
+ window: word.window === null ? null : { start: word.window.start * scale, end: word.window.end * scale },
300
+ };
301
+ }
302
+
303
+ /** A list of word objects, or rows of segments (whisper's .tsv: start, end, text in ms). */
304
+ function wordList(list: readonly Value[], unit: string | null): Content {
305
+ const rows = list.map((item) => objectFields(item));
306
+ const multiWord = rows.some((row) => row !== null && typeof row.text === "string" && /\S\s+\S/.test(row.text.trim()) && row.word === undefined);
307
+ if (multiWord) {
308
+ const times = rows.flatMap((row) => (row === null ? [] : [num(row.start), num(row.end)])).filter((time): time is number => time !== null);
309
+ const scale = inMilliseconds(times, unit) ? 1 / 1000 : 1;
310
+ const words = rows.flatMap((row) => {
311
+ if (row === null) return [];
312
+ const start = num(row.start);
313
+ const end = num(row.end);
314
+ return segmentWords(str(row.text) ?? "", start === null ? null : start * scale, end === null ? null : end * scale, speakerName(first(row, SPEAKER_KEYS, str)));
315
+ });
316
+ return { words, source: "rows", detail: null, name: null, untimed: 0 };
317
+ }
318
+ const raws = list.map((item) => rawWord(item, null)).filter((raw): raw is RawWord => raw !== null);
319
+ const { words, untimed } = timedWords(raws, unit);
320
+ return { words, source: "words", detail: null, name: null, untimed };
321
+ }
322
+
323
+ /** Plain text or subtitles. */
324
+ function textContent(text: string): Content {
325
+ if (text.split(/\r?\n/, 40).some((line) => CUE_TIME.test(line))) return { words: subtitleWords(text), source: "subtitles", detail: null, name: null, untimed: 0 };
326
+ const words = text
327
+ .replace(/^WEBVTT.*$/m, "")
328
+ .split(/\s+/)
329
+ .filter((piece) => piece !== "")
330
+ .map((piece): Word => ({ text: piece, start: null, end: null, timing: "none", window: null, speaker: null, confidence: null }));
331
+ return { words, source: "text", detail: null, name: null, untimed: 0 };
332
+ }
333
+
334
+ /** Any one transcript: a string, a list of words or segment rows, or a tool's JSON object. */
335
+ function content(value: Value | undefined, unit: string | null): Content | null {
336
+ if (typeof value === "string") return textContent(value);
337
+ if (Array.isArray(value)) return wordList(value, unit);
338
+ const fields = objectFields(value);
339
+ if (fields === null) return null;
340
+ const ownUnit = str(fields.unit) ?? unit;
341
+ if (Array.isArray(fields.transcription)) return whisperCpp(fields, fields.transcription);
342
+ // Deepgram: results.channels[0].alternatives[0].words (seconds).
343
+ const results = objectFields(fields.results);
344
+ if (results !== null && Array.isArray(results.channels)) {
345
+ const channel = objectFields(results.channels[0]);
346
+ const alternative = channel !== null && Array.isArray(channel.alternatives) ? objectFields(channel.alternatives[0]) : null;
347
+ if (alternative !== null && Array.isArray(alternative.words)) {
348
+ const metadata = objectFields(fields.metadata);
349
+ const models = metadata === null ? null : objectFields(metadata.model_info);
350
+ const model = models === null ? null : objectFields(Object.values(models)[0]);
351
+ const read = wordList(alternative.words, ownUnit ?? "s");
352
+ return { ...read, source: "deepgram", name: model === null ? "Deepgram" : `Deepgram ${str(model.name) ?? ""}`.trim(), detail: details(str(channel?.detected_language)) };
353
+ }
354
+ }
355
+ if (Array.isArray(fields.segments) && fields.segments.some((segment) => objectFields(segment) !== null)) {
356
+ // WhisperX writes word_segments too; segments keep the speakers.
357
+ return whisperSegments(fields, fields.segments, ownUnit);
358
+ }
359
+ if (Array.isArray(fields.words)) {
360
+ // AssemblyAI: words[] in ms beside audio_duration in seconds.
361
+ const assembly = typeof fields.audio_duration === "number" || typeof fields.acoustic_model === "string" || typeof fields.speech_model === "string";
362
+ const read = wordList(fields.words, ownUnit ?? (assembly ? "ms" : null));
363
+ return { ...read, source: assembly ? "assemblyai" : read.source, name: assembly ? "AssemblyAI" : null, detail: details(str(fields.language), str(fields.language_code), str(fields.model)) };
364
+ }
365
+ if (Array.isArray(fields.word_segments)) return { ...wordList(fields.word_segments, ownUnit), source: "whisper" };
366
+ if (typeof fields.text === "string") return textContent(fields.text);
367
+ return null;
368
+ }
369
+
370
+ function trackOf(value: Value, position: number): Track | string {
371
+ const fields = typeof value === "string" || Array.isArray(value) ? null : objectFields(value);
372
+ const unit = fields === null ? null : str(fields.unit);
373
+ // A track is {name, words | text | segments | transcription …} or a tool's JSON as it stands; `words` may itself be a tool's JSON.
374
+ const inner = fields !== null && fields.words !== undefined && !Array.isArray(fields.words) ? fields.words : value;
375
+ const read = content(inner, unit);
376
+ if (read === null) return `track ${position + 1} has no words, text or segments`;
377
+ if (read.words.length > MAX_WORDS) return `track ${position + 1} has ${read.words.length.toLocaleString("en-US")} words; at most ${MAX_WORDS.toLocaleString("en-US")} can be compared`;
378
+ const given = fields === null ? null : (str(fields.name) ?? str(fields.label) ?? str(fields.title));
379
+ const name = given?.trim() || read.name || (position === 0 ? "Reference" : `Transcript ${position + 1}`);
380
+ const detail = details(fields === null ? null : (str(fields.model) === name ? null : str(fields.model)), fields === null ? null : str(fields.detail), read.detail);
381
+ return { name, words: read.words, source: read.source, detail, timing: bestTiming(read.words), untimed: read.untimed };
382
+ }
383
+
384
+ /** A list item that is one word rather than one track. */
385
+ function looksLikeWord(item: Value): boolean {
386
+ const fields = objectFields(item);
387
+ if (fields === null) return false;
388
+ if (fields.words !== undefined || fields.segments !== undefined || fields.transcription !== undefined || fields.name !== undefined) return false;
389
+ return (num(fields.start) !== null || num(fields.end) !== null) && (typeof fields.word === "string" || typeof fields.text === "string");
390
+ }
391
+
392
+ /**
393
+ * Reads Transcript's `tracks`: a list of tracks (the first is the reference),
394
+ * `{tracks: [...]}`, or one transcript on its own (a tool's JSON, a word
395
+ * list, text). Never throws.
396
+ */
397
+ export function readTracks(value: Value | undefined): ReadTracks {
398
+ if (value === undefined || value === null) return { ok: false, error: "Transcript needs tracks: [{name, words: [{text, start, end}]} | {name, text}]" };
399
+ if (Array.isArray(value) && value.length === 0) return { ok: true, tracks: [] };
400
+ const fields = objectFields(value);
401
+ let items: readonly Value[];
402
+ if (fields !== null && Array.isArray(fields.tracks)) items = fields.tracks;
403
+ // A list of tracks, unless it is one track's words (word objects, or single-word strings).
404
+ else if (Array.isArray(value) && !looksLikeWord(value[0]) && !value.every((item) => typeof item === "string" && !/\s/.test(item.trim()))) items = value;
405
+ else items = [value];
406
+ const tracks: Track[] = [];
407
+ for (const [position, item] of items.entries()) {
408
+ const track = trackOf(item, position);
409
+ if (typeof track === "string") return { ok: false, error: track };
410
+ tracks.push(track);
411
+ }
412
+ return { ok: true, tracks };
413
+ }
414
+
415
+ // ——— Normalisation ———
416
+
417
+ /** A comparable unit: one normalised word, or a spoken number joined from several words. */
418
+ export interface Token {
419
+ readonly key: string;
420
+ /** The track's words it came from (inclusive). */
421
+ readonly from: number;
422
+ readonly to: number;
423
+ }
424
+
425
+ const UNITS: Readonly<Record<string, number>> = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9 };
426
+ const TEENS: Readonly<Record<string, number>> = {
427
+ ten: 10,
428
+ eleven: 11,
429
+ twelve: 12,
430
+ thirteen: 13,
431
+ fourteen: 14,
432
+ fifteen: 15,
433
+ sixteen: 16,
434
+ seventeen: 17,
435
+ eighteen: 18,
436
+ nineteen: 19,
437
+ };
438
+ const TENS: Readonly<Record<string, number>> = { twenty: 20, thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90 };
439
+ const SCALES: Readonly<Record<string, number>> = { thousand: 1e3, million: 1e6, billion: 1e9 };
440
+ const ORDINALS: Readonly<Record<string, string>> = {
441
+ first: "one",
442
+ second: "two",
443
+ third: "three",
444
+ fifth: "five",
445
+ eighth: "eight",
446
+ ninth: "nine",
447
+ twelfth: "twelve",
448
+ };
449
+
450
+ /** An ordinal number word as its cardinal ("twentieth" → "twenty"), or null. */
451
+ function cardinalOf(word: string): string | null {
452
+ if (Object.hasOwn(ORDINALS, word)) return ORDINALS[word];
453
+ if (word.endsWith("ieth")) {
454
+ const base = `${word.slice(0, -4)}y`;
455
+ return Object.hasOwn(TENS, base) ? base : null;
456
+ }
457
+ if (word.endsWith("th")) {
458
+ const base = word.slice(0, -2);
459
+ return Object.hasOwn(UNITS, base) || Object.hasOwn(TEENS, base) || Object.hasOwn(SCALES, base) || base === "hundred" ? base : null;
460
+ }
461
+ return null;
462
+ }
463
+
464
+ type NumberKind = "unit" | "teen" | "ten" | "hundred" | "scale" | "zero";
465
+
466
+ function numberKind(word: string): NumberKind | null {
467
+ if (word === "zero") return "zero";
468
+ if (Object.hasOwn(UNITS, word)) return "unit";
469
+ if (Object.hasOwn(TEENS, word)) return "teen";
470
+ if (Object.hasOwn(TENS, word)) return "ten";
471
+ if (word === "hundred") return "hundred";
472
+ if (Object.hasOwn(SCALES, word)) return "scale";
473
+ return null;
474
+ }
475
+
476
+ interface Piece {
477
+ readonly text: string;
478
+ readonly word: number;
479
+ }
480
+
481
+ /** One word's text as normalised pieces: lower case, no punctuation, hyphens split, "19:49" as 1949, 50% as "50 percent". */
482
+ function pieces(text: string): string[] {
483
+ return text
484
+ .normalize("NFKC")
485
+ .toLowerCase()
486
+ .replace(/[\u2018\u2019\u02bc]/g, "'")
487
+ .replace(/(\d),(?=\d{3}\b)/g, "$1")
488
+ .replace(/(\d):(?=\d{2}\b)/g, "$1")
489
+ .replace(/(\d)\s*%/g, "$1 percent")
490
+ .replace(/&/g, " and ")
491
+ .replace(/'/g, "")
492
+ .replace(/(\d)\.(?=\d)/g, "$1\u0000")
493
+ .replace(/[^\p{L}\p{N}\p{M}\u0000]+/gu, " ")
494
+ .replace(/\u0000/g, ".")
495
+ .split(" ")
496
+ .filter((piece) => piece !== "");
497
+ }
498
+
499
+ /**
500
+ * A spoken number starting at `at`, as digits: "twenty five" → 25,
501
+ * "one hundred and five" → 105, "two thousand twenty four" → 2024, and the
502
+ * way years and clock times are said: "nineteen forty nine" → 1949 (which
503
+ * also matches "19:49"), "nine oh five" → 905. Ordinals keep a marker so
504
+ * "twenty first" matches "21st". Null when no number starts there.
505
+ */
506
+ function spokenNumber(list: readonly Piece[], at: number): { key: string; next: number } | null {
507
+ const group = numberGroup(list, at);
508
+ if (group === null) return null;
509
+ // Year and clock style: a group under 100 followed by a 10–99 group, or "oh" and a digit.
510
+ if (!group.ordinal && !group.scaled && group.value > 0 && group.value < 100 && group.next < list.length) {
511
+ const word = list[group.next].text;
512
+ if (word === "oh" && group.next + 1 < list.length && Object.hasOwn(UNITS, list[group.next + 1].text)) {
513
+ const unit = UNITS[list[group.next + 1].text];
514
+ return { key: `${group.value}0${unit}`, next: group.next + 2 };
515
+ }
516
+ const kind = numberKind(word) ?? numberKind(cardinalOf(word) ?? "");
517
+ if (kind === "teen" || kind === "ten") {
518
+ const second = numberGroup(list, group.next);
519
+ if (second !== null && !second.scaled && second.value >= 10 && second.value < 100) {
520
+ return { key: `${group.value}${second.value}${second.ordinal ? "th" : ""}`, next: second.next };
521
+ }
522
+ }
523
+ }
524
+ return { key: `${group.value}${group.ordinal ? "th" : ""}`, next: group.next };
525
+ }
526
+
527
+ function numberGroup(list: readonly Piece[], at: number): { value: number; next: number; ordinal: boolean; scaled: boolean } | null {
528
+ let total = 0;
529
+ let current = 0;
530
+ let last: NumberKind | null = null;
531
+ let lastScale = Infinity;
532
+ let index = at;
533
+ let ordinal = false;
534
+ let scaled = false;
535
+ while (index < list.length) {
536
+ let word = list[index].text;
537
+ if (word === "and" && (last === "hundred" || last === "scale") && index + 1 < list.length) {
538
+ const after = numberKind(list[index + 1].text) ?? numberKind(cardinalOf(list[index + 1].text) ?? "");
539
+ if (after === "unit" || after === "teen" || after === "ten") {
540
+ index++;
541
+ continue;
542
+ }
543
+ break;
544
+ }
545
+ const cardinal = numberKind(word) === null ? cardinalOf(word) : null;
546
+ if (cardinal !== null) word = cardinal;
547
+ const kind = numberKind(word);
548
+ if (kind === null) break;
549
+ let accepted = true;
550
+ switch (kind) {
551
+ case "zero":
552
+ if (last !== null) accepted = false;
553
+ else {
554
+ index++;
555
+ return { value: 0, next: index, ordinal: false, scaled: false };
556
+ }
557
+ break;
558
+ case "unit":
559
+ if (last === null || last === "ten" || last === "hundred" || last === "scale") current += UNITS[word];
560
+ else accepted = false;
561
+ break;
562
+ case "teen":
563
+ case "ten":
564
+ if (last === null || last === "hundred" || last === "scale") current += kind === "teen" ? TEENS[word] : TENS[word];
565
+ else accepted = false;
566
+ break;
567
+ case "hundred":
568
+ if ((last === "unit" || last === "teen") && current < 100) {
569
+ current *= 100;
570
+ scaled = true;
571
+ } else accepted = false;
572
+ break;
573
+ case "scale": {
574
+ const scale = SCALES[word];
575
+ if (last !== null && last !== "scale" && last !== "zero" && scale < lastScale) {
576
+ total += current * scale;
577
+ current = 0;
578
+ lastScale = scale;
579
+ scaled = true;
580
+ } else accepted = false;
581
+ break;
582
+ }
583
+ }
584
+ if (!accepted) break;
585
+ last = kind;
586
+ index++;
587
+ if (cardinal !== null) {
588
+ ordinal = true;
589
+ break;
590
+ }
591
+ }
592
+ if (last === null) return null;
593
+ return { value: total + current, next: index, ordinal, scaled };
594
+ }
595
+
596
+ /** Ordinals written with digits ("21st") keep the same marker as spoken ones. */
597
+ function digitKey(text: string): string {
598
+ const ordinal = /^(\d+)(st|nd|rd|th)$/.exec(text);
599
+ return ordinal === null ? text : `${Number(ordinal[1])}th`;
600
+ }
601
+
602
+ /**
603
+ * The comparable tokens of a track's words. WER counts these: hyphenated
604
+ * words count per part, a spoken number as one ("twenty five" = "25").
605
+ */
606
+ export function tokenize(words: readonly { readonly text: string }[]): Token[] {
607
+ const list: Piece[] = [];
608
+ words.forEach((word, index) => {
609
+ for (const text of pieces(word.text)) list.push({ text, word: index });
610
+ });
611
+ const tokens: Token[] = [];
612
+ let index = 0;
613
+ while (index < list.length) {
614
+ const piece = list[index];
615
+ const spoken = spokenNumber(list, index);
616
+ if (spoken !== null) {
617
+ tokens.push({ key: spoken.key, from: piece.word, to: list[spoken.next - 1].word });
618
+ index = spoken.next;
619
+ continue;
620
+ }
621
+ tokens.push({ key: /^\d/.test(piece.text) ? digitKey(piece.text) : piece.text, from: piece.word, to: piece.word });
622
+ index++;
623
+ }
624
+ return tokens;
625
+ }
626
+
627
+ // ——— Alignment ———
628
+
629
+ export type OpKind = "match" | "sub" | "del" | "ins";
630
+
631
+ /** One step of an alignment: token indexes into the reference and the hypothesis. */
632
+ export interface Op {
633
+ readonly kind: OpKind;
634
+ readonly ref: number | null;
635
+ readonly hyp: number | null;
636
+ }
637
+
638
+ /**
639
+ * Optional tie-breaks among alignments with the fewest errors: a 0–1 penalty
640
+ * for pairing reference token `ref` with hypothesis token `hyp` (time
641
+ * distance where both are timed), so "hello hello" pairs in order and a
642
+ * substitution lands on the word said at that moment.
643
+ */
644
+ export type PairPenalty = (ref: number, hyp: number) => number;
645
+
646
+ /** Cells one dynamic-programming table may hold before the alignment splits on anchors or narrows to a band. */
647
+ const MAX_CELLS = 4_000_000;
648
+
649
+ const DIAGONAL = 0;
650
+ const UP = 1; // reference token deleted
651
+ const LEFT = 2; // hypothesis token inserted
652
+
653
+ /**
654
+ * A minimum-edit-distance alignment of two token sequences (substitution,
655
+ * deletion and insertion cost 1). Long inputs are split on anchors (tokens
656
+ * that occur exactly once in both, in order), then aligned piecewise, and a
657
+ * piece still too large is aligned inside a diagonal band.
658
+ */
659
+ export function align(ref: readonly Token[], hyp: readonly Token[], penalty: PairPenalty | null = null): Op[] {
660
+ const ops: Op[] = [];
661
+ alignRange(ref, hyp, 0, ref.length, 0, hyp.length, penalty, ops, 0);
662
+ return ops;
663
+ }
664
+
665
+ function alignRange(
666
+ ref: readonly Token[],
667
+ hyp: readonly Token[],
668
+ r0: number,
669
+ r1: number,
670
+ h0: number,
671
+ h1: number,
672
+ penalty: PairPenalty | null,
673
+ out: Op[],
674
+ depth: number,
675
+ ): void {
676
+ // Common head and tail match outright.
677
+ while (r0 < r1 && h0 < h1 && ref[r0].key === hyp[h0].key && (penalty === null || penalty(r0, h0) < 0.5)) {
678
+ out.push({ kind: "match", ref: r0++, hyp: h0++ });
679
+ }
680
+ const tail: Op[] = [];
681
+ while (r1 > r0 && h1 > h0 && ref[r1 - 1].key === hyp[h1 - 1].key && (penalty === null || penalty(r1 - 1, h1 - 1) < 0.5)) {
682
+ tail.push({ kind: "match", ref: --r1, hyp: --h1 });
683
+ }
684
+ const n = r1 - r0;
685
+ const m = h1 - h0;
686
+ if (n === 0 || m === 0) {
687
+ for (let index = r0; index < r1; index++) out.push({ kind: "del", ref: index, hyp: null });
688
+ for (let index = h0; index < h1; index++) out.push({ kind: "ins", ref: null, hyp: index });
689
+ } else if ((n + 1) * (m + 1) <= MAX_CELLS) {
690
+ table(ref, hyp, r0, r1, h0, h1, penalty, out, Infinity);
691
+ } else {
692
+ const anchors = depth < 8 ? uniqueAnchors(ref, hyp, r0, r1, h0, h1) : [];
693
+ if (anchors.length === 0) {
694
+ table(ref, hyp, r0, r1, h0, h1, penalty, out, Math.max(Math.abs(n - m) + 64, Math.floor(MAX_CELLS / (n + 1) / 2)));
695
+ } else {
696
+ let r = r0;
697
+ let h = h0;
698
+ for (const [ra, ha] of anchors) {
699
+ alignRange(ref, hyp, r, ra, h, ha, penalty, out, depth + 1);
700
+ out.push({ kind: "match", ref: ra, hyp: ha });
701
+ r = ra + 1;
702
+ h = ha + 1;
703
+ }
704
+ alignRange(ref, hyp, r, r1, h, h1, penalty, out, depth + 1);
705
+ }
706
+ }
707
+ for (let index = tail.length - 1; index >= 0; index--) out.push(tail[index]);
708
+ }
709
+
710
+ /** Tokens unique on both sides within the ranges, longest increasing run of them (patience diff). */
711
+ function uniqueAnchors(ref: readonly Token[], hyp: readonly Token[], r0: number, r1: number, h0: number, h1: number): [number, number][] {
712
+ const count = (tokens: readonly Token[], from: number, to: number) => {
713
+ const seen = new Map<string, number>();
714
+ for (let index = from; index < to; index++) seen.set(tokens[index].key, seen.has(tokens[index].key) ? -1 : index);
715
+ return seen;
716
+ };
717
+ const inRef = count(ref, r0, r1);
718
+ const inHyp = count(hyp, h0, h1);
719
+ const pairs: [number, number][] = [];
720
+ for (let index = r0; index < r1; index++) {
721
+ const key = ref[index].key;
722
+ const there = inHyp.get(key);
723
+ if (inRef.get(key) === index && there !== undefined && there >= 0) pairs.push([index, there]);
724
+ }
725
+ // Longest increasing subsequence on the hypothesis positions.
726
+ const tails: number[] = [];
727
+ const previous = new Array<number>(pairs.length).fill(-1);
728
+ for (let index = 0; index < pairs.length; index++) {
729
+ let low = 0;
730
+ let high = tails.length;
731
+ while (low < high) {
732
+ const middle = (low + high) >> 1;
733
+ if (pairs[tails[middle]][1] < pairs[index][1]) low = middle + 1;
734
+ else high = middle;
735
+ }
736
+ if (low > 0) previous[index] = tails[low - 1];
737
+ tails[low] = index;
738
+ }
739
+ const chain: [number, number][] = [];
740
+ for (let index = tails.length === 0 ? -1 : tails[tails.length - 1]; index >= 0; index = previous[index]) chain.push(pairs[index]);
741
+ return chain.reverse();
742
+ }
743
+
744
+ /** The edit-distance table over the ranges, within `band` of the diagonal, and its backtrace. */
745
+ function table(
746
+ ref: readonly Token[],
747
+ hyp: readonly Token[],
748
+ r0: number,
749
+ r1: number,
750
+ h0: number,
751
+ h1: number,
752
+ penalty: PairPenalty | null,
753
+ out: Op[],
754
+ band: number,
755
+ ): void {
756
+ const n = r1 - r0;
757
+ const m = h1 - h0;
758
+ // Tie-breaks stay below one error in total, so they never change the error count.
759
+ const epsilon = 0.5 / (n + m + 1);
760
+ const width = m + 1;
761
+ const moves = new Uint8Array((n + 1) * width);
762
+ let previous = new Float64Array(width);
763
+ let current = new Float64Array(width);
764
+ for (let j = 0; j <= m; j++) {
765
+ previous[j] = j;
766
+ moves[j] = LEFT;
767
+ }
768
+ for (let i = 1; i <= n; i++) {
769
+ const centre = Math.round((i * m) / n);
770
+ const low = Math.max(1, centre - band);
771
+ const high = Math.min(m, centre + band);
772
+ current.fill(Infinity);
773
+ current[0] = i;
774
+ moves[i * width] = UP;
775
+ const key = ref[r0 + i - 1].key;
776
+ for (let j = low; j <= high; j++) {
777
+ const same = key === hyp[h0 + j - 1].key;
778
+ const tie = penalty === null ? (same ? 0 : 0.5) : penalty(r0 + i - 1, h0 + j - 1);
779
+ const diagonal = previous[j - 1] + (same ? 0 : 1) + epsilon * tie;
780
+ const up = previous[j] + 1 + epsilon * 0.75;
781
+ const left = current[j - 1] + 1 + epsilon * 0.75;
782
+ let best = diagonal;
783
+ let move = DIAGONAL;
784
+ if (up < best) {
785
+ best = up;
786
+ move = UP;
787
+ }
788
+ if (left < best) {
789
+ best = left;
790
+ move = LEFT;
791
+ }
792
+ current[j] = best;
793
+ moves[i * width + j] = move;
794
+ }
795
+ [previous, current] = [current, previous];
796
+ }
797
+ const steps: Op[] = [];
798
+ let i = n;
799
+ let j = m;
800
+ while (i > 0 || j > 0) {
801
+ const move = i === 0 ? LEFT : j === 0 ? UP : moves[i * width + j];
802
+ if (move === DIAGONAL) {
803
+ const kind = ref[r0 + i - 1].key === hyp[h0 + j - 1].key ? "match" : "sub";
804
+ steps.push({ kind, ref: r0 + i - 1, hyp: h0 + j - 1 });
805
+ i--;
806
+ j--;
807
+ } else if (move === UP) {
808
+ steps.push({ kind: "del", ref: r0 + i - 1, hyp: null });
809
+ i--;
810
+ } else {
811
+ steps.push({ kind: "ins", ref: null, hyp: h0 + j - 1 });
812
+ j--;
813
+ }
814
+ }
815
+ for (let index = steps.length - 1; index >= 0; index--) out.push(steps[index]);
816
+ }
817
+
818
+ // ——— Scoring ———
819
+
820
+ export type HypStatus = "ok" | "sub" | "ins";
821
+ export type RefStatus = "ok" | "sub" | "del";
822
+
823
+ export interface Counts {
824
+ /** Reference tokens: WER's denominator. */
825
+ readonly words: number;
826
+ readonly hits: number;
827
+ readonly substitutions: number;
828
+ readonly deletions: number;
829
+ readonly insertions: number;
830
+ /** Aligned words whose speaker differs (after mapping labels); null when the two tracks don't both name speakers. */
831
+ readonly speaker: number | null;
832
+ }
833
+
834
+ export type RegionKind = "sub" | "del" | "ins" | "mixed" | "speaker";
835
+
836
+ /** A run of errors in one hypothesis, as a reader steps through them. */
837
+ export interface Region {
838
+ readonly track: number;
839
+ readonly kind: RegionKind;
840
+ /** Reference words involved (inclusive), and the hypothesis words. */
841
+ readonly ref: readonly [number, number] | null;
842
+ readonly hyp: readonly [number, number] | null;
843
+ /** Seconds, from the reference's times where it is timed, else the hypothesis's; null when neither is. */
844
+ readonly start: number | null;
845
+ readonly end: number | null;
846
+ readonly refText: string;
847
+ readonly hypText: string;
848
+ /** For speaker regions: the reference speaker and the one the hypothesis gave. */
849
+ readonly speakers: readonly [string, string] | null;
850
+ }
851
+
852
+ export interface Comparison {
853
+ /** Index of the hypothesis among the tracks (≥ 1). */
854
+ readonly track: number;
855
+ readonly ops: readonly Op[];
856
+ readonly counts: Counts;
857
+ /** (S + D + I) / reference words; null when the reference has no words. */
858
+ readonly wer: number | null;
859
+ readonly hypStatus: readonly HypStatus[];
860
+ /** Per reference word, as this hypothesis has it. */
861
+ readonly refStatus: readonly RefStatus[];
862
+ /** Per hypothesis word, the reference word it is aligned to (match or substitution), else null. */
863
+ readonly pairedRef: readonly (number | null)[];
864
+ /** Per hypothesis word: its speaker disagrees with the reference's. */
865
+ readonly speakerWrong: readonly boolean[];
866
+ /** Hypothesis speaker label → the reference speaker it stands for (null: none). */
867
+ readonly speakerMap: ReadonlyMap<string, string | null>;
868
+ readonly regions: readonly Region[];
869
+ }
870
+
871
+ /** The reference and the hypotheses aligned to it. */
872
+ export interface Analysis {
873
+ readonly tracks: readonly Track[];
874
+ readonly reference: Track;
875
+ readonly refTokens: readonly Token[];
876
+ readonly comparisons: readonly Comparison[];
877
+ /** All regions of all hypotheses, by time (untimed ones last, in order). */
878
+ readonly regions: readonly Region[];
879
+ }
880
+
881
+ function midpoint(words: readonly Word[], token: Token): number | null {
882
+ const start = words[token.from].start;
883
+ const end = words[token.to].end;
884
+ return start === null || end === null ? null : (start + end) / 2;
885
+ }
886
+
887
+ /** Hypothesis speaker labels mapped one to one onto the reference speakers they share most aligned words with. */
888
+ function mapSpeakers(pairs: readonly [string, string][]): Map<string, string | null> {
889
+ const tally = new Map<string, number>();
890
+ for (const [hyp, ref] of pairs) tally.set(`${hyp}\u0000${ref}`, (tally.get(`${hyp}\u0000${ref}`) ?? 0) + 1);
891
+ const ranked = [...tally.entries()]
892
+ .map(([key, count]) => {
893
+ const [hyp, ref] = key.split("\u0000");
894
+ return { hyp, ref, count, same: hyp === ref };
895
+ })
896
+ .sort((a, b) => b.count - a.count || Number(b.same) - Number(a.same) || a.hyp.localeCompare(b.hyp));
897
+ const map = new Map<string, string | null>();
898
+ const taken = new Set<string>();
899
+ for (const { hyp, ref } of ranked) {
900
+ if (map.has(hyp) || taken.has(ref)) continue;
901
+ map.set(hyp, ref);
902
+ taken.add(ref);
903
+ }
904
+ for (const [hyp] of pairs) if (!map.has(hyp)) map.set(hyp, null);
905
+ return map;
906
+ }
907
+
908
+ function wordsText(words: readonly Word[], range: readonly [number, number] | null): string {
909
+ return range === null ? "" : words.slice(range[0], range[1] + 1).map((word) => word.text).join(" ");
910
+ }
911
+
912
+ function span(words: readonly Word[], range: readonly [number, number] | null): [number, number] | null {
913
+ if (range === null) return null;
914
+ let start: number | null = null;
915
+ let end: number | null = null;
916
+ for (let index = range[0]; index <= range[1]; index++) {
917
+ const word = words[index];
918
+ if (word.start !== null) start = start === null ? word.start : Math.min(start, word.start);
919
+ if (word.end !== null) end = end === null ? word.end : Math.max(end, word.end);
920
+ }
921
+ return start === null || end === null ? null : [start, end];
922
+ }
923
+
924
+ function extend(range: [number, number] | null, from: number, to: number): [number, number] {
925
+ return range === null ? [from, to] : [Math.min(range[0], from), Math.max(range[1], to)];
926
+ }
927
+
928
+ function compare(reference: Track, refTokens: readonly Token[], track: Track, index: number): Comparison {
929
+ const hypTokens = tokenize(track.words);
930
+ const timed = reference.timing !== "none" && track.timing !== "none";
931
+ const refMid = timed ? refTokens.map((token) => midpoint(reference.words, token)) : [];
932
+ const hypMid = timed ? hypTokens.map((token) => midpoint(track.words, token)) : [];
933
+ const penalty: PairPenalty | null = timed
934
+ ? (r, h) => {
935
+ const a = refMid[r];
936
+ const b = hypMid[h];
937
+ if (a === null || b === null) return refTokens[r].key === hypTokens[h].key ? 0 : 0.5;
938
+ return Math.min(1, Math.abs(a - b) / 2);
939
+ }
940
+ : null;
941
+ const ops = align(refTokens, hypTokens, penalty);
942
+
943
+ const hypStatus: HypStatus[] = track.words.map(() => "ok");
944
+ const refStatus: RefStatus[] = reference.words.map(() => "ok");
945
+ const pairedRef: (number | null)[] = track.words.map(() => null);
946
+ let hits = 0;
947
+ let substitutions = 0;
948
+ let deletions = 0;
949
+ let insertions = 0;
950
+ const worse = <T extends string>(current: T, next: T, order: readonly T[]) => (order.indexOf(next) > order.indexOf(current) ? next : current);
951
+ for (const op of ops) {
952
+ const refToken = op.ref === null ? null : refTokens[op.ref];
953
+ const hypToken = op.hyp === null ? null : hypTokens[op.hyp];
954
+ if (op.kind === "match") hits++;
955
+ else if (op.kind === "sub") substitutions++;
956
+ else if (op.kind === "del") deletions++;
957
+ else insertions++;
958
+ if (refToken !== null) {
959
+ const status: RefStatus = op.kind === "match" ? "ok" : op.kind === "sub" ? "sub" : "del";
960
+ for (let word = refToken.from; word <= refToken.to; word++) refStatus[word] = worse(refStatus[word], status, ["ok", "sub", "del"]);
961
+ }
962
+ if (hypToken !== null) {
963
+ const status: HypStatus = op.kind === "match" ? "ok" : op.kind === "sub" ? "sub" : "ins";
964
+ for (let word = hypToken.from; word <= hypToken.to; word++) {
965
+ hypStatus[word] = worse(hypStatus[word], status, ["ok", "ins", "sub"]);
966
+ if (refToken !== null && pairedRef[word] === null) pairedRef[word] = refToken.from;
967
+ }
968
+ }
969
+ }
970
+
971
+ // Speakers: compared on aligned words where both name one, labels mapped first.
972
+ const bothSpeak = reference.words.some((word) => word.speaker !== null) && track.words.some((word) => word.speaker !== null);
973
+ const pairs: [string, string][] = [];
974
+ if (bothSpeak) {
975
+ track.words.forEach((word, position) => {
976
+ const paired = pairedRef[position];
977
+ const other = paired === null ? null : reference.words[paired].speaker;
978
+ if (word.speaker !== null && other !== null && other !== undefined) pairs.push([word.speaker, other]);
979
+ });
980
+ }
981
+ const speakerMap = mapSpeakers(pairs);
982
+ const speakerWrong = track.words.map((word, position) => {
983
+ const paired = pairedRef[position];
984
+ if (!bothSpeak || paired === null || word.speaker === null) return false;
985
+ const other = reference.words[paired].speaker;
986
+ return other !== null && speakerMap.get(word.speaker) !== other;
987
+ });
988
+ const speakerErrors = bothSpeak ? speakerWrong.filter(Boolean).length : null;
989
+
990
+ const counts: Counts = { words: refTokens.length, hits, substitutions, deletions, insertions, speaker: speakerErrors };
991
+ const wer = refTokens.length === 0 ? null : (substitutions + deletions + insertions) / refTokens.length;
992
+ return {
993
+ track: index,
994
+ ops,
995
+ counts,
996
+ wer,
997
+ hypStatus,
998
+ refStatus,
999
+ pairedRef,
1000
+ speakerWrong,
1001
+ speakerMap,
1002
+ regions: regionsOf(index, ops, refTokens, hypTokens, reference, track, speakerWrong, speakerMap),
1003
+ };
1004
+ }
1005
+
1006
+ function regionsOf(
1007
+ index: number,
1008
+ ops: readonly Op[],
1009
+ refTokens: readonly Token[],
1010
+ hypTokens: readonly Token[],
1011
+ reference: Track,
1012
+ track: Track,
1013
+ speakerWrong: readonly boolean[],
1014
+ speakerMap: ReadonlyMap<string, string | null>,
1015
+ ): Region[] {
1016
+ const regions: Region[] = [];
1017
+ let run: { kinds: Set<OpKind>; ref: [number, number] | null; hyp: [number, number] | null } | null = null;
1018
+ const close = () => {
1019
+ if (run === null) return;
1020
+ const kinds = [...run.kinds];
1021
+ const kind: RegionKind = kinds.length === 1 ? (kinds[0] as RegionKind) : kinds.every((kind) => kind === "sub" || kind === "del") || kinds.every((kind) => kind === "sub" || kind === "ins") ? "sub" : "mixed";
1022
+ // A substitution run with extra or missing words reads as one replacement ("can you hear me" → "Jeremy").
1023
+ const timing = span(reference.words, run.ref) ?? span(track.words, run.hyp) ?? nearTime(run, ops, refTokens, reference);
1024
+ regions.push({
1025
+ track: index,
1026
+ kind,
1027
+ ref: run.ref,
1028
+ hyp: run.hyp,
1029
+ start: timing === null ? null : timing[0],
1030
+ end: timing === null ? null : timing[1],
1031
+ refText: wordsText(reference.words, run.ref),
1032
+ hypText: wordsText(track.words, run.hyp),
1033
+ speakers: null,
1034
+ });
1035
+ run = null;
1036
+ };
1037
+ for (const op of ops) {
1038
+ if (op.kind === "match") {
1039
+ close();
1040
+ continue;
1041
+ }
1042
+ run ??= { kinds: new Set(), ref: null, hyp: null };
1043
+ run.kinds.add(op.kind);
1044
+ if (op.ref !== null) run.ref = extend(run.ref, refTokens[op.ref].from, refTokens[op.ref].to);
1045
+ if (op.hyp !== null) run.hyp = extend(run.hyp, hypTokens[op.hyp].from, hypTokens[op.hyp].to);
1046
+ }
1047
+ close();
1048
+
1049
+ // Speaker regions: consecutive hypothesis words attributed to the wrong speaker.
1050
+ let from = -1;
1051
+ for (let position = 0; position <= track.words.length; position++) {
1052
+ const wrong = position < track.words.length && speakerWrong[position];
1053
+ if (wrong && from < 0) from = position;
1054
+ const breaks = from >= 0 && (!wrong || track.words[position].speaker !== track.words[from].speaker);
1055
+ if (breaks) {
1056
+ const hyp: [number, number] = [from, position - 1];
1057
+ const words = track.words.slice(from, position);
1058
+ const pairedRefs = ops.filter((op) => op.hyp !== null && op.ref !== null && hypTokens[op.hyp].from >= from && hypTokens[op.hyp].to <= position - 1).map((op) => refTokens[op.ref!]);
1059
+ const ref: [number, number] | null = pairedRefs.length === 0 ? null : [pairedRefs[0].from, pairedRefs[pairedRefs.length - 1].to];
1060
+ const timing = span(reference.words, ref) ?? span(track.words, hyp);
1061
+ const given = words[0].speaker ?? "";
1062
+ const expected = ref === null ? null : reference.words[ref[0]].speaker;
1063
+ regions.push({
1064
+ track: index,
1065
+ kind: "speaker",
1066
+ ref,
1067
+ hyp,
1068
+ start: timing === null ? null : timing[0],
1069
+ end: timing === null ? null : timing[1],
1070
+ refText: wordsText(reference.words, ref),
1071
+ hypText: wordsText(track.words, hyp),
1072
+ speakers: [expected ?? "another speaker", speakerMap.get(given) ?? given],
1073
+ });
1074
+ from = wrong ? position : -1;
1075
+ }
1076
+ }
1077
+ return regions;
1078
+ }
1079
+
1080
+ /** An insertion's time where only the reference is timed: just after the reference word before it. */
1081
+ function nearTime(
1082
+ run: { ref: [number, number] | null; hyp: [number, number] | null },
1083
+ ops: readonly Op[],
1084
+ refTokens: readonly Token[],
1085
+ reference: Track,
1086
+ ): [number, number] | null {
1087
+ if (run.hyp === null) return null;
1088
+ let before: number | null = null;
1089
+ for (const op of ops) {
1090
+ if (op.ref !== null) before = refTokens[op.ref].to;
1091
+ if (op.hyp !== null && op.kind === "ins") break;
1092
+ }
1093
+ const word = before === null ? reference.words.find((item) => item.start !== null) : reference.words[before];
1094
+ if (word === undefined || word.end === null || word.start === null) return null;
1095
+ const at = before === null ? word.start : word.end;
1096
+ return [at, at];
1097
+ }
1098
+
1099
+ function byTime(a: Region, b: Region): number {
1100
+ if (a.start === null || b.start === null) return a.start === null ? (b.start === null ? 0 : 1) : -1;
1101
+ return a.start - b.start || a.track - b.track;
1102
+ }
1103
+
1104
+ /** Aligns every hypothesis (tracks after the first) to the reference. */
1105
+ export function analyse(tracks: readonly Track[]): Analysis | null {
1106
+ if (tracks.length === 0) return null;
1107
+ const reference = tracks[0];
1108
+ const refTokens = tokenize(reference.words);
1109
+ const comparisons = tracks.slice(1).map((track, position) => compare(reference, refTokens, track, position + 1));
1110
+ const regions = comparisons.flatMap((comparison) => comparison.regions).sort(byTime);
1111
+ return { tracks, reference, refTokens, comparisons, regions };
1112
+ }
1113
+
1114
+ // ——— Placement and text ———
1115
+
1116
+ /** Where a word sits on the time axis, and whether that place is the word's own time. */
1117
+ export interface Placed {
1118
+ readonly start: number;
1119
+ readonly end: number;
1120
+ /** `aligned`: an untimed word shown under the word it aligns to. */
1121
+ readonly basis: "word" | "segment" | "aligned";
1122
+ }
1123
+
1124
+ /**
1125
+ * Positions of every track's words on the time axis: their own times, else
1126
+ * (untimed tracks) the times of the words they align to; null for a track
1127
+ * nothing places. The reference borrows a timed hypothesis's times when it
1128
+ * has none of its own.
1129
+ */
1130
+ export function placeTracks(analysis: Analysis): (readonly (Placed | null)[] | null)[] {
1131
+ const { reference, comparisons } = analysis;
1132
+ const own = (track: Track) => track.words.map((word): Placed | null => (word.start === null || word.end === null ? null : { start: word.start, end: word.end, basis: word.timing === "segment" ? "segment" : "word" }));
1133
+ let refPlaces: (Placed | null)[] | null = reference.timing === "none" ? null : own(reference);
1134
+ if (refPlaces === null) {
1135
+ const timed = comparisons.find((comparison) => analysis.tracks[comparison.track].timing !== "none");
1136
+ if (timed !== undefined) {
1137
+ const track = analysis.tracks[timed.track];
1138
+ const borrowed: (Placed | null)[] = reference.words.map(() => null);
1139
+ track.words.forEach((word, position) => {
1140
+ const paired = timed.pairedRef[position];
1141
+ if (paired === null || word.start === null || word.end === null) return;
1142
+ const existing = borrowed[paired];
1143
+ borrowed[paired] = existing === null ? { start: word.start, end: word.end, basis: "aligned" } : { ...existing, end: Math.max(existing.end, word.end) };
1144
+ });
1145
+ refPlaces = fillGaps(borrowed);
1146
+ }
1147
+ }
1148
+ const result: (readonly (Placed | null)[] | null)[] = [refPlaces];
1149
+ for (const comparison of comparisons) {
1150
+ const track = analysis.tracks[comparison.track];
1151
+ if (track.timing !== "none") {
1152
+ result.push(own(track));
1153
+ continue;
1154
+ }
1155
+ if (refPlaces === null) {
1156
+ result.push(null);
1157
+ continue;
1158
+ }
1159
+ const places: (Placed | null)[] = track.words.map((_, position) => {
1160
+ const paired = comparison.pairedRef[position];
1161
+ const place = paired === null ? null : refPlaces[paired];
1162
+ return place === null ? null : { start: place.start, end: place.end, basis: "aligned" };
1163
+ });
1164
+ // Words sharing one reference word split its span.
1165
+ let position = 0;
1166
+ while (position < places.length) {
1167
+ const place = places[position];
1168
+ let next = position + 1;
1169
+ while (place !== null && next < places.length && comparison.pairedRef[next] === comparison.pairedRef[position]) next++;
1170
+ if (place !== null && next - position > 1) {
1171
+ const step = (place.end - place.start) / (next - position);
1172
+ for (let index = position; index < next; index++) places[index] = { start: place.start + step * (index - position), end: place.start + step * (index - position + 1), basis: "aligned" };
1173
+ }
1174
+ position = next;
1175
+ }
1176
+ result.push(fillGaps(places));
1177
+ }
1178
+ return result;
1179
+ }
1180
+
1181
+ /** Unplaced words (insertions in an untimed track) share the gap between their placed neighbours. */
1182
+ function fillGaps(places: (Placed | null)[]): (Placed | null)[] {
1183
+ let position = 0;
1184
+ while (position < places.length) {
1185
+ if (places[position] !== null) {
1186
+ position++;
1187
+ continue;
1188
+ }
1189
+ let next = position;
1190
+ while (next < places.length && places[next] === null) next++;
1191
+ const before = position > 0 ? places[position - 1] : null;
1192
+ const after = next < places.length ? places[next] : null;
1193
+ if (before !== null || after !== null) {
1194
+ const from = before?.end ?? Math.max(0, (after?.start ?? 0) - 0.3 * (next - position));
1195
+ const to = after?.start ?? from + 0.3 * (next - position);
1196
+ const gap = Math.max(to - from, 0.12 * (next - position));
1197
+ const step = gap / (next - position);
1198
+ const origin = to - from < gap ? from - (gap - (to - from)) / 2 : from;
1199
+ for (let index = position; index < next; index++) places[index] = { start: Math.max(0, origin + step * (index - position)), end: Math.max(0, origin + step * (index - position + 1)), basis: "aligned" };
1200
+ }
1201
+ position = next;
1202
+ }
1203
+ return places;
1204
+ }
1205
+
1206
+ /** One column of the aligned text view: a reference word (or an insertion slot) and what each hypothesis has there. */
1207
+ export interface Column {
1208
+ /** The reference word, or null for words only hypotheses have. */
1209
+ readonly ref: number | null;
1210
+ /** Per hypothesis (in comparison order): its words in this column. */
1211
+ readonly hyp: readonly (readonly number[])[];
1212
+ }
1213
+
1214
+ /** The alignment as columns under the reference words, insertions in columns of their own. */
1215
+ export function columns(analysis: Analysis): Column[] {
1216
+ const { reference, refTokens, comparisons } = analysis;
1217
+ const count = comparisons.length;
1218
+ const under: number[][][] = reference.words.map(() => comparisons.map(() => []));
1219
+ // Insertions before reference word k (k = words.length: after the last).
1220
+ const before: number[][][] = [...reference.words.map(() => comparisons.map(() => [])), comparisons.map(() => [])];
1221
+ comparisons.forEach((comparison, slot) => {
1222
+ const hypTokens = tokenize(analysis.tracks[comparison.track].words);
1223
+ let nextRef = 0;
1224
+ const seen = new Set<number>();
1225
+ for (const op of comparison.ops) {
1226
+ if (op.ref !== null) nextRef = refTokens[op.ref].to + 1;
1227
+ if (op.hyp === null) continue;
1228
+ const token = hypTokens[op.hyp];
1229
+ for (let word = token.from; word <= token.to; word++) {
1230
+ if (seen.has(word)) continue;
1231
+ seen.add(word);
1232
+ if (op.ref !== null) under[refTokens[op.ref].from][slot].push(word);
1233
+ else before[nextRef][slot].push(word);
1234
+ }
1235
+ }
1236
+ });
1237
+ const result: Column[] = [];
1238
+ for (let index = 0; index <= reference.words.length; index++) {
1239
+ if (before[index].some((words) => words.length > 0)) result.push({ ref: null, hyp: before[index] });
1240
+ if (index < reference.words.length) result.push({ ref: index, hyp: count === 0 ? [] : under[index] });
1241
+ }
1242
+ return result;
1243
+ }
1244
+
1245
+ // ——— Formatting ———
1246
+
1247
+ /** A time as m:ss.s (h:mm:ss.s past an hour); `precise` keeps tenths. */
1248
+ export function formatClock(seconds: number, precise = true): string {
1249
+ const safe = Math.max(0, seconds);
1250
+ const hours = Math.floor(safe / 3600);
1251
+ const minutes = Math.floor((safe % 3600) / 60);
1252
+ const rest = safe % 60;
1253
+ const secs = precise ? (Math.floor(rest * 10) / 10).toFixed(1).padStart(4, "0") : String(Math.floor(rest)).padStart(2, "0");
1254
+ return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}`;
1255
+ }
1256
+
1257
+ /** "19:49", "1:02:03", "93", "19:49.5" as seconds; null when it isn't a time. */
1258
+ export function parseClock(text: string): number | null {
1259
+ const trimmed = text.trim();
1260
+ if (!/^\d+(:\d{1,2}){0,2}(\.\d+)?$/.test(trimmed)) return null;
1261
+ const parts = trimmed.split(":");
1262
+ if (parts.slice(1).some((part) => Number(part.split(".")[0]) >= 60)) return null;
1263
+ return parts.reduce((sum, part) => sum * 60 + Number(part), 0);
1264
+ }
1265
+
1266
+ export function formatWer(wer: number): string {
1267
+ const percent = wer * 100;
1268
+ return `${percent >= 10 || percent === 0 ? percent.toFixed(percent % 1 === 0 ? 0 : 1) : percent.toFixed(1)}%`;
1269
+ }
1270
+
1271
+ /** A region in words: ‘can you hear me’ → ‘Jeremy’, missing ‘you’, extra ‘so’, Maya's words given to Sam. */
1272
+ export function describeRegion(region: Region): string {
1273
+ switch (region.kind) {
1274
+ case "del":
1275
+ return `missing “${region.refText}”`;
1276
+ case "ins":
1277
+ return `extra “${region.hypText}”`;
1278
+ case "speaker":
1279
+ return region.speakers === null ? `“${region.hypText}” has the wrong speaker` : `“${region.hypText}” given to ${region.speakers[1]}, said by ${region.speakers[0]}`;
1280
+ default:
1281
+ return `“${region.refText}” heard as “${region.hypText}”`;
1282
+ }
1283
+ }
1284
+
1285
+ /** "3 misheard · 2 missing · 1 extra" (S, D, I) and speaker mismatches, zero parts left out. */
1286
+ export function countsText(counts: Counts): string {
1287
+ const parts = [
1288
+ counts.substitutions > 0 ? `${counts.substitutions} misheard` : null,
1289
+ counts.deletions > 0 ? `${counts.deletions} missing` : null,
1290
+ counts.insertions > 0 ? `${counts.insertions} extra` : null,
1291
+ counts.speaker !== null && counts.speaker > 0 ? `${counts.speaker} with the wrong speaker` : null,
1292
+ ].filter((part) => part !== null);
1293
+ return parts.length === 0 ? "no errors" : parts.join(" · ");
1294
+ }
1295
+
1296
+ // ——— Layout ———
1297
+
1298
+ /** The fewest and most pixels per second the default zoom picks: at most about 3.5 s across a 700 px lane. */
1299
+ export const MIN_SCALE = 40;
1300
+ export const MAX_SCALE = 200;
1301
+
1302
+ /**
1303
+ * Pixels per second at zoom 1: enough that every word's text (`measure`d in
1304
+ * pixels) fits the room it has, from its start to the next word's start in its
1305
+ * lane (or its own end for the last), set by the densest stretch of any lane,
1306
+ * within MIN_SCALE–MAX_SCALE. `pad` is the box's padding and the gap kept to
1307
+ * the next word, in pixels.
1308
+ */
1309
+ export function autoScale(
1310
+ lanes: readonly { readonly places: readonly (Placed | null)[]; readonly words: readonly Word[] }[],
1311
+ measure: (text: string) => number,
1312
+ pad: number,
1313
+ ): number {
1314
+ let scale = 0;
1315
+ for (const { places, words } of lanes) {
1316
+ const { order } = laneIndex(places);
1317
+ order.forEach((index, position) => {
1318
+ const place = places[index]!;
1319
+ const next = position + 1 < order.length ? places[order[position + 1]]!.start : place.end;
1320
+ const room = Math.max(next - place.start, 0.01);
1321
+ scale = Math.max(scale, (measure(words[index].text) + pad) / room);
1322
+ });
1323
+ }
1324
+ return scale === 0 ? 120 : Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
1325
+ }
1326
+
1327
+ /** Seconds between ruler labels so they sit at least `minPx` apart. */
1328
+ export function tickStep(pixelsPerSecond: number, minPx = 64): number {
1329
+ const steps = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
1330
+ return steps.find((step) => step * pixelsPerSecond >= minPx) ?? 7200;
1331
+ }
1332
+
1333
+ /** A lane's words by start time, for finding the ones on screen without scanning them all. */
1334
+ export interface LaneIndex {
1335
+ /** Word indexes ordered by start. */
1336
+ readonly order: readonly number[];
1337
+ readonly starts: readonly number[];
1338
+ /** The longest word, so a word starting before the window but reaching into it is found. */
1339
+ readonly longest: number;
1340
+ }
1341
+
1342
+ export function laneIndex(places: readonly (Placed | null)[]): LaneIndex {
1343
+ const order = places.flatMap((place, index) => (place === null ? [] : [index])).sort((a, b) => places[a]!.start - places[b]!.start || a - b);
1344
+ return {
1345
+ order,
1346
+ starts: order.map((index) => places[index]!.start),
1347
+ longest: order.reduce((longest, index) => Math.max(longest, places[index]!.end - places[index]!.start), 0),
1348
+ };
1349
+ }
1350
+
1351
+ /** Positions in `index.order` of the words overlapping [from, to] seconds: [first, last) . */
1352
+ export function visibleSlice(index: LaneIndex, from: number, to: number): [number, number] {
1353
+ const search = (time: number) => {
1354
+ let low = 0;
1355
+ let high = index.starts.length;
1356
+ while (low < high) {
1357
+ const middle = (low + high) >> 1;
1358
+ if (index.starts[middle] < time) low = middle + 1;
1359
+ else high = middle;
1360
+ }
1361
+ return low;
1362
+ };
1363
+ return [search(from - index.longest), search(to)];
1364
+ }