Skip to content

Instantly share code, notes, and snippets.

@MiyacoGBF

MiyacoGBF/vc.py Secret

Last active June 30, 2021 00:37
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save MiyacoGBF/acd865c7070f73d8236ccc4f67883f10 to your computer and use it in GitHub Desktop.
Save MiyacoGBF/acd865c7070f73d8236ccc4f67883f10 to your computer and use it in GitHub Desktop.
Discordのテキスト読み上げボット用モジュール(Open JTalk使用)
#!/usr/bin/env python3
'''
Copyright 2019 Miyaco
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This software includes the work distributed under the following license:
Copyright 2018-2019 Kohei Yamada
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
'''
import asyncio
from copy import copy
import json
from pathlib import Path
import re
import subprocess
import time
import discord
# ======= 設定開始 =======
# このファイルのディレクトリ
CURRENT_DIR = Path(__file__).absolute().parent
# ファイルの一時保存に使うディレクトリ
TEMP_DIR = Path('/tmp') # Windows なら CURRENT_DIR にしちゃってください
# Open JTalk の辞書があるディレクトリを指定
DICT_DIR = Path('/usr/share/open-jtalk/dic')
# Open JTalk付属の音声ファイルがあるディレクトリを指定
SYS_VOICES = Path('/usr/share/open-jtalk/voices')
# Open JTalk付属以外の音声ファイルがあるディレクトリを指定
LOCAL_VOICES = CURRENT_DIR / 'voices'
# コマンドプレフィックス
PREFIX = '!!' # 自由に変えてください
# ======= 設定終わり =======
CURRENT_VC = None
CURRENT_CHAT = None
_RE_URL = re.compile(r'https?://([\w-]+\.)+[\w-]+(/[-\w ./?%&=#]*)?')
_RE_EMOJI = re.compile(r':(\w\w+):\d+')
_RE_NEWLINE = re.compile(r'\n')
WORDS_FILE = CURRENT_DIR / 'words.json'
with WORDS_FILE.open() as f:
WORDS = json.loads(f.read())
# 音声ファイルを指定
VOICES = {
'normal': SYS_VOICES / 'mei_normal.htsvoice',
'happy': SYS_VOICES / 'mei_happy.htsvoice',
'bashful': SYS_VOICES / 'mei_bashful.htsvoice',
'angry': SYS_VOICES / 'mei_angry.htsvoice',
'sad': SYS_VOICES / 'mei_sad.htsvoice',
'male': SYS_VOICES / 'nitech_jp_atr503_m001.htsvoice',
# 'momo': LOCAL_VOICES / '桃音モモ.htsvoice',
}
USER_DEFAULT = {
'voice': 'normal',
'speed': 1.0,
'tone': 0.0,
'intone': 1.0,
'threshold': 0.5,
'allpass': 0.0,
'volume': -6,
}
USER_FILE = CURRENT_DIR / 'user.json'
with USER_FILE.open() as f:
USER_CONFIG = json.loads(f.read())
def update_words() -> None:
with WORDS_FILE.open('w') as f:
f.write(json.dumps(WORDS, ensure_ascii=False))
def new_user(id) -> None:
USER_CONFIG[id] = copy(USER_DEFAULT)
def update_user() -> None:
with USER_FILE.open('w') as f:
f.write(json.dumps(USER_CONFIG))
def _check_command(_input, command) -> bool:
if len(_input) != 2:
return False
if not _input[0].endswith(command):
return False
return True
async def show_status(msg) -> None:
_config = USER_CONFIG.get(str(msg.author.id), USER_DEFAULT)
_msg = 'voice: {}, speed: {}, tone: {}, intone: {}, threshold: {}, allpass: {}, volume: {}'.format( # noqa: E501
_config['voice'],
_config['speed'],
_config['tone'],
_config['intone'],
_config.get('threshold', USER_DEFAULT['threshold']), # 追加したのでget
_config.get('allpass') or 'auto', # 追加したのでget, 0なら'auto'
_config.get('volume', USER_DEFAULT['volume']), # 追加したのでget
)
_user = msg.author
message = discord.Embed(title=_msg)
message.set_author(
name=_user.nick or _user.name,
icon_url=_user.avatar_url or _user.default_avatar_url,
)
await msg.channel.send(embed=message)
async def set_status(msg, command, checker='range', min=None, max=None, candidates=None) -> None: # noqa: E501
_user_id = str(msg.author.id)
_input = msg.content.split()
if checker == 'range':
err = f'`{PREFIX}{command} 数値({min}〜{max})` で指定してください。'
elif checker == 'choice':
choice = [val for val in candidates]
err = f'`{PREFIX}{command} {choice}` で指定してください。'
if not _check_command(_input, command):
await msg.channel.send(err)
return
if checker == 'range':
try:
value = float(_input[1])
except ValueError:
await msg.channel.send(err)
return
if value < min or value > max:
await msg.channel.send(err)
return
elif checker == 'choice':
value = _input[1]
if value not in candidates:
await msg.channel.send(err)
return
else:
raise ValueError('Invalid checker type: {}'.format(checker))
if _user_id not in USER_CONFIG:
new_user(_user_id)
USER_CONFIG[_user_id][command] = value
update_user()
# 変更後のステータス表示
await show_status(msg)
def convert_voice(msg, config) -> Path:
ftime = time.perf_counter()
text_file = TEMP_DIR / 'voice-{}.txt'.format(ftime)
_msg = msg
# URLを`URL`に置換
_msg = _RE_URL.sub('URL', _msg)
# 絵文字のID部分を削除
_msg = _RE_EMOJI.sub(r'\1', _msg)
for (pre, post) in WORDS.items():
if pre in _msg:
_msg = _msg.replace(pre, post)
# 改行後読み上げないので改行を空白に変換
_msg = _RE_NEWLINE.sub(r' ', _msg)
with text_file.open('w') as f:
f.write(_msg)
wav_file = TEMP_DIR / 'voice-{}.wav'.format(ftime)
voice_file = VOICES[config['voice']]
cmd = [
'open_jtalk',
'-x', str(DICT_DIR),
'-m', str(voice_file),
'-ow', str(wav_file),
'-r', str(config['speed']),
'-fm', str(config['tone']),
'-jf', str(config['intone']),
'-u', str(config.get('threshold', USER_DEFAULT['threshold'])), # 追加したのでget # noqa: E501
'-g', str(config.get('volume', USER_DEFAULT['volume'])), # 追加したのでget # noqa: E501
]
# allpassはデフォルトでautoなので設定がないか0より大きい時だけ追加
allpass = config.get('allpass')
if allpass:
cmd.append('-a')
cmd.append(str(allpass))
# 読み上げファイル
cmd.append(str(text_file))
subprocess.run(cmd)
text_file.unlink()
return wav_file
async def talk(msg) -> None:
if msg.author.bot:
return
global CURRENT_VC
global CURRENT_CHAT
if msg.content == '{}help'.format(PREFIX):
await msg.channel.send(
'''
`{0}help`: このヘルプを表示します。
`{0}summon`: 参加中のVCに呼び出します。
`{0}bye`: VCから切断します。
`{0}stop`: 読み上げを中断させます。
`{0}wa 単語 読み`: 単語辞書に「単語」を「読み」として登録します。
`{0}wd 単語`: 単語辞書に登録されている「単語」を削除します。
`{0}wl`: 単語辞書に登録されている「単語」と「読み」の一覧を表示します。
`{0}voice {1}`: 声のタイプを設定します。
`{0}speed 0.5〜2.0`: 話す速度を設定します。(デフォルト`1.0`)
`{0}tone -20〜20`: 声のトーンを設定します。(デフォルト`0.0`)
`{0}intone 0.0〜4.0`: 声のイントネーションを設定します。(デフォルト`1.0`)
`{0}threshold 0.0〜1.0`: ブツブツするときとか改善するかも。(デフォルト`0.5`)
`{0}allpass 0.0〜1.0`: all-pass constant(`0`で自動設定)
`{0}volume -20〜0`: 音量(dB)を設定します。(デフォルト`-6`)
`{0}status`: 現在の声の設定を表示します。
'''.strip().format(PREFIX, [k for k in VOICES])
)
return
# VCに呼び出す
if msg.content == '{}summon'.format(PREFIX):
# 呼び出した人がVCに入ってなかったらエラー
if msg.author.voice is None:
await msg.channel.send(
'`{}summon`はVCに入ってる状態で使ってください'.format(PREFIX)
)
return
# すでにVCに入っていたら出る
if CURRENT_VC:
await CURRENT_VC.disconnect()
CURRENT_VC = None
CURRENT_CHAT = None
CURRENT_VC = await msg.author.voice.channel.connect()
CURRENT_CHAT = msg.channel
await msg.channel.send('しゃべりに来ました')
return
# VCから退出させる
if msg.content == '{}bye'.format(PREFIX):
if CURRENT_VC:
await msg.channel.send('じゃあね〜')
await CURRENT_VC.disconnect()
CURRENT_VC = None
CURRENT_CHAT = None
else:
await msg.channel.send('VCに呼ばれてないですよ')
return
# 読み上げの中断
if msg.content == '{}stop'.format(PREFIX):
if CURRENT_VC and CURRENT_VC.is_playing():
CURRENT_VC.stop()
return
# ここから辞書設定
# 辞書に単語追加
if msg.content.startswith('{}wa'.format(PREFIX)):
_input = msg.content.split()
err = '単語登録は `{}wa 単語 読み` の形にしてくださいね'.format(PREFIX)
if len(_input) < 3:
await msg.channel.send(err)
return
if not _input[0].endswith('wa'):
await msg.channel.send(err)
return
word_pre = _input[1]
# 追加する単語がスペースを含んでいるかもなので連結
word_post = ' '.join(_input[2:])
WORDS[word_pre] = word_post
update_words()
await msg.channel.send(
'単語「{}」を読み「{}」で登録しました'.format(word_pre, word_post)
)
return
# 辞書から単語削除
if msg.content.startswith('{}wd'.format(PREFIX)):
_input = msg.content.split()
err = '単語の削除は `{}wd 単語` の形にしてくださいね'.format(PREFIX)
if len(_input) > 2:
await msg.channel.send(err)
return
if not _input[0].endswith('wd'):
await msg.channel.send(err)
return
word = _input[1]
if word not in WORDS:
# 辞書に登録されてない単語だったらエラー
await msg.channel.send(
'「{}」は単語の辞書に登録されてないですよ'.format(word)
)
return
del WORDS[word]
update_words()
await msg.channel.send(
'単語「{}」を削除しました'.format(word)
)
return
# 辞書一覧の表示
if msg.content == '{}wl'.format(PREFIX):
word_list = ['登録されている単語の一覧です']
for (word, read) in WORDS.items():
word_list.append('・単語: {}、読み: {}'.format(word, read))
await msg.channel.send('\n'.join(word_list))
return
# ここからユーザー別の音声設定
if msg.content.startswith('{}voice'.format(PREFIX)):
await set_status(msg, 'voice', checker='choice', candidates=VOICES)
return
if msg.content.startswith('{}speed'.format(PREFIX)):
await set_status(msg, 'speed', min=0.5, max=2.0)
return
if msg.content.startswith('{}tone'.format(PREFIX)):
await set_status(msg, 'tone', min=-20, max=20)
return
if msg.content.startswith('{}intone'.format(PREFIX)):
await set_status(msg, 'intone', min=0.0, max=4.0)
return
if msg.content.startswith('{}threshold'.format(PREFIX)):
await set_status(msg, 'threshold', min=0.0, max=1.0)
return
if msg.content.startswith('{}allpass'.format(PREFIX)):
await set_status(msg, 'allpass', min=0.0, max=1.0)
return
if msg.content.startswith('{}volume'.format(PREFIX)):
await set_status(msg, 'volume', min=-20, max=0)
return
# その人の声のステータス表示
if msg.content == '{}status'.format(PREFIX):
await show_status(msg)
return
# VCにいて呼び出された部屋の発言だったら喋る
if CURRENT_VC and msg.channel == CURRENT_CHAT:
wav_file = convert_voice(
msg.clean_content,
USER_CONFIG.get(str(msg.author.id), USER_DEFAULT),
)
# 前のメッセージ喋ってたら120秒待つ
for _ in range(600):
try:
CURRENT_VC.play(
discord.FFmpegPCMAudio(str(wav_file)),
# 終わったら音声ファイル削除
after=lambda err: wav_file.unlink(),
)
break
except discord.errors.ClientException:
await asyncio.sleep(0.2) # 200msec待ち
else:
await msg.channel.send(
'めっちゃ長い文章喋ってる途中だから読み上げ放棄'
)
@Miu2050
Copy link

Miu2050 commented Jun 30, 2021

はじめまして、みうとお申します!

あまり詳しくないのですが、見様見真似でこちらのソース使わせていただこうと各種設定行い、Open JTalkのテスト、ボット用のPython仮想環境の作成/アクティベートまできたのですが、main.pyを起動すると、words.jsonがディレクトリにないとエラーになります。
FileNotFoundError: [Errno 2] No Such file or directory: '/home/k/BOT/words.json
ためしに同名のファイルを作成すると、以下のようなエラーになります。
from None json.decoder.JSONDecodeError: Expecting value:line 1 colum 1 (char 0)

どこかの手順が漏れたのでしょうか。words.jsonが自動生成されるものか、設定時に入るものかも分からず。。
あと発言したユーザー名を読み上げてから発言内容を読み上げる機能ありますでしょうか。
質問ばかりで申し訳ございません。
何卒よろしくお願いいたします!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment