improved web ui, upload is sorta better, 5sec advertise btn
This commit is contained in:
+15
-3
@@ -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 <slot>` start upload · `0x03` finish · `0x04 <slot>` play · `0x05` stop · `0x06` list · `0x07 <slot>` 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 <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 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
|
||||
|
||||
|
||||
+162
-18
@@ -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 =
|
||||
`<b>${currentFile.name}</b> → ${bits}-bit ${decoded.rate / 1000} kHz mono<br>` +
|
||||
`${durationSec.toFixed(1)}s${trimmed} · ${fmtBytes(wav.byteLength)} (incl. 44-byte WAV header)`;
|
||||
`<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', '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() {
|
||||
|
||||
+49
-29
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
+34
-4
@@ -39,10 +39,31 @@
|
||||
<label>Format <select id="bits"></select></label>
|
||||
<label>Rate <select id="rate"></select></label>
|
||||
</div>
|
||||
<div id="trim" class="opts trim" hidden>
|
||||
<label>Trim start <input id="trim-start" type="number" min="0" step="0.1" value="0"><span class="unit">s</span></label>
|
||||
<label>Trim end <input id="trim-end" type="number" min="0" step="0.1" value="0"><span class="unit">s</span></label>
|
||||
<button id="btn-preview" class="mini" disabled>▶ Preview</button>
|
||||
<div id="trim" class="trim" hidden>
|
||||
<div class="wave-wrap">
|
||||
<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>
|
||||
</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>
|
||||
<div class="row">
|
||||
@@ -52,6 +73,15 @@
|
||||
<div class="progress"><div id="upload-bar" class="bar"></div></div>
|
||||
</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">
|
||||
<div class="card-head">
|
||||
<h2>Tracks on device</h2>
|
||||
|
||||
+47
-2
@@ -55,8 +55,53 @@ select, input[type=number] {
|
||||
}
|
||||
input[type=number] { width: 4.5rem; }
|
||||
label { display: inline-flex; align-items: center; gap: 8px; font-size: .9rem; color: var(--muted); }
|
||||
.trim { align-items: center; }
|
||||
.trim .unit { color: var(--muted); margin-left: -4px; }
|
||||
/* ---- graphical trim: waveform + dual-range slider ---- */
|
||||
.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 {
|
||||
border: 2px dashed var(--line); border-radius: 14px; padding: 26px 16px;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// sw.js — minimal offline cache so the PWA launches without a network.
|
||||
const CACHE = 'babymobile-v5';
|
||||
const CACHE = 'babymobile-v8';
|
||||
const ASSETS = [
|
||||
'./', './index.html', './styles.css',
|
||||
'./app.js', './ble.js', './wav.js',
|
||||
|
||||
+7
-6
@@ -27,13 +27,13 @@ async function decodeMono(arrayBuffer, rate) {
|
||||
return { samples: rendered.getChannelData(0), rate, srcRate: decoded.sampleRate };
|
||||
}
|
||||
|
||||
function floatToPcm(samples, bits) {
|
||||
function floatToPcm(samples, bits, gain = 1) {
|
||||
const n = samples.length;
|
||||
if (bits === 16) {
|
||||
const out = new Uint8Array(n * 2);
|
||||
const dv = new DataView(out.buffer);
|
||||
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
|
||||
}
|
||||
return out;
|
||||
@@ -41,7 +41,7 @@ function floatToPcm(samples, bits) {
|
||||
// 8-bit unsigned PCM, center 128
|
||||
const out = new Uint8Array(n);
|
||||
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));
|
||||
}
|
||||
return out;
|
||||
@@ -88,10 +88,11 @@ export function trimSamples(samples, rate, trimStart = 0, trimEnd = 0) {
|
||||
return samples.subarray(start, end);
|
||||
}
|
||||
|
||||
// Encode mono Float32 samples to a WAV. Returns { wav, durationSec }.
|
||||
export function encodeWav(samples, rate, bits, { trimStart = 0, trimEnd = 0 } = {}) {
|
||||
// Encode mono Float32 samples to a WAV, applying an optional per-track `gain`
|
||||
// (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 pcm = floatToPcm(sliced, bits);
|
||||
const pcm = floatToPcm(sliced, bits, gain);
|
||||
const wav = buildWav(pcm, rate, bits);
|
||||
return { wav, durationSec: sliced.length / rate };
|
||||
}
|
||||
|
||||
@@ -584,10 +584,14 @@ void ble_connect_cb(uint16_t conn_handle) {
|
||||
|
||||
// Request fast connection parameters and maximum throughput features.
|
||||
// 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);
|
||||
if (conn) {
|
||||
conn->requestConnectionParameter(6); // 6×1.25ms = 7.5ms interval
|
||||
conn->requestMtuExchange(247); // 244-byte ATT payload
|
||||
conn->requestDataLengthUpdate(); // LE Data Length Extension
|
||||
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
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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
|
||||
// CMD 0x01 [num_tracks] [track_entries...] = write track table
|
||||
// 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:
|
||||
// [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 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,
|
||||
uint8_t* data, uint16_t len) {
|
||||
if (len < 1) return;
|
||||
@@ -763,6 +830,19 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
|
||||
Serial.println(ok ? " OK" : " FAILED");
|
||||
}
|
||||
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
|
||||
audioSvc.begin();
|
||||
|
||||
// Command characteristic (write)
|
||||
audioCmd.setProperties(CHR_PROPS_WRITE);
|
||||
// Command characteristic (write without response — avoids "GATT already in progress" errors)
|
||||
audioCmd.setProperties(CHR_PROPS_WRITE_WO_RESP);
|
||||
audioCmd.setPermission(SECMODE_OPEN, SECMODE_OPEN);
|
||||
audioCmd.setMaxLen(240);
|
||||
audioCmd.setWriteCallback(audioCmd_write_cb);
|
||||
@@ -943,7 +1023,7 @@ void setupBLE() {
|
||||
audioStat.setMaxLen(4);
|
||||
audioStat.begin();
|
||||
|
||||
// Start advertising.
|
||||
// Configure advertising packet.
|
||||
// The 128-bit service UUID (18 bytes) plus flags + TxPower nearly fills the
|
||||
// 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
|
||||
@@ -952,11 +1032,25 @@ void setupBLE() {
|
||||
Bluefruit.Advertising.addTxPower();
|
||||
Bluefruit.Advertising.addService(audioSvc);
|
||||
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.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_SILENCE 250 // midpoint of 0–500 duty-cycle range
|
||||
|
||||
// Software gain: 1=unity, 2=2x, etc. Adjustable via 'u'/'d' serial.
|
||||
static uint8_t g_audioGain = 1;
|
||||
// Audio gain (g_audioGainQ8 + GAIN_* + volume persistence) is defined earlier,
|
||||
// before the BLE command callback that sets it. See "GLOBAL VOLUME" section.
|
||||
|
||||
// Read PCM into g_pwmBuf[b], pad tail with silence.
|
||||
// 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++) {
|
||||
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 < -32768) sv = -32768;
|
||||
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;
|
||||
}
|
||||
for (uint32_t i = 0; i < samples; i++) {
|
||||
int16_t s = (int16_t)pcm[i] - 128;
|
||||
s *= g_audioGain;
|
||||
int32_t s = (((int32_t)pcm[i] - 128) * g_audioGainQ8) >> 8;
|
||||
if (s > 127) s = 127;
|
||||
if (s < -128) s = -128;
|
||||
g_pwmBuf[b][i] = (uint16_t)((uint8_t)(s + 128)) * PWM_COUNTERTOP / 256;
|
||||
@@ -1305,6 +1398,7 @@ void setup() {
|
||||
#ifdef POC_INTERNAL_FLASH
|
||||
InternalFS.begin();
|
||||
Serial.println("InternalFS mounted");
|
||||
loadVolume(); // restore persisted global volume
|
||||
#else
|
||||
pinMode(PIN_FLASH_CS, OUTPUT);
|
||||
digitalWrite(PIN_FLASH_CS, HIGH);
|
||||
@@ -1478,9 +1572,22 @@ static void serUploadTick() {
|
||||
}
|
||||
#endif
|
||||
} else if (g_serLineLen == 1 && (g_serLineBuf[0] == 'u' || g_serLineBuf[0] == 'd')) {
|
||||
if (g_serLineBuf[0] == 'u') g_audioGain++;
|
||||
else if (g_audioGain > 1) g_audioGain--;
|
||||
Serial.print("GAIN "); Serial.println(g_audioGain);
|
||||
// Nudge gain by 0.125 (32 in Q8), clamped to [GAIN_MIN, GAIN_MAX].
|
||||
if (g_serLineBuf[0] == 'u') {
|
||||
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 (0–400%).
|
||||
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') {
|
||||
if (g_playing) { audioStop(); Serial.println("STOP"); }
|
||||
else if (g_numTracks > 0) { audioStart(g_currentTrack); Serial.println("PLAY"); }
|
||||
@@ -1653,7 +1760,27 @@ void loop() {
|
||||
g_lastActivity = millis();
|
||||
|
||||
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 3:
|
||||
case 4:
|
||||
|
||||
Reference in New Issue
Block a user