7 Commits

Author SHA1 Message Date
341cb6cfab Merge pull request 'TX-Audio: speechnorm Auto-Level + überarbeitete EQ-Oberfläche' (#5) from feat/eq-speechnorm-autolevel into main 2026-10-25 21:39:28 +01:00
ARCG Admin (Claude)
5ac966572a feat(microham-eq): speechnorm Auto-Level + überarbeitete EQ-Oberfläche
- Adaptive Pegel-Normalisierung (speechnorm) in die TX-Audio-EQ-Kette
  aufgenommen: gleicher Sendepegel aus jedem Browser/Mikrofon, unabhängig
  von Client-AGC und Mic-Pegel. Neue Sektion "Pegel-Normalisierung" mit
  Ziel-Pegel (%) und Max. Anhebung (dB); build/parse round-trip getestet.
- speechnorm sitzt nach dem Noise Gate und vor dem Limiter, damit
  Grundrauschen in Pausen zuerst gegatet und nicht hochgezogen wird.
- EQ-Admin-UI überarbeitet: gruppierte Sektionen, Preset-Chips (inkl.
  neuem "Konstanter Pegel"), Switch-Toggles, Wert-Badges, Accent-Slider,
  Live-Vorschau der FFmpeg-Kette; abhängige Regler werden deaktiviert,
  wenn ihre Sektion aus ist.
- Stimmigkeit korrigiert: De-Esser-Frequenz liegt jetzt im SSB-Durchlass-
  band (war 3500–8000 Hz, oberhalb des Lowpass), Lowpass auf 3200 Hz
  begrenzt, agate mit explizitem attack; Gate-Parser tolerant gemacht.
- Presets (flat/level/dx/ragchew) auf sinnvolle SSB-Werte neu abgestimmt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-25 21:34:32 +01:00
d02bf05a33 Merge pull request 'feat(ui): Impressum/Datenschutz footer links' (#4) from feat/footer-legal-links into main 2026-10-23 11:19:53 +02:00
ARCG Admin (Claude)
487c801bab feat(ui): add Impressum/Datenschutz footer links
remote.arcg.at has the same Medieninhaber (ARCG) as the homepage, so link the
legal pages there: Impressum -> amateurradioclubgraz.at/imprint/, Datenschutz ->
amateurradioclubgraz.at/datenschutz/ (the homepage DSE now covers the remote
station processing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-23 11:19:52 +02:00
dxd
1acbb569dd Merge pull request 'feat(wavelog): link to the Wavelog instance next to the API key field' (#3) from feat/wavelog-instance-link into main
Reviewed-on: #3
2026-10-22 21:46:20 +02:00
ARCG Admin (Claude)
9862f751a2 feat(wavelog): link to the Wavelog instance next to the API key field
Show an "Open Wavelog" link beside the Wavelog API Key heading in the
OpenWebRX panel. The URL comes from the runtime WAVELOG_BASE_URL (exposed
via GET /v1/me and the OpenWebRX session response), so the private hostname
is never hardcoded in this public repo. The link is hidden when no base URL
is configured.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-22 21:21:27 +02:00
dxd
3387ded386 Merge pull request 'fix(wavelog): push live tuned frequency, not just band center' (#2) from fix/wavelog-cat-live-frequency into main
Reviewed-on: #2
2026-10-22 21:17:36 +02:00
6 changed files with 677 additions and 237 deletions

View File

@@ -138,6 +138,7 @@ const els = {
wavelogKeyInput: document.getElementById("wavelogKeyInput"), wavelogKeyInput: document.getElementById("wavelogKeyInput"),
wavelogKeySaveBtn: document.getElementById("wavelogKeySaveBtn"), wavelogKeySaveBtn: document.getElementById("wavelogKeySaveBtn"),
wavelogKeyMessage: document.getElementById("wavelogKeyMessage"), wavelogKeyMessage: document.getElementById("wavelogKeyMessage"),
wavelogInstanceLink: document.getElementById("wavelogInstanceLink"),
openwebrxTxStatePill: document.getElementById("openwebrxTxStatePill"), openwebrxTxStatePill: document.getElementById("openwebrxTxStatePill"),
rotorCurrent: document.getElementById("rotorCurrent"), rotorCurrent: document.getElementById("rotorCurrent"),
rotorCompass: document.getElementById("rotorCompass"), rotorCompass: document.getElementById("rotorCompass"),
@@ -1505,6 +1506,7 @@ async function refreshCurrentUser() {
const result = await api("/v1/me", { authRequired: true }); const result = await api("/v1/me", { authRequired: true });
state.user = result.user; state.user = result.user;
state.wavelogApiKey = typeof result.wavelogApiKey === "string" ? result.wavelogApiKey : ""; state.wavelogApiKey = typeof result.wavelogApiKey === "string" ? result.wavelogApiKey : "";
state.wavelogBaseUrl = typeof result.wavelogBaseUrl === "string" ? result.wavelogBaseUrl : "";
applyWavelogKeyToInput(); applyWavelogKeyToInput();
renderUserSettingsAuthMethods(); renderUserSettingsAuthMethods();
await ensureLanguageFromUserPreference(); await ensureLanguageFromUserPreference();
@@ -1698,6 +1700,16 @@ function applyWavelogKeyToInput() {
if (els.wavelogKeyInput) { if (els.wavelogKeyInput) {
els.wavelogKeyInput.value = String(state.wavelogApiKey || ""); els.wavelogKeyInput.value = String(state.wavelogApiKey || "");
} }
if (els.wavelogInstanceLink) {
const url = String(state.wavelogBaseUrl || "").trim();
if (url) {
els.wavelogInstanceLink.href = url;
els.wavelogInstanceLink.hidden = false;
} else {
els.wavelogInstanceLink.removeAttribute("href");
els.wavelogInstanceLink.hidden = true;
}
}
} }
async function saveWavelogKey() { async function saveWavelogKey() {
@@ -1723,6 +1735,9 @@ async function openOpenWebRxSession() {
const session = result && result.session ? result.session : null; const session = result && result.session ? result.session : null;
if (result && typeof result.wavelogApiKey === "string") { if (result && typeof result.wavelogApiKey === "string") {
state.wavelogApiKey = result.wavelogApiKey; state.wavelogApiKey = result.wavelogApiKey;
if (typeof result.wavelogBaseUrl === "string") {
state.wavelogBaseUrl = result.wavelogBaseUrl;
}
applyWavelogKeyToInput(); applyWavelogKeyToInput();
} }
const sessionTicket = session && session.ticket ? String(session.ticket) : ""; const sessionTicket = session && session.ticket ? String(session.ticket) : "";
@@ -1752,6 +1767,9 @@ async function openOpenWebRxExternal() {
const session = result && result.session ? result.session : null; const session = result && result.session ? result.session : null;
if (result && typeof result.wavelogApiKey === "string") { if (result && typeof result.wavelogApiKey === "string") {
state.wavelogApiKey = result.wavelogApiKey; state.wavelogApiKey = result.wavelogApiKey;
if (typeof result.wavelogBaseUrl === "string") {
state.wavelogBaseUrl = result.wavelogBaseUrl;
}
applyWavelogKeyToInput(); applyWavelogKeyToInput();
} }
const sessionTicket = session && session.ticket ? String(session.ticket) : ""; const sessionTicket = session && session.ticket ? String(session.ticket) : "";
@@ -3766,141 +3784,154 @@ function maybeAttachMicrohamEqBuilder(settingsForm, settingFields, plugin) {
const presets = { const presets = {
flat: { flat: {
hp: 250, label: "Flat",
lp: 2800, desc: "Neutrale Referenz: kaum Klangfärbung, ohne Dynamik und ohne Auto-Level. Ideal zum Vergleichen und Feintunen.",
mids: 0, hp: 150, lp: 3000, mids: 0, presence: 0,
presence: 0, deesserEnabled: false, deesserFreq: 2900, deesserCut: 0,
deesserEnabled: false, compressorEnabled: false, compressorThreshold: -18, compressorRatio: 2.5,
deesserFreq: 5200, gateEnabled: false, gateThreshold: -52, gateRelease: 180,
deesserCut: 0, normalizerEnabled: false, normalizerPeak: 90, normalizerMaxGain: 12,
compressorEnabled: false, limiter: true
compressorThreshold: -18, },
compressorRatio: 2.5, level: {
gateEnabled: false, label: "Konstanter Pegel",
gateThreshold: -52, 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.",
gateRelease: 180, 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 limiter: true
}, },
dx: { dx: {
hp: 360, label: "DX",
lp: 2500, desc: "Durchsetzungsstark: enges Sprachband, angehobene Präsenz, Kompressor plus Auto-Level für maximale Lesbarkeit im Pile-Up.",
mids: 1.0, hp: 400, lp: 2700, mids: 2.0, presence: 3.5,
presence: 1.8, deesserEnabled: true, deesserFreq: 2800, deesserCut: 2.5,
deesserEnabled: true, compressorEnabled: true, compressorThreshold: -18, compressorRatio: 3.5,
deesserFreq: 5200, gateEnabled: true, gateThreshold: -50, gateRelease: 150,
deesserCut: 2.0, normalizerEnabled: true, normalizerPeak: 92, normalizerMaxGain: 18,
compressorEnabled: true,
compressorThreshold: -20,
compressorRatio: 3.0,
gateEnabled: false,
gateThreshold: -50,
gateRelease: 160,
limiter: true limiter: true
}, },
ragchew: { ragchew: {
hp: 300, label: "Ragchew",
lp: 2800, desc: "Natürlicher Klang: breiteres Band, sanfte Anhebung, milder Kompressor und moderates Auto-Level für entspanntes QSO.",
mids: 0.5, hp: 150, lp: 3000, mids: 1.0, presence: 1.5,
presence: 1.0, deesserEnabled: true, deesserFreq: 3000, deesserCut: 1.5,
deesserEnabled: true, compressorEnabled: true, compressorThreshold: -22, compressorRatio: 2.2,
deesserFreq: 5000, gateEnabled: true, gateThreshold: -55, gateRelease: 220,
deesserCut: 1.2, normalizerEnabled: true, normalizerPeak: 88, normalizerMaxGain: 12,
compressorEnabled: true,
compressorThreshold: -22,
compressorRatio: 2.2,
gateEnabled: true,
gateThreshold: -55,
gateRelease: 220,
limiter: true limiter: true
} }
}; };
const parsed = parseMicrohamEqArgs(extraArgsField.element.value); const parsed = parseMicrohamEqArgs(extraArgsField.element.value);
const initial = parsed || presets.flat; const initial = Object.assign({}, presets.flat, parsed || {});
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = "plugin-eq-builder"; 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"); const title = document.createElement("strong");
title.textContent = "TX Audio EQ"; title.className = "eq-head-title";
wrap.appendChild(title); title.textContent = "TX Audio Equalizer";
const hint = document.createElement("small"); const hint = document.createElement("small");
hint.className = "muted"; hint.className = "eq-head-hint";
hint.textContent = parsed hint.textContent = parsed
? "EQ aus aktuellen Extra-Args geladen" ? "Aus den aktuellen Extra-Args geladen."
: "Preset/Regler schreiben den FFmpeg-EQ in audioFfmpegExtraArgs"; : "Regler und Presets schreiben die FFmpeg-Kette in audioFfmpegExtraArgs.";
wrap.appendChild(hint); headText.appendChild(title);
headText.appendChild(hint);
head.appendChild(headText);
wrap.appendChild(head);
const presetRow = document.createElement("div"); const presetBar = document.createElement("div");
presetRow.className = "plugin-eq-row"; presetBar.className = "eq-presets";
const presetOrder = ["flat", "level", "dx", "ragchew"];
const presetLabel = document.createElement("span"); const presetButtons = {};
presetLabel.className = "muted"; presetOrder.forEach((key) => {
presetLabel.textContent = "Preset"; const chip = document.createElement("button");
presetRow.appendChild(presetLabel); chip.type = "button";
chip.className = "eq-chip" + (key === "level" ? " is-star" : "");
const presetSelect = document.createElement("select"); chip.dataset.preset = key;
presetSelect.innerHTML = [ chip.textContent = presets[key].label;
'<option value="flat">Flat - neutral</option>', presetBar.appendChild(chip);
'<option value="dx">DX - durchsetzungsstark</option>', presetButtons[key] = chip;
'<option value="ragchew">Ragchew - angenehmer Klang</option>' });
].join(""); wrap.appendChild(presetBar);
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 presetInfo = document.createElement("small"); const presetInfo = document.createElement("small");
presetInfo.className = "muted"; presetInfo.className = "eq-preset-info";
wrap.appendChild(presetInfo); wrap.appendChild(presetInfo);
const hpControl = createEqSliderControl("Highpass (Hz)", 80, 600, 10, initial.hp); // --- Section: Klangfilter (EQ) ---
const lpControl = createEqSliderControl("Lowpass (Hz)", 1800, 4000, 10, initial.lp); const eqSec = createEqSection("Klangfilter", { subtitle: "Bandbreite und Frequenzgang der Stimme." });
const midsControl = createEqSliderControl("Mitten (dB)", -8, 12, 0.5, initial.mids); const hpControl = createEqSliderControl("Highpass", 80, 600, 10, initial.hp, "Hz");
const presenceControl = createEqSliderControl("Presence (dB)", -8, 12, 0.5, initial.presence); const lpControl = createEqSliderControl("Lowpass", 2000, 3200, 10, initial.lp, "Hz");
const deesserFreqControl = createEqSliderControl("De-Esser (Hz)", 3500, 8000, 100, initial.deesserFreq); const midsControl = createEqSliderControl("Mitten · 1200 Hz", -8, 12, 0.5, initial.mids, "dB");
const deesserCutControl = createEqSliderControl("De-Esser Cut (dB)", 0, 8, 0.5, initial.deesserCut); const presenceControl = createEqSliderControl("Presence · 2100 Hz", -8, 12, 0.5, initial.presence, "dB");
const compThresholdControl = createEqSliderControl("Compressor Threshold (dB)", -40, -6, 1, initial.compressorThreshold); [hpControl, lpControl, midsControl, presenceControl].forEach((c) => eqSec.body.appendChild(c.row));
const compRatioControl = createEqSliderControl("Compressor Ratio", 1.2, 6, 0.1, initial.compressorRatio); wrap.appendChild(eqSec.section);
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);
const limiterRow = document.createElement("label"); // --- Section: De-Esser (toggle) ---
limiterRow.className = "plugin-eq-checkbox"; const deSec = createEqSection("De-Esser", {
const limiterInput = document.createElement("input"); subtitle: "Zähmt harte Zischlaute – wirkt nur innerhalb des Durchlassbands.",
limiterInput.type = "checkbox"; toggle: true,
limiterInput.checked = Boolean(initial.limiter); toggleChecked: initial.deesserEnabled
limiterRow.appendChild(limiterInput); });
const limiterText = document.createElement("span"); const deesserFreqControl = createEqSliderControl("Frequenz", 2200, 3400, 50, initial.deesserFreq, "Hz");
limiterText.textContent = "Limiter aktivieren (alimiter=0.95)"; const deesserCutControl = createEqSliderControl("Absenkung", 0, 8, 0.5, initial.deesserCut, "dB");
limiterRow.appendChild(limiterText); [deesserFreqControl, deesserCutControl].forEach((c) => deSec.body.appendChild(c.row));
wrap.appendChild(limiterRow); wrap.appendChild(deSec.section);
const preview = document.createElement("small"); // --- Section: Dynamik (compressor + gate + limiter) ---
preview.className = "muted"; const dynSec = createEqSection("Dynamik", { subtitle: "Kompression, Rauschsperre und Übersteuerungsschutz." });
wrap.appendChild(preview); 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"]'); const saveButton = settingsForm.querySelector('button[type="submit"]');
if (saveButton && saveButton.parentNode === settingsForm) { if (saveButton && saveButton.parentNode === settingsForm) {
@@ -3909,55 +3940,51 @@ function maybeAttachMicrohamEqBuilder(settingsForm, settingFields, plugin) {
settingsForm.appendChild(wrap); 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 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({ const args = buildMicrohamEqExtraArgs({
hp, hp: Number(hpControl.number.value),
lp, lp: Number(lpControl.number.value),
mids, mids: Number(midsControl.number.value),
presence, presence: Number(presenceControl.number.value),
deesserEnabled, deesserEnabled: Boolean(deSec.toggle.input.checked),
deesserFreq, deesserFreq: Number(deesserFreqControl.number.value),
deesserCut, deesserCut: Number(deesserCutControl.number.value),
compressorEnabled, compressorEnabled: Boolean(compToggle.input.checked),
compressorThreshold, compressorThreshold: Number(compThresholdControl.number.value),
compressorRatio, compressorRatio: Number(compRatioControl.number.value),
gateEnabled, gateEnabled: Boolean(gateToggle.input.checked),
gateThreshold, gateThreshold: Number(gateThresholdControl.number.value),
gateRelease, gateRelease: Number(gateReleaseControl.number.value),
limiter 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; extraArgsField.element.value = args;
preview.textContent = `Generierte Extra-Args: ${args}`; preview.textContent = args;
}; };
const updatePresetInfo = () => { const sliderControls = [
const selected = String(presetSelect.value || "flat"); hpControl, lpControl, midsControl, presenceControl,
if (selected === "dx") { deesserFreqControl, deesserCutControl,
presetInfo.textContent = "DX: sprachfokussiert mit reduziertem Bass und moderater Praesenz, weniger kuenstlich abgestimmt."; compThresholdControl, compRatioControl,
return; gateThresholdControl, gateReleaseControl,
} normPeakControl, normGainControl
if (selected === "ragchew") { ];
presetInfo.textContent = "Ragchew: natuerlicher Sprachklang mit leicht reduziertem Bass und sanfter Mitten/Praesenz-Anhebung."; sliderControls.forEach((control) => {
return;
}
presetInfo.textContent = "Flat: neutrale Basis mit wenig Klangfaerbung, gut fuer Vergleich und Feintuning.";
};
const bindControlUpdate = (control) => {
control.range.addEventListener("input", () => { control.range.addEventListener("input", () => {
control.number.value = control.range.value; control.number.value = control.range.value;
applyControlsToExtraArgs(); applyControlsToExtraArgs();
@@ -3966,99 +3993,156 @@ function maybeAttachMicrohamEqBuilder(settingsForm, settingFields, plugin) {
control.range.value = control.number.value; control.range.value = control.number.value;
applyControlsToExtraArgs(); 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>"}`; const setActiveChip = (key) => {
updatePresetInfo(); 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"); 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"); const label = document.createElement("span");
label.className = "muted"; label.className = "eq-control-label";
label.textContent = labelText; 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"); const range = document.createElement("input");
range.type = "range"; range.type = "range";
range.className = "eq-range";
range.min = String(min); range.min = String(min);
range.max = String(max); range.max = String(max);
range.step = String(step); range.step = String(step);
range.value = String(value); range.value = String(value);
row.appendChild(range); row.appendChild(range);
const number = document.createElement("input"); const setDisabled = (disabled) => {
number.type = "number"; const flag = Boolean(disabled);
number.min = String(min); row.classList.toggle("is-disabled", flag);
number.max = String(max); range.disabled = flag;
number.step = String(step); number.disabled = flag;
number.value = String(value); };
row.appendChild(number);
return { row, range, number }; return { row, range, number, setDisabled };
} }
function createEqCheckboxRow(labelText, checked) { function createEqToggle(labelText, checked) {
const row = document.createElement("label"); const row = document.createElement("label");
row.className = "plugin-eq-checkbox"; row.className = "eq-switch";
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "checkbox"; input.type = "checkbox";
input.checked = Boolean(checked); 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); row.appendChild(input);
const label = document.createElement("span"); row.appendChild(track);
label.textContent = labelText; row.appendChild(text);
row.appendChild(label);
return { row, input }; 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({ function buildMicrohamEqExtraArgs({
hp, hp,
lp, lp,
@@ -4073,6 +4157,9 @@ function buildMicrohamEqExtraArgs({
gateEnabled, gateEnabled,
gateThreshold, gateThreshold,
gateRelease, gateRelease,
normalizerEnabled,
normalizerPeak,
normalizerMaxGain,
limiter limiter
}) { }) {
const filters = [ const filters = [
@@ -4082,7 +4169,7 @@ function buildMicrohamEqExtraArgs({
`equalizer=f=2100:t=q:w=0.9:g=${Number(Number(presence || 0).toFixed(1))}` `equalizer=f=2100:t=q:w=0.9:g=${Number(Number(presence || 0).toFixed(1))}`
]; ];
if (deesserEnabled && Number(deesserCut || 0) > 0) { 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)); const cut = Number(Number(-Math.abs(Number(deesserCut || 0))).toFixed(1));
filters.push(`equalizer=f=${freq}:t=q:w=1.1:g=${cut}`); filters.push(`equalizer=f=${freq}:t=q:w=1.1:g=${cut}`);
} }
@@ -4096,7 +4183,17 @@ function buildMicrohamEqExtraArgs({
const gateThresholdDb = Number(Number(gateThreshold || -55).toFixed(1)); const gateThresholdDb = Number(Number(gateThreshold || -55).toFixed(1));
const gateThresholdLinear = Number(Math.pow(10, gateThresholdDb / 20).toFixed(6)); const gateThresholdLinear = Number(Math.pow(10, gateThresholdDb / 20).toFixed(6));
const release = Math.round(Number(gateRelease || 180)); 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) { if (limiter) {
filters.push("alimiter=limit=0.95"); filters.push("alimiter=limit=0.95");
@@ -4131,10 +4228,37 @@ function parseMicrohamEqArgs(value) {
const compressorEnabled = Boolean(compMatch); const compressorEnabled = Boolean(compMatch);
const compressorThreshold = compMatch ? Number((20 * Math.log10(Number(compMatch[1]))).toFixed(1)) : -20; const compressorThreshold = compMatch ? Number((20 * Math.log10(Number(compMatch[1]))).toFixed(1)) : -20;
const compressorRatio = compMatch ? Number(compMatch[2]) : 3; 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 gateTokenMatch = text.match(/agate\s*=\s*([^",]+)/i);
const gateEnabled = Boolean(gateMatch); const gateEnabled = Boolean(gateTokenMatch);
const gateThreshold = gateMatch ? Number((20 * Math.log10(Number(gateMatch[1]))).toFixed(1)) : -55; let gateThreshold = -55;
const gateRelease = gateMatch ? Number(gateMatch[2]) : 180; 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); const limiter = /alimiter\s*=\s*limit\s*=\s*0\.95/i.test(text);
return { return {
hp, hp,
@@ -4150,6 +4274,9 @@ function parseMicrohamEqArgs(value) {
gateEnabled, gateEnabled,
gateThreshold, gateThreshold,
gateRelease, gateRelease,
normalizerEnabled,
normalizerPeak,
normalizerMaxGain,
limiter limiter
}; };
} }

View File

@@ -56,6 +56,7 @@
"Key speichern": "Key speichern", "Key speichern": "Key speichern",
"Frequenz und Mode der Station werden beim Bandwechsel an dein Wavelog-Logbuch gesendet.": "Frequenz und Mode der Station werden beim Bandwechsel an dein Wavelog-Logbuch gesendet.", "Frequenz und Mode der Station werden beim Bandwechsel an dein Wavelog-Logbuch gesendet.": "Frequenz und Mode der Station werden beim Bandwechsel an dein Wavelog-Logbuch gesendet.",
"Wavelog API Key gespeichert.": "Wavelog API Key gespeichert.", "Wavelog API Key gespeichert.": "Wavelog API Key gespeichert.",
"Wavelog öffnen": "Wavelog öffnen",
"Reservierungen": "Reservierungen", "Reservierungen": "Reservierungen",
"Naechsten Slot reservieren": "Naechsten Slot reservieren", "Naechsten Slot reservieren": "Naechsten Slot reservieren",
"SWR Uebersicht": "SWR Uebersicht", "SWR Uebersicht": "SWR Uebersicht",

View File

@@ -56,6 +56,7 @@
"Key speichern": "Save key", "Key speichern": "Save key",
"Frequenz und Mode der Station werden beim Bandwechsel an dein Wavelog-Logbuch gesendet.": "The station's frequency and mode are sent to your Wavelog logbook on band change.", "Frequenz und Mode der Station werden beim Bandwechsel an dein Wavelog-Logbuch gesendet.": "The station's frequency and mode are sent to your Wavelog logbook on band change.",
"Wavelog API Key gespeichert.": "Wavelog API key saved.", "Wavelog API Key gespeichert.": "Wavelog API key saved.",
"Wavelog öffnen": "Open Wavelog",
"Reservierungen": "Reservations", "Reservierungen": "Reservations",
"Naechsten Slot reservieren": "Reserve next slot", "Naechsten Slot reservieren": "Reserve next slot",
"SWR Uebersicht": "SWR Overview", "SWR Uebersicht": "SWR Overview",

View File

@@ -167,7 +167,10 @@
<p class="muted">Session Key (Ticket): <code id="openwebrxSessionTicket">-</code></p> <p class="muted">Session Key (Ticket): <code id="openwebrxSessionTicket">-</code></p>
</div> </div>
<div id="wavelogKeyBox" class="stack"> <div id="wavelogKeyBox" class="stack">
<div class="section-head">
<label for="wavelogKeyInput">Wavelog API Key (Rig-Key)</label> <label for="wavelogKeyInput">Wavelog API Key (Rig-Key)</label>
<a id="wavelogInstanceLink" target="_blank" rel="noopener noreferrer" hidden>Wavelog öffnen</a>
</div>
<input id="wavelogKeyInput" type="text" autocomplete="off" spellcheck="false" placeholder="Wavelog API Key" /> <input id="wavelogKeyInput" type="text" autocomplete="off" spellcheck="false" placeholder="Wavelog API Key" />
<div class="actions"> <div class="actions">
<button id="wavelogKeySaveBtn" type="button" class="ghost-btn">Key speichern</button> <button id="wavelogKeySaveBtn" type="button" class="ghost-btn">Key speichern</button>
@@ -548,6 +551,12 @@
</nav> </nav>
</main> </main>
<footer class="site-footer">
<a href="https://amateurradioclubgraz.at/imprint/" target="_blank" rel="noopener noreferrer">Impressum</a>
<span class="site-footer-sep" aria-hidden="true">·</span>
<a href="https://amateurradioclubgraz.at/datenschutz/" target="_blank" rel="noopener noreferrer">Datenschutz</a>
</footer>
<script src="/app.js" defer></script> <script src="/app.js" defer></script>
</body> </body>
</html> </html>

View File

@@ -762,24 +762,298 @@ button:disabled {
.plugin-eq-builder { .plugin-eq-builder {
border: 1px solid var(--surface-border); border: 1px solid var(--surface-border);
border-radius: 10px; border-radius: 16px;
padding: 0.65rem; padding: 1rem;
display: grid; display: grid;
gap: 0.5rem; gap: 0.9rem;
background: rgba(255, 255, 255, 0.02); 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 { .eq-head {
display: grid; display: flex;
grid-template-columns: 130px 1fr 88px; align-items: flex-start;
gap: 0.5rem; gap: 0.6rem;
align-items: center;
} }
.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; display: flex;
align-items: center; 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 { dt {
@@ -885,3 +1159,24 @@ html[data-theme="light"] .message.domain-notice {
display: grid; display: grid;
} }
} }
/* Legal footer links (Impressum / Datenschutz -> homepage) */
.site-footer {
margin: 2rem auto 1.25rem;
padding: 1rem;
text-align: center;
font-size: 0.85rem;
opacity: 0.8;
}
.site-footer a {
color: inherit;
text-decoration: underline;
}
.site-footer a:hover,
.site-footer a:focus-visible {
opacity: 1;
}
.site-footer-sep {
margin: 0 0.5rem;
opacity: 0.6;
}

View File

@@ -360,7 +360,7 @@ async function routeRequest(req, res) {
if (method === "GET" && url.pathname === "/v1/me") { if (method === "GET" && url.pathname === "/v1/me") {
const auth = requireAuth(req, res); const auth = requireAuth(req, res);
if (!auth) return; if (!auth) return;
return sendJson(res, 200, { user: sanitizeUser(auth.user), wavelogApiKey: auth.user.wavelogApiKey || "", capabilities: userCapabilities(auth.user) }); return sendJson(res, 200, { user: sanitizeUser(auth.user), wavelogApiKey: auth.user.wavelogApiKey || "", wavelogBaseUrl: wavelogBaseUrlPublic(), capabilities: userCapabilities(auth.user) });
} }
if (method === "GET" && url.pathname === "/v1/auth/session/diag") { if (method === "GET" && url.pathname === "/v1/auth/session/diag") {
@@ -3641,7 +3641,8 @@ async function handleOpenWebRxSessionIssue(res, user) {
return sendJson(res, 200, { return sendJson(res, 200, {
ok: true, ok: true,
session: result, session: result,
wavelogApiKey: user.wavelogApiKey || "" wavelogApiKey: user.wavelogApiKey || "",
wavelogBaseUrl: wavelogBaseUrlPublic()
}); });
} }
@@ -3719,6 +3720,12 @@ async function handleOpenWebRxBandSelect(res, user, body) {
} }
} }
// Public Wavelog base URL (for the UI link). Comes from runtime env only, so the
// private hostname is never baked into this (public) repo.
function wavelogBaseUrlPublic() {
return String(process.env.WAVELOG_BASE_URL || "").trim().replace(/\/+$/, "");
}
// Push a frequency/mode to the active operator's Wavelog logbook (CAT API). // Push a frequency/mode to the active operator's Wavelog logbook (CAT API).
// Best-effort: never throws into the caller's flow. // Best-effort: never throws into the caller's flow.
async function sendWavelogCat(user, frequencyHz, mode) { async function sendWavelogCat(user, frequencyHz, mode) {