"Cuando el reloj marca las doce". O una guirnalda en el navegador

Supongamos que tenemos varios monitores. Y queríamos usar estos monitores como guirnalda. Por ejemplo, haz que parpadeen al mismo tiempo. O tal vez cambie sincrónicamente el color de acuerdo con algún tipo de algoritmo inteligente. Y qué pasa si lo haces en un navegador, entonces puedes conectar teléfonos inteligentes y tabletas a esto. Todo eso está a la mano.



Y, como estamos usando un navegador, también puede agregar diseño de sonido. Después de todo, si es lo suficientemente preciso como para sincronizar los dispositivos a tiempo, puede reproducir sonidos en cada uno como si un sistema multicanal sonara.


Lo que se puede encontrar al sincronizar Web Audio y relojes de juego dentro de una aplicación javascript; cuántas "horas" diferentes hay en javasctipt (¡tres!) y por qué se necesitan todas, así como una aplicación preparada para node.js debajo del gato.

Mira el reloj


Para cualquier guirnalda en línea condicional, se requiere una sincronización precisa del reloj. Después de todo, puede ignorar cualquier retraso de red (incluso intermitente). Es suficiente proporcionar a los comandos de control una marca de tiempo y generar estos comandos un poco "hacia el futuro". En los clientes, se almacenarán en el búfer y luego se ejecutarán sincrónicamente y a tiempo.

O incluso puede ir más allá: tome el viejo algoritmo aleatorio determinista y use una semilla común (emitida por el servidor una vez, cuando esté conectada) en todos los dispositivos. Si usa dicha semilla junto con el tiempo exacto, puede determinar completamente el comportamiento del algoritmo en todos los dispositivos. Imagínese: de hecho, no necesita una red o un servidor para cambiar el estado de forma única y sincrónica. La semilla ya contiene la totalidad (condicionalmente infinita) "grabación de video" de acciones por adelantado. Lo principal es la hora exacta.



Cada método tiene sus límites de aplicabilidad. Con la entrada instantánea del usuario, por supuesto, no hay nada que hacer, queda transmitirlo "tal cual". Pero todo lo que se puede calcular es calcular. En mi implementación, utilizo los tres enfoques, dependiendo de la situación.

Subjetivo "al mismo tiempo"


Idealmente, todo debería sonar "al mismo tiempo": no se necesita más de ± 10 ms de discrepancia para el peor par entre los dispositivos combinados. No puede contar con esa precisión desde la hora del sistema, y ​​los métodos estándar para sincronizar la hora utilizando el protocolo NTP no están disponibles en el navegador. Por lo tanto, manejaremos nuestro servidor de sincronización. El principio es simple: el casco "pings" y aceptar "pongs" con la marca de tiempo del servidor. Si hace esto muchas veces seguidas, puede nivelar estadísticamente el error y obtener el tiempo de retraso promedio.

Código: calcular la hora del servidor en el 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; 



Los sockets web y las soluciones basadas en él son los más adecuados porque no requieren tiempo para crear una conexión TCP y puede "comunicarse" con ellos en ambas direcciones. No UDP o ICMP, por supuesto, pero incomparablemente más rápido que una conexión en frío normal que utiliza la API HTTP. Por lo tanto, socket.io. Todo es muy fácil allí:

Código: implementación 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(); } } 

Sería bueno, en lugar de calcular el promedio, calcular la mediana; esto mejorará la precisión con una conexión inestable. La elección de los métodos de filtrado depende del lector. Deliberadamente simplifico el código aquí a favor de los esquemas. Mi solución completa se puede encontrar en el repositorio.


performance.now ()


Permítame recordarle que el objeto de performance es una API que proporciona acceso a un temporizador de alta resolución. Compara:

  • Date.now() devuelve el número de milisegundos desde el 1 de enero de 1970, y lo hace en forma entera . Es decir, el error solo del redondeo es 0.5 ms en promedio. Por ejemplo, en una operación de resta ab puede "perder" sin éxito hasta 2 ms. Además, histórica y conceptualmente, el medidor de tiempo en sí mismo no garantiza una alta precisión y está afilado para trabajar con una escala de tiempo más grande.
  • performance.now() devuelve el número de milisegundos desde que se abrió la página web .
    Esta es una API relativamente reciente, "afilada" específicamente para la medición precisa de intervalos de tiempo. Devuelve un valor de punto flotante , teóricamente dando un nivel de precisión cercano a las capacidades del sistema operativo en sí.


Creo que esta información es conocida por casi todos los desarrolladores de JavaScript. Pero no todos saben eso ...

Espectro


Debido al sensacional ataque de sincronización de Specter en 2018, todo llega al punto de que el temporizador de alta resolución se endurecerá artificialmente si no hay otra solución al problema de vulnerabilidad. Firefox, comenzando con la versión 60, redondea el valor de este temporizador a un milisegundo, y Edge, aún peor.

Esto es lo que dice MDN :

La marca de tiempo no es en realidad de alta resolución. Para mitigar las amenazas de seguridad como Spectre, los navegadores actualmente redondean los resultados en diversos grados. (Firefox comenzó a redondearse a 1 milisegundo en Firefox 60). Algunos navegadores también pueden aleatorizar ligeramente la marca de tiempo. La precisión puede mejorar nuevamente en futuras versiones; Los desarrolladores de navegadores aún están investigando estos ataques de tiempo y la mejor manera de mitigarlos.

Ejecutemos la prueba y echemos un vistazo a los gráficos. Este es el resultado de la prueba en un intervalo de 10 ms:

Código de prueba: medición del tiempo en un 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()

Borde



estadísticas
Versión del navegador: 44.17763.771.0

Date.now ()

intervalo promedio: 1.0538336052202284 ms
desviación del intervalo promedio, RMS: 0.7547819181245603 ms
mediana del intervalo: 1 ms

performance.now ()

intervalo promedio: 1.567100970873786 ms
desviación del intervalo promedio, RMS: 0.6748006785171455 ms
mediana del intervalo: 1.5015000000003056 ms


Firefox



estadísticas
Versión del navegador: 71.0

Date.now ()

intervalo promedio: 1.0168350168350169 ms
desviación del intervalo promedio, RMS: 0.21645930182417966 ms
mediana del intervalo: 1 ms

performance.now ()

intervalo promedio: 1.0134453781512605 ms
desviación del intervalo promedio, RMS: 0.1734108492762375 ms
mediana del intervalo: 1 ms


Cromo



estadísticas
Versión del navegador: 79.0.3945.88

Date.now ()

intervalo promedio: 1.02442996742671 ms
desviación del intervalo promedio, RMS: 0.49858684744444 ms
mediana del intervalo: 1 ms

performance.now ()

intervalo promedio: 0.005555847229948915 ms
desviación del intervalo promedio, RMS: 0.027497846727194235 ms
mediana del intervalo: 0.0050000089686363935 ms


Ok, Chrome, haz zoom a 1 ms.



Por lo tanto, Chrome todavía está aguantando, y su implementación de performance.now() aún no se ha estrangulado y el paso es hermoso 0.005 ms. ¡Bajo Edge, el temporizador performance.now() es más duro que Date.now() ! En Firefox, ambos temporizadores tienen la misma precisión de milisegundos.

En esta etapa, ya se pueden sacar algunas conclusiones. Pero hay otro temporizador en javascript (sin el cual no podemos prescindir).

WebAudio API Timer


Esta es una bestia ligeramente diferente. Se utiliza para colas de audio retrasadas. El hecho es que los eventos de audio (reproducción de notas, gestión de efectos) no pueden depender de herramientas de JavaScript asíncronas estándar: setInterval y setTimeout , debido a su error demasiado grande. Y esto no es solo el error de los valores del temporizador (con los que tratamos anteriormente), sino que es el error con el que la máquina de eventos ejecuta eventos. Y ya es algo alrededor de 5-25 ms, incluso en condiciones de invernadero.

Gráficos para el caso asíncrono debajo del spoiler
El resultado de la prueba en un intervalo de 100 ms:

Código de prueba: medición del tiempo en un ciclo asincrónico
 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()

Borde



estadísticas
Versión del navegador: 44.17763.771.0

Date.now ()

intervalo promedio: 25.595959595959595 ms
desviación del intervalo promedio, RMS: 10.12639235162126 ms
mediana del intervalo: 28 ms

performance.now ()

intervalo promedio: 25.862596938775525 ms
desviación del intervalo promedio, RMS: 10.123711255512573 ms
Intervalo medio: 27.027099999999336 ms


Firefox



estadísticas
Versión del navegador: 71.0

Date.now ()

intervalo promedio: 1.6914893617021276 ms
desviación del intervalo promedio, RMS: 0.6018870280772611 ms
mediana del intervalo: 2 ms

performance.now ()

intervalo promedio: 1.7865168539325842 ms
desviación del intervalo promedio, RMS: 0.6442818510935484 ms
mediana del intervalo: 2 ms


Cromo



estadísticas
Versión del navegador: 79.0.3945.88

Date.now ()

intervalo promedio: 4.7878787878787888, ms
desviación del intervalo promedio, RMS: 0.7557553886872682 ms
mediana del intervalo: 5 ms

performance.now ()

intervalo promedio: 4.783989898979516 ms
desviación del intervalo promedio, RMS: 0.6483716900974945 ms
intervalo medio: 4.750000000058208 ms



Quizás alguien recordará las primeras aplicaciones experimentales de audio HTML. Antes de que WebAudio de pleno derecho llegara a los navegadores, todos parecían un poco borrachos, descuidados. Solo porque usaron setTimeout como secuenciador.

La API moderna de WebAudio, por el contrario, ofrece una resolución garantizada de hasta 0,02 ms (especulación basada en la frecuencia de muestreo de 44100Hz). Esto se debe al hecho de que se utiliza un mecanismo diferente para la reproducción de sonido diferido que setTimeout :

 source.start(when); 

De hecho, cualquier reproducción de una muestra de audio está "retrasada". Solo para perderlo "no se pospone", debe posponerlo "hasta ahora".

 source.start(audioCtx.currentTime); 

Acerca de la música generada por software en tiempo real
Si toca una melodía sintetizada por programa a partir de notas, entonces estas notas deben agregarse a la cola de reproducción con un poco de anticipación. Luego, a pesar de todas las restricciones e irregularidades no fundamentales de los temporizadores, la melodía se reproducirá perfectamente.

En otras palabras, la melodía sintetizada en tiempo real no debe ser "inventada" en tiempo real, sino un poco por adelantado.


Un temporizador para gobernarlos a todos


Dado que audioCtx.currentTime tan estable y preciso, ¿tal vez deberíamos usarlo como la fuente principal de tiempo relativo? Ejecutemos la prueba nuevamente.

Código de prueba: medición de tiempo síncrono en un 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

Borde



estadísticas
Versión del navegador: 44.17763.771.0

Date.now ()

intervalo promedio: 1.037037037037037 ms
desviación del intervalo promedio, RMS: 0.6166609846299806 ms
mediana del intervalo: 1 ms

performance.now ()

intervalo promedio: 1.5447103117505993 ms
desviación del intervalo promedio, RMS: 0.4390514285320851 ms
mediana del intervalo: 1.5015000000000782 ms

audioCtx.currentTime

intervalo promedio: 2.955751134714949 ms
desviación del intervalo promedio, RMS: 0.6193645611529503 ms
Intervalo medio: 2.902507781982422 ms



Firefox



estadísticas
Versión del navegador: 71.0

Date.now ()

intervalo promedio: 1.005128205128205 ms
desviación del intervalo promedio, RMS: 0.12392867665225249 ms
mediana del intervalo: 1 ms

performance.now ()

intervalo promedio: 1.00513698630137 ms
desviación del intervalo promedio, RMS: 0.07148844433269844 ms
mediana del intervalo: 1 ms

audioCtx.currentTime

Firefox no actualiza el valor del temporizador de audio en el bucle de sincronización



Cromo



estadísticas
Versión del navegador: 79.0.3945.88

Date.now ()

intervalo promedio: 1.0207612456747406 ms
desviación del intervalo promedio, RMS: 0.49870223457982504 ms
mediana del intervalo: 1 ms

performance.now ()

intervalo promedio: 0.005414502034674972 ms
desviación del intervalo promedio, RMS: 0.027441293974958335 ms
intervalo medio: 0.004999999873689376 ms

audioCtx.currentTime

intervalo promedio: 3.0877599266656963 ms
desviación del intervalo promedio, RMS: 1.1445555956407658 ms
mediana del intervalo: 2.9024943310650997 ms



Gráficos para el caso asíncrono debajo del spoiler
Código de prueba: medición del tiempo en un ciclo asincrónico
El resultado de la prueba en un 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

Borde



estadísticas
Versión del navegador: 44.17763.771.0

Date.now ():

intervalo promedio: 24.505050505050505 ms
desviación del intervalo promedio: 11.513166584195204 ms
mediana del intervalo: 26 ms

performance.now ():

intervalo promedio: 24.50935757575754 ms
desviación del intervalo promedio: 11.679091435527388 ms
mediana del intervalo: 25.525499999999738 ms

audioCtx.currentTime:

intervalo promedio: 24.76005164944396 ms
desviación del intervalo promedio: 11.311571546205316 ms
mediana del intervalo: 26.121139526367187 ms


Firefox



estadísticas
Versión del navegador: 71.0

Date.now ():

intervalo promedio: 1.6875 ms
desviación del intervalo promedio: 0.6663410663216448 ms
mediana del intervalo: 2 ms

performance.now ():

intervalo promedio: 1.7234042553191489 ms
desviación del intervalo promedio: 0.6588877688171075 ms
mediana del intervalo: 2 ms

audioCtx.currentTime:

intervalo promedio: 10.158730158730123 ms
desviación del intervalo promedio: 1.4512471655330046 ms
intervalo medio: 8.707482993195299 ms


Cromo



estadísticas
Versión del navegador: 79.0.3945.88

Date.now ():

intervalo promedio: 4.585858585858586 ms
desviación del intervalo promedio: 0.9102125516015199 ms
mediana del intervalo: 5 ms

performance.now ():

intervalo promedio: 4.592424242424955 ms
desviación del intervalo promedio: 0.719936993603155 ms
Intervalo medio: 4.605000001902226 ms

audioCtx.currentTime:

intervalo promedio: 10.12648022171832 ms
desviación del intervalo promedio: 1.4508887886499262 ms
mediana del intervalo: 8.707482993197118 ms



Bueno, no funcionará. "Fuera", este temporizador es el más inexacto. Firefox no actualiza el valor del temporizador dentro del bucle. Pero en general: la resolución es de 3 ms y peor y notable jitter. Quizás el valor de audioCtx.currentTime refleja la posición en el búfer en anillo del controlador de la tarjeta de audio. En otras palabras, muestra el tiempo mínimo que aún es posible retrasar de forma segura la reproducción.

Y que hacer Después de todo, necesitamos un temporizador preciso para sincronizar con el servidor y lanzar eventos de JavaScript en la pantalla, ¡y un temporizador de audio para eventos de sonido!

Resulta que necesita sincronizar todos los temporizadores entre sí:

  • Cliente audioCtx.currentTime con el performance.now() cliente.now performance.now() en el cliente.
  • Y cliente performance.now() con performance.now() del lado del servidor.

Sincronizado, sincronizado


En general, esto es bastante divertido si lo piensa: puede tener dos buenas fuentes de tiempo A y B, cada una de las cuales es muy gruesa y ruidosa en la salida (A '= A + err A ; B' = B + err B ) para que pueda incluso ser inutilizable por sí solo. Pero la diferencia d entre las fuentes originales no ruidosas puede restaurarse con mucha precisión.

Dado que la distancia de tiempo real entre los relojes ideales es constante, tomando mediciones n veces, reduciremos el error de medición err n veces, respectivamente. A menos, por supuesto, que el reloj funcione a la misma velocidad.

Si no sincronizado


La mala noticia es que no, no van a la misma velocidad. Y no estoy hablando de la divergencia de horas en el servidor y en el cliente, esto es comprensible y esperado. Lo que es más inesperado: audioCtx.currentTime divergiendo gradualmente de performance.now() . Está dentro del cliente. Es posible que no nos demos cuenta, pero a veces, bajo carga, el sistema de audio puede no tragar una pequeña pieza de datos y (al contrario de la naturaleza del búfer en anillo) el tiempo de audio cambiará en relación con el tiempo del sistema. Esto no ocurre tan raramente, simplemente no afecta a muchas personas: pero si, por ejemplo, lanzas dos videos de YouTube al mismo tiempo simultáneamente en diferentes computadoras, no es un hecho que dejen de reproducirse al mismo tiempo. Y el punto, por supuesto, no está en la publicidad.

Por lo tanto, para un funcionamiento estable y sincrónico. Necesitamos revisar periódicamente todos los relojes entre sí, utilizando la hora del servidor, como referencia. Y luego aparece el equilibrio en cuántas mediciones usar para promediar: cuanto más, más preciso, pero mayor es la posibilidad de que un salto brusco en audioCtx.currentTime caiga en la ventana de tiempo en la que filtramos los valores. Luego, si, por ejemplo, usamos la ventana de minutos, todos los minutos tendremos el tiempo transcurrido. La elección de los filtros es amplia: exponencial , mediana , filtro de Kalman , etc. Pero esta compensación es en cualquier caso.

Ventana de tiempo


En el caso de sincronizar audioCtx.currentTime con performance.now() , en un bucle asíncrono, para no interferir con la interfaz de usuario, podemos tomar una medida, digamos, 100 ms.
Suponga que el error de medición err = errA + errB = 1 + 3 = 4 ms
En consecuencia, en 1 segundo podemos reducirlo a 0,4 ms, y en 10 segundos a 0,04 ms. Una mejora adicional del resultado no tiene sentido, y una buena ventana para el filtrado será: 1 - 10 segundos.

En el caso de la sincronización de red, los retrasos y los errores ya son mucho más significativos, pero no hay un salto repentino en el tiempo, como es el caso de audioCtx.currentTime . Y puede permitirse acumular estadísticas realmente excelentes. Después de todo, err para ping puede ser de hasta 500 ms. Y las medidas en sí no podemos hacer tan a menudo.

En este punto, propongo parar. Si alguien estaba interesado, estaré encantado de decirle cómo "dibujar el resto de la lechuza". Pero como parte de la historia sobre los temporizadores, creo que mi historia ha terminado.

Y quiero compartir lo que obtuve. De todos modos, el año nuevo.

Que paso


Descargo de responsabilidad: Técnicamente, este es un sitio de relaciones públicas en Habré, pero este es un proyecto de mascotas de código abierto completamente sin fines de lucro en el que prometo nunca: poner anuncios o ganar dinero de otra manera. Por el contrario, he recaudado más casos de mi dinero ahora para sobrevivir a un posible efecto de habra. Por lo tanto, por favor, buena gente, no me rompan y no me alcancen. Todo esto es puramente divertido.

Feliz año nuevo, Habr!



snowtime.fun

Puede girar las perillas y controlar la visualización, la música y los efectos de audio. Si tiene una tarjeta de video normal, vaya a la configuración y configure el número de partículas al 100%.

Requiere WebAudio y WebGL.




UPD: no funciona en Safari con macOS Mojave. Desafortunadamente, no hay forma de averiguar rápidamente qué está sucediendo, debido a la ausencia de este Safari en sí. iOS parece estar funcionando.

UPD2: Si snowtime.fun y web.snowtime.fun no responden, pruebe el nuevo subdominio habr .snowtime.fun . Trasladó el servidor a otro centro de datos, y la antigua IP se almacenó en caché en DNS, expire=1w . :(

Repositorio: bitbucket
Al escribir este artículo, se utilizaron ilustraciones de macrovector / Freepik .

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


All Articles