From c5f173697579b8b002ad655d831b233093532998 Mon Sep 17 00:00:00 2001 From: klodynlov Date: Mon, 27 Jul 2026 16:14:03 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(forge):=20forge=5Floops=20=E2=80=94=20?= =?UTF-8?q?arranger=20un=20pack=20de=20loops,=20laisser=20le=20SMS=20tranc?= =?UTF-8?q?her?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il manquait à Forge la source la plus banale d'un studio : un pack de loops. La matière y est humaine — ce que `make_corpus` ne sait pas inventer et qu'une transcription dégrade. Ce qu'un pack n'a pas, c'est une forme : Forge la cherche. `loop_index.py` lit ce que les noms annoncent (tonalité, tempo, instrument) et mesure ce qu'ils taisent (longueur, polyphonie, tessiture). La tonalité vient de l'étiquette, jamais de l'estimateur quand elle existe — mesuré à 0.400 sur une boucle isolée. Une famille (dossier + tonalité + tempo) est ce qui s'arrange ensemble sans transposer ; seule la batterie se prête d'une famille à l'autre. Sur 820 fichiers : 800 indexés, 76 familles arrangeables. `forge_loops.py` tire N plans (forme, sections, effectif, courbe d'énergie, parfois une modulation ou une mélodie empruntée), tuile, écrit les marqueurs, et réutilise tel quel le classement fiabilité-d'abord de `forge.py`. À matière égale — même pack Naija Waves — le plan cherché bat le plan écrit à la main : 0.839 contre 0.760. Contre le générateur synthétique à budget égal (48 candidats, 3 graines), match nul : gagnant médian 0.849 contre 0.863, meilleur score 0.893 contre 0.884, peloton 0.757 contre 0.786. Par groupe : la forme monte (A 0.846 vs 0.766), la cohérence aussi (F 0.649 vs 0.500), la mélodie s'effondre (C 0.597 vs 0.834, l'axe 15 à lui seul 0.471 vs 0.956). Deux corrections essayées et réfutées, gardées en commentaire : accompagnement sous la mélodie (C inchangé, meilleur score 0.908 → 0.899) et mélodie plus fréquente (C 0.597 → 0.574). Reste l'explication la plus économique — les lignes d'un pack ne satisfont pas des bandes de tolérance écrites sous les mêmes hypothèses que le générateur maison. `scripts/mesure_tonalite.py` cesse de dupliquer la lecture des étiquettes : elle vit dans `loop_index.py`, le harnais la mesure. Mêmes chiffres après déduplication (0.400 / 0.334). Co-Authored-By: Claude Opus 5 --- README.md | 75 ++++++ examples/forge_loops.py | 468 ++++++++++++++++++++++++++++++++++ examples/loop_index.py | 397 ++++++++++++++++++++++++++++ scripts/mesure_tonalite.py | 117 +-------- tests/test_forge_loops.py | 147 +++++++++++ tests/test_loop_index.py | 111 ++++++++ tests/test_mesure_tonalite.py | 5 +- 7 files changed, 1208 insertions(+), 112 deletions(-) create mode 100644 examples/forge_loops.py create mode 100644 examples/loop_index.py create mode 100644 tests/test_forge_loops.py create mode 100644 tests/test_loop_index.py diff --git a/README.md b/README.md index 165982c..b70306b 100644 --- a/README.md +++ b/README.md @@ -525,6 +525,81 @@ 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. + +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_loops.py b/examples/forge_loops.py new file mode 100644 index 0000000..25fde26 --- /dev/null +++ b/examples/forge_loops.py @@ -0,0 +1,468 @@ +""" +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 autres vont aux pupitres tonaux. +DRUMS_CHANNEL = 9 +TONAL_CHANNELS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15] + +# 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]] = [] + canaux = iter(TONAL_CHANNELS) + + def prendre(pupitre: str, n: int) -> None: + dispo = plan.pupitres.get(pupitre) or [] + for lp in dispo[:n]: + choix.append((lp, next(canaux, 15))) + + 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/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_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): From 1b682b0c101f6026a8f014d2992c311b0d434df5 Mon Sep 17 00:00:00 2001 From: klodynlov Date: Mon, 27 Jul 2026 16:49:48 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(annotate):=20duels=20=E2=80=94=20confr?= =?UTF-8?q?onter=20un=20classement=20=C3=A0=20l'oreille,=20pas=20une=20d?= =?UTF-8?q?=C3=A9gradation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cinq sessions d'écoute ont validé des dégradations : un morceau contre sa version abîmée. Aucune n'avait testé un classement — un morceau contre un autre — qui est pourtant l'hypothèse sur laquelle Forge repose tout entier. `build_duel_tasks` oppose deux fichiers distincts, le côté « original » devenant celui que le moteur préfère. Contrôles, équilibrage des positions, anonymat du paquet client : inchangés, et c'est le but. `examples/forge_duels.py` construit les paires depuis n'importe quel forge_report.json, par tranche d'écart de score, en plafonnant la durée et en empêchant un candidat de monopoliser une tranche. Première session (session 6 dans resultats_ecoute.md) : 14 duels, 4 contrôles, rendu v3-instrument. Non concluante — 50 % (IC95 0.27-0.73), 43 % sur les écarts forts, 57 % sur les faibles. Le verdict qui compte est méthodologique et il est à charge : le lot ne pouvait pas conclure. Sur n = 7 par tranche, le seul résultat significatif possible était 7/7 ; la puissance pour une vraie détection de 0.80 était de 21 %. Dimensionnement consigné : 20, 30, 49 ou 90 jugements tranchés par tranche pour détecter 0.80, 0.75, 0.70 ou 0.65. Le lot n'autorise pas à conclure que le classement ne vaut rien. Il n'autorise plus non plus à présenter le n°1 de Forge comme le mieux construit sans préciser qu'aucune oreille ne l'a jamais confirmé. Aussi : canaux MIDI par pupitre dans forge_loops, pour que FluidSynth joue une basse comme une basse. Sans effet sur les scores — le moteur ne lit que « canal 9 ou pas », gagnant toujours 0.832 à graine égale. Co-Authored-By: Claude Opus 5 --- README.md | 24 ++++++ examples/forge_duels.py | 143 ++++++++++++++++++++++++++++++++++ examples/forge_loops.py | 13 ++-- jugements_duels.json | 169 ++++++++++++++++++++++++++++++++++++++++ libretto/annotate.py | 57 +++++++++++++- libretto/cli.py | 5 +- resultats_ecoute.md | 59 ++++++++++++++ tests/test_annotate.py | 65 ++++++++++++++++ 8 files changed, 528 insertions(+), 7 deletions(-) create mode 100644 examples/forge_duels.py create mode 100644 jugements_duels.json diff --git a/README.md b/README.md index b70306b..264bb46 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,30 @@ 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 +``` + +Première session (14 duels, 4 contrôles) : **non concluante** — 50 % (IC95 +0.27–0.73), 43 % sur les écarts forts (≥ 0.10), 57 % sur les faibles. Et le +verdict qui compte est méthodologique : **le lot ne pouvait pas conclure**. +Sur n = 7 par tranche, le seul résultat significatif possible était 7/7, et +la puissance pour une vraie détection de 0.80 était de 21 %. Il faut 20, 30, +49 ou 90 jugements tranchés par tranche pour détecter respectivement 0.80, +0.75, 0.70 ou 0.65. Détail et pistes dans +[`resultats_ecoute.md`](resultats_ecoute.md) — d'ici là, le n°1 de Forge est +le mieux construit *au sens des 29 axes*, et personne ne l'a encore entendu +dire par une oreille. + ## Tonalité : l'estimateur confronté à une étiquette qu'il n'a pas écrite Krumhansl-Kessler (`libretto.axes.estimate_key`) alimente sept axes du diff --git a/examples/forge_duels.py b/examples/forge_duels.py new file mode 100644 index 0000000..55ba424 --- /dev/null +++ b/examples/forge_duels.py @@ -0,0 +1,143 @@ +""" +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 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 construire_duels(rapport: dict, dossier: Path, n_par_tranche: int = 8, + seed: int = 1, max_secondes: float = 90.0) -> 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) + duels: list[dict] = [] + 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) + # 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. + vus: dict[str, int] = {} + retenues = [] + for a, b in paires: + if vus.get(a["file"], 0) >= 2 or vus.get(b["file"], 0) >= 2: + 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 + 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": f"{fort['file'].replace('.mid', '')}" + f"_vs_{faible['file'].replace('.mid', '')}", + "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) + args = ap.parse_args(argv) + + dossier = Path(args.dossier).expanduser().resolve() + rapport = json.loads((dossier / "forge_report.json").read_text(encoding="utf-8")) + duels = construire_duels(rapport, dossier, args.par_tranche, args.seed, + args.max_secondes) + 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"]) + total = sum(duree(Path(d[c])) for d in duels 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}") + print(f" ~{total / 60:.0f} min d'écoute (les deux côtés, sans réécoute) " + f"+ 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 index 25fde26..1a3a821 100644 --- a/examples/forge_loops.py +++ b/examples/forge_loops.py @@ -59,9 +59,12 @@ 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 autres vont aux pupitres tonaux. +# 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 -TONAL_CHANNELS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15] +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. @@ -305,12 +308,12 @@ def _effectif(plan: Plan, energie: float, rng: _random.Random) -> list[tuple[Loo qui rend la courbe lisible d'un bout à l'autre. """ choix: list[tuple[Loop, int]] = [] - canaux = iter(TONAL_CHANNELS) def prendre(pupitre: str, n: int) -> None: dispo = plan.pupitres.get(pupitre) or [] - for lp in dispo[:n]: - choix.append((lp, next(canaux, 15))) + 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: diff --git a/jugements_duels.json b/jugements_duels.json new file mode 100644 index 0000000..5566139 --- /dev/null +++ b/jugements_duels.json @@ -0,0 +1,169 @@ +{ + "seed": 1, + "n_tasks": 18, + "renderer": "v3-instrument:MuseScore_General.sf3", + "judgements": [ + { + "task_id": 0, + "file": "candidate_032_vs_candidate_015", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 112.2 + }, + { + "task_id": 1, + "file": "candidate_010_vs_candidate_037", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 50.0 + }, + { + "task_id": 2, + "file": "candidate_010.mid", + "degradation": "__control__", + "original_slot": "A", + "choice": "same", + "picked_original": null, + "listened_seconds": 72.3 + }, + { + "task_id": 3, + "file": "candidate_032.mid", + "degradation": "__control__", + "original_slot": "A", + "choice": "same", + "picked_original": null, + "listened_seconds": 82.6 + }, + { + "task_id": 4, + "file": "candidate_011_vs_candidate_018", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 60.6 + }, + { + "task_id": 5, + "file": "candidate_009_vs_candidate_000", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 34.8 + }, + { + "task_id": 6, + "file": "candidate_032.mid", + "degradation": "__control__", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 83.4 + }, + { + "task_id": 7, + "file": "candidate_010.mid", + "degradation": "__control__", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 46.6 + }, + { + "task_id": 8, + "file": "candidate_010_vs_candidate_014", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 32.1 + }, + { + "task_id": 9, + "file": "candidate_045_vs_candidate_003", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 40.4 + }, + { + "task_id": 10, + "file": "candidate_014_vs_candidate_042", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 26.3 + }, + { + "task_id": 11, + "file": "candidate_035_vs_candidate_037", + "degradation": "duel_ecart_faible", + "original_slot": "A", + "choice": "A", + "picked_original": true, + "listened_seconds": 38.1 + }, + { + "task_id": 12, + "file": "candidate_022_vs_candidate_027", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 45.9 + }, + { + "task_id": 13, + "file": "candidate_009_vs_candidate_036", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 83.4 + }, + { + "task_id": 14, + "file": "candidate_011_vs_candidate_000", + "degradation": "duel_ecart_fort", + "original_slot": "B", + "choice": "A", + "picked_original": false, + "listened_seconds": 32.0 + }, + { + "task_id": 15, + "file": "candidate_009_vs_candidate_037", + "degradation": "duel_ecart_faible", + "original_slot": "B", + "choice": "B", + "picked_original": true, + "listened_seconds": 20.0 + }, + { + "task_id": 16, + "file": "candidate_032_vs_candidate_003", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 16.5 + }, + { + "task_id": 17, + "file": "candidate_032_vs_candidate_042", + "degradation": "duel_ecart_fort", + "original_slot": "A", + "choice": "B", + "picked_original": false, + "listened_seconds": 14.4 + } + ] +} \ No newline at end of file diff --git a/libretto/annotate.py b/libretto/annotate.py index a6a899f..1b9fc69 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) @@ -487,13 +537,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 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..69d0353 100644 --- a/resultats_ecoute.md +++ b/resultats_ecoute.md @@ -932,6 +932,65 @@ 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 +reprenable et cumulable. + ### 2. Autres priorités - Un second annotateur : **fait** (session 5b) — réplication indépendante, diff --git a/tests/test_annotate.py b/tests/test_annotate.py index 9f142b5..307c5b0 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 From 991ea2209eb1cc1e5e1a7c96ad484692fb22c313 Mon Sep 17 00:00:00 2001 From: klodynlov Date: Mon, 27 Jul 2026 16:58:17 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(annotate):=20reprendre=20une=20session?= =?UTF-8?q?=20sur=20l'identit=C3=A9,=20pas=20sur=20le=20rang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La reprise se faisait sur `task_id`, c'est-à-dire sur la position 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 recollent à d'autres comparaisons. Silencieusement : rien ne plante, le fichier reste valide, les chiffres sont faux. `task_key` nomme la comparaison elle-même (paire ou fichier+dégradation, plus un rang pour les contrôles, seuls à se répéter dans un lot). Les jugements repris sont re-rattachés à leur tâche du nouveau lot ; ceux qui n'y figurent plus sont conservés — ils restent valides et cumulables. Les fichiers d'avant les clés en reçoivent une, reconstruite de leur contenu. `forge_duels.py` accepte `--reprendre` : les paires déjà jugées sont remises dans le lot plutôt qu'écartées, pour qu'un seul dépouillement couvre toute la session. Le plafond de duels par candidat suit la demande au lieu d'être figé à 2, et une tranche qui ne peut pas être remplie le dit au lieu de sortir courte en silence. Lot de la session 7 prêt : 96 candidats, 30 duels par tranche, 14 déjà jugés repris, 53 comparaisons neuves. Co-Authored-By: Claude Opus 5 --- examples/forge_duels.py | 60 ++++++++++++++++++++++++++++------ libretto/annotate.py | 72 +++++++++++++++++++++++++++++++++++------ resultats_ecoute.md | 14 ++++++-- tests/test_annotate.py | 34 ++++++++++++++++++- 4 files changed, 158 insertions(+), 22 deletions(-) diff --git a/examples/forge_duels.py b/examples/forge_duels.py index 55ba424..4e3a128 100644 --- a/examples/forge_duels.py +++ b/examples/forge_duels.py @@ -41,6 +41,7 @@ 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)) @@ -55,8 +56,25 @@ def duree(path: Path) -> float: 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) -> list[dict]: + 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 @@ -72,32 +90,48 @@ def construire_duels(rapport: dict, dossier: Path, n_par_tranche: int = 8, 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. + # 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) >= 2 or vus.get(b["file"], 0) >= 2: + 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": f"{fort['file'].replace('.mid', '')}" - f"_vs_{faible['file'].replace('.mid', '')}", + "nom": nom_de(a, b), "ecart": round(fort["score"] - faible["score"], 4), "scores": [fort["score"], faible["score"]], "fiabilites": [fort["confidence"], faible["confidence"]], @@ -114,12 +148,17 @@ def main(argv: list[str] | None = None) -> int: 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) + args.max_secondes, conserver) Path(args.out).write_text( json.dumps({"source": str(dossier), "duels": duels}, ensure_ascii=False, indent=1), encoding="utf-8") @@ -127,13 +166,16 @@ def main(argv: list[str] | None = None) -> int: par_tranche: dict[str, list[float]] = {} for d in duels: par_tranche.setdefault(d["etiquette"], []).append(d["ecart"]) - total = sum(duree(Path(d[c])) for d in duels for c in ("prefere", "autre")) + 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}") - print(f" ~{total / 60:.0f} min d'écoute (les deux côtés, sans réécoute) " - f"+ les paires de contrôle") + 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 diff --git a/libretto/annotate.py b/libretto/annotate.py index 1b9fc69..e97c282 100644 --- a/libretto/annotate.py +++ b/libretto/annotate.py @@ -249,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): @@ -271,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 @@ -287,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"], @@ -305,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") @@ -504,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: @@ -529,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 @@ -584,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/resultats_ecoute.md b/resultats_ecoute.md index 69d0353..8d6754f 100644 --- a/resultats_ecoute.md +++ b/resultats_ecoute.md @@ -988,8 +988,18 @@ 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 -reprenable et cumulable. +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. ### 2. Autres priorités diff --git a/tests/test_annotate.py b/tests/test_annotate.py index 307c5b0..f71c4e6 100644 --- a/tests/test_annotate.py +++ b/tests/test_annotate.py @@ -312,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.""" From 602ced36c49210541cf0e0c9d0ffe9149e32f6fb Mon Sep 17 00:00:00 2001 From: klodynlov Date: Mon, 27 Jul 2026 17:48:08 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs(ecoute):=20session=207=20=E2=80=94=20l?= =?UTF-8?q?e=20classement=20fin=20ne=20s'entend=20pas,=20le=20gros=20?= =?UTF-8?q?=C3=A9cart=20peut-=C3=AAtre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lot dimensionné : 96 candidats forge_loops, 30 duels par tranche, 11 contrôles, les 18 jugements de la session 6 repris et cumulés — 71 jugements, un dépouillement, 54 minutes d'écoute. écart fort (0.102-0.206) : 18/29 = 62 % IC95 0.44-0.77 p = 0.132 écart faible (0.001-0.038) : 13/30 = 43 % IC95 0.27-0.61 p = 0.819 Aucun 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 % dans le sens attendu, contraste entre tranches dans le même sens (Fisher p = 0.119), mais établir 0.62 demanderait 106 duels par tranche. Le coût de la question est chiffré, il n'est pas payé. Réserve, et elle se répète depuis la session 1 : sur 11 paires IDENTIQUES, l'annotateur n'a dit « aucune différence » que 3 fois. Il n'appuie pas au hasard pour autant — 27 % contre 1.7 % sur les duels réels, Fisher p = 0.011 — mais chaque préférence inventée est un pile ou face qui tire le taux vers 0.5 : les 62 % sont un plancher, pas une estimation. Conséquence écrite noir sur blanc dans le README : sur du loop arrangé comme sur du transcrit, Forge est un triage grossier. Écarter le bas se défend, couronner le n°1 ne se défend pas. Le 0.839 contre 0.760 de forge_loops reste une mesure de charpente, pas une promesse d'écoute. Co-Authored-By: Claude Opus 5 --- README.md | 46 ++- jugements_duels.json | 654 +++++++++++++++++++++++++++++++++++++++---- resultats_ecoute.md | 59 ++++ 3 files changed, 696 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 264bb46..0fc9d4f 100644 --- a/README.md +++ b/README.md @@ -278,16 +278,35 @@ python3 -m libretto.cli annotate duels.json --out jugements_duels.json --render python3 -m libretto.cli agreement jugements_duels.json # sans --corpus : pas de dégradation à rejouer ``` -Première session (14 duels, 4 contrôles) : **non concluante** — 50 % (IC95 -0.27–0.73), 43 % sur les écarts forts (≥ 0.10), 57 % sur les faibles. Et le -verdict qui compte est méthodologique : **le lot ne pouvait pas conclure**. -Sur n = 7 par tranche, le seul résultat significatif possible était 7/7, et -la puissance pour une vraie détection de 0.80 était de 21 %. Il faut 20, 30, -49 ou 90 jugements tranchés par tranche pour détecter respectivement 0.80, -0.75, 0.70 ou 0.65. Détail et pistes dans -[`resultats_ecoute.md`](resultats_ecoute.md) — d'ici là, le n°1 de Forge est -le mieux construit *au sens des 29 axes*, et personne ne l'a encore entendu -dire par une oreille. +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 @@ -621,6 +640,13 @@ 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. diff --git a/jugements_duels.json b/jugements_duels.json index 5566139..893c1fe 100644 --- a/jugements_duels.json +++ b/jugements_duels.json @@ -1,25 +1,27 @@ { "seed": 1, - "n_tasks": 18, + "n_tasks": 69, "renderer": "v3-instrument:MuseScore_General.sf3", "judgements": [ { "task_id": 0, - "file": "candidate_032_vs_candidate_015", - "degradation": "duel_ecart_faible", - "original_slot": "A", - "choice": "B", + "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": 112.2 + "listened_seconds": 58.9 }, { "task_id": 1, - "file": "candidate_010_vs_candidate_037", + "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": 50.0 + "listened_seconds": 56.1 }, { "task_id": 2, @@ -28,142 +30,688 @@ "original_slot": "A", "choice": "same", "picked_original": null, - "listened_seconds": 72.3 + "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, - "file": "candidate_032.mid", + "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": "same", - "picked_original": null, - "listened_seconds": 82.6 + "choice": "B", + "picked_original": false, + "listened_seconds": 130.1 }, { - "task_id": 4, - "file": "candidate_011_vs_candidate_018", + "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": 60.6 + "listened_seconds": 53.0 }, { - "task_id": 5, + "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 + "listened_seconds": 34.8, + "cle": "candidate_009_vs_candidate_000|duel_ecart_fort" }, { - "task_id": 6, - "file": "candidate_032.mid", - "degradation": "__control__", + "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": 83.4 + "listened_seconds": 60.6, + "cle": "candidate_011_vs_candidate_018|duel_ecart_faible" }, { - "task_id": 7, - "file": "candidate_010.mid", + "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": 46.6 + "listened_seconds": 19.7 }, { - "task_id": 8, - "file": "candidate_010_vs_candidate_014", + "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": 32.1 + "listened_seconds": 80.1 }, { - "task_id": 9, - "file": "candidate_045_vs_candidate_003", + "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": 10, - "file": "candidate_014_vs_candidate_042", + "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": 26.3 + "listened_seconds": 69.9 }, { - "task_id": 11, - "file": "candidate_035_vs_candidate_037", + "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": 38.1 + "listened_seconds": 15.9 }, { - "task_id": 12, - "file": "candidate_022_vs_candidate_027", + "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": 45.9 + "listened_seconds": 43.8 }, { - "task_id": 13, - "file": "candidate_009_vs_candidate_036", + "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": 83.4 + "listened_seconds": 23.1 }, { - "task_id": 14, + "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 + "listened_seconds": 32.0, + "cle": "candidate_011_vs_candidate_000|duel_ecart_fort" }, { - "task_id": 15, - "file": "candidate_009_vs_candidate_037", + "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": 20.0 + "listened_seconds": 62.1 }, { - "task_id": 16, - "file": "candidate_032_vs_candidate_003", + "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": 16.5 + "listened_seconds": 112.2, + "cle": "candidate_032_vs_candidate_015|duel_ecart_faible" }, { - "task_id": 17, - "file": "candidate_032_vs_candidate_042", + "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": 14.4 + "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/resultats_ecoute.md b/resultats_ecoute.md index 8d6754f..de6d1b6 100644 --- a/resultats_ecoute.md +++ b/resultats_ecoute.md @@ -1001,6 +1001,65 @@ 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,