
Chunking аудіо для довготривалої транскрипції: розділення та зшивання за допомогою ffmpeg + TypeScript
https://ift.tt/lE1ZWek
API для розпізнавання мовлення — Groq Whisper, OpenAI Whisper та подібні — мають одну спільну проблему: обмеження розміру файлу. Жорстке обмеження Groq становить 25 МБ. Типове інтерв’ю тривалістю один годині з пристойною якістю може легко складати 80–150 МБ. Якщо надіслати це просто так, ви отримаєте помилку 413 або помилку через обмеження швидкості ще до початку транскрипції.
Рішення — розбиття на шматки: розділити аудіо на керовані частини, транскрибувати кожну, а потім зшити результати — з правильними часовими мітками. саме ця частина часто реалізується неправильно.
Ось підхід, який я застосував, побудований навколо ffmpeg та TypeScript.
Стратегія
якщо файл < 24 МБ → відправляти напряму (швидкий шлях)
інакше → розбити на 20-хвилинні сегменти при 32 кбіт/с моно → транскрибувати кожен → зшити
Комбінація 20 хвилин / 32 кбіт/с дозволяє кожному шматку бути well under 5 МБ, що забезпечує достатній запас простору нижче ліміту 25 МБ незалежно від формату джерела.
Отримання тривалості
Перед розбиттям потрібно знати, скільки шматків очікувати. ffprobe (у комплекті з ffmpeg) справляється з цим чисто:
import { execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
async function getAudioDurationSec(audioPath: string): Promise<number> {
const { stdout } = await execFileAsync("ffprobe", [
"-v, "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
audioPath,
]);
return parseFloat(stdout.trim());
}
Splitting with ffmpeg -f segment
Сегментний мікшер (segment muxer) — потрібний інструмент тут — він ділить на заданий інтервал, скидає часові мітки для кожного шматка та виводить пронумеровані файли:
async function splitAudioIntoChunks(
audioPath: string,
chunkDurationSec: number
): Promise<string[]> {
const dir = path.dirname(audioPath);
const base = path.basename(audioPath, ".mp3");
const pattern = path.join(dir, `${base}-chunk-%03d.mp3`);
await execFileAsync("ffmpeg", [
"-i", audioPath,
"-f", "segment",
"-segment_time", String(chunkDurationSec),
"-ar", "16000", // 16kHz耳ний стандарт для мовлення
"-ac", "1", // моно — вдвічі менше розміру порівняно з стерео
"-b:a", "32k", // 32кбіт/с → ~4.8МБ за 20хв
"-reset_timestamps", "1",
"-y",
pattern,
]);
// Збірка шматків у порядку
const chunkFiles: string[] = [];
let i = 0;
while (true) {
const chunkPath = path.join(dir, `${base}-chunk-${String(i).padStart(3, "0")}.mp3`);
if (!f s.existsSync(chunkPath)) break;
chunkFiles.push(chunkPath);
i++;
}
return chunkFiles;
}
Ключові прапори, які потрібно розуміти:
-
-ar 16000— 16 кГц частота дискретизації є стандартною для моделей Whisper; вищий рівень зайвий простір без покращення точності -
-ac 1— моно зменшує розмір файлу вдвічі; діарізація (відокремлення мовців) обробляється API STT, а не кількість аудіоканалів -
-reset_timestamps 1— часові позначки кожного шматка починаються з 0, що очікує API; ми додаватимемо реальне зсування самостійно під час зшивання -
%03d— нульовий заповнений індекс, щоб порядок glob/sort був узгоджений
Проблема з офсетом (і як виправити)
Коли ви транскрибуєте шматок 3 (який починається на 40:00 у вихідному файлі), API повертає сегменти на зразок [{ start: 0.5, end: 4.2, text: "Hello" }] — відносно початку шматка, а не відносно оригінального файлу.
Виправлення просте: відслідкуйте timeOffsetSec та додавайте його до кожного сегмента:
async function transcribeChunk(
filePath: string,
timeOffsetSec: number
): Promise<TranscriptResult> {
const transcription = await groq.audio.transcriptions.create({
file: fs.createReadStream(filePath),
model: "whisper-large-v3",
response_format: "verbose_json",
});
const rawSegments = (transcription as any).segments ?? [];
return {
text: transcription.text,
segments: rawSegments.map((seg: any, idx: number) => ({
id: seg.id ?? idx,
start: (seg.start ?? 0) + timeOffsetSec, // ← ключовий рядок
end: (seg.end ?? 0) + timeOffsetSec,
text: seg.text,
})),
};
}
Зшивка всього разом
export async function transcribeAudio(audioPath: string): Promise<TranscriptResult> {
const stats = fs.statSync(audioPath);
const GROQ_MAX_BYTES = 24 * 1024 * 1024;
const CHUNK_DURATION_SEC = 20 * 60; // 20 хвилин
// Швидкий шлях
if (stats.size <= GROQ_MAX_BYTES) {
return transcribeChunk(audioPath, 0);
}
// Повільний шлях
const chunkFiles = await splitAudioIntoChunks(audioPath, CHUNK_DURATION_SEC);
const results: TranscriptResult[] = [];
for (let i = 0; i < chunkFiles.length; i++) {
const timeOffsetSec = i * CHUNK_DURATION_SEC;
const result = await transcribeChunk(chunkFiles[i], timeOffsetSec);
results.push(result);
// Очистка одразу — немає сенсу тримати тимчасові файли в пам’яті
fs.unlink(chunkFiles[i], () => undefined);
}
// Зшивка: з’єднати текст через пробіл, глобально переіндексувати сегменти
return {
text: results.map(r => r.text).join(" "),
segments: results
.flatMap(r => r.segments)
.map((seg, idx) => ({ ...seg, id: idx })),
};
}
Обчислення timeOffsetSec = i * CHUNK_DURATION_SEC працює, бо -reset_timestamps 1 робить кожен шматок з нульових часових міток, тому реальний зсув шматка N дорівнює саме N × chunkDuration.
Практичні нотатки
Дисковий простір: шматки видаляються одразу після транскрипції. Для файлу тривалістю 2 години ви тимчасово матимете ~2 шматки на диску (поточний, що обробляється, та попередній, який видаляється). Використовуйте тимчасові файли в os.tmpdir() або призначеній тимчашовій директорії.
Обхідні слова: Слова, що знаходяться прямо на межі шматка, інколи можуть бути розірвані на половину висловлювання. Більшість STT API обробляють це коректно, але якщо потрібно ідеальне керування межами, додайте 2–3 секунд перекриття між шматками та видаліть перекриту частину зі сегментів перед зшиванням.
Обмеження швидкості: Якщо обробляєте багато файлів, додайте невелику затримку між запитами до шматків. Повідомлення про помилки Groq містять "Please try again in Xm Ys" — розпізнайте це та дотримуйтесь:
function parseRetryAfterMs(message: string): number | null {
const match = message.match(/try again in (\d+)m(\d+)s/);
if (match) return (parseInt(match[1]) * 60 + parseInt(match[2]) + 5) * 1000;
const secMatch = message.match(/try again in (\d+)s/);
if (secMatch) return (parseInt(secMatch[1]) + 5) * 1000;
return null;
}
Інші провайдери: Цей шаблон працює з будь-яким API STT з обмеженим розміром. Замість внутрішнього transcribeChunk використайте OpenAI, AssemblyAI або Sarvam — логіка розбиття та зшивання залишається тією ж.
Підсумок
| Крок | Інструмент | Ключова деталь |
|---|---|---|
| Проба тривалості | ffprobe |
Парсити float з stdout |
| Розбиття | ffmpeg -f segment |
16 кГц, моно, 32 кбіт/с, скидання часових міток |
| Транскрибування | Groq / OpenAI / тощо |
verbose_json для даних сегментів |
| Зшивка часових міток | TypeScript | seg.start + i * chunkDuration |
| Очистка | fs.unlink |
Асинхронно, у вогні після кожного шматка |
Повна реалізація з Groq Whisper приблизно 150 рядків і обробляє швидкий шлях (малі файли — напряму), повільний шлях (розбиття) та повідомлення про повторні запити через обмеження швидкості. Працює у будь-якому середовищі Node.js, де встановлено ffmpeg.
HI-FI News
via DEV Community: typescript https://ift.tt/1DNyL2R
22 березня 2026 р. о 16:39. МІСТИТЬ українською. Тільки текст, який було перекладено.
March 21, 2026 at 04:39PM

Залишити відповідь