Communitygithub.com

wjs-transcribing-audio

將音訊/視訊檔案轉換為原始語言的 SRT 字幕(使用 Whisper 或火山引擎)。這是 jianshuo 在地化工具組的第一步。

wjs-transcribing-audio 是什麼?

這個技能是音訊/視訊轉字幕流程的核心,專門用於生成與原始口語語言相同的 SRT 字幕檔。它不執行翻譯,而是專注於高精度的語音辨識與時間碼對齊,是 jianshuo 在地化工具組的必備第一步。 此技能支援多種 AI 引擎,根據來源語言自動選擇最佳路徑:針對中文(zh-CN、zh-HK、zh-TW)預設使用**火山引擎(豆包)ASR**,因其在中文準確度上明顯優於 Whisper;針對其他語言(如英文、西班牙文、法文等)則使用 **OpenAI Whisper API** 並啟用逐詞時間戳記,再透過自訂演算法組合字幕提示,以避免 Whisper 原生 SRT 輸出常見的 30 秒區塊提示與迴圈幻覺問題。當無法連接 API 時,則以本地 Whisper(medium模型)作為最後手段。 對於中文輸出,技能會額外執行一個 AI 潤色步驟,由 Claude 在會話中修正明顯的同音錯別字(例如「意數」→「總數」),確保字幕的閱讀品質。整體工作流程包含音訊壓縮、分段上傳(10分鐘片段)、指數退避重試,以及合併偏移量等機制,以處理長音訊的可靠性與穩定性。此技能適用於 Claude Code、Cursor 等支援自訂技能的開發環境。

相容平台✓Claude Code✓Codex CLI~Cursor
npx skills add https://github.com/jianshuo/claude-skills/tree/main/wjs-transcribing-audio

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

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 輸出有兩種失敗模式:

  1. 30 秒區塊提示。 在長獨白中,whisper-1 搭配 response_format=srt 會發出一個覆蓋整個 30 秒 condition_on_previous_text 視窗的提示。逐字稿沒問題;但時間碼無法用於螢幕閱讀。
  2. 安靜結尾的迴圈幻覺。 在低能量音訊上使用貪婪 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 值重新建立(它們是同一個祕密)。
  • 死路(不要再試): 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)。

嚴格規則 ── 這是修正,而非編輯:

  • ✅ 僅修正明顯的同音 / 錯別字錯誤,其中意圖的詞彙從上下文來看是明確無歧義的(意數→總數,虛求→需求)。
  • ⛔ 絕對不要更改時間戳、提示持續時間或提示文字的任何解讀部分。不做摘要、改述、新增情緒或任何超出修正的範圍。
  • 🟡 如果你不確定一個詞是錯誤還是說話者刻意的風格,保持原樣不變。寧可漏掉一個真正的錯誤,也不要在沒有證據的情況下「修正」一個正確的文字。

Individual skills in this repo

This repo contains 5 individual skills — each has its own dedicated page.

相關技能