From a88bc63a37601491beb743a96d679ae532c70d34 Mon Sep 17 00:00:00 2001 From: litagin02 Date: Sun, 7 Jan 2024 07:23:57 +0900 Subject: [PATCH] Fix Japanese g2p again and refactor --- text/japanese.py | 1106 ++++++++++++++++++++-------------------------- 1 file changed, 488 insertions(+), 618 deletions(-) diff --git a/text/japanese.py b/text/japanese.py index aaa8e0b..e03b35c 100644 --- a/text/japanese.py +++ b/text/japanese.py @@ -9,9 +9,495 @@ from num2words import num2words from transformers import AutoTokenizer from common.log import logger -from text import punctuation, symbols +from text import punctuation + +# 正規化で記号を変換するための辞書 +rep_map = { + ":": ",", + ";": ",", + ",": ",", + "。": ".", + "!": "!", + "?": "?", + "\n": ".", + ".": ".", + "…": "...", + "···": "...", + "・・・": "...", + "·": ",", + "・": ",", + "、": ",", + "$": ".", + "“": "'", + "”": "'", + '"': "'", + "‘": "'", + "’": "'", + "(": "'", + ")": "'", + "(": "'", + ")": "'", + "《": "'", + "》": "'", + "【": "'", + "】": "'", + "[": "'", + "]": "'", + "—": "-", + "−": "-", + # "~": "-", # これは長音記号「ー」として扱うよう変更 + "~": "-", + "「": "'", + "」": "'", +} +def text_normalize(text): + """ + 日本語のテキストを正規化する。 + 結果は、ちょうど次の文字のみからなる: + - ひらがな + - カタカナ(全角長音記号「ー」が入る!) + - 漢字 + - 半角アルファベット(大文字と小文字) + - ギリシャ文字 + - `.` (句点`。`や`…`の一部や改行等) + - `,` (読点`、`や`:`等) + - `?` (疑問符`?`) + - `!` (感嘆符`!`) + - `'` (`「`や`」`等) + - `-` (`―`(ダッシュ、長音記号ではない)や`-`等) + + 注意点: + - 三点リーダー`…`は`...`に変換される(`なるほど…。` → `なるほど....`) + - 数字は漢字に変換される(`1,100円` → `千百円`、`52.34` → `五十二点三四`) + - 読点や疑問符等の位置・個数等は保持される(`??あ、、!!!` → `??あ,,!!!`) + """ + logger.debug(f"Before text_normalize: {text}") + res = unicodedata.normalize("NFKC", text) # ここでアルファベットは半角になる + logger.debug(f"After unicodedata.normalize: {res}") + res = japanese_convert_numbers_to_words(res) # 「100円」→「百円」等 + logger.debug(f"After japanese_convert_numbers_to_words: {res}") + # 「~」と「~」も長音記号として扱う + res = res.replace("~", "ー") + res = res.replace("~", "ー") + + res = replace_punctuation(res) # 句読点等正規化、読めない文字を削除 + + # 結合文字の濁点・半濁点を削除 + # 通常の「ば」等はそのままのこされる、「あ゛」は上で「あ゙」になりここで「あ」になる + res = res.replace("\u3099", "") # 結合文字の濁点を削除、る゙ → る + res = res.replace("\u309A", "") # 結合文字の半濁点を削除、な゚ → な + return res + + +def replace_punctuation(text: str) -> str: + """句読点等を「.」「,」「!」「?」「'」「-」に正規化し、OpenJTalkで読みが取得できるもののみ残す: + 漢字・平仮名・カタカナ、アルファベット、ギリシャ文字 + """ + pattern = re.compile("|".join(re.escape(p) for p in rep_map.keys())) + + # 句読点を辞書で置換 + replaced_text = pattern.sub(lambda x: rep_map[x.group()], text) + + replaced_text = re.sub( + # ↓ ひらがな、カタカナ、漢字 + r"[^\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\u3400-\u4DBF\u3005" + # ↓ 半角アルファベット(大文字と小文字) + + r"\u0041-\u005A\u0061-\u007A" + # ↓ 全角アルファベット(大文字と小文字) + + r"\uFF21-\uFF3A\uFF41-\uFF5A" + # ↓ ギリシャ文字 + + r"\u0370-\u03FF\u1F00-\u1FFF" + # ↓ "!", "?", "…", ",", ".", "'", "-", 但し`…`はすでに`...`に変換されている + + "".join(punctuation) + r"]+", + # 上述以外の文字を削除 + "", + replaced_text, + ) + + return replaced_text + + +_NUMBER_WITH_SEPARATOR_RX = re.compile("[0-9]{1,3}(,[0-9]{3})+") +_CURRENCY_MAP = {"$": "ドル", "¥": "円", "£": "ポンド", "€": "ユーロ"} +_CURRENCY_RX = re.compile(r"([$¥£€])([0-9.]*[0-9])") +_NUMBER_RX = re.compile(r"[0-9]+(\.[0-9]+)?") + + +def japanese_convert_numbers_to_words(text: str) -> str: + res = _NUMBER_WITH_SEPARATOR_RX.sub(lambda m: m[0].replace(",", ""), text) + res = _CURRENCY_RX.sub(lambda m: m[2] + _CURRENCY_MAP.get(m[1], m[1]), res) + res = _NUMBER_RX.sub(lambda m: num2words(m[0], lang="ja"), res) + return res + + +def g2p(norm_text: str) -> tuple[list[str], list[int], list[int]]: + """ + 他で使われるメインの関数。`text_normalize()`で正規化された`norm_text`を受け取り、 + - phones: 音素のリスト(ただし`!`や`,`や`.`等punctuationが含まれうる) + - tones: アクセントのリスト、0(低)と1(高)からなり、phonesと同じ長さ + - word2ph: 元のテキストの各文字に音素が何個割り当てられるかを表すリスト + のタプルを返す。 + ただし`phones`と`tones`の最初と終わりに`_`が入り、応じて`word2ph`の最初と最後に1が追加される。 + """ + # pyopenjtalkのフルコンテキストラベルを使ってアクセントを取り出すと、punctuationの位置が消えてしまい情報が失われてしまう: + # 「こんにちは、世界。」と「こんにちは!世界。」と「こんにちは!!!???世界……。」は全て同じになる。 + # よって、まずpunctuation無しの音素とアクセントのリストを作り、 + # それとは別にpyopenjtalk.run_frontend()で得られる音素リスト(こちらはpunctuationが保持される)を使い、 + # アクセント割当をしなおすことによってpunctuationを含めた音素とアクセントのリストを作る。 + + # punctuationがすべて消えた、音素とアクセントのタプルのリスト + phone_tone_list_wo_punct = g2phone_tone_list(norm_text) + + # sep_text: 単語単位の単語のリスト + # sep_kata: 単語単位の単語のカタカナ読みのリスト + sep_text, sep_kata = text2sep_kata(norm_text) + logger.debug(f"sep_text: {sep_text}") + logger.debug(f"sep_kata: {sep_kata}") + + # sep_phonemes: 各単語ごとの音素のリストのリスト + sep_phonemes = handle_long([kata2phoneme(i) for i in sep_kata]) + + # phone_w_punct: sep_phonemesを結合した、punctuationを元のまま保持した音素列 + phone_w_punct: list[str] = [] + for i in sep_phonemes: + phone_w_punct += i + + # punctuation無しのアクセント情報を使って、punctuationを含めたアクセント情報を作る + phone_tone_list = align_tones(phone_w_punct, phone_tone_list_wo_punct) + logger.debug(f"phone_tone_list:\n{phone_tone_list}") + # word2phは厳密な解答は不可能なので(「今日」「眼鏡」等の熟字訓が存在)、 + # Bert-VITS2では、単語単位の分割を使って、単語の文字ごとにだいたい均等に音素を分配する + + # sep_textから、各単語を1文字1文字分割して、文字のリスト(のリスト)を作る + sep_tokenized: list[list[str]] = [] + for i in sep_text: + if i not in punctuation: + sep_tokenized.append(tokenizer.tokenize(i)) # ここでおそらく`i`が文字単位に分割される + else: + sep_tokenized.append([i]) + logger.debug(f"sep_tokenized: {sep_tokenized}") + + logger.debug(list(zip(sep_tokenized, sep_phonemes))) + # 各単語について、音素の数と文字の数を比較して、均等っぽく分配する + word2ph = [] + for token, phoneme in zip(sep_tokenized, sep_phonemes): + phone_len = len(phoneme) + word_len = len(token) + word2ph += distribute_phone(phone_len, word_len) + + # 最初と最後に`_`記号を追加、アクセントは0(低)、word2phもそれに合わせて追加 + phone_tone_list = [("_", 0)] + phone_tone_list + [("_", 0)] + word2ph = [1] + word2ph + [1] + + phones = [phone for phone, _ in phone_tone_list] + tones = [tone for _, tone in phone_tone_list] + + logger.info(f"phones: {phones}") + logger.info(f"tones: {tones}") + logger.info(f"word2ph: {word2ph}") + assert len(phones) == sum(word2ph), f"{len(phones)} != {sum(word2ph)}" + + return phones, tones, word2ph + + +def g2phone_tone_list(text: str) -> list[tuple[str, int]]: + """ + テキストに対して、音素とアクセント(0か1)のペアのリストを返す。 + ただし「!」「.」「?」等の非音素記号は全て消える(ポーズ記号も残さない)。 + 非音素記号を含める処理は`align_tones()`で行われる。 + また「っ」は「q」に、「ん」は「n」に変換される。 + 例: "こんにちは、世界ー。。元気?!" → + [('k', 0), ('o', 0), ('n', 1), ('n', 1), ('i', 1), ('ch', 1), ('i', 1), ('w', 1), ('a', 1), ('s', 1), ('e', 1), ('k', 0), ('a', 0), ('i', 0), ('i', 0), ('g', 1), ('e', 1), ('n', 0), ('k', 0), ('i', 0)] + """ + prosodies = pyopenjtalk_g2p_prosody(text, drop_unvoiced_vowels=True) + result: list[tuple[str, int]] = [] + current_phrase: list[tuple[str, int]] = [] + current_tone = 0 + for i, letter in enumerate(prosodies): + # 特殊記号の処理 + + # 文頭記号、無視する + if letter == "^": + assert i == 0, "Unexpected ^" + # アクセント句の終わりに来る記号 + elif letter in ("$", "?", "_", "#"): + # 保持しているフレーズを、アクセント数値を0-1に修正し結果に追加 + result.extend(fix_phone_tone(current_phrase)) + # 末尾に来る終了記号、無視(文中の疑問文は`_`になる) + if letter in ("$", "?"): + assert i == len(prosodies) - 1, f"Unexpected {letter}" + # あとは"_"(ポーズ)と"#"(アクセント句の境界)のみ + # これらは残さず、次のアクセント句に備える。 + current_phrase = [] + # 0を基準点にしてそこから上昇・下降する(負の場合は上の`fix_phone_tone`で直る) + current_tone = 0 + # アクセント上昇記号 + elif letter == "[": + current_tone = current_tone + 1 + # アクセント下降記号 + elif letter == "]": + current_tone = current_tone - 1 + # それ以外は通常の音素 + else: + if letter == "cl": # 「っ」の処理 + letter = "q" + elif letter == "N": # 「ん」の処理 + letter = "n" + current_phrase.append((letter, current_tone)) + return result + + +def text2sep_kata(norm_text: str) -> tuple[list[str], list[str]]: + """ + `text_normalize`で正規化済みの`norm_text`を受け取り、それを単語分割し、 + 分割された単語リストとその読み(カタカナor記号1文字)のリストのタプルを返す。 + 単語分割結果は、`g2p()`の`word2ph`で1文字あたりに割り振る音素記号の数を決めるために使う。 + 例: + `私はそう思う!って感じ?` → + ["私", "は", "そう", "思う", "!", "って", "感じ", "?"], ["ワタシ", "ワ", "ソー", "オモウ", "!", "ッテ", "カンジ", "?"] + """ + # parsed: OpenJTalkの解析結果 + parsed = pyopenjtalk.run_frontend(norm_text) + sep_text: list[str] = [] + sep_kata: list[str] = [] + for parts in parsed: + # word: 実際の単語の文字列 + # yomi: その読み、但し無声化サインの`’`は除去 + word, yomi = replace_punctuation(parts["string"]), parts["pron"].replace( + "’", "" + ) + logger.debug(f"word: {word}, yomi: {yomi}") + """ + ここで`yomi`の取りうる値は以下の通りのはず。 + - `word`が通常単語 → 通常の読み(カタカナ) + (カタカナからなり、長音記号も含みうる、`アー` 等) + - `word`が`ー` から始まる → `ーラー` や `ーーー` など + - `word`が句読点や空白等 → `、` + - `word`が`?` → `?`(全角になる) + 他にも`word`が読めないキリル文字アラビア文字等が来ると`、`になるが、正規化でこの場合は起きないはず。 + また元のコードでは`yomi`が空白の場合の処理があったが、これは起きないはず。 + 処理すべきは`yomi`が`、`の場合のみのはず。 + """ + assert yomi != "", f"Empty yomi: {word}" + if yomi == "、": + assert len(word) == 1, f"yomi `、` comes from: {word}" + # wordは1文字の記号。正規化されているので、`.`, `,`, `!`, `'`, `-`のいずれか + logger.debug(word) + assert word in (".", ",", "!", "'", "-"), f"word: {word}" + # yomiは元の記号のままに変更 + yomi = word + elif yomi == "?": + assert word == "?", f"yomi `?` comes from: {word}" + yomi = "?" + sep_text.append(word) + sep_kata.append(yomi) + return sep_text, sep_kata + + +def kata2phoneme(text: str) -> list[str]: + """ + 原則カタカナの`text`を受け取り、それをそのままいじらずに音素記号のリストに変換。 + 注意点: + - punctuationが来た場合(punctuationが1文字の場合がありうる)、処理せず1文字のリストを返す + - 冒頭に続く「ー」はそのまま「ー」のままにする(`handle_long()`で処理される) + - 文中の「ー」は前の音素記号の最後の音素記号に変換される。 + 例: + `ーーソーナノカーー` → ["ー", "ー", "s", "o", "o", "n", "a", "n", "o", "k", "a", "a", "a"] + `?` → ["?"] + """ + if text in punctuation: + return [text] + # `text`がカタカナ(`ー`含む)のみからなるかどうかをチェック + if re.fullmatch(r"[\u30A0-\u30FF]+", text) is None: + raise ValueError(f"Non-punctuation input must be katakana only: {text}") + # 以降`text`はカタカナのみからなる + if text == "ー": + return ["ー"] + elif text.startswith("ー"): + return ["ー"] + kata2phoneme(text[1:]) + res: list[str] = [] + while text: + # カタカナをひらがなに変換してから`hiragana2p`をかける + res += hiragana2p(jaconv.kata2hira(text)).split(" ") + break + return res + + +# ESPnetの実装から引用、変更点無し +# https://github.com/espnet/espnet/blob/master/espnet2/text/phoneme_tokenizer.py +def pyopenjtalk_g2p_prosody(text: str, drop_unvoiced_vowels: bool = True) -> list[str]: + """Extract phoneme + prosoody symbol sequence from input full-context labels. + + The algorithm is based on `Prosodic features control by symbols as input of + sequence-to-sequence acoustic modeling for neural TTS`_ with some r9y9's tweaks. + + Args: + text (str): Input text. + drop_unvoiced_vowels (bool): whether to drop unvoiced vowels. + + Returns: + List[str]: List of phoneme + prosody symbols. + + Examples: + >>> from espnet2.text.phoneme_tokenizer import pyopenjtalk_g2p_prosody + >>> pyopenjtalk_g2p_prosody("こんにちは。") + ['^', 'k', 'o', '[', 'N', 'n', 'i', 'ch', 'i', 'w', 'a', '$'] + + .. _`Prosodic features control by symbols as input of sequence-to-sequence acoustic + modeling for neural TTS`: https://doi.org/10.1587/transinf.2020EDP7104 + + """ + labels = pyopenjtalk.make_label(pyopenjtalk.run_frontend(text)) + N = len(labels) + + phones = [] + for n in range(N): + lab_curr = labels[n] + + # current phoneme + p3 = re.search(r"\-(.*?)\+", lab_curr).group(1) + # deal unvoiced vowels as normal vowels + if drop_unvoiced_vowels and p3 in "AEIOU": + p3 = p3.lower() + + # deal with sil at the beginning and the end of text + if p3 == "sil": + assert n == 0 or n == N - 1 + if n == 0: + phones.append("^") + elif n == N - 1: + # check question form or not + e3 = _numeric_feature_by_regex(r"!(\d+)_", lab_curr) + if e3 == 0: + phones.append("$") + elif e3 == 1: + phones.append("?") + continue + elif p3 == "pau": + phones.append("_") + continue + else: + phones.append(p3) + + # accent type and position info (forward or backward) + a1 = _numeric_feature_by_regex(r"/A:([0-9\-]+)\+", lab_curr) + a2 = _numeric_feature_by_regex(r"\+(\d+)\+", lab_curr) + a3 = _numeric_feature_by_regex(r"\+(\d+)/", lab_curr) + + # number of mora in accent phrase + f1 = _numeric_feature_by_regex(r"/F:(\d+)_", lab_curr) + + a2_next = _numeric_feature_by_regex(r"\+(\d+)\+", labels[n + 1]) + # accent phrase border + if a3 == 1 and a2_next == 1 and p3 in "aeiouAEIOUNcl": + phones.append("#") + # pitch falling + elif a1 == 0 and a2_next == a2 + 1 and a2 != f1: + phones.append("]") + # pitch rising + elif a2 == 1 and a2_next == 2: + phones.append("[") + + return phones + + +def _numeric_feature_by_regex(regex, s): + match = re.search(regex, s) + if match is None: + return -50 + return int(match.group(1)) + + +def fix_phone_tone(phone_tone_list: list[tuple[str, int]]) -> list[tuple[str, int]]: + """ + `phone_tone_list`のtone(アクセントの値)を0か1の範囲に修正する。 + 例: [(a, 0), (i, -1), (u, -1)] → [(a, 1), (i, 0), (u, 0)] + """ + tone_values = set(tone for _, tone in phone_tone_list) + if len(tone_values) == 1: + assert tone_values == {0}, tone_values + return phone_tone_list + elif len(tone_values) == 2: + if tone_values == {0, 1}: + return phone_tone_list + elif tone_values == {-1, 0}: + return [ + (letter, 0 if tone == -1 else 1) for letter, tone in phone_tone_list + ] + else: + raise ValueError(f"Unexpected tone values: {tone_values}") + else: + raise ValueError(f"Unexpected tone values: {tone_values}") + + +def distribute_phone(n_phone: int, n_word: int) -> list[int]: + """ + 左から右に1ずつ振り分け、次にまた左から右に1ずつ増やし、というふうに、 + 音素の数`n_phone`を単語の数`n_word`に分配する。 + """ + phones_per_word = [0] * n_word + for _ in range(n_phone): + min_tasks = min(phones_per_word) + min_index = phones_per_word.index(min_tasks) + phones_per_word[min_index] += 1 + return phones_per_word + + +def handle_long(sep_phonemes: list[list[str]]) -> list[list[str]]: + for i in range(len(sep_phonemes)): + if sep_phonemes[i][0] == "ー": + sep_phonemes[i][0] = sep_phonemes[i - 1][-1] + if "ー" in sep_phonemes[i]: + for j in range(len(sep_phonemes[i])): + if sep_phonemes[i][j] == "ー": + sep_phonemes[i][j] = sep_phonemes[i][j - 1][-1] + return sep_phonemes + + +tokenizer = AutoTokenizer.from_pretrained("./bert/deberta-v2-large-japanese-char-wwm") + + +def align_tones( + phones_with_punct: list[str], phone_tone_list: list[tuple[str, int]] +) -> list[tuple[str, int]]: + """ + 例: + …私は、、そう思う。 + phones_with_punct: + [".", ".", ".", "w", "a", "t", "a", "sh", "i", "w", "a", ",", ",", "s", "o", "o", "o", "m", "o", "u", "."] + phone_tone_list: + [("w", 0), ("a", 0), ("t", 1), ("a", 1), ("sh", 1), ("i", 1), ("w", 1), ("a", 1), ("_", 0), ("s", 0), ("o", 0), ("o", 1), ("o", 1), ("m", 1), ("o", 1), ("u", 0))] + Return: + [(".", 0), (".", 0), (".", 0), ("w", 0), ("a", 0), ("t", 1), ("a", 1), ("sh", 1), ("i", 1), ("w", 1), ("a", 1), (",", 0), (",", 0), ("s", 0), ("o", 0), ("o", 1), ("o", 1), ("m", 1), ("o", 1), ("u", 0), (".", 0)] + """ + result: list[tuple[str, int]] = [] + tone_index = 0 + for phone in phones_with_punct: + if tone_index >= len(phone_tone_list): + # 余ったpunctuationがある場合 → (punctuation, 0)を追加 + result.append((phone, 0)) + elif phone == phone_tone_list[tone_index][0]: + # phone_tone_listの現在の音素と一致する場合 → toneをそこから取得、(phone, tone)を追加 + result.append((phone, phone_tone_list[tone_index][1])) + # 探すindexを1つ進める + tone_index += 1 + elif phone in punctuation: + # phoneがpunctuationの場合 → (phone, 0)を追加 + result.append((phone, 0)) + else: + logger.debug(f"phones: {phones_with_punct}") + logger.debug(f"phone_tone_list: {phone_tone_list}") + logger.debug(f"result: {result}") + logger.debug(f"tone_index: {tone_index}") + logger.debug(f"phone: {phone}") + raise ValueError(f"Unexpected phone: {phone}") + return result + + +# jaconvから借りて修正 +# https://github.com/ikegami-yukino/jaconv/blob/master/jaconv/jaconv.py def hiragana2p(text: str) -> str: """ Modification of `jaconv.hiragana2julius`. @@ -348,626 +834,10 @@ def hiragana2p(text: str) -> str: replacement = lambda m: m.group(1) + (" " + m.group(1)) * len(m.group(2)) text = re.sub(pattern, replacement, text) - text = text.replace("N", "n") # 促音のNをnに変換 + text = text.replace("N", "n") # 「ん」のNをnに変換 return text -def kata2phoneme(text: str) -> list[str]: - """Convert katakana text to phonemes.""" - text = text.strip() - if text == "ー": - return ["ー"] - elif text.startswith("ー"): - return ["ー"] + kata2phoneme(text[1:]) - res: list[str] = [] - prev = None - while text: - if re.match(_MARKS, text): - res.append(text) - text = text[1:] - continue - if text.startswith("ー"): - if prev: - res.append(prev[-1]) - text = text[1:] - continue - res += hiragana2p(jaconv.kata2hira(text)).split(" ") - break - # res = _COLON_RX.sub(":", res) - return res - - -_SYMBOL_TOKENS = set(list("・、。?!")) -_NO_YOMI_TOKENS = set(list("「」『』―()[][]")) -_MARKS = re.compile( - r"[^A-Za-z\d\u3005\u3040-\u30ff\u4e00-\u9fff\uff11-\uff19\uff21-\uff3a\uff41-\uff5a\uff66-\uff9d]" -) - - -def text2sep_kata(text: str) -> tuple[list[str], list[str]]: - parsed = pyopenjtalk.run_frontend(text) - res = [] - sep = [] - for parts in parsed: - word, yomi = replace_punctuation(parts["string"]), parts["pron"].replace( - "’", "" - ) - if yomi: - if re.match(_MARKS, yomi): - if len(word) > 1: - word = [replace_punctuation(i) for i in list(word)] - yomi = word - res += yomi - sep += word - continue - elif word not in rep_map.keys() and word not in rep_map.values(): - word = "," - yomi = word - res.append(yomi) - else: - if word in _SYMBOL_TOKENS: - res.append(word) - elif word in ("っ", "ッ"): - res.append("ッ") - elif word in _NO_YOMI_TOKENS: - pass - else: - res.append(word) - sep.append(word) - return sep, res - - -def get_accent(parsed): - labels = pyopenjtalk.make_label(parsed) - - phonemes = [] - accents = [] - for n, label in enumerate(labels): - phoneme = re.search(r"\-([^\+]*)\+", label).group(1) - if phoneme not in ["sil", "pau"]: - phonemes.append(phoneme.replace("cl", "q").lower()) - else: - continue - a1 = int(re.search(r"/A:(\-?[0-9]+)\+", label).group(1)) - a2 = int(re.search(r"\+(\d+)\+", label).group(1)) - if re.search(r"\-([^\+]*)\+", labels[n + 1]).group(1) in ["sil", "pau"]: - a2_next = -1 - else: - a2_next = int(re.search(r"\+(\d+)\+", labels[n + 1]).group(1)) - # Falling - if a1 == 0 and a2_next == a2 + 1: - accents.append(-1) - # Rising - elif a2 == 1 and a2_next == 2: - accents.append(1) - else: - accents.append(0) - return list(zip(phonemes, accents)) - - -_ALPHASYMBOL_YOMI = { - "#": "シャープ", - "%": "パーセント", - "&": "アンド", - "+": "プラス", - "-": "マイナス", - ":": "コロン", - ";": "セミコロン", - "<": "小なり", - "=": "イコール", - ">": "大なり", - "@": "アット", - "a": "エー", - "b": "ビー", - "c": "シー", - "d": "ディー", - "e": "イー", - "f": "エフ", - "g": "ジー", - "h": "エイチ", - "i": "アイ", - "j": "ジェー", - "k": "ケー", - "l": "エル", - "m": "エム", - "n": "エヌ", - "o": "オー", - "p": "ピー", - "q": "キュー", - "r": "アール", - "s": "エス", - "t": "ティー", - "u": "ユー", - "v": "ブイ", - "w": "ダブリュー", - "x": "エックス", - "y": "ワイ", - "z": "ゼット", - "α": "アルファ", - "β": "ベータ", - "γ": "ガンマ", - "δ": "デルタ", - "ε": "イプシロン", - "ζ": "ゼータ", - "η": "イータ", - "θ": "シータ", - "ι": "イオタ", - "κ": "カッパ", - "λ": "ラムダ", - "μ": "ミュー", - "ν": "ニュー", - "ξ": "クサイ", - "ο": "オミクロン", - "π": "パイ", - "ρ": "ロー", - "σ": "シグマ", - "τ": "タウ", - "υ": "ウプシロン", - "φ": "ファイ", - "χ": "カイ", - "ψ": "プサイ", - "ω": "オメガ", -} - - -_NUMBER_WITH_SEPARATOR_RX = re.compile("[0-9]{1,3}(,[0-9]{3})+") -_CURRENCY_MAP = {"$": "ドル", "¥": "円", "£": "ポンド", "€": "ユーロ"} -_CURRENCY_RX = re.compile(r"([$¥£€])([0-9.]*[0-9])") -_NUMBER_RX = re.compile(r"[0-9]+(\.[0-9]+)?") - - -def japanese_convert_numbers_to_words(text: str) -> str: - res = _NUMBER_WITH_SEPARATOR_RX.sub(lambda m: m[0].replace(",", ""), text) - res = _CURRENCY_RX.sub(lambda m: m[2] + _CURRENCY_MAP.get(m[1], m[1]), res) - res = _NUMBER_RX.sub(lambda m: num2words(m[0], lang="ja"), res) - return res - - -def japanese_convert_alpha_symbols_to_words(text: str) -> str: - return "".join([_ALPHASYMBOL_YOMI.get(ch, ch) for ch in text.lower()]) - - -def is_japanese_character(char): - # 定义日语文字系统的 Unicode 范围 - japanese_ranges = [ - (0x3040, 0x309F), # 平假名 - (0x30A0, 0x30FF), # 片假名 - (0x4E00, 0x9FFF), # 汉字 (CJK Unified Ideographs) - (0x3400, 0x4DBF), # 汉字扩展 A - (0x20000, 0x2A6DF), # 汉字扩展 B - # 可以根据需要添加其他汉字扩展范围 - ] - - # 将字符的 Unicode 编码转换为整数 - char_code = ord(char) - - # 检查字符是否在任何一个日语范围内 - for start, end in japanese_ranges: - if start <= char_code <= end: - return True - - return False - - -rep_map = { - ":": ",", - ";": ",", - ",": ",", - "。": ".", - "!": "!", - "?": "?", - "\n": ".", - ".": ".", - "…": "...", - "···": "...", - "・・・": "...", - "·": ",", - "・": ",", - "、": ",", - "$": ".", - "“": "'", - "”": "'", - '"': "'", - "‘": "'", - "’": "'", - "(": "'", - ")": "'", - "(": "'", - ")": "'", - "《": "'", - "》": "'", - "【": "'", - "】": "'", - "[": "'", - "]": "'", - "—": "-", - "−": "-", - "~": "-", - "~": "-", - "「": "'", - "」": "'", -} - - -def _numeric_feature_by_regex(regex, s): - match = re.search(regex, s) - if match is None: - return -50 - return int(match.group(1)) - - -def replace_punctuation(text): - """句読点等を正規化し、日本語と句読点記号以外を削除""" - pattern = re.compile("|".join(re.escape(p) for p in rep_map.keys())) - - # 句読点を辞書で置換 - replaced_text = pattern.sub(lambda x: rep_map[x.group()], text) - - replaced_text = re.sub( - # ↓ ひらがな、カタカナ、漢字 - r"[^\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\u3400-\u4DBF\u3005" - # ↓ "!", "?", "…", ",", ".", "'", "-", 但し`…`はすでに`...`に変換されている - + "".join(punctuation) + r"]+", - # 上述以外の文字を削除 - "", - replaced_text, - ) - - return replaced_text - - -def text_normalize(text): - """ - 日本語のテキストを正規化する。 - 結果は、ちょうど次の文字のみからなる: - - ひらがな - - カタカナ(全角長音記号「ー」が入る!) - - 漢字 - - `.` (句点`。`や`…`の一部や改行等) - - `,` (読点`、`や`:`等) - - `?` (疑問符`?`) - - `!` (感嘆符`!`) - - `'` (`「`や`」`等) - - `-` (`~`や`-`等、これいる?) - - 注意点: - - 三点リーダー`…`は`...`に変換される(`なるほど…。` → `なるほど....`) - - `~`は`-`に変換される!(`あ~、そっかー。` → `あ-, なるほどー.`) - - 数字は漢字に変換される(`1,100円` → `千百円`、`52.34` → `五十二点三四`) - - 読点や疑問符等の位置・個数等は保持される(`??あ、、!!!` → `??あ,,!!!`) - """ - res = unicodedata.normalize("NFKC", text) - res = japanese_convert_numbers_to_words(res) # 「100円」→「百円」等 - # res = "".join([i for i in res if is_japanese_character(i)]) - res = replace_punctuation(res) - res = res.replace("゙", "") - return res - - -def pyopenjtalk_g2p_prosody(text: str, drop_unvoiced_vowels: bool = True) -> list[str]: - """Extract phoneme + prosoody symbol sequence from input full-context labels. - - The algorithm is based on `Prosodic features control by symbols as input of - sequence-to-sequence acoustic modeling for neural TTS`_ with some r9y9's tweaks. - - Args: - text (str): Input text. - drop_unvoiced_vowels (bool): whether to drop unvoiced vowels. - - Returns: - List[str]: List of phoneme + prosody symbols. - - Examples: - >>> from espnet2.text.phoneme_tokenizer import pyopenjtalk_g2p_prosody - >>> pyopenjtalk_g2p_prosody("こんにちは。") - ['^', 'k', 'o', '[', 'N', 'n', 'i', 'ch', 'i', 'w', 'a', '$'] - - .. _`Prosodic features control by symbols as input of sequence-to-sequence acoustic - modeling for neural TTS`: https://doi.org/10.1587/transinf.2020EDP7104 - - """ - labels = pyopenjtalk.make_label(pyopenjtalk.run_frontend(text)) - N = len(labels) - - phones = [] - for n in range(N): - lab_curr = labels[n] - - # current phoneme - p3 = re.search(r"\-(.*?)\+", lab_curr).group(1) - # deal unvoiced vowels as normal vowels - if drop_unvoiced_vowels and p3 in "AEIOU": - p3 = p3.lower() - - # deal with sil at the beginning and the end of text - if p3 == "sil": - assert n == 0 or n == N - 1 - if n == 0: - phones.append("^") - elif n == N - 1: - # check question form or not - e3 = _numeric_feature_by_regex(r"!(\d+)_", lab_curr) - if e3 == 0: - phones.append("$") - elif e3 == 1: - phones.append("?") - continue - elif p3 == "pau": - phones.append("_") - continue - else: - phones.append(p3) - - # accent type and position info (forward or backward) - a1 = _numeric_feature_by_regex(r"/A:([0-9\-]+)\+", lab_curr) - a2 = _numeric_feature_by_regex(r"\+(\d+)\+", lab_curr) - a3 = _numeric_feature_by_regex(r"\+(\d+)/", lab_curr) - - # number of mora in accent phrase - f1 = _numeric_feature_by_regex(r"/F:(\d+)_", lab_curr) - - a2_next = _numeric_feature_by_regex(r"\+(\d+)\+", labels[n + 1]) - # accent phrase border - if a3 == 1 and a2_next == 1 and p3 in "aeiouAEIOUNcl": - phones.append("#") - # pitch falling - elif a1 == 0 and a2_next == a2 + 1 and a2 != f1: - phones.append("]") - # pitch rising - elif a2 == 1 and a2_next == 2: - phones.append("[") - - return phones - - -def fix_tone(tones: list[tuple[str, int]]) -> list[tuple[str, int]]: - # Fix tone to use only 0 and 1 - tone_values = set(tone for _, tone in tones) - if len(tone_values) == 1: - assert tone_values == {0}, tone_values - return tones - elif len(tone_values) == 2: - if tone_values == {0, 1}: - return tones - elif tone_values == {-1, 0}: - return [(letter, 0 if tone == -1 else 1) for letter, tone in tones] - else: - raise ValueError(f"Unexpected tone values: {tone_values}") - else: - raise ValueError(f"Unexpected tone values: {tone_values}") - - -def g2p_with_acc(text: str) -> list[tuple[str, int]]: - """ - テキストに対して、音素とアクセント(0か1)のペアのリストを返す。 - ただし「!」「.」「?」等の非音素記号は全て消える(ポーズ記号も残さない)。 - """ - prosodies = pyopenjtalk_g2p_prosody(text, drop_unvoiced_vowels=True) - result: list[tuple[str, int]] = [] - current_phrase = [] - current_tone = 0 - for i, letter in enumerate(prosodies): - # 特殊記号の処理 - - # 文頭記号、無視する - if letter == "^": - assert i == 0, "Unexpected ^" - # アクセント句の終わりに来る記号 - elif letter in ("$", "?", "_", "#"): - # 保持しているフレーズを、アクセント数値を0-1に修正し結果に追加 - result.extend(fix_tone(current_phrase)) - # 末尾に来る終了記号、無視(文中の疑問文は`_`になる) - if letter in ("$", "?"): - assert i == len(prosodies) - 1, "Unexpected $ or ?" - # あとは"_"(ポーズ)と"#"(アクセント句の境界)のみ - # 都合上ここは無視し、次のアクセント句に備える。 - current_phrase = [] - current_tone = 0 - # アクセント上昇記号 - elif letter == "[": - current_tone += 1 - # アクセント下降記号 - elif letter == "]": - current_tone -= 1 - # それ以外は通常の音素 - else: - if letter == "cl": # 「っ」の処理 - letter = "q" - elif letter == "N": # 「ん」の処理 - letter = "n" - current_phrase.append((letter, current_tone)) - return result - - -def g2p(text: str) -> tuple[list[str], list[int], list[int]]: - # 例: "こんにちは、世界ー。。元気?!" - # text = text_normalize(text) # 正規化はすでにされているという仮定 - # こんにちは,世界ー..元気?! - - # `phone_tone_list_wo_punct`: punctuationがすべて消えた、音素とアクセントのタプルのリスト - phone_tone_list_wo_punct = g2p_with_acc(text) - - # sep_text: 単語単位のリスト - # sep_kata: 単語単位のリスト(sep_textのカタカナ読みのリスト) - sep_text, sep_kata = text2sep_kata(text) - - # sep_phonemes: 各単語ごとの音素のリストのリスト - sep_phonemes = handle_long([kata2phoneme(i) for i in sep_kata]) - - # phone_w_punct: sep_phonemesを結合した、punctiationを含む音素列 - phone_w_punct: list[str] = [] - for i in sep_phonemes: - phone_w_punct += i - - phone_tone_list = my_align_tones(phone_w_punct, phone_tone_list_wo_punct) - logger.debug(f"phone_tone_list:\n{phone_tone_list}") - logger.debug(f"len(phone_tone_list): {len(phone_tone_list)}") - # 最後の戻り値のword2phは、各文字に音素が何個割り当てられるかを表すリスト - # 厳密な解答は不可能なので(「今日」「眼鏡」等の熟字訓が存在)、 - # Bert-VITS2では、mecabでまず単語単位に細かく分割し、そのあとだいたい均等に分割する - - # sep_textから、各単語を1文字1文字分割してsep_tokenizedを作る - sep_tokenized = [] - for i in sep_text: - if i not in punctuation: - sep_tokenized.append(tokenizer.tokenize(i)) - else: - sep_tokenized.append([i]) - - logger.debug(list(zip(sep_tokenized, sep_phonemes))) - # 各単語について、音素の数と文字の数を比較して、均等っぽく分配する - word2ph = [] - for token, phoneme in zip(sep_tokenized, sep_phonemes): - phone_len = len(phoneme) - word_len = len(token) - word2ph += distribute_phone(phone_len, word_len) - - logger.debug(f"sum(word2ph): {sum(word2ph)}") - - # 最初と最後に`_`記号を追加、アクセントは0(低)、word2phもそれに合わせて追加 - phone_tone_list = [("_", 0)] + phone_tone_list + [("_", 0)] - word2ph = [1] + word2ph + [1] - - phones = [phone for phone, _ in phone_tone_list] - tones = [tone for _, tone in phone_tone_list] - - logger.info(f"phones: {phones}") - logger.info(f"tones: {tones}") - logger.info(f"word2ph: {word2ph}") - assert len(phones) == sum(word2ph), f"{len(phones)} != {sum(word2ph)}" - - return phones, tones, word2ph - - -def distribute_phone(n_phone, n_word): - phones_per_word = [0] * n_word - for task in range(n_phone): - min_tasks = min(phones_per_word) - min_index = phones_per_word.index(min_tasks) - phones_per_word[min_index] += 1 - return phones_per_word - - -def handle_long(sep_phonemes: list[list[str]]) -> list[list[str]]: - for i in range(len(sep_phonemes)): - if sep_phonemes[i][0] == "ー": - sep_phonemes[i][0] = sep_phonemes[i - 1][-1] - if "ー" in sep_phonemes[i]: - for j in range(len(sep_phonemes[i])): - if sep_phonemes[i][j] == "ー": - sep_phonemes[i][j] = sep_phonemes[i][j - 1][-1] - return sep_phonemes - - -tokenizer = AutoTokenizer.from_pretrained("./bert/deberta-v2-large-japanese-char-wwm") - - -def align_tones(phones, tones): - res = [] - for pho in phones: - temp = [0] * len(pho) - for idx, p in enumerate(pho): - if len(tones) == 0: - break - if p == tones[0][0]: - temp[idx] = tones[0][1] - if idx > 0: - temp[idx] += temp[idx - 1] - tones.pop(0) - temp = [0] + temp - temp = temp[:-1] - if -1 in temp: - temp = [i + 1 for i in temp] - res.append(temp) - res = [i for j in res for i in j] - assert not any([i < 0 for i in res]) and not any([i > 1 for i in res]) - return res - - -def my_align_tones( - phones_with_punct: list[str], phone_tone_list: list[tuple[str, int]] -) -> list[tuple[str, int]]: - """ - 例: - …私は、、そう思う。 - phones_with_punct: - [".", ".", ".", "w", "a", "t", "a", "sh", "i", "w", "a", ",", ",", "s", "o", "o", "o", "m", "o", "u", "."] - phone_tone_list: - [("w", 0), ("a", 0), ("t", 1), ("a", 1), ("sh", 1), ("i", 1), ("w", 1), ("a", 1), ("_", 0), ("s", 0), ("o", 0), ("o", 1), ("o", 1), ("m", 1), ("o", 1), ("u", 0))] - Return: - [(".", 0), (".", 0), (".", 0), ("w", 0), ("a", 0), ("t", 1), ("a", 1), ("sh", 1), ("i", 1), ("w", 1), ("a", 1), (",", 0), (",", 0), ("s", 0), ("o", 0), ("o", 1), ("o", 1), ("m", 1), ("o", 1), ("u", 0), (".", 0)] - """ - result = [] - tone_index = 0 - for phone in phones_with_punct: - if tone_index >= len(phone_tone_list): - # 余ったpunctuationがある場合 - result.append((phone, 0)) - elif phone == phone_tone_list[tone_index][0]: - result.append((phone, phone_tone_list[tone_index][1])) - tone_index += 1 - elif phone in punctuation: - result.append((phone, 0)) - else: - logger.error(f"result: {result}") - logger.error(f"phones: {phones_with_punct}") - logger.error(f"tones: {phone_tone_list}") - logger.error(f"tone_index: {tone_index}") - logger.error(f"phone: {phone}") - raise ValueError(f"Unexpected phone: {phone}") - return result - - -def rearrange_tones(tones, phones): - res = [0] * len(tones) - for i in range(len(tones)): - if i == 0: - if tones[i] not in punctuation: - res[i] = 1 - elif tones[i] == prev: - if phones[i] in punctuation: - res[i] = 0 - else: - res[i] = 1 - elif tones[i] > prev: - res[i] = 2 - elif tones[i] < prev: - res[i - 1] = 3 - res[i] = 1 - prev = tones[i] - return res - - -def g2p_old(norm_text): - sep_text, sep_kata, acc = text2sep_kata(norm_text) - sep_tokenized = [] - for i in sep_text: - if i not in punctuation: - sep_tokenized.append(tokenizer.tokenize(i)) - else: - sep_tokenized.append([i]) - sep_phonemes = handle_long([kata2phoneme(i) for i in sep_kata]) - # 异常处理,MeCab不认识的词的话会一路传到这里来,然后炸掉。目前来看只有那些超级稀有的生僻词会出现这种情况 - for i in sep_phonemes: - for j in i: - assert j in symbols, (sep_text, sep_kata, sep_phonemes) - tones = align_tones(sep_phonemes, acc) - - word2ph = [] - for token, phoneme in zip(sep_tokenized, sep_phonemes): - phone_len = len(phoneme) - word_len = len(token) - - aaa = distribute_phone(phone_len, word_len) - word2ph += aaa - phones = ["_"] + [j for i in sep_phonemes for j in i] + ["_"] - # tones = [0] + rearrange_tones(tones, phones[1:-1]) + [0] - tones = [0] + tones + [0] - word2ph = [1] + word2ph + [1] - assert len(phones) == len(tones) - return phones, tones, word2ph - - if __name__ == "__main__": tokenizer = AutoTokenizer.from_pretrained("./bert/deberta-v2-large-japanese") text = "hello,こんにちは、世界ー!……"