/** * 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 => `${d}`; 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> = { 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 ``; }).join(''); if (devices.length === 0) { select.innerHTML = ''; } 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 => `` ).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 => `` ).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 => `` ).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 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('[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('[data-card-section="audio-multi"], [data-card-section="audio-mono"], [data-card-section="audio-band-extract"]'); if (!section) return; const card = btn.closest('[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'); } }