Skip to content

Save and restore AetherSDR settings not captured by FlexRadio SSDR profiles #3384

Description

@fl-aver

Request preparation

  • I used an AI assistant to help structure this request
  • I checked for existing issues covering the same feature

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>

);
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    GUIUser interfaceaudioAudio engine and streamingenhancementImprovement to existing featuremaintainer-reviewRequires maintainer review before any action is taken

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions