#!/usr/bin/env python3 """ upload_track.py — serial audio uploader for baby_mobile_v2 POC_INTERNAL_FLASH mode Usage: python upload_track.py Supported formats: .wav, .mp3, .ogg, .flac, .aac, .m4a, .raw (anything ffmpeg handles) Output format: 8-bit unsigned PCM WAV, 8 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 8-bit 8 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 or non-conformant WAV files """ import sys import os import shutil import struct import subprocess import serial import time CHUNK_SIZE = 4096 BAUD_RATE = 115200 TIMEOUT_S = 10.0 # Sample rates the firmware PWM can reproduce (32kHz carrier / (REFRESH+1)) SUPPORTED_RATES = {8000, 16000, 32000} 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() # ── 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 # ── WAV: check if already conformant ──────────────────────────────────── if ext == '.wav': with open(path, 'rb') as f: data = f.read() if data[:4] == b'RIFF' and len(data) >= 44: try: audio_format = struct.unpack_from(' 8-bit 8 kHz mono WAV...") result = subprocess.run( ['ffmpeg', '-y', '-i', path, '-ar', '8000', '-ac', '1', '-f', 'wav', '-acodec', 'pcm_u8', 'pipe:1'], capture_output=True ) if result.returncode != 0: sys.exit(f"ERROR: ffmpeg failed:\n{result.stderr.decode(errors='replace')[-400:]}") wav = result.stdout if len(wav) >= 44: bits = struct.unpack_from(' None: total = len(wav) print(f"Uploading {total} bytes as track {track_num} via {port} ...") with serial.Serial(port, BAUD_RATE, timeout=2.0, dsrdtr=False, rtscts=False) as ser: time.sleep(0.5) ser.reset_input_buffer() ser.write(b"\n") ser.flush() time.sleep(0.1) ser.reset_input_buffer() # Send UPLOAD command cmd = f"UPLOAD {track_num} {total}\n" ser.write(cmd.encode()) ser.flush() # Wait for READY deadline = time.time() + TIMEOUT_S while True: if time.time() > deadline: sys.exit("Timed out waiting for READY") line = ser.readline().decode(errors="replace").strip() if line == "READY": break if line: print(f" firmware: {line}") # Stream WAV data without per-chunk polling — the firmware only ever sends # ERR mid-transfer on a flash write failure (very rare); polling after every # chunk adds 50 ms of readline timeout overhead per packet, which for a large # file at USB CDC speeds amounts to many minutes of wasted time. sent = 0 ser.timeout = 0 # non-blocking reads while sending while sent < total: 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) # Drain any early ERR the firmware might have sent line = ser.read(ser.in_waiting).decode(errors="replace").strip() if line.startswith("ERR"): print() print(f"ERROR from firmware: {line}") sys.exit(1) print() ser.timeout = 2.0 # Wait for OK — allow up to 60 s for the final sector erase + flash writes deadline = time.time() + 60.0 while True: if time.time() > deadline: sys.exit("Timed out waiting for OK") line = ser.readline().decode(errors="replace").strip() if line.startswith("OK"): print(f"Upload done: {line}") break if line.startswith("ERR"): print(f"ERROR from firmware: {line}") sys.exit(1) if line: print(f" firmware: {line}") # 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()) ser.flush() deadline = time.time() + TIMEOUT_S fw_size = None fw_bytes = bytearray() while True: if time.time() > deadline: print("WARNING: timed out waiting for DUMP response") break line = ser.readline().decode(errors="replace").strip() if not line: continue if line.startswith("SIZE"): fw_size = int(line.split()[1]) elif line == "END": break elif line == "NO FILE": print("ERROR: firmware says file does not exist!") break else: for tok in line.split(): try: fw_bytes.append(int(tok, 16)) except ValueError: pass if fw_size is not None: print(f" Firmware file size: {fw_size} bytes") if fw_size == 0: print(" ERROR: file is empty — write failed (LittleFS full?)") elif fw_size < total: print(f" WARNING: only {fw_size}/{total} bytes written (LittleFS full?)") else: print(f" Size OK: {fw_size}/{total}") # 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] != wav[i]) if mismatches == 0: 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 wav[:16])}") print(f" Got: {' '.join(f'{b:02X}' for b in fw_bytes[:16])}") def main(): if len(sys.argv) != 4: print(__doc__) sys.exit(1) port = sys.argv[1] track_num = int(sys.argv[2]) path = sys.argv[3] if not 0 <= track_num < 32: sys.exit("track_num must be 0–31") wav = load_wav(path) if not wav: sys.exit("File is empty after conversion") upload(port, track_num, wav) if __name__ == "__main__": main()