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.
- polywhisper/__init__.py +7 -0
- polywhisper/__main__.py +3 -0
- polywhisper/audio.py +65 -0
- polywhisper/cli.py +229 -0
- polywhisper/model.py +241 -0
- polywhisper/onnx_backend.py +215 -0
- polywhisper/transcribe.py +205 -0
- polywhisper-0.2.0.dist-info/METADATA +309 -0
- polywhisper-0.2.0.dist-info/RECORD +12 -0
- polywhisper-0.2.0.dist-info/WHEEL +4 -0
- polywhisper-0.2.0.dist-info/entry_points.txt +2 -0
- polywhisper-0.2.0.dist-info/licenses/LICENSE +21 -0
polywhisper/__init__.py
ADDED
polywhisper/__main__.py
ADDED
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
|
+
[](https://huggingface.co/eulogik/polywhisper)
|
|
137
|
+
[](https://github.com/eulogik/PolyWhisper)
|
|
138
|
+
[](https://github.com/eulogik/PolyWhisper/releases)
|
|
139
|
+
[](https://opensource.org/licenses/MIT)
|
|
140
|
+
[](https://www.python.org/downloads/)
|
|
141
|
+
[](https://pytorch.org/)
|
|
142
|
+
[](https://onnxruntime.ai/)
|
|
143
|
+
    
|
|
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,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.
|