Communitygithub.com

wjs-transcribing-audio

将音频/视频转录为源语言SRT字幕(Whisper/火山引擎ASR)。jianshuo 本地化工具套件的第一步。

wjs-transcribing-audio 是什么?

该技能是一个高级音频/视频转SRT字幕工具,专为需要高质量源语言转录的本地化工作流设计。它支持三种引擎:对于中文,默认使用火山引擎(豆包)流式ASR,通过WebSocket直接推送PCM字节,无需公开URL,提供逐词毫秒级时间戳和更好的准确率;对于其他语言(如西班牙语、英语、葡萄牙语等),则使用OpenAI Whisper API的逐词级别时间戳,手动组装字幕片段以避免Whisper自动分段导致的30秒大块提示与循环幻觉问题;作为最后手段,可以使用本地openai-whisper。该技能是jianshuo本地化套件的第一步,输出源语言SRT后,下一步是翻译字幕(/wjs-translating-subtitles)。它还包含一个AI润色步骤(仅对中文),用于修正明显同音错别字,例如“意数→总数”。需要注意的是,不要使用飞书妙记进行ASR,因为它只提供说话人轮次时间戳,无法用于制作准确的SRT字幕。

兼容平台✓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。
  • 用户只需要配音或硬字幕(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时的两种失败模式:

  1. 30秒大块提示。 在长独白中,whisper-1 配合 response_format=srt 会生成一个覆盖整个30秒 condition_on_previous_text 窗口的大块提示。转录文本没问题,但时间信息无法用于屏幕阅读。
  2. 静默尾部的循环幻觉。 在低能量音频上使用贪心的 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值重新创建(它们是同一个秘密)。
  • 死胡同(不要重试): 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)。
  • ⛔ 如果中间性错误(可能是故意的方言/语码转换)则跳过。 不确定时,不要修改。

Individual skills in this repo

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

相关技能