关于 wjs-transcribing-audio
wjs-transcribing-audio 是将音频或视频语音转为源语言、带时间戳 SRT 的技能,适用于制作字幕或逐字稿。它按语言选择 ASR 引擎,并用词级时间戳组装字幕片段;中文优先使用 Volcano。
主要功能
- 01按源语言选择 ASR 引擎
- 02生成词级时间戳
- 03组装并分段 SRT 字幕
- 04长音频分块转写
- 05检测并删除重复字幕
- 06中文 ASR 后润色错字
适用场景
- 01为视频制作源语言字幕
- 02为音频生成带时间戳的逐字稿
- 03补齐已翻译字幕缺失的源语言 SRT
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ jianshuo/ claude-skills --skill wjs-transcribing-audio安装后在 Claude Code 中输入/wjs-transcribing-audio确认已加载。
附带文件scripts/build_srt_from_asr.pyscripts/volc_asr_stream.py
详细介绍
wjs-transcribing-audio
输入语音音频 → 输出带时间戳的同语言 SRT。此技能仅处理源语言 SRT。 翻译成其他语言属于下一个技能(/wjs-translating-subtitles)。
适用场景
- 用户提供视频或音频文件,并希望获得源语言的转录文本 / SRT。
- 用户已有翻译后的 SRT,但缺少源 SRT。
- 用户要求“做 SRT” / “制作字幕” / “出逐字稿”,但尚未要求翻译。
不适用场景
- 已有源语言 SRT → 直接跳转到
/wjs-translating-subtitles。 - 用户希望转录文本使用与语音不同的语言 → 先运行此技能,再运行
/wjs-translating-subtitles。 - 用户只想要配音或烧录字幕 → 如果已有 SRT,则跳过;否则先运行此技能。
路由:选择哪个引擎
| 源语言 | 默认引擎 | 原因 |
|---|---|---|
| 中文(zh-CN、zh-HK、zh-TW) | Volcano(豆包)ASR | 中文识别准确率明显优于 Whisper——这是用户长期偏好 |
| 其他任何语言(es、en、pt、fr、it、ja、ko、…) | OpenAI Whisper API,使用词级粒度 | Whisper 的多语言能力很强;词级时间戳让我们能自行组装字幕片段 |
| 离线 / 无 API 访问权限 | 本地 openai-whisper(medium) | 质量底线;同样会遇到循环 / 大块片段故障模式 |
对于中文,除非用户明确要求使用 Whisper,或 Volcano 不可用,否则不要默认使用 Whisper。这是有意的路由决策——请参阅用户关于中文 ASR 优先级的记忆。
OpenAI Whisper API 流程(非中文,以及中文备用方案)
核心原则:不要请求 response_format=srt。 Whisper 在长篇独白(30 秒的大块字幕片段)和安静片段(循环幻觉)中容易出现字幕片段分割失败。请求词级时间戳,并自行组装字幕片段——后处理是确定性的,而且免费。
为什么不使用 response_format=srt
在长内容中,whisper-1 的 SRT 输出有两种严重故障模式:
- 30 秒的大块字幕片段。 在长篇独白中,使用
response_format=srt的whisper-1会输出一个覆盖整个 30 秒condition_on_previous_text窗口的字幕片段。转录文本没问题,但时间安排不适合屏幕阅读。 - 安静尾段中的循环幻觉。 对低能量音频使用贪心
temperature=0,会导致“你如果不把拥抱浪费写在这上面,你很难的”重复 50 次。
这两种问题都源于让 Whisper 决定字幕片段的边界。解决方法:使用词级时间戳 + 自行组装,并根据标点进行切分。
调用 API
# 1. Compress for upload — 64kbps mono MP3 is plenty for speech.
# OpenAI limit is 25MB per request; chunk into 10-min pieces
# (≈4.5MB at 64kbps) for resilience under flaky proxies.
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. Request word-level timestamps. Do NOT request response_format=srt.
import httpx, os
data = {
"model": "whisper-1",
"language": "es", # pin source language; never auto-detect
"response_format": "verbose_json",
"timestamp_granularities[]": "word", # ← the critical flag
"temperature": "0.2", # enable fallback chain (anti-loop)
}
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"] # see surprise below
注意:words[] 没有标点,segments[] 不一致
Whisper 的 words[] 数组通常在 word["word"] 中不含标点——每个条目都是类似 "做"、"个"、"测"、"试" 这样的单独词元。标点(如果有)只出现在 segments[] 的 text 字段中。
更糟的是,同一文件不同分块的 segments[] 文本标点使用不一致:一段 79 分钟的播客中,第 0 块可能输出 285 个没有标点的独立片段(“做个测试”“你在”“呵呵”),每段持续 1–2 秒;第 7 块则可能输出 34 个带标点的片段,每段持续 14–30 秒。同一个 API 响应中可能同时出现这两种情况。
因此,正确的做法是将两者结合:使用 segments[] 作为自然停顿边界(已与呼吸节奏对齐),但将其视为自定义字幕片段组装器的原始输入;当片段过长时,组装器会使用词级时间戳进行切分。
字幕片段组装方法
TARGET_DUR = 3.0 # try to make cues this long
MAX_CUE_DUR = 5.0 # never exceed
MAX_CHARS = 18 # ~one line at Fontsize 14 on 1080-wide vertical
MAX_GAP = 1.0 # silence threshold → force cue boundary
MIN_PIECE = 0.3 # below this, merge with neighbor
SPLIT_PUNCT = set(",。!?;,.;!?")
# Step A: merge short segments[] toward TARGET_DUR (use segments,
# not words — Whisper's segment boundaries are already
# pause-aligned).
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"]
# Long single segment WITH internal punct → split standalone
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
# Step B: final pass — split every internal comma/period to its own cue
# (proportional timestamps by char position). Coalesce pieces
# shorter than MIN_PIECE forward.
# Step C: any cue still > MAX_CHARS gets split at the largest inter-word
# gap using words[] timestamps. Recursive until under cap.
根据平台的阅读节奏调整 TARGET_DUR 和 MAX_CHARS。对于 Fontsize=14 的竖屏 1080×1920 烧录字幕,18 个字符的上限很重要——更长的文本会换成多行,难以阅读。
操作详情
- 身份验证: 凭据存放在
~/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 调用来“修复”错误的字幕条目。 如果首次调用产生了大段字幕条目或循环幻觉,只需重新按词级粒度处理一次——不要只对出错的子范围重新转录。
- ❌ 不要对可能较安静的音频使用
temperature=0(瑜伽、灵性内容、播客结尾)。贪婪解码会导致循环。0.2可以启用回退链。 - ❌ 不要省略
language=...。 自动检测有时会在前 30 秒将中文误判为日语,或将西班牙语误判为葡萄牙语,导致整份转录都出错。
火山(豆包)ASR 路径——中文优先
火山 ASR 的普通话准确率(识别率、标点、专有名词)通常优于 Whisper。对于 zh-* 源语言,默认使用此方案。
⛔ 绝对不要使用飞书妙记 / lark-minutes 进行 ASR。 它只提供轮次级(说话人轮次)时间戳,不提供逐词时间戳——基于它制作的字幕会产生数秒的偏移,并在不自然的位置断开。用户的长期规则:“以后不要用飞书妙记来做ASR,不能作为SRT使用,记住”。没有飞书妙记回退方案。如果火山不可用,请回退到上文的 OpenAI Whisper 逐词路径(固定
language=zh)。
使用 STREAMING WebSocket API — 推送字节,无需公共 URL
文件 / 录音文件识别 / MediaKit API 都要求音频 URL 可通过公共网络访问。用户拒绝托管 URL(“不要再用什么从服务器端去 download 的这 mp3 这样的模式”——隧道在用户的热点上无法使用)。大模型流式语音识别 (bigmodel streaming) API 不需要 URL,直接通过 WebSocket 推送原始 PCM 字节。**这是可行的方案。**它会返回精确到每个词的毫秒级时间戳。
附带的脚本(请使用这些脚本——这是已验证可用的方案):
# 1. Transcribe: pushes 16k mono PCM bytes over WebSocket → ASR JSON
# (decodes any input via ffmpeg; .pcm passes straight through)
export VOLC_ASR_APPID=… VOLC_ASR_ACCESS_TOKEN=… # credentials live with the user
python3 scripts/volc_asr_stream.py <clip.mp4|wav|mp3|pcm> <out.asr.json>
# 2. Build a clean, word-timed SRT from the ASR JSON
python3 scripts/build_srt_from_asr.py <out.asr.json> <out.srt> [max_chars=18]
# Segmentation knobs (optional):
# --max-chars N raise to keep cues whole / break on punctuation, not mid-sentence
# --soft-min N min chars before a ,、; flushes a cue (default 8)
# --strip-punct remove ALL punctuation from displayed text (clean subtitle look)
- Endpoint:
wss://openspeech.bytedance.com/api/v3/sauc/bigmodel - Headers:
X-Api-App-Key:{appid}、X-Api-Access-Key:{token}、X-Api-Resource-Id:volc.bigasr.sauc.duration、X-Api-Connect-Id:{uuid} - Binary v3 framing: 4 字节头部
[(ver<<4)|hdrsize, (msgtype<<4)|flags, (ser<<4)|comp, 0]。Full-client(0x1)+POS_SEQ 发送经 gzip 压缩的 JSON 配置;audio(0x2) 数据包发送经 gzip 压缩的 PCM 数据块(16k 单声道 s16le 下,每 200ms 为 6400 字节);最后一个数据包使用带 负数 seq 的 NEG_WITH_SEQ(0x3)。服务器 full-response(0x9) 返回经 gzip 压缩的 JSON。 - Config 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[](词元及毫秒级start_time/end_time)。像 “AI” 这样的拉丁字母词元会返回start=end=0——build_srt_from_asr.py会根据相邻词元向前和向后填补时间戳。 - build_srt_from_asr.py 还会:按标点拆分字幕条目(遇到硬标点
。!?即刷新;软标点,、;后,超过--soft-min字符时刷新;--max-chars设定字符数上限);可选择从显示内容中移除所有标点(--strip-punct);丢弃“呃/嗯/唉”等填充词;合并紧邻的重复短词元(才才→才)。每条字幕的时间戳取自首尾词语,因此会与语音精确对齐,不会产生漂移。为避免句中断开,请提高--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(以及~/.zshrc中的VOLC_APPID),并已添加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 key。录音文件识别文件 API 也需要公共 URL。
本地 Whisper 作为最后手段
仅在离线、API 配额耗尽或只需超低成本粗略草稿时使用。其中文识别质量明显较低;相同的 blob/loop 故障模式也会出现;本地 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 润色步骤 — 修正明显错别字(最后一步,中文始终执行)
即使 Volcano ASR 也会出现明显的同音错误,导致字幕读起来不对——已观察到:「总数」→「意数」、「需求」→「虚求」、「程序员」→「成员」。原始 ASR 文本只是底线,不是上限。SRT 组装完成后,对完整 SRT 执行一次 Claude 润色,根据句子上下文修正明显错误。
此步骤由 Claude 在会话中读取 SRT 来完成——不调用外部 API。直接改写 .srt 文件(或另存为 <stem>.polished.srt)。
硬性规则——这是纠错,不是编辑:
- ✅ 仅修正明显的同音错误/错别字,且根据上下文能明确判断原意的情况(意数→总数、虚求→需求)。
- ⛔ 绝不更改时间戳、字幕编号或字幕边界。 仅编辑每条现有字幕内的文本行。输入和输出的字幕条数必须相同。
- ⛔ 不要改写、润色语法、压缩内容或“改善”措辞。 保留口语风格、组装时保留的语气词、重复内容和句式。除修正错字外,SRT 必须与音频逐字对应。
- ⛔ 不要擅自“纠正”专有名词/人名/品牌/产品名。 根据同音字猜名字会导致你把「黄一孟」改成「黄一梦」。保持原样,并向用户列出这些名称以供确认(参见下方陷阱)。绝不要编造没听到的名字。
- ✅ 数字必须严格按原话保留(50万、1,000万)——不要规范化数字/单位。
**工作流:**读取 SRT → 生成时间信息完全相同的修正后 SRT → 简要列出更改内容(意数→总数 @ 00:01:11),方便用户抽查。如果不确定某项修正是否正确,就保留原文,并将其加入专有名词/不确定项列表,而不是猜测。
必须在分段/制作片段之前执行此步骤,确保所有下游片段都使用干净的文本。
输出
- 文件名:
<source-stem>.srt(不带语言后缀——这是源语言 SRT,即主版本)。 - 格式:标准 SRT,
HH:MM:SS,mmm(毫秒以逗号分隔),编号从 1 开始。 - 字幕规则:按标点断句;常见时长为 3-8 秒;每个可见行最多 18 个汉字或 42 个拉丁字符。
- 音频不清晰:仅在必要时标记
[inaudible];不要猜测。
交付前质量检查
- 字幕编号连续
- 时间戳不重叠
- 毫秒使用逗号分隔
- 字幕不得在词语中间结束
- 字幕不得超过 MAX_CHARS,超出时须在内部拆分
- 不得连续重复同一句 3 次或以上(循环残留)
- 已执行 AI 润色步骤——明显错别字已修正,时间戳/字幕条数未更改,专有名词已列出供用户确认,未擅自修改
下游
/wjs-mining-articles— 将王建硕的独白/对谈 SRT 改编成多篇微信公众号文章。/wjs-translating-subtitles— 将源 SRT 翻译成目标语言,并按标点重新分段。/wjs-dubbing-video— 仅当用户需要使用源语言配音时使用(较少见);通常应先翻译。/wjs-burning-subtitles— 仅当用户需要将源语言 SRT 烧录到源视频上时使用(例如,西班牙语视频配西班牙语听障字幕)。
常见陷阱
- 在一次 API 调用中发送完整的 60 分钟文件。 OpenAI 的硬性限制是 25 MB,而且超过 15 分钟后调用会变得不稳定。先分块。
- 把
segments[]文本视为权威内容。 同一文件的不同分块之间,标点不一致——没有经过组装器处理时,绝不要相信它。 - 让 Whisper 自动检测语言。 每次都要指定语言。
- 忘记添加分块偏移量。 每个 API 响应中的时间戳都相对于该分块的 t=0;合并时若不加上分块的绝对起始时间,第一块之后的所有字幕都会错开数分钟。
- 专有名词/人名几乎一定有错。 ASR 把人名、品牌、产品名识别成同音错别字是常态(实测「黄一孟」→「黄一梦」)。SRT 里的专有名词都先视为存疑;下游成文/发布前(尤其是
/wjs-mining-articles)务必与用户核对,别照着错字写出去。