412 lines
14 KiB
Python
412 lines
14 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""
|
|||
|
|
VOICE-EMOTION-COMPILER
|
|||
|
|
语音情感编译器 — 把"苏白·大声·自信"转成 TTS 参数。
|
|||
|
|
|
|||
|
|
功能:
|
|||
|
|
1. 情感标签解析 ("苏白·大声·自信" → rate/pitch/volume)
|
|||
|
|
2. 支持 Edge-TTS 和豆包语音 A/B 测试
|
|||
|
|
3. 生成 voice_profile.hdlp 供 Agent_04 读取
|
|||
|
|
4. 批量生成不同情感参数的音频供 A/B 测试
|
|||
|
|
|
|||
|
|
用法:
|
|||
|
|
python voice-emotion-compiler.py --text "未来的天下第一宗!" --emotion "苏白·大声·自信" --output su-bai-loud.mp3
|
|||
|
|
python voice-emotion-compiler.py --ab-test --text "你好" --emotion "苏白·平静"
|
|||
|
|
python voice-emotion-compiler.py --generate-profile --character "苏白"
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import json
|
|||
|
|
import argparse
|
|||
|
|
import importlib.util
|
|||
|
|
from pathlib import Path
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
PROJECT_ROOT = Path(__file__).parent.parent
|
|||
|
|
sys.path.insert(0, str(PROJECT_ROOT / "engines"))
|
|||
|
|
|
|||
|
|
# 导入现有的 TTS 引擎。文件名是 tts-engine.py,不能用普通 import。
|
|||
|
|
tts_engine_path = PROJECT_ROOT / "engines" / "tts-engine.py"
|
|||
|
|
try:
|
|||
|
|
spec = importlib.util.spec_from_file_location("tts_engine", tts_engine_path)
|
|||
|
|
tts_engine = importlib.util.module_from_spec(spec)
|
|||
|
|
spec.loader.exec_module(tts_engine)
|
|||
|
|
generate_speech = tts_engine.generate_speech
|
|||
|
|
generate_by_character = tts_engine.generate_by_character
|
|||
|
|
load_voice_config = tts_engine.load_voice_config
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"⚠️ 无法导入 tts-engine,将使用简化模式: {exc}")
|
|||
|
|
generate_speech = None
|
|||
|
|
generate_by_character = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
class VoiceEmotionCompiler:
|
|||
|
|
"""语音情感编译器"""
|
|||
|
|
|
|||
|
|
# 情感映射表: "角色·情感·强度" → TTS 参数
|
|||
|
|
EMOTION_MAP = {
|
|||
|
|
# 苏白情感库
|
|||
|
|
"苏白·平静·正常": {
|
|||
|
|
"rate": "+0%",
|
|||
|
|
"pitch": "+0Hz",
|
|||
|
|
"volume": "+0%",
|
|||
|
|
"voice": "zh-CN-XiaoxiaoNeural", # 阳光少年音
|
|||
|
|
"style": None, # Edge-TTS 不支持 style,用参数模拟
|
|||
|
|
},
|
|||
|
|
"苏白·大声·自信": {
|
|||
|
|
"rate": "+20%", # 语速加快
|
|||
|
|
"pitch": "+10Hz", # 音调略高
|
|||
|
|
"volume": "+15%", # 音量增加
|
|||
|
|
"voice": "zh-CN-XiaoxiaoNeural",
|
|||
|
|
"style": None,
|
|||
|
|
},
|
|||
|
|
"苏白·小声·犹豫": {
|
|||
|
|
"rate": "-15%",
|
|||
|
|
"pitch": "-5Hz",
|
|||
|
|
"volume": "-10%",
|
|||
|
|
"voice": "zh-CN-XiaoxiaoNeural",
|
|||
|
|
"style": None,
|
|||
|
|
},
|
|||
|
|
"苏白·生气·愤怒": {
|
|||
|
|
"rate": "+25%",
|
|||
|
|
"pitch": "+15Hz",
|
|||
|
|
"volume": "+20%",
|
|||
|
|
"voice": "zh-CN-XiaoxiaoNeural",
|
|||
|
|
"style": None,
|
|||
|
|
},
|
|||
|
|
"苏白·惊讶·震惊": {
|
|||
|
|
"rate": "+30%",
|
|||
|
|
"pitch": "+20Hz",
|
|||
|
|
"volume": "+10%",
|
|||
|
|
"voice": "zh-CN-XiaoxiaoNeural",
|
|||
|
|
"style": None,
|
|||
|
|
},
|
|||
|
|
"苏白·悲伤·失落": {
|
|||
|
|
"rate": "-20%",
|
|||
|
|
"pitch": "-10Hz",
|
|||
|
|
"volume": "-5%",
|
|||
|
|
"voice": "zh-CN-XiaoxiaoNeural",
|
|||
|
|
"style": None,
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
# 诸葛风情感库
|
|||
|
|
"诸葛风·平静·沉稳": {
|
|||
|
|
"rate": "+0%",
|
|||
|
|
"pitch": "-5Hz",
|
|||
|
|
"volume": "+0%",
|
|||
|
|
"voice": "zh-CN-YunxiNeural", # 沉稳男声
|
|||
|
|
"style": None,
|
|||
|
|
},
|
|||
|
|
"诸葛风·大声·威严": {
|
|||
|
|
"rate": "+10%",
|
|||
|
|
"pitch": "-10Hz", # 低沉有力
|
|||
|
|
"volume": "+20%",
|
|||
|
|
"voice": "zh-CN-YunxiNeural",
|
|||
|
|
"style": None,
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
# 萧灵汐情感库
|
|||
|
|
"萧灵汐·平静·清冷": {
|
|||
|
|
"rate": "+0%",
|
|||
|
|
"pitch": "+5Hz",
|
|||
|
|
"volume": "+0%",
|
|||
|
|
"voice": "zh-CN-XiaoyiNeural", # 清冷女声
|
|||
|
|
"style": None,
|
|||
|
|
},
|
|||
|
|
"萧灵汐·大声·愤怒": {
|
|||
|
|
"rate": "+15%",
|
|||
|
|
"pitch": "+10Hz",
|
|||
|
|
"volume": "+15%",
|
|||
|
|
"voice": "zh-CN-XiaoyiNeural",
|
|||
|
|
"style": None,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 豆包语音情感映射 (如果豆包 API 支持情感参数)
|
|||
|
|
DOUBAO_EMOTION_MAP = {
|
|||
|
|
"苏白·平静·正常": {"emotion": "neutral", "speed": 1.0, "pitch": 1.0, "volume": 1.0},
|
|||
|
|
"苏白·大声·自信": {"emotion": "happy", "speed": 1.2, "pitch": 1.1, "volume": 1.15},
|
|||
|
|
"苏白·生气·愤怒": {"emotion": "angry", "speed": 1.25, "pitch": 1.15, "volume": 1.2},
|
|||
|
|
"苏白·惊讶·震惊": {"emotion": "surprised", "speed": 1.3, "pitch": 1.2, "volume": 1.1},
|
|||
|
|
"苏白·悲伤·失落": {"emotion": "sad", "speed": 0.8, "pitch": 0.9, "volume": 0.95},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def __init__(self, character=None):
|
|||
|
|
self.character = character
|
|||
|
|
self.voice_profiles = {}
|
|||
|
|
|
|||
|
|
def parse_emotion_tag(self, emotion_tag):
|
|||
|
|
"""
|
|||
|
|
解析情感标签
|
|||
|
|
格式: "角色·情感·强度" 或 "情感·强度"
|
|||
|
|
返回: TTS 参数字典
|
|||
|
|
"""
|
|||
|
|
print(f"🔍 解析情感标签: {emotion_tag}")
|
|||
|
|
|
|||
|
|
# 直接查找映射表
|
|||
|
|
if emotion_tag in self.EMOTION_MAP:
|
|||
|
|
params = self.EMOTION_MAP[emotion_tag].copy()
|
|||
|
|
print(f" ✅ 找到映射: rate={params['rate']}, pitch={params['pitch']}, volume={params['volume']}")
|
|||
|
|
return params
|
|||
|
|
|
|||
|
|
# 模糊匹配: 只给情感,不给角色
|
|||
|
|
for key, val in self.EMOTION_MAP.items():
|
|||
|
|
if emotion_tag in key:
|
|||
|
|
params = val.copy()
|
|||
|
|
print(f" ⚠️ 模糊匹配: {key} → rate={params['rate']}")
|
|||
|
|
return params
|
|||
|
|
|
|||
|
|
# 未找到,使用默认
|
|||
|
|
print(f" ⚠️ 未找到映射,使用默认参数")
|
|||
|
|
return {
|
|||
|
|
"rate": "+0%",
|
|||
|
|
"pitch": "+0Hz",
|
|||
|
|
"volume": "+0%",
|
|||
|
|
"voice": "zh-CN-XiaoxiaoNeural",
|
|||
|
|
"style": None,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def compile_to_tts_params(self, emotion_tag, engine="edge-tts"):
|
|||
|
|
"""
|
|||
|
|
将情感标签编译为 TTS 参数
|
|||
|
|
engine: "edge-tts" | "doubao"
|
|||
|
|
"""
|
|||
|
|
if engine == "edge-tts":
|
|||
|
|
return self.parse_emotion_tag(emotion_tag)
|
|||
|
|
elif engine == "doubao":
|
|||
|
|
# 豆包语音参数
|
|||
|
|
if emotion_tag in self.DOUBAO_EMOTION_MAP:
|
|||
|
|
return self.DOUBAO_EMOTION_MAP[emotion_tag]
|
|||
|
|
else:
|
|||
|
|
return {"emotion": "neutral", "speed": 1.0, "pitch": 1.0, "volume": 1.0}
|
|||
|
|
else:
|
|||
|
|
raise ValueError(f"不支持的引擎: {engine}")
|
|||
|
|
|
|||
|
|
def generate_speech_with_emotion(self, text, emotion_tag, output_path, engine="edge-tts"):
|
|||
|
|
"""
|
|||
|
|
生成带情感的语音
|
|||
|
|
"""
|
|||
|
|
print(f"\n🎤 生成情感语音")
|
|||
|
|
print(f" 文本: {text}")
|
|||
|
|
print(f" 情感: {emotion_tag}")
|
|||
|
|
print(f" 引擎: {engine}")
|
|||
|
|
|
|||
|
|
params = self.compile_to_tts_params(emotion_tag, engine)
|
|||
|
|
|
|||
|
|
if engine == "edge-tts":
|
|||
|
|
if generate_speech is None:
|
|||
|
|
print(" ❌ tts-engine 不可用")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
ok = generate_speech(
|
|||
|
|
text=text,
|
|||
|
|
output_path=output_path,
|
|||
|
|
voice=params["voice"],
|
|||
|
|
rate=params["rate"],
|
|||
|
|
pitch=params["pitch"],
|
|||
|
|
volume=params["volume"]
|
|||
|
|
)
|
|||
|
|
return ok
|
|||
|
|
|
|||
|
|
elif engine == "doubao":
|
|||
|
|
# 豆包语音 API 调用
|
|||
|
|
print(f" 📤 调用豆包语音 API...")
|
|||
|
|
print(f" 参数: {params}")
|
|||
|
|
# TODO: 实现豆包 API 调用
|
|||
|
|
# doubao_api_call(text, output_path, params)
|
|||
|
|
print(f" ⚠️ 豆包 API 调用未实现")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def ab_test(self, text, emotion_tag, output_dir):
|
|||
|
|
"""
|
|||
|
|
A/B 测试: 生成不同参数的音频
|
|||
|
|
"""
|
|||
|
|
print(f"\n🧪 A/B 测试: {emotion_tag}")
|
|||
|
|
print(f" 文本: {text}")
|
|||
|
|
|
|||
|
|
output_dir = Path(output_dir)
|
|||
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
results = []
|
|||
|
|
|
|||
|
|
# 生成多个变体
|
|||
|
|
variants = self._generate_variants(emotion_tag)
|
|||
|
|
|
|||
|
|
for i, variant_params in enumerate(variants):
|
|||
|
|
output_path = output_dir / f"ab-test-{i+1:03d}.mp3"
|
|||
|
|
print(f"\n [{i+1}/{len(variants)}] {variant_params['label']}")
|
|||
|
|
|
|||
|
|
if generate_speech:
|
|||
|
|
ok = generate_speech(
|
|||
|
|
text=text,
|
|||
|
|
output_path=str(output_path),
|
|||
|
|
voice=variant_params["params"]["voice"],
|
|||
|
|
rate=variant_params["params"]["rate"],
|
|||
|
|
pitch=variant_params["params"]["pitch"],
|
|||
|
|
volume=variant_params["params"]["volume"]
|
|||
|
|
)
|
|||
|
|
if ok:
|
|||
|
|
results.append({
|
|||
|
|
"label": variant_params["label"],
|
|||
|
|
"path": str(output_path),
|
|||
|
|
"params": variant_params["params"]
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# 生成 A/B 测试报告
|
|||
|
|
report_path = output_dir / "ab-test-report.json"
|
|||
|
|
with open(report_path, "w", encoding="utf-8") as f:
|
|||
|
|
json.dump({
|
|||
|
|
"emotion_tag": emotion_tag,
|
|||
|
|
"text": text,
|
|||
|
|
"variants": results,
|
|||
|
|
"generated_at": datetime.now().isoformat()
|
|||
|
|
}, f, ensure_ascii=False, indent=2)
|
|||
|
|
|
|||
|
|
print(f"\n✅ A/B 测试完成,生成 {len(results)} 个变体")
|
|||
|
|
print(f" 报告: {report_path}")
|
|||
|
|
|
|||
|
|
return results
|
|||
|
|
|
|||
|
|
def _generate_variants(self, emotion_tag):
|
|||
|
|
"""生成多个变体参数"""
|
|||
|
|
base_params = self.parse_emotion_tag(emotion_tag)
|
|||
|
|
|
|||
|
|
variants = [
|
|||
|
|
{"label": "基准", "params": base_params},
|
|||
|
|
{"label": "语速+10%", "params": {**base_params, "rate": f"+{int(base_params['rate'].strip('%+')) + 10}%"}},
|
|||
|
|
{"label": "音调+5Hz", "params": {**base_params, "pitch": f"+{int(base_params['pitch'].strip('Hz+')) + 5}Hz"}},
|
|||
|
|
{"label": "音量+10%", "params": {**base_params, "volume": f"+{int(base_params['volume'].strip('%+')) + 10}%"}},
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
return variants
|
|||
|
|
|
|||
|
|
def generate_voice_profile(self, character):
|
|||
|
|
"""
|
|||
|
|
生成角色的 voice_profile.hdlp
|
|||
|
|
保存到 assets/characters/<CHAR-ID>/voice/voice-profile.hdlp
|
|||
|
|
"""
|
|||
|
|
print(f"\n📝 生成 {character} 的语音画像...")
|
|||
|
|
|
|||
|
|
character_dir = PROJECT_ROOT / "assets" / "characters" / character
|
|||
|
|
voice_dir = character_dir / "voice"
|
|||
|
|
voice_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
profile_path = voice_dir / "voice-profile.hdlp"
|
|||
|
|
|
|||
|
|
# 收集该角色的所有情感
|
|||
|
|
character_prefix = character.replace("CHAR-", "").replace("-", "")
|
|||
|
|
# 简单匹配: 找所有以 "苏白" 开头的情感标签
|
|||
|
|
emotions = {}
|
|||
|
|
for key in self.EMOTION_MAP.keys():
|
|||
|
|
if key.startswith("苏白"): # TODO: 根据实际角色名匹配
|
|||
|
|
emotions[key] = self.EMOTION_MAP[key]
|
|||
|
|
|
|||
|
|
# 生成 HLDP 格式的配置
|
|||
|
|
profile_content = f"""# 语音画像 · {character}
|
|||
|
|
|
|||
|
|
> HLDP://video-ai-system/assets/characters/{character}/voice/voice-profile
|
|||
|
|
> 类型: 语音配置 · 情感参数映射
|
|||
|
|
> 建立: D144 · 2026-06-24
|
|||
|
|
> 铸渊 ICE-GL-ZY001 · 冰朔 TCS-0002∞
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 默认音色
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
voice: {list(emotions.values())[0]['voice'] if emotions else 'zh-CN-XiaoxiaoNeural'}
|
|||
|
|
engine: edge-tts
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 情感参数映射
|
|||
|
|
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
for emotion_tag, params in emotions.items():
|
|||
|
|
profile_content += f"""### {emotion_tag}
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
rate: {params['rate']}
|
|||
|
|
pitch: {params['pitch']}
|
|||
|
|
volume: {params['volume']}
|
|||
|
|
voice: {params['voice']}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
profile_content += """---
|
|||
|
|
|
|||
|
|
## 使用方式
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
from voice_emotion_compiler import VoiceEmotionCompiler
|
|||
|
|
compiler = VoiceEmotionCompiler()
|
|||
|
|
params = compiler.compile_to_tts_params("苏白·大声·自信", engine="edge-tts")
|
|||
|
|
generate_speech(text, output_path, **params)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
⊢ 此文件由 VOICE-EMOTION-COMPILER 自动生成。
|
|||
|
|
⊢ Agent_04 (配音) 读取此文件获取角色情感参数。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
with open(profile_path, "w", encoding="utf-8") as f:
|
|||
|
|
f.write(profile_content)
|
|||
|
|
|
|||
|
|
print(f" ✅ 已生成: {profile_path}")
|
|||
|
|
return profile_path
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
parser = argparse.ArgumentParser(description="VOICE-EMOTION-COMPILER")
|
|||
|
|
parser.add_argument("--text", type=str, help="要合成的文本")
|
|||
|
|
parser.add_argument("--emotion", type=str, help="情感标签 (如: '苏白·大声·自信')")
|
|||
|
|
parser.add_argument("--output", type=str, help="输出音频路径")
|
|||
|
|
parser.add_argument("--engine", type=str, default="edge-tts", choices=["edge-tts", "doubao"], help="TTS 引擎")
|
|||
|
|
parser.add_argument("--ab-test", action="store_true", help="A/B 测试模式")
|
|||
|
|
parser.add_argument("--output-dir", type=str, help="A/B 测试输出目录")
|
|||
|
|
parser.add_argument("--generate-profile", action="store_true", help="生成 voice_profile.hdlp")
|
|||
|
|
parser.add_argument("--character", type=str, help="角色ID")
|
|||
|
|
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
|
|||
|
|
compiler = VoiceEmotionCompiler()
|
|||
|
|
|
|||
|
|
if args.generate_profile:
|
|||
|
|
if not args.character:
|
|||
|
|
print("❌ --generate-profile 需要 --character")
|
|||
|
|
sys.exit(1)
|
|||
|
|
compiler.generate_voice_profile(args.character)
|
|||
|
|
sys.exit(0)
|
|||
|
|
|
|||
|
|
if args.ab_test:
|
|||
|
|
if not args.text or not args.emotion or not args.output_dir:
|
|||
|
|
print("❌ --ab-test 需要 --text, --emotion, --output-dir")
|
|||
|
|
sys.exit(1)
|
|||
|
|
compiler.ab_test(args.text, args.emotion, args.output_dir)
|
|||
|
|
sys.exit(0)
|
|||
|
|
|
|||
|
|
if not args.text or not args.emotion or not args.output:
|
|||
|
|
parser.print_help()
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
ok = compiler.generate_speech_with_emotion(
|
|||
|
|
text=args.text,
|
|||
|
|
emotion_tag=args.emotion,
|
|||
|
|
output_path=args.output,
|
|||
|
|
engine=args.engine
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
sys.exit(0 if ok else 1)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|