Техническая статья · для разработчиков

Как мы измеряем задержку наушников из браузера: AudioWorklet и chirp-замер

Обновлено 8 сентября 2026 · 16 минут чтения

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

Это разбор для тех, кому интересно, как устроено под капотом: весь путь звука от getUserMedia до наушника, DSP внутри AudioWorkletProcessor, акустический замер задержки chirp-импульсом — и баг, из-за которого замер ошибался ровно на 500 мс. Если вы пришли за практикой, а не за кодом, вам сюда: как измерить задержку своего телефона.

Задача

Нужно замкнуть звуковое кольцо: голос → микрофон → обработка → наушники → ухо. Обработка — задержка (DAF) и/или сдвиг тона на пару полутонов (FAF: тот же эффект, но без замедления речи, поэтому годится для живого разговора).

Требование к задержке жёсткое: она должна быть управляемой в диапазоне примерно 50–250 мс. Рабочее значение у людей разное, подбирается перебором, и промах на сто миллисекунд — это разница между «говорить стало легче» и «ничего не изменилось».

А теперь неприятное. Суммарная задержка складывается так:

голос → микрофон → буфер захвата ОС → обработка → буфер вывода →
    [ кодирование + радио + декодирование ] → динамик → ухо
В квадратных скобках — Bluetooth. Это 150–200 мс, и они возникают уже после того, как приложение отдало звук системе.

Значит, «поставить задержку 180 мс» нельзя: у проводных наушников канал съедает единицы миллисекунд, у беспроводных — почти весь нужный диапазон. Надо знать, сколько уже есть, и добавлять разницу. А если канал даёт больше нужного — не добавлять ничего и работать сдвигом тона.

Почему браузер

Короткий ответ — дистрибуция. Нативное приложение это две платформы, сторы и модерация; веб — это Telegram Mini App и PWA по одной ссылке, без установки, с одной кодовой базой. Для инструмента, который человек хочет попробовать прямо сейчас и решить, работает ли приём лично на нём, «без установки» — не удобство, а условие.

Длинный ответ: Web Audio для такой задачи хватает, если соблюдать несколько правил. Ниже они все будут.

Стек получился скучным, и это хорошо: TypeScript и Vite, ноль runtime-зависимостей, а DSP — обычные классы, ничего не знающие про Web Audio. Один и тот же код исполняется в двух местах: в браузере внутри AudioWorkletProcessor и в Node под vitest, где его можно кормить синтетическими сигналами и проверять цифры. Для аудио это едва ли не главное архитектурное решение: тестировать DSP через настоящий аудиоконтекст невозможно, а через чистые функции — тривиально.

in → meter → DelayLine(DAF) → PitchShifter(FAF) → ×gain →
   + Noise(VAD) → + Metronome → Limiter → out
Цепочка обработки. Всё внутри одного воркета, блоками по 128 кадров.

Тракт по шагам

1. getUserMedia: три флага, без которых ничего не работает

const base: MediaTrackConstraints = {
  echoCancellation: false,
  noiseSuppression: false,
  autoGainControl: false,
  channelCount: 1,
};

Каждый флаг здесь — не оптимизация, а необходимость.

echoCancellation: false — самое важное. Подавление эха занимается ровно тем, что мы делаем специально: находит в сигнале микрофона то, что недавно играло в динамике, и вычитает. Наш задержанный голос для этого алгоритма — образцовое эхо. С включённым подавлением приложение выглядит рабочим (звук идёт, индикаторы шевелятся), а эффект просто пропадает — тем сильнее, чем лучше алгоритм у браузера.

noiseSuppression: false — шумодав режет то, что считает шумом; наш маскирующий розовый шум он считает шумом с полным основанием.

autoGainControl: false — автоматическая регулировка усиления ломает измерение уровня. У нас громкость маскирующего шума задана относительно RMS голоса, и если микрофонный тракт сам подкручивает усиление, эта привязка плывёт.

Тонкость: браузер имеет право проигнорировать constraints. Поэтому после получения потока мы смотрим, что получилось на самом деле, и пишем предупреждение в служебный журнал:

const st = track.getSettings?.() ?? {};
if (st.echoCancellation === true) {
  log.warn('audio',
    'echoCancellation=true — браузер может вырезать задержанный голос как эхо');
}

2. Какой микрофон брать — не тот, который кажется очевидным

Bluetooth-гарнитура видна браузеру и как выход, и как вход. Логично взять её микрофон: он ближе ко рту. Так делать нельзя.

Как только система отдаёт приложению микрофон гарнитуры, она переводит её из A2DP (односторонний стереопрофиль хорошего качества) в HFP/SCO — двусторонний «телефонный» режим. Звук в наушниках падает до узкополосного, а задержка при этом не становится меньше. Мы теряем качество и не выигрываем ничего.

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

Проблема в том, что Web API не говорит «это Bluetooth». Есть только label, и то не всегда. Пришлось писать эвристику:

const BT_MARKERS = [
  'bluetooth', 'hands-free', 'handsfree', 'headset', 'airpods', 'buds',
  'wh-', 'wf-', 'sco', 'hfp', 'wireless', 'беспровод', 'гарнитур',
  'jbl', 'bose', 'sony', /* … */
];
const BUILTIN_MARKERS = [
  'built-in', 'internal', 'встроен', 'iphone', 'ipad', /* … */
];

Логика выбора: сначала явно встроенный и не Bluetooth; если такого нет — единственный не-Bluetooth; если и тут неоднозначно — 'default' и запись в журнал, что именно было в списке. Плюс ручной выбор в настройках, потому что никакая эвристика не покроет весь зоопарк устройств.

Отдельная мелочь, на которую уходит время у всех, кто делает это впервые: до первого getUserMedia все label пустые. Поэтому если в списке нет ни одного имени — делаем пробный запрос, немедленно останавливаем треки и перечисляем устройства заново.

3. AudioWorklet и правила жизни в аудиопотоке

ScriptProcessorNode устарел и работает в главном потоке — для нашей задачи это неприемлемо: любая перерисовка интерфейса даёт слышимый щелчок. AudioWorkletProcessor живёт в аудиопотоке и получает блоки по 128 кадров, то есть 2,67 мс при 48 кГц.

Сам процессор — тонкая обёртка, вся логика в движке:

class SkirDafProcessor extends AudioWorkletProcessor {
  private readonly engine = new Engine(sampleRate);
  process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
    const out = outputs[0][0];
    const inCh = inputs[0] && inputs[0][0];
    const input = inCh && inCh.length === out.length ? inCh : null;
    if (!input) this.inputGaps++;          // вход пропал, пригодится в журнале
    this.engine.processBlock(input, out);
    if ((++this.blocks & (STATS_EVERY_BLOCKS - 1)) === 0)
      this.port.postMessage(this.stats());
    return true;
  }
}

Правила, которые здесь соблюдаются и которые стоит соблюдать всем:

4. DSP: что внутри

Задержка — кольцевой буфер на 65 536 сэмплов (1,37 с при 48 кГц), степень двойки, чтобы вместо остатка от деления была маска. Единственная неочевидная деталь — кроссфейд:

process(x: number): number {
  const buf = this.buf, mask = this.mask, w = this.w;
  buf[w] = x;
  let y: number;
  if (this.fadePos > 0) {                    // идёт смена задержки
    const t = 1 - this.fadePos / this.fadeLen;
    const a = buf[(w - this.oldDelay) & mask];
    const b = buf[(w - this.delay) & mask];
    y = a + (b - a) * t;                     // 8 мс перехода
    this.fadePos--;
  } else {
    y = buf[(w - this.delay) & mask];
  }
  this.w = (w + 1) & mask;
  return y;
}

Без этих восьми миллисекунд каждое движение ползунка задержки даёт отчётливый щелчок в ухо — а ползунок человек двигает постоянно, он же подбирает своё значение.

Сдвиг тона — гранулярный алгоритм на той же линии задержки: две читающие головки едут по буферу со скоростью 2st/12 относительно записи, окна Ханна обеих головок в сумме дают единицу, и скачок при переходе головки через границу окна попадает ровно в ноль её окна. Окно 40 мс, то есть алгоритм добавляет к тракту ещё около 20 мс в среднем — их тоже надо держать в уме, когда считаешь общую задержку.

Известный артефакт честно записан в комментарии к классу: на стационарных тонах спектральный пик смещён на ±(f₀·W mod 1)/W относительно точного отношения. На речи не слышно, на синтезаторе слышно сразу. Фазовый вокодер или WSOLA дали бы чище, но стоят дороже по процессору — это в списке улучшений.

Детектор голосовой активности — RMS с адаптивным порогом: оценка фона ползёт вниз быстро (коэффициент 0,2) и вверх медленно (0,005, около двух секунд при блоках 10 мс), и только пока нет речи, чтобы фон не «догонял» голос. Гистерезис (порог выключения вдвое ниже порога включения) плюс удержание в 30 блоков, иначе маскирующий шум дробится на паузах между словами.

Детектор нужен ради одной продуктовой идеи, которой я неожиданно горжусь: уровень маскирующего шума задан не в децибелах, а относительно громкости вашего голоса.

// mask = 0.7 → шум на 30 % тише голоса.
// noiseRms — измеренный RMS генератора при level = 1.
const target = (this.speechEnv * gain * mask) / noiseRms;
noise.setLevel(gated ? (speaking ? target : 0) : target);

Человек крутит не абстрактную громкость, а понятное «насколько шум тише меня»; в паузах шум уходит в ноль, и случайно оглушить себя становится заметно труднее. Для приёма, где громкий шум в наушниках — реальный риск для слуха, это важнее, чем звучит.

Лимитер — мягкий пиковый, порог 0,92, кривая 1/(1 + 8·over), release 0,995 на сэмпл. Это не звуковой эффект, а страховка: человек снимает наушник, микрофон слышит динамик, и кольцо начинает возбуждаться. Лимитер не даёт этому превратиться в вой.

5. Как понять, что тракт поплыл

Web Audio почти ничего не рассказывает о своём здоровье, поэтому пришлось собрать три независимых индикатора.

Дрейф часов. У AudioContext своё время; если аудиопоток не успевает, оно отстаёт от performance.now(). Скачок больше двух квантов (2 × 2,67 мс) считаем сбоем:

const drift =
  (performance.now() - ref.perf) / 1000 - (ctx.currentTime - ref.ctx);
const quantum = 128 / ctx.sampleRate;
const jump = drift - ref.lastDrift;
if (jump > quantum * 2) {
  glitches++;
  log.warn('audio', `дрейф часов +${(jump * 1000).toFixed(1)} мс`);
}
ref.lastDrift = drift;

renderCapacity (там, где поддерживается) даёт underrunRatio напрямую — подписываемся и считаем. inputGaps из воркета — сколько раз вход оказался пустым.

Все три складываются в служебный журнал, который человек может приложить к обращению в поддержку одной кнопкой. Половина писем «у меня не работает» разбирается по одной строчке из него — обычно это echoCancellation=true или выбранный микрофон гарнитуры.

Сколько задержки на самом деле

Теперь к главному вопросу. У AudioContext есть два свойства: baseLatency — задержка обработки внутри графа, и outputLatency — оценка задержки вывода вместе с буферами системы. Они полезны, мы их читаем и складываем в профиль устройства: на iPhone с беспроводными наушниками система обычно рапортует около 176 мс, цифра правдоподобная.

Но опираться только на неё нельзя:

Значит, нужен сквозной физический замер: приложение само издаёт звук и само его слышит.

Chirp-замер

Почему chirp, а не щелчок

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

Линейная частотная модуляция решает обе проблемы. У chirp узкий пик автокорреляции — то есть в записи его видно даже тогда, когда по амплитуде он тише шума, — и энергия размазана по времени, поэтому громко «кричать» не нужно. Это тот же принцип, по которому работает радиолокация со сжатием импульса.

Наши параметры: 20 мс, частота едет с 1 до 4 кГц, окно Ханна, амплитуда 0,5.

export function makeChirp(
  sampleRate: number, ms = 20, f0 = 1000, f1 = 4000,
): Float32Array {
  const n = Math.round((sampleRate * ms) / 1000);
  const out = new Float32Array(n);
  const T = n / sampleRate;
  const k = (f1 - f0) / T;                    // скорость свипа, Гц/с
  for (let i = 0; i < n; i++) {
    const t = i / sampleRate;
    const phase = 2 * Math.PI * (f0 * t + 0.5 * k * t * t);
    const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (n - 1));   // Ханн
    out[i] = 0.5 * Math.sin(phase) * w;
  }
  return out;
}

Диапазон 1–4 кГц выбран не случайно: там хорошо работают и микрофон телефона, и динамик наушника, и туда не лезет низкочастотный гул комнаты.

Главный трюк: общая система координат

Чтобы измерить задержку, нужно знать две вещи в одних единицах: когда импульс начал играть и когда он пришёл в запись.

Первое даёт Web Audio: AudioBufferSourceNode.start(at) планирует воспроизведение на конкретный момент таймлайна AudioContext.

Второе — самодельный воркет-рекордер, который к каждому блоку прикладывает currentFrame, глобальную переменную аудиопотока со счётчиком отрендеренных кадров:

class SkirRecorderProcessor extends AudioWorkletProcessor {
  process(inputs: Float32Array[][]): boolean {
    const inCh = inputs[0] && inputs[0][0];
    if (this.active && inCh?.length) {
      // Копия обязательна: буфер входа переиспользуется движком.
      this.port.postMessage({
        type: 'block', frame: currentFrame, data: Float32Array.from(inCh),
      });
    }
    return this.active;
  }
}

Теперь позиция импульса в склеенной записи и время его старта живут в одной системе координат, и задержка — это просто их разность. Без привязки к currentFrame пришлось бы гадать, сколько времени сообщение шло из аудиопотока в главный, и точность рассыпалась бы.

Планирование выглядит так:

const t0 = ctx.currentTime + 0.3;          // 0,3 с на прогрев тракта
for (let i = 0; i < CHIRP_COUNT; i++) {
  const s = ctx.createBufferSource();
  s.buffer = buf;
  s.connect(ctx.destination);
  const at = t0 + (i * CHIRP_INTERVAL_MS) / 1000;
  s.start(at);
  starts.push(at);
}

Поиск: нормированная кросс-корреляция

Дальше скользящим окном считаем корреляцию записи с эталоном, нормируя на энергию обоих:

export function findChirp(
  rec: Float32Array, ref: Float32Array, from: number, to: number,
): Match {
  const n = ref.length;
  let refE = 0;
  for (let i = 0; i < n; i++) refE += ref[i] * ref[i];
  const refNorm = Math.sqrt(refE) || 1;
  const end = Math.min(to, rec.length - n);
  let best = -1, bestIdx = -1;
  let recE = 0;                             // скользящая энергия окна записи
  const start = Math.max(0, from);
  for (let i = start; i < start + n && i < rec.length; i++) {
    recE += rec[i] * rec[i];
  }
  for (let pos = start; pos < end; pos++) {
    if (pos > start) {                      // вычли ушедший, добавили пришедший
      const outV = rec[pos - 1], inV = rec[pos + n - 1];
      recE += inV * inV - outV * outV;
    }
    if (recE <= 1e-12) continue;
    let dot = 0;
    for (let i = 0; i < n; i++) dot += rec[pos + i] * ref[i];
    const score = dot / (refNorm * Math.sqrt(recE));
    if (score > best) { best = score; bestIdx = pos; }
  }
  return { index: bestIdx, score: Math.max(0, best) };
}

Нормировка здесь принципиальна: без неё алгоритм всегда предпочтёт громкое место записи, а нам нужно похожее, а не громкое. score получается в диапазоне от нуля до единицы и работает как коэффициент доверия: при шуме порог около 0,25–0,3 отделяет найденное от выдуманного.

Да, это наивная свёртка вместо честного БПФ. При окне 700 мс и эталоне 960 сэмплов это порядка тридцати миллионов умножений на импульс — десятки миллисекунд в главном потоке, один раз за замер. БПФ сэкономил бы время, которого никто не заметит, и добавил бы код, который придётся сопровождать. Не тот случай.

Агрегация: почему одного импульса мало

Импульсов пять. По каждому — своя задержка и свой score. Дальше:

export function summarize(
  delaysMs: number[], scores: number[], minScore = 0.25,
): MeasurementResult {
  const good = delaysMs.filter((d, i) =>
    scores[i] >= minScore && Number.isFinite(d) && d >= MIN_DELAY_MS);
  if (good.length < 3) {
    return fail(`распознано только ${good.length} из ${delaysMs.length}`);
  }
  // Сначала выбросы, и только потом разброс:
  // один сбойный импульс не должен рушить замер.
  const median0 = medianOf(good);
  const kept = good.filter((d) => Math.abs(d - median0) <= OUTLIER_MS);
  if (kept.length < 3) return fail('импульсы разошлись');
  const median = medianOf(kept);
  const spread = Math.max(...kept) - Math.min(...kept);
  if (spread > MAX_SPREAD_MS) {
    return fail(`разброс ${spread.toFixed(0)} мс больше ${MAX_SPREAD_MS} мс`);
  }
  return ok(median, spread);
}

Три решения, каждое оплачено кровью:

Ещё есть нижняя граница: задержка меньше 10 мс физически невозможна, а вот прямая наводка из динамика в микрофон вполне может дать корреляцию на нулевом сдвиге. Поэтому поиск начинается с 10 мс.

Баг: «разброс 500 мс»

Первая версия замера на iPhone стабильно проваливалась. Не «иногда», не «плавает» — стабильно возвращала ошибку с огромным разбросом. При этом на синтетических тестах в Node всё было идеально.

Разгадка была в цифре. Ошибка составляла ровно 500 мс — а 500 мс был интервал между импульсами.

Дальше всё складывается за минуту. Окно поиска было длиной одна секунда — шире интервала между импульсами. То есть в окно каждого импульса попадал следующий. Пока свой импульс ловился уверенно, максимум корреляции был на нём. Но человек во время замера подносит наушник к микрофону — первые импульсы тише последних. Как только свой импульс становился слабее соседнего, алгоритм честно выбирал более похожее место — то есть следующий импульс, — и задержка получалась ровно на интервал больше.

Классика: ошибка, равная константе из вашего же кода. Если видите такую — не ищите плавающий баг, ищите константу.

Починка свелась к четырём числам и одному жёсткому правилу:

export const CHIRP_INTERVAL_MS = 800;
export const SEARCH_WINDOW_MS = 700;   // ОБЯЗАНО быть меньше интервала
export const MIN_DELAY_MS = 10;
export const MAX_SPREAD_MS = 30;
export const OUTLIER_MS = 40;

И регрессионный тест, который воспроизводит именно ту ситуацию — нарастающую громкость:

it('окно поиска уже интервала между импульсами', () => {
  expect(SEARCH_WINDOW_MS).toBeLessThan(CHIRP_INTERVAL_MS);
  const chirp = makeChirp(SR);
  const delay = Math.round((180 / 1000) * SR);   // задержка тракта 180 мс
  const step = Math.round((CHIRP_INTERVAL_MS / 1000) * SR);
  const rec = new Float32Array(step * (CHIRP_COUNT + 1));
  for (let i = 0; i < rec.length; i++) rec[i] = 0.01 * rnd();
  // Первый импульс тише последнего — так бывает,
  // когда наушник подносят к микрофону.
  for (let k = 0; k < CHIRP_COUNT; k++) {
    const at = k * step + delay;
    const gain = 0.05 + k * 0.2;
    for (let i = 0; i < chirp.length; i++) rec[at + i] += gain * chirp[i];
  }
  // Ищем первый, самый тихий импульс.
  const m = findChirp(rec, chirp, from, to);
  expect(Math.abs((m.index / SR) * 1000 - 180)).toBeLessThan(2);
});

Отдельно стоит сказать про честную медиану. В первой версии при чётном числе значений возвращался элемент по индексу length / 2, и тест на четырёх значениях проходил «случайно правильно». Теперь считается среднее двух средних — и тест на наборе 180, 182, 179, 181 ожидает 180,5, а не 181.

Что дала цифра

Замер на iPhone с AirPods Pro дал 162 мс против 176 мс, о которых рапортует система. Разница небольшая, но она в правильную сторону: физический замер включает микрофонный путь и не включает то, что система приписывает себе «на всякий случай».

Практически результат используется так:

Что не получилось и где грабли

Помогите цифрами, если у вас Android

Нам не хватает замеров с разного железа. Если у вас Android-телефон и десять свободных минут — откройте тренажёр, пройдите калибровку и сделайте акустический замер. Связка «модель + наушники + задержка» уходит обезличенно и ровно для того, чтобы у следующего человека настройки подобрались сами. Особенно интересны расхождения физического замера с тем, что рапортует outputLatency.

Посмотреть, как это работает

Открывается по ссылке в браузере или в Telegram, ничего ставить не нужно. Первые 15 минут в день — бесплатно, без карты.

SKIR-DAF — тренажёр речи: он не является медицинским изделием и не заменяет консультацию специалиста. Приём изменённой слуховой обратной связи помогает не всем — поэтому базовые режимы бесплатны.