improved web ui, upload is sorta better, 5sec advertise btn

This commit is contained in:
zyphlar
2026-07-04 03:28:14 -07:00
parent 8cba579780
commit d3bd7b71b9
8 changed files with 458 additions and 79 deletions
+15 -3
View File
@@ -25,9 +25,21 @@ Pairs with the firmware in `../baby_mobile_v2/baby_mobile_v2.ino`.
| | UUID | Use | | | UUID | Use |
|---|---|---| |---|---|---|
| Service | `12340001-…` | advertised | | Service | `12340001-…` | advertised |
| Cmd (write) | `12340002-…` | `0x02 <slot>` start upload · `0x03` finish · `0x04 <slot>` play · `0x05` stop · `0x06` list · `0x07 <slot>` delete | | Cmd (write) | `12340002-…` | `0x02 <slot>` start upload · `0x03` finish · `0x04 <slot>` play · `0x05` stop · `0x06` list · `0x07 <slot>` delete · `0x08 <pct>` set volume (persisted) · `0x09` get volume |
| Data (write-without-response) | `12340003-…` | raw WAV bytes, chunked to 180 B | | Data (write-without-response) | `12340003-…` | raw WAV bytes, chunked to MTU3 |
| 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 | | 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 ## Running it
+161 -17
View File
@@ -38,8 +38,29 @@ function fmtBytes(n) {
// ---- connection state ---- // ---- connection state ----
dev.addEventListener('log', (e) => log(e.detail)); dev.addEventListener('log', (e) => log(e.detail));
dev.addEventListener('connected', (e) => { setConnected(true, e.detail.name); refresh(); }); dev.addEventListener('connected', (e) => { setConnected(true, e.detail.name); refresh(); fetchVolume(); });
dev.addEventListener('disconnected', () => { setConnected(false); deviceTracks = null; renderSlots(); }); 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) { function setConnected(on, name) {
$('status-dot').className = on ? 'dot on' : 'dot'; $('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]); }); 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 // Decode the file (expensive) once per file/rate, then re-encode cheaply when
// the user tweaks bit-depth or trim. // the user tweaks bit-depth or trim.
async function handleFile(file) { async function handleFile(file) {
@@ -83,9 +109,14 @@ async function handleFile(file) {
try { try {
const rate = parseInt($('rate').value, 10); const rate = parseInt($('rate').value, 10);
decoded = await decodeFileToMono(file, rate); decoded = await decodeFileToMono(file, rate);
$('trim-start').value = '0'; fullDur = decoded.samples.length / decoded.rate;
$('trim-end').value = '0'; selStart = 0;
selEnd = fullDur;
$('track-level').value = '100';
$('track-level-val').textContent = '100%';
$('trim').hidden = false; $('trim').hidden = false;
drawWave();
layoutTrim();
reEncode(); reEncode();
} catch (err) { } catch (err) {
$('convert-info').textContent = 'Could not decode this file: ' + (err.message || 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() { function trimValues() {
return { return { trimStart: selStart, trimEnd: fullDur - selEnd };
trimStart: Math.max(0, parseFloat($('trim-start').value) || 0), }
trimEnd: Math.max(0, parseFloat($('trim-end').value) || 0),
// 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). // Re-encode the staged WAV from the cached decode (no re-decode).
function reEncode() { function reEncode() {
if (!decoded || !currentFile) return; if (!decoded || !currentFile) return;
const bits = parseInt($('bits').value, 10); const bits = parseInt($('bits').value, 10);
const gain = parseInt($('track-level').value, 10) / 100;
const { trimStart, trimEnd } = trimValues(); const { trimStart, trimEnd } = trimValues();
const fullDur = decoded.samples.length / decoded.rate; const { wav, durationSec } = encodeWav(decoded.samples, decoded.rate, bits, { trimStart, trimEnd, gain });
const { wav, durationSec } = encodeWav(decoded.samples, decoded.rate, bits, { trimStart, trimEnd }); pendingWav = { wav, durationSec, rate: decoded.rate, bits, level: Math.round(gain * 100), name: currentFile.name };
pendingWav = { wav, durationSec, rate: decoded.rate, bits, name: currentFile.name }; const lvl = gain !== 1 ? ` · ${Math.round(gain * 100)}% level` : '';
const trimmed = (trimStart || trimEnd) ? ` (trimmed from ${fullDur.toFixed(1)}s)` : '';
$('convert-info').innerHTML = $('convert-info').innerHTML =
`<b>${currentFile.name}</b> → ${bits}-bit ${decoded.rate / 1000} kHz mono<br>` + `<b>${currentFile.name}</b> → ${bits}-bit ${decoded.rate / 1000} kHz mono · ` +
`${durationSec.toFixed(1)}s${trimmed} · ${fmtBytes(wav.byteLength)} (incl. 44-byte WAV header)`; `${durationSec.toFixed(1)}s${lvl} · ${fmtBytes(wav.byteLength)} (incl. 44-byte WAV header)`;
$('btn-upload').disabled = !dev.connected || durationSec <= 0; $('btn-upload').disabled = !dev.connected || durationSec <= 0;
$('btn-preview').disabled = 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. // Changing the rate needs a fresh decode; bits/trim only need a re-encode.
$('rate').addEventListener('change', () => { if (currentFile) handleFile(currentFile); }); $('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 ---- // ---- local preview of the trimmed clip ----
let previewCtx = null; let previewCtx = null;
@@ -137,8 +279,10 @@ $('btn-preview').addEventListener('click', () => {
if (!slice.length) return; if (!slice.length) return;
const AC = window.AudioContext || window.webkitAudioContext; const AC = window.AudioContext || window.webkitAudioContext;
previewCtx = new AC(); previewCtx = new AC();
const gain = parseInt($('track-level').value, 10) / 100;
const buf = previewCtx.createBuffer(1, slice.length, decoded.rate); 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 = previewCtx.createBufferSource();
previewSrc.buffer = buf; previewSrc.buffer = buf;
previewSrc.connect(previewCtx.destination); previewSrc.connect(previewCtx.destination);
@@ -149,6 +293,7 @@ $('btn-preview').addEventListener('click', () => {
// ---- upload ---- // ---- upload ----
$('btn-upload').addEventListener('click', async () => { $('btn-upload').addEventListener('click', async () => {
reEncode(); // ensure pendingWav matches the current sliders/format (rAF may be pending)
if (!pendingWav) return; if (!pendingWav) return;
const track = parseInt($('upload-slot').value, 10); const track = parseInt($('upload-slot').value, 10);
if (occupiedSlots().has(track)) { if (occupiedSlots().has(track)) {
@@ -275,7 +420,6 @@ function fillSelect(sel, values, fmt, selected) {
} }
fillSelect($('rate'), RATES, (v) => `${v / 1000} kHz`, 8000); fillSelect($('rate'), RATES, (v) => `${v / 1000} kHz`, 8000);
fillSelect($('bits'), BITS, (v) => `${v}-bit`, 8); 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. // Which slots currently hold a track: device truth when connected, else local memory.
function occupiedSlots() { function occupiedSlots() {
+50 -30
View File
@@ -23,14 +23,18 @@ export const CMD_PLAY = 0x04;
export const CMD_STOP = 0x05; export const CMD_STOP = 0x05;
export const CMD_LIST = 0x06; export const CMD_LIST = 0x06;
export const CMD_DELETE = 0x07; 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): // Status-notification tags in byte 0 (see firmware):
// 0x00 → upload progress: whole packet is uint32 BE byte count (files <16 MB) // 0x00 → upload progress: whole packet is uint32 BE byte count (files <16 MB)
// 0x80|idx → list entry: [0x80|idx, bits, rate_kHz, duration_s] // 0x80|idx → list entry: [0x80|idx, bits, rate_kHz, duration_s]
// 0xFFlist end: [0xFF, num_tracks, 0, 0] // 0xC0volume reply: [0xC0, percent, 0, 0]
// 0xD0 → delete reply: [0xD0, idx, success, 0] // 0xD0 → delete reply: [0xD0, idx, success, 0]
// 0xFF → list end: [0xFF, num_tracks, 0, 0]
const TAG_LIST_END = 0xff; const TAG_LIST_END = 0xff;
const TAG_DELETE = 0xd0; const TAG_DELETE = 0xd0;
const TAG_VOLUME = 0xc0;
export const MAX_TRACKS = 32; export const MAX_TRACKS = 32;
@@ -159,6 +163,8 @@ export class BabyMobile extends EventTarget {
if (tag === TAG_LIST_END && dv.byteLength >= 2) { if (tag === TAG_LIST_END && dv.byteLength >= 2) {
this._emit('list-end', { numTracks: dv.getUint8(1) }); 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) { } else if (tag === TAG_DELETE && dv.byteLength >= 3) {
this._emit('deleted', { idx: dv.getUint8(1), success: dv.getUint8(2) === 1 }); this._emit('deleted', { idx: dv.getUint8(1), success: dv.getUint8(2) === 1 });
} else if ((tag & 0x80) && (tag & 0x7f) < MAX_TRACKS && dv.byteLength >= 4) { } else if ((tag & 0x80) && (tag & 0x7f) < MAX_TRACKS && dv.byteLength >= 4) {
@@ -180,7 +186,7 @@ export class BabyMobile extends EventTarget {
async _writeCmd(bytes) { async _writeCmd(bytes) {
this._requireConnected(); this._requireConnected();
await this.cmd.writeValue(Uint8Array.from(bytes)); await this.cmd.writeValueWithoutResponse(Uint8Array.from(bytes));
} }
async play(track) { async play(track) {
@@ -193,6 +199,25 @@ export class BabyMobile extends EventTarget {
this._log('⏹ Stop'); this._log('⏹ Stop');
} }
// Set the persistent global volume (0255 % 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. // Returns [{ idx, bits, rate, durationSec }], sorted by slot.
async listTracks(timeoutMs = 4000) { async listTracks(timeoutMs = 4000) {
this._requireConnected(); this._requireConnected();
@@ -257,24 +282,17 @@ export class BabyMobile extends EventTarget {
this._log(`Starting upload of ${total} bytes to slot ${track}`); this._log(`Starting upload of ${total} bytes to slot ${track}`);
await this._writeCmd([CMD_UPLOAD_START, track & 0xff]); await this._writeCmd([CMD_UPLOAD_START, track & 0xff]);
// Send all data chunks in pipelined batches. Chrome on Windows serialises // Send all data chunks sequentially. Chrome on Windows (WinRT BLE) only
// individually-awaited writeValueWithoutResponse calls (one Promise per OS // allows one writeValueWithoutResponse in flight at a time; concurrent calls
// event loop tick), which limits throughput to ~one packet per ~50 ms and // fail with "GATT operation already in progress". Sequential writes are
// turns a 1.4 MB upload into a 6-minute ordeal. Firing PIPE writes before // reliable and throughput is governed by the BLE connection interval, not
// awaiting them all fills multiple BLE connection events per await, saturating // by how many calls are in flight.
// the link. The firmware's 32 KB ring buffer absorbs bursts comfortably. for (let off = 0; off < total; off += DATA_CHUNK) {
const PIPE = 6; // packets in flight before each await (~1 KB per batch) await this.data.writeValueWithoutResponse(
for (let off = 0; off < total; off += DATA_CHUNK * PIPE) { wavBytes.subarray(off, Math.min(off + DATA_CHUNK, total)));
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 // 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. // 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 // 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) => { return new Promise((resolve) => {
if (this._statBytes >= target) return resolve(); if (this._statBytes >= target) return resolve();
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
const tick = () => { const cleanup = () => {
if (this._statBytes >= target || Date.now() > deadline || !this.connected) {
this.removeEventListener('progress', tick);
resolve();
}
};
this.addEventListener('progress', tick);
// Fallback poll in case no further notifications arrive.
const poll = setInterval(() => {
if (this._statBytes >= target || Date.now() > deadline || !this.connected) {
clearInterval(poll); clearInterval(poll);
this.removeEventListener('progress', tick); this.removeEventListener('progress', tick);
resolve(); 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);
this.addEventListener('disconnected', onDisconnect);
// Fallback poll for timeout and when no further notifications arrive.
const poll = setInterval(() => {
if (this._statBytes >= target || Date.now() > deadline) { cleanup(); resolve(); }
}, 200); }, 200);
}); });
} }
+33 -3
View File
@@ -39,11 +39,32 @@
<label>Format <select id="bits"></select></label> <label>Format <select id="bits"></select></label>
<label>Rate <select id="rate"></select></label> <label>Rate <select id="rate"></select></label>
</div> </div>
<div id="trim" class="opts trim" hidden> <div id="trim" class="trim" hidden>
<label>Trim start <input id="trim-start" type="number" min="0" step="0.1" value="0"><span class="unit">s</span></label> <div class="wave-wrap">
<label>Trim end <input id="trim-end" type="number" min="0" step="0.1" value="0"><span class="unit">s</span></label> <canvas id="wave" class="wave"></canvas>
<div id="trim-region" class="trim-region"></div>
<div id="handle-start" class="handle" role="slider" tabindex="0" aria-label="Clip start">
<span class="chev top"></span><span class="line"></span><span class="chev bot"></span>
</div>
<div id="handle-end" class="handle" role="slider" tabindex="0" aria-label="Clip end">
<span class="chev top"></span><span class="line"></span><span class="chev bot"></span>
</div>
</div>
<div class="trim-readout">
<span class="time-fields">
<input id="trim-start-s" class="time-in" type="number" min="0" step="0.01" inputmode="decimal" aria-label="Clip start seconds">
<span class="dash"></span>
<input id="trim-end-s" class="time-in" type="number" min="0" step="0.01" inputmode="decimal" aria-label="Clip end seconds">
<span class="unit">s</span>
</span>
<span id="trim-clip" class="clip">0.0s clip</span>
<button id="btn-preview" class="mini" disabled>▶ Preview</button> <button id="btn-preview" class="mini" disabled>▶ Preview</button>
</div> </div>
<label class="level">This file's level
<input id="track-level" type="range" min="0" max="150" value="100">
<span id="track-level-val" class="unit">100%</span>
</label>
</div>
<p id="convert-info" class="info">No file selected.</p> <p id="convert-info" class="info">No file selected.</p>
<div class="row"> <div class="row">
<label>Upload to <select id="upload-slot" data-needs-conn disabled></select></label> <label>Upload to <select id="upload-slot" data-needs-conn disabled></select></label>
@@ -52,6 +73,15 @@
<div class="progress"><div id="upload-bar" class="bar"></div></div> <div class="progress"><div id="upload-bar" class="bar"></div></div>
</section> </section>
<section class="card">
<div class="card-head">
<h2>Playback volume</h2>
<span id="volume-val" class="muted-val"></span>
</div>
<input id="volume" type="range" min="0" max="150" value="100" class="vol-slider" data-needs-conn disabled>
<p class="hint">Master volume on the device, saved across reboots. A file's own level is set above, before upload.</p>
</section>
<section class="card"> <section class="card">
<div class="card-head"> <div class="card-head">
<h2>Tracks on device</h2> <h2>Tracks on device</h2>
+47 -2
View File
@@ -55,8 +55,53 @@ select, input[type=number] {
} }
input[type=number] { width: 4.5rem; } input[type=number] { width: 4.5rem; }
label { display: inline-flex; align-items: center; gap: 8px; font-size: .9rem; color: var(--muted); } label { display: inline-flex; align-items: center; gap: 8px; font-size: .9rem; color: var(--muted); }
.trim { align-items: center; } /* ---- graphical trim: waveform + dual-range slider ---- */
.trim .unit { color: var(--muted); margin-left: -4px; } .trim { margin: 14px 0 4px; }
.wave-wrap { position: relative; height: 72px; margin-bottom: 8px; touch-action: none; }
.wave {
width: 100%; height: 100%; display: block;
background: #150d33; border: 1px solid var(--line); border-radius: 10px;
}
.trim-region {
position: absolute; top: 0; bottom: 0;
background: rgba(124, 77, 255, .20);
border-left: 2px solid var(--accent); border-right: 2px solid var(--accent);
pointer-events: none; border-radius: 4px;
}
/* Custom precision handles: a translucent 1px line with a chevron cap at top and
bottom. A wide transparent hit area keeps them easy to grab on touch. */
.handle {
position: absolute; top: 0; bottom: 0; width: 22px;
transform: translateX(-50%); cursor: ew-resize; touch-action: none; z-index: 3;
}
.handle .line {
position: absolute; left: 50%; top: 0; bottom: 0; width: 1px;
transform: translateX(-50%); background: rgba(183, 148, 255, .7);
}
.handle .chev {
position: absolute; left: 50%; transform: translateX(-50%);
width: 0; height: 0; border-left: 6px solid transparent; border-right: 6px solid transparent;
}
.handle .chev.top { top: -1px; border-top: 9px solid var(--accent); } /* points down */
.handle .chev.bot { bottom: -1px; border-bottom: 9px solid var(--accent); } /* points up */
.handle:focus { outline: none; }
.handle:focus .line, .handle.dragging .line { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
.handle:focus .chev, .handle.dragging .chev { filter: drop-shadow(0 0 4px var(--accent)); }
.trim-readout { display: flex; align-items: center; gap: 12px; font-size: .85rem; color: var(--muted); }
.time-fields { display: inline-flex; align-items: center; gap: 6px; }
.time-fields .dash { color: var(--muted); }
.time-in {
width: 4.2rem; padding: 5px 7px; font-size: .85rem;
background: #1c123e; color: var(--text); border: 1px solid var(--line); border-radius: 7px;
}
.trim-readout .clip { color: var(--accent); font-weight: 600; }
.trim-readout button { margin-left: auto; }
.level { display: flex; align-items: center; gap: 10px; width: 100%; margin-top: 12px; font-size: .85rem; color: var(--muted); }
.level input[type=range] { flex: 1; min-width: 0; }
.level .unit { min-width: 3.2rem; text-align: right; color: var(--accent); font-weight: 600; }
input[type=range] { accent-color: var(--accent2); }
.vol-slider { width: 100%; }
.muted-val { color: var(--accent); font-weight: 600; font-size: .95rem; }
.drop { .drop {
border: 2px dashed var(--line); border-radius: 14px; padding: 26px 16px; border: 2px dashed var(--line); border-radius: 14px; padding: 26px 16px;
+1 -1
View File
@@ -1,5 +1,5 @@
// sw.js — minimal offline cache so the PWA launches without a network. // sw.js — minimal offline cache so the PWA launches without a network.
const CACHE = 'babymobile-v5'; const CACHE = 'babymobile-v8';
const ASSETS = [ const ASSETS = [
'./', './index.html', './styles.css', './', './index.html', './styles.css',
'./app.js', './ble.js', './wav.js', './app.js', './ble.js', './wav.js',
+7 -6
View File
@@ -27,13 +27,13 @@ async function decodeMono(arrayBuffer, rate) {
return { samples: rendered.getChannelData(0), rate, srcRate: decoded.sampleRate }; return { samples: rendered.getChannelData(0), rate, srcRate: decoded.sampleRate };
} }
function floatToPcm(samples, bits) { function floatToPcm(samples, bits, gain = 1) {
const n = samples.length; const n = samples.length;
if (bits === 16) { if (bits === 16) {
const out = new Uint8Array(n * 2); const out = new Uint8Array(n * 2);
const dv = new DataView(out.buffer); const dv = new DataView(out.buffer);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
let s = Math.max(-1, Math.min(1, samples[i])); let s = Math.max(-1, Math.min(1, samples[i] * gain));
dv.setInt16(i * 2, Math.round(s * 32767), true); // signed LE dv.setInt16(i * 2, Math.round(s * 32767), true); // signed LE
} }
return out; return out;
@@ -41,7 +41,7 @@ function floatToPcm(samples, bits) {
// 8-bit unsigned PCM, center 128 // 8-bit unsigned PCM, center 128
const out = new Uint8Array(n); const out = new Uint8Array(n);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
let s = Math.max(-1, Math.min(1, samples[i])); let s = Math.max(-1, Math.min(1, samples[i] * gain));
out[i] = Math.max(0, Math.min(255, Math.round(s * 127) + 128)); out[i] = Math.max(0, Math.min(255, Math.round(s * 127) + 128));
} }
return out; return out;
@@ -88,10 +88,11 @@ export function trimSamples(samples, rate, trimStart = 0, trimEnd = 0) {
return samples.subarray(start, end); return samples.subarray(start, end);
} }
// Encode mono Float32 samples to a WAV. Returns { wav, durationSec }. // Encode mono Float32 samples to a WAV, applying an optional per-track `gain`
export function encodeWav(samples, rate, bits, { trimStart = 0, trimEnd = 0 } = {}) { // (1 = unchanged) that is baked into the samples. Returns { wav, durationSec }.
export function encodeWav(samples, rate, bits, { trimStart = 0, trimEnd = 0, gain = 1 } = {}) {
const sliced = trimSamples(samples, rate, trimStart, trimEnd); const sliced = trimSamples(samples, rate, trimStart, trimEnd);
const pcm = floatToPcm(sliced, bits); const pcm = floatToPcm(sliced, bits, gain);
const wav = buildWav(pcm, rate, bits); const wav = buildWav(pcm, rate, bits);
return { wav, durationSec: sliced.length / rate }; return { wav, durationSec: sliced.length / rate };
} }
+143 -16
View File
@@ -584,10 +584,14 @@ void ble_connect_cb(uint16_t conn_handle) {
// Request fast connection parameters and maximum throughput features. // Request fast connection parameters and maximum throughput features.
// The central may accept, renegotiate, or ignore these — all safe. // The central may accept, renegotiate, or ignore these — all safe.
// NOTE: do NOT call requestMtuExchange() here. The GATT client (browser)
// initiates MTU exchange automatically during connection setup. A second
// ATT_EXCHANGE_MTU_REQ from the peripheral is a protocol violation (only
// one exchange per connection) and leaves Chrome's ATT layer in a stale
// "operation in progress" state, blocking all subsequent GATT writes.
BLEConnection* conn = Bluefruit.Connection(conn_handle); BLEConnection* conn = Bluefruit.Connection(conn_handle);
if (conn) { if (conn) {
conn->requestConnectionParameter(6); // 6×1.25ms = 7.5ms interval conn->requestConnectionParameter(6); // 6×1.25ms = 7.5ms interval
conn->requestMtuExchange(247); // 244-byte ATT payload
conn->requestDataLengthUpdate(); // LE Data Length Extension conn->requestDataLengthUpdate(); // LE Data Length Extension
conn->requestPHY(BLE_GAP_PHY_2MBPS); // 2M PHY if supported conn->requestPHY(BLE_GAP_PHY_2MBPS); // 2M PHY if supported
} }
@@ -601,6 +605,67 @@ void ble_disconnect_cb(uint16_t conn_handle, uint8_t reason) {
g_bleErasing = false; // stop tracking the in-progress erase; it will finish in HW g_bleErasing = false; // stop tracking the in-progress erase; it will finish in HW
} }
// ============================================================
// GLOBAL VOLUME
// ============================================================
// Software gain in Q8 fixed point: 256 = unity (1.0x), 128 = half (-6 dB),
// 512 = 2x. Lets us ATTENUATE below unity (smaller PWM swing → lower amp output
// → less supply current, which avoids the regulator brown-out at high volume).
// Default below unity because the fixed-gain amp is loud at full scale.
// Set via BLE CMD 0x08 (persisted) or 'u'/'d' / "VOL <pct>" over serial.
#define GAIN_UNITY 256
#define GAIN_MIN 8 // ~0.03x
#define GAIN_MAX 1024 // 4x
#define VOL_FILE "/volume"
static uint16_t g_audioGainQ8 = 128; // 0.5x (overridden by saved value on boot)
static uint8_t gainToPercent() {
uint32_t p = ((uint32_t)g_audioGainQ8 * 100u) / GAIN_UNITY;
return (uint8_t)min(p, (uint32_t)255);
}
static void setGainPercent(uint8_t pct) {
uint32_t q = ((uint32_t)pct * GAIN_UNITY) / 100u;
if (q < GAIN_MIN) q = GAIN_MIN;
if (q > GAIN_MAX) q = GAIN_MAX;
g_audioGainQ8 = (uint16_t)q;
}
static void saveVolume() {
#ifdef POC_INTERNAL_FLASH
InternalFS.remove(VOL_FILE); // FILE_O_WRITE has no truncate
File f(InternalFS);
if (f.open(VOL_FILE, FILE_O_WRITE)) {
uint8_t b[2] = { (uint8_t)(g_audioGainQ8 & 0xFF), (uint8_t)(g_audioGainQ8 >> 8) };
f.write(b, 2);
f.close();
}
#endif
}
static void loadVolume() {
#ifdef POC_INTERNAL_FLASH
File f(InternalFS);
if (f.open(VOL_FILE, FILE_O_READ)) {
uint8_t b[2];
if (f.read(b, 2) == 2) {
uint16_t v = (uint16_t)b[0] | ((uint16_t)b[1] << 8);
if (v >= GAIN_MIN && v <= GAIN_MAX) g_audioGainQ8 = v;
}
f.close();
}
Serial.print("Volume: "); Serial.print(gainToPercent()); Serial.println("%");
#endif
}
// Report current volume over BLE: [0xC0, percent, 0, 0]. Tag 0xC0 has bit7 set
// but (0xC0 & 0x7F)=64 ≥ MAX_TRACKS, so the host won't mistake it for a list entry.
static void notifyVolume() {
uint8_t pkt[4] = { 0xC0, gainToPercent(), 0, 0 };
audioStat.write(pkt, 4);
audioStat.notify(pkt, 4);
}
// BLE command characteristic: receives commands // BLE command characteristic: receives commands
// CMD 0x01 [num_tracks] [track_entries...] = write track table // CMD 0x01 [num_tracks] [track_entries...] = write track table
// CMD 0x02 [track_num] = start writing audio to track slot // CMD 0x02 [track_num] = start writing audio to track slot
@@ -610,6 +675,8 @@ void ble_disconnect_cb(uint16_t conn_handle, uint8_t reason) {
// CMD 0x06 = list tracks → audioStat notifications: // CMD 0x06 = list tracks → audioStat notifications:
// [0x80|idx, bits, rate_kHz, dur_s] per track, then [0xFF, count, 0, 0] // [0x80|idx, bits, rate_kHz, dur_s] per track, then [0xFF, count, 0, 0]
// CMD 0x07 [track_num] = delete track → audioStat: [0xD0, idx, ok, 0] // CMD 0x07 [track_num] = delete track → audioStat: [0xD0, idx, ok, 0]
// CMD 0x08 [percent] = set global volume (persisted) → audioStat: [0xC0, pct, 0, 0]
// CMD 0x09 = get global volume → audioStat: [0xC0, pct, 0, 0]
void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr, void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
uint8_t* data, uint16_t len) { uint8_t* data, uint16_t len) {
if (len < 1) return; if (len < 1) return;
@@ -763,6 +830,19 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
Serial.println(ok ? " OK" : " FAILED"); Serial.println(ok ? " OK" : " FAILED");
} }
break; break;
case 0x08: // Set global volume (percent of unity), persist, and echo back
if (len >= 2) {
setGainPercent(data[1]);
saveVolume();
notifyVolume();
Serial.print("BLE: Volume "); Serial.print(gainToPercent()); Serial.println("%");
}
break;
case 0x09: // Get global volume
notifyVolume();
break;
} }
} }
@@ -923,8 +1003,8 @@ void setupBLE() {
// Audio service // Audio service
audioSvc.begin(); audioSvc.begin();
// Command characteristic (write) // Command characteristic (write without response — avoids "GATT already in progress" errors)
audioCmd.setProperties(CHR_PROPS_WRITE); audioCmd.setProperties(CHR_PROPS_WRITE_WO_RESP);
audioCmd.setPermission(SECMODE_OPEN, SECMODE_OPEN); audioCmd.setPermission(SECMODE_OPEN, SECMODE_OPEN);
audioCmd.setMaxLen(240); audioCmd.setMaxLen(240);
audioCmd.setWriteCallback(audioCmd_write_cb); audioCmd.setWriteCallback(audioCmd_write_cb);
@@ -943,7 +1023,7 @@ void setupBLE() {
audioStat.setMaxLen(4); audioStat.setMaxLen(4);
audioStat.begin(); audioStat.begin();
// Start advertising. // Configure advertising packet.
// The 128-bit service UUID (18 bytes) plus flags + TxPower nearly fills the // The 128-bit service UUID (18 bytes) plus flags + TxPower nearly fills the
// 31-byte advertisement, leaving room for only ~5 name characters — which // 31-byte advertisement, leaving room for only ~5 name characters — which
// truncated the name to "BabyM" over the air. Put the full name in the scan // truncated the name to "BabyM" over the air. Put the full name in the scan
@@ -952,11 +1032,25 @@ void setupBLE() {
Bluefruit.Advertising.addTxPower(); Bluefruit.Advertising.addTxPower();
Bluefruit.Advertising.addService(audioSvc); Bluefruit.Advertising.addService(audioSvc);
Bluefruit.ScanResponse.addName(); // full name here, not in the ad packet Bluefruit.ScanResponse.addName(); // full name here, not in the ad packet
Bluefruit.Advertising.restartOnDisconnect(true); Bluefruit.Advertising.restartOnDisconnect(false); // don't auto-re-advertise after disconnect
Bluefruit.Advertising.setInterval(160, 320); // 100-200ms Bluefruit.Advertising.setInterval(160, 320); // 100-200ms
Bluefruit.Advertising.start(0); // advertise forever // Do NOT start advertising here — user must hold BTN1 for 5 s to enable it.
Serial.println("BLE advertising as 'BabyMobile'"); Serial.println("BLE ready (hold BTN1 for 5 s to advertise)");
}
// Start BLE advertising for up to 60 seconds.
// Called when the user holds BTN1 for 5 s. Advertising stops automatically
// after the timeout, or immediately when a connection is made.
#define BLE_ADV_TIMEOUT_S 60
static void startBLEAdvertising() {
if (Bluefruit.Advertising.isRunning()) {
Serial.println("BLE already advertising");
return;
}
Bluefruit.Advertising.start(BLE_ADV_TIMEOUT_S);
Serial.println("BLE advertising started (60 s)");
} }
@@ -976,8 +1070,8 @@ void setupBLE() {
#define PWM_COUNTERTOP 500 // 16 MHz / 500 = 32 kHz carrier #define PWM_COUNTERTOP 500 // 16 MHz / 500 = 32 kHz carrier
#define PWM_SILENCE 250 // midpoint of 0500 duty-cycle range #define PWM_SILENCE 250 // midpoint of 0500 duty-cycle range
// Software gain: 1=unity, 2=2x, etc. Adjustable via 'u'/'d' serial. // Audio gain (g_audioGainQ8 + GAIN_* + volume persistence) is defined earlier,
static uint8_t g_audioGain = 1; // before the BLE command callback that sets it. See "GLOBAL VOLUME" section.
// Read PCM into g_pwmBuf[b], pad tail with silence. // Read PCM into g_pwmBuf[b], pad tail with silence.
// Supports 8-bit unsigned and 16-bit signed WAV; format read from g_trackBits[]. // Supports 8-bit unsigned and 16-bit signed WAV; format read from g_trackBits[].
@@ -1007,7 +1101,7 @@ static void audioFillBuf(uint8_t b) {
} }
for (uint32_t i = 0; i < samples; i++) { for (uint32_t i = 0; i < samples; i++) {
int16_t s = (int16_t)((uint16_t)raw[i * 2] | ((uint16_t)raw[i * 2 + 1] << 8)); int16_t s = (int16_t)((uint16_t)raw[i * 2] | ((uint16_t)raw[i * 2 + 1] << 8));
int32_t sv = (int32_t)s * g_audioGain; int32_t sv = ((int32_t)s * g_audioGainQ8) >> 8;
if (sv > 32767) sv = 32767; if (sv > 32767) sv = 32767;
if (sv < -32768) sv = -32768; if (sv < -32768) sv = -32768;
g_pwmBuf[b][i] = (uint16_t)(((uint32_t)(sv + 32768)) * PWM_COUNTERTOP / 65536); g_pwmBuf[b][i] = (uint16_t)(((uint32_t)(sv + 32768)) * PWM_COUNTERTOP / 65536);
@@ -1023,8 +1117,7 @@ static void audioFillBuf(uint8_t b) {
g_nextReadAddr += toReadBytes; g_nextReadAddr += toReadBytes;
} }
for (uint32_t i = 0; i < samples; i++) { for (uint32_t i = 0; i < samples; i++) {
int16_t s = (int16_t)pcm[i] - 128; int32_t s = (((int32_t)pcm[i] - 128) * g_audioGainQ8) >> 8;
s *= g_audioGain;
if (s > 127) s = 127; if (s > 127) s = 127;
if (s < -128) s = -128; if (s < -128) s = -128;
g_pwmBuf[b][i] = (uint16_t)((uint8_t)(s + 128)) * PWM_COUNTERTOP / 256; g_pwmBuf[b][i] = (uint16_t)((uint8_t)(s + 128)) * PWM_COUNTERTOP / 256;
@@ -1305,6 +1398,7 @@ void setup() {
#ifdef POC_INTERNAL_FLASH #ifdef POC_INTERNAL_FLASH
InternalFS.begin(); InternalFS.begin();
Serial.println("InternalFS mounted"); Serial.println("InternalFS mounted");
loadVolume(); // restore persisted global volume
#else #else
pinMode(PIN_FLASH_CS, OUTPUT); pinMode(PIN_FLASH_CS, OUTPUT);
digitalWrite(PIN_FLASH_CS, HIGH); digitalWrite(PIN_FLASH_CS, HIGH);
@@ -1478,9 +1572,22 @@ static void serUploadTick() {
} }
#endif #endif
} else if (g_serLineLen == 1 && (g_serLineBuf[0] == 'u' || g_serLineBuf[0] == 'd')) { } else if (g_serLineLen == 1 && (g_serLineBuf[0] == 'u' || g_serLineBuf[0] == 'd')) {
if (g_serLineBuf[0] == 'u') g_audioGain++; // Nudge gain by 0.125 (32 in Q8), clamped to [GAIN_MIN, GAIN_MAX].
else if (g_audioGain > 1) g_audioGain--; if (g_serLineBuf[0] == 'u') {
Serial.print("GAIN "); Serial.println(g_audioGain); g_audioGainQ8 = (g_audioGainQ8 + 32 > GAIN_MAX) ? GAIN_MAX : g_audioGainQ8 + 32;
} else {
g_audioGainQ8 = (g_audioGainQ8 < GAIN_MIN + 32) ? GAIN_MIN : g_audioGainQ8 - 32;
}
Serial.print("GAIN "); Serial.print((g_audioGainQ8 * 100) / GAIN_UNITY);
Serial.println("%");
} else if (sscanf(g_serLineBuf, "VOL %u", &utrk) == 1) {
// Set absolute volume as a percent of unity (0400%).
uint32_t q = ((uint32_t)utrk * GAIN_UNITY) / 100u;
if (q < GAIN_MIN) q = GAIN_MIN;
if (q > GAIN_MAX) q = GAIN_MAX;
g_audioGainQ8 = (uint16_t)q;
Serial.print("GAIN "); Serial.print((g_audioGainQ8 * 100) / GAIN_UNITY);
Serial.println("%");
} else if (g_serLineLen == 1 && g_serLineBuf[0] == 'p') { } else if (g_serLineLen == 1 && g_serLineBuf[0] == 'p') {
if (g_playing) { audioStop(); Serial.println("STOP"); } if (g_playing) { audioStop(); Serial.println("STOP"); }
else if (g_numTracks > 0) { audioStart(g_currentTrack); Serial.println("PLAY"); } else if (g_numTracks > 0) { audioStart(g_currentTrack); Serial.println("PLAY"); }
@@ -1653,7 +1760,27 @@ void loop() {
g_lastActivity = millis(); g_lastActivity = millis();
switch (btn) { switch (btn) {
case 1: // Play / Pause case 1: { // Short press: Play/Pause. Hold 5 s: start BLE advertising.
unsigned long pressStart = millis();
bool longPress = false;
while (buttonRead() == 1) {
if (millis() - pressStart >= 5000UL) { longPress = true; break; }
if (g_playing) {
for (uint8_t b = 0; b < 2; b++) if (!g_bufReady[b]) audioFillBuf(b);
if (g_trackDoneMs != 0 && millis() >= g_trackDoneMs) {
g_trackDoneMs = 0; audioStop();
}
}
}
if (longPress) {
startBLEAdvertising();
waitButtonRelease(1);
} else {
if (g_playing) { audioStop(); }
else if (g_numTracks > 0) { audioStart(g_currentTrack); }
}
break;
}
case 2: case 2:
case 3: case 3:
case 4: case 4: