要把影片裡的語音交給語音模型(例如 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.pts = None:原影片的時間戳是以原本的取樣率計算的,轉換後就對不上了,所以清掉,讓編碼器重新計算。out_stream.encode(None):編碼器內部會暫存一些資料,最後一定要送一次None,把剩下的資料寫出來,不然結尾會少一小段。
這個函式是串流處理,一次只處理一個 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() 存檔。
為什麼讀取要多繞幾步
直接從中間開始讀,會遇到三個問題,程式裡都有處理:
- seek 不精準:
container.seek()會跳到目標時間「之前」最近的一個可以開始解碼的位置,不會剛好停在你要的那一秒。所以程式記下實際開始解碼的時間first_time,最後再依時間差把多讀的樣本切掉,切點可以精準到單一樣本。 - 剛 seek 完的第一個 frame 解不完整:aac、mp3 這類壓縮格式,每個 frame 都要靠前一個 frame 才能完整還原。所以 seek 時刻意往前多跳 1 秒,讓不完整的部分落在後來會被切掉的範圍裡。實測如果不多跳這 1 秒,從 mp4 直接擷取的片段,數值會和完整解碼的結果差到三萬多。
- 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 擷取,就能做到每個樣本都精準。
要注意的地方
- 輸出 mp3 時開頭的靜音:mp3 編碼器會在開頭加入幾十毫秒的延遲。實測用 PyAV 讀回剛擷取的 mp3,長度剛好是指定的秒數,因為 FFmpeg 會依 LAME 寫入的資訊自動去掉這段延遲;但有些播放器不會處理,聽起來開頭會多一小段靜音。需要精準的話請用 wav。
- 只處理第一條音軌:程式都用
streams.audio[0],影片有多條音軌(例如多國語言)時,要改成對應的索引。 - 聲道數:
CHANNEL_LAYOUTS只列了單聲道與雙聲道。5.1 聲道的檔案請用video_to_audio()時指定channels=1或2先降成一般聲道。