Emacs用読み上げ拡張のIvisSpeech対応

しむどん 2024-11-22

Emacsの読み上げ拡張として say.el を実装していた1。初期バージョンでは、macOSに付属している say コマンドを使って実装した。そして先日の作業では、IvisSpeech-Engineを使って読み上げを行う方法を学んだ2。そこで今回は say.el を拡張し、 IvisSpeech-Engine を使って読み上げを行えるようにする。

実装方針

実装の方針は基本的には say.el の既存のものと同じ、つまり以下のようにする。

  1. 標準入力から受け取った文字列を読み上げる、IvisSpeech-Engineを利用した読み上げスクリプトをPythonで実装し、Emacsの子プロセスとして起動しておく。
  2. バッファ上の文字列をマークし say-on-region コマンドを実行すると、リージョンの範囲内の文字列が、スタンバイ状態の子プロセスの標準入力に送られる。
  3. 標準入力から受け取った文字列を処理し読み上げを行う。
  4. 読み上げが完了したら、また標準入力からのデータを待つ。

voicevox_core及びvoicevox_engineもそうだけれど、IvisSpeech-EngineもHTTPサーバとして起動できるようになっており、REST APIを提供できるようになっている。もちろん、それを使う方法もあるが、それは選択しなかった。

標準入力からデータを受ける方法はREST APIと比較すると劣る部分もあるけれど、良い部分もある。それは実装がとてもシンプルになり、本当に必要な処理だけを記述する事ができるという事だ。それによりデバッグも容易になる。個人で使うようなツールは、壮大なライブラリ、フレームワーク、コードになってはいけない。それはきっと、間違いなく管理できなくなる。本当に必要なコードだけを手元に持つ事で、後で見ても処理を追う事ができるし、管理に時間を取られない。できるかぎり抽象化なんてしてはいけない。愚直に、そして最小に実装するのが、手元のツールでは重要だと考えている。だから、今回も小さなスクリプトを実装する。

実装

Pythonスクリプト

Pythonスクリプトでは、標準入力に渡たされた文字列を、音声合成しそれを再生する。一連の処理が終わったら、また標準入力に文字列が渡されるのを待つ。

#! /usr/bin/env python
"""
say_ivisspeech_engine.py

Reads text from standard input, performs speech synthesis, and plays
back speech.

* LICENSE

  This software is free software: you can redistribute it and/or modify
  it under the terms of the GNU General Public License as published by
  the Free Software Foundation, either version 3 of the License, or
  (at your option) any later version.

  This software is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.

"""
import os.path
import pathlib
import sys
import time
import wave

import numpy as np
import sounddevice as sd

from voicevox_engine.aivm_manager import AivmManager
from voicevox_engine.core.core_initializer import MOCK_VER
from voicevox_engine.model import AudioQuery
from voicevox_engine.tts_pipeline.style_bert_vits2_tts_engine import StyleBertVITS2TTSEngine
from voicevox_engine.tts_pipeline.tts_engine import TTSEngineManager

__author__ = "TakesxiSximada"
__version__ = "1"

aivm_dir_path = os.path.expanduser("~/ng/symdon/articles/posts/1732150183")  # AVIMファイルを配置したディレクトリへのパス
aivm_manager = AivmManager(pathlib.PosixPath())
tts_engines = TTSEngineManager()
tts_engines.register_engine(StyleBertVITS2TTSEngine(aivm_manager, False, True), MOCK_VER)
engine = tts_engines.get_engine("0.0.0")
style_id = engine.aivm_manager.get_speakers()[0].styles[0].id

while True:
    text = sys.stdin.read().strip().replace("\n\n", "。")  # 見出しと本文を続けて読まないために、空行を句点で置き換える。

    if len(text) > 30:
        sentences = text.split("。")
    else:
        sentences = [text]

    for sentence in sentences:
        sentence = sentence.strip()
        if not sentence:  # 雑音が入ってしまう事になるため、空文字列の場合は無視する。
            continue

        accent_phrases = engine.create_accent_phrases(sentence, style_id)
        query = AudioQuery(
                accent_phrases=accent_phrases,
                speedScale=1.0,
                intonationScale=1.0,
                tempoDynamicsScale=1.0,
                pitchScale=0.0,
                volumeScale=1.0,
                prePhonemeLength=0.1,
                postPhonemeLength=0.1,
                pauseLength=None,
                pauseLengthScale=1,
                outputSamplingRate=engine.default_sampling_rate,
                outputStereo=False,
                kana=sentence)

        wave_data = engine.synthesize_wave(query, style_id, enable_interrogative_upspeak=True)

        sd.wait()
        sd.play(wave_data, samplerate=44100)  # , device=1)
        print(repr(sentence))

        output_file_path = f"voice-{str(time.time())}.wav"
        with wave.open(output_file_path, 'wb') as wf:
            wf.setnchannels(1)  # モノラル
            wf.setsampwidth(2)  # 16ビット
            wf.setframerate(engine.default_sampling_rate)
            wf.writeframes((wave_data * 32767).astype(np.int16).tobytes())  # WAVファイルに書き出す

say_ivisspeech_engine.py

Emacs Lisp

Emacs Lispでは say_ivisspeech_engine.py を子プロセスとして起動する。リージョン選択された領域の文字列を、その子プロセスに渡す事で発話を行う。

;;; say-ivisspeech.el --- Text To Speech Using IvisSpeech -*- lexical-binding: t; -*-

;; Copyright (C) 2024 TakesxiSximada
;;
;; Author: TakesxiSximada <[email protected]>
;; Maintainer: TakesxiSximada <[email protected]>
;; Keywords: TTS texttospeech
;; Package-Requires: ((emacs "29.1"))

;; Version: 1

;; This file is not part of GNU Emacs.
;;
;; This software is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This software is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.

;;; Code:
(defvar say-ivisspeech-process nil
  "Process that launched the script that uses IvisSpeech-Engine")

(defcustom say-ivisspeech-source-dir (expand-file-name "~/src/AivisSpeech-Engine")
  "IvisSpeech-Engine repository source directory path"
  :type 'string
  :group 'say-ivisspeech)

(defcustom say-ivisspeech-python-executable "python3"
  "Use python binary"
  :type 'string
  :group 'say-ivisspeech)

(defcustom say-ivisspeech-python-script (file-name-concat
					 (file-name-directory (or load-file-name buffer-file-name))
					 "say_ivisspeech_engine.py")
  "say_ivisspeech_engine.py script path"
  :type 'string
  :group 'say-ivisspeech)

(defun say-ivisspeech-start-process ()
  (unless (process-live-p say-ivisspeech-process)
    (setq say-ivisspeech-process
	  (let ((process-environment (append process-environment `(,(format "PYTHONPATH=%s" say-ivisspeech-source-dir))))
		(default-directory say-ivisspeech-source-dir))  ; Model caching does not work unless it is in a fixed directory
	    (start-process "*IVIS*" (get-buffer-create "*IVIS*")
			   say-ivisspeech-python-executable
			   say-ivisspeech-python-script
			   )))))

;;;###autoload
(defun say-ivisspeech-on-region (beg end)
  (interactive "r")
  (say-ivisspeech-start-process)
  (process-send-string say-ivisspeech-process
		       (concat
			;; 1024バイト以上のデータを送信するため^Mをテキス内に差し込む
			(string-replace "。" "。
"
					(string-replace "\n" ""
							(buffer-substring-no-properties beg end)))
			"")))

(provide 'say-ivisspeech)
;;; say-ivisspeech.el ends here

say-ivisspeech.el

実行方法

これらのEmacs Lispを評価しておく。そして、読み上げたい文章をリージョンで選択し、 M-x say-ivisspeech-on-region を実行する。すると、読み上げ用の子プロセスが停止している場合は子プロセスを起動し、読み上げ処理を行う。

確認してみた所、正常に文章の読み上げが行われた。声も良くなり、以前のmacOSのsayコマンドを利用した時と比較すると、かなり聞きやすくなった。

ただ、長文だと読み上げまでに時間がかかったり、文章の最後の部分が切れて発音されてしまったり、マークアップの文字まで読み上げてしまったり、英語をアルファベットで読み上げたりしてしまうなど、改善点もいくつかあった。

まとめ

今回は、IvisSpeech-EngineとPythonを使って、Emacsから読み上げ処理を行う拡張を実装した。正常に文章の読み上げを行えるようになり、かなり聞きやすくなった。一方、読み上げまでの速度や、発音の不備など、幾つかの改善点も見えた。改善点については別の機会に改善したい。