anwar-dev's picture
Bayan Arabic TTS: MagpieTTS 357M + LoRA (ROCm fine-tune)
5125634 verified
Raw History Blame Contribute Delete
20.2 kB
#!/usr/bin/env python3
"""Build compare_models/index.html from metrics.json and the audio folders."""
from __future__ import annotations
import html
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
METRICS = json.loads((ROOT / "metrics.json").read_text(encoding="utf-8"))
TEXTS = [ln.strip() for ln in (ROOT / "texts.txt").read_text(encoding="utf-8").splitlines() if ln.strip()]
CATEGORIES = ["أخبار", "علوم", "سؤال", "أرقام", "فصحى طويلة"]
MODELS = [
dict(key="bayan", name="Bayan (صوت مراد)", short="Bayan", params="357M", arabic="فصحى + مصرية",
clone="مدمج (تدريب)", lic="NVIDIA Open", lic_url="../NOTICE.md", rtf=0.99,
hf="", tested=True, ours=True),
dict(key="chatterbox", name="Chatterbox Multilingual v2", short="Chatterbox", params="0.5B", arabic="23 لغة",
clone="zero-shot", lic="MIT", lic_url="https://huggingface.co/ResembleAI/chatterbox", rtf=1.06,
hf="https://huggingface.co/ResembleAI/chatterbox", tested=True),
dict(key="xtts", name="XTTS-v2", short="XTTS-v2", params="~467M", arabic="17 لغة",
clone="zero-shot", lic="CPML (غير تجاري)", lic_url="https://coqui.ai/cpml", rtf=0.31,
hf="https://huggingface.co/coqui/XTTS-v2", tested=True),
dict(key="mms", name="MMS-TTS Arabic", short="MMS", params="~36M", arabic="فصحى",
clone="لا", lic="CC-BY-NC 4.0", lic_url="https://huggingface.co/facebook/mms-tts-ara", rtf=0.03,
hf="https://huggingface.co/facebook/mms-tts-ara", tested=True),
dict(key="magpie", name="MagpieTTS الأصلي (Jason)", short="MagpieTTS", params="357M", arabic="فصحى + مصرية",
clone="أصوات مدمجة", lic="NVIDIA Open", lic_url="https://huggingface.co/nvidia/magpie_tts_multilingual_357m", rtf=1.03,
hf="https://huggingface.co/nvidia/magpie_tts_multilingual_357m", tested=True),
]
BY_KEY = {m["key"]: m for m in MODELS}
ROW_ORDER = ["bayan", "chatterbox", "xtts", "mms", "magpie"]
POPULAR = [
("hexgrad/Kokoro-82M", "Kokoro-82M", "82M", "10.5M", "7,243", "لا — 8 لغات", "Apache-2.0", False),
("coqui/XTTS-v2", "XTTS-v2", "~467M", "6.3M", "3,901", "نعم — 17 لغة", "CPML", True),
("Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", "Qwen3-TTS 1.7B", "1.7B", "2.0M", "2,154", "لا — 10 لغات", "Apache-2.0", False),
("ResembleAI/chatterbox", "Chatterbox Multilingual", "0.5B", "1.77M", "1,858", "نعم — 23 لغة", "MIT", True),
("openbmb/VoxCPM2", "VoxCPM2", "2B", "902k", "1,705", "نعم — 30 لغة", "Apache-2.0", False),
("SWivid/F5-TTS", "F5-TTS", "336M", "887k", "1,219", "لا رسمياً — en/zh", "CC-BY-NC 4.0", False),
("microsoft/VibeVoice-1.5B", "VibeVoice-1.5B", "1.5B", "483k", "2,502", "لا — en/zh", "MIT", False),
("sesame/csm-1b", "CSM-1B", "1B", "129k", "2,481", "لا — إنجليزي", "Apache-2.0", False),
("nvidia/magpie_tts_multilingual_357m", "MagpieTTS 357M", "357M", "10.7k", "231", "نعم", "NVIDIA Open", True),
("facebook/mms-tts-ara", "MMS-TTS Arabic", "~36M", "6.5k", "24", "نعم", "CC-BY-NC 4.0", True),
("fishaudio/fish-speech-1.5", "Fish Speech 1.5", "~0.5B", "3.9k", "779", "نعم — 13 لغة", "CC-BY-NC-SA", False),
]
def pct(x: float) -> str:
return f"{x * 100:.2f}%"
def badge_class(cer_norm: float) -> str:
if cer_norm < 0.005:
return "ok"
if cer_norm < 0.03:
return "warn"
return "bad"
def badge_text(cer_norm: float) -> str:
if cer_norm < 0.005:
return "مطابق"
return f"CER* {cer_norm * 100:.1f}%"
def aggregate_table() -> str:
rows = []
for key in ROW_ORDER:
m = BY_KEY[key]
r = METRICS[key]
name = f'<a href="{m["hf"]}" target="_blank" rel="noopener">{html.escape(m["name"])}</a>' if m["hf"] else f'<b>{html.escape(m["name"])}</b>'
ours = " class=\"ours\"" if m.get("ours") else ""
rows.append(
f"<tr{ours}><td dir=\"auto\">{name}</td><td>{m['params']}</td>"
f"<td class=\"main\">{pct(r['mean_cer_norm'])}</td><td>{pct(r['mean_cer'])}</td>"
f"<td>{pct(r['mean_wer'])}</td><td>{r['mean_sim']:.3f}</td><td>{m['rtf']:.2f}</td>"
f"<td dir=\"auto\"><a href=\"{m['lic_url']}\" target=\"_blank\" rel=\"noopener\">{html.escape(m['lic'])}</a></td></tr>"
)
return "\n".join(rows)
def sentence_cards() -> str:
cards = []
for i, text in enumerate(TEXTS):
players = []
for key in ROW_ORDER:
m = BY_KEY[key]
r = METRICS[key]["samples"][i]
players.append(
f"""<div class="player">
<div class="ph"><span class="pn">{html.escape(m['short'])}</span><span class="tag {badge_class(r['cer_norm'])}">{badge_text(r['cer_norm'])}</span></div>
<audio controls preload="none" src="audio/{key}/sample_{i:02d}.wav"></audio>
</div>"""
)
hyp_rows = "\n".join(
f"<tr><td>{html.escape(BY_KEY[k]['short'])}</td><td dir=\"rtl\">{html.escape(METRICS[k]['samples'][i]['hyp'])}</td></tr>"
for k in ROW_ORDER
)
cards.append(
f"""<article class="sentence">
<div class="sh"><span class="num">{i + 1:02d}</span><span class="cat">{CATEGORIES[i]}</span></div>
<p class="st">{html.escape(text)}</p>
<div class="players">{''.join(players)}</div>
<details class="hyp"><summary>ما سمعه Whisper من كل نظام</summary>
<table><thead><tr><th>النظام</th><th>النص المُلتقط</th></tr></thead><tbody>{hyp_rows}</tbody></table>
</details>
</article>"""
)
return "\n".join(cards)
def popular_table() -> str:
rows = []
for repo, name, params, dl, likes, ar, lic, tested in POPULAR:
mark = '<span class="yes">✓</span>' if tested else '<span class="no">—</span>'
rows.append(
f"<tr><td dir=\"auto\"><a href=\"https://huggingface.co/{repo}\" target=\"_blank\" rel=\"noopener\">{html.escape(name)}</a></td>"
f"<td>{params}</td><td dir=\"ltr\">{dl}</td><td dir=\"ltr\">{likes}</td><td>{ar}</td><td dir=\"auto\">{html.escape(lic)}</td><td>{mark}</td></tr>"
)
return "\n".join(rows)
CSS = """
:root{--bg:#0b1020;--panel:#141b31;--panel2:#1b2440;--text:#e8ecf7;--muted:#9aa7c7;
--accent:#5ee0b5;--accent2:#7aa2ff;--line:#26304d;--warn:#ffcf6e;--bad:#ff7a90}
*{box-sizing:border-box}
body{margin:0;background:radial-gradient(1200px 600px at 80% -10%,#1d2a55 0%,var(--bg) 55%);
color:var(--text);font-family:"Noto Naskh Arabic","Segoe UI",Tahoma,Arial,sans-serif;line-height:1.8}
a,a:visited{color:var(--accent2);text-decoration:none}a:hover{text-decoration:underline}
.wrap{max-width:1100px;margin:0 auto;padding:40px 22px 70px}
header{border:1px solid var(--line);background:linear-gradient(160deg,#182242,#101830 70%);
border-radius:22px;padding:34px 30px;position:relative;overflow:hidden}
header:before{content:"";position:absolute;inset:auto -60px -120px auto;width:320px;height:320px;
background:radial-gradient(circle,#5ee0b544,transparent 65%);filter:blur(10px)}
.eyebrow{color:var(--accent);font-size:13px;letter-spacing:.4px;margin:0 0 8px}
h1{margin:0 0 8px;font-size:clamp(27px,4vw,42px);letter-spacing:-.5px}
.sub{color:var(--muted);margin:0 0 18px;font-size:17.5px;max-width:850px}
.badges{display:flex;flex-wrap:wrap;gap:8px;position:relative;z-index:1}
.badge{border:1px solid var(--line);background:#0f1730;color:var(--muted);padding:5px 12px;border-radius:999px;font-size:13px}
.badge b{color:var(--text);font-weight:600}
section{margin-top:34px}
h2{font-size:22px;margin:0 0 14px;display:flex;align-items:center;gap:10px}
h2:before{content:"";width:7px;height:22px;border-radius:4px;background:linear-gradient(180deg,var(--accent),var(--accent2))}
.grid{display:grid;gap:14px}
.g4{grid-template-columns:repeat(auto-fit,minmax(190px,1fr))}
.card{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:18px}
.metric{font-size:30px;font-weight:700;color:var(--accent);letter-spacing:-.5px}
.metric.blue{color:var(--accent2)}
.label{color:var(--muted);font-size:14px}
.note{color:var(--muted);font-size:13.5px;margin-top:8px}
.scroll{overflow-x:auto;border:1px solid var(--line);border-radius:16px;background:var(--panel)}
table{width:100%;border-collapse:collapse;font-size:14.5px;min-width:760px}
th,td{padding:11px 12px;border-bottom:1px solid var(--line);text-align:right;white-space:normal}
th{color:var(--muted);font-weight:600;font-size:12.5px;background:#111a33}
tr:last-child td{border-bottom:none}
td.main{color:var(--accent);font-weight:700;white-space:nowrap}
tr.ours td{background:#123a3055}
.sentence{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:16px 18px;margin:14px 0}
.sh{display:flex;align-items:center;gap:10px;margin-bottom:4px}
.num{color:var(--muted);font-size:13px;font-variant-numeric:tabular-nums}
.cat{font-size:12px;color:var(--accent2);border:1px solid #2a3a66;background:#151f3a;border-radius:999px;padding:1px 10px}
.st{font-size:16px;margin:4px 0 12px}
.players{display:grid;grid-template-columns:repeat(auto-fit,minmax(185px,1fr));gap:10px}
.player{background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:10px 11px}
.ph{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:2px}
.pn{font-size:13.5px;font-weight:600}
.tag{font-size:11px;border-radius:999px;padding:1px 8px;border:1px solid var(--line);color:var(--muted)}
.tag.ok{color:var(--accent);border-color:#1f5a4a;background:#123a30}
.tag.warn{color:var(--warn);border-color:#5a4a1f;background:#3a3012}
.tag.bad{color:var(--bad);border-color:#5a1f2a;background:#3a121a}
audio{width:100%;height:34px;margin-top:4px;filter:invert(.92) hue-rotate(180deg)}
details.hyp{margin-top:10px;color:var(--muted);font-size:13px}
details.hyp summary{cursor:pointer;color:var(--accent2)}
details.hyp table{min-width:0;margin-top:8px;font-size:13px}
details.hyp th,details.hyp td{white-space:normal;padding:6px 8px}
.yes{color:var(--accent);font-weight:700}.no{color:var(--muted)}
.callout{background:var(--panel2);border:1px solid var(--line);border-radius:14px;padding:16px 18px;font-size:14.5px;color:var(--muted)}
.callout b{color:var(--text)}
ul{margin:8px 0 0;padding-right:20px;color:var(--muted)}li{margin:6px 0}
footer{margin-top:44px;color:var(--muted);font-size:13px;border-top:1px solid var(--line);padding-top:18px}
.links{display:flex;gap:16px;flex-wrap:wrap;margin-top:8px}
@media(max-width:640px){.players{grid-template-columns:1fr 1fr}.st{font-size:15px}}
@media(max-width:420px){.players{grid-template-columns:1fr}}
"""
def main() -> int:
bayan = METRICS["bayan"]
chatter = METRICS["chatterbox"]
xtts = METRICS["xtts"]
html_doc = f"""<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bayan (صوت مراد) مقابل النماذج المشهورة على Hugging Face</title>
<style>{CSS}</style>
</head>
<body>
<div class="wrap">
<header>
<p class="eyebrow">مقارنة مفتوحة · 5 جمل فصحى · 5 أنظمة</p>
<h1>Bayan (صوت مراد) مقابل النماذج المشهورة على Hugging Face</h1>
<p class="sub">نفس النصوص، نفس البطاقة الصوتية، ونفس أدوات القياس. لا نختار الجمل التي ننجح فيها فقط،
ونعرض أيضاً ما لم يكن في صالحنا: التعادل النصي مع Chatterbox، وتفوق XTTS في السرعة.</p>
<div class="badges">
<span class="badge"><b>5</b> جمل من اختبار الـ32</span>
<span class="badge"><b>Whisper</b> large-v3</span>
<span class="badge"><b>TitaNet</b> لتشابه الصوت</span>
<span class="badge"><b>RX 6700 XT</b> · ROCm</span>
<span class="badge">آخر تحديث: <b>21 أكتوبر 2026</b></span>
</div>
</header>
<section>
<h2>الخلاصة في أربع بطاقات</h2>
<div class="grid g4">
<div class="card"><div class="metric">{pct(bayan['mean_cer_norm'])}</div><div class="label">CER* لـBayan — 5/5 بلا خطأ نطقي</div><div class="note">بعد تطبيع الأرقام والوحدات</div></div>
<div class="card"><div class="metric">{bayan['mean_sim']:.3f}</div><div class="label">تشابه الصوت مع مراد — الأعلى</div><div class="note">Chatterbox 0.830 · XTTS 0.718</div></div>
<div class="card"><div class="metric blue">{pct(chatter['mean_cer_norm'])}</div><div class="label">CER* لـChatterbox — تعادل نصي</div><div class="note">أقوى منافس مفتوح في هذه العينة</div></div>
<div class="card"><div class="metric blue">0.31</div><div class="label">RTF لـXTTS — الأسرع بين الجودة العالية</div><div class="note">Bayan 0.99 · Chatterbox 1.06</div></div>
</div>
</section>
<section>
<h2>النتائج المجمّعة</h2>
<div class="scroll">
<table>
<thead><tr><th>النظام</th><th>المعاملات</th><th>CER* ↓</th><th>CER خام (سياقي)</th><th>WER ↓</th><th>التشابه ↑</th><th>RTF ↓</th><th>الترخيص</th></tr></thead>
<tbody>
{aggregate_table()}
</tbody>
</table>
</div>
<p class="note"><b>CER*</b> هو المقياس العادل للنطق: يستبدل الأرقام والوحدات برمز واحد في الطرفين. السبب أن Whisper يكتب «110 كم» بينما النص «مئة وعشرة كيلومترات»؛ لذلك يبدو CER الخام أسوأ مما هو. Bayan وChatterbox كلاهما 0.00% بعد التطبيع.</p>
</section>
<section>
<h2>استمع وقارن — الجمل الخمس</h2>
<p class="note" style="margin-top:-4px">الترتيب: Bayan، Chatterbox، XTTS-v2، MMS، ثم MagpieTTS الأصلي بصوت Jason. كل المشغلات موحّدة الجهارة (−20 LUFS) لتحكيم عادل؛ القياسات على الملفات الخام.</p>
{sentence_cards()}
</section>
<section>
<h2>خريطة النماذج المشهورة على Hugging Face</h2>
<div class="scroll">
<table>
<thead><tr><th>النموذج</th><th>المعاملات</th><th>التنزيلات</th><th>الإعجابات</th><th>العربية</th><th>الترخيص</th><th>جُرّب؟</th></tr></thead>
<tbody>
{popular_table()}
</tbody>
</table>
</div>
<p class="note">الأرقام لحظة 21 أكتوبر 2026 وقد تتغير. «جُرّب هنا» تعني أننا ولّدنا به نفس الجمل الخمس؛ غيره مذكور للسياق فقط. Kokoro وQwen3-TTS وVibeVoice وCSM لا تدعم العربية، وF5-TTS أساسه إنجليزي/صيني.</p>
</section>
<section>
<h2>لماذا هذه النماذج تحديداً؟</h2>
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(240px,1fr))">
<div class="card"><b>Chatterbox</b><div class="note">0.5B، MIT، 23 لغة، استنساخ فوري. أوزان v2 المتاحة عبر الحزمة 0.1.7. أقوى منافس في هذه العينة: 0.00% CER* وتشابه 0.830.</div></div>
<div class="card"><b>XTTS-v2</b><div class="note">~467M، 17 لغة، استنساخ من 6 ثوانٍ. الأسرع بين النماذج عالية الجودة (RTF 0.31)، لكنه غير تجاري (CPML) وارتكب خطأين بسيطين.</div></div>
<div class="card"><b>MMS-TTS Arabic</b><div class="note">~36M فقط من Meta، بلا استنساخ، وأسرع نظام (RTF 0.03). جودته أقل بوضوح: WER 15.8% وتشابه 0.407.</div></div>
<div class="card"><b>MagpieTTS الأصلي</b><div class="note">النموذج الأساسي بصوت Jason. يوضح أن الفرق ليس في النص فقط: التشابه مع مراد 0.299 مقابل 0.867 لنسختنا.</div></div>
</div>
</section>
<section>
<h2>المنهجية والعدالة</h2>
<div class="callout">
<ul>
<li><b>النصوص:</b> خمس جمل من اختبار الـ32 الحصري (فهارس 0، 1، 8، 19، 30): أخبار، علوم، سؤال، أرقام، وجملة فصحى طويلة. مشكّلة بالكامل.</li>
<li><b>Bayan:</b> 357M إجمالاً، دُرّب منها 13.9M فقط (LoRA + صف صوت) ثم دُمجت في الملف المنشور.</li>
<li><b>الاستنساخ:</b> XTTS وChatterbox استلما نفس المقطع المرجعي (9.04 ثانية) من بيانات مراد. Bayan نفسه تدرب على 71.6 دقيقة — عدم تماثل واضح، وليس في مصلحة المنافسين.</li>
<li><b>الإعدادات:</b> XTTS وChatterbox وMMS على إعداداتها الافتراضية؛ Bayan على cfg 2.5 / temperature 0.6 / top-k 80 / seed 1234. لم نُعد ضبط أي نموذج خصيصاً لهذه الجمل.</li>
<li><b>القياس:</b> Whisper large-v3 للـCER/WER بعد تطبيع عربي (إزالة التشكيل والترقيم وتوحيد الألف والتاء المربوطة). التشابه = cosine بين TitaNet ومتوسط بصمة مراد من 12 مقطعاً تدريبياً.</li>
<li><b>RTF:</b> زمن التوليد ÷ زمن الصوت، بدون تحميل النموذج، على RX 6700 XT عبر ROCm 6.3. الأرقام في الجدول من تشغيل الـ5 جمل (وBayan/Jason من تشغيل الـ32).</li>
<li><b>العينات:</b> XTTS وChatterbox يخرجان 24 kHz، وMMS 16 kHz، وBayan 22.05 kHz. وُحّدت كلها إلى 22.05 kHz في مشغلات الصفحة فقط؛ القياسات على الملفات الأصلية.</li>
<li><b>الشفافية:</b> أخطاء XTTS: «للمتجددته» و«الميتوكندرية». MMS: «ألن»، «الميت كوندريا»، «بدقا»، «الساعد»، وجملة طويلة مشوّشة. Chatterbox: لا أخطاء نصية. Bayan: لا أخطاء بعد تطبيع الأرقام.</li>
</ul>
</div>
</section>
<section>
<h2>الحكم النهائي</h2>
<div class="callout">
<b>Bayan ليس الأفضل في كل شيء، لكنه الأفضل في ما صُنع من أجله:</b> بصمة صوت «مراد» المصرية
(0.867)، وفصحى سليمة 5/5 بعد تطبيع الأرقام، بحجم 357M يعمل محلياً داخل NeMo. في المقابل،
<b>Chatterbox</b> منافس قوي جداً للفصحى (0.00% CER*، تشابه 0.830، رخصة MIT)، و<b>XTTS-v2</b>
أسرع (0.31) لكنه غير تجاري وأقل دقة قليلاً. <b>MMS</b> خفيف وسريع لكنه ليس في نفس المستوى،
و<b>Jason</b> يثبت أن الصوت الأصلي بعيد عن هدفنا.
</div>
</section>
<footer>
<div>القياسات الخام: <a href="metrics.json" style="color:var(--accent2)">metrics.json</a> · <a href="results.csv" style="color:var(--accent2)">results.csv</a> · النصوص: <a href="texts.txt" style="color:var(--accent2)">texts.txt</a></div>
<div class="links">
<a href="../model_card.html" style="color:var(--accent2)">بطاقة النموذج</a>
<a href="../README.md" style="color:var(--accent2)">README</a>
<a href="../eval/msa_32/report.html" style="color:var(--accent2)">اختبار الـ32 جملة</a>
<a href="../compare/index.html" style="color:var(--accent2)">مقارنة النص الطويل</a>
</div>
<div style="margin-top:10px">الصفحة مولّدة آلياً من <code>scripts/make_page.py</code>. لا تُستخدم عينات النماذج الأخرى خارج نطاق المقارنة دون مراجعة تراخيصها.</div>
</footer>
</div>
</body>
</html>
"""
(ROOT / "index.html").write_text(html_doc, encoding="utf-8")
print("wrote", ROOT / "index.html", len(html_doc))
return 0
if __name__ == "__main__":
raise SystemExit(main())