レンタルサーバでWebアプリ作り:リアルタイム録音

ロリポップレンタルサーバではできることが限られていたので、ホスティングサービスの DigitalOcean を使ってWebアプリを公開するまでの道のりを記録します。
データ分析を仕事としているのでパソコンやITのことは多少は詳しいですが、インフラまわりは全くの素人です。そんな人が見て参考になる情報をまとめていきたいと思います。

DigitalOceanの紹介リンク [PR]

ここから手続きを進めてもらえると200ドル分の無料チケット(有効期間2ヶ月)がもらえるようですので、ぜひご活用ください。
※ 2023/12/29時点

https://m.do.co/c/a8b31ed34b75


背景

過去の記事で「しゃべってチャットボット」を作りましたが、録音時間が長いとエラーになるという事象が発生したため、リアルタイム録音に変更しました。録音している間、一定期間でデータを送り続けるような仕様に変更します。


ゴール=音声データを送り続ける

この前作ったものは「録音停止ボタンが押されてから一気にデータ送信」でしたが、「録音停止ボタンが押されるまでちょこちょこデータを送る」に変更します。

箱づくり

いつものようにGPT先生に箱を作ってもらいます。以前、socketioのバージョンが使い物にならないものをGPT先生が選んだことがあったので、こちらで指定してあげます。

Flaskで以下のようなプログラムを作りたい。
・ファイルは2つ。app.pyとtemplates/index.html
・通信はsocketioを使う。以下のプログラムを使う。ポート5000で通信する。
・HTMLには「録音開始」と「録音停止」ボタンがある。具体的な機能実装は何もなしでOK。

socketio関連JS: """
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.0/socket.io.js"></script>
"""

https対応

DigitalOceanではhttpsで接続しないといけないので、以下のように修正してもらいます。単にローカル環境でテストするだけなら以下は不要です。

socketioは、以下を使ってください。

// Socket.IOの接続設定
var socketUrl;
var options = { transports: ['websocket'] }; // WebSocketのみを使用
if (location.protocol === 'https:') {
socketUrl = 'https://' + document.domain;
} else {
socketUrl = 'http://' + document.domain + ':5000';
}
var socket = io.connect(socketUrl, options);

録音機能の実装

以下のプロンプトだけでガツガツと録音機能のコードを書いてくれます。

録音機能を実装してください。ポイントは以下の通り。
・種類はaudio/webm
・オーディオデータをArrayBufferに変換してBase64にエンコードする

今回の目玉=データを送り続ける

ここまでは前回のおさらいみたいな話で、今回の目玉は以下の「送り続ける」部分です。

以下のように修正してください。
・録音開始ボタンを押したら録音をスタートさせ、一定期間でPythonにデータを送り続ける(5秒間隔)
・録音停止が押されたら、最後の録音部分を送り、録音を停止する。Pythonに停止信号を送る
・Pythonでは、停止信号がやってきたら録音データを保存する。

ユーザーごとの切り分け

前回発生したトラブル対応部分です。

以下の機能を追加してください。
・アクセスユーザーごとに動くよう、session_idを取り入れてください。

Pythonから停止信号

これで最後。

Python側での保存が終了したら、HTMLに"処理完了"という文字列を送り、HTML側ではその信号を受けて録音開始ボタンを有効にしてください。

トラブルポイント

以下のようなポイントで少々はまりました。

  • 最後のパートが保存されない:7秒話したとして、最初の5秒は録音されるけど6~7秒のパートが録音されていない

  • 録音が5秒以内だとエラーが出る:「最初の5秒のデータが送られてきたときにsession_idを作り、停止ボタンが押されたら最終パートを追加して保存する」という形になっていると、5秒以内の場合にエラーになります。

コード全文

GPT先生が作ってくれたプログラムは以下の通り。 .env を読み込むなど、過去のソースコードも一部取り入れています。

app.py

from flask import Flask, render_template, request
from flask_socketio import SocketIO
import os
import base64

app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('FLASK_SECRET_KEY', 'your_default_secret_key')
socketio = SocketIO(app)

audio_chunks = {}

@app.route('/')
def index():
    return render_template('index.html')

@socketio.on('sending_recdata')
def sending_recdata(data):
    print('on recording')
    session_id = request.sid
    chunk_data = data.get('webmdata')
    if session_id not in audio_chunks:
        audio_chunks[session_id] = []
    audio_chunks[session_id].append(base64.b64decode(chunk_data))

@socketio.on('stop_recording')
def stop_recording(data):
    session_id = request.sid
    chunk_data = data.get('webmdata')

    # session_id に対応するエントリーが存在しない場合は、新しく作成する
    if session_id not in audio_chunks:
        audio_chunks[session_id] = []

    audio_chunks[session_id].append(base64.b64decode(chunk_data))  # 最後のチャンクを追加
    complete_audio = b''.join(audio_chunks.get(session_id, []))
    filename = f"{session_id}.webm"
    with open(filename, 'wb') as file:
        file.write(complete_audio)
    
    # 処理後、辞書からエントリーを削除
    if session_id in audio_chunks:
        del audio_chunks[session_id]

    socketio.emit('response_stt', {'res_stt': '処理完了'}, room=session_id)


if __name__ == '__main__':
    socketio.run(app, port=5000, debug=True)

templates/index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>録音アプリ</title>
    <script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.0/socket.io.js"></script>
</head>
<body>
    <h1>録音アプリ</h1>
    <button id="btn_recording_start">録音開始</button>
    <button id="btn_recording_stop" style="display: none;">録音停止</button>
    <p id="status"></p>

    <script>
        let mediaRecorder;
        let audioChunks = [];
        let recordingInterval;
        const RECORDING_INTERVAL_MS = 5000; // 5秒ごとにデータを送信

        // Socket.IOの接続設定
        var socketUrl;
        var options = { transports: ['websocket'] }; // WebSocketのみを使用
        if (location.protocol === 'https:') {
            socketUrl = 'https://' + document.domain;
        } else {
            socketUrl = 'http://' + document.domain + ':5000';
        }
        var socket = io.connect(socketUrl, options);

        function startRecording() {
            navigator.mediaDevices.getUserMedia({ audio: true })
                .then(stream => {
                    const options = { mimeType: 'audio/webm' };
                    mediaRecorder = new MediaRecorder(stream, options);
                    mediaRecorder.ondataavailable = async event => {
                        if (mediaRecorder.state === "recording") {
                            const base64Data = await getBase64Data(event.data);
                            socket.emit('sending_recdata', { webmdata: base64Data });
                        } else if (mediaRecorder.state === "inactive") {
                            // 録音が停止したときに最後のデータを送信
                            const base64Data = await getBase64Data(event.data);
                            socket.emit('stop_recording', { webmdata: base64Data });
                        }
                    };
                    mediaRecorder.start(RECORDING_INTERVAL_MS);
                });
        }


        async function stopRecording() {
            mediaRecorder.stop(); // stop すると最後のデータが ondataavailable で取得される
        }


        async function getBase64Data(blob) {
            const arrayBuffer = await blob.arrayBuffer();
            return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
        }

        document.getElementById("btn_recording_start").addEventListener("click", function() {
            startRecording();
            document.getElementById("status").textContent = "録音中...";
            document.getElementById("btn_recording_stop").style.display = 'block';
        });

        document.getElementById("btn_recording_stop").addEventListener("click", async function() {
            await stopRecording();
            document.getElementById("status").textContent = "録音が停止されました。";
            document.getElementById("btn_recording_stop").style.display = 'none';
        });

    </script> 

</body>
</html>

最後まで見ていただきありがとうございました!


サポート問い合わせ先

DigitalOceanのサポート問い合わせリンクがなかなか見つからないので、リンクを載せておきます。

https://cloudsupport.digitalocean.com/s/

場所は、トップページの右下にある「Ask a question」に行き、そのページの一番下(欄外っぽいところ)にひっそりと「Support」というリンクがあります(Contact内)。そのページの一番最後に「Contact Support」ボタンがあります。


この記事が気に入ったらサポートをしてみませんか?