• #Electron
  • #Vue3
  • #TypeScript
  • #Deepgram
  • #Gemini AI
  • #音声認識
  • #リアルタイム処理
開発misc-dev

Electron×Vue3で作るリアルタイム文字起こしアプリ「即録くん」の開発記録

「即録くん」(sokuroku)の初期開発が一段落した。システム音声とマイク音声を同時にキャプチャして、Deepgram APIでリアルタイム文字起こしするElectronアプリだ。

プロジェクト初期化

electron-vite + Vue3 + TypeScriptの構成でスタート。最初の npm create electron-vite コマンドで、意外にもスムーズにボイラープレートが生成された。

npm create electron-vite sokuroku -- --template vue-ts
cd sokuroku
npm install
npm run dev

開発サーバーが立ち上がり、Electronウィンドウが開いた瞬間、「よし、これなら行ける」と手応えを感じた。

Step 1〜6の実装過程

Step 1: マイク音声取得(Web Audio API)

最初のマイルストーンは、マイクからの音声取得。ブラウザのgetUserMediaを使えば簡単だろうと高を括っていたが、Electronのセキュリティ設定で少し躓いた。

// レンダラープロセスでマイクアクセス
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const context = new AudioContext()
const source = context.createMediaStreamSource(stream)

コンソールに波形データが流れ始めた時、第一歩を踏み出せたと実感した。

Step 2: システム音声キャプチャ(desktopCapturer)

ここが最大の難関だった。当初はシンプルに考えていた:

  1. desktopCapturerでシステム音声を取得
  2. マイク音声とミックス
  3. 一つのストリームとして処理

しかし現実は甘くなかった。まず試したのはWeb Audio APIでのミキシング:

// 初回の試み - うまくいかなかった
const merger = context.createChannelMerger(2)
micSource.connect(merger, 0, 0)
systemSource.connect(merger, 0, 1)

結果は無音。ログを睨みながら3時間格闘したが、どうやらElectronでは別々のコンテキストから来た音声ソースは直接ミックスできないようだった。

別アプローチ:デュアルストリーム方式

ミキシングを諦め、2つのストリームを別々に管理する方針に転換:

// マイクとシステム音声を独立して処理
const micStream = await navigator.mediaDevices.getUserMedia({ audio: true })
const systemStream = await desktopCapturer.getSources({ types: ['screen'] })

この方式なら、少なくとも両方の音声を取得できる。ミキシングは後回しにして先に進むことにした。

Step 3: Deepgram連携

WebSocket接続でリアルタイム文字起こしを実装。最初はCORS関連のエラーで悩まされたが、Electronのメインプロセス経由で接続することで解決:

// メインプロセスでWebSocket接続を管理
const ws = new WebSocket('wss://api.deepgram.com/v1/listen', {
  headers: { 'Authorization': `Token ${apiKey}` }
})

初めて「こんにちは」という音声が文字として画面に表示された瞬間、思わず小さくガッツポーズをした。

Step 4: Gemini後処理

文字起こし結果の整形にGemini APIを導入。ただし、リアルタイムで毎回呼ぶとコストが跳ね上がるため、バッファリング戦略を採用:

// 30秒分の転写結果をバッファして一括処理
const buffer: string[] = []
const BUFFER_TIMEOUT = 30000 // 30秒

Step 5: システムトレイ常駐

タスクトレイに常駐させ、ホットキーで録音開始/停止を制御:

// Ctrl+Shift+R で録音トグル
globalShortcut.register('CommandOrControl+Shift+R', () => {
  mainWindow.webContents.send('toggle-recording')
})

トレイアイコンをクリックして、メニューから「録音開始」を選べるようにもした。これで本格的なツールらしくなってきた。

Step 6: UX改善

録音状態の視覚的フィードバック、転写結果のリアルタイム表示、エラーハンドリングを追加。特に録音中は赤い●アイコンを表示して、ユーザーが録音状態を一目で把握できるようにした。

.env運用と配布時のキー管理戦略

APIキーの管理で悩んだ。開発時は.envファイルで管理するが、配布時はどうするか?

検討した選択肢:

  1. ビルド時にキーを埋め込む → セキュリティ的にNG
  2. ユーザーに入力してもらう → UXが悪い
  3. 別途設定ファイルを用意 → 現実的

最終的に、初回起動時にAPIキー設定画面を表示し、ElectronのsafeStorageで暗号化保存する方式を採用することにした。

Deepgramの料金体系についての議論

料金計算で一瞬頭を抱えた。Deepgramは「0.0043/分」という料金体系。1時間録音したら約26セント。1日8時間使っても2程度。月20日稼働で$40前後。

「個人利用なら十分リーズナブルだな」と電卓を叩きながら思った。ただし、複数ユーザーに配布する場合は、各自でAPIキーを取得してもらう必要がある。

今後の課題

システム音声とマイク音声のミキシング問題は未解決のまま残っている。現状は2つのストリームを別々にDeepgramに送信しているが、理想は1つのストリームにミックスして送信したい。

また、Gemini後処理のレイテンシも改善の余地がある。30秒バッファは長すぎるかもしれない。

それでも、「動くものができた」という達成感は大きい。明日はミキシング問題に再挑戦してみようと思う。