dictawhisper 0.0.13 → 0.0.14

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 (60) hide show
  1. package/README.md +10 -2
  2. package/bin/dictawhisper.mjs +14 -0
  3. package/config.example.json +41 -0
  4. package/dist/ui/_app/immutable/assets/0.C6M3gHo0.css +1 -0
  5. package/dist/ui/_app/immutable/assets/2.C96vgj_4.css +1 -0
  6. package/dist/ui/_app/immutable/chunks/BSs9MmST.js +3 -0
  7. package/dist/ui/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  8. package/dist/ui/_app/immutable/chunks/BslYtab-.js +1 -0
  9. package/dist/ui/_app/immutable/chunks/xihTtKlq.js +1 -0
  10. package/dist/ui/_app/immutable/entry/app.BJlRju4c.js +2 -0
  11. package/dist/ui/_app/immutable/entry/start.C5XBbXTT.js +1 -0
  12. package/dist/ui/_app/immutable/nodes/0.D23pkrHr.js +1 -0
  13. package/dist/ui/_app/immutable/nodes/1.C5T-U4uV.js +1 -0
  14. package/dist/ui/_app/immutable/nodes/2.59uAq_ko.js +5 -0
  15. package/dist/ui/_app/version.json +1 -0
  16. package/dist/ui/favicon.png +0 -0
  17. package/dist/ui/favicon.svg +3 -0
  18. package/dist/ui/index.html +38 -0
  19. package/dist/ui/logo.png +0 -0
  20. package/package.json +12 -3
  21. package/scripts/transcribe_faster_whisper.py +428 -0
  22. package/src/apiRoutes.ts +464 -0
  23. package/src/classes/Queue.ts +77 -0
  24. package/src/classes/Watcher.ts +117 -0
  25. package/src/cli.ts +132 -0
  26. package/src/config.ts +238 -0
  27. package/src/dev.ts +63 -0
  28. package/src/doctor.ts +12 -0
  29. package/src/lib/alignLib.ts +154 -0
  30. package/src/lib/audioLib.ts +202 -0
  31. package/src/lib/audioProbeLib.ts +112 -0
  32. package/src/lib/cleanupProvenanceLib.ts +80 -0
  33. package/src/lib/downloadLib.ts +8 -0
  34. package/src/lib/fileSettleLib.ts +213 -0
  35. package/src/lib/fsLib.ts +37 -0
  36. package/src/lib/healthLib.ts +377 -0
  37. package/src/lib/inboxUrl.ts +123 -0
  38. package/src/lib/journalEmbedLib.ts +127 -0
  39. package/src/lib/journalIndexLib.ts +923 -0
  40. package/src/lib/journalQueryLib.ts +269 -0
  41. package/src/lib/journalService.ts +287 -0
  42. package/src/lib/jsonLib.ts +71 -0
  43. package/src/lib/localberthGet.ts +14 -0
  44. package/src/lib/ollanetLib.ts +117 -0
  45. package/src/lib/ollanetReadyLib.ts +7 -0
  46. package/src/lib/organizationLib.ts +182 -0
  47. package/src/lib/pathAllowLib.ts +60 -0
  48. package/src/lib/queueLib.ts +30 -0
  49. package/src/lib/speechCleanupLib.ts +14 -0
  50. package/src/lib/tagConsolidateLib.ts +431 -0
  51. package/src/lib/tailscaleLib.ts +78 -0
  52. package/src/lib/transcriptionLib.ts +484 -0
  53. package/src/lib/whisperLib.ts +409 -0
  54. package/src/mcp.ts +151 -0
  55. package/src/prompts/cleanTranscription.ts +62 -0
  56. package/src/prompts/consolidateTags.ts +38 -0
  57. package/src/prompts/whisperInitial.ts +11 -0
  58. package/src/server.ts +220 -0
  59. package/src/socketEvents.ts +72 -0
  60. package/src/types/transcription.ts +56 -0
package/src/cli.ts ADDED
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, type ChildProcess } from 'node:child_process';
3
+ import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ const exampleConfig = path.join(packageRoot, 'config.example.json');
10
+ const serverPath = path.join(packageRoot, 'src', 'server.ts');
11
+ const doctorPath = path.join(packageRoot, 'src', 'doctor.ts');
12
+ const uiDir = path.join(packageRoot, 'dist', 'ui');
13
+
14
+ function usage(): string {
15
+ return `dictawhisper — local voice journal
16
+
17
+ Usage:
18
+ dictawhisper init [--home] write config.example.json
19
+ dictawhisper doctor check python / CUDA / ffmpeg / ollanet
20
+ dictawhisper start API + inbox (default)
21
+ dictawhisper serve same as start
22
+
23
+ A global install serves the packaged inbox. Checkout still uses pnpm dev.
24
+
25
+ Config (first hit wins):
26
+ $DICTA_CONFIG
27
+ ./config.json
28
+ ~/.dictawhisper/config.json
29
+
30
+ Requires Node 20+ and a Python with faster-whisper. See dictawhisper.com
31
+ `;
32
+ }
33
+
34
+ function fail(message: string, code = 1): never {
35
+ console.error(message);
36
+ process.exit(code);
37
+ }
38
+
39
+ function resolveExistingConfig(): string | null {
40
+ const fromEnv = process.env.DICTA_CONFIG?.trim();
41
+ if (fromEnv) return path.resolve(fromEnv);
42
+ const inCwd = path.resolve(process.cwd(), 'config.json');
43
+ if (existsSync(inCwd)) return inCwd;
44
+ const inHome = path.join(os.homedir(), '.dictawhisper', 'config.json');
45
+ if (existsSync(inHome)) return inHome;
46
+ return null;
47
+ }
48
+
49
+ function init(home: boolean): void {
50
+ const dest = home
51
+ ? path.join(os.homedir(), '.dictawhisper', 'config.json')
52
+ : path.resolve(process.cwd(), 'config.json');
53
+ if (existsSync(dest)) {
54
+ fail(`Already exists: ${dest}`);
55
+ }
56
+ if (!existsSync(exampleConfig)) {
57
+ fail(`Missing ${exampleConfig}`);
58
+ }
59
+ mkdirSync(path.dirname(dest), { recursive: true });
60
+ copyFileSync(exampleConfig, dest);
61
+ console.log(`Wrote ${dest}`);
62
+ console.log('Edit whisper.python, then: dictawhisper doctor');
63
+ }
64
+
65
+ function runNode(script: string, extraEnv: NodeJS.ProcessEnv): ChildProcess {
66
+ return spawn(process.execPath, ['--experimental-strip-types', script], {
67
+ stdio: 'inherit',
68
+ env: { ...process.env, ...extraEnv },
69
+ windowsHide: false,
70
+ });
71
+ }
72
+
73
+ function start(): void {
74
+ const configFile = resolveExistingConfig();
75
+ if (!configFile) {
76
+ fail('No config.json. Run: dictawhisper init\nThen edit whisper.python and run: dictawhisper doctor');
77
+ }
78
+ if (!existsSync(uiDir)) {
79
+ console.warn(`[ui] no packaged inbox at ${uiDir} — API only. In a checkout: pnpm --dir client build`);
80
+ }
81
+ const child = runNode(serverPath, {
82
+ DICTA_CONFIG: configFile,
83
+ DICTA_SERVE_UI: existsSync(uiDir) ? '1' : '',
84
+ DICTA_UI_DIR: uiDir,
85
+ });
86
+ const shutdown = () => {
87
+ if (child.pid) child.kill();
88
+ };
89
+ process.on('SIGINT', shutdown);
90
+ process.on('SIGTERM', shutdown);
91
+ child.on('exit', (code) => process.exit(code ?? 1));
92
+ }
93
+
94
+ function doctor(): void {
95
+ const configFile = resolveExistingConfig();
96
+ if (!configFile) {
97
+ fail('No config.json. Run: dictawhisper init');
98
+ }
99
+ const child = runNode(doctorPath, { DICTA_CONFIG: configFile });
100
+ child.on('exit', (code) => process.exit(code ?? 1));
101
+ }
102
+
103
+ function main(argv: string[]): number {
104
+ const args = argv.slice(2);
105
+ if (args.includes('-h') || args.includes('--help') || args[0] === 'help') {
106
+ console.log(usage());
107
+ return 0;
108
+ }
109
+ const command = args[0] && !args[0].startsWith('-') ? args[0] : 'start';
110
+ if (command === 'init') {
111
+ init(args.includes('--home'));
112
+ return 0;
113
+ }
114
+ if (command === 'doctor') {
115
+ doctor();
116
+ return 0;
117
+ }
118
+ if (command === 'start' || command === 'serve') {
119
+ start();
120
+ return 0;
121
+ }
122
+ console.error(`Unknown command: ${command}\n`);
123
+ console.log(usage());
124
+ return 1;
125
+ }
126
+
127
+ const entry = process.argv[1]?.replaceAll('\\', '/');
128
+ if (entry && (entry.endsWith('/cli.ts') || entry.endsWith('/cli.js'))) {
129
+ process.exitCode = main(process.argv);
130
+ }
131
+
132
+ export { main, resolveExistingConfig };
package/src/config.ts ADDED
@@ -0,0 +1,238 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { z } from 'zod';
5
+ import { localberthGet } from './lib/localberthGet.ts';
6
+
7
+ export function resolveConfigPath(
8
+ env: NodeJS.ProcessEnv = process.env,
9
+ cwd = process.cwd(),
10
+ home = os.homedir()
11
+ ): string {
12
+ const fromEnv = env.DICTA_CONFIG?.trim();
13
+ if (fromEnv) return path.resolve(fromEnv);
14
+ const inCwd = path.resolve(cwd, 'config.json');
15
+ if (fs.existsSync(inCwd)) return inCwd;
16
+ const inHome = path.join(home, '.dictawhisper', 'config.json');
17
+ if (fs.existsSync(inHome)) return inHome;
18
+ return inCwd;
19
+ }
20
+
21
+ export const defaultConfigPath = resolveConfigPath();
22
+
23
+ function resolveAgainst(configDir: string, filePath: string): string {
24
+ return path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath);
25
+ }
26
+
27
+ function servePackagedUi(env: NodeJS.ProcessEnv = process.env): boolean {
28
+ return env.DICTA_SERVE_UI === '1' || env.DICTA_SERVE_UI === 'true';
29
+ }
30
+
31
+ const httpSchema = z
32
+ .object({
33
+ host: z.string().min(1).default('127.0.0.1'),
34
+ port: z.number().int().positive().default(8008),
35
+ corsOrigins: z.array(z.string()).default(['http://localhost:7777', 'http://127.0.0.1:7777']),
36
+ tailscale: z.boolean().default(false),
37
+ })
38
+ .default({});
39
+
40
+ const watchSchema = z
41
+ .object({
42
+ roots: z.array(z.string()).default([]),
43
+ browserDropFolder: z.string().default('./data/audio-files'),
44
+ settleMinutes: z.number().nonnegative().default(30),
45
+ browserSettleMs: z.number().nonnegative().default(0),
46
+ recursiveYears: z.boolean().default(true),
47
+ createMissingRoots: z.boolean().default(false),
48
+ })
49
+ .default({});
50
+
51
+ const whisperSchema = z
52
+ .object({
53
+ python: z.string().min(1).default('python'),
54
+ model: z.string().min(1).default('large-v3'),
55
+ device: z.string().min(1).default('cuda'),
56
+ computeType: z.string().min(1).default('float16'),
57
+ promptTerms: z.array(z.string()).default([]),
58
+ })
59
+ .default({});
60
+
61
+ const queuesSchema = z
62
+ .object({
63
+ transcription: z.object({ active: z.boolean().default(true), concurrency: z.number().int().positive().default(1) }).default({}),
64
+ processing: z.object({ active: z.boolean().default(true), concurrency: z.number().int().positive().default(1) }).default({}),
65
+ })
66
+ .default({});
67
+
68
+ const ollanetSchema = z
69
+ .object({
70
+ machine: z.string().default(''),
71
+ cleanModel: z.string().default(''),
72
+ saveChats: z.boolean().default(false),
73
+ required: z.boolean().default(false),
74
+ })
75
+ .default({});
76
+
77
+ const journalSchema = z
78
+ .object({
79
+ index: z.string().default('./data/journal.sqlite'),
80
+ search: z.enum(['lex', 'semantic', 'hybrid']).default('hybrid'),
81
+ embedModel: z.string().default(''),
82
+ })
83
+ .default({});
84
+
85
+ export const dictaConfigFileSchema = z
86
+ .object({
87
+ http: httpSchema,
88
+ watch: watchSchema,
89
+ whisper: whisperSchema,
90
+ audio: z.object({ preprocess: z.boolean().default(true) }).default({}),
91
+ queues: queuesSchema,
92
+ ollanet: ollanetSchema,
93
+ journal: journalSchema,
94
+ })
95
+ .strip();
96
+
97
+ export type DictaConfig = {
98
+ http: {
99
+ host: string;
100
+ port: number;
101
+ corsOrigins: string[];
102
+ tailscale: boolean;
103
+ };
104
+ watch: {
105
+ roots: string[];
106
+ browserDropFolder: string;
107
+ settleMinutes: number;
108
+ browserSettleMs: number;
109
+ recursiveYears: boolean;
110
+ createMissingRoots: boolean;
111
+ };
112
+ whisper: {
113
+ python: string;
114
+ model: string;
115
+ device: string;
116
+ computeType: string;
117
+ promptTerms: string[];
118
+ };
119
+ audio: { preprocess: boolean };
120
+ queues: {
121
+ transcription: { active: boolean; concurrency: number };
122
+ processing: { active: boolean; concurrency: number };
123
+ };
124
+ ollanet: {
125
+ machine: string;
126
+ cleanModel: string;
127
+ saveChats: boolean;
128
+ required: boolean;
129
+ };
130
+ journal: {
131
+ index: string;
132
+ search: 'lex' | 'semantic' | 'hybrid';
133
+ embedModel: string;
134
+ };
135
+ };
136
+
137
+ function readJson(filePath: string): unknown {
138
+ if (!fs.existsSync(filePath)) return {};
139
+ const raw = fs.readFileSync(filePath, 'utf-8');
140
+ try {
141
+ return JSON.parse(raw);
142
+ } catch (error) {
143
+ const detail = error instanceof Error ? error.message : String(error);
144
+ throw new Error(`config is not valid JSON (${filePath}): ${detail}`);
145
+ }
146
+ }
147
+
148
+ export function applyConfigToEnv(config: DictaConfig): void {
149
+ process.env.WHISPER_MODEL = config.whisper.model;
150
+ process.env.WHISPER_PYTHON = config.whisper.python;
151
+ process.env.WHISPER_DEVICE = config.whisper.device;
152
+ process.env.WHISPER_COMPUTE_TYPE = config.whisper.computeType;
153
+ }
154
+
155
+ /** LocalBerth start on the UI lease sets PORT=7777. That must not become the API. */
156
+ export function resolveApiListenPort(
157
+ envPort: string | undefined,
158
+ uiPort: number | undefined,
159
+ apiLeasePort: number | undefined,
160
+ configPort: number
161
+ ): number {
162
+ const n = Number(envPort);
163
+ if (Number.isInteger(n) && n > 0 && n !== uiPort) return n;
164
+ return apiLeasePort || configPort;
165
+ }
166
+
167
+ /** Combined inbox+API (npm CLI) binds the UI port so the page and /note share origin. */
168
+ export function resolveListenPort(
169
+ envPort: string | undefined,
170
+ uiPort: number | undefined,
171
+ apiLeasePort: number | undefined,
172
+ configPort: number,
173
+ serveUi = false
174
+ ): number {
175
+ if (serveUi) return uiPort || 7777;
176
+ return resolveApiListenPort(envPort, uiPort, apiLeasePort, configPort);
177
+ }
178
+
179
+ export function loadConfig(configPath: string = resolveConfigPath()): DictaConfig {
180
+ const parsed = dictaConfigFileSchema.parse(readJson(configPath));
181
+ const configDir = path.dirname(path.resolve(configPath));
182
+ const config: DictaConfig = {
183
+ http: {
184
+ host: process.env.HOST?.trim() || parsed.http.host,
185
+ port: resolveListenPort(
186
+ process.env.PORT,
187
+ localberthGet('dictawhisper'),
188
+ localberthGet('dictawhisper-api'),
189
+ parsed.http.port,
190
+ servePackagedUi()
191
+ ),
192
+ corsOrigins: parsed.http.corsOrigins,
193
+ tailscale:
194
+ process.env.DICTA_TAILSCALE === '1' ||
195
+ process.env.DICTA_TAILSCALE === 'true' ||
196
+ parsed.http.tailscale,
197
+ },
198
+ watch: {
199
+ roots: parsed.watch.roots.map((root) => resolveAgainst(configDir, root)),
200
+ browserDropFolder: resolveAgainst(configDir, parsed.watch.browserDropFolder),
201
+ settleMinutes: Number(process.env.VOICE_SETTLE_MINUTES) || parsed.watch.settleMinutes,
202
+ browserSettleMs:
203
+ process.env.VOICE_BROWSER_SETTLE_MS !== undefined
204
+ ? Number(process.env.VOICE_BROWSER_SETTLE_MS)
205
+ : parsed.watch.browserSettleMs,
206
+ recursiveYears: parsed.watch.recursiveYears,
207
+ createMissingRoots: parsed.watch.createMissingRoots,
208
+ },
209
+ whisper: {
210
+ python: process.env.WHISPER_PYTHON?.trim() || parsed.whisper.python,
211
+ model: process.env.WHISPER_MODEL?.trim() || parsed.whisper.model,
212
+ device: process.env.WHISPER_DEVICE?.trim() || parsed.whisper.device,
213
+ computeType: process.env.WHISPER_COMPUTE_TYPE?.trim() || parsed.whisper.computeType,
214
+ promptTerms: parsed.whisper.promptTerms.map((term) => String(term).trim()).filter(Boolean),
215
+ },
216
+ audio: {
217
+ preprocess: parsed.audio.preprocess,
218
+ },
219
+ queues: parsed.queues,
220
+ ollanet: {
221
+ machine: process.env.OLLANET_MACHINE?.trim() || parsed.ollanet.machine,
222
+ cleanModel: process.env.OLLANET_CLEAN_MODEL?.trim() || parsed.ollanet.cleanModel,
223
+ saveChats: parsed.ollanet.saveChats,
224
+ required: parsed.ollanet.required,
225
+ },
226
+ journal: {
227
+ index: resolveAgainst(configDir, parsed.journal.index),
228
+ search: parsed.journal.search,
229
+ embedModel: process.env.DICTA_EMBED_MODEL?.trim() || parsed.journal.embedModel,
230
+ },
231
+ };
232
+
233
+ applyConfigToEnv(config);
234
+ return config;
235
+ }
236
+
237
+ export const configPath = resolveConfigPath();
238
+ export const config = loadConfig(configPath);
package/src/dev.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { execSync, spawn, type ChildProcess } from 'child_process';
2
+ import { localberthGet } from './lib/localberthGet.ts';
3
+
4
+ const children: ChildProcess[] = [];
5
+
6
+ function apiEnv(): NodeJS.ProcessEnv {
7
+ return {
8
+ ...process.env,
9
+ PORT: String(localberthGet('dictawhisper-api') ?? 8008),
10
+ };
11
+ }
12
+
13
+ function run(command: string, args: string[], hide = true, env: NodeJS.ProcessEnv = process.env) {
14
+ const child = spawn(command, args, {
15
+ stdio: 'inherit',
16
+ env,
17
+ windowsHide: hide,
18
+ });
19
+ children.push(child);
20
+ child.on('error', (error) => {
21
+ console.error(`[dev] failed to start ${command}:`, error.message);
22
+ shutdown(1);
23
+ });
24
+ child.on('exit', (code, signal) => {
25
+ if (signal) return;
26
+ if (code && code !== 0) shutdown(code);
27
+ });
28
+ }
29
+
30
+ // windowsHide hides Node stdout in Git Bash; the API child must stay visible.
31
+ // Do not inherit the UI lease PORT (7777) — the API is dictawhisper-api (8008).
32
+ run(process.execPath, ['--experimental-strip-types', 'src/server.ts'], false, apiEnv());
33
+
34
+ if (process.platform === 'win32') {
35
+ // pnpm is a .cmd on PATH; CreateProcess cannot run it without cmd.exe.
36
+ // Args are fixed literals — not user input.
37
+ run('cmd.exe', ['/d', '/s', '/c', 'pnpm --dir client dev']);
38
+ } else {
39
+ run('pnpm', ['--dir', 'client', 'dev']);
40
+ }
41
+
42
+ function killTree(child: ChildProcess) {
43
+ if (!child.pid) return;
44
+ if (process.platform === 'win32') {
45
+ try {
46
+ execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'ignore' });
47
+ } catch {
48
+ child.kill();
49
+ }
50
+ return;
51
+ }
52
+ child.kill();
53
+ }
54
+
55
+ function shutdown(code = 0) {
56
+ for (const child of children) {
57
+ killTree(child);
58
+ }
59
+ process.exit(code);
60
+ }
61
+
62
+ process.on('SIGINT', () => shutdown(0));
63
+ process.on('SIGTERM', () => shutdown(0));
package/src/doctor.ts ADDED
@@ -0,0 +1,12 @@
1
+ import fs from 'fs';
2
+ import { config, configPath } from './config.ts';
3
+ import { collectHealth, printHealthReport } from './lib/healthLib.ts';
4
+
5
+ if (!fs.existsSync(configPath)) {
6
+ console.error(`[doctor] FAIL config.json missing — copy config.example.json to ${configPath}`);
7
+ process.exit(1);
8
+ }
9
+
10
+ const report = await collectHealth(config, { mode: 'doctor' });
11
+ printHealthReport(report, 'doctor');
12
+ process.exit(report.ok ? 0 : 1);
@@ -0,0 +1,154 @@
1
+ export type WhisperWord = {
2
+ word: string;
3
+ start: number;
4
+ end: number;
5
+ };
6
+
7
+ export type WhisperSegment = {
8
+ start: number;
9
+ end: number;
10
+ text: string;
11
+ words?: WhisperWord[];
12
+ };
13
+
14
+ export type PlaybackCue = {
15
+ start: number | null;
16
+ end: number | null;
17
+ text: string;
18
+ };
19
+
20
+ const FILLERS = new Set(['uh', 'um', 'umm', 'hmm', 'yeah', 'like']);
21
+
22
+ export function splitCleanedSections(cleaned: string): string[] {
23
+ const trimmed = String(cleaned || '').trim();
24
+ if (!trimmed) return [];
25
+ const paras = trimmed.split(/\n{2,}/).map((part) => part.trim()).filter(Boolean);
26
+ if (paras.length >= 2) return paras;
27
+ const sentences = trimmed.split(/(?<=[.!?])\s+/).map((part) => part.trim()).filter(Boolean);
28
+ if (sentences.length >= 2) return sentences;
29
+ return [trimmed];
30
+ }
31
+
32
+ function normWord(text: string): string {
33
+ return String(text || '')
34
+ .toLowerCase()
35
+ .replace(/['’]/g, '')
36
+ .replace(/[^a-z0-9]/g, '');
37
+ }
38
+
39
+ function contentWords(text: string): string[] {
40
+ return String(text || '')
41
+ .split(/\s+/)
42
+ .map(normWord)
43
+ .filter((word) => word.length > 1 && !FILLERS.has(word));
44
+ }
45
+
46
+ export function flattenSegmentWords(segments: WhisperSegment[] | undefined): WhisperWord[] {
47
+ const out: WhisperWord[] = [];
48
+ for (const segment of segments || []) {
49
+ if (!Array.isArray(segment.words)) continue;
50
+ for (const word of segment.words) {
51
+ if (typeof word?.start !== 'number' || typeof word.end !== 'number') continue;
52
+ if (!String(word.word || '').trim()) continue;
53
+ out.push({ word: word.word, start: word.start, end: word.end });
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ type Token = WhisperWord & { key: string };
60
+
61
+ function tokenizeWords(words: WhisperWord[]): Token[] {
62
+ return words
63
+ .map((word) => ({ ...word, key: normWord(word.word) }))
64
+ .filter((word) => word.key.length > 1 && !FILLERS.has(word.key));
65
+ }
66
+
67
+ /** Map cleaned sections onto Whisper word timestamps. Sequential; does not invent times. */
68
+ export function alignCleanedToWords(cleaned: string, words: WhisperWord[]): PlaybackCue[] {
69
+ const sections = splitCleanedSections(cleaned);
70
+ const tokens = tokenizeWords(words);
71
+ if (!sections.length || !tokens.length) return [];
72
+
73
+ let cursor = 0;
74
+ const cues: PlaybackCue[] = [];
75
+
76
+ for (const text of sections) {
77
+ const needed = contentWords(text);
78
+ if (!needed.length) {
79
+ const at = tokens[Math.min(cursor, tokens.length - 1)];
80
+ cues.push({ start: at.start, end: at.end, text });
81
+ continue;
82
+ }
83
+
84
+ const searchEnd = Math.min(tokens.length, cursor + Math.max(30, needed.length * 4));
85
+ let startIdx = -1;
86
+ for (let i = cursor; i < searchEnd; i += 1) {
87
+ if (tokens[i].key === needed[0]) {
88
+ startIdx = i;
89
+ break;
90
+ }
91
+ }
92
+ if (startIdx < 0) {
93
+ cues.push({ start: null, end: null, text });
94
+ continue;
95
+ }
96
+
97
+ let wordIdx = startIdx;
98
+ let needIdx = 0;
99
+ let endIdx = startIdx;
100
+ while (wordIdx < tokens.length && needIdx < needed.length) {
101
+ if (tokens[wordIdx].key === needed[needIdx]) {
102
+ endIdx = wordIdx;
103
+ wordIdx += 1;
104
+ needIdx += 1;
105
+ continue;
106
+ }
107
+ const upcoming = tokens.slice(wordIdx + 1, wordIdx + 4).some((token) => token.key === needed[needIdx]);
108
+ if (upcoming) wordIdx += 1;
109
+ else needIdx += 1;
110
+ }
111
+
112
+ cues.push({
113
+ start: tokens[startIdx].start,
114
+ end: tokens[endIdx].end,
115
+ text,
116
+ });
117
+ cursor = endIdx + 1;
118
+ }
119
+
120
+ return cues;
121
+ }
122
+
123
+ export function ensurePlaybackCues(json: {
124
+ cleanedTranscription?: string;
125
+ segments?: WhisperSegment[];
126
+ playbackCues?: PlaybackCue[];
127
+ playbackCuesSource?: string;
128
+ }): boolean {
129
+ const cleaned = json?.cleanedTranscription;
130
+ const words = flattenSegmentWords(json?.segments);
131
+ const sections = cleaned ? splitCleanedSections(cleaned) : [];
132
+
133
+ if (words.length && cleaned && sections.length) {
134
+ const existing = json.playbackCues;
135
+ if (
136
+ json.playbackCuesSource === 'words' &&
137
+ Array.isArray(existing) &&
138
+ existing.length === sections.length &&
139
+ existing.every((cue) => cue && typeof cue.text === 'string')
140
+ ) {
141
+ return false;
142
+ }
143
+ json.playbackCues = alignCleanedToWords(cleaned, words);
144
+ json.playbackCuesSource = 'words';
145
+ return true;
146
+ }
147
+
148
+ if (json.playbackCues || json.playbackCuesSource) {
149
+ delete json.playbackCues;
150
+ delete json.playbackCuesSource;
151
+ return true;
152
+ }
153
+ return false;
154
+ }