Discord.jsで文字起こしをしてみた

:::note warn
この記事のベースは実験的にGithub Copilotに書いてもらいました。
~~他に制作中の物がいくつかあるから仕方よね....ね？~~
20%程は手動で書き直しています。
:::

## はじめに

フレンドから「OpenAI-Whisperモデルが無料でダウンロードできるよ！」と教えてもらったのがきっかけで、Discord上でリアルタイム音声文字起こしボットを作ってみました。

日本語での音声認識精度の高さと、ローカルで動作するプライバシー性の高さが魅力的だったので、実際にプロジェクトを立ち上げて様々な課題を解決していった過程をまとめます。

## 使用技術

- **Node.js** (TypeScript)
- **discord.js** v14
- **mopo-discordjs** (自己プロジェクト用 DiscordBot機能モジュール化ライブラリ)
- **OpenAI-Whisper** (nodejs-whisper)
- **FFmpeg** (音声変換)
- **prism-media** (Opus音声処理)

## プロジェクト概要

作成したボットの主な機能：

- **リアルタイム音声文字起こし**: Discord音声チャンネルでの発言をリアルタイムでテキスト化
- **Webhook表示**: Teams風に話者を表示
- **レポート出力**: 会話記録をテキストファイルで保存
- **音声録音**: 複数ユーザーの音声を時間軸で統合した録音ファイル出力
- **自動退室**: ボイスチャンネルが無人になると自動で退室

## 開発過程で解決した課題

### 1. nodejs-whisperライブラリとの出会い

大半の記事がPythonでwhisperを動かしていたのですが、npmを眺めていたら`nodejs-whisper`という素晴らしいライブラリを発見しました。

```bash
npm install nodejs-whisper
```

このライブラリを使えば、Node.js環境でWhisperモデルを直接利用できます。

### 2. cmake関連のトラブルとCUDA Toolkit対応

```bash
npx nodejs-whisper download
```

を実行した際に、cmakeのビルドでエラーが発生しました。

**解決方法**: CUDA Toolkitを再インストール

CUDA加速を有効にするため、NVIDIA CUDA Toolkitをインストールし直しました。

### 3. Visual Studio と CUDA Toolkit の統合問題

Visual StudioとCUDA Toolkitのintegrationがうまく動作しない問題が発生しました。

**解決方法**: 手動でIntegrationを実行

こちらの記事を参考に手動でintegrationを行いました：
[Windows11 での xgboost gpu build 時のエラー](https://zenn.dev/fehde/articles/9f75cf56f25f4a)

### 4. Discord音声データの処理問題

discord.jsでよく紹介されているopus -> wav変換ライブラリがうまく動作しませんでした。

**解決方法**: prism-media + FFmpegの組み合わせ

nodejs-whisper内部でFFmpegを使用することから、以下の処理フローに変更：

1. **Opus → PCM**: `prism-media`を使用
2. **PCM → WAV**: `FFmpeg`を使用

```typescript
// Opus to PCM conversion
private async encodeOpusToPcm(
  guildId: string,
  uuid: string,
  opusStream: AudioReceiveStream,
): Promise<void> {
  const opusDecoder = new prism.opus.Decoder({
    frameSize: 960,
    channels: 2,
    rate: 48000,
  });

  const out = fs.createWriteStream(
    path.join(Transcription.getGuildTempDir(guildId), `${uuid}.pcm`),
  );
  
  await pipeline(
    opusStream as unknown as NodeJS.ReadableStream,
    opusDecoder as unknown as NodeJS.WritableStream,
    out as unknown as NodeJS.WritableStream,
  );
}

// PCM to WAV conversion
private async encodePcmToWav(guildId: string, uuid: string): Promise<void> {
  const pcmFilePath = path.join(
    Transcription.getGuildTempDir(guildId),
    `${uuid}.pcm`,
  );
  const wavFilePath = path.join(
    Transcription.getGuildTempDir(guildId),
    `${uuid}.wav`,
  );

  const command = `ffmpeg -f s16le -ar 48k -ac 2 -i "${pcmFilePath}" "${wavFilePath}"`;
  const result = shell.exec(command);
  
  if (result.code !== 0) {
    throw new Error(`Failed to encode PCM to WAV: ${result.stderr}`);
  }
}
```

### 5. ノイズフィルタリングの実装

初期段階では前処理をしていなかったため、吐息などのノイズでWhisperが不適切な解釈をしてしまう問題がありました。

**解決方法**: 多層フィルタリングシステムの実装

#### 音声レベル検証

```typescript
private hasValidAudioLevel(pcmFilePath: string): boolean {
  const pcmData = fs.readFileSync(pcmFilePath);
  let sumSquared = 0;
  let maxAmplitude = 0;
  const sampleCount = pcmData.length / 2;

  // RMS計算
  for (let i = 0; i < pcmData.length; i += 2) {
    const sample = pcmData.readInt16LE(i);
    const amplitude = Math.abs(sample);
    sumSquared += sample * sample;
    maxAmplitude = Math.max(maxAmplitude, amplitude);
  }

  const rms = Math.sqrt(sumSquared / sampleCount);
  const rmsDb = 20 * Math.log10(rms / 32767);

  // 音量閾値: -40dB以上、最大振幅1000以上
  return rmsDb > -40 && maxAmplitude > 1000;
}
```

#### 音声活動検出（VAD）

```typescript
private detectVoiceActivity(pcmFilePath: string, durationInSeconds: number): boolean {
  const pcmData = fs.readFileSync(pcmFilePath);
  const frameSize = Math.floor(48000 * 0.025) * 2 * 2; // 25msフレーム
  const frameCount = Math.floor(pcmData.length / frameSize);
  
  let voiceFrames = 0;
  const energyThreshold = 1000000;

  for (let frame = 0; frame < frameCount; frame++) {
    const frameStart = frame * frameSize;
    const frameEnd = Math.min(frameStart + frameSize, pcmData.length);
    let frameEnergy = 0;

    for (let i = frameStart; i < frameEnd; i += 2) {
      const sample = pcmData.readInt16LE(i);
      frameEnergy += sample * sample;
    }

    if (frameEnergy > energyThreshold) voiceFrames++;
  }

  const voiceRatio = voiceFrames / frameCount;
  return voiceRatio >= 0.1; // 10%以上のフレームで音声検出が必要
}
```

#### 日本語文字起こし検証

```typescript
private isValidJapaneseTranscription(text: string): boolean {
  const trimmedText = text.trim();
  
  // 長さチェック
  if (trimmedText.length < 2 || trimmedText.length > 200) return false;

  // よくある誤認識パターンを除外
  const commonFalsePositives = [
    'ありがとうございました', 'お疲れ様でした', 'そうですね',
    'はい', 'いえ', 'うん', 'えーと', 'あのー'
  ];

  if (trimmedText.length <= 10) {
    const isCommonFalsePositive = commonFalsePositives.some(pattern =>
      trimmedText.includes(pattern)
    );
    if (isCommonFalsePositive) return false;
  }

  // 日本語文字の存在確認
  const japaneseCharRegex = /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/;
  const hasJapaneseChars = japaneseCharRegex.test(trimmedText);

  // 英語のみや記号のみを除外
  const englishOnlyRegex = /^[a-zA-Z\s.,!?]+$/;
  const symbolOnlyRegex = /^[.,!?。、！？\s\-_]+$/;
  
  return hasJapaneseChars && 
         !englishOnlyRegex.test(trimmedText) && 
         !symbolOnlyRegex.test(trimmedText);
}
```

### 6. 追加機能の実装

#### 録音機能

複数ユーザーの音声を時間軸で統合して1つのファイルに結合：

```typescript
private async mergeWithFFmpeg(
  inputFiles: { file: string; silence: number; userId: string }[],
  outputPath: string,
): Promise<void> {
  let command = 'ffmpeg -y';
  let filterComplex = '';

  inputFiles.forEach((input) => {
    command += ` -i "${input.file}"`;
  });

  let concatInputs = '';
  inputFiles.forEach((input, index) => {
    if (input.silence > 0) {
      const silenceDurationSec = input.silence / 1000;
      filterComplex += `anullsrc=channel_layout=stereo:sample_rate=48000:duration=${silenceDurationSec}[silence${index}];`;
      concatInputs += `[silence${index}][${index}:a]`;
    } else {
      concatInputs += `[${index}:a]`;
    }
  });

  filterComplex += `${concatInputs}concat=n=${inputFiles.length * 2}:v=0:a=1[out]`;
  command += ` -filter_complex "${filterComplex}" -map "[out]" "${outputPath}"`;

  const result = shell.exec(command);
  if (result.code !== 0) {
    throw new Error(`FFmpeg failed: ${result.stderr}`);
  }
}
```

#### レポート出力機能

会話の記録をテキストファイルとして出力：

```typescript
if (session.option.exportReport) {
  const user = await this.client.users.fetch(completedItem.userId);
  session.report += `User: ${user.displayName}(ID:${completedItem.userId})\n`;
  session.report += `Transcription: ${context}\n\n`;
}
```

## Whisperモデルの設定

```typescript
private static readonly whisperOptions: IOptions = {
  modelName: ModelName.LARGE_V3_TURBO,
  autoDownloadModelName: ModelName.LARGE_V3_TURBO,
  removeWavFileAfterTranscription: false,
  withCuda: true,
  logger: console,
  whisperOptions: {
    language: 'ja',
    wordTimestamps: false,
    timestamps_length: 20,
    splitOnWord: true,
    translateToEnglish: false,
  },
};
```

## 使用方法

### 1. セットアップ

```bash
# 依存関係のインストール
npm install

# Whisperモデルのダウンロード
npx nodejs-whisper download
```

### 2. 環境変数設定

```env
BOT_TOKEN=your_discord_bot_token
```

### 3. 実行

```bash
npm run start
```

### 4. Discordでの利用

```bash
/join [realtime:true] [report:true] [audio:true]
```

各オプションで機能を個別にON/OFF可能です。

## まとめ

OpenAI-Whisperとnodejs-whisperライブラリを使用することで、高精度な日本語音声認識をローカル環境で実現できました。

特に課題となったのは：

- CUDA環境の構築
- Discord音声データの適切な前処理
- ノイズフィルタリングの実装

これらを解決することで、実用的なDiscord音声文字起こしボットを作成できました。

音声認識技術の民主化が進む中、このようなツールが個人でも手軽に作れる時代になったことを実感しています。

残課題として、まだまだノイズ等による誤認識や音声の途切れによる不自然な改行等が見られる為、少しずつ改善していこうかと考えています。

さーて、Discordで会議する機会増えたしガンガン使っていこーっと....

## 参考リンク

- [nodejs-whisper](https://www.npmjs.com/package/nodejs-whisper)
- [discord.js](https://discord.js.org/)
- [OpenAI Whisper](https://openai.com/research/whisper)
- [CUDA Toolkit Integration 手動設定](https://zenn.dev/fehde/articles/9f75cf56f25f4a)

## GitHubリポジトリ

実際のコードは以下のリポジトリで公開しています：
https://github.com/Rollphes/discord-whisper