├── .devcontainer ├── Dockerfile ├── devcontainer.json ├── docker-compose.yml └── postCreateCommand.sh ├── .env_sample ├── .github ├── ISSUE_TEMPLATE │ ├── バグ報告.md │ └── 機能追加リクエスト.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── requirements.txt ├── scripts ├── install-bind.py └── util.py └── src ├── SynthesisRunner.py ├── bot.py ├── commands.py ├── main.py ├── preprocessor.py ├── romanization ├── hepburn.json └── japanese.json ├── tests ├── __init__.py └── test_preprocessor.py ├── textToSpeechQueue.py └── voicevox.py /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | 3 | ARG USERNAME=vscode 4 | ARG USER_UID=1000 5 | ARG USER_GID=$USER_UID 6 | ARG INSTALL_ZSH=true 7 | 8 | # Create the user 9 | RUN groupadd --gid $USER_GID $USERNAME \ 10 | && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \ 11 | && apt-get update \ 12 | && apt-get install -y sudo \ 13 | && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ 14 | && chmod 0440 /etc/sudoers.d/$USERNAME 15 | 16 | RUN apt-get install -y ffmpeg libnacl-dev python3-dev 17 | RUN python -m pip install --upgrade pip 18 | 19 | # devcontainer common script 20 | RUN git clone --depth 1 https://github.com/microsoft/vscode-dev-containers.git 21 | RUN bash vscode-dev-containers/script-library/common-debian.sh "${INSTALL_ZSH}" "${USERNAME}" "${USER_UID}" "${USER_GID}" \ 22 | && apt-get clean -y && rm -rf /var/lib/apt/lists/* /tmp/library-scripts 23 | 24 | USER $USERNAME -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "murmur-devcontainer", 3 | "dockerComposeFile": [ 4 | "docker-compose.yml" 5 | ], 6 | "service": "bot", 7 | "workspaceFolder": "/workspace", 8 | "postCreateCommand": "/bin/sh .devcontainer/postCreateCommand.sh", 9 | "features": { 10 | "ghcr.io/devcontainers/features/docker-in-docker:2": {} 11 | }, 12 | "customizations": { 13 | "vscode": { 14 | "extensions": [ 15 | "ms-python.mypy-type-checker", 16 | "ms-python.black-formatter", 17 | "ms-azuretools.vscode-docker" 18 | ], 19 | "settings": { 20 | "[python]": { 21 | "editor.defaultFormatter": "ms-python.black-formatter", 22 | "editor.formatOnSave": true 23 | }, 24 | "python.analysis.typeCheckingMode": "basic" 25 | } 26 | } 27 | }, 28 | "remoteUser": "vscode", 29 | "remoteEnv": { 30 | "LD_LIBRARY_PATH":"/workspace/voicevox_core/" 31 | } 32 | } -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | # Update this to the name of the service you want to work with in your docker-compose.yml file 4 | bot: 5 | container_name: 'bot' 6 | build: 7 | context: . 8 | env_file: 9 | - ../.env 10 | restart: always 11 | tty: true 12 | working_dir: /workspace 13 | volumes: 14 | - type: bind 15 | source: ../ 16 | target: /workspace 17 | -------------------------------------------------------------------------------- /.devcontainer/postCreateCommand.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | VOICEVOX_VERSION=0.14.4 3 | DEVICE_TYPE=cpu 4 | 5 | # voicevox coreのダウンロード 6 | # すでに存在する場合はスキップする 7 | VERSION_FILE="./voicevox_core/VERSION" 8 | VERSION_FILE_CONTENT=$(cat "$VERSION_FILE") 9 | if [ "$VERSION_FILE_CONTENT" != "$VOICEVOX_VERSION" ]; then 10 | rm -rf ./voicevox_core 11 | curl -L https://github.com/VOICEVOX/voicevox_core/releases/download/${VOICEVOX_VERSION}/download.sh \ 12 | | bash /dev/stdin --device ${DEVICE_TYPE} --version ${VOICEVOX_VERSION} --os linux --output ./voicevox_core 13 | ldconfig 14 | else 15 | echo '[skip] voicevox_core download' 16 | fi 17 | 18 | pip install --upgrade pip 19 | pip install -r requirements.txt 20 | 21 | python scripts/install-bind.py ${DEVICE_TYPE} -------------------------------------------------------------------------------- /.env_sample: -------------------------------------------------------------------------------- 1 | BOT_TOKEN=YOUR_DISCORD_TOKEN 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/バグ報告.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: バグ報告 3 | about: プロジェクトのバグを報告するためのテンプレートです。 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### バグの概要 11 | バグの内容を簡潔に説明してください。 12 | 13 | ### 再現手順 14 | バグを再現するための具体的なステップを説明してください。 15 | 16 | 1. ステップ1 17 | 2. ステップ2 18 | 3. ... 19 | 20 | ### 期待される動作 21 | バグが存在しない場合に期待される動作を説明してください。 22 | 23 | ### 実際の動作 24 | バグが発生した際の実際の動作を説明してください。 25 | 26 | ### 環境情報 27 | バグが発生した環境に関する情報を提供してください。 28 | 29 | - OS: 30 | - ブラウザ(もしくはアプリ): 31 | - バージョン情報: 32 | - その他の関連情報: 33 | 34 | ### スクリーンショットやログ(任意) 35 | バグの説明や再現に役立つスクリーンショット、エラーログ、またはその他の関連情報があれば添付してください。 36 | 37 | ### 追加のコメント(任意) 38 | バグに関する追加の情報やコメントがあればこちらに追記してください。 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/機能追加リクエスト.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 機能追加リクエスト 3 | about: 新しい機能の提案や追加リクエストに使用するテンプレートです。 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### 概要 11 | このIssueは、新しい機能の提案や追加リクエストを行うためのものです。提案したい機能について簡潔に説明してください。 12 | 13 | ### 問題の背景 14 | 機能追加が必要とされる具体的な問題や背景情報を提供してください。なぜこの機能が必要なのか、どのような問題を解決するのかを説明してください。 15 | 16 | ### 提案 17 | 新しい機能に関する提案やアイデアを詳細に説明してください。提案内容には、機能の詳細な説明、動作仕様、UI/UXデザインなどが含まれるべきです。 18 | 19 | ### タスク 20 | この機能追加に関連する具体的なタスクやステップをリストアップしてください。必要に応じて、作業予定の期限や責任者も指定してください。 21 | 22 | - [ ] タスク1 23 | - [ ] タスク2 24 | 25 | ### 追加情報(任意) 26 | 追加の情報や参考資料があれば、こちらにリンクやテキストとして追記してください。 27 | 28 | ### 関連するIssue(任意) 29 | この機能追加リクエストに関連する既存のIssueがあれば、こちらにリンクを貼ってください。 30 | 31 | ### スクリーンショットや図表(任意) 32 | 機能の理解を助けるために、スクリーンショットや図表があれば添付してください。 33 | 34 | このテンプレートを使用して新しい機能追加に関するIssueを作成する際に、提案内容や問題の背景、タスクなどを詳細に記述して、コミュニケーションとプロジェクト管理を効果的に行えるようにしてください。必要に応じてカスタマイズしてプロジェクトに適した形に調整してください。 35 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 概要 2 | 3 | このセクションでは、このPRの目的と概要を簡潔に説明してください。 4 | 5 | ## 変更点 6 | 7 | このセクションでは、具体的な変更点や修正箇所を箇条書きでリストアップしてください。 8 | 9 | - 変更点1 10 | - 変更点2 11 | - 変更点3 12 | 13 | ## 影響範囲 14 | 15 | このセクションでは、このPRが影響を及ぼす範囲や他の機能への影響を説明してください。 16 | 17 | ## テスト 18 | 19 | このセクションでは、このPRに関連するテストケースやテスト方法を記載してください。 20 | 21 | - テストケース1 22 | - テストケース2 23 | - テストケース3 24 | 25 | ## 関連Issue 26 | 27 | このセクションでは、このPRが関連するIssueやタスクをリンクしてください。以下のように記述します。 28 | 29 | - 関連Issue: #123 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | src/audio_tmp/ 162 | 163 | # voicevox core 164 | voicevox_core/ 165 | voicevox_core-*.whl 166 | 167 | # voicevox downloader 168 | download.sh 169 | voicevox_core-*.whl -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-python.black-formatter", 4 | "ms-python.mypy-type-checker" 5 | ] 6 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "ms-python.black-formatter", 4 | "editor.formatOnSave": true 5 | }, 6 | "editor.indentSize": "tabSize", 7 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | 3 | ARG VOICEVOX_VERSION=0.14.4 4 | ARG DEVICE_TYPE=cpu 5 | 6 | ENV LD_LIBRARY_PATH=/bot/voicevox_core/:$LD_LIBRARY_PATH 7 | 8 | RUN apt-get update 9 | RUN apt-get install -y ffmpeg libnacl-dev python3-dev 10 | 11 | WORKDIR /bot 12 | 13 | # voicevox coreのダウンロード 14 | RUN curl -L https://github.com/VOICEVOX/voicevox_core/releases/download/${VOICEVOX_VERSION}/download.sh \ 15 | | bash /dev/stdin --device ${DEVICE_TYPE} --version ${VOICEVOX_VERSION} --os linux --output ./voicevox_core 16 | RUN ldconfig 17 | 18 | COPY requirements.txt /bot 19 | RUN pip install --upgrade pip 20 | RUN pip install -r requirements.txt 21 | 22 | # Python用のラッパーをダウンロードしてインストールする 23 | COPY /scripts /bot/scripts 24 | RUN python scripts/install-bind.py ${DEVICE_TYPE} 25 | 26 | COPY /src /bot/src 27 | CMD ["python", "src/main.py"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 takashiTkg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # murmur 2 | _囁き声のように優しい読み上げbot_ 3 | discordのボイスチャットをvoicevoxで読み上げるためのbotです。 4 | ## development 5 | 6 | ### create .env file 7 | ```bash 8 | touch .env 9 | ``` 10 | `.env_sample`を参考にDiscord botのTokenを書いてください。 11 | ### use docker-compose 12 | #### build 13 | ```bash 14 | docker compose build 15 | ``` 16 | #### run 17 | ```bash 18 | docker compose up 19 | ``` 20 | 21 | ### VSCode devcontainer 22 | VSCodeのDevContainer拡張機能を使用して開発環境を共有しています。 23 | `.devcontainer`フォルダに設定ファイルがあります。 24 | devcontainer内では以下の方法でアプリケーションを実行できます。 25 | #### python 26 | ```bash 27 | python src/main.py 28 | ``` 29 | #### docker compose (docker in docker) 30 | #### build 31 | ```bash 32 | docker compose build 33 | ``` 34 | #### run 35 | ```bash 36 | docker compose up 37 | ``` 38 | 39 | ## commands 40 | `/join [voice_channel_name]` 41 | ボイスチャンネルに接続します。 42 | 引数が無い場合はコマンドを入力したチャンネルに接続します。 43 | `/bye` 44 | ボイスチャンネルから退出します。 45 | 46 | ## クレジット 47 | VOICEVOX 48 | [VOICEVOX 公式ページ](https://voicevox.hiroshiba.jp/) 49 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | bot: 4 | container_name: discord-bot 5 | restart: always 6 | env_file: 7 | - .env 8 | build: . 9 | tty: true 10 | volumes: 11 | - ./src:/bot/src 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.5 2 | aiosignal==1.3.1 3 | async-timeout==4.0.3 4 | attrs==23.1.0 5 | certifi==2023.7.22 6 | cffi==1.15.1 7 | charset-normalizer==3.2.0 8 | discord.py==2.3.2 9 | frozenlist==1.4.0 10 | idna==3.4 11 | multidict==6.0.4 12 | pycparser==2.21 13 | PyNaCl==1.5.0 14 | python-dotenv==1.0.0 15 | urllib3==2.0.4 16 | yarl==1.9.2 17 | mypy~=1.5.1 18 | requests~=2.31 19 | types-requests~=2.31 20 | psutil~=5.9.6 21 | types-psutil~=5.9.5.17 22 | pytest~=7.4.2 -------------------------------------------------------------------------------- /scripts/install-bind.py: -------------------------------------------------------------------------------- 1 | import os, sys, platform, subprocess 2 | from util import download 3 | 4 | 5 | def sdk_url(version: str, os: str, arch: str | None, device: str = "cpu"): 6 | if arch is not None: 7 | arch = f"_{arch}" 8 | 9 | return f"https://github.com/VOICEVOX/voicevox_core/releases/download/{version}/voicevox_core-{version}+{device}-cp38-abi3-{os}{arch}.whl" 10 | 11 | 12 | voicevox_version = ( 13 | os.environ["VOICEVOX_VERSION"] if "VOICEVOX_VERSION" in os.environ else "0.14.4" 14 | ) 15 | 16 | device_type = sys.argv[1] if len(sys.argv) >= 2 else "cpu" 17 | if not device_type in ("cpu", "cuda", "directml"): 18 | raise Exception(f"Invalid device type {device_type}.") 19 | 20 | os_name = platform.system() 21 | match os_name: 22 | case "Windows": 23 | os_name = "win" 24 | 25 | case "Linux": 26 | os_name = "linux" 27 | 28 | case "Darwin": 29 | os_name = "macosx" 30 | 31 | arch = platform.machine().lower() 32 | 33 | print( 34 | "Download voicevox core binding.\n\n" 35 | + f"version: {voicevox_version}\n" 36 | + f"os_name: {os_name}\n" 37 | + f"device_type: {device_type}\n", 38 | file=sys.stderr, 39 | ) 40 | 41 | # 得た情報からライブラリをダウンロードする 42 | match (device_type, os_name, arch): 43 | # GPU 44 | case ("cuda", "linux", "amd64" | "x86_64"): 45 | filename = download(sdk_url(voicevox_version, os_name, "x86_64", device_type)) 46 | 47 | case ("directml" | "cuda", "win", "amd64" | "x86_64"): 48 | filename = download(sdk_url(voicevox_version, os_name, "amd64", device_type)) 49 | 50 | # CPU 51 | case ("cpu", "win", "amd64" | "x86_64"): 52 | filename = download(sdk_url(voicevox_version, os_name, "amd64", device_type)) 53 | 54 | case ("cpu", "win", "x86" | "i386"): 55 | filename = download(sdk_url(voicevox_version, "win32", None, device_type)) 56 | 57 | case ("cpu", "macosx", "amd64" | "x86_64"): 58 | filename = download( 59 | sdk_url(voicevox_version, f"{os_name}_10_7", "amd64", device_type), 60 | ) 61 | 62 | case ("cpu", "macosx", "aarch64" | "arm64"): 63 | filename = download( 64 | sdk_url(voicevox_version, f"{os_name}_11_0", "arm64", device_type), 65 | ) 66 | 67 | case ("cpu", "linux", "amd64" | "x86_64"): 68 | filename = download(sdk_url(voicevox_version, os_name, "x86_64", device_type)) 69 | 70 | case ("cpu", "linux", "aarch64" | "arm64"): 71 | filename = download(sdk_url(voicevox_version, os_name, "aarch64", device_type)) 72 | 73 | case _ as t: 74 | raise Exception(f"Invalid library version {t}.") 75 | 76 | subprocess.run(["python", "-m", "pip", "install", filename]) 77 | -------------------------------------------------------------------------------- /scripts/util.py: -------------------------------------------------------------------------------- 1 | import requests, urllib.parse, os.path 2 | 3 | 4 | def download(url: str): 5 | res = requests.get(url) 6 | if not (res.status_code >= 200 and res.status_code < 300): 7 | raise Exception( 8 | f"Failed to download file from url.\nstatus_code: {res.status_code}.\nurl: {url}" 9 | ) 10 | 11 | filename = os.path.basename(urllib.parse.urlparse(url).path) 12 | 13 | with open(filename, "w+b") as f: 14 | f.write(res.content) 15 | 16 | return filename 17 | -------------------------------------------------------------------------------- /src/SynthesisRunner.py: -------------------------------------------------------------------------------- 1 | from voicevox_core import VoicevoxCore, AudioQuery 2 | import psutil 3 | 4 | from dataclasses import dataclass 5 | from typing import Callable, Coroutine 6 | from concurrent.futures import ThreadPoolExecutor 7 | import asyncio 8 | from asyncio import AbstractEventLoop, Task 9 | 10 | 11 | @dataclass 12 | class SynthesisTask: 13 | query: AudioQuery 14 | speaker: int 15 | callback: Callable[[bytes | None], None] 16 | 17 | 18 | # CPU使用率を見て、適度に休憩しながら音声の生成を行う 19 | class SynthesisRunner: 20 | tasks: list[SynthesisTask] = [] 21 | async_tasks: set[Task] = set() 22 | 23 | core: VoicevoxCore 24 | loop: AbstractEventLoop 25 | 26 | threshold: float 27 | thread_pool: ThreadPoolExecutor 28 | 29 | def __init__( 30 | self, 31 | core: VoicevoxCore, 32 | loop: AbstractEventLoop, 33 | threshold=65, 34 | thread_pool=ThreadPoolExecutor(max_workers=50), 35 | ): 36 | self.core = core 37 | self.threshold = threshold 38 | self.thread_pool = thread_pool 39 | self.loop = loop 40 | 41 | def synthesis( 42 | self, query: AudioQuery, spaker: int, callback: Callable[[bytes | None], None] 43 | ): 44 | self.tasks.append(SynthesisTask(query, spaker, callback)) 45 | 46 | def create_task(self, co: Coroutine): 47 | task = self.loop.create_task(co) 48 | self.async_tasks.add(task) 49 | task.add_done_callback(self.async_tasks.discard) 50 | 51 | return task 52 | 53 | def run(self): 54 | self.create_task(self._run()) 55 | 56 | async def _run(self): 57 | while True: 58 | if len(self.tasks): 59 | perc = psutil.cpu_percent() 60 | await asyncio.sleep(perc / self.threshold * 5) 61 | 62 | task = self.tasks.pop(0) 63 | 64 | def _fn(): 65 | task.callback(self.core.synthesis(task.query, task.speaker)) 66 | 67 | self.thread_pool.submit(_fn) 68 | 69 | else: 70 | await asyncio.sleep(0.1) 71 | -------------------------------------------------------------------------------- /src/bot.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | from preprocessor import preprocess_text 4 | from voicevox import Voicevox 5 | from textToSpeechQueue import Text2SpeechQueue, SpeakTask 6 | 7 | counter = 0 8 | 9 | description = """ 10 | voicevoxの読み上げbot 11 | """ 12 | 13 | intents = discord.Intents.default() 14 | intents.message_content = True 15 | 16 | bot = commands.Bot( 17 | command_prefix=commands.when_mentioned_or("$"), 18 | description=description, 19 | intents=intents, 20 | ) 21 | 22 | voicevox = Voicevox(bot.loop) 23 | voice_queue = Text2SpeechQueue(bot, voicevox) 24 | 25 | 26 | @bot.event 27 | async def on_ready(): 28 | print(f"Logged in as {bot.user} (ID: {bot.user.id if bot.user else 'user None'})") 29 | print("------") 30 | await bot.tree.sync() 31 | voice_queue.run() 32 | voicevox.synthesis_runner.loop = bot.loop 33 | voicevox.synthesis_runner.run() 34 | 35 | 36 | @bot.listen() 37 | async def on_message(message: discord.Message): 38 | if ( 39 | type(message.channel) is discord.channel.VoiceChannel 40 | and bot.user in message.channel.members 41 | and message.author != bot.user 42 | and not message.content.startswith("$") 43 | and message.guild 44 | and type(message.guild.voice_client) is discord.VoiceClient 45 | and message.guild.voice_client.is_connected() 46 | ): 47 | buf = await voicevox.genarete_sound( 48 | text=preprocess_text(message.content), guild=message.guild 49 | ) 50 | if buf is None: 51 | await message.reply("audio failed") 52 | return 53 | 54 | src = discord.FFmpegOpusAudio(source=buf, pipe=True) 55 | voice_queue.add(SpeakTask(message=message, source=src)) 56 | -------------------------------------------------------------------------------- /src/commands.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord import app_commands 3 | from discord.ext import commands 4 | 5 | 6 | class ReadingAloud(commands.Cog): 7 | def __init__(self, bot: commands.Bot): 8 | self.bot = bot 9 | 10 | @app_commands.command(name="join", description="botをボイスチャンネルに参加させます") 11 | @app_commands.describe(channel="参加させるボイスチャンネル(何も入力しない場合は現在のボイスチャンネルに参加します)") 12 | async def join( 13 | self, 14 | interaction: discord.Interaction, 15 | *, 16 | channel: discord.VoiceChannel | None = None, 17 | ): 18 | """join voice channel""" 19 | target_channel = channel 20 | if target_channel is None: 21 | if type(interaction.channel) is not discord.VoiceChannel: 22 | embed = discord.Embed( 23 | title="Error", 24 | color=discord.Color.brand_red(), 25 | description=( 26 | """Not participating in voice channel. 27 | Please join the voice channel or specify a valid channel as an argument.""" 28 | ), 29 | ) 30 | embed.set_author(name=self.bot.user) 31 | await interaction.response.send_message(embed=embed) 32 | return 33 | 34 | target_channel = interaction.channel 35 | if ( 36 | interaction.guild 37 | and type(interaction.guild.voice_client) is discord.VoiceClient 38 | ): 39 | return await interaction.guild.voice_client.move_to(target_channel) 40 | 41 | await target_channel.connect() 42 | embed = discord.Embed( 43 | title="Connect", 44 | color=discord.Color.brand_green(), 45 | description=f"{self.bot.user}が接続しました", 46 | ) 47 | embed.set_author(name=self.bot.user) 48 | await interaction.response.send_message(embed=embed) 49 | 50 | @app_commands.command(name="bye", description="botをボイスチャンネルから退出させます") 51 | async def bye(self, interaction: discord.Interaction): 52 | """leave voice channel""" 53 | if ( 54 | interaction.guild 55 | and type(interaction.guild.voice_client) is not discord.VoiceClient 56 | ): 57 | embed = discord.Embed( 58 | title="Error", 59 | color=discord.Color.brand_red(), 60 | description="Already left the audio channel.", 61 | ) 62 | embed.set_author(name=self.bot.user) 63 | await interaction.response.send_message(embed=embed) 64 | return 65 | if interaction.guild and interaction.guild.voice_client: 66 | await interaction.guild.voice_client.disconnect(force=True) 67 | embed = discord.Embed( 68 | title="Disconnect", 69 | color=discord.Color.greyple(), 70 | description=f"{self.bot.user}が退出しました", 71 | ) 72 | embed.set_author(name=self.bot.user) 73 | await interaction.response.send_message(embed=embed) 74 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | import os 3 | import discord 4 | from commands import ReadingAloud 5 | import asyncio 6 | import logging 7 | from bot import bot 8 | 9 | discord.utils.setup_logging(level=logging.INFO, root=False) 10 | 11 | token = os.environ.get("BOT_TOKEN", "DISCORD_BOT_TOKEN") 12 | 13 | 14 | async def main(): 15 | async with bot: 16 | await bot.add_cog(ReadingAloud(bot)) 17 | await bot.start(token=token) 18 | 19 | 20 | asyncio.run(main()) 21 | -------------------------------------------------------------------------------- /src/preprocessor.py: -------------------------------------------------------------------------------- 1 | import re, json 2 | from os import path 3 | from functools import reduce 4 | from sys import prefix 5 | 6 | 7 | def load_romanization_table(paths: list[str]): 8 | tables: dict[str, str] = {} 9 | for path in paths: 10 | with open(path) as f: 11 | for k, v in json.load(f).items(): 12 | tables[k] = v 13 | 14 | tables = dict(sorted(tables.items(), key=lambda x: -len(x[0]))) 15 | 16 | return tables 17 | 18 | 19 | romanization_table = load_romanization_table( 20 | [ 21 | path.join(path.dirname(__file__), "romanization/hepburn.json"), 22 | path.join(path.dirname(__file__), "romanization/japanese.json"), 23 | ] 24 | ) 25 | 26 | # duplicated consonant adds "ッ" at the beginning 27 | dup_consonant_table: dict[str, str] = {} 28 | for k, v in romanization_table.items(): 29 | if len(k) > 1: 30 | dup_consonant_table[k[0] + k] = f"っ{v}" 31 | 32 | 33 | def preprocess_url(text: str): 34 | return re.sub( 35 | r"https?://(([a-zA-Z0-9]-)*[a-zA-Z0-9]+.)*[a-zA-Z0-9](-[a-zA-Z0-9]+)*(/[a-zA-Z0-9-.]+)*(\?[a-zA-Z0-9%=-]*)?/?", 36 | "URL省略", 37 | text, 38 | ) 39 | 40 | 41 | def preprocess_emoji(text: str): 42 | return re.sub(r"", r"\1", text) 43 | 44 | 45 | def preprocess_dup_consonant_alphabet(text: str): 46 | dup_consonant_table_key_regexp = "|".join( 47 | map(re.escape, dup_consonant_table.keys()) 48 | ) 49 | return re.sub( 50 | dup_consonant_table_key_regexp, lambda k: dup_consonant_table[k.group()], text 51 | ) 52 | 53 | 54 | def preprocess_alphabet(text: str): 55 | romanization_table_key_regexp = "|".join(map(re.escape, romanization_table.keys())) 56 | return re.sub( 57 | romanization_table_key_regexp, lambda k: romanization_table[k.group()], text 58 | ) 59 | 60 | 61 | def preprocess_omission_long_text(text: str): 62 | # この長さ以下は省略 63 | omission_length = 40 64 | if len(text) > omission_length: 65 | return text[:omission_length] + "以下略" 66 | else: 67 | return text 68 | 69 | 70 | # ;(セミコロン)で始まる行を省略 71 | def preprocess_ignore_line(text: str): 72 | prefix_char = ";" 73 | lines = text.split("\n") # テキストを行に分割 74 | filtered_lines = [ 75 | line for line in lines if not line.strip().startswith(prefix_char) 76 | ] 77 | result = "\n".join(filtered_lines) # 行を再結合してテキストに戻す 78 | return result 79 | 80 | 81 | def preprocess_text(text: str): 82 | processors = [ 83 | preprocess_url, 84 | preprocess_emoji, 85 | preprocess_dup_consonant_alphabet, 86 | preprocess_alphabet, 87 | preprocess_ignore_line, 88 | preprocess_omission_long_text, 89 | ] 90 | 91 | return reduce(lambda p, c: c(p), processors, text) 92 | -------------------------------------------------------------------------------- /src/romanization/hepburn.json: -------------------------------------------------------------------------------- 1 | { 2 | "kya": "きゃ", 3 | "kyi": "きぃ", 4 | "kyu": "きゅ", 5 | "kye": "きぇ", 6 | "kyo": "きょ", 7 | "gya": "ぎゃ", 8 | "gyi": "ぎぃ", 9 | "gyu": "ぎゅ", 10 | "gye": "ぎぇ", 11 | "gyo": "ぎょ", 12 | "shi": "しぃ", 13 | "sha": "しゃ", 14 | "shu": "しゅ", 15 | "she": "しぇ", 16 | "sho": "しょ", 17 | "chi": "ちぃ", 18 | "tsu": "つ", 19 | "cha": "ちゃ", 20 | "chu": "ちゅ", 21 | "che": "ちぇ", 22 | "cho": "ちょ", 23 | "dya": "ぢゃ", 24 | "dyi": "ぢぃ", 25 | "dyu": "ぢゅ", 26 | "dye": "ぢぇ", 27 | "dyo": "ぢょ", 28 | "nya": "にゃ", 29 | "nyi": "にぃ", 30 | "nyu": "にゅ", 31 | "nye": "にぇ", 32 | "nyo": "にょ", 33 | "hya": "ひゃ", 34 | "hyi": "ひぃ", 35 | "hyu": "ひゅ", 36 | "hye": "ひぇ", 37 | "hyo": "ひょ", 38 | "bya": "びゃ", 39 | "byi": "びぃ", 40 | "byu": "びゅ", 41 | "bye": "びぇ", 42 | "byo": "びょ", 43 | "pya": "ぴゃ", 44 | "pyi": "ぴぃ", 45 | "pyu": "ぴゅ", 46 | "pye": "ぴぇ", 47 | "pyo": "ぴょ", 48 | "mya": "みゃ", 49 | "myi": "みぃ", 50 | "myu": "みゅ", 51 | "mye": "みぇ", 52 | "myo": "みょ", 53 | "rya": "りゃ", 54 | "ryi": "りぃ", 55 | "ryu": "りゅ", 56 | "rye": "りぇ", 57 | "ryo": "りょ", 58 | "lya": "り゚ゃ", 59 | "lyi": "り゚ぃ", 60 | "lyu": "り゚ゅ", 61 | "lye": "り゚ぇ", 62 | "lyo": "り゚ょ", 63 | "ka": "か", 64 | "ki": "き", 65 | "ku": "く", 66 | "ke": "け", 67 | "ko": "こ", 68 | "ga": "が", 69 | "gi": "ぎ", 70 | "gu": "ぐ", 71 | "ge": "げ", 72 | "go": "ご", 73 | "sa": "さ", 74 | "su": "す", 75 | "se": "せ", 76 | "so": "そ", 77 | "za": "ざ", 78 | "ji": "ぢ", 79 | "zu": "づ", 80 | "ze": "ぜ", 81 | "zo": "ぞ", 82 | "ja": "じゃ", 83 | "ju": "じゅ", 84 | "je": "じぇ", 85 | "jo": "じょ", 86 | "ta": "た", 87 | "te": "て", 88 | "to": "と", 89 | "da": "だ", 90 | "de": "で", 91 | "do": "ど", 92 | "na": "な", 93 | "ni": "に", 94 | "nu": "ぬ", 95 | "ne": "ね", 96 | "no": "の", 97 | "ha": "は", 98 | "hi": "ひ", 99 | "fu": "ふ", 100 | "he": "へ", 101 | "ho": "ほ", 102 | "ba": "ば", 103 | "bi": "び", 104 | "bu": "ぶ", 105 | "be": "べ", 106 | "bo": "ぼ", 107 | "pa": "ぱ", 108 | "pi": "ぴ", 109 | "pu": "ぷ", 110 | "pe": "ぺ", 111 | "po": "ぽ", 112 | "ma": "ま", 113 | "mi": "み", 114 | "mu": "む", 115 | "me": "め", 116 | "mo": "も", 117 | "ya": "や", 118 | "yu": "ゆ", 119 | "yo": "よ", 120 | "ra": "ら", 121 | "ri": "り", 122 | "ru": "る", 123 | "re": "れ", 124 | "ro": "ろ", 125 | "la": "ら゚", 126 | "li": "り゚", 127 | "lu": "る゚", 128 | "le": "れ゚", 129 | "lo": "ろ゚", 130 | "wa": "わ", 131 | "va": "わ゙", 132 | "vi": "ゐ゙", 133 | "vu": "ゔ", 134 | "ve": "ゑ゙", 135 | "vo": "を゙", 136 | "a": "あ", 137 | "i": "ゐ", 138 | "u": "う", 139 | "e": "ゑ", 140 | "o": "を", 141 | "n": "ん" 142 | } 143 | -------------------------------------------------------------------------------- /src/romanization/japanese.json: -------------------------------------------------------------------------------- 1 | { 2 | "kya": "きゃ", 3 | "kyu": "きゅ", 4 | "kyo": "きょ", 5 | "sya": "しゃ", 6 | "syu": "しゅ", 7 | "syo": "しょ", 8 | "tya": "ちゃ", 9 | "tyu": "ちゅ", 10 | "tyo": "ちょ", 11 | "nya": "にゃ", 12 | "nyu": "にゅ", 13 | "nyo": "にょ", 14 | "hya": "ひゃ", 15 | "hyu": "ひゅ", 16 | "hyo": "ひょ", 17 | "mya": "みゃ", 18 | "myu": "みゅ", 19 | "myo": "みょ", 20 | "rya": "りゃ", 21 | "ryu": "りゅ", 22 | "ryo": "りょ", 23 | "gya": "ぎゃ", 24 | "gyu": "ぎゅ", 25 | "gyo": "ぎょ", 26 | "zya": "じゃ", 27 | "zyu": "じゅ", 28 | "zyo": "じょ", 29 | "dya": "ぢゃ", 30 | "dyu": "ぢゅ", 31 | "dyo": "ぢょ", 32 | "bya": "びゃ", 33 | "byu": "びゅ", 34 | "byo": "びょ", 35 | "pya": "ぴゃ", 36 | "pyu": "ぴゅ", 37 | "pyo": "ぴょ", 38 | "kwa": "くゎ", 39 | "gwa": "ぐゎ", 40 | "ka": "か", 41 | "ki": "き", 42 | "ku": "く", 43 | "ke": "け", 44 | "ko": "こ", 45 | "sa": "さ", 46 | "si": "し", 47 | "su": "す", 48 | "se": "せ", 49 | "so": "そ", 50 | "ta": "た", 51 | "ti": "ち", 52 | "tu": "つ", 53 | "te": "て", 54 | "to": "と", 55 | "na": "な", 56 | "ni": "に", 57 | "nu": "ぬ", 58 | "ne": "ね", 59 | "no": "の", 60 | "ha": "は", 61 | "hi": "ひ", 62 | "hu": "ふ", 63 | "he": "へ", 64 | "ho": "ほ", 65 | "ma": "ま", 66 | "mi": "み", 67 | "mu": "む", 68 | "me": "め", 69 | "mo": "も", 70 | "ya": "や", 71 | "yi": "い", 72 | "yu": "ゆ", 73 | "ye": "え", 74 | "yo": "よ", 75 | "ra": "ら", 76 | "ri": "り", 77 | "ru": "る", 78 | "re": "れ", 79 | "ro": "ろ", 80 | "wa": "わ", 81 | "wi": "ゐ", 82 | "wu": "う", 83 | "we": "ゑ", 84 | "wo": "を", 85 | "ga": "が", 86 | "gi": "ぎ", 87 | "gu": "ぐ", 88 | "ge": "げ", 89 | "go": "ご", 90 | "za": "ざ", 91 | "zi": "じ", 92 | "zu": "ず", 93 | "ze": "ぜ", 94 | "zo": "ぞ", 95 | "da": "だ", 96 | "di": "ぢ", 97 | "du": "づ", 98 | "de": "で", 99 | "do": "ど", 100 | "ba": "ば", 101 | "bi": "び", 102 | "bu": "ぶ", 103 | "be": "べ", 104 | "bo": "ぼ", 105 | "pa": "ぱ", 106 | "pi": "ぴ", 107 | "pu": "ぷ", 108 | "pe": "ぺ", 109 | "po": "ぽ", 110 | "a": "あ", 111 | "i": "い", 112 | "u": "う", 113 | "e": "え", 114 | "o": "お" 115 | } 116 | -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/murmur-develop/murmur/02b2cd88467645da24ad17bca02e71e53d0bf602/src/tests/__init__.py -------------------------------------------------------------------------------- /src/tests/test_preprocessor.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from preprocessor import preprocess_ignore_line 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "input_string, expected_output", 7 | [ 8 | ( 9 | """ 10 | これはテストです 11 | ; この行は無視します 12 | ; この行は無視します 13 | この行は読み上げます 14 | ; この行は無視します 15 | """, 16 | """ 17 | これはテストです 18 | この行は読み上げます 19 | """, 20 | ), 21 | ( 22 | """ 23 | ;先頭を無視するテストです 24 | この行は読み上げます 25 | """, 26 | """ 27 | この行は読み上げます 28 | """, 29 | ), 30 | ], 31 | ) 32 | def test_preprocess_ignore_line(input_string, expected_output): 33 | output_string = preprocess_ignore_line(input_string) 34 | assert output_string == expected_output 35 | -------------------------------------------------------------------------------- /src/textToSpeechQueue.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | from typing import cast, Coroutine 4 | from voicevox import Voicevox 5 | import asyncio 6 | 7 | 8 | class SpeakTask: 9 | message: discord.Message 10 | source: discord.FFmpegOpusAudio 11 | 12 | def __init__(self, message: discord.Message, source: discord.FFmpegOpusAudio): 13 | self.message = message 14 | self.source = source 15 | 16 | @property 17 | def channel(self): 18 | return cast(discord.VoiceChannel, self.message.channel) 19 | 20 | @property 21 | def guild(self): 22 | return self.message.guild 23 | 24 | 25 | class Text2SpeechQueue: 26 | bot: commands.Bot 27 | voicevox: Voicevox 28 | 29 | queue: list[SpeakTask] = [] 30 | 31 | _tasks: set[asyncio.Task] = set() 32 | 33 | def __init__(self, bot: commands.Bot, voicevox: Voicevox): 34 | self.bot = bot 35 | self.voicevox = voicevox 36 | 37 | def add(self, task: SpeakTask): 38 | self.queue.append(task) 39 | 40 | def shift(self) -> SpeakTask | None: 41 | if len(self.queue) <= 0: 42 | return None 43 | return self.queue.pop(0) 44 | 45 | def create_task(self, c: Coroutine): 46 | task = self.bot.loop.create_task(c) 47 | self._tasks.add(task) 48 | 49 | task.add_done_callback(self._tasks.discard) 50 | 51 | def run(self): 52 | def after_fn(prev_result: Exception | None = None): 53 | if prev_result is not None: 54 | raise prev_result 55 | 56 | self.create_task(next_fn()) 57 | 58 | async def next_fn(): 59 | await asyncio.sleep(0.05) 60 | 61 | task = self.shift() 62 | if task and task.guild: 63 | if task.source and type(task.guild.voice_client) is discord.VoiceClient: 64 | task.guild.voice_client.play(task.source, after=after_fn) 65 | else: 66 | await task.message.reply("audio failed") 67 | after_fn(None) 68 | else: 69 | await asyncio.sleep(0.1) 70 | after_fn(None) 71 | 72 | self.create_task(next_fn()) 73 | -------------------------------------------------------------------------------- /src/voicevox.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from dataclasses import dataclass 3 | import re 4 | from discord.guild import Guild 5 | import os 6 | from os import path 7 | import asyncio 8 | from concurrent.futures import ThreadPoolExecutor 9 | from SynthesisRunner import SynthesisRunner 10 | from typing import Awaitable 11 | 12 | voicevox_path = ( 13 | os.environ["VOICEVOX_PATH"] 14 | if "VOICEVOX_PATH" in os.environ 15 | else path.join(path.dirname(__file__), "../voicevox_core/") 16 | ) 17 | 18 | from voicevox_core import VoicevoxCore 19 | 20 | 21 | class Voicevox: 22 | synthesis_runner: SynthesisRunner 23 | core: VoicevoxCore 24 | 25 | def __init__( 26 | self, 27 | loop: asyncio.AbstractEventLoop, 28 | dict_dir=path.join(voicevox_path, "open_jtalk_dic_utf_8-1.11"), 29 | thread_pool=ThreadPoolExecutor(max_workers=8), 30 | ) -> None: 31 | self.core = VoicevoxCore(open_jtalk_dict_dir=dict_dir, load_all_models=True) 32 | self.synthesis_runner = SynthesisRunner( 33 | self.core, loop, thread_pool=thread_pool 34 | ) 35 | 36 | def genarete_sound( 37 | self, text: str, guild: Guild, speaker=1, speed=100, pitch=0 38 | ) -> Awaitable[BytesIO | None]: 39 | # Mention先のUserID取得 40 | mention_id = re.search("[0-9]+", text) 41 | if mention_id is not None: 42 | member = guild.get_member(int(mention_id.group())) 43 | if member is not None: 44 | user_name = member.display_name 45 | # @{user_id} を @{display_name}さん に置き換える 46 | text = text.replace(mention_id.group(), user_name + "さん") 47 | 48 | query = self.core.audio_query(text, speaker) 49 | query.speed_scale = speed / 100 50 | query.pitch_scale = pitch / 100 51 | 52 | f: asyncio.Future = asyncio.Future() 53 | 54 | def _cb(res: bytes | None): 55 | f.set_result(res and BytesIO(res)) 56 | 57 | self.synthesis_runner.synthesis(query, speaker, _cb) 58 | 59 | return f 60 | --------------------------------------------------------------------------------