#!/usr/bin/env python3 """ Convert audio samples into embedded C data. Reads: - samples/servo_seeks_eq.wav (48kHz float32 mono, 40 seeks) - samples/fan_idle_loop.wav (any format, mono) Output: src/frontend/generated/audio_samples.h Process: 1. Resample to 44100 Hz, convert to int16 mono via ffmpeg. 2. For servo seeks: detect silence gaps to split into individual seek sounds. 3. For fan: convert entire file to int16 PCM. 4. Generate a C header with sample data arrays and lookup tables. """ import os import subprocess import struct import sys # ── Paths ─────────────────────────────────────────────────────────────── SEEK_WAV = "samples/servo_seeks_eq.wav" FAN_WAV = "samples/fan_idle_loop.wav" OUT_DIR = "src/frontend/generated" OUT_FILE = os.path.join(OUT_DIR, "audio_samples.h") TARGET_RATE = 44100 # ── Silence detection (for seek splitting) ────────────────────────────── SILENCE_THRESHOLD = 500 # int16 absolute value threshold MIN_SILENCE_SAMPLES = 2205 # 50ms at 44100 Hz # ── Helpers ───────────────────────────────────────────────────────────── def ffmpeg_to_int16(path): """Resample and convert to int16 mono, return raw PCM bytes.""" cmd = [ "ffmpeg", "-y", "-i", path, "-ar", str(TARGET_RATE), "-ac", "1", "-sample_fmt", "s16", "-f", "s16le", "-" ] result = subprocess.run(cmd, capture_output=True) if result.returncode != 0: print(f"ffmpeg failed for {path}:", result.stderr.decode(), file=sys.stderr) sys.exit(1) return result.stdout def parse_int16(raw): """Convert raw s16le bytes to a list of int16 values.""" count = len(raw) // 2 return list(struct.unpack(f"<{count}h", raw)) def find_silence_regions(data): """Find continuous silent regions in int16 PCM data.""" regions = [] start = None for i, sample in enumerate(data): is_silent = abs(sample) < SILENCE_THRESHOLD if is_silent and start is None: start = i elif not is_silent and start is not None: length = i - start if length >= MIN_SILENCE_SAMPLES: regions.append((start, i)) start = None if start is not None and len(data) - start >= MIN_SILENCE_SAMPLES: regions.append((start, len(data))) return regions def split_into_samples(data, silence_regions): """Split PCM data into individual seek samples using silence delimiters.""" samples = [] prev_end = 0 for s_start, s_end in silence_regions: if s_start > prev_end: chunk = data[prev_end:s_start] if len(chunk) > 1000: # sanity: > 22ms samples.append(chunk) prev_end = s_end if prev_end < len(data): chunk = data[prev_end:] if len(chunk) > 1000: samples.append(chunk) return samples def write_int16_array(f, name, data, values_per_line=12): """Write a static const int16_t array.""" f.write(f"static const int16_t {name}_data[] = {{\n ") for j in range(0, len(data), values_per_line): vals = data[j:j+values_per_line] line = ", ".join(str(v) for v in vals) if j + values_per_line < len(data): f.write(line + ",\n ") else: f.write(line + "\n") f.write("};\n\n") # ── Main ──────────────────────────────────────────────────────────────── def main(): os.makedirs(OUT_DIR, exist_ok=True) # ── Process seek samples ──────────────────────────────────────────── if not os.path.exists(SEEK_WAV): print(f"Error: {SEEK_WAV} not found.", file=sys.stderr) sys.exit(1) print(f"Converting {SEEK_WAV}...") raw_seek = ffmpeg_to_int16(SEEK_WAV) seek_data = parse_int16(raw_seek) print(f" PCM samples: {len(seek_data)} ({len(seek_data) / TARGET_RATE:.2f}s)") silence = find_silence_regions(seek_data) print(f" Found {len(silence)} silence regions") seek_samples = split_into_samples(seek_data, silence) print(f" Extracted {len(seek_samples)} seek samples") if len(seek_samples) == 0: print("Error: No seek samples extracted.", file=sys.stderr) sys.exit(1) for i, s in enumerate(seek_samples[:5]): print(f" Sample {i}: {len(s)} samples ({len(s)/TARGET_RATE*1000:.0f}ms)") if len(seek_samples) > 5: print(f" ... and {len(seek_samples)-5} more") # ── Process fan sample ────────────────────────────────────────────── if not os.path.exists(FAN_WAV): print(f"Error: {FAN_WAV} not found.", file=sys.stderr) sys.exit(1) print(f"\nConverting {FAN_WAV}...") raw_fan = ffmpeg_to_int16(FAN_WAV) fan_data = parse_int16(raw_fan) print(f" PCM samples: {len(fan_data)} ({len(fan_data) / TARGET_RATE:.2f}s)") # ── Generate header ───────────────────────────────────────────────── print(f"\nWriting {OUT_FILE}...") with open(OUT_FILE, "w") as f: f.write("// Auto-generated by tools/samples/gen_samples.py\n") f.write("// Do not edit manually.\n\n") f.write("#ifndef GENERATED_AUDIO_SAMPLES_H\n") f.write("#define GENERATED_AUDIO_SAMPLES_H\n\n") f.write("#include \n\n") f.write("// ── Seek sample entry ──────────────────────────────────────────────\n\n") f.write("typedef struct {\n") f.write(" const int16_t *data;\n") f.write(" uint32_t length;\n") f.write(" uint8_t distance;\n") f.write("} sample_entry_t;\n\n") # Write seek sample arrays for i, sample in enumerate(seek_samples): write_int16_array(f, f"seek_sample_{i}", sample) # Write seek lookup table f.write("static const sample_entry_t seek_samples[] = {\n") for i, sample in enumerate(seek_samples): start_track = i distance = (40 - start_track) * 100 // 40 if distance < 1: distance = 1 if distance > 100: distance = 100 f.write(f" {{ seek_sample_{i}_data, {len(sample)}, {distance} }}") if i < len(seek_samples) - 1: f.write(",") f.write(f" // {i}->40 seek, distance={distance}\n") f.write("};\n\n") f.write(f"static const int num_seek_samples = {len(seek_samples)};\n\n") # Write fan idle sample f.write("// ── Fan idle loop sample ───────────────────────────────────────────\n\n") write_int16_array(f, "fan_idle", fan_data) f.write(f"static const uint32_t fan_idle_size = {len(fan_data)};\n\n") # Note: write_int16_array adds _data suffix, so array is fan_idle_data f.write("#endif // GENERATED_AUDIO_SAMPLES_H\n") print("Done.") if __name__ == "__main__": main()