---
tags:
  - pipeline
  - audio
  - myanmar
  - transcription
  - translation
  - cron
  - cache-hit
  - gemini
created: 2026-06-18
updated: 2026-08-30
---

# 06 — Pipeline Audio Myanmar → Transcript + Dịch Việt

> **Thay thế hoàn toàn Google Speech-to-Text V2 bằng Gemini 3 Flash Preview**
> Chi phí ~$0.65 cho 1 giờ audio (rẻ hơn 2 lần, chất lượng cao hơn)

---

## I. TỔNG QUAN

Pipeline 2 tầng, dùng **Gemini 3 Flash Preview** cho cả transcribe lẫn dịch:

```
Audio MP3 (52 phút)
  │
  ▼ G1: audio_preprocess.py — ffmpeg: 16kHz Mono + compand filter
  │
  ▼ G2: audio_chunk.py — Cắt 27 block × 2 phút (overlap 0.3s)
  │
  ▼ G3: gemini_transcribe.py — Gemini 3 Flash Preview
  │   Audio → [MM:SS.mmm] transcript → TXT + SRT (1 API call)
  │
  ▼ QA: qa_transcript_gaps.py — Dò gap bị sót (transcript vs audio)
  │
  ▼ Extract: SRT → 753 dòng text-only
  │
  ▼ Cron: translate-batch-v1 — Translator agent
      Mỗi 3 phút, 2 LLM calls/batch, cache-hit
      26 batch × ~30 câu → 753 câu Việt
  │
  ▼ Map → SRT Việt + File song ngữ
```

## II. CHI PHÍ (52 phút audio)

| Giai đoạn | Model | Cost |
|-----------|-------|------|
| G1+G2: ffmpeg | Local | Free |
| G3: Transcribe | Gemini 3 Flash Preview | **~$0.15** |
| Dịch (26 batch) | Gemini 3 Flash Preview (cron cache-hit) | **~$0.44** |
| **Tổng** | | **~$0.59** |

> So với pipeline cũ (STT V2 $0.84 + hiệu đính $0.48 = $1.33): **rẻ hơn 2.2 lần, chất lượng cao hơn, đơn giản hơn.**

---

## III. CÀI ĐẶT

### 3.1 Yêu cầu

```bash
# FFmpeg (có sẵn ~/bin/ffmpeg)
pip install openai --break-system-packages

# Environment
export OPENROUTER_API_KEY="sk-or-..."
```

### 3.2 Scripts

Tất cả scripts lưu trong `obsidian/quy-trinh/scripts/` để tái sử dụng:

| Script | Vai trò |
|--------|--------|
| `scripts/audio_preprocess.py` | G1: Chuẩn hóa audio (16kHz Mono + compand) |
| `scripts/audio_chunk.py` | G2: Cắt block 2 phút |
| `scripts/gemini_transcribe.py` | G3: Transcribe Audio → TXT + SRT (timestamped) |
| `scripts/qa_transcript_gaps.py` | QA: Dò gap bị sót (transcript vs audio) |
| `scripts/phase1_select_batch.py` | Cron Phase 1: chọn batch + đọc guide + glossary |
| `scripts/GUIDE-TRANSLATION.md` | Quy tắc dịch (single source of truth) |

> 📂 **Path:** `/home/tuan-nguyen/.openclaw/workspace/obsidian/quy-trinh/scripts/`

### 3.3 Model

- **Transcribe:** `google/gemini-3-flash-preview` qua **OpenRouter**
- **Dịch:** `openrouter/google/gemini-3-flash-preview` (cron payload)
- **Prompt transcribe:** Yêu cầu format `[MM:SS.mmm] text` — Gemini tự ước lượng timestamp

---

## IV. CHẠY PIPELINE

### 4.1 Toàn bộ pipeline (tự động)

```bash
cd /home/tuan-nguyen/.openclaw/workspace/012-trich-xuat-audio

# G1+G2
python3 scripts/audio_preprocess.py 012.mp3 -o output/012_clean.wav
python3 scripts/audio_chunk.py output/012_clean.wav -d 120 -o output/chunks/

# G3: Transcribe (cần OPENROUTER_API_KEY)
python3 scripts/gemini_transcribe.py output/chunks/ -o output/transcripts/

# Extract SRT → text-only
python3 -c "
import re, json
with open('output/012_final.srt') as f: srt = f.read()
lines = srt.strip().split('\n')
records = []; i = 0
while i < len(lines):
    line = lines[i].strip()
    if not line: i += 1; continue
    try: idx = int(line)
    except: i += 1; continue
    i += 1
    ts = re.match(r'(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})', lines[i].strip())
    if not ts: i += 1; continue
    start, end = ts.group(1), ts.group(2); i += 1
    text_lines = []
    while i < len(lines):
        l = lines[i].strip()
        if not l: i += 1; break
        if re.match(r'^\d+$', l) and i+1 < len(lines) and '-->' in lines[i+1]: break
        text_lines.append(l); i += 1
    text = ' '.join(text_lines).strip()
    if text: records.append({'index': len(records)+1, 'start': start, 'end': end, 'myanmar': text})
with open('output/012_srt_map.json', 'w') as f: json.dump(records, f, ensure_ascii=False, indent=2)
with open('output/012_myanmar_lines.txt', 'w') as f:
    for r in records: f.write(f\"[{r['index']-1}] {r['myanmar']}\n\")
# Create batches
BATCH_SIZE = 30
n = (len(records) + BATCH_SIZE - 1) // BATCH_SIZE
import os; os.makedirs('output/batches', exist_ok=True)
for b in range(n):
    start = b * BATCH_SIZE; end = min(start + BATCH_SIZE, len(records))
    with open(f'output/batches/012_batch_{b+1:03d}_my.txt', 'w') as f:
        for r in records[start:end]: f.write(f\"[{r['index']-1}] {r['myanmar']}\n\")
# Create progress
progress = {'project': '012', 'total_batches': n, 'batches': [{'name': f'012_batch_{i+1:03d}', 'status': 'pending'} for i in range(n)]}
with open('output/translation_progress.json', 'w') as f: json.dump(progress, f, indent=2)
print(f'{len(records)} subtitles → {n} batches')
"

# Deploy cron job (xem Section V)
```

### 4.2 Assemble SRT Việt + Song ngữ

Sau khi dịch xong:

```python
# Map translations back to SRT
import json
with open('output/012_srt_map.json') as f: records = json.load(f)

# Read all translations
vi_lines = {}
for b in range(1, 27):
    path = f'output/batches/012_batch_{b:03d}_vi.txt'
    if not os.path.exists(path): continue
    with open(path) as f:
        for line in f:
            m = re.match(r'\[(\d+)\]\s*(.+)', line)
            if m: vi_lines[int(m.group(1))] = m.group(2).strip()

# Create SRT Vietnamese
with open('output/012_final_vi.srt', 'w') as f:
    for r in records:
        vi = vi_lines.get(r['index']-1, '')
        if vi:
            f.write(f"{r['index']}\n{r['start']} --> {r['end']}\n{vi}\n\n")

# Create bilingual file
with open('output/012_bilingual.txt', 'w') as f:
    for r in records:
        vi = vi_lines.get(r['index']-1, '')
        f.write(f"[{r['index']-1}] {r['myanmar']}\n")
        if vi: f.write(f"     {vi}\n")
        f.write('\n')
```

---

## V. CRON JOB TRANSLATION (CACHE-HIT PATTERN)

### 5.1 Kiến trúc

```
CRON PROMPT (STATIC ~1.2K — cache hit 100%)
  │  Chỉ chứa script + hướng dẫn gọi tool
  │  KHÔNG chứa rules dịch, glossary
  │
  ▼ Phase 1: exec python (1 LLM call)
  │  Đọc GUIDE + glossary (SQLite) + source
  │  → In tất cả ra exec output
  │
  ▼ Phase 2: GỌI TẤT CẢ TOOL CÙNG LÚC (1 LLM call)
     write file dịch + exec update progress
```

### 5.2 Cron Job (Mẫu — cập nhật path + job ID cho dự án mới)

```json
{
  "name": "tissa-parami-translate",
  "schedule": { "kind": "every", "everyMs": 180000 },
  "sessionTarget": "isolated",
  "agentId": "translator",
  "payload": {
    "kind": "agentTurn",
    "model": "openrouter/google/gemini-3-flash-preview",
    "lightContext": true,
    "thinking": "off",
    "timeoutSeconds": 600,
    "message": "Bạn là Translator Agent dịch Myanmar → Việt.\n\n═══════════════════\nPHASE 1 (1 LLM call): CHẠY SCRIPT NÀY\n═══════════════════\n\npython3 /path/to/project/scripts/phase1_select_batch.py\n\nScript sẽ in ra: BATCH + GUIDE + SOURCE + GLOSSARY.\n\n⚠️ Nếu in \"ALL_DONE\" hoặc \"CRON_SHOULD_STOP\":\n  → GỌI NGAY: cron(action=\"remove\", jobId=\"<HARDCODE_JOB_ID>\")\n  → DỪNG, không làm gì thêm.\n\n═══════════════════\nPHASE 2 (1 response, GỌI TẤT CẢ TOOL CÙNG LÚC)\n═══════════════════\n\nĐọc GUIDE, SOURCE, GLOSSARY từ Phase 1 → dịch.\n\nGỌI ĐỒNG THỜI 2 TOOL SAU:\n\n[TOOL 1] write → /path/to/project/output/batches/{BATCH}_vi.txt\n  Nội dung: bản dịch, 1 dòng/câu, giữ số thứ tự [0],[1]...\n\n[TOOL 2] exec python:\n  import json\n  from datetime import datetime,timezone,timedelta\n  PROJ=\"/path/to/project/output\"\n  BATCH=\"{BATCH}\"\n  with open(f\"{PROJ}/translation_progress.json\") as f: data=json.load(f)\n  for b in data[\"batches\"]:\n      if b[\"name\"]==BATCH: b[\"status\"]=\"done\";b[\"completed_at\"]=datetime.now(timezone(timedelta(hours=7))).isoformat()\n  with open(f\"{PROJ}/translation_progress.json\",\"w\") as f: json.dump(data,f,indent=2,ensure_ascii=False)\n  print(\"OK\")\n\n→ Xác nhận: \"Đã dịch xong {BATCH}\""
  },
  "delivery": { "mode": "none" }
}
```

> 🔑 **Quan trọng:** Hardcode `jobId` của cron job vào prompt `<HARDCODE_JOB_ID>` để translator có thể tự gọi `cron remove` tắt job sau khi hoàn tất. Isolated cron run **có quyền** remove chính job của nó (restricted cron self-cleanup grant).

### 5.3 Deploy

```bash
openclaw cron add \
  --name translate-batch-v1 \
  --every 3m \
  --session isolated \
  --agent translator \
  --model openrouter/google/gemini-3-flash-preview \
  --light-context true --thinking off \
  --timeout-seconds 600 --delivery none \
  --message '...'  # prompt từ section 5.2
```

### 5.4 Cache Hit Strategy

- Prompt cron **hoàn toàn static** (~1.2K chars) → cache hit 100%
- Rules/glossary nằm trong `GUIDE-TRANSLATION.md`, in qua exec output
- Mỗi batch chỉ có source khác nhau (~2K chars mới)
- Token mới/batch: ~5-8K (chỉ guide + source + glossary)
- Token static (cache hit): ~1K (prompt cron)

---

## VI. OUTPUT

```
output/
├── 012_clean.wav                # Audio đã chuẩn hóa (96MB, 16kHz mono)
├── chunks/                      # 27 block × 2 phút
├── transcripts/                 # Raw TXT + SRT từng chunk
├── 012_full_transcript.txt      # Transcript Myanmar sạch (57K chars)
├── 012_final.srt                # 753 subtitles Myanmar
├── 012_final_vi.srt             # 753 subtitles tiếng Việt
├── 012_bilingual.txt            # Song ngữ Myanmar | Việt
├── 012_srt_map.json             # Map index → timestamp + text
├── 012_myanmar_lines.txt        # 753 dòng text-only
├── batches/                     # Batch files cho dịch
│   ├── 012_batch_XXX_my.txt     # ~30 dòng Myanmar/batch
│   └── 012_batch_XXX_vi.txt     # ~30 dòng Việt/batch
└── translation_progress.json    # State tracking
```

---

## VII. TÁI SỬ DỤNG CHO DỰ ÁN KHÁC

1. Tạo cấu trúc thư mục dự án mới (vd: `003_Tissa_Parami/`)
2. Copy scripts từ dự án cũ → cập nhật `PROJ` path
3. Đặt file audio vào thư mục dự án
4. Chạy G1+G2+G3 (dùng scripts trong `012-trich-xuat-audio/scripts/`)
5. Merge SRT với time offset → `_final.srt` + `_myanmar_lines.txt`
6. **QA gap bị sót (bắt buộc)** — `qa_transcript_gaps.py --srt _final.srt --audio <audio> -o reports/qa.md` → re-transcribe các gap 🔴
7. Tạo batches (25 dòng/batch) + `translation_progress.json`
8. Tạo `phase1_select_batch.py` riêng cho dự án
9. Deploy cron job (section V) — **quan trọng: hardcode job ID vào prompt để tự tắt**
10. Sau khi dịch xong → assemble SRT Việt + song ngữ (4.2)

> ⚠️ **Lưu ý:** Cập nhật `PROJ` path trong tất cả scripts + cron message cho khớp dự án mới.

---

## VIII. LƯU Ý

1. **Gemini 3 Flash Preview xử lý audio Myanmar xuất sắc** — hiểu ngữ cảnh, đúng trợ từ, phân biệt từ đồng âm
2. **Timestamp format `[MM:SS.mmm]`** trong prompt → Gemini tự ước lượng, normalize về duration
3. **Input token cố định ~3,382/chunk 120s** — dễ dự toán chi phí
4. **Cache-hit cron** giảm token ~40% so với spawn từng batch
5. **2 LLM calls/batch** — Phase 1 (exec) + Phase 2 (write+exec song song)
6. **`thinking: "off"` bắt buộc** với Gemini Flash — tránh reasoning mode tốn token vô ích
7. **Cắt block 2 phút** — tối ưu cho API limit + dễ retry
8. **Compand filter** quan trọng cho audio hội trường (đẩy giọng nhỏ = giọng to)
9. 🔑 **Cron tự tắt:** Hardcode `jobId` vào prompt + hướng dẫn gọi `cron(action="remove", jobId="...")` khi `ALL_DONE`. Isolated cron run có quyền tự xóa chính nó. Nếu prompt không hardcode job ID → cron chạy mãi không dừng.
10. **Merge SRT cần time offset:** Khi merge nhiều chunk SRT, cộng dồn duration từng chunk để timestamp liên tục. Script assemble cũ (`*_clean.txt`) không hỗ trợ G3 output (`*_gemini.txt`) — cần custom merge script.
11. **Cấu trúc per-project:** Mỗi audio nên có thư mục riêng (vd: `003_Tissa_Parami/`) với `output/` và `scripts/` riêng, thay vì dùng chung `output/` top-level.
12. **Chunk bị transcribe CỤT (Gemini dừng giữa chừng):** Kiểm tra `last timestamp` trong `*_gemini_raw.txt` — nếu << duration chunk (vd 34s/120s) và output kết thúc giữa từ → chunk bị cụt. Fix: xóa file transcript cũ → re-transcribe chunk đó bằng OpenRouter. Thêm `timeout=180.0` vào OpenAI client để tránh treo API (1 chunk treo ~4 phút không timeout).
13. **Re-transcribe bị skip:** `gemini_transcribe.py` có check "already done" (skip nếu `.txt` + `.srt` tồn tại). Phải xóa file transcript cũ trước khi re-transcribe.
14. **agentId cron = calling agent:** Gateway chặn cron job `agentId` khác calling agent. Từ session `architect` chỉ tạo được cron `agentId=architect` (bỏ trường `agentId` để auto). Prompt + model override vẫn đảm bảo dịch đúng.

---

## IX. QA TRANSCRIPT — DÒ GAP BỊ SÓT (bắt buộc sau G3)

> ⚠️ **Bài học 2026-08-30:** Gemini hay bỏ sót đoạn cuối chunk (ngừng transcribe sớm trước khi hết audio). Biểu hiện: khoảng trống giữa 2 subtitle **có tiếng nhưng không có phụ đề**. Phải chạy QA trước khi dịch.

### Cách chạy

```bash
# Path: obsidian/quy-trinh/scripts/qa_transcript_gaps.py
python3 qa_transcript_gaps.py \
  --srt output/012_final.srt \
  --audio 012.mp3 \
  -o reports/qa_012.md
```

### Phân loại kết quả

| Nhãn | Ý nghĩa | Hành động |
|------|---------|-----------|
| 🔴 SÓT | gap có tiếng ≥ 2s | Re-transcribe đoạn đó (extract audio → Gemini → chèn lại) |
| 🟡 NGHI NGỜ | gap có tiếng < 2s (thường là "đuôi câu" — timestamp hơi sớm) | Đọc nội dung 2 bên: câu trước đã đủ → bỏ qua; thiếu từ → re-transcribe |
| 🟢 NGHỈ | im lặng (< -25dB) | Khoảng lặng tự nhiên, bỏ qua |

### Nguyên lý

- So sánh timestamp SRT với audio nguồn bằng `volumedetect` (mean_volume).
- Gap > ngưỡng (1s) + âm lượng ≥ -25dB → có tiếng trong khoảng trống.
- Dùng `volumedetect` (không dùng `silencedetect`) — ổn định hơn khi chạy qua subprocess (seek MP3 không chính xác).

### Lưu ý

- Chỉ **DÒ**, không tự sửa. Gap 🔴 cần re-transcribe thủ công hoặc qua script `retranscribe_gaps.py` của dự án.
- ⚠️ **Đừng chỉ dựa vào dB:** gap ngắn có tiếng (~1.3s, -13dB) thường chỉ là **đuôi câu** (nội dung đã nằm trong subtitle trước). Luôn đọc nội dung 2 bên trước khi kết luận "sót".

---

## X. CHUẨN YOUTUBE — POST-PROCESSING SRT (bắt buộc trước khi upload)

> ⚠️ **Bài học 2026-08-29 (dự án 015-phu_de_video):** SRT merge trực tiếp từ chunk bị overlap timestamp. Phải chạy `fix_youtube_srt.py` trước khi upload.

### Lỗi cần sửa (bắt buộc)

**Overlap timestamp (~0.3s × số chunk)** — chunk cut có overlap 0.3s, khi merge không clamp `end ≤ start` segment kế tiếp → YouTube cảnh báo "overlapping".

> ✅ **Giữ nguyên câu dài (mặc định):** KHÔNG tách câu ngắn, KHÔNG xuống dòng cưỡng ép. Giữ nguyên câu dài 1 dòng như bản dịch gốc. Các ngưỡng >7s / >42 ký tự chỉ là khuyến nghị mềm của YouTube, không bắt buộc sửa.

### Script fix (post-process)

```bash
# Path: obsidian/quy-trinh/scripts/fix_youtube_srt.py
# Chỉ sửa overlap + thêm gap 30ms giữa các subtitle, giữ nguyên câu dài.
python3 fix_youtube_srt.py output/012_final_vi.srt
```

### Fix overlap ngay tại nguồn (merge)

Trong merge script, clamp `end` của segment ≤ `start` segment kế tiếp (trừ gap 30ms):

```python
GAP_MS = 30
for i in range(len(segs)):
    if i < len(segs) - 1 and segs[i]['end'] > segs[i+1]['start'] - GAP_MS:
        segs[i]['end'] = segs[i+1]['start'] - GAP_MS
```
