Communitygithub.com

wjs-transcribing-audio

音声・動画ファイルから、発話と同じ言語のタイムスタンプ付きSRT字幕を生成します(Whisper / 火山エンジン対応)。jianshuo ローカライズスイートの第1ステップです。

wjs-transcribing-audio とは?

このスキルは、音声・動画ファイルを入力とし、発話言語そのままのタイムスタンプ付きSRT字幕を出力する、字幕生成パイプラインの第1ステップです。主にOpenAI Whisper API(非中国語向け)と火山エンジン(中国語向け、優先)の2つのルーティングを持ちます。Whisper APIでは、`response_format=srt` を避け、ワードレベルのタイムスタンプを取得して、自前のキューアセンブラで最適な区切りに再構成する点が特徴です(30秒ブロックキューやループ幻覚などの失敗モードを回避)。火山エンジンでは、ストリーミングWebSocket APIを利用してパブリックURL不要で高精度な中国語ASRを実現し、専用スクリプトでSRTを構築します。最終ステップとして、AIによる誤字修正パス(Claudeによる文脈ベースの校正)を常に実行し、出力品質を高めます。このスキルは、Claude CodeやCursorなどのAIエージェントが「SRTを作って」というユーザー指示に応じて自動実行するために設計されており、次のスキル(`/wjs-translating-subtitles`)への橋渡しとなります。

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

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

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)火山(豆包)ASRWhisperよりも中国語精度が明らかに高い — ユーザーの固定希望
その他 (es, en, pt, fr, it, ja, ko 等)OpenAI Whisper API(ワードレベル)Whisperの多言語性能は高く、単語タイムスタンプから自分でキューを組める
オフライン / API接続不可ローカル openai-whisper (medium)最低品質保証;同じループ・ブロック障害モードが発生しうる

中国語の場合、ユーザーが明示的にWhisperを要求した場合や火山が利用できない場合を除き、Whisperをデフォルトにしないでください。これは意図的なルーティング判断です(ユーザーの記憶にある中国語ASR優先順位を参照)。

OpenAI Whisper API経路(非中国語、および中国語フォールバック)

重要な原則:response_format=srt を要求しないでください。 Whisperのキュー区切りは長い独白(30秒ブロックキュー)や静かな部分(ループ幻覚)で失敗します。ワードレベルのタイムスタンプを要求し、自分でキューを組み立ててください — 後処理は決定論的でコストはかかりません。

なぜ response_format=srt がダメか

長いコンテンツで whisper-1 のSRT出力を台無しにする2つの失敗モード:

  1. 30秒ブロックキュー。 長い独白では、response_format=srt を指定した whisper-1 は、condition_on_previous_text ウィンドウ全体をカバーする1つのキューを出力します。文字起こし自体は正しいですが、タイミングが画面上の読み取りには不適切になります。
  2. 静かな末尾でのループ幻覚。 低エネルギーの音声に対する貪欲な temperature=0 により、「你如果不把拥抱浪费写在这上面,你很难的」が50回繰り返されるような現象が発生します。

どちらもWhisperにキュー境界を任せることが原因です。対策:ワードレベルのタイムスタンプ+独自の句読点認識アセンブラを使います。

APIの呼び出し方

# 1. アップロード用に圧縮 — 音声には64kbpsモノラルMP3で十分。
#    OpenAIの制限は1リクエストあたり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[] 配列には通常、word["word"] に句読点がありません — 各エントリは "做"、"个"、"测"、"试" のような裸のトークンです。句読点がある場合、それは segments[] の text フィールドにのみ存在します。

さらに悪いことに、segments[] のテキストは同じファイルのチャンク間で句読点の有無が一貫しません:79分のポッドキャストのチャンク0では、句読点なしで1〜2秒の285個の裸のセグメント("做个测试" "你在" "呵呵")が出力される一方、チャンク7では、句読点付きで14〜30秒の34個のセグメントが出力されることがあります。どちらの動作も同じAPIレスポンスに含まれます。

したがって、正しい方法は両方を組み合わせることです:segments[] を自然な休止境界(すでに呼吸に合わせて調整済み)として使用しますが、自分自身のキューアセンブラへの生の入力として扱い、単語タイムスタンプを使用してセグメントが長すぎる場合に分割します。

キューアセンブルのレシピ

TARGET_DUR = 3.0   # キューの目標長さ
MAX_CUE_DUR = 5.0  # 最大長さ
MAX_CHARS = 18     # フォントサイズ14、1080幅縦表示で約1行
MAX_GAP = 1.0      # 無音閾値 → 強制的にキュー区切り
MIN_PIECE = 0.3    # これ以下の場合は隣接と結合
SPLIT_PUNCT = set(",。!?;,.;!?")

# ステップA:短いsegments[] を TARGET_DUR に向けてマージ(wordsではなくsegmentsを使用
#            — 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 を要求しないでください。
  • ❌ 悪いキューを2回目のAPI呼び出しで「修正」しないでください。 最初の呼び出しでブロックキューやループ幻覚が発生した場合、ワードレベルでやり直してください — 壊れたサブ範囲だけを再文字起こししないでください。
  • ❌ 潜在的に静かな音声(ヨガ、スピリチュアルコンテンツ、ポッドキャストのエンディング)で temperature=0 を使用しないでください。 貪欲なデコードがループします。0.2 でフォールバックチェーンが有効になります。
  • ❌ language=... を省略しないでください。 自動検出は、最初の30秒間で中国語→日本語やスペイン語→ポルトガル語に切り替わることがあり、文字起こし全体が間違ったものになります。

火山(豆包)ASR経路 — 中国語で優先

火山ASRは、標準語の精度(認識率、句読点、固有名詞)でWhisperを常に上回ります。中国語ソースのデフォルトとして使用してください。

⛔ ASRに 飛書妙記 / lark-minutes を絶対に使用しないでください。 これは発話交代(話者交代)レベルのタイムスタンプしか提供せず、単語単位のタイミングはありません — それから作られた字幕は数秒ずれ、不自然な場所で切れます。ユーザーの固定ルール:「以後不要用飛書妙記来做ASR,不能作為SRT使用,記住」。飛書妙記のフォールバックはありません。火山が利用できない場合は、上記のOpenAI Whisperワードレベル経路にフォールバックしてください(language=zh を固定)。

ストリーミングWebSocket APIを使用 — バイトをプッシュし、公開URLは不要

ファイル / 录音文件识别 / MediaKit APIはすべて、公開HTTP(S)オーディオURLを必要とします。ユーザーはURLホスティングを拒否しています(「不要再用什麼從Server端去Download的這Mp3這樣的模式」 — トンネルはユーザーのホットスポットで失敗します)。大模型流式語音識別 (bigmodel streaming) APIは、WebSocket経由で生のPCMバイトをプッシュすることで、URLを完全に回避します。これが機能する経路です。 単語単位のミリ秒タイムスタンプを返します。

バンドルスクリプト(これらを使用してください — 動作確認済みの経路です):

# 1. 文字起こし:16kモノラルPCMバイトをWebSocket経由でプッシュ → 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 = 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[](トークン + ミリ秒単位の 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は1つの認証情報セットを共有しています — 同じ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トークンを別に探す必要はありません。エイリアスが欠けている場合は、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が組み立てられた後、Claudeによる1回の校正パスをSRT全体に対して実行し、文脈を使って明らかな誤りを修正します。

このパスはセッション内でClaudeがSRTを読み取って実行します — 外部API呼び出しは不要です。.srt をその場で書き換えるか(または <stem>.polished.srt として保存)。

厳格なルール — これは編集ではなく修正です:

  • ✅ 文脈から意図が明確に分かる、明らかな同音異義語・誤字のみを修正します(意数→総数、虚求→需求)。
  • ⛔ 決してタイムスタンプやキュー区切りを変更しないでください。
  • ✅ 句読点は元のまま保持 — ASR出力の句読点を尊重します。
  • ⛔ 文を言い換えたり、口調を変えたり、内容を「改善」したりしないでください。 誤字訂正のみです。
  • ✅ 確信が持てない場合は、元のままにしてください。

テクニック: Claudeに(高熱を装った)「ゆっくり読む」モードで処理させる — 「各行を一字一句確認してください — タイムスタンプを変更せず、句読点も変えず、同音異義語だけをチェック」。これにより非ネイティブスピーカーのエラーが減ります。


パフォーマンスノート

  • 中国語の所要時間(火山): 実時間の約20〜30%(例:50分の録音 → 10〜15分の処理)
  • 中国語の所要時間(Whisperフォールバック): 同上、ただしキューアセンブル時間を含む
  • 非中国語の所要時間(Whisper): 実時間の約10〜20%(ワードレベルのpost-processingを含む)
  • コスト: OpenAI Whisperのコストは約 $0.006/分(whisper-1)。火山のコストは約 $0.003/分(豆包 bigmodel)。ローカルWhisperは無料ですが、GPUが必要です。

Individual skills in this repo

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

関連スキル