Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

追加: OJT 音素のバリデーション #1004

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
46 changes: 27 additions & 19 deletions test/tts_pipeline/test_text_analyzer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
from unittest import TestCase

from voicevox_engine.model import AccentPhrase, Mora
import pytest

from voicevox_engine.model import (
AccentPhrase,
Mora,
NonOjtPhonemeError,
OjtUnknownPhonemeError,
)
from voicevox_engine.tts_pipeline.text_analyzer import (
AccentPhraseLabel,
BreathGroupLabel,
Expand Down Expand Up @@ -402,22 +409,23 @@ def stub_unknown_features_koxx(_: str) -> list[str]:
]


def test_label_non_ojt_phoneme():
"""`Label` は OJT で想定されない音素をパース失敗する"""
non_ojt_feature = ".^.-G+.=./A:.+2+./B:.-._./C:._.+./D:.+._./E:._.!._.-./F:2_1#0_.@1_.|._./G:._.%._._./H:._./I:.-.@1+.&.-.|.+./J:._./K:.+.-." # noqa: B950
tarepan marked this conversation as resolved.
Show resolved Hide resolved
with pytest.raises(NonOjtPhonemeError):
unknown_label = Label.from_feature(non_ojt_feature)
unknown_label.phoneme


def test_label_unknown_phoneme():
"""`Label` は unknown 音素 `xx` をパース失敗する"""
unknown_feature = stub_unknown_features_koxx("dummy")[3]
with pytest.raises(OjtUnknownPhonemeError):
unknown_label = Label.from_feature(unknown_feature)
unknown_label.phoneme


def test_text_to_accent_phrases_unknown():
"""`text_to_accent_phrases` は unknown 音素を含む features をパースする"""
# Expects
true_accent_phrases = [
AccentPhrase(
moras=[
_gen_mora("コ", "k", "o"),
_gen_mora("xx", None, "xx"),
],
accent=1,
pause_mora=None,
),
]
# Outputs
accent_phrases = text_to_accent_phrases(
"dummy", text_to_features=stub_unknown_features_koxx
)
# Tests
assert accent_phrases == true_accent_phrases
"""`text_to_accent_phrases` は unknown 音素を含む features をパース失敗する"""
with pytest.raises(OjtUnknownPhonemeError):
text_to_accent_phrases("dummy", text_to_features=stub_unknown_features_koxx)
17 changes: 0 additions & 17 deletions test/tts_pipeline/test_tts_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from unittest.mock import Mock

import numpy as np
import pytest
from numpy.typing import NDArray
from syrupy.extensions.json import JSONSnapshotExtension

Expand All @@ -14,16 +13,13 @@
UNVOICED_MORA_TAIL_PHONEMES,
Phoneme,
)
from voicevox_engine.tts_pipeline.text_analyzer import text_to_accent_phrases
from voicevox_engine.tts_pipeline.tts_engine import (
TTSEngine,
apply_interrogative_upspeak,
to_flatten_moras,
to_flatten_phonemes,
)

from .test_text_analyzer import stub_unknown_features_koxx


def yukarin_s_mock(
length: int, phoneme_list: NDArray[np.int64], style_id: NDArray[np.int64]
Expand Down Expand Up @@ -309,19 +305,6 @@ def result_value(i: int) -> float:
self.assertEqual(result, true_result)


def test_create_accent_phrases_toward_unknown():
"""`TTSEngine.create_accent_phrases()` は unknown 音素の Phoneme 化に失敗する"""
engine = TTSEngine(MockCoreWrapper())

# NOTE: TTSEngine.create_accent_phrases() のコールで unknown feature を得ることが難しいため、疑似再現
accent_phrases = text_to_accent_phrases(
"dummy", text_to_features=stub_unknown_features_koxx
)
with pytest.raises(ValueError) as e:
accent_phrases = engine.update_length_and_pitch(accent_phrases, StyleId(0))
assert str(e.value) == "tuple.index(x): x not in tuple"


def test_mocked_update_length_output(snapshot_json: JSONSnapshotExtension) -> None:
# Inputs
tts_engine = TTSEngine(MockCoreWrapper())
Expand Down
10 changes: 10 additions & 0 deletions voicevox_engine/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ def __init__(self, err: ParseKanaError):
super().__init__(text=err.text, error_name=err.errname, error_args=err.kwargs)


class NonOjtPhonemeError(Exception):
def __init__(self, **kwargs: Any) -> None:
self.text = "OpenJTalk で想定されていない音素が生成されたため処理できません。"


class OjtUnknownPhonemeError(Exception):
def __init__(self, **kwargs: Any) -> None:
self.text = "OpenJTalk の unknown 音素 `xx` は非対応です。"


class MorphableTargetInfo(BaseModel):
is_morphable: bool = Field(title="指定した話者に対してモーフィングの可否")
# FIXME: add reason property
Expand Down
64 changes: 60 additions & 4 deletions voicevox_engine/tts_pipeline/text_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

import pyopenjtalk

from ..model import AccentPhrase, Mora
from ..model import AccentPhrase, Mora, NonOjtPhonemeError, OjtUnknownPhonemeError
from .acoustic_feature_extractor import Consonant, Vowel
from .mora_list import mora_phonemes_to_mora_kana

OjtVowel = Literal[
Expand Down Expand Up @@ -47,6 +48,55 @@
]
OjtUnknown = Literal["xx"]
OjtPhoneme = OjtVowel | OjtConsonant | OjtUnknown
_OJT_PHONEMES: list[OjtPhoneme] = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tupleか、もっと言えばsetのが良さそうかなと!
あとtyping.Final使えば再代入も(静的に)禁止できそうでした。

"A",
"E",
"I",
"N",
tarepan marked this conversation as resolved.
Show resolved Hide resolved
"O",
"U",
"a",
"cl",
"e",
"i",
"o",
"pau",
"sil",
"u",
"b",
"by",
"ch",
"d",
"dy",
"f",
"g",
"gw",
"gy",
"h",
"hy",
"j",
"k",
"kw",
"ky",
"m",
"my",
"n",
"ny",
"p",
"py",
"r",
"ry",
"s",
"sh",
"t",
"ts",
"ty",
"v",
"w",
"y",
"z",
"xx",
]


@dataclass
Expand Down Expand Up @@ -82,10 +132,16 @@ def from_feature(cls, feature: str) -> Self:
return cls(contexts=contexts)

@property
def phoneme(self) -> OjtPhoneme:
def phoneme(self) -> Vowel | Consonant | Literal["sil"]:
"""このラベルに含まれる音素。子音 or 母音 (無音含む)。"""
# FIXME: バリデーションする
return self.contexts["p3"] # type: ignore
p = self.contexts["p3"]
if p not in _OJT_PHONEMES:
raise NonOjtPhonemeError()
elif p == "xx":
raise OjtUnknownPhonemeError()
else:
# NOTE: mypy が型推論に失敗。pyright の推論した型が返り値型と一致することをマニュアル確認済み @2024-01-10 tarepan
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

このNOTEはずっと残ることを想定されていますか? 👀
もしそうであれば、typing.TypeGuardを使うととりあえず外す方法がわかったので、プルリク送ろうかなと考えています。

あるいはtyping.castを使えば、とりあえず# type: ignoreは避けられると思います。
(解決策がだいぶいまいちですが。。)

return p # type: ignore

@property
def mora_index(self) -> int:
Expand Down