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 |
|
| | 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 MTU−3 |
|
||||||
| 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
@@ -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
@@ -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]
|
||||||
// 0xFF → list end: [0xFF, num_tracks, 0, 0]
|
// 0xC0 → volume 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 (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.
|
// 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
@@ -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
@@ -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,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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 0–500 duty-cycle range
|
#define PWM_SILENCE 250 // midpoint of 0–500 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 (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') {
|
} 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:
|
||||||
|
|||||||
Reference in New Issue
Block a user