目錄(27 節)

TTS 加 SRT 字幕:一條 pipeline 出聲又出字幕

廣東話 TTS 可以一次過出聲同字幕:喺 POST /v1/audio/jobs 加 "srt": true,job 完成後 poll response 就會多一個 srt 欄位,入面係成段 SRT 文字。附完整 poll code 同格式拆解。

— 港嘢內容團隊約 11 分鐘ttstutorial
分享:

一句答你:港嘢 Canto-Gen 支援一次過出聲同字幕 — 你只要喺 POST /v1/audio/jobs 加 "srt": true,等 job 完成之後,poll response 就會多一個 srt 欄位,入面就係成段 SRT 文字。

做影片、做有聲書、做無障礙,最煩嘅通常唔係「整把聲」,而係跟住要自己打字幕、自己對時間軸。一段 10 分鐘旁白,聲音好快生成好,字幕卻可能要人手打足 2 個鐘。呢篇就係講點樣將「出聲」同「出字幕」壓縮成一條 pipeline:提交一次、poll 一次、攞到兩份嘢。

如果你連基本 API 流程都未行過,建議先睇《粵語 TTS API 快速上手》,再返嚟睇呢篇。

Key Takeaways

  • SRT 係async 專屬:"srt": true 只可以行 POST /v1/audio/jobs,sync 捷徑 POST /v1/audio/speech 會回 400 srt_not_supported_sync
  • Job 完成後,poll response 嘅 srt 欄位就係完整 SRT 文字(用 \n 分行),直接寫入 .srt 檔即可用
  • srt_requested 恆在場,但唔等於字幕已就緒 — 判斷準則係 status: "completed" + srt 欄位出現
  • 需要嘅 scope 係 tts:generate(preset/design 生成通用),唔使額外開權限
  • 計費有兩個獨立計法:帳戶 credit 會含 SRT 附加費,而 key 嘅月度字符上限係按提交文字長度平均計
  • 攞到 audio_url 要即刻下載 — pre-signed URL 由素材庫出嘅係 1 小時,由生成 worker 直出嘅係 7 日

SRT 係咩?30 秒搞懂

SRT(SubRip Text)係最通用嘅字幕格式:一條 cue 由序號、時間軸、字幕文字三部分組成,中間用空行分隔。 副檔名 .srt,幾乎所有播放器、剪片軟件、影片平台都食。

一個最基本的 SRT 長成咁:

1
00:00:00,000 --> 00:00:02,400
你好,歡迎使用港嘢!

三個部分:

部分例子說明
序號1由 1 開始遞增
時間軸00:00:00,000 --> 00:00:02,400時:分:秒,毫秒,箭嘴兩邊係開始/結束
字幕文字你好,歡迎使用港嘢!呢段時間要顯示嘅字

留意毫秒用逗號 , 分隔,唔係句號。呢個係 SRT 同 WebVTT 其中一個最常見嘅混淆位(WebVTT 用小數點,仲要有 WEBVTT 開頭行)。WebVTT 係 W3C 制定嘅 web 字幕標準,完整格式見 W3C WebVTT 規範;如果你要將字幕直接放上網頁播,HTML 嘅 <track> 元素 就係掛載 .vtt 檔嘅原生方法。

點解要「TTS 直接出字幕」?

因為時間軸係 TTS 自己最清楚嘅嘢。把聲係佢合成嘅,每句由第幾毫秒講到第幾毫秒,佢本來就有。你搵第三方 ASR 回聽一次再對時間軸,係多一重工序、多一重誤差。

對比一下兩條路:

做法工序時間軸準確度來源
先出音頻,再另外做 ASR 對齊生成 → 下載 → 送 ASR → 對齊 → 校對ASR 回聽推算
出音頻同時出 SRT提交 → poll → 攞兩份嘢生成時本身嘅句子時間

如果你關心嘅係「字幕同把聲夾唔夾」,第二條路明顯少一層。

一條 pipeline 出聲又出字幕:整體流程

流程同一般 async 生成一樣,只係多一個欄位、多一份收穫:

① POST /v1/audio/jobs   (body 加 "srt": true)
        ↓
② 202 { job_id, status, poll_url, srt_requested }
        ↓
③ GET /v1/audio/jobs/{id}  每 2–5 秒 poll 一次
        ↓
④ status: "completed"
   → audio_url   (音頻,pre-signed)
   → srt         (字幕文字,可直接寫檔)

重點:SRT 唔會另開一個 endpoint,佢係同一條 job 帶出嚟嘅第二份產物。 所以你要做嘅改動,基本上就係 body 入面加四個字。

實際 API 用法:srt=true 行 async 軌

第一步:提交 job

curl -s -X POST https://canto-gen.com/v1/audio/jobs \
  -H "Authorization: Bearer $CANTOGEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "cantogen-tts",
    "voice": "sum",
    "text": "你好,歡迎使用港嘢!今日天氣幾好,一齊去行山啦。",
    "response_format": "mp3",
    "srt": true
  }'

成功會回 202 Accepted:

{
  "job_id": "b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b",
  "status": "queued",
  "poll_url": "/v1/audio/jobs/b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b",
  "srt_requested": false
}

⚠️ 唔好用 202 嗰刻嘅 srt_requested 做判斷。 呢個欄位喺 job 剛入列時未反映請求;要知字幕有冇就緒,睇 status 同 srt。

voice 用邊個 slug,去 GET /v1/voices 攞(聲線目錄連試聽)。本文例子用 sum(心妍,female,adult)— 但目錄係動態嘅,記住由自己嘅 response 揀。其他生成參數(speed、pitch、volume、emotion、style、language、cfg_value)同平時一樣,同 srt 可以並用;詳情見《點樣用 API 生成地道粵語語音》。

第二步:poll 攞結果(連 srt 欄位)

curl -s https://canto-gen.com/v1/audio/jobs/b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b \
  -H "Authorization: Bearer $CANTOGEN_API_KEY"

完成後嘅 response:

{
  "job_id": "b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b",
  "status": "completed",
  "poll_url": "/v1/audio/jobs/b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b",
  "srt_requested": true,
  "srt": "1\n00:00:00,000 --> 00:00:02,400\n你好,歡迎使用港嘢!",
  "audio_url": "https://canto-gen.com/storage/v1/object/sign/audio_history/..."
}

狀態生命週期係 queued → processing → completed | failed | cancelled。建議每 2–5 秒 poll 一次。

兩個欄位嘅分工:

  • srt_requested — 恆在場,話你「呢條 job 有冇要求過字幕」
  • srt — 只喺「有要求」+「字幕已就緒」時出現;佢缺席唔會影響其他欄位

所以最穩陣嘅寫法係:唔好靠 srt_requested 判斷有冇字幕,直接睇 srt 存唔存在。

第三步:寫入 .srt 檔

srt 係一個 JSON 字串,入面嘅換行係 \n。將佢原樣寫入檔案,就係一個合法嘅 .srt:

import { writeFile } from "node:fs/promises";

await writeFile("narration.srt", job.srt, "utf8");

唔需要自己補 header、唔需要改逗號做句號。呢個係一個好常見嘅誤會 — 有人以為要轉格式,結果親手整壞個檔。

完整 Node.js 例子(提交 → poll → 落檔)

以下係一條可以直接抄去用嘅最小 pipeline(Node 18+,用內建 fetch):

import { writeFile } from "node:fs/promises";

const BASE = "https://canto-gen.com";
const KEY = process.env.CANTOGEN_API_KEY;

async function ttsWithSrt(text, voice = "sum") {
  // ① 提交
  const submit = await fetch(`${BASE}/v1/audio/jobs`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "cantogen-tts",
      voice,
      text,
      response_format: "mp3",
      srt: true,
    }),
  });

  if (submit.status !== 202) {
    throw new Error(`submit failed: ${submit.status} ${await submit.text()}`);
  }

  const { job_id } = await submit.json();

  // ② poll(每 3 秒一次,最多試 60 次)
  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 3000));

    const res = await fetch(`${BASE}/v1/audio/jobs/${job_id}`, {
      headers: { Authorization: `Bearer ${KEY}` },
    });
    const job = await res.json();

    if (job.status === "completed") return job;
    if (job.status === "failed" || job.status === "cancelled") {
      throw new Error(`job ${job.status}`);
    }
  }

  throw new Error("poll timeout");
}

// ③ 用
const job = await ttsWithSrt(
  "你好,歡迎使用港嘢!今日天氣幾好,一齊去行山啦。"
);

await writeFile("narration.mp3", Buffer.from(
  await (await fetch(job.audio_url)).arrayBuffer()
));
await writeFile("narration.srt", job.srt, "utf8");

console.log("done:", job.srt.split("\n")[0], "cue(s) ready");

幾個實作注意:

  • audio_url 要即刻用。 由素材庫出嘅 pre-signed URL 有效期 1 小時;由生成 worker 直出、未入庫嘅係 7 日。擺過夜就可能過期,到時重新 poll 一次攞新 URL 就得。
  • read endpoint 冇 rate limit。 每分鐘 60 次嘅限制係針對提交(POST);GET /v1/audio/jobs/{id} 唔計,所以 poll 放心 poll(但都唔好癲到 100ms 一次)。
  • 失敗要分開處理。 failed/cancelled 嘅 job 冇 audio_url、冇 srt,唔好當佢係「未準備好」而無限重試。

⚠️ 大陷阱:sync 捷徑唔支援 SRT

好多人見到有 sync 端點 POST /v1/audio/speech(≤300 字符、即回音頻 bytes)就想抄近路 — 唔得:

# 唔好咁做,會失敗
curl -s -X POST https://canto-gen.com/v1/audio/speech \
  -H "Authorization: Bearer $CANTOGEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "model": "cantogen-tts", "voice": "sum", "text": "你好。", "srt": true }'

會回:

{ "error": "srt_not_supported_sync", "message": "..." }

HTTP 400。 原因好簡單:字幕需要按句切分同時間軸,係一個後處理步驟,sync 軌嘅設計係「即時回音頻」;同理 clone 模式亦唔支援 sync(400 clone_not_supported_sync)。

軌道Endpoint支援 srt: true?失敗時
Async(字幕只可以行呢條)POST /v1/audio/jobs✅ 支援—
Sync 捷徑POST /v1/audio/speech❌ 唔支援400 srt_not_supported_sync

順帶一提:sync 端點 POST /v1/audio/speech 目前會 fail closed,回 503 pool_cold。所以就算你唔要字幕,生產 pipeline 都應該以 async 為主。

權限同計費

  • Scope:tts:generate 就夠(preset/design 生成+所有讀取 endpoint 都用呢個)。要 clone 先需要 tts:clone。
  • 計費兩個計法要分清楚:帳戶 credit 係按引擎計費,srt: true 時會含 SRT 附加費(即 billedCharacters 會大過 base 字數);而 key 嘅月度字符上限 ledger 係按提交嘅 text 長度平計(唔計附加費)。
  • 實際數字喺 app 嘅帳單頁面為準 — poll response 唔會回帶計費數字。

SRT 輸出格式實例:逐段拆解

返去上面嗰個 srt 欄位,JSON 入面睇係一行,實際內容係:

1
00:00:00,000 --> 00:00:02,400
你好,歡迎使用港嘢!

再多幾句嘅話,就會係一連串 cue,中間空一行分隔:

1
00:00:00,000 --> 00:00:02,400
你好,歡迎使用港嘢!

2
00:00:02,400 --> 00:00:05,120
今日天氣幾好。

3
00:00:05,120 --> 00:00:08,000
一齊去行山啦。
你要留意嘅位正確做法
換行JSON 字串入面係 \n(同 \r\n 唔同,但主流播放器兩者都食)
毫秒分隔逗號 ,(WebVTT 先用句號)
cue 分隔空行
編碼UTF-8(廣東話用字必須)
寫檔原樣寫,唔好自己加工

如果要 WebVTT:SRT 轉 .vtt 係好機械化嘅事(加 WEBVTT 開頭、時間軸逗號改句號)。但呢個轉換係你自己 pipeline 嘅事,API 出嘅係 SRT。

實際應用場景

1. 影片字幕(YouTube/短片/課程)

最直接:出聲 + 出 .srt,兩份嘢一齊交去剪片或者上載。因為字幕係跟生成時嘅句子時間,你唔使再入剪片軟件逐句對位。

一個實務建議:字幕檔本身係有價值嘅文字資產(可被搜尋、可做逐字稿、可做章節)。所以出稿前,讀音要啱先值得配字幕 — 呢點可以睇《廣東話多音字攻略》。

2. 有聲書/Podcast 章節

cue 嘅時間軸可以直接用嚟做邊聽邊高亮嘅閱讀器:音頻播到幾多毫秒,就 highlight 對應嗰句。因為時間軸係生成時就有,唔使做完 ASR 對齊先做得到。

同樣邏輯可以延伸去「章節標記」— 將長篇拆做多條 job,每條 job 一份 SRT,就自然有段落級嘅時間對照。

3. 無障礙(Accessibility)

字幕係無障礙嘅基本功,唔止服務聽障觀眾:

  • 靜音環境:地鐵、辦公室睇片唔開聲
  • 語言可及性:字幕 + 語音雙軌,理解門檻最低
  • 內容檢索:有逐字稿先可以被全文搜尋

呢個位值得留意:無障礙用途對字幕準確度嘅要求最高(同教材一樣)。所以出街前應該抽樣人手核對,特別係專名、數字、多音字。

4. 批量生成字幕

一次過要出幾十條片嘅字幕,就係「批量 + SRT」嘅組合。批量提交嘅時候,每條 job 各自帶 srt: true,之後逐條 poll。批量提交同輪詢嘅設計(並行度、backoff、失敗重試)可以睇《TTS 批量生成 workflow》。

5. 語氣同字幕配合

同一句「你真係好嘢」,配唔同情緒,字幕可能想跟住調整標點。emotion 同 style 可以同 srt: true 一齊用,做法見《TTS 情緒控制指南》。

常見錯誤排查

症狀可能原因解決
400 srt_not_supported_sync你打咗去 POST /v1/audio/speech改用 POST /v1/audio/jobs
400 invalid_srtsrt 唔係 boolean傳 true/false,唔好傳 "true" 字串
400 text_too_long文字超 500 字拆句/拆 job;超限係拒絕唔係截斷
403 insufficient_scopekey 冇 tts:generate喺 playground「API 金鑰」入面重新開 key
403 monthly_cap_reachedkey 月度上限用盡等下月 UTC 重置,或調高上限
429 rate_limit_exceeded提交太密(預設 60/min)讀 Retry-After,等夠秒數先重試
404 job_not_foundjob id 錯/屬另一個 key核對 id;job 有過期
503 jobs_unavailable後端短暫問題用 backoff 重試
srt 欄位一直唔出現job 未 completed,或生成失敗睇 status;failed/cancelled 唔會有字幕

常見問題 FAQ

TTS 出 SRT 字幕,係咪可以用 sync 捷徑?

唔可以。srt: true 只行 async 軌。你打去 POST /v1/audio/speech(sync 捷徑,≤300 字符)會即回 400 srt_not_supported_sync,要用 POST /v1/audio/jobs。同樣道理,clone 模式亦唔支援 sync(400 clone_not_supported_sync)。

點解 poll response 冇 srt 欄位?

三個可能:① job 未 completed(仲 queued/processing);② job failed 或 cancelled;③你提交嗰陣冇帶 "srt": true。srt 係additive 欄位 — 只喺「要求過」+「字幕就緒」先出現,佢缺席唔影響其他欄位。

srt_requested 同 srt 有咩分別?

srt_requested 係 boolean,恆在場,話你「呢條 job 有冇要求字幕」;srt 係字幕文字本身,有要求+已就緒先出現。所以判斷字幕有冇,應該睇 srt 存唔存在,而唔係睇 srt_requested。

SRT 同 WebVTT 有咩分別?我應該用邊個?

兩者都係純文字字幕格式。SRT 最通用(播放器、剪片軟件幾乎全食),時間軸用逗號分隔毫秒;WebVTT 係 web 標準,開頭要有 WEBVTT,時間軸用句號。港嘢 API 出嘅係 SRT;要 WebVTT 就自己喺 pipeline 轉,轉換係機械式嘅。

一條 job 最長幾多字?長片旁白點算?

text 上限 500 字(超限回 400 text_too_long,係拒絕唔係截斷)。長片做法係按段落/章節拆 job,每條 job 各自 srt: true,最後再將多份 SRT 順序接埋。接嘅時候記得將後面 cue 嘅序號同時間軸做 offset。

SRT 會唔會另外收費?

計費有兩個計法:帳戶 credit 係按引擎計費,srt: true 時會含 SRT 附加費(billedCharacters 會大過 base 字數);而 key 嘅月度字符上限係按提交嘅 text 長度平計,唔計附加費。實際數字以 app 帳單面為準。

批量出幾十條片嘅字幕,要注意咩?

三個位:① 提交受 60/min rate limit 限制(read endpoint 唔受限);② 每條 job 各自 poll,建議用 2–5 秒 backoff,唔好死循環密 poll;③ failed/cancelled 要當終態處理並重試提交,唔好當「未準備好」。完整做法見批量生成 workflow 篇。

結語:兩份產物,一次提交

字幕呢件事,最痛唔係「打」,而係「對時間」。當時間軸係生成時本來就有嘅嘢,你就唔應該再花人手去估。

最快嘅試法:攞一句你想配字幕嘅稿,加 "srt": true 行一次。 睇下 poll 返嚟嘅 srt 有幾乾淨。

有聲,有字,先至係一條完整嘅 pipeline。


相關文章