feat(microham-eq): speechnorm Auto-Level + überarbeitete EQ-Oberfläche
- Adaptive Pegel-Normalisierung (speechnorm) in die TX-Audio-EQ-Kette aufgenommen: gleicher Sendepegel aus jedem Browser/Mikrofon, unabhängig von Client-AGC und Mic-Pegel. Neue Sektion "Pegel-Normalisierung" mit Ziel-Pegel (%) und Max. Anhebung (dB); build/parse round-trip getestet. - speechnorm sitzt nach dem Noise Gate und vor dem Limiter, damit Grundrauschen in Pausen zuerst gegatet und nicht hochgezogen wird. - EQ-Admin-UI überarbeitet: gruppierte Sektionen, Preset-Chips (inkl. neuem "Konstanter Pegel"), Switch-Toggles, Wert-Badges, Accent-Slider, Live-Vorschau der FFmpeg-Kette; abhängige Regler werden deaktiviert, wenn ihre Sektion aus ist. - Stimmigkeit korrigiert: De-Esser-Frequenz liegt jetzt im SSB-Durchlass- band (war 3500–8000 Hz, oberhalb des Lowpass), Lowpass auf 3200 Hz begrenzt, agate mit explizitem attack; Gate-Parser tolerant gemacht. - Presets (flat/level/dx/ragchew) auf sinnvolle SSB-Werte neu abgestimmt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
555
public/app.js
555
public/app.js
@@ -3784,141 +3784,154 @@ function maybeAttachMicrohamEqBuilder(settingsForm, settingFields, plugin) {
|
||||
|
||||
const presets = {
|
||||
flat: {
|
||||
hp: 250,
|
||||
lp: 2800,
|
||||
mids: 0,
|
||||
presence: 0,
|
||||
deesserEnabled: false,
|
||||
deesserFreq: 5200,
|
||||
deesserCut: 0,
|
||||
compressorEnabled: false,
|
||||
compressorThreshold: -18,
|
||||
compressorRatio: 2.5,
|
||||
gateEnabled: false,
|
||||
gateThreshold: -52,
|
||||
gateRelease: 180,
|
||||
label: "Flat",
|
||||
desc: "Neutrale Referenz: kaum Klangfärbung, ohne Dynamik und ohne Auto-Level. Ideal zum Vergleichen und Feintunen.",
|
||||
hp: 150, lp: 3000, mids: 0, presence: 0,
|
||||
deesserEnabled: false, deesserFreq: 2900, deesserCut: 0,
|
||||
compressorEnabled: false, compressorThreshold: -18, compressorRatio: 2.5,
|
||||
gateEnabled: false, gateThreshold: -52, gateRelease: 180,
|
||||
normalizerEnabled: false, normalizerPeak: 90, normalizerMaxGain: 12,
|
||||
limiter: true
|
||||
},
|
||||
level: {
|
||||
label: "Konstanter Pegel",
|
||||
desc: "Auto-Level: hält den Sendepegel über alle Browser und Mikrofone konstant (speechnorm). Noise Gate kappt Pausen, Kompressor aus – die Normalisierung bestimmt die Lautheit.",
|
||||
hp: 200, lp: 2900, mids: 1.0, presence: 2.0,
|
||||
deesserEnabled: false, deesserFreq: 2900, deesserCut: 0,
|
||||
compressorEnabled: false, compressorThreshold: -18, compressorRatio: 2.5,
|
||||
gateEnabled: true, gateThreshold: -50, gateRelease: 180,
|
||||
normalizerEnabled: true, normalizerPeak: 92, normalizerMaxGain: 22,
|
||||
limiter: true
|
||||
},
|
||||
dx: {
|
||||
hp: 360,
|
||||
lp: 2500,
|
||||
mids: 1.0,
|
||||
presence: 1.8,
|
||||
deesserEnabled: true,
|
||||
deesserFreq: 5200,
|
||||
deesserCut: 2.0,
|
||||
compressorEnabled: true,
|
||||
compressorThreshold: -20,
|
||||
compressorRatio: 3.0,
|
||||
gateEnabled: false,
|
||||
gateThreshold: -50,
|
||||
gateRelease: 160,
|
||||
label: "DX",
|
||||
desc: "Durchsetzungsstark: enges Sprachband, angehobene Präsenz, Kompressor plus Auto-Level für maximale Lesbarkeit im Pile-Up.",
|
||||
hp: 400, lp: 2700, mids: 2.0, presence: 3.5,
|
||||
deesserEnabled: true, deesserFreq: 2800, deesserCut: 2.5,
|
||||
compressorEnabled: true, compressorThreshold: -18, compressorRatio: 3.5,
|
||||
gateEnabled: true, gateThreshold: -50, gateRelease: 150,
|
||||
normalizerEnabled: true, normalizerPeak: 92, normalizerMaxGain: 18,
|
||||
limiter: true
|
||||
},
|
||||
ragchew: {
|
||||
hp: 300,
|
||||
lp: 2800,
|
||||
mids: 0.5,
|
||||
presence: 1.0,
|
||||
deesserEnabled: true,
|
||||
deesserFreq: 5000,
|
||||
deesserCut: 1.2,
|
||||
compressorEnabled: true,
|
||||
compressorThreshold: -22,
|
||||
compressorRatio: 2.2,
|
||||
gateEnabled: true,
|
||||
gateThreshold: -55,
|
||||
gateRelease: 220,
|
||||
label: "Ragchew",
|
||||
desc: "Natürlicher Klang: breiteres Band, sanfte Anhebung, milder Kompressor und moderates Auto-Level für entspanntes QSO.",
|
||||
hp: 150, lp: 3000, mids: 1.0, presence: 1.5,
|
||||
deesserEnabled: true, deesserFreq: 3000, deesserCut: 1.5,
|
||||
compressorEnabled: true, compressorThreshold: -22, compressorRatio: 2.2,
|
||||
gateEnabled: true, gateThreshold: -55, gateRelease: 220,
|
||||
normalizerEnabled: true, normalizerPeak: 88, normalizerMaxGain: 12,
|
||||
limiter: true
|
||||
}
|
||||
};
|
||||
|
||||
const parsed = parseMicrohamEqArgs(extraArgsField.element.value);
|
||||
const initial = parsed || presets.flat;
|
||||
const initial = Object.assign({}, presets.flat, parsed || {});
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "plugin-eq-builder";
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "eq-head";
|
||||
const headText = document.createElement("div");
|
||||
headText.className = "eq-head-text";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = "TX Audio EQ";
|
||||
wrap.appendChild(title);
|
||||
|
||||
title.className = "eq-head-title";
|
||||
title.textContent = "TX Audio Equalizer";
|
||||
const hint = document.createElement("small");
|
||||
hint.className = "muted";
|
||||
hint.className = "eq-head-hint";
|
||||
hint.textContent = parsed
|
||||
? "EQ aus aktuellen Extra-Args geladen"
|
||||
: "Preset/Regler schreiben den FFmpeg-EQ in audioFfmpegExtraArgs";
|
||||
wrap.appendChild(hint);
|
||||
? "Aus den aktuellen Extra-Args geladen."
|
||||
: "Regler und Presets schreiben die FFmpeg-Kette in audioFfmpegExtraArgs.";
|
||||
headText.appendChild(title);
|
||||
headText.appendChild(hint);
|
||||
head.appendChild(headText);
|
||||
wrap.appendChild(head);
|
||||
|
||||
const presetRow = document.createElement("div");
|
||||
presetRow.className = "plugin-eq-row";
|
||||
|
||||
const presetLabel = document.createElement("span");
|
||||
presetLabel.className = "muted";
|
||||
presetLabel.textContent = "Preset";
|
||||
presetRow.appendChild(presetLabel);
|
||||
|
||||
const presetSelect = document.createElement("select");
|
||||
presetSelect.innerHTML = [
|
||||
'<option value="flat">Flat - neutral</option>',
|
||||
'<option value="dx">DX - durchsetzungsstark</option>',
|
||||
'<option value="ragchew">Ragchew - angenehmer Klang</option>'
|
||||
].join("");
|
||||
presetRow.appendChild(presetSelect);
|
||||
|
||||
const presetBtn = document.createElement("button");
|
||||
presetBtn.type = "button";
|
||||
presetBtn.className = "ghost-btn";
|
||||
presetBtn.textContent = "Preset anwenden";
|
||||
presetRow.appendChild(presetBtn);
|
||||
|
||||
wrap.appendChild(presetRow);
|
||||
const presetBar = document.createElement("div");
|
||||
presetBar.className = "eq-presets";
|
||||
const presetOrder = ["flat", "level", "dx", "ragchew"];
|
||||
const presetButtons = {};
|
||||
presetOrder.forEach((key) => {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "eq-chip" + (key === "level" ? " is-star" : "");
|
||||
chip.dataset.preset = key;
|
||||
chip.textContent = presets[key].label;
|
||||
presetBar.appendChild(chip);
|
||||
presetButtons[key] = chip;
|
||||
});
|
||||
wrap.appendChild(presetBar);
|
||||
|
||||
const presetInfo = document.createElement("small");
|
||||
presetInfo.className = "muted";
|
||||
presetInfo.className = "eq-preset-info";
|
||||
wrap.appendChild(presetInfo);
|
||||
|
||||
const hpControl = createEqSliderControl("Highpass (Hz)", 80, 600, 10, initial.hp);
|
||||
const lpControl = createEqSliderControl("Lowpass (Hz)", 1800, 4000, 10, initial.lp);
|
||||
const midsControl = createEqSliderControl("Mitten (dB)", -8, 12, 0.5, initial.mids);
|
||||
const presenceControl = createEqSliderControl("Presence (dB)", -8, 12, 0.5, initial.presence);
|
||||
const deesserFreqControl = createEqSliderControl("De-Esser (Hz)", 3500, 8000, 100, initial.deesserFreq);
|
||||
const deesserCutControl = createEqSliderControl("De-Esser Cut (dB)", 0, 8, 0.5, initial.deesserCut);
|
||||
const compThresholdControl = createEqSliderControl("Compressor Threshold (dB)", -40, -6, 1, initial.compressorThreshold);
|
||||
const compRatioControl = createEqSliderControl("Compressor Ratio", 1.2, 6, 0.1, initial.compressorRatio);
|
||||
const gateThresholdControl = createEqSliderControl("Gate Threshold (dB)", -70, -20, 1, initial.gateThreshold);
|
||||
const gateReleaseControl = createEqSliderControl("Gate Release (ms)", 60, 500, 10, initial.gateRelease);
|
||||
wrap.appendChild(hpControl.row);
|
||||
wrap.appendChild(lpControl.row);
|
||||
wrap.appendChild(midsControl.row);
|
||||
wrap.appendChild(presenceControl.row);
|
||||
const deesserEnabledRow = createEqCheckboxRow("De-Esser aktivieren", initial.deesserEnabled);
|
||||
wrap.appendChild(deesserEnabledRow.row);
|
||||
wrap.appendChild(deesserFreqControl.row);
|
||||
wrap.appendChild(deesserCutControl.row);
|
||||
const compressorEnabledRow = createEqCheckboxRow("Compressor aktivieren", initial.compressorEnabled);
|
||||
wrap.appendChild(compressorEnabledRow.row);
|
||||
wrap.appendChild(compThresholdControl.row);
|
||||
wrap.appendChild(compRatioControl.row);
|
||||
const gateEnabledRow = createEqCheckboxRow("Noise Gate aktivieren", initial.gateEnabled);
|
||||
wrap.appendChild(gateEnabledRow.row);
|
||||
wrap.appendChild(gateThresholdControl.row);
|
||||
wrap.appendChild(gateReleaseControl.row);
|
||||
// --- Section: Klangfilter (EQ) ---
|
||||
const eqSec = createEqSection("Klangfilter", { subtitle: "Bandbreite und Frequenzgang der Stimme." });
|
||||
const hpControl = createEqSliderControl("Highpass", 80, 600, 10, initial.hp, "Hz");
|
||||
const lpControl = createEqSliderControl("Lowpass", 2000, 3200, 10, initial.lp, "Hz");
|
||||
const midsControl = createEqSliderControl("Mitten · 1200 Hz", -8, 12, 0.5, initial.mids, "dB");
|
||||
const presenceControl = createEqSliderControl("Presence · 2100 Hz", -8, 12, 0.5, initial.presence, "dB");
|
||||
[hpControl, lpControl, midsControl, presenceControl].forEach((c) => eqSec.body.appendChild(c.row));
|
||||
wrap.appendChild(eqSec.section);
|
||||
|
||||
const limiterRow = document.createElement("label");
|
||||
limiterRow.className = "plugin-eq-checkbox";
|
||||
const limiterInput = document.createElement("input");
|
||||
limiterInput.type = "checkbox";
|
||||
limiterInput.checked = Boolean(initial.limiter);
|
||||
limiterRow.appendChild(limiterInput);
|
||||
const limiterText = document.createElement("span");
|
||||
limiterText.textContent = "Limiter aktivieren (alimiter=0.95)";
|
||||
limiterRow.appendChild(limiterText);
|
||||
wrap.appendChild(limiterRow);
|
||||
// --- Section: De-Esser (toggle) ---
|
||||
const deSec = createEqSection("De-Esser", {
|
||||
subtitle: "Zähmt harte Zischlaute – wirkt nur innerhalb des Durchlassbands.",
|
||||
toggle: true,
|
||||
toggleChecked: initial.deesserEnabled
|
||||
});
|
||||
const deesserFreqControl = createEqSliderControl("Frequenz", 2200, 3400, 50, initial.deesserFreq, "Hz");
|
||||
const deesserCutControl = createEqSliderControl("Absenkung", 0, 8, 0.5, initial.deesserCut, "dB");
|
||||
[deesserFreqControl, deesserCutControl].forEach((c) => deSec.body.appendChild(c.row));
|
||||
wrap.appendChild(deSec.section);
|
||||
|
||||
const preview = document.createElement("small");
|
||||
preview.className = "muted";
|
||||
wrap.appendChild(preview);
|
||||
// --- Section: Dynamik (compressor + gate + limiter) ---
|
||||
const dynSec = createEqSection("Dynamik", { subtitle: "Kompression, Rauschsperre und Übersteuerungsschutz." });
|
||||
const compToggle = createEqToggle("Kompressor", initial.compressorEnabled);
|
||||
compToggle.row.classList.add("eq-sub-toggle");
|
||||
dynSec.body.appendChild(compToggle.row);
|
||||
const compThresholdControl = createEqSliderControl("Threshold", -40, -6, 1, initial.compressorThreshold, "dB");
|
||||
const compRatioControl = createEqSliderControl("Ratio", 1.2, 6, 0.1, initial.compressorRatio, ":1");
|
||||
[compThresholdControl, compRatioControl].forEach((c) => dynSec.body.appendChild(c.row));
|
||||
const gateToggle = createEqToggle("Noise Gate", initial.gateEnabled);
|
||||
gateToggle.row.classList.add("eq-sub-toggle");
|
||||
dynSec.body.appendChild(gateToggle.row);
|
||||
const gateThresholdControl = createEqSliderControl("Gate Threshold", -70, -20, 1, initial.gateThreshold, "dB");
|
||||
const gateReleaseControl = createEqSliderControl("Gate Release", 60, 500, 10, initial.gateRelease, "ms");
|
||||
[gateThresholdControl, gateReleaseControl].forEach((c) => dynSec.body.appendChild(c.row));
|
||||
const limiterToggle = createEqToggle("Limiter · alimiter=0.95", initial.limiter);
|
||||
limiterToggle.row.classList.add("eq-sub-toggle");
|
||||
dynSec.body.appendChild(limiterToggle.row);
|
||||
wrap.appendChild(dynSec.section);
|
||||
|
||||
// --- Section: Pegel-Normalisierung (speechnorm, toggle) ---
|
||||
const normSec = createEqSection("Pegel-Normalisierung", {
|
||||
subtitle: "Auto-Level: gleicher Sendepegel aus jedem Browser und Mikrofon (speechnorm).",
|
||||
toggle: true,
|
||||
toggleChecked: initial.normalizerEnabled
|
||||
});
|
||||
const normPeakControl = createEqSliderControl("Ziel-Pegel", 50, 99, 1, initial.normalizerPeak, "%");
|
||||
const normGainControl = createEqSliderControl("Max. Anhebung", 0, 30, 1, initial.normalizerMaxGain, "dB");
|
||||
[normPeakControl, normGainControl].forEach((c) => normSec.body.appendChild(c.row));
|
||||
const normNote = document.createElement("p");
|
||||
normNote.className = "eq-note";
|
||||
normNote.textContent = "Tipp: Für konstanten Pegel das Noise Gate aktiv lassen – sonst wird Grundrauschen in Sprechpausen mit angehoben.";
|
||||
normSec.body.appendChild(normNote);
|
||||
wrap.appendChild(normSec.section);
|
||||
|
||||
// --- Live preview of the generated chain ---
|
||||
const previewWrap = document.createElement("div");
|
||||
previewWrap.className = "eq-preview";
|
||||
const previewLabel = document.createElement("span");
|
||||
previewLabel.className = "eq-preview-label";
|
||||
previewLabel.textContent = "FFmpeg-Kette";
|
||||
const preview = document.createElement("code");
|
||||
preview.className = "eq-preview-code";
|
||||
previewWrap.appendChild(previewLabel);
|
||||
previewWrap.appendChild(preview);
|
||||
wrap.appendChild(previewWrap);
|
||||
|
||||
const saveButton = settingsForm.querySelector('button[type="submit"]');
|
||||
if (saveButton && saveButton.parentNode === settingsForm) {
|
||||
@@ -3927,55 +3940,51 @@ function maybeAttachMicrohamEqBuilder(settingsForm, settingFields, plugin) {
|
||||
settingsForm.appendChild(wrap);
|
||||
}
|
||||
|
||||
const syncDisabled = () => {
|
||||
deesserFreqControl.setDisabled(!deSec.toggle.input.checked);
|
||||
deesserCutControl.setDisabled(!deSec.toggle.input.checked);
|
||||
deSec.section.classList.toggle("is-active", deSec.toggle.input.checked);
|
||||
compThresholdControl.setDisabled(!compToggle.input.checked);
|
||||
compRatioControl.setDisabled(!compToggle.input.checked);
|
||||
gateThresholdControl.setDisabled(!gateToggle.input.checked);
|
||||
gateReleaseControl.setDisabled(!gateToggle.input.checked);
|
||||
normPeakControl.setDisabled(!normSec.toggle.input.checked);
|
||||
normGainControl.setDisabled(!normSec.toggle.input.checked);
|
||||
normSec.section.classList.toggle("is-active", normSec.toggle.input.checked);
|
||||
};
|
||||
|
||||
const applyControlsToExtraArgs = () => {
|
||||
const hp = Number(hpControl.number.value);
|
||||
const lp = Number(lpControl.number.value);
|
||||
const mids = Number(midsControl.number.value);
|
||||
const presence = Number(presenceControl.number.value);
|
||||
const deesserEnabled = Boolean(deesserEnabledRow.input.checked);
|
||||
const deesserFreq = Number(deesserFreqControl.number.value);
|
||||
const deesserCut = Number(deesserCutControl.number.value);
|
||||
const compressorEnabled = Boolean(compressorEnabledRow.input.checked);
|
||||
const compressorThreshold = Number(compThresholdControl.number.value);
|
||||
const compressorRatio = Number(compRatioControl.number.value);
|
||||
const gateEnabled = Boolean(gateEnabledRow.input.checked);
|
||||
const gateThreshold = Number(gateThresholdControl.number.value);
|
||||
const gateRelease = Number(gateReleaseControl.number.value);
|
||||
const limiter = Boolean(limiterInput.checked);
|
||||
const args = buildMicrohamEqExtraArgs({
|
||||
hp,
|
||||
lp,
|
||||
mids,
|
||||
presence,
|
||||
deesserEnabled,
|
||||
deesserFreq,
|
||||
deesserCut,
|
||||
compressorEnabled,
|
||||
compressorThreshold,
|
||||
compressorRatio,
|
||||
gateEnabled,
|
||||
gateThreshold,
|
||||
gateRelease,
|
||||
limiter
|
||||
hp: Number(hpControl.number.value),
|
||||
lp: Number(lpControl.number.value),
|
||||
mids: Number(midsControl.number.value),
|
||||
presence: Number(presenceControl.number.value),
|
||||
deesserEnabled: Boolean(deSec.toggle.input.checked),
|
||||
deesserFreq: Number(deesserFreqControl.number.value),
|
||||
deesserCut: Number(deesserCutControl.number.value),
|
||||
compressorEnabled: Boolean(compToggle.input.checked),
|
||||
compressorThreshold: Number(compThresholdControl.number.value),
|
||||
compressorRatio: Number(compRatioControl.number.value),
|
||||
gateEnabled: Boolean(gateToggle.input.checked),
|
||||
gateThreshold: Number(gateThresholdControl.number.value),
|
||||
gateRelease: Number(gateReleaseControl.number.value),
|
||||
normalizerEnabled: Boolean(normSec.toggle.input.checked),
|
||||
normalizerPeak: Number(normPeakControl.number.value),
|
||||
normalizerMaxGain: Number(normGainControl.number.value),
|
||||
limiter: Boolean(limiterToggle.input.checked)
|
||||
});
|
||||
extraArgsField.element.value = args;
|
||||
preview.textContent = `Generierte Extra-Args: ${args}`;
|
||||
preview.textContent = args;
|
||||
};
|
||||
|
||||
const updatePresetInfo = () => {
|
||||
const selected = String(presetSelect.value || "flat");
|
||||
if (selected === "dx") {
|
||||
presetInfo.textContent = "DX: sprachfokussiert mit reduziertem Bass und moderater Praesenz, weniger kuenstlich abgestimmt.";
|
||||
return;
|
||||
}
|
||||
if (selected === "ragchew") {
|
||||
presetInfo.textContent = "Ragchew: natuerlicher Sprachklang mit leicht reduziertem Bass und sanfter Mitten/Praesenz-Anhebung.";
|
||||
return;
|
||||
}
|
||||
presetInfo.textContent = "Flat: neutrale Basis mit wenig Klangfaerbung, gut fuer Vergleich und Feintuning.";
|
||||
};
|
||||
|
||||
const bindControlUpdate = (control) => {
|
||||
const sliderControls = [
|
||||
hpControl, lpControl, midsControl, presenceControl,
|
||||
deesserFreqControl, deesserCutControl,
|
||||
compThresholdControl, compRatioControl,
|
||||
gateThresholdControl, gateReleaseControl,
|
||||
normPeakControl, normGainControl
|
||||
];
|
||||
sliderControls.forEach((control) => {
|
||||
control.range.addEventListener("input", () => {
|
||||
control.number.value = control.range.value;
|
||||
applyControlsToExtraArgs();
|
||||
@@ -3984,99 +3993,156 @@ function maybeAttachMicrohamEqBuilder(settingsForm, settingFields, plugin) {
|
||||
control.range.value = control.number.value;
|
||||
applyControlsToExtraArgs();
|
||||
});
|
||||
};
|
||||
|
||||
bindControlUpdate(hpControl);
|
||||
bindControlUpdate(lpControl);
|
||||
bindControlUpdate(midsControl);
|
||||
bindControlUpdate(presenceControl);
|
||||
bindControlUpdate(deesserFreqControl);
|
||||
bindControlUpdate(deesserCutControl);
|
||||
bindControlUpdate(compThresholdControl);
|
||||
bindControlUpdate(compRatioControl);
|
||||
bindControlUpdate(gateThresholdControl);
|
||||
bindControlUpdate(gateReleaseControl);
|
||||
limiterInput.addEventListener("change", applyControlsToExtraArgs);
|
||||
deesserEnabledRow.input.addEventListener("change", applyControlsToExtraArgs);
|
||||
compressorEnabledRow.input.addEventListener("change", applyControlsToExtraArgs);
|
||||
gateEnabledRow.input.addEventListener("change", applyControlsToExtraArgs);
|
||||
|
||||
presetBtn.addEventListener("click", () => {
|
||||
const selected = presets[presetSelect.value] || presets.flat;
|
||||
hpControl.range.value = String(selected.hp);
|
||||
hpControl.number.value = String(selected.hp);
|
||||
lpControl.range.value = String(selected.lp);
|
||||
lpControl.number.value = String(selected.lp);
|
||||
midsControl.range.value = String(selected.mids);
|
||||
midsControl.number.value = String(selected.mids);
|
||||
presenceControl.range.value = String(selected.presence);
|
||||
presenceControl.number.value = String(selected.presence);
|
||||
deesserEnabledRow.input.checked = Boolean(selected.deesserEnabled);
|
||||
deesserFreqControl.range.value = String(selected.deesserFreq);
|
||||
deesserFreqControl.number.value = String(selected.deesserFreq);
|
||||
deesserCutControl.range.value = String(selected.deesserCut);
|
||||
deesserCutControl.number.value = String(selected.deesserCut);
|
||||
compressorEnabledRow.input.checked = Boolean(selected.compressorEnabled);
|
||||
compThresholdControl.range.value = String(selected.compressorThreshold);
|
||||
compThresholdControl.number.value = String(selected.compressorThreshold);
|
||||
compRatioControl.range.value = String(selected.compressorRatio);
|
||||
compRatioControl.number.value = String(selected.compressorRatio);
|
||||
gateEnabledRow.input.checked = Boolean(selected.gateEnabled);
|
||||
gateThresholdControl.range.value = String(selected.gateThreshold);
|
||||
gateThresholdControl.number.value = String(selected.gateThreshold);
|
||||
gateReleaseControl.range.value = String(selected.gateRelease);
|
||||
gateReleaseControl.number.value = String(selected.gateRelease);
|
||||
limiterInput.checked = Boolean(selected.limiter);
|
||||
applyControlsToExtraArgs();
|
||||
});
|
||||
|
||||
presetSelect.addEventListener("change", updatePresetInfo);
|
||||
[deSec.toggle.input, compToggle.input, gateToggle.input, limiterToggle.input, normSec.toggle.input].forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
syncDisabled();
|
||||
applyControlsToExtraArgs();
|
||||
});
|
||||
});
|
||||
|
||||
preview.textContent = `Aktuelle Extra-Args: ${extraArgsField.element.value || "<leer>"}`;
|
||||
updatePresetInfo();
|
||||
const setActiveChip = (key) => {
|
||||
presetOrder.forEach((k) => presetButtons[k].classList.toggle("is-active", k === key));
|
||||
presetInfo.textContent = key && presets[key] ? presets[key].desc : "Eigene Einstellung – weicht von den Presets ab.";
|
||||
};
|
||||
|
||||
const applyPreset = (key) => {
|
||||
const selected = presets[key] || presets.flat;
|
||||
const set = (control, value) => {
|
||||
control.range.value = String(value);
|
||||
control.number.value = String(value);
|
||||
};
|
||||
set(hpControl, selected.hp);
|
||||
set(lpControl, selected.lp);
|
||||
set(midsControl, selected.mids);
|
||||
set(presenceControl, selected.presence);
|
||||
deSec.toggle.input.checked = Boolean(selected.deesserEnabled);
|
||||
set(deesserFreqControl, selected.deesserFreq);
|
||||
set(deesserCutControl, selected.deesserCut);
|
||||
compToggle.input.checked = Boolean(selected.compressorEnabled);
|
||||
set(compThresholdControl, selected.compressorThreshold);
|
||||
set(compRatioControl, selected.compressorRatio);
|
||||
gateToggle.input.checked = Boolean(selected.gateEnabled);
|
||||
set(gateThresholdControl, selected.gateThreshold);
|
||||
set(gateReleaseControl, selected.gateRelease);
|
||||
limiterToggle.input.checked = Boolean(selected.limiter);
|
||||
normSec.toggle.input.checked = Boolean(selected.normalizerEnabled);
|
||||
set(normPeakControl, selected.normalizerPeak);
|
||||
set(normGainControl, selected.normalizerMaxGain);
|
||||
syncDisabled();
|
||||
applyControlsToExtraArgs();
|
||||
setActiveChip(key);
|
||||
};
|
||||
|
||||
presetOrder.forEach((key) => {
|
||||
presetButtons[key].addEventListener("click", () => applyPreset(key));
|
||||
});
|
||||
|
||||
syncDisabled();
|
||||
preview.textContent = extraArgsField.element.value || "–";
|
||||
setActiveChip(parsed ? null : "flat");
|
||||
}
|
||||
|
||||
function createEqSliderControl(labelText, min, max, step, value) {
|
||||
function createEqSliderControl(labelText, min, max, step, value, unit) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "plugin-eq-row";
|
||||
row.className = "eq-control";
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "eq-control-head";
|
||||
const label = document.createElement("span");
|
||||
label.className = "muted";
|
||||
label.className = "eq-control-label";
|
||||
label.textContent = labelText;
|
||||
row.appendChild(label);
|
||||
head.appendChild(label);
|
||||
|
||||
const number = document.createElement("input");
|
||||
number.type = "number";
|
||||
number.className = "eq-control-value";
|
||||
number.min = String(min);
|
||||
number.max = String(max);
|
||||
number.step = String(step);
|
||||
number.value = String(value);
|
||||
head.appendChild(number);
|
||||
|
||||
if (unit) {
|
||||
const unitEl = document.createElement("span");
|
||||
unitEl.className = "eq-control-unit";
|
||||
unitEl.textContent = unit;
|
||||
head.appendChild(unitEl);
|
||||
}
|
||||
row.appendChild(head);
|
||||
|
||||
const range = document.createElement("input");
|
||||
range.type = "range";
|
||||
range.className = "eq-range";
|
||||
range.min = String(min);
|
||||
range.max = String(max);
|
||||
range.step = String(step);
|
||||
range.value = String(value);
|
||||
row.appendChild(range);
|
||||
|
||||
const number = document.createElement("input");
|
||||
number.type = "number";
|
||||
number.min = String(min);
|
||||
number.max = String(max);
|
||||
number.step = String(step);
|
||||
number.value = String(value);
|
||||
row.appendChild(number);
|
||||
const setDisabled = (disabled) => {
|
||||
const flag = Boolean(disabled);
|
||||
row.classList.toggle("is-disabled", flag);
|
||||
range.disabled = flag;
|
||||
number.disabled = flag;
|
||||
};
|
||||
|
||||
return { row, range, number };
|
||||
return { row, range, number, setDisabled };
|
||||
}
|
||||
|
||||
function createEqCheckboxRow(labelText, checked) {
|
||||
function createEqToggle(labelText, checked) {
|
||||
const row = document.createElement("label");
|
||||
row.className = "plugin-eq-checkbox";
|
||||
row.className = "eq-switch";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.checked = Boolean(checked);
|
||||
const track = document.createElement("span");
|
||||
track.className = "eq-switch-track";
|
||||
track.setAttribute("aria-hidden", "true");
|
||||
const text = document.createElement("span");
|
||||
text.className = "eq-switch-label";
|
||||
text.textContent = labelText;
|
||||
row.appendChild(input);
|
||||
const label = document.createElement("span");
|
||||
label.textContent = labelText;
|
||||
row.appendChild(label);
|
||||
row.appendChild(track);
|
||||
row.appendChild(text);
|
||||
return { row, input };
|
||||
}
|
||||
|
||||
function createEqSection(titleText, options) {
|
||||
const opts = options || {};
|
||||
const section = document.createElement("section");
|
||||
section.className = "eq-section";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "eq-section-head";
|
||||
const title = document.createElement("h4");
|
||||
title.className = "eq-section-title";
|
||||
title.textContent = titleText;
|
||||
header.appendChild(title);
|
||||
|
||||
let toggle = null;
|
||||
if (opts.toggle) {
|
||||
toggle = createEqToggle(opts.toggleLabel || "aktiv", opts.toggleChecked);
|
||||
toggle.row.classList.add("eq-section-toggle");
|
||||
header.appendChild(toggle.row);
|
||||
}
|
||||
section.appendChild(header);
|
||||
|
||||
if (opts.subtitle) {
|
||||
const sub = document.createElement("p");
|
||||
sub.className = "eq-section-sub";
|
||||
sub.textContent = opts.subtitle;
|
||||
section.appendChild(sub);
|
||||
}
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "eq-section-body";
|
||||
section.appendChild(body);
|
||||
|
||||
return { section, body, toggle };
|
||||
}
|
||||
|
||||
function buildMicrohamEqExtraArgs({
|
||||
hp,
|
||||
lp,
|
||||
@@ -4091,6 +4157,9 @@ function buildMicrohamEqExtraArgs({
|
||||
gateEnabled,
|
||||
gateThreshold,
|
||||
gateRelease,
|
||||
normalizerEnabled,
|
||||
normalizerPeak,
|
||||
normalizerMaxGain,
|
||||
limiter
|
||||
}) {
|
||||
const filters = [
|
||||
@@ -4100,7 +4169,7 @@ function buildMicrohamEqExtraArgs({
|
||||
`equalizer=f=2100:t=q:w=0.9:g=${Number(Number(presence || 0).toFixed(1))}`
|
||||
];
|
||||
if (deesserEnabled && Number(deesserCut || 0) > 0) {
|
||||
const freq = Math.round(Number(deesserFreq) || 5200);
|
||||
const freq = Math.round(Number(deesserFreq) || 2900);
|
||||
const cut = Number(Number(-Math.abs(Number(deesserCut || 0))).toFixed(1));
|
||||
filters.push(`equalizer=f=${freq}:t=q:w=1.1:g=${cut}`);
|
||||
}
|
||||
@@ -4114,7 +4183,17 @@ function buildMicrohamEqExtraArgs({
|
||||
const gateThresholdDb = Number(Number(gateThreshold || -55).toFixed(1));
|
||||
const gateThresholdLinear = Number(Math.pow(10, gateThresholdDb / 20).toFixed(6));
|
||||
const release = Math.round(Number(gateRelease || 180));
|
||||
filters.push(`agate=threshold=${gateThresholdLinear}:release=${release}`);
|
||||
filters.push(`agate=threshold=${gateThresholdLinear}:attack=5:release=${release}`);
|
||||
}
|
||||
// Adaptive level normalisation (speechnorm): lifts quiet inputs toward a target
|
||||
// peak so every browser/microphone reaches the same TX level. Placed after the
|
||||
// gate so background noise in pauses is cut first and not boosted.
|
||||
if (normalizerEnabled) {
|
||||
const peakPct = Math.min(99, Math.max(50, Math.round(Number(normalizerPeak || 90))));
|
||||
const peak = Number((peakPct / 100).toFixed(2));
|
||||
const gainDb = Math.min(30, Math.max(0, Math.round(Number(normalizerMaxGain || 12))));
|
||||
const expansion = Math.max(1, Number(Math.pow(10, gainDb / 20).toFixed(2)));
|
||||
filters.push(`speechnorm=p=${peak}:e=${expansion}:l=1`);
|
||||
}
|
||||
if (limiter) {
|
||||
filters.push("alimiter=limit=0.95");
|
||||
@@ -4149,10 +4228,37 @@ function parseMicrohamEqArgs(value) {
|
||||
const compressorEnabled = Boolean(compMatch);
|
||||
const compressorThreshold = compMatch ? Number((20 * Math.log10(Number(compMatch[1]))).toFixed(1)) : -20;
|
||||
const compressorRatio = compMatch ? Number(compMatch[2]) : 3;
|
||||
const gateMatch = text.match(/agate\s*=\s*threshold\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*:\s*release\s*=\s*([0-9]+(?:\.[0-9]+)?)/i);
|
||||
const gateEnabled = Boolean(gateMatch);
|
||||
const gateThreshold = gateMatch ? Number((20 * Math.log10(Number(gateMatch[1]))).toFixed(1)) : -55;
|
||||
const gateRelease = gateMatch ? Number(gateMatch[2]) : 180;
|
||||
const gateTokenMatch = text.match(/agate\s*=\s*([^",]+)/i);
|
||||
const gateEnabled = Boolean(gateTokenMatch);
|
||||
let gateThreshold = -55;
|
||||
let gateRelease = 180;
|
||||
if (gateTokenMatch) {
|
||||
const gateStr = gateTokenMatch[1];
|
||||
const gateThrMatch = gateStr.match(/(?:^|:)\s*threshold\s*=\s*([0-9]*\.?[0-9]+)/i);
|
||||
const gateRelMatch = gateStr.match(/(?:^|:)\s*release\s*=\s*([0-9]*\.?[0-9]+)/i);
|
||||
if (gateThrMatch) {
|
||||
gateThreshold = Number((20 * Math.log10(Number(gateThrMatch[1]))).toFixed(1));
|
||||
}
|
||||
if (gateRelMatch) {
|
||||
gateRelease = Number(gateRelMatch[1]);
|
||||
}
|
||||
}
|
||||
const normMatch = text.match(/speechnorm\s*=\s*([^",]+)/i);
|
||||
const normalizerEnabled = Boolean(normMatch);
|
||||
let normalizerPeak = 90;
|
||||
let normalizerMaxGain = 12;
|
||||
if (normMatch) {
|
||||
const normStr = normMatch[1];
|
||||
const peakMatch = normStr.match(/(?:^|:)\s*(?:peak|p)\s*=\s*([0-9]*\.?[0-9]+)/i);
|
||||
const expMatch = normStr.match(/(?:^|:)\s*(?:expansion|e)\s*=\s*([0-9]*\.?[0-9]+)/i);
|
||||
if (peakMatch) {
|
||||
normalizerPeak = Math.round(Number(peakMatch[1]) * 100);
|
||||
}
|
||||
if (expMatch) {
|
||||
const exp = Number(expMatch[1]);
|
||||
normalizerMaxGain = exp > 0 ? Math.round(20 * Math.log10(exp)) : 0;
|
||||
}
|
||||
}
|
||||
const limiter = /alimiter\s*=\s*limit\s*=\s*0\.95/i.test(text);
|
||||
return {
|
||||
hp,
|
||||
@@ -4168,6 +4274,9 @@ function parseMicrohamEqArgs(value) {
|
||||
gateEnabled,
|
||||
gateThreshold,
|
||||
gateRelease,
|
||||
normalizerEnabled,
|
||||
normalizerPeak,
|
||||
normalizerMaxGain,
|
||||
limiter
|
||||
};
|
||||
}
|
||||
|
||||
@@ -762,24 +762,298 @@ button:disabled {
|
||||
|
||||
.plugin-eq-builder {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem;
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
gap: 0.9rem;
|
||||
background:
|
||||
radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--primary) 10%, transparent), transparent 55%),
|
||||
color-mix(in srgb, var(--surface) 70%, transparent);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 6%, transparent);
|
||||
}
|
||||
|
||||
.plugin-eq-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr 88px;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
.eq-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.plugin-eq-checkbox {
|
||||
.eq-head-text {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.eq-head-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.eq-head-hint {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.eq-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.eq-chip {
|
||||
appearance: none;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: color-mix(in srgb, var(--surface) 55%, transparent);
|
||||
color: var(--text);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition), background var(--transition), transform var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.eq-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--primary) 55%, var(--surface-border));
|
||||
}
|
||||
|
||||
.eq-chip.is-star::before {
|
||||
content: "★ ";
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.eq-chip.is-active {
|
||||
background: var(--primary);
|
||||
color: var(--primary-ink);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 6px 18px color-mix(in srgb, var(--primary) 32%, transparent);
|
||||
}
|
||||
|
||||
.eq-preset-info {
|
||||
color: var(--muted);
|
||||
min-height: 1.1rem;
|
||||
}
|
||||
|
||||
.eq-section {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
background: color-mix(in srgb, var(--surface) 45%, transparent);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.eq-section.is-active {
|
||||
border-color: color-mix(in srgb, var(--primary) 45%, var(--surface-border));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 18%, transparent) inset;
|
||||
}
|
||||
|
||||
.eq-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.eq-section-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.eq-section-sub {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.eq-section-body {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.eq-note {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--primary) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
|
||||
.eq-control {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.eq-control.is-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.eq-control-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.eq-control-label {
|
||||
flex: 1;
|
||||
font-size: 0.86rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.eq-control-value {
|
||||
width: 72px;
|
||||
text-align: right;
|
||||
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.eq-control-unit {
|
||||
width: 2.2rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.eq-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--muted) 35%, transparent);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.eq-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
border: 2px solid var(--primary-ink);
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 45%, transparent);
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
|
||||
.eq-range::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
|
||||
.eq-range::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
border: 2px solid var(--primary-ink);
|
||||
}
|
||||
|
||||
.eq-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.eq-switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.eq-switch-track {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
border: 1px solid var(--surface-border);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.eq-switch-track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform var(--transition);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.eq-switch input:checked + .eq-switch-track {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.eq-switch input:checked + .eq-switch-track::after {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.eq-switch input:focus-visible + .eq-switch-track {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 35%, transparent);
|
||||
}
|
||||
|
||||
.eq-switch-label {
|
||||
font-size: 0.86rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.eq-sub-toggle {
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.eq-preview {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.eq-preview-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.eq-preview-code {
|
||||
display: block;
|
||||
font-family: "SFMono-Regular", "Consolas", "Liberation Mono", monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--bg-0) 55%, transparent);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.7rem;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.eq-control-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
dt {
|
||||
|
||||
Reference in New Issue
Block a user