Files

191 lines
7.6 KiB
Python
Executable File

#!/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 <stdint.h>\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()