oddadmix commited on
Commit
4cdd2cf
·
verified ·
1 Parent(s): a9b856e

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. app.py +56 -17
  2. static/app.js +17 -2
  3. static/index.html +4 -2
  4. static/style.css +8 -0
app.py CHANGED
@@ -1,4 +1,4 @@
1
- """Nawah-ASR-50M demo.
2
 
3
  The browser does all audio handling: decodeAudioData accepts wav/mp3/m4a/webm, resamples to
4
  16 kHz mono and posts raw float32 samples. So this server needs no ffmpeg, no soundfile, and no
@@ -7,8 +7,12 @@ format guessing -- it only ever sees the array the model wants.
7
  The audio token count is per-clip, not fixed: `<audio>` is repeated exactly
8
  `_get_feat_extract_output_lengths(...)` times, which is 25 per second. A 3-second clip costs 75
9
  tokens rather than the 375 a fixed 30-second window would spend on silence.
 
 
 
 
10
  """
11
- import os, time
12
 
13
  import numpy as np
14
  import torch
@@ -19,20 +23,47 @@ from pydantic import BaseModel
19
  from transformers import (AutoTokenizer, Qwen2AudioForConditionalGeneration,
20
  WhisperFeatureExtractor)
21
 
22
- REPO = os.environ.get("ASR_MODEL", "oddadmix/Nawah-ASR-89M-v1")
 
 
 
 
 
 
 
 
 
 
23
  TOKEN = os.environ.get("MODEL_HF_TOKEN") or os.environ.get("HF_TOKEN")
24
  SR, MAX_SEC = 16000, 30
25
 
26
- print(f"[*] loading {REPO}", flush=True)
27
- tok = AutoTokenizer.from_pretrained(REPO, token=TOKEN)
28
- fe = WhisperFeatureExtractor.from_pretrained(REPO, token=TOKEN)
29
- model = Qwen2AudioForConditionalGeneration.from_pretrained(REPO, token=TOKEN,
30
- dtype=torch.float32).eval()
31
  torch.set_num_threads(int(os.environ.get("OMP_NUM_THREADS", 4)))
32
- CV = tok.convert_tokens_to_ids
33
- AUDIO, A_START, A_END = CV("<audio>"), CV("<|audio_start|>"), CV("<|audio_end|>")
34
- PARAMS = sum(p.numel() for p in model.parameters())
35
- print(f"[+] {PARAMS/1e6:.1f}M params ready", flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  app = FastAPI()
38
 
@@ -40,15 +71,18 @@ app = FastAPI()
40
  class Req(BaseModel):
41
  samples: list[float] = []
42
  max_new: int = 96
 
43
 
44
 
45
  @torch.no_grad()
46
- def run(wav: np.ndarray, max_new: int):
 
 
47
  wav = wav[: SR * MAX_SEC]
48
  feats = fe([wav], sampling_rate=SR, return_attention_mask=True, return_tensors="pt")
49
  _, n = model.model.audio_tower._get_feat_extract_output_lengths(feats.attention_mask.sum(-1))
50
  n = int(n[0])
51
- ids = torch.tensor([[tok.bos_token_id, A_START] + [AUDIO] * n + [A_END]])
52
  t0 = time.perf_counter()
53
  out = model.generate(input_ids=ids, attention_mask=torch.ones_like(ids),
54
  input_features=feats.input_features,
@@ -60,7 +94,8 @@ def run(wav: np.ndarray, max_new: int):
60
  dur = len(wav) / SR
61
  return {"text": text, "audio_tokens": n, "duration": round(dur, 2),
62
  "ms": round(ms), "rtf": round(dur * 1000 / max(ms, 1), 1),
63
- "new_tokens": int(out.shape[1] - ids.shape[1])}
 
64
 
65
 
66
  @app.get("/")
@@ -70,7 +105,11 @@ def index():
70
 
71
  @app.get("/api/ready")
72
  def ready():
73
- return {"ready": True, "model": REPO, "params": PARAMS, "max_sec": MAX_SEC}
 
 
 
 
74
 
75
 
76
  @app.post("/api/asr")
@@ -80,7 +119,7 @@ def asr(req: Req):
80
  wav = np.asarray(req.samples, dtype=np.float32)
81
  if wav.size < SR // 4:
82
  return JSONResponse({"error": "clip too short"}, status_code=400)
83
- return JSONResponse(run(wav, max(8, min(req.max_new, 200))))
84
 
85
 
86
  app.mount("/static", StaticFiles(directory="static"), name="static")
 
1
+ """Nawah-ASR demo — pick between the 89M and 157M models.
2
 
3
  The browser does all audio handling: decodeAudioData accepts wav/mp3/m4a/webm, resamples to
4
  16 kHz mono and posts raw float32 samples. So this server needs no ffmpeg, no soundfile, and no
 
7
  The audio token count is per-clip, not fixed: `<audio>` is repeated exactly
8
  `_get_feat_extract_output_lengths(...)` times, which is 25 per second. A 3-second clip costs 75
9
  tokens rather than the 375 a fixed 30-second window would spend on silence.
10
+
11
+ Models load LAZILY and are cached. Only the default is loaded at boot, so adding the second one
12
+ costs nothing in start-up time or RAM until somebody actually selects it -- these are fp32 CPU
13
+ weights (356 MB + 627 MB) and this Space has no GPU to hide the cost.
14
  """
15
+ import os, threading, time
16
 
17
  import numpy as np
18
  import torch
 
23
  from transformers import (AutoTokenizer, Qwen2AudioForConditionalGeneration,
24
  WhisperFeatureExtractor)
25
 
26
+ MODELS = {
27
+ "89m": {"repo": os.environ.get("ASR_MODEL", "oddadmix/Nawah-ASR-89M-v1"),
28
+ "label": "Nawah-ASR-89M-v1 · Whisper-base · 89M",
29
+ "note": "MASC + WorldSpeech (1681 h) · MASC WER 0.3372"},
30
+ "157m": {"repo": os.environ.get("ASR_MODEL_SMALL", "oddadmix/Nawah-ASR-50M-v2"),
31
+ "label": "Nawah-ASR-50M-v2 · Whisper-small · 157M",
32
+ "note": "MASC (866 h) · MASC WER 0.3614"},
33
+ }
34
+ DEFAULT = os.environ.get("ASR_DEFAULT", "89m")
35
+ if DEFAULT not in MODELS:
36
+ DEFAULT = "89m"
37
  TOKEN = os.environ.get("MODEL_HF_TOKEN") or os.environ.get("HF_TOKEN")
38
  SR, MAX_SEC = 16000, 30
39
 
 
 
 
 
 
40
  torch.set_num_threads(int(os.environ.get("OMP_NUM_THREADS", 4)))
41
+ _loaded, _lock = {}, threading.Lock()
42
+
43
+
44
+ def get_model(key: str):
45
+ """Load-and-cache. The lock matters: FastAPI runs sync endpoints in a threadpool, so two
46
+ first-requests for the same model would otherwise both pay the load."""
47
+ key = key if key in MODELS else DEFAULT
48
+ with _lock:
49
+ if key not in _loaded:
50
+ repo = MODELS[key]["repo"]
51
+ print(f"[*] loading {repo}", flush=True)
52
+ tok = AutoTokenizer.from_pretrained(repo, token=TOKEN)
53
+ fe = WhisperFeatureExtractor.from_pretrained(repo, token=TOKEN)
54
+ model = Qwen2AudioForConditionalGeneration.from_pretrained(
55
+ repo, token=TOKEN, dtype=torch.float32).eval()
56
+ cv = tok.convert_tokens_to_ids
57
+ _loaded[key] = {
58
+ "tok": tok, "fe": fe, "model": model, "repo": repo,
59
+ "audio": cv("<audio>"), "start": cv("<|audio_start|>"), "end": cv("<|audio_end|>"),
60
+ "params": sum(p.numel() for p in model.parameters()),
61
+ }
62
+ print(f"[+] {repo} ready ({_loaded[key]['params']/1e6:.1f}M params)", flush=True)
63
+ return _loaded[key]
64
+
65
+
66
+ get_model(DEFAULT) # warm the default so the first request is not a cold load
67
 
68
  app = FastAPI()
69
 
 
71
  class Req(BaseModel):
72
  samples: list[float] = []
73
  max_new: int = 96
74
+ model: str = DEFAULT
75
 
76
 
77
  @torch.no_grad()
78
+ def run(wav: np.ndarray, max_new: int, key: str):
79
+ b = get_model(key)
80
+ tok, fe, model = b["tok"], b["fe"], b["model"]
81
  wav = wav[: SR * MAX_SEC]
82
  feats = fe([wav], sampling_rate=SR, return_attention_mask=True, return_tensors="pt")
83
  _, n = model.model.audio_tower._get_feat_extract_output_lengths(feats.attention_mask.sum(-1))
84
  n = int(n[0])
85
+ ids = torch.tensor([[tok.bos_token_id, b["start"]] + [b["audio"]] * n + [b["end"]]])
86
  t0 = time.perf_counter()
87
  out = model.generate(input_ids=ids, attention_mask=torch.ones_like(ids),
88
  input_features=feats.input_features,
 
94
  dur = len(wav) / SR
95
  return {"text": text, "audio_tokens": n, "duration": round(dur, 2),
96
  "ms": round(ms), "rtf": round(dur * 1000 / max(ms, 1), 1),
97
+ "new_tokens": int(out.shape[1] - ids.shape[1]),
98
+ "model": b["repo"], "params": b["params"]}
99
 
100
 
101
  @app.get("/")
 
105
 
106
  @app.get("/api/ready")
107
  def ready():
108
+ return {"ready": True, "default": DEFAULT, "max_sec": MAX_SEC,
109
+ "models": [{"key": k, "label": v["label"], "repo": v["repo"], "note": v["note"],
110
+ "loaded": k in _loaded,
111
+ "params": _loaded[k]["params"] if k in _loaded else None}
112
+ for k, v in MODELS.items()]}
113
 
114
 
115
  @app.post("/api/asr")
 
119
  wav = np.asarray(req.samples, dtype=np.float32)
120
  if wav.size < SR // 4:
121
  return JSONResponse({"error": "clip too short"}, status_code=400)
122
+ return JSONResponse(run(wav, max(8, min(req.max_new, 200)), req.model))
123
 
124
 
125
  app.mount("/static", StaticFiles(directory="static"), name="static")
static/app.js CHANGED
@@ -38,7 +38,7 @@ async function send(samples) {
38
  try {
39
  const r = await fetch("/api/asr", {
40
  method: "POST", headers: { "Content-Type": "application/json" },
41
- body: JSON.stringify({ samples: Array.from(samples) })
42
  });
43
  const d = await r.json();
44
  if (d.error) { $("hint").textContent = d.error; return; }
@@ -52,6 +52,7 @@ async function send(samples) {
52
  <span>اتولد <b>${d.new_tokens}</b> token</span>
53
  <span>الموديل <b>${d.ms}ms</b> (${d.rtf}× realtime)</span>
54
  <span>كلي <b>${wall}ms</b></span>
 
55
  </div>
56
  </div>`);
57
  $("hint").textContent = "جاهز";
@@ -83,7 +84,21 @@ $("rec").addEventListener("click", async () => {
83
  $("hint").textContent = "بيسجّل… اتكلم بالعربي";
84
  });
85
 
 
 
 
 
 
 
86
  fetch("/api/ready").then(r => r.json()).then(d => {
87
- $("meta").textContent = `${d.model} · ${(d.params / 1e6).toFixed(1)}M params · CPU · لحد ${d.max_sec}s`;
 
 
 
 
 
 
 
 
88
  $("hint").textContent = "اضغط سجّل أو ارفع ملف";
89
  }).catch(() => { $("hint").textContent = "الموديل لسه بيحمّل…"; });
 
38
  try {
39
  const r = await fetch("/api/asr", {
40
  method: "POST", headers: { "Content-Type": "application/json" },
41
+ body: JSON.stringify({ samples: Array.from(samples), model: $("model").value })
42
  });
43
  const d = await r.json();
44
  if (d.error) { $("hint").textContent = d.error; return; }
 
52
  <span>اتولد <b>${d.new_tokens}</b> token</span>
53
  <span>الموديل <b>${d.ms}ms</b> (${d.rtf}× realtime)</span>
54
  <span>كلي <b>${wall}ms</b></span>
55
+ <span>الموديل <b>${(d.model || "").split("/").pop()}</b></span>
56
  </div>
57
  </div>`);
58
  $("hint").textContent = "جاهز";
 
84
  $("hint").textContent = "بيسجّل… اتكلم بالعربي";
85
  });
86
 
87
+ function describe(m) {
88
+ // A model not yet loaded says so: it is lazy, so the first request that picks it pays a
89
+ // one-off CPU load of several hundred MB and would otherwise just look like a hang.
90
+ return m.loaded ? m.note : m.note + " · بيتحمّل عند أول استخدام";
91
+ }
92
+
93
  fetch("/api/ready").then(r => r.json()).then(d => {
94
+ const sel = $("model");
95
+ sel.innerHTML = d.models.map(m =>
96
+ `<option value="${m.key}" ${m.key === d.default ? "selected" : ""}>${m.label}</option>`).join("");
97
+ const meta = () => {
98
+ const m = d.models.find(x => x.key === sel.value) || d.models[0];
99
+ $("meta").textContent = `${m.repo} · ${describe(m)} · CPU · لحد ${d.max_sec}s`;
100
+ };
101
+ sel.addEventListener("change", meta);
102
+ meta();
103
  $("hint").textContent = "اضغط سجّل أو ارفع ملف";
104
  }).catch(() => { $("hint").textContent = "الموديل لسه بيحمّل…"; });
static/index.html CHANGED
@@ -9,6 +9,7 @@
9
  <div class="bar">
10
  <button class="go" id="rec" type="button">🎙️ سجّل</button>
11
  <label class="up">📂 ارفع ملف صوت<input id="file" type="file" accept="audio/*" hidden></label>
 
12
  <span class="hint" id="hint">اضغط سجّل أو ارفع ملف (لحد ٣٠ ثانية)</span>
13
  </div>
14
  <div class="wave" id="wave"></div>
@@ -17,8 +18,9 @@
17
  <section id="out"></section>
18
 
19
  <section class="note">
20
- <b>ملاحظة:</b> ده v1، متدرب على ٦٠ ساعة بس الـ WER ٠٫٦١ وده أضعف من Whisper المتدرب.
21
- الهدف إثبات إن LLM صغير بالعربي يقدر يقرا صوت. نسخة ٣٠٠ ساعة تحت التدريب.
 
22
  </section>
23
  </div>
24
  <script src="/static/app.js"></script></body></html>
 
9
  <div class="bar">
10
  <button class="go" id="rec" type="button">🎙️ سجّل</button>
11
  <label class="up">📂 ارفع ملف صوت<input id="file" type="file" accept="audio/*" hidden></label>
12
+ <select class="sel" id="model" title="اختار الموديل"></select>
13
  <span class="hint" id="hint">اضغط سجّل أو ارفع ملف (لحد ٣٠ ثانية)</span>
14
  </div>
15
  <div class="wave" id="wave"></div>
 
18
  <section id="out"></section>
19
 
20
  <section class="note">
21
+ <b>ملاحظة:</b> فيه موديلين تقدر تختار بينهم من فوق. الـ <b>89M</b> أصغر وأحسن متدرب على
22
+ ١٦٨١ ساعة (MASC + WorldSpeech) و الـ WER بتاعه ٠٫٣٣٧ على MASC، مقابل ٠٫٣٦١ للـ <b>157M</b>
23
+ اللي متدرب على ٨٦٦ ساعة MASC بس. اللهجات المحكية لسه صعبة على الاتنين.
24
  </section>
25
  </div>
26
  <script src="/static/app.js"></script></body></html>
static/style.css CHANGED
@@ -24,3 +24,11 @@ button,.up{font:inherit;border-radius:10px;padding:9px 16px;cursor:pointer;
24
  font-family:ui-monospace,monospace;border-top:1px solid var(--rule);padding-top:10px}
25
  .stats b{color:var(--ink);font-weight:600}
26
  .note{color:var(--dim);font-size:13px;border-right:3px solid var(--warn);padding:2px 12px}
 
 
 
 
 
 
 
 
 
24
  font-family:ui-monospace,monospace;border-top:1px solid var(--rule);padding-top:10px}
25
  .stats b{color:var(--ink);font-weight:600}
26
  .note{color:var(--dim);font-size:13px;border-right:3px solid var(--warn);padding:2px 12px}
27
+
28
+ /* model picker sits in the same bar as record/upload */
29
+ .sel {
30
+ font: inherit; padding: .55rem .7rem; border-radius: 10px;
31
+ border: 1px solid rgba(255, 255, 255, .18);
32
+ background: rgba(255, 255, 255, .06); color: inherit; cursor: pointer; max-width: 100%;
33
+ }
34
+ .sel:hover { border-color: rgba(255, 255, 255, .32); }