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:
2026-02-26 13:55:46 +03:00
parent cbbaa852ed
commit bae2166bc2
35 changed files with 2163 additions and 402 deletions

View File

@@ -10,7 +10,7 @@
* This module manages the editor modal and API operations.
*/
import { _cachedAudioSources, set_cachedAudioSources } from '../core/state.js';
import { _cachedAudioSources, set_cachedAudioSources, _cachedAudioTemplates } from '../core/state.js';
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
@@ -26,6 +26,7 @@ class AudioSourceModal extends Modal {
description: document.getElementById('audio-source-description').value,
type: document.getElementById('audio-source-type').value,
device: document.getElementById('audio-source-device').value,
audioTemplate: document.getElementById('audio-source-audio-template').value,
parent: document.getElementById('audio-source-parent').value,
channel: document.getElementById('audio-source-channel').value,
};
@@ -57,6 +58,7 @@ export async function showAudioSourceModal(sourceType, editData) {
document.getElementById('audio-source-description').value = editData.description || '';
if (editData.source_type === 'multichannel') {
_loadAudioTemplates(editData.audio_template_id);
await _loadAudioDevices();
_selectAudioDevice(editData.device_index, editData.is_loopback);
} else {
@@ -68,6 +70,7 @@ export async function showAudioSourceModal(sourceType, editData) {
document.getElementById('audio-source-description').value = '';
if (sourceType === 'multichannel') {
_loadAudioTemplates();
await _loadAudioDevices();
} else {
_loadMultichannelSources();
@@ -110,6 +113,7 @@ export async function saveAudioSource() {
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').value || null;
} else {
payload.audio_source_id = document.getElementById('audio-source-parent').value;
payload.channel = document.getElementById('audio-source-channel').value;
@@ -223,3 +227,12 @@ function _loadMultichannelSources(selectedId) {
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
}
function _loadAudioTemplates(selectedId) {
const select = document.getElementById('audio-source-audio-template');
if (!select) return;
const templates = _cachedAudioTemplates || [];
select.innerHTML = templates.map(t =>
`<option value="${t.id}"${t.id === selectedId ? ' selected' : ''}>${escapeHtml(t.name)} (${t.engine_type.toUpperCase()})</option>`
).join('');
}