Created
April 12, 2026 23:51
-
-
Save brahimmachkouri/258b009c7b9b62817bd11ef07ba1f08a to your computer and use it in GitHub Desktop.
Découpe un mix MP3 selon une playlist.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Découpe un long mix MP3 selon une playlist, en alignant chaque coupe sur le point | |
| audio le plus calme (RMS minimum) autour du timestamp annoncé, puis en option | |
| sur le zero-crossing le plus proche pour limiter les clics. | |
| Dépendances : | |
| - ffmpeg | |
| - ffprobe | |
| """ | |
| import argparse | |
| import array | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| from typing import List, Tuple | |
| PLAYLIST = [ | |
| ("00:00:00", "Nattlys i Regnet"), | |
| ("00:03:39", "Skygger over Byen"), | |
| ("00:08:29", "Danser Gjennom Mørket"), | |
| ("00:12:27", "Flammer i Nordvinden"), | |
| ("00:16:53", "Sølvspor på Asfalt"), | |
| ("00:21:43", "Hjertet Slår i Neon"), | |
| ("00:25:23", "Måneskinn og Motorlyd"), | |
| ("00:28:23", "Gater av Rav"), | |
| ("00:31:14", "Drømmer under Brostein"), | |
| ("00:34:42", "Nattlys i Regnet"), | |
| ("00:38:48", "Vind over Fjorden"), | |
| ("00:42:00", "Aske og Sommerlyn"), | |
| ("00:48:10", "Ekko fra Bakgården"), | |
| ("00:51:58", "Blå Timer i Sør"), | |
| ("00:55:52", "Støv og Stjerneskinn"), | |
| ("01:01:04", "Langs Elva ved Midnatt"), | |
| ("01:05:49", "Byen Sover Aldri Helt"), | |
| ] | |
| def timestamp_vers_secondes(ts: str) -> float: | |
| parts = [float(p) for p in ts.strip().split(":")] | |
| if len(parts) == 3: | |
| h, m, s = parts | |
| elif len(parts) == 2: | |
| h, m, s = 0, parts[0], parts[1] | |
| else: | |
| raise ValueError(f"Timestamp invalide : {ts}") | |
| return h * 3600 + m * 60 + s | |
| def nettoyer_nom(nom: str) -> str: | |
| nom = nom.replace("’", "'").replace("“", '"').replace("”", '"') | |
| nom = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", nom) | |
| nom = re.sub(r"\s+", " ", nom) | |
| return nom.strip().rstrip(".") | |
| def verifier_outils() -> None: | |
| manquants = [outil for outil in ("ffmpeg", "ffprobe") if not shutil.which(outil)] | |
| if manquants: | |
| sys.exit(f"Outil(s) introuvable(s) dans le PATH : {', '.join(manquants)}") | |
| def executer(cmd: List[str], capture_output: bool = False) -> subprocess.CompletedProcess: | |
| try: | |
| return subprocess.run( | |
| cmd, | |
| check=True, | |
| capture_output=capture_output, | |
| text=capture_output, | |
| ) | |
| except subprocess.CalledProcessError as e: | |
| if capture_output: | |
| msg = e.stderr or e.stdout or str(e) | |
| else: | |
| msg = str(e) | |
| sys.exit(f"Erreur lors de l'exécution de la commande :\n{' '.join(cmd)}\n\n{msg}") | |
| def duree_fichier(fichier: str) -> float: | |
| result = executer( | |
| [ | |
| "ffprobe", | |
| "-v", "error", | |
| "-show_entries", "format=duration", | |
| "-of", "default=noprint_wrappers=1:nokey=1", | |
| fichier, | |
| ], | |
| capture_output=True, | |
| ) | |
| try: | |
| return float(result.stdout.strip()) | |
| except ValueError: | |
| sys.exit(f"Impossible de lire la durée du fichier : {fichier}") | |
| def extraire_pcm_mono( | |
| fichier: str, | |
| debut: float, | |
| duree: float, | |
| sample_rate: int, | |
| ) -> array.array: | |
| cmd = [ | |
| "ffmpeg", | |
| "-v", "error", | |
| "-ss", f"{debut:.6f}", | |
| "-i", fichier, | |
| "-t", f"{duree:.6f}", | |
| "-ac", "1", | |
| "-ar", str(sample_rate), | |
| "-f", "s16le", | |
| "-", | |
| ] | |
| result = subprocess.run(cmd, check=True, capture_output=True) | |
| samples = array.array("h") | |
| samples.frombytes(result.stdout) | |
| return samples | |
| def trouver_zero_crossing_proche( | |
| samples: array.array, | |
| centre_index: int, | |
| rayon_samples: int, | |
| ) -> int: | |
| if not samples: | |
| return centre_index | |
| debut = max(1, centre_index - rayon_samples) | |
| fin = min(len(samples) - 1, centre_index + rayon_samples) | |
| meilleur_index = centre_index | |
| meilleure_distance = None | |
| for i in range(debut, fin): | |
| a = samples[i - 1] | |
| b = samples[i] | |
| # Vrai passage par zéro ou changement de signe | |
| if (a <= 0 < b) or (a >= 0 > b) or a == 0 or b == 0: | |
| distance = abs(i - centre_index) | |
| if meilleure_distance is None or distance < meilleure_distance: | |
| meilleure_distance = distance | |
| meilleur_index = i | |
| if distance == 0: | |
| break | |
| return meilleur_index | |
| def trouver_point_le_plus_calme( | |
| fichier: str, | |
| timestamp: float, | |
| tolerance: float = 5.0, | |
| fenetre_ms: float = 50.0, | |
| sample_rate: int = 8000, | |
| ajuster_zero_crossing: bool = True, | |
| zero_crossing_ms: float = 10.0, | |
| ) -> Tuple[float, float]: | |
| """ | |
| Retourne (position, delta) où : | |
| - position = meilleur point de coupe trouvé en secondes | |
| - delta = position - timestamp | |
| """ | |
| debut_extraction = max(0.0, timestamp - tolerance) | |
| duree_extraction = max(0.01, tolerance * 2.0) | |
| samples = extraire_pcm_mono(fichier, debut_extraction, duree_extraction, sample_rate) | |
| if len(samples) == 0: | |
| return timestamp, 0.0 | |
| taille_fenetre = max(1, int(sample_rate * fenetre_ms / 1000.0)) | |
| pas = max(1, taille_fenetre // 4) | |
| if len(samples) < taille_fenetre: | |
| centre = len(samples) // 2 | |
| position = debut_extraction + centre / sample_rate | |
| return position, position - timestamp | |
| # Somme glissante des carrés en O(n) : | |
| # on calcule l'énergie initiale sur la première fenêtre, puis on glisse | |
| # sample par sample en ajoutant l'entrant et en retirant le sortant. | |
| energie = 0 | |
| for i in range(taille_fenetre): | |
| v = samples[i] | |
| energie += v * v | |
| min_energie = energie | |
| min_debut = 0 | |
| for i in range(1, len(samples) - taille_fenetre + 1): | |
| sortant = samples[i - 1] | |
| entrant = samples[i + taille_fenetre - 1] | |
| energie += entrant * entrant - sortant * sortant | |
| # On n'évalue le minimum que toutes les 'pas' positions, | |
| # comme dans la version d'origine, pour rester cohérent. | |
| if i % pas == 0 and energie < min_energie: | |
| min_energie = energie | |
| min_debut = i | |
| centre_fenetre = min_debut + taille_fenetre // 2 | |
| if ajuster_zero_crossing: | |
| rayon_zc = max(1, int(sample_rate * zero_crossing_ms / 1000.0)) | |
| centre_fenetre = trouver_zero_crossing_proche(samples, centre_fenetre, rayon_zc) | |
| position = debut_extraction + (centre_fenetre / sample_rate) | |
| return position, position - timestamp | |
| def construire_bornes( | |
| fichier_entree: str, | |
| playlist: List[Tuple[str, str]], | |
| duree_totale: float, | |
| tolerance: float, | |
| fenetre_ms: float, | |
| sample_rate: int, | |
| ajuster_zero_crossing: bool, | |
| zero_crossing_ms: float, | |
| duree_min_piste: float, | |
| ) -> List[float]: | |
| bornes = [0.0] | |
| print("Recherche des points de coupe optimaux…") | |
| for i in range(1, len(playlist)): | |
| ts_theorique = timestamp_vers_secondes(playlist[i][0]) | |
| position, delta = trouver_point_le_plus_calme( | |
| fichier=fichier_entree, | |
| timestamp=ts_theorique, | |
| tolerance=tolerance, | |
| fenetre_ms=fenetre_ms, | |
| sample_rate=sample_rate, | |
| ajuster_zero_crossing=ajuster_zero_crossing, | |
| zero_crossing_ms=zero_crossing_ms, | |
| ) | |
| # Garde-fou : pas de retour en arrière | |
| position = max(position, bornes[-1]) | |
| # Garde-fou : pas au-delà de la durée totale | |
| position = min(position, duree_totale) | |
| # Garde-fou : respecter une durée minimale pour la piste précédente | |
| if position - bornes[-1] < duree_min_piste: | |
| position = min(duree_totale, bornes[-1] + duree_min_piste) | |
| signe = "+" if delta >= 0 else "" | |
| print( | |
| f" Piste {i+1:2d} « {playlist[i][1]:30s} » : " | |
| f"{ts_theorique:8.2f}s → {position:8.2f}s ({signe}{delta:.2f}s)" | |
| ) | |
| bornes.append(position) | |
| bornes.append(duree_totale) | |
| # Dernier garde-fou global | |
| for i in range(1, len(bornes)): | |
| if bornes[i] < bornes[i - 1]: | |
| bornes[i] = bornes[i - 1] | |
| return bornes | |
| def exporter_piste( | |
| fichier_entree: str, | |
| chemin_sortie: str, | |
| debut: float, | |
| duree: float, | |
| reencoder: bool, | |
| bitrate: str, | |
| titre: str, | |
| numero_piste: int, | |
| total_pistes: int, | |
| album: str, | |
| artiste: str = None, | |
| ) -> None: | |
| cmd = [ | |
| "ffmpeg", | |
| "-y", | |
| "-loglevel", "error", | |
| "-i", fichier_entree, | |
| "-ss", f"{debut:.6f}", | |
| "-t", f"{duree:.6f}", | |
| ] | |
| if reencoder: | |
| cmd += ["-c:a", "libmp3lame", "-b:a", bitrate] | |
| else: | |
| cmd += ["-c", "copy"] | |
| cmd += [ | |
| "-metadata", f"title={titre}", | |
| "-metadata", f"track={numero_piste}/{total_pistes}", | |
| "-metadata", f"album={album}", | |
| ] | |
| if artiste: | |
| cmd += ["-metadata", f"artist={artiste}"] | |
| cmd.append(chemin_sortie) | |
| executer(cmd, capture_output=False) | |
| def decouper( | |
| fichier_entree: str, | |
| playlist: List[Tuple[str, str]], | |
| dossier_sortie: str = None, | |
| reencoder: bool = True, | |
| album: str = None, | |
| artiste: str = None, | |
| tolerance: float = 5.0, | |
| fenetre_ms: float = 50.0, | |
| sample_rate: int = 8000, | |
| ajuster_zero_crossing: bool = True, | |
| zero_crossing_ms: float = 10.0, | |
| duree_min_piste: float = 1.0, | |
| bitrate: str = "192k", | |
| ) -> None: | |
| if not os.path.isfile(fichier_entree): | |
| sys.exit(f"Fichier introuvable : {fichier_entree}") | |
| verifier_outils() | |
| if not playlist: | |
| sys.exit("Playlist vide.") | |
| nom_base = os.path.splitext(os.path.basename(fichier_entree))[0] | |
| if dossier_sortie is None: | |
| dossier_sortie = os.path.join( | |
| os.path.dirname(os.path.abspath(fichier_entree)), | |
| nom_base, | |
| ) | |
| os.makedirs(dossier_sortie, exist_ok=True) | |
| duree_totale = duree_fichier(fichier_entree) | |
| nom_album = album or nom_base | |
| print(f"Fichier : {fichier_entree}") | |
| print(f"Durée totale : {duree_totale:.2f} s") | |
| print(f"Sortie : {dossier_sortie}") | |
| print(f"Mode : {'ré-encodage' if reencoder else 'copie directe'}") | |
| if reencoder: | |
| print(f"Bitrate : {bitrate}") | |
| print() | |
| bornes = construire_bornes( | |
| fichier_entree=fichier_entree, | |
| playlist=playlist, | |
| duree_totale=duree_totale, | |
| tolerance=tolerance, | |
| fenetre_ms=fenetre_ms, | |
| sample_rate=sample_rate, | |
| ajuster_zero_crossing=ajuster_zero_crossing, | |
| zero_crossing_ms=zero_crossing_ms, | |
| duree_min_piste=duree_min_piste, | |
| ) | |
| print( | |
| f"\nDécoupage en {len(playlist)} pistes " | |
| f"({'ré-encodage' if reencoder else 'copie directe'})…\n" | |
| ) | |
| largeur = len(str(len(playlist))) | |
| for i, (_, titre) in enumerate(playlist): | |
| debut = bornes[i] | |
| fin = min(bornes[i + 1], duree_totale) | |
| duree = fin - debut | |
| if debut >= duree_totale: | |
| print(f" ⚠ Piste {i+1:02d} ignorée : début au-delà de la durée totale.") | |
| continue | |
| if duree <= 0: | |
| print(f" ⚠ Piste {i+1:02d} ignorée : durée nulle ou négative.") | |
| continue | |
| numero = str(i + 1).zfill(largeur) | |
| nom_fichier = f"{numero} - {nettoyer_nom(titre)}.mp3" | |
| chemin_sortie = os.path.join(dossier_sortie, nom_fichier) | |
| exporter_piste( | |
| fichier_entree=fichier_entree, | |
| chemin_sortie=chemin_sortie, | |
| debut=debut, | |
| duree=duree, | |
| reencoder=reencoder, | |
| bitrate=bitrate, | |
| titre=titre, | |
| numero_piste=i + 1, | |
| total_pistes=len(playlist), | |
| album=nom_album, | |
| artiste=artiste, | |
| ) | |
| print( | |
| f" ✔ {numero} : {debut:8.2f}s → {fin:8.2f}s " | |
| f"({duree:6.2f}s) → {nom_fichier}" | |
| ) | |
| print(f"\nTerminé. Fichiers enregistrés dans : {dossier_sortie}") | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| "Découpe un MP3 selon une playlist, en cherchant un point de coupe " | |
| "audio calme près de chaque timestamp." | |
| ) | |
| ) | |
| parser.add_argument("fichier", help="Fichier MP3 à découper") | |
| parser.add_argument("-o", "--sortie", help="Dossier de sortie") | |
| parser.add_argument( | |
| "--copie", | |
| action="store_true", | |
| help="Copier sans ré-encoder (plus rapide mais moins précis)", | |
| ) | |
| parser.add_argument("--album", help="Nom de l'album (tag ID3)") | |
| parser.add_argument("--artiste", help="Nom de l'artiste (tag ID3)") | |
| parser.add_argument( | |
| "--tolerance", | |
| type=float, | |
| default=5.0, | |
| help="Demi-fenêtre de recherche en secondes autour du timestamp (défaut : 5)", | |
| ) | |
| parser.add_argument( | |
| "--fenetre-ms", | |
| type=float, | |
| default=50.0, | |
| help="Taille de la fenêtre RMS en ms (défaut : 50)", | |
| ) | |
| parser.add_argument( | |
| "--sample-rate", | |
| type=int, | |
| default=8000, | |
| help="Fréquence d'échantillonnage d'analyse (défaut : 8000)", | |
| ) | |
| parser.add_argument( | |
| "--sans-zero-crossing", | |
| action="store_true", | |
| help="Désactive l'ajustement sur le zero-crossing le plus proche", | |
| ) | |
| parser.add_argument( | |
| "--zero-crossing-ms", | |
| type=float, | |
| default=10.0, | |
| help="Rayon de recherche du zero-crossing en ms (défaut : 10)", | |
| ) | |
| parser.add_argument( | |
| "--duree-min-piste", | |
| type=float, | |
| default=1.0, | |
| help="Durée minimale d'une piste en secondes (défaut : 1.0)", | |
| ) | |
| parser.add_argument( | |
| "--bitrate", | |
| default="192k", | |
| help="Bitrate MP3 si ré-encodage, ex. 192k, 256k, 320k (défaut : 192k)", | |
| ) | |
| args = parser.parse_args() | |
| decouper( | |
| fichier_entree=args.fichier, | |
| playlist=PLAYLIST, | |
| dossier_sortie=args.sortie, | |
| reencoder=not args.copie, | |
| album=args.album, | |
| artiste=args.artiste, | |
| tolerance=args.tolerance, | |
| fenetre_ms=args.fenetre_ms, | |
| sample_rate=args.sample_rate, | |
| ajuster_zero_crossing=not args.sans_zero_crossing, | |
| zero_crossing_ms=args.zero_crossing_ms, | |
| duree_min_piste=args.duree_min_piste, | |
| bitrate=args.bitrate, | |
| ) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment