Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,49 @@ rejoué le lot à l'identique (session 5b, `agreement A.json B.json`) :
[0.60–0.84]), et κ ≈ 0 signant la décorrélation des erreurs — deux
oreilles, un même verdict, aucun artefact partagé.

**Et le classement ?** Tout ce qui précède valide des *dégradations* : un
morceau contre sa version abîmée. Rien n'y valide un *classement* — un
morceau contre un autre — qui est pourtant l'hypothèse sur laquelle Forge
repose tout entier. Le protocole s'y prête sans rien changer : le côté
« original » devient le candidat que le moteur place devant.

```bash
python3 examples/forge_loops.py /tmp/index.json sortie/ 48 3 --keep-all
python3 examples/forge_duels.py sortie/ --out duels.json
python3 -m libretto.cli annotate duels.json --out jugements_duels.json --render instrument
python3 -m libretto.cli agreement jugements_duels.json # sans --corpus : pas de dégradation à rejouer
```

Deux sessions, cumulées : 60 duels, 11 contrôles, 71 jugements, rendu
v3-instrument (la première, à 14 duels, ne pouvait rien conclure — sur n = 7
par tranche, seul un 7/7 aurait été significatif ; détail en session 6).

| tranche | écart SMS | l'oreille suit le moteur | IC95 | p unilatéral |
|---|---|---|---|---|
| écart fort | 0.102 – 0.206 | **18/29 = 62 %** | 0.44 – 0.77 | 0.132 |
| écart faible | 0.001 – 0.038 | 13/30 = 43 % | 0.27 – 0.61 | 0.819 |

Aucun des deux n'est significatif, et les deux ne disent pas la même chose.
**Le classement fin ne s'entend pas** : sur les écarts de quelques
centièmes — ceux qui décident du n°1 contre le n°3 — l'oreille est au
hasard, et cette fois la puissance était là (un effet réel de 0.75 aurait
été vu 8 fois sur 10). **Sur les gros écarts il y a un signal, trop faible
pour ce lot** : 62 % va dans le sens attendu, l'écart entre tranches aussi
(Fisher p = 0.119), mais établir un effet de 0.62 demanderait 106 duels par
tranche. Le coût de la question est chiffré, il n'est pas payé.

À lire avec la réserve que le harnais signale lui-même : sur 11 paires
**identiques**, l'annotateur n'a dit « aucune différence » que 3 fois. Il
n'appuie pas au hasard pour autant — 27 % de « aucune différence » sur les
paires identiques contre 1.7 % sur les duels réels (Fisher p = 0.011) — mais
chaque préférence inventée est un tirage à pile ou face qui tire le taux vers
0.5 : **les 62 % sont un plancher, pas une estimation**.

Conséquence pratique, écrite noir sur blanc : sur du loop arrangé comme sur
du transcrit, Forge est un **triage grossier**. Écarter le bas du classement
se défend ; couronner le n°1 ne se défend pas. Détail en session 7 de
[`resultats_ecoute.md`](resultats_ecoute.md).

## Tonalité : l'estimateur confronté à une étiquette qu'il n'a pas écrite

Krumhansl-Kessler (`libretto.axes.estimate_key`) alimente sept axes du
Expand Down Expand Up @@ -525,6 +568,88 @@ diversité est bon marché : la shortlist round-robin (k=5) **retient 5.0
formes sur 5, pour un coût en score moyen de 0.008** (max 0.020) — une forme
entière regagnée coûte moins d'un centième de score.

## Forge sur un pack de loops

Il manquait à Forge la source la plus banale d'un studio : **un pack de
loops**. Les progressions et les riffs y sont écrits par des humains, ce que
le générateur de `make_corpus` ne sait pas inventer et qu'une transcription
audio dégrade (−0.18). Ce qu'un pack n'a pas, en revanche, c'est une
**forme** : il livre huit boucles de quatre mesures, pas une chanson. C'est
exactement la division du travail que Forge permet — la matière vient du
pack, la charpente est cherchée.

```bash
python3 examples/loop_index.py ~/Desktop/SAMPLES/MIDI --out /tmp/index.json
python3 examples/forge_loops.py /tmp/index.json sortie/ 48 3 --axes --shortlist 5
python3 examples/forge_loops.py /tmp/index.json sortie/ 48 3 --famille "Naija Waves"
```

`loop_index.py` lit ce que les noms de fichiers et de dossiers annoncent
(tonalité, tempo, instrument) et mesure ce qu'ils taisent (longueur,
polyphonie, tessiture). **La tonalité vient de l'étiquette, pas de
l'estimateur** — sur une boucle isolée, celui-ci tombe juste 4 fois sur 10
(voir *Tonalité*) ; il n'est appelé que pour les fichiers non étiquetés, et
l'index dit toujours d'où vient l'information. Le rôle est lu dans le nom
quand il s'y trouve, tranché par le contenu sinon (polyphonie ≥ 1.8 =
accompagnement ; monodie grave = basse). Une **famille** — un dossier, une
tonalité, un tempo — est ce qui s'arrange ensemble sans transposer ; elle
sépare toute seule `Bonfire_Dm_100` de `Moody_90_Bm` dans le même dossier.
Sur 820 fichiers : 800 indexés, 76 familles arrangeables.

Chaque candidat tire un plan (forme, longueur des sections, effectif par
section, courbe d'énergie, parfois une modulation ou une mélodie empruntée à
une autre famille du même pack, transposée à mode égal), tuile les boucles
dessus et écrit les marqueurs. Seule la batterie se prête d'une famille à
l'autre : elle n'a pas de tonalité.

**Ce que ça vaut.** Le seul comparatif qui isole l'apport de la recherche est
à matière égale — le même pack Naija Waves, plan écrit à la main contre plan
cherché :

| Naija Waves | score SMS | fiabilité |
|---|---|---|
| `assemble_naija.py` (PLAN écrit à la main) | 0.760 | 0.97 |
| `forge_loops.py` (48 plans cherchés, graine 3) | **0.839** | 0.93 |

Contre le générateur synthétique, à budget égal (48 candidats, graines 1-2-3)
le match est nul, et il faut le dire ainsi plutôt que de choisir la ligne qui
arrange :

| médiane sur 3 graines | gagnant | meilleur score | médiane du peloton |
|---|---|---|---|
| `forge.py` (synthétique) | **0.863** | 0.884 | **0.786** |
| `forge_loops.py` (pack) | 0.849 | **0.893** | 0.757 |

Le plafond est un peu plus haut sur les loops, le peloton un peu plus bas :
la matière humaine est plus inégale que celle d'un générateur réglé sur les
mêmes hypothèses que les axes.

**Où ça se joue**, par groupe d'axes (médianes, 48 candidats) : la forme
monte (A 0.846 contre 0.766) et la cohérence globale aussi (F 0.649 contre
0.500) — c'est l'apport de la recherche de plan. La mélodie s'effondre
(**C 0.597 contre 0.834**), et l'axe 15 (contour mélodique) porte presque
tout l'écart : 0.471 contre 0.956. Deux corrections ont été essayées et
**réfutées**, chiffres à l'appui : pousser tout l'accompagnement sous la note
la plus grave de la mélodie, pour que la voix supérieure lue par Libretto
soit bien la mélodie — groupe C inchangé (0.597), meilleur score 0.908 →
0.899 ; et faire entrer la mélodie beaucoup plus souvent — groupe C 0.597 →
0.574. Ce qui reste est l'explication la plus économique : les lignes d'un
pack ne satisfont pas les bandes de tolérance des axes 15 et 19 comme le font
celles de `make_corpus`, **écrites sous les mêmes hypothèses que les axes**.
C'est la circularité vue d'un autre angle — le générateur maison a un
avantage de naissance sur le juge maison, et il ne se voyait pas tant qu'on
ne lui opposait pas de la matière étrangère.

Ces écarts sont ceux du **juge**, et le juge a depuis été confronté à une
oreille sur ces mêmes candidats (voir *Validation externe*, sessions 6-7) :
sur des écarts de score supérieurs à 0.10 l'accord est de 62 % — un signal
non établi ; sur des écarts de quelques centièmes, rien. Le 0.839 contre
0.760 tient donc comme mesure de charpente, pas comme promesse que le
gagnant s'entend mieux.

Rien du pack n'entre dans le dépôt : l'index ne contient que des chemins, et
le dossier reste où il est.

## Interface web locale

```bash
Expand Down
185 changes: 185 additions & 0 deletions examples/forge_duels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""
Duels d'écoute à partir d'un rapport Forge : le classement tient-il à l'oreille ?

Tout ce que Forge affirme repose sur une hypothèse jamais testée sur ses
propres sorties : que le candidat qu'il place devant est réellement le mieux
construit. Les sessions d'écoute passées ont validé les **dégradations** —
un morceau contre sa version abîmée. Elles n'ont jamais validé un
**classement** — un morceau contre un autre morceau.

Ce script prend un `forge_report.json` (de `forge.py`, `forge_loops.py`,
`forge_musiclang.py` — n'importe lequel), en tire des paires de candidats et
les écrit au format attendu par `libretto annotate`. Le côté « original » du
protocole devient le candidat que Forge préfère : si l'oreille le désigne
significativement plus souvent que le hasard, le classement veut dire
quelque chose.

python3 examples/forge_loops.py /tmp/index.json sortie/ 48 3 --keep-all
python3 examples/forge_duels.py sortie/ --out duels.json
python3 -m libretto.cli annotate duels.json --out jugements_duels.json \
--render instrument
python3 -m libretto.cli agreement jugements_duels.json

Les paires sont réparties en deux tranches, parce que la question n'est pas
seulement « l'oreille suit-elle ? » mais **à partir de quel écart** :

· `duel_ecart_fort` — écart de score ≥ 0.10. Si l'oreille ne suit pas
là, le score ne classe rien du tout ;
· `duel_ecart_faible` — écart ≤ 0.04. Le classement fin de Forge (« le
n°1 plutôt que le n°3 ») repose entièrement sur cette tranche ; c'est
aussi celle où un désaccord serait le moins grave.

Le dépouillement se fait **sans `--corpus`** : `agreement` y recalculerait
la dégradation, qui n'existe pas ici. Le taux de détection par tranche EST
l'accord moteur/oreille.
"""

from __future__ import annotations

import argparse
import json
import random
import sys
from itertools import combinations
from math import ceil
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from libretto.midi import parse_midi, tick_to_seconds # noqa: E402

TRANCHES = (("duel_ecart_fort", 0.10, 1.0), ("duel_ecart_faible", 0.0, 0.04))


def duree(path: Path) -> float:
md = parse_midi(path)
return tick_to_seconds(md, md.end_tick)


def deja_jugees(jugements: Path | None) -> set[str]:
"""Noms des paires déjà jugées dans un fichier de jugements.

Elles sont **remises dans le lot** plutôt qu'écartées : `annotate` les
reconnaît à leur clé et ne les repropose pas, et le lot décrit alors la
session complète — les 14 premiers duels et les 46 suivants comptent
dans le même dépouillement, ce qui est tout l'intérêt d'agrandir plutôt
que de recommencer.
"""
if not jugements or not Path(jugements).exists():
return set()
data = json.loads(Path(jugements).read_text(encoding="utf-8"))
return {r["file"] for r in data.get("judgements", [])
if r["degradation"] != "__control__"}


def construire_duels(rapport: dict, dossier: Path, n_par_tranche: int = 8,
seed: int = 1, max_secondes: float = 90.0,
conserver: set[str] | None = None) -> list[dict]:
# Un duel se juge en écoutant les DEUX côtés en entier : deux candidats
# de quatre minutes, c'est un quart d'heure pour une seule réponse, et
# une session qu'on n'achève pas ne produit aucun intervalle de
# confiance. Les longs sont écartés ici, pas en cours de route.
ranked = [c for c in rapport["leaderboard"]
if (dossier / c["file"]).exists()
and duree(dossier / c["file"]) <= max_secondes]
if len(ranked) < 2:
raise SystemExit(
f"{dossier}/forge_report.json : moins de deux candidats de moins de "
f"{max_secondes:.0f} s encore sur le disque — relancez Forge avec "
f"--keep-all (le gagnant seul ne permet aucun duel), ou relevez "
f"--max-secondes.")

rng = random.Random(seed)
conserver = conserver or set()
duels: list[dict] = []

def nom_de(a: dict, b: dict) -> str:
fort, faible = (a, b) if a["score"] >= b["score"] else (b, a)
return (f"{fort['file'].replace('.mid', '')}"
f"_vs_{faible['file'].replace('.mid', '')}")

for etiquette, bas, haut in TRANCHES:
paires = [(a, b) for a, b in combinations(ranked, 2)
if bas <= abs(a["score"] - b["score"]) <= haut]
rng.shuffle(paires)
# Les paires déjà jugées passent devant : elles occupent leur place
# dans la tranche sans coûter une écoute.
paires.sort(key=lambda ab: nom_de(*ab) not in conserver)
# Un même candidat ne monopolise pas la tranche : sans ce garde-fou,
# le gagnant se retrouve dans huit duels sur huit et la tranche ne
# mesure plus que lui. Le plafond suit la demande — 30 duels sur 48
# candidats en exigent 3 chacun, l'imposer à 2 rendrait la tranche
# silencieusement plus courte que demandée.
plafond = max(2, ceil(2 * n_par_tranche / max(1, len(ranked))))
vus: dict[str, int] = {}
retenues = []
for a, b in paires:
if vus.get(a["file"], 0) >= plafond or vus.get(b["file"], 0) >= plafond:
continue
vus[a["file"]] = vus.get(a["file"], 0) + 1
vus[b["file"]] = vus.get(b["file"], 0) + 1
retenues.append((a, b))
if len(retenues) >= n_par_tranche:
break
if len(retenues) < n_par_tranche:
print(f" ⚠ {etiquette} : {len(retenues)} paires seulement sur "
f"{n_par_tranche} demandées ({len(paires)} paires possibles "
f"dans la tranche, plafond {plafond} duels par candidat)")
for a, b in retenues:
fort, faible = (a, b) if a["score"] >= b["score"] else (b, a)
duels.append({
"prefere": str((dossier / fort["file"]).resolve()),
"autre": str((dossier / faible["file"]).resolve()),
"etiquette": etiquette,
"nom": nom_de(a, b),
"ecart": round(fort["score"] - faible["score"], 4),
"scores": [fort["score"], faible["score"]],
"fiabilites": [fort["confidence"], faible["confidence"]],
})
return duels


def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[1])
ap.add_argument("dossier", help="dossier de sortie Forge (avec forge_report.json)")
ap.add_argument("--out", default="duels.json", help="fichier de duels")
ap.add_argument("--par-tranche", type=int, default=8,
help="duels par tranche d'écart (défaut 8)")
ap.add_argument("--max-secondes", type=float, default=90.0,
help="écarter les candidats plus longs (défaut 90 s)")
ap.add_argument("--seed", type=int, default=1)
ap.add_argument("--reprendre", metavar="JUGEMENTS",
help="fichier de jugements d'une session précédente : ses "
"paires sont replacées dans le lot (annotate ne les "
"repropose pas, le dépouillement les cumule)")
args = ap.parse_args(argv)

dossier = Path(args.dossier).expanduser().resolve()
rapport = json.loads((dossier / "forge_report.json").read_text(encoding="utf-8"))
conserver = deja_jugees(Path(args.reprendre) if args.reprendre else None)
duels = construire_duels(rapport, dossier, args.par_tranche, args.seed,
args.max_secondes, conserver)
Path(args.out).write_text(
json.dumps({"source": str(dossier), "duels": duels},
ensure_ascii=False, indent=1), encoding="utf-8")

par_tranche: dict[str, list[float]] = {}
for d in duels:
par_tranche.setdefault(d["etiquette"], []).append(d["ecart"])
restants = [d for d in duels if d["nom"] not in conserver]
total = sum(duree(Path(d[c])) for d in restants for c in ("prefere", "autre"))
print(f"{len(duels)} duels → {args.out}")
for nom, ecarts in par_tranche.items():
print(f" {nom:18s} {len(ecarts):2d} paires · écart "
f"{min(ecarts):.3f} à {max(ecarts):.3f}")
if conserver:
print(f" {len(duels) - len(restants)} déjà jugées, reprises telles quelles")
print(f" ~{total / 60:.0f} min d'écoute restante (les deux côtés, sans "
f"réécoute) + les paires de contrôle")
print("\nécoute : python3 -m libretto.cli annotate "
f"{args.out} --out jugements_duels.json --render instrument")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading