@larkup/tool-video-audio 0.2.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 +176 -0
- package/README.md +55 -0
- package/dist/audio-processor.d.ts +65 -0
- package/dist/audio-processor.js +507 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +15 -0
- package/dist/url-importer.d.ts +40 -0
- package/dist/url-importer.js +291 -0
- package/dist/video-processor.d.ts +127 -0
- package/dist/video-processor.js +469 -0
- package/package.json +51 -0
- package/src/audio-processor.ts +666 -0
- package/src/index.ts +44 -0
- package/src/nodejs-whisper.d.ts +25 -0
- package/src/url-importer.ts +371 -0
- package/src/video-processor.ts +687 -0
- package/tool.manifest.json +63 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio processing pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Transcription strategies:
|
|
5
|
+
* 1. Explicitly selected Audio Provider API (OpenAI, Groq, Deepgram, or ElevenLabs)
|
|
6
|
+
* 2. Local Whisper.cpp — fully offline via nodejs-whisper (optional)
|
|
7
|
+
*
|
|
8
|
+
* The transcription is split into timestamped chunks for granular
|
|
9
|
+
* retrieval in the RAG pipeline.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface TranscriptChunk {
|
|
13
|
+
text: string;
|
|
14
|
+
startSecs: number;
|
|
15
|
+
endSecs: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TranscriptionOrigin {
|
|
19
|
+
kind: 'youtube-manual' | 'youtube-auto' | 'provider-stt' | 'local-stt';
|
|
20
|
+
provider?: string;
|
|
21
|
+
language?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TranscriptionResult {
|
|
25
|
+
/** Full transcript text */
|
|
26
|
+
fullText: string;
|
|
27
|
+
/** Timestamped chunks for granular indexing */
|
|
28
|
+
chunks: TranscriptChunk[];
|
|
29
|
+
/** Detected language */
|
|
30
|
+
language?: string;
|
|
31
|
+
/** Duration of the audio in seconds */
|
|
32
|
+
durationSecs: number;
|
|
33
|
+
/** How the transcript was produced, so callers can judge source authority. */
|
|
34
|
+
origin?: TranscriptionOrigin;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TranscriptionOptions {
|
|
38
|
+
/** The provider name (e.g. 'openai', 'deepgram', 'local') */
|
|
39
|
+
provider?: string;
|
|
40
|
+
/** The API key to use */
|
|
41
|
+
apiKey?: string;
|
|
42
|
+
/** Chunk duration in seconds for splitting transcript (default: 30) */
|
|
43
|
+
chunkDurationSecs?: number;
|
|
44
|
+
/** Language hint (e.g., "en", "de") */
|
|
45
|
+
language?: string;
|
|
46
|
+
/** Short context such as the media title, used to preserve names and infer Arabic. */
|
|
47
|
+
context?: string;
|
|
48
|
+
/** Reports completed transcription work and its current unit. */
|
|
49
|
+
onProgress?: (
|
|
50
|
+
current: number,
|
|
51
|
+
total: number,
|
|
52
|
+
message: string,
|
|
53
|
+
unit?: string,
|
|
54
|
+
) => void | Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DEEPGRAM_TRANSCRIPTION_URL = 'https://api.deepgram.com/v1/listen';
|
|
58
|
+
const AUTO_LANGUAGE = 'auto';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Infer only languages that cannot reliably use Deepgram's automatic language
|
|
62
|
+
* detection. Other text intentionally returns undefined so the provider can
|
|
63
|
+
* perform its own detection.
|
|
64
|
+
*/
|
|
65
|
+
export function inferLanguageHintFromText(text?: string): string | undefined {
|
|
66
|
+
if (!text) return undefined;
|
|
67
|
+
const letters = text.match(/\p{Letter}/gu) ?? [];
|
|
68
|
+
if (letters.length === 0) return undefined;
|
|
69
|
+
|
|
70
|
+
const arabicLetters = text.match(/\p{Script=Arabic}/gu)?.length ?? 0;
|
|
71
|
+
return arabicLetters >= 2 && arabicLetters / letters.length >= 0.2 ? 'ar' : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the Deepgram request URL without reading environment or provider state,
|
|
76
|
+
* making provider routing and language behavior straightforward to test.
|
|
77
|
+
*/
|
|
78
|
+
export function buildDeepgramTranscriptionUrl(
|
|
79
|
+
options: Pick<TranscriptionOptions, 'language' | 'context'> = {},
|
|
80
|
+
): string {
|
|
81
|
+
const configuredLanguage = normalizeLanguage(options.language);
|
|
82
|
+
const language = configuredLanguage ?? inferLanguageHintFromText(options.context);
|
|
83
|
+
const url = new URL(DEEPGRAM_TRANSCRIPTION_URL);
|
|
84
|
+
|
|
85
|
+
url.searchParams.set('model', language ? 'nova-3' : 'nova-3-general');
|
|
86
|
+
url.searchParams.set('smart_format', 'true');
|
|
87
|
+
url.searchParams.set('punctuate', 'true');
|
|
88
|
+
url.searchParams.set('diarize', 'false');
|
|
89
|
+
|
|
90
|
+
if (language) {
|
|
91
|
+
url.searchParams.set('language', language);
|
|
92
|
+
} else {
|
|
93
|
+
url.searchParams.set('detect_language', 'true');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const keyterm of extractContextKeyterms(options.context)) {
|
|
97
|
+
url.searchParams.append('keyterm', keyterm);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return url.toString();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Transcribe an audio file and return timestamped chunks.
|
|
105
|
+
*/
|
|
106
|
+
export async function transcribeAudio(
|
|
107
|
+
audioPath: string,
|
|
108
|
+
options: TranscriptionOptions = {},
|
|
109
|
+
): Promise<TranscriptionResult> {
|
|
110
|
+
const provider = requireTranscriptionProvider(options);
|
|
111
|
+
await reportProgress(
|
|
112
|
+
options,
|
|
113
|
+
0,
|
|
114
|
+
1,
|
|
115
|
+
`Transcribing with ${formatProviderName(provider)}...`,
|
|
116
|
+
'request',
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const result =
|
|
120
|
+
provider === 'local'
|
|
121
|
+
? await transcribeLocal(audioPath, options)
|
|
122
|
+
: await transcribeViaApi(audioPath, options);
|
|
123
|
+
|
|
124
|
+
await reportProgress(options, 1, 1, 'Transcription complete.', 'request');
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Full pipeline: transcribe + chunk for an audio file.
|
|
130
|
+
*/
|
|
131
|
+
export async function processAudio(
|
|
132
|
+
audioPath: string,
|
|
133
|
+
options: TranscriptionOptions = {},
|
|
134
|
+
): Promise<TranscriptionResult> {
|
|
135
|
+
const provider = requireTranscriptionProvider(options);
|
|
136
|
+
if (provider !== 'local') {
|
|
137
|
+
const { promises: fs } = await import('node:fs');
|
|
138
|
+
const stat = await fs.stat(audioPath);
|
|
139
|
+
const durationSecs = await probeAudioDuration(audioPath);
|
|
140
|
+
if (stat.size > 24 * 1024 * 1024 || durationSecs > 10 * 60) {
|
|
141
|
+
return transcribeLongAudio(audioPath, durationSecs, options);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return transcribeAudio(audioPath, options);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function transcribeLongAudio(
|
|
148
|
+
audioPath: string,
|
|
149
|
+
durationSecs: number,
|
|
150
|
+
options: TranscriptionOptions,
|
|
151
|
+
): Promise<TranscriptionResult> {
|
|
152
|
+
const { promises: fs } = await import('node:fs');
|
|
153
|
+
const path = await import('node:path');
|
|
154
|
+
const os = await import('node:os');
|
|
155
|
+
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'larkup-transcription-'));
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await reportProgress(options, 0, 100, 'Preparing long audio for transcription...', '%');
|
|
159
|
+
const parts = await splitAudio(audioPath, outputDir, 10 * 60, (progress) =>
|
|
160
|
+
reportProgress(
|
|
161
|
+
options,
|
|
162
|
+
Math.round(progress * 20),
|
|
163
|
+
100,
|
|
164
|
+
`Preparing long audio (${Math.round(progress * 100)}%)...`,
|
|
165
|
+
'%',
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
if (parts.length === 0) throw new Error('Audio splitting produced no transcription chunks.');
|
|
169
|
+
let completedParts = 0;
|
|
170
|
+
let progressQueue = Promise.resolve();
|
|
171
|
+
const queueProgress = (current: number, message: string) => {
|
|
172
|
+
progressQueue = progressQueue.then(() =>
|
|
173
|
+
reportProgress(options, Math.round(20 + (current / parts.length) * 80), 100, message, '%'),
|
|
174
|
+
);
|
|
175
|
+
return progressQueue;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
await queueProgress(0, `Transcribing 0 of ${parts.length} audio parts...`);
|
|
179
|
+
const results = await mapWithConcurrency(parts, 2, async (part, index) => {
|
|
180
|
+
const result = await transcribeViaApi(part, options);
|
|
181
|
+
const offset = index * 10 * 60;
|
|
182
|
+
const adjustedResult = {
|
|
183
|
+
...result,
|
|
184
|
+
chunks: result.chunks.map((chunk) => ({
|
|
185
|
+
...chunk,
|
|
186
|
+
startSecs: chunk.startSecs + offset,
|
|
187
|
+
endSecs: chunk.endSecs + offset,
|
|
188
|
+
})),
|
|
189
|
+
};
|
|
190
|
+
completedParts += 1;
|
|
191
|
+
await queueProgress(
|
|
192
|
+
completedParts,
|
|
193
|
+
`Transcribed ${completedParts} of ${parts.length} audio parts.`,
|
|
194
|
+
);
|
|
195
|
+
return adjustedResult;
|
|
196
|
+
});
|
|
197
|
+
await progressQueue;
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
fullText: results
|
|
201
|
+
.map((result) => result.fullText)
|
|
202
|
+
.filter(Boolean)
|
|
203
|
+
.join(' '),
|
|
204
|
+
chunks: results.flatMap((result) => result.chunks),
|
|
205
|
+
language: results.find((result) => result.language)?.language,
|
|
206
|
+
durationSecs: durationSecs || results.reduce((sum, result) => sum + result.durationSecs, 0),
|
|
207
|
+
origin: results.find((result) => result.origin)?.origin,
|
|
208
|
+
};
|
|
209
|
+
} finally {
|
|
210
|
+
await fs.rm(outputDir, { recursive: true, force: true }).catch(() => {});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* ------------------------------------------------------------------ */
|
|
215
|
+
/* API-based transcription */
|
|
216
|
+
/* ------------------------------------------------------------------ */
|
|
217
|
+
|
|
218
|
+
async function transcribeViaApi(
|
|
219
|
+
audioPath: string,
|
|
220
|
+
options: TranscriptionOptions,
|
|
221
|
+
): Promise<TranscriptionResult> {
|
|
222
|
+
const { promises: fs } = await import('node:fs');
|
|
223
|
+
const audioBuffer = await fs.readFile(audioPath);
|
|
224
|
+
const path = await import('node:path');
|
|
225
|
+
const fileName = path.basename(audioPath);
|
|
226
|
+
|
|
227
|
+
const provider = requireTranscriptionProvider(options);
|
|
228
|
+
const apiKey = options.apiKey;
|
|
229
|
+
|
|
230
|
+
if (!apiKey) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`API Key is required for ${provider} transcription. ` +
|
|
233
|
+
`Please configure it in the Tool Settings.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const chunkDuration = options.chunkDurationSecs ?? 30;
|
|
238
|
+
const supportedProviders = new Set(['openai', 'groq', 'deepgram', 'elevenlabs']);
|
|
239
|
+
if (!supportedProviders.has(provider)) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`${provider} does not provide a configured speech-to-text integration. ` +
|
|
242
|
+
'Choose OpenAI, Groq, Deepgram, ElevenLabs, or Local Whisper in Tool Settings.',
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 1. Handle Deepgram Native API
|
|
247
|
+
if (provider === 'deepgram') {
|
|
248
|
+
const dgUrl = buildDeepgramTranscriptionUrl(options);
|
|
249
|
+
const res = await fetch(dgUrl, {
|
|
250
|
+
method: 'POST',
|
|
251
|
+
headers: {
|
|
252
|
+
Authorization: `Token ${apiKey}`,
|
|
253
|
+
'Content-Type': contentTypeForFile(fileName),
|
|
254
|
+
},
|
|
255
|
+
body: audioBuffer,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (!res.ok) {
|
|
259
|
+
const err = await res.text();
|
|
260
|
+
throw new Error(`Deepgram API error (${res.status}): ${err}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const data = await res.json();
|
|
264
|
+
const channel = data.results?.channels?.[0];
|
|
265
|
+
const alt = channel?.alternatives?.[0];
|
|
266
|
+
const text = alt?.transcript || '';
|
|
267
|
+
const words = alt?.words || [];
|
|
268
|
+
const requestedLanguage =
|
|
269
|
+
normalizeLanguage(options.language) ?? inferLanguageHintFromText(options.context);
|
|
270
|
+
const detectedLanguage =
|
|
271
|
+
channel?.detected_language ??
|
|
272
|
+
alt?.languages?.[0] ??
|
|
273
|
+
data.metadata?.language ??
|
|
274
|
+
requestedLanguage;
|
|
275
|
+
|
|
276
|
+
const chunks: TranscriptChunk[] = [];
|
|
277
|
+
let currentText = '';
|
|
278
|
+
let chunkStart = words[0]?.start ?? 0;
|
|
279
|
+
let chunkEnd = words[0]?.end ?? 0;
|
|
280
|
+
|
|
281
|
+
for (const w of words) {
|
|
282
|
+
if (w.start - chunkStart >= chunkDuration && currentText.trim()) {
|
|
283
|
+
chunks.push({
|
|
284
|
+
text: currentText.trim(),
|
|
285
|
+
startSecs: chunkStart,
|
|
286
|
+
endSecs: chunkEnd,
|
|
287
|
+
});
|
|
288
|
+
currentText = w.punctuated_word || w.word;
|
|
289
|
+
chunkStart = w.start;
|
|
290
|
+
chunkEnd = w.end;
|
|
291
|
+
} else {
|
|
292
|
+
currentText += (currentText ? ' ' : '') + (w.punctuated_word || w.word);
|
|
293
|
+
chunkEnd = w.end;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (currentText.trim()) {
|
|
297
|
+
chunks.push({ text: currentText.trim(), startSecs: chunkStart, endSecs: chunkEnd });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const language = typeof detectedLanguage === 'string' ? detectedLanguage : undefined;
|
|
301
|
+
return {
|
|
302
|
+
fullText: text,
|
|
303
|
+
chunks,
|
|
304
|
+
language,
|
|
305
|
+
durationSecs: data.metadata?.duration ?? 0,
|
|
306
|
+
origin: { kind: 'provider-stt', provider, language },
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 2. Handle OpenAI-compatible APIs (OpenAI, Groq, etc)
|
|
311
|
+
let url = 'https://api.openai.com/v1/audio/transcriptions';
|
|
312
|
+
let model = 'whisper-1';
|
|
313
|
+
|
|
314
|
+
if (provider === 'groq') {
|
|
315
|
+
url = 'https://api.groq.com/openai/v1/audio/transcriptions';
|
|
316
|
+
model = 'whisper-large-v3';
|
|
317
|
+
} else if (provider === 'elevenlabs') {
|
|
318
|
+
url = 'https://api.elevenlabs.io/v1/speech-to-text';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const formData = new FormData();
|
|
322
|
+
formData.append('file', new Blob([audioBuffer]), fileName);
|
|
323
|
+
const language = normalizeLanguage(options.language);
|
|
324
|
+
|
|
325
|
+
if (provider !== 'elevenlabs') {
|
|
326
|
+
formData.append('model', model);
|
|
327
|
+
formData.append('response_format', 'verbose_json');
|
|
328
|
+
formData.append('timestamp_granularities[]', 'segment');
|
|
329
|
+
if (language) {
|
|
330
|
+
formData.append('language', language);
|
|
331
|
+
}
|
|
332
|
+
const context = normalizeContext(options.context);
|
|
333
|
+
if (context) {
|
|
334
|
+
formData.append('prompt', context);
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
formData.append('model_id', 'scribe_v2');
|
|
338
|
+
if (language) formData.append('language_code', language);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const res = await fetch(url, {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
headers: {
|
|
344
|
+
Authorization: provider === 'elevenlabs' ? apiKey : `Bearer ${apiKey}`,
|
|
345
|
+
...(provider === 'elevenlabs' ? { 'xi-api-key': apiKey } : {}),
|
|
346
|
+
},
|
|
347
|
+
body: formData,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
if (!res.ok) {
|
|
351
|
+
const err = await res.text();
|
|
352
|
+
throw new Error(`${provider} API error (${res.status}): ${err}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const data = await res.json();
|
|
356
|
+
|
|
357
|
+
// ElevenLabs format is slightly different
|
|
358
|
+
if (provider === 'elevenlabs') {
|
|
359
|
+
const words = data.words || [];
|
|
360
|
+
const chunks: TranscriptChunk[] = [];
|
|
361
|
+
let currentText = '';
|
|
362
|
+
let chunkStart = words[0]?.start ?? 0;
|
|
363
|
+
let chunkEnd = words[0]?.end ?? 0;
|
|
364
|
+
|
|
365
|
+
for (const w of words) {
|
|
366
|
+
if (w.start - chunkStart >= chunkDuration && currentText.trim()) {
|
|
367
|
+
chunks.push({ text: currentText.trim(), startSecs: chunkStart, endSecs: chunkEnd });
|
|
368
|
+
currentText = w.text;
|
|
369
|
+
chunkStart = w.start;
|
|
370
|
+
chunkEnd = w.end;
|
|
371
|
+
} else {
|
|
372
|
+
currentText += (currentText ? ' ' : '') + w.text;
|
|
373
|
+
chunkEnd = w.end;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (currentText.trim()) {
|
|
377
|
+
chunks.push({ text: currentText.trim(), startSecs: chunkStart, endSecs: chunkEnd });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
fullText: data.text,
|
|
382
|
+
chunks,
|
|
383
|
+
language: data.language_code ?? language,
|
|
384
|
+
durationSecs: words[words.length - 1]?.end ?? 0,
|
|
385
|
+
origin: {
|
|
386
|
+
kind: 'provider-stt',
|
|
387
|
+
provider,
|
|
388
|
+
language: data.language_code ?? language,
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// OpenAI/Groq compatible format
|
|
394
|
+
const segments = data.segments || [];
|
|
395
|
+
const chunks = mergeSegmentsIntoChunks(segments, chunkDuration);
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
fullText: data.text,
|
|
399
|
+
chunks,
|
|
400
|
+
language: data.language,
|
|
401
|
+
durationSecs: data.duration ?? 0,
|
|
402
|
+
origin: {
|
|
403
|
+
kind: 'provider-stt',
|
|
404
|
+
provider,
|
|
405
|
+
language: data.language ?? language,
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/* ------------------------------------------------------------------ */
|
|
411
|
+
/* Local Whisper transcription */
|
|
412
|
+
/* ------------------------------------------------------------------ */
|
|
413
|
+
|
|
414
|
+
async function transcribeLocal(
|
|
415
|
+
audioPath: string,
|
|
416
|
+
options: TranscriptionOptions,
|
|
417
|
+
): Promise<TranscriptionResult> {
|
|
418
|
+
// Try to load nodejs-whisper
|
|
419
|
+
let whisper: any;
|
|
420
|
+
try {
|
|
421
|
+
whisper = await import('nodejs-whisper');
|
|
422
|
+
} catch {
|
|
423
|
+
throw new Error(
|
|
424
|
+
'Local Whisper is not installed. ' +
|
|
425
|
+
'Install it with: pnpm add nodejs-whisper -w ' +
|
|
426
|
+
'or switch to API-based transcription.',
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const result = await whisper.nodewhisper(audioPath, {
|
|
431
|
+
modelName: 'base',
|
|
432
|
+
autoDownloadModelName: 'base',
|
|
433
|
+
whisperOptions: {
|
|
434
|
+
outputInText: true,
|
|
435
|
+
outputInVtt: false,
|
|
436
|
+
outputInSrt: false,
|
|
437
|
+
outputInCsv: false,
|
|
438
|
+
translateToEnglish: false,
|
|
439
|
+
wordTimestamps: true,
|
|
440
|
+
language: normalizeLanguage(options.language) ?? AUTO_LANGUAGE,
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const fullText = typeof result === 'string' ? result : String(result);
|
|
445
|
+
const chunkDuration = options.chunkDurationSecs ?? 30;
|
|
446
|
+
|
|
447
|
+
// Without timestamps from local whisper, create fixed-size chunks
|
|
448
|
+
const chunks = splitTextIntoChunks(fullText, chunkDuration);
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
fullText,
|
|
452
|
+
chunks,
|
|
453
|
+
language: normalizeLanguage(options.language),
|
|
454
|
+
durationSecs: 0, // Not available from basic whisper output
|
|
455
|
+
origin: {
|
|
456
|
+
kind: 'local-stt',
|
|
457
|
+
provider: 'local',
|
|
458
|
+
language: normalizeLanguage(options.language),
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* ------------------------------------------------------------------ */
|
|
464
|
+
/* Chunking helpers */
|
|
465
|
+
/* ------------------------------------------------------------------ */
|
|
466
|
+
|
|
467
|
+
function mergeSegmentsIntoChunks(
|
|
468
|
+
segments: { start: number; end: number; text: string }[],
|
|
469
|
+
chunkDurationSecs: number,
|
|
470
|
+
): TranscriptChunk[] {
|
|
471
|
+
if (segments.length === 0) return [];
|
|
472
|
+
|
|
473
|
+
const chunks: TranscriptChunk[] = [];
|
|
474
|
+
let currentText = '';
|
|
475
|
+
let chunkStart = segments[0].start;
|
|
476
|
+
let chunkEnd = segments[0].end;
|
|
477
|
+
|
|
478
|
+
for (const seg of segments) {
|
|
479
|
+
if (seg.start - chunkStart >= chunkDurationSecs && currentText.trim()) {
|
|
480
|
+
chunks.push({
|
|
481
|
+
text: currentText.trim(),
|
|
482
|
+
startSecs: chunkStart,
|
|
483
|
+
endSecs: chunkEnd,
|
|
484
|
+
});
|
|
485
|
+
currentText = seg.text;
|
|
486
|
+
chunkStart = seg.start;
|
|
487
|
+
chunkEnd = seg.end;
|
|
488
|
+
} else {
|
|
489
|
+
currentText += ' ' + seg.text;
|
|
490
|
+
chunkEnd = seg.end;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (currentText.trim()) {
|
|
495
|
+
chunks.push({
|
|
496
|
+
text: currentText.trim(),
|
|
497
|
+
startSecs: chunkStart,
|
|
498
|
+
endSecs: chunkEnd,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return chunks;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function splitTextIntoChunks(text: string, chunkDurationSecs: number): TranscriptChunk[] {
|
|
506
|
+
// Approximate: split text into equal parts based on word count
|
|
507
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
508
|
+
if (words.length === 0) return [];
|
|
509
|
+
|
|
510
|
+
// Assume ~150 words per minute of speech
|
|
511
|
+
const wordsPerChunk = Math.ceil((150 / 60) * chunkDurationSecs);
|
|
512
|
+
const chunks: TranscriptChunk[] = [];
|
|
513
|
+
let offset = 0;
|
|
514
|
+
|
|
515
|
+
for (let i = 0; i < words.length; i += wordsPerChunk) {
|
|
516
|
+
const chunkWords = words.slice(i, i + wordsPerChunk);
|
|
517
|
+
const startSecs = offset;
|
|
518
|
+
const endSecs = offset + chunkDurationSecs;
|
|
519
|
+
chunks.push({
|
|
520
|
+
text: chunkWords.join(' '),
|
|
521
|
+
startSecs,
|
|
522
|
+
endSecs,
|
|
523
|
+
});
|
|
524
|
+
offset = endSecs;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return chunks;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function contentTypeForFile(fileName: string): string {
|
|
531
|
+
const extension = fileName.split('.').pop()?.toLowerCase();
|
|
532
|
+
if (extension === 'wav') return 'audio/wav';
|
|
533
|
+
if (extension === 'm4a' || extension === 'mp4') return 'audio/mp4';
|
|
534
|
+
if (extension === 'ogg' || extension === 'opus') return 'audio/ogg';
|
|
535
|
+
if (extension === 'webm') return 'audio/webm';
|
|
536
|
+
return 'audio/mpeg';
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function normalizeLanguage(language?: string): string | undefined {
|
|
540
|
+
const normalized = language?.trim();
|
|
541
|
+
return !normalized || normalized.toLowerCase() === AUTO_LANGUAGE ? undefined : normalized;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function normalizeContext(context?: string): string | undefined {
|
|
545
|
+
const normalized = context?.replace(/\s+/g, ' ').trim();
|
|
546
|
+
return normalized ? normalized.slice(0, 500) : undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function extractContextKeyterms(context?: string): string[] {
|
|
550
|
+
const normalized = normalizeContext(context);
|
|
551
|
+
if (!normalized) return [];
|
|
552
|
+
|
|
553
|
+
const candidates =
|
|
554
|
+
normalized
|
|
555
|
+
.replace(/https?:\/\/\S+/gi, ' ')
|
|
556
|
+
.match(/[\p{Letter}\p{Number}][\p{Letter}\p{Mark}\p{Number}'’_-]{2,}/gu) ?? [];
|
|
557
|
+
const unique = new Map<string, string>();
|
|
558
|
+
for (const candidate of candidates) {
|
|
559
|
+
const key = candidate.toLocaleLowerCase();
|
|
560
|
+
if (!unique.has(key)) unique.set(key, candidate.slice(0, 48));
|
|
561
|
+
if (unique.size >= 12) break;
|
|
562
|
+
}
|
|
563
|
+
return [...unique.values()];
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function reportProgress(
|
|
567
|
+
options: TranscriptionOptions,
|
|
568
|
+
current: number,
|
|
569
|
+
total: number,
|
|
570
|
+
message: string,
|
|
571
|
+
unit?: string,
|
|
572
|
+
): Promise<void> {
|
|
573
|
+
try {
|
|
574
|
+
await options.onProgress?.(current, total, message, unit);
|
|
575
|
+
} catch {
|
|
576
|
+
// Progress persistence must not invalidate an otherwise valid transcript.
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function requireTranscriptionProvider(options: TranscriptionOptions): string {
|
|
581
|
+
const provider = options.provider?.trim();
|
|
582
|
+
if (!provider) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
'An Audio Provider is required for transcription. Choose one in Marketplace Tool Settings; transcription never falls back to OpenAI.',
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
return provider;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function formatProviderName(provider: string): string {
|
|
591
|
+
return provider
|
|
592
|
+
.split(/[_-]/)
|
|
593
|
+
.filter(Boolean)
|
|
594
|
+
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
|
595
|
+
.join(' ');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function importFfmpeg() {
|
|
599
|
+
return import('fluent-ffmpeg');
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function probeAudioDuration(audioPath: string): Promise<number> {
|
|
603
|
+
const ffmpeg = await importFfmpeg();
|
|
604
|
+
return new Promise((resolve, reject) => {
|
|
605
|
+
ffmpeg.default.ffprobe(audioPath, (error: Error | null, data: any) => {
|
|
606
|
+
if (error) reject(error);
|
|
607
|
+
else resolve(Number(data.format?.duration ?? 0));
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function splitAudio(
|
|
613
|
+
audioPath: string,
|
|
614
|
+
outputDir: string,
|
|
615
|
+
segmentSecs: number,
|
|
616
|
+
onProgress?: (progress: number) => void | Promise<void>,
|
|
617
|
+
): Promise<string[]> {
|
|
618
|
+
const ffmpeg = await importFfmpeg();
|
|
619
|
+
const path = await import('node:path');
|
|
620
|
+
const { promises: fs } = await import('node:fs');
|
|
621
|
+
const pattern = path.join(outputDir, 'part_%04d.mp3');
|
|
622
|
+
|
|
623
|
+
await new Promise<void>((resolve, reject) => {
|
|
624
|
+
ffmpeg
|
|
625
|
+
.default(audioPath)
|
|
626
|
+
.noVideo()
|
|
627
|
+
.audioChannels(1)
|
|
628
|
+
.audioFrequency(16_000)
|
|
629
|
+
.audioCodec('libmp3lame')
|
|
630
|
+
.audioBitrate('32k')
|
|
631
|
+
.format('segment')
|
|
632
|
+
.outputOptions([`-segment_time ${segmentSecs}`, '-reset_timestamps 1'])
|
|
633
|
+
.output(pattern)
|
|
634
|
+
.on('progress', (progress: { percent?: number }) => {
|
|
635
|
+
if (typeof progress.percent === 'number') {
|
|
636
|
+
void onProgress?.(Math.max(0, Math.min(1, progress.percent / 100)));
|
|
637
|
+
}
|
|
638
|
+
})
|
|
639
|
+
.on('end', () => resolve())
|
|
640
|
+
.on('error', reject)
|
|
641
|
+
.run();
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
return (await fs.readdir(outputDir))
|
|
645
|
+
.filter((name) => /^part_\d+\.mp3$/.test(name))
|
|
646
|
+
.sort()
|
|
647
|
+
.map((name) => path.join(outputDir, name));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function mapWithConcurrency<T, R>(
|
|
651
|
+
items: T[],
|
|
652
|
+
concurrency: number,
|
|
653
|
+
callback: (item: T, index: number) => Promise<R>,
|
|
654
|
+
): Promise<R[]> {
|
|
655
|
+
const results = new Array<R>(items.length);
|
|
656
|
+
let cursor = 0;
|
|
657
|
+
await Promise.all(
|
|
658
|
+
Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
659
|
+
while (cursor < items.length) {
|
|
660
|
+
const index = cursor++;
|
|
661
|
+
results[index] = await callback(items[index], index);
|
|
662
|
+
}
|
|
663
|
+
}),
|
|
664
|
+
);
|
|
665
|
+
return results;
|
|
666
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @larkup/tool-video-audio
|
|
3
|
+
*
|
|
4
|
+
* Marketplace tool for indexing video and audio files.
|
|
5
|
+
* Extracts transcripts, keyframes, and scene descriptions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
processVideo,
|
|
10
|
+
extractFrames,
|
|
11
|
+
extractSceneFrames,
|
|
12
|
+
createVideoSamplingPlan,
|
|
13
|
+
createEndingSamplingPlan,
|
|
14
|
+
buildMultimodalSegments,
|
|
15
|
+
} from './video-processor.js';
|
|
16
|
+
export {
|
|
17
|
+
buildDeepgramTranscriptionUrl,
|
|
18
|
+
inferLanguageHintFromText,
|
|
19
|
+
processAudio,
|
|
20
|
+
transcribeAudio,
|
|
21
|
+
} from './audio-processor.js';
|
|
22
|
+
export { importMediaUrl, inspectMediaUrl, parseYouTubeJson3Transcript } from './url-importer.js';
|
|
23
|
+
export type {
|
|
24
|
+
MultimodalSegment,
|
|
25
|
+
TimedText,
|
|
26
|
+
EndingSamplingPlan,
|
|
27
|
+
VideoSamplingPlan,
|
|
28
|
+
VideoProcessOptions,
|
|
29
|
+
VideoProcessResult,
|
|
30
|
+
} from './video-processor.js';
|
|
31
|
+
export type {
|
|
32
|
+
TranscriptChunk,
|
|
33
|
+
TranscriptionOptions,
|
|
34
|
+
TranscriptionOrigin,
|
|
35
|
+
TranscriptionResult,
|
|
36
|
+
} from './audio-processor.js';
|
|
37
|
+
export type { ImportedMedia, MediaType, UrlImportOptions, UrlInspection } from './url-importer.js';
|
|
38
|
+
|
|
39
|
+
/** Tool metadata for the marketplace loader. */
|
|
40
|
+
export const TOOL_META = {
|
|
41
|
+
id: 'video-audio',
|
|
42
|
+
name: 'Video & Audio',
|
|
43
|
+
version: '0.1.0',
|
|
44
|
+
} as const;
|