Техническая статья · для разработчиков
Приёму задержанной слуховой обратной связи семьдесят пять лет, и полвека он жил в железе — вплоть до устройств в ухо за несколько тысяч долларов. Сегодня то же самое делает браузер в телефоне. Но чтобы задать задержку, нужно сначала узнать, сколько её уже есть в тракте, — а браузер об этом честно не рассказывает. Пришлось мерить физически.
Это разбор для тех, кому интересно, как устроено под капотом: весь путь звука от getUserMedia до наушника, DSP внутри AudioWorkletProcessor, акустический замер задержки chirp-импульсом — и баг, из-за которого замер ошибался ровно на 500 мс. Если вы пришли за практикой, а не за кодом, вам сюда: как измерить задержку своего телефона.
Нужно замкнуть звуковое кольцо: голос → микрофон → обработка → наушники → ухо. Обработка — задержка (DAF) и/или сдвиг тона на пару полутонов (FAF: тот же эффект, но без замедления речи, поэтому годится для живого разговора).
Требование к задержке жёсткое: она должна быть управляемой в диапазоне примерно 50–250 мс. Рабочее значение у людей разное, подбирается перебором, и промах на сто миллисекунд — это разница между «говорить стало легче» и «ничего не изменилось».
А теперь неприятное. Суммарная задержка складывается так:
голос → микрофон → буфер захвата ОС → обработка → буфер вывода →
[ кодирование + радио + декодирование ] → динамик → ухо
Значит, «поставить задержку 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
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 — браузер может вырезать задержанный голос как эхо');
}
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 пустые. Поэтому если в списке нет ни одного имени — делаем пробный запрос, немедленно останавливаем треки и перечисляем устройства заново.
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;
}
}
Правила, которые здесь соблюдаются и которые стоит соблюдать всем:
process(). Все буферы созданы в конструкторе. Сборщик мусора в аудиопотоке — это щелчки.inputs[0][0] бывает пустым (устройство переключается, вкладка ушла в фон) — это не ошибка, это надо пережить, выдав тишину.Задержка — кольцевой буфер на 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 на сэмпл. Это не звуковой эффект, а страховка: человек снимает наушник, микрофон слышит динамик, и кольцо начинает возбуждаться. Лимитер не даёт этому превратиться в вой.
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 узкий пик автокорреляции — то есть в записи его видно даже тогда, когда по амплитуде он тише шума, — и энергия размазана по времени, поэтому громко «кричать» не нужно. Это тот же принцип, по которому работает радиолокация со сжатием импульса.
Наши параметры: 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);
}
Три решения, каждое оплачено кровью:
score отсекает импульсы, которые «нашлись» в шуме.Ещё есть нижняя граница: задержка меньше 10 мс физически невозможна, а вот прямая наводка из динамика в микрофон вполне может дать корреляцию на нулевом сдвиге. Поэтому поиск начинается с 10 мс.
Первая версия замера на 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 мс, о которых рапортует система. Разница небольшая, но она в правильную сторону: физический замер включает микрофонный путь и не включает то, что система приписывает себе «на всякий случай».
Практически результат используется так:
iPhone|bt|180. Корзина нужна, чтобы колебания в пару миллисекунд не плодили профили. Хранится в localStorage, с согласия пользователя обезличенно уходит на сервер — так подбор настроек для следующего человека с той же связкой становится точнее.iPhone. Профили получаются грубее, чем хотелось бы: iPhone SE и iPhone 15 Pro для нас одно и то же устройство.AudioContext уходит в suspended, экран гаснет. Спасают подписка на visibilitychange с автоматическим resume и Wake Lock.Нам не хватает замеров с разного железа. Если у вас Android-телефон и десять свободных минут — откройте тренажёр, пройдите калибровку и сделайте акустический замер. Связка «модель + наушники + задержка» уходит обезличенно и ровно для того, чтобы у следующего человека настройки подобрались сами. Особенно интересны расхождения физического замера с тем, что рапортует outputLatency.
Открывается по ссылке в браузере или в Telegram, ничего ставить не нужно. Первые 15 минут в день — бесплатно, без карты.
SKIR-DAF — тренажёр речи: он не является медицинским изделием и не заменяет консультацию специалиста. Приём изменённой слуховой обратной связи помогает не всем — поэтому базовые режимы бесплатны.