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

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

Open
3 tasks done
qryxip opened this issue Jan 24, 2024 · 13 comments
Open
3 tasks done

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

qryxip opened this issue Jan 24, 2024 · 13 comments

Comments

@qryxip
Copy link
Member

qryxip commented Jan 24, 2024

内容

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

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

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

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

Pros 良くなる点

Cons 悪くなる点

  • 将来(text: string) => AccentPhrase[]という処理にあてはまらない手法を導入するときに困る?

実現方法

VOICEVOXのバージョン

N/A

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

  • Windows
  • macOS
  • Linux

その他

@Hiroshiba
Copy link
Member

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

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

@eyr1n
Copy link
Contributor

eyr1n commented Feb 1, 2024

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

@qryxip
Copy link
Member Author

qryxip commented May 19, 2024

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

@qryxip
Copy link
Member Author

qryxip commented May 19, 2024

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
Copy link
Member Author

qryxip commented May 19, 2024

色々考えましたが、やはり"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
Copy link
Member Author

qryxip commented May 22, 2024

どっちにしろ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
Copy link
Member Author

qryxip commented May 26, 2024

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

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

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

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

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

  • Emptyset ($\emptyset$): データ構造の”set”を想起させてややこしい
  • Empty: 情報量が無さすぎ
  • DefaultTextAnalyzer: 普通に機能を有していそうに見えてしまう

@Hiroshiba
Copy link
Member

Hiroshiba commented Jun 16, 2024

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

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

Varnothing

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

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

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

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

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

@qryxip
Copy link
Member Author

qryxip commented Jun 18, 2024

そもそも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に持たせることになりこれもあまり良くないんじゃないかと思います。

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

  • OpenJtalk: ユーザー辞書を取り込むことができる
  • JPreprocess: 〃
  • Kana: ユーザー辞書の内容はすべて無視してもよい
  • Varnothing: ユーザー辞書の内容はすべて無視することになる

@qryxip
Copy link
Member Author

qryxip commented Jun 18, 2024

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

@Hiroshiba
Copy link
Member

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

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

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

@qryxip
Copy link
Member Author

qryxip commented Jul 5, 2024

複数の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
Copy link
Member

なるほどです!!

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

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

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

ただ、

_from_kana系のAPIを統合でき

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants