add app, fix ble, use flash
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "babymobile-static",
|
||||
"runtimeExecutable": "python",
|
||||
"runtimeArgs": ["-m", "http.server", "8012"],
|
||||
"port": 8012
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 & 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 & iOS setup</a>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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 <Adafruit_LittleFS.h>
|
||||
@@ -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 <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 };
|
||||
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 <track> <len>
|
||||
|
||||
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) {
|
||||
|
||||
-198
@@ -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()
|
||||
Reference in New Issue
Block a user