バイナリデータでスタァライトイントロクイズ
この記事はめのフェ (@Menophe9901) さん主催『ここが舞台だ!スタァライト Advent Calendar 2024』8日目の記事です。
概要
皆さんはイントロクイズは得意ですか?私は苦手です。
ディスカバリー! の各校歌い分けでイントロクイズされたら無理です
しかもまた最近歌い分けの多い楽曲『復讐の剣』『LIFE IS LIKE A VOYAGE』が増えました。これでは並み居る舞台創造科達に勝ち目がありません。
そこで私は「イントロが始まる前に見分けてやろう」と考えました。そう、
00000000 00 00 00 20 66 74 79 70 4d 34 41 20 00 00 00 00 |... ftypM4A ....|
00000010 4d 34 41 20 6d 70 34 32 69 73 6f 6d 00 00 00 00 |M4A mp42isom....|
00000020 00 00 e2 bc 6d 6f 6f 76 00 00 00 6c 6d 76 68 64 |....moov...lmvhd|
00000030 00 00 00 00 e3 7a 55 81 e3 7a 55 91 00 00 ac 44 |.....zU..zU....D|
00000040 00 cb 30 00 00 01 00 00 01 00 00 00 00 00 00 00 |..0.............|
00000050 00 00 00 00 00 01 00 00 00 00 00 00 00 00 00 00 |................|
*
00000070 00 00 00 00 40 00 00 00 00 00 00 00 00 00 00 00 |....@...........|
00000080
バイナリデータでね。
問題設定
M4A (*) ファイルがスタァライトの何の楽曲であるかを識別する。
当然メディアプレーヤーは用いない。
python3 (標準ライブラリのみ使用可) と bash のみ利用可能とする。
*) M4A … MP4 の拡張で、Apple Lossless がサポートされています。Macbook (筆者のPC) の iTunes で CD を読み込むと、デフォルトでは .m4a ファイルとしてインポートされます。今日解説する範囲では基本的に .m4a と .mp4 は同じものと思って問題ないです。
MP4 とは
MP4 はデジタルマルチメディアコンテナフォーマットで、映像や音声の格納によく用いられます。
こちらのファイル構造の解説記事にあるように、mp4 はボックスと呼ばれるデータのかたまりを木構造にして持っています。各ボックスは初めに自身のサイズとタイプの情報を持ち、続く領域にデータを持っているか、子ボックスを格納しています。深さ優先でバイナリを前から駆けていくといい感じに一覧することができました。
- ftyp 32
- moov 58044
- mvhd 108
- trak 54941
- tkhd 92
- mdia 54841
- mdhd 32
- hdlr 34
- minf 54767
- smhd 16
- dinf 36
- stbl 54707
- stsd 103
- stts 24
- stsc 40
- stsz 52036
- stco 2496
- udta 2987
- meta 2979
- free 3356
- mdat 9834539
例として『LIFE IS LIKE A VOYAGE (まひる x なな)』の構造を表示します。
英小文字4字がボックスのタイプを、それに続く整数はボックスのサイズ (byte) を表します。
重要なのは moov ボックスと mdat ボックスで、moov には楽曲名を含むすべてのメタデータが、mdat には音声の生データが格納されています。
決め手となるメタデータを探す
まず初めに、m4a 内で楽曲名が含まれるボックスを特定します。バイナリイントロクイズをやるにあたって楽曲名よりもプリミティブな情報はありません。しかも、経験的にですが、楽曲名などのメタデータは音声生データ領域より先に記録されている = 曲が始まる前に答えられるのです。
"LIFE" で検索すると、
$ hexdump -C 1-10\ LIFE\ IS\ LIKE\ A\ VOYAGE\ \(まひる\ x\ なな\).m4a | grep -C 5 LIFE
0000d730 05 00 00 0b ab 75 64 74 61 00 00 0b a3 6d 65 74 |.....udta....met|
0000d740 61 00 00 00 00 00 00 00 22 68 64 6c 72 00 00 00 |a......."hdlr...|
0000d750 00 00 00 00 00 6d 64 69 72 61 70 70 6c 00 00 00 |.....mdirappl...|
0000d760 00 00 00 00 00 00 b4 00 00 05 15 69 6c 73 74 00 |...........ilst.|
0000d770 00 00 42 a9 6e 61 6d 00 00 00 3a 64 61 74 61 00 |..B.nam...:data.|
0000d780 00 00 01 00 00 00 00 4c 49 46 45 20 49 53 20 4c |.......LIFE IS L|
0000d790 49 4b 45 20 41 20 56 4f 59 41 47 45 20 28 e3 81 |IKE A VOYAGE (..|
0000d7a0 be e3 81 b2 e3 82 8b 20 78 20 e3 81 aa e3 81 aa |....... x ......|
0000d7b0 29 00 00 00 54 a9 41 52 54 00 00 00 4c 64 61 74 |)...T.ART...Ldat|
0000d7c0 61 00 00 00 01 00 00 00 00 e9 9c b2 e5 b4 8e e3 |a...............|
0000d7d0 81 be e3 81 b2 e3 82 8b 20 28 e5 b2 a9 e7 94 b0 |........ (......|
見つかりました。moov -> udta -> と潜ったところにある meta ボックスの中に楽曲名が記載されているようです。さらに、meta ボックスは下位の階層構造を持つことがわかりました。調べてみると meta に含まれている ilst ボックス (from d767 bytes) がメタデータのアイテムリストを表すようです。
この ilst こそが決め手と考えて、より詳しく調べていきます。
ilst の構造
他と違って、ilst はどうやら特殊な構造をしたボックスを子に持つようです。
これまで「サイズ4バイト + タイプ4バイト + データか子ボックス」という形をしていました。わかりやすいのは冒頭でもお見せした ftyp ボックスの領域です。
ファイルタイプのボックス
00000000 00 00 00 20 66 74 79 70 4d 34 41 20 00 00 00 00 |... ftypM4A ....|
00000010 4d 34 41 20 6d 70 34 32 69 73 6f 6d 00 00 00 00 |M4A mp42isom....|
解説
00 00 00 20: ボックスのサイズ = 32 byte
66 74 79 70: ボックスのタイプ = ftyp
4d 34 20 ..: ボックスのデータ = M4A の mp42isom (よくわからないけど)
対して、ilst に含まれている楽曲名を格納したボックスは、タイプ名の 1 文字目に b"\xa9" を持ち(そのため utf-8 でデコードできない)、下位に "data" なる領域を持ちます。
そのあとに続く b"\x00\x00\x00\x01\x00\x00\x00\x00" はおそらくエンコーディング情報でしょう。
楽曲名のボックス
0000d76f 00 00 00 42 a9 6e 61 6d 00 00 00 3a 64 61 74 61 |...B.nam...:data|
0000d77f 00 00 00 01 00 00 00 00 4c 49 46 45 20 49 53 20 |........LIFE IS |
0000d78f 4c 49 4b 45 20 41 20 56 4f 59 41 47 45 20 28 e3 |LIKE A VOYAGE (.|
0000d79f 81 be e3 81 b2 e3 82 8b 20 78 20 e3 81 aa e3 81 |........ x .....|
0000d7af aa 29 |.)|
0000d7b1
ドキュメントのこの辺を読めばちゃんとわかりそうだけど、とりあえずそれらしく問題回避して、utf-8 で decode したら、楽曲情報が色々出てきました!
- ilst 1301
- nam 66 LIFE IS LIKE A VOYAGE (まひる x なな)
- ART 84 露崎まひる (岩田陽葵) & 大場なな (小泉萌香)
- wrt 36 伊藤和馬
- alb 145 少女☆歌劇 レヴュースタァライト 舞台奏像劇 遙かなるエルドラド 劇中歌アルバム [Disc 1]
- gen 34 Soundtrack
- trkn 32
- disk 30
- day 28 2024
- cpil 25
- pgap 25
- tmpo 26
- too 39 iTunes 12.8.2.3
- ---- 188
- ---- 119
- ---- 162
- ---- 165
- ---- 89
せっかくなので楽曲名の部分のバイト列を念入りに見ておきましょう。
4c 49 46 45 20 49 53 20 4c 49 4b 45 20 41 20 56 4f 59 41 47 45 20 28 e3 81 be e3 81 b2 e3 82 8b 20 78 20 e3 81 aa e3 81 aa 29
L I F E ' ' I S ' ' L I K E ' ' A ' ' V O Y A G E ' ' ( ま ひ る ' ' x ' ' な な )
utf8 では 41 ~ 5a が英大文字、61 ~ 7a が英小文字を表します。空白や括弧などの半角記号はその前後にあります。後ろの方をみると、e3 で始まる 3 バイトを使って平仮名が表現されています。急に一文字にかかるバイト数が変わって奇妙ですが、utf8 エンコーディングは 1 バイト目に 1 文字にかかるバイト数の情報を持っているのでこういう動作をします。
e381**あたりが平仮名の領域で、続く e382**らへんは片仮名の領域です。とすると、次の楽曲名は何でしょうか?
4c 49 46 45 20 49 53 20 4c 49 4b 45 20 41 20 56 4f 59 41 47 45 20
28
e5 8f 8c e8 91 89
20 78 20
e3 82 af e3 83 ad e3 83 87 e3 82 a3 e3 83 bc e3 83 8c
29
1行目は先ほどと一緒(LIFE IS ~)、5行目は e382/e383 始まりの 7 文字、ということは、
28 # (
e5 8f 8c e8 91 89 # 双葉
20 78 20 # x
e3 82 af e3 83 ad e3 83 87 e3 82 a3 e3 83 bc e3 83 8c # クロディーヌ
29 # )
そう、「クロディーヌ」と書かれています。こちらは ふたクロ ver. でした。
「双葉」の箇所が e5 と e8 で始まっていることからわかるように、漢字の領域はめちゃめちゃ広いです。これは CJK 統合漢字と呼ばれる中国も韓国もごちゃまぜのフィールドになっているからで、コードから漢字を予測するのは不可能です。
でもせっかくなので、9人の名前の utf8 バイナリ一覧を置いときますね。
name_dict = {
'純那': b'\xe7\xb4\x94\xe9\x82\xa3',
'華恋': b'\xe8\x8f\xaf\xe6\x81\x8b',
'香子': b'\xe9\xa6\x99\xe5\xad\x90',
'真矢': b'\xe7\x9c\x9f\xe7\x9f\xa2',
'双葉': b'\xe5\x8f\x8c\xe8\x91\x89',
'クロディーヌ': b'\xe3\x82\xaf\xe3\x83\xad\xe3\x83\x87\xe3\x82\xa3\xe3\x83\xbc\xe3\x83\x8c',
'まひる': b'\xe3\x81\xbe\xe3\x81\xb2\xe3\x82\x8b',
'なな': b'\xe3\x81\xaa\xe3\x81\xaa',
'ひかり': b'\xe3\x81\xb2\xe3\x81\x8b\xe3\x82\x8a'
}
今回は文字列が utf8 エンコーディングされていたからやり易かったですが、そうでない mp4 ファイルも考えられます。各自確認してみてください。
楽曲名より早い位置で見分けられるのか
正直、わかりませんでした。
- ftyp 32
- moov 58044
- mvhd 108
- trak 54941
- tkhd 92
- mdia 54841
- mdhd 32
- hdlr 34
- minf 54767
- smhd 16
- dinf 36
- stbl 54707
- stsd 103
- stts 24
- stsc 40
- stsz 52036
- stco 2496
- udta 2987
- meta 2979
- hdlr 34
- ilst 1301
- nam 66 LIFE IS LIKE A VOYAGE (まひる x なな) # ここまで来れば識別できる
...
- free 1632
- free 3356
- mdat 9834539
可能性1: moov のブロックサイズで識別できる
メタデータ領域のサイズは 33 ~ 36 バイト目に記載されているので、もしかしたらサイズを見るだけで見分けがつくかもしれません。しかし通常メタデータのサイズは環境に依存するため(隠していますが、インポートしている私の PC の情報とかが含まれていました)。一般には不可能と考えられます。
可能性2: trak ブロックに識別可能な情報が含まれる
楽曲名が格納されているより前のデータ、具体的には trak に何かよいものが入っている可能性があります。しかし今回は発見できませんでした。
結論
mp4 バイナリの moov -> udta -> meta -> ilst -> \xa9nam を見に行くと楽曲名がありました。これであなたもスタァライト (バイナリ) イントロクイズで曲が始まる前に早押しできます。
今回の検証に用いたスクリプトを置いておきます。自由に使っていただいて構いませんが、作者(私)はソフトウェアに対して一切の責任を負いません。
import argparse
import glob
GRN = '\033[32m'
END = '\033[0m'
node_types = sorted(["moov", "udta", "meta", "ilst"])
def read_atom_header(data, idx, indent):
atom_size = int.from_bytes(data[idx:idx+4], "big")
if atom_size == 0: return 4, None
# if item in ilst
if data[idx+4] == int.from_bytes(b'\xa9', "big"):
atom_type = data[idx+5:idx+8].decode()
data_elem = data[idx+24:idx+atom_size]
print(GRN+f"{' '*indent}- {atom_type} {atom_size} {data_elem.decode()}"+END)
else:
atom_type = data[idx+4:idx+8].decode()
print(f"{' '*indent}- {atom_type} {atom_size}")
if atom_type in node_types:
child_idx = idx + 8
while child_idx < idx + atom_size:
child_size, _ = read_atom_header(data, child_idx, indent+1)
child_idx += child_size
return atom_size, atom_type
def read_m4a(m4a_file):
with open(m4a_file, 'rb') as f:
data = f.read()
filesize = len(data)
idx = 0
while idx < filesize:
atom_size, atom_type = read_atom_header(data, idx, 0)
idx += atom_size
if __name__ == "__main__":
m4a_dir = "m4a_files"
parser = argparse.ArgumentParser()
parser.add_argument('--m4a-file', nargs='*')
args = parser.parse_args()
if args.m4a_file is None:
songlist = sorted(glob.glob(f"{m4a_dir}/*"))
else:
songlist = [args.m4a_file] if isinstance(args.m4a_file, str) else args.m4a_file
for song in songlist:
print(song)
read_m4a(song)