#!/usr/bin/env python3 """ Converts .bin audio files (GBA format) to .wav files. Reads the binary format created by aif2pcm and generates WAV files that will produce identical binaries when processed by wav2agb -b. """ import struct import sys import os from typing import Optional # Delta encoding table used for compression/decompression # Matches the table in tools/aif2pcm/main.c DELTA_ENCODING_TABLE = [ 0, 1, 4, 9, 16, 25, 36, 49, -64, -49, -36, -25, -16, -9, -4, -1, ] def delta_decompress(compressed_data: bytes, expected_length: int) -> bytes: """ Decompress delta-encoded audio data. Delta compression format (from tools/aif2pcm/main.c): - Data is organized in blocks of up to 64 samples each - Each block starts with a base sample value (1 byte) - Followed by a delta index (4 bits) for the second sample - Then 31 pairs of delta indices (4 bits each, packed into bytes) - Delta indices reference DELTA_ENCODING_TABLE to get the actual delta value Args: compressed_data: The compressed audio data expected_length: Expected length of decompressed data Returns: Decompressed audio data as bytes """ pcm = bytearray(expected_length + 0x40) # Extra buffer space i = 0 # Input position j = 0 # Output position while i < len(compressed_data) and j < expected_length: # Read base sample for this block base = compressed_data[i] # Convert to signed int8 for calculations base_signed = base if base < 128 else base - 256 pcm[j] = base i += 1 j += 1 if i >= len(compressed_data) or j >= expected_length: break # Read second sample using low nibble delta lo = compressed_data[i] & 0xf base_signed += DELTA_ENCODING_TABLE[lo] pcm[j] = base_signed & 0xff i += 1 j += 1 if i >= len(compressed_data) or j >= expected_length: break # Process up to 31 pairs of samples (62 samples total) for k in range(31): # High nibble hi = (compressed_data[i] >> 4) & 0xf base_signed += DELTA_ENCODING_TABLE[hi] pcm[j] = base_signed & 0xff j += 1 if j >= expected_length: break # Low nibble lo = compressed_data[i] & 0xf base_signed += DELTA_ENCODING_TABLE[lo] pcm[j] = base_signed & 0xff j += 1 i += 1 if i >= len(compressed_data): break if j >= expected_length: break if j >= expected_length: break return bytes(pcm[:j]) def read_bin(bin_path: str) -> tuple: """ Read a GBA audio .bin file and extract all data. Binary format (little-endian): - Bytes 0-3: flags (bit 0 = compression, bit 30 = loop enabled) - Bytes 4-7: pitch value = sample_rate * 1024 - Bytes 8-11: loop start position - Bytes 12-15: loop end position (stored as actual_end - 1) - Remaining bytes: audio samples (8-bit signed) """ with open(bin_path, 'rb') as f: bin_data = f.read() if len(bin_data) < 16: raise ValueError(f"File too small: {len(bin_data)} bytes") # Read header flags = struct.unpack(' 0 else 0 padding = bytes([last_sample] * (expected_num_samples - len(samples))) samples = samples + padding else: # For uncompressed data, only read expected_num_samples # (ignore any trailing alignment padding in the .bin file) samples = compressed_data[:expected_num_samples] # For loop_end, use the expected number from the header # This matches aif2pcm's behavior where the COMM chunk has the expected count # even if the actual SSND data is shorter loop_end = expected_num_samples if is_looped else 0 return sample_rate, is_looped, loop_start, loop_end, samples def write_wav(wav_path: str, sample_rate: float, is_looped: bool, loop_start: int, loop_end: int, samples: bytes): """ Write a .wav file with smpl chunk. """ # WAV uses unsigned 8-bit, GBA bin uses signed 8-bit # Convert signed (-128 to +127) to unsigned (0 to 255) samples_unsigned = bytes((b + 128) & 0xFF for b in samples) # For WAV fmt chunk, use integer sample rate sample_rate_int = int(sample_rate) num_channels = 1 bytes_per_sample = 1 bits_per_sample = 8 byte_rate = sample_rate_int * num_channels * bytes_per_sample block_align = num_channels * bytes_per_sample # Build fmt chunk fmt_chunk = struct.pack(' {wav_path}") sample_rate, is_looped, loop_start, loop_end, samples = read_bin(bin_path) print(f" Sample rate: {sample_rate} Hz") print(f" Num samples: {len(samples)}") if is_looped: print(f" Loop: {loop_start} -> {loop_end}") else: print(f" Loop: None") write_wav(wav_path, sample_rate, is_looped, loop_start, loop_end, samples) print(f" Done!") def main(): if len(sys.argv) < 2: print("Usage: bin_to_wav.py [output.wav]") print(" or: bin_to_wav.py (converts all .bin files in directory)") sys.exit(1) input_path = sys.argv[1] if os.path.isdir(input_path): # Convert all .bin files in directory for filename in sorted(os.listdir(input_path)): if filename.lower().endswith('.bin'): bin_path = os.path.join(input_path, filename) convert_bin_to_wav(bin_path) else: # Convert single file output_path = sys.argv[2] if len(sys.argv) > 2 else None convert_bin_to_wav(input_path, output_path) if __name__ == '__main__': main()