Repository: VOICEVOX/voicevox_engine (Python)

Issue:
ソング：ノートごとに ID を持たせ、音素と対応づける
## 内容

どの音素がどのノートと対応しているかがないと、UI も大変だし、エンジンにリクエストを再度送る時に対応が取れなくなったりと色々問題が生じます。
今は母音の開始位置とノートの開始位置が同じであるという制約があるのでなんとか対応は取れなくはないですが、将来的に音素の位置を移動するとわからなくなるはずです。

API が返すノートごとに ID を生成し、API が返す 音素にも対応するノート ID を付与するようにしたいです。

↓でちょこっと言及されている点です。

- https://github.com/VOICEVOX/voicevox_engine/issues/1045#issuecomment-1926071830

### Pros 良くなる点

UI の開発が楽になる。
API の拡張性も上がる。

### Cons 悪くなる点

結構考えなくちゃいけない点はある。

### 実現方法

普通にノート側にUUIDを設定して、あとはFramePhonemeに同じ値を入れればいい気がするのですが、なんか見落としがあるかも。

## VOICEVOXのバージョン

0.20.0

## その他

<!-- 関連して何か気がついたこと、気になることがあればココに書いてください -->



Discussion: これ、noteIdをnullableにすると結構エディタ側のコーディングしやすさに影響ありそうですね。。。
どうしようかな･･･。

まだソング系のマルチエンジンはない・保存されているクエリーはVOICEVOXエディタ以外にない、とするのであれば割と破壊的変更はやっても良さそう。
でも一応正式リリースしているし、ポンポン破壊的変更するのもよくなさそう･･････。

まーーーーーーーnullableかなぁ。。。。

とりあえず相談したくメンテナ共有です 🙇 @y-chan 

idをnullableにするとき、エンジン側のバリデーション処理が結構複雑になるかなと思ったんですが、そもそもバリエーションを一切しない方がいい気がしました！

スコアのノートの ID と、FrameAudioQueryの中の音素が持ってるノート IDが一致するかどうかをチェックするみたいなのもできるので、やるべきなのかなと思ってました。
ただエディタやサードパーティアプリでの使用を考えると、例えばスコアとFramePhonemeから音量を生成する時、スコアとFramePhonemeの片方だけにノート ID が含まれている、みたいなリクエストが来うるのでややこしいなぁと。
まあそもそもバリデーションが通らなかったとしてもエンジン側としてはスルーすればいい（そういう仕様だということにすれば良い）ので、別にいいかなと！

･･･本当にエンジン側はID の対応を考えなくていいのか、若干心配。

（もっとちゃんと考えると、そもそもノートの位置と音素の位置がずれてる入力が来た時にちゃんとした音声が合成できるのかわからなそう。多分ノートの位置ずらして音素の位置と合わせた方が音声っぽい音声にはなりそう。でもユーザーにとってノートの位置をずらしたことにするのは微妙そう。なのでやっぱりエンジン側にとってはID の対応は取る必要はなさそう･･･？）
（追記：位置を合わせる必要はなさそう！ってかむしろしない方が良さそう！わざと音素を遅らせる歌唱手法があるので。）

色々考えたことメモ：

エディタ側のコーディングのことは考えなくてもよさそう。
エディタ側で新しい型を実装すればいいだけだった。

スコアのノート ID はAPI 側で付与する形ではなく、クライアント側がノート ID を付与する 形。




Fix (patch):
diff --git a/voicevox_engine/tts_pipeline/model.py b/voicevox_engine/tts_pipeline/model.py
index dde8b7631..542b859a4 100644
--- a/voicevox_engine/tts_pipeline/model.py
+++ b/voicevox_engine/tts_pipeline/model.py
@@ -5,10 +5,13 @@
 """
 
 from enum import Enum
+from typing import NewType
 
 from pydantic import BaseModel, ConfigDict, Field
 from pydantic.json_schema import SkipJsonSchema
 
+NoteId = NewType("NoteId", str)
+
 
 class Mora(BaseModel):
     """
@@ -63,6 +66,7 @@ class Note(BaseModel):
     音符ごとの情報
     """
 
+    id: NoteId | None = Field(default=None, description="ID")
     key: int | SkipJsonSchema[None] = Field(default=None, description="音階")
     frame_length: int = Field(description="音符のフレーム長")
     lyric: str = Field(description="音符の歌詞")
@@ -83,6 +87,7 @@ class FramePhoneme(BaseModel):
 
     phoneme: str = Field(description="音素")
     frame_length: int = Field(description="音素のフレーム長")
+    note_id: NoteId | None = Field(default=None, description="音符のID")
 
 
 class FrameAudioQuery(BaseModel):
diff --git a/voicevox_engine/tts_pipeline/tts_engine.py b/voicevox_engine/tts_pipeline/tts_engine.py
index 664e11b95..ea0f250ff 100644
--- a/voicevox_engine/tts_pipeline/tts_engine.py
+++ b/voicevox_engine/tts_pipeline/tts_engine.py
@@ -17,7 +17,15 @@
 from ..metas.Metas import StyleId
 from ..model import AudioQuery
 from .kana_converter import parse_kana
-from .model import AccentPhrase, FrameAudioQuery, FramePhoneme, Mora, Note, Score
+from .model import (
+    AccentPhrase,
+    FrameAudioQuery,
+    FramePhoneme,
+    Mora,
+    Note,
+    NoteId,
+    Score,
+)
 from .mora_mapping import mora_kana_to_mora_phonemes, mora_phonemes_to_mora_kana
 from .phoneme import Phoneme
 from .text_analyzer import text_to_accent_phrases
@@ -313,6 +321,7 @@ def _notes_to_keys_and_phonemes(
     NDArray[np.int64],
     NDArray[np.int64],
     NDArray[np.int64],
+    list[NoteId | None],
 ]:
     """
     ノート単位の長さ・モーラ情報や、音素列・音素ごとのキー列を作成する
@@ -332,6 +341,8 @@ def _notes_to_keys_and_phonemes(
         音素列
     phoneme_keys : NDArray[np.int64]
         音素ごとのキー列
+    phoneme_note_ids : list[NoteId]
+        音素ごとのノートID列
     """
 
     note_lengths: list[int] = []
@@ -339,6 +350,7 @@ def _notes_to_keys_and_phonemes(
     note_vowels: list[int] = []
     phonemes: list[int] = []
     phoneme_keys: list[int] = []
+    phoneme_note_ids: list[NoteId | None] = []
 
     for note in notes:
         if note.lyric == "":
@@ -350,6 +362,7 @@ def _notes_to_keys_and_phonemes(
             note_vowels.append(0)  # pau
             phonemes.append(0)  # pau
             phoneme_keys.append(-1)
+            phoneme_note_ids.append(note.id)
         else:
             if note.key is None:
                 msg = "keyがnullの場合、lyricは空文字列である必要があります。"
@@ -378,8 +391,10 @@ def _notes_to_keys_and_phonemes(
             if consonant_id != -1:
                 phonemes.append(consonant_id)
                 phoneme_keys.append(note.key)
+                phoneme_note_ids.append(note.id)
             phonemes.append(vowel_id)
             phoneme_keys.append(note.key)
+            phoneme_note_ids.append(note.id)
 
     # 各データをnumpy配列に変換する
     note_lengths_array = np.array(note_lengths, dtype=np.int64)
@@ -394,6 +409,7 @@ def _notes_to_keys_and_phonemes(
         note_vowels_array,
         phonemes_array,
         phoneme_keys_array,
+        phoneme_note_ids,
     )
 
 
@@ -599,6 +615,7 @@ def create_sing_phoneme_and_f0_and_volume(
             note_vowels_array,
             phonemes_array,
             phoneme_keys_array,
+            phoneme_note_ids,
         ) = _notes_to_keys_and_phonemes(notes)
 
         # コアを用いて子音長を生成する
@@ -628,8 +645,11 @@ def create_sing_phoneme_and_f0_and_volume(
             FramePhoneme(
                 phoneme=Phoneme._PHONEME_LIST[phoneme_id],
                 frame_length=phoneme_duration,
+                note_id=phoneme_note_id,
+            )
+            for phoneme_id, phoneme_duration, phoneme_note_id in zip(
+                phonemes_array, phoneme_lengths, phoneme_note_ids
             )
-            for phoneme_id, phoneme_duration in zip(phonemes_array, phoneme_lengths)
         ]
 
         return phoneme_data_list, f0s.tolist(), volumes.tolist()
@@ -650,6 +670,7 @@ def create_sing_volume_from_phoneme_and_f0(
             _,
             phonemes_array_from_notes,
             phoneme_keys_array,
+            _,
         ) = _notes_to_keys_and_phonemes(notes)
 
         phonemes_array = np.array(