~/textes/gometer-130fps-led
130 images par seconde, dérivées d'un fil
J'ai bâti un démon Go qui pilote un visualiseur de musique sur un Pi, puis j'ai découvert que le débit d'images n'était pas à moi de choisir. Le protocole LED l'avait déjà décidé. Le vrai travail, c'était un budget de latence que je devais mesurer avant que les lumières paraissent verrouillées sur la musique.
À quelle vitesse un visualiseur de musique doit-il se rafraîchir? Je pensais que ça dépendait de moi.
Je présumais que j'allais choisir un chiffre. Soixante fps, peut-être, parce que c'est ce que tout le reste utilise, ajuster la boucle de rendu jusqu'à atteindre ça, fini. Ce qui s'est vraiment passé, c'est que je n'ai jamais eu le choix. Le chiffre était déjà décidé, dans le silicium, par le protocole sur le fil qui pilote les LEDs. Mon travail n'était pas de choisir le débit d'images. Mon travail était de le calculer à partir des principes de base et de bâtir tout le reste pour s'y conformer.
Le projet, c'est gometer: un démon Go sur un Pi qui lit l'audio en direct sorti de squeezelite, exécute une FFT, et peint un panneau WS2812 de 32x16 en analyseur de spectre avec une traîne de phosphore. La FFT n'est pas la partie intéressante. "A l'air correct" et "paraît verrouillé sur la musique" sont séparés par une vingtaine de millisecondes, et on ne passe pas de l'un à l'autre en devinant.
Le débit d'images est sur le fil
WS2812 est un protocole à un fil cadencé à 800 kHz. Chaque pixel fait 24 bits, donc à 800 kHz ça donne 30 µs par pixel sur le fil. Après le dernier pixel, on maintient la ligne basse pour un verrou de réinitialisation, environ 50 µs, pour que la bande sache que l'image est terminée et la valide. C'est tout le modèle de synchronisation, et il n'est pas négociable: c'est le protocole.
Le panneau fait 256 pixels. Donc une image complète coûte:
frame = pixels * per_pixel + latch
= 256 * 30 us + 50 us
= 7730 us
= 7.73 ms -> ~129 fps7,73 ms. Environ 129 fps. C'est le plafond, et il est dur: on ne peut pas faire sortir les bits plus vite que la bande ne les lit.
Il y a un endroit où j'aurais pu faire mieux, et je l'avais déjà fait: les deux moitiés du panneau se cadencent en parallèle sur PWM0 et PWM1. On s'attendrait à ce que 512 pixels prennent deux fois plus de temps, mais les deux canaux décalent simultanément, donc une mise à jour complète reste à 7,73 ms, pas 15,5. Le parallélisme n'achète pas de vitesse au-delà du plafond. Il m'achète le plafond à pleine taille de panneau plutôt qu'à moitié.
Maintenant la partie qui a pris un moment à accepter. La boucle de rendu a un ticker réglé à un sommeil de 500 µs, nominalement 2 kHz. Elle ne tourne pas à 2 kHz. Elle tourne à environ 129 Hz, et c'est correct.
ticker := time.NewTicker(500 * time.Microsecond) // nominal 2 kHz
defer ticker.Stop()
for range ticker.C {
frame := vis.Render(spectrum())
// pa.Write blocks until the DMA has clocked every bit to the strip.
// The loop does not free-run at the ticker rate. It settles at the
// wire rate, because this call will not return until 7.73 ms have passed.
pa.Write(frame)
}pa.Write bloque jusqu'à ce que le DMA finisse de faire sortir l'image. Le ticker est juste là pour m'assurer d'être prêt à commencer l'image suivante à l'instant où la précédente est validée. La boucle se cadence elle-même sur le matériel. Je n'ai réglé 129 fps nulle part. Le fil l'a réglé, et la boucle l'a trouvé.
Le budget de latence est le vrai produit
Le débit d'images, c'est du débit. Il dit à quelle fréquence une nouvelle image apparaît. Il ne dit pas si cette image montre l'audio qu'on entend en ce moment ou l'audio d'il y a un quart de seconde. Ce sont des problèmes différents, et le second décide si la chose paraît vivante.
Il y a un seuil, à peu près 40 ms, au-delà duquel un son et un flash de lumière correspondant cessent de s'enregistrer comme le même événement. En deçà, votre cerveau les fusionne. Au-delà, les lumières ont l'air de réagir à la musique, ce qui est une expérience différente et pire que d'avoir l'air d'être la musique. Donc toute la conception a une seule contrainte au-dessus d'elle: latence totale audio vers LED sous 40 ms, idéalement bien en dessous.
J'ai mesuré le trajet et j'ai additionné.
La fenêtre FFT est la tension dans tout le budget. Une fenêtre plus longue résout des fréquences plus basses: 14 ms d'audio me descendent jusqu'à environ 70 Hz, assez bas pour voir une grosse caisse et une ligne de basse comme des choses distinctes. Mais une fenêtre plus longue veut aussi dire qu'il faut accumuler plus d'audio avant de pouvoir le transformer. Ça, c'est de la latence. Rallongez-la pour une meilleure résolution des basses et vous floutez les transitoires et dépassez le seuil de fusion. Raccourcissez-la pour des transitoires plus vifs et le bas du spectre tourne en bouillie. Quatorze millisecondes, c'est là où ces deux pressions s'équilibrent pour ce panneau.
Additionnez: la fenêtre FFT, la transformation et le mappage, le temps d'image, la mise en mémoire tampon entre les deux, et l'audio atteint les LEDs environ 22 ms après avoir atteint les haut-parleurs. Confortablement à l'intérieur de 40. Un beat tombe sur le panneau au même instant qu'il tombe dans vos oreilles. Cet alignement n'est pas de la chance. C'est un chiffre que j'ai gardé sous un plafond exprès.
Le débit n'est pas la latence
Un débit d'images élevé avec un tampon profond devant donne un mouvement fluide qui arrive en retard. Le panneau a l'air superbe et paraît mort. Le débit d'images décide de l'allure du mouvement. Le budget de latence décide si c'est la musique ou un enregistrement de la musique d'un instant plus tôt.
Le panneau ne peut pas faire le rouge, et autres lois de la physique
La synchronisation réglée, la couleur était la prochaine étape. Les panneaux WS2812 sont mauvais en rouge. La puce rouge se situe vers 620 nm, et un rouge saturé, pleinement allumé, se lit comme un minuscule point terne à côté du vert et du bleu, qui éclatent bien plus fort. Pilotez du rouge pur à pleine valeur et on dirait que le panneau est à peine allumé.
Le premier réflexe est de pousser le rouge et de tirer les autres vers le bas pour s'ajuster. Ça marche pour exactement un panneau. Les vrais lots de WS2812 varient assez pour qu'un équilibre réglé sur une bande ait l'air faux sur la suivante, donc la correction devait être un réglage par déploiement, pas une constante cuite dans le binaire. Les gains par défaut s'appuient sur les canaux qui sont déjà trop forts:
// Per-deployment, because WS2812 batches vary enough that a constant lies.
// Pull green and blue down toward the weak red die instead of overdriving red.
var ColorBalance = [3]float64{1.0, 0.85, 0.85} // R, G, B gains
func balance(r, g, b uint8) (uint8, uint8, uint8) {
return scale(r, ColorBalance[0]),
scale(g, ColorBalance[1]),
scale(b, ColorBalance[2])
}Le deuxième problème de couleur, c'était les transitions. Un analyseur de spectre veut balayer du froid au chaud à mesure que l'énergie monte, du bleu vers le cyan et l'orange jusqu'au rouge. La façon évidente est d'interpoler le RGB directement, et la façon évidente est mauvaise: le point milieu entre le bleu et le rouge est un violet boueux qui, sur un panneau LED à faible contraste, se lit comme une bavure sans bords. Donc je ne mélange pas à travers le point milieu. Je fais passer la transition par des paires complémentaires qui tiennent leur contraste: rouge contre cyan, orange contre bleu. Celles-là survivent à un faible rapport de contraste parce que l'oeil les lit comme opposées plutôt que comme un dégradé vers le gris. Le mouvement reste lisible au lieu de se dissoudre en bouillie violette.
La dernière pièce, c'est pourquoi 129 fps vaut la peine du tout, étant donné que le cinéma s'en tire avec 24. La fusion de scintillement, le débit au-delà duquel une lumière clignotante paraît stable, est autour de 60 Hz pour la plupart des gens. À environ le double, le panneau n'est pas juste au-delà du scintillement. Il est à l'intérieur de la persistance rétinienne, où des images successives se mêlent en mouvement continu dans l'oeil lui-même. Une barre qui grimpe le panneau ne saute pas de pixel en pixel. Elle se lit comme un mouvement fluide sous-pixel, parce qu'au moment où votre rétine a lâché une image les deux suivantes sont déjà arrivées.
La décroissance de la traîne de phosphore est à deux étages pour correspondre à la façon dont un vrai phosphore se comporte. Une demi-vie brillante d'environ une image, pour qu'une barre fraîchement frappée bondisse à pleine luminosité et retombe vite, et une demi-vie terne d'environ 50 ms en dessous, pour que la queue s'attarde et s'estompe à la manière d'un vieux tube cathodique. Un seul étage a l'air faux: une seule décroissance rapide n'a pas de traîne, et une seule décroissance lente étale tout en un flou lumineux. Deux étages donnent un bord d'attaque net avec une queue douce derrière.
Le bogue qui exigeait un redémarrage complet
Maintenant l'histoire de correction. Les maths de synchronisation sont la colonne vertébrale, mais c'est la partie que je n'ai pas vue venir.
gometer lit l'audio sorti de squeezelite à travers un segment de mémoire partagée que squeezelite exporte pour son VU-mètre. Pour le trouver, le démon balaie /dev/shm à la recherche de segments nommés squeezelite* et s'attache à un. Le code original prenait la première correspondance:
func findSqueezeliteShm() (string, error) {
entries, _ := os.ReadDir("/dev/shm")
for _, e := range entries {
// os.ReadDir returns lexical order. The first squeezelite* segment
// is not necessarily the live one. This is the bug.
if strings.HasPrefix(e.Name(), "squeezelite") {
return filepath.Join("/dev/shm", e.Name()), nil
}
}
return "", errSegNotFound
}os.ReadDir retourne les entrées en ordre lexical. Donc "la première correspondance" est "la première correspondance par ordre alphabétique", ce qui n'a rien à voir avec quel segment appartient au lecteur en cours d'exécution. squeezelite ne nettoie pas toujours son segment quand il meurt, donc un segment périmé d'un PID mort peut survivre au processus et se classer avant le vivant. Le visualiseur se verrouillait sur un cadavre et restait là à réagir au silence, ou pire, à n'importe quel rebut laissé pour la dernière fois dans ce tampon.
La correction est d'arrêter de faire confiance à l'ordre et de commencer à lire les segments. Chacun porte un octet running et un horodatage updated. Faire un stat de chaque candidat, vérifier s'il est réellement vivant, et parmi les vivants choisir le plus récemment mis à jour:
func findSqueezeliteShm() (string, error) {
entries, _ := os.ReadDir("/dev/shm")
var best string
var bestUpdated uint64
for _, e := range entries {
if !strings.HasPrefix(e.Name(), "squeezelite") {
continue
}
hdr := peekHeader(filepath.Join("/dev/shm", e.Name()))
// running == 0 is a corpse. Among the live ones, newest updated wins.
if hdr.running != 0 && hdr.updated > bestUpdated {
bestUpdated = hdr.updated
best = filepath.Join("/dev/shm", e.Name())
}
}
if best == "" {
return "", errSegNotFound
}
return best, nil
}Ça a réglé le verrouillage au démarrage. Ça n'a pas réglé l'autre moitié du même bogue: un lecteur qui démarrait bien puis devenait noir. Le démon perdait l'audio en cours de session et exigeait un redémarrage complet pour récupérer. Le segment auquel il s'était attaché était toujours là, toujours mappé, juste plus écrit, parce que le lecteur était passé à un nouveau. La découverte ne s'exécutait qu'une fois au démarrage, donc rien n'allait jamais chercher de nouveau.
La réponse était un minuteur de stagnation. Si aucune image n'est arrivée depuis cinq secondes, l'audio a cessé de circuler à travers le segment qu'on tient, donc on le laisse tomber et on relance la découverte. Ça a transformé "perd l'audio et exige un redémarrage" en "perd l'audio pendant cinq secondes et se re-verrouille silencieusement sur le lecteur vivant". Le même bogue que le verrouillage au démarrage, sous un déguisement plus lent: ne faites pas confiance à un handle pour rester valide juste parce qu'il était valide quand vous l'avez obtenu.
Un aparté, parce que je l'ai mérité
Le visualiseur a une petite interface web de contrôle: luminosité, palette, quelle visualisation. J'ai bâti la première version avec shadcn et React, je l'ai regardée, et je ne l'ai pas aimée. Une application monopage avec une API JSON derrière, parlant à un démon dont le travail entier est de pousser des pixels vers un fil aussi vite que le fil le permet. Le frontend avait plus de pièces mobiles que la chose qu'il contrôlait.
Je l'ai arraché et je l'ai rebâti avec htmx. Le serveur rend du HTML, les boutons y postent, la page échange des fragments. Il n'y avait pas d'API JSON à concevoir parce qu'il n'y avait pas de client qui en avait besoin. Un visualiseur n'a pas besoin d'une bibliothèque de gestion d'état. Il a besoin de trois boutons et d'un démon qui ne cligne jamais.
Ce que c'était vraiment
J'y suis allé en pensant que la FFT ou la couleur seraient là où je resterais coincé. La FFT est un appel de bibliothèque. La couleur était de la physique que je pouvais mesurer et corriger. Ce qui m'a vraiment mordu, c'était un chiffre que je n'ai jamais eu le choix de choisir et un budget que je devais garder sous un seuil que je ne pouvais pas voir.
Le débit d'images était sur le fil depuis le début: 256 pixels, 30 µs chacun, 50 pour le verrou, 7,73 ms, 129 fps. Je ne l'ai pas réglé. Je l'ai calculé, j'ai bâti la boucle pour qu'elle y tombe, et j'ai dépensé le reste du budget à m'assurer que la lumière arrive pendant que le son était encore dans l'air.
Le code est sur GitHub.