eprec 0.0.1 → 1.0.1
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/LICENSE +21 -0
- package/README.md +52 -29
- package/cli.ts +150 -0
- package/package.json +39 -7
- package/process-course/chapter-processor.ts +1037 -0
- package/process-course/cli.ts +236 -0
- package/process-course/config.ts +50 -0
- package/process-course/edits/cli.ts +167 -0
- package/process-course/edits/combined-video-editor.ts +316 -0
- package/process-course/edits/edit-workspace.ts +90 -0
- package/process-course/edits/index.ts +20 -0
- package/process-course/edits/regenerate-transcript.ts +84 -0
- package/process-course/edits/remove-ranges.test.ts +36 -0
- package/process-course/edits/remove-ranges.ts +287 -0
- package/process-course/edits/timestamp-refinement.test.ts +25 -0
- package/process-course/edits/timestamp-refinement.ts +172 -0
- package/process-course/edits/transcript-diff.test.ts +105 -0
- package/process-course/edits/transcript-diff.ts +214 -0
- package/process-course/edits/transcript-output.test.ts +50 -0
- package/process-course/edits/transcript-output.ts +36 -0
- package/process-course/edits/types.ts +26 -0
- package/process-course/edits/video-editor.ts +246 -0
- package/process-course/errors.test.ts +63 -0
- package/process-course/errors.ts +82 -0
- package/process-course/ffmpeg.ts +449 -0
- package/process-course/jarvis-commands/handlers.ts +71 -0
- package/process-course/jarvis-commands/index.ts +14 -0
- package/process-course/jarvis-commands/parser.test.ts +348 -0
- package/process-course/jarvis-commands/parser.ts +257 -0
- package/process-course/jarvis-commands/types.ts +46 -0
- package/process-course/jarvis-commands/windows.ts +254 -0
- package/process-course/logging.ts +24 -0
- package/process-course/paths.test.ts +59 -0
- package/process-course/paths.ts +53 -0
- package/process-course/summary.test.ts +209 -0
- package/process-course/summary.ts +210 -0
- package/process-course/types.ts +85 -0
- package/process-course/utils/audio-analysis.test.ts +348 -0
- package/process-course/utils/audio-analysis.ts +463 -0
- package/process-course/utils/chapter-selection.test.ts +307 -0
- package/process-course/utils/chapter-selection.ts +136 -0
- package/process-course/utils/file-utils.test.ts +83 -0
- package/process-course/utils/file-utils.ts +57 -0
- package/process-course/utils/filename.test.ts +27 -0
- package/process-course/utils/filename.ts +12 -0
- package/process-course/utils/time-ranges.test.ts +221 -0
- package/process-course/utils/time-ranges.ts +86 -0
- package/process-course/utils/transcript.test.ts +257 -0
- package/process-course/utils/transcript.ts +86 -0
- package/process-course/utils/video-editing.ts +44 -0
- package/process-course-video.ts +389 -0
- package/speech-detection.ts +355 -0
- package/utils.ts +138 -0
- package/whispercpp-transcribe.ts +345 -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
|
+
}
|