自備 AI 耍廢文學

2026-10-14 · 4,762 字 · 11 分鐘

煩躁的有聲書

YouTube 上有不少「小說文字化」的影片,把網路小說配上合成語音直接唸出來。我原本很喜歡在長時間步行時聽這類影片,但耍廢最怕越聽越煩躁:有些聲音的語調很刺耳,有些配上奇怪的畫面,還墊了一層惱人的背景音樂。我要的只是故事本身,畫面和背景音樂都是多餘的。

另外,我開始聽一部作品之前,最常做的事是先拉到最後,看看故事有沒有結局。沒有的話就果斷放棄;聽了好幾個小時,卻停在一個沒有結尾的地方,實在太悶了。自己做的話,挑作品時就能先確認它有沒有完結,再決定要不要開工。

既然內容只是文字,聲音只是 TTS,與其忍受別人的品質,不如自己控制品質才合理。Azure 和 GCP 的 TTS 都有每月免費額度,我想以小成本自己製作有聲書,在運動、通勤、睡前聽。

這篇記錄的是方法,重點放在 pipeline 的設計。文字怎麼取得、用的是哪部小說,就不在這裡談;文中的範例都改用一段 AI 臨時寫的短文:便利商店夜班店員林小雨、常來的外送員阿哲,以及嗓門很大的店長。如果雷同,一定是真的雷同。

小廢文學流水線

最開始給 Claude 的指示很簡單:先做個 PoC 看看。但很快就遇到一點問題。不管是我自己買的電子書,還是網路上還沒正式發行的小說,它都不太願意動手,變成我得自己把資料下載好,再告訴它檔案放在哪裡。到了最後組合成品的步驟,它也不願意當「觸發者」,避免成果是由它直接產生出來的。

所以我只好跟它用「多階段」的想法來實作:每一階段只做一件事、產物存成檔案,Claude 負責寫工具,並在 AI 臨時寫的短文上驗證;真正處理小說內容的那幾步,由我自己在本機執行。這樣切下來,自然而然就很像在做一個編譯器。小說文字是原始碼,前端負責理解內容:誰在說話、這個角色是什麼樣的人;後端負責把理解的結果變成聲音。

最初的版本其實沒有中間那一層,前端直接產出 Azure 的 SSML,聲線也直接寫 Azure 的聲音名稱。但我想到,不同 TTS provider 提供的參數都不一樣,前端應該用最抽象的方式描述聲音才對,於是加了一份中間碼把兩邊隔開,我叫它 tts-ir。

有聲書 pipeline:前端理解內容,經過 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,斷線和限流是常態,這一層因此長出幾個機制:

快取 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 的故事。