Синхронизация камер по звуку без хлопушки: что я понял, пока делал плагин для Premiere Pro с нейросетью


Я монтажер, не программист. За месяц вместе с нейросетью сделал плагин для Premiere Pro: он синхронизирует камеры с рекордером по звуку, режет паузы и дубли и собирает секвенцию. Историю я уже рассказывал на vc.ru, а здесь будут внутренности: как найти сдвиг между файлами, почему одного порога корреляции мало, как отличить готовый ролик от исходника и почему тесты на каждый релиз оказались важнее любого кода.
Сразу оговорюсь: код писала нейросеть под мою постановку, а я проверял на своих проектах. Поэтому это не статья «смотрите, как красиво». Это статья про задачи, которые пришлось решить, грабли, на которые наступил, и числа, которые пришлось намерить.
Задача
На входе папка одной съемки. Обычно это две‑три камеры, петличка и отдельный рекордер. Хлопушки нет. Камеры пишут кусками: оператор останавливал запись, кто‑то забыл включить. Часы у камер врут, иногда на часы, иногда на дни. Рекордер при этом пишет весь разговор одним файлом.
На выходе нужна секвенция в Premiere Pro: каждая камера на своей дорожке, куски на своих местах, звук с рекордера основной, паузы и дубли вырезаны.
Стек простой: Python, numpy, ffmpeg. Секвенция отдается в Premiere как XML (формат xmeml 4), панель на UXP ее импортирует. Речь распознается локально через faster‑whisper. Все считается на компьютере монтажера, в интернет ничего не уходит.

Шаг 1. Не звук, а рисунок громкости
Первая мысль была сравнивать сами семплы. Но у камеры и рекордера разные микрофоны, разное расстояние до головы и разная комната в звуке. Волны получаются непохожие. Зато одинаковый рисунок: где фраза, где пауза, где спикер засмеялся.
Поэтому ffmpeg вытаскивает из каждого файла моно 16 кГц, и по нему считается огибающая: громкость в дБ в окнах по 10 мс. Час записи это 360 тысяч чисел вместо 57 миллионов семплов.
WIN_MS = 10
def envelope(x, sr):
n = int(sr * WIN_MS / 1000)
frames = x[:len(x) // n * n].reshape(-1, n)
rms = np.sqrt(np.mean(frames ** 2, axis=1) + 1e-12)
return 20 * np.log10(rms + 1e-12)
И еще один шаг, без которого ничего не работало. У огибающей есть медленный рельеф: общая громкость сцены. Он похож у любых двух кусков речи и только мешает. Вычитаем скользящее среднее за 1 секунду, и остается рисунок фраз и пауз, а он у каждого момента съемки свой.
СГЛАЖИВАНИЕ = 100 # 100 окон по 10 мс = 1 секунда
def для_сравнения(env):
ядро = np.ones(СГЛАЖИВАНИЕ) / СГЛАЖИВАНИЕ
return env - np.convolve(env, ядро, mode="same")
Да, в коде есть кириллица в именах. Я читаю этот код чаще, чем пишу, и мне так проще. Python не против.
Шаг 2. Сдвиг через FFT и четыре грабли
Ищем сдвиг, на котором две огибающие совпадают лучше всего. Это взаимная корреляция, и через FFT она считается сразу для всех сдвигов. В учебнике это три строки:
n = 1 << int(np.ceil(np.log2(len(a) + len(b))))
c = np.fft.irfft(np.fft.rfft(a, n) * np.conj(np.fft.rfft(b, n)), n)
lag = np.argmax(c)
На живых файлах эти три строки врали четырьмя разными способами.
Грабли 1: знак сдвига. Сначала стояло правило «все, что больше n/2, считаем отрицательным». Оно тихо ломалось, когда короткий файл лежит глубоко внутри длинного: кусок камеры на 22-й минуте получасовой записи рекордера. Настоящий сдвиг больше n/2 и «заворачивался» в отрицательный. Правильно так: индексы до len(a) это положительные сдвиги, индексы с конца отрицательные, а середина, зона нулевого дополнения, вообще не сдвиги.
lags = np.arange(n)
lags = np.where(lags < len(a), lags, lags - n)
mask = (lags >= -(len(b) - 1)) & (lags <= len(a) - 1)
Грабли 2: длинное перекрытие выигрывает само по себе. В сумме больше слагаемых, и максимум уезжает туда, где файлы просто сильнее накладываются. Итог вида «плюс четыре минуты» там, где камеры включили почти вместе. Помогла нормировка каждого сдвига на энергию именно того куска, который на нем перекрывается. Энергию удобно брать из накопленных сумм, это бесплатно по времени:
ea = np.concatenate(([0.0], np.cumsum(a ** 2)))
eb = np.concatenate(([0.0], np.cumsum(b ** 2)))
# границы перекрытия для каждого сдвига L
a0, a1 = np.clip(L, 0, la), np.clip(lb + L, 0, la)
b0, b1 = np.clip(-L, 0, lb), np.clip(la - L, 0, lb)
nc = c[idx] / (np.sqrt((ea[a1] - ea[a0]) * (eb[b1] - eb[b0])) + 1e-12)
Грабли 3: крошечное перекрытие. У нормировки есть обратная сторона: на сдвиге, где файлы соприкасаются парой секунд, случайно совпасть может что угодно. Сдвиги, где перекрытие меньше 30% короткого файла, просто выкидываем.
Грабли 4: сглаживание смазывает пик. Огибающая без рельефа хорошо отличает моменты съемки, но сам сдвиг уезжал на 40 мс. Для монтажа это целый кадр, губы уже не совпадают со звуком. Поэтому два прохода: грубый сдвиг по сглаженной огибающей, точный по сырой, в окне ±0.5 секунды вокруг грубого.
Шаг 3. Высокий пик еще ничего не значит
Сдвиг найден. Но теперь главный вопрос: эти два файла вообще сняты одновременно? Если да, их надо ставить внахлест, каждый на свою дорожку. Если нет, друг за другом. Ошибка в любую сторону ломает всю сборку.
Первый критерий очевидный, высота пика. На синтетике одна и та же съемка дает 1.00, разные куски речи 0.23–0.30. Порог поставил 0.40. И этого оказалось мало: два разных куска речи похожи уже тем, что это речь.
Второй критерий оказался важнее первого: насколько пик выделяется среди всех остальных сдвигов. Само значение корреляции зависит от материала, а отрыв от фона почти нет. Считается как устойчивый z‑score, через медиану и MAD, чтобы сам пик не раздувал фон:
фон = np.median(nc)
разброс = np.median(np.abs(nc - фон)) * 1.4826 + 1e-12
z = (nc[k] - фон) / разброс
годная = score >= 0.40 and z >= 6.5
Одновременная съемка дает отрыв 8–16, разные моменты 3.5–5.3. Порог 6.5 стоит посередине.

Есть и запасной вариант для совсем шумных камер. Если по звуку файлы не совпали, но по метке времени внутри файлов они сняты в одно и то же время и перекрываются хотя бы наполовину, считаем их параллельными. Дальше все пары с годным совпадением собираются в группы обычным union‑find: одна группа это один момент съемки, а моменты встают на шкале друг за другом.
До этого была смешная история: одна камера сняла подряд три файла, а программа решила, что это три камеры, и положила их внахлест. 19 минут исходников превратились в шкалу на 28.
Баг, который нашел тестер: 88 минут превратились в 153
Первым плагин прогнал монтажер подкастов на своем реальном проекте. Рекордер писал 88 минут подряд, а камеры разрезали запись на куски по 23, 39 и 25 минут. Второй кусок начинается на 24-й минуте записи рекордера, третий на 63-й.
А в коде стоял предел поиска сдвига: 600 секунд. Казалось, кто включает камеру на 10 минут позже звука? В итоге совпадение с рекордером для второго и третьего куска не искалось вообще, в логе было «совпадение 0.04, врозь». Куски уехали в конец шкалы после звука, и 88 минут стали 153.

Исправление в одну строку: предел теперь 6 часов, то есть практически вся съемка. От случайных совпадений и так защищают отрыв пика и проверка длины перекрытия. Интереснее другое: этот предел был «разумным допущением», которое никто не проверял. Теперь у каждой константы в коде есть комментарий: откуда взялось число, на каком материале и когда его мерили.
Шаг 4. Готовый ролик в папке с исходниками
Этот баг я нашел сам, пока готовил статьи. Прогнал старый проект, а в папке с двумя камерами лежал экспорт смонтированного ролика. Целиком он ни с чем не совпал, он же порезан. Поэтому встал отдельным моментом съемки в конец шкалы, занял первую видеодорожку и ушел в распознавание речи.
По имени файла его не узнать, по длине тоже. Зато у монтажа есть особенность: он склеен из кусков тех же исходников. Поэтому файл без пары режется на куски по 20 секунд (не больше 24 штук, равномерно по файлу), и каждый кусок ищется в остальных файлах той же функцией и с теми же порогами.

На реальном проекте у готового ролика нашлось 15 кусков из 24. У разных кусков речи того же спикера 0 из 64. Порог 30% стоит с большим запасом в обе стороны.
Но тут есть ловушка. Куски находятся и у шумной камеры, которая целиком не совпала с рекордером. Если ее выкинуть, человек потеряет целый ракурс. Отличить помогло простое наблюдение: у камеры все найденные куски стоят на одном и том же сдвиге, а у монтажа на разных, ведь монтаж выкидывает время между кусками.
# где кусок лежит в другом файле минус где он лежит в этом
сдвиги = [...]
if max(сдвиги) - min(сдвиги) <= 5.0:
return None # один сдвиг: это камера, а не монтаж
И еще одна страховка. Файл исключается, только если у него другой размер кадра или метка времени позже съемки больше чем на 3 часа. Экспорт всегда делается после съемки, а повторный дубль в тот же день так не потеряется. В панели при этом прямо пишется, какой файл пропущен и почему.
Паузы: мерить в кадрах, а не «на слух»
Речь ищется по той же огибающей с гистерезисом. Фраза начинается, когда громкость выше -34 дБFS, а заканчивается, только когда упала на 10 дБ ниже. Порог не подходит ближе 9 дБ к локальному шуму, а шум считается в окне 20 секунд: в тихой комнате и на улице он разный. Тихие хвосты слов дотягиваются еще до 400 мс. Главное правило у меня со старта: лучше оставить лишний вдох, чем обрубить конец слова.
После первых прогонов стыки звучали как провалы. Пока я говорил себе «как‑то воздушно», не менялось ничего. Посчитал: на каждом резе оставалось 520 мс тишины, 360 после фразы и 160 перед следующей. Это 13 кадров. Сейчас на стандартной плотности 120 мс после и 60 перед, всего 4.5 кадра при 25 кадрах в секунду. Так мало можно потому, что тихие начала и хвосты уже внутри участка речи, а запас это чистый воздух сверх слова.

Пресеты плотности собраны так же: брал свой ручной монтаж и мерил, сколько тишины сам оставляю до фразы и после.
Плотность | Мин. пауза, мс | Запас до фразы, мс | Запас после, мс |
|---|---|---|---|
Бережно | 800 | 100 | 220 |
Стандарт | 500 | 60 | 120 |
Плотно | 300 | 40 | 80 |
Очень плотно | 200 | 25 | 50 |
Еще одна вещь, которую я выключил по замеру. Чистка «аканий», затяжек внутри фразы, на одном проекте дала 76 меток на 16 секунд. Это 1% хронометража ценой 76 лишних резов, 650 стало бы 726. Цена выше выгоды, теперь по умолчанию выключено.
Дубли: Whisper и обычный difflib
Речь распознает faster‑whisper с моделью large‑v3 и таймингами слов. Точность int8_float16: около 2.5 ГБ видеопамяти вместо примерно 5 у float16, и плагин влезает на бюджетные карты. Если видеокарты NVIDIA нет, все идет на процессоре, просто медленнее.
Дальше без всякой магии. Текст режется на фразы по паузам от 700 мс. Каждая фраза сравнивается со следующими 14 через SequenceMatcher из стандартной библиотеки. Похожесть от 0.45 или три одинаковых слова в начале значат повтор. Лучший дубль выбирается по простым приметам: фраза не оборвана на «и», «а», «что», в ней нет «стоп», «заново», «еще раз». Мат в фразе тоже очень хороший признак неудачного дубля, это тоже в словаре.
Самое важное тут не алгоритм, а что делать при сомнении. Если плагин не уверен, что это перезаход, он не режет, а ставит маркер с текстом фразы. Решает монтажер.
Что не получилось
Я хотел убирать мусор в паузах: щелчки, покашливания, машины за окном. Разметил на слух 14 таких мест, половина шум, половина короткие тихие слова. Пробовал по громкости, по частотам, через распознавание. Лучший результат 9 из 14, то есть терялось бы примерно два настоящих слова из пяти. А на чистом шуме Whisper начинает «слышать» слова, которых не было. Идея в корзину, неделя туда же.

Еще отказался удалять «ну», «типа», «вот» по словарю. Сравнил со своим ручным монтажом, и оказалось, что сам я эти слова чаще оставляю, чем режу.
Главное: регрессия перед каждым релизом
После бага с 88 минутами я понял неприятную вещь. Каждая правка синхрона чинит один случай и может тихо сломать другой. И узнаю я об этом от человека, у которого съехал проект.
Поэтому теперь есть набор синтетических сцен. Скрипт собирает их заново, гоняет через тот же код, что у пользователя, и сверяет три вещи: сколько моментов съемки, сколько видеодорожек и какая длина шкалы. И что в итоговом XML нет наложений.
Сцена | Что проверяет | Ожидание |
|---|---|---|
Подряд | три куска одной камеры друг за другом | 3 момента, 1 дорожка, 7.5 мин |
Параллель | две камеры одного момента, сдвиг 4 с | 1 момент, 2 дорожки, ~3 мин |
Смешанная | два момента по две камеры | 2 момента, 2 дорожки, ~5.5 мин |
Один | один файл | 1 момент, 1 дорожка, 3 мин |
Петличка | камера двумя кусками + петличка подряд | 1 момент, 1 дорожка, ~6.5 мин |
Две камеры | по три куска, часы второй сбиты | 3 момента, 2 дорожки |
Мусор | служебные звуки плагинов и бип в подпапках не берутся | 1 момент, 1 дорожка, 3 мин |
Рекордер | 30 мин подряд, куски камеры на 0-й, 12-й и 22-й минуте | 1 момент, 1 дорожка, 30 мин |
Экспорт | две камеры + готовый ролик из тех же кусков | 1 момент, 2 дорожки, ~4 мин |
Каждый найденный баг становится новой сценой. Скрипт выкладки не соберет новую версию, пока все сцены не сошлись.
Как это делалось с нейросетью
Честно и без романтики. Код писала нейросеть. Моя часть в том, чего она не знает: как режет живой монтажер, где стык звучит плохо и какие файлы реально лежат в папке после съемки. За месяц я вынес три правила, которые сработают и без нейросети.
«Исправил» ничего не значит без прогона. Два дня я правил панель, и ничего не менялось. Оказалось, Premiere все это время читал ее копию из системной папки. Теперь выкладка сама раскладывает файлы по всем местам и сверяет контрольные суммы.
У каждого числа есть история. В комментарии к константе написано, на чем ее мерили и почему именно так. Иначе через неделю ни я, ни нейросеть не вспомним, почему порог 6.5, и кто‑то «улучшит» его до 5.
Думать числами. «Как‑то не смотрится» это не правка. «На каждом резе 13 кадров тишины» это правка. В монтаже ровно так же.
Что в итоге
На интервью из беты с двумя камерами и рекордером из 35:25 исходника получилось 22:07: вырезано 249 пауз и 14 дублей, расчет занял 2 минуты. Без распознавания речи, только синхрон и паузы, 35 минут съемки считаются за 6 секунд. Раньше на такую черновую сборку у меня уходило несколько часов.
Это не замена монтажеру. Лучший дубль по смыслу плагин не выберет, и если спикер проглотил окончание, рез может пройти по краю слова. Зато механическую часть он забирает, а спорные места помечает маркерами.
И вопрос к тем, кто разбирается в обработке сигналов лучше меня. Огибающая с отрывом пика пока справляется, но наверняка есть способы точнее, например GCC‑PHAT, до которого я еще не добрался. Если вы делали синхрон по звуку иначе, расскажите в комментариях, мне правда интересно.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.