fixed most upload issues, updated schematic

This commit is contained in:
zyphlar
2026-07-04 03:28:14 -07:00
parent 12140b7ff6
commit 8cba579780
10 changed files with 1305 additions and 1020 deletions
+112 -26
View File
@@ -1,6 +1,6 @@
// app.js — UI glue for the BabyMobile manager PWA.
import { BabyMobile, isSupported, MAX_TRACKS } from './ble.js';
import { fileToWav, RATES, BITS } from './wav.js';
import { decodeFileToMono, encodeWav, trimSamples, RATES, BITS } from './wav.js';
const $ = (id) => document.getElementById(id);
const dev = new BabyMobile();
@@ -12,6 +12,8 @@ let slots = loadSlots();
let pendingWav = null; // { wav, durationSec, rate, bits, name }
let deviceTracks = null; // last result of dev.listTracks(), or null
let currentFile = null; // the picked File
let decoded = null; // { samples, rate, srcRate } cached decode of currentFile
function loadSlots() {
try { return JSON.parse(localStorage.getItem(SLOTS_KEY)) || {}; }
@@ -67,35 +69,93 @@ $('drop').addEventListener('drop', (e) => {
});
fileInput.addEventListener('change', () => { if (fileInput.files.length) handleFile(fileInput.files[0]); });
// Decode the file (expensive) once per file/rate, then re-encode cheaply when
// the user tweaks bit-depth or trim.
async function handleFile(file) {
const rate = parseInt($('rate').value, 10);
const bits = parseInt($('bits').value, 10);
$('convert-info').textContent = `Converting "${file.name}"…`;
stopPreview();
currentFile = file;
decoded = null;
pendingWav = null;
$('btn-upload').disabled = true;
$('btn-preview').disabled = true;
$('trim').hidden = true;
$('convert-info').textContent = `Decoding "${file.name}"…`;
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;
const rate = parseInt($('rate').value, 10);
decoded = await decodeFileToMono(file, rate);
$('trim-start').value = '0';
$('trim-end').value = '0';
$('trim').hidden = false;
reEncode();
} 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]);
}));
function trimValues() {
return {
trimStart: Math.max(0, parseFloat($('trim-start').value) || 0),
trimEnd: Math.max(0, parseFloat($('trim-end').value) || 0),
};
}
// Re-encode the staged WAV from the cached decode (no re-decode).
function reEncode() {
if (!decoded || !currentFile) return;
const bits = parseInt($('bits').value, 10);
const { trimStart, trimEnd } = trimValues();
const fullDur = decoded.samples.length / decoded.rate;
const { wav, durationSec } = encodeWav(decoded.samples, decoded.rate, bits, { trimStart, trimEnd });
pendingWav = { wav, durationSec, rate: decoded.rate, bits, name: currentFile.name };
const trimmed = (trimStart || trimEnd) ? ` (trimmed from ${fullDur.toFixed(1)}s)` : '';
$('convert-info').innerHTML =
`<b>${currentFile.name}</b> → ${bits}-bit ${decoded.rate / 1000} kHz mono<br>` +
`${durationSec.toFixed(1)}s${trimmed} · ${fmtBytes(wav.byteLength)} (incl. 44-byte WAV header)`;
$('btn-upload').disabled = !dev.connected || durationSec <= 0;
$('btn-preview').disabled = durationSec <= 0;
}
// Changing the rate needs a fresh decode; bits/trim only need a re-encode.
$('rate').addEventListener('change', () => { if (currentFile) handleFile(currentFile); });
['bits', 'trim-start', 'trim-end'].forEach((id) => $(id).addEventListener('input', () => { stopPreview(); reEncode(); }));
// ---- local preview of the trimmed clip ----
let previewCtx = null;
let previewSrc = null;
function stopPreview() {
if (previewSrc) { try { previewSrc.stop(); } catch {} previewSrc = null; }
if (previewCtx) { try { previewCtx.close(); } catch {} previewCtx = null; }
$('btn-preview').textContent = '▶ Preview';
}
$('btn-preview').addEventListener('click', () => {
if (previewSrc) { stopPreview(); return; }
if (!decoded) return;
const { trimStart, trimEnd } = trimValues();
const slice = trimSamples(decoded.samples, decoded.rate, trimStart, trimEnd);
if (!slice.length) return;
const AC = window.AudioContext || window.webkitAudioContext;
previewCtx = new AC();
const buf = previewCtx.createBuffer(1, slice.length, decoded.rate);
buf.getChannelData(0).set(slice);
previewSrc = previewCtx.createBufferSource();
previewSrc.buffer = buf;
previewSrc.connect(previewCtx.destination);
previewSrc.onended = stopPreview;
previewSrc.start();
$('btn-preview').textContent = '⏹ Stop';
});
// ---- upload ----
$('btn-upload').addEventListener('click', async () => {
if (!pendingWav) return;
const track = parseInt($('upload-slot').value, 10);
if (occupiedSlots().has(track)) {
const name = slots[track]?.name;
if (!confirm(`Slot ${track} already holds ${name ? `"${name}"` : 'a track'}. Overwrite it?`)) return;
}
stopPreview();
$('btn-upload').disabled = true;
const bar = $('upload-bar');
bar.style.width = '0%';
@@ -120,10 +180,6 @@ $('btn-upload').addEventListener('click', async () => {
}
});
// ---- 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());
@@ -138,8 +194,10 @@ async function refresh() {
}
}
async function onDelete(idx, onDevice) {
async function onDelete(idx, onDevice, name) {
const label = name ? `"${name}"` : `the track in slot ${idx}`;
if (onDevice) {
if (!confirm(`Delete ${label} from the device? This can't be undone.`)) return;
try {
const r = await dev.deleteTrack(idx);
if (r.success) {
@@ -153,6 +211,7 @@ async function onDelete(idx, onDevice) {
log('Delete failed: ' + (err.message || err));
}
} else {
if (!confirm(`Forget ${label} from this browser's list? (It only clears the local record.)`)) return;
delete slots[idx]; saveSlots(); renderSlots();
}
}
@@ -171,6 +230,8 @@ function renderSlots() {
durationSec: slots[k].durationSec, name: slots[k].name,
}));
updateUploadSlotOptions(); // keep the upload picker's "next free" in sync
if (!rows.length) {
list.innerHTML = `<li class="empty">${onDevice ? 'No tracks on the device yet.' : 'Connect to list device tracks.'}</li>`;
return;
@@ -185,11 +246,12 @@ function renderSlots() {
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 stopBtn = btn('⏹', 'Stop', () => dev.stop().catch((e) => log('' + e)));
playBtn.dataset.needsConn = ''; stopBtn.dataset.needsConn = '';
playBtn.disabled = stopBtn.disabled = !dev.connected;
const delBtn = btn('🗑', onDevice ? 'Delete from device' : 'Forget (local only)',
() => onDelete(t.idx, onDevice));
actions.append(playBtn, delBtn);
() => onDelete(t.idx, onDevice, t.name));
actions.append(playBtn, stopBtn, delBtn);
li.append(actions);
list.append(li);
}
@@ -213,9 +275,33 @@ function fillSelect(sel, values, fmt, selected) {
}
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);
fillSelect($('play-slot'), Array.from({ length: MAX_TRACKS }, (_, i) => i), (v) => `Slot ${v}`, 0);
// Which slots currently hold a track: device truth when connected, else local memory.
function occupiedSlots() {
if (dev.connected && Array.isArray(deviceTracks)) return new Set(deviceTracks.map((t) => t.idx));
return new Set(Object.keys(slots).map(Number));
}
function nextFreeSlot() {
const occ = occupiedSlots();
for (let i = 0; i < MAX_TRACKS; i++) if (!occ.has(i)) return i;
return MAX_TRACKS - 1; // all full → default to the last slot
}
// Rebuild the upload-slot picker, label occupied slots, and default to next free.
function updateUploadSlotOptions() {
const occ = occupiedSlots();
const sel = $('upload-slot');
sel.innerHTML = '';
for (let i = 0; i < MAX_TRACKS; i++) {
const o = document.createElement('option');
o.value = i;
const name = slots[i]?.name;
o.textContent = occ.has(i) ? `Slot ${i} — in use${name ? `: ${name}` : ''}` : `Slot ${i} — free`;
sel.append(o);
}
sel.value = String(nextFreeSlot());
}
updateUploadSlotOptions();
// ---- support banner ----
if (!isSupported()) {
+38 -18
View File
@@ -34,9 +34,9 @@ const TAG_DELETE = 0xd0;
export const MAX_TRACKS = 32;
// Keep below ATT MTU - 3. The firmware calls configPrphBandwidth(BANDWIDTH_MAX)
// so Chrome typically negotiates MTU 247 (244-byte payload). 180 stays well clear.
const DATA_CHUNK = 180;
// ATT MTU is negotiated to 247 (firmware requests via requestMtuExchange(247)).
// Write-without-response payload = MTU - 3 = 244 bytes.
const DATA_CHUNK = 244;
export function isSupported() {
return typeof navigator !== 'undefined' && !!navigator.bluetooth;
@@ -231,40 +231,60 @@ export class BabyMobile extends EventTarget {
});
}
// Stream a complete WAV file to a slot. `onProgress(sent, total)` is called as
// the firmware acknowledges bytes via the status characteristic.
// Stream a complete WAV file to a slot. `onProgress(shown, total)` reports a
// single monotonic value combining the locally-sent count and the device's
// acknowledged count.
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);
// The optimistic local "sent" count races ahead of the device's ack
// notifications, and the two arrive interleaved (and acks can lag badly over
// a slow mobile link). Reporting both directly makes the bar jump forward then
// snap backward. Clamp to a high-water mark so progress only ever advances.
let shown = 0;
const report = (n) => {
const v = Math.min(Math.max(0, n), total);
if (v > shown) { shown = v; if (onProgress) onProgress(shown, total); }
};
const progressHandler = (ev) => { acked = ev.detail.bytes; report(acked); };
this.addEventListener('progress', progressHandler);
try {
this._log(`Starting upload of ${total} bytes to slot ${track}`);
await this._writeCmd([CMD_UPLOAD_START, track & 0xff]);
// Send all data chunks. Progress is tracked client-side (bytes sent).
// The firmware queues bytes into a ring buffer; actual flash writes happen
// in the background. We do NOT poll per-packet notifications here — that
// caused BLE notification-buffer overflow for large files.
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(off + chunk.length, total);
// Send all data chunks in pipelined batches. Chrome on Windows serialises
// individually-awaited writeValueWithoutResponse calls (one Promise per OS
// event loop tick), which limits throughput to ~one packet per ~50 ms and
// turns a 1.4 MB upload into a 6-minute ordeal. Firing PIPE writes before
// awaiting them all fills multiple BLE connection events per await, saturating
// the link. The firmware's 32 KB ring buffer absorbs bursts comfortably.
const PIPE = 6; // packets in flight before each await (~1 KB per batch)
for (let off = 0; off < total; off += DATA_CHUNK * PIPE) {
const batch = [];
for (let i = 0; i < PIPE && off + i * DATA_CHUNK < total; i++) {
const s = off + i * DATA_CHUNK;
batch.push(this.data.writeValueWithoutResponse(
wavBytes.subarray(s, Math.min(s + DATA_CHUNK, total))));
}
await Promise.all(batch);
// Cap the optimistic local count at 99%; the final 1% is filled only when
// the device's finalize ack confirms the bytes actually landed in flash.
report(Math.min(off + DATA_CHUNK * PIPE, Math.floor(total * 0.99)));
}
// Tell the firmware we're done. It will drain its ring buffer, write the
// track table, then send ONE finalize notification with the total byte count.
await this._writeCmd([CMD_UPLOAD_END]);
// Wait for that single finalize notification (up to 20 s to allow flash writes).
await this._waitForAck(total, 20000);
// Wait for that single finalize notification. Allow up to 60 s: at 300 ms/sector
// worst-case, a 1.4 MB file has ~350 sectors = 105 s of erase time. In practice
// most sectors erase in ~30 ms so finalization completes in well under 60 s.
await this._waitForAck(total, 60000);
const ok = acked >= total;
if (ok) {
this._log(`✅ Upload complete: ${acked}/${total} bytes acknowledged.`);
+6 -11
View File
@@ -13,8 +13,7 @@
<body>
<header>
<div class="brand">
<img src="./icon.svg" alt="" width="32" height="32">
<h1>BabyMobile</h1>
<img src="./icon.svg" alt="BabyMobile" width="28" height="28">
</div>
<div class="conn">
<span id="status-dot" class="dot"></span>
@@ -40,6 +39,11 @@
<label>Format <select id="bits"></select></label>
<label>Rate <select id="rate"></select></label>
</div>
<div id="trim" class="opts trim" hidden>
<label>Trim start <input id="trim-start" type="number" min="0" step="0.1" value="0"><span class="unit">s</span></label>
<label>Trim end <input id="trim-end" type="number" min="0" step="0.1" value="0"><span class="unit">s</span></label>
<button id="btn-preview" class="mini" disabled>▶ Preview</button>
</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>
@@ -48,15 +52,6 @@
<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>
+14 -1
View File
@@ -16,6 +16,7 @@ body {
background: linear-gradient(160deg, var(--bg), var(--bg2));
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
-webkit-tap-highlight-color: transparent;
}
header {
@@ -48,11 +49,14 @@ 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 {
select, input[type=number] {
font: inherit; background: #1c123e; color: var(--text);
border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px;
}
input[type=number] { width: 4.5rem; }
label { display: inline-flex; align-items: center; gap: 8px; font-size: .9rem; color: var(--muted); }
.trim { align-items: center; }
.trim .unit { color: var(--muted); margin-left: -4px; }
.drop {
border: 2px dashed var(--line); border-radius: 14px; padding: 26px 16px;
@@ -87,3 +91,12 @@ label { display: inline-flex; align-items: center; gap: 8px; font-size: .9rem; c
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); }
@media (max-width: 480px) {
header { padding: 12px 12px calc(12px + env(safe-area-inset-top)); }
.conn { gap: 6px; font-size: .8rem; }
main { padding: 12px; gap: 12px; }
.card { padding: 14px; border-radius: 14px; }
.opts { gap: 12px; }
.slot-meta b { max-width: 42vw; }
}
+1 -1
View File
@@ -1,5 +1,5 @@
// sw.js — minimal offline cache so the PWA launches without a network.
const CACHE = 'babymobile-v3';
const CACHE = 'babymobile-v5';
const ASSETS = [
'./', './index.html', './styles.css',
'./app.js', './ble.js', './wav.js',
+20 -5
View File
@@ -72,11 +72,26 @@ function buildWav(pcm, rate, bits) {
return u8;
}
// Returns { wav: Uint8Array, durationSec, rate, bits, srcRate }.
export async function fileToWav(file, { rate = 8000, bits = 8 } = {}) {
// Decode a file to mono Float32 samples at `rate`. This is the expensive step,
// so callers cache the result and re-encode cheaply when bits/trim change.
// Returns { samples, rate, srcRate }.
export async function decodeFileToMono(file, rate = 8000) {
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 };
return { samples, rate, srcRate };
}
// Slice [trimStart, end-trimEnd] off the mono samples and return that range.
export function trimSamples(samples, rate, trimStart = 0, trimEnd = 0) {
const start = Math.max(0, Math.min(samples.length, Math.floor(trimStart * rate)));
const end = Math.max(start, samples.length - Math.max(0, Math.floor(trimEnd * rate)));
return samples.subarray(start, end);
}
// Encode mono Float32 samples to a WAV. Returns { wav, durationSec }.
export function encodeWav(samples, rate, bits, { trimStart = 0, trimEnd = 0 } = {}) {
const sliced = trimSamples(samples, rate, trimStart, trimEnd);
const pcm = floatToPcm(sliced, bits);
const wav = buildWav(pcm, rate, bits);
return { wav, durationSec: sliced.length / rate };
}
+716 -800
View File
File diff suppressed because it is too large Load Diff
+215 -83
View File
@@ -2872,10 +2872,10 @@
(uuid "017eac50-3406-4cc1-bd32-b83e5696234a")
)
(junction
(at 311.16 90.17)
(at 71.12 153.67)
(diameter 0)
(color 0 0 0 0)
(uuid "19afce19-eefe-43da-9ff9-594d5e7450df")
(uuid "17e89eec-f620-4c7a-8887-8563a7dca664")
)
(junction
(at 330.2 145.97)
@@ -2896,10 +2896,28 @@
(uuid "6d7a0476-c2d8-409f-acf0-c51d5cc44c54")
)
(junction
(at 311.16 87.63)
(at 309.89 87.63)
(diameter 0)
(color 0 0 0 0)
(uuid "eb2c5735-191b-4d6e-b9b7-6f9a1ba9da7c")
(uuid "7f9aeab0-b298-49b0-95a9-13f903c592bd")
)
(junction
(at 311.16 90.17)
(diameter 0)
(color 0 0 0 0)
(uuid "b41da60f-ed92-4caa-94b4-a8f17f7b37f7")
)
(junction
(at 309.88 87.63)
(diameter 0)
(color 0 0 0 0)
(uuid "b4ef88db-519f-48de-b199-16343987ba1f")
)
(junction
(at 67.31 166.37)
(diameter 0)
(color 0 0 0 0)
(uuid "e2984591-bbbd-47a7-b9f3-f6e744a0177d")
)
(junction
(at 207.01 204.47)
@@ -2913,6 +2931,12 @@
(color 0 0 0 0)
(uuid "ef3a8681-bfa6-40af-9816-ec3346f9cb23")
)
(junction
(at 304.8 87.63)
(diameter 0)
(color 0 0 0 0)
(uuid "fa1bd6ee-5560-42cc-8cc4-182475fa5265")
)
(no_connect
(at 223.52 234.95)
(uuid "519c324a-3d40-42a9-a963-f53dd2b596c0")
@@ -2953,6 +2977,16 @@
)
(uuid "0a315227-f5b7-4de4-9dc2-5a9da0d1e499")
)
(wire
(pts
(xy 304.8 91.44) (xy 304.72 91.44)
)
(stroke
(width 0)
(type default)
)
(uuid "0e7e7f7d-d9d1-48e4-b0a4-efa3065d2db0")
)
(wire
(pts
(xy 342.9 92.69) (xy 342.9 92.61)
@@ -3005,13 +3039,23 @@
)
(wire
(pts
(xy 311.16 87.63) (xy 312.43 87.63)
(xy 67.31 166.37) (xy 67.31 171.45)
)
(stroke
(width 0)
(type default)
)
(uuid "24800943-9fa3-42fe-8fc7-e4e93239651c")
(uuid "1e67db82-9086-4cc6-acee-2e0c4028091c")
)
(wire
(pts
(xy 62.23 166.37) (xy 67.31 166.37)
)
(stroke
(width 0)
(type default)
)
(uuid "1fbb2173-f466-4fb3-a1e1-1fd99f26203e")
)
(wire
(pts
@@ -3053,6 +3097,16 @@
)
(uuid "33cecf5c-c05b-46be-b395-224afca830f4")
)
(wire
(pts
(xy 304.8 87.63) (xy 297.18 87.63)
)
(stroke
(width 0)
(type default)
)
(uuid "34fe1a69-5ff4-4a92-871e-1af3f56feee9")
)
(wire
(pts
(xy 330.2 148.59) (xy 330.2 149.86)
@@ -3233,6 +3287,16 @@
)
(uuid "7a32a98d-a07b-4e23-97a7-ff5c2761b4a9")
)
(wire
(pts
(xy 67.31 171.45) (xy 65.96 171.45)
)
(stroke
(width 0)
(type default)
)
(uuid "7a5c76e9-bfde-4599-a03c-144047a97246")
)
(wire
(pts
(xy 207.01 201.93) (xy 223.52 201.93)
@@ -3243,6 +3307,16 @@
)
(uuid "7b09f59c-2799-44c6-bf90-c378030ea9b1")
)
(wire
(pts
(xy 62.23 153.67) (xy 71.12 153.67)
)
(stroke
(width 0)
(type default)
)
(uuid "83ec5324-e898-46e6-9fe2-31d9a05b3da0")
)
(wire
(pts
(xy 334.01 90.15) (xy 334.01 90.17)
@@ -3273,6 +3347,16 @@
)
(uuid "8b2e0b76-8ce1-43a5-b900-90897c8f6ac4")
)
(wire
(pts
(xy 71.12 153.67) (xy 71.12 157.48)
)
(stroke
(width 0)
(type default)
)
(uuid "8cbf3c35-fa13-44b4-bcf4-21252fe25fcb")
)
(wire
(pts
(xy 231.14 207.01) (xy 243.84 207.01)
@@ -3283,6 +3367,26 @@
)
(uuid "8f5e7827-d21e-468e-b028-9b178eb97914")
)
(wire
(pts
(xy 304.8 87.63) (xy 304.8 91.44)
)
(stroke
(width 0)
(type default)
)
(uuid "8fbf48d3-9d5a-45d9-8e37-a4596be807cd")
)
(wire
(pts
(xy 309.89 87.63) (xy 309.88 87.63)
)
(stroke
(width 0)
(type default)
)
(uuid "930181c0-0f67-43b3-8e66-2256b00ebbc0")
)
(wire
(pts
(xy 302.26 162.56) (xy 299.72 162.56)
@@ -3303,6 +3407,16 @@
)
(uuid "968c6461-fd30-4fe2-9334-56f61262ca7c")
)
(wire
(pts
(xy 67.31 166.37) (xy 78.74 166.37)
)
(stroke
(width 0)
(type default)
)
(uuid "9a505b6a-5abe-47a4-9e70-9865514e51a2")
)
(wire
(pts
(xy 312.43 90.17) (xy 311.16 90.17)
@@ -3325,7 +3439,7 @@
)
(wire
(pts
(xy 311.16 90.17) (xy 309.89 90.17)
(xy 309.89 90.17) (xy 311.16 90.17)
)
(stroke
(width 0)
@@ -3383,6 +3497,16 @@
)
(uuid "b103c0d7-a6ac-40b0-9282-0e3442120b6c")
)
(wire
(pts
(xy 309.88 87.63) (xy 304.8 87.63)
)
(stroke
(width 0)
(type default)
)
(uuid "b4e0a463-68c6-49f4-96e9-2451c3680296")
)
(wire
(pts
(xy 223.52 217.17) (xy 207.01 217.17)
@@ -3473,6 +3597,26 @@
)
(uuid "cf357f80-93fc-47ae-8a9d-c3f2a82281d2")
)
(wire
(pts
(xy 309.88 85.09) (xy 311.16 85.09)
)
(stroke
(width 0)
(type default)
)
(uuid "d23b020f-474a-44c7-aa50-5d2d6a637b91")
)
(wire
(pts
(xy 71.12 157.48) (xy 71.04 157.48)
)
(stroke
(width 0)
(type default)
)
(uuid "d5374591-14af-4a6a-a74f-9e6b20508561")
)
(wire
(pts
(xy 231.14 237.49) (xy 243.84 237.49)
@@ -3523,6 +3667,16 @@
)
(uuid "dc56447b-2913-4073-a3e6-ae9a14e1130a")
)
(wire
(pts
(xy 71.12 153.67) (xy 78.74 153.67)
)
(stroke
(width 0)
(type default)
)
(uuid "dce908f3-84d4-4a63-89a0-3e8809dbb8a5")
)
(wire
(pts
(xy 334.01 90.17) (xy 334.02 90.17)
@@ -3563,6 +3717,16 @@
)
(uuid "f96fe0e4-f1ba-4802-b049-1ac2454eeab6")
)
(wire
(pts
(xy 309.88 87.63) (xy 309.88 85.09)
)
(stroke
(width 0)
(type default)
)
(uuid "f9b0891a-84da-4d7e-9086-65a34b6b7d6f")
)
(wire
(pts
(xy 339.09 76.18) (xy 339.17 76.18)
@@ -3654,7 +3818,7 @@
(uuid "21f44609-2138-4f22-9db6-f9e7fee8972d")
)
(label "C_3V3"
(at 159.58 167.43 270)
(at 55.96 171.45 180)
(effects
(font
(size 1.27 1.27)
@@ -3693,7 +3857,7 @@
)
(uuid "39dfef0c-d7a2-4482-bca9-5affd0a9afcc")
)
(label "AUDIO_PWM"
(label "LED1"
(at 85.09 95.25 180)
(effects
(font
@@ -3813,7 +3977,7 @@
)
(uuid "7e061299-bff5-40d1-8f87-e3efcbd5b5b0")
)
(label "LED1"
(label "C_BTN1"
(at 85.09 80.01 180)
(effects
(font
@@ -3824,7 +3988,7 @@
(uuid "82233f70-9329-4389-8977-125c2201a8e6")
)
(label "FLASH_CS"
(at 78.74 166.37 180)
(at 62.23 166.37 180)
(effects
(font
(size 1.27 1.27)
@@ -3844,7 +4008,7 @@
(uuid "848da8f0-b353-4b3c-9a06-b30dcd05db07")
)
(label "GND"
(at 151.14 167.58 270)
(at 61.04 157.48 180)
(effects
(font
(size 1.27 1.27)
@@ -3873,15 +4037,15 @@
)
(uuid "94725878-e74f-4b21-aae5-a56d4b5f5068")
)
(label "GND"
(at 322.59 95.25 270)
(label "AMP_SD"
(at 85.09 99.06 180)
(effects
(font
(size 1.27 1.27)
)
(justify right bottom)
)
(uuid "9ba0110e-0516-473b-b196-7a8965125707")
(uuid "968fcbdf-0690-43cd-87a6-f65d215b6d57")
)
(label "VO+"
(at 340.36 160.02 0)
@@ -3924,7 +4088,7 @@
(uuid "b0235f9d-665c-4f41-be2f-d227623085bc")
)
(label "GND"
(at 295.91 95.15 270)
(at 294.72 91.44 180)
(effects
(font
(size 1.27 1.27)
@@ -3964,7 +4128,7 @@
(uuid "ba54329f-b24b-491c-af5b-8195107bd63a")
)
(label "C_VBAT"
(at 312.43 87.63 180)
(at 297.18 87.63 180)
(effects
(font
(size 1.27 1.27)
@@ -3983,7 +4147,7 @@
)
(uuid "bf9261b2-0bea-41a0-aa2a-4209adebed25")
)
(label "C_BTN1"
(label "AUDIO_PWM"
(at 85.09 76.2 180)
(effects
(font
@@ -4023,16 +4187,6 @@
)
(uuid "debd7b52-0059-4c0b-bf7c-967d3c2772e8")
)
(label "FLASH_CS"
(at 159.58 157.43 90)
(effects
(font
(size 1.27 1.27)
)
(justify left bottom)
)
(uuid "e2af49fb-22e1-4190-a130-4ab73a10b921")
)
(label "GND"
(at 308.53 157.48 180)
(effects
@@ -4044,17 +4198,7 @@
(uuid "e7b3cd1a-4907-40c3-92a2-ed993d774d88")
)
(label "C_3V3"
(at 151.14 157.58 90)
(effects
(font
(size 1.27 1.27)
)
(justify left bottom)
)
(uuid "efd4637c-fa54-44ce-86ca-590813c3a03a")
)
(label "C_3V3"
(at 78.74 153.67 180)
(at 62.23 153.67 180)
(effects
(font
(size 1.27 1.27)
@@ -4063,26 +4207,6 @@
)
(uuid "f1103770-6762-4d62-b659-6b7212eb3807")
)
(label "C_VBAT"
(at 311.16 85.09 180)
(effects
(font
(size 1.27 1.27)
)
(justify right bottom)
)
(uuid "f1a32f8a-5ce2-4e1f-9c31-201dda2521a1")
)
(label "C_VBAT"
(at 295.91 85.15 90)
(effects
(font
(size 1.27 1.27)
)
(justify left bottom)
)
(uuid "f4e3eb90-54e4-46d9-ab89-62e9cba84a0b")
)
(label "SPI_SCK"
(at 125.73 95.25 0)
(effects
@@ -4093,7 +4217,7 @@
)
(uuid "fa336a55-f4b8-4536-98b8-03cd9dc53b95")
)
(label "V_BAT"
(label "AMP_SD"
(at 307.34 148.59 180)
(effects
(font
@@ -4789,7 +4913,7 @@
)
(symbol
(lib_id "bm:C")
(at 151.14 162.58 0)
(at 66.04 157.48 270)
(unit 1)
(exclude_from_sim no)
(in_bom yes)
@@ -4797,7 +4921,7 @@
(dnp no)
(uuid "4ea7caff-53f9-4c00-bf01-2c0cfc1ebd9c")
(property "Reference" "C3"
(at 154.14 159.58 0)
(at 69.04 160.48 0)
(effects
(font
(size 1.27 1.27)
@@ -4805,7 +4929,7 @@
)
)
(property "Value" "100nF"
(at 154.14 165.58 0)
(at 63.04 160.48 0)
(effects
(font
(size 1.27 1.27)
@@ -4813,7 +4937,7 @@
)
)
(property "Footprint" "Capacitor_SMD:C_0805_2012Metric"
(at 151.14 162.58 0)
(at 66.04 157.48 0)
(effects
(font
(size 1.27 1.27)
@@ -4822,7 +4946,7 @@
)
)
(property "Datasheet" ""
(at 151.14 162.58 0)
(at 66.04 157.48 0)
(effects
(font
(size 1.27 1.27)
@@ -4831,7 +4955,7 @@
)
)
(property "Description" ""
(at 151.14 162.58 0)
(at 66.04 157.48 0)
(effects
(font
(size 1.27 1.27)
@@ -4923,7 +5047,7 @@
)
(symbol
(lib_id "bm:R")
(at 159.58 162.43 0)
(at 60.96 171.45 270)
(unit 1)
(exclude_from_sim no)
(in_bom yes)
@@ -4931,7 +5055,7 @@
(dnp no)
(uuid "696b8ca8-97f4-4851-97cc-70c56c8f54f5")
(property "Reference" "R2"
(at 162.58 159.43 0)
(at 63.96 174.45 0)
(effects
(font
(size 1.27 1.27)
@@ -4939,7 +5063,7 @@
)
)
(property "Value" "10K"
(at 162.58 165.43 0)
(at 57.96 174.45 0)
(effects
(font
(size 1.27 1.27)
@@ -4947,7 +5071,7 @@
)
)
(property "Footprint" "Resistor_SMD:R_0805_2012Metric"
(at 159.58 162.43 0)
(at 60.96 171.45 0)
(effects
(font
(size 1.27 1.27)
@@ -4956,7 +5080,7 @@
)
)
(property "Datasheet" ""
(at 159.58 162.43 0)
(at 60.96 171.45 0)
(effects
(font
(size 1.27 1.27)
@@ -4965,7 +5089,7 @@
)
)
(property "Description" ""
(at 159.58 162.43 0)
(at 60.96 171.45 0)
(effects
(font
(size 1.27 1.27)
@@ -5555,7 +5679,6 @@
(in_bom yes)
(on_board yes)
(dnp no)
(fields_autoplaced yes)
(uuid "90218aa1-4311-468c-8ce8-89d467c4ac29")
(property "Reference" "R4"
(at 311.15 142.24 90)
@@ -5600,6 +5723,15 @@
(hide yes)
)
)
(property "Todo" "Is this necessary?"
(at 310.896 140.462 90)
(effects
(font
(size 1.27 1.27)
(italic yes)
)
)
)
(pin "1"
(uuid "0b135b1d-91e6-4233-b45c-8d0fafdbdcfb")
)
@@ -5788,7 +5920,7 @@
)
)
(property "Value" "0.33uF"
(at 345.9 90.61 0)
(at 347.726 90.17 0)
(effects
(font
(size 1.27 1.27)
@@ -5814,7 +5946,7 @@
)
)
(property "Description" "Ceramic"
(at 342.9 87.61 0)
(at 348.234 87.63 0)
(effects
(font
(size 1.27 1.27)
@@ -5922,7 +6054,7 @@
)
)
(property "Value" "10uF"
(at 347.17 73.18 0)
(at 347.218 71.882 0)
(effects
(font
(size 1.27 1.27)
@@ -5948,7 +6080,7 @@
)
)
(property "Description" "Ceramic"
(at 344.17 76.18 0)
(at 344.424 71.12 0)
(effects
(font
(size 1.27 1.27)
@@ -6309,7 +6441,7 @@
)
(symbol
(lib_id "bm:C")
(at 295.91 90.15 0)
(at 299.72 91.44 270)
(unit 1)
(exclude_from_sim no)
(in_bom yes)
@@ -6317,7 +6449,7 @@
(dnp no)
(uuid "d225c3fa-9586-478d-a854-6d14dbaa4bfd")
(property "Reference" "C1"
(at 298.91 87.15 0)
(at 302.72 94.44 0)
(effects
(font
(size 1.27 1.27)
@@ -6325,7 +6457,7 @@
)
)
(property "Value" "10uF"
(at 298.91 93.15 0)
(at 296.72 94.44 0)
(effects
(font
(size 1.27 1.27)
@@ -6333,7 +6465,7 @@
)
)
(property "Footprint" "Capacitor_SMD:C_0805_2012Metric"
(at 295.91 90.15 0)
(at 299.72 91.44 0)
(effects
(font
(size 1.27 1.27)
@@ -6342,7 +6474,7 @@
)
)
(property "Datasheet" ""
(at 295.91 90.15 0)
(at 299.72 91.44 0)
(effects
(font
(size 1.27 1.27)
@@ -6351,7 +6483,7 @@
)
)
(property "Description" "Ceramic"
(at 295.91 90.15 0)
(at 299.466 97.028 0)
(effects
(font
(size 1.27 1.27)
+154 -34
View File
@@ -183,7 +183,8 @@ BLECharacteristic audioCmd = BLECharacteristic("12340002-0000-1000-8000-00805f9
BLECharacteristic audioData = BLECharacteristic("12340003-0000-1000-8000-00805f9b34fb");
BLECharacteristic audioStat = BLECharacteristic("12340004-0000-1000-8000-00805f9b34fb");
// BLE upload state
// BLE connection / upload state
volatile bool g_bleConnected = false; // true while a GATT connection is active
volatile bool g_bleUploading = false;
volatile uint32_t g_bleWriteLen = 0;
@@ -192,17 +193,20 @@ File g_pocFile(InternalFS); // open file handle (read or write)
uint8_t g_pocWriteTrack = 0; // track slot being written via BLE
#else
// Ring buffer between BLE callbacks and loop() flash writes.
// Callbacks return immediately; flash I/O (especially sector erase ~100ms)
// happens in loop() so the SoftDevice receive buffer never stalls.
// Size: IS25LP128F sector erase takes up to 300 ms; at ~56 KB/s upload rate
// that is ~17 KB of incoming data. 32 KB covers it with margin.
#define BLE_FLASH_BUF 32768u
// Callbacks return immediately; flash I/O happens in loop() so the SoftDevice
// receive buffer never stalls. Sector erase is async (non-blocking): bleFlashTick()
// kicks off the erase and returns; loop() polls completion each iteration. This
// prevents the ring buffer from filling during the ~30300 ms erase window.
// Buffer: 64 KB handles worst-case 300 ms erase at up to ~200 KB/s BLE throughput.
#define BLE_FLASH_BUF 65536u
static uint8_t g_bleBuf[BLE_FLASH_BUF];
static volatile uint32_t g_bleBufHead = 0; // advanced by BLE callback
static volatile uint32_t g_bleBufTail = 0; // advanced by loop()
static uint32_t g_bleFlashAddr = 0; // current flash write head
static uint32_t g_bleFlashStart = 0; // address where this upload began
static uint32_t g_bleFlashErased = 0; // end of last erased sector
static uint32_t g_bleFlashErased = 0; // upper boundary of erased flash
static bool g_bleErasing = false; // async sector erase in progress
static uint32_t g_bleNotifyThresh = 0; // next addr to send progress notify
static uint8_t g_bleWriteTrack = 0;
static volatile bool g_bleFinalizing = false;
@@ -306,6 +310,17 @@ void flashWaitBusy() {
SPI.endTransaction();
}
// Non-blocking busy check: reads the WIP bit without spinning.
bool flashIsBusy() {
SPI.beginTransaction(flashSPI);
flashSelect();
SPI.transfer(CMD_READ_SR1);
uint8_t sr = SPI.transfer(0);
flashDeselect();
SPI.endTransaction();
return (sr & 0x01) != 0;
}
void flashWriteEnable() {
SPI.beginTransaction(flashSPI);
flashSelect();
@@ -564,12 +579,26 @@ bool msc_start_stop_cb(uint8_t power_condition, bool start, bool load_eject) {
void ble_connect_cb(uint16_t conn_handle) {
Serial.println("BLE connected");
g_bleConnected = true;
g_lastActivity = millis();
// Request fast connection parameters and maximum throughput features.
// The central may accept, renegotiate, or ignore these — all safe.
BLEConnection* conn = Bluefruit.Connection(conn_handle);
if (conn) {
conn->requestConnectionParameter(6); // 6×1.25ms = 7.5ms interval
conn->requestMtuExchange(247); // 244-byte ATT payload
conn->requestDataLengthUpdate(); // LE Data Length Extension
conn->requestPHY(BLE_GAP_PHY_2MBPS); // 2M PHY if supported
}
}
void ble_disconnect_cb(uint16_t conn_handle, uint8_t reason) {
Serial.println("BLE disconnected");
g_bleConnected = false;
g_bleUploading = false;
g_bleFinalizing = false;
g_bleErasing = false; // stop tracking the in-progress erase; it will finish in HW
}
// BLE command characteristic: receives commands
@@ -627,8 +656,10 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
// Track 0 resets allocation; subsequent tracks append
if (g_bleWriteTrack == 0) g_flashNextFree = AUDIO_START_ADDR;
g_bleFlashAddr = g_flashNextFree;
g_bleFlashStart = g_bleFlashAddr; // save start for finalize
g_bleFlashStart = g_bleFlashAddr;
g_bleFlashErased = g_bleFlashAddr; // nothing erased yet
g_bleErasing = false;
g_bleNotifyThresh = g_bleFlashAddr;
g_bleBufHead = g_bleBufTail = 0;
g_bleFinalizing = false;
g_bleUploading = true;
@@ -763,37 +794,92 @@ void audioData_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
// and writes the track table).
}
// Drain the BLE ring buffer to SPI flash one page at a time.
// Called from loop() to keep flash I/O off the BLE callback thread.
// Drain the BLE ring buffer to SPI flash.
// Sector erases are asynchronous: we issue the erase command and return immediately
// so loop() keeps running (and the ring buffer keeps draining from BLE callbacks).
// On the next call we poll the WIP bit to confirm completion before writing.
// This prevents the 30300 ms erase window from filling the ring buffer.
// A pre-erase is also kicked off as soon as we start writing each sector so
// the next sector is ready before we reach it. Writes stop while any erase is
// in progress because the flash ignores page-program when WIP=1.
#ifndef POC_INTERNAL_FLASH
static void bleFlashTick() {
// Drain as much as we can without blocking too long
if (!g_bleUploading && !g_bleFinalizing) return;
// Poll async sector erase completion.
if (g_bleErasing && !flashIsBusy()) {
g_bleErasing = false;
g_bleFlashErased += FLASH_SECTOR;
}
// Drain all available ring-buffer data into flash, one page per iteration.
while (g_bleBufHead != g_bleBufTail) {
if (g_bleErasing) break; // never write while an erase is in progress
uint32_t avail = (g_bleBufHead - g_bleBufTail + BLE_FLASH_BUF) % BLE_FLASH_BUF;
uint16_t pageOff = (uint16_t)(g_bleFlashAddr % FLASH_PAGE);
uint16_t chunk = (uint16_t)min((uint32_t)(FLASH_PAGE - pageOff), avail);
if (chunk == 0) break;
// Lazy erase: erase the next sector just before we write into it
while (g_bleFlashAddr + chunk > g_bleFlashErased) {
flashEraseSector(g_bleFlashErased);
g_bleFlashErased += FLASH_SECTOR;
if (g_bleFlashAddr + chunk > g_bleFlashErased) {
// Write pointer has reached the erased boundary — need another sector erased.
if (!g_bleErasing) {
flashWriteEnable();
SPI.beginTransaction(flashSPI);
flashSelect();
SPI.transfer(CMD_SECT_ERASE);
SPI.transfer((g_bleFlashErased >> 16) & 0xFF);
SPI.transfer((g_bleFlashErased >> 8) & 0xFF);
SPI.transfer( g_bleFlashErased & 0xFF);
flashDeselect();
SPI.endTransaction();
g_bleErasing = true;
// Do NOT advance g_bleFlashErased — wait for WIP confirmation next call.
}
break; // return to loop(); erase runs in HW, ring buffer fills freely
}
// Copy chunk from ring buffer into a temp page buffer
// Erase boundary is ahead — safe to program this page.
uint8_t tmp[FLASH_PAGE];
for (uint16_t i = 0; i < chunk; i++) {
tmp[i] = g_bleBuf[(g_bleBufTail + i) % BLE_FLASH_BUF];
}
flashPageProgram(g_bleFlashAddr, tmp, chunk);
flashPageProgram(g_bleFlashAddr, tmp, chunk); // ~0.5 ms blocking
g_bleBufTail = (g_bleBufTail + chunk) % BLE_FLASH_BUF;
g_bleFlashAddr += chunk;
// Yield after each page so the rest of loop() stays responsive
break;
// Pre-erase: kick off the next sector erase as soon as we start writing
// the current sector so the erase (~30 ms typ) completes before we need it.
// Break immediately — never write while an async erase is in progress since
// the flash chip silently ignores page-program commands when WIP=1.
if (!g_bleErasing && g_bleFlashAddr > g_bleFlashErased - FLASH_SECTOR) {
flashWriteEnable();
SPI.beginTransaction(flashSPI);
flashSelect();
SPI.transfer(CMD_SECT_ERASE);
SPI.transfer((g_bleFlashErased >> 16) & 0xFF);
SPI.transfer((g_bleFlashErased >> 8) & 0xFF);
SPI.transfer( g_bleFlashErased & 0xFF);
flashDeselect();
SPI.endTransaction();
g_bleErasing = true;
break; // wait for erase; ring buffer fills freely in the meantime
}
// Finalize when CMD 0x03 received and ring buffer is empty
// Periodic progress notification — keeps the Windows BLE stack from
// dropping the connection during long silent uploads, and gives the
// client real flash-write progress (not just BLE-send progress).
if (g_bleConnected && g_bleFlashAddr - g_bleNotifyThresh >= 4096u) {
g_bleNotifyThresh = g_bleFlashAddr;
uint32_t written = g_bleFlashAddr - g_bleFlashStart;
uint8_t stat[4] = {
(uint8_t)(written >> 24), (uint8_t)(written >> 16),
(uint8_t)(written >> 8), (uint8_t)(written)
};
audioStat.notify(stat, 4);
}
}
// Finalize when CMD 0x03 received and ring buffer is fully drained.
if (g_bleFinalizing && g_bleBufHead == g_bleBufTail) {
uint32_t startAddr = g_bleFlashStart;
uint32_t bytesStored = g_bleFlashAddr - g_bleFlashStart;
@@ -806,6 +892,7 @@ static void bleFlashTick() {
g_bleUploading = false;
g_bleFinalizing = false;
g_bleErasing = false; // pre-erase of unused sector, if any, will finish in HW
writeTrackTable();
loadTrackTable();
Serial.printf("BLE: Upload finalized — start=0x%08lX stored=%lu bytes\n", startAddr, bytesStored);
@@ -856,11 +943,15 @@ void setupBLE() {
audioStat.setMaxLen(4);
audioStat.begin();
// Start advertising
// Start advertising.
// The 128-bit service UUID (18 bytes) plus flags + TxPower nearly fills the
// 31-byte advertisement, leaving room for only ~5 name characters — which
// truncated the name to "BabyM" over the air. Put the full name in the scan
// response (its own separate 31 bytes) so scanners see "BabyMobile" intact.
Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
Bluefruit.Advertising.addTxPower();
Bluefruit.Advertising.addService(audioSvc);
Bluefruit.Advertising.addName();
Bluefruit.ScanResponse.addName(); // full name here, not in the ad packet
Bluefruit.Advertising.restartOnDisconnect(true);
Bluefruit.Advertising.setInterval(160, 320); // 100-200ms
Bluefruit.Advertising.start(0); // advertise forever
@@ -1401,22 +1492,46 @@ 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);
if (InternalFS.remove(fname)) {
Serial.print("DELETED "); Serial.println(fname);
} else {
Serial.print("ERR no file "); Serial.println(fname);
}
loadTrackTable();
} else if (strcmp(g_serLineBuf, "FORMAT") == 0) {
audioStop();
#ifdef POC_INTERNAL_FLASH
Serial.println("Formatting LittleFS...");
InternalFS.format();
g_numTracks = 0;
#else
Serial.println("Clearing track table...");
g_numTracks = 0;
g_flashNextFree = AUDIO_START_ADDR;
writeTrackTable();
loadTrackTable();
#endif
Serial.println("FORMAT OK");
} else if (sscanf(g_serLineBuf, "DELETE %u", &utrk) == 1) {
#ifdef POC_INTERNAL_FLASH
char fname[16];
pocFilename((uint8_t)utrk, fname);
if (InternalFS.remove(fname)) {
Serial.print("DELETED "); Serial.println(utrk);
} else {
Serial.print("ERR no file "); Serial.println(utrk);
}
loadTrackTable();
#else
if ((uint8_t)utrk < g_numTracks) {
for (uint8_t j = (uint8_t)utrk; j < g_numTracks - 1; j++) {
g_trackStart[j] = g_trackStart[j+1];
g_trackLen[j] = g_trackLen[j+1];
g_trackBits[j] = g_trackBits[j+1];
g_trackRate[j] = g_trackRate[j+1];
g_trackDataOff[j] = g_trackDataOff[j+1];
}
g_numTracks--;
writeTrackTable();
loadTrackTable();
Serial.print("DELETED "); Serial.println(utrk);
} else {
Serial.print("ERR no track "); Serial.println(utrk);
}
#endif
} else {
Serial.print("ERR bad cmd: ");
@@ -1430,6 +1545,7 @@ static void serUploadTick() {
}
}
} else { // SER_RECEIVING
g_lastActivity = millis(); // keep device awake during long transfers
uint8_t chunk[64];
while (Serial.available() && g_serBytesReceived < g_serBytesExpected) {
int n = Serial.readBytes(chunk, min((int)sizeof(chunk),
@@ -1498,7 +1614,7 @@ static void serUploadTick() {
}
void loop() {
if (g_playing) g_lastActivity = millis();
if (g_playing || g_bleUploading) g_lastActivity = millis();
// ---- Serial upload ----
serUploadTick();
@@ -1592,14 +1708,18 @@ void loop() {
}
// ---- Auto shutoff / idle sleep ----
// Never sleep while BLE is connected — upload may be in progress or about
// to begin, and the CMD callback sets g_bleUploading asynchronously.
if (!g_bleConnected) {
if (millis() - g_lastActivity > AUTO_OFF_MS) {
enterDeepSleep();
}
if (!g_playing && !g_motorOn && !g_usbConnected && !g_bleUploading) {
if (!g_playing && !g_motorOn && !g_usbConnected) {
if (millis() - g_lastActivity > IDLE_SLEEP_MS) {
enterDeepSleep();
}
}
}
}
+17 -29
View File
@@ -7,9 +7,9 @@ Usage:
Supported formats: .wav, .mp3, .ogg, .flac, .aac, .m4a, .raw (anything ffmpeg handles)
Output format: 16-bit signed PCM WAV, 16 kHz, mono (WAV header included).
Output format: 8-bit unsigned PCM WAV, 8 kHz, mono (WAV header included).
- Already-conformant WAV files (PCM, mono, 8 or 16-bit, 8/16/32 kHz) are sent as-is.
- All other formats are converted via ffmpeg to 16-bit 16 kHz mono WAV.
- All other formats are converted via ffmpeg to 8-bit 8 kHz mono WAV.
- .raw files are treated as legacy 8-bit unsigned 16 kHz and wrapped in a WAV header.
Requires: pyserial (pip install pyserial)
@@ -24,7 +24,7 @@ import subprocess
import serial
import time
CHUNK_SIZE = 512
CHUNK_SIZE = 4096
BAUD_RATE = 115200
TIMEOUT_S = 10.0
@@ -92,10 +92,10 @@ def load_wav(path: str) -> bytes:
f" ffmpeg -i \"{path}\" -ar 16000 -ac 1 -acodec pcm_s16le out.wav"
)
print(f"Converting {os.path.basename(path)} via ffmpeg -> 16-bit 16 kHz mono WAV...")
print(f"Converting {os.path.basename(path)} via ffmpeg -> 8-bit 8 kHz mono WAV...")
result = subprocess.run(
['ffmpeg', '-y', '-i', path,
'-ar', '16000', '-ac', '1', '-f', 'wav', '-acodec', 'pcm_s16le', 'pipe:1'],
'-ar', '8000', '-ac', '1', '-f', 'wav', '-acodec', 'pcm_u8', 'pipe:1'],
capture_output=True
)
if result.returncode != 0:
@@ -141,41 +141,29 @@ def upload(port: str, track_num: int, wav: bytes) -> None:
if line:
print(f" firmware: {line}")
# Stream WAV data in chunks; poll for ERR
# Stream WAV data without per-chunk polling — the firmware only ever sends
# ERR mid-transfer on a flash write failure (very rare); polling after every
# chunk adds 50 ms of readline timeout overhead per packet, which for a large
# file at USB CDC speeds amounts to many minutes of wasted time.
sent = 0
err_line = None
ser.timeout = 0.05
ser.timeout = 0 # non-blocking reads while sending
while sent < total:
chunk = wav[sent:sent + CHUNK_SIZE]
ser.write(chunk)
sent += len(chunk)
pct = sent * 100 // total
print(f"\r {sent}/{total} bytes ({pct}%) ", end="", flush=True)
line = ser.readline().decode(errors="replace").strip()
# Drain any early ERR the firmware might have sent
line = ser.read(ser.in_waiting).decode(errors="replace").strip()
if line.startswith("ERR"):
err_line = line
break
print()
print(f"ERROR from firmware: {line}")
sys.exit(1)
print()
ser.timeout = 2.0
if err_line:
print(f"ERROR from firmware: {err_line}")
if "at=" in err_line:
accepted = int(err_line.split("at=")[1].split()[0])
safe = int(accepted * 0.9)
# estimate audio bytes (subtract header)
audio_accepted = max(0, safe - 44)
bits = struct.unpack_from('<H', wav, 34)[0] if len(wav) >= 44 else 16
rate = struct.unpack_from('<I', wav, 24)[0] if len(wav) >= 44 else 16000
max_s = audio_accepted / (bits // 8) / rate
print(f" LittleFS accepted {accepted} bytes before full.")
print(f" Safe clip length: ~{max_s:.1f}s")
print(f" Trim with: ffmpeg -i input.mp3 -t {max_s:.0f} -ar 16000 -ac 1 "
f"-acodec pcm_s16le out.wav")
sys.exit(1)
# Wait for OK
deadline = time.time() + TIMEOUT_S
# Wait for OK — allow up to 60 s for the final sector erase + flash writes
deadline = time.time() + 60.0
while True:
if time.time() > deadline:
sys.exit("Timed out waiting for OK")