Why Your Web Audio Tuner Sucks at Low Notes (and What to Use Instead of FFT)

від

у

Чому ваш Web Audio тюнер погано працює з низькими нотами (і чим користуватися натомість FFT)

https://ift.tt/glNQh4K

«Першою річчю, до якої схиляються всі розробники, коли створюють в браузері детектор висоти тону, є AnalyserNode.getFloatFrequencyData() — спектр амплітуд FFT. Він працює, а потім зазнає збоїв, як тільки підключаєш гітару і граєш низьку ноту E. Ось чому так відбувається і що з цим робити».

Точне підстроювання з точністю до долей цента приблизно з ~80 рядків автокореляції. Без залежностей. Чистий JS.

Демонстрація: наукова UI залишається у режимі гітари; живий сигнал внизу. Темна тема.
Demo: https://ift.tt/bYXRZOL
GitHub: https://ift.tt/VurDQnP

Симптом — тюнеры на основі FFT не читають низькі ноти
Канонічний детектор висоти тону в Web Audio виглядає так:
const ctx = new AudioContext(); // частота дискретизації за замовчуванням 48000
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048; // за замовчуванням
const spectrum = new Float32Array(analyser.frequencyBinCount);

requestAnimationFrame(function loop() {
analyser.getFloatFrequencyData(spectrum);
const maxBin = argmax(spectrum);
const freq = maxBin * ctx.sampleRate / analyser.fftSize;
// перетворити частоту на назву ноти…
requestAnimationFrame(loop);
});

Коли граєш A4 (440 Гц), виявляється приблизно 440. Прекрасно. А коли грати низьку E на гітарі (E2 = 82.41 Гц), відлік коливається між “82 Гц” та “105 Гц” як зламаний курсор. Причина — ширина бінів:
bin width = sampleRate / fftSize = 48000 / 2048 = 23.4375 Hz

І відстань між полутоном зростає пропорційно частоті, тому низькі ноти “ближчі одна до одної”:

E2 82.41 Гц — 4.90 Гц до F2
A2 110.00 Гц — 6.54 Гц
A4 440.00 Гц — 26.16 Гц
A5 880.00 Гц — 52.33 Гц

E2 та F2 розділені менш ніж на одну п’яту бінa. FFT буквально не може їх відрізнити. Відчуття “я підстроїв гітару — звучить неправильно” — це ваш тюнер.

Обхідні шляхи та їх обмеження
– Збільшуємо fftSize: 32768 дає ширину бінів близько 1.46 Hz, але fftSize >= 2 * N, тобто потрібно 32768 зразків (близько 0.68 с затримки за 48 кГц) на кадр. Досить важко для живого дисплея.
– Zero padding: підвищує роздільну здатність лише виглядів. Ви не вимірюєте більше — ви інтерполюєте.
– Парболічна інтерполяція: підганяє квадратичну криву до трьох сусідніх бінів та оцінює пікове значення. Класичний прийом, наближає, але не допомагає, якщо два піки зливаються в один.

І тут є більш глибока проблема.

Нота — це не частота
A4 не чиста; вона містить півтони 880, 1320, 1760 Гц і вище. У спектрі за амплітудою FFT залежно від інструменту та моменту запису другий гармонік може бути голоснішим за основний. Роз個 наприклад, зірвана гітара відразу після атаки піку — відомий випадок. Наївний argmax повертає 880 Гц — A5, на октаву вище.

Можна пристосувати (HPS = Harmonic Product Spectrum, або піковий відбір з гармонічними обмеженнями), але ці підходи поводяться не з питань висоти тону, а з очищенням безладу, який дали FFT. І вони не виправляють проблему з шириною біну.

Фізичне визначення висоти тону — це період, з якого повторюється сигнал. Якщо сигнал повторюється кожні 480 зразків за 48 кГц, це висота тону 100 Гц. Гармоніки не змінюють період — вони змінюють форму кожного циклу. Тому виміряйте період безпосередньо.

Автокореляція — вимірювання періоду безпосередньо
Функція автокореляції (ACF) — це внутрішній добуток буфера з копією себе, зсунутою на τ зразків:
r[τ] = Σ_{i=0..N−τ−1} buffer[i] · buffer[i + τ]

– При τ = 0 кожен член дорівнює buffer[i]^2, отже r[0] — максимум (енергія сигналу).
– Коли τ досягає одного періоду, зсунута копія суміщується знову і r[τ] досягає піку.
– Розташування першого помітного піку — період.

Частота = sampleRate / τ. Ось і вся ідея.

export function detectPitch(buffer, sampleRate) {
const N = buffer.length;
const minLag = Math.floor(sampleRate / 1500); // верхня межа частоти 1500 Гц
const maxLag = Math.floor(sampleRate / 60); // нижня межа частоти 60 Гц

// RMS вхідного сигналу, щоб не запускати на тиші
let sumSq = 0;
for (let i = 0; i < N; i++) sumSq += buffer[i] * buffer[i]; if (Math.sqrt(sumSq / N) < 0.01) return null; const r0 = acf(buffer, 0, N); // Пропустити початковий спад від r[0] let lag = minLag; while (lag < maxLag && acf(buffer, lag, N) > acf(buffer, lag + 1, N)) lag++;

// Знайти найвищу локальну максимальну в залишеному діапазоні
let bestLag = -1, bestVal = -Infinity;
while (lag < maxLag - 1) { const a = acf(buffer, lag - 1, N); const b = acf(buffer, lag, N); const c = acf(buffer, lag + 1, N); if (b > a && b >= c && b > bestVal) { bestVal = b; bestLag = lag; }
lag++;
}
if (bestLag < 0 || bestVal / r0 < 0.5) return null; // відхиляємо слабку періодичність // Парболічна інтерполяція для субзразкової точності const a = acf(buffer, bestLag - 1, N); const b = bestVal; const c = acf(buffer, bestLag + 1, N); const shift = (a - c) / (2 * (a - 2 * b + c)); return sampleRate / (bestLag + shift); } acf(buf, lag, N) { let s = 0; for (let i = 0; i < N - lag; i++) s += buf[i] * buf[i + lag]; return s; } Три речі мають значення: 1) Вибирайте maxLag з урахуванням нижньої частоти: нижній ліміт 60 Гц означає maxLag = 800 зразків при 48 кГц. Бажано, щоб у буфері було щонайменше дві періоди, тому maxLag ≤ N / 2 — практична межа. Занадто низьке значення — голодить оцінювач. 2) Пропускайте початковий спад: r[τ] — плато навколо τ = 0, потім знижується, потім знову зростає до періоду. Пройдіть вперед, поки значення не перестануть знижуватися, перш ніж почати шукати піки — інакше ви підберете τ = 1 і отримаєте 48 кГц. 3) Відкидайте слабкі піки відносно r[0]. Шум не має чіткого періоду, тому не створює гострий пік. bestVal / r0 < 0.5 говорить "у нас немає впевненого періоду" і повертає null. Це дозволяє тюнеру мовчати на фоновому шипінні, замість того щоб гадати. Парболічна інтерполяція для субзразкової точності Звичайні затримки — це дискретні цілочисельні значення. Для A4 (440 Гц, період ≈ 109 зразків) відхилення на один зразок дає 48000/109 - 48000/110 = 4 Гц, близько 15 центов. Не достатньо. Підведіть параболу через (bestLag-1, bestLag, bestLag+1) і розв’яжіть вершину. Знайома трюк, близько 4 рядків коду, і тести показують, що він виводить чисті синуси з точністю нижче 1 цента. Завдяки цій техніці ви отримаєте стабільність і точність по всьому діапазону інструмента. Зв’язування з Web Audio Вам потрібен часовий буфер, не FFT. AnalyserNode.getFloatTimeDomainData() повертає Float32Array з сирими зразками, саме те, чого потребує detectPitch. Зауваження: елементи конфігурації getUserMedia: echoCancellation, noiseSuppression, autoGainControl за замовчуванням true. Вони припускають роботу голосу та активно пригнічують чисті тони — вони повністю заглушать ваш тести-сінус. Встановлюйте їх у false для будь-якого музичного застосування. Смужки в логарифмічному частотному просторі, не в Гц Висновок буде коливатися, якщо ви відображаєте сирі щоденні оцінки кадру, тому краще використати експоненційне згладжування. Важливо: робити це у лог₂(Гц), а не лінійних Hz. Людське сприйняття висоти тону — логарифмічне — 2 Гц коливання на A2 — зовсім інша проблема, ніж 2 Гц коливання на A5. alpha = 0.35 sLog = Math.log2(smoothedFreq); fLog = Math.log2(newFreq); smoothedFreq = Math.pow(2, sLog * (1 - alpha) + fLog * alpha); Результат: однакова чутливість і стабільність по всьому діапазону інструмента. Вартість проти альтернативи ACF має складність O(N · L), де L — діапазон кандидатних лагів. Для N = 2048 та L ≈ 768 це близько 1.5 млн множень і додавань за кадр. Ноутбучний Chrome виконує це значно швидше, ніж 1 мс — незначно в рамках 16.7 мс анімаційного кадру. FFT має O(N log N), приблизно на 60 разів дешевше за теорією. Але коли ваш кадр ACF уже субмикросекундний, перевага не настільки переконлива, щоб відмовитися від точності низьких нот. Якщо буфери більші, або багатоканальний вхід, або посилена денойзинг — розгляньте YIN (покращена версія ACF) або CREPE (невелика нейронна мережева модель). Але для монофонного тюнера чиста автокореляція — достатня, і поміщається в 80 рядків. Висновок - Тюнеры з FFT зазнають невдалого відліку на низьких частотах через широкі біни (bin width ≈ sampleRate / fftSize ≈ 23 Hz), які більші за відстань міжSemitone на E2 (4.9 Hz). - Висота тону — це період повторення сигналу. Вимірюйте його безпосередньо за допомогою автокореляції. - Три речі, що роблять алгоритм надійним: пропустити початковий спад, порогова перевірка відношення піка до енергії для відсіювання шуму, та парболічна інтерполяція піку для субзразкової точності. - Попередження щодо інтеграції з Web Audio: використовуйте getFloatTimeDomainData(), вимкніть ефекти голосового помічника у constraints getUserMedia, згладжуйте в логарифмічному просторі. Джерело повного коду на GitHub — detector.js: сам алгоритм; tests/detector.test.js містить 14 тестів, що охоплюють синуси, Sawtooth, тишу, шум та всі пресети тюнера. MIT ліцензія. Live-demo — дозволити доступ до мікрофона та співати, свистіти або грати на інструменті. HI-FI News за DEV Community: javascript Квітень 25, 2026 року, 12:54 AM April 25, 2026 at 12:54AM