Colab + WhisperX 將音檔轉成逐字稿

前幾天在聽podcast時聽到一段不錯的內容,不過因為我用的不是 Apple Podcast, 沒有逐字稿,索性把之前在 colab 上跑的 fast whisper 拿出來用。但因為 podcast 是對談,想分不同的講者,於是找了一下有沒有 solution。發現大多是用 pyannote 去進行說話人分割 (diarization),然後再 對齊 (align)。 目前 whisperX 已經有支援。

whisperX 之前有「停更」過一段,今年又恢復更新。於是找了些資料,把 colab 的版本架了起來。後面又發現中文的斷句和標點有些麻煩, GPT 介紹的幾個作法不是不好用,就是有幻覺。最後還是選擇直接叫 GPT 來修飾文字,畢竟本來就是語言模型。

過程中發現 ChatGPT 很有耐心地關心我的需求和碰到的錯誤,真的像是要一步步地帶著我完成所有的程式碼。雖然產出的東西偶爾會有錯,我也會自己手殘去修改一些我要的邏輯。不過如果回饋給他,他會記住,並且在後續的版本持續完善整個程式碼。

這些 code 完全由我自己寫的已經 <50% 了, vibe coding 真是驚人。

以下是 音檔轉中文逐字稿 (WhisperX 2025) 的 code 和 簡單的講解說明。


1. 首先是掛載 Google Drive

用途:是將自己的 Google Drive 掛載到 Colab 以便讀取/儲存音檔與結果檔。

from google.colab import drive
drive.mount('/content/drive')

重點說明

  • 執行後會跳出授權連結,必須點擊確認授權
  • 路徑 /content/drive 是預設位置,也可改成其他子資料夾。

2. 安裝與環境配置

用途:移除舊版 PyTorch,安裝相容的 PyTorch、WhisperX、Diarization 等套件,並做 CUDA、cuDNN 的微調。

!pip uninstall torch torchvision torchaudio -y

# follow https://github.com/m-bain/whisperX/issues/1087
!pip install torch==2.5.1 torchvision==0.20.1 torchaudio==2.5.1 --index-url https://download.pytorch.org/whl/cu121

!pip install ctranslate2==4.4.0
!pip install faster-whisper==1.1.0
!pip install whisperx==3.3.1

!apt-get update
!apt-get install libcudnn8=8.9.2.26-1+cuda12.1
!apt-get install libcudnn8-dev=8.9.2.26-1+cuda12.1

!python -c "import torch; torch.backends.cuda.matmul.allow_tf32 = True; torch.backends.cudnn.allow_tf32 = True"


# 為了使用說話人分割(Diarization)功能,需要 pyannote.audio
# 注意:pyannote.audio 需要你同意 Hugging Face 上的模型使用條款
# 並且需要一個 Hugging Face 的 User Access Token
# !pip install pyannote.audio==2.1.1 --quiet # WhisperX README 推薦特定版本,檢查最新推薦
# 或者嘗試讓 pip 自動選擇最新的 3.x 版本
#!pip install pyannote.audio>=3.0.0 --quiet
!pip install pyannote.audio

重點說明

  1. 版本鎖定:Colab CUDA 版本常改,需指定與 CUDA 12.1 相容的 cuDNN(libcudnn8=8.9.2.26-1+cuda12.1)以避免運行時找不到函式或版本不符。我試過用當時最 (2025/4) 最新的版本 3.3.2,跑起來會 crash。後來還是乖乖地降版回去跑。細節可以參考 https://github.com/m-bain/whisperX/issues/1087https://github.com/m-bain/whisperX/issues/1114
  2. Diarization 套件pyannote.audio 預設不含依賴檔,首次載入 Hugging Face 模型前,需要在 notebook 最上方設定環境變數 HUGGING_FACE_HUB_TOKEN。或是在左邊找到 Secret (錀匙符號) 設定這個變數。


3. 載入函式庫與參數設定

用途:匯入 WhisperX、Torch、Colab 工具、設定模型大小、音檔路徑與 Hugging Face Token。

import whisperx
import torch
import gc
import os

# from faster_whisper import WhisperModel
from tqdm import tqdm
import os
from google.colab import files
from google.colab import userdata



model_size = "large-v2" # tiny, base, small, medium, large, large-v2, large-v3
batch_size = 16 # reduce if low on GPU mem
device="cuda"

# 設定檔案路徑
audio_path = "/content/drive/MyDrive/Colab Notebooks/test_sample.mp3" # 替換成你的檔案名稱

HF_TOKEN = userdata.get('HF_TOKEN')
os.environ['HUGGING_FACE_HUB_TOKEN'] = HF_TOKEN

重點說明

  • userdata.get('HF_TOKEN') 會抓 Colab → 代碼儲存的使用者資料欄位,請確保事先將 Token 寫入 Colab 使用者資料。
  • batch_size 若遇到 OOM,可適度降低至 4、8。我自己試是還好,沒有 Out of memory。
  • model_size 我自己是選 large-v2。往下不建議,往上可以到 “large-v3”。但之前用其他版本跑 large-v3 時,有時會出現幻覺。這次沒仔細測試。

4. WhisperX 轉錄、對齊與說話人分配

用途:一條龍執行:語音轉文字 → 字詞對齊 → 說話人分割 → 將字詞貼上對應說話人。


# 1. Transcribe with original whisper (batched)
print("正在載入 Whisper 模型...")
model = whisperx.load_model(model_size, device, compute_type="float16")

print(f"正在載入音訊檔案: {audio_path}")
audio = whisperx.load_audio(audio_path)

print("正在進行轉錄...")
result = model.transcribe(audio, batch_size=batch_size, chunk_size=6)
# print( result["segments"]) # before alignment

# 2. Align whisper output
print("正在載入對齊模型...")
model_a, metadata = whisperx.load_align_model(language_code=result["language"], device=device)
aligned_result = whisperx.align(result["segments"], model_a, metadata, audio, device, return_char_alignments=True, interpolate_method="linear")
# print(aligned_result["segments"]) # after alignment


# 3. Assign speaker labels
print("正在載入說話人分割模型...")
diarize_model = whisperx.DiarizationPipeline(device=device)
diarize_segments = diarize_model(audio)
# add min/max number of speakers if known
# diarize_segments = diarize_model(audio, min_speakers=1, max_speakers=2)
# print(diarize_segments)


print("正在將說話人標籤分配給詞語...")
final_result = whisperx.assign_word_speakers(diarize_segments, aligned_result)
# print(final_result["segments"]) # segments are now assigned speaker IDs



print("\n--- 最終結果 (片段與說話人) ---")

重點說明

  • chunk_size=6:每段音訊切 6 秒,增加對齊精度,但過小會導致重疊、過多段落。我建議中文設 6.。
  • 對齊模型load_align_model 回傳的 metadata 包含字元/時間對應資訊;interpolate_method="linear" 可平滑短詞的時間戳。我自己測試有些微差異 (切字出來的成果),不過實務上中文還是有不少切不好的地方,所以還是先設 linear,後續再丟 GPT 順一次。
  • Diarization:如已知講者數,可傳入 min_speakersmax_speakers 參數,否則自動估算。
  • 輸出 final_result["segments"] 為每段詞語級別的列表,格式類似:
{
  "word": "你好",
  "start": 1.23,
  "end": 1.45,
  "speaker": "SPEAKER_00"
}
  • 實作上會發現分割的時間點即使切得很細,對齊的結果仍然有落差。有時候會出現對齊後格式內只有word,沒有start/end 時間資訊,有時是沒有 speaker 資訊。
  • 另外一個問題是有時最後一個字會跑到下一個說話人那邊去 (或是反過來),可能是中文加上兩個說話人在搶話,也有可能是切話的時間資訊有些微誤差。像是下面的狀況:
[00:14:30 - 00:14:32] SPEAKER_00: 那你為什麼這麼強烈地想要追求邏輯我
[00:14:32 - 00:16:18] SPEAKER_01: 不知道欸就像有些人沒辦法接受桌子很亂
  • 以上兩個問題都困擾了我蠻久,最後決定留到下一個階段處理。

5. 合併同講者詞與 GPT-4 斷句

用途:將同一講者的話合併,再呼叫 OpenAI GPT-4 為長句補標點並斷句。

!pip install openai
print("\n--------------- 中文merge 與斷句處理  ---------\n")


import openai
import time
import os


HF_TOKEN = userdata.get('OPENAI_API')
os.environ['OPENAI_API_KEY'] = HF_TOKEN

##########################################

# 先把同一個發言者的字合併


def merge_words_by_speaker(segments):
    merged = []
    current = {
        "speaker": None,
        "text": "",
        "start": None,
        "end": None
    }

    pending_unknown = None  # 用來暫存沒有時間的 UNKNOWN 詞

    for segment in segments:
        for word in segment.get("words", []):

            speaker = word.get("speaker", "UNKNOWN")
            word_text = word["word"]

            # 處理 UNKNOWN 且沒時間標記的詞
            if speaker == "UNKNOWN" and "start" not in word and "end" not in word:
                # 當作當前 speaker 的延伸,先暫存
                current["text"] += word_text
                pending_unknown = word_text
                continue

            # speaker 改變時,儲存上一段
            if current["speaker"] != speaker:
                if current["text"]:
                    merged.append(current)

                # 如果有 pending_unknown,要接到下一個 speaker 的開頭
                if pending_unknown:
                    word_text = pending_unknown + word_text
                    pending_unknown = None

                current = {
                    "speaker": speaker,
                    "text": word_text,
                    "start": word.get("start"),
                    "end": word.get("end")
                }

            else:
                # 同一位 speaker 的詞
                pending_unknown = None
                current["text"] += word_text
                if "end" in word:
                    current["end"] = word["end"]


    # 加入最後一段
    if current["text"]:
        merged.append(current)

    return merged






# 用 GPT-4 做斷句處理


client = openai.OpenAI()  # 用環境變數設定 OPENAI_API_KEY

def punctuate_with_gpt(text, model="gpt-4o-mini", max_retry=3):
    prompt = f"""請幫我將以下沒有標點的中文話語補上合適的標點(例如句號、逗號、問號等),並分成自然語言語句:

{text}

輸出時只需要修正後的文本,不需要其他解釋。"""

    for _ in range(max_retry):
        try:
            response = client.chat.completions.create(
                model=model,
                messages=[{"role": "user", "content": prompt}],
                temperature=0.3
            )
            return response.choices[0].message.content.strip()
        except Exception as e:
            print(f"API error: {e}")
            time.sleep(2)
    return text


# 對合併後的結果做 GPT 斷句處理
def process_segments_with_gpt(merged_results, length_threshold=10):
    processed = []
    for segment in merged_results:
        raw_text = segment['text']
        if len(raw_text) >= length_threshold:
            processed_text = punctuate_with_gpt(raw_text)
        else:
            processed_text = raw_text

        processed.append({
            "speaker": segment.get("speaker", "未知說話人"),
            "start": segment.get("start"),
            "end": segment.get("end"),
            "text": processed_text
        })
    return processed



# Step 4: 輸出結果
def print_segments(segments):
    for seg in segments:
        start = seg["start"]
        end = seg["end"]
        speaker = seg["speaker"]
        text = seg["text"]
        print(f"[{start:.2f}s - {end:.2f}s] {speaker}: {text}")






  # 使用方式

print("正在合併同一個發言者的發言...\n")
merged_results = merge_words_by_speaker(final_result["segments"])

print("正在使用 GPT 作斷句和標點處理…...\n")
final_sentences = process_segments_with_gpt(merged_results)

重點說明

  • merge_words_by_speaker 用來把同一發言者 (說話人)的話合併。 合併邏輯:遇到 speaker 變動時,才將先前的文字推入 merged 清單;對於 UNKNOWN 無時間戳字詞,暫存後接到下一段。如果合併時發現是落單的最後一個字, 後面要換說話人了,那麼就把這個字同時分配給兩個說話人,後面再用 AI 把多餘的字去掉。
  • GPT 斷句:只對長度 ≥ length_threshold 的句子呼叫 API,可省 token。
  • 錯誤重試:API 呼叫失敗時,最多重試 max_retry 次並等待 2 秒。
  • 使用的模型:我這裡是使用 GPT-4o-mini,。我個人測試用 GPT-4o-mini 、GPT-4.1-mini 、 或是 GPT-4.1-nano 都沒有太大問題。


6. 輸出與儲存結果

用途:將最終句子按照 [hh:mm:ss - hh:mm:ss] 講者:內容 格式,存成 .txt 檔並下載。


print("正在輸出結果...\n")
# print_segments(final_sentences)  # 顯示結果
# save_segments_to_txt(final_sentences, filename="transcription_result.txt")  # 儲存結果

# Step 5: 儲存結果到文字檔

def format_time(seconds):
    if seconds is None:
        return "??:??:??"
    hours = int(seconds // 3600)
    minutes = int((seconds % 3600) // 60)
    seconds = int(seconds % 60)
    return f"{hours:02}:{minutes:02}:{seconds:02}"


# 獲取不帶副檔名的檔案名稱
filename_orig = os.path.splitext(os.path.basename(audio_path))[0]
filename_orig = filename_orig + ".txt"

def save_segments_to_txt(segments, filename=filename_orig):
    with open(filename, "w", encoding="utf-8") as f:
        for seg in segments:
            start = format_time(seg["start"])
            end = format_time(seg["end"])
            speaker = seg["speaker"]
            text = seg["text"]
            f.write(f"[{start} - {end}] {speaker}: {text}\n")

    files.download(f"{filename}")
    print(f"儲存成功:{filename}")


# save_segments_to_txt(final_sentences, filename="transcription_result.txt")  # 儲存結果
save_segments_to_txt(final_sentences)  # 儲存結果

重點說明

  • format_time 需確保輸入秒數非 None,否則會顯示 0
  • 加上 files.download 可以觸發自動跳出瀏覽器下載視窗。

總結與未來展望

以上就是我的Vibe coding 首嘗試,從 Google Drive 掛載、環境配置、模型載入與轉錄、說話人分割,到句子合併和 GPT-4 斷句,最後輸出成帶時間戳記與說話人標記的文字檔。未來還可以做的有:

  • 在轉錄時顯示進度:之前有寫過一版 (在Fast Whisper 上)
  • 更新到最新/穩定的 whisperX 及相依版本。不然重裝 pytorch 、libcudnn 其實蠻花時間的
  • 對切字/疊字有更好的作法。

但我覺得應該不會做 😀


發佈留言