diff --git a/app/README.md b/app/README.md index 1bbbd03..c98c8c2 100644 --- a/app/README.md +++ b/app/README.md @@ -25,9 +25,21 @@ Pairs with the firmware in `../baby_mobile_v2/baby_mobile_v2.ino`. | | UUID | Use | |---|---|---| | Service | `12340001-…` | advertised | -| Cmd (write) | `12340002-…` | `0x02 ` start upload · `0x03` finish · `0x04 ` play · `0x05` stop · `0x06` list · `0x07 ` delete | -| Data (write-without-response) | `12340003-…` | raw WAV bytes, chunked to 180 B | -| Status (notify) | `12340004-…` | tagged by byte 0: `0x00…` upload byte count (uint32 BE) · `0x80\|idx, bits, kHz, secs` list entry · `0xFF, n` list end · `0xD0, idx, ok` delete reply | +| Cmd (write) | `12340002-…` | `0x02 ` start upload · `0x03` finish · `0x04 ` play · `0x05` stop · `0x06` list · `0x07 ` delete · `0x08 ` set volume (persisted) · `0x09` get volume | +| Data (write-without-response) | `12340003-…` | raw WAV bytes, chunked to MTU−3 | +| Status (notify) | `12340004-…` | tagged by byte 0: `0x00…` upload byte count (uint32 BE) · `0x80\|idx, bits, kHz, secs` list entry · `0xC0, pct` volume reply · `0xD0, idx, ok` delete reply · `0xFF, n` list end | + +### Volume + +- **Global volume** is a master gain on the device (Q8 fixed-point, applied to all + playback). It's persisted to LittleFS (`/volume`) so it survives reboots. The + app reads it on connect (`0x09`) and sets it via the Playback-volume slider + (`0x08`). Lowering it reduces the PWM swing → less amp output → less supply + current, which mitigates the regulator brown-out at high volume. +- **Per-file level** is baked into the WAV in the browser *before upload* (the + "This file's level" slider scales the samples). Effective loudness = + global × per-file. Note: attenuating an 8-bit clip costs dynamic range, so use + 16-bit if you plan to run a file much quieter than the rest. ## Running it diff --git a/app/app.js b/app/app.js index 5cbf849..211b27f 100644 --- a/app/app.js +++ b/app/app.js @@ -38,8 +38,29 @@ function fmtBytes(n) { // ---- 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(); }); +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'; @@ -69,6 +90,11 @@ $('drop').addEventListener('drop', (e) => { }); 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) { @@ -83,9 +109,14 @@ async function handleFile(file) { try { const rate = parseInt($('rate').value, 10); decoded = await decodeFileToMono(file, rate); - $('trim-start').value = '0'; - $('trim-end').value = '0'; + 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) + @@ -93,33 +124,144 @@ async function handleFile(file) { } } +// Current selection as the values encodeWav wants (trimEnd = seconds off the tail). function trimValues() { - return { - trimStart: Math.max(0, parseFloat($('trim-start').value) || 0), - trimEnd: Math.max(0, parseFloat($('trim-end').value) || 0), - }; + 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 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)` : ''; + 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${trimmed} · ${fmtBytes(wav.byteLength)} (incl. 44-byte WAV header)`; + `${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', 'trim-start', 'trim-end'].forEach((id) => $(id).addEventListener('input', () => { stopPreview(); reEncode(); })); +$('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; @@ -137,8 +279,10 @@ $('btn-preview').addEventListener('click', () => { 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); - buf.getChannelData(0).set(slice); + 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); @@ -149,6 +293,7 @@ $('btn-preview').addEventListener('click', () => { // ---- 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)) { @@ -275,7 +420,6 @@ function fillSelect(sel, values, fmt, selected) { } 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() { diff --git a/app/ble.js b/app/ble.js index 387a057..328626f 100644 --- a/app/ble.js +++ b/app/ble.js @@ -23,14 +23,18 @@ export const CMD_PLAY = 0x04; export const CMD_STOP = 0x05; export const CMD_LIST = 0x06; export const CMD_DELETE = 0x07; +export const CMD_SET_VOL = 0x08; +export const CMD_GET_VOL = 0x09; // Status-notification tags in byte 0 (see firmware): // 0x00 → upload progress: whole packet is uint32 BE byte count (files <16 MB) // 0x80|idx → list entry: [0x80|idx, bits, rate_kHz, duration_s] -// 0xFF → list end: [0xFF, num_tracks, 0, 0] +// 0xC0 → volume reply: [0xC0, percent, 0, 0] // 0xD0 → delete reply: [0xD0, idx, success, 0] +// 0xFF → list end: [0xFF, num_tracks, 0, 0] const TAG_LIST_END = 0xff; const TAG_DELETE = 0xd0; +const TAG_VOLUME = 0xc0; export const MAX_TRACKS = 32; @@ -159,6 +163,8 @@ export class BabyMobile extends EventTarget { if (tag === TAG_LIST_END && dv.byteLength >= 2) { this._emit('list-end', { numTracks: dv.getUint8(1) }); + } else if (tag === TAG_VOLUME && dv.byteLength >= 2) { + this._emit('volume', { percent: dv.getUint8(1) }); } else if (tag === TAG_DELETE && dv.byteLength >= 3) { this._emit('deleted', { idx: dv.getUint8(1), success: dv.getUint8(2) === 1 }); } else if ((tag & 0x80) && (tag & 0x7f) < MAX_TRACKS && dv.byteLength >= 4) { @@ -180,7 +186,7 @@ export class BabyMobile extends EventTarget { async _writeCmd(bytes) { this._requireConnected(); - await this.cmd.writeValue(Uint8Array.from(bytes)); + await this.cmd.writeValueWithoutResponse(Uint8Array.from(bytes)); } async play(track) { @@ -193,6 +199,25 @@ export class BabyMobile extends EventTarget { this._log('⏹ Stop'); } + // Set the persistent global volume (0–255 % of unity). The device echoes the + // applied value via a 'volume' event. + async setVolume(percent) { + const p = Math.max(0, Math.min(255, Math.round(percent))); + await this._writeCmd([CMD_SET_VOL, p]); + } + + // Ask the device for its current global volume; resolves to the percent. + async getVolume(timeoutMs = 3000) { + this._requireConnected(); + return new Promise((resolve, reject) => { + const onVol = (e) => { cleanup(); resolve(e.detail.percent); }; + const cleanup = () => { clearTimeout(timer); this.removeEventListener('volume', onVol); }; + const timer = setTimeout(() => { cleanup(); reject(new Error('Volume read timed out')); }, timeoutMs); + this.addEventListener('volume', onVol); + this._writeCmd([CMD_GET_VOL]).catch((err) => { cleanup(); reject(err); }); + }); + } + // Returns [{ idx, bits, rate, durationSec }], sorted by slot. async listTracks(timeoutMs = 4000) { this._requireConnected(); @@ -257,24 +282,17 @@ export class BabyMobile extends EventTarget { this._log(`Starting upload of ${total} bytes to slot ${track}…`); await this._writeCmd([CMD_UPLOAD_START, track & 0xff]); - // 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); + // Send all data chunks sequentially. Chrome on Windows (WinRT BLE) only + // allows one writeValueWithoutResponse in flight at a time; concurrent calls + // fail with "GATT operation already in progress". Sequential writes are + // reliable and throughput is governed by the BLE connection interval, not + // by how many calls are in flight. + for (let off = 0; off < total; off += DATA_CHUNK) { + await this.data.writeValueWithoutResponse( + wavBytes.subarray(off, Math.min(off + DATA_CHUNK, total))); // 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))); + report(Math.min(off + DATA_CHUNK, Math.floor(total * 0.99))); } // Tell the firmware we're done. It will drain its ring buffer, write the @@ -302,20 +320,22 @@ export class BabyMobile extends EventTarget { return new Promise((resolve) => { if (this._statBytes >= target) return resolve(); const deadline = Date.now() + timeoutMs; - const tick = () => { - if (this._statBytes >= target || Date.now() > deadline || !this.connected) { - this.removeEventListener('progress', tick); - resolve(); - } + const cleanup = () => { + clearInterval(poll); + this.removeEventListener('progress', tick); + this.removeEventListener('disconnected', onDisconnect); }; + const tick = () => { + if (this._statBytes >= target || Date.now() > deadline) { cleanup(); resolve(); } + }; + // Resolve on a real disconnect rather than polling .connected, which can + // return false momentarily on some BLE stacks even when still connected. + const onDisconnect = () => { cleanup(); resolve(); }; this.addEventListener('progress', tick); - // Fallback poll in case no further notifications arrive. + this.addEventListener('disconnected', onDisconnect); + // Fallback poll for timeout and when no further notifications arrive. const poll = setInterval(() => { - if (this._statBytes >= target || Date.now() > deadline || !this.connected) { - clearInterval(poll); - this.removeEventListener('progress', tick); - resolve(); - } + if (this._statBytes >= target || Date.now() > deadline) { cleanup(); resolve(); } }, 200); }); } diff --git a/app/index.html b/app/index.html index f4c72d1..fd8630b 100644 --- a/app/index.html +++ b/app/index.html @@ -39,10 +39,31 @@ -