ずんだもんがリアルタイムで会議内容をレビューし読み上げるプログラムを作る

:::note info
本記事は2024年6月に執筆しました。
今後もっと効率的な実現方法が出てくるかと思います。
:::

![03.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2951879/fcc974ac-d576-057b-2559-b56f77544b67.jpeg)

# 趣旨
前回の記事（[GPT-4o同士で会話して会議を代行するプログラムを作る（プロト版）](https://qiita.com/kdi384ldp/items/8b8714d5f0a8395a1eb7)）執筆中に、「ChatGPTの出力内容を音声合成ソフトで読み上げることが出来るのでは？」と思いついたので、作ってみた。

# 環境
| 製品        | バージョン                |
|:------------|:-------------------|
|Windows 11|23H2|
|Ruby|3.3.1|
|ChatGPT API|モデルはGPT-4oを指定|
|Google Speech-To-Text|-|
|Google Cloud Storage|-|
|VOICEVOX|0.19.1|
|AssistantSeika|20240612/c|
|ffmpeg|N-115813-gedfca84a6f-20240612|
|VB-CABLE Driver Pack（必須ではありません）|43|

# シナリオ
ChatGPTの出力内容をそのまま読み上げるだけでは味気無いので、会議に利用するシナリオをもとにプログラムを作成します。

会議中、定期的にずんだもんが進行中の議事内容をレビューして発声するようにしてみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2951879/18e3a94f-9bd8-8cc2-e380-9360ce541b6b.png)

# 本プログラムで狙う効果
定期的にレビュー内容を発声することによって、

- 議論が明後日の方向に向かわないよう、ずんだもんがコントロールする
- 議論の観点をずんだもんが提案する

などの効果を狙います。

# 構成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2951879/4fd87622-cb74-aa83-2d2f-ec5392028af7.png)

1. ずんだもん（VOICEVOX）がインストールされたPCで会議内容を録音し、音声ファイルにして保存する
2. 音声ファイルをCloud Storageにアップロードする
3. Cloud Storage上の音声ファイルを、Speech-to-Textで文字起こしする
4. 文字起こしした内容を、ChatGPTに入力して分析する
5. 分析結果をずんだもんで読み上げる

# 準備
### ソフトのインストール
#### AssistantSeika
今回の肝です。現時点では、音声合成ソフトにはコマンドラインなどを使って実行できるインターフェースはありません。
しかし、外部プログラムから操作したいという、私と同じ考えを持つ方が少なからずいるようで、有志の方がツールを公開してくれています。今回はそれを利用します。

https://wiki.hgotoh.jp/documents/tools/assistantseika/assistantseika-000

#### VOICEVOX
AssistantSeikaが対応している音声合成ソフトなら何でもよいです。今回はVOICEVOXを利用します。

https://voicevox.hiroshiba.jp/

#### ffmpeg
録音に使います。

https://ffmpeg.org/download.html

#### VB-Cable（必須ではありません）
私がぼっちであるが故に、複数人で会議のロールプレイができません。その代わりとして、今回は仮想オーディオデバイスを使ってYouTubeにある対談動画の音声を録音することにします。

https://vb-audio.com/Cable/

その他のソフトについては割愛します。

### 音声合成ソフトの話者cid確認
AssistantSeikaで、読み上げたい話者のcidを確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2951879/b06c3b70-a7ad-9c93-c842-2a4f19d89d81.png)

### SeikaSay2.exeのフルパス
AssistantSeikaに、SeikaSay2.exeというプログラムが同梱されています。これをRubyから呼び出すことで、任意のテキスト読み上げを制御します。その処理のために、SeikaSay2.exeのフルパスを控えておきます。

### その他の準備
以下の準備が必要です。
- ChatGPT APIを有効化して秘密鍵を取得
- Speech-to-Text APIを有効化して秘密鍵を取得
- Cloud Storageに音声ファイルのアップロード先となるバケットを作成
- バケットに対して権限設定（ファイルのRead/Writeが出来るように）

全部書くと長くなるので、ここでは割愛します。

# プログラム
### 全体像
#### フォルダ構成
```
app_root
├── audio_output
├── key
│   └── speech-to-text-key.json
├── logic
│   ├── conversation_history.rb
│   ├── gpt_service.rb
│   ├── person.rb
│   ├── speech_to_text_service.rb
│   ├── upload_service.rb
│   ├── voice_recorder.rb
│   └── voicevox_adaptor.rb
├── Gemfile
├── Gemfile.lock
└── Main.rb
```

#### 主要なクラスなどの説明
| 項目                      | 説明                                          |
|:--------------------------|:---------------------------------------------|
| audio_output              | 録音した音声ファイルの保存先                  |
| speech-to-text-key.json   | Speech-to-Text APIを利用するための秘密鍵      |
| conversation_history.rb   | ChatGPT APIの入出力履歴クラス                 |
| gpt_service.rb            | ChatGPT APIを利用するクラス                   |
| person.rb                 | ペルソナ用のクラス                            |
| speech_to_text_service.rb | Speech-to-Text APIを利用するクラス            |
| upload_service.rb         | Cloud Storageへファイルをアップロードするクラス |
| voice_recorder.rb         | マイクで録音するためのクラス                   |
| voicevox_adaptor.rb       | VOICEVOXで読み上げるためのクラス               |

### 内容
```ruby:Gemfile
source 'https://rubygems.org'

gem 'google-cloud-speech'
gem 'google-cloud-storage'
```

```ruby:Main.rb
require_relative 'logic/voice_recorder'
require_relative 'logic/speech_to_text_service'
require_relative 'logic/upload_service'
require_relative 'logic/conversation_history'
require_relative 'logic/gpt_service'
require_relative 'logic/person'
require_relative 'logic/voicevox_adaptor'

class Main 
  WAV_EXT = ".wav".freeze
  MP3_EXT = ".mp3".freeze
  AUDIO_FILE_PATH = "audio_output/".freeze
  DEVICE_NAME = "CABLE Output (VB-Audio Virtual Cable)".freeze   # 今回は仮想オーディオデバイスを指定

  # ChatGPTの会話履歴保存用
  history = ConversationHistory.new

  # ChatGPT API接続サービス
  gpt_service = GptService.new(max_output_length: 200, conversation_history: history)

  # ボイスレコーダー
  recorder = VoiceRecorder.new(device_name: DEVICE_NAME)

  # Google Speech To Text API接続サービス
  s2t_service = SpeechToTextService.new

  # VOICEBOXで読み上げるためのアダプター
  adapter = VoicevoxAdaptor.new(speaker_id: "話者のcid")  

  # ずんだもんのペルソナ
  zundamon = Person.new(name: "ずんだもん", role: "会議のレビュアー", gpt_service: gpt_service, voicevox_adaptor: adapter)

  # ずんだもん経由でChatGPTに役割を設定
  zundamon.setup
  zundamon.speak_summary

  threads = []
  # 5分ごとに3回レビューする
  3.times do |i|
    puts "#{Time.now} - #{i + 1}回目の録音開始"
    file_stem = Time.now.strftime("%Y%m%d%H%M%S")
    recorder.record(output_file_name: file_stem, duration: 300)
    puts "#{Time.now} - #{i + 1}回目の録音完了"

    # 録音後の処理を並列実行してすぐに次の録音ができるようにする
    threads << Thread.new do
      begin
        # 録音した音声ファイルをGoogle Cloud Storageにアップロード
        puts "#{Time.now} - #{i + 1}回目のファイルアップロード開始"
        UploadService.upload(file_path: AUDIO_FILE_PATH + file_stem + MP3_EXT)
        puts "#{Time.now} - #{i + 1}回目のファイルアップロード完了"
  
        # 音声ファイルから文字起こし
        puts "#{Time.now} - #{i + 1}回目の文字起こし開始"
        transcript = s2t_service.long_transcribe(file_name: file_stem + MP3_EXT)
        puts "#{Time.now} - #{i + 1}回目の文字起こし完了(#{transcript.length}文字)"
      
        # 文字起こしした会議内容を要約
        puts "#{Time.now} - #{i + 1}回目の要約開始"
        zundamon.think(message: transcript)
        puts "#{Time.now} - #{i + 1}回目の要約完了：#{gpt_service.last_message}"

        # 要約内容を読み上げる
        puts "#{Time.now} - #{i + 1}回目の読み上げ指示"
        zundamon.speak_summary
        puts "#{Time.now} - #{i + 1}回目の読み上げ完了"
      rescue => e
        puts "Error during processing recording: #{e.message}"
      end
    end
  end

  threads.each(&:join)
end
```

```ruby:conversation_history.rb
class ConversationHistory
  ROLE_SYSTEM = "system".freeze
  ROLE_USER = "user".freeze
  ROLE_ASSISTANT = "assistant".freeze

  def initialize
    @history = []
  end

  def add_system_message(content:)
    @history << { "role" => ROLE_SYSTEM, "content" => content }
  end

  def add_user_message(content:)
    @history << { "role" => ROLE_USER, "content" => content }
  end

  def add_assistant_message(content:)
    @history << { "role" => ROLE_ASSISTANT, "content" => content }
  end

  def history
    @history
  end

  def last_message
    @history.last["content"]
  end

  def empty?
    @history.empty?
  end
end
```

```ruby:gpt_service.rb
require 'net/http'
require 'uri'
require 'json'

class GptService
  API_KEY = "My API Key".freeze
  SYSTEM_CONTENT = "これからあなたに役割を指定します。最大%s文字で回答してください。".freeze
  API_ENDPOINT  = URI.parse("https://api.openai.com/v1/chat/completions").freeze
  HEADERS = {
    'Content-Type' => 'application/json',
    'Authorization' => "Bearer #{API_KEY}"
  }.freeze
  GPT_4O_MODEL = "gpt-4o".freeze

  def initialize(max_output_length:, conversation_history:)
    # ChatGPTの最大出力文字数
    @max_output_length = max_output_length
    @history = conversation_history
    @history.add_system_message(content: system_content)
  end

  def post_prompt(message:)
    body = build_request_body(message)
    response = send_request(body)

    # 回答を会話履歴に保存
    add_assistant_message(response)
  end

  def last_message
    @history.last_message
  end

  private

  def build_request_body(message) 
    # 質問を会話履歴に保存
    @history.add_user_message(content: message)

    # 会話履歴を全部ChatGPTに入力する
    {
      "model" => GPT_4O_MODEL,
      "messages" => @history.history
    }.to_json
  end

  def system_content
    SYSTEM_CONTENT % @max_output_length
  end

  def send_request(body)
    http = Net::HTTP.new(API_ENDPOINT.host, API_ENDPOINT.port)
    http.use_ssl = true
    request = Net::HTTP::Post.new(API_ENDPOINT.request_uri, HEADERS)
    request.body = body
    http.request(request)
  end

  def add_assistant_message(response)
    response_content = parse_response(response)
    @history.add_assistant_message(content: response_content)
  end

  def parse_response(response)
    data = JSON.parse(response.body)
    data["choices"].first["message"]["content"]
  end
end
```

```ruby:person.rb
class Person
  attr_accessor :name
  
  SETUP_MESSAGE = "これから会議を行います。会議中に逐次会議の会話内容を伝えるので、%sの立場として進行状況をレビューして。".freeze

  def initialize(name:, role:, gpt_service:, voicevox_adaptor:)
    @name = name
    @role = role
    @gpt_service = gpt_service
    @voicevox_adaptor = voicevox_adaptor
  end

  def setup
    message = SETUP_MESSAGE
    @gpt_service.post_prompt(message: message % @role)
  end

  def think(message:)
    @gpt_service.post_prompt(message: message)
  end

  def speak_summary
    # ChatGPTの最後の発言を取得して読み上げ
    message = @gpt_service.last_message
    @voicevox_adaptor.speak(text: message)
  end
end
```

```speech_to_text_service.rb
require 'bundler/setup'
Bundler.require

class SpeechToTextService
  KEY_FILE = "./key/speech-to-text-key.json".freeze
  BUCKET_URI = "gs://バケット名/".freeze
  MP3 = "MP3".freeze
  SAMPLE_RATE_HERTZ_44100  = 44100.freeze
  JA_JP = "ja-JP".freeze

  def initialize
      @client = Google::Cloud::Speech.speech do |config|
        config.credentials = KEY_FILE
      end
  end

  def long_transcribe(file_name:)
    operation = recognize_long_audio(file_name)
    response = wait_for_operation(operation)
    extract_transcript(response.response.results)
  end

  private

  def default_config
    {
      encoding: MP3,
      sample_rate_hertz: SAMPLE_RATE_HERTZ_44100 ,
      language_code: JA_JP
    }
  end

  def recognize_long_audio(file_path)
    @client.long_running_recognize(config: default_config, audio: load_long_audio(file_path))
  end

  def load_long_audio(file_path)
    { uri: "#{BUCKET_URI}#{File.basename(file_path)}" }
  end

  def extract_transcript(results)
    results.map { |result| result.alternatives.first.transcript }.join
  end

  def wait_for_operation(operation)
    operation.wait_until_done!
  end

end
```

```ruby:upload_service.rb
require 'google/cloud/storage'

class UploadService
  KEY_FILE = "./key/speech-to-text-key.json".freeze
  BUCKET_NAME = "バケット名".freeze

  def self.upload(file_path:)
    bucket = prepare_bucket
    file_name = extract_file_name(file_path)
    file = bucket.create_file file_path, file_name
  end
  
  private

  def self.prepare_bucket
    storage = Google::Cloud::Storage.new(credentials: KEY_FILE)
    storage.bucket(BUCKET_NAME)
  end

  def self.extract_file_name(file_path)
    File.basename(file_path)
  end
end
```

```ruby:voice_recorder.rb
require 'open3'
require 'fileutils'

class VoiceRecorder
  AUDIO_OUTPUT_DIR = File.join(__dir__, '../audio_output').freeze

  def initialize(device_name:)
    @device_name = device_name
  end

  def record(output_file_name:, duration:)
    output_path_wav = full_output_path(output_file_name, 'wav')
    output_path_mp3 = full_output_path(output_file_name, 'mp3')

    # 録音
    record_audio(output_path_wav, duration)
    # サイズ圧縮のためにmp3に変換
    convert_to_mp3(output_path_wav, output_path_mp3)
  end

  private

  def full_output_path(file_name, extension)
    File.join(AUDIO_OUTPUT_DIR, "#{file_name}.#{extension}")
  end

  def record_audio(output_path, duration)
    command = build_recording_command(output_path, duration)
    execute_command(command)
  end

  def convert_to_mp3(input_path, output_path)
    command = build_convert_to_mp3_command(input_path, output_path)
    execute_command(command)
  end

  def build_recording_command(output_path, duration)
    "ffmpeg -f dshow -i audio=\"#{@device_name}\" -t #{duration} #{output_path}"
  end

  def build_convert_to_mp3_command(input_path, output_path)
    "ffmpeg -i #{input_path} -codec:a libmp3lame -qscale:a 2 #{output_path}"
  end

  def execute_command(command)
    Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
      exit_status = wait_thr.value
      unless exit_status.success?
        abort "録音失敗: #{exit_status}"
      end
    end
  end
end
```

```voicevox_adaptor.rb
class VoicevoxAdaptor
  SEIKASAY2_PATH = 'SeikaSay2.exeのフルパス'.freeze

  def initialize(speaker_id:)
    @speaker_id = speaker_id
  end

  def speak(text:)
    # SeikaSay2.exeを使って読み上げ
    # 80文字以上の文章は読み上げられない場合があるので分割する
    text.scan(/.{1,80}/).each do |chunk|
      system("#{SEIKASAY2_PATH} -cid #{@speaker_id} -t \"#{chunk}\"")
    end
  end
end
```

### Main.rbについて補足（バグ報告）
ずんだもんの読み上げ処理は別スレッドで行っていますが、読み上げ最中に次のテキストの読み上げ処理がくると、正常に処理できません。
対処法を考え中（キューを使えばよいかも）。


# やってみた
まずVOICEVOXを起動します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2951879/a11ac8e8-3937-e30c-ac6f-934213561b47.png)

次にAssistantSeikaを起動して、製品スキャンをします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2951879/5fe7bc75-cfe6-e567-e343-1388bf6a0746.png)

その後は作成したプログラムを実行し、会議をすれば良いだけです。マイクを通じて会議内容が録音され、進行内容に合わせたレビュー結果をずんだもんが喋ってくれます。

今回は会議のロールプレイが（私がぼっちのため）出来ないので、代わりとしてYouTubeにある対談動画の音声を録音し、その内容をレビューして貰います。
1回5分の長さで録音し、内容をレビューするというプロセスを3回繰り返します。

- 動画の対談テーマ：地球温暖化をはじめとした気候変動ついて
- パネリスト数：男性1名、女性1名
- 動画の長さ：約45分（冒頭の15分を録音）

# 結果
動画音声を文字起こしした結果をここに記載すると、著作権侵害になるかも知れないので、ずんだもんがレビューした結果だけ記載します。
※個人の特定に繋がる情報は削除しています。

```text:1回目のレビュー結果
1. **挨拶と導入**
   - 挨拶があり、特別ゲストのxxxさんの経歴紹介が行われた。
   - xxxさんの専門知識と背景について詳細に説明された。

2. **議題紹介**
   - 本日のテーマ "地球環境危機を救う社会経済システムの構築" が明示され、その背景として気候変動や経済活動についての概要が述べられた。

3. **xxxさんのプレゼンテーション**
   - xxxさんから地球温暖化や災害の原因についての説明が行われ、資料を用いて具体例を挙げながら説明された。
   - 経済活動と地球環境の関係性についての深掘りが行われ、問題の全体像が技術的に説明された。

4. **現在の状況と将来の見通し**
   - IPCCのレポートを引用し、2050年までにネットゼロカーボンを達成する必要性について強調。
   - ここ10年が重要な舵取りの期間であり、具体的なアクションと社会経済の変革が求められていると指摘。

### 今後の進行に向けた点
- 次のアクション項目や実行計画の議論が期待される。
- 具体的な政策提言や各国の取り組み事例についても触れると議論が深化する可能性がある。
```

```text:2回目のレビュー結果
1. **二次議題の紹介と説明**
   - 気候変動や海面上昇、パンデミックへの対策がどれほど緊急の問題であるかを具体例を交えて説明。
   - 特に途上国や太平洋諸国での海面上昇や塩害の影響が述べられた。

2. **人間活動の影響とパンデミックの原因**
   - 経済活動や食料生産地域の拡大が、生態系に影響を与え、新型感染症の発生につながるとの指摘。
   - コロナウイルスの起源についての説明があり、パンデミックと経済活動の関係性が浮き彫りにされた。

3. **地球の歴史と環境変動**
   - 地球の気温変動と安定期（ホロシーン）の重要性が説明され、人間文明の発展がこの気温安定期に依存しているとの指摘。
   - 産業革命以降の急速な気温上昇と、その結果としての環境負荷について詳細に説明された。

4. **視覚資料の活用**
   - 気温変動や人口増加、GDPなどの数値資料を用いて説明され、産業革命以降の急速な変化が強調された。

### 今後の進行に向けた点
- 経済活動による環境負荷について更に深掘りすることで、具体的な対策の議論を進めるべきです。
- パンデミック対策や感染症管理についての具体的な政策提言が求められる。
- 持続可能な経済活動のモデルケースや成功事例の共有が求められる。
```

```text:3回目のレビュー結果
1. **環境問題の多様性とその影響**
   - 気候変動や海の酸性化、サンゴの死滅、熱帯雨林の減少などの環境問題が詳細に説明された。
   - 人間の経済活動が、環境システムに大きな負荷をかけている事例が紹介された。

2. **人間活動の地球システムへの影響**
   - 農業の活発化や都市の拡大に伴う生物多様性の喪失が議論された。
   - 経済活動が地球環境を大きく変えてしまっている現状が強調。

3. **生物多様性の重要性**
   - 生物多様性が地球の安定システムに与える重要な役割について説明。
   - 生物多様性喪失による影響は、他の環境問題に比べて認識されにくい点が指摘され、「サイレントキラー」としての危険性が強調された。

4. **プラネタリー・バウンダリーの概念**
   - 地球システム科学者が特定した、地球が安定に機能するための9つのドメインが紹介された。
   - これらのドメインが人間活動によってどれだけ崩壊し、リスクが高まっているかを説明。
   - 安全な運用空間（Safe Operating Space）の範囲を超えると、地球のシステムが不可逆的な変化を迎える可能性があるとの指摘があります。

### 今後の進行に向けた点
- 環境問題に対する具体的な解決策や政策提言を議論するフェーズに移行する。
- 生物多様性の保護を促進するための具体的なアクションプランや成功事例の共有を期待。
- プラネタリー・バウンダリーをどう保護するかについての戦略的な議論を深掘りする。
```

いい感じですね！　ずんだもんもちゃんと5分ごとにこの内容を喋ってくれています！（動いているところをお見せ出来ないのが残念です）
会議の方向性を提案しているのも思惑通りです。

面白いので他の動画でも試した結果をいくつか抜粋します。

```text:お天気動画のレビュー結果
1. 会議の進行状況:
    - 継続して視聴者からのタイムラプスのリポート紹介が行われています。
    - 秋田県、山口県、新潟県からのリポートが紹介されました。

2. ポイント:
    - 視聴者リポートを通じて全国の天気情報がカバーされています。
    - 内容が視覚的に捉えやすく、興味深いリポートが続いています。

3. コメント:
    - タイムラプスリポートの紹介は良いが、会議全体の目標や議題にまだ触れていない点が気になります。
    - 情報の提供が続いているが、議論や意見交換がほとんどないため、会議としての双方向性が不足しています。

次の段階で、会議の目標や議題について具体的に言及し、視聴者からのリポートに基づいた議論や意見交換を始めることをお勧めします。
```

```text:雑談配信のレビュー結果
現在の会話は、ティッシュや絆創膏、お裁縫セットなど学校生活に関する個人的な持ち物についての雑談が主な内容のようです。
この話題は親しみやすくリラックスした雰囲気を作り出す一方、会議の主要目的とは異なる可能性があります。
議題に関する具体的な進行状況や結論にはまだ至っていないようです。今後、議題に即した話題に焦点を移すことが重要です。
```

会議内容のレビューという観点では、ネガティブポイントになりそうな箇所をしっかり取り上げ、改善案も提案しています。

# まとめ
ずんだもんと一緒に会議するという、夢のような環境が出来あがりました！
これで毎日の~~つまらない~~会議も、ずんだの妖精の魔法にかかったように楽しく、効率的に進行することができますね。

偉い人に忖度し迎合して終わるだけの会議にうんざりしている人も多いことでしょう。そのような時こそ、デジタルの出番です。人間が指摘できないことは、ずんだもんに代わりに指摘してもらえばよいのです。意義のある会議と保身が両立できます。

なお、現時点ではChatGPT APIに直接音声入力できるインターフェースがないためGoogleのサービスを使いましたが、きっと将来的にはChatGPT API自体が音声入力をサポートするようになるでしょう。そうすれば、もっとシンプルにプログラムを構築することが出来ますね。