refactor: comprehensive code quality, security, and release readiness improvements
Some checks failed
Lint & Test / test (push) Failing after 48s
Some checks failed
Lint & Test / test (push) Failing after 48s
Security: tighten CORS defaults, add webhook rate limiting, fix XSS in automations, guard WebSocket JSON.parse, validate ADB address input, seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors. Code quality: add Pydantic models for brightness/power endpoints, fix thread safety and name uniqueness in DeviceStore, immutable update pattern, split 6 oversized files into 16 focused modules, enable TypeScript strictNullChecks (741→102 errors), type state variables, add dom-utils helper, migrate 3 modules from inline onclick to event delegation, ProcessorDependencies dataclass. Performance: async store saves, health endpoint log level, command palette debounce, optimized entity-events comparison, fix service worker precache list. Testing: expand from 45 to 293 passing tests — add store tests (141), route tests (25), core logic tests (42), E2E flow tests (33), organize into tests/api/, tests/storage/, tests/core/, tests/e2e/. DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage build with non-root user and health check, docker-compose improvements, version bump to 0.2.0. Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76), create contexts/server-operations.md, fix .js→.ts references, fix env var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and .env.example.
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* 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) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
// ── 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 = '<div class="config-grid">';
|
||||
Object.entries(defaultConfig).forEach(([key, value]) => {
|
||||
const fieldType = typeof value === 'number' ? 'number' : 'text';
|
||||
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
|
||||
gridHtml += `
|
||||
<label class="config-grid-label" for="audio-config-${key}">${key}</label>
|
||||
<div class="config-grid-value">
|
||||
${typeof value === 'boolean' ? `
|
||||
<select id="audio-config-${key}" data-config-key="${key}">
|
||||
<option value="true" ${value ? 'selected' : ''}>true</option>
|
||||
<option value="false" ${!value ? 'selected' : ''}>false</option>
|
||||
</select>
|
||||
` : `
|
||||
<input type="${fieldType}" id="audio-config-${key}" data-config-key="${key}" value="${fieldValue}">
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
gridHtml += '</div>';
|
||||
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 `<option value="${val}">${escapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
if (devices.length === 0) {
|
||||
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user