ГлавнаяБлог → Как построить пакетную транскрибацию архива записей

Как построить пакетную транскрибацию архива записей

Опубликовано 2026-08-18 · 7 мин чтения

Одна запись — это одна строка кода. Десять тысяч записей — совсем другая задача: что-нибудь отвалится на середине, процесс убьют, половина файлов уже будет обработана, и вам нужно будет знать, какая именно половина. Вот из чего собирается конвейер, который всё это переживает.

Что ломается при переходе от одного файла к десяти тысячам

Цикл по папке отлично работает, пока архив небольшой. Дальше вылезают четыре проблемы:

Каждый пункт ниже закрывает одну из этих четырёх проблем.

Шаг 1. Сделайте прогресс переживающим перезапуск

Самое важное решение: запись о сделанном должна пережить процесс. Достаточно каталога с результатами — наличие файла с текстом и есть состояние.

import pathlib

SRC = pathlib.Path("recordings")
OUT = pathlib.Path("transcripts")
OUT.mkdir(exist_ok=True)

def pending():
    for p in sorted(SRC.glob("*.mp3")):
        if not (OUT / (p.stem + ".txt")).exists():
            yield p

Теперь задача идемпотентна: запустили, убили, запустили снова — она продолжит ровно с места остановки и не заплатит за один файл дважды. Для архивов побольше так же работает таблица в SQLite со столбцом статуса, зато появляются запросы; принцип не меняется.

Записывайте результат атомарно, иначе убитый на середине записи процесс оставит обрезанный файл, который выглядит готовым:

tmp = out.with_suffix(".part")
tmp.write_text(text, encoding="utf-8")
tmp.rename(out)          # атомарно: либо файла нет, либо он целый

Шаг 2. Обрабатывайте несколько файлов сразу

Расшифровка — это ожидание сети, а не работа процессора, поэтому потоки здесь правильный инструмент, и хватает обычного пула:

import os
from concurrent.futures import ThreadPoolExecutor
from openai import OpenAI

client = OpenAI(base_url="https://voicesscribe.com/v1", api_key=os.environ["VS_KEY"], max_retries=0)

def transcribe(path):
    with path.open("rb") as f:
        return client.audio.transcriptions.create(model="whisper-1", file=f).text

with ThreadPoolExecutor(max_workers=4) as pool:
    for path, text in zip(pending(), pool.map(transcribe, pending())):
        (OUT / (path.stem + ".txt")).write_text(text, encoding="utf-8")

Значение max_workers подбирайте под лимит запросов в секунду на вашем тарифе, а не под число ядер. Шире лимита быстрее не станет — успешные запросы просто превратятся в 429.

Шаг 3. Повторяйте неудачи, а не успехи

Два класса ошибок требуют противоположного обращения. 5xx или таймаут повторить стоит, а 400 на битом файле будет одинаково падать вечно.

import time
from openai import APIStatusError, APIConnectionError

def with_retry(path, attempts=3):
    for n in range(attempts):
        try:
            return transcribe(path)
        except APIConnectionError:
            pass                                  # сетевой сбой — повторяем
        except APIStatusError as e:
            if e.status_code == 429 or e.status_code >= 500:
                pass                              # лимит или ошибка сервера — повторяем
            else:
                raise                             # 400/401 — повтор не поможет
        time.sleep(2 ** n)                        # 1 с, 2 с, 4 с
    raise RuntimeError(f"не вышло за {attempts} попытки: {path}")

Растущая пауза особенно важна для 429: мгновенный повтор держит вас за пределами лимита. Обратите внимание на max_retries=0 у клиента выше — иначе SDK повторяет тоже, и две политики начинают мешать друг другу.

Не позволяйте одному плохому файлу остановить прогон. Запишите в журнал, идите дальше, а со списком неудач разберитесь в конце — обычно это два-три файла из тысяч.

Шаг 4. Подготовьте звук до отправки

Именно здесь определяется счёт. Два правила делают почти всю работу:

# переводим весь архив одним проходом
find recordings -name '*.wav' -print0 |
  xargs -0 -P4 -I{} ffmpeg -loglevel error -i {} -ac 1 -ar 16000 -b:a 48k {}.mp3

Файлы больше 25 МБ всё равно придётся резать, но в моно на 48 кбит/с этот порог — примерно час звука, поэтому после конвертации большинству архивов резка вообще перестаёт быть нужна.

Шаг 5. Узнайте стоимость до запуска

Оплата идёт за минуты аудио, поэтому итог известен заранее — измерьте архив вместо того, чтобы гадать:

ffprobe -v error -show_entries format=duration -of csv=p=0 file.mp3
import subprocess

def minutes(path):
    out = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
                          "format=duration", "-of", "csv=p=0", str(path)],
                         capture_output=True, text=True).stdout
    return float(out) / 60

total = sum(minutes(p) for p in SRC.glob("*.mp3"))
print(f"{total:.0f} мин, {total * RATE:.2f} по текущей ставке")

Запускайте это до конвейера, а не после. Занимает минуту, стоит ноль, а разница — между запланированным расходом и сюрпризом.

Шаг 6. Следите за прогоном

Длинному пакету нужны три видимых числа: сделано, упало, скорость. Хватит счётчика, печатающего строку каждые сто файлов:

done = failed = 0
for path in pending():
    try:
        save(path, with_retry(path))
        done += 1
    except Exception as e:
        failed += 1
        print("FAIL", path.name, e)
    if (done + failed) % 100 == 0:
        print(f"{done} готово, {failed} упало")

Личный кабинет показывает тот же прогон с другой стороны — израсходованные минуты, запросы и историю по каждому обращению. Это самый быстрый способ убедиться, что конвейер делает именно то, что вы думаете.

Чего строить не надо

Попробуйте на своих записях. Регистрация занимает минуту, бесплатных минут хватает, чтобы оценить качество.

Получить ключ бесплатно

Частые вопросы

Сколько файлов можно отправлять одновременно?

Параллельность ограничена лимитом запросов в секунду на вашем тарифе, а не сервисом. Ставьте размер пула чуть ниже лимита: шире — это уже не скорость, а ответы 429.

Что будет, если процесс умрёт на середине?

Ничего не потеряется, если прогресс выводится из готовых файлов: на следующем запуске конвейер пропустит всё, для чего уже есть расшифровка, и продолжит дальше.

Платить ли повторно за файл, отправленный второй раз?

Запрос, вернувший ошибку, минуты аудио не расходует; успешная расшифровка — расходует. Поэтому и важно пропускать уже готовые файлы: деньги стоит именно повторение успешной работы.

Нужно ли конвертировать звук перед отправкой?

Для архива — да, выигрыш двойной: моно 16 кГц загружается заметно быстрее, а файлы перестают пересекать порог 25 МБ, и резать их не приходится.

Как оценить стоимость большого архива?

Сложите длительности через ffprobe и умножьте на свою ставку за минуту. Оплата идёт за минуты аудио, поэтому оценка до запуска совпадает с тем, что вы заплатите.

Читайте также