mirror of
https://github.com/xming521/WeClone.git
synced 2026-09-01 15:36:15 +08:00
重构项目
This commit is contained in:
+1
-1
@@ -155,6 +155,6 @@ test.*
|
||||
*users.json
|
||||
Spark-TTS-0.5B/
|
||||
uv.lock
|
||||
*output.wav
|
||||
*.wav
|
||||
|
||||
Qwen*/
|
||||
@@ -0,0 +1,136 @@
|
||||
# WeClone-audio 模块
|
||||
|
||||
WeClone-audio 是一个使用微信语音消息克隆声音的模块,使用模型实现高质量语音合成。
|
||||
### 显存需求
|
||||
**Spark-TTS** 推荐
|
||||
- **0.5B 模型**: 约 4GB 显存
|
||||
|
||||
**Llasa** (已弃用)
|
||||
- **3B 模型**: 约 16GB 显存
|
||||
- **1B 模型**: 约 9GB 显存
|
||||
|
||||
|
||||
|
||||
|
||||
## 1. 导出微信语音数据
|
||||
|
||||
### 1.1 准备工作
|
||||
- 使用 [PyWxDump](https://github.com/xaoyaoo/PyWxDump) 提取微信聊天记录
|
||||
- 下载软件并解密数据库
|
||||
- 点击聊天备份,导出类型选择"解密文件"
|
||||
|
||||
### 1.2 环境配置
|
||||
语音导出仅支持Windows环境
|
||||
WeClone Audio使用uv作为包管理器。
|
||||
```bash
|
||||
# 为 PyWxDump 创建 Python 环境和安装依赖
|
||||
#
|
||||
uv venv .venv-wx --python=3.9
|
||||
.venv-wx\Scripts\activate
|
||||
# 安装 wx 依赖组
|
||||
uv pip install --group wx -e .
|
||||
```
|
||||
|
||||
### 1.3 导出语音文件
|
||||
```bash
|
||||
# 假设 get_sample_audio.py 现在位于 src/ 目录下
|
||||
python weclone-audio/src/get_sample_audio.py --db-path "导出数据库路径" --MsgSvrID "导出聊天记录的MsgSvrID字段"
|
||||
```
|
||||
|
||||
## 2. 语音合成推理
|
||||
### Spark-TTS模型
|
||||
|
||||
**环境安装**
|
||||
可不创建新环境,直接安装依赖组到WeClone共主环境
|
||||
|
||||
```bash
|
||||
uv venv .venv-sparktts --python=3.10
|
||||
source .venv-sparktts/bin/activate
|
||||
uv pip install --group sparktts -e .
|
||||
|
||||
git clone https://github.com/SparkAudio/Spark-TTS.git weclone-audio/src/Spark-TTS
|
||||
```
|
||||
|
||||
|
||||
**模型下载**
|
||||
|
||||
通过python下载:
|
||||
```python
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
# 假设此 Python 代码在 weclone-audio 目录下运行 模型将下载到 weclone-audio/pretrained_models/Spark-TTS-0.5B
|
||||
snapshot_download("SparkAudio/Spark-TTS-0.5B", local_dir="pretrained_models/Spark-TTS-0.5B")
|
||||
```
|
||||
|
||||
或通过git下载:
|
||||
```sh
|
||||
# 假设当前在 weclone-audio 目录
|
||||
mkdir -p pretrained_models
|
||||
|
||||
# Make sure you have git-lfs installed (https://git-lfs.com)
|
||||
git lfs install
|
||||
git clone https://huggingface.co/SparkAudio/Spark-TTS-0.5B pretrained_models/Spark-TTS-0.5B
|
||||
```
|
||||
使用代码推理
|
||||
```python
|
||||
import os
|
||||
import SparkTTS
|
||||
import soundfile as sf
|
||||
import torch
|
||||
|
||||
from SparkTTS import SparkTTS
|
||||
|
||||
# 假设此 Python 代码在 weclone-audio 目录下运行
|
||||
# 模型路径相对于当前目录
|
||||
model_path = "pretrained_models/Spark-TTS-0.5B"
|
||||
sample_audio = "sample.wav"
|
||||
output_audio = "output.wav"
|
||||
|
||||
model = SparkTTS(model_path, "cuda")
|
||||
|
||||
with torch.no_grad():
|
||||
wav = model.inference(
|
||||
text="晚上好啊,小可爱们,该睡觉了哦",
|
||||
prompt_speech_path=sample_audio, # 使用相对路径
|
||||
prompt_text="对,这就是我万人敬仰的太乙真人,虽然有点婴儿肥,但也掩不住我逼人的帅气。",
|
||||
)
|
||||
sf.write(output_audio, wav, samplerate=16000) # 使用相对路径
|
||||
```
|
||||
### Llasa模型 (已弃用)
|
||||
### 2.1 环境配置
|
||||
```bash
|
||||
# 创建并配置推理环境
|
||||
## 可不创建新环境,与LLaMA-Factory环境共用
|
||||
uv venv .venv-xcodec --python=3.9
|
||||
source .venv-xcodec/bin/activate
|
||||
uv pip install --group xcodec -e .
|
||||
# 退出环境
|
||||
deactivate
|
||||
|
||||
# 系统依赖安装(如果需要)
|
||||
sudo apt install python3-dev
|
||||
sudo apt install build-essential
|
||||
```
|
||||
|
||||
### 2.2 使用代码推理
|
||||
如果遇到问题,请尝试将参考音频转换为WAV或MP3格式,将其裁剪至15秒以内,并缩短提示文本。
|
||||
```python
|
||||
import os
|
||||
import soundfile as sf
|
||||
# 假设 text_to_speech.py 位于 src/ 或其他可导入的位置
|
||||
from text_to_speech import TextToSpeech
|
||||
|
||||
|
||||
sample_audio_text = "对,这就是我万人敬仰的太乙真人,虽然有点婴儿肥,但也掩不住我逼人的帅气。" # 示例音频文本
|
||||
# 假设此 Python 代码在 weclone-audio 目录下运行
|
||||
# 示例音频路径相对于当前目录
|
||||
sample_audio_path = "sample.wav"
|
||||
output_audio = "output.wav"
|
||||
|
||||
|
||||
tts = TextToSpeech(sample_audio_path, sample_audio_text)
|
||||
target_text = "晚上好啊" # 生成目标文本
|
||||
result = tts.infer(target_text)
|
||||
sf.write(output_audio, result[1], result[0]) # 使用相对路径
|
||||
```
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
import soundfile as sf
|
||||
from text_to_speech import TextToSpeech
|
||||
|
||||
|
||||
sample_audio_text = "对,这就是我万人敬仰的太乙真人,虽然有点婴儿肥,但也掩不住我逼人的帅气。" # 示例音频文本
|
||||
sample_audio_path = os.path.join(os.path.dirname(__file__), "sample.wav") # 示例音频路径
|
||||
tts = TextToSpeech(sample_audio_path, sample_audio_text)
|
||||
target_text = "晚上好啊" # 生成目标文本
|
||||
result = tts.infer(target_text)
|
||||
sf.write(os.path.join(os.path.dirname(__file__), "output.wav"), result[1], result[0]) # 保存生成音频
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import os
|
||||
from transformers import AutoTokenizer, AutoModelForCausalLM
|
||||
import torch
|
||||
import soundfile as sf
|
||||
from xcodec2.modeling_xcodec2 import XCodec2Model
|
||||
import torchaudio
|
||||
|
||||
|
||||
class TextToSpeech:
|
||||
def __init__(self, sample_audio_path, sample_audio_text):
|
||||
self.sample_audio_text = sample_audio_text
|
||||
# 初始化模型
|
||||
llasa_3b = "HKUSTAudio/Llasa-3B"
|
||||
xcodec2 = "HKUSTAudio/xcodec2"
|
||||
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(llasa_3b)
|
||||
self.llasa_3b_model = AutoModelForCausalLM.from_pretrained(
|
||||
llasa_3b,
|
||||
trust_remote_code=True,
|
||||
device_map="auto",
|
||||
)
|
||||
self.llasa_3b_model.eval()
|
||||
|
||||
self.xcodec_model = XCodec2Model.from_pretrained(xcodec2)
|
||||
self.xcodec_model.eval().cuda()
|
||||
|
||||
# 处理音频
|
||||
waveform, sample_rate = torchaudio.load(sample_audio_path)
|
||||
if len(waveform[0]) / sample_rate > 15:
|
||||
print("已将音频裁剪至前15秒。")
|
||||
waveform = waveform[:, : sample_rate * 15]
|
||||
|
||||
# 检查音频是否为立体声
|
||||
if waveform.size(0) > 1:
|
||||
waveform_mono = torch.mean(waveform, dim=0, keepdim=True)
|
||||
else:
|
||||
waveform_mono = waveform
|
||||
|
||||
self.prompt_wav = torchaudio.transforms.Resample(
|
||||
orig_freq=sample_rate, new_freq=16000
|
||||
)(waveform_mono)
|
||||
|
||||
# Encode the prompt wav
|
||||
vq_code_prompt = self.xcodec_model.encode_code(input_waveform=self.prompt_wav)
|
||||
vq_code_prompt = vq_code_prompt[0, 0, :]
|
||||
self.speech_ids_prefix = self.ids_to_speech_tokens(vq_code_prompt)
|
||||
self.speech_end_id = self.tokenizer.convert_tokens_to_ids("<|SPEECH_GENERATION_END|>")
|
||||
|
||||
def ids_to_speech_tokens(self, speech_ids):
|
||||
speech_tokens_str = []
|
||||
for speech_id in speech_ids:
|
||||
speech_tokens_str.append(f"<|s_{speech_id}|>")
|
||||
return speech_tokens_str
|
||||
|
||||
def extract_speech_ids(self, speech_tokens_str):
|
||||
speech_ids = []
|
||||
for token_str in speech_tokens_str:
|
||||
if token_str.startswith("<|s_") and token_str.endswith("|>"):
|
||||
num_str = token_str[4:-2]
|
||||
num = int(num_str)
|
||||
speech_ids.append(num)
|
||||
else:
|
||||
print(f"Unexpected token: {token_str}")
|
||||
return speech_ids
|
||||
|
||||
@torch.inference_mode()
|
||||
def infer(self, target_text):
|
||||
if len(target_text) == 0:
|
||||
return None
|
||||
elif len(target_text) > 300:
|
||||
print("文本过长,请保持在300字符以内。")
|
||||
target_text = target_text[:300]
|
||||
|
||||
input_text = self.sample_audio_text + " " + target_text
|
||||
|
||||
formatted_text = (
|
||||
f"<|TEXT_UNDERSTANDING_START|>{input_text}<|TEXT_UNDERSTANDING_END|>"
|
||||
)
|
||||
|
||||
chat = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Convert the text to speech:" + formatted_text,
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<|SPEECH_GENERATION_START|>"
|
||||
+ "".join(self.speech_ids_prefix),
|
||||
},
|
||||
]
|
||||
|
||||
input_ids = self.tokenizer.apply_chat_template(
|
||||
chat, tokenize=True, return_tensors="pt", continue_final_message=True
|
||||
)
|
||||
input_ids = input_ids.to("cuda")
|
||||
|
||||
outputs = self.llasa_3b_model.generate(
|
||||
input_ids,
|
||||
max_length=2048,
|
||||
eos_token_id=self.speech_end_id,
|
||||
do_sample=True,
|
||||
top_p=1,
|
||||
temperature=0.8,
|
||||
)
|
||||
generated_ids = outputs[0][input_ids.shape[1] - len(self.speech_ids_prefix): -1]
|
||||
|
||||
speech_tokens = self.tokenizer.batch_decode(
|
||||
generated_ids, skip_special_tokens=True
|
||||
)
|
||||
|
||||
speech_tokens = self.extract_speech_ids(speech_tokens)
|
||||
speech_tokens = torch.tensor(speech_tokens).cuda().unsqueeze(0).unsqueeze(0)
|
||||
|
||||
gen_wav = self.xcodec_model.decode_code(speech_tokens)
|
||||
gen_wav = gen_wav[:, :, self.prompt_wav.shape[1]:]
|
||||
|
||||
return (16000, gen_wav[0, 0, :].cpu().numpy())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 如果遇到问题,请尝试将参考音频转换为WAV或MP3格式,将其裁剪至15秒以内,并缩短提示文本。
|
||||
sample_audio_text = "对,这就是我万人敬仰的太乙真人,虽然有点婴儿肥,但也掩不住我逼人的帅气。"
|
||||
sample_audio_path = os.path.join(os.path.dirname(__file__), "sample.wav")
|
||||
|
||||
tts = TextToSpeech(sample_audio_path, sample_audio_text)
|
||||
target_text = "晚上好啊,吃了吗您"
|
||||
result = tts.infer(target_text)
|
||||
sf.write(os.path.join(os.path.dirname(__file__), "output.wav"), result[1], result[0])
|
||||
target_text = "我是老北京正黄旗!"
|
||||
result = tts.infer(target_text)
|
||||
sf.write(os.path.join(os.path.dirname(__file__), "output1.wav"), result[1], result[0])
|
||||
Submodule
+1
Submodule weclone-audio/src/Spark-TTS added at ee29f36806
@@ -0,0 +1,223 @@
|
||||
import re
|
||||
import torch
|
||||
from typing import Tuple
|
||||
from pathlib import Path
|
||||
from transformers import AutoTokenizer, AutoModelForCausalLM
|
||||
import os
|
||||
import sys
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "./Spark-TTS")))
|
||||
from sparktts.utils.file import load_config
|
||||
from sparktts.models.audio_tokenizer import BiCodecTokenizer
|
||||
from sparktts.utils.token_parser import LEVELS_MAP, GENDER_MAP, TASK_TOKEN_MAP
|
||||
|
||||
|
||||
class SparkTTS:
|
||||
"""
|
||||
Spark-TTS for text-to-speech generation.
|
||||
"""
|
||||
|
||||
def __init__(self, model_dir: Path, device: torch.device = torch.device("cuda:0")):
|
||||
"""
|
||||
Initializes the SparkTTS model with the provided configurations and device.
|
||||
|
||||
Args:
|
||||
model_dir (Path): Directory containing the model and config files.
|
||||
device (torch.device): The device (CPU/GPU) to run the model on.
|
||||
"""
|
||||
self.device = device
|
||||
self.model_dir = model_dir
|
||||
self.configs = load_config(f"{model_dir}/config.yaml")
|
||||
self.sample_rate = self.configs["sample_rate"]
|
||||
self._initialize_inference()
|
||||
|
||||
def _initialize_inference(self):
|
||||
"""Initializes the tokenizer, model, and audio tokenizer for inference."""
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(f"{self.model_dir}/LLM")
|
||||
self.model = AutoModelForCausalLM.from_pretrained(f"{self.model_dir}/LLM")
|
||||
self.audio_tokenizer = BiCodecTokenizer(self.model_dir, device=self.device)
|
||||
self.model.to(self.device)
|
||||
|
||||
def process_prompt(
|
||||
self,
|
||||
text: str,
|
||||
prompt_speech_path: Path,
|
||||
prompt_text: str = None,
|
||||
) -> Tuple[str, torch.Tensor]:
|
||||
"""
|
||||
Process input for voice cloning.
|
||||
|
||||
Args:
|
||||
text (str): The text input to be converted to speech.
|
||||
prompt_speech_path (Path): Path to the audio file used as a prompt.
|
||||
prompt_text (str, optional): Transcript of the prompt audio.
|
||||
|
||||
Return:
|
||||
Tuple[str, torch.Tensor]: Input prompt; global tokens
|
||||
"""
|
||||
|
||||
global_token_ids, semantic_token_ids = self.audio_tokenizer.tokenize(
|
||||
prompt_speech_path
|
||||
)
|
||||
global_tokens = "".join(
|
||||
[f"<|bicodec_global_{i}|>" for i in global_token_ids.squeeze()]
|
||||
)
|
||||
|
||||
# Prepare the input tokens for the model
|
||||
if prompt_text is not None:
|
||||
semantic_tokens = "".join(
|
||||
[f"<|bicodec_semantic_{i}|>" for i in semantic_token_ids.squeeze()]
|
||||
)
|
||||
inputs = [
|
||||
TASK_TOKEN_MAP["tts"],
|
||||
"<|start_content|>",
|
||||
prompt_text,
|
||||
text,
|
||||
"<|end_content|>",
|
||||
"<|start_global_token|>",
|
||||
global_tokens,
|
||||
"<|end_global_token|>",
|
||||
"<|start_semantic_token|>",
|
||||
semantic_tokens,
|
||||
]
|
||||
else:
|
||||
inputs = [
|
||||
TASK_TOKEN_MAP["tts"],
|
||||
"<|start_content|>",
|
||||
text,
|
||||
"<|end_content|>",
|
||||
"<|start_global_token|>",
|
||||
global_tokens,
|
||||
"<|end_global_token|>",
|
||||
]
|
||||
|
||||
inputs = "".join(inputs)
|
||||
|
||||
return inputs, global_token_ids
|
||||
|
||||
def process_prompt_control(
|
||||
self,
|
||||
gender: str,
|
||||
pitch: str,
|
||||
speed: str,
|
||||
text: str,
|
||||
):
|
||||
"""
|
||||
Process input for voice creation.
|
||||
|
||||
Args:
|
||||
gender (str): female | male.
|
||||
pitch (str): very_low | low | moderate | high | very_high
|
||||
speed (str): very_low | low | moderate | high | very_high
|
||||
text (str): The text input to be converted to speech.
|
||||
|
||||
Return:
|
||||
str: Input prompt
|
||||
"""
|
||||
assert gender in GENDER_MAP.keys()
|
||||
assert pitch in LEVELS_MAP.keys()
|
||||
assert speed in LEVELS_MAP.keys()
|
||||
|
||||
gender_id = GENDER_MAP[gender]
|
||||
pitch_level_id = LEVELS_MAP[pitch]
|
||||
speed_level_id = LEVELS_MAP[speed]
|
||||
|
||||
pitch_label_tokens = f"<|pitch_label_{pitch_level_id}|>"
|
||||
speed_label_tokens = f"<|speed_label_{speed_level_id}|>"
|
||||
gender_tokens = f"<|gender_{gender_id}|>"
|
||||
|
||||
attribte_tokens = "".join(
|
||||
[gender_tokens, pitch_label_tokens, speed_label_tokens]
|
||||
)
|
||||
|
||||
control_tts_inputs = [
|
||||
TASK_TOKEN_MAP["controllable_tts"],
|
||||
"<|start_content|>",
|
||||
text,
|
||||
"<|end_content|>",
|
||||
"<|start_style_label|>",
|
||||
attribte_tokens,
|
||||
"<|end_style_label|>",
|
||||
]
|
||||
|
||||
return "".join(control_tts_inputs)
|
||||
|
||||
@torch.no_grad()
|
||||
def inference(
|
||||
self,
|
||||
text: str,
|
||||
prompt_speech_path: Path = None,
|
||||
prompt_text: str = None,
|
||||
gender: str = None,
|
||||
pitch: str = None,
|
||||
speed: str = None,
|
||||
temperature: float = 0.8,
|
||||
top_k: float = 50,
|
||||
top_p: float = 0.95,
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Performs inference to generate speech from text, incorporating prompt audio and/or text.
|
||||
|
||||
Args:
|
||||
text (str): The text input to be converted to speech.
|
||||
prompt_speech_path (Path): Path to the audio file used as a prompt.
|
||||
prompt_text (str, optional): Transcript of the prompt audio.
|
||||
gender (str): female | male.
|
||||
pitch (str): very_low | low | moderate | high | very_high
|
||||
speed (str): very_low | low | moderate | high | very_high
|
||||
temperature (float, optional): Sampling temperature for controlling randomness. Default is 0.8.
|
||||
top_k (float, optional): Top-k sampling parameter. Default is 50.
|
||||
top_p (float, optional): Top-p (nucleus) sampling parameter. Default is 0.95.
|
||||
|
||||
Returns:
|
||||
torch.Tensor: Generated waveform as a tensor.
|
||||
"""
|
||||
if gender is not None:
|
||||
prompt = self.process_prompt_control(gender, pitch, speed, text)
|
||||
|
||||
else:
|
||||
prompt, global_token_ids = self.process_prompt(
|
||||
text, prompt_speech_path, prompt_text
|
||||
)
|
||||
model_inputs = self.tokenizer([prompt], return_tensors="pt").to(self.device)
|
||||
|
||||
# Generate speech using the model
|
||||
generated_ids = self.model.generate(
|
||||
**model_inputs,
|
||||
max_new_tokens=3000,
|
||||
do_sample=True,
|
||||
top_k=top_k,
|
||||
top_p=top_p,
|
||||
temperature=temperature,
|
||||
)
|
||||
|
||||
# Trim the output tokens to remove the input tokens
|
||||
generated_ids = [
|
||||
output_ids[len(input_ids):]
|
||||
for input_ids, output_ids in zip(model_inputs.input_ids, generated_ids)
|
||||
]
|
||||
|
||||
# Decode the generated tokens into text
|
||||
predicts = self.tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]
|
||||
|
||||
# Extract semantic token IDs from the generated text
|
||||
pred_semantic_ids = (
|
||||
torch.tensor([int(token) for token in re.findall(r"bicodec_semantic_(\d+)", predicts)])
|
||||
.long()
|
||||
.unsqueeze(0)
|
||||
)
|
||||
|
||||
if gender is not None:
|
||||
global_token_ids = (
|
||||
torch.tensor([int(token) for token in re.findall(r"bicodec_global_(\d+)", predicts)])
|
||||
.long()
|
||||
.unsqueeze(0)
|
||||
.unsqueeze(0)
|
||||
)
|
||||
|
||||
# Convert semantic tokens back to waveform
|
||||
wav = self.audio_tokenizer.detokenize(
|
||||
global_token_ids.to(self.device).squeeze(0),
|
||||
pred_semantic_ids.to(self.device),
|
||||
)
|
||||
|
||||
return wav
|
||||
@@ -0,0 +1,35 @@
|
||||
import os
|
||||
import argparse
|
||||
from pywxdump.db import MediaHandler
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Extract audio from WeChat database")
|
||||
parser.add_argument("--db-path", type=str, required=True,
|
||||
help="Path to WeChat database file")
|
||||
parser.add_argument("--MsgSvrID", type=str, required=True,
|
||||
help="Message server ID of the audio")
|
||||
parser.add_argument("--save-path", type=str,
|
||||
default=os.path.join(os.path.dirname(__file__), "sample.wav"),
|
||||
help="Path to save the audio file (default: sample.wav in script directory)")
|
||||
parser.add_argument("--rate", type=int, default=24000,
|
||||
help="Sample rate for audio conversion (default: 24000)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
config = {
|
||||
"key": "test1",
|
||||
"type": "sqlite",
|
||||
"path": args.db_path,
|
||||
}
|
||||
|
||||
t1 = MediaHandler(config)
|
||||
t1.get_audio(
|
||||
MsgSvrID=args.msg_id,
|
||||
is_play=True,
|
||||
is_wave=True,
|
||||
save_path=args.save_path,
|
||||
rate=args.rate,
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
import soundfile as sf
|
||||
import torch
|
||||
|
||||
from SparkTTS import SparkTTS
|
||||
|
||||
model = SparkTTS("weclone-audio/pretrained_models/Spark-TTS-0.5B", "cuda")
|
||||
|
||||
|
||||
with torch.no_grad():
|
||||
wav = model.inference(
|
||||
text="晚上好啊,小可爱们,该睡觉了哦",
|
||||
prompt_speech_path=os.path.join(os.path.dirname(__file__), "sample.wav"),
|
||||
prompt_text="对,这就是我万人敬仰的太乙真人,虽然有点婴儿肥,但也掩不住我逼人的帅气。",
|
||||
)
|
||||
sf.write(os.path.join(os.path.dirname(__file__), "output.wav"), wav, samplerate=16000)
|
||||
print("生成成功!")
|
||||
@@ -0,0 +1,14 @@
|
||||
API_KEY=your_api_key_here
|
||||
PORT=5050
|
||||
|
||||
DEFAULT_VOICE=en-US-AvaNeural
|
||||
DEFAULT_RESPONSE_FORMAT=mp3
|
||||
DEFAULT_SPEED=1.0
|
||||
|
||||
DEFAULT_LANGUAGE=en-US
|
||||
|
||||
REQUIRE_API_KEY=True
|
||||
|
||||
REMOVE_FILTER=False
|
||||
|
||||
EXPAND_API=True
|
||||
@@ -0,0 +1,62 @@
|
||||
import re
|
||||
import emoji
|
||||
|
||||
def prepare_tts_input_with_context(text: str) -> str:
|
||||
"""
|
||||
Prepares text for a TTS API by cleaning Markdown and adding minimal contextual hints
|
||||
for certain Markdown elements like headers. Preserves paragraph separation.
|
||||
|
||||
Args:
|
||||
text (str): The raw text containing Markdown or other formatting.
|
||||
|
||||
Returns:
|
||||
str: Cleaned text with contextual hints suitable for TTS input.
|
||||
"""
|
||||
|
||||
# Remove emojis
|
||||
text = emoji.replace_emoji(text, replace='')
|
||||
|
||||
# Add context for headers
|
||||
def header_replacer(match):
|
||||
level = len(match.group(1)) # Number of '#' symbols
|
||||
header_text = match.group(2).strip()
|
||||
if level == 1:
|
||||
return f"Title — {header_text}\n"
|
||||
elif level == 2:
|
||||
return f"Section — {header_text}\n"
|
||||
else:
|
||||
return f"Subsection — {header_text}\n"
|
||||
|
||||
text = re.sub(r"^(#{1,6})\s+(.*)", header_replacer, text, flags=re.MULTILINE)
|
||||
|
||||
# Announce links (currently commented out for potential future use)
|
||||
# text = re.sub(r"\[([^\]]+)\]\((https?:\/\/[^\)]+)\)", r"\1 (link: \2)", text)
|
||||
|
||||
# Remove links while keeping the link text
|
||||
text = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", text)
|
||||
|
||||
# Describe inline code
|
||||
text = re.sub(r"`([^`]+)`", r"code snippet: \1", text)
|
||||
|
||||
# Remove bold/italic symbols but keep the content
|
||||
text = re.sub(r"(\*\*|__|\*|_)", '', text)
|
||||
|
||||
# Remove code blocks (multi-line) with a description
|
||||
text = re.sub(r"```([\s\S]+?)```", r"(code block omitted)", text)
|
||||
|
||||
# Remove image syntax but add alt text if available
|
||||
text = re.sub(r"!\[([^\]]*)\]\([^\)]+\)", r"Image: \1", text)
|
||||
|
||||
# Remove HTML tags
|
||||
text = re.sub(r"</?[^>]+(>|$)", '', text)
|
||||
|
||||
# Normalize line breaks
|
||||
text = re.sub(r"\n{2,}", '\n\n', text) # Ensure consistent paragraph separation
|
||||
|
||||
# Replace multiple spaces within lines
|
||||
text = re.sub(r" {2,}", ' ', text)
|
||||
|
||||
# Trim leading and trailing whitespace from the whole text
|
||||
text = text.strip()
|
||||
|
||||
return text
|
||||
@@ -0,0 +1,5 @@
|
||||
flask
|
||||
gevent
|
||||
python-dotenv
|
||||
edge-tts
|
||||
emoji
|
||||
@@ -0,0 +1,167 @@
|
||||
# server.py
|
||||
|
||||
from flask import Flask, request, send_file, jsonify
|
||||
from gevent.pywsgi import WSGIServer
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
from handle_text import prepare_tts_input_with_context
|
||||
from tts_handler import generate_speech, get_models, get_voices
|
||||
from utils import getenv_bool, require_api_key, AUDIO_FORMAT_MIME_TYPES
|
||||
|
||||
app = Flask(__name__)
|
||||
load_dotenv()
|
||||
|
||||
API_KEY = os.getenv('API_KEY', 'your_api_key_here')
|
||||
PORT = int(os.getenv('PORT', 5050))
|
||||
|
||||
DEFAULT_VOICE = os.getenv('DEFAULT_VOICE', 'en-US-AvaNeural')
|
||||
DEFAULT_RESPONSE_FORMAT = os.getenv('DEFAULT_RESPONSE_FORMAT', 'mp3')
|
||||
DEFAULT_SPEED = float(os.getenv('DEFAULT_SPEED', 1.0))
|
||||
|
||||
REMOVE_FILTER = getenv_bool('REMOVE_FILTER', False)
|
||||
EXPAND_API = getenv_bool('EXPAND_API', True)
|
||||
|
||||
# DEFAULT_MODEL = os.getenv('DEFAULT_MODEL', 'tts-1')
|
||||
|
||||
@app.route('/v1/audio/speech', methods=['POST'])
|
||||
@app.route('/audio/speech', methods=['POST']) # Add this line for the alias
|
||||
@require_api_key
|
||||
def text_to_speech():
|
||||
data = request.json
|
||||
if not data or 'input' not in data:
|
||||
return jsonify({"error": "Missing 'input' in request body"}), 400
|
||||
|
||||
text = data.get('input')
|
||||
|
||||
if not REMOVE_FILTER:
|
||||
text = prepare_tts_input_with_context(text)
|
||||
|
||||
# model = data.get('model', DEFAULT_MODEL)
|
||||
voice = data.get('voice', DEFAULT_VOICE)
|
||||
|
||||
response_format = data.get('response_format', DEFAULT_RESPONSE_FORMAT)
|
||||
speed = float(data.get('speed', DEFAULT_SPEED))
|
||||
|
||||
mime_type = AUDIO_FORMAT_MIME_TYPES.get(response_format, "audio/mpeg")
|
||||
|
||||
# Generate the audio file in the specified format with speed adjustment
|
||||
output_file_path = generate_speech(text, voice, response_format, speed)
|
||||
|
||||
# Return the file with the correct MIME type
|
||||
return send_file(output_file_path, mimetype=mime_type, as_attachment=True, download_name=f"speech.{response_format}")
|
||||
|
||||
@app.route('/v1/models', methods=['GET', 'POST'])
|
||||
@app.route('/models', methods=['GET', 'POST'])
|
||||
@require_api_key
|
||||
def list_models():
|
||||
return jsonify({"data": get_models()})
|
||||
|
||||
@app.route('/v1/voices', methods=['GET', 'POST'])
|
||||
@app.route('/voices', methods=['GET', 'POST'])
|
||||
@require_api_key
|
||||
def list_voices():
|
||||
specific_language = None
|
||||
|
||||
data = request.args if request.method == 'GET' else request.json
|
||||
if data and ('language' in data or 'locale' in data):
|
||||
specific_language = data.get('language') if 'language' in data else data.get('locale')
|
||||
|
||||
return jsonify({"voices": get_voices(specific_language)})
|
||||
|
||||
@app.route('/v1/voices/all', methods=['GET', 'POST'])
|
||||
@app.route('/voices/all', methods=['GET', 'POST'])
|
||||
@require_api_key
|
||||
def list_all_voices():
|
||||
return jsonify({"voices": get_voices('all')})
|
||||
|
||||
"""
|
||||
Support for ElevenLabs and Azure AI Speech
|
||||
(currently in beta)
|
||||
"""
|
||||
|
||||
# http://localhost:5050/elevenlabs/v1/text-to-speech
|
||||
# http://localhost:5050/elevenlabs/v1/text-to-speech/en-US-AndrewNeural
|
||||
@app.route('/elevenlabs/v1/text-to-speech/<voice_id>', methods=['POST'])
|
||||
@require_api_key
|
||||
def elevenlabs_tts(voice_id):
|
||||
if not EXPAND_API:
|
||||
return jsonify({"error": f"Endpoint not allowed"}), 500
|
||||
|
||||
# Parse the incoming JSON payload
|
||||
try:
|
||||
payload = request.json
|
||||
if not payload or 'text' not in payload:
|
||||
return jsonify({"error": "Missing 'text' in request body"}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Invalid JSON payload: {str(e)}"}), 400
|
||||
|
||||
text = payload['text']
|
||||
|
||||
if not REMOVE_FILTER:
|
||||
text = prepare_tts_input_with_context(text)
|
||||
|
||||
voice = voice_id # ElevenLabs uses the voice_id in the URL
|
||||
|
||||
# Use default settings for edge-tts
|
||||
response_format = 'mp3'
|
||||
speed = DEFAULT_SPEED # Optional customization via payload.get('speed', DEFAULT_SPEED)
|
||||
|
||||
# Generate speech using edge-tts
|
||||
try:
|
||||
output_file_path = generate_speech(text, voice, response_format, speed)
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"TTS generation failed: {str(e)}"}), 500
|
||||
|
||||
# Return the generated audio file
|
||||
return send_file(output_file_path, mimetype="audio/mpeg", as_attachment=True, download_name="speech.mp3")
|
||||
|
||||
# tts.speech.microsoft.com/cognitiveservices/v1
|
||||
# https://{region}.tts.speech.microsoft.com/cognitiveservices/v1
|
||||
# http://localhost:5050/azure/cognitiveservices/v1
|
||||
@app.route('/azure/cognitiveservices/v1', methods=['POST'])
|
||||
@require_api_key
|
||||
def azure_tts():
|
||||
if not EXPAND_API:
|
||||
return jsonify({"error": f"Endpoint not allowed"}), 500
|
||||
|
||||
# Parse the SSML payload
|
||||
try:
|
||||
ssml_data = request.data.decode('utf-8')
|
||||
if not ssml_data:
|
||||
return jsonify({"error": "Missing SSML payload"}), 400
|
||||
|
||||
# Extract the text and voice from SSML
|
||||
from xml.etree import ElementTree as ET
|
||||
root = ET.fromstring(ssml_data)
|
||||
text = root.find('.//{http://www.w3.org/2001/10/synthesis}voice').text
|
||||
voice = root.find('.//{http://www.w3.org/2001/10/synthesis}voice').get('name')
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Invalid SSML payload: {str(e)}"}), 400
|
||||
|
||||
# Use default settings for edge-tts
|
||||
response_format = 'mp3'
|
||||
speed = DEFAULT_SPEED
|
||||
|
||||
if not REMOVE_FILTER:
|
||||
text = prepare_tts_input_with_context(text)
|
||||
|
||||
# Generate speech using edge-tts
|
||||
try:
|
||||
output_file_path = generate_speech(text, voice, response_format, speed)
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"TTS generation failed: {str(e)}"}), 500
|
||||
|
||||
# Return the generated audio file
|
||||
return send_file(output_file_path, mimetype="audio/mpeg", as_attachment=True, download_name="speech.mp3")
|
||||
|
||||
print(f" Edge TTS (Free Azure TTS) Replacement for OpenAI's TTS API")
|
||||
print(f" ")
|
||||
print(f" * Serving OpenAI Edge TTS")
|
||||
print(f" * Server running on http://localhost:{PORT}")
|
||||
print(f" * TTS Endpoint: http://localhost:{PORT}/v1/audio/speech")
|
||||
print(f" ")
|
||||
|
||||
if __name__ == '__main__':
|
||||
http_server = WSGIServer(('0.0.0.0', PORT), app)
|
||||
http_server.serve_forever()
|
||||
@@ -0,0 +1,133 @@
|
||||
import edge_tts
|
||||
import asyncio
|
||||
import tempfile
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Language default (environment variable)
|
||||
DEFAULT_LANGUAGE = os.getenv('DEFAULT_LANGUAGE', 'en-US')
|
||||
|
||||
# OpenAI voice names mapped to edge-tts equivalents
|
||||
voice_mapping = {
|
||||
'alloy': 'en-US-AvaNeural',
|
||||
'echo': 'en-US-AndrewNeural',
|
||||
'fable': 'en-GB-SoniaNeural',
|
||||
'onyx': 'en-US-EricNeural',
|
||||
'nova': 'en-US-SteffanNeural',
|
||||
'shimmer': 'en-US-EmmaNeural'
|
||||
}
|
||||
|
||||
def is_ffmpeg_installed():
|
||||
"""Check if FFmpeg is installed and accessible."""
|
||||
try:
|
||||
subprocess.run(['ffmpeg', '-version'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
async def _generate_audio(text, voice, response_format, speed):
|
||||
"""Generate TTS audio and optionally convert to a different format."""
|
||||
# Determine if the voice is an OpenAI-compatible voice or a direct edge-tts voice
|
||||
edge_tts_voice = voice_mapping.get(voice, voice) # Use mapping if in OpenAI names, otherwise use as-is
|
||||
|
||||
# Generate the TTS output in mp3 format first
|
||||
temp_output_file = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3")
|
||||
|
||||
# Convert speed to SSML rate format
|
||||
try:
|
||||
speed_rate = speed_to_rate(speed) # Convert speed value to "+X%" or "-X%"
|
||||
except Exception as e:
|
||||
print(f"Error converting speed: {e}. Defaulting to +0%.")
|
||||
speed_rate = "+0%"
|
||||
|
||||
# Generate the MP3 file
|
||||
communicator = edge_tts.Communicate(text=text, voice=edge_tts_voice, rate=speed_rate)
|
||||
await communicator.save(temp_output_file.name)
|
||||
|
||||
# If the requested format is mp3, return the generated file directly
|
||||
if response_format == "mp3":
|
||||
return temp_output_file.name
|
||||
|
||||
# Check if FFmpeg is installed
|
||||
if not is_ffmpeg_installed():
|
||||
print("FFmpeg is not available. Returning unmodified mp3 file.")
|
||||
return temp_output_file.name
|
||||
|
||||
# Create a new temporary file for the converted output
|
||||
converted_output_file = tempfile.NamedTemporaryFile(delete=False, suffix=f".{response_format}")
|
||||
|
||||
# Build the FFmpeg command
|
||||
ffmpeg_command = [
|
||||
"ffmpeg",
|
||||
"-i", temp_output_file.name, # Input file
|
||||
"-c:a", {
|
||||
"aac": "aac",
|
||||
"mp3": "libmp3lame",
|
||||
"wav": "pcm_s16le",
|
||||
"opus": "libopus",
|
||||
"flac": "flac"
|
||||
}.get(response_format, "aac"), # Default to AAC if unknown
|
||||
"-b:a", "192k" if response_format != "wav" else None, # Bitrate not needed for WAV
|
||||
"-f", {
|
||||
"aac": "mp4", # AAC in MP4 container
|
||||
"mp3": "mp3",
|
||||
"wav": "wav",
|
||||
"opus": "ogg",
|
||||
"flac": "flac"
|
||||
}.get(response_format, response_format), # Default to matching format
|
||||
"-y", # Overwrite without prompt
|
||||
converted_output_file.name # Output file
|
||||
]
|
||||
|
||||
try:
|
||||
# Run FFmpeg command and ensure no errors occur
|
||||
subprocess.run(ffmpeg_command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(f"FFmpeg error during audio conversion: {e}")
|
||||
|
||||
# Clean up the original temporary file
|
||||
Path(temp_output_file.name).unlink(missing_ok=True)
|
||||
|
||||
return converted_output_file.name
|
||||
|
||||
def generate_speech(text, voice, response_format, speed=1.0):
|
||||
return asyncio.run(_generate_audio(text, voice, response_format, speed))
|
||||
|
||||
def get_models():
|
||||
return [
|
||||
{"id": "tts-1", "name": "Text-to-speech v1"},
|
||||
{"id": "tts-1-hd", "name": "Text-to-speech v1 HD"}
|
||||
]
|
||||
|
||||
async def _get_voices(language=None):
|
||||
# List all voices, filter by language if specified
|
||||
all_voices = await edge_tts.list_voices()
|
||||
language = language or DEFAULT_LANGUAGE # Use default if no language specified
|
||||
filtered_voices = [
|
||||
{"name": v['ShortName'], "gender": v['Gender'], "language": v['Locale']}
|
||||
for v in all_voices if language == 'all' or language is None or v['Locale'] == language
|
||||
]
|
||||
return filtered_voices
|
||||
|
||||
def get_voices(language=None):
|
||||
return asyncio.run(_get_voices(language))
|
||||
|
||||
def speed_to_rate(speed: float) -> str:
|
||||
"""
|
||||
Converts a multiplicative speed value to the edge-tts "rate" format.
|
||||
|
||||
Args:
|
||||
speed (float): The multiplicative speed value (e.g., 1.5 for +50%, 0.5 for -50%).
|
||||
|
||||
Returns:
|
||||
str: The formatted "rate" string (e.g., "+50%" or "-50%").
|
||||
"""
|
||||
if speed < 0 or speed > 2:
|
||||
raise ValueError("Speed must be between 0 and 2 (inclusive).")
|
||||
|
||||
# Convert speed to percentage change
|
||||
percentage_change = (speed - 1) * 100
|
||||
|
||||
# Format with a leading "+" or "-" as required
|
||||
return f"{percentage_change:+.0f}%"
|
||||
@@ -0,0 +1,38 @@
|
||||
# utils.py
|
||||
|
||||
from flask import request, jsonify
|
||||
from functools import wraps
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
def getenv_bool(name: str, default: bool = False) -> bool:
|
||||
return os.getenv(name, str(default)).lower() in ("yes", "y", "true", "1", "t")
|
||||
|
||||
API_KEY = os.getenv('API_KEY', 'your_api_key_here')
|
||||
REQUIRE_API_KEY = getenv_bool('REQUIRE_API_KEY', True)
|
||||
|
||||
def require_api_key(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not REQUIRE_API_KEY:
|
||||
return f(*args, **kwargs)
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
return jsonify({"error": "Missing or invalid API key"}), 401
|
||||
token = auth_header.split('Bearer ')[1]
|
||||
if token != API_KEY:
|
||||
return jsonify({"error": "Invalid API key"}), 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
# Mapping of audio format to MIME type
|
||||
AUDIO_FORMAT_MIME_TYPES = {
|
||||
"mp3": "audio/mpeg",
|
||||
"opus": "audio/ogg",
|
||||
"aac": "audio/aac",
|
||||
"flac": "audio/flac",
|
||||
"wav": "audio/wav",
|
||||
"pcm": "audio/L16"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
from dataclasses import dataclass
|
||||
from pandas import Timestamp
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatMessage:
|
||||
id: int
|
||||
MsgSvrID: int
|
||||
type_name: str
|
||||
is_sender: int
|
||||
talker: str
|
||||
room_name: str
|
||||
msg: str
|
||||
src: str
|
||||
CreateTime: Timestamp
|
||||
|
||||
|
||||
@dataclass
|
||||
class CutMessage:
|
||||
is_sender: int
|
||||
cut_type: str
|
||||
CreateTime: Timestamp
|
||||
|
||||
|
||||
skip_type_list = [
|
||||
"添加好友",
|
||||
"推荐公众号",
|
||||
"动画表情",
|
||||
"位置",
|
||||
"文件",
|
||||
"位置共享",
|
||||
"接龙",
|
||||
"引用回复",
|
||||
"视频号直播或直播回放",
|
||||
"用户上传的GIF表情",
|
||||
"文件(猜)",
|
||||
"群公告",
|
||||
"视频号直播或直播回放等",
|
||||
"游戏相关",
|
||||
"转账",
|
||||
"赠送红包封面",
|
||||
"语音通话",
|
||||
"企业微信打招呼(猜)",
|
||||
"企业微信添加好友(猜)",
|
||||
"系统通知",
|
||||
"消息撤回1",
|
||||
"拍一拍",
|
||||
"消息撤回5",
|
||||
"消息撤回6",
|
||||
"消息撤回33",
|
||||
"消息撤回36",
|
||||
"消息撤回57",
|
||||
"邀请加群",
|
||||
"未知-11000,0",
|
||||
]
|
||||
# 没处理的类型
|
||||
unprocessed_type_list = []
|
||||
@@ -0,0 +1,354 @@
|
||||
import os
|
||||
from typing import Dict, List
|
||||
import re
|
||||
|
||||
import pandas as pd
|
||||
import json
|
||||
|
||||
from weclone.utils.config import load_config
|
||||
from weclone.utils.log import logger
|
||||
from weclone.data.models import ChatMessage, CutMessage, skip_type_list
|
||||
from weclone.data.strategies import TimeWindowStrategy, LLMStrategy
|
||||
|
||||
|
||||
class DataProcessor:
|
||||
def __init__(self):
|
||||
self.config = load_config(arg_type="make_dataset")
|
||||
self.csv_folder = "./dataset/csv"
|
||||
self.system_prompt = self.config["default_system"]
|
||||
self.cut_type_list = [
|
||||
"图片",
|
||||
"视频",
|
||||
"合并转发的聊天记录",
|
||||
"语音",
|
||||
"(分享)音乐",
|
||||
"(分享)卡片式链接",
|
||||
"(分享)笔记",
|
||||
"(分享)小程序",
|
||||
"(分享)收藏夹",
|
||||
"(分享)小说(猜)",
|
||||
"(分享)视频号名片",
|
||||
"(分享)视频号视频",
|
||||
"粘贴的文本", # 无法解析的分享链接
|
||||
]
|
||||
|
||||
if self.config["single_combine_strategy"] == "time_window":
|
||||
self.single_combine_strategy = TimeWindowStrategy(
|
||||
time_window=self.config["single_combine_time_window"] * 60,
|
||||
is_single_chat=True,
|
||||
)
|
||||
elif self.config["single_combine_strategy"] == "llm":
|
||||
self.single_combine_strategy = LLMStrategy(
|
||||
is_single_chat=True,
|
||||
)
|
||||
|
||||
if self.config["qa_match_strategy"] == "time_window":
|
||||
self.qa_match_strategy = TimeWindowStrategy(
|
||||
time_window=self.config["qa_match_time_window"] * 60,
|
||||
is_single_chat=False,
|
||||
)
|
||||
elif self.config["qa_match_strategy"] == "llm":
|
||||
self.qa_match_strategy = LLMStrategy(is_single_chat=False)
|
||||
|
||||
self.c = self.config
|
||||
|
||||
def main(self):
|
||||
if not os.path.exists(self.csv_folder) or not os.listdir(self.csv_folder):
|
||||
logger.error(f"错误:目录 '{self.csv_folder}' 不存在或为空,请检查路径并确保其中包含 CSV 聊天数据文件。")
|
||||
return
|
||||
|
||||
csv_files = self.get_csv_files()
|
||||
message_list: List[ChatMessage] = []
|
||||
for csv_file in csv_files:
|
||||
chat_messages = self.load_csv(csv_file)
|
||||
message_list.extend(self.group_consecutive_messages(messages=chat_messages))
|
||||
# self.process_by_msgtype(chat_message)
|
||||
qa_res = self.match_qa(message_list)
|
||||
if self.c["prompt_with_history"]:
|
||||
qa_res = self.add_history_to_qa(qa_res)
|
||||
self.save_result(qa_res)
|
||||
|
||||
def get_csv_files(self):
|
||||
"""遍历文件夹获取所有CSV文件路径"""
|
||||
|
||||
csv_files = []
|
||||
for chat_obj_folder in os.listdir(self.csv_folder):
|
||||
chat_obj_folder_path = os.path.join(self.csv_folder, chat_obj_folder)
|
||||
for csvfile in os.listdir(chat_obj_folder_path):
|
||||
if not csvfile.endswith(".csv"):
|
||||
continue
|
||||
csvfile_path = os.path.join(chat_obj_folder_path, csvfile)
|
||||
csv_files.append(csvfile_path)
|
||||
return csv_files
|
||||
|
||||
def match_qa(self, messages: List[ChatMessage]) -> List[Dict]:
|
||||
"""
|
||||
匹配问答对
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
|
||||
Returns:
|
||||
List[Dict]: 包含指令和输出的问答对列表
|
||||
"""
|
||||
# 状态定义
|
||||
WAITING_INSTRUCTION = "waiting_instruction" # 等待指令
|
||||
WAITING_RESPONSE = "waiting_response" # 等待回复
|
||||
|
||||
current_state = WAITING_INSTRUCTION
|
||||
qa_res = []
|
||||
last_message = None
|
||||
current_instruction = None
|
||||
|
||||
for msg in messages:
|
||||
# 检查是否为CutMessage
|
||||
if isinstance(msg, CutMessage):
|
||||
current_state = WAITING_INSTRUCTION
|
||||
current_instruction = None
|
||||
last_message = None
|
||||
if self.c["prompt_with_history"]:
|
||||
qa_res.append(msg)
|
||||
continue
|
||||
|
||||
if current_state == WAITING_INSTRUCTION:
|
||||
if msg.is_sender == 0: # 收到对方消息
|
||||
current_instruction = msg.msg
|
||||
last_message = msg
|
||||
current_state = WAITING_RESPONSE
|
||||
|
||||
elif current_state == WAITING_RESPONSE:
|
||||
if msg.is_sender == 0: # 收到对方消息
|
||||
current_instruction = msg.msg
|
||||
last_message = msg
|
||||
# 状态保持不变
|
||||
else: # 自己的回复 使用策略判断是否属于同一对话
|
||||
if last_message and self.qa_match_strategy.is_same_conversation([last_message], msg):
|
||||
qa_res.append(
|
||||
{"instruction": current_instruction, "output": msg.msg, "system": self.system_prompt}
|
||||
)
|
||||
else:
|
||||
if self.c["prompt_with_history"]:
|
||||
qa_res.append(
|
||||
CutMessage(
|
||||
is_sender=msg.is_sender,
|
||||
cut_type=msg.type_name,
|
||||
CreateTime=msg.CreateTime,
|
||||
)
|
||||
)
|
||||
# 无论是否匹配,都重置状态
|
||||
current_state = WAITING_INSTRUCTION
|
||||
current_instruction = None
|
||||
last_message = None
|
||||
|
||||
return qa_res
|
||||
|
||||
def add_history_to_qa(self, qa_res: List[Dict]) -> List[Dict]:
|
||||
qa_res_with_history = []
|
||||
last_res = {"instruction": "", "output": "", "history": [], "system": self.system_prompt}
|
||||
|
||||
for _, qa in enumerate(qa_res):
|
||||
if isinstance(qa, CutMessage):
|
||||
if len(last_res["history"]) == 0:
|
||||
continue
|
||||
else:
|
||||
if len(last_res["history"]) == 1:
|
||||
last_res = {
|
||||
"system": self.system_prompt,
|
||||
"instruction": last_res["history"][0][0],
|
||||
"output": last_res["history"][0][1],
|
||||
"history": [],
|
||||
}
|
||||
else:
|
||||
last_res = {
|
||||
"system": self.system_prompt,
|
||||
"instruction": last_res["history"][-1][0],
|
||||
"output": last_res["history"][-1][1],
|
||||
"history": last_res["history"][:-1],
|
||||
}
|
||||
qa_res_with_history.append(last_res)
|
||||
last_res = {"instruction": "", "output": "", "history": [], "system": self.system_prompt}
|
||||
else:
|
||||
last_res["history"].append([qa["instruction"], qa["output"]])
|
||||
|
||||
return qa_res_with_history
|
||||
|
||||
def group_consecutive_messages(self, messages: List[ChatMessage]) -> List[ChatMessage]:
|
||||
"""
|
||||
将同一个人连续发送的多条消息组合成一条消息,遇到cut_type添加cut
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
|
||||
Returns:
|
||||
List[ChatMessage]: 组合后的消息列表
|
||||
"""
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
def _combine_text(messages: List[ChatMessage]) -> ChatMessage:
|
||||
"""
|
||||
合并多条消息为一条
|
||||
|
||||
Args:
|
||||
messages: 要合并的消息列表
|
||||
|
||||
Returns:
|
||||
ChatMessage: 合并后的消息
|
||||
"""
|
||||
base_msg = messages[0]
|
||||
combined_content = messages[0].msg
|
||||
|
||||
for i in messages[1:]:
|
||||
content = i.msg
|
||||
if not content:
|
||||
continue
|
||||
|
||||
if combined_content and combined_content[-1] not in ["。", "!", "?", "…", ",", "."]:
|
||||
combined_content += ","
|
||||
|
||||
combined_content += content
|
||||
if len(combined_content) > self.c["combine_msg_max_length"]:
|
||||
logger.warning(f"组合后消息长度超过{self.c['combine_msg_max_length']}将截断:\n {combined_content}")
|
||||
combined_content = combined_content[: self.c["combine_msg_max_length"]]
|
||||
|
||||
combined_message = ChatMessage(
|
||||
id=base_msg.id,
|
||||
MsgSvrID=base_msg.MsgSvrID,
|
||||
type_name=base_msg.type_name,
|
||||
is_sender=base_msg.is_sender,
|
||||
talker=base_msg.talker,
|
||||
room_name=base_msg.room_name,
|
||||
msg=combined_content,
|
||||
src=base_msg.src,
|
||||
CreateTime=messages[-1].CreateTime, # 使用最后一条消息的时间
|
||||
)
|
||||
|
||||
return combined_message
|
||||
|
||||
def _create_cut_message(message: ChatMessage) -> CutMessage:
|
||||
return CutMessage(
|
||||
is_sender=message.is_sender,
|
||||
cut_type=message.type_name,
|
||||
CreateTime=message.CreateTime,
|
||||
)
|
||||
|
||||
def _combine_current_group(group):
|
||||
"""
|
||||
处理当前消息组并添加到grouped_messages
|
||||
|
||||
Args:
|
||||
group: 当前消息组
|
||||
"""
|
||||
if len(group) > 1:
|
||||
combined_msg = _combine_text(group)
|
||||
grouped_messages.append(combined_msg)
|
||||
else:
|
||||
grouped_messages.append(group[0])
|
||||
|
||||
grouped_messages = []
|
||||
current_group = []
|
||||
|
||||
for _, current_msg in enumerate(messages):
|
||||
if current_msg.type_name in self.cut_type_list:
|
||||
if current_group:
|
||||
# 当前组有消息,合并当前组,并添加一条cut
|
||||
_combine_current_group(current_group)
|
||||
current_group = []
|
||||
|
||||
cut_msg = _create_cut_message(current_msg)
|
||||
grouped_messages.append(cut_msg)
|
||||
else:
|
||||
# 当前组没消息,检查上一个组
|
||||
if grouped_messages:
|
||||
if not isinstance(grouped_messages[-1], CutMessage):
|
||||
cut_msg = _create_cut_message(current_msg)
|
||||
grouped_messages.append(cut_msg)
|
||||
# 如果上一个组没消息或最后一条是CutMessage,直接continue
|
||||
continue
|
||||
|
||||
if not current_group:
|
||||
current_group = [current_msg]
|
||||
continue
|
||||
|
||||
last_msg = current_group[-1]
|
||||
|
||||
# 判断是否是同一个人的连续消息
|
||||
if (
|
||||
current_msg.is_sender == last_msg.is_sender
|
||||
and current_msg.talker == last_msg.talker
|
||||
and self.single_combine_strategy.is_same_conversation([last_msg], current_msg)
|
||||
):
|
||||
current_group.append(current_msg)
|
||||
else:
|
||||
# 不是同一个人的消息,处理当前组并开始新组
|
||||
_combine_current_group(current_group)
|
||||
# 开始新组
|
||||
current_group = [current_msg]
|
||||
|
||||
# 处理最后一组消息
|
||||
if current_group:
|
||||
_combine_current_group(current_group)
|
||||
|
||||
return grouped_messages
|
||||
|
||||
def process_by_msgtype(self, chat_message: ChatMessage):
|
||||
if chat_message.type_name == "文本":
|
||||
self.process_text(chat_message)
|
||||
# elif chat_message.type_name == "图片":
|
||||
# self.process_image(chat_message)
|
||||
|
||||
def load_csv(self, file_path) -> List[ChatMessage]:
|
||||
"""
|
||||
做整体第一次预处理,过滤不符合条件的行
|
||||
"""
|
||||
df = pd.read_csv(file_path, encoding="utf-8", dtype={"msg": str})
|
||||
|
||||
blocked_words = json.load(open("./dataset/blocked_words.json", encoding="utf-8"))["blocked_words"]
|
||||
|
||||
df = df[~df["type_name"].isin(values=skip_type_list)]
|
||||
|
||||
# 如果type_name为文本 并且msg 包含 手机号、身份证号、邮箱、网址则删除这行
|
||||
for i in df.index:
|
||||
if df.loc[i, "type_name"] == "文本":
|
||||
msg_str = str(df.loc[i, "msg"])
|
||||
if (
|
||||
re.search(r"1\d{10}", msg_str)
|
||||
or re.search(r"\d{18}", msg_str)
|
||||
or re.search(r"\w+@\w+", msg_str)
|
||||
or "http" in msg_str
|
||||
or r"\\xa0" in msg_str
|
||||
or r"\\u" in msg_str
|
||||
):
|
||||
df = df.drop(index=i)
|
||||
continue
|
||||
for blocked_word in blocked_words:
|
||||
if blocked_word in msg_str:
|
||||
df = df.drop(index=i)
|
||||
break
|
||||
else:
|
||||
df.loc[i, "msg"] = ""
|
||||
|
||||
df = df.dropna(how="all")
|
||||
# 时间格式 2021-07-07 10:27:23
|
||||
# 遍历行 相同is_sender的行合并msg()遇到不同is_sender就重新开始
|
||||
df["CreateTime"] = pd.to_datetime(df["CreateTime"])
|
||||
|
||||
return [ChatMessage(*row) for row in df.values]
|
||||
|
||||
def process_text(self, chat_message: ChatMessage):
|
||||
pass
|
||||
|
||||
def save_result(self, qa_res: List[Dict]):
|
||||
# 保存结果
|
||||
with open(
|
||||
"./dataset/res_csv/sft/sft-my.json",
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
json.dump(qa_res, f, ensure_ascii=False)
|
||||
logger.success(f"聊天记录处理成功,共{len(qa_res)}条,保存到 {f.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
processor = DataProcessor()
|
||||
processor.main()
|
||||
@@ -0,0 +1,60 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
from .models import ChatMessage
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationStrategy(ABC):
|
||||
"""对话策略的抽象基类"""
|
||||
|
||||
is_single_chat: bool
|
||||
|
||||
@abstractmethod
|
||||
def is_same_conversation(
|
||||
self, history_msg: List[ChatMessage], current_msg: ChatMessage
|
||||
) -> bool:
|
||||
"""判断两条消息是否属于同一个对话"""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimeWindowStrategy(ConversationStrategy):
|
||||
"""基于时间窗口的判断策略"""
|
||||
|
||||
time_window: int # 时间窗口(分钟)
|
||||
|
||||
def is_same_conversation(
|
||||
self, history_msg: List[ChatMessage], current_msg: ChatMessage
|
||||
) -> bool:
|
||||
time_diff = abs(
|
||||
(current_msg.CreateTime - history_msg[-1].CreateTime)
|
||||
).total_seconds()
|
||||
return time_diff <= self.time_window
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMStrategy(ConversationStrategy):
|
||||
"""基于大模型判断策略"""
|
||||
|
||||
def is_same_conversation(
|
||||
self, history_msg: List[ChatMessage], current_msg: ChatMessage
|
||||
) -> bool:
|
||||
# 修复user_id错误,使用talker字段代替user_id
|
||||
return current_msg.talker == history_msg[-1].talker if history_msg else False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompositeStrategy(ConversationStrategy):
|
||||
"""组合多个策略的复合策略"""
|
||||
|
||||
strategies: List[ConversationStrategy]
|
||||
require_all: bool = True # True表示所有策略都满足,False表示任一策略满足即可
|
||||
|
||||
def is_same_conversation(
|
||||
self, history_msg: List[ChatMessage], current_msg: ChatMessage
|
||||
) -> bool:
|
||||
results = [
|
||||
s.is_same_conversation(history_msg, current_msg) for s in self.strategies
|
||||
]
|
||||
return all(results) if self.require_all else any(results)
|
||||
@@ -0,0 +1,49 @@
|
||||
from llamafactory.chat import ChatModel
|
||||
from llamafactory.extras.misc import torch_gc
|
||||
|
||||
|
||||
try:
|
||||
import platform
|
||||
|
||||
if platform.system() != "Windows":
|
||||
import readline # noqa: F401
|
||||
except ImportError:
|
||||
print("Install `readline` for a better experience.")
|
||||
|
||||
|
||||
def main():
|
||||
chat_model = ChatModel()
|
||||
messages = []
|
||||
print("Welcome to the CLI application, use `clear` to remove the history, use `exit` to exit the application.")
|
||||
|
||||
while True:
|
||||
try:
|
||||
query = input("\nUser: ")
|
||||
except UnicodeDecodeError:
|
||||
print("Detected decoding error at the inputs, please set the terminal encoding to utf-8.")
|
||||
continue
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
if query.strip() == "exit":
|
||||
break
|
||||
|
||||
if query.strip() == "clear":
|
||||
messages = []
|
||||
torch_gc()
|
||||
print("History has been removed.")
|
||||
continue
|
||||
|
||||
messages.append({"role": "user", "content": query})
|
||||
print("Assistant: ", end="", flush=True)
|
||||
|
||||
response = ""
|
||||
for new_text in chat_model.stream_chat(messages):
|
||||
print(new_text, end="", flush=True)
|
||||
response += new_text
|
||||
print()
|
||||
messages.append({"role": "assistant", "content": response})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
from llamafactory.eval.evaluator import Evaluator
|
||||
|
||||
|
||||
def main():
|
||||
evaluator = Evaluator()
|
||||
evaluator.eval()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
import openai
|
||||
|
||||
from tqdm import tqdm
|
||||
from typing import List, Dict
|
||||
|
||||
from weclone.utils.config import load_config
|
||||
|
||||
config = load_config("web_demo")
|
||||
|
||||
config = {
|
||||
"default_prompt": config["default_system"],
|
||||
"model": "gpt-3.5-turbo",
|
||||
"history_len": 15,
|
||||
}
|
||||
|
||||
config = type("Config", (object,), config)()
|
||||
|
||||
openai.api_key = """sk-test"""
|
||||
openai.api_base = "http://127.0.0.1:8005/v1"
|
||||
|
||||
|
||||
def handler_text(content: str, history: List[Dict[str, str]], config):
|
||||
messages = [{"role": "system", "content": f"{config.default_prompt}"}]
|
||||
for item in history:
|
||||
messages.append(item)
|
||||
messages.append({"role": "user", "content": content})
|
||||
history.append({"role": "user", "content": content})
|
||||
try:
|
||||
response = openai.ChatCompletion.create(model=config.model, messages=messages, max_tokens=50)
|
||||
except openai.APIError as e:
|
||||
history.pop()
|
||||
return "AI接口出错,请重试\n" + str(e)
|
||||
|
||||
resp = str(response.choices[0].message.content) # type: ignore
|
||||
resp = resp.replace("\n ", "")
|
||||
history.append({"role": "assistant", "content": resp})
|
||||
return resp
|
||||
|
||||
|
||||
def main():
|
||||
test_list = json.loads(open("dataset/test_data.json", "r", encoding="utf-8").read())["questions"]
|
||||
res = []
|
||||
for questions in tqdm(test_list, desc=" Testing..."):
|
||||
history = []
|
||||
for q in questions:
|
||||
handler_text(q, history=history, config=config)
|
||||
res.append(history)
|
||||
|
||||
res_file = open("test_result-my.txt", "w")
|
||||
for r in res:
|
||||
for i in r:
|
||||
res_file.write(i["content"] + "\n")
|
||||
res_file.write("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
from llamafactory.webui.interface import create_web_demo
|
||||
from weclone.utils.config import load_config
|
||||
|
||||
config = load_config("web_demo")
|
||||
|
||||
|
||||
def main():
|
||||
demo = create_web_demo()
|
||||
demo.queue()
|
||||
demo.launch(server_name="0.0.0.0", share=True, inbrowser=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,18 @@
|
||||
import os
|
||||
import uvicorn
|
||||
from llamafactory.chat import ChatModel
|
||||
from llamafactory.api.app import create_app
|
||||
from weclone.utils.config import load_config
|
||||
|
||||
config = load_config("api_service")
|
||||
|
||||
|
||||
def main():
|
||||
chat_model = ChatModel(config)
|
||||
app = create_app(chat_model)
|
||||
print("Visit http://localhost:{}/docs for API document.".format(os.environ.get("API_PORT", 8005)))
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("API_PORT", 8005)), workers=1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
from llamafactory.train.tuner import export_model
|
||||
|
||||
|
||||
def main():
|
||||
export_model()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
from llamafactory.train.tuner import run_exp
|
||||
from weclone.utils.config import load_config
|
||||
|
||||
config = load_config("train_pt")
|
||||
run_exp(config)
|
||||
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
import sys
|
||||
from llamafactory.train.tuner import run_exp
|
||||
from llamafactory.extras.misc import get_current_device
|
||||
from weclone.utils.config import load_config
|
||||
from weclone.utils.log import logger
|
||||
|
||||
config = load_config(arg_type="train_sft")
|
||||
|
||||
device = get_current_device()
|
||||
if device == "cpu":
|
||||
logger.warning("请注意你正在使用CPU训练,非Mac设备可能会出现问题")
|
||||
|
||||
sft_json_path = os.path.join(config["dataset_dir"], "sft-my.json")
|
||||
if not os.path.exists(sft_json_path):
|
||||
logger.error(f"错误:文件 '{sft_json_path}' 不存在,请确保数据处理步骤已正确生成该文件。")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_exp(config)
|
||||
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
import commentjson
|
||||
import sys
|
||||
|
||||
from .log import logger
|
||||
from .tools import dict_to_argv
|
||||
|
||||
|
||||
def load_config(arg_type: str):
|
||||
with open("./settings.json", "r", encoding="utf-8") as f:
|
||||
s_config: dict = commentjson.load(f)
|
||||
if arg_type == "web_demo" or arg_type == "api_service":
|
||||
# infer_args和common_args求并集
|
||||
config = {**s_config["infer_args"], **s_config["common_args"]}
|
||||
elif arg_type == "train_pt":
|
||||
config = {**s_config["train_pt_args"], **s_config["common_args"]}
|
||||
elif arg_type == "train_sft":
|
||||
config = {**s_config["train_sft_args"], **s_config["common_args"]}
|
||||
if s_config["make_dataset_args"]["prompt_with_history"]:
|
||||
dataset_info_path = os.path.join(config["dataset_dir"], "dataset_info.json")
|
||||
dataset_info = commentjson.load(open(dataset_info_path, "r", encoding="utf-8"))[config["dataset"]]
|
||||
if dataset_info["columns"].get("history") is None:
|
||||
logger.warning(f"{config['dataset']}数据集不包history字段,尝试使用wechat-sft-with-history数据集")
|
||||
s_config["make_dataset_args"]["dataset"] = "wechat-sft-with-history"
|
||||
|
||||
elif arg_type == "make_dataset":
|
||||
config = {**s_config["make_dataset_args"], **s_config["common_args"]}
|
||||
else:
|
||||
raise ValueError("暂不支持的参数类型")
|
||||
|
||||
if "train" in arg_type:
|
||||
config["output_dir"] = config["adapter_name_or_path"]
|
||||
config.pop("adapter_name_or_path")
|
||||
config["do_train"] = True
|
||||
|
||||
sys.argv += dict_to_argv(config)
|
||||
|
||||
return config
|
||||
@@ -0,0 +1,10 @@
|
||||
from loguru import logger
|
||||
import sys
|
||||
|
||||
logger.remove()
|
||||
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
format="<green><b>[WeClone]</b></green> <level>{level.name[0]}</level> | <level>{time:HH:mm:ss}</level> | <level>{message}</level>",
|
||||
colorize=True,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
def dict_to_argv(d):
|
||||
argv = []
|
||||
for k, v in d.items():
|
||||
argv.append("--" + k)
|
||||
if v is not None:
|
||||
argv.append(str(v))
|
||||
return argv
|
||||
|
||||
|
||||
Reference in New Issue
Block a user