Eva's Dev Notes

前後端、AI 與日常開發筆記

0%

用 Colab 免費 GPU 與 Breeze ASR 25,把錄音轉成正體中文逐字稿

最近有些錄音需要轉成文字,但手動整理非常耗時。需求很單純:聽得懂台灣口音與中英夾雜、輸出是正體中文、成本越低越好。因此,我決定嘗試用 Colab 免費 GPU 搭配 Breeze ASR 25 來自動化這個流程。

這篇記錄我用 Google Colab 的免費 GPU,搭配聯發創新基地開源的 Breeze ASR 25,做出一個可批次、可續跑的轉錄流程。

方案比較:本機、雲端 API,還是借 GPU

一次性的批次轉錄,使用 Colab 免費 GPU 跑開源模型最划算。三種做法的取捨如下:

做法 優點 缺點 適合情境
本機執行 錄音不離開電腦 需要夠力的硬體,環境要自己裝 內容敏感,且有 Apple Silicon 或獨立顯卡
雲端 API 幾行程式就能用,速度快 按量計費,錄音要上傳第三方 想最快拿到結果
Colab 加開源模型 免費,可選台灣在地模型 工作階段會中斷,流程要自己寫 批次、非即時的需求

模型選 Breeze ASR 25 的理由有三個:它以 Whisper-large-v2 微調,專門針對台灣華語與中英夾雜;採 Apache 2.0 授權可免費使用;模型約 3 GB,免費的 T4 顯卡就跑得動。

整體架構

  1. 錄音檔(m4a)放進 Google Drive 的 transcribe/audio
  2. ffmpeg 把音檔解碼成 16 kHz 單聲道的浮點數陣列
  3. Breeze ASR 25 把陣列轉成帶時間戳的文字段落
  4. 每個音檔寫出兩份 txt 到 transcribe/output

Colab 是用完即丟的虛擬機,所以輸入和輸出都放在雲端空間,就算斷線,已經存好的成果也不會遺失。

整份 notebook 只有六段程式碼,由上到下依序執行。前四段是準備,後兩段才真正轉錄:

段 目的 執行後得到什麼
1 確認 GPU 檢查有沒有拿到顯卡 印出 GPU 名稱
2 掛載 Google Drive 連上雲端硬碟,設定輸入與輸出資料夾 list_audio() 函式與音檔清單
3 載入模型 下載權重,組成轉錄管線 可直接呼叫的 asr 物件
4 定義函式 音檔前處理、時間格式化、轉錄 load_audio、fmt、transcribe
5 試跑 只轉第一個檔案的前 5 分鐘 畫面上印出帶時間的逐字稿
6 整批轉錄 處理資料夾內全部音檔 每個音檔兩份 txt

後面的段落會用到前面定義的東西,所以順序不能跳。例如第 6 段呼叫第 4 段的 transcribe,而 transcribe 又用到第 3 段的 asr。

逐段實作

執行前準備

  1. 開啟 Google Colab,新增一份筆記本。
  2. 選單「執行階段 → 變更執行階段類型」,硬體加速器選 T4 GPU。
  3. 之後把每一段程式碼貼上,按執行鍵即可。

Colab 已預先裝好 PyTorch、Transformers 和 ffmpeg,所以全文沒有任何安裝指令。

第 1 段:確認 GPU

1
2
3
4
import torch

assert torch.cuda.is_available(), "沒有偵測到 GPU:請到「執行階段 → 變更執行階段類型」選 T4 GPU,再重新執行這一段"
print("GPU:", torch.cuda.get_device_name(0))

這段只做一件事:確認上一步的 GPU 設定有生效。torch.cuda 是 PyTorch 管理 NVIDIA 顯卡的模組。assert 在條件不成立時會丟出錯誤並停下,後面的程式就不會在沒有 GPU 的狀態下白跑。

放在第一段的理由是「越早失敗越好」。模型要下載 3 GB,如果等到載入完才發現沒開 GPU,就浪費了好幾分鐘。

第 2 段:掛載 Google Drive

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pathlib import Path
from google.colab import drive

drive.mount("/content/drive")

BASE = Path("/content/drive/MyDrive/transcribe")
AUDIO_DIR = BASE / "audio" # 錄音放這裡
OUT_DIR = BASE / "output" # 逐字稿會存到這裡
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
OUT_DIR.mkdir(parents=True, exist_ok=True)

AUDIO_EXTS = {".m4a", ".mp3", ".wav", ".mp4", ".aac", ".flac", ".ogg", ".wma", ".mov"}

def list_audio():
return sorted(p for p in AUDIO_DIR.iterdir() if p.suffix.lower() in AUDIO_EXTS)

files = list_audio()
print(f"在 {AUDIO_DIR} 找到 {len(files)} 個音檔:")
for p in files:
print(" -", p.name)
if not files:
print("(還沒有檔案:請把錄音放進 Drive 的 transcribe/audio 後,重新執行這一段)")

這段做三件事:

  • 掛載:drive.mount 把 Google Drive 掛成虛擬機裡的一個資料夾,之後裡面的檔案就能像一般檔案一樣讀寫。執行時會跳出授權視窗。
  • 建資料夾:mkdir 的 parents=True 會連上層資料夾一起建立,exist_ok=True 則讓資料夾已存在時不報錯,所以不管執行第幾次都能順利通過。
  • 列出音檔:list_audio() 會掃描資料夾,只留下副檔名符合的檔案,並依檔名排序。

第一次執行時資料夾還是空的,所以會看到提示訊息。把錄音放進 Drive 的 transcribe/audio 後再執行一次,確認檔案都有列出來。Mac「語音備忘錄」匯出的 m4a 可以直接用,不必轉檔。

所有路徑都從 BASE 往下組,換環境時只要改這一處。

第 3 段:載入模型並組成管線

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from transformers import (
WhisperProcessor,
WhisperForConditionalGeneration,
AutomaticSpeechRecognitionPipeline,
)

MODEL_ID = "MediaTek-Research/Breeze-ASR-25"

processor = WhisperProcessor.from_pretrained(MODEL_ID)
model = WhisperForConditionalGeneration.from_pretrained(MODEL_ID).to("cuda").half().eval()

asr = AutomaticSpeechRecognitionPipeline(
model=model,
tokenizer=processor.tokenizer,
feature_extractor=processor.feature_extractor,
device="cuda:0",
)
print("模型載入完成")

這段是整份 notebook 的核心:先準備三個元件,再把它們組成一條管線。

元件 來源 負責什麼
feature_extractor processor 把聲音波形轉成模型看得懂的頻譜圖
model model 神經網路本體,從頻譜圖推論出文字代碼
tokenizer processor 把文字代碼轉回人看得懂的字

from_pretrained 會依模型代號到 Hugging Face 下載需要的檔案。processor 那行只抓幾個小設定檔,model 那行抓的則是約 3 GB 的權重,所以第一次執行要等幾分鐘。

model 那行串了三個方法:.to("cuda") 把模型搬上 GPU;.half() 把數值精度減半,省記憶體也跑得更快;.eval() 則切換成推論模式。

最後組出來的 asr 是一個可以像函式一樣呼叫的物件:把音訊陣列丟進去,它就會自動跑完「前處理、推論、解碼」三個步驟。

兩個實務提醒:

  • 執行時出現的 HF_TOKEN 警告可以忽略。它只是提醒你沒有登入 Hugging Face,而公開模型不需要登入。
  • 在同一個工作階段內重新執行這段,不會重新下載,因為檔案已經有快取。不過工作階段結束後快取就會清空,隔天得再下載一次。

第 4 段:定義三個函式

這段只定義函式,執行後不會有任何輸出。三個函式的關係是:transcribe 是主角,它先呼叫 load_audio 讀音檔,再把結果交給第 3 段建好的 asr;fmt 則是獨立的小工具。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import subprocess
import time
import numpy as np

SR = 16000 # Whisper 系列固定吃 16 kHz 單聲道

# 降低「鬼打牆重複」與靜音段幻覺的設定。
# 如果因為套件版本不同而報錯,程式會自動改用預設值重跑。
GENERATE_KWARGS = {
"condition_on_prev_tokens": False,
"temperature": (0.0, 0.2, 0.4, 0.6, 0.8, 1.0),
"compression_ratio_threshold": 1.35,
"logprob_threshold": -1.0,
"no_speech_threshold": 0.6,
}

def load_audio(path, max_minutes=None):
"""用 ffmpeg 把任何格式轉成 16 kHz 單聲道 float32。"""
cmd = ["ffmpeg", "-nostdin", "-v", "error", "-i", str(path)]
if max_minutes:
cmd += ["-t", str(int(max_minutes * 60))]
cmd += ["-f", "f32le", "-ac", "1", "-ar", str(SR), "-"]
raw = subprocess.run(cmd, capture_output=True, check=True).stdout
return np.frombuffer(raw, dtype=np.float32).copy()

def fmt(seconds):
s = int(seconds)
return f"{s // 3600:02d}:{s % 3600 // 60:02d}:{s % 60:02d}"

def transcribe(path, max_minutes=None):
"""回傳 [(開始秒數, 文字), ...]"""
audio = load_audio(path, max_minutes)
try:
result = asr(audio.copy(), return_timestamps=True, generate_kwargs=dict(GENERATE_KWARGS))
except (TypeError, ValueError) as e:
print(f" ⚠ 進階設定不相容({e}),改用預設值重跑")
result = asr(audio.copy(), return_timestamps=True)
segments = []
for chunk in result["chunks"]:
text = chunk["text"].strip()
if text:
segments.append((chunk["timestamp"][0] or 0.0, text))
return segments

load_audio:把音檔變成數字陣列

聲音在電腦裡就是一長串數字。這個函式會呼叫 ffmpeg,把任何格式的音檔解碼成「每秒 16000 個數字」的陣列。那幾行 cmd 是在拼一道終端機指令,以只讀前 5 分鐘為例,等同於:

1
ffmpeg -i 錄音.m4a -t 300 -f f32le -ac 1 -ar 16000 -

為什麼一定要轉成 16 kHz 單聲道?
因為模型訓練時只看過這種規格。手機和電腦的錄音通常是 44.1 或 48 kHz,直接餵進去的話,模型會以為聲音被放慢了約三倍。而人聲的資訊集中在 8000 Hz 以下,16 kHz 的取樣率剛好能涵蓋,所以轉換並不會讓辨識變差。

這個函式的價值在於統一入口:不管進來的是 m4a 還是 mp3,出去的都是同一種陣列,後面的程式只需要面對一種輸入。如果有給 max_minutes,指令會多加一個 -t,只讀取前幾分鐘;第 5 段的試跑就是靠它。

fmt:秒數轉成時間格式

用整數除法 // 和取餘數 % 拆出時、分、秒,:02d 則表示不足兩位時補零。例如 3725 秒會變成 01:02:05。

transcribe:主函式

流程分四步:讀音檔、丟進 asr、整理結果、回傳。asr 回傳的資料長這樣:

1
2
3
4
5
6
7
{
"text": "大家好今天要討論...",
"chunks": [
{"timestamp": (0.0, 3.2), "text": " 大家好"},
{"timestamp": (3.2, 7.5), "text": " 今天要討論"},
],
}

後面的迴圈會把它整理成更單純的 [(0.0, "大家好"), (3.2, "今天要討論"), ...],並略過空白的段落。

有兩個設計值得一提:

  • GENERATE_KWARGS 是解碼設定,用來減少兩種常見毛病:同一句話不斷重複,以及在靜音段落憑空編字。其中 temperature 那一串數字的意思是:先用最保守的方式解碼,結果不對勁再逐級放寬重試。
  • try / except 是保險。這些進階設定在不同版本的套件上可能不相容,一旦報錯就改用預設值重跑,避免整批工作中斷。

第 5 段:試跑前 5 分鐘

1
2
3
4
5
6
7
8
9
10
TEST_MINUTES = 5

files = list_audio()
assert files, "transcribe/audio 裡沒有音檔"

t0 = time.time()
print(f"試跑:{files[0].name}(前 {TEST_MINUTES} 分鐘)\n")
for start, text in transcribe(files[0], max_minutes=TEST_MINUTES):
print(f"[{fmt(start)}] {text}")
print(f"\n耗時 {time.time() - t0:.0f} 秒")

這段的目的是用最小成本驗證整條流程。它只轉第一個檔案的前 5 分鐘,結果直接印在畫面上,不寫入檔案。

先試跑再跑整批的好處有兩個:流程有問題可以馬上發現,不必等一整個檔案跑完;也能先看辨識品質,再決定要不要調整設定或換模型。

transcribe 回傳的每個元素是一對「秒數、文字」,for start, text in ... 直接把它拆成兩個變數。輸出會像這樣:

1
2
3
4
5
試跑:01_開場.m4a(前 5 分鐘)

[00:00:00] 大家好
[00:00:03] 今天要討論的是
...

執行時畫面會先安靜一陣子,然後所有句子一次出現。因為 transcribe 是整段轉完才回傳,不是邊轉邊印。

第 6 段:整批轉錄

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
files = list_audio()
print(f"共 {len(files)} 個音檔\n")

for i, path in enumerate(files, 1):
out_plain = OUT_DIR / f"{path.stem}.txt"
out_ts = OUT_DIR / f"{path.stem}_timestamps.txt"
if out_plain.exists() and out_ts.exists():
print(f"[{i}/{len(files)}] 已完成,跳過:{path.name}")
continue

print(f"[{i}/{len(files)}] 轉錄中:{path.name}")
t0 = time.time()
segments = transcribe(path)
out_ts.write_text("\n".join(f"[{fmt(s)}] {t}" for s, t in segments), encoding="utf-8")
out_plain.write_text("\n".join(t for _, t in segments), encoding="utf-8")
print(f" 完成,{len(segments)} 段,耗時 {(time.time() - t0) / 60:.1f} 分鐘")

print("\n全部完成,逐字稿在 Drive 的 transcribe/output")

骨架很單純:對每個音檔,先檢查是否已經做過;還沒做過就轉錄,再存成兩份 txt。

輸出檔 內容 用途
檔名.txt 純文字,一段一行 交給語言模型後製,或直接閱讀
檔名_timestamps.txt 每行前面帶 [時:分:秒] 需要回頭對照錄音時使用

輸出檔名取自 path.stem,也就是去掉副檔名後的音檔名稱。例如 01_開場.m4a 會產生 01_開場.txt 和 01_開場_timestamps.txt。

這段最重要的設計是那個 continue。程式用「兩個輸出檔是否都存在」來判斷音檔有沒有做過,做過就跳過。免費版 Colab 隨時可能斷線,有了這個判斷,重新執行時就會從還沒完成的那個音檔接著做,不必整批重來。這個做法的好處是簡單:輸出檔本身就是進度紀錄,不必另外寫程式去記做到哪裡。

日後使用的三個情境

流程跑通之後,實際使用時最常遇到的是這三種狀況:

情境 做法 原因
之後又新增幾個音檔 直接放進 audio,重新執行第 6 段 舊檔案的輸出已存在,會自動跳過
某個檔案想重新轉錄 刪掉 output 裡它對應的兩個 txt,再執行第 6 段 程式找不到輸出檔,就會當成沒做過
隔天重開 Colab 依序重跑第 1 到 4 段,再執行第 6 段 虛擬機已重置,模型和函式都要重新載入

要留意兩件事:不要移動或刪除 output 裡的舊 txt,也不要更改已轉錄音檔的檔名。這兩者都會讓程式判斷成「還沒做過」而重新轉錄。

測試與實測觀察

用 assert 驗證小函式

第 4 段的 fmt 是純函式,輸入相同就得到相同輸出,最適合用 assert 快速驗證。在 notebook 新增一段程式碼貼上即可,全部通過就不會有任何錯誤:

1
2
3
4
5
assert fmt(0) == "00:00:00"
assert fmt(65) == "00:01:05"
assert fmt(3725.9) == "01:02:05"
assert fmt(36000) == "10:00:00"
print("全部通過")

transcribe 裡整理結果的那段迴圈也能用同樣方式測試,而且不需要 GPU。做法是自己造一份長得像 asr 回傳值的假資料,就像前端測試時模擬 API 回應:

1
2
3
4
5
6
7
8
9
10
11
12
13
fake_result = {"chunks": [
{"timestamp": (0.0, 3.2), "text": " 大家好"},
{"timestamp": (3.2, 5.0), "text": " "},
{"timestamp": (5.0, 9.0), "text": " 今天要討論"},
]}

segments = []
for chunk in fake_result["chunks"]:
text = chunk["text"].strip()
if text:
segments.append((chunk["timestamp"][0] or 0.0, text))

assert segments == [(0.0, "大家好"), (5.0, "今天要討論")]

中間那筆只有空白的段落被正確略過,文字前後的空白也被去掉了。

這裡有個最佳實踐值得分清楚。assert 適合寫測試和 notebook 裡的快速檢查,但正式程式的執行期檢查應該用 if ... raise。原因是 Python 以 -O 模式執行時會略過所有 assert,靠它把關的檢查會悄悄消失。

試跑結果與改善方向

我先只轉第一個檔案的前 5 分鐘,確認流程和品質後才跑全部。流程順利跑通,但輸出有三個明顯問題:

  • 部分中文詞彙辨識不到位,會被換成發音相近的常見字
  • 幾乎沒有標點符號
  • 沒有分段

後兩者是 Whisper 系列模型的共同特性,換同類模型也一樣。改善要分兩層:轉錄時提供專有名詞詞彙表,引導模型選對字;轉錄後再用語言模型補標點、依語意分段並修正同音字。

後製用哪種語言模型也有取捨。在同一份 notebook 載入開源模型可以全自動,文字也不必再交給第三方,但免費 GPU 裝得下的小模型較容易擅自改寫內容。大型模型比較忠於原文,代價是多一道手動步驟或 API 費用。後續建議嘗試拿同一段試跑結果讓兩種方式各處理一次,比較後再決定。

小結

這次實作帶走三個重點:

  • 依情境選方案:一次性批次工作用 Colab 加開源模型就夠,不必一開始就上付費 API 或架推論伺服器。
  • 流程要能續跑:以輸出檔是否存在來判斷進度,中斷後重新執行就能接續,不必另外記錄。
  • 語音辨識只是第一步:模型負責「聽」,標點、分段和錯字修正要靠後製補上。

未來可以加入詞彙表提示,並實測開源語言模型的後製效果。這份流程在 Mac 本機也能跑,要改的只有檔案路徑和運算裝置,之後有機會再另外整理。