Files
baby-mobile/app/app.js
T
2026-07-04 03:28:14 -07:00

319 lines
12 KiB
JavaScript

// app.js — UI glue for the BabyMobile manager PWA.
import { BabyMobile, isSupported, MAX_TRACKS } from './ble.js';
import { decodeFileToMono, encodeWav, trimSamples, 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
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)) || {}; }
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]); });
// Decode the file (expensive) once per file/rate, then re-encode cheaply when
// the user tweaks bit-depth or trim.
async function handleFile(file) {
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 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.';
}
}
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%';
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;
}
});
// ---- 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, 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) {
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 {
if (!confirm(`Forget ${label} from this browser's list? (It only clears the local record.)`)) return;
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,
}));
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;
}
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)));
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, t.name));
actions.append(playBtn, stopBtn, 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);
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()) {
$('unsupported').hidden = false;
$('btn-connect').disabled = true;
}
renderSlots();
setConnected(false);
// ---- PWA service worker ----
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js').catch(() => {});
}