wjs-transcribing-audio 是做什么的?
输入口语音频 → 输出同一语言、带时间戳的SRT字幕。此技能仅生成源语言SRT。 翻译为其他语言是下一个技能 (/wjs-translating-subtitles)。
使用场景
- 用户提供视频或音频文件,希望获得源语言的转录稿 / SRT。
- 用户已有翻译后的SRT,但缺少源语言SRT。
- 用户要求“做 SRT” / “make subtitles” / “出逐字稿”,且目前不需要翻译步骤。
不要使用此技能的场景
- 源语言SRT已存在 → 直接跳转到
/wjs-translating-subtitles。 - 用户希望转录为与口语不同的语言 → 先运行此技能,然后运行
/wjs-translating-subtitles。 - 用户只需要配音或硬字幕(burn-in)→ 如果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分钟一段
# (约64kbps时4.5MB)以在代理不稳定时保持韧性。
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[] 数组通常没有标点 — 每个条目是一个裸 token,如 "做"、"个"、"测"、"试"。标点(如果有的话)只存在于 segments[] 的 text 字段中。
更糟糕的是,segments[] 的 text 在同一文件的不同分块间标点不一致:一个79分钟播客的第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。对于垂直1080×1920屏幕、Fontsize=14 的硬字幕,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-* 源语言,将其作为默认选项。
⛔ 永远不要使用飞书妙记进行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配置;音频包(0x2)发送gzip压缩的PCM块(200ms = 16k单声道s16le时6400字节);最后一个包使用 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会从相邻词进行前向/后向填充。 - 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密钥。录音文件识别文件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)。
硬性规则 — 这是修正,而非编辑:
- ✅ 仅修正明显的同音 / 错别字,其中根据上下文意图词是明确的(意数→总数,虚求→需求)。
- ⛔ 绝不能改动时间戳、提示边界或时序。 仅替换文本。
- ⛔ 绝不能重写句子为“更好”的表达方式。 这是转录,不是散文。(反例:将“程序员不写注释”改为“软件开发人员未能进行适当的代码文档” → ❌)
- ⛔ 绝不能添加或删除标点。 火山ASR提供的标点已足够;我们的分段器依赖它。
- ⛔ 绝不能更改英文单词或专有名词。 (Stuart → Stuart ✅, 不要改为 stu art)。
- ⛔ 如果中间性错误(可能是故意的方言/语码转换)则跳过。 不确定时,不要修改。