TX-Audio: speechnorm Auto-Level + überarbeitete EQ-Oberfläche #5

Merged
oe6dxd-automation merged 1 commits from feat/eq-speechnorm-autolevel into main 2026-10-25 21:39:28 +01:00
2 changed files with 617 additions and 234 deletions

View File

@@ -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
};
}

View File

@@ -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 {