polywhisper 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """PolyWhisper โ€” efficient multilingual Indic ASR via frozen Whisper + per-language LoRA."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ from polywhisper.transcribe import transcribe, TranscriptionResult
6
+
7
+ __all__ = ["transcribe", "TranscriptionResult"]
@@ -0,0 +1,3 @@
1
+ """Allow `python -m polywhisper` to work."""
2
+ from polywhisper.cli import main
3
+ main()
polywhisper/audio.py ADDED
@@ -0,0 +1,65 @@
1
+ """Audio loading, format conversion, and chunking utilities."""
2
+
3
+ import numpy as np
4
+ import soundfile as sf
5
+ from pathlib import Path
6
+
7
+ TARGET_SR = 16000
8
+ MAX_DURATION_SEC = 30.0
9
+ CHUNK_OVERLAP_SEC = 3.0
10
+
11
+
12
+ def load_audio(path, target_sr=TARGET_SR):
13
+ """Load audio file, return (samples: np.ndarray[float32], sr: int).
14
+
15
+ Supports wav, flac, mp3, ogg, m4a via soundfile/libsndfile.
16
+ For formats not supported by soundfile, falls back to ffmpeg.
17
+ """
18
+ path = str(path)
19
+ try:
20
+ audio, sr = sf.read(path, dtype="float32")
21
+ except Exception:
22
+ # ffmpeg fallback for mp3/ogg/m4a
23
+ import subprocess, io, tempfile
24
+ with tempfile.NamedTemporaryFile(suffix=".wav") as tmp:
25
+ subprocess.run(
26
+ ["ffmpeg", "-i", path, "-ar", str(target_sr), "-ac", "1",
27
+ "-f", "wav", "-y", tmp.name],
28
+ capture_output=True, check=True,
29
+ )
30
+ audio, sr = sf.read(tmp.name, dtype="float32")
31
+ if audio.ndim > 1:
32
+ audio = audio.mean(axis=1) # stereo โ†’ mono
33
+ if sr != target_sr:
34
+ import resampy
35
+ audio = resampy.resample(audio, sr, target_sr)
36
+ sr = target_sr
37
+ return audio, sr
38
+
39
+
40
+ def chunk_audio(audio, chunk_sec=MAX_DURATION_SEC, overlap_sec=CHUNK_OVERLAP_SEC, sr=TARGET_SR):
41
+ """Split long audio into overlapping chunks. Returns list of (start_sample, end_sample)."""
42
+ chunk_samples = int(chunk_sec * sr)
43
+ overlap_samples = int(overlap_sec * sr)
44
+ n = len(audio)
45
+ if n <= chunk_samples:
46
+ return [(0, n)]
47
+ chunks = []
48
+ start = 0
49
+ while start < n:
50
+ end = min(start + chunk_samples, n)
51
+ chunks.append((start, end))
52
+ if end == n:
53
+ break
54
+ start += chunk_samples - overlap_samples
55
+ return chunks
56
+
57
+
58
+ def audio_to_chunks(audio_or_path, chunk_sec=MAX_DURATION_SEC, overlap_sec=CHUNK_OVERLAP_SEC, sr=TARGET_SR):
59
+ """Load audio (if path) and return list of (chunk_np, start_sec, end_sec)."""
60
+ if isinstance(audio_or_path, (str, Path)):
61
+ audio, _ = load_audio(audio_or_path, sr)
62
+ else:
63
+ audio = np.asarray(audio_or_path, dtype="float32")
64
+ spans = chunk_audio(audio, chunk_sec, overlap_sec, sr)
65
+ return [(audio[s:e], s / sr, e / sr) for s, e in spans]
polywhisper/cli.py ADDED
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env python3
2
+ """PolyWhisper CLI โ€” transcribe audio files from the command line.
3
+
4
+ Usage:
5
+ polywhisper transcribe audio.wav --lang hi
6
+ polywhisper transcribe audio.wav # auto-detect language
7
+ polywhisper batch ./audio_folder/ --lang te --output results.json
8
+ polywhisper languages
9
+ """
10
+
11
+ import os
12
+ os.environ.setdefault("TRANSFORMERS_NO_ADVISORY_WARNINGS", "1")
13
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+
21
+
22
+ def cmd_transcribe(args):
23
+ from polywhisper import transcribe
24
+ from polywhisper.model import PolyWhisper
25
+ import sys, io, os, warnings
26
+
27
+ # Suppress everything
28
+ warnings.filterwarnings("ignore")
29
+ old_stderr = sys.stderr
30
+ old_stdout = sys.stdout
31
+ sys.stderr = io.StringIO()
32
+ sys.stdout = io.StringIO()
33
+ os.environ["TRANSFORMERS_NO_ADVISORY_WARNINGS"] = "1"
34
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
35
+
36
+ model = PolyWhisper(backbone=args.backbone, device=args.device)
37
+
38
+ t0 = time.time()
39
+ result = transcribe(
40
+ args.audio,
41
+ lang=args.lang,
42
+ backbone=args.backbone,
43
+ variant=args.variant,
44
+ device=args.device,
45
+ max_new_tokens=args.max_tokens,
46
+ num_beams=args.beams,
47
+ model=model,
48
+ backend=args.backend,
49
+ )
50
+ dt = time.time() - t0
51
+
52
+ sys.stderr = old_stderr
53
+ sys.stdout = old_stdout
54
+
55
+ if args.format == "text":
56
+ print(result.text)
57
+ elif args.format == "json":
58
+ out = result.to_dict()
59
+ out["duration_sec"] = result.duration_sec
60
+ out["process_time_sec"] = round(dt, 2)
61
+ print(json.dumps(out, ensure_ascii=False, indent=2))
62
+ elif args.format == "srt":
63
+ for i, seg in enumerate(result.segments, 1):
64
+ start = _fmt_srt(seg.start_sec)
65
+ end = _fmt_srt(seg.end_sec)
66
+ print(f"{i}\n{start} --> {end}\n{seg.text}\n")
67
+
68
+ print(f"[{dt:.1f}s, {result.lang}, {len(result.segments)} segments]", file=old_stderr)
69
+
70
+
71
+ def cmd_batch(args):
72
+ from polywhisper import transcribe
73
+ from polywhisper.model import PolyWhisper
74
+ import sys as _sys, io as _io, os as _os
75
+
76
+ _os.environ["TRANSFORMERS_NO_ADVISORY_WARNINGS"] = "1"
77
+ _os.environ["TOKENIZERS_PARALLELISM"] = "false"
78
+
79
+ audio_dir = Path(args.audio_dir)
80
+ exts = {".wav", ".flac", ".mp3", ".ogg", ".m4a", ".webm"}
81
+ files = sorted(f for f in audio_dir.iterdir() if f.suffix.lower() in exts)
82
+ if not files:
83
+ print(f"No audio files found in {audio_dir}")
84
+ return
85
+
86
+ old_stderr = _sys.stderr
87
+ _sys.stderr = _io.StringIO()
88
+ print(f"Loading model (backbone={args.backbone})...", file=old_stderr)
89
+ model = PolyWhisper(backbone=args.backbone, device=args.device)
90
+
91
+ results = []
92
+ total_t0 = time.time()
93
+ for i, f in enumerate(files, 1):
94
+ t0 = time.time()
95
+ result = transcribe(
96
+ f, lang=args.lang, backbone=args.backbone, variant=args.variant,
97
+ device=args.device, max_new_tokens=args.max_tokens, num_beams=args.beams,
98
+ model=model,
99
+ )
100
+ dt = time.time() - t0
101
+ entry = result.to_dict()
102
+ entry["file"] = str(f)
103
+ entry["process_time_sec"] = round(dt, 2)
104
+ results.append(entry)
105
+ print(f" [{i}/{len(files)}] {f.name} โ†’ {result.lang} ({dt:.1f}s)", file=old_stderr)
106
+
107
+ total_dt = time.time() - total_t0
108
+ out = {"results": results, "total_files": len(results), "total_time_sec": round(total_dt, 2)}
109
+
110
+ _sys.stderr = old_stderr
111
+ if args.output:
112
+ Path(args.output).write_text(json.dumps(out, ensure_ascii=False, indent=2))
113
+ print(f"Wrote {args.output} ({len(results)} files)", file=old_stderr)
114
+ else:
115
+ print(json.dumps(out, ensure_ascii=False, indent=2))
116
+
117
+
118
+ def cmd_languages(args):
119
+ from polywhisper.model import AVAILABLE_LANGS, ADAPTER_REGISTRY, _adapter_search_paths
120
+ from pathlib import Path
121
+
122
+ search = _adapter_search_paths()
123
+ print("Available languages:")
124
+ for lang in AVAILABLE_LANGS:
125
+ reg = ADAPTER_REGISTRY[lang]
126
+ if isinstance(reg, dict):
127
+ variants = list(reg.keys())
128
+ else:
129
+ variants = ["base"]
130
+ found = []
131
+ for variant in variants:
132
+ fname = reg[variant] if isinstance(reg, dict) else reg
133
+ for d in search:
134
+ if (d / fname).exists():
135
+ found.append(f"{variant} ({fname})")
136
+ break
137
+ status = ", ".join(found) if found else "NOT FOUND locally"
138
+ print(f" {lang:4s} adapters: {status}")
139
+ print(f"\nSearch paths: {[str(p) for p in search]}")
140
+
141
+
142
+ def _fmt_srt(sec):
143
+ h = int(sec // 3600)
144
+ m = int((sec % 3600) // 60)
145
+ s = int(sec % 60)
146
+ ms = int((sec - int(sec)) * 1000)
147
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
148
+
149
+
150
+ def cmd_export(args):
151
+ from polywhisper.onnx_backend import export_language
152
+ from polywhisper.model import ADAPTER_REGISTRY, _adapter_search_paths
153
+
154
+ # Resolve adapter path
155
+ reg = ADAPTER_REGISTRY.get(args.lang)
156
+ if reg is None:
157
+ print(f"Unknown language: {args.lang}. Available: {list(ADAPTER_REGISTRY.keys())}")
158
+ return
159
+ fname = reg.get(args.variant, reg.get("prod", list(reg.values())[0])) if isinstance(reg, dict) else reg
160
+ adapter_path = None
161
+ for d in _adapter_search_paths():
162
+ p = d / fname
163
+ if p.exists():
164
+ adapter_path = p
165
+ break
166
+ if adapter_path is None:
167
+ print(f"Adapter not found: {fname}")
168
+ return
169
+
170
+ print(f"Exporting {args.lang} ({args.variant}) to ONNX...")
171
+ enc_path, dec_path = export_language(
172
+ args.lang, str(adapter_path), backbone=args.backbone,
173
+ out_dir=args.out_dir, int8=args.int8,
174
+ )
175
+ print(f"Done. Encoder: {enc_path}\nDecoder: {dec_path}")
176
+
177
+
178
+ def main():
179
+ p = argparse.ArgumentParser(
180
+ prog="polywhisper",
181
+ description="PolyWhisper โ€” efficient multilingual Indic ASR",
182
+ )
183
+ sub = p.add_subparsers(dest="command", required=True)
184
+
185
+ # transcribe
186
+ t = sub.add_parser("transcribe", help="Transcribe a single audio file")
187
+ t.add_argument("audio", help="Path to audio file")
188
+ t.add_argument("--lang", "-l", default=None, help="Language code (hi/ta/te/bn/mr)")
189
+ t.add_argument("--backbone", "-b", default="small", help="Whisper backbone (default: small)")
190
+ t.add_argument("--variant", "-v", default="prod", help="Adapter variant (prod/base)")
191
+ t.add_argument("--device", "-d", default="auto", help="Device (auto/cuda/mps/cpu)")
192
+ t.add_argument("--max-tokens", type=int, default=256, help="Max tokens to generate")
193
+ t.add_argument("--beams", type=int, default=1, help="Beam width")
194
+ t.add_argument("--format", "-f", default="text", choices=["text", "json", "srt"],
195
+ help="Output format")
196
+ t.add_argument("--backend", default="auto", choices=["auto", "torch", "onnx"],
197
+ help="Inference backend (auto: ONNX if available, else torch)")
198
+ t.set_defaults(func=cmd_transcribe)
199
+
200
+ # batch
201
+ b = sub.add_parser("batch", help="Transcribe all audio files in a directory")
202
+ b.add_argument("audio_dir", help="Directory containing audio files")
203
+ b.add_argument("--lang", "-l", default=None, help="Language code")
204
+ b.add_argument("--backbone", "-b", default="small")
205
+ b.add_argument("--variant", "-v", default="prod")
206
+ b.add_argument("--device", "-d", default="auto")
207
+ b.add_argument("--max-tokens", type=int, default=256)
208
+ b.add_argument("--beams", type=int, default=1)
209
+ b.add_argument("--output", "-o", default=None, help="Output JSON file")
210
+ b.set_defaults(func=cmd_batch)
211
+
212
+ # export
213
+ e = sub.add_parser("export", help="Export language to ONNX (CPU inference)")
214
+ e.add_argument("--lang", "-l", required=True, help="Language to export")
215
+ e.add_argument("--variant", "-v", default="prod", help="Adapter variant")
216
+ e.add_argument("--backbone", "-b", default="small")
217
+ e.add_argument("--out-dir", "-o", default="export/onnx", help="Output directory")
218
+ e.add_argument("--int8", action="store_true", help="Also quantize to int8")
219
+ e.set_defaults(func=cmd_export)
220
+
221
+ # languages
222
+ sub.add_parser("languages", help="List available languages and adapters").set_defaults(func=cmd_languages)
223
+
224
+ args = p.parse_args()
225
+ args.func(args)
226
+
227
+
228
+ if __name__ == "__main__":
229
+ main()
polywhisper/model.py ADDED
@@ -0,0 +1,241 @@
1
+ """PolyWhisper model โ€” frozen Whisper backbone + per-language LoRA adapters."""
2
+
3
+ import os
4
+ os.environ.setdefault("TRANSFORMERS_NO_ADVISORY_WARNINGS", "1")
5
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
6
+
7
+ import sys
8
+ import io
9
+ import torch
10
+ import torch.nn as nn
11
+ import logging
12
+ import warnings
13
+ warnings.filterwarnings("ignore", message=".*past_key_values.*")
14
+ warnings.filterwarnings("ignore", message=".*attention mask.*")
15
+ warnings.filterwarnings("ignore", message=".*forced_decoder_ids.*")
16
+ logging.getLogger("transformers").setLevel(logging.ERROR)
17
+
18
+ from pathlib import Path
19
+ from transformers import WhisperProcessor, WhisperForConditionalGeneration
20
+
21
+ WHISPER_SMALL = "openai/whisper-small"
22
+ RANK = 16
23
+ LANG_TOKENS = {
24
+ "en": 50259, "hi": 50276, "ta": 50287,
25
+ "te": 50299, "bn": 50302, "mr": 50320,
26
+ }
27
+ BOS_EOS = 50257
28
+
29
+ ADAPTER_REGISTRY = {
30
+ "hi": {"base": "hi_best_v5.pt", "prod": "hi_best_prod.pt"},
31
+ "ta": {"base": "ta_best_ta.pt", "prod": "ta_best_prod.pt"},
32
+ "te": {"base": "te_best_te.pt", "prod": "te_best_prod.pt"},
33
+ "bn": {"base": "bn_best_bn.pt", "prod": "bn_best_prod.pt"},
34
+ "mr": {"base": "mr_best_mr.pt", "prod": "mr_best_prod.pt"},
35
+ }
36
+
37
+ AVAILABLE_LANGS = list(ADAPTER_REGISTRY.keys())
38
+
39
+
40
+ def _get_processor(backbone="small"):
41
+ """Get WhisperProcessor for the given backbone."""
42
+ from transformers import WhisperProcessor
43
+ return WhisperProcessor.from_pretrained(f"openai/whisper-{backbone}")
44
+
45
+
46
+ def _adapter_search_paths():
47
+ """Return candidate directories containing adapters_v3/."""
48
+ candidates = [
49
+ Path(__file__).parent.parent / "polywhisper_output" / "adapters_v3",
50
+ Path.home() / "polywhisper_output" / "adapters_v3",
51
+ Path("/Volumes/KIOXIA 1TB/polywhisper_output/adapters_v3"),
52
+ ]
53
+ return [p for p in candidates if p.exists()]
54
+
55
+
56
+ class PolyWhisper(nn.Module):
57
+ """Whisper backbone with frozen weights and per-language LoRA adapters.
58
+
59
+ Usage:
60
+ model = PolyWhisper(backbone="small", device="auto")
61
+ model.load_adapter("hi", "path/to/hi_best_prod.pt")
62
+ model.set_language("hi")
63
+ tokens = model.generate(features, max_new_tokens=256)
64
+ """
65
+
66
+ PROJ_MAP = [
67
+ ("self_attn", "self_q"), ("self_attn", "self_k"), ("self_attn", "self_v"),
68
+ ("encoder_attn", "cross_q"), ("encoder_attn", "cross_k"), ("encoder_attn", "cross_v"),
69
+ ("self_attn", "self_out"), ("encoder_attn", "cross_out"),
70
+ ]
71
+ ENC_PROJ_MAP = [
72
+ ("self_attn", "enc_q"), ("self_attn", "enc_k"), ("self_attn", "enc_v"),
73
+ ("self_attn", "enc_out"),
74
+ ]
75
+
76
+ def __init__(self, backbone="small", rank=RANK, device="auto"):
77
+ super().__init__()
78
+ if device == "auto":
79
+ if torch.cuda.is_available():
80
+ device = "cuda"
81
+ elif torch.backends.mps.is_available():
82
+ device = "mps"
83
+ else:
84
+ device = "cpu"
85
+ self.device = torch.device(device)
86
+ self.rank = rank
87
+ self._current_lang = None
88
+ self._hooks = []
89
+
90
+ whisper_name = f"openai/whisper-{backbone}"
91
+ import warnings as _warn
92
+ _warn.filterwarnings("ignore")
93
+ old_stdout, old_stderr = sys.stdout, sys.stderr
94
+ sys.stdout, sys.stderr = io.StringIO(), io.StringIO()
95
+ try:
96
+ self.whisper = WhisperForConditionalGeneration.from_pretrained(whisper_name)
97
+ finally:
98
+ sys.stdout, sys.stderr = old_stdout, old_stderr
99
+ for p in self.whisper.parameters():
100
+ p.requires_grad = False
101
+ self.whisper.to(self.device)
102
+
103
+ self.d_model = self.whisper.config.d_model
104
+ self.decoder_layers = self.whisper.config.decoder_layers
105
+ self.encoder_layers = self.whisper.config.encoder_layers
106
+ self.lora_adapters = nn.ModuleDict()
107
+ old_stdout, old_stderr = sys.stdout, sys.stderr
108
+ sys.stdout, sys.stderr = io.StringIO(), io.StringIO()
109
+ try:
110
+ self.processor = WhisperProcessor.from_pretrained(whisper_name)
111
+ finally:
112
+ sys.stdout, sys.stderr = old_stdout, old_stderr
113
+
114
+ def add_language(self, lang):
115
+ d, r = self.d_model, self.rank
116
+ dev = self.device
117
+ adpt = nn.ModuleDict()
118
+ for i in range(self.decoder_layers):
119
+ for _, base_name in self.PROJ_MAP:
120
+ adpt[f"d{i}_{base_name}_a"] = nn.Linear(d, r, bias=False).to(dev)
121
+ adpt[f"d{i}_{base_name}_b"] = nn.Linear(r, d, bias=False).to(dev)
122
+ nn.init.zeros_(adpt[f"d{i}_{base_name}_b"].weight)
123
+ if lang not in self.lora_adapters or not any(
124
+ k.startswith("e0_") for k in self.lora_adapters.get(lang, {}).keys()
125
+ ):
126
+ pass # decoder-only by default; encoder LoRA loaded from checkpoint
127
+ self.lora_adapters[lang] = adpt
128
+ return adpt
129
+
130
+ @staticmethod
131
+ def _proj_name(base_name):
132
+ if "out" in base_name:
133
+ return "out_proj"
134
+ if "q" in base_name:
135
+ return "q_proj"
136
+ if "k" in base_name:
137
+ return "k_proj"
138
+ return "v_proj"
139
+
140
+ def set_language(self, lang):
141
+ if self._current_lang == lang:
142
+ return
143
+ self._remove_hooks()
144
+ if lang not in self.lora_adapters:
145
+ self.add_language(lang)
146
+ lora = self.lora_adapters[lang]
147
+ self._hooks = []
148
+ for i, layer in enumerate(self.whisper.model.decoder.layers):
149
+ for attn_name, base_name in self.PROJ_MAP:
150
+ a = lora[f"d{i}_{base_name}_a"]
151
+ b = lora[f"d{i}_{base_name}_b"]
152
+
153
+ def make_hook(a, b):
154
+ def hook(mod, inp, out):
155
+ return out + b(a(inp[0]))
156
+ return hook
157
+
158
+ proj = getattr(getattr(layer, attn_name), self._proj_name(base_name))
159
+ self._hooks.append(proj.register_forward_hook(make_hook(a, b)))
160
+ # encoder LoRA hooks (only if adapter has encoder keys)
161
+ if any(k.startswith("e0_") for k in lora.keys()):
162
+ for i, layer in enumerate(self.whisper.model.encoder.layers):
163
+ for attn_name, base_name in self.ENC_PROJ_MAP:
164
+ a = lora[f"e{i}_{base_name}_a"]
165
+ b = lora[f"e{i}_{base_name}_b"]
166
+ proj = getattr(getattr(layer, attn_name), self._proj_name(base_name))
167
+ self._hooks.append(proj.register_forward_hook(make_hook(a, b)))
168
+ self._current_lang = lang
169
+
170
+ def _remove_hooks(self):
171
+ for h in self._hooks:
172
+ h.remove()
173
+ self._hooks = []
174
+
175
+ def load_adapter(self, lang, path):
176
+ path = str(path)
177
+ st = torch.load(path, map_location="cpu", weights_only=True)
178
+ d, r = self.d_model, self.rank
179
+ dev = self.device
180
+ if lang not in self.lora_adapters:
181
+ self.lora_adapters[lang] = nn.ModuleDict()
182
+ adpt = self.lora_adapters[lang]
183
+ # create decoder layers if present in checkpoint
184
+ dec_keys = [k for k in st if k.startswith("d0_")]
185
+ if dec_keys:
186
+ for i in range(self.decoder_layers):
187
+ for _, base_name in self.PROJ_MAP:
188
+ a_key = f"d{i}_{base_name}_a"
189
+ b_key = f"d{i}_{base_name}_b"
190
+ if a_key not in adpt:
191
+ adpt[a_key] = nn.Linear(d, r, bias=False).to(dev)
192
+ adpt[b_key] = nn.Linear(r, d, bias=False).to(dev)
193
+ # create encoder LoRA layers if present in checkpoint
194
+ enc_keys = [k for k in st if k.startswith("e0_")]
195
+ if enc_keys:
196
+ for i in range(self.encoder_layers):
197
+ for _, base_name in self.ENC_PROJ_MAP:
198
+ a_key = f"e{i}_{base_name}_a"
199
+ b_key = f"e{i}_{base_name}_b"
200
+ if a_key not in adpt:
201
+ adpt[a_key] = nn.Linear(d, r, bias=False).to(dev)
202
+ adpt[b_key] = nn.Linear(r, d, bias=False).to(dev)
203
+ adpt.load_state_dict(st)
204
+
205
+ def auto_load_adapter(self, lang, variant="prod"):
206
+ """Load adapter by language name, searching standard paths."""
207
+ reg = ADAPTER_REGISTRY.get(lang)
208
+ if reg is None:
209
+ raise ValueError(f"Unknown language: {lang}. Available: {AVAILABLE_LANGS}")
210
+ if isinstance(reg, dict):
211
+ fname = reg.get(variant, reg.get("prod", list(reg.values())[0]))
212
+ else:
213
+ fname = reg
214
+ for search_dir in _adapter_search_paths():
215
+ path = search_dir / fname
216
+ if path.exists():
217
+ self.load_adapter(lang, path)
218
+ return str(path)
219
+ raise FileNotFoundError(
220
+ f"Adapter {fname} not found. Searched: {[str(p) for p in _adapter_search_paths()]}"
221
+ )
222
+
223
+ def forward(self, feat, dec_ids, lang):
224
+ self.set_language(lang)
225
+ if (dec_ids == -100).any():
226
+ dec_ids = torch.where(dec_ids == -100, torch.tensor(BOS_EOS, device=dec_ids.device), dec_ids)
227
+ enc = self.whisper.model.encoder(feat).last_hidden_state
228
+ dec_out = self.whisper.model.decoder(dec_ids, encoder_hidden_states=enc)
229
+ return self.whisper.proj_out(dec_out.last_hidden_state)
230
+
231
+ @torch.no_grad()
232
+ def generate(self, feat, lang, **kwargs):
233
+ self.set_language(lang)
234
+ old_stderr = sys.stderr
235
+ sys.stderr = io.StringIO()
236
+ result = self.whisper.generate(feat, **kwargs)
237
+ sys.stderr = old_stderr
238
+ return result
239
+
240
+ def save_adapter(self, lang, path):
241
+ torch.save(self.lora_adapters[lang].state_dict(), path)
@@ -0,0 +1,215 @@
1
+ """ONNX Runtime inference backend โ€” no torch required at inference time.
2
+
3
+ Exports per-language ONNX models (encoder+decoder with LoRA baked in),
4
+ then runs autoregressive decoding via onnxruntime.
5
+ """
6
+
7
+ import os
8
+ os.environ.setdefault("TRANSFORMERS_NO_ADVISORY_WARNINGS", "1")
9
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
10
+
11
+ import numpy as np
12
+ import soundfile as sf
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ try:
17
+ import onnxruntime as ort
18
+ except ImportError:
19
+ ort = None
20
+
21
+ from polywhisper.audio import load_audio, TARGET_SR
22
+
23
+ START_TOKEN = 50257
24
+ LANG_TOKENS = {
25
+ "en": 50259, "hi": 50276, "ta": 50287,
26
+ "te": 50299, "bn": 50302, "mr": 50320,
27
+ }
28
+ TASK_TOKEN = 50359
29
+ NO_TIME_TOKEN = 50363
30
+
31
+
32
+ def _export_dir():
33
+ candidates = [
34
+ Path(__file__).parent.parent / "export" / "onnx",
35
+ Path.home() / "polywhisper_output" / "export" / "onnx",
36
+ Path("/Volumes/KIOXIA 1TB/polywhisper_output/export/onnx"),
37
+ ]
38
+ return next((p for p in candidates if p.exists()), candidates[0])
39
+
40
+
41
+ class OnnxBackend:
42
+ """ONNX Runtime inference for PolyWhisper (no torch needed)."""
43
+
44
+ def __init__(self, export_dir: Optional[Path] = None, lang: str = "hi",
45
+ int8: bool = False, device: str = "cpu"):
46
+ if ort is None:
47
+ raise ImportError("onnxruntime is required: pip install onnxruntime")
48
+ self.export_dir = Path(export_dir) if export_dir else _export_dir()
49
+ self.lang = lang
50
+ self.int8 = int8
51
+
52
+ suffix = "_int8" if int8 else ""
53
+ enc_path = self.export_dir / f"{lang}_encoder{suffix}.onnx"
54
+ dec_path = self.export_dir / f"{lang}_decoder{suffix}.onnx"
55
+
56
+ if not enc_path.exists() or not dec_path.exists():
57
+ raise FileNotFoundError(
58
+ f"ONNX models not found for '{lang}'. "
59
+ f"Expected: {enc_path.name}, {dec_path.name}\n"
60
+ f"Run: python export_onnx.py --lang {lang} --adapter {lang}_best_prod.pt "
61
+ f"--adapter-dir polywhisper_output/adapters_v3 --int8"
62
+ )
63
+
64
+ opts = ort.SessionOptions()
65
+ opts.inter_op_num_threads = 4
66
+ opts.intra_op_num_threads = 4
67
+ providers = ["CPUExecutionProvider"]
68
+
69
+ self.enc_session = ort.InferenceSession(str(enc_path), opts, providers=providers)
70
+ self.dec_session = ort.InferenceSession(str(dec_path), opts, providers=providers)
71
+
72
+ @staticmethod
73
+ def _mel(audio, processor):
74
+ """Compute mel features from audio array."""
75
+ import torch
76
+ feats = processor.feature_extractor(
77
+ [audio], sampling_rate=TARGET_SR, return_tensors="pt", padding=True
78
+ )["input_features"]
79
+ if feats.shape[-1] < 3000:
80
+ feats = torch.cat(
81
+ [feats, torch.zeros(1, 80, 3000 - feats.shape[-1], dtype=feats.dtype)], -1
82
+ )
83
+ return feats.numpy()
84
+
85
+ def transcribe(self, audio_input, processor, max_new_tokens=256,
86
+ num_beams=1, language=None) -> str:
87
+ """Transcribe audio via ONNX Runtime (greedy only for now)."""
88
+ lang = language or self.lang
89
+
90
+ # Load audio
91
+ if isinstance(audio_input, (str, Path)):
92
+ audio, _ = load_audio(str(audio_input), TARGET_SR)
93
+ else:
94
+ audio = np.asarray(audio_input, dtype=np.float32)
95
+
96
+ # Compute features
97
+ feats = self._mel(audio, processor)
98
+
99
+ # Encode
100
+ enc_hidden = self.enc_session.run(None, {"feats": feats})[0]
101
+
102
+ # Decode (greedy autoregressive)
103
+ lang_token = LANG_TOKENS.get(lang, LANG_TOKENS["hi"])
104
+ ids = np.array([[START_TOKEN, lang_token, TASK_TOKEN, NO_TIME_TOKEN]], dtype=np.int64)
105
+
106
+ for _ in range(max_new_tokens):
107
+ logits = self.dec_session.run(None, {
108
+ "input_ids": ids,
109
+ "enc_hidden": enc_hidden,
110
+ })[0]
111
+ next_token = int(np.argmax(logits[0, -1]))
112
+ ids = np.concatenate([ids, [[next_token]]], axis=1)
113
+ if next_token == START_TOKEN:
114
+ break
115
+
116
+ return processor.decode(ids[0], skip_special_tokens=True).strip()
117
+
118
+
119
+ def export_language(lang, adapter_path, backbone="small", out_dir=None, int8=True):
120
+ """Export a single language to ONNX with LoRA baked in."""
121
+ import torch
122
+ import torch.nn as nn
123
+ from transformers import WhisperForConditionalGeneration, WhisperProcessor
124
+
125
+ if ort is None:
126
+ raise ImportError("onnxruntime required for export")
127
+
128
+ out_dir = Path(out_dir) if out_dir else _export_dir()
129
+ out_dir.mkdir(parents=True, exist_ok=True)
130
+
131
+ whisper_name = f"openai/whisper-{backbone}"
132
+ processor = WhisperProcessor.from_pretrained(whisper_name)
133
+
134
+ # Load model with LoRA
135
+ from polywhisper.model import PolyWhisper
136
+ model = PolyWhisper(backbone=backbone, device="cpu")
137
+ model.load_adapter(lang, adapter_path)
138
+ model.set_language(lang)
139
+
140
+ # Freeze everything for export
141
+ model.eval()
142
+ for p in model.parameters():
143
+ p.requires_grad = False
144
+ for p in model.lora_adapters[lang].parameters():
145
+ p.requires_grad = False
146
+
147
+ # Wrapper classes matching export_onnx.py
148
+ class EncoderWrapper(nn.Module):
149
+ def __init__(self, whisper):
150
+ super().__init__()
151
+ self.w = whisper
152
+ def forward(self, feats):
153
+ return self.w.model.encoder(feats).last_hidden_state
154
+
155
+ class DecoderWrapper(nn.Module):
156
+ def __init__(self, whisper):
157
+ super().__init__()
158
+ self.w = whisper
159
+ def forward(self, input_ids, enc_hidden):
160
+ out = self.w.model.decoder(input_ids, encoder_hidden_states=enc_hidden).last_hidden_state
161
+ return self.w.proj_out(out)
162
+
163
+ feats = torch.randn(1, 80, 3000)
164
+ ids = torch.randint(0, 100, (1, 10))
165
+ d = model.d_model
166
+ enc_hidden = torch.randn(1, 1500, d)
167
+
168
+ # Export encoder
169
+ enc_w = EncoderWrapper(model.whisper)
170
+ enc_path = out_dir / f"{lang}_encoder.onnx"
171
+ torch.onnx.export(
172
+ enc_w, (feats,), str(enc_path),
173
+ input_names=["feats"], output_names=["enc_hidden"],
174
+ dynamic_axes={"feats": {0: "batch", 2: "time"}, "enc_hidden": {0: "batch", 1: "enc_seq"}},
175
+ opset_version=17, do_constant_folding=True, dynamo=False,
176
+ )
177
+ print(f" encoder: {enc_path}")
178
+
179
+ # Export decoder
180
+ dec_w = DecoderWrapper(model.whisper)
181
+ dec_path = out_dir / f"{lang}_decoder.onnx"
182
+ torch.onnx.export(
183
+ dec_w, (ids, enc_hidden), str(dec_path),
184
+ input_names=["input_ids", "enc_hidden"], output_names=["logits"],
185
+ dynamic_axes={
186
+ "input_ids": {0: "batch", 1: "seq"},
187
+ "enc_hidden": {0: "batch", 1: "enc_seq"},
188
+ "logits": {0: "batch", 1: "seq"},
189
+ },
190
+ opset_version=17, do_constant_folding=True, dynamo=False,
191
+ )
192
+ print(f" decoder: {dec_path}")
193
+
194
+ # Int8 quantization
195
+ if int8:
196
+ from onnxruntime.quantization import quantize_dynamic, QuantType
197
+ for part in ("encoder", "decoder"):
198
+ src = out_dir / f"{lang}_{part}.onnx"
199
+ dst = out_dir / f"{lang}_{part}_int8.onnx"
200
+ quantize_dynamic(str(src), str(dst), weight_type=QuantType.QInt8)
201
+ print(f" int8: {dst}")
202
+
203
+ # Parity check
204
+ s_enc = ort.InferenceSession(str(enc_path), providers=["CPUExecutionProvider"])
205
+ s_dec = ort.InferenceSession(str(dec_path), providers=["CPUExecutionProvider"])
206
+ with torch.no_grad():
207
+ e_t = enc_w(feats).numpy()
208
+ d_t = dec_w(ids, enc_hidden).numpy()
209
+ e_o = s_enc.run(None, {"feats": feats.numpy()})[0]
210
+ d_o = s_dec.run(None, {"input_ids": ids.numpy(), "enc_hidden": enc_hidden.numpy()})[0]
211
+ enc_diff = float(np.abs(e_t - e_o).max())
212
+ dec_diff = float(np.abs(d_t - d_o).max())
213
+ print(f" parity: enc={enc_diff:.2e} dec={dec_diff:.2e} {'OK' if enc_diff < 1e-3 and dec_diff < 1e-3 else 'FAIL'}")
214
+
215
+ return enc_path, dec_path
@@ -0,0 +1,205 @@
1
+ """High-level transcription API."""
2
+
3
+ import os
4
+ os.environ.setdefault("TRANSFORMERS_NO_ADVISORY_WARNINGS", "1")
5
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
6
+
7
+ import torch
8
+ import numpy as np
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Optional, List, Union
12
+
13
+ from polywhisper.model import PolyWhisper, AVAILABLE_LANGS
14
+ from polywhisper.audio import load_audio, audio_to_chunks, TARGET_SR
15
+
16
+
17
+ @dataclass
18
+ class Segment:
19
+ """A single transcription segment."""
20
+ text: str
21
+ start_sec: float
22
+ end_sec: float
23
+ tokens: List[int] = field(default_factory=list)
24
+
25
+
26
+ @dataclass
27
+ class TranscriptionResult:
28
+ """Full transcription result."""
29
+ text: str
30
+ lang: str
31
+ segments: List[Segment] = field(default_factory=list)
32
+ language_probs: dict = field(default_factory=dict)
33
+
34
+ @property
35
+ def duration_sec(self):
36
+ if not self.segments:
37
+ return 0.0
38
+ return self.segments[-1].end_sec
39
+
40
+ def to_dict(self):
41
+ return {
42
+ "text": self.text,
43
+ "lang": self.lang,
44
+ "segments": [
45
+ {"text": s.text, "start": s.start_sec, "end": s.end_sec}
46
+ for s in self.segments
47
+ ],
48
+ }
49
+
50
+
51
+ def _get_model(backbone="small", device="auto", model=None):
52
+ """Return a PolyWhisper instance, reusing if already loaded."""
53
+ if model is not None:
54
+ return model
55
+ return PolyWhisper(backbone=backbone, device=device)
56
+
57
+
58
+ def _prepare_features(model, audio):
59
+ """Convert audio array to model input features."""
60
+ feats = model.processor.feature_extractor(
61
+ [audio], sampling_rate=TARGET_SR, return_tensors="pt", padding=True
62
+ )["input_features"]
63
+ if feats.shape[-1] < 3000:
64
+ feats = torch.cat(
65
+ [feats, torch.zeros(1, 80, 3000 - feats.shape[-1], dtype=feats.dtype)], -1
66
+ )
67
+ return feats.to(model.device)
68
+
69
+
70
+ def _decode_tokens(model, tokens):
71
+ """Decode token IDs to text."""
72
+ return model.processor.decode(tokens, skip_special_tokens=True).strip()
73
+
74
+
75
+ def transcribe(
76
+ audio_input: Union[str, Path, np.ndarray],
77
+ lang: Optional[str] = None,
78
+ backbone: str = "small",
79
+ variant: str = "prod",
80
+ device: str = "auto",
81
+ max_new_tokens: int = 256,
82
+ num_beams: int = 1,
83
+ chunk_sec: float = 30.0,
84
+ model: Optional[PolyWhisper] = None,
85
+ language_probs: bool = False,
86
+ backend: str = "auto",
87
+ ) -> TranscriptionResult:
88
+ """Transcribe audio file or numpy array.
89
+
90
+ Args:
91
+ audio_input: file path (str/Path) or numpy float32 array at 16kHz.
92
+ lang: language code ("hi", "ta", "te", "bn", "mr"). If None, auto-detect.
93
+ backbone: whisper backbone ("small" recommended).
94
+ variant: adapter variant ("prod" or "base").
95
+ device: "auto", "cuda", "mps", or "cpu".
96
+ max_new_tokens: max generation length.
97
+ num_beams: beam width (1 = greedy).
98
+ chunk_sec: chunk long audio into this many seconds.
99
+ model: pre-loaded PolyWhisper instance (avoids re-loading).
100
+ language_probs: if lang is None, return probs for all languages.
101
+ backend: "auto" (ONNX if available, else torch), "torch", or "onnx".
102
+
103
+ Returns:
104
+ TranscriptionResult with text, segments, and metadata.
105
+ """
106
+ # Load audio
107
+ if isinstance(audio_input, (str, Path)):
108
+ audio, sr = load_audio(audio_input, TARGET_SR)
109
+ source = str(audio_input)
110
+ else:
111
+ audio = np.asarray(audio_input, dtype=np.float32)
112
+ source = "<array>"
113
+
114
+ # Try ONNX backend first (no torch needed, faster on CPU)
115
+ use_onnx = backend in ("onnx", "auto") and device in ("auto", "cpu")
116
+ if use_onnx:
117
+ try:
118
+ from polywhisper.onnx_backend import OnnxBackend
119
+ from polywhisper.model import _get_processor
120
+ processor = _get_processor(backbone)
121
+ onnx = OnnxBackend(lang=lang or "hi")
122
+ text = onnx.transcribe(audio_input, processor, max_new_tokens=max_new_tokens,
123
+ num_beams=num_beams, language=lang)
124
+ segments = [Segment(text=text, start_sec=0.0, end_sec=len(audio) / TARGET_SR)]
125
+ return TranscriptionResult(text=text, lang=lang or "hi", segments=segments)
126
+ except (FileNotFoundError, ImportError):
127
+ pass # fall through to torch backend
128
+
129
+ m = _get_model(backbone, device, model)
130
+
131
+ # Auto-detect language
132
+ if lang is None:
133
+ lang, probs = _detect_language(m, audio, max_new_tokens=max_new_tokens)
134
+ if language_probs:
135
+ probs_str = ", ".join(f"{k}:{v:.1%}" for k, v in sorted(probs.items(), key=lambda x: -x[1]))
136
+ print(f"Detected: {lang} ({probs_str})")
137
+ else:
138
+ probs = {}
139
+
140
+ # Load adapter
141
+ m.auto_load_adapter(lang, variant=variant)
142
+
143
+ # Chunk and transcribe
144
+ chunks = audio_to_chunks(audio, chunk_sec=chunk_sec)
145
+ segments = []
146
+ all_text = []
147
+
148
+ for chunk_audio, start_sec, end_sec in chunks:
149
+ feats = _prepare_features(m, chunk_audio)
150
+ out = m.generate(
151
+ feats, lang=lang, max_new_tokens=max_new_tokens,
152
+ num_beams=num_beams, use_cache=True, task="transcribe",
153
+ )
154
+ text = _decode_tokens(m, out[0])
155
+ if text:
156
+ segments.append(Segment(text=text, start_sec=start_sec, end_sec=end_sec))
157
+ all_text.append(text)
158
+
159
+ full_text = " ".join(all_text)
160
+ return TranscriptionResult(
161
+ text=full_text, lang=lang, segments=segments, language_probs=probs,
162
+ )
163
+
164
+
165
+ def _detect_language(model, audio, max_new_tokens=128):
166
+ """Run all adapters, pick the one with highest confidence."""
167
+ from polywhisper.model import ADAPTER_REGISTRY
168
+ from polywhisper.audio import TARGET_SR
169
+ import torch.nn.functional as F
170
+
171
+ feats = _prepare_features(model, audio)
172
+ scores = {}
173
+
174
+ for lang in ADAPTER_REGISTRY:
175
+ try:
176
+ model.auto_load_adapter(lang, variant="prod")
177
+ except FileNotFoundError:
178
+ try:
179
+ model.auto_load_adapter(lang, variant="base")
180
+ except FileNotFoundError:
181
+ continue
182
+
183
+ model.set_language(lang)
184
+ with torch.no_grad():
185
+ # Get decoder logits for the first few tokens
186
+ enc = model.whisper.model.encoder(feats).last_hidden_state
187
+ # Start with BOS + lang token
188
+ lang_token = model.processor.tokenizer.convert_tokens_to_ids(f"<|{lang}|>")
189
+ if lang_token is None:
190
+ continue
191
+ dec_input = torch.tensor([[50257, lang_token, 50359]], device=model.device)
192
+ logits = model.whisper.model.decoder(dec_input, encoder_hidden_states=enc).last_hidden_state
193
+ # Score: average log-prob of next-token predictions
194
+ logprobs = F.log_softmax(logits[0, -1], dim=-1)
195
+ top_k = logprobs.topk(5)
196
+ scores[lang] = top_k.values.mean().item()
197
+
198
+ if not scores:
199
+ return "hi", {}
200
+
201
+ # Normalize to probabilities
202
+ total = sum(np.exp(v) for v in scores.values())
203
+ probs = {k: np.exp(v) / total for k, v in sorted(scores.items(), key=lambda x: -x[1])}
204
+ best = max(scores, key=scores.get)
205
+ return best, probs
@@ -0,0 +1,309 @@
1
+ Metadata-Version: 2.5
2
+ Name: polywhisper
3
+ Version: 0.2.0
4
+ Summary: Efficient multilingual Indic ASR via frozen Whisper + per-language LoRA
5
+ Project-URL: Homepage, https://github.com/eulogik/PolyWhisper
6
+ Project-URL: Documentation, https://github.com/eulogik/PolyWhisper#readme
7
+ Project-URL: Repository, https://github.com/eulogik/PolyWhisper
8
+ Project-URL: Issues, https://github.com/eulogik/PolyWhisper/issues
9
+ Author: Eulogik
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: asr,bengali,hindi,indic,lora,marathi,multilingual,onnx,speech-recognition,tamil,telugu,whisper
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: numpy
26
+ Requires-Dist: resampy
27
+ Requires-Dist: soundfile
28
+ Requires-Dist: torch>=2.0
29
+ Requires-Dist: transformers>=4.30
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest; extra == 'dev'
32
+ Requires-Dist: ruff; extra == 'dev'
33
+ Provides-Extra: onnx
34
+ Requires-Dist: onnx>=1.15; extra == 'onnx'
35
+ Requires-Dist: onnxruntime>=1.16; extra == 'onnx'
36
+ Description-Content-Type: text/markdown
37
+
38
+ ---
39
+ language:
40
+ - hi
41
+ - ta
42
+ - te
43
+ - bn
44
+ - mr
45
+ license: mit
46
+ library_name: transformers
47
+ pipeline_tag: automatic-speech-recognition
48
+ base_model: openai/whisper-small
49
+ tags:
50
+ - polywhisper
51
+ - indic-asr
52
+ - hindi-asr
53
+ - tamil-speech-recognition
54
+ - telugu-stt
55
+ - bengali-asr
56
+ - marathi-speech-to-text
57
+ - speech-recognition
58
+ - multilingual
59
+ - lora
60
+ - whisper
61
+ - hindi
62
+ - tamil
63
+ - telugu
64
+ - bengali
65
+ - marathi
66
+ - indic-languages
67
+ - indian-languages
68
+ - automatic-speech-recognition
69
+ - speech-to-text
70
+ - low-resource-asr
71
+ - fleurs
72
+ - indicvoices
73
+ - onnx
74
+ - quantized
75
+ - efficient-asr
76
+ - edge-asr
77
+ - peft
78
+ datasets:
79
+ - ai4bharat/indicvoices-st
80
+ - google/fleurs
81
+ model-index:
82
+ - name: PolyWhisper v9 (Whisper-Small + Per-Language LoRA)
83
+ results:
84
+ - task:
85
+ type: automatic-speech-recognition
86
+ name: Hindi Speech Recognition
87
+ dataset:
88
+ name: FLEURS Hindi (hi_in)
89
+ type: google/fleurs
90
+ metrics:
91
+ - type: wer
92
+ value: 46.3
93
+ name: WER (beam=1, normalized)
94
+ - task:
95
+ type: automatic-speech-recognition
96
+ name: Tamil Speech Recognition
97
+ dataset:
98
+ name: FLEURS Tamil (ta_in)
99
+ type: google/fleurs
100
+ metrics:
101
+ - type: wer
102
+ value: 70.1
103
+ name: WER (beam=1, normalized)
104
+ - task:
105
+ type: automatic-speech-recognition
106
+ name: Telugu Speech Recognition
107
+ dataset:
108
+ name: FLEURS Telugu (te_in)
109
+ type: google/fleurs
110
+ metrics:
111
+ - type: wer
112
+ value: 100.1
113
+ name: WER (beam=1, normalized)
114
+ - task:
115
+ type: automatic-speech-recognition
116
+ name: Bengali Speech Recognition
117
+ dataset:
118
+ name: FLEURS Bengali (bn_in)
119
+ type: google/fleurs
120
+ metrics:
121
+ - type: wer
122
+ value: 130.2
123
+ name: WER (beam=1, normalized)
124
+ - task:
125
+ type: automatic-speech-recognition
126
+ name: Marathi Speech Recognition
127
+ dataset:
128
+ name: FLEURS Marathi (mr_in)
129
+ type: google/fleurs
130
+ metrics:
131
+ - type: wer
132
+ value: 96.7
133
+ name: WER (beam=1, normalized)
134
+ ---
135
+
136
+ [![Model](https://img.shields.io/badge/%F0%9F%A4%97%20Model-eulogik%2Fpolywhisper-ffd21e)](https://huggingface.co/eulogik/polywhisper)
137
+ [![GitHub](https://img.shields.io/badge/GitHub-eulogik%2FPolyWhisper-181717?style=flat&logo=github)](https://github.com/eulogik/PolyWhisper)
138
+ [![Release](https://img.shields.io/github/v/release/eulogik/PolyWhisper?label=release)](https://github.com/eulogik/PolyWhisper/releases)
139
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
140
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
141
+ [![PyTorch](https://img.shields.io/badge/PyTorch-2.0+-ee4c2c?logo=pytorch)](https://pytorch.org/)
142
+ [![ONNX](https://img.shields.io/badge/ONNX-Runtime-orange?logo=onnx)](https://onnxruntime.ai/)
143
+ ![Hindi](https://img.shields.io/badge/Hindi-hi-138808) ![Tamil](https://img.shields.io/badge/Tamil-ta-FF9933) ![Telugu](https://img.shields.io/badge/Telugu-te-046A38) ![Bengali](https://img.shields.io/badge/Bengali-bn-006A4E) ![Marathi](https://img.shields.io/badge/Marathi-mr-FF9933)
144
+
145
+ # ๐ŸŽ™๏ธ PolyWhisper v9 โ€” Efficient Multilingual Indic ASR
146
+
147
+ > **TL;DR:** PolyWhisper v9 is a production-ready automatic speech recognition (ASR) system for **Hindi, Tamil, Telugu, Bengali, and Marathi**. It pairs a **frozen OpenAI Whisper-Small backbone (244M params)** with tiny **per-language LoRA adapters (~14MB each)**. Bengali WER drops **โˆ’34.5%** and Marathi **โˆ’43.2%** versus the no-augmentation baseline โ€” at roughly **1% of the storage cost** of full fine-tuning.
148
+
149
+ ## โœจ Why PolyWhisper?
150
+
151
+ | | Full fine-tune (per language) | **PolyWhisper v9** |
152
+ |---|---|---|
153
+ | Storage per language | ~1.5 GB | **~14 MB (100ร— smaller)** |
154
+ | Backbone | retrained each time | **frozen once, shared by all 5** |
155
+ | Bengali (bn) FLEURS WER | 198.8 (baseline) | **130.2 (โˆ’34.5%)** |
156
+ | Marathi (mr) FLEURS WER | 170.1 (baseline) | **96.7 (โˆ’43.2%)** |
157
+ | Telugu (te) FLEURS WER | 105.9 (baseline) | **100.1 (โˆ’5.5%)** |
158
+ | Hindi (hi) FLEURS WER | 43.0 (baseline) | **46.3** |
159
+ | Tamil (ta) FLEURS WER | 68.2 (baseline) | **70.1** |
160
+ | CPU deployment | heavy | **ONNX INT8, no GPU needed** |
161
+
162
+ *WER = word error rate (lower is better). FLEURS test set, beam=1, punctuation-normalized scoring.*
163
+
164
+ ## ๐Ÿ“Š Benchmarks (FLEURS, beam=1, normalized WER)
165
+
166
+ | Language | Code | Script | v7 (no augment) | **v9 final** | ฮ” vs v7 |
167
+ |---|---|---|---|---|---|
168
+ | Hindi | `hi` | Devanagari | 43.0 | **46.3** | +7.7% |
169
+ | Tamil | `ta` | Tamil | 68.2 | **70.1** | +2.8% |
170
+ | Telugu | `te` | Telugu | 105.9 | **100.1** | โœ… **โˆ’5.5%** |
171
+ | Bengali | `bn` | Bengali | 198.8 | **130.2** | โœ… **โˆ’34.5%** |
172
+ | Marathi | `mr` | Devanagari | 170.1 | **96.7** | โœ… **โˆ’43.2%** |
173
+
174
+ ### ๐Ÿงช The v9 finding: augment per language, not globally
175
+
176
+ Training with SpecAugment + speed perturbation on **all** languages damaged Hindi/Tamil (token-loop degeneration) while massively helping Bengali/Marathi. The v9 recipe augments **only `bn`/`mr`** and trains `hi`/`ta` clean:
177
+
178
+ | Language | Augmentation | Result |
179
+ |---|---|---|
180
+ | Hindi, Tamil | none (clean) | matches no-augment baseline |
181
+ | Telugu, Bengali, Marathi | SpecAugment + 0.9ร—/1.1ร— speed perturb | large gains on hard languages |
182
+
183
+ ## ๐Ÿ“ฆ Which adapter should I use?
184
+
185
+ | Language | Adapter file | Backbone | WER |
186
+ |---|---|---|---|
187
+ | Hindi (`hi`) | `polywhisper_output_hi/adapters_v3/hi_best_clean.pt` | `openai/whisper-small` | 46.3 |
188
+ | Tamil (`ta`) | `polywhisper_output_ta/adapters_v3/ta_best_clean.pt` | `openai/whisper-small` | 70.1 |
189
+ | Telugu (`te`) | `polywhisper_output_gpu0/adapters_v3/te_best_prod.pt` | `openai/whisper-small` | 100.1 |
190
+ | Bengali (`bn`) | `polywhisper_output_gpu0/adapters_v3/bn_best_prod.pt` | `openai/whisper-small` | 130.2 |
191
+ | Marathi (`mr`) | `polywhisper_output_gpu1/adapters_v3/mr_best_prod.pt` | `openai/whisper-small` | 96.7 |
192
+
193
+ All adapters are rank-16 LoRA (decoder + encoder attention), ~14MB each. Backbone weights are **not** included โ€” they load from `openai/whisper-small` at runtime.
194
+
195
+ ## ๐Ÿš€ Quickstart
196
+
197
+ ```bash
198
+ pip install -e .
199
+ ```
200
+
201
+ ```bash
202
+ # Hindi speech to text
203
+ polywhisper transcribe audio.wav --lang hi
204
+
205
+ # Tamil with JSON output
206
+ polywhisper transcribe audio.wav --lang ta --format json
207
+
208
+ # Auto-detect language, SRT subtitles
209
+ polywhisper transcribe audio.wav --format srt > subs.srt
210
+
211
+ # Batch a folder
212
+ polywhisper batch ./audio_folder/ --lang bn --output results.json
213
+ ```
214
+
215
+ ```python
216
+ from polywhisper import transcribe
217
+
218
+ result = transcribe("audio.wav", lang="mr")
219
+ print(result.text)
220
+ print(result.segments) # timestamped segments
221
+ ```
222
+
223
+ ## ๐Ÿ–ฅ๏ธ CPU-only inference (ONNX Runtime)
224
+
225
+ Export INT8-quantized ONNX graphs (no PyTorch, no GPU needed at inference):
226
+
227
+ ```bash
228
+ polywhisper export --lang hi --variant prod --int8
229
+ ```
230
+
231
+ Pre-exported v9 graphs live under `export/onnx/` on the [Hub](https://huggingface.co/eulogik/polywhisper/tree/main/export/onnx) โ€” per language, fp32 + INT8:
232
+
233
+ | Lang | Encoder (fp32 / INT8) | Decoder (fp32 / INT8) |
234
+ |---|---|---|
235
+ | hi | 358MB / 97MB | 784MB / 204MB |
236
+ | ta | 358MB / 97MB | 784MB / 204MB |
237
+ | te | 358MB / 97MB | 784MB / 204MB |
238
+ | bn | 358MB / 97MB | 784MB / 204MB |
239
+ | mr | 358MB / 97MB | 784MB / 204MB |
240
+
241
+ Files are named `{lang}_{lang}_best_prod_{encoder,decoder}{,_int8}.onnx`. INT8 is ~4ร— smaller.
242
+
243
+ **Verification:** fp32 ONNX vs PyTorch max diff < 1e-3 on all five languages (encoder + decoder). End-to-end greedy spot-checks (FLEURS audio, beam=1):
244
+
245
+ | Lang | torch WER | ONNX INT8 WER |
246
+ |---|---|---|
247
+ | hi (10 samples) | 43.4% | 48.3% |
248
+ | ta (5 samples) | 100.0% | 100.0% |
249
+ | te (5 samples) | 100.0% | 101.6% |
250
+ | bn (5 samples) | 104.9% | 118.7% |
251
+ | mr (5 samples) | 82.9% | 89.4% |
252
+
253
+ *Spot-checks are tiny (5โ€“10 utterances) so single-sentence flips move the numbers; fp32 ONNX is at parity with torch. INT8 trades a few points for 4ร— smaller files.*
254
+
255
+ ## ๐Ÿ‹๏ธ Training recipe (reproducible)
256
+
257
+ - **Data:** [IndicVoices-ST](https://huggingface.co/datasets/ai4bharat/indicvoices-st) (~19โ€“20k clips/language) ยท **Eval:** [FLEURS](https://huggingface.co/datasets/google/fleurs)
258
+ - **Backbone:** `openai/whisper-small`, frozen ยท **Adapters:** LoRA rank-16, encoder + decoder attention
259
+ - **Schedule:** 3โ€“5 epochs/language, batch 4, AdamW, cosine LR (peak 1e-4), 2ร— NVIDIA T4
260
+ - **Augmentation (v9):** SpecAugment + speed perturb for `bn`/`mr` only; `hi`/`ta`/`te` clean
261
+ - **Selection:** WER-gated checkpoints (`*_best_*.pt`) on FLEURS dev slices
262
+ - **Code:** [`train_v3.py`](https://github.com/eulogik/PolyWhisper/blob/main/train_v3.py) ยท orchestrator [`kaggle_train_resumable.py`](https://github.com/eulogik/PolyWhisper/blob/main/kaggle_train_resumable.py) ยท scoring [`normalize_ortho.py`](https://github.com/eulogik/PolyWhisper/blob/main/normalize_ortho.py)
263
+
264
+ ## โ“ FAQ
265
+
266
+ **What is PolyWhisper?**
267
+ PolyWhisper is an open-source Indic ASR toolkit: one frozen Whisper-Small backbone plus five small per-language LoRA adapters covering Hindi, Tamil, Telugu, Bengali, and Marathi.
268
+
269
+ **How is it different from fine-tuning Whisper?**
270
+ Full fine-tuning rewrites ~244Mโ€“1.5B weights per language. PolyWhisper freezes the backbone and trains ~3.5M LoRA parameters per language (~14MB), so five languages ship for the storage cost of a rounding error.
271
+
272
+ **Which languages are production-ready?**
273
+ All five ship working adapters. Hindi (46.3 WER) and Tamil (70.1) are strongest; Bengali and Marathi improved dramatically in v9 (โˆ’34.5% / โˆ’43.2% vs baseline) but remain the hardest languages.
274
+
275
+ **Can I run it on CPU?**
276
+ Yes โ€” export to ONNX INT8 and run with ONNX Runtime, no GPU required.
277
+
278
+ **Can I run it on a Mac?**
279
+ Yes โ€” PyTorch MPS is supported (`Device: mps`), plus CPU via ONNX.
280
+
281
+ **What data was it trained/evaluated on?**
282
+ Trained on IndicVoices-ST conversational speech, evaluated on FLEURS read speech with punctuation-normalized, script-aware scoring.
283
+
284
+ ## โš ๏ธ Limitations
285
+
286
+ - Absolute WER on Telugu/Bengali/Marathi is still high โ€” usable for assistive/search/subtitle-draft workflows, not verbatim legal/medical transcription.
287
+ - Evaluated on read speech (FLEURS); spontaneous conversational accuracy will differ.
288
+ - Beam=1 numbers above; beam=5 decoding improves results at higher latency.
289
+
290
+ ## ๐Ÿ“„ License & citation
291
+
292
+ Apache 2.0. Whisper weights ยฉ OpenAI. Training data: IndicVoices-ST (CC-BY) ยท Eval: FLEURS (CC-BY).
293
+
294
+ ```bibtex
295
+ @misc{polywhisper2026,
296
+ title = {PolyWhisper: Efficient Multilingual Indic ASR via Frozen Backbone + Per-Language LoRA},
297
+ author = {Eulogik},
298
+ year = {2026},
299
+ publisher = {HuggingFace},
300
+ url = {https://huggingface.co/eulogik/polywhisper}
301
+ }
302
+ ```
303
+
304
+ ## ๐Ÿ”— Links
305
+
306
+ - ๐Ÿค— Model: [huggingface.co/eulogik/polywhisper](https://huggingface.co/eulogik/polywhisper)
307
+ - ๐Ÿ’ป Code: [github.com/eulogik/PolyWhisper](https://github.com/eulogik/PolyWhisper)
308
+ - ๐Ÿ—ฃ๏ธ Train data: [ai4bharat/indicvoices-st](https://huggingface.co/datasets/ai4bharat/indicvoices-st)
309
+ - ๐Ÿงช Eval data: [google/fleurs](https://huggingface.co/datasets/google/fleurs)
@@ -0,0 +1,12 @@
1
+ polywhisper/__init__.py,sha256=vDnGpzmeWV2WoULg72CuAfvQ-cNf_WWJtYihEX1KnRc,235
2
+ polywhisper/__main__.py,sha256=Gsf4y5lgUzui6-4PrYwWfGwZRoYRFJhVj5f-wi8aAeo,85
3
+ polywhisper/audio.py,sha256=4OFpJIs3LH2LbwlVX6PaxSvlWO8r70KiX7MdzEB1uOs,2261
4
+ polywhisper/cli.py,sha256=YRHGHCCofXeYhuXcR2LGOA9ZWCtt7SkRt0EOEs3o5ks,8374
5
+ polywhisper/model.py,sha256=jLc1b0LPkfNwFxH5ycQWxvi_IylIeLWzahWup_qguaE,9567
6
+ polywhisper/onnx_backend.py,sha256=nbUcDVb2rpA0rbqmzWjOEM3Qafh2DSDTU0U1kSoQvxg,7889
7
+ polywhisper/transcribe.py,sha256=D6UcK7QUHQMJXDp9AMrM8VtlWj8n9xeFq_btvp5rV3Y,7177
8
+ polywhisper-0.2.0.dist-info/METADATA,sha256=yOw05KVIcUrsVF1K1A85J5An7_KaVZMlHNaBZM7164Q,12763
9
+ polywhisper-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ polywhisper-0.2.0.dist-info/entry_points.txt,sha256=iBrqtTyS0CGIDyHlkd96O8EkgxRhzN2SulXUC4uefKk,53
11
+ polywhisper-0.2.0.dist-info/licenses/LICENSE,sha256=4bt-7Tb8w5uJ6opej7_pcNbLsqhfXSHvxDKW1YEivSE,1064
12
+ polywhisper-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ polywhisper = polywhisper.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eulogik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.