diff --git a/README.md b/README.md index f52b0f0..ff18ece 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ## 核心功能✨ - 💬 使用微信聊天记录微调LLM - 🎙️ 使用微信语音消息➕0.5B大模型实现高质量声音克隆 👉[WeClone-audio](https://github.com/xming521/WeClone/tree/master/WeClone-audio) -- 🔗 绑定到微信机器人,实现自己的数字分身 +- 🔗 绑定到微信、QQ、Telegram、企微、飞书机器人,实现自己的数字分身 ## 特性与说明📋 @@ -197,4 +197,15 @@ python ./src/wechat_bot/main.py

+## ⭐ Star History +> [!TIP] +> 如果本项目对您有帮助,或者您关注本项目的未来发展,请给项目 Star,谢谢 + +
+ +[![Star History Chart](https://api.star-history.com/svg?repos=xming521/WeClone&type=Date)](https://www.star-history.com/#xming521/WeClone&Date) + +
+ +
克隆我们,保留那灵魂的芬芳
diff --git a/WeClone-audio/src/server未完工/.env.example b/WeClone-audio/src/server未完工/.env.example new file mode 100644 index 0000000..627d413 --- /dev/null +++ b/WeClone-audio/src/server未完工/.env.example @@ -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 \ No newline at end of file diff --git a/WeClone-audio/src/server未完工/handle_text.py b/WeClone-audio/src/server未完工/handle_text.py new file mode 100644 index 0000000..c4fba21 --- /dev/null +++ b/WeClone-audio/src/server未完工/handle_text.py @@ -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 diff --git a/WeClone-audio/src/server未完工/requirements.txt b/WeClone-audio/src/server未完工/requirements.txt new file mode 100644 index 0000000..4019693 --- /dev/null +++ b/WeClone-audio/src/server未完工/requirements.txt @@ -0,0 +1,5 @@ +flask +gevent +python-dotenv +edge-tts +emoji \ No newline at end of file diff --git a/WeClone-audio/src/server未完工/server.py b/WeClone-audio/src/server未完工/server.py new file mode 100644 index 0000000..486ffd3 --- /dev/null +++ b/WeClone-audio/src/server未完工/server.py @@ -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/', 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() diff --git a/WeClone-audio/src/server未完工/tts_handler.py b/WeClone-audio/src/server未完工/tts_handler.py new file mode 100644 index 0000000..f243acc --- /dev/null +++ b/WeClone-audio/src/server未完工/tts_handler.py @@ -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}%" diff --git a/WeClone-audio/src/server未完工/utils.py b/WeClone-audio/src/server未完工/utils.py new file mode 100644 index 0000000..7fe9845 --- /dev/null +++ b/WeClone-audio/src/server未完工/utils.py @@ -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" +} diff --git a/WeClone-audio/src/text_to_speech.py b/WeClone-audio/src/text_to_speech.py deleted file mode 100644 index 2bb468b..0000000 --- a/WeClone-audio/src/text_to_speech.py +++ /dev/null @@ -1,131 +0,0 @@ -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])