Coder = composer

Lulalib est un DSL déclaratif de composition musicale pour JavaScript et TypeScript. Pas un séquenceur, pas un plugin, pas un langage de live coding qui tourne en boucle : une façon d'écrire une intention musicale — un rythme, une mélodie, un enchaînement de sections — et de la voir se compiler en quelque chose de fixe, d'exact, de relisible.

Une partition, pas un séquenceur

La plupart des outils de composition assistée par code pilotent une horloge : ils jouent, en direct, pendant que le programme tourne. Lulalib fait l'inverse. Il ne joue rien. Il lit votre code une fois et en tire un Score, un objet figé qui décrit exactement ce qui se joue, quand, et avec quel instrument. Ce Score ne dépend d'aucune horloge, d'aucun moteur audio, d'aucun état qui change en cours d'exécution. Il existe, tout simplement, comme une partition existe indépendamment du musicien qui la joue.

Cette différence change la façon d'écrire. Pas d'effets de bord à traquer, pas de timing à synchroniser à la main : une section décrit ce qui joue ensemble, un song décrit ce qui joue à la suite, et le reste — la résolution du temps, des degrés de gamme, des durées — est le travail de la lib.

Sans dépendance, sans surprise

Le cœur de Lulalib ne connaît ni le DOM, ni Node, ni aucun moteur audio. Il tourne partout où tourne du JavaScript. Les durées ne sont jamais des flottants qui dérivent après vingt additions : ce sont des fractions exactes, réduites à chaque opération. Ce que vous écrivez est, au bit près, ce qui finit dans le Score.

Tout y est également immuable. Une section, un song, une track : chaque méthode retourne une nouvelle valeur plutôt que de modifier l'existante. Composer revient à assembler des valeurs, jamais à gérer un état qui change sous vos pieds.

La suite se trouve dans Installation, pour ajouter Lulalib à un projet, ou dans Modèle mental, pour comprendre comment Lulalib organise le temps avant d'écrire la moindre ligne.

Installation

Lulalib n'a aucune dépendance runtime. Il s'installe comme n'importe quel paquet npm.

npm install Lulalib
pnpm add Lulalib

Import

Toute l'API publique passe par un seul point d'entrée. Les fonctions de composition (song, section), les sucres syntaxiques (bass, lead, inst, drums) et les exporteurs (toJSON, toMIDI) s'importent tous depuis Lulalib.

import { song, section, bass, lead, drums, toJSON, toMIDI } from "Lulalib";

Rien n'est caché derrière cette surface : les outils bas niveau (track, at, les combinateurs stack/cat/fast/slow, les fonctions de fraction, les parseurs parseRhythm/parseMelody) s'importent depuis le même module, pour qui veut construire ses propres abstractions au-dessus du cœur de la lib.

Distribution

Le paquet est publié en ESM et en CommonJS, avec ses déclarations de types TypeScript. import comme require fonctionnent sans configuration particulière. Le paquet est marqué "sideEffects": false, donc un bundler peut ne garder que ce qui est réellement utilisé (tree-shaking).

Environnement

Le cœur de la lib ne touche ni au DOM ni à Node, et n'a aucune dépendance de production : il tourne dans n'importe quel environnement JS/TS, du navigateur à un worker, en passant par le serveur. Rien à installer à côté — pas de moteur audio, pas de runtime spécifique.

Le paquet en place, direction Premier démarrage pour écrire et exporter un premier Score.

Premier démarrage

En partant du principe que Lulalib est déjà installé et importé (voir Installation), voici comment écrire et exporter un premier Score, en deux étapes.

1. Écrire une section

Une section empile des tracks qui jouent simultanément. Chaque track se construit avec un sucre syntaxique : drums() pour la percussion, bass()/lead()/inst() pour le mélodique.

const verse = section([
    drums("909").kick("x---x---").snare("----x---").hihat("xxxxxxxx"),
    bass("acid").notes("1 5 1 4").rhythm("x-x-x-x-"),
    lead("saw").notes("1 3 5 3").octave(5).rhythm("x-x-x-x-"),
]);

Ligne par ligne : la batterie "909" déclenche un kick sur le premier temps fort, une snare sur le deuxième, et un hihat sur chaque pas. La basse "acid" joue les degrés 1, 5, 1, 4 de la gamme, un pas sur deux (x-x-x-x-). Le lead "saw" fait la même chose une octave plus haut (octave(5)), avec les degrés 1, 3, 5, 3. Aucune de ces pistes ne connaît encore sa clé : les degrés ne sont résolus en notes réelles qu'à l'export.

2. Composer un song et l'exporter

Un song porte le tempo et la clé, et enchaîne des sections dans le temps avec .arrange(). .export() résout les degrés contre la clé et retourne le Score.

const score = song({ bpm: 120, key: "Am" })
    .arrange([verse, verse])
    .export();

const json = toJSON(score, { pretty: true });
const midi = toMIDI(score); // Uint8Array (fichier MIDI standard)

verse est réutilisé deux fois : rien n'empêche de rejouer la même section plusieurs fois, elle est immuable. Le Score qui en sort est un objet plat, sérialisable. toJSON en fait une chaîne lisible ; toMIDI un fichier MIDI standard, à ouvrir dans n'importe quelle DAW pour l'entendre — Lulalib lui-même ne joue aucun son.

Et ensuite ?

Modèle mental explique comment Lulalib organise le temps sur ses deux axes. Tracks et Mini-notation détaillent la syntaxe des rythmes et des mélodies. Pièges courants liste les erreurs les plus fréquentes, avec leur message exact.

Modèle mental

Une composition Lulalib s'organise sur deux axes : l'arrangement, horizontal, et la section, verticale. Comprendre ces deux axes suffit à prédire ce que produit n'importe quel bout de code.

ARRANGEMENT (horizontal, -> cat)   intro -> verse -> chorus -> verse
SECTION     (vertical,   -> stack) drums + bass + lead jouent ensemble

Les trois niveaux

  • Track : une partie, un instrument + un pattern. Construite avec un sucre syntaxique — bass(), lead(), inst() (mélodique) ou drums() (percussion) — ou directement avec track(instrument, spec) pour un usage bas niveau.
  • section([...]) : des tracks qui jouent simultanément, empilées. La longueur de la section est celle de sa track la plus longue ; les tracks plus courtes se taisent simplement une fois terminées.
  • song().arrange([...]) : des sections placées les unes après les autres sur la timeline. .export() parcourt cet arrangement, résout les degrés, et retourne le Score.

Sous le capot, empiler correspond au combinateur stack, enchaîner correspond à cat. Ce sont des fonctions exportées à part entière, utilisables directement pour composer des Pattern sans passer par section/song — utile pour des besoins qui sortent du cadre standard.

Immutabilité

Tout est immuable : chaque méthode retourne une nouvelle valeur plutôt que de modifier l'existante. Une section ou un song peut donc être réutilisé sans risque, y compris plusieurs fois dans le même arrangement.

const verse = section([...]);

song({ bpm: 120 }).arrange([verse, verse, verse]); // ok, 3 fois la même section

Deux méthodes illustrent bien ce principe :

  • section.with(extraTracks) ne modifie pas la section d'origine — elle en retourne une nouvelle, avec les tracks en plus. La section de départ reste utilisable telle quelle ailleurs.
  • section.repeat(n) ne renvoie pas une section plus longue : elle renvoie un tableau de n fois la même section. .arrange() accepte aussi bien des sections que des tableaux de sections, et les aplatit automatiquement — donc .repeat() s'insère directement dans un arrangement.
song({ bpm: 128, key: "Cm" }).arrange([intro, verse.repeat(2), chorus]).export();
            
// équivalent à : .arrange([intro, verse, verse, chorus])

Tracks : bass, lead, inst, drums

Une track associe un instrument à un pattern d'events. Les sucres mélodiques et percussifs la construisent progressivement, méthode après méthode, jusqu'à .build() — appelé automatiquement par section et song, rarement à la main.

Sucre mélodique : bass / lead / inst

Les trois sont des alias strictement identiques — des noms sémantiques pour lire le code plus facilement, pas des comportements différents. Signature : bass(instrument: string) -> MelodicBuilder.

MéthodeRôle
.notes(pitches)les hauteurs (notes absolues ou degrés), en mini-notation de mélodie
.rhythm(pattern)le rythme, en mini-notation de rythme
.step(value)durée d'un pas, ex. "1/8" (défaut "1/4")
.octave(n)octave de base pour les degrés (défaut 4)
.build(ctx?)retourne une Track

Le pool de hauteurs et la grille de rythme

.notes() et .rhythm() décrivent deux choses indépendantes : un pool de hauteurs à jouer, et une grille de déclenchements dans le temps. Ils n'ont pas besoin d'avoir la même longueur. À chaque déclenchement de la grille, la note suivante est piochée dans le pool, de façon cyclique : quand le pool est épuisé, il recommence depuis le début.

lead("saw").notes("1 3 5").rhythm("xxxxxxxx");

// 8 déclenchements, 3 notes dans le pool -> 1 3 5 1 3 5 1 3

Si .rhythm() est omis, chaque note du pool déclenche une fois (un déclenchement par note, dans l'ordre). Si .notes() est omis, chaque déclenchement produit un event sans hauteur — utile pour une track purement rythmique.

Sucre percussif : drums

drums(instrument: string) -> DrumsBuilder. Une track de percussion n'a pas de pool de hauteurs : chaque méthode ajoute une part, une sous-track rythmique nommée, jouée en parallèle des autres parts de la même track.

MéthodeRôle
.kick(rhythm) / .snare(rhythm) / .hihat(rhythm)raccourcis pour les parts nommées "kick"/"snare"/"hihat"
.part(name, rhythm)une part avec un nom libre (ex. "clap", "tom")
.step(value)durée d'un pas (défaut "1/4"), partagée par toutes les parts
.build(ctx?)retourne une Track
drums("909").kick("x---x---").snare("----x---").hihat("xxxxxxxx").part("clap", "----x---");

Cas particuliers

  • Une track créée avec track(instrument, spec) directement (l'API bas niveau, pas le sucre) exige au moins notes ou rhythm dans spec ; sans les deux, elle lève une erreur. Les sucres bass/lead/inst n'ont pas cette contrainte : sans rien renseigné, ils produisent simplement une track vide.
  • .step() attend une chaîne ("1/8"), jamais un nombre. Chaque track choisit son propre pas ; rien n'oblige toutes les tracks d'une section à partager le même.
  • .octave() ne sert qu'aux degrés (1-7). Sur des notes absolues (C4...), il n'a aucun effet : l'octave est déjà dans le token.

Sections et songs

section(tracks, name?)

section(tracks: Buildable[], name?: string) -> Section. Empile les tracks : elles jouent simultanément, à partir du même instant zéro. La longueur de la section est celle de sa track la plus longue — les tracks plus courtes ne bouclent pas, elles se taisent simplement une fois épuisées.

MéthodeRôle
.repeat(n)retourne un tableau de la section répétée n fois (à passer tel quel à arrange, qui l'aplatit)
.with(extra)retourne une nouvelle section avec des tracks en plus (immuable, ne modifie pas l'originale)

song(meta)

song({ bpm, key?, timeSignature? }) -> Song. Seul bpm est obligatoire. Sans key, les tracks de l'arrangement doivent utiliser des notes absolues — aucun degré ne pourra être résolu.

MéthodeRôle
.arrange(sections)accepte un tableau de Section ou de Section[] (aplati automatiquement, donc .repeat() s'insère directement) ; appeler .arrange() plusieurs fois remplace l'arrangement précédent, il ne s'accumule pas
.export()résout les degrés contre la key et retourne le Score
song({ bpm: 128, key: "Cm" }).arrange([intro, verse.repeat(2), chorus]).export();

Comment les events se combinent

Empiler (section) et enchaîner (song) reposent sur deux combinateurs bas niveau, exportés et réutilisables directement : stack(...patterns) fusionne des patterns qui démarrent au même instant, et cat(...patterns) les met bout à bout, en décalant chaque pattern suivant de la longueur cumulée des précédents. Dans les deux cas, les events du résultat sont toujours triés par instant de départ croissant : l'ordre des tracks dans le tableau n'a aucune influence sur l'ordre des events dans le Score final.

at(offset, item)

Décale un élément dans le temps sans passer par le rythme : at(offset: Fraction, item) -> Buildable. L'offset est une fraction, construite avec frac (exporté). Pratique pour placer un élément isolé à un instant précis d'une section, sans écrire de silences dans sa mini-notation.

import { at, frac } from "Lulalib";

section([at(frac(3), drums("909").part("crash", "x"))]);

Mini-notation

Deux syntaxes distinctes : celle du rythme, passée à .rhythm(), décrit une grille de déclenchements dans le temps. Celle de la mélodie, passée à .notes(), décrit un pool de hauteurs. Elles sont indépendantes — voir Tracks pour comment elles se combinent.

Rythme (la timeline)

Un caractère = un pas. Le pas par défaut vaut "1/4" (une double-croche). N'importe quel caractère qui n'est pas réservé déclenche une note : x est l'habitude, mais o, 1 ou k fonctionnent identiquement. Le caractère choisi n'a aucun effet sur le son ou la vélocité, seulement sur la lisibilité du pattern.

SymboleSignification
tout caractère non réservé (x, o...)déclenchement
- ~silence
_tenue : prolonge le déclenchement précédent d'un pas. Doit toujours suivre un déclenchement — un _ en tout début de pattern est une erreur
@néquivalent compact à n tenues : doit suivre immédiatement un déclenchement (x@4 == x___)
[ ... ] *ngroupe + répétition, imbricable
espaceignoré, purement visuel
"x-x-x-x-"      // 8 pas, un déclenchement un pas sur deux
"x_ _ _ x---"   // un déclenchement tenu 3 pas, puis un déclenchement, puis 3 silences
"x@4 x@4"       // deux déclenchements tenus 4 pas chacun : x___x___
"[x-]*4"        // le groupe "x-" répété 4 fois : x-x-x-x-

Mélodie (un pool de hauteurs)

Le pool est une liste de hauteurs, piochées cycliquement à chaque déclenchement de la grille de rythme — voir Tracks pour le mécanisme complet.

TokenSignification
C4 Eb4 F#3note absolue : une lettre A-G, un dièse/bémol optionnel, un numéro d'octave obligatoire
C4,E4,G4un accord : des virgules, sans espaces — une seule entrée du pool
1-7degré de gamme, nécessite une key sur le song
# baltération du degré, placée juste après le chiffre (ex. 3b)
' .décale le degré d'une octave : ' monte, . descend
[ ... ] *ngroupe + répétition, imbricable, comme en rythme
"C4,E4,G4"   // un accord de do majeur
"1 3b 5"     // degré 1, degré 3 abaissé d'un demi-ton, degré 5
"1' 1"       // le degré 1 une octave au-dessus, puis le degré 1 normal
"[1 3 5]*2"  // le groupe "1 3 5" joué deux fois

Une note sans octave (C au lieu de C4) est une erreur de syntaxe : l'octave n'est jamais optionnelle sur une note absolue. Un degré sans key sur le song en est une autre — voir Pièges courants pour le détail des messages d'erreur.

Le temps

L'unité de temps d'un Score est le beat (une noire). Un pas par défaut de "1/4" vaut donc 1/4 de beat (une double-croche). Dans le Score, start et dur sont des fractions sérialisées en chaînes ("1/4", "3/2"), jamais des flottants.

La fraction, pas le flottant

Fraction est un type exporté, { num: number, den: number }. Toutes les opérations (add, sub, mul, div, compare, equals, min, max...) retournent une fraction réduite au plus simple, jamais un flottant approché. C'est ce qui garantit qu'une somme de dix-sept croches ne finit jamais avec une erreur d'arrondi de type 0.1 + 0.2.

import { frac, add } from "Lulalib";

// { num: 1, den: 4 }
frac(1, 4);

// { num: 1, den: 2 }, pas { num: 2, den: 8 }
add(frac(1, 4), frac(1, 4));

frac(num, den) exige des entiers pour les deux arguments — elle lève une erreur sinon. Pour convertir une chaîne comme "1/8" (le format attendu par .step()), utilisez parseFraction(value: string).

Convertir en secondes

Le Score ne connaît que des beats. Pour un lecteur externe qui a besoin de secondes, la conversion se fait avec le bpm :

secondes = (num / den) * 60 / bpm

num/den sont ceux du start ou du dur de l'event. toNumber(fraction), exporté, calcule directement num / den si la précision d'un flottant suffit à ce stade (typiquement en sortie, jamais pendant la composition).

Degrés et key

Les degrés (1-7) ne sont résolus qu'à export(), contre la key du song. Le même code donne des notes différentes selon la clé :

  • key: "Cm" notes("1 3 5") devient C Eb G.
  • key: "C" notes("1 3 5") devient C E G.

Sans key, utilisez uniquement des notes absolues (C4, Eb3...) ; tenter un degré sans clé est une erreur. Seuls les modes majeur et mineur sont supportés — pas de dorien, phrygien ou autre mode ecclésiastique. .octave(n) fixe l'octave de base pour les degrés d'une track ; il n'a aucun effet sur des notes absolues, déjà porteuses de leur octave.

Le Score et l'export

song.export() (et toScore(tracks, meta) pour un usage bas niveau, sans passer par song) produisent un objet plat :

{
  "version": 1,
  "bpm": 120,
  "key": "Am",
  "duration": "2/1",
  "instruments": { "909": { "parts": ["kick", "snare", "hihat"] }, "acid": {} },
  "events": [
    { "start": "0/1", "dur": "1/4", "instrument": "909", "part": "kick" },
    { "start": "0/1", "dur": "1/4", "instrument": "acid", "note": "A2" }
  ]
}

key et timeSignature n'apparaissent que s'ils ont été fournis au song. duration est la longueur totale de l'arrangement, en beats. instruments liste chaque instrument utilisé, avec la liste de ses parts (percussion uniquement) dans l'ordre de leur première apparition. events est trié par start croissant, tous instruments confondus.

Chaque event porte soit part (percussion) soit note (mélodique), jamais les deux, et jamais aucun des deux si la track n'avait ni notes ni part. velocity est optionnelle ; absente, elle vaut 100 sur 127 au moment de l'export MIDI. instrument n'est qu'un nom : c'est au lecteur (DAW, synthétiseur, moteur maison) de décider quel son y associer.

toJSON

toJSON(score, { pretty? }) sérialise le Score tel quel. pretty: true indente la sortie sur 2 espaces ; sans l'option, tout tient sur une ligne.

toMIDI

toMIDI(score, { ppq? }) retourne un Uint8Array — un fichier MIDI standard (format 0, une piste), prêt à écrire sur disque ou à envoyer au navigateur. ppq (pulses per quarter note, défaut 480) fixe la résolution temporelle ; les fractions du Score sont arrondies au tick le plus proche à cette résolution.

  • Un event percussif (avec part) part toujours sur le canal 10 (index 9), la convention MIDI pour les percussions. Le nom de la part est mappé sur une note General MIDI : kick→36, snare→38, hihat→42, clap→39, tom→45. Une part au nom inconnu tombe sur la note 37 par défaut.
  • Un event mélodique (avec note) obtient un canal dédié par instrument, attribué dans l'ordre de première rencontre, en sautant le canal 9 (réservé à la percussion). Avec plus de 15 instruments mélodiques distincts, les canaux MIDI disponibles (0-15, moins le 9) sont épuisés.
  • velocity (0 à 1 dans le Score) est convertie en 0-127 pour le MIDI ; absente, elle vaut 100.

Référence des exports

DSL : bass lead inst drums — Composition : section song track at — Combinateurs bas niveau : stack cat fast slow pure silence — Export : toScore toJSON toMIDI — Théorie : noteToSemitones semitonesToNote transpose degreeToNote parseKey scaleNotes — Temps : frac add sub mul div compare equals parseFraction toNumber — Mini-notation : parseRhythm parseMelody ParseError.

Pièges courants

Les erreurs les plus fréquentes en écrivant du Lulalib, avec le message exact qui s'affiche et la cause.

  • Pas de son. N'attendez jamais de la lib qu'elle joue un son ; elle produit un Score. Pour l'entendre, exportez en MIDI et ouvrez le fichier dans une DAW ou un lecteur externe.
  • note xor part. Un event mélodique porte note, un event de batterie porte part, jamais les deux à la fois.
  • Accords sans espaces : "C4,E4,G4", jamais "C4, E4, G4". Un espace après la virgule change le parsing.
  • Note absolue sans octave. "note is missing its octave" : "C" seul ne suffit pas, il faut "C4". L'octave n'est jamais optionnelle.
  • Marqueur temporel dans une mélodie. "temporal marker '...' is not allowed in notes" : -, ~, _ et @ appartiennent à la mini-notation de rythme, pas à celle de mélodie. Un silence dans une mélodie se gère côté .rhythm(), pas côté .notes().
  • _ orphelin. "'_' has no note to extend" : une tenue doit toujours suivre un déclenchement dans la même grille de rythme, jamais démarrer un pattern ou suivre un silence.
  • @ orphelin. "'@' must follow a trigger" : la notation compacte x@4 exige un déclenchement juste avant le @.
  • Degré sans clé. "degrees require a key on the song" : utiliser notes("1 3 5") sur un song sans key échoue à l'export. Sans clé, seules les notes absolues sont utilisables.
  • Altération impossible. Un degré altéré (#/b) dans certaines combinaisons de clé peut exiger un double dièse ou double bémol pour rester juste enharmoniquement ; Lulalib ne les supporte pas et lève une erreur plutôt que d'approximer. Rare en pratique, mais possible avec des clés et altérations inhabituelles combinées.
  • Track vide sans erreur. Contrairement à l'API bas niveau track() (qui exige notes ou rhythm), les sucres bass/lead/inst sans aucune des deux méthodes ne lèvent pas d'erreur : ils produisent silencieusement une track vide.
  • .step() prend une chaîne ("1/8"), jamais un nombre ni une Fraction brute.
  • Immuable : gardez toujours la valeur retournée (x = x.with(...)) ; n'attendez pas de mutation en place, il n'y en a jamais.
  • .repeat(n) retourne un tableau, pas une section plus longue ; passez-le à arrange, qui l'aplatit.
  • Unité de temps = le beat, ni la seconde, ni la mesure. Pour convertir, voir Le temps.