この記事は Zenn に公開した記事の再掲です(原文・最新版: https://zenn.dev/acs_developer/articles/irodori-tts-emotion-intensity-emoji-cfg )。
当方では、日本語TTSの Irodori-TTS(500M v3)をバックエンドにした自作の合成WebUIで、台本をブロック単位に分けて読み上げています。困っていたのは感情プリセットが強すぎることでした。「ささやき」を選ぶとほぼ息だけになり、「嬉しい」も一段上の芝居になってしまいます。ほしいのは「少しだけ明るく」です。
そこで各ブロックに 強度 0.0〜1.5 のスライダーを付けました。本稿では、Irodori-TTSで感情が何によって効いているかをコードで確かめた結果と、それを連続値へ写像した式、写像を全域でスイープしたときに見えた「階段」についてまとめます。
感情は「絵文字の回数」と「テキストCFG」で効いていた
Irodori-TTSは、本文の先頭に置いた絵文字をスタイルの指示として読みます。公式のTipsには「同じ絵文字を重ねると効果が強まる」とあります。当方のWebUIは、感情ラベルを次の2つへ変換して推論に渡していました。
- 本文の先頭に、ラベルに対応する絵文字を
repeat回つける -
cfg_scale_text(テキスト条件のCFG)を上げる
推論の呼び出し部分は次のとおりです。
from irodori_tts.inference_runtime import SamplingRequest
request = SamplingRequest(
text=text, # 例: "😊😊出た。言わないやつ。"
ref_wav=ref_wav_path, # 話者の参照音声
num_steps=num_steps,
cfg_scale_text=cfg_scale_text,
cfg_scale_speaker=cfg_scale_speaker,
seed=seed, # 当方は未指定(None)
)
ラベル定義ファイル(JSON)にあった既定値は次のとおりです。
| 種類 | 絵文字×回数 | cfg_scale_text | cfg_scale_speaker |
|---|---|---|---|
| ニュートラル | なし | 3.0 | 5.0 |
| 感情つき(既定) | ×2 | 4.5 | 5.0 |
| ささやき | 👂×3 | 5.0 | 4.0 |
実装コメントによると、テキストCFGを3.0のままにすると話者CFG(5.0)が勝って感情が乗りにくいため、感情つきのときは4.5へ上げていました。つまり強さのつまみは最初から2本あったわけです(回数とCFG)。UIから触れなかっただけでした。
写像式:強度 i を回数と2つのCFGへ
ラベルの既定値を「強度1.0」とみなし、0.0をニュートラル、1.5を「既定より強め」に置きました。ラベルの回数を R、テキストCFGを T、話者CFGを S とします。
NEUTRAL_CFG_TEXT, NEUTRAL_CFG_SPEAKER = 3.0, 5.0
CFG_TEXT_MAX, CFG_SPEAKER_MIN, REPEAT_MAX = 6.0, 3.5, 4
def apply_emotion(emoji, R, T, S, text, i):
i = max(0.0, min(1.5, float(i)))
if not emoji or abs(i - 1.0) < 1e-6:
return preset_path(...) # 1.0は従来の経路をそのまま通す
cfg_text = min(CFG_TEXT_MAX, 3.0 + (T - 3.0) * i)
cfg_speaker = max(CFG_SPEAKER_MIN, 5.0 + (S - 5.0) * i)
if i <= 0.0:
rep = 0
elif i > 1.0:
rep = min(R + 1, REPEAT_MAX)
else:
rep = max(1, round(R * i))
return emoji * rep + text, cfg_text, cfg_speaker
設計で決めたことは3つです。
- i=1.0 のときは写像を通さず、従来の関数の出力をそのまま使う。既存の767ブロックは列の既定値を1.0にしたため、パラメータが1ビットも変わりません。強度を渡さない古い呼び出し元も1.0として動きます
- CFGは線形に動かします。ただし上限と下限で頭打ちにします(text≤6.0、speaker≥3.5)
- 絵文字は整数なので丸めます。0より大きければ最低1回はつけます
全域スイープで見えた「階段」
UIの刻み(0.05)で、本番と同じ apply_emotion を 0.00〜1.50 の全31点に当てました。関数は本番のソースからASTで切り出して実行しています。抜粋は次のとおりです。
## 明るい・嬉しい (R=2, T=4.5, S=5.0)
i=0.00 repeat=0 cfg_text=3.000
i=0.05 repeat=1 cfg_text=3.075 <- repeat変化
i=0.70 repeat=1 cfg_text=4.050
i=0.75 repeat=2 cfg_text=4.125 <- repeat変化
i=1.00 repeat=2 cfg_text=4.500 preset=True
i=1.05 repeat=3 cfg_text=4.575 <- repeat変化
i=1.50 repeat=3 cfg_text=5.250
## ささやき (R=3, T=5.0, S=4.0)
i=0.05 repeat=1 cfg_text=3.100 cfg_speaker=4.950
i=0.50 repeat=2 cfg_text=4.000 cfg_speaker=4.500 <- repeat変化
i=0.85 repeat=3 cfg_text=4.700 cfg_speaker=4.150 <- repeat変化
i=1.05 repeat=4 cfg_text=5.100 cfg_speaker=3.950 <- repeat変化
i=1.50 repeat=4 cfg_text=6.000 cfg_speaker=3.500
スライダーは連続値に見えますが、中身は絵文字回数の3〜4段の階段を、CFGが斜面で埋めている形でした。気づいた点は次のとおりです。
- R=2 のラベルでは、0.05〜0.70 の14段階がすべて絵文字1回です。たとえば0.30と0.70の差はCFGの3.45と4.05だけです
- 1.00→1.05 では、CFGが0.075しか動かないのに絵文字が1回増えます。入力の変化が最も大きい境目が「既定値のすぐ上」にあることになります。1.0より上を滑らかにしたいなら、
R + 1へ飛ばさずround(R * i)を延長するほうが素直です - Pythonの
round()は偶数丸め(round(0.5)=0、round(2.5)=2)です。今回の刻みで同点になるのは R=2 の 0.25 と 0.75、R=3 の 0.50 だけでした。0.25 はmax(1, …)で1回に戻ります。どの点も四捨五入と同じ結果になり、実害はありませんでした。ただし刻みを変えると結果がずれる箇所が出ます
合成した結果(1文・各1回の観測)
同じ1文(「出た。言わないやつ。」)を同じ話者で合成しました。パラメータは、ワーカーのログに出した実際の受け渡し値です。
| 感情 | 強度 | 絵文字 | text CFG | speaker CFG | 尺 | LUFS |
|---|---|---|---|---|---|---|
| 明るい | 0.00 | 0 | 3.00 | 5.0 | 4.04s | -16.2 |
| 明るい | 0.30 | 1 | 3.45 | 5.0 | 4.04s | -16.2 |
| 明るい | 0.60 | 1 | 3.90 | 5.0 | 4.04s | -16.3 |
| 明るい | 1.00 | 2 | 4.50 | 5.0 | 4.12s | -15.7 |
| 明るい | 1.30 | 3 | 4.95 | 5.0 | 4.20s | -15.7 |
| ささやき | 0.30 | 1 | 3.60 | 4.7 | 4.04s | -15.9 |
| ささやき | 0.70 | 2 | 4.40 | 4.3 | 4.16s | -16.5 |
| ささやき | 1.00 | 3 | 5.00 | 4.0 | 4.32s | -16.5 |
- 評価者1名の聴取では、段階の差は聞き分けられました(どの値が実用的かは、まだ運用しながら調べている段階です)
- 尺は、絵文字の回数が増えたところでわずかに伸びました(ささやき 4.04→4.16→4.32秒)
- ささやきでもラウドネスは下がりませんでした(-15.9〜-16.5 LUFS)。ピークもどの本も0dBFS付近にそろっていました。音量で「小声らしさ」を判定する自動検査は使えません。声色で判断することになります
-
seedを指定していないので、同じパラメータでも毎回波形は変わります。1.0は「同じ設定」という意味で、「同じ音」が出るわけではありません。強度の比較は、1本ずつ聴いて決め打ちしないほうが安全です
なお、これは1文・各1回の観測です。ラベルや話者によって効き方は変わる可能性があります。
ログに「実際に渡した値」を残す
写像を挟むと、UIに出ている数字と推論に渡る数字が一致しなくなります。そこでワーカーは合成のたびに1行を出すようにしました。
[intensity] block=2181 take=b2181_….wav emotion=明るい・嬉しい i=0.30 repeat=1 cfg_text=3.45 cfg_speaker=5.0 preset=False
実装から3日間で、このログは135行たまりました。そのうち124行は「ニュートラル・i=1.00・preset=True」、つまり従来の経路そのままでした。既存の台本に影響が出ていないことは、この行で確認できます。
まとめ
- Irodori-TTSの感情は「先頭の絵文字の回数」と「
cfg_scale_text」の2本で効いている。強度のつまみは、この2本へ写像すれば作れる - 既定値(i=1.0)は写像を通さず、従来の経路をそのまま通す。こうすると既存データとの互換が崩れない
- 整数の回数を丸めるので、連続値のスライダーでも中身は階段になる。全域をスイープし、段の位置(特に既定値の直上)を把握してから運用する
- ラウドネスは強度の指標にならなかった。判定は耳で行い、実際に渡した値をログで追えるようにしておく
検証の続きや関連ツールはACS Developerで公開しています → ACS Developer