Разбираем баг в детекторе высоты тона на YIN: почему все ноты были выше и как исправить. Читайте и проверьте свой алгоритм!
Вы когда-нибудь замечали, что ваш детектор высоты тона всегда показывает ноты чуть выше, чем они есть на самом деле? Я столкнулся с этим, когда строил свой инструмент для вокалистов. Оказалось, что проблема не в моём голосе, а в математике, лежащей в основе алгоритма. В этой статье я расскажу, как найти и исправить подобный баг, и дам практические советы, которые помогут вам проверить свой код.
Vocal Pitch Monitor — это Android-приложение, которое рисует ноту на графике в реальном времени. Оно популярно: более миллиона загрузок и 4.1 звезды на Google Play. Но если посмотреть на поисковые подсказки Google, видно, что люди ищут версии для других платформ: "vocal pitch monitor online", "vocal pitch monitor pc", "vocal pitch monitor free". iOS-версия от того же разработчика имеет всего 2.6 звезды и 42 отзыва. Такой разрыв между версиями — сигнал, что есть спрос на альтернативу.
Современные браузеры поддерживают Web Audio API, который может получать сырые аудиоданные с микрофона. Это значит, что такой инструмент больше не обязан быть приложением — он может работать прямо в браузере. Я решил построить свой детектор, но при проверке обнаружил, что он работает неправильно.
Я перестал тестировать свой детектор на своём голосе и начал подавать на вход чистые тоны с известной частотой. Результаты меня озадачили: показания всегда были завышены, причём тем сильнее, чем ниже нота. Вот пример:
Частота Мой детектор
82.41 Гц +29.8 центов
98 Гц +15.5 центов
130.81 Гц +9.1 центов
440 Гц +2.6 центов
Тридцать центов — это примерно треть полутона. На низкой ноте "ми" мой инструмент врал бы любому, кто проверяет его камертоном. Три симптома указывали на одну причину: знак всегда положительный, ошибка растёт на низких нотах, и она особенно заметна на чистой синусоиде, а не на голосе.
Мой детектор использует алгоритм YIN. Он строит функцию разности:
d(tau) = sum_j (x[j] - x[j+tau])^2
Это можно разложить как:
d(tau) = мощность головного окна
+ мощность хвостового окна
- 2 * автокорреляция(tau)
Обычная быстрая реализация вычисляет автокорреляцию через БПФ и использует упрощение: предполагает, что обе мощности равны r0 (автокорреляции при нулевом лаге). В итоге получается:
d(tau) = 2*r0 - 2*acf[tau]
Вот здесь и кроется ошибка. Дело в том, что автокорреляция через БПФ смещена. При лаге tau перекрывается только N - tau пар отсчётов, но результат всё равно делится на N. Поэтому acf[tau] искусственно убывает с ростом tau, даже для идеально периодического сигнала. Подставьте это в формулу — и разность завышается на больших лагах, а минимум сдвигается к меньшим лагам. Меньший лаг означает более высокую частоту. В итоге все показания завышены.
Теперь все три симптома объясняются: смещение всегда в одну сторону, поэтому знак всегда плюс; низкие ноты имеют большой период, а значит большой лаг, где смещение максимально; чистая синусоида имеет широкий плоский минимум, который легко сдвинуть, а голос с богатым гармоническим составом — узкий и устойчивый.
Вместо того чтобы исправлять автокорреляцию, я пересчитал функцию разности честно, на окне постоянной длины, для нескольких лагов вокруг первой оценки. Вот пример кода на Python:
def difference_function(signal, tau_min, tau_max):
n = len(signal)
# Используем половину буфера, чтобы окно было одинаковым для всех лагов
window_size = n // 2
d = []
for tau in range(tau_min, tau_max + 1):
diff = 0.0
for j in range(window_size):
diff += (signal[j] - signal[j + tau]) ** 2
d.append(diff)
return d
Постоянная длина — ключевой момент. Каждый лаг оценивается по одному и тому же числу отсчётов, поэтому артефакт счёта исчезает. Затем берём локальный минимум и интерполируем параболой. Это стоит один узкий проход по половине буфера, что немного по сравнению с уже выполненным БПФ.
После исправления таблица стала идеальной:
82.41 Гц 0.0 центов
98 Гц 0.0 центов
130.81 Гц 0.0 центов
440 Гц 0.0 центов
Сначала я исправил баг неправильно. Моя первая версия искала три отсчёта в обе стороны от первой оценки. На 82 Гц это не дало никакого эффекта. Ошибка в тридцать центов — это около девяти отсчётов на низкой ноте и меньше одного на высокой. Фиксированная ширина поиска пропускала именно те случаи, где нужна была корректировка, и помогала только там, где она не нужна. Ширина должна была быть пропорциональна периоду:
radius = max(3, round(tau_center * 0.04))
Когда размер бага зависит от какой-то величины, исправление должно зависеть от неё так же. Константа против пропорционального дефекта работает только в одной точке шкалы.
Ещё одна деталь меня спасла: мой поиск возвращает исходную оценку без изменений, если минимум попадает на границу интервала, а не интерполирует с отсутствующим соседом. Эта защита позволила ошибке проявиться как "нет изменений", а не как правдоподобное неверное число. Защита, которая возвращает вход, когда не уверена, ценнее той, что всегда выдаёт ответ.
Держите два вида тестов отдельно. У меня есть стенд, который сравнивает мой порт с эталонной реализацией с точностью до десятитысячной цента, и он оставался зелёным всё это время, потому что обе стороны были неправильны одинаково. Тест на соответствие говорит лишь о том, что вы скопировали правильно. Только известная частота говорит, что вы правы.
Если вы разрабатываете или используете детектор высоты тона, немедленно проверьте его на чистых тонах с известной частотой, особенно в низком регистре. Если обнаружите, что показания завышены, посмотрите, как вы вычисляете автокорреляцию: вероятно, там есть смещение из-за БПФ. Исправьте функцию разности, как показано выше, и убедитесь, что ширина поиска масштабируется с периодом. Эти простые шаги сделают ваш инструмент точным и надёжным.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →