#!/usr/bin/env python3 """Prepare locally supplied audio for the S3. Requires ffmpeg and ffprobe. The v4 catalog contains ordered v3 songs with metadata and 20 ms Opus packets. Writes the flash image music.bin and local summary music.json to artifacts/radio. """ import argparse import json import os from pathlib import Path import struct import subprocess import unicodedata import zlib from project import ROOT from music_partition import MUSIC_BYTES MAX_TAG_BYTES = 256 MAX_TRACKS = 32 MAX_TOTAL_FRAMES = 180_000 AUDIO_SUFFIXES = {".flac", ".wav", ".mp3", ".m4a", ".ogg", ".opus"} DEFAULT_BITRATE_KBPS = 96 def music_bitrate(value): if type(value) is not int or not 1 <= value <= 512: raise ValueError("bitrateKbps must be an integer from 1 to 512") return value def music_tag(value, name, required=False): value = value.strip() if (required and not value) or len(value.encode("utf-8")) > MAX_TAG_BYTES: raise ValueError(f"{name} must be {'1' if required else '0'}..{MAX_TAG_BYTES} UTF-8 bytes; use --{name.lower()} to override it") if any(unicodedata.category(character) == "Cc" for character in value): raise ValueError(f"{name} contains control characters; use --{name.lower()} to override it") return value def ogg_packets(data): cursor = 0 packet = bytearray() while cursor < len(data): if data[cursor:cursor + 4] != b"OggS": raise ValueError("Invalid Ogg page") segments = data[cursor + 26] sizes = data[cursor + 27:cursor + 27 + segments] cursor += 27 + segments for size in sizes: packet.extend(data[cursor:cursor + size]) cursor += size if size < 255: yield bytes(packet) packet.clear() if packet: raise ValueError("Truncated Ogg packet") def pack_song(packets, title, artist): title = music_tag(title, "Title", required=True) artist = music_tag(artist, "Artist") if not 1 <= len(packets) <= 30000: raise ValueError("Each song must contain 1..30000 audio frames (up to 10 minutes)") title_bytes, artist_bytes = title.encode(), artist.encode() payload = bytearray(struct.pack(" MAX_TOTAL_FRAMES: raise ValueError("Playlist exceeds the bounded 60-minute frame index") payload = b"".join(struct.pack(" MUSIC_BYTES: raise ValueError("Encoded playlist exceeds the 16 MiB music partition") header = bytearray(struct.pack("<8sIIIIII32x", b"S3MUSIC\0", 4, len(songs), 48000, 2, 20, zlib.crc32(payload))) struct.pack_into("