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,221 @@
1
+ import { test, expect } from 'bun:test'
2
+ import {
3
+ mergeTimeRanges,
4
+ buildKeepRanges,
5
+ sumRangeDuration,
6
+ adjustTimeForRemovedRanges,
7
+ } from './time-ranges'
8
+ import type { TimeRange } from '../types'
9
+
10
+ // Factory functions for test data
11
+ function createRange(start: number, end: number): TimeRange {
12
+ return { start, end }
13
+ }
14
+
15
+ function createRanges(...pairs: [number, number][]): TimeRange[] {
16
+ return pairs.map(([start, end]) => createRange(start, end))
17
+ }
18
+
19
+ // mergeTimeRanges tests
20
+ test('mergeTimeRanges returns empty array for empty input', () => {
21
+ expect(mergeTimeRanges([])).toEqual([])
22
+ })
23
+
24
+ test('mergeTimeRanges returns single range unchanged', () => {
25
+ expect(mergeTimeRanges([createRange(1, 5)])).toEqual([createRange(1, 5)])
26
+ })
27
+
28
+ test('mergeTimeRanges merges overlapping ranges', () => {
29
+ const ranges = createRanges([1, 5], [3, 8])
30
+ expect(mergeTimeRanges(ranges)).toEqual([createRange(1, 8)])
31
+ })
32
+
33
+ test('mergeTimeRanges merges adjacent ranges within 0.01 tolerance', () => {
34
+ const ranges = createRanges([1, 5], [5.005, 8])
35
+ expect(mergeTimeRanges(ranges)).toEqual([createRange(1, 8)])
36
+ })
37
+
38
+ test('mergeTimeRanges keeps separate non-adjacent ranges', () => {
39
+ const ranges = createRanges([1, 5], [6, 10])
40
+ expect(mergeTimeRanges(ranges)).toEqual(createRanges([1, 5], [6, 10]))
41
+ })
42
+
43
+ test('mergeTimeRanges handles unsorted input', () => {
44
+ const ranges = createRanges([10, 15], [1, 5], [3, 8])
45
+ expect(mergeTimeRanges(ranges)).toEqual(createRanges([1, 8], [10, 15]))
46
+ })
47
+
48
+ test('mergeTimeRanges merges multiple overlapping ranges into one', () => {
49
+ const ranges = createRanges([1, 3], [2, 5], [4, 7], [6, 10])
50
+ expect(mergeTimeRanges(ranges)).toEqual([createRange(1, 10)])
51
+ })
52
+
53
+ test('mergeTimeRanges handles contained range', () => {
54
+ const ranges = createRanges([1, 10], [3, 5])
55
+ expect(mergeTimeRanges(ranges)).toEqual([createRange(1, 10)])
56
+ })
57
+
58
+ test('mergeTimeRanges handles same start time', () => {
59
+ const ranges = createRanges([1, 5], [1, 8])
60
+ expect(mergeTimeRanges(ranges)).toEqual([createRange(1, 8)])
61
+ })
62
+
63
+ test('mergeTimeRanges handles same end time', () => {
64
+ const ranges = createRanges([1, 10], [5, 10])
65
+ expect(mergeTimeRanges(ranges)).toEqual([createRange(1, 10)])
66
+ })
67
+
68
+ test('mergeTimeRanges handles zero-width range', () => {
69
+ expect(mergeTimeRanges([createRange(5, 5)])).toEqual([createRange(5, 5)])
70
+ })
71
+
72
+ // buildKeepRanges tests
73
+ test('buildKeepRanges returns full range when no exclusions', () => {
74
+ expect(buildKeepRanges(0, 100, [])).toEqual([createRange(0, 100)])
75
+ })
76
+
77
+ test('buildKeepRanges excludes single middle range', () => {
78
+ const exclude = [createRange(30, 50)]
79
+ expect(buildKeepRanges(0, 100, exclude)).toEqual(
80
+ createRanges([0, 30], [50, 100]),
81
+ )
82
+ })
83
+
84
+ test('buildKeepRanges excludes range at start', () => {
85
+ const exclude = [createRange(0, 20)]
86
+ expect(buildKeepRanges(0, 100, exclude)).toEqual([createRange(20, 100)])
87
+ })
88
+
89
+ test('buildKeepRanges excludes range at end', () => {
90
+ const exclude = [createRange(80, 100)]
91
+ expect(buildKeepRanges(0, 100, exclude)).toEqual([createRange(0, 80)])
92
+ })
93
+
94
+ test('buildKeepRanges excludes multiple non-overlapping ranges', () => {
95
+ const exclude = createRanges([10, 20], [50, 60])
96
+ expect(buildKeepRanges(0, 100, exclude)).toEqual(
97
+ createRanges([0, 10], [20, 50], [60, 100]),
98
+ )
99
+ })
100
+
101
+ test('buildKeepRanges merges overlapping exclusions', () => {
102
+ const exclude = createRanges([10, 30], [20, 40])
103
+ expect(buildKeepRanges(0, 100, exclude)).toEqual(
104
+ createRanges([0, 10], [40, 100]),
105
+ )
106
+ })
107
+
108
+ test('buildKeepRanges handles exclusion beyond end', () => {
109
+ const exclude = [createRange(80, 120)]
110
+ expect(buildKeepRanges(0, 100, exclude)).toEqual([createRange(0, 80)])
111
+ })
112
+
113
+ test('buildKeepRanges handles exclusion before start', () => {
114
+ const exclude = [createRange(-10, 20)]
115
+ expect(buildKeepRanges(0, 100, exclude)).toEqual([createRange(20, 100)])
116
+ })
117
+
118
+ test('buildKeepRanges returns empty when entire range excluded', () => {
119
+ const exclude = [createRange(0, 100)]
120
+ expect(buildKeepRanges(0, 100, exclude)).toEqual([])
121
+ })
122
+
123
+ test('buildKeepRanges handles unsorted exclusions', () => {
124
+ const exclude = createRanges([50, 60], [10, 20])
125
+ expect(buildKeepRanges(0, 100, exclude)).toEqual(
126
+ createRanges([0, 10], [20, 50], [60, 100]),
127
+ )
128
+ })
129
+
130
+ test('buildKeepRanges filters zero-width keep ranges', () => {
131
+ const exclude = createRanges([0, 50], [50, 100])
132
+ expect(buildKeepRanges(0, 100, exclude)).toEqual([])
133
+ })
134
+
135
+ test('buildKeepRanges ignores exclusion before range', () => {
136
+ const exclude = [createRange(-20, -10)]
137
+ expect(buildKeepRanges(0, 100, exclude)).toEqual([createRange(0, 100)])
138
+ })
139
+
140
+ // sumRangeDuration tests
141
+ test('sumRangeDuration returns 0 for empty array', () => {
142
+ expect(sumRangeDuration([])).toBe(0)
143
+ })
144
+
145
+ test('sumRangeDuration returns duration of single range', () => {
146
+ expect(sumRangeDuration([createRange(10, 30)])).toBe(20)
147
+ })
148
+
149
+ test('sumRangeDuration sums multiple ranges', () => {
150
+ const ranges = createRanges([0, 10], [20, 35], [50, 60])
151
+ expect(sumRangeDuration(ranges)).toBe(35)
152
+ })
153
+
154
+ test('sumRangeDuration handles zero-width ranges', () => {
155
+ const ranges = createRanges([5, 5], [10, 20])
156
+ expect(sumRangeDuration(ranges)).toBe(10)
157
+ })
158
+
159
+ test('sumRangeDuration handles invalid negative duration', () => {
160
+ expect(sumRangeDuration([createRange(20, 10)])).toBe(-10)
161
+ })
162
+
163
+ test('sumRangeDuration handles floating point values', () => {
164
+ const ranges = createRanges([0, 1.5], [2.5, 4.75])
165
+ expect(sumRangeDuration(ranges)).toBeCloseTo(3.75)
166
+ })
167
+
168
+ // adjustTimeForRemovedRanges tests
169
+ test('adjustTimeForRemovedRanges returns same time when no ranges removed', () => {
170
+ expect(adjustTimeForRemovedRanges(50, [])).toBe(50)
171
+ })
172
+
173
+ test('adjustTimeForRemovedRanges adjusts for removed range before time', () => {
174
+ const removed = [createRange(10, 20)]
175
+ expect(adjustTimeForRemovedRanges(50, removed)).toBe(40)
176
+ })
177
+
178
+ test('adjustTimeForRemovedRanges does not adjust for removed range after time', () => {
179
+ const removed = [createRange(60, 80)]
180
+ expect(adjustTimeForRemovedRanges(50, removed)).toBe(50)
181
+ })
182
+
183
+ test('adjustTimeForRemovedRanges adjusts for multiple removed ranges', () => {
184
+ const removed = createRanges([5, 10], [20, 30])
185
+ expect(adjustTimeForRemovedRanges(50, removed)).toBe(35)
186
+ })
187
+
188
+ test('adjustTimeForRemovedRanges handles time within removed range', () => {
189
+ const removed = [createRange(40, 60)]
190
+ expect(adjustTimeForRemovedRanges(50, removed)).toBe(40)
191
+ })
192
+
193
+ test('adjustTimeForRemovedRanges handles time at start of removed range', () => {
194
+ const removed = [createRange(50, 60)]
195
+ expect(adjustTimeForRemovedRanges(50, removed)).toBe(50)
196
+ })
197
+
198
+ test('adjustTimeForRemovedRanges handles time at end of removed range', () => {
199
+ const removed = [createRange(40, 50)]
200
+ expect(adjustTimeForRemovedRanges(50, removed)).toBe(40)
201
+ })
202
+
203
+ test('adjustTimeForRemovedRanges handles overlapping removed ranges', () => {
204
+ const removed = createRanges([10, 30], [20, 40])
205
+ expect(adjustTimeForRemovedRanges(50, removed)).toBe(20)
206
+ })
207
+
208
+ test('adjustTimeForRemovedRanges handles unsorted removed ranges', () => {
209
+ const removed = createRanges([30, 40], [10, 20])
210
+ expect(adjustTimeForRemovedRanges(50, removed)).toBe(30)
211
+ })
212
+
213
+ test('adjustTimeForRemovedRanges handles time at zero', () => {
214
+ const removed = [createRange(10, 20)]
215
+ expect(adjustTimeForRemovedRanges(0, removed)).toBe(0)
216
+ })
217
+
218
+ test('adjustTimeForRemovedRanges handles removed range starting at zero', () => {
219
+ const removed = [createRange(0, 10)]
220
+ expect(adjustTimeForRemovedRanges(50, removed)).toBe(40)
221
+ })
@@ -0,0 +1,86 @@
1
+ import type { TimeRange } from '../types'
2
+
3
+ /**
4
+ * Merge overlapping or adjacent time ranges into a minimal set of non-overlapping ranges.
5
+ */
6
+ export function mergeTimeRanges(ranges: TimeRange[]): TimeRange[] {
7
+ if (ranges.length === 0) {
8
+ return []
9
+ }
10
+ const sorted = [...ranges].sort((a, b) => a.start - b.start)
11
+ const merged: TimeRange[] = []
12
+ let current = sorted[0]
13
+ if (!current) {
14
+ return []
15
+ }
16
+ for (const range of sorted.slice(1)) {
17
+ if (range.start <= current.end + 0.01) {
18
+ current = { start: current.start, end: Math.max(current.end, range.end) }
19
+ } else {
20
+ merged.push(current)
21
+ current = range
22
+ }
23
+ }
24
+ merged.push(current)
25
+ return merged
26
+ }
27
+
28
+ /**
29
+ * Build keep ranges by subtracting exclusion windows from a full duration.
30
+ */
31
+ export function buildKeepRanges(
32
+ start: number,
33
+ end: number,
34
+ exclude: TimeRange[],
35
+ ): TimeRange[] {
36
+ if (exclude.length === 0) {
37
+ return [{ start, end }]
38
+ }
39
+ const ranges: TimeRange[] = []
40
+ let cursor = start
41
+ for (const window of mergeTimeRanges(exclude)) {
42
+ if (window.end <= cursor) {
43
+ continue
44
+ }
45
+ if (window.start > cursor) {
46
+ ranges.push({ start: cursor, end: window.start })
47
+ }
48
+ cursor = Math.max(cursor, window.end)
49
+ }
50
+ if (cursor < end) {
51
+ ranges.push({ start: cursor, end })
52
+ }
53
+ return ranges.filter((range) => range.end > range.start)
54
+ }
55
+
56
+ /**
57
+ * Sum the total duration of a set of time ranges.
58
+ */
59
+ export function sumRangeDuration(ranges: TimeRange[]): number {
60
+ return ranges.reduce((total, range) => total + (range.end - range.start), 0)
61
+ }
62
+
63
+ /**
64
+ * Adjust a timestamp to account for removed time ranges.
65
+ */
66
+ export function adjustTimeForRemovedRanges(
67
+ time: number,
68
+ removed: TimeRange[],
69
+ ): number {
70
+ if (removed.length === 0) {
71
+ return time
72
+ }
73
+ let adjusted = time
74
+ for (const range of mergeTimeRanges(removed)) {
75
+ if (range.end <= time) {
76
+ adjusted -= range.end - range.start
77
+ continue
78
+ }
79
+ if (range.start < time && range.end > time) {
80
+ adjusted -= time - range.start
81
+ break
82
+ }
83
+ break
84
+ }
85
+ return adjusted
86
+ }
@@ -0,0 +1,257 @@
1
+ import { test, expect } from 'bun:test'
2
+ import {
3
+ countTranscriptWords,
4
+ findWordTimings,
5
+ normalizeSkipPhrases,
6
+ transcriptIncludesWord,
7
+ normalizeWords,
8
+ } from './transcript'
9
+ import { TRANSCRIPTION_PHRASES } from '../config'
10
+ import type { TranscriptSegment } from '../../whispercpp-transcribe'
11
+
12
+ function createPhrases(...phrases: string[]): string[] {
13
+ return phrases
14
+ }
15
+
16
+ function createSegment(
17
+ start: number,
18
+ end: number,
19
+ text: string,
20
+ ): TranscriptSegment {
21
+ return { start, end, text }
22
+ }
23
+
24
+ function createSegments(...segments: TranscriptSegment[]): TranscriptSegment[] {
25
+ return segments
26
+ }
27
+
28
+ // normalizeSkipPhrases tests
29
+ test('normalizeSkipPhrases trims, lowercases, and filters empty', () => {
30
+ expect(normalizeSkipPhrases(createPhrases(' Hello ', ' ', 'World'))).toEqual(
31
+ ['hello', 'world'],
32
+ )
33
+ })
34
+
35
+ test('normalizeSkipPhrases accepts a single phrase value', () => {
36
+ expect(normalizeSkipPhrases(' Jarvis Bad Take ')).toEqual(['jarvis bad take'])
37
+ })
38
+
39
+ test('normalizeSkipPhrases de-duplicates phrases while preserving order', () => {
40
+ expect(
41
+ normalizeSkipPhrases(
42
+ createPhrases(
43
+ 'Jarvis Bad Take',
44
+ 'jarvis bad take',
45
+ 'bad take jarvis',
46
+ 'BAD TAKE JARVIS',
47
+ ),
48
+ ),
49
+ ).toEqual(['jarvis bad take', 'bad take jarvis'])
50
+ })
51
+
52
+ test('normalizeSkipPhrases falls back to defaults when empty', () => {
53
+ expect(normalizeSkipPhrases(' ')).toEqual(TRANSCRIPTION_PHRASES)
54
+ expect(normalizeSkipPhrases([])).toEqual(TRANSCRIPTION_PHRASES)
55
+ })
56
+
57
+ // countTranscriptWords tests
58
+ test('countTranscriptWords returns 0 for empty string', () => {
59
+ expect(countTranscriptWords('')).toBe(0)
60
+ })
61
+
62
+ test('countTranscriptWords returns 0 for whitespace-only string', () => {
63
+ expect(countTranscriptWords(' ')).toBe(0)
64
+ expect(countTranscriptWords('\t\n')).toBe(0)
65
+ })
66
+
67
+ test('countTranscriptWords counts single word', () => {
68
+ expect(countTranscriptWords('hello')).toBe(1)
69
+ })
70
+
71
+ test('countTranscriptWords counts multiple words', () => {
72
+ expect(countTranscriptWords('hello world')).toBe(2)
73
+ expect(countTranscriptWords('one two three four five')).toBe(5)
74
+ })
75
+
76
+ test('countTranscriptWords handles multiple spaces between words', () => {
77
+ expect(countTranscriptWords('hello world')).toBe(2)
78
+ })
79
+
80
+ test('countTranscriptWords handles leading and trailing whitespace', () => {
81
+ expect(countTranscriptWords(' hello world ')).toBe(2)
82
+ })
83
+
84
+ test('countTranscriptWords handles mixed whitespace', () => {
85
+ expect(countTranscriptWords('hello\tworld\nfoo')).toBe(3)
86
+ })
87
+
88
+ test('countTranscriptWords counts punctuated words', () => {
89
+ expect(countTranscriptWords('Hello, world! How are you?')).toBe(5)
90
+ })
91
+
92
+ test('countTranscriptWords counts typical transcript', () => {
93
+ const text =
94
+ "So today we're going to learn about React hooks and how they work."
95
+ expect(countTranscriptWords(text)).toBe(13)
96
+ })
97
+
98
+ // transcriptIncludesWord tests
99
+ test('transcriptIncludesWord returns false for empty transcript', () => {
100
+ expect(transcriptIncludesWord('', 'hello')).toBe(false)
101
+ })
102
+
103
+ test('transcriptIncludesWord returns false for whitespace-only transcript', () => {
104
+ expect(transcriptIncludesWord(' ', 'hello')).toBe(false)
105
+ })
106
+
107
+ test('transcriptIncludesWord finds exact word', () => {
108
+ expect(transcriptIncludesWord('hello world', 'hello')).toBe(true)
109
+ expect(transcriptIncludesWord('hello world', 'world')).toBe(true)
110
+ })
111
+
112
+ test('transcriptIncludesWord returns false for missing word', () => {
113
+ expect(transcriptIncludesWord('hello world', 'foo')).toBe(false)
114
+ })
115
+
116
+ test('transcriptIncludesWord is case insensitive', () => {
117
+ expect(transcriptIncludesWord('Hello World', 'hello')).toBe(true)
118
+ expect(transcriptIncludesWord('hello world', 'HELLO')).toBe(true)
119
+ })
120
+
121
+ test('transcriptIncludesWord does not match partial words', () => {
122
+ expect(transcriptIncludesWord('hello world', 'hell')).toBe(false)
123
+ expect(transcriptIncludesWord('hello world', 'ello')).toBe(false)
124
+ })
125
+
126
+ test('transcriptIncludesWord handles punctuation', () => {
127
+ expect(transcriptIncludesWord('Hello, world!', 'hello')).toBe(true)
128
+ expect(transcriptIncludesWord('Hello, world!', 'world')).toBe(true)
129
+ })
130
+
131
+ test('transcriptIncludesWord finds jarvis after jervis normalization', () => {
132
+ expect(transcriptIncludesWord('Jervis said hello', 'jarvis')).toBe(true)
133
+ })
134
+
135
+ test('transcriptIncludesWord finds bad and take after badtake normalization', () => {
136
+ expect(transcriptIncludesWord('That was a badtake', 'bad')).toBe(true)
137
+ expect(transcriptIncludesWord('That was a badtake', 'take')).toBe(true)
138
+ })
139
+
140
+ // normalizeWords - basic normalization
141
+ test('normalizeWords returns empty array for empty string', () => {
142
+ expect(normalizeWords('')).toEqual([])
143
+ })
144
+
145
+ test('normalizeWords returns empty array for whitespace-only string', () => {
146
+ expect(normalizeWords(' ')).toEqual([])
147
+ expect(normalizeWords('\t\n')).toEqual([])
148
+ })
149
+
150
+ test('normalizeWords lowercases all words', () => {
151
+ expect(normalizeWords('Hello World')).toEqual(['hello', 'world'])
152
+ expect(normalizeWords('HELLO WORLD')).toEqual(['hello', 'world'])
153
+ })
154
+
155
+ test('normalizeWords removes punctuation', () => {
156
+ expect(normalizeWords('Hello, world!')).toEqual(['hello', 'world'])
157
+ expect(normalizeWords("What's up?")).toEqual(['what', 's', 'up'])
158
+ })
159
+
160
+ test('normalizeWords handles multiple spaces', () => {
161
+ expect(normalizeWords('hello world')).toEqual(['hello', 'world'])
162
+ })
163
+
164
+ test('normalizeWords preserves numbers', () => {
165
+ expect(normalizeWords('hello 123 world')).toEqual(['hello', '123', 'world'])
166
+ })
167
+
168
+ test('normalizeWords handles mixed alphanumeric', () => {
169
+ expect(normalizeWords('react18 hooks')).toEqual(['react18', 'hooks'])
170
+ })
171
+
172
+ // normalizeWords - corrections
173
+ test('normalizeWords corrects jervis to jarvis', () => {
174
+ expect(normalizeWords('jervis')).toEqual(['jarvis'])
175
+ expect(normalizeWords('Jervis said hello')).toEqual([
176
+ 'jarvis',
177
+ 'said',
178
+ 'hello',
179
+ ])
180
+ })
181
+
182
+ test('normalizeWords splits badtake into bad take', () => {
183
+ expect(normalizeWords('badtake')).toEqual(['bad', 'take'])
184
+ expect(normalizeWords('That was a badtake')).toEqual([
185
+ 'that',
186
+ 'was',
187
+ 'a',
188
+ 'bad',
189
+ 'take',
190
+ ])
191
+ })
192
+
193
+ test('normalizeWords splits batteik into bad take', () => {
194
+ expect(normalizeWords('batteik')).toEqual(['bad', 'take'])
195
+ })
196
+
197
+ test('normalizeWords splits batteke into bad take', () => {
198
+ expect(normalizeWords('batteke')).toEqual(['bad', 'take'])
199
+ })
200
+
201
+ // normalizeWords - blank audio handling
202
+ test('normalizeWords returns empty for blank audio', () => {
203
+ expect(normalizeWords('blank audio')).toEqual([])
204
+ })
205
+
206
+ test('normalizeWords returns empty for Blank Audio (case insensitive)', () => {
207
+ expect(normalizeWords('Blank Audio')).toEqual([])
208
+ })
209
+
210
+ test('normalizeWords returns empty for blankaudio', () => {
211
+ expect(normalizeWords('blankaudio')).toEqual([])
212
+ })
213
+
214
+ test('normalizeWords keeps blank and audio in other contexts', () => {
215
+ expect(normalizeWords('blank space')).toEqual(['blank', 'space'])
216
+ expect(normalizeWords('audio file')).toEqual(['audio', 'file'])
217
+ })
218
+
219
+ // normalizeWords - edge cases
220
+ test('normalizeWords returns empty for special characters only', () => {
221
+ expect(normalizeWords('!@#$%')).toEqual([])
222
+ })
223
+
224
+ test('normalizeWords handles single character', () => {
225
+ expect(normalizeWords('a')).toEqual(['a'])
226
+ })
227
+
228
+ test('normalizeWords handles single number', () => {
229
+ expect(normalizeWords('5')).toEqual(['5'])
230
+ })
231
+
232
+ test('normalizeWords handles complex sentence with commands', () => {
233
+ expect(
234
+ normalizeWords("Jarvis, bad take! Let's try again... thanks!"),
235
+ ).toEqual(['jarvis', 'bad', 'take', 'let', 's', 'try', 'again', 'thanks'])
236
+ })
237
+
238
+ test('normalizeWords handles multiple corrections in one string', () => {
239
+ expect(normalizeWords('jervis badtake')).toEqual(['jarvis', 'bad', 'take'])
240
+ })
241
+
242
+ // findWordTimings tests
243
+ test('findWordTimings returns empty for missing segments', () => {
244
+ expect(findWordTimings(createSegments(), 'jarvis')).toEqual([])
245
+ })
246
+
247
+ test('findWordTimings returns jarvis timings with normalization', () => {
248
+ const segments = createSegments(
249
+ createSegment(0, 4, 'Hello Jarvis'),
250
+ createSegment(4, 6, 'jervis again'),
251
+ )
252
+
253
+ expect(findWordTimings(segments, 'jarvis')).toEqual([
254
+ { start: 2, end: 4 },
255
+ { start: 4, end: 5 },
256
+ ])
257
+ })
@@ -0,0 +1,86 @@
1
+ import type { TranscriptSegment } from '../../whispercpp-transcribe'
2
+ import type { TimeRange } from '../types'
3
+ import { TRANSCRIPTION_PHRASES } from '../config'
4
+ import { buildTranscriptWords } from '../jarvis-commands/parser'
5
+
6
+ /**
7
+ * Normalize skip phrases from CLI input.
8
+ */
9
+ export function normalizeSkipPhrases(rawPhrases: unknown): string[] {
10
+ const rawList = Array.isArray(rawPhrases) ? rawPhrases : [rawPhrases]
11
+ const phrases = rawList
12
+ .filter((value): value is string => typeof value === 'string')
13
+ .map((value) => value.trim())
14
+ .filter(Boolean)
15
+ .map((value) => value.toLowerCase())
16
+ const uniquePhrases = [...new Set(phrases)]
17
+
18
+ return uniquePhrases.length > 0 ? uniquePhrases : TRANSCRIPTION_PHRASES
19
+ }
20
+
21
+ /**
22
+ * Count words in a transcript string.
23
+ */
24
+ export function countTranscriptWords(transcript: string): number {
25
+ if (!transcript.trim()) {
26
+ return 0
27
+ }
28
+ return transcript.trim().split(/\s+/).length
29
+ }
30
+
31
+ /**
32
+ * Check if a transcript includes a specific word.
33
+ */
34
+ export function transcriptIncludesWord(
35
+ transcript: string,
36
+ word: string,
37
+ ): boolean {
38
+ if (!transcript.trim()) {
39
+ return false
40
+ }
41
+ const normalized = normalizeWords(transcript)
42
+ return normalized.includes(word.toLowerCase())
43
+ }
44
+
45
+ /**
46
+ * Normalize text into an array of lowercase words, with common corrections.
47
+ */
48
+ export function normalizeWords(text: string): string[] {
49
+ const normalized = text
50
+ .toLowerCase()
51
+ .replace(/[^a-z0-9]+/g, ' ')
52
+ .trim()
53
+ if (!normalized) {
54
+ return []
55
+ }
56
+ if (normalized === 'blank audio' || normalized === 'blankaudio') {
57
+ return []
58
+ }
59
+ const words = normalized
60
+ .split(/\s+/)
61
+ .filter(Boolean)
62
+ .flatMap((word) => {
63
+ if (word === 'jervis') {
64
+ return ['jarvis']
65
+ }
66
+ if (word === 'badtake' || /^batte(ik|ke)$/.test(word)) {
67
+ return ['bad', 'take']
68
+ }
69
+ return [word]
70
+ })
71
+ return words
72
+ }
73
+
74
+ export function findWordTimings(
75
+ segments: TranscriptSegment[],
76
+ word: string,
77
+ ): TimeRange[] {
78
+ const target = word.trim().toLowerCase()
79
+ if (!target) {
80
+ return []
81
+ }
82
+ const words = buildTranscriptWords(segments)
83
+ return words
84
+ .filter((entry) => entry.word === target)
85
+ .map((entry) => ({ start: entry.start, end: entry.end }))
86
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Allocate padding at join points between videos, intelligently distributing
3
+ * silence when one side lacks sufficient padding.
4
+ */
5
+ export function allocateJoinPadding(options: {
6
+ paddingSeconds: number
7
+ previousAvailableSeconds: number
8
+ currentAvailableSeconds: number
9
+ }): { previousPaddingSeconds: number; currentPaddingSeconds: number } {
10
+ const desiredTotal = options.paddingSeconds * 2
11
+ const totalAvailable =
12
+ options.previousAvailableSeconds + options.currentAvailableSeconds
13
+ const targetTotal = Math.min(desiredTotal, totalAvailable)
14
+ let previousPadding = Math.min(
15
+ options.paddingSeconds,
16
+ options.previousAvailableSeconds,
17
+ )
18
+ let currentPadding = Math.min(
19
+ options.paddingSeconds,
20
+ options.currentAvailableSeconds,
21
+ )
22
+ let remaining = targetTotal - (previousPadding + currentPadding)
23
+
24
+ if (remaining > 0) {
25
+ const extra = Math.min(
26
+ options.previousAvailableSeconds - previousPadding,
27
+ remaining,
28
+ )
29
+ previousPadding += extra
30
+ remaining -= extra
31
+ }
32
+ if (remaining > 0) {
33
+ const extra = Math.min(
34
+ options.currentAvailableSeconds - currentPadding,
35
+ remaining,
36
+ )
37
+ currentPadding += extra
38
+ }
39
+
40
+ return {
41
+ previousPaddingSeconds: previousPadding,
42
+ currentPaddingSeconds: currentPadding,
43
+ }
44
+ }