Tách thoại, nội tâm và gán giọng riêng cho từng nhân vật

Mục lục bài viết33 mục

Bài này sửa thẳng ba file của skill viet-truyen-trung-sinh-giau-nghe-giau-than-phan: quy ước đánh dấu mới (nhãn TÊN: cho thoại, [NT]...[/NT] cho nội tâm, một lựa chọn ký hiệu minh họa có thể đổi), bảng nhân vật→giọng trong 02-nhan-vat.md, diff cho chapter_pipeline.py, mục QA checklist, và khuyến nghị di trú 20 chương đã viết. Đọc xong, bạn sửa được ngay.

Vì sao một giọng và một loại “narration” duy nhất đang chặn cả hai mục tiêu

Hai yêu cầu của bạn thất bại vì đúng hai chỗ trong code/tts/chapter_pipeline.py, không phải vì thiếu mô hình TTS mạnh hơn hay giọng đọc hay hơn.

Yêu cầu (1), mỗi nhân vật một giọng, chặn ở main(): --voice-ref là một argparse string đơn, truyền thẳng xuống generate_audio(segments, tts_dir, voice_ref, voice_name), nơi đúng một clone_prompt được tạo trước vòng lặp rồi dùng cho mọi segment của toàn bộ dự án:

clone_prompt = model.create_voice_clone_prompt(ref_audio=voice_ref, ref_text=None)
...
def gen_one(text):
    torch.manual_seed(seed)
    audios = model.generate(
        text=text, language="Vietnamese", voice_clone_prompt=clone_prompt,
        num_step=32, guidance_scale=2.0,
    )
    return audios[0]

clone_prompt là biến ngoài, đóng (closure) trong gen_one, không có tham số nào của gen_one cho phép đổi giọng theo từng lời gọi. Muốn giọng khác nhau theo nhân vật, gen_one bắt buộc phải nhận một clone_prompt khác nhau mỗi lần gọi, và nguồn để chọn đúng clone_prompt đó phải đến từ chính segment đang xử lý, tức segment phải mang theo thông tin “ai đang nói”, mà segment hiện tại hoàn toàn không có.

Yêu cầu (2), tách tường thuật/thoại/nội tâm, chặn ở parse_chapter_text (dòng 73-90), phần phân tích ở hai mục tiếp theo. Hai điểm chặn này không độc lập: sửa yêu cầu (1) mà không sửa yêu cầu (2) vẫn vô dụng, vì nếu mọi segment đều mang type: "narration" như hiện tại, code mới dù có bảng giọng đầy đủ cũng không biết đoạn nào nên tra giọng nhân vật nào, nó chỉ thấy một chuỗi “narration” liên tục. Ngược lại, sửa yêu cầu (2) mà không sửa yêu cầu (1) thì có speaker đúng trong tay nhưng generate_audio vẫn gọi gen_one(text) bằng đúng một clone_prompt bất kể speaker là ai. Hai phần sửa phải đi cùng nhau, và outline phía dưới xử lý đúng theo thứ tự phụ thuộc đó: trước tiên đổi quy ước văn bản để segment mang được speaker+type chính xác, sau đó mới sửa generate_audio để dùng đúng thông tin đó.

Đọc lại parse_chapter_text: mọi đoạn văn thành “narration”

Cơ chế gán cứng nằm trong ba dòng cuối của vòng lặp chính:

for para in paragraphs:
    ...
    clean = para.replace("*", "")
    for sentence in split_sentences(clean):
        for chunk in split_long_sentence(sentence):
            segments.append({"type": "narration", "text": chunk})

Không có bất kỳ điều kiện if/elif nào phân nhánh theo nội dung của para trước khi gán "type": "narration", chuỗi ký tự "narration" được viết cứng (hardcode) ngay tại lời gọi segments.append. Một đoạn văn chứa "Còn mì không ạ?" anh hỏi và một đoạn văn thuần tường thuật như Mưa Trùng Khánh không rơi thẳng... đi qua chính xác cùng một nhánh code, cùng một kết quả type. Hàm split_sentences/split_long_sentence chỉ lo việc cắt câu theo độ dài (MAX_CHUNK_CHARS = 300) để mỗi lần gọi model không quá dài, chúng không hề nhìn vào dấu ngoặc kép hay bất kỳ tín hiệu ngữ nghĩa nào.

So sánh với parse_story.py, script này đã có đúng cơ chế còn thiếu: split_into_pieces quét đoạn văn theo QUOTE_RE = re.compile(r'[""\"]([^""\"]+)[""\"]'), tách đoạn thành các mảnh (is_quote, text) xen kẽ, rồi parse_paragraph gán type: "dialogue" cho mảnh trong ngoặc kép và type: "narration" cho phần còn lại. Nghĩa là bài toán “biết đoạn nào là thoại” đã có lời giải sẵn trong codebase, vấn đề không phải thiếu kỹ thuật, mà là chapter_pipeline.py (script thực sự được SKILL.md Bước 5.7 gọi) chưa bao giờ import hay gọi tới logic đó. Đây là chỗ đầu tiên phần sửa code (mục “Nối parse_story.py vào parse_chapter_text” phía sau) phải can thiệp: thay đúng ba dòng trên bằng một lời gọi tới cơ chế tách quote đã có sẵn, không viết lại từ đầu.

Dòng clean = para.replace(“*”, “”) xóa tín hiệu nội tâm ra sao

Trước khi một đoạn văn được tách câu, dòng 86 chạy đúng một phép thay thế:

clean = para.replace("*", "")

Đây là phép xóa vô điều kiện mọi ký tự * trong đoạn, không phân biệt * đang đánh dấu in nghiêng mở đầu, in nghiêng kết thúc, hay tình cờ xuất hiện vì lý do khác. Với một đoạn văn nguyên bản như dòng 13 của chuong-001.md:

*Khí vận không phải thứ có thể giữ mãi trong người, sư huynh. Nó phải chảy...*

sau clean = para.replace("*", ""), chuỗi còn lại là:

Khí vận không phải thứ có thể giữ mãi trong người, sư huynh. Nó phải chảy...

Không còn bất kỳ ký tự nào phân biệt đoạn này với một câu tường thuật thường ở ngay phía trên nó (Anh chống tay vào tường, thở dốc...). Cả hai đi tiếp qua split_sentences rồi segments.append({"type": "narration", ...}) với kết quả giống hệt nhau về type. Tín hiệu duy nhất từng tồn tại trong văn bản gốc, cặp *...*, bị xóa trước khi bất kỳ logic phân loại nào có cơ hội đọc nó, nên vấn đề không nằm ở chỗ logic phân loại kém, mà ở chỗ tín hiệu bị hủy trước khi tới logic phân loại.

Hệ quả nghe được cụ thể: đoạn hồi ức giọng A Vi và đoạn nội tâm thật của Trầm Dư (nếu có đánh dấu in nghiêng) đều được đọc bằng đúng một giọng người kể chuyện, với đúng một tông giọng gen_one(text) tạo ra, không có gì trong audio xuất ra phân biệt được “đây là ký ức của ai đó nói trong quá khứ” với “đây là văn tường thuật của người dẫn chuyện ở hiện tại”. Đây là lý do phần sửa code không thể chỉ đơn giản “đừng xóa dấu * nữa”, phải thay clean = para.replace("*", "") bằng một bước đọc dấu * như một tín hiệu cấu trúc trước khi xóa nó, xử lý ở mục “Nối parse_story.py vào parse_chapter_text” và “Thêm type inner_voice” phía sau.

Những gì parse_story.py đã giải quyết được

Bốn cơ chế trong parse_story.py đáng lấy nguyên, không viết lại:

SPEECH_VERBS là danh sách 16 động từ nói năng tiếng Việt (nói|đáp|hỏi|thì thầm|hét|gào|thở dài|cất tiếng|lặp lại|thốt lên|reo lên|gọi|kêu lên|nạt|quát|khẽ nói|buông lời) dùng làm cơ sở cho hai regex attribution: ATTRIB_AFTER_RE khớp mẫu X nói đứng ngay sau quote ("...", X nói.), ATTRIB_BEFORE_RE khớp mẫu X nói: đứng ngay trước quote (X nói: "...").

split_into_pieces(para) quét một đoạn văn theo QUOTE_RE, trả về danh sách tuple (is_quote, text) theo đúng thứ tự xuất hiện trong đoạn, đây là cơ chế tách narration/dialogue trong cùng một đoạn văn gộp mà chapter_pipeline.py hoàn toàn không có.

resolve_quote_speaker(pieces, idx, gender_hints, last_name_by_gender) nhận một quote cụ thể (theo idx trong danh sách pieces), thử khớp attribution phía sau trước (ATTRIB_AFTER_RE, “đáng tin hơn” theo đúng comment trong code), rồi mới thử phía trước (ATTRIB_BEFORE_RE). Nếu token attribution là một đại từ (anh, cô, bà…) thay vì tên riêng, hàm tra last_name_by_gender để suy ra tên thật.

build_gender_hints(full_text) quét toàn bộ chương một lần, tìm mọi cụm PRONOUN + TÊN_RIÊNG (ví dụ “anh Trầm Dư”) để suy giới tính của từng tên riêng xuất hiện trong chương, còn update_pronoun_state cập nhật “tên giới tính X được nhắc gần nhất” liên tục khi quét qua từng đoạn narration, đây là cơ chế cho phép một câu thoại chỉ có "..." anh nói (không nêu tên) vẫn resolve ra đúng tên nhân vật nam được nhắc gần nhất trước đó.

Bốn cơ chế này cộng lại giải quyết đúng phần khó nhất của bài toán multi-voice bằng heuristic thuần túy: tách được narration khỏi dialogue trong một đoạn gộp, và gán được tên nhân vật cho phần lớn lượt thoại có attribution rõ ràng. Phần còn thiếu, nêu ở mục tiếp theo, không phải vì bốn cơ chế này sai, mà vì chúng chưa từng được gọi từ nơi thực sự sinh audio, và chưa có khái niệm nào cho nội tâm.

Ba giới hạn khiến parse_story.py chưa dùng được ngay

Giới hạn 1, chưa nối vào pipeline chính. chapter_pipeline.py không có bất kỳ import parse_story hay lời gọi nào tới các hàm ở mục trước. Hai script tồn tại song song trong cùng thư mục code/tts/, một cái được SKILL.md Bước 5.7 gọi thật (chapter_pipeline.py), một cái không được gọi từ đâu cả (parse_story.py chỉ có main() đọc file rời qua sys.argv, dùng để chạy thử độc lập). Sửa giới hạn này là thuần túy kỹ thuật, copy hàm, không cần thiết kế gì mới, xử lý ở mục “Nối parse_story.py vào parse_chapter_text”.

Giới hạn 2, không có type cho nội tâm. Nhìn thẳng vào parse_paragraph (dòng 168-195), nhánh xử lý khi đoạn văn không chứa quote nào:

if not any(p[0] for p in pieces):
    for t in split_long(para):
        segments.append({"speaker": "narrator", "gender": None, "type": "narration", "text": t})
    return segments

Đoạn này gán type: "narration" cho TOÀN BỘ phần không nằm trong ngoặc kép, không có nhánh nào kiểm tra dấu *. Một đoạn nội tâm không có dấu ngoặc kép thoại (đúng bản chất của nội tâm, nó không phải lời nói ra) rơi thẳng vào nhánh này, y hệt một đoạn tường thuật cảnh vật. parse_story.py chưa từng được thiết kế để biết “nội tâm” là một khái niệm tồn tại.

Giới hạn 3, heuristic attribution vỡ khi một đoạn có nhiều lượt thoại xen hành động. ATTRIB_AFTER_RE/ATTRIB_BEFORE_RE chỉ nhìn vào mảnh pieces[idx-1]/pieces[idx+1], tức câu văn NGAY SÁT quote, không nhìn xa hơn. Đúng văn phong chuong-001.md: dòng 43 "Sao cậu biết đường nào thằng Bảo hay đi?" không có động từ nói năng nào đứng sát ngay trước hay sau nó trong cùng đoạn, câu văn trước nó là mô tả hành động (“Bà Lục dừng tay, lần đầu tiên nhìn thẳng vào mắt anh…”) không chứa từ nào trong SPEECH_VERBS. Với văn phong gộp đoạn (thoại + attribution + hành động liên tục, không xuống dòng theo lượt) đang dùng trong toàn bộ dự án mẫu, tỷ lệ attribution “đứng sát” quote thấp hơn nhiều so với văn phong chuẩn "...", X nói. mà regex được thiết kế cho, số liệu cụ thể tính trên chính đoạn này nằm ở mục “Chương cũ chạy qua parser mới sẽ sai ra sao” phía sau.

Ba giới hạn này định hình toàn bộ hướng giải quyết của bài: giới hạn 1 là việc nối code (dễ), giới hạn 2 đòi một quy ước văn bản mới (phần lớn nội dung phía dưới), giới hạn 3 là lý do quy ước mới không thể dựa tiếp vào heuristic attribution mà phải chuyển sang nhãn tường minh.

Vì sao phải sửa từ văn bản gốc, không chỉ vá parser

Đụng độ ý nghĩa của dấu in nghiêng, dùng cho cả hồi ức lẫn (nếu áp dụng) nội tâm, không phải lỗi có thể vá bằng cách viết thêm điều kiện if trong parser, vì bản chất của nó là một ký hiệu mang hai nghĩa khác nhau tùy ngữ cảnh mà chỉ người viết mới biết, không phải thứ suy ra được từ hình dạng ký tự. Dòng 13 của chuong-001.md, *Khí vận không phải thứ có thể giữ mãi trong người, sư huynh...*, và một đoạn nội tâm giả định *Mình không thể để lộ ra ngoài lúc này...* có cùng cấu trúc ký tự: cặp dấu * bao quanh một câu tiếng Việt. Không có quy tắc cú pháp nào (độ dài câu, dấu chấm câu, đại từ nhân xưng) phân biệt được hai trường hợp một cách đáng tin cậy trên diện rộng, phân biệt được chúng đòi hỏi hiểu rằng câu đầu là lời của A Vi (một nhân vật khác, ở một thời điểm khác) còn câu sau là ý nghĩ của nhân vật đang là POV ở hiện tại, tức đòi hỏi hiểu NGỮ CẢNH TRUYỆN, không phải hiểu CÚ PHÁP VĂN BẢN.

Đây chính là ranh giới mà brief của bài này đặt ra tường minh: quy ước phải “đủ tường minh để parser không cần đoán qua heuristic ngữ nghĩa nữa”. Một parser dù viết khéo đến đâu cũng không thể tách được hai nghĩa của cùng một ký hiệu bằng suy luận cú pháp thuần túy, nó cần thứ mà chỉ người viết, tại thời điểm gõ câu văn đó, biết chắc: đây là hồi ức hay đây là suy nghĩ hiện tại. Nghĩa là điểm quyết định phải nằm ở Bước 5 của SKILL.md (lúc viết chương), không phải ở chapter_pipeline.py (lúc parse audio), người viết gõ đúng ký hiệu ngay khi viết, parser chỉ việc đọc ký hiệu đó, không đoán.

Đây cũng là lý do toàn bộ phần còn lại của bài đi theo đúng thứ tự: trước tiên là quy ước đánh dấu ở tầng văn bản (mục tiếp theo), sau đó mới là sửa code để đọc đúng quy ước đó, sửa code trước khi có quy ước rõ ràng sẽ chỉ tạo ra một heuristic tinh vi hơn, không giải quyết được gốc vấn đề.

Ba nguyên tắc ràng buộc cho quy ước markup mới

Trước khi vào cú pháp cụ thể, ba ràng buộc dưới đây quyết định mọi lựa chọn ký hiệu ở các mục sau, mỗi lần một ký hiệu cụ thể được chọn, nó phải thắng cả ba, không chỉ một.

Tường minh hơn heuristic ngữ nghĩa. Một ký hiệu chỉ hợp lệ nếu một regex đơn giản, neo tại vị trí cố định (đầu dòng, hoặc cặp mở/đóng tường minh), đọc ra đúng loại nội dung mà không cần biết ngữ cảnh truyện. Đây là tiêu chuẩn loại bỏ ngay phương án “suy luận qua độ dài câu” hay “suy luận qua đại từ nhân xưng”, cả hai đều là heuristic ngữ nghĩa trá hình.

Đủ đơn giản để nhiều người, nhiều session tuân theo nhất quán. Dự án viết theo mô hình soft-lock, nhiều người giữ khóa các chương khác nhau cùng lúc, không ai đọc lại toàn bộ quy ước trước mỗi lần viết. Một quy ước đòi nhớ quá nhiều trường hợp ngoại lệ (ví dụ: “nội tâm dùng ký hiệu A trừ khi lồng trong hồi ức thì dùng ký hiệu B trừ khi…” nhiều tầng) sẽ bị áp dụng sai ngay từ chương thứ hai. Ba ký hiệu, không hơn (thoại, nội tâm, hồi ức, tường thuật là mặc định không cần ký hiệu) là số lượng người viết còn nhớ được không cần tra tài liệu mỗi lần.

Không phá khả năng resume của segments.json/state.json hiện có. sync_segments xác định chương nào đã xử lý qua state.json["processed_chapters"], và generate_audio xác định segment nào đã có audio qua index trong manifest.json. Quy ước mới không được đổi ý nghĩa của những gì đã ghi, nó chỉ được phép làm giàu thêm dữ liệu của các segment MỚI sinh ra từ các chương chưa xử lý, không đòi phải xóa/viết lại segments.json/manifest.json đã tồn tại. Ràng buộc này trở thành trọng tâm ở phần “Sửa sync_segments để mang theo speaker” và toàn bộ phần di trú 20 chương cũ phía sau.

Ba ràng buộc này áp dụng cho mọi lựa chọn ký hiệu ở các mục tiếp theo, khi một phương án khác (ví dụ khối cue kiểu kịch bản audio-drama toàn hoa, hoặc file sidecar tách rời) được cân nhắc, nó được đánh giá lại đúng theo ba tiêu chí này, không phải theo cảm tính “trông chuyên nghiệp hơn”.

Quy ước tách lời thoại: mỗi lượt một dòng, có nhãn tên

Cú pháp chốt: mỗi lượt thoại đứng riêng một dòng (một đoạn văn markdown độc lập, ngăn cách bằng dòng trống với phần tường thuật xung quanh), mở đầu bằng tên nhân vật viết đúng như trong 02-nhan-vat.md, theo sau bởi dấu hai chấm và một khoảng trắng, rồi mới tới lời thoại:

TÊN_NHÂN_VẬT: Lời thoại nguyên văn, giữ dấu câu tiếng Việt bình thường.

Quy tắc neo: nhãn chỉ hợp lệ khi đứng ở VỊ TRÍ ĐẦU DÒNG (sau khi tách văn bản theo \n, đúng đơn vị mà cả chapter_pipeline.py lẫn parse_story.py đã dùng), tên nhân vật dùng đúng bộ chữ cái tiếng Việt liệt kê tường minh (như biến UP trong parse_story.py, không dùng range Unicode kiểu [A-ZÀ-Ỹ] vì range đó khớp nhầm nhiều ký tự không liên quan), và không dài quá khoảng 30 ký tự trước dấu hai chấm đầu tiên. Regex cụ thể: ^([A-ZÀÁẢÃẠ...ỴÝ][^:\n]{0,30}):\s+(.+)$, chỉ cần neo ^ là đủ loại trừ mọi câu tường thuật có dấu hai chấm nằm giữa câu (dấu hai chấm giữa dòng không bao giờ khớp regex neo đầu dòng).

Hành động và attribution (“X nói, giọng đều đều…”) bị TÁCH RA khỏi dòng thoại, viết thành một đoạn tường thuật riêng đứng trước hoặc sau dòng TÊN:, không còn gộp chung một đoạn văn như văn phong hiện tại. Đây là thay đổi lớn nhất so với văn phong chuong-001.md gốc, mục tiếp theo áp trực tiếp quy tắc này lên nguyên văn đoạn quán mì để thấy rõ hình dạng trước/sau.

Nhãn TÊN: không thay thế cho việc miêu tả cách nói (giọng run, giọng đều đều, ngập ngừng…), những mô tả đó vẫn ở lại trong đoạn tường thuật kèm theo, TTS không đọc “TÊN:” thành lời (nhãn bị lọc bỏ ở bước parse trước khi đưa text vào model.generate(), xử lý ở mục “Thêm type inner_voice”), nó chỉ dùng để chọn đúng voice_ref cho segment đó.

Một phương án khác từng cân nhắc: khối cue kiểu kịch bản audio-drama chuyên nghiệp, tên nhân vật viết hoa toàn bộ đứng riêng hẳn một dòng, tách biệt hoàn toàn khỏi attribution thay vì đứng chung dòng với lời thoại. Về độ chính xác parse, hai lược đồ tương đương nhau, cả hai đều neo ^ ở đầu dòng với cùng tập ký tự hợp lệ cho tên, không lược đồ nào giảm lỗi hơn lược đồ kia. Nhãn TÊN: được chọn vì lý do thực tiễn: ít cồng kềnh hơn khi gõ tay, gần văn phong tiểu thuyết Việt hơn nên người viết ít bị rớt nhịp văn khi gõ so với một khối cue toàn chữ hoa tách dòng.

Viết lại đoạn quán mì trong chuong-001.md theo quy ước thoại mới

Bản gốc (dòng 21-25), thoại và attribution gộp trong cùng đoạn văn với hành động:

Anh bước ra khỏi mái hiên, đi ngược dòng người, tới một quán mì nhỏ nằm khuất trong con hẻm dốc phía sau khu Giải Phóng Bi. Bà chủ quán, người anh chỉ biết là "Bà Lục" vì tấm biển hiệu đã phai chữ, đang dọn bàn chuẩn bị đóng cửa.

"Còn mì không ạ?" anh hỏi, giọng đều đều, không để lộ cơn đau đang lan từ ngực xuống bụng.

Bà Lục nhìn anh, rồi nhìn cơn mưa sau lưng anh, gật đầu. "Ngồi đi. Nồi cuối cùng đấy."

Viết lại theo quy ước mới, tách hành động/attribution khỏi lời thoại, mỗi lượt thoại một dòng riêng:

Anh bước ra khỏi mái hiên, đi ngược dòng người, tới một quán mì nhỏ nằm khuất trong con hẻm dốc phía sau khu Giải Phóng Bi. Bà chủ quán, người anh chỉ biết là "Bà Lục" vì tấm biển hiệu đã phai chữ, đang dọn bàn chuẩn bị đóng cửa.

Anh hỏi, giọng đều đều, không để lộ cơn đau đang lan từ ngực xuống bụng.

TRẦM DƯ: Còn mì không ạ?

Bà Lục nhìn anh, rồi nhìn cơn mưa sau lưng anh, gật đầu.

BÀ LỤC: Ngồi đi. Nồi cuối cùng đấy.

Chú ý ba điều khi viết lại kiểu này: (1) attribution (“Anh hỏi, giọng đều đều…”) đứng thành đoạn tường thuật riêng ngay trước dòng thoại, không đứng chung dòng với nó, parser đọc đoạn này với speaker: "narrator", type: "narration" như bình thường; (2) tên trong nhãn dùng chính xác tên đã đăng ký trong bảng nhân vật→giọng (“TRẦM DƯ”, không phải “Anh” hay “Trầm Dư, cái tên anh đang dùng…”) để tra voice_ref không bị trượt; (3) câu dẫn “Còn mì không ạ?” trong bản gốc còn giữ dấu ngoặc kép, ở quy ước mới, dấu ngoặc kép quanh lời thoại KHÔNG còn bắt buộc (nhãn TÊN: đã đủ đánh dấu ranh giới), nhưng giữ lại cũng không sai, vì regex neo ^TÊN: không quan tâm phần sau dấu hai chấm có ngoặc kép hay không.

Ký hiệu riêng cho nội tâm: [NT]…[/NT] và lý do chọn

Ký hiệu chốt cho nội tâm của nhân vật đang là POV: cặp mở/đóng tường minh [NT]...[/NT], bọc trọn một hoặc nhiều câu liên tiếp:

[NT]Mình không thể để lộ ra ngoài lúc này.[/NT]

Đây là một lựa chọn minh họa cụ thể mà bài này đề xuất, người đọc có thể đổi sang ký hiệu khác miễn giữ nguyên tắc “khác hẳn in nghiêng” bên dưới, nhưng lý do chọn cặp mở/đóng thay vì hai phương án còn lại trong brief gốc đáng nêu rõ:

So với > (kiểu blockquote Markdown): dấu > chỉ đánh dấu được MỘT DÒNG hoặc một khối liên tục bắt đầu từ đầu dòng, không tự nhiên khi nội tâm xen giữa một câu tường thuật dài (ví dụ nội tâm chỉ dài nửa câu rồi tường thuật tiếp tục ngay trên cùng dòng văn). [NT]...[/NT] không bị ràng buộc vị trí dòng, đóng mở được ở bất kỳ đâu trong một đoạn văn, kể cả giữa câu.

So với tái dùng in nghiêng: đã loại ở mục “Vì sao phải sửa từ văn bản gốc”, đây chính là đụng độ đang tồn tại, không phải phương án khả thi.

Cặp mở/đóng tường minh còn có một lợi thế kỹ thuật cụ thể mà ký hiệu một-ký-tự không có: parser luôn biết chắc CHỖ KẾT THÚC của một đoạn nội tâm mà không cần đoán qua ranh giới câu hay ranh giới đoạn văn, hữu ích trực tiếp cho quy tắc auto-close ở mục tiếp theo, nơi việc phát hiện “[NT] mở nhưng chưa gặp [/NT]” là một điều kiện regex đơn giản, còn phát hiện “in nghiêng mở nhưng chưa đóng” thực ra CŨNG đơn giản tương tự, điểm khác biệt thật sự là in nghiêng đã bị chiếm nghĩa từ trước, còn [NT] là ký hiệu trắng, không mang gánh nặng lịch sử nào trong corpus hiện có.

Đoạn hồi ức (*...*) giữ nguyên như hiện trạng, không đổi gì, mục tiếp theo nêu rõ ranh giới giữa hai ký hiệu.

Ranh giới bắt buộc: [NT] không được vắt qua nhiều đoạn văn

Một ràng buộc bắt buộc đi kèm ký hiệu [NT]: mỗi cặp [NT]...[/NT] phải mở và đóng trong CÙNG MỘT đoạn văn (khối văn bản giữa hai dòng trống), không được để [NT] mở ở đoạn này rồi [/NT] đóng ở đoạn sau. Nếu một đoạn nội tâm thật sự dài, cần nhiều đoạn văn vì lý do nhịp đọc, viết thành nhiều cặp [NT]...[/NT] liên tiếp, mỗi cặp trong một đoạn văn riêng, không viết một cặp duy nhất vắt qua nhiều đoạn.

Lý do ràng buộc này tồn tại: trong một dự án nhiều người viết ở nhiều session khác nhau, quên đóng thẻ là lỗi thực tế sẽ xảy ra, không phải giả định lý thuyết. Với ràng buộc “không vắt qua đoạn văn”, parser có một điểm dừng an toàn tường minh: nếu quét hết một đoạn văn mà gặp [NT] không có [/NT] tương ứng, tự động coi đoạn văn kết thúc là điểm đóng, ghi log cảnh báo, và KHÔNG để lỗi lan sang đoạn văn tiếp theo. Thiệt hại tối đa của một lần quên đóng thẻ là một đoạn văn bị đọc sai giọng, không phải toàn bộ phần còn lại của chương.

Ràng buộc thứ hai đi kèm: [NT]...[/NT] phải bọc TRỌN VẸN câu, không để lộ nửa câu tường thuật ở giữa mà chưa đóng thẻ trước, nếu không, đoạn tường thuật kẹt giữa hai lần mở [NT] sẽ bị merge nhầm vào segment nội tâm cho tới khi gặp [/NT] thật.

Việc lồng [NT] vào bên trong cặp in nghiêng hồi ức (nhân vật hiện tại vừa hồi tưởng vừa tự vấn ngay trong ký ức) là hợp lệ và không vi phạm ràng buộc trên, miễn cặp [NT] bên trong vẫn tự đóng gọn trong cùng đoạn văn, trường hợp lồng cụ thể được xử lý ở mục “Case khó: thoại nhớ lại lồng trong hồi ức” phía sau.

Viết lại đoạn hồi ức lẫn nội tâm trong chuong-001.md (dòng 11-15)

Bản gốc, in nghiêng dùng cho lời hồi ức của A Vi, không có gì đánh dấu nội tâm thật của Trầm Dư nằm ngay câu trước đó:

Anh chống tay vào tường, thở dốc. Trong đầu, một mảnh ký ức không mời mà đến, không phải hình ảnh, mà là một giọng nói, giọng của chính anh, nhưng cách đây rất lâu, ở một nơi không có tên trên bản đồ nào của thế giới này.

*Khí vận không phải thứ có thể giữ mãi trong người, sư huynh. Nó phải chảy. Ngăn nó lại, nó sẽ chảy ngược vào xương cốt của chính ngươi.*

Là giọng của A Vi. Mười năm rồi, giọng nói ấy vẫn rõ như mới hôm qua.

Viết lại theo quy ước mới, giữ nguyên in nghiêng cho hồi ức (không đổi ký hiệu), gắn thêm nhãn TÊN: bên trong cặp in nghiêng để chỉ rõ đây là lời của A Vi chứ không phải nội tâm Trầm Dư:

Anh chống tay vào tường, thở dốc. Trong đầu, một mảnh ký ức không mời mà đến, không phải hình ảnh, mà là một giọng nói, giọng của chính anh, nhưng cách đây rất lâu, ở một nơi không có tên trên bản đồ nào của thế giới này.

*A VI: Khí vận không phải thứ có thể giữ mãi trong người, sư huynh. Nó phải chảy. Ngăn nó lại, nó sẽ chảy ngược vào xương cốt của chính ngươi.*

Là giọng của A Vi. Mười năm rồi, giọng nói ấy vẫn rõ như mới hôm qua.

Nếu ở gần đó có một câu nội tâm thật của Trầm Dư (giả định thêm để minh họa, không có trong bản gốc), ví dụ ngay sau khi hồi tưởng, anh tự nhủ mình không được để lộ, câu đó dùng [NT], không phải in nghiêng:

Là giọng của A Vi. Mười năm rồi, giọng nói ấy vẫn rõ như mới hôm qua.

[NT]Nếu để lộ ra bây giờ, mọi thứ mười năm qua sẽ đổ sông đổ biển.[/NT]

Kết quả: hai ký hiệu khác nhau (*TÊN: ...* cho hồi ức có gắn tên người nói, [NT]...[/NT] cho nội tâm hiện tại) không còn khả năng đụng độ nhau, vì parser đọc * để nhận diện “đây là khối hồi ức” (giữ nguyên type cũ hoặc gán type mới tùy thiết kế ở mục sau) trong khi đọc [NT] để nhận diện “đây là type inner_voice”, hai luồng xử lý tách bạch hoàn toàn ở tầng regex, không cần suy luận ngữ cảnh.

Tường thuật mặc định: case còn lại không cần đánh dấu gì

Sau ba quy ước ở trên (nhãn TÊN: cho thoại, [NT]...[/NT] cho nội tâm, *...* giữ nguyên cho hồi ức), phần còn lại của một chương, mọi đoạn văn không khớp bất kỳ mẫu nào trong ba quy ước đó, mặc định là tường thuật, không cần ký hiệu gì thêm. Đây đúng là hành vi ngầm định mà parse_chapter_text hiện tại đã có (type: "narration" là kết quả cho mọi thứ không rơi vào nhánh khác), chỉ khác ở chỗ sau khi sửa, nhánh “mọi thứ khác” chỉ còn nhận đúng phần thực sự là tường thuật, không còn “nuốt” luôn cả thoại và nội tâm như hiện tại.

Về mặt thiết kế quy ước, đây là điểm quan trọng cần nói rõ với người viết: KHÔNG cần học một ký hiệu thứ tư cho tường thuật, im lặng (không có TÊN: ở đầu dòng, không có [NT], không có *) chính là ký hiệu của nó. Giữ số ký hiệu cần nhớ ở mức ba, đúng nguyên tắc “đủ đơn giản để nhiều người, nhiều session tuân theo nhất quán” đã đặt ra ở phần đầu bài.

Một điểm cần lưu ý khi viết: một đoạn văn có thể chứa NHIỀU đoạn tường thuật xen giữa các dòng thoại/nội tâm khác nhau, ví dụ đoạn tường thuật ngắn “Bà Lục múc mì, không ngẩng đầu.” đứng giữa hai dòng thoại trong ví dụ ở mục “Case khó” phía sau. Mỗi đoạn tường thuật như vậy vẫn là một segment type: "narration" riêng, speaker: "narrator", không gộp chung với dòng thoại đứng cạnh nó dù về mặt đọc mắt chúng liền mạch với nhau.

Bảng cheat-sheet 4 loại nội dung và ký hiệu tương ứng

Bảng tra nhanh dán ngay đầu 02-nhan-vat.md hoặc 00-tong-quan.md, để người giữ khóa một chương không phải lật tài liệu này mỗi lần viết:

Loại nội dungKý hiệuVí dụspeaker (segment)type (segment)
Tường thuậtKhông đánh dấu gì (mặc định)Mưa Trùng Khánh không rơi thẳng...narratornarration
Lời thoạiTÊN: lời thoại, đầu dòng riêngTRẦM DƯ: Còn mì không ạ?tên nhân vậtdialogue
Nội tâm (POV hiện tại)[NT]...[/NT], đóng trong cùng đoạn văn[NT]Mình không thể để lộ...[/NT]nhân vật POV của chươnginner_voice
Hồi ức / lời nhớ lại*...*, giữ nguyên hiện trạng*A VI: Khí vận không phải...*nhân vật được nhớ lại (nếu có nhãn TÊN: bên trong) hoặc narratornarration (giữ nguyên hành vi cũ, không đổi type)

Ba điều cần nhớ khi tra bảng này lúc viết: (1) TÊN: chỉ hợp lệ ở ĐẦU DÒNG, không dùng nó giữa câu tường thuật; (2) [NT] không được vắt qua nhiều đoạn văn, mỗi đoạn nội tâm dài chia thành nhiều cặp; (3) hồi ức không bắt buộc phải gắn TÊN: bên trong, chỉ gắn khi muốn TTS đọc bằng giọng người được nhớ lại thay vì giọng người đang hồi tưởng, quyết định biên tập này thuộc về người viết chương, không phải quy tắc cứng.

Bảng này không thay thế bảng nhân vật→giọng (mục sau), nó chỉ trả lời “gõ ký hiệu gì”, còn bảng nhân vật→giọng trả lời “ký hiệu đó tương ứng file âm thanh nào”.

Case khó: đoạn văn xen hành động-thoại-nội tâm liên tục

Đoạn khó nhất trong toàn bộ chuong-001.md để tách đúng là đoạn dòng 33-45 (khe hở khí vận xả ra ở quán mì), hành động, nội tâm ngầm (dạng tường thuật giải thích cơ chế truyện, không phải nội tâm nhân vật), và hai lượt thoại xen kẽ liên tục trong vài đoạn văn liền nhau. Bản gốc:

"Cháu bà là đứa nhỏ trong ảnh à?" anh hỏi, cố giữ giọng bình thường.

Bà Lục múc mì, không ngẩng đầu. "Ừ, thằng Bảo. Học lớp năm."

"Mấy hôm tới, có lẽ bà nên dặn nó đi học không được đi qua ngã tư có cây cầu vượt sơn xanh gần trường. Đi đường vòng cũng được." Anh nói xong, tự thấy giọng mình có gì đó lạ, quá chắc chắn, quá không giống một câu nói bâng quơ.

Viết lại: tách từng lượt thoại thành dòng TÊN: riêng, giữ nguyên phần tường thuật/nội tâm-ngầm-của-người-kể xen giữa làm đoạn narration độc lập:

Anh hỏi, cố giữ giọng bình thường.

TRẦM DƯ: Cháu bà là đứa nhỏ trong ảnh à?

Bà Lục múc mì, không ngẩng đầu.

BÀ LỤC: Ừ, thằng Bảo. Học lớp năm.

TRẦM DƯ: Mấy hôm tới, có lẽ bà nên dặn nó đi học không được đi qua ngã tư có cây cầu vượt sơn xanh gần trường. Đi đường vòng cũng được.

Anh nói xong, tự thấy giọng mình có gì đó lạ, quá chắc chắn, quá không giống một câu nói bâng quơ.

Quy tắc rút ra để áp dụng chung: khi một đoạn văn gốc chứa NHIỀU hơn một lượt thoại xen hành động, tách theo trình tự đọc tự nhiên, mỗi lần chuyển từ “ai đó hành động/được mô tả” sang “ai đó nói”, xuống dòng mới; mỗi lần chuyển ngược lại, cũng xuống dòng mới. Không gộp hai lượt thoại của hai nhân vật khác nhau vào cùng một dòng dù chúng liền mạch trong bản gốc, mỗi dòng TÊN: chỉ chứa lời của đúng một người nói.

Về nhịp văn: tách dòng nhiều hơn không làm hỏng cảm giác đọc nếu người viết coi đây là quy ước ĐỊNH DẠNG cho việc lưu file, không phải quy tắc VIẾT VĂN, khi xuất ra final.md/đọc trên nền tảng đăng truyện, có thể (tùy công cụ xuất bản) hiển thị lại không cần nhãn TÊN: nếu ngữ cảnh đã đủ rõ; nhưng file nguồn dùng cho TTS luôn giữ nhãn tường minh.

Case khó: thoại nhớ lại lồng trong hồi ức

Trường hợp lồng hai lớp, một nhân vật khác đang được nhớ lại nói một câu thoại NGUYÊN VĂN bên trong đoạn hồi ức của nhân vật POV hiện tại, giải quyết bằng cách áp đúng nhãn TÊN: đã dùng cho thoại thường vào BÊN TRONG cặp *...*, như đã làm ở mục “Viết lại đoạn hồi ức lẫn nội tâm” với A Vi. Đây không phải một ký hiệu thứ tư, nó là composition của hai ký hiệu đã có: * đánh dấu “đây là khối hồi ức” (trục thời gian: quá khứ, không phải hiện tại), TÊN: bên trong đánh dấu “đây là lời của ai” (trục người nói), hai trục độc lập, không xung đột nhau.

Trường hợp phức tạp hơn: hồi ức có NHIỀU lượt thoại qua lại giữa hai nhân vật trong quá khứ (ví dụ một đoạn đối thoại cũ được nhớ lại nguyên vẹn). Áp y hệt quy tắc ở mục “Case khó: đoạn văn xen hành động-thoại-nội tâm”, mỗi lượt một dòng TÊN: riêng, nhưng toàn bộ khối vẫn nằm trong MỘT cặp *...* bao ngoài cùng nếu muốn giữ nguyên tắc “hồi ức không vắt qua nhiều đoạn văn” tương tự [NT]; nếu hồi ức dài hơn một đoạn văn, chia thành nhiều cặp *...* liên tiếp, mỗi cặp một đoạn văn, đúng logic đã áp cho [NT] ở mục “Ranh giới bắt buộc”.

Trường hợp nội tâm của nhân vật POV xen NGAY TRONG một đoạn hồi ức (nhân vật vừa hồi tưởng vừa tự vấn về chính ký ức đó), ví dụ: nhân vật nhớ lại lời A Vi, rồi ngay sau đó tự hỏi liệu A Vi có còn sống không, viết thành hai đoạn văn tách biệt, đoạn hồi ức dùng *...*, đoạn nội tâm dùng [NT]...[/NT], không lồng [NT] vào bên trong cặp *...* cùng cấp với nhãn TÊN: (tránh ba ký hiệu chồng nhau trong cùng một khối, vi phạm nguyên tắc đơn giản). Nếu nội tâm và hồi ức thực sự cần đứng liền một hơi văn không ngắt đoạn, đây là dấu hiệu nên tách thành hai câu/đoạn riêng khi viết, không phải dấu hiệu cần thêm ký hiệu mới.

Vị trí bảng nhân vật → giọng: 02-nhan-vat.md, không phải 00-tong-quan.md

Bảng nhân vật→giọng đặt trong brief/02-nhan-vat.md, ngay dưới hồ sơ từng nhân vật, không đặt trong 00-tong-quan.md. Ba lý do kỹ thuật, không phải sở thích cá nhân:

02-nhan-vat.md đã là nơi liệt kê MỌI nhân vật, kể cả “nhân vật phụ xuất hiện thưa thớt” theo đúng yêu cầu bắt buộc của cau-truc-brief-chung.md (“hồ sơ nhân vật phải liệt kê cả nhân vật phụ xuất hiện thưa thớt, không chỉ tuyến chính”), đây chính xác là tập hợp cần có voice_ref, kể cả Bà Lục (chỉ xuất hiện 1 chương, có 2 lượt thoại). Đặt bảng giọng ở 00-tong-quan.md (nơi hiện chỉ có “danh sách nhân vật chính, tóm tắt 1 câu vai trò”) sẽ tạo ra hai danh sách nhân vật không đồng bộ, một đầy đủ ở 02-nhan-vat.md, một rút gọn kèm giọng ở 00-tong-quan.md, và với soft-lock theo dòng chương, không ai chịu trách nhiệm giữ hai danh sách này khớp nhau khi có nhân vật mới xuất hiện giữa truyện.

load_voice_table (mục sau) cần đọc đúng MỘT NGUỒN để tránh hai bảng ghi hai voice_ref khác nhau cho cùng một tên, đặt cạnh hồ sơ nhân vật nghĩa là bất kỳ ai thêm nhân vật mới vào 02-nhan-vat.md cũng thấy ngay cột giọng đang chờ được điền, không phải một bước riêng dễ quên ở file khác.

00-tong-quan.md vẫn giữ đúng vai trò hiện có của nó (tên truyện, thể loại, độ dài dự kiến), không phình thêm một bảng có thể dài bằng cả một bảng nhân vật thứ hai. Thay đổi duy nhất ở 00-tong-quan.md, xử lý ở mục “Sửa Bước 4”, là bỏ câu “chốt 1 giọng đọc duy nhất” và thay bằng một câu trỏ sang 02-nhan-vat.md.

Cấu trúc cột bảng nhân vật → giọng

Thêm một bảng mới ở cuối 02-nhan-vat.md, tiêu đề ## Bảng giọng đọc (TTS), bốn cột:

Tên (khớp nhãn TÊN: trong chương)Đường dẫn voice-refGiới tínhGhi chú tông giọng
__default__code/tts/voices/nam-tram-40.wav,Giọng người dẫn chuyện, dùng cho narration và mọi speaker chưa có dòng riêng
TRẦM DƯcode/tts/voices/nam-tram-30-menh-mong.wavNamNhân vật chính, giọng trầm, tiết chế cảm xúc
BÀ LỤCcode/tts/voices/nu-gia-am-am.wavNữNhân vật phụ, xuất hiện từ chương 1, giọng lớn tuổi ấm
A VIcode/tts/voices/nam-tre-nhe-nhang.wavNamChỉ xuất hiện qua hồi ức, không xuất hiện trực tiếp
TỐNG DIỆUcode/tts/voices/nu-lanh-sac.wavNữPhản diện, giọng lạnh

Bốn quy tắc ràng buộc cho bảng này: (1) cột “Tên” phải khớp CHÍNH XÁC chuỗi ký tự dùng trong nhãn TÊN: ở văn bản chương (viết hoa toàn bộ, không dấu ngoặc, không khoảng trắng thừa), sai khác một ký tự sẽ khiến load_voice_table không tra được và rơi vào __default__, xử lý ở mục “Validate sớm”; (2) dòng __default__ là BẮT BUỘC, không được thiếu, đây là giọng fallback cho narration và cho bất kỳ speaker nào chưa kịp đăng ký; (3) một nhân vật chỉ xuất hiện qua hồi ức (như A Vi, chưa từng “xuất hiện trực tiếp” trong truyện đang viết) vẫn cần một dòng riêng nếu muốn TTS đọc hồi ức bằng giọng của họ, không xóa dòng của nhân vật khi họ ngừng xuất hiện trực tiếp, đúng như phân tích ở mục “case nhân vật đã hết vai trò” (phần di trú/QA phía sau nhắc lại điểm này); (4) đường dẫn voice-ref là đường dẫn tương đối từ gốc repo, giống cách --voice-ref hiện tại được truyền, để không đổi quy ước đường dẫn đang dùng ở nơi khác trong code/tts/.

Bảng này được điền lần đầu ở Bước 2 (Dựng Brief) cùng lúc với hồ sơ nhân vật chính, và được bổ sung dần khi nhân vật phụ mới xuất hiện ở các chương sau, không cần điền đủ hết nhân vật phụ ngay từ đầu, miễn dòng __default__ luôn có sẵn để không chương nào bị chặn vì thiếu giọng.

Sửa Bước 4 (bàn giao) của SKILL.md

Đoạn hiện tại trong SKILL.md (Bước 4):

Nếu dự án này sẽ được đọc thành audio/video (xem "Tích hợp TTS theo chương" ở cuối file):
chốt 1 giọng đọc duy nhất cho toàn truyện ngay ở bước này (không đổi giữa chừng), chọn
từ code/tts/voices/ (ưu tiên voice gắn nhãn "đọc truyện" hợp giới tính/tông truyện, không
dùng nhãn "Quảng cáo"), và nếu có, 1 ảnh nền tĩnh duy nhất. Ghi rõ đường dẫn giọng + ảnh
đã chọn vào 00-tong-quan.md để mọi lần chạy TTS sau này dùng lại đúng 1 giọng, không cần
hỏi lại.

Thay bằng:

Nếu dự án này sẽ được đọc thành audio/video (xem "Tích hợp TTS theo chương" ở cuối file):
điền đủ bảng "Bảng giọng đọc (TTS)" ở cuối 02-nhan-vat.md ngay ở bước này, tối thiểu gồm
dòng __default__ (giọng người dẫn chuyện, bắt buộc) và mọi nhân vật chính đã xuất hiện
trong outline hồi đầu tiên, chọn voice-ref từ code/tts/voices/ (ưu tiên voice gắn nhãn
"đọc truyện" hợp giới tính/tông từng nhân vật, không dùng nhãn "Quảng cáo"), và nếu có,
1 ảnh nền tĩnh duy nhất, ghi vào 00-tong-quan.md như hiện tại (ảnh nền không theo nhân
vật, giữ nguyên 1 ảnh cho toàn dự án). Nhân vật phụ xuất hiện muộn hơn được thêm dòng vào
bảng giọng khi họ xuất hiện lần đầu, ở đúng Bước 5 (Viết một chương) của chương đó, trước
khi đổi trạng thái chương sang "done", xem mục QA chương bổ sung ở qa-checklist-chung.md.

Điểm khác biệt cốt lõi so với bản gốc: “chốt 1 giọng duy nhất, không đổi giữa chừng” trở thành “điền một bảng, có thể bổ sung dòng mới giữa chừng khi nhân vật mới xuất hiện”, vì bản chất bài toán đã đổi từ chọn-một-giá-trị-cố-định sang duy trì-một-bảng-tra-cứu-mở-rộng-được. Ảnh nền tĩnh KHÔNG đổi logic (vẫn một ảnh cho toàn dự án, không phải một ảnh theo nhân vật), đây là ranh giới rõ ràng của phạm vi sửa đổi, tránh mở rộng thay đổi sang phần video ngoài phạm vi hai yêu cầu của bài này.

Sửa Bước 7 (chạy TTS) của SKILL.md

Đoạn hiện tại trong SKILL.md (mục “Tích hợp TTS theo chương”):

.venv/Scripts/python code/tts/chapter_pipeline.py --project "<thư mục dự án>" --voice-ref
"<đường dẫn ref audio>" --voice-name "<tên giọng>"

kèm mô tả “sinh audio nối tiếp bằng ĐÚNG 1 giọng (--voice-ref) xuyên suốt cả dự án”.

Thay bằng:

.venv/Scripts/python code/tts/chapter_pipeline.py --project "<thư mục dự án>"

kèm mô tả thay thế: “script tự đọc bảng giọng đọc (TTS) trong <thư mục dự án>/brief/02-nhan-vat.md, tự map từng segment sang đúng voice-ref theo tên nhân vật (nhãn TÊN: trong chương) hoặc rơi về giọng __default__ cho tường thuật/nội tâm/nhân vật chưa đăng ký. Không còn cần --voice-ref/--voice-name truyền tay, hai cờ này vẫn được giữ lại như tùy chọn GHI ĐÈ (override) cho trường hợp muốn ép toàn bộ dự án đọc bằng một giọng duy nhất (ví dụ thử nghiệm nhanh trước khi điền đủ bảng giọng), không phải hành vi mặc định nữa.”

Giữ --voice-ref như tùy chọn override thay vì xóa hẳn có hai lý do thực tế: (1) không phá lệnh gọi mà các dự án CŨ (nếu có, xem phần di trú phía sau) đang dùng, với dự án chưa migrate sang bảng giọng, --voice-ref truyền tay vẫn hoạt động y hệt hành vi hiện tại; (2) một số tình huống thực sự cần ép một giọng (ví dụ nghe thử nhanh một chương mới viết mà chưa kịp điền bảng giọng đầy đủ), giữ đường thoát này rẻ hơn buộc luôn phải điền bảng trước khi nghe thử được bất cứ điều gì. Chi tiết cách main() phân biệt hai chế độ (đọc bảng vs. override toàn cục) nằm ở mục “Sửa argparse và nguồn đọc bảng giọng”…, thực ra mục này đã được gộp vào “Đọc bảng giọng trong main()” và “Validate sớm” phía sau, không tách riêng nữa vì logic argparse chỉ là điều kiện if args.voice_ref: ... else: ... đơn giản, không đủ nội dung cho một mục riêng.

Nối parse_story.py vào parse_chapter_text

Thay toàn bộ vòng lặp gán cứng "type": "narration" bằng logic tách quote đã có sẵn trong parse_story.py, giữ nguyên MAX_CHUNK_CHARS/split_long_sentence cho việc cắt câu dài. Diff cụ thể trên chapter_pipeline.py:

# Thêm ở đầu file, cùng chỗ với các import khác:
from parse_story import split_into_pieces, resolve_quote_speaker, build_gender_hints, update_pronoun_state

def parse_chapter_text(full_text):
    """Tra ve list segment {"speaker", "type", "text"} cho 1 chuong."""
    gender_hints = build_gender_hints(full_text)
    last_name_by_gender = {"male": None, "female": None}
    paragraphs = [p.strip() for p in full_text.split("\n") if p.strip()]
    segments = []
    for para in paragraphs:
        if RULE_LINE_RE.match(para):
            continue
        if para.startswith("#"):
            title = para.lstrip("#").strip().strip("*")
            if title:
                segments.append({"speaker": "narrator", "type": "heading", "text": title})
            continue

        # --- MỚI: nhận diện nhãn thoại "TÊN: ..." ở đầu dòng, ưu tiên trước quote-splitting ---
        m = DIALOGUE_LABEL_RE.match(para)
        if m:
            speaker, rest = m.group(1).strip(), m.group(2)
            for sentence in split_sentences(rest):
                for chunk in split_long_sentence(sentence):
                    segments.append({"speaker": speaker, "type": "dialogue", "text": chunk})
            continue

        # --- MỚI: nhận diện [NT]...[/NT] -- xử lý ở mục "Thêm type inner_voice" ---
        if INNER_VOICE_RE.search(para):
            segments.extend(extract_inner_voice(para, current_pov_speaker))
            continue

        # --- MỚI: nhận diện *...* (hồi ức) TRƯỚC quote-splitting, vì */TÊN:/ đứng
        # sau dấu * nên không khớp DIALOGUE_LABEL_RE ở đầu para -- xử lý ở mục
        # "Thêm type inner_voice" cùng với [NT], giữ type="narration" như hành vi gốc ---
        if HOI_UC_RE.search(para):
            segments.extend(extract_hoi_uc(para))
            continue

        # --- Phần còn lại: quote-splitting của parse_story.py, thay cho gán cứng "narration" ---
        pieces = split_into_pieces(para)
        if not any(p[0] for p in pieces):
            update_pronoun_state(para, last_name_by_gender)
            for sentence in split_sentences(para):
                for chunk in split_long_sentence(sentence):
                    segments.append({"speaker": "narrator", "type": "narration", "text": chunk})
            continue
        for idx, (is_quote, text) in enumerate(pieces):
            if is_quote:
                speaker, _ = resolve_quote_speaker(pieces, idx, gender_hints, last_name_by_gender)
                for sentence in split_sentences(text):
                    for chunk in split_long_sentence(sentence):
                        segments.append({"speaker": speaker, "type": "dialogue", "text": chunk})
            else:
                update_pronoun_state(text, last_name_by_gender)
                for sentence in split_sentences(text):
                    for chunk in split_long_sentence(sentence):
                        segments.append({"speaker": "narrator", "type": "narration", "text": chunk})
    return segments

Bốn điểm cần chú ý khi ráp diff này vào file thật: DIALOGUE_LABEL_RE/INNER_VOICE_RE/HOI_UC_RE/extract_inner_voice/extract_hoi_uc/current_pov_speaker được định nghĩa ở mục tiếp theo, chưa có ở đây; nhánh nhãn TÊN: được kiểm TRƯỚC nhánh quote-splitting vì một dòng thoại theo quy ước mới có thể không còn cần dấu ngoặc kép (đã nêu ở mục “Viết lại đoạn quán mì”), nếu để split_into_pieces chạy trước, dòng TRẦM DƯ: Còn mì không ạ? không có ngoặc kép sẽ rơi thẳng vào nhánh “không có quote nào” và bị gán nhầm narrator; nhánh *...* được kiểm TRƯỚC quote-splitting vì lý do tương tự, một đoạn hồi ức mở đầu bằng *A VI: ...* bắt đầu bằng dấu *, không khớp DIALOGUE_LABEL_RE (regex đòi ký tự đầu dòng là chữ hoa tiếng Việt, không phải *), nên phải có nhánh riêng đọc dấu * trước khi rơi vào quote-splitting; và clean = para.replace("*", "") của bản gốc biến mất hoàn toàn khỏi luồng chính, dấu * không còn bị xóa vô điều kiện ở đầu hàm, việc xóa nó (chỉ xóa ký tự * bao ngoài, giữ lại nội dung) chuyển vào đúng bên trong extract_hoi_uc, xử lý ở mục tiếp theo.

Thêm type inner_voice cho nội tâm

Ba định nghĩa còn thiếu từ mục trước, thêm vào đầu chapter_pipeline.py:

UP = "AÁÀẢÃẠĂẮẰẲẴẶÂẤẦẨẪẬBCDĐEÉÈẺẼẸÊẾỀỂỄỆGHIÍÌỈĨỊKLMNOÓÒỎÕỌÔỐỒỔỖỘƠỚỜỞỠỢPQRSTUÚÙỦŨỤƯỨỪỬỮỰVXYÝỲỶỸỴ "
DIALOGUE_LABEL_RE = re.compile(rf"^([{UP}][^:\n]{{0,30}}):\s+(.+)$")
INNER_VOICE_RE = re.compile(r"\[NT\](.*?)\[/NT\]", re.DOTALL)

def extract_inner_voice(para, pov_speaker):
    """Tach [NT]...[/NT] khoi 1 doan van, phan con lai (neu co) xu ly nhu
    narration binh thuong. [NT] khong dong trong cung doan -> auto-close
    tai cuoi doan, ghi canh bao (rang buoc da neu o muc "Ranh gioi bat buoc")."""
    segments = []
    pos = 0
    for m in INNER_VOICE_RE.finditer(para):
        if m.start() > pos:
            before = para[pos:m.start()].strip(" ,")
            if before:
                for s in split_sentences(before):
                    for c in split_long_sentence(s):
                        segments.append({"speaker": "narrator", "type": "narration", "text": c})
        inner_text = m.group(1).strip()
        for s in split_sentences(inner_text):
            for c in split_long_sentence(s):
                segments.append({"speaker": pov_speaker, "type": "inner_voice", "text": c})
        pos = m.end()
    remainder = para[pos:]
    if "[NT]" in remainder and "[/NT]" not in remainder:
        print(f"[warn] [NT] thieu the dong trong doan: {remainder[:60]}...", flush=True)
        inner_text = remainder.split("[NT]", 1)[1].strip()
        for s in split_sentences(inner_text):
            for c in split_long_sentence(s):
                segments.append({"speaker": pov_speaker, "type": "inner_voice", "text": c})
    elif remainder.strip(" ,"):
        for s in split_sentences(remainder.strip(" ,")):
            for c in split_long_sentence(s):
                segments.append({"speaker": "narrator", "type": "narration", "text": c})
    return segments

Song song với [NT], hồi ức (*...*) cần một hàm tương tự để (a) không còn để lọt ký tự * vào text đưa cho model.generate(), TTS đọc thành tiếng ký tự * nếu nó lọt qua, một lỗi âm thanh nghe được ngay, và (b) nhận diện được nhãn TÊN: đứng NGAY SAU dấu * mở (trường hợp ở mục “Viết lại đoạn hồi ức lẫn nội tâm”, ví dụ *A VI: Khí vận...*), việc DIALOGUE_LABEL_RE áp lên đầu para không bắt được vì para bắt đầu bằng *, không bắt đầu bằng chữ hoa tên riêng:

HOI_UC_RE = re.compile(r"\*(.*?)\*", re.DOTALL)

def extract_hoi_uc(para):
    """Tach *...* (hoi uc), giu type='narration' nhu hanh vi goc, nhung:
    (1) bo dau * khoi text truoc khi dua vao TTS: (2) neu ben trong co nhan
    TEN: o dau, gan speaker=TEN thay vi 'narrator' (cho phep hoi uc doc bang
    giong nguoi duoc nho lai neu nguoi viet chon gan nhan)."""
    segments = []
    pos = 0
    for m in HOI_UC_RE.finditer(para):
        if m.start() > pos:
            before = para[pos:m.start()].strip(" ,")
            if before:
                for s in split_sentences(before):
                    for c in split_long_sentence(s):
                        segments.append({"speaker": "narrator", "type": "narration", "text": c})
        inner = m.group(1).strip()
        label = DIALOGUE_LABEL_RE.match(inner)
        speaker = label.group(1).strip() if label else "narrator"
        body = label.group(2) if label else inner
        for s in split_sentences(body):
            for c in split_long_sentence(s):
                segments.append({"speaker": speaker, "type": "narration", "text": c})
        pos = m.end()
    remainder = para[pos:].strip(" ,")
    if remainder:
        for s in split_sentences(remainder):
            for c in split_long_sentence(s):
                segments.append({"speaker": "narrator", "type": "narration", "text": c})
    return segments

type vẫn là "narration", đúng quyết định đã nêu ở mục trước (không đổi nghĩa hành vi cũ của hồi ức để không phá continuity), chỉ speaker thay đổi tùy có nhãn TÊN: bên trong hay không, và ký tự * không còn xuất hiện trong text cuối cùng.

pov_speaker (nhân vật POV hiện tại của chương, dùng cho extract_inner_voice) không tự suy ra được từ văn bản một cách đáng tin cậy, cách rẻ nhất và đúng với kiến trúc hiện có: thêm một trường pov (tên nhân vật POV) vào brief chương trong hoi-NN.md, đọc một lần ở đầu sync_segments cho mỗi chương, truyền xuống parse_chapter_text(text, pov_speaker=ch.get("pov", "narrator")). Phần lớn chương trong thể loại giấu thực lực/giấu thân phận/trùng sinh đã có đúng một nhân vật POV cố định mỗi chương (thông tin bất đối xứng theo nhân vật là trục dữ liệu cốt lõi của ba thể loại này, theo SKILL.md gốc), trường pov gần như luôn suy ra được trực tiếp từ chính brief chương đã có, không phải trường hoàn toàn mới về mặt khái niệm.

Giữ nguyên type: "narration" cho hồi ức (thay vì đặt một type thứ ba như "flashback") là quyết định có chủ đích, không phải thiếu sót: đổi type sẽ đòi thêm một dòng giọng "flashback" mới trong bảng nhân vật→giọng và một nhánh xử lý mới trong generate_audio, trong khi hồi ức vốn đã đọc đúng bằng giọng narrator hoặc giọng nhân vật được nhắc tới (qua speaker suy từ nhãn TÊN: bên trong), không có nhu cầu âm thanh nào đòi một type riêng cho nó. Việc duy nhất thay đổi so với hành vi gốc là dấu * không còn bị .replace("*", "") xóa mù quáng ở đầu hàm, nó được đọc như tín hiệu cấu trúc trong extract_hoi_uc rồi mới bị bóc đi, và tên trong nhãn TÊN: (nếu có) giờ quyết định được speaker thay vì luôn luôn là "narrator".

Sửa sync_segments để mang theo speaker

sync_segments hiện tại chỉ thêm index/chapter vào mỗi segment trả về từ parse_chapter_text trước khi ghi vào segments.json, không cần đổi cấu trúc vòng lặp, chỉ cần parse_chapter_text (đã sửa ở hai mục trước) trả về speaker cùng type, phần còn lại tự động đi theo:

new_segs = parse_chapter_text(text, pov_speaker=ch.get("pov", "narrator"))
for s in new_segs:
    s["index"] = next_index
    s["chapter"] = ch["num"]
    next_index += 1
segments.extend(new_segs)

Cho một dự án không cần phân biệt chế độ cũ/mới (mọi chương đều theo quy ước mới), đây là dòng DUY NHẤT cần đổi trong sync_segments (mục “Khuyến nghị cụ thể” phía sau mở rộng thêm một điều kiện dispatch theo cột “Quy ước định dạng” cho trường hợp có cả chương cũ lẫn chương mới, một thay đổi bổ sung, không thay thế dòng này). s["index"]/s["chapter"] gán y hệt bản gốc, chỉ khác new_segs giờ có thêm khóa "speaker" trong mỗi dict (đến từ parse_chapter_text mới) mà bản gốc không có. state.json["processed_chapters"] và cơ chế next_index = max(...) + 1 giữ nguyên 100%, đây chính là ràng buộc “không phá khả năng resume” đặt ra ở phần nguyên tắc thiết kế: file segments.json cũ (ghi bởi phiên bản trước khi sửa) vẫn đọc được bình thường bởi code mới, vì code mới không đòi hỏi khóa speaker phải tồn tại ở segment cũ để hoạt động, nó chỉ dùng speaker khi có, và generate_audio (mục sau) có nhánh fallback cho segment không có khóa này.

ch.get("pov", "narrator") đọc từ đâu: thêm cột hoặc trường POV vào hoi-NN.md (brief chương, theo đúng cấu trúc “trường brief chương chuẩn” mà qa-checklist-chung.md mục QA Brief đã yêu cầu điền đủ), parse_outline/sync_segments hiện chỉ đọc outline-tong.md cho số chương/hồi/trạng thái, cần thêm một bước đọc hoi-NN.md tương ứng để lấy pov mỗi chương; nếu trường này chưa được điền (dự án cũ chưa migrate), ch.get("pov", "narrator") rơi về "narrator", nội tâm không có POV xác định sẽ đọc bằng giọng người dẫn chuyện thay vì crash, một fallback êm chấp nhận được cho trường hợp thiếu dữ liệu.

Đọc bảng giọng trong main(): load_voice_table

Đọc bảng “Bảng giọng đọc (TTS)” trong 02-nhan-vat.md một lần, ngay đầu main(), trước khi gọi sync_segments, dùng lại đúng kỹ thuật tìm cột theo tên header (không theo vị trí cố định) mà parse_outline đã dùng cho outline-tong.md:

def load_voice_table(project_dir):
    path = os.path.join(project_dir, "brief", "02-nhan-vat.md")
    with io.open(path, "r", encoding="utf-8") as f:
        lines = f.readlines()

    table, header = {}, None
    in_voice_section = False
    for line in lines:
        line = line.rstrip("\n")
        if line.strip().startswith("## Bảng giọng đọc"):
            in_voice_section = True
            continue
        if in_voice_section and line.startswith("## "):
            break  # het bang, sang muc khac cua file
        if not in_voice_section or not line.strip().startswith("|"):
            continue
        cells = [c.strip() for c in line.strip().strip("|").split("|")]
        if header is None:
            header = cells
            continue
        if all(re.match(r"^:?-+:?$", c) for c in cells if c):
            continue
        idx_name = next(i for i, h in enumerate(header) if "tên" in h.lower())
        idx_ref = next(i for i, h in enumerate(header) if "voice-ref" in h.lower() or "đường dẫn" in h.lower())
        if len(cells) <= max(idx_name, idx_ref):
            continue
        name = cells[idx_name].strip().strip("`")
        ref = cells[idx_ref].strip().strip("`")
        if name and ref:
            table[name] = ref

    if "__default__" not in table:
        print(f"[error] Bang giong dieu trong {path} thieu dong __default__ bat buoc.", file=sys.stderr)
        sys.exit(1)
    return table

Gọi trong main():

voice_table = load_voice_table(project_dir)
segments = sync_segments(project_dir, tts_dir)

load_voice_table chạy TRƯỚC sync_segments, không phải sau, vì lỗi thiếu __default__ cần chặn ngay từ đầu, trước khi tốn thời gian đọc/parse toàn bộ chương, đúng nguyên tắc “dừng sớm, rẻ” đặt ra ở mục “Validate sớm” phía sau (nơi việc validate MỞ RỘNG sang kiểm tra từng speaker cụ thể trong segments, cần segments đã có nên phải chạy SAU sync_segments, hai lớp validate khác tầng, không mâu thuẫn: lớp này chỉ đảm bảo bảng giọng tự nó hợp lệ, lớp sau đối chiếu bảng với nội dung chương thật).

Sửa generate_audio: map speaker sang voice_ref và cache clone_prompt

Thay clone_prompt đơn (một biến, một create_voice_clone_prompt) bằng một dict cache theo voice_ref, xây một lần trước vòng lặp, KHÔNG theo tên nhân vật, vì việc tạo clone_prompt chỉ phụ thuộc file audio mẫu, không phụ thuộc tên (hai nhân vật lý thuyết có thể trỏ cùng một voice_ref):

def generate_audio(segments, tts_dir, voice_table, seed=1234,
                    sentence_gap_sec=0.12, heading_gap_sec=0.6, empty_cache_every=8):
    audio_out = os.path.join(tts_dir, "audio_out")
    os.makedirs(audio_out, exist_ok=True)
    manifest_path = os.path.join(audio_out, "manifest.json")

    manifest_by_index = {}
    if os.path.exists(manifest_path):
        with io.open(manifest_path, "r", encoding="utf-8") as f:
            manifest_by_index = {m["index"]: m for m in json.load(f)}

    todo = [s for s in segments if s["index"] not in manifest_by_index]
    print(f"[tts] Tong {len(segments)} segment, da co audio {len(manifest_by_index)}, con lai {len(todo)}.", flush=True)
    if not todo:
        print("[tts] Khong con gi de sinh.", flush=True)
        return

    import numpy as np, torch, soundfile as sf
    from omnivoice.models.omnivoice import OmniVoice

    MODEL_ID = "splendor1811/omnivoice-vietnamese"
    MODEL_REVISION = "f70093f90d78a5b790ff0f066b3629ebcc33cb5d"
    model = OmniVoice.from_pretrained(MODEL_ID, revision=MODEL_REVISION, device_map="cuda", dtype=torch.float16)
    sr = model.sampling_rate

    # --- MOI: cache 1 clone_prompt cho MOI voice_ref duy nhat, khong phai moi ten ---
    unique_refs = {ref: name for name, ref in voice_table.items()}  # ref -> 1 ten dai dien (chi de log)
    clone_prompts = {}
    for ref, name in unique_refs.items():
        t1 = time.time()
        clone_prompts[ref] = model.create_voice_clone_prompt(ref_audio=ref, ref_text=None)
        print(f"[info] Voice clone prompt ({name}, {ref}) xong sau {time.time()-t1:.1f}s", flush=True)

    def gen_one(text, voice_ref):
        torch.manual_seed(seed)
        audios = model.generate(
            text=text, language="Vietnamese", voice_clone_prompt=clone_prompts[voice_ref],
            num_step=32, guidance_scale=2.0,
        )
        return audios[0]

    manifest = list(manifest_by_index.values())
    try:
        for n, seg in enumerate(todo):
            i = seg["index"]
            text = seg["text"]
            if not text.strip():
                continue
            speaker = seg.get("speaker", "narrator")
            voice_ref = voice_table.get(speaker, voice_table["__default__"])
            gap_sec = heading_gap_sec if seg["type"] == "heading" else sentence_gap_sec
            audio = gen_one(text, voice_ref)
            gap = np.zeros(int(round(gap_sec * sr)), dtype=np.float32)
            audio = np.concatenate([gap, audio, gap])

            wav_path = os.path.join(audio_out, f"seg-{i:04d}.wav")
            sf.write(wav_path, audio, sr)
            manifest.append({
                "index": i, "type": seg["type"], "chapter": seg.get("chapter"),
                "speaker": speaker, "voice_ref": voice_ref,
                "text": text, "wav": wav_path, "duration_sec": len(audio) / sr,
            })
            manifest.sort(key=lambda m: m["index"])
            with io.open(manifest_path, "w", encoding="utf-8") as f:
                json.dump(manifest, f, ensure_ascii=False, indent=2)
            if (n + 1) % empty_cache_every == 0:
                torch.cuda.empty_cache()
    except KeyboardInterrupt:
        print("[warn] Bi ngat giua chung. Manifest da luu -- chay lai se resume.", flush=True)

Ba khác biệt so với bản gốc, mỗi cái phục vụ đúng một ràng buộc đã đặt ra trước đó: clone_prompts là dict thay vì biến đơn (phục vụ yêu cầu mỗi nhân vật một giọng); voice_table.get(speaker, voice_table["__default__"]) là điểm fallback duy nhất, không có nhánh nào khác âm thầm chọn giọng (phục vụ nguyên tắc “dừng rõ ràng, không suy biến ngẫu nhiên”, dù ở đây fallback về __default__ chứ không dừng hẳn, vì đây là fallback ĐÃ ĐƯỢC KHAI BÁO tường minh trong bảng, khác với việc thiếu __default__ hoàn toàn, xử lý ở mục sau); và manifest giờ ghi thêm speaker/voice_ref mỗi dòng, hữu ích để rà soát sau này xem chương nào đã dùng đúng giọng nào mà không phải nghe lại toàn bộ audio.

Validate sớm và giọng fallback default

Hai việc còn thiếu để main() hoàn chỉnh: xử lý --voice-ref như override toàn cục (giữ lại theo quyết định ở mục “Sửa Bước 7”), và validate toàn bộ speaker xuất hiện trong segments trước khi load model, không phải giữa vòng lặp sinh audio đang chạy không giám sát hàng chục phút.

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--project", required=True)
    ap.add_argument("--voice-ref", help="Override: ep TOAN BO du an dung 1 giong nay (bo qua bang giong)")
    ap.add_argument("--voice-name", default="")
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    project_dir = os.path.abspath(args.project)
    tts_dir = os.path.join(project_dir, "tts")

    if args.voice_ref:
        voice_table = {"__default__": args.voice_ref}
        print(f"[info] Che do override: toan bo du an dung 1 giong ({args.voice_name or args.voice_ref}).", flush=True)
    else:
        voice_table = load_voice_table(project_dir)

    segments = sync_segments(project_dir, tts_dir)

    if args.dry_run:
        print("[dry-run] Dung lai o buoc dong bo segment, khong sinh audio.", flush=True)
        return

    # --- MOI: validate truoc khi load model ---
    speakers_in_use = {s.get("speaker", "narrator") for s in segments}
    missing = speakers_in_use - set(voice_table.keys())
    if missing and "__default__" not in voice_table:
        print(f"[error] Thieu voice-ref cho: {sorted(missing)}, va khong co __default__ de fallback.", file=sys.stderr)
        sys.exit(1)
    elif missing:
        print(f"[warn] {sorted(missing)} chua co dong rieng trong bang giong, se dung __default__.", flush=True)

    generate_audio(segments, tts_dir, voice_table)

Khi override bằng --voice-ref (không đọc 02-nhan-vat.md), voice_table chỉ có đúng một dòng __default__, mọi speaker đều missing so với bảng, rơi vào nhánh elif missing (cảnh báo, không dừng) vì __default__ luôn có mặt trong trường hợp này, hành vi cuối cùng: mọi segment đọc bằng đúng một giọng, y hệt hành vi gốc trước khi sửa. Đây là lý do override hoạt động đúng như một “chế độ tương thích ngược” thật sự, không phải một tính năng phụ chắp vá.

Khi dùng bảng giọng thật (không override) và có speaker thiếu dòng riêng, script KHÔNG dừng, chỉ cảnh báo rồi tiếp tục với __default__, vì đây là tình huống thực tế phổ biến (nhân vật phụ mới xuất hiện, người viết quên thêm dòng) không đáng để chặn cả mẻ sinh audio. Script chỉ dừng hẳn (sys.exit(1)) trong đúng một trường hợp: bảng giọng thật sự không có __default__, lỗi cấu hình gốc, không phải lỗi dữ liệu chương, xứng đáng chặn ngay từ load_voice_table (mục trước), trước khi kịp tốn công đọc chương nào.

Mục QA checklist mới cho định dạng

Nội dung sau dán thẳng vào references/qa-checklist-chung.md, mục “QA chương”, thêm làm mục 6 mới (mục 5 hiện có kết thúc bằng việc cập nhật state trước khi đổi trạng thái, mục mới này chạy TRƯỚC mục 5, vì phải xác nhận định dạng đúng trước khi có ý nghĩa cập nhật state đặc thù):

6. **(BẮT BUỘC nếu dự án có dùng TTS đa giọng)** Định dạng tường thuật/thoại/nội
   tâm/hồi ức đúng quy ước: mọi lượt thoại đứng riêng một dòng, mở đầu bằng
   `TÊN_NHÂN_VẬT:` khớp chính xác một dòng trong "Bảng giọng đọc (TTS)" ở
   `02-nhan-vat.md` (không viết thường, không thêm khoảng trắng thừa); mọi
   đoạn nội tâm của nhân vật POV bọc trong `[NT]...[/NT]`, không vắt qua quá
   một đoạn văn; hồi ức/lời-nhớ-lại giữ nguyên in nghiêng `*...*`, không lẫn
   với `[NT]`. Xác nhận bằng cách chạy thử:
   `python code/tts/chapter_pipeline.py --project <du an> --dry-run` rồi mở
   `tts/segments.json`, đối chiếu số lượng segment `type: "dialogue"` với số
   lượt thoại đếm tay trong chương, lệch số nghĩa là có dòng `TÊN:` viết sai
   cú pháp (thiếu dấu hai chấm, hoặc không đứng đầu dòng).
6b. Nếu nhân vật xuất hiện lần đầu trong chương này có lời thoại, đã thêm
   dòng tương ứng vào "Bảng giọng đọc (TTS)" ở `02-nhan-vat.md` **trước khi**
   đổi trạng thái dòng chương sang "done", không để trống chờ "chương sau
   điền" (đúng nguyên tắc chung đã áp cho `brief/dac-thu/` ở mục QA Brief).

Ba lý do đặt mục này ở đây, không phải nơi khác: đặt trong “QA chương” (không phải “QA Brief”) vì lỗi định dạng chỉ có thể xảy ra khi viết một chương cụ thể, không phải khi dựng Brief; đặt là mục (BẮT BUỘC) có điều kiện (“nếu dự án có dùng TTS đa giọng”) thay vì bắt buộc tuyệt đối, vì không phải mọi dự án dùng skill này đều bật TTS, ép một dự án không chạy TTS phải tuân quy ước markup này là chi phí vô ích; và bước xác nhận dùng đúng --dry-run đã có sẵn trong chapter_pipeline.py (không tốn GPU, không sinh audio thật) thay vì yêu cầu người QA tự đọc mắt đếm ký hiệu, đúng tinh thần “tường minh, kiểm bằng máy” xuyên suốt bài này, không dừng ở việc đề ra quy ước rồi lại QA quy ước đó bằng mắt.

Chương cũ chạy qua parser mới sẽ sai ra sao nếu không migrate

Chạy trực tiếp parse_chapter_text mới (mục “Nối parse_story.py vào”) lên nguyên văn chuong-001.md chưa migrate, không có bất kỳ dòng TÊN: hay [NT] nào trong file, cho kết quả gần như y hệt hành vi CŨ đã mô tả ở đầu bài, không phải kết quả tốt hơn: đoạn quán mì gốc (dòng 21-47) đi qua nhánh quote-splitting của parse_story.py, và resolve_quote_speaker chỉ nhận diện đúng attribution khi động từ trong SPEECH_VERBS đứng SÁT quote. Đếm cụ thể trên 6 lượt thoại trong đoạn này:

  • "Còn mì không ạ?" anh hỏi, attribution sau, sát ngay → nhận diện đúng (rơi vào speaker suy từ đại từ “anh” → tra last_name_by_gender["male"], tại thời điểm đó đã có “Trầm Dư” từ đoạn trước → đúng).
  • "Ngồi đi. Nồi cuối cùng đấy." (Bà Lục), đứng ngay sau "Ừ, thằng Bảo. Học lớp năm." cách một câu tường thuật ngắn “Bà Lục múc mì, không ngẩng đầu.” → không có SPEECH_VERBS sát quote, resolve_quote_speaker trả "unknown", None.
  • "Cháu bà là đứa nhỏ trong ảnh à?" anh hỏi, sát → nhận diện đúng.
  • "Ừ, thằng Bảo. Học lớp năm.", không có attribution sát (câu trước nó là “Bà Lục múc mì, không ngẩng đầu.”, không chứa động từ nói năng) → "unknown".
  • Câu thoại dài “Mấy hôm tới, có lẽ bà nên dặn nó…”, attribution “Anh nói xong” đứng SAU, cách một khoảng, và mẫu ATTRIB_AFTER_RE đòi khớp ngay đầu chuỗi còn lại → tùy độ chính xác dấu câu có thể trượt, xếp vào diện rủi ro.
  • "Sao cậu biết đường nào thằng Bảo hay đi?", câu trước là hành động thuần túy, không SPEECH_VERBS → "unknown".

Tối thiểu 2/6 lượt thoại (33%) rơi thẳng vào speaker: "unknown", trong generate_audio đã sửa, voice_table.get("unknown", voice_table["__default__"]) sẽ đọc các câu này bằng giọng người dẫn chuyện thay vì giọng Bà Lục, nghe như người kể chuyện đột nhiên trích dẫn nguyên văn một câu hỏi mà không đổi giọng, đúng loại lỗi mà cả bài này tồn tại để giải quyết, chỉ khác là bây giờ xảy ra ở MỘT SỐ câu thay vì TOÀN BỘ chương. Nội tâm (nếu chương cũ có dùng in nghiêng cho nội tâm ở đâu đó, không chỉ hồi ức) đi qua extract_hoi_uc mới, đọc đúng bằng giọng suy từ nhãn TÊN: bên trong nếu có, hoặc narrator nếu không, với nội tâm THẬT (không phải hồi ức có tên người nói), kết quả luôn là narrator, tức vẫn đọc như tường thuật, y hệt lỗi gốc, vì nội tâm chưa migrate không có [NT] để nhận diện.

Kết luận số liệu: chạy code mới lên văn bản cũ không tự động “sửa” gì cả, nó chỉ khôi phục đúng phần mà resolve_quote_speaker có thể suy luận được (khoảng 2/3 lượt thoại có attribution sát), phần còn lại (thoại không rõ ràng + toàn bộ nội tâm) vẫn đọc sai giọng y như trước khi sửa code.

Phương án migrate toàn bộ 20 chương cũ

Viết lại cả 20 chương của du-an-thong-thien-su theo quy ước mới nghĩa là: thêm nhãn TÊN: cho mọi lượt thoại, thêm [NT] cho nội tâm (nếu có), và điền đủ bảng giọng cho toàn bộ nhân vật đã xuất hiện, không đổi nội dung văn học, chỉ đổi định dạng lưu trữ.

Chi phí thực tế: mỗi chương trung bình có khoảng 6-10 lượt thoại (tính theo mật độ của chuong-001.md), việc tách dòng thủ công cho 20 chương là công việc cơ học, không đòi sáng tạo, nhưng vẫn cần một người đọc lại từng chương để xác định đúng ai nói câu nào ở những chỗ attribution mơ hồ (chính 2/6 lượt “unknown” ở mục trước), đây là việc máy không tự làm được, người viết/QA phải tự đọc và quyết định.

Rủi ro cụ thể: (1) sai sót khi tách dòng thủ công có thể vô tình đổi nghĩa câu văn, ví dụ gộp nhầm hai lượt thoại của hai người thành một dòng, hoặc tách một câu tường thuật đang mô tả hành động thành một dòng thoại không đúng; (2) 20 chương đã có audio sinh sẵn từ trước (dùng 1 giọng cũ, theo tts/audio_out/ hiện có), nếu ghi đè file .md gốc mà không xử lý đồng bộ tts/state.json/tts/segments.json, sync_segments sẽ KHÔNG tự động phát hiện nội dung đã đổi (nó chỉ kiểm tra ch["num"] in processed, không so sánh nội dung file), nghĩa là audio cũ (1 giọng, sai định dạng) tiếp tục được coi là “đã xử lý” trừ khi chủ động xóa 20 khỏi processed_chapters và các segment cũ khỏi segments.json/manifest.json để buộc sinh lại; (3) cần QA lại cả 20 chương sau khi viết lại, không chỉ QA định dạng mới mà còn QA continuity (tên nhân vật, thời gian, đúng như mục 1 của “QA chương” hiện có) để đảm bảo việc tách dòng không vô tình làm lệch nội dung so với bản đã xuất bản.

Chi phí lớn nhất không phải công sức gõ lại, mà là RỦI RO ĐỒNG BỘ giữa văn bản mới và audio/video đã publish trước đó (nếu người dùng đã đăng video lên nền tảng nào), viết lại 20 chương kéo theo phải sinh lại toàn bộ audio 20 chương, không thể chỉ sinh phần chênh lệch.

Trước khi quyết định chọn phương án này, đáng chạy một lệnh xác nhận quy mô thật của việc đụng độ ký hiệu in nghiêng, thay vì suy đoán: grep -rn '\*[^*]*\*' chuong/**/*.md trên toàn bộ 20 chương, rồi đọc tay từng kết quả để đếm bao nhiêu dòng là nội tâm thật (không có tên người nói) so với hồi ức có tên. Số liệu này quyết định trực tiếp khối lượng công việc thực tế của phương án migrate toàn bộ, không phải một ước lượng chung chung.

Phương án chỉ áp dụng từ chương tiếp theo

Giữ nguyên 20 chương cũ, chỉ áp quy ước mới từ chương 21 trở đi. chapter_pipeline.py cần phân biệt hai “chế độ” theo từng chương, cách rẻ nhất, không đổi cấu trúc outline-tong.md hiện có quá nhiều: thêm một cột mới “Quy ước định dạng” (giá trị cũ hoặc mới) vào bảng chương, đọc bằng đúng kỹ thuật col_index đã dùng cho các cột khác:

idx_format = col_index("quy ước định dạng") if any("quy ước định dạng" in h.lower() for h in header) else None
...
fmt = cells[idx_format].strip().lower() if idx_format is not None else "moi"
chapters.append({"num": num, "hoi": hoi, "status": status, "format": fmt})

Trong sync_segments, chương có format == "cu" gọi nhánh parse CŨ (giữ nguyên clean = para.replace("*", "") + gán cứng narration, y hệt hành vi trước khi sửa, tách thành một hàm riêng parse_chapter_text_legacy thay vì xóa hẳn code cũ), chương format != "cu" gọi parse_chapter_text mới. Cột này mặc định "moi" nếu không có (dự án mới tạo sau bài viết này không cần điền cột legacy).

Rủi ro cụ thể: (1) 20 chương cũ VĨNH VIỄN đọc bằng một giọng phẳng, không đạt yêu cầu (1) của brief cho phần đã publish, nếu người dùng muốn TOÀN BỘ truyện (kể cả chương cũ) có đa giọng, phương án này không giải quyết được, chỉ ngăn vấn đề LAN RỘNG THÊM; (2) hai nhánh parse song song (parse_chapter_text/parse_chapter_text_legacy) là gánh nặng bảo trì lâu dài, mọi lần sửa logic tách câu chung (split_sentences/split_long_sentence) phải kiểm tra ảnh hưởng lên cả hai nhánh; (3) nếu một chương cũ (đã “done”, đã có audio) sau này cần sửa nội dung (ví dụ QA phát hiện lỗi continuity phải fix), người sửa dễ quên chương đó vẫn ở format: cu và vô tình viết thêm nội dung theo quy ước mới (TÊN:/[NT]) vào một chương đang được parse bằng nhánh cũ, nhãn đó sẽ bị đọc y nguyên thành lời thoại ("TÊN: lời thoại" đọc thành tiếng cả chữ “TÊN:”) vì nhánh cũ không nhận diện cú pháp mới, một lỗi ngược hẳn với ý định.

Rủi ro (3) là lý do cột “Quy ước định dạng” phải được đối chiếu ở đúng bước QA chương mỗi khi một chương done bị mở lại để sửa, không chỉ đọc một lần lúc chương mới viết xong.

Khuyến nghị cụ thể và cách đánh dấu chế độ trong outline-tong.md

Khuyến nghị: chiến lược lai, không phải một trong hai phương án thuần túy ở hai mục trước. Với 20 chương cũ: chạy parse_story.py như MỘT LƯỢT AUTO-TAG DUY NHẤT (không viết lại file .md gốc), dùng chính resolve_quote_speaker để suy ra speaker cho các lượt thoại nhận diện được (khoảng 2/3 theo số liệu ở mục “Chương cũ chạy qua parser mới”), giữ format: cu trong outline-tong.md cho các chương này để chapter_pipeline.py biết dùng nhánh parse có suy luận thay vì nhánh legacy phẳng tuyệt đối. Với chương 21 trở đi: bắt buộc nhãn tay theo quy ước mới, format: moi.

Đây KHÔNG phải phương án migrate hoàn chỉnh, 1/3 lượt thoại “unknown” của 20 chương cũ vẫn đọc bằng __default__, và nội tâm cũ (nếu có) vẫn không tách được vì parse_story.py gốc không có type nội tâm. Điểm khác biệt so với “giữ nguyên hoàn toàn”: chi phí gần bằng không (không cần đọc lại viết lại từng chương tay), thu được đa giọng cho ĐA SỐ (không phải toàn bộ) lượt thoại của 20 chương cũ, và không đụng tới file .md gốc nên không có rủi ro (1)/(3) đã nêu ở phương án migrate toàn bộ. Nếu sau này người dùng thực sự cần TOÀN BỘ 20 chương đạt chuẩn migrate đầy đủ, phương án lai này không cản trở, nó chỉ trì hoãn, không loại trừ, việc migrate tay từng chương khi có đủ thời gian.

Cách đánh dấu trong outline-tong.md: thêm cột “Quy ước định dạng” (đã nêu ở mục trước) với giá trị cho 20 dòng đầu là cu (auto-tag), khác với cu thuần (chưa từng chạy auto-tag) để phân biệt rõ ba trạng thái có thể có của một chương: moi (viết theo quy ước mới, nhãn tay), cu (auto-tag) (văn bản gốc không đổi, nhưng đọc bằng nhánh có suy luận speaker), cu (hoàn toàn phẳng một giọng). Điểm đáng chú ý về mặt cài đặt: cu (auto-tag) và moi thực ra dùng ĐƯỢC CHUNG một hàm, parse_chapter_text mới (mục “Nối parse_story.py vào”) đã có sẵn nhánh resolve_quote_speaker làm fallback cho mọi lượt thoại không có nhãn TÊN: tường minh; một chương cũ hoàn toàn không có nhãn nào sẽ tự động rơi hết vào đúng nhánh fallback đó, không cần một hàm thứ ba riêng. Chỉ giá trị cu thuần mới cần parse_chapter_text_legacy (nhánh gán cứng narration, giữ nguyên hành vi trước khi sửa), dành cho dự án khác muốn giữ nguyên 100% hành vi cũ, không áp dụng cho du-an-thong-thien-su trong khuyến nghị này.

Sai lầm thường gặp khi bắt đầu dùng quy ước mới

Quên dấu hai chấm hoặc thêm khoảng trắng trước nó trong nhãn thoại. TRẦM DƯ : Còn mì không ạ? (khoảng trắng trước dấu hai chấm) không khớp DIALOGUE_LABEL_RE (regex đòi : đứng ngay sau nhóm tên, không có khoảng trắng xen giữa), dòng này rơi tuột xuống nhánh quote-splitting, đọc cả cụm “TRẦM DƯ : Còn mì không ạ?” như một câu tường thuật duy nhất. Cách bắt lỗi này rẻ nhất: bước xác nhận bằng --dry-run ở mục QA checklist đã nêu, số segment dialogue sẽ ít hơn số lượt thoại đếm tay.

Quên đóng [NT] khi đoạn nội tâm có nhiều câu. Đã có auto-close theo đoạn văn (mục “Ranh giới bắt buộc”) nên hậu quả bị giới hạn ở một đoạn văn, nhưng vẫn tạo ra một dòng cảnh báo [warn] [NT] thieu the dong... trong log, bỏ qua cảnh báo này thay vì sửa là sai lầm phổ biến vì audio vẫn sinh ra “được”, chỉ sai giọng ở đúng đoạn đó, dễ lọt qua khi không nghe lại toàn bộ.

voice-ref trỏ sai đường dẫn hoặc thiếu file. Bảng giọng ghi đường dẫn nhưng file audio mẫu không tồn tại ở đó (đổi tên thư mục code/tts/voices/ sau khi đã điền bảng), lỗi này KHÔNG bị load_voice_table bắt (nó chỉ kiểm tra bảng có dòng __default__, không kiểm tra file có tồn tại), chỉ lộ ra khi model.create_voice_clone_prompt thất bại giữa chừng, sau khi đã load model xong. Nên thêm một dòng os.path.exists(ref) ngay trong vòng lặp xây clone_prompts ở mục “Sửa generate_audio”, dừng sớm với thông báo rõ ref nào thiếu, thay vì để lỗi lộ ra ở tầng model.

Nhầm hồi ức với nội tâm vì cả hai đều là “suy nghĩ”. Người viết mới quen quy ước dễ dùng [NT] cho một đoạn thực chất là lời-nhớ-lại của người khác (nên phải dùng *TÊN: ...*), vì cả hai đều “không phải lời nói ra ngoài ở hiện tại”. Phân biệt đúng: nếu câu văn đó là NGÔN TỪ của một người CỤ THỂ (kể cả đã qua đời, đã vắng mặt) tại một thời điểm khác, dùng *...*; nếu câu văn đó là Ý NGHĨ của nhân vật POV NGAY TẠI HIỆN TẠI của cảnh đang diễn ra, dùng [NT]. Không có ranh giới nào khác đáng tin hơn câu hỏi này.

Thiếu dòng default hoặc thiếu POV chương khi migrate dở dang. Cả hai đều có fallback êm (rơi về narrator/dừng sớm với thông báo rõ ràng, mục “Validate sớm”), nhưng sai lầm thực tế là coi cảnh báo “thiếu POV, dùng narrator” là bình thường cho MỌI chương thay vì chỉ chấp nhận nó cho các chương chưa migrate, với chương format: moi, thiếu trường pov trong hoi-NN.md là lỗi thật cần điền, không phải hành vi mong đợi.

Kết luận

Ba file đã có đủ nội dung để sửa ngay: SKILL.md (Bước 4, Bước 7), references/qa-checklist-chung.md (mục QA chương), và code/tts/chapter_pipeline.py (parse_chapter_text, sync_segments, main, generate_audio). Một điểm cần nhớ khi áp dụng vào du-an-thong-thien-su: chiến lược lai (auto-tag 20 chương cũ, nhãn tay từ chương 21) chỉ khôi phục khoảng hai phần ba số lượt thoại đọc đúng giọng ở phần cũ, không phải toàn bộ, nên đáng chạy thử --dry-run rồi nghe lại vài đoạn trước khi coi phần di trú đã xong.