Some checks failed
Lint & Test / test (push) Failing after 27s
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.
673 lines
29 KiB
TypeScript
673 lines
29 KiB
TypeScript
/**
|
||
* 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: '20–250 Hz' },
|
||
{ value: 'mid', icon: _svg(P.music), label: t('audio_source.band.mid'), desc: '250–4000 Hz' },
|
||
{ value: 'treble', icon: _svg(P.zap), label: t('audio_source.band.treble'), desc: '4k–20k 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');
|
||
}
|
||
}
|