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.
- package/LICENSE +21 -0
- package/README.md +122 -29
- package/app/assets/styles.css +129 -0
- package/app/client/app.tsx +37 -0
- package/app/client/counter.tsx +22 -0
- package/app/client/entry.tsx +8 -0
- package/app/components/layout.tsx +37 -0
- package/app/config/env.ts +31 -0
- package/app/config/import-map.ts +9 -0
- package/app/config/init-env.ts +3 -0
- package/app/config/routes.ts +5 -0
- package/app/helpers/render.ts +6 -0
- package/app/router.tsx +102 -0
- package/app/routes/index.tsx +50 -0
- package/app-server.ts +60 -0
- package/cli.ts +173 -0
- package/package.json +46 -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/public/robots.txt +2 -0
- package/server/bundling.ts +210 -0
- package/speech-detection.ts +355 -0
- package/utils.ts +138 -0
- package/whispercpp-transcribe.ts +343 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'
|
|
5
|
+
import yargs from 'yargs/yargs'
|
|
6
|
+
import { hideBin } from 'yargs/helpers'
|
|
7
|
+
import { extractChapterSegmentAccurate, concatSegments } from '../ffmpeg'
|
|
8
|
+
import { buildKeepRanges, mergeTimeRanges } from '../utils/time-ranges'
|
|
9
|
+
import { clamp, getMediaDurationSeconds } from '../../utils'
|
|
10
|
+
import type { TimeRange } from '../types'
|
|
11
|
+
|
|
12
|
+
export type RemoveRangesOptions = {
|
|
13
|
+
inputPath: string
|
|
14
|
+
outputPath: string
|
|
15
|
+
ranges: TimeRange[]
|
|
16
|
+
duration?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type RemoveRangesResult = {
|
|
20
|
+
success: boolean
|
|
21
|
+
error?: string
|
|
22
|
+
outputPath?: string
|
|
23
|
+
removedRanges: TimeRange[]
|
|
24
|
+
keepRanges: TimeRange[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildRangesRemovedOutputPath(inputPath: string): string {
|
|
28
|
+
const parsed = path.parse(inputPath)
|
|
29
|
+
return path.join(parsed.dir, `${parsed.name}.ranges-removed${parsed.ext}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseTimeRanges(value: string): TimeRange[] {
|
|
33
|
+
const trimmed = value.trim()
|
|
34
|
+
if (!trimmed) {
|
|
35
|
+
throw new Error('No time ranges provided.')
|
|
36
|
+
}
|
|
37
|
+
const normalized = trimmed
|
|
38
|
+
.replace(/\s*-\s*/g, '-')
|
|
39
|
+
.replace(/\s*\.\.\s*/g, '..')
|
|
40
|
+
const tokens = normalized.split(/[\s,;]+/).filter(Boolean)
|
|
41
|
+
if (tokens.length === 0) {
|
|
42
|
+
throw new Error('No time ranges provided.')
|
|
43
|
+
}
|
|
44
|
+
return tokens.map(parseRangeToken)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeRemovalRanges(options: {
|
|
48
|
+
ranges: TimeRange[]
|
|
49
|
+
duration: number
|
|
50
|
+
}): { ranges: TimeRange[]; warnings: string[] } {
|
|
51
|
+
const normalized: TimeRange[] = []
|
|
52
|
+
const warnings: string[] = []
|
|
53
|
+
for (const range of options.ranges) {
|
|
54
|
+
const clampedStart = clamp(range.start, 0, options.duration)
|
|
55
|
+
const clampedEnd = clamp(range.end, 0, options.duration)
|
|
56
|
+
if (clampedStart !== range.start || clampedEnd !== range.end) {
|
|
57
|
+
warnings.push(
|
|
58
|
+
`Clamped range ${range.start.toFixed(3)}-${range.end.toFixed(3)} to ${clampedStart.toFixed(3)}-${clampedEnd.toFixed(3)}.`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
if (clampedEnd <= clampedStart + 0.005) {
|
|
62
|
+
warnings.push(
|
|
63
|
+
`Skipping empty range ${clampedStart.toFixed(3)}-${clampedEnd.toFixed(3)}.`,
|
|
64
|
+
)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
normalized.push({ start: clampedStart, end: clampedEnd })
|
|
68
|
+
}
|
|
69
|
+
return { ranges: mergeTimeRanges(normalized), warnings }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function removeRangesFromMedia(
|
|
73
|
+
options: RemoveRangesOptions,
|
|
74
|
+
): Promise<RemoveRangesResult> {
|
|
75
|
+
const removedRanges: TimeRange[] = []
|
|
76
|
+
const keepRanges: TimeRange[] = []
|
|
77
|
+
try {
|
|
78
|
+
const duration =
|
|
79
|
+
typeof options.duration === 'number'
|
|
80
|
+
? options.duration
|
|
81
|
+
: await getMediaDurationSeconds(options.inputPath)
|
|
82
|
+
const normalized = normalizeRemovalRanges({
|
|
83
|
+
ranges: options.ranges,
|
|
84
|
+
duration,
|
|
85
|
+
})
|
|
86
|
+
normalized.warnings.forEach((warning) => console.warn(`[warn] ${warning}`))
|
|
87
|
+
removedRanges.push(...normalized.ranges)
|
|
88
|
+
if (removedRanges.length === 0) {
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
error: 'No valid ranges to remove after normalization.',
|
|
92
|
+
removedRanges,
|
|
93
|
+
keepRanges,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
keepRanges.push(...buildKeepRanges(0, duration, removedRanges))
|
|
97
|
+
if (keepRanges.length === 0) {
|
|
98
|
+
return {
|
|
99
|
+
success: false,
|
|
100
|
+
error: 'Requested removals delete the entire file.',
|
|
101
|
+
removedRanges,
|
|
102
|
+
keepRanges,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const resolvedInput = path.resolve(options.inputPath)
|
|
107
|
+
const resolvedOutput = path.resolve(options.outputPath)
|
|
108
|
+
if (resolvedInput === resolvedOutput) {
|
|
109
|
+
return {
|
|
110
|
+
success: false,
|
|
111
|
+
error: 'Output path must be different from input path.',
|
|
112
|
+
removedRanges,
|
|
113
|
+
keepRanges,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await mkdir(path.dirname(options.outputPath), { recursive: true })
|
|
118
|
+
|
|
119
|
+
const isFullRange =
|
|
120
|
+
keepRanges.length === 1 &&
|
|
121
|
+
keepRanges[0] &&
|
|
122
|
+
keepRanges[0].start <= 0.001 &&
|
|
123
|
+
keepRanges[0].end >= duration - 0.001
|
|
124
|
+
if (isFullRange) {
|
|
125
|
+
await copyFile(options.inputPath, options.outputPath)
|
|
126
|
+
return {
|
|
127
|
+
success: true,
|
|
128
|
+
outputPath: options.outputPath,
|
|
129
|
+
removedRanges,
|
|
130
|
+
keepRanges,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (keepRanges.length === 1 && keepRanges[0]) {
|
|
135
|
+
await extractChapterSegmentAccurate({
|
|
136
|
+
inputPath: options.inputPath,
|
|
137
|
+
outputPath: options.outputPath,
|
|
138
|
+
start: keepRanges[0].start,
|
|
139
|
+
end: keepRanges[0].end,
|
|
140
|
+
})
|
|
141
|
+
return {
|
|
142
|
+
success: true,
|
|
143
|
+
outputPath: options.outputPath,
|
|
144
|
+
removedRanges,
|
|
145
|
+
keepRanges,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'remove-ranges-'))
|
|
150
|
+
try {
|
|
151
|
+
const segmentPaths: string[] = []
|
|
152
|
+
for (const [index, range] of keepRanges.entries()) {
|
|
153
|
+
const segmentPath = path.join(tempDir, `segment-${index + 1}.mp4`)
|
|
154
|
+
await extractChapterSegmentAccurate({
|
|
155
|
+
inputPath: options.inputPath,
|
|
156
|
+
outputPath: segmentPath,
|
|
157
|
+
start: range.start,
|
|
158
|
+
end: range.end,
|
|
159
|
+
})
|
|
160
|
+
segmentPaths.push(segmentPath)
|
|
161
|
+
}
|
|
162
|
+
if (segmentPaths.length < 2) {
|
|
163
|
+
throw new Error('Expected at least two segments to concat.')
|
|
164
|
+
}
|
|
165
|
+
await concatSegments({
|
|
166
|
+
segmentPaths,
|
|
167
|
+
outputPath: options.outputPath,
|
|
168
|
+
})
|
|
169
|
+
} finally {
|
|
170
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
success: true,
|
|
175
|
+
outputPath: options.outputPath,
|
|
176
|
+
removedRanges,
|
|
177
|
+
keepRanges,
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return {
|
|
181
|
+
success: false,
|
|
182
|
+
error: error instanceof Error ? error.message : String(error),
|
|
183
|
+
removedRanges,
|
|
184
|
+
keepRanges,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function main() {
|
|
190
|
+
const argv = yargs(hideBin(process.argv))
|
|
191
|
+
.scriptName('remove-ranges')
|
|
192
|
+
.option('input', {
|
|
193
|
+
type: 'string',
|
|
194
|
+
demandOption: true,
|
|
195
|
+
describe: 'Input media file',
|
|
196
|
+
})
|
|
197
|
+
.option('ranges', {
|
|
198
|
+
type: 'string',
|
|
199
|
+
demandOption: true,
|
|
200
|
+
describe: 'Comma or space-separated ranges (e.g. 12-15, 1:02-1:05)',
|
|
201
|
+
})
|
|
202
|
+
.option('output', {
|
|
203
|
+
type: 'string',
|
|
204
|
+
describe: 'Output path (defaults to .ranges-removed)',
|
|
205
|
+
})
|
|
206
|
+
.help()
|
|
207
|
+
.parseSync()
|
|
208
|
+
|
|
209
|
+
const inputPath = path.resolve(String(argv.input))
|
|
210
|
+
const outputPath =
|
|
211
|
+
typeof argv.output === 'string' && argv.output.trim().length > 0
|
|
212
|
+
? path.resolve(argv.output)
|
|
213
|
+
: buildRangesRemovedOutputPath(inputPath)
|
|
214
|
+
const ranges = parseTimeRanges(String(argv.ranges))
|
|
215
|
+
const result = await removeRangesFromMedia({
|
|
216
|
+
inputPath,
|
|
217
|
+
outputPath,
|
|
218
|
+
ranges,
|
|
219
|
+
})
|
|
220
|
+
if (!result.success) {
|
|
221
|
+
console.error(result.error ?? 'Range removal failed.')
|
|
222
|
+
process.exit(1)
|
|
223
|
+
}
|
|
224
|
+
console.log(`Updated file written to ${outputPath}`)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseRangeToken(token: string): TimeRange {
|
|
228
|
+
const separator = token.includes('..') ? '..' : '-'
|
|
229
|
+
const parts = token.split(separator)
|
|
230
|
+
if (parts.length !== 2) {
|
|
231
|
+
throw new Error(`Invalid range "${token}". Use start-end format.`)
|
|
232
|
+
}
|
|
233
|
+
const startText = parts[0]?.trim() ?? ''
|
|
234
|
+
const endText = parts[1]?.trim() ?? ''
|
|
235
|
+
if (!startText || !endText) {
|
|
236
|
+
throw new Error(`Invalid range "${token}". Start and end required.`)
|
|
237
|
+
}
|
|
238
|
+
const start = parseTimestamp(startText)
|
|
239
|
+
const end = parseTimestamp(endText)
|
|
240
|
+
if (end <= start) {
|
|
241
|
+
throw new Error(`Invalid range "${token}". End must be after start.`)
|
|
242
|
+
}
|
|
243
|
+
return { start, end }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parseTimestamp(value: string): number {
|
|
247
|
+
const trimmed = value.trim()
|
|
248
|
+
if (!trimmed) {
|
|
249
|
+
throw new Error('Invalid time value.')
|
|
250
|
+
}
|
|
251
|
+
if (!trimmed.includes(':')) {
|
|
252
|
+
const seconds = Number.parseFloat(trimmed)
|
|
253
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
254
|
+
throw new Error(`Invalid time value "${value}".`)
|
|
255
|
+
}
|
|
256
|
+
return seconds
|
|
257
|
+
}
|
|
258
|
+
const parts = trimmed.split(':')
|
|
259
|
+
if (parts.length < 2 || parts.length > 3) {
|
|
260
|
+
throw new Error(`Invalid time value "${value}".`)
|
|
261
|
+
}
|
|
262
|
+
let totalSeconds = 0
|
|
263
|
+
for (const [index, part] of parts.entries()) {
|
|
264
|
+
const segment = part.trim()
|
|
265
|
+
if (!segment) {
|
|
266
|
+
throw new Error(`Invalid time value "${value}".`)
|
|
267
|
+
}
|
|
268
|
+
if (segment.includes('.') && index < parts.length - 1) {
|
|
269
|
+
throw new Error(`Invalid time value "${value}".`)
|
|
270
|
+
}
|
|
271
|
+
const numberValue = Number.parseFloat(segment)
|
|
272
|
+
if (!Number.isFinite(numberValue) || numberValue < 0) {
|
|
273
|
+
throw new Error(`Invalid time value "${value}".`)
|
|
274
|
+
}
|
|
275
|
+
totalSeconds = totalSeconds * 60 + numberValue
|
|
276
|
+
}
|
|
277
|
+
return totalSeconds
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (import.meta.main) {
|
|
281
|
+
main().catch((error) => {
|
|
282
|
+
console.error(
|
|
283
|
+
`[error] ${error instanceof Error ? error.message : String(error)}`,
|
|
284
|
+
)
|
|
285
|
+
process.exit(1)
|
|
286
|
+
})
|
|
287
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import { wordsToTimeRanges } from './timestamp-refinement'
|
|
3
|
+
import type { TranscriptWordWithIndex } from './types'
|
|
4
|
+
|
|
5
|
+
function createWord(
|
|
6
|
+
word: string,
|
|
7
|
+
start: number,
|
|
8
|
+
end: number,
|
|
9
|
+
index = 0,
|
|
10
|
+
): TranscriptWordWithIndex {
|
|
11
|
+
return { word, start, end, index }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('wordsToTimeRanges merges overlapping ranges', () => {
|
|
15
|
+
const words = [
|
|
16
|
+
createWord('hello', 0, 0.5, 0),
|
|
17
|
+
createWord('world', 0.45, 1, 1),
|
|
18
|
+
]
|
|
19
|
+
const ranges = wordsToTimeRanges(words)
|
|
20
|
+
expect(ranges).toEqual([{ start: 0, end: 1 }])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('wordsToTimeRanges returns empty for no words', () => {
|
|
24
|
+
expect(wordsToTimeRanges([])).toEqual([])
|
|
25
|
+
})
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readAudioSamples } from '../ffmpeg'
|
|
2
|
+
import { CONFIG, EDIT_CONFIG } from '../config'
|
|
3
|
+
import { clamp } from '../../utils'
|
|
4
|
+
import { mergeTimeRanges } from '../utils/time-ranges'
|
|
5
|
+
import { findLowestAmplitudeBoundaryProgressive } from '../utils/audio-analysis'
|
|
6
|
+
import type { TimeRange } from '../types'
|
|
7
|
+
import type { TranscriptWordWithIndex } from './types'
|
|
8
|
+
|
|
9
|
+
export type RefinedRange = {
|
|
10
|
+
original: TimeRange
|
|
11
|
+
refined: TimeRange
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function wordsToTimeRanges(
|
|
15
|
+
words: TranscriptWordWithIndex[],
|
|
16
|
+
): TimeRange[] {
|
|
17
|
+
const ranges = words.map((word) => ({ start: word.start, end: word.end }))
|
|
18
|
+
return mergeTimeRanges(ranges)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function refineRemovalRange(options: {
|
|
22
|
+
inputPath: string
|
|
23
|
+
duration: number
|
|
24
|
+
range: TimeRange
|
|
25
|
+
paddingMs?: number
|
|
26
|
+
}): Promise<RefinedRange> {
|
|
27
|
+
const paddingSeconds =
|
|
28
|
+
(options.paddingMs ?? EDIT_CONFIG.speechBoundaryPaddingMs) / 1000
|
|
29
|
+
const silenceStart = await findSilenceBoundary({
|
|
30
|
+
inputPath: options.inputPath,
|
|
31
|
+
duration: options.duration,
|
|
32
|
+
targetTime: options.range.start,
|
|
33
|
+
direction: 'before',
|
|
34
|
+
})
|
|
35
|
+
if (silenceStart === null) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
buildSilenceError({
|
|
38
|
+
direction: 'before',
|
|
39
|
+
targetTime: options.range.start,
|
|
40
|
+
maxWindowSeconds: getMaxSilenceSearchSeconds({
|
|
41
|
+
duration: options.duration,
|
|
42
|
+
targetTime: options.range.start,
|
|
43
|
+
direction: 'before',
|
|
44
|
+
}),
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
const silenceEnd = await findSilenceBoundary({
|
|
49
|
+
inputPath: options.inputPath,
|
|
50
|
+
duration: options.duration,
|
|
51
|
+
targetTime: options.range.end,
|
|
52
|
+
direction: 'after',
|
|
53
|
+
})
|
|
54
|
+
if (silenceEnd === null) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
buildSilenceError({
|
|
57
|
+
direction: 'after',
|
|
58
|
+
targetTime: options.range.end,
|
|
59
|
+
maxWindowSeconds: getMaxSilenceSearchSeconds({
|
|
60
|
+
duration: options.duration,
|
|
61
|
+
targetTime: options.range.end,
|
|
62
|
+
direction: 'after',
|
|
63
|
+
}),
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const paddedStart = clamp(silenceStart + paddingSeconds, 0, options.duration)
|
|
69
|
+
const paddedEnd = clamp(silenceEnd - paddingSeconds, 0, options.duration)
|
|
70
|
+
const refinedStart =
|
|
71
|
+
paddedStart <= options.range.start ? paddedStart : silenceStart
|
|
72
|
+
const refinedEnd = paddedEnd >= options.range.end ? paddedEnd : silenceEnd
|
|
73
|
+
|
|
74
|
+
if (refinedEnd <= refinedStart + 0.005) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Unable to create a non-empty cut around ${options.range.start.toFixed(3)}s-${options.range.end.toFixed(3)}s.`,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
original: options.range,
|
|
82
|
+
refined: { start: refinedStart, end: refinedEnd },
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function refineAllRemovalRanges(options: {
|
|
87
|
+
inputPath: string
|
|
88
|
+
duration: number
|
|
89
|
+
ranges: TimeRange[]
|
|
90
|
+
paddingMs?: number
|
|
91
|
+
}): Promise<RefinedRange[]> {
|
|
92
|
+
const refined: RefinedRange[] = []
|
|
93
|
+
for (const range of options.ranges) {
|
|
94
|
+
refined.push(
|
|
95
|
+
await refineRemovalRange({
|
|
96
|
+
inputPath: options.inputPath,
|
|
97
|
+
duration: options.duration,
|
|
98
|
+
range,
|
|
99
|
+
paddingMs: options.paddingMs,
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
return refined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type SpeechBoundaryDirection = 'before' | 'after'
|
|
107
|
+
|
|
108
|
+
async function findSilenceBoundary(options: {
|
|
109
|
+
inputPath: string
|
|
110
|
+
duration: number
|
|
111
|
+
targetTime: number
|
|
112
|
+
direction: SpeechBoundaryDirection
|
|
113
|
+
}): Promise<number | null> {
|
|
114
|
+
const maxWindowSeconds = getMaxSilenceSearchSeconds(options)
|
|
115
|
+
if (maxWindowSeconds <= 0.01) {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
const windowStart =
|
|
119
|
+
options.direction === 'before'
|
|
120
|
+
? Math.max(0, options.targetTime - maxWindowSeconds)
|
|
121
|
+
: options.targetTime
|
|
122
|
+
const windowEnd =
|
|
123
|
+
options.direction === 'before'
|
|
124
|
+
? options.targetTime
|
|
125
|
+
: Math.min(options.duration, options.targetTime + maxWindowSeconds)
|
|
126
|
+
const windowDuration = windowEnd - windowStart
|
|
127
|
+
if (windowDuration <= 0.01) {
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
const samples = await readAudioSamples({
|
|
131
|
+
inputPath: options.inputPath,
|
|
132
|
+
start: windowStart,
|
|
133
|
+
duration: windowDuration,
|
|
134
|
+
sampleRate: CONFIG.vadSampleRate,
|
|
135
|
+
})
|
|
136
|
+
if (samples.length === 0) {
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const boundary = findLowestAmplitudeBoundaryProgressive({
|
|
141
|
+
samples,
|
|
142
|
+
sampleRate: CONFIG.vadSampleRate,
|
|
143
|
+
direction: options.direction,
|
|
144
|
+
rmsWindowMs: CONFIG.commandSilenceRmsWindowMs,
|
|
145
|
+
rmsThreshold: CONFIG.commandSilenceRmsThreshold,
|
|
146
|
+
startWindowSeconds: EDIT_CONFIG.silenceSearchStartSeconds,
|
|
147
|
+
stepSeconds: EDIT_CONFIG.silenceSearchStepSeconds,
|
|
148
|
+
maxWindowSeconds: windowDuration,
|
|
149
|
+
})
|
|
150
|
+
return boundary === null ? null : windowStart + boundary
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildSilenceError(options: {
|
|
154
|
+
direction: SpeechBoundaryDirection
|
|
155
|
+
targetTime: number
|
|
156
|
+
maxWindowSeconds: number
|
|
157
|
+
}): string {
|
|
158
|
+
const directionLabel = options.direction === 'before' ? 'before' : 'after'
|
|
159
|
+
return `No low-amplitude boundary found ${directionLabel} ${options.targetTime.toFixed(3)}s within ${options.maxWindowSeconds.toFixed(2)}s.`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function getMaxSilenceSearchSeconds(options: {
|
|
163
|
+
duration: number
|
|
164
|
+
targetTime: number
|
|
165
|
+
direction: SpeechBoundaryDirection
|
|
166
|
+
}): number {
|
|
167
|
+
const availableSeconds =
|
|
168
|
+
options.direction === 'before'
|
|
169
|
+
? options.targetTime
|
|
170
|
+
: options.duration - options.targetTime
|
|
171
|
+
return Math.min(EDIT_CONFIG.silenceSearchMaxSeconds, availableSeconds)
|
|
172
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import { diffTranscripts, validateEditedTranscript } from './transcript-diff'
|
|
3
|
+
import type { TranscriptWordWithIndex } from './types'
|
|
4
|
+
|
|
5
|
+
function createWord(
|
|
6
|
+
word: string,
|
|
7
|
+
index: number,
|
|
8
|
+
start = index * 0.5,
|
|
9
|
+
end = start + 0.4,
|
|
10
|
+
): TranscriptWordWithIndex {
|
|
11
|
+
return { word, index, start, end }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createWords(...words: string[]): TranscriptWordWithIndex[] {
|
|
15
|
+
return words.map((word, index) => createWord(word, index))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('diffTranscripts removes words from the middle', () => {
|
|
19
|
+
const originalWords = createWords('hello', 'this', 'is', 'a', 'test')
|
|
20
|
+
const result = diffTranscripts({
|
|
21
|
+
originalWords,
|
|
22
|
+
editedText: 'hello this a test',
|
|
23
|
+
})
|
|
24
|
+
expect(result.success).toBe(true)
|
|
25
|
+
expect(result.removedWords.map((word) => word.word)).toEqual(['is'])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('diffTranscripts removes words at the start', () => {
|
|
29
|
+
const originalWords = createWords('hello', 'this', 'is', 'a', 'test')
|
|
30
|
+
const result = diffTranscripts({
|
|
31
|
+
originalWords,
|
|
32
|
+
editedText: 'this is a test',
|
|
33
|
+
})
|
|
34
|
+
expect(result.success).toBe(true)
|
|
35
|
+
expect(result.removedWords.map((word) => word.word)).toEqual(['hello'])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('diffTranscripts removes words at the end', () => {
|
|
39
|
+
const originalWords = createWords('hello', 'this', 'is', 'a', 'test')
|
|
40
|
+
const result = diffTranscripts({
|
|
41
|
+
originalWords,
|
|
42
|
+
editedText: 'hello this is a',
|
|
43
|
+
})
|
|
44
|
+
expect(result.success).toBe(true)
|
|
45
|
+
expect(result.removedWords.map((word) => word.word)).toEqual(['test'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('diffTranscripts removes multiple disjoint words', () => {
|
|
49
|
+
const originalWords = createWords('hello', 'this', 'is', 'a', 'test', 'today')
|
|
50
|
+
const result = diffTranscripts({
|
|
51
|
+
originalWords,
|
|
52
|
+
editedText: 'hello is test today',
|
|
53
|
+
})
|
|
54
|
+
expect(result.success).toBe(true)
|
|
55
|
+
expect(result.removedWords.map((word) => word.word)).toEqual(['this', 'a'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('validateEditedTranscript errors on added words', () => {
|
|
59
|
+
const originalWords = createWords('hello', 'world')
|
|
60
|
+
const result = validateEditedTranscript({
|
|
61
|
+
originalWords,
|
|
62
|
+
editedText: 'hello brave world',
|
|
63
|
+
})
|
|
64
|
+
expect(result.valid).toBe(false)
|
|
65
|
+
expect(result.error).toContain('Transcript mismatch')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('validateEditedTranscript errors on modified words', () => {
|
|
69
|
+
const originalWords = createWords('processing', 'pipeline')
|
|
70
|
+
const result = validateEditedTranscript({
|
|
71
|
+
originalWords,
|
|
72
|
+
editedText: 'prosessing pipeline',
|
|
73
|
+
})
|
|
74
|
+
expect(result.valid).toBe(false)
|
|
75
|
+
expect(result.error).toContain('Transcript mismatch')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('validateEditedTranscript errors on empty edited text', () => {
|
|
79
|
+
const originalWords = createWords('hello', 'world')
|
|
80
|
+
const result = validateEditedTranscript({
|
|
81
|
+
originalWords,
|
|
82
|
+
editedText: ' ',
|
|
83
|
+
})
|
|
84
|
+
expect(result.valid).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('diffTranscripts ignores whitespace differences', () => {
|
|
88
|
+
const originalWords = createWords('hello', 'world')
|
|
89
|
+
const result = diffTranscripts({
|
|
90
|
+
originalWords,
|
|
91
|
+
editedText: 'hello\n world\t',
|
|
92
|
+
})
|
|
93
|
+
expect(result.success).toBe(true)
|
|
94
|
+
expect(result.removedWords).toHaveLength(0)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('diffTranscripts is case insensitive', () => {
|
|
98
|
+
const originalWords = createWords('hello', 'world')
|
|
99
|
+
const result = diffTranscripts({
|
|
100
|
+
originalWords,
|
|
101
|
+
editedText: 'Hello WORLD',
|
|
102
|
+
})
|
|
103
|
+
expect(result.success).toBe(true)
|
|
104
|
+
expect(result.removedWords).toHaveLength(0)
|
|
105
|
+
})
|