中級者向け

【保存版】Python自動化:フォルダ内の演奏時間集計→YouTube概要欄チャプターの生成

以前も似たサンプルコード紹介していますが、結構使うコードですので、最新情報をアップデートしようと思います。

  • Macユーザーの音楽家/エンジニア
  • JupyterLabやconda環境でPythonを使っている人
  • アルバムまとめ動画のYouTube概要欄にチャプターを一括生成したい人

この記事でできること

  • フォルダ内の音源(01 タイトル.wav など)を番号順に走査
  • 演奏時間を自動取得mutagen があれば高精度/なければ macOSのmdlsで代用
  • YouTubeのチャプター行0:00 タイトル (mm:ss))を自動生成し、コピペするだけでOK

このサンプルコードを使えば、Youtubeチャプターの編集時間をカットすることができます。

    サンプルコード

    解説はコメントアウトにて。

    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    """
    YouTube概要欄のチャプター行を自動生成(フォルダパスはコード内で固定)。
    
    目的:
        - フォルダ内の音源ファイルを「先頭番号順(01,02,...)」に並べ、
          各曲の演奏時間からチャプター行を生成します。
        - mutagen が使えれば優先。無い場合は macOS の `mdls` を使って秒数を取得します。
    
    使い方:
        1) 下の TARGET_DIR をご自分のフォルダに書き換える(今回は既に指定済み)。
        2) 端末で実行:  python3 make_youtube_chapters_fixed.py
        3) 出力されたテキストを YouTube の概要欄にコピペ。
    
    Example:
        出力例の1行: 0:00  01 オッペケペー (1:41)
    """
    
    from __future__ import annotations
    import re
    import subprocess
    from pathlib import Path
    from typing import List, Optional, Tuple
    
    # === ★ ここを書き換えるだけでOK(分析したいフォルダのパスを書いてください) ==================
    TARGET_DIR = Path("ここにパスを貼り付け")
    # ====================================================================================
    
    # 対象とする拡張子(必要に応じて追加)
    AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".aac", ".aif", ".aiff", ".flac", ".ogg", ".oga", ".wma", ".mp4", ".mka"}
    
    
    # ---------- 基本ユーティリティ ----------
    
    def hhmmss_from_seconds(sec: float) -> str:
        """秒を h:mm:ss / m:ss に整形(例: 101→'1:41', 3661→'1:01:01')。"""
        total = int(round(sec))
        h, r = divmod(total, 3600)
        m, s = divmod(r, 60)
        return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
    
    
    def extract_leading_number(name: str) -> int:
        """ファイル名の先頭連番('01 ...' → 1)を抽出。無ければ 9999(末尾へ)。"""
        m = re.match(r"^(\d+)", name)
        return int(m.group(1)) if m else 9999
    
    
    # ---------- 演奏時間の取得(mutagen 優先 / mdls フォールバック) ----------
    
    def duration_seconds_mutagen(path: Path) -> Optional[float]:
        """mutagen で長さ(秒)を取得。mutagen無い/未対応なら None。"""
        try:
            from mutagen import File as MutagenFile  # 遅延インポートで未導入でもOK
        except Exception:
            return None
        try:
            m = MutagenFile(str(path))
            if m and getattr(m, "info", None) and getattr(m.info, "length", None):
                return float(m.info.length)
        except Exception:
            pass
        return None
    
    
    def duration_seconds_mdls(path: Path) -> Optional[float]:
        """macOSの `mdls` から kMDItemDurationSeconds を取得。取れなければ None。"""
        try:
            out = subprocess.run(
                ["mdls", "-name", "kMDItemDurationSeconds", str(path)],
                capture_output=True, text=True, check=False
            ).stdout
            if "=" in out:
                val = out.split("=", 1)[1].strip()
                if val and val.lower() != "(null)":
                    return float(val)
        except Exception:
            pass
        return None
    
    
    # ---------- フォルダ走査とチャプター作成 ----------
    
    def scan_folder(folder: Path) -> List[Tuple[str, float]]:
        """
        フォルダ内の音源を「先頭番号順」に並べ、(タイトル, 秒) の配列を返す。
        タイトルは拡張子を除いたファイル名(例: '01 オッペケペー')。
        """
        files = [
            p for p in folder.iterdir()
            if p.is_file() and not p.name.startswith(".") and p.suffix.lower() in AUDIO_EXTS
        ]
        files.sort(key=lambda p: extract_leading_number(p.name))  # 01, 02, 03 ... の順
    
        items: List[Tuple[str, float]] = []
        for p in files:
            sec = duration_seconds_mutagen(p)
            if sec is None:
                sec = duration_seconds_mdls(p)
            if sec is None:
                # 取得できないファイルはスキップ(必要なら 0 秒扱いに変更可)
                continue
            items.append((p.stem, float(sec)))
        return items
    
    
    def make_chapters(items: List[Tuple[str, float]]) -> List[str]:
        """
        (タイトル, 秒) → チャプター行の配列を返す。
        例: ["0:00  01 タイトル (1:41)", "1:41  02 次曲 (2:51)", ...]
        """
        chapters: List[str] = []
        t = 0.0
        for title, sec in items:
            chapters.append(f"{hhmmss_from_seconds(t)}  {title} ({hhmmss_from_seconds(sec)})")
            t += sec
        return chapters
    
    
    def main() -> None:
        # 1) フォルダ存在チェック
        if not TARGET_DIR.is_dir():
            print(f"[Error] Folder not found: {TARGET_DIR}")
            return
    
        # 2) 走査 → (タイトル, 秒) を得る
        items = scan_folder(TARGET_DIR)
        if not items:
            print("[Info] 対象が見つかりませんでした。")
            return
    
        # 3) チャプター生成
        chapters = make_chapters(items)
    
        # 4) 画面に出力(YouTube概要欄へコピペ)
        print("\n=== YouTube Chapters ===\n")
        print("\n".join(chapters))
    
    
    if __name__ == "__main__":
        main()
    

    mutagenを使う場合

    mutagenを使えば以下のようなことが実現できます。

    • 演奏時間(length) の取得
    • サンプルレート(sample_rate)/チャンネル数(channels)/ビットレート(bitrate) の取得
    • タグ読取:ID3(MP3)、MP4(M4A/MP4)、FLAC(VorbisComment)、OGG、ASF(WMA)など
    • アートワークの抽出(ID3 APIC、MP4 “covr”、FLAC PICTURE など)

    インストール方法ですが、(Mac/Anaconda/Jupyter 対応)conda推奨です。

    # 新規環境(任意)
    conda create -n audiochap python=3.11 -y
    conda activate audiochap
    
    # mutagen を conda-forge から
    conda install -c conda-forge mutagen -y
    

    pip / venvの場合

    python3 -m venv .venv
    source .venv/bin/activate
    pip install --upgrade pip
    pip install mutagen
    

    JupyterLab の“今のカーネル”に入れる(セルで実行)場合

    import sys, subprocess
    print(sys.executable)  # 参照中のPython確認
    subprocess.check_call([sys.executable, "-m", "pip", "install", "mutagen"])
    

    サンプルコード

    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    """
    YouTube概要欄のチャプター行を生成(再帰走査・mutagen→mdls フォールバック付き)
    
    - サブフォルダも含めて音源を探索(rglob)
    - mutagen で長さが取れない場合は macOS の mdls を使用
    - 先頭番号(01, 02, ...)で数値ソート
    - 最後に成功/失敗の集計を表示して原因を把握しやすくする
    """
    
    from __future__ import annotations
    from pathlib import Path
    from typing import Optional, List, Tuple
    import subprocess
    import re
    
    # === ★ フォルダパス(こうたろうさんのフォルダ) ===================================
    TARGET_DIR = Path("ここにパス")
    # ===================================================================================
    
    # 対象拡張子(小文字想定)。空集合なら「全ファイル試す」でもOK
    AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".aac", ".aif", ".aiff", ".flac", ".ogg", ".oga", ".wma", ".mp4", ".mka"}
    
    # ---- まず mutagen を試す(未導入でも動くように遅延import) -----------------------
    def duration_seconds_mutagen(path: Path) -> Optional[float]:
        try:
            from mutagen import File as MutagenFile
        except Exception:
            return None
        try:
            m = MutagenFile(str(path))
            if m and getattr(m, "info", None) and getattr(m.info, "length", None):
                return float(m.info.length)
        except Exception:
            pass
        return None
    
    # ---- だめなら mdls(macOS標準) ---------------------------------------------------
    def duration_seconds_mdls(path: Path) -> Optional[float]:
        try:
            out = subprocess.run(
                ["mdls", "-name", "kMDItemDurationSeconds", str(path)],
                capture_output=True, text=True, check=False
            ).stdout
            if "=" in out:
                val = out.split("=", 1)[1].strip()
                if val and val.lower() != "(null)":
                    return float(val)
        except Exception:
            pass
        return None
    
    # ---- ユーティリティ ---------------------------------------------------------------
    def hhmmss_from_seconds(sec: float) -> str:
        total = int(round(sec))
        h, r = divmod(total, 3600)
        m, s = divmod(r, 60)
        return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
    
    def extract_leading_number(name: str) -> int:
        m = re.match(r"^(\d+)", name)
        return int(m.group(1)) if m else 9999
    
    # ---- 走査&取得 -------------------------------------------------------------------
    def scan_folder_recursive(folder: Path) -> List[Tuple[str, float]]:
        files = []
        for p in folder.rglob("*"):
            if not p.is_file() or p.name.startswith("."):
                continue
            if AUDIO_EXTS and p.suffix.lower() not in AUDIO_EXTS:
                continue
            files.append(p)
    
        # 01,02,... の先頭番号で安定ソート
        files.sort(key=lambda p: extract_leading_number(p.name))
    
        items: List[Tuple[str, float]] = []
        stats = {"total": 0, "ok_mutagen": 0, "ok_mdls": 0, "fail": 0}
        failed_samples: List[str] = []
    
        for p in files:
            stats["total"] += 1
            sec = duration_seconds_mutagen(p)
            if sec is not None:
                stats["ok_mutagen"] += 1
            else:
                sec = duration_seconds_mdls(p)
                if sec is not None:
                    stats["ok_mdls"] += 1
                else:
                    stats["fail"] += 1
                    if len(failed_samples) < 5:
                        failed_samples.append(p.name)
                    continue
            items.append((p.stem, float(sec)))
    
        # 診断出力
        print("\n--- Scan summary --------------------------------")
        print(f"Scanned files : {stats['total']}")
        print(f"✓ mutagen     : {stats['ok_mutagen']}")
        print(f"✓ mdls        : {stats['ok_mdls']}")
        print(f"✗ failed      : {stats['fail']}")
        if failed_samples:
            print("Examples of failed files:", ", ".join(failed_samples))
        print("-------------------------------------------------\n")
    
        return items
    
    def make_chapters(items: List[Tuple[str, float]]) -> List[str]:
        chapters: List[str] = []
        t = 0.0
        for title, sec in items:
            chapters.append(f"{hhmmss_from_seconds(t)}  {title} ({hhmmss_from_seconds(sec)})")
            t += sec
        return chapters
    
    def main() -> None:
        if not TARGET_DIR.is_dir():
            print(f"[Error] Folder not found: {TARGET_DIR}")
            return
        items = scan_folder_recursive(TARGET_DIR)
        if not items:
            print("[Info] 音源は見つかりましたが、長さが取得できませんでした。拡張子やメタデータをご確認ください。")
            print("      ・WAVでも特殊コーデック/壊れたヘッダだと取得できない場合があります。")
            print("      ・Spotlight未インデックス時は `mdimport \"<folder>\"` をお試しください。")
            return
    
        chapters = make_chapters(items)
        print("=== YouTube Chapters ===\n")
        print("\n".join(chapters))
    
    if __name__ == "__main__":
        main()
    
    朝比奈幸太郎

    音楽家:朝比奈幸太郎

    神戸生まれ。2025 年、40 年近く住んだ神戸を離れ北海道・十勝へ移住。
    録音エンジニア五島昭彦氏より金田式バランス電流伝送 DC 録音技術を承継し、 ヴィンテージ機材で高品位録音を実践。
    ヒーリング音響ブランド「Curanz Sounds」でソルフェジオ周波数音源を配信。
    “音の文化を未来へ”届ける活動を展開中。