This repository has no description
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 où 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_, où 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
652…Sauf 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 s’immisç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 d’exposer à 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], … ],
1141 …
1142 ]
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 s’avé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)