""" ساخت بسته‌های درس (متن + صدا + تصویر) و فایل فهرست (manifest.json). ورودی : curriculum.json + lessons_src/*.json (+ اختیاری: lessons_src//img/*.jpg|png|webp) خروجی : OUT_DIR/manifest.json و OUT_DIR/packs/-v.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())