Buckets:
| # /// script | |
| # requires-python = ">=3.10" | |
| # dependencies = [ | |
| # "requests", | |
| # "librosa", | |
| # "soundfile", | |
| # ] | |
| # /// | |
| """ | |
| High-throughput transcription + diarization via an in-job sgl-omni server. | |
| Same model and outputs as moss-transcribe-diarize.py, but serves | |
| MOSS-Transcribe-Diarize behind sglang-omni inside the job and posts files | |
| concurrently — continuous batching decodes many tapes at once (measured | |
| 37.8x realtime aggregate at concurrency 4 on a100-large including server | |
| boot, vs 3.2x for the sequential transformers recipe). | |
| This script is the *driver* half: it expects the server on localhost | |
| (started by the job command below), splits long audio to fit the model's | |
| context window, posts files concurrently, and writes JSON (+ .txt). | |
| Requires a GPU with enough KV-cache room for long audio — use a100-large | |
| (80 GB). 24 GB cards (l4x1, a10g) OOM on tapes over ~30 min. | |
| Run on HF Jobs (single command — installs sglang-omni per upstream docs, | |
| starts the server, then runs this driver against it): | |
| hf jobs run --detach --flavor a100-large -s HF_TOKEN --timeout 8h \\ | |
| -v hf://buckets/user/audio-files:/input:ro \\ | |
| -v hf://buckets/user/transcripts:/output \\ | |
| lmsysorg/sglang:nightly-dev-cu13-20260709-074bb928 -- \\ | |
| bash -c "pip install -q uv; git clone --depth 1 https://github.com/sgl-project/sglang-omni.git && cd sglang-omni && uv venv .venv -p 3.12 && . .venv/bin/activate && uv pip install . && (sgl-omni serve --model-path OpenMOSS-Team/MOSS-Transcribe-Diarize --host 0.0.0.0 --port 8000 --max-running-requests 16 --mem-fraction-static 0.80 &) && uv run https://huggingface.co/datasets/uv-scripts/transcription/raw/main/moss-transcribe-diarize-server.py /input /output --emit-txt" | |
| Input: Output: | |
| /input/tape1.mp3 -> /output/tape1.json (segments; parts merged) | |
| /input/sub/tape2.mp3 -> /output/sub/tape2.json | |
| Model: OpenMOSS-Team/MOSS-Transcribe-Diarize (0.9B, Apache 2.0) | |
| - Long tapes are split into clips that fit the model's context window; | |
| speaker labels are consistent WITHIN a clip but reset BETWEEN clips | |
| (clip index is recorded on every segment as "part"). | |
| - The model occasionally stops generating early (EOS mid-tape); the driver | |
| detects short coverage and automatically continues from the last | |
| timestamp. Compare coverage_s vs duration_s in the output JSON. | |
| """ | |
| import argparse | |
| import concurrent.futures | |
| import json | |
| import logging | |
| import re | |
| import sys | |
| import tempfile | |
| import time | |
| from pathlib import Path | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") | |
| logger = logging.getLogger(__name__) | |
| MODEL = "OpenMOSS-Team/MOSS-Transcribe-Diarize" | |
| AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".wma", ".aac", ".opus"} | |
| SAMPLE_RATE = 16000 | |
| # Context budget. The model's window is 131072 tokens and the server | |
| # reserves max_new_tokens from it, so input audio gets | |
| # (131072 - max_new_tokens - margin) tokens. Audio tokenizes at ~17.5 | |
| # tokens/sec (30s Whisper chunks -> temporal merge), so requesting 65536 | |
| # output tokens silently truncates input past ~62 min. We split audio into | |
| # parts that fit alongside a generation budget sized to the part. | |
| CONTEXT_TOKENS = 131072 | |
| AUDIO_TOKENS_PER_SECOND = 17.5 | |
| CONTEXT_MARGIN_TOKENS = 2048 | |
| GENERATION_TOKENS_PER_AUDIO_SECOND = 20 # same over-provision as sibling script | |
| MIN_GENERATION_TOKENS = 5120 | |
| # Longest part where input + generation + margin fits the window: | |
| # d*17.5 + max(5120, d*20) + 2048 <= 131072 -> d ~ 3440s. Stay under it. | |
| DEFAULT_PART_SECONDS = 3300 # 55 min | |
| def generation_budget(duration_s: float) -> int: | |
| """Output-token budget for a clip, capped so input still fits the window.""" | |
| want = max( | |
| MIN_GENERATION_TOKENS, int(duration_s * GENERATION_TOKENS_PER_AUDIO_SECOND) | |
| ) | |
| room = ( | |
| CONTEXT_TOKENS | |
| - CONTEXT_MARGIN_TOKENS | |
| - int(duration_s * AUDIO_TOKENS_PER_SECOND) | |
| ) | |
| return max(MIN_GENERATION_TOKENS, min(want, room)) | |
| def discover_audio_files(input_dir: Path) -> list[Path]: | |
| return [ | |
| p | |
| for p in sorted(input_dir.rglob("*")) | |
| if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS | |
| ] | |
| def transcribe_clip(server: str, clip: Path, duration_s: float, timeout_s: int) -> dict: | |
| import requests | |
| with open(clip, "rb") as f: | |
| resp = requests.post( | |
| f"{server}/v1/audio/transcriptions", | |
| data={ | |
| "model": MODEL, | |
| "response_format": "verbose_json", | |
| "max_new_tokens": generation_budget(duration_s), | |
| }, | |
| files={"file": (clip.name, f)}, | |
| timeout=timeout_s, | |
| ) | |
| resp.raise_for_status() | |
| return resp.json() | |
| SPEAKER_RE = re.compile(r"^\[(S\d+)\]\s*") | |
| def parse_verbose_segments( | |
| payload: dict, offset_s: float, part_index: int | |
| ) -> list[dict]: | |
| """Normalize sgl-omni verbose_json segments; shift by part offset.""" | |
| out = [] | |
| for seg in payload.get("segments", []): | |
| m = SPEAKER_RE.match(seg["text"]) | |
| out.append( | |
| { | |
| "start": round(seg["start"] + offset_s, 2), | |
| "end": round(seg["end"] + offset_s, 2), | |
| "speaker": m.group(1) if m else None, | |
| "text": SPEAKER_RE.sub("", seg["text"]).strip(), | |
| "part": part_index, | |
| } | |
| ) | |
| return out | |
| def write_txt(segments: list[dict], path: Path): | |
| lines = [ | |
| f"[{s['start']:.2f} - {s['end']:.2f}] {s['speaker'] or '?'}: {s['text']}" | |
| for s in segments | |
| ] | |
| path.write_text("\n".join(lines) + "\n", encoding="utf-8") | |
| def wait_for_server(server: str, timeout_s: int = 1800): | |
| import requests | |
| logger.info(f"Waiting for server at {server}...") | |
| deadline = time.time() + timeout_s | |
| while time.time() < deadline: | |
| try: | |
| if requests.get(f"{server}/health", timeout=5).status_code == 200: | |
| logger.info("Server is ready") | |
| return | |
| except requests.RequestException: | |
| pass | |
| time.sleep(10) | |
| logger.error(f"Server did not become ready within {timeout_s}s") | |
| sys.exit(1) | |
| # A clip's transcript should reach near its end. If generation stops more | |
| # than this many seconds short (the model sometimes emits EOS early — and | |
| # always does past ~62 min of input), transcribe the remainder as a new clip. | |
| COVERAGE_SLACK_S = 240 | |
| MIN_CONTINUATION_PROGRESS_S = 60 | |
| def process_file( | |
| file_path: Path, | |
| input_dir: Path, | |
| output_dir: Path, | |
| server: str, | |
| part_seconds: int, | |
| request_timeout: int, | |
| emit_txt: bool, | |
| workdir: Path, | |
| ) -> dict: | |
| import librosa | |
| import soundfile | |
| rel = file_path.relative_to(input_dir) | |
| start_time = time.time() | |
| audio, _ = librosa.load(str(file_path), sr=SAMPLE_RATE, mono=True) | |
| duration = len(audio) / SAMPLE_RATE | |
| # Work queue of (offset_s) positions still needing transcription. Each | |
| # round transcribes up to part_seconds from the offset; if the model | |
| # stops early (early EOS or the serve stack's ~62-min input cap), the | |
| # uncovered remainder is re-queued as a continuation. Speaker labels are | |
| # consistent within a clip and reset between clips ("part" index). | |
| segments: list[dict] = [] | |
| offset = 0.0 | |
| part_index = 0 | |
| while duration - offset > COVERAGE_SLACK_S / 2: | |
| clip_dur = min(part_seconds, duration - offset) | |
| if offset == 0.0 and clip_dur >= duration: | |
| clip_path = file_path # single-clip file: send as-is | |
| else: | |
| clip_path = workdir / f"{file_path.stem}.part{part_index:02d}.wav" | |
| lo = int(offset * SAMPLE_RATE) | |
| hi = int((offset + clip_dur) * SAMPLE_RATE) | |
| soundfile.write(str(clip_path), audio[lo:hi], SAMPLE_RATE) | |
| payload = transcribe_clip(server, clip_path, clip_dur, request_timeout) | |
| if clip_path != file_path: | |
| clip_path.unlink(missing_ok=True) | |
| clip_segments = parse_verbose_segments(payload, offset, part_index) | |
| segments.extend(clip_segments) | |
| covered = ( | |
| (max(s["end"] for s in clip_segments) - offset) if clip_segments else 0.0 | |
| ) | |
| part_index += 1 | |
| if clip_dur - covered <= COVERAGE_SLACK_S: | |
| offset += clip_dur # clip fully covered — next part | |
| elif covered >= MIN_CONTINUATION_PROGRESS_S: | |
| logger.info( | |
| f" {rel}: early stop at {offset + covered:.0f}s of " | |
| f"{offset + clip_dur:.0f}s — continuing from there" | |
| ) | |
| offset += covered | |
| else: | |
| # No meaningful progress (e.g. trailing static) — skip this clip | |
| # to avoid looping; the gap is visible in the coverage stats. | |
| logger.warning( | |
| f" {rel}: no progress on clip at {offset:.0f}s " | |
| f"({len(clip_segments)} segments) — skipping {clip_dur:.0f}s" | |
| ) | |
| offset += clip_dur | |
| coverage_s = round(max((s["end"] for s in segments), default=0.0), 1) | |
| speakers_per_part = { | |
| p: sorted({s["speaker"] for s in segments if s["part"] == p and s["speaker"]}) | |
| for p in sorted({s["part"] for s in segments}) | |
| } | |
| record = { | |
| "file": str(rel), | |
| "model": MODEL, | |
| "duration_s": round(duration, 1), | |
| "coverage_s": coverage_s, | |
| "num_parts": part_index, | |
| "segments": segments, | |
| "num_segments": len(segments), | |
| "speakers_per_part": speakers_per_part, | |
| } | |
| json_path = output_dir / rel.with_suffix(".json") | |
| json_path.parent.mkdir(parents=True, exist_ok=True) | |
| json_path.write_text( | |
| json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8" | |
| ) | |
| if emit_txt: | |
| write_txt(segments, json_path.with_suffix(".txt")) | |
| elapsed = time.time() - start_time | |
| logger.info( | |
| f" {rel}: {duration / 60:.0f} min, {part_index} clip(s), " | |
| f"{len(segments)} segments, coverage {coverage_s / max(duration, 1) * 100:.0f}% " | |
| f"in {elapsed:.0f}s" | |
| ) | |
| return { | |
| "file": str(rel), | |
| "duration_s": round(duration, 1), | |
| "coverage_s": coverage_s, | |
| "num_parts": part_index, | |
| "num_segments": len(segments), | |
| "elapsed_s": round(elapsed, 1), | |
| } | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Concurrent transcription + diarization via in-job sgl-omni server.", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog="See module docstring for the full `hf jobs run` command.", | |
| ) | |
| parser.add_argument("input_dir", help="Directory containing audio files") | |
| parser.add_argument("output_dir", help="Directory to write transcript JSON files") | |
| parser.add_argument( | |
| "--server", | |
| default="http://127.0.0.1:8000", | |
| help="sgl-omni server base URL (default: in-job localhost:8000)", | |
| ) | |
| parser.add_argument( | |
| "--concurrency", | |
| type=int, | |
| default=4, | |
| help="Concurrent transcription requests (default: 4; KV-cache bound)", | |
| ) | |
| parser.add_argument( | |
| "--part-minutes", | |
| type=float, | |
| default=DEFAULT_PART_SECONDS / 60, | |
| help="Split audio into parts of this length (default: 55 min — the " | |
| "longest that fits the model's context window with generation room). " | |
| "Speaker labels reset between parts.", | |
| ) | |
| parser.add_argument( | |
| "--request-timeout", | |
| type=int, | |
| default=3600, | |
| help="Per-request timeout in seconds (default: 3600)", | |
| ) | |
| parser.add_argument( | |
| "--emit-txt", action="store_true", help="Also write .txt transcripts" | |
| ) | |
| parser.add_argument( | |
| "--max-files", type=int, default=None, help="Limit files (for testing)" | |
| ) | |
| args = parser.parse_args() | |
| input_dir = Path(args.input_dir) | |
| output_dir = Path(args.output_dir) | |
| if not input_dir.is_dir(): | |
| logger.error(f"Input directory does not exist: {input_dir}") | |
| sys.exit(1) | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| files = discover_audio_files(input_dir) | |
| if not files: | |
| logger.error(f"No audio files found in {input_dir}") | |
| sys.exit(1) | |
| if args.max_files: | |
| files = files[: args.max_files] | |
| # Resume: outputs are written per file, so a rerun after a timeout or | |
| # crash only processes what's missing. | |
| done = { | |
| f | |
| for f in files | |
| if (output_dir / f.relative_to(input_dir).with_suffix(".json")).exists() | |
| } | |
| if done: | |
| logger.info(f"Skipping {len(done)} file(s) with existing output JSON") | |
| files = [f for f in files if f not in done] | |
| if not files: | |
| logger.info("Nothing to do — all outputs exist") | |
| return | |
| logger.info(f"Found {len(files)} audio file(s) to process") | |
| wait_for_server(args.server) | |
| part_seconds = int(args.part_minutes * 60) | |
| start_time = time.time() | |
| results = [] | |
| with tempfile.TemporaryDirectory() as tmp: | |
| workdir = Path(tmp) | |
| with concurrent.futures.ThreadPoolExecutor( | |
| max_workers=args.concurrency | |
| ) as pool: | |
| futures = { | |
| pool.submit( | |
| process_file, | |
| f, | |
| input_dir, | |
| output_dir, | |
| args.server, | |
| part_seconds, | |
| args.request_timeout, | |
| args.emit_txt, | |
| workdir, | |
| ): f | |
| for f in files | |
| } | |
| for fut in concurrent.futures.as_completed(futures): | |
| f = futures[fut] | |
| try: | |
| results.append(fut.result()) | |
| except Exception as exc: | |
| logger.error(f" {f.name} FAILED: {exc}") | |
| results.append({"file": f.name, "error": str(exc)}) | |
| elapsed = time.time() - start_time | |
| summary_path = output_dir / "summary.jsonl" | |
| with open(summary_path, "w", encoding="utf-8") as f: | |
| for r in results: | |
| f.write(json.dumps(r, ensure_ascii=False) + "\n") | |
| ok = [r for r in results if "error" not in r] | |
| total_audio = sum(r["duration_s"] for r in ok) | |
| elapsed_str = f"{elapsed / 60:.1f} min" if elapsed > 60 else f"{elapsed:.1f}s" | |
| logger.info("=" * 50) | |
| logger.info(f"Done! {len(ok)}/{len(files)} file(s) in {elapsed_str}") | |
| if total_audio: | |
| logger.info(f" Audio: {total_audio / 3600:.1f} h total") | |
| logger.info(f" RTFx: {total_audio / elapsed:.1f}x realtime (aggregate)") | |
| if len(ok) < len(files): | |
| logger.warning(f" Failed: {len(files) - len(ok)} file(s) — see {summary_path}") | |
| logger.info(f" Summary: {summary_path}") | |
| if __name__ == "__main__": | |
| if len(sys.argv) == 1: | |
| print("=" * 60) | |
| print("Concurrent Transcription + Diarization (sgl-omni server)") | |
| print("=" * 60) | |
| print("\nDriver for an in-job sgl-omni server: splits long audio to") | |
| print("fit the model's context window, posts files concurrently,") | |
| print("writes JSON segments with timestamps + speaker labels.") | |
| print("\nSee module docstring for the full `hf jobs run` command") | |
| print("(server + driver in one job, a100-large recommended).") | |
| print("\nFor full help: uv run moss-transcribe-diarize-server.py --help") | |
| sys.exit(0) | |
| main() | |
Xet Storage Details
- Size:
- 15.5 kB
- Xet hash:
- 99b06948887607b634f0a1ffc3316ba5640d50a5ca885e0910248555538b9d28
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.