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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user