Skip to content
File

Blob: archive/prepare-music.py

python127 lines
1#!/usr/bin/env python3
2"""Prepare private version 1 music for the archived C radio and FFT benchmark.
3Requires ffmpeg, ffprobe and NumPy. Leaves the maintained radio's music pack alone.
4"""
5import argparse
6import json
7import os
8from pathlib import Path
9import struct
10import subprocess
11import unicodedata
12import zlib
13 
14 
15ROOT = Path(__file__).resolve().parents[1]
16MAX_TAG_BYTES = 256
17 
18 
19def music_tag(value, name, required=False):
20 value = value.strip()
21 if (required and not value) or len(value.encode("utf-8")) > MAX_TAG_BYTES:
22 raise ValueError(f"{name} must be {'1' if required else '0'}..{MAX_TAG_BYTES} UTF-8 bytes; use --{name.lower()} to override it")
23 if any(unicodedata.category(character) == "Cc" for character in value):
24 raise ValueError(f"{name} contains control characters; use --{name.lower()} to override it")
25 return value
26 
27 
28def ogg_packets(data):
29 cursor = 0
30 packet = bytearray()
31 while cursor < len(data):
32 if data[cursor:cursor + 4] != b"OggS":
33 raise ValueError("Invalid Ogg page")
34 segments = data[cursor + 26]
35 sizes = data[cursor + 27:cursor + 27 + segments]
36 cursor += 27 + segments
37 for size in sizes:
38 packet.extend(data[cursor:cursor + size])
39 cursor += size
40 if size < 255:
41 yield bytes(packet)
42 packet.clear()
43 if packet:
44 raise ValueError("Truncated Ogg packet")
45 
46 
47def legacy_spectrum(file, packets, preskip):
48 import numpy as np
49 pcm = np.frombuffer(subprocess.check_output([
50 "ffmpeg", "-v", "error", "-i", str(file), "-map", "0:a:0", "-vn",
51 "-ar", "48000", "-ac", "1", "-f", "f32le", "pipe:1"]), dtype="<f4")
52 window_size = 2048
53 window = np.hanning(window_size)
54 padded = np.pad(pcm, (window_size, window_size))
55 frequencies = np.fft.rfftfreq(window_size, 1 / 48000)
56 edges = np.geomspace(30, 20000, 33)
57 bins = [np.flatnonzero((frequencies >= low) & (frequencies < high))
58 for low, high in zip(edges[:-1], edges[1:])]
59 bins = [group if len(group) else np.array([np.argmin(abs(frequencies - (edges[i] + edges[i + 1]) / 2))])
60 for i, group in enumerate(bins)]
61 for index in range(len(packets)):
62 center = index * 960 + 480 - preskip + window_size
63 sample = padded[center - window_size // 2:center + window_size // 2]
64 sample = np.pad(sample, (0, max(0, window_size - len(sample))))
65 power = abs(np.fft.rfft(sample * window)) * 2 / window.sum()
66 db = np.array([20 * np.log10(max(1e-6, power[group].max())) for group in bins])
67 bands = np.clip((db + 72) / 66 * 255, 0, 255).astype(np.uint8).tobytes()
68 yield bands
69 
70 
71def main():
72 parser = argparse.ArgumentParser(description=__doc__)
73 parser.add_argument("file", type=Path, nargs="?", default=os.environ.get("TRACK") or None)
74 parser.set_defaults(legacy_spectrum=True)
75 parser.add_argument("--title", help="Override the audio file's title tag")
76 parser.add_argument("--artist", help="Override the audio file's artist tag")
77 args = parser.parse_args()
78 if args.file is None:
79 parser.error("provide an audio file or set TRACK for make music")
80 out = ROOT / "artifacts/archive/music"
81 out.mkdir(parents=True, exist_ok=True, mode=0o700)
82 metadata = json.loads(subprocess.check_output([
83 "ffprobe", "-v", "error", "-show_format", "-of", "json", str(args.file)]))["format"]
84 tags = {key.lower(): value for key, value in metadata.get("tags", {}).items()}
85 title = music_tag(args.title if args.title is not None else tags.get("title", args.file.stem), "Title", required=True)
86 artist = music_tag(args.artist if args.artist is not None else tags.get("artist", ""), "Artist")
87 encoded = subprocess.check_output([
88 "ffmpeg", "-v", "error", "-i", str(args.file), "-map", "0:a:0", "-vn",
89 "-ar", "48000", "-ac", "2", "-c:a", "libopus", "-b:a", "96k",
90 "-vbr", "on", "-frame_duration", "20", "-application", "audio", "-f", "opus", "pipe:1"])
91 packets = list(ogg_packets(encoded))
92 if not packets[0].startswith(b"OpusHead") or not packets[1].startswith(b"OpusTags"):
93 raise ValueError("Expected Opus headers")
94 preskip = struct.unpack_from("<H", packets[0], 10)[0]
95 packets = packets[2:]
96 legacy = legacy_spectrum(args.file, packets, preskip) if args.legacy_spectrum else None
97 frames = bytearray()
98 for index, packet in enumerate(packets):
99 if not 0 < len(packet) <= 1275:
100 raise ValueError("Unexpected Opus packet size")
101 bands = next(legacy) if legacy is not None else b""
102 frames.extend(struct.pack("<H", len(packet)) + bands + packet)
103 if not 1 <= len(packets) <= 30000:
104 raise ValueError("Track must contain 1..30000 audio frames")
105 version = 1 if args.legacy_spectrum else 3
106 tag_bytes = b"" if args.legacy_spectrum else (
107 struct.pack("<HH", len(title.encode("utf-8")), len(artist.encode("utf-8")))
108 + title.encode("utf-8") + artist.encode("utf-8"))
109 payload = tag_bytes + frames
110 header = struct.pack("<8sIIIIII32x", b"S3MUSIC\0", version, len(packets), 48000, 2, 20, zlib.crc32(payload))
111 binary = header + payload
112 if len(binary) > 4 * 1024 * 1024:
113 raise ValueError("Music exceeds the configured 4 MiB partition")
114 (out / "music.bin").write_bytes(binary)
115 (out / "music.bin").chmod(0o600)
116 manifest = {"title": title, "artist": artist,
117 "durationMs": len(packets) * 20, "sourceDurationMs": round(float(metadata["duration"]) * 1000),
118 "codec": "Opus", "sampleRate": 48000, "channels": 2, "frameMs": 20,
119 "bands": 32, "frames": len(packets), "bytes": len(binary), "packVersion": version,
120 "spectrum": "precomputed" if args.legacy_spectrum else "live-fft"}
121 (out / "music.json").write_text(json.dumps(manifest, indent=2) + "\n")
122 print(json.dumps(manifest, indent=2))
123 
124 
125if __name__ == "__main__":
126 main()