9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

議事録作成Discord botを実装した話🐱

Last updated at Posted at 2025-07-23

要約

Discordの会議で議事録を自動作成するBot開発において、特に録音処理で直面したメモリ問題を解決した知見と、その他音声認識などの技術的なポイントを共有します。

はじめに

私たちは普段、Discordで会議を行っています。その中で、議事録作成の負担を軽減したいという思いから、議事録を自動で作成してくれるDiscord Botを開発しました。セルフホストで運用しているため、運用コストを抑えつつ、便利な議事録作成環境を構築できています。

リポジトリも公開しているので、コードベースで参考にしたい方はぜひご覧ください。

reconata

この記事が、同じような課題を抱える方の参考になれば幸いです。

スクリーンショット 2025-07-23 23.53.24.png

対象読者

  • Discord Bot開発に興味がある方
  • 議事録自動化に興味がある方
  • 音声認識に興味がある方

環境

項目 詳細
OS Ubuntu Server
言語 Python 3.11
ライブラリ pycord 2.6.1

開発経緯

世の中にはすでに議事録作成が可能なサービスがありますが、以下の理由から自前で開発を行うことにしました。

  • Discordで利用可能かつセルフホストで運用できるものがなかった
  • 料金が高いものが多く、コストを抑えたかった
  • 手作業の負担を減らしたかった

当初は、Craigという既存のDiscord Botを利用して会議を録音し、その録音データを手動でダウンロードしてから文字起こし・要約を行うというフローでした。しかし、この方法では手間がかかっていたため、自動化を目指すことにしました。

また、AIとの統合を考えていたため、Pythonを使用したく、その中でも録音するAPIがあるpycordを選択しました。

録音の難しさ

Bot開発で最も苦労したのは録音機能の実装です。pycordでは録音データをすべてメモリ上に保持する仕様でした。そのため、長時間の会議ではメモリ不足に陥ってしまうという問題がありました。

録音処理の全体像

先に全体がどうなっているかを簡略化して示します。

from dataclasses import dataclass
from typing import cast
import discord
from discord.sinks import WaveSink

@dataclass
class Meeting:
    voice_client: discord.VoiceClient
    # 他に、処理に必要なプロパティを追加

bot = discord.Bot()
meetings: dict[int, Meeting] = {}

@bot.command()
async def start(ctx: discord.ApplicationContext):
    await ctx.defer()
    member = cast(discord.Member, ctx.author)
    voice = member.voice

    if voice is None:
        await ctx.respond("ボイスチャンネルに参加してください。")
        return

    voice = cast(discord.VoiceState, voice)

    if (channel := voice.channel) is not None:
        vc = await channel.connect()
        meetings[ctx.guild.id] = Meeting(voice_client=vc)

        vc.start_recording(
            WaveSink(),
            on_finish_recording,
            ctx.channel,
            sync_start=True,  # 録音の開始時間を同期する。ミックスする際に便利
        )
        await ctx.respond("録音を開始しました。")
    else:
        await ctx.respond("チャンネルが見つかりません。")


@bot.command()
async def stop(ctx: discord.ApplicationContext):
    await ctx.defer()
    guild_id = ctx.guild.id
    meeting = meetings.get(guild_id)

    if meeting is None:
        await ctx.respond("録音情報がありません。")
        return

    meeting.voice_client.stop_recording()
    await ctx.respond("録音を停止しました。")

async def on_finish_recording(sink: WaveSink, channel: discord.TextChannel):
    await sink.vc.disconnect()

    data = sink.audio_data
    # ここで録音データ(data)を処理する
    # dataはユーザーIDと音声データの辞書

    del meetings[channel.guild.id]

castやセイウチ演算子を用いているので、見慣れない部分があるかもしれません。以下に重要な部分をまとめます。

  • VoiceClientstart_recordingメソッドで録音を開始する
  • VoiceClientstop_recordingメソッドで録音を停止する
  • start_recordingには以下を渡す
    • 録音データを管理するSinkの実装クラス(例:WaveSink, FileSinkなど)
    • 録音終了時に呼ばれるコールバック関数

Sinkの実装クラスは、WaveSinkMP3Sinkなど、ライブラリ側で様々な音声フォーマットのものが用意されています。

さて、このSinkクラスが問題になりました。

ライブラリのSinkの実装

以下がWaveSinkMP3Sinkなどの親クラス、Sinkクラスの実装の一部です。

class Sink(Filters):
    ...

    @Filters.container
    def write(self, data, user):
        if user not in self.audio_data:
            file = io.BytesIO()  # この行が問題
            self.audio_data.update({user: AudioData(file)})

        file = self.audio_data[user]
        file.write(data)

    def cleanup(self):
        self.finished = True
        for file in self.audio_data.values():
            file.cleanup()
            self.format_audio(file)

Sinkにはwriteメソッドが存在し、ここでリアルタイムに音声データが渡されます。デフォルトの実装では、BytesIOを用いているため、メモリ上にデータが蓄積されます。そのため、長時間の録音ではメモリが不足してしまうという問題が発生しました。

cleanupメソッドは録音終了時にVoiceClient側から呼ばれるメソッドです。cleanupメソッド内で呼ばれているformat_audioメソッドはこのSinkクラスでは実装されておらず、それぞれの実装クラス(WaveSink, MP3Sink)で実装するという設計方針のようです。

自前のSinkの実装 (FileSink)

この問題を解決するために、録音データを一時ファイルに書き出すFileSinkクラスを新たに作成しました。

import os
import tempfile

import discord
from discord.types.snowflake import Snowflake


class FileSink(discord.sinks.Sink):
    def write(self, data: bytes, user: Snowflake) -> None:
        if user not in self.audio_data:
            temp = tempfile.NamedTemporaryFile(
                mode="w+b",
                delete=False,
                prefix=f"{user}_",
            )
            self.audio_data[user] = temp

        self.audio_data[user].write(data)

    def cleanup(self):
        for user, file in self.audio_data.items():
            file.close()
            self.audio_data[user] = file.name

メモリにwriteするのではなく、ファイルにwriteするようにしています。これにより、ディスク上にデータが蓄積されるため、メモリ使用量を大幅に削減し、長時間の録音が可能になりました。

cleanupメソッド内では、ファイルをcloseして、audio_dataにそのファイルのパスが保存されるようにしています。このファイルパスを後続の処理で利用する設計にしました。

音声認識

音声認識にはfaster-whisperlarge-v3モデルを使用しています。メモリが許せば、batch_sizeを大きくすることで、一度に文字起こしをする量を増やすことができ、より高速に文字起こしが可能です。私の環境では、batch_size=8で運用しています。

また、GPU環境で運用していますが、CPUでも十分に動作します。

また、専門用語などをhotwordsに登録することで、認識精度を向上させています。

hotwordsについては、多くの単語を指定するとかえって認識精度が下がりました。名前などの固有名詞は諦めて、数単語程度に留めるのが良いでしょう。

私たちのチームでは「nekonata」という単語を登録しています。これにより、会議中に「nekonata」という単語が出てきた際の認識精度が向上しました。

議事録生成

会議の議事録はGemini APIを利用して生成しています。コンテキスト長が長く、無料枠がある点が魅力的で採用しました。

thinkする必要は少ないため、手軽に使用できるgemini-2.0-flashを用いています。ここで文字起こしの誤字やフィラーも吸収してくれます。

何度か試しましたが、Geminiはマークダウンが少し苦手かもしれません。無駄なインデントやスペースがあるなど、きれいなマークダウンではないような印象です。お金に余裕があれば、別のモデルを検討しても良いと感じています。

また、議事録作成後、GitHubのリポジトリにコミット・プッシュする機能も実装しています。私たちはObsidianをナレッジツールとして使っているため、マークダウンで議事録を作成し、Obsidianに紐づけているリポジトリにコミットすることで、会議の内容を簡単に参照できるようにしています。

データベース

いくつかのサーバーでBotを運用しているため、サーバーごとの設定を保存する必要がありました。そこで、軽量なデータベースとしてTinyDBを採用しました。

TinyDBはPythonで人気のJSONベース(NoSQL)のデータベースで、シンプルなAPIで扱いやすいです。小規模な用途であれば、手軽に利用できて非常に便利です。

以下のようなファイルで管理されます。人間にも見やすいのが特徴です。

{
    "_default": {
        "1": {
            "guild_id": 123456789012345678,
            "parameters": {
                "prompt_key": "default",
                "user_names": {
                    "111111111111111111": "ユーザーA"
                },
                "additional_context": null,
                "github": {
                    "repo_url": "https://<YOUR_GITHUB_TOKEN>@github.com/user/repo.git",
                    "local_repo_path": "data/repo/temp-vault_123456789012345678"
                },
                "hotwords": null
            }
        },
        "2": {
            "guild_id": 234567890123456789,
            "parameters": {
                "prompt_key": "custom_prompt",
                "user_names": {
                    "222222222222222222": "ユーザーB",
                    "333333333333333333": "ユーザーC",
                    "444444444444444444": "ユーザーD"
                },
                "additional_context": "アプリ制作を行う、株式会社Yの会議です。\nリリース済み:プロジェクトA, プロジェクトB\n開発ツール:Obsidian, Discord, GitHub, Codemagic\n開発中:プロジェクトC",
                "github": {
                    "repo_url": "https://<YOUR_GITHUB_TOKEN>@github.com/user/repo.git",
                    "local_repo_path": "data/repo/repo-name_234567890123456789"
                },
                "hotwords": "プロジェクトY"
            }
        },
        "3": {
            "guild_id": 345678901234567890,
            "parameters": {
                "prompt_key": "default",
                "user_names": {
                    "111111111111111111": "ユーザーA",
                    "555555555555555555": "ユーザーE"
                },
                "additional_context": "プロジェクトXというアプリに関する会議の文字起こしです。",
                "github": null,
                "hotwords": "プロジェクトX"
            }
        }
    }
}

セルフホスト

現在は私が所有しているUbuntu Server上で運用しています。

Docker Composeで起動するようにしているため、誰でも簡単に実行できるようにし、属人性を排除しました。将来的には、クラウドへの移行も検討しています。

まとめ

  • pycordでの録音はメモリ管理に注意が必要
    • 特に数時間に及ぶ会議や人数が多い場合は、本記事のFileSinkのような自前のSinkクラスの実装が有効
  • 音声認識にはfaster-whisperが有用
  • 議事録生成にはGemini APIを活用
  • TinyDBは小規模なデータ管理に最適
  • セルフホストで運用コストを削減

参考

9
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?