- BrainTools - https://www.braintools.ru -

audiogear: как разметить миллионы аудиозаписей для TTS

Конвейер на Python + Hydra, который превращает папку с аудио в богато размеченный датасет: качество речи, просодия, разборчивость, спикер, транскрипция — по колонке на запись. От одной видеокарты до кластера, карты под нагрузкой, и он не падает на «длинном хвосте» записей, на которых обычно рассыпается наивный скрипт.

Я автор блога От обезьяны к LLM [1]. Делюсь с вами пайплайном по подготовке аудиозаписей для обучения [2] моделей синтеза речи (TTS). Для обучения TTS систем нужно много данных. И не просто «аудио + текст», а аудио, про которое известно всё: насколько оно чистое, какой у диктора темп и высота голоса, нет ли клиппинга, тот ли это спикер, совпадает ли текст с тем, что реально произнесено. Без этой разметки обучать TTS — всё равно что готовить по фотографии блюда: вроде похоже, но на вкус [3] сюрприз.

Звучит как разовая задача, пока перед тобой не оказывается много датасетов на десятки тысяч часов — игровые рипы без транскриптов, аудиокниги, подкасты, публичные корпуса. Каждый со своей структурой, своими дырами в метаданных и своими сюрпризами. Прогнать по ним десяток моделей (MOS, SQUIM, ASR, спикер-эмбеддинги…), ничего не уронить на середине и не ждать неделю — вот настоящая задача.

Готового инструмента, который закрывал бы это целиком, под рукой не оказалось (ближайший по духу — DataSpeech [4] от HF, но он заточен под свой сценарий). Так появился audiogear — блочный конвейер разметки речевых датасетов. В этой статье расскажу, что он умеет, как устроен внутри.

Что audiogear выдаёт на выходе

Вы даёте ему per-dataset metadata.csv (таблица с |-разделителем, относительным audio_path и опциональным text) — а получаете ту же таблицу, к которой дописаны колонки фич. Каталог того, что можно посчитать на запись:

Что узнаём о записи

Колонки

Бэкенд

No-reference MOS (общее качество)

distillmos

DistillMOS

Разборчивость / перцептивное качество

pyt_stoi, pyt_pesq, pyt_si_sdr

torchaudio SQUIM

Bandwidth и «это апсемпл?»

bandwidth_hz, is_upsampled_est

DSP (FFT)

Pitch (среднее / разброс)

pitch_mean, pitch_std

torchcrepe / librosa pyin / penn

Громкость и выразительность

energy_db, energy_dynamics, expressiveness

DSP

Темп речи

speaking_rate, char_rate

phonemizer (espeak)

Фоновый шум (blind SNR)

wada_snr

WADA

Реверберация + SNR

snr, c50

Brouhaha (pyannote)

Ошибка [5] транскрипции vs референс

whisper_wer, whisper_cer

faster-whisper

Пол / эмоция [6]

gender_pred / emotion_pred

wav2vec2 / HuBERT

Любая модель с HuggingFace

настраивается

🤗 classification или regression

Этого уже хватает, чтобы фильтровать датасет (выкинуть низкий MOS, шумные и рассогласованные записи), балансировать его (по спикеру, полу, питчу, темпу) и описывать под TTS. А две вещи стоит выделить отдельно — они закрывают то, обо что обычно спотыкаешься уже после разметки:

  • Консенсус-ASR дотранскрибирует наборы без текста, устойчиво к сбою отдельной модели (про механику — целый раздел ниже).

  • Спикер-лейблинг проставляет id там, где их нет, но только когда уверен: близость к центроиду кластера выше порога и явный отрыв от второго-ближайшего. Иначе запись остаётся unknown. Лучше честный пропуск, чем молча слить двух дикторов в один голос.

Как устроен пайплайн

Под капотом — простая и узнаваемая (привет, datatrove [7]) идея: конвейер это список блоков, через которые протекают данные. Блок — объект с методом run(data) -> data, где data — список объектов AudioSegment.

Схема 1. Поток данных через конвейер

Схема 1. Поток данных через конвейер

Схема 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

Схема 2. Executor: шардинг и пиннинг GPU

Схема 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 не просто «ещё одним скриптом».

Фишка: консенсус нескольких ASR

Игровые рипы и сграбленные наборы часто приходят с пустым text. Можно прогнать одну ASR — но любая модель иногда галлюцинирует, и вы об этом не узнаете. Нужно пойти иначе: прогоняем несколько ASR и оставляем ту гипотезу, с которой согласны остальные. Этим занимается отдельный шаг ConsensusTranscriber.

Как выбирается гипотеза — по шагам:

  1. Каждый бэкенд транскрибирует запись → набор гипотез.

  2. Гипотезы нормализуются (нижний регистр, без пунктуации), и считается попарная матрица CER (character error rate) — насколько строки расходятся.

  3. Для каждой гипотезы берём средний CER до всех остальных. Побеждает медоид — гипотеза с минимальным средним CER.

  4. asr_agreement = 1 − средний CER медоида ∈ [0, 1] — мера согласия (1 = слово-в-слово).

Ключевая деталь: именно медоид, а не «голосование большинством». Тексты почти никогда не совпадают побайтово (регистр, окончания, пунктуация), так что «большинство» не посчитать — а расстояние по CER устойчиво ранжирует гипотезы по близости к консенсусу. Интуиция [9] простая: правильная транскрипция близка к другим правильным, а галлюцинация одной модели стоит особняком и проигрывает.

Покажу на примере. Три модели на одной записи:

Схема 3. Медоид-консенсус трёх ASR

Схема 3. Медоид-консенсус трёх ASR

Схема 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. Бакеты по длине под VRAM-бюджет

Схема 4. Бакеты по длине под VRAM-бюджет

Схема 4. Бакеты по длине: однородные батчи = меньше «пустого» паддинга.

Ключевая деталь: max_batch_seconds — это прямой прокси VRAM (память [10] активаций ≈ batch × padded_len), так что одним числом регулируется и размер батча, и риск OOM. Результат — утилизация conv-моделей выросла с ~20% до 70–90%.

Длинная запись больше не роняет прогон

Длительная аудиозапись не влезет в VRAM. Раньше исключение всплывало и убивало воркер — а с ним и целый датасет. Теперь OOM ловится и обрабатывается единой лестницей:

Схема 5. Лестница восстановления при CUDA OOM

Схема 5. Лестница восстановления при CUDA 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

Схема 6. Параллельные дорожки CPU и GPU

Схема 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. Мульти-нода: каждая нода берёт свой срез шардов

Схема 7. Мульти-нода: каждая нода берёт свой срез шардов

Схема 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

www.BrainTools.ru

Rambler's Top100