Files
theplasmak__faster-whisper/scripts/transcribe.py
T
ThePlasmak d02a385363 feat: precise word timestamps via wav2vec2 forced alignment (--precise)
- Add --precise flag for wav2vec2 forced alignment (~10ms word accuracy)
- Uses torchaudio MMS model (multilingual, 1000+ languages)
- Model cached across files for efficient batch processing
- Runs before diarization when both flags used (improves speaker assignment)
- Auto-enables word timestamps when --precise is set
- Graceful fallback: segments with unsupported chars keep original timestamps
- Install torchaudio alongside torch in setup.sh
- Update SKILL.md with --precise documentation and agent guidance
2026-02-17 18:46:23 +08:00

853 lines
28 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
faster-whisper transcription CLI
High-performance speech-to-text using CTranslate2 backend with batched inference.
Features:
- Multiple output formats: text, JSON, SRT, VTT
- URL/YouTube input via yt-dlp
- Speaker diarization (optional, requires pyannote.audio)
- Batch processing with glob patterns and directories
- Initial prompt for domain-specific terminology
- Confidence-based segment filtering
- Performance statistics
"""
import sys
import os
import json
import time
import glob
import argparse
import tempfile
import subprocess
import shutil
from pathlib import Path
try:
from faster_whisper import WhisperModel, BatchedInferencePipeline
except ImportError:
print("Error: faster-whisper not installed", file=sys.stderr)
print("Run setup: ./setup.sh", file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def check_cuda_available():
"""Check if CUDA is available and return device info."""
try:
import torch
if torch.cuda.is_available():
return True, torch.cuda.get_device_name(0)
return False, None
except ImportError:
return False, None
def format_ts_srt(seconds):
"""Format seconds as SRT timestamp: HH:MM:SS,mmm"""
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds % 1) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def format_ts_vtt(seconds):
"""Format seconds as VTT timestamp: HH:MM:SS.mmm"""
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds % 1) * 1000)
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
def format_duration(seconds):
"""Format duration as human-readable string."""
if seconds < 60:
return f"{seconds:.1f}s"
elif seconds < 3600:
m = int(seconds // 60)
s = seconds % 60
return f"{m}m{s:.0f}s"
else:
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
return f"{h}h{m}m"
def is_url(path):
"""Check if the input looks like a URL."""
return path.startswith(("http://", "https://", "www."))
# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------
def to_srt(segments):
"""Format segments as SRT subtitle content."""
lines = []
for i, seg in enumerate(segments, 1):
text = seg["text"].strip()
if seg.get("speaker"):
text = f"[{seg['speaker']}] {text}"
lines.append(str(i))
lines.append(f"{format_ts_srt(seg['start'])} --> {format_ts_srt(seg['end'])}")
lines.append(text)
lines.append("")
return "\n".join(lines)
def to_vtt(segments):
"""Format segments as WebVTT subtitle content."""
lines = ["WEBVTT", ""]
for i, seg in enumerate(segments, 1):
text = seg["text"].strip()
if seg.get("speaker"):
text = f"[{seg['speaker']}] {text}"
lines.append(str(i))
lines.append(f"{format_ts_vtt(seg['start'])} --> {format_ts_vtt(seg['end'])}")
lines.append(text)
lines.append("")
return "\n".join(lines)
def to_text(segments):
"""Format segments as plain text, with speaker labels if present."""
has_speakers = any(seg.get("speaker") for seg in segments)
if not has_speakers:
return "".join(seg["text"] for seg in segments).strip()
lines = []
current_speaker = None
for seg in segments:
sp = seg.get("speaker")
if sp and sp != current_speaker:
current_speaker = sp
lines.append(f"\n[{sp}]")
lines.append(seg["text"])
return "".join(lines).strip()
# ---------------------------------------------------------------------------
# URL download
# ---------------------------------------------------------------------------
def download_url(url, quiet=False):
"""Download audio from URL using yt-dlp. Returns (audio_path, tmpdir)."""
ytdlp = shutil.which("yt-dlp")
if not ytdlp:
pipx_path = Path.home() / ".local/share/pipx/venvs/yt-dlp/bin/yt-dlp"
if pipx_path.exists():
ytdlp = str(pipx_path)
else:
print("Error: yt-dlp not found. Install with: pipx install yt-dlp", file=sys.stderr)
sys.exit(1)
tmpdir = tempfile.mkdtemp(prefix="faster-whisper-")
out_tmpl = os.path.join(tmpdir, "audio.%(ext)s")
cmd = [ytdlp, "-x", "--audio-format", "mp3", "-o", out_tmpl, "--no-playlist"]
if quiet:
cmd.append("-q")
cmd.append(url)
if not quiet:
print("⬇️ Downloading audio from URL...", file=sys.stderr)
try:
subprocess.run(cmd, check=True, capture_output=quiet)
except subprocess.CalledProcessError as e:
print(f"Error downloading URL: {e}", file=sys.stderr)
shutil.rmtree(tmpdir, ignore_errors=True)
sys.exit(1)
files = list(Path(tmpdir).glob("audio.*"))
if not files:
print("Error: No audio file downloaded", file=sys.stderr)
shutil.rmtree(tmpdir, ignore_errors=True)
sys.exit(1)
return str(files[0]), tmpdir
# ---------------------------------------------------------------------------
# Word-level alignment (wav2vec2)
# ---------------------------------------------------------------------------
_align_cache = {} # reuse model across files in batch mode
# Characters to strip before alignment (numbers, punctuation except apostrophe)
import re
_ALIGN_CLEAN = re.compile(r"[^a-z'\u00e0-\u00ff]") # keep letters, ', accented
def run_alignment(audio_path, segments, quiet=False):
"""Refine word timestamps using wav2vec2 forced alignment (MMS model).
Tokenises each word into character-level token groups, concatenates
them, runs CTC forced alignment on the segment emission, then maps
aligned spans back to words. Falls back per-segment on failure.
"""
global _align_cache
try:
import torch
import torchaudio
except ImportError:
print(
"Error: torchaudio not installed (required for --precise).\n"
" Reinstall with: ./setup.sh",
file=sys.stderr,
)
sys.exit(1)
if not quiet:
print("🎯 Refining word timestamps (wav2vec2)...", file=sys.stderr)
# --- load / cache model ---------------------------------------------------
if "model" not in _align_cache:
bundle = torchaudio.pipelines.MMS_FA
model = bundle.get_model()
try:
if torch.cuda.is_available():
model = model.to("cuda")
_align_cache["device"] = "cuda"
else:
_align_cache["device"] = "cpu"
except Exception:
_align_cache["device"] = "cpu"
_align_cache["model"] = model
_align_cache["tokenizer"] = bundle.get_tokenizer()
_align_cache["aligner"] = bundle.get_aligner()
_align_cache["sample_rate"] = bundle.sample_rate
model = _align_cache["model"]
tokenizer = _align_cache["tokenizer"]
aligner = _align_cache["aligner"]
target_sr = _align_cache["sample_rate"]
device = _align_cache["device"]
# --- load audio -----------------------------------------------------------
waveform, sr = torchaudio.load(audio_path)
if waveform.shape[0] > 1:
waveform = waveform.mean(dim=0, keepdim=True) # stereo → mono
if sr != target_sr:
waveform = torchaudio.functional.resample(waveform, sr, target_sr)
sr = target_sr
# --- emissions (one pass over full audio) ---------------------------------
with torch.inference_mode():
emission, _ = model(waveform.to(device))
emission = emission[0].cpu() # (num_frames, num_classes)
num_samples = waveform.shape[1]
num_frames = emission.shape[0]
frame_dur = (num_samples / num_frames) / sr # seconds per emission frame
aligned_count = 0
for seg in segments:
words = seg.get("words")
if not words:
continue
# tokenise each word → list of token groups [[t], [t], ...]
word_map = [] # (index-in-words, token_groups, group_count)
all_groups = []
for i, w in enumerate(words):
raw = w["word"].strip().lower()
cleaned = _ALIGN_CLEAN.sub("", raw)
if not cleaned:
continue
try:
groups = tokenizer(cleaned) # [[t1], [t2], ...] per char
if groups:
word_map.append((i, len(groups)))
all_groups.extend(groups)
except Exception:
continue
if not all_groups:
continue
# slice emission for this segment
seg_start_frame = max(0, int(seg["start"] / frame_dur))
seg_end_frame = min(num_frames, int(seg["end"] / frame_dur))
seg_emission = emission[seg_start_frame:seg_end_frame]
if seg_emission.shape[0] < len(all_groups):
continue
try:
# aligner expects List[List[int]], returns List[List[TokenSpan]]
all_spans = aligner(seg_emission, all_groups)
except Exception:
continue
if len(all_spans) != len(all_groups):
continue
# map spans back to words by group count
grp_idx = 0
for orig_idx, count in word_map:
char_spans = all_spans[grp_idx : grp_idx + count]
grp_idx += count
# each char_spans[j] is [TokenSpan, ...] for one character
first = char_spans[0] if char_spans else []
last = char_spans[-1] if char_spans else []
if not first or not last:
continue
start_t = round((seg_start_frame + first[0].start) * frame_dur, 3)
end_t = round((seg_start_frame + last[-1].end) * frame_dur, 3)
words[orig_idx]["start"] = start_t
words[orig_idx]["end"] = end_t
aligned_count += 1
# tighten segment boundaries to aligned words
valid = [w for w in words if w.get("start") is not None]
if valid:
seg["start"] = valid[0]["start"]
seg["end"] = valid[-1]["end"]
if not quiet:
print(f" Refined {aligned_count} word timestamps", file=sys.stderr)
return segments
# ---------------------------------------------------------------------------
# Speaker diarization
# ---------------------------------------------------------------------------
def run_diarization(audio_path, segments, quiet=False):
"""Assign speaker labels to segments using pyannote.audio."""
try:
from pyannote.audio import Pipeline as PyannotePipeline
except ImportError:
print(
"Error: pyannote.audio not installed.\n"
" Install: ./setup.sh --diarize\n"
" Or: pip install pyannote.audio",
file=sys.stderr,
)
sys.exit(1)
if not quiet:
print("🔊 Running speaker diarization...", file=sys.stderr)
try:
pipeline = PyannotePipeline.from_pretrained(
"pyannote/speaker-diarization-3.1"
)
except Exception as e:
print(f"Error loading diarization model: {e}", file=sys.stderr)
print(
" Ensure you have a HuggingFace token at ~/.cache/huggingface/token\n"
" and accepted: https://hf.co/pyannote/speaker-diarization-3.1",
file=sys.stderr,
)
sys.exit(1)
# Move to GPU if available
try:
import torch
if torch.cuda.is_available():
pipeline.to(torch.device("cuda"))
except Exception:
pass
# pyannote works best with WAV; convert compressed formats to avoid
# sample-count mismatches (known issue with MP3/OGG)
diarize_path = audio_path
tmp_wav = None
if not audio_path.lower().endswith(".wav"):
tmp_wav = audio_path + ".diarize.wav"
try:
subprocess.run(
["ffmpeg", "-y", "-i", audio_path, "-ar", "16000", "-ac", "1", tmp_wav],
check=True, capture_output=True,
)
diarize_path = tmp_wav
except Exception:
# Fall back to original file if conversion fails
tmp_wav = None
try:
diarize_result = pipeline(diarize_path)
finally:
if tmp_wav and os.path.exists(tmp_wav):
os.remove(tmp_wav)
# pyannote 4.x returns DiarizeOutput with .speaker_diarization attribute;
# pyannote 3.x returns an Annotation directly
if hasattr(diarize_result, "speaker_diarization"):
annotation = diarize_result.speaker_diarization
else:
annotation = diarize_result
# Build speaker timeline
timeline = [
{"start": turn.start, "end": turn.end, "speaker": speaker}
for turn, _, speaker in annotation.itertracks(yield_label=True)
]
def speaker_at(t):
"""Find the speaker at a given timestamp by max overlap with a point."""
best, best_overlap = None, 0
for tl in timeline:
if tl["start"] <= t <= tl["end"]:
overlap = min(tl["end"], t + 0.01) - max(tl["start"], t)
if overlap > best_overlap:
best_overlap = overlap
best = tl["speaker"]
return best
# Collect all words across segments for word-level speaker assignment
all_words = []
for seg in segments:
if seg.get("words"):
all_words.extend(seg["words"])
if all_words:
# Word-level diarization: assign speaker to each word, then regroup
# into speaker-homogeneous segments
for w in all_words:
mid = (w["start"] + w["end"]) / 2
w["speaker"] = speaker_at(mid)
# Group consecutive words by speaker into new segments
new_segments = []
current_speaker = None
current_words = []
def flush_group():
if not current_words:
return
new_segments.append({
"start": current_words[0]["start"],
"end": current_words[-1]["end"],
"text": "".join(w["word"] for w in current_words),
"speaker": current_speaker,
"words": list(current_words),
})
for w in all_words:
sp = w.get("speaker")
if sp != current_speaker and current_words:
flush_group()
current_words = []
current_speaker = sp
current_words.append(w)
flush_group()
segments = new_segments
else:
# No word-level data: fall back to segment-level assignment
for seg in segments:
mid = (seg["start"] + seg["end"]) / 2
seg["speaker"] = speaker_at(mid)
# Rename to SPEAKER_1, SPEAKER_2, ... in order of appearance
seen = {}
for seg in segments:
raw = seg.get("speaker")
if raw and raw not in seen:
seen[raw] = f"SPEAKER_{len(seen) + 1}"
if raw:
seg["speaker"] = seen[raw]
if not quiet:
print(f" Found {len(seen)} speaker(s)", file=sys.stderr)
return segments, list(seen.values())
# ---------------------------------------------------------------------------
# File resolution
# ---------------------------------------------------------------------------
AUDIO_EXTS = {
".mp3", ".wav", ".m4a", ".flac", ".ogg", ".webm",
".mp4", ".mkv", ".avi", ".wma", ".aac",
}
def resolve_inputs(inputs):
"""Expand globs, directories, and URLs into a flat list of audio paths."""
files = []
for inp in inputs:
if is_url(inp):
files.append(inp)
continue
expanded = sorted(glob.glob(inp, recursive=True)) or [inp]
for p_str in expanded:
p = Path(p_str)
if p.is_dir():
files.extend(
str(f) for f in sorted(p.iterdir())
if f.is_file() and f.suffix.lower() in AUDIO_EXTS
)
elif p.is_file():
files.append(str(p))
else:
print(f"Warning: not found: {inp}", file=sys.stderr)
return files
# ---------------------------------------------------------------------------
# Core transcription
# ---------------------------------------------------------------------------
def transcribe_file(audio_path, pipeline, args):
"""Transcribe a single audio file. Returns result dict."""
t0 = time.time()
need_words = (
args.word_timestamps
or args.min_confidence is not None
or args.diarize # word-level needed for accurate speaker assignment
or args.precise # word-level needed for wav2vec2 alignment
)
kw = dict(
language=args.language,
beam_size=args.beam_size,
word_timestamps=need_words,
vad_filter=not args.no_vad,
hotwords=args.hotwords,
initial_prompt=args.initial_prompt,
)
if not args.no_batch:
kw["batch_size"] = args.batch_size
segments_iter, info = pipeline.transcribe(str(audio_path), **kw)
segments = []
full_text = ""
for seg in segments_iter:
# Confidence filter (needs word-level probabilities)
if args.min_confidence is not None and seg.words:
avg = sum(w.probability for w in seg.words) / len(seg.words)
if avg < args.min_confidence:
continue
full_text += seg.text
seg_data = {"start": seg.start, "end": seg.end, "text": seg.text}
if need_words and seg.words:
seg_data["words"] = [
{
"word": w.word,
"start": w.start,
"end": w.end,
"probability": w.probability,
}
for w in seg.words
]
segments.append(seg_data)
# Refine word timestamps with wav2vec2 (before diarization so it benefits)
if args.precise:
segments = run_alignment(str(audio_path), segments, quiet=args.quiet)
# Diarize after transcription (and alignment if --precise)
speakers = None
if args.diarize:
segments, speakers = run_diarization(
str(audio_path), segments, quiet=args.quiet
)
elapsed = time.time() - t0
dur = info.duration
rt = round(dur / elapsed, 1) if elapsed > 0 else 0
result = {
"file": Path(audio_path).name,
"text": full_text.strip(),
"language": info.language,
"language_probability": info.language_probability,
"duration": dur,
"segments": segments,
"stats": {
"processing_time": round(elapsed, 2),
"realtime_factor": rt,
},
}
if speakers:
result["speakers"] = speakers
if not args.quiet:
print(
f"✅ {result['file']}: {format_duration(dur)} in "
f"{format_duration(elapsed)} ({rt}× realtime)",
file=sys.stderr,
)
return result
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
EXT_MAP = {"text": ".txt", "json": ".json", "srt": ".srt", "vtt": ".vtt"}
def format_result(result, fmt):
"""Render a result dict in the requested format."""
if fmt == "json":
return json.dumps(result, indent=2, ensure_ascii=False)
if fmt == "srt":
return to_srt(result["segments"])
if fmt == "vtt":
return to_vtt(result["segments"])
return to_text(result["segments"])
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
p = argparse.ArgumentParser(
description="Transcribe audio with faster-whisper",
epilog=(
"examples:\n"
" %(prog)s audio.mp3\n"
" %(prog)s audio.mp3 --format srt -o subtitles.srt\n"
" %(prog)s https://youtube.com/watch?v=... --language en\n"
" %(prog)s *.mp3 --skip-existing -o ./transcripts/\n"
" %(prog)s meeting.wav --diarize --format vtt\n"
" %(prog)s lecture.mp3 --initial-prompt 'Kubernetes, gRPC'\n"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
# --- Positional ---
p.add_argument(
"audio", nargs="+", metavar="AUDIO",
help="Audio file(s), directory, glob pattern, or URL",
)
# --- Model & language ---
p.add_argument(
"-m", "--model", default="distil-large-v3.5",
help="Whisper model (default: distil-large-v3.5)",
)
p.add_argument(
"-l", "--language", default=None,
help="Language code, e.g. en, es, fr (auto-detects if omitted)",
)
p.add_argument(
"--initial-prompt", default=None, metavar="TEXT",
help="Prompt to condition the model (terminology, formatting hints)",
)
p.add_argument(
"--hotwords", default=None, metavar="WORDS",
help="Hotwords to boost recognition (space-separated)",
)
# --- Output format ---
p.add_argument(
"-f", "--format", default="text",
choices=["text", "json", "srt", "vtt"],
help="Output format (default: text)",
)
p.add_argument(
"--word-timestamps", action="store_true",
help="Include word-level timestamps (auto-enabled for --diarize)",
)
p.add_argument(
"-o", "--output", default=None, metavar="PATH",
help="Output file or directory (directory for batch mode)",
)
# --- Inference tuning ---
p.add_argument(
"--beam-size", type=int, default=5, metavar="N",
help="Beam search size (default: 5)",
)
p.add_argument(
"--batch-size", type=int, default=8, metavar="N",
help="Batch size for batched inference (default: 8; reduce if OOM)",
)
p.add_argument("--no-vad", action="store_true",
help="Disable voice activity detection")
p.add_argument("--no-batch", action="store_true",
help="Disable batched inference (use standard WhisperModel)")
# --- Advanced features ---
p.add_argument(
"--precise", action="store_true",
help="Refine word timestamps with wav2vec2 forced alignment (~10ms accuracy)",
)
p.add_argument(
"--diarize", action="store_true",
help="Speaker diarization (requires pyannote.audio; install via setup.sh --diarize)",
)
p.add_argument(
"--min-confidence", type=float, default=None, metavar="PROB",
help="Drop segments below this avg word confidence (0.0–1.0)",
)
p.add_argument(
"--skip-existing", action="store_true",
help="Skip files whose output already exists (batch mode)",
)
# --- Device ---
p.add_argument(
"--device", default="auto", choices=["auto", "cpu", "cuda"],
help="Compute device (default: auto)",
)
p.add_argument(
"--compute-type", default="auto",
choices=["auto", "int8", "float16", "float32"],
help="Quantization (default: auto)",
)
p.add_argument(
"-q", "--quiet", action="store_true",
help="Suppress progress messages",
)
# --- Backward compat (hidden) ---
p.add_argument("-j", "--json", action="store_true", help=argparse.SUPPRESS)
p.add_argument("--vad", action="store_true", help=argparse.SUPPRESS)
args = p.parse_args()
if args.json:
args.format = "json"
# ---- Resolve inputs ----
temp_dirs = []
audio_files = []
for inp in args.audio:
if is_url(inp):
path, td = download_url(inp, quiet=args.quiet)
audio_files.append(path)
temp_dirs.append(td)
else:
audio_files.extend(resolve_inputs([inp]))
if not audio_files:
print("Error: No audio files found", file=sys.stderr)
sys.exit(1)
is_batch = len(audio_files) > 1
# ---- Device setup ----
device = args.device
compute_type = args.compute_type
cuda_ok, gpu_name = check_cuda_available()
if device == "auto":
device = "cuda" if cuda_ok else "cpu"
if device == "cpu" and not args.quiet:
print("⚠️ CUDA not available — using CPU (this will be slow!)", file=sys.stderr)
print(" To enable GPU: pip install torch --index-url https://download.pytorch.org/whl/cu121", file=sys.stderr)
if compute_type == "auto":
compute_type = "float16" if device == "cuda" else "int8"
use_batched = not args.no_batch
if not args.quiet:
mode = f"batched (bs={args.batch_size})" if use_batched else "standard"
gpu_str = f" on {gpu_name}" if device == "cuda" and gpu_name else ""
print(f"🎙️ {args.model} ({device}/{compute_type}){gpu_str} [{mode}]", file=sys.stderr)
if is_batch:
print(f"📁 {len(audio_files)} files queued", file=sys.stderr)
# ---- Load model ----
try:
model = WhisperModel(args.model, device=device, compute_type=compute_type)
pipe = BatchedInferencePipeline(model) if use_batched else model
except Exception as e:
print(f"Error loading model: {e}", file=sys.stderr)
sys.exit(1)
# ---- Transcribe ----
results = []
total_audio = 0
wall_start = time.time()
for audio_path in audio_files:
name = Path(audio_path).name
# Skip-existing check
if args.skip_existing and args.output:
out_dir = Path(args.output)
if out_dir.is_dir():
target = out_dir / (Path(audio_path).stem + EXT_MAP.get(args.format, ".txt"))
if target.exists():
if not args.quiet:
print(f"⏭️ Skip (exists): {name}", file=sys.stderr)
continue
if not args.quiet and is_batch:
print(f"▶️ {name}", file=sys.stderr)
try:
r = transcribe_file(audio_path, pipe, args)
results.append(r)
total_audio += r["duration"]
except Exception as e:
print(f"Error: {name}: {e}", file=sys.stderr)
if not is_batch:
sys.exit(1)
# Cleanup temp dirs
for td in temp_dirs:
shutil.rmtree(td, ignore_errors=True)
if not results:
if args.skip_existing:
if not args.quiet:
print("All files already transcribed (--skip-existing)", file=sys.stderr)
sys.exit(0)
print("Error: No files transcribed", file=sys.stderr)
sys.exit(1)
# ---- Write output ----
for r in results:
output = format_result(r, args.format)
if args.output:
out_path = Path(args.output)
if out_path.is_dir() or (is_batch and not out_path.suffix):
out_path.mkdir(parents=True, exist_ok=True)
dest = out_path / (Path(r["file"]).stem + EXT_MAP.get(args.format, ".txt"))
else:
dest = out_path
dest.write_text(output, encoding="utf-8")
if not args.quiet:
print(f"💾 {dest}", file=sys.stderr)
else:
if is_batch and args.format == "text":
print(f"\n=== {r['file']} ===")
print(output)
# Batch summary
if is_batch and not args.quiet:
wall = time.time() - wall_start
rt = total_audio / wall if wall > 0 else 0
print(
f"\n📊 Done: {len(results)} files, {format_duration(total_audio)} audio "
f"in {format_duration(wall)} ({rt:.1f}× realtime)",
file=sys.stderr,
)
if __name__ == "__main__":
main()