- BrainTools - https://www.braintools.ru -
Конвейер на Python + Hydra, который превращает папку с аудио в богато размеченный датасет: качество речи, просодия, разборчивость, спикер, транскрипция — по колонке на запись. От одной видеокарты до кластера, карты под нагрузкой, и он не падает на «длинном хвосте» записей, на которых обычно рассыпается наивный скрипт.
Я автор блога От обезьяны к LLM [1]. Делюсь с вами пайплайном по подготовке аудиозаписей для обучения [2] моделей синтеза речи (TTS). Для обучения TTS систем нужно много данных. И не просто «аудио + текст», а аудио, про которое известно всё: насколько оно чистое, какой у диктора темп и высота голоса, нет ли клиппинга, тот ли это спикер, совпадает ли текст с тем, что реально произнесено. Без этой разметки обучать TTS — всё равно что готовить по фотографии блюда: вроде похоже, но на вкус [3] сюрприз.
Звучит как разовая задача, пока перед тобой не оказывается много датасетов на десятки тысяч часов — игровые рипы без транскриптов, аудиокниги, подкасты, публичные корпуса. Каждый со своей структурой, своими дырами в метаданных и своими сюрпризами. Прогнать по ним десяток моделей (MOS, SQUIM, ASR, спикер-эмбеддинги…), ничего не уронить на середине и не ждать неделю — вот настоящая задача.
Готового инструмента, который закрывал бы это целиком, под рукой не оказалось (ближайший по духу — DataSpeech [4] от HF, но он заточен под свой сценарий). Так появился 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) |
|
Ошибка [5] транскрипции vs референс |
|
faster-whisper |
|
Пол / эмоция [6] |
|
wav2vec2 / HuBERT |
|
Любая модель с HuggingFace |
настраивается |
🤗 classification или regression |
Этого уже хватает, чтобы фильтровать датасет (выкинуть низкий MOS, шумные и рассогласованные записи), балансировать его (по спикеру, полу, питчу, темпу) и описывать под TTS. А две вещи стоит выделить отдельно — они закрывают то, обо что обычно спотыкаешься уже после разметки:
Консенсус-ASR дотранскрибирует наборы без текста, устойчиво к сбою отдельной модели (про механику — целый раздел ниже).
Спикер-лейблинг проставляет id там, где их нет, но только когда уверен: близость к центроиду кластера выше порога и явный отрыв от второго-ближайшего. Иначе запись остаётся unknown. Лучше честный пропуск, чем молча слить двух дикторов в один голос.
Под капотом — простая и узнаваемая (привет, datatrove [7]) идея: конвейер это список блоков, через которые протекают данные. Блок — объект с методом 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 [8], указываете путь и оставляете в metrics: нужные строки. Добавить готовую метрику — строка в ямле; подключить любую модель с HuggingFace — тоже строка (через HFAudioModelMetric); своя метрика — подкласс BaseMetric с единственным методом compute_metric.
Теперь — про то, что делает audiogear не просто «ещё одним скриптом».
Игровые рипы и сграбленные наборы часто приходят с пустым text. Можно прогнать одну ASR — но любая модель иногда галлюцинирует, и вы об этом не узнаете. Нужно пойти иначе: прогоняем несколько ASR и оставляем ту гипотезу, с которой согласны остальные. Этим занимается отдельный шаг ConsensusTranscriber.
Как выбирается гипотеза — по шагам:
Каждый бэкенд транскрибирует запись → набор гипотез.
Гипотезы нормализуются (нижний регистр, без пунктуации), и считается попарная матрица CER (character error rate) — насколько строки расходятся.
Для каждой гипотезы берём средний CER до всех остальных. Побеждает медоид — гипотеза с минимальным средним CER.
asr_agreement = 1 − средний CER медоида ∈ [0, 1] — мера согласия (1 = слово-в-слово).
Ключевая деталь: именно медоид, а не «голосование большинством». Тексты почти никогда не совпадают побайтово (регистр, окончания, пунктуация), так что «большинство» не посчитать — а расстояние по CER устойчиво ранжирует гипотезы по близости к консенсусу. Интуиция [9] простая: правильная транскрипция близка к другим правильным, а галлюцинация одной модели стоит особняком и проигрывает.
Покажу на примере. Три модели на одной записи:
Схема 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 закрывает обе проблемы.
Изначально метрики шли по одной записи. На батче из одного GPU занят на проценты: доминируют латентность запуска ядер и Python-оверхед, а между записями карта ждёт, пока CPU декодирует следующий файл.
Решение — собирать батчи, но с умом. Считаем длительности всех записей шарда (из манифеста или быстрым header-probe), сортируем по длине и жадно набираем батч, пока len(batch) × max_длина_в_батче ≤ max_batch_seconds:
Схема 4. Бакеты по длине: однородные батчи = меньше «пустого» паддинга.
Ключевая деталь: max_batch_seconds — это прямой прокси VRAM (память [10] активаций ≈ batch × padded_len), так что одним числом регулируется и размер батча, и риск OOM. Результат — утилизация conv-моделей выросла с ~20% до 70–90%.
Длительная аудиозапись не влезет в VRAM. Раньше исключение всплывало и убивало воркер — а с ним и целый датасет. Теперь OOM ловится и обрабатывается единой лестницей:
Схема 5. Лестница восстановления при CUDA OOM.
Размер батча сам подстраивается под VRAM, а «длинная аудио → OOM» перестаёт быть фатальной. Именно это позволяет спокойно поднимать workers: несколько патологических записей просто стекают на CPU вместо краха прогона.
Тут была поучительная история. Я был уверен, что батчинг безопасен для всех моделей. Сверка на реальных весах сказала обратное: у SQUIM PESQ разъехался на 1.2, а SI-SDR — на 5 дБ. Причина: модель не принимает attention-mask, и zero-padding до длины самой длинной записи в батче читается как «тишина в конце» и сдвигает оценки коротких записей.
Вывод: батчинг безопасен, только если модель умеет маскировать паддинг. Где не умеет (SQUIM; DistillMOS вообще сегментирует и усредняет внутри) — используем prefetch: декод идёт вперёд на пуле потоков, а инференс — одним потоком, так что модель никогда не трогают конкурентно (без лока, без гонки) и GPU почти не ждёт декода. Сверка: prefetch-прогон бит-в-бит совпадает с проходом по одной записи.
Даже с батчингом метрики шли последовательно по всему шарду: сначала все 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 [11], поэтому входной 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 [12]
Пишу про машинное обучение: https://t.me/decent_researcher [1]
Автор: monkey_llm
Источник [13]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/32487
URLs in this post:
[1] От обезьяны к LLM: https://t.me/decent_researcher
[2] обучения: http://www.braintools.ru/article/5125
[3] вкус: http://www.braintools.ru/article/6291
[4] DataSpeech: https://github.com/huggingface/dataspeech
[5] Ошибка: http://www.braintools.ru/article/4192
[6] эмоция: http://www.braintools.ru/article/9540
[7] datatrove: https://github.com/huggingface/datatrove
[8] configs/resd.yaml: https://github.com/lIkesimba9/audiogear/blob/main/configs/resd.yaml
[9] Интуиция: http://www.braintools.ru/article/6929
[10] память: http://www.braintools.ru/article/4140
[11] fsspec: https://filesystem-spec.readthedocs.io/
[12] https://github.com/lIkesimba9/audiogear: https://github.com/lIkesimba9/audiogear
[13] Источник: https://habr.com/ru/articles/1053986/?utm_campaign=1053986&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.