improved web ui, upload is sorta better, 5sec advertise btn
This commit is contained in:
+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() {
|
||||
|
||||
Reference in New Issue
Block a user