Ajay commited on
Commit
e630855
·
0 Parent(s):

VoxSplit POC

Browse files
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .env
6
+ uploads/
7
+ output/
8
+ .git/
9
+ .DS_Store
10
+ agent-tools/
.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Copy this file to .env and fill in your Sarvam AI subscription key.
2
+ # Get one at https://dashboard.sarvam.ai
3
+ SARVAM_API_KEY=your_sarvam_api_key_here
4
+
5
+ # Expected number of speakers passed to Sarvam diarization (1-8).
6
+ NUM_SPEAKERS=2
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .env
6
+ uploads/
7
+ output/
8
+ .DS_Store
DEPLOY.md ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying the POC
2
+
3
+ The app needs PyTorch + a ~360 MB model, so pick a host with **≥ 1–2 GB RAM**
4
+ (the smallest free tiers like Render's 512 MB will OOM). A `Dockerfile` is
5
+ included and works on any container host.
6
+
7
+ Set your Sarvam key as a **secret env var** on the host — never commit `.env`:
8
+
9
+ ```
10
+ SARVAM_API_KEY=your_key
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Option A — Hugging Face Spaces (recommended, free, persistent)
16
+
17
+ Free CPU Spaces give 16 GB RAM and a stable public URL like
18
+ `https://<user>-voxsplit.hf.space` — ideal for an ML demo.
19
+
20
+ 1. Create a Space at https://huggingface.co/new-space → **SDK: Docker** → Blank.
21
+ 2. Push this folder to the Space's git repo (or upload files in the UI):
22
+ ```bash
23
+ git init && git add . && git commit -m "voxsplit poc"
24
+ git remote add space https://huggingface.co/spaces/<user>/voxsplit
25
+ git push space main
26
+ ```
27
+ 3. Space → **Settings → Variables and secrets** → add secret `SARVAM_API_KEY`.
28
+ 4. It builds the Dockerfile and serves on port 7860 automatically. Share the URL.
29
+
30
+ > The Dockerfile pre-downloads the gender model during build, so the first
31
+ > request is fast.
32
+
33
+ ## Option B — Render (Docker web service)
34
+
35
+ 1. Push this repo to GitHub.
36
+ 2. Render → **New → Web Service** → connect repo → it detects the `Dockerfile`.
37
+ 3. Instance type: pick one with **≥ 2 GB RAM** (Starter/Standard, not Free).
38
+ 4. Add env var `SARVAM_API_KEY`. Render injects `PORT`; the container already
39
+ honors it. Deploy and share the `*.onrender.com` URL.
40
+
41
+ ## Option C — Fly.io (Docker, good for long requests)
42
+
43
+ ```bash
44
+ fly launch --no-deploy # generates fly.toml from the Dockerfile
45
+ fly secrets set SARVAM_API_KEY=your_key
46
+ fly scale memory 2048 # give it 2 GB
47
+ fly deploy
48
+ ```
49
+
50
+ ## Option D — Instant link, zero deploy (temporary)
51
+
52
+ Fastest way to show a client *right now*, while your server runs locally:
53
+
54
+ ```bash
55
+ # terminal 1: your app is already running on :8000
56
+ # terminal 2:
57
+ brew install cloudflared
58
+ cloudflared tunnel --url http://localhost:8000
59
+ ```
60
+
61
+ This prints a public `https://*.trycloudflare.com` link. Downsides: the link is
62
+ temporary and your machine must stay on. (`ngrok http 8000` works the same way.)
63
+
64
+ ---
65
+
66
+ ## Heads-up: long transcription jobs
67
+
68
+ The `/api/transcribe` request blocks until Sarvam's **batch** job finishes, which
69
+ can take a while for long audio. Some platform proxies cut idle HTTP requests at
70
+ ~60–100s. For a smooth client demo, **use short clips** (≤ ~1–2 min). If you need
71
+ long files in production, the next step is to make transcription async (return a
72
+ job id + poll for status) — ask and I'll wire that up.
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1 \
4
+ PIP_NO_CACHE_DIR=1 \
5
+ HF_HOME=/app/.cache/huggingface \
6
+ UPLOAD_DIR=/tmp/uploads \
7
+ PORT=7860
8
+
9
+ WORKDIR /app
10
+
11
+ # System libs needed by soundfile / librosa for audio decoding.
12
+ RUN apt-get update && apt-get install -y --no-install-recommends \
13
+ libsndfile1 ffmpeg \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ COPY requirements.txt .
17
+ RUN pip install -r requirements.txt
18
+
19
+ COPY backend ./backend
20
+ COPY frontend ./frontend
21
+
22
+ # Pre-download the gender model so the first request isn't slow.
23
+ RUN python -c "from transformers import Wav2Vec2ForSequenceClassification, Wav2Vec2FeatureExtractor as F; M='prithivMLmods/Common-Voice-Gender-Detection'; Wav2Vec2ForSequenceClassification.from_pretrained(M); F.from_pretrained(M)"
24
+
25
+ # Make caches/uploads writable for non-root hosts (e.g. HF Spaces runs as uid 1000).
26
+ RUN mkdir -p /tmp/uploads "$HF_HOME" && chmod -R 777 /app/.cache /tmp/uploads
27
+
28
+ EXPOSE 7860
29
+
30
+ CMD ["sh", "-c", "uvicorn backend.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
README.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: VoxSplit
3
+ emoji: 🎙️
4
+ colorFrom: purple
5
+ colorTo: pink
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Audio Transcription & Gender Detection (POC)
12
+
13
+ Upload a `.wav` file and get a **diarized, timestamped transcript** (via the
14
+ [Sarvam AI](https://sarvam.ai) `saaras:v3` batch API) plus a **per-speaker
15
+ gender estimate**. The transcript is synced to audio playback — the active
16
+ segment highlights as it plays, and clicking a segment seeks to it.
17
+
18
+ ## How it works
19
+
20
+ 1. The WAV is uploaded to a small FastAPI backend.
21
+ 2. The backend runs a Sarvam **batch STT job** with `with_diarization=True`,
22
+ which returns speaker-labelled segments with start/end timestamps.
23
+ 3. For each speaker, the backend pools all of their audio and runs the
24
+ [`prithivMLmods/Common-Voice-Gender-Detection`](https://huggingface.co/prithivMLmods/Common-Voice-Gender-Detection)
25
+ wav2vec2 classifier, returning softmax **female / male** probabilities. Below
26
+ a 0.6 confidence floor (or with too little audio) the speaker is marked
27
+ **uncertain**.
28
+ 4. The frontend renders the audio player, a speaker legend, and the synced
29
+ transcript.
30
+
31
+ > The gender model is downloaded from HuggingFace on first run (~360 MB) and
32
+ > cached. It's a trained classifier (~98% reported accuracy) but can still err
33
+ > on children, atypical voices, or noisy/short audio.
34
+
35
+ ## Setup
36
+
37
+ ```bash
38
+ cd audio-gender-detection
39
+ python3 -m venv .venv
40
+ source .venv/bin/activate
41
+ pip install -r requirements.txt
42
+
43
+ cp .env.example .env # then add your Sarvam API key
44
+ ```
45
+
46
+ `.env`:
47
+
48
+ ```
49
+ SARVAM_API_KEY=your_sarvam_api_key_here
50
+ NUM_SPEAKERS=2
51
+ ```
52
+
53
+ (You can also paste the key directly into the UI instead of using `.env`.)
54
+
55
+ ## Run
56
+
57
+ ```bash
58
+ uvicorn backend.main:app --reload --port 8000
59
+ ```
60
+
61
+ Open http://localhost:8000
62
+
63
+ ## Notes
64
+
65
+ - Diarization is **only** available through Sarvam's Batch API, so processing is
66
+ asynchronous — longer files take longer.
67
+ - Uploaded files land in `uploads/` (gitignored). Clean it up periodically.
68
+ - `librosa`/`soundfile` need a working audio backend; on macOS these install
69
+ cleanly via pip. On Linux you may need `libsndfile1` (`apt install libsndfile1`).
backend/__init__.py ADDED
File without changes
backend/gender.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gender detection using a fine-tuned wav2vec2 classifier.
2
+
3
+ Model: prithivMLmods/Common-Voice-Gender-Detection
4
+ https://huggingface.co/prithivMLmods/Common-Voice-Gender-Detection
5
+ A `facebook/wav2vec2-base-960h` model fine-tuned for binary (female/male)
6
+ speaker-gender classification. We feed it a 16 kHz mono waveform and read the
7
+ softmax probabilities. If the top probability is below a confidence floor (or
8
+ there isn't enough audio) we report "uncertain" instead of guessing.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from functools import lru_cache
15
+
16
+ import numpy as np
17
+ import torch
18
+ from transformers import Wav2Vec2FeatureExtractor, Wav2Vec2ForSequenceClassification
19
+
20
+ MODEL_NAME = "prithivMLmods/Common-Voice-Gender-Detection"
21
+ TARGET_SR = 16000
22
+ MIN_SAMPLES = int(0.3 * TARGET_SR) # need ~0.3s of audio to bother
23
+ CONFIDENCE_FLOOR = 0.6 # below this we say "uncertain"
24
+
25
+
26
+ @dataclass
27
+ class GenderResult:
28
+ label: str # "male" | "female" | "uncertain"
29
+ confidence: float # top-class softmax probability (0..1)
30
+ probs: dict = field(default_factory=dict) # {"male": p, "female": p}
31
+ audio_seconds: float = 0.0
32
+
33
+
34
+ @lru_cache(maxsize=1)
35
+ def _load():
36
+ """Load (and cache) the model + feature extractor once per process."""
37
+ model = Wav2Vec2ForSequenceClassification.from_pretrained(MODEL_NAME)
38
+ extractor = Wav2Vec2FeatureExtractor.from_pretrained(MODEL_NAME)
39
+ model.eval()
40
+ id2label = {int(k): str(v).lower() for k, v in model.config.id2label.items()}
41
+ return model, extractor, id2label
42
+
43
+
44
+ def warmup() -> None:
45
+ """Eagerly load the model (e.g. at server startup) to avoid a cold first request."""
46
+ _load()
47
+
48
+
49
+ def classify(samples: np.ndarray, sr: int) -> GenderResult:
50
+ """Classify gender from a mono waveform (float32)."""
51
+ audio_seconds = float(len(samples) / sr) if sr else 0.0
52
+
53
+ if samples is None or samples.size < MIN_SAMPLES:
54
+ return GenderResult("uncertain", 0.0, {}, round(audio_seconds, 2))
55
+
56
+ samples = np.asarray(samples, dtype=np.float32)
57
+ if sr != TARGET_SR:
58
+ import librosa
59
+
60
+ samples = librosa.resample(samples, orig_sr=sr, target_sr=TARGET_SR)
61
+ sr = TARGET_SR
62
+
63
+ model, extractor, id2label = _load()
64
+
65
+ inputs = extractor(samples, sampling_rate=sr, return_tensors="pt", padding=True)
66
+ with torch.no_grad():
67
+ logits = model(**inputs).logits
68
+ probs = torch.softmax(logits, dim=1).squeeze(0).tolist()
69
+
70
+ prob_map = {id2label[i]: float(probs[i]) for i in range(len(probs))}
71
+ label = max(prob_map, key=prob_map.get)
72
+ confidence = prob_map[label]
73
+ if confidence < CONFIDENCE_FLOOR:
74
+ label = "uncertain"
75
+
76
+ return GenderResult(
77
+ label=label,
78
+ confidence=round(confidence, 3),
79
+ probs={k: round(v, 3) for k, v in prob_map.items()},
80
+ audio_seconds=round(audio_seconds, 2),
81
+ )
backend/main.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI app: upload a WAV, get diarized transcription + per-speaker gender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from collections import defaultdict
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import librosa
12
+ import numpy as np
13
+ import soundfile as sf
14
+ from dotenv import load_dotenv
15
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
16
+ from fastapi.responses import FileResponse, HTMLResponse
17
+ from fastapi.staticfiles import StaticFiles
18
+
19
+ from . import gender
20
+ from .transcribe import Transcription, transcribe_with_diarization
21
+
22
+ load_dotenv()
23
+
24
+ BASE_DIR = Path(__file__).resolve().parent.parent
25
+ UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR") or (BASE_DIR / "uploads"))
26
+ FRONTEND_DIR = BASE_DIR / "frontend"
27
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
28
+
29
+ app = FastAPI(title="Audio Gender + Transcription POC")
30
+
31
+
32
+ @app.on_event("startup")
33
+ def _warmup_model() -> None:
34
+ # Load the gender model ahead of the first request (best-effort).
35
+ try:
36
+ gender.warmup()
37
+ except Exception:
38
+ pass
39
+
40
+
41
+ @app.get("/", response_class=HTMLResponse)
42
+ def index() -> HTMLResponse:
43
+ return HTMLResponse((FRONTEND_DIR / "index.html").read_text(encoding="utf-8"))
44
+
45
+
46
+ @app.get("/api/audio/{file_id}")
47
+ def get_audio(file_id: str) -> FileResponse:
48
+ # Guard against path traversal; we only ever serve from UPLOAD_DIR.
49
+ safe_id = Path(file_id).name
50
+ path = UPLOAD_DIR / safe_id
51
+ if not path.exists():
52
+ raise HTTPException(status_code=404, detail="Audio not found")
53
+ return FileResponse(path, media_type="audio/wav")
54
+
55
+
56
+ @app.post("/api/transcribe")
57
+ async def transcribe(
58
+ file: UploadFile = File(...),
59
+ api_key: Optional[str] = Form(default=None),
60
+ num_speakers: int = Form(default=int(os.getenv("NUM_SPEAKERS", "2"))),
61
+ ):
62
+ key = (api_key or os.getenv("SARVAM_API_KEY") or "").strip()
63
+ if not key:
64
+ raise HTTPException(
65
+ status_code=400,
66
+ detail="Server is missing the SARVAM_API_KEY. Set it in the .env file and restart.",
67
+ )
68
+
69
+ file_id = f"{uuid.uuid4().hex}.wav"
70
+ dest = UPLOAD_DIR / file_id
71
+ dest.write_bytes(await file.read())
72
+
73
+ try:
74
+ result = transcribe_with_diarization(str(dest), key, num_speakers=num_speakers)
75
+ except Exception as exc: # surface a clean error to the UI
76
+ raise HTTPException(status_code=502, detail=str(exc))
77
+
78
+ speakers = _detect_genders(str(dest), result)
79
+
80
+ return {
81
+ "file_id": file_id,
82
+ "audio_url": f"/api/audio/{file_id}",
83
+ "language_code": result.language_code,
84
+ "full_transcript": result.full_transcript,
85
+ "speakers": speakers,
86
+ "segments": [
87
+ {
88
+ "start": round(s.start, 2),
89
+ "end": round(s.end, 2),
90
+ "speaker_id": s.speaker_id,
91
+ "text": s.text,
92
+ "gender": speakers.get(s.speaker_id, {}).get("label", "uncertain"),
93
+ }
94
+ for s in result.segments
95
+ ],
96
+ }
97
+
98
+
99
+ def _detect_genders(audio_path: str, result: Transcription) -> dict:
100
+ """Estimate gender per speaker by pooling all of their voiced audio."""
101
+ samples, sr = librosa.load(audio_path, sr=16000, mono=True)
102
+
103
+ by_speaker: dict[str, list[np.ndarray]] = defaultdict(list)
104
+ for seg in result.segments:
105
+ if seg.end <= seg.start:
106
+ continue
107
+ start_idx = max(0, int(seg.start * sr))
108
+ end_idx = min(len(samples), int(seg.end * sr))
109
+ if end_idx > start_idx:
110
+ by_speaker[seg.speaker_id].append(samples[start_idx:end_idx])
111
+
112
+ speakers: dict[str, dict] = {}
113
+ for speaker_id, chunks in by_speaker.items():
114
+ pooled = np.concatenate(chunks) if chunks else np.array([])
115
+ res = gender.classify(pooled, sr)
116
+ speakers[speaker_id] = {
117
+ "label": res.label,
118
+ "confidence": res.confidence,
119
+ "probs": res.probs,
120
+ "audio_seconds": res.audio_seconds,
121
+ }
122
+
123
+ # Ensure every diarized speaker has an entry even with no usable audio.
124
+ for seg in result.segments:
125
+ speakers.setdefault(
126
+ seg.speaker_id,
127
+ {"label": "uncertain", "confidence": 0.0, "probs": {}, "audio_seconds": 0.0},
128
+ )
129
+ return speakers
130
+
131
+
132
+ if FRONTEND_DIR.exists():
133
+ app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
backend/transcribe.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sarvam AI batch speech-to-text with diarization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import json
7
+ import os
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+
11
+ from sarvamai import SarvamAI
12
+
13
+
14
+ @dataclass
15
+ class Segment:
16
+ start: float
17
+ end: float
18
+ speaker_id: str
19
+ text: str
20
+
21
+
22
+ @dataclass
23
+ class Transcription:
24
+ language_code: str | None
25
+ segments: list[Segment]
26
+ full_transcript: str
27
+
28
+
29
+ def transcribe_with_diarization(
30
+ audio_path: str,
31
+ api_key: str,
32
+ num_speakers: int = 2,
33
+ ) -> Transcription:
34
+ """Run a Sarvam batch STT job with diarization and return parsed segments."""
35
+ client = SarvamAI(api_subscription_key=api_key)
36
+
37
+ job = client.speech_to_text_job.create_job(
38
+ model="saaras:v3",
39
+ mode="verbatim",
40
+ language_code="unknown",
41
+ with_diarization=True,
42
+ num_speakers=num_speakers,
43
+ )
44
+
45
+ job.upload_files(file_paths=[audio_path])
46
+ job.start()
47
+ # Default timeout is 600s; raise it so longer recordings don't get cut off.
48
+ job.wait_until_complete(poll_interval=5, timeout=3600)
49
+
50
+ file_results = job.get_file_results()
51
+ if not file_results.get("successful"):
52
+ failed = file_results.get("failed", [])
53
+ msg = failed[0].get("error_message") if failed else "unknown error"
54
+ raise RuntimeError(f"Sarvam transcription failed: {msg}")
55
+
56
+ with tempfile.TemporaryDirectory() as out_dir:
57
+ job.download_outputs(output_dir=out_dir)
58
+ json_files = sorted(glob.glob(os.path.join(out_dir, "*.json")))
59
+ if not json_files:
60
+ raise RuntimeError("No output JSON returned by Sarvam.")
61
+ with open(json_files[0], "r", encoding="utf-8") as fh:
62
+ data = json.load(fh)
63
+
64
+ return _parse(data)
65
+
66
+
67
+ def _parse(data: dict) -> Transcription:
68
+ language_code = data.get("language_code")
69
+ full_transcript = data.get("transcript", "") or ""
70
+
71
+ segments: list[Segment] = []
72
+ diarized = data.get("diarized_transcript") or {}
73
+ for entry in diarized.get("entries", []) or []:
74
+ text = (entry.get("transcript") or "").strip()
75
+ if not text:
76
+ continue
77
+ segments.append(
78
+ Segment(
79
+ start=float(entry.get("start_time_seconds", 0.0)),
80
+ end=float(entry.get("end_time_seconds", 0.0)),
81
+ speaker_id=str(entry.get("speaker_id", "0")),
82
+ text=text,
83
+ )
84
+ )
85
+
86
+ # Fallback: no diarization entries -> single segment from the full transcript.
87
+ if not segments and full_transcript:
88
+ segments.append(Segment(0.0, 0.0, "0", full_transcript))
89
+
90
+ segments.sort(key=lambda s: s.start)
91
+ return Transcription(language_code, segments, full_transcript)
frontend/index.html ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>VoxSplit · Transcribe + Gender</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
10
+ <style>
11
+ :root {
12
+ --bg: #07070d;
13
+ --text: #f4f5fb;
14
+ --muted: #9ea3bb;
15
+ --glass: rgba(255, 255, 255, 0.045);
16
+ --glass-2: rgba(255, 255, 255, 0.03);
17
+ --stroke: rgba(255, 255, 255, 0.09);
18
+ --stroke-strong: rgba(255, 255, 255, 0.18);
19
+ --violet: #a855f7;
20
+ --pink: #ec4899;
21
+ --cyan: #22d3ee;
22
+ --male: #38bdf8;
23
+ --female: #fb7185;
24
+ --uncertain: #9ea3bb;
25
+ --grad: linear-gradient(115deg, #a855f7 0%, #ec4899 50%, #22d3ee 100%);
26
+ }
27
+ * { box-sizing: border-box; }
28
+ html { scroll-behavior: smooth; }
29
+ body {
30
+ margin: 0;
31
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
32
+ background: var(--bg);
33
+ color: var(--text);
34
+ min-height: 100vh;
35
+ overflow-x: hidden;
36
+ -webkit-font-smoothing: antialiased;
37
+ }
38
+ /* animated gradient mesh backdrop */
39
+ .blob { position: fixed; border-radius: 50%; filter: blur(90px); opacity: .55; z-index: 0; pointer-events: none; }
40
+ .b1 { width: 520px; height: 520px; background: #7c3aed; top: -160px; left: -120px; animation: float1 16s ease-in-out infinite; }
41
+ .b2 { width: 480px; height: 480px; background: #ec4899; top: 10%; right: -160px; animation: float2 19s ease-in-out infinite; }
42
+ .b3 { width: 460px; height: 460px; background: #06b6d4; bottom: -200px; left: 25%; animation: float3 22s ease-in-out infinite; }
43
+ @keyframes float1 { 0%,100%{transform:translate(0,0)} 50%{transform:translate(60px,50px)} }
44
+ @keyframes float2 { 0%,100%{transform:translate(0,0)} 50%{transform:translate(-50px,40px)} }
45
+ @keyframes float3 { 0%,100%{transform:translate(0,0)} 50%{transform:translate(40px,-50px)} }
46
+
47
+ .wrap { position: relative; z-index: 1; max-width: 880px; margin: 0 auto; padding: 56px 20px 100px; }
48
+
49
+ header { text-align: center; margin-bottom: 36px; }
50
+ .logo {
51
+ display: inline-flex; align-items: center; gap: 9px; padding: 7px 16px; border-radius: 999px;
52
+ background: var(--glass); border: 1px solid var(--stroke); backdrop-filter: blur(12px);
53
+ font-family: "Space Grotesk"; font-weight: 600; font-size: 13px; letter-spacing: .04em;
54
+ margin-bottom: 22px;
55
+ }
56
+ .logo .ping { width: 8px; height: 8px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 12px var(--cyan); }
57
+ header h1 {
58
+ font-family: "Space Grotesk", sans-serif; font-weight: 700; font-size: 46px; line-height: 1.05;
59
+ margin: 0 0 14px; letter-spacing: -0.03em;
60
+ background: var(--grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
61
+ }
62
+ header p { color: var(--muted); margin: 0 auto; max-width: 520px; font-size: 16px; line-height: 1.5; }
63
+
64
+ .card {
65
+ position: relative;
66
+ background: var(--glass);
67
+ border: 1px solid var(--stroke);
68
+ border-radius: 24px;
69
+ padding: 26px;
70
+ backdrop-filter: blur(22px);
71
+ -webkit-backdrop-filter: blur(22px);
72
+ box-shadow: 0 24px 60px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.05);
73
+ margin-bottom: 24px;
74
+ }
75
+
76
+ .drop {
77
+ border: 1.5px dashed var(--stroke-strong); border-radius: 20px; padding: 40px 24px;
78
+ text-align: center; color: var(--muted); cursor: pointer; transition: .25s cubic-bezier(.2,.8,.2,1);
79
+ background: var(--glass-2);
80
+ }
81
+ .drop:hover, .drop.over {
82
+ border-color: var(--violet); color: var(--text);
83
+ background: rgba(168,85,247,.07); transform: translateY(-2px);
84
+ box-shadow: 0 12px 30px rgba(168,85,247,.16);
85
+ }
86
+ .drop .ic {
87
+ width: 56px; height: 56px; margin: 0 auto 14px; border-radius: 16px; display: grid; place-items: center;
88
+ background: var(--grad); box-shadow: 0 10px 26px rgba(236,72,153,.35); font-size: 26px;
89
+ }
90
+ .drop .big { font-family: "Space Grotesk"; font-size: 17px; font-weight: 600; color: var(--text); }
91
+ .drop .big span { background: var(--grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
92
+ .fileName { font-size: 13px; color: var(--cyan); margin-top: 12px; font-weight: 500; }
93
+
94
+ .btn {
95
+ margin-top: 22px; width: 100%; padding: 16px; border: none; border-radius: 16px; position: relative;
96
+ background: var(--grad); background-size: 200% 200%; color: white; font-size: 15.5px; font-weight: 700;
97
+ font-family: "Space Grotesk"; letter-spacing: .01em; cursor: pointer; transition: .2s;
98
+ box-shadow: 0 14px 34px rgba(168,85,247,.32); animation: shimmer 6s ease infinite;
99
+ }
100
+ @keyframes shimmer { 0%,100%{background-position:0% 50%} 50%{background-position:100% 50%} }
101
+ .btn:disabled { opacity: .4; cursor: not-allowed; box-shadow: none; animation: none; }
102
+ .btn:not(:disabled):hover { transform: translateY(-2px); box-shadow: 0 18px 44px rgba(236,72,153,.42); }
103
+ .btn:not(:disabled):active { transform: translateY(0); }
104
+
105
+ .hint { font-size: 12.5px; color: var(--muted); margin-top: 14px; text-align: center; }
106
+ .status { display: none; align-items: center; justify-content: center; gap: 12px; color: var(--text); margin-top: 18px; font-size: 14px; }
107
+ .status.show { display: flex; }
108
+ .spinner { width: 20px; height: 20px; border: 2.5px solid var(--stroke); border-top-color: var(--violet); border-right-color: var(--pink); border-radius: 50%; animation: spin .9s linear infinite; }
109
+ @keyframes spin { to { transform: rotate(360deg); } }
110
+ .error { display: none; color: #fecaca; background: rgba(239,68,68,.1); border: 1px solid rgba(239,68,68,.3);
111
+ padding: 13px 16px; border-radius: 14px; margin-top: 16px; font-size: 14px; }
112
+ .error.show { display: block; }
113
+
114
+ audio { width: 100%; margin-bottom: 20px; border-radius: 12px; filter: saturate(1.1); }
115
+ audio::-webkit-media-controls-panel { background: var(--glass-2); }
116
+
117
+ .meta { display: flex; gap: 9px; flex-wrap: wrap; margin-bottom: 18px; }
118
+ .pill { font-size: 12px; padding: 6px 13px; border-radius: 999px; background: var(--glass-2);
119
+ border: 1px solid var(--stroke); color: var(--muted); font-weight: 500; }
120
+
121
+ .section-title { font-family: "Space Grotesk"; font-size: 13px; letter-spacing: .12em; text-transform: uppercase;
122
+ color: var(--muted); margin: 0 0 12px; }
123
+ .speakers { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 26px; }
124
+ .spk {
125
+ display: flex; align-items: flex-start; gap: 13px; padding: 15px 18px; border-radius: 18px;
126
+ background: var(--glass-2); border: 1px solid var(--stroke); width: 280px; flex: 1 1 260px; max-width: 340px; transition: .2s;
127
+ }
128
+ .spk:hover { border-color: var(--stroke-strong); transform: translateY(-2px); }
129
+ .spk .info { flex: 1; min-width: 0; }
130
+ .avatar { width: 40px; height: 40px; border-radius: 50%; flex: none; display: grid; place-items: center;
131
+ font-family: "Space Grotesk"; font-weight: 700; font-size: 15px; color: #fff; position: relative; margin-top: 2px; }
132
+ .dot { width: 11px; height: 11px; border-radius: 50%; flex: none; }
133
+ .spk .name { font-family: "Space Grotesk"; font-weight: 600; font-size: 14.5px; display: flex; align-items: center; gap: 8px; }
134
+ .spk .sub { font-size: 12px; color: var(--muted); margin-top: 8px; font-variant-numeric: tabular-nums; }
135
+
136
+ .bars { margin-top: 10px; display: flex; flex-direction: column; gap: 7px; }
137
+ .bar { display: flex; align-items: center; gap: 9px; font-size: 11.5px; }
138
+ .bar .lab { width: 48px; color: var(--muted); text-transform: capitalize; }
139
+ .bar .track { flex: 1; height: 7px; border-radius: 999px; background: rgba(255,255,255,.07); overflow: hidden; }
140
+ .bar .fill { height: 100%; width: 0; border-radius: 999px; transition: width .8s cubic-bezier(.2,.85,.25,1); }
141
+ .bar .pct { width: 38px; text-align: right; color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
142
+ .bar.female .fill { background: linear-gradient(90deg, #fb7185, #f472b6); }
143
+ .bar.male .fill { background: linear-gradient(90deg, #38bdf8, #22d3ee); }
144
+ .bar.top .lab { color: var(--text); font-weight: 600; }
145
+ .badge { font-size: 10.5px; padding: 3px 10px; border-radius: 999px; font-weight: 700; text-transform: capitalize; letter-spacing: .02em; }
146
+ .badge.male { background: rgba(56,189,248,.16); color: var(--male); border: 1px solid rgba(56,189,248,.3); }
147
+ .badge.female { background: rgba(251,113,133,.16); color: var(--female); border: 1px solid rgba(251,113,133,.3); }
148
+ .badge.uncertain { background: rgba(158,163,187,.14); color: var(--uncertain); border: 1px solid rgba(158,163,187,.25); }
149
+
150
+ .transcript { display: flex; flex-direction: column; gap: 10px; }
151
+ .seg {
152
+ display: grid; grid-template-columns: 58px 1fr; gap: 16px; padding: 15px 18px; position: relative;
153
+ border-radius: 16px; border: 1px solid var(--stroke); cursor: pointer; transition: .18s cubic-bezier(.2,.8,.2,1);
154
+ background: var(--glass-2); overflow: hidden;
155
+ }
156
+ .seg::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: transparent; transition: .18s; }
157
+ .seg:hover { border-color: var(--stroke-strong); transform: translateX(2px); }
158
+ .seg.active { border-color: rgba(168,85,247,.5); background: rgba(168,85,247,.09); box-shadow: 0 8px 24px rgba(168,85,247,.18); }
159
+ .seg.active::before { background: var(--grad); }
160
+ .seg .time { font-variant-numeric: tabular-nums; font-size: 12px; color: var(--muted); padding-top: 3px; font-weight: 500; }
161
+ .seg .who { display: flex; align-items: center; gap: 9px; margin-bottom: 6px; }
162
+ .seg .who b { font-family: "Space Grotesk"; font-size: 12.5px; }
163
+ .seg .txt { font-size: 15px; line-height: 1.55; color: #e9eaf4; }
164
+ .hidden { display: none; }
165
+
166
+ @media (max-width: 560px) {
167
+ header h1 { font-size: 34px; }
168
+ .wrap { padding-top: 40px; }
169
+ }
170
+ </style>
171
+ </head>
172
+ <body>
173
+ <div class="blob b1"></div>
174
+ <div class="blob b2"></div>
175
+ <div class="blob b3"></div>
176
+
177
+ <div class="wrap">
178
+ <header>
179
+ <div class="logo"><span class="ping"></span> VoxSplit</div>
180
+ <h1>Hear who said what.</h1>
181
+ <p>Drop a WAV and get a diarized, timestamped transcript with per-speaker gender — synced live to playback.</p>
182
+ </header>
183
+
184
+ <div class="card">
185
+ <div id="drop" class="drop">
186
+ <div class="ic">🎙️</div>
187
+ <div class="big">Drop your <span>.wav</span> here, or click to browse</div>
188
+ <div id="fileName" class="fileName"></div>
189
+ </div>
190
+ <input id="fileInput" type="file" accept=".wav,audio/wav,audio/x-wav" class="hidden" />
191
+
192
+ <button id="goBtn" class="btn" disabled>Transcribe &amp; Analyze ✨</button>
193
+ <div class="hint">Diarized, timestamped transcription with per-speaker gender. Longer files take a bit.</div>
194
+ <div id="status" class="status"><div class="spinner"></div><span id="statusText">Working…</span></div>
195
+ <div id="error" class="error"></div>
196
+ </div>
197
+
198
+ <div id="results" class="card hidden">
199
+ <audio id="player" controls preload="auto"></audio>
200
+ <div class="meta" id="meta"></div>
201
+ <div id="speakersWrap">
202
+ <div class="section-title">Speakers</div>
203
+ <div class="speakers" id="speakers"></div>
204
+ </div>
205
+ <div class="section-title">Transcript</div>
206
+ <div class="transcript" id="transcript"></div>
207
+ </div>
208
+ </div>
209
+
210
+ <script>
211
+ const SPEAKER_COLORS = ["#a855f7", "#22d3ee", "#fb7185", "#34d399", "#fbbf24", "#60a5fa", "#f472b6", "#c084fc"];
212
+ const fileInput = document.getElementById("fileInput");
213
+ const drop = document.getElementById("drop");
214
+ const fileName = document.getElementById("fileName");
215
+ const goBtn = document.getElementById("goBtn");
216
+ const statusEl = document.getElementById("status");
217
+ const statusText = document.getElementById("statusText");
218
+ const errorEl = document.getElementById("error");
219
+ const results = document.getElementById("results");
220
+ const player = document.getElementById("player");
221
+ const meta = document.getElementById("meta");
222
+ const speakersEl = document.getElementById("speakers");
223
+ const transcriptEl = document.getElementById("transcript");
224
+
225
+ let selectedFile = null;
226
+ let segEls = [];
227
+
228
+ const colorFor = (id, order) => SPEAKER_COLORS[order % SPEAKER_COLORS.length];
229
+ const fmt = (s) => {
230
+ const m = Math.floor(s / 60);
231
+ const sec = Math.floor(s % 60);
232
+ return `${m}:${sec.toString().padStart(2, "0")}`;
233
+ };
234
+
235
+ drop.addEventListener("click", () => fileInput.click());
236
+ ["dragover", "dragenter"].forEach(e =>
237
+ drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.add("over"); }));
238
+ ["dragleave", "drop"].forEach(e =>
239
+ drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.remove("over"); }));
240
+ drop.addEventListener("drop", (ev) => {
241
+ if (ev.dataTransfer.files.length) setFile(ev.dataTransfer.files[0]);
242
+ });
243
+ fileInput.addEventListener("change", () => { if (fileInput.files.length) setFile(fileInput.files[0]); });
244
+
245
+ function setFile(f) {
246
+ selectedFile = f;
247
+ fileName.textContent = `✓ ${f.name} · ${(f.size / 1048576).toFixed(2)} MB`;
248
+ goBtn.disabled = false;
249
+ }
250
+
251
+ goBtn.addEventListener("click", async () => {
252
+ if (!selectedFile) return;
253
+ errorEl.classList.remove("show");
254
+ results.classList.add("hidden");
255
+ statusEl.classList.add("show");
256
+ statusText.textContent = "Uploading & transcribing…";
257
+ goBtn.disabled = true;
258
+
259
+ const fd = new FormData();
260
+ fd.append("file", selectedFile);
261
+
262
+ try {
263
+ const res = await fetch("/api/transcribe", { method: "POST", body: fd });
264
+ const data = await res.json();
265
+ if (!res.ok) throw new Error(data.detail || "Request failed");
266
+ render(data);
267
+ } catch (e) {
268
+ errorEl.textContent = "Error: " + e.message;
269
+ errorEl.classList.add("show");
270
+ } finally {
271
+ statusEl.classList.remove("show");
272
+ goBtn.disabled = false;
273
+ }
274
+ });
275
+
276
+ function render(data) {
277
+ results.classList.remove("hidden");
278
+ player.src = data.audio_url + "?t=" + Date.now();
279
+
280
+ meta.innerHTML = "";
281
+ if (data.language_code) meta.appendChild(pill("🌐 " + data.language_code));
282
+ meta.appendChild(pill(data.segments.length + " segments"));
283
+ meta.appendChild(pill(Object.keys(data.speakers).length + " speakers"));
284
+
285
+ const order = {};
286
+ Object.keys(data.speakers).sort().forEach((id, i) => (order[id] = i));
287
+
288
+ speakersEl.innerHTML = "";
289
+ Object.keys(data.speakers).sort().forEach((id) => {
290
+ const s = data.speakers[id];
291
+ const c = colorFor(id, order[id]);
292
+ const div = document.createElement("div");
293
+ div.className = "spk";
294
+ div.innerHTML = `
295
+ <span class="avatar" style="background:${c}33; color:${c}; box-shadow:0 0 0 1px ${c}55, 0 6px 18px ${c}33">${escapeHtml(String(id))}</span>
296
+ <div class="info">
297
+ <div class="name">Speaker ${escapeHtml(String(id))}
298
+ <span class="badge ${s.label}">${s.label}</span>
299
+ </div>
300
+ ${buildBars(s)}
301
+ </div>`;
302
+ speakersEl.appendChild(div);
303
+ });
304
+
305
+ // Animate the bar fills in after they're in the DOM.
306
+ requestAnimationFrame(() => {
307
+ speakersEl.querySelectorAll(".fill").forEach((el) => { el.style.width = el.dataset.w + "%"; });
308
+ });
309
+
310
+ transcriptEl.innerHTML = "";
311
+ segEls = data.segments.map((seg) => {
312
+ const c = colorFor(seg.speaker_id, order[seg.speaker_id]);
313
+ const el = document.createElement("div");
314
+ el.className = "seg";
315
+ el.dataset.start = seg.start;
316
+ el.dataset.end = seg.end;
317
+ el.innerHTML = `
318
+ <div class="time">${fmt(seg.start)}</div>
319
+ <div class="body">
320
+ <div class="who">
321
+ <span class="dot" style="background:${c}; box-shadow:0 0 10px ${c}88"></span>
322
+ <b style="color:${c}">Speaker ${escapeHtml(String(seg.speaker_id))}</b>
323
+ <span class="badge ${seg.gender}">${seg.gender}</span>
324
+ </div>
325
+ <div class="txt">${escapeHtml(seg.text)}</div>
326
+ </div>`;
327
+ el.addEventListener("click", () => {
328
+ if (seg.end > seg.start) { player.currentTime = seg.start; player.play(); }
329
+ });
330
+ transcriptEl.appendChild(el);
331
+ return el;
332
+ });
333
+
334
+ results.scrollIntoView({ behavior: "smooth" });
335
+ }
336
+
337
+ player.addEventListener("timeupdate", () => {
338
+ const t = player.currentTime;
339
+ let active = -1;
340
+ segEls.forEach((el, i) => {
341
+ const s = parseFloat(el.dataset.start), e = parseFloat(el.dataset.end);
342
+ if (e > s && t >= s && t < e) active = i;
343
+ });
344
+ segEls.forEach((el, i) => el.classList.toggle("active", i === active));
345
+ const cur = segEls[active];
346
+ if (cur) cur.scrollIntoView({ block: "nearest", behavior: "smooth" });
347
+ });
348
+
349
+ function buildBars(s) {
350
+ const probs = s.probs || {};
351
+ const keys = Object.keys(probs);
352
+ if (!keys.length) return `<div class="sub">not enough audio to analyze</div>`;
353
+ const top = keys.reduce((a, b) => (probs[b] > probs[a] ? b : a), keys[0]);
354
+ const rows = keys
355
+ .sort((a, b) => probs[b] - probs[a])
356
+ .map((k) => {
357
+ const pct = Math.round(probs[k] * 100);
358
+ return `<div class="bar ${k} ${k === top ? "top" : ""}">
359
+ <span class="lab">${escapeHtml(k)}</span>
360
+ <span class="track"><span class="fill" data-w="${pct}"></span></span>
361
+ <span class="pct">${pct}%</span>
362
+ </div>`;
363
+ })
364
+ .join("");
365
+ return `<div class="bars">${rows}</div>`;
366
+ }
367
+ function pill(t) { const s = document.createElement("span"); s.className = "pill"; s.textContent = t; return s; }
368
+ function escapeHtml(s) {
369
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
370
+ }
371
+ </script>
372
+ </body>
373
+ </html>
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.34.0
3
+ python-multipart==0.0.20
4
+ python-dotenv==1.0.1
5
+ sarvamai==0.1.28
6
+ librosa==0.10.2.post1
7
+ soundfile==0.12.1
8
+ numpy==1.26.4
9
+ torch==2.2.2
10
+ transformers==4.46.3