Skip to content

Conversation

@qryxip
Copy link
Member

@qryxip qryxip commented Apr 12, 2025

Co-authored-by: Hiroshiba <[email protected]>
Co-authored-by: Yuto Ashida <[email protected]>
@qryxip qryxip force-pushed the pr/feat-voicevox-song branch from 86a5f97 to 7c12e27 Compare April 12, 2025 12:44
qryxip added a commit that referenced this pull request Apr 13, 2025
Rust APIの`crate::engine`にあるアイテムを、`acoustic_feature_extractor`
と`audio_file`を除いて`crate::engine::talk`に移動する。

`acoustic_feature_extractor`や`OjtPhoneme`という名前が微妙になりつつある
が、考えるのは今後ということにする。
#1074 (comment)

Refs: #1073
See-also: #1065
@qryxip
Copy link
Member Author

qryxip commented Apr 14, 2025

考えていること:

  • keylyricの不一致 (例: "lyricが空文字列の場合、keyはnullである必要があります。")は、Note側でバリデートする
  • create_sing_frame_audio_queryは、ScoreFrameAudioQueryが一体になったものを返す
    • 名前はどうしよう。PartialFrameAudioQueryとか?「確定」してFrameAudioQueryになる。
    • ユーザーがFrameAudioQueryを変更する方法としては、modify_query的なメソッドにコールバックを渡す形で行う。そのメソッドはコールバックの後にScoreFrameAudioQueryの整合性をチェックし、結果をResult<(), InconsistentError>的なもので返す
    • FrameAudioQueryにはライフタイムパラメータを持たせてFrameAudioQuery<'score>としてもいいかもしれない。serde周りがちょっとややこしくなるかもだけど 流石にやめた方がよさげ

(Discord: https://discord.com/channels/879570910208733277/893889888208977960/1360825986941190357)

@Hiroshiba
Copy link
Member

Hiroshiba commented Apr 14, 2025

modify_query的なメソッドにコールバックを渡す形で行う。

うおーどうでしょう、実際のユースケースを想定して便利に使えそうも考えておいたほうが良いかもです。(ちょっと不安)

例えばUIだとこういう使われ方になります。
最初にスコアを書いたあと(その時点で何度もやりなおす)、歌い方が作られ、それをユーザーが一部編集し・・・みたいな感じです。

hoge.mp4

これが無理なく実現できそうなら一旦大丈夫そう!

あとまあ、とりあえずtalk側と歩調合わせる形もありかも・・・?
talk側はまだcallbackでmodifyする形じゃなく、優先度はAPIの変更よりソングAPI実装のが高そう?みたいな。

qryxip added a commit that referenced this pull request Apr 14, 2025
`mora_list`を`engine::talk`から`engine`直下に移し、`text2mora`だけ
`full_context_label`の中に埋め込む。

Refs: #1073
See-also: #1074
@qryxip
Copy link
Member Author

qryxip commented Apr 14, 2025

実際のユースケースを想定して便利に使えそうも考えておいたほうが良いかもです。(ちょっと不安)

考える必要があるのは「f0だけ再生成」と「音量だけ再生成」ですね。その二つにはScoreが必要。
(当時の会話はこのあたり?: VOICEVOX/voicevox_engine#1185 (comment))

イメージとしてはこんな感じですね。
(こうして書いて気付いたけど、無意識にRust基準で考えていたところがありました。Rustでは共有参照で済む一方で、PythonだとFrozenFrameAudioQueryという概念まで誕生してしまう)

query_with_score: FrameAudioQueryWithScore = synth.create_sing_frame_audio_query(...)
_: FrozenScore = query_with_score.score
_: FrozenFrameAudioQuery = query_with_score.query
with query_with_score.modify_query() as query:  # Pythonはコールバックに恨みでもあるのか、こういう用途には向かないためコンテキストマネージャ
    _: FrameAudioQuery = query
    assert type(query) is not type(query_with_score.query)
    ...  # ユーザーが`f0`を編集
volume = synth.generate_sing_frame_volume(query)  # https://github.com/VOICEVOX/voicevox/pull/2015
with query_with_score.modify_query() as query:
    query.volume = volume

talk側はまだcallbackでmodifyする形じゃなく、

ソングでは元のScoreも大事に持っておく必要があるけど、トークではそうではないという違いはあると思います。テキストと音素の紐付けみたいなことをやるのなら元の日本語テキストを持っておきたくなるかもですが、多分AudioQuery内に埋め込むのが妥当になるんじゃないかなと。


うーんソングのことがまだよくわからない。例えば VOICEVOX/voicevox#2369 において VOICEVOX/voicevox#2018 みたいな話はどうなるか、みたいなのがよくわからない。エディタを触ってみればもしかしたらイメージが湧くかも。

@qryxip qryxip mentioned this pull request Apr 14, 2025
3 tasks
@Hiroshiba
Copy link
Member

Hiroshiba commented Apr 15, 2025

使い方ですが、とりあえずこの4通りのデータ編集・生成フローが通れればOKだと思います!
例えばf0において:

  • スコアからf0を全部作り直す
    • 新しく楽譜を書いたときとかはこれ
  • f0を一部置き換えられる
    • 楽譜を書いたあとに一部のf0を置き換えるとかがこれ
  • f0を置き換えたあと、スコアを変えても書き換えたf0をそのままにできる
    • f0を置き換えたあとにスコアを変えたときがこれ
    • エディタだと、アプリ側で書き換えたf0情報を持っておいて、スコアを変えたあとにf0を作ってそのf0を情報を上書きしてる
  • 消せる
    • まあこれは大体の場合は破棄すれば良いだけ

こういう機能をそのままコアでサポートする必要はないと思います。こういうことができればOKかなと。

ちなみにスコアから音声までのデータフローはこんな感じです。
スコア→音素→音素長→f0→volume→歌声
前のが変わると後ろのが全部変わる感じかなと。
(正確に言うと、スコアの中でも歌詞だけが音素に関わる)

@Hiroshiba
Copy link
Member

Hiroshiba commented Apr 15, 2025

ソングでは元のScoreも大事に持っておく必要があるけど、トークではそうではない

今のエディタのトーク機能はそうなっているかもですが、需要としてはソングと同じく元のデータや中間データをもっておきたいこともあると思います!
↓とかがそうかな?

@qryxip
Copy link
Member Author

qryxip commented Apr 15, 2025

もっておきたい、というのはAudioQueryの外に持つ感じでしょうか?確かにsynthesisには不要な部分なので、外付けにすべきというのは理にかなってそうです。
(AudioItemのような単位で管理するのなら整合性の担保はあまり考えなくてよさそう、というのもありそう)

うーんFrameAudioQueryWithOriginalNotesのようなものを用意すればそこからF0/volume生成ができるし(Score, FrameAudioQuery)の復元もできる、というのも考えたのですが、こうなってくるとトークを視野に入れたなんか良い概念を、もうちょっとだけ考え続けたい感があります。

@Hiroshiba
Copy link
Member

もっておきたい、というのはAudioQueryの外に持つ感じでしょうか?

たぶんそう・・・?
下流の編集した部分だけ外で持っておいて、上流のデータが変わって下流のデータが置き換わったあと、変わる前の編集部分を上書きしたい、みたいな感じ・・・!
まあかなりニッチなのですが(といってもこれが本家VOICEVOXがやってることですが)、そういうユースケースも考えると設計が捗るかもくらいの気持ちです。

こうなってくるとトークを視野に入れたなんか良い概念を、もうちょっとだけ考え続けたい感があります。

凝りすぎてもという気もしつつ、APIがコロコロ変わるのもアレだし、塩梅が難しいですねぇ。
とりあえずAPIとして露出させずに実装してみる、とかもありかも?
(これはまあ僕は結構、予想した設計を実装すると想定外が発生しまくるタイプだからですが。。)

@qryxip
Copy link
Member Author

qryxip commented Apr 18, 2025

ちょっと色々考えましたが、Parse, don’t validateをきっぱり諦めるという手もあるかなと思いました。Scoreは一応pub fn validate(&self, &FrameAudioQuery) -> Result<(), InlivadFrameAudioQueryForScore>を備える形で。まあ音素だけはトークと比べて不正を許さないので、struct Phonemeとかenum Phonemeにしようかなと。

というのもRustでも「不正な入力」に対するエラーを処理本体のI/Oエラーに混ぜてしまう例がいくつかあったりするので。std::fs::readとかに"\0"を与えた場合とかreqwestに不正なURLを渡した場合ですね。

とりあえずAPIとして露出させずに実装してみる、とかもありかも?

ちょっと個人的には覚悟を決めてパブリックAPIを作るか、そうでなければPRをぶら下げたままの方がよいかなと思っています。APIの決定を長く先延ばしにしたい強い理由があるのならfeature-gateですかね。
(ストリーミングAPIのときは普通にパプリックな機能を作るつもりで、リリースの都合上封印したという認識です)

@Hiroshiba
Copy link
Member

Parse, don’t validateをきっぱり諦めるという手もあるかなと思いました

文脈的に逆かな・・・?
parse with validateを諦めて、Parse, don’t validateにするのも良いかも、みたいな?

ちょっと個人的には覚悟を決めてパブリックAPIを作るか、そうでなければPRをぶら下げたままの方がよいかなと思っています。APIの決定を長く先延ばしにしたい強い理由があるのならfeature-gateですかね。

個人的にPRぶら下げたままだとレビューが蓄積していって辛いので、できれば1回マージしたい気持ちはあります!
ってことで僕的にはこの三択ならfeature-gateが嬉しそうではあります。
もちろん 覚悟を決めて パブリック API でもいいです!

@qryxip
Copy link
Member Author

qryxip commented Apr 19, 2025

"parse with validate"という言葉に聞き馴染みがないので、とりあえず"Parse, don't validate"について補足しておきます。

元記事ではHaskellで書かれていますが、私の理解ではPythonでいうとこれ↓よりは、

def f(xs: list[int]):
    if not xs:
        raise ValueError("argument `xs` must not be empty")
    ...  # `xs`が空ではないことを期待した処理

こうせよということになると思います。チャットAIに記事内容を食わせても上手く要約してくれる…かもしれません。

import dataclasses
from collections.abc import Iterable, Iterator
from typing import Union


@dataclasses.dataclass
class NonEmptyList[T](Iterable[T]):
    head: T
    tail: list[T]

    @staticmethod
    def from_list(lst: list[T]) -> Union["NonEmptyList[T]", None]:
        match lst:
            case [x, *xs]:
                return NonEmptyList(x, xs)
            case _:
                return None

    def __repr__(self) -> str:
        return repr([self.head, *self.tail])

    def __iter__(self) -> Iterator[T]:
        return iter([self.head, *self.tail])


def f(xs: NonEmptyList[int]):
    ...

@Hiroshiba
Copy link
Member

なるほどです!
Parse, don't validateについて知らなかったので勘違いしました。知らない諺が通じないのに似てるかもしれない。

validateしながらparseして型付けると良いよ、って感じですかね(from_listがそういう風に見えたので)
これを諦めて、まあ正しいかどうかだけ見てくれるvalidate関数作って見かけ上型だけ付けよう的な…?

なんかよくわかってないけど全然良いと思います!!

@qryxip
Copy link
Member Author

qryxip commented Apr 20, 2025

というのもRustでも「不正な入力」に対するエラーを処理本体のI/Oエラーに混ぜてしまう例がいくつかあったりするので。

これですがファイルIOとかとは違って、ユーザー視点ではONNXでの推論は基本成功して欲しい感じであるという考えかたもありそうです。
というわけでやっぱりParse, don't validateをやれないかということを考えた結果、以下のような感じでいけそうな気がしてきました。これで実装してみます

pub struct ContextedFrameAudioQuery {
    pub f0: Vec<f32>,
    pub volume: Vec<f32>,
    pub phonemes: Vec<ContextedFramePhoneme>,
    pub volume_scale: f32,
    pub output_sample_rate: u32,
    pub output_stereo: bool,
}

pub struct ContextedFramePhoneme {
    pub phoneme: String,
    pub frame_length: U53,
    pub note_id: Option<NoteId>,

    /// 音階と歌詞。
    pub key_and_lyric: KeyAndLyric,
}

impl ContextedFrameAudioQuery {
    pub fn new(query: FrameAudioQuery, score: Score) -> Result<Self, _>;
    pub fn to_frame_audio_query(&self) -> FrameAudioQuery;
    pub fn to_score(&self) -> Score;
    pub fn split(self) -> (FrameAudioQuery, Score);
}

// 以下のようなシリアライズ形式でシリアライズ・デシリアライズを行う。
// ```json
// {
//   "query": …,
//   "score": …
// }
// ```
impl<'de> Deserialize<'de> for ContextedFrameAudioQuery {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let ContextedFrameAudioQuerySerdeRepr { query, score } =
            ContextedFrameAudioQuerySerdeRepr::deserialize(deserializer)?;
        return Self::new(query, score).map_err(D::Error::custom);
    }
}

#[derive(Deserialize, Serialize)]
struct ContextedFrameAudioQuerySerdeRepr {
    query: FrameAudioQuery,
    score: Score,
}

@qryxip qryxip changed the title feat: VOICEVOX Song機能 feat(rust): Rust APIのVOICEVOX Song Dec 9, 2025
@qryxip qryxip marked this pull request as ready for review December 9, 2025 22:08
@qryxip
Copy link
Member Author

qryxip commented Dec 9, 2025

レビューのしやすさの観点から対象をRust APIに絞り、とりあえずdraftを外しました。

@qryxip
Copy link
Member Author

qryxip commented Dec 9, 2025

Claude君にレビューさせたらdiffが大きすぎると言われ、割とどうでもいいことと事実誤認のことを列挙するだけだった…。誤字は一箇所見つけてはくれたけど。

Copy link
Member

@Hiroshiba Hiroshiba left a comment

Choose a reason for hiding this comment

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

AIのレビューが頓珍漢なのは、変更量が大きすぎてAI君のトークンが入らないからかもです。
Claude Code君は[OUTPUT TRUNCATED - exceeded 25000 token limit]となっていました。

とりあえずこっちのAI君が発見した観点をコメントしてみました。

Comment on lines +120 to +123
pub(crate) fn phoneme_lengths(
consonant_lengths: &NonEmptySlice<i64>,
note_durations: &NonEmptySlice<U53>,
) -> Vec<U53> {
Copy link
Member

Choose a reason for hiding this comment

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

これcrate内メソッドだけどpanic条件書いたほうが良いかも(ってAI君が言ってました) 個人的にはどっちでも良さそうで、他の似たようなとこでもpanic条件書いてるなら書いたほうが良さそうかなーくらい

Copy link
Member Author

Choose a reason for hiding this comment

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

あー忘れてました。後で書く。

Comment on lines +210 to +217
pub(super) fn hira_to_kana(s: &str) -> SmolStr {
s.chars()
.map(|c| match c {
'ぁ'..='ゔ' => (u32::from(c) + 96).try_into().expect("should be OK"),
c => c,
})
.collect()
}
Copy link
Member

Choose a reason for hiding this comment

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

1つくらい正常系のテストがあっても良いかも。あ→アになる、みたいな。

Comment on lines +47 to +60
pub fn validate(&self) -> crate::Result<()> {
self.to_validated().map(|_| ())
}

pub(crate) fn to_validated(&self) -> crate::Result<ValidatedScore> {
let notes = ValidatedNoteSeq::new(&self.notes)?;
Ok(ValidatedScore { notes })
}
}

impl Note {
pub fn validate(&self) -> crate::Result<()> {
self.clone().into_validated().map(|_| ())
}
Copy link
Member

Choose a reason for hiding this comment

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

Score::validateとNote::validateのテストあっても良いかも(ってAI君が)

個人的にはない理由 or なくて良い理由があるならなくても良さそう感。

Copy link
Member Author

Choose a reason for hiding this comment

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

無いのはトークの方 (#1208)もなので、そちらとまとめて考えたい気がします。

Copy link
Member

Choose a reason for hiding this comment

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

TODOコメントとか?どうするかおまかせします!

Copy link
Member

Choose a reason for hiding this comment

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

この辺docstring的なのあっても良さそう。
どういうものが配置されるのかとか、それぞれ何を示してるのかとか。
このままだとなんでも集まってきそう。

不要ならなくても良いのかも。

Copy link
Member

@Hiroshiba Hiroshiba Dec 9, 2025

Choose a reason for hiding this comment

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

(このファイルと関係ないけど議論を分けたいのでこちらでコメントしています)

レビューの方針を相談したいです!

正直このPRのレビューを正確にするのは難しいと思います。
量が多いのと(これだけなら問題ないけど)、あと実際にAPIに触れないのと(Score型が難しい・Rustに不慣れなため)。

2択ありそうです。
変更行数を100~500行ほどにしたPRに分割していくか、不正確かもだけどえいやでマージするか。
個人的には、設計についてはトークと似てて把握できてるので、後者で良いと思ってます。
例えばこのあと、Python APIの実装とサンプルコードだけ作って、ちゃんと音ができてそうかを見てチェックで良いかな~と。

ちなみにclaude code君に分割案を頼んだら、まあそういう感じだよねって案が来ました。
これを元にcommit履歴を綺麗にしてもらって、コミットごとにAIレビューさせるとかもありかもですね!(面倒だから今回やるかはさておき)
https://gist.github.com/Hiroshiba/82c30500f9b71140edfd1b7ec2524624

Copy link
Member Author

Choose a reason for hiding this comment

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

ちょっと今考えているのは逆で、このPRにPython, C, Java APIのも実装したい気持ちがあります。どうせ2000いっちゃってるので、PRとしてアトミックにしたい気持ちの方が強くなってきました。特にPython APIの場合、パブリックAPIの形がpyiの形で出現するのでAPIの方針について考えるならそっちの方がよいかなと。

AIにとっても、「./crates/voicevox_coreだけ見ろ」とか「PRのdiff全体はどうせ見れないだろうからエントリポイントから「ソングAPI」の実装を追え。ENGINEの実装とも比較」とかすればいけそうな気がしてます。

ちなみに出力チェックについては、ONNX推論を無理矢理モックした上でcompatible_engine + ENGINEとCORE APIで比較、というのができるんじゃないかなと思ってます。

Copy link
Member

@Hiroshiba Hiroshiba Dec 10, 2025

Choose a reason for hiding this comment

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

なんだかんだレビューしたので、このPRのコードは問題なさそうだと思っています。
追加して再レビュー大変なので、一旦今のでマージだとありがたいです。

あと2000行の変更程度だからこそ、ぎりぎりAI君が全体を見れたので、詳細なレビューをパスして良いと迅速に判断できた気がします。
これ以上大きかったら、レビューをパスする判断が妥当なのかもわからなかったかもです。

まあ実装した側的にはどうレビューすれば適切なのかわかると思うので、レビュワー向けにひたすら観点を区切ってレビューの段取りを伝えれば、レビュワー側はそれをAI頼りにレビューできるので良いのかも・・・?
でもそのレビュー観点に網羅性があるかの確認ができないので・・・やっぱレビューが難しい気がします。

あとAI君的にはGithubの議論も与えないと精度が出ないことがあるので与えるんですが、それも増えていくのでこれ以上はいろいろ難しくなりそうです。

Copy link
Member

@Hiroshiba Hiroshiba Dec 10, 2025

Choose a reason for hiding this comment

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

あ。
レビューする前提でいましたが、ソングAPIの初期実装はレビューなしでマージしたいという提案なら話は別かもです。
この場合、全言語のAPIも実装するPRであっても、コードは @qryxip さんを信頼して、あとは僕からいくつか設計等について質問しつつ、今後の段取り(APIの動作確認の流れと、リリース前に何するのか)を決めて、マージって流れにできると思います。

Copy link
Member Author

Choose a reason for hiding this comment

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

なるほどです。

思い付いたのですが、このPRから部分的に切り出せるものとして、Phoneme型、Sil型、SamplingRate型のパブリック化の部分があるかもしれません。で、パブリック化をPRに切り出すんだったらC, Java, Pythonでどうするべきかも同時に語れるだけの余裕も出てきそうかと。

具体的には↓のような型をPython APIとかJava APIに爆誕させるか、あるいは単にstrとかintにするかですね。なお個人的には後者寄りなかと思ってます。というのもUint53型とかPositiveFiniteFloat型とかも誕生させるべきかという話にもなりかねないし、def validate(self) -> Noneに全てを任せるという形がいいかなと。

# PyO3のオブジェクト
class Sil:
	def __new__(cls, s: str = "sil") -> "Sil": ...
    def __str__(self) -> str: ...

SIL: Final[Sil] = Sil()

type Phoneme = Sil | Literal["sil", "pau", "A", "E", ...] | _Reserved

# PyO3のオブジェクト
class OptionalLyric:
	def __new__(cls, s: str) -> "OptionalLyric": ...
    def __str__(self) -> str: ...

# PyO3のオブジェクト
class SamplingRate:
	def __new__(cls, value: int = 24000) -> "SamplingRate": ...

Copy link
Member

@Hiroshiba Hiroshiba Dec 10, 2025

Choose a reason for hiding this comment

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

Phoneme型やSil型などのパブリック化についての議論を切り出す案、賛成です!
ただどうするかをいろんな話題が入り乱れるこのPRの1コメントの中で議論するのは、後から結論を探しづらくてもったいない気がしています。
issueに切り出すか、あるいは先に提案PR作ってそちらで議論するとかはどうでしょう?

(ソングAPIの仕様の議論、もっとissueに分けていっても良さそうだと今気づきました)

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants