# PLAN — OmniSub (dịch SRT ZH→VI bằng Qwen3-Omni) Tài liệu thiết kế chi tiết. Quyết định đã chốt: **cloud-first (Colab L4 24GB)**, **Qwen3-Omni 4-bit làm tất cả** (nghe giọng + nhìn cảnh + dịch), **bỏ regex xưng hô** của bản cũ. --- ## 0. Quyết định đã chốt - **Model**: `Qwen/Qwen3-Omni-30B-A3B-Instruct` (bản **Instruct**, 4-bit). - **OCR sửa phụ đề cháy hình** (Bước 1, tùy chọn): **giữ lại nhưng MẶC ĐỊNH TẮT** (`correct_ocr: false`), bật khi cần qua config/CLI. Nguồn SRT hiện đã sạch. - **Tên file ra**: giữ nếp cũ `*.vi.srt` (+ `*.vi.srt.report.json`). ## 1. Mục tiêu 1. Dịch SRT **Trung → Việt** tự nhiên, đúng văn nói, súc tích. 2. **Xưng hô đúng vai vế** nhờ: - **Nghe giọng**: ai đang nói (diarization) + giới/tuổi/cảm xúc/vai vế (Omni nghe trực tiếp). - **Nhìn ngữ cảnh**: frame video của cảnh đang dịch. 3. Nhất quán **tên riêng** và **cách xưng hô** xuyên suốt phim. 4. Phụ đề **vừa thời lượng** hiển thị (chars/giây, số dòng). ## 2. Bài học từ `Gemma-4` (giữ / bỏ) **Giữ (port sạch):** - `parse_srt`, `write_srt`, `subtitle_char_budget`, `_seconds_to_srt_time` — I/O SRT vững. - `group_scenes`, `merge_adjacent_pairs` — gom cảnh, ghép cue vụn (sentence-aware, same-speaker). - Pattern `chat_json` + `_extract_json` — parse JSON từ output model bền bỉ. - Cấu trúc Colab + Google Drive (`Phim/`, `Cache/`). **Bỏ / thay thế:** - ❌ `RelationshipRegistry`, `_CJK_ROMANCE_RE`, `_CJK_FAMILY_RE`, `_VN_ANH_EM_RE`, `enforce_cue` (hàng trăm dòng regex khóa anh/em vs mẹ/con) → thay bằng **hồ sơ giọng + ngữ cảnh đa phương thức**. - ❌ `estimate_speaker_demographics` (wav2vec2 age/gender) → Omni nghe hiểu trực tiếp. - ❌ `_patch_speechbrain_lazy` (vá Windows) → Colab Linux không cần. - 🔄 Gemma 4 12B (llama.cpp) → **Qwen3-Omni-30B-A3B** (transformers). ## 3. Kiến trúc & luồng 5 bước ``` INPUT: video.mp4 + video.srt (ZH) │ ┌───────────────────────────────────────────────────────────────────────────────┐ │ Bước 1 Chuẩn bị SRT srt.py + scenes.py │ │ parse → gom cảnh → ghép cue vụn → char budget │ │ [tùy chọn] OCR sửa phụ đề cháy hình (correct.py) — MẶC ĐỊNH TẮT │ └───────────────────────────────────────────────────────────────────────────────┘ │ ┌───────────────────────────────────────────────────────────────────────────────┐ │ Bước 2 Phân tích người nói diarize.py │ │ pyannote community-1 → speaker turns → gán cue.speaker │ └───────────────────────────────────────────────────────────────────────────────┘ │ ┌───────────────────────────────────────────────────────────────────────────────┐ │ Bước 3 Hồ sơ giọng profiles.py │ │ Qwen3-Omni nghe 3–5 clip/speaker → VoiceProfile │ │ {gender, age_range, emotion, role_guess, register_hint} │ └───────────────────────────────────────────────────────────────────────────────┘ │ ┌───────────────────────────────────────────────────────────────────────────────┐ │ Bước 4 Dịch đa phương thức translate.py + scene_context.py │ │ Qwen3-Omni: audio cảnh + frames + cues + profiles + glossary (partial)│ │ → bản dịch VN + ghi chú xưng hô │ └───────────────────────────────────────────────────────────────────────────────┘ │ ┌───────────────────────────────────────────────────────────────────────────────┐ │ Bước 5 Hậu kiểm & xuất glossary.py + srt.py │ │ nhất quán tên/xưng hô → fit thời lượng → ghi file │ └───────────────────────────────────────────────────────────────────────────────┘ │ OUTPUT: video.vi.srt + video.vi.srt.report.json + glossary.json ``` | Bước | Module | Model | Mặc định | |---|---|---|---| | 1 — Chuẩn bị SRT | `srt.py`, `scenes.py`, `correct.py` | — / Omni (nếu bật OCR) | OCR **tắt** | | 2 — Phân tích người nói | `diarize.py` | pyannote | bật | | 3 — Hồ sơ giọng | `profiles.py` | Qwen3-Omni | bật | | 4 — Dịch | `translate.py`, `scene_context.py` | Qwen3-Omni | bật | | 5 — Hậu kiểm | `glossary.py` | — / Omni (rút gọn cue) | bật | ### Vì sao vẫn cần pyannote (Bước 2) Qwen3-Omni hiện **chưa gán nhãn speaker ổn định** trên cả phim (xác nhận qua tài liệu kỹ thuật & nghiên cứu HumanOmni-Speaker 2026 — cần adapter riêng). pyannote cho **nhãn nhất quán** để gắn hồ sơ giọng (Bước 3) cho đúng người ở mọi cảnh. ### Vì sao một mô hình Omni cho Bước 3 & 4 Đưa **audio đoạn cảnh + frame + cue** cùng lúc → model "nghe" được tông giọng (ai đang nói với ai, tình cảm hay gắt gỏng) và "thấy" bối cảnh → tự chọn xưng hô. Không còn luật cứng. ## 4. Module chi tiết (`src/omnisub/`) | File | Trách nhiệm | Nguồn | |---|---|---| | `srt.py` | Dataclass `Cue`, parse/write SRT, char budget | Port từ bản cũ | | `scenes.py` | `Scene`, `group_scenes`, `merge_adjacent_pairs` | Port, dọn lại | | `diarize.py` | Wrapper pyannote → `SpeakerTurn`, gán `cue.speaker` | Port rút gọn | | `correct.py` | Bước 1 — OCR sửa phụ đề cháy hình (**mặc định tắt**) | Port từ `correct_scene` cũ | | `profiles.py` | ★ `VoiceProfile`; trích clip/speaker; gọi Omni nghe → hồ sơ | Mới | | `scene_context.py` | Trích frame (ffmpeg), chọn audio đoạn cảnh | Port `extract_frame` + mới | | `translate.py` | Dựng prompt đa phương thức/cảnh, gọi Omni, parse kết quả | Viết lại (bỏ regex) | | `glossary.py` | ★ Nhất quán tên riêng + xưng hô theo dữ liệu (không regex) | Mới (thay NameRegistry) | | `backends/base.py` | Interface `LLMBackend.chat_json(text, images, audio, …)` | Mới | | `backends/transformers_qwen.py` | Qwen3-Omni qua HF transformers (Colab) | Mới | | `pipeline.py` | Điều phối Bước 1→5, logging, progress, stop | Viết lại gọn | | `cli.py` | Argparse CLI | Phỏng theo bản cũ | ### `VoiceProfile` (Bước 3) — cấu trúc đề xuất ```python @dataclass class VoiceProfile: speaker: str # "SPEAKER_01" (khớp pyannote) gender: str # nam | nữ | trẻ em | chưa rõ age_range: str # trẻ em | thiếu niên | thanh niên | trung niên | lớn tuổi emotion_baseline: str # vd: điềm đạm / nóng nảy / dịu dàng role_guess: str # vd: "phụ nữ trẻ, có vẻ là người yêu của SPEAKER_02" register_hint: str # gợi ý xưng hô VN: "nên dùng em với SPEAKER_02" evidence: str # vì sao (model tự giải thích ngắn) ``` Hồ sơ này được nhét vào prompt dịch để Omni giữ xưng hô nhất quán theo từng nhân vật. ### Glossary (Bước 5) — thay regex bằng dữ liệu - Thu thập **tên riêng** (token CJK lặp lại) → khóa **một** cách phiên âm VN, tái dùng (giữ ý tưởng `NameRegistry` cũ nhưng tách bạch, có thể chỉnh tay qua `glossary.json`). - **Cặp xưng hô** giữa các speaker do **model đề xuất** (không hard-code), lưu vào glossary và đưa lại vào prompt các cảnh sau để khóa nhất quán — thay cho `RelationshipRegistry`. ## 5. Backend & mô hình (Colab L4 24GB) - **Model**: `Qwen/Qwen3-Omni-30B-A3B-Instruct` (Bước 3 & 4). - **Lượng hóa**: **4-bit** (bitsandbytes hoặc checkpoint AWQ/GPTQ Int4) → ~17–18GB, vừa L4 24GB (chừa chỗ cho KV cache + encoder audio/vision). *Cần kiểm tra checkpoint 4-bit sẵn có trên HF.* - **Audio**: resample 16 kHz mono (đã có sẵn `extract_audio` bản cũ). Đưa audio **sau** text trong prompt (theo best practice Gemma/Qwen multimodal: ảnh trước text, audio sau text). - **Vision**: frame trích bằng ffmpeg, đưa **trước** text. - **Tham số sampling**: theo khuyến nghị Qwen (temperature ~0.7–1.0, top_p 0.95, top_k 64) — chốt khi test. - **Drive**: cache model ở `Gemma/Cache/`, phim ở `Gemma/Phim/` (giữ nếp cũ). ## 6. Cấu hình (`config.yaml`) ```yaml models: omni: "Qwen/Qwen3-Omni-30B-A3B-Instruct" quant: "4bit" diarize: "pyannote/speaker-diarization-community-1" translate: source_lang: "Chinese" target_lang: "Vietnamese" chars_per_sec: 22 max_line_chars: 52 max_lines: 2 correct_ocr: false # Bước 1 — OCR sửa phụ đề cháy hình (mặc định tắt) output_suffix: ".vi.srt" # giữ nếp cũ scene: max_gap: 1.5 max_cues: 4 max_dur: 20.0 profiling: clips_per_speaker: 5 max_seconds_per_clip: 8 paths: drive_root: "/content/drive/MyDrive/Gemma" ``` ## 7. Lộ trình (milestones) - **M1 — Bước 1 Chuẩn bị SRT**: scaffold thư mục, `srt.py`, `scenes.py`, `config.yaml`, `backends/base.py`. *Done khi*: parse SRT → gom cảnh → write SRT chạy được; `python -m omnisub.cli --help`. - **M2 — Backend Omni (Colab)**: notebook tải Qwen3-Omni 4-bit; `chat_json` text-only; dịch thử 1 cảnh.- **M3 — Bước 2 Diarization**: port pyannote gọn; gán `cue.speaker`; xuất nhãn. - **M4 — Bước 3 Voice Profiling ★**: `profiles.py` — Omni nghe clip → `VoiceProfile`. - **M5 — Bước 4 Dịch đa phương thức**: `translate.py` ghép audio+frame+cue+profile; **không regex**. - **M6 — Bước 5 Hậu kiểm**: nhất quán tên/xưng hô; fit timing; xuất `.vi.srt` + report JSON. - **M7 — Tài liệu & hoàn thiện**: tinh chỉnh prompt, hoàn thiện README + notebook Colab. ## 8. Rủi ro & phương án | Rủi ro | Phương án | |---|---| | Omni 4-bit không có checkpoint sẵn / chất lượng tụt | Thử AWQ Int4; nếu kẹt → tách: Qwen3-Omni nghe giọng + Qwen3-VL dịch | | L4 24GB tràn VRAM khi kèm audio+vision dài | Giảm số frame/cảnh, cắt audio ≤ 8s/clip, giảm visual token budget | | pyannote chậm với phim dài | Chỉ diarize tới `max(cue.end)`; cân nhắc NeMo Sortformer | | Diarization gán sai người nói | Cho phép sửa tay nhãn speaker trước Bước 3 | | Mất kết nối/ngắt phiên Colab giữa chừng | Cache theo cảnh, cho chạy lại từ cảnh dở; lưu kết quả ra Drive | ## 9. Quyết định (đã chốt — xem mục 0) - ✅ Qwen3-Omni **`-Instruct`** 4-bit. - ✅ OCR (Bước 1) **mặc định tắt** (`correct_ocr: false`), bật khi cần. - ✅ Tên file ra **`.vi.srt`**. ### Việc cần kiểm tra trước M2 - Xác nhận có checkpoint **Qwen3-Omni-30B-A3B-Instruct 4-bit / AWQ Int4** trên Hugging Face. Nếu chưa có → phương án dự phòng: Qwen3-Omni (nghe giọng) + Qwen3-VL (dịch). (Xem mục 8.)