Конвейер на Python + Hydra, который превращает папку с аудио в богато размеченный датасет: качество речи, просодия, разборчивость, спикер, транскрипция — по колонке на запись. От одной видеокарты до кластера, карты под нагрузкой, и он не падает на «длинном хвосте» записей, на которых обычно рассыпается наивный скрипт.
Привет, Хабр!
Я автор блога От обезьяны к LLM. Делюсь с вами пайплайном по подготовке аудиозаписей для обучения моделей синтеза речи (TTS). Для обучения TTS систем нужно много данных. И не просто «аудио + текст», а аудио, про которое известно всё: насколько оно чистое, какой у диктора темп и высота голоса, нет ли клиппинга, тот ли это спикер, совпадает ли текст с тем, что реально произнесено. Без этой разметки обучать TTS — всё равно что готовить по фотографии блюда: вроде похоже, но на вкус сюрприз.
Звучит как разовая задача, пока перед тобой не оказывается много датасетов на десятки тысяч часов — игровые рипы без транскриптов, аудиокниги, подкасты, публичные корпуса. Каждый со своей структурой, своими дырами в метаданных и своими сюрпризами. Прогнать по ним десяток моделей (MOS, SQUIM, ASR, спикер-эмбеддинги…), ничего не уронить на середине и не ждать неделю — вот настоящая задача.
Готового инструмента, который закрывал бы это целиком, под рукой не оказалось (ближайший по духу — DataSpeech от HF, но он заточен под свой сценарий). Так появился audiogear — блочный конвейер разметки речевых датасетов. В этой статье расскажу, что он умеет, как устроен внутри.
Что audiogear выдаёт на выходе
Вы даёте ему per-dataset metadata.csv (таблица с |-разделителем, относительным audio_path и опциональным text) — а получаете ту же таблицу, к которой дописаны колонки фич. Каталог того, что можно посчитать на запись:
Что узнаём о записи |
Колонки |
Бэкенд |
|---|---|---|
No-reference MOS (общее качество) |
|
DistillMOS |
Разборчивость / перцептивное качество |
|
torchaudio SQUIM |
Bandwidth и «это апсемпл?» |
|
DSP (FFT) |
Pitch (среднее / разброс) |
|
torchcrepe / librosa pyin / penn |
Громкость и выразительность |
|
DSP |
Темп речи |
|
phonemizer (espeak) |
Фоновый шум (blind SNR) |
|
WADA |
Реверберация + SNR |
|
Brouhaha (pyannote) |
Ошибка транскрипции vs референс |
|
faster-whisper |
Пол / эмоция |
|
wav2vec2 / HuBERT |
Любая модель с HuggingFace |
настраивается |
? classification или regression |
Этого уже хватает, чтобы фильтровать датасет (выкинуть низкий MOS, шумные и рассогласованные записи), балансировать его (по спикеру, полу, питчу, темпу) и описывать под TTS. А две вещи стоит выделить отдельно — они закрывают то, обо что обычно спотыкаешься уже после разметки:
Консенсус-ASR дотранскрибирует наборы без текста, устойчиво к сбою отдельной модели (про механику — целый раздел ниже).
Спикер-лейблинг проставляет id там, где их нет, но только когда уверен: близость к центроиду кластера выше порога и явный отрыв от второго-ближайшего. Иначе запись остаётся
unknown. Лучше честный пропуск, чем молча слить двух дикторов в один голос.
Как устроен пайплайн
Под капотом — простая и узнаваемая (привет, datatrove) идея: конвейер это список блоков, через которые протекают данные. Блок — объект с методом run(data) -> data, где data — список объектов AudioSegment.

Схема 1. Поток данных: reader → метрики → writer.
AudioSegment — это просто описание одной аудиозаписи, которое по ходу конвейера обрастает колонками:
segment = { id, audio_file, text, duration, sample_rate, … } ├─ после DistillMOS → + distillmos ├─ после SQUIM → + pyt_stoi, pyt_pesq, pyt_si_sdr ├─ после Pitch → + pitch_mean, pitch_std └─ … (метрика-кортеж пишет сразу несколько колонок)
Весь конвейер собирается из Hydra-конфига. Каждый блок — это узел с _target_, который hydra.utils.instantiate превращает в объект. Хотите другой набор фич — меняете список metrics:, кода писать не надо:
metrics: - _target_: audiogear.pipeline.metrics.distillmos.DistillMosMetric device: ${device} - _target_: audiogear.pipeline.metrics.squim.SquimMetrics device: ${device}
А поверх конвейера стоит Executor. Он отвечает за то, чтобы всё это поехало по железу: режет датасет на tasks шардов, запускает workers параллельно и пиннит каждый воркер к своей видеокарте.

Схема 2. Executor: шардинг и пиннинг GPU.
Три типа блоков — Reader (CSV/JSONL/папка/HF), Metric (метрика / ASR / спикер-лейблер) и Writer (CSV/JSONL) — и всё собирается как конструктор. Дальше эта архитектура будет нам помогать на каждом шаге: и для батчинга, и для параллелизма, и для масштабирования.
Запуск: одна команда
Прежде чем нырять в кишки — покажу, что снаружи всё просто. Установка и прогон на своём датасете:
uv sync --extra ru-pipeline # поставить всё для пайплайна # датасет = папка с metadata.csv (относительный audio_path) + audio/ uv run python process.py --config-name resd \ reader.data_folder=/path/to/dataset # сухой прогон на 10 записях: добавьте reader.limit=10
На выходе — тот же metadata.csv с дописанными колонками фич. Новый датасет — это конфиг, а не код: копируете configs/resd.yaml, указываете путь и оставляете в metrics: нужные строки. Добавить готовую метрику — строка в ямле; подключить любую модель с HuggingFace — тоже строка (через HFAudioModelMetric); своя метрика — подкласс BaseMetric с единственным методом compute_metric.
Теперь — про то, что делает audiogear не просто «ещё одним скриптом».
Фишка: консенсус нескольких ASR
Игровые рипы и сграбленные наборы часто приходят с пустым text. Можно прогнать одну ASR — но любая модель иногда галлюцинирует, и вы об этом не узнаете. Нужно пойти иначе: прогоняем несколько ASR и оставляем ту гипотезу, с которой согласны остальные. Этим занимается отдельный шаг ConsensusTranscriber.
Как выбирается гипотеза — по шагам:
Каждый бэкенд транскрибирует запись → набор гипотез.
Гипотезы нормализуются (нижний регистр, без пунктуации), и считается попарная матрица CER (character error rate) — насколько строки расходятся.
Для каждой гипотезы берём средний CER до всех остальных. Побеждает медоид — гипотеза с минимальным средним CER.
asr_agreement = 1 − средний CER медоида∈ [0, 1] — мера согласия (1 = слово-в-слово).
Ключевая деталь: именно медоид, а не «голосование большинством». Тексты почти никогда не совпадают побайтово (регистр, окончания, пунктуация), так что «большинство» не посчитать — а расстояние по CER устойчиво ранжирует гипотезы по близости к консенсусу. Интуиция простая: правильная транскрипция близка к другим правильным, а галлюцинация одной модели стоит особняком и проигрывает.
Покажу на примере. Три модели на одной записи:

Схема 3. Медоид: галлюцинация отбрасывается тем, что она далеко от обеих.
asr_agreement = 1 − 0.40 = 0.60. Одна сбойная модель результат не портит.
Дальше — пара приятных мелочей:
Пунктуация.
prefer_punctuated: trueоставляет медоид победителем по точности, но вtextпишет ближайшую к нему пунктуированную гипотезу — в примере это Whisper («Привет, как дела?»): так пунктуация берётся прямо из аудио.Колонки:
asr_text_<name>на каждый бэкенд,asr_chosen_backend,asr_agreement; при заданномmin_agreement— флагasr_low_confidenceна записях, где модели разошлись (удобно отправить на ручную проверку).Устойчивость. Бэкенд, который не загрузился (например, не установлен T-one), отключается после одного варнинга, а не падает на каждой записи; ошибка на отдельной записи → пустая гипотеза, остальные продолжают голосовать.
В комплекте четыре бэкенда, все открытые и русскоязычные: GigaAM (Conformer, топ открытых русских лидербордов), Whisper (faster-whisper large-v3), Wav2Vec2 (CTC — ради архитектурного разнообразия) и T-one (стриминговый Conformer-CTC от t-tech). Добавить свой — это подкласс ASRBackend с методами _load и transcribe, и строчка в списке backends:. Чем разнообразнее архитектуры в ансамбле, тем устойчивее консенсус.
Использование ресурсов
Типичная беда таких конвейеров: они либо обрабатывают аудио последовательно, по одной записи (и GPU при этом скучает), либо падают с OOM, когда попадается длинная запись. Дальше — как audiogear закрывает обе проблемы.
Батчинг с бакетами по длине под VRAM-бюджет
Изначально метрики шли по одной записи. На батче из одного GPU занят на проценты: доминируют латентность запуска ядер и Python-оверхед, а между записями карта ждёт, пока CPU декодирует следующий файл.
Решение — собирать батчи, но с умом. Считаем длительности всех записей шарда (из манифеста или быстрым header-probe), сортируем по длине и жадно набираем батч, пока len(batch) × max_длина_в_батче ≤ max_batch_seconds:

Схема 4. Бакеты по длине: однородные батчи = меньше «пустого» паддинга.
Ключевая деталь: max_batch_seconds — это прямой прокси VRAM (память активаций ≈ batch × padded_len), так что одним числом регулируется и размер батча, и риск OOM. Результат — утилизация conv-моделей выросла с ~20% до 70–90%.
Длинная запись больше не роняет прогон
Длительная аудиозапись не влезет в VRAM. Раньше исключение всплывало и убивало воркер — а с ним и целый датасет. Теперь OOM ловится и обрабатывается единой лестницей:

Схема 5. Лестница восстановления при CUDA OOM.
Размер батча сам подстраивается под VRAM, а «длинная аудио → OOM» перестаёт быть фатальной. Именно это позволяет спокойно поднимать workers: несколько патологических записей просто стекают на CPU вместо краха прогона.
Где батч опасен — там prefetch
Тут была поучительная история. Я был уверен, что батчинг безопасен для всех моделей. Сверка на реальных весах сказала обратное: у SQUIM PESQ разъехался на 1.2, а SI-SDR — на 5 дБ. Причина: модель не принимает attention-mask, и zero-padding до длины самой длинной записи в батче читается как «тишина в конце» и сдвигает оценки коротких записей.
Вывод: батчинг безопасен, только если модель умеет маскировать паддинг. Где не умеет (SQUIM; DistillMOS вообще сегментирует и усредняет внутри) — используем prefetch: декод идёт вперёд на пуле потоков, а инференс — одним потоком, так что модель никогда не трогают конкурентно (без лока, без гонки) и GPU почти не ждёт декода. Сверка: prefetch-прогон бит-в-бит совпадает с проходом по одной записи.
CPU и GPU работают одновременно
Даже с батчингом метрики шли последовательно по всему шарду: сначала все CPU-метрики, потом все GPU. То есть в CPU-фазе карта простаивала, и наоборот. ParallelLanes гоняет две под-дорожки над одними и теми же сегментами конкурентно:

Схема 6. Параллельные дорожки CPU∥GPU.
Почему без гонки: дорожки пишут непересекающиеся колонки, а dict.__setitem__ для разных ключей атомарен под GIL; librosa и CUDA отпускают GIL → два Python-потока реально перекрываются. Сверка с последовательным прогоном — Δ=0 по 17 колонкам, ускорение ×1.47 уже на 16 записях (на длинных наборах больше — CPU целиком прячется под GPU).
Модели грузятся один раз
Тонкий баг, который сжирал минуты. Executor пересоздаёт объекты метрик на каждый шард (deepcopy/ре-pickle), и стек моделей грузился tasks раз. Лечится process-global кэшем: модели лежат в module-level словаре по ключу (класс, checkpoint, device), а не на инстансе:
_MODEL_CACHE = {} def cached_model(key, factory): if key not in _MODEL_CACHE: _MODEL_CACHE[key] = factory() return _MODEL_CACHE[key]
Теперь модель грузится раз на воркер-процесс и переиспользуется всеми его шардами. Бонус: первый load фиксирует GPU процесса, а инстансы метрик стали дёшевы для pickle (несут только конфиг).
От одной видеокарты до кластера
Та самая архитектура с шардами (Схема 2) даёт одну модель масштабирования на все случаи. Датасет режется на tasks шардов, workers идут параллельно, каждый пишет свой ext_${rank}.csv:
# 1 GPU uv run python process.py --config-name resd executor.tasks=8 executor.workers=1 # много GPU (одна карта на воркер) uv run python process.py --config-name resd executor.tasks=64 executor.workers=2
Несколько нод — без отдельного Slurm-класса. Это тот же executor, запущенный по разу на каждой ноде; audiogear сам читает (node_rank, num_nodes) из окружения лаунчера (SLURM_*, torchrun GROUP_RANK/NNODES или явные AUDIOGEAR_NODE_RANK/NUM_NODES) и выдаёт ноде непрерывный кусок шардов:

Схема 7. Мульти-нода: каждая нода берёт свой срез шардов.
srun -N4 --gpus-per-node=8 \ uv run python process.py --config-name resd executor.tasks=256 executor.workers=8
Прогоны resumable: готовые шарды пропускаются (skip_completed), а детект GPU не инициализирует CUDA в родителе — поэтому мульти-GPU не уходит в дедлок.
Данные можно держать в S3. Весь табличный I/O — на fsspec, поэтому входной metadata.csv, выходные CSV и logging_dir могут быть s3://… (проверено на Yandex Object Storage). Один нюанс, который стоит знать: аудио декодируется локально — torchaudio не умеет s3://. Рабочий layout: аудио на локальном диске (или FUSE-маунт бакета), а результаты и логи — в S3.
На чём можно споткнуться
uv sync --extra Xприводит venv РОВНО к base+X и сносит остальные extra. Ставить нужные extra одной командой:uv sync --extra ru-pipeline --extra tone.Дорожки — это dict плоских списков (
lanes: {cpu: [...], gpu: [...]}): Hydra корректно инстанцирует такой узел, а вложенный list-of-lists_target_— нет.Аудио из S3 напрямую не читается (см. выше) — только локально или через FUSE.
Итого
Один конфиг — и папка аудио превращается в размеченный под TTS датасет.
Расширяется на лету: готовая метрика — строка в ямле, любая HF-модель — тоже строка, своя метрика — подкласс с одним методом.
Эффективно и при этом корректно — проверено бит-в-бит и тестами.
Масштаб от одной GPU до кластера, прогоны resumable, не падает на «длинном хвосте».
Если готовите данные для TTS (или просто размечаете речь в объёмах) — забирайте, адаптируйте под свои наборы, заводите свои метрики. Буду рад звёздам, issue и PR.
Код, конфиги и тесты: https://github.com/lIkesimba9/audiogear
Пишу про машинное обучение: https://t.me/decent_researcher