「Difyで作る生成AIアプリ完全入門」を読んで、議事録をまとめてくれるアプリを試す


## はじめに

Dify というローコードツールを使った生成AIアプリの作り方を図解やスクリーンショットとともに分かりやすく説明している書籍、「Difyで作る生成AIアプリ完全入門」を読んでDifyに入門しました。この書籍では、名刺読み取りアプリや議事録まとめアプリ、稟議書をレビューするアプリなどが紹介されています。この書籍のなかから、第4章「会議の議事録をまとめてくれるアプリを作ろう」をもとに、遭遇したエラーの対処や書籍では提供されていない`会議音声サンプル`の作成方法をまとめてみます。

https://bookplus.nikkei.com/atcl/catalog/25/03/21/01919/

## 参考情報

https://qiita.com/minorun365/items/15527ec7ffe8804ce290

https://github.com/GenerativeAgents/dify-book?tab=readme-ov-file

https://zenn.dev/ma_ro/articles/49b67565462299

## 会議の議事録をまとめてくれるアプリを作る

書籍P106の第4章「会議の議事録をまとめてくれるアプリを作ろう」をもとに、Difyで議事録作成アプリを構築します。

### 音声データをテキストに変換する

まず、チャットフローを作成し、「LLM」ノードと「LLM2」ノードのモデルをそれぞれ「gpt-4o-audio-preview」と「o3-mini」に設定します。

音声データをアップロードして議事録作成を試みると、「LLM」ノードでエラーが発生しました。

![音声の文字起こし処理](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/5bb9ac93-1a68-42a4-9664-f9971461a335.png)

> Run failed: [openai] Error: PluginInvokeError: {"args":{},"error_type":"ValidationError","message":"1 validation error for ModelInvokeLLMRequest\nprompt_messages.content.type\n Input should be \u003cPromptMessageContentType.AUDIO: 'audio'\u003e [type=literal_error, input_value='audio', input_type=str]\n For further information visit https://errors.pydantic.dev/2.8/v/literal_error"}

書籍のサポート [GitHub リポジトリ](https://github.com/GenerativeAgents/dify-book?tab=readme-ov-file#%E3%81%8A%E7%9F%A5%E3%82%89%E3%81%9B2025%E5%B9%B43%E6%9C%88%E6%9B%B4%E6%96%B0) には、「2025年3月現在、クラウド版で音声ファイルの書き起こしがエラーになる場合があります。」という記載とともに[Issueのリンク](https://github.com/langgenius/dify/issues/17303/)が記載されていますが、このIssueの内容とは異なるようです。

### エラー回避のため「LLM」ノードのモデルを変更する

「LLM」ノードのモデルをAUDIO対応の「Gemini 2.0 Flash-Lite」に変更し、音声ファイルをアップロードして「文字起こしして。」と送信すると、正常に文字起こしと議事録が生成されました。

![モデルを変更する](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/e7f5b3b2-0140-47a5-8a7a-598c00c3172b.png)

### Gemini APIキーを取得するには

Gemini 2.0 Flash-Liteを利用するには、Google CloudでGemini APIを有効化し、APIキーを取得する必要があります。手順は以下の記事が参考になります。

https://zenn.dev/ma_ro/articles/49b67565462299

APIキー取得後、Difyのモデルプロバイダー画面でGeminiを追加すれば、LLMノードでGeminiを選択できるようになります。

![Difyのモデルプロバイダー画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/9a35c465-0154-4034-86be-2b83782dd945.png)

## 会議の音声サンプルデータを作成する

アプリの動作確認用に、Amazon Pollyを使って会議の音声サンプルデータを作成する方法を紹介します。

### Amazon Pollyで音声データを作成する

Amazon Pollyはテキストを音声に変換するサービスです。会議の会話スクリプトを用意し、Pollyで音声化します。会話スクリプトは[Amazon Q Developer CLI](https://docs.aws.amazon.com/ja_jp/amazonq/latest/qdeveloper-ug/command-line.html)で生成しました。
このように指示を出すと、会議模様のスクリプトを作成してくれます。

```console
> 以下の設定で、会議模様のスクリプトを作成してください。
会議テーマ：新サービスのアイデア出し
登場人物：
- 進行役（MC）
- エンジニアA（技術視点）
- エンジニアB（実装・運用視点）
- PM（全体調整・事業視点）
言語：日本語
会議長さ：10分以内
成果物：音声ファイル（複数話者の自然な会話風）
```

すると、以下のような会議の会話のテキストが生成されました。

```console
+     1: # 新サービスのアイデア出し会議
+     2:
+     3: ## 登場人物
+     4: - MC：会議進行役
+     5: - エンジニアA：技術視点担当
+     6: - エンジニアB：実装・運用視点担当
+     7: - PM：全体調整・事業視点担当
+     8:
+     9: ## 会議スクリプト
+    10:
+    11: **MC**: 皆さん、お集まりいただきありがとうございます。今日は新サービスのアイデア出しを行いたいと思います。まずは自由に発想を広げていきましょう。何かアイデアのある方はいますか？
+    12:
+    13: **エンジニアA**: はい、最近AIの発展が著しいので、それを活用したサービスはどうでしょうか。例えば、ユーザーの行動パターンを学習して、最適な情報を提供するパーソナルアシスタントアプリとか。
+    14:
+    15: **PM**: 面白いですね。ターゲットユーザーとしては誰を想定していますか？
+    16:
+    17: **エンジニアA**: まずはビジネスパーソンを対象にしてはどうでしょう。スケジュール管理から始まって、関連資料の準備、会議の要約作成など、日々の業務効率化をサポートする機能が考えられます。
```

Amazon PollyのAPIを呼び出すスクリプト(後述)で扱いやすように、フォーマットを変更します。

```console
> このスクリプトを次のフォーマットで出力してください。 ("進行役","皆さん、お集まりいただきありがとうございます。"),("エンジニアA","はい、最近AIの.."),

会議スクリプトを指定のフォーマットで出力します:

("MC","皆さん、お集まりいただきありがとうございます。今日は新サービスのアイデア出しを行いたいと思います。まずは自由に発想を広げていきましょう。何かアイデアのある方はいますか？"),
("エンジニアA","はい、最近AIの発展が著しいので、それを活用したサービスはどうでしょうか。例えば、ユーザーの行動パターンを学習して、最適な情報を提供するパーソナルアシスタントアプリとか。"),
("PM","面白いですね。ターゲットユーザーとしては誰を想定していますか？"),
("エンジニアA","まずはビジネスパーソンを対象にしてはどうでしょう。スケジュール管理から始まって、関連資料の準備、会議の要約作成など、日々の業務効率化をサポートする機能が考えられます。"),
```

これをもとに、Amazon Pollyで音声データを作成します。以下のスクリプトを実行すると、`output_audio`フォルダに発話ごとの音声ファイルが作成されます。
※ AWSクレデンシャル情報は設定済み、boto3モジュールのインストールも完了しているものとします。

```python:create_meeting_voices.py
import boto3
import os

# Amazon Polly クライアント作成
polly = boto3.client("polly", region_name="ap-northeast-1")

# 話者別の音声設定（PollyのVoiceId）と対応するエンジン
voices = {
    "進行役": {"voice_id": "Mizuki", "engine": "standard"},     # 落ち着いた女性の声
    "PM": {"voice_id": "Takumi", "engine": "neural"},         # 男性の中低音
    "エンジニアA": {"voice_id": "Kazuha", "engine": "neural"},  # 技術者っぽい落ち着いた女性声
    "エンジニアB": {"voice_id": "Tomoko", "engine": "neural"},   # 現実主義な女性 明るめの女性
}

# スクリプト定義（順番にリストで処理）
script = [
    ("MC","皆さん、お集まりいただきありがとうございます。今日は新サービスのアイデア出しを行いたいと思います。まずは自由に発想を広げていきましょう。何かアイデアのある方はいますか？"),
    ("エンジニアA","はい、最近AIの発展が著しいので、それを活用したサービスはどうでしょうか。例えば、ユーザーの行動パターンを学習して、最適な情報を提供するパーソナルアシスタントアプリとか。"),
    ("PM","面白いですね。ターゲットユーザーとしては誰を想定していますか？"),
    ("エンジニアA","まずはビジネスパーソンを対象にしてはどうでしょう。スケジュール管理から始まって、関連資料の準備、会議の要約作成など、日々の業務効率化をサポートする機能が考えられます。"),
    ("エンジニアB","技術的には実現可能ですが、既存のサービスとの差別化が必要ですね。例えば、社内システムとの連携を強化するとか。あとはプライバシーの問題もあります。データの取り扱いについては慎重に設計する必要があります。"),
    ("MC","良い指摘ですね。他にアイデアはありますか？"),
    ("PM","最近、リモートワークが定着してきていますが、チームのコミュニケーションや一体感の醸成が課題になっています。バーチャルオフィス的な要素と、実際の業務効率化を組み合わせたサービスはどうでしょう？"),
    ("エンジニアB","それは面白いですね。技術的には、WebRTCを使ったビデオ通話機能に加えて、共同作業スペースをバーチャルで表現する。さらに、作業状況の可視化機能も付けられます。"),
    ("エンジニアA","そこにAIを組み込んで、チームの状況を分析し、コミュニケーションのタイミングを提案したり、必要な情報を自動で集約したりする機能も追加できますね。"),
    ("MC","両方とも魅力的なアイデアです。もう少し具体的に考えていきましょう。例えば、最初のAIアシスタントの場合、どんな機能が差別化ポイントになりますか？"),
    ("エンジニアA","音声認識と自然言語処理を組み合わせて、会議中にリアルタイムで議事録を作成し、重要なアクションアイテムを自動抽出する機能はどうでしょう。"),
    ("PM","それは価値がありそうですね。さらに、過去の会議内容と関連付けて、プロジェクトの進捗状況も可視化できれば、マネジメント層にも訴求できそうです。"),
    ("エンジニアB","実装面では、オフライン環境でも動作する設計が重要です。また、企業のセキュリティポリシーに合わせたデータ保護機能も必須になりますね。"),
    ("MC","バーチャルオフィスの方はどうでしょう？"),
    ("PM","コロナ以降、多くの企業がハイブリッドワークを導入していますが、オフィスとリモートのメンバーの間で情報格差が生じがちです。この問題を解決するサービスとして位置づけられるのではないでしょうか。"),
    ("エンジニアB","そうですね。オフィスの様子をセンサーで取得して、リモートワーカーにも共有する機能を入れられます。例えば、誰がオフィスにいるか、どの会議室が空いているかなどの情報です。"),
    ("エンジニアA","さらに、AIでオフィスとリモートの両方のコミュニケーションパターンを分析して、チームの一体感を高めるためのアクティビティを提案する機能も面白そうです。"),
    ("MC","両方のアイデアを比較すると、どちらが実現性が高いと思いますか？"),
    ("エンジニアB","技術的には、AIアシスタントの方が既存技術の組み合わせで実現できる部分が多いので、初期バージョンのリリースは早そうです。"),
    ("PM","市場性の観点では、バーチャルオフィスの方が差別化しやすく、具体的な課題解決につながりそうです。ただ、導入のハードルは少し高いかもしれません。"),
    ("MC","なるほど。では、今日の議論をまとめると、2つの有力なアイデアが出ました。1つ目はAIを活用したビジネスパーソン向けパーソナルアシスタント、2つ目はハイブリッドワーク環境を支援するバーチャルオフィスサービスです。次回までに、それぞれのアイデアについて、もう少し詳細な機能リストとターゲットユーザーの分析をしてきていただけますか？"),
    ("PM","了解しました。市場調査も含めて準備します。"),
    ("エンジニアA","私は技術的な実現性と必要なリソースについて詳細化します。"),
    ("エンジニアB","私は運用面での課題と解決策をまとめてきます。"),
    ("MC","ありがとうございます。それでは今日の会議はここまでとします。皆さん、お疲れ様でした。")
]

# 音声ファイル出力用フォルダ
os.makedirs("output_audio", exist_ok=True)

# 1つずつ音声を生成して保存
for i, (speaker, text) in enumerate(script, 1):
    voice_info = voices.get(speaker, {"voice_id": "Mizuki", "engine": "standard"})
    response = polly.synthesize_speech(
        Text=text,
        OutputFormat="mp3",
        VoiceId=voice_info["voice_id"],
        LanguageCode="ja-JP",
        Engine=voice_info["engine"]
    )
    filename = f"output_audio/{i:02d}_{speaker}.mp3"
    with open(filename, "wb") as f:
        f.write(response["AudioStream"].read())
    print(f"✅ {filename} を生成しました。")
```

次に、生成された音声ファイルをひとつのファイルに結合します。output_audioフォルダにある音声ファイルを結合して、`final_meeting_audio.mp3`というファイルを作成します。
※ ffmpegはインストールしているものとします。

### 音声ファイルを結合する

生成した音声ファイルを1つのファイルに結合します。ここでは`ffmpeg`を利用します。

```python
import os
import subprocess
import re
import tempfile
import shutil

# 音声ファイルディレクトリ
audio_dir = "output_audio"
output_file = "final_meeting_audio.mp3"
temp_dir = "temp_audio"

# 数字で始まるファイル名を正しく数値順にソートする関数
def natural_sort_key(s):
    # ファイル名の先頭の数字部分を抽出して数値として扱う
    match = re.match(r'(\d+)_.*', s)
    if match:
        return int(match.group(1))
    return 0

# MP3ファイルを番号順で取得
mp3_files = [f for f in os.listdir(audio_dir) if f.endswith(".mp3")]
mp3_files.sort(key=natural_sort_key)

print(f"処理対象ファイル数: {len(mp3_files)}")

# 一時ディレクトリを作成
if os.path.exists(temp_dir):
    shutil.rmtree(temp_dir)
os.makedirs(temp_dir)

# 各ファイルを直接結合する方法を試す
print("🔧 音声ファイルを直接結合中...")

# 入力ファイルを一つずつ指定する方法
input_args = []
for mp3 in mp3_files:
    input_args.extend(["-i", os.path.join(audio_dir, mp3)])

# フィルターコンプレックスで結合
filter_complex = ""
for i in range(len(mp3_files)):
    filter_complex += f"[{i}:0]"
filter_complex += f"concat=n={len(mp3_files)}:v=0:a=1[out]"

command = [
    "ffmpeg",
    *input_args,
    "-filter_complex", filter_complex,
    "-map", "[out]",
    "-y",
    output_file
]

print("結合順序:")
for i, mp3 in enumerate(mp3_files, 1):
    print(f"{i}. {mp3}")

try:
    # コマンドを表示
    print(f"実行コマンド: {' '.join(command)}")

    # ffmpegを実行
    result = subprocess.run(command, check=True, capture_output=True, text=True)
    print(f"✅ 結合完了: {output_file}")

    # 結合されたファイルのサイズを確認
    output_size = os.path.getsize(output_file)
    total_input_size = sum(os.path.getsize(os.path.join(audio_dir, mp3)) for mp3 in mp3_files)

    print(f"入力ファイル合計サイズ: {total_input_size} バイト")
    print(f"出力ファイルサイズ: {output_size} バイト")

    # 各ファイルの長さを確認
    total_duration = 0
    print("\n各ファイルの長さ:")
    for mp3 in mp3_files:
        cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", os.path.join(audio_dir, mp3)]
        duration = float(subprocess.check_output(cmd, text=True).strip())
        total_duration += duration
        print(f"{mp3}: {duration:.2f}秒")

    print(f"\n入力ファイル合計時間: {total_duration:.2f}秒")

    # 出力ファイルの長さを確認
    cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", output_file]
    output_duration = float(subprocess.check_output(cmd, text=True).strip())
    print(f"出力ファイル時間: {output_duration:.2f}秒")

    if abs(output_duration - total_duration) > 1.0:  # 1秒以上の差があれば警告
        print("⚠️ 警告: 出力ファイルの長さが入力ファイルの合計と大きく異なります。すべてのファイルが正しく結合されていない可能性があります。")

except subprocess.CalledProcessError as e:
    print(f"❌ エラー発生: {e}")
    print(f"標準出力: {e.stdout}")
    print(f"標準エラー: {e.stderr}")
    print("ffmpegコマンドの実行に失敗しました。")
finally:
    # 一時ディレクトリを削除
    try:
        shutil.rmtree(temp_dir)
        print(f"一時ディレクトリ削除: {temp_dir}")
    except:
        print(f"一時ディレクトリの削除に失敗: {temp_dir}")
```

Pythonコードと階層構造は以下のとおりです。

```text
.
├── combine_audio_files.py  # 音声ファイルを結合するスクリプト
├── create_meeting_voices.py  # 音声ファイルを作成するスクリプト
├── final_meeting_audio.mp3   # 最終成果物
└── output_audio              # 作成した音声ファイルの保存場所
```

実際に生成した音声ファイルは以下のとおりです。

https://rev-system.net/assets/final_meeting_audio.mp3

`final_meeting_audio.mp3`をDifyのチャットフローにアップロードすると、議事録の作成に成功します。

![モデルを変更する](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/e7f5b3b2-0140-47a5-8a7a-598c00c3172b.png)

## まとめ

本記事では、Difyで議事録をまとめてくれるアプリの構築手順と、エラー回避のためのモデル変更方法、さらにAmazon Pollyを使った会議音声サンプルデータの作成方法を紹介しました。アプリの動作確認用に音声データが必要な場合、Pollyを活用することで手軽にサンプルを用意できます。ぜひお試しください。