/**
* Streams — Audio template CRUD, engine config, test modal.
* Extracted from streams.ts to reduce file size.
*/
import {
availableAudioEngines, setAvailableAudioEngines,
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
_cachedAudioTemplates,
audioTemplatesCache,
apiKey,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, setupBackdropClose } from '../core/ui.ts';
import {
getAudioEngineIcon,
ICON_AUDIO_TEMPLATE,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { TagInput } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts';
import { loadPictureSources } from './streams.ts';
const _icon = (d: string) => ``;
// ── TagInput instance for audio template modal ──
let _audioTemplateTagsInput: TagInput | null = null;
class AudioTemplateModal extends Modal {
constructor() { super('audio-template-modal'); }
snapshotValues() {
const vals: any = {
name: (document.getElementById('audio-template-name') as HTMLInputElement).value,
description: (document.getElementById('audio-template-description') as HTMLInputElement).value,
engine: (document.getElementById('audio-template-engine') as HTMLSelectElement).value,
tags: JSON.stringify(_audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : []),
};
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach((field: any) => {
vals['cfg_' + field.dataset.configKey] = field.value;
});
return vals;
}
onForceClose() {
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
setCurrentEditingAudioTemplateId(null);
set_audioTemplateNameManuallyEdited(false);
}
}
const audioTemplateModal = new AudioTemplateModal();
// ===== Audio Templates =====
async function loadAvailableAudioEngines() {
try {
const response = await fetchWithAuth('/audio-engines');
if (!response.ok) throw new Error(`Failed to load audio engines: ${response.status}`);
const data = await response.json();
setAvailableAudioEngines(data.engines || []);
const select = document.getElementById('audio-template-engine') as HTMLSelectElement;
select.innerHTML = '';
availableAudioEngines.forEach((engine: any) => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = `${engine.type.toUpperCase()}`;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('audio_template.engine.unavailable')})`;
}
select.appendChild(option);
});
if (!select.value) {
const firstAvailable = availableAudioEngines.find(e => e.available);
if (firstAvailable) select.value = firstAvailable.type;
}
// Update icon-grid selector with dynamic engine list
const items = availableAudioEngines
.filter(e => e.available)
.map(e => ({ value: e.type, icon: getAudioEngineIcon(e.type), label: e.type.toUpperCase(), desc: '' }));
if (_audioEngineIconSelect) { _audioEngineIconSelect.updateItems(items); }
else { _audioEngineIconSelect = new IconSelect({ target: select, items, columns: 2 }); }
_audioEngineIconSelect.setValue(select.value);
} catch (error) {
console.error('Error loading audio engines:', error);
showToast(t('audio_template.error.engines') + ': ' + error.message, 'error');
}
}
let _audioEngineIconSelect: IconSelect | null = null;
export async function onAudioEngineChange() {
const engineType = (document.getElementById('audio-template-engine') as HTMLSelectElement).value;
if (_audioEngineIconSelect) _audioEngineIconSelect.setValue(engineType);
const configSection = document.getElementById('audio-engine-config-section')!;
const configFields = document.getElementById('audio-engine-config-fields')!;
if (!engineType) { configSection.style.display = 'none'; return; }
const engine = availableAudioEngines.find((e: any) => e.type === engineType);
if (!engine) { configSection.style.display = 'none'; return; }
if (!_audioTemplateNameManuallyEdited && !(document.getElementById('audio-template-id') as HTMLInputElement).value) {
(document.getElementById('audio-template-name') as HTMLInputElement).value = engine.type.toUpperCase();
}
const hint = document.getElementById('audio-engine-availability-hint')!;
if (!engine.available) {
hint.textContent = t('audio_template.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
} else {
let gridHtml = '
';
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
gridHtml += `
${typeof value === 'boolean' ? `
` : `
`}
`;
});
gridHtml += '
';
configFields.innerHTML = gridHtml;
}
configSection.style.display = 'block';
}
function populateAudioEngineConfig(config: any) {
Object.entries(config).forEach(([key, value]: [string, any]) => {
const field = document.getElementById(`audio-config-${key}`) as HTMLInputElement | HTMLSelectElement | null;
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
function collectAudioEngineConfig() {
const config: any = {};
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach((field: any) => {
const key = field.dataset.configKey;
let value: any = field.value;
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
async function loadAudioTemplates() {
try {
await audioTemplatesCache.fetch();
await loadPictureSources();
} catch (error) {
if (error.isAuth) return;
console.error('Error loading audio templates:', error);
showToast(t('audio_template.error.load'), 'error');
}
}
export async function showAddAudioTemplateModal(cloneData: any = null) {
setCurrentEditingAudioTemplateId(null);
document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.add')}`;
(document.getElementById('audio-template-form') as HTMLFormElement).reset();
(document.getElementById('audio-template-id') as HTMLInputElement).value = '';
document.getElementById('audio-engine-config-section')!.style.display = 'none';
document.getElementById('audio-template-error')!.style.display = 'none';
set_audioTemplateNameManuallyEdited(!!cloneData);
(document.getElementById('audio-template-name') as HTMLInputElement).oninput = () => { set_audioTemplateNameManuallyEdited(true); };
await loadAvailableAudioEngines();
if (cloneData) {
(document.getElementById('audio-template-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
(document.getElementById('audio-template-description') as HTMLInputElement).value = cloneData.description || '';
(document.getElementById('audio-template-engine') as HTMLSelectElement).value = cloneData.engine_type;
await onAudioEngineChange();
populateAudioEngineConfig(cloneData.engine_config);
}
// Tags
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
_audioTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
audioTemplateModal.open();
audioTemplateModal.snapshot();
}
export async function editAudioTemplate(templateId: any) {
try {
const response = await fetchWithAuth(`/audio-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load audio template: ${response.status}`);
const template = await response.json();
setCurrentEditingAudioTemplateId(templateId);
document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.edit')}`;
(document.getElementById('audio-template-id') as HTMLInputElement).value = templateId;
(document.getElementById('audio-template-name') as HTMLInputElement).value = template.name;
(document.getElementById('audio-template-description') as HTMLInputElement).value = template.description || '';
await loadAvailableAudioEngines();
(document.getElementById('audio-template-engine') as HTMLSelectElement).value = template.engine_type;
await onAudioEngineChange();
populateAudioEngineConfig(template.engine_config);
document.getElementById('audio-template-error')!.style.display = 'none';
// Tags
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
_audioTemplateTagsInput.setValue(template.tags || []);
audioTemplateModal.open();
audioTemplateModal.snapshot();
} catch (error: any) {
console.error('Error loading audio template:', error);
showToast(t('audio_template.error.load') + ': ' + error.message, 'error');
}
}
export async function closeAudioTemplateModal() {
await audioTemplateModal.close();
}
export async function saveAudioTemplate() {
const templateId = currentEditingAudioTemplateId;
const name = (document.getElementById('audio-template-name') as HTMLInputElement).value.trim();
const engineType = (document.getElementById('audio-template-engine') as HTMLSelectElement).value;
if (!name || !engineType) {
showToast(t('audio_template.error.required'), 'error');
return;
}
const description = (document.getElementById('audio-template-description') as HTMLInputElement).value.trim();
const engineConfig = collectAudioEngineConfig();
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/audio-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save audio template');
}
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
audioTemplateModal.forceClose();
audioTemplatesCache.invalidate();
await loadAudioTemplates();
} catch (error) {
console.error('Error saving audio template:', error);
document.getElementById('audio-template-error')!.textContent = (error as any).message;
document.getElementById('audio-template-error')!.style.display = 'block';
}
}
export async function deleteAudioTemplate(templateId: any) {
const confirmed = await showConfirm(t('audio_template.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete audio template');
}
showToast(t('audio_template.deleted'), 'success');
audioTemplatesCache.invalidate();
await loadAudioTemplates();
} catch (error) {
console.error('Error deleting audio template:', error);
showToast(t('audio_template.error.delete') + ': ' + error.message, 'error');
}
}
export async function cloneAudioTemplate(templateId: any) {
try {
const resp = await fetchWithAuth(`/audio-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load audio template');
const tmpl = await resp.json();
showAddAudioTemplateModal(tmpl);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone audio template:', error);
showToast(t('audio_template.error.clone_failed'), 'error');
}
}
// ===== Audio Template Test =====
const NUM_BANDS_TPL = 64;
const TPL_PEAK_DECAY = 0.02;
const TPL_BEAT_FLASH_DECAY = 0.06;
let _tplTestWs: WebSocket | null = null;
let _tplTestAnimFrame: number | null = null;
let _tplTestLatest: any = null;
let _tplTestPeaks = new Float32Array(NUM_BANDS_TPL);
let _tplTestBeatFlash = 0;
let _currentTestAudioTemplateId: string | null = null;
const testAudioTemplateModal = new Modal('test-audio-template-modal', { backdrop: true, lock: true });
export async function showTestAudioTemplateModal(templateId: any) {
_currentTestAudioTemplateId = templateId;
// Find template's engine type so we show the correct device list
const template = _cachedAudioTemplates.find((t: any) => t.id === templateId);
const engineType = template ? template.engine_type : null;
// Load audio devices for picker — filter by engine type
const deviceSelect = document.getElementById('test-audio-template-device') as HTMLSelectElement;
try {
const resp = await fetchWithAuth('/audio-devices');
if (resp.ok) {
const data = await resp.json();
// Use engine-specific device list if available, fall back to flat list
const devices = (engineType && data.by_engine && data.by_engine[engineType])
? data.by_engine[engineType]
: (data.devices || []);
deviceSelect.innerHTML = devices.map(d => {
const label = d.name;
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return ``;
}).join('');
if (devices.length === 0) {
deviceSelect.innerHTML = '';
}
}
} catch {
deviceSelect.innerHTML = '';
}
// Restore last used device
const lastDevice = localStorage.getItem('lastAudioTestDevice');
if (lastDevice) {
const opt = Array.from(deviceSelect.options).find((o: any) => o.value === lastDevice);
if (opt) deviceSelect.value = lastDevice;
}
// Reset visual state
document.getElementById('audio-template-test-canvas')!.style.display = 'none';
document.getElementById('audio-template-test-stats')!.style.display = 'none';
document.getElementById('audio-template-test-status')!.style.display = 'none';
document.getElementById('test-audio-template-start-btn')!.style.display = '';
_tplCleanupTest();
testAudioTemplateModal.open();
}
export function closeTestAudioTemplateModal() {
_tplCleanupTest();
testAudioTemplateModal.forceClose();
_currentTestAudioTemplateId = null;
}
export function startAudioTemplateTest() {
if (!_currentTestAudioTemplateId) return;
const deviceVal = (document.getElementById('test-audio-template-device') as HTMLSelectElement).value || '-1:1';
const [devIdx, devLoop] = deviceVal.split(':');
localStorage.setItem('lastAudioTestDevice', deviceVal);
// Show canvas + stats, hide run button, disable device picker
document.getElementById('audio-template-test-canvas')!.style.display = '';
document.getElementById('audio-template-test-stats')!.style.display = '';
document.getElementById('test-audio-template-start-btn')!.style.display = 'none';
(document.getElementById('test-audio-template-device') as HTMLSelectElement).disabled = true;
const statusEl = document.getElementById('audio-template-test-status')!;
statusEl.textContent = t('audio_source.test.connecting');
statusEl.style.display = '';
// Reset state
_tplTestLatest = null;
_tplTestPeaks.fill(0);
_tplTestBeatFlash = 0;
document.getElementById('audio-template-test-rms')!.textContent = '---';
document.getElementById('audio-template-test-peak')!.textContent = '---';
document.getElementById('audio-template-test-beat-dot')!.classList.remove('active');
// Size canvas
const canvas = document.getElementById('audio-template-test-canvas') as HTMLCanvasElement;
_tplSizeCanvas(canvas);
// Connect WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/audio-templates/${_currentTestAudioTemplateId}/test/ws?token=${encodeURIComponent(apiKey ?? '')}&device_index=${devIdx}&is_loopback=${devLoop === '1' ? '1' : '0'}`;
try {
_tplTestWs = new WebSocket(wsUrl);
_tplTestWs.onopen = () => {
statusEl.style.display = 'none';
};
_tplTestWs.onmessage = (event) => {
try { _tplTestLatest = JSON.parse(event.data); } catch {}
};
_tplTestWs.onclose = () => { _tplTestWs = null; };
_tplTestWs.onerror = () => {
showToast(t('audio_source.test.error'), 'error');
_tplCleanupTest();
};
} catch {
showToast(t('audio_source.test.error'), 'error');
_tplCleanupTest();
return;
}
_tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop);
}
function _tplCleanupTest() {
if (_tplTestAnimFrame) {
cancelAnimationFrame(_tplTestAnimFrame);
_tplTestAnimFrame = null;
}
if (_tplTestWs) {
_tplTestWs.onclose = null;
_tplTestWs.close();
_tplTestWs = null;
}
_tplTestLatest = null;
// Re-enable device picker
const devSel = document.getElementById('test-audio-template-device') as HTMLSelectElement | null;
if (devSel) devSel.disabled = false;
}
function _tplSizeCanvas(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 _tplRenderLoop() {
_tplRenderSpectrum();
if (testAudioTemplateModal.isOpen && _tplTestWs) {
_tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop);
}
}
function _tplRenderSpectrum() {
const canvas = document.getElementById('audio-template-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;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const data = _tplTestLatest;
if (!data || !data.spectrum) return;
const spectrum = data.spectrum;
const gap = 1;
const barWidth = (w - gap * (NUM_BANDS_TPL - 1)) / NUM_BANDS_TPL;
// Beat flash
if (data.beat) _tplTestBeatFlash = Math.min(1.0, data.beat_intensity + 0.3);
if (_tplTestBeatFlash > 0) {
ctx.fillStyle = `rgba(255, 255, 255, ${_tplTestBeatFlash * 0.08})`;
ctx.fillRect(0, 0, w, h);
_tplTestBeatFlash = Math.max(0, _tplTestBeatFlash - TPL_BEAT_FLASH_DECAY);
}
for (let i = 0; i < NUM_BANDS_TPL; i++) {
const val = Math.min(1, spectrum[i]);
const barHeight = val * h;
const x = i * (barWidth + gap);
const y = h - barHeight;
const hue = (1 - val) * 120;
ctx.fillStyle = `hsl(${hue}, 85%, 50%)`;
ctx.fillRect(x, y, barWidth, barHeight);
if (val > _tplTestPeaks[i]) {
_tplTestPeaks[i] = val;
} else {
_tplTestPeaks[i] = Math.max(0, _tplTestPeaks[i] - TPL_PEAK_DECAY);
}
const peakY = h - _tplTestPeaks[i] * h;
const peakHue = (1 - _tplTestPeaks[i]) * 120;
ctx.fillStyle = `hsl(${peakHue}, 90%, 70%)`;
ctx.fillRect(x, peakY, barWidth, 2);
}
document.getElementById('audio-template-test-rms')!.textContent = (data.rms * 100).toFixed(1) + '%';
document.getElementById('audio-template-test-peak')!.textContent = (data.peak * 100).toFixed(1) + '%';
const beatDot = document.getElementById('audio-template-test-beat-dot')!;
if (data.beat) {
beatDot.classList.add('active');
} else {
beatDot.classList.remove('active');
}
}