feat(02-03): redesign frontend — browser audio element, beat markers, animation selector

- index.html: add <audio id=player> (hidden), beat-indicator dot in header
- index.html: animation selector panel with Beat Pulse/Strobe/Color Wave/Rainbow options
- index.html: remove seek slider + time display (browser audio element has native controls)
- index.html: upload button + file selector remain in transport bar
- app.js: wire <audio> element — play/pause/seeked/timeupdate events send WS ticks
- app.js: 10Hz setInterval tick loop (supplements native timeupdate ~4Hz)
- app.js: WS beat message -> flashBeatIndicator() animation
- app.js: loadWaveformAndBeats() draws waveform peaks + beat marker lines on canvas
- app.js: loadSelectedFile() sets audio.src=/shows/filename, fetches beats+waveform
- app.js: upload response caches beat data returned from server immediately
- style.css: .beat-indicator with .beat-flash (yellow glow animation)
- style.css: .animation-select-input for the animations panel dropdown
This commit is contained in:
Claude
2026-04-06 15:38:30 +00:00
parent 1f1ff92446
commit f1941a90db
3 changed files with 277 additions and 171 deletions

View File

@@ -1,6 +1,6 @@
// LightSync frontend — Phase 1 shell // LightSync frontend — Phase 02-03 browser-audio architecture
// WebSocket stub + device list loader // Browser <audio> element plays audio; server does beat detection via librosa
// Phase 2 expands audio, transport, and WS protocol // 10Hz position ticks sent to server; server sends beat notifications back
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
@@ -19,6 +19,12 @@ async function apiDelete(path) {
if (!res.ok && res.status !== 204) throw new Error(`DELETE ${path}: HTTP ${res.status}`); 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 client ---
class LightSyncClient { class LightSyncClient {
constructor() { constructor() {
this.ws = null; this.ws = null;
@@ -56,9 +62,8 @@ class LightSyncClient {
} }
handleMessage(msg) { handleMessage(msg) {
if (msg.type === 'tick') { if (msg.type === 'beat') {
updatePosition(msg.position, msg.duration, msg.paused); flashBeatIndicator();
audioLoaded = msg.loaded;
} else { } else {
console.debug('[ws]', msg); console.debug('[ws]', msg);
} }
@@ -71,12 +76,19 @@ class LightSyncClient {
} }
} }
// --- Waveform rendering --- // --- Beat indicator flash ---
let waveformPeaks = []; function flashBeatIndicator() {
let audioDuration = 0; const dot = document.getElementById('beat-indicator');
let audioLoaded = false; if (!dot) return;
dot.classList.add('beat-flash');
// Remove class after animation completes (200ms)
setTimeout(() => dot.classList.remove('beat-flash'), 200);
}
function drawWaveform(canvas, peaks) { // --- Waveform + beat marker rendering ---
let waveformPeaks = [];
function drawWaveform(canvas, peaks, beats, duration) {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
@@ -87,74 +99,230 @@ function drawWaveform(canvas, peaks) {
const w = rect.width; const w = rect.width;
const h = rect.height; const h = rect.height;
const mid = h / 2; const mid = h / 2;
const barWidth = w / peaks.length;
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
// Draw waveform bars using --accent color // Draw waveform bars using --accent color
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#0ff'; if (peaks.length > 0) {
for (let i = 0; i < peaks.length; i++) { const barWidth = w / peaks.length;
const amp = peaks[i] * mid * 0.9; ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#0ff';
const x = i * barWidth; for (let i = 0; i < peaks.length; i++) {
ctx.fillRect(x, mid - amp, Math.max(barWidth - 0.5, 0.5), amp * 2); 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 loadWaveform() { async function loadWaveformAndBeats(filePath) {
try { try {
const res = await fetch('/api/audio/waveform?peaks=2000'); const encodedPath = encodeURIComponent(filePath);
if (!res.ok) return; const [waveRes, beatRes] = await Promise.allSettled([
const data = await res.json(); fetch(`/api/audio/waveform?path=${encodedPath}&peaks=2000`),
waveformPeaks = data.peaks; fetch(`/api/audio/beats?path=${encodedPath}`),
const canvas = document.getElementById('waveform-canvas'); ]);
if (canvas) drawWaveform(canvas, waveformPeaks);
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) { } catch (err) {
console.error('[waveform]', err); console.error('[waveform/beats]', err);
} }
} }
// --- Time formatting --- function redrawCanvas() {
function formatTime(seconds) { const canvas = document.getElementById('waveform-canvas');
if (seconds == null || isNaN(seconds)) return '0:00.0'; if (canvas) {
const m = Math.floor(seconds / 60); const audio = document.getElementById('player');
const s = seconds % 60; const dur = (audio && isFinite(audio.duration)) ? audio.duration : audioDuration;
return `${m}:${s < 10 ? '0' : ''}${s.toFixed(1)}`; drawWaveform(canvas, waveformPeaks, beatTimestamps, dur);
}
} }
// --- Position update from WebSocket tick --- // --- 10Hz position tick loop ---
function updatePosition(position, duration, paused) { let _tickInterval = null;
audioDuration = duration || 0;
document.getElementById('time-current').textContent = formatTime(position); function startTickLoop() {
document.getElementById('time-duration').textContent = formatTime(duration); if (_tickInterval) return;
_tickInterval = setInterval(() => {
const audio = document.getElementById('player');
if (audio && !audio.paused && !audio.ended) {
client.send({ type: 'tick', position: audio.currentTime });
}
}, 100);
}
// Update seek bar function stopTickLoop() {
const seekBar = document.getElementById('seek-bar'); if (_tickInterval) {
if (duration > 0 && !seekBar._dragging) { clearInterval(_tickInterval);
seekBar.value = (position / duration) * 100; _tickInterval = null;
} }
}
// Update playback cursor position // --- Audio element event wiring ---
function wireAudioElement() {
const audio = document.getElementById('player');
if (!audio) return;
// Also send ticks on native timeupdate (~4Hz) as a fallback bridge
audio.addEventListener('timeupdate', () => {
if (!audio.paused) {
client.send({ type: 'tick', position: audio.currentTime });
updateCursor(audio.currentTime, audio.duration);
}
});
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);
});
audio.addEventListener('loadedmetadata', () => {
audioDuration = audio.duration;
redrawCanvas();
});
}
function updateCursor(position, duration) {
const cursor = document.getElementById('playback-cursor'); const cursor = document.getElementById('playback-cursor');
const container = document.querySelector('.waveform-container'); const container = document.querySelector('.waveform-container');
if (cursor && container && duration > 0) { if (cursor && container && duration > 0) {
const pct = (position / duration) * 100; const pct = (position / duration) * 100;
cursor.style.left = pct + '%'; cursor.style.left = pct + '%';
} }
}
// Toggle play/pause button visibility // --- File selector ---
const btnPlay = document.getElementById('btn-play'); async function refreshFileList(selectValue) {
const btnPause = document.getElementById('btn-pause'); const sel = document.getElementById('audio-select');
if (btnPlay && btnPause) { if (!sel) return;
btnPlay.style.display = paused ? '' : 'none'; try {
btnPause.style.display = paused ? 'none' : ''; 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="">&#8212; select file &#8212;</option>';
files.forEach(name => {
const opt = document.createElement('option');
opt.value = `/app/shows/${name}`;
opt.textContent = name;
if (opt.value === prev) opt.selected = true;
sel.appendChild(opt);
});
} catch (e) {
console.error('[audio] file list error:', e);
} }
} }
const client = new LightSyncClient(); // --- Upload handler ---
client.connect(); document.getElementById('audio-upload')?.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const form = new FormData();
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;
}
await refreshFileList(`/shows/${path.split('/').pop()}`);
// Auto-select and load the just-uploaded file
const sel = document.getElementById('audio-select');
const serverPath = `/shows/${path.split('/').pop()}`;
if (sel) {
// find matching option and select it
for (const opt of sel.options) {
if (opt.value === serverPath || opt.value === path) {
opt.selected = true;
break;
}
}
}
loadSelectedFile(path);
} catch (err) {
console.error('[audio] upload error:', err);
}
e.target.value = '';
});
// Load device list on startup // --- Load button handler ---
document.getElementById('btn-load')?.addEventListener('click', () => {
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);
});
function loadSelectedFile(absPath) {
const audio = document.getElementById('player');
if (!audio) return;
// 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();
currentFilePath = absPath;
document.getElementById('show-name').textContent = absPath.split('/').pop();
// Tell WebSocket which file is loaded for beat lookup
client.send({ type: 'load', path: absPath });
// Fetch waveform + beat markers
loadWaveformAndBeats(absPath);
}
// --- Device management ---
async function loadDevices() { async function loadDevices() {
try { try {
const res = await fetch("/api/devices"); const res = await fetch("/api/devices");
@@ -197,116 +365,24 @@ function escapeHtml(str) {
return div.innerHTML; return div.innerHTML;
} }
loadDevices(); // --- Waveform click-to-seek ---
// --- Transport controls ---
document.getElementById('btn-play')?.addEventListener('click', () => {
client.send({ type: 'play' });
});
document.getElementById('btn-pause')?.addEventListener('click', () => {
client.send({ type: 'pause' });
});
document.getElementById('btn-stop')?.addEventListener('click', () => {
client.send({ type: 'pause' });
client.send({ type: 'seek', position: 0 });
});
// Seek bar interaction
const seekBar = document.getElementById('seek-bar');
if (seekBar) {
seekBar.addEventListener('mousedown', () => { seekBar._dragging = true; });
seekBar.addEventListener('mouseup', () => {
seekBar._dragging = false;
const pos = (parseFloat(seekBar.value) / 100) * audioDuration;
client.send({ type: 'seek', position: pos });
});
}
// Populate file selector
async function refreshFileList(selectValue) {
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>';
files.forEach(name => {
const opt = document.createElement('option');
opt.value = `/app/shows/${name}`;
opt.textContent = name;
if (opt.value === prev) opt.selected = true;
sel.appendChild(opt);
});
} catch (e) {
console.error('[audio] file list error:', e);
}
}
refreshFileList();
// Upload audio file
document.getElementById('audio-upload')?.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const form = new FormData();
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 } = await res.json();
await refreshFileList(path);
setTimeout(loadWaveform, 500);
} catch (err) {
console.error('[audio] upload error:', err);
}
e.target.value = '';
});
// Load selected audio file
document.getElementById('btn-load')?.addEventListener('click', async () => {
const sel = document.getElementById('audio-select');
const path = sel?.value;
if (!path) return;
try {
const res = await fetch('/api/audio/load', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
if (!res.ok) {
const err = await res.json();
console.error('[audio] load failed:', err.detail);
return;
}
setTimeout(loadWaveform, 500);
} catch (err) {
console.error('[audio] load error:', err);
}
});
// Click on waveform to seek
document.querySelector('.waveform-container')?.addEventListener('click', (e) => { document.querySelector('.waveform-container')?.addEventListener('click', (e) => {
if (audioDuration <= 0) return; const audio = document.getElementById('player');
if (!audio || !isFinite(audio.duration) || audio.duration <= 0) return;
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width; const pct = (e.clientX - rect.left) / rect.width;
const pos = pct * audioDuration; audio.currentTime = pct * audio.duration;
client.send({ type: 'seek', position: pos });
}); });
// Resize waveform on window resize // --- Resize redraws ---
window.addEventListener('resize', () => { window.addEventListener('resize', redrawCanvas);
if (waveformPeaks.length > 0) {
const canvas = document.getElementById('waveform-canvas'); // --- Init ---
if (canvas) drawWaveform(canvas, waveformPeaks); 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(); e.preventDefault();

View File

@@ -17,6 +17,7 @@
<div class="status-dot" id="status-dot"></div> <div class="status-dot" id="status-dot"></div>
<span id="status-text" class="text-dim">OFFLINE</span> <span id="status-text" class="text-dim">OFFLINE</span>
<span id="show-name" class="text-dim">&#8212; no show loaded &#8212;</span> <span id="show-name" class="text-dim">&#8212; no show loaded &#8212;</span>
<div id="beat-indicator" class="beat-indicator" title="Beat pulse"></div>
</header> </header>
<aside class="sidebar"> <aside class="sidebar">
@@ -26,7 +27,7 @@
<span class="text-dim">loading...</span> <span class="text-dim">loading...</span>
</div> </div>
<details class="device-manage-section"> <details class="device-manage-section">
<summary> MANAGE DEVICES</summary> <summary>&#9881; MANAGE DEVICES</summary>
<form id="add-device-form" class="device-form"> <form id="add-device-form" class="device-form">
<input type="text" name="name" placeholder="Device name" required> <input type="text" name="name" placeholder="Device name" required>
<select name="strip_type" required> <select name="strip_type" required>
@@ -43,7 +44,14 @@
</div> </div>
<div class="panel" id="animations-panel" style="flex: 1; min-height: 0;"> <div class="panel" id="animations-panel" style="flex: 1; min-height: 0;">
<div class="panel-header">ANIMATIONS</div> <div class="panel-header">ANIMATIONS</div>
<div class="panel-content text-dim">[ PHASE 3+ ]</div> <div class="panel-content">
<select id="animation-select" class="animation-select-input">
<option value="beat_pulse">Beat Pulse</option>
<option value="strobe">Strobe</option>
<option value="color_wave">Color Wave</option>
<option value="rainbow">Rainbow</option>
</select>
</div>
</div> </div>
</aside> </aside>
@@ -58,26 +66,16 @@
</main> </main>
<footer class="transport-bar" id="transport-panel"> <footer class="transport-bar" id="transport-panel">
<div class="transport-controls"> <!-- Hidden native audio element — browser handles playback -->
<button id="btn-play" class="transport-btn" title="Play">&#9654;</button> <audio id="player" style="display:none;"></audio>
<button id="btn-pause" class="transport-btn" title="Pause" style="display:none">&#9646;&#9646;</button>
<button id="btn-stop" class="transport-btn" title="Stop">&#9632;</button>
</div>
<div class="transport-time">
<span id="time-current">0:00.0</span>
<span class="text-dim">/</span>
<span id="time-duration">0:00.0</span>
</div>
<div class="transport-seek">
<input type="range" id="seek-bar" min="0" max="100" value="0" step="0.1" class="seek-slider">
</div>
<div class="transport-file"> <div class="transport-file">
<label class="transport-btn" title="Upload MP3/WAV/FLAC/OGG from your computer" style="cursor:pointer;"> <label class="transport-btn" title="Upload MP3/WAV/FLAC/OGG from your computer" style="cursor:pointer;">
+ UPLOAD + UPLOAD
<input type="file" id="audio-upload" accept=".mp3,.wav,.flac,.ogg" style="display:none;"> <input type="file" id="audio-upload" accept=".mp3,.wav,.flac,.ogg" style="display:none;">
</label> </label>
<select id="audio-select" class="audio-path-input"> <select id="audio-select" class="audio-path-input">
<option value=""> no files yet, upload first </option> <option value="">&#8212; no files yet, upload first &#8212;</option>
</select> </select>
<button id="btn-load" class="transport-btn" title="Load selected file">LOAD</button> <button id="btn-load" class="transport-btn" title="Load selected file">LOAD</button>
</div> </div>

View File

@@ -337,3 +337,35 @@ button:hover {
transition: left 0.1s linear; transition: left 0.1s linear;
z-index: 10; z-index: 10;
} }
/* Beat indicator in header */
.beat-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-dim);
flex-shrink: 0;
transition: background 0.05s ease;
}
.beat-indicator.beat-flash {
background: #ffcc00;
box-shadow: 0 0 8px #ffcc00;
}
/* Animation selector */
.animation-select-input {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-dim);
padding: 4px 8px;
font-family: var(--font-mono);
font-size: var(--font-size);
width: 100%;
cursor: pointer;
}
.animation-select-input:focus {
border-color: var(--border-bright);
outline: none;
}