asr-transcribe-to-text — скилл для Claude Code: транскрипт с метками
Переводит аудио и видео в текст с таймкодами и метками спикеров: локально через MLX на Apple Silicon или через удалённый API.
npx skills add daymade/claude-code-skills --skill asr-transcribe-to-text -a claude-codeЧто делает asr-transcribe-to-text
Скилл принимает локальный файл, прямую ссылку на медиа или страницу подкаста. Сначала он проверяет, нет ли уже готового транскрипта в каталоге проекта, потому что наличие сырой записи — не повод генерировать текст заново. Если канонического транскрипта нет, выбирается маршрут: обычные встречи и записи с DJI уходят в Feishu Minutes после сжатия в небольшой M4A, а офлайн-обработка или независимая проверка качества запускают локальный ASR.
Локально есть два маршрута. Длинные записи без присмотра идут через whisper.cpp с чекпоинтами и Silero VAD, а метки спикеров pyannote приклеиваются после распознавания. Короткие и средние записи может обработать связка Qwen3-ASR и mlx-whisper. Ни один маршрут не режет вход на репликах: атрибуция спикеров идёт после ASR по непрерывному контексту. По умолчанию каждый прогон даёт строки вида [start-end] SPEAKER_xx: text плюс CSV.
Для диаризации нужен токен HuggingFace: pyannote — gated-модель. Первый запуск без него падает с инструкцией по настройке, дальше всё определяется автоматически. Отказ от диаризации (--no-diarization) убирает и таймкоды: на выходе остаётся только текст сессии. Настройки хранятся в ${CLAUDE_PLUGIN_DATA}/config.json, режим выбирается по платформе — на macOS Apple Silicon это local-mlx, на остальных remote-api.
Какие задачи решает
- расшифровать запись встречи в текст с разбивкой по спикерам
- сделать субтитры с таймкодами из монолога
- перевести запись в M4A и ужать перед загрузкой в Feishu Minutes
- сверить две независимые разметки спикеров на спорной реплике
- сложить сегменты диктофона в один файл и понизить частоту до 16kHz
Примеры запросов агенту
- расшифруй
meeting.m4aс метками спикеров - сделай субтитры из
interview.mp4, нужны таймкоды по строкам - подготовь
recording.wavдля Feishu Minutes: сожми и ускорь
Как установить asr-transcribe-to-text
1Через Skills CLI (Vercel)
npx skills add daymade/claude-code-skills --skill asr-transcribe-to-text -a claude-code2Вручную: положить файлы
~/.claude/skills/asr-transcribe-to-text/SKILL.mdПапка скилла целиком; для одного проекта — .claude/skills/asr-transcribe-to-text/
1Через Skills CLI (Vercel)
npx skills add daymade/claude-code-skills --skill asr-transcribe-to-text -a codex2Вручную: положить файлы
.agents/skills/asr-transcribe-to-text/SKILL.mdДля всех проектов — ~/.agents/skills/
1Через Skills CLI (Vercel)
npx skills add daymade/claude-code-skills --skill asr-transcribe-to-text -a cursor2Вручную: положить файлы
.cursor/skills/asr-transcribe-to-text/SKILL.mdCursor читает и .agents/skills/, и .claude/skills/
1GitHub CLI
gh skill install daymade/claude-code-skills asr-transcribe-to-text2Вручную: положить файлы
.github/skills/asr-transcribe-to-text/SKILL.md1Через Skills CLI (Vercel)
npx skills add daymade/claude-code-skills --skill asr-transcribe-to-text -a gemini-cli2Командой платформы
gemini skills install https://github.com/daymade/claude-code-skills --path daymade-audio/asr-transcribe-to-textВручную: положить файлы
.devin/skills/asr-transcribe-to-text/SKILL.mdСтарый путь .windsurf/skills/ тоже читается
Через Skills CLI (Vercel)
npx skills add daymade/claude-code-skills --skill asr-transcribe-to-text -a opencodeClaude.ai
Упакуйте папку в zip и загрузите в Settings → Capabilities → Skills
По шагам, с командами для каждой платформы: Как установить скилл в Claude Code: пять способов
Вопросы о asr-transcribe-to-text
Как установить asr-transcribe-to-text в Claude?
Выполните npx skills add daymade/claude-code-skills --skill asr-transcribe-to-text -a claude-code. Скилл появится в ~/.claude/skills/asr-transcribe-to-text/SKILL.md.
Что делает asr-transcribe-to-text?
Расшифровывает аудио и видео в текст с таймкодами и метками спикеров. Работает локально через MLX на Apple Silicon или через удалённый ASR-эндпоинт.
Что нужно для диаризации спикеров?
Токен HuggingFace: pyannote — gated-модель. Без него первый запуск завершится ошибкой с шагами настройки, после настройки возможность определяется автоматически.
Исходный текст
SKILL.md · MIT
---
name: asr-transcribe-to-text
description: >-
Transcribes audio or video to speaker-labeled, timestamped text, locally with MLX on Apple Silicon
or remotely. Use for 转录 / 录音转文字 / 说话人分离 / 字幕, and also for preparing audio for ASR without
transcribing: 转格式, 降采样到 16kHz, merging recorder segments, or compressing and speeding up audio
before 飞书妙记 — even when it looks like a one-line ffmpeg job.
argument-hint: "[audio-or-video-file-path-or-url ...]"
---
# ASR Transcribe to Text
Transcribe audio/video to **speaker-labeled** text. Local execution has two
explicit routes. Long or unattended recordings use checkpointed whisper.cpp +
Silero VAD blocks, then late-fuse pyannote speakers. Short/medium recordings may
use the Qwen3-ASR + mlx-whisper alignment route. Neither route cuts ASR input at
diarization turns; speaker attribution happens after continuous-context ASR.
## Route before ASR: the transcript is the result, not the run
Before starting transcription, check the owning project's transcript catalog,
external source index, and declared prior-work carriers for an existing
canonical transcript using source ID, date, title, and entity terms. A verified
human-reviewed/current transcript ends the task unless the user explicitly
asked for a new independent comparison. Raw audio existing is not a reason to
regenerate text that already exists.
When no canonical transcript exists:
1. For ordinary meeting/DJI recordings where cloud processing is allowed, use
Feishu Minutes as the normal primary route (preprocess to a small M4A first).
2. Use local ASR when the user requires offline/privacy handling, Feishu is
unavailable or failed, or the task explicitly needs an independent quality
comparison.
2b. **For a second, independent speaker timeline**, use StepFun's async file
endpoint via the `stepfun-asr` skill's `asr_file.py`. It is the only vendor
diarization route here that does not share code or models with local pyannote, so
agreement between the two is real corroboration rather than one tool agreeing
with itself. Measured 2026-09-19 on a 10-minute film: both split the key turn
identically, while Feishu Minutes mis-attributed it. The constraint is that it
fetches a public URL — base64 is rejected — so it needs staging (see
`aliyun-multi-account.md`); reach for it when an attribution is load-bearing,
not for routine transcripts.
3. For non-meeting media or an explicit local/remote ASR request, choose the
execution location by the audio-location rule below.
Do not run local ASR merely to make a two-route process look complete.
Before choosing local versus remote execution, read
[execution location and comparison](references/execution_location_and_comparison.md).
Use existing text and validated checkpoints first, then compare source placement,
processing permission, actual GPU/model identity and end-to-end quality evidence.
When measurements are missing, prefer an eligible route next to the audio and
state that this avoids a transfer; do not claim it is faster.
Configuration persists in `${CLAUDE_PLUGIN_DATA}/config.json`.
> **Speaker labels are the default.** Every run produces `[start-end] SPEAKER_xx: text`
> + CSV. Plain-text-only output is the opt-out (`--no-diarization`) for monologues,
> podcasts, or when you just want a summary — see Step 3. It also drops every
> timestamp: the plain-text path returns the session text and nothing else. A
> monologue that needs line-level timestamps (subtitles, jump-to-moment archives)
> runs the full pipeline and ignores the `SPEAKER_00` labels.
>
> **One-time setup for diarization:** pyannote is a gated HuggingFace model — it
> needs a token once (`## Speaker Diarization & Identification` below). First run
> without it FAILS with setup steps; after setup, full capability is permanent
> and auto-detected.
## Step 0: Detect Platform and Load Config
```bash
cat "${CLAUDE_PLUGIN_DATA}/config.json" 2>/dev/null
```
**If config exists**, read values and proceed to Step 1.
**If config does not exist**, auto-detect platform first:
```bash
python3 -c "
import sys, platform
is_mac_arm = sys.platform == 'darwin' and platform.machine() in ('arm64', 'aarch64')
print(f'Platform: {sys.platform} {platform.machine()}')
print(f'Apple Silicon: {is_mac_arm}')
if is_mac_arm:
print('RECOMMEND: local-mlx')
else:
print('RECOMMEND: remote-api')
"
```
Then use **AskUserQuestion** with platform-aware defaults:
For **macOS Apple Silicon** (recommended: local):
```
ASR setup — your Mac supports local MLX; choose from data location and the execution SOP.
Q1: Transcription mode?
A) Local MLX — runs on your Mac's GPU, no API key needed
B) Remote API — send audio to a server (vLLM, Tailscale workstation, etc.)
Q2: Does your network have an HTTP proxy that might intercept traffic?
A) Yes — bypass proxy for ASR traffic (Recommended if using Shadowrocket/Clash)
B) No — direct connection
```
For **other platforms** (recommended: remote):
```
ASR setup — local MLX requires macOS Apple Silicon. Using remote API mode.
Q1: ASR Endpoint URL?
A) https://asr.example.com/v1/audio/transcriptions (Self-hosted remote ASR)
B) http://localhost:8002/v1/audio/transcriptions (Local ASR server)
C) Custom URL
Q2: Proxy bypass needed?
A) Yes (Recommended for Shadowrocket/Clash/corporate proxy)
B) No
```
Save config:
```bash
mkdir -p "${CLAUDE_PLUGIN_DATA}"
python3 -c "
import json
config = {
'mode': 'MODE', # 'local-mlx' or 'remote-api'
'model': 'MODEL_ID', # local: 'mlx-community/Qwen3-ASR-1.7B-8bit', remote: 'Qwen/Qwen3-ASR-1.7B'
'max_tokens': 8192, # local only; PER 20-minute chunk, not per recording
'endpoint': 'URL', # remote only
'noproxy': True,
'max_timeout': 900 # remote only
# 'diarization_declined': True # set only after the user explicitly declines
# the pyannote setup in Step 3 — every run then warns + goes plain-text
# until an HF token appears (auto-detected)
}
with open('${CLAUDE_PLUGIN_DATA}/config.json', 'w') as f:
json.dump(config, f, indent=2)
print('Config saved.')
"
```
## Step 1: Resolve Input
Accept local files, direct media URLs, or web/podcast episode pages.
- **Web or podcast page URL**: inspect the page for an existing transcript first. Use an official/platform transcript only when it is directly accessible to the user's account. If the transcript endpoint requires a login token and none is available, say that clearly and fall back to ASR from the audio URL.
- **Local file, direct media URL, or page URL fallback**: run the bundled resolver. It extracts media from common page metadata (`og:audio`, media tags, JSON-LD, RSS-style enclosure links), downloads URLs with atomic temp-file replacement, verifies remote `Content-Length` when present, computes SHA-256, and validates the result with `ffprobe`.
```bash
uv run ${CLAUDE_SKILL_DIR}/scripts/resolve_media_input.py \
INPUT_FILE_OR_URL [INPUT_FILE_OR_URL2 ...] \
--output-dir OUTPUT_DIR \
--manifest OUTPUT_DIR/media_manifest.json
```
For suspicious or high-value downloads, add `--decode-check` to make `ffmpeg` decode the whole file before transcription:
```bash
uv run ${CLAUDE_SKILL_DIR}/scripts/resolve_media_input.py \
"https://www.xiaoyuzhoufm.com/episode/EPISODE_ID" \
--output-dir OUTPUT_DIR \
--manifest OUTPUT_DIR/media_manifest.json \
--decode-check
```
Expected output:
```text
Downloaded ... bytes in ...s -> OUTPUT_DIR/episode-title.m4a
OUTPUT_DIR/episode-title.m4a
```
Use the printed local path as `INPUT_AUDIO` in later steps. If your runtime shows the literal `${CLAUDE_SKILL_DIR}` instead of a substituted path, resolve the skill directory per the Troubleshooting entry at the bottom of this document.
For third-party public podcasts or copyrighted media, save the transcript as a local file for the user's personal analysis. Do not paste a full long transcript into chat; provide a path, previews, summaries, or short excerpts instead.
## Step 2: Extract Audio (if input is video)
For video files (mp4, mov, mkv, avi, webm), extract as 16kHz mono WAV:
```bash
ffmpeg -i INPUT_VIDEO -vn -acodec pcm_s16le -ar 16000 -ac 1 OUTPUT.wav -y
```
Audio files (wav, mp3, m4a, flac, ogg) can be used directly. Get duration:
```bash
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 INPUT_FILE
```
**Cleanup**: After transcription succeeds, delete extracted WAV files to save disk space.
## Preprocess: Merge Segments & Shrink Metered Uploads (optional)
Run this BEFORE transcription when either applies:
- **The recording is a multi-segment dump** — body mics and field recorders split
sessions into fixed-length files (e.g. `TX02_MIC024_....wav`, `TX02_MIC025_....wav`;
`TX01/TX02` = DJI MIC MINI 2S internal recording — device roster and the
recorder→Feishu-Minutes paths: the meeting-ingest skill's `meeting-ingest/references/architecture.md` §①-L0).
Merge them once to preserve session order; the explicit long-audio runner
(Step 3 Path L) then owns stable source-time blocks. Transcribing device
segments separately throws away cross-segment context inside those chunks.
- **The audio goes to a metered ASR** (Feishu Minutes, any per-minute quota) — a
pitch-PRESERVED speedup cuts billed duration directly, and modern ASR does not care:
1.3x was user-verified on Feishu Minutes (2026-07-16) with no perceptible recognition
difference, and public Whisper benchmarks show no sharp WER drop until 2.0x
(≤1.5x = safe zone, ~3% WER increase at 1.5x; >2x unusable).
Use the bundled script — it merges, normalizes to 16 kHz mono, optionally speeds up,
and verifies its own output instead of trusting the ffmpeg exit code:
```bash
uv run ${CLAUDE_SKILL_DIR}/scripts/prepare_asr_input.py SEG1.wav SEG2.wav -o merged.wav # merge only
uv run ${CLAUDE_SKILL_DIR}/scripts/prepare_asr_input.py SEG*.wav -o upload.m4a --speed 1.3 # merge + quota-saving speedup
```
Expected output:
```text
Merge order:
1. SEG1.wav [pcm_s24le 48000Hz ch=1 1800.14s]
2. SEG2.wav [pcm_s24le 48000Hz ch=1 1800.15s]
[OK] duration: 4946.19s vs expected 4946.18s (delta +0.00s)
[OK] boundary 1 @ 1384.7s: max_volume -15.5 dB
[info] overall: mean_volume -38.3 dB, max_volume 0.0 dB
Wrote upload.m4a
```
- Segments sort by the `YYYYMMDD_HHMMSS` timestamp embedded in their filenames when
every file has one (recorder dumps do); otherwise the given order is kept with a note —
eyeball the printed merge order before transcribing.
- Self-verification: output duration must equal Σsegments ÷ speed (±1.5 s, hard FAIL
otherwise); each splice gets a 10 s volume spot-check (dead air at a boundary = wrong
order or a missing segment); overall loudness prints for comparison with the source.
- Speedup must be `atempo`-style pitch-preserved stretch — never sample-rate trickery,
which shifts pitch and breaks both ASR accuracy and diarization voiceprints.
- **Pick the output format by destination** — codec follows the file extension:
| Destination | Format | Why |
|---|---|---|
| Local MLX pipeline (Path A) | `.wav` or `.m4a` | Both feed the pipeline directly (m4a verified 2026-07-18: a 3-min slice transcribed cleanly). M4A is ~5x smaller — 324 MB WAV → 63 MB M4A on a 2h49m merge, duration identical to the second |
| Metered upload (Feishu Minutes, per-minute quota) | `.m4a` + `--speed 1.3` | AAC 48k is speech-transparent for ASR, ~30% smaller than mp3 at equal speech quality; speedup cuts billed duration ~23% |
| Self-hosted vLLM endpoint (Path B) | `.ogg` | Accepted where MP3 is refused, and ~8× smaller than WAV — which is what keeps a long recording under the server's 25 MB request cap. See Path B's limits section |
| Lossless archive | `.flac` | ~50% of WAV, bit-perfect |
| Only when the target rejects the above | `.mp3` | Compatibility fallback |
- Keep the originals until the transcript passes Step 4 verification.
## Option: Upload to Feishu Minutes for transcription
After preprocessing, use this path when the user wants **Feishu Minutes**, or
when an ordinary meeting/DJI recording has no canonical transcript and cloud
processing is allowed. It is the normal meeting-audio route, not a fallback
that requires a failed local run first.
**Trigger phrases**: 传到妙记 / 上传到飞书妙记 / 让妙记转写 / create a minute from this audio / upload to Feishu minutes.
**Track the requested outcome before uploading**:
- **Upload-only** — the user explicitly says they only want a Minute link or
only want the media uploaded. Creating `minute_url` is the terminal state.
- **Transcript-only** — the user wants the transcript/summary but no project
filing. After upload, follow the current version-matched `lark-minutes`
instructions through the ready transcript.
- **Project delivery** — the user wants correction, routing, a knowledge-base
file, project indexes, or Git handoff. `meeting-ingest` owns the overall job;
this skill is only its preprocessing participant. Switch to that orchestrator
before upload and keep running until its delivery receipt is verified.
- **Downstream unspecified** — the request says to preprocess/upload (for
example, “上传到飞书妙记,先转成适合 ASR 的格式”) but does not say what should
happen after the Minute exists. Honor the requested upload without inventing
transcript or project scope, then emit `outcome_pending` with the durable
token/URL. A later transcript or knowledge-base request resumes that same
Minute; it never starts over or depends on the original guess.
Do not infer upload-only merely because the user's first clause says “上传到妙记”.
An explicit downstream outcome in the same request or project context wins.
**Constraints**:
- **No proxy**: all `lark-cli` calls must use `LARK_CLI_NO_PROXY=1`.
- **Single profile**: use the active Feishu profile only. Do not iterate tenant
profiles or invoke tenant routing.
- **No duplicate local ASR by default**: Feishu owns transcription on this
branch. Local ASR runs only for an explicit offline/comparison purpose.
- **Load current Feishu guidance**: use `lark-cli-router`, then read the
version-matched `lark-minutes` upload/detail instructions. Do not copy a stale
stop condition from this skill over the live CLI contract.
**Step-by-step**:
1. **Use the already-preprocessed audio** from the section above when possible.
Feishu accepts `.m4a`, `.mp3`, `.wav`, `.aac` in an MP4/MOV wrapper; the
preprocessor's "Metered upload" row is already shaped for this. Keep the file
under 6 GB and under 6 hours — those are Feishu upload hard limits.
2. **Upload to Drive as the user**:
```bash
LARK_CLI_NO_PROXY=1 lark-cli drive +upload \
--file '<preprocessed-media-path>' \
--name '<basename>' \
--as user \
--format json
```
From the result, record `file_token`. If the command errors with a path
validation or multipart failure, do not retry blindly — switch format or size
strategy and try once more, then report the exact failure.
3. **Create the minute from that Drive file**:
```bash
LARK_CLI_NO_PROXY=1 lark-cli minutes +upload \
--file-token '<file_token>' \
--as user \
--format json
```
From the result, record `minute_token` and `minute_url`.
4. **Branch on the previously fixed terminal outcome**:
- Upload-only: return `minute_url` and stop.
- Transcript-only: use the current `lark-minutes` detail command with its
ready-wait behavior and `--transcript`. A created URL is intermediate;
success requires a readable transcript artifact.
- Project delivery: return this handoff tuple to `meeting-ingest` and continue
in the same run: `prepared_media`, `file_token`, `minute_token`,
`minute_url`, `next_required_phase=minute_ready`. The orchestrator waits for
the cloud transcript, invokes `sync-feishu-minutes` for token-scoped
ingest/routing/delegation, runs full `transcript-fixer`, updates every
project-owned index, completes Git handoff, and records delivery.
- Downstream unspecified: return `minute_token`, `minute_url`,
`outcome_pending`, and `next_required_phase=outcome_decision`. Do not wait
for or file a transcript until the user supplies that downstream outcome.
If readiness times out or a downstream stage blocks, report the exact last
completed phase plus the durable `minute_token`/`minute_url`. Resume that same
token; never re-upload just to obtain a fresh URL.
**Expected output**:
- Upload-only success: a single `minute_url` the user can open.
- Downstream-unspecified success: the requested upload is complete, while the
run remains explicitly resumable at `outcome_pending`; do not call it project
delivery.
- Transcript-only success: `minute_url` plus a readable transcript artifact.
- Project-delivery success: only the `meeting-ingest` pushed delivery receipt;
preprocessing, URL creation, and transcript download are intermediate phases.
- Failure: exact API error from `drive +upload` or `minutes +upload`, plus one
suggested next action.
**Wrong-skill recovery**: if this request lands while you are inside
`sync-feishu-minutes`, choose by outcome: upload-only routes here; project
delivery routes to `meeting-ingest`, which calls this preprocessing branch and
then returns to token-scoped Feishu ingestion. Never silently downgrade project
delivery to a Minute URL.
## Step 3: Transcribe (speaker labels by default)
### Path L: Long local recording — whisper.cpp + Silero VAD (default for >30 min)
Use this path for recordings longer than 30 minutes, unattended batch work, or
any source likely to contain long silence / post-meeting ambient audio. It is
the Apple-Silicon long-form route: source-time blocks are checkpointed, each
block runs whisper.cpp's Silero VAD, 2-second overlap is de-duplicated at block
seams, and pyannote speaker labels are late-fused afterward.
Runtime assets and the GPU build manifest are explicit operator inputs. Do not
discover or download models silently inside a batch. Before using this route,
read [GPU-only runtime preparation](references/whispercpp_gpu_runtime.md).
The agent prepares the isolated pinned build within existing setup authorization;
an unmodified binary's `--use-gpu` flag does not prove GPU VAD. The runner rejects
unknown build identities before model load and the native guard rejects CPU or
unknown graph assignments before inference.
```bash
# 1. Checkpointed ASR on the original source timeline
uv run ${CLAUDE_SKILL_DIR}/scripts/transcribe_long_whispercpp.py \
INPUT_16K_MONO_PCM16.wav OUTPUT_DIR \
--ffmpeg-path /absolute/path/to/ffmpeg \
--whisper-bin /absolute/path/to/whisper-cli \
--gpu-runtime-manifest /absolute/path/to/user-cache/gpu-runtime.json \
--whisper-model /absolute/path/to/ggml-large-v2-or-v3.bin \
--vad-model /absolute/path/to/ggml-silero-v6.2.0.bin
# 2. Independent speaker timeline (controlled FFmpeg decode; no TorchCodec path)
uv run --frozen ${CLAUDE_SKILL_DIR}/scripts/diarize_speakers.py \
INPUT_16K_MONO_PCM16.wav OUTPUT_DIR/STEM.diarization.json \
--device mps --ffmpeg-path /absolute/path/to/ffmpeg
# 3. Keep only speech-grounded ASR segments and assign speakers by time overlap
uv run ${CLAUDE_SKILL_DIR}/scripts/fuse_whispercpp_diarization.py \
OUTPUT_DIR/STEM.whispercpp.json \
OUTPUT_DIR/STEM.diarization.json \
INPUT_16K_MONO_PCM16.wav OUTPUT_DIR
```
If the physical recorder was left running after the business session ended,
pass an evidence-backed source timestamp to both relevant steps (`--end-at
SECONDS`). Keep the raw audio and unbounded ASR evidence, but do not force
post-meeting car noise or silence into the meeting transcript.
Completion means the final TXT/CSV/fusion receipt exists and beginning, middle,
end, and every outer block seam have been sampled. `N/N blocks complete` alone
is not a quality claim. A rerun must report every completed block as cached.
Official architecture basis: whisper.cpp VAD extracts speech before inference;
OpenAI Whisper resets previous-text context to avoid repetition loops; NeMo
long-audio guidance uses overlapping buffered chunks. The project-specific
measured evidence and parameter rationale live in
`references/speaker_diarization.md`.
### Path A: Local MLX (macOS Apple Silicon) — short/medium alternative
Run the decoupled speaker pipeline — it handles dependency pins, bounded
per-chunk generation, resumable checkpoints, model loading, and process-tree
cleanup internally.
Input extensions are not trusted as decoder contracts. The Qwen worker first
uses the pinned MLX decoder; if that decoder reports a recognized input/container
decode failure (for example miniaudio rejecting Ogg/Opus), it uses `ffmpeg` to
create a temporary 16 kHz mono PCM WAV for the pinned default model (custom local
models use their declared sample rate) and retries. GPU, memory, and unrelated
runtime failures propagate unchanged and never trigger normalization. The
checkpoint, output name, and provenance remain bound to the original source
bytes; the temporary WAV is never a completion artifact.
```bash
uv run ${CLAUDE_SKILL_DIR}/scripts/speaker_transcribe.py \
INPUT_AUDIO [INPUT_AUDIO2 ...] OUTPUT_DIR
```
Unattended batches: one file per invocation. A deterministic failure in any leg
(for example `ASR_DETERMINISTIC_BLOCKED: ChunkTokenLimitError` when one recording
hits the per-chunk token ceiling) exits the whole multi-input run by design, and
the files queued after it are never started — a 31-file batch died on file 11 that
way. Per-file calls contain the damage to the one recording; the intermediate
legs are cached, so a rerun of the others costs only the alignment.
Expected output (per file):
```text
Device: mps
+ uv run .../transcribe_local_mlx.py ... (leg 1: session text)
Chunk 1/6 starting at 0.0s (max_tokens=8192)
Chunk 1/6 committed: 6310 chars, 3812 tokens
+ uv run .../word_timestamps_whisper.py ... (leg 2: timing lattice)
... diarization ... (leg 3: pyannote segments)
STEM: 42 turns, speakers=['SPEAKER_00', 'SPEAKER_01'], anchored_ratio=0.93
Wrote STEM.txt, STEM.csv, STEM.alignment.json, STEM.receipt.json
```
Outputs per input: `<stem>.txt` (`[MM:SS - MM:SS] SPEAKER_xx` + text),
`<stem>.csv` (`file,start,end,duration,speaker,text` — feeds review UIs and
voiceprint ID), `<stem>.diarization.json`, `<stem>.alignment.json` (provenance
+ `anchored_ratio` trust signal; < 0.5 prints a loud warning — verify labels
against the audio before trusting them), and `<stem>.receipt.json` (the atomic
completion record binding source bytes, all four final artifact hashes, producer
scripts, pinned model/dependencies, and semantic parameters). Intermediate legs are cached in
`OUTPUT_DIR/_align/` so re-runs are cheap (`--force` redoes final legs). Each
intermediate cache sidecar binds source-audio bytes, producer-script bytes,
semantic parameters, and artifact bytes; file existence alone is never a cache
hit. Downstream completion checks require the final receipt, not a non-empty
artifact or alignment JSON alone. Qwen chunk checkpoints live below its staging directory; an interrupted run verifies
the source-audio SHA-256, producer/splitter/dependency contract, immutable model
revision, generation parameters, and completed chunk hashes, then skips completed chunks
instead of starting the recording over. A language-agnostic 12-character n-gram
guard rejects highly repetitive chunk or whole-session text before final delivery;
the quality-policy ID is part of checkpoint identity, so older unchecked parts
cannot silently bypass the guard.
Before using the Qwen3 route, smoke-test its leg once:
```bash
uv run ${CLAUDE_SKILL_DIR}/scripts/transcribe_local_mlx.py --smoke-test
```
Expect the bundled script's dependency validation and `Smoke test OK`; pins are
defined by `scripts/transcribe_local_mlx.py`. For performance, per-chunk token
semantics, resource bounds, and recovery, read `references/local_mlx_guide.md`.
**How it works (and why):** session-wide Qwen3-ASR text + mlx-whisper word
timestamps + pyannote speaker segments, aligned after the fact. It avoids the
quality-damaging old cascade that transcribed each diarized speaker turn in
isolation. Do not promote its bounded generation chunks into a long-form
guarantee: a real multi-hour recording reached the token ceiling at 20, 10, and
5 minute windows. Architecture, alignment algorithm, and failure modes:
`references/decoupled_speaker_alignment.md`.
**First run: pyannote needs a one-time HuggingFace token.** If the script exits
with the setup hint (exit code 3), STOP and use **AskUserQuestion**:
```
Speaker diarization needs a one-time setup (gated model, free):
1. Accept terms at https://hf.co/pyannote/speaker-diarization-3.1
2. Run `huggingface-cli login` (or set HF_TOKEN)
Options:
A) Set it up now — I'll wait, then rerun with full speaker labels (Recommended)
B) Continue without speakers this time — plain text only
```
- **A** → after the user confirms login, rerun the same command. The token is
auto-detected every run; full capability is permanent from then on.
- **B** → persist the choice (`diarization_declined: true` in config.json) and
rerun the SAME command. The script detects the flag, prints a one-line warning
with the two setup steps, and auto-falls back to plain text for that run —
no need to pass `--no-diarization` (the fallback is automatic now, enforced in
the script not just the doc). The same warn-and-continue happens on every
later run while the token is still missing. When a token later appears,
diarization resumes automatically (the flag is ignored once a token is
present) — mention this so the user knows setup is all that's needed.
**Plain-text fast path** (monologue, podcast, "just summarize it"):
```bash
uv run ${CLAUDE_SKILL_DIR}/scripts/speaker_transcribe.py \
INPUT_AUDIO OUTPUT_DIR --no-diarization
```
**Remote/pre-made ASR text** (e.g. from Path B, or another ASR service): skip
the Qwen3 leg and align that text instead. `--text-file` pairs ONE transcript
with ONE input wav — passing multiple inputs is rejected (one transcript can't
be aligned to several files):
```bash
uv run ${CLAUDE_SKILL_DIR}/scripts/speaker_transcribe.py \
INPUT_AUDIO OUTPUT_DIR --text-file TRANSCRIPT.txt
```
**Non-Apple-Silicon machines:** the whisper timing leg is MLX-only. Without it
there is no timing lattice to align speakers onto — run with `--no-diarization`
and tell the user speaker mode currently requires Apple Silicon (cloud ASR with
built-in diarization, e.g. Feishu Minutes, is the no-local-GPU alternative).
**Before batching many short files** (promo clips, montage cuts — anything that
may contain music-only audio), read `## Batch Transcription (many short files)`
below: one music-only clip can stall the whole batch for 10+ minutes.
### Path B: Remote API
The remote endpoint returns plain text only — speakers are added locally by
aligning that text (leg 1) with the local timing + diarization legs. So Path B
= fetch text remotely, then run Path A's pipeline with `--text-file`.
Use the bundled remote runner with the existing configuration. It checks
response semantics, binds source and result bytes, and records observed producer
identity when the configured service exposes it; it never switches a shared model.
For self-hosted GPU evidence and performance comparison, follow
[execution location and comparison](references/execution_location_and_comparison.md).
```bash
python3 ${CLAUDE_SKILL_DIR}/scripts/transcribe_remote.py \
INPUT_AUDIO OUTPUT.txt --config "${CLAUDE_PLUGIN_DATA}/config.json"
```
Expect `OUTPUT.txt` and `OUTPUT.txt.asr.json`; the receipt represents the plain-text
leg, not a recognition-quality or speaker-completion claim.
Then attach speakers locally (Apple Silicon + pyannote token required):
```bash
uv run ${CLAUDE_SKILL_DIR}/scripts/speaker_transcribe.py \
INPUT_AUDIO OUTPUT_DIR --text-file OUTPUT.txt
```
#### Self-hosted vLLM: the limits that fail in confusing ways
**Version matters here — two of these changed between releases.** Behavior below was
measured end-to-end against vLLM `0.15.2rc1.dev68` (a dev build; there is no `0.15.2`
release — PyPI goes 0.15.1 → 0.16.0) serving `Qwen/Qwen3-ASR-1.7B`, then re-read
against the `v0.26.0` sources. Check your own version first — `pip show vllm` — and
read the version notes on #1 and #3.
**1. Send OGG, not WAV — and never MP3.** MP3 is rejected outright on 0.15.x, but
the reflex fix (convert to WAV) is what walks you into the size cap in #2:
| Format | 60 s @ 16 kHz mono, 16-bit | Accepted (0.15.x) |
|---|---|---|
| WAV `pcm_s16le` | 1,920 KB | yes |
| FLAC | 1,092 KB | yes |
| **OGG Vorbis** | **245 KB** | **yes** |
| MP3 | — | **no** |
OGG is ~8× smaller than WAV at the same sample rate:
```bash
ffmpeg -nostdin -v error -i INPUT -ar 16000 -ac 1 -c:a libvorbis OUTPUT.ogg
```
Pin the bit depth when you compare formats yourself — decoding a lossy source
leaves ffmpeg free to widen it, and a 24-bit FLAC comes out *larger* than 16-bit
PCM, which reads as "FLAC doesn't compress" when the two simply weren't the same
recording. Add `-sample_fmt s16`.
The MP3 rejection is worth recognizing because **it arrives as HTTP 200 with an
error body** — a check that only inspects `%{http_code}` reports success:
```
HTTP=200
{"error": {"message": "Error opening <_io.BytesIO object>: Format not recognised.", ...}}
```
*Version note:* on 0.15.x the upload is read via `librosa`/soundfile on a `BytesIO`,
which refuses MP3 there even where the host's libsndfile handles MP3 on disk. `v0.26.0`
added a pyav fallback after a soundfile `LibsndfileError` (`multimodal/media/audio.py`),
so MP3/M4A likely decode on current releases — but OGG stays the better choice for the
size reason above.
**2. Requests are capped at 25 MB.**
```
{"error":{"message":"Maximum file size...
© daymade · MIT · daymade/claude-code-skills