From 24de5d8d397b2a7e09d1d28f4a2137a9e1c4e0d6 Mon Sep 17 00:00:00 2001 From: zyphlar Date: Sat, 4 Jul 2026 03:28:14 -0700 Subject: [PATCH] add app, fix ble, use flash --- app/.claude/launch.json | 11 + app/README.md | 77 ++++++ app/app.js | 232 ++++++++++++++++ app/ble.js | 251 ++++++++++++++++++ app/icon.svg | 18 ++ app/index.html | 81 ++++++ app/manifest.webmanifest | 14 + app/styles.css | 89 +++++++ app/sw.js | 31 +++ app/wav.js | 82 ++++++ baby_mobile_v2/baby_mobile_v2.ino | 193 ++++++++++---- flash_upload.py | 198 -------------- .../upload_track.py => upload_track.py | 0 13 files changed, 1028 insertions(+), 249 deletions(-) create mode 100644 app/.claude/launch.json create mode 100644 app/README.md create mode 100644 app/app.js create mode 100644 app/ble.js create mode 100644 app/icon.svg create mode 100644 app/index.html create mode 100644 app/manifest.webmanifest create mode 100644 app/styles.css create mode 100644 app/sw.js create mode 100644 app/wav.js delete mode 100644 flash_upload.py rename baby_mobile_v2/upload_track.py => upload_track.py (100%) diff --git a/app/.claude/launch.json b/app/.claude/launch.json new file mode 100644 index 0000000..e48654f --- /dev/null +++ b/app/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "babymobile-static", + "runtimeExecutable": "python", + "runtimeArgs": ["-m", "http.server", "8012"], + "port": 8012 + } + ] +} diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..85e1177 --- /dev/null +++ b/app/README.md @@ -0,0 +1,77 @@ +# BabyMobile Audio Manager (PWA) + +A dependency-free, **client-side** web app that scans for the BabyMobile XIAO +(nRF52840) over Bluetooth LE and manages the audio files on it. No backend, no +build step, nothing leaves your device — all audio conversion happens in the +browser with the Web Audio API. + +Pairs with the firmware in `../baby_mobile_v2/baby_mobile_v2.ino`. + +## What it does + +- **Scan & connect** to the `BabyMobile` BLE peripheral. +- **Convert** any browser-decodable file (WAV/MP3/M4A/OGG/FLAC…) to a conformant + mono PCM WAV — **8-bit / 8 kHz by default**, with 8/16-bit and 8/16/32 kHz + selectable. The full WAV (44-byte header + data) is streamed intact; the + firmware self-configures bit depth and sample rate from the header. +- **Upload** to any of the 32 track slots, with a live progress bar driven by the + device's status-notification byte count. +- **Play / stop** any slot. +- **Remember** what you uploaded per browser (localStorage), since the firmware + exposes no track-listing command over BLE. + +## BLE protocol used + +| | UUID | Use | +|---|---|---| +| Service | `12340001-…` | advertised | +| Cmd (write) | `12340002-…` | `0x02 ` start upload · `0x03` finish · `0x04 ` play · `0x05` stop · `0x06` list · `0x07 ` delete | +| Data (write-without-response) | `12340003-…` | raw WAV bytes, chunked to 180 B | +| Status (notify) | `12340004-…` | tagged by byte 0: `0x00…` upload byte count (uint32 BE) · `0x80\|idx, bits, kHz, secs` list entry · `0xFF, n` list end · `0xD0, idx, ok` delete reply | + +## Running it + +Web Bluetooth requires a **secure context**: `https://` or `http://localhost`. + +```sh +# from this app/ directory — any static server works +python -m http.server 8000 +# then open http://localhost:8000 +``` + +Deploy the folder as-is to any static host (GitHub Pages, Netlify, Cloudflare +Pages…) to install it as a PWA from a phone. It's a complete app shell with a +service worker, so it also launches offline once cached. + +## Platform support + +| Platform | BLE works? | How | +|---|---|---| +| Android | ✅ | Chrome or Edge; "Add to Home screen" installs the PWA | +| Windows / macOS / Linux | ✅ | Chrome or Edge | +| **iPhone / iPad** | ⚠️ via Bluefy | iOS Safari/WebKit has **no** Web Bluetooth | + +### iOS setup (no App Store) + +Apple doesn't ship Web Bluetooth, so a plain Safari PWA can't reach BLE. Use the +free **[Bluefy – Web BLE Browser](https://apps.apple.com/app/bluefy-web-ble-browser/id1492822055)**: +open this app's URL inside Bluefy and everything works. (Bluefy itself is the +only App Store install needed — the app stays a web app.) + +If you later want a true installable iOS app without Bluefy, wrap this same +folder in [Capacitor](https://capacitorjs.com/) with `@capacitor-community/bluetooth-le` +and reuse `ble.js`'s protocol constants. + +## Notes & limits + +- **Flash size:** the firmware's POC stores tracks in the nRF52840's internal + LittleFS, which is small. 8-bit/8 kHz ≈ 8 KB per second of audio — keep clips + short. If the device acknowledges fewer bytes than sent, the app warns you + (flash likely full). +- **Listing & delete are live:** on connect (and after every upload/delete) the + app sends `0x06` to read the device's real track table, and `0x07` deletes a + slot on the device. Track *titles* aren't stored on the device, so they're + remembered locally and shown next to the live entries; an uploaded slot with no + local name just shows "Track N". +- **MTU:** data is chunked to 180 bytes to stay within a modest negotiated ATT + MTU (firmware char max is 240). diff --git a/app/app.js b/app/app.js new file mode 100644 index 0000000..deb009b --- /dev/null +++ b/app/app.js @@ -0,0 +1,232 @@ +// app.js — UI glue for the BabyMobile manager PWA. +import { BabyMobile, isSupported, MAX_TRACKS } from './ble.js'; +import { fileToWav, RATES, BITS } from './wav.js'; + +const $ = (id) => document.getElementById(id); +const dev = new BabyMobile(); + +// Slot metadata is kept locally because the firmware can't enumerate tracks +// over BLE. This remembers what *we* uploaded so the UI can show slot contents. +const SLOTS_KEY = 'babymobile.slots.v1'; +let slots = loadSlots(); + +let pendingWav = null; // { wav, durationSec, rate, bits, name } +let deviceTracks = null; // last result of dev.listTracks(), or null + +function loadSlots() { + try { return JSON.parse(localStorage.getItem(SLOTS_KEY)) || {}; } + catch { return {}; } +} +function saveSlots() { + localStorage.setItem(SLOTS_KEY, JSON.stringify(slots)); +} + +function log(msg) { + const el = $('log'); + const time = new Date().toLocaleTimeString(); + el.textContent += `[${time}] ${msg}\n`; + el.scrollTop = el.scrollHeight; +} + +function fmtBytes(n) { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(2)} MB`; +} + +// ---- connection state ---- +dev.addEventListener('log', (e) => log(e.detail)); +dev.addEventListener('connected', (e) => { setConnected(true, e.detail.name); refresh(); }); +dev.addEventListener('disconnected', () => { setConnected(false); deviceTracks = null; renderSlots(); }); + +function setConnected(on, name) { + $('status-dot').className = on ? 'dot on' : 'dot'; + $('status-text').textContent = on ? `Connected${name ? ' · ' + name : ''}` : 'Not connected'; + $('btn-connect').textContent = on ? 'Disconnect' : 'Scan & connect'; + document.querySelectorAll('[data-needs-conn]').forEach((el) => { el.disabled = !on; }); +} + +$('btn-connect').addEventListener('click', async () => { + try { + if (dev.connected) await dev.disconnect(); + else await dev.connect(); + } catch (err) { + if (err && err.name === 'NotFoundError') log('Scan cancelled.'); + else log('Error: ' + (err && err.message ? err.message : err)); + } +}); + +// ---- file selection & conversion ---- +const fileInput = $('file'); +$('drop').addEventListener('click', () => fileInput.click()); +$('drop').addEventListener('dragover', (e) => { e.preventDefault(); $('drop').classList.add('hover'); }); +$('drop').addEventListener('dragleave', () => $('drop').classList.remove('hover')); +$('drop').addEventListener('drop', (e) => { + e.preventDefault(); $('drop').classList.remove('hover'); + if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); +}); +fileInput.addEventListener('change', () => { if (fileInput.files.length) handleFile(fileInput.files[0]); }); + +async function handleFile(file) { + const rate = parseInt($('rate').value, 10); + const bits = parseInt($('bits').value, 10); + $('convert-info').textContent = `Converting "${file.name}"…`; + pendingWav = null; + $('btn-upload').disabled = true; + try { + const res = await fileToWav(file, { rate, bits }); + pendingWav = { ...res, name: file.name }; + const sec = res.durationSec.toFixed(1); + $('convert-info').innerHTML = + `${file.name} → ${bits}-bit ${rate / 1000} kHz mono
` + + `${sec}s · ${fmtBytes(res.wav.byteLength)} (incl. 44-byte WAV header)`; + $('btn-upload').disabled = !dev.connected; + } catch (err) { + $('convert-info').textContent = 'Could not decode this file: ' + (err.message || err) + + '. Try a WAV/MP3/M4A the browser can decode.'; + } +} + +// Re-convert if the format options change while a file is staged. +['rate', 'bits'].forEach((id) => $(id).addEventListener('change', () => { + if (fileInput.files.length) handleFile(fileInput.files[0]); +})); + +// ---- upload ---- +$('btn-upload').addEventListener('click', async () => { + if (!pendingWav) return; + const track = parseInt($('upload-slot').value, 10); + $('btn-upload').disabled = true; + const bar = $('upload-bar'); + bar.style.width = '0%'; + bar.parentElement.classList.add('active'); + try { + const { ok, acked } = await dev.upload(track, pendingWav.wav, (sent, total) => { + bar.style.width = `${Math.round((sent / total) * 100)}%`; + }); + if (ok) { + slots[track] = { + name: pendingWav.name, bits: pendingWav.bits, rate: pendingWav.rate, + durationSec: pendingWav.durationSec, bytes: pendingWav.wav.byteLength, at: Date.now(), + }; + saveSlots(); + await refresh(); // pull the authoritative list back from the device + } + } catch (err) { + log('Upload failed: ' + (err.message || err)); + } finally { + bar.parentElement.classList.remove('active'); + $('btn-upload').disabled = !dev.connected; + } +}); + +// ---- playback ---- +$('btn-play').addEventListener('click', () => dev.play(parseInt($('play-slot').value, 10)).catch((e) => log('' + e))); +$('btn-stop').addEventListener('click', () => dev.stop().catch((e) => log('' + e))); + +// ---- track list ---- +$('btn-refresh').addEventListener('click', () => refresh()); + +async function refresh() { + if (!dev.connected) return; + try { + deviceTracks = await dev.listTracks(); + log(`Device reports ${deviceTracks.length} track(s).`); + renderSlots(); + } catch (err) { + log('List failed: ' + (err.message || err)); + } +} + +async function onDelete(idx, onDevice) { + if (onDevice) { + try { + const r = await dev.deleteTrack(idx); + if (r.success) { + delete slots[idx]; saveSlots(); + log(`🗑 Deleted slot ${idx}.`); + await refresh(); + } else { + log(`Device could not delete slot ${idx}.`); + } + } catch (err) { + log('Delete failed: ' + (err.message || err)); + } + } else { + delete slots[idx]; saveSlots(); renderSlots(); + } +} + +function renderSlots() { + const list = $('slots'); + list.innerHTML = ''; + const onDevice = dev.connected && Array.isArray(deviceTracks); + + // When connected, the device list is the source of truth (annotated with local + // names). Otherwise fall back to the local record of past uploads. + const rows = onDevice + ? deviceTracks.map((t) => ({ ...t, name: slots[t.idx]?.name })) + : Object.keys(slots).map(Number).sort((a, b) => a - b).map((k) => ({ + idx: k, bits: slots[k].bits, rate: slots[k].rate, + durationSec: slots[k].durationSec, name: slots[k].name, + })); + + if (!rows.length) { + list.innerHTML = `
  • ${onDevice ? 'No tracks on the device yet.' : 'Connect to list device tracks.'}
  • `; + return; + } + + for (const t of rows) { + const li = document.createElement('li'); + const title = t.name || `Track ${t.idx}`; + li.innerHTML = + `
    ${t.idx}` + + `
    ${title}${t.bits}-bit ${t.rate / 1000}kHz · ${(+t.durationSec).toFixed(1)}s
    `; + const actions = document.createElement('div'); + actions.className = 'slot-actions'; + const playBtn = btn('▶', 'Play', () => dev.play(t.idx).catch((e) => log('' + e))); + playBtn.dataset.needsConn = ''; + playBtn.disabled = !dev.connected; + const delBtn = btn('🗑', onDevice ? 'Delete from device' : 'Forget (local only)', + () => onDelete(t.idx, onDevice)); + actions.append(playBtn, delBtn); + li.append(actions); + list.append(li); + } +} +function btn(label, title, fn) { + const b = document.createElement('button'); + b.textContent = label; b.title = title; b.className = 'mini'; + b.addEventListener('click', fn); + return b; +} + +// ---- populate selectors ---- +function fillSelect(sel, values, fmt, selected) { + sel.innerHTML = ''; + for (const v of values) { + const o = document.createElement('option'); + o.value = v; o.textContent = fmt(v); + if (v === selected) o.selected = true; + sel.append(o); + } +} +fillSelect($('rate'), RATES, (v) => `${v / 1000} kHz`, 8000); +fillSelect($('bits'), BITS, (v) => `${v}-bit`, 8); +for (const sel of [$('upload-slot'), $('play-slot')]) { + fillSelect(sel, Array.from({ length: MAX_TRACKS }, (_, i) => i), (v) => `Slot ${v}`, 0); +} + +// ---- support banner ---- +if (!isSupported()) { + $('unsupported').hidden = false; + $('btn-connect').disabled = true; +} + +renderSlots(); +setConnected(false); + +// ---- PWA service worker ---- +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('./sw.js').catch(() => {}); +} diff --git a/app/ble.js b/app/ble.js new file mode 100644 index 0000000..1feb3c3 --- /dev/null +++ b/app/ble.js @@ -0,0 +1,251 @@ +// ble.js — Web Bluetooth client for the BabyMobile XIAO firmware. +// +// Protocol (see baby_mobile_v2.ino): +// Service 12340001-… +// Cmd char 12340002 (write): 0x02 start upload to slot +// 0x03 finish upload + reload tracks +// 0x04 play slot +// 0x05 stop playback +// Data char 12340003 (write-w/o-resp): raw WAV bytes (header included) +// Stat char 12340004 (read/notify): uint32 BE = cumulative bytes written +// +// The firmware stores the WAV intact and parses bit-depth / sample-rate from the +// header at load time, so we just stream a conformant WAV file as-is. + +export const SVC_UUID = '12340001-0000-1000-8000-00805f9b34fb'; +export const CMD_UUID = '12340002-0000-1000-8000-00805f9b34fb'; +export const DATA_UUID = '12340003-0000-1000-8000-00805f9b34fb'; +export const STAT_UUID = '12340004-0000-1000-8000-00805f9b34fb'; + +export const CMD_UPLOAD_START = 0x02; +export const CMD_UPLOAD_END = 0x03; +export const CMD_PLAY = 0x04; +export const CMD_STOP = 0x05; +export const CMD_LIST = 0x06; +export const CMD_DELETE = 0x07; + +// Status-notification tags in byte 0 (see firmware): +// 0x00 → upload progress: whole packet is uint32 BE byte count (files <16 MB) +// 0x80|idx → list entry: [0x80|idx, bits, rate_kHz, duration_s] +// 0xFF → list end: [0xFF, num_tracks, 0, 0] +// 0xD0 → delete reply: [0xD0, idx, success, 0] +const TAG_LIST_END = 0xff; +const TAG_DELETE = 0xd0; + +export const MAX_TRACKS = 32; + +// audioCmd.setMaxLen(240) / audioData.setMaxLen(240) on the firmware. We chunk the +// data characteristic conservatively so it fits even a modest negotiated ATT MTU. +const DATA_CHUNK = 180; + +export function isSupported() { + return typeof navigator !== 'undefined' && !!navigator.bluetooth; +} + +export class BabyMobile extends EventTarget { + constructor() { + super(); + this.device = null; + this.server = null; + this.cmd = null; + this.data = null; + this.stat = null; + this._statBytes = 0; + this._onStat = this._onStat.bind(this); + this._onDisconnect = this._onDisconnect.bind(this); + } + + get connected() { + return !!(this.device && this.device.gatt && this.device.gatt.connected); + } + + _log(msg) { + this.dispatchEvent(new CustomEvent('log', { detail: msg })); + } + + _emit(type, detail) { + this.dispatchEvent(new CustomEvent(type, { detail })); + } + + async connect() { + if (!isSupported()) { + throw new Error('Web Bluetooth is not available in this browser. On iOS use the Bluefy browser; on desktop/Android use Chrome or Edge.'); + } + this._log('Requesting device…'); + this.device = await navigator.bluetooth.requestDevice({ + filters: [{ namePrefix: 'BabyMobile' }, { services: [SVC_UUID] }], + optionalServices: [SVC_UUID], + }); + this.device.addEventListener('gattserverdisconnected', this._onDisconnect); + + this._log(`Connecting to ${this.device.name || 'device'}…`); + this.server = await this.device.gatt.connect(); + const svc = await this.server.getPrimaryService(SVC_UUID); + this.cmd = await svc.getCharacteristic(CMD_UUID); + this.data = await svc.getCharacteristic(DATA_UUID); + this.stat = await svc.getCharacteristic(STAT_UUID); + + await this.stat.startNotifications(); + this.stat.addEventListener('characteristicvaluechanged', this._onStat); + + this._log('Connected.'); + this._emit('connected', { name: this.device.name }); + return this.device.name; + } + + async disconnect() { + if (this.device && this.device.gatt && this.device.gatt.connected) { + this.device.gatt.disconnect(); + } + } + + _onDisconnect() { + this._log('Disconnected.'); + this.server = this.cmd = this.data = this.stat = null; + this._emit('disconnected', {}); + } + + _onStat(ev) { + const dv = ev.target.value; + if (dv.byteLength < 1) return; + const tag = dv.getUint8(0); + + if (tag === TAG_LIST_END && dv.byteLength >= 2) { + this._emit('list-end', { numTracks: dv.getUint8(1) }); + } else if (tag === TAG_DELETE && dv.byteLength >= 3) { + this._emit('deleted', { idx: dv.getUint8(1), success: dv.getUint8(2) === 1 }); + } else if ((tag & 0x80) && (tag & 0x7f) < MAX_TRACKS && dv.byteLength >= 4) { + this._emit('list-entry', { + idx: tag & 0x7f, + bits: dv.getUint8(1), + rate: dv.getUint8(2) * 1000, + durationSec: dv.getUint8(3), + }); + } else if (dv.byteLength >= 4) { + this._statBytes = dv.getUint32(0, false); // big-endian byte count + this._emit('progress', { bytes: this._statBytes }); + } + } + + _requireConnected() { + if (!this.connected) throw new Error('Not connected.'); + } + + async _writeCmd(bytes) { + this._requireConnected(); + await this.cmd.writeValue(Uint8Array.from(bytes)); + } + + async play(track) { + await this._writeCmd([CMD_PLAY, track & 0xff]); + this._log(`▶ Play slot ${track}`); + } + + async stop() { + await this._writeCmd([CMD_STOP]); + this._log('⏹ Stop'); + } + + // Returns [{ idx, bits, rate, durationSec }], sorted by slot. + async listTracks(timeoutMs = 4000) { + this._requireConnected(); + return new Promise((resolve, reject) => { + const entries = []; + const onEntry = (e) => entries.push(e.detail); + const finish = () => { + cleanup(); + resolve(entries.sort((a, b) => a.idx - b.idx)); + }; + const cleanup = () => { + clearTimeout(timer); + this.removeEventListener('list-entry', onEntry); + this.removeEventListener('list-end', finish); + }; + const timer = setTimeout(finish, timeoutMs); // resolve with whatever arrived + this.addEventListener('list-entry', onEntry); + this.addEventListener('list-end', finish); + this._writeCmd([CMD_LIST]).catch((err) => { cleanup(); reject(err); }); + }); + } + + // Resolves { idx, success }. + async deleteTrack(track, timeoutMs = 4000) { + this._requireConnected(); + return new Promise((resolve, reject) => { + const onDel = (e) => { + if (e.detail.idx !== (track & 0xff)) return; // ignore replies for other slots + cleanup(); + resolve(e.detail); + }; + const cleanup = () => { clearTimeout(timer); this.removeEventListener('deleted', onDel); }; + const timer = setTimeout(() => { cleanup(); reject(new Error('Delete timed out')); }, timeoutMs); + this.addEventListener('deleted', onDel); + this._writeCmd([CMD_DELETE, track & 0xff]).catch((err) => { cleanup(); reject(err); }); + }); + } + + // Stream a complete WAV file to a slot. `onProgress(sent, total)` is called as + // the firmware acknowledges bytes via the status characteristic. + async upload(track, wavBytes, onProgress) { + this._requireConnected(); + const total = wavBytes.byteLength; + this._statBytes = 0; + let acked = 0; + + const progressHandler = (ev) => { + acked = ev.detail.bytes; + if (onProgress) onProgress(Math.min(acked, total), total); + }; + this.addEventListener('progress', progressHandler); + + try { + this._log(`Starting upload of ${total} bytes to slot ${track}…`); + await this._writeCmd([CMD_UPLOAD_START, track & 0xff]); + + // writeWithoutResponse: await each chunk so the browser's queue provides + // back-pressure. The firmware writes each packet to LittleFS synchronously. + for (let off = 0; off < total; off += DATA_CHUNK) { + const chunk = wavBytes.subarray(off, Math.min(off + DATA_CHUNK, total)); + await this.data.writeValueWithoutResponse(chunk); + if (onProgress) onProgress(Math.min(off + chunk.length, total), total); + } + + // Wait until the device's acknowledged byte count catches up (or times out). + await this._waitForAck(total, 8000); + + await this._writeCmd([CMD_UPLOAD_END]); + const ok = acked >= total; + if (ok) { + this._log(`✅ Upload complete: ${acked}/${total} bytes acknowledged.`); + } else { + this._log(`⚠ Upload finished but device acknowledged only ${acked}/${total} bytes (flash full?).`); + } + this._emit('uploaded', { track, total, acked, ok }); + return { total, acked, ok }; + } finally { + this.removeEventListener('progress', progressHandler); + } + } + + _waitForAck(target, timeoutMs) { + return new Promise((resolve) => { + if (this._statBytes >= target) return resolve(); + const deadline = Date.now() + timeoutMs; + const tick = () => { + if (this._statBytes >= target || Date.now() > deadline || !this.connected) { + this.removeEventListener('progress', tick); + resolve(); + } + }; + this.addEventListener('progress', tick); + // Fallback poll in case no further notifications arrive. + const poll = setInterval(() => { + if (this._statBytes >= target || Date.now() > deadline || !this.connected) { + clearInterval(poll); + this.removeEventListener('progress', tick); + resolve(); + } + }, 200); + }); + } +} diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..189ef4e --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..3a7df39 --- /dev/null +++ b/app/index.html @@ -0,0 +1,81 @@ + + + + + + + BabyMobile Audio Manager + + + + + + +
    +
    + +

    BabyMobile

    +
    +
    + + Not connected + +
    +
    + + + +
    +
    +

    1 · Add audio

    +
    +

    Tap or drop an audio file

    + WAV, MP3, M4A, OGG… — converted in your browser + +
    +
    + + +
    +

    No file selected.

    +
    + + +
    +
    +
    + +
    +

    2 · Play

    +
    + + + +
    +
    + +
    +
    +

    Tracks on device

    + +
    +

    Live from the device. Titles are remembered locally — the device stores audio, not names.

    +
      +
      + +
      +

      Log

      +
      
      +    
      +
      + + + + + + diff --git a/app/manifest.webmanifest b/app/manifest.webmanifest new file mode 100644 index 0000000..6c340f8 --- /dev/null +++ b/app/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "BabyMobile Audio Manager", + "short_name": "BabyMobile", + "description": "Scan, upload and play audio on the BabyMobile XIAO over Bluetooth LE.", + "start_url": "./index.html", + "scope": "./", + "display": "standalone", + "orientation": "portrait", + "background_color": "#140d2b", + "theme_color": "#1f1147", + "icons": [ + { "src": "./icon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any maskable" } + ] +} diff --git a/app/styles.css b/app/styles.css new file mode 100644 index 0000000..a341ed5 --- /dev/null +++ b/app/styles.css @@ -0,0 +1,89 @@ +:root { + --bg: #140d2b; + --bg2: #1f1147; + --card: #271a52; + --accent: #b794ff; + --accent2: #7c4dff; + --text: #ece8ff; + --muted: #a99fd0; + --ok: #4ade80; + --line: #3a2a6b; +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + background: linear-gradient(160deg, var(--bg), var(--bg2)); + color: var(--text); + min-height: 100vh; + -webkit-tap-highlight-color: transparent; +} +header { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; padding: 14px 16px calc(14px + env(safe-area-inset-top)); + position: sticky; top: 0; background: rgba(20,13,43,.85); + backdrop-filter: blur(8px); border-bottom: 1px solid var(--line); z-index: 5; +} +.brand { display: flex; align-items: center; gap: 10px; } +h1 { font-size: 1.1rem; margin: 0; letter-spacing: .5px; } +h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: 1px; color: var(--accent); margin: 0 0 12px; } +.card-head { display: flex; align-items: center; justify-content: space-between; } +.card-head h2 { margin-bottom: 0; } +.conn { display: flex; align-items: center; gap: 8px; font-size: .85rem; } +.dot { width: 10px; height: 10px; border-radius: 50%; background: #6b6b6b; box-shadow: 0 0 0 0 rgba(74,222,128,.6); } +.dot.on { background: var(--ok); animation: pulse 2s infinite; } +@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(74,222,128,.5); } 70% { box-shadow: 0 0 0 8px rgba(74,222,128,0); } } + +main { max-width: 640px; margin: 0 auto; padding: 16px; display: grid; gap: 16px; } +.card { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 18px; } + +button { + font: inherit; cursor: pointer; border: 1px solid var(--line); + background: #34246b; color: var(--text); padding: 10px 16px; border-radius: 10px; + transition: filter .15s, transform .05s; +} +button:hover:not(:disabled) { filter: brightness(1.15); } +button:active:not(:disabled) { transform: translateY(1px); } +button:disabled { opacity: .4; cursor: not-allowed; } +button.primary { background: linear-gradient(135deg, var(--accent2), var(--accent)); border: none; color: #1a0f3a; font-weight: 600; } +button.mini { padding: 6px 10px; border-radius: 8px; } + +select { + font: inherit; background: #1c123e; color: var(--text); + border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; +} +label { display: inline-flex; align-items: center; gap: 8px; font-size: .9rem; color: var(--muted); } + +.drop { + border: 2px dashed var(--line); border-radius: 14px; padding: 26px 16px; + text-align: center; cursor: pointer; transition: border-color .15s, background .15s; +} +.drop:hover, .drop.hover { border-color: var(--accent); background: rgba(124,77,255,.08); } +.drop p { margin: 0 0 4px; font-weight: 600; } +.drop small { color: var(--muted); } + +.opts { display: flex; gap: 16px; margin: 14px 0; flex-wrap: wrap; } +.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 12px; } +.info { font-size: .9rem; color: var(--muted); min-height: 1.2em; margin: 12px 0 0; } +.hint { font-size: .8rem; color: var(--muted); margin: -6px 0 12px; } + +.progress { height: 8px; background: #1c123e; border-radius: 6px; margin-top: 14px; overflow: hidden; } +.progress.active .bar { transition: width .2s; } +.bar { height: 100%; width: 0; background: linear-gradient(90deg, var(--accent2), var(--accent)); } + +.slots { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.slots li { display: flex; align-items: center; justify-content: space-between; gap: 10px; + background: #1c123e; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; } +.slots li.empty { color: var(--muted); justify-content: center; font-size: .9rem; } +.slot-meta { display: flex; align-items: center; gap: 12px; min-width: 0; } +.slot-num { background: var(--accent2); color: #1a0f3a; font-weight: 700; border-radius: 8px; + width: 30px; height: 30px; display: grid; place-items: center; flex: none; } +.slot-meta b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 46vw; } +.slot-meta small { color: var(--muted); } +.slot-actions { display: flex; gap: 6px; flex: none; } + +.banner { background: #5a2a2a; color: #ffd9d9; padding: 10px 16px; text-align: center; font-size: .9rem; } +.log { background: #0d0822; border: 1px solid var(--line); border-radius: 10px; padding: 12px; + font-size: .78rem; color: var(--muted); max-height: 200px; overflow: auto; white-space: pre-wrap; margin: 0; } +footer { text-align: center; color: var(--muted); font-size: .78rem; padding: 8px 16px calc(24px + env(safe-area-inset-bottom)); } +footer a { color: var(--accent); } diff --git a/app/sw.js b/app/sw.js new file mode 100644 index 0000000..037d89d --- /dev/null +++ b/app/sw.js @@ -0,0 +1,31 @@ +// sw.js — minimal offline cache so the PWA launches without a network. +const CACHE = 'babymobile-v2'; +const ASSETS = [ + './', './index.html', './styles.css', + './app.js', './ble.js', './wav.js', + './manifest.webmanifest', './icon.svg', +]; + +self.addEventListener('install', (e) => { + e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting())); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +// Cache-first for app shell; network passthrough otherwise. +self.addEventListener('fetch', (e) => { + const url = new URL(e.request.url); + if (e.request.method !== 'GET' || url.origin !== location.origin) return; + e.respondWith( + caches.match(e.request).then((hit) => hit || fetch(e.request).then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(e.request, copy)).catch(() => {}); + return res; + }).catch(() => hit)) + ); +}); diff --git a/app/wav.js b/app/wav.js new file mode 100644 index 0000000..440935d --- /dev/null +++ b/app/wav.js @@ -0,0 +1,82 @@ +// wav.js — decode any browser-supported audio file and re-encode it as a +// conformant mono PCM WAV the firmware can parse: 8 or 16-bit, 8/16/32 kHz. + +export const RATES = [8000, 16000, 32000]; +export const BITS = [8, 16]; + +// Decode -> resample to `rate` mono -> Float32 samples in [-1, 1]. +async function decodeMono(arrayBuffer, rate) { + const AC = window.AudioContext || window.webkitAudioContext; + const tmp = new AC(); + let decoded; + try { + decoded = await tmp.decodeAudioData(arrayBuffer.slice(0)); + } finally { + tmp.close(); + } + + const frames = Math.max(1, Math.ceil(decoded.duration * rate)); + const OAC = window.OfflineAudioContext || window.webkitOfflineAudioContext; + // 1-channel destination auto-downmixes any number of source channels to mono. + const off = new OAC(1, frames, rate); + const src = off.createBufferSource(); + src.buffer = decoded; + src.connect(off.destination); + src.start(); + const rendered = await off.startRendering(); + return { samples: rendered.getChannelData(0), rate, srcRate: decoded.sampleRate }; +} + +function floatToPcm(samples, bits) { + const n = samples.length; + if (bits === 16) { + const out = new Uint8Array(n * 2); + const dv = new DataView(out.buffer); + for (let i = 0; i < n; i++) { + let s = Math.max(-1, Math.min(1, samples[i])); + dv.setInt16(i * 2, Math.round(s * 32767), true); // signed LE + } + return out; + } + // 8-bit unsigned PCM, center 128 + const out = new Uint8Array(n); + for (let i = 0; i < n; i++) { + let s = Math.max(-1, Math.min(1, samples[i])); + out[i] = Math.max(0, Math.min(255, Math.round(s * 127) + 128)); + } + return out; +} + +function buildWav(pcm, rate, bits) { + const blockAlign = bits >> 3; // mono + const byteRate = rate * blockAlign; + const buf = new ArrayBuffer(44 + pcm.byteLength); + const dv = new DataView(buf); + const u8 = new Uint8Array(buf); + const ws = (off, str) => { for (let i = 0; i < str.length; i++) dv.setUint8(off + i, str.charCodeAt(i)); }; + + ws(0, 'RIFF'); + dv.setUint32(4, 36 + pcm.byteLength, true); + ws(8, 'WAVE'); + ws(12, 'fmt '); + dv.setUint32(16, 16, true); // fmt chunk size + dv.setUint16(20, 1, true); // PCM + dv.setUint16(22, 1, true); // mono + dv.setUint32(24, rate, true); + dv.setUint32(28, byteRate, true); + dv.setUint16(32, blockAlign, true); + dv.setUint16(34, bits, true); + ws(36, 'data'); + dv.setUint32(40, pcm.byteLength, true); + u8.set(pcm, 44); + return u8; +} + +// Returns { wav: Uint8Array, durationSec, rate, bits, srcRate }. +export async function fileToWav(file, { rate = 8000, bits = 8 } = {}) { + const arrayBuffer = await file.arrayBuffer(); + const { samples, srcRate } = await decodeMono(arrayBuffer, rate); + const pcm = floatToPcm(samples, bits); + const wav = buildWav(pcm, rate, bits); + return { wav, durationSec: samples.length / rate, rate, bits, srcRate }; +} diff --git a/baby_mobile_v2/baby_mobile_v2.ino b/baby_mobile_v2/baby_mobile_v2.ino index d91e2b3..adc5322 100644 --- a/baby_mobile_v2/baby_mobile_v2.ino +++ b/baby_mobile_v2/baby_mobile_v2.ino @@ -29,9 +29,10 @@ * D5 (P0.05) - Button 6 * D6 (P1.11) - Button 7 * D7 (P1.12) - Button 8 - * D8 (P0.07) - SPI SCK → Flash pin 6 - * D9 (P0.06) - SPI MISO → Flash pin 2 - * D10 (P0.05) - SPI MOSI → Flash pin 5 + * D4 (P0.04) - Flash ~CS (PIN_FLASH_CS) + * D8 (P0.07) - SPI SCK → Flash CLK + * D9 (P0.06) - SPI MISO → Flash DO + * D10 (P0.05) - SPI MOSI → Flash DI * A0 (P0.02) - Audio PWM output → R+C LPF → PAM8302A * A1 (P0.03) - Amp ~SD (HIGH=on) * A2 (P0.28) - Motor PWM → MOSFET gate @@ -55,7 +56,7 @@ // dynamic bit-depth (8/16-bit) and sample-rate support. // Disable this define to revert to full SPI flash mode. // ============================================================ -#define POC_INTERNAL_FLASH +//#define POC_INTERNAL_FLASH #ifdef POC_INTERNAL_FLASH #include @@ -407,20 +408,37 @@ void loadTrackTable() { ((uint32_t)entry[2] << 8) | entry[3]; g_trackLen[i] = ((uint32_t)entry[4] << 24) | ((uint32_t)entry[5] << 16) | ((uint32_t)entry[6] << 8) | entry[7]; - } + // Parse WAV header from flash to get format metadata + g_trackBits[i] = 8; + g_trackRate[i] = SAMPLE_RATE; + g_trackDataOff[i] = 0; + if (g_trackLen[i] >= 44) { + uint8_t hdr[44]; + flashReadBytes(g_trackStart[i], hdr, 44); + if (hdr[0]=='R' && hdr[1]=='I' && hdr[2]=='F' && hdr[3]=='F') { + uint16_t bits = (uint16_t)hdr[34] | ((uint16_t)hdr[35] << 8); + uint32_t rate = (uint32_t)hdr[24] | ((uint32_t)hdr[25] << 8) + | ((uint32_t)hdr[26] << 16) | ((uint32_t)hdr[27] << 24); + if ((bits == 8 || bits == 16) && rate > 0) { + g_trackBits[i] = (uint8_t)bits; + g_trackRate[i] = rate; + g_trackDataOff[i] = 44; + } + } + } + + Serial.print(" track"); Serial.print(i); + Serial.print(": "); Serial.print(g_trackBits[i]); Serial.print("bit "); + Serial.print(g_trackRate[i] / 1000); Serial.print("kHz "); + uint32_t audioBytes = g_trackLen[i] - g_trackDataOff[i]; + uint32_t bps = g_trackBits[i] / 8; + Serial.print((audioBytes / bps) / g_trackRate[i]); + Serial.println("s"); + } Serial.print("Loaded "); Serial.print(g_numTracks); Serial.println(" tracks from flash"); - for (uint8_t i = 0; i < g_numTracks; i++) { - Serial.print(" Track "); - Serial.print(i + 1); - Serial.print(": addr=0x"); - Serial.print(g_trackStart[i], HEX); - Serial.print(", "); - Serial.print(g_trackLen[i] / SAMPLE_RATE); - Serial.println("s"); - } #endif } @@ -449,8 +467,8 @@ void writeTrackTable() { e[5] = (g_trackLen[i] >> 16) & 0xFF; e[6] = (g_trackLen[i] >> 8) & 0xFF; e[7] = g_trackLen[i] & 0xFF; - e[8] = 8; // sample rate kHz - e[9] = 8; // bits + e[8] = (uint8_t)(g_trackRate[i] / 1000); + e[9] = g_trackBits[i]; e[10] = 0; e[11] = 0; } @@ -995,11 +1013,16 @@ uint8_t buttonRead() { // cooldown from expiring while the button is still held and re-triggering. void waitButtonRelease(uint8_t btn) { while (buttonRead() == btn) { - // Keep DMA buffers fed while waiting if (g_playing) { + // Keep DMA buffers fed while waiting for (uint8_t b = 0; b < 2; b++) { if (!g_bufReady[b]) audioFillBuf(b); } + // Stop amp as soon as track ends — don't wait for loop() to resume + if (g_trackDoneMs != 0 && millis() >= g_trackDoneMs) { + g_trackDoneMs = 0; + audioStop(); + } } } delay(20); // debounce after release @@ -1144,49 +1167,75 @@ void setup() { // ============================================================ // ============================================================ -// SERIAL UPLOAD STATE MACHINE (POC_INTERNAL_FLASH only) +// SERIAL UPLOAD STATE MACHINE +// Works in both POC_INTERNAL_FLASH and SPI flash modes. +// Protocol: UPLOAD (full WAV file, header included) +// DUMP DUMP +// p / l / u / d / r +// DELETE (POC only) +// FORMAT (POC only) // ============================================================ -#ifdef POC_INTERNAL_FLASH enum SerUploadState { SER_IDLE, SER_RECEIVING }; -static SerUploadState g_serState = SER_IDLE; -static char g_serLineBuf[64] = {0}; -static uint8_t g_serLineLen = 0; -static uint8_t g_serTrack = 0; +static SerUploadState g_serState = SER_IDLE; +static char g_serLineBuf[64] = {0}; +static uint8_t g_serLineLen = 0; +static uint8_t g_serTrack = 0; static uint32_t g_serBytesExpected = 0; static uint32_t g_serBytesReceived = 0; + +#ifdef POC_INTERNAL_FLASH static File g_serFile(InternalFS); +#else +// SPI flash upload: tracks are packed sequentially starting at AUDIO_START_ADDR. +// Uploading track 0 resets the allocation pointer. +static uint32_t g_serFlashCurAddr = 0; // current write head +static uint32_t g_serFlashNextFree = AUDIO_START_ADDR; +static uint32_t g_serFlashErasedThru = 0; // highest erased byte address +#endif static void serUploadTick() { if (g_serState == SER_IDLE) { - // Accumulate characters until newline while (Serial.available()) { - // Serial.print("r"); char c = (char)Serial.read(); if (c == '\n' || c == '\r') { g_serLineBuf[g_serLineLen] = '\0'; if (g_serLineLen == 0) { g_serLineLen = 0; break; } - // Parse: UPLOAD + unsigned int utrk = 0, ulen = 0; if (sscanf(g_serLineBuf, "UPLOAD %u %u", &utrk, &ulen) == 2) { g_serTrack = (uint8_t)utrk; g_serBytesExpected = (uint32_t)ulen; g_serBytesReceived = 0; +#ifdef POC_INTERNAL_FLASH char fname[16]; pocFilename(g_serTrack, fname); if (g_serFile) g_serFile.close(); - // Remove first — FILE_O_WRITE has no truncate flag InternalFS.remove(fname); if (!g_serFile.open(fname, FILE_O_WRITE)) { - Serial.print("ERR cannot open "); - Serial.println(fname); + Serial.print("ERR cannot open "); Serial.println(fname); } else { g_serState = SER_RECEIVING; g_usbConnected = true; Serial.println("READY"); } +#else + // Track 0 resets flash allocation + if (g_serTrack == 0) g_serFlashNextFree = AUDIO_START_ADDR; + g_serFlashCurAddr = g_serFlashNextFree; + g_trackStart[g_serTrack] = g_serFlashCurAddr; + + // Erase first sector now; subsequent sectors erased lazily during receive + uint32_t firstSector = (g_serFlashCurAddr / FLASH_SECTOR) * FLASH_SECTOR; + flashEraseSector(firstSector); + g_serFlashErasedThru = firstSector + FLASH_SECTOR - 1; + + g_serState = SER_RECEIVING; + g_usbConnected = true; + Serial.println("READY"); +#endif } else if (sscanf(g_serLineBuf, "DUMP %u", &utrk) == 1) { - // Hex-dump first 256 bytes of a track file +#ifdef POC_INTERNAL_FLASH char fname[16]; pocFilename((uint8_t)utrk, fname); File df(InternalFS); @@ -1197,20 +1246,40 @@ static void serUploadTick() { uint32_t limit = min(fsz, (uint32_t)256); uint32_t off = 0; while (off < limit) { - int n = df.read(dbuf, min((uint32_t)sizeof(dbuf), limit - off)); - if (n <= 0) break; - for (int j = 0; j < n; j++) { + int rd = df.read(dbuf, min((uint32_t)sizeof(dbuf), limit - off)); + if (rd <= 0) break; + for (int j = 0; j < rd; j++) { if (dbuf[j] < 0x10) Serial.print("0"); Serial.print(dbuf[j], HEX); Serial.print(j % 16 == 15 || (off + j + 1) == limit ? "\n" : " "); } - off += n; + off += rd; } df.close(); Serial.println("END"); } else { Serial.println("NO FILE"); } +#else + if ((uint8_t)utrk < g_numTracks) { + Serial.print("SIZE "); Serial.println(g_trackLen[utrk]); + uint8_t dbuf[16]; + uint32_t limit = min(g_trackLen[utrk], (uint32_t)256); + for (uint32_t off = 0; off < limit; ) { + uint32_t rd = min((uint32_t)sizeof(dbuf), limit - off); + flashReadBytes(g_trackStart[utrk] + off, dbuf, rd); + for (uint32_t j = 0; j < rd; j++) { + if (dbuf[j] < 0x10) Serial.print("0"); + Serial.print(dbuf[j], HEX); + Serial.print(j % 16 == 15 || (off + j + 1) == limit ? "\n" : " "); + } + off += rd; + } + Serial.println("END"); + } else { + Serial.println("NO FILE"); + } +#endif } else if (g_serLineLen == 1 && (g_serLineBuf[0] == 'u' || g_serLineBuf[0] == 'd')) { if (g_serLineBuf[0] == 'u') g_audioGain++; else if (g_audioGain > 1) g_audioGain--; @@ -1226,6 +1295,7 @@ static void serUploadTick() { Serial.println("REBOOT"); delay(10); NVIC_SystemReset(); +#ifdef POC_INTERNAL_FLASH } else if (sscanf(g_serLineBuf, "DELETE %u", &utrk) == 1) { char fname[16]; pocFilename((uint8_t)utrk, fname); @@ -1241,32 +1311,28 @@ static void serUploadTick() { InternalFS.format(); g_numTracks = 0; Serial.println("FORMAT OK"); +#endif } else { Serial.print("ERR bad cmd: "); Serial.println(g_serLineBuf); } g_serLineLen = 0; - // Serial.print("0"); } else { if (g_serLineLen < (sizeof(g_serLineBuf) - 1)) { g_serLineBuf[g_serLineLen++] = c; } - // Serial.print(","); } } } else { // SER_RECEIVING - // Serial.print("e"); uint8_t chunk[64]; while (Serial.available() && g_serBytesReceived < g_serBytesExpected) { - // Serial.print(";"); int n = Serial.readBytes(chunk, min((int)sizeof(chunk), (int)(g_serBytesExpected - g_serBytesReceived))); if (n <= 0) break; +#ifdef POC_INTERNAL_FLASH int32_t wr = g_serFile.write(chunk, (uint16_t)n); if (wr != n) { - // Write failed (LittleFS full or error) — abort immediately. - // Do NOT loop printing errors; that fills USB CDC TX and hangs. g_serFile.close(); g_usbConnected = false; g_serState = SER_IDLE; @@ -1274,14 +1340,32 @@ static void serUploadTick() { Serial.println(g_serBytesReceived); break; } - g_serBytesReceived += n; +#else + // Lazily erase the next sector as we reach it + uint32_t chunkEnd = g_serFlashCurAddr + (uint32_t)n - 1; + if (chunkEnd > g_serFlashErasedThru) { + uint32_t nextSector = (g_serFlashErasedThru + 1) / FLASH_SECTOR * FLASH_SECTOR; + flashEraseSector(nextSector); + g_serFlashErasedThru = nextSector + FLASH_SECTOR - 1; + } + // Write to flash in page-aligned chunks + uint16_t written = 0; + while (written < (uint16_t)n) { + uint16_t pageOff = (uint16_t)((g_serFlashCurAddr + written) % FLASH_PAGE); + uint16_t pageChunk = min((uint16_t)(FLASH_PAGE - pageOff), (uint16_t)(n - written)); + flashPageProgram(g_serFlashCurAddr + written, chunk + written, pageChunk); + written += pageChunk; + } + g_serFlashCurAddr += (uint32_t)n; +#endif + g_serBytesReceived += (uint32_t)n; } - if (g_serState != SER_RECEIVING) return; // aborted in write-fail handler above + if (g_serState != SER_RECEIVING) return; if (g_serBytesReceived >= g_serBytesExpected) { +#ifdef POC_INTERNAL_FLASH g_serFile.close(); - // Reopen to read the actual flushed size from LittleFS char fname2[16]; pocFilename(g_serTrack, fname2); File tmp(InternalFS); uint32_t fsz = 0; @@ -1289,22 +1373,29 @@ static void serUploadTick() { g_usbConnected = false; g_serState = SER_IDLE; loadTrackTable(); - Serial.print("OK "); - Serial.print(fsz); - Serial.print("/"); - Serial.println(g_serBytesReceived); + Serial.print("OK "); Serial.print(fsz); + Serial.print("/"); Serial.println(g_serBytesReceived); +#else + // Advance free pointer to next sector boundary + g_serFlashNextFree = ((g_serFlashCurAddr + FLASH_SECTOR - 1) / FLASH_SECTOR) * FLASH_SECTOR; + g_trackLen[g_serTrack] = g_serBytesReceived; + if (g_serTrack >= g_numTracks) g_numTracks = g_serTrack + 1; + writeTrackTable(); + loadTrackTable(); + g_usbConnected = false; + g_serState = SER_IDLE; + Serial.print("OK "); Serial.print(g_serBytesReceived); + Serial.print("/"); Serial.println(g_serBytesExpected); +#endif } } } -#endif void loop() { if (g_playing) g_lastActivity = millis(); - // ---- Serial upload (POC mode) ---- -#ifdef POC_INTERNAL_FLASH + // ---- Serial upload ---- serUploadTick(); -#endif // ---- Refill audio buffers ---- if (g_playing) { diff --git a/flash_upload.py b/flash_upload.py deleted file mode 100644 index 2aa2723..0000000 --- a/flash_upload.py +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env python3 -""" -flash_upload.py — Upload audio to Baby Mobile v2 via serial. - -This is a fallback for uploading audio if USB mass storage isn't working. -Normally you'd just drag files onto the USB drive. - -Usage: - python3 flash_upload.py /dev/ttyACM0 song1.mp3 song2.wav song3.raw - -Audio files will be auto-converted to 8kHz/8-bit/unsigned PCM using ffmpeg. - -Track table format (v2): - [0] magic = 0xBB - [1] num_tracks - [2..3] reserved - [4..] 12-byte entries: start(4) + length(4) + rate_khz(1) + bits(1) + pad(2) -""" - -import serial -import struct -import sys -import time -import os -import subprocess -import shutil - -BAUD = 115200 -PAGE_SIZE = 256 -SECTOR_SIZE = 4096 -AUDIO_START = 0x1000 -MAX_TRACKS = 32 -FLASH_SIZE = 16 * 1024 * 1024 -TABLE_MAGIC = 0xBB -ENTRY_SIZE = 12 - - -def open_serial(port): - ser = serial.Serial(port, BAUD, timeout=2) - time.sleep(2) - ser.read(ser.in_waiting) - return ser - - -def convert_to_raw(input_path): - """Convert any audio file to 8kHz 8-bit unsigned PCM.""" - if input_path.endswith('.raw'): - return input_path - - raw_path = input_path + ".raw" - if not shutil.which('ffmpeg'): - print(f" ERROR: ffmpeg not found. Convert manually:") - print(f" ffmpeg -i {input_path} -ar 8000 -ac 1 -f u8 -acodec pcm_u8 {raw_path}") - sys.exit(1) - - print(f" Converting {os.path.basename(input_path)}...") - result = subprocess.run([ - 'ffmpeg', '-y', '-i', input_path, - '-ar', '8000', '-ac', '1', '-f', 'u8', '-acodec', 'pcm_u8', - raw_path - ], capture_output=True) - - if result.returncode != 0: - print(f" ERROR: ffmpeg failed: {result.stderr.decode()[-200:]}") - sys.exit(1) - - return raw_path - - -def build_track_table(tracks): - """Build v2 track table.""" - table = bytearray() - table.append(TABLE_MAGIC) - table.append(len(tracks)) - table.extend(b'\x00\x00') # reserved - - for start, length in tracks: - table.extend(struct.pack('>I', start)) - table.extend(struct.pack('>I', length)) - table.append(8) # sample rate kHz - table.append(8) # bits per sample - table.extend(b'\x00\x00') - - # Pad to page boundary - while len(table) % PAGE_SIZE != 0: - table.append(0xFF) - - return bytes(table) - - -def serial_cmd(ser, cmd): - """Send a single-char command and read response line.""" - ser.write(cmd.encode()) - return ser.readline().decode().strip() - - -def serial_write_page(ser, addr, data): - """Write up to 256 bytes via the serial 'W' command.""" - assert len(data) <= 256 - ser.write(b'W') - ser.write(struct.pack('>I', addr)[1:]) # 3-byte addr - ser.write(struct.pack('>H', len(data))) # 2-byte length - ser.write(data) - return ser.readline().decode().strip() - - -def serial_write_data(ser, start_addr, data, label=""): - """Write arbitrary-length data, page by page.""" - total = len(data) - written = 0 - addr = start_addr - - while written < total: - chunk = data[written:written + PAGE_SIZE] - if len(chunk) < PAGE_SIZE: - chunk = chunk + b'\x80' * (PAGE_SIZE - len(chunk)) - - serial_write_page(ser, addr, chunk) - written += PAGE_SIZE - addr += PAGE_SIZE - - pct = min(100, written * 100 // total) - print(f"\r Writing {label}: {pct}%", end="", flush=True) - - print() - - -def main(): - if len(sys.argv) < 3: - print("Usage: python3 flash_upload.py [...]") - print(" Supported: .raw .mp3 .wav .ogg .flac .aac") - sys.exit(1) - - port = sys.argv[1] - audio_files = sys.argv[2:] - - # Convert - print("=== Preparing audio ===") - raw_files = [] - for f in audio_files: - raw = convert_to_raw(f) - size = os.path.getsize(raw) - print(f" {os.path.basename(f)}: {size/1024:.0f} KB ({size/8000:.1f}s)") - raw_files.append(raw) - - total = sum(os.path.getsize(f) for f in raw_files) - print(f" Total: {total/1024:.0f} KB ({total/8000/60:.1f} min)") - - # Connect - print(f"\n=== Connecting to {port} ===") - ser = open_serial(port) - print(f" {serial_cmd(ser, 'I')}") - - # Erase - print("\n=== Erasing flash ===") - ser.write(b'E') - while True: - line = ser.readline().decode().strip() - if line: print(f" {line}") - if "Done" in line: break - - # Write tracks - print("\n=== Writing audio ===") - tracks = [] - addr = AUDIO_START - - for i, raw in enumerate(raw_files): - with open(raw, 'rb') as f: - data = f.read() - - tracks.append((addr, len(data))) - serial_write_data(ser, addr, data, label=f"Track {i+1}") - - addr += len(data) - # Align to sector - if addr % SECTOR_SIZE: - addr = ((addr // SECTOR_SIZE) + 1) * SECTOR_SIZE - - # Write track table - print("\n=== Writing track table ===") - table = build_track_table(tracks) - serial_write_data(ser, 0, table, label="Track table") - - # Summary - print(f"\n=== Done! {len(tracks)} tracks uploaded ===") - for i, (s, l) in enumerate(tracks): - print(f" Track {i+1}: 0x{s:06X}, {l/8000:.1f}s") - - # Cleanup - for raw, orig in zip(raw_files, audio_files): - if raw != orig and os.path.exists(raw): - os.remove(raw) - - ser.close() - - -if __name__ == "__main__": - main() diff --git a/baby_mobile_v2/upload_track.py b/upload_track.py similarity index 100% rename from baby_mobile_v2/upload_track.py rename to upload_track.py