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 dfc9bf7..3beec5c 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 {