
Asr Transcribe To Text
- 542 installs
- 1.3k repo stars
- Updated August 4, 2026
- daymade/claude-code-skills
Integrate automatic speech recognition pipelines to convert meetings, voice notes, and call audio into searchable text for agents, docs, and downstream NLP workflows.
About
Guides building ASR transcribe-to-text capabilities inside Claude Code projects: capturing audio, invoking recognition services, normalizing output, and feeding transcripts to agents or databases. Covers error retries, chunking long recordings, confidence thresholds, and integration touchpoints for meeting bots, voice UIs, and support automation.
- Batch and streaming transcription patterns
- Provider selection and fallback handling
- Speaker diarization and timestamp alignment
- Post-processing cleanup for LLM consumption
- Privacy-aware audio handling hooks
Asr Transcribe To Text by the numbers
- 542 all-time installs (skills.sh)
- Ranked #1,687 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/daymade/claude-code-skills --skill asr-transcribe-to-textAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 542 |
|---|---|
| repo stars | ★ 1.3k |
| Last updated | August 4, 2026 |
| Repository | daymade/claude-code-skills ↗ |
What it does
Integrate automatic speech recognition pipelines to convert meetings, voice notes, and call audio into searchable text for agents, docs, and downstream NLP workflows.
Files
ASR Transcribe to Text
Transcribe audio/video files to text using Qwen3-ASR. Two inference paths:
| Mode | When | Speed | Cost |
|---|---|---|---|
| Local MLX | macOS Apple Silicon | 15-27x realtime | Free |
| Remote API | Any platform, or when local unavailable | Depends on GPU | API/self-hosted |
Configuration persists in ${CLAUDE_PLUGIN_DATA}/config.json.
Step 0: Detect Platform and Load Config
cat "${CLAUDE_PLUGIN_DATA}/config.json" 2>/dev/nullIf config exists, read values and proceed to Step 1.
If config does not exist, auto-detect platform first:
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 has Apple Silicon, so local transcription is recommended.
Q1: Transcription mode?
A) Local MLX — runs on your Mac's GPU, no API key needed, 15-27x realtime (Recommended)
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 connectionFor other platforms (recommended: remote):
ASR setup — local MLX requires macOS Apple Silicon. Using remote API mode.
Q1: ASR Endpoint URL?
A) http://workstation-4090-wsl:8002/v1/audio/transcriptions (Qwen3-ASR vLLM via Tailscale)
B) http://localhost:8002/v1/audio/transcriptions (Local server)
C) Custom URL
Q2: Proxy bypass needed?
A) Yes (Recommended for Shadowrocket/Clash/corporate proxy)
B) NoSave config:
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': 200000, # local only, critical for long audio
'endpoint': 'URL', # remote only
'noproxy': True,
'max_timeout': 900 # remote only
}
with open('${CLAUDE_PLUGIN_DATA}/config.json', 'w') as f:
json.dump(config, f, indent=2)
print('Config saved.')
"Step 1: Extract Audio (if input is video)
For video files (mp4, mov, mkv, avi, webm), extract as 16kHz mono WAV:
ffmpeg -i INPUT_VIDEO -vn -acodec pcm_s16le -ar 16000 -ac 1 OUTPUT.wav -yAudio files (wav, mp3, m4a, flac, ogg) can be used directly. Get duration:
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 INPUT_FILECleanup: After transcription succeeds, delete extracted WAV files to save disk space.
Step 2: Transcribe
Path A: Local MLX (macOS Apple Silicon)
Use the bundled script — it handles model loading, chunking, and the critical max_tokens parameter:
uv run ${CLAUDE_PLUGIN_ROOT}/scripts/transcribe_local_mlx.py \
INPUT_AUDIO [INPUT_AUDIO2 ...] \
--output-dir OUTPUT_DIRThe script loads the model once and transcribes all files sequentially (no GPU contention). For details on performance, model compatibility, and the max_tokens truncation issue, see references/local_mlx_guide.md.
Critical: The upstream mlx-audio default max_tokens=8192 silently truncates audio longer than ~40 minutes. The bundled script defaults to 200000. If calling model.generate() directly, always pass max_tokens=200000.
Path B: Remote API
Health check first (skip if already verified this session):
python3 -c "
import json, subprocess, sys
with open('${CLAUDE_PLUGIN_DATA}/config.json') as f:
cfg = json.load(f)
base = cfg['endpoint'].rsplit('/audio/', 1)[0]
noproxy = ['--noproxy', '*'] if cfg.get('noproxy', True) else []
result = subprocess.run(
['curl', '-s', '--max-time', '10'] + noproxy + [f'{base}/models'],
capture_output=True, text=True
)
if result.returncode != 0 or not result.stdout.strip():
print(f'HEALTH CHECK FAILED: {base}/models', file=sys.stderr)
sys.exit(1)
print(f'Service healthy: {base}')
"Read config and send via curl:
python3 -c "
import json, subprocess, sys, os, tempfile
with open('${CLAUDE_PLUGIN_DATA}/config.json') as f:
cfg = json.load(f)
noproxy = ['--noproxy', '*'] if cfg.get('noproxy', True) else []
timeout = str(cfg.get('max_timeout', 900))
audio_file = 'AUDIO_FILE_PATH'
output_json = tempfile.mktemp(suffix='.json', prefix='asr_')
result = subprocess.run(
['curl', '-s', '--max-time', timeout] + noproxy + [
cfg['endpoint'],
'-F', f'file=@{audio_file}',
'-F', f'model={cfg[\"model\"]}',
'-o', output_json
], capture_output=True, text=True
)
with open(output_json) as f:
data = json.load(f)
if 'text' not in data:
print(f'ERROR: {json.dumps(data)[:300]}', file=sys.stderr)
sys.exit(1)
text = data['text']
print(f'Transcribed: {len(text)} chars', file=sys.stderr)
print(text)
os.unlink(output_json)
" > OUTPUT.txtIf remote health check fails, diagnose in order: 1. Network: ping -c 1 HOST or tailscale status | grep HOST 2. Service: tailscale ssh USER@HOST "curl -s localhost:PORT/v1/models" 3. Proxy: retry with --noproxy '*' toggled
Step 3: Verify Output
After transcription, check for truncation — the most common failure mode:
1. Confirm output is not empty 2. Check character count is plausible (~400 chars/min for Chinese, ~200 words/min for English) 3. Check the ending — does it trail off mid-sentence? If so, max_tokens was exhausted 4. Show user the first and last ~200 characters as preview
If truncated or wrong, use AskUserQuestion:
Transcription may be truncated:
- Expected: ~[N] chars for [M] minutes of audio
- Got: [actual] chars ([pct]% of expected)
- Last line: "[last 100 chars...]"
Options:
A) Retry with higher max_tokens (current: [N], try: [N*2])
B) Switch mode — try [local/remote] instead
C) Save as-is — the output looks complete to me
D) AbortStep 4: Fallback — Overlap-Merge (Remote API Only)
If single remote request fails (timeout, OOM), fall back to chunked transcription:
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/overlap_merge_transcribe.py \
--config "${CLAUDE_PLUGIN_DATA}/config.json" \
INPUT_AUDIO OUTPUT.txtSplits into 18-minute chunks with 2-minute overlap, merges using punctuation-stripped fuzzy matching. See references/overlap_merge_strategy.md for algorithm details.
For local MLX mode, overlap-merge is unnecessary — the bundled script handles chunking internally with max_tokens=200000.
Step 5: Recommend Transcript Correction
ASR output always contains recognition errors — homophones, garbled technical terms, broken sentences. After successful transcription, proactively suggest running the transcript-fixer skill on the output:
Transcription complete: [N] chars saved to [output_path].
ASR output typically contains recognition errors (homophones, garbled terms, broken sentences).
Would you like me to run /daymade-audio:transcript-fixer to clean up the text?
Options:
A) Yes — run daymade-audio:transcript-fixer on the output now (Recommended)
B) No — the raw transcription is good enough for my needs
C) Later — I'll run it myself when readyIf the user chooses A, invoke the transcript-fixer skill with the output file path. The two skills form a natural pipeline: transcribe → correct → review.
Reconfigure
rm "${CLAUDE_PLUGIN_DATA}/config.json"Then re-run Step 0.
Bundled Resources
Scripts:
transcribe_local_mlx.py— Local MLX transcription (macOS ARM64, PEP 723 deps)overlap_merge_transcribe.py— Chunked transcription with overlap merge (remote API fallback)
References:
local_mlx_guide.md— Performance benchmarks, max_tokens truncation, model compatibilityoverlap_merge_strategy.md— Why naive chunking fails, fuzzy merge algorithm
Security scan passed
Scanned at: 2026-03-22T23:58:59.508059
Tool: gitleaks + pattern-based validation
Content hash: 5dbbc175b8bfd6c6c8ab97f4112bf1f48eb489ef2a55375f88266deade644cd4
Local MLX Transcription Guide
Platform Requirements
- macOS on Apple Silicon (M1/M2/M3/M4/M5+)
- Python 3.10+
uvpackage manager- ~3GB disk for model weights (first download)
Recommended Configuration
| Setting | Value | Why |
|---|---|---|
| Model | mlx-community/Qwen3-ASR-1.7B-8bit | 8-bit quantized, fast inference, good quality |
| max_tokens | 200000 | Default 8192 silently truncates audio >40min |
| Audio format | WAV 16kHz mono PCM | Best compatibility with ASR models |
Performance Benchmarks (M5 Pro 48GB, April 2026)
| Audio Length | Inference Time | Speed | Chars | Tokens |
|---|---|---|---|---|
| 1 min | 3.7s | 16x realtime | 295 | ~180 |
| 5 min | 11.1s | 27x realtime | 1,633 | ~980 |
| 15 min | 50.5s | 17.8x realtime | 5,074 | ~3,045 |
| 123 min | 502s (8m22s) | 14.7x realtime | 40,347 | 24,337 |
| 96 min | 409s (6m48s) | 14.1x realtime | 30,018 | 18,214 |
Model load: ~4s (cached), ~130s (first download).
Critical: max_tokens Truncation
The model.generate() method in mlx-audio has max_tokens=8192 as default. This is a global budget shared across all audio chunks, not per-chunk. When exhausted, remaining chunks are silently skipped.
For 123 minutes of Chinese speech:
- Required: ~24,000 tokens
- Default budget: 8,192 tokens
- Result: only first ~40 minutes transcribed, rest silently dropped
Always pass max_tokens=200000 for any audio longer than 20 minutes.
Model Weight Compatibility
Two MLX packages exist for Qwen3-ASR. Their weight formats are incompatible:
| Package | Use with | Weight Format |
|---|---|---|
mlx-audio (Blaizzy) | mlx-community/Qwen3-ASR-1.7B-8bit | mlx-audio quantization (audio_tower quantized) |
mlx-qwen3-asr (moona3k) | Qwen/Qwen3-ASR-1.7B | Own loader (audio_tower NOT quantized) |
Crossing these produces "Missing 297 parameters" error. This skill uses mlx-audio.
Alternatives Not Recommended
| Approach | Issue |
|---|---|
| PyTorch MPS (qwen-asr package) | 97.77% time in GPU↔CPU sync, RTF 5.5-24.5x |
| whisper.cpp large-v3-turbo | High Chinese error rate |
| Official qwen-asr on macOS | Designed for CUDA only |
Overlap-Merge Strategy: Why and How
The Problem with Naive Chunking
When ASR transcribes audio in chunks, each chunk's last sentence gets forcibly truncated. The model closes the sentence at the chunk boundary even if the speaker is mid-sentence.
Real example from testing (5-minute chunks):
| Version | Text at boundary |
|---|---|
| 5min chunk ending | "...靠的就。" (truncated) |
| Continuous 10min | "...靠的就是其中一次战略会。" (complete) |
| Next 5min chunk start | "如果不这么啃,这个业务..." (picks up but gap exists) |
Concatenating these chunks produces: "靠的就。如果不这么啃..." — losing "是其中一次战略会" entirely.
Why Exact String Matching Fails
The same 2-minute audio segment transcribed in two different contexts (end of chunk A vs start of chunk B) produces different punctuation:
- Chunk A: "可能三年AI的进化"
- Chunk B: "可能。三年AI的进化"
The words are identical, but punctuation differs because the model's sentence boundary decisions depend on surrounding context. Exact string matching finds zero overlap.
The Solution: Punctuation-Stripped Fuzzy Matching
1. Strip all punctuation from both the tail of chunk A and the head of chunk B 2. Find the longest common substring in the stripped versions 3. Use chunk B's version at the merge point (because chunk A's ending is truncated while chunk B has the complete sentence)
Optimal Parameters (Empirically Determined)
| Parameter | Value | Rationale |
|---|---|---|
| Chunk duration | 18 min (1080s) | Safe margin under 20min paper benchmark; 4090 24GB handles much more |
| Overlap duration | 2 min (120s) | ~800 chars overlap region; enough for reliable fuzzy matching |
| Min match length | 15 chars | Filters false positives while catching real overlaps |
| Search window | 600 chars | Covers the overlap region with margin |
When to Use This Fallback
Only use overlap-merge when the single full-length request fails. Reasons it might fail:
- Audio longer than ~2 hours (untested territory, may OOM on 24GB VRAM)
- GPU memory pressure from other processes
- Network timeout (curl max-time exceeded)
For audio under 1 hour, always try single request first — it's faster, simpler, and produces the best quality.
Empirical Comparison (55-minute AI course recording)
| Strategy | Segments | Boundaries | Total chars | Quality |
|---|---|---|---|---|
| 12x5min direct concat | 12 | 11 cuts | 23,060 | 11 truncated sentences |
| 4x18min overlap merge | 4 | 3 merges | 22,781 | Zero truncation |
| 1x55min single request | 1 | 0 | 22,889 | Perfect (best) |
# /// script
# requires-python = ">=3.9"
# ///
"""
Overlap-merge transcription for long audio files.
Splits audio into 18-minute chunks with 2-minute overlap, transcribes each chunk
via a configurable ASR endpoint, then merges using punctuation-stripped fuzzy
matching to eliminate sentence truncation at boundaries.
Usage:
python3 scripts/overlap_merge_transcribe.py INPUT_AUDIO OUTPUT.txt --config CONFIG.json
python3 scripts/overlap_merge_transcribe.py INPUT_AUDIO OUTPUT.txt --endpoint URL --model MODEL
"""
import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
def get_duration(audio_path: str) -> float:
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", audio_path],
capture_output=True, text=True
)
return float(result.stdout.strip())
def split_audio(audio_path: str, chunk_dir: str, chunk_duration: int, overlap: int) -> list[tuple[int, int, str]]:
"""Split audio into overlapping chunks. Returns list of (start_sec, duration_sec, chunk_path)."""
total = int(get_duration(audio_path))
chunks = []
start = 0
while start < total:
duration = min(chunk_duration, total - start)
chunk_path = os.path.join(chunk_dir, f"chunk_{len(chunks):02d}.mp3")
subprocess.run(
["ffmpeg", "-i", audio_path, "-ss", str(start), "-t", str(duration),
"-acodec", "copy", chunk_path, "-y"],
capture_output=True
)
chunks.append((start, duration, chunk_path))
print(f" Chunk {len(chunks)-1}: {start//60}:{start%60:02d} - {(start+duration)//60}:{(start+duration)%60:02d}", file=sys.stderr)
start += duration - overlap
if start + duration >= total and duration == chunk_duration:
start = total - duration # ensure last chunk covers the end
if start <= chunks[-1][0]:
break
return chunks
def transcribe(audio_path: str, endpoint: str, model: str, noproxy: bool = True) -> str:
"""Send audio to ASR endpoint and return text."""
noproxy_args = ["--noproxy", "*"] if noproxy else []
result = subprocess.run(
["curl", "-s", "--max-time", "600"] + noproxy_args + [
endpoint,
"-F", f"file=@{audio_path}",
"-F", f"model={model}"
],
capture_output=True, text=True
)
data = json.loads(result.stdout)
return data["text"]
def strip_punct(text: str) -> str:
"""Remove all punctuation, keep only CJK chars, letters, and digits."""
return re.sub(r'[^\w\u4e00-\u9fff]', '', text)
def fuzzy_merge(text_a: str, text_b: str, search_chars: int = 600, min_match: int = 15) -> str:
"""
Merge two overlapping transcription segments using punctuation-stripped fuzzy matching.
The ASR model produces slightly different punctuation for the same audio segment
across different runs, so exact string matching fails. By stripping punctuation
before matching, we find the true overlap region reliably.
Uses text_b's version at the merge point because text_a truncates its final sentence
while text_b has the complete version.
"""
tail_a_clean = strip_punct(text_a[-search_chars:])
text_b_clean = strip_punct(text_b)
best_match_len = 0
best_b_clean_end = 0
# Search for longest matching substring (punctuation-stripped)
for start in range(len(tail_a_clean)):
substr = tail_a_clean[start:start + min_match]
if len(substr) < min_match:
break
pos = text_b_clean.find(substr)
if pos >= 0:
# Extend the match as far as possible
match_len = min_match
while (start + match_len < len(tail_a_clean)
and pos + match_len < len(text_b_clean)
and tail_a_clean[start + match_len] == text_b_clean[pos + match_len]):
match_len += 1
if match_len > best_match_len:
best_match_len = match_len
best_b_clean_end = pos + match_len
best_a_clean_start = start
if best_match_len >= min_match:
# Map clean positions back to raw text positions
# For text_a: find where the match starts in raw text
a_offset = len(text_a) - search_chars
clean_count = 0
a_cut_pos = len(text_a)
for idx, ch in enumerate(text_a[-search_chars:]):
if strip_punct(ch):
clean_count += 1
if clean_count > best_a_clean_start:
a_cut_pos = a_offset + idx
break
# For text_b: find where the match ends in raw text
clean_count = 0
b_start_pos = 0
for idx, ch in enumerate(text_b):
if strip_punct(ch):
clean_count += 1
if clean_count >= best_b_clean_end:
b_start_pos = idx + 1
break
print(f" Merged: {best_match_len} chars matched (punct-stripped)", file=sys.stderr)
return text_a[:a_cut_pos] + text_b[b_start_pos:]
else:
print(f" Warning: no overlap found ({best_match_len} chars), concatenating directly", file=sys.stderr)
return text_a + text_b
def main():
parser = argparse.ArgumentParser(description="Overlap-merge ASR transcription")
parser.add_argument("input", help="Input audio/video file")
parser.add_argument("output", help="Output text file")
parser.add_argument("--config", help="Path to config.json (from CLAUDE_PLUGIN_DATA)")
parser.add_argument("--endpoint", default="http://workstation-4090-wsl:8002/v1/audio/transcriptions", help="ASR endpoint URL")
parser.add_argument("--model", default="Qwen/Qwen3-ASR-1.7B", help="Model name")
parser.add_argument("--noproxy", action="store_true", default=True, help="Use --noproxy with curl")
parser.add_argument("--chunk-duration", type=int, default=1080, help="Chunk duration in seconds (default: 1080 = 18min)")
parser.add_argument("--overlap", type=int, default=120, help="Overlap duration in seconds (default: 120 = 2min)")
args = parser.parse_args()
# Load config from file if provided, otherwise use CLI args
if args.config and os.path.exists(args.config):
with open(args.config) as f:
cfg = json.load(f)
args.endpoint = cfg.get("endpoint", args.endpoint)
args.model = cfg.get("model", args.model)
args.noproxy = cfg.get("noproxy", args.noproxy)
print(f"Input: {args.input}", file=sys.stderr)
total_duration = get_duration(args.input)
print(f"Duration: {total_duration:.0f}s ({total_duration/60:.1f}min)", file=sys.stderr)
with tempfile.TemporaryDirectory() as chunk_dir:
# Split
print(f"\nSplitting into {args.chunk_duration}s chunks with {args.overlap}s overlap...", file=sys.stderr)
chunks = split_audio(args.input, chunk_dir, args.chunk_duration, args.overlap)
print(f"Created {len(chunks)} chunks\n", file=sys.stderr)
# Transcribe each chunk
texts = []
for i, (start, dur, path) in enumerate(chunks):
print(f"Transcribing chunk {i} ({start//60}:{start%60:02d})...", end=" ", file=sys.stderr, flush=True)
text = transcribe(path, args.endpoint, args.model, args.noproxy)
texts.append(text)
print(f"{len(text)} chars", file=sys.stderr)
# Merge
print(f"\nMerging {len(texts)} segments...", file=sys.stderr)
merged = texts[0]
for i in range(1, len(texts)):
print(f" Merging chunk {i-1} + {i}:", file=sys.stderr)
merged = fuzzy_merge(merged, texts[i])
# Save
with open(args.output, "w", encoding="utf-8") as f:
f.write(merged)
print(f"\nDone! {len(merged)} chars saved to {args.output}", file=sys.stderr)
if __name__ == "__main__":
main()
# /// script
# requires-python = ">=3.10"
# dependencies = ["mlx-audio>=0.3.1"]
# ///
"""
Local ASR transcription using mlx-audio + Qwen3-ASR on Apple Silicon.
Usage:
uv run scripts/transcribe_local_mlx.py INPUT_AUDIO [INPUT_AUDIO2 ...] [--output-dir DIR]
CRITICAL: max_tokens defaults to 200000. The upstream mlx-audio default (8192)
silently truncates audio longer than ~40 minutes. This was discovered empirically:
123 minutes of Chinese speech requires ~24,000 tokens. 8192 only covers the first
~40 minutes before the token budget is exhausted and remaining chunks are skipped.
"""
import argparse
import os
import platform
import sys
import time
def check_platform():
if sys.platform != "darwin" or platform.machine() not in ("arm64", "aarch64"):
print("ERROR: Local MLX transcription requires macOS on Apple Silicon (M1+).", file=sys.stderr)
print("Use the remote API mode instead.", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Transcribe audio/video using local MLX Qwen3-ASR")
parser.add_argument("inputs", nargs="+", help="Audio/video file paths")
parser.add_argument("--output-dir", default=None, help="Output directory (default: same as input)")
parser.add_argument("--model", default="mlx-community/Qwen3-ASR-1.7B-8bit",
help="HuggingFace model ID (default: mlx-community/Qwen3-ASR-1.7B-8bit)")
parser.add_argument("--max-tokens", type=int, default=200000,
help="Max tokens for generation (default: 200000, covers ~3 hours of speech)")
args = parser.parse_args()
check_platform()
from mlx_audio.stt.generate import load_model
print(f"Loading model {args.model}...", file=sys.stderr, flush=True)
t0 = time.time()
model = load_model(args.model)
load_time = time.time() - t0
print(f"Model loaded in {load_time:.1f}s", file=sys.stderr, flush=True)
for audio_path in args.inputs:
if not os.path.exists(audio_path):
print(f"SKIP: {audio_path} not found", file=sys.stderr)
continue
name = os.path.splitext(os.path.basename(audio_path))[0]
out_dir = args.output_dir or os.path.dirname(audio_path) or "."
output_path = os.path.join(out_dir, f"{name}.txt")
print(f"\nTranscribing: {os.path.basename(audio_path)}", file=sys.stderr, flush=True)
t1 = time.time()
result = model.generate(audio_path, max_tokens=args.max_tokens, verbose=True)
elapsed = time.time() - t1
text = result.text if hasattr(result, "text") else str(result)
gen_tokens = result.generation_tokens if hasattr(result, "generation_tokens") else "N/A"
with open(output_path, "w", encoding="utf-8") as f:
f.write(text)
print(f"Done: {elapsed:.1f}s, {len(text)} chars, {gen_tokens} tokens → {output_path}",
file=sys.stderr, flush=True)
total = time.time() - t0
print(f"\nAll done. Total: {total:.1f}s", file=sys.stderr, flush=True)
if __name__ == "__main__":
main()