• #Electron
  • #Vue 3
  • #TypeScript
  • #Deepgram
  • #リアルタイム文字起こし
  • #AudioWorklet
  • #electron-builder
  • #VOICEVOX
開発misc-devメモ

即録くん(Sokuroku) -- ゼロからv0.4.2までの1日

前日に計画書を書いて設計を固めていたリアルタイム文字起こしアプリ「即録くん」を、朝から一気に実装した。Step 1のスキャフォールドから始めて、マイク入力、システム音声キャプチャ、2チャンネル分離、exe配布、VOICEVOX連携まで辿り着いた。途中でステレオ→モノラル変換に30分ハマり、AudioWorkletがexeに含まれない問題で手が止まり、Windows Smart App Controlに最後まで阻まれた。

Step 1〜2: スキャフォールドからマイクMVPまで

前日のCodexレビューで指摘された --template vue-ts オプションを使い、pnpm create @electron-vite sokuroku --template vue-ts でプロジェクトを生成。対話型CLIのプロンプトをスキップでき、そのままDeepgram Nova-3のWebSocket接続を実装した。音声パイプラインの構成は前日の計画通り:

マイク(48kHz) → AudioWorklet(16kHz Int16変換) → WebSocket → Deepgram → IPC → Vue画面

AudioWorkletでFloat32→Int16への変換とダウンサンプリングを行い、メインプロセスのWebSocket経由でDeepgramに送信する。APIキーがレンダラーに露出しないよう、Deepgramとの通信はすべてメインプロセスが担う。

マイク入力だけの文字起こしは1時間ほどで動いた。

Step 2.5: desktopCapturer PoCでシステム音声キャプチャ検証

計画書にPoC検証ステップを入れておいたのが効いた。desktopCapturer.getSources({ types: ['screen'] }) でシステム音声をキャプチャできるか試した。YouTubeを再生しながら波形データを取得し、実際に音声波形がゼロでないことを確認。Windows 11 + Electron環境では追加ソフト(BlackHole等)なしでシステム音声が取れる。

ただし権限周りで問題が出た。本番ビルドでは desktopCapturer がブロックされ、setDisplayMediaRequestHandlersession.setPermissionCheckHandler の両方を設定する必要があった。これは後のexeビルドでも再び効いてくる。

Step 3: マイク + システム音声のミキシング -- ステレオ変換の罠

マイクとシステム音声を同時にキャプチャして混ぜる段階で、最初の大きなハマりが来た。

desktopCapturer が返すシステム音声はステレオ(2ch)で、マイクはモノラル(1ch)。これをそのままChannelMergerNodeに繋ぐとチャンネル数が合わずに音声が片側に寄る。GainNodeを挟んでステレオ→モノラルに変換する処理を入れて解決した。

システム音声(ステレオ) → ChannelSplitter → L/R平均化 → モノラル → GainNode → Merger

「モノラル同士をマージすればいい」という前提が崩れた瞬間だった。Web Audio APIのチャンネルカウントの挙動は仕様を読んだだけでは掴みにくく、実際に音を流して確認するしかない。

マルチチャンネル対応: MIC/SYSを2チャンネルに分離

当初はマイクとシステム音声をミックスして1チャンネルで送っていたが、「誰が話しているか」を区別するためにDeepgramの multichannel: true オプションを使い、2チャンネルで送信する方式に切り替えた。MICが左チャンネル、SYSが右チャンネル。Deepgramがチャンネルごとに文字起こし結果を返してくれるので、UIで発話元を色分けできる。

言語切り替えとMICミュートの試行錯誤

言語: multi → 手動切替

最初はDeepgramの language: "multi" で自動言語検出を使っていた。しかし日本語の会議中に英語の固有名詞が出ると、それ以降の日本語まで英語として認識される現象が頻発した。結局、EN / JA / AUTO のラジオボタンで手動切替する方式に落ち着いた。

自動ダッキング → 削除して手動切替へ

相手が話しているとき(SYS側の音量が高い)にMICを自動ミュートする「ダッキング」機能を実装した。しかし閾値の調整が難しく、静かな環境では問題なく動くが、BGMやタイピング音で誤発火する。チューニングに時間をかけるよりも、ショートカットキーでMICのON/OFFを手動切替するほうが確実だった。自動化が裏目に出るケースとして記憶に残った。

UIの進化: 2カラム → 3カラム → 文の結合

文字起こし結果の表示は何度も変えた。

  1. 初期: 1カラムにMIC/SYS混在で表示
  2. 2カラム: MIC列とSYS列に分離、間にTIME列を挿入して3カラムに
  3. TIME表示改善: DeepgramのstartTime(セッション開始からの秒数)ではなく、録音開始からの経過時間に変更。30秒ごとに自動改行を入れて区切りを作った
  4. 文の結合: 句点(。)が来るまで同じチャンネルの連続エントリを結合。短い断片が並ぶ表示から、読める文章に変わった

APIキー管理: .envからelectron-storeへ

開発中は .env でAPIキーを管理していたが、exeで配布する場合はユーザーが自分のキーを入力する必要がある。electron-store に移行し、設定画面(モーダル)からDeepgram APIキーとGemini APIキーを入力・保存できるようにした。暗号化はelectron-storeのデフォルト機能を使っている。

Deepgramの残高表示も付けた。Admin権限(billing:readスコープ)のAPIキーが必要で、通常のキーでは残高が取得できない。Electronの net モジュールでDeepgramのAdmin APIを叩いている。

exeビルド: 3つの壁

electron-builder + NSISでexeを作る段階で、開発時には見えなかった問題が3つ連続で出た。

壁1: AudioWorkletファイルが含まれない

AudioWorkletのプロセッサーファイル(.js)が本番ビルドのasarアーカイブに含まれず、実行時に404になる。Viteのビルドパイプラインが外部ワーカーファイルを自動的には拾ってくれない。

解決策として、AudioWorkletのコードをテンプレートリテラルに埋め込み、Blob URLで読み込む方式に変えた:

const workletCode = `class Processor extends AudioWorkletProcessor { ... }`
const blob = new Blob([workletCode], { type: 'application/javascript' })
await audioContext.audioWorklet.addModule(URL.createObjectURL(blob))

ファイルの存在に依存しなくなり、ビルド設定を気にする必要がなくなった。

壁2: CSPがmedia://をブロック

Content Security Policy(CSP)が file:// コンテキストでのメディアアクセスをブロックしていた。開発時は localhost で動いているので問題にならないが、本番ではElectronが file:// プロトコルでHTMLを読み込むため、CSPのmedia-srcディレクティブに引っかかる。

CSPのメタタグを削除して解決した。Electronのデスクトップアプリではブラウザと同じレベルのCSPは必要なく、むしろ害になる。

壁3: Windows Smart App Control (SAC)

署名なしのexeがWindows Smart App Controlにブロックされる。コード署名証明書(年間数万円)を買わない限り回避できない。SACをオフにするしかないが、SACは一度オフにすると再度オンにはできない。zip配布も試したがSACの判定はファイル形式に関係なく適用される。

個人配布の範囲ではSACオフを案内するしかない、という結論になった。

NSISカスタムスクリプト

インストーラー実行時に旧バージョンのプロセスが残っているとファイル上書きに失敗する。NSISのカスタムスクリプトで旧プロセスを自動killする処理を追加した。

Codex(GPT-5.4)レビューで4件修正

ある程度動く状態になったところでCodexにコードレビューを依頼した。返ってきた指摘:

  1. メディアストリームのリーク: 録音停止時に MediaStream.getTracks().forEach(t => t.stop()) を呼んでいなかった
  2. エントリID衝突: タイムスタンプベースのIDが同時刻のエントリで重複する可能性 → UUIDに変更
  3. 時系列ソート: MICとSYSのエントリが到着順で並んでいたが、タイムスタンプでソートすべき
  4. MICラジオボタン: デフォルトがONだが、会議開始前にOFFにしたいケースを想定してデフォルトOFFに変更

どれも「動いているが正しくない」系の指摘で、自分では見落としていた。

VOICEVOX連携とずんだもん会議参加

文字起こしとは逆方向の実験として、VOICEVOX(ずんだもん)の音声をGoogle Meetに流し込む検証をした。文字起こし結果を要約してVOICEVOXに渡し、生成された音声をスピーカーから再生すると、Google Meetが拾って相手に聞こえる。

/summarize-meeting スキルも作成した。即録くんが出力するJSONLログを読み込んで要約を生成し、memo/に保存する。voice 引数をつけるとずんだもんが読み上げる。差分要約にも対応し、既存の要約がある場合は新規エントリのみ処理して追記する。

Step 4〜6: Gemini後処理、トレイ常駐、UX改善

Step 4のGemini Flash後処理(誤字修正用)はAPIキー未設定のまま骨組みだけ入れた。fire-and-forget方式で、Deepgramの確定テキストを即座にUIに表示し、裏でGeminiに投げて修正結果が返ったら差し替える設計。動作検証は後日。

Step 5でシステムトレイ常駐とグローバルショートカット(Ctrl+Shift+R)を実装。最小化してもトレイに残り、ショートカットで録音のON/OFFを切り替えられる。

Step 6はUX改善の詰め込み。コピーボタン、テキストクリア、録音状態の視覚的な表示(赤い点滅アイコン)を追加した。

最終バージョン: v0.4.2

1日でv0.4.2まで到達した。最終的なデフォルト設定はMICオフ、言語JA。

主要な機能:

  • マイク + システム音声のリアルタイム文字起こし(Deepgram Nova-3)
  • MIC/SYSの2チャンネル分離表示(3カラムレイアウト)
  • 言語切替(EN / JA / AUTO)
  • Gemini Flash後処理(誤字修正、未検証)
  • システムトレイ常駐 + グローバルショートカット(Ctrl+Shift+R)
  • 設定画面(APIキー管理)、履歴画面、タブ切替UI
  • コピーボタン、テキストクリア、録音状態表示
  • exe配布(electron-builder + NSIS)

振り返り

計画を前日に書いておいたおかげで、朝から実装にフルスピードで入れた。設計の8割はそのまま通り、残り2割(ステレオ変換、自動ダッキング、AudioWorkletインライン化)は実際に動かして初めて見えた問題だった。

自動ダッキングは「閾値を自動で決めるのは無理だ」と気づいて30分で捨てた判断が正しかった。動かないものに時間をかけるより、手動切替で確実に動くものを選ぶ。exeビルドの3連続トラブル(AudioWorklet、CSP、SAC)は開発環境と本番環境の差分が全て一気に噴き出した形で、「devで動く」と「配布できる」の間にある溝の深さを体感した。