Replace plain <select> dropdowns with a searchable command palette modal for 16 entity selectors across 6 editors (targets, streams, CSS sources, value sources, audio sources, pattern templates). Unified EntityPalette singleton + EntitySelect wrapper in core/entity-palette.js. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
486 lines
18 KiB
JavaScript
486 lines
18 KiB
JavaScript
/**
|
|
* 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 } from '../core/state.js';
|
|
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
|
|
import { t } from '../core/i18n.js';
|
|
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';
|
|
import { Modal } from '../core/modal.js';
|
|
import { ICON_MUSIC, getAudioSourceIcon, ICON_AUDIO_TEMPLATE, ICON_AUDIO_INPUT, ICON_AUDIO_LOOPBACK } from '../core/icons.js';
|
|
import { EntitySelect } from '../core/entity-palette.js';
|
|
import { loadPictureSources } from './streams.js';
|
|
|
|
class AudioSourceModal extends Modal {
|
|
constructor() { super('audio-source-modal'); }
|
|
|
|
snapshotValues() {
|
|
return {
|
|
name: document.getElementById('audio-source-name').value,
|
|
description: document.getElementById('audio-source-description').value,
|
|
type: document.getElementById('audio-source-type').value,
|
|
device: document.getElementById('audio-source-device').value,
|
|
audioTemplate: document.getElementById('audio-source-audio-template').value,
|
|
parent: document.getElementById('audio-source-parent').value,
|
|
channel: document.getElementById('audio-source-channel').value,
|
|
};
|
|
}
|
|
}
|
|
|
|
const audioSourceModal = new AudioSourceModal();
|
|
|
|
// ── EntitySelect instances for audio source editor ──
|
|
let _asTemplateEntitySelect = null;
|
|
let _asDeviceEntitySelect = null;
|
|
let _asParentEntitySelect = null;
|
|
|
|
// ── Modal ─────────────────────────────────────────────────────
|
|
|
|
export async function showAudioSourceModal(sourceType, editData) {
|
|
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').value = isEdit ? editData.id : '';
|
|
document.getElementById('audio-source-error').style.display = 'none';
|
|
|
|
const typeSelect = document.getElementById('audio-source-type');
|
|
typeSelect.value = isEdit ? editData.source_type : sourceType;
|
|
typeSelect.disabled = isEdit; // can't change type after creation
|
|
|
|
onAudioSourceTypeChange();
|
|
|
|
if (isEdit) {
|
|
document.getElementById('audio-source-name').value = editData.name || '';
|
|
document.getElementById('audio-source-description').value = editData.description || '';
|
|
|
|
if (editData.source_type === 'multichannel') {
|
|
_loadAudioTemplates(editData.audio_template_id);
|
|
document.getElementById('audio-source-audio-template').onchange = _filterDevicesBySelectedTemplate;
|
|
await _loadAudioDevices();
|
|
_selectAudioDevice(editData.device_index, editData.is_loopback);
|
|
} else {
|
|
_loadMultichannelSources(editData.audio_source_id);
|
|
document.getElementById('audio-source-channel').value = editData.channel || 'mono';
|
|
}
|
|
} else {
|
|
document.getElementById('audio-source-name').value = '';
|
|
document.getElementById('audio-source-description').value = '';
|
|
|
|
if (sourceType === 'multichannel') {
|
|
_loadAudioTemplates();
|
|
document.getElementById('audio-source-audio-template').onchange = _filterDevicesBySelectedTemplate;
|
|
await _loadAudioDevices();
|
|
} else {
|
|
_loadMultichannelSources();
|
|
}
|
|
}
|
|
|
|
audioSourceModal.open();
|
|
audioSourceModal.snapshot();
|
|
}
|
|
|
|
export async function closeAudioSourceModal() {
|
|
await audioSourceModal.close();
|
|
}
|
|
|
|
export function onAudioSourceTypeChange() {
|
|
const type = document.getElementById('audio-source-type').value;
|
|
document.getElementById('audio-source-multichannel-section').style.display = type === 'multichannel' ? '' : 'none';
|
|
document.getElementById('audio-source-mono-section').style.display = type === 'mono' ? '' : 'none';
|
|
}
|
|
|
|
// ── Save ──────────────────────────────────────────────────────
|
|
|
|
export async function saveAudioSource() {
|
|
const id = document.getElementById('audio-source-id').value;
|
|
const name = document.getElementById('audio-source-name').value.trim();
|
|
const sourceType = document.getElementById('audio-source-type').value;
|
|
const description = document.getElementById('audio-source-description').value.trim() || null;
|
|
const errorEl = document.getElementById('audio-source-error');
|
|
|
|
if (!name) {
|
|
errorEl.textContent = t('audio_source.error.name_required');
|
|
errorEl.style.display = '';
|
|
return;
|
|
}
|
|
|
|
const payload = { name, source_type: sourceType, description };
|
|
|
|
if (sourceType === 'multichannel') {
|
|
const deviceVal = document.getElementById('audio-source-device').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').value || null;
|
|
} else {
|
|
payload.audio_source_id = document.getElementById('audio-source-parent').value;
|
|
payload.channel = document.getElementById('audio-source-channel').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();
|
|
await loadPictureSources();
|
|
} catch (e) {
|
|
errorEl.textContent = e.message;
|
|
errorEl.style.display = '';
|
|
}
|
|
}
|
|
|
|
// ── Edit ──────────────────────────────────────────────────────
|
|
|
|
export async function editAudioSource(sourceId) {
|
|
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) {
|
|
if (e.isAuth) return;
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ── Clone ─────────────────────────────────────────────────────
|
|
|
|
export async function cloneAudioSource(sourceId) {
|
|
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) {
|
|
if (e.isAuth) return;
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ── Delete ────────────────────────────────────────────────────
|
|
|
|
export async function deleteAudioSource(sourceId) {
|
|
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');
|
|
await loadPictureSources();
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ── 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');
|
|
if (!select) return;
|
|
|
|
const prevOption = select.options[select.selectedIndex];
|
|
const prevName = prevOption ? prevOption.textContent : '';
|
|
|
|
const templateId = (document.getElementById('audio-source-audio-template') || {}).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 => {
|
|
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 => 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 => ({
|
|
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'),
|
|
});
|
|
}
|
|
}
|
|
|
|
function _selectAudioDevice(deviceIndex, isLoopback) {
|
|
const select = document.getElementById('audio-source-device');
|
|
if (!select) return;
|
|
const val = `${deviceIndex ?? -1}:${isLoopback !== false ? '1' : '0'}`;
|
|
const opt = Array.from(select.options).find(o => o.value === val);
|
|
if (opt) select.value = val;
|
|
}
|
|
|
|
function _loadMultichannelSources(selectedId) {
|
|
const select = document.getElementById('audio-source-parent');
|
|
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 => ({
|
|
value: s.id,
|
|
label: s.name,
|
|
icon: getAudioSourceIcon('multichannel'),
|
|
})),
|
|
placeholder: t('palette.search'),
|
|
});
|
|
}
|
|
}
|
|
|
|
function _loadAudioTemplates(selectedId) {
|
|
const select = document.getElementById('audio-source-audio-template');
|
|
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 => ({
|
|
value: tmpl.id,
|
|
label: tmpl.name,
|
|
icon: ICON_AUDIO_TEMPLATE,
|
|
desc: tmpl.engine_type.toUpperCase(),
|
|
})),
|
|
placeholder: t('palette.search'),
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── 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 = null;
|
|
let _testAudioAnimFrame = null;
|
|
let _testAudioLatest = 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) {
|
|
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');
|
|
_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) {
|
|
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');
|
|
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');
|
|
}
|
|
}
|