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,210 @@
1
+ import path from 'node:path'
2
+ import { formatSeconds } from '../utils'
3
+ import {
4
+ buildSummaryLogPath,
5
+ buildJarvisWarningLogPath,
6
+ buildJarvisEditLogPath,
7
+ buildJarvisNoteLogPath,
8
+ } from './paths'
9
+ import { logInfo } from './logging'
10
+ import type {
11
+ Chapter,
12
+ JarvisWarning,
13
+ JarvisEdit,
14
+ JarvisNote,
15
+ EditWorkspaceInfo,
16
+ } from './types'
17
+
18
+ export type ProcessingSummary = {
19
+ totalSelected: number
20
+ processed: number
21
+ skippedShortInitial: number
22
+ skippedShortTrimmed: number
23
+ skippedTranscription: number
24
+ fallbackNotes: number
25
+ logsWritten: number
26
+ jarvisWarnings: number
27
+ editsPending: number
28
+ }
29
+
30
+ export async function writeJarvisLogs(options: {
31
+ outputDir: string
32
+ inputPath: string
33
+ jarvisWarnings: JarvisWarning[]
34
+ jarvisEdits: JarvisEdit[]
35
+ jarvisNotes: JarvisNote[]
36
+ editWorkspaces: EditWorkspaceInfo[]
37
+ dryRun: boolean
38
+ }) {
39
+ const {
40
+ outputDir,
41
+ inputPath,
42
+ jarvisWarnings,
43
+ jarvisEdits,
44
+ jarvisNotes,
45
+ editWorkspaces,
46
+ dryRun,
47
+ } = options
48
+
49
+ const jarvisWarningLogPath = buildJarvisWarningLogPath(outputDir)
50
+ if (dryRun) {
51
+ logInfo(`[dry-run] Would write jarvis warning log: ${jarvisWarningLogPath}`)
52
+ } else {
53
+ const warningLines = [
54
+ `Input: ${inputPath}`,
55
+ `Output dir: ${outputDir}`,
56
+ `Jarvis warnings: ${jarvisWarnings.length}`,
57
+ ]
58
+ if (jarvisWarnings.length > 0) {
59
+ warningLines.push('Detected in:')
60
+ jarvisWarnings.forEach((warning) => {
61
+ warningLines.push(
62
+ `- Chapter ${warning.chapter.index + 1}: ${warning.chapter.title} -> ${path.basename(
63
+ warning.outputPath,
64
+ )}`,
65
+ )
66
+ const timestampLabel =
67
+ warning.timestamps.length > 0
68
+ ? warning.timestamps
69
+ .map(
70
+ (timestamp) =>
71
+ `${formatSeconds(timestamp.start)}-${formatSeconds(timestamp.end)}`,
72
+ )
73
+ .join(', ')
74
+ : 'unavailable'
75
+ warningLines.push(` Jarvis timestamps: ${timestampLabel}`)
76
+ })
77
+ } else {
78
+ warningLines.push('Detected in: none')
79
+ }
80
+ await Bun.write(jarvisWarningLogPath, `${warningLines.join('\n')}\n`)
81
+ }
82
+
83
+ const jarvisEditLogPath = buildJarvisEditLogPath(outputDir)
84
+ if (dryRun) {
85
+ logInfo(`[dry-run] Would write jarvis edit log: ${jarvisEditLogPath}`)
86
+ } else {
87
+ const editLines = [
88
+ `Input: ${inputPath}`,
89
+ `Output dir: ${outputDir}`,
90
+ `Edit commands: ${jarvisEdits.length}`,
91
+ `Manual edits: ${editWorkspaces.length}`,
92
+ ]
93
+ if (jarvisEdits.length > 0) {
94
+ editLines.push('Files needing edits:')
95
+ jarvisEdits.forEach((edit) => {
96
+ editLines.push(
97
+ `- Chapter ${edit.chapter.index + 1}: ${edit.chapter.title} -> ${path.basename(
98
+ edit.outputPath,
99
+ )}`,
100
+ )
101
+ })
102
+ } else {
103
+ editLines.push('Files needing edits: none')
104
+ }
105
+ if (editWorkspaces.length > 0) {
106
+ editLines.push('Edit workspaces:')
107
+ editWorkspaces.forEach((workspace) => {
108
+ editLines.push(
109
+ `- Chapter ${workspace.chapter.index + 1}: ${workspace.chapter.title} -> ${path.basename(
110
+ workspace.outputPath,
111
+ )}`,
112
+ )
113
+ editLines.push(` Reason: ${workspace.reason}`)
114
+ editLines.push(` Directory: ${workspace.editsDirectory}`)
115
+ })
116
+ } else {
117
+ editLines.push('Edit workspaces: none')
118
+ }
119
+ await Bun.write(jarvisEditLogPath, `${editLines.join('\n')}\n`)
120
+ }
121
+
122
+ const jarvisNoteLogPath = buildJarvisNoteLogPath(outputDir)
123
+ if (dryRun) {
124
+ logInfo(`[dry-run] Would write jarvis note log: ${jarvisNoteLogPath}`)
125
+ } else {
126
+ const noteLines = [
127
+ `Input: ${inputPath}`,
128
+ `Output dir: ${outputDir}`,
129
+ `Note commands: ${jarvisNotes.length}`,
130
+ ]
131
+ if (jarvisNotes.length > 0) {
132
+ noteLines.push('Notes:')
133
+ jarvisNotes.forEach((note) => {
134
+ noteLines.push(
135
+ `- Chapter ${note.chapter.index + 1}: ${note.chapter.title} -> ${path.basename(
136
+ note.outputPath,
137
+ )}`,
138
+ )
139
+ noteLines.push(` Note: ${note.note}`)
140
+ })
141
+ } else {
142
+ noteLines.push('Notes: none')
143
+ }
144
+ await Bun.write(jarvisNoteLogPath, `${noteLines.join('\n')}\n`)
145
+ }
146
+ }
147
+
148
+ export async function writeSummaryLogs(options: {
149
+ tmpDir: string
150
+ outputDir: string
151
+ inputPath: string
152
+ summary: ProcessingSummary
153
+ summaryDetails: string[]
154
+ jarvisWarnings: JarvisWarning[]
155
+ jarvisEdits: JarvisEdit[]
156
+ editWorkspaces: EditWorkspaceInfo[]
157
+ dryRun: boolean
158
+ }) {
159
+ const {
160
+ tmpDir,
161
+ outputDir,
162
+ inputPath,
163
+ summary,
164
+ summaryDetails,
165
+ jarvisWarnings,
166
+ jarvisEdits,
167
+ editWorkspaces,
168
+ dryRun,
169
+ } = options
170
+
171
+ const summaryLines = [
172
+ `Input: ${inputPath}`,
173
+ `Output dir: ${outputDir}`,
174
+ `Chapters selected: ${summary.totalSelected}`,
175
+ `${dryRun ? 'Would process' : 'Processed'} chapters: ${summary.processed}`,
176
+ `Skipped (short initial): ${summary.skippedShortInitial}`,
177
+ `Skipped (trimmed short): ${summary.skippedShortTrimmed}`,
178
+ `Skipped (transcription): ${summary.skippedTranscription}`,
179
+ `Fallback notes: ${summary.fallbackNotes}`,
180
+ `Log files written: ${summary.logsWritten}`,
181
+ `Jarvis warnings: ${summary.jarvisWarnings}`,
182
+ `Manual edits: ${summary.editsPending}`,
183
+ ]
184
+ if (editWorkspaces.length > 0) {
185
+ summaryLines.push('Edit workspaces:')
186
+ editWorkspaces.forEach((workspace) => {
187
+ summaryLines.push(
188
+ `- Chapter ${workspace.chapter.index + 1}: ${workspace.chapter.title} -> ${path.basename(
189
+ workspace.outputPath,
190
+ )}`,
191
+ )
192
+ summaryLines.push(` Reason: ${workspace.reason}`)
193
+ summaryLines.push(` Directory: ${workspace.editsDirectory}`)
194
+ })
195
+ }
196
+ if (summaryDetails.length > 0) {
197
+ summaryLines.push('Details:', ...summaryDetails)
198
+ }
199
+
200
+ logInfo('Summary:')
201
+ summaryLines.forEach((line) => logInfo(line))
202
+
203
+ if (dryRun) {
204
+ const summaryLogPath = buildSummaryLogPath(tmpDir)
205
+ logInfo(`[dry-run] Would write summary log: ${summaryLogPath}`)
206
+ } else {
207
+ const summaryLogPath = buildSummaryLogPath(tmpDir)
208
+ await Bun.write(summaryLogPath, `${summaryLines.join('\n')}\n`)
209
+ }
210
+ }
@@ -0,0 +1,85 @@
1
+ export type Chapter = {
2
+ index: number
3
+ start: number
4
+ end: number
5
+ title: string
6
+ }
7
+
8
+ export type LoudnormAnalysis = {
9
+ input_i: string
10
+ input_tp: string
11
+ input_lra: string
12
+ input_thresh: string
13
+ target_offset: string
14
+ }
15
+
16
+ export type SpeechBounds = {
17
+ start: number
18
+ end: number
19
+ note?: string
20
+ }
21
+
22
+ export type TimeRange = {
23
+ start: number
24
+ end: number
25
+ }
26
+
27
+ export type SilenceBoundaryDirection = 'before' | 'after'
28
+
29
+ export type JarvisWarning = {
30
+ chapter: Chapter
31
+ outputPath: string
32
+ timestamps: TimeRange[]
33
+ }
34
+
35
+ export type JarvisEdit = {
36
+ chapter: Chapter
37
+ outputPath: string
38
+ }
39
+
40
+ export type EditReason = 'edit-command' | 'combine-previous' | 'jarvis-warning'
41
+
42
+ export type EditWorkspaceInfo = {
43
+ chapter: Chapter
44
+ outputPath: string
45
+ reason: EditReason
46
+ editsDirectory: string
47
+ transcriptTextPath: string
48
+ transcriptJsonPath: string
49
+ originalVideoPath: string
50
+ instructionsPath: string
51
+ }
52
+
53
+ export type ChapterRange = {
54
+ start: number
55
+ end: number | null
56
+ }
57
+
58
+ export type ChapterSelection = {
59
+ base: 0 | 1
60
+ ranges: ChapterRange[]
61
+ }
62
+
63
+ // Re-export from jarvis-commands for backward compatibility
64
+ export type { TranscriptCommand, TranscriptWord } from './jarvis-commands/types'
65
+
66
+ export type JarvisNote = {
67
+ chapter: Chapter
68
+ outputPath: string
69
+ note: string
70
+ timestamp: number
71
+ }
72
+
73
+ export type JarvisSplit = {
74
+ chapter: Chapter
75
+ outputPath: string
76
+ timestamp: number
77
+ }
78
+
79
+ export type ProcessedChapterInfo = {
80
+ chapter: Chapter
81
+ outputPath: string
82
+ // The path to the normalized/processed video before final trimming
83
+ processedPath: string
84
+ processedDuration: number
85
+ }
@@ -0,0 +1,348 @@
1
+ import { test, expect } from 'bun:test'
2
+ import {
3
+ buildSilenceGapsFromSpeech,
4
+ computeMinWindowRms,
5
+ computeRms,
6
+ findLowestAmplitudeBoundaryProgressive,
7
+ findLowestAmplitudeOffset,
8
+ findSilenceBoundaryFromGaps,
9
+ findSilenceBoundaryProgressive,
10
+ findSilenceBoundaryWithRms,
11
+ findSpeechEndWithRms,
12
+ findSpeechStartWithRms,
13
+ speechFallback,
14
+ } from './audio-analysis'
15
+ import type { TimeRange } from '../types'
16
+
17
+ // Factory function for creating audio samples
18
+ function createSamples(...values: number[]): Float32Array {
19
+ return new Float32Array(values)
20
+ }
21
+
22
+ function createUniformSamples(value: number, length: number): Float32Array {
23
+ return new Float32Array(length).fill(value)
24
+ }
25
+
26
+ function createRange(start: number, end: number): TimeRange {
27
+ return { start, end }
28
+ }
29
+
30
+ function createRanges(...pairs: [number, number][]): TimeRange[] {
31
+ return pairs.map(([start, end]) => createRange(start, end))
32
+ }
33
+
34
+ function createRmsOptions(
35
+ samples: number[],
36
+ direction: 'before' | 'after',
37
+ overrides: Partial<{
38
+ sampleRate: number
39
+ rmsWindowMs: number
40
+ rmsThreshold: number
41
+ minSilenceMs: number
42
+ }> = {},
43
+ ) {
44
+ return {
45
+ samples: createSamples(...samples),
46
+ sampleRate: overrides.sampleRate ?? 10,
47
+ direction,
48
+ rmsWindowMs: overrides.rmsWindowMs ?? 100,
49
+ rmsThreshold: overrides.rmsThreshold ?? 0.5,
50
+ minSilenceMs: overrides.minSilenceMs ?? 200,
51
+ }
52
+ }
53
+
54
+ function createProgressiveOptions(
55
+ samples: number[],
56
+ direction: 'before' | 'after',
57
+ overrides: Partial<{
58
+ sampleRate: number
59
+ startWindowSeconds: number
60
+ stepSeconds: number
61
+ maxWindowSeconds: number
62
+ rmsWindowMs: number
63
+ rmsThreshold: number
64
+ minSilenceMs: number
65
+ }> = {},
66
+ ) {
67
+ const sampleRate = overrides.sampleRate ?? 10
68
+ const maxWindowSeconds =
69
+ overrides.maxWindowSeconds ?? samples.length / sampleRate
70
+ return {
71
+ samples: createSamples(...samples),
72
+ sampleRate,
73
+ direction,
74
+ startWindowSeconds: overrides.startWindowSeconds ?? 0.2,
75
+ stepSeconds: overrides.stepSeconds ?? 0.2,
76
+ maxWindowSeconds,
77
+ rmsWindowMs: overrides.rmsWindowMs ?? 100,
78
+ rmsThreshold: overrides.rmsThreshold ?? 0.5,
79
+ minSilenceMs: overrides.minSilenceMs ?? 100,
80
+ }
81
+ }
82
+
83
+ // computeRms tests
84
+ test('computeRms returns 0 for empty array', () => {
85
+ expect(computeRms(createSamples())).toBe(0)
86
+ })
87
+
88
+ test('computeRms returns 0 for all zeros', () => {
89
+ expect(computeRms(createSamples(0, 0, 0, 0))).toBe(0)
90
+ })
91
+
92
+ test('computeRms computes correct value for uniform samples', () => {
93
+ expect(computeRms(createSamples(1, 1, 1, 1))).toBe(1)
94
+ })
95
+
96
+ test('computeRms computes correct value for [3, 4]', () => {
97
+ const samples = createSamples(3, 4)
98
+ expect(computeRms(samples)).toBeCloseTo(Math.sqrt(12.5))
99
+ })
100
+
101
+ test('computeRms handles negative values correctly', () => {
102
+ expect(computeRms(createSamples(-1, -1, -1, -1))).toBe(1)
103
+ })
104
+
105
+ test('computeRms handles mixed positive and negative values', () => {
106
+ expect(computeRms(createSamples(1, -1, 1, -1))).toBe(1)
107
+ })
108
+
109
+ test('computeRms computes correct value for single sample', () => {
110
+ expect(computeRms(createSamples(5))).toBe(5)
111
+ })
112
+
113
+ test('computeRms computes correct value for typical audio samples', () => {
114
+ const samples = createSamples(0.5, -0.3, 0.8, -0.2, 0.1)
115
+ const expectedSumSquares = 0.25 + 0.09 + 0.64 + 0.04 + 0.01
116
+ const expectedRms = Math.sqrt(expectedSumSquares / 5)
117
+ expect(computeRms(samples)).toBeCloseTo(expectedRms)
118
+ })
119
+
120
+ test('computeRms handles very small values', () => {
121
+ const samples = createSamples(0.001, 0.002, 0.001, 0.002)
122
+ const rms = computeRms(samples)
123
+ expect(rms).toBeGreaterThan(0)
124
+ expect(rms).toBeLessThan(0.01)
125
+ })
126
+
127
+ // computeMinWindowRms tests
128
+ test('computeMinWindowRms returns 0 for empty array', () => {
129
+ expect(computeMinWindowRms(createSamples(), 10)).toBe(0)
130
+ })
131
+
132
+ test('computeMinWindowRms returns 0 for zero window size', () => {
133
+ expect(computeMinWindowRms(createSamples(1, 2, 3), 0)).toBe(0)
134
+ })
135
+
136
+ test('computeMinWindowRms returns 0 for negative window size', () => {
137
+ expect(computeMinWindowRms(createSamples(1, 2, 3), -5)).toBe(0)
138
+ })
139
+
140
+ test('computeMinWindowRms returns full RMS when window exceeds samples', () => {
141
+ const samples = createSamples(1, 2, 3)
142
+ expect(computeMinWindowRms(samples, 5)).toBe(computeRms(samples))
143
+ })
144
+
145
+ test('computeMinWindowRms returns full RMS when window equals samples', () => {
146
+ const samples = createSamples(1, 2, 3)
147
+ expect(computeMinWindowRms(samples, 3)).toBe(computeRms(samples))
148
+ })
149
+
150
+ test('computeMinWindowRms finds quietest section', () => {
151
+ // Loud (RMS=1) | Quiet (RMS=0.1) | Loud (RMS=1)
152
+ const samples = createSamples(1, 1, 1, 1, 0.1, 0.1, 1, 1, 1, 1)
153
+ expect(computeMinWindowRms(samples, 2)).toBeCloseTo(0.1)
154
+ })
155
+
156
+ test('computeMinWindowRms with window size 1 finds minimum absolute value', () => {
157
+ const samples = createSamples(5, 1, 3, 0, 4)
158
+ expect(computeMinWindowRms(samples, 1)).toBe(0)
159
+ })
160
+
161
+ test('computeMinWindowRms returns same value for uniform array', () => {
162
+ const samples = createUniformSamples(0.5, 5)
163
+ expect(computeMinWindowRms(samples, 2)).toBeCloseTo(0.5)
164
+ })
165
+
166
+ test('computeMinWindowRms finds minimum at start', () => {
167
+ const samples = createSamples(0.1, 0.1, 1, 1, 1, 1)
168
+ expect(computeMinWindowRms(samples, 2)).toBeCloseTo(0.1)
169
+ })
170
+
171
+ test('computeMinWindowRms finds minimum at end', () => {
172
+ const samples = createSamples(1, 1, 1, 1, 0.1, 0.1)
173
+ expect(computeMinWindowRms(samples, 2)).toBeCloseTo(0.1)
174
+ })
175
+
176
+ test('computeMinWindowRms returns 0 for silence', () => {
177
+ const samples = createUniformSamples(0, 6)
178
+ expect(computeMinWindowRms(samples, 3)).toBe(0)
179
+ })
180
+
181
+ test('computeMinWindowRms handles mixed positive and negative', () => {
182
+ const samples = createSamples(1, -1, 1, -1, 0.1, -0.1, 1, -1, 1, -1)
183
+ expect(computeMinWindowRms(samples, 2)).toBeCloseTo(0.1)
184
+ })
185
+
186
+ test('findSpeechStartWithRms finds first non-silent window', () => {
187
+ const start = findSpeechStartWithRms({
188
+ samples: createSamples(0, 0, 0, 1, 1),
189
+ sampleRate: 10,
190
+ rmsWindowMs: 100,
191
+ rmsThreshold: 0.5,
192
+ })
193
+ expect(start).toBeCloseTo(0.3, 3)
194
+ })
195
+
196
+ test('findSpeechStartWithRms returns null for silence', () => {
197
+ const start = findSpeechStartWithRms({
198
+ samples: createSamples(0, 0, 0, 0),
199
+ sampleRate: 10,
200
+ rmsWindowMs: 100,
201
+ rmsThreshold: 0.1,
202
+ })
203
+ expect(start).toBeNull()
204
+ })
205
+
206
+ test('findSpeechEndWithRms finds last non-silent window', () => {
207
+ const end = findSpeechEndWithRms({
208
+ samples: createSamples(1, 1, 0, 0),
209
+ sampleRate: 10,
210
+ rmsWindowMs: 100,
211
+ rmsThreshold: 0.5,
212
+ })
213
+ expect(end).toBeCloseTo(0.2, 3)
214
+ })
215
+
216
+ test('findSpeechEndWithRms returns null for silence', () => {
217
+ const end = findSpeechEndWithRms({
218
+ samples: createSamples(0, 0, 0, 0),
219
+ sampleRate: 10,
220
+ rmsWindowMs: 100,
221
+ rmsThreshold: 0.1,
222
+ })
223
+ expect(end).toBeNull()
224
+ })
225
+
226
+ // buildSilenceGapsFromSpeech tests
227
+ test('buildSilenceGapsFromSpeech returns full gap for no speech', () => {
228
+ expect(buildSilenceGapsFromSpeech([], 10)).toEqual([createRange(0, 10)])
229
+ })
230
+
231
+ test('buildSilenceGapsFromSpeech returns gaps between segments', () => {
232
+ const segments = createRanges([0, 1], [3, 4])
233
+ expect(buildSilenceGapsFromSpeech(segments, 5)).toEqual(
234
+ createRanges([1, 3], [4, 5]),
235
+ )
236
+ })
237
+
238
+ test('buildSilenceGapsFromSpeech filters tiny gaps', () => {
239
+ const segments = createRanges([0, 1], [1.0005, 2])
240
+ expect(buildSilenceGapsFromSpeech(segments, 2)).toEqual([])
241
+ })
242
+
243
+ // findSilenceBoundaryFromGaps tests
244
+ test('findSilenceBoundaryFromGaps returns target inside gap', () => {
245
+ const gaps = createRanges([0, 2], [4, 6])
246
+ expect(findSilenceBoundaryFromGaps(gaps, 1.5, 'before')).toBe(1.5)
247
+ })
248
+
249
+ test('findSilenceBoundaryFromGaps finds boundary before target', () => {
250
+ const gaps = createRanges([0, 1], [3, 4])
251
+ expect(findSilenceBoundaryFromGaps(gaps, 2, 'before')).toBe(1)
252
+ })
253
+
254
+ test('findSilenceBoundaryFromGaps finds boundary after target', () => {
255
+ const gaps = createRanges([0, 1], [3, 4])
256
+ expect(findSilenceBoundaryFromGaps(gaps, 2, 'after')).toBe(3)
257
+ })
258
+
259
+ test('findSilenceBoundaryFromGaps returns null when none found', () => {
260
+ const gaps = createRanges([1, 2])
261
+ expect(findSilenceBoundaryFromGaps(gaps, 0.5, 'before')).toBeNull()
262
+ })
263
+
264
+ // speechFallback tests
265
+ test('speechFallback returns full duration range with note', () => {
266
+ expect(speechFallback(12.5, 'fallback')).toEqual({
267
+ start: 0,
268
+ end: 12.5,
269
+ note: 'fallback',
270
+ })
271
+ })
272
+
273
+ // findSilenceBoundaryWithRms tests
274
+ test('findSilenceBoundaryWithRms returns null for empty samples', () => {
275
+ const options = createRmsOptions([], 'after')
276
+ expect(findSilenceBoundaryWithRms(options)).toBeNull()
277
+ })
278
+
279
+ test('findSilenceBoundaryWithRms finds silence start for after direction', () => {
280
+ const options = createRmsOptions([1, 1, 0, 0, 1], 'after')
281
+ expect(findSilenceBoundaryWithRms(options)).toBeCloseTo(0.2)
282
+ })
283
+
284
+ test('findSilenceBoundaryWithRms finds silence end for before direction', () => {
285
+ const options = createRmsOptions([1, 1, 1, 0, 0], 'before')
286
+ expect(findSilenceBoundaryWithRms(options)).toBeCloseTo(0.5)
287
+ })
288
+
289
+ test('findSilenceBoundaryWithRms returns null when silence is too short', () => {
290
+ const options = createRmsOptions([1, 0, 1, 1], 'after')
291
+ expect(findSilenceBoundaryWithRms(options)).toBeNull()
292
+ })
293
+
294
+ test('findLowestAmplitudeOffset picks the lowest RMS window', () => {
295
+ const result = findLowestAmplitudeOffset({
296
+ samples: createSamples(1, 1, 1, 0, 0, 0),
297
+ sampleRate: 10,
298
+ rmsWindowMs: 100,
299
+ })
300
+ expect(result).not.toBeNull()
301
+ expect(result?.offsetSeconds).toBeCloseTo(0.35, 3)
302
+ expect(result?.rms).toBeLessThan(0.2)
303
+ })
304
+
305
+ test('findLowestAmplitudeBoundaryProgressive uses closest low amplitude', () => {
306
+ const options = createProgressiveOptions(
307
+ [1, 1, 1, 1, 0, 0, 0, 1, 1, 1],
308
+ 'before',
309
+ { startWindowSeconds: 0.2, stepSeconds: 0.2, maxWindowSeconds: 1 },
310
+ )
311
+ const boundary = findLowestAmplitudeBoundaryProgressive({
312
+ ...options,
313
+ rmsThreshold: 0.2,
314
+ })
315
+ expect(boundary).toBeCloseTo(0.65, 2)
316
+ })
317
+
318
+ test('findLowestAmplitudeBoundaryProgressive returns null without quiet audio', () => {
319
+ const options = createProgressiveOptions([1, 1, 1, 1, 1], 'after', {
320
+ startWindowSeconds: 0.2,
321
+ stepSeconds: 0.2,
322
+ maxWindowSeconds: 0.5,
323
+ })
324
+ const boundary = findLowestAmplitudeBoundaryProgressive({
325
+ ...options,
326
+ rmsThreshold: 0.2,
327
+ })
328
+ expect(boundary).toBeNull()
329
+ })
330
+
331
+ test('findSilenceBoundaryProgressive widens window until silence is found', () => {
332
+ const options = createProgressiveOptions(
333
+ [1, 1, 1, 1, 1, 0, 0, 1, 1, 1],
334
+ 'before',
335
+ { startWindowSeconds: 0.2, stepSeconds: 0.2, maxWindowSeconds: 1 },
336
+ )
337
+ const boundary = findSilenceBoundaryProgressive(options)
338
+ expect(boundary).toBeCloseTo(0.7, 3)
339
+ })
340
+
341
+ test('findSilenceBoundaryProgressive returns null when no silence exists', () => {
342
+ const options = createProgressiveOptions([1, 1, 1, 1, 1], 'after', {
343
+ startWindowSeconds: 0.2,
344
+ stepSeconds: 0.2,
345
+ maxWindowSeconds: 0.5,
346
+ })
347
+ expect(findSilenceBoundaryProgressive(options)).toBeNull()
348
+ })