見出し画像

Mac版GarageBandで作ったファイルをMIDIファイルに変換しよう

こんにちは。今回はGarageBand(Mac版)のファイルからMIDIファイルに変換するアプリケーションを作っていきたいと思います。
※説明はわかりやすくするため、正確でない表現が含まれている可能性がありますが含まれていたとしても大体は同じようなものだと思うのでなので許してください。
※投稿頻度が高いのは最初だからです。しばらくすると投稿頻度が年2回とかになるかもしれません。

はじめに

実は他にもMIDIに変換するソフトはあったのですが、僕の調べた限りトラックごとにMIDIファイルができるソフトがほとんどで、トラックをまとめて1つのMIDIファイルにするソフトは見当たりませんでした。
また、今回のWebアプリも必ず自己責任でご利用ください。

今回できたサイト

どうしても早く使いたいあなたのために最初に書いておきます。
このページの「使い方」「注意点」必ずみてから使ってください。

開発環境

PCはM2 Macbook Air、メモリ16GB、使用したアプリケーションはGarageBand、VSCode、Hex Fiendとブラウザ(自分はArc)です。

準備するもの

主に必要なものはMac用GarageBandのファイル(.band)です。ファイルが特にない方のためにシャイニングスターのサビ部分だけ耳コピしたファイルを貼っておきます。耳コピの正確性は保証しません。

(提供:魔王魂様)

仕組み

まずGarageBandでトラックを選択し(複数可)、ファイル>リージョンをループライブラリに追加をするとその名の通りトラックがループライブラリに追加されるのですが、実はその時~/Library/Audio/Apple Loops/User Loops/SingleFilesに.aifファイルが格納されます。
それをバイナリエディタで開いて確認すると、中身の一部分が.midファイルと同じ構造になっているんです。
それが4D546864(MThd)から434853(CHS)までの部分(MThdは含みますがCHSは含まない)。
つまりその部分だけを.midとして保存すれば良いのですが、それだと1トラックごとに1ファイルとなってしまい困ります。
midiファイルというのは3種類書き方があって、Format0と1と2がありますがFormat2はほとんど使われていません。Format0と1の違いは、簡単に言えば0はトラックが1つのみのファイル、1はトラックが複数あるファイルです。先ほどの.aifはFormat0の形式なので、無理やりFormat1の形式にしちゃえば良いんです。ちなみにWikipediaに規格の概要が載ってます。

実装

HTML

ささっと書いてしまいましょう。最低限のコード。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>export to .mid for GarageBand(Mac)</title>
    <style>
        html {
            font-family: Arial, Helvetica, sans-serif;
            text-align: center;
        }
    </style>
</head>
<body>
    <h1>MacのGarageBandで作ったものを.midにエクスポートする補助用サイト。</h1>
    <input type="file" id="files" multiple accept="audio/aiff">
    <script></script>
</body>
</html>

ではJSを記述していきましょう。

JavaScript

まずinputで送られてくるバイナリファイルを読み込んでMThdを探します。こちらが参考になりました。

const input = document.getElementById('files');
input.onchange = (e) => {
    //選択されたファイルが入った配列
    const files = input.files;
    //16進数に変換したファイル内容を格納する配列
    let filesText = [];
    if (files.length > 0) {
        //ファイルを1枚ごと処理
        Array.from(files).forEach((file => {
            let reader = new FileReader();
            reader.onload = (rFile) => {
                const byteArray = new Uint8Array(rFile.target.result);
                //16進数に変換
                const hexString = Array.from(byteArray, function (byte) {
                    return ('0' + (byte & 0xFF).toString(16)).slice(-2);
                }).join('')
                //filesTextに追加
                filesText.push(hexString);
                //全ファイルを処理し終わったら
                if (filesText.length == files.length) {
                    //関数を呼び出す
                    filesReadFinished(filesText);
                }
             };
             //ファイル読み込み
             reader.readAsArrayBuffer(file);
         }));
     }
};
function filesReadFinished(filesText) {
    //「MThd~(略)~MTrk」、「CHS」が存在するファイルのみを格納した配列
    const filteredText = filesText.filter((iFile) => {
        return (iFile.toUpperCase().includes('4D54726B') && iFile.toUpperCase().includes('434853'));
    });
    //midi部分を切り出した文字列を格納した配列
    const midiText = filteredText.map(t => {
        //アルファベット部分が小文字なことに気づかなかった。なんとなくバイナリエディタを見習って大文字に
        return t.toUpperCase().substr(t.toUpperCase().indexOf('4D546864000000060000000101E04D54726B'), t.toUpperCase().indexOf('434853'));
    });
    //コンソールに出力
    console.log(midiText);
}

長いです。解説していきます。
input.onchangeはファイルが選択されたときに呼び出されます。
そしてonchangeの中ですが、簡単にいうとファイルを16進数に変換してfilesTextという配列に入れています。
そしてfilesReadFinished(array)が呼び出されるのですが、その中も簡単にいうと「MThd~(略)~MTrk」、「CHS」が存在するファイル=MIDIファイルの規格と同じになっている部分が存在するファイルであることを確かめ、確認できたファイルはMIDIフォーマットの部分を抜き出してコンソールに出力しています。細かい解説はコメントアウトを見てください。
次はトラック部分を繋げて保存するスクリプトを書きます。ほぼ完成。あとよくわからなくなってきてしまったので、コード全文を載せます

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>export to .mid for GarageBand(Mac)</title>
    <style>
        html {
            font-family: Arial, Helvetica, sans-serif;
            text-align: center;
        }
    </style>
</head>

<body>
    <h1>MacのGarageBandで作ったものを.midにエクスポートする補助用サイト。</h1>
    <input type="file" id="files" multiple accept="audio/aiff">
    <script>
        const input = document.getElementById('files');
        input.onchange = () => {
            const files = input.files;
            let filesText = [];
            if (files.length > 0) {
                Array.from(files).forEach((file => {
                    let reader = new FileReader();
                    reader.onload = (rFile) => {
                        const byteArray = new Uint8Array(rFile.target.result);
                        const hexString = Array.from(byteArray, function (byte) {
                            return ('0' + (byte & 0xFF).toString(16)).slice(-2);
                        }).join('')
                        filesText.push(hexString);
                        if (filesText.length == files.length) {
                            filesReadFinished(filesText);
                        }
                    };
                    reader.readAsArrayBuffer(file);
                }));
            }
        };
        function filesReadFinished(filesText) {
            const filteredText = filesText.filter((iFile) => {
                return (iFile.toUpperCase().includes('4D54726B') && iFile.toUpperCase().includes('434853'));
            });
            const midiText = filteredText.map(t => {
                return t.toUpperCase().substr(t.toUpperCase().indexOf('4D546864000000060000000101E04D54726B') + 28, t.toUpperCase().indexOf('434853'));
            });
            if (midiText.length < 65535) {
                const header_chunk = '4D546864000000060001' + midiText.length.toString(16).padStart(4, '0') + '01E0';
                const track_chanks = midiText.map(t => {
                    return (t.substr(0, t.indexOf('FF2F00')) + 'FF2F00');
                }).join('');
                const midiData = header_chunk + track_chanks;
                const key = '0123456789ABCDEF';
                let newBytes = [];
                let currentChar = 0;
                let currentByte = 0;
                for (let i = 0; i < midiData.length; i++) {
                    currentChar = key.indexOf(midiData[i]);
                    if (i % 2 === 0) {
                        currentByte = (currentChar << 4);
                    }
                    if (i % 2 === 1) {
                        currentByte += (currentChar);
                        newBytes.push(currentByte);
                    }
                }
                const buffer = new Uint8Array(newBytes);
                var blob = new Blob([buffer], { type: "audio/midi" });
                var url = window.URL.createObjectURL(blob);

                var a = document.createElement("a");
                a.href = url;
                a.download = "garageband2midi.mid";
                a.click();
                window.URL.revokeObjectURL(url);
            }
        }   
    </script>
</body>
</html>

まず

if (midiText.length < 65535)

ですが、トラックの最大数が65535個(16進数4桁の最大値)なのでないと思いますが念の為制限しています。
そして

const header_chunk = '4D546864000000060001' + midiText.length.toString(16).padStart(4, '0') + '01E0';

こちらはMIDIフォーマットのヘッダチャンクという、簡単にいうと曲の情報が書かれた部分を設定しています。細かく解説すると「4D546864」はヘッダチャンクですよ〜という宣言で、「00000006」はヘッダチャンクの部分は6byteですよ〜ということを教えてくれています。
「0001」はこのファイルがフォーマット1で書かれたものだという宣言(フォーマット0なら0000になる)です。
midiText.length.toString(16).padStart(4, '0')の部分、難しそうに見えますがトラック数を16進数に変換し、4桁になるように0で埋めている(5だったら0005とか)、要するにトラック数を書いてるだけです。で「01E0」は4分音符あたりの分解能らしいですがよくわからないので誰かコメント欄で解説してください。続いて

const track_chunks = midiText.map(t => {
    return (t.substr(0, t.indexOf('FF2F00')) + 'FF2F00');
}).join('');

これはトラックチャンクという、トラックごとのデータが格納された文字列です。一応MIDIファイルはこのようになってます(フォーマット1=複数トラックの話)

ヘッダチャンク
トラックチャンク1
トラックチャンク2
...
トラックチャンクn(n≦65535)

で上のコードですが、まずindexofでFF2F00というトラックチャンクの終了宣言の場所を探し出して、substrで切り抜いています。プラスでFF2F00をつけているのは切り抜いたときに元のFF2F00も消えてしまうからです。indexOfの後に+6とつけても良いと思います。
そしてjoinで切り抜いたもの(トラックの記述)を繋げています。
さらにmidiDataでヘッダチャンクとトラックチャンクを繋げて、もう実質MIDIファイル完成です。しかし保存するのでそこでまた一手間かかります。
16進数の文字列をUint8Arrayに変換するのですが、便利なコードがあったので使用させてもらいます

そしてみんな大好きBlobでダウンロード処理を実行しています。遂に完成しました!

と思っていたのですが、実はトラックはバラバラでもチャンネルが同じになってしまっています。なので例えばピアノロールのアプリケーションに読み込ませても同じ色になっちゃいます。
そこでトラックごとにチャンネルを変えるコードを書きます。
僕の構想では
トラックごとに
「90」を「9[チャンネル数]」、「80」を「8[チャンネル数]」に置き換えるというものです。ただこれを実現するのに2日かかりました。
正規表現を使って置き換えたのですが、結局できた正規表現はこちら(1byteが90になっている部分にマッチする)。

/(?<=((?<!.)((?:.{2})+)))90/g

自分でもよくわからない正規表現ができました。書いてる途中はわかっていたはず。
でも今わかるところは、偶数個の文字列の次に90が来る、という中の90の部分にマッチする正規表現のはずです。試行錯誤してチャンネルを振り分けられてエラーが消えた時は飛び上がりました(実際に)
この正規表現を実現させるためにconst track_chunks…〜const midiData = …;を書き換えます。

const track_chunks = midiText.map(t => {
    return (t.substr(0, t.indexOf('FF2F00')) + 'FF2F00');
});
const trackchunks_replace = [];
for (let i = 0; i < track_chunks.length; i++) {
    let f = i.toString(16);
    if (i == 10) {
        f = 'F';
    }
    if (i > 15) {
        f = 'E';
    }
    trackchunks_replace.push(track_chunks[i].replace(/(?<=((?<!.)((?:.{2})+)))90/g, `9${f}`).replace(/(?<=((?<!.)((?:.{2})+)))80/g, `8${f}`));
}
const midiData = header_chunk + trackchunks_replace.join('');

まずfor文の中ですが、チャンネル10はドラムに振り分けられているのでスキップするような形にしています(そういえばドラムはリージョンをLoopsに追加してもMIDI形式にはならないようなので、ドラムもMIDIで保存したい方はgaragebandで音色をドラム以外に変えてから書き出してください)。
そして先ほどの正規表現を使って置き換えたものを配列にし、midiDataの宣言でjoin()を使い繋げています。

もう確実に使えます。
こんなこともできるよという話なんですが、実はmidiって音色を変えられるんですよ。デフォルトはピアノです。音色の一覧はWikipediaを参照。

トラックを抜き出した文字列から8byte=16文字のところに「00C0XX(XXは先ほどのWikiに書いてあった16進数のコード)」を追加すれば音色も指定できます。僕の場合は音色を変える必要がないのでやりませんが、コメントとかで教えてくれれば追加します。
あとこれはコードではどうしようもないのですが、GarageBandからリージョンをLoopsに追加する時はリージョンを最初まで伸ばしてから書き出しましょう。そうでないと、リージョンの再生位置がずれてしまいます(文だとわかりづらいので画像をご覧ください)

リージョンの開始位置は揃えよう(悪い例)
こうするとずれない(良い例)

ではCSSでデザインを整えていきます。

CSS

すでにフォントと中央揃えは実装してあります。要素ごとのデザインを調整していきましょう。今の時点ではこんな感じです。

超シンプル

シンプルなのは良いですがデザインが悪すぎる(4点)なので調整していきます。JSで疲れたので一気に描いちゃいます

input::file-selector-button {
    font-size: 20px;
    color: #EE1;
    text-shadow: 0.5px 0.5px 0 #555;
    font-weight: bold;
    width: 8em;
    height: 3em;
    border-radius: 8px;
    border: 3px solid #6AD;
    background-color: aqua;
    margin-right: 10px;
}

input {
    font-size: 18px;
    margin: 20px;
}

これで大分よくなりました。

デザイン12点になった

デザインがまだイマイチなのはブラウザのデフォルトCSSが悪いせいです。

完成

できたコード。

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>export to .mid for GarageBand(Mac)</title>
    <style>
        html {
            font-family: Arial, Helvetica, sans-serif;
            text-align: center;
        }

        input::file-selector-button {
            font-size: 20px;
            color: #EE1;
            text-shadow: 0.5px 0.5px 0 #555;
            font-weight: bold;
            width: 8em;
            height: 3em;
            border-radius: 8px;
            border: 3px solid #6AD;
            background-color: aqua;
            margin-right: 10px;
        }

        input {
            font-size: 18px;
            margin: 20px;
        }

    </style>
</head>

<body>
    <h1>MacのGarageBandで作ったものを.midにエクスポートする補助用サイト。</h1>
    <input type="file" id="files" multiple accept="audio/aiff">
    <script>
        const input = document.getElementById('files');
        input.onchange = () => {
            const files = input.files;
            let filesText = [];
            if (files.length > 0) {
                Array.from(files).forEach((file => {
                    let reader = new FileReader();
                    reader.onload = (rFile) => {
                        const byteArray = new Uint8Array(rFile.target.result);
                        const hexString = Array.from(byteArray, function (byte) {
                            return ('0' + (byte & 0xFF).toString(16)).slice(-2);
                        }).join('')
                        filesText.push(hexString);
                        if (filesText.length == files.length) {
                            filesReadFinished(filesText);
                        }
                    };
                    reader.readAsArrayBuffer(file);
                }));
            }
        };
        function filesReadFinished(filesText) {
            const filteredText = filesText.filter((iFile) => {
                return (iFile.toUpperCase().includes('4D54726B') && iFile.toUpperCase().includes('434853'));
            });
            const midiText = filteredText.map(t => {
                return t.toUpperCase().substr(t.toUpperCase().indexOf('4D546864000000060000000101E04D54726B') + 28, t.toUpperCase().indexOf('434853'));
            });
            if (midiText.length < 65535) {
                const header_chunk = '4D546864000000060001' + midiText.length.toString(16).padStart(4, '0') + '01E0';
                const track_chunks = midiText.map(t => {
                    return (t.substr(0, t.indexOf('FF2F00')) + 'FF2F00');
                });
                const trackchunks_replace = [];
                for (let i = 0; i < track_chunks.length; i++) {
                    let f = i.toString(16);
                    if (i == 10) {
                        f = 'F';
                    }
                    if (i < 15) {
                        f = 'E';
                    }
                    trackchunks_replace.push(track_chunks[i].replace(/(?<=((?<!.)((?:.{2})+)))90/g, `9${f}`).replace(/(?<=((?<!.)((?:.{2})+)))80/g, `8${f}`));
                }
                const midiData = header_chunk + trackchunks_replace.join('');
                const key = '0123456789ABCDEF';
                let newBytes = [];
                let currentChar = 0;
                let currentByte = 0;
                for (let i = 0; i < midiData.length; i++) {
                    currentChar = key.indexOf(midiData[i]);
                    if (i % 2 === 0) {
                        currentByte = (currentChar << 4);
                    }
                    if (i % 2 === 1) {
                        currentByte += (currentChar);
                        newBytes.push(currentByte);
                    }
                }
                const buffer = new Uint8Array(newBytes);
                var blob = new Blob([buffer], { type: "audio/midi" });
                var url = window.URL.createObjectURL(blob);

                var a = document.createElement("a");
                a.href = url;
                a.download = "garageband2midi.mid";
                a.click();
                window.URL.revokeObjectURL(url);
            }
        }   
    </script>
</body>

</html>

おわりに

今回作ったサイトはこちらで公開しています。

これでMIDITrailが使える!

ピアノロールソフト「MIDITrail」の画面。今回のサイトを使ってGarageBandからMIDIに変換して使ってみた。

いいなと思ったら応援しよう!