/** * 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) => `${d}`; // ── 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'); } }