add app, fix ble, use flash

This commit is contained in:
zyphlar
2026-07-04 03:28:14 -07:00
parent e6a2a65b82
commit 24de5d8d39
13 changed files with 1028 additions and 249 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "babymobile-static",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "8012"],
"port": 8012
}
]
}
+77
View File
@@ -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 <slot>` start upload · `0x03` finish · `0x04 <slot>` play · `0x05` stop · `0x06` list · `0x07 <slot>` 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).
+232
View File
@@ -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 =
`<b>${file.name}</b> → ${bits}-bit ${rate / 1000} kHz mono<br>` +
`${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 = `<li class="empty">${onDevice ? 'No tracks on the device yet.' : 'Connect to list device tracks.'}</li>`;
return;
}
for (const t of rows) {
const li = document.createElement('li');
const title = t.name || `Track ${t.idx}`;
li.innerHTML =
`<div class="slot-meta"><span class="slot-num">${t.idx}</span>` +
`<div><b>${title}</b><small>${t.bits}-bit ${t.rate / 1000}kHz · ${(+t.durationSec).toFixed(1)}s</small></div></div>`;
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(() => {});
}
+251
View File
@@ -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 <track> start upload to slot
// 0x03 finish upload + reload tracks
// 0x04 <track> 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);
});
}
}
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7c4dff"/>
<stop offset="1" stop-color="#b794ff"/>
</linearGradient>
</defs>
<rect width="192" height="192" rx="40" fill="#1f1147"/>
<!-- bluetooth rune -->
<path d="M96 40 L124 64 L96 92 L96 40 L96 152 L124 128 L96 100"
fill="none" stroke="url(#g)" stroke-width="9"
stroke-linejoin="round" stroke-linecap="round"/>
<line x1="72" y1="64" x2="124" y2="128" stroke="url(#g)" stroke-width="9" stroke-linecap="round"/>
<line x1="72" y1="128" x2="124" y2="64" stroke="url(#g)" stroke-width="9" stroke-linecap="round"/>
<!-- sound waves -->
<path d="M150 78 a26 26 0 0 1 0 36" fill="none" stroke="#b794ff" stroke-width="7" stroke-linecap="round" opacity=".8"/>
<path d="M40 84 a16 16 0 0 0 0 24" fill="none" stroke="#b794ff" stroke-width="7" stroke-linecap="round" opacity=".8"/>
</svg>

After

Width:  |  Height:  |  Size: 1013 B

+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#1f1147">
<title>BabyMobile Audio Manager</title>
<link rel="manifest" href="./manifest.webmanifest">
<link rel="icon" href="./icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="./icon.svg">
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<header>
<div class="brand">
<img src="./icon.svg" alt="" width="32" height="32">
<h1>BabyMobile</h1>
</div>
<div class="conn">
<span id="status-dot" class="dot"></span>
<span id="status-text">Not connected</span>
<button id="btn-connect" class="primary">Scan &amp; connect</button>
</div>
</header>
<div id="unsupported" class="banner" hidden>
⚠ This browser has no Web Bluetooth. Use <b>Chrome</b> or <b>Edge</b> on
Android / desktop, or the free <b>Bluefy</b> browser on iPhone/iPad.
</div>
<main>
<section class="card">
<h2>1 · Add audio</h2>
<div id="drop" class="drop">
<p>Tap or drop an audio file</p>
<small>WAV, MP3, M4A, OGG… — converted in your browser</small>
<input id="file" type="file" accept="audio/*" hidden>
</div>
<div class="opts">
<label>Format <select id="bits"></select></label>
<label>Rate <select id="rate"></select></label>
</div>
<p id="convert-info" class="info">No file selected.</p>
<div class="row">
<label>Upload to <select id="upload-slot" data-needs-conn disabled></select></label>
<button id="btn-upload" class="primary" data-needs-conn disabled>Upload</button>
</div>
<div class="progress"><div id="upload-bar" class="bar"></div></div>
</section>
<section class="card">
<h2>2 · Play</h2>
<div class="row">
<label>Play <select id="play-slot" data-needs-conn disabled></select></label>
<button id="btn-play" data-needs-conn disabled>▶ Play</button>
<button id="btn-stop" data-needs-conn disabled>⏹ Stop</button>
</div>
</section>
<section class="card">
<div class="card-head">
<h2>Tracks on device</h2>
<button id="btn-refresh" class="mini" data-needs-conn disabled>↻ Refresh</button>
</div>
<p class="hint">Live from the device. Titles are remembered locally — the device stores audio, not names.</p>
<ul id="slots" class="slots"></ul>
</section>
<section class="card">
<h2>Log</h2>
<pre id="log" class="log"></pre>
</section>
</main>
<footer>
Client-side only · no data leaves your device · <a href="./README.md">about &amp; iOS setup</a>
</footer>
<script type="module" src="./app.js"></script>
</body>
</html>
+14
View File
@@ -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" }
]
}
+89
View File
@@ -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); }
+31
View File
@@ -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))
);
});
+82
View File
@@ -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 };
}
+142 -51
View File
@@ -29,9 +29,10 @@
* D5 (P0.05) - Button 6 * D5 (P0.05) - Button 6
* D6 (P1.11) - Button 7 * D6 (P1.11) - Button 7
* D7 (P1.12) - Button 8 * D7 (P1.12) - Button 8
* D8 (P0.07) - SPI SCK → Flash pin 6 * D4 (P0.04) - Flash ~CS (PIN_FLASH_CS)
* D9 (P0.06) - SPI MISO → Flash pin 2 * D8 (P0.07) - SPI SCK → Flash CLK
* D10 (P0.05) - SPI MOSI → Flash pin 5 * D9 (P0.06) - SPI MISO → Flash DO
* D10 (P0.05) - SPI MOSI → Flash DI
* A0 (P0.02) - Audio PWM output → R+C LPF → PAM8302A * A0 (P0.02) - Audio PWM output → R+C LPF → PAM8302A
* A1 (P0.03) - Amp ~SD (HIGH=on) * A1 (P0.03) - Amp ~SD (HIGH=on)
* A2 (P0.28) - Motor PWM → MOSFET gate * A2 (P0.28) - Motor PWM → MOSFET gate
@@ -55,7 +56,7 @@
// dynamic bit-depth (8/16-bit) and sample-rate support. // dynamic bit-depth (8/16-bit) and sample-rate support.
// Disable this define to revert to full SPI flash mode. // Disable this define to revert to full SPI flash mode.
// ============================================================ // ============================================================
#define POC_INTERNAL_FLASH //#define POC_INTERNAL_FLASH
#ifdef POC_INTERNAL_FLASH #ifdef POC_INTERNAL_FLASH
#include <Adafruit_LittleFS.h> #include <Adafruit_LittleFS.h>
@@ -407,20 +408,37 @@ void loadTrackTable() {
((uint32_t)entry[2] << 8) | entry[3]; ((uint32_t)entry[2] << 8) | entry[3];
g_trackLen[i] = ((uint32_t)entry[4] << 24) | ((uint32_t)entry[5] << 16) | g_trackLen[i] = ((uint32_t)entry[4] << 24) | ((uint32_t)entry[5] << 16) |
((uint32_t)entry[6] << 8) | entry[7]; ((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("Loaded ");
Serial.print(g_numTracks); Serial.print(g_numTracks);
Serial.println(" tracks from flash"); 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 #endif
} }
@@ -449,8 +467,8 @@ void writeTrackTable() {
e[5] = (g_trackLen[i] >> 16) & 0xFF; e[5] = (g_trackLen[i] >> 16) & 0xFF;
e[6] = (g_trackLen[i] >> 8) & 0xFF; e[6] = (g_trackLen[i] >> 8) & 0xFF;
e[7] = g_trackLen[i] & 0xFF; e[7] = g_trackLen[i] & 0xFF;
e[8] = 8; // sample rate kHz e[8] = (uint8_t)(g_trackRate[i] / 1000);
e[9] = 8; // bits e[9] = g_trackBits[i];
e[10] = 0; e[10] = 0;
e[11] = 0; e[11] = 0;
} }
@@ -995,11 +1013,16 @@ uint8_t buttonRead() {
// cooldown from expiring while the button is still held and re-triggering. // cooldown from expiring while the button is still held and re-triggering.
void waitButtonRelease(uint8_t btn) { void waitButtonRelease(uint8_t btn) {
while (buttonRead() == btn) { while (buttonRead() == btn) {
// Keep DMA buffers fed while waiting
if (g_playing) { if (g_playing) {
// Keep DMA buffers fed while waiting
for (uint8_t b = 0; b < 2; b++) { for (uint8_t b = 0; b < 2; b++) {
if (!g_bufReady[b]) audioFillBuf(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 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 <track> <total_wav_bytes> (full WAV file, header included)
// DUMP <track> DUMP <track>
// p / l / u / d / r
// DELETE <track> (POC only)
// FORMAT (POC only)
// ============================================================ // ============================================================
#ifdef POC_INTERNAL_FLASH
enum SerUploadState { SER_IDLE, SER_RECEIVING }; enum SerUploadState { SER_IDLE, SER_RECEIVING };
static SerUploadState g_serState = SER_IDLE; static SerUploadState g_serState = SER_IDLE;
static char g_serLineBuf[64] = {0}; static char g_serLineBuf[64] = {0};
static uint8_t g_serLineLen = 0; static uint8_t g_serLineLen = 0;
static uint8_t g_serTrack = 0; static uint8_t g_serTrack = 0;
static uint32_t g_serBytesExpected = 0; static uint32_t g_serBytesExpected = 0;
static uint32_t g_serBytesReceived = 0; static uint32_t g_serBytesReceived = 0;
#ifdef POC_INTERNAL_FLASH
static File g_serFile(InternalFS); 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() { static void serUploadTick() {
if (g_serState == SER_IDLE) { if (g_serState == SER_IDLE) {
// Accumulate characters until newline
while (Serial.available()) { while (Serial.available()) {
// Serial.print("r");
char c = (char)Serial.read(); char c = (char)Serial.read();
if (c == '\n' || c == '\r') { if (c == '\n' || c == '\r') {
g_serLineBuf[g_serLineLen] = '\0'; g_serLineBuf[g_serLineLen] = '\0';
if (g_serLineLen == 0) { g_serLineLen = 0; break; } if (g_serLineLen == 0) { g_serLineLen = 0; break; }
// Parse: UPLOAD <track> <len>
unsigned int utrk = 0, ulen = 0; unsigned int utrk = 0, ulen = 0;
if (sscanf(g_serLineBuf, "UPLOAD %u %u", &utrk, &ulen) == 2) { if (sscanf(g_serLineBuf, "UPLOAD %u %u", &utrk, &ulen) == 2) {
g_serTrack = (uint8_t)utrk; g_serTrack = (uint8_t)utrk;
g_serBytesExpected = (uint32_t)ulen; g_serBytesExpected = (uint32_t)ulen;
g_serBytesReceived = 0; g_serBytesReceived = 0;
#ifdef POC_INTERNAL_FLASH
char fname[16]; char fname[16];
pocFilename(g_serTrack, fname); pocFilename(g_serTrack, fname);
if (g_serFile) g_serFile.close(); if (g_serFile) g_serFile.close();
// Remove first — FILE_O_WRITE has no truncate flag
InternalFS.remove(fname); InternalFS.remove(fname);
if (!g_serFile.open(fname, FILE_O_WRITE)) { if (!g_serFile.open(fname, FILE_O_WRITE)) {
Serial.print("ERR cannot open "); Serial.print("ERR cannot open "); Serial.println(fname);
Serial.println(fname);
} else { } else {
g_serState = SER_RECEIVING; g_serState = SER_RECEIVING;
g_usbConnected = true; g_usbConnected = true;
Serial.println("READY"); 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) { } 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]; char fname[16];
pocFilename((uint8_t)utrk, fname); pocFilename((uint8_t)utrk, fname);
File df(InternalFS); File df(InternalFS);
@@ -1197,20 +1246,40 @@ static void serUploadTick() {
uint32_t limit = min(fsz, (uint32_t)256); uint32_t limit = min(fsz, (uint32_t)256);
uint32_t off = 0; uint32_t off = 0;
while (off < limit) { while (off < limit) {
int n = df.read(dbuf, min((uint32_t)sizeof(dbuf), limit - off)); int rd = df.read(dbuf, min((uint32_t)sizeof(dbuf), limit - off));
if (n <= 0) break; if (rd <= 0) break;
for (int j = 0; j < n; j++) { for (int j = 0; j < rd; j++) {
if (dbuf[j] < 0x10) Serial.print("0"); if (dbuf[j] < 0x10) Serial.print("0");
Serial.print(dbuf[j], HEX); Serial.print(dbuf[j], HEX);
Serial.print(j % 16 == 15 || (off + j + 1) == limit ? "\n" : " "); Serial.print(j % 16 == 15 || (off + j + 1) == limit ? "\n" : " ");
} }
off += n; off += rd;
} }
df.close(); df.close();
Serial.println("END"); Serial.println("END");
} else { } else {
Serial.println("NO FILE"); 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')) { } else if (g_serLineLen == 1 && (g_serLineBuf[0] == 'u' || g_serLineBuf[0] == 'd')) {
if (g_serLineBuf[0] == 'u') g_audioGain++; if (g_serLineBuf[0] == 'u') g_audioGain++;
else if (g_audioGain > 1) g_audioGain--; else if (g_audioGain > 1) g_audioGain--;
@@ -1226,6 +1295,7 @@ static void serUploadTick() {
Serial.println("REBOOT"); Serial.println("REBOOT");
delay(10); delay(10);
NVIC_SystemReset(); NVIC_SystemReset();
#ifdef POC_INTERNAL_FLASH
} else if (sscanf(g_serLineBuf, "DELETE %u", &utrk) == 1) { } else if (sscanf(g_serLineBuf, "DELETE %u", &utrk) == 1) {
char fname[16]; char fname[16];
pocFilename((uint8_t)utrk, fname); pocFilename((uint8_t)utrk, fname);
@@ -1241,32 +1311,28 @@ static void serUploadTick() {
InternalFS.format(); InternalFS.format();
g_numTracks = 0; g_numTracks = 0;
Serial.println("FORMAT OK"); Serial.println("FORMAT OK");
#endif
} else { } else {
Serial.print("ERR bad cmd: "); Serial.print("ERR bad cmd: ");
Serial.println(g_serLineBuf); Serial.println(g_serLineBuf);
} }
g_serLineLen = 0; g_serLineLen = 0;
// Serial.print("0");
} else { } else {
if (g_serLineLen < (sizeof(g_serLineBuf) - 1)) { if (g_serLineLen < (sizeof(g_serLineBuf) - 1)) {
g_serLineBuf[g_serLineLen++] = c; g_serLineBuf[g_serLineLen++] = c;
} }
// Serial.print(",");
} }
} }
} else { // SER_RECEIVING } else { // SER_RECEIVING
// Serial.print("e");
uint8_t chunk[64]; uint8_t chunk[64];
while (Serial.available() && g_serBytesReceived < g_serBytesExpected) { while (Serial.available() && g_serBytesReceived < g_serBytesExpected) {
// Serial.print(";");
int n = Serial.readBytes(chunk, min((int)sizeof(chunk), int n = Serial.readBytes(chunk, min((int)sizeof(chunk),
(int)(g_serBytesExpected - g_serBytesReceived))); (int)(g_serBytesExpected - g_serBytesReceived)));
if (n <= 0) break; if (n <= 0) break;
#ifdef POC_INTERNAL_FLASH
int32_t wr = g_serFile.write(chunk, (uint16_t)n); int32_t wr = g_serFile.write(chunk, (uint16_t)n);
if (wr != 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_serFile.close();
g_usbConnected = false; g_usbConnected = false;
g_serState = SER_IDLE; g_serState = SER_IDLE;
@@ -1274,14 +1340,32 @@ static void serUploadTick() {
Serial.println(g_serBytesReceived); Serial.println(g_serBytesReceived);
break; 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) { if (g_serBytesReceived >= g_serBytesExpected) {
#ifdef POC_INTERNAL_FLASH
g_serFile.close(); g_serFile.close();
// Reopen to read the actual flushed size from LittleFS
char fname2[16]; pocFilename(g_serTrack, fname2); char fname2[16]; pocFilename(g_serTrack, fname2);
File tmp(InternalFS); File tmp(InternalFS);
uint32_t fsz = 0; uint32_t fsz = 0;
@@ -1289,22 +1373,29 @@ static void serUploadTick() {
g_usbConnected = false; g_usbConnected = false;
g_serState = SER_IDLE; g_serState = SER_IDLE;
loadTrackTable(); loadTrackTable();
Serial.print("OK "); Serial.print("OK "); Serial.print(fsz);
Serial.print(fsz); Serial.print("/"); Serial.println(g_serBytesReceived);
Serial.print("/"); #else
Serial.println(g_serBytesReceived); // 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() { void loop() {
if (g_playing) g_lastActivity = millis(); if (g_playing) g_lastActivity = millis();
// ---- Serial upload (POC mode) ---- // ---- Serial upload ----
#ifdef POC_INTERNAL_FLASH
serUploadTick(); serUploadTick();
#endif
// ---- Refill audio buffers ---- // ---- Refill audio buffers ----
if (g_playing) { if (g_playing) {
-198
View File
@@ -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 <port> <audio_file> [...]")
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()