OpenAI Whisper 進行語音轉文字(Audio to Text),「將檔案切成小片段」是否比較有效率

關於使用 OpenAI Whisper 進行語音轉文字(Audio to Text),「將檔案切成小片段」是否比較有效率,答案是:是的,通常比較有效率,但前提是「切分的方式要正確」。

如果只是單純為了速度而粗暴地切割檔案(例如固定每 60 秒切一刀),可能會導致辨識準確度下降。

以下為您詳細分析為什麼要切分、以及如何切分才是最好的策略:


1. 為什麼切成小檔案比較有效率?(優點)

  • 平行處理(Parallel Processing / Batching):這是最大的優勢。如果您將一個 1 小時的檔案切成 60 個 1 分鐘的檔案,您可以利用 GPU 的「批次處理(Batching)」功能,同時辨識多個片段。這比依序讀取一個長檔案要快得多。
  • 記憶體管理(VRAM Usage):雖然 Whisper 的記憶體使用量主要取決於模型大小(如 large-v3),但處理極長的音訊檔案有時會導致記憶體溢出(OOM)或處理緩衝區過大的問題。切分檔案可以讓記憶體負載更穩定。
  • 避免累積錯誤(Hallucination Loop):Whisper 在遇到長時間的靜音或背景噪音時,有時會產生「幻覺」(Hallucinations),例如不斷重複同一句話。切分檔案可以強制模型在每個新片段「重置」狀態,減少這種錯誤無限延續的風險。
  • 容錯率:如果處理長檔案到 99% 時程式崩潰,您可能要從頭再來;如果是小檔案,您只需要重新跑失敗的那一小段。

2. 切分檔案的潛在風險(缺點)

  • 上下文遺失(Context Loss):Whisper 是利用上下文來判斷同音異字的。如果您剛好切在一個句子的中間,模型會失去前後文,導致誤判。例子: 如果您在「人工智…」切斷,下一段是「…能」,模型可能分別辨識成錯誤的字,而不是完整的「人工智慧」。
  • 邊界效應:切分點附近的音訊可能會因為波形被截斷而產生雜音,影響辨識。

3. 如何正確地切分?(最佳實務)

為了兼顧效率與準確率,絕對不要使用「固定時間切割」(例如每 30 秒切一次),建議採用以下策略:

A. 使用 VAD(語音活動檢測)切割

這是最標準的做法。利用 VAD (Voice Activity Detection) 演算法,找出音訊中的「靜音片段」,在說話者停頓換氣的地方進行切割。

  • 優點: 確保句子是完整的,不會切斷單字。
  • 工具: pyannote-audio 或 Silero VAD。

B. 使用優化過的函式庫(推薦 faster-whisper

原版的 OpenAI Whisper 程式碼在處理長檔案時速度較慢。目前社群最推薦使用的是 faster-whisper

  • 原理: 它底層使用了 CTranslate2 引擎,比原版快 4-5 倍,並且內建了很好的 VAD 處理邏輯。它會自動在記憶體中幫您做好「切分 -> 批次辨識 -> 合併」的動作,您不需要手動去切實體檔案。

C. 若必須手動切檔 (使用 FFmpeg)

如果您因為某些原因必須手動切檔,請利用 ffmpegsilencedetect 功能來尋找靜音點切割,而不是單純用 -segment_time


4. 總結比較表

處理方式速度效率準確度記憶體消耗建議程度
單一長檔案 (原版 Whisper)低 (依序處理)高 (上下文完整)高 (可能不穩)⭐⭐
固定時間切割 (如每30秒)高 (可平行) (容易切斷句子)⭐ (不建議)
VAD 靜音切割 (手動/程式)極高 (可平行)高 (保留句子完整)⭐⭐⭐⭐
使用 faster-whisper極高最佳化⭐⭐⭐⭐⭐

這是一個非常好的優化方向。將原版的 openai-whisper CLI 替換為 faster-whisper,並利用其內建的 VAD (Voice Activity Detection) 機制來達成「自動切檔」與「合併輸出」的效果,速度會提升 4~5 倍,且記憶體佔用更低。

修改重點說明

  1. 引入 faster-whisper:不再使用 subprocess 呼叫外部指令,而是直接在 Python 內部執行,這樣可以省去重複載入模型的時間。
  2. 使用 BackgroundTasks:因為轉檔是耗時運算,直接執行會卡死網頁伺服器,改用 FastAPI 的背景任務處理。
  3. 自動切檔 (VAD):在 model.transcribe 中開啟 vad_filter=True,這就是最有效率的「切檔」方式(只辨識有人聲的片段,跳過靜音)。
  4. 自動合併 (SRT Generator):程式會即時將辨識出來的片段(segments)寫入 SRT 檔案,無需事後合併。

修改後的程式碼

請確保您已經安裝了 faster-whisper:

Bash

pip install faster-whisper

以下是修改後的完整 app.py

Python

import os
import shutil
import shlex
import subprocess
import math
from typing import List

import uvicorn
from fastapi import FastAPI, Request, Form, BackgroundTasks
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import JSONResponse

# 引入 faster-whisper
# 請確保已安裝: pip install faster-whisper
from faster_whisper import WhisperModel

app = FastAPI()

if not os.path.exists("static"):
    os.makedirs("static")
app.mount("/static", StaticFiles(directory="static"), name="static")

templates = Jinja2Templates(directory="templates")

if not os.path.exists("templates"):
    os.makedirs("templates")

# --- 輔助函式:將秒數轉為 SRT 時間格式 (00:00:00,000) ---
def format_timestamp(seconds: float):
    x = seconds
    hours = int(x // 3600)
    x %= 3600
    minutes = int(x // 60)
    x %= 60
    seconds = int(x)
    milliseconds = int((x - seconds) * 1000)
    return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"

# --- 核心邏輯:使用 Faster-Whisper 執行辨識 ---
def run_faster_whisper_task(input_file: str, language: str, model_size: str, device: str, output_formats: List[str]):
    """
    背景執行的核心函數:
    1. 載入模型
    2. 使用 VAD 自動切分並辨識
    3. 即時寫入 SRT/TXT 檔案 (自動合併)
    """
    try:
        print(f"--- 開始處理: {input_file} (Device: {device}, Model: {model_size}) ---")
        
        # 決定計算類型 (GPU 用 float16, CPU 用 int8 以加速)
        compute_type = "float16" if device == "cuda" else "int8"
        
        # 1. 載入模型 (這只需要做一次,比切成實體小檔案反覆載入快得多)
        model = WhisperModel(model_size, device=device, compute_type=compute_type)

        # 2. 開始轉錄
        # vad_filter=True : 這就是「自動切檔」的關鍵,它會忽略靜音片段,只辨識人聲
        segments, info = model.transcribe(input_file, language=language, vad_filter=True)

        # 準備輸出檔名
        base_name = os.path.splitext(input_file)[0]
        srt_path = f"{base_name}.srt"
        txt_path = f"{base_name}.txt"
        
        # 開啟檔案準備寫入 (這裡演示同時輸出 srt 和 txt,如果需要)
        # 透過 with open 保持檔案開啟,逐行寫入,達成「自動合併」的效果
        
        print(f"偵測到語言: {info.language} (信心度: {info.language_probability})")
        
        with open(srt_path, "w", encoding="utf-8") as f_srt, \
             open(txt_path, "w", encoding="utf-8") as f_txt:
            
            for i, segment in enumerate(segments, start=1):
                # 處理時間戳
                start_time = format_timestamp(segment.start)
                end_time = format_timestamp(segment.end)
                text = segment.text.strip()
                
                # 寫入 SRT 格式
                if "srt" in output_formats or "all" in output_formats:
                    f_srt.write(f"{i}\n")
                    f_srt.write(f"{start_time} --> {end_time}\n")
                    f_srt.write(f"{text}\n\n")
                    # 強制刷新緩衝區,讓使用者能看到檔案變大
                    f_srt.flush() 

                # 寫入 TXT 格式
                if "txt" in output_formats or "all" in output_formats:
                    f_txt.write(f"{text}\n")
                    f_txt.flush()
                
                # 在 Console 顯示進度
                print(f"[{start_time} -> {end_time}] {text}")

        print(f"--- 處理完成: {srt_path} ---")

    except Exception as e:
        print(f"Faster-Whisper 執行錯誤: {e}")

# ---------------------------------------------------------

@app.get("/")
async def read_root(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})

@app.post("/run")
async def run_cutter(
    input_file: str = Form(...),
    srt_file: str = Form(None),
    output_file: str = Form(None),
    highpass: float = Form(80),
    afftdn: float = Form(12),
    aecho: str = Form("0.8:0.3:40:0.2"),
    speechnorm_e: float = Form(4),
    speechnorm_p: float = Form(0.9)
):
    # (此處保持您原本的 video_cutter 邏輯不變)
    cmd = ["python", "video_cutter.py", input_file]
    if srt_file: cmd.extend(["--srt", srt_file])
    if output_file: cmd.extend(["--output", output_file])
    if highpass > 0: cmd.extend(["--highpass", str(highpass)])
    else: cmd.extend(["--highpass", "0"])
    if afftdn > 0: cmd.extend(["--afftdn", str(afftdn)])
    if aecho and aecho.lower() != "none" and aecho != "0": cmd.extend(["--aecho", aecho])
    cmd.extend(["--speechnorm-e", str(speechnorm_e)])
    cmd.extend(["--speechnorm-p", str(speechnorm_p)])

    full_command = " ".join(shlex.quote(arg) for arg in cmd)
    try:
        subprocess.Popen(cmd)
        message = "後端已啟動剪輯程序 (PID active)。"
    except Exception as e:
        message = f"啟動失敗: {str(e)}"

    return JSONResponse({
        "status": "success",
        "command": full_command,
        "message": message
    })

@app.post("/extract-audio")
async def extract_audio(input_video: str = Form(...)):
    if not os.path.exists(input_video):
        return JSONResponse({"status": "error", "message": "找不到輸入影片檔案"})

    base_name = os.path.splitext(input_video)[0]
    output_mp3 = base_name + ".mp3"

    cmd = [
        "ffmpeg", "-y", "-i", input_video,
        "-ar", "16000", "-ac", "1", "-ab", "16k",
        output_mp3
    ]
    full_command = " ".join(shlex.quote(arg) for arg in cmd)

    try:
        subprocess.run(cmd, check=True)
        return JSONResponse({
            "status": "success",
            "output_path": output_mp3,
            "command": full_command,
            "message": f"轉檔成功!已輸出至: {output_mp3}"
        })
    except subprocess.CalledProcessError as e:
        return JSONResponse({"status": "error", "message": f"FFmpeg 執行錯誤: {e}"})
    except Exception as e:
        return JSONResponse({"status": "error", "message": f"未知錯誤: {e}"})

# --- 修改後的 Whisper Endpoint ---
@app.post("/run-whisper")
async def run_whisper(
    background_tasks: BackgroundTasks,  # 注入 BackgroundTasks
    input_mp3: str = Form(...),
    language: str = Form("zh"),
    model: str = Form("base"),
    device: str = Form("cpu"),
    output_formats: str = Form(...) 
):
    """
    呼叫 Faster-Whisper 進行高效能辨識
    """
    if not os.path.exists(input_mp3):
        return JSONResponse({"status": "error", "message": "找不到輸入音訊檔案"})

    # 處理格式字串 "srt,txt" -> ["srt", "txt"]
    formats_list = output_formats.split(',')

    # 將耗時任務加入 BackgroundTasks
    # 這樣 API 會立刻回傳 success,而轉檔會在後台繼續跑
    background_tasks.add_task(
        run_faster_whisper_task, 
        input_mp3, 
        language, 
        model, 
        device, 
        formats_list
    )

    return JSONResponse({
        "status": "success",
        "message": f"Faster-Whisper 已在背景啟動。\n檔案: {input_mp3}\n模型: {model}\n裝置: {device}\n(請查看後端 Console 監控進度)"
    })

if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8000)

為什麼這樣改比較好?

  1. 不再使用 Subprocess 呼叫 CLI:原本的寫法是產生一個 shell process 去跑 whisper,這會導致每次都要重新載入 PyTorch 和模型檔案(約 1-3 GB),非常慢。改成 Python Library 呼叫後,如果您未來想做「批次上傳」,模型只需要載入記憶體一次。
  2. VAD Filter (自動切檔):model.transcribe(…, vad_filter=True) 這一行是精髓。它使用 Silero VAD 引擎快速掃描音訊,只在有人說話的時間點進行辨識。這比使用 FFmpeg 物理切割檔案更精準,而且不會切斷單字。
  3. 串流寫入 (自動合併):原本的邏輯若要切檔,得變成:切檔 -> 辨識 file1 -> 辨識 file2 -> … -> 合併 txt。現在的邏輯是:faster-whisper 內部生成一個 generator(產生器),程式碼中的 for segment in segments: 迴圈會一邊辨識、一邊吐出結果。我們直接將結果寫入同一個檔案 (f_srt.write),當迴圈結束時,檔案自然就是合併好的完整版。

下一步建議

如果您要處理非常長的影片(例如 3 小時以上),建議在 BackgroundTasks 中增加一個簡單的狀態追蹤機制(例如寫入一個 JSON 檔紀錄進度),這樣前端可以用 Polling 的方式顯示進度條。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *