wjs-transcribing-audio 是做什麼的?
將口語音訊轉換為相同語言的時間戳 SRT 字幕。此技能僅止於原始語言的 SRT。 翻譯成其他語言是下一個技能(/wjs-translating-subtitles)。
使用時機
- 使用者提供視訊或音訊檔案,並希望取得原始語言的逐字稿 / SRT。
- 使用者已有翻譯好的 SRT,但缺少原始語言的 SRT。
- 使用者要求「做 SRT」、「make subtitles」、「出逐字稿」,但尚未要求翻譯。
不應使用時機
- 原始語言的 SRT 已存在 → 直接跳至
/wjs-translating-subtitles。 - 使用者希望取得不同於口語語言的逐字稿 → 先執行此技能,再執行
/wjs-translating-subtitles。 - 使用者只需要配音或燒錄字幕 → 如果 SRT 已存在則跳過,否則先執行此技能。
路由:使用哪種引擎
| 來源語言 | 預設引擎 | 原因 |
|---|---|---|
| 中文(zh-CN、zh-HK、zh-TW) | 火山引擎(豆包)ASR | 中文準確度遠優於 Whisper ── 使用者的長期偏好 |
| 任何其他語言(es、en、pt、fr、it、ja、ko 等) | OpenAI Whisper API,啟用逐詞粒度 | Whisper 的多語言能力強;逐詞時間戳可讓我們自行組合提示 |
| 離線 / 無 API 存取 | 本地 openai-whisper(medium) | 品質下限;同樣有區塊與迴圈失敗模式 |
對於中文,請勿預設使用 Whisper,除非使用者明確要求或火山引擎無法使用。這是故意的路由決定 ── 請參閱使用者關於中文 ASR 優先級的回憶。
OpenAI Whisper API 路徑(非中文,以及中文的備用方案)
關鍵原則:不要要求 response_format=srt。 Whisper 的提示分割在長獨白(30 秒區塊提示)和安靜片段(迴圈幻覺)上會失敗。要求逐詞時間戳並自行組合提示 ── 後處理是確定性的且免費。
為何不使用 response_format=srt
在長內容上,whisper-1 的 SRT 輸出有兩種失敗模式:
- 30 秒區塊提示。 在長獨白中,
whisper-1搭配response_format=srt會發出一個覆蓋整個 30 秒condition_on_previous_text視窗的提示。逐字稿沒問題;但時間碼無法用於螢幕閱讀。 - 安靜結尾的迴圈幻覺。 在低能量音訊上使用貪婪
temperature=0會產生「你如果不把擁抱浪費寫在這上面,你很難的」重複 50 次。
兩者都源於讓 Whisper 決定提示邊界。解決方案:逐詞時間戳 + 你自己的、能感知標點符號的組合器。
呼叫 API
# 1. 壓縮以便上傳 ── 64kbps 單聲道 MP3 對語音已足夠。
# OpenAI 限制每個請求 25MB;切成 10 分鐘片段
# (約 4.5MB @ 64kbps)以在代理不穩時保持彈性。
ffmpeg -hide_banner -loglevel error -y \
-ss <start> -t 600 -i input.mp4 \
-vn -ac 1 -ar 16000 -c:a libmp3lame -b:a 64k chunk.mp3
# 2. 要求逐詞時間戳。請勿要求 response_format=srt。
import httpx, os
data = {
"model": "whisper-1",
"language": "es", # 固定來源語言;絕不自動偵測
"response_format": "verbose_json",
"timestamp_granularities[]": "word", # ← 關鍵旗標
"temperature": "0.2", # 啟用回退鏈(反迴圈)
}
with open("chunk.mp3", "rb") as f:
r = httpx.post(
"https://api.openai.com/v1/audio/transcriptions",
headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
data=data,
files={"file": ("chunk.mp3", f, "audio/mpeg")},
timeout=600.0,
)
r.raise_for_status()
j = r.json()
words = j["words"] # [{"word": "hola", "start": 0.12, "end": 0.34}, ...]
segments = j["segments"] # 請參閱下方的意外情況
意外情況:words[] 沒有標點符號,segments[] 不一致
Whisper 的 words[] 陣列通常在 word["word"] 沒有標點符號 ── 每個條目都是像 "做"、"個"、"測"、"試" 這樣的裸 token。標點符號(如果存在)只存在於 segments[] text 欄位中。
更糟的是,segments[] 的文字在同一個檔案的不同區塊間標點符號不一致:一個 79 分鐘 podcast 的第 0 個區塊可能發出 285 個裸區段(「做個測試」「你在」「呵呵」),每個長 1-2 秒,沒有標點符號;第 7 個區塊可能發出 34 個區段,每個長 14-30 秒,帶有標點符號。兩種行為同時出現在同一個 API 回應中。
因此正確的配方是結合兩者:使用 segments[] 取得自然的停頓邊界(已經對齊呼吸),但將其視為你自己提示組合器的原始輸入,該組合器使用逐詞時間戳在區段過長時進行分割。
提示組合配方
TARGET_DUR = 3.0 # 嘗試讓提示達到此長度
MAX_CUE_DUR = 5.0 # 絕不超過
MAX_CHARS = 18 # ~在 1080 寬垂直螢幕上,字型大小 14 時約一行
MAX_GAP = 1.0 # 靜音閾值 → 強制提示邊界
MIN_PIECE = 0.3 # 低於此值則與鄰近合併
SPLIT_PUNCT = set(",。!?;,.;!?")
# 步驟 A:將短 segments[] 合併至 TARGET_DUR(使用 segments,
# 而非 words ── Whisper 的區段邊界已對齊停頓)。
def assemble(segments, offset):
cues, buf = [], []
def flush():
if buf:
cues.append((buf[0]["start"]+offset, buf[-1]["end"]+offset,
"".join(s["text"].strip() for s in buf)))
buf.clear()
for s in segments:
dur = s["end"] - s["start"]
# 長的單一區段,內部有標點符號 → 獨立分割
if dur > MAX_CUE_DUR and any(c in s["text"] for c in SPLIT_PUNCT):
flush(); cues.extend(split_long_segment(s, offset)); continue
if not buf: buf.append(s); continue
if (s["start"] - buf[-1]["end"]) >= MAX_GAP \
or (buf[-1]["end"] - buf[0]["start"]) >= TARGET_DUR \
or (s["end"] - buf[0]["start"]) > MAX_CUE_DUR:
flush()
buf.append(s)
flush(); return cues
# 步驟 B:最終遍歷 ── 將每個內部的逗號/句點分割成自己的提示
# (按字元位置比例分配時間戳)。合併短於 MIN_PIECE 的片段到前方。
# 步驟 C:任何仍超過 MAX_CHARS 的提示,使用 words[] 時間戳在最大的詞間
# 間隙處分割。遞迴直到低於上限。
調整 TARGET_DUR 和 MAX_CHARS 以符合平台閱讀節奏。18 字元上限對於在垂直 1080×1920、Fontsize=14 下燒錄很重要 ── 更長會換行成多行且難以閱讀。
操作細節
- 認證: 憑證存在
~/code/.env。在呼叫前使用set -a; source ~/code/.env; set +a載入。 - 此機器上的 SOCKS 代理:
httpx需要socksio擴充套件 ── 使用uvx --with httpx --with socksio python ...(否則你會得到ImportError: Using SOCKS proxy, but the 'socksio' package is not installed)。 - 分段: 10 分鐘片段、64kbps 單聲道 MP3(每個約 4.5MB)是可靠性的甜蜜點。20 分鐘片段(約 9MB)有時會在不穩定的代理下遇到 RST。並發
max_workers=2比4更可靠。 - 重試: 每個 API 呼叫都包在 5 倍指數退避中(
time.sleep(min(2**n, 30)))─RemoteProtocolError: Server disconnected常見且是暫時性的。 - 偏移量拼接: 每個區塊的詞彙回傳的時間戳是相對於該區塊的。合併時,在組合提示之前,將區塊的絕對開始偏移量加到每個詞的
start/end。 - 迴圈防護(雙重保險): 即使使用
temperature=0.2,偶爾還是會有子區塊發生迴圈。組合後,對每個提示的文字執行迴圈偵測器 ── 如果任何長度 8-40 字的短語連續重複 3 次以上,則丟棄該提示。
反模式(請勿執行)
- ❌ 對於長於約 2 分鐘的內容,請勿要求
response_format=srt。 - ❌ 請勿透過第二次 API 呼叫來「修復」不良提示。 如果你從第一次呼叫得到了區塊提示或迴圈幻覺,請使用逐詞粒度重做一次 ── 不要只重新轉錄壞掉的子範圍。
- ❌ 請勿對可能安靜的音訊(瑜珈、心靈內容、podcast 結尾)使用
temperature=0。 貪婪解碼會產生迴圈。0.2啟用回退鏈。 - ❌ 請勿跳過
language=...。 自動偵測偶爾會在前 30 秒將中文→日文或西班牙文→葡萄牙文搞混,然後整個逐字稿就錯了。
火山引擎(豆包)ASR 路徑 ── 中文的首選
火山引擎 ASR 在普通話準確度(辨識率、標點符號、命名實體)上通常勝過 Whisper。將其作為 zh-* 來源語言的預設值。
⛔ 切勿使用 飛書妙記 / lark-minutes 進行 ASR。 它只提供輪次級別(發言人輪次)的時間戳,而非逐詞時間 ── 由此建立的字幕會漂移數秒並在不自然的地方斷開。使用者的長期規則:「以後不要用飛書妙記來做ASR,不能作為SRT使用,記住」。沒有 飛書妙記 的備用方案。如果火山引擎無法使用,請回退到上述 OpenAI Whisper 逐詞路徑(固定
language=zh)。
使用串流 WebSocket API ── 推入位元組,不需要公開 URL
檔案 / 錄音檔案辨識 / MediaKit API 都需要一個可公開存取的 HTTP(S) 音訊 URL。使用者拒絕了 URL 託管(「不要再用什麼從服務器端去 download 的這 mp3 這樣的模式」── 通道在使用者的熱點上會失敗)。大模型流式語音識別 (bigmodel streaming) API 透過在 WebSocket 上推送原始 PCM 位元組完全繞過了 URL。這是可行路徑。 它回傳逐詞毫秒時間戳。
捆綁的腳本(使用這些 ── 它們是經過驗證的可行路徑):
# 1. 轉錄:透過 WebSocket 推送 16k 單聲道 PCM 位元組 → ASR JSON
# (透過 ffmpeg 解碼任何輸入;.pcm 直接通過)
export VOLC_ASR_APPID=… VOLC_ASR_ACCESS_TOKEN=… # 憑證在使用者端
python3 scripts/volc_asr_stream.py <clip.mp4|wav|mp3|pcm> <out.asr.json>
# 2. 從 ASR JSON 建立乾淨的、有逐詞時間的 SRT
python3 scripts/build_srt_from_asr.py <out.asr.json> <out.srt> [max_chars=18]
# 分段調整旋鈕(可選):
# --max-chars N 提高以保持提示完整 / 在標點符號而非句子中間斷開
# --soft-min N 在 ,、; 刷新提示前的最少字元數(預設 8)
# --strip-punct 從顯示文字中移除所有標點符號(乾淨字幕外觀)
- 端點:
wss://openspeech.bytedance.com/api/v3/sauc/bigmodel - 標頭:
X-Api-App-Key:{appid},X-Api-Access-Key:{token},X-Api-Resource-Id:volc.bigasr.sauc.duration,X-Api-Connect-Id:{uuid} - 二進位 v3 框架: 4 位元組標頭
[(ver<<4)|hdrsize, (msgtype<<4)|flags, (ser<<4)|comp, 0]。Full-client(0x1)+POS_SEQ 發送 gzip 壓縮的 JSON 配置;audio(0x2) 封包發送 gzip 壓縮的 PCM 區塊(200ms = 6400 位元組 @16k 單聲道 s16le);最後一個封包使用 NEG_WITH_SEQ(0x3) 搭配負數序列號。伺服器 full-response(0x9) 回傳 gzip 壓縮的 JSON。 - 配置 JSON:
{user:{uid}, audio:{format:"pcm",rate:16000,bits:16,channel:1,codec:"raw"}, request:{model_name:"bigmodel", enable_punc:true, enable_itn:true, show_utterances:true}}。請勿設定result_type:"single"── 那會只回傳每幀的最新句子;預設模式會累積所有話語。 - 結果結構:
result.utterances[],每個都包含text(有標點符號)和words[](token + 毫秒start_time/end_time)。像 "AI" 這樣的拉丁 token 會回傳start=end=0──build_srt_from_asr.py會從相鄰 token 向前/向後填補。 - build_srt_from_asr.py 還會:在標點符號處分割提示(硬性
。!?刷新;軟性,、;在超過--soft-min字元後刷新;硬上限--max-chars),可選地從顯示中移除所有標點符號(--strip-punct),丟棄 呃/嗯/唉 填充詞,並合併立即重複的短 token(才才→才)。每個提示都根據其第一個和最後一個單詞進行時間設定,因此它精確地位於口語音訊上 ── 無漂移。為避免在句子中間斷開,請提高--max-chars,使邊界落在標點符號而非字元上限上。 - 逐段轉錄: 轉錄每個片段本身的音訊(16k 單聲道 PCM),以便 SRT 時間戳是相對於片段的。
- 憑證: 在使用者端(豆包語音引擎)。腳本接受的環境變數名稱:
VOLC_ASR_APPID/VOLC_APPID和VOLC_ASR_ACCESS_TOKEN/VOLC_TOKEN;FFMPEG_BIN可選。- 此使用者的 火山 ASR 和 TTS 共享同一組憑證 ── 相同的 appid 和 access token 適用於兩種服務。它們儲存在
~/code/.env中,名為VOLC_TTS_APPID/VOLC_TTS_ACCESS_TOKEN(加上VOLC_APPID在~/.zshrc中),並附加指向相同值的VOLC_ASR_APPID/VOLC_ASR_ACCESS_TOKEN別名。因此set -a; source ~/code/.env; set +a就足夠了 ── 無需另外尋找 ASR token。如果別名遺失,請從 TTS 值重新建立(它們是同一個祕密)。
- 此使用者的 火山 ASR 和 TTS 共享同一組憑證 ── 相同的 appid 和 access token 適用於兩種服務。它們儲存在
- 死路(不要再試): MediaKit asr-subtitles(文件 6448/2381968)仍然需要一個公開 URL 和一個單獨的 MediaKit API 金鑰。錄音檔案辨識檔案 API 需要一個公開 URL。
本地 Whisper 作為最後手段
僅在離線、API 配額耗盡或需要超便宜草稿時使用。中文品質明顯較低;同樣的區塊/迴圈失敗模式適用;本地 Whisper 不透過 CLI 公開逐詞時間戳,因此原則性修復不可用。
ffmpeg -i input.mp4 -vn -ac 1 -ar 16000 -c:a pcm_s16le _audio.wav -y
uvx --from openai-whisper whisper _audio.wav \
--language zh --task transcribe \
--model medium --output_format srt --output_dir .
rm _audio.wav
medium 是中文準確度的實用下限;small 僅適用於乾淨的錄音室英語。Whisper 寫入 . 毫秒;檔案仍然是有效的 SRT。如果你重新產生 SRT,請總是發出 , 毫秒。
AI 潤色步驟 ── 修正明顯的錯別字(最終步驟,中文一律執行)
即使是火山引擎 ASR 也會出現明顯的同音錯誤,在螢幕上閱讀時會出錯 ── 觀察到:「總數」→「意數」、「需求」→「虛求」、「程序員」→「成員」。原始 ASR 文字是下限,而非上限。組合好 SRT 後,對整個 SRT 執行一次 Claude 潤色步驟,使用句子上下文修正明顯的錯誤。
此步驟由 Claude 在會話中讀取 SRT 完成 ── 不執行外部 API 呼叫。就地改寫 .srt 檔案(或改寫為 <stem>.polished.srt)。
嚴格規則 ── 這是修正,而非編輯:
- ✅ 僅修正明顯的同音 / 錯別字錯誤,其中意圖的詞彙從上下文來看是明確無歧義的(意數→總數,虛求→需求)。
- ⛔ 絕對不要更改時間戳、提示持續時間或提示文字的任何解讀部分。不做摘要、改述、新增情緒或任何超出修正的範圍。
- 🟡 如果你不確定一個詞是錯誤還是說話者刻意的風格,保持原樣不變。寧可漏掉一個真正的錯誤,也不要在沒有證據的情況下「修正」一個正確的文字。