Skip to content
Greg's Space
Go back

內網沒有 ffmpeg 怎麼辦?用 PyAV 把影片轉成聲音檔,再擷取想要的時間區間

要把影片裡的語音交給語音模型(例如 Gemma 4 這類能吃音訊的模型)處理,第一步通常是用 ffmpeg 把音軌抽出來。但公司內網的機器常常不能裝系統套件,apt install ffmpeg 這條路走不通。

這時可以改用 PyAV。它是 FFmpeg 的 Python 綁定,而且官方 wheel 已經內建 FFmpeg 函式庫,用 pip 裝好就能用,系統上不需要另外安裝 ffmpeg。

這篇提供五個函式,每個都可以單獨複製使用:

函式功能
video_to_audio()影片 → wav / mp3,順便轉取樣率與聲道
read_audio() / write_audio()讀出指定時間區間的樣本、把樣本寫回檔案(下面三個函式的基礎)
cut_range()擷取「第幾秒到第幾秒」
cut_duration()擷取「從第幾秒開始、多長」
split_by_duration()把整個檔案每 N 秒切成一段

安裝

pip install av numpy

如果內網機器完全不能上網,可以先在能上網的機器用 pip download av numpy -d wheelhouse 把 wheel 抓下來,或是用這篇文章的方法把現有環境重新打包,再帶進內網安裝:

pip install --no-index --find-links wheelhouse av numpy

裝好後確認一下 mp3 編碼器在不在:

import av
print(av.__version__)
print("libmp3lame" in av.codecs_available)   # True 才能輸出 mp3

官方 wheel 都有包含 libmp3lame。如果是自己從原始碼編譯的 PyAV,或 conda 環境裡的 FFmpeg 沒有附 lame,這裡會是 False,那就只能輸出 wav;wav 用的是 FFmpeg 內建的 PCM 編碼器,一定能用。

本文程式在 PyAV 19.0.1、numpy 2.3、Python 3.12 上測試過。

共用設定

所有函式都依輸出檔的副檔名決定編碼器,所以檔案開頭先放兩個對照表:

import os
from pathlib import Path

import av
import numpy as np

# 副檔名 → 編碼器。wav 用 FFmpeg 內建的 PCM,一定能用;mp3 需要 libmp3lame
AUDIO_CODECS = {
    ".wav": "pcm_s16le",
    ".mp3": "libmp3lame",
}

# 聲道數 → 聲道配置名稱。有些 wav 讀出來的名稱是 "1 channels",mp3 編碼器不接受
CHANNEL_LAYOUTS = {1: "mono", 2: "stereo"}

想多支援其他格式,在 AUDIO_CODECS 加一行就好,例如 ".flac": "flac"。


1. 影片轉聲音檔:video_to_audio()

import os
from pathlib import Path

import av
import numpy as np

# 副檔名 → 編碼器。wav 用 FFmpeg 內建的 PCM,一定能用;mp3 需要 libmp3lame
AUDIO_CODECS = {
    ".wav": "pcm_s16le",
    ".mp3": "libmp3lame",
}

# 聲道數 → 聲道配置名稱。有些 wav 讀出來的名稱是 "1 channels",mp3 編碼器不接受
CHANNEL_LAYOUTS = {1: "mono", 2: "stereo"}


def video_to_audio(src, dst, sample_rate=16000, channels=1):
    """
    把影片(mp4、mkv、mov…)的音軌轉成聲音檔,輸出格式依 dst 的副檔名決定。

    sample_rate:取樣率,預設 16000(語音模型常用);給 None 表示沿用原檔
    channels:1 是單聲道、2 是雙聲道;給 None 表示沿用原檔
    """

    codec = AUDIO_CODECS[Path(dst).suffix.lower()]

    with av.open(src) as inp, av.open(dst, "w") as out:
        in_stream = inp.streams.audio[0]
        out_stream = out.add_stream(
            codec,
            rate=sample_rate or in_stream.rate,
            layout=CHANNEL_LAYOUTS[channels or in_stream.channels],
        )

        for frame in inp.decode(in_stream):
            frame.pts = None                          # 時間戳交給編碼器重新計算
            for packet in out_stream.encode(frame):   # 編碼器會自動轉取樣率與聲道
                out.mux(packet)

        for packet in out_stream.encode(None):        # 把編碼器裡剩下的資料寫出去
            out.mux(packet)

使用方式:

# 語音模型常用的 16 kHz 單聲道 wav
video_to_audio("meeting.mp4", "meeting.wav")

# 轉成 mp3
video_to_audio("meeting.mp4", "meeting.mp3")

# 保留原本的取樣率與聲道(例如 44.1 kHz 雙聲道)
video_to_audio("meeting.mp4", "meeting_orig.wav", sample_rate=None, channels=None)

輸入不限於 mp4,mkv、mov、webm、m4a 這些 FFmpeg 讀得懂的格式都可以。

運作方式: 解碼影片的音軌,把每個 frame 交給輸出的編碼器。add_stream() 時指定的取樣率和聲道,PyAV 會在 encode() 裡自動轉換,所以不需要自己處理重新取樣。

兩個小細節:

這個函式是串流處理,一次只處理一個 frame,再長的影片也不會吃掉太多記憶體。


2. 讀寫樣本:read_audio() 與 write_audio()

擷取片段的做法是:先把需要的那一段讀成 numpy array,用切片取出精確的範圍,再寫回檔案。下面三個擷取函式都靠這兩個函式,複製擷取函式時,記得連這兩個一起複製。

def read_audio(src, start=0.0, end=None):
    """
    讀取聲音檔 start 秒到 end 秒的內容(end 給 None 表示讀到結尾)。
    回傳 (samples, sample_rate):samples 是 int16 array,形狀為 (聲道數, 樣本數)。
    """

    with av.open(src) as container:
        stream = container.streams.audio[0]
        sample_rate = stream.rate
        layout = CHANNEL_LAYOUTS[stream.channels]

        # mp3 這類格式的時間戳不是從 0 開始(編碼器延遲),先記下起點
        base = float(stream.start_time * stream.time_base) if stream.start_time else 0.0

        # 取樣率與聲道不變,只把格式統一成 int16,所以不會產生延遲或偏移
        resampler = av.AudioResampler(format="s16p", layout=layout, rate=sample_rate)

        # aac、mp3 剛 seek 完的第一個 frame 解不完整,所以多往前讀 1 秒,之後再切掉
        if start > 1.0:
            container.seek(int((start - 1.0 + base) / stream.time_base), stream=stream)

        chunks = []
        first_time = None
        for frame in container.decode(stream):
            frame_time = frame.time - base if frame.time is not None else None
            if first_time is None:
                first_time = frame_time or 0.0
            if end is not None and frame_time is not None and frame_time > end:
                break
            chunks += [f.to_ndarray() for f in resampler.resample(frame)]
        chunks += [f.to_ndarray() for f in resampler.resample(None)]

    if not chunks:
        return np.zeros((stream.channels, 0), dtype=np.int16), sample_rate

    samples = np.concatenate(chunks, axis=1)
    begin = max(0, round((start - first_time) * sample_rate))
    if end is None:
        return samples[:, begin:], sample_rate
    return samples[:, begin:begin + round((end - start) * sample_rate)], sample_rate


def write_audio(dst, samples, sample_rate):
    """把 read_audio 讀到的 int16 array 寫成聲音檔,格式依 dst 的副檔名決定。"""

    codec = AUDIO_CODECS[Path(dst).suffix.lower()]
    layout = CHANNEL_LAYOUTS[samples.shape[0]]

    with av.open(dst, "w") as out:
        out_stream = out.add_stream(codec, rate=sample_rate, layout=layout)

        frame = av.AudioFrame.from_ndarray(
            np.ascontiguousarray(samples), format="s16p", layout=layout
        )
        frame.sample_rate = sample_rate

        for packet in out_stream.encode(frame):
            out.mux(packet)
        for packet in out_stream.encode(None):
            out.mux(packet)

read_audio() 回傳的 samples 形狀是 (聲道數, 樣本數),型別是 int16。因為是普通的 numpy array,你也可以自己做其他處理(例如調整音量、接起多段),再交給 write_audio() 存檔。

為什麼讀取要多繞幾步

直接從中間開始讀,會遇到三個問題,程式裡都有處理:

  1. seek 不精準:container.seek() 會跳到目標時間「之前」最近的一個可以開始解碼的位置,不會剛好停在你要的那一秒。所以程式記下實際開始解碼的時間 first_time,最後再依時間差把多讀的樣本切掉,切點可以精準到單一樣本。
  2. 剛 seek 完的第一個 frame 解不完整:aac、mp3 這類壓縮格式,每個 frame 都要靠前一個 frame 才能完整還原。所以 seek 時刻意往前多跳 1 秒,讓不完整的部分落在後來會被切掉的範圍裡。實測如果不多跳這 1 秒,從 mp4 直接擷取的片段,數值會和完整解碼的結果差到三萬多。
  3. mp3 的時間戳不是從 0 開始:mp3 有編碼器延遲,第一個 frame 的時間戳大約是 0.069 秒,但聲音其實是從 0 秒開始的。所以程式先用 stream.start_time 算出起點 base,再把每個 frame 的時間扣掉它。

另外,有些 wav 檔讀出來的聲道配置名稱是 "1 channels" 而不是 "mono",mp3 編碼器不接受這種名稱,所以讀寫時都依聲道數,從 CHANNEL_LAYOUTS 查出標準的名稱。


3. 擷取時間區間:cut_range()

def cut_range(src, dst, start, end):
    """擷取 start 秒到 end 秒之間的片段,存成 dst。"""

    if end <= start:
        raise ValueError(f"end({end})必須大於 start({start})")

    samples, sample_rate = read_audio(src, start, end)
    write_audio(dst, samples, sample_rate)
# 擷取 1:30 ~ 2:45
cut_range("meeting.wav", "clip.wav", 90, 165)

# 秒數可以有小數,輸出也可以直接存成 mp3
cut_range("meeting.wav", "clip.mp3", 123.456, 130)

end 超過檔案長度也沒關係,會擷取到結尾為止。


4. 擷取指定長度:cut_duration()

def cut_duration(src, dst, duration, start=0.0):
    """從 start 秒開始,擷取 duration 秒長的片段,存成 dst。"""

    if duration <= 0:
        raise ValueError(f"duration({duration})必須大於 0")

    cut_range(src, dst, start, start + duration)
# 前 30 秒
cut_duration("meeting.wav", "head.wav", 30)

# 從第 60 秒開始,擷取 10 秒
cut_duration("meeting.wav", "clip.wav", 10, start=60)

它只是把「開始加長度」換算成「開始到結束」,再交給 cut_range(),所以複製時要連 cut_range() 一起帶走。


5. 切成固定長度的多段:split_by_duration()

def split_by_duration(src, out_dir, duration, suffix=".wav"):
    """
    把整個聲音檔每 duration 秒切成一段,存成 out_dir/part_000.wav、part_001.wav…
    最後一段可能比 duration 短。回傳所有片段的路徑。
    """

    if duration <= 0:
        raise ValueError(f"duration({duration})必須大於 0")

    samples, sample_rate = read_audio(src)
    os.makedirs(out_dir, exist_ok=True)

    step = round(duration * sample_rate)
    paths = []
    for index, begin in enumerate(range(0, samples.shape[1], step)):
        path = os.path.join(out_dir, f"part_{index:03d}{suffix}")
        write_audio(path, samples[:, begin:begin + step], sample_rate)
        paths.append(path)
    return paths
# 每 5 分鐘切一段,方便分批送進語音模型
paths = split_by_duration("meeting.wav", "parts", 300)
print(paths)   # ['parts/part_000.wav', 'parts/part_001.wav', ...]

# 也可以輸出成 mp3
split_by_duration("meeting.wav", "parts_mp3", 300, suffix=".mp3")

這個函式會先把整個檔案讀進記憶體再切,切出來的每一段接起來會和原檔一模一樣,不會在切點多出或漏掉樣本。16 kHz 單聲道的 int16 音訊,一小時大約只佔 115 MB,一般的會議錄音都沒問題;如果是好幾個小時的高取樣率雙聲道檔案,建議先用 video_to_audio() 轉成 16 kHz 單聲道再切。


完整程式碼

全部放在同一個檔案,存成 audio_tools.py 就能 from audio_tools import ... 使用。

檔案位置:audio_tools.py

import os
from pathlib import Path

import av
import numpy as np

# 副檔名 → 編碼器。wav 用 FFmpeg 內建的 PCM,一定能用;mp3 需要 libmp3lame
AUDIO_CODECS = {
    ".wav": "pcm_s16le",
    ".mp3": "libmp3lame",
}

# 聲道數 → 聲道配置名稱。有些 wav 讀出來的名稱是 "1 channels",mp3 編碼器不接受
CHANNEL_LAYOUTS = {1: "mono", 2: "stereo"}


def video_to_audio(src, dst, sample_rate=16000, channels=1):
    """
    把影片(mp4、mkv、mov…)的音軌轉成聲音檔,輸出格式依 dst 的副檔名決定。

    sample_rate:取樣率,預設 16000(語音模型常用);給 None 表示沿用原檔
    channels:1 是單聲道、2 是雙聲道;給 None 表示沿用原檔
    """

    codec = AUDIO_CODECS[Path(dst).suffix.lower()]

    with av.open(src) as inp, av.open(dst, "w") as out:
        in_stream = inp.streams.audio[0]
        out_stream = out.add_stream(
            codec,
            rate=sample_rate or in_stream.rate,
            layout=CHANNEL_LAYOUTS[channels or in_stream.channels],
        )

        for frame in inp.decode(in_stream):
            frame.pts = None                          # 時間戳交給編碼器重新計算
            for packet in out_stream.encode(frame):   # 編碼器會自動轉取樣率與聲道
                out.mux(packet)

        for packet in out_stream.encode(None):        # 把編碼器裡剩下的資料寫出去
            out.mux(packet)


def read_audio(src, start=0.0, end=None):
    """
    讀取聲音檔 start 秒到 end 秒的內容(end 給 None 表示讀到結尾)。
    回傳 (samples, sample_rate):samples 是 int16 array,形狀為 (聲道數, 樣本數)。
    """

    with av.open(src) as container:
        stream = container.streams.audio[0]
        sample_rate = stream.rate
        layout = CHANNEL_LAYOUTS[stream.channels]

        # mp3 這類格式的時間戳不是從 0 開始(編碼器延遲),先記下起點
        base = float(stream.start_time * stream.time_base) if stream.start_time else 0.0

        # 取樣率與聲道不變,只把格式統一成 int16,所以不會產生延遲或偏移
        resampler = av.AudioResampler(format="s16p", layout=layout, rate=sample_rate)

        # aac、mp3 剛 seek 完的第一個 frame 解不完整,所以多往前讀 1 秒,之後再切掉
        if start > 1.0:
            container.seek(int((start - 1.0 + base) / stream.time_base), stream=stream)

        chunks = []
        first_time = None
        for frame in container.decode(stream):
            frame_time = frame.time - base if frame.time is not None else None
            if first_time is None:
                first_time = frame_time or 0.0
            if end is not None and frame_time is not None and frame_time > end:
                break
            chunks += [f.to_ndarray() for f in resampler.resample(frame)]
        chunks += [f.to_ndarray() for f in resampler.resample(None)]

    if not chunks:
        return np.zeros((stream.channels, 0), dtype=np.int16), sample_rate

    samples = np.concatenate(chunks, axis=1)
    begin = max(0, round((start - first_time) * sample_rate))
    if end is None:
        return samples[:, begin:], sample_rate
    return samples[:, begin:begin + round((end - start) * sample_rate)], sample_rate


def write_audio(dst, samples, sample_rate):
    """把 read_audio 讀到的 int16 array 寫成聲音檔,格式依 dst 的副檔名決定。"""

    codec = AUDIO_CODECS[Path(dst).suffix.lower()]
    layout = CHANNEL_LAYOUTS[samples.shape[0]]

    with av.open(dst, "w") as out:
        out_stream = out.add_stream(codec, rate=sample_rate, layout=layout)

        frame = av.AudioFrame.from_ndarray(
            np.ascontiguousarray(samples), format="s16p", layout=layout
        )
        frame.sample_rate = sample_rate

        for packet in out_stream.encode(frame):
            out.mux(packet)
        for packet in out_stream.encode(None):
            out.mux(packet)


def cut_range(src, dst, start, end):
    """擷取 start 秒到 end 秒之間的片段,存成 dst。"""

    if end <= start:
        raise ValueError(f"end({end})必須大於 start({start})")

    samples, sample_rate = read_audio(src, start, end)
    write_audio(dst, samples, sample_rate)


def cut_duration(src, dst, duration, start=0.0):
    """從 start 秒開始,擷取 duration 秒長的片段,存成 dst。"""

    if duration <= 0:
        raise ValueError(f"duration({duration})必須大於 0")

    cut_range(src, dst, start, start + duration)


def split_by_duration(src, out_dir, duration, suffix=".wav"):
    """
    把整個聲音檔每 duration 秒切成一段,存成 out_dir/part_000.wav、part_001.wav…
    最後一段可能比 duration 短。回傳所有片段的路徑。
    """

    if duration <= 0:
        raise ValueError(f"duration({duration})必須大於 0")

    samples, sample_rate = read_audio(src)
    os.makedirs(out_dir, exist_ok=True)

    step = round(duration * sample_rate)
    paths = []
    for index, begin in enumerate(range(0, samples.shape[1], step)):
        path = os.path.join(out_dir, f"part_{index:03d}{suffix}")
        write_audio(path, samples[:, begin:begin + step], sample_rate)
        paths.append(path)
    return paths

一個從影片到分段的完整流程:

from audio_tools import video_to_audio, cut_range, split_by_duration

video_to_audio("meeting.mp4", "meeting.wav")            # 1. 影片 → 16 kHz 單聲道 wav
cut_range("meeting.wav", "intro.wav", 0, 90)            # 2. 取出開場的 1 分半
split_by_duration("meeting.wav", "parts", 300)          # 3. 整段每 5 分鐘切一份

實測結果

我用一支 200 秒的測試影片(H.264 畫面加上 44.1 kHz 雙聲道 AAC 音軌)驗證每個函式,並拿系統上的 ffmpeg 當比對基準。

和 ffmpeg CLI 比較(ffmpeg -i video.mp4 -vn -ac 1 -ar 16000 ref.wav):

項目結果
樣本對齊完全對齊,沒有位移
內容差異只有結尾不同,其他樣本全部相同
長度PyAV 轉出 3,200,000 個樣本,剛好 200.000 秒;ffmpeg CLI 在結尾多補了 267 個樣本(約 17 毫秒)

擷取精準度:把擷取出來的片段,和「整個檔案解碼後直接用 array 切片」的結果逐一比對:

來源格式結果
wav(16 kHz 單聲道、44.1 kHz 雙聲道)長度與每個樣本都完全相同
mp3長度與每個樣本都完全相同
直接從 mp4(AAC)擷取長度相同,數值最多差 1(浮點數換算成整數的捨入誤差);只有檔案最後約 50 毫秒差異較大

AAC 最後那 50 毫秒的差異,用 ffmpeg CLI 的 -ss 擷取也會發生,是 AAC 解碼器在 seek 後處理最後一個 frame 的特性。所以建議先用 video_to_audio() 轉成 wav,再從 wav 擷取,就能做到每個樣本都精準。

要注意的地方



Previous Post
沒有網路也能搬 Python 環境:把 venv 裡已安裝的套件重新打包成 .whl