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

@@ -13,6 +13,11 @@
animation: fadeIn 0.2s ease-out;
}
/* Confirm dialog must stack above all other modals */
#confirm-modal {
z-index: 2500;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }

View File

@@ -46,6 +46,8 @@ import {
loadPictureSources, switchStreamTab,
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest,
showAddAudioTemplateModal, editAudioTemplate, closeAudioTemplateModal, saveAudioTemplate, deleteAudioTemplate,
cloneAudioTemplate, onAudioEngineChange,
showAddStreamModal, editStream, closeStreamModal, saveStream, deleteStream,
onStreamTypeChange, onStreamDisplaySelected, onTestDisplaySelected,
showTestStreamModal, closeTestStreamModal, updateStreamTestDuration, runStreamTest,
@@ -238,6 +240,13 @@ Object.assign(window, {
cloneStream,
cloneCaptureTemplate,
clonePPTemplate,
showAddAudioTemplateModal,
editAudioTemplate,
closeAudioTemplateModal,
saveAudioTemplate,
deleteAudioTemplate,
cloneAudioTemplate,
onAudioEngineChange,
// kc-targets
createKCTargetCard,

View File

@@ -67,6 +67,7 @@ export const ICON_TEMPLATE = '\uD83D\uDCCB'; // 📋 (generic card head
export const ICON_CAPTURE_TEMPLATE = '\uD83D\uDCF7'; // 📷
export const ICON_PP_TEMPLATE = '\uD83D\uDD27'; // 🔧
export const ICON_PATTERN_TEMPLATE = '\uD83D\uDCC4'; // 📄
export const ICON_AUDIO_TEMPLATE = '\uD83C\uDFB5'; // 🎵
// ── Action constants ────────────────────────────────────────

View File

@@ -178,6 +178,19 @@ export const PATTERN_RECT_BORDERS = [
export let _cachedAudioSources = [];
export function set_cachedAudioSources(v) { _cachedAudioSources = v; }
// Audio templates
export let _cachedAudioTemplates = [];
export function set_cachedAudioTemplates(v) { _cachedAudioTemplates = v; }
export let availableAudioEngines = [];
export function setAvailableAudioEngines(v) { availableAudioEngines = v; }
export let currentEditingAudioTemplateId = null;
export function setCurrentEditingAudioTemplateId(v) { currentEditingAudioTemplateId = v; }
export let _audioTemplateNameManuallyEdited = false;
export function set_audioTemplateNameManuallyEdited(v) { _audioTemplateNameManuallyEdited = v; }
// Value sources
export let _cachedValueSources = [];
export function set_cachedValueSources(v) { _cachedValueSources = v; }

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('');
}

View File

@@ -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')}">&#x2715;</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() {

View File

@@ -789,6 +789,30 @@
"audio_source.deleted": "Audio source deleted",
"audio_source.delete.confirm": "Are you sure you want to delete this audio source?",
"audio_source.error.name_required": "Please enter a name",
"audio_source.audio_template": "Audio Template:",
"audio_source.audio_template.hint": "Audio capture template that defines which engine and settings to use for this device",
"audio_template.title": "🎵 Audio Templates",
"audio_template.add": "Add Audio Template",
"audio_template.edit": "Edit Audio Template",
"audio_template.name": "Template Name:",
"audio_template.name.placeholder": "My Audio Template",
"audio_template.description.label": "Description (optional):",
"audio_template.description.placeholder": "Describe this template...",
"audio_template.engine": "Audio Engine:",
"audio_template.engine.hint": "Select the audio capture backend to use. WASAPI is Windows-only with loopback support. Sounddevice is cross-platform.",
"audio_template.engine.unavailable": "Unavailable",
"audio_template.engine.unavailable.hint": "This engine is not available on your system",
"audio_template.config": "Configuration",
"audio_template.config.show": "Show configuration",
"audio_template.created": "Audio template created",
"audio_template.updated": "Audio template updated",
"audio_template.deleted": "Audio template deleted",
"audio_template.delete.confirm": "Are you sure you want to delete this audio template?",
"audio_template.error.load": "Failed to load audio templates",
"audio_template.error.engines": "Failed to load audio engines",
"audio_template.error.required": "Please fill in all required fields",
"audio_template.error.delete": "Failed to delete audio template",
"streams.group.value": "Value Sources",
"value_source.group.title": "🔢 Value Sources",

View File

@@ -789,6 +789,30 @@
"audio_source.deleted": "Аудиоисточник удалён",
"audio_source.delete.confirm": "Удалить этот аудиоисточник?",
"audio_source.error.name_required": "Введите название",
"audio_source.audio_template": "Аудиошаблон:",
"audio_source.audio_template.hint": "Шаблон аудиозахвата определяет, какой движок и настройки использовать для этого устройства",
"audio_template.title": "🎵 Аудиошаблоны",
"audio_template.add": "Добавить аудиошаблон",
"audio_template.edit": "Редактировать аудиошаблон",
"audio_template.name": "Название шаблона:",
"audio_template.name.placeholder": "Мой аудиошаблон",
"audio_template.description.label": "Описание (необязательно):",
"audio_template.description.placeholder": "Опишите этот шаблон...",
"audio_template.engine": "Аудиодвижок:",
"audio_template.engine.hint": "Выберите движок аудиозахвата. WASAPI — только Windows с поддержкой loopback. Sounddevice — кроссплатформенный.",
"audio_template.engine.unavailable": "Недоступен",
"audio_template.engine.unavailable.hint": "Этот движок недоступен в вашей системе",
"audio_template.config": "Конфигурация",
"audio_template.config.show": "Показать конфигурацию",
"audio_template.created": "Аудиошаблон создан",
"audio_template.updated": "Аудиошаблон обновлён",
"audio_template.deleted": "Аудиошаблон удалён",
"audio_template.delete.confirm": "Удалить этот аудиошаблон?",
"audio_template.error.load": "Не удалось загрузить аудиошаблоны",
"audio_template.error.engines": "Не удалось загрузить аудиодвижки",
"audio_template.error.required": "Пожалуйста, заполните все обязательные поля",
"audio_template.error.delete": "Не удалось удалить аудиошаблон",
"streams.group.value": "Источники значений",
"value_source.group.title": "🔢 Источники значений",