@autoaiai/aicut 0.0.0-stage → 0.1.7

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 (146) hide show
  1. package/LICENSE +21 -0
  2. package/MANUAL.md +84 -0
  3. package/README.md +36 -2
  4. package/SPEC.md +221 -0
  5. package/THIRD_PARTY_NOTICES.md +10 -0
  6. package/TRADEMARK.md +25 -0
  7. package/bin/aicut.mjs +264 -0
  8. package/package.json +89 -6
  9. package/product.json +5 -0
  10. package/skills/aicut/LICENSE +21 -0
  11. package/skills/aicut/SKILL.md +66 -0
  12. package/skills/aicut/references/commands.md +72 -0
  13. package/skills/aicut/references/film-format.md +119 -0
  14. package/skills/aicut/references/recipes.md +184 -0
  15. package/skills/aicut/references/troubleshooting.md +57 -0
  16. package/skills/aicut-motion/LICENSE +21 -0
  17. package/skills/aicut-motion/SKILL.md +255 -0
  18. package/skills/aicut-motion/references/design-systems/README.md +25 -0
  19. package/skills/aicut-motion/references/design-systems/bold-poster.md +62 -0
  20. package/skills/aicut-motion/references/design-systems/data-instrument.md +71 -0
  21. package/skills/aicut-motion/references/design-systems/drafting-table.md +76 -0
  22. package/skills/aicut-motion/references/design-systems/editorial-ink.md +83 -0
  23. package/skills/aicut-motion/references/design-systems/riso-halftone.md +70 -0
  24. package/skills/aicut-motion/references/design-systems/storybook-scene.md +75 -0
  25. package/skills/aicut-motion/references/design-systems/swiss-kinetic.md +67 -0
  26. package/skills/aicut-motion/references/pages.md +258 -0
  27. package/src/film-doc.d.mts +204 -0
  28. package/src/film-doc.mjs +1225 -0
  29. package/src/host.d.mts +35 -0
  30. package/src/host.mjs +542 -0
  31. package/src/look.d.mts +2 -0
  32. package/src/look.mjs +358 -0
  33. package/src/media-links.mjs +68 -0
  34. package/src/of-filters.mjs +237 -0
  35. package/src/overrides.mjs +75 -0
  36. package/src/render.mjs +202 -0
  37. package/src/seekable.mjs +101 -0
  38. package/src/shared.d.mts +27 -0
  39. package/src/shared.mjs +391 -0
  40. package/src/timeline.mjs +1158 -0
  41. package/studio/client.mjs +311 -0
  42. package/studio/server/atomic.mjs +41 -0
  43. package/studio/server/bridge.js +89 -0
  44. package/studio/server/browse.mjs +104 -0
  45. package/studio/server/captions.mjs +502 -0
  46. package/studio/server/chat.mjs +49 -0
  47. package/studio/server/closure.mjs +407 -0
  48. package/studio/server/compound.mjs +133 -0
  49. package/studio/server/custom-services.mjs +299 -0
  50. package/studio/server/derived.mjs +225 -0
  51. package/studio/server/examples.mjs +89 -0
  52. package/studio/server/exports.mjs +1621 -0
  53. package/studio/server/fetch-error.mjs +14 -0
  54. package/studio/server/fetch-github.mjs +181 -0
  55. package/studio/server/fetch-repo.mjs +399 -0
  56. package/studio/server/files.mjs +628 -0
  57. package/studio/server/film-subtitle.mjs +869 -0
  58. package/studio/server/film.mjs +109 -0
  59. package/studio/server/folder-picker.mjs +199 -0
  60. package/studio/server/fonts.mjs +308 -0
  61. package/studio/server/get.mjs +700 -0
  62. package/studio/server/heard.mjs +108 -0
  63. package/studio/server/hidden.mjs +85 -0
  64. package/studio/server/history-migrate.mjs +239 -0
  65. package/studio/server/history-store.mjs +538 -0
  66. package/studio/server/history.mjs +682 -0
  67. package/studio/server/html-deck.mjs +168 -0
  68. package/studio/server/html-film.mjs +686 -0
  69. package/studio/server/html-pages.mjs +604 -0
  70. package/studio/server/http.mjs +40 -0
  71. package/studio/server/jianying.mjs +289 -0
  72. package/studio/server/keys.mjs +126 -0
  73. package/studio/server/main.mjs +120 -0
  74. package/studio/server/markers.mjs +80 -0
  75. package/studio/server/nle.mjs +537 -0
  76. package/studio/server/ops.mjs +506 -0
  77. package/studio/server/page-links.mjs +98 -0
  78. package/studio/server/pageframes.mjs +335 -0
  79. package/studio/server/pages.mjs +197 -0
  80. package/studio/server/prefs.mjs +56 -0
  81. package/studio/server/project-fonts.mjs +263 -0
  82. package/studio/server/project-settings.mjs +130 -0
  83. package/studio/server/projects.mjs +457 -0
  84. package/studio/server/providers/azure.mjs +100 -0
  85. package/studio/server/providers/brave.mjs +55 -0
  86. package/studio/server/providers/common.mjs +321 -0
  87. package/studio/server/providers/contract.mjs +98 -0
  88. package/studio/server/providers/doubao.mjs +65 -0
  89. package/studio/server/providers/edge-drm.mjs +263 -0
  90. package/studio/server/providers/edge-speech.mjs +292 -0
  91. package/studio/server/providers/edge-tts.mjs +112 -0
  92. package/studio/server/providers/elevenlabs.mjs +144 -0
  93. package/studio/server/providers/fal.mjs +212 -0
  94. package/studio/server/providers/google.mjs +201 -0
  95. package/studio/server/providers/groq.mjs +97 -0
  96. package/studio/server/providers/index.mjs +284 -0
  97. package/studio/server/providers/local-mdx.mjs +293 -0
  98. package/studio/server/providers/local-whisper.mjs +847 -0
  99. package/studio/server/providers/mdx-net.mjs +275 -0
  100. package/studio/server/providers/openai.mjs +131 -0
  101. package/studio/server/providers/pexels.mjs +40 -0
  102. package/studio/server/providers/pixabay.mjs +53 -0
  103. package/studio/server/providers/tar-bz2.mjs +356 -0
  104. package/studio/server/providers/tavily.mjs +68 -0
  105. package/studio/server/providers/web.mjs +231 -0
  106. package/studio/server/proxies.mjs +151 -0
  107. package/studio/server/recycle.mjs +222 -0
  108. package/studio/server/render-worker.mjs +29 -0
  109. package/studio/server/separate.mjs +44 -0
  110. package/studio/server/server.mjs +1021 -0
  111. package/studio/server/sfx.mjs +321 -0
  112. package/studio/server/skills.mjs +357 -0
  113. package/studio/server/slides.mjs +230 -0
  114. package/studio/server/stage.js +1553 -0
  115. package/studio/server/stock.mjs +122 -0
  116. package/studio/server/subtitle-edits.mjs +234 -0
  117. package/studio/server/subtitle-segments.mjs +602 -0
  118. package/studio/server/template-posters.mjs +68 -0
  119. package/studio/server/templates-page.mjs +93 -0
  120. package/studio/server/templates-styles.mjs +583 -0
  121. package/studio/server/templates.mjs +269 -0
  122. package/studio/server/transcripts.mjs +490 -0
  123. package/studio/server/transition-pages.mjs +43 -0
  124. package/studio/server/trash.mjs +143 -0
  125. package/studio/server/video-models.mjs +226 -0
  126. package/studio/server/watch.mjs +160 -0
  127. package/studio/server/web-agents.mjs +143 -0
  128. package/studio/server/websocket.mjs +82 -0
  129. package/studio/server/zip.mjs +159 -0
  130. package/studio/ui/dist/assets/ExportCenter-F-2vF-lQ.js +1 -0
  131. package/studio/ui/dist/assets/SettingsModal-BgVYguNu.js +2 -0
  132. package/studio/ui/dist/assets/app-icon-BpqHp2uj.png +0 -0
  133. package/studio/ui/dist/assets/es-BRUzvuoM.js +1 -0
  134. package/studio/ui/dist/assets/index-Bhg-9dA3.css +1 -0
  135. package/studio/ui/dist/assets/index-CWur1Iw9.js +1625 -0
  136. package/studio/ui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  137. package/studio/ui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  138. package/studio/ui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  139. package/studio/ui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  140. package/studio/ui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  141. package/studio/ui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  142. package/studio/ui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  143. package/studio/ui/dist/assets/ja-Cth-uDcW.js +1 -0
  144. package/studio/ui/dist/assets/ko-upvw591D.js +1 -0
  145. package/studio/ui/dist/assets/zh-CN-BCfJFazB.js +1 -0
  146. package/studio/ui/dist/index.html +23 -0
@@ -0,0 +1,847 @@
1
+ // @ts-check
2
+ /**
3
+ * The local recognizers: transcription on this computer, no key and no bill. Two of them, each a provider of its own
4
+ * in Settings → Providers — Whisper (本地), with Whisper small, medium or large-v3, and SenseVoice Small — and Studio
5
+ * downloads what runs the one the person wants (and, once, the command itself) into ~/.aicut/whisper. A run is
6
+ * that command on a 16 kHz WAV of the speech, read back from its JSON.
7
+ *
8
+ * Two commands, one per provider: whisper.cpp for the Whisper models, sherpa-onnx for SenseVoiceSmall (whisper.cpp
9
+ * cannot run it — it is a different kind of model). Which one a run needs is the model's `engine`, which is also
10
+ * what says whose row it is listed in.
11
+ *
12
+ * The models come from the ggml-org/whisper.cpp collection and from Hugging Face's copy of SenseVoiceSmall; the
13
+ * commands come from the whisper.cpp and sherpa-onnx releases on GitHub. None of those sites answers on every
14
+ * network (this one included), so SenseVoice is taken from a ModelScope copy first — the only source that comes down
15
+ * at speed from here, two minutes instead of forty — and a GitHub release through gh-proxy; Xget (the engine behind
16
+ * the Xget Now extension) is the way round to Hugging Face, and every list ends with the site itself. Every download
17
+ * lands as `.part` first and is renamed only when whole, so a broken download never looks like a model (and a
18
+ * `.part` that stopped half way is taken up where it stopped rather than from nothing).
19
+ */
20
+ import { spawn } from 'node:child_process';
21
+ import { createWriteStream } from 'node:fs';
22
+ import { mkdtemp, open, readdir, readFile, rm, stat, rename, mkdir, writeFile } from 'node:fs/promises';
23
+ import { basename, dirname, join } from 'node:path';
24
+ import { cpus, tmpdir } from 'node:os';
25
+ import { pipeline } from 'node:stream/promises';
26
+ import { Readable, Transform } from 'node:stream';
27
+ import { extractTarBz2 } from './tar-bz2.mjs';
28
+ import { studioHome } from '../projects.mjs';
29
+
30
+ /** @typedef {import('./contract.mjs').Provider} Provider */
31
+
32
+ /** Who owns each family of models: the provider ids, each with the row Settings gives it. */
33
+ const WHISPER = /** @type {const} */ ('local-whisper');
34
+ const SENSEVOICE = /** @type {const} */ ('local-sensevoice');
35
+ const NAME = 'Whisper (本地)';
36
+ const SENSE_NAME = 'SenseVoice Small';
37
+ /** the name of the provider that runs a model. @param {Model} model */
38
+ const nameOf = (model) => (model.owner === SENSEVOICE ? SENSE_NAME : NAME);
39
+ /** where everything of the local recognizers is kept */
40
+ const home = () => join(studioHome(), 'whisper');
41
+ const binDir = () => join(home(), 'bin');
42
+ const modelsDir = () => join(home(), 'models');
43
+
44
+ /* ── where a download comes from ─────────────────────────────────────────────────────────────────────────────────── */
45
+
46
+ /**
47
+ * ModelScope (魔搭) is the one place a model comes down fast from a Chinese network — 2.5 MB/s against the 0.08 MB/s
48
+ * Xget gives here, which is the difference between two minutes and forty. It is tried first, for the files it is
49
+ * known to carry, and only those: what it carries was put there by someone else, so each file it serves is checked
50
+ * against the size the model is known to be (`partsOf`'s `exact`), and a copy of another build fails that check and
51
+ * loses to the address that has the right one. `AICUT_MODELSCOPE` names another site of its own (`=off` leaves it
52
+ * out).
53
+ */
54
+ const modelscopeBase = () => {
55
+ const set = process.env.AICUT_MODELSCOPE?.trim();
56
+ if (set === 'off') return '';
57
+ return (set || 'https://modelscope.cn').replace(/\/+$/, '');
58
+ };
59
+
60
+ /**
61
+ * Where a file of `repo` is on ModelScope (its `master` revision, which is what these copies publish as).
62
+ * @param {string} repo the ModelScope repository, `owner/name`
63
+ * @param {string} file the path inside it
64
+ * @returns {string[]}
65
+ */
66
+ function viaModelScope(repo, file) {
67
+ const base = modelscopeBase();
68
+ return base ? [`${base}/models/${repo}/resolve/master/${file}`] : [];
69
+ }
70
+
71
+ /**
72
+ * Xget (xget.xi-xu.me — the engine the Xget Now browser extension hands downloads to) takes the original address
73
+ * after a prefix of its own, `/gh/` for GitHub and `/hf/` for Hugging Face, and fetches it from an edge that reaches
74
+ * where this computer cannot: github.com and huggingface.co time out here, and hf-mirror has been answering nothing
75
+ * at all, so for a Hugging Face file it is the only way there is. Two public instances are listed because the first
76
+ * one rate-limits a burst of requests (429) and the second usually has not — `AICUT_XGET` names one's own
77
+ * instead (several, comma by comma, same way round, no trailing slash); `AICUT_XGET=off` leaves Xget out.
78
+ */
79
+ const XGET = 'https://xget.xi-xu.me,https://xget.yage.eu.org';
80
+
81
+ /** The Xget instances to go through, in the order tried (none with it switched off). @returns {string[]} */
82
+ function xgetBases() {
83
+ const set = process.env.AICUT_XGET?.trim();
84
+ if (set === 'off') return [];
85
+ return (set || XGET).split(',').map((one) => one.trim().replace(/\/+$/, '')).filter(Boolean);
86
+ }
87
+
88
+ /** The prefixes of that engine, by the site an address is on. */
89
+ const XGET_PREFIX = [
90
+ ['https://github.com/', 'gh'],
91
+ ['https://huggingface.co/', 'hf'],
92
+ ];
93
+
94
+ /**
95
+ * The address of a file on each Xget instance — the original address after the prefix of the site it is on — and
96
+ * none for a site that has no prefix there. @param {string} url @returns {string[]}
97
+ */
98
+ function throughXget(url) {
99
+ const site = XGET_PREFIX.find(([prefix]) => url.startsWith(prefix));
100
+ if (!site) return [];
101
+ return xgetBases().map((base) => `${base}/${site[1]}/${url.slice(site[0].length)}`);
102
+ }
103
+
104
+ /**
105
+ * The addresses one file is tried at, best first. Two orders, because the two things are not equally served by the
106
+ * same mirror: a GitHub release (a command, 9–23 MB) comes down at 0.4 MB/s through gh-proxy and stalls at nothing
107
+ * through Xget, so gh-proxy goes first and Xget is the way round if it refuses; a Hugging Face file has no such
108
+ * mirror left (hf-mirror answers nothing here), so Xget is the only one that works and goes first.
109
+ *
110
+ * The site itself is always last — the address that always exists, and the one to copy into a browser when every
111
+ * mirror is down. `fetchTo` waits out the ones that say "not now" before going round again.
112
+ *
113
+ * Exported because the separator (local-mdx.mjs) downloads its model from a GitHub release too, and the mirrors that
114
+ * work from a Chinese network are the same ones: one list, both downloads.
115
+ * @param {string} url
116
+ */
117
+ export const mirrorsOf = (url) => {
118
+ if (url.startsWith('https://huggingface.co/')) {
119
+ return [
120
+ ...throughXget(url),
121
+ url.replace('https://huggingface.co/', 'https://hf-mirror.com/'),
122
+ url,
123
+ ];
124
+ }
125
+ return [`https://gh-proxy.com/${url}`, `https://ghproxy.net/${url}`, ...throughXget(url), url];
126
+ };
127
+
128
+ /** The command that transcribes, and where it comes from. */
129
+ const CLI_RELEASE = 'https://github.com/ggml-org/whisper.cpp/releases/download/b5454/whisper-bin-x64.zip';
130
+ const CLI_URLS = mirrorsOf(CLI_RELEASE);
131
+ const CLI_NAME = process.platform === 'win32' ? 'whisper-cli.exe' : 'whisper-cli';
132
+
133
+ /* ── sherpa-onnx, for SenseVoice ─────────────────────────────────────────────────────────────────────────────────── */
134
+
135
+ /** SenseVoiceSmall in sherpa-onnx's own form: the int8 model and its tokens, one folder's worth. */
136
+ const SENSE_VOICE_REPO = 'csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17';
137
+ const SENSE_VOICE_DIR = 'sense-voice';
138
+
139
+ /**
140
+ * That same export, put on ModelScope by someone else and byte for byte the same (239,233,841 and 315,894 — checked
141
+ * against the sizes `partsOf` insists on). It is what makes SenseVoice a two-minute download here instead of a
142
+ * forty-minute one: the two files sit at the repository's root, with no folder of their own.
143
+ */
144
+ const SENSE_VOICE_MIRROR = 'poloniumrock/SenseVoiceSmallOnnx';
145
+
146
+ /**
147
+ * Where sherpa-onnx's command comes from: its release page, one archive per system. The `no-tts` ones (it does not
148
+ * speak, only listens), except the arm64 Linux build, which comes only whole.
149
+ */
150
+ const SHERPA_VERSION = '1.13.8';
151
+ const SHERPA_ASSETS = /** @type {Record<string, string>} */ ({
152
+ 'win32-x64': `sherpa-onnx-v${SHERPA_VERSION}-win-x64-shared-MT-Release-no-tts.tar.bz2`,
153
+ 'darwin-arm64': `sherpa-onnx-v${SHERPA_VERSION}-osx-arm64-shared-no-tts.tar.bz2`,
154
+ 'darwin-x64': `sherpa-onnx-v${SHERPA_VERSION}-osx-x64-shared-no-tts.tar.bz2`,
155
+ 'linux-x64': `sherpa-onnx-v${SHERPA_VERSION}-linux-x64-shared-no-tts.tar.bz2`,
156
+ 'linux-arm64': `sherpa-onnx-v${SHERPA_VERSION}-linux-aarch64-shared-cpu.tar.bz2`,
157
+ });
158
+ const SHERPA_NAME = process.platform === 'win32' ? 'sherpa-onnx-offline.exe' : 'sherpa-onnx-offline';
159
+ /** where the archive is unpacked: its `bin/` (the command and the libraries it needs) lands here */
160
+ const sherpaDir = () => join(binDir(), 'sherpa');
161
+ const sherpaBin = () => join(sherpaDir(), 'bin');
162
+
163
+ /* ── the models ─────────────────────────────────────────────────────────────────────────────────────────────────── */
164
+
165
+ /**
166
+ * @typedef {{
167
+ * id: string,
168
+ * name: string,
169
+ * engine: 'whisper' | 'sherpa',
170
+ * owner: string,
171
+ * bytes: number,
172
+ * vram?: number,
173
+ * file?: string,
174
+ * languages?: readonly string[],
175
+ * }} Model
176
+ */
177
+
178
+ /**
179
+ * The models, each in the row of the provider that runs it. `owner` is that provider's id: SenseVoice Small is a
180
+ * provider of its own, not a model lent to Whisper — it runs on a different command, and downloads on its own.
181
+ *
182
+ * The file a model lands as, how big it is, and `vram`, the memory it takes while it runs, in GB — about 1.3–1.5
183
+ * times the file: the model itself, then the f16 copy and the decoder's state on top.
184
+ * It is the graphics card's memory when whisper.cpp runs on the GPU, and plain RAM when it runs on the CPU (the
185
+ * command this Studio downloads is the CPU build) — SenseVoice runs on the CPU only, so it says no amount.
186
+ *
187
+ * `languages` are the ones a model hears well, as tabs a page translates (Settings → Providers shows them under its
188
+ * name): SenseVoiceSmall hears five, and hears Chinese and Cantonese better than any Whisper that fits in a download.
189
+ */
190
+ const MODELS = /** @type {Model[]} */ ([
191
+ {
192
+ id: 'sensevoice-small',
193
+ name: 'SenseVoice Small',
194
+ engine: 'sherpa',
195
+ owner: SENSEVOICE,
196
+ /* model.int8.onnx + tokens.txt */
197
+ bytes: 239_549_735,
198
+ languages: ['zh', 'yue', 'en', 'ja', 'ko'],
199
+ },
200
+ { id: 'small', name: 'Whisper Small', engine: 'whisper', owner: WHISPER, file: 'ggml-small.bin', bytes: 487_601_967, vram: 1.5 },
201
+ /* the smallest one whose Chinese is worth the subtitles: small drops and swaps characters */
202
+ { id: 'medium', name: 'Whisper Medium', engine: 'whisper', owner: WHISPER, file: 'ggml-medium.bin', bytes: 1_533_763_059, vram: 3 },
203
+ { id: 'large-v3', name: 'Whisper Large v3', engine: 'whisper', owner: WHISPER, file: 'ggml-large-v3.bin', bytes: 3_095_033_483, vram: 6 },
204
+ ]);
205
+
206
+ /** The models of one provider, in the order Settings lists them. @param {string} owner */
207
+ const modelsOf = (owner) => MODELS.filter((m) => m.owner === owner);
208
+
209
+ /** The provider whose command runs a model, by model id: null when no local model has that id. @param {string} id */
210
+ export const ownerOfModel = (/** @type {string} */ id) => MODELS.find((m) => m.id === id)?.owner ?? null;
211
+
212
+ /**
213
+ * The same ggml models, put on ModelScope by someone else. Checked the loud way rather than by size: the small one
214
+ * was downloaded from here and loaded by the whisper.cpp command this Studio downloads (b5454), which read it and
215
+ * transcribed — so it is the same format, whatever its build. It is what makes 465 MB three minutes instead of an
216
+ * hour and a half. The sizes below are what these files are.
217
+ */
218
+ const WHISPER_MIRROR = 'cjc1887415157/whisper.cpp';
219
+
220
+ /**
221
+ * Where a Whisper model comes from: ModelScope first (the only one that comes down at speed from here), then the
222
+ * addresses that reach Hugging Face's copy, the last of which is the site itself.
223
+ */
224
+ const modelUrls = (/** @type {string} */ file) => [
225
+ ...viaModelScope(WHISPER_MIRROR, file),
226
+ ...mirrorsOf(`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${file}`),
227
+ ];
228
+
229
+ /**
230
+ * Where a piece of SenseVoice comes from, the same way round: the ModelScope copy first, then the addresses that
231
+ * reach Hugging Face. These two are the ones whose size is known to the byte, so a copy that is another build of the
232
+ * same name loses to the one that is right.
233
+ */
234
+ const senseVoiceUrls = (/** @type {string} */ file) => [
235
+ ...viaModelScope(SENSE_VOICE_MIRROR, file),
236
+ ...mirrorsOf(`https://huggingface.co/${SENSE_VOICE_REPO}/resolve/main/${file}`),
237
+ ];
238
+
239
+ /**
240
+ * What a model is made of: each file it needs, where it comes from, and where it lands under the models folder.
241
+ * Whisper models are one file; SenseVoice is the model and the tokens it reads them with. `exact` says the size is
242
+ * the truth and not an approximation — the two of SenseVoice are, which is what lets a copy taken from ModelScope be
243
+ * told apart from another build of the same name.
244
+ * @param {Model} model
245
+ */
246
+ function partsOf(model) {
247
+ if (model.engine === 'sherpa') {
248
+ return [
249
+ { path: `${SENSE_VOICE_DIR}/model.int8.onnx`, bytes: 239_233_841, exact: true, urls: senseVoiceUrls('model.int8.onnx') },
250
+ { path: `${SENSE_VOICE_DIR}/tokens.txt`, bytes: 315_894, exact: true, urls: senseVoiceUrls('tokens.txt') },
251
+ ];
252
+ }
253
+ const file = model.file ?? '';
254
+ return [{ path: file, bytes: model.bytes, urls: modelUrls(file) }];
255
+ }
256
+
257
+ /** The downloads running now (and the last that failed): id → how far it has gone, 0..1. */
258
+ const downloads = new Map();
259
+
260
+ /** Whether a path exists. @param {string} path */
261
+ const there = async (path) => { try { await stat(path); return true; } catch { return false; } };
262
+
263
+ /** Whether every file of a model is here. @param {Model} model */
264
+ async function whole(model) {
265
+ for (const one of partsOf(model)) if (!await there(join(modelsDir(), one.path))) return false;
266
+ return true;
267
+ }
268
+
269
+ /* ── the commands ───────────────────────────────────────────────────────────────────────────────────────────────── */
270
+
271
+ /** The whisper.cpp command, found once where it was unpacked (or set in AICUT_WHISPER_CLI). */
272
+ let whisperAt = '';
273
+ async function whisperCli() {
274
+ const set = process.env.AICUT_WHISPER_CLI?.trim();
275
+ if (set) return set;
276
+ if (whisperAt && await there(whisperAt)) return whisperAt;
277
+ const direct = join(binDir(), CLI_NAME);
278
+ if (await there(direct)) return (whisperAt = direct);
279
+ /* unpacked one folder deep: find it */
280
+ try {
281
+ for (const one of await readdir(binDir(), { withFileTypes: true })) {
282
+ const found = one.isDirectory() ? join(binDir(), one.name, CLI_NAME) : '';
283
+ if (found && await there(found)) return (whisperAt = found);
284
+ }
285
+ } catch { /* no bin yet */ }
286
+ return '';
287
+ }
288
+
289
+ /** The sherpa-onnx command, found once where it was unpacked (or set in AICUT_SHERPA_CLI). */
290
+ let sherpaAt = '';
291
+ async function sherpaCli() {
292
+ const set = process.env.AICUT_SHERPA_CLI?.trim();
293
+ if (set) return set;
294
+ if (sherpaAt && await there(sherpaAt)) return sherpaAt;
295
+ const direct = join(sherpaBin(), SHERPA_NAME);
296
+ if (await there(direct)) return (sherpaAt = direct);
297
+ /* unpacked with its own folder kept: find it one deep */
298
+ try {
299
+ for (const one of await readdir(sherpaBin(), { withFileTypes: true })) {
300
+ const found = one.isDirectory() ? join(sherpaBin(), one.name, SHERPA_NAME) : '';
301
+ if (found && await there(found)) return (sherpaAt = found);
302
+ }
303
+ } catch { /* no bin yet */ }
304
+ return '';
305
+ }
306
+
307
+ /** Whether whisper.cpp's command is here (the models are asked about separately). */
308
+ export const hasCli = async () => Boolean(await whisperCli());
309
+
310
+ /* ── what Settings shows ────────────────────────────────────────────────────────────────────────────────────────── */
311
+
312
+ /**
313
+ * What one of the two shows in Settings: whether its command is here, and each of its models with whether it is
314
+ * here, the languages it hears, and how far the one downloading has gone.
315
+ * @param {string} [owner] the provider id — Whisper's when none is named
316
+ */
317
+ export async function status(owner = WHISPER) {
318
+ const models = modelsOf(owner);
319
+ const command = owner === SENSEVOICE ? await sherpaCli() : await whisperCli();
320
+ return {
321
+ bin: Boolean(command),
322
+ path: command || null,
323
+ models: await Promise.all(models.map(async (m) => ({
324
+ id: m.id,
325
+ name: m.name,
326
+ bytes: m.bytes,
327
+ ...(m.vram !== undefined ? { vram: m.vram } : {}),
328
+ ...(m.languages ? { languages: [...m.languages] } : {}),
329
+ downloaded: await whole(m),
330
+ progress: downloads.get(m.id)?.progress ?? null,
331
+ error: downloads.get(m.id)?.error ?? null,
332
+ }))),
333
+ };
334
+ }
335
+
336
+ /**
337
+ * The model a run uses: the one asked for when it is here; any that is here when it is not (a model downloaded and
338
+ * then moved is not worth failing over while another can hear); else the one asked for, or the smallest of its
339
+ * provider, so the message can name what to download.
340
+ * @param {string} owner @param {string} [modelId]
341
+ */
342
+ async function pick(owner, modelId) {
343
+ const own = modelsOf(owner);
344
+ const asked = own.find((m) => m.id === modelId);
345
+ if (asked && await whole(asked)) return asked;
346
+ for (const m of own) if (await whole(m)) return m;
347
+ return asked ?? own.find((m) => m.id === 'small') ?? own[0];
348
+ }
349
+
350
+ /** Whether a run could go now: the command its model needs, and that model, are here. @param {string} modelId */
351
+ export async function usable(modelId) {
352
+ const model = MODELS.find((m) => m.id === modelId);
353
+ if (!model || !await whole(model)) return false;
354
+ return Boolean(model.engine === 'sherpa' ? await sherpaCli() : await whisperCli());
355
+ }
356
+
357
+ /** Whether any model of that provider is here (whether it counts as connected). @param {string} [owner] */
358
+ export async function anyModel(owner = WHISPER) {
359
+ for (const m of modelsOf(owner)) if (await whole(m)) return true;
360
+ return false;
361
+ }
362
+
363
+ /* ── downloads ──────────────────────────────────────────────────────────────────────────────────────────────────── */
364
+
365
+ /** Whether a mirror's answer is "not now" rather than "not here". */
366
+ const WAIT = new Set([408, 425, 429, 500, 502, 503, 504]);
367
+ /** How long a wait comes before the addresses are gone round again (milliseconds, one per round). */
368
+ const BACKOFF = [2000, 8000, 20000];
369
+ /** The longest a mirror can ask to be left alone for (`Retry-After`): past that the next address is worth a try. */
370
+ const LONGEST_WAIT = 30_000;
371
+
372
+ /**
373
+ * One fetch of a big file, to `.part` and renamed when whole; how far it has gone goes to `downloads` (or wherever
374
+ * `onProgress` puts it, a model of several files being more than one number). The first address that answers wins.
375
+ *
376
+ * The addresses are gone round until one answers or none can: an address that says "not now" — Xget's public
377
+ * instances rate-limit a burst of requests with 429 — leaves what it already sent in `.part`, the next address is
378
+ * tried, and after a wait the same ones are asked again; the request that follows asks for a range, so a 240 MB
379
+ * model cut off at 200 MB costs the last 40 rather than the whole. An address that says "not here" (404, 403) is
380
+ * written off at once. A connection that broke keeps its bytes, and is gone round for again only when it had been
381
+ * sending something — an address that answers nothing at all never will, and is not worth the wait.
382
+ *
383
+ * Exported for the test: it is the one part of a download that needs no model and no command, so a stand-in server
384
+ * can say what a mirror says and the answer be checked.
385
+ * @param {string} id what the download is called in `downloads`
386
+ * @param {string[]} urls the addresses tried in order
387
+ * @param {string} to the file it lands as
388
+ * @param {(seen: number, total: number) => void} [onProgress]
389
+ * @param {number} [expect] the size this file is, when that is known exactly: what a copy taken from a mirror
390
+ * someone else made is checked against, so another build of the same name loses to the address that has the right
391
+ * one instead of becoming a model that will not load
392
+ */
393
+ export async function fetchTo(id, urls, to, onProgress, expect) {
394
+ await mkdir(dirname(to), { recursive: true });
395
+ const part = `${to}.part`;
396
+ const report = onProgress ?? ((seen, total) => { if (total) downloads.set(id, { progress: Math.min(0.99, seen / total) }); });
397
+ /** what `.part` holds: what the last try sent, which the next one goes on from */
398
+ const held = async () => { try { return (await stat(part)).size; } catch { return 0; } };
399
+ let last = null;
400
+ for (let round = 0; ; round += 1) {
401
+ const before = await held();
402
+ /** whether anything this round said "not now": the only reason to go round again */
403
+ let again = false;
404
+ /** and whether a connection broke — worth another round only if it had been sending something */
405
+ let broken = false;
406
+ /** the longest any of them asked to be left alone for */
407
+ let after = 0;
408
+ for (const url of urls) {
409
+ try {
410
+ const have = await held();
411
+ const res = await fetch(url, { redirect: 'follow', headers: have > 0 ? { range: `bytes=${have}-` } : {} });
412
+ if (res.status === 416) {
413
+ /* `.part` is already all of it (or more than all of it): start over rather than stitch a lie together */
414
+ await rm(part, { force: true });
415
+ throw Object.assign(new Error('HTTP 416'), { status: 416 });
416
+ }
417
+ if (!res.ok || !res.body) {
418
+ /* how long it wants to be left alone, when it says (Xget does, with a 429) */
419
+ const seconds = Number(res.headers.get('retry-after')) || 0;
420
+ await res.body?.cancel();
421
+ throw Object.assign(new Error(`HTTP ${res.status}`), {
422
+ status: res.status,
423
+ ...(seconds > 0 ? { after: Math.min(LONGEST_WAIT, seconds * 1000) } : {}),
424
+ });
425
+ }
426
+ const more = res.status === 206 && have > 0;
427
+ const whole = Number(res.headers.get('content-range')?.split('/')[1]) || 0;
428
+ const total = whole || (Number(res.headers.get('content-length')) || 0) + (more ? have : 0);
429
+ /* a copy someone else put up under the same name can be another build of it: its size says which one */
430
+ if (expect && whole && whole !== expect) throw new Error(`不是同一个文件(${whole} 字节,应当是 ${expect})`);
431
+ let seen = more ? have : 0;
432
+ const counted = new Transform({
433
+ transform(chunk, _encoding, done) {
434
+ seen += /** @type {Buffer} */ (chunk).length;
435
+ report(seen, total);
436
+ done(null, chunk);
437
+ },
438
+ });
439
+ await pipeline(
440
+ Readable.fromWeb(/** @type {import('node:stream/web').ReadableStream} */ (res.body)),
441
+ counted,
442
+ createWriteStream(part, { flags: more ? 'a' : 'w' }),
443
+ );
444
+ const landed = (await stat(part)).size;
445
+ if (expect && landed !== expect) {
446
+ await rm(part, { force: true });
447
+ throw new Error(`下到的不是同一个文件(${landed} 字节,应当是 ${expect})`);
448
+ }
449
+ await rename(part, to);
450
+ return;
451
+ } catch (e) {
452
+ last = e;
453
+ const { status, after: wanted } = /** @type {{ status?: number, after?: number }} */ (e) ?? {};
454
+ /* "not here" is the next address's business; "not now" is worth waiting out, its bytes kept; a connection
455
+ that broke is only worth another round if it was sending (an address that answers nothing never will) */
456
+ if (typeof status !== 'number') { broken = true; continue; }
457
+ if (!WAIT.has(status)) continue;
458
+ again = true;
459
+ if (wanted && wanted > after) after = wanted;
460
+ }
461
+ }
462
+ if (!again && broken && await held() > before) again = true;
463
+ const backoff = BACKOFF[round];
464
+ if (!again || backoff === undefined) break;
465
+ await new Promise((done) => setTimeout(done, Math.min(LONGEST_WAIT, Math.max(backoff, after))));
466
+ }
467
+ throw new Error(`下载失败(${String(/** @type {Error} */ (last)?.message ?? last)})。检查网络后重试(未下完的部分留着,会接着下),或从 ${urls[0]} 手动下载后放到 ${to}。换源:AICUT_MODELSCOPE=off 关掉魔搭,AICUT_XGET=https://你的实例 换加速站`);
468
+ }
469
+
470
+ /**
471
+ * Every file of a model that is not here yet, one after the other, the one progress bar covering them all (each file
472
+ * counts for the bytes it is, not the same as the last). `from` is where on the bar these bytes start: the command a
473
+ * model needs comes first, and its download is a share of the wait too.
474
+ * @param {string} id @param {{ path: string, bytes: number, exact?: boolean, urls: string[] }[]} parts @param {number} total @param {number} [from]
475
+ */
476
+ async function fetchParts(id, parts, total, from = 0) {
477
+ let base = 0;
478
+ /* the bar from `from` to just short of the end: 0.99 is as far as a download goes (unpacking comes after) */
479
+ const span = 0.99 - from;
480
+ for (const one of parts) {
481
+ const to = join(modelsDir(), one.path);
482
+ if (!await there(to)) {
483
+ await fetchTo(id, one.urls, to, (seen, all) => {
484
+ const done = base + (all ? Math.min(seen, one.bytes) : 0);
485
+ downloads.set(id, { progress: Math.min(0.99, from + (span * done) / total) });
486
+ }, one.exact ? one.bytes : undefined);
487
+ }
488
+ base += one.bytes;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * The whisper.cpp command, downloaded and unpacked once. On Windows with PowerShell's own unzip (there is no tar that
494
+ * reads zip everywhere); elsewhere `unzip` or `tar`, whichever is there.
495
+ * @param {string} zip @param {string} to
496
+ */
497
+ async function unpack(zip, to) {
498
+ const one = process.platform === 'win32'
499
+ ? spawn('powershell.exe', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath "$env:WHISPER_ZIP" -DestinationPath "$env:WHISPER_TO" -Force`],
500
+ { windowsHide: true, stdio: 'ignore', env: { ...process.env, WHISPER_ZIP: zip, WHISPER_TO: to } })
501
+ : spawn('sh', ['-c', 'unzip -o "$1" -d "$2" || tar -xf "$1" -C "$2"', 'sh', zip, to], { stdio: 'ignore' });
502
+ await new Promise((done, fail) => {
503
+ one.on('error', fail);
504
+ one.on('close', (code) => (code === 0 ? done(undefined) : fail(new Error(`解压失败(exit ${code})`))));
505
+ });
506
+ }
507
+
508
+ /**
509
+ * An archive unpacked without the folder it came in, so what lies inside lands where it was told to.
510
+ *
511
+ * It used to be `tar -xjf` on this file's input, and `spawn('tar')` answered ENOENT: this machine's System32 has no
512
+ * `tar.exe` at all, so the archive came down whole and then could not be opened. `tar-bz2.mjs` reads the format here
513
+ * instead, which needs nothing installed on the machine.
514
+ * @param {string} file @param {string} to
515
+ */
516
+ async function untar(file, to) {
517
+ await extractTarBz2(file, to);
518
+ }
519
+
520
+ /** The whisper.cpp command, downloaded and unpacked once. @param {(fraction: number) => void} [onProgress] */
521
+ async function fetchCli(onProgress) {
522
+ if (process.env.AICUT_WHISPER_CLI?.trim() || await whisperCli()) return;
523
+ await mkdir(binDir(), { recursive: true });
524
+ const zip = join(binDir(), 'whisper-bin.zip');
525
+ try {
526
+ await fetchTo('cli', CLI_URLS, zip, (seen, all) => onProgress?.(all ? Math.min(1, seen / all) : 0));
527
+ } catch (e) {
528
+ throw new Error(`识别程序下载失败:${String(/** @type {Error} */ (e)?.message ?? e)}`);
529
+ }
530
+ try {
531
+ await unpack(zip, binDir());
532
+ } catch (e) {
533
+ throw new Error(`${String(/** @type {Error} */ (e)?.message ?? e)}:可手动把 ${CLI_RELEASE} 解压到 ${binDir()}`);
534
+ }
535
+ await rm(zip, { force: true });
536
+ if (!await whisperCli()) throw new Error(`解压后没有找到 ${CLI_NAME}(在 ${binDir()})`);
537
+ }
538
+
539
+ /** The sherpa-onnx command, downloaded and unpacked once (what runs SenseVoice). @param {(fraction: number) => void} [onProgress] */
540
+ async function fetchSherpa(onProgress) {
541
+ if (process.env.AICUT_SHERPA_CLI?.trim() || await sherpaCli()) return;
542
+ const asset = SHERPA_ASSETS[`${process.platform}-${process.arch}`];
543
+ if (!asset) {
544
+ throw new Error(`SenseVoice 还没有 ${process.platform} ${process.arch} 上的现成程序:改用 Whisper 模型,或自己编译 sherpa-onnx 后用 AICUT_SHERPA_CLI 指定它。`);
545
+ }
546
+ const release = `https://github.com/k2-fsa/sherpa-onnx/releases/download/v${SHERPA_VERSION}/${asset}`;
547
+ await mkdir(sherpaBin(), { recursive: true });
548
+ const tar = join(binDir(), 'sherpa-onnx.tar.bz2');
549
+ try {
550
+ await fetchTo('cli', mirrorsOf(release), tar,
551
+ (seen, all) => onProgress?.(all ? Math.min(1, seen / all) : 0));
552
+ } catch (e) {
553
+ throw new Error(`识别程序下载失败:${String(/** @type {Error} */ (e)?.message ?? e)}`);
554
+ }
555
+ try {
556
+ await untar(tar, sherpaDir());
557
+ } catch (e) {
558
+ throw new Error(`${String(/** @type {Error} */ (e)?.message ?? e)}:可手动把 ${release} 解压到 ${sherpaDir()}`);
559
+ }
560
+ await rm(tar, { force: true });
561
+ if (!await sherpaCli()) throw new Error(`解压后没有找到 ${SHERPA_NAME}(在 ${sherpaDir()})`);
562
+ }
563
+
564
+ /**
565
+ * A model (or, with it, the command) downloaded in the background; the Settings page watches `status()` for how far
566
+ * it has gone. The promise settles when it is done or has failed; the failure is also kept for the page to show.
567
+ *
568
+ * The bar is on from the first moment — before the command is fetched, before a byte of the model is — so a press
569
+ * that started something is never a press that looks like it did nothing (a model of several files, one of which is
570
+ * a 22 MB command, can be quiet for a while otherwise).
571
+ * @param {string} modelId
572
+ */
573
+ export function download(modelId) {
574
+ const want = MODELS.find((m) => m.id === modelId);
575
+ if (!want) throw new Error(`no such local model: ${modelId}`);
576
+ const run = (async () => {
577
+ try {
578
+ downloads.set(want.id, { progress: 0 });
579
+ /* the command is the first tenth of the wait (it is unpacked after, which the bar holds at that tenth until
580
+ the model itself starts coming) */
581
+ const program = (/** @type {number} */ fraction) => downloads.set(want.id, { progress: Math.min(0.09, fraction * 0.09) });
582
+ if (want.engine === 'sherpa') await fetchSherpa(program);
583
+ else await fetchCli(program);
584
+ await fetchParts(want.id, partsOf(want), want.bytes, 0.09);
585
+ downloads.delete(want.id);
586
+ /* the first model here is transcription working out of the box: a kind with no choice goes to it, and to the
587
+ model that came when none was named (a download is not a choice made yet) */
588
+ const { readSettings, changeSettings } = await import('../keys.mjs');
589
+ const s = await readSettings();
590
+ if (!s.use.asr || !s.model.asr) {
591
+ await changeSettings((next) => {
592
+ if (!next.use.asr) next.use.asr = want.owner;
593
+ if (!next.model.asr) next.model.asr = want.id;
594
+ });
595
+ }
596
+ } catch (e) {
597
+ downloads.set(want.id, { progress: null, error: String(/** @type {Error} */ (e)?.message ?? e) });
598
+ throw e;
599
+ }
600
+ })();
601
+ run.catch(() => { /* the error stays in `downloads` for the page; the promise is not left to reject alone */ });
602
+ return run;
603
+ }
604
+
605
+ /** A model taken away (the command stays). @param {string} modelId */
606
+ export async function remove(modelId) {
607
+ const want = MODELS.find((m) => m.id === modelId);
608
+ if (!want) throw new Error(`no such local model: ${modelId}`);
609
+ if (downloads.has(want.id)) throw new Error('正在下载,先等它完成');
610
+ for (const one of partsOf(want)) await rm(join(modelsDir(), one.path), { force: true });
611
+ /* a model that comes in its own folder takes the folder with it */
612
+ if (want.engine === 'sherpa') await rm(join(modelsDir(), SENSE_VOICE_DIR), { recursive: true, force: true });
613
+ }
614
+
615
+ /* ── hearing ───────────────────────────────────────────────────────────────────────────────────────────────────── */
616
+
617
+ /**
618
+ * A command, its whole output back — stdout and stderr together, these tools talking on both — and a non-zero exit
619
+ * thrown with the end of what it said (the end is where a failing tool says why).
620
+ * @param {string} command @param {string[]} args @param {import('./contract.mjs').RunContext} ctx @param {string} label
621
+ */
622
+ function run(command, args, ctx, label) {
623
+ return new Promise((done, fail) => {
624
+ const one = spawn(command, args, { windowsHide: true });
625
+ /** @type {Buffer[]} */
626
+ const said = [];
627
+ one.stdout.on('data', (b) => said.push(b));
628
+ one.stderr.on('data', (b) => said.push(b));
629
+ one.on('error', fail);
630
+ one.on('close', (code) => {
631
+ const out = Buffer.concat(said).toString('utf8');
632
+ if (code === 0) done(out);
633
+ else fail(new Error(`${label} 结束于 ${code}:${out.slice(-400)}`));
634
+ });
635
+ ctx.signal.addEventListener('abort', () => { try { one.kill(); } catch { /* already gone */ } }, { once: true });
636
+ });
637
+ }
638
+
639
+ /**
640
+ * The speech of a project file, as a 16 kHz mono WAV at `to` (what both commands hear).
641
+ * @param {string} to @param {string} src @param {import('./contract.mjs').RunContext} ctx
642
+ */
643
+ async function speechInto(to, src, ctx) {
644
+ const dir = dirname(to);
645
+ const raw = join(dir, basename(src) || 'speech.bin');
646
+ await writeFile(raw, await ctx.read(src));
647
+ await new Promise((done, fail) => {
648
+ spawn('ffmpeg', ['-y', '-v', 'error', '-i', raw, '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', to],
649
+ { windowsHide: true, stdio: 'ignore' })
650
+ .on('close', (code) => (code === 0 ? done(undefined) : fail(new Error('转写需要 ffmpeg:请先安装(Windows: winget install ffmpeg)'))))
651
+ .on('error', () => fail(new Error('转写需要 ffmpeg:请先安装(Windows: winget install ffmpeg)')));
652
+ });
653
+ await rm(raw, { force: true });
654
+ }
655
+
656
+ /**
657
+ * How long a WAV is, from its own header: what its `data` chunk holds and how many bytes a second. 0 when that is not
658
+ * to be had (a shape this Studio did not write) — the end of the audio is then not held against a word.
659
+ * @param {string} file
660
+ */
661
+ async function wavSeconds(file) {
662
+ const handle = await open(file, 'r');
663
+ try {
664
+ const head = Buffer.alloc(4096);
665
+ const { bytesRead } = await handle.read(head, 0, head.length, 0);
666
+ const said = head.subarray(0, bytesRead);
667
+ if (said.length < 12 || said.toString('ascii', 0, 4) !== 'RIFF' || said.toString('ascii', 8, 12) !== 'WAVE') return 0;
668
+ /* RIFF → WAVE, then its chunks: `fmt ` says how many bytes a second, `data` how many there are */
669
+ let rate = 0;
670
+ for (let at = 12; at + 8 <= said.length;) {
671
+ const id = said.toString('ascii', at, at + 4);
672
+ const size = said.readUInt32LE(at + 4);
673
+ if (id === 'fmt ' && at + 20 <= said.length) rate = said.readUInt32LE(at + 16);
674
+ if (id === 'data') {
675
+ const bytes = size && size !== 0xffff_ffff ? size : (await handle.stat()).size - at - 8;
676
+ return rate > 0 && bytes > 0 ? bytes / rate : 0;
677
+ }
678
+ at += 8 + size + (size % 2);
679
+ }
680
+ return 0;
681
+ } finally {
682
+ await handle.close();
683
+ }
684
+ }
685
+
686
+ /** The spoken words of whisper.cpp's full JSON, with its marker tokens (`[_BEG_]` and friends) left out. */
687
+ const wordsOf = (/** @type {any} */ body) => (Array.isArray(body?.transcription) ? body.transcription : [])
688
+ .flatMap((/** @type {any} */ seg) => Array.isArray(seg?.tokens) ? seg.tokens : [])
689
+ .filter((/** @type {any} */ w) => typeof w?.offsets?.from === 'number' && typeof w?.offsets?.to === 'number'
690
+ && !(typeof w.text === 'string' && w.text.startsWith('[') && w.text.endsWith(']')))
691
+ .map((/** @type {any} */ w) => ({ token: String(w.text ?? '').trim(), start: w.offsets.from / 1000, end: w.offsets.to / 1000 }))
692
+ .filter((/** @type {{ token: string }} */ w) => w.token);
693
+
694
+ /** The seconds, to a hundredth: what a subtitle can use and no more. */
695
+ const hundredth = (/** @type {number} */ seconds) => Math.round(seconds * 100) / 100;
696
+
697
+ /** A marker token of SenseVoice's own (`<|zh|>`, `<|NEUTRAL|>`, `<|Speech|>`): in the result's own fields, not the words. */
698
+ const marker = /^<\|.*\|>$/;
699
+
700
+ /** How long a token is taken to last when the model times only where it starts, and a pause follows it (seconds). */
701
+ const TOKEN_MS = 0.4;
702
+
703
+ /**
704
+ * SenseVoice's JSON read as timed words and text: one token each, timed where it starts, ended where the next begins
705
+ * (or, after the last, a token's worth on). A gap between tokens is the pause that follows one, not part of it, so no
706
+ * word is stretched past where a person stopped speaking.
707
+ * @param {any} body
708
+ * @returns {{ words: { token: string, start: number, end: number }[], text: string }}
709
+ */
710
+ export function senseVoiceResult(body) {
711
+ const tokens = Array.isArray(body?.tokens) ? body.tokens : [];
712
+ const starts = Array.isArray(body?.timestamps) ? body.timestamps : [];
713
+ const lengths = Array.isArray(body?.durations) ? body.durations : [];
714
+ /** @type {{ token: string, start: number, end: number }[]} */
715
+ const words = [];
716
+ for (let i = 0; i < tokens.length; i += 1) {
717
+ const token = String(tokens[i] ?? '').trim();
718
+ const start = Number(starts[i]);
719
+ if (!token || marker.test(token) || !Number.isFinite(start) || start < 0) continue;
720
+ const own = Number(lengths[i]);
721
+ const next = Number(starts[i + 1]);
722
+ const end = Number.isFinite(own) && own > 0 ? start + own
723
+ : Number.isFinite(next) && next > start ? Math.min(next, start + 0.5)
724
+ : start + TOKEN_MS;
725
+ words.push({ token, start: hundredth(start), end: hundredth(Math.max(end, start + 0.04)) });
726
+ }
727
+ const said = typeof body?.text === 'string' ? body.text : '';
728
+ return { words, text: (said.replace(/<\|[^|]*\|>/g, ' ').replace(/\s+/g, ' ').trim()) };
729
+ }
730
+
731
+ /** The JSON line sherpa-onnx writes among its chatter (the whole output, one line being the result). */
732
+ function heardJson(/** @type {string} */ out) {
733
+ for (const line of out.split(/\r?\n/)) {
734
+ const one = line.trim();
735
+ if (!one.startsWith('{') || !one.endsWith('}')) continue;
736
+ try {
737
+ const body = JSON.parse(one);
738
+ if (typeof body?.text === 'string') return body;
739
+ } catch { /* not the one */ }
740
+ }
741
+ return null;
742
+ }
743
+
744
+ /**
745
+ * whisper.cpp on a WAV: the words it timed and the text.
746
+ * @param {Model} model @param {string} dir @param {string} wav @param {import('./contract.mjs').RunContext} ctx
747
+ * @returns {Promise<{ words: { token: string, start: number, end: number }[], text: string }>}
748
+ */
749
+ async function whisperHeard(model, dir, wav, ctx) {
750
+ const command = await whisperCli();
751
+ if (!command) throw new Error(`${NAME} 还没有可运行的程序:在设置 → 服务商里下载一个模型(会连同程序一起下载)。`);
752
+ const out = join(dir, 'heard');
753
+ await run(command, ['-m', join(modelsDir(), model.file ?? ''), '-f', wav, '-ojf', '-of', out], ctx, 'whisper-cli');
754
+ const body = JSON.parse(await readFile(`${out}.json`, 'utf8'));
755
+ const words = wordsOf(body);
756
+ const text = (Array.isArray(body?.transcription) ? body.transcription : [])
757
+ .map((/** @type {any} */ s) => String(s?.text ?? '').trim()).filter(Boolean).join(' ');
758
+ return { words, text };
759
+ }
760
+
761
+ /**
762
+ * sherpa-onnx on a WAV: SenseVoice detects the language itself (普通话、粤语、英语、日语、韩语), and its JSON comes
763
+ * back with a time for every token.
764
+ * @param {Model} model @param {string} dir @param {string} wav @param {import('./contract.mjs').RunContext} ctx
765
+ */
766
+ async function senseVoiceHeard(model, dir, wav, ctx) {
767
+ const command = await sherpaCli();
768
+ if (!command) throw new Error(`${SENSE_NAME} 还没有可运行的程序:在设置 → 服务商里下载 ${model.name}(会连同程序一起下载)。`);
769
+ const folder = join(modelsDir(), SENSE_VOICE_DIR);
770
+ const threads = Math.max(1, Math.min(4, cpus().length || 1));
771
+ const out = await run(command, [
772
+ `--tokens=${join(folder, 'tokens.txt')}`,
773
+ `--sense-voice-model=${join(folder, 'model.int8.onnx')}`,
774
+ '--sense-voice-language=auto',
775
+ '--sense-voice-use-itn=true',
776
+ `--num-threads=${threads}`,
777
+ wav,
778
+ ], ctx, SHERPA_NAME);
779
+ const body = heardJson(out);
780
+ if (!body) throw new Error(`${SHERPA_NAME} 没有给出结果:${out.trim().slice(-400)}`);
781
+ return senseVoiceResult(body);
782
+ }
783
+
784
+ /**
785
+ * Transcribes one file of the project: its speech alone (as `read` gives it) made into a 16 kHz WAV, the model's own
786
+ * command run on it, its JSON read back. Auto-detects the language; the words come timed.
787
+ * @param {string} root @param {string} src @param {import('./contract.mjs').RunContext} ctx
788
+ * @param {string} owner which of the two is asked for this file
789
+ */
790
+ async function transcribe(root, src, ctx, owner) {
791
+ const model = await pick(owner, ctx.model);
792
+ if (!await whole(model)) {
793
+ throw new Error(`${nameOf(model)} 还没有下载 ${model.name}:在设置 → 服务商里下载,或换一个已下载的模型。`);
794
+ }
795
+ const dir = await mkdtemp(join(tmpdir(), 'aicut-whisper-'));
796
+ try {
797
+ const wav = join(dir, 'speech.wav');
798
+ await speechInto(wav, src, ctx);
799
+ const heard = model.engine === 'sherpa'
800
+ ? await senseVoiceHeard(model, dir, wav, ctx)
801
+ : await whisperHeard(model, dir, wav, ctx);
802
+ if (!heard.text) throw new Error('没有听到话(音频里没有可识别的语音)。');
803
+ /* a word cannot outlast the sound it was heard in: the last one of a run is taken to end where the audio does */
804
+ const length = await wavSeconds(wav);
805
+ const words = length > 0 ? heard.words.map((w) => (w.end > length ? { ...w, end: hundredth(length) } : w)) : heard.words;
806
+ const dur = words.length ? hundredth(words[words.length - 1].end) : undefined;
807
+ return {
808
+ files: [],
809
+ index: [{ src, ...(dur !== undefined ? { dur } : {}), text: heard.text, words, transcribed: true }],
810
+ receipt: { provider: owner, model: model.id, src, text: heard.text, words: words.length },
811
+ cost: { note: '本地运行,不花钱' },
812
+ };
813
+ } finally {
814
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
815
+ }
816
+ }
817
+
818
+ /** The provider both of them share, filled in for one: its id and name, and the models it runs. */
819
+ function providerOf(/** @type {string} */ owner, /** @type {string} */ name, /** @type {string} */ env, /** @type {string} */ keysUrl) {
820
+ return /** @type {Provider} */ ({
821
+ id: owner,
822
+ name,
823
+ env,
824
+ keysUrl,
825
+ billingUrl: keysUrl,
826
+ local: true,
827
+ /* connected once a model of it (and the program that runs it) is here: index.mjs asks this before handing it a
828
+ file */
829
+ ready: async () => { for (const m of modelsOf(owner)) if (await whole(m) && await usable(m.id)) return true; return false; },
830
+ verbs: ['asr'],
831
+ models: { asr: modelsOf(owner).map(({ id, name: modelName }) => ({ id, name: modelName })) },
832
+ async run(verb, args, ctx) {
833
+ if (verb !== 'asr') throw new Error(`${name} only transcribes.`);
834
+ const src = String(args.src ?? '');
835
+ if (!src) throw new Error('which file? --src');
836
+ return transcribe(ctx.root, src, ctx, owner);
837
+ },
838
+ });
839
+ }
840
+
841
+ /** Whisper's own models, run by whisper.cpp. */
842
+ export const whisper = providerOf(WHISPER, NAME, 'AICUT_WHISPER_CLI', 'https://github.com/ggml-org/whisper.cpp');
843
+
844
+ /** SenseVoice Small, a provider of its own: run by sherpa-onnx, one model, five languages. */
845
+ export const senseVoice = providerOf(SENSEVOICE, SENSE_NAME, 'AICUT_SHERPA_CLI', 'https://github.com/k2-fsa/sherpa-onnx');
846
+
847
+ export default whisper;