diff --git a/test/test_synthesis_engine.py b/test/test_synthesis_engine.py index f9bfa2078..e155c2649 100644 --- a/test/test_synthesis_engine.py +++ b/test/test_synthesis_engine.py @@ -723,9 +723,14 @@ def result_value(i: int): self.assertEqual(result, true_result) def synthesis_test_base(self, audio_query: AudioQuery): + # Inputs 音素長・モーラ音高の設定 & Expects 音素長・音素ID・モーラ音高の記録 + # Inputs + # `audio_query`: 子音長0.1秒/母音長0.1秒/モーラ音高ランダム + # Expects + # `phoneme_length_list`: 音素長系列 + # `phoneme_id_list`: 音素ID系列 + # `f0_list`: モーラ音高系列 accent_phrases = audio_query.accent_phrases - - # decode forwardのために適当にpitchとlengthを設定し、リストで持っておく phoneme_length_list = [0.0] phoneme_id_list = [0] f0_list = [0.0] @@ -750,42 +755,46 @@ def synthesis_test_base(self, audio_query: AudioQuery): phoneme_length_list.append(0.0) phoneme_id_list.append(0) f0_list.append(0.0) - phoneme_length_list[0] = audio_query.prePhonemeLength phoneme_length_list[-1] = audio_query.postPhonemeLength + # Expects: speedScale適用 for i in range(len(phoneme_length_list)): phoneme_length_list[i] /= audio_query.speedScale + # Outputs: MockCore入りSynthesisEngine の `.synthesis` 出力および core.decode_forward 引数 result = self.synthesis_engine.synthesis(query=audio_query, style_id=1) - - # decodeに渡される値の検証 decode_args = self.decode_mock.call_args[1] list_length = decode_args["length"] + + # Test: フレーム長 self.assertEqual( list_length, int(sum([round(p * 24000 / 256) for p in phoneme_length_list])), ) + # Expects: Apply/Convert/Rescale num_phoneme = OjtPhoneme.num_phoneme # mora_phoneme_listのPhoneme ID版 mora_phoneme_id_list = [OjtPhoneme(p).phoneme_id for p in mora_phoneme_list] - # numpy.repeatをfor文でやる - f0 = [] - phoneme = [] + f0 = [] # フレームごとの音高系列 + phoneme = [] # フレームごとの音素onehotベクトル系列 f0_index = 0 mean_f0 = [] for i, phoneme_length in enumerate(phoneme_length_list): + # Expects: pitchScale適用 f0_single = numpy.array(f0_list[f0_index], dtype=numpy.float32) * ( 2**audio_query.pitchScale ) + # Expects: フレームスケール化 for _ in range(int(round(phoneme_length * (24000 / 256)))): f0.append([f0_single]) + # Expects: 音素onehot化 phoneme_s = [] for _ in range(num_phoneme): phoneme_s.append(0) - # one hot + # Expects: 音素フレームスケール化 phoneme_s[phoneme_id_list[i]] = 1 phoneme.append(phoneme_s) # consonantとvowelを判別し、vowelであればf0_indexを一つ進める @@ -793,44 +802,56 @@ def synthesis_test_base(self, audio_query: AudioQuery): if f0_single > 0: mean_f0.append(f0_single) f0_index += 1 - + # Expects: 抑揚スケール適用 mean_f0 = numpy.array(mean_f0, dtype=numpy.float32).mean() f0 = numpy.array(f0, dtype=numpy.float32) for i in range(len(f0)): if f0[i][0] != 0.0: f0[i][0] = (f0[i][0] - mean_f0) * audio_query.intonationScale + mean_f0 - phoneme = numpy.array(phoneme, dtype=numpy.float32) + assert_f0_count = 0 + + # Outputs: decode_forward `f0` 引数 + decode_f0 = decode_args["f0"] + + # Test: フレームごとの音高系列 # 乱数の影響で数値の位置がずれが生じるので、大半(4/5)があっていればよしとする # また、上の部分のint(round(phoneme_length * (24000 / 256)))の影響で # 本来のf0/phonemeとテスト生成したf0/phonemeの長さが変わることがあり、 # テスト生成したものが若干長くなることがあるので、本来のものの長さを基準にassertする - assert_f0_count = 0 - decode_f0 = decode_args["f0"] for i in range(len(decode_f0)): # 乱数の影響等で数値にずれが生じるので、10の-5乗までの近似値であれば許容する assert_f0_count += math.isclose(f0[i][0], decode_f0[i][0], rel_tol=10e-5) self.assertTrue(assert_f0_count >= int(len(decode_f0) / 5) * 4) + assert_phoneme_count = 0 + + # Outputs: decode_forward `phoneme` 引数 decode_phoneme = decode_args["phoneme"] + + # Test: フレームごとの音素系列 for i in range(len(decode_phoneme)): assert_true_count = 0 for j in range(len(decode_phoneme[i])): assert_true_count += bool(phoneme[i][j] == decode_phoneme[i][j]) assert_phoneme_count += assert_true_count == num_phoneme + self.assertTrue(assert_phoneme_count >= int(len(decode_phoneme) / 5) * 4) + + # Test: スタイルID self.assertEqual(decode_args["style_id"], 1) - # decode forwarderのmockを使う + # Expects: waveform (by mock) true_result = decode_mock(list_length, num_phoneme, f0, phoneme, 1) - + # Expects: 音量スケール適用 true_result *= audio_query.volumeScale # TODO: resampyの部分は値の検証しようがないので、パスする if audio_query.outputSamplingRate != 24000: return + # Test: assert_result_count = 0 for i in range(len(true_result)): if audio_query.outputStereo: diff --git a/voicevox_engine/full_context_label.py b/voicevox_engine/full_context_label.py index 894a56751..5ca599276 100644 --- a/voicevox_engine/full_context_label.py +++ b/voicevox_engine/full_context_label.py @@ -519,6 +519,17 @@ def labels(self): def extract_full_context_label(text: str): + """ + 日本語テキストから発話クラスを抽出 + Parameters + ---------- + text : str + 日本語テキスト + Returns + ------- + utterance : Utterance + 発話 + """ labels = pyopenjtalk.extract_fullcontext(text) phonemes = [Phoneme.from_label(label=label) for label in labels] utterance = Utterance.from_phonemes(phonemes) diff --git a/voicevox_engine/kana_parser.py b/voicevox_engine/kana_parser.py index 8e0ff845a..14efb4672 100644 --- a/voicevox_engine/kana_parser.py +++ b/voicevox_engine/kana_parser.py @@ -1,15 +1,23 @@ +""" +「AquesTalk風記法」を実装した AquesTalk風記法テキスト <-> アクセント句系列 変換。 +記法定義: `https://github.com/VOICEVOX/voicevox_engine/blob/master/README.md#読み方を-aquestalk風記法で取得修正するサンプルコード` # noqa +""" + from typing import List, Optional from .model import AccentPhrase, Mora, ParseKanaError, ParseKanaErrorCode from .mora_list import openjtalk_text2mora _LOOP_LIMIT = 300 -_UNVOICE_SYMBOL = "_" -_ACCENT_SYMBOL = "'" -_NOPAUSE_DELIMITER = "/" -_PAUSE_DELIMITER = "、" -_WIDE_INTERROGATION_MARK = "?" +# AquesTalk風記法特殊文字 +_UNVOICE_SYMBOL = "_" # 無声化 +_ACCENT_SYMBOL = "'" # アクセント位置 +_NOPAUSE_DELIMITER = "/" # ポーズ無しアクセント句境界 +_PAUSE_DELIMITER = "、" # ポーズ有りアクセント句境界 +_WIDE_INTERROGATION_MARK = "?" # 疑問形 + +# AquesTalk風記法とモーラの対応(音素長・音高 0 初期化、疑問形 off 初期化) _text2mora_with_unvoice = {} for text, (consonant, vowel) in openjtalk_text2mora.items(): _text2mora_with_unvoice[text] = Mora( @@ -22,6 +30,8 @@ is_interrogative=False, ) if vowel in ["a", "i", "u", "e", "o"]: + # 手前に`_`を入れると無声化 + # 例: "_ホ" -> "hO" _text2mora_with_unvoice[_UNVOICE_SYMBOL + text] = Mora( text=text, consonant=consonant if len(consonant) > 0 else None, @@ -35,9 +45,19 @@ def _text_to_accent_phrase(phrase: str) -> AccentPhrase: """ - longest matchにより読み仮名からAccentPhraseを生成 - 入力長Nに対し計算量O(N^2) + 単一アクセント句に相当するAquesTalk風記法テキストからアクセント句オブジェクトを生成 + longest matchによりモーラ化。入力長Nに対し計算量O(N^2)。 + Parameters + ---------- + phrase : str + 単一アクセント句に相当するAquesTalk風記法テキスト + Returns + ------- + accent_phrase : AccentPhrase + アクセント句 """ + # NOTE: ポーズと疑問形はこの関数内で処理しない + accent_index: Optional[int] = None moras: List[Mora] = [] @@ -48,24 +68,33 @@ def _text_to_accent_phrase(phrase: str) -> AccentPhrase: outer_loop = 0 while base_index < len(phrase): outer_loop += 1 + + # `'`の手前がアクセント位置 if phrase[base_index] == _ACCENT_SYMBOL: if len(moras) == 0: raise ParseKanaError(ParseKanaErrorCode.ACCENT_TOP, text=phrase) + # すでにアクセント位置がある場合はエラー if accent_index is not None: raise ParseKanaError(ParseKanaErrorCode.ACCENT_TWICE, text=phrase) accent_index = len(moras) base_index += 1 continue + + # モーラ探索 + # より長い要素からなるモーラが見つかれば上書き(longest match) + # 例: phrase "キャ" -> "キ" 検出 -> "キャ" 検出/上書き -> Mora("キャ") for watch_index in range(base_index, len(phrase)): + # アクセント位置特殊文字が来たら探索打ち切り if phrase[watch_index] == _ACCENT_SYMBOL: break - # 普通の文字の場合 stack += phrase[watch_index] if stack in _text2mora_with_unvoice: + # より長い要素からなるモーラが見つかれば上書き(longest match) + # 例: phrase "キャ" -> "キ" 検出 -> "キャ" 検出/上書き -> Mora("キャ") matched_text = stack - # push mora if matched_text is None: raise ParseKanaError(ParseKanaErrorCode.UNKNOWN_TEXT, text=stack) + # push mora else: moras.append(_text2mora_with_unvoice[matched_text].copy(deep=True)) base_index += len(matched_text) @@ -81,7 +110,15 @@ def _text_to_accent_phrase(phrase: str) -> AccentPhrase: def parse_kana(text: str) -> List[AccentPhrase]: """ - AquesTalk風記法テキストをパースして音長・音高未指定のaccent phraseに変換 + AquesTalk風記法テキストからアクセント句系列を生成 + Parameters + ---------- + text : str + AquesTalk風記法テキスト + Returns + ------- + parsed_results : List[AccentPhrase] + アクセント句(音素・モーラ音高 0初期化)系列を生成 """ parsed_results: List[AccentPhrase] = [] @@ -90,6 +127,7 @@ def parse_kana(text: str) -> List[AccentPhrase]: raise ParseKanaError(ParseKanaErrorCode.EMPTY_PHRASE, position=1) for i in range(len(text) + 1): + # アクセント句境界(`/`か`、`)の出現までインデックス進展 if i == len(text) or text[i] in [_PAUSE_DELIMITER, _NOPAUSE_DELIMITER]: phrase = text[phrase_base:i] if len(phrase) == 0: @@ -99,15 +137,19 @@ def parse_kana(text: str) -> List[AccentPhrase]: ) phrase_base = i + 1 + # アクセント句末に`?`で疑問文 is_interrogative = _WIDE_INTERROGATION_MARK in phrase if is_interrogative: if _WIDE_INTERROGATION_MARK in phrase[:-1]: raise ParseKanaError( ParseKanaErrorCode.INTERROGATION_MARK_NOT_AT_END, text=phrase ) + # 疑問形はモーラでなくアクセント句属性で表現 phrase = phrase.replace(_WIDE_INTERROGATION_MARK, "") accent_phrase: AccentPhrase = _text_to_accent_phrase(phrase) + + # `、`で無音区間を挿入 if i < len(text) and text[i] == _PAUSE_DELIMITER: accent_phrase.pause_mora = Mora( text="、", @@ -125,22 +167,38 @@ def parse_kana(text: str) -> List[AccentPhrase]: def create_kana(accent_phrases: List[AccentPhrase]) -> str: + """ + アクセント句系列からAquesTalk風記法テキストを生成 + Parameters + ---------- + accent_phrases : List[AccentPhrase] + アクセント句系列 + Returns + ------- + text : str + AquesTalk風記法テキスト + """ text = "" + # アクセント句を先頭から逐次パースし、`text`末尾にAquesTalk風記法の文字を都度追加(ループ) for i, phrase in enumerate(accent_phrases): for j, mora in enumerate(phrase.moras): + # Rule3: "カナの手前に`_`を入れるとそのカナは無声化される" if mora.vowel in ["A", "I", "U", "E", "O"]: text += _UNVOICE_SYMBOL - text += mora.text + # `'`でアクセント位置 if j + 1 == phrase.accent: text += _ACCENT_SYMBOL + # Rule5: "アクセント句末に`?`(全角)を入れることにより疑問文の発音ができる" if phrase.is_interrogative: text += _WIDE_INTERROGATION_MARK if i < len(accent_phrases) - 1: if phrase.pause_mora is None: + # アクセント句区切り text += _NOPAUSE_DELIMITER else: + # 無音でアクセント句区切り text += _PAUSE_DELIMITER return text diff --git a/voicevox_engine/synthesis_engine/synthesis_engine_base.py b/voicevox_engine/synthesis_engine/synthesis_engine_base.py index fde453574..6a139a830 100644 --- a/voicevox_engine/synthesis_engine/synthesis_engine_base.py +++ b/voicevox_engine/synthesis_engine/synthesis_engine_base.py @@ -11,6 +11,16 @@ def mora_to_text(mora: str) -> str: + """ + Parameters + ---------- + mora : str + モーラ音素文字列 + Returns + ------- + mora : str + モーラ音素文字列 + """ if mora[-1:] in ["A", "I", "U", "E", "O"]: # 無声化母音を小文字に mora = mora[:-1] + mora[-1].lower() @@ -24,10 +34,18 @@ def adjust_interrogative_accent_phrases( accent_phrases: List[AccentPhrase], ) -> List[AccentPhrase]: """ - enable_interrogative_upspeakが有効になっていて与えられたaccent_phrasesに疑問系のものがあった場合、 - 各accent_phraseの末尾にある疑問系発音用のMoraに対して直前のMoraより少し音を高くすることで疑問文ぽくする - NOTE: リファクタリング時に適切な場所へ移動させること + アクセント句系列の必要に応じて疑問系に補正 + 各accent_phraseの末尾のモーラより少し音の高い有声母音モーラを付与するすることで疑問文ぽくする + Parameters + ---------- + accent_phrases : List[AccentPhrase] + アクセント句系列 + Returns + ------- + accent_phrases : List[AccentPhrase] + 必要に応じて疑問形補正されたアクセント句系列 """ + # NOTE: リファクタリング時に適切な場所へ移動させること return [ AccentPhrase( moras=adjust_interrogative_moras(accent_phrase), @@ -40,7 +58,19 @@ def adjust_interrogative_accent_phrases( def adjust_interrogative_moras(accent_phrase: AccentPhrase) -> List[Mora]: + """ + アクセント句に含まれるモーラ系列の必要に応じた疑問形補正 + Parameters + ---------- + accent_phrase : AccentPhrase + アクセント句 + Returns + ------- + moras : List[Mora] + 補正済みモーラ系列 + """ moras = copy.deepcopy(accent_phrase.moras) + # 疑問形補正条件: 疑問形フラグON & 終端有声母音 if accent_phrase.is_interrogative and not (len(moras) == 0 or moras[-1].pitch == 0): interrogative_mora = make_interrogative_mora(moras[-1]) moras.append(interrogative_mora) @@ -50,6 +80,17 @@ def adjust_interrogative_moras(accent_phrase: AccentPhrase) -> List[Mora]: def make_interrogative_mora(last_mora: Mora) -> Mora: + """ + 疑問形用のモーラ(同一母音・継続長 0.15秒・音高↑)の生成 + Parameters + ---------- + last_mora : Mora + 疑問形にするモーラ + Returns + ------- + mora : Mora + 疑問形用のモーラ + """ fix_vowel_length = 0.15 adjust_pitch = 0.3 max_pitch = 6.5 @@ -66,6 +107,17 @@ def make_interrogative_mora(last_mora: Mora) -> Mora: def full_context_label_moras_to_moras( full_context_moras: List[full_context_label.Mora], ) -> List[Mora]: + """ + Moraクラスのキャスト (`full_context_label.Mora` -> `Mora`) + Parameters + ---------- + full_context_moras : List[full_context_label.Mora] + モーラ系列 + Returns + ------- + moras : List[Mora] + モーラ系列。音素長・モーラ音高は 0 初期化 + """ return [ Mora( text=mora_to_text("".join([p.phoneme for p in mora.phonemes])), @@ -85,25 +137,30 @@ class SynthesisEngineBase(metaclass=ABCMeta): def default_sampling_rate(self) -> int: raise NotImplementedError - # FIXME: jsonではなくModelを返すようにする @property @abstractmethod def speakers(self) -> str: + """話者情報(json文字列)""" + # FIXME: jsonではなくModelを返すようにする raise NotImplementedError @property @abstractmethod def supported_devices(self) -> Optional[str]: + """ + デバイス対応情報 + Returns + ------- + 対応デバイス一覧(None: 情報取得不可) + """ raise NotImplementedError def initialize_style_id_synthesis( # noqa: B027 - self, - style_id: int, - skip_reinit: bool, + self, style_id: int, skip_reinit: bool ): """ - 指定したスタイルでの音声合成を初期化する。何度も実行可能。 - 未実装の場合は何もしない + 指定したスタイルでの音声合成を初期化する。 + 何度も実行可能。未実装の場合は何もしない。 Parameters ---------- style_id : int @@ -132,62 +189,86 @@ def replace_phoneme_length( self, accent_phrases: List[AccentPhrase], style_id: int ) -> List[AccentPhrase]: """ - accent_phrasesの母音・子音の長さを設定する + 音素長の更新 Parameters ---------- accent_phrases : List[AccentPhrase] - アクセント句モデルのリスト + アクセント句系列 style_id : int スタイルID Returns ------- accent_phrases : List[AccentPhrase] - 母音・子音の長さが設定されたアクセント句モデルのリスト + 音素長が更新されたアクセント句系列 """ raise NotImplementedError() @abstractmethod def replace_mora_pitch( - self, - accent_phrases: List[AccentPhrase], - style_id: int, + self, accent_phrases: List[AccentPhrase], style_id: int ) -> List[AccentPhrase]: """ - accent_phrasesの音高(ピッチ)を設定する + モーラ音高の更新 Parameters ---------- accent_phrases : List[AccentPhrase] - アクセント句モデルのリスト + アクセント句系列 style_id : int スタイルID Returns ------- accent_phrases : List[AccentPhrase] - 音高(ピッチ)が設定されたアクセント句モデルのリスト + モーラ音高が更新されたアクセント句系列 """ raise NotImplementedError() def replace_mora_data( - self, - accent_phrases: List[AccentPhrase], - style_id: int, + self, accent_phrases: List[AccentPhrase], style_id: int ) -> List[AccentPhrase]: + """ + 音素長・モーラ音高の更新 + Parameters + ---------- + accent_phrases : List[AccentPhrase] + アクセント句系列 + style_id : int + スタイルID + Returns + ------- + accent_phrases : List[AccentPhrase] + アクセント句系列 + """ return self.replace_mora_pitch( accent_phrases=self.replace_phoneme_length( - accent_phrases=accent_phrases, - style_id=style_id, + accent_phrases=accent_phrases, style_id=style_id ), style_id=style_id, ) def create_accent_phrases(self, text: str, style_id: int) -> List[AccentPhrase]: + """ + テキストからアクセント句系列を生成。 + 音素長やモーラ音高も更新。 + Parameters + ---------- + text : str + 日本語テキスト + style_id : int + スタイルID + Returns + ------- + accent_phrases : List[AccentPhrase] + アクセント句系列 + """ if len(text.strip()) == 0: return [] + # 音素とアクセントの推定 utterance = extract_full_context_label(text) if len(utterance.breath_groups) == 0: return [] + # Utterance -> List[AccentPharase] のキャスト & 音素長・モーラ音高の推定と更新 accent_phrases = self.replace_mora_data( accent_phrases=[ AccentPhrase(