它第一天就會講話,門是第二天才裝的

9 月 2 日 larch-tts-bridge 的四個端點跑起來,同一份 spec 寫著「不做登入、不做限流」。9 月 3 日凌晨開第二份 spec,兩小時二十一分鐘後管理台合併進 master:登入、API 金鑰、sqlite 紀錄、五頁後台。另外修掉兩個錯判斷,其中一個讓 332 個聲線只翻到第一頁。

larch-tts-bridge 管理台的測試頁,走的是跟外部呼叫端同一條合成路徑

9 月 2 日下午 3 點 15 分,larch-tts-bridge 的 /tts、/voices、/subs、/health 四個端點跑起來了,離那天的設計 spec 三小時二十五分鐘。

同一份 spec 的「不做的事」裡有一行:不做登入、不做限流,只綁內網,要對外再加 bearer token 與 nginx 那層。

那行是一張支票。9 月 3 日就是在兌現它。

先講分工:程式是 AI 寫的。我出的是幾個決定:後處理做哪四件、UI 參考哪裡,還有要不要做、做到哪、合不合。

它本來只會做一件事

larch-tts-bridge 的工作很單純:收一段文字,送去 Larch 的 VoiceBridge 專案配音。音檔回來用 ffmpeg 切掉頭尾靜音、響度統一成 EBU R128 的 -18 LUFS、轉成要的格式,再回一個檔案。後處理這四件(同音字替身、去靜音、響度統一、轉格式)是我拍的板。

第一個呼叫端是人,開網頁自己打字。第二個呼叫端在 9 月 2 日那份 spec 的第二段就寫好了:格莉奇寫完心情日記順手打這支,把唸日記的聲音存回日記。要讓程式從外面呼叫,得先有一道門。

9 月 2 日 16 筆 commit,9 月 3 日 11 筆。第二天做的大半是門、帳本跟一張臉,合成的 ffmpeg 管線只動了一處。

兩個錯判斷,一個還被寫進了 spec

這兩個錯都不會噴錯,只會給出一個看起來很合理的畫面:下拉選單裡全是英文,晶片上寫著連不到。

聲線清單只翻到第一頁

9 月 2 日,AI 在那份 spec 的「事實」表裡一列一列記實測結果。其中一列寫著:GET /api/agent/voices 只回 40 個英文聲線,中文聲線的 ID 是從桃園專案實測得知的。

那一列是錯的。當天傍晚我看 Larch 上的聲線很多,問它可合成的角色怎麼那麼少。

那個端點是分頁的,預設 limit=40、最大 100,回應裡有一個 total 欄位寫著 332。不帶參數呼叫,就只拿到第一頁。中文聲線一直都在,只是沒落在第一頁。

18 點 25 分那筆 fix 改成翻頁,limit=100 一頁一頁抓到 offset 追上 total 為止。抓回來是 332 個聲線、24 種語言。國語 34 個、粵語 6 個,這兩種排到清單最前面。最多的是葡萄牙語 73 個,英語排第四。

修之前,那份 voices.json 快照裡躺著 8 筆中文聲線,language 欄位還是手寫的「中文」。功能上沒壞,所以它就一直躺在那裡。

探針挑錯端點

第二個錯活到隔天早上 8 點 43 分。

管理台總覽有一顆 Larch 連線狀態的晶片,做法是打一次 Larch 看會不會炸,逾時設 5 秒。它說「連不到」,可是合成照樣出得來。早上我把總覽那一行貼回去問,兩分鐘後修好。

原因是探針挑錯了端點。Larch 的 /projects/:id、/boards/:id、/versions 固定約 7 秒,跟板子多大無關。7 秒撞上 5 秒逾時,每次都被判死。連線一直是好的,錯在這邊挑的端點。

改成打 voices?limit=1,約 2 秒,逾時放寬到 10 秒,/health 一起換掉。

搬自己的東西

我的交待只有一句:UI 參考 gemini-web 或 codex-image-service。

9 月 3 日那份 spec 把這句翻成了整包搬。gemini-web 那套管理台早就有登入、金鑰、紀錄、測試頁,版面跟樣式都調過了。spec 的做法那一欄直接寫著「不從零寫」。

security.py 是原樣拿過來的,67 行。diff 只有兩行不一樣:第一行的 docstring,還有金鑰前綴從 gmw_ 改成 ltb_。

再往上追一層,gemini-web 那份的第一行寫著「沿用 codex-image-service 同款做法」。codex-image-service 那份的前綴是 cimg_,同樣 67 行。一份 session 簽章加金鑰雜湊的檔案,現在在三個服務裡跑。

admin_db.py 是仿的,表結構換成 TTS 的欄位,拿掉用不到的 worker 欄位。admin.py 沿用同一套版面、側欄與樣式表,頁面內容重寫,從 gemini-web 的 1241 行剪成 843 行。

金鑰政策:沒發就是開放

spec 裡這條只寫了一句「政策照 gemini-web」,那是我在選項裡挑的。gemini-web 的做法是:先看帶進來的金鑰對不對,對就放行。不對的話,再看資料庫跟環境變數裡是不是一把都沒有,一把都沒有就放行;有,就 403。

規則搬過來沒改(程式不是逐字照抄,讀的標頭名稱本來就不一樣)。所以這道門的開關就是「有沒有發過金鑰」這件事本身,沒有另外一個設定值要記得去改。發出第一把金鑰,同時就是關門。

代價要講明白:第一把金鑰發出去之前,內網裡摸得到那個 port 的人都能免費呼叫。點數有沒有被扣,agent API 到現在還看不到,要開網頁看側欄。

而且不只是點數。那個時候 /subs 的讀寫兩支也都沒有門。替身表管的是同音字替身,改得動它,就改得動所有呼叫端唸出來的字。16 點 44 分那筆 commit 把 /subs 也拉進同一條金鑰政策,commit 裡留了一行:對外之後替身表不能沒門。管理台自己的替身表頁,改走另一條只吃登入 cookie 的路徑。

0.35 秒的極短句子一定 500

這個坑在後處理的第二段:兩段式 loudnorm,先量一次響度,把量到的值餵回去做第二次。

EBU R128 的積分響度要一個 400 毫秒的閘門。句子不到 0.4 秒就量不到,ffmpeg 量出來的 input_i 會是 -inf。

寫這篇的時候重跑了那段量測,拿 0.35 秒的正弦波去量:input_i 是 -inf,真實峰值 -30.06。把 -inf 原樣餵回第二段,ffmpeg 不收:

Value -inf for parameter 'measured_I' out of range [-99 - 0]

ffmpeg 回非零退出碼,包成 RuntimeError,合成主體再把它轉成 HTTPException 500。呼叫端拿到的就是一個 500。

「好」「嗯」「對啊」這種長度,在一支拿來配音的 API 上不算邊緣案例。

9 月 3 日 3 點 47 分那筆修法分兩種情況。去完頭尾靜音以後連真實峰值都是 -inf,那是整段靜音,還是該報錯。只有積分響度是 -inf 的話,就改用峰值對齊,把真實峰值推到 -2 dB。測試補了一支 0.35 秒的正弦波,驗時長還在 0.25 到 0.5 秒之間、處理完的峰值在 -4 到 -1 dB 之間。

同一筆還把紀錄補齊。原本只攔 HTTPException,逾時、Larch 回傳格式變了、寫檔失敗這些都不會被記下來。改成 400 以外全部要記。400 是呼叫端自己參數打錯,記進來只會把表洗滿。

16 點 44 分那筆是這天的收尾,讓管理台可以掛在 nginx 的路徑前綴底下。三分鐘前我交待的是「照 gemini-web 補一個 ADMIN_URL_PREFIX」。

實作是一個環境變數。路由本身一行都不動,nginx 負責把前綴剝掉。要跟著帶前綴的有四種東西:頁面裡的連結、303 轉址的目的地、cookie 的 path,還有替身表那支 JS 的 fetch。

cookie 的 path 沒帶的話,登入完看起來沒事,下一頁會被踢回登入頁。同一筆還加了一條:cookie 的 secure 旗標看 x-forwarded-proto 是不是 https 才加,內網直連 http 照常。

這一改有個後果,後來被記了下來:設了前綴之後,管理台要從 nginx 那個入口進。直接連內網 port 開管理台,頁面上的連結會壞掉。合成端點不受影響。

前綴沒有型別,沒有編譯器會提醒。同一筆加了一條測試守著:頁面裡只要還留著沒帶前綴的連結就紅。

這天的帳

管理台那條線一共九筆,從開 spec 到合併 PR 兩小時二十一分鐘:

時間 那一筆
02:23:34 設計 spec
02:23:44 spec 自審
02:29 實作計劃
02:46 底層
02:52 合成主體抽離
03:03 骨架
03:22 各頁
03:31 文件
03:47 審查修正
04:45 合併 PR

中間那筆「spec 自審」只改了一行。原本 spec 裡留著一個沒拍完的問號:「.env 用 python-dotenv 讀?」自審把它定成不加依賴:systemd 用 EnvironmentFile 讀,本機開發用 set -a; source .env; set +a,README 寫清楚。動手前二十分鐘,這個問號就關掉了。

spec 117 行。實作計劃 1126 行,切成五個任務。合併進來的 PR 是 2581 行新增、127 行刪除,其中 admin.py 821 行、admin_db.py 120 行、security.py 67 行。

這些全部是 AI 寫的。spec 是、1126 行的計劃是、admin.py 那 821 行是、15 支自檢也是,管理台那幾筆裡有三筆的 Co-Authored-By 掛的是 Claude Fable 5.1。我出的是那句「UI 參考 gemini-web 或 codex-image-service」,還有要不要做、做到哪、合不合。

寫這篇的時候把 15 支自檢重跑了一次,全過。跑的時候 Larch 的聲線端點正好逾時,服務退回 voices.json 那 332 筆快照繼續開。

拉不到就用快照,這條退路 9 月 2 日就有了。18 點 25 分那筆改的是快照的角色:以前它是清單的底,現在純粹是退路。

下一步

金鑰頁的建立表單,輸入框的 placeholder 寫著「呼叫端名稱,例如 格莉奇日記」。

那是 9 月 2 日 spec 第二段就排好的第二個呼叫端。設計上,發出第一把金鑰的那一秒,這扇門就自動關上。9 月 3 日當天建過測試金鑰之後,不帶金鑰打 /tts 已經回 403。