creator-editing-studio 1.3.5 → 1.3.6

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.
@@ -1,424 +1,424 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Best-take selection: one messy take in, one clean cut plan out.
4
- *
5
- * npm run takes -- <words.json> [--media <video>] [--script <script.md>]
6
- * [--project <name>] [--out <file>] [--fps 30]
7
- * [--similar 0.72] [--window 90] [--gap-max 0.6] [--gap-keep 0.25]
8
- *
9
- * WHAT IT IS FOR. A talking head is full of second attempts. Somebody fumbles, stops, says the
10
- * sentence again, and both versions end up in the cut — so a human watches the whole thing to
11
- * find them. This finds them instead, keeps the better one, and proves afterwards that none are
12
- * left.
13
- *
14
- * THE SPEECH IS THE TRUTH, NOT THE SCRIPT. An earlier version of this file anchored to the
15
- * written script: every script sentence got one span and anything unmatched was dropped. It
16
- * threw away six passages of real material on the first take it was pointed at — lines the
17
- * speaker improvised on the day and meant to keep, including "you don't have to put the brief
18
- * into the brief file". A repeat that survives is visible and fixable; a good line silently
19
- * deleted is neither. So the default here is inverted:
20
- *
21
- * KEEP EVERYTHING, UNLESS IT IS A SECOND ATTEMPT AT SOMETHING ELSE THAT IS KEPT.
22
- *
23
- * Nothing has to justify its existence. A script, if there is one, is only a checker: it reports
24
- * lines that never got said and breaks ties between close takes. It can never remove anything,
25
- * and most people filming to camera do not have one.
26
- *
27
- * WHAT IT WRITES work/<project>/<stem>.takes.json
28
- * keep[] {from, to, units} source seconds to use, in order
29
- * groups[] every retake cluster, which attempt won and why the others lost
30
- * dropped[] what went, with the reason
31
- * checks{} verification, all of which must pass
32
- */
33
- import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
34
- import {basename, dirname, join} from 'node:path';
35
- import {decodePcm} from './lib/media.mjs';
36
-
37
- /* ------------------------------------------------------------------------ args */
38
-
39
- const args = process.argv.slice(2);
40
- const flag = (name, fallback) => {
41
- const i = args.indexOf(`--${name}`);
42
- return i === -1 ? fallback : args[i + 1];
43
- };
44
- const positional = args.filter((a, i) => !a.startsWith('--') && !String(args[i - 1] ?? '').startsWith('--'));
45
-
46
- const wordsPath = positional[0];
47
- if (!wordsPath) {
48
- console.error('usage: npm run takes -- <words.json> [--media <video>] [--script <script.md>]');
49
- process.exit(1);
50
- }
51
-
52
- const FPS = Number(flag('fps', 30));
53
- /** How alike two passages must be before they count as attempts at the same thing. */
54
- const SIMILAR = Number(flag('similar', 0.72));
55
- /** Retakes happen close together. Two matching sentences minutes apart are both wanted. */
56
- const WINDOW = Number(flag('window', 90));
57
- /** A silence longer than this is dead air and gets collapsed. */
58
- const GAP_MAX = Number(flag('gap-max', 0.6));
59
- /** ...down to this. Not zero: speech with every gap removed sounds like a machine. */
60
- const GAP_KEEP = Number(flag('gap-keep', 0.25));
61
- /**
62
- * Breathing room kept either side of a kept passage.
63
- *
64
- * 150ms, not the 60 an earlier version used. Below about 120 the cut lands close enough to the
65
- * first phoneme to clip it, and the join reads as abrupt even when every word is intact.
66
- */
67
- const PAD = 0.15;
68
-
69
- /* ------------------------------------------------------------------- the words */
70
-
71
- const key = (t) => t.toLowerCase().replace(/['‘’]/g, '').replace(/[^a-z0-9]/g, '');
72
-
73
- const words = JSON.parse(readFileSync(wordsPath, 'utf8')).words.filter((w) => w.start != null && w.end != null);
74
- if (words.length < 10) {
75
- console.error('Not enough timed words to work with.');
76
- process.exit(1);
77
- }
78
-
79
- /**
80
- * Cues that announce a retake out loud.
81
- *
82
- * When someone says one of these, the passage BEFORE it was abandoned — they are telling you so.
83
- * Cheaper and more certain than any similarity measure.
84
- */
85
- const RESTART_CUES = [
86
- 'let me redo that', 'let me try that again', 'one more time', 'sorry let me',
87
- 'can i do that again', 'take two', 'scratch that', 'let me say that again',
88
- 'start again', 'i will say that again',
89
- ];
90
-
91
- /* --------------------------------------------------------------- units of speech
92
- * A unit is one spoken thought: it ends where the speaker put a full stop, or where they left a
93
- * gap long enough to be a boundary rather than a breath. Working in units rather than fixed word
94
- * counts means a retake is compared against the whole thought it replaced. */
95
-
96
- const units = [];
97
- {
98
- let start = 0;
99
- for (let i = 0; i < words.length; i++) {
100
- const w = words[i];
101
- const next = words[i + 1];
102
- const gapAfter = next ? next.start - w.end : Infinity;
103
- const endsSentence = /[.?!]$/.test(w.word);
104
- if (endsSentence || gapAfter > 0.7 || i === words.length - 1) {
105
- const span = words.slice(start, i + 1);
106
- if (span.length) {
107
- units.push({
108
- index: units.length,
109
- startIndex: start,
110
- endIndex: i,
111
- from: span[0].start,
112
- to: span[span.length - 1].end,
113
- text: span.map((x) => x.word).join(' '),
114
- keys: span.map((x) => key(x.word)).filter(Boolean),
115
- confidence: span.reduce((a, x) => a + (x.score ?? 0.8), 0) / span.length,
116
- complete: endsSentence,
117
- worstGap: span.slice(1).reduce((m, x, k) => Math.max(m, x.start - span[k].end), 0),
118
- });
119
- }
120
- start = i + 1;
121
- }
122
- }
123
- }
124
-
125
- /* -------------------------------------------------------------- energy and pace */
126
-
127
- /**
128
- * Loudness per unit, so a flat read can lose to a committed one.
129
- *
130
- * Optional: without --media there is no audio to measure and the score simply does without it,
131
- * rather than the tool refusing to run.
132
- */
133
- let rmsOf = () => null;
134
- const mediaPath = flag('media', null);
135
- if (mediaPath && existsSync(mediaPath)) {
136
- const RATE = 22050;
137
- const pcm = decodePcm(mediaPath, RATE);
138
- if (pcm) {
139
- rmsOf = (from, to) => {
140
- const a = Math.max(0, Math.floor(from * RATE));
141
- const b = Math.min(pcm.length, Math.ceil(to * RATE));
142
- if (b <= a) return null;
143
- let sum = 0;
144
- for (let i = a; i < b; i++) sum += pcm[i] * pcm[i];
145
- return Math.sqrt(sum / (b - a));
146
- };
147
- }
148
- }
149
-
150
- for (const u of units) {
151
- u.seconds = u.to - u.from;
152
- u.wordsPerSecond = u.keys.length / Math.max(0.3, u.seconds);
153
- u.rms = rmsOf(u.from, u.to);
154
- }
155
-
156
- const median = (xs) => {
157
- const s = xs.filter((x) => x != null).sort((a, b) => a - b);
158
- return s.length ? s[Math.floor(s.length / 2)] : null;
159
- };
160
- const medianRms = median(units.map((u) => u.rms));
161
- const medianPace = median(units.map((u) => u.wordsPerSecond)) ?? 3;
162
-
163
- /* ------------------------------------------------------------------ similarity */
164
-
165
- /** Longest common subsequence length — tolerant of an inserted stumble or a swapped word. */
166
- const lcs = (a, b) => {
167
- const prev = new Array(b.length + 1).fill(0);
168
- const cur = new Array(b.length + 1).fill(0);
169
- for (let i = 1; i <= a.length; i++) {
170
- for (let j = 1; j <= b.length; j++) {
171
- cur[j] = a[i - 1] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], cur[j - 1]);
172
- }
173
- for (let j = 0; j <= b.length; j++) prev[j] = cur[j];
174
- }
175
- return prev[b.length];
176
- };
177
-
178
- /**
179
- * How alike two units are, measured against the SHORTER one.
180
- *
181
- * Against the shorter, because an abandoned start — "so the main reason, so the main reason is
182
- * that…" — is a short fragment entirely contained in the full attempt. Measured against the
183
- * longer it would score low and be kept; against the shorter it scores 1.0 and is correctly
184
- * recognised as the same thought, begun twice.
185
- */
186
- const similarity = (a, b) => {
187
- if (!a.keys.length || !b.keys.length) return 0;
188
- const shorter = Math.min(a.keys.length, b.keys.length);
189
- // Short units match each other on function words alone: two four-word lines sharing "give it"
190
- // score 0.5 without meaning anything. Below five words there is not enough signal to call
191
- // something a retake.
192
- if (shorter < 5) return 0;
193
- const overlap = lcs(a.keys, b.keys);
194
- // And the overlap has to be real words, not two articles and a pronoun.
195
- if (overlap < 4) return 0;
196
- return overlap / shorter;
197
- };
198
-
199
- /* ---------------------------------------------------------------------- groups */
200
-
201
- const groupOf = new Array(units.length).fill(-1);
202
- const groups = [];
203
-
204
- /**
205
- * Grouping is against a representative, never transitive.
206
- *
207
- * Chaining members together — A matches B, B matches C, so group them all — merges passages that
208
- * have nothing to do with each other as soon as one ambiguous unit sits between them — two
209
- * unrelated sentences end up in a single group because each matched the same middle one. A unit
210
- * joins a group only if it is similar to that group's FIRST member, which keeps a group to one
211
- * thought said more than once.
212
- */
213
- for (let i = 0; i < units.length; i++) {
214
- if (groupOf[i] !== -1) continue;
215
- const members = [i];
216
- for (let j = i + 1; j < units.length; j++) {
217
- if (groupOf[j] !== -1) continue;
218
- if (units[j].from - units[i].to > WINDOW) break;
219
- if (similarity(units[i], units[j]) < SIMILAR) continue;
220
- members.push(j);
221
- }
222
- if (members.length < 2) continue;
223
- groups.push({members});
224
- for (const m of members) groupOf[m] = groups.length - 1;
225
- }
226
-
227
- /** A spoken cue abandons whatever came immediately before it. */
228
- const abandoned = new Set();
229
- for (const u of units) {
230
- const flat = u.keys.join(' ');
231
- if (!RESTART_CUES.some((c) => flat.includes(c.replace(/[^a-z0-9 ]/g, '')))) continue;
232
- const prev = units[u.index - 1];
233
- if (prev) abandoned.add(prev.index);
234
- }
235
-
236
- /* --------------------------------------------------------------------- scoring */
237
-
238
- /**
239
- * One number per attempt.
240
- *
241
- * Completeness dominates — a finished sentence beats a smoother fragment every time. Everything
242
- * else nudges. The tie-break at the end is deliberate and matches how people actually record:
243
- * they repeat a line until they are happy, so the last attempt is usually the keeper.
244
- */
245
- const score = (u) => {
246
- let s = 0;
247
- s += u.complete ? 6 : 0;
248
- s += Math.min(u.keys.length / 12, 1) * 3;
249
- s += u.confidence * 2;
250
- s -= Math.max(0, u.worstGap - 0.45) * 3;
251
- if (abandoned.has(u.index)) s -= 20;
252
- // Pace: too fast reads as rushed, too slow as laboured. Either direction costs the same.
253
- s -= Math.min(2, Math.abs(u.wordsPerSecond - medianPace) / Math.max(0.8, medianPace) * 2);
254
- // Energy, only when there was audio to measure.
255
- if (u.rms != null && medianRms) s += Math.max(-1.5, Math.min(1.5, (u.rms / medianRms - 1) * 2));
256
- return s;
257
- };
258
-
259
- for (const u of units) u.score = score(u);
260
-
261
- /* ------------------------------------------------------- optional script checker
262
- * A script never removes anything. It reports lines that were never said, and nudges a tie
263
- * toward whichever attempt is closest to what was written. */
264
-
265
- const scriptPath = flag('script', null);
266
- let scriptMissing = [];
267
- if (scriptPath && existsSync(scriptPath)) {
268
- const raw = readFileSync(scriptPath, 'utf8');
269
- const body = /^---\s*$/m.test(raw) ? raw.split(/^---\s*$/m).slice(1).join('\n') : raw;
270
- const lines = body
271
- .replace(/<!--[\s\S]*?-->/g, '')
272
- .replace(/^#.*$/gm, '')
273
- .replace(/`[^`]*`/g, ' ')
274
- .split(/(?<=[.?!])\s+/)
275
- .map((s) => s.replace(/\s+/g, ' ').trim())
276
- .filter((s) => s.split(/\s+/).length >= 4);
277
-
278
- for (const line of lines) {
279
- const lk = line.split(/\s+/).map(key).filter(Boolean);
280
- const best = Math.max(0, ...units.map((u) => lcs(lk, u.keys) / Math.max(1, lk.length)));
281
- if (best < 0.6) scriptMissing.push({text: line, bestMatch: Number(best.toFixed(2))});
282
- }
283
- // Closest-to-script wins a close call, but only by a little.
284
- for (const g of groups) {
285
- for (const m of g.members) {
286
- const u = units[m];
287
- const best = Math.max(0, ...lines.map((l) => lcs(l.split(/\s+/).map(key).filter(Boolean), u.keys) / Math.max(1, u.keys.length)));
288
- u.score += best * 1.2;
289
- }
290
- }
291
- }
292
-
293
- /* ----------------------------------------------------------------- the decision */
294
-
295
- const drop = new Set();
296
- const groupReport = [];
297
-
298
- for (const g of groups) {
299
- const members = [...new Set(g.members)].sort((a, b) => a - b);
300
- let winner = members[0];
301
- for (const m of members) {
302
- const a = units[m];
303
- const b = units[winner];
304
- // Within a hair of each other, the later one wins.
305
- if (a.score > b.score + 0.3 || (Math.abs(a.score - b.score) <= 0.3 && a.from > b.from)) winner = m;
306
- }
307
- for (const m of members) if (m !== winner) drop.add(m);
308
- groupReport.push({
309
- kept: {at: Number(units[winner].from.toFixed(2)), text: units[winner].text.slice(0, 90), score: Number(units[winner].score.toFixed(2))},
310
- dropped: members.filter((m) => m !== winner).map((m) => ({
311
- at: Number(units[m].from.toFixed(2)),
312
- text: units[m].text.slice(0, 90),
313
- score: Number(units[m].score.toFixed(2)),
314
- })),
315
- });
316
- }
317
- for (const i of abandoned) drop.add(i);
318
-
319
- const kept = units.filter((u) => !drop.has(u.index));
320
-
321
- /* ------------------------------------------------------------- building the plan
322
- * Consecutive survivors become one span, so the cut is not chopped at every sentence. A gap
323
- * between spans is collapsed only when it is longer than a breath — and a pause in front of a
324
- * short line is usually deliberate, so those are left alone. */
325
-
326
- const keep = [];
327
- for (const u of kept) {
328
- const last = keep[keep.length - 1];
329
- if (last && u.startIndex === last.endIndex + 1 && u.from - last.to < GAP_MAX) {
330
- last.to = u.to;
331
- last.endIndex = u.endIndex;
332
- last.units.push(u.index);
333
- } else {
334
- keep.push({from: u.from, to: u.to, startIndex: u.startIndex, endIndex: u.endIndex, units: [u.index]});
335
- }
336
- }
337
-
338
- for (const k of keep) {
339
- k.from = Math.max(0, k.from - PAD);
340
- k.to += PAD;
341
- }
342
- for (let i = 1; i < keep.length; i++) {
343
- if (keep[i].from < keep[i - 1].to) {
344
- const mid = (keep[i].from + keep[i - 1].to) / 2;
345
- keep[i - 1].to = mid;
346
- keep[i].from = mid;
347
- }
348
- }
349
-
350
- /** Gaps between spans, after the cut: long dead air collapsed, deliberate beats kept. */
351
- const gaps = [];
352
- for (let i = 1; i < keep.length; i++) {
353
- const gap = keep[i].from - keep[i - 1].to;
354
- const nextIsShort = units[keep[i].units[0]].keys.length <= 6;
355
- const target = gap > GAP_MAX && !nextIsShort ? GAP_KEEP : gap;
356
- gaps.push({after: i - 1, was: Number(gap.toFixed(2)), becomes: Number(target.toFixed(2)), keptAsBeat: target === gap && gap > GAP_MAX});
357
- }
358
-
359
- const dropped = [...drop].sort((a, b) => a - b).map((i) => ({
360
- from: Number(units[i].from.toFixed(2)),
361
- to: Number(units[i].to.toFixed(2)),
362
- reason: abandoned.has(i) ? 'announced-retake' : 'retake',
363
- text: units[i].text.slice(0, 110),
364
- }));
365
-
366
- /* ----------------------------------------------------------------- verification */
367
-
368
- const survivors = kept;
369
- const stillAlike = [];
370
- for (let i = 0; i < survivors.length; i++) {
371
- for (let j = i + 1; j < survivors.length; j++) {
372
- if (survivors[j].from - survivors[i].to > WINDOW) break;
373
- if (similarity(survivors[i], survivors[j]) >= SIMILAR) {
374
- stillAlike.push({
375
- a: Number(survivors[i].from.toFixed(2)),
376
- b: Number(survivors[j].from.toFixed(2)),
377
- text: survivors[j].text.slice(0, 70),
378
- });
379
- }
380
- }
381
- }
382
-
383
- const keptSeconds = keep.reduce((a, k) => a + (k.to - k.from), 0);
384
- const sourceSeconds = words[words.length - 1].end - words[0].start;
385
- const checks = {
386
- noRepeatsLeft: stillAlike.length === 0,
387
- units: units.length,
388
- retakeGroups: groups.length,
389
- unitsDropped: drop.size,
390
- spans: keep.length,
391
- keptSeconds: Number(keptSeconds.toFixed(2)),
392
- sourceSeconds: Number(sourceSeconds.toFixed(2)),
393
- energyScored: medianRms != null,
394
- };
395
-
396
- /* ---------------------------------------------------------------------- output */
397
-
398
- const stem = basename(wordsPath).replace(/\.?words\.json$|\.json$/, '') || 'take';
399
- const project = flag('project', null);
400
- const outPath = flag('out', null) ?? (project ? join('work', project, `${stem}.takes.json`) : join(dirname(wordsPath), `${stem}.takes.json`));
401
- mkdirSync(dirname(outPath), {recursive: true});
402
- writeFileSync(outPath, JSON.stringify({fps: FPS, gapMax: GAP_MAX, gapKeep: GAP_KEEP, pad: PAD, checks, keep, gaps, groups: groupReport, dropped, scriptMissing}, null, 2));
403
-
404
- console.log(outPath);
405
- console.log(` ${checks.units} units, ${checks.retakeGroups} retake group(s), ${checks.unitsDropped} dropped`);
406
- console.log(` ${checks.keptSeconds}s kept of ${checks.sourceSeconds}s (${((keptSeconds / sourceSeconds) * 100).toFixed(0)}%) in ${checks.spans} span(s)`);
407
- console.log(` energy scoring: ${checks.energyScored ? 'on' : 'off (pass --media to enable)'}`);
408
- if (groupReport.length) {
409
- console.log('\n retakes dropped:');
410
- for (const g of groupReport.slice(0, 8)) {
411
- for (const d of g.dropped) console.log(` ${String(d.at).padStart(7)}s "${d.text}"`);
412
- }
413
- }
414
- const beats = gaps.filter((g) => g.keptAsBeat).length;
415
- if (beats) console.log(`\n ${beats} long pause(s) kept as deliberate beats before a short line.`);
416
- if (scriptPath) {
417
- console.log(`\n script check: ${scriptMissing.length} line(s) never said`);
418
- for (const m of scriptMissing.slice(0, 6)) console.log(` - "${m.text.slice(0, 68)}"`);
419
- }
420
- if (stillAlike.length) {
421
- console.error(`\n${stillAlike.length} near-duplicate(s) still in the cut:`);
422
- for (const s of stillAlike.slice(0, 8)) console.error(` ${s.a}s and ${s.b}s — "${s.text}"`);
423
- process.exit(1);
424
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Best-take selection: one messy take in, one clean cut plan out.
4
+ *
5
+ * npm run takes -- <words.json> [--media <video>] [--script <script.md>]
6
+ * [--project <name>] [--out <file>] [--fps 30]
7
+ * [--similar 0.72] [--window 90] [--gap-max 0.6] [--gap-keep 0.25]
8
+ *
9
+ * WHAT IT IS FOR. A talking head is full of second attempts. Somebody fumbles, stops, says the
10
+ * sentence again, and both versions end up in the cut — so a human watches the whole thing to
11
+ * find them. This finds them instead, keeps the better one, and proves afterwards that none are
12
+ * left.
13
+ *
14
+ * THE SPEECH IS THE TRUTH, NOT THE SCRIPT. An earlier version of this file anchored to the
15
+ * written script: every script sentence got one span and anything unmatched was dropped. It
16
+ * threw away six passages of real material on the first take it was pointed at — lines the
17
+ * speaker improvised on the day and meant to keep, including "you don't have to put the brief
18
+ * into the brief file". A repeat that survives is visible and fixable; a good line silently
19
+ * deleted is neither. So the default here is inverted:
20
+ *
21
+ * KEEP EVERYTHING, UNLESS IT IS A SECOND ATTEMPT AT SOMETHING ELSE THAT IS KEPT.
22
+ *
23
+ * Nothing has to justify its existence. A script, if there is one, is only a checker: it reports
24
+ * lines that never got said and breaks ties between close takes. It can never remove anything,
25
+ * and most people filming to camera do not have one.
26
+ *
27
+ * WHAT IT WRITES work/<project>/<stem>.takes.json
28
+ * keep[] {from, to, units} source seconds to use, in order
29
+ * groups[] every retake cluster, which attempt won and why the others lost
30
+ * dropped[] what went, with the reason
31
+ * checks{} verification, all of which must pass
32
+ */
33
+ import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
34
+ import {basename, dirname, join} from 'node:path';
35
+ import {decodePcm} from './lib/media.mjs';
36
+
37
+ /* ------------------------------------------------------------------------ args */
38
+
39
+ const args = process.argv.slice(2);
40
+ const flag = (name, fallback) => {
41
+ const i = args.indexOf(`--${name}`);
42
+ return i === -1 ? fallback : args[i + 1];
43
+ };
44
+ const positional = args.filter((a, i) => !a.startsWith('--') && !String(args[i - 1] ?? '').startsWith('--'));
45
+
46
+ const wordsPath = positional[0];
47
+ if (!wordsPath) {
48
+ console.error('usage: npm run takes -- <words.json> [--media <video>] [--script <script.md>]');
49
+ process.exit(1);
50
+ }
51
+
52
+ const FPS = Number(flag('fps', 30));
53
+ /** How alike two passages must be before they count as attempts at the same thing. */
54
+ const SIMILAR = Number(flag('similar', 0.72));
55
+ /** Retakes happen close together. Two matching sentences minutes apart are both wanted. */
56
+ const WINDOW = Number(flag('window', 90));
57
+ /** A silence longer than this is dead air and gets collapsed. */
58
+ const GAP_MAX = Number(flag('gap-max', 0.6));
59
+ /** ...down to this. Not zero: speech with every gap removed sounds like a machine. */
60
+ const GAP_KEEP = Number(flag('gap-keep', 0.25));
61
+ /**
62
+ * Breathing room kept either side of a kept passage.
63
+ *
64
+ * 150ms, not the 60 an earlier version used. Below about 120 the cut lands close enough to the
65
+ * first phoneme to clip it, and the join reads as abrupt even when every word is intact.
66
+ */
67
+ const PAD = 0.15;
68
+
69
+ /* ------------------------------------------------------------------- the words */
70
+
71
+ const key = (t) => t.toLowerCase().replace(/['‘’]/g, '').replace(/[^a-z0-9]/g, '');
72
+
73
+ const words = JSON.parse(readFileSync(wordsPath, 'utf8')).words.filter((w) => w.start != null && w.end != null);
74
+ if (words.length < 10) {
75
+ console.error('Not enough timed words to work with.');
76
+ process.exit(1);
77
+ }
78
+
79
+ /**
80
+ * Cues that announce a retake out loud.
81
+ *
82
+ * When someone says one of these, the passage BEFORE it was abandoned — they are telling you so.
83
+ * Cheaper and more certain than any similarity measure.
84
+ */
85
+ const RESTART_CUES = [
86
+ 'let me redo that', 'let me try that again', 'one more time', 'sorry let me',
87
+ 'can i do that again', 'take two', 'scratch that', 'let me say that again',
88
+ 'start again', 'i will say that again',
89
+ ];
90
+
91
+ /* --------------------------------------------------------------- units of speech
92
+ * A unit is one spoken thought: it ends where the speaker put a full stop, or where they left a
93
+ * gap long enough to be a boundary rather than a breath. Working in units rather than fixed word
94
+ * counts means a retake is compared against the whole thought it replaced. */
95
+
96
+ const units = [];
97
+ {
98
+ let start = 0;
99
+ for (let i = 0; i < words.length; i++) {
100
+ const w = words[i];
101
+ const next = words[i + 1];
102
+ const gapAfter = next ? next.start - w.end : Infinity;
103
+ const endsSentence = /[.?!]$/.test(w.word);
104
+ if (endsSentence || gapAfter > 0.7 || i === words.length - 1) {
105
+ const span = words.slice(start, i + 1);
106
+ if (span.length) {
107
+ units.push({
108
+ index: units.length,
109
+ startIndex: start,
110
+ endIndex: i,
111
+ from: span[0].start,
112
+ to: span[span.length - 1].end,
113
+ text: span.map((x) => x.word).join(' '),
114
+ keys: span.map((x) => key(x.word)).filter(Boolean),
115
+ confidence: span.reduce((a, x) => a + (x.score ?? 0.8), 0) / span.length,
116
+ complete: endsSentence,
117
+ worstGap: span.slice(1).reduce((m, x, k) => Math.max(m, x.start - span[k].end), 0),
118
+ });
119
+ }
120
+ start = i + 1;
121
+ }
122
+ }
123
+ }
124
+
125
+ /* -------------------------------------------------------------- energy and pace */
126
+
127
+ /**
128
+ * Loudness per unit, so a flat read can lose to a committed one.
129
+ *
130
+ * Optional: without --media there is no audio to measure and the score simply does without it,
131
+ * rather than the tool refusing to run.
132
+ */
133
+ let rmsOf = () => null;
134
+ const mediaPath = flag('media', null);
135
+ if (mediaPath && existsSync(mediaPath)) {
136
+ const RATE = 22050;
137
+ const pcm = decodePcm(mediaPath, RATE);
138
+ if (pcm) {
139
+ rmsOf = (from, to) => {
140
+ const a = Math.max(0, Math.floor(from * RATE));
141
+ const b = Math.min(pcm.length, Math.ceil(to * RATE));
142
+ if (b <= a) return null;
143
+ let sum = 0;
144
+ for (let i = a; i < b; i++) sum += pcm[i] * pcm[i];
145
+ return Math.sqrt(sum / (b - a));
146
+ };
147
+ }
148
+ }
149
+
150
+ for (const u of units) {
151
+ u.seconds = u.to - u.from;
152
+ u.wordsPerSecond = u.keys.length / Math.max(0.3, u.seconds);
153
+ u.rms = rmsOf(u.from, u.to);
154
+ }
155
+
156
+ const median = (xs) => {
157
+ const s = xs.filter((x) => x != null).sort((a, b) => a - b);
158
+ return s.length ? s[Math.floor(s.length / 2)] : null;
159
+ };
160
+ const medianRms = median(units.map((u) => u.rms));
161
+ const medianPace = median(units.map((u) => u.wordsPerSecond)) ?? 3;
162
+
163
+ /* ------------------------------------------------------------------ similarity */
164
+
165
+ /** Longest common subsequence length — tolerant of an inserted stumble or a swapped word. */
166
+ const lcs = (a, b) => {
167
+ const prev = new Array(b.length + 1).fill(0);
168
+ const cur = new Array(b.length + 1).fill(0);
169
+ for (let i = 1; i <= a.length; i++) {
170
+ for (let j = 1; j <= b.length; j++) {
171
+ cur[j] = a[i - 1] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], cur[j - 1]);
172
+ }
173
+ for (let j = 0; j <= b.length; j++) prev[j] = cur[j];
174
+ }
175
+ return prev[b.length];
176
+ };
177
+
178
+ /**
179
+ * How alike two units are, measured against the SHORTER one.
180
+ *
181
+ * Against the shorter, because an abandoned start — "so the main reason, so the main reason is
182
+ * that…" — is a short fragment entirely contained in the full attempt. Measured against the
183
+ * longer it would score low and be kept; against the shorter it scores 1.0 and is correctly
184
+ * recognised as the same thought, begun twice.
185
+ */
186
+ const similarity = (a, b) => {
187
+ if (!a.keys.length || !b.keys.length) return 0;
188
+ const shorter = Math.min(a.keys.length, b.keys.length);
189
+ // Short units match each other on function words alone: two four-word lines sharing "give it"
190
+ // score 0.5 without meaning anything. Below five words there is not enough signal to call
191
+ // something a retake.
192
+ if (shorter < 5) return 0;
193
+ const overlap = lcs(a.keys, b.keys);
194
+ // And the overlap has to be real words, not two articles and a pronoun.
195
+ if (overlap < 4) return 0;
196
+ return overlap / shorter;
197
+ };
198
+
199
+ /* ---------------------------------------------------------------------- groups */
200
+
201
+ const groupOf = new Array(units.length).fill(-1);
202
+ const groups = [];
203
+
204
+ /**
205
+ * Grouping is against a representative, never transitive.
206
+ *
207
+ * Chaining members together — A matches B, B matches C, so group them all — merges passages that
208
+ * have nothing to do with each other as soon as one ambiguous unit sits between them — two
209
+ * unrelated sentences end up in a single group because each matched the same middle one. A unit
210
+ * joins a group only if it is similar to that group's FIRST member, which keeps a group to one
211
+ * thought said more than once.
212
+ */
213
+ for (let i = 0; i < units.length; i++) {
214
+ if (groupOf[i] !== -1) continue;
215
+ const members = [i];
216
+ for (let j = i + 1; j < units.length; j++) {
217
+ if (groupOf[j] !== -1) continue;
218
+ if (units[j].from - units[i].to > WINDOW) break;
219
+ if (similarity(units[i], units[j]) < SIMILAR) continue;
220
+ members.push(j);
221
+ }
222
+ if (members.length < 2) continue;
223
+ groups.push({members});
224
+ for (const m of members) groupOf[m] = groups.length - 1;
225
+ }
226
+
227
+ /** A spoken cue abandons whatever came immediately before it. */
228
+ const abandoned = new Set();
229
+ for (const u of units) {
230
+ const flat = u.keys.join(' ');
231
+ if (!RESTART_CUES.some((c) => flat.includes(c.replace(/[^a-z0-9 ]/g, '')))) continue;
232
+ const prev = units[u.index - 1];
233
+ if (prev) abandoned.add(prev.index);
234
+ }
235
+
236
+ /* --------------------------------------------------------------------- scoring */
237
+
238
+ /**
239
+ * One number per attempt.
240
+ *
241
+ * Completeness dominates — a finished sentence beats a smoother fragment every time. Everything
242
+ * else nudges. The tie-break at the end is deliberate and matches how people actually record:
243
+ * they repeat a line until they are happy, so the last attempt is usually the keeper.
244
+ */
245
+ const score = (u) => {
246
+ let s = 0;
247
+ s += u.complete ? 6 : 0;
248
+ s += Math.min(u.keys.length / 12, 1) * 3;
249
+ s += u.confidence * 2;
250
+ s -= Math.max(0, u.worstGap - 0.45) * 3;
251
+ if (abandoned.has(u.index)) s -= 20;
252
+ // Pace: too fast reads as rushed, too slow as laboured. Either direction costs the same.
253
+ s -= Math.min(2, Math.abs(u.wordsPerSecond - medianPace) / Math.max(0.8, medianPace) * 2);
254
+ // Energy, only when there was audio to measure.
255
+ if (u.rms != null && medianRms) s += Math.max(-1.5, Math.min(1.5, (u.rms / medianRms - 1) * 2));
256
+ return s;
257
+ };
258
+
259
+ for (const u of units) u.score = score(u);
260
+
261
+ /* ------------------------------------------------------- optional script checker
262
+ * A script never removes anything. It reports lines that were never said, and nudges a tie
263
+ * toward whichever attempt is closest to what was written. */
264
+
265
+ const scriptPath = flag('script', null);
266
+ let scriptMissing = [];
267
+ if (scriptPath && existsSync(scriptPath)) {
268
+ const raw = readFileSync(scriptPath, 'utf8');
269
+ const body = /^---\s*$/m.test(raw) ? raw.split(/^---\s*$/m).slice(1).join('\n') : raw;
270
+ const lines = body
271
+ .replace(/<!--[\s\S]*?-->/g, '')
272
+ .replace(/^#.*$/gm, '')
273
+ .replace(/`[^`]*`/g, ' ')
274
+ .split(/(?<=[.?!])\s+/)
275
+ .map((s) => s.replace(/\s+/g, ' ').trim())
276
+ .filter((s) => s.split(/\s+/).length >= 4);
277
+
278
+ for (const line of lines) {
279
+ const lk = line.split(/\s+/).map(key).filter(Boolean);
280
+ const best = Math.max(0, ...units.map((u) => lcs(lk, u.keys) / Math.max(1, lk.length)));
281
+ if (best < 0.6) scriptMissing.push({text: line, bestMatch: Number(best.toFixed(2))});
282
+ }
283
+ // Closest-to-script wins a close call, but only by a little.
284
+ for (const g of groups) {
285
+ for (const m of g.members) {
286
+ const u = units[m];
287
+ const best = Math.max(0, ...lines.map((l) => lcs(l.split(/\s+/).map(key).filter(Boolean), u.keys) / Math.max(1, u.keys.length)));
288
+ u.score += best * 1.2;
289
+ }
290
+ }
291
+ }
292
+
293
+ /* ----------------------------------------------------------------- the decision */
294
+
295
+ const drop = new Set();
296
+ const groupReport = [];
297
+
298
+ for (const g of groups) {
299
+ const members = [...new Set(g.members)].sort((a, b) => a - b);
300
+ let winner = members[0];
301
+ for (const m of members) {
302
+ const a = units[m];
303
+ const b = units[winner];
304
+ // Within a hair of each other, the later one wins.
305
+ if (a.score > b.score + 0.3 || (Math.abs(a.score - b.score) <= 0.3 && a.from > b.from)) winner = m;
306
+ }
307
+ for (const m of members) if (m !== winner) drop.add(m);
308
+ groupReport.push({
309
+ kept: {at: Number(units[winner].from.toFixed(2)), text: units[winner].text.slice(0, 90), score: Number(units[winner].score.toFixed(2))},
310
+ dropped: members.filter((m) => m !== winner).map((m) => ({
311
+ at: Number(units[m].from.toFixed(2)),
312
+ text: units[m].text.slice(0, 90),
313
+ score: Number(units[m].score.toFixed(2)),
314
+ })),
315
+ });
316
+ }
317
+ for (const i of abandoned) drop.add(i);
318
+
319
+ const kept = units.filter((u) => !drop.has(u.index));
320
+
321
+ /* ------------------------------------------------------------- building the plan
322
+ * Consecutive survivors become one span, so the cut is not chopped at every sentence. A gap
323
+ * between spans is collapsed only when it is longer than a breath — and a pause in front of a
324
+ * short line is usually deliberate, so those are left alone. */
325
+
326
+ const keep = [];
327
+ for (const u of kept) {
328
+ const last = keep[keep.length - 1];
329
+ if (last && u.startIndex === last.endIndex + 1 && u.from - last.to < GAP_MAX) {
330
+ last.to = u.to;
331
+ last.endIndex = u.endIndex;
332
+ last.units.push(u.index);
333
+ } else {
334
+ keep.push({from: u.from, to: u.to, startIndex: u.startIndex, endIndex: u.endIndex, units: [u.index]});
335
+ }
336
+ }
337
+
338
+ for (const k of keep) {
339
+ k.from = Math.max(0, k.from - PAD);
340
+ k.to += PAD;
341
+ }
342
+ for (let i = 1; i < keep.length; i++) {
343
+ if (keep[i].from < keep[i - 1].to) {
344
+ const mid = (keep[i].from + keep[i - 1].to) / 2;
345
+ keep[i - 1].to = mid;
346
+ keep[i].from = mid;
347
+ }
348
+ }
349
+
350
+ /** Gaps between spans, after the cut: long dead air collapsed, deliberate beats kept. */
351
+ const gaps = [];
352
+ for (let i = 1; i < keep.length; i++) {
353
+ const gap = keep[i].from - keep[i - 1].to;
354
+ const nextIsShort = units[keep[i].units[0]].keys.length <= 6;
355
+ const target = gap > GAP_MAX && !nextIsShort ? GAP_KEEP : gap;
356
+ gaps.push({after: i - 1, was: Number(gap.toFixed(2)), becomes: Number(target.toFixed(2)), keptAsBeat: target === gap && gap > GAP_MAX});
357
+ }
358
+
359
+ const dropped = [...drop].sort((a, b) => a - b).map((i) => ({
360
+ from: Number(units[i].from.toFixed(2)),
361
+ to: Number(units[i].to.toFixed(2)),
362
+ reason: abandoned.has(i) ? 'announced-retake' : 'retake',
363
+ text: units[i].text.slice(0, 110),
364
+ }));
365
+
366
+ /* ----------------------------------------------------------------- verification */
367
+
368
+ const survivors = kept;
369
+ const stillAlike = [];
370
+ for (let i = 0; i < survivors.length; i++) {
371
+ for (let j = i + 1; j < survivors.length; j++) {
372
+ if (survivors[j].from - survivors[i].to > WINDOW) break;
373
+ if (similarity(survivors[i], survivors[j]) >= SIMILAR) {
374
+ stillAlike.push({
375
+ a: Number(survivors[i].from.toFixed(2)),
376
+ b: Number(survivors[j].from.toFixed(2)),
377
+ text: survivors[j].text.slice(0, 70),
378
+ });
379
+ }
380
+ }
381
+ }
382
+
383
+ const keptSeconds = keep.reduce((a, k) => a + (k.to - k.from), 0);
384
+ const sourceSeconds = words[words.length - 1].end - words[0].start;
385
+ const checks = {
386
+ noRepeatsLeft: stillAlike.length === 0,
387
+ units: units.length,
388
+ retakeGroups: groups.length,
389
+ unitsDropped: drop.size,
390
+ spans: keep.length,
391
+ keptSeconds: Number(keptSeconds.toFixed(2)),
392
+ sourceSeconds: Number(sourceSeconds.toFixed(2)),
393
+ energyScored: medianRms != null,
394
+ };
395
+
396
+ /* ---------------------------------------------------------------------- output */
397
+
398
+ const stem = basename(wordsPath).replace(/\.?words\.json$|\.json$/, '') || 'take';
399
+ const project = flag('project', null);
400
+ const outPath = flag('out', null) ?? (project ? join('work', project, `${stem}.takes.json`) : join(dirname(wordsPath), `${stem}.takes.json`));
401
+ mkdirSync(dirname(outPath), {recursive: true});
402
+ writeFileSync(outPath, JSON.stringify({fps: FPS, gapMax: GAP_MAX, gapKeep: GAP_KEEP, pad: PAD, checks, keep, gaps, groups: groupReport, dropped, scriptMissing}, null, 2));
403
+
404
+ console.log(outPath);
405
+ console.log(` ${checks.units} units, ${checks.retakeGroups} retake group(s), ${checks.unitsDropped} dropped`);
406
+ console.log(` ${checks.keptSeconds}s kept of ${checks.sourceSeconds}s (${((keptSeconds / sourceSeconds) * 100).toFixed(0)}%) in ${checks.spans} span(s)`);
407
+ console.log(` energy scoring: ${checks.energyScored ? 'on' : 'off (pass --media to enable)'}`);
408
+ if (groupReport.length) {
409
+ console.log('\n retakes dropped:');
410
+ for (const g of groupReport.slice(0, 8)) {
411
+ for (const d of g.dropped) console.log(` ${String(d.at).padStart(7)}s "${d.text}"`);
412
+ }
413
+ }
414
+ const beats = gaps.filter((g) => g.keptAsBeat).length;
415
+ if (beats) console.log(`\n ${beats} long pause(s) kept as deliberate beats before a short line.`);
416
+ if (scriptPath) {
417
+ console.log(`\n script check: ${scriptMissing.length} line(s) never said`);
418
+ for (const m of scriptMissing.slice(0, 6)) console.log(` - "${m.text.slice(0, 68)}"`);
419
+ }
420
+ if (stillAlike.length) {
421
+ console.error(`\n${stillAlike.length} near-duplicate(s) still in the cut:`);
422
+ for (const s of stillAlike.slice(0, 8)) console.error(` ${s.a}s and ${s.b}s — "${s.text}"`);
423
+ process.exit(1);
424
+ }