This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 60 kB View raw
1#import "template.typ": arkheion, arkheion-appendices, monospace 2#import "utils.typ": include-function, cut-around, cut-between, dedent 3 4#import "@preview/diagraph:0.3.6" 5#show raw.where(lang: "dot"): it => diagraph.render(it.text) 6#show raw.where(lang: "mermaid"): it => diagraph.render( 7 it.text.replace("graph TD", "digraph {").replace("-->", "->") + "}", 8) 9 10 11#let imagefigure(path, caption, size: 100%) = figure( 12 image(path, width: size), 13 caption: caption, 14) 15 16#let diagram(caption: "", size: 100%, content) = figure( 17 caption: caption, 18 kind: image, 19 scale(size, content, reflow: true), 20) 21 22#let breakout(content) = block( 23 inset: 1em, 24 fill: luma(95%), 25 radius: 4pt, 26 width: 100%, 27 pad(x: 1em, align(center, text(size: 1.1em, content))), 28) 29 30#let codesnippet(caption: "", content, lang: "rust", size: 1em) = { 31 let snip = text( 32 size: size, 33 block( 34 inset: 1.5em, 35 fill: luma(95%), 36 radius: 4pt, 37 width: 100%, 38 // Figure itself is already non breakable, AFAIK 39 breakable: caption != "", 40 if type(content) == str { 41 raw( 42 lang: lang, 43 content, 44 ) 45 } else { 46 content 47 }, 48 ), 49 ) 50 51 if caption != "" { 52 figure(caption: caption, align(left, snip)) 53 } else { 54 snip 55 } 56} 57 58#show link: underline 59 60#show: arkheion.with( 61 title: "Shapemaker: Créations audiovisuelles procédurales musicalement synchrones", 62 headertitle: "Shapemaker", 63 authors: ( 64 ( 65 name: "Gwenn Le Bihan", 66 email: "gwenn.lebihan@etu.inp-n7.fr", 67 affiliation: "ENSEEIHT", 68 ), 69 ), 70 logo: "./enseeiht.jpeg", 71 date: [#datetime.today().day() Mars 2025], 72 // keywords: ( 73 // "audiovisuel", 74 // "procédural", 75 // "DSP", 76 // "SVG", 77 // "Rust", 78 // "WASM", 79 // "MIDI", 80 // "VST", 81 // ), 82) 83 84#pagebreak() 85 86#align(center, pad(y: 30%, image("./dna-analysis-machine.png", width: 100%))) 87 88#pagebreak() 89 90#text( 91 size: 0.88em, 92 raw(lang: "rust", read("../examples/dna-analysis-machine/src/main.rs")), 93) <demo-code> 94 95#pagebreak() 96 97#outline() 98 99= Introduction 100 101== À la recherche d'une impossible énumération des formes 102 103#grid( 104 columns: (1fr, 1.5fr), 105 gutter: 2em, 106 imagefigure("./majus.png", [_MAJUS_ @vasarely-majus]), 107 [ 108 Fascinée depuis longtemps par les œuvres du plasticien et artiste Op-Art _Victor Vasarely_, j'ai été saisie par une de ses périodes, la période "Planetary Folklore", pendant laquelle il a expérimenté à travers plusieurs œuvres autour de l'idée d'un alphabet universel fait de combinaisons simples de formes et couleurs. D'apparence très simple, ces combinaisons sont, d'une manières assez fascinante, uniques, d'où l'idée d'alphabet @planetary-folklore-period. 109 110 En particulier, un tableau, _MAJUS_, implémente à la fois ce concept, et est également une transcription d'une fugue de Bach. 111 ], 112) 113 114Avec cette idée en tête, je me mets à gribouiller une ébauche d'"alphabet des formes", qui, naïvement, cherche à énumérer toutes les formes constructibles à partir de formes simples, en autorisant les superpositions, rotations et translations. 115 116#grid( 117 columns: (1fr, 1fr), 118 gutter: 1em, 119 imagefigure("./alphabetdesformes.png", "Un “alphabet” incomplet"), 120 imagefigure("./alphabetdesformes.svg", "Une vectorisation"), 121) 122 123Principalement par simple intérêt esthétique, je vectorise cette page via Illustrator. Vectoriser signifie convertir une image bitmap, représentée par des pixels, en une image vectorielle, qui est décrite par une série d'instructions permettant de tracer des vecteurs (d'où le nom), leur ajouter des attributs comme des couleurs, des règles de remplissage (Even-Odd, Non-Zero, etc.), des effets de dégradés, etc. 124 125Un aspect intéressant des images vectorielles est que, parmi les différents formats les décrivant, le _SVG_, pour _Scalable Vector Graphics_, est indéniablement le plus populaire, et est un standard ouvert décrivant un format texte. 126 127Il est donc très facile de programmatiquement générer des images vectorielles à travers ce format. 128 129== Une approche procédurale ? 130 131#figure( 132 caption: "Exemples d'œuvres résultant d'une procédure de génération semi-aléatoire", 133 grid( 134 columns: (1fr, 1fr, 1fr, 1fr, 1fr), 135 ..( 136 "designing-a-font", 137 "drone-operating-system", 138 "HAL-9000", 139 "japan-sledding-olympics", 140 "lunatic-green-energy", 141 "measuring-spirits", 142 "phone-cameras", 143 "reflections", 144 "spline-optimisation", 145 "weaving", 146 ).map(artwork => grid.cell( 147 image("../examples/gallery/" + artwork + ".svg", width: 100%), 148 )) 149 ), 150) 151 152L'étape prochaine dans cette démarche était donc de générer procéduralement ces formes. Afin d'avoir des résultats intéressants, et devant l'évidente absurdité d'un projet d'énumération _complète_ de _toutes les formes_, on préférera des générations procédurales dites "semi-aléatoires", dans le sens certains aspects du résultat final sont laissés à l'aléatoire, comme le placement des formes élémentaires, tandis que de d'autres, comme la palette de couleurs, sont des décisions de l'artiste. 153 154Le modèle choisi dans les premières ébauches de Shapemaker est le suivant: 155 156#figure( 157 caption: "Vocabulaire visuel des premières ébauches: grille de placement à 9 points, formes et couleurs", 158 grid( 159 columns: (1fr, 1fr, 1fr), 160 gutter: 3em, 161 grid.cell(image("./grid.svg"), align: center), 162 grid.cell(image("./shapeshed.svg"), align: center), 163 grid.cell(image("./colorshed.svg"), align: center) 164 ), 165) 166 167L'idée est donc de limiter la part d'aléatoire à des choix dans des ensembles prédéfinis d'éléments, que ce soit dans le choix des couleurs, des placements ou des formes élémentaires. 168 169Cette méthode amène donc l'artiste à définir, d'une certaine manière, son _propre langage visuel_, les éléments de langage sont les couleurs, formes, placements et post-traitements (flou, rotations, etc) utilisables. 170 171La part aléatoire engendre _une_ infinité réduite d'œuvres, qui naissent dans les confins du langage visuel défini par l'artiste. 172 173== Excursion dans le monde physique 174 175#figure( 176 caption: [Planches d'impression (merci à Relais Copies @relaiscopies)], 177 stack( 178 image("./street/workshop.jpeg"), 179 // image("./street/stack.jpeg") 180 ), 181) 182 183Bien évidemment, les décisions dans le processus créatif ne s'arrêtent pas au choix du vocabulaire visuel utilisé par le processus de génération. 184 185Étant donné la simplicité avec laquelle l'on peut dériver de grandes quantités d'œuvres à partir du même langage, la _sélection des meilleures œures_ influe évidemment sur la série exposée et/ou partagée. 186 187C'est dans cette optique que j'ai réalisé une série d'impressions de 30 générations, dont certaines ont été légèrement retouchées après génération. 188 189=== Interprétation collective 190 191Avec 30 œuvres (cf #ref(<annexe-serie-interp-collective>, supplement: "Annexe")) abstraites sans nom, je me suis posé la question de comment les nommer. J'aurais pu les nommer au gré de ma propre imagination, mais j'ai trouvé intéressant le faire de laisser cette décision au grand public, qui tomberait nez à nez avec ces manifestations de semi-hasard. 192 193Le choix du nom d'une œuvre, en particulier quand elle est aussi abstraite et déta de contexte explicite, peut se faire parmi une potentielle infinité de titres, du littéral, au descriptiviste au poétique. 194 195Les œuvres possèdent toutes un QR code amenant sur une page web qui permet de (re)nommer l'œuvre, en y apposant optionnellement son nom, en l'adoptant jusqu'à ce que lea prochain·e n'en prenne la garde. 196 197J'ai donc laissé le public trouver ces œuvres, cachées à travers la ville, dans l'esprit des fameux _Spaces Invaders_ de Paris @spaceinvadersparis (qui d'ailleurs étendent leur colonisation bien au-delà de Paris, allant même jusqu'à l'ISS @spaceinvadersiss). 198 199 200#let work = ( 201 slug, 202 caption, 203 with-context: false, 204 only-context: false, 205 screenshot: true, 206) => figure( 207 caption: caption, 208 grid( 209 gutter: 0.5em, 210 columns: if screenshot { 211 (if with-context and not only-context { 2fr } else { 1fr }, 3fr) 212 } else { 213 1fr 214 } 215 , 216 if screenshot { 217 grid.cell(rowspan: 2, image("./street/" + slug + "-screenshot.png")) 218 }, 219 if not only-context { 220 image("./street/" + slug + ".jpeg") 221 }, 222 if with-context or only-context { 223 image("./street/" + slug + "-context.jpeg") 224 }, 225 ), 226) 227 228Certaines ont été souvent renommées, beaucoup ont disparues, et certaines restent encore inconquises. 229 230#work("reflets-citadins", ["Reflets Citadins", nommée par _Enide_]) 231#work("paramount", ["Paramount"]) 232#work( 233 "lenvolée-du-cerf-volant", 234 ["l'envolée du Cerf-Volant", nommée par _Nicolas C._], 235) 236 237 238#work("danse-le-ciel", ["Danse le ciel"], with-context: true) 239#work("bridging", [_Sans titre_], only-context: true) 240 241== Lien musical 242 243#figure( 244 caption: [Frames d'une _story_ Instagram montrant une première esquisse de vidéo], 245 stack( 246 dir: ltr, 247 ..range(7).map(it => image( 248 "./blackmirrorlike/frame-" + str(it) + ".png", 249 width: 14%, 250 )), 251 ), 252) 253 254À force de générer des centaines de petites images géométriques, il m'est venu à l'idée de les transformer en frames d'une _vidéo_. 255 256Afin d'évaluer à quoi pourrait ressembler une telle chose, j'ai commencé par simplement faire une boucle, écrasant un même fichier .png à un intervalle de temps régulier, fichier ouvert dans XnView @xnview, qui se recharge automatiquement quand le fichier affiché change. 257 258Bien évidemment, surtout s'il s'agit d'une vidéo synchronisée à sa bande son, il ne suffit pas de générer une frame aléatoire chaque seconde. Il faut pouvoir _*réagir* à des moments et rythmes clés du morceau_. 259 260 261= Une _crate_ Rust avec un API sympathique 262 263Pour implémenter cette génération, il faut donc donner un moyen à l'artiste de décrire son langage visuel. 264 265Ainsi, Shapemaker est une bibliothèque, ou _crate_ dans l'écosystème Rust @rustcrates, dont l'on peut se servir pour créer son script, dont un exemple est montré #ref(<demo-code>, form: "page"). 266 267La procédure est conceptualisée par un canvas, composé de une ou plusieurs couches ou _layers_ d'objets. Ces objets sont _colorés_ (possèdent une information sur la manière dont il faut les remplir: bleu solide, hachures cyan, etc.), et peuvent également subir des filtres et transformations #footnote[Avec un peu de recul, le terme d'objet texturé est plus approprié, mais le code n'a pas encore changé]. Ils sont aussi _placés_ dans l'espace du canvas: le canvas possède une information de _région_, un intervalle 2D de points valables. Les objets se placent dans cette région, en stockant en leur sein les coordonnées de _points_ marquant leur positionnement dans l'espace (par exemple, #raw(lang: "rust", "Object::Rectangle") stocke deux `Point` pour définir ses coins) 268 269 270#diagram( 271 caption: [Modèle objet du Canvas], 272 size: 70%, 273 ```dot 274 digraph { 275 // rankdir="LR"; 276 node [shape="record"]; 277 278 Canvas -> Layer [label="1+"] 279 region2 [label="Region"] 280 Layer -> region2 281 Canvas -> Region [label=".world_region"] 282 point2 [label="Point"] 283 Region -> point2 [label="RegionIterator"] 284 Layer -> ColoredObject [label="0+"] 285 Object -> "Object::Rectangle,\nObject::Circle,\n…" -> Point 286 ColoredObject -> Object 287 ColoredObject -> Fill 288 ColoredObject -> Transform 289 ColoredObject -> Filter 290 Fill -> "Fill::Solid,\nFill::Hatches,\n…" -> Color 291 Transform -> "Transform::Rotate,\nTransform::Translate,\n…" 292 Filter -> "Filter::Blur,\nFilter::Glow,\n…" 293 } 294 ```, 295) 296 297Ce modèle mental permet de travailler plus efficacement car il est bien plus proche de la manière dont on a tendance à penser l'art visuel: sur Illustrator par exemple, ce sont des objets, organisés en plusieurs couches, qui possèdent des attributs dictant leur remplissage. 298 299Les concepts de transformations et de filtres sont également très proche de ce qu'on peut retrouver dans des logiciels de traitement d'images raster, comme Photoshop. 300 301== Découpage en modules 302 303Pour rendre la bibliothèque plus claire, et pouvoir éventuellement séparer la crate en plusieurs sous-crates et ainsi améliorer la vitesse de compilation @rustcompileunits dans le futur, la crate est découpée en plusieurs modules: 304 305#grid( 306 columns: (1fr, 1fr), 307 gutter: 2em, 308 [ 309 / geometry: partie purement géométrique, définissant `Point`, `Region` et leurs opérations associées 310 / graphics: définitions des objets et tout leurs aspects visuels (`Fill`, `Transform`, `Filter`, `Color`, `Object`, `ColoredObject`) 311 / random: fonctions de génération aléatoire, permettant d'introduire facilement et de manière plus ou moins granulaire, une part d'aléatoire dans le processus de génération: `Region.random_point()`, `Color::random()`, etc. 312 / rendering: implémentation du rendu en SVG et PNG 313 / video: cf #ref(<crate::video>) 314 / synchronization: cf #ref(<crate::synchronization>) 315 / vst: cf #ref(<crate::vst>) 316 / wasm: cf #ref(<crate::wasm>) 317 ], 318 diagram( 319 caption: [Dépendances entre les modules de la bibliothèque], 320 size: 60%, 321 raw( 322 lang: "mermaid", 323 cut-between( 324 it => it == "```mermaid", 325 it => it == "```", 326 read("../src/README.md"), 327 ), 328 ), 329 ), 330) 331 332= Rendu en images 333 334Maintenant que l'on a cette structure, il est bien évidemment essentiel de pouvoir l'exporter en un fichier image exploitable, en PNG par exemple. 335 336L'idée est d'utiliser le standard SVG et tout l'écosystème existant autour, pour éviter d'avoir à ré-implémenter un moteur de rastérisation à la main: SVG possède déjà énormément de fonctionnalités, et faire ainsi nous permet également d'avoir un "escape hatch" et de fournir à Shapemaker des fragments de code SVG pour des cas spécifiques que la bibliothèque ne couvrirait pas, à travers `Object::RawSVG`, qui prend en argument un arbre SVG brut. 337 338Ce processus de rendu est réalisé via l'implémentation d'un _trait_, une sorte d'équivalent en Rust des interfaces présentes dans les langages orientés objet @rusttraits: 339 340#codesnippet( 341 lang: "rust", 342 cut-around( 343 it => it.trim().starts-with("pub trait SVGRenderable"), 344 it => it == "}", 345 read("../src/rendering/renderable.rs"), 346 ), 347) 348 349Ce _trait_ est ensuite implémenté par la plupart des structures de `shapemaker::graphics`, de la façon suivante: 350 351/ Canvas: rendu de toutes ses `Layer`, en prenant garde à les ordonner correctement pour que les premières couches soit dessinées par dessus les dernières 352/ Layer: rendu de l'ensemble des `ColoredObject` qu'elle contient, en les regroupant dans un groupe SVG #raw(lang: "svg", "<g>"), ce qui garanti l'ordre de superposition des objets qu'elle contient 353/ ColoredObject: rendu de l'`Object` qu'il contient, en appliquant les transformations et filtres 354/ Object: dépend de la variante: `Object::Rectangle` est rendu comme un #raw(lang: "svg", "<rect>"), `Object::Circle` est rendu comme un #raw(lang: "svg", "<circle>"), etc. 355/ Fill: dépend de la variante: simple attribut SVG `fill` pour `Fill::Solid`, utilisation de #raw(lang: "svg", "<pattern>") pour `Fill::Hatches`, etc. 356/ Transform: attribut SVG `transform` 357/ Filter: définition d'un #raw(lang: "svg", "<filter>") avec les attributs correspondants 358/ Color: utilise le `ColorMapping` donné pour réifier sa variante#footnote["variante" dans le sens des _variantes d'un enum_, `Color` étant un enum de couleurs nommées, `Color::Black`, `Color::Pink`, etc.] en une valeur de couleur SVG (en notation hexadécimale) 359 360#diagram( 361 caption: [Objets rendables en SVG], 362 size: 60%, 363 ```dot 364 digraph { 365 // rankdir="LR"; 366 node [shape="record", style="filled", fillcolor="#e0e000"]; 367 368 Canvas -> Layer 369 region2 [label="Region", style="solid"] 370 Layer -> region2 371 Canvas -> Region 372 point2 [label="Point", style="solid"] 373 Region -> point2 374 Layer -> ColoredObject 375 Point[style="solid"] 376 Object -> "Object::Rectangle,\nObject::Circle,\n…" -> Point 377 ColoredObject -> Object 378 ColoredObject -> Fill 379 ColoredObject -> Transform 380 ColoredObject -> Filter 381 Fill -> "Fill::Solid,\nFill::Hatches,\n…" -> Color 382 Transform -> "Transform::Rotate,\nTransform::Translate,\n…" 383 Filter -> "Filter::Blur,\nFilter::Glow,\n…" 384 } 385 ```, 386) 387 388#grid( 389 columns: (1fr, 1fr), 390 gutter: 2em, 391 [ 392 Les arguments `cell_size` et `object_sizes` permettent de réaliser en valeur concrètes (pixels) les valeurs de taille abstraites: la distance unitaire entre deux points est définie par `cell_size`, et les tailles des objets, qui, par choix, ne sont pas finement contrôlables, sont définies par `object_sizes`. 393 ], 394 codesnippet( 395 caption: [Définition du type de `ObjectSizes`], 396 lang: "rust", 397 size: 0.87em, 398 cut-around( 399 it => it.trim().starts-with("pub struct ObjectSizes"), 400 it => it == "}", 401 read("../src/graphics/objects.rs"), 402 ), 403 ), 404) 405 406En suite, pour convertir en PNG, on utilise une autre bibliothèque, _resvg_, qui implémente presque complètement la spécification SVG 1.1, et l'implémente même mieux que Firefox, Safari et Chrome @resvg. L'arbre SVG que l'on a construit est sérialisé en string, puis parsé par _resvg_#footnote[Ce choix à première vue étonnant, qui consistue une perte de performance, est discuté au #ref(<perf-svgstring>), #ref(<perf-svgstring>, form: "page")], qui le transforme en un arbre de rendu, qui est ensuite rasterisé en une pixmap#footnote[Matrice plate de pixels RGBA], qui est finalement encodée en PNG puis écrite dans un fichier. 407 408#diagram( 409 caption: [Rendu d'un canvas SVG en PNG], 410 ```dot 411 digraph { 412 rankdir="LR"; 413 node [shape="record"]; 414 "svg tree" -> "svg string" 415 "svg string" -> "usvg tree" 416 "usvg tree" -> "pixmap" 417 pixmap -> "png file" 418 } 419 ```, 420) 421 422 423= Render loop et hooks <crate::video> 424 425On peut maintenant rastériser un canvas. Passer à l'étape vidéo consiste donc à réaliser cette opération sur chaque _frame_ de la vidéo finale. Cependant, la vidéo devant se synchroniser au son, la tâche est rendue plus difficile: en effet, il ne suffit pas d'exposer à l'artiste une fonction `render_frame`, qui prendrait en argument le numéro de frame actuel et permettrait de définir le canvas pour chaque frame: on a besoin de _réagir_ à des moments clés de la musique. 426 427Pour donner les moyens à l'artiste d'exprimer cela, on utilise un concept assez commun en programmation, les _hooks_, nommés ainsi car, essentiellement, ils permettent à du code utilisateur de s’immiscer dans certains moments de l'exécution d'une bibliothèque @hooks. 428 429Dans notre cas, on va donner les hooks suivants: 430 431/ each_beat: Appelé sur chaque battement de la musique 432/ on_note: Appelé à chaque début de note jouée, par un ou des instruments en particulier à préciser 433/ at_timestamp: Appelé une fois, à un instant précis de la vidéo 434/ ...: et pleins d'autres 435 436Un `Hook` est consistué de deux fonctions: `when` pour savoir si le hook doit être exécuté à un instant donné, et `render_function` qui décrit les modifications à effectuer sur le canvas. 437 438#codesnippet( 439 size: 0.85em, 440 cut-around( 441 it => it.trim().starts-with("pub struct Hook"), 442 it => it == "}", 443 read("../src/video/engine.rs"), 444 ) 445 + "\n\n" 446 + cut-around( 447 it => it.trim().starts-with("pub type HookCondition"), 448 it => it.trim().ends-with(";"), 449 read("../src/video/engine.rs"), 450 ) 451 + "\n\n" 452 + cut-around( 453 it => it.trim().starts-with("pub type RenderFunction"), 454 it => it.trim().ends-with(";"), 455 read("../src/video/engine.rs"), 456 ).replace("anyhow::Result", "Result"), 457) 458 459Un hook reçoit notamment une référence mutable au Canvas, #raw(lang: "rust", "&mut Canvas"), car il _modifie le canvas de la frame en cours_. Le moteur de rendu vidéo ne possède en effet qu'un seul canvas, qui est successivement modifié au cours de la vidéo. 460 461Le paramètre générique #raw(lang: "rust", "<C>") existe car l'artiste peut définir des données additionnelles à stocker dans le contexte, ce dernier étant partagé entre les différentes exécutions des hooks. Par exemple: "quelle a été la dernière ligne de parole affichée? il faut passer à la prochaine" 462 463On met également à disposition une méthode `with_hook`, qui rajoute un hook à la liste, permettant de facilement les définir: 464 465 466#codesnippet( 467 include-function( 468 "../src/video/engine.rs", 469 "with_hook", 470 lang: "rust", 471 is_method: true, 472 transform: it => ( 473 "impl Video<C> {\n ...\n" 474 + it.replace("<AdditionalContext>", "<C>") 475 + "\n}" 476 ), 477 ), 478) 479 480Voici par exemple la définition du hook `on_note`: 481 482#codesnippet( 483 size: 0.9em, 484 include-function( 485 "../src/video/engine.rs", 486 "on_note", 487 lang: "rust", 488 is_method: true, 489 transform: it => ( 490 "impl Video<C> {\n ...\n" 491 + it.replace("<AdditionalContext>", "<C>") 492 + "\n}" 493 ), 494 ), 495) 496 497Le moteur de rendu vidéo est donc une boucle qui, à chaque itération, regarde dans l'ensemble des _hooks_ enregistrés, exécute ceux qui le demande, puis rastérise le canvas en une frame qui est ensuite donnée à l'encodeur vidéo: 498 499#diagram( 500 caption: [Pipeline], 501 size: 60%, 502 ```dot 503 digraph G { 504 rankdir="LR"; 505 compound=true; 506 node[shape="record"]; 507 508 subgraph cluster_0 { 509 label = "Render loop" 510 style = "filled" 511 color = "#f0f0f0" 512 513 // Set specific weights to encourage circular layout 514 "next frame" -> hooks // [label="Trigger"]; 515 hooks -> canvas // [label="Modify"]; 516 // is_fresh[shape=diamond, label="New frame?"] 517 is_fresh[shape=point, label=""] 518 canvas -> is_fresh [label="new frame?"]; 519 is_fresh -> frame [label="Yes"]; 520 is_fresh -> "next frame" [label="No"]; 521 frame -> "next frame"; 522 } 523 524 syncdata[label="sync data"]; 525 526 audioin[label="stems .wav + BPM"] 527 midi[label="MIDI export"] 528 flp[label=".flp project file"] 529 530 midi -> syncdata 531 audioin -> syncdata 532 flp -> syncdata 533 534 syncdata -> "next frame" 535 536 usercode[label="user code"]; 537 usercode -> hooks [style=dashed] // [label="Defines"] 538 539 frame -> video 540 syncdata -> audio -> video 541 } 542 ```, 543) 544 545La boucle de rendu en elle-même itère sur *les instants de la vidéo, milliseconde par milliseconde, et non pas les frames*. C'est important pour garder la vidéo en synchronisation avec le son. J'avais initialement fait itérer la boucle sur les frames, et la vidéo se décalait progressivement de sa bande son#footnote[Ma théorie est qu'il faut itérer sur un sorte de dénominateur commun des deux pas temporels, sachant les informations de synchronisation de la musique ont un pas de temps bien plus court que le FPS de la vidéo]. 546 547#codesnippet(```rust 548let render_ms_range = self.start_rendering_at..self.duration_ms(); 549 550let mut frames_to_encode: Vec<(Time, String)> = vec![]; 551 552for _ in render_ms_range.into_iter() { 553 context.ms += 1_usize; 554 context.frame = self.fps * context.ms / 1000; 555```) 556 557On exécute bien les hooks à chaque itération de la boucle, mais par contre on ne rend une nouvelle frame uniquement si le numéro de frame change: 558 559#codesnippet( 560 dedent( 561 cut-around( 562 it => it 563 .trim() 564 .starts-with("if context.frame != previous_rendered_frame"), 565 it => it.trim().ends-with("}"), 566 read("../src/video/encoding.rs"), 567 ), 568 ), 569) 570 571La rastérisation et l'encodage sont réalisés après la fin de la boucle de rendu pour pouvoir paralléliser la rastérisation (voir #ref(<perf-parallelrasterize>)). 572 573 574= Sources de synchronisation <crate::synchronization> 575 576On a pu voir dans les exemples de code précédents que les hooks reçoivent deux arguments essentiels dans leur fonctions: le _canvas_, discuté précédemment, et un _contexte_. 577 578Ce contexte, en plus de quelques informations déposées par la boucle de rendu (milliseconde actuelle, numéro de frame actuel, etc), contient surtout _des informations musicales sur l'instant présent_, comme les notes actuellement jouées, les amplitudes instantanées de chaque piste, etc. 579 580Afin d'obtenir ces information, il faut bien analyser quelque chose---la question est donc: de quels fichiers ou signaux tirer parti pour construire ces informations de synchronisation? 581 582Les sous-sections suivantes traites des différentes approches explorées: 583 584/ Amplitudes des _stems_: utilisation des signaux audio bruts depuis des exports piste par piste du morceau 585/ Analyse de fichiers MIDI: utilisation d'un standard stockant les notes jouées dans le temps. 586/ Analyse de fichiers .flp: utilisation des fichiers de projet de FL Studio, un logiciel de production musicale. C'est l'équivalent d'un fichier source en programmation, là où l'export .mp3 serait l'équivalent d'un exécutable. 587/ Sondes dans le logiciel de MAO#footnote[MAO: Musique Assistée par Ordinateur]: utilisation de plugins VST pour envoyer des informations de synchronisation potentiellement arbitraire, directement depuis le logiciel de production musicale. 588/ Temps réel: utilisation de signaux MIDI en "live", solution contournant le problème de la synchronisation et toute la partie rendu vidéo et rastérisation. Plutôt prévue pour un autre cas d'usage, les concerts et installations live 589 590Dans chacun de ces cas, l'objectif est de pouvoir inférer depuis ces ressources les informations suivantes: 591 592- Le BPM#footnote[Beats per minute, aussi appelé tempo], avec éventuellement des évolutions au cours du morceau 593- Des marqueurs temporels, permettant de réagir à des changements de phrases musicales (par exemple, la classique construction _build-up_ / _drop_ / _break_ en EDM#footnote[Electronic Dance Music]), sans avoir à coder en dur un timestamp dans le code de la vidéo. Ces marqueurs sont placés dans le logiciel de production musicale (cf #ref(<flstudiomarkers>), #ref(<flstudiomarkers>, form: "page")) 594- Pour chaque instrument, et à chaque instant: 595 - Les notes jouées: pitch#footnote[hauteur] et vélocité#footnote[intensité avec laquelle la note a été jouée] 596 - Des éventuelles évolutions de paramètres influant sur le timbre de l'instrument (ouverture d'un filtre passe bas pour un synthétiseur, pédale de sustain pour un piano, etc) 597 598 599== Amplitudes des _stems_ 600 601Cette approche consiste à demander à l'artiste de fournir un fichier audio par piste du morceau de musique. La définition de "piste" est ici assez vague. Plus le nombre de fichiers est grand, plus il est possible de réagir à des changements d'amplitudes individuels. En général, une piste à un instrument. 602 603=== Accessibilité 604 605Exporter un projet en fichiers audios piste-par-piste, des _stems_, est une pratique plutôt courante, par exemple lors de concours de remix @remixconteststems, pour fournir aux participant·e·s les éléments du morceau séparés et ainsi faciliter la création d'un remix. 606 607On pourrait faciliter encore plus l'usage en, par exemple, proposant de faire de la séparation de source par réseaux neuronaux si l'artiste ne peut pas ou ne souhaite pas faire un export en stems @sourcesep. Cette approche resterait pertinente même en cas de résultats habituellements considérés comme insatisfaisants dans le domaine de la séparation de sources, étant donné que l'on ne s'en sert qu'à des fins d'analyse pour de la synchronisation -- l'export audio final du morceau fournit à lui seul la bande son de la vidéo. 608 609=== Performance 610 611Néanmoins, ce processus demande de remplir une structure de donnée avec des amplitudes _à chaque instant_, ce qui est assez coûteux, que ce soit en temps de calcul ou en mémoire. 612 613=== Faisabilité 614 615La correspondance signal $mapsto$ note jouée est beaucoup moins évidente qu'elle ne le paraît. Un signal peut être décomposé en amplitude et fréquence, mais une note possède deux caractéristiques bien plus utiles aux musicien·ne·s: 616 617/ Vélocité $cancel(arrow.l.bar)$ amplitude: Les amplitudes d'un signal sont très variables, et il est difficile de déterminer un seuil de détection efficace pour considéré qu'une note a été jouée, surtout en la présence d'effets (en particulier de l'echo ou de la réverbération). 618/ Pitch $arrow.l.bar$ fréquence: Pour obtenir le pitch d'une note, il faut effectuer une analyse fréquentielle du signal. Ceci pourrait à priori ne pas être trop complexe, mais n'a pas été tenté étant donné les difficultés soulevées par le point précédent. Il est en plus très difficile de séparer plusieurs notes d'un accord. 619 620 621== Export MIDI 622 623Cette méthode consiste d'une certaine manière à prendre le problème "à l'envers" par rapport à la méthode précédente: on part d'information _sur les notes jouées_, desquelles on peut dériver les amplitudes, depuis la vélocité. 624 625=== Faisabilité 626 627Le format MIDI @midispec permet de spécifier: 628 629- Pour chaque piste: les notes jouées (pitch et vélocité) 630- Pour le morceau dans sa globalité, le BPM 631 632Bien que l'on puisse assez facilement inférer une sorte d'amplitude simulée à partir des vélocités, le problème opposé se pose: si l'on veut animer un objet en prenant en compte les échos, par exemple, MIDI ne peut pas nous aider. 633 634Mais pour de nombreux usages, le résultat final paraît beaucoup plus "en réaction avec la musique" qu'avec une approche par amplitudes réelles, certainement grâce à la précision apportée par le fait d'utiliser les évènements de notes jouées "à la source". 635 636==== Ticks MIDI 637 638Pour l'implémentation, rien de bien compliqué, on rajoute les notes une à une dans notre structure de données en partant des évènements MIDI: 639 640#codesnippet( 641 lang: "rust", 642 dedent( 643 cut-around( 644 it => it.trim().starts-with("match message"), 645 it => it == " }", 646 read("../src/synchronization/midi.rs"), 647 ), 648 ), 649) 650 651 652Sauf que les coordonnées temporelles MIDI sont en _deltas de ticks MIDI_. Les ticks sont indépendant du BPM, et les deltas sont de simples différences du nombre de ticks passés entre deux évènements. 653 654La durée d'un tick est aussi dépendante du _PPQ_, ou _Pulse per quarter_, qui correspond à la résolution temporelle d'un fichier MIDI -- c'est l'équivalent des FPS en vidéos ou de la fréquence déchantillonnage en audio @midippq. 655 656#codesnippet( 657 include-function( 658 "../src/synchronization/midi.rs", 659 "midi_tick_to_ms", 660 lang: "rust", 661 ), 662) 663 664Pour passer de ticks à des millisecondes réelles, il faut réifier ces ticks en lisant le BPM, *qui peut changer au cours du morceau*. Les changements de BPM sont des évènements MIDI parmi le stream plat du fichier. 665 666#codesnippet[ 667 ```rust 668 // Convert deltas to absolute ticks 669 let mut track_no = 0; 670 for track in midifile.tracks.iter() { 671 track_no += 1; 672 let mut absolute_tick = 0; 673 for event in track { 674 absolute_tick += event.delta.as_int(); 675 timeline 676 .entry(absolute_tick) 677 .or_default() 678 .insert(track_names[&track_no].clone(), *event); 679 } 680 } 681 682 // Convert ticks to ms 683 let mut absolute_tick_to_ms = HashMap::<u32, usize>::new(); 684 let mut last_tick = 0; 685 for (tick, tracks) in timeline.iter().sorted_by_key(|(tick, _)| *tick) { 686 for event in tracks.values() { 687 if let TrackEventKind::Meta(MetaMessage::Tempo(tempo)) = event.kind { 688 now.tempo = tempo.as_int() as usize; 689 } 690 } 691 let delta = tick - last_tick; 692 last_tick = *tick; 693 now.ms += midi_tick_to_ms(delta, now.tempo, now.ticks_per_beat as usize); 694 absolute_tick_to_ms.insert(*tick, now.ms); 695 } 696 ``` 697] 698 699 700 701=== Performance 702 703L'inférence d'amplitudes à partir des vélocités est assez coûteuse. La raison de ce coût n'a pas encore été étudiée. 704 705=== Accessibilité 706 707Malheureusement, là où l'export d'un projet musical en stems se résume à un simple clic dans un menu, l'export en MIDI est souvent plus complexe. Par exemple, sur FL Studio, il demande à créer _une copie du projet, avec toutes les pistes converties en "instruments MIDI"_, ce qui est fastidieux: 708 709#imagefigure( 710 size: 80%, 711 "./flstudiomidimacro.png", 712 [ 713 Dialogue d'avertissement lors de l'utilisation de la macro "Prepare for MIDI export" dans FL Studio 714 ], 715) 716 717=== Conclusion 718 719Cette méthode, malgré l'aspect fastidieux de sa mise en place, est une amélioration nette par rapport à l'approche par amplitude 720 721#codesnippet[ 722 #monospace[ 723 Commit #link("https://github.com/gwennlbh/shapemaker/commit/7ae7a14a90f16f664edee3f433ade9b8c5019ffa")[7ae7a14a90f16f664edee3f433ade9b8c5019ffa] 724 ] 725 726 ``` 727 ⚗️ Figure out a POC to get notes from MIDI file into note[ms][stem_name] 728 729 And the conversion from MIDI ticks to milliseconds does not drift at 730 all, after 6 mins on a real-world track (see research_midi/source.mid), 731 it's still fucking _spot on_, to the FUCKING CENTISECOND (FL Studio 732 can't show me more precision anyways). 733 734 So beautiful. 735 736 aight, imma go to sleep now 737 ```] 738 739== Fichier de projet 740 741Étant donné l'aspect fastidieux de la solution précédente, il est intéressant de se pencher sur les fichiers de projet des logiciels de production musicale, afin de _remonter totalement à la source du morceau de musique_: le fichier qui est ouvert par l'artiste, celui sur lequel iel travaille. 742 743Malheureusement, les logiciel libres sont très loin derrière les standards de l'industrie en terme de production musicale, et il est aujourd'hui assez irréaliste de penser pouvoir produire de la musique avec des alternatives libres qui possède des formats de fichier de projet ouverts. 744 745On doit donc se tourner vers de la rétro-ingénierie, et avoir une implémentation d'un "adaptateur" pour chaque logiciel de production musicale que l'on souhaite supporter. 746 747=== FL Studio 748 749Il existe une bibliothèque Python, pyflp @pyflp, qui permet de parser les fichiers de projets FL Studio, et d'en extraire la quasi totalité des informations intéréssantes. 750 751#codesnippet( 752 size: 0.9em, 753 include-function( 754 "../research/adapters/flstudio/adapter.py", 755 "main", 756 lang: "python", 757 transform: it => "import pyflp\n\n" + it.replace("\n\n# end", ""), 758 ), 759) 760 761Cependant, l'auteur·ice de la bibliothèque n'a malheureusement plus le temps de la maintenir @pyflp3.12, et, étant donné l'évolution de FL Studio, le parser est voué à progressivement ne plus supporter les dernières versions du logiciel. 762 763Étant donné que je suis utilisatrice de FL Studio, je n'a pas cherché de potentielles solutions pour d'autres logiciels de MAO. 764 765==== Performance 766 767Étant donné que l'adapter est en Python, l'intégrer proprement dans Shapemaker consisterai à éventuellement utiliser une solution de FFI#footnote[Foreign Function Interface, permettant d'appeler des fonctions écrites dans un autre langage de programmation] comme PyOxide @pyo3, ce qui demanderait beaucoup de travail d'adaptation. 768 769== Dépôt de "sondes" dans le logiciel de MAO <crate::vst> 770 771#grid( 772 columns: (3fr, 1fr), 773 gutter: 1em, 774 [ 775 776 Cette dernière solution, dont l'implémentation est encore en cours, consiste à donner la possibilité aux artistes d'exposer directement des signaux depuis leur logiciel, en les exfiltrant à Shapemaker à travers un VST#footnote[Virtual Studio Technology, un standard de plugins audio] @vst dédié. 777 778 L'avantage de cette approche est qu'elle est agnostique au logiciel de MAO: en effet, VST est _le_ standard de plugins audio, supporté par tout les logiciels. 779 780 C'est via cette technologie que les artistes peuvent jouer des instruments virtuels, allant des pianos physiquement simulés @pianoteq, en passant par vocaloïdes#footnote[simulateurs de parole chantée, cas à application musicale de la synthèse vocale] (comme par exemple Hatsune Miku @mikudayooo), aux synthétiseurs additifs, soustractifs ou à wavetables (dont un exemple très populaire est Serum @serum). 781 782 C'est aussi cette technologie qui est utilisée pour appliquer des effets aux signaux audio créés par les instruments (on parle de VST _effets_, contrairement aux VST _générateurs_), allant des modélisations de pédales d'effets de guitare ou de compresseurs analogiques à tube, aux simulation de compression digitale de signaux ("bitcrushing"), aux égaliseurs fréquentiels. 783 784 ], 785 imagefigure( 786 "./flstudioprobe.png", 787 [Un VST Shapemaker servant de sonde, dans une chaîne d'effets sur FL Studio], 788 ), 789) 790 791 792#breakout[ 793 Il est donc possible de recevoir du signal, *autant audio que MIDI*, en entrée d'un VST. 794] 795 796Autre possibilité, qui s'avère utile parmi nos objectifs: les VSTs peuvent exposer à l'hôte (le logiciel de MAO) des paramètres changeables, ce qui permet de faire évoluer le timbre d'un instrument, l'intensité d'une réverbération, etc. Faire varier des paramètres au cours du temps est un aspect essentiel de la musique, en particulier électronique, qui contribue à "donner vie" à un morceau. 797 798On peut donc également définir des paramètres sur notre VST-sonde, qui peuvent servir à, par exemple, automatiser des changements de couleurs en suivant une évolution dans le timbre d'un instrument, depuis la source directement (il suffit d'envoyer le signal d'automatisation au VST-sonde, en plus de l'instrument lui-même). 799 800On exfiltre ensuite ces données hors du logiciel vers un "beacon", via un simple API WebSocket, qui permet une communication instantanée beaucoup plus performante que des requêtes HTTP, et est plus approprié à l'envoie de potentiellement plusieurs milliers de points de données par secondes: en effet, le VST-sonde simmisçant dans la chaîne de traitement audio, il ne doit pas la ralentir considérablement, sous peine de rendre le logiciel de MAO inutilisable. 801 802#codesnippet( 803 caption: "Implémentation de la fonction permettant à une probe de se signaler auprès du beacon", 804 [ 805 #include-function( 806 "../src/vst/beacon.rs", 807 "connect_to_beacon", 808 lang: "rust", 809 ) 810 811 #include-function( 812 "../src/vst/beacon.rs", 813 "register_probe", 814 lang: "rust", 815 ) 816 ], 817) 818 819Enfin, on utilise la crate _nih-plug_ @nihplug pour exporter la partie VST de notre code en un fichier `.vst3`, chargeable dans un logiciel de MAO. 820 821#diagram( 822 caption: [Exfiltration de données depuis la chaîne de traitement du logiciel de MAO], 823 size: 75%, 824 [ 825 ```dot 826 digraph G { 827 rankdir="LR"; 828 // splines=ortho; 829 compound=true; 830 node[shape="record"]; 831 832 subgraph cluster_host { 833 label = "Logiciel de MAO" 834 835 subgraph cluster_bass { 836 label = "Bass" 837 midi -> synth [style=dashed] 838 synth -> probe_1 839 midi -> probe_1 [style=dashed] 840 autom_in_bass [shape=point, style=invis, label=""] 841 autom_in_bass -> probe_1 [style=dotted] 842 autom_in_bass -> synth [style=dotted] 843 844 probe_1[label="probe #1"] 845 } 846 subgraph cluster_drums { 847 label = "Drums" 848 midi_2 [label="midi"] 849 midi_2 -> drums [style=dashed] 850 drums -> probe_2 851 midi_2 -> probe_2 [style=dashed] 852 autom_in_drums [shape=plaintext, label=""] 853 854 probe_2[label="probe #2"] 855 } 856 857 subgraph cluster_voice { 858 label = "Voice" 859 sampler -> effects -> probe_3 860 autom_in_voice [shape=point, style=invis, label=""] 861 autom_in_voice -> probe_3 [style=dotted] 862 autom_in_voice -> effects [style=dotted] 863 864 probe_3[label="probe #3"] 865 } 866 867 automation -> autom_in_bass [arrowhead=none, style=dotted] 868 automation -> autom_in_voice [arrowhead=none, style=dotted] 869 automation -> autom_in_drums [style=invis] 870 } 871 872 subgraph cluster_shapemaker { 873 label = "Shapemaker" 874 wip[label="(en développement)", shape="plaintext", color=darkblue] 875 beacon -> wip 876 } 877 878 probe_1 -> beacon [label="ws://", color=darkblue] 879 probe_2 -> beacon [label="ws://", color=darkblue] 880 probe_3 -> beacon [label="ws://", color=darkblue] 881 } 882 ``` 883 884 #place( 885 dy: -7em, 886 dx: 35em, 887 ```dot 888 digraph { 889 rankdir=LR; 890 // splines=ortho; 891 label = "Légende" 892 node[style=invis,shape=point,label=""] 893 a1 -> b1 [style=dotted, label="Automation"] 894 a2 -> b2 [style=dashed, label="Notes"] 895 } 896 ```, 897 ) 898 899 #place( 900 dy: -7em, 901 dx: 47em, 902 ```dot 903 digraph { 904 rankdir=LR; 905 // splines=ortho; 906 label = "Légende" 907 node[style=invis,shape=point,label=""] 908 a3 -> b3 [style=solid, label="Audio"] 909 a4 -> b4 [color=darkblue, label="Syncdata"] 910 } 911 ```, 912 ) 913 ], 914) 915 916 917== Temps réel: WASM et WebMIDI <crate::wasm> 918 919Il est possible de réagir en temps réel à des pressions de touches sur des appareils conçus pour la production musicale assistée par ordinateur (MAO): des claviers, des potentiomètres pour ajuster des réglages affectant le timbre d'un son, des pads pour déclencher des sons et, par exemple, jouer des percussions, etc. 920 921Ces appareils sont appelés "contrôleurs MIDI", du protocole standard qui régit leur communication avec l'ordinateur. 922 923S'il est évidemment possible d'interagir avec ces contrôleurs depuis un programme natif (c'est après tout ce que font les logiciels de production musicale), j'ai préféré tenté l'approche Web, pour en faciliter l'accessibilité et en réduire le temps nécessaire à la mise en place #footnote[ 924 Imaginez, votre ordinateur a un problème 5 minutes avant le début d'une installation live, et vous aviez prévu d'utiliser Shapemaker pour des visuels. En faisant du dispositif un site web, il suffit de brancher son contrôleur à l'ordinateur d'un·e ami·e, et c'est tout bon. 925]. 926 927Comme pour de nombreuses autres technologies existant à la frontière entre le matériel et le logiciel, les navigateurs mettent à disposition des sites web une technologie permettant de communiquer avec les périphériques MIDI connectés à la machine: c'est l'API WebMIDI @webmidi. 928 929Mais bien évidemment, tout le code de Shapemaker, toutes ses capacités de génération de formes, sont implémentées en Rust. 930 931Il existe cependant un moyen de "faire tourner du code Rust" dans un navigateur Web: la compilation vers WebAssembly (WASM), un langage assembleur pour le web @wasm, qui est une cible de compilation pour quelques des langages compilés plus modernes, comme Go @gowasm or Rust @rustwasm. 932 933En exportant la _crate_ shapemaker en bibliothèque Javascript via wasm-bindgen @wasmbindgen, il est donc possible dexposer à une balise #raw("<script>", lang: "html") les fonctions de la bibliothèque, pour les brancher à un _callback_ donné par l'API WebMIDI: 934 935#figure( 936 caption: "Exposition de fonctions à WASM depuis Rust, et utilisation de celles-ci dans un script Javascript", 937 grid( 938 columns: (1fr, 1fr), 939 gutter: 2em, 940 text( 941 size: 0.75em, 942 [ 943 ```rust 944 #[wasm_bindgen] 945 pub fn render_image(opacity: f32, color: Color) -> Result<(), JsValue> { 946 let mut canvas = /* ... */ 947 948 *WEB_CANVAS.lock().unwrap() = canvas; 949 render_canvas_at(String::from("body")); 950 951 Ok(()) 952 } 953 ``` 954 ], 955 ), 956 text( 957 size: 0.75em, 958 [ 959 ```js 960 import init, { render_image } from "./shapemaker.js" 961 962 void init() 963 964 navigator.requestMIDIAccess().then((midi) => { 965 Array.from(midi.inputs).forEach((input) => { 966 input[1].onmidimessage = (msg) => { 967 const [cmd, ...args] = [...msg.data] 968 if (cmd !== 144) return 969 970 const [pitch, velocity] = args 971 const octave = Math.floor(pitch / 12) - 1 972 973 render_image(velocity / 128, colors[octave]) 974 } 975 }) 976 }) 977 ``` 978 ], 979 ), 980 ), 981) 982 983Au final, on peut arriver à une performance live interactive @pianowasmdemo intéressante, et assez réactive pour ne pas avoir de latence (et donc de désynchronisation audio/vidéo) perceptible. 984 985Les navigateurs Web supportant nativement le format SVG, qui se décrit notamment comme directement incluable dans le code HTML d'une page web @svginhtml, il est possible de simplement générer le code SVG, et de laisser le navigateur faire le rendu, ce qui s'avère être une solution très performante. 986 987= Performance 988 989Les premiers prototypes de Shapemaker avait une implémentation sérielle, ou le code Rust s'occupait seulement de la partie génération de formes et sérialisation en SVG. Chaque frame SVG était sauvegardée dans un fichier, puis converti en PNG en ligne de commande via ImageMagick @imagemagick. Les frames étaient ensuite concaténées en une vidéo via FFmpeg, également en ligne de commande. 990 991#diagram( 992 caption: [Pipeline de rendu, premier prototype], 993 size: 85%, 994 ```dot 995 digraph { 996 rankdir="LR"; 997 compound=true; 998 node [shape="record"]; 999 subgraph cluster_each_frame { 1000 label = "Chaque frame" 1001 subgraph cluster_rust { 1002 label = "Rust" 1003 canvas -> "Frame 0037.svg" 1004 } 1005 "Frame 0037.svg" -> "Frame 0037.png" [label="$ magick"] 1006 } 1007 "Frame 0037.png" -> "video.mp4" [label="$ ffmpeg", ltail=cluster_each_frame] 1008 } 1009 ```, 1010) 1011 1012Un des plus gros gains de performance a été achevé en éliminant le plus d'I/O#footnote[Input/Output] possible ainsi qu'un encodage/décodage PNG, en passant des pixmap (matrices de pixels) directement. 1013 1014 1015#diagram( 1016 caption: [Pipeline de rendu sans #emph[shell-out]#footnote[Invoquer un programme en ligne de commande (dans un shell), au lieu de faire tourner du code dans le programme courant]s], 1017 size: 85%, 1018 ```dot 1019 digraph { 1020 rankdir="LR"; 1021 compound=true; 1022 node [shape="record"]; 1023 subgraph cluster_rust { 1024 label = "Rust" 1025 subgraph cluster_each_frame { 1026 label = "Chaque frame" 1027 canvas -> "SVG string" 1028 "SVG string" -> "Pixmap" [label="resvg"] 1029 } 1030 Pixmap -> "video.mp4" [label="libx264", ltail=cluster_each_frame] 1031 } 1032 } 1033 ```, 1034) 1035 1036L'inconvénient est que, pour la partie encodage vidéo, il n'existe pas encore vraiment d'encodeur H.264#footnote[Codec vidéo, très souvent utilisé pour les fichiers MP4, par exemple] en pur Rust, la plupart des solutions étant des bindings#footnote[bibliothèque utilisant des FFIs pour donner un accès idiomatique à une bibliothèque provenant d'un autre langage de programmation] vers des bibliothèques C, notamment ffmpeg @ffmpeg. 1037 1038Cela rend l'installation de la bibliothèque beaucoup plus complexe, notamment sur Windows (les logiciels de production musicale sont très rares à fonctionner correctement sur Linux, surtout quand on prend en compte que les VSTs doivent eux aussi fonctionner sur Linux): 1039 1040#codesnippet( 1041 caption: "Erreur rencontrée pendant la compilation des bindings Rust à libx264", 1042 ``` 1043 Compiling ffmpeg-sys-next v7.1.0 1044 error: failed to run custom build command for `ffmpeg-sys-next v7.1.0` 1045 note: To improve backtraces for build dependencies, set the CARGO_PROFILE_DEV_BUILD_OVERRIDE_DEBUG=true environment variable to enable debug information generation. 1046 1047 Caused by: 1048 process didn't exit successfully: `C:\Users\\projects.local\shapemaker\target\debug\build\ffmpeg-sys-next-d2108b58b450b79e\build-script-build` (exit code: 101) 1049 --- stdout 1050 Could not find ffmpeg with vcpkg: Could not look up details of packages in vcpkg tree could not read status file updates dir: The system cannot find the path specified. (os error 3) 1051 ```, 1052) 1053 1054Malgré plusieurs guides contradictoires d'installation, utiliser _vcpkg_ @vcpkg pour installer ffmpeg a fini par fonctionner 1055 1056Une fois cette optimisation faite, qui a *divisé par 10* le temps de rendu, on peut se pencher sur le détail de la boucle de rendu pour identifier les potentiels gains de performance 1057 1058 1059#grid( 1060 columns: (1.3fr, 1.1fr), 1061 gutter: 1em, 1062 diagram( 1063 size: 73%, 1064 caption: [Détail de la boucle de rendu], 1065 [ 1066 ```dot 1067 digraph G { 1068 compound=true; 1069 // Either of these makes edge labels disappear... 1070 // splines="ortho"; 1071 // node[shape="record"]; 1072 1073 hooks -> canvas; 1074 subgraph cluster_tosvg { 1075 label = "SVG string rendering [0.2ms]" 1076 subgraph g_svg { 1077 rank=same; 1078 canvas -> render_to_svg [label="0.1ms"] 1079 render_to_svg -> stringify_svg [label="0.1ms"] 1080 } 1081 } 1082 stringify_svg -> "svg" [label="0.1ms"] 1083 subgraph cluster_rasterize { 1084 label = "Encode frame [167ms]" 1085 subgraph g_rasterize { 1086 rank=same; 1087 svg [label="svg\n(str)"] 1088 usvg [label="usvg\n(tree)"] 1089 "svg" -> "usvg" [label="48ms"] 1090 } 1091 subgraph g_rasterize2 { 1092 rank=same; 1093 "usvg" -> pixmap [label="11ms"] 1094 pixmap -> "hwc" [label="108ms"] 1095 } 1096 } 1097 1098 canvas -> "svg" [weight=10, style=invis] 1099 } 1100 ``` 1101 ], 1102 ), 1103 figure( 1104 caption: "Durées d'exécution par tâche, pour une vidéo de test de 5 secondes (millisecondes)", 1105 table( 1106 columns: 3, 1107 inset: 0.5em, 1108 [*Tâche*], [*$Delta t$*], [*\#*], 1109 ..csv("./timings.csv").slice(1).flatten() 1110 ), 1111 ), 1112) 1113 1114== Rastérisation parallèle <perf-parallelrasterize> 1115 1116Si la partie `render_to_svg` n'est pas parallélisable car il faut bien faire exécuter tous les hooks dans l'ordre, la rastérisation des SVG sortants, elle, est bien parallélisable. Malheureusement, le gain de performance n'a pas été significatif, au contraire: rastériser toutes les frames, avant de commencer à encoder, implique de remplir la RAM avec des pixmaps -- une par frame -- ce qui conduit à une utilisation complète de toute la RAM de la machine, et bloque ainsi le système. Une approche avec une _queue_ de taille maximale limitée, de laquelle l'encodeur pourrait récupérer les pixmaps rastérisées, reste à explorer. 1117 1118== Encodage H.264 parallèle? 1119 1120Si l'on est bien capable de donner à l'encodeur nos frames dans le désordre, tout en lui indiquant le timestamp de chaque frame, l'encodeur doit recevoir les frames dans l'ordre @libx264order. Il est donc impossible de paralléliser l'encodage. 1121 1122== Pixmap et frames HWC: 100ms de standards 1123 1124L'encodage vidéo étant fait par une bibliothèque totalement séparée de celle s'occupant de la rastérisation SVG, il y a un risque d'incompatibilité entre les formats de pixmap utilisés par les deux bibliothèques, ce qui est le cas ici. 1125 1126En effet, les SVG rasterisés sont stockées dans un array plat de valeurs RGBA @pixmapvecu8: 1127 1128#align(center)[ 1129 ``` 1130 [R, G, B, A, R, G, B, A, …] 1131 ``` 1132] 1133 1134Tandis que la bibliothèque utilisée, _video-rs_, attend une matrice HWC, ou height-width-channels, de pixels RGB @videorshwc @videorshcwframe @array3rust: 1135 1136#align(center)[ 1137 ``` 1138 [ 1139 [ [R, G, B], [R, G, B], … ], 1140 [ [R, G, B], [R, G, B], … ], 11411142 ] 1143 ``` 1144] 1145 1146Il est donc nécessaire de convertir entre ces deux formats, ce qui est lent car demande de copier les données. 1147 1148La solution initiale utilisait `video_rs::Frame::from_shape_fn`: 1149 1150#codesnippet[ 1151 ```rust 1152 Ok(video_rs::Frame::from_shape_fn( 1153 (pixmap.height() as usize, pixmap.width() as usize, 3), 1154 |(y, x, c)| { 1155 let pixel = pixmap 1156 .pixel(x as u32, y as u32) 1157 .expect(&format!("No pixel found at x, y = {x}, {y}")); 1158 match c { 1159 0 => pixel.red(), 1160 1 => pixel.green(), 1161 2 => pixel.blue(), 1162 _ => unreachable!(), 1163 } 1164 }, 1165 )) 1166 ``` 1167] 1168 1169Cependant, cette solution est très lente car _non parallélisée_, je l'ai donc réimplémentée avec de la parallélisation sur chaque pixel: 1170 1171#codesnippet( 1172 include-function( 1173 "../src/video/encoding.rs", 1174 "pixmap_to_hwc_frame", 1175 lang: "rust", 1176 is_method: true, 1177 ), 1178) 1179 1180On effectue toujours de la copie, mais la conversion est nettement plus rapide ainsi. 1181 1182Bien évidemment, il ne faut pas faire d'erreur dans les calculs des coordonnées des pixels, ce qui peut donner des résultats surprenants, même si éventuellement artistiquement intéréssants: 1183 1184#grid( 1185 columns: (1fr, 1fr), 1186 gutter: 1em, 1187 imagefigure("./hwccorrect.png", [Frame cible correcte]), 1188 imagefigure( 1189 "./hwcwrong.png", 1190 [Interversion de `%` et `/`], 1191 ), 1192) 1193 1194=== Aller plus loin 1195 1196L'opération reste de loin la plus coûteuse de la chaîne de rendu. 1197 1198Une solution serait de passer à une bibliothèque plus bas niveau et voir s'il est possible de donner directement les données de pixmap à l'encodeur, sans conversion, ou tout du moins sans avoir à copier les données. 1199 1200Une autre solution est de proposer une contribution à la bibliothèque de rendu utilisée par _resvg_, _tiny_skia_#footnote[Tiny-skia est notamment utilisé par Typst @typsttinyskia, l'alternative moderne à LaTeX sur laquelle ce papier a été typeset], pour y ajouter la possibilité d'instrumentaliser les lectures et écritures à sa pixmap, et ainsi stocker la représentation voulue par libx264 directement. 1201 1202== SVG vers string vers SVG <perf-svgstring> 1203 1204Comme on peut le remarquer, un gain de performance assez conséquent est possible si l'on parvient à utiliser usvg, non seulement pour la rastérisation, mais également pour la construction de l'arbre SVG: sur une boule de rendu de 167 ms, *on passe 29% du temps à parser un arbre SVG sérialisé, alors que l'on vient de construire cette arbre.* 1205 1206= Conclusion 1207 1208Malgré les multiples solutions de synchronisation audio-vidéo testées, avec certaines savérant infructueuses, l'approche par VST-sondes semble prometteuse, et permettrait de remplir presque tout les objectifs fixés au début du #ref(<crate::synchronization>). 1209 1210L'approche WASM/WebMIDI explorée au #ref(<crate::wasm>) est une solution appropriée pour des installations live, qui mérite d'être d'avantage explorée, possiblement en vue de la création d'une solution de scripting pour VJing#footnote[Visual Jockeying, l'art de mixer des visuels en live, souvent en concert ou en boîte de nuit] 1211 1212== Pistes d'améliorations 1213 1214=== Feedback loop 1215 1216Enfin, un des points les plus importants à améliorer reste la "feedback loop" _pendant la conception d'une procédure de génération_, qui reste extrêmement longue à cause de la lenteur de compilation de Rust, et du fait que, contrairement à un logiciel de montage vidéo, par exemple, on ne peut que re-rendre la vidéo en MP4 (même si l'on peut décider de rendre qu'une petite partie), ouvrir le fichier, et regarder le résultat. 1217 1218Une idée serait de, là aussi, utiliser le backend WASM/WebMIDI pour fournir une sorte de preview du code en temps réel: une interface simple permettrait de placer une tête de lecture à un instant pour y montrer la frame, et se rafraîchirait quand le code change. Avec éventuellement la possibilité de faire "play". 1219 1220Encore faut-il que la vitesse de recompilation de Rust le permette, même si ce serait à proiri possible tant que la crate utilisant Shapemaker (celle que l'artiste écrit) reste légère. 1221 1222=== Un langage de scripting 1223 1224Rust étant un des langages de programmation les plus difficiles à utiliser, on pourrait éventuellement exposer l'API de Shapemaker à un langage de scripting plus léger, comme Lua par exemple, ce qui permettrait également de rendre le projet plus accessible. 1225 1226Cela permettrait éventuellement aussi d'améliorer la vitesse de compilation de la crate écrite par l'artiste, qui pourrait, si elle est trop faible, empêcher l'implémentation de la solution de feedback loop telle qu'évoquée plus tôt. Des projets comme Tauri embarque un système de HMR#footnote[Hot Module Replacement, permettant de recharger du code en temps réel sans recharger la page, technologie assez prévalente dans le développement web frontend], non pas pour leur bibliothèque Rust, mais pour les bindings JavaScript exposé aux utilisateur·ice·s de la bibliothèque @taurihmr. 1227 1228On pourrait même envisager afficher cette _preview_ dans le logiciel de MAO, en tant qu'un 2e VST, "Shapemaker Preview". Ceci demande d'implémenter encore un backend de rendu, autre que H.264 ou WASM, mais serait certainement la meilleure solution en terme d'UX#footnote[expérience utilisateur·ice]. 1229 1230== Code source 1231 1232Le code source du projet est disponible en ligne sur Github: 1233 1234#align(center)[ 1235 #link("https://github.com/gwennlbh/shapemaker")[gwennlbh/shapemaker] 1236] 1237 1238Le répertoire `paper/` contient la source de ce papier, écrit en Typst 1239 1240== Exemples 1241 1242Le projet n'étant pas encore terminé, il n'y a pas encore de clips musicaux publiés. Cependant, voici des liens vers quelques tests: 1243 1244- #link("https://youtu.be/3lx6VAz_UKM") 1245- #link("https://instagram.com/p/C62JfogoUt9") 1246 1247#heading(numbering: none)[Remerciements] 1248 1249Je souhaiterais remercier l'équide de Relais & Copies, qui a été très accueillante et m'a bien aidé dans ma démarche artistique d'impression et plastifications d'une trentaine de carrés imprimmés, en me laissant utiliser le matériel de leur atelier. Ce n'est pas une demande qu'iels doivent recontrer tout le jours, et mon expérience dans le domaine se limitait à un stage chez un imprimmeur au lycée. On m'a expliqué comment me servir des outils nécéssaire, on m'a laissé les utiliser, on m'a aussi aidé dans l'étape de plastification individuelle de chacune des oeuvres -- processus très répétitif -- et on m'a même remboursé quelques plastifications ratées à cause de défaut sur leur machine, alors que les tarifs pratiqués étaient déjà généreusement rabaissés pour les étudiant$dot.op$e$dot.op$s. 1250 1251Je souhaiterais également remercier Yevhenii Reizner, qui a écrit quasiment seul$dot.op$e _resvg_, _tiny-skia_, _usvg_, et beaucoup d'autres bibliothèques sur lesquelles je me suis reposée pour mener ce projet. Malheureusement, il y a 2 ans, iel a évoqué ne plus pouvoir maintenir ces crates, dont iel a confié les rênes de la maintenance au collectif Linebender @resvglinebendertransfer. Sur le profil Github de Yevhenii, #link("https://github.com/RazrFalcon"), on peut lire ceci: 1252 1253#block( 1254 fill: luma(85%), 1255 inset: 1em, 1256 width: 100%, 1257 radius: 4pt, 1258 text( 1259 size: 1em, 1260 align(left)[ 1261 This is a personal profile from a developer of Ukraine. \ 1262 Russia invaded Ukraine, killing tens of thousands of civilians and displacing millions more. \ 1263 It's a genocide. Please help defend freedom, democracy and Ukraine's right to exist. 1264 ], 1265 ), 1266) 1267 1268On ne peut que supposer les raisons de cet arrêt soudain dans le développement d'une bibliothèque de rendu 2D impressionante, dont dépendent des projets d'envergure conséquente comme Typst. 1269 1270#bibliography("bibliography.yaml") 1271 1272 1273 1274#show: arkheion-appendices 1275 1276#heading(numbering: none)[Annexes] 1277 1278= Marqueurs dans un logiciel de MAO 1279 1280#imagefigure( 1281 "./flstudiomarkers.png", 1282 [ 1283 Marqueurs dans FL Studio: 1284 #smallcaps([intro end, block 1, break 1, buildup 1, ]) 1285 #linebreak() 1286 Fichier de projet pour _Onset_ de Postamble @onset 1287 ], 1288) <flstudiomarkers> 1289 1290= Série "interprétation collective" 1 <annexe-serie-interp-collective> 1291 1292#grid( 1293 columns: 6, 1294 ..range(1, 31).map(it => image("./street/frames/" + str(it) + ".svg")) 1295)