Open qryxip opened 7 months ago
どちらかというと賛成寄り、かな、という印象です! KanaParserなるほどです。
実装コストの割にできることがほぼ増えないので、もし自分が実装するならかなり後回しにするかもです。 ただ実装と設計がある程度固まっているなら、導入の方針で良いのかなと思いました!
外部から呼ばれるAPIをいきなり変更すると結構な部分に波及してしまうと思うので,一旦Rust側にTextAnalyzer traitを実装してみました. ここから徐々に上層のAPIに波及させていけたらいいかな,って思ってます.https://github.com/VOICEVOX/voicevox_core/pull/740
この話ですが、ソングの存在によってTTS機能自体が必ずしも必要ではなくなる、つまりkanaのパーサーすら必要無い場合がありえるようになりました。つまり #694 で話した議論に戻ることになります。
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)
色々考えましたが、やはり"Synthesizer"という名前に機能が集約されていた方がわかりやすいし便利なのではないかと思いました。Synthesizer
とTextAnalyzer
から成るオブジェクトを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:
# 実装
...
...
どっちにしろ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)
↑からまた考えたのですが、
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
: 普通に機能を有していそうに見えてしまうすみません、遅くなりました!
個人的には実行時エラーで良い気がしました! ぶっちゃけ凝った実装はユーザーにとってそんなに必要ではなく、実装がOSSとして誰でもメンテできる程度にわかりやすいのが一番かなぁと。
Varnothing
よくわかってないのですが、そもそもgenerics的な感じで型を用意する必要がないかも、とちょっと思いました。
synth: Synthesizer = Synthesizer()
synth: Synthesizer = Synthesizer(ojt)
で、あとは.tts()
などで
if self._analyzer == null: throw Error("ないです")
みたいな。まあ実行時エラーならこれでも・・・?
そもそも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
: ユーザー辞書の内容はすべて無視することになる一つ困る点が発生しうるとしたら、「JPreprocess
で駆動するSynthesizer
だけ、他のSynthesizer
ではできないことができる」みたいなケースですかね。まあ今は具体例がぱっと思い浮かびませんし、遠い未来だとは思います。
すみません遅くなりました!!
考えてたんですが、Synthesizerに2つ以上のAnalyzerを入れたくなるかもです!! 例えばボイボソフトのトークだとKanaParserとOpenjtalkParserが欲しくなりそうです。
うーーーーーーーーーーーーーん。。。どうしたもんか。。。 KanaとOpenJtalkは補完しあえず、役割が違うかも。
複数の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": "コンニチワ"}))
なるほどです!!
textは文字列だからとということで、jsonなりなんなりを入れる設計はかなり危ない気がちょっとしてます・・・! うまく説明できないのですが、ワークアラウンド感があるなぁと。
色々考えたのですが、複数のTextAnalyzerを使う場合、今のSynthesizer相当のものを複数作ってもらうか、Synthesizer内で複数のTextAnalyzerを扱えるようにするかの二択になる気がしています。
で、どっちがいいのかはちょっと分からないです。。。
なんとなく使い回しが聞くように疎結合にして、Synthesizer
相当のものを複数作れるように設計していくのが綺麗な気もしています。
どっちが良いかまだユースケースが出てきてなくてわからないので、一旦今の用途から考えると、OpenjtalkParaserなしでKanaParserを使うアプリがないのと、KanaParser側はオプショナルなので、SynthesizerはOpenjtalkParaserを受け取るか受け取らないかの2択だけで良さそうに思いました! つまりTextAnalyzerインターフェイスの有無は今のとこあってもなくてもどちらでも良さそう。
ただ、
_from_kana系のAPIを統合でき
というのが実現できないですが・・・。 まあ、KanaParserを使う場合はAccentPhraseの加工を自分でしていく感じか、リソースをリッチに使ってSynthesizerを2つ定義するかですかねぇ・・・。
あと多分なのですが、Opnjtalk→JPreprocessの乗り換えは完全に互換性があると思っていて、スイッチングする必要がない(片方だけで良い)と思ってたりします。 実際どうかちゃんと調べてないですが・・・!
内容
インターフェイス
TextAnalyzer
を導入し、PythonやJavaのパブリックAPIでSynthesizer<T extends TextAnalyzer>
のような形にします。TextAnalyzer
が取り得る型は次の通りです。Cなどの型引数の表現が難しい言語では、パブリックAPIとしては消去(erase)して単なる
Synthesizer
として扱います。Pros 良くなる点
_from_kana
系のAPIを統合でき、 #694 で話したような設計も悩まずにすむCons 悪くなる点
(text: string) => AccentPhrase[]
という処理にあてはまらない手法を導入するときに困る?実現方法
VOICEVOXのバージョン
N/A
OSの種類/ディストリ/バージョン
その他