Wizard Notes

Python, JavaScript を使った音楽信号分析の技術録、作曲活動に関する雑記

Flask:オーディオファイルをjQuery.ajaxでアップロードしながら、クライアント側でローカルのファイルを再生

スクリーンショット

f:id:Kurene:20190127143147p:plain
デモアプリのスクリーンショット

背景

ユーザがアップロードするオーディオファイルを使ってWebアプリを作成する時に、ファイルサイズによる通信帯域の圧迫、アップロードの処理待ちが問題になります。

具体的には、1つの楽曲のオーディオファイルは非可逆圧縮フォーマット(e.g. mp3, aac, etc.)で数MB、可逆圧縮フォーマット(e.g. wav, flac, etc.)で数十MB程度のファイルサイズです。 従って、1つの楽曲ファイルならあまり気になりませんが、可逆圧縮の楽曲ファイルや複数楽曲をサーバへアップロードし処理しようとすると、ユーザを長い間待たせてしまいます。そうなるとアプリケーション品質に致命的な影響を与えるため、なるべくアップロード高速化したり、ユーザの体感的待ち時間を短くしたりすることが重要となります。

以上より、今回は、サーバへアップロード中に、アップロードしているオーディオをブラウザ上で確認できる簡単なWebアプリを実装してみました。 なお、応用としては、WebAudioAPIJavaScriptの信号処理ライブラリを用いることで、処理負荷が低く単純な信号処理をクライアント側で、複雑で独自性のあるリッチな信号処理(e.g. 深層学習)をサーバで行うようなWebアプリが考えられます。

実装戦略・参考ページ

サーバへの複数オーディオファイルのアップロード(ドラッグアンドドロップ

Flask-jquery.ajax間の非同期通信を使用しています。 また、ドラッグアンドドロップによる複数ファイルのアップロードもjQueryで記述できるようです。

hayageek.com

www.it-view.net

qiita.com

サーバ側でのファイルアップロード処理・ファイル保存

qiita.com

クライアント側で、ローカルにあるオーディオファイルを再生

HTML5<audio>を使っています。 stackoverflow.com

なお、Web音楽アプリのサーバ開発に‘‘‘Python‘‘‘を利用している理由としては、Flaskによる開発が楽なことと、PythonのNumpy, Scikit-learn, LibROSA, TensorFlowといった優れた信号処理向けライブラリを簡単に使いたいということが挙げられます。

実装

ディレクトリ構成

※開発環境の都合で、js, cssをresourcesに置いていますが、Flaskの定石通り、staticにおいても大丈夫です。

project_root
 ├ resources/
 │ ├ dropzone_handler.js
 │ ├ dropzone_uploader.js
 │ └ style_dropzone.css
 ├ templates/
 │ └ index.html
 └ run.py

dropzone_handler.js

参考ページに書いてあるように、破線内の領域にファイルがドラッグアンドドロップした時の挙動を記述しています。ファイルが領域内にドロップされると、dropzone_uploader.jshandleFileUpload()でファイルのアップロードおよびオーディオファイル操作用の要素の生成が行われます。

クリックするとソースコードを展開

/* Detect file dropping inside the dropzone */
$(document).ready(function() {
    var filetype = 'audio.*'
    var obj = $("#dropzone");
    
    obj.on('dragenter', function (e) {
        e.stopPropagation();
        e.preventDefault();
        $(this).css('border', '2px solid #0B85A1');
    });
    obj.on('dragover', function (e) {
        e.stopPropagation();
        e.preventDefault();
    });
    obj.on('drop', function (e) {
        $(this).css('border', '2px dotted #0B85A1');
        e.preventDefault();
        var files = e.originalEvent.dataTransfer.files;
        //We need to send dropped files to Server
        handleFileUpload(files, obj, filetype);
    });
    $(document).on('dragenter', function (e) {
        e.stopPropagation();
        e.preventDefault();
    });
    $(document).on('dragover', function (e) {
        e.stopPropagation();
        e.preventDefault();
        obj.css('border', '2px dotted #0B85A1');
    });
    $(document).on('drop', function (e) {
        e.stopPropagation();
        e.preventDefault();
    });
});

dropzone_uploader.js

  • createAudioTrack:各オーディオファイルの操作および情報提示用のオブジェクトを生成します。
  • handleFileUpload:各ファイルごとにfiletypeを確認してcreateAudioTrack()でオーディオ操作用のオブジェクトを生成します。
  • sendFileToServerjQuery.ajax()でファイルをアップロードしています。

クリックするとソースコードを展開

var n_tracks=0;

function createAudioTrack(obj) {
     n_tracks++;
     this.track_id = "track"+('000'+n_tracks).slice( -3 )
     this.track_src = this.track_id+"_src"
     this.audiotrack = $("<div class='audiotrack'></div>");
     this.audio = $("<audio id='"+this.track_id+"' controls><source id='"+this.track_src+"' src=''></source></audio>").appendTo(this.audiotrack);
     this.filename = $("<div class='filename'></div>").appendTo(this.audiotrack);
     this.size = $("<div class='filesize'></div>").appendTo(this.audiotrack);
     this.state = $("<div class='state'><div>").appendTo(this.audiotrack);
     this.abort = $("<div class='abort'>Abort</div>").appendTo(this.state);
     this.completed = $("<div class='completed'>Completed</div>").appendTo(this.state);
     this.remove = $("<div class='remove'>Remove</div>").appendTo(this.state);
     this.completed.hide()

     this.progressBar = $("<div class='progressBar'><div></div></div>").appendTo(this.audiotrack);
     obj.after(this.audiotrack);
 
    this.setFileNameSize = function(name, size) {
        var sizeStr = "";
        var sizeKB = size/1024;
        if(parseInt(sizeKB) > 1024) {
            var sizeMB = sizeKB/1024;
            sizeStr = sizeMB.toFixed(2)+" MB";
        }
        else {
            sizeStr = sizeKB.toFixed(2)+" KB";
        }
        this.filename.html(name);
        this.size.html(sizeStr);
    }
    this.setAudioPlayer = function(file) {
        var source = document.getElementById(this.track_src);
        source.src = URL.createObjectURL(file);
    }
    this.setProgress = function(progress) {       
        var progressBarWidth =progress*this.progressBar.width()/ 100;  
        this.progressBar.find('div').animate({ width: progressBarWidth }, 10).html(progress + "% ");
        if(parseInt(progress) >= 100) {
            this.abort.hide();
            this.completed.show();
            this.remove.show();
        }
    }
    this.setAbort = function(jqxhr) {
        var at = this.audiotrack;
        this.abort.click(function() {
            jqxhr.abort();
            sb.hide();
        });
    }
}

function handleFileUpload(files, obj, filetype) {
   for (var i = 0; i < files.length; i++) {
        if (!files[i].type.match(filetype)) {
            continue;
        }
        var fd = new FormData();
        fd.append('file', files[i]);
        
        // Set audiotrack
        var audiotrack = new createAudioTrack(obj);
        audiotrack.setFileNameSize(files[i].name, files[i].size);
        audiotrack.setAudioPlayer(files[i]);
        
        // Upload the file
        sendFileToServer(fd, audiotrack);
   }
}

function sendFileToServer(formData, audio_track) {
    var jqXHR=$.ajax({
        xhr: function() {
            var xhrobj = $.ajaxSettings.xhr();
            if (xhrobj.upload) {
                xhrobj.upload.addEventListener('progress', function(event) {
                    var percent = 0;
                    var position = event.loaded || event.position;
                    var total = event.total;
                    if (event.lengthComputable) {
                        percent = Math.ceil(position / total * 100);
                    }
                    audio_track.setProgress(percent);
                }, false);
            }
            return xhrobj;
        },
        url: "./upload",
        type: "POST",
        contentType:false,
        processData: false,
        cache: false,
        data: formData,
        success: function(data){ audio_track.setProgress(100); }
    }); 
    audio_track.setAbort(jqXHR);
}

style_dropzone.css

クリックするとソースコードを展開

#dropzone {
    border: 2px dotted #0B85A1;
    width: 600px;
    height: 300px;
    color: #92AAB0;
    text-align: center;
    vertical-align: middle;
    padding: 10px 10px 10 10px;
    margin-bottom: 10px;
    font-size: 2.0rem;
    background-color: rgba(255, 255, 255, 0.8);
}

.progressBar {
    width: 200px;
    height: 22px;
    border: 1px solid #ddd;
    border-radius: 5px; 
    overflow: hidden;
    display:inline-block;
    margin:0px 10px 5px 5px;
    vertical-align:top;
}
 
.progressBar div {
    height: 100%;
    color: #fff;
    text-align: right;
    line-height: 22px; /* same as #progressBar height if we want text middle aligned */
    width: 0;
    background-color: #0ba1b5; border-radius: 3px; 
}
.audiotrack {
    border-top: 1px solid #A9CCD1;
    min-height: 25px;
    width: 700px;
    padding: 10px 10px 0px 10px;
    vertical-align: top;
}
.filename {
    display: inline-block;
    vertical-align: top;
    width: 250px;
}
.filesize {
    display: inline-block;
    vertical-align: top;
    color: #30693D;
    width: 100px;
    margin-left: 10px;
    margin-right: 5px;
}
.abort, .completed, .remove{
    -moz-border-radius: 0.1rem;
    -webkit-border-radius: 0.1rem;
    border-radius: 0.1rem;
    display: inline-block;
    color: #fff;
    font-family: arial;
    font-size: 1.0rem;
    font-weight: normal;
    margin-right: 0.2rem;
    padding: 0.1rem 1rem;
    cursor: pointer;
    vertical-align: top;
}

.abort{ background-color: #A8352F; }
.completed{ background-color: #2fa863; }
.remove{ background-color: #444444; }

run.py (Flask)

クリックするとソースコードを展開

import os
from flask import Flask
from flask import render_template, request, send_from_directory
from pprint import pprint


app = Flask(__name__)

basedir = os.path.abspath(os.path.dirname(__file__))
app.config.update(
    UPLOADED_PATH=os.path.join(basedir, 'uploads')
)

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

@app.route("/resources/<path:filepath>")
def send_resource(filepath):
    return send_from_directory("resources", filepath)

@app.route('/upload', methods=['POST'])
def upload_audio():
    pprint(request.files)
    f = request.files['file']
    filepath = os.path.join(app.config["UPLOADED_PATH"], f.filename)
    f.save(filepath)
    os.remove(filepath)
    return "", 204


if __name__ == '__main__':
    app.run("0.0.0.0", 80, debug=True)

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
  <script type="text/javascript" src="resources/dropzone_upload.js"></script>
  <script type="text/javascript" src="resources/dropzone_handler.js"></script>
  <link type="text/css" href="resources/style_dropzone.css" rel="stylesheet">
</head>
<body>
<div id="dropzone">Drag and Drop Your Audio-files</div>
</body>
</html>

デモ

複数の楽曲ファイルをアップロードしてみてください。 ※以下のデモでは、アップロードしたファイルはアップロード完了後にサーバ側で即削除しております。 http://www.wizardcraft.works/dev/flask_dropzone_local_playback_and_upload/www.wizardcraft.works

最後に

ファイルのアップロードについては、サーバへの攻撃を防ぐため、ファイル名、フォーマット、サイズなどに注意する必要があります。 デモの方では、アップロードできるファイル数やサイズを制限しています。