ぽらろいどの日記

新しい知見を得たり、得られた知見を記録したり共有したりする場を予定しています。

COEIROINK & VOICEVOXに英語を含むテキストを読ませる with Python on Windows

やりたいこと

冒頭のタイトルに対して、VOICEVOXに英語を読ませる(カタカナ英語) #Python - Qiitaという記事が存在し、これによりカタカナ英語で読ませることが出来る。

なお、記事中では以下の問題が指摘されている。

先のライブラリでは、「アイ ラブ ユー」のように英語の発音を単語ごとに半角スペースで区切るようにしている。 これをVOICEVOXに読ませると、単語間の空白時間が長すぎる。(場所によっては0.5秒ほどの無音が入る)

記事の方法とは別に、英単語の前後で空白が入ること自体を防げる方法があれば、それでも対応可能だと思われる。例えば「この無線LANを」を「この無線」「ラン」「を」と分けずに「この無線ランを」に変更できれば、そもそも無音の時間は生じない。

ここで2つの希望が生じる。

  1. 上記の解決に変更したうえで、txtの文章をVOICEVOXに読ませたい。
  2. COEIROINKでの発話に対応させたい。

自動での改行に対応するようにもしたい。

解決案(VOICEVOX)

そこでまず、1つ目の希望を達成する。①eng_to_kana.pyを変更して期待通りカタカナ英語に変換させる。その後、②これを呼び出して音声合成するスクリプトを作成する。

①カタカナ英語への変換

まずは、eng_to_kana.pyを変更する。生成AIに尋ねつつ、以下の修正となった。

@@ -14,9 +14,6 @@
 reduction=[["It\'s","イッツ"],["I\'m","アイム"],["You\'re","ユーァ"],["He\'s","ヒーィズ"],["She\'s","シーィズ"],["We\'re","ウィーアー"],["They\'re","ゼァー"],["That\'s","ザッツ"],["Who\'s","フーズ"],["Where\'s","フェアーズ"],["I\'d","アイドゥ"],["You\'d","ユードゥ"],["I\'ve","アイブ"],["I\'ll","アイル"],["You\'ll","ユール"],["He\'ll","ヒール"],["She\'ll","シール"],["We\'ll","ウィール"]]
 
 def eng_to_kana(text):
-  # 読みたい記号。他の単語と混ざらないように、前後に半角スペースを挟む
-  text = text.replace("+"," プラス ").replace("+"," プラス ").replace("-"," マイナス ").replace("="," イコール ").replace("="," イコール ")
-
   # No.2、No6みたいに、No.の後に数字が続く場合はノーではなくナンバーと読む
   text = re.sub(r'No\.([0-9])',"ナンバー\\1",text)
   text = re.sub(r'No([0-9])',"ナンバー\\1",text)
@@ -27,21 +24,16 @@
   # this is a pen.のように、aの後に半角スペース、続いてアルファベットの場合、エーではなくアッと呼ぶ
   text = re.sub(r'a ([a-zA-Z])',"アッ \\1",text)
 
-  # 文を区切る文字は消してはダメなので、前後に半角スペースを挟む
-  text = text.replace("."," . ").replace("。"," 。 ").replace("!"," ! ").replace("!"," ! ")
-
-  # アルファベットとアルファベット以外が近接している時、その間に半角スペースを挟む(この後、英単語を単語ごとに区切るための前準備)
-  text_l=list(text)
-  for i in range(len(text))[::-1][:-1]:
-    if re.compile("[a-zA-Z]").search(text_l[i]) and re.compile("[^a-zA-Z]").search(text_l[i-1]): text_l.insert(i," ")
-    elif re.compile("[^a-zA-Z]").search(text_l[i]) and re.compile("[a-zA-Z]").search(text_l[i+-1]): text_l.insert(i," ")
-
-  text = "".join(text_l)
-
-  # 半角スペースや読まなくて良い文字で区切り、各単語の英語をカタカナに変換
-  text_split = re.split('[ \,\*\-\_\=\(\)\[\]\'\"\&\$ ]',text)
-  for i in range(len(text_split)):
-    if str.upper(text_split[i]) in kana_dict:
-      text_split[i] = kana_dict[str.upper(text_split[i])]
+  # アルファベットの文字列を抽出
+  words = re.findall(r"[A-Za-z0-9]+", text)
+  words.sort(key=len, reverse=True)
+
+  # 辞書型の変数kana_dictから読み仮名を取り出し、置換
+  for word in words:
+    # 大文字に変換
+    upper_word = word.upper()
+    if upper_word in kana_dict:
+      # re.sub()を使用して、テキスト内のすべての出現を置換
+      text = re.sub(word, kana_dict[upper_word], text)
 
-  return (" ".join(text_split))
+  return text

変更後ではテキストを分割せず、全体を対象に置換を行う。これにより、元のテキストの構造を変えずに、英語だけをカタカナ英語に変換できる(ということらしい)。

なお「-」をマイナスとして読ませないようにしている。例えばEAP-TTLSはイーエーピーティーティーエルエスと読んでほしいため。

②呼び出して音声合成

つぎに、これを呼び出してテキストをカタカナ英語に変換した後、engineにクエリを投げて音声を再生するスクリプトtalk.pyを作成する。

先記事のsave_voice()では保存してしまうので、これは再生させるように変更する。

# 英語をカタカナに変換するモジュールをインポート
from eng_to_kana import eng_to_kana

# 並行処理を行うためのモジュールをインポート
from concurrent.futures import ThreadPoolExecutor
import json
import requests
import winsound

# スピーカーの詳細を取得する関数
def get_speaker(speaker_name) -> map:
    # スピーカーの情報を取得
    res = requests.get("http://" + voice_server + "/speakers").json()

    # 指定したスピーカーの詳細のみ抽出
    voice_details = list(filter(lambda x: x["name"] == speaker_name, res))

    if len(voice_details) >= 1:
        return voice_details[0]
    else:
        raise Exception("There is no such a speaker.")

# スピーカーの音声IDを取得する関数
def get_voiceid(speaker_name, style_name=None) -> int:
    # スピーカーの詳細を取得
    voice_detail = get_speaker(speaker_name)

    # 指定したスタイルのボイスIDを抽出(未指定ならトップのボイスID)
    if style_name == None:
        voice_styles = voice_detail["styles"]
    else:
        voice_styles = list(
            filter(lambda x: x["name"] == style_name, voice_detail["styles"])
        )

    if len(voice_styles) >= 1:
        return voice_styles[0]["id"]
    else:
        raise Exception("There is no such a style.")

# 音声データを取得する関数
def get_wave_voice(msg, voice_id, custom=[0, 1.1, 1]):
    # メッセージをカタカナに変換
    msg = eng_to_kana(msg)

    # 音声合成クエリを作成
    request = requests.post(
        "http://"
        + voice_server
        + "/audio_query?speaker="
        + str(voice_id)
        + '&text="'
        + msg
        + '"'
    ).json()

    # ピッチ、スピード、イントネーションのスケールを設定
    request["pitchScale"] = custom[0]
    request["speedScale"] = custom[1]
    request["intonationScale"] = custom[2]

    # 音声を合成
    return requests.post(
        "http://" + voice_server + "/synthesis?speaker=" + str(voice_id),
        data=json.dumps(request, ensure_ascii=False).encode("utf-8"),
    )

# 音声サーバーのアドレスを設定
voice_server = "localhost:50021"

def main():
    # スピーカーを指定
    speaker_name = "No.7"
    style_name = None  # デフォルト指定ならNone
    msg_file = "message.txt" # 読むテキストを保存したファイル
    tasklist = []  # 各スレッドのタスクを保持するためのリスト

    # ボイスIDを取得
    voice_id = get_voiceid(speaker_name, style_name)

    with open(msg_file, mode="r", encoding="utf-8") as f:
        # 句点でテキストを分割
        lines = sum([item.split("。") for item in f.readlines()], [])
        with ThreadPoolExecutor() as executor:
            for line in lines:
                # スレッドを使って、各行を音声合成
                tasklist.append(executor.submit(get_wave_voice, line, voice_id))
            for task in tasklist:
                # 合成結果を取得して再生
                wavdata = task.result()
                winsound.PlaySound(wavdata.content, winsound.SND_MEMORY)

main()

解決案(COEIROINK)

つぎに、COEIROINKでの発話に対応させる。

もちろんeng_to_kana.pyは上記の変更と同じで良い。ただしリクエストの形式などが異なるので、呼び出し部talk.pyは変更する必要がある。

# 英語をカタカナに変換するモジュールをインポート
from eng_to_kana import eng_to_kana

# 並行処理を行うためのモジュールをインポート
from concurrent.futures import ThreadPoolExecutor
import json
import requests
import winsound

# スピーカーの詳細を取得する関数
def get_speaker(speaker_name) -> map:
    # スピーカーの詳細を取得
    res = requests.get("http://" + voice_server + "/v1/speakers").json()

    # 指定したスピーカーの詳細のみ抽出
    voice_details = list(filter(lambda x: x["speakerName"] == speaker_name, res))

    if len(voice_details) >= 1:
        return voice_details[0]
    else:
        raise Exception("There is no such a speaker.")

# デフォルトのリクエストを作成する関数
def get_default_request(speaker_name, style_name, custom=[0, 1.2, 1]) -> dict:
    # スピーカーの情報を取得
    speaker_info = get_speaker(speaker_name)

    # 指定したスタイルのスタイルIDを抽出(未指定ならトップのスタイルID)
    voice_styles = list(
        filter(lambda x: x["styleName"] == style_name, speaker_info["styles"])
    )

    if len(voice_styles) > 0:
        style_id = voice_styles[0]["styleId"]
    else:
        style_id = speaker_info["styles"][0]["styleId"]

    # リクエストの内容を作成
    request = {
        "speakerUuid": speaker_info["speakerUuid"],
        "styleId": style_id,
        "text": "",
        "speedScale": custom[1],
        "volumeScale": 1,
        "pitchScale": custom[0],
        "intonationScale": custom[2],
        "prePhonemeLength": 0.1,
        "postPhonemeLength": 0.1,
        "outputSamplingRate": 44100
    }

    return request

# 音声データを取得する関数
def get_wave_voice(msg, request):
    # メッセージをカタカナに変換
    msg = eng_to_kana(msg)

    # メッセージが存在すればリクエストのテキストに設定
    request["text"] = msg if len(msg) > 0 else ""
    
    # 音声を合成
    return requests.post(
        "http://" + voice_server + "/v1/synthesis",
        data=json.dumps(request, ensure_ascii=False).encode("utf-8"),
    )

# 音声サーバーのアドレスを設定
voice_server = "localhost:50032"


def main():
    # スピーカーを指定
    speaker_name = "ディアちゃん"
    style_name = None  # デフォルト指定ならNone
    msg_file = "message.txt" # 読むテキストを保存したファイル
    tasklist = []  # 各スレッドのタスクを保持するためのリスト

    # リクエストのベースを作成
    default_request = get_default_request(speaker_name, style_name)

    with open(msg_file, mode="r", encoding="utf-8") as f:
        # 句点でテキストを分割
        lines = sum([item.split("。") for item in f.readlines()], [])
        # 2スレッドで実施するとエラーっぽいのがengineに出力されるので、一応1
        with ThreadPoolExecutor(max_workers=1) as executor:
            for line in lines:
                # 行に文字がなければスキップ
                if len(''.join(line.split())) == 0:
                    continue
                # 音声データを作成するタスクを追加
                tasklist.append(executor.submit(get_wave_voice, line, default_request))
            for task in tasklist:
                # 音声合成結果を取得し、再生
                wavdata = task.result()
                if wavdata.status_code != 200:
                    continue
                winsound.PlaySound(wavdata.content, winsound.SND_MEMORY)

main()

実際に発話させてみる

あとはrun.exeもしくはengine.exeを起動しておいて、talk.pyを実行すれば良い。

venv環境で実行が面倒であれば、activate.battalk.batなどとしてコピーしておいて、末尾に以下を足せばこのバッチファイルから実行できる(適宜start /MIN /REALTIMEなどとしてよい)。

start python talk.py

コード部分は多いに改善点がありそうだが、とりあえず用は満たしているので良しとする。