[Electron] #04 Live2Dデスクトップマスコット — キャラごとの声、右クリックでの個別設定、サムネ付きのモデル一覧

[Electron] #04 Live2Dデスクトップマスコット — キャラごとの声、右クリックでの個別設定、サムネ付きのモデル一覧

はじめに

前回の記事の最後に「次は、しばらく普段使いして、反応の頻度やセリフを調整します。今度こそ。」と書きました。今回もその前に、やりたいことが先に出てきました。

前回、複数のキャラを同時に表示できるようにしました。5体並べて1日置いておくと、気になることがありました。見た目は全員違うのに、全員がハルの声でしゃべるのです。

できたものは次のとおりです。

  • VOICEVOX が起動していれば、クリックやランダムのセリフも、キャラごとに選んだ話者で話す
  • 声の設定を「ボイスパックだけ/VOICEVOX/ボイスパック+OS 音声/ミュート」の1つにまとめた
  • キャラを右クリックすると、そのキャラのモデル・声・話者・視線追従などを切り替えられる
  • VS Code の枠内に留める設定をキャラごとにした
  • 設定ウィンドウのモデル選択を、サムネ付きの一覧にした。サムネは画像をドロップして付けることもできる
  • 同じ名前のモデルを取り込むと、増やさずに上書きする

13時半に始めて、15時半に v1.5.0 を公開しました。

スクリーンショット

右クリックのメニューからキャラごとにモデルや声を切り替えるところ サムネ付きのモデル一覧になった設定ウィンドウ

リポジトリ・ダウンロード


1. 話者の設定は、もうキャラごとにあった

最初は「キャラごとに VOICEVOX の話者を選べるようにする」つもりでした。ところがコードを読むと、話者(voicevoxSpeaker)は前回からキャラごとの設定として characters.json に入っていて、設定ウィンドウでもキャラごとに変えられました。

それでも同じ声に聞こえていた原因は、2つありました。

  • クリックやランダムイベントで鳴るのは、ボイスパックの wav だった。既定のボイスパックは全員 haru なので、全員ハルの声になる。VOICEVOX は通らない
  • VOICEVOX を使うのは、時報やあいさつのような自作のセリフだけだった。しかも、それを読み上げるかどうかの設定(voice)は既定でオフ

つまり、話者を変えても、普段いちばん耳にするクリックの反応は変わりません。直すのは「ボイスパックのセリフも VOICEVOX で読む」ところでした。


2. ボイスパックのセリフを VOICEVOX で読む

ボイスパックの1件には、音声ファイルと字幕(text)が入っています。読み上げがオンで、エンジンが VOICEVOX のキャラは、wav の代わりにこの字幕をそのキャラの話者で合成して鳴らすことにしました。しぐさ(モーション)の合わせ方は今までのままです。

// src/renderer/app.js(抜粋)
function playVoice(v = pick(voices), { auto = false } = {}) {
  // ……話す順番の申し出、しぐさの再生
  const spoken = usesVoicevox() ? spokenText(v.text) : '';
  const audio = spoken
    ? synthVoicevox(spoken).then((wav) => wav || fs.promises.readFile(v.abs))
    : fs.promises.readFile(v.abs);
  audio
    .then((data) => { if (token === playToken) return playAudioData(data, token); })
    .catch((e) => { console.warn('ボイス再生失敗:', e); maybeEndSpeech(); });

  if (v.text) say(v.text, undefined, { tts: false, auto });
  return true;
}

VOICEVOX が起動していないときは、合成の結果が null で返ってくるので、元の wav に戻ります。OS 標準の読み上げには落としません。OS の声は全員同じなので、元の問題に戻るだけだからです。

合成した音声は playAudioData に渡すので、#01 で作った音量からの口パクも、ボイスパックの wav と同じように動きます。

読めないセリフ

ハルのボイスパックには、字幕が「(笑)」だけの声が1つあります。笑い声の wav です。そのまま VOICEVOX に渡すと「かっこわらい」と読みます。

括弧書きは外してから読むことにしました。外して何も残らないセリフは、最初は wav のまま鳴らしていましたが、そこだけハルの声が混ざるので、「(笑)」だけのときは「ふふっ」と読ませています。字幕は「(笑)」のままです。

// src/renderer/app.js(抜粋)
function spokenText(text) {
  const src = String(text || '');
  let t = src.replace(/[((][^))]*[))]/g, '').trim();
  if (!/[\p{L}\p{N}]/u.test(t) && /[((]笑[))]/.test(src)) t = 'ふふっ';
  return /[\p{L}\p{N}]/u.test(t) ? t : '';
}

「うふふ。いえ、なんでもありません。(笑)」のように文の後ろに付いている場合は、括弧だけ外して前の文を読みます。

合成結果は main プロセスで覚える

ボイスパックのセリフは決まった文なので、同じ話者・同じ話速なら毎回同じ音声になります。クリックのたびに合成を待つのはもったいないので、main プロセスで64件まで覚えておくことにしました。2回目からは、クリックしたらすぐに声が出ます。

// src/main/main.js(抜粋)
const synthCache = new Map();
const SYNTH_CACHE_MAX = 64;

ipcMain.handle('voicevox-synth', async (event, { text, speaker, speed = 1 }) => {
  const key = `${speaker}:${speed}:${text}`;
  const hit = synthCache.get(key);
  if (hit) {
    synthCache.delete(key); // 新しい側へ
    synthCache.set(key, hit);
    return hit;
  }
  // ……audio_query → synthesis
  const wav = new Uint8Array(await s.arrayBuffer());
  synthCache.set(key, wav);
  if (synthCache.size > SYNTH_CACHE_MAX) synthCache.delete(synthCache.keys().next().value);
  return wav;
});

Map は入れた順に並ぶので、使ったものを入れ直せば、先頭が一番長く使われていないものになります。あふれたら先頭を消すだけで、簡単な LRU になります。


3. 「オフなのにしゃべる」をログで切り分ける

パッチを当てて試してもらうと、「独り言はまだハルの声のまま。VOICEVOX を認識していない?」と返ってきました。「コミットした?」のような自作のセリフは VOICEVOX の声に変わっていて、ボイスパックの独り言だけがハルのままです。

ターミナルのログには、VOICEVOX につながらないときに出す警告が出ていませんでした。つまり VOICEVOX は見えています。コードを読み直しても、独り言は直した経路を通っています。

ここで推測で直すのはやめて、ボイスを鳴らすたびに、どちらで鳴らしたかをログに出すことにしました。

// src/renderer/app.js(抜粋)
console.log(spoken
  ? `[voice] VOICEVOX(${settings.voicevoxSpeaker}): ${spoken}`
  : `[voice] wav: ${v.text}(voice=${settings.voice} engine=${settings.ttsEngine})`);

結果は、ハルの声で独り言を言っていたキャラの読み上げがオフになっていた、でした。5体のうち、読み上げをオンにしていたのは一部のキャラだけで、オフのキャラはこれまでどおりボイスパックの wav を鳴らしていました。仕様どおりの動きです。

オフのキャラも、ボイスパックの声でしゃべります。だから「しゃべっているから、読み上げはオンだと思い込んでいた」。原因はコードではなく、「読み上げ」という名前でした。オフにしてもしゃべる設定に「読み上げ」と付けると、こう勘違いします。

ログは消さずに残しています。1回の発言で1行しか出ないので、次に似たことがあったときにすぐ分かります。

声の設定を1つにまとめる

それまでの設定画面には、「読み上げエンジン(VOICEVOX/OS 標準)」の選択と、「自作セリフを読み上げ」のチェックが別々にありました。この2つの組み合わせで何が鳴るのかは、作った本人でも分からなくなります。

そこで、見せ方を1つの選択にまとめました。

話し方クリック・ランダム(ボイスパックのセリフ)ひとこと・時報・VS Code への反応
ボイスパックだけボイスパックの声字幕だけ
VOICEVOXそのキャラの話者で読むそのキャラの話者で読む
ボイスパック+OS 音声ボイスパックの声OS の読み上げ
ミュート字幕だけ字幕だけ

保存の形は変えていません。今までの voice・ttsEngine に、新しく mute を1つ足しただけで、画面に出すときに1つの値に読み替えます。保存の形を変えると、前の版の設定を移し替える処理が要るからです。

// src/renderer/app.js(抜粋)
// 声の出し方:'pack'(ボイスパックだけ)/ 'voicevox' / 'os'(ボイスパック+OS 音声)/ 'mute'
function voiceModeOf(s) {
  if (s.mute) return 'mute';
  if (!s.voice) return 'pack';
  return s.ttsEngine === 'os' ? 'os' : 'voicevox';
}

function setVoiceMode(mode) {
  settings.mute = mode === 'mute';
  if (mode === 'pack') settings.voice = false;
  if (mode === 'voicevox' || mode === 'os') { settings.voice = true; settings.ttsEngine = mode; }
  if (settings.mute) stopVoice();
  saveSettings();
}

ミュートは、マスコットに大事なことのうち「邪魔しない」のための設定です。音を出さずに、字幕としぐさで反応します。


4. 右クリックで、そのキャラの設定を変える

5体並べると、設定ウィンドウで「キャラ」欄からキャラを選び直してから設定を変えるのが面倒になります。触っているキャラを右クリックして、そのキャラの設定を変えられるようにしました。

💬 話しかける
───────────
👗 モデル           ▸ 同梱/ライブラリの一覧
🔊 声              ▸ ボイスパックだけ/VOICEVOX/ボイスパック+OS 音声/ミュート
🎙 VOICEVOX の話者   ▸ 話者 ▸ スタイル
───────────
✓ 視線追従
✓ ランダムイベント
✓ 時報(代表のみ)
✓ VS Code に追従
───────────
⚙ 設定を開く / 📜 クレジット / 🙈 隠す / ❌ 終了

右クリックのメニューは、OS 標準のメニュー(Electron の Menu)です。ここにはプルダウンを入れられません。そのかわり、サブメニューにラジオボタンを並べると、プルダウンと同じように「今の値に印が付いた一覧から1つ選ぶ」形になります。どのアプリもこの作りです。

操作は設定ウィンドウと同じ IPC で送る

メニューの中身は、main プロセスが作ります。main プロセスは、各キャラから届いた最新の状態(settings-state)を覚えているので、レンダラーに問い合わせなくても今の設定でメニューを作れます。

選ばれた操作は、設定ウィンドウが送るのと同じ settings-action でキャラに送ります。マスコット側の受け口は1つのままで、右クリックで変えても設定ウィンドウで変えても、両方の表示がそろいます。

// src/main/main.js(抜粋)
onMascot('show-context-menu', async (m) => {
  const st = lastStates.get(m.id)?.settings || {};
  const act = (type, extra = {}) => m.win.webContents.send('settings-action', { type, ...extra });
  const set = (key, value) => act('set', { key, value });
  const mode = voiceModeOf(st);

  const template = [
    { label: '💬 話しかける', click: () => act('talk') },
    { type: 'separator' },
    { label: '👗 モデル', submenu: modelItems },
    {
      label: '🔊 声',
      submenu: VOICE_MODES.map(([value, label]) => ({
        label, type: 'radio', checked: mode === value, click: () => act('voice-mode', { value })
      }))
    },
    speakerItem,
    { type: 'separator' },
    { label: '👀 視線追従', type: 'checkbox', checked: st.gaze !== false, click: (item) => set('gaze', item.checked) },
    // ……ランダムイベント・時報・VS Code 追従
  ];
  Menu.buildFromTemplate(template).popup(m.win);
});

話者の一覧

VOICEVOX の話者は、話者(四国めたん、ずんだもん…)の下にスタイル(ノーマル、あまあま…)がある2段の構造なので、サブメニューも2段にしました。

サブメニューを持つ項目には、チェックの印を付けられません。今どの話者を選んでいるかが分からないので、話者名の頭に「●」を付けて示しています。

submenu: speakers.map((sp) => ({
  // サブメニューを持つ項目にはチェックを付けられないので、今の話者は印で示す
  label: (sp.styles.some((t) => t.id === st.voicevoxSpeaker) ? '● ' : ' ') + sp.name,
  submenu: sp.styles.map((t) => ({
    label: t.name,
    type: 'radio',
    checked: t.id === st.voicevoxSpeaker,
    click: () => set('voicevoxSpeaker', t.id)
  }))
}))

話者の一覧は VOICEVOX から取ってくるので、右クリックのたびに問い合わせるとメニューが出るのが遅れます。30秒は覚えておき、取りに行くときも右クリック用は 0.8 秒で諦めます。VOICEVOX が起動していなければ、項目は灰色にして「エンジン未起動」と出します。

右クリックのメニューにあった「モデルを取り込む」は外しました。モデルの取り込みは、キャラへのドロップと設定ウィンドウで足ります。


5. VS Code 追従をキャラごとに

VS Code の枠内に留めるモード(固定モード)は、全キャラ共通で、しかも保存していませんでした。起動するたびにオンに戻ります。

これが別の勘違いも生んでいました。キャラの位置は足元の中心で覚えていますが、起動した直後に固定モードが VS Code の枠内に寄せるので、「位置を覚えていない」ように見えていたのです。

最初は全体のモードを保存するようにしましたが、右クリックに入れるときに「キャラごとにできる?」と聞かれました。枠内に寄せる処理(clampToVSCode(m))は、もともとキャラごとに呼んでいます。全体のフラグを、キャラの設定の follow に変えるだけで済みました。「代表は VS Code の横に、ほかは画面のどこかに」という置き方ができます。

// src/main/main.js(抜粋)
function follows(m) {
  if (!tracker.supported) return false;           // VS Code の位置が取れるのは Windows だけ
  const st = lastStates.get(m.id)?.settings ?? store.get(m.id)?.settings;
  return st?.follow !== false;
}

function clampPosition(m, x, y) {
  if (!follows(m) || !vscodeRect) return { x, y };
  // ……体の範囲が VS Code の中に収まるように押し戻す
}

設定が変わったときは、settings-state を受け取ったところで clampToVSCode(m) を呼びます。オンにした瞬間に枠内へ寄ります。

一度「全体のモード」として保存する版を作って手元で使っていたので、その値(shared.trackingMode)がオフなら、キャラごとの follow に引き継ぐようにしています。


6. 設定ウィンドウを整理する

設定ウィンドウは、作りを急いでいたので、項目を足した順に並んでいました。キャラの設定の途中に全キャラ共通の「動作モード」が挟まっていたり、モデルの取り込みがモデルのプルダウンの一番下に「操作」として入っていたりしました。

  • 「見た目/声/ふるまい/全キャラ共通」の見出しで分けた。全キャラ共通(ライブラリとモーションの置き場所)は一番下
  • 声は「話し方」の1つの選択と、VOICEVOX のときだけ出る話者・話速
  • 取り込みはモデルの見出しの横のボタンにした

設定ウィンドウは値を持たず、操作を送って、返ってきた状態を描き直すだけ、という #02 の設計はそのままです。今回の整理も HTML の並べ替えがほとんどでした。


7. 同じ名前のモデルは上書きする

これまでは、同じ名前のモデルを取り込むと lain02_2、lain02_3 と名前をずらして増やしていました。間違えて何度もドロップすると、ライブラリに同じモデルが並びます。上書きにしました。

// src/main/library.js(抜粋)
// 取り込み先のフォルダ名。同じ名前があれば上書きする(間違えて何度もドロップしても増えないように)
function targetName(base) {
  const dir = libraryDir();
  const name = base.replace(/[\\/:*?"<>|]/g, '_').trim() || 'model';
  const dest = path.resolve(dir, name);
  if (!dest.startsWith(dir + path.sep)) throw new Error('invalid name');
  fs.rmSync(dest, { recursive: true, force: true });
  return name;
}

上書きにすると、1つ危ないことが起きます。ライブラリの中にあるモデルのフォルダをドロップすると、コピーする前に、コピー元の自分を消してしまいます。ライブラリの中のものを取り込もうとしたときは、断るようにしました。


8. サムネ付きのモデル一覧

モデルが10体を超えると、プルダウンの名前だけでは、どれがどのキャラか分かりにくくなります。サイトの VRM の選択メニューでは、もうサムネを並べていました。

最初は「プルダウンの中にサムネを入れる」ことを考えました。ところが、HTML の <select> には画像を入れられません。開く・閉じる・はみ出さない位置に出す・外をクリックしたら閉じる、を全部自分で作ることになります。

エクスプローラのように、サムネを並べっぱなしにするほうが軽いと分かりました。並べて、クリックで選んで、今のモデルに枠を付けるだけです。

/* src/renderer/settings.html(抜粋) */
.model-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
  gap: 8px;
  max-height: 330px;
  overflow-y: auto;
}
.model-tile .name {
  word-break: break-all;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}

列の数は auto-fill で窓の幅から決まります。340px の窓なら3列、広げれば4列以上になります。名前は2行まで折り返し、それより長ければ「…」で切ります。一覧の高さは止めて、中でスクロールします。

VRM の埋め込みサムネを取り出す

VRM には、モデルの中にサムネの画像を入れておく場所があります。

  • VRM 0.x:extensions.VRM.meta.texture(テクスチャの番号)→ textures[n].source(画像の番号)
  • VRM 1.0:extensions.VRMC_vrm.meta.thumbnailImage(画像の番号)

画像の番号から images と bufferViews をたどると、バイナリのどこからどこまでが画像かが分かります。この読み方は、以前作った VRM Thumbnail Editor のコードをそのまま使いました。

ブラウザのツールでは VRM 全体を ArrayBuffer に読んでいましたが、マスコットでは一覧に並ぶモデル全部を読みます。VRM は1体で数十 MB あるので、全体は読まず、先頭のヘッダ、JSON の部分、画像の部分だけを読むことにしました。

// src/main/thumbs.js(抜粋)
function readAt(fd, offset, length) {
  const buf = Buffer.alloc(length);
  fs.readSync(fd, buf, 0, length, offset);
  return buf;
}

function vrmThumbnailBytes(file) {
  const fd = fs.openSync(file, 'r');
  try {
    const head = readAt(fd, 0, 20);
    if (head.readUInt32LE(0) !== 0x46546c67) return null;  // 'glTF'
    const jsonLength = head.readUInt32LE(12);
    if (head.readUInt32LE(16) !== 0x4e4f534a) return null; // 'JSON'
    const json = JSON.parse(readAt(fd, 20, jsonLength).toString('utf8'));

    const vrm0 = json.extensions?.VRM?.meta;
    const vrm1 = json.extensions?.VRMC_vrm?.meta;
    let imageIndex = null;
    if (vrm0?.texture != null && vrm0.texture >= 0) imageIndex = json.textures?.[vrm0.texture]?.source;
    else if (vrm1?.thumbnailImage != null) imageIndex = vrm1.thumbnailImage;
    if (imageIndex == null) return null;

    const bv = json.bufferViews?.[json.images?.[imageIndex]?.bufferView];
    if (!bv) return null;
    const binStart = 20 + jsonLength + 8; // BIN チャンクのヘッダ(8バイト)の後
    return readAt(fd, binStart + (bv.byteOffset || 0), bv.byteLength);
  } finally {
    fs.closeSync(fd);
  }
}

GLB(VRM の中身)は、12バイトのヘッダのあとに「長さ・種類・中身」のチャンクが続きます。1つ目が JSON、2つ目がバイナリ(BIN)です。BIN の中身はチャンクのヘッダ8バイトの後から始まるので、そこに bufferView の byteOffset を足せば、画像の先頭です。

取り出した画像は 1024px 四方のことが多いので、Electron の nativeImage で 192px に縮めてから data URL にして、設定ウィンドウに渡します。縮めたものはファイルの更新日時と一緒に覚えておき、日時が変わったら読み直します。

サムネが無いモデルは撮影しない

Live2D のモデルには、決まったサムネの置き場所がありません。サムネの入っていない VRM もあります。読み込んだときに画面を撮影してサムネにする案もありましたが、やめました。VRM のサムネは VRM Thumbnail Editor で自分で入れられるからです。サムネの無いモデルは、名前の頭文字を出しています。

かわりに、自分で画像を付けられるようにしました。一覧のタイルに画像(PNG・JPEG・WebP)をドロップすると、確認のダイアログが出ます。

「mao_pro」にサムネ画像「img26389.png」を追加しますか?

OK を押すと、そのモデルのフォルダ(models/mao_pro/)に thumb.png としてコピーします。もう付いているときは「入れ替えますか?」と聞き、前の thumb.* を消してから入れます。サムネは thumb.* → VRM の埋め込み → 頭文字の順に探すので、Live2D のモデルにも、埋め込みのサムネを差し替えたい VRM にも使えます。

変わるのはサムネの画像だけで、モデルのファイルには触りません。間違えたら正しい画像をもう一度ドロップすればいいので、確認のダイアログ1つで十分だと判断しました。

ここで、7章の上書きとぶつかりました。同じ名前で取り込み直すとフォルダごと消すので、付けたサムネも一緒に消えます。取り込む前に thumb.* を退避しておき、取り込んだものにサムネが入っていなければ戻すようにしました。


9. 試してから渡す

今回も AI に git format-patch のパッチで作ってもらい、手元で git am して取り込みました。パッチは8本になりました。

AI の作業環境では、Electron を仮想ディスプレイ(Xvfb)の上で動かして確かめています。今回は次のことを自動で確かめてから受け取りました。

  • 右クリックのメニューの中身。Menu.buildFromTemplate を差し替えて、作られたメニューを文字にして見る。代表以外のキャラは時報が灰色になること、VOICEVOX が無いときに話者が灰色になること
  • 声の切り替えとミュートで、設定の値が正しく変わること
  • 旧設定(全体の固定モードがオフ)が、キャラごとの設定に引き継がれること
  • サムネの取り出し。Python で埋め込みサムネ入りの VRM(0.x と 1.0)とサムネ無しの VRM を作り、色で正しい画像が出ているかを見る
  • thumb.* の追加・入れ替え・優先順と、取り込み直してもサムネが残ること
  • 設定ウィンドウを撮影して、一覧の並びと長い名前の折り返しを見る

Cubism Core とサンプルモデルが無いので、キャラは描かれません。本物のモデルでの見た目、確認のダイアログ、VOICEVOX の声は、手元で確かめました。

v1.4.0 のときに確かめないまま出していた「Mac で Space を切り替えたら全員ついてくるか」も、今回のビルドで確かめました。全員ついてきました。Linux(Debian 12、xrdp 越し)では、タイルへの画像のドロップまで動きました。3 OS で地雷が一番多かったのは Linux でしたが、今回は一発で動いています。


まとめ

  • 機能を足す前に、今のコードを読む。話者の設定はもうキャラごとにあって、足りなかったのは「ボイスパックのセリフも VOICEVOX で読む」ところだった。
  • ボイスパックの字幕を VOICEVOX で合成すれば、wav と差し替えるだけでキャラごとの声になる。合成できなければ wav に戻す。決まった文の合成結果は main プロセスで覚える。
  • 「直したのに直っていない」ときは、推測で直さず、どちらの経路を通ったかをログに出す。今回はコードではなく、設定の名前が勘違いの原因だった。
  • 組み合わせで意味が変わる設定は、画面では1つの選択にまとめる。保存の形は変えず、読み替えるだけにすれば移し替えが要らない。
  • ネイティブの右クリックメニューでは、サブメニューにラジオを並べるのがプルダウンの代わり。サブメニューを持つ項目にはチェックを付けられないので、印で示す。
  • 右クリックと設定ウィンドウの操作は同じ IPC で送る。受け口が1つなら、どちらで変えても表示がそろう。
  • 全体のフラグを、キャラごとの設定に移すだけで済むことがある。処理がもともとキャラごとに呼ばれているなら。
  • <select> に画像は入らない。サムネで選ばせるなら、プルダウンを作り込むより、CSS Grid の auto-fill で並べっぱなしにするほうが軽い。
  • VRM の埋め込みサムネは、GLB のヘッダと JSON チャンクを読めば場所が分かる。全体を読まずに、その部分だけ読む。

次こそ、しばらく普段使いして、反応の頻度やセリフを調整します。