"Quando o relógio bate doze horas." Ou uma guirlanda no navegador

Suponha que tenhamos vários monitores. E queríamos usar esses monitores como uma guirlanda. Por exemplo, faça-os piscar ao mesmo tempo. Ou talvez altere de forma síncrona a cor de acordo com algum tipo de algoritmo inteligente. E se você fizer isso em um navegador - poderá conectar smartphones e tablets a isso. Tudo o que está à mão.



E, como estamos usando um navegador, você também pode adicionar um design de som. Afinal, se for preciso o suficiente para sincronizar os dispositivos a tempo, você poderá reproduzir sons em cada um deles, como se um sistema multicanal soasse.


O que pode ser encontrado ao sincronizar o Web Audio e os relógios de jogo dentro de um aplicativo javascript; quantas “horas” diferentes existem no javasctipt (três!) e por que todas elas são necessárias, além de um aplicativo pronto para o node.js sob o gato.

Verifique o relógio


Para qualquer guirlanda on-line condicional, é necessária uma sincronização precisa do relógio. Afinal, você pode ignorar qualquer atraso de rede (mesmo intermitente). É suficiente fornecer aos comandos de controle um carimbo de data e hora e gerar esses comandos um pouco "no futuro". Nos clientes, eles serão armazenados em buffer e, em seguida, executados de maneira síncrona e pontual.

Ou você pode ir ainda mais longe - pegue o bom e velho algoritmo aleatório determinístico e use uma semente comum (emitida pelo servidor uma vez, quando conectada) em todos os dispositivos. Se você usar essa semente juntamente com o tempo exato, poderá predeterminar completamente o comportamento do algoritmo em todos os dispositivos. Imagine: de fato, você não precisa de uma rede ou servidor para alterar o estado de forma única e síncrona. A semente já contém a "gravação de vídeo" inteira (condicionalmente infinita) de ações com antecedência. A principal coisa é a hora exata.



Cada método tem seus limites de aplicabilidade. Com a entrada instantânea do usuário, é claro, não há nada a ser feito, resta transmiti-la "como está". Mas tudo o que pode ser calculado deve ser calculado. Na minha implementação, uso as três abordagens, dependendo da situação.

Subjetivo "ao mesmo tempo"


Idealmente, tudo deve soar "ao mesmo tempo" - não são necessários mais de ± 10 ms de discrepância para o pior par entre os dispositivos combinados. Você não pode contar com essa precisão desde o horário do sistema, e os métodos padrão para sincronizar o horário usando o protocolo NTP não estão disponíveis no navegador. Portanto, dirigiremos nosso servidor de sincronização. O princípio é simples: o capacete "pings" e aceita "pongs" com o carimbo de data / hora do servidor. Se você fizer isso várias vezes seguidas, poderá nivelar estatisticamente o erro e obter o tempo médio de atraso.

Código: calculando o tempo do servidor no cliente
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; 



Os Websockets e as soluções baseadas nele são mais adequados porque não precisam de tempo para criar uma conexão TCP, e você pode "se comunicar" com eles nas duas direções. Não UDP ou ICMP, é claro, mas incomparavelmente mais rápido que uma conexão fria comum usando a API HTTP. Portanto, socket.io. Tudo é muito fácil lá:

Código: implementação 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(); } } 

Seria bom, em vez de calcular a média, calcular a mediana - isso aumentará a precisão com uma conexão instável. A escolha dos métodos de filtragem é com o leitor. Eu simplifico deliberadamente o código aqui em favor dos esquemas. Minha solução completa pode ser encontrada no repositório.


performance.now ()


Deixe-me lembrá-lo de que o objeto de performance é uma API que fornece acesso a um cronômetro de alta resolução. Compare:

  • Date.now() retorna o número de milissegundos desde 1º de janeiro de 1970 e o faz em formato inteiro . Ou seja, o erro apenas do arredondamento é de 0,5 ms em média. Por exemplo, em uma operação de subtração ab você pode "perder" sem êxito até 2 ms. Além disso, histórica e conceitualmente, o medidor de tempo em si não garante alta precisão e é aprimorado para trabalhos com uma escala de tempo maior.
  • performance.now() retorna o número de milissegundos desde que a página da web foi aberta .
    Esta é uma API relativamente recente, "aprimorada" especificamente para medição precisa de intervalos de tempo. Retorna um valor de ponto flutuante , teoricamente, fornecendo um nível de precisão próximo aos recursos do próprio sistema operacional.


Eu acho que essa informação é conhecida por quase todos os desenvolvedores de javascript. Mas nem todo mundo sabe disso ...

Espectro


Devido ao sensacional ataque de tempo de Spectre em 2018, tudo chega ao ponto de o temporizador de alta resolução ser artificialmente aumentado se não houver outra solução para o problema de vulnerabilidade. O Firefox, começando na versão 60, arredonda o valor desse timer para um milissegundo e o Edge, ainda pior.

Aqui está o que o MDN diz:

O registro de data e hora não é de alta resolução. Para atenuar ameaças à segurança, como o Spectre, os navegadores atualmente arredondam os resultados em vários graus. (O Firefox começou a arredondar para 1 milissegundo no Firefox 60.) Alguns navegadores também podem randomizar levemente o carimbo de data e hora. A precisão pode melhorar novamente em versões futuras; os desenvolvedores de navegadores ainda estão investigando esses ataques de tempo e a melhor forma de mitigá-los.

Vamos executar o teste e dar uma olhada nos gráficos. Este é o resultado do teste no intervalo de 10 ms:

Código do teste: medição do tempo em um ciclo
 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



estatísticas
Versão do navegador: 44.17763.771.0

Date.now ()

intervalo médio: 1,0538336052202284 ms
desvio do intervalo médio, RMS: 0,7547819181245603 ms
mediana do intervalo: 1 ms

performance.now ()

intervalo médio: 1,567100970873786 ms
desvio do intervalo médio, RMS: 0,6748006785171455 ms
mediana do intervalo: 1,5015000000003056 ms


Firefox



estatísticas
Versão do navegador: 71.0

Date.now ()

intervalo médio: 1,0168350168350169 ms
desvio do intervalo médio, RMS: 0,21645930182417966 ms
mediana do intervalo: 1 ms

performance.now ()

intervalo médio: 1,0134453781512605 ms
desvio do intervalo médio, RMS: 0,1734108492762375 ms
mediana do intervalo: 1 ms


Chrome



estatísticas
Versão do navegador: 79.0.3945.88

Date.now ()

intervalo médio: 1,02442996742671 ms
desvio do intervalo médio, RMS: 0,49858684744444 ms
mediana do intervalo: 1 ms

performance.now ()

intervalo médio: 0,005555847229948915 ms
desvio do intervalo médio, RMS: 0,027497846727194235 ms
mediana do intervalo: 0.0050000089686363935 ms


Ok, Chrome, zoom para 1 ms.



Portanto, o Chrome ainda está aguentando, e sua implementação de performance.now() ainda não foi estrangulada e a etapa é linda 0,005 ms. No Edge, o timer performance.now() é mais áspero que o Date.now() ! No Firefox, os dois timers têm a mesma precisão de milissegundos.

Nesta fase, algumas conclusões já podem ser tiradas. Mas há outro temporizador em javascript (sem o qual não podemos prescindir).

Temporizador da API do WebAudio


Este é um animal ligeiramente diferente. É usado para filas de áudio atrasadas. O fato é que os eventos de áudio (tocando notas, gerenciando efeitos) não podem confiar nas ferramentas javascript assíncronas padrão: setInterval e setTimeout - por causa de seu erro muito grande. E este não é apenas o erro dos valores do timer (com os quais lidamos anteriormente), mas é o erro com o qual a máquina de eventos executa eventos. E já é algo em torno de 5 a 25 ms, mesmo em condições de estufa.

Gráficos para o caso assíncrono sob o spoiler
O resultado do teste em um intervalo de 100 ms:

Código de teste: medição do tempo em um ciclo assíncrono
 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



estatísticas
Versão do navegador: 44.17763.771.0

Date.now ()

intervalo médio: 25,595959595959595 ms
desvio do intervalo médio, RMS: 10.12639235162126 ms
mediana do intervalo: 28 ms

performance.now ()

intervalo médio: 25,862596938775525 ms
desvio do intervalo médio, RMS: 10.123711255512573 ms
mediana do intervalo: 27.027099999999336 ms


Firefox



estatísticas
Versão do navegador: 71.0

Date.now ()

intervalo médio: 1,6914893617021276 ms
desvio do intervalo médio, RMS: 0,6018870280772611 ms
mediana do intervalo: 2 ms

performance.now ()

intervalo médio: 1,7865168539325842 ms
desvio do intervalo médio, RMS: 0,6442818510935484 ms
mediana do intervalo: 2 ms


Chrome



estatísticas
Versão do navegador: 79.0.3945.88

Date.now ()

intervalo médio: 4.787878787878788888, ms
desvio do intervalo médio, RMS: 0,7557553886872682 ms
mediana do intervalo: 5 ms

performance.now ()

intervalo médio: 4.783989898979516 ms
desvio do intervalo médio, RMS: 0,6483716900974945 ms
intervalo médio: 4,750000000058208 ms



Talvez alguém se lembre das primeiras aplicações experimentais de áudio HTML. Antes que o WebAudio completo chegasse aos navegadores - todos pareciam um pouco bêbados e desleixados. Só porque eles usaram setTimeout como um seqüenciador.

A moderna API WebAudio, em contraste, fornece uma resolução garantida de até 0,02 ms (especulação com base na frequência de amostragem de 44100Hz). Isso se deve ao fato de um mecanismo diferente ser usado para reprodução de som atrasada que setTimeout :

 source.start(when); 

De fato, qualquer reprodução de uma amostra de áudio é "atrasada". Só para perdê-lo "não é adiado", você precisa adiá-lo "até agora".

 source.start(audioCtx.currentTime); 

Sobre música gerada por software em tempo real
Se você tocar uma melodia sintetizada por programa a partir de notas, essas notas deverão ser adicionadas à fila de reprodução um pouco antes. Então, apesar de todas as restrições e irregularidades não fundamentais dos timers, a melodia tocará perfeitamente.

Em outras palavras, a melodia sintetizada em tempo real não deve ser "inventada" em tempo real, mas com um pouco de antecedência.


Um temporizador para governar todos eles


Como audioCtx.currentTime tão estável e preciso, talvez devêssemos usá-lo como a principal fonte de tempo relativo? Vamos executar o teste novamente.

Código de teste: medindo a medição síncrona do tempo em um ciclo
 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



estatísticas
Versão do navegador: 44.17763.771.0

Date.now ()

intervalo médio: 1,037037037037037 ms
desvio do intervalo médio, RMS: 0,6166609846299806 ms
mediana do intervalo: 1 ms

performance.now ()

intervalo médio: 1,5447103117505993 ms
desvio do intervalo médio, RMS: 0,4390514285320851 ms
mediana do intervalo: 1,5015000000000782 ms

audioCtx.currentTime

intervalo médio: 2,955751134714949 ms
desvio do intervalo médio, RMS: 0,6193645611529503 ms
mediana do intervalo: 2,902507781982422 ms



Firefox



estatísticas
Versão do navegador: 71.0

Date.now ()

intervalo médio: 1,005128205128205 ms
desvio do intervalo médio, RMS: 0,12392867665225249 ms
mediana do intervalo: 1 ms

performance.now ()

intervalo médio: 1,00513698630137 ms
desvio do intervalo médio, RMS: 0,07148844433269844 ms
mediana do intervalo: 1 ms

audioCtx.currentTime

O Firefox não atualiza o valor do timer de áudio no loop de sincronização



Chrome



estatísticas
Versão do navegador: 79.0.3945.88

Date.now ()

intervalo médio: 1,0207612456747406 ms
desvio do intervalo médio, RMS: 0,49870223457982504 ms
mediana do intervalo: 1 ms

performance.now ()

intervalo médio: 0,005414502034674972 ms
desvio do intervalo médio, RMS: 0,027441293974958335 ms
intervalo médio: 0,004999999873689376 ms

audioCtx.currentTime

intervalo médio: 3,0877599266656963 ms
desvio do intervalo médio, RMS: 1,1445555956407658 ms
mediana do intervalo: 2,9024943310650997 ms



Gráficos para o caso assíncrono sob o spoiler
Código de teste: medição do tempo em um ciclo assíncrono
O resultado do teste em um intervalo de 100 ms:

 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



estatísticas
Versão do navegador: 44.17763.771.0

Date.now ():

intervalo médio: 24,505050505050505 ms
desvio do intervalo médio: 11,513166584195204 ms
mediana do intervalo: 26 ms

performance.now ():

intervalo médio: 24,50935757575754 ms
desvio do intervalo médio: 11,679091435527388 ms
mediana do intervalo: 25,525499999999738 ms

audioCtx.currentTime:

intervalo médio: 24,76005164944396 ms
desvio do intervalo médio: 11.311571546205316 ms
mediana do intervalo: 26.121139526367187 ms


Firefox



estatísticas
Versão do navegador: 71.0

Date.now ():

intervalo médio: 1,6875 ms
desvio do intervalo médio: 0,6663410663216448 ms
mediana do intervalo: 2 ms

performance.now ():

intervalo médio: 1.7234042553191489 ms
desvio do intervalo médio: 0,6588877688171075 ms
mediana do intervalo: 2 ms

audioCtx.currentTime:

intervalo médio: 10,158730158730123 ms
desvio do intervalo médio: 1,4512471655330046 ms
intervalo médio: 8,707482993195299 ms


Chrome



estatísticas
Versão do navegador: 79.0.3945.88

Date.now ():

intervalo médio: 4,585858585858586 ms
desvio do intervalo médio: 0,9102125516015199 ms
mediana do intervalo: 5 ms

performance.now ():

intervalo médio: 4,592424242424955 ms
desvio do intervalo médio: 0,719936993603155 ms
mediana do intervalo: 4,605000001902226 ms

audioCtx.currentTime:

intervalo médio: 10.12648022171832 ms
desvio do intervalo médio: 1.4508887886499262 ms
mediana do intervalo: 8.707482993197118 ms



Bem, não vai dar certo. "Fora", este temporizador é o mais impreciso. O Firefox não atualiza o valor do timer dentro do loop. Mas, em geral: a resolução é de 3 ms e o jitter é pior e mais visível. Talvez o valor de audioCtx.currentTime reflita a posição no buffer de anel do driver da placa de áudio. Em outras palavras, mostra o tempo mínimo em que ainda é possível atrasar a reprodução com segurança.

E o que fazer? Afinal, precisamos de um cronômetro preciso para sincronizar com o servidor e iniciar eventos javascript na tela e um cronômetro de áudio para eventos sonoros!

Acontece que você precisa sincronizar todos os cronômetros entre si:

  • Cliente audioCtx.currentTime com o cliente performance.now() no cliente.
  • E cliente performance.now() com performance.now() do lado do servidor.

Sincronizado, sincronizado


Em geral, isso é bem engraçado se você pensar a respeito: você pode ter duas boas fontes de tempo A e B, cada uma das quais é muito grossa e barulhenta na saída (A '= A + erro A ; B' = B + erro B ) para que possa até ser inutilizável por si só. Mas a diferença d entre as fontes não barulhentas originais pode ser restaurada com muita precisão.

Como a distância real entre os relógios ideais é uma constante, fazendo medições n vezes, reduziremos o erro de medição err n vezes, respectivamente. A menos, é claro, que o relógio funcione na mesma velocidade.

Sim não sincronizado


A má notícia é que não, eles não vão na mesma velocidade. E não estou falando da divergência de horas no servidor e no cliente - isso é compreensível e esperado. O que é mais inesperado: audioCtx.currentTime gradualmente divergindo de performance.now() . Está dentro do cliente. Podemos não perceber, mas, às vezes, sob carga, o sistema de áudio pode não engolir um pequeno pedaço de dados e (ao contrário da natureza do buffer de toque), o tempo do áudio muda em relação ao tempo do sistema. Isso acontece não tão raramente, apenas não preocupa muitas pessoas: mas, por exemplo, se você lança dois vídeos do YouTube ao mesmo tempo simultaneamente em computadores diferentes, não é fato que eles parem de reproduzir simultaneamente. E o ponto, é claro, não está na publicidade.

Assim, para operação estável e síncrona. Precisamos verificar novamente regularmente todos os relógios, usando o tempo do servidor - como referência. E, em seguida, o trade-off aparece em quantas medidas usar para calcular a média: quanto mais - mais precisa, mas maior a chance de um salto acentuado no audioCtx.currentTime cair na janela de tempo em que filtramos os valores. Então, se, por exemplo, usarmos a janela de minutos, todos os minutos terão o tempo decorrido. A escolha dos filtros é ampla: exponencial , mediana , filtro Kalman , etc. Mas essa troca é, de qualquer forma.

Janela de tempo


No caso de sincronizar audioCtx.currentTime com performance.now() , em um loop assíncrono, para não interferir com a interface do usuário, podemos fazer uma medição, digamos, 100 ms.
Suponha que o erro de medição err = errA + errB = 1 + 3 = 4 ms
Assim, em 1 segundo, podemos reduzi-lo para 0,4 ms e em 10 segundos para 0,04 ms. Melhorias adicionais no resultado não fazem sentido e uma boa janela para filtragem será: 1 a 10 segundos.

No caso da sincronização da rede, atrasos e erros já são muito mais significativos, mas não há um salto acentuado no tempo, como no caso do audioCtx.currentTime . E você pode se permitir acumular ótimas estatísticas. Afinal, o erro no ping pode ser de até 500 ms. E as medidas em si não podemos fazer com tanta frequência.

Neste ponto, proponho parar. Se alguém estiver interessado, ficarei feliz em lhe dizer como "desenhar o resto da coruja". Mas, como parte da história sobre temporizadores, acho que minha história acabou.

E quero compartilhar o que recebi. Mesmo assim, o ano novo.

O que aconteceu


Isenção de responsabilidade: tecnicamente, este é um site de relações públicas em Habré, mas este é um projeto de código aberto totalmente sem fins lucrativos, no qual prometo nunca: colocar anúncios ou ganhar dinheiro de qualquer outra maneira. Pelo contrário, levantei mais instâncias do meu dinheiro agora para sobreviver a um possível efeito habitual. Portanto, por favor, gente boa, não me quebre nem me alcance. Tudo isso é puramente divertido.

Feliz Ano Novo, Habr!



snowtime.fun

Você pode girar os botões e controlar os efeitos de visualização, música e áudio. Se você possui uma placa de vídeo normal, vá para as configurações e defina o número de partículas para 100%.

Requer WebAudio e WebGL.




UPD: não funciona no Safari no macOS Mojave. Infelizmente, não há como descobrir rapidamente o que está acontecendo, devido à ausência do próprio Safari. O iOS parece estar funcionando.

UPD2: Se snowtime.fun e web.snowtime.fun não estiverem respondendo, tente o novo subdomínio habr .snowtime.fun . Ele mudou o servidor para outro datacenter e o IP antigo foi armazenado em cache no DNS, expire=1w . :(

Repositório: bitbucket
Ao escrever este artigo, foram utilizadas ilustrações de macrovetor / Freepik .

Source: https://habr.com/ru/post/pt482168/


All Articles