Skip to content

feat: 母音リップシンク・チャンク単位のストリーミング合成/再生・字幕同期、句ごとの合成の共通化 - #7

Merged
ciniml merged 11 commits into
ciniml:mainfrom
mongonta0716:feat/streaming-tts-lipsync
Sep 23, 2026
Merged

ciniml merged 11 commits into
ciniml:mainfrom
mongonta0716:feat/streaming-tts-lipsync

Conversation

@mongonta0716

Copy link
Copy Markdown
Contributor

概要

jtts の発話を「母音に連動した口形」で喋らせ、長い発話を チャンクごとに合成しながら再生 して、吹き出しの字幕もチャンクに同期させます。あわせて、句読点での「間」を sanoTTS / フォルマント / 単位連結 / HMM で共通の処理にしました。

主な変更

母音リップシンク (avatar / main)

  • DSL 変数 mouth_form を追加 (口の幅を開きから独立に制御。opcode 0x1F)。
  • jtts が口形イベント (あ/い/う/え/お + 閉口) を返し、Speech が 20 ms ごとの esp_timer で口の開き・形を更新。口形を出せないエンジンは従来どおり音量エンベロープ。

ストリーミング合成と再生 (jtts / main)

  • synthesize_stream(kana, ChunkSink, opt) を追加。長文は句読点などで分割して合成し、最初のチャンクができた時点で再生を始める (18 s → 約 1.2 s で発音開始)。
  • HMM は空きメモリに合わせてチャンクの大きさを決める。長いアイドル発話フレーズでのクラッシュ (一括合成でヒープ不足) を解消。
  • ChunkPlayer (main) がチャンクの PCM を 2 スロットで連続再生。合成は別タスク (speech_synth、PSRAM スタック) で非同期に行い、stop() / 新しい発話で取り消せる。
  • fix(hts_engine): float** 配列を sizeof(float *) で確保するよう修正 (64 bit ホストでの領域不足)。

吹き出し (avatar / main)

  • 字幕を再生中のチャンクに同期 (読みと表示の句読点数の対応で分割。対応が取れないときは全文を先頭チャンクで表示)。
  • 吹き出しは左詰めで表示し、はみ出すときだけ文頭から一度スクロール。
  • 前の吹き出しの完了通知が次の吹き出しを消してしまう競合を修正。

アイドル発話 (speech)

  • アイドル発話フレーズの順序 (ランダム / 連続) を選べるようにした。

句ごとの合成 (jtts)

  • sanoTTS: 句読点 (、。) ごとに 1 句ずつ合成し、句の間に HMM の pau と同じ長さの無音を明示的に挟む。口形は推論の音素継続長 (d_hat) から作る。
  • 共通化: 「句に分ける → 合成 → 句間の無音」を stream_clauses (clause_stream.cpp) に切り出し、sanoTTS・フォルマント・単位連結が使う。間の長さは clause_pause_ms(mora_ms) の 1 か所で決め、HMM の pau もこの値から導く (HMM の出力は従来と byte 単位で同一)。
  • 従来、フォルマント / 単位連結は句読点を読み飛ばし、句の間に間が入らなかった。今回、句ごとに 1 チャンクになり、話速に比例した間が入る。句読点のない発話は従来と同じ出力。

動作の変化・互換性

  • synthesize_stream のフォルマント / 単位連結は、句読点のある発話で複数チャンクになる (以前は全体で 1 チャンク)。SynthChunk::text / visemes / sample_rate は各チャンク単位。
  • 句読点を含むフォルマント出力は、句間の無音の分だけ長くなる (例: 「こんにちは、きょうはいいてんきですね。」 30720 → 38050 サンプル @16 kHz)。
  • ホスト テストの実行ファイル名を jtts_test_sano_clause → jtts_test_clause_stream に変更 (CI も更新)。
  • sanoTTS の重みは含みません (従来どおり別途取得)。

テスト

  • ホスト テスト (components/jtts/test/host、components/avatar/test/host): 追加・更新のうえ全て通過 (mei16 の音声を使う構成も通過)。
    • 句の分割 / 無音の切り詰め / 口形の切り詰め / 句間の無音の長さ / スキップ / 中断、フォルマント実物での句数・一括版との一致 など。
  • 単句の出力と HMM の出力が、変更前 (HEAD) とハッシュ一致することを確認。
  • 実機:
    • CoreS3 (ESP-IDF 5.5.4): ビルドして書き込み、フォルマント / HMM / sanoTTS の発話・口形・吹き出し同期・アイドル発話を確認。
    • AtomS3R + EchoBase (8 MB, ADR-001 レイアウト): ビルドして書き込み、HMM (mei16) の合成と発話を確認。

既知の制限

  • sanoTTS は実機の合成速度が再生の約 0.7 倍 (RTF ≈ 1.4) のため、句の切れ目で再生が途切れることがある (数句先読みで解消できる見込みだが今回は未対応)。
  • 48 kHz の HMM ボイスは合成が遅く、句間の待ちが長く感じる。16 kHz の mei16 を推奨。

🤖 Generated with Claude Code

mongonta0716 and others added 9 commits September 21, 2026 18:12
HTS_pstream.c / HTS_sstream.c の win_coefficient (float**) を
HTS_calloc(win_size, sizeof(float)) で確保していた (double → float 化の際の
取り違え)。ESP32 (32bit) ではポインタも 4 バイトなので顕在化しないが、64bit
ホストでは win_size 個のポインタが確保域を超えてヒープを壊し、合成後の
HTS_Engine_refresh で abort する。

ホストで HMM 合成を回すテスト (jtts) に必要。実機の挙動は変わらない。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
従来は、収まる文字列は中央寄せ、溢れる文字列は右の外から入ってきて左へ
流れ切るループ表示だった (最初は文字が見えない)。

- 左詰めで表示する。収まる文字列はスクロールしない。
- 溢れる文字列は、文頭が見える状態で少し止まり、溢れた分だけ左へ動かして、
  文末が右端に揃ったところで止める。ループしない。
- hold_ms が指定されているときは、その時間内に文末まで読めるよう速度を
  合わせる (30〜120 px/s、前後の静止は各 1/5・最大 1 秒)。

位置の計算は副作用のない関数 (balloon_layout.hpp) に切り出し、ホストで
テストする (components/avatar/test/host)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
描画タスクは毎フレーム「新しい吹き出しを反映 → 描画 → 表示が終わっていれば
notify_balloon_complete()」の順に処理する。notify_balloon_complete() は
どの吹き出しの完了かを見ずに共有状態の *現在の* 吹き出しを消していたため、
反映と通知の間に次の吹き出しが set されると、それが一度も描画されないまま
消えていた。吹き出しを短い間隔で差し替える使い方 (チャンクごとの字幕) で
表示時間と切り替えが重なり、「たまに吹き出しが出ない」症状になる。

- notify_balloon_complete(version): 終わった吹き出しの版を受け取り、現在の
  版と違えば古い通知として無視する。
- snapshot_balloon(): テキスト・hold・版を同じロックの下で取得する。
- 描画タスクは反映した吹き出しの版を保持して通知に渡す。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
従来の口は幅・高さとも mouth_open だけで決まり、「開くほど幅が狭くなる」
形しか作れなかった。母音ごとに口の形を変えるため、幅を決める変数
mouth_form (0 = 横広 .. 1 = すぼめ) を追加する。

- Var::MouthForm = 0x1F (opcodes.hpp / opcodes.js)。
- Avatar::set_mouth_form()。負の値 (既定 -1) は「未設定」で、mouth_open と
  同値になるため、mouth_open だけを駆動する既存の使い方 (マイクのレベル
  メーター等) は見た目が変わらない。
- default_face.avdsl の口幅を mouth_form で決める。
- wasm に avatar_set_mouth_form を export。
- docs/avatar_dsl.md の変数表 (未記載だった cheek 系も追記)、VM ホスト テスト。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
jtts 設定 JSON に "phrase_order" ("random" | "sequential") を追加する。
random (既定・従来どおり) は毎回どれかを選び、sequential は一覧を上の行から
順に発話して最後まで行ったら先頭に戻る (設定を送り直すと先頭から)。
未指定・不明な値は random。

設定ページ (BLE 版 tools/settings.html、Wi-Fi 版 settings_wifi.html) の
アイドル発話フレーズ欄の下に「発話の順序」を追加する。random のときは
キーを出さないので、古いファームでもそのまま読める。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
■ 口形イベント (リップシンク用)
- synthesize(kana, pcm, visemes, opt): PCM と時間軸の揃った VisemeEvent 列
  (母音 あ/い/う/え/お、閉口 = Vowel::None) を返す。
- フォルマント: Segment に口形を持たせる。両唇音 (m b p) は閉口、それ以外の
  子音は後続母音の形を先取り、無声化母音・撥音・促音は閉口、長音は直前を保つ。
- HMM: 音素ラベル (p3) と hts_engine の状態継続長から作る (同じ規則)。
  sanoTTS / 単位連結は口形を出せない (空) ので、呼び出し側は音量で代用する。

■ 長文の分割合成 (HMM)
- hts_engine は合成中フレーム数に比例して作業メモリ (≈ 2 KB / 5 ms フレーム)
  を確保し続け、確保失敗はクラッシュになる (146 文字 ≈ 11 MB > PSRAM)。
  空き PSRAM から 1 チャンクで扱えるモーラ数を見積もり、句読点 (、。)・
  アクセント句境界 (/)、最後の手段でモーラ境界で分割して順に合成する。
  境界の無音は元の pau と同じ長さに揃える。全体が収まる文は従来と同一出力。
- 予算に 1 モーラも入らない場合は HMM を諦めて次のエンジンにフォールバック。
- PCM が空きメモリに収まらない巨大な発話は合成前に Error::OutOfMemory で断る
  (std::vector の確保失敗は例外無効ビルドで abort)。

■ ストリーミング合成
- synthesize_stream(kana, sink, opt): チャンクごとに sink へ渡す。最初のチャンクを
  小さく (≈ 14 モーラ)、以降を直前の 1.3 倍まで徐々に大きくして、再生しながら
  次を合成しても途切れにくくする。sink が false で中断 (Error::Cancelled)。
- SynthChunk::sample_rate: チャンクの出力レート。sanoTTS (Engine::Sano、22.05 kHz
  固定) は synthesize_ex と同様に全体を 1 チャンクで渡す。Auto / Sano では Sano、
  次に HMM、単位連結、フォルマントの順 (従来どおり)。
- SubtitleMapper (jtts/subtitle.hpp): 表示テキストを句読点で区切り、チャンクの
  読みに対応する部分を返す (吹き出しの同期用)。合わないときは全文を最初に出す。

テスト: test_visemes / test_hmm_chunk (ホスト、.htsvoice を渡すと HMM も検証)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
jtts の口形イベント / ストリーミング合成 (前コミット) と、avatar の mouth_form
(mouth_form コミット) を発話に繋ぐ。Speech は上流の非同期化 (合成を別タスクで行い
demo_loop をブロックしない) の構造をそのまま使う。

■ 母音リップシンク
- Speech::current_mouth(): 母音ごとの口形 (開き, 幅) を返す。母音間は 60 ms で
  補間。口形が無いエンジン (単位連結 / sanoTTS) は従来どおり音量エンベロープ。
- SharedState::face.mouth_form (既定 -1 = mouth_open に追従) → render_task →
  Avatar::set_mouth_form。マイク・会話・BLE ストリームなど他の mouth_open の
  書き手に切り替わるときは demo_loop が -1 に戻す。

■ チャンク単位の再生
- ChunkPlayer (新規): チャンクを M5.Speaker の 1 チャンネル (2 スロット) に順に
  積み、再生し終えたバッファから解放する。スロットが空くまで待つので合成が
  再生より先に進みすぎない。再生予定時刻も返す。stop() は別タスクから呼べ、
  「取り消し確認 → 再生開始」と「取り消し → 停止」を同じミューテックスで直列化する
  ので、stop() の後に遅れてチャンクが鳴り出すことは無い。
- speech_synth タスクの中で synthesize_stream を回し、1 チャンク目が出来次第
  再生を始めて再生中に次を合成する (最初の音まで ≈ 18 秒 → ≈ 1.2 秒、146 文字の
  フレーズ)。sanoTTS など 22.05 kHz のチャンクはチャンクのレートで鳴らす。
  stop() は上流の世代カウンタ (gen_) で進行中の合成を捨てる。
- 口の更新は Speech 自身の 20 ms タイマー (esp_timer): demo_loop のポーリング
  周期に依存せず、合成中もチャンクの継ぎ目で口が止まらない。
- say_worker (設定ページの発話テスト / MCP say / jtts-say) もストリーミング再生。
  上流の PSRAM スタックとリーク対策 (処理本体を関数に切り出す) はそのまま。

■ 吹き出しのチャンク同期
- babble() は、表示テキストを SubtitleMapper で句ごとに対応付け、各チャンクの
  再生開始時刻に、そのチャンクの部分をタイマーから吹き出しに出す (hold は
  チャンクの音声長)。対応が取れないときは最初の音と同時に全文を出す。合成できな
  かった / 開始できなかったときも全文を 1 回出す。従来は合成の完了後 (最初の音から
  約 17 秒遅れ) に全文を出していた。
- 次の発話の開始は balloon_in_flight ではなく balloon_visible() で判定する。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
■ 句ごとの合成 (HMM と同じ区切りと間)
- sanoTTS は発話全体を 1 回で合成すると、「、」「。」が同じ短いポーズ (長さは
  ニューラル モデル任せ) になって句の区切りが弱くなり、長い文は arena 上限
  (350 ids) で失敗して HMM に落ちていた。
- 句読点 (、。,,.. — HMM と同じ) ごとに 1 句ずつ合成し、句と句の間に HMM の pau
  と同じ長さの無音 (等速 420 ms × mora_ms / 110、既定で ≈ 0.46 s) を明示的に
  挟む。モデルが句の前後に付ける無音は (ピークの約 1 % を閾値に、10 ms の余白を
  残して) 切り詰めてから足すので、間の長さがモデル任せにならない。発話の先頭 /
  末尾は従来どおり切り詰めない。
- synthesize_stream は句ごとにチャンクで渡す (最初の音が早くなり、吹き出しの句ごと
  の同期も HMM と同じ仕組みで動く)。synthesize / synthesize_ex も同じ経路を通る。
- 最初の句が失敗したら何も出さず次のエンジンへフォールバック、途中の失敗は中断、
  読める内容が無い句は飛ばす。重みのロックは 1 句ごと (再生キューの空き待ちの間は
  重みの差し替えや状態の問い合わせを塞がない)。
- 境界の整形は sanoTTS のコアに依存しない sano_clause.cpp に置き、1 句の合成を関数
  で受け取る (重み = 非 MIT の blob が無くてもホストでテストできる)。

■ 母音リップシンク
- saan_stream_init が音素ごとの継続長 d_hat [フレーム] を確定させているので、音素
  ID (saan_g2p の出力) と合わせて口形の区間列にする。規則は HMM と同じ: 母音は
  その形、子音は直後の母音を先取り、両唇音 (m b p、拗音含む)・ん・っ・無声化母音・
  ポーズ (PAD の連続) は閉口、PAD / 記号は直前の形を保つ。1 フレームの長さは実際
  の出力長から求めるので、区間の合計は音声にぴったり合う (ずれたら警告)。
- 前後の無音の切り詰めと句間の無音の追加に合わせて、口形の区間も切り詰め / 閉口を
  足す (音と口の時刻が揃ったまま)。synthesize (visemes 付き) も sanoTTS の口形を返す。
  従来は口形が無く、Speech は音量エンベロープで口を動かしていた。

テスト: test_sano_clause (重み不要)。句の分割、無音の切り詰めと余白、句間の無音の
長さと話速への追従、中断 / 失敗 / 読めない句、音素 ID → 口形、口形の切り詰め、
そして口形の規則が使う音素 ID (母音 10..14、ん 20..23、っ 24、両唇音 35 36 37 38 51
52) が上流の仮名表 kSaanG2pMora と一致すること (上流の語彙が変わると落ちる)。CI
(host-tests.yml) にも追加。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
これまで句読点 (、。) ごとに区切って間を入れていたのは sanoTTS (と HMM の pau) だけで、
フォルマント / 単位連結は句読点を読み飛ばすため句の間に間が全く入らなかった。

- clause_stream.cpp: 句ごとの合成と送出 (stream_clauses) を独立させ、エンジン非依存にする。
  1 句の合成は ClauseSynth、送出は ClauseChunkFn として差し替える。前後の無音を削るかは
  trim_edges で切り替える (sanoTTS は削る / フォルマント・単位連結は削らない)。
- 句間の無音の長さは clause_pause_ms(mora_ms) の 1 か所で決める。HMM の pau (hmm_synth.cpp) も
  同じ値から導くので、どのエンジンでも間の長さが揃う (出力は従来と byte 単位で同一)。
- フォルマント / 単位連結 (jtts.cpp) を句ごとの合成 (render_clause_local) にし、一括版と
  ストリーミング版が同じ経路を通る。句読点の無い発話は従来と同じ出力。ストリーミングは
  句ごとに 1 チャンクになり、長文でも 1 句が収まれば PCM 上限に掛からない。
- sano 専用だった sano_clause.cpp は音素 ID → 口形の sano_visemes.cpp に縮める。
- ホスト テスト: test_sano_clause → test_clause_stream (trim_edges=false・フォルマント実物の
  句数 / 間の長さ / 口形の長さ / 一括版との一致 / スキップ / 中断を追加)、
  test_hmm_chunk の期待値 (長文 12 チャンク) を更新。CI の実行ファイル名も更新。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ciniml

ciniml commented Sep 23, 2026

Copy link
Copy Markdown
Owner

今気づきました、すみません。見ます。

@ciniml

ciniml commented Sep 23, 2026

Copy link
Copy Markdown
Owner

レビューありがとうございます。IDF v5.5.5 で cores3 / atoms3r をビルドし、ホストテスト (avatar / avatar_vm / jtts) を全て通したうえで読みました。hts_engine の sizeof(float*) 修正、吹き出しのバージョン付き完了通知、句ごとの合成の共通化と HMM のチャンク分割、SubtitleMapper のフォールバックなど、設計・エッジケースの扱いともに良いと思います。fork PR なので CI が走っていない点だけご留意ください (手元ビルドは通っています)。

マージ前にお願いしたい点が 1 つ、任意の点が 2 つあります。

1. main/speech.cpp の診断ログ (要対応)
ESP_LOGI("speech", "play: mck=%d bck=%d ws=%d dout=%d ...") が発話ごとに出るようになっています。コメントに「JTTS-on-Module-Audio が安定したら消す」とあるとおり作業用のログに見えるので、削除か ESP_LOGD への降格をお願いします。

2. ChunkPlayer::begin() / stop() の M5.Speaker.stop() (任意)
main/chunk_player.cpp の 2 箇所が引数なしの M5.Speaker.stop() で全チャンネルを止めています。元の speech.cpp も同じだったので退行ではありませんが、この PR でチャンネル (会話 0 / 発話 1 / say 2) を分けたので、M5.Speaker.stop(static_cast<std::uint8_t>(channel_)) にすると他チャンネルの再生を巻き込まずに済みます。

3. demo_loop.cpp の balloon_in_flight (任意)
バージョン付き通知でほぼ役目を終えていて、nadenade の経路にしか残っていないようです。フォローアップで整理できそうです。

補足: DSL 変数 ID 0x1F
こちらで並行して進めていたアクセサリ機能 (accessories / accessory_0..7) が同じ 0x1F を使っていたので、こちら側を 0x20..0x28 に振り直して main に push しました。mouth_form = 0x1F はそのままで大丈夫です (main の docs/avatar_dsl.md にも 0x1F を予約と明記済み)。マージ時に opcodes.hpp / opcodes.js の enum が隣接するので、その部分だけ軽く衝突するかもしれません。

@ciniml

ciniml commented Sep 23, 2026

Copy link
Copy Markdown
Owner

@mongonta0716 ↑の対応とconflictの解消だけよろです。たぶんPRに対応しろってClaudeに言えば終わると思います。

mongonta0716 and others added 2 commits September 24, 2026 00:11
- speech.cpp: Module Audio 調査用の Speaker ピン設定ログ (ESP_LOGI) を削除
- chunk_player.cpp: begin() / stop() の M5.Speaker.stop() をチャンネル指定にし、
  他チャンネル (会話 / say) の再生を巻き込まないようにする

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
アクセサリ スロット (accessories / accessory_0..7 = 0x20..0x28) と
mouth_form (0x1F) の隣接で衝突した opcodes.hpp / opcodes.js / avatar_dsl.md /
wasm/build.sh / test_vm.cpp を両方残す形で解消。test_vm の run_program は
DrawContext と FaceTuning の両方を受け取るようにした。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mongonta0716

Copy link
Copy Markdown
Contributor Author

ご確認ありがとうございます。対応しました。

  1. speech.cpp の診断ログは削除しました。
  2. ChunkPlayer::begin() / stop() を M5.Speaker.stop(ch) に変更し、自チャンネルのみ止めるようにしました。
  3. balloon_in_flight の整理はフォローアップで対応します。

main をマージし、mouth_form (0x1F) と accessories (0x20..0x28) を両方残す形で衝突を解消しました。avatar_vm / avatar のホストテストと cores3 のビルドは通過しています。

🤖 Generated with Claude Code

@ciniml
ciniml merged commit c81315e into ciniml:main Sep 23, 2026
@ciniml

ciniml commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

@mongonta0716 0.15.0-alpha.1 としてリリースしたので問題なさそうかお手すきの時に確認よろです。

https://www.fugafuga.org/stackchan-idf/?channel=alpha

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