Уроки 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.pydone.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: промптинг и исполнение
- Опровергайте первый уверенный отказ агента фактом, а не спором. «Звук вшит в видео, ничего не выйдет» развалилось от одного скриншота с переключателем языка. Скриншот интерфейса весит больше трёх абзацев объяснений.
- Спрашивайте «а это точно нужно?» на каждом дорогом шаге. Вопрос про лишнюю транскрипцию убрал whisper-проход на каждом уроке и весь ручной перевод. Агент оптимизирует выбранный путь, а не выбор пути.
- Требуйте пробу на трёх минутах до полного прогона. Голос, темп и попадание в окна слышно на коротком фрагменте; 43 минуты синтеза ради «ой, не тот голос» — потерянный вечер.
- Давайте агенту проверяемый инвариант. «Длина обязана совпасть до пакета» превратилась в автоматическую проверку перед подменой файла — и поймала баг с
amix, который на слух заметить трудно. - Просите журнал состояния с первого дня.
done.jsonна 21 уроке — разница между «прогнать заново всё» и «переделать один».
Common Pitfalls
- Whisper галлюцинирует на музыке. На участках без речи
large-v3зацикливает одну фразу по 13 раз. Если транскрипция всё-таки нужна — дедупликация подряд идущих одинаковых реплик и отсев огрызков короче 0.35 с обязательны. amixобрезает хвост,-tне помогает. Микс кончается на последнем входе. Безapadдорожка выходит короче оригинала, и синхронизация с видео разъезжается.- Агент делает выводы по отфильтрованному выводу. В одном месте
lsс фильтром по размеру не показал модель whisper, и последовал уверенный вывод «модели нет, нужно качать». Модель лежала на месте. Проверяйте выводы, сделанные по неполным данным. - Языковые теги в
.movврут. Все 15 дорожек субтитров помеченыeng. Искать нужную можно только по содержимому — в нашем случае по кириллице. - Синтез не попадает в ритм. Всё, что служит метрономом («1, 2, 3, 4», «вниз, вверх»), нужно брать из оригинала. Технически озвучить можно, музыкально — вредно.
- Меню языков не расширяется. Список зашит в бинарный
projectData. Русский живёт под чужим пунктом — это цена решения, не баг.
Всё сделано для личного использования: озвучка генерируется локально из купленных уроков и никуда не распространяется.