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,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
|
+
})
|