Главная → Блог → Как построить пакетную транскрибацию архива записей
Как построить пакетную транскрибацию архива записей
Одна запись — это одна строка кода. Десять тысяч записей — совсем другая задача: что-нибудь отвалится на середине, процесс убьют, половина файлов уже будет обработана, и вам нужно будет знать, какая именно половина. Вот из чего собирается конвейер, который всё это переживает.
Что ломается при переходе от одного файла к десяти тысячам
Цикл по папке отлично работает, пока архив небольшой. Дальше вылезают четыре проблемы:
- Что-нибудь обязательно упадёт. На десяти тысячах запросов несколько наткнутся на сетевой сбой или 5xx. Без повторных попыток цикл умрёт на файле 4312.
- Процесс убьют. Деплой, нехватка памяти, закрытая крышка ноутбука. Если прогресс живёт только в памяти, вы начинаете с нуля и платите за те же минуты второй раз.
- Последовательно — долго. Файл обрабатывается пару секунд, десять тысяч подряд — почти сутки. Параллельность превращает это в час.
- Счёт не виден. Оплата за минуты означает, что стоимость определяется тем, что вы отправили, — и узнаёте вы об этом постфактум.
Каждый пункт ниже закрывает одну из этих четырёх проблем.
Шаг 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. Подготовьте звук до отправки
Именно здесь определяется счёт. Два правила делают почти всю работу:
- Конвертируйте один раз, заранее. Моно 16 кГц — то, что модель использует внутри, и оно загружается несопоставимо быстрее исходного wav.
- Пропускайте то, где нет речи. Тишина всё равно стоит минут.
# переводим весь архив одним проходом
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} упало")
Личный кабинет показывает тот же прогон с другой стороны — израсходованные минуты, запросы и историю по каждому обращению. Это самый быстрый способ убедиться, что конвейер делает именно то, что вы думаете.
Чего строить не надо
- Брокер сообщений ради десяти тысяч файлов. Папка и пул потоков справляются с этим объёмом. Очередь нужна, когда работа приходит непрерывно, а не когда это разовый архив.
- Собственный ограничитель скорости. Поставьте размер пула ниже лимита тарифа — и вы в него не упрётесь.
- Счётчик позиции для продолжения. Запись «остановился на файле 4312» ломается, как только меняется порядок входных файлов. Выводите состояние из того, что лежит на диске.
Попробуйте на своих записях. Регистрация занимает минуту, бесплатных минут хватает, чтобы оценить качество.
Получить ключ бесплатноЧастые вопросы
Сколько файлов можно отправлять одновременно?
Параллельность ограничена лимитом запросов в секунду на вашем тарифе, а не сервисом. Ставьте размер пула чуть ниже лимита: шире — это уже не скорость, а ответы 429.
Что будет, если процесс умрёт на середине?
Ничего не потеряется, если прогресс выводится из готовых файлов: на следующем запуске конвейер пропустит всё, для чего уже есть расшифровка, и продолжит дальше.
Платить ли повторно за файл, отправленный второй раз?
Запрос, вернувший ошибку, минуты аудио не расходует; успешная расшифровка — расходует. Поэтому и важно пропускать уже готовые файлы: деньги стоит именно повторение успешной работы.
Нужно ли конвертировать звук перед отправкой?
Для архива — да, выигрыш двойной: моно 16 кГц загружается заметно быстрее, а файлы перестают пересекать порог 25 МБ, и резать их не приходится.
Как оценить стоимость большого архива?
Сложите длительности через ffprobe и умножьте на свою ставку за минуту. Оплата идёт за минуты аудио, поэтому оценка до запуска совпадает с тем, что вы заплатите.
Читайте также
- Как расшифровать запись звонка в текст — Пошаговое руководство: как перевести аудиозапись телефонного разговора в текст через API за несколько минут. Примеры кода на Python и C#, разбор ошибок.
- Стоимость распознавания речи: за что вы платите на самом деле — Как устроена оплата транскрибации, какие минуты съедают бюджет незаметно, когда своя видеокарта дешевле API и как посчитать собственные расходы до первого счёта.
- Как повысить точность распознавания речи: восемь рабочих приёмов — Восемь изменений, которые действительно двигают качество расшифровки: подготовка звука, подсказка языка, параметр prompt, нарезка по паузам, форматы и замер ошибок.