diff --git a/public/app.js b/public/app.js
index 897b033..bdba183 100644
--- a/public/app.js
+++ b/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 = [
- '',
- '',
- ''
- ].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 || ""}`;
- 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
};
}
diff --git a/public/styles.css b/public/styles.css
index 9ec387d..a038b93 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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 {