diff --git a/README.md b/README.md index 165982c..0fc9d4f 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,49 @@ rejoué le lot à l'identique (session 5b, `agreement A.json B.json`) : [0.60–0.84]), et κ ≈ 0 signant la décorrélation des erreurs — deux oreilles, un même verdict, aucun artefact partagé. +**Et le classement ?** Tout ce qui précède valide des *dégradations* : un +morceau contre sa version abîmée. Rien n'y valide un *classement* — un +morceau contre un autre — qui est pourtant l'hypothèse sur laquelle Forge +repose tout entier. Le protocole s'y prête sans rien changer : le côté +« original » devient le candidat que le moteur place devant. + +```bash +python3 examples/forge_loops.py /tmp/index.json sortie/ 48 3 --keep-all +python3 examples/forge_duels.py sortie/ --out duels.json +python3 -m libretto.cli annotate duels.json --out jugements_duels.json --render instrument +python3 -m libretto.cli agreement jugements_duels.json # sans --corpus : pas de dégradation à rejouer +``` + +Deux sessions, cumulées : 60 duels, 11 contrôles, 71 jugements, rendu +v3-instrument (la première, à 14 duels, ne pouvait rien conclure — sur n = 7 +par tranche, seul un 7/7 aurait été significatif ; détail en session 6). + +| tranche | écart SMS | l'oreille suit le moteur | IC95 | p unilatéral | +|---|---|---|---|---| +| écart fort | 0.102 – 0.206 | **18/29 = 62 %** | 0.44 – 0.77 | 0.132 | +| écart faible | 0.001 – 0.038 | 13/30 = 43 % | 0.27 – 0.61 | 0.819 | + +Aucun des deux n'est significatif, et les deux ne disent pas la même chose. +**Le classement fin ne s'entend pas** : sur les écarts de quelques +centièmes — ceux qui décident du n°1 contre le n°3 — l'oreille est au +hasard, et cette fois la puissance était là (un effet réel de 0.75 aurait +été vu 8 fois sur 10). **Sur les gros écarts il y a un signal, trop faible +pour ce lot** : 62 % va dans le sens attendu, l'écart entre tranches aussi +(Fisher p = 0.119), mais établir un effet de 0.62 demanderait 106 duels par +tranche. Le coût de la question est chiffré, il n'est pas payé. + +À lire avec la réserve que le harnais signale lui-même : sur 11 paires +**identiques**, l'annotateur n'a dit « aucune différence » que 3 fois. Il +n'appuie pas au hasard pour autant — 27 % de « aucune différence » sur les +paires identiques contre 1.7 % sur les duels réels (Fisher p = 0.011) — mais +chaque préférence inventée est un tirage à pile ou face qui tire le taux vers +0.5 : **les 62 % sont un plancher, pas une estimation**. + +Conséquence pratique, écrite noir sur blanc : sur du loop arrangé comme sur +du transcrit, Forge est un **triage grossier**. Écarter le bas du classement +se défend ; couronner le n°1 ne se défend pas. Détail en session 7 de +[`resultats_ecoute.md`](resultats_ecoute.md). + ## Tonalité : l'estimateur confronté à une étiquette qu'il n'a pas écrite Krumhansl-Kessler (`libretto.axes.estimate_key`) alimente sept axes du @@ -525,6 +568,88 @@ diversité est bon marché : la shortlist round-robin (k=5) **retient 5.0 formes sur 5, pour un coût en score moyen de 0.008** (max 0.020) — une forme entière regagnée coûte moins d'un centième de score. +## Forge sur un pack de loops + +Il manquait à Forge la source la plus banale d'un studio : **un pack de +loops**. Les progressions et les riffs y sont écrits par des humains, ce que +le générateur de `make_corpus` ne sait pas inventer et qu'une transcription +audio dégrade (−0.18). Ce qu'un pack n'a pas, en revanche, c'est une +**forme** : il livre huit boucles de quatre mesures, pas une chanson. C'est +exactement la division du travail que Forge permet — la matière vient du +pack, la charpente est cherchée. + +```bash +python3 examples/loop_index.py ~/Desktop/SAMPLES/MIDI --out /tmp/index.json +python3 examples/forge_loops.py /tmp/index.json sortie/ 48 3 --axes --shortlist 5 +python3 examples/forge_loops.py /tmp/index.json sortie/ 48 3 --famille "Naija Waves" +``` + +`loop_index.py` lit ce que les noms de fichiers et de dossiers annoncent +(tonalité, tempo, instrument) et mesure ce qu'ils taisent (longueur, +polyphonie, tessiture). **La tonalité vient de l'étiquette, pas de +l'estimateur** — sur une boucle isolée, celui-ci tombe juste 4 fois sur 10 +(voir *Tonalité*) ; il n'est appelé que pour les fichiers non étiquetés, et +l'index dit toujours d'où vient l'information. Le rôle est lu dans le nom +quand il s'y trouve, tranché par le contenu sinon (polyphonie ≥ 1.8 = +accompagnement ; monodie grave = basse). Une **famille** — un dossier, une +tonalité, un tempo — est ce qui s'arrange ensemble sans transposer ; elle +sépare toute seule `Bonfire_Dm_100` de `Moody_90_Bm` dans le même dossier. +Sur 820 fichiers : 800 indexés, 76 familles arrangeables. + +Chaque candidat tire un plan (forme, longueur des sections, effectif par +section, courbe d'énergie, parfois une modulation ou une mélodie empruntée à +une autre famille du même pack, transposée à mode égal), tuile les boucles +dessus et écrit les marqueurs. Seule la batterie se prête d'une famille à +l'autre : elle n'a pas de tonalité. + +**Ce que ça vaut.** Le seul comparatif qui isole l'apport de la recherche est +à matière égale — le même pack Naija Waves, plan écrit à la main contre plan +cherché : + +| Naija Waves | score SMS | fiabilité | +|---|---|---| +| `assemble_naija.py` (PLAN écrit à la main) | 0.760 | 0.97 | +| `forge_loops.py` (48 plans cherchés, graine 3) | **0.839** | 0.93 | + +Contre le générateur synthétique, à budget égal (48 candidats, graines 1-2-3) +le match est nul, et il faut le dire ainsi plutôt que de choisir la ligne qui +arrange : + +| médiane sur 3 graines | gagnant | meilleur score | médiane du peloton | +|---|---|---|---| +| `forge.py` (synthétique) | **0.863** | 0.884 | **0.786** | +| `forge_loops.py` (pack) | 0.849 | **0.893** | 0.757 | + +Le plafond est un peu plus haut sur les loops, le peloton un peu plus bas : +la matière humaine est plus inégale que celle d'un générateur réglé sur les +mêmes hypothèses que les axes. + +**Où ça se joue**, par groupe d'axes (médianes, 48 candidats) : la forme +monte (A 0.846 contre 0.766) et la cohérence globale aussi (F 0.649 contre +0.500) — c'est l'apport de la recherche de plan. La mélodie s'effondre +(**C 0.597 contre 0.834**), et l'axe 15 (contour mélodique) porte presque +tout l'écart : 0.471 contre 0.956. Deux corrections ont été essayées et +**réfutées**, chiffres à l'appui : pousser tout l'accompagnement sous la note +la plus grave de la mélodie, pour que la voix supérieure lue par Libretto +soit bien la mélodie — groupe C inchangé (0.597), meilleur score 0.908 → +0.899 ; et faire entrer la mélodie beaucoup plus souvent — groupe C 0.597 → +0.574. Ce qui reste est l'explication la plus économique : les lignes d'un +pack ne satisfont pas les bandes de tolérance des axes 15 et 19 comme le font +celles de `make_corpus`, **écrites sous les mêmes hypothèses que les axes**. +C'est la circularité vue d'un autre angle — le générateur maison a un +avantage de naissance sur le juge maison, et il ne se voyait pas tant qu'on +ne lui opposait pas de la matière étrangère. + +Ces écarts sont ceux du **juge**, et le juge a depuis été confronté à une +oreille sur ces mêmes candidats (voir *Validation externe*, sessions 6-7) : +sur des écarts de score supérieurs à 0.10 l'accord est de 62 % — un signal +non établi ; sur des écarts de quelques centièmes, rien. Le 0.839 contre +0.760 tient donc comme mesure de charpente, pas comme promesse que le +gagnant s'entend mieux. + +Rien du pack n'entre dans le dépôt : l'index ne contient que des chemins, et +le dossier reste où il est. + ## Interface web locale ```bash diff --git a/examples/forge_duels.py b/examples/forge_duels.py new file mode 100644 index 0000000..4e3a128 --- /dev/null +++ b/examples/forge_duels.py @@ -0,0 +1,185 @@ +""" +Duels d'écoute à partir d'un rapport Forge : le classement tient-il à l'oreille ? + +Tout ce que Forge affirme repose sur une hypothèse jamais testée sur ses +propres sorties : que le candidat qu'il place devant est réellement le mieux +construit. Les sessions d'écoute passées ont validé les **dégradations** — +un morceau contre sa version abîmée. Elles n'ont jamais validé un +**classement** — un morceau contre un autre morceau. + +Ce script prend un `forge_report.json` (de `forge.py`, `forge_loops.py`, +`forge_musiclang.py` — n'importe lequel), en tire des paires de candidats et +les écrit au format attendu par `libretto annotate`. Le côté « original » du +protocole devient le candidat que Forge préfère : si l'oreille le désigne +significativement plus souvent que le hasard, le classement veut dire +quelque chose. + + python3 examples/forge_loops.py /tmp/index.json sortie/ 48 3 --keep-all + python3 examples/forge_duels.py sortie/ --out duels.json + python3 -m libretto.cli annotate duels.json --out jugements_duels.json \ + --render instrument + python3 -m libretto.cli agreement jugements_duels.json + +Les paires sont réparties en deux tranches, parce que la question n'est pas +seulement « l'oreille suit-elle ? » mais **à partir de quel écart** : + + · `duel_ecart_fort` — écart de score ≥ 0.10. Si l'oreille ne suit pas + là, le score ne classe rien du tout ; + · `duel_ecart_faible` — écart ≤ 0.04. Le classement fin de Forge (« le + n°1 plutôt que le n°3 ») repose entièrement sur cette tranche ; c'est + aussi celle où un désaccord serait le moins grave. + +Le dépouillement se fait **sans `--corpus`** : `agreement` y recalculerait +la dégradation, qui n'existe pas ici. Le taux de détection par tranche EST +l'accord moteur/oreille. +""" + +from __future__ import annotations + +import argparse +import json +import random +import sys +from itertools import combinations +from math import ceil +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from libretto.midi import parse_midi, tick_to_seconds # noqa: E402 + +TRANCHES = (("duel_ecart_fort", 0.10, 1.0), ("duel_ecart_faible", 0.0, 0.04)) + + +def duree(path: Path) -> float: + md = parse_midi(path) + return tick_to_seconds(md, md.end_tick) + + +def deja_jugees(jugements: Path | None) -> set[str]: + """Noms des paires déjà jugées dans un fichier de jugements. + + Elles sont **remises dans le lot** plutôt qu'écartées : `annotate` les + reconnaît à leur clé et ne les repropose pas, et le lot décrit alors la + session complète — les 14 premiers duels et les 46 suivants comptent + dans le même dépouillement, ce qui est tout l'intérêt d'agrandir plutôt + que de recommencer. + """ + if not jugements or not Path(jugements).exists(): + return set() + data = json.loads(Path(jugements).read_text(encoding="utf-8")) + return {r["file"] for r in data.get("judgements", []) + if r["degradation"] != "__control__"} + + +def construire_duels(rapport: dict, dossier: Path, n_par_tranche: int = 8, + seed: int = 1, max_secondes: float = 90.0, + conserver: set[str] | None = None) -> list[dict]: + # Un duel se juge en écoutant les DEUX côtés en entier : deux candidats + # de quatre minutes, c'est un quart d'heure pour une seule réponse, et + # une session qu'on n'achève pas ne produit aucun intervalle de + # confiance. Les longs sont écartés ici, pas en cours de route. + ranked = [c for c in rapport["leaderboard"] + if (dossier / c["file"]).exists() + and duree(dossier / c["file"]) <= max_secondes] + if len(ranked) < 2: + raise SystemExit( + f"{dossier}/forge_report.json : moins de deux candidats de moins de " + f"{max_secondes:.0f} s encore sur le disque — relancez Forge avec " + f"--keep-all (le gagnant seul ne permet aucun duel), ou relevez " + f"--max-secondes.") + + rng = random.Random(seed) + conserver = conserver or set() + duels: list[dict] = [] + + def nom_de(a: dict, b: dict) -> str: + fort, faible = (a, b) if a["score"] >= b["score"] else (b, a) + return (f"{fort['file'].replace('.mid', '')}" + f"_vs_{faible['file'].replace('.mid', '')}") + + for etiquette, bas, haut in TRANCHES: + paires = [(a, b) for a, b in combinations(ranked, 2) + if bas <= abs(a["score"] - b["score"]) <= haut] + rng.shuffle(paires) + # Les paires déjà jugées passent devant : elles occupent leur place + # dans la tranche sans coûter une écoute. + paires.sort(key=lambda ab: nom_de(*ab) not in conserver) + # Un même candidat ne monopolise pas la tranche : sans ce garde-fou, + # le gagnant se retrouve dans huit duels sur huit et la tranche ne + # mesure plus que lui. Le plafond suit la demande — 30 duels sur 48 + # candidats en exigent 3 chacun, l'imposer à 2 rendrait la tranche + # silencieusement plus courte que demandée. + plafond = max(2, ceil(2 * n_par_tranche / max(1, len(ranked)))) + vus: dict[str, int] = {} + retenues = [] + for a, b in paires: + if vus.get(a["file"], 0) >= plafond or vus.get(b["file"], 0) >= plafond: + continue + vus[a["file"]] = vus.get(a["file"], 0) + 1 + vus[b["file"]] = vus.get(b["file"], 0) + 1 + retenues.append((a, b)) + if len(retenues) >= n_par_tranche: + break + if len(retenues) < n_par_tranche: + print(f" ⚠ {etiquette} : {len(retenues)} paires seulement sur " + f"{n_par_tranche} demandées ({len(paires)} paires possibles " + f"dans la tranche, plafond {plafond} duels par candidat)") + for a, b in retenues: + fort, faible = (a, b) if a["score"] >= b["score"] else (b, a) + duels.append({ + "prefere": str((dossier / fort["file"]).resolve()), + "autre": str((dossier / faible["file"]).resolve()), + "etiquette": etiquette, + "nom": nom_de(a, b), + "ecart": round(fort["score"] - faible["score"], 4), + "scores": [fort["score"], faible["score"]], + "fiabilites": [fort["confidence"], faible["confidence"]], + }) + return duels + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[1]) + ap.add_argument("dossier", help="dossier de sortie Forge (avec forge_report.json)") + ap.add_argument("--out", default="duels.json", help="fichier de duels") + ap.add_argument("--par-tranche", type=int, default=8, + help="duels par tranche d'écart (défaut 8)") + ap.add_argument("--max-secondes", type=float, default=90.0, + help="écarter les candidats plus longs (défaut 90 s)") + ap.add_argument("--seed", type=int, default=1) + ap.add_argument("--reprendre", metavar="JUGEMENTS", + help="fichier de jugements d'une session précédente : ses " + "paires sont replacées dans le lot (annotate ne les " + "repropose pas, le dépouillement les cumule)") + args = ap.parse_args(argv) + + dossier = Path(args.dossier).expanduser().resolve() + rapport = json.loads((dossier / "forge_report.json").read_text(encoding="utf-8")) + conserver = deja_jugees(Path(args.reprendre) if args.reprendre else None) + duels = construire_duels(rapport, dossier, args.par_tranche, args.seed, + args.max_secondes, conserver) + Path(args.out).write_text( + json.dumps({"source": str(dossier), "duels": duels}, + ensure_ascii=False, indent=1), encoding="utf-8") + + par_tranche: dict[str, list[float]] = {} + for d in duels: + par_tranche.setdefault(d["etiquette"], []).append(d["ecart"]) + restants = [d for d in duels if d["nom"] not in conserver] + total = sum(duree(Path(d[c])) for d in restants for c in ("prefere", "autre")) + print(f"{len(duels)} duels → {args.out}") + for nom, ecarts in par_tranche.items(): + print(f" {nom:18s} {len(ecarts):2d} paires · écart " + f"{min(ecarts):.3f} à {max(ecarts):.3f}") + if conserver: + print(f" {len(duels) - len(restants)} déjà jugées, reprises telles quelles") + print(f" ~{total / 60:.0f} min d'écoute restante (les deux côtés, sans " + f"réécoute) + les paires de contrôle") + print("\nécoute : python3 -m libretto.cli annotate " + f"{args.out} --out jugements_duels.json --render instrument") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/forge_loops.py b/examples/forge_loops.py new file mode 100644 index 0000000..1a3a821 --- /dev/null +++ b/examples/forge_loops.py @@ -0,0 +1,471 @@ +""" +Forge sur loops : arranger de la matière humaine, laisser le SMS trancher. + +`forge.py` tire ses candidats d'un générateur synthétique ; `forge_musiclang` +d'un transformer ; `forge_acestep` d'une transcription. Il manquait la +source la plus banale d'un studio : **un pack de loops**. Les progressions, +les voicings et les riffs y sont écrits par des humains — c'est exactement +la matière que le générateur de `make_corpus` ne sait pas inventer (groupes +B et C), et c'est ce qui reste hors de portée d'une transcription (−0.18 de +score, mesuré). + +Ce que Forge apporte par-dessus, c'est ce que le pack n'a pas : une +**forme**. Un pack livre huit boucles de quatre mesures, pas une chanson. +Chaque candidat tire un plan (forme, longueur des sections, effectif, +courbe d'énergie, parfois une modulation), tuile les boucles dessus, écrit +les marqueurs — puis Libretto note, et le classement fiabilité-d'abord +choisit. La recherche porte sur l'arrangement, la matière ne bouge pas. + + python3 examples/loop_index.py ~/Desktop/SAMPLES/MIDI --out /tmp/index.json + python3 examples/forge_loops.py /tmp/index.json sortie/ 24 1 --axes --shortlist 5 + +Le premier argument accepte aussi directement le dossier du pack (l'index +est alors construit en mémoire). + +Trois choix assumés : + + · **la tonalité vient de l'étiquette du pack**, pas de l'estimateur — + mesuré à 0.400 sur une boucle isolée (`scripts/mesure_tonalite.py`). + Une famille est un dossier + une tonalité + un tempo : ce qui s'arrange + ensemble sans transposer ; + · **les percussions se prêtent, pas les hauteurs.** Une batterie n'a pas + de tonalité : elle peut venir d'une autre famille du même dossier à + tempo voisin. Un instrument tonal, non — sauf emprunt explicite, + transposé, et seulement à mode égal ; + · **la circularité est réelle.** Optimiser un arrangement contre le juge + qui le note ne prouve pas qu'il sonne mieux ; ça prouve qu'il est mieux + construit *au sens des 29 axes*. Livrer une `--shortlist` à une oreille + reste la seule validation — le harnais existe (`annotate`/`agreement`). +""" + +from __future__ import annotations + +import argparse +import math +import random as _random +import sys +from dataclasses import dataclass +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from libretto.axes import PC_NAMES, SenseOfMusicalStructure # noqa: E402 +from libretto.midi import parse_midi, write_midi # noqa: E402 + +from forge import (_print_axes_report, _print_report, # noqa: E402 + _print_shortlist, _select_and_report, judge_midi) +from loop_index import BEATS_PER_BAR, Loop, ensure_index, familles # noqa: E402 +from make_corpus import FORMS, MARKER_NAMES, ROLE_ENERGY # noqa: E402 + +# Canaux MIDI : 9 est réservé aux percussions (Libretto les exclut du chroma +# et de la mélodie). Les pupitres tonaux ont chacun les leurs, ceux que +# `libretto/render.py::GM_PROGRAMS` associe à l'instrument correspondant — +# l'analyse ne lit que « canal 9 ou pas », mais une écoute rendue à +# FluidSynth joue une basse comme une basse et non comme un piano grave. +DRUMS_CHANNEL = 9 +CANAUX = {"harmonie": [0, 3, 5, 6], "basse": [1, 7], "melodie": [2, 4, 8]} + +# Vélocité : bornes serrées à dessein. L'axe 26 pénalise une gamme dynamique +# au-delà de 45, et une boucle poussée à 127 s'entend comme une saturation. +VEL_MIN, VEL_MAX = 25, 118 + +# Courbes d'énergie par section, appliquées PAR-DESSUS ROLE_ENERGY. L'arche +# est la forme que l'axe 28 récompense ; les autres existent pour que le +# peloton ne soit pas fait de clones. +ARCS = ("arche", "arche", "montee", "plat") + +# Registre visé par pupitre (hauteur médiane MIDI). Les boucles d'un pack +# sont écrites chacune dans son coin : un pad peut se retrouver au-dessus de +# la mélodie, et comme Libretto lit la mélodie dans la **voix supérieure**, +# elle devient alors le sommet des accords — la ligne mesurée n'est plus une +# ligne. Recaler chaque pupitre à l'octave (multiple de 12 : la tonalité ne +# bouge pas) est ce que ferait un arrangeur, et ça rend la mélodie lisible +# pour l'analyse comme pour l'oreille. Mesuré (48 candidats, graine 3) : +# groupe C 0.570 → 0.597, meilleur score 0.885 → 0.908. +REGISTRES = {"basse": (28, 48), "harmonie": (50, 69), "melodie": (69, 88)} + + +@dataclass +class Plan: + """Un arrangement tiré : tout ce qui distingue un candidat d'un autre.""" + famille: str + forme: str + sections: list[tuple[str, int, float]] # (étiquette, mesures, énergie) + pupitres: dict[str, list[Loop]] + bpm: float + tonique: int | None + mode: str | None + arc: str + modulation: tuple[int, int] # (section index, demi-tons), (0, 0) sinon + emprunt: str | None # loop empruntée à une autre famille + # Transposition par loop, en demi-tons. Portée par le plan et non par la + # boucle : l'index est partagé par tous les candidats, y écrire + # contaminerait les tirages suivants. + transpositions: dict[str, int] + + +# ────────────────────────────────────────────── +# Matière : familles utilisables +# ────────────────────────────────────────────── + +def familles_arrangeables(loops: list[Loop], min_pupitres: int = 2 + ) -> dict[str, list[Loop]]: + """Une famille s'arrange si elle a de quoi faire un morceau : au moins + deux pupitres tonaux distincts. Une famille de huit pads ne compte que + pour un — elle n'a pas de contrepoint à offrir, et Libretto le verrait + (texture, polyphonie, mélodie à plat).""" + out = {} + for nom, membres in familles(loops).items(): + tonaux = {lp.pupitre for lp in membres} & {"harmonie", "basse", "melodie"} + if len(tonaux) >= min_pupitres: + out[nom] = membres + return out + + +def batteries_compatibles(loops: list[Loop], famille: str, bpm: float | None, + tolerance: float = 0.08) -> list[Loop]: + """Percussions jouables sous cette famille : les siennes d'abord, sinon + celles du même dossier à tempo voisin. Une batterie n'a pas de tonalité, + l'emprunt ne coûte rien — c'est le seul pupitre dans ce cas.""" + dossier = famille.split("|")[0] + propres = [lp for lp in loops if lp.famille == famille and lp.pupitre == "batterie"] + if propres: + return propres + return [lp for lp in loops + if lp.pupitre == "batterie" and lp.dossier == dossier + and (bpm is None or lp.bpm is None + or abs(lp.bpm - bpm) <= tolerance * bpm)] + + +def emprunt_melodique(loops: list[Loop], plan_famille: list[Loop], famille: str, + tonique: int | None, mode: str | None, bpm: float | None, + rng: _random.Random) -> tuple[Loop, int] | None: + """Une mélodie d'une AUTRE famille, transposée dans la tonalité courante. + + Élargit la matière sans mentir sur ce qu'on fait : même pack, tempo + voisin, **mode identique** (transposer un majeur dans un mineur ne + transpose pas le mode), et tonalité des deux côtés connue — sinon + l'intervalle de transposition est une invention. + """ + if tonique is None or mode is None: + return None + pack = plan_famille[0].pack + candidats = [lp for lp in loops + if lp.pupitre == "melodie" and lp.famille != famille + and lp.pack == pack and lp.tonique is not None and lp.mode == mode + and lp.source_tonalite in ("fichier", "dossier") + and (bpm is None or lp.bpm is None + or abs(lp.bpm - bpm) <= 0.08 * bpm)] + if not candidats: + return None + lp = rng.choice(candidats) + # intervalle replié dans [-5, +6] : transposer d'une octave n'a pas de sens + demi = (tonique - lp.tonique) % 12 + if demi > 6: + demi -= 12 + return lp, demi + + +# ────────────────────────────────────────────── +# Tirage d'un plan +# ────────────────────────────────────────────── + +def _energie(label: str, index: int, total: int, arc: str) -> float: + """Énergie d'une section : son rôle, modulé par l'arc du morceau.""" + base = ROLE_ENERGY.get(label, 0.8) + if total <= 1: + return base + pos = index / (total - 1) + if arc == "arche": + facteur = 0.88 + 0.24 * math.sin(math.pi * min(1.0, pos * 1.15)) + elif arc == "montee": + facteur = 0.85 + 0.30 * pos + else: + facteur = 1.0 + return base * facteur + + +def tirer_plan(loops: list[Loop], arrangeables: dict[str, list[Loop]], + rng: _random.Random, famille_imposee: str | None = None) -> Plan | None: + noms = ([n for n in arrangeables if famille_imposee in n] if famille_imposee + else list(arrangeables)) + if not noms: + return None + # Pondéré par la richesse : une famille de douze boucles offre plus de + # variantes d'effectif qu'une de trois, et la sélection a besoin d'un + # peloton qui ne soit pas fait de jumeaux. + famille = rng.choices(noms, weights=[min(len(arrangeables[n]), 12) for n in noms])[0] + membres = arrangeables[famille] + + pupitres: dict[str, list[Loop]] = {} + for lp in membres: + pupitres.setdefault(lp.pupitre, []).append(lp) + pupitres["batterie"] = batteries_compatibles(loops, famille, membres[0].bpm) + # La distribution est tirée une fois pour tout le morceau : les sections + # doivent partager leurs instruments, sinon la « texture » change à + # chaque section pour rien et le matériau ne revient jamais (axe 6). + # D'un candidat à l'autre, en revanche, la distribution change. + for v in pupitres.values(): + rng.shuffle(v) + + tonique = next((lp.tonique for lp in membres if lp.tonique is not None), None) + mode = next((lp.mode for lp in membres if lp.mode is not None), None) + bpm = next((lp.bpm for lp in membres if lp.bpm is not None), 100.0) + + emprunt = None + transpositions: dict[str, int] = {} + if rng.random() < 0.35: + pris = emprunt_melodique(loops, membres, famille, tonique, mode, bpm, rng) + if pris: + lp, demi = pris + pupitres.setdefault("melodie", []).insert(0, lp) + emprunt = f"{lp.nom} ({demi:+d})" + transpositions[lp.chemin] = demi + + forme = rng.choice(list(FORMS)) + mesures = rng.choice([4, 4, 8, 8, 16]) + labels = FORMS[forme] + arc = rng.choice(ARCS) + sections = [(lab, mesures, _energie(lab, i, len(labels), arc)) + for i, lab in enumerate(labels)] + + # Modulation : les axes 11 et 13 la mesurent, et un pack n'en produit + # jamais tout seul — toutes ses boucles sont dans la même tonalité. + modulation = (0, 0) + if len(labels) >= 4 and rng.random() < 0.30: + modulation = (rng.randrange(len(labels) // 2, len(labels)), + rng.choice([-3, -2, 2, 3])) + + return Plan(famille=famille, forme=forme, sections=sections, pupitres=pupitres, + bpm=bpm, tonique=tonique, mode=mode, arc=arc, + modulation=modulation, emprunt=emprunt, + transpositions=transpositions) + + +# ────────────────────────────────────────────── +# Rendu +# ────────────────────────────────────────────── + +def octave_de_registre(lp: Loop) -> int: + """Décalage en octaves qui amène la boucle dans le registre de son + pupitre. Renvoie 0 si elle y est déjà, ou si elle n'a pas de hauteur + (percussions).""" + bande = REGISTRES.get(lp.pupitre) + if bande is None or lp.hauteur_mediane is None: + return 0 + bas, haut = bande + if bas <= lp.hauteur_mediane <= haut: + return 0 + centre = (bas + haut) / 2 + return max(-24, min(24, round((centre - lp.hauteur_mediane) / 12) * 12)) + + +def octaves_du_plan(plan: Plan) -> dict[str, int]: + """Décalages d'octave de tout l'effectif, décidés une fois pour le morceau. + + Une seule règle : chaque pupitre rejoint son registre. Une seconde a été + essayée et **retirée** — pousser tout l'accompagnement *sous* la note la + plus grave de la mélodie, pour que la voix supérieure que lit Libretto + soit à coup sûr la mélodie. L'hypothèse était que le groupe C s'effondre + (0.597 contre 0.834 pour le générateur synthétique) parce que la ligne + mesurée est un sommet d'accords. Mesuré sur 48 candidats, graine 3 : + groupe C **inchangé** (0.597), meilleur score 0.908 → 0.899. L'hypothèse + est fausse, la règle est partie ; ce qui reste du diagnostic est dans le + README. + """ + return {lp.chemin: octave_de_registre(lp) + for pupitre in plan.pupitres.values() for lp in pupitre} + + +def charger(lp: Loop, canal: int, transpose: int = 0 + ) -> tuple[list[tuple[float, float, int, int, int]], float]: + """(événements normalisés en beats, période musicale en beats). + + La période est arrondie à la mesure supérieure : un pack exporte des + boucles de 7.94 mesures qui en valent 8, et tuiler sur la longueur brute + ferait dériver toutes les entrées de section. + """ + md = parse_midi(Path(lp.chemin)) + events = [] + for n in md.notes: + start = n.start / md.ppq + dur = max(0.05, (n.end - n.start) / md.ppq) + pitch = n.pitch if canal == DRUMS_CHANNEL else min(126, max(1, n.pitch + transpose)) + events.append((start, dur, pitch, n.velocity, canal)) + brut = md.end_tick / md.ppq if md.ppq else 0.0 + periode = max(BEATS_PER_BAR, + math.ceil(brut / BEATS_PER_BAR) * BEATS_PER_BAR) if brut else BEATS_PER_BAR + return events, float(periode) + + +def _effectif(plan: Plan, energie: float, rng: _random.Random) -> list[tuple[Loop, int]]: + """Qui joue dans cette section, et sur quel canal. + + Un morceau dont l'effectif ne bouge pas est plat pour les axes 25 et 27, + et faux pour l'oreille : une intro est dépouillée, un refrain est plein. + Les seuils sont ceux de l'énergie de section, pas un tirage — c'est ce + qui rend la courbe lisible d'un bout à l'autre. + """ + choix: list[tuple[Loop, int]] = [] + + def prendre(pupitre: str, n: int) -> None: + dispo = plan.pupitres.get(pupitre) or [] + canaux = CANAUX[pupitre] + for i, lp in enumerate(dispo[:n]): + choix.append((lp, canaux[i % len(canaux)])) + + prendre("harmonie", 2 if energie >= 0.9 else 1) + if energie >= 0.68: + prendre("basse", 1) + if energie >= 0.85: + prendre("melodie", 2 if energie >= 1.0 else 1) + elif energie >= 0.72 and rng.random() < 0.4: + prendre("melodie", 1) + + perc = plan.pupitres.get("batterie") or [] + if perc and energie >= 0.62: + n = 1 if energie < 0.72 else (2 if energie < 0.9 else min(4, len(perc))) + for lp in perc[:n]: + choix.append((lp, DRUMS_CHANNEL)) + return choix + + +def rendre(plan: Plan, path: Path, rng: _random.Random) -> int: + """Écrit le MIDI du plan. Renvoie le nombre total de mesures.""" + cache: dict[str, tuple[list, float]] = {} + pistes: dict[str, list] = {} + markers: list[tuple[float, str]] = [] + tempo_changes: list[tuple[float, float]] = [] + octaves = octaves_du_plan(plan) + + beat = 0.0 + mod_index, mod_demi = plan.modulation + for i, (label, mesures, energie) in enumerate(plan.sections): + markers.append((beat, MARKER_NAMES.get(label, label))) + decalage = mod_demi if mod_index and i >= mod_index else 0 + section_beats = mesures * BEATS_PER_BAR + + for lp, canal in _effectif(plan, energie, rng): + cle = f"{lp.chemin}|{canal}" + if cle not in cache: + cache[cle] = charger(lp, canal, + plan.transpositions.get(lp.chemin, 0) + + octaves.get(lp.chemin, 0)) + events, periode = cache[cle] + reps = max(1, round(section_beats / periode)) + for rep in range(reps): + depart = beat + rep * periode + for start, dur, pitch, vel, ch in events: + if start >= section_beats: + continue + if depart + start >= beat + section_beats: + continue + p = pitch if ch == DRUMS_CHANNEL else min(126, max(1, pitch + decalage)) + v = min(VEL_MAX, max(VEL_MIN, round(vel * energie))) + pistes.setdefault(cle, []).append((depart + start, dur, p, v, ch)) + beat += section_beats + + write_midi(path, list(pistes.values()), ppq=480, bpm=plan.bpm, + time_sig=(4, 4), markers=markers, + tempo_changes=tempo_changes or None) + return int(beat / BEATS_PER_BAR) + + +# ────────────────────────────────────────────── +# Forge +# ────────────────────────────────────────────── + +def forge_loops(source: str | Path, out_dir: str | Path, n: int = 24, seed: int = 1, + index_path: str | Path | None = None, + min_confidence: float = SenseOfMusicalStructure.INTERPRETABLE_CONFIDENCE, + min_score: float = 0.0, keep_all: bool = False, + axes_report: bool = False, shortlist: int = 0, + famille: str | None = None) -> dict: + loops, data = ensure_index(Path(source), Path(index_path) if index_path else None) + arrangeables = familles_arrangeables(loops) + if not arrangeables: + raise SystemExit(f"aucune famille arrangeable dans {source} — " + f"il faut au moins deux pupitres tonaux dans un même " + f"dossier, à tonalité et tempo égaux.") + + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + + candidates, empties, familles_vues = [], 0, set() + for i in range(n): + rng = _random.Random(f"forge_loops:{seed}:{i}") + plan = tirer_plan(loops, arrangeables, rng, famille) + if plan is None: + raise SystemExit(f"aucune famille ne correspond à « {famille} ».") + path = out / f"candidate_{i:03d}.mid" + mesures = rendre(plan, path, rng) + familles_vues.add(plan.famille) + cand = judge_midi(path, i, form=plan.forme, + mode=plan.mode or "—", meter="4/4", + bpm=int(round(plan.bpm)), tonic=plan.tonique, bars=mesures) + if cand is None: + empties += 1 + continue + candidates.append(cand) + + return _select_and_report( + candidates, empties, out, + header={"n_requested": n, "seed": seed, "source": str(source), + "constraints": None, + "pack": data.get("racine"), + "n_loops": len(loops), + "n_familles_arrangeables": len(arrangeables), + "familles_tirees": sorted(familles_vues)}, + min_confidence=min_confidence, min_score=min_score, + keep_all=keep_all, axes_report=axes_report, shortlist=shortlist) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="forge_loops", + description="Forge sur pack de loops : tire N arrangements de matière " + "humaine, garde celui que Libretto juge le mieux construit.") + parser.add_argument("source", help="index JSON (loop_index.py) ou dossier du pack") + parser.add_argument("out_dir", help="dossier de sortie") + parser.add_argument("n", nargs="?", type=int, default=24, + help="nombre de candidats (défaut 24)") + parser.add_argument("seed", nargs="?", type=int, default=1, + help="graine déterministe (défaut 1)") + parser.add_argument("--index", metavar="F", + help="écrire/relire l'index ici quand la source est un dossier") + parser.add_argument("--famille", metavar="MOTIF", + help="restreindre aux familles dont le nom contient MOTIF") + parser.add_argument("--min-confidence", type=float, + default=SenseOfMusicalStructure.INTERPRETABLE_CONFIDENCE, + help="fiabilité minimale pour concourir (défaut 0.55)") + parser.add_argument("--min-score", type=float, default=0.0, + help="score SMS minimal pour concourir (défaut 0.0)") + parser.add_argument("--keep-all", action="store_true", + help="conserver tous les candidats (défaut : garder le gagnant)") + parser.add_argument("--axes", action="store_true", + help="détailler le gagnant axe par axe") + parser.add_argument("--shortlist", type=int, default=0, metavar="K", + help="K candidats sous contrainte de diversité") + args = parser.parse_args(argv) + + report = forge_loops(args.source, args.out_dir, n=args.n, seed=args.seed, + index_path=args.index, + min_confidence=args.min_confidence, min_score=args.min_score, + keep_all=args.keep_all, axes_report=args.axes, + shortlist=args.shortlist, famille=args.famille) + print(f"pack : {report['n_loops']} loops · " + f"{report['n_familles_arrangeables']} familles arrangeables · " + f"{len(report['familles_tirees'])} tirées") + _print_report(report) + _print_shortlist(report) + _print_axes_report(report) + if report["winner"]: + w = report["winner"] + print(f"\nmatière : {w['key']} · {w['bpm']} bpm · {w['bars']} mesures") + # Exit 2 si aucun candidat fiable : utilisable comme gate, comme forge. + return 0 if report["winner"] else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/loop_index.py b/examples/loop_index.py new file mode 100644 index 0000000..b5e33d4 --- /dev/null +++ b/examples/loop_index.py @@ -0,0 +1,397 @@ +""" +Index d'un pack de loops MIDI : rôle, tonalité, tempo, longueur, famille. + +Un pack du commerce est un tas de fichiers dont tout ce qui compte est dans +les noms — `Bonfire_Dm_100_Piano_Chords.mid` dit la chanson, la tonalité, le +tempo et l'instrument. Ce module lit ces noms (et les dossiers, qui portent +souvent la tonalité du kit), mesure ce qui ne s'y trouve pas (longueur, +polyphonie, tessiture), et range le tout dans un index JSON réutilisable. +`forge_loops.py` s'en sert pour arranger des morceaux. + + python3 examples/loop_index.py ~/Desktop/SAMPLES/MIDI --out /tmp/index.json + +**La tonalité vient de l'étiquette, pas de l'estimateur.** Mesuré +(`scripts/mesure_tonalite.py`, voir README) : sur une boucle isolée, +Krumhansl-Kessler trouve la bonne tonique dans 40 % des cas, et ni la mise +en commun par kit ni la marge ne le sauvent. L'estimation n'est donc écrite +que pour les fichiers sans étiquette, et l'index dit toujours d'où vient +l'information (`source_tonalite`) — un consommateur qui préfère ne pas +parier peut filtrer dessus. + +Le **rôle** est lu dans le nom quand il s'y trouve, et corrigé par le +contenu quand il n'y est pas : une piste dont la polyphonie moyenne dépasse +1.8 est un accompagnement harmonique, une monodie grave est une basse. Les +percussions sont repérées par le nom ou par le canal 9, et ne portent +jamais de tonalité — l'étiquette d'un snare est celle de son kit. + +La **famille** est l'unité qui s'arrange ensemble : même dossier, même +tonalité, même tempo. Elle sépare toute seule `Bonfire_Dm_100`, +`Lonely_F_m_102` et `Moody_90_Bm` d'un même pack, là où le dossier les +mélange. + +Aucun fichier du pack n'est copié ni modifié : l'index ne contient que des +chemins. +""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import sys +from collections import Counter +from dataclasses import asdict, dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from libretto.axes import PC_NAMES, estimate_key # noqa: E402 +from libretto.midi import parse_midi # noqa: E402 + +BEATS_PER_BAR = 4 + +# ────────────────────────────────────────────── +# Étiquettes lues dans les noms +# ────────────────────────────────────────────── + +# Tonique A-G, altération optionnelle, mode optionnel séparé ou collé +# (`Dm`, `F_m`, `Emin`, `Cmn`, `F#`, `C_maj`). Les gardes de part et d'autre +# évitent d'attraper l'initiale d'un mot : « Bonfire » n'est pas un si. +KEY_RE = re.compile( + r"(? pupitre. Ce qui n'est pas nommé est tranché par le contenu. +PUPITRE = { + "bass": "basse", "808": "basse", + "chord": "harmonie", "pad": "harmonie", "piano": "harmonie", + "key": "harmonie", "string": "harmonie", "organ": "harmonie", + "guitar": "harmonie", "brass": "harmonie", "synth": "harmonie", + "melod": "melodie", "lead": "melodie", "pluck": "melodie", + "flute": "melodie", "sax": "melodie", "arp": "melodie", + "marimba": "melodie", "vox": "melodie", "bell": "melodie", +} + + +def parse_key_label(text: str) -> tuple[int, str | None] | None: + """(classe de hauteur, 'maj'|'min'|None) lue dans `text`, ou None. + + Une tonique **nue** — sans altération ni mode — n'est retenue qu'en + capitale : `Moves_B_95` est un si, `d cymbl` est une convention de + nommage de batterie. Renvoie None aussi quand le texte annonce deux + toniques différentes : une étiquette contradictoire n'est pas une + vérité terrain. + """ + found: dict[int, str | None] = {} + for m in KEY_RE.finditer(text): + letter, acc, mode = m.group(1), m.group(2), m.group(3) + if not acc and not mode and not letter.isupper(): + continue + pc = BASE_PC[letter.upper()] + if acc == "#": + pc = (pc + 1) % 12 + elif acc and acc.lower() == "b": + pc = (pc - 1) % 12 + norm = None + if mode: + norm = "maj" if mode.lower() in ("maj", "major") else "min" + # un mode explicite l'emporte sur une occurrence muette de la même tonique + if pc not in found or (norm and not found[pc]): + found[pc] = norm + if len(found) != 1: + return None + pc, mode = next(iter(found.items())) + return pc, mode + + +def label_for(path: Path, root: Path) -> tuple[int, str | None, str] | None: + """Étiquette du fichier, la mieux étayée d'abord. + + Une étiquette **avec mode** l'emporte sur une tonique nue, où qu'elles + se trouvent — parce que ces packs nomment souvent chaque boucle par sa + fondamentale locale à l'intérieur d'un kit dont le dossier, lui, annonce + la tonalité : `KIT_1_PAD_SOULFUL_Eb_100BPM.mid` dans `KIT_1_Gmin_100BPM/` + n'est pas un pad en mi bémol, c'est le VI d'un sol mineur. À force égale, + le nom du fichier passe avant le dossier (plus précis). Le troisième + champ dit d'où vient l'étiquette retenue. + """ + candidates: list[tuple[int, str | None, str]] = [] + lab = parse_key_label(path.stem) + if lab: + candidates.append((lab[0], lab[1], "fichier")) + for parent in path.relative_to(root).parents: + if str(parent) in (".", ""): + continue + lab = parse_key_label(parent.name) + if lab: + candidates.append((lab[0], lab[1], "dossier")) + if not candidates: + return None + with_mode = [c for c in candidates if c[1]] + return with_mode[0] if with_mode else candidates[0] + + +def parse_bpm(text: str) -> float | None: + """Tempo lu dans un nom. Un nombre suivi de « bpm » l'emporte sur un + nombre nu — `SOSO_02_96bpm` a un numéro de prise ET un tempo.""" + marques, nus = [], [] + for m in BPM_RE.finditer(text): + val = float(m.group(1)) + if not BPM_MIN <= val <= BPM_MAX: + continue + (marques if m.group(2) else nus).append(val) + for source in (marques, nus): + if source: + return source[0] + return None + + +def bpm_for(path: Path, root: Path) -> tuple[float | None, str | None]: + for texte, source in [(path.stem, "fichier")] + [ + (p.name, "dossier") for p in path.relative_to(root).parents + if str(p) not in (".", "")]: + val = parse_bpm(texte) + if val: + return val, source + return None, None + + +def role_of(path: Path) -> str: + low = path.stem.lower() + for w in DRUM_WORDS: + if w in low: + return "batterie" + for w in ROLE_WORDS: + if w in low: + return w + return "?" + + +def hist_brut(md) -> list[float]: + """Histogramme des classes de hauteur pondéré par la durée, canal 9 exclu.""" + h = [0.0] * 12 + for n in md.notes: + if n.channel == 9: + continue + h[n.pitch % 12] += max(1, n.end - n.start) + return h + + +# ────────────────────────────────────────────── +# Index +# ────────────────────────────────────────────── + +@dataclass +class Loop: + chemin: str + pack: str + dossier: str # dossier relatif — le « kit » + famille: str # dossier + tonalité + tempo : ce qui s'arrange ensemble + nom: str + role: str # étiquette de nom (`chord`, `808`, `batterie`, `?`) + pupitre: str # basse | harmonie | melodie | batterie + tonique: int | None + mode: str | None + source_tonalite: str | None # fichier | dossier | estime + marge_kk: float | None # marge Krumhansl-Kessler si estimée + bpm: float | None + source_bpm: str | None + beats: float + mesures: int + n_notes: int + polyphonie: float + hauteur_mediane: int | None + # (plus grave, plus aiguë) : ce qu'il faut pour empiler deux pupitres + # sans qu'ils se marchent dessus — voir `forge_loops.REGISTRES`. + ambitus: list[int] | None = None + classes: list[int] = field(default_factory=list) + + +def _polyphonie(md) -> float: + """Notes par attaque distincte : 1.0 = monodie, ≥ 2 = accords.""" + onsets = {n.start for n in md.notes if n.channel != 9} + tonales = [n for n in md.notes if n.channel != 9] + return len(tonales) / len(onsets) if onsets else 0.0 + + +def _pupitre(role: str, poly: float, mediane: int | None, percussif: bool) -> str: + """Le nom d'abord, le contenu quand le nom se tait. + + Un pack sur cinq nomme ses fichiers `loop_03.mid` : sans lecture du + contenu, un tiers de la matière serait inclassable et donc perdue. + """ + if percussif or role == "batterie": + return "batterie" + if role in PUPITRE: + return PUPITRE[role] + if mediane is not None and mediane <= 52 and poly < 1.8: + return "basse" + return "harmonie" if poly >= 1.8 else "melodie" + + +def index_pack(root: Path, estimer: bool = True, + marge_min: float = 0.10) -> tuple[list[Loop], Counter]: + """Parcourt un pack et renvoie (loops, statistiques). + + `estimer` autorise Krumhansl-Kessler pour les fichiers **sans** + étiquette, et seulement au-dessus de `marge_min` — un pari assumé, + tracé par `source_tonalite="estime"`, jamais mélangé aux étiquettes. + """ + stats = Counter() + loops: list[Loop] = [] + for path in sorted(root.rglob("*")): + if path.suffix.lower() not in (".mid", ".midi"): + continue + stats["total"] += 1 + try: + md = parse_midi(path) + except Exception: + stats["illisibles"] += 1 + continue + if not md.notes: + stats["vides"] += 1 + continue + + rel = path.relative_to(root) + role = role_of(path) + percussif = sum(1 for n in md.notes if n.channel == 9) / len(md.notes) >= 0.8 + poly = _polyphonie(md) + tonales = [n for n in md.notes if n.channel != 9] + mediane = sorted(n.pitch for n in tonales)[len(tonales) // 2] if tonales else None + pupitre = _pupitre(role, poly, mediane, percussif) + + tonique = mode = source_ton = None + marge = None + if pupitre != "batterie": + lab = label_for(path, root) + if lab: + tonique, mode, source_ton = lab + elif estimer and tonales: + pc, m, _corr, marge_kk = estimate_key(hist_brut(md)) + if marge_kk >= marge_min: + tonique, mode, source_ton, marge = pc, m, "estime", round(marge_kk, 3) + stats["tonalite_estimee"] += 1 + else: + stats["tonalite_inconnue"] += 1 + else: + stats["tonalite_inconnue"] += 1 + + bpm, source_bpm = bpm_for(path, root) + if bpm is None and md.tempos: + bpm, source_bpm = round(md.tempos[0][1], 2), "midi" + + beats = md.end_tick / md.ppq if md.ppq else 0.0 + mesures = max(1, math.ceil(beats / BEATS_PER_BAR)) if beats else 1 + # Famille : ce qui peut s'arranger ensemble sans réfléchir. Le tempo + # est arrondi — un pack écrit 100 ici et 100.02 là. + cle_ton = f"{PC_NAMES[tonique]}{mode or ''}" if tonique is not None else "?" + famille = f"{rel.parent}|{cle_ton}|{round(bpm) if bpm else '?'}" + + loops.append(Loop( + chemin=str(path), pack=rel.parts[0] if len(rel.parts) > 1 else "", + dossier=str(rel.parent), famille=famille, nom=path.stem, + role=role, pupitre=pupitre, + tonique=tonique, mode=mode, source_tonalite=source_ton, marge_kk=marge, + bpm=bpm, source_bpm=source_bpm, + beats=round(beats, 3), mesures=mesures, n_notes=len(md.notes), + polyphonie=round(poly, 2), hauteur_mediane=mediane, + ambitus=([min(n.pitch for n in tonales), max(n.pitch for n in tonales)] + if tonales else None), + classes=sorted({n.pitch % 12 for n in tonales}), + )) + stats[pupitre] += 1 + return loops, stats + + +def save_index(loops: list[Loop], stats: Counter, root: Path, out: Path) -> None: + out.write_text(json.dumps( + {"racine": str(root), "stats": dict(stats), + "loops": [asdict(v) for v in loops]}, + ensure_ascii=False, indent=1), encoding="utf-8") + + +def load_index(path: Path) -> tuple[list[Loop], dict]: + data = json.loads(Path(path).read_text(encoding="utf-8")) + return [Loop(**d) for d in data["loops"]], data + + +def ensure_index(source: Path, index_path: Path | None, + estimer: bool = True) -> tuple[list[Loop], dict]: + """Charge l'index s'il existe, le construit sinon. `source` peut être un + dossier de pack ou un index déjà écrit.""" + source = Path(source).expanduser().resolve() + if source.is_file(): + return load_index(source) + if index_path and Path(index_path).exists(): + return load_index(Path(index_path)) + loops, stats = index_pack(source, estimer=estimer) + data = {"racine": str(source), "stats": dict(stats), + "loops": [asdict(v) for v in loops]} + if index_path: + Path(index_path).parent.mkdir(parents=True, exist_ok=True) + save_index(loops, stats, source, Path(index_path)) + return loops, data + + +def familles(loops: list[Loop]) -> dict[str, list[Loop]]: + out: dict[str, list[Loop]] = {} + for lp in loops: + out.setdefault(lp.famille, []).append(lp) + return out + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[1]) + ap.add_argument("pack", help="dossier du pack MIDI") + ap.add_argument("--out", default="loops_index.json", help="index JSON de sortie") + ap.add_argument("--sans-estimation", action="store_true", + help="ne pas estimer la tonalité des fichiers non étiquetés") + args = ap.parse_args(argv) + + root = Path(args.pack).expanduser().resolve() + loops, stats = index_pack(root, estimer=not args.sans_estimation) + save_index(loops, stats, root, Path(args.out)) + + fam = familles(loops) + utilisables = {k: v for k, v in fam.items() + if len({lp.pupitre for lp in v} & {"harmonie", "basse", "melodie"}) >= 2} + print(f"{stats['total']} fichiers → {len(loops)} indexés " + f"({stats['illisibles']} illisibles, {stats['vides']} vides)") + print(" pupitres : " + " · ".join( + f"{p} {stats[p]}" for p in ("harmonie", "melodie", "basse", "batterie"))) + etiquetees = sum(1 for lp in loops if lp.source_tonalite in ("fichier", "dossier")) + print(f" tonalité : {etiquetees} étiquetées · {stats['tonalite_estimee']} estimées" + f" · {stats['tonalite_inconnue']} inconnues") + print(f" {len(fam)} familles, dont {len(utilisables)} arrangeables " + f"(≥ 2 pupitres tonaux)") + for nom, v in sorted(utilisables.items(), key=lambda kv: -len(kv[1]))[:8]: + pupitres = Counter(lp.pupitre for lp in v) + print(f" {len(v):3d} loops {nom} {dict(pupitres)}") + print(f"index → {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/jugements_duels.json b/jugements_duels.json new file mode 100644 index 0000000..893c1fe --- /dev/null +++ b/jugements_duels.json @@ -0,0 +1,717 @@ +{ + "seed": 1, + "n_tasks": 69, + "renderer": "v3-instrument:MuseScore_General.sf3", + "judgements": [ + { + "task_id": 0, + "cle": "candidate_036_vs_candidate_026|duel_ecart_fort", + "file": "candidate_036_vs_candidate_026", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 58.9 + }, + { + "task_id": 1, + "cle": "candidate_095_vs_candidate_036|duel_ecart_fort", + "file": "candidate_095_vs_candidate_036", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 56.1 + }, + { + "task_id": 2, + "file": "candidate_010.mid", + "degradation": "__control__", + "original_slot": "A", + "choice": "same", + "picked_original": null, + "listened_seconds": 72.3, + "cle": "candidate_010.mid|__control__|0" + }, + { + "task_id": 2, + "cle": "candidate_015_vs_candidate_018|duel_ecart_fort", + "file": "candidate_015_vs_candidate_018", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 37.0 + }, + { + "task_id": 3, + "cle": "candidate_093_vs_candidate_056|duel_ecart_fort", + "file": "candidate_093_vs_candidate_056", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 65.7 + }, + { + "task_id": 4, + "cle": "candidate_068_vs_candidate_081|duel_ecart_fort", + "file": "candidate_068_vs_candidate_081", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 0.0 + }, + { + "task_id": 5, + "cle": "candidate_002.mid|__control__|0", + "file": "candidate_002.mid", + "degradation": "__control__", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 130.1 + }, + { + "task_id": 6, + "file": "candidate_032_vs_candidate_003", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 16.5, + "cle": "candidate_032_vs_candidate_003|duel_ecart_fort" + }, + { + "task_id": 7, + "file": "candidate_010.mid", + "degradation": "__control__", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 46.6, + "cle": "candidate_010.mid|__control__|1" + }, + { + "task_id": 7, + "cle": "candidate_093_vs_candidate_018|duel_ecart_fort", + "file": "candidate_093_vs_candidate_018", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 63.6 + }, + { + "task_id": 8, + "cle": "candidate_042_vs_candidate_058|duel_ecart_faible", + "file": "candidate_042_vs_candidate_058", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 23.4 + }, + { + "task_id": 9, + "cle": "candidate_024.mid|__control__|0", + "file": "candidate_024.mid", + "degradation": "__control__", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 47.4 + }, + { + "task_id": 10, + "cle": "candidate_055_vs_candidate_003|duel_ecart_faible", + "file": "candidate_055_vs_candidate_003", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 62.1 + }, + { + "task_id": 11, + "cle": "candidate_081.mid|__control__|0", + "file": "candidate_081.mid", + "degradation": "__control__", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 53.0 + }, + { + "task_id": 12, + "file": "candidate_010_vs_candidate_037", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 50.0, + "cle": "candidate_010_vs_candidate_037|duel_ecart_fort" + }, + { + "task_id": 13, + "cle": "candidate_053_vs_candidate_076|duel_ecart_fort", + "file": "candidate_053_vs_candidate_076", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 65.5 + }, + { + "task_id": 14, + "cle": "candidate_002_vs_candidate_016|duel_ecart_fort", + "file": "candidate_002_vs_candidate_016", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 61.3 + }, + { + "task_id": 15, + "file": "candidate_009_vs_candidate_000", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 34.8, + "cle": "candidate_009_vs_candidate_000|duel_ecart_fort" + }, + { + "task_id": 16, + "file": "candidate_032_vs_candidate_042", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 14.4, + "cle": "candidate_032_vs_candidate_042|duel_ecart_fort" + }, + { + "task_id": 17, + "file": "candidate_011_vs_candidate_018", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 60.6, + "cle": "candidate_011_vs_candidate_018|duel_ecart_faible" + }, + { + "task_id": 18, + "cle": "candidate_045_vs_candidate_068|duel_ecart_faible", + "file": "candidate_045_vs_candidate_068", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 26.0 + }, + { + "task_id": 19, + "file": "candidate_014_vs_candidate_042", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 26.3, + "cle": "candidate_014_vs_candidate_042|duel_ecart_faible" + }, + { + "task_id": 20, + "cle": "candidate_045_vs_candidate_011|duel_ecart_fort", + "file": "candidate_045_vs_candidate_011", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 17.1 + }, + { + "task_id": 21, + "cle": "candidate_092_vs_candidate_086|duel_ecart_fort", + "file": "candidate_092_vs_candidate_086", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 60.2 + }, + { + "task_id": 22, + "cle": "candidate_068.mid|__control__|0", + "file": "candidate_068.mid", + "degradation": "__control__", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 28.2 + }, + { + "task_id": 23, + "cle": "candidate_053_vs_candidate_086|duel_ecart_fort", + "file": "candidate_053_vs_candidate_086", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 19.7 + }, + { + "task_id": 24, + "cle": "candidate_091_vs_candidate_083|duel_ecart_fort", + "file": "candidate_091_vs_candidate_083", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 80.1 + }, + { + "task_id": 25, + "cle": "candidate_001_vs_candidate_042|duel_ecart_fort", + "file": "candidate_001_vs_candidate_042", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 26.0 + }, + { + "task_id": 26, + "cle": "candidate_065_vs_candidate_026|duel_ecart_fort", + "file": "candidate_065_vs_candidate_026", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 70.2 + }, + { + "task_id": 27, + "cle": "candidate_081_vs_candidate_084|duel_ecart_faible", + "file": "candidate_081_vs_candidate_084", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 32.9 + }, + { + "task_id": 28, + "cle": "candidate_076.mid|__control__|0", + "file": "candidate_076.mid", + "degradation": "__control__", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 130.2 + }, + { + "task_id": 29, + "cle": "candidate_024_vs_candidate_016|duel_ecart_faible", + "file": "candidate_024_vs_candidate_016", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 40.4 + }, + { + "task_id": 30, + "file": "candidate_022_vs_candidate_027", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 45.9, + "cle": "candidate_022_vs_candidate_027|duel_ecart_faible" + }, + { + "task_id": 31, + "cle": "candidate_016_vs_candidate_021|duel_ecart_faible", + "file": "candidate_016_vs_candidate_021", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 30.9 + }, + { + "task_id": 32, + "cle": "candidate_093_vs_candidate_052|duel_ecart_faible", + "file": "candidate_093_vs_candidate_052", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 69.9 + }, + { + "task_id": 33, + "cle": "candidate_024_vs_candidate_063|duel_ecart_faible", + "file": "candidate_024_vs_candidate_063", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 29.6 + }, + { + "task_id": 34, + "cle": "candidate_033_vs_candidate_086|duel_ecart_faible", + "file": "candidate_033_vs_candidate_086", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 73.4 + }, + { + "task_id": 35, + "cle": "candidate_002_vs_candidate_045|duel_ecart_faible", + "file": "candidate_002_vs_candidate_045", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 20.0 + }, + { + "task_id": 36, + "cle": "candidate_020_vs_candidate_073|duel_ecart_faible", + "file": "candidate_020_vs_candidate_073", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 40.1 + }, + { + "task_id": 37, + "file": "candidate_009_vs_candidate_037", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 20.0, + "cle": "candidate_009_vs_candidate_037|duel_ecart_faible" + }, + { + "task_id": 38, + "cle": "candidate_040_vs_candidate_071|duel_ecart_faible", + "file": "candidate_040_vs_candidate_071", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 15.9 + }, + { + "task_id": 39, + "cle": "candidate_061_vs_candidate_075|duel_ecart_faible", + "file": "candidate_061_vs_candidate_075", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 13.7 + }, + { + "task_id": 40, + "cle": "candidate_068_vs_candidate_085|duel_ecart_faible", + "file": "candidate_068_vs_candidate_085", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 17.5 + }, + { + "task_id": 41, + "cle": "candidate_085_vs_candidate_081|duel_ecart_fort", + "file": "candidate_085_vs_candidate_081", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 43.8 + }, + { + "task_id": 42, + "file": "candidate_032.mid", + "degradation": "__control__", + "original_slot": "A", + "choice": "same", + "picked_original": null, + "listened_seconds": 82.6, + "cle": "candidate_032.mid|__control__|0" + }, + { + "task_id": 43, + "cle": "candidate_071_vs_candidate_061|duel_ecart_faible", + "file": "candidate_071_vs_candidate_061", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 24.7 + }, + { + "task_id": 44, + "cle": "candidate_095_vs_candidate_076|duel_ecart_fort", + "file": "candidate_095_vs_candidate_076", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 90.3 + }, + { + "task_id": 45, + "cle": "candidate_020.mid|__control__|0", + "file": "candidate_020.mid", + "degradation": "__control__", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 129.8 + }, + { + "task_id": 46, + "cle": "candidate_067_vs_candidate_055|duel_ecart_fort", + "file": "candidate_067_vs_candidate_055", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 12.6 + }, + { + "task_id": 47, + "cle": "candidate_067_vs_candidate_092|duel_ecart_faible", + "file": "candidate_067_vs_candidate_092", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 23.1 + }, + { + "task_id": 48, + "file": "candidate_011_vs_candidate_000", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 32.0, + "cle": "candidate_011_vs_candidate_000|duel_ecart_fort" + }, + { + "task_id": 49, + "cle": "candidate_038_vs_candidate_083|duel_ecart_faible", + "file": "candidate_038_vs_candidate_083", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 18.8 + }, + { + "task_id": 50, + "cle": "candidate_015_vs_candidate_022|duel_ecart_faible", + "file": "candidate_015_vs_candidate_022", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 62.1 + }, + { + "task_id": 51, + "cle": "candidate_076_vs_candidate_003|duel_ecart_faible", + "file": "candidate_076_vs_candidate_003", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 24.2 + }, + { + "task_id": 52, + "cle": "candidate_040_vs_candidate_056|duel_ecart_fort", + "file": "candidate_040_vs_candidate_056", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 38.2 + }, + { + "task_id": 53, + "file": "candidate_032.mid", + "degradation": "__control__", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 83.4, + "cle": "candidate_032.mid|__control__|1" + }, + { + "task_id": 54, + "cle": "candidate_055_vs_candidate_086|duel_ecart_faible", + "file": "candidate_055_vs_candidate_086", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 75.9 + }, + { + "task_id": 55, + "file": "candidate_045_vs_candidate_003", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 40.4, + "cle": "candidate_045_vs_candidate_003|duel_ecart_fort" + }, + { + "task_id": 56, + "file": "candidate_032_vs_candidate_015", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 112.2, + "cle": "candidate_032_vs_candidate_015|duel_ecart_faible" + }, + { + "task_id": 57, + "file": "candidate_010_vs_candidate_014", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 32.1, + "cle": "candidate_010_vs_candidate_014|duel_ecart_fort" + }, + { + "task_id": 58, + "cle": "candidate_091_vs_candidate_084|duel_ecart_fort", + "file": "candidate_091_vs_candidate_084", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 33.0 + }, + { + "task_id": 59, + "cle": "candidate_052_vs_candidate_027|duel_ecart_faible", + "file": "candidate_052_vs_candidate_027", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 23.7 + }, + { + "task_id": 60, + "cle": "candidate_067_vs_candidate_063|duel_ecart_fort", + "file": "candidate_067_vs_candidate_063", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 40.4 + }, + { + "task_id": 61, + "cle": "candidate_005_vs_candidate_063|duel_ecart_faible", + "file": "candidate_005_vs_candidate_063", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 23.7 + }, + { + "task_id": 62, + "file": "candidate_035_vs_candidate_037", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 38.1, + "cle": "candidate_035_vs_candidate_037|duel_ecart_faible" + }, + { + "task_id": 63, + "cle": "candidate_033_vs_candidate_084|duel_ecart_faible", + "file": "candidate_033_vs_candidate_084", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 13.0 + }, + { + "task_id": 64, + "cle": "candidate_067.mid|__control__|0", + "file": "candidate_067.mid", + "degradation": "__control__", + "original_slot": "A", + "choice": "same", + "picked_original": null, + "listened_seconds": 66.9 + }, + { + "task_id": 65, + "cle": "candidate_092_vs_candidate_035|duel_ecart_fort", + "file": "candidate_092_vs_candidate_035", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "same", + "picked_original": null, + "listened_seconds": 0.0 + }, + { + "task_id": 66, + "file": "candidate_009_vs_candidate_036", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 83.4, + "cle": "candidate_009_vs_candidate_036|duel_ecart_faible" + }, + { + "task_id": 67, + "cle": "candidate_085_vs_candidate_037|duel_ecart_fort", + "file": "candidate_085_vs_candidate_037", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 41.1 + }, + { + "task_id": 68, + "cle": "candidate_068_vs_candidate_033|duel_ecart_fort", + "file": "candidate_068_vs_candidate_033", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 21.5 + } + ] +} \ No newline at end of file diff --git a/libretto/annotate.py b/libretto/annotate.py index a6a899f..e97c282 100644 --- a/libretto/annotate.py +++ b/libretto/annotate.py @@ -180,12 +180,62 @@ def build_tasks(corpus_dir: str | Path, seed: int = 1, per_file: int = 2, return tasks +def build_duel_tasks(duels_path: str | Path, seed: int = 1) -> list[dict]: + """Lot de **duels** : deux morceaux DIFFÉRENTS, pas un morceau et sa + version dégradée. + + Le protocole d'écoute a été construit pour valider les dégradations ; + il vaut tel quel pour une autre question, plus directe — *le classement + de Libretto est-il celui de l'oreille ?* On oppose deux candidats que le + moteur sépare, et le côté « original » devient celui qu'il place devant. + Tout le reste est inchangé et c'est le but : ordre tiré au sort, position + équilibrée, paires de contrôle, « aucune différence » toujours possible. + + Le fichier attendu (voir `examples/forge_duels.py`) : + `{"duels": [{"prefere": chemin, "autre": chemin, "etiquette": nom, + "nom": libellé, "ecart": 0.12}, …]}` — `etiquette` sert de + clé de dépouillement, exactement comme un nom de dégradation : la + séparer par tranche d'écart répond à « à partir de quel écart de score + l'oreille suit-elle ? », qui est la vraie question. + """ + data = json.loads(Path(duels_path).read_text(encoding="utf-8")) + rng = random.Random(seed) + tasks: list[dict] = [] + pool: list[str] = [] + for d in data["duels"]: + if not (Path(d["prefere"]).exists() and Path(d["autre"]).exists()): + continue + tasks.append({"file": d.get("nom", Path(d["prefere"]).name), + "path": d["prefere"], "path_autre": d["autre"], + "degradation": d.get("etiquette", "duel"), + "ecart": d.get("ecart")}) + pool.append(d["prefere"]) + if not tasks: + return [] + # Mêmes contrôles que pour les dégradations : sans eux, un taux de + # détection élevé peut n'être qu'un biais de réponse. + n_control = max(4, round(CONTROL_RATIO * len(tasks))) + for i in range(n_control): + path = pool[(i * 7 + 3) % len(pool)] + tasks.append({"file": Path(path).name, "path": path, + "degradation": "__control__"}) + rng.shuffle(tasks) + slots = ["A", "B"] * ((len(tasks) + 1) // 2) + rng.shuffle(slots) + for i, task in enumerate(tasks): + task["id"] = i + task["original_slot"] = slots[i] + return tasks + + def midi_pair(task: dict, seed: int = 1): """(MidiData du côté A, MidiData du côté B) — la position de l'original est déjà résolue, l'appelant n'a aucun moyen de la connaître.""" md = parse_midi(task["path"]) if task["degradation"] == "__control__": other = md + elif task.get("path_autre"): + other = parse_midi(task["path_autre"]) else: rng = random.Random(f"{seed}:{task['file']}:{task['degradation']}") other = DEGRADATIONS[task["degradation"]](md, rng) @@ -199,6 +249,36 @@ def render_task(task: dict, seed: int = 1) -> dict: "A": notes_in_seconds(side_a), "B": notes_in_seconds(side_b)} +def task_key(task: dict, rang: int = 0) -> str: + """Identité **stable** d'une comparaison, indépendante de sa position. + + La reprise s'est longtemps faite sur `task_id`, c'est-à-dire sur le rang + dans le lot. Tant qu'on rejoue le même lot, les deux coïncident ; dès + qu'on l'agrandit — passer de 14 à 60 duels pour obtenir la puissance + qui manquait — tous les rangs glissent, et les jugements déjà rendus se + retrouvent collés à d'autres comparaisons. Silencieusement : rien ne + plante, le fichier reste valide, les chiffres sont faux. + + La clé nomme donc la comparaison elle-même. `rang` ne sert qu'aux + contrôles, qui répètent le même fichier plusieurs fois dans un lot. + """ + if task["degradation"] == "__control__": + return f"{task['file']}|__control__|{rang}" + # Hors contrôle, (fichier, dégradation) est unique dans un lot — et pour + # un duel, `file` est le nom de la paire, donc la paire elle-même. + return f"{task['file']}|{task['degradation']}" + + +def _stamp_keys(tasks: list[dict]) -> None: + """Pose `cle` sur chaque tâche, en numérotant les contrôles répétés.""" + vus: dict[str, int] = {} + for task in tasks: + base = task_key(task) + rang = vus.get(base, 0) + vus[base] = rang + 1 + task["cle"] = task_key(task, rang) + + class _Judgements: def __init__(self, out_path: Path, tasks: list[dict], seed: int, renderer: str = RENDERER): @@ -221,14 +301,35 @@ def __init__(self, out_path: Path, tasks: list[dict], seed: int, f"le rendu actuel est « {renderer} ». Les mélanger rendrait la " "session ininterprétable — reprenez avec un nouveau --out.") self.records = data.get("judgements", []) - - def done_ids(self) -> set[int]: - return {r["task_id"] for r in self.records} + _stamp_keys(self.tasks) + # Fichiers d'avant les clés : on la reconstruit du contenu du + # jugement, dans l'ordre où les contrôles apparaissent — c'est + # exactement ce que `_stamp_keys` vient de faire sur les tâches. + vus: dict[str, int] = {} + for r in sorted(self.records, key=lambda r: r["task_id"]): + if "cle" in r: + continue + base = task_key(r) + rang = vus.get(base, 0) + vus[base] = rang + 1 + r["cle"] = task_key(r, rang) + # Un jugement repris garde sa clé mais reçoit le rang qu'il occupe + # dans CE lot : un fichier où `task_id` désigne autre chose que la + # comparaison jugée est un piège pour le prochain lecteur. Ceux qui + # ne concernent aucune tâche du lot courant sont conservés tels + # quels — ils restent des jugements valides, cumulables. + rangs = {t["cle"]: t["id"] for t in self.tasks} + for r in self.records: + if r["cle"] in rangs: + r["task_id"] = rangs[r["cle"]] + + def done_keys(self) -> set[str]: + return {r["cle"] for r in self.records} def next_task(self) -> dict | None: - done = self.done_ids() + done = self.done_keys() for task in self.tasks: - if task["id"] not in done: + if task["cle"] not in done: return task return None @@ -237,9 +338,10 @@ def record(self, task_id: int, choice: str, listened: float) -> None: if task is None: raise ValueError(f"tâche inconnue : {task_id}") with self.lock: - self.records = [r for r in self.records if r["task_id"] != task_id] + self.records = [r for r in self.records if r["cle"] != task["cle"]] self.records.append({ "task_id": task_id, + "cle": task["cle"], "file": task["file"], "degradation": task["degradation"], "original_slot": task["original_slot"], @@ -255,7 +357,7 @@ def record(self, task_id: int, choice: str, listened: float) -> None: def _flush(self) -> None: payload = {"seed": self.seed, "n_tasks": len(self.tasks), "renderer": self.renderer, - "judgements": sorted(self.records, key=lambda r: r["task_id"])} + "judgements": sorted(self.records, key=lambda r: (r["task_id"], r["cle"]))} self.out_path.write_text( json.dumps(payload, ensure_ascii=False, indent=1), encoding="utf-8") @@ -454,7 +556,7 @@ def do_GET(self): else: payload = render_task(task, seed) payload["n_total"] = len(store.tasks) - payload["n_done"] = len(store.done_ids()) + payload["n_done"] = len(store.done_keys()) self._json(200, payload) elif self.path.startswith("/api/audio/") and wav_cache is not None: try: @@ -479,7 +581,7 @@ def do_POST(self): except (ValueError, KeyError, TypeError) as exc: self._json(400, {"error": str(exc)}) return - self._json(200, {"ok": True, "n_done": len(store.done_ids())}) + self._json(200, {"ok": True, "n_done": len(store.done_keys())}) return Handler @@ -487,13 +589,18 @@ def do_POST(self): def main(corpus_dir: str, out: str, host: str = "127.0.0.1", port: int | None = None, seed: int = 1, per_file: int = 2, only: list[str] | None = None, render: str = "synth") -> int: + duels = Path(corpus_dir).suffix.lower() == ".json" + if only and duels: + print("libretto: --only ne s'applique qu'aux dégradations, pas aux duels") + return 1 if only: inconnues = sorted(set(only) - set(DEGRADATIONS)) if inconnues: print(f"libretto: dégradation(s) inconnue(s) : {', '.join(inconnues)}") print(f" disponibles : {', '.join(sorted(DEGRADATIONS))}") return 1 - tasks = build_tasks(corpus_dir, seed=seed, per_file=per_file, only=only) + tasks = (build_duel_tasks(corpus_dir, seed=seed) if duels + else build_tasks(corpus_dir, seed=seed, per_file=per_file, only=only)) if not tasks: print(f"libretto: aucun MIDI exploitable dans {corpus_dir}") return 1 @@ -529,7 +636,7 @@ def main(corpus_dir: str, out: str, host: str = "127.0.0.1", n_control = sum(1 for t in tasks if t["degradation"] == "__control__") print(f"{len(tasks)} comparaisons ({n_control} paires de contrôle identiques), " f"rendu {renderer}") - print(f"déjà jugées : {len(store.done_ids())} → jugements dans {out}") + print(f"déjà jugées : {len(store.done_keys())} → jugements dans {out}") print(f"écoute comparée : http://{host}:{real} (Ctrl-C pour arrêter)") try: httpd.serve_forever() diff --git a/libretto/cli.py b/libretto/cli.py index 3235388..cc0f4cb 100644 --- a/libretto/cli.py +++ b/libretto/cli.py @@ -115,7 +115,10 @@ def main(argv: list[str] | None = None) -> int: p_ann = sub.add_parser( "annotate", help="écoute comparée A/B en aveugle : recueille des jugements humains") - p_ann.add_argument("corpus", help="dossier de fichiers .mid") + p_ann.add_argument("corpus", + help="dossier de fichiers .mid (paires original/dégradé), " + "ou fichier .json de duels entre deux morceaux " + "différents (voir examples/forge_duels.py)") p_ann.add_argument("--out", metavar="JSON", default="jugements.json", help="fichier de jugements (repris s'il existe)") p_ann.add_argument("--host", default="127.0.0.1") diff --git a/resultats_ecoute.md b/resultats_ecoute.md index 56e2ae1..de6d1b6 100644 --- a/resultats_ecoute.md +++ b/resultats_ecoute.md @@ -932,6 +932,134 @@ frontière est une ondulation parmi les ondulations. Quatre voies instruites, chiffres consignés, aucune ne paie. Le front ferme en acquittement, pas en promesse. +### Session 6 — le classement de Forge devant une oreille : non concluant, et sous-dimensionné par construction + +Toutes les sessions précédentes ont testé des **dégradations** : un morceau +contre sa version abîmée. Aucune n'a testé un **classement** : un morceau +contre un autre morceau. C'est pourtant l'hypothèse sur laquelle repose tout +ce que Forge affirme — que le candidat qu'il place devant est réellement le +mieux construit. + +Le protocole d'écoute s'y prête sans rien changer : le côté « original » +devient celui que le moteur préfère (`build_duel_tasks`, +`examples/forge_duels.py`). Lot : 48 candidats `forge_loops` (pack de loops, +graine 3), 14 duels répartis en deux tranches d'écart de score, 4 paires de +contrôle, rendu v3-instrument, position équilibrée 9/9. + +| tranche | écart SMS | l'oreille suit le moteur | IC95 | +|---|---|---|---| +| `duel_ecart_fort` | 0.102 – 0.199 | 43 % (3/7) | 0.16 – 0.75 | +| `duel_ecart_faible` | 0.004 – 0.026 | 57 % (4/7) | 0.25 – 0.84 | +| **ensemble** | | **50 % (7/14)** | **0.27 – 0.73** | + +Contrôles : 2 « aucune différence » sur 4 — les deux autres ont été tranchés +sur des extraits identiques. Bruit de réponse réel, mais 4 contrôles ne +l'estiment qu'à la louche. + +**Le vrai enseignement est méthodologique, et il est à charge.** Ce lot ne +pouvait pas conclure. Test binomial bilatéral contre 0.5, α = 0.05 : sur +n = 7, le seul résultat significatif possible est **7/7** — 6/7 donne déjà +p = 0.125. La puissance de la tranche pour une vraie détection de 0.80 est +de **21 %**. Autrement dit, quatre sessions sur cinq auraient rendu « non +concluant » même si le classement était excellent. Le dimensionnement doit +précéder la session, pas la commenter : + +| vraie détection | jugements tranchés nécessaires par tranche (puissance 80 %) | +|---|---| +| 0.80 | 20 | +| 0.75 | 30 | +| 0.70 | 49 | +| 0.65 | 90 | + +**Ce que le lot n'autorise pas** : conclure que le classement ne vaut rien. +50 % sur 14 jugements est compatible avec à peu près n'importe quoi entre +« aveugle » et « bon ». **Ce qu'il n'autorise pas non plus** : continuer à +présenter le n°1 de Forge comme le mieux construit sans préciser que +personne ne l'a jamais vérifié à l'oreille. + +Deux observations à traiter comme des pistes, pas comme des résultats (n = 2 +chacune) : `candidate_000`, le plus bas score du lot (0.567), a gagné ses +**deux** duels contre des candidats mieux notés ; `candidate_032` (score +0.813 mais fiabilité 0.64) a perdu les **deux** siens. Si quelque chose se +confirme là, ce serait que la fiabilité pèse davantage que le score dans ce +que l'oreille entend — ce qui est déjà la règle de classement de Forge +(tranche de fiabilité d'abord), mais pas au point de départager deux +candidats de la même tranche. + +Suite : une session dimensionnée (≥ 30 jugements tranchés par tranche, deux +annotateurs pour la décorrélation des erreurs, comme en session 5b). Les +jugements de celle-ci sont dans `jugements_duels.json`. + +**Le lot est cumulable — il ne l'était pas.** La reprise se faisait sur +`task_id`, c'est-à-dire sur le rang dans le lot : agrandir le lot décale +tous les rangs, et les 14 jugements rendus se seraient recollés à d'autres +comparaisons. Sans rien casser — fichier valide, chiffres faux. La reprise +suit désormais l'identité de la comparaison (`task_key`), les jugements +repris sont re-rattachés à leur tâche dans le nouveau lot, et ceux qui n'y +figurent plus sont conservés tels quels. Le lot de la session 7 (30 duels +par tranche, tiré de 96 candidats) reprend donc les 14 duels et les +contrôles déjà jugés : 53 comparaisons neuves à écouter, un seul +dépouillement à la fin. + +### Session 7 — le lot dimensionné : un signal sur les gros écarts, rien sur les petits + +Session 6 refaite à la taille qu'elle aurait dû avoir : 96 candidats +`forge_loops` (graine 3), **30 duels par tranche**, 11 contrôles, les 18 +jugements de la session 6 repris et cumulés — 71 jugements en tout, un seul +dépouillement, même annotateur, même rendu v3-instrument. 54 minutes +d'écoute effective, médiane 40 s par comparaison. + +| tranche | écart SMS | l'oreille suit le moteur | IC95 | p unilatéral | +|---|---|---|---|---| +| `duel_ecart_fort` | 0.102 – 0.206 | **18/29 = 62 %** | 0.44 – 0.77 | 0.132 | +| `duel_ecart_faible` | 0.001 – 0.038 | 13/30 = 43 % | 0.27 – 0.61 | 0.819 | +| ensemble | | 31/59 = 53 % | 0.40 – 0.65 | 0.397 | + +**Aucun des deux n'est significatif.** Mais les deux ne disent pas la même +chose, et c'est là qu'est le résultat. + +**Le classement fin ne s'entend pas.** Sur les écarts de 0.001 à 0.038 — +ceux qui décident du n°1 contre le n°3 dans un rapport Forge — l'oreille est +à 43 %, c'est-à-dire au hasard, du mauvais côté. Ce n'est pas une absence de +puissance : à n = 30, un effet réel de 0.75 aurait été détecté 8 fois sur 10. +Départager deux candidats que le SMS sépare de deux centièmes n'a pas de +correspondant audible. + +**Sur les gros écarts, il y a un signal — trop faible pour ce lot.** 62 % va +dans le sens attendu, l'écart entre les deux tranches aussi (Fisher +unilatéral p = 0.119), mais rien n'est établi. Le dimensionnement de la +session visait 0.75 ; l'effet, s'il existe, est plus près de 0.62, et pour +celui-là 30 duels ne donnent que **29 % de puissance**. Il en faudrait +**106 par tranche** — soit une dizaine d'heures d'écoute pour un annotateur +seul, ou trois à quatre annotateurs. C'est le coût réel de la question, il +est maintenant chiffré. + +**Le biais de réponse est le point faible, et il se répète.** Sur 11 paires +identiques, l'annotateur n'a répondu « aucune différence » que 3 fois : les +8 autres fois il a inventé une préférence. Le harnais le signale comme +SUSPECT, et il a raison de le faire — mais le chiffre à regarder est le +contraste : **27 % de « aucune différence » sur les paires identiques contre +1.7 % sur les duels réels** (3/11 contre 1/60, Fisher unilatéral +p = 0.011). L'annotateur n'appuie donc pas au hasard : il reconnaît bien les +paires indiscernables, il refuse simplement de le dire. Conséquence +arithmétique : chaque préférence inventée est un tirage à pile ou face qui +tire le taux observé vers 0.5. **Les 62 % sont donc un plancher, pas une +estimation** — l'accord réel, sur les comparaisons où quelque chose +s'entend, est plus haut. C'est exactement la réserve notée après la session 1 +(« répondre plus souvent : je n'entends pas de différence ») ; elle n'a pas +été suivie, et elle coûte de la puissance à chaque session. + +Aucun biais de position : original en A 31 fois sur 59, et l'annotateur +choisit A 23 fois contre B 36 — un penchant pour le second extrait entendu, +sans effet systématique puisque les positions sont équilibrées. + +**Ce que la session autorise à écrire.** Forge, sur du loop arrangé, est un +**triage grossier** : les gros écarts de score portent peut-être un signal +audible, les petits n'en portent pas. C'est le même verdict que celui déjà +établi sur du transcrit — écarter le bas est défendable, couronner le n°1 ne +l'est pas — mais il vient cette fois d'une oreille, pas d'une corrélation de +rangs. + ### 2. Autres priorités - Un second annotateur : **fait** (session 5b) — réplication indépendante, diff --git a/scripts/mesure_tonalite.py b/scripts/mesure_tonalite.py index 301ea01..9757ec1 100644 --- a/scripts/mesure_tonalite.py +++ b/scripts/mesure_tonalite.py @@ -40,126 +40,23 @@ import argparse import json -import re import sys from collections import Counter, defaultdict from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "examples")) from libretto.axes import PC_NAMES, SenseOfMusicalStructure, estimate_key # noqa: E402 from libretto.builder import build_score # noqa: E402 from libretto.midi import parse_midi # noqa: E402 -# ────────────────────────────────────────────── -# Étiquettes : lecture du chemin -# ────────────────────────────────────────────── - -# Tonique A-G, altération optionnelle, mode optionnel séparé ou collé -# (`Dm`, `F_m`, `Emin`, `Cmn`, `F#`, `C_maj`). Les gardes de part et d'autre -# évitent d'attraper l'initiale d'un mot : « Bonfire » n'est pas un si. -KEY_RE = re.compile( - r"(? tuple[int, str | None] | None: - """(classe de hauteur, 'maj'|'min'|None) lue dans `text`, ou None. - - Une tonique **nue** — sans altération ni mode — n'est retenue qu'en - capitale : `Moves_B_95` est un si, `d cymbl` est une convention de - nommage de batterie. Renvoie None aussi quand le texte annonce deux - toniques différentes : une étiquette contradictoire n'est pas une - vérité terrain. - """ - found: dict[int, str | None] = {} - for m in KEY_RE.finditer(text): - letter, acc, mode = m.group(1), m.group(2), m.group(3) - if not acc and not mode and not letter.isupper(): - continue - pc = BASE_PC[letter.upper()] - if acc == "#": - pc = (pc + 1) % 12 - elif acc and acc.lower() == "b": - pc = (pc - 1) % 12 - norm = None - if mode: - norm = "maj" if mode.lower() in ("maj", "major") else "min" - # un mode explicite l'emporte sur une occurrence muette de la même tonique - if pc not in found or (norm and not found[pc]): - found[pc] = norm - if len(found) != 1: - return None - pc, mode = next(iter(found.items())) - return pc, mode - - -def label_for(path: Path, root: Path) -> tuple[int, str | None, str] | None: - """Étiquette du fichier, la mieux étayée d'abord. - - Une étiquette **avec mode** l'emporte sur une tonique nue, où qu'elles - se trouvent — parce que ces packs nomment souvent chaque boucle par sa - fondamentale locale à l'intérieur d'un kit dont le dossier, lui, annonce - la tonalité : `KIT_1_PAD_SOULFUL_Eb_100BPM.mid` dans `KIT_1_Gmin_100BPM/` - n'est pas un pad en mi bémol, c'est le VI d'un sol mineur. À force égale, - le nom du fichier passe avant le dossier (plus précis). Le troisième - champ dit d'où vient l'étiquette retenue. - """ - candidates: list[tuple[int, str | None, str]] = [] - lab = parse_key_label(path.stem) - if lab: - candidates.append((lab[0], lab[1], "fichier")) - for parent in path.relative_to(root).parents: - if str(parent) in (".", ""): - continue - lab = parse_key_label(parent.name) - if lab: - candidates.append((lab[0], lab[1], "dossier")) - if not candidates: - return None - with_mode = [c for c in candidates if c[1]] - return with_mode[0] if with_mode else candidates[0] - - -def role_of(path: Path) -> str: - low = path.stem.lower() - for w in DRUM_WORDS: - if w in low: - return "batterie" - for w in ROLE_WORDS: - if w in low: - return w - return "?" - - -# ────────────────────────────────────────────── -# Estimateurs -# ────────────────────────────────────────────── - -def hist_brut(md) -> list[float]: - """Histogramme des classes de hauteur pondéré par la durée, canal 9 exclu.""" - h = [0.0] * 12 - for n in md.notes: - if n.channel == 9: - continue - h[n.pitch % 12] += max(1, n.end - n.start) - return h +# La lecture des étiquettes d'un pack vit dans `examples/loop_index.py` : ce +# harnais MESURE ce que cette lecture vaut, il ne peut pas en tenir une +# seconde copie — deux parseurs qui divergent, et le chiffre ne dit plus de +# quoi il parle. +from loop_index import (hist_brut, label_for, # noqa: E402,F401 + parse_key_label, role_of) def hist_pipeline(md) -> list[float]: diff --git a/tests/test_annotate.py b/tests/test_annotate.py index 9f142b5..f71c4e6 100644 --- a/tests/test_annotate.py +++ b/tests/test_annotate.py @@ -19,7 +19,9 @@ from libretto.agreement import CONTROL, analyse, format_report from libretto.annotate import ( RENDERER, + build_duel_tasks, build_tasks, + midi_pair, notes_in_seconds, render_task, _Judgements, @@ -171,6 +173,69 @@ def test_drums_are_flagged(self): self.assertTrue(any(n[4] == 0 for n in notes)) +class TestDuels(unittest.TestCase): + """Mode duel : deux morceaux DIFFÉRENTS, le « original » étant celui que + le moteur place devant. Les garanties du protocole doivent tenir à + l'identique — sans elles, le taux mesuré ne dit rien.""" + + def _duels(self, tmp: Path, n: int = 6) -> Path: + corpus = _corpus(tmp, n=n) + fichiers = sorted(corpus.glob("*.mid")) + duels = [{"prefere": str(fichiers[i]), "autre": str(fichiers[i + 1]), + "etiquette": "duel_ecart_fort" if i % 2 else "duel_ecart_faible", + "nom": f"{fichiers[i].stem}_vs_{fichiers[i + 1].stem}", + "ecart": 0.1} + for i in range(len(fichiers) - 1)] + path = tmp / "duels.json" + path.write_text(json.dumps({"duels": duels}), encoding="utf-8") + return path + + def test_tache_oppose_deux_fichiers_distincts(self): + with tempfile.TemporaryDirectory() as d: + tasks = build_duel_tasks(self._duels(Path(d)), seed=1) + duel = next(t for t in tasks if t["degradation"] != CONTROL) + self.assertNotEqual(duel["path"], duel["path_autre"]) + a, b = midi_pair(duel, seed=1) + self.assertNotEqual([n.pitch for n in a.notes], [n.pitch for n in b.notes]) + + def test_controles_presents_et_identiques(self): + with tempfile.TemporaryDirectory() as d: + tasks = build_duel_tasks(self._duels(Path(d)), seed=1) + controles = [t for t in tasks if t["degradation"] == CONTROL] + self.assertGreaterEqual(len(controles), 4) + a, b = midi_pair(controles[0], seed=1) + self.assertEqual([n.pitch for n in a.notes], [n.pitch for n in b.notes]) + + def test_position_equilibree_et_deterministe(self): + with tempfile.TemporaryDirectory() as d: + duels = self._duels(Path(d), n=20) + tasks = build_duel_tasks(duels, seed=4) + part_a = sum(1 for t in tasks if t["original_slot"] == "A") / len(tasks) + self.assertGreater(part_a, 0.3) + self.assertLess(part_a, 0.7) + self.assertEqual([(t["file"], t["original_slot"]) for t in tasks], + [(t["file"], t["original_slot"]) + for t in build_duel_tasks(duels, seed=4)]) + + def test_le_client_ignore_qui_est_prefere(self): + with tempfile.TemporaryDirectory() as d: + tasks = build_duel_tasks(self._duels(Path(d)), seed=1) + paquet = render_task(tasks[0], seed=1) + self.assertEqual(set(paquet), {"id", "n_total", "A", "B"}) + + def test_fichier_manquant_ignore_sans_planter(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + path = self._duels(tmp) + data = json.loads(path.read_text(encoding="utf-8")) + data["duels"].append({"prefere": str(tmp / "absent.mid"), + "autre": str(tmp / "p0.mid"), + "etiquette": "duel_ecart_fort"}) + path.write_text(json.dumps(data), encoding="utf-8") + tasks = build_duel_tasks(path, seed=1) + self.assertTrue(all(Path(t["path"]).exists() for t in tasks)) + + class TestRendererVersioning(unittest.TestCase): """Deux rendus audio sont deux expériences : les sessions 1-2 (v1-volume) ont conclu que la dynamique ne s'entend pas, conclusion qui ne vaut que @@ -247,9 +312,41 @@ def test_records_and_resumes(self): store.record(first["id"], "A", 12.0) # une session interrompue reprend où elle s'est arrêtée store2 = _Judgements(out, tasks, 1) - self.assertIn(first["id"], store2.done_ids()) + self.assertIn(first["cle"], store2.done_keys()) self.assertNotEqual(store2.next_task()["id"], first["id"]) + def test_reprise_sur_un_lot_agrandi(self): + """La reprise suit la comparaison, pas son rang. + + Agrandir un lot décale tous les rangs. Tant que la reprise se faisait + sur `task_id`, les jugements déjà rendus se recollaient à d'autres + comparaisons — sans rien casser, et avec des chiffres faux. + """ + with tempfile.TemporaryDirectory() as d: + corpus = _corpus(Path(d), n=6) + petit = build_tasks(corpus, seed=1, per_file=1) + out = Path(d) / "j.json" + store = _Judgements(out, petit, 1) + juge = store.next_task() + store.record(juge["id"], "A", 9.0) + + grand = build_tasks(corpus, seed=1, per_file=2) # lot élargi + self.assertGreater(len(grand), len(petit)) + store2 = _Judgements(out, grand, 1) + self.assertEqual(store2.done_keys(), {juge["cle"]}) + # la comparaison déjà jugée n'est pas reproposée… + vues = set() + while (t := store2.next_task()) is not None and len(vues) < len(grand): + if t["cle"] in vues: + break + vues.add(t["cle"]) + store2.record(t["id"], "same", 1.0) + self.assertNotIn(juge["cle"], vues) + # …et le jugement repris pointe sur la bonne tâche du nouveau lot + repris = next(r for r in store2.records if r["cle"] == juge["cle"]) + self.assertEqual(repris["task_id"], + next(t["id"] for t in grand if t["cle"] == juge["cle"])) + def test_picked_original_is_derived_not_asked(self): """Le client envoie « A » ou « B » ; c'est le serveur, seul à savoir où était l'original, qui en déduit la bonne réponse.""" diff --git a/tests/test_forge_loops.py b/tests/test_forge_loops.py new file mode 100644 index 0000000..f71536b --- /dev/null +++ b/tests/test_forge_loops.py @@ -0,0 +1,147 @@ +"""Tests de Forge sur loops — ce qui doit tenir dans l'arrangement. + +Un arrangement raté ne lève pas d'exception : il sort un MIDI qui joue. +Ce qui est vérifié ici, c'est ce qui se voit seulement en relisant le +fichier — les percussions sur le canal 9 (sinon Libretto les compte comme +des hauteurs et l'analyse harmonique est fausse), les registres qui ne se +recouvrent pas, les marqueurs de section (sans eux la segmentation est +devinée), et le déterminisme : à graine égale, même verdict. +""" + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "examples")) + +from libretto.midi import parse_midi, write_midi # noqa: E402 +from forge_loops import (REGISTRES, familles_arrangeables, # noqa: E402 + forge_loops, octave_de_registre, tirer_plan) +from loop_index import index_pack # noqa: E402 + + +def ecrire_loop(path: Path, pitches, channel=0, bars=2, accords=False, vel=90): + notes = [] + for beat in range(bars * 4): + base = pitches[beat % len(pitches)] + hauteurs = [base, base + 3, base + 7] if accords else [base] + for p in hauteurs: + notes.append((float(beat), 0.9, p, vel, channel)) + path.parent.mkdir(parents=True, exist_ok=True) + write_midi(path, [notes], ppq=480, bpm=100.0) + + +def pack_fixture(root: Path) -> None: + """Un pack minuscule mais complet : quatre pupitres, une seule famille.""" + d = root / "FixturePack" / "MIDI" + ecrire_loop(d / "Sunrise_Am_100_Chords.mid", [57, 60, 62], accords=True) + ecrire_loop(d / "Sunrise_Am_100_Piano.mid", [45, 48, 52], accords=True) + ecrire_loop(d / "Sunrise_Am_100_Bass.mid", [33, 36, 40]) + ecrire_loop(d / "Sunrise_Am_100_Lead.mid", [69, 72, 76, 74]) + ecrire_loop(d / "Sunrise_Am_100_Kick.mid", [36], channel=9) + ecrire_loop(d / "Sunrise_Am_100_Hats.mid", [42], channel=9) + + +class TestMatiere(unittest.TestCase): + def test_famille_arrangeable(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + pack_fixture(root) + loops, _ = index_pack(root) + arr = familles_arrangeables(loops) + self.assertEqual(len(arr), 1) + pupitres = {lp.pupitre for lp in next(iter(arr.values()))} + self.assertTrue({"harmonie", "basse", "melodie"} <= pupitres) + + def test_famille_pauvre_ecartee(self): + # deux pads et rien d'autre : un seul pupitre tonal, pas un morceau + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + ecrire_loop(root / "Solo_Am_100_Pad.mid", [57, 60], accords=True) + ecrire_loop(root / "Solo_Am_100_Pad2.mid", [57, 62], accords=True) + loops, _ = index_pack(root) + self.assertEqual(familles_arrangeables(loops), {}) + + def test_octave_de_registre(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + pack_fixture(root) + loops, _ = index_pack(root) + for lp in loops: + if lp.pupitre == "batterie": + self.assertEqual(octave_de_registre(lp), 0) + continue + vise = lp.hauteur_mediane + octave_de_registre(lp) + bas, haut = REGISTRES[lp.pupitre] + # à une octave près de la bande : le décalage est un multiple + # de 12, il ne peut pas viser le centre exactement + self.assertTrue(bas - 12 <= vise <= haut + 12, + f"{lp.nom} {lp.pupitre} → {vise} hors de {bas}-{haut}") + + +class TestArrangement(unittest.TestCase): + def test_rendu_complet(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + pack_fixture(root) + out = root / "sortie" + report = forge_loops(root, out, n=4, seed=1, keep_all=True) + + self.assertEqual(report["n_generated"], 4) + self.assertIsNotNone(report["winner"]) + + md = parse_midi(out / "forge_winner.mid") + self.assertTrue(md.notes) + # marqueurs de section : sans eux, la segmentation est devinée + self.assertGreaterEqual(len(md.markers), 3) + # percussions sur le canal 9, et rien d'autre dessus + perc = [n for n in md.notes if n.channel == 9] + self.assertTrue(perc) + self.assertTrue(all(n.pitch in (36, 42) for n in perc)) + # la basse reste sous la mélodie + graves = [n.pitch for n in md.notes if n.channel != 9] + self.assertLess(min(graves), 55) + self.assertGreater(max(graves), 65) + + def test_deterministe(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + pack_fixture(root) + a = forge_loops(root, root / "a", n=3, seed=7) + b = forge_loops(root, root / "b", n=3, seed=7) + self.assertEqual([c["score"] for c in a["leaderboard"]], + [c["score"] for c in b["leaderboard"]]) + self.assertEqual(a["winner"]["form"], b["winner"]["form"]) + + def test_graines_differentes_donnent_des_plans_differents(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + pack_fixture(root) + loops, _ = index_pack(root) + arr = familles_arrangeables(loops) + import random + plans = [tirer_plan(loops, arr, random.Random(f"t:{i}")) for i in range(12)] + self.assertGreater(len({p.forme for p in plans}), 1) + + def test_index_partage_non_contamine(self): + """Un emprunt transposé ne doit pas déteindre sur les candidats + suivants : la transposition vit dans le plan, pas dans l'index.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + pack_fixture(root) + # seconde famille, autre tonalité : rend l'emprunt possible + d = root / "FixturePack" / "MIDI" + ecrire_loop(d / "Dusk_Dm_100_Chords.mid", [50, 53, 57], accords=True) + ecrire_loop(d / "Dusk_Dm_100_Lead.mid", [74, 77, 81]) + loops, _ = index_pack(root) + arr = familles_arrangeables(loops) + import random + for i in range(20): + tirer_plan(loops, arr, random.Random(f"emprunt:{i}")) + self.assertTrue(all(not hasattr(lp, "transposition") for lp in loops)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_loop_index.py b/tests/test_loop_index.py new file mode 100644 index 0000000..522921d --- /dev/null +++ b/tests/test_loop_index.py @@ -0,0 +1,111 @@ +"""Tests de l'index de pack — tempo, pupitre, famille. + +L'index est la seule chose que `forge_loops` sait d'un pack : une boucle +rangée dans le mauvais pupitre joue au mauvais moment dans le mauvais +registre, et une famille mal découpée mélange deux tonalités dans le même +morceau. Rien de tout ça ne casse bruyamment — d'où ces tests. +""" + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "examples")) + +from libretto.midi import write_midi # noqa: E402 +from loop_index import _pupitre, familles, index_pack, parse_bpm # noqa: E402 + + +def ecrire_loop(path: Path, pitches, channel=0, bars=2, accords=False): + """Boucle minimale : une note (ou un accord) par temps.""" + notes = [] + for beat in range(bars * 4): + base = pitches[beat % len(pitches)] + hauteurs = [base, base + 4, base + 7] if accords else [base] + for p in hauteurs: + notes.append((float(beat), 0.9, p, 90, channel)) + path.parent.mkdir(parents=True, exist_ok=True) + write_midi(path, [notes], ppq=480, bpm=100.0) + + +class TestTempo(unittest.TestCase): + def test_formes_reconnues(self): + for texte, attendu in [ + ("Bonfire_Dm_100_Piano_Chords", 100.0), + ("SOSO_02_96bpm_MIDI_Hat", 96.0), # le « 02 » est une prise + ("t2_Cmn_134.23bpm midi", 134.23), + ("Kit 4 Eb 090", 90.0), + ("KIT_1_BASS_Bb_100BPM", 100.0), + ]: + self.assertEqual(parse_bpm(texte), attendu, texte) + + def test_hors_bornes_et_absents(self): + for texte in ("loop_01", "take_12", "melodie_2000", "Chat To Me"): + self.assertIsNone(parse_bpm(texte), texte) + + +class TestPupitre(unittest.TestCase): + def test_le_nom_fait_foi(self): + self.assertEqual(_pupitre("bass", 1.0, 40, False), "basse") + self.assertEqual(_pupitre("808", 1.0, 30, False), "basse") + self.assertEqual(_pupitre("chord", 1.0, 60, False), "harmonie") + self.assertEqual(_pupitre("lead", 3.0, 72, False), "melodie") + + def test_le_contenu_tranche_quand_le_nom_se_tait(self): + # polyphonie franche -> accompagnement ; monodie grave -> basse + self.assertEqual(_pupitre("?", 3.0, 60, False), "harmonie") + self.assertEqual(_pupitre("?", 1.0, 40, False), "basse") + self.assertEqual(_pupitre("?", 1.0, 74, False), "melodie") + + def test_percussion_prioritaire(self): + # canal 9 : même un nom tonal ne rattrape pas une piste de batterie + self.assertEqual(_pupitre("piano", 2.0, 60, True), "batterie") + + +class TestIndex(unittest.TestCase): + def test_familles_et_etiquettes(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + ecrire_loop(root / "Bonfire_Am_100_Chords.mid", [57, 60, 64], accords=True) + ecrire_loop(root / "Bonfire_Am_100_Bass.mid", [33, 35, 40]) + ecrire_loop(root / "Bonfire_Am_100_Kick.mid", [36], channel=9) + ecrire_loop(root / "Moody_Dm_90_Chords.mid", [50, 53, 57], accords=True) + loops, stats = index_pack(root) + + self.assertEqual(stats["total"], 4) + par_nom = {lp.nom: lp for lp in loops} + + self.assertEqual(par_nom["Bonfire_Am_100_Chords"].pupitre, "harmonie") + self.assertEqual(par_nom["Bonfire_Am_100_Bass"].pupitre, "basse") + self.assertEqual(par_nom["Bonfire_Am_100_Kick"].pupitre, "batterie") + + # tonalité : étiquette du nom, jamais estimée quand elle est là + chords = par_nom["Bonfire_Am_100_Chords"] + self.assertEqual((chords.tonique, chords.mode), (9, "min")) + self.assertEqual(chords.source_tonalite, "fichier") + self.assertEqual(chords.bpm, 100.0) + + # une percussion ne porte pas de tonalité, même étiquetée + self.assertIsNone(par_nom["Bonfire_Am_100_Kick"].tonique) + + # deux chansons du même dossier = deux familles + fam = familles(loops) + self.assertEqual(par_nom["Bonfire_Am_100_Chords"].famille, + par_nom["Bonfire_Am_100_Bass"].famille) + self.assertNotEqual(par_nom["Bonfire_Am_100_Chords"].famille, + par_nom["Moody_Dm_90_Chords"].famille) + self.assertEqual(len(fam), 3) # Bonfire tonal, Bonfire batterie, Moody + + def test_ambitus_et_mediane(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + ecrire_loop(root / "Test_Cm_100_Lead.mid", [72, 76, 79]) + lp = index_pack(root)[0][0] + self.assertEqual(lp.ambitus, [72, 79]) + self.assertTrue(72 <= lp.hauteur_mediane <= 79) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mesure_tonalite.py b/tests/test_mesure_tonalite.py index 107a59f..1879046 100644 --- a/tests/test_mesure_tonalite.py +++ b/tests/test_mesure_tonalite.py @@ -13,9 +13,10 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "examples")) -from scripts.mesure_tonalite import (error_kind, label_for, # noqa: E402 - parse_key_label, role_of) +from loop_index import label_for, parse_key_label, role_of # noqa: E402 +from scripts.mesure_tonalite import error_kind # noqa: E402 class TestParseEtiquette(unittest.TestCase):