JavaScript: цикличные таймеры с автокоррекцией

в 13:19, , рубрики: javascript, Веб-разработка, метки:

JavaScript: цикличные таймеры с автокоррекцией

В посте в повествовательной и не очень манере рассказывается о различных реализациях «точных» таймеров на JS. Материал рассчитан на новичков… Добро пожаловать под кат.

Как заметили многие, на картинке к посту изображены часы из работы Дали «Время течет», выбор отнюдь не случаен и метафоричен по своей сути. Ибо, в рамках программирования на JS, время может течь не совсем так, как мы это предполагаем. JS однопоточен по своей сути, что порождает очередь выполнения функций, а очередь подразумевает непременный порядок следования. И если некоторые из этапов вычислений оказываются излишне ресурсоёмкими, мы имеем явное расхождение требуемого с результатом исполнения. Особенно критично это в случаях небиблиотечного контролирования переходных процессов. К примеру: выполнения перехода по кубической кривой (easing), или работы с ритмичным вызовом логики приложения для обновления текущего состояния. Пару месяцев назад, в качестве «weekend project», я выбрал для себя написание простого пошагового секвенсера (wiki), и столкнулся с физической невозможностью точного тайминга на среднеслабых и слабых системах посредством стандартных setTimeout() и setInterval(). Рассогласование достигало непримиримых в этом случае полусекунд. В поисках решения, я наткнулся на отличную статью по этой теме. А сам пост, в некоем роде, — вольный перевод оной.

В итоге, задача «точного» тайминга сводится к вычитанию задержки предыдущего выполнения функции из настоящего. Можно просто измерить разницу в системном времени между итерациями и вычесть её при следующем вызове. Звучит просто, а вот и код:

var start = new Date().getTime(),
    time = 0,
    elapsed = '0.0';

function instance()
{
    time += 100;

    elapsed = Math.floor(time / 100) / 10;
    if(Math.round(elapsed) == elapsed) { elapsed += '.0'; }

    document.title = elapsed;

    var diff = (new Date().getTime() - start) - time;
    window.setTimeout(instance, (100 - diff));
}

window.setTimeout(instance, 100);

Все довольно просто, посмотрим на результаты. Вот демо на JSfiddle с комментариями на русском языке, для сравнения работы обычных таймеров и таймеров с автокоррекцией.
Лучшее в таком подходе то, что не имеет практического значения насколько неточен таймер, т.к. впоследствии небольшая постоянноя задержка (как 3-4ms в последнем примере демо), может быть очень легко компенсирована. В то время, как неточность простого таймера носит куммулятивный характер, накапливаясь с каждой итерацией, что в конце приводит к адски заметной разнице.

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

//по нажатию на кнопку "play/stop", срабатывает функция включающая таймер
        function preciousTimer (step) {

//как и в примерах выше, берем DateStamp для оценки
            var start = new Date().getTime(),
                time = 0,
/*а эта переменная появилась из необходимости
 проводить в четное количество раз больше итераций,
чем шагов в секвенсере (точность все еще довольно слабенькая)*/
                it = 0;

            function instance () {

//рассчитываем идеальное время
                time += step;

//считаем разницу
                var diff = (new Date().getTime()- start) - time;

//выполняем согласно значению итератора
                if (it == 4) {
                    it = 0;
/*место для работы секвенсера с матрицей,
здесь смотрим значения логического массива для
каждого прохода по планке. */      
                    if (m == 8) {
                        m = 0;
                    };
                    for (var i = 0; i < 4; i++) {
                        if (noteArr[i][m]) {
                            sound[i].play();
                        };
                    };
                    m++;
                };
                    it++;

//если за время итерации была нажата кнопка паузы, 
//выходим из хвостовой рекурсивной цепочки
                    if (pause) { 
                        return; 
                    };

//вызываем следующую итерацию, с учетом задержки
                    window.setTimeout(instance, (step - diff));
                };

//а это самый первый вызов функции instance(), 
//после которого начинается последовательный вызов итераций
            setTimeout(instance, step);
        };

Кто-то уже наверняка задался вопросом: а как же быть с переполнением стека вызовов. В данном конкретном случае, его размер колеблется от 10 до 17 позиций, что мало для любого современного браузера. Однако с увеличением темпа, либо вместе с ростом количества перерасчетных итераций, может случится и приступ удушья у оного и необходимо будет задуматься о реализации .tail() — подобных вызовов. Но об этом уже совсем другая история.

Также нельзя не упомянуть про метод window.performance.now(), который возвращает число с плавающей запятой, значащее количество миллисекунд, прошедшее с загрузки страницы (не совсем точное определение) Следовательно после десятичной запятой у нас будет уже субмиллисекундное разрешение, что очень очень хорошо. Используя это значение, можно по схожему методу вычислить рассогласование с точностью до десятой доли миллисекунды, и более точно выполнить предзапуск последующей итерации.

Посмотреть секвенсер вживую можно здесь: stepograph.hol.es (webkit required)
Cсылка на оригинал статьи, частично используемой в посте: Сreating accurate timers in JavaScript

Спасибо за внимание!

Автор: Everlier

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js