一個專門拿來量語音辨識準不準的 repo,花最多篇幅的地方是在懷疑它自己。

meeting-asr-kit 設定的場景很具體:一場 90 分鐘、11 個人、國台語夾雜、只有單點收音的會議錄音。丟給 Whisper 或任何雲端 ASR,你會拿回一份看起來很像逐字稿的東西。漏了多少、幻覺出在哪、台語有沒有被寫成國語、換一家會不會比較好,光盯著那份稿子,一題都答不出來。

照直覺,這種工具的重心應該放在評分演算法。把這個 repo 讀完、測試也跑過一輪之後,我的看法剛好反過來:評測 ASR,真正要花力氣的是證明你的尺沒壞。引擎出錯,你至少知道要懷疑它。尺出錯,量出來的數字看起來就像證據。

兩家引擎互相對答案,對到的可能是同一個幻覺

最省事的做法大家都想過。沒有標準答案,那就跑兩家引擎互比,一致的地方當作對的。

README 直接把這條路封掉。兩份都錯的地方會一起錯,一致不代表正確,有時候兩家會吐出一模一樣的幻覺。

這等於拿兩把沒校準的尺互相校準。刻度一起歪,你只會看到它們完美吻合。

所以最底層那件事躲不掉:沒有參考答案,就算不出 CER(字元錯誤率)。一場 90 分鐘的會議全檔人工聽打又不切實際。repo 的解法是把「全檔標註」縮成「只標幾段 60 秒」,這幾段叫黃金段(golden segment),其餘全自動,再拿黃金段回頭量自動那一段的品質。作者自己的比喻是單元測試:人工聽打那一步省不掉,但可以只做一點點。

黃金段卡了兩個月,卡在沒有 schema

這個做法拖了兩個月才做成,拖的原因跟工時無關。作者在 repo 裡的說法是:從來沒有人定義過黃金段長什麼樣,沒有 schema,聽打就沒有交付定義,兩個月後自己打的稿跟當初的判準對不起來,量到的會變成打字風格,不是模型能力。

尺的第一種壞法還沒碰到任何一行程式碼,它壞在「刻度到底是誰定的」。

最後定下來的結構是兩個檔。manifest.json 記段落清單和 provenance(來源音檔、md5、時長),lines.jsonl 一行一句標註。每句七個必填欄位:id、seg、t_start、t_end、spk、lang、zh。語言欄只收 zho、nan、mixed、eng 四個值,台語用 ISO 639-3 的 nan,沒有自造一個 tw。

驗證器擋的東西,每一條都像是先被咬過才補上的。source_md5 原本只驗有沒有值,填個 "x" 也能過,後來規定必須是 32 位 hex。段的時間原本沒跟 duration_sec 比對過,60 秒的錄音可以標出 [-10, 100] 這種段。每段一定要寫選段理由(reason 欄),因為選段偏誤事後沒有補救的辦法。

最陰的是 NaN。NaN 跟任何值比較都回 False,於是 te <= ts 這個檢查永遠不成立,兩個 NaN 時間戳會一路溜到下游,把 CER 算成 NaN。

反過來,它刻意不檢查同一段裡的時間重疊。多人搶話的會議,重疊正是要量的東西本身,把它當錯誤擋掉,等於把考題刪掉。

量不到就說量不到

指標那一層,我最想借走的是一條紀律:判不動的時候回 None,不回 0。

指標 量什麼 什麼時候回 None
cer_content 內容正確率 不會回 None
verbosity 稿面膨脹率 參考答案為空
coverage 涵蓋率 黃金段秒數為 0
hallucination 憑空生字率的上限 有三種理由會回 None
taigi_retention_per10k 台語忠實度 沒有台語句,或那些句子引擎零輸出
model_precision / model_recall 專有名詞的準確與召回 分母為 0

程式碼裡寫的設計理由很直白。窗內一秒辨識輸出都沒有,分母就是 0,那叫沒東西可判,不叫沒幻覺;回 0.0 會被讀成「這家不幻覺」,等於把量不到講成優點。

體重計沒電的時候顯示 0 公斤,沒有人會說自己瘦身成功。問題是程式裡的 0.0 看起來比 None 正常太多了,它會安安靜靜地進到比較表,再進到簡報。

對齊演算法是另一個洞。它用近似子字串比對,也就是 Levenshtein 的變形:首列全填 0,答案取末列最小值。好處是視窗外多出來的輸出不會被算成插入錯誤。代價是可以被鑽。作者在 repo 裡記錄過一次實測:1618 字的胡言亂語包住 18 字的正確答案,CER 得 0.0000,膨脹率 89.89 倍。

所以 CLI 強制把 verbosity 印在 CER 旁邊。單看 CER 是滿分,兩個數字擺在一起就露餡了。

台語那一軌走過一條死路。原本設計要標註者填台語原形當第二軌參考答案,後來作廢:標註者不會打台語文,改由 AI 從國語回譯來補那一欄,等於自己製造 ground truth。現在人只標「這句是不是台語」,忠實度用一張 25 個詞的台語專屬字形表偵測,不需要參考答案。字表排除國台通用字、國語也用得到的字,還有簡體同形字。例如 个 是簡體的「個」,引擎輸出沒過 opencc,整份就會變成假陽性。

校準閘全綠,一個真 bug 都沒抓到

整個 repo 最核心的設計叫校準閘。它是一組答案已知的輸入,專門驗尺有沒有壞:把黃金段自己當逐字稿餵回去,CER 必須是 0.0;刪掉 10% 字元,CER 要落在 8–12%;空稿的幻覺率要是 n/a,不能是 0%;時間戳整體平移 30 秒,覆蓋率必須大幅下降。

看起來很周到。前五條全綠。

然後真實資料一送進來就炸了。某家字幕裡有一顆 296.8 秒的 cue,用中點篩的話整顆被丟掉,CER 判成 100%;改成只要重疊就收,又把多出來的文字算成插入,判成 187%。

前五條閘一個真實缺陷都沒抓到,因為它們的輸入全是拿黃金段自己變形出來的,切法跟黃金段一模一樣,「時間分桶」這類錯誤永遠不會現形。作者從這裡收斂出一條通則,我認為是整個 repo 最值得帶走的一句:校準閘至少要有一條的輸入形狀跟被測對象刻意不同,否則它驗的是自洽,不是正確。

還有第二層。覆蓋率那道閘後來被發現,對它自己 docstring 點名要抓的缺陷恆綠。fixture 把 cue 對半切,切點又剛好對齊句子邊界,每一半都是 50%,正好落在舊 bug 規則邊界的內側。修法不只換 fixture,還加了一條測試把舊規則實作出來、斷言它會紅,再加一條測試守住 fixture 自己的形狀。

「先看到測試失敗」這件事,寫過 TDD 的人都聽過。這裡多走了一步,連「會讓它紅的那個輸入長什麼樣」都要被鎖住,不然哪天有人好心改了 fixture,閘就悄悄瞎掉。

說明書也歪了

我自己拿 README 去對程式碼,抓到三處說法不一致:

項目 README 的說法 程式碼裡實際的樣子
校準閘條數 8 條 TestCalibrationGates 裡只有 7 個方法;gate8 的三條放在一個無關的 class 底下,全檔查無 gate7
指標數 7 個 score.py 的 docstring 說 6 個,同一份 docstring 自己的表格有 8 列
逐字稿格式 3 種 原始碼支援 4 種,漏掉 Recapp 的 .md,而那條路徑的特殊處理最多

後兩條是記帳錯誤,扣分但不傷人。會咬人的是第一條。我照 README 只跑 pytest tests/test_meeting_score.py::TestCalibrationGates,得到的是 7 passed,gate8 那三條根本沒被執行。偏偏 gate8 就是「校準閘的校準閘」,整套方法論最精彩的部分。跑 pytest tests/ 全套就沒這個問題,我跑出來是 246 passed、1 skipped,不需要任何安裝步驟。

一個花大把力氣證明「一致不代表正確、全綠不代表沒壞」的 repo,照它自己的說明書操作,會拿到一個全綠、卻少跑了三條關鍵測試的結果。這剛好說明這個論點有多難守:尺要校準,尺的測試要有對照組,連尺的說明書都會漂。

同一個形狀,在 repo 裡還出現過兩次,而且都跟 ASR 無關。

第一次是判定邏輯被複製。「這顆 cue 有沒有碰到這個區間」這個判定曾經有四份實作,只有第一份處理了零長度 cue,於是文字完全正確、落在句子正中間的零長度 cue 被系統性誤判。作者記錄的實測是 1268 段裡有 92 段零長度 cue,因為某些來源的時間戳只有秒精度,兩個人在同一秒交替講話就撞成零長度。第一份判定函式的 docstring 裡有一句話,大意是:真正該消掉的,是「能有第二份實作」這件事本身。

第二次更安靜。repo 裡有一支 subprocess_encoding_lint.py,它抓的是「這個呼叫會不會拿系統 locale 去解碼」,不管字面上有沒有寫 text=True。起因是一個編碼 bug:靜音偵測回傳 0 個切點,程式靜默退化成固定秒數硬切,切點正好壓在黃金段邊界上。作者的說法是,這段邏輯上線後在那台機器上從來沒有真正生效過,每次執行卻都顯示成功。那支 lint 的語料收了 12 種繞道寫法、6 種乾淨寫法,後者是拿來防假陽性的;我實際跑過,筆數對得上。

差一點寫出去的結論

黃金段不是逐秒窮盡的。人只打了幾段,所以「跟任何黃金句都沒有重疊的輸出」裡,必然混著人沒打到的真實語音。這就是前面表格裡 hallucination 只能叫上限的原因,要坐實成幻覺,得人工複聽,或跑 adjudicate.py 用語音密度把兩者拆開。

作者記了一次差點出事的案例。某引擎被判幻覺的 15.3 秒裡,有 13.3 秒落在一個 14 秒的空白裡,那段的語音密度是 75–88%,全檔中位數才 41.7%,語意跟前後文連成同一段論述。當時差一點就被寫成「甲線幻覺率比乙線高一個數量級」。

樣本量是另一個會讓尺說謊的地方。一次實測裡連續撤回了三條結論,共同的形狀都是「指標算出一個大數字,分子只有個位數事件」。某引擎台語忠實度 375/萬,真身是 3 個字元除以 80 字。repo 也提醒,5 段黃金段做符號檢定,p 值下限是 0.063,永遠到不了 0.05。想判斷哪家引擎比較準,要的是更多獨立 cluster,不是更多段。

這把尺拿來量「前處理有沒有用」時,也量出過不太好聽的答案。低電平錄音會讓 VAD 把整段判成非語音,作者記錄了一次三線實測,給出數字的兩條是:同一道處理讓 Whisper 覆蓋率從 89.2% 到 95.0%,另一家從 49.3% 到 90.8%;可是同一道處理套在另一場收音本來就健康的會議上,某引擎的覆蓋率反而掉了 2.9 個百分點。還有一個被踩過的坑:量術語正名表的效果時,主轉錄不要餵熱詞表,餵了就分不出引擎是真的聽對了,還是被提示推過去的。

我會拿它當教材,不會拿它當依賴

先講清楚哪些是我自己跑的:我把 repo clone 下來跑過全套測試(246 passed、1 skipped),拿 README 對過程式碼,也跑過那支 lint 的語料。其他實測數字,像 1618 字那個例子、1268 段裡的 92 段、三線覆蓋率、15.3 秒那次差點誤判,都是 repo 作者自己的紀錄,我沒有重現。

以我寫這篇時查到的 GitHub 狀態來看,vulture-s/meeting-asr-kit 是 MIT 授權,2026-09-14 建立,最後一次 push 是 2026-09-16,中間隔兩天多一點。7 顆星、0 個 fork,沒有 CI,沒有 pyproject.toml 也沒有 setup.py,不在 PyPI,用法就是 clone 下來直接跑腳本。四支 CLI 各管一步:scan_levels.py 掃電平挑黃金段(刻意不看稿,避免選段偏誤)、transcribe_ui.py 開一個只綁 127.0.0.1 的本機聽打網頁、score.py 評分、adjudicate.py 拆幻覺。唯一的外部需求是 ffmpeg,而且只有讀音檔的路徑用得到,拿別人做好的黃金段純評分連它都不必裝。我用 grep 掃過 8 個模組的 import,全是標準函式庫,連 rapidfuzz 都刻意沒用。

它真正卡住「直接拿來用」的是三件事。repo 裡沒有任何範例黃金段,原始素材是僱主的 IP、刻意留在內部,所以你沒辦法先跑跑看,得自己從頭做一份。它標了 spk、也會解析講者,卻沒有任何指標拿講者分離來算分,對「多人會議」這個主打場景是個缺口。docstring 還大量引用讀者看不到的內部文件,有兩處指向不存在的路徑,看得出是從內部 repo 抽出來時沒清乾淨。

只算 CER 不算 WER 我倒覺得合理,中文本來就沒有天然的詞界。台語指標的絕對值也別太當真,那是一張啟發式字表,只能拿同一批句子跨引擎比。

所以我的立場是:這份 repo 值得從頭讀到尾,但我不會把它接進自己的流程。哪天它補上一份可以公開的範例黃金段,加上 CI、讓 README 那條測試指令跟全套測試對得起來,我會改口。

下次看到一份 ASR 比較表上寫著 0.0,先別急著問哪家贏。先問那個 0.0,是量到了零,還是什麼都沒量到。


參考來源