28 December 2019

«Когда часы двенадцать бьют». Или гирлянда в браузере

Website developmentJavaScriptProgramming
Предположим, у нас есть несколько мониторов. И нам захотелось использовать эти мониторы в качестве гирлянды. Например, заставить их моргать одновременно. Или, может быть, синхронно менять цвет согласно какому-то умному алгоритму. И что, если сделать это в браузере – ведь тогда можно будет подключить к этому и смартфоны, и планшеты. Всё что есть под рукой.



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


С чем можно столкнуться при синхронизации Web Audio и геймплейных часов внутри javascript-приложения; сколько вообще разных «часов» есть в javasctipt (три!) и зачем все они нужны, а также готовое приложение для node.js – под катом.

Сверим часы


Для любой условной онлайн-гирлянды необходима точная синхронизация часов. Ведь тогда можно игнорировать любые (даже непостоянные) сетевые задержки. Достаточно снабдить управляющие команды временнóй меткой и генерировать эти команды немного «в будущее». На клиентах они будут буферизованы и затем выполнены синхронно и точно в срок.

Или даже можно пойти ещё дальше – взять старый добрый детерминированный random-алгоритм и использовать один общий seed (выдаваемый сервером единожды, при подключении) на всех устройствах. Если использовать такой seed вместе с точным временем – можно полностью предопределить поведение алгоритма на всех устройствах. Просто представьте: по сути вам не нужна ни сеть, ни сервер чтобы уникально и синхронно изменять состояние. Seed уже содержит в себе всю (условно-бесконечную) «видеозапись» действий наперёд. Главное – точное время.



Каждый метод имеет свои границы применимости. С моментальным пользовательским вводом, конечно, уже ничего не поделаешь, его остается передавать «как есть». Но всё, что может быть предрассчитано – должно быть предрассчитано. В своей реализации я использую все три подхода в зависимости от ситуации.

Субъективное «одновременно»


В идеале, чтобы всё звучало «одновременно» – нужно не более ±10 мс расхождения для худшей пары среди объединенных устройств. Рассчитывать на такую точность от системного времени не приходится, а стандартные способы синхронизации времени по протоколу NTP в браузере недоступны. Поэтому свелосипедим свой сервер синхронизации. Принцип простой: шлем «пинги» и принимаем «понги» с временнóй меткой сервера. Если делать это много раз подряд, можно статистически нивелировать ошибку, и получить среднее время задержки.

Код: вычисление серверного времени на клиенте
let pingClientTime = 1; // performace.now() time when ping started
let pongClientTime = 3; // performace.now() time when pong received
let pongServerTime = 20; // server timstamp in pong answer

let clientServerRawOffset = pongServerTime - pongClientTime;
let pingPongOffset = pongClientTime - pingClientTime; // roundtrip
let estimatedPingOffset = pingPongOffset / 2; // one-way
let offset = clientServerRawOffset + estimatedPingOffset;

console.log(estimatedPingOffset) // 1
console.log(offset); // 18

let sharedServerTime = performace.now() + offset;



Websockets и решения на его основе подходят лучше всего, так как не требуют времени на создание TCP-соединения, и по ним можно «общаться» в обе стороны. Не UDP и не ICMP, конечно, но несравнимо быстрее, чем обычное холодное соединение по HTTP API. Поэтому, socket.io. Там всё очень легко:

Код: реализация на socket.io
// server
socket.on('ping', (pongCallback) => {
  let pongServerTime = performace.now();
  pongCallback(pongServerTime);
});

//client
const binSize = 100;
let clientServerCalculatedOffset;

function ping() {
  socket.emit('ping', pongCallback);
  const pingClientTime = performace.now();
  function pongCallback(pongServerTime) {
    const pongClientTime = performace.now();
    const clientServerRawOffset = pongServerTime - pongClientTime;
    const pingPongOffset = pongClientTime - pingClientTime; // roundtrip
    const estimatedPingOffset = pingPongOffset / 2; // one-way
    const offset = clientServerRawOffset + estimatedPingOffset;
    offsets.unshift(offset);
    offsets.splice(binSize);
    let offsetSum = 0;
    offsets.forEach((offset) => {
      offsetSum += offset;
    });
    clientServerCalculatedOffset = offsetSum / offset.length();
  }
}

Неплохо бы, вместо вычисления среднего, посчитать медиану – это улучшит точность при нестабильном соединении. Выбор методов фильтрации остается за читателем. Я намеренно упрощаю здесь и далее код в пользу схематичности. Мое полное решение можно найти в репозитории.


performance.now()


Напомню, объект performance – это API предоставляющее доступ к таймеру высокого разрешения. Сравним:

  • Date.now() возвращает количество миллисекунд с 1 января 1970, и делает это в целочисленном виде. То есть погрешность только лишь от округления составляет 0.5 мс в среднем. Например, на одной операции вычитания a-b можно неудачно «потерять» до 2 мс. Кроме того, исторически и концептуально, сам счетчик времени не гарантирует высокой точности и заточен под работу с бóльшим масштабом времени.
  • performance.now() возвращает количество миллисекунд c момента открытия веб-страницы.
    Это относительно свежее API, «заточенное» специально для аккуратного замера временных промежутков. Возвращает значение c плавающей точкой, теоретически давая уровень точности близкий к возможностям самой ОС.


Думаю, эта информация известна почти всем javascript разработчикам. Но не всем известно, что…

Spectre


Из-за нашумевшей в 2018 тайминговой атаки Spectre, всё идет к тому что таймер высокого разрешения будет искусственно огрублен, если не найдется другого решения проблемы с уязвимостью. Firefox, ещё начиная с версии 60, округляет значение этого таймера до миллисекунды, а Edge, и того хуже.

Вот что говорит MDN:

The timestamp is not actually high-resolution. To mitigate security threats such as Spectre, browsers currently round the results to varying degrees. (Firefox started rounding to 1 millisecond in Firefox 60.) Some browsers may also slightly randomize the timestamp. The precision may improve again in future releases; browser developers are still investigating these timing attacks and how best to mitigate them.

Давайте запустим тест и взглянем на графики. Это результат работы теста на промежутке 10 мс:

Код теста: замер времени в цикле
function measureTimesLoop(length) {
  const d = new Array(length);
  const p = new Array(length);
  for (let i = 0; i < length; i++) {
    d[i] = Date.now();
    p[i] = performance.now();
  }
  return { d, p }
}


Date.now()
performance.now()

Edge



статистика
Версия браузера: 44.17763.771.0

Date.now()

средний интервал: 1.0538336052202284 мс
отклонение от среднего интервала, RMS: 0.7547819181245603 мс
медиана интервала: 1 мс

performance.now()

средний интервал: 1.567100970873786 мс
отклонение от среднего интервала, RMS: 0.6748006785171455 мс
медиана интервала: 1.5015000000003056 мс


Firefox



статистика
Версия браузера: 71.0

Date.now()

средний интервал: 1.0168350168350169 мс
отклонение от среднего интервала, RMS: 0.21645930182417966 мс
медиана интервала: 1 мс

performance.now()

средний интервал: 1.0134453781512605 мс
отклонение от среднего интервала, RMS: 0.1734108492762375 мс
медиана интервала: 1 мс


Chrome



статистика
Версия браузера: 79.0.3945.88

Date.now()

средний интервал: 1.02442996742671 мс
отклонение от среднего интервала, RMS: 0.49858684744384 мс
медиана интервала: 1 мс

performance.now()

средний интервал: 0.005555847229948915 мс
отклонение от среднего интервала, RMS: 0.027497846727194235 мс
медиана интервала: 0.0050000089686363935 мс


Ok, Chrome, zoom to 1 msec.



Итак, Сhrome всё еще держится, и его реализация performance.now() пока не задушена и шаг составляет красивые 0.005 мс. Под Edge таймер performance.now() грубее чем Date.now()! В Firefox оба таймера обладают одинаковой миллисекундной точностью.

На этом этапе можно уже сделать некоторые выводы. Но есть ещё один таймер в javascript (без которого нам никак не обойтись).

Таймер WebAudio API


Это несколько иной зверь. Он используется для отложенной аудио-очереди. Дело в том, что аудио-события (проигрывания нот, управление эффектами) не могут полагаться на стандартные асинхронные средства javascript: setInterval и setTimeout – из-за их слишком большой погрешности. И это не просто погрешность значений таймера, (с которой мы имели дело ранее), а это погрешность, с которой event-машина выполняет события. И она составляет уже что-то около 5-25 мс даже в тепличных условиях.

Графики для асинхронного случая под спойлером
Результат работы теста на промежутке 100 мс:

Код теста: замер времени в асинхронном цикле
function pause(duration) {
  return new Promise((resolve) => {
      setInterval(() => {
        resolve();
      }, duration);
  });
}

async function measureTimesInAsyncLoop(length) {
  const d = new Array(length);
  const p = new Array(length);
  for (let i = 0; i < length; i++) {
    d[i] = Date.now();
    p[i] = performance.now();
    await pause(1);
  }
  return { d, p }
}


Date.now()
performance.now()

Edge



статистика
Версия браузера: 44.17763.771.0

Date.now()

средний интервал: 25.595959595959595 мс
отклонение от среднего интервала, RMS: 10.12639235162126 мс
медиана интервала: 28 мс

performance.now()

средний интервал: 25.862596938775525 мс
отклонение от среднего интервала, RMS: 10.123711255512573 мс
медиана интервала: 27.027099999999336 мс


Firefox



статистика
Версия браузера: 71.0

Date.now()

средний интервал: 1.6914893617021276 мс
отклонение от среднего интервала, RMS: 0.6018870280772611 мс
медиана интервала: 2 мс

performance.now()

средний интервал: 1.7865168539325842 мс
отклонение от среднего интервала, RMS: 0.6442818510935484 мс
медиана интервала: 2 мс


Chrome



статистика
Версия браузера: 79.0.3945.88

Date.now()

средний интервал: 4.787878787878788, мс
отклонение от среднего интервала, RMS: 0.7557553886872682 мс
медиана интервала: 5 мс

performance.now()

средний интервал: 4.783989898979516 мс
отклонение от среднего интервала, RMS: 0.6483716900974945 мс
медиана интервала: 4.750000000058208 мс



Может кто-то вспомнит первые экспериментальные HTML аудиоприложения. До того, как полноценное WebAudio пришло в браузеры – они все звучали, будто немного пьяно, расхлябанно. Как раз потому что использовали setTimeout в качестве секвенсора.

Современный WebAudio API, в противовес этому дает гарантированное разрешение аж до 0.02 мсек (спекуляция исходя из частоты дискретизации в 44100Hz). Это происходит благодаря тому, что для отложенного воспроизведения звука используется иной механизм, чем setTimeout:

source.start(when);

Фактически, любое воспроизведение аудиосемпла – «отложенное». Просто, чтобы проиграть его «не отложено», нужно отложить его «до сейчас».

source.start(audioCtx.currentTime);

О программно-генерируемой музыке «в реальном времени»
Если воспроизводить программно-синтезируемую мелодию из нот – то эти ноты нужно немного заранее добавить в очередь воспроизведения. Тогда, не смотря на все фундаментальные ограничения и неровности таймеров, мелодия воспроизведется идеально ровно.

Иначе говоря, синтезируемая в реальном времени мелодия должна быть «придумана» не в реальном времени, а чуть-чуть заранее.


One timer to rule them all


Раз уж audioCtx.currentTime такой стабильный и точный, может быть нам его использовать как основной источник относительного времени? Давайте ещё раз запустим тест.

Код теста: замер синхронного замера времени в цикле
function measureTimesInLoop(length) {
  const d = new Array(length);
  const p = new Array(length);
  const a = new Array(length);
  for (let i = 0; i < length; i++) {
    d[i] = Date.now();
    p[i] = performance.now();
    a[i] = audioCtx.currentTime * 1000;
  }
  return { d, p, a }
}


Date.now()
performance.now()
audioCtx.currentTime

Edge



статистика
Версия браузера: 44.17763.771.0

Date.now()

средний интервал: 1.037037037037037 мс
отклонение от среднего интервала, RMS: 0.6166609846299806 мс
медиана интервала: 1 мс

performance.now()

средний интервал: 1.5447103117505993 мс
отклонение от среднего интервала, RMS: 0.4390514285320851 мс
медиана интервала: 1.5015000000000782 мс

audioCtx.currentTime

средний интервал: 2.955751134714949 мс
отклонение от среднего интервала, RMS: 0.6193645611529503 мс
медиана интервала: 2.902507781982422 мс



Firefox



статистика
Версия браузера: 71.0

Date.now()

средний интервал: 1.005128205128205 мс
отклонение от среднего интервала, RMS: 0.12392867665225249 мс
медиана интервала: 1 мс

performance.now()

средний интервал: 1.00513698630137 мс
отклонение от среднего интервала, RMS: 0.07148844433269844 мс
медиана интервала: 1 мс

audioCtx.currentTime

В сихнронном цикле Firefox не обновляет значение аудиотаймера



Chrome



статистика
Версия браузера: 79.0.3945.88

Date.now()

средний интервал: 1.0207612456747406 мс
отклонение от среднего интервала, RMS: 0.49870223457982504 мс
медиана интервала: 1 мс

performance.now()

средний интервал: 0.005414502034674972 мс
отклонение от среднего интервала, RMS: 0.027441293974958335 мс
медиана интервала: 0.004999999873689376 мс

audioCtx.currentTime

средний интервал: 3.0877599266656963 мс
отклонение от среднего интервала, RMS: 1.1445555956407658 мс
медиана интервала: 2.9024943310650997 мс



Графики для асинхронного случая под спойлером
Код теста: замер времени в асинхронном цикле
Результат работы теста на промежутке 100 мс:

function pause(duration) {
    return new Promise((resolve) => {
        setInterval(() => {
          resolve();
        }, duration);
    });
  }

  async function measureTimesInAsyncLoop(length) {
    const d = new Array(length);
    const p = new Array(length);
    const a = new Array(length);
    for (let i = 0; i < length; i++) {
      d[i] = Date.now();
      p[i] = performance.now();
      await pause(1);
    }
    return { d, p }
  }



Date.now()
performance.now()
audioCtx.currentTime

Edge



статистика
Версия браузера: 44.17763.771.0

Date.now():

средний интервал: 24.505050505050505 мс
отклонение от среднего интервала: 11.513166584195204 мс
медиана интервала: 26 мс

performance.now():

средний интервал: 24.50935757575754 мс
отклонение от среднего интервала: 11.679091435527388 мс
медиана интервала: 25.525499999999738 мс

audioCtx.currentTime:

средний интервал: 24.76005164944396 мс
отклонение от среднего интервала: 11.311571546205316 мс
медиана интервала: 26.121139526367187 мс


Firefox



статистика
Версия браузера: 71.0

Date.now():

средний интервал: 1.6875 мс
отклонение от среднего интервала: 0.6663410663216448 мс
медиана интервала: 2 мс

performance.now():

средний интервал: 1.7234042553191489 мс
отклонение от среднего интервала: 0.6588877688171075 мс
медиана интервала: 2 мс

audioCtx.currentTime:

средний интервал: 10.158730158730123 мс
отклонение от среднего интервала: 1.4512471655330046 мс
медиана интервала: 8.707482993195299 мс


Chrome



статистика
Версия браузера: 79.0.3945.88

Date.now():

средний интервал: 4.585858585858586 мс
отклонение от среднего интервала: 0.9102125516015199 мс
медиана интервала: 5 мс

performance.now():

средний интервал: 4.59242424242095 мс
отклонение от среднего интервала: 0.719936993603155 мс
медиана интервала: 4.605000001902226 мс

audioCtx.currentTime:

средний интервал: 10.12648022171832 мс
отклонение от среднего интервала: 1.4508887886499262 мс
медиана интервала: 8.707482993197118 мс



Чтож, не получится. «Снаружи» этот таймер оказывается самым неточным. Firefox не обновляет значение таймера внутри цикла. А в целом: разрешение 3 мс и хуже и заметный джиттер. Возможно, значение audioCtx.currentTime отражает позицию в кольцевом буфере драйвера аудиокарты. Иными словами, он показывает минимальное время, на которое ещё возможно безопасно отложить воспроизведение.

И что же делать? Ведь нам нужен одновременно и точный таймер для синхронизации с сервером и запуска javascript событий на экране, и аудиотаймер для звуковых событий!

Выходит, что нужно синхронизировать все таймеры друг с другом:

  • Клиентский audioCtx.currentTime с клиентским performance.now() на клиенте.
  • И клиентский performance.now() c performance.now() серверным.

Синхронизировали, синхронизировали


Вообще, это довольно забавно, если задуматься: можно иметь два хороших источника времени A и B, каждый из которых на выходе очень сильно огрублен и зашумлен (A' = A + errA; B' = B + errB) так, что может даже быть непригоден для использования сам по себе. Но разницу d между исходными незашумленными источниками при этом можно восстановить очень точно.

Поскольку истинное временнóе расстояние между идеальными часами – это константа, проведя измерения n раз, мы уменьшим ошибку измерения err в n раз соответственно. Если, конечно, часы идут с одной скоростью.

Да не высинхронизировали


Плохая новость заключается в том, что нет, не идут они с одной скоростью. И я говорю не о расхождении часов не сервере и на клиенте – это понятно и ожидаемо. Что более неожиданно: audioCtx.currentTime постепенно расходится с performance.now(). Именно внутри клиента. Мы можем не замечать, но иногда, при нагрузке, аудиосистема может не проглотить небольшой кусок данных и, (вопреки природе кольцевого буфера) аудиовремя сдвинется относительно системного. Это происходит не так уж и редко, просто это мало кого заботит: но если например, запустить, одновременно два youtube видео синхронно на разных компьютерах – не факт что они закончат играть одновременно. И дело, конечно же, не в рекламе.

Таким образом, для стабильной и синхронной работы. Нам нужно регулярно пересверять все часы друг с другом, используя серверное время – как опорное. И тут появляется trade-off в том, сколько замеров использовать для усреднения: чем больше – тем точнее, но тем больше шанс что во временное окно, в рамках которого мы фильтруем значения, попал резкий скачок в audioCtx.currentTime. Тогда, если мы, например, используем минутное окно, то всю минуту мы будем иметь съехавшее время. Выбор фильтров широк: экспоненциальный, медианный, фильтр Калмана, и т.п. Но этот trade-off есть в любом случае.

Временнóе окно


В случае сихронизации audioCtx.currentTime с performance.now(), в асинхронном цикле, чтобы не мешать UI, мы можем делать одно измерение, допустим, в 100 мс.
Предположим, что ошибка измерения err=errA + errB = 1+3 = 4 мс
Соответственно за 1 секунду мы можем её снизить до 0.4 мс, а за 10 секунд до 0.04 мс. Дальнейшее улучшение результата не имеет смысла, и хорошим окном для фильтрации будет: 1 – 10 секунд.

В случае с синхронизацией по сети, задержки и ошибки уже намного более весомые, но зато нет резкого скачкообразного изменения времени, как в случае с лагающим audioCtx.currentTime. И можно позволить себя накопить действительно большую статистику. Ведь err для пинга может составлять и 500 мс. А сами измерения мы можем делать не настолько же часто.

На этом месте я предлагаю остановиться. Если кому-то было интересно, я с удовольствием расскажу как «нарисовал остальную сову». Но а рамках истории о таймерах, считаю свой рассказ оконченным.

И хочу поделиться тем, что получилось у меня. Всё таки новый год.

Что получилось


Disclaimer: Технически это пиар сайта на Хабре, но это совершенно некоммерческий opensource pet-проект, на котором я обещаю никогда: ни ставить рекламу, ни как-либо ещё зарабатывать. Наоборот, я из своих денег поднял сейчас побольше инстансов чтобы пережить возможный хабраэффект. По-этому, пожалуйста, добрые люди, не ломайте меня и не ддосьте. Это всё чисто по-фану.

С наступающим новым годом, Хабр!



snowtime.fun

Можно крутить ручки и управлять визуализацией, музыкой и аудиоэффектами. Если у вас нормальная видеокарта, заходите в настройки и ставьте количество частиц 100%.

Требуется наличие WebAudio и WebGL.




UPD: Не работает в Safari под macOS Mojave. К сожалению, быстро разобраться в чём дело нет возможности, ввиду отсутствия этого самого Safari. iOS вроде бы работает.

UPD2: Если snowtime.fun и web.snowtime.fun не отвечает, попробуйте новый поддомен habr.snowtime.fun. Перевел сервер в другой датацентр, а старый IP закешировался в DNS, expire=1w. :(

Репозиторий: bitbucket
При написании статьи были использованы иллюстрации macrovector / Freepik
Tags:timerjavascriptWebAudioновый годснег
Hubs: Website development JavaScript Programming
+27
8.8k 80
Comments 18
Top of the last 24 hours