Add audio capture engine template system with multi-backend support
Introduces an engine+template abstraction for audio capture, mirroring the existing screen capture engine pattern. This enables multiple audio backends (WASAPI for Windows, sounddevice for cross-platform) with per-source engine configuration via reusable templates. Backend: - AudioCaptureEngine ABC with WasapiEngine and SounddeviceEngine implementations - AudioEngineRegistry for engine discovery and factory creation - AudioAnalyzer class decouples FFT/RMS/beat analysis from engine-specific capture - ManagedAudioStream wraps engine stream + analyzer in background thread - AudioCaptureTemplate model and AudioTemplateStore with JSON CRUD - AudioCaptureManager keyed by (engine_type, device_index, is_loopback) - Auto-migration: default template created on startup, assigned to existing sources - Full REST API: CRUD for audio templates + engine listing with availability flags - audio_template_id added to MultichannelAudioSource model and API schemas Frontend: - Audio template cards in Streams > Audio tab with engine badge and config details - Audio template editor modal with engine selector and dynamic config fields - Audio template dropdown in multichannel audio source editor - Template name crosslink badge on multichannel audio source cards - Confirm modal z-index fix (always stacks above editor modals) - i18n keys for EN and RU Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,10 @@ import {
|
||||
_lastValidatedImageSource, set_lastValidatedImageSource,
|
||||
_cachedAudioSources, set_cachedAudioSources,
|
||||
_cachedValueSources, set_cachedValueSources,
|
||||
_cachedAudioTemplates, set_cachedAudioTemplates,
|
||||
availableAudioEngines, setAvailableAudioEngines,
|
||||
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
|
||||
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
|
||||
_sourcesLoading, set_sourcesLoading,
|
||||
apiKey,
|
||||
} from '../core/state.js';
|
||||
@@ -35,6 +39,7 @@ import {
|
||||
getEngineIcon, getPictureSourceIcon, getAudioSourceIcon,
|
||||
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
|
||||
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
|
||||
ICON_AUDIO_TEMPLATE,
|
||||
} from '../core/icons.js';
|
||||
|
||||
// ── Card section instances ──
|
||||
@@ -45,6 +50,7 @@ const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postproce
|
||||
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')" });
|
||||
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')" });
|
||||
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')" });
|
||||
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()" });
|
||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()" });
|
||||
|
||||
// Re-render picture sources when language changes
|
||||
@@ -113,12 +119,34 @@ class PPTemplateEditorModal extends Modal {
|
||||
}
|
||||
}
|
||||
|
||||
class AudioTemplateModal extends Modal {
|
||||
constructor() { super('audio-template-modal'); }
|
||||
|
||||
snapshotValues() {
|
||||
const vals = {
|
||||
name: document.getElementById('audio-template-name').value,
|
||||
description: document.getElementById('audio-template-description').value,
|
||||
engine: document.getElementById('audio-template-engine').value,
|
||||
};
|
||||
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => {
|
||||
vals['cfg_' + field.dataset.configKey] = field.value;
|
||||
});
|
||||
return vals;
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
setCurrentEditingAudioTemplateId(null);
|
||||
set_audioTemplateNameManuallyEdited(false);
|
||||
}
|
||||
}
|
||||
|
||||
const templateModal = new CaptureTemplateModal();
|
||||
const testTemplateModal = new Modal('test-template-modal');
|
||||
const streamModal = new StreamEditorModal();
|
||||
const testStreamModal = new Modal('test-stream-modal');
|
||||
const ppTemplateModal = new PPTemplateEditorModal();
|
||||
const testPPTemplateModal = new Modal('test-pp-template-modal');
|
||||
const audioTemplateModal = new AudioTemplateModal();
|
||||
|
||||
// ===== Capture Templates =====
|
||||
|
||||
@@ -511,6 +539,261 @@ export async function deleteTemplate(templateId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 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');
|
||||
select.innerHTML = '';
|
||||
|
||||
availableAudioEngines.forEach(engine => {
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading audio engines:', error);
|
||||
showToast(t('audio_template.error.engines') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function onAudioEngineChange() {
|
||||
const engineType = document.getElementById('audio-template-engine').value;
|
||||
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 => e.type === engineType);
|
||||
if (!engine) { configSection.style.display = 'none'; return; }
|
||||
|
||||
if (!_audioTemplateNameManuallyEdited && !document.getElementById('audio-template-id').value) {
|
||||
document.getElementById('audio-template-name').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) {
|
||||
Object.entries(config).forEach(([key, value]) => {
|
||||
const field = document.getElementById(`audio-config-${key}`);
|
||||
if (field) {
|
||||
if (field.tagName === 'SELECT') {
|
||||
field.value = value.toString();
|
||||
} else {
|
||||
field.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function collectAudioEngineConfig() {
|
||||
const config = {};
|
||||
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => {
|
||||
const key = field.dataset.configKey;
|
||||
let value = 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 {
|
||||
const response = await fetchWithAuth('/audio-templates');
|
||||
if (!response.ok) throw new Error(`Failed to load audio templates: ${response.status}`);
|
||||
const data = await response.json();
|
||||
set_cachedAudioTemplates(data.templates || []);
|
||||
renderPictureSourcesList(_cachedStreams);
|
||||
} 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 = null) {
|
||||
setCurrentEditingAudioTemplateId(null);
|
||||
document.getElementById('audio-template-modal-title').textContent = t('audio_template.add');
|
||||
document.getElementById('audio-template-form').reset();
|
||||
document.getElementById('audio-template-id').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').oninput = () => { set_audioTemplateNameManuallyEdited(true); };
|
||||
|
||||
await loadAvailableAudioEngines();
|
||||
|
||||
if (cloneData) {
|
||||
document.getElementById('audio-template-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
document.getElementById('audio-template-description').value = cloneData.description || '';
|
||||
document.getElementById('audio-template-engine').value = cloneData.engine_type;
|
||||
await onAudioEngineChange();
|
||||
populateAudioEngineConfig(cloneData.engine_config);
|
||||
}
|
||||
|
||||
audioTemplateModal.open();
|
||||
audioTemplateModal.snapshot();
|
||||
}
|
||||
|
||||
export async function editAudioTemplate(templateId) {
|
||||
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').textContent = t('audio_template.edit');
|
||||
document.getElementById('audio-template-id').value = templateId;
|
||||
document.getElementById('audio-template-name').value = template.name;
|
||||
document.getElementById('audio-template-description').value = template.description || '';
|
||||
|
||||
await loadAvailableAudioEngines();
|
||||
document.getElementById('audio-template-engine').value = template.engine_type;
|
||||
await onAudioEngineChange();
|
||||
populateAudioEngineConfig(template.engine_config);
|
||||
|
||||
document.getElementById('audio-template-error').style.display = 'none';
|
||||
|
||||
audioTemplateModal.open();
|
||||
audioTemplateModal.snapshot();
|
||||
} catch (error) {
|
||||
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').value.trim();
|
||||
const engineType = document.getElementById('audio-template-engine').value;
|
||||
|
||||
if (!name || !engineType) {
|
||||
showToast(t('audio_template.error.required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const description = document.getElementById('audio-template-description').value.trim();
|
||||
const engineConfig = collectAudioEngineConfig();
|
||||
|
||||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null };
|
||||
|
||||
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();
|
||||
await loadAudioTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error saving audio template:', error);
|
||||
document.getElementById('audio-template-error').textContent = error.message;
|
||||
document.getElementById('audio-template-error').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAudioTemplate(templateId) {
|
||||
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');
|
||||
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) {
|
||||
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('Failed to clone audio template', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Picture Sources =====
|
||||
|
||||
export async function loadPictureSources() {
|
||||
@@ -518,13 +801,14 @@ export async function loadPictureSources() {
|
||||
set_sourcesLoading(true);
|
||||
setTabRefreshing('streams-list', true);
|
||||
try {
|
||||
const [filtersResp, ppResp, captResp, streamsResp, audioResp, valueResp] = await Promise.all([
|
||||
const [filtersResp, ppResp, captResp, streamsResp, audioResp, valueResp, audioTplResp] = await Promise.all([
|
||||
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
|
||||
fetchWithAuth('/postprocessing-templates'),
|
||||
fetchWithAuth('/capture-templates'),
|
||||
fetchWithAuth('/picture-sources'),
|
||||
fetchWithAuth('/audio-sources'),
|
||||
fetchWithAuth('/value-sources'),
|
||||
fetchWithAuth('/audio-templates'),
|
||||
]);
|
||||
|
||||
if (filtersResp && filtersResp.ok) {
|
||||
@@ -547,6 +831,10 @@ export async function loadPictureSources() {
|
||||
const vd = await valueResp.json();
|
||||
set_cachedValueSources(vd.sources || []);
|
||||
}
|
||||
if (audioTplResp && audioTplResp.ok) {
|
||||
const atd = await audioTplResp.json();
|
||||
set_cachedAudioTemplates(atd.templates || []);
|
||||
}
|
||||
if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`);
|
||||
const data = await streamsResp.json();
|
||||
set_cachedStreams(data.streams || []);
|
||||
@@ -745,7 +1033,9 @@ function renderPictureSourcesList(streams) {
|
||||
const devIdx = src.device_index ?? -1;
|
||||
const loopback = src.is_loopback !== false;
|
||||
const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`;
|
||||
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>`;
|
||||
const tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null;
|
||||
const tplBadge = tpl ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.audio_template'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-templates','data-audio-template-id','${src.audio_template_id}')">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}</span>` : '';
|
||||
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>${tplBadge}`;
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -764,6 +1054,40 @@ function renderPictureSourcesList(streams) {
|
||||
`;
|
||||
};
|
||||
|
||||
const renderAudioTemplateCard = (template) => {
|
||||
const configEntries = Object.entries(template.engine_config || {});
|
||||
return `
|
||||
<div class="template-card" data-audio-template-id="${template.id}">
|
||||
<button class="card-remove-btn" onclick="deleteAudioTemplate('${template.id}')" title="${t('common.delete')}">✕</button>
|
||||
<div class="template-card-header">
|
||||
<div class="template-name">${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}</div>
|
||||
</div>
|
||||
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('audio_template.engine')}">${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}</span>
|
||||
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('audio_template.config.show')}">🔧 ${configEntries.length}</span>` : ''}
|
||||
</div>
|
||||
${configEntries.length > 0 ? `
|
||||
<details class="template-config-details">
|
||||
<summary>${t('audio_template.config.show')}</summary>
|
||||
<table class="config-table">
|
||||
${configEntries.map(([key, val]) => `
|
||||
<tr>
|
||||
<td class="config-key">${escapeHtml(key)}</td>
|
||||
<td class="config-value">${escapeHtml(String(val))}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
</details>
|
||||
` : ''}
|
||||
<div class="template-card-actions">
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneAudioTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="editAudioTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const panels = tabs.map(tab => {
|
||||
let panelContent = '';
|
||||
|
||||
@@ -778,7 +1102,8 @@ function renderPictureSourcesList(streams) {
|
||||
} else if (tab.key === 'audio') {
|
||||
panelContent =
|
||||
csAudioMulti.render(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) +
|
||||
csAudioMono.render(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
|
||||
csAudioMono.render(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) +
|
||||
csAudioTemplates.render(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) })));
|
||||
} else if (tab.key === 'value') {
|
||||
panelContent = csValueSources.render(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
|
||||
} else {
|
||||
@@ -789,7 +1114,7 @@ function renderPictureSourcesList(streams) {
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = tabBar + panels;
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csStaticStreams, csValueSources]);
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources]);
|
||||
}
|
||||
|
||||
export function onStreamTypeChange() {
|
||||
|
||||
Reference in New Issue
Block a user