// 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 = `${currentFile.name} → ${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 = `
  • ${onDevice ? 'No tracks on the device yet.' : 'Connect to list device tracks.'}
  • `; return; } for (const t of rows) { const li = document.createElement('li'); const title = t.name || `Track ${t.idx}`; li.innerHTML = `
    ${t.idx}` + `
    ${title}${t.bits}-bit ${t.rate / 1000}kHz · ${(+t.durationSec).toFixed(1)}s
    `; 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(() => {}); }