diff --git a/style_bert_vits2/models/infer.py b/style_bert_vits2/models/infer.py index a9bcbf6..da416b8 100644 --- a/style_bert_vits2/models/infer.py +++ b/style_bert_vits2/models/infer.py @@ -102,6 +102,7 @@ def get_text( device: str, assist_text: Optional[str] = None, assist_text_weight: float = 0.7, + given_phone: Optional[list[str]] = None, given_tone: Optional[list[int]] = None, ): use_jp_extra = hps.version.endswith("JP-Extra") @@ -112,10 +113,43 @@ def get_text( use_jp_extra=use_jp_extra, raise_yomi_error=False, ) - if given_tone is not None: - if len(given_tone) != len(phone): + # phone と tone の両方が与えられた場合はそれを使う + if given_phone is not None and given_tone is not None: + # 指定された phone と指定された tone 両方の長さが一致していなければならない + if len(given_phone) != len(given_tone): + raise InvalidPhoneError( + f"Length of given_phone ({len(given_phone)}) != length of given_tone ({len(given_tone)})" + ) + # 与えられた音素数と pyopenjtalk で生成した読みの音素数が一致しない + if len(given_phone) != sum(word2ph): + # 日本語の場合、len(given_phone) と sum(word2ph) が一致するように word2ph を適切に調整する + # 他の言語は word2ph の調整方法が思いつかないのでエラー + if language_str == Languages.JP: + from style_bert_vits2.nlp.japanese.g2p import adjust_word2ph + word2ph = adjust_word2ph(word2ph, phone, given_phone) + # 上記処理により word2ph の合計が given_phone の長さと一致するはず + # それでも一致しない場合、大半は読み上げテキストと given_phone が著しく乖離していて調整し切れなかったことを意味する + if len(given_phone) != sum(word2ph): + raise InvalidPhoneError( + f"Length of given_phone ({len(given_phone)}) != sum of word2ph ({sum(word2ph)})" + ) + else: + raise InvalidPhoneError( + f"Length of given_phone ({len(given_phone)}) != sum of word2ph ({sum(word2ph)})" + ) + phone = given_phone + # 生成あるいは指定された phone と指定された tone 両方の長さが一致していなければならない + if len(phone) != len(given_tone): raise InvalidToneError( - f"Length of given_tone ({len(given_tone)}) != length of phone ({len(phone)})" + f"Length of phone ({len(phone)}) != length of given_tone ({len(given_tone)})" + ) + tone = given_tone + # tone だけが与えられた場合は clean_text() で生成した phone と合わせて使う + elif given_tone is not None: + # 生成した phone と指定された tone 両方の長さが一致していなければならない + if len(phone) != len(given_tone): + raise InvalidToneError( + f"Length of phone ({len(phone)}) != length of given_tone ({len(given_tone)})" ) tone = given_tone phone, tone, language = cleaned_text_to_sequence(phone, tone, language_str) @@ -179,6 +213,7 @@ def infer( skip_end: bool = False, assist_text: Optional[str] = None, assist_text_weight: float = 0.7, + given_phone: Optional[list[str]] = None, given_tone: Optional[list[int]] = None, ): is_jp_extra = hps.version.endswith("JP-Extra") @@ -189,6 +224,7 @@ def infer( device, assist_text=assist_text, assist_text_weight=assist_text_weight, + given_phone=given_phone, given_tone=given_tone, ) if skip_start: @@ -263,5 +299,8 @@ def infer( return audio +class InvalidPhoneError(ValueError): + pass + class InvalidToneError(ValueError): pass diff --git a/style_bert_vits2/nlp/japanese/g2p.py b/style_bert_vits2/nlp/japanese/g2p.py index 1f6b450..8038573 100644 --- a/style_bert_vits2/nlp/japanese/g2p.py +++ b/style_bert_vits2/nlp/japanese/g2p.py @@ -1,4 +1,5 @@ import re +from typing import TypedDict from style_bert_vits2.constants import Languages from style_bert_vits2.logging import logger @@ -23,7 +24,7 @@ def g2p( Args: norm_text (str): 正規化されたテキスト use_jp_extra (bool, optional): False の場合、「ん」の音素を「N」ではなく「n」とする。Defaults to True. - raise_yomi_error (bool, optional): False の場合、読めない文字が消えたような扱いとして処理される。Defaults to False. + raise_yomi_error (bool, optional): False の場合、読めない文字が「ん」として発音される。Defaults to False. Returns: tuple[list[str], list[int], list[int]]: 音素のリスト、アクセントのリスト、word2ph のリスト @@ -38,8 +39,8 @@ def g2p( # punctuation がすべて消えた、音素とアクセントのタプルのリスト(「ん」は「N」) phone_tone_list_wo_punct = __g2phone_tone_wo_punct(norm_text) - # sep_text: 単語単位の単語のリスト、読めない文字があったら raise_yomi_error なら例外、そうでないなら読めない文字が消えて返ってくる - # sep_kata: 単語単位の単語のカタカナ読みのリスト + # sep_text: 単語単位の単語のリスト + # sep_kata: 単語単位の単語のカタカナ読みのリスト、読めない文字は raise_yomi_error=True なら例外、False なら読めない文字を「ン」として返ってくる sep_text, sep_kata = text_to_sep_kata(norm_text, raise_yomi_error=raise_yomi_error) # sep_phonemes: 各単語ごとの音素のリストのリスト @@ -103,7 +104,7 @@ def text_to_sep_kata( Args: norm_text (str): 正規化されたテキスト - raise_yomi_error (bool, optional): False の場合、読めない文字が消えたような扱いとして処理される。Defaults to False. + raise_yomi_error (bool, optional): False の場合、読めない文字が「ん」として発音される。Defaults to False. Returns: tuple[list[str], list[str]]: 分割された単語リストと、その読み(カタカナ or 記号1文字)のリスト @@ -137,12 +138,17 @@ def text_to_sep_kata( # word は正規化されているので、`.`, `,`, `!`, `'`, `-`, `--` のいずれか if not set(word).issubset(set(PUNCTUATIONS)): # 記号繰り返しか判定 # ここは pyopenjtalk が読めない文字等のときに起こる + ## 例外を送出する場合 if raise_yomi_error: raise YomiError(f"Cannot read: {word} in:\n{norm_text}") - logger.warning(f"Ignoring unknown: {word} in:\n{norm_text}") - continue - # yomi は元の記号のままに変更 - yomi = word + ## 例外を送出しない場合 + ## 読めない文字は「ん」として扱う + logger.warning(f"Cannot read: {word} in:\n{norm_text}, replaced with 'ん'") + # word の文字数分「ん」を追加 + yomi = "ン" * len(word) + else: + # yomi は元の記号のままに変更 + yomi = word elif yomi == "?": assert word == "?", f"yomi `?` comes from: {word}" yomi = "?" @@ -152,6 +158,188 @@ def text_to_sep_kata( return sep_text, sep_kata +def adjust_word2ph( + word2ph: list[int], generated_phone: list[str], given_phone: list[str], +) -> list[int]: + """ + `g2p()` で得られた `word2ph` を、generated_phone と given_phone の差分情報を使っていい感じに調整する。 + generated_phone は正規化された読み上げテキストから生成された読みの情報だが、 + given_phone で 同じ読み上げテキストに異なる読みが与えられた場合、正規化された読み上げテキストの各文字に + 音素が何文字割り当てられるかを示す word2ph の合計値が given_phone の長さ (音素数) と一致しなくなりうる + そこで generated_phone と given_phone の差分を取り変更箇所に対応する word2ph の要素の値だけを増減させ、 + アクセントへの影響を最低限に抑えつつ word2ph の合計値を given_phone の長さ (音素数) に一致させる。 + + Args: + word2ph (list[int]): 単語ごとの音素の数のリスト + generated_phone (list[str]): 生成された音素のリスト + given_phone (list[str]): 与えられた音素のリスト + + Returns: + list[int]: 修正された word2ph のリスト + """ + + # word2ph・generated_phone・given_phone 全ての先頭と末尾にダミー要素が入っているので、処理の都合上それらを削除 + # word2ph は先頭と末尾に 1 が入っている (返す際に再度追加する) + word2ph = word2ph[1:-1] + generated_phone = generated_phone[1:-1] + given_phone = given_phone[1:-1] + + class DiffDetail(TypedDict): + begin_index: int + end_index: int + value: list[str] + class Diff(TypedDict): + generated: DiffDetail + given: DiffDetail + + def extract_differences(generated_phone: list[str], given_phone: list[str]) -> list[Diff]: + """ + 最長共通部分列を基にして、二つのリストの異なる部分を抽出する。 + """ + + def longest_common_subsequence(X: list[str], Y: list[str]) -> list[tuple[int, int]]: + """ + 二つのリストの最長共通部分列のインデックスのペアを返す。 + """ + m, n = len(X), len(Y) + L = [[0] * (n + 1) for _ in range(m + 1)] + # LCSの長さを構築 + for i in range(1, m + 1): + for j in range(1, n + 1): + if X[i - 1] == Y[j - 1]: + L[i][j] = L[i - 1][j - 1] + 1 + else: + L[i][j] = max(L[i - 1][j], L[i][j - 1]) + # LCSを逆方向にトレースしてインデックスのペアを取得 + index_pairs = [] + i, j = m, n + while i > 0 and j > 0: + if X[i - 1] == Y[j - 1]: + index_pairs.append((i - 1, j - 1)) + i -= 1 + j -= 1 + elif L[i - 1][j] >= L[i][j - 1]: + i -= 1 + else: + j -= 1 + index_pairs.reverse() + return index_pairs + + differences = [] + common_indices = longest_common_subsequence(generated_phone, given_phone) + prev_x, prev_y = -1, -1 + + # 共通部分のインデックスを基にして差分を抽出 + for (x, y) in common_indices: + diff_X = {"begin_index": prev_x + 1, "end_index": x, "value": generated_phone[prev_x + 1:x]} + diff_Y = {"begin_index": prev_y + 1, "end_index": y, "value": given_phone[prev_y + 1:y]} + if diff_X or diff_Y: + differences.append({"generated": diff_X, "given": diff_Y}) + prev_x, prev_y = x, y + # 最後の非共通部分を追加 + if prev_x < len(generated_phone) - 1 or prev_y < len(given_phone) - 1: + differences.append({ + "generated": {"begin_index": prev_x + 1, "end_index": len(generated_phone) - 1, "value": generated_phone[prev_x + 1:len(generated_phone) - 1]}, + "given": {"begin_index": prev_y + 1, "end_index": len(given_phone) - 1, "value": given_phone[prev_y + 1:len(given_phone) - 1]} + }) + # generated.value と given.value の両方が空の要素を diffrences から削除 + for diff in differences[:]: + if len(diff["generated"]["value"]) == 0 and len(diff["given"]["value"]) == 0: + differences.remove(diff) + + return differences + + # 二つのリストの差分を抽出 + differences = extract_differences(generated_phone, given_phone) + + # word2ph をもとにして新しく作る word2ph のリスト + ## 長さは word2ph と同じだが、中身は 0 で初期化されている + adjusted_word2ph: list[int] = [0] * len(word2ph) + # 現在処理中の generated_phone のインデックス + current_generated_index = 0 + + # word2ph の要素数 (=正規化された読み上げテキストの文字数) を維持しながら、差分情報を使って word2ph を修正 + ## 音素数が generated_phone と given_phone で異なる場合にこの align_word2ph() が呼び出される + ## word2ph は正規化された読み上げテキストの文字数に対応しているので、要素数はそのまま given_phone で増減した音素数に合わせて各要素の値を増減する + for word2ph_element_index, word2ph_element in enumerate(word2ph): + # ここの word2ph_element は、正規化された読み上げテキストの各文字に割り当てられる音素の数を示す + # 例えば word2ph_element が 2 ならば、その文字には 2 つの音素 (例: "k", "a") が割り当てられる + # 音素の数だけループを回す + for _ in range(word2ph_element): + # difference の中に 処理中の generated_phone から始まる差分があるかどうかを確認 + current_diff: Diff | None = None + for diff in differences: + if diff["generated"]["begin_index"] == current_generated_index: + current_diff = diff + break + # current_diff が None でない場合、generated_phone から始まる差分がある + if current_diff is not None: + # generated から given で変わった音素数の差分を取得 (2増えた場合は +2 だし、2減った場合は -2) + diff_in_phonemes = len(current_diff["given"]["value"]) - len(current_diff["generated"]["value"]) + # adjusted_word2ph[(読み上げテキストの各文字のインデックス)] に上記差分を反映 + adjusted_word2ph[word2ph_element_index] += diff_in_phonemes + # adjusted_word2ph[(読み上げテキストの各文字のインデックス)] に処理が完了した分の音素として 1 を加える + adjusted_word2ph[word2ph_element_index] += 1 + # 処理中の generated_phone のインデックスを進める + current_generated_index += 1 + + # この時点で given_phone の長さと adjusted_word2ph に記録されている音素数の合計が一致しているはず + assert len(given_phone) == sum(adjusted_word2ph), f"{len(given_phone)} != {sum(adjusted_word2ph)}" + + # generated_phone から given_phone の間で音素が減った場合 (例: a, sh, i, t, a -> a, s, u) 、 + # adjusted_word2ph の要素の値が 1 未満になることがあるので、1 になるように値を増やす + ## この時、adjusted_word2ph に記録されている音素数の合計を変えないために、 + ## 値を 1 にした分だけ右隣の要素から増やした分の差分を差し引く + for adjusted_word2ph_element_index, adjusted_word2ph_element in enumerate(adjusted_word2ph): + # もし現在の要素が 1 未満ならば + if adjusted_word2ph_element < 1: + # 値を 1 にするためにどれだけ足せばいいかを計算 + diff = 1 - adjusted_word2ph_element + # adjusted_word2ph[(読み上げテキストの各文字のインデックス)] を 1 にする + # これにより、当該文字に最低ラインとして 1 つの音素が割り当てられる + adjusted_word2ph[adjusted_word2ph_element_index] = 1 + # 次の要素のうち、一番近くてかつ 1 以上の要素から diff を引く + # この時、diff を引いた結果引いた要素が 1 未満になる場合は、その要素の次の要素の中から一番近くてかつ 1 以上の要素から引く + # 上記を繰り返していって、diff が 0 になるまで続ける + for i in range(1, len(adjusted_word2ph)): + if adjusted_word2ph_element_index + i >= len(adjusted_word2ph): + break # adjusted_word2ph の最後に達した場合は諦める + if adjusted_word2ph[adjusted_word2ph_element_index + i] - diff >= 1: + adjusted_word2ph[adjusted_word2ph_element_index + i] -= diff + break + else: + diff -= adjusted_word2ph[adjusted_word2ph_element_index + i] - 1 + adjusted_word2ph[adjusted_word2ph_element_index + i] = 1 + if diff == 0: + break + + # 逆に、generated_phone から given_phone の間で音素が増えた場合 (例: a, s, u -> a, sh, i, t, a) 、 + # 1文字あたり7音素以上も割り当てられてしまう場合があるので、最大6音素にした上で削った分の差分を次の要素に加える + # 次の要素に差分を加えた結果7音素以上になってしまう場合は、その差分をさらに次の要素に加える + for adjusted_word2ph_element_index, adjusted_word2ph_element in enumerate(adjusted_word2ph): + if adjusted_word2ph_element > 6: + diff = adjusted_word2ph_element - 6 + adjusted_word2ph[adjusted_word2ph_element_index] = 6 + for i in range(1, len(adjusted_word2ph)): + if adjusted_word2ph_element_index + i >= len(adjusted_word2ph): + break # adjusted_word2ph の最後に達した場合は諦める + if adjusted_word2ph[adjusted_word2ph_element_index + i] + diff <= 6: + adjusted_word2ph[adjusted_word2ph_element_index + i] += diff + break + else: + diff -= 6 - adjusted_word2ph[adjusted_word2ph_element_index + i] + adjusted_word2ph[adjusted_word2ph_element_index + i] = 6 + if diff == 0: + break + + # この時点で given_phone の長さと adjusted_word2ph に記録されている音素数の合計が一致していない場合、 + # 正規化された読み上げテキストと given_phone が著しく乖離していることを示す + # このとき、この関数の呼び出し元の get_text() にて InvalidPhoneError が送出される + + # 最初に削除した前後のダミー要素を追加して返す + return [1] + adjusted_word2ph + [1] + + def __g2phone_tone_wo_punct(text: str) -> list[tuple[str, int]]: """ テキストに対して、音素とアクセント(0か1)のペアのリストを返す。 @@ -446,6 +634,10 @@ def __align_tones( elif phone in PUNCTUATIONS: # phone が punctuation の場合 → (phone, 0) を追加 result.append((phone, 0)) + elif phone == 'N' or phone == 'n': + # ここに到達するのは raise_yomi_error=False 時に読めない文字を「ん」に代替した場合のみ + # この際、phone_tone_list には「ん」の音素は含まれていないので、(phone, 0) を追加 + result.append((phone, 0)) else: logger.debug(f"phones: {phones_with_punct}") logger.debug(f"phone_tone_list: {phone_tone_list}") diff --git a/style_bert_vits2/tts_model.py b/style_bert_vits2/tts_model.py index 1280308..dbead9e 100644 --- a/style_bert_vits2/tts_model.py +++ b/style_bert_vits2/tts_model.py @@ -29,7 +29,6 @@ from style_bert_vits2.models.models import SynthesizerTrn from style_bert_vits2.models.models_jp_extra import ( SynthesizerTrn as SynthesizerTrnJPExtra, ) -from style_bert_vits2.nlp import bert_models from style_bert_vits2.voice import adjust_voice @@ -155,6 +154,7 @@ class TTSModel: use_assist_text: bool = False, style: str = DEFAULT_STYLE, style_weight: float = DEFAULT_STYLE_WEIGHT, + given_phone: Optional[list[str]] = None, given_tone: Optional[list[int]] = None, pitch_scale: float = 1.0, intonation_scale: float = 1.0, @@ -171,13 +171,14 @@ class TTSModel: noise (float, optional): DP に与えられるノイズ. Defaults to DEFAULT_NOISE. noise_w (float, optional): SDP に与えられるノイズ. Defaults to DEFAULT_NOISEW. length (float, optional): 生成音声の長さ(話速)のパラメータ。大きいほど生成音声が長くゆっくり、小さいほど短く早くなる。 Defaults to DEFAULT_LENGTH. - line_split (bool, optional): テキストを改行ごとに分割して生成するかどうか. Defaults to DEFAULT_LINE_SPLIT. + line_split (bool, optional): テキストを改行ごとに分割して生成するかどうか (True の場合 given_phone/given_tone は無視される). Defaults to DEFAULT_LINE_SPLIT. split_interval (float, optional): 改行ごとに分割する場合の無音 (秒). Defaults to DEFAULT_SPLIT_INTERVAL. assist_text (Optional[str], optional): 感情表現の参照元の補助テキスト. Defaults to None. assist_text_weight (float, optional): 感情表現の補助テキストを適用する強さ. Defaults to DEFAULT_ASSIST_TEXT_WEIGHT. use_assist_text (bool, optional): 音声合成時に感情表現の補助テキストを使用するかどうか. Defaults to False. style (str, optional): 音声スタイル (Neutral, Happy など). Defaults to DEFAULT_STYLE. style_weight (float, optional): 音声スタイルを適用する強さ. Defaults to DEFAULT_STYLE_WEIGHT. + given_phone (Optional[list[int]], optional): 読み上げテキストの読みを表す音素列。指定する場合は given_tone も別途指定が必要. Defaults to None. given_tone (Optional[list[int]], optional): アクセントのトーンのリスト. Defaults to None. pitch_scale (float, optional): ピッチの高さ (1.0 から変更すると若干音質が低下する). Defaults to 1.0. intonation_scale (float, optional): 抑揚の平均からの変化幅 (1.0 から変更すると若干音質が低下する). Defaults to 1.0. @@ -222,6 +223,7 @@ class TTSModel: assist_text=assist_text, assist_text_weight=assist_text_weight, style_vec=style_vector, + given_phone=given_phone, given_tone=given_tone, ) else: