
前情提要
第一篇我用 Gemini 3.8 Flash TTS 做了 Song Lingo:貼 YouTube MV 網址,Gemini 轉錄歌詞、加上拼音翻譯文法,再由一位用 voice design 設計的老師逐句示範發音。第二篇把它部署到 Cloud Run,用 IAP 鎖到只有我自己能用。
部署完我在 GitHub 開了一份 roadmap,排了三件事:手機版面、跟讀評分、單字卡。手機版面已經做完了,這篇講的是第二件。
老師會示範,但我一直不知道自己念得對不對。聽十遍老師念,不如自己念一遍、被指出哪裡錯。這是 Song Lingo 從「聽歌看歌詞」變成「練發音」的關鍵一步。
Gemini 3.5 Transcribe 是什麼
Google 在 8/26 發布了 Gemini 3.5 Transcribe,是兩個專門做語音轉文字的模型:
| 模型 | 用途 |
|---|---|
gemini-3.5-transcribe |
錄好的音檔,整段送出轉錄 |
gemini-3.5-transcribe-live |
透過 Live API 的 WebSocket 即時串流,一邊講一邊出字 |
幾個重點功能:
- 85 種以上語言,會自動偵測,也能處理講到一半換語言的情況。
- 逐字時間戳:每個詞都有開始和結束時間(整段轉錄的版本才有)。
- 講者分辨:標出每句話是誰講的。
- 自訂詞彙:最多 1,000 個專有名詞,提高特殊用語的辨識率。
- 兩種模式:
smart會處理說話時的自我修正、拿掉語助詞、自動排版;verbatim則是逐字照實轉錄。
官方部落格引用 Artificial Analysis 的測量:整段轉錄的平均字錯誤率(WER)2.6%,串流版 4.0%;和前一代 Chirp 3 比,拿到最終轉錄結果的時間快了 70%。價格沒有公布。
實際呼叫的方式和 TTS 一樣走 interactions API,但音檔要先用 Files API 上傳:
uploaded = client.files.upload(file=audio_path, config={"mime_type": "audio/m4a"})
interaction = client.interactions.create(
model="gemini-3.5-transcribe",
input=[{"type": "audio", "uri": uploaded.uri, "mime_type": uploaded.mime_type}],
generation_config={
"transcription_config": {
"language_codes": ["ja-JP"],
"mode": {"type": "verbatim", "timestamp_granularities": ["word"]},
}
},
)
支援的格式很多,其中 audio/webm 剛好是 Chrome 錄音的格式,audio/m4a 則對應 iPhone Safari 錄出來的 AAC。
選哪個模型
做跟讀評分,其實有三個候選:
| 模型 | 適合 | 用在跟讀 |
|---|---|---|
gemini-3.5-transcribe |
錄完整段再轉錄 | ✅ 有逐字時間戳,實測約 5 秒回來 |
gemini-3.5-transcribe-live |
即時串流 | 一句歌詞只有幾秒,等念完再轉錄就夠了;要在 Cloud Run 和 IAP 後面維持 WebSocket 也麻煩得多 |
gemini-3.8-flash 這類多模態模型 |
直接聽錄音給文字講評 | 結果每次不一定一樣,也沒有保證的逐字時間戳,不適合當評分的主體 |
最後選了 gemini-3.5-transcribe。專門的語音轉文字模型,結果穩定,有逐字資訊,而且每次只用大約 40 個 token,不占 TTS 那每天 100 次的額度。
先看真實回應,再寫程式
第一篇最貴的一課是:TTS 的網頁版照著 Python SDK 的寫法讀 output_audio,結果 REST 回應裡根本沒有這個欄位,一個小時燒光一整天的額度。
所以這次在 roadmap 的 issue 裡直接寫了一條規則:先用真實的 API 回應驗證解析邏輯,再上線。
測試音檔用 macOS 內建的 say 產生,內容是我自己寫的句子,一段念「今日は晴れです」,一段故意念成「今日は雨です」:
say -v Kyoko -o ok.aiff "今日は晴れです"
afconvert -f m4af -d aac ok.aiff ok.m4a # 和 iPhone 錄音一樣是 AAC
然後攔截 SDK 送出與收到的原始 HTTP 內容,把回應的結構印出來。結果發現了兩件文件沒講清楚、照著寫就會出錯的事。
output_text 又是 SDK 自己組的
文件說轉錄結果在 interaction.output_text。實際的 REST 回應長這樣:
steps[] → { type: "model_output",
content[] → { type: "text", text: "今日は晴れです。",
annotations[] → { type: "word_info", text: "今日",
start_index: 0, end_index: 6,
start_offset: "0.100s", end_offset: "0.400s" } } }
output_text 和上次的 output_audio 一樣,是 Python SDK 從 steps 裡組出來的便利欄位。這次在寫程式之前就知道了,所以直接從 steps[].content[] 讀。
詞的位置是用 UTF-8 位元組算的
「今日」這個詞的 start_index 是 0、end_index 是 6,不是 0 到 2。位置是用 UTF-8 的位元組計算的,一個中日文字元佔 3 個位元組。如果照 JavaScript 字串的索引去切,日文會全部錯位。
這兩件事都只有看過真實回應才會知道。這次花了兩次 API 呼叫,換來的是上線後不用再猜。
怎麼判斷「念對了」
用 verbatim,不用 smart
官方主打的 smart 模式會處理說話時的自我修正、拿掉語助詞。對會議記錄來說這是優點,對跟讀卻是缺點:你念錯了又改口,smart 可能只留下改好的版本;你漏念了一個助詞,它可能幫你補得很通順。跟讀要的是「你實際念了什麼」,所以用 verbatim。
不用自訂詞彙
把這句的原文設成自訂詞彙,看起來能提高辨識率,但它會讓模型偏向聽成正確答案,反而抓不到念錯的地方。而且文件寫明,自訂詞彙和逐字時間戳不能同時使用。
讀音和寫法都比
轉錄結果可能寫成漢字「今日」,也可能寫成假名「きょう」,兩種都是念對。只比對文字的話,會把念對的判成錯。
所以日文同時用兩種方式比對,任一方式對得上就算念對:
- 讀音:把轉錄結果用 pykakasi 轉成平假名,和這句已經標註好的讀音比對。
- 寫法:直接比對文字,防止 pykakasi 把某個漢字念錯。
比對本身用 Python 內建的 difflib.SequenceMatcher 做字元對齊,再把結果對回每個詞。英文和韓文則以詞為單位比對。
分清楚「念錯」和「沒念到」
第一版只看「這個詞的字有沒有對上」,結果「今日は雨です」的「晴れ」被標成沒念到,但其實是念成了別的詞。
改成記錄每個字的對齊結果(相符、被替換、被刪除)之後才分得開:
| 情況 | 轉錄結果 | 判定 |
|---|---|---|
| 念對 | 今日は晴れです。 | 全部念對,100 分 |
| 換掉一個詞 | 今日は雨です。 | 「晴れ」念錯,75 分 |
| 跳過中間的詞 | 今日はです | 「晴れ」沒念到,75 分 |
| 念到一半 | 今日は | 「晴れ」「です」沒念到,50 分 |
| 轉錄成假名 | きょうははれです | 全部念對 |
| 片假名加全形符號 | キョウハ ハレデス! | 全部念對 |
| 前後多了語助詞 | えっと今日は晴れですね | 全部念對 |
這些都是不呼叫 API 的單元測試,每次修改比對邏輯都能馬上跑一遍。
先講清楚限制
語音轉文字檢查的是「別人聽不聽得出你念的是哪個詞」,不是精細的發音評分:
- 稍微不標準、但還聽得出是正確的詞,會被判成念對。模型會往最可能的詞去猜,這是語音轉文字的本質。
- 長音、促音「っ」、音調高低這些細節,只有錯到變成另一個詞時才會被抓到。
所以卡片上的說明直接寫著:「只檢查聽不聽得出是哪個詞,音調與長短音不在評分範圍」。之後如果想要更細的講評,可以另外把錄音和老師的示範音一起交給 gemini-3.8-flash,請它用文字說明,但那是另一個功能。
踩坑一:按鈕在麥克風還沒準備好時,就說「錄音中」
第一版的流程是:點「跟讀」→ 按鈕立刻變成「■ 停止並評分」→ 開始要求麥克風。
問題在於第一次使用時,瀏覽器會跳出麥克風權限的詢問。這時按鈕已經顯示「停止並評分」,但麥克風根本還沒開始錄。如果這時再按一次,程式會以為還沒開始,於是又要求一次麥克風,而不是停止。
原因與解法:多一個「準備麥克風中」的狀態。按下之後按鈕先顯示「準備麥克風…」並暫時不能按,等 MediaRecorder 真的開始錄了,才切換成「停止並評分」。
順帶一提,issue 裡原本寫的是「按住錄音、放開送出」,實作時改成「點一下開始、再點一下停止」,原因也是這個權限詢問:按住的話,手指一定會在按權限按鈕時放開,第一次永遠錄不到。再加上 iPhone 長按按鈕容易觸發文字選取,念一整句歌詞也要好幾秒,點兩下比按住好用。
踩坑二:Mac 上的無頭 Chrome 拿不到麥克風
端對端測試我一直用 puppeteer 開無頭 Chrome。Chrome 有一組測試參數可以「拿一個音檔假裝成麥克風」:
--use-fake-ui-for-media-stream
--use-fake-device-for-media-stream
--use-file-for-fake-audio-capture=ok.wav
結果 getUserMedia 一直卡住不回應。換成 48kHz 的 wav、不指定音檔、預先授予麥克風權限,全部一樣。連最基本的假裝置都拿不到,看起來是 macOS 的麥克風權限機制擋住了無頭 Chrome。
原因與解法:與其卡在測試環境,不如換一個切入點。在頁面載入前替換掉 getUserMedia,把測試音檔解碼後用 Web Audio 播放,產生一條真的音訊串流:
navigator.mediaDevices.getUserMedia = async () => {
const ctx = new AudioContext({ sampleRate: 48000 });
const buffer = await ctx.decodeAudioData(testWavBytes.buffer);
const src = ctx.createBufferSource();
src.buffer = buffer;
const dest = ctx.createMediaStreamDestination();
src.connect(dest);
src.start();
return dest.stream;
};
除了「系統麥克風」這一段,錄音(MediaRecorder 錄成 webm/opus)、上傳、轉錄、比對、顯示結果整條流程都是真的。測試結果是 100 分、四個詞都是綠色,只送出一次評分請求,伺服器的暫存資料夾也沒有殘留檔案。
踩坑一的 bug,也是在這個測試的第一個版本裡抓到的:按鈕顯示「停止並評分」,但 MediaRecorder 從來沒有被建立過。
隱私與額度
跟讀會碰到兩樣敏感的東西:我的錄音,以及 API 額度。
| 設計 | 做法 |
|---|---|
| 錄音不保存 | 只在評分時寫到伺服器的暫存資料夾,評完立刻連資料夾一起刪掉(寫在 finally 裡),不進 bucket,也不寫 log |
| 不信任瀏覽器傳來的歌詞 | 伺服器依歌曲 ID 和句子編號,自己去讀這句的原文和讀音 |
| 不重複計費 | 同一時間只處理一次評分;SDK 的自動重試關掉 |
| 額度用完就停 | 收到 429 之後,在 Retry-After 指定的時間內直接拒絕,不再呼叫 Gemini |
| 沒登入的人碰不到 | 跟讀的 API 一樣在 IAP 後面,沒登入的請求在 IAP 那一層就被擋下,連程式都進不來,也就不會花到額度 |
錄音送給 Gemini 轉錄是這個功能本身必要的,但在自己的服務這一側,錄音存在的時間只有那一次呼叫的幾秒鐘。
順帶:讓 PWA 在 IAP 後面裝得起來

roadmap 的第一項是手機版面:MV 固定在上方、卡片可以左右滑動換句、拇指按得到的底部控制列,外加可以加入主畫面的 PWA。版面本身沒什麼特別,但 PWA 在 IAP 後面有一個坑值得記一下。
瀏覽器抓 PWA 的設定檔(manifest)時,預設不帶 cookie。在 IAP 後面,沒有 cookie 的請求會被導向 Google 登入頁,manifest 就讀不到,PWA 也就裝不起來。
HTML 的解法是在 <link rel="manifest"> 加上 crossorigin="use-credentials"。但翻 Next.js 16 的原始碼才發現,它內建的 manifest 連結只有在 Vercel 的預覽環境才會加這個屬性:
crossOrigin: !manifestOrigin && process.env.VERCEL_ENV === 'preview' ? 'use-credentials' : undefined
原因與解法:不用 Next 內建的 app/manifest.ts,改用一般的 route 提供 manifest,自己在 <head> 放帶 use-credentials 的連結。manifest 裡的圖示也直接內嵌成 data URL,免得瀏覽器再發一次不帶 cookie 的請求。
成果與效益
| 數字 | |
|---|---|
| 這次的 commit | 2(手機版面與 PWA、跟讀評分) |
| repo 總 commit | 18 |
| 關閉的 issue | 2 / 3(roadmap 剩單字卡) |
| 一次跟讀評分 | 約 9–10 秒(上傳約 2 秒、轉錄約 4 秒,其餘是啟動 Python) |
| 一次評分的用量 | 約 40 個 token,不占 TTS 額度 |
| 驗證 API 花掉的呼叫 | 5–6 次 transcribe |
現在在手機上學一首歌的流程是:看卡片 → 聽老師念 → 按跟讀自己念 → 看哪個詞沒念對 → 聽自己的錄音和老師對照 → 滑到下一句。
幾個我會帶走的東西
「先看真實回應」要寫成規則,不能靠記得。這次在 issue 裡明確寫了這條,開工前就先打了兩次 API。結果 output_text 和 UTF-8 位元組這兩件事,都在寫第一行程式之前就知道了。
官方主打的模式,不一定適合你的用途。 smart 模式對大部分場景都是加分,但跟讀要的恰好是它會修掉的東西。選模式之前,先想清楚你要的是「整理好的結果」還是「實際發生的事」。
能幫你提高準確度的功能,可能正好在幫倒忙。自訂詞彙會讓模型偏向正確答案,而評分需要的是能抓到錯誤。
UI 狀態要反映真實狀態,而不是你以為的狀態。按鈕說「錄音中」的時候,麥克風其實還沒開,這種落差在第一次授權的那一刻最明顯,而那剛好是使用者最容易亂按的時候。
測試環境卡住時,換一個切入點。與其跟 macOS 的麥克風權限搏鬥,不如把「系統麥克風」這一小段替換掉,讓其他部分全部照真的跑。
先講清楚能做到什麼。語音轉文字檢查的是「聽不聽得懂」,不是發音評分。把這個限制直接寫在畫面上,比讓使用者以為拿了 100 分就代表發音完美誠實得多。
程式碼在 kkdai/song-lingo,跟讀評分的實作在 shadow.py 與 web/app/api/songs/[id]/lines/[index]/shadow/。官方資料:Gemini 3.5 Transcribe 發布文章、Transcribe 文件。
- Gemini (34) ,
- Gemini 3.5 Transcribe (1) ,
- Speech-to-Text (1) ,
- Language Learning (1) ,
- Next.js (3) ,
- Claude Code (9)