Dynamic WAV format support: 8/16-bit, variable sample rate, BLE list/delete, FORMAT command

This commit is contained in:
zyphlar
2026-07-04 03:28:14 -07:00
parent 7b1dc8e118
commit e6a2a65b82
2 changed files with 261 additions and 124 deletions
+90 -55
View File
@@ -5,10 +5,15 @@ upload_track.py — serial audio uploader for baby_mobile_v2 POC_INTERNAL_FLASH
Usage:
python upload_track.py <port> <track_num> <file>
Supported formats: .raw, .wav, .mp3, .ogg, .flac, .aac, .m4a (anything ffmpeg handles)
Supported formats: .wav, .mp3, .ogg, .flac, .aac, .m4a, .raw (anything ffmpeg handles)
Output format: 16-bit signed PCM WAV, 16 kHz, mono (WAV header included).
- Already-conformant WAV files (PCM, mono, 8 or 16-bit, 8/16/32 kHz) are sent as-is.
- All other formats are converted via ffmpeg to 16-bit 16 kHz mono WAV.
- .raw files are treated as legacy 8-bit unsigned 16 kHz and wrapped in a WAV header.
Requires: pyserial (pip install pyserial)
ffmpeg in PATH for non-WAV/RAW files
ffmpeg in PATH for non-WAV/RAW files or non-conformant WAV files
"""
import sys
@@ -23,69 +28,96 @@ CHUNK_SIZE = 512
BAUD_RATE = 115200
TIMEOUT_S = 10.0
RAW_EXTS = {'.raw'}
WAV_EXTS = {'.wav'}
# Sample rates the firmware PWM can reproduce (32kHz carrier / (REFRESH+1))
SUPPORTED_RATES = {8000, 16000, 32000}
def convert_to_pcm(path: str) -> bytes:
def _make_wav_header(num_samples: int, sample_rate: int, bits: int) -> bytes:
"""Build a minimal 44-byte PCM WAV header."""
num_channels = 1
byte_rate = sample_rate * num_channels * bits // 8
block_align = num_channels * bits // 8
data_size = num_samples * block_align
chunk_size = 36 + data_size
return struct.pack('<4sI4s4sIHHIIHH4sI',
b'RIFF', chunk_size, b'WAVE',
b'fmt ', 16, 1, num_channels, sample_rate,
byte_rate, block_align, bits,
b'data', data_size)
def load_wav(path: str) -> bytes:
"""Return full WAV bytes (header + PCM data) ready to upload."""
ext = os.path.splitext(path)[1].lower()
if ext in RAW_EXTS:
with open(path, "rb") as f:
return f.read()
# ── Legacy .raw: 8-bit unsigned 16 kHz mono ─────────────────────────────
if ext == '.raw':
with open(path, 'rb') as f:
pcm = f.read()
print(f"RAW file: wrapping {len(pcm):,} bytes as 8-bit 16 kHz mono WAV")
header = _make_wav_header(len(pcm), 16000, 8)
return header + pcm
if ext in WAV_EXTS:
with open(path, "rb") as f:
# ── WAV: check if already conformant ────────────────────────────────────
if ext == '.wav':
with open(path, 'rb') as f:
data = f.read()
if data[:4] == b"RIFF":
if data[:4] == b'RIFF' and len(data) >= 44:
try:
audio_format = struct.unpack_from("<H", data, 20)[0]
num_channels = struct.unpack_from("<H", data, 22)[0]
sample_rate = struct.unpack_from("<I", data, 24)[0]
bits_per_samp = struct.unpack_from("<H", data, 34)[0]
needs_convert = (audio_format != 1 or num_channels != 1
or sample_rate != 16000 or bits_per_samp != 8)
audio_format = struct.unpack_from('<H', data, 20)[0]
num_channels = struct.unpack_from('<H', data, 22)[0]
sample_rate = struct.unpack_from('<I', data, 24)[0]
bits_per_samp = struct.unpack_from('<H', data, 34)[0]
conformant = (
audio_format == 1 and # PCM
num_channels == 1 and # mono
bits_per_samp in (8, 16) and
sample_rate in SUPPORTED_RATES
)
except Exception:
needs_convert = True
conformant = False
if not needs_convert:
print("WAV is already 8-bit mono 16kHz — stripping header")
return data[44:]
print("WAV needs resampling — converting via ffmpeg")
# fall through to ffmpeg conversion
if conformant:
duration = (len(data) - 44) / (bits_per_samp // 8) / sample_rate
print(f"WAV already conformant: {bits_per_samp}-bit {sample_rate // 1000}kHz "
f"mono — {duration:.1f}s")
return data # send as-is, header included
print("WAV needs conversion — running ffmpeg")
# fall through to ffmpeg
# Use ffmpeg for everything else (mp3, ogg, flac, aac, non-conformant wav...)
# ── All other formats (and non-conformant WAV): convert via ffmpeg ───────
if not shutil.which('ffmpeg'):
sys.exit(
"ERROR: ffmpeg not found. Install it or convert manually:\n"
f" ffmpeg -i \"{path}\" -ar 8000 -ac 1 -f u8 -acodec pcm_u8 out.raw"
f" ffmpeg -i \"{path}\" -ar 16000 -ac 1 -acodec pcm_s16le out.wav"
)
print(f"Converting {os.path.basename(path)} via ffmpeg...")
print(f"Converting {os.path.basename(path)} via ffmpeg -> 16-bit 16 kHz mono WAV...")
result = subprocess.run(
['ffmpeg', '-y', '-i', path,
'-ar', '16000', '-ac', '1', '-f', 'u8', '-acodec', 'pcm_u8', 'pipe:1'],
'-ar', '16000', '-ac', '1', '-f', 'wav', '-acodec', 'pcm_s16le', 'pipe:1'],
capture_output=True
)
if result.returncode != 0:
sys.exit(f"ERROR: ffmpeg failed:\n{result.stderr.decode(errors='replace')[-400:]}")
pcm = result.stdout
duration = len(pcm) / 16000
print(f" Converted: {len(pcm):,} bytes ({duration:.1f}s)")
return pcm
wav = result.stdout
if len(wav) >= 44:
bits = struct.unpack_from('<H', wav, 34)[0]
rate = struct.unpack_from('<I', wav, 24)[0]
audio_bytes = len(wav) - 44
duration = audio_bytes / (bits // 8) / rate
print(f" Converted: {audio_bytes:,} audio bytes ({duration:.1f}s), "
f"{bits}-bit {rate // 1000}kHz")
return wav
def upload(port: str, track_num: int, pcm: bytes) -> None:
total = len(pcm)
def upload(port: str, track_num: int, wav: bytes) -> None:
total = len(wav)
print(f"Uploading {total} bytes as track {track_num} via {port} ...")
# dsrdtr=False / rtscts=False prevents Windows from toggling DTR/RTS on
# port open, which would reset the nRF52840 and stall the 5-second boot loop.
with serial.Serial(port, BAUD_RATE, timeout=2.0,
dsrdtr=False, rtscts=False) as ser:
# Drain any boot output; send a bare newline first to flush any
# partial line sitting in the firmware's line buffer.
time.sleep(0.5)
ser.reset_input_buffer()
ser.write(b"\n")
@@ -98,7 +130,7 @@ def upload(port: str, track_num: int, pcm: bytes) -> None:
ser.write(cmd.encode())
ser.flush()
# Wait for READY — print everything received so failures are diagnosable
# Wait for READY
deadline = time.time() + TIMEOUT_S
while True:
if time.time() > deadline:
@@ -109,17 +141,16 @@ def upload(port: str, track_num: int, pcm: bytes) -> None:
if line:
print(f" firmware: {line}")
# Stream PCM in chunks; stop early if firmware sends ERR
# Stream WAV data in chunks; poll for ERR
sent = 0
err_line = None
ser.timeout = 0.05 # short timeout so we can poll for ERR while sending
ser.timeout = 0.05
while sent < total:
chunk = pcm[sent:sent + CHUNK_SIZE]
chunk = wav[sent:sent + CHUNK_SIZE]
ser.write(chunk)
sent += len(chunk)
pct = sent * 100 // total
print(f"\r {sent}/{total} bytes ({pct}%) ", end="", flush=True)
# Check for early ERR from firmware (e.g. LittleFS full)
line = ser.readline().decode(errors="replace").strip()
if line.startswith("ERR"):
err_line = line
@@ -129,14 +160,18 @@ def upload(port: str, track_num: int, pcm: bytes) -> None:
if err_line:
print(f"ERROR from firmware: {err_line}")
# at= tells us how many bytes were accepted before the failure
if "at=" in err_line:
accepted = int(err_line.split("at=")[1].split()[0])
safe = int(accepted * 0.9) # 10% headroom
max_s = safe / 16000
safe = int(accepted * 0.9)
# estimate audio bytes (subtract header)
audio_accepted = max(0, safe - 44)
bits = struct.unpack_from('<H', wav, 34)[0] if len(wav) >= 44 else 16
rate = struct.unpack_from('<I', wav, 24)[0] if len(wav) >= 44 else 16000
max_s = audio_accepted / (bits // 8) / rate
print(f" LittleFS accepted {accepted} bytes before full.")
print(f" Safe clip length: ~{max_s:.1f}s")
print(f" Trim with: ffmpeg -i input.mp3 -t {max_s:.0f} -ar 16000 -ac 1 -f u8 out.raw")
print(f" Trim with: ffmpeg -i input.mp3 -t {max_s:.0f} -ar 16000 -ac 1 "
f"-acodec pcm_s16le out.wav")
sys.exit(1)
# Wait for OK
@@ -154,7 +189,7 @@ def upload(port: str, track_num: int, pcm: bytes) -> None:
if line:
print(f" firmware: {line}")
# Verify: request hex dump of first 256 bytes and compare
# Verify: hex-dump first 256 bytes and check WAV header is intact
print("Verifying...")
ser.reset_input_buffer()
ser.write(f"DUMP {track_num}\n".encode())
@@ -178,7 +213,6 @@ def upload(port: str, track_num: int, pcm: bytes) -> None:
print("ERROR: firmware says file does not exist!")
break
else:
# Parse hex bytes
for tok in line.split():
try:
fw_bytes.append(int(tok, 16))
@@ -194,14 +228,15 @@ def upload(port: str, track_num: int, pcm: bytes) -> None:
else:
print(f" Size OK: {fw_size}/{total}")
compare_len = min(len(fw_bytes), len(pcm), 256)
# Compare first 256 bytes (includes WAV header — confirms format metadata made it)
compare_len = min(len(fw_bytes), len(wav), 256)
if compare_len > 0:
mismatches = sum(1 for i in range(compare_len) if fw_bytes[i] != pcm[i])
mismatches = sum(1 for i in range(compare_len) if fw_bytes[i] != wav[i])
if mismatches == 0:
print(f" Data OK: first {compare_len} bytes match")
print(f" Data OK: first {compare_len} bytes match (WAV header intact)")
else:
print(f" ERROR: {mismatches}/{compare_len} byte mismatches in first {compare_len} bytes")
print(f" Expected: {' '.join(f'{b:02X}' for b in pcm[:16])}")
print(f" Expected: {' '.join(f'{b:02X}' for b in wav[:16])}")
print(f" Got: {' '.join(f'{b:02X}' for b in fw_bytes[:16])}")
@@ -217,11 +252,11 @@ def main():
if not 0 <= track_num < 32:
sys.exit("track_num must be 031")
pcm = convert_to_pcm(path)
if not pcm:
wav = load_wav(path)
if not wav:
sys.exit("File is empty after conversion")
upload(port, track_num, pcm)
upload(port, track_num, wav)
if __name__ == "__main__":