# ۱۵ — فاز ۵: صداهای سفارشی (والد → ادمین → TTS)

درخواست کاربر: «اگر صدای والدین بود از آن استفاده شود، نبود از صدای ادمین و در نبود آنها از صدای سیستمی» — دقیقاً همین زنجیره پیاده شد.

## معماری

```
پخش حرف/کلمه در بازی:
  ۱. GET /api/v1/audio/play?pack&type&id&cs=   (سرور)
       ├─ صدای «والدِ همین کودک» هست؟ → پخش 🔵
       ├─ نه؟ صدای ادمین هست؟          → پخش 🟣
       └─ نه؟ 404                       → TTS مرورگر ⚪
```

### سرور

- **جدول `custom_audio`** (db.js): owner_type=parent/admin + کلید یکتا (owner, pack, item_type, item_id) — ضبط دوباره جایگزین می‌شود.
- **`server/routes/audio.js`**:
  - `POST /audio?pack&type&id&d=` — والد، بدنه‌ی خام صوتی (express.raw، سقف ۳MB، فرمت webm/mp3/wav/m4a/ogg با تشخیص magic-bytes)
  - `GET /audio?pack=` — وضعیت هر آیتم برای والد (صداهای خودش + ادمین)
  - `DELETE /audio/:id` — فقط صدای خود والد
  - `GET /audio/play` — زنجیره؛ والدِ کودک از جلسه‌ی دستگاه حل می‌شود؛ هدر `X-Audio-Owner` برای تست/عیب‌یابی
- **Seed ادمین (بدون نیاز به پنل):** فایل در `ALPHABET_DATA_DIR/audio/admin/` با الگوی `{pack}__{letter|word}__{itemId}.{ext}` بگذار و سرور را ری‌استارت کن — idempotent، حذف فایل = حذف صدا.

### کلاینت

- **`AudioEngine`**: `_sayWithCustom` در sayLetterName/sayLetterSound/sayWord — اول صدای سفارشی (fetch + کش Blob در حافظه + پخش Audio)، وگرنه TTS. رویداد `audio:updated` کش را پاک می‌کند. فقط دستگاهِ متصل به سرور (بقیه: TTS).
- **`ui/components/voiceCard.js`** — کارت «🎙️ صداهای من» در پنل والدین (نمای متصل):
  - گرید حروف/کلمات با نشان وضعیت: 🔵 تو / 🟣 ادمین / ⚪ سیستمی
  - مودال ضبط: میکروفون (MediaRecorder، سقف ۵ ثانیه، تایمر زنده) یا انتخاب فایل + پیش‌نمایش + ذخیره + حذف (با تأیید)
  - در iframe سندباکس میکروفون ممکن است مسدود باشد → توست دوستانه + آپلود فایل همیشه کار می‌کند
- **RestRepo**: `uploadAudio` (بدنه‌ی خام + raw:true)، `listAudio`، `deleteAudio`، `audioPlayURL` (توکن در کوئری — درس باگ 401 پروکسی)

## تست

- api.test.js **+۶**: آپلود→پخش کودک همان والد (owner=parent) · کودکِ والد دیگر → ادمین · نبودن → 404 (=TTS) · اولویت والد بر ادمین + حذف → بازگشت به ادمین · حذف صدای دیگران/ادمین ممنوع · توکن الکی 401
- e2e.mjs **+۱ استپ (۱۳ کل)**: آپلود فایل واقعی از UI → chip تبدیل به «صدای تو»
- درس Playwright: `waitForSelector` پیش‌فرض state=visible است — برای input فایلِ hidden باید `state:'attached'` یا المنت visible دیگر را صبر کرد

## وضعیت

- واحد: **۸۷/۸۷** (engine 18 · sync 10 · api 28 · packs 13 · pwa 10 · trace 6 · imports 2)
- E2E: **۱۳/۱۳** بدون خطای کنسول
- نسخه: **1.1.0**
