Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/audio-sources.ts
alexei.dolgolyov 347b252f06
Some checks failed
Lint & Test / test (push) Failing after 27s
feat: add auto-name generation to all remaining creation modals
Audio sources: type + device/parent/channel/band detail
Weather sources: provider + coordinates (updates on geolocation)
Sync clocks: "Sync Clocks · Nx" (updates on speed slider)
Automations: scene name + condition count/logic
Scene presets: "Scenes · N targets" (updates on add/remove)
Pattern templates: "Pattern Templates · N rects" (updates on add/remove)

All follow the same pattern: name auto-generates on create, stops
when user manually edits the name field.
2026-03-24 21:44:28 +03:00

673 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Audio Sources — CRUD for multichannel, mono, and band extract audio sources.
*
* Audio sources are managed entities that encapsulate audio device
* configuration. Multichannel sources represent physical audio devices;
* mono sources extract a single channel from a multichannel source;
* band extract sources filter a parent source to a frequency band.
* CSS audio type references an audio source by ID.
*
* Card rendering is handled by streams.js (Audio tab).
* This module manages the editor modal and API operations.
*/
import { _cachedAudioSources, _cachedAudioTemplates, apiKey, audioSourcesCache } from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { ICON_MUSIC, getAudioSourceIcon, ICON_AUDIO_TEMPLATE, ICON_AUDIO_INPUT, ICON_AUDIO_LOOPBACK } from '../core/icons.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { IconSelect } from '../core/icon-select.ts';
import { TagInput } from '../core/tag-input.ts';
import * as P from '../core/icon-paths.ts';
import { loadPictureSources } from './streams.ts';
let _audioSourceTagsInput: TagInput | null = null;
class AudioSourceModal extends Modal {
constructor() { super('audio-source-modal'); }
onForceClose() {
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
}
snapshotValues() {
return {
name: (document.getElementById('audio-source-name') as HTMLInputElement).value,
description: (document.getElementById('audio-source-description') as HTMLInputElement).value,
type: (document.getElementById('audio-source-type') as HTMLSelectElement).value,
device: (document.getElementById('audio-source-device') as HTMLSelectElement).value,
audioTemplate: (document.getElementById('audio-source-audio-template') as HTMLSelectElement).value,
parent: (document.getElementById('audio-source-parent') as HTMLSelectElement).value,
channel: (document.getElementById('audio-source-channel') as HTMLSelectElement).value,
bandParent: (document.getElementById('audio-source-band-parent') as HTMLSelectElement).value,
band: (document.getElementById('audio-source-band') as HTMLSelectElement).value,
freqLow: (document.getElementById('audio-source-freq-low') as HTMLInputElement).value,
freqHigh: (document.getElementById('audio-source-freq-high') as HTMLInputElement).value,
tags: JSON.stringify(_audioSourceTagsInput ? _audioSourceTagsInput.getValue() : []),
};
}
}
const audioSourceModal = new AudioSourceModal();
// ── EntitySelect / IconSelect instances for audio source editor ──
let _asTemplateEntitySelect: EntitySelect | null = null;
let _asDeviceEntitySelect: EntitySelect | null = null;
let _asParentEntitySelect: EntitySelect | null = null;
let _asBandParentEntitySelect: EntitySelect | null = null;
let _asBandIconSelect: IconSelect | null = null;
const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
function _buildBandItems() {
return [
{ value: 'bass', icon: _svg(P.volume2), label: t('audio_source.band.bass'), desc: '20250 Hz' },
{ value: 'mid', icon: _svg(P.music), label: t('audio_source.band.mid'), desc: '2504000 Hz' },
{ value: 'treble', icon: _svg(P.zap), label: t('audio_source.band.treble'), desc: '4k20k Hz' },
{ value: 'custom', icon: _svg(P.slidersHorizontal), label: t('audio_source.band.custom') },
];
}
// ── Auto-name generation ──────────────────────────────────────
let _asNameManuallyEdited = false;
function _autoGenerateAudioSourceName() {
if (_asNameManuallyEdited) return;
if ((document.getElementById('audio-source-id') as HTMLInputElement).value) return;
const type = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
let name = '';
if (type === 'multichannel') {
const devSel = document.getElementById('audio-source-device') as HTMLSelectElement | null;
const devName = devSel?.selectedOptions[0]?.textContent?.trim();
name = devName || t('audio_source.type.multichannel');
} else if (type === 'mono') {
const parentSel = document.getElementById('audio-source-parent') as HTMLSelectElement | null;
const parentName = parentSel?.selectedOptions[0]?.textContent?.trim() || '';
const ch = (document.getElementById('audio-source-channel') as HTMLSelectElement).value;
const chLabel = ch === 'left' ? 'L' : ch === 'right' ? 'R' : 'M';
name = parentName ? `${parentName} · ${chLabel}` : t('audio_source.type.mono');
} else if (type === 'band_extract') {
const parentSel = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null;
const parentName = parentSel?.selectedOptions[0]?.textContent?.trim() || '';
const band = (document.getElementById('audio-source-band') as HTMLSelectElement).value;
const bandLabel = band === 'custom'
? `${(document.getElementById('audio-source-freq-low') as HTMLInputElement).value}${(document.getElementById('audio-source-freq-high') as HTMLInputElement).value} Hz`
: t(`audio_source.band.${band}`);
name = parentName ? `${parentName} · ${bandLabel}` : bandLabel;
}
(document.getElementById('audio-source-name') as HTMLInputElement).value = name;
}
// ── Modal ─────────────────────────────────────────────────────
const _titleKeys: Record<string, Record<string, string>> = {
multichannel: { add: 'audio_source.add.multichannel', edit: 'audio_source.edit.multichannel' },
mono: { add: 'audio_source.add.mono', edit: 'audio_source.edit.mono' },
band_extract: { add: 'audio_source.add.band_extract', edit: 'audio_source.edit.band_extract' },
};
export async function showAudioSourceModal(sourceType: any, editData?: any) {
const isEdit = !!editData;
const st = isEdit ? editData.source_type : sourceType;
const titleKey = _titleKeys[st]?.[isEdit ? 'edit' : 'add'] || _titleKeys.multichannel.add;
document.getElementById('audio-source-modal-title')!.innerHTML = `${ICON_MUSIC} ${t(titleKey)}`;
(document.getElementById('audio-source-id') as HTMLInputElement).value = isEdit ? editData.id : '';
(document.getElementById('audio-source-error') as HTMLElement).style.display = 'none';
const typeSelect = document.getElementById('audio-source-type') as HTMLSelectElement;
typeSelect.value = st;
typeSelect.disabled = isEdit; // can't change type after creation
onAudioSourceTypeChange();
if (isEdit) {
(document.getElementById('audio-source-name') as HTMLInputElement).value = editData.name || '';
(document.getElementById('audio-source-description') as HTMLInputElement).value = editData.description || '';
if (editData.source_type === 'multichannel') {
_loadAudioTemplates(editData.audio_template_id);
(document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = () => { _filterDevicesBySelectedTemplate(); _autoGenerateAudioSourceName(); };
await _loadAudioDevices();
_selectAudioDevice(editData.device_index, editData.is_loopback);
} else if (editData.source_type === 'mono') {
_loadMultichannelSources(editData.audio_source_id);
(document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono';
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName();
} else if (editData.source_type === 'band_extract') {
_loadBandParentSources(editData.audio_source_id);
(document.getElementById('audio-source-band') as HTMLSelectElement).value = editData.band || 'bass';
_ensureBandIconSelect();
(document.getElementById('audio-source-freq-low') as HTMLInputElement).value = String(editData.freq_low ?? 20);
(document.getElementById('audio-source-freq-high') as HTMLInputElement).value = String(editData.freq_high ?? 20000);
onBandPresetChange();
}
} else {
(document.getElementById('audio-source-name') as HTMLInputElement).value = '';
(document.getElementById('audio-source-description') as HTMLInputElement).value = '';
if (sourceType === 'multichannel') {
_loadAudioTemplates();
(document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = () => { _filterDevicesBySelectedTemplate(); _autoGenerateAudioSourceName(); };
await _loadAudioDevices();
} else if (sourceType === 'mono') {
_loadMultichannelSources();
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName();
} else if (sourceType === 'band_extract') {
_loadBandParentSources();
(document.getElementById('audio-source-band') as HTMLSelectElement).value = 'bass';
_ensureBandIconSelect();
(document.getElementById('audio-source-freq-low') as HTMLInputElement).value = '20';
(document.getElementById('audio-source-freq-high') as HTMLInputElement).value = '20000';
onBandPresetChange();
}
}
// Tags
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
_audioSourceTagsInput = new TagInput(document.getElementById('audio-source-tags-container'), { placeholder: t('tags.placeholder') });
_audioSourceTagsInput.setValue(isEdit ? (editData.tags || []) : []);
// Auto-name wiring
_asNameManuallyEdited = isEdit;
(document.getElementById('audio-source-name') as HTMLElement).oninput = () => { _asNameManuallyEdited = true; };
if (!isEdit) _autoGenerateAudioSourceName();
audioSourceModal.open();
audioSourceModal.snapshot();
}
export async function closeAudioSourceModal() {
await audioSourceModal.close();
}
export function onAudioSourceTypeChange() {
const type = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
(document.getElementById('audio-source-multichannel-section') as HTMLElement).style.display = type === 'multichannel' ? '' : 'none';
(document.getElementById('audio-source-mono-section') as HTMLElement).style.display = type === 'mono' ? '' : 'none';
(document.getElementById('audio-source-band-extract-section') as HTMLElement).style.display = type === 'band_extract' ? '' : 'none';
}
export function onBandPresetChange() {
const band = (document.getElementById('audio-source-band') as HTMLSelectElement).value;
(document.getElementById('audio-source-custom-freq') as HTMLElement).style.display = band === 'custom' ? '' : 'none';
}
// ── Save ──────────────────────────────────────────────────────
export async function saveAudioSource() {
const id = (document.getElementById('audio-source-id') as HTMLInputElement).value;
const name = (document.getElementById('audio-source-name') as HTMLInputElement).value.trim();
const sourceType = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
const description = (document.getElementById('audio-source-description') as HTMLInputElement).value.trim() || null;
const errorEl = document.getElementById('audio-source-error') as HTMLElement;
if (!name) {
errorEl.textContent = t('audio_source.error.name_required');
errorEl.style.display = '';
return;
}
const payload: any = { name, source_type: sourceType, description, tags: _audioSourceTagsInput ? _audioSourceTagsInput.getValue() : [] };
if (sourceType === 'multichannel') {
const deviceVal = (document.getElementById('audio-source-device') as HTMLSelectElement).value || '-1:1';
const [devIdx, devLoop] = deviceVal.split(':');
payload.device_index = parseInt(devIdx) || -1;
payload.is_loopback = devLoop !== '0';
payload.audio_template_id = (document.getElementById('audio-source-audio-template') as HTMLSelectElement).value || null;
} else if (sourceType === 'mono') {
payload.audio_source_id = (document.getElementById('audio-source-parent') as HTMLSelectElement).value;
payload.channel = (document.getElementById('audio-source-channel') as HTMLSelectElement).value;
} else if (sourceType === 'band_extract') {
payload.audio_source_id = (document.getElementById('audio-source-band-parent') as HTMLSelectElement).value;
payload.band = (document.getElementById('audio-source-band') as HTMLSelectElement).value;
if (payload.band === 'custom') {
payload.freq_low = parseFloat((document.getElementById('audio-source-freq-low') as HTMLInputElement).value) || 20;
payload.freq_high = parseFloat((document.getElementById('audio-source-freq-high') as HTMLInputElement).value) || 20000;
}
}
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/audio-sources/${id}` : '/audio-sources';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
audioSourceModal.forceClose();
audioSourcesCache.invalidate();
await loadPictureSources();
} catch (e: any) {
errorEl.textContent = e.message;
errorEl.style.display = '';
}
}
// ── Edit ──────────────────────────────────────────────────────
export async function editAudioSource(sourceId: any) {
try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
if (!resp.ok) throw new Error(t('audio_source.error.load'));
const data = await resp.json();
await showAudioSourceModal(data.source_type, data);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Clone ─────────────────────────────────────────────────────
export async function cloneAudioSource(sourceId: any) {
try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
if (!resp.ok) throw new Error(t('audio_source.error.load'));
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showAudioSourceModal(data.source_type, data);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Delete ────────────────────────────────────────────────────
export async function deleteAudioSource(sourceId: any) {
const confirmed = await showConfirm(t('audio_source.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('audio_source.deleted'), 'success');
audioSourcesCache.invalidate();
await loadPictureSources();
} catch (e: any) {
showToast(e.message, 'error');
}
}
// ── Refresh devices ───────────────────────────────────────────
export async function refreshAudioDevices() {
const btn = document.getElementById('audio-source-refresh-devices') as HTMLButtonElement | null;
if (btn) btn.disabled = true;
try {
await _loadAudioDevices();
} finally {
if (btn) btn.disabled = false;
}
}
// ── Helpers ───────────────────────────────────────────────────
let _cachedDevicesByEngine = {};
async function _loadAudioDevices() {
try {
const resp = await fetchWithAuth('/audio-devices');
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
_cachedDevicesByEngine = data.by_engine || {};
} catch {
_cachedDevicesByEngine = {};
}
_filterDevicesBySelectedTemplate();
}
function _filterDevicesBySelectedTemplate() {
const select = document.getElementById('audio-source-device') as HTMLSelectElement | null;
if (!select) return;
const prevOption = select.options[select.selectedIndex];
const prevName = prevOption ? prevOption.textContent : '';
const templateId = ((document.getElementById('audio-source-audio-template') as HTMLSelectElement | null) || { value: '' } as any).value;
const templates = _cachedAudioTemplates || [];
const template = templates.find(t => t.id === templateId);
const engineType = template ? template.engine_type : null;
let devices: any[] = [];
if (engineType && _cachedDevicesByEngine[engineType]) {
devices = _cachedDevicesByEngine[engineType];
} else {
for (const devList of Object.values(_cachedDevicesByEngine)) {
devices = devices.concat(devList as any[]);
}
}
select.innerHTML = devices.map((d: any) => {
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return `<option value="${val}">${escapeHtml(d.name)}</option>`;
}).join('');
if (devices.length === 0) {
select.innerHTML = '<option value="-1:1">Default</option>';
}
if (prevName) {
const match = Array.from(select.options).find((o: HTMLOptionElement) => o.textContent === prevName);
if (match) select.value = match.value;
}
if (_asDeviceEntitySelect) _asDeviceEntitySelect.destroy();
if (devices.length > 0) {
_asDeviceEntitySelect = new EntitySelect({
target: select,
getItems: () => devices.map((d: any) => ({
value: `${d.index}:${d.is_loopback ? '1' : '0'}`,
label: d.name,
icon: d.is_loopback ? ICON_AUDIO_LOOPBACK : ICON_AUDIO_INPUT,
desc: d.is_loopback ? 'Loopback' : 'Input',
})),
placeholder: t('palette.search'),
} as any);
}
}
function _selectAudioDevice(deviceIndex: any, isLoopback: any) {
const select = document.getElementById('audio-source-device') as HTMLSelectElement | null;
if (!select) return;
const val = `${deviceIndex ?? -1}:${isLoopback !== false ? '1' : '0'}`;
const opt = Array.from(select.options).find((o: HTMLOptionElement) => o.value === val);
if (opt) select.value = val;
}
function _loadMultichannelSources(selectedId?: any) {
const select = document.getElementById('audio-source-parent') as HTMLSelectElement | null;
if (!select) return;
const multichannel = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
select.innerHTML = multichannel.map(s =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
if (_asParentEntitySelect) _asParentEntitySelect.destroy();
if (multichannel.length > 0) {
_asParentEntitySelect = new EntitySelect({
target: select,
getItems: () => multichannel.map((s: any) => ({
value: s.id,
label: s.name,
icon: getAudioSourceIcon('multichannel'),
})),
placeholder: t('palette.search'),
} as any);
}
}
function _ensureBandIconSelect() {
const sel = document.getElementById('audio-source-band') as HTMLSelectElement | null;
if (!sel) return;
if (_asBandIconSelect) {
_asBandIconSelect.updateItems(_buildBandItems());
return;
}
_asBandIconSelect = new IconSelect({
target: sel,
items: _buildBandItems(),
columns: 2,
onChange: () => { onBandPresetChange(); _autoGenerateAudioSourceName(); },
});
}
function _loadBandParentSources(selectedId?: any) {
const select = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null;
if (!select) return;
// Band extract can reference any audio source type
const sources = _cachedAudioSources;
select.innerHTML = sources.map(s =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
if (_asBandParentEntitySelect) _asBandParentEntitySelect.destroy();
if (sources.length > 0) {
_asBandParentEntitySelect = new EntitySelect({
target: select,
getItems: () => sources.map((s: any) => ({
value: s.id,
label: s.name,
icon: getAudioSourceIcon(s.source_type),
desc: s.source_type,
})),
placeholder: t('palette.search'),
} as any);
}
}
function _loadAudioTemplates(selectedId?: any) {
const select = document.getElementById('audio-source-audio-template') as HTMLSelectElement | null;
if (!select) return;
const templates = _cachedAudioTemplates || [];
select.innerHTML = templates.map(t =>
`<option value="${t.id}"${t.id === selectedId ? ' selected' : ''}>${escapeHtml(t.name)} (${t.engine_type.toUpperCase()})</option>`
).join('');
if (_asTemplateEntitySelect) _asTemplateEntitySelect.destroy();
if (templates.length > 0) {
_asTemplateEntitySelect = new EntitySelect({
target: select,
getItems: () => templates.map((tmpl: any) => ({
value: tmpl.id,
label: tmpl.name,
icon: ICON_AUDIO_TEMPLATE,
desc: tmpl.engine_type.toUpperCase(),
})),
placeholder: t('palette.search'),
} as any);
}
}
// ── Audio Source Test (real-time spectrum) ────────────────────
const NUM_BANDS = 64;
const PEAK_DECAY = 0.02; // peak drop per frame
const BEAT_FLASH_DECAY = 0.06; // beat flash fade per frame
let _testAudioWs: WebSocket | null = null;
let _testAudioAnimFrame: number | null = null;
let _testAudioLatest: any = null;
let _testAudioPeaks = new Float32Array(NUM_BANDS);
let _testBeatFlash = 0;
const testAudioModal = new Modal('test-audio-source-modal', { backdrop: true, lock: true });
export function testAudioSource(sourceId: any) {
const statusEl = document.getElementById('audio-test-status');
if (statusEl) {
statusEl.textContent = t('audio_source.test.connecting');
statusEl.style.display = '';
}
// Reset state
_testAudioLatest = null;
_testAudioPeaks.fill(0);
_testBeatFlash = 0;
document.getElementById('audio-test-rms')!.textContent = '---';
document.getElementById('audio-test-peak')!.textContent = '---';
document.getElementById('audio-test-beat-dot')!.classList.remove('active');
testAudioModal.open();
// Size canvas to container
const canvas = document.getElementById('audio-test-canvas') as HTMLCanvasElement;
_sizeCanvas(canvas);
// Connect WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/audio-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey ?? '')}`;
try {
_testAudioWs = new WebSocket(wsUrl);
_testAudioWs.onopen = () => {
if (statusEl) statusEl.style.display = 'none';
};
_testAudioWs.onmessage = (event) => {
try {
_testAudioLatest = JSON.parse(event.data);
} catch {}
};
_testAudioWs.onclose = () => {
_testAudioWs = null;
};
_testAudioWs.onerror = () => {
showToast(t('audio_source.test.error'), 'error');
_cleanupTest();
};
} catch {
showToast(t('audio_source.test.error'), 'error');
_cleanupTest();
return;
}
// Start render loop
_testAudioAnimFrame = requestAnimationFrame(_renderLoop);
}
export function closeTestAudioSourceModal() {
_cleanupTest();
testAudioModal.forceClose();
}
function _cleanupTest() {
if (_testAudioAnimFrame) {
cancelAnimationFrame(_testAudioAnimFrame);
_testAudioAnimFrame = null;
}
if (_testAudioWs) {
_testAudioWs.onclose = null;
_testAudioWs.close();
_testAudioWs = null;
}
_testAudioLatest = null;
}
function _sizeCanvas(canvas: HTMLCanvasElement) {
const rect = canvas.parentElement!.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = 200 * dpr;
canvas.style.height = '200px';
canvas.getContext('2d')!.scale(dpr, dpr);
}
function _renderLoop() {
_renderAudioSpectrum();
if (testAudioModal.isOpen) {
_testAudioAnimFrame = requestAnimationFrame(_renderLoop);
}
}
// ── Event delegation for audio source card actions ──
const _audioSourceActions: Record<string, (id: string) => void> = {
'test-audio': testAudioSource,
'clone-audio': cloneAudioSource,
'edit-audio': editAudioSource,
};
export function initAudioSourceDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (!action) return;
// Only handle audio-source actions (prefixed with audio-)
const handler = _audioSourceActions[action];
if (handler) {
// Verify we're inside an audio source section
const section = btn.closest<HTMLElement>('[data-card-section="audio-multi"], [data-card-section="audio-mono"], [data-card-section="audio-band-extract"]');
if (!section) return;
const card = btn.closest<HTMLElement>('[data-id]');
const id = card?.getAttribute('data-id');
if (!id) return;
e.stopPropagation();
handler(id);
}
});
}
function _renderAudioSpectrum() {
const canvas = document.getElementById('audio-test-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ctx = canvas.getContext('2d')!;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
// Reset transform for clearing
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const data = _testAudioLatest;
if (!data || !data.spectrum) return;
const spectrum = data.spectrum;
const gap = 1;
const barWidth = (w - gap * (NUM_BANDS - 1)) / NUM_BANDS;
// Beat flash background
if (data.beat) _testBeatFlash = Math.min(1.0, data.beat_intensity + 0.3);
if (_testBeatFlash > 0) {
ctx.fillStyle = `rgba(255, 255, 255, ${_testBeatFlash * 0.08})`;
ctx.fillRect(0, 0, w, h);
_testBeatFlash = Math.max(0, _testBeatFlash - BEAT_FLASH_DECAY);
}
for (let i = 0; i < NUM_BANDS; i++) {
const val = Math.min(1, spectrum[i]);
const barHeight = val * h;
const x = i * (barWidth + gap);
const y = h - barHeight;
// Bar color: green → yellow → red based on value
const hue = (1 - val) * 120;
ctx.fillStyle = `hsl(${hue}, 85%, 50%)`;
ctx.fillRect(x, y, barWidth, barHeight);
// Falling peak indicator
if (val > _testAudioPeaks[i]) {
_testAudioPeaks[i] = val;
} else {
_testAudioPeaks[i] = Math.max(0, _testAudioPeaks[i] - PEAK_DECAY);
}
const peakY = h - _testAudioPeaks[i] * h;
const peakHue = (1 - _testAudioPeaks[i]) * 120;
ctx.fillStyle = `hsl(${peakHue}, 90%, 70%)`;
ctx.fillRect(x, peakY, barWidth, 2);
}
// Update stats
document.getElementById('audio-test-rms')!.textContent = (data.rms * 100).toFixed(1) + '%';
document.getElementById('audio-test-peak')!.textContent = (data.peak * 100).toFixed(1) + '%';
const beatDot = document.getElementById('audio-test-beat-dot');
if (data.beat) {
beatDot!.classList.add('active');
} else {
beatDot!.classList.remove('active');
}
}