| |
| |
| """Generate response-side TTS controls from accepted query candidates. |
| |
| This is the second semantic planning stage: |
| query candidate -> assistant/TTS response Global+Control. |
| |
| The output intentionally keeps both `response_generated_control` and |
| `final_generated_control` so existing Qwen3TTS generation scripts can consume it |
| with their normal control key. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import re |
| from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait |
| from pathlib import Path |
| from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple |
|
|
| from common_llm import DEFAULT_BASE_URL, chat_completion, extract_json_object |
| from prompt_loader import get_prompt, render_prompt |
|
|
|
|
| DEFAULT_INPUT = "/workspace/echoloc/Dataset/Novel/query_data/v2_2000/filtered/query_candidates_keep.jsonl" |
| DEFAULT_OUTPUT = "/workspace/echoloc/Dataset/Novel/query_data/v2_2000/thinker_targets/response_controls.jsonl" |
| DEFAULT_FAILED_OUTPUT = "/workspace/echoloc/Dataset/Novel/query_data/v2_2000/thinker_targets/response_controls_failed.jsonl" |
| CONTROL_KEY = "response_generated_control" |
|
|
| ALLOWED_GENDERS = {"Male", "Female"} |
| ALLOWED_AGES = {"Child", "Teen", "Young_Adult", "Middle_Aged", "Senior"} |
| ALLOWED_LEVELS = {"Level_1_Subtle", "Level_2_Mild", "Level_3_Strong", "Level_4_Extreme"} |
| ACTION_OR_MARKUP_RE = re.compile(r"[\(\(\[\【].*?[\)\)\]\】]|旁白|动作|叹气|沉默|停顿|笑声:|哭声:") |
| INSTRUCTION_PREFIX_RE = re.compile(r"^\s*(请|帮我|麻烦|用|以).{0,40}(说|读|念|朗读|播报)[::,,]") |
|
|
|
|
| def iter_jsonl(path: Path) -> Iterable[Dict[str, Any]]: |
| with path.open("r", encoding="utf-8") as f: |
| for line in f: |
| if line.strip(): |
| yield json.loads(line) |
|
|
|
|
| def load_existing_qids(path: Path) -> Set[str]: |
| qids: Set[str] = set() |
| if not path.exists(): |
| return qids |
| with path.open("r", encoding="utf-8") as f: |
| for line in f: |
| try: |
| qid = json.loads(line).get("qid") |
| except json.JSONDecodeError: |
| continue |
| if qid: |
| qids.add(str(qid)) |
| return qids |
|
|
|
|
| def append_jsonl(path: Path, rows: Sequence[Dict[str, Any]]) -> None: |
| if not rows: |
| return |
| path.parent.mkdir(parents=True, exist_ok=True) |
| with path.open("a", encoding="utf-8") as f: |
| for row in rows: |
| f.write(json.dumps(row, ensure_ascii=False) + "\n") |
|
|
|
|
| def norm_text(text: Any) -> str: |
| return re.sub(r"\s+", "", str(text or "")).strip() |
|
|
|
|
| def clean_text(text: Any) -> str: |
| text = str(text or "") |
| text = re.sub(r"\s+", " ", text).strip() |
| text = re.sub(r"^[\"'“”‘’]+|[\"'“”‘’]+$", "", text).strip() |
| return text |
|
|
|
|
| def extract_quoted_payload(text: str) -> str: |
| """Best-effort extraction of target utterance from an instruction query.""" |
| if not text: |
| return "" |
| patterns = [ |
| r"(?:说|读|念|朗读|播报)[::]\s*[‘'“\"](.+?)[’'”\"]\s*$", |
| r"[‘'“\"](.+?)[’'”\"]\s*$", |
| ] |
| for pattern in patterns: |
| match = re.search(pattern, text, flags=re.S) |
| if match: |
| return clean_text(match.group(1)) |
| return "" |
|
|
|
|
| def item_language(item: Dict[str, Any]) -> str: |
| language = str(item.get("language") or (item.get("source") or {}).get("language") or "").strip().lower() |
| if language: |
| return language |
| source_candidate = item.get("source_query_candidate") or {} |
| language = str(source_candidate.get("language") or ((source_candidate.get("source") or {}).get("language")) or "").strip().lower() |
| if language: |
| return language |
| qid = str(item.get("qid") or "") |
| if qid.startswith("vstyle_en_") or "_en_" in qid: |
| return "en" |
| return "zh" |
|
|
|
|
| def get_language_prompt(base_key: str, language: str, default: str) -> str: |
| if language and language != "zh": |
| prompt = get_prompt(f"{base_key}.{language}", "") |
| if prompt: |
| return prompt |
| return get_prompt(base_key, default) |
|
|
|
|
| def render_language_prompt(base_key: str, language: str, default: str, replacements: Dict[str, Any]) -> str: |
| if language and language != "zh": |
| rendered = render_prompt(f"{base_key}.{language}", "", replacements) |
| if rendered: |
| return rendered |
| return render_prompt(base_key, default, replacements) |
|
|
|
|
| def build_generation_messages( |
| item: Dict[str, Any], |
| previous_control: Optional[Dict[str, Any]] = None, |
| feedback: str = "", |
| ) -> List[Dict[str, str]]: |
| language = item_language(item) |
| system = """ |
| 你是 Omni 情感语音训练数据的“回复规划 + TTS 声学控制”Agent。输入是一条已经质检通过的用户 query candidate。你要生成后续 talker 需要合成的高表现力回复语音控制,也就是 `response_generated_control`。 |
| |
| 两类 query 的任务完全不同: |
| |
| 1. dialogue: |
| - 用户是在向 AI 倾诉、抱怨、请求回应或表达情绪。 |
| - 你需要生成 AI 助手应该说出的回复台词,并给出该回复的 Global+Control。 |
| - 回复要接住 visible_query.text 中可见/可听的情绪,必要时参考 target_contract。 |
| - hidden_context/source_event 只能帮助理解抽象处境,不能把小说专名、未说出口的人物关系或具体剧情写进回复。 |
| |
| 2. instruction: |
| - 用户是在要求 TTS 系统“用某种风格说某段话”。 |
| - 你需要解析这条指令,生成真正应该被朗读/表演的目标文本,以及目标风格对应的 Global+Control。 |
| - 绝对不要把整句用户指令当作 sample_text。通常 sample_text 应是引号中的目标台词。 |
| |
| Global/Control 规范: |
| - Global 只描述目标说话人的稳定音色/年龄/性别/人设/风格,必须包含明确年龄和性别。 |
| - Control 描述每个台词片段的局部静态情绪、语速、音量、音高、气息、韵律。 |
| - Control 可以 1-4 段。只有存在自然情绪、语义或强度变化时才分段。 |
| - `sample_text` 必须是纯净 TTS 文本:不要 Markdown、不要括号动作、不要“AI:”、不要解释。 |
| - VAD 坐标范围是 [0,1]:Valence 愉悦度,Arousal 唤醒度,Dominance 支配感。 |
| - `instruct_en` 是对应中文声学描述的英文翻译。 |
| |
| 输出只允许是合法 JSON,不要 Markdown: |
| |
| { |
| "response_generated_control": { |
| "Global": { |
| "Gender": "Male | Female", |
| "Age": "Child | Teen | Young_Adult | Middle_Aged | Senior", |
| "Persona": "String", |
| "Style": "String", |
| "instruct_zh": "一句话全局音色/人设描述,必须包含明确年龄和性别", |
| "instruct_en": "English translation" |
| }, |
| "Control": [ |
| { |
| "emotion": "String", |
| "level": "Level_1_Subtle | Level_2_Mild | Level_3_Strong | Level_4_Extreme", |
| "instruct_zh": "该段静态声学控制", |
| "instruct_en": "English translation", |
| "vad_coordinates": [0.5, 0.5, 0.5], |
| "vad_explanation": "一句话说明 VAD 依据", |
| "sample_text": "该段要合成的纯净回复/目标台词" |
| } |
| ] |
| }, |
| "planning_reasoning": "简述如何从 query 推断回复目标、哪些隐藏信息没有使用、为何这样分段" |
| } |
| """ |
| system = get_language_prompt("response_controls.generation.system", language, system) |
| payload = { |
| "qid": item.get("qid"), |
| "query_type": item.get("query_type"), |
| "visible_query": item.get("visible_query"), |
| "query_voice": item.get("query_voice"), |
| "source_event": item.get("source_event"), |
| "hidden_context": item.get("hidden_context"), |
| "target_contract": item.get("target_contract"), |
| "quality_hints": item.get("quality_hints"), |
| } |
| user = get_language_prompt( |
| "response_controls.generation.user_prefix", |
| language, |
| "请为下面 query candidate 生成 response_generated_control:\n", |
| ) |
| user += json.dumps(payload, ensure_ascii=False, indent=2) |
| if previous_control is not None or feedback: |
| user += render_language_prompt( |
| "response_controls.generation.retry", |
| language, |
| "\n\n上一次生成未通过,请根据反馈重写。\n上一次 control:\n__PREVIOUS_CONTROL_JSON__\n反馈:\n__FEEDBACK__", |
| { |
| "PREVIOUS_CONTROL_JSON": json.dumps(previous_control, ensure_ascii=False, indent=2) if previous_control is not None else "", |
| "FEEDBACK": feedback, |
| }, |
| ) |
| return [ |
| {"role": "system", "content": system.strip()}, |
| {"role": "user", "content": user}, |
| ] |
|
|
|
|
| def build_eval_messages(item: Dict[str, Any], control: Dict[str, Any], deterministic_issues: Sequence[str]) -> List[Dict[str, str]]: |
| language = item_language(item) |
| system = """ |
| 你是 query->response TTS control 的质检专家。请判断 generated control 是否适合合成后续 talker 的回复/目标语音。 |
| |
| 硬失败项: |
| 1. dialogue 类型没有生成 AI 助手回复,而是复读用户原话。 |
| 2. dialogue 类型引入小说专名、隐藏人物关系或用户未说出口的具体事实。 |
| 3. instruction 类型把整条用户指令当作 sample_text,而不是提取目标台词。 |
| 4. instruction 类型丢失用户明确要求的声学风格。 |
| 5. sample_text 包含括号动作、旁白、Markdown、解释性文字或角色前缀。 |
| 6. Global 缺少明确年龄/性别,或枚举字段/VAD 不合法。 |
| |
| 评分: |
| - 5:完全可用,回复/目标文本自然,声学控制细致,分段合理。 |
| - 4:可用,有轻微措辞瑕疵。 |
| - 3:可修复,但存在明显偏差。 |
| - 1/2:不可用。 |
| |
| 只输出 JSON: |
| {"score": 5, "reason": "简短理由", "repair_suggestions": []} |
| """ |
| system = get_language_prompt("response_controls.eval.system", language, system) |
| payload = { |
| "qid": item.get("qid"), |
| "query_type": item.get("query_type"), |
| "visible_query": item.get("visible_query"), |
| "target_contract": item.get("target_contract"), |
| "deterministic_issues": list(deterministic_issues), |
| "generated_control": control, |
| } |
| return [ |
| {"role": "system", "content": system.strip()}, |
| {"role": "user", "content": json.dumps(payload, ensure_ascii=False, indent=2)}, |
| ] |
|
|
|
|
| def parse_control(raw: str) -> Tuple[Dict[str, Any], str]: |
| parsed = extract_json_object(raw) |
| reasoning = str(parsed.get("planning_reasoning", "")) |
| control = parsed.get(CONTROL_KEY) or parsed.get("final_generated_control") or parsed.get("generated_control") or parsed |
| if isinstance(control, dict) and CONTROL_KEY in control: |
| control = control[CONTROL_KEY] |
| return control, reasoning |
|
|
|
|
| def validate_control(item: Dict[str, Any], control: Dict[str, Any]) -> List[str]: |
| issues: List[str] = [] |
| if not isinstance(control, dict): |
| return ["control_not_object"] |
|
|
| global_ctrl = control.get("Global") |
| segments = control.get("Control") |
| if not isinstance(global_ctrl, dict): |
| issues.append("missing_Global") |
| global_ctrl = {} |
| if not isinstance(segments, list) or not segments: |
| issues.append("missing_Control") |
| segments = [] |
|
|
| gender = global_ctrl.get("Gender") |
| age = global_ctrl.get("Age") |
| global_zh = str(global_ctrl.get("instruct_zh", "")) |
| if gender not in ALLOWED_GENDERS: |
| issues.append("Gender_must_be_Male_or_Female") |
| if age not in ALLOWED_AGES: |
| issues.append("Age_enum_invalid") |
| if not re.search(r"(男|女|男性|女性|男孩|女孩|少年|少女|老人|老年|中年|年轻)", global_zh): |
| issues.append("Global_instruct_zh_missing_age_gender") |
|
|
| sample_texts: List[str] = [] |
| for idx, seg in enumerate(segments): |
| if not isinstance(seg, dict): |
| issues.append(f"Control_{idx}_not_object") |
| continue |
| for key in ["emotion", "level", "instruct_zh", "instruct_en", "vad_coordinates", "vad_explanation", "sample_text"]: |
| if key not in seg: |
| issues.append(f"Control_{idx}_missing_{key}") |
| if seg.get("level") not in ALLOWED_LEVELS: |
| issues.append(f"Control_{idx}_level_invalid") |
| vad = seg.get("vad_coordinates") |
| if ( |
| not isinstance(vad, list) |
| or len(vad) != 3 |
| or any(not isinstance(x, (int, float)) or x < 0 or x > 1 for x in vad) |
| ): |
| issues.append(f"Control_{idx}_vad_invalid") |
| sample_text = clean_text(seg.get("sample_text", "")) |
| if not sample_text: |
| issues.append(f"Control_{idx}_sample_text_empty") |
| if ACTION_OR_MARKUP_RE.search(sample_text): |
| issues.append(f"Control_{idx}_sample_text_contains_action_or_markup") |
| sample_texts.append(sample_text) |
|
|
| joined = "".join(sample_texts) |
| visible = str((item.get("visible_query") or {}).get("text", "")) |
| qtype = item.get("query_type") |
|
|
| if qtype == "dialogue": |
| if norm_text(joined) == norm_text(visible): |
| issues.append("dialogue_response_must_not_repeat_user_query") |
| if len(joined) < 8: |
| issues.append("dialogue_response_too_short") |
| if len(joined) > 260: |
| issues.append("dialogue_response_too_long") |
| elif qtype == "instruction": |
| quoted = extract_quoted_payload(visible) |
| if quoted and norm_text(joined) != norm_text(quoted): |
| issues.append("instruction_sample_text_should_equal_quoted_target_text") |
| if not quoted and INSTRUCTION_PREFIX_RE.search(joined): |
| issues.append("instruction_sample_text_looks_like_full_user_instruction") |
| if len(joined) > 220: |
| issues.append("instruction_target_text_too_long") |
|
|
| return sorted(set(issues)) |
|
|
|
|
| def score_with_llm( |
| item: Dict[str, Any], |
| control: Dict[str, Any], |
| issues: Sequence[str], |
| args: argparse.Namespace, |
| ) -> Tuple[int, str, List[str]]: |
| if issues: |
| return 0, "deterministic validation failed: " + "; ".join(issues), list(issues) |
| if args.no_eval: |
| return 5, "deterministic validation passed; LLM eval disabled", [] |
| raw = chat_completion( |
| build_eval_messages(item, control, issues), |
| model=args.model, |
| base_url=args.base_url, |
| api_key_env=args.api_key_env, |
| temperature=0.1, |
| enable_thinking=args.enable_thinking, |
| stream=args.stream, |
| timeout=args.http_timeout, |
| ) |
| parsed = extract_json_object(raw) |
| score = int(parsed.get("score", 0)) |
| reason = str(parsed.get("reason", "")) |
| suggestions = parsed.get("repair_suggestions") or [] |
| if not isinstance(suggestions, list): |
| suggestions = [str(suggestions)] |
| return score, reason, [str(x) for x in suggestions] |
|
|
|
|
| def normalize_row( |
| item: Dict[str, Any], |
| control: Dict[str, Any], |
| input_idx: int, |
| model: str, |
| score: int, |
| reason: str, |
| planning_reasoning: str, |
| ) -> Dict[str, Any]: |
| qid = item.get("qid") or f"query_{input_idx:06d}" |
| sample_text = "".join(str(seg.get("sample_text", "")) for seg in control.get("Control", [])) |
| language = item_language(item) |
| row = { |
| "qid": qid, |
| "query_type": item.get("query_type"), |
| "audio_content": sample_text, |
| "language": language, |
| "instruct_id": qid, |
| "file_name": f"response_{input_idx:06d}", |
| "ability": "response_tts_from_query", |
| "visible_query": item.get("visible_query"), |
| "source_event": item.get("source_event", ""), |
| "query_voice": item.get("query_voice", {}), |
| "target_contract": item.get("target_contract", {}), |
| "source_query_candidate": { |
| "language": language, |
| "source": item.get("source"), |
| "visible_query": item.get("visible_query"), |
| "hidden_context": item.get("hidden_context"), |
| "source_event": item.get("source_event"), |
| "target_contract": item.get("target_contract"), |
| "quality_hints": item.get("quality_hints"), |
| }, |
| CONTROL_KEY: control, |
| "final_generated_control": control, |
| "response_control_generation": { |
| "model": model, |
| "prompt_version": "query_response_control_v1_grounded", |
| "score": score, |
| "reason": reason, |
| "planning_reasoning": planning_reasoning, |
| }, |
| } |
| return row |
|
|
|
|
| def generate_one(task: Tuple[int, Dict[str, Any]], args: argparse.Namespace) -> Dict[str, Any]: |
| input_idx, item = task |
| attempts: List[Dict[str, Any]] = [] |
| previous_control: Optional[Dict[str, Any]] = None |
| feedback = "" |
| best: Optional[Tuple[Dict[str, Any], int, str, str]] = None |
|
|
| for attempt in range(1, args.max_attempts + 1): |
| try: |
| raw = chat_completion( |
| build_generation_messages(item, previous_control=previous_control, feedback=feedback), |
| model=args.model, |
| base_url=args.base_url, |
| api_key_env=args.api_key_env, |
| temperature=args.temperature, |
| enable_thinking=args.enable_thinking, |
| stream=args.stream, |
| timeout=args.http_timeout, |
| ) |
| control, planning_reasoning = parse_control(raw) |
| issues = validate_control(item, control) |
| score, reason, suggestions = score_with_llm(item, control, issues, args) |
| attempts.append( |
| { |
| "attempt": attempt, |
| "score": score, |
| "reason": reason, |
| "issues": issues, |
| "repair_suggestions": suggestions, |
| "control_preview": control, |
| } |
| ) |
| if best is None or score > best[1]: |
| best = (control, score, reason, planning_reasoning) |
| if score >= args.accept_score: |
| return { |
| "status": "ok", |
| "row": normalize_row(item, control, input_idx, args.model, score, reason, planning_reasoning), |
| "failed": None, |
| } |
| previous_control = control |
| feedback = reason |
| if suggestions: |
| feedback += "\n修复建议:" + json.dumps(suggestions, ensure_ascii=False) |
| except Exception as exc: |
| attempts.append({"attempt": attempt, "error": repr(exc)}) |
| feedback = repr(exc) |
|
|
| failed = { |
| "qid": item.get("qid"), |
| "query_type": item.get("query_type"), |
| "status": "failed", |
| "model": args.model, |
| "attempts": attempts, |
| "visible_query": item.get("visible_query"), |
| } |
| if best is not None and args.keep_best: |
| control, score, reason, planning_reasoning = best |
| return { |
| "status": "ok", |
| "row": normalize_row(item, control, input_idx, args.model, score, reason, planning_reasoning), |
| "failed": failed, |
| } |
| return {"status": "failed", "row": None, "failed": failed} |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--input", default=DEFAULT_INPUT) |
| parser.add_argument("--output", default=DEFAULT_OUTPUT) |
| parser.add_argument("--failed_output", default=DEFAULT_FAILED_OUTPUT) |
| parser.add_argument("--model", default="deepseek-v4-pro") |
| parser.add_argument("--base_url", default=DEFAULT_BASE_URL) |
| parser.add_argument("--api_key_env", default="DASHSCOPE_API_KEY") |
| parser.add_argument("--temperature", type=float, default=0.35) |
| parser.add_argument("--http_timeout", type=int, default=180) |
| parser.add_argument("--num_workers", type=int, default=4) |
| parser.add_argument("--limit", type=int, default=0) |
| parser.add_argument("--max_attempts", type=int, default=3) |
| parser.add_argument("--accept_score", type=int, default=4) |
| parser.add_argument("--resume", action="store_true") |
| parser.add_argument("--enable_thinking", action="store_true") |
| parser.add_argument("--stream", action="store_true") |
| parser.add_argument("--no_eval", action="store_true") |
| parser.add_argument("--keep_best", action="store_true") |
| return parser.parse_args() |
|
|
|
|
| def main() -> int: |
| args = parse_args() |
| input_path = Path(args.input) |
| output_path = Path(args.output) |
| failed_path = Path(args.failed_output) |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
| failed_path.parent.mkdir(parents=True, exist_ok=True) |
|
|
| done = load_existing_qids(output_path) if args.resume else set() |
| indexed_items = [(idx, item) for idx, item in enumerate(iter_jsonl(input_path), start=1) if str(item.get("qid")) not in done] |
| if args.limit > 0: |
| indexed_items = indexed_items[: args.limit] |
|
|
| print( |
| f"[INFO] items={len(indexed_items)} resume_done={len(done)} output={output_path} failed_output={failed_path}", |
| flush=True, |
| ) |
| ok_count = 0 |
| fail_count = 0 |
|
|
| with ThreadPoolExecutor(max_workers=max(1, args.num_workers)) as executor: |
| pending = {} |
| iterator = iter(indexed_items) |
|
|
| def submit_next() -> bool: |
| try: |
| task = next(iterator) |
| except StopIteration: |
| return False |
| pending[executor.submit(generate_one, task, args)] = task[1].get("qid") |
| return True |
|
|
| while len(pending) < args.num_workers and submit_next(): |
| pass |
|
|
| processed = 0 |
| while pending: |
| finished, _ = wait(pending, return_when=FIRST_COMPLETED) |
| ok_rows: List[Dict[str, Any]] = [] |
| failed_rows: List[Dict[str, Any]] = [] |
| for fut in finished: |
| pending.pop(fut, None) |
| result = fut.result() |
| processed += 1 |
| if result["row"] is not None: |
| ok_rows.append(result["row"]) |
| ok_count += 1 |
| if result["failed"] is not None: |
| failed_rows.append(result["failed"]) |
| if result["status"] == "failed": |
| fail_count += 1 |
| submit_next() |
| append_jsonl(output_path, ok_rows) |
| append_jsonl(failed_path, failed_rows) |
| print(f"[PROGRESS] processed={processed}/{len(indexed_items)} ok={ok_count} failed={fail_count}", flush=True) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|