Communitygithub.com

wjs-transcribing-audio

오디오/비디오 → 원어 SRT (Whisper). jianshuo 현지화 도구 모음의 1단계.

wjs-transcribing-audio란 무엇인가요?

이 스킬은 오디오 또는 비디오 파일에서 동일한 언어의 타임스탬프가 포함된 SRT 자막 파일을 생성합니다. 주로 중국어를 포함한 다양한 언어의 음성을 텍스트로 변환하는 작업을 자동화하며, Whisper API와 Volcano(豆包) ASR 등 여러 음성 인식 엔진을 지원합니다. 특히 중국어의 경우 Volcano ASR이 더 높은 정확도를 제공하므로 기본 엔진으로 사용하며, 그 외 언어는 OpenAI Whisper API를 사용합니다. 사용자는 'SRT 만들기', '자막 생성', '대본 작성' 등의 요청으로 이 스킬을 활성화할 수 있으며, 생성된 원어 SRT는 다음 단계인 번역 스킬(`/wjs-translating-subtitles`)로 전달됩니다. 이 스킬은 반복되는 오디오 루프 환각(hallucination)을 방지하고 정확한 단어 수준의 타임스탬프를 제공하도록 설계되었습니다.

지원 대상✓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를 실행합니다.
  • 사용자가 더빙 또는 영상에 자막을 입히기(burn-in)만 원하는 경우 → 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)최소 품질 보장; 동일한 루프/청크 실패 모드가 적용됨

중국어의 경우, 사용자가 명시적으로 요청하거나 Volcano를 사용할 수 없는 상황이 아니라면 Whisper를 기본값으로 사용하지 마십시오. 이는 의도적인 라우팅 결정입니다 — 사용자의 중국어 ASR 우선순위에 대한 메모리를 참조하십시오.

OpenAI Whisper API 경로 (비중국어 및 중국어 대체)

핵심 원칙: response_format=srt를 요청하지 마십시오. Whisper의 큐 분할은 긴 독백(30초 청크 큐)과 조용한 구간(루프 환각)에서 실패합니다. 단어 수준 타임스탬프를 요청하고 직접 큐를 조립하십시오 — 후처리는 결정적이며 무료입니다.

response_format=srt를 사용하지 않는 이유

긴 콘텐츠에서 whisper-1 SRT 출력을 망치는 두 가지 실패 모드:

  1. 30초 청크 큐. 긴 독백에서 response_format=srt를 사용하는 whisper-1은 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[] 배열에는 일반적으로 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     # 1080 너비 세로 화면에서 Fontsize 14 기준 약 한 줄
MAX_GAP = 1.0      # 침묵 임계값 → 큐 경계 강제
MIN_PIECE = 0.3    # 이 값보다 짧으면 이웃과 병합
SPLIT_PUNCT = set(",。!?;,.;!?")

# Step 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

# Step B: 최종 패스 — 모든 내부 쉼표/마침표를 자체 큐로 분할
#         (문자 위치에 비례한 타임스탬프). MIN_PIECE보다 짧은 조각은 앞으로 병합.

# Step C: 여전히 MAX_CHARS보다 긴 큐는 words[] 타임스탬프를 사용하여
#         가장 큰 단어 간 간격에서 분할. 한도 이하가 될 때까지 재귀.

플랫폼의 읽기 리듬에 맞게 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 오류 발생).
  • 청크 분할: 64kbps 모노 MP3의 10분 조각(약 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초 동안 중국어→일본어 또는 스페인어→포르투갈어로 잘못 전환되는 경우가 있으며, 그러면 전체 대본이 잘못됩니다.

Volcano (豆包) ASR 경로 — 중국어에 권장

Volcano ASR은 일반적으로 중국어 정확도(인식률, 구두점, 고유 명사)에서 Whisper를 능가합니다. zh-* 원어의 기본값으로 사용하십시오.

⛔ ASR에 절대 飞书妙记 / lark-minutes를 사용하지 마십시오. 이는 턴 수준(화자 전환) 타임스탬프만 제공하며 단어별 타이밍은 제공하지 않습니다 — 이를 기반으로 구축된 자막은 초 단위로 표류하고 부자연스러운 위치에서 끊어집니다. 사용자의 고정 규칙: "앞으로 飞书妙记를 ASR에 사용하지 말고, SRT로 사용할 수 없음을 기억하십시오." 飞书妙记 대체는 없습니다. Volcano를 사용할 수 없으면 위의 OpenAI Whisper 단어 수준 경로로 대체하십시오(language=zh 고정).

STREAMING WebSocket API 사용 — 바이트를 푸시하며 공개 URL이 필요 없음

파일 / 录音文件识别 / MediaKit API는 모두 공개적으로 접근 가능한 HTTP(S) 오디오 URL이 필요합니다. 사용자는 URL 호스팅을 거부했습니다("더 이상 서버에서 mp3를 다운로드하는 방식을 사용하지 마십시오" — 터널이 사용자의 핫스팟에서 실패함). 大模型流式语音识别 (빅모델 스트리밍) API는 원시 PCM 바이트를 WebSocket을 통해 푸시하여 URL을 완전히 우회합니다. 이것이 작동하는 경로입니다. 단어 수준 ms 타임스탬프를 반환합니다.

번들 스크립트 (검증된 작동 경로이므로 이것을 사용하십시오):

# 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 구성을 보냅니다; audio(0x2) 패킷은 gzip 압축 PCM 청크(200ms = 16k 모노 s16le에서 6400바이트)를 보냅니다; 마지막 패킷은 음수 seq와 함께 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[] (토큰 + ms 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 토큰을 찾을 필요가 없습니다. 별칭이 없는 경우 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를 다시 생성하는 경우 항상 , ms를 사용하십시오.

AI 교정 패스 — 명백한 오타 수정 (최종 단계, 중국어의 경우 항상 실행)

Volcano ASR도 화면에서 잘못 읽히는 명백한 동음이의어 오류를 생성합니다: 관찰된 예: 「总数」→「意数」, 「需求」→「虚求」, 「程序员」→「成员」. 원시 ASR 텍스트는 최선이 아니라 최악입니다. SRT가 조립된 후, Claude가 문장 맥락을 사용하여 SRT 전체를 한 번 교정하여 명백한 오류를 수정하십시오.

이 패스는 Claude가 SRT를 읽는 동일 세션 내에서 수행됩니다 — 외부 API 호출이 없습니다. .srt 파일을 그 자리에서 수정하거나 <stem>.polished.srt로 다시 작성하십시오.

엄격한 규칙 — 이것은 편집이 아닌 수정입니다:

  • ✅ 맥락에서 의도된 단어가 명백한 명확한 동음이의어 / 오타만 수정합니다 (意数→总数, 虚求→需求).
  • ⛔ 타임스탬프, 큐 분할 또는 표시 텍스트를 변경하지 마십시오 (오타 수정 제외). 큐 분할을 "더 좋게" 만들지 마십시오.
  • ⛔ 의역하지 마십시오. 원래 음성은 "总数"이었고 ASR이 "意数"라고 잘못 썼습니다. 단어를 "总计数目"으로 "개선"하지 마십시오. 그냥 "意数" → "总数"로 고치십시오.
  • ⛔ 중국어가 아닌 SRT를 교정하지 마십시오. 영어/스페인어/포르투갈어 SRT는 교정하지 마십시오 — 이 패스는 중국어 전용입니다.
# 구현 노트: Claude가 srt 파일을 읽고, 올바른 수정으로 같은 줄을 다시 씁니다.
# (또는 --polished 접미사와 함께).
# 교정은 SRT 구조를 건드리지 않습니다 — 각 큐는 그대로 유지됩니다.
# 예:
#   1
#   00:00:01,234 --> 00:00:04,567
#   意数是关键
# →
#   1
#   00:00:01,234 --> 00:00:04,567
#   总数是关键

Individual skills in this repo

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

관련 스킬