feat(02): controller UI — vertical anim list, play/pause, browser audio, terminal aesthetic
- Replace waveform+DAW layout with animation controller - Animations as vertical list with > cursor, no icons - Transport: play/pause button, seek slider, time display - Browser <audio> handles playback, 10Hz ticks to server - Sidebar: devices only (200px) - python:3.11-slim base (replaces Alpine, fixes librosa/numba build) - libsndfile symlink kept for soundfile - Remove python-mpv, add librosa for beat detection
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
// LightSync frontend — Phase 02-03 browser-audio architecture
|
||||
// Browser <audio> element plays audio; server does beat detection via librosa
|
||||
// 10Hz position ticks sent to server; server sends beat notifications back
|
||||
// LightSync frontend — controller UI
|
||||
// Browser <audio> handles playback; server receives position ticks and sends beat events
|
||||
|
||||
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
||||
|
||||
// ── API helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function apiPost(path, data) {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error(`POST ${path}: HTTP ${res.status}`);
|
||||
if (!res.ok && res.status !== 204) throw new Error(`POST ${path}: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -19,229 +20,138 @@ async function apiDelete(path) {
|
||||
if (!res.ok && res.status !== 204) throw new Error(`DELETE ${path}: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
// --- Beat markers state ---
|
||||
let beatTimestamps = []; // sorted list of beat times in seconds
|
||||
let audioDuration = 0;
|
||||
let currentFilePath = ""; // absolute server path for beat/waveform fetches
|
||||
// ── WebSocket ────────────────────────────────────────────────────────────────
|
||||
|
||||
// --- WebSocket client ---
|
||||
class LightSyncClient {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this.reconnectDelay = 2000;
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
constructor() { this.ws = null; this._timer = null; }
|
||||
|
||||
connect() {
|
||||
if (this._reconnectTimer) {
|
||||
clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.ws = new WebSocket(WS_URL);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
document.getElementById("status-dot").className = "status-dot connected";
|
||||
document.getElementById("status-text").textContent = "CONNECTED";
|
||||
document.getElementById('status-dot')?.classList.add('connected');
|
||||
document.getElementById('status-text').textContent = 'CONNECTED';
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
document.getElementById("status-dot").className = "status-dot";
|
||||
document.getElementById("status-text").textContent = "OFFLINE";
|
||||
this._reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay);
|
||||
document.getElementById('status-dot')?.classList.remove('connected');
|
||||
document.getElementById('status-text').textContent = 'OFFLINE';
|
||||
this._timer = setTimeout(() => this.connect(), 2000);
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
document.getElementById("status-dot").className = "status-dot error";
|
||||
document.getElementById('status-dot')?.classList.add('error');
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
this.handleMessage(msg);
|
||||
this.ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'beat') flashBeat();
|
||||
};
|
||||
}
|
||||
|
||||
handleMessage(msg) {
|
||||
if (msg.type === 'beat') {
|
||||
flashBeatIndicator();
|
||||
} else {
|
||||
console.debug('[ws]', msg);
|
||||
}
|
||||
}
|
||||
|
||||
send(msg) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN)
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Beat indicator flash ---
|
||||
function flashBeatIndicator() {
|
||||
const client = new LightSyncClient();
|
||||
client.connect();
|
||||
|
||||
// ── Beat flash ───────────────────────────────────────────────────────────────
|
||||
|
||||
function flashBeat() {
|
||||
const dot = document.getElementById('beat-indicator');
|
||||
if (!dot) return;
|
||||
dot.classList.add('beat-flash');
|
||||
// Remove class after animation completes (200ms)
|
||||
setTimeout(() => dot.classList.remove('beat-flash'), 200);
|
||||
setTimeout(() => dot.classList.remove('beat-flash'), 180);
|
||||
}
|
||||
|
||||
// --- Waveform + beat marker rendering ---
|
||||
let waveformPeaks = [];
|
||||
// ── 10Hz position tick loop ──────────────────────────────────────────────────
|
||||
|
||||
function drawWaveform(canvas, peaks, beats, duration) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const mid = h / 2;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Draw waveform bars using --accent color
|
||||
if (peaks.length > 0) {
|
||||
const barWidth = w / peaks.length;
|
||||
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#0ff';
|
||||
for (let i = 0; i < peaks.length; i++) {
|
||||
const amp = peaks[i] * mid * 0.9;
|
||||
const x = i * barWidth;
|
||||
ctx.fillRect(x, mid - amp, Math.max(barWidth - 0.5, 0.5), amp * 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw beat markers as thin vertical lines
|
||||
if (beats.length > 0 && duration > 0) {
|
||||
ctx.strokeStyle = 'rgba(255, 200, 0, 0.6)';
|
||||
ctx.lineWidth = 1;
|
||||
for (const t of beats) {
|
||||
const x = (t / duration) * w;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWaveformAndBeats(filePath) {
|
||||
try {
|
||||
const encodedPath = encodeURIComponent(filePath);
|
||||
const [waveRes, beatRes] = await Promise.allSettled([
|
||||
fetch(`/api/audio/waveform?path=${encodedPath}&peaks=2000`),
|
||||
fetch(`/api/audio/beats?path=${encodedPath}`),
|
||||
]);
|
||||
|
||||
if (waveRes.status === 'fulfilled' && waveRes.value.ok) {
|
||||
const data = await waveRes.value.json();
|
||||
waveformPeaks = data.peaks;
|
||||
}
|
||||
|
||||
if (beatRes.status === 'fulfilled' && beatRes.value.ok) {
|
||||
const data = await beatRes.value.json();
|
||||
beatTimestamps = data.beats || [];
|
||||
audioDuration = document.getElementById('player')?.duration || audioDuration;
|
||||
}
|
||||
|
||||
redrawCanvas();
|
||||
} catch (err) {
|
||||
console.error('[waveform/beats]', err);
|
||||
}
|
||||
}
|
||||
|
||||
function redrawCanvas() {
|
||||
const canvas = document.getElementById('waveform-canvas');
|
||||
if (canvas) {
|
||||
const audio = document.getElementById('player');
|
||||
const dur = (audio && isFinite(audio.duration)) ? audio.duration : audioDuration;
|
||||
drawWaveform(canvas, waveformPeaks, beatTimestamps, dur);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 10Hz position tick loop ---
|
||||
let _tickInterval = null;
|
||||
|
||||
function startTickLoop() {
|
||||
function startTicks() {
|
||||
if (_tickInterval) return;
|
||||
_tickInterval = setInterval(() => {
|
||||
const audio = document.getElementById('player');
|
||||
if (audio && !audio.paused && !audio.ended) {
|
||||
if (audio && !audio.paused && !audio.ended)
|
||||
client.send({ type: 'tick', position: audio.currentTime });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function stopTickLoop() {
|
||||
if (_tickInterval) {
|
||||
clearInterval(_tickInterval);
|
||||
_tickInterval = null;
|
||||
}
|
||||
function stopTicks() {
|
||||
clearInterval(_tickInterval);
|
||||
_tickInterval = null;
|
||||
}
|
||||
|
||||
// --- Audio element event wiring ---
|
||||
function wireAudioElement() {
|
||||
// ── Audio element ────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(s) {
|
||||
if (!isFinite(s)) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60).toString().padStart(2, '0');
|
||||
return `${m}:${sec}`;
|
||||
}
|
||||
|
||||
function wireAudio() {
|
||||
const audio = document.getElementById('player');
|
||||
const btn = document.getElementById('btn-play');
|
||||
const seek = document.getElementById('seek-bar');
|
||||
const cur = document.getElementById('time-current');
|
||||
const dur = document.getElementById('time-duration');
|
||||
if (!audio) return;
|
||||
|
||||
// Also send ticks on native timeupdate (~4Hz) as a fallback bridge
|
||||
// Play/Pause button
|
||||
btn?.addEventListener('click', () => {
|
||||
if (audio.paused) audio.play();
|
||||
else audio.pause();
|
||||
});
|
||||
|
||||
audio.addEventListener('play', () => { btn.textContent = '⏸'; startTicks(); });
|
||||
audio.addEventListener('pause', () => { btn.textContent = '▶'; stopTicks(); });
|
||||
audio.addEventListener('ended', () => { btn.textContent = '▶'; stopTicks(); seek.value = 0; });
|
||||
|
||||
// Time display + seek bar sync
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
if (!audio.paused) {
|
||||
client.send({ type: 'tick', position: audio.currentTime });
|
||||
updateCursor(audio.currentTime, audio.duration);
|
||||
if (!seek._dragging) {
|
||||
const pct = audio.duration > 0 ? (audio.currentTime / audio.duration) * 100 : 0;
|
||||
seek.value = pct;
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener('play', () => {
|
||||
client.send({ type: 'play', position: audio.currentTime });
|
||||
startTickLoop();
|
||||
document.getElementById('show-name').textContent = currentFilePath
|
||||
? currentFilePath.split('/').pop()
|
||||
: '— playing —';
|
||||
});
|
||||
|
||||
audio.addEventListener('pause', () => {
|
||||
client.send({ type: 'pause', position: audio.currentTime });
|
||||
stopTickLoop();
|
||||
});
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
stopTickLoop();
|
||||
client.send({ type: 'pause', position: audio.currentTime });
|
||||
});
|
||||
|
||||
audio.addEventListener('seeked', () => {
|
||||
client.send({ type: 'seek', position: audio.currentTime });
|
||||
updateCursor(audio.currentTime, audio.duration);
|
||||
cur.textContent = fmt(audio.currentTime);
|
||||
client.send({ type: 'tick', position: audio.currentTime });
|
||||
});
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
audioDuration = audio.duration;
|
||||
redrawCanvas();
|
||||
dur.textContent = fmt(audio.duration);
|
||||
document.getElementById('show-name').textContent =
|
||||
audio.src.split('/').pop() || '— playing —';
|
||||
});
|
||||
|
||||
// Seek bar drag
|
||||
seek.addEventListener('mousedown', () => { seek._dragging = true; });
|
||||
seek.addEventListener('input', () => {
|
||||
if (audio.duration > 0)
|
||||
cur.textContent = fmt((seek.value / 100) * audio.duration);
|
||||
});
|
||||
seek.addEventListener('mouseup', () => {
|
||||
seek._dragging = false;
|
||||
if (audio.duration > 0) {
|
||||
audio.currentTime = (seek.value / 100) * audio.duration;
|
||||
client.send({ type: 'seek', position: audio.currentTime });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateCursor(position, duration) {
|
||||
const cursor = document.getElementById('playback-cursor');
|
||||
const container = document.querySelector('.waveform-container');
|
||||
if (cursor && container && duration > 0) {
|
||||
const pct = (position / duration) * 100;
|
||||
cursor.style.left = pct + '%';
|
||||
}
|
||||
}
|
||||
// ── File list ────────────────────────────────────────────────────────────────
|
||||
|
||||
// --- File selector ---
|
||||
async function refreshFileList(selectValue) {
|
||||
async function refreshFiles(selectPath) {
|
||||
const sel = document.getElementById('audio-select');
|
||||
if (!sel) return;
|
||||
try {
|
||||
const res = await fetch('/api/audio/files');
|
||||
if (!res.ok) return;
|
||||
const { files } = await res.json();
|
||||
const prev = selectValue ?? sel.value;
|
||||
sel.innerHTML = '<option value="">— select file —</option>';
|
||||
const { files } = await (await fetch('/api/audio/files')).json();
|
||||
const prev = selectPath ?? sel.value;
|
||||
sel.innerHTML = '<option value="">— select file —</option>';
|
||||
files.forEach(name => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = `/app/shows/${name}`;
|
||||
@@ -249,12 +159,13 @@ async function refreshFileList(selectValue) {
|
||||
if (opt.value === prev) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[audio] file list error:', e);
|
||||
}
|
||||
} catch (e) { console.error('[files]', e); }
|
||||
}
|
||||
|
||||
// --- Upload handler ---
|
||||
refreshFiles();
|
||||
|
||||
// ── Upload ───────────────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('audio-upload')?.addEventListener('change', async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -262,133 +173,90 @@ document.getElementById('audio-upload')?.addEventListener('change', async (e) =>
|
||||
form.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/api/audio/upload', { method: 'POST', body: form });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
console.error('[audio] upload failed:', err.detail);
|
||||
return;
|
||||
}
|
||||
const { path, beats } = await res.json();
|
||||
// Cache beat timestamps returned from upload response
|
||||
if (beats && beats.beats) {
|
||||
beatTimestamps = beats.beats;
|
||||
}
|
||||
// Options use /app/shows/filename as value — pass abs path to pre-select
|
||||
await refreshFileList(path);
|
||||
loadSelectedFile(path);
|
||||
} catch (err) {
|
||||
console.error('[audio] upload error:', err);
|
||||
}
|
||||
if (!res.ok) { console.error('[upload]', await res.json()); return; }
|
||||
const { path } = await res.json();
|
||||
await refreshFiles(path);
|
||||
// Auto-select and load
|
||||
const audio = document.getElementById('player');
|
||||
if (audio) { audio.src = `/shows/${file.name}`; audio.load(); }
|
||||
} catch (e) { console.error('[upload]', e); }
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
// --- Load button handler ---
|
||||
document.getElementById('btn-load')?.addEventListener('click', () => {
|
||||
const sel = document.getElementById('audio-select');
|
||||
// ── Load selected ────────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('btn-load')?.addEventListener('click', async () => {
|
||||
const sel = document.getElementById('audio-select');
|
||||
const path = sel?.value;
|
||||
if (!path) return;
|
||||
// Convert /shows/filename to /app/shows/filename for absolute path
|
||||
const absPath = path.startsWith('/app/') ? path : `/app${path}`;
|
||||
loadSelectedFile(absPath);
|
||||
const filename = path.split('/').pop();
|
||||
const audio = document.getElementById('player');
|
||||
if (audio) { audio.src = `/shows/${filename}`; audio.load(); }
|
||||
// Trigger beat analysis in background
|
||||
fetch(`/api/audio/beats?path=${encodeURIComponent(path)}`).catch(() => {});
|
||||
});
|
||||
|
||||
function loadSelectedFile(absPath) {
|
||||
const audio = document.getElementById('player');
|
||||
if (!audio) return;
|
||||
// ── Animation tiles ──────────────────────────────────────────────────────────
|
||||
|
||||
// Derive the URL path from the absolute server path
|
||||
// /app/shows/song.mp3 -> /shows/song.mp3
|
||||
const urlPath = absPath.replace(/^\/app/, '');
|
||||
audio.src = urlPath;
|
||||
audio.load();
|
||||
let selectedAnim = 'beat_pulse';
|
||||
|
||||
currentFilePath = absPath;
|
||||
document.getElementById('show-name').textContent = absPath.split('/').pop();
|
||||
document.getElementById('animation-grid')?.addEventListener('click', (e) => {
|
||||
const tile = e.target.closest('.anim-tile');
|
||||
if (!tile) return;
|
||||
document.querySelectorAll('.anim-tile').forEach(t => t.classList.remove('active'));
|
||||
tile.classList.add('active');
|
||||
selectedAnim = tile.dataset.anim;
|
||||
client.send({ type: 'select_animation', animation: selectedAnim });
|
||||
});
|
||||
|
||||
// Tell WebSocket which file is loaded for beat lookup
|
||||
client.send({ type: 'load', path: absPath });
|
||||
// ── Devices ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Fetch waveform + beat markers
|
||||
loadWaveformAndBeats(absPath);
|
||||
}
|
||||
|
||||
// --- Device management ---
|
||||
async function loadDevices() {
|
||||
const list = document.getElementById('device-list');
|
||||
try {
|
||||
const res = await fetch("/api/devices");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const devices = await res.json();
|
||||
const list = document.getElementById("device-list");
|
||||
if (devices.length === 0) {
|
||||
list.innerHTML = '<span class="text-dim">no devices registered</span>';
|
||||
const devices = await (await fetch('/api/devices')).json();
|
||||
if (!devices.length) {
|
||||
list.innerHTML = '<span class="text-dim">no devices</span>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = devices.map(d =>
|
||||
`<div class="device-row">
|
||||
list.innerHTML = devices.map(d => `
|
||||
<div class="device-row">
|
||||
<div class="device-row-info">
|
||||
<span class="text-accent">${escapeHtml(d.name)}</span>
|
||||
<span class="text-dim">${escapeHtml(d.strip_type)} / ${d.led_count} LEDs / ${escapeHtml(d.ip)}:${d.port}</span>
|
||||
<span>${d.name}</span>
|
||||
<span class="text-dim" style="font-size:11px">${d.ip}:${d.port} · ${d.led_count} LEDs</span>
|
||||
</div>
|
||||
<button class="device-remove" onclick="removeDevice('${d.id}')" title="Remove device">×</button>
|
||||
</div>`
|
||||
).join("");
|
||||
} catch (err) {
|
||||
const list = document.getElementById("device-list");
|
||||
list.innerHTML = `<span class="text-dim">error loading devices</span>`;
|
||||
console.error("[devices]", err);
|
||||
<button class="device-remove" data-id="${d.id}" title="Remove">×</button>
|
||||
</div>`).join('');
|
||||
list.querySelectorAll('.device-remove').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
await apiDelete(`/api/devices/${btn.dataset.id}`);
|
||||
await loadDevices();
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
list.innerHTML = '<span class="text-dim">error loading devices</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDevice(id) {
|
||||
try {
|
||||
await apiDelete(`/api/devices/${id}`);
|
||||
await loadDevices();
|
||||
} catch (err) {
|
||||
console.error("[devices] remove failed:", err);
|
||||
}
|
||||
}
|
||||
window.removeDevice = removeDevice;
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement("div");
|
||||
div.appendChild(document.createTextNode(String(str)));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// --- Waveform click-to-seek ---
|
||||
document.querySelector('.waveform-container')?.addEventListener('click', (e) => {
|
||||
const audio = document.getElementById('player');
|
||||
if (!audio || !isFinite(audio.duration) || audio.duration <= 0) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
audio.currentTime = pct * audio.duration;
|
||||
});
|
||||
|
||||
// --- Resize redraws ---
|
||||
window.addEventListener('resize', redrawCanvas);
|
||||
|
||||
// --- Init ---
|
||||
const client = new LightSyncClient();
|
||||
client.connect();
|
||||
wireAudioElement();
|
||||
loadDevices();
|
||||
refreshFileList();
|
||||
|
||||
document.getElementById("add-device-form").addEventListener("submit", async (e) => {
|
||||
document.getElementById('add-device-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
name: form.elements["name"].value.trim(),
|
||||
name: form.elements['name'].value.trim(),
|
||||
strip_type: form.strip_type.value,
|
||||
led_count: parseInt(form.led_count.value, 10),
|
||||
ip: form.ip.value.trim(),
|
||||
port: parseInt(form.port.value, 10),
|
||||
};
|
||||
try {
|
||||
await apiPost("/api/devices", data);
|
||||
await apiPost('/api/devices', data);
|
||||
form.reset();
|
||||
form.port.value = "21324";
|
||||
await loadDevices();
|
||||
} catch (err) {
|
||||
console.error("[devices] add failed:", err);
|
||||
}
|
||||
} catch (e) { console.error('[device]', e); }
|
||||
});
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
wireAudio();
|
||||
|
||||
Reference in New Issue
Block a user