Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions docs/architecture/flex-meter-learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,28 @@ captured FLEX-6600 four-slice manifest, `COMPPEAK` appeared in repeated TX
blocks such as `23`, `45`, `67`, and `89`. Those numeric IDs are not stable API
contracts; they are manifest slots for that session.

AetherSDR stores `COMPPEAK` meter IDs by explicit TX waveform `sourceIndex`
when the manifest provides one. In 8000-style captures where repeated TX
blocks all report `num=0`, AetherSDR keys those block-local meters to the most
recent `SLC` slice context from the manifest. Runtime updates then resolve the
active TX slice to the correct manifest meter ID.
FlexLib only treats `Meter.SourceIndex` as a slice ID when
`Meter.Source == Meter.SOURCE_SLICE` (`"SLC"`). It attaches those meters to
`Slice.Meters` with `FindSliceByIndex(m.SourceIndex)`. `TX-` waveform meters
remain radio-level meters; FlexLib does not attach them to a slice or promise
that their `num` field is a slice index.

AetherSDR therefore keys each TX waveform meter to the most recent `SLC` slice
context in the ordered meter manifest. It retains an explicit TX
`sourceIndex` map only as a fallback for manifests without that context. This
also handles the FLEX-8400M 4.2.18 shape observed on hardware: slice A's TX
block used `num=0`, while slice B's used `num=9`. Arithmetic over those values
cannot recover slice IDs, but their preceding SLC blocks identify slices 0 and
1 directly. Runtime updates resolve the active TX slice to that block's meter
ID.

The implementation intentionally derives a slice/source key and then looks up
the manifest ID for that key. It does not calculate final meter IDs directly.

| Radio family / manifest shape | Slice/source resolution | Compression meter | Model value | UI gauge value |
|---|---|---|---|---|
| FLEX-6000-style explicit TX source | `txBase = min(TX sourceIndex >= 8) - min(SLC sourceIndex)`, then `activeTxSource = txBase + activeTxSlice` | `COMPPEAK` at `activeTxSource` | `clamp(COMPPEAK, 0, 25)` | `-modelValue` |
| FLEX-8000-style explicit TX source | Same explicit TX-source lookup when the manifest provides per-slice TX source indices | `COMPPEAK` at `activeTxSource` | `clamp(COMPPEAK, 0, 25)` | `-modelValue` |
| FLEX-8000-style repeated `TX- num=0` blocks | Use the most recent `SLC` source index as manifest context, then resolve by `activeTxSlice` at runtime | `COMPPEAK` mapped to `activeTxSlice` | `clamp(COMPPEAK, 0, 25)` | `-modelValue` |
| FLEX-6000/8000 ordered per-slice blocks | Use the most recent `SLC` source index as manifest context, then resolve by `activeTxSlice` at runtime | `COMPPEAK` mapped to `activeTxSlice` | `clamp(COMPPEAK, 0, 25)` | `-modelValue` |
| TX block without preceding SLC context | Fall back to the explicit TX `sourceIndex` map | `COMPPEAK` at the resolved TX source | `clamp(COMPPEAK, 0, 25)` | `-modelValue` |

Issue #2040 describes the 6600 failure mode this avoids: the old scalar meter
index approach was last-match-wins, so a multi-slice session could bind to the
Expand Down
119 changes: 90 additions & 29 deletions src/models/MeterModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -129,36 +129,54 @@ void MeterModel::defineMeter(const MeterDef& def)
else if (def.name == "MICPEAK")
m_micPeakIdx = def.index;
else if (isTxWaveformMeter(def) && def.name == "COMPPEAK") {
if (hasExplicitTxWaveformSourceIndex(def))
// FlexLib only attaches SLC meters to Slice.Meters. TX- num is not a
// slice ID contract (an 8400M reports 0 for slice A and 9 for B), so
// the preceding SLC block is authoritative when it is available.
if (m_manifestSliceContext >= 0) {
m_compPeakIdxBySlice[m_manifestSliceContext] = def.index;
}
if (hasExplicitTxWaveformSourceIndex(def)) {
m_compPeakIdxByTxSource[def.sourceIndex] = def.index;
else
} else if (m_manifestSliceContext < 0) {
m_compPeakIdxBySlice[implicitTxWaveformSliceIndex()] = def.index;
}
}
else if (def.name == "MIC")
m_micLevelIdx = def.index;
else if (def.name == "COMP")
m_compLevelIdx = def.index;
else if (def.name == "HWALC")
m_hwAlcIdx = def.index;
else if (def.name == "ALC") {
m_swAlcIdx = def.index;
m_swAlcUnit = def.unit;
else if (isTxWaveformMeter(def) && def.name == "ALC") {
if (m_manifestSliceContext >= 0) {
m_swAlcIdxBySlice[m_manifestSliceContext] = def.index;
Comment thread
rfoust marked this conversation as resolved.
Outdated
}
if (hasExplicitTxWaveformSourceIndex(def)) {
m_swAlcIdxByTxSource[def.sourceIndex] = def.index;
} else if (m_manifestSliceContext < 0) {
m_swAlcIdxBySlice[implicitTxWaveformSliceIndex()] = def.index;
}
}
else if (isTxWaveformMeter(def)
&& (def.name == "SC_MIC" || def.name == "SC_FILT_1"
|| def.name == "SC_FILT_2")) {
// Same resolution COMPPEAK uses: key by explicit TX-waveform
// sourceIndex where the radio supplies one, otherwise by the slice
// context the manifest was in when this block arrived.
const bool explicitSource = hasExplicitTxWaveformSourceIndex(def);
const int key = explicitSource ? def.sourceIndex
: implicitTxWaveformSliceIndex();
if (def.name == "SC_MIC")
(explicitSource ? m_scMicIdxByTxSource : m_scMicIdxBySlice)[key] = def.index;
else if (def.name == "SC_FILT_1")
(explicitSource ? m_scFilt1IdxByTxSource : m_scFilt1IdxBySlice)[key] = def.index;
else
(explicitSource ? m_scFilt2IdxByTxSource : m_scFilt2IdxBySlice)[key] = def.index;
QMap<int, int>& byTxSource = def.name == "SC_MIC" ? m_scMicIdxByTxSource
: def.name == "SC_FILT_1" ? m_scFilt1IdxByTxSource
: m_scFilt2IdxByTxSource;
QMap<int, int>& bySlice = def.name == "SC_MIC" ? m_scMicIdxBySlice
: def.name == "SC_FILT_1" ? m_scFilt1IdxBySlice
: m_scFilt2IdxBySlice;
if (m_manifestSliceContext >= 0) {
bySlice[m_manifestSliceContext] = def.index;
}
if (hasExplicitTxWaveformSourceIndex(def)) {
byTxSource[def.sourceIndex] = def.index;
} else if (m_manifestSliceContext < 0) {
bySlice[implicitTxWaveformSliceIndex()] = def.index;
}
}
else if (def.source != "AMP" && def.name == "PATEMP")
m_paTempIdx = def.index;
Expand Down Expand Up @@ -198,6 +216,7 @@ void MeterModel::defineMeter(const MeterDef& def)

void MeterModel::removeMeter(int index)
{
const int activeSwAlcIdx = swAlcIndexForActiveTxSlice();
m_defs.remove(index);
m_values.remove(index);
m_valueUpdatedMs.remove(index);
Expand Down Expand Up @@ -240,7 +259,19 @@ void MeterModel::removeMeter(int index)
if (index == m_micLevelIdx) m_micLevelIdx = -1;
if (index == m_compLevelIdx) m_compLevelIdx = -1;
if (index == m_hwAlcIdx) m_hwAlcIdx = -1;
if (index == m_swAlcIdx) { m_swAlcIdx = -1; m_swAlcUnit.clear(); }
for (QMap<int, int>* map : {&m_swAlcIdxByTxSource, &m_swAlcIdxBySlice}) {
for (auto it = map->begin(); it != map->end(); ) {
if (it.value() == index) {
it = map->erase(it);
} else {
++it;
}
}
}
if (index == activeSwAlcIdx) {
m_swAlc = 0.0f;
emit swAlcChanged(m_swAlc);
}
// A level must never outlive the meter it describes.
// Resolve the ACTIVE indices BEFORE erasing: once the entry is gone the
// resolver returns -1 and the has-a-sample flag would never be cleared.
Expand Down Expand Up @@ -357,8 +388,8 @@ void MeterModel::clear()
m_micLevelIdx = -1;
m_compLevelIdx = -1;
m_hwAlcIdx = -1;
m_swAlcIdx = -1;
m_swAlcUnit.clear();
m_swAlcIdxByTxSource.clear();
m_swAlcIdxBySlice.clear();
m_paTempIdx = -1;
m_paCurrentIdx = -1;
m_hasPaTempValue = false;
Expand Down Expand Up @@ -419,8 +450,10 @@ void MeterModel::setActiveTxSlice(int sliceIndex)
m_hasScFilt1Value = false;
m_hasScFilt2Value = false;
clearCompressionState();
m_swAlc = 0.0f;
logCompressionSummary("active-slice-change", true);
emit micMetersChanged(m_micLevel, m_compLevel, m_micPeak, m_compPeak);
emit swAlcChanged(m_swAlc);
Comment thread
rfoust marked this conversation as resolved.
}

void MeterModel::clearCompressionState()
Expand All @@ -438,13 +471,13 @@ void MeterModel::clearCompressionState()
// because models must not include gui headers; meter_model_test pins the pair.
static constexpr float kAlcGaugeFloorDbfs = -20.0f;

float MeterModel::convertAlcToGaugeDbfs(float raw) const
float MeterModel::convertAlcToGaugeDbfs(float raw, const QString& unit) const
{
if (m_swAlcUnit.compare(QLatin1String("dBFS"), Qt::CaseInsensitive) == 0
|| m_swAlcUnit.isEmpty()) {
if (unit.compare(QLatin1String("dBFS"), Qt::CaseInsensitive) == 0
|| unit.isEmpty()) {
return raw; // already the gauge's own unit, or a backend from before this field
}
if (m_swAlcUnit.compare(QLatin1String("Percent"), Qt::CaseInsensitive) == 0) {
if (unit.compare(QLatin1String("Percent"), Qt::CaseInsensitive) == 0) {
const float frac = qBound(0.0f, raw / 100.0f, 1.0f);
return kAlcGaugeFloorDbfs * (1.0f - frac);
}
Expand Down Expand Up @@ -520,12 +553,14 @@ int MeterModel::activeTxWaveformSourceIndex() const

int MeterModel::compPeakIndexForActiveTxSlice() const
{
const int txSource = activeTxWaveformSourceIndex();
if (txSource >= 0 && m_compPeakIdxByTxSource.contains(txSource))
return m_compPeakIdxByTxSource.value(txSource);
const int bySlice = m_compPeakIdxBySlice.value(m_activeTxSlice, -1);
if (bySlice >= 0)
if (bySlice >= 0) {
return bySlice;
}
const int txSource = activeTxWaveformSourceIndex();
if (txSource >= 0 && m_compPeakIdxByTxSource.contains(txSource)) {
return m_compPeakIdxByTxSource.value(txSource);
}

// ONE transmitter, and transmit is not on the slice the manifest filed the
// meter under.
Expand All @@ -549,6 +584,27 @@ int MeterModel::compPeakIndexForActiveTxSlice() const
return -1;
}

int MeterModel::swAlcIndexForActiveTxSlice() const
{
const int bySlice = m_swAlcIdxBySlice.value(m_activeTxSlice, -1);
if (bySlice >= 0) {
return bySlice;
}
const int txSource = activeTxWaveformSourceIndex();
if (txSource >= 0 && m_swAlcIdxByTxSource.contains(txSource)) {
return m_swAlcIdxByTxSource.value(txSource);
}

// Backends with one modulator publish one implicit ALC meter even when
// transmit moves between receivers. Keep that single-meter shape working,
// but never guess when the radio supplies an explicit or multi-entry map.
if (m_activeTxSlice >= 0 && m_swAlcIdxByTxSource.isEmpty()
&& m_swAlcIdxBySlice.size() == 1) {
return m_swAlcIdxBySlice.constBegin().value();
}
return -1;
}

void MeterModel::logCompressionMeterMap(const MeterDef& def) const
{
if (!lcMeters().isDebugEnabled())
Expand Down Expand Up @@ -646,6 +702,7 @@ void MeterModel::updateValues(const QVector<quint16>& ids, const QVector<qint16>
const int n = qMin(ids.size(), vals.size());
const qint64 packetUpdatedMs = QDateTime::currentMSecsSinceEpoch();
const int activeCompPeakIdx = compPeakIndexForActiveTxSlice();
const int activeSwAlcIdx = swAlcIndexForActiveTxSlice();
// Resolved once per packet, same shape as activeCompPeakIdx: these must
// track the ACTIVE TX slice, not whichever block was defined last.
const int activeScMicIdx = scMicIndexForActiveTxSlice();
Expand Down Expand Up @@ -781,7 +838,7 @@ void MeterModel::updateValues(const QVector<quint16>& ids, const QVector<qint16>
} else if (idx == m_hwAlcIdx) {
m_hwAlc = v;
hwAlcChangedFlag = true;
} else if (idx == m_swAlcIdx) {
} else if (idx == activeSwAlcIdx) {
// The ALC consumers are a dBFS gauge (-20..0). A radio that runs its
// OWN ALC has no dBFS to give — the IC-705 reports 0..100 % of full
// scale — so a percentage handed straight over pins the gauge at the
Expand All @@ -791,7 +848,7 @@ void MeterModel::updateValues(const QVector<quint16>& ids, const QVector<qint16>
// Map it onto the gauge instead. This is a PRESENTATION mapping and
// not a measurement: it says "this fraction of the radio's own ALC
// range", and the only honest claim it makes is proportionality.
m_swAlc = convertAlcToGaugeDbfs(v);
m_swAlc = convertAlcToGaugeDbfs(v, it->unit);
swAlcChangedFlag = true;
} else if (activeScMicIdx >= 0 && idx == activeScMicIdx) {
m_scMic = v;
Expand Down Expand Up @@ -919,9 +976,13 @@ static int resolveTxWaveformIndex(const QMap<int, int>& byTxSource,
const QMap<int, int>& bySlice,
int txSource, int activeSlice)
{
if (txSource >= 0 && byTxSource.contains(txSource))
if (bySlice.contains(activeSlice)) {
return bySlice.value(activeSlice);
}
if (txSource >= 0 && byTxSource.contains(txSource)) {
return byTxSource.value(txSource);
return bySlice.value(activeSlice, -1);
}
return -1;
}

int MeterModel::scMicIndexForActiveTxSlice() const
Expand Down
26 changes: 14 additions & 12 deletions src/models/MeterModel.h
Original file line number Diff line number Diff line change
Expand Up @@ -311,13 +311,14 @@ class MeterModel : public QObject {
void recomputeSourceIndexMins();
// Map a radio-side ALC reading onto the dBFS range the gauges are built
// for. Identity when the backend already declares dBFS.
float convertAlcToGaugeDbfs(float raw) const;
float convertAlcToGaugeDbfs(float raw, const QString& unit) const;
bool isTxWaveformMeter(const MeterDef& def) const;
bool hasExplicitTxWaveformSourceIndex(const MeterDef& def) const;
int implicitTxWaveformSliceIndex() const;
int txWaveformBase() const;
int activeTxWaveformSourceIndex() const;
int compPeakIndexForActiveTxSlice() const;
int swAlcIndexForActiveTxSlice() const;
void logCompressionMeterMap(const MeterDef& def) const;
void logCompressionSummary(const char* reason, bool force = false);

Expand All @@ -331,13 +332,14 @@ class MeterModel : public QObject {
// Cached indices for fast lookup of important meters
QMap<int, int> m_sLevelIdxBySlice; // sliceIndex → meter index for "SLC"/"LEVEL"
QMap<int, int> m_escLevelIdxBySlice; // sliceIndex → meter index for "SLC"/"ESC"
QMap<int, int> m_compPeakIdxByTxSource; // TX waveform sourceIndex → "COMPPEAK"
QMap<int, int> m_compPeakIdxBySlice; // active slice → "COMPPEAK" for TX blocks with num=0
QMap<int, int> m_compPeakIdxByTxSource; // TX waveform sourceIndex → "COMPPEAK" fallback
QMap<int, int> m_compPeakIdxBySlice; // preceding SLC manifest block → "COMPPEAK"
int m_minSliceSourceIndex{-1};
int m_minTxWaveformSourceIndex{-1};
int m_manifestSliceContext{-1};
int m_activeTxSlice{-1};
// The UNIT each of these was DECLARED with, cached at definition time.
// The UNIT each directional-power meter was DECLARED with, cached at
// definition time. ALC resolves the unit from the active meter definition.
//
// Load-bearing, and the absence of it was a real defect. This model used to
// interpret a meter purely by NAME and apply a unit it ASSUMED — FWDPWR was
Expand All @@ -350,7 +352,6 @@ class MeterModel : public QObject {
// correctly reported both as fed.
QString m_fwdPwrUnit;
QString m_refPwrUnit;
QString m_swAlcUnit;

int m_fwdPwrIdx{-1}; // "FWDPWR"
int m_refPwrIdx{-1}; // "REFPWR"
Expand All @@ -359,14 +360,15 @@ class MeterModel : public QObject {
int m_micLevelIdx{-1}; // "COD-" / "MIC" (hardware mic RX level)
int m_compLevelIdx{-1}; // "TX" / "COMP" (instantaneous)
int m_hwAlcIdx{-1}; // "TX" / "HWALC" — external RCA jack voltage
int m_swAlcIdx{-1}; // "TX" / "ALC" — post-software-ALC SSB peak
QMap<int, int> m_swAlcIdxByTxSource; // TX waveform sourceIndex → "ALC" fallback
QMap<int, int> m_swAlcIdxBySlice; // preceding SLC manifest block → "ALC"
// Per-slice, exactly like COMPPEAK above: a radio can publish one TX
// waveform meter block PER ACTIVE SLICE (6600 uses distinct sourceIndex
// values, 8000 repeats "TX- num=0" after each SLC block). A single index
// per meter would be last-definition-wins, and the TX-filter check would
// silently watch some other slice's filter.
QMap<int, int> m_scMicIdxByTxSource; // "TX" / "SC_MIC" (explicit sourceIndex)
QMap<int, int> m_scMicIdxBySlice; // (implicit, num=0)
// waveform meter block PER ACTIVE SLICE. TX- sourceIndex is not a slice-ID
// contract: models may use distinct values, repeated zero, or mixed 0/9.
// The preceding SLC block supplies the slice association. A single index
// per meter would be last-definition-wins and silently watch another slice.
QMap<int, int> m_scMicIdxByTxSource; // "TX" / "SC_MIC" sourceIndex fallback
QMap<int, int> m_scMicIdxBySlice; // preceding SLC manifest block
QMap<int, int> m_scFilt1IdxByTxSource; // "TX" / "SC_FILT_1"
QMap<int, int> m_scFilt1IdxBySlice;
QMap<int, int> m_scFilt2IdxByTxSource; // "TX" / "SC_FILT_2"
Expand Down
Loading
Loading