自備 AI 耍廢文學
煩躁的有聲書
YouTube 上有不少「小說文字化」的影片,把網路小說配上合成語音直接唸出來。我原本很喜歡在長時間步行時聽這類影片,但耍廢最怕越聽越煩躁:有些聲音的語調很刺耳,有些配上奇怪的畫面,還墊了一層惱人的背景音樂。我要的只是故事本身,畫面和背景音樂都是多餘的。
另外,我開始聽一部作品之前,最常做的事是先拉到最後,看看故事有沒有結局。沒有的話就果斷放棄;聽了好幾個小時,卻停在一個沒有結尾的地方,實在太悶了。自己做的話,挑作品時就能先確認它有沒有完結,再決定要不要開工。
既然內容只是文字,聲音只是 TTS,與其忍受別人的品質,不如自己控制品質才合理。Azure 和 GCP 的 TTS 都有每月免費額度,我想以小成本自己製作有聲書,在運動、通勤、睡前聽。
這篇記錄的是方法,重點放在 pipeline 的設計。文字怎麼取得、用的是哪部小說,就不在這裡談;文中的範例都改用一段 AI 臨時寫的短文:便利商店夜班店員林小雨、常來的外送員阿哲,以及嗓門很大的店長。如果雷同,一定是真的雷同。
小廢文學流水線
最開始給 Claude 的指示很簡單:先做個 PoC 看看。但很快就遇到一點問題。不管是我自己買的電子書,還是網路上還沒正式發行的小說,它都不太願意動手,變成我得自己把資料下載好,再告訴它檔案放在哪裡。到了最後組合成品的步驟,它也不願意當「觸發者」,避免成果是由它直接產生出來的。
所以我只好跟它用「多階段」的想法來實作:每一階段只做一件事、產物存成檔案,Claude 負責寫工具,並在 AI 臨時寫的短文上驗證;真正處理小說內容的那幾步,由我自己在本機執行。這樣切下來,自然而然就很像在做一個編譯器。小說文字是原始碼,前端負責理解內容:誰在說話、這個角色是什麼樣的人;後端負責把理解的結果變成聲音。
最初的版本其實沒有中間那一層,前端直接產出 Azure 的 SSML,聲線也直接寫 Azure 的聲音名稱。但我想到,不同 TTS provider 提供的參數都不一樣,前端應該用最抽象的方式描述聲音才對,於是加了一份中間碼把兩邊隔開,我叫它 tts-ir。
有了這一層,還沒決定要用哪家 TTS 也沒關係。Azure、Google、Gemini、macOS 內建的 say 我都想比較,如果前端綁死某一家的格式,每換一家就要重跑整個前端,而前端最貴的那一步是 LLM 判斷說話者。現在前端跑一次,後端可以一直換。
每一步的產物都會存下來,並且依檔案時間判斷要不要重做。改了角色檔,只會重跑受影響的步驟;中斷了,從斷點接著做。
前處理:轉成正體中文(zh-TW)
這一層把來源語言轉成正體中文(zh-TW),來源可能是簡體中文,也可能是日文,例如「成為小說家吧」上那些台灣沒有代理的作品。轉換本身不是這篇的重點,但有兩個小地方值得一提。
簡體轉正體時,我沒有用 OpenCC 會順便轉用語的 s2twp,因為它在小說語境裡過度轉換得很嚴重,例如「對象→物件」「通過→透過」。我改成只轉字形,用語交給 LLM 逐詞判斷,結果存成每部作品一份的詞表。另外,譯名統一、英文殘字、引號這些雜事在這一步一起清掉;但刪節號、【】、▶ 這類帶語意的符號會刻意保留,它們是後面判斷說話者的重要線索。
角色管理:先認識角色,再決定聲音
這一層的設計巧思,在於把「角色是什麼樣的人」和「聲音該怎麼唸」拆成兩份檔案。在請 LLM 判斷「這句話是誰說的」之前,它得先知道故事裡有哪些人、各是什麼個性。所以每部作品會有一份角色分析檔,由我和 Claude 一起整理:
narrator:
pov: 第三人稱
tone: 輕鬆日常
characters:
- id: xiaoyu
name: 林小雨
gender: female
personality: 便利商店夜班店員,嘴巴不饒人但心軟
實際作品的角色檔還會多幾個欄位:aliases 收集同一個角色的各種譯名、speech_style 描述說話方式(口語短句、愛用反問句)、voice_hint 寫下對聲音的想像(年輕男聲、語氣懶散、錯愕時音調上揚)。角色檔整部作品共用一份,新章節出現新角色就補進去,譯名也因此能跨章節保持一致。
角色檔是寫給人和 LLM 看的文字描述,TTS 引擎讀不懂。所以還有第二份「聲線檔」,把每個角色的聲音翻譯成一組抽象屬性:
voices:
narrator: {gender: neutral, age: adult, pitch: 0, rate: 0, energy: 0, tone: [narration]}
xiaoyu: {gender: female, age: young, pitch: 1, rate: 1, energy: 1, tone: [teasing]}
manager: {gender: male, age: adult, pitch: -1, rate: 0, energy: 2, tone: [stern]}
system: {gender: neutral, age: adult, pitch: 0, rate: 0, energy: -2, tone: [robotic]}
這裡刻意不寫任何一家的聲音名稱。性別、年齡、語氣標籤,加上 pitch、rate、energy 三個 -2 到 +2 的相對等級,描述的是「這個角色聽起來應該是什麼樣子」,至於要用哪家的哪個聲音、怎麼換算成實際參數,是後端的事。這份檔案也是在加入 IR 層時,從原本寫死 Azure 聲音名稱的版本改過來的。
說話者判斷與改寫:用 JSON schema 管住 LLM
小說的台詞大多沒有標示說話者,用規則判斷不了語意,這一步交給 claude -p。我把文字每 30 行切一批,附上角色表與前 5 行當上下文,要求它輸出結構化的 JSON:
claude -p --output-format json \
--json-schema "$schema" \
--system-prompt "$rules" \
--tools "" --no-session-persistence
JSON schema 是這一步的關鍵。speaker 欄位用 enum 限定只能是角色表裡的 id,LLM 就沒辦法自己發明一個角色出來;--tools "" 關掉所有工具,讓它專心做文字處理。回來的結果在 structured_output 欄位,程式拿到就能直接用。
system prompt 裡寫了一些 TTS 才會在意的規則:台詞前後的「他說」留給旁白;【】包起來的廣播給 broadcaster、▶ 開頭的系統訊息給 system;只有刪節號的沉默台詞,改成旁白的一句描述;標點只能用「,。?!、;:」,每段不超過 80 字。
同一次呼叫會產出兩個版本。演出版保留第一人稱,每段標上說話者;轉述版則全部交給旁白,把台詞改成間接引語,台詞裡的「我」「你」要換成對應的人名或他、她。以前面的短文為例,小雨那句「店長早就回家了啦」,在轉述版裡就變成「小雨說店長早就回家了」。
tts-ir:前端與後端之間的約定
改寫完的段落和聲線檔,會組成一份 tts-ir:
{
"format": "tts-ir", "version": 1, "locale": "zh-TW",
"voices": {"xiaoyu": {"gender": "female", "age": "young", "pitch": 1, "...": "..."}},
"segments": [
{"speaker": "narrator", "text": "進門的是常來的外送員阿哲。", "pause_after_ms": 400},
{"speaker": "azhe", "text": "小雨,今天也是你和店長兩個人顧店嗎?", "pause_after_ms": 400}
]
}
組 IR 時會做三件事。第一,同一個人連續的段落會合併,但上限 200 字,避免單段超出 TTS 的請求上限,也避免一整章被併成一段。第二,自動推算停頓:換人說話停 400ms,同一人分段停 200ms,最後一段不停。第三,再做一次標點保險,前面 LLM 若漏掉刪節號或破折號,在這裡轉成逗號。
另外還會輸出一份 [speaker] text 的純文字稿。合成要花額度和時間,在那之前用眼睛掃一遍,說話者判斷錯了就能先抓出來。
tts render:可替換的後端
後端讀 tts-ir,決定每一段要交給哪個引擎、用哪個聲音。這個決定寫在一份路由設定裡,設定一個 default,再針對個別角色覆寫:
default: {engine: azure, voice: zh-TW-HsiaoChenNeural}
speakers:
xiaoyu: {engine: azure, voice: zh-TW-HsiaoYuNeural}
azhe: {engine: gemini, voice: Achird, style: "年輕男性,隨和、愛開玩笑"}
system: {engine: say, voice: "Reed (Chinese (Taiwan))"}
每個引擎只要實作兩個函式:params() 把路由設定和 IR 的抽象聲線換算成這個引擎的實際參數,synthesize() 負責呼叫 API 產出音檔。換算規則各家不同。Azure 的 pitch 每一級是 2 個半音、rate 每一級是 12%;Google 只換算語速;Gemini 則完全不看數字,如果路由沒有指定 style,就用 IR 的年齡、性別、語速、語氣標籤組出一句中文描述,例如「年輕女性,語速稍快,帶點調侃」。
各家輸出的格式、取樣率、音量都不一樣,混在一起串接一定會忽大忽小。所以每段合成完都先統一成 24kHz 單聲道 WAV 並做 loudnorm 音量標準化,再依 pause_after_ms 插入靜音串接起來。每次輸出還會附一份 manifest,記錄每段用了哪個引擎和參數,方便比較不同設定。
長時間批次呼叫 API,斷線和限流是常態,這一層因此長出幾個機制:
- 每段用「引擎、實際參數、文字」的 hash 快取。只換某個角色的聲音時,其他段落直接沿用,不重打 API
- 某段失敗不中止整章,記錄錯誤繼續做;全部成功才輸出,避免把缺段的音檔當成品
- 開跑前用
--plan看各引擎還有幾段待合成,不呼叫任何 API,先確認額度夠不夠
快取 key 用的是換算後的實際參數,而不是 IR 的抽象值。這樣只要實際送給引擎的東西沒變就能沿用;哪天換算規則改了,受影響的段落也會自動重算。
選角:用 HTML 加 WAV 試聽
路由設定裡「哪個角色配哪個聲音」要怎麼決定?光看聲音名稱和官方描述是挑不出來的。我一開始用 macOS say 測,只看名稱以為是男聲,聽了才發現各角色都像女聲;從此之後,選聲音一定要實際聽過。
所以選角時,我會請 Claude 依角色檔的個性與 voice_hint,替每個角色挑三個候選聲音,各自配上 style 描述和一句原創台詞,合成成 WAV,再產出一頁靜態 HTML,每個候選旁邊放一個播放器。下面是當時其中一個角色「父親」的三個候選,可以直接按播放聽聽看:
角色描述:中老年男聲,低沉、慢條斯理,說教、俗語多
style:七十歲男性,聲音低沉、語速偏慢,說教口吻
台詞:年輕人聽好,做人要踏實,企業再大也是人在撐,和人相處靠的是誠信。
| 候選聲音 | 試聽 |
|---|---|
| Algenib 男、低;沙啞、沉穩、有份量 | |
| Rasalgethi 男、中;資訊豐富、穩重 | |
| en-us-enzo 男、低;62 歲、說書人 |
三個都是 Gemini 的聲音,前兩個是精選聲音,en-us-enzo 則來自擴充聲音庫,描述上直接寫著 62 歲。Gemini 的聲音庫裡沒有中文專屬的聲音,這些其實都是其他語言的聲音在唸中文,口音與年齡感要靠 style 描述去拉,最後還是得用耳朵判斷。這個角色我最後選了 Rasalgethi。
我在瀏覽器裡一列一列聽,挑出每個角色最順耳的那一個,回報候選編號,再寫回路由設定。過程中也會修正角色本身,例如某個角色原本被設定成成熟女性,聽了才發現應該和男主角同齡,就回頭改角色檔的年齡與 style。
選完之後,還會再產一頁「定案試聽」:每個角色用同一句測試句各唸一次。測試句裡放了「星期」「垃圾」「和」這些兩岸讀音不同的字,順便檢查是不是台灣讀音。這些頁面都直接重用 render,和整章合成共用快取,試聽過的句子在整章合成時不會重打 API。
做完一輪之後,我覺得回報候選編號還是太麻煩。下次再選角的話,試聽頁應該直接做成可以勾選的網頁,選完就匯出成路由設定。
從演出版轉念成說書人
一開始我想做的是「演出版」:旁白一個聲音,每個角色各配一個聲音,台詞保留第一人稱。試下來是可行的,但考慮到預算,我只做了一個版本來體驗。
真正卡住的是聲音本身。Azure 的 zh-TW 只有三個聲音,實際聽起來大概只有兩種;把 pitch 拉開會失真,調 rate 和 range 又聽不太出差別。Gemini 用文字描述角色的年齡和口吻倒是很有效,只是有每日額度,整章合成時常常卡住。
我想了想,現有的語音模型要組出一部小說裡的各種角色,本來就有難度。真的要做好,大概得去模仿現有聲優的聲音,而這不是我打算做的事。
所以我轉念,改用「說書人」模式:把對白改寫成旁白的第三人稱轉述,整章只需要一種聲音。這樣不用再選角,也不用再擔心額度。我通勤和睡前都會聽,一個穩定的聲音慢慢把故事講完,反而比較像有人在床邊講故事。
這次轉向的成本低得出乎意料。轉述版本來就和演出版在同一次 LLM 呼叫裡產出,後端的路由設定也只剩一行:
default: {engine: azure, voice: zh-TW-YunJheNeural}
YunJhe 是 Azure zh-TW 裡唯一的男聲,當說書人剛好。前面為了多角色做的角色管理也沒有白費:說書人雖然只有一個聲音,改寫時仍然要靠角色表判斷誰說了什麼,轉述才會正確。
免費額度與交付
Azure Speech 的免費方案(F0)每月有 50 萬字元的 Neural 額度。一章大約 3000 到 5500 字,合成出來約 10 到 18 分鐘,換算下來每月可以做上百章,我根本聽不完。GCP 的 Standard 和 WaveNet 也有每月 400 萬字元的免費額度,而且有 cmn-TW 的聲音,可以當備案。
合成出來的 WAV 用 ffmpeg 轉成 AAC 96 kbps 的 m4a,放進 iCloud Drive 的資料夾,手機開「檔案」就能直接播。轉檔時先寫成保留 .m4a 副檔名的暫存檔,成功後才改名,避免 iCloud 把轉到一半的半成品同步到手機上。我聽完一章通常就刪掉,所以程式會在本機留一個交付紀錄,就算 iCloud 裡的檔案已經被我刪了,重跑時也不會再放一次。
尊重模型的顧慮
前面提過,Claude 不太願意直接處理小說內容,也不願意當最後產出成品的那個觸發者。我尊重它的顧慮。不過我認為,在個人合理使用、不商業化的情境下,這樣的工具應該是可以自動化的。
所以折衷的做法是:Claude 不親自跑,那至少讓它把整條流程整合成一個簡單的 script,由我自己按下執行。給章節網址,script 會依序跑完九個步驟,每步印出目前進度;中斷了重跑就從斷點接著做。再搭配一份章節進度表,用 --next 就能自動挑下一個還沒做的章節,一章接著一章往下做。
這次算是久違地用 vibe coding 解決自己的日常小需求。Claude 負責大部分的實作,說話者判斷與用語判斷也是透過 claude -p 完成;Codex 則在幾個設計關卡給了第二意見,例如詞表要不要跨作品共用,它就提醒我同一個詞在不同作品裡意思可能不同,這才決定每部作品各自一份。
總而言之,這次最值得的投資是那層中間碼。它讓我能放心地在 Azure、Google、Gemini 之間試來試去,也讓最後「只要一個說書人」的轉向只需要改一行設定。現在通勤、走路、睡前,耳機裡都是我自己生出來、沒有 BGM 的故事。