fixed most upload issues, updated schematic
This commit is contained in:
+112
-26
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user