Request preparation
What would you like?
Save and restore AetherSDR settings not captured by FlexRadio SSDR profiles using a feature within in AetherSDR.
import { useState, useEffect, useCallback } from "react";
const DEFAULT_SETTINGS = {
profileName: "",
description: "",
band: "20m",
mode: "RADE",
createdBy: "",
createdAt: "",
updatedAt: "",
// RADE / FreeDV
rade: {
enabled: true,
rxBandwidth: 2400,
txBandwidth: 2400,
squelch: -100,
squelchEnabled: false,
clipThreshold: 0,
freedvReporterEnabled: false,
},
// Audio source / sink
audio: {
txSource: "MIC", // MIC | BAL | PC | DAX
rxSink: "PC", // PC | RADIO_SPEAKER | DAX
pcAudioInputDevice: "",
pcAudioOutputDevice: "",
daxChannel: 1,
daxIqChannel: 1,
daxIqSampleRate: 48, // kHz
},
// Mic / TX audio
mic: {
gain: 40,
bias: false,
boost: false,
acc: false,
vox: false,
voxLevel: 50,
voxDelay: 250,
dexp: false,
dexpLevel: 50,
dexpOffset: -40,
monitor: false,
monitorGain: 50,
txEqEnabled: false,
txEq: { "63": 0, "125": 0, "250": 0, "500": 0, "1k": 0, "2k": 0, "4k": 0, "8k": 0 },
},
// RX audio
rx: {
afGain: 50,
agcMode: "MED", // FAST | MED | SLOW | OFF
agcThreshold: -100,
agcMaxGain: 90,
agcAttack: 250,
agcDecay: 500,
nrEnabled: false,
nrLevel: 50,
nbEnabled: false,
nbLevel: 50,
anrEnabled: false,
anrLevel: 50,
rxEqEnabled: false,
rxEq: { "63": 0, "125": 0, "250": 0, "500": 0, "1k": 0, "2k": 0, "4k": 0, "8k": 0 },
},
// Aetherial Audio / AetherVoice
aetherialAudio: {
enabled: false,
nrStrength: 50,
deesserEnabled: false,
deesserStrength: 50,
compressorEnabled: false,
compressorThreshold: -20,
compressorRatio: 4,
compressorAttack: 10,
compressorRelease: 100,
eqEnabled: false,
},
// SmartLink / network
network: {
smartlinkEnabled: false,
tciEnabled: false,
tciPort: 50001,
catEnabled: false,
catPort: 4532,
},
// FreeDV Reporter
freedvReporter: {
enabled: false,
callsign: "",
gridSquare: "",
reportingServer: "qso.freedvreporter.net",
},
// Misc AetherSDR-side
misc: {
panFollowVfo: true,
spotFilterEnabled: false,
autoSquelchEnabled: false,
tnfEnabled: false,
cwDecoderEnabled: false,
clientNrEnabled: false,
clientNrLevel: 50,
midiProfileName: "",
flexControlAcceleration: 3,
},
};
const BANDS = ["160m","80m","60m","40m","30m","20m","17m","15m","12m","10m","6m","2m"];
const MODES = ["RADE","FreeDV 700D","FreeDV 1600","SSB","LSB","USB","AM","FM","CW","DIGI","FT8","WSPR"];
const TX_SOURCES = ["MIC","BAL","PC","DAX"];
const RX_SINKS = ["PC","RADIO_SPEAKER","DAX"];
const AGC_MODES = ["FAST","MED","SLOW","OFF"];
const DAX_RATES = [24,48,96,192];
const EQ_BANDS = ["63","125","250","500","1k","2k","4k","8k"];
function deepMerge(base, override) {
const out = { ...base };
for (const k of Object.keys(override || {})) {
if (override[k] !== null && typeof override[k] === "object" && !Array.isArray(override[k])) {
out[k] = deepMerge(base[k] || {}, override[k]);
} else {
out[k] = override[k];
}
}
return out;
}
// ─── Small UI atoms ───────────────────────────────────────────────────────────
function Row({ label, children, hint }) {
return (
<div style={{ display:"flex", alignItems:"center", gap:8, marginBottom:8, minHeight:32 }}>
<label style={{ width:180, fontSize:13, color:"var(--color-text-secondary)", flexShrink:0 }}>{label}
<div style={{ flex:1, display:"flex", alignItems:"center", gap:6 }}>{children}
{hint && <span style={{ fontSize:11, color:"var(--color-text-tertiary)" }}>{hint}}
);
}
function Toggle({ value, onChange }) {
return (
<button
onClick={() => onChange(!value)}
style={{
width:40, height:22, borderRadius:11, border:"none", cursor:"pointer",
background: value ? "#1D9E75" : "var(--color-border-secondary)",
position:"relative", padding:0, transition:"background .15s"
}}
aria-pressed={value}
>
<span style={{
position:"absolute", top:3, left: value ? 21 : 3,
width:16, height:16, borderRadius:"50%", background:"white", transition:"left .15s"
}} />
);
}
function Slider({ min, max, step=1, value, onChange, unit="" }) {
return (
<div style={{ display:"flex", alignItems:"center", gap:8, flex:1 }}>
<input type="range" min={min} max={max} step={step} value={value}
onChange={e => onChange(Number(e.target.value))} style={{ flex:1 }} />
<span style={{ fontSize:13, fontWeight:500, minWidth:52, textAlign:"right",
color:"var(--color-text-primary)" }}>{value}{unit}
);
}
function Select({ value, options, onChange }) {
return (
<select value={value} onChange={e => onChange(e.target.value)}
style={{ fontSize:13, padding:"4px 8px", borderRadius:6,
border:"0.5px solid var(--color-border-secondary)",
background:"var(--color-background-primary)",
color:"var(--color-text-primary)" }}>
{options.map(o => {o})}
);
}
function Input({ value, onChange, placeholder, wide }) {
return (
<input value={value} onChange={e => onChange(e.target.value)}
placeholder={placeholder}
style={{ fontSize:13, padding:"4px 8px", borderRadius:6,
border:"0.5px solid var(--color-border-secondary)",
background:"var(--color-background-primary)",
color:"var(--color-text-primary)",
width: wide ? "100%" : 180 }} />
);
}
function Section({ title, icon, children }) {
const [open, setOpen] = useState(true);
return (
<div style={{ marginBottom:12, border:"0.5px solid var(--color-border-tertiary)",
borderRadius:"var(--border-radius-lg)", overflow:"hidden" }}>
<button onClick={() => setOpen(o => !o)}
style={{ width:"100%", display:"flex", alignItems:"center", gap:8, padding:"10px 16px",
background:"var(--color-background-secondary)", border:"none", cursor:"pointer",
color:"var(--color-text-primary)", fontSize:14, fontWeight:500 }}>
<i className={ti ${icon}} aria-hidden="true" style={{ fontSize:16 }} />
{title}
<i className={ti ${open ? "ti-chevron-up" : "ti-chevron-down"}}
aria-hidden="true" style={{ marginLeft:"auto", fontSize:14 }} />
{open && (
<div style={{ padding:"12px 16px", background:"var(--color-background-primary)" }}>
{children}
)}
);
}
function EqGrid({ values, onChange }) {
return (
<div style={{ display:"grid", gridTemplateColumns:"repeat(8,1fr)", gap:4, marginTop:4 }}>
{EQ_BANDS.map(b => (
<div key={b} style={{ display:"flex", flexDirection:"column", alignItems:"center", gap:2 }}>
<span style={{ fontSize:10, color:"var(--color-text-tertiary)" }}>{b}
<input type="range" min={-12} max={12} step={1} value={values[b] || 0}
onChange={e => onChange({ ...values, [b]: Number(e.target.value) })}
style={{ writingMode:"vertical-lr", transform:"rotate(180deg)", height:60 }} />
<span style={{ fontSize:10, color:"var(--color-text-secondary)" }}>{values[b] || 0}
))}
);
}
// ─── Tabs ─────────────────────────────────────────────────────────────────────
const TABS = [
{ id:"rade", label:"RADE / FreeDV", icon:"ti-radio" },
{ id:"audio", label:"Audio routing", icon:"ti-headphones" },
{ id:"mic", label:"Mic / TX", icon:"ti-microphone" },
{ id:"rx", label:"RX / AGC", icon:"ti-volume" },
{ id:"aether", label:"AetherVoice", icon:"ti-brain" },
{ id:"misc", label:"Misc", icon:"ti-adjustments" },
];
// ─── Main app ─────────────────────────────────────────────────────────────────
export default function App() {
const [profiles, setProfiles] = useState({});
const [activeProfile, setActiveProfile] = useState(null);
const [settings, setSettings] = useState(deepMerge(DEFAULT_SETTINGS, {}));
const [tab, setTab] = useState("rade");
const [status, setStatus] = useState(null);
const [newName, setNewName] = useState("");
const [newOp, setNewOp] = useState("");
const [confirmDelete, setConfirmDelete] = useState(null);
// Load from storage on mount
useEffect(() => {
(async () => {
try {
const r = await window.storage.get("aether-op-profiles");
if (r) setProfiles(JSON.parse(r.value));
} catch (_) {}
})();
}, []);
const flash = (msg, type="success") => {
setStatus({ msg, type });
setTimeout(() => setStatus(null), 2800);
};
const save = useCallback(async (ps = profiles) => {
try {
await window.storage.set("aether-op-profiles", JSON.stringify(ps));
} catch (e) {
flash("Storage error: " + e.message, "error");
}
}, [profiles]);
function patch(path, value) {
setSettings(prev => {
const parts = path.split(".");
const next = JSON.parse(JSON.stringify(prev));
let cur = next;
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]];
cur[parts[parts.length - 1]] = value;
return next;
});
}
function createProfile() {
const name = newName.trim();
const op = newOp.trim();
if (!name) { flash("Enter a profile name", "error"); return; }
if (!op) { flash("Enter operator callsign/name", "error"); return; }
const now = new Date().toISOString();
const entry = deepMerge(settings, {
profileName: name,
createdBy: op,
createdAt: now,
updatedAt: now,
});
const next = { ...profiles, [name]: entry };
setProfiles(next);
setActiveProfile(name);
setSettings(entry);
save(next);
setNewName("");
flash(Profile "${name}" created);
}
function updateProfile() {
if (!activeProfile) { flash("No profile selected", "error"); return; }
const now = new Date().toISOString();
const entry = { ...settings, profileName: activeProfile, updatedAt: now };
const next = { ...profiles, [activeProfile]: entry };
setProfiles(next);
save(next);
flash("${activeProfile}" updated);
}
function loadProfile(name) {
const p = profiles[name];
if (!p) return;
setSettings(deepMerge(DEFAULT_SETTINGS, p));
setActiveProfile(name);
flash(Loaded "${name}");
}
function deleteProfile(name) {
const next = { ...profiles };
delete next[name];
setProfiles(next);
save(next);
if (activeProfile === name) {
setActiveProfile(null);
setSettings(deepMerge(DEFAULT_SETTINGS, {}));
}
setConfirmDelete(null);
flash("${name}" deleted);
}
function exportProfile() {
if (!activeProfile) { flash("No profile selected", "error"); return; }
const blob = new Blob([JSON.stringify(profiles[activeProfile], null, 2)],
{ type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = AetherSDR_${activeProfile.replace(/\s+/g,"_")}.json;
a.click();
flash("Exported");
}
function importProfile(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
try {
const data = JSON.parse(ev.target.result);
const name = data.profileName || file.name.replace(".json","");
const now = new Date().toISOString();
const entry = deepMerge(DEFAULT_SETTINGS, { ...data, profileName: name, updatedAt: now });
const next = { ...profiles, [name]: entry };
setProfiles(next);
setActiveProfile(name);
setSettings(entry);
save(next);
flash(Imported "${name}");
} catch {
flash("Invalid JSON file", "error");
}
};
reader.readAsText(file);
e.target.value = "";
}
const profileList = Object.keys(profiles);
return (
<div style={{ fontFamily:"var(--font-sans)", color:"var(--color-text-primary)",
maxWidth:760, margin:"0 auto", padding:"1rem 0" }}>
<h2 style={{ fontSize:18, fontWeight:500, margin:"0 0 4px" }}>
<i className="ti ti-radio" aria-hidden="true" style={{ marginRight:8, fontSize:18 }} />
AetherSDR operator settings
</h2>
<p style={{ fontSize:13, color:"var(--color-text-secondary)", margin:"0 0 16px" }}>
Save and restore AetherSDR settings not captured by FlexRadio SSDR profiles —
RADE bandwidths, audio routing, mic processing, AGC, and more.
</p>
{status && (
<div style={{ marginBottom:12, padding:"8px 12px", borderRadius:"var(--border-radius-md)",
fontSize:13, fontWeight:500,
background: status.type === "error" ? "var(--color-background-danger)" : "var(--color-background-success)",
color: status.type === "error" ? "var(--color-text-danger)" : "var(--color-text-success)",
border: `0.5px solid ${status.type === "error" ? "var(--color-border-danger)" : "var(--color-border-success)"}` }}>
<i className={`ti ${status.type === "error" ? "ti-alert-circle" : "ti-circle-check"}`}
aria-hidden="true" style={{ marginRight:6 }} />
{status.msg}
</div>
)}
{/* ── Profile bar ── */}
<div style={{ display:"grid", gridTemplateColumns:"1fr 1fr", gap:8, marginBottom:8 }}>
<div style={{ padding:"12px 14px", borderRadius:"var(--border-radius-lg)",
border:"0.5px solid var(--color-border-tertiary)",
background:"var(--color-background-secondary)" }}>
<p style={{ fontSize:12, fontWeight:500, margin:"0 0 8px",
color:"var(--color-text-secondary)", textTransform:"uppercase", letterSpacing:.5 }}>
Saved profiles
</p>
{profileList.length === 0
? <p style={{ fontSize:13, color:"var(--color-text-tertiary)", margin:0 }}>No profiles yet</p>
: profileList.map(n => (
<div key={n} style={{ display:"flex", alignItems:"center", gap:4, marginBottom:4 }}>
<button onClick={() => loadProfile(n)}
style={{ flex:1, textAlign:"left", fontSize:13, padding:"4px 8px",
borderRadius:6, cursor:"pointer",
border: activeProfile===n ? "1px solid var(--color-border-info)" : "0.5px solid var(--color-border-tertiary)",
background: activeProfile===n ? "var(--color-background-info)" : "var(--color-background-primary)",
color: activeProfile===n ? "var(--color-text-info)" : "var(--color-text-primary)",
fontWeight: activeProfile===n ? 500 : 400 }}>
<i className="ti ti-file-text" aria-hidden="true" style={{ marginRight:5 }} />
{n}
<span style={{ fontSize:11, color:"var(--color-text-tertiary)", marginLeft:6 }}>
{profiles[n].band} / {profiles[n].mode}
</span>
</button>
{confirmDelete === n
? <>
<button onClick={() => deleteProfile(n)}
style={{ fontSize:11, padding:"3px 8px", borderRadius:5, cursor:"pointer",
border:"0.5px solid var(--color-border-danger)",
background:"var(--color-background-danger)",
color:"var(--color-text-danger)" }}>Delete</button>
<button onClick={() => setConfirmDelete(null)}
style={{ fontSize:11, padding:"3px 8px", borderRadius:5, cursor:"pointer",
border:"0.5px solid var(--color-border-tertiary)",
background:"transparent", color:"var(--color-text-secondary)" }}>Cancel</button>
</>
: <button onClick={() => setConfirmDelete(n)}
style={{ border:"none", background:"transparent", cursor:"pointer",
color:"var(--color-text-tertiary)", fontSize:15, padding:2 }}
aria-label={`Delete ${n}`}>
<i className="ti ti-trash" aria-hidden="true" />
</button>
}
</div>
))
}
</div>
<div style={{ padding:"12px 14px", borderRadius:"var(--border-radius-lg)",
border:"0.5px solid var(--color-border-tertiary)",
background:"var(--color-background-secondary)", display:"flex", flexDirection:"column", gap:8 }}>
<p style={{ fontSize:12, fontWeight:500, margin:0,
color:"var(--color-text-secondary)", textTransform:"uppercase", letterSpacing:.5 }}>
{activeProfile ? `Editing: ${activeProfile}` : "Create new profile"}
</p>
<div style={{ display:"flex", gap:6 }}>
<Input value={newName} onChange={setNewName} placeholder="Profile name, e.g. 20m RADE" wide />
</div>
<div style={{ display:"flex", gap:6 }}>
<Input value={newOp} onChange={setNewOp} placeholder="Operator callsign / name" wide />
</div>
<div style={{ display:"flex", gap:6, flexWrap:"wrap" }}>
<button onClick={createProfile} style={{ fontSize:13, padding:"5px 12px", borderRadius:6,
cursor:"pointer", border:"0.5px solid var(--color-border-secondary)",
background:"var(--color-background-primary)", color:"var(--color-text-primary)" }}>
<i className="ti ti-plus" aria-hidden="true" style={{ marginRight:5 }} />Save as new
</button>
{activeProfile && (
<button onClick={updateProfile} style={{ fontSize:13, padding:"5px 12px", borderRadius:6,
cursor:"pointer", border:"0.5px solid var(--color-border-secondary)",
background:"var(--color-background-primary)", color:"var(--color-text-primary)" }}>
<i className="ti ti-device-floppy" aria-hidden="true" style={{ marginRight:5 }} />Update
</button>
)}
{activeProfile && (
<button onClick={exportProfile} style={{ fontSize:13, padding:"5px 12px", borderRadius:6,
cursor:"pointer", border:"0.5px solid var(--color-border-secondary)",
background:"var(--color-background-primary)", color:"var(--color-text-primary)" }}>
<i className="ti ti-download" aria-hidden="true" style={{ marginRight:5 }} />Export
</button>
)}
<label style={{ fontSize:13, padding:"5px 12px", borderRadius:6, cursor:"pointer",
border:"0.5px solid var(--color-border-secondary)",
background:"var(--color-background-primary)", color:"var(--color-text-primary)" }}>
<i className="ti ti-upload" aria-hidden="true" style={{ marginRight:5 }} />Import
<input type="file" accept=".json" onChange={importProfile} style={{ display:"none" }} />
</label>
</div>
</div>
</div>
{/* ── Band / mode row ── */}
<div style={{ display:"grid", gridTemplateColumns:"repeat(4,1fr)", gap:8, marginBottom:12,
padding:"10px 14px", borderRadius:"var(--border-radius-lg)",
border:"0.5px solid var(--color-border-tertiary)",
background:"var(--color-background-secondary)" }}>
<div>
<p style={{ fontSize:11, fontWeight:500, margin:"0 0 4px", color:"var(--color-text-tertiary)",
textTransform:"uppercase" }}>Band</p>
<Select value={settings.band} options={BANDS} onChange={v => patch("band", v)} />
</div>
<div>
<p style={{ fontSize:11, fontWeight:500, margin:"0 0 4px", color:"var(--color-text-tertiary)",
textTransform:"uppercase" }}>Mode</p>
<Select value={settings.mode} options={MODES} onChange={v => patch("mode", v)} />
</div>
<div style={{ gridColumn:"span 2" }}>
<p style={{ fontSize:11, fontWeight:500, margin:"0 0 4px", color:"var(--color-text-tertiary)",
textTransform:"uppercase" }}>Description</p>
<Input value={settings.description} onChange={v => patch("description",v)}
placeholder="e.g. 20m RADE evening DX" wide />
</div>
</div>
{/* ── Tab bar ── */}
<div style={{ display:"flex", gap:4, marginBottom:8, flexWrap:"wrap" }}>
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
style={{ fontSize:12, padding:"5px 10px", borderRadius:6, cursor:"pointer",
border: tab===t.id ? "1px solid var(--color-border-info)" : "0.5px solid var(--color-border-tertiary)",
background: tab===t.id ? "var(--color-background-info)" : "var(--color-background-secondary)",
color: tab===t.id ? "var(--color-text-info)" : "var(--color-text-secondary)",
fontWeight: tab===t.id ? 500 : 400 }}>
<i className={`ti ${t.icon}`} aria-hidden="true" style={{ marginRight:5 }} />
{t.label}
</button>
))}
</div>
{/* ──────────────── RADE tab ──────────────── */}
{tab === "rade" && (
<>
<Section title="FreeDV RADE" icon="ti-wave-sine">
<Row label="RADE enabled">
<Toggle value={settings.rade.enabled} onChange={v => patch("rade.enabled", v)} />
</Row>
<Row label="RX bandwidth" hint="Hz">
<Slider min={1200} max={3000} step={100} value={settings.rade.rxBandwidth}
onChange={v => patch("rade.rxBandwidth", v)} unit=" Hz" />
</Row>
<Row label="TX bandwidth" hint="Hz">
<Slider min={1200} max={3000} step={100} value={settings.rade.txBandwidth}
onChange={v => patch("rade.txBandwidth", v)} unit=" Hz" />
</Row>
<Row label="Squelch enabled">
<Toggle value={settings.rade.squelchEnabled}
onChange={v => patch("rade.squelchEnabled", v)} />
</Row>
<Row label="Squelch threshold">
<Slider min={-130} max={0} step={1} value={settings.rade.squelch}
onChange={v => patch("rade.squelch", v)} unit=" dB" />
</Row>
<Row label="Clip threshold">
<Slider min={-20} max={20} step={1} value={settings.rade.clipThreshold}
onChange={v => patch("rade.clipThreshold", v)} unit=" dB" />
</Row>
</Section>
<Section title="FreeDV Reporter" icon="ti-broadcast">
<Row label="Reporting enabled">
<Toggle value={settings.freedvReporter.enabled}
onChange={v => patch("freedvReporter.enabled", v)} />
</Row>
<Row label="Callsign">
<Input value={settings.freedvReporter.callsign}
onChange={v => patch("freedvReporter.callsign", v)} placeholder="W1XYZ" />
</Row>
<Row label="Grid square">
<Input value={settings.freedvReporter.gridSquare}
onChange={v => patch("freedvReporter.gridSquare", v)} placeholder="FN31" />
</Row>
</Section>
</>
)}
{/* ──────────────── Audio routing tab ──────────────── */}
{tab === "audio" && (
<Section title="Audio routing" icon="ti-arrows-exchange">
<Row label="TX audio source">
<Select value={settings.audio.txSource} options={TX_SOURCES}
onChange={v => patch("audio.txSource", v)} />
<span style={{ fontSize:11, color:"var(--color-text-tertiary)" }}>
MIC=front panel · BAL=balanced input · PC=PC audio · DAX=network audio
</span>
</Row>
<Row label="RX audio sink">
<Select value={settings.audio.rxSink} options={RX_SINKS}
onChange={v => patch("audio.rxSink", v)} />
</Row>
<Row label="PC audio input device">
<Input value={settings.audio.pcAudioInputDevice}
onChange={v => patch("audio.pcAudioInputDevice", v)}
placeholder="e.g. Focusrite USB Audio" wide />
</Row>
<Row label="PC audio output device">
<Input value={settings.audio.pcAudioOutputDevice}
onChange={v => patch("audio.pcAudioOutputDevice", v)}
placeholder="e.g. Speakers (Realtek)" wide />
</Row>
<Row label="DAX channel">
<Slider min={1} max={8} step={1} value={settings.audio.daxChannel}
onChange={v => patch("audio.daxChannel", v)} />
</Row>
<Row label="DAX IQ channel">
<Slider min={1} max={4} step={1} value={settings.audio.daxIqChannel}
onChange={v => patch("audio.daxIqChannel", v)} />
</Row>
<Row label="DAX IQ sample rate">
<Select value={String(settings.audio.daxIqSampleRate)}
options={DAX_RATES.map(String)}
onChange={v => patch("audio.daxIqSampleRate", Number(v))} />
<span style={{ fontSize:11, color:"var(--color-text-tertiary)" }}>kHz</span>
</Row>
</Section>
)}
{/* ──────────────── Mic / TX tab ──────────────── */}
{tab === "mic" && (
<>
<Section title="Microphone" icon="ti-microphone">
<Row label="Mic gain">
<Slider min={0} max={100} value={settings.mic.gain}
onChange={v => patch("mic.gain", v)} unit="%" />
</Row>
<Row label="Mic bias (phantom)">
<Toggle value={settings.mic.bias} onChange={v => patch("mic.bias", v)} />
</Row>
<Row label="Mic boost">
<Toggle value={settings.mic.boost} onChange={v => patch("mic.boost", v)} />
</Row>
<Row label="ACC input">
<Toggle value={settings.mic.acc} onChange={v => patch("mic.acc", v)} />
</Row>
</Section>
<Section title="VOX" icon="ti-speakerphone">
<Row label="VOX enabled">
<Toggle value={settings.mic.vox} onChange={v => patch("mic.vox", v)} />
</Row>
<Row label="VOX level">
<Slider min={0} max={100} value={settings.mic.voxLevel}
onChange={v => patch("mic.voxLevel", v)} unit="%" />
</Row>
<Row label="VOX delay">
<Slider min={50} max={2000} step={50} value={settings.mic.voxDelay}
onChange={v => patch("mic.voxDelay", v)} unit=" ms" />
</Row>
</Section>
<Section title="DEXP (downward expander)" icon="ti-adjustments-horizontal">
<Row label="DEXP enabled">
<Toggle value={settings.mic.dexp} onChange={v => patch("mic.dexp", v)} />
</Row>
<Row label="DEXP level">
<Slider min={0} max={100} value={settings.mic.dexpLevel}
onChange={v => patch("mic.dexpLevel", v)} unit="%" />
</Row>
<Row label="DEXP offset">
<Slider min={-80} max={0} step={1} value={settings.mic.dexpOffset}
onChange={v => patch("mic.dexpOffset", v)} unit=" dB" />
</Row>
</Section>
<Section title="TX monitor" icon="ti-ear">
<Row label="Monitor enabled">
<Toggle value={settings.mic.monitor} onChange={v => patch("mic.monitor", v)} />
</Row>
<Row label="Monitor gain">
<Slider min={0} max={100} value={settings.mic.monitorGain}
onChange={v => patch("mic.monitorGain", v)} unit="%" />
</Row>
</Section>
<Section title="TX equalizer" icon="ti-wave-saw-tool">
<Row label="TX EQ enabled">
<Toggle value={settings.mic.txEqEnabled}
onChange={v => patch("mic.txEqEnabled", v)} />
</Row>
<EqGrid values={settings.mic.txEq}
onChange={v => patch("mic.txEq", v)} />
</Section>
</>
)}
{/* ──────────────── RX / AGC tab ──────────────── */}
{tab === "rx" && (
<>
<Section title="RX audio" icon="ti-volume">
<Row label="AF gain">
<Slider min={0} max={100} value={settings.rx.afGain}
onChange={v => patch("rx.afGain", v)} unit="%" />
</Row>
</Section>
<Section title="AGC" icon="ti-activity">
<Row label="AGC mode">
<Select value={settings.rx.agcMode} options={AGC_MODES}
onChange={v => patch("rx.agcMode", v)} />
</Row>
<Row label="AGC threshold">
<Slider min={-130} max={0} step={1} value={settings.rx.agcThreshold}
onChange={v => patch("rx.agcThreshold", v)} unit=" dB" />
</Row>
<Row label="AGC max gain">
<Slider min={0} max={100} step={1} value={settings.rx.agcMaxGain}
onChange={v => patch("rx.agcMaxGain", v)} unit=" dB" />
</Row>
<Row label="AGC attack">
<Slider min={10} max={2000} step={10} value={settings.rx.agcAttack}
onChange={v => patch("rx.agcAttack", v)} unit=" ms" />
</Row>
<Row label="AGC decay">
<Slider min={10} max={5000} step={10} value={settings.rx.agcDecay}
onChange={v => patch("rx.agcDecay", v)} unit=" ms" />
</Row>
</Section>
<Section title="Noise / interference reduction" icon="ti-filter">
<Row label="NR (noise reduction)">
<Toggle value={settings.rx.nrEnabled} onChange={v => patch("rx.nrEnabled", v)} />
<Slider min={0} max={100} value={settings.rx.nrLevel}
onChange={v => patch("rx.nrLevel", v)} unit="%" />
</Row>
<Row label="NB (noise blanker)">
<Toggle value={settings.rx.nbEnabled} onChange={v => patch("rx.nbEnabled", v)} />
<Slider min={0} max={100} value={settings.rx.nbLevel}
onChange={v => patch("rx.nbLevel", v)} unit="%" />
</Row>
<Row label="ANR (auto notch)">
<Toggle value={settings.rx.anrEnabled} onChange={v => patch("rx.anrEnabled", v)} />
<Slider min={0} max={100} value={settings.rx.anrLevel}
onChange={v => patch("rx.anrLevel", v)} unit="%" />
</Row>
</Section>
<Section title="RX equalizer" icon="ti-wave-saw-tool">
<Row label="RX EQ enabled">
<Toggle value={settings.rx.rxEqEnabled}
onChange={v => patch("rx.rxEqEnabled", v)} />
</Row>
<EqGrid values={settings.rx.rxEq}
onChange={v => patch("rx.rxEq", v)} />
</Section>
</>
)}
{/* ──────────────── AetherVoice tab ──────────────── */}
{tab === "aether" && (
<Section title="Aetherial Audio / AetherVoice" icon="ti-brain">
<Row label="Enabled">
<Toggle value={settings.aetherialAudio.enabled}
onChange={v => patch("aetherialAudio.enabled", v)} />
</Row>
<Row label="NR strength">
<Slider min={0} max={100} value={settings.aetherialAudio.nrStrength}
onChange={v => patch("aetherialAudio.nrStrength", v)} unit="%" />
</Row>
<Row label="De-esser">
<Toggle value={settings.aetherialAudio.deesserEnabled}
onChange={v => patch("aetherialAudio.deesserEnabled", v)} />
<Slider min={0} max={100} value={settings.aetherialAudio.deesserStrength}
onChange={v => patch("aetherialAudio.deesserStrength", v)} unit="%" />
</Row>
<Row label="Compressor">
<Toggle value={settings.aetherialAudio.compressorEnabled}
onChange={v => patch("aetherialAudio.compressorEnabled", v)} />
</Row>
<Row label="Threshold">
<Slider min={-60} max={0} step={1} value={settings.aetherialAudio.compressorThreshold}
onChange={v => patch("aetherialAudio.compressorThreshold", v)} unit=" dBFS" />
</Row>
<Row label="Ratio">
<Slider min={1} max={20} step={1} value={settings.aetherialAudio.compressorRatio}
onChange={v => patch("aetherialAudio.compressorRatio", v)} unit=":1" />
</Row>
<Row label="Attack">
<Slider min={1} max={200} step={1} value={settings.aetherialAudio.compressorAttack}
onChange={v => patch("aetherialAudio.compressorAttack", v)} unit=" ms" />
</Row>
<Row label="Release">
<Slider min={10} max={1000} step={10} value={settings.aetherialAudio.compressorRelease}
onChange={v => patch("aetherialAudio.compressorRelease", v)} unit=" ms" />
</Row>
<Row label="EQ enabled">
<Toggle value={settings.aetherialAudio.eqEnabled}
onChange={v => patch("aetherialAudio.eqEnabled", v)} />
</Row>
</Section>
)}
{/* ──────────────── Misc tab ──────────────── */}
{tab === "misc" && (
<>
<Section title="AetherSDR client options" icon="ti-settings">
<Row label="Pan-follow VFO">
<Toggle value={settings.misc.panFollowVfo}
onChange={v => patch("misc.panFollowVfo", v)} />
</Row>
<Row label="Smart spot filter">
<Toggle value={settings.misc.spotFilterEnabled}
onChange={v => patch("misc.spotFilterEnabled", v)} />
</Row>
<Row label="Auto-squelch">
<Toggle value={settings.misc.autoSquelchEnabled}
onChange={v => patch("misc.autoSquelchEnabled", v)} />
</Row>
<Row label="TNF enabled">
<Toggle value={settings.misc.tnfEnabled}
onChange={v => patch("misc.tnfEnabled", v)} />
</Row>
<Row label="CW decoder">
<Toggle value={settings.misc.cwDecoderEnabled}
onChange={v => patch("misc.cwDecoderEnabled", v)} />
</Row>
<Row label="Client-side NR">
<Toggle value={settings.misc.clientNrEnabled}
onChange={v => patch("misc.clientNrEnabled", v)} />
<Slider min={0} max={100} value={settings.misc.clientNrLevel}
onChange={v => patch("misc.clientNrLevel", v)} unit="%" />
</Row>
<Row label="MIDI profile name">
<Input value={settings.misc.midiProfileName}
onChange={v => patch("misc.midiProfileName", v)}
placeholder="e.g. DX_Contest" />
</Row>
<Row label="FlexControl acceleration">
<Slider min={1} max={10} step={1} value={settings.misc.flexControlAcceleration}
onChange={v => patch("misc.flexControlAcceleration", v)} />
</Row>
</Section>
<Section title="Network / integration" icon="ti-network">
<Row label="SmartLink enabled">
<Toggle value={settings.network.smartlinkEnabled}
onChange={v => patch("network.smartlinkEnabled", v)} />
</Row>
<Row label="TCI server">
<Toggle value={settings.network.tciEnabled}
onChange={v => patch("network.tciEnabled", v)} />
<span style={{ fontSize:12, color:"var(--color-text-tertiary)" }}>port</span>
<input type="number" value={settings.network.tciPort}
onChange={e => patch("network.tciPort", Number(e.target.value))}
style={{ width:70, fontSize:13, padding:"3px 6px", borderRadius:5,
border:"0.5px solid var(--color-border-secondary)",
background:"var(--color-background-primary)",
color:"var(--color-text-primary)" }} />
</Row>
<Row label="CAT / rigctld">
<Toggle value={settings.network.catEnabled}
onChange={v => patch("network.catEnabled", v)} />
<span style={{ fontSize:12, color:"var(--color-text-tertiary)" }}>port</span>
<input type="number" value={settings.network.catPort}
onChange={e => patch("network.catPort", Number(e.target.value))}
style={{ width:70, fontSize:13, padding:"3px 6px", borderRadius:5,
border:"0.5px solid var(--color-border-secondary)",
background:"var(--color-background-primary)",
color:"var(--color-text-primary)" }} />
</Row>
</Section>
{activeProfile && profiles[activeProfile] && (
<div style={{ marginTop:8, padding:"10px 14px", borderRadius:"var(--border-radius-md)",
border:"0.5px solid var(--color-border-tertiary)",
background:"var(--color-background-secondary)", fontSize:12,
color:"var(--color-text-tertiary)" }}>
Created by <strong>{profiles[activeProfile].createdBy}</strong> ·{" "}
{new Date(profiles[activeProfile].createdAt).toLocaleString()} ·{" "}
Last updated {new Date(profiles[activeProfile].updatedAt).toLocaleString()}
</div>
)}
</>
)}
<div style={{ marginTop:16, padding:"8px 14px", borderRadius:"var(--border-radius-md)",
border:"0.5px solid var(--color-border-tertiary)",
background:"var(--color-background-secondary)", fontSize:12,
color:"var(--color-text-tertiary)" }}>
<i className="ti ti-info-circle" aria-hidden="true" style={{ marginRight:5 }} />
These settings complement — not replace — your FlexRadio SSDR profiles.
SSDR handles frequency, filters, and TX band power. This tool saves everything AetherSDR-side:
RADE codec params, audio routing, mic/AGC processing, AetherVoice, and integration settings.
Profiles are stored in your browser and can be exported as JSON for backup.
</div>
</div>
);
}
Request preparation
What would you like?
Save and restore AetherSDR settings not captured by FlexRadio SSDR profiles using a feature within in AetherSDR.
import { useState, useEffect, useCallback } from "react";
const DEFAULT_SETTINGS = {
profileName: "",
description: "",
band: "20m",
mode: "RADE",
createdBy: "",
createdAt: "",
updatedAt: "",
// RADE / FreeDV
rade: {
enabled: true,
rxBandwidth: 2400,
txBandwidth: 2400,
squelch: -100,
squelchEnabled: false,
clipThreshold: 0,
freedvReporterEnabled: false,
},
// Audio source / sink
audio: {
txSource: "MIC", // MIC | BAL | PC | DAX
rxSink: "PC", // PC | RADIO_SPEAKER | DAX
pcAudioInputDevice: "",
pcAudioOutputDevice: "",
daxChannel: 1,
daxIqChannel: 1,
daxIqSampleRate: 48, // kHz
},
// Mic / TX audio
mic: {
gain: 40,
bias: false,
boost: false,
acc: false,
vox: false,
voxLevel: 50,
voxDelay: 250,
dexp: false,
dexpLevel: 50,
dexpOffset: -40,
monitor: false,
monitorGain: 50,
txEqEnabled: false,
txEq: { "63": 0, "125": 0, "250": 0, "500": 0, "1k": 0, "2k": 0, "4k": 0, "8k": 0 },
},
// RX audio
rx: {
afGain: 50,
agcMode: "MED", // FAST | MED | SLOW | OFF
agcThreshold: -100,
agcMaxGain: 90,
agcAttack: 250,
agcDecay: 500,
nrEnabled: false,
nrLevel: 50,
nbEnabled: false,
nbLevel: 50,
anrEnabled: false,
anrLevel: 50,
rxEqEnabled: false,
rxEq: { "63": 0, "125": 0, "250": 0, "500": 0, "1k": 0, "2k": 0, "4k": 0, "8k": 0 },
},
// Aetherial Audio / AetherVoice
aetherialAudio: {
enabled: false,
nrStrength: 50,
deesserEnabled: false,
deesserStrength: 50,
compressorEnabled: false,
compressorThreshold: -20,
compressorRatio: 4,
compressorAttack: 10,
compressorRelease: 100,
eqEnabled: false,
},
// SmartLink / network
network: {
smartlinkEnabled: false,
tciEnabled: false,
tciPort: 50001,
catEnabled: false,
catPort: 4532,
},
// FreeDV Reporter
freedvReporter: {
enabled: false,
callsign: "",
gridSquare: "",
reportingServer: "qso.freedvreporter.net",
},
// Misc AetherSDR-side
misc: {
panFollowVfo: true,
spotFilterEnabled: false,
autoSquelchEnabled: false,
tnfEnabled: false,
cwDecoderEnabled: false,
clientNrEnabled: false,
clientNrLevel: 50,
midiProfileName: "",
flexControlAcceleration: 3,
},
};
const BANDS = ["160m","80m","60m","40m","30m","20m","17m","15m","12m","10m","6m","2m"];
const MODES = ["RADE","FreeDV 700D","FreeDV 1600","SSB","LSB","USB","AM","FM","CW","DIGI","FT8","WSPR"];
const TX_SOURCES = ["MIC","BAL","PC","DAX"];
const RX_SINKS = ["PC","RADIO_SPEAKER","DAX"];
const AGC_MODES = ["FAST","MED","SLOW","OFF"];
const DAX_RATES = [24,48,96,192];
const EQ_BANDS = ["63","125","250","500","1k","2k","4k","8k"];
function deepMerge(base, override) {
const out = { ...base };
for (const k of Object.keys(override || {})) {
if (override[k] !== null && typeof override[k] === "object" && !Array.isArray(override[k])) {
out[k] = deepMerge(base[k] || {}, override[k]);
} else {
out[k] = override[k];
}
}
return out;
}
// ─── Small UI atoms ───────────────────────────────────────────────────────────
function Row({ label, children, hint }) {
return (
<div style={{ display:"flex", alignItems:"center", gap:8, marginBottom:8, minHeight:32 }}>
<label style={{ width:180, fontSize:13, color:"var(--color-text-secondary)", flexShrink:0 }}>{label}
<div style={{ flex:1, display:"flex", alignItems:"center", gap:6 }}>{children}
{hint && <span style={{ fontSize:11, color:"var(--color-text-tertiary)" }}>{hint}}
);
}
function Toggle({ value, onChange }) {
return (
<button
onClick={() => onChange(!value)}
style={{
width:40, height:22, borderRadius:11, border:"none", cursor:"pointer",
background: value ? "#1D9E75" : "var(--color-border-secondary)",
position:"relative", padding:0, transition:"background .15s"
}}
aria-pressed={value}
>
<span style={{
position:"absolute", top:3, left: value ? 21 : 3,
width:16, height:16, borderRadius:"50%", background:"white", transition:"left .15s"
}} />
);
}
function Slider({ min, max, step=1, value, onChange, unit="" }) {
return (
<div style={{ display:"flex", alignItems:"center", gap:8, flex:1 }}>
<input type="range" min={min} max={max} step={step} value={value}
onChange={e => onChange(Number(e.target.value))} style={{ flex:1 }} />
<span style={{ fontSize:13, fontWeight:500, minWidth:52, textAlign:"right",
color:"var(--color-text-primary)" }}>{value}{unit}
);
}
function Select({ value, options, onChange }) {
return (
<select value={value} onChange={e => onChange(e.target.value)}
style={{ fontSize:13, padding:"4px 8px", borderRadius:6,
border:"0.5px solid var(--color-border-secondary)",
background:"var(--color-background-primary)",
color:"var(--color-text-primary)" }}>
{options.map(o => {o})}
);
}
function Input({ value, onChange, placeholder, wide }) {
return (
<input value={value} onChange={e => onChange(e.target.value)}
placeholder={placeholder}
style={{ fontSize:13, padding:"4px 8px", borderRadius:6,
border:"0.5px solid var(--color-border-secondary)",
background:"var(--color-background-primary)",
color:"var(--color-text-primary)",
width: wide ? "100%" : 180 }} />
);
}
function Section({ title, icon, children }) {
const [open, setOpen] = useState(true);
return (
<div style={{ marginBottom:12, border:"0.5px solid var(--color-border-tertiary)",
borderRadius:"var(--border-radius-lg)", overflow:"hidden" }}>
<button onClick={() => setOpen(o => !o)}
style={{ width:"100%", display:"flex", alignItems:"center", gap:8, padding:"10px 16px",
background:"var(--color-background-secondary)", border:"none", cursor:"pointer",
color:"var(--color-text-primary)", fontSize:14, fontWeight:500 }}>
<i className={
ti ${icon}} aria-hidden="true" style={{ fontSize:16 }} />{title}
<i className={
ti ${open ? "ti-chevron-up" : "ti-chevron-down"}}aria-hidden="true" style={{ marginLeft:"auto", fontSize:14 }} />
{open && (
<div style={{ padding:"12px 16px", background:"var(--color-background-primary)" }}>
{children}
)}
);
}
function EqGrid({ values, onChange }) {
return (
<div style={{ display:"grid", gridTemplateColumns:"repeat(8,1fr)", gap:4, marginTop:4 }}>
{EQ_BANDS.map(b => (
<div key={b} style={{ display:"flex", flexDirection:"column", alignItems:"center", gap:2 }}>
<span style={{ fontSize:10, color:"var(--color-text-tertiary)" }}>{b}
<input type="range" min={-12} max={12} step={1} value={values[b] || 0}
onChange={e => onChange({ ...values, [b]: Number(e.target.value) })}
style={{ writingMode:"vertical-lr", transform:"rotate(180deg)", height:60 }} />
<span style={{ fontSize:10, color:"var(--color-text-secondary)" }}>{values[b] || 0}
))}
);
}
// ─── Tabs ─────────────────────────────────────────────────────────────────────
const TABS = [
{ id:"rade", label:"RADE / FreeDV", icon:"ti-radio" },
{ id:"audio", label:"Audio routing", icon:"ti-headphones" },
{ id:"mic", label:"Mic / TX", icon:"ti-microphone" },
{ id:"rx", label:"RX / AGC", icon:"ti-volume" },
{ id:"aether", label:"AetherVoice", icon:"ti-brain" },
{ id:"misc", label:"Misc", icon:"ti-adjustments" },
];
// ─── Main app ─────────────────────────────────────────────────────────────────
export default function App() {
const [profiles, setProfiles] = useState({});
const [activeProfile, setActiveProfile] = useState(null);
const [settings, setSettings] = useState(deepMerge(DEFAULT_SETTINGS, {}));
const [tab, setTab] = useState("rade");
const [status, setStatus] = useState(null);
const [newName, setNewName] = useState("");
const [newOp, setNewOp] = useState("");
const [confirmDelete, setConfirmDelete] = useState(null);
// Load from storage on mount
useEffect(() => {
(async () => {
try {
const r = await window.storage.get("aether-op-profiles");
if (r) setProfiles(JSON.parse(r.value));
} catch (_) {}
})();
}, []);
const flash = (msg, type="success") => {
setStatus({ msg, type });
setTimeout(() => setStatus(null), 2800);
};
const save = useCallback(async (ps = profiles) => {
try {
await window.storage.set("aether-op-profiles", JSON.stringify(ps));
} catch (e) {
flash("Storage error: " + e.message, "error");
}
}, [profiles]);
function patch(path, value) {
setSettings(prev => {
const parts = path.split(".");
const next = JSON.parse(JSON.stringify(prev));
let cur = next;
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]];
cur[parts[parts.length - 1]] = value;
return next;
});
}
function createProfile() {
const name = newName.trim();
const op = newOp.trim();
if (!name) { flash("Enter a profile name", "error"); return; }
if (!op) { flash("Enter operator callsign/name", "error"); return; }
const now = new Date().toISOString();
const entry = deepMerge(settings, {
profileName: name,
createdBy: op,
createdAt: now,
updatedAt: now,
});
const next = { ...profiles, [name]: entry };
setProfiles(next);
setActiveProfile(name);
setSettings(entry);
save(next);
setNewName("");
flash(
Profile "${name}" created);}
function updateProfile() {
if (!activeProfile) { flash("No profile selected", "error"); return; }
const now = new Date().toISOString();
const entry = { ...settings, profileName: activeProfile, updatedAt: now };
const next = { ...profiles, [activeProfile]: entry };
setProfiles(next);
save(next);
flash(
"${activeProfile}" updated);}
function loadProfile(name) {
const p = profiles[name];
if (!p) return;
setSettings(deepMerge(DEFAULT_SETTINGS, p));
setActiveProfile(name);
flash(
Loaded "${name}");}
function deleteProfile(name) {
const next = { ...profiles };
delete next[name];
setProfiles(next);
save(next);
if (activeProfile === name) {
setActiveProfile(null);
setSettings(deepMerge(DEFAULT_SETTINGS, {}));
}
setConfirmDelete(null);
flash(
"${name}" deleted);}
function exportProfile() {
if (!activeProfile) { flash("No profile selected", "error"); return; }
const blob = new Blob([JSON.stringify(profiles[activeProfile], null, 2)],
{ type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download =
AetherSDR_${activeProfile.replace(/\s+/g,"_")}.json;a.click();
flash("Exported");
}
function importProfile(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
try {
const data = JSON.parse(ev.target.result);
const name = data.profileName || file.name.replace(".json","");
const now = new Date().toISOString();
const entry = deepMerge(DEFAULT_SETTINGS, { ...data, profileName: name, updatedAt: now });
const next = { ...profiles, [name]: entry };
setProfiles(next);
setActiveProfile(name);
setSettings(entry);
save(next);
flash(
Imported "${name}");} catch {
flash("Invalid JSON file", "error");
}
};
reader.readAsText(file);
e.target.value = "";
}
const profileList = Object.keys(profiles);
return (
<div style={{ fontFamily:"var(--font-sans)", color:"var(--color-text-primary)",
maxWidth:760, margin:"0 auto", padding:"1rem 0" }}>
);
}