|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Generate MP3 test fixtures for Audio::Scan TXXX multi-value tests. |
| 3 | +
|
| 4 | +Uses a valid MPEG frame extracted from an existing fixture as the audio |
| 5 | +payload, then hand-builds ID3v2.4 tags with null-separated TXXX values |
| 6 | +so we get exact byte-level control over the multi-value encoding. |
| 7 | +""" |
| 8 | +import struct, os |
| 9 | + |
| 10 | +OUTDIR = "/home/martijn/github/Audio-Scan/t/mp3" |
| 11 | +# Use an existing valid MP3 as the audio base |
| 12 | +BASE_MP3 = "/home/martijn/github/Audio-Scan/t/mp3/no-tags-mp1l3.mp3" |
| 13 | + |
| 14 | + |
| 15 | +def get_audio_payload(): |
| 16 | + """Read raw MPEG audio frames from an existing no-tags fixture.""" |
| 17 | + with open(BASE_MP3, 'rb') as f: |
| 18 | + return f.read() |
| 19 | + |
| 20 | + |
| 21 | +def syncsafe_encode(size): |
| 22 | + """Encode an integer as a 4-byte syncsafe integer (ID3v2.4 spec).""" |
| 23 | + return ( |
| 24 | + ((size & 0x0FE00000) << 3) | |
| 25 | + ((size & 0x001FC000) << 2) | |
| 26 | + ((size & 0x00003F80) << 1) | |
| 27 | + (size & 0x0000007F) |
| 28 | + ) |
| 29 | + |
| 30 | + |
| 31 | +def make_txxx_frame(desc, values, encoding=3): |
| 32 | + """Build an ID3v2.4 TXXX frame with null-separated values. |
| 33 | + encoding: 0=Latin1, 3=UTF-8 |
| 34 | + """ |
| 35 | + if encoding == 3: |
| 36 | + sep = b'\x00' |
| 37 | + desc_bytes = desc.encode('utf-8') + b'\x00' |
| 38 | + val_bytes = sep.join(v.encode('utf-8') for v in values) |
| 39 | + else: |
| 40 | + sep = b'\x00' |
| 41 | + desc_bytes = desc.encode('latin-1') + b'\x00' |
| 42 | + val_bytes = sep.join(v.encode('latin-1') for v in values) |
| 43 | + |
| 44 | + data = bytes([encoding]) + desc_bytes + val_bytes |
| 45 | + size = len(data) |
| 46 | + frame = b'TXXX' + struct.pack('>I', syncsafe_encode(size)) + b'\x00\x00' + data |
| 47 | + return frame |
| 48 | + |
| 49 | + |
| 50 | +def make_id3v2_tag(frames): |
| 51 | + """Wrap frames in an ID3v2.4 tag header.""" |
| 52 | + body = b''.join(frames) |
| 53 | + size = len(body) |
| 54 | + header = b'ID3' + b'\x04\x00' + b'\x00' + struct.pack('>I', syncsafe_encode(size)) |
| 55 | + return header + body |
| 56 | + |
| 57 | + |
| 58 | +def write_fixture(name, frames): |
| 59 | + tag = make_id3v2_tag(frames) |
| 60 | + audio = get_audio_payload() |
| 61 | + path = os.path.join(OUTDIR, name) |
| 62 | + with open(path, 'wb') as f: |
| 63 | + f.write(tag + audio) |
| 64 | + print(f"Wrote {path} ({len(tag) + len(audio)} bytes)") |
| 65 | + |
| 66 | + |
| 67 | +# Fixture 1: Two-value TXXX (ALBUMARTISTS) |
| 68 | +write_fixture("v2.4-txxx-multivalue.mp3", [ |
| 69 | + make_txxx_frame("ALBUMARTISTS", ["Artist1", "Artist2"]), |
| 70 | + make_txxx_frame("ARTISTS", ["TrackArtist1", "TrackArtist2"]), |
| 71 | +]) |
| 72 | + |
| 73 | +# Fixture 2: Three-value TXXX |
| 74 | +write_fixture("v2.4-txxx-multivalue-3.mp3", [ |
| 75 | + make_txxx_frame("ALBUMARTISTS", ["Artist1", "Artist2", "Artist3"]), |
| 76 | +]) |
| 77 | + |
| 78 | +# Fixture 3: Multi-value with empty slot (tagger bug simulation) |
| 79 | +write_fixture("v2.4-txxx-multivalue-empty.mp3", [ |
| 80 | + make_txxx_frame("ALBUMARTISTS", ["Artist1", "", "Artist3"]), |
| 81 | +]) |
| 82 | + |
| 83 | +# Fixture 4: Single-value TXXX (regression guard) |
| 84 | +write_fixture("v2.4-txxx-single.mp3", [ |
| 85 | + make_txxx_frame("ALBUMARTISTS", ["OnlyArtist"]), |
| 86 | + make_txxx_frame("USER FRAME", ["SingleValue"]), |
| 87 | +]) |
0 commit comments