import re from dataclasses import dataclass from itertools import chain from typing import Dict, List, Optional import pyopenjtalk @dataclass class Phoneme: """ 音素(母音・子音)クラス、音素の元となるcontextを保持する 音素には、母音や子音以外にも無音(silent/pause)も含まれる Attributes ---------- contexts: Dict[str, str] 音素の元 """ contexts: Dict[str, str] @classmethod def from_label(cls, label: str): """ pyopenjtalk.extract_fullcontextで得られる音素の元(ラベル)から、Phonemeクラスを作成する Parameters ---------- label : str pyopenjtalk.extract_fullcontextで得られるラベルを渡す Returns ------- phoneme: Phoneme Phonemeクラスを返す """ # フルコンテキストラベルの仕様は、 # http://hts.sp.nitech.ac.jp/?Download の HTS-2.3のJapanese tar.bz2 (126 MB)をダウンロードして、data/lab_format.pdfを見るとリストが見つかります。 # noqa contexts = re.search( r"^(?P.+?)\^(?P.+?)\-(?P.+?)\+(?P.+?)\=(?P.+?)" r"/A\:(?P.+?)\+(?P.+?)\+(?P.+?)" r"/B\:(?P.+?)\-(?P.+?)\_(?P.+?)" r"/C\:(?P.+?)\_(?P.+?)\+(?P.+?)" r"/D\:(?P.+?)\+(?P.+?)\_(?P.+?)" r"/E\:(?P.+?)\_(?P.+?)\!(?P.+?)\_(?P.+?)\-(?P.+?)" r"/F\:(?P.+?)\_(?P.+?)\#(?P.+?)\_(?P.+?)\@(?P.+?)\_(?P.+?)\|(?P.+?)\_(?P.+?)" # noqa r"/G\:(?P.+?)\_(?P.+?)\%(?P.+?)\_(?P.+?)\_(?P.+?)" r"/H\:(?P

.+?)\_(?P

.+?)" r"/I\:(?P.+?)\-(?P.+?)\@(?P.+?)\+(?P.+?)\&(?P.+?)\-(?P.+?)\|(?P.+?)\+(?P.+?)" # noqa r"/J\:(?P.+?)\_(?P.+?)" r"/K\:(?P.+?)\+(?P.+?)\-(?P.+?)$", label, ).groupdict() return cls(contexts=contexts) @property def label(self): """ pyopenjtalk.extract_fullcontextで得られるラベルと等しい Returns ------- lebel: str ラベルを返す """ return ( "{p1}^{p2}-{p3}+{p4}={p5}" "/A:{a1}+{a2}+{a3}" "/B:{b1}-{b2}_{b3}" "/C:{c1}_{c2}+{c3}" "/D:{d1}+{d2}_{d3}" "/E:{e1}_{e2}!{e3}_{e4}-{e5}" "/F:{f1}_{f2}#{f3}_{f4}@{f5}_{f6}|{f7}_{f8}" "/G:{g1}_{g2}%{g3}_{g4}_{g5}" "/H:{h1}_{h2}" "/I:{i1}-{i2}@{i3}+{i4}&{i5}-{i6}|{i7}+{i8}" "/J:{j1}_{j2}" "/K:{k1}+{k2}-{k3}" ).format(**self.contexts) @property def phoneme(self): """ 音素クラスの中で、発声に必要な要素を返す Returns ------- phoneme : str 発声に必要な要素を返す """ return self.contexts["p3"] def is_pause(self): """ 音素がポーズ(無音、silent/pause)であるかを返す Returns ------- is_pose : bool 音素がポーズ(無音、silent/pause)であるか(True)否か(False) """ return self.contexts["f1"] == "xx" def __repr__(self): return f"" @dataclass class Mora: """ モーラクラス モーラは1音素(母音や促音「っ」、撥音「ん」など)か、2音素(母音と子音の組み合わせ)で成り立つ Attributes ---------- consonant : Optional[Phoneme] 子音 vowel : Phoneme 母音 """ consonant: Optional[Phoneme] vowel: Phoneme def set_context(self, key: str, value: str): """ Moraクラス内に含まれるPhonemeのcontextのうち、指定されたキーの値を変更する consonantが存在する場合は、vowelと同じようにcontextを変更する Parameters ---------- key : str 変更したいcontextのキー value : str 変更したいcontextの値 """ self.vowel.contexts[key] = value if self.consonant is not None: self.consonant.contexts[key] = value @property def phonemes(self): """ 音素群を返す Returns ------- phonemes : List[Phoneme] 母音しかない場合は母音のみ、子音もある場合は子音、母音の順番でPhonemeのリストを返す """ if self.consonant is not None: return [self.consonant, self.vowel] else: return [self.vowel] @property def labels(self): """ ラベル群を返す Returns ------- labels : List[str] Moraに含まれるすべてのラベルを返す """ return [p.label for p in self.phonemes] @dataclass class AccentPhrase: """ アクセント句クラス 同じアクセントのMoraを複数保持する Attributes ---------- moras : List[Mora] 音韻のリスト accent : int アクセント """ moras: List[Mora] accent: int is_interrogative: bool @classmethod def from_phonemes(cls, phonemes: List[Phoneme]): """ PhonemeのリストからAccentPhraseクラスを作成する Parameters ---------- phonemes : List[Phoneme] phonemeのリストを渡す Returns ------- accent_phrase : AccentPhrase AccentPhraseクラスを返す """ moras: List[Mora] = [] mora_phonemes: List[Phoneme] = [] for phoneme, next_phoneme in zip(phonemes, phonemes[1:] + [None]): # workaround for Hihosiba/voicevox_engine#57 # (py)openjtalk によるアクセント句内のモーラへの附番は 49 番目まで # 49 番目のモーラについて、続く音素のモーラ番号を単一モーラの特定に使えない if int(phoneme.contexts["a2"]) == 49: break mora_phonemes.append(phoneme) if ( next_phoneme is None or phoneme.contexts["a2"] != next_phoneme.contexts["a2"] ): if len(mora_phonemes) == 1: consonant, vowel = None, mora_phonemes[0] elif len(mora_phonemes) == 2: consonant, vowel = mora_phonemes[0], mora_phonemes[1] else: raise ValueError(mora_phonemes) mora = Mora(consonant=consonant, vowel=vowel) moras.append(mora) mora_phonemes = [] accent = int(moras[0].vowel.contexts["f2"]) # workaround for Hihosiba/voicevox_engine#55 # アクセント位置とするキー f2 の値がアクセント句内のモーラ数を超える場合がある accent = accent if accent <= len(moras) else len(moras) is_interrogative = moras[-1].vowel.contexts["f3"] == "1" return cls(moras=moras, accent=accent, is_interrogative=is_interrogative) def set_context(self, key: str, value: str): """ AccentPhraseに間接的に含まれる全てのPhonemeのcontextの、指定されたキーの値を変更する Parameters ---------- key : str 変更したいcontextのキー value : str 変更したいcontextの値 """ for mora in self.moras: mora.set_context(key, value) @property def phonemes(self): """ 音素群を返す Returns ------- phonemes : List[Phoneme] AccentPhraseに間接的に含まれる全てのPhonemeを返す """ return list(chain.from_iterable(m.phonemes for m in self.moras)) @property def labels(self): """ ラベル群を返す Returns ------- labels : List[str] AccentPhraseに間接的に含まれる全てのラベルを返す """ return [p.label for p in self.phonemes] def merge(self, accent_phrase: "AccentPhrase"): """ AccentPhraseを合成する (このクラスが保持するmorasの後ろに、引数として渡されたAccentPhraseのmorasを合成する) Parameters ---------- accent_phrase : AccentPhrase 合成したいAccentPhraseを渡す Returns ------- accent_phrase : AccentPhrase 合成されたAccentPhraseを返す """ return AccentPhrase( moras=self.moras + accent_phrase.moras, accent=self.accent, is_interrogative=accent_phrase.is_interrogative, ) @dataclass class BreathGroup: """ 発声の区切りクラス アクセントの異なるアクセント句を複数保持する Attributes ---------- accent_phrases : List[AccentPhrase] アクセント句のリスト """ accent_phrases: List[AccentPhrase] @classmethod def from_phonemes(cls, phonemes: List[Phoneme]): """ PhonemeのリストからBreathGroupクラスを作成する Parameters ---------- phonemes : List[Phoneme] phonemeのリストを渡す Returns ------- breath_group : BreathGroup BreathGroupクラスを返す """ accent_phrases: List[AccentPhrase] = [] accent_phonemes: List[Phoneme] = [] for phoneme, next_phoneme in zip(phonemes, phonemes[1:] + [None]): accent_phonemes.append(phoneme) if ( next_phoneme is None or phoneme.contexts["i3"] != next_phoneme.contexts["i3"] or phoneme.contexts["f5"] != next_phoneme.contexts["f5"] ): accent_phrase = AccentPhrase.from_phonemes(accent_phonemes) accent_phrases.append(accent_phrase) accent_phonemes = [] return cls(accent_phrases=accent_phrases) def set_context(self, key: str, value: str): """ BreathGroupに間接的に含まれる全てのPhonemeのcontextの、指定されたキーの値を変更する Parameters ---------- key : str 変更したいcontextのキー value : str 変更したいcontextの値 """ for accent_phrase in self.accent_phrases: accent_phrase.set_context(key, value) @property def phonemes(self): """ 音素群を返す Returns ------- phonemes : List[Phoneme] BreathGroupに間接的に含まれる全てのPhonemeを返す """ return list( chain.from_iterable( accent_phrase.phonemes for accent_phrase in self.accent_phrases ) ) @property def labels(self): """ ラベル群を返す Returns ------- labels : List[str] BreathGroupに間接的に含まれる全てのラベルを返す """ return [p.label for p in self.phonemes] @dataclass class Utterance: """ 発声クラス 発声の区切りと無音を複数保持する Attributes ---------- breath_groups : List[BreathGroup] 発声の区切りのリスト pauses : List[Phoneme] 無音のリスト """ breath_groups: List[BreathGroup] pauses: List[Phoneme] @classmethod def from_phonemes(cls, phonemes: List[Phoneme]): """ Phonemeの完全なリストからUtteranceクラスを作成する Parameters ---------- phonemes : List[Phoneme] phonemeのリストを渡す Returns ------- utterance : Utterance Utteranceクラスを返す """ pauses: List[Phoneme] = [] breath_groups: List[BreathGroup] = [] group_phonemes: List[Phoneme] = [] for phoneme in phonemes: if not phoneme.is_pause(): group_phonemes.append(phoneme) else: pauses.append(phoneme) if len(group_phonemes) > 0: breath_group = BreathGroup.from_phonemes(group_phonemes) breath_groups.append(breath_group) group_phonemes = [] return cls(breath_groups=breath_groups, pauses=pauses) def set_context(self, key: str, value: str): """ Utteranceに間接的に含まれる全てのPhonemeのcontextの、指定されたキーの値を変更する Parameters ---------- key : str 変更したいcontextのキー value : str 変更したいcontextの値 """ for breath_group in self.breath_groups: breath_group.set_context(key, value) @property def phonemes(self): """ 音素群を返す Returns ------- phonemes : List[Phoneme] Utteranceクラスに直接的・間接的に含まれる、全てのPhonemeを返す """ accent_phrases = list( chain.from_iterable( breath_group.accent_phrases for breath_group in self.breath_groups ) ) for prev, cent, post in zip( [None] + accent_phrases[:-1], accent_phrases, accent_phrases[1:] + [None], ): mora_num = len(cent.moras) accent = cent.accent if prev is not None: prev.set_context("g1", str(mora_num)) prev.set_context("g2", str(accent)) if post is not None: post.set_context("e1", str(mora_num)) post.set_context("e2", str(accent)) cent.set_context("f1", str(mora_num)) cent.set_context("f2", str(accent)) for i_mora, mora in enumerate(cent.moras): mora.set_context("a1", str(i_mora - accent + 1)) mora.set_context("a2", str(i_mora + 1)) mora.set_context("a3", str(mora_num - i_mora)) for prev, cent, post in zip( [None] + self.breath_groups[:-1], self.breath_groups, self.breath_groups[1:] + [None], ): accent_phrase_num = len(cent.accent_phrases) if prev is not None: prev.set_context("j1", str(accent_phrase_num)) if post is not None: post.set_context("h1", str(accent_phrase_num)) cent.set_context("i1", str(accent_phrase_num)) cent.set_context( "i5", str(accent_phrases.index(cent.accent_phrases[0]) + 1) ) cent.set_context( "i6", str(len(accent_phrases) - accent_phrases.index(cent.accent_phrases[0])), ) self.set_context( "k2", str( sum( [ len(breath_group.accent_phrases) for breath_group in self.breath_groups ] ) ), ) phonemes: List[Phoneme] = [] for i in range(len(self.pauses)): if self.pauses[i] is not None: phonemes += [self.pauses[i]] if i < len(self.pauses) - 1: phonemes += self.breath_groups[i].phonemes return phonemes @property def labels(self): """ ラベル群を返す Returns ------- labels : List[str] Utteranceクラスに直接的・間接的に含まれる全てのラベルを返す """ return [p.label for p in self.phonemes] def extract_full_context_label(text: str): labels = pyopenjtalk.extract_fullcontext(text) phonemes = [Phoneme.from_label(label=label) for label in labels] utterance = Utterance.from_phonemes(phonemes) return utterance