463 lines
17 KiB
JavaScript
463 lines
17 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(); fetchVolume(); });
|
|
dev.addEventListener('disconnected', () => {
|
|
setConnected(false); deviceTracks = null; renderSlots();
|
|
$('volume-val').textContent = '—';
|
|
});
|
|
dev.addEventListener('volume', (e) => setVolumeUI(e.detail.percent));
|
|
|
|
// ---- global (device) volume ----
|
|
function setVolumeUI(pct) {
|
|
$('volume').value = String(pct);
|
|
$('volume-val').textContent = `${pct}%`;
|
|
}
|
|
async function fetchVolume() {
|
|
try { setVolumeUI(await dev.getVolume()); }
|
|
catch (err) { log('Volume read failed: ' + (err.message || err)); }
|
|
}
|
|
let volTimer = null;
|
|
$('volume').addEventListener('input', () => {
|
|
const pct = parseInt($('volume').value, 10);
|
|
$('volume-val').textContent = `${pct}%`;
|
|
clearTimeout(volTimer);
|
|
volTimer = setTimeout(() => { dev.setVolume(pct).catch((e) => log('' + e)); }, 120);
|
|
});
|
|
|
|
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]); });
|
|
|
|
const MIN_CLIP = 0.05; // shortest selectable clip, seconds
|
|
let fullDur = 0; // full decoded duration in seconds
|
|
let selStart = 0; // selection start, seconds
|
|
let selEnd = 0; // selection end (kept), seconds
|
|
|
|
// 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);
|
|
fullDur = decoded.samples.length / decoded.rate;
|
|
selStart = 0;
|
|
selEnd = fullDur;
|
|
$('track-level').value = '100';
|
|
$('track-level-val').textContent = '100%';
|
|
$('trim').hidden = false;
|
|
drawWave();
|
|
layoutTrim();
|
|
reEncode();
|
|
} catch (err) {
|
|
$('convert-info').textContent = 'Could not decode this file: ' + (err.message || err) +
|
|
'. Try a WAV/MP3/M4A the browser can decode.';
|
|
}
|
|
}
|
|
|
|
// Current selection as the values encodeWav wants (trimEnd = seconds off the tail).
|
|
function trimValues() {
|
|
return { trimStart: selStart, trimEnd: fullDur - selEnd };
|
|
}
|
|
|
|
// Apply a new selection (clamped + ordered), then repaint handles/region/readout.
|
|
function setSelection(start, end) {
|
|
selStart = Math.max(0, Math.min(start, fullDur - MIN_CLIP));
|
|
selEnd = Math.min(fullDur, Math.max(end, selStart + MIN_CLIP));
|
|
layoutTrim();
|
|
stopPreview();
|
|
scheduleReEncode();
|
|
}
|
|
|
|
// Position the two handles + the highlighted region and refresh the readout.
|
|
// Skips overwriting a time field while it's being edited.
|
|
function layoutTrim() {
|
|
const a = fullDur ? selStart / fullDur : 0;
|
|
const b = fullDur ? selEnd / fullDur : 1;
|
|
$('handle-start').style.left = `${a * 100}%`;
|
|
$('handle-end').style.left = `${b * 100}%`;
|
|
$('trim-region').style.left = `${a * 100}%`;
|
|
$('trim-region').style.right = `${(1 - b) * 100}%`;
|
|
$('handle-start').setAttribute('aria-valuetext', `${selStart.toFixed(2)}s`);
|
|
$('handle-end').setAttribute('aria-valuetext', `${selEnd.toFixed(2)}s`);
|
|
if (document.activeElement !== $('trim-start-s')) $('trim-start-s').value = selStart.toFixed(2);
|
|
if (document.activeElement !== $('trim-end-s')) $('trim-end-s').value = selEnd.toFixed(2);
|
|
$('trim-clip').textContent = `${(selEnd - selStart).toFixed(2)}s clip`;
|
|
}
|
|
|
|
// ---- drag + keyboard + typed-entry on the trim handles ----
|
|
function secAtClientX(clientX) {
|
|
const rect = $('wave').getBoundingClientRect();
|
|
const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
return frac * fullDur;
|
|
}
|
|
for (const which of ['start', 'end']) {
|
|
const el = $(`handle-${which}`);
|
|
el.addEventListener('pointerdown', (e) => {
|
|
e.preventDefault();
|
|
el.focus();
|
|
el.setPointerCapture(e.pointerId);
|
|
el.classList.add('dragging');
|
|
const move = (ev) => {
|
|
const s = secAtClientX(ev.clientX);
|
|
if (which === 'start') setSelection(s, selEnd);
|
|
else setSelection(selStart, s);
|
|
};
|
|
const up = () => {
|
|
el.classList.remove('dragging');
|
|
el.removeEventListener('pointermove', move);
|
|
el.removeEventListener('pointerup', up);
|
|
};
|
|
el.addEventListener('pointermove', move);
|
|
el.addEventListener('pointerup', up);
|
|
});
|
|
el.addEventListener('keydown', (e) => {
|
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
e.preventDefault();
|
|
const d = (e.key === 'ArrowRight' ? 1 : -1) * (e.shiftKey ? 0.1 : 0.01);
|
|
if (which === 'start') setSelection(selStart + d, selEnd);
|
|
else setSelection(selStart, selEnd + d);
|
|
});
|
|
}
|
|
// Editable time fields for exact entry (commit on change/Enter).
|
|
$('trim-start-s').addEventListener('change', () => {
|
|
const v = parseFloat($('trim-start-s').value);
|
|
setSelection(isNaN(v) ? 0 : v, selEnd);
|
|
});
|
|
$('trim-end-s').addEventListener('change', () => {
|
|
const v = parseFloat($('trim-end-s').value);
|
|
setSelection(selStart, isNaN(v) ? fullDur : v);
|
|
});
|
|
|
|
// Draw a min/max waveform of the full (pre-trim) samples onto the canvas.
|
|
function drawWave() {
|
|
const canvas = $('wave');
|
|
if (!decoded || !canvas.clientWidth) return;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const w = canvas.clientWidth, h = canvas.clientHeight;
|
|
canvas.width = w * dpr; canvas.height = h * dpr;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.scale(dpr, dpr);
|
|
ctx.clearRect(0, 0, w, h);
|
|
const s = decoded.samples;
|
|
const mid = h / 2;
|
|
const step = Math.max(1, Math.floor(s.length / w));
|
|
ctx.fillStyle = '#7c4dff';
|
|
for (let x = 0; x < w; x++) {
|
|
let min = 1, max = -1;
|
|
const base = x * step;
|
|
for (let i = 0; i < step && base + i < s.length; i++) {
|
|
const v = s[base + i];
|
|
if (v < min) min = v;
|
|
if (v > max) max = v;
|
|
}
|
|
const y1 = mid - max * mid;
|
|
ctx.fillRect(x, y1, 1, Math.max(1, (mid - min * mid) - y1));
|
|
}
|
|
}
|
|
|
|
// 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 gain = parseInt($('track-level').value, 10) / 100;
|
|
const { trimStart, trimEnd } = trimValues();
|
|
const { wav, durationSec } = encodeWav(decoded.samples, decoded.rate, bits, { trimStart, trimEnd, gain });
|
|
pendingWav = { wav, durationSec, rate: decoded.rate, bits, level: Math.round(gain * 100), name: currentFile.name };
|
|
const lvl = gain !== 1 ? ` · ${Math.round(gain * 100)}% level` : '';
|
|
$('convert-info').innerHTML =
|
|
`<b>${currentFile.name}</b> → ${bits}-bit ${decoded.rate / 1000} kHz mono · ` +
|
|
`${durationSec.toFixed(1)}s${lvl} · ${fmtBytes(wav.byteLength)} (incl. 44-byte WAV header)`;
|
|
$('btn-upload').disabled = !dev.connected || durationSec <= 0;
|
|
$('btn-preview').disabled = durationSec <= 0;
|
|
}
|
|
|
|
// Coalesce rapid slider events: update the UI immediately, re-encode once per frame.
|
|
let reEncodeQueued = false;
|
|
function scheduleReEncode() {
|
|
if (reEncodeQueued) return;
|
|
reEncodeQueued = true;
|
|
requestAnimationFrame(() => { reEncodeQueued = false; reEncode(); });
|
|
}
|
|
|
|
// Changing the rate needs a fresh decode; bits/trim only need a re-encode.
|
|
$('rate').addEventListener('change', () => { if (currentFile) handleFile(currentFile); });
|
|
$('bits').addEventListener('input', () => { stopPreview(); reEncode(); });
|
|
$('track-level').addEventListener('input', () => {
|
|
$('track-level-val').textContent = `${$('track-level').value}%`;
|
|
stopPreview(); scheduleReEncode();
|
|
});
|
|
// Redraw the waveform on viewport resize (rotation / window size change).
|
|
let resizeTimer = null;
|
|
window.addEventListener('resize', () => {
|
|
clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(() => { if (decoded) drawWave(); }, 150);
|
|
});
|
|
|
|
// ---- 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 gain = parseInt($('track-level').value, 10) / 100;
|
|
const buf = previewCtx.createBuffer(1, slice.length, decoded.rate);
|
|
const ch = buf.getChannelData(0);
|
|
for (let i = 0; i < slice.length; i++) ch[i] = Math.max(-1, Math.min(1, slice[i] * gain));
|
|
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 () => {
|
|
reEncode(); // ensure pendingWav matches the current sliders/format (rAF may be pending)
|
|
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);
|
|
|
|
// 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(() => {});
|
|
}
|