Add audio visualizer with spectrogram, beat-reactive art, and device selection
- New audio_analyzer service: loopback capture via soundcard + numpy FFT - Real-time spectrogram bars below album art with accent color gradient - Album art and vinyl pulse to bass energy beats - WebSocket subscriber pattern for opt-in audio data streaming - Audio device selection in Settings tab with auto-detect fallback - Optimized FFT pipeline: vectorized cumsum bin grouping, pre-serialized JSON broadcast - Visualizer config: enabled/fps/bins/device in config.yaml - Optional deps: soundcard + numpy (graceful degradation if missing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -318,6 +318,260 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Audio Visualizer
|
||||
let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
|
||||
let visualizerAvailable = false;
|
||||
let visualizerCtx = null;
|
||||
let visualizerAnimFrame = null;
|
||||
let frequencyData = null;
|
||||
let smoothedFrequencies = null;
|
||||
const VISUALIZER_SMOOTHING = 0.65;
|
||||
|
||||
async function checkVisualizerAvailability() {
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const resp = await fetch('/api/media/visualizer/status', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
visualizerAvailable = data.available && data.running;
|
||||
}
|
||||
} catch (e) {
|
||||
visualizerAvailable = false;
|
||||
}
|
||||
const btn = document.getElementById('visualizerToggle');
|
||||
if (btn) btn.style.display = visualizerAvailable ? '' : 'none';
|
||||
}
|
||||
|
||||
function toggleVisualizer() {
|
||||
visualizerEnabled = !visualizerEnabled;
|
||||
localStorage.setItem('visualizerEnabled', visualizerEnabled);
|
||||
applyVisualizerMode();
|
||||
}
|
||||
|
||||
function applyVisualizerMode() {
|
||||
const container = document.querySelector('.album-art-container');
|
||||
const btn = document.getElementById('visualizerToggle');
|
||||
if (!container) return;
|
||||
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
container.classList.add('visualizer-active');
|
||||
if (btn) btn.classList.add('active');
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'enable_visualizer' }));
|
||||
}
|
||||
initVisualizerCanvas();
|
||||
startVisualizerRender();
|
||||
} else {
|
||||
container.classList.remove('visualizer-active');
|
||||
if (btn) btn.classList.remove('active');
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'disable_visualizer' }));
|
||||
}
|
||||
stopVisualizerRender();
|
||||
}
|
||||
}
|
||||
|
||||
function initVisualizerCanvas() {
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (!canvas) return;
|
||||
visualizerCtx = canvas.getContext('2d');
|
||||
canvas.width = 300;
|
||||
canvas.height = 64;
|
||||
}
|
||||
|
||||
function startVisualizerRender() {
|
||||
if (visualizerAnimFrame) return;
|
||||
renderVisualizerFrame();
|
||||
}
|
||||
|
||||
function stopVisualizerRender() {
|
||||
if (visualizerAnimFrame) {
|
||||
cancelAnimationFrame(visualizerAnimFrame);
|
||||
visualizerAnimFrame = null;
|
||||
}
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (visualizerCtx && canvas) {
|
||||
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
// Reset album art / vinyl pulse
|
||||
const art = document.getElementById('album-art');
|
||||
if (art) {
|
||||
art.style.transform = '';
|
||||
art.style.removeProperty('--vinyl-scale');
|
||||
}
|
||||
const glow = document.getElementById('album-art-glow');
|
||||
if (glow) glow.style.opacity = '';
|
||||
frequencyData = null;
|
||||
smoothedFrequencies = null;
|
||||
}
|
||||
|
||||
function renderVisualizerFrame() {
|
||||
visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame);
|
||||
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (!frequencyData || !visualizerCtx || !canvas) return;
|
||||
|
||||
const bins = frequencyData.frequencies;
|
||||
const numBins = bins.length;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const gap = 2;
|
||||
const barWidth = (w / numBins) - gap;
|
||||
const accent = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--accent').trim();
|
||||
|
||||
// Smooth transitions
|
||||
if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) {
|
||||
smoothedFrequencies = new Array(numBins).fill(0);
|
||||
}
|
||||
for (let i = 0; i < numBins; i++) {
|
||||
smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING
|
||||
+ bins[i] * (1 - VISUALIZER_SMOOTHING);
|
||||
}
|
||||
|
||||
visualizerCtx.clearRect(0, 0, w, h);
|
||||
|
||||
for (let i = 0; i < numBins; i++) {
|
||||
const barHeight = Math.max(1, smoothedFrequencies[i] * h);
|
||||
const x = i * (barWidth + gap) + gap / 2;
|
||||
const y = h - barHeight;
|
||||
|
||||
const grad = visualizerCtx.createLinearGradient(x, y, x, h);
|
||||
grad.addColorStop(0, accent);
|
||||
grad.addColorStop(1, accent + '30');
|
||||
|
||||
visualizerCtx.fillStyle = grad;
|
||||
visualizerCtx.beginPath();
|
||||
visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5);
|
||||
visualizerCtx.fill();
|
||||
}
|
||||
|
||||
// Album art / vinyl pulse based on bass energy
|
||||
const bass = frequencyData.bass || 0;
|
||||
const scale = 1 + bass * 0.03;
|
||||
const art = document.getElementById('album-art');
|
||||
if (art) {
|
||||
if (vinylMode) {
|
||||
// Use CSS custom property so it composes with the rotation animation
|
||||
art.style.setProperty('--vinyl-scale', scale);
|
||||
} else {
|
||||
art.style.transform = `scale(${scale})`;
|
||||
}
|
||||
}
|
||||
const glow = document.getElementById('album-art-glow');
|
||||
if (glow) {
|
||||
glow.style.opacity = (0.5 + bass * 0.3).toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Audio device selection
|
||||
async function loadAudioDevices() {
|
||||
const section = document.getElementById('audioDeviceSection');
|
||||
const select = document.getElementById('audioDeviceSelect');
|
||||
if (!section || !select) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
|
||||
// Fetch devices and current status in parallel
|
||||
const [devicesResp, statusResp] = await Promise.all([
|
||||
fetch('/api/media/visualizer/devices', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
}),
|
||||
fetch('/api/media/visualizer/status', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
]);
|
||||
|
||||
if (!devicesResp.ok || !statusResp.ok) return;
|
||||
|
||||
const devices = await devicesResp.json();
|
||||
const status = await statusResp.json();
|
||||
|
||||
if (!status.available && devices.length === 0) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show section
|
||||
section.style.display = '';
|
||||
|
||||
// Populate dropdown (keep auto-detect as first option)
|
||||
while (select.options.length > 1) select.remove(1);
|
||||
for (const dev of devices) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = dev.name;
|
||||
opt.textContent = dev.name;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
// Select current device
|
||||
if (status.current_device) {
|
||||
for (let i = 0; i < select.options.length; i++) {
|
||||
if (select.options[i].value === status.current_device) {
|
||||
select.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update status indicator
|
||||
updateAudioDeviceStatus(status);
|
||||
} catch (e) {
|
||||
// Silently hide if unavailable
|
||||
section.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateAudioDeviceStatus(status) {
|
||||
const el = document.getElementById('audioDeviceStatus');
|
||||
if (!el) return;
|
||||
if (status.running) {
|
||||
el.className = 'audio-device-status active';
|
||||
el.textContent = t('settings.audio.status_active');
|
||||
} else if (status.available) {
|
||||
el.className = 'audio-device-status available';
|
||||
el.textContent = t('settings.audio.status_available');
|
||||
} else {
|
||||
el.className = 'audio-device-status unavailable';
|
||||
el.textContent = t('settings.audio.status_unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
async function onAudioDeviceChanged() {
|
||||
const select = document.getElementById('audioDeviceSelect');
|
||||
if (!select) return;
|
||||
|
||||
const deviceName = select.value || null;
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/media/visualizer/device', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ device_name: deviceName })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const result = await resp.json();
|
||||
updateAudioDeviceStatus(result);
|
||||
// Re-check visualizer availability since device changed
|
||||
await checkVisualizerAvailability();
|
||||
if (visualizerEnabled) applyVisualizerMode();
|
||||
showToast(t('settings.audio.device_changed'), 'success');
|
||||
} else {
|
||||
showToast(t('settings.audio.device_change_failed'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(t('settings.audio.device_change_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Locale management
|
||||
let currentLocale = 'en';
|
||||
let translations = {};
|
||||
@@ -574,6 +828,13 @@
|
||||
// Initialize vinyl mode
|
||||
applyVinylMode();
|
||||
|
||||
// Initialize audio visualizer
|
||||
checkVisualizerAvailability().then(() => {
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
applyVisualizerMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize locale (async - loads JSON file)
|
||||
await initLocale();
|
||||
|
||||
@@ -587,6 +848,7 @@
|
||||
loadScriptsTable();
|
||||
loadCallbacksTable();
|
||||
loadLinksTable();
|
||||
loadAudioDevices();
|
||||
} else {
|
||||
showAuthForm();
|
||||
}
|
||||
@@ -897,6 +1159,11 @@
|
||||
loadCallbacksTable();
|
||||
loadLinksTable();
|
||||
loadHeaderLinks();
|
||||
loadAudioDevices();
|
||||
// Re-enable visualizer subscription on reconnect
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
ws.send(JSON.stringify({ type: 'enable_visualizer' }));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -913,6 +1180,8 @@
|
||||
loadHeaderLinks();
|
||||
loadLinksTable();
|
||||
displayQuickAccess();
|
||||
} else if (msg.type === 'audio_data') {
|
||||
frequencyData = msg.data;
|
||||
} else if (msg.type === 'error') {
|
||||
console.error('WebSocket error:', msg.message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user