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:
2026-02-27 21:42:19 +03:00
parent 8a8f00ff31
commit 0691e3d338
11 changed files with 919 additions and 2 deletions

View File

@@ -628,8 +628,39 @@ h1 {
}
@keyframes vinylSpin {
from { transform: rotate(var(--vinyl-offset, 0deg)); }
to { transform: rotate(calc(var(--vinyl-offset, 0deg) + 360deg)); }
from { transform: rotate(var(--vinyl-offset, 0deg)) scale(var(--vinyl-scale, 1)); }
to { transform: rotate(calc(var(--vinyl-offset, 0deg) + 360deg)) scale(var(--vinyl-scale, 1)); }
}
/* Audio Spectrogram Visualization */
.spectrogram-canvas {
position: absolute;
bottom: -4px;
left: 50%;
transform: translateX(-50%);
z-index: 2;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
border-radius: 0 0 8px 8px;
}
.visualizer-active .spectrogram-canvas {
opacity: 1;
}
.visualizer-active #album-art {
transition: transform 0.08s ease-out;
}
.visualizer-active .album-art-glow {
transition: opacity 0.08s ease-out;
}
/* Adapt spectrogram for vinyl mode */
.album-art-container.vinyl .spectrogram-canvas {
bottom: -10px;
border-radius: 0 0 50% 50%;
}
.track-info {
@@ -1087,6 +1118,74 @@ button:disabled {
margin-bottom: 1rem;
}
.audio-device-selector {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.audio-device-selector label {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.audio-device-selector label span {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
}
.audio-device-selector select {
padding: 0.5rem 0.625rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
}
.audio-device-status {
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 0.375rem;
}
.audio-device-status::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
}
.audio-device-status.active {
color: var(--accent);
}
.audio-device-status.active::before {
background: var(--accent);
}
.audio-device-status.available {
color: var(--text-secondary);
}
.audio-device-status.available::before {
background: var(--text-muted);
}
.audio-device-status.unavailable {
color: var(--text-muted);
}
.audio-device-status.unavailable::before {
background: var(--text-muted);
opacity: 0.5;
}
/* Link card in Quick Access */
.link-card {
text-decoration: none;

View File

@@ -131,6 +131,7 @@
<div class="album-art-container">
<img id="album-art-glow" class="album-art-glow" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E" alt="" aria-hidden="true">
<img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
</div>
<div class="player-details">
@@ -189,6 +190,9 @@
<button class="vinyl-toggle-btn" onclick="toggleVinylMode()" id="vinylToggle" data-i18n-title="player.vinyl" title="Vinyl mode">
<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="3" fill="currentColor"/><circle cx="12" cy="12" r="6.5" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.5"/></svg>
</button>
<button class="vinyl-toggle-btn" onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 18h2v-8H3v8zm4 0h2V6H7v12zm4 0h2V2h-2v16zm4 0h2v-6h-2v6zm4 0h2V9h-2v9z"/></svg>
</button>
</div>
</div>
</div>
@@ -273,6 +277,24 @@
<!-- Settings Section (Scripts, Callbacks, Links management) -->
<div class="settings-container" data-tab-content="settings" role="tabpanel" id="panel-settings">
<details class="settings-section" open id="audioDeviceSection" style="display: none;">
<summary data-i18n="settings.section.audio">Audio</summary>
<div class="settings-section-content">
<p class="settings-section-description" data-i18n="settings.audio.description">
Select which audio output device to capture for the visualizer.
</p>
<div class="audio-device-selector">
<label>
<span data-i18n="settings.audio.device">Loopback Device</span>
<select id="audioDeviceSelect" onchange="onAudioDeviceChanged()">
<option value="" data-i18n="settings.audio.auto">Auto-detect</option>
</select>
</label>
<div class="audio-device-status" id="audioDeviceStatus"></div>
</div>
</div>
</details>
<details class="settings-section" open>
<summary data-i18n="settings.section.scripts">Scripts</summary>
<div class="settings-section-content">

View File

@@ -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);
}

View File

@@ -23,6 +23,7 @@
"player.source": "Source:",
"player.unknown_source": "Unknown",
"player.vinyl": "Vinyl mode",
"player.visualizer": "Audio visualizer",
"state.playing": "Playing",
"state.paused": "Paused",
"state.stopped": "Stopped",
@@ -123,6 +124,15 @@
"settings.section.scripts": "Scripts",
"settings.section.callbacks": "Callbacks",
"settings.section.links": "Links",
"settings.section.audio": "Audio",
"settings.audio.description": "Select which audio output device to capture for the visualizer.",
"settings.audio.device": "Loopback Device",
"settings.audio.auto": "Auto-detect",
"settings.audio.status_active": "Capturing audio",
"settings.audio.status_available": "Available, not capturing",
"settings.audio.status_unavailable": "Unavailable",
"settings.audio.device_changed": "Audio device changed",
"settings.audio.device_change_failed": "Failed to change audio device",
"quick_access.no_items": "No quick actions or links configured",
"display.loading": "Loading monitors...",
"display.error": "Failed to load monitors",

View File

@@ -23,6 +23,7 @@
"player.source": "Источник:",
"player.unknown_source": "Неизвестно",
"player.vinyl": "Режим винила",
"player.visualizer": "Аудио визуализатор",
"state.playing": "Воспроизведение",
"state.paused": "Пауза",
"state.stopped": "Остановлено",
@@ -123,6 +124,15 @@
"settings.section.scripts": "Скрипты",
"settings.section.callbacks": "Колбэки",
"settings.section.links": "Ссылки",
"settings.section.audio": "Аудио",
"settings.audio.description": "Выберите аудиоустройство для захвата звука визуализатора.",
"settings.audio.device": "Устройство захвата",
"settings.audio.auto": "Автоопределение",
"settings.audio.status_active": "Захват аудио",
"settings.audio.status_available": "Доступно, не захватывает",
"settings.audio.status_unavailable": "Недоступно",
"settings.audio.device_changed": "Аудиоустройство изменено",
"settings.audio.device_change_failed": "Не удалось изменить аудиоустройство",
"quick_access.no_items": "Быстрые действия и ссылки не настроены",
"display.loading": "Загрузка мониторов...",
"display.error": "Не удалось загрузить мониторы",