Slack Bolt for PythonでSlack上に音声書き起こしアプリ作ってみたwith WhisperAPI on docker(3秒ルールも回避)

# 動機～書き起こしサービスを求めて～
学会発表の質疑応答や会議の議事録の作成，皆さんどうしてますか？
「これ録音して，後で書き起こしてまとめよう！」と思い立つものの、無料で30分くらいの音声を書き起こせるサービスが見当たらない．．．なんでも無料のサービスを頼ろうとしちゃうのは現代人の悪い癖だと思いつつも、「ないなら作ろう！」ということで開発に手を付けました．

そこで目をつけたのが，最近話題の高性能書き起こしツール，**Whisper**
試してみたところ，値段も安く，超便利だったので，これを活用して共有アプリケーションを開発しようと考えました．
## 🤔なぜSlack？
Webアプリを簡単に作るなら，今やStreamlitとか，選択肢は星の数ほど．
ですが，今回はOpenAIのAPIキーを使うということで．．．
APIキー漏れがgithubや色々なサービスで問題視される中，「セキュリティがヤバいWebアプリ」は全力で避けたいわけです.

そこで考えたのが，所属組織しかアクセスできないプラットフォーム．さらに，日常的にみんなが使っているサービス...
「あ、Slackあるやん！」というわけで、Slackアプリを作ることにしました．使ったのは**Slack Bolt**。公式フレームワークで快適に開発できるのも魅力のひとつです．
## 🐳なぜDocker?
Slack Boltは**JavaScript, Python, Java**で開発できますが,今回はPythonで書いてます

所属組織である研究室の個人VM（仮想マシン）にデプロイするわけですが，Pythonで動かす際面倒くさい点が一つありますよね？
そう，**バージョン管理**です
まず，卒業したらVMごと吹き飛ばされたりして管理から離れます．また新しい管理者が動いてるVMにデプロイするわけですが，Pythonのバージョンが変わったり、必要なパッケージが行方不明になったり．．．考えただけで胃が痛い．

そこで登場するのが**Docker**！
Dockerなら「Pythonのバージョンが違う！」とか「このパッケージ入ってない！」みたいな問題をコードとして管理することでまるっと解決．まさにInfrastructure as Codeの出番というわけです．
個人的に、DockerとPythonの相性の良さは抜群だと思ってるので個人で使う分にもDockerの知識があるならDockerで，ないなら生Python(?)でパッケージを各々インスコして動かしてみてください.

# コードだけほしい人向け
githubからクローンしてきてください
https://github.com/hino-s/speech2text-for-Slack.git

当然SlackやOpenAIのAPIキーは隠しているのでdocker-compose.ymlとおなじディレクトリに.envファイルを作って
```js:.env
SLACK_SIGNING_SECRET=XXXXXXXXXXXXXX
SLACK_BOT_TOKEN=xoxb-XXXXXXXXXXXXXX
SLACK_APP_TOKEN=xapp-XXXXXXXXXXXXXX
OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXX
```
このように書けばOKです
それぞれのAPIキーは以下で作成してください
### Slack
https://api.slack.com/apps

Slackアプリを作るのが初めての場合以下のチュートリアルを参考に作ってみてください
https://tools.slack.dev/bolt-python/ja-jp/getting-started

色々ボットに権限を持たせていくわけですが，**App Manifest**欄がこのようになっていて

~~~json
{
    "display_information": {
        "name": "Speech2Text"
    },
    "features": {
        "bot_user": {
            "display_name": "Speech2Text",
            "always_online": false
        }
    },
    "oauth_config": {
        "scopes": {
            "bot": [
                "app_mentions:read",
                "channels:history",
                "files:read",
                "groups:history",
                "im:history",
                "metadata.message:read",
                "mpim:history",
                "chat:write"
            ]
        }
    },
    "settings": {
        "event_subscriptions": {
            "bot_events": [
                "app_mention",
                "message.channels",
                "message.groups",
                "message.im",
                "message.mpim"
            ]
        },
        "interactivity": {
            "is_enabled": true
        },
        "org_deploy_enabled": false,
        "socket_mode_enabled": true,
        "token_rotation_enabled": false
    }
}
~~~
**App Home**がこのようになったら動くはずです．．．(動かなかったら適宜Slackをタスクマネージャから落として再起動することをお勧めします)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934794/73ed6525-ad7e-1e73-4b2d-2c2c13bfa538.png)
### OpenAI API
https://openai.com/index/openai-api/
## 起動
それぞれの設定が終わったらdocker-compose.ymlのあるディレクトリで
~~~
docker compose up -d
~~~
で起動完了です！
# コード解説
Slack Boltは究極app.pyだけでも動かすことができるのでこいつの仕様について簡単に説明します
~~~python:app.py
import os
import requests
from dotenv import load_dotenv
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from openai import OpenAI
import threading

print("Starting app.py")  # for debug
load_dotenv()
app = App(token=os.environ["SLACK_BOT_TOKEN"], process_before_response=True)
client = OpenAI(api_key=os.environ['OPENAI_API_KEY'])

def process_audio(event, say):
    if not ("files" in event):
        output = "ファイルが見つかりません。\n対応するファイルは以下の通りです。\nmp3, mp4, mpeg, mpga, m4a, wav, webm"
    else:
        file = event["files"][0]
        filetype = file["filetype"]
        
        if filetype in ["mp3", "mp4", "mpeg", "m4a", "mpga", "webm", "wav"]:
            title = file["title"]
            url = file["url_private"]
            extension = os.path.splitext(url)[1]
            filename = "tmp" + extension
            resp = requests.get(url, headers={'Authorization': 'Bearer %s' % os.environ["SLACK_BOT_TOKEN"]})
            with open(filename, 'wb') as f:
                f.write(resp.content)
                thread_ts = event.get("thread_ts") or None
                channel = event["channel"]
                if thread_ts is not None:
                    say(text="読み込み中．．．", thread_ts=thread_ts, channel=channel)
                else:
                    say(text="読み込み中．．．", channel=channel)
            if os.path.getsize(filename) > 25000000:              
                output = "ファイルサイズオーバー。ファイルサイズは25MB以下に分割してください"
            else:
                if os.path.getsize(filename) > 12000000:
                    say(text="ファイルサイズが大きいため，処理に時間がかかることが予想されます",  channel=channel)
                with open(filename, "rb") as audio_file:
                    language = "ja"
                    print(audio_file)
                    transcript = client.audio.transcriptions.create(model="whisper-1", file=audio_file, language=language)
                    organized_text = organize_text_by_speaker(transcript.text)
                output = f"書き起こし完了：{title}\n----\n" + organized_text
            os.remove(filename)
        else:
            output = "対応するファイルではありません。対応するファイルは以下の通りです。\nmp3, mp4, mpeg, mpga, m4a, wav, webm"
    
    # スレッド内でsayを呼び出して応答を送信
    thread_ts = event.get("thread_ts") or None
    channel = event["channel"]
    if thread_ts is not None:
        say(text=output, thread_ts=thread_ts, channel=channel)
    else:
        say(text=output, channel=channel)
    print(output)

@app.event("message")
def handle_message(event, say):
    threading.Thread(target=process_audio, args=(event, say)).start()

def organize_text_by_speaker(text):
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "音声認識のテキストを話者ごとに段落分けして整理してください。またテキストが長くない場合などの登場人物が一人のみの場合，見やすく段落分けするだけで結構です．テキストの文字を絶対に変更せずに段落分けのみしてください"},
            {"role": "user", "content": text}
        ],
        temperature=0.3
    )
    organized_text = response.choices[0].message.content
    return organized_text

if __name__ == "__main__":
    handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"])
    handler.start()
~~~
## 音声ファイル処理：process_audio
ここでは，Slackから送信されたメッセージが音声ファイルであるかを判定し，さらにWhisperに送信可能な最大容量（25MB，約30分相当）以下であるかを確認しています．その上で，条件を満たす音声ファイルについて，書き起こしを行う処理を実装しました．

しかし，実装中に以下のようなエラーが発生しました：
~~~
openai.BadRequestError: Error code: 400 - {'error': {'message': "Unrecognized file format. Supported formats: ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm']"
~~~
送信した際のファイルの拡張子がSlackのurlでは書き変わる現象に遭遇し，ファイル名の拡張子と内容のバイナリが一致しないことからこのエラーが起こってしまいました．そこで，urlから拡張子を拾ってきてくっつけるコードで対応しました．

## テキストの整理：organize_text_by_speaker
whisperから送られてくる文は改行もなく読みにくいため，せっかくOpenAI APIを設定しているので整理もさせてみました．

# 使用例
いきなり長い音声を書き起こすのはトークンの無駄なので適当に以下のサイトの音声を入れてみました
https://pro-video.jp/voice/announce/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934794/83065ef3-c632-0c01-3b08-4498333bc7a8.png)

かなりはやい速度で書き起こしができました！

サンプル音声はさすがにプロの発音なのでSlackのトランスクリプトと大差はありませんが，Whisperが真価を発揮するのは音声がある程度長い場合や雑音が乗っているときです．この時の音声の認識精度は目を見張るものがあります．

また，chatGPTの部分のプロンプトを書き換えることで会話している人を文章から判断してより可読性を高めることも可能です！