From 7ff45df90a7dd1042e30061f1820e09fa73d2379 Mon Sep 17 00:00:00 2001 From: "Claude Haiku 4.5 (1M context)" Date: Thu, 8 Oct 2026 02:22:40 +0200 Subject: [PATCH 1/2] feat(audio,#19739): classifier onset/mid/end/spread des spans d'omission (phase 2 CPU) PR #19699 MERGEE (phase 1) a livre l'organe p7 (vote 2/3 ASR) qui detecte les spans >=3 mots sur 20 segments echantillonnes. Sur la passe 3, 24 spans / 145 mots -- plusieurs debutent au mot 0 du segment source, signature d'un onset-drop CosyVoice3 (cf. corps de PR #19699). Ce module classifier deterministe range chaque span d'omission par position dans le segment source normalise : - onset_drop : span_start == 0 (premiere proposition omise) - end_drop : span_end >= seg_len (queue tombee) - mid_omission : entre les deux - spread : span couvre >= 80 % du segment (catastrophe) - none : span < 3 mots ou seg < 3 mots (invariant p7) Hierarchie de priorite : SPREAD > ONSET_DROP > END_DROP > MID_OMISSION > NONE. CPU pur, deterministe, sans appel ASR. Consomme l'artefact omission_report.json de p7_verify.py + annotated_v4.json pour les longueurs normalisees. Tests : 16/16 verts (test_onset_chunk.py). Le re-roll cible par chunk (graine espacee, garde d'attaque ASR turbo) est hors perimetre CPU -- extension GPU ulterieure (lane po-2023 ou po-2024 RTX 3090). Lane myia-po-2027:CoursIA-2 -- cycle c.1468. --- .../v4/prosody_lab/corpus_damage_chunk.py | 313 ++++++++++++++++ .../v4/prosody_lab/tests/test_onset_chunk.py | 348 ++++++++++++++++++ docs/genai/audio-onset-chunk-detection.md | 114 ++++++ 3 files changed, 775 insertions(+) create mode 100644 MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/corpus_damage_chunk.py create mode 100644 MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/tests/test_onset_chunk.py create mode 100644 docs/genai/audio-onset-chunk-detection.md diff --git a/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/corpus_damage_chunk.py b/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/corpus_damage_chunk.py new file mode 100644 index 0000000000..b014e1647c --- /dev/null +++ b/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/corpus_damage_chunk.py @@ -0,0 +1,313 @@ +"""corpus_damage_chunk.py -- classification onset/mid/end des spans d'omission. + +Cadrage issue #19739 phase 2 : la phase 1 (PR #19699) a livré l'organe p7 +(`MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/p7_verify.py`) qui détecte +les spans >=3 mots omis sous >=2 ASR sur 3 (vote 2/3) sur les segments +audio complets de l'audiobook Boule de Suif v4. Sur la passe 3, 24 spans +d'omission sur 145 mots sont mesurés -- dont plusieurs debutent au mot 0 +du segment source, signature d'un onset-drop CosyVoice3 (cf. corps de PR +#19699, "Le residuel : onset-drop intrinseque au moteur"). + +Ce module **classifie** chaque span d'omission par **position dans le +segment source normalise** : +- `onset_drop` : span debute au mot 0 du segment -- la premiere proposition + est omise. +- `end_drop` : span finit au dernier mot du segment -- la queue du segment + est tombee. +- `mid_omission` : span entre les deux. +- `spread` : span couvre >= 80 % du segment -- la totalite du segment est + tombee (catastrophe). +- `none` : ne classifie pas (seg trop court, span trop court). + +L'instrument est CPU pur, deterministe, sans appel ASR. Il prend en entree +l'artefact `omission_report.json` produit par p7_verify.py et un mapping +des longueurs normalisees par segment (depuis `annotated_v4.json` ou un +mapping equivalent). La sortie est un rapport JSON + resume stdout, +structure compatible avec les outils du banc de reference (#19695). + +Note : ce module **detecte** l'onset-drop dans les artefacts deja mesures. +Le **re-roll cible par chunk** (graine espacee, garde d'attaque) est une +extension GPU ulterieure (lane po-2023 ou po-2024 RTX 3090) ; ce module +fournit le diagnostic sans le remede. + +Usage : + python corpus_damage_chunk.py \\ + --omission-report outputs/omission_report.json \\ + --annotated outputs/annotated_v4.json \\ + --out outputs/onset_chunk_report.json + +Sortie : + { + "method": "...", + "n_records": int, + "n_classified": int, + "counts": {"onset_drop": int, "mid_omission": int, ...}, + "onset_segments": [{"seg_index": int, "speaker": str, + "first_span_words": str, "n_spans": int}], + ... + } +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +# Categories de position. Constantes exportees pour les tests. +ONSET_DROP = "onset_drop" +MID_OMISSION = "mid_omission" +END_DROP = "end_drop" +SPREAD = "spread" +NONE = "none" + +# Seuil : un span qui couvre >= 80 % du segment est "spread" (catastrophe). +SPREAD_RATIO = 0.8 + +# Taille minimale d'un segment pour etre classifie. Les segments plus +# courts que 3 mots ne peuvent pas produire de span >=3 mots (invariant +# p7) ; on les ignore. +MIN_SEG_WORDS = 3 + + +def _normalize_for_length(text: str) -> int: + """Compte de mots normalises (meme convention que p7._normalize_words).""" + import re + import unicodedata + + text = unicodedata.normalize("NFC", text).lower() + text = "".join(c if c.isalnum() else " " for c in text) + return len(re.sub(r"\s+", " ", text).strip().split()) + + +def classify_span_position( + span_start: int, span_end: int, seg_word_count: int +) -> str: + """Classifie un span d'omission par position dans le segment source. + + Conventions : + - span_start / span_end sont en indices de mots **normalises** (cf. + p7._normalize_words), inclusifs a gauche, exclusifs a droite. + - seg_word_count : nombre total de mots normalises du segment source. + + Retourne l'une des categories : onset_drop | mid_omission | end_drop | + spread | none. + """ + if seg_word_count < MIN_SEG_WORDS: + return NONE + if span_end <= span_start: + return NONE + if span_end - span_start < 3: + return NONE # span trop court pour etre un signal + + span_len = span_end - span_start + if span_len / seg_word_count >= SPREAD_RATIO: + return SPREAD + + if span_start == 0: + return ONSET_DROP + if span_end >= seg_word_count: + return END_DROP + return MID_OMISSION + + +def build_seg_word_count_map( + annotated_path: Path, +) -> dict[str, int]: + """Construit le mapping seg_index -> nombre de mots normalises. + + Le fichier annotated_v4.json contient une liste de segments avec un + champ `seg_index` et un champ `text` (cf. schemas.AnnotatedBatch). + Cette fonction extrait juste le mapping necessaire au calcul de + position. + + Retourne un dict vide si le fichier est introuvable (cas degenere : + on ne peut pas classer ; l'appelant doit le signaler). + """ + if not annotated_path.exists(): + return {} + try: + batch = json.loads(annotated_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + segments = batch.get("segments", []) + out: dict[str, int] = {} + for seg in segments: + idx = seg.get("seg_index") + text = seg.get("text", "") + if idx is None: + continue + out[str(idx)] = _normalize_for_length(text) + return out + + +def classify_records( + records: list[dict], + seg_len_map: dict[str, int], +) -> dict: + """Classifie chaque record par position du span principal. + + Un record est un dict avec au moins : + - `seg_index` (int) + - `spans` (list[{start, end, words}]) -- produit par p7_verify.py + - `speaker` (str, optionnel, transmis tel quel) + + Le span principal est le **premier** span du record (les spans sont + tries par ordre d'apparition dans la source). Si le premier span est + onset_drop, on considere que le segment a un onset-drop. Si tous les + spans sont end_drop, on rapporte end_drop. Si mixes, on rapporte + spread (le segment a une perte etendue). + + Retourne un dict compatible avec le banc de reference (cf. schema). + """ + counts = {ONSET_DROP: 0, MID_OMISSION: 0, END_DROP: 0, SPREAD: 0, NONE: 0} + onset_segments: list[dict] = [] + end_drop_segments: list[dict] = [] + spread_segments: list[dict] = [] + unclassified: list[dict] = [] + n_classified = 0 + + for rec in records: + idx = rec.get("seg_index") + spans = rec.get("spans", []) + if idx is None or not spans: + unclassified.append({"seg_index": idx, "reason": "no_spans"}) + continue + seg_len = seg_len_map.get(str(idx)) + if seg_len is None or seg_len < MIN_SEG_WORDS: + unclassified.append( + {"seg_index": idx, "reason": "seg_len_unknown_or_too_short"} + ) + continue + + # Classification par record : on prend le verdict majoritaire parmi + # les spans, avec la regle "spread > onset_drop > end_drop > + # mid_omission" pour le cas mixte (un spread domine un onset, etc.). + per_span: list[str] = [] + for sp in spans: + cat = classify_span_position( + int(sp["start"]), int(sp["end"]), seg_len + ) + per_span.append(cat) + counts[cat] += 1 + n_classified += 1 + + if SPREAD in per_span: + verdict = SPREAD + elif ONSET_DROP in per_span: + verdict = ONSET_DROP + elif END_DROP in per_span: + verdict = END_DROP + elif per_span: + verdict = per_span[0] + else: + verdict = NONE + + rec_summary = { + "seg_index": idx, + "speaker": rec.get("speaker", ""), + "first_span_words": spans[0].get("words", ""), + "n_spans": len(spans), + "verdict": verdict, + } + if verdict == ONSET_DROP: + onset_segments.append(rec_summary) + elif verdict == END_DROP: + end_drop_segments.append(rec_summary) + elif verdict == SPREAD: + spread_segments.append(rec_summary) + + return { + "method": ( + "classification onset/mid/end/spread par position de span " + "d'omission (>=3 mots, vote 2/3 ASR) dans le segment source " + "normalise. Convention : ONSET_DROP si span_start == 0, " + "END_DROP si span_end >= seg_len, MID_OMISSION entre les " + "deux, SPREAD si span couvre >= 80% du segment, NONE " + "sinon (seg trop court ou span trop court)." + ), + "n_records": len(records), + "n_classified": n_classified, + "counts": counts, + "onset_segments": onset_segments, + "end_drop_segments": end_drop_segments, + "spread_segments": spread_segments, + "unclassified": unclassified, + } + + +def main() -> int: + p = argparse.ArgumentParser( + description=__doc__.splitlines()[0] if __doc__ else "" + ) + p.add_argument( + "--omission-report", + required=True, + type=Path, + help="Artefact omission_report.json de p7_verify.py.", + ) + p.add_argument( + "--annotated", + required=True, + type=Path, + help="Artefact annotated_v4.json (source des longueurs).", + ) + p.add_argument( + "--out", + type=Path, + default=None, + help="Sortie JSON (defaut : stdout).", + ) + args = p.parse_args() + + if not args.omission_report.exists(): + print( + f"ERREUR : omission-report introuvable : {args.omission_report}", + file=sys.stderr, + ) + return 2 + + payload = json.loads(args.omission_report.read_text(encoding="utf-8")) + records = payload.get("records", []) + + seg_len_map = build_seg_word_count_map(args.annotated) + if not seg_len_map: + print( + f"AVERTISSEMENT : annotated {args.annotated} absent ou vide ; " + f"classification sans longueur de segment -- tous les spans " + f"seront 'none'.", + file=sys.stderr, + ) + + report = classify_records(records, seg_len_map) + + # Resume stdout compact + print(f"=== ONSET CHUNK CLASSIFICATION (#19739 phase 2) ===") + print(f" Records : {report['n_records']}") + print(f" Spans classifiables : {report['n_classified']}") + print(f" Counts : {json.dumps(report['counts'], ensure_ascii=False)}") + print(f" Onset-drop segments : {len(report['onset_segments'])}") + print(f" End-drop segments : {len(report['end_drop_segments'])}") + print(f" Spread segments : {len(report['spread_segments'])}") + if report["onset_segments"]: + print(f" -- onset-drops --") + for rec in report["onset_segments"][:5]: + print( + f" seg {rec['seg_index']:3d} " + f"({rec.get('speaker', '?'):>5s}): " + f"{rec['first_span_words'][:70]}" + ) + + out_text = json.dumps(report, indent=2, ensure_ascii=False) + if args.out is not None: + args.out.write_text(out_text, encoding="utf-8") + print(f"\n Ecrit : {args.out}") + else: + print() + print(out_text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/tests/test_onset_chunk.py b/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/tests/test_onset_chunk.py new file mode 100644 index 0000000000..9d25f35a44 --- /dev/null +++ b/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/tests/test_onset_chunk.py @@ -0,0 +1,348 @@ +"""test_onset_chunk.py -- couverture de corpus_damage_chunk. + +Issue #19739 phase 2 : classifier onset/mid/end/spread des spans d'omission +sur les artefacts phase 1 (PR #19699 MERGEDe, organe p7). + +Tests : +1. test_normalize_for_length : compte mots normalises (meme conv. p7). +2. test_classify_span_onset : span demarrant au mot 0 -> onset_drop. +3. test_classify_span_end : span finissant au dernier mot -> end_drop. +4. test_classify_span_mid : span entre les deux -> mid_omission. +5. test_classify_span_spread : span couvrant >= 80% du segment -> spread. +6. test_classify_span_too_short : span < 3 mots -> none. +7. test_classify_seg_too_short : seg < 3 mots -> none (invariant p7). +8. test_classify_records_majority : verdict par record = majorite. +9. test_classify_records_mixed_spread_dominates : spread > onset > end > mid. +10. test_classify_records_unclassified : seg_index absent -> unclassified. +11. test_classify_records_onset_segment_listed : onset_segments rempli. +12. test_build_seg_word_count_map_missing_file : fichier absent -> {}. +13. test_build_seg_word_count_map_parses_annotated : parsing JSON standard. +14. test_main_no_omission_report : exit 2 si omission-report absent. +15. test_smoke_run_end_to_end : run avec fixtures + sortie JSON structuree. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +# Permet l'import quand pytest est lance depuis la racine du worktree. +THIS_DIR = Path(__file__).resolve().parent +PROSODY_LAB = THIS_DIR.parent +sys.path.insert(0, str(PROSODY_LAB)) + +from corpus_damage_chunk import ( # noqa: E402 + ONSET_DROP, + MID_OMISSION, + END_DROP, + SPREAD, + NONE, + classify_span_position, + classify_records, + build_seg_word_count_map, + _normalize_for_length, +) + + +# ---------- 1. normalize_for_length ---------- + +def test_normalize_for_length() -> None: + """Compte de mots normalises -- meme convention que p7.""" + # Normalise : NFC, lowercase, non-alnum -> espace, collapse whitespace. + assert _normalize_for_length("Bonjour, monde !") == 2 + assert _normalize_for_length("L'été était accablé") == 4 + assert _normalize_for_length(" espaces en trop ") == 3 + assert _normalize_for_length("") == 0 + # Les chiffres comptent comme alnum, donc comme mots. + assert _normalize_for_length("il etait 3 fois") == 4 + + +# ---------- 2-7. classify_span_position ---------- + +def test_classify_span_onset() -> None: + """Span demarrant au mot 0 -> onset_drop.""" + # "Quand la diligence" (4 mots), premiere triplette omise. + assert classify_span_position(0, 3, seg_word_count=10) == ONSET_DROP + + +def test_classify_span_end() -> None: + """Span finissant au dernier mot -> end_drop.""" + assert classify_span_position(7, 10, seg_word_count=10) == END_DROP + + +def test_classify_span_mid() -> None: + """Span entre les deux -> mid_omission.""" + assert classify_span_position(3, 6, seg_word_count=10) == MID_OMISSION + + +def test_classify_span_spread() -> None: + """Span couvrant >= 80% du segment -> spread (catastrophe). + + Convention de hierarchie : SPREAD (>=80%) prime sur ONSET_DROP (start=0) + et END_DROP (end=seg_len). Un span (0, 7) sur 10 mots est onset_drop + (70% < 80% mais start=0) -- c'est l'ordre des priorites qui tranche. + """ + # 8 mots sur 10 = 80 % pile -> spread (categorie la plus haute). + assert classify_span_position(0, 8, seg_word_count=10) == SPREAD + # 9 mots sur 10 = 90 % -> spread. + assert classify_span_position(0, 9, seg_word_count=10) == SPREAD + # 7 mots sur 10 = 70 % -> onset_drop (start=0 et 70 < 80). + assert classify_span_position(0, 7, seg_word_count=10) == ONSET_DROP + + +def test_classify_span_too_short() -> None: + """Span < 3 mots -> none (invariant p7 min_words=3).""" + # (2, 5) = 3 mots : OK -> mid_omission. + assert classify_span_position(2, 5, seg_word_count=10) == MID_OMISSION + # (2, 4) = 2 mots : trop court -> none. + assert classify_span_position(2, 4, seg_word_count=10) == NONE + # (2, 3) = 1 mot : trop court -> none. + assert classify_span_position(2, 3, seg_word_count=10) == NONE + # (0, 2) = 2 mots au debut : trop court -> none (pas onset_drop). + assert classify_span_position(0, 2, seg_word_count=10) == NONE + + +def test_classify_seg_too_short() -> None: + """Segment < 3 mots -> none (invariant p7 min_words=3).""" + assert classify_span_position(0, 3, seg_word_count=2) == NONE + assert classify_span_position(0, 5, seg_word_count=1) == NONE + + +def test_classify_span_empty_range() -> None: + """Span vide (end <= start) -> none.""" + assert classify_span_position(5, 5, seg_word_count=10) == NONE + assert classify_span_position(7, 3, seg_word_count=10) == NONE + + +# ---------- 8-11. classify_records ---------- + +def test_classify_records_majority() -> None: + """Le verdict par record suit la hierarchie spread > onset > end > mid.""" + records = [ + { + "seg_index": 10, + "speaker": "narrator", + "spans": [ + {"start": 0, "end": 3, "words": "premier triplette omise"}, + ], + }, + ] + seg_len_map = {"10": 10} + report = classify_records(records, seg_len_map) + assert report["counts"][ONSET_DROP] == 1 + assert report["counts"][MID_OMISSION] == 0 + assert len(report["onset_segments"]) == 1 + assert report["onset_segments"][0]["verdict"] == ONSET_DROP + + +def test_classify_records_mixed_spread_dominates() -> None: + """Un spread parmi les spans impose le verdict spread du record.""" + records = [ + { + "seg_index": 50, + "speaker": "narrator", + # Spread 0..8 (8 mots sur 10 = 80 %) + un mid 4..7 (3 mots). + "spans": [ + {"start": 0, "end": 8, "words": "le segment presque entier"}, + {"start": 4, "end": 7, "words": "milieu"}, + ], + }, + ] + seg_len_map = {"50": 10} + report = classify_records(records, seg_len_map) + assert report["counts"][SPREAD] == 1 + assert report["counts"][MID_OMISSION] == 1 + assert len(report["spread_segments"]) == 1 + assert len(report["onset_segments"]) == 0 + + +def test_classify_records_unclassified() -> None: + """seg_index absent ou seg_len inconnu -> unclassified.""" + records = [ + { + "seg_index": 999, # pas dans seg_len_map + "speaker": "narrator", + "spans": [{"start": 0, "end": 3, "words": "test"}], + }, + { + # seg_index absent + "speaker": "narrator", + "spans": [{"start": 0, "end": 3, "words": "test"}], + }, + { + "seg_index": 1, + "speaker": "narrator", + # spans vide + "spans": [], + }, + ] + seg_len_map = {"1": 10} # 999 absent + report = classify_records(records, seg_len_map) + assert report["n_records"] == 3 + # 999 absent de seg_len_map -> unclassified, donc n_classified == 0. + # Le 1er record (seg_index absent) et le 3eme (spans vide) sont + # aussi unclassified par construction. + assert report["n_classified"] == 0 + assert len(report["unclassified"]) == 3 + + +def test_classify_records_onset_segment_listed() -> None: + """onset_segments rempli avec first_span_words pour lecture humaine.""" + records = [ + { + "seg_index": 57, # exemple documente dans PR #19699 + "speaker": "narrator", + "spans": [ + {"start": 0, "end": 3, "words": "chacun guettait l'arrivee"}, + ], + }, + ] + seg_len_map = {"57": 12} + report = classify_records(records, seg_len_map) + assert len(report["onset_segments"]) == 1 + seg = report["onset_segments"][0] + assert seg["seg_index"] == 57 + assert seg["speaker"] == "narrator" + assert seg["first_span_words"] == "chacun guettait l'arrivee" + assert seg["verdict"] == ONSET_DROP + + +# ---------- 12-13. build_seg_word_count_map ---------- + +def test_build_seg_word_count_map_missing_file(tmp_path: Path) -> None: + """Fichier absent -> dict vide, classification degradee.""" + missing = tmp_path / "nope.json" + assert build_seg_word_count_map(missing) == {} + + +def test_build_seg_word_count_map_parses_annotated(tmp_path: Path) -> None: + """Parsing standard d'un annotated_v4.json minimaliste. + + Convention : non-alnum -> espace, donc "Bouledesuif" reste un seul + mot normalise (les separateurs typographiques sont le seul separateur). + """ + annotated = tmp_path / "annotated.json" + annotated.write_text( + json.dumps( + { + "segments": [ + {"seg_index": 0, "text": "Quand la diligence parut"}, + {"seg_index": 1, "text": "Boule de suif rougit"}, + {"seg_index": 5, "text": ""}, # vide -> 0 mots + ] + } + ), + encoding="utf-8", + ) + m = build_seg_word_count_map(annotated) + assert m["0"] == 4 + assert m["1"] == 4 + assert m["5"] == 0 + + +# ---------- 14. main() exit codes ---------- + +def test_main_no_omission_report(tmp_path: Path, capsys) -> None: + """omission-report absent -> exit 2.""" + # Importer seulement quand pytest est actif (lazy import de main). + import corpus_damage_chunk as cdc + + rc = cdc.main.__wrapped__() if hasattr(cdc.main, "__wrapped__") else None + # Pas de wrapper ; on invoque via argparse direct. + import argparse + + parser = cdc.main.__globals__["p"] if False else None + # Le plus simple : invoquer main() avec sys.argv, capturer SystemExit. + import sys + + old_argv = sys.argv + sys.argv = [ + "corpus_damage_chunk.py", + "--omission-report", + str(tmp_path / "missing.json"), + "--annotated", + str(tmp_path / "missing.json"), + ] + try: + rc = cdc.main() + except SystemExit as e: + rc = e.code + finally: + sys.argv = old_argv + assert rc == 2 + + +# ---------- 15. smoke run end-to-end ---------- + +def test_smoke_run_end_to_end(tmp_path: Path, capsys) -> None: + """Run complet avec artefacts en fichiers, sortie structuree.""" + annotated = tmp_path / "annotated.json" + annotated.write_text( + json.dumps( + { + "segments": [ + {"seg_index": 10, "text": "Quand la diligence parut au loin"}, + {"seg_index": 11, "text": "Le segment entier est reste muet"}, + {"seg_index": 12, "text": "Phrase complete sans omission"}, + ], + } + ), + encoding="utf-8", + ) + omission = tmp_path / "omission.json" + omission.write_text( + json.dumps( + { + "records": [ + { + "seg_index": 10, + "speaker": "narrator", + "spans": [ + {"start": 0, "end": 3, "words": "quand la diligence"} + ], + }, + { + "seg_index": 11, + "speaker": "narrator", + "spans": [ + {"start": 0, "end": 6, "words": "le segment entier"} + ], + }, + ] + } + ), + encoding="utf-8", + ) + out = tmp_path / "out.json" + + import corpus_damage_chunk as cdc + import sys + + old_argv = sys.argv + sys.argv = [ + "corpus_damage_chunk.py", + "--omission-report", + str(omission), + "--annotated", + str(annotated), + "--out", + str(out), + ] + try: + rc = cdc.main() + except SystemExit as e: + rc = e.code + finally: + sys.argv = old_argv + + assert rc == 0 + assert out.exists() + payload = json.loads(out.read_text(encoding="utf-8")) + assert payload["n_records"] == 2 + assert payload["n_classified"] == 2 + assert payload["counts"][ONSET_DROP] == 1 # seg 10 + assert payload["counts"][SPREAD] == 1 # seg 11 (6/7 = 86%) + assert len(payload["onset_segments"]) == 1 + assert payload["onset_segments"][0]["seg_index"] == 10 + assert len(payload["spread_segments"]) == 1 + assert payload["spread_segments"][0]["seg_index"] == 11 \ No newline at end of file diff --git a/docs/genai/audio-onset-chunk-detection.md b/docs/genai/audio-onset-chunk-detection.md new file mode 100644 index 0000000000..a01cdf8e0e --- /dev/null +++ b/docs/genai/audio-onset-chunk-detection.md @@ -0,0 +1,114 @@ +# Onset-drop detection — classifier onset/mid/end des spans d'omission + +> Cadrage : issue [#19739](https://github.com/jsboige/CoursIA/issues/19739) — `feat(audio,#19692): phase 2 -- garde d'attaque ASR par chunk (onset-drop CV3 mesure, porte #17586 non conforme)`. Lane `myia-po-2027:CoursIA-2`, scope **CPU** (re-roll GPU hors périmètre ; l'extension GPU est la suite logique, à livrer par lane po-2023 ou po-2024 RTX 3090). + +## Pourquoi ce module ? + +La phase 1 ([PR #19699](https://github.com/jsboige/CoursIA/pull/19699), MERGEDe par lane `myia-po-2023:CoursIA`) a livré l'organe `p7` — vote 2/3 ASR (tiny, large-v3, large-v3-turbo) sur 20 segments échantillonnés de l'audiobook Boule de Suif v4, avec détection de spans ≥ 3 mots omis. Sur la passe 3, **24 spans / 145 mots** sont mesurés. Plusieurs debutent au **mot 0** du segment source : c'est la signature d'un **onset-drop** intrinsèque au moteur CosyVoice3 (cf. corps de PR #19699, section "Le résiduel : onset-drop intrinsèque au moteur, prouvé par expérience"). + +Le défaut est **graine-dépendant** : seg 57 dit la première proposition sous les graines 4243/4244/4245, et aucune ne la prononce. C'est documenté comme **non corrigeable côté pipeline** — c'est au moteur qu'il faut parler. Phase 2 introduit une garde d'attaque par chunk (ASR turbo sur chaque chunk ≤ 280 c rendu, re-roll ciblé si l'attaque manque). Cette PR ne livre **que** le diagnostic : un classifier déterministe qui prend les spans d'omission déjà mesurés par phase 1 et les range par position (onset_drop, mid_omission, end_drop, spread, none). La garde d'attaque elle-même et son re-roll GPU sont **hors périmètre CPU**. + +## Catégorie de défaut : `onset_drop` vs `mid_omission` vs `end_drop` vs `spread` + +Le défaut mesuré par phase 1 — "24 spans d'omission" — est **position-agnostique** : p7 détecte les spans sans dire **où** dans le segment ils se trouvent. Or, un onset-drop (premier triplette omise, parole démarre au milieu du texte fourni) n'est **pas le même remède** qu'un mid_omission (un passage du milieu est tombé) ou un end_drop (la queue est coupée). Re-rouler la graine d'un onset-drop est la voie documentée dans le corps de #19699 ; re-rouler un mid_omission est une intervention plus profonde. + +Convention de classification (CPU pur, déterministe) : + +| Catégorie | Condition | Lecture humaine | +|---|---|---| +| `onset_drop` | `span_start == 0` (et pas SPREAD) | La première proposition est omise. Re-roll ciblé (graine espacée, ASR turbo par chunk). | +| `end_drop` | `span_end >= seg_len` (et pas SPREAD) | La queue du segment est coupée. Probable troncature (cf. PR #19699 cause n°2). | +| `mid_omission` | sinon (et span ≥ 3 mots) | Un passage intermédiaire a disparu. Re-roll souvent inefficace. | +| `spread` | span couvre ≥ 80 % du segment | Catastrophe — le moteur a quasi-rien dit. À investiguer (cache, decoder). | +| `none` | span < 3 mots **ou** seg < 3 mots | Hors signal p7 — pas un span d'omission par convention. | + +**Hiérarchie de priorité par span** : `SPREAD > ONSET_DROP > END_DROP > MID_OMISSION > NONE`. Un span qui couvre 90 % du segment **est** un spread (catastrophe), même s'il commence au mot 0. Un span (0, 7) sur 10 mots est onset_drop (70 % < 80, start = 0). Cette hiérarchie est documentée dans le module et testée (`test_classify_span_spread`, `test_classify_records_mixed_spread_dominates`). + +**Hiérarchie par record** : un record (un segment) rapporte le verdict le plus haut parmi ses spans — `SPREAD` s'il y a un spread parmi les spans, sinon `ONSET_DROP`, sinon `END_DROP`, sinon le verdict du premier span. Cela reflète l'intuition "un seul spread suffit à dire catastrophe". + +## Usage + +```bash +# Diagnostic post-mesure p7 (artefact omission_report.json). +python corpus_damage_chunk.py \\ + --omission-report outputs/omission_report.json \\ + --annotated outputs/annotated_v4.json \\ + --out outputs/onset_chunk_report.json +``` + +Codes de retour : + +| Code | Sens | +|---|---| +| 0 | Diagnostic émis, JSON écrit. | +| 2 | Artefact `--omission-report` absent (à signaler en dashboard). | + +Sortie `onset_chunk_report.json` (compatible avec le banc de référence [#19695](https://github.com/jsboige/CoursIA/issues/19695)) : + +```json +{ + "method": "classification onset/mid/end/spread par position de span ...", + "n_records": 20, + "n_classified": 18, + "counts": {"onset_drop": 3, "mid_omission": 8, "end_drop": 5, "spread": 2, "none": 0}, + "onset_segments": [{"seg_index": 57, "speaker": "narrator", + "first_span_words": "chacun guettait l'arrivee", + "n_spans": 1, "verdict": "onset_drop"}], + "end_drop_segments": [...], + "spread_segments": [...], + "unclassified": [...] +} +``` + +Le stdout ajoute un résumé compact : counts + 5 premiers onset_segments (lecture humaine rapide avant d'agir). + +## Tests + +`tests/test_onset_chunk.py` — **16 tests unitaires, tous verts** (c.1468). + +| Test | Vérifie | +|---|---| +| `test_normalize_for_length` | Compte de mots normalisés = convention p7 (NFC, lower, non-alnum → espace). | +| `test_classify_span_onset` | span (0, 3) seg 10 → onset_drop. | +| `test_classify_span_end` | span (7, 10) seg 10 → end_drop. | +| `test_classify_span_mid` | span (3, 6) seg 10 → mid_omission. | +| `test_classify_span_spread` | 8/10 = 80 % → spread ; 7/10 = 70 % → onset_drop (hiérarchie). | +| `test_classify_span_too_short` | span 2 mots → none (invariant p7 min_words=3). | +| `test_classify_seg_too_short` | seg 2 mots → none. | +| `test_classify_span_empty_range` | end ≤ start → none. | +| `test_classify_records_majority` | Verdict par record = hiérarchie SPREAD > ONSET > END > MID. | +| `test_classify_records_mixed_spread_dominates` | Un spread parmi plusieurs spans impose SPREAD. | +| `test_classify_records_unclassified` | seg_index absent de seg_len_map → unclassified. | +| `test_classify_records_onset_segment_listed` | onset_segments populé avec first_span_words. | +| `test_build_seg_word_count_map_missing_file` | Fichier annoté absent → `{}`. | +| `test_build_seg_word_count_map_parses_annotated` | Parsing standard JSON. | +| `test_main_no_omission_report` | omission-report absent → exit 2. | +| `test_smoke_run_end_to_end` | Run complet fixtures → structure JSON conforme. | + +Câblage CI : la suite `tests/` de `v4/prosody_lab/` est collectée par `Scripts & Notebook-Tools Tests` (runner `Scripts Tests (CPU)`). Pas de gate CI bloquant propre — voir [#19468 sweep 100 obsolètes 4.2c-k] pour l'opportunit. Le test est **off-CI** pour l'instant (pas de pytest dans `scripts/notebook_tools/`), à intégrer quand la suite v4 pytest sera officialisée. + +## Limites et suites + +- **Pas d'appel ASR** : ce module **détecte** l'onset-drop dans les artefacts déjà mesurés. La **garde d'attaque par chunk** (ASR turbo sur chaque chunk rendu, re-roll ciblé) est une extension GPU à livrer par une autre lane — cf. issue [#19739](https://github.com/jsboige/CoursIA/issues/19739) pour le périmètre complet. +- **Segments courts (< 3 mots)** : aucun span classifié (`none`). C'est l'invariant p7 — un span ≥ 3 mots nécessite un segment ≥ 3 mots. +- **Mesure E2E absente** : les artefacts `omission_report.json` et `annotated_v4.json` sont produits sur une machine GPU (RTX 3090, po-2024 ou po-2023). Cette PR n'inclut pas de mesure end-to-end faute d'accès GPU ; un cycle ultérieur lancera `p7_verify.py` puis ce classifier sur les 270 segments. +- **Pas de diagnostic cross-cycle** : ce module lit un seul artefact à la fois. La non-régression (cf. issue [#15002](https://github.com/jsboige/CoursIA/issues/15002) critère #3) — un run qui dégrade par rapport au précédent — est portée par le **banc de référence TTS** [#19695](https://github.com/jsboige/CoursIA/issues/19695), pas par ce module. + +## Anti-patterns évités + +- **Ré-implémenter p7** : refus. Ce module **consomme** l'artefact de p7_verify.py, ne le duplique pas. Cf. [#13564 organ-first implementation](https://github.com/jsboige/CoursIA/issues/13564) — `p7` est l'organe canonique de détection d'omission. +- **Re-render GPU local** : refus (règle F). La mesure GPU est déléguée à une lane capable, jamais un workaround dégradé en local. +- **Hand-edit de sortie** : refus (règle 6 secrets-hygiene). Le diagnostic est produit par le code, jamais maquillé. + +## Pointeurs + +- Module : [`corpus_damage_chunk.py`](../../MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/corpus_damage_chunk.py) +- Tests : [`tests/test_onset_chunk.py`](../../MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/tests/test_onset_chunk.py) +- Phase 1 : [PR #19699](https://github.com/jsboige/CoursIA/pull/19699) — organe p7 (MERGEDe) +- EPIC parent : [#19692](https://github.com/jsboige/CoursIA/issues/19692) +- Banc de référence : [#19695](https://github.com/jsboige/CoursIA/issues/19695) — `bake_bank` schema v1 +- Issue : [#19739](https://github.com/jsboige/CoursIA/issues/19739) +- Cadrage coordinateur : DM ai-01 11:09Z 07/10 (file audio post-#19820) +- `corpus_damage.py` (phase 1) : [PR #19699](https://github.com/jsboige/CoursIA/pull/19699) — instrument de mesure plancher 40 car/s + +— daté 2026-10-08, lane `myia-po-2027:CoursIA-2`, cycle c.1468 \ No newline at end of file From 15a480fc4687cc9f5adb9fcb6e8c8276600065df Mon Sep 17 00:00:00 2001 From: "Claude Haiku 4.5 (1M context)" Date: Thu, 8 Oct 2026 04:00:09 +0200 Subject: [PATCH 2/2] fix(audio,#19829): enregister audio-onset-chunk-detection.md dans docs/README Le CI gate docs-index-guard detecte 1 NEW doc non reachable depuis l'index : docs/genai/audio-onset-chunk-detection.md (nouveau fichier de la PR, non reference dans docs/README.md, son point d'entree canonique). Ajout d'une ligne dans la section GenAI de docs/README.md, rangee apres audio-fading-remediation.md (meme sous-section audio, meme famille GenAI). La nouvelle entree mentionne le classifier onset/mid/end/spread (phase 2 CPU, #19739) et la convention de hierarchie documentee (SPREAD >= 80% > ONSET > END > MID > NONE). Co-Authored-By: Claude Haiku 4.5 (1M context) --- docs/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/README.md b/docs/README.md index 99fd38baa5..d6825097e7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -112,6 +112,7 @@ Documentation détaillée de l'infrastructure GenAI (ComfyUI, Docker, modèles l | [genai/secrets-management.md](genai/secrets-management.md) | Secrets management — central source of truth (master.env + render_envs.py) | | [transients/2026-06-14-genai-service-security-audit.md](transients/2026-06-14-genai-service-security-audit.md) | Audit de sécurisation des services IA auto-hébergés (po-2023) | | [genai/audio-fading-remediation.md](genai/audio-fading-remediation.md) | Remédiation FADING audio et verdicts SOTA associés (#11719) — référence opérationnelle ; le détecteur vit dans les scripts, ce document porte le raisonnement | +| [genai/audio-onset-chunk-detection.md](genai/audio-onset-chunk-detection.md) | Détection onset/mid/end/spread des spans d'omission audio (classifier déterministe CPU, #19739) — convention de hiérarchie et tests verts | | [genai/decision-models.md](genai/decision-models.md) | Modèles de décision — relevé et shortlist | | [genai/model-mapping-openai.md](genai/model-mapping-openai.md) | Mapping des modèles OpenAI — génération courante | | [genai/model-mapping.md](genai/model-mapping.md) | Inventaire et table de correspondance — modèles GenAI/OpenAI |