| # 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.) |
|
|