音声ファイルを無音区間で自動分割するPythonスクリプトをClaude Codeと作った話

音声ファイルを無音区間で自動分割するPythonスクリプトをClaude Codeと作った話

ffmpegのsilencedetectで無音区間を検出し、Pythonスクリプトで音声ファイルを自動分割する方法を解説。無音の長さ順で境界を選び、指定個数に分割できる。Claude Codeとの設計プロセスも紹介。
2026.04.27

手元の長い音声ファイルを複数のファイルに分割したいことはありませんか?私はまれによくあります。

ffmpeg で音声を加工できるらしいとは知っていたものの、オプションの多さに気後れして手をつけられずにいました。そこで Claude Code (AIコーディングエージェント)に「音声ファイルを無音区間で自動分割するPythonスクリプトを作りたい」と最初に投げてみました。

Claude Codeとの設計プロセス

無音区間検出の方針が決まるまで

最初の壁打ちで提案されたのが ffmpeg の silencedetect フィルタです。「silencedetect=noise=...dB:d=... のようにパラメーターを指定して実行すると、無音区間の開始・終了時刻が stderr に出力される。それをPythonの subprocess で受け取ってパースすれば分割点が得られる」という設計の骨格がここで固まりました。stderr に出るという点は最初ハマりかけたのですが、ffmpegのフィルタログは元々そういう仕様だと教えてもらい納得しました。

「無音の定義」を詰める

骨格が決まったあとは「無音とは何か」を具体的に決める必要がありました。「何dB以下を無音とみなすか(音量閾値)」と「何秒以上続いたら無音と呼ぶか(最短継続時間)」の2軸です。実際の音声ファイルで試しながら -30dB / 1.0秒 あたりを基準にしつつ、環境ノイズや息継ぎで本来1つの区切りが複数の短い無音として検出されるケースが出てきました。「近い時刻の無音はひとつに統合するとよい」という提案が出て、--merge-gap パラメーターの利用につながりました。

無音選択アルゴリズムの変更

設計を進める中で「 最終的にN個に分割 できればよい」という要件が明確になりました。最初の想定では「検出したすべての無音を境界にする」つもりでしたが、N個に揃えるには「どの無音を境界として採用するか」を選ぶ必要があります。ここで「無音が長いほど、明確な区切りである可能性が高い」という考えをもとに、長さ降順でソートして上位N-1個を採用するアルゴリズムを提案されました。シンプルで直感に合うと感じ、この方向で進めることにしました。

細部の磨き込み

最後は細かい詰めです。セグメントをぴったりの時刻で切るとブツッとした不自然さが出るため、前後数十msのパディングを入れる設計を追加しました。ffmpegで切り出す -ss オプションは -i の前に置く(fast seek)か後に置くかでトレードオフがあります。「音声ファイルであれば精度の劣化はほぼ気にならない水準。大量に切り出す場合は速度差が大きくなるため前置きfast seekがよい」と示してもらい、今回はfast seek側を採用しました。


この記事では出来上がったスクリプトを題材に、中でやっていることを解説します。作成したスクリプトの全文は、ブログの最後に載せています。

ffmpegとは?

ffmpeg は音声・動画ファイルの変換、カット、エンコード、フィルタ処理などをコマンドラインで行えるオープンソースツールです。Linux / macOS / Windowsで動き、対応フォーマットの広さとフィルタの豊富さから、動画編集ソフトや配信ツールの内部でも広く使われています。

一方でオプションが非常に多く、初めて触るときは何から調べればよいかわかりにくいかと思います。なので、今回は Claude Code にサポートしてもらいながら使ってみました。

https://ffmpeg.org/

今回作ったもの

以下の入出力でCLIとして動くPythonスクリプトを作りました。

入力

  • .mp3.m4a などの音声ファイル

出力

  • 元ファイル名-001.mp3, 元ファイル名-002.mp3, ... と連番のセグメントファイル群
  • セグメントの境界情報を記録した 元ファイル名-manifest.tsv

実行イメージは以下のとおりです。

# 全無音区間で分割(境界確認だけ)
python split_audio.py input.mp3 --dry-run
detected 11 silences merged to 11 selected 9 boundaries 10 segments
silence: 0.000s .. 1.108s  (1.108s)
001: 1.108s .. 2.066s  (0.958s)
silence: 2.066s .. 3.215s  (1.149s)
002: 3.215s .. 5.535s  (2.320s)
silence: 5.535s .. 7.184s  (1.649s)
003: 7.184s .. 10.921s  (3.737s)
silence: 10.921s .. 12.695s  (1.774s)
004: 12.695s .. 17.957s  (5.262s)
silence: 17.957s .. 19.997s  (2.040s)
005: 19.997s .. 24.857s  (4.860s)
silence: 24.857s .. 26.890s  (2.033s)
006: 26.890s .. 29.614s  (2.725s)
silence: 29.614s .. 31.740s  (2.126s)
007: 31.740s .. 35.275s  (3.535s)
silence: 35.275s .. 37.479s  (2.204s)
008: 37.479s .. 43.639s  (6.159s)
silence: 43.639s .. 45.354s  (1.716s)
009: 45.354s .. 48.156s  (2.802s)
silence: 48.156s .. 49.957s  (1.801s)
010: 49.957s .. 54.792s  (4.835s)
silence: 54.792s .. 57.353s  (2.561s)

# 10個に分割して out/ へ出力
python split_audio.py input.mp3 --target-count 10 --output-dir out/

--target-count を省略すると検出されたすべての無音区間を境界にします。指定するとちょうどN個のセグメントになるよう無音区間が選ばれます。

処理の全体像はこんな感じです。

仕組み:ffmpegのsilencedetect

silencedetect はffmpegのオーディオフィルタで、指定した音量以下が一定時間続いた区間を「無音」として検出します。

ffmpeg -i input.mp3 -af "silencedetect=noise=-30dB:d=1.0" -f null -
  • noise=-30dB — この音量以下を無音とみなす閾値
  • d=1.0 — 無音とみなす最短秒数(フィルター)

d を大きくすると短い区切りを見逃し、小さくすると細かなノイズも拾いやすくなります。本スクリプトではデフォルト値を 1.0秒 としています。

検出結果は stderr に出力されます。

[silencedetect @ 0x...] silence_start: 0
[silencedetect @ 0x...] silence_end: 1.148005 | silence_duration: 1.148005
[silencedetect @ 0x...] silence_start: 2.025556
[silencedetect @ 0x...] silence_end: 3.254853 | silence_duration: 1.229297

pythonプログラム上では、subprocessでffmpeg を呼び出し、 silence_endsilence_duration の行だけを正規表現でパースし、start = end - duration で開始時刻を算出してます。

設計のポイント

近接する無音をマージする

息継ぎや環境ノイズで、本来1つの区切り目が数十ms間隔で複数回検出されることがあります。--merge-gap 秒以内に連続する無音は1つに統合します。

split_audio.py
def merge_nearby(silences: list[Silence], merge_gap: float) -> list[Silence]:
    """merge_gap 秒以内に連続する無音区間を1つに統合する。"""
    merged: list[Silence] = []
    for si in silences:
        if merged and si.start - merged[-1].end <= merge_gap:
            prev = merged[-1]
            new_end = max(prev.end, si.end)
            merged[-1] = Silence(start=prev.start, end=new_end, duration=new_end - prev.start)
        else:
            merged.append(si)
    return merged

タイムラインを構築する

マージ後の無音リストから、[Silence, Content, Silence, Content, ..., Silence] と交互に並ぶタイムラインを作ります。Content は無音と無音の間のコンテンツ区間です。

split_audio.py
def build_timeline(silences: list[Silence], total: float) -> list[Silence | Content]:
    timeline: list[Silence | Content] = []
    pos = 0.0
    for si in silences:
        if si.start > pos:
            timeline.append(Content(start=pos, end=si.start))
        timeline.append(si)
        pos = si.end
    # 末尾の無音の後の微小区間(ffmpeg の silence_end が total より手前になる検出誤差)は無視する
    if total - pos > 0.1:
        timeline.append(Content(start=pos, end=total))
    return timeline

無音長でソートして上位を採用する

select_boundaries は音声の先頭(0.0秒)から始まる Silence と末尾(total秒)で終わる Silence を除いた中間 Silence のみを境界候補にします。--dry-run では先頭・末尾の無音も表示されるので、どこからコンテンツが始まるかを確認できます。

--target-count N が指定されたとき、N個のセグメントを作るためにN-1個の境界が必要です。無音が長いほど明確な区切り目である可能性が高いため、長さ降順で上位N-1個を選びます。

split_audio.py
# ※ 概念を示す簡略コードです
def select_boundaries(timeline: list[Silence | Content], target_count: int | None, total: float) -> list[Silence]:
    # 音声の先頭(0.0)から始まる Silence と末尾(total)で終わる Silence は除外する
    # build_timeline が末尾の微小 Content を 0.1s 閾値で省略するため同じ閾値で判定する
    eps = 0.1
    inner = [b for b in timeline if isinstance(b, Silence) and b.start > eps and total - b.end > eps]
    if target_count is None:
        # target_count 未指定のときはすべての内部 Silence を境界にする
        return inner
    needed = target_count - 1
    ...
    # 無音が長いほど明確な区切り目である可能性が高いため、長さ降順で上位を選ぶ
    selected = sorted(inner, key=lambda si: si.duration, reverse=True)[:needed]
    return sorted(selected, key=lambda si: si.start)

パディングで自然な切り出しにする

セグメントをぴったりで切ると再生開始・終了時にブツッとした違和感が出ることがあります。各セグメントの開始を padding 秒だけ前に、終了を padding 秒後にずらします(デフォルト40ms)。

split_audio.py
# ※ 概念を示す簡略コードです
def build_segments(timeline: list[Silence | Content], boundaries: list[Silence], total: float, padding: float) -> list[Segment]:
    first, last = timeline[0], timeline[-1]
    content_start = first.end if isinstance(first, Silence) else first.start
    content_end = last.start if isinstance(last, Silence) else last.end
    pairs = list(zip(
        [content_start] + [b.end for b in boundaries],
        [b.start for b in boundaries] + [content_end],
        strict=True,
    ))
    for i, (start, end) in enumerate(pairs, 1):
        padded_start = max(0.0, start - padding)
        padded_end = min(total, end + padding)
        ...

content_start / content_end はタイムラインの端を見て自動的に決まります。先頭が Silence なら content_start = first.end、Content(先頭が無音でない)なら first.start = 0.0 です。

切り出し部分の実装

実際の切り出しはffmpegの -ss / -t オプションを使います。

split_audio.py
command = [
    "ffmpeg",
    "-hide_banner",
    "-loglevel", "error",
    "-y",
    # -ss を -i より前に置くと高速な keyframe seek になる
    # 後ろに置くと精度が上がる代わりに先頭から全フレームをデコードするため遅い
    "-ss", f"{segment.start:.6f}",
    "-i", str(source),
    "-t", f"{segment.end - segment.start:.6f}",
    "-vn",  # 映像ストリームを除外
]

-ss-iに置くと入力ファイルのキーフレームにジャンプするfast seekになり、大量のセグメントを切り出すときに速度が大幅に改善します。音声ファイルに限って言えば精度の劣化はほぼ気になりません。

出力形式は拡張子で切り替えます。m4aはAACエンコード(128k)、mp3はMP3エンコード(96k)+44100Hzリサンプルです。

動作確認

まず --dry-run で分割結果を確認します。10個のセグメントを作るには9個の境界が必要なので、11個の無音から上位9個が選ばれています。

python split_audio.py input.mp3 --target-count 10 --dry-run
detected 11 silences → merged to 11 → selected 9 boundaries → 10 segments
  silence: 0.000s .. 1.108s  (1.108s)
  001: 1.108s .. 2.066s  (0.958s)
  silence: 2.066s .. 3.215s  (1.149s)
  002: 3.215s .. 5.535s  (2.320s)
  silence: 5.535s .. 7.184s  (1.649s)
  003: 7.184s .. 10.921s  (3.737s)
  silence: 10.921s .. 12.695s  (1.774s)
  004: 12.695s .. 17.957s  (5.262s)
  silence: 17.957s .. 19.997s  (2.040s)
  005: 19.997s .. 24.857s  (4.860s)
  silence: 24.857s .. 26.890s  (2.033s)
  006: 26.890s .. 29.614s  (2.725s)
  silence: 29.614s .. 31.740s  (2.126s)
  007: 31.740s .. 35.275s  (3.535s)
  silence: 35.275s .. 37.479s  (2.204s)
  008: 37.479s .. 43.639s  (6.159s)
  silence: 43.639s .. 45.354s  (1.716s)
  009: 45.354s .. 48.156s  (2.802s)
  silence: 48.156s .. 49.957s  (1.801s)
  010: 49.957s .. 54.792s  (4.835s)
  silence: 54.792s .. 57.353s  (2.561s)

先頭・末尾の無音と、セグメント間の無音が表示されます。境界が想定どおりであれば実際に切り出します。

python split_audio.py input.mp3 --target-count 10 --output-dir out/
detected 11 silences → merged to 11 → selected 9 boundaries → 10 segments
wrote 10 files to out

出力ディレクトリに input-001.mp3input-010.mp3input-manifest.tsv が生成されます。

file	start	end	duration
input-001	1.108	2.066	0.958
input-002	3.215	5.535	2.320
input-003	7.184	10.921	3.737
input-004	12.695	17.957	5.262
input-005	19.997	24.857	4.860
input-006	26.890	29.614	2.725
input-007	31.740	35.275	3.535
input-008	37.479	43.639	6.159
input-009	45.354	48.156	2.802
input-010	49.957	54.792	4.835

環境ノイズが多くて誤検出が出る場合は --noise-db を下げる(例: -45)と、より静かな部分だけを無音とみなすようになります。

おわりに

ffmpegの silencedetect とPythonのsubprocessを組み合わせると、シンプルな構成で音声分割スクリプトが作れました。

私はあまりffmpegについて詳しくなかったのですが、Claude Codeと壁打ちしながら、ffmpegってそういう風に使えるのかという発見があっておもしろかったです。

一方で、パラメーターの最終調整は人間の手が必要です。
対象ファイルの特性によって --noise-db--min-silence の調整が必要な場面が出てくるので、まず --dry-run で境界を確認してから本実行して生成物の音声を聞いて、自分の想定と合わなかったら修正するみたいな作業は必要です。

実作業をClaude Codeにやってもらいつつ、要件に合わせた仕様を人間が決める。みたいな分担ができてClaude Codeがとても役立ちました。

また、 find + xargs コマンドと組み合わせれば、複数ファイルの一括処理もできます。

このブログがどなたかのお役に立てれば幸いです。

参考資料

ソースコード全文
split_audio.py
#!/usr/bin/env python3
"""音声ファイルを無音区間でセグメントに分割する CLI ツール。

依存: ffmpeg / ffprobe (PATH に通っていること)
"""

import argparse
import csv
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path

SILENCE_RE = re.compile(r"silence_end:\s*([\d.]+)\s*\|\s*silence_duration:\s*([\d.]+)")

@dataclass(frozen=True)
class Silence:
    """検出された無音区間。"""
    start: float
    end: float
    duration: float

@dataclass(frozen=True)
class Content:
    """Silence と Silence の間のコンテンツ区間。"""
    start: float
    end: float

@dataclass(frozen=True)
class Segment:
    """切り出し対象のセグメント(padding 適用済み)。"""
    index: int
    start: float
    end: float

def probe_duration(path: Path) -> float:
    """ffprobe で音声ファイルの長さ(秒)を返す。"""
    result = subprocess.run(
        [
            "ffprobe",
            "-v", "error",
            "-show_entries", "format=duration",
            "-of", "default=noprint_wrappers=1:nokey=1",
            str(path),
        ],
        text=True,
        capture_output=True,
        check=True,
    )
    return float(result.stdout.strip())

def detect_silences(path: Path, noise_db: float, min_silence: float) -> list[Silence]:
    """ffmpeg silencedetect フィルタで無音区間を検出して返す。"""
    result = subprocess.run(
        [
            "ffmpeg",
            "-hide_banner",
            "-nostats",
            "-i", str(path),
            "-af", f"silencedetect=noise={noise_db}dB:d={min_silence}",
            "-f", "null", "-",
        ],
        text=True,
        capture_output=True,
        check=True,
    )
    # ffmpeg はフィルタのログを stdout ではなく stderr に出力する
    silences: list[Silence] = []
    for line in result.stderr.splitlines():
        m = SILENCE_RE.search(line)
        if m:
            end = float(m.group(1))
            duration = float(m.group(2))
            # silence_start 行は使わず、end - duration で start を算出する
            silences.append(Silence(start=end - duration, end=end, duration=duration))
    return silences

def merge_nearby(silences: list[Silence], merge_gap: float) -> list[Silence]:
    """merge_gap 秒以内に連続する無音区間を1つに統合する。"""
    merged: list[Silence] = []
    for si in silences:
        if merged and si.start - merged[-1].end <= merge_gap:
            prev = merged[-1]
            new_end = max(prev.end, si.end)
            merged[-1] = Silence(start=prev.start, end=new_end, duration=new_end - prev.start)
        else:
            merged.append(si)
    return merged

def build_timeline(silences: list[Silence], total: float) -> list[Silence | Content]:
    """Silence リストから Silence と Content を交互に並べたタイムラインを構築する。"""
    timeline: list[Silence | Content] = []
    pos = 0.0
    for si in silences:
        if si.start > pos:
            timeline.append(Content(start=pos, end=si.start))
        timeline.append(si)
        pos = si.end
    # 末尾の無音の後の微小区間(ffmpeg の silence_end が total より手前になる検出誤差)は無視する
    if total - pos > 0.1:
        timeline.append(Content(start=pos, end=total))
    return timeline

def select_boundaries(timeline: list[Silence | Content], target_count: int | None, total: float) -> list[Silence]:
    """タイムラインから境界 Silence を選ぶ。先頭・末尾の Silence は除外する。"""
    # build_timeline が末尾の微小 Content を 0.1s 閾値で省略するため同じ閾値で判定する
    eps = 0.1
    inner = [b for b in timeline if isinstance(b, Silence) and b.start > eps and total - b.end > eps]
    if target_count is None:
        # target_count 未指定のときはすべての内部 Silence を境界にする
        return inner
    needed = target_count - 1
    if needed < 0:
        raise ValueError(f"target-count must be >= 1, got {target_count}")
    if len(inner) < needed:
        raise ValueError(
            f"need {needed} boundaries for {target_count} segments, "
            f"but only {len(inner)} silences detected"
        )
    # 無音が長いほど明確な区切り目である可能性が高いため、長さ降順で上位を選ぶ
    selected = sorted(inner, key=lambda si: si.duration, reverse=True)[:needed]
    return sorted(selected, key=lambda si: si.start)

def build_segments(timeline: list[Silence | Content], boundaries: list[Silence], total: float, padding: float) -> list[Segment]:
    """タイムラインと境界 Silence から Segment を生成し、前後に padding 秒を付与する。"""
    first, last = timeline[0], timeline[-1]
    content_start = first.end if isinstance(first, Silence) else first.start
    content_end = last.start if isinstance(last, Silence) else last.end
    pairs = list(zip(
        [content_start] + [b.end for b in boundaries],
        [b.start for b in boundaries] + [content_end],
        strict=True,
    ))
    segments: list[Segment] = []
    for i, (start, end) in enumerate(pairs, 1):
        padded_start = max(0.0, start - padding)
        padded_end = min(total, end + padding)
        if padded_end <= padded_start:
            raise ValueError(f"invalid segment {i}: {start:.3f}..{end:.3f}")
        segments.append(Segment(index=i, start=padded_start, end=padded_end))
    return segments

def write_segment(
    segment: Segment,
    source: Path,
    output_dir: Path,
    prefix: str,
    extension: str,
) -> None:
    """ffmpeg でセグメントを切り出してファイルに保存する。"""
    output = output_dir / f"{prefix}{segment.index:03d}.{extension}"
    command = [
        "ffmpeg",
        "-hide_banner",
        "-loglevel", "error",
        "-y",
        # -ss を -i より前に置くと高速な keyframe seek になる
        # 後ろに置くと精度が上がる代わりに先頭から全フレームをデコードするため遅い
        "-ss", f"{segment.start:.6f}",
        "-i", str(source),
        "-t", f"{segment.end - segment.start:.6f}",
        "-vn",  # 映像ストリームを除外
    ]
    if extension == "m4a":
        command += ["-c:a", "aac", "-b:a", "128k"]
    elif extension == "mp3":
        command += ["-af", "aresample=44100", "-c:a", "libmp3lame", "-b:a", "96k"]
    else:
        raise ValueError(f"unsupported extension: {extension}")
    command.append(str(output))
    subprocess.run(command, check=True)

def write_manifest(segments: list[Segment], output_dir: Path, prefix: str) -> None:
    """セグメントの境界情報を TSV ファイルに出力する。"""
    tsv_path = output_dir / f"{prefix.rstrip('-_')}-manifest.tsv"
    with tsv_path.open("w", encoding="utf-8", newline="") as f:
        writer = csv.writer(f, delimiter="\t")
        writer.writerow(["file", "start", "end", "duration"])
        for s in segments:
            writer.writerow(
                [
                    f"{prefix}{s.index:03d}",
                    f"{s.start:.3f}",
                    f"{s.end:.3f}",
                    f"{s.end - s.start:.3f}",
                ]
            )

def main() -> None:
    parser = argparse.ArgumentParser(description="音声ファイルを無音区間でセグメントに分割する")
    parser.add_argument("input", help="入力音声ファイル")
    parser.add_argument("--output-dir", default="out")
    parser.add_argument("--target-count", type=int, default=None)
    parser.add_argument("--prefix", default=None, help="出力ファイルのプレフィックス(省略時は元ファイル名)")
    parser.add_argument("--extension", choices=["mp3", "m4a"], default="mp3")
    parser.add_argument("--noise-db", type=float, default=-30.0)
    parser.add_argument("--min-silence", type=float, default=1.0)
    parser.add_argument("--merge-gap", type=float, default=0.1)
    parser.add_argument("--padding", type=float, default=0.04)
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()

    source = Path(args.input)
    output_dir = Path(args.output_dir)
    prefix = args.prefix if args.prefix is not None else f"{source.stem}-"

    try:
        total = probe_duration(source)
        raw_silences = detect_silences(source, args.noise_db, args.min_silence)
        silences = merge_nearby(raw_silences, args.merge_gap)
        timeline = build_timeline(silences, total)
        boundaries = select_boundaries(timeline, args.target_count, total)
        segments = build_segments(timeline, boundaries, total, args.padding)
    except (ValueError, subprocess.CalledProcessError) as e:
        parser.exit(1, f"error: {e}\n")

    print(f"detected {len(raw_silences)} silences → merged to {len(silences)} → selected {len(boundaries)} boundaries → {len(segments)} segments")

    if args.dry_run:
        if isinstance(timeline[0], Silence):
            print(f"  silence: 0.000s .. {segments[0].start:.3f}s  ({segments[0].start:.3f}s)")
        for i, s in enumerate(segments):
            print(f"  {s.index:03d}: {s.start:.3f}s .. {s.end:.3f}s  ({s.end - s.start:.3f}s)")
            if i < len(segments) - 1:
                silence_start = s.end
                silence_end = segments[i + 1].start
                print(f"  silence: {silence_start:.3f}s .. {silence_end:.3f}s  ({silence_end - silence_start:.3f}s)")
        if isinstance(timeline[-1], Silence):
            print(f"  silence: {segments[-1].end:.3f}s .. {total:.3f}s  ({total - segments[-1].end:.3f}s)")
        return

    output_dir.mkdir(parents=True, exist_ok=True)
    write_manifest(segments, output_dir, prefix)
    for s in segments:
        write_segment(s, source, output_dir, prefix, args.extension)
    print(f"wrote {len(segments)} files to {output_dir}")

if __name__ == "__main__":
    main()

この記事をシェアする

関連記事