見出し画像

バイナリデータでスタァライトイントロクイズ

この記事はめのフェ (@Menophe9901) さん主催『ここが舞台だ!スタァライト Advent Calendar 2024』8日目の記事です。

概要

皆さんはイントロクイズは得意ですか?私は苦手です。
ディスカバリー! の各校歌い分けでイントロクイズされたら無理です

スタリラ4校分の歌い分けがありますが、15秒続くイントロのうちには区別がつきません

しかもまた最近歌い分けの多い楽曲『復讐の剣』『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)

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