VOICEVOX / voicevox_core

無料で使える中品質なテキスト読み上げソフトウェア、VOICEVOXのコア
https://voicevox.hiroshiba.jp/
MIT License
871 stars 117 forks source link

`TextAnalyzer`インターフェイスの導入 #730

Open qryxip opened 10 months ago

qryxip commented 10 months ago

内容

インターフェイスTextAnalyzerを導入し、PythonやJavaのパブリックAPIでSynthesizer<T extends TextAnalyzer>のような形にします。

TextAnalyzerが取り得る型は次の通りです。

KanaParser | OpenJTalk | ((text: string) => AccentPhrase[])

Cなどの型引数の表現が難しい言語では、パブリックAPIとしては消去(erase)して単なるSynthesizerとして扱います。

Pros 良くなる点

Cons 悪くなる点

実現方法

VOICEVOXのバージョン

N/A

OSの種類/ディストリ/バージョン

その他

Hiroshiba commented 10 months ago

どちらかというと賛成寄り、かな、という印象です! KanaParserなるほどです。

実装コストの割にできることがほぼ増えないので、もし自分が実装するならかなり後回しにするかもです。 ただ実装と設計がある程度固まっているなら、導入の方針で良いのかなと思いました!

eyr1n commented 9 months ago

外部から呼ばれるAPIをいきなり変更すると結構な部分に波及してしまうと思うので,一旦Rust側にTextAnalyzer traitを実装してみました. ここから徐々に上層のAPIに波及させていけたらいいかな,って思ってます.https://github.com/VOICEVOX/voicevox_core/pull/740

qryxip commented 6 months ago

この話ですが、ソングの存在によってTTS機能自体が必ずしも必要ではなくなる、つまりkanaのパーサーすら必要無い場合がありえるようになりました。つまり #694 で話した議論に戻ることになります。

qryxip commented 6 months ago

https://discord.com/channels/879570910208733277/893889888208977960/1241243358131912704

APIの方に話を戻すと、TextAnalyzer的なものをSynthesizerに持たせない、という選択もありかも

 HISOHISO_ZUNDAMON = 38

 ojt = await OpenJtalk.new("./…")
-synth = Synthesizer(ojt)
+synth = Synthesizer()

 await synth.load_voice_model(await VoiceModel.from_path("./5.vvm"))

-wav = await synth.tts("こんにちは", HISOHISO_ZUNDAMON)
+wav = await synth.synthesis(ojt.analyze("こんにちは"), HISOHISO_ZUNDAMON)
# `Synthesizer.synthesis`は、引数が"textual"の場合に限り音素長・音高を生成する

@pydantic.dataclasses.dataclass
class TextualAccentPhrase:
    moras: list[TextualMora]  # 音素長・音高を持たない
    ...

@pydantic.dataclasses.dataclass
class AccentPhrase:
    moras: list[Mora]  # こっちは音素長・音高を持つ
    ...

class TextAnalyzer(ABC):
    @abstractmethod
    def analyze(self, text: str) -> list[TextualAccentPhrase]:
        ...

要は次のようなことのショートハンドを提供できればよい

aps = ojt.analyze("こんにちは")
aps = await synth.replace_phoneme_length(aps, HISOHISO_ZUNDAMON)
aps = await synth.replace_mora_pitch(aps, HISOHISO_ZUNDAMON)
wav = await synth.synthesis(AudioQuery.from_accent_phrases(aps), HISOHISO_ZUNDAMON)
qryxip commented 6 months ago

色々考えましたが、やはり"Synthesizer"という名前に機能が集約されていた方がわかりやすいし便利なのではないかと思いました。SynthesizerTextAnalyzerから成るオブジェクトをTalkableSynthesizer<T: TextAnalyzer>とかTtsableSynthesizer<〃>、あるいはSynthesizerWithTextAnalyzer<〃>というように呼ぶのはどうでしょうか。このような名前であれば、ここからソングAPIを使えても違和感は無いと思います。

synth: TalkableSynthesizer[OpenJtalk] = Synthesizer().with_text_analyzer(ojt)
#      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^   ^^^^^^^^^^^^^                    ^^^
#      class TalkableSynthesizer[_]     class Synthesizer                interface TextAnalyzer
# `TextAnalyzer`が不要な操作はここに集約
class HasSynthesizer(ABC):
    @property
    @abstractmethod
    def _synthesizer(self) -> "Synthesizer": ...

    def load_voice_model(self, model: VoiceModel) -> None:
        # 実装
        ...

    def synthesis(self, query: AudioQuery) -> bytes:
        # 実装
        ...

    def with_text_analyzer[T: TextAnalyzer](self, text_analyzer: T) -> "TalkableSynthesizer[T]":
        return TalkableSynthesizer(self._synthesizer, text_analyzer)

    ...

class Synthesizer(HasSynthesizer):
    def __init__(self) -> None:
        # 実装
        ...

    @property
    def _synthesizer(self) -> "Synthesizer":
        return self

# `TextAnalyzer`が必要になってくる操作はここに
class TalkableSynthesizer[T: TextAnalyzer](HasSynthesizer):
    def __init__(self, synthesizer: Synthesizer, text_analyzer: T) -> None:
        self.synthesizer = synthesizer
        self.text_analyzer = text_analyzer

    @property
    def _synthesizer(self) -> Synthesizer:
        return self.synthesizer

    def tts(self, text: str) -> bytes:
        # 実装
        ...

    ...
qryxip commented 6 months ago

どっちにしろTextualMora/TextualAccentPhrase (音素長・音高を0.0にするのではなく、フィールド自体を持たない)みたいなのは入れた方がいいかもしれません。TextAnalyzer::analyzeはパブリックにして。

次の二つが同じですよいう流れでドキュメントも書きやすくなる。

wav = await synth.tts("こんにちは", style_id)
phrases = synth.text_analyzer.analyze("こんにちは")
phrases = list(map(TextualAccentPhrase.with_zeros, phrases))
phrases = await synth.replace_phoneme_length(phrases, style_id)
phrases = await synth.replace_mora_pitch(phrases, style_id)
query = AudioQuery.from_accent_phrases(phrases)
wav = await synth.synthesis(query, style_id)
qryxip commented 6 months ago

↑からまた考えたのですが、

OpenJtalkはオプショナルなオブジェクトとして持ち、null(相当)であるときにメソッドを呼ぶと実行時エラー

みたいな感じでよいことに気づきました。別にPythonで言うTypeErrorを発する必要はなく、「どの文字列も受理しないTextAnalyzer」を考えればよい。

# Pythonだと`text_analyzer: T = Varnothing()`のような形式の引数指定が可能
synth: Synthesizer[Varnothing] = Synthesizer()
synth: Synthesizer[OpenJtalk] = Synthesizer(ojt)

名前はVarnothing ($\varnothing$)とかはどうかなと思ってます。他の候補としては:

Hiroshiba commented 5 months ago

すみません、遅くなりました!

個人的には実行時エラーで良い気がしました! ぶっちゃけ凝った実装はユーザーにとってそんなに必要ではなく、実装がOSSとして誰でもメンテできる程度にわかりやすいのが一番かなぁと。

Varnothing

よくわかってないのですが、そもそもgenerics的な感じで型を用意する必要がないかも、とちょっと思いました。

synth: Synthesizer = Synthesizer()
synth: Synthesizer = Synthesizer(ojt)

で、あとは.tts()などで

if self._analyzer == null: throw Error("ないです")

みたいな。まあ実行時エラーならこれでも・・・?

qryxip commented 5 months ago

そもそもgenerics的な感じで型を用意する必要がないかも

今は無いですけどこういう感じでgetterを用意することを考えてました。これがあればユーザーが持ち回るのはSynthesizer一つでよくなるので使いやすくなるかなと。

synth.text_analyzer.use_user_dict(UserDict.load("./userdic.csv"))
# ^^^^^^^^^^^^^^^^^
# class OpenJtalk

(getterを用意する場合、型をeraseしてしまうとユーザー側でダウンキャストするということになりむしろわかりにくくなります。また erase ダウンキャストさせないとすると「Mecab形式のユーザー辞書を読み込める」という機能をTextAnalyzerに持たせることになりこれもあまり良くないんじゃないかと思います。C APIだけはダウンキャストでもいいかなと)

追記

また erase ダウンキャストさせないとすると「Mecab形式のユーザー辞書を読み込める」という機能をTextAnalyzerに持たせることになりこれもあまり良くないんじゃないかと思います。

まあ無理は生じない…かも? むしろ悪くない気がしてきた。

qryxip commented 5 months ago

一つ困る点が発生しうるとしたら、「JPreprocessで駆動するSynthesizerだけ、他のSynthesizerではできないことができる」みたいなケースですかね。まあ今は具体例がぱっと思い浮かびませんし、遠い未来だとは思います。

Hiroshiba commented 4 months ago

すみません遅くなりました!!

考えてたんですが、Synthesizerに2つ以上のAnalyzerを入れたくなるかもです!! 例えばボイボソフトのトークだとKanaParserとOpenjtalkParserが欲しくなりそうです。

うーーーーーーーーーーーーーん。。。どうしたもんか。。。 KanaとOpenJtalkは補完しあえず、役割が違うかも。

qryxip commented 4 months ago

複数のTextAnalyzerを使うことは一応ありうるかなとは思っています。TextAnalyzerは一応合成可能なので、ユーザー側でこうしてもらうというのはどうかなと思っています。複数使うようなユースケースだったらこれも許容されるかなと。

class EngineTextAnalyzer(TextAnalyzer):
    def __init__(self, ojt: Openjtalk) -> None:
        self._ojt = ojt

    def analyze(self, text: str) -> list[AccentPhrase]:
        if text.startswith("japanese:"):
            return self._ojt.analyze(text.removeprefix("japanese:"))
        if text.startswith("kana:"):
            return Kana().analyze(text.removeprefix("kana:"))
        raise ValueError('expected "japanese:…" or "kana:…"')

text_analyzer = EngineTextAnalyzer(ojt)
phrases = text_analyzer.analyze("japanese:こんにちは")
phrases = text_analyzer.analyze("kana:コンニチワ")

"prefix"じゃなくてJSONとかにしてもよいし、あるいはライブラリ側で合成用APIを用意してもよいと思います。

class TextAnalyzer(ABC):
    @staticmethod
    def composite(text_analyzers: dict[str, "TextAnalyzer"]) -> "TextAnalyzer":
        return _rust.composite_text_analyzers(text_analyzers)

    @abstractmethod
    def analyze(self, text: str) -> list[AccentPhrase]:
        ...
text_analyzer = TextAnalyzer.composite({"japanese": ojt, "kana": Kana()})
phrases = text_analyzer.analyze(json.dumps({"type": "japanese", "value": "こんにちは"}))
phrases = text_analyzer.analyze(json.dumps({"type": "kana", "value": "コンニチワ"}))
Hiroshiba commented 4 months ago

なるほどです!!

textは文字列だからとということで、jsonなりなんなりを入れる設計はかなり危ない気がちょっとしてます・・・! うまく説明できないのですが、ワークアラウンド感があるなぁと。

色々考えたのですが、複数のTextAnalyzerを使う場合、今のSynthesizer相当のものを複数作ってもらうか、Synthesizer内で複数のTextAnalyzerを扱えるようにするかの二択になる気がしています。 で、どっちがいいのかはちょっと分からないです。。。 なんとなく使い回しが聞くように疎結合にして、Synthesizer相当のものを複数作れるように設計していくのが綺麗な気もしています。

どっちが良いかまだユースケースが出てきてなくてわからないので、一旦今の用途から考えると、OpenjtalkParaserなしでKanaParserを使うアプリがないのと、KanaParser側はオプショナルなので、SynthesizerはOpenjtalkParaserを受け取るか受け取らないかの2択だけで良さそうに思いました! つまりTextAnalyzerインターフェイスの有無は今のとこあってもなくてもどちらでも良さそう。

ただ、

_from_kana系のAPIを統合でき

というのが実現できないですが・・・。 まあ、KanaParserを使う場合はAccentPhraseの加工を自分でしていく感じか、リソースをリッチに使ってSynthesizerを2つ定義するかですかねぇ・・・。

あと多分なのですが、Opnjtalk→JPreprocessの乗り換えは完全に互換性があると思っていて、スイッチングする必要がない(片方だけで良い)と思ってたりします。 実際どうかちゃんと調べてないですが・・・!