creator-editing-studio 1.3.1 → 1.3.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "creator-editing-studio",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "Scaffolds a Creator Editing OS studio — components, scripts and rules for editing video from a plain-English brief.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -180,6 +180,14 @@ is the thing most likely to be wrong, not them. Three rules, learned the hard wa
180
180
  If the audio really is clean, the repeat is visual — a held caption, a reused graphic, a duplicated
181
181
  framing — and that is still a real fault, not a misunderstanding to explain away.
182
182
 
183
+ **A transcript is not evidence.** Whisper suppresses repeated phrases: when someone says a line
184
+ twice it transcribes it once and stretches the last recognised word across the speech it dropped,
185
+ so the transcript reads clean while the audio plainly repeats. Run `node scripts/coverage.mjs
186
+ <media> <words.json>` after every transcription and before planning anything — it flags any word
187
+ far longer than the median, and any stretch above the noise floor with no word on it. Both mean
188
+ the transcript is hiding speech. Check the energy across the flagged word: one decaying word is
189
+ fine, a dip to silence followed by a second loud onset is a repeat.
190
+
183
191
  **Four generations of lossy audio.** The voice must be cut as PCM and encoded exactly once, at
184
192
  delivery. Cutting an AAC file, trimming the result, and then letting the renderer re-encode it
185
193
  puts the voice through four generations of loss, and raising the bitrate on the later passes
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Find speech the transcriber did not transcribe.
4
+ *
5
+ * node scripts/coverage.mjs <media> <words.json> [--gap 0.35] [--floor -38]
6
+ *
7
+ * WHY THIS EXISTS. Whisper suppresses repeated phrases. When a speaker says the same line twice,
8
+ * it transcribes it once and silently leaves a hole where the duplicate was — so a transcript can
9
+ * read perfectly clean while the audio plainly repeats. On demo-test that cost four rounds: the
10
+ * owner said three times that "adds a few zooms" repeated, the transcript said it did not, and the
11
+ * hole got mistaken for a pause and trimmed.
12
+ *
13
+ * A transcript is not evidence on its own. This checks it against the audio: any stretch that is
14
+ * loud enough to be speech but carries no word is either a dropped duplicate or a dropped line,
15
+ * and both are faults. Run it after every transcription, before planning anything.
16
+ */
17
+ import {spawnSync} from 'node:child_process';
18
+ import {readFileSync} from 'node:fs';
19
+ import {FFMPEG} from './lib/media.mjs';
20
+
21
+ const [, , media, wordsFile, ...rest] = process.argv;
22
+ if (!media || !wordsFile) {
23
+ console.error('Usage: node scripts/coverage.mjs <media> <words.json> [--gap 0.35] [--floor -38]');
24
+ process.exit(2);
25
+ }
26
+ const opt = (n, d) => {
27
+ const i = rest.indexOf(`--${n}`);
28
+ return i >= 0 ? Number(rest[i + 1]) : d;
29
+ };
30
+ /** Shortest unlabelled speech run worth reporting, in seconds. */
31
+ const MIN_GAP = opt('gap', 0.35);
32
+ /** Above this RMS it is speech, below it is room tone. */
33
+ const FLOOR_DB = opt('floor', -38);
34
+
35
+ const SR = 16000;
36
+ const WIN = Math.round(SR * 0.05);
37
+
38
+ const pcm = spawnSync(FFMPEG, ['-v', 'error', '-i', media, '-vn', '-ac', '1', '-ar', String(SR), '-f', 's16le', '-'], {
39
+ encoding: 'buffer',
40
+ maxBuffer: 1 << 28,
41
+ });
42
+ if (pcm.status !== 0) {
43
+ console.error(pcm.stderr?.toString().slice(0, 600));
44
+ process.exit(1);
45
+ }
46
+ const buf = pcm.stdout;
47
+ const samples = buf.length / 2;
48
+
49
+ const loud = (t) => {
50
+ const a = Math.round(t * SR);
51
+ let s = 0;
52
+ let c = 0;
53
+ for (let i = a; i < a + WIN && i < samples; i++) {
54
+ const v = buf.readInt16LE(i * 2);
55
+ s += v * v;
56
+ c++;
57
+ }
58
+ const r = Math.sqrt(s / Math.max(1, c));
59
+ return r > 0 ? 20 * Math.log10(r / 32768) : -99;
60
+ };
61
+
62
+ const data = JSON.parse(readFileSync(wordsFile, 'utf8'));
63
+ const words = data.words ?? data;
64
+ const fps = data.fps ?? 30;
65
+ /** Words may carry frames (f/t) or seconds (start/end). */
66
+ const spans = words.map((w) => ({
67
+ from: w.start ?? w.f / fps,
68
+ to: w.end ?? (w.t ?? w.f) / fps,
69
+ }));
70
+
71
+ const covered = (t) => spans.some((s) => t >= s.from - 0.06 && t <= s.to + 0.06);
72
+
73
+ const duration = samples / SR;
74
+ const holes = [];
75
+ let run = null;
76
+ for (let t = 0; t < duration; t += 0.05) {
77
+ const speech = loud(t) > FLOOR_DB;
78
+ if (speech && !covered(t)) {
79
+ if (!run) run = {from: t, to: t};
80
+ else run.to = t;
81
+ } else if (run) {
82
+ if (run.to - run.from >= MIN_GAP) holes.push(run);
83
+ run = null;
84
+ }
85
+ }
86
+ if (run && run.to - run.from >= MIN_GAP) holes.push(run);
87
+
88
+ /**
89
+ * The detector that actually works.
90
+ *
91
+ * A collapsed duplicate does not leave an untranscribed hole — Whisper stretches the LAST word it
92
+ * did recognise across the speech it dropped. On demo-test the word "adds" came back 0.68s long
93
+ * with the lowest confidence in the sentence, covering a whole repeated phrase, so a coverage test
94
+ * based on spans called the file clean. Word duration is what gives it away.
95
+ */
96
+ const lengths = words.map((w, i) => ({w, i, dur: spans[i].to - spans[i].from})).filter((x) => x.dur > 0);
97
+ const sortedDur = lengths.map((x) => x.dur).sort((a, b) => a - b);
98
+ const median = sortedDur[Math.floor(sortedDur.length / 2)] || 0.2;
99
+ const stretched = lengths.filter((x) => x.dur > median * 3);
100
+ if (stretched.length) {
101
+ console.log(
102
+ `${stretched.length} word(s) far longer than the ${median.toFixed(2)}s median — ` +
103
+ `each may be covering speech the transcriber dropped:\n`,
104
+ );
105
+ for (const x of stretched) {
106
+ const label = x.w.w ?? x.w.word;
107
+ console.log(
108
+ ` "${label}" ${spans[x.i].from.toFixed(2)}s - ${spans[x.i].to.toFixed(2)}s = ` +
109
+ `${x.dur.toFixed(2)}s (${(x.dur / median).toFixed(1)}x median)`,
110
+ );
111
+ }
112
+ console.log('\nCheck the energy across each one. A single decaying word is fine; a dip to');
113
+ console.log('silence followed by a second loud onset is a repeat the transcript is hiding.');
114
+ }
115
+
116
+ if (!holes.length && !stretched.length) {
117
+ console.log(`clean — every stretch above ${FLOOR_DB}dB has a word on it (${words.length} words, ${duration.toFixed(2)}s)`);
118
+ process.exit(0);
119
+ }
120
+
121
+ if (!holes.length) process.exit(1);
122
+
123
+ console.log(`${holes.length} stretch(es) of SPEECH WITH NO WORDS — the transcript is missing something:\n`);
124
+ for (const h of holes) {
125
+ const before = spans.filter((s) => s.to <= h.from).pop();
126
+ const after = spans.find((s) => s.from >= h.to);
127
+ const bw = before ? words[spans.indexOf(before)] : null;
128
+ const aw = after ? words[spans.indexOf(after)] : null;
129
+ console.log(
130
+ ` ${h.from.toFixed(2)}s - ${h.to.toFixed(2)}s (${(h.to - h.from).toFixed(2)}s)` +
131
+ ` between "${bw ? bw.w ?? bw.word : 'start'}" and "${aw ? aw.w ?? aw.word : 'end'}"`,
132
+ );
133
+ }
134
+ console.log('\nMost likely a repeated phrase the transcriber collapsed. Listen to it before cutting it.');
135
+ process.exit(1);