Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/audio-sources.ts
alexei.dolgolyov 997ff2fd70 Migrate frontend from JavaScript to TypeScript
- Rename all 54 .js files to .ts, update esbuild entry point
- Add tsconfig.json, TypeScript devDependency, typecheck script
- Create types.ts with 25+ interfaces matching backend Pydantic schemas
  (Device, OutputTarget, ColorStripSource, PatternTemplate, ValueSource,
  AudioSource, PictureSource, ScenePreset, SyncClock, Automation, etc.)
- Make DataCache generic (DataCache<T>) with typed state instances
- Type all state variables in state.ts with proper entity types
- Type all create*Card functions with proper entity interfaces
- Type all function parameters and return types across all 54 files
- Type core component constructors (CardSection, IconSelect, EntitySelect,
  FilterList, TagInput, TreeNav, Modal) with exported option interfaces
- Add comprehensive global.d.ts for window function declarations
- Type fetchWithAuth with FetchAuthOpts interface
- Remove all (window as any) casts in favor of global.d.ts declarations
- Zero tsc errors, esbuild bundle unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:08:23 +03:00

513 lines
20 KiB
TypeScript

/**
* Audio Sources — CRUD for multichannel and mono 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.
* CSS audio type references a mono 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 { TagInput } from '../core/tag-input.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,
tags: JSON.stringify(_audioSourceTagsInput ? _audioSourceTagsInput.getValue() : []),
};
}
}
const audioSourceModal = new AudioSourceModal();
// ── EntitySelect instances for audio source editor ──
let _asTemplateEntitySelect: EntitySelect | null = null;
let _asDeviceEntitySelect: EntitySelect | null = null;
let _asParentEntitySelect: EntitySelect | null = null;
// ── Modal ─────────────────────────────────────────────────────
export async function showAudioSourceModal(sourceType: any, editData?: any) {
const isEdit = !!editData;
const titleKey = isEdit
? (editData.source_type === 'mono' ? 'audio_source.edit.mono' : 'audio_source.edit.multichannel')
: (sourceType === 'mono' ? 'audio_source.add.mono' : 'audio_source.add.multichannel');
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 = isEdit ? editData.source_type : sourceType;
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;
await _loadAudioDevices();
_selectAudioDevice(editData.device_index, editData.is_loopback);
} else {
_loadMultichannelSources(editData.audio_source_id);
(document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono';
}
} 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;
await _loadAudioDevices();
} else {
_loadMultichannelSources();
}
}
// 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 || []) : []);
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';
}
// ── 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 {
payload.audio_source_id = (document.getElementById('audio-source-parent') as HTMLSelectElement).value;
payload.channel = (document.getElementById('audio-source-channel') as HTMLSelectElement).value;
}
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 = [];
if (engineType && _cachedDevicesByEngine[engineType]) {
devices = _cachedDevicesByEngine[engineType];
} else {
for (const devList of Object.values(_cachedDevicesByEngine)) {
devices = devices.concat(devList);
}
}
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 _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);
}
}
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');
}
}