mcpField
W
wjs-transcribing-audio

wjs-transcribing-audio

github.com/jianshuo创意与媒体137186 次安装今天更新
代码仓库

关于 wjs-transcribing-audio

wjs-transcribing-audio 是将音频或视频语音转为源语言、带时间戳 SRT 的技能,适用于制作字幕或逐字稿。它按语言选择 ASR 引擎,并用词级时间戳组装字幕片段;中文优先使用 Volcano。

主要功能

  1. 01按源语言选择 ASR 引擎
  2. 02生成词级时间戳
  3. 03组装并分段 SRT 字幕
  4. 04长音频分块转写
  5. 05检测并删除重复字幕
  6. 06中文 ASR 后润色错字

适用场景

  1. 01为视频制作源语言字幕
  2. 02为音频生成带时间戳的逐字稿
  3. 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 输出有两种严重故障模式:

  1. 30 秒的大块字幕片段。 在长篇独白中,使用 response_format=srt 的 whisper-1 会输出一个覆盖整个 30 秒 condition_on_previous_text 窗口的字幕片段。转录文本没问题,但时间安排不适合屏幕阅读。
  2. 安静尾段中的循环幻觉。 对低能量音频使用贪心 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 对应的值重新创建(它们是同一个密钥)。
  • **已验证不可行的方案(不要重试):**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)务必与用户核对,别照着错字写出去。

评论

支持 Markdown