diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..9acf47f --- /dev/null +++ b/README.MD @@ -0,0 +1,18 @@ +# ComfyUI_MSSpeech_TTS +ComfyUI下使用的文本转语音插件。使用Microsoft speech TTS 接口将文本内容转为MP3格式的语音文件。 + +![image](image/ComfyUI_MSSpeech_TTS..png) + +### 插件调整项: +**voice:** 语音种类。 +**rate:** 语音速度。默认是0,调整范围从-200到200。数字越大速度越快。 +**filename_prefix:** 文件名前缀。 + +### 输出: +MP3 File,字符串类型,其内容是语音文件地址。 + +## 安装方法: +- 解压zip文件,将"ComfyUI_MSSpeech_TTS"文件夹复制到 ComfyUI\custom_nodes\ +- 安装依赖包,在资源管理器ComfyUI\custom_nodes\ComfyUI_MSSpeech_TTS\ 这个位置打开cmd窗口,输入以下命令: +```..\..\..\python_embeded\python.exe -s -m pip install -r requirements.txt``` +- 重新打开ComfyUI diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b2913a9 --- /dev/null +++ b/__init__.py @@ -0,0 +1,24 @@ +import importlib.util +import glob +import os +import sys +from .msspeech import init, get_ext_dir + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +if init(): + py = get_ext_dir("py") + files = glob.glob("*.py", root_dir=py, recursive=False) + for file in files: + name = os.path.splitext(file)[0] + spec = importlib.util.spec_from_file_location(name, os.path.join(py, file)) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + if hasattr(module, "NODE_CLASS_MAPPINGS") and getattr(module, "NODE_CLASS_MAPPINGS") is not None: + NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS) + if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None: + NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS) + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/image/ComfyUI_MSSpeech_TTS.png b/image/ComfyUI_MSSpeech_TTS.png new file mode 100644 index 0000000..627a5d5 Binary files /dev/null and b/image/ComfyUI_MSSpeech_TTS.png differ diff --git a/msspeech.json b/msspeech.json new file mode 100644 index 0000000..71b02b1 --- /dev/null +++ b/msspeech.json @@ -0,0 +1,4 @@ +{ + "name": "MSSpeech_TTS ", + "logging": false +} \ No newline at end of file diff --git a/msspeech.py b/msspeech.py new file mode 100644 index 0000000..d51664c --- /dev/null +++ b/msspeech.py @@ -0,0 +1,269 @@ +import asyncio +import os +import json +import shutil +import inspect +import aiohttp +from server import PromptServer +from tqdm import tqdm + +config = None + + +def is_logging_enabled(): + config = get_extension_config() + if "logging" not in config: + return False + return config["logging"] + + +def log(message, type=None, always=False, name=None): + if not always and not is_logging_enabled(): + return + + if type is not None: + message = f"[{type}] {message}" + + if name is None: + name = get_extension_config()["name"] + + print(f"(MSSpeech:{name}) {message}") + + +def get_ext_dir(subpath=None, mkdir=False): + dir = os.path.dirname(__file__) + if subpath is not None: + dir = os.path.join(dir, subpath) + + dir = os.path.abspath(dir) + + if mkdir and not os.path.exists(dir): + os.makedirs(dir) + return dir + + +def get_comfy_dir(subpath=None, mkdir=False): + dir = os.path.dirname(inspect.getfile(PromptServer)) + if subpath is not None: + dir = os.path.join(dir, subpath) + + dir = os.path.abspath(dir) + + if mkdir and not os.path.exists(dir): + os.makedirs(dir) + return dir + + +def get_web_ext_dir(): + config = get_extension_config() + name = config["name"] + dir = get_comfy_dir("web/extensions/msspeech") + if not os.path.exists(dir): + os.makedirs(dir) + dir = os.path.join(dir, name) + return dir + + +def get_extension_config(reload=False): + global config + if reload == False and config is not None: + return config + + config_path = get_ext_dir("msspeech.json") + if not os.path.exists(config_path): + log("Missing json, this extension may not work correctly. Please reinstall the extension.", + type="ERROR", always=True, name="???") + print(f"Extension path: {get_ext_dir()}") + return {"name": "Unknown", "version": -1} + with open(config_path, "r") as f: + config = json.loads(f.read()) + return config + + +def link_js(src, dst): + src = os.path.abspath(src) + dst = os.path.abspath(dst) + if os.name == "nt": + try: + import _winapi + _winapi.CreateJunction(src, dst) + return True + except: + pass + try: + os.symlink(src, dst) + return True + except: + import logging + logging.exception('') + return False + +def is_junction(path): + if os.name != "nt": + return False + try: + return bool(os.readlink(path)) + except OSError: + return False + +def install_js(): + src_dir = get_ext_dir("js") + if not os.path.exists(src_dir): + log("No JS") + return + + dst_dir = get_web_ext_dir() + + if os.path.exists(dst_dir): + if os.path.islink(dst_dir) or is_junction(dst_dir): + log("JS already linked") + return + elif link_js(src_dir, dst_dir): + log("JS linked") + return + + log("Copying JS files") + shutil.copytree(src_dir, dst_dir, dirs_exist_ok=True) + + +def init(check_imports=None): + log("Init") + + if check_imports is not None: + import importlib.util + for imp in check_imports: + spec = importlib.util.find_spec(imp) + if spec is None: + log(f"{imp} is required, please check requirements are installed.", + type="ERROR", always=True) + return False + + install_js() + return True + + +def get_async_loop(): + loop = None + try: + loop = asyncio.get_event_loop() + except: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + +def get_http_session(): + loop = get_async_loop() + return aiohttp.ClientSession(loop=loop) + + +async def download(url, stream, update_callback=None, session=None): + close_session = False + if session is None: + close_session = True + session = get_http_session() + try: + async with session.get(url) as response: + size = int(response.headers.get('content-length', 0)) or None + + with tqdm( + unit='B', unit_scale=True, miniters=1, desc=url.split('/')[-1], total=size, + ) as progressbar: + perc = 0 + async for chunk in response.content.iter_chunked(2048): + stream.write(chunk) + progressbar.update(len(chunk)) + if update_callback is not None and progressbar.total is not None and progressbar.total != 0: + last = perc + perc = round(progressbar.n / progressbar.total, 2) + if perc != last: + last = perc + await update_callback(perc) + finally: + if close_session and session is not None: + await session.close() + + +async def download_to_file(url, destination, update_callback=None, is_ext_subpath=True, session=None): + if is_ext_subpath: + destination = get_ext_dir(destination) + with open(destination, mode='wb') as f: + download(url, f, update_callback, session) + + +def wait_for_async(async_fn, loop=None): + res = [] + + async def run_async(): + r = await async_fn() + res.append(r) + + if loop is None: + try: + loop = asyncio.get_event_loop() + except: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + loop.run_until_complete(run_async()) + + return res[0] + + +def update_node_status(client_id, node, text, progress=None): + if client_id is None: + client_id = PromptServer.instance.client_id + + if client_id is None: + return + + PromptServer.instance.send_sync("azure/update_status", { + "node": node, + "progress": progress, + "text": text + }, client_id) + + +async def update_node_status_async(client_id, node, text, progress=None): + if client_id is None: + client_id = PromptServer.instance.client_id + + if client_id is None: + return + + await PromptServer.instance.send("azure/update_status", { + "node": node, + "progress": progress, + "text": text + }, client_id) + + +def get_config_value(key, default=None, throw=False): + split = key.split(".") + obj = get_extension_config() + for s in split: + if s in split: + obj = obj[s] + else: + if throw: + raise KeyError("Configuration key missing: " + key) + else: + return default + return obj + + +def is_inside_dir(root_dir, check_path): + root_dir = os.path.abspath(root_dir) + if not os.path.isabs(check_path): + check_path = os.path.abspath(os.path.join(root_dir, check_path)) + return os.path.commonpath([check_path, root_dir]) == root_dir + + +def get_child_dir(root_dir, child_path, throw_if_outside=True): + child_path = os.path.abspath(os.path.join(root_dir, child_path)) + if is_inside_dir(root_dir, child_path): + return child_path + if throw_if_outside: + raise NotADirectoryError( + "Saving outside the target folder is not allowed.") + return None diff --git a/py/msspeechTTS.py b/py/msspeechTTS.py new file mode 100644 index 0000000..0f14c76 --- /dev/null +++ b/py/msspeechTTS.py @@ -0,0 +1,64 @@ +import asyncio +import datetime + +import edge_tts +import numpy as np +import folder_paths +import os + +async def gen_tts(_text,_voice,_rate,filename): + tts = edge_tts.Communicate(text = _text, voice = _voice, rate = _rate) + await tts.save(filename) + +class Text2AutioEdgeTts: + def __init__(self): + self.output_dir = os.path.join(folder_paths.get_output_directory(), 'audio') + if not os.path.exists(self.output_dir): + os.makedirs(self.output_dir) + + @classmethod + def INPUT_TYPES(cls): + VOICES=['zh-CN-XiaoxiaoNeural','zh-CN-XiaoyiNeural','zh-CN-YunjianNeural','zh-CN-YunxiNeural','zh-CN-YunxiaNeural', +'zh-CN-YunyangNeural','zh-CN-liaoning-XiaobeiNeural','zh-CN-shaanxi-XiaoniNeural','zh-HK-HiuGaaiNeural', +'zh-HK-HiuMaanNeural','zh-HK-WanLungNeural','zh-TW-HsiaoChenNeural','zh-TW-HsiaoYuNeural','zh-TW-YunJheNeural'] + return { + "required": { + "voice": (VOICES, ), + "rate": ("INT", {"default": 0, "min": -200, "max": 200}), + "filename_prefix": ("STRING", {"default": "comfyUI"}), + "text": ("STRING", {"multiline": True}) + } + } + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("MP3 file: String",) + FUNCTION = "text_2_autio" + OUTPUT_NODE = True + + CATEGORY = "MicorsoftSpeech_TTS" + + def text_2_autio(self,voice,filename_prefix,text,rate): + full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir) + _datetime = datetime.datetime.now().strftime("%Y_%m_%d") + _datetime = _datetime + "_" + datetime.datetime.now().strftime("%H_%M_%S") + file = f"{filename}_{_datetime}_{counter:02}.mp3" + autio_path=os.path.join(full_output_folder, file) + _rate = str(rate) + "%" if rate < 0 else "+" + str(rate) + "%" + print(f"MicrosoftSpeech TTS: Generating voice files, voice=鈥榹voice}鈥�, rate={rate}, audiofile_path='{autio_path}, 'text='{text}'") + # asyncio.run(edge_tts_text_2_aution(voice,text,autio_path)) + asyncio.run(gen_tts(text,voice,_rate,autio_path)) + + return {"ui": {"text": "Audio file saved to锛�"+os.path.join(full_output_folder, file), + 'autios':[{'filename':file,'type':'output','subfolder':'autio'}]}, "result": (autio_path, )} + + +async def edge_tts_text_2_aution(VOICE,TEXT,OUTPUT_FILE) -> None: + communicate = edge_tts.Communicate(TEXT, VOICE) + await communicate.save(OUTPUT_FILE) + +NODE_CLASS_MAPPINGS = { + "MicorsoftSpeech_TTS": Text2AutioEdgeTts +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "MicorsoftSpeech_TTS": "MicorsoftSpeech_TTS" +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..753a1af --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +numpy==1.23.4 +edge_tts +asyncio +