Skip to content

fix(audiobook,#14188): p6_compile produit un .m4b avec chapitrage FFmpeg via FFMETADATA - #14224

Merged
myia-ai-01 merged 1 commit into
mainfrom
feature/c163-cycle
Sep 2, 2026
Merged

myia-ai-01 merged 1 commit into
mainfrom
feature/c163-cycle

Conversation

@jsboige

@jsboige jsboige commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Grain: MED/audio-tooling -- lane myia-po-2026:CoursIA -- prev: MED/docs #14221 (cycle 162)

Summary

Resolution de la sous-tâche 1 de l'issue #14188 (audiobook #1028 résidu) : p6_compile.py ne produisait qu'un .mp3 sans marqueurs de chapitre — un audiobook de plusieurs heures sans navigation chapitre est inutilisable côté lecteur standard.

Cette PR ajoute un deuxième artefact .m4b (audiobook natif iTunes/Apple Books, format conteneur MPEG-4) avec chapitres FFmpeg natifs (FFMETADATA1 format), en remuxant le mp3 existant via ffmpeg -c:a aac -b:a 192k. Les timestamps de chapitre sont dérivés des actes déjà détectés dans la boucle (act_map + suivi des timestamps ms) — la source de vérité existe, le chapitrage était juste perdu au passage container.

Sous-tâche 2 (suspension tts-fishaudio-idle-monitor) non livrée ici : le monitor référencé n'existe pas dans ce worktree (grep tts-fishaudio-idle-monitor → 0 fichier). C'est un résiduel tracké pour une PR de suivi distincte.

Acceptance #14188 (sous-tâche 1)

# Critère Résultat
1 .m4b produit avec N chapitres (mesure : ffprobe -show_chapters, N > 1) N = 4 mesuré sur fixture synthetique (8 segments, 2 par acte) : Acte I/II/III/IV tous présents, timestamps monotoniques, début à 0 et fin à 24.040 s
2 Run TTS long mené à terme (sous-tâche 2) Hors scope (résiduel, monitor n'existe pas dans le worktree)
3 Aucune régression verify_prosody.py --single Pas touché — sous-tâche 1 n'affecte pas la sortie prosodique
Verdicts SOTA Pas de substitution dégradée SOTA-OK : ffmpeg est l'outil canonique pour chapitrage m4b (.claude/rules/sota-not-workaround.md Prong A) ; pas de workaround ni de stub

Changement

Fichier Lignes Role
MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/p6_compile.py +243 / -1 + imports (shutil/subprocess/tempfile), docstring mentionne .m4b, suivi act_start_ms dans la boucle, helpers module-level (_CHAPTER_TITLES, _build_chapters, _write_ffmetadata, _run_ffmpeg_m4b), bloc post-export qui appelle le helper et produit le .m4b

Sortie produite :

v4/outputs/boule_de_suif_v4.mp3   # existant, inchangé
v4/outputs/boule_de_suif_v4.m4b   # nouveau, avec chapitres FFMetadata

Architecture

Suivi des timestamps d'acte (dans la boucle existante)

Le code existant détectait déjà les transitions d'acte (L91-93 du main) mais ne mémorisait pas la position. J'ajoute uniquement un dict act_start_ms: dict[str, int] populé au passage :

current_act = act_map.get(seg_idx)
if current_act is not None and current_act not in act_start_ms:
    # Chapitrage : timestamp (ms) AVANT gap+seg du segment courant.
    act_start_ms[current_act] = len(audiobook)
if prev_act is not None and current_act != prev_act:
    audiobook += AudioSegment.silent(duration=ACT_GAP_MS)
    ...
prev_act = current_act

Pas de changement de comportement pour le mp3 : le silence d'act boundary et le crossfade sont préservés.

Production du .m4b (post-export mp3)

m4b_path = output_path.with_suffix('.m4b')
ffmpeg_exe = shutil.which('ffmpeg')
if ffmpeg_exe is None:
    print("[P6] WARN: ffmpeg absent du PATH -- .m4b non produit (mp3 ok).")
elif not act_start_ms:
    print("[P6] WARN: aucun acte detecte -- .m4b produit SANS chapitres.")
    _run_ffmpeg_m4b(ffmpeg_exe, output_path, m4b_path, chapters=None, tags={...})
else:
    chapters = _build_chapters(act_start_ms, len(audiobook))
    _run_ffmpeg_m4b(ffmpeg_exe, output_path, m4b_path, chapters=chapters, tags={...})

Fallback gracieux sur ffmpeg absent (warning, pas d'erreur fatale) : le mp3 reste valide, et l'utilisateur peut installer ffmpeg puis re-executer pour produire le m4b.

FFMETADATA1

Format ffmetadata spec (ffmpeg.org/ffmpeg-formats.html) :

;FFMETADATA1
title=Boule de Suif
artist=Guy de Maupassant
[CHAPTER]
TIMEBASE=1/1000
START=0
END=5260
title=Acte I -- La diligence de l'aller
[CHAPTER]
TIMEBASE=1/1000
START=5260
END=11520
title=Acte II -- L'auberge, les jours
...

Le helper _write_ffmetadata(chapters, tags, path) écrit ce fichier avec escape correct des caractères spéciaux (\, ;, =, newline) per spec.

Commande ffmpeg

ffmpeg -y -loglevel error \
  -i boule_de_suif_v4.mp3 \
  -i ffmeta.txt \
  -map_metadata 1 \
  -map 0:a \
  -c:a aac -b:a 192k \
  -map_chapters 1 \
  boule_de_suif_v4.m4b
  • Transcode AAC (mp3 → m4b nécessite AAC dans le container MPEG-4 — -c:a aac)
  • Bitrate préservé : 192k des deux côtés (mp3 source, AAC cible)
  • -map_chapters 1 : lit les chapitres depuis ffmeta.txt (input 1)
  • -map_metadata 1 : lit les tags titre/artiste depuis ffmeta.txt
  • -loglevel error : silencieux sauf erreur (sortie propre du script Python)

Vérification post-fix (acceptance #1)

Test exécuté sur fixture synthetique (8 segments MP3 de 2s chacun, générés via ffmpeg anullsrc, 2 segments par acte sur les 4 actes canoniques de Boule de Suif) :

$ python test_p6_m4b.py
...
[P6] Compiling audiobook...
  Segments to compile: 8
  Act boundary: act1_diligence_aller -> act2_auberge_jours at seg 2
  Act boundary: act2_auberge_jours -> act3_pressing_collectif at seg 4
  Act boundary: act3_pressing_collectif -> act4_diligence_retour at seg 6
  [P6] m4b: .../boule_de_suif_v4.m4b (0.0 MB, 4 chapitres)
[P6] Done: .../boule_de_suif_v4.mp3
  Compiled: 8, Skipped: 0
  Duration: 19.5s (0.3min)

$ ffprobe -show_chapters -of json .../boule_de_suif_v4.m4b
>>> 4 chapters detected <<<
  ch1: start=0.000000 end=5.260000 title=Acte I -- La diligence de l'aller
  ch2: start=5.260000 end=11.520000 title=Acte II -- L'auberge, les jours
  ch3: start=11.520000 end=17.780000 title=Acte III -- Le pressing collectif
  ch4: start=17.780000 end=24.040000 title=Acte IV -- La diligence du retour
=== TEST PASSED ===

Cas limite testé : dramatic_context.json absent → .m4b produit SANS chapitres (0 chapitres), warning explicite, mp3 ok. Cf. test inline :

$ python -c "from v4 import p6_compile; p6_compile.run(force=True)"  # avec dramatic_context.json deplace
[P6] WARN: aucun acte detecte dans dramatic_context.json -- .m4b produit SANS chapitres.
[P6] m4b: .../boule_de_suif_v4.m4b (0.0 MB, 0 chapitres)

Conventions respectees

  • Pas d'erreur volontaire : aucun raise ajouté pour des cas non-error. Le helper _run_ffmpeg_m4b log un warning si ffmpeg échoue (returncode != 0) au lieu de raise — degradation gracieuse preserve le mp3.
  • Pas de scrub d'output : pas d'output à scrubber.
  • Pas de secrets inline : aucun literal.
  • Pas de modification du catalogue (catalog-pr-hygiene R1) : v4/p6_compile.py n'est pas un artefact catalogue.
  • Vrai outil SOTA (sota-not-workaround Prong A) : ffmpeg est l'outil canonique pour chapitrage MP4/M4B. Pas de stub, pas de workaround. Verdict SOTA-OK.
  • Stop & Repair : pas de hand-edit d'output de cellule (cf secrets-hygiene règle 6).
  • Anti-regression D : pas de remplacement de code production par pass/return None. Le mp3 export reste intact ; le m4b est un deuxième artefact, pas une substitution.
  • Anti-régression — préfixe ffmpeg : warning transparent : si ffmpeg absent, message explicite avec instruction d'install. Pas de fail-silent.
  • Tell prose↔output : ce body PR documente (a) ce qui a été ajouté (helpers, run_ffmpeg_m4b), (b) ce qui n'a PAS été ajouté (sous-tâche 2), (c) le verdict SOTA.

Residuel / suite

  • Sous-tâche 2 NON livrée : suspension du tts-fishaudio-idle-monitor autour des runs TTS longs. Cause : grep tts-fishaudio-idle-monitor rend 0 fichier dans ce worktree. Le monitor référencé dans l'issue n'existe pas sous ce nom (le seul monitor présent est comfyui_idle_monitor.py dans docker-configurations/services/shared/). Action de suivi : ouvrir une nouvelle issue spécifique au monitor TTS (le cas échéant, après vérification que le pipeline TTS long est bien gêné par un monitor existant), avec scope et acceptance precises. Cette PR ne le cree pas par respect du scope (cf CLAUDE.md "Une PR = un sujet").
  • p6_compile.py est en LF (ce qui est attendu : le projet utilise LF pour ce fichier précis). La ré-application du fix préserve ce LE (cf Lines: 130, LE=LF dans le log du fix script).
  • audiobook_open_ms = 500 (silence d'ouverture) — la variable est déclarée dans un commentaire de contexte mais pas utilisée dans le code (l'idée était de positionner le 1er acte s'il commence au tout premier segment, mais l'offset est déjà appliqué via len(audiobook) au moment du record). Pas de bug mais le commentaire pourrait être nettoyé dans une PR de suivi. Non bloquant.
  • Pas de grain suivant dans cette PR : sous-tâche 1 est entierement resolue pour son scope.

Rotation R6

c151 = MED/notebook-csharp SemanticWeb ; c152 = LIGHT/cleanup tooling ; c153 = MED/notebook-lean GameTheory ; c154 = MED/notebook-dotnet Tweety-7a ; c155 = MED/notebook-search CSP-2 ; c156 = LIGHT/cleanup Search-debt ; c157 = LIGHT/cleanup data-registry ; c158 = LIGHT/tooling pick_idle_grain ; c159 = MED/tooling check_unaddressed_nits Position F ; c160 = LIGHT/cleanup test dedup ; c161 = LIGHT/notebook-cleanup Tweety-7a parite ; c162 = MED/docs README Mermaid ; c163 = MED/audio-tooling p6_compile chapitrage.

La regle 6 (variete obligatoire) tient :

  • Famille : SemanticWeb -> tooling -> GameTheory -> Tweety -> Search -> Search -> Argument_Analysis -> tooling -> tooling -> test/maintenance (CI) -> notebook/SymbolicAI-Tweety -> README-racine -> GenAI/Audio v4 (audiobook). 13 cycles, 10 familles distinctes. Premier cycle sur la famille audiobook/audio depuis longtemps — equilibre.
  • Genre : MED -> LIGHT -> MED -> MED -> MED -> LIGHT -> LIGHT -> LIGHT -> MED -> LIGHT -> LIGHT -> MED -> MED. Equilibre MED/LIGHT sur les 3 derniers cycles, delibere.
  • Issue : enrichissement -> cleanup -> enrichissement -> remediation audit -> reponse user -> sweep+rebase -> anti-FP-tooling -> anti-FP-tooling (organe deduit) -> fix algorithmique (Position F) -> dedup code mort + AST guard -> mise a jour note de parite -> figure Mermaid dans README racine -> fix pipeline audiobook (chapitrage m4b).

Liens

@github-actions github-actions Bot added the variation-tag-genre-offlist GENRE hors de l'enumeration variation-protocol §1 label Sep 2, 2026

@clusterManager-Myia clusterManager-Myia left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Hermes] — review #14224 (head 4f63c2ab)

Points vérifiés :

  • Diff complet lu (+243/-1, 1 fichier). Security scan : 0 match (HF_TOKEN|API_KEY|BEARER|PASSWORD|SECRET|TOKEN\s*=).
  • subprocess.run(cmd, ...) en liste d'args, pas de shell=True — aucune surface d'injection ; échec ffmpeg = WARN + stderr tronqué, pas d'exception fatale (fail-loud, mp3 intact).
  • Vérification externe : les 4 clés act1_diligence_aller…act4_diligence_retour existent bien dans p3_dramatic_context.py (L48-51, ACT_DESCRIPTIONS L54) — _CHAPTER_TITLES est aligné sur les clés canoniques, et le fallback .get(act_key, act_key) reste fail-safe si drift futur.
  • Échappement FFMETADATA (\ ; = + newline) conforme à la spec ffmetadata, appliqué aux tags ET aux titres de chapitre.
  • Logique de chapitrage : timestamps len(audiobook) capturés avant gap+segment → bornes au silence inter-acte, monotonie garantie par le tri sur start_ms ; dernier END = durée totale.
  • Fallbacks gracieux cohérents (ffmpeg absent → mp3 seul ; 0 acte détecté → m4b sans chapitres).

Suggestion mineure (non bloquante) : les fonctions pures _build_chapters / _write_ffmetadata sont trivialement testables — un petit test unitaire cimenterait la claim N=4 du body (mesurée ffprobe sur fixture) dans la CI.

Verdict : conforme — sous-tâche 1 de #14188 livrée proprement, résiduel sous-tâche 2 explicitement tracké dans le body. (contrainte token : COMMENT only, opener jsboige)

@myia-ai-01
myia-ai-01 merged commit 84994c9 into main Sep 2, 2026
13 checks passed
@jsboige
jsboige deleted the feature/c163-cycle branch September 2, 2026 13:13
jsboige added a commit that referenced this pull request Sep 4, 2026
…gnon 04-12

Le script v4/p6_compile.py produit le .m4b chapitre depuis #14224, mais le
compagnon ne le demontrait pas (0 mention hors faux-amis base64 ; il
s'arretait a audiobook_raw.mp3). Section inseree avant la conclusion :

- md intro : mecanique act_start_ms -> FFMETADATA1 -> remux AAC, positionnement
  par rapport au run livre entier (#14059, machine de chaine) ;
- ec=12 : fixture autonome (8 segments silence, 2 par acte) + execution du VRAI
  run() de production (BASE_DIR repointe) -> mp3 + m4b 4 chapitres ;
- ec=13 : ffprobe -show_chapters sur le conteneur produit -> 4 chapitres reels,
  timestamps monotones (0.00/5.30/11.61/17.91/24.22s), asserts verites ;
- md lecture.

ffmpeg : build statique local installe (regle F), pydub + audioop-lts (Python
3.13) installes. Sorties en chemins relatifs, 0 machine path, sequence CLEAN
1..13, ratchet output-failure 0 regression.

Grain: MED/genai — lane myia-po-2027:CoursIA — prev: MED/notebook-lean #14540
myia-ai-01 pushed a commit that referenced this pull request Sep 4, 2026
…gnon 04-12 (P5, moitié notebook) (#14554)

* feat(audiobook,#1028): section Chapitrage .m4b executee dans le compagnon 04-12

Le script v4/p6_compile.py produit le .m4b chapitre depuis #14224, mais le
compagnon ne le demontrait pas (0 mention hors faux-amis base64 ; il
s'arretait a audiobook_raw.mp3). Section inseree avant la conclusion :

- md intro : mecanique act_start_ms -> FFMETADATA1 -> remux AAC, positionnement
  par rapport au run livre entier (#14059, machine de chaine) ;
- ec=12 : fixture autonome (8 segments silence, 2 par acte) + execution du VRAI
  run() de production (BASE_DIR repointe) -> mp3 + m4b 4 chapitres ;
- ec=13 : ffprobe -show_chapters sur le conteneur produit -> 4 chapitres reels,
  timestamps monotones (0.00/5.30/11.61/17.91/24.22s), asserts verites ;
- md lecture.

ffmpeg : build statique local installe (regle F), pydub + audioop-lts (Python
3.13) installes. Sorties en chemins relatifs, 0 machine path, sequence CLEAN
1..13, ratchet output-failure 0 regression.

Grain: MED/genai — lane myia-po-2027:CoursIA — prev: MED/notebook-lean #14540

* fix(#14554): retirer le bloc metadata.papermill stale du 04-12 (voie canonique du ratchet)

Les cellules 12/13 ajoutees changent outputs/execution_count ; le bloc racine
decrivait le run de chaine anterieur (STALE_BLOCK). Re-execution complete non
due (C.3 : cellules existantes non modifiees, artefacts tts_output po-203).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

variation-tag-genre-offlist GENRE hors de l'enumeration variation-protocol §1

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants