eprec 0.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -29
  3. package/app/assets/styles.css +129 -0
  4. package/app/client/app.tsx +37 -0
  5. package/app/client/counter.tsx +22 -0
  6. package/app/client/entry.tsx +8 -0
  7. package/app/components/layout.tsx +37 -0
  8. package/app/config/env.ts +31 -0
  9. package/app/config/import-map.ts +9 -0
  10. package/app/config/init-env.ts +3 -0
  11. package/app/config/routes.ts +5 -0
  12. package/app/helpers/render.ts +6 -0
  13. package/app/router.tsx +102 -0
  14. package/app/routes/index.tsx +50 -0
  15. package/app-server.ts +60 -0
  16. package/cli.ts +173 -0
  17. package/package.json +46 -7
  18. package/process-course/chapter-processor.ts +1037 -0
  19. package/process-course/cli.ts +236 -0
  20. package/process-course/config.ts +50 -0
  21. package/process-course/edits/cli.ts +167 -0
  22. package/process-course/edits/combined-video-editor.ts +316 -0
  23. package/process-course/edits/edit-workspace.ts +90 -0
  24. package/process-course/edits/index.ts +20 -0
  25. package/process-course/edits/regenerate-transcript.ts +84 -0
  26. package/process-course/edits/remove-ranges.test.ts +36 -0
  27. package/process-course/edits/remove-ranges.ts +287 -0
  28. package/process-course/edits/timestamp-refinement.test.ts +25 -0
  29. package/process-course/edits/timestamp-refinement.ts +172 -0
  30. package/process-course/edits/transcript-diff.test.ts +105 -0
  31. package/process-course/edits/transcript-diff.ts +214 -0
  32. package/process-course/edits/transcript-output.test.ts +50 -0
  33. package/process-course/edits/transcript-output.ts +36 -0
  34. package/process-course/edits/types.ts +26 -0
  35. package/process-course/edits/video-editor.ts +246 -0
  36. package/process-course/errors.test.ts +63 -0
  37. package/process-course/errors.ts +82 -0
  38. package/process-course/ffmpeg.ts +449 -0
  39. package/process-course/jarvis-commands/handlers.ts +71 -0
  40. package/process-course/jarvis-commands/index.ts +14 -0
  41. package/process-course/jarvis-commands/parser.test.ts +348 -0
  42. package/process-course/jarvis-commands/parser.ts +257 -0
  43. package/process-course/jarvis-commands/types.ts +46 -0
  44. package/process-course/jarvis-commands/windows.ts +254 -0
  45. package/process-course/logging.ts +24 -0
  46. package/process-course/paths.test.ts +59 -0
  47. package/process-course/paths.ts +53 -0
  48. package/process-course/summary.test.ts +209 -0
  49. package/process-course/summary.ts +210 -0
  50. package/process-course/types.ts +85 -0
  51. package/process-course/utils/audio-analysis.test.ts +348 -0
  52. package/process-course/utils/audio-analysis.ts +463 -0
  53. package/process-course/utils/chapter-selection.test.ts +307 -0
  54. package/process-course/utils/chapter-selection.ts +136 -0
  55. package/process-course/utils/file-utils.test.ts +83 -0
  56. package/process-course/utils/file-utils.ts +57 -0
  57. package/process-course/utils/filename.test.ts +27 -0
  58. package/process-course/utils/filename.ts +12 -0
  59. package/process-course/utils/time-ranges.test.ts +221 -0
  60. package/process-course/utils/time-ranges.ts +86 -0
  61. package/process-course/utils/transcript.test.ts +257 -0
  62. package/process-course/utils/transcript.ts +86 -0
  63. package/process-course/utils/video-editing.ts +44 -0
  64. package/process-course-video.ts +389 -0
  65. package/public/robots.txt +2 -0
  66. package/server/bundling.ts +210 -0
  67. package/speech-detection.ts +355 -0
  68. package/utils.ts +138 -0
  69. package/whispercpp-transcribe.ts +343 -0
@@ -0,0 +1,348 @@
1
+ import { test, expect } from 'bun:test'
2
+ import { scaleTranscriptSegments, extractTranscriptCommands } from './parser'
3
+ import type { TranscriptSegment } from '../../whispercpp-transcribe'
4
+
5
+ // Factory functions for test data
6
+ function createSegment(
7
+ start: number,
8
+ end: number,
9
+ text: string,
10
+ ): TranscriptSegment {
11
+ return { start, end, text }
12
+ }
13
+
14
+ function createSegments(
15
+ ...entries: Array<[number, number, string]>
16
+ ): TranscriptSegment[] {
17
+ return entries.map(([start, end, text]) => createSegment(start, end, text))
18
+ }
19
+
20
+ const defaultOptions = { wakeWord: 'jarvis', closeWord: 'thanks' }
21
+
22
+ // scaleTranscriptSegments - no scaling needed
23
+ test('scaleTranscriptSegments returns empty array for empty input', () => {
24
+ expect(scaleTranscriptSegments([], 100)).toEqual([])
25
+ })
26
+
27
+ test('scaleTranscriptSegments returns unchanged when max end matches duration', () => {
28
+ const segments = createSegments([0, 50, 'Hello'], [50, 100, 'world'])
29
+ expect(scaleTranscriptSegments(segments, 100)).toEqual(segments)
30
+ })
31
+
32
+ test('scaleTranscriptSegments returns unchanged within 2% tolerance', () => {
33
+ const segments = createSegments([0, 50, 'Hello'], [50, 99, 'world'])
34
+ expect(scaleTranscriptSegments(segments, 100)).toEqual(segments)
35
+ })
36
+
37
+ // scaleTranscriptSegments - scaling applied
38
+ test('scaleTranscriptSegments scales up when transcript shorter than duration', () => {
39
+ const segments = createSegments([0, 25, 'Hello'], [25, 50, 'world'])
40
+ const result = scaleTranscriptSegments(segments, 100)
41
+ expect(result).toEqual(createSegments([0, 50, 'Hello'], [50, 100, 'world']))
42
+ })
43
+
44
+ test('scaleTranscriptSegments scales down when transcript longer than duration', () => {
45
+ const segments = createSegments([0, 100, 'Hello'], [100, 200, 'world'])
46
+ const result = scaleTranscriptSegments(segments, 100)
47
+ expect(result).toEqual(createSegments([0, 50, 'Hello'], [50, 100, 'world']))
48
+ })
49
+
50
+ test('scaleTranscriptSegments preserves text when scaling', () => {
51
+ const segments = createSegments([0, 50, 'Hello world'])
52
+ const result = scaleTranscriptSegments(segments, 100)
53
+ expect(result[0]?.text).toBe('Hello world')
54
+ })
55
+
56
+ // scaleTranscriptSegments - edge cases
57
+ test('scaleTranscriptSegments returns unchanged for zero duration', () => {
58
+ const segments = createSegments([0, 50, 'Hello'])
59
+ expect(scaleTranscriptSegments(segments, 0)).toEqual(segments)
60
+ })
61
+
62
+ test('scaleTranscriptSegments returns unchanged for negative duration', () => {
63
+ const segments = createSegments([0, 50, 'Hello'])
64
+ expect(scaleTranscriptSegments(segments, -100)).toEqual(segments)
65
+ })
66
+
67
+ test('scaleTranscriptSegments returns unchanged for NaN duration', () => {
68
+ const segments = createSegments([0, 50, 'Hello'])
69
+ expect(scaleTranscriptSegments(segments, NaN)).toEqual(segments)
70
+ })
71
+
72
+ test('scaleTranscriptSegments returns unchanged for Infinity duration', () => {
73
+ const segments = createSegments([0, 50, 'Hello'])
74
+ expect(scaleTranscriptSegments(segments, Infinity)).toEqual(segments)
75
+ })
76
+
77
+ test('scaleTranscriptSegments returns unchanged when all segments have zero end', () => {
78
+ const segments = createSegments([0, 0, 'Hello'])
79
+ expect(scaleTranscriptSegments(segments, 100)).toEqual(segments)
80
+ })
81
+
82
+ test('scaleTranscriptSegments uses alphanumeric candidates for max end', () => {
83
+ const segments = createSegments([0, 50, 'Hello'], [50, 100, '...'])
84
+ const result = scaleTranscriptSegments(segments, 100)
85
+ expect(result[0]).toEqual(createSegment(0, 100, 'Hello'))
86
+ expect(result[1]).toEqual(createSegment(100, 200, '...'))
87
+ })
88
+
89
+ test('scaleTranscriptSegments falls back to all segments when no alphanumeric', () => {
90
+ const segments = createSegments([0, 25, '...'], [25, 50, '!!!'])
91
+ const result = scaleTranscriptSegments(segments, 100)
92
+ expect(result).toEqual(createSegments([0, 50, '...'], [50, 100, '!!!']))
93
+ })
94
+
95
+ // extractTranscriptCommands - bad-take
96
+ test('extractTranscriptCommands extracts bad-take command', () => {
97
+ const segments = createSegments([0, 2, 'Jarvis bad take thanks'])
98
+ const commands = extractTranscriptCommands(segments, defaultOptions)
99
+ expect(commands).toHaveLength(1)
100
+ expect(commands[0]).toEqual({
101
+ type: 'bad-take',
102
+ window: { start: 0, end: 2 },
103
+ })
104
+ })
105
+
106
+ test('extractTranscriptCommands extracts bad-take from badtake as one word', () => {
107
+ const segments = createSegments([0, 2, 'Jarvis badtake thanks'])
108
+ const commands = extractTranscriptCommands(segments, defaultOptions)
109
+ expect(commands).toHaveLength(1)
110
+ expect(commands[0]?.type).toBe('bad-take')
111
+ })
112
+
113
+ test('extractTranscriptCommands extracts bad-take from Jervis (corrected)', () => {
114
+ const segments = createSegments([0, 2, 'Jervis bad take thanks'])
115
+ const commands = extractTranscriptCommands(segments, defaultOptions)
116
+ expect(commands).toHaveLength(1)
117
+ expect(commands[0]?.type).toBe('bad-take')
118
+ })
119
+
120
+ // extractTranscriptCommands - filename
121
+ test('extractTranscriptCommands extracts filename command with value', () => {
122
+ const segments = createSegments([
123
+ 0,
124
+ 3,
125
+ 'Jarvis filename intro to react thanks',
126
+ ])
127
+ const commands = extractTranscriptCommands(segments, defaultOptions)
128
+ expect(commands).toHaveLength(1)
129
+ expect(commands[0]).toEqual({
130
+ type: 'filename',
131
+ value: 'intro to react',
132
+ window: { start: 0, end: 3 },
133
+ })
134
+ })
135
+
136
+ test('extractTranscriptCommands extracts filename with file name as two words', () => {
137
+ const segments = createSegments([
138
+ 0,
139
+ 3,
140
+ 'Jarvis file name hooks tutorial thanks',
141
+ ])
142
+ const commands = extractTranscriptCommands(segments, defaultOptions)
143
+ expect(commands).toHaveLength(1)
144
+ expect(commands[0]?.type).toBe('filename')
145
+ expect(commands[0]?.value).toBe('hooks tutorial')
146
+ })
147
+
148
+ test('extractTranscriptCommands ignores filename without value', () => {
149
+ const segments = createSegments([0, 2, 'Jarvis filename thanks'])
150
+ const commands = extractTranscriptCommands(segments, defaultOptions)
151
+ expect(commands).toHaveLength(0)
152
+ })
153
+
154
+ test('extractTranscriptCommands ignores file name without value', () => {
155
+ const segments = createSegments([0, 2, 'Jarvis file name thanks'])
156
+ const commands = extractTranscriptCommands(segments, defaultOptions)
157
+ expect(commands).toHaveLength(0)
158
+ })
159
+
160
+ // extractTranscriptCommands - edit
161
+ test('extractTranscriptCommands extracts edit command', () => {
162
+ const segments = createSegments([0, 2, 'Jarvis edit thanks'])
163
+ const commands = extractTranscriptCommands(segments, defaultOptions)
164
+ expect(commands).toHaveLength(1)
165
+ expect(commands[0]).toEqual({
166
+ type: 'edit',
167
+ window: { start: 0, end: 2 },
168
+ })
169
+ })
170
+
171
+ // extractTranscriptCommands - note
172
+ test('extractTranscriptCommands extracts note command with value', () => {
173
+ const segments = createSegments([
174
+ 0,
175
+ 3,
176
+ 'Jarvis note add more examples thanks',
177
+ ])
178
+ const commands = extractTranscriptCommands(segments, defaultOptions)
179
+ expect(commands).toHaveLength(1)
180
+ expect(commands[0]).toEqual({
181
+ type: 'note',
182
+ value: 'add more examples',
183
+ window: { start: 0, end: 3 },
184
+ })
185
+ })
186
+
187
+ test('extractTranscriptCommands ignores note without value', () => {
188
+ const segments = createSegments([0, 2, 'Jarvis note thanks'])
189
+ const commands = extractTranscriptCommands(segments, defaultOptions)
190
+ expect(commands).toHaveLength(0)
191
+ })
192
+
193
+ // extractTranscriptCommands - split
194
+ test('extractTranscriptCommands extracts split command', () => {
195
+ const segments = createSegments([0, 2, 'Jarvis split thanks'])
196
+ const commands = extractTranscriptCommands(segments, defaultOptions)
197
+ expect(commands).toHaveLength(1)
198
+ expect(commands[0]?.type).toBe('split')
199
+ })
200
+
201
+ test('extractTranscriptCommands extracts new chapter as split', () => {
202
+ const segments = createSegments([0, 3, 'Jarvis new chapter thanks'])
203
+ const commands = extractTranscriptCommands(segments, defaultOptions)
204
+ expect(commands).toHaveLength(1)
205
+ expect(commands[0]?.type).toBe('split')
206
+ })
207
+
208
+ // extractTranscriptCommands - combine-previous
209
+ test('extractTranscriptCommands extracts combine previous command', () => {
210
+ const segments = createSegments([0, 3, 'Jarvis combine previous thanks'])
211
+ const commands = extractTranscriptCommands(segments, defaultOptions)
212
+ expect(commands).toHaveLength(1)
213
+ expect(commands[0]).toEqual({
214
+ type: 'combine-previous',
215
+ window: { start: 0, end: 3 },
216
+ })
217
+ })
218
+
219
+ // extractTranscriptCommands - nevermind
220
+ test('extractTranscriptCommands extracts nevermind cancellation', () => {
221
+ const segments = createSegments([0, 3, 'Jarvis oops nevermind thanks'])
222
+ const commands = extractTranscriptCommands(segments, defaultOptions)
223
+ expect(commands).toHaveLength(1)
224
+ expect(commands[0]?.type).toBe('nevermind')
225
+ })
226
+
227
+ test('extractTranscriptCommands extracts never mind as two words', () => {
228
+ const segments = createSegments([0, 3, 'Jarvis oops never mind thanks'])
229
+ const commands = extractTranscriptCommands(segments, defaultOptions)
230
+ expect(commands).toHaveLength(1)
231
+ expect(commands[0]?.type).toBe('nevermind')
232
+ })
233
+
234
+ // extractTranscriptCommands - multiple commands
235
+ test('extractTranscriptCommands extracts multiple commands', () => {
236
+ const segments = createSegments(
237
+ [0, 2, 'Jarvis bad take thanks'],
238
+ [10, 13, 'Jarvis filename chapter one thanks'],
239
+ )
240
+ const commands = extractTranscriptCommands(segments, defaultOptions)
241
+ expect(commands).toHaveLength(2)
242
+ expect(commands[0]?.type).toBe('bad-take')
243
+ expect(commands[1]?.type).toBe('filename')
244
+ })
245
+
246
+ test('extractTranscriptCommands extracts multiple commands in one segment', () => {
247
+ const segments = createSegments([
248
+ 0,
249
+ 4,
250
+ 'Jarvis bad take thanks Jarvis edit thanks',
251
+ ])
252
+ const commands = extractTranscriptCommands(segments, defaultOptions)
253
+ expect(commands).toHaveLength(2)
254
+ expect(commands[0]?.type).toBe('bad-take')
255
+ expect(commands[1]?.type).toBe('edit')
256
+ })
257
+
258
+ // extractTranscriptCommands - no commands
259
+ test('extractTranscriptCommands returns empty for normal transcript', () => {
260
+ const segments = createSegments([
261
+ 0,
262
+ 5,
263
+ 'Hello world, this is a normal transcript',
264
+ ])
265
+ const commands = extractTranscriptCommands(segments, defaultOptions)
266
+ expect(commands).toHaveLength(0)
267
+ })
268
+
269
+ test('extractTranscriptCommands returns empty for empty segments', () => {
270
+ const commands = extractTranscriptCommands([], defaultOptions)
271
+ expect(commands).toHaveLength(0)
272
+ })
273
+
274
+ test('extractTranscriptCommands ignores jarvis without valid command starter', () => {
275
+ const segments = createSegments([0, 2, 'Jarvis hello thanks'])
276
+ const commands = extractTranscriptCommands(segments, defaultOptions)
277
+ expect(commands).toHaveLength(0)
278
+ })
279
+
280
+ test('extractTranscriptCommands ignores command without close word if too long', () => {
281
+ const segments = createSegments([
282
+ 0,
283
+ 20,
284
+ 'Jarvis bad take and then a lot more content',
285
+ ])
286
+ const commands = extractTranscriptCommands(segments, defaultOptions)
287
+ expect(commands).toHaveLength(0)
288
+ })
289
+
290
+ // extractTranscriptCommands - edge cases
291
+ test('extractTranscriptCommands handles non-chronological segments', () => {
292
+ const segments = createSegments([5, 8, 'bad take thanks'], [0, 3, 'Jarvis'])
293
+ const commands = extractTranscriptCommands(segments, defaultOptions)
294
+ expect(commands).toHaveLength(1)
295
+ expect(commands[0]?.type).toBe('bad-take')
296
+ })
297
+
298
+ test('extractTranscriptCommands handles command without close word at end', () => {
299
+ const segments = createSegments([0, 3, 'Jarvis bad take'])
300
+ const commands = extractTranscriptCommands(segments, defaultOptions)
301
+ expect(commands).toHaveLength(1)
302
+ expect(commands[0]?.type).toBe('bad-take')
303
+ })
304
+
305
+ test('extractTranscriptCommands handles blank audio segments', () => {
306
+ const segments = createSegments(
307
+ [0, 1, 'Jarvis'],
308
+ [1, 2, 'blank audio'],
309
+ [2, 3, 'bad take thanks'],
310
+ )
311
+ const commands = extractTranscriptCommands(segments, defaultOptions)
312
+ expect(commands).toHaveLength(1)
313
+ expect(commands[0]?.type).toBe('bad-take')
314
+ })
315
+
316
+ test('extractTranscriptCommands calculates correct window across segments', () => {
317
+ const segments = createSegments(
318
+ [5, 6, 'Jarvis'],
319
+ [6, 7, 'bad'],
320
+ [7, 8, 'take'],
321
+ [8, 9, 'thanks'],
322
+ )
323
+ const commands = extractTranscriptCommands(segments, defaultOptions)
324
+ expect(commands).toHaveLength(1)
325
+ expect(commands[0]?.window.start).toBe(5)
326
+ expect(commands[0]?.window.end).toBe(9)
327
+ })
328
+
329
+ // extractTranscriptCommands - custom options
330
+ test('extractTranscriptCommands uses custom wake word', () => {
331
+ const segments = createSegments([0, 2, 'Computer bad take thanks'])
332
+ const commands = extractTranscriptCommands(segments, {
333
+ wakeWord: 'computer',
334
+ closeWord: 'thanks',
335
+ })
336
+ expect(commands).toHaveLength(1)
337
+ expect(commands[0]?.type).toBe('bad-take')
338
+ })
339
+
340
+ test('extractTranscriptCommands uses custom close word', () => {
341
+ const segments = createSegments([0, 2, 'Jarvis bad take done'])
342
+ const commands = extractTranscriptCommands(segments, {
343
+ wakeWord: 'jarvis',
344
+ closeWord: 'done',
345
+ })
346
+ expect(commands).toHaveLength(1)
347
+ expect(commands[0]?.type).toBe('bad-take')
348
+ })
@@ -0,0 +1,257 @@
1
+ import type { TranscriptSegment } from '../../whispercpp-transcribe'
2
+ import { CONFIG } from '../config'
3
+ import type { TimeRange } from '../types'
4
+ import { normalizeWords } from '../utils/transcript'
5
+ import type {
6
+ TranscriptCommand,
7
+ TranscriptWord,
8
+ CommandExtractionOptions,
9
+ } from './types'
10
+
11
+ /**
12
+ * Scale transcript segments to match actual duration if needed.
13
+ */
14
+ export function scaleTranscriptSegments(
15
+ segments: TranscriptSegment[],
16
+ duration: number,
17
+ ): TranscriptSegment[] {
18
+ if (segments.length === 0) {
19
+ return segments
20
+ }
21
+ const candidates = segments.filter((segment) =>
22
+ /[a-z0-9]/i.test(segment.text),
23
+ )
24
+ const maxEnd = Math.max(
25
+ ...(candidates.length > 0 ? candidates : segments).map(
26
+ (segment) => segment.end,
27
+ ),
28
+ )
29
+ if (!Number.isFinite(maxEnd) || maxEnd <= 0) {
30
+ return segments
31
+ }
32
+ if (!Number.isFinite(duration) || duration <= 0) {
33
+ return segments
34
+ }
35
+ const scale = duration / maxEnd
36
+ if (!Number.isFinite(scale) || Math.abs(scale - 1) < 0.02) {
37
+ return segments
38
+ }
39
+ return segments.map((segment) => ({
40
+ ...segment,
41
+ start: segment.start * scale,
42
+ end: segment.end * scale,
43
+ }))
44
+ }
45
+
46
+ /**
47
+ * Extract jarvis commands from transcript segments.
48
+ */
49
+ export function extractTranscriptCommands(
50
+ segments: TranscriptSegment[],
51
+ options: CommandExtractionOptions,
52
+ ): TranscriptCommand[] {
53
+ const words = buildTranscriptWords(segments)
54
+ if (words.length === 0) {
55
+ return []
56
+ }
57
+ const commands: TranscriptCommand[] = []
58
+ const wakeWord = options.wakeWord.toLowerCase()
59
+ const closeWord = options.closeWord.toLowerCase()
60
+ let index = 0
61
+ while (index < words.length) {
62
+ const startWord = words[index]
63
+ if (!startWord || startWord.word !== wakeWord) {
64
+ index += 1
65
+ continue
66
+ }
67
+ const nextWord = words[index + 1]
68
+ // Check for nevermind cancellation pattern: jarvis ... nevermind ... thanks
69
+ if (nextWord) {
70
+ let nevermindIndex = index + 1
71
+ let foundNevermind = false
72
+ while (nevermindIndex < words.length) {
73
+ const word = words[nevermindIndex]
74
+ if (!word) {
75
+ break
76
+ }
77
+ // Check for "nevermind" as one word
78
+ if (word.word === 'nevermind') {
79
+ foundNevermind = true
80
+ break
81
+ }
82
+ // Check for "never mind" as two consecutive words
83
+ if (word.word === 'never' && nevermindIndex + 1 < words.length) {
84
+ const nextWordAfterNever = words[nevermindIndex + 1]
85
+ if (nextWordAfterNever && nextWordAfterNever.word === 'mind') {
86
+ foundNevermind = true
87
+ break
88
+ }
89
+ }
90
+ if (word.word === closeWord) {
91
+ break
92
+ }
93
+ nevermindIndex += 1
94
+ }
95
+ if (foundNevermind) {
96
+ // Look for the close word after nevermind
97
+ // If nevermind was two words, skip past both
98
+ const searchStartIndex =
99
+ words[nevermindIndex]?.word === 'never' &&
100
+ nevermindIndex + 1 < words.length &&
101
+ words[nevermindIndex + 1]?.word === 'mind'
102
+ ? nevermindIndex + 2
103
+ : nevermindIndex + 1
104
+ let endIndex = searchStartIndex
105
+ while (endIndex < words.length && words[endIndex]?.word !== closeWord) {
106
+ endIndex += 1
107
+ }
108
+ const endWord = words[endIndex]
109
+ if (endWord && endWord.word === closeWord) {
110
+ // Found jarvis ... nevermind ... thanks pattern - remove it
111
+ commands.push({
112
+ type: 'nevermind',
113
+ window: {
114
+ start: startWord.start,
115
+ end: endWord.end,
116
+ },
117
+ })
118
+ index = endIndex + 1
119
+ continue
120
+ }
121
+ }
122
+ }
123
+ // Check for regular commands with command starters
124
+ if (!nextWord || !isCommandStarter(nextWord.word)) {
125
+ index += 1
126
+ continue
127
+ }
128
+ let endIndex = index + 1
129
+ while (endIndex < words.length && words[endIndex]?.word !== closeWord) {
130
+ endIndex += 1
131
+ }
132
+ let endWord = words[endIndex]
133
+ const hasCloseWord = endIndex < words.length && endWord?.word === closeWord
134
+ if (!hasCloseWord) {
135
+ const fallbackEndWord = words[words.length - 1]
136
+ if (!fallbackEndWord) {
137
+ break
138
+ }
139
+ const tailDuration = fallbackEndWord.end - startWord.start
140
+ if (tailDuration > CONFIG.commandTailMaxSeconds) {
141
+ index += 1
142
+ continue
143
+ }
144
+ endWord = fallbackEndWord
145
+ endIndex = words.length
146
+ }
147
+ if (!endWord) {
148
+ index += 1
149
+ continue
150
+ }
151
+ const commandWords = words
152
+ .slice(index + 1, endIndex)
153
+ .map((item) => item.word)
154
+ .filter(Boolean)
155
+ if (commandWords.length > 0) {
156
+ const command = parseCommand(commandWords, {
157
+ start: startWord.start,
158
+ end: endWord.end,
159
+ })
160
+ if (command) {
161
+ commands.push(command)
162
+ }
163
+ }
164
+ index = hasCloseWord ? endIndex + 1 : words.length
165
+ }
166
+ return commands
167
+ }
168
+
169
+ /**
170
+ * Parse command words into a typed command.
171
+ */
172
+ function parseCommand(
173
+ words: string[],
174
+ window: TimeRange,
175
+ ): TranscriptCommand | null {
176
+ if (words.length >= 2 && words[0] === 'bad' && words[1] === 'take') {
177
+ return { type: 'bad-take', window }
178
+ }
179
+ if (words[0] === 'filename') {
180
+ const value = words.slice(1).join(' ').trim()
181
+ if (!value) {
182
+ return null
183
+ }
184
+ return { type: 'filename', value, window }
185
+ }
186
+ if (words.length >= 2 && words[0] === 'file' && words[1] === 'name') {
187
+ const value = words.slice(2).join(' ').trim()
188
+ if (!value) {
189
+ return null
190
+ }
191
+ return { type: 'filename', value, window }
192
+ }
193
+ if (words[0] === 'edit') {
194
+ return { type: 'edit', window }
195
+ }
196
+ if (words[0] === 'note') {
197
+ const value = words.slice(1).join(' ').trim()
198
+ if (!value) {
199
+ return null
200
+ }
201
+ return { type: 'note', value, window }
202
+ }
203
+ if (words[0] === 'split') {
204
+ return { type: 'split', window }
205
+ }
206
+ if (words.length >= 2 && words[0] === 'new' && words[1] === 'chapter') {
207
+ return { type: 'split', window }
208
+ }
209
+ if (words.length >= 2 && words[0] === 'combine' && words[1] === 'previous') {
210
+ return { type: 'combine-previous', window }
211
+ }
212
+ return null
213
+ }
214
+
215
+ /**
216
+ * Check if a word can start a command.
217
+ */
218
+ function isCommandStarter(word: string): boolean {
219
+ return (
220
+ word === 'bad' ||
221
+ word === 'filename' ||
222
+ word === 'file' ||
223
+ word === 'edit' ||
224
+ word === 'note' ||
225
+ word === 'split' ||
226
+ word === 'new' ||
227
+ word === 'combine'
228
+ )
229
+ }
230
+
231
+ /**
232
+ * Build word-level timing from transcript segments.
233
+ */
234
+ export function buildTranscriptWords(
235
+ segments: TranscriptSegment[],
236
+ ): TranscriptWord[] {
237
+ const words: TranscriptWord[] = []
238
+ const ordered = [...segments].sort((a, b) => a.start - b.start)
239
+ for (const segment of ordered) {
240
+ const segmentWords = normalizeWords(segment.text)
241
+ if (segmentWords.length === 0) {
242
+ continue
243
+ }
244
+ const segmentDuration = Math.max(segment.end - segment.start, 0)
245
+ const wordDuration =
246
+ segmentWords.length > 0 ? segmentDuration / segmentWords.length : 0
247
+ for (const [index, word] of segmentWords.entries()) {
248
+ const start = segment.start + wordDuration * index
249
+ const end =
250
+ index === segmentWords.length - 1
251
+ ? segment.end
252
+ : segment.start + wordDuration * (index + 1)
253
+ words.push({ word, start, end })
254
+ }
255
+ }
256
+ return words
257
+ }
@@ -0,0 +1,46 @@
1
+ import type { TimeRange } from '../types'
2
+
3
+ export type CommandType =
4
+ | 'bad-take'
5
+ | 'filename'
6
+ | 'nevermind'
7
+ | 'edit'
8
+ | 'note'
9
+ | 'split'
10
+ | 'combine-previous'
11
+
12
+ export interface TranscriptCommand {
13
+ type: CommandType
14
+ value?: string
15
+ window: TimeRange
16
+ }
17
+
18
+ export interface TranscriptWord {
19
+ word: string
20
+ start: number
21
+ end: number
22
+ }
23
+
24
+ export interface CommandExtractionOptions {
25
+ wakeWord: string
26
+ closeWord: string
27
+ }
28
+
29
+ export interface CommandWindowOptions {
30
+ offset: number
31
+ min: number
32
+ max: number
33
+ paddingSeconds: number
34
+ }
35
+
36
+ export interface CommandAnalysisResult {
37
+ commands: TranscriptCommand[]
38
+ filenameOverride: string | null
39
+ hasBadTake: boolean
40
+ hasEdit: boolean
41
+ hasCombinePrevious: boolean
42
+ notes: Array<{ value: string; window: TimeRange }>
43
+ splits: Array<{ window: TimeRange }>
44
+ shouldSkip: boolean
45
+ skipReason?: string
46
+ }