Странствующий техник
Подкинуть работёнку
Назад к статьям
AUTOMATION21 сентября 2026·9 MIN

Русская озвучка уроков GarageBand: Silero TTS вместо японской дорожки

Как озвучить уроки Learn to Play по-русски: субтитры Apple из .mov, локальный Silero TTS и подмена CAF-дорожки в пакете .mwand. Скрипт и команды.

Уроки Learn to Play в GarageBand идут с английской озвучкой преподавателя и русскими субтитрами. Читать титры и одновременно смотреть на гриф невозможно — либо играешь, либо читаешь. Ниже — рабочий рецепт, как озвучить 18 уроков по-русски за один прогон скрипта: русские субтитры достаются прямо из .mov, озвучиваются локальным Silero TTS и подменяют японскую аудиодорожку внутри пакета .mwand. Всё копипастится: batch.py целиком, команды установки и отката.

Главное, ради чего стоит читать: транскрипция и перевод в этой задаче не нужны вообще. Официальный русский перевод Apple уже лежит внутри урока отдельной дорожкой субтитров — с точными таймкодами и правильной гитарной терминологией. Это снимает два самых дорогих шага пайплайна.

Стек инструментов

  • Claude Code (Opus) — реверс пакета .mwand, написание и отладка пайплайна
  • Silero TTS (v4_ru, голос eugene) — локальный синтез на CPU, без облака и без API-ключей
  • ffmpeg / ffprobe — извлечение субтитров, микс по таймкодам, контроль длительности
  • afconvert — нативная macOS-конвертация в CAF (AAC 44100, стерео)

Whisper (large-v3) в первой версии пайплайна был — и оказался лишним. Почему, расскажу ниже: это главный поворот всей истории.

Как устроен пакет урока .mwand

Первая гипотеза была неверной, и это стоит проговорить: я исходил из того, что речь преподавателя замиксована в видеодорожку урока — значит, подменить её нельзя. Опровергается это одним скриншотом настроек урока, где есть переключатель «Язык» с английским, немецким, французским и японским.

Раз языки переключаются на лету — значит, озвучка лежит отдельными файлами. Так и есть. .mwand — это обычная папка (Finder → «Показать содержимое пакета»), внутри Media/ лежат CAF-файлы, по одному на язык:

Rock_Guitar5 US.caf   ← Teacher's Voice (English)
Rock_Guitar5 D.caf    ← Deutsch
Rock_Guitar5 F.caf    ← Français
Rock_Guitar5 J.caf    ← 日本語

Рядом — инструменты (_BASS, _DRMS, _GTRS, _KEYS) и .mov без вшитой речи. Внутри проекта есть группа треков Language Tracks с дорожками Teacher's Voice (English/French/German/Japanese/Spanish), и переключатель языка просто солирует нужную.

Все четыре дорожки идентичны по параметрам: 2 канала, 44100 Гц, AAC, ровно 2610.000000 сек и 112406 пакетов. Это важно: целевая длина известна с точностью до пакета, и подмена выходит побитово совместимой по таймингу.

Единственное ограничение. Добавить шестой пункт «русский» в меню нельзя: список языков зашит в бинарный блоб projectData (835 КБ недокументированной структуры Logic). Поэтому русская озвучка кладётся вместо японской (J), а меню остаётся прежним. Слушать — «Язык → японский». Компромисс некрасивый, зато не требует правки бинарников и переживает любые манипуляции с проектом.

Поворот: субтитры Apple отменяют whisper и перевод

Первый пайплайн выглядел так: whisper.cpp с large-v3 транскрибирует английскую дорожку → я перевожу сотни реплик → Silero озвучивает. Транскрипция работала хорошо, 43 минуты урока укладывались в ~9 минут обработки.

Но у неё были две болезни. Первая — галлюцинации whisper на музыкальных участках: там, где преподаватель не говорит, а играет, модель зацикливалась и повторяла одну фразу по 13 раз подряд с интервалами 40–100 мс. Если такое озвучить, синтез 13 раз протараторит реплику поверх гитарного проигрыша. Вторая — перевод сотен реплик с гитарной терминологией вручную.

Обе болезни снялись одной проверкой: русские субтитры уже лежат внутри .mov отдельной дорожкой. Официальный перевод Apple, точные таймкоды, правильные «лады», «медиатор», «бой». Дорожек в .mov около 15 (испанский, немецкий, японский, польский и прочие), все помечены тегом eng — тег Apple оставила по умолчанию, поэтому искать нужную приходится по содержимому.

Скрипт находит её сам — по наличию кириллицы:

def ru_track(mov):
    idx = sh(['ffprobe','-v','error','-select_streams','s','-show_entries',
              'stream=index','-of','csv=p=0', mov]).stdout.split()
    for i in idx:
        i = i.strip(',')
        o = sh(['ffmpeg','-v','error','-i',mov,'-map',f'0:{i}',
                '-c:s','srt','-f','srt','-']).stdout
        if len(re.findall(r'[а-яА-ЯёЁ]', re.sub(r'<[^>]*>|{[^}]*}','',o))) > 50:
            return o
    return None

Индекс дорожки плавает по поколениям уроков (7 у Blues/Rock 2016, 6 у Guitar Lesson 2009, 4 у Piano) — поэтому перебор по содержимому, а не хардкод индекса.

Вывод, который переносится на любую задачу локализации: прежде чем транскрибировать, проверьте, нет ли готового перевода внутри контейнера. ffprobe на дорожки — минута работы против девяти минут whisper на каждый урок плюс ручной перевод.

Промпт, с которого всё началось

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

я скачал уроки игры на электрогитаре в приложении GarageBand но там аудиодорожки на английском а титры на русском, можем ли мы сделать звуковые дорожки на русском и встроить в приложение GarageBand?

Первый ответ агента был «практически нельзя, звук вшит в видео» — уверенный и неправильный. Развернул его скриншот с переключателем языка. Второй важный сдвиг тоже пришёл не от модели, а от одной реплики:

там есть русские субтитры возможно транскрибация лишняя?

Эта фраза сэкономила whisper-проход на каждом из 18 уроков и весь ручной перевод. Мораль для работы с агентами: агент хорошо копает вглубь выбранного пути, но плохо ставит под сомнение сам путь. Про это же — в разборе ponytail-аудита блога, где вопрос «а нужно ли это вообще» вычистил почти три тысячи строк.

Счёт долей: почему его нельзя синтезировать

После дедупликации 592 реплики. Из них 227 — окна короче 1.5 с, и состоят они из «1», «2», «3», «4», «3 и», «Вниз, вниз, вверх». Это не объяснение, а метроном голосом: преподаватель отсчитывает ритм ровно в долю, пока вы играете.

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

Классификация — тупой регексп, и этого достаточно:

COUNT_TOK = re.compile(r'\d+|и|вниз|вверх|раз|два|три|четыре|-|—')
 
def is_count(t):
    t = t.strip().strip('.,!?«»').lower()
    return bool(t) and all(re.fullmatch(COUNT_TOK, x)
                           for x in re.split(r'[\s,]+', t) if x)

На 18 уроках разделение отработало чисто: от 2 блоков счёта на коротком уроке до 73 на «Blues Scale and Quarter Bends».

Склейка реплик и попадание в окна

Субтитры нарезаны по строкам экрана, а не по фразам — медианное окно 2.55 с, синтез в такое влезает впритык. Поэтому соседние реплики склеиваются по смыслу: если разрыв меньше 50 мс, суммарная длина меньше 180 символов и предыдущая не кончается точкой — объединяем.

389 реплик → 241, медианное окно выросло до 3.97 с. Ускорять через atempo пришлось 31 реплику из 241, остальные легли свободно.

def merge(sp):
    m = []
    for s in sp:
        if m and abs(m[-1][1]-s['start']) < 0.05 and len(m[-1][2])+len(s['text']) < 180 \
           and not m[-1][2].rstrip().endswith(('.','!','?',':')):
            m[-1][1] = s['end']; m[-1][2] += ' ' + s['text']
        else:
            m.append([s['start'], s['end'], s['text']])
    return m

Помогает то, что уроки на 90% состоят из игры: между репликами паузы по 10–20 секунд. Русский текст длиннее английского, но в такие промежутки укладывается с запасом.

Сборка дорожки и контроль длины

Реплики раскладываются по исходным таймкодам через adelay + amix. Здесь — грабли, на которые легко наступить: amix завершается по последнему входу, и -t его не удлиняет. Первая сборка дала 81.77 с вместо 180 — хвост тишины просто отсутствовал. Лечится apad в конце фильтра:

filt.append(''.join(f'[d{i}]' for i in range(len(items)))
            + f'amix=inputs={len(items)}:dropout_transition=0:normalize=0,apad[m]')

Почему это критично: если длина не совпадёт с оригиналом, разъедется синхронизация с видео и нотной дорожкой урока. Скрипт проверяет результат и отказывается подменять файл при расхождении больше 0.5 с:

got = round(dur(caf), 1); want = round(total, 1)
if abs(got - want) > 0.5:
    return (name, f'ОШИБКА длины: {got} vs {want}')

Микс идёт чанками по 60 реплик — amix на 240 входов разом ffmpeg переваривает плохо. Финальная конвертация:

afconvert -f caff -d aac -b 224000 --mix -c 2 work/ru.wav work/out.caf

Итог по ключевым параметрам совпал с оригиналом: 2 ch, 44100 Гц, AAC, 2610.000000 сек, 112406 пакетов. Битрейт вышел ниже (74 против 224 кбит/с) — дорожка на 90% тишина и синтезированная речь, на воспроизведение не влияет.

Запуск на всех уроках

cd ~/projects/garageband-ru
python3 batch.py

done.json — журнал обработанных уроков, повторный запуск их пропускает. Чтобы переделать урок, удалите его строку. Результат на моей библиотеке — 18 из 21:

Blues Guitar 5_Bends and Vibrato   OK  язык=J  реплик=321 (ускорено 55)  счёт=20  2880.1s
Guitar Lesson 7_Major Barre Chords OK  язык=E  реплик=126 (ускорено 20)  счёт=4   782.1s
Guitar Lesson 1                    ПРОПУСК: нет языковой дорожки для подмены

Три урока пропущены — у них в Media/ только английская дорожка, подменять нечего (русские субтитры при этом есть, так что можно докачать языковые версии через GarageBand и прогнать заново). У Guitar Lesson 7 нет японской дорожки, поэтому русский лёг под испанский — скрипт принимает форс-тег.

Зависимости: ffmpeg, afconvert (штатный в macOS), pip install silero (тянет torch). Модель v4_ru — 38 МБ, качается сама при первом запуске, работает на CPU.

Откат

Оригиналы подменённых дорожек скрипт складывает в backups/ до первой записи:

cp backups/Blues_Guitar1_J_ORIGINAL.caf \
   "/Library/Application Support/GarageBand/Learn to Play/Basic Lessons/Blues Guitar 1_12 Bar Blues in A.mwand/Media/Blues_Guitar1 J.caf"

Обновление GarageBand или переустановка урока вернут японскую озвучку — тогда просто прогоните скрипт заново.

Pro Tips: промптинг и исполнение

  1. Опровергайте первый уверенный отказ агента фактом, а не спором. «Звук вшит в видео, ничего не выйдет» развалилось от одного скриншота с переключателем языка. Скриншот интерфейса весит больше трёх абзацев объяснений.
  2. Спрашивайте «а это точно нужно?» на каждом дорогом шаге. Вопрос про лишнюю транскрипцию убрал whisper-проход на каждом уроке и весь ручной перевод. Агент оптимизирует выбранный путь, а не выбор пути.
  3. Требуйте пробу на трёх минутах до полного прогона. Голос, темп и попадание в окна слышно на коротком фрагменте; 43 минуты синтеза ради «ой, не тот голос» — потерянный вечер.
  4. Давайте агенту проверяемый инвариант. «Длина обязана совпасть до пакета» превратилась в автоматическую проверку перед подменой файла — и поймала баг с amix, который на слух заметить трудно.
  5. Просите журнал состояния с первого дня. done.json на 21 уроке — разница между «прогнать заново всё» и «переделать один».

Common Pitfalls

  1. Whisper галлюцинирует на музыке. На участках без речи large-v3 зацикливает одну фразу по 13 раз. Если транскрипция всё-таки нужна — дедупликация подряд идущих одинаковых реплик и отсев огрызков короче 0.35 с обязательны.
  2. amix обрезает хвост, -t не помогает. Микс кончается на последнем входе. Без apad дорожка выходит короче оригинала, и синхронизация с видео разъезжается.
  3. Агент делает выводы по отфильтрованному выводу. В одном месте ls с фильтром по размеру не показал модель whisper, и последовал уверенный вывод «модели нет, нужно качать». Модель лежала на месте. Проверяйте выводы, сделанные по неполным данным.
  4. Языковые теги в .mov врут. Все 15 дорожек субтитров помечены eng. Искать нужную можно только по содержимому — в нашем случае по кириллице.
  5. Синтез не попадает в ритм. Всё, что служит метрономом («1, 2, 3, 4», «вниз, вверх»), нужно брать из оригинала. Технически озвучить можно, музыкально — вредно.
  6. Меню языков не расширяется. Список зашит в бинарный projectData. Русский живёт под чужим пунктом — это цена решения, не баг.

Всё сделано для личного использования: озвучка генерируется локально из купленных уроков и никуда не распространяется.

Идентификация

Денис Заплахов

Исследую Пустоши рутинных задач: настраиваю AI-инструменты, создаю скиллы и тестирую нейросети на реальных кейсах.

Досье в Pip-Boy 3000
© 2026 79z.ru · Странствующий техник