Spaces:
Running
Running
Download builder.py from Elias207/imageeditingpro1: direct link, hf CLI and curl.
- Browser
- Download file 26.1 kB
-
https://huggingface.co/spaces/Elias207/imageeditingpro1/resolve/main/builder.py
- Command line
-
hf download hf://spaces/Elias207/imageeditingpro1/builder.py
-
curl -L -o builder.py https://huggingface.co/spaces/Elias207/imageeditingpro1/resolve/main/builder.py
26.1 kB
| """ | |
| ساخت بستههای درس (متن + صدا + تصویر) و فایل فهرست (manifest.json). | |
| ورودی : curriculum.json + lessons_src/*.json (+ اختیاری: lessons_src/<id>/img/*.jpg|png|webp) | |
| خروجی : OUT_DIR/manifest.json و OUT_DIR/packs/<id>-v<version>.zip | |
| صداها: | |
| • واژهها (آیتمهای بخش واژگان و هر متن تککلمهای): | |
| edge-tts با صدای en-US-GuyNeural (مرد آمریکایی) و سرعت -15٪ | |
| (همان تنظیمی که در «آلفا مترجم» انتخاب کردهاید) | |
| • مکالمهی درسها: جملههای گویندهی A با صدای مرد (Kokoro، Adam) و همان سرعت صدای زن؛ کلید صدایشان "m|<متن>" است | |
| • جملهها: Space صدای Kokoro (صدای پیشفرض Heart ، سرعت 0.80) | |
| اگر Kokoro فایل WAV بدهد، با ffmpeg به MP3 کمحجم (تککاناله، 24kHz، ~48kbps) تبدیل و همان ذخیره میشود. | |
| اگر آن Space جواب ندهد، با edge-tts (Jenny ، سرعت -25٪) جایگزین میشود و در گزارش نوشته میشود. | |
| اجرای مستقل: python builder.py (FAKE_TTS=1 برای تست بدون اینترنت) | |
| """ | |
| import asyncio | |
| import hashlib | |
| import io | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import time | |
| import wave | |
| import zipfile | |
| from pathlib import Path | |
| import audio_mp3 | |
| import grammar | |
| import kokoro_client | |
| ROOT = Path(__file__).parent | |
| SRC = ROOT / "lessons_src" | |
| OUT = Path(os.getenv("OUT_DIR", "/tmp/content_out")) | |
| STORE_DIR = Path(os.getenv("STORE_DIR", "/tmp/store")) # با store.py در Dataset ذخیره میشود | |
| CACHE = Path(os.getenv("CACHE_DIR", str(STORE_DIR / "audio"))) # صدای هر متن (یک بار ساخته و برای همیشه نگه داشته میشود) | |
| TAKES_FILE = STORE_DIR / "takes.json" | |
| TAKES = {} # متن ← شمارهی «بازسازی»؛ بالا رفتنش یعنی صدای تازه برای آن متن | |
| REVS_FILE = STORE_DIR / "revs.json" | |
| REVS = {} # شناسهی درس ← عددی که خودکار به version اضافه میشود (مثلاً وقتی صدای گرامر عوض شد) | |
| METAS = {} # شناسهی درس ← اطلاعات آخرین بستهی ساختهشده (برای manifest و صفحهی اصلی) | |
| LESSON_AUDIO = {} # برای صفحهی بررسی صداها | |
| FALLBACK_TEXTS = set() # جملههایی که با صدای جایگزین ساخته شدهاند | |
| STATE = {"dirty": False} # آیا صدای تازهای هست که هنوز در ذخیرهی پایدار نرفته؟ | |
| WORD_VOICE = os.getenv("WORD_VOICE", "en-US-GuyNeural") | |
| WORD_RATE = os.getenv("WORD_RATE", "-15%") | |
| FALLBACK_VOICE = os.getenv("FALLBACK_SENT_VOICE", "en-US-JennyNeural") | |
| FALLBACK_RATE = os.getenv("FALLBACK_SENT_RATE", "-25%") | |
| KOKORO_BASE = os.getenv("KOKORO_BASE", "https://opera10-translate-tts-aloha.hf.space") | |
| KOKORO_SPEED = float(os.getenv("KOKORO_SPEED", "0.80")) | |
| KOKORO_VOICE = os.getenv("KOKORO_VOICE", "") # خالی = صدای پیشفرض خود Space (Heart) | |
| KOKORO_URL = os.getenv("KOKORO_URL", "") # اگر آدرس دقیق API را میدانید | |
| KOKORO_BODY = os.getenv("KOKORO_BODY", "") # قالب JSON دستی (اختیاری) | |
| CONCURRENCY = int(os.getenv("TTS_CONCURRENCY", "4")) | |
| FAKE = os.getenv("FAKE_TTS") == "1" | |
| # صدای مرد (Adam) برای گویندهی A در بخش «مکالمه»ی درسها (نه پادکست، نه آزمون، نه جای دیگر). | |
| # سرعتش همان KOKORO_SPEED صدای زن است. CONV_A_VOICE خالی = خاموش. اگر Space نام صدا را جور دیگری میخواهد، | |
| # KOKORO_VOICE_MALE را بگذارید (مثلاً Adam). | |
| CONV_A_VOICE = os.getenv("CONV_A_VOICE", "male") | |
| # صدای مرد برای خطهای گویندهای که اسمش در این فهرست باشد (نه «گویندهی اول»). Ali برای درسهای قدیمی است. | |
| MALE_SPEAKERS = {x.strip().lower() for x in os.getenv("CONV_MALE_SPEAKERS", "John,Ali").split(",") if x.strip()} | |
| MALE_VOICE = os.getenv("KOKORO_VOICE_MALE", "") | |
| MALE_VOICE_TRIES = [MALE_VOICE] if MALE_VOICE else ["am_adam", "Adam", "adam"] | |
| MALE_FALLBACK_VOICE = os.getenv("FALLBACK_MALE_VOICE", "en-US-GuyNeural") | |
| MALE = "m|" # پیشوند کلید صدا در lesson.audio برای جملههایی که با صدای مرد ساخته میشوند | |
| # عددی که به version همهی درسها اضافه میشود. وقتی صداها عوض شدند (مثلاً سرعت یا فرمت) این را یکی بالا ببرید | |
| # تا اپ کاربران قدیمی همهی درسها را دوباره بگیرد. پیشفرض 0 یعنی هیچ تغییری در version نمیدهد. | |
| AUDIO_REV = int(os.getenv("AUDIO_REV", "0") or 0) | |
| MP3_STATE = {"warned": False, "ok": 0, "kept_wav": 0} # آمار تبدیل WAV به MP3 در همین بار ساخت | |
| # اطلاعاتی که صفحهی وضعیت نشان میدهد | |
| INFO = {"words": f"{WORD_VOICE} {WORD_RATE}", "sentences": "—", "fallback_used": False, "kokoro_error": "", | |
| "male": "—", "male_fallback": False} | |
| _male_good = {"v": None} | |
| def collect(node, acc): | |
| """همهی متنهایی که باید صدا بگیرند را به ترتیب پیدا میکند.""" | |
| if isinstance(node, dict): | |
| en = node.get("en") | |
| if isinstance(en, str) and en.strip(): | |
| acc.setdefault((node.get("tts") or en).strip(), None) | |
| for v in node.values(): | |
| collect(v, acc) | |
| elif isinstance(node, list): | |
| for v in node: | |
| collect(v, acc) | |
| def tag_male_lines(lesson): | |
| """ | |
| در هر بخش «مکالمه»ی یک درس معمولی (نه پادکست)، خطهای گویندهای که اسمش در MALE_SPEAKERS است (John) کلید صدای مرد | |
| میگیرند: \"ak\": \"m|<متن>\". بقیه (Sara) با صدای زن مثل قبل. مهم نیست چه کسی مکالمه را شروع کند. | |
| کلیدهای مرد را برمیگرداند. | |
| """ | |
| out = set() | |
| if not CONV_A_VOICE or lesson.get("kind") == "podcast": | |
| return out | |
| for s in lesson.get("sections", []): | |
| if s.get("type") != "conversation": | |
| continue | |
| for ln in s.get("lines") or []: | |
| en = (ln.get("tts") or ln.get("en") or "").strip() | |
| if en and (ln.get("speaker") or "").strip().lower() in MALE_SPEAKERS: | |
| ln["ak"] = MALE + en | |
| out.add(MALE + en) | |
| return out | |
| def all_texts(lesson): | |
| """همهی کلیدهای صدای یک درس (متنها + کلیدهای مرد). lesson تغییر میکند (خطهای مرد علامت میخورند).""" | |
| texts = {} | |
| collect(lesson, texts) | |
| for k in tag_male_lines(lesson): | |
| texts.setdefault(k, None) | |
| return texts | |
| def collect_imgs(node, acc): | |
| if isinstance(node, dict): | |
| v = node.get("img") | |
| if isinstance(v, str) and v.strip(): | |
| acc.setdefault(v.strip(), None) | |
| for x in node.values(): | |
| collect_imgs(x, acc) | |
| elif isinstance(node, list): | |
| for x in node: | |
| collect_imgs(x, acc) | |
| def vocab_texts(lesson): | |
| out = set() | |
| for s in lesson.get("sections", []): | |
| if s.get("type") == "vocab": | |
| for it in s.get("items", []): | |
| out.add((it.get("tts") or it.get("en") or "").strip()) | |
| return out | |
| def find_cached(base): | |
| found = [p for p in CACHE.glob(base + ".*") if p.stat().st_size > 500] # فایل ناقص را قبول نکن | |
| if not found: | |
| return None | |
| # اگر از یک متن هم mp3 و هم wav مانده بود، همیشه mp3 (کمحجمتر) برداشته میشود | |
| found.sort(key=lambda p: (p.suffix != ".mp3", p.name)) | |
| return found[0] | |
| def load_takes(): | |
| TAKES.clear() | |
| try: | |
| TAKES.update(json.loads(TAKES_FILE.read_text(encoding="utf-8"))) | |
| except Exception: # noqa: BLE001 | |
| pass | |
| def save_takes(): | |
| TAKES_FILE.parent.mkdir(parents=True, exist_ok=True) | |
| TAKES_FILE.write_text(json.dumps(TAKES, ensure_ascii=False, indent=1), encoding="utf-8") | |
| STATE["dirty"] = True | |
| def load_revs(): | |
| REVS.clear() | |
| try: | |
| REVS.update({k: int(v) for k, v in json.loads(REVS_FILE.read_text(encoding="utf-8")).items()}) | |
| except Exception: # noqa: BLE001 | |
| pass | |
| def bump_rev(lesson_id): | |
| """version همین درس یکی بالا میرود تا اپ کاربران نسخهی تازه را بگیرد (بدون دست زدن به فایل درس).""" | |
| load_revs() | |
| REVS[lesson_id] = REVS.get(lesson_id, 0) + 1 | |
| REVS_FILE.parent.mkdir(parents=True, exist_ok=True) | |
| REVS_FILE.write_text(json.dumps(REVS, ensure_ascii=False, indent=1), encoding="utf-8") | |
| STATE["dirty"] = True | |
| def take_suffix(text): | |
| n = TAKES.get(text, 0) | |
| return f"|t{n}" if n else "" | |
| def regen(lesson_id, texts=None): | |
| """صدای کل یک درس (یا فقط متنهای دادهشده) را برای ساخت بعدی «تازه» علامت میزند. تعداد متنها را برمیگرداند.""" | |
| if not re.fullmatch(r"[A-Za-z0-9_-]+", lesson_id or ""): | |
| raise ValueError("شناسهی درس نامعتبر است") | |
| src = SRC / f"{lesson_id}.json" | |
| if not src.exists(): | |
| raise FileNotFoundError(f"فایل {src.name} پیدا نشد") | |
| found = all_texts(json.loads(src.read_text(encoding="utf-8"))) | |
| wanted = [t for t in texts if t in found] if texts else list(found) | |
| if not wanted: | |
| raise ValueError("این متنها در این درس پیدا نشدند") | |
| load_takes() | |
| for t in wanted: | |
| TAKES[t] = TAKES.get(t, 0) + 1 | |
| save_takes() | |
| return len(wanted) | |
| _kokoro = None | |
| def _get_kokoro(): | |
| global _kokoro | |
| if _kokoro is None: | |
| _kokoro = kokoro_client.KokoroSpace(KOKORO_BASE, KOKORO_SPEED, KOKORO_VOICE, KOKORO_URL, KOKORO_BODY) | |
| return _kokoro | |
| async def edge_save(text, voice, rate, path): | |
| import edge_tts # بارگذاری دیرهنگام تا تست بدون این کتابخانه هم کار کند | |
| last = None | |
| for attempt in range(4): | |
| try: | |
| await edge_tts.Communicate(text, voice, rate=rate).save(str(path)) | |
| if path.exists() and path.stat().st_size > 500: | |
| return | |
| except Exception as e: # noqa: BLE001 | |
| last = e | |
| path.unlink(missing_ok=True) | |
| await asyncio.sleep(1.5 * (attempt + 1)) | |
| raise RuntimeError(f"ساخت صدا ناموفق بود برای {text!r}: {last}") | |
| def fake_wav(): | |
| """برای تست بدون اینترنت: یک WAV واقعی و کوتاه (یک ثانیه سکوت) میسازد تا مسیر تبدیل به MP3 هم آزمایش شود.""" | |
| buf = io.BytesIO() | |
| with wave.open(buf, "wb") as w: | |
| w.setnchannels(1) | |
| w.setsampwidth(2) | |
| w.setframerate(24000) | |
| w.writeframes(b"\x00\x00" * 24000) | |
| return buf.getvalue() | |
| def shrink(data, ext, log): | |
| """اگر صدا WAV (یا FLAC) بود به MP3 کمحجم تبدیل میکند. اگر نشد، همان صدای اصلی را برمیگرداند (ساخت متوقف نمیشود).""" | |
| if ext not in audio_mp3.LOSSLESS: | |
| return data, ext | |
| try: | |
| out = audio_mp3.bytes_to_mp3(data, ext) | |
| MP3_STATE["ok"] += 1 | |
| return out, "mp3" | |
| except Exception as e: # noqa: BLE001 | |
| MP3_STATE["kept_wav"] += 1 | |
| if not MP3_STATE["warned"]: | |
| MP3_STATE["warned"] = True | |
| log(f"⚠️ تبدیل صدا به MP3 نشد ({str(e)[:160]}). همان {ext.upper()} نگه داشته میشود.") | |
| return data, ext | |
| async def make_male_audio(key, sfx, log): | |
| """صدای مرد (Adam) از همان Space Kokoro و با همان سرعت صدای زن. اگر نشد، با صدای مرد edge-tts جایگزین میشود.""" | |
| text = key[len(MALE):] | |
| kbase = hashlib.sha1(f"m|kokoro|{MALE_VOICE or 'adam'}|{KOKORO_SPEED}|{text}{sfx}".encode()).hexdigest()[:12] | |
| hit = find_cached(kbase) | |
| if hit: | |
| FALLBACK_TEXTS.discard(key) | |
| return hit.name | |
| fbase = hashlib.sha1(f"m|edge|{MALE_FALLBACK_VOICE}|{FALLBACK_RATE}|{text}{sfx}".encode()).hexdigest()[:12] | |
| fhit = find_cached(fbase) | |
| if fhit: | |
| FALLBACK_TEXTS.add(key) | |
| return fhit.name | |
| if FAKE: | |
| data, ext = await asyncio.to_thread(shrink, fake_wav(), "wav", log) | |
| path = CACHE / f"{kbase}.{ext}" | |
| path.write_bytes(data) | |
| INFO["male"] = "Kokoro (آزمایشی)" | |
| STATE["dirty"] = True | |
| return path.name | |
| if not INFO["male_fallback"]: | |
| tries = ([_male_good["v"]] if _male_good["v"] else []) + [v for v in MALE_VOICE_TRIES if v != _male_good["v"]] | |
| last = None | |
| for v in tries: | |
| try: | |
| data, ext = await asyncio.to_thread(_get_kokoro().synth, text, v) | |
| data, ext = await asyncio.to_thread(shrink, data, ext, log) | |
| path = CACHE / f"{kbase}.{ext}" | |
| path.write_bytes(data) | |
| _male_good["v"] = v | |
| STATE["dirty"] = True | |
| INFO["male"] = f"Kokoro ({v}) سرعت {KOKORO_SPEED}" | |
| FALLBACK_TEXTS.discard(key) | |
| return path.name | |
| except Exception as e: # noqa: BLE001 | |
| last = e | |
| INFO["male_fallback"] = True | |
| log(f"⚠️ صدای مرد (Adam) از Kokoro گرفته نشد ({str(last)[:160]}). با صدای جایگزین ادامه میدهیم؛ " | |
| "اگر نام صدا فرق دارد، متغیر KOKORO_VOICE_MALE را بگذارید.") | |
| path = CACHE / (fbase + ".mp3") | |
| await edge_save(text, MALE_FALLBACK_VOICE, FALLBACK_RATE, path) | |
| STATE["dirty"] = True | |
| FALLBACK_TEXTS.add(key) | |
| INFO["male"] = f"جایگزین: {MALE_FALLBACK_VOICE} {FALLBACK_RATE}" | |
| return path.name | |
| async def make_audio(text, kind, log): | |
| """فایل صوتی یک متن را میسازد؛ اگر قبلاً ساخته شده باشد (در ذخیرهی پایدار) همان را برمیدارد و دیگر نمیسازد.""" | |
| sfx = take_suffix(text) | |
| if kind == "w": | |
| base = hashlib.sha1(f"w|{WORD_VOICE}|{WORD_RATE}|{text}{sfx}".encode()).hexdigest()[:12] | |
| hit = find_cached(base) | |
| if hit: | |
| return hit.name | |
| path = CACHE / (base + ".mp3") | |
| if FAKE: | |
| path.write_bytes(b"ID3" + b"\x00" * 700 + text.encode()) | |
| else: | |
| await edge_save(text, WORD_VOICE, WORD_RATE, path) | |
| STATE["dirty"] = True | |
| return path.name | |
| if kind == "m": | |
| return await make_male_audio(text, sfx, log) | |
| # جمله: اگر صدای Kokoro یا صدای جایگزین قبلاً ساخته شده، همان میماند (تثبیت) | |
| kbase = hashlib.sha1(f"s|kokoro|{KOKORO_VOICE}|{KOKORO_SPEED}|{text}{sfx}".encode()).hexdigest()[:12] | |
| hit = find_cached(kbase) | |
| if hit: | |
| FALLBACK_TEXTS.discard(text) | |
| return hit.name | |
| fbase = hashlib.sha1(f"s|edge|{FALLBACK_VOICE}|{FALLBACK_RATE}|{text}{sfx}".encode()).hexdigest()[:12] | |
| fhit = find_cached(fbase) | |
| if fhit: | |
| FALLBACK_TEXTS.add(text) | |
| return fhit.name | |
| if FAKE: | |
| data, ext = await asyncio.to_thread(shrink, fake_wav(), "wav", log) | |
| path = CACHE / f"{kbase}.{ext}" | |
| path.write_bytes(data) | |
| INFO["sentences"] = "Kokoro (آزمایشی)" | |
| STATE["dirty"] = True | |
| return path.name | |
| if not INFO["fallback_used"]: | |
| try: | |
| data, ext = await asyncio.to_thread(_get_kokoro().synth, text) | |
| # WAV را قبل از نوشتن در CACHE به MP3 کمحجم تبدیل میکنیم (اگر نشد، همان WAV میماند) | |
| data, ext = await asyncio.to_thread(shrink, data, ext, log) | |
| path = CACHE / f"{kbase}.{ext}" | |
| path.write_bytes(data) | |
| STATE["dirty"] = True | |
| INFO["sentences"] = f"Kokoro (Heart) سرعت {KOKORO_SPEED}" | |
| FALLBACK_TEXTS.discard(text) | |
| return path.name | |
| except Exception as e: # noqa: BLE001 | |
| if not INFO["fallback_used"]: | |
| INFO["fallback_used"] = True | |
| INFO["kokoro_error"] = str(e)[:300] | |
| log(f"⚠️ صدای جمله از Kokoro گرفته نشد ({str(e)[:160]}). با صدای جایگزین ادامه میدهیم.") | |
| path = CACHE / (fbase + ".mp3") | |
| await edge_save(text, FALLBACK_VOICE, FALLBACK_RATE, path) | |
| STATE["dirty"] = True | |
| FALLBACK_TEXTS.add(text) | |
| INFO["sentences"] = f"جایگزین: {FALLBACK_VOICE} {FALLBACK_RATE}" | |
| return path.name | |
| def attach_lecture(lesson, log): | |
| """ | |
| اگر برای این درس صدای گرامر ذخیره شده باشد، آن را به بخش گرامر درس وصل میکند: | |
| section["lecture"] = {"f": نام فایل در بسته, "dur": ثانیه} | |
| اگر درس بخش گرامر نداشت، یکی (قبل از آزمون) ساخته میشود. این صدا TTS نمیگیرد. | |
| """ | |
| lec = grammar.lecture(lesson["id"]) | |
| if not lec: | |
| return None | |
| lec["name"] = f"g-{lec['sha']}.mp3" | |
| sec = next((s for s in lesson.get("sections", []) if s.get("type") == "grammar"), None) | |
| if sec is None: | |
| sec = {"type": "grammar", "title": "Grammar", "script": []} | |
| secs = lesson.setdefault("sections", []) | |
| pos = next((i for i, s in enumerate(secs) if s.get("type") == "exercises"), len(secs)) | |
| secs.insert(pos, sec) | |
| sec["lecture"] = {"f": lec["name"], "dur": round(lec["dur"], 2)} | |
| log(f" {lesson['id']}: صدای گرامر ({lec['size'] // 1024} KB)") | |
| return lec | |
| async def build_lesson(src_file, packs_dir, sem, log): | |
| lesson = json.loads(src_file.read_text(encoding="utf-8")) | |
| lesson.pop("audio", None) | |
| # AUDIO_REV بالا برود، همهی درسها دوباره دانلود میشوند؛ REVS مخصوص همین درس است (مثلاً بعد از عوض شدن صدای گرامر) | |
| lesson["version"] = int(lesson.get("version", 1)) + AUDIO_REV + REVS.get(lesson["id"], 0) | |
| texts = all_texts(lesson) | |
| vocab = vocab_texts(lesson) | |
| lec = attach_lecture(lesson, log) # صدای گرامر (دستی) ؛ TTS نمیگیرد | |
| kinds = {t: ("m" if t.startswith(MALE) else "w" if (t in vocab or len(t.split()) == 1) else "s") for t in texts} | |
| async def one(t): | |
| async with sem: | |
| return t, await make_audio(t, kinds[t], log) | |
| log(f" {lesson['id']}: {len(texts)} متن برای صدا…") | |
| # واژهها اول ساخته میشوند تا اگر Kokoro نیاز به بیدار شدن داشت، همزمان گرم شود | |
| results = await asyncio.gather(*[one(t) for t in texts]) | |
| audio = {t: {"f": f, "k": kinds[t]} for t, f in results} | |
| LESSON_AUDIO[lesson["id"]] = { | |
| "title": lesson.get("title", ""), | |
| "items": [{"t": t, "k": kinds[t], "f": f, "fb": t in FALLBACK_TEXTS} for t, f in results], | |
| } | |
| lesson["audio"] = audio | |
| imgs = {} | |
| collect_imgs(lesson, imgs) | |
| zip_name = f"{lesson['id']}-v{lesson['version']}.zip" | |
| zip_path = packs_dir / zip_name | |
| tmp_zip = packs_dir / (zip_name + ".tmp") # اول کامل ساخته میشود، بعد جا میافتد (دانلود نیمهکاره نمیدهد) | |
| with zipfile.ZipFile(tmp_zip, "w", zipfile.ZIP_DEFLATED) as z: | |
| z.writestr("lesson.json", json.dumps(lesson, ensure_ascii=False)) | |
| for a in audio.values(): | |
| z.write(CACHE / a["f"], f"audio/{a['f']}") | |
| if lec: | |
| z.write(lec["path"], f"audio/{lec['name']}") | |
| for name in imgs: | |
| f = SRC / lesson["id"] / "img" / Path(name).name | |
| if f.exists(): | |
| z.write(f, f"img/{Path(name).name}") | |
| else: | |
| log(f" هشدار: تصویر {name} برای درس {lesson['id']} پیدا نشد ({f})") | |
| os.replace(tmp_zip, zip_path) | |
| data = zip_path.read_bytes() | |
| return lesson, { | |
| "id": lesson["id"], | |
| "title": lesson["title"], | |
| "subtitle": lesson.get("subtitle", ""), | |
| "version": lesson["version"], | |
| "zip": f"packs/{zip_name}", | |
| "size": len(data), | |
| "sha256": hashlib.sha256(data).hexdigest(), | |
| } | |
| def load_curriculum(): | |
| return json.loads((ROOT / "curriculum.json").read_text(encoding="utf-8")) | |
| def lesson_ids(curriculum=None): | |
| """[(شناسه, گروه)] به ترتیب سطح ← ترم ← درس و بعد پادکست""" | |
| c = curriculum or load_curriculum() | |
| out = [(i, f"{lv.get('title', '')} · {t.get('title', '')}") for lv in c["levels"] for t in lv.get("terms", []) | |
| for i in t["lessons"]] | |
| seen = {i for i, _ in out} | |
| out += [(i, "پادکست") for i in c.get("podcast", []) if i not in seen] | |
| return out | |
| def make_manifest(curriculum, metas): | |
| levels = [] | |
| for lv in curriculum["levels"]: | |
| terms = [] | |
| for t in lv.get("terms", []): | |
| lessons = [metas[i] for i in t["lessons"] if i in metas] | |
| if lessons: | |
| terms.append({k: v for k, v in t.items() if k != "lessons"} | {"lessons": lessons}) | |
| levels.append({k: v for k, v in lv.items() if k != "terms"} | {"terms": terms}) | |
| pod_ids = list(curriculum.get("podcast", [])) | |
| return {"version": int(time.time()), "levels": levels, "podcast": [metas[i] for i in pod_ids if i in metas]} | |
| async def build_one(lesson_id, log=print, on_lesson_done=None): | |
| """ | |
| فقط یک درس (یا اپیزود) را میسازد: بستهی زیپ و manifest همان درس تازه میشود و بقیه دست نمیخورند. | |
| صداهای آماده در ذخیرهی پایدار دوباره ساخته نمیشوند؛ فقط متنهای تازهی این درس صدا میگیرند. | |
| """ | |
| if not re.fullmatch(r"[A-Za-z0-9_-]+", lesson_id or ""): | |
| raise ValueError("شناسهی درس نامعتبر است") | |
| src = SRC / f"{lesson_id}.json" | |
| if not src.exists(): | |
| raise FileNotFoundError(f"فایل {src.name} پیدا نشد") | |
| CACHE.mkdir(parents=True, exist_ok=True) | |
| load_takes() | |
| load_revs() | |
| INFO.update(fallback_used=False, male_fallback=False) # برای این درس دوباره Kokoro امتحان شود | |
| _male_good["v"] = None | |
| packs = OUT / "packs" | |
| packs.mkdir(parents=True, exist_ok=True) | |
| log(f"درس {lesson_id}…") | |
| _, meta = await build_lesson(src, packs, asyncio.Semaphore(CONCURRENCY), log) | |
| METAS[lesson_id] = meta | |
| curriculum = load_curriculum() | |
| tmp = OUT / "manifest.json.tmp" | |
| tmp.write_text(json.dumps(make_manifest(curriculum, METAS), ensure_ascii=False, indent=2), encoding="utf-8") | |
| os.replace(tmp, OUT / "manifest.json") | |
| keep = Path(meta["zip"]).name | |
| for old in packs.glob(f"{lesson_id}-v*.zip"): # نسخههای قدیمی همین درس | |
| if old.name != keep: | |
| old.unlink(missing_ok=True) | |
| if on_lesson_done: | |
| await asyncio.to_thread(on_lesson_done) | |
| log(f"درس {lesson_id} آماده شد (نسخهی {meta['version']}).") | |
| return meta | |
| async def build_all(log=print, on_lesson_done=None): | |
| """همهی درسها را میسازد. خروجی: (manifest, errors)""" | |
| CACHE.mkdir(parents=True, exist_ok=True) | |
| load_takes() | |
| load_revs() | |
| FALLBACK_TEXTS.clear() | |
| new = OUT.parent / (OUT.name + ".new") | |
| shutil.rmtree(new, ignore_errors=True) | |
| (new / "packs").mkdir(parents=True) | |
| INFO.update(sentences="—", fallback_used=False, kokoro_error="", male="—", male_fallback=False) | |
| _male_good["v"] = None | |
| MP3_STATE.update(warned=False, ok=0, kept_wav=0) | |
| curriculum = load_curriculum() | |
| sem = asyncio.Semaphore(CONCURRENCY) | |
| metas, errors = {}, {} | |
| ids = [i for lv in curriculum["levels"] for t in lv.get("terms", []) for i in t["lessons"]] | |
| # اپیزودهای پادکست هم مثل درس ساخته میشوند (متن + صدا + بستهی زیپ) و در manifest زیر کلید "podcast" میآیند | |
| pod_ids = list(curriculum.get("podcast", [])) | |
| ids += [i for i in pod_ids if i not in ids] | |
| for lid in ids: | |
| src = SRC / f"{lid}.json" | |
| try: | |
| if not src.exists(): | |
| raise FileNotFoundError(f"فایل {src.name} پیدا نشد") | |
| log(f"درس {lid}…") | |
| _, meta = await build_lesson(src, new / "packs", sem, log) | |
| metas[lid] = meta | |
| except Exception as e: # noqa: BLE001 | |
| errors[lid] = str(e) | |
| log(f" خطا در درس {lid}: {e}") | |
| if on_lesson_done: # بعد از هر درس، صداهای تازه در ذخیرهی پایدار نگه داشته میشود | |
| await asyncio.to_thread(on_lesson_done) | |
| manifest = make_manifest(curriculum, metas) | |
| (new / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") | |
| METAS.clear() | |
| METAS.update(metas) | |
| shutil.rmtree(OUT, ignore_errors=True) | |
| new.rename(OUT) | |
| log(f"صدای واژهها: {INFO['words']} | صدای جملهها: {INFO['sentences']}") | |
| if MP3_STATE["ok"] or MP3_STATE["kept_wav"]: | |
| log(f"تبدیل به MP3: {MP3_STATE['ok']} جمله تبدیل شد، {MP3_STATE['kept_wav']} جمله WAV ماند.") | |
| log(f"تمام شد: {len(metas)} درس آماده، {len(errors)} خطا.") | |
| return manifest, errors | |
| if __name__ == "__main__": | |
| asyncio.run(build_all()) |