🎤動機~無料書き起こしサービスを求めて~
学会発表の質疑応答や会議の議事録の作成,皆さんどうしてますか?
「これ録音して,後で書き起こしてまとめよう!」と思い立つものの、無料で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ファイルを作って
SLACK_SIGNING_SECRET=XXXXXXXXXXXXXX
SLACK_BOT_TOKEN=xoxb-XXXXXXXXXXXXXX
SLACK_APP_TOKEN=xapp-XXXXXXXXXXXXXX
OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXX
このように書けばOKです
それぞれのAPIキーは以下で作成してください
Slack
Slackアプリを作るのが初めての場合以下のチュートリアルを参考に作ってみてください
https://tools.slack.dev/bolt-python/ja-jp/getting-started
色々ボットに権限を持たせていくわけですが,App Manifest欄がこのようになっていて
{
"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をタスクマネージャから落として再起動することをお勧めします)
OpenAI API
起動
それぞれの設定が終わったらdocker-compose.ymlのあるディレクトリで
docker compose up -d
で起動完了です!
コード解説
Slack Boltは究極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, ack):
ack() # すぐに応答してSlackの3秒制限を回避
# 非同期で音声処理を実行
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から拡張子を拾ってきてくっつけるコードで対応しました.
メッセージイベントのハンドリング:handle_message
メッセージが送られてきたらprocess_audioを呼び出すよー的なことを書いてます
また,slackAPIは3秒以内に応答が返ってこなければ再リクエストを数回送り直す仕様となっているためack()で応答し続けることで回避しています.
ちなみに付けないと何回も音声書き起こしを行ってしまって無駄にAPI料金を支払う羽目になります(ちなみにWhisperのAPI料金は音声データ1分あたり0.006ドルとめちゃくちゃ安いですが...)
テキストの整理:organize_text_by_speaker
whisperから送られてくる文は改行もなく読みにくいため,せっかくOpenAI APIを設定しているので整理もさせてみました.
使用例
いきなり長い音声を書き起こすのはトークンの無駄なので適当に以下のサイトの音声を入れてみました
https://pro-video.jp/voice/announce/
かなりはやい速度で書き起こしができました!
サンプル音声はさすがにプロの発音なのでSlackのトランスクリプトと大差はありませんが,Whisperが真価を発揮するのは音声がある程度長い場合や雑音が乗っているときです.この時の音声の認識精度は目を見張るものがあります.
また,chatGPTの部分のプロンプトを書き換えることで会話している人を文章から判断してより可読性を高めることも可能です!