diff --git a/docs/architecture/flex-meter-learnings.md b/docs/architecture/flex-meter-learnings.md index e7fd0805b..54fc19dff 100644 --- a/docs/architecture/flex-meter-learnings.md +++ b/docs/architecture/flex-meter-learnings.md @@ -59,20 +59,49 @@ 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 associates new TX waveform meters with the preceding `SLC` slice +context in the observed ordered meter manifest. The Flex decoder preserves +first-appearance wire order when grouping a status message's fields; it never +sorts the definitions by meter ID. Existing meter identities retain their +association when re-announced, including unit or description changes. A removed +or repurposed ID loses its old routes and samples, and removal or a non-TX/non-SLC +block invalidates the context for subsequent new definitions. + +Definitions are registered in either the slice map or the explicit TX +`sourceIndex` fallback map, never both. A contextual meter cannot become another +slice's fallback when that slice's own meter disappears. The fallback keeps the +legacy arithmetic `txBase = min(TX sourceIndex >= 8) - min(SLC sourceIndex)` +(or just the TX minimum when no SLC is present), then looks up +`txBase + activeTxSlice`. This is compatibility behavior for context-free +manifests, not a FlexLib guarantee. The observed SLC association +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. This block-order association comes from captured firmware behavior; +FlexLib's SLC-only ownership rule does not itself guarantee TX block ordering. +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` | +| Exactly one implicit COMPPEAK meter and no explicit fallback entries | Follow the single modulator across receivers, including before a TX slice is selected | The unique implicit meter | Same conversion as above | Same conversion as above | + +The ALC/COMPPEAK singleton fallback never volunteers an explicitly associated +meter for a different active slice. SC_MIC/SC_FILT_1/SC_FILT_2 require active-slice resolution +without that fallback so filter diagnostics cannot combine unrelated chains. +ALC resolves its conversion unit from the selected definition and resets to its +presentation floor, -20 dBFS, at startup, disconnect, TX-slice changes and active +meter removal. That reset is already in gauge units even for Percent meters. 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 diff --git a/src/core/backends/flex/FlexBackend.cpp b/src/core/backends/flex/FlexBackend.cpp index b49315098..345208de0 100644 --- a/src/core/backends/flex/FlexBackend.cpp +++ b/src/core/backends/flex/FlexBackend.cpp @@ -683,8 +683,12 @@ void FlexBackend::decodeMeterStatus(const QString& rawBody) return; } - // Group tokens by meter index. + // Group fields by meter ID, but publish in first-appearance wire order. + // MeterModel associates a TX waveform block with its preceding SLC block + // (observed FLEX-8400M fw 4.2.18); sorting IDs can move a reused TX ID + // ahead of its own SLC context. QMap> grouped; + QList meterOrder; const QStringList tokens = rawBody.split('#', Qt::SkipEmptyParts); for (const QString& token : tokens) { const int dot = token.indexOf('.'); @@ -694,11 +698,14 @@ void FlexBackend::decodeMeterStatus(const QString& rawBody) bool ok = false; const int idx = token.left(dot).toInt(&ok); if (!ok) continue; + if (!grouped.contains(idx)) { + meterOrder.append(idx); + } grouped[idx][token.mid(dot + 1, eq - dot - 1)] = token.mid(eq + 1); } - for (auto it = grouped.constBegin(); it != grouped.constEnd(); ++it) { - const QMap& f = it.value(); + for (int index : meterOrder) { + const QMap& f = grouped.constFind(index).value(); // Build the typed MeterDef directly (#4070). Present-only: a field the // wire didn't report keeps its MeterDef default. The carry() ok-guard is // defensive/consistency only here — a plain MeterDef field's default IS @@ -709,7 +716,7 @@ void FlexBackend::decodeMeterStatus(const QString& rawBody) // (slice/transmit), where a dropped value leaves the field disengaged. // (#4075 review.) MeterDef def; - def.index = it.key(); + def.index = index; carry(f, "src", def.source); carry(f, "num", def.sourceIndex, /*base=*/0); carry(f, "nam", def.name); diff --git a/src/models/MeterModel.cpp b/src/models/MeterModel.cpp index b1438d1da..99fde928b 100644 --- a/src/models/MeterModel.cpp +++ b/src/models/MeterModel.cpp @@ -107,9 +107,25 @@ void MeterModel::setTgxlHandle(quint32 handle) void MeterModel::defineMeter(const MeterDef& def) { + const auto previous = m_defs.constFind(def.index); + const bool redefinition = previous != m_defs.constEnd() + && previous->source == def.source && previous->sourceIndex == def.sourceIndex + && previous->name == def.name; + if (previous != m_defs.constEnd() && !redefinition) { + // An ID reused for a different meter must lose every old route/sample. + // This is an incoming definition, not a protocol removal: keep the + // context of its current block while purging the previous identity. + const int sliceContext = m_manifestSliceContext; + removeMeter(def.index); + m_manifestSliceContext = sliceContext; + } m_defs[def.index] = def; - if (def.source == "SLC") + if (def.source == "SLC") { m_manifestSliceContext = def.sourceIndex; + } else if (!isTxWaveformMeter(def)) { + // A non-waveform block ends the observed SLC -> TX manifest context. + m_manifestSliceContext = -1; + } // Cache indices for high-frequency lookups if (def.source == "SLC" && def.name == "LEVEL") @@ -129,10 +145,8 @@ 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)) - m_compPeakIdxByTxSource[def.sourceIndex] = def.index; - else - m_compPeakIdxBySlice[implicitTxWaveformSliceIndex()] = def.index; + registerTxWaveformMeter(def, redefinition, + m_compPeakIdxByTxSource, m_compPeakIdxBySlice); } else if (def.name == "MIC") m_micLevelIdx = def.index; @@ -140,25 +154,25 @@ void MeterModel::defineMeter(const MeterDef& def) m_compLevelIdx = def.index; else if (def.name == "HWALC") m_hwAlcIdx = def.index; + else if (isTxWaveformMeter(def) && def.name == "ALC") { + registerTxWaveformMeter(def, redefinition, + m_swAlcIdxByTxSource, m_swAlcIdxBySlice); + } else if (def.name == "ALC") { - m_swAlcIdx = def.index; - m_swAlcUnit = def.unit; + qCWarning(lcMeters) << "MeterModel: ALC definition has no TX waveform route" + << def.index << def.source << def.sourceIndex; } 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; + // All waveform meters share the same stable registration policy. + QMap& byTxSource = def.name == "SC_MIC" ? m_scMicIdxByTxSource + : def.name == "SC_FILT_1" ? m_scFilt1IdxByTxSource + : m_scFilt2IdxByTxSource; + QMap& bySlice = def.name == "SC_MIC" ? m_scMicIdxBySlice + : def.name == "SC_FILT_1" ? m_scFilt1IdxBySlice + : m_scFilt2IdxBySlice; + registerTxWaveformMeter(def, redefinition, byTxSource, bySlice); } else if (def.source != "AMP" && def.name == "PATEMP") m_paTempIdx = def.index; @@ -198,40 +212,26 @@ void MeterModel::defineMeter(const MeterDef& def) void MeterModel::removeMeter(int index) { + const int activeSwAlcIdx = swAlcIndexForActiveTxSlice(); + // Removal starts a new manifest lifecycle. Existing ownership survives, + // but newly declared meters need fresh SLC context or the source fallback. + m_manifestSliceContext = -1; m_defs.remove(index); m_values.remove(index); m_valueUpdatedMs.remove(index); - // Remove from per-slice LEVEL map - for (auto it = m_sLevelIdxBySlice.begin(); it != m_sLevelIdxBySlice.end(); ) { - if (it.value() == index) it = m_sLevelIdxBySlice.erase(it); - else ++it; - } - for (auto it = m_escLevelIdxBySlice.begin(); it != m_escLevelIdxBySlice.end(); ) { - if (it.value() == index) it = m_escLevelIdxBySlice.erase(it); - else ++it; - } - bool compressionMapChanged = false; - for (auto it = m_compPeakIdxByTxSource.begin(); it != m_compPeakIdxByTxSource.end(); ) { - if (it.value() == index) { - it = m_compPeakIdxByTxSource.erase(it); - compressionMapChanged = true; - } else { - ++it; - } - } - for (auto it = m_compPeakIdxBySlice.begin(); it != m_compPeakIdxBySlice.end(); ) { - if (it.value() == index) { - it = m_compPeakIdxBySlice.erase(it); - compressionMapChanged = true; - } else { - ++it; - } - } + const auto matchesIndex = [index](QMap::iterator entry) { + return entry.value() == index; + }; + m_sLevelIdxBySlice.removeIf(matchesIndex); + m_escLevelIdxBySlice.removeIf(matchesIndex); + const bool removedCompSource = m_compPeakIdxByTxSource.removeIf(matchesIndex) > 0; + const bool removedCompSlice = m_compPeakIdxBySlice.removeIf(matchesIndex) > 0; + const bool compressionMapChanged = removedCompSource || removedCompSlice; if (index == m_fwdPwrIdx) { m_fwdPwrIdx = -1; m_fwdPwrUnit.clear(); } if (index == m_refPwrIdx) { m_refPwrIdx = -1; - m_refPwrUnit.clear(); + m_refPwrUnit.clear(); m_reflectedPower = 0.0f; m_lastReflectedPowerUpdateMs = 0; } @@ -240,7 +240,12 @@ 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(); } + m_swAlcIdxByTxSource.removeIf(matchesIndex); + m_swAlcIdxBySlice.removeIf(matchesIndex); + if (index == activeSwAlcIdx) { + m_swAlc = kAlcGaugeFloorDbfs; + 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. @@ -250,11 +255,9 @@ void MeterModel::removeMeter(int index) for (QMap* m : {&m_scMicIdxByTxSource, &m_scMicIdxBySlice, &m_scFilt1IdxByTxSource, &m_scFilt1IdxBySlice, &m_scFilt2IdxByTxSource, &m_scFilt2IdxBySlice}) { - for (auto it = m->begin(); it != m->end(); ) { - if (it.value() == index) it = m->erase(it); - else ++it; - } + m->removeIf(matchesIndex); } + if (index == activeScMic) m_hasScMicValue = false; if (index == activeScFilt1) m_hasScFilt1Value = false; if (index == activeScFilt2) m_hasScFilt2Value = false; @@ -357,8 +360,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; @@ -398,7 +401,7 @@ void MeterModel::clear() m_micLevel = -50.0f; m_compLevel = 0.0f; m_hwAlc = 0.0f; - m_swAlc = 0.0f; + m_swAlc = kAlcGaugeFloorDbfs; m_paTemp = 0.0f; m_paCurrent = 0.0f; m_supplyVolts = 0.0f; @@ -419,8 +422,10 @@ void MeterModel::setActiveTxSlice(int sliceIndex) m_hasScFilt1Value = false; m_hasScFilt2Value = false; clearCompressionState(); + m_swAlc = kAlcGaugeFloorDbfs; logCompressionSummary("active-slice-change", true); emit micMetersChanged(m_micLevel, m_compLevel, m_micPeak, m_compPeak); + emit swAlcChanged(m_swAlc); } void MeterModel::clearCompressionState() @@ -434,17 +439,13 @@ void MeterModel::clearCompressionState() m_lastCompressionSummaryReason.clear(); } -// Mirrors PhoneCwApplet's kAlcGaugeFloorDbfs. Duplicated rather than shared -// 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) { - 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); } @@ -473,6 +474,30 @@ bool MeterModel::hasExplicitTxWaveformSourceIndex(const MeterDef& def) const return isTxWaveformMeter(def) && def.sourceIndex >= kMinTxWaveformSourceIndex; } +void MeterModel::registerTxWaveformMeter(const MeterDef& def, bool redefinition, + QMap& byTxSource, + QMap& bySlice) +{ + // Updating units/description is not a new ownership declaration. In + // particular, an isolated re-announcement must not inherit another + // slice's most recent SLC block. Identity changes are removed first. + if (redefinition) { + return; + } + + // FlexLib attaches only SLC meters to Slice.Meters. The observed + // FLEX-8400M fw 4.2.18 TX- sources are 0 for A and 9 for B, so use the + // preceding SLC block when available. Do not also register a source + // fallback: that could volunteer a known B meter after A's is removed. + if (m_manifestSliceContext >= 0) { + bySlice[m_manifestSliceContext] = def.index; + } else if (hasExplicitTxWaveformSourceIndex(def)) { + byTxSource[def.sourceIndex] = def.index; + } else { + bySlice[implicitTxWaveformSliceIndex()] = def.index; + } +} + int MeterModel::implicitTxWaveformSliceIndex() const { if (m_manifestSliceContext >= 0) @@ -520,33 +545,12 @@ 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) - return bySlice; - - // ONE transmitter, and transmit is not on the slice the manifest filed the - // meter under. - // - // A Flex declares COMPPEAK per TX-waveform slice, so the explicit map above - // answers and this never runs. A backend with a single modulator however - // many receivers it runs (HL2) declares ONE implicit-source COMPPEAK, and - // defineMeter() files it under implicitTxWaveformSliceIndex() — the - // manifest's SLC sourceIndex, which is 0 — while m_activeTxSlice follows - // whichever receiver currently owns transmit. Move transmit to the second - // receiver and the lookup above misses, so the compression gauge went dead - // for a compressor that was still working (#4609 review). - // - // Deliberately narrow. With an explicit per-waveform map present, or more - // than one implicit entry, "which slice" is a real question and answering it - // by picking the only entry would point the gauge at the wrong transmitter. - if (m_activeTxSlice >= 0 && m_compPeakIdxByTxSource.isEmpty() - && m_compPeakIdxBySlice.size() == 1) { - return m_compPeakIdxBySlice.constBegin().value(); - } - return -1; + return resolveTxWaveformIndex(m_compPeakIdxByTxSource, m_compPeakIdxBySlice, true); +} + +int MeterModel::swAlcIndexForActiveTxSlice() const +{ + return resolveTxWaveformIndex(m_swAlcIdxByTxSource, m_swAlcIdxBySlice, true); } void MeterModel::logCompressionMeterMap(const MeterDef& def) const @@ -555,9 +559,9 @@ void MeterModel::logCompressionMeterMap(const MeterDef& def) const return; const int base = txWaveformBase(); - const int slice = hasExplicitTxWaveformSourceIndex(def) - ? (base >= 0 ? def.sourceIndex - base : -1) - : implicitTxWaveformSliceIndex(); + const int knownSlice = m_compPeakIdxBySlice.key(def.index, -1); + const int slice = knownSlice >= 0 ? knownSlice + : (base >= 0 ? def.sourceIndex - base : -1); qCDebug(lcMeters) << "MeterModel: compression meter map" << "name" << def.name << "id" << def.index @@ -646,6 +650,7 @@ void MeterModel::updateValues(const QVector& ids, const QVector 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(); @@ -781,7 +786,7 @@ void MeterModel::updateValues(const QVector& ids, const QVector } 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 @@ -791,7 +796,7 @@ void MeterModel::updateValues(const QVector& ids, const QVector // 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; @@ -915,31 +920,51 @@ void MeterModel::updateValues(const QVector& ids, const QVector emit tgxlMetersChanged(m_tgxlFwdPwr, m_tgxlSwr); } -static int resolveTxWaveformIndex(const QMap& byTxSource, - const QMap& bySlice, - int txSource, int activeSlice) +int MeterModel::resolveTxWaveformIndex(const QMap& byTxSource, + const QMap& bySlice, + bool allowSingleImplicit) const { - if (txSource >= 0 && byTxSource.contains(txSource)) + if (bySlice.contains(m_activeTxSlice)) { + return bySlice.value(m_activeTxSlice); + } + const int txSource = activeTxWaveformSourceIndex(); + if (txSource >= 0 && byTxSource.contains(txSource)) { return byTxSource.value(txSource); - return bySlice.value(activeSlice, -1); + } + // A single implicit modulator follows TX between receivers (#4609). + // Registration is exclusive, so map size counts distinct meter indices. + // Keep explicit per-waveform ownership strict even with only one meter; + // one observed entry does not prove another slice uses that transmitter. + if (allowSingleImplicit && byTxSource.isEmpty() && bySlice.size() == 1) { + const int index = bySlice.constBegin().value(); + const MeterDef* def = meterDef(index); + if (def && !hasExplicitTxWaveformSourceIndex(*def)) { + return index; + } + } + return -1; } int MeterModel::scMicIndexForActiveTxSlice() const { + // Filter diagnostics require an associated pair for the active slice. + // Unlike ALC/COMPPEAK, do not assemble one from singleton fallbacks that + // could belong to different chains. A one-modulator backend can declare + // the taps for its active slice if it supports this diagnostic. return resolveTxWaveformIndex(m_scMicIdxByTxSource, m_scMicIdxBySlice, - activeTxWaveformSourceIndex(), m_activeTxSlice); + false); } int MeterModel::scFilt1IndexForActiveTxSlice() const { return resolveTxWaveformIndex(m_scFilt1IdxByTxSource, m_scFilt1IdxBySlice, - activeTxWaveformSourceIndex(), m_activeTxSlice); + false); } int MeterModel::scFilt2IndexForActiveTxSlice() const { return resolveTxWaveformIndex(m_scFilt2IdxByTxSource, m_scFilt2IdxBySlice, - activeTxWaveformSourceIndex(), m_activeTxSlice); + false); } qint64 MeterModel::txFilterLevelSkewMs() const diff --git a/src/models/MeterModel.h b/src/models/MeterModel.h index 486b5fa17..d760f419b 100644 --- a/src/models/MeterModel.h +++ b/src/models/MeterModel.h @@ -311,13 +311,21 @@ 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; + // Mirrors the Phone/CW gauge's floor without introducing a gui dependency. + static constexpr float kAlcGaugeFloorDbfs = -20.0f; + static float convertAlcToGaugeDbfs(float raw, const QString& unit); + void registerTxWaveformMeter(const MeterDef& def, bool redefinition, + QMap& byTxSource, QMap& bySlice); + int resolveTxWaveformIndex(const QMap& byTxSource, + const QMap& bySlice, + bool allowSingleImplicit = false) 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); @@ -331,13 +339,14 @@ class MeterModel : public QObject { // Cached indices for fast lookup of important meters QMap m_sLevelIdxBySlice; // sliceIndex → meter index for "SLC"/"LEVEL" QMap m_escLevelIdxBySlice; // sliceIndex → meter index for "SLC"/"ESC" - QMap m_compPeakIdxByTxSource; // TX waveform sourceIndex → "COMPPEAK" - QMap m_compPeakIdxBySlice; // active slice → "COMPPEAK" for TX blocks with num=0 + QMap m_compPeakIdxByTxSource; // TX waveform sourceIndex → "COMPPEAK" fallback + QMap m_compPeakIdxBySlice; // preceding SLC manifest block → "COMPPEAK" int m_minSliceSourceIndex{-1}; int m_minTxWaveformSourceIndex{-1}; - int m_manifestSliceContext{-1}; + int m_manifestSliceContext{-1}; // new definitions only; cleared by removal/non-TX blocks 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 @@ -350,7 +359,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" @@ -359,14 +367,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 m_swAlcIdxByTxSource; // TX waveform sourceIndex → "ALC" fallback + QMap 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 m_scMicIdxByTxSource; // "TX" / "SC_MIC" (explicit sourceIndex) - QMap 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 m_scMicIdxByTxSource; // "TX" / "SC_MIC" sourceIndex fallback + QMap m_scMicIdxBySlice; // preceding SLC manifest block QMap m_scFilt1IdxByTxSource; // "TX" / "SC_FILT_1" QMap m_scFilt1IdxBySlice; QMap m_scFilt2IdxByTxSource; // "TX" / "SC_FILT_2" @@ -406,7 +415,7 @@ class MeterModel : public QObject { float m_micLevel{-50.0f}; float m_compLevel{0.0f}; float m_hwAlc{0.0f}; - float m_swAlc{0.0f}; + float m_swAlc{kAlcGaugeFloorDbfs}; float m_scMic{0.0f}; float m_scFilt1{0.0f}; float m_scFilt2{0.0f}; diff --git a/tests/aetherd_meter_decode_test.cpp b/tests/aetherd_meter_decode_test.cpp index 1f16b6e75..dbadbeca1 100644 --- a/tests/aetherd_meter_decode_test.cpp +++ b/tests/aetherd_meter_decode_test.cpp @@ -4,6 +4,7 @@ #include "core/backends/flex/FlexBackend.h" #include "core/backends/MeterDef.h" +#include "models/MeterModel.h" #include #include @@ -53,7 +54,7 @@ int main(int argc, char** argv) CHECK(rem.takeFirst().at(0).toInt() == 7); } - // ---- multiple meters in one body, grouped by index (ascending) ---- + // ---- multiple meters in one body, grouped in first-appearance order ---- { FlexBackend backend; QSignalSpy def(&backend, &IRadioBackend::meterDefined); @@ -66,6 +67,41 @@ int main(int argc, char** argv) CHECK(b.index == 2 && b.name == QStringLiteral("SWR")); } + // The SLC block identifies the following TX waveform block in the observed + // FLEX-8400M fw 4.2.18 manifest. A lower/reused TX meter ID must not move + // ahead of its SLC context when the decoder groups definition fields. + // This injects strings into the decoder; it never connects a backend. + { + FlexBackend backend; + MeterModel model; + QObject::connect(&backend, &IRadioBackend::meterDefined, + &model, &MeterModel::defineMeter); + QSignalSpy definitions(&backend, &IRadioBackend::meterDefined); + backend.decodeMeterStatus(QStringLiteral( + "50.src=SLC#50.num=0#50.nam=LEVEL#50.unit=dBm#" + "20.src=TX-#20.num=0#20.nam=ALC#20.unit=dBFS#" + "70.src=SLC#70.num=1#70.nam=LEVEL#70.unit=dBm#" + "40.src=TX-#40.num=9#40.nam=ALC#40.unit=dBFS#20.desc=first-slice")); + CHECK(definitions.count() == 4); + if (definitions.count() == 4) { + CHECK(definitions.at(0).at(0).value().index == 50); + CHECK(definitions.at(1).at(0).value().index == 20); + CHECK(definitions.at(2).at(0).value().index == 70); + CHECK(definitions.at(3).at(0).value().index == 40); + CHECK(definitions.at(1).at(0).value().description == "first-slice"); + } + model.setActiveTxSlice(0); + model.updateValues({20, 40}, {-768, -1536}); + CHECK(model.swAlc() == -6.0f); + model.setActiveTxSlice(1); + model.updateValues({20, 40}, {-768, -1536}); + CHECK(model.swAlc() == -12.0f); + backend.decodeMeterStatus(QStringLiteral( + "20.src=TX-#20.num=0#20.nam=ALC#20.unit=dBFS")); + model.updateValues({20, 40}, {-768, -1536}); + CHECK(model.swAlc() == -12.0f); + } + // ---- malformed index token skipped, no emission ---- { FlexBackend backend; diff --git a/tests/meter_model_test.cpp b/tests/meter_model_test.cpp index 650c790bb..0d7e270a3 100644 --- a/tests/meter_model_test.cpp +++ b/tests/meter_model_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -339,6 +340,7 @@ void testAlcPercentIsMappedOntoTheGaugeRange() // and stays there, which is what "ALC is completely pegged" looked like. MeterModel model; model.defineMeter(txMeter(11, "ALC", "Percent")); + model.setActiveTxSlice(0); float alc = 999.0f; QObject::connect(&model, &MeterModel::swAlcChanged, [&alc](float v) { alc = v; }); @@ -356,6 +358,7 @@ void testAlcPercentIsMappedOntoTheGaugeRange() // cannot speak dBFS, not a reinterpretation of the ones that can. MeterModel dbfs; dbfs.defineMeter(txMeter(11, "ALC", "dBFS")); + dbfs.setActiveTxSlice(0); float passthrough = 999.0f; QObject::connect(&dbfs, &MeterModel::swAlcChanged, [&passthrough](float v) { passthrough = v; }); @@ -363,6 +366,260 @@ void testAlcPercentIsMappedOntoTheGaugeRange() report("a dBFS ALC meter passes through unchanged", nearlyEqual(passthrough, -6.0f)); } +void testActiveTxSliceSelectsAlcAndItsUnit() +{ + MeterModel model; + model.defineMeter(slcMeter(15, 0)); + model.defineMeter(txMeter(23, "ALC", "dBFS", 8)); + model.defineMeter(slcMeter(37, 1)); + model.defineMeter(txMeter(45, "ALC", "Percent", 9)); + + model.setActiveTxSlice(0); + model.updateValues({23}, {rawDb(-6.0f)}); + report("active TX slice 0 uses its ALC meter and dBFS unit", + nearlyEqual(model.swAlc(), -6.0f)); + + model.updateValues({45}, {50}); + report("inactive ALC meter is ignored", nearlyEqual(model.swAlc(), -6.0f)); + + model.setActiveTxSlice(1); + report("changing active TX slice clears stale ALC", nearlyEqual(model.swAlc(), -20.0f)); + + model.updateValues({45}, {50}); + report("active TX slice 1 uses its ALC meter and Percent unit", + nearlyEqual(model.swAlc(), -10.0f)); +} + +void testMixedSourceAlcUsesManifestSliceContext() +{ + // FLEX-8400M fw 4.2.18 declares slice A's TX waveform block with num=0, + // then slice B's with num=9. Source-index arithmetic cannot map that pair; + // the preceding SLC block is the radio's stable association. + MeterModel model; + model.defineMeter(slcMeter(12, 0)); + model.defineMeter(txMeter(22, "ALC", "dBFS", 0)); + model.defineMeter(slcMeter(30, 1)); + model.defineMeter(txMeter(40, "ALC", "dBFS", 9)); + + model.setActiveTxSlice(1); + model.updateValues({22}, {rawDb(-3.0f)}); + report("8400M slice B ignores slice A's zero-source ALC", + nearlyEqual(model.swAlc(), -20.0f)); + + model.updateValues({40}, {rawDb(-6.4f)}); + report("8400M slice B resolves ALC from manifest context", + nearlyEqual(model.swAlc(), -6.4f)); +} + +void testMixedSourceTxWaveformMetersUseManifestSliceContext() +{ + MeterModel model; + model.defineMeter(slcMeter(12, 0)); + model.defineMeter(txMeter(18, "SC_MIC", "dBFS", 0)); + model.defineMeter(txMeter(20, "COMPPEAK", "dB", 0)); + model.defineMeter(txMeter(21, "SC_FILT_1", "dBFS", 0)); + model.defineMeter(txMeter(24, "SC_FILT_2", "dBFS", 0)); + model.defineMeter(slcMeter(30, 1)); + model.defineMeter(txMeter(36, "SC_MIC", "dBFS", 9)); + model.defineMeter(txMeter(38, "COMPPEAK", "dB", 9)); + model.defineMeter(txMeter(39, "SC_FILT_1", "dBFS", 9)); + model.defineMeter(txMeter(42, "SC_FILT_2", "dBFS", 9)); + + model.setActiveTxSlice(1); + model.updateValues({36, 38, 39, 42}, + {rawDb(-12.0f), rawDb(8.0f), rawDb(-9.0f), rawDb(-15.0f)}); + + report("8400M slice B resolves COMPPEAK from manifest context", + model.hasCompressionMeterValue() && nearlyEqual(model.compPeak(), 8.0f)); + report("8400M slice B resolves TX filter levels from manifest context", + model.hasTxFilterLevels() && nearlyEqual(model.scFilt1(), -9.0f) + && nearlyEqual(model.scFilt2(), -15.0f)); +} + +void testZeroSourceAlcUsesSliceContext() +{ + MeterModel model; + model.defineMeter(slcMeter(14, 0)); + model.defineMeter(txMeter(20, "ALC", "dBFS", 0)); + model.defineMeter(slcMeter(32, 1)); + model.defineMeter(txMeter(44, "ALC", "dBFS", 0)); + + model.setActiveTxSlice(1); + model.updateValues({20}, {rawDb(-4.0f)}); + report("inactive zero-source ALC meter is ignored", nearlyEqual(model.swAlc(), -20.0f)); + + model.updateValues({44}, {rawDb(-12.0f)}); + report("zero-source ALC meter follows active slice context", + nearlyEqual(model.swAlc(), -12.0f)); +} + +void testSingleImplicitAlcFollowsTransmitToAnySlice() +{ + MeterModel model; + model.defineMeter(slcMeter(1, 0)); + model.defineMeter(txMeter(8, "ALC", "dBFS", 0)); + + model.setActiveTxSlice(1); + model.updateValues({8}, {rawDb(-9.0f)}); + report("a single implicit ALC follows transmit onto another slice", + nearlyEqual(model.swAlc(), -9.0f)); + + model.removeMeter(8); + report("removing the active ALC meter clears its value", + nearlyEqual(model.swAlc(), -20.0f)); +} + +void testTxMeterRedefinitionsPreserveTheirSlice() +{ + for (bool implicit : {false, true}) { + MeterModel model; + const QStringList names{"ALC", "COMPPEAK", "SC_MIC", "SC_FILT_1", "SC_FILT_2"}; + for (int slice = 0; slice < 2; ++slice) { + model.defineMeter(slcMeter(10 + 20 * slice, slice)); + for (int i = 0; i < names.size(); ++i) { + model.defineMeter(txMeter(20 + 20 * slice + i, names[i], + names[i] == "COMPPEAK" ? "dB" : "dBFS", + implicit ? 0 : 8 + slice)); + } + } + model.setActiveTxSlice(1); + // A profile re-announces A's definitions after B's SLC context. The + // same meter identity must retain its original ownership and units + // must still update. No synthetic radio or socket is involved. + for (int i = 0; i < names.size(); ++i) { + model.defineMeter(txMeter(20 + i, names[i], + names[i] == "ALC" ? "Percent" + : names[i] == "COMPPEAK" ? "dB" : "dBFS", + implicit ? 0 : 8)); + } + model.updateValues({20, 21, 22, 23, 24, 40, 41, 42, 43, 44}, + {50, rawDb(3), rawDb(-3), rawDb(-4), rawDb(-5), + rawDb(-8), rawDb(12), rawDb(-10), rawDb(-11), rawDb(-12)}); + report("A's redefinition cannot replace B's ALC/compression/filter ownership", + nearlyEqual(model.swAlc(), -8) && nearlyEqual(model.compPeak(), 12) + && nearlyEqual(model.scMic(), -10) && nearlyEqual(model.scFilt1(), -11) + && nearlyEqual(model.scFilt2(), -12)); + model.setActiveTxSlice(0); + model.updateValues({20}, {50}); + report("a redefined ALC unit updates without changing its slice", + nearlyEqual(model.swAlc(), -10)); + model.removeMeter(40); + model.setActiveTxSlice(1); + model.updateValues({20}, {50}); + report("a redefinition leaves no duplicate slice alias for an ALC meter", + implicit ? nearlyEqual(model.swAlc(), -10) + : nearlyEqual(model.swAlc(), -20)); + } +} + +void testAlcClearsToPresentationFloor() +{ + for (const QString& unit : {QStringLiteral("dBFS"), QStringLiteral("Percent")}) { + MeterModel model; + report("ALC starts at the presentation floor", nearlyEqual(model.swAlc(), -20)); + model.defineMeter(slcMeter(10, 0)); + model.defineMeter(txMeter(20, "ALC", unit, 8)); + model.defineMeter(slcMeter(30, 1)); + model.defineMeter(txMeter(40, "ALC", unit, 9)); + model.setActiveTxSlice(1); + model.updateValues({40}, {unit == "Percent" ? qint16(50) : rawDb(-8)}); + float emitted = 999; + int emissions = 0; + QObject::connect(&model, &MeterModel::swAlcChanged, [&](float value) { + emitted = value; + ++emissions; + }); + model.setActiveTxSlice(0); + report("a TX slice change emits the empty ALC presentation value", + emissions == 1 && nearlyEqual(emitted, -20)); + model.setActiveTxSlice(0); + report("re-selecting the TX slice does not emit another clear", emissions == 1); + model.updateValues({20}, {unit == "Percent" ? qint16(50) : rawDb(-8)}); + model.removeMeter(20); + report("active ALC removal emits the empty presentation value", + emissions == 3 && nearlyEqual(emitted, -20)); + model.clear(); + report("disconnect resets ALC to the presentation floor", nearlyEqual(model.swAlc(), -20)); + } +} + +void testTxMeterIdentityReuseAndContextLifetime() +{ + MeterModel model; + model.defineMeter(slcMeter(10, 0)); + model.defineMeter(txMeter(20, "ALC", "dBFS", 8)); + model.defineMeter(slcMeter(30, 1)); + model.defineMeter(txMeter(40, "ALC", "dBFS", 9)); + model.setActiveTxSlice(1); + model.updateValues({40}, {rawDb(-8)}); + MeterDef replacement = txMeter(40, "UNRELATED", "dBFS", 9); + model.defineMeter(replacement); + model.updateValues({40}, {rawDb(-3)}); + report("an index reused for another meter cannot keep its ALC route", + nearlyEqual(model.swAlc(), -20)); + model.removeMeter(30); + model.removeMeter(10); + model.removeMeter(20); + model.removeMeter(40); + model.defineMeter(txMeter(60, "ALC", "dBFS", 8)); + model.defineMeter(txMeter(80, "ALC", "dBFS", 9)); + model.setActiveTxSlice(0); + model.updateValues({60, 80}, {rawDb(-6), rawDb(-12)}); + report("removed SLC context cannot poison a later context-free explicit map", + nearlyEqual(model.swAlc(), -6)); + model.setActiveTxSlice(1); + model.updateValues({60, 80}, {rawDb(-6), rawDb(-12)}); + report("context-free explicit ALC resolves the second slice", nearlyEqual(model.swAlc(), -12)); + + MeterModel repurposed; + repurposed.defineMeter(slcMeter(10, 0)); + repurposed.defineMeter(txMeter(20, "ALC", "dBFS", 0)); + repurposed.defineMeter(txMeter(40, "SC_MIC", "dBFS", 0)); + repurposed.defineMeter(slcMeter(30, 1)); + repurposed.defineMeter(txMeter(40, "ALC", "dBFS", 9)); + repurposed.setActiveTxSlice(1); + repurposed.updateValues({20, 40}, {rawDb(-6), rawDb(-12)}); + report("repurposing an ID retains the incoming definition's SLC context", + nearlyEqual(repurposed.swAlc(), -12)); +} + +void testExplicitAlcIsNotVolunteeredToAnotherSlice() +{ + MeterModel model; + model.defineMeter(slcMeter(10, 0)); + model.defineMeter(txMeter(20, "ALC", "dBFS", 8)); + model.defineMeter(slcMeter(30, 1)); + model.setActiveTxSlice(1); + model.updateValues({20}, {rawDb(-3)}); + report("a lone explicitly associated ALC is not assigned to another slice", + nearlyEqual(model.swAlc(), -20)); +} + +void testImplicitModulatorBeforeTxSelectionAndUnrelatedContext() +{ + MeterModel implicit; + implicit.defineMeter(txMeter(20, "ALC", "dBFS", 0)); + implicit.defineMeter(txMeter(21, "COMPPEAK", "dB", 0)); + implicit.updateValues({20, 21}, {rawDb(-8), rawDb(6)}); + report("one implicit modulator can publish before a TX slice is selected", + nearlyEqual(implicit.swAlc(), -8) && nearlyEqual(implicit.compPeak(), 6)); + MeterModel separate; + separate.defineMeter(slcMeter(10, 1)); + MeterDef unrelated; + unrelated.index = 15; + unrelated.source = "RAD"; + unrelated.name = "PATEMP"; + unrelated.unit = "degC"; + separate.defineMeter(unrelated); + separate.defineMeter(txMeter(20, "ALC", "dBFS", 8)); + separate.defineMeter(txMeter(40, "ALC", "dBFS", 9)); + separate.setActiveTxSlice(1); + // The legacy fallback's base is 8 - min(SLC=1); slice 1 resolves source 8. + separate.updateValues({20, 40}, {rawDb(-6), rawDb(-12)}); + report("an unrelated manifest block ends SLC context before explicit fallback", + nearlyEqual(separate.swAlc(), -6)); +} + void testDirectionalPowerUsesDirectReflectedMeter() { MeterModel model; @@ -843,9 +1100,10 @@ void testChangingActiveTxSliceDropsStaleFilterLevels() { MeterModel model; model.defineMeter(slcMeter(10, 0)); - model.defineMeter(slcMeter(11, 1)); model.defineMeter(txMeter(29, "SC_FILT_1", "dBFS", 8)); model.defineMeter(txMeter(32, "SC_FILT_2", "dBFS", 8)); + // Declare the taps in slice A's block before moving to slice B's block. + model.defineMeter(slcMeter(11, 1)); model.setActiveTxSlice(0); model.updateValues({29, 32}, {rawDb(-8.0f), rawDb(-70.0f)}); const bool hadLevels = model.hasTxFilterLevels(); @@ -916,6 +1174,16 @@ int main(int argc, char** argv) testForwardPowerHonoursItsDeclaredUnit(); testReflectedPowerHonoursItsDeclaredUnit(); testAlcPercentIsMappedOntoTheGaugeRange(); + testActiveTxSliceSelectsAlcAndItsUnit(); + testMixedSourceAlcUsesManifestSliceContext(); + testMixedSourceTxWaveformMetersUseManifestSliceContext(); + testZeroSourceAlcUsesSliceContext(); + testSingleImplicitAlcFollowsTransmitToAnySlice(); + testTxMeterRedefinitionsPreserveTheirSlice(); + testAlcClearsToPresentationFloor(); + testTxMeterIdentityReuseAndContextLifetime(); + testExplicitAlcIsNotVolunteeredToAnotherSlice(); + testImplicitModulatorBeforeTxSelectionAndUnrelatedContext(); testDirectionalPowerUsesDirectReflectedMeter(); testNativeSwrRemainsRadioProvidedAtLowPower(); testForwardPowerSnapsToZeroWhenTheCarrierStops(); diff --git a/tests/phone_cw_level_meter_state_test.cpp b/tests/phone_cw_level_meter_state_test.cpp index 23e244d7c..b3401412b 100644 --- a/tests/phone_cw_level_meter_state_test.cpp +++ b/tests/phone_cw_level_meter_state_test.cpp @@ -4,8 +4,10 @@ #include "TestSettingsProfile.h" #include "gui/HGauge.h" #include "gui/PhoneCwApplet.h" +#include "models/MeterModel.h" #include +#include #include @@ -79,5 +81,48 @@ int main(int argc, char** argv) check(levelGauge->value() == -16.0f, "a repeated disconnected state preserves live PC-mic telemetry"); + // Drive the actual ALC consumer with normalized model values, including + // Percent input. There is no backend, transport or keyed transmitter. + MeterModel meters; + for (int slice = 0; slice < 2; ++slice) { + MeterDef slc; + slc.index = 10 + slice; + slc.source = "SLC"; + slc.sourceIndex = slice; + slc.name = "LEVEL"; + slc.unit = "dBm"; + meters.defineMeter(slc); + MeterDef alc; + alc.index = 20 + slice; + alc.source = "TX-"; + alc.sourceIndex = 8 + slice; + alc.name = "ALC"; + alc.unit = "Percent"; + meters.defineMeter(alc); + } + meters.setActiveTxSlice(1); + QObject::connect(&meters, &MeterModel::swAlcChanged, + &applet, &PhoneCwApplet::updateAlc); + QList alcGauges; + for (QWidget* widget : applet.findChildren()) { + if (widget->accessibleName() == "ALC gauge (Phone)" + || widget->accessibleName() == "ALC gauge (CW)") { + alcGauges.append(static_cast(widget)); + } + } + check(alcGauges.size() == 2, "both Phone and CW ALC mirrors are present"); + const auto checkAlc = [&](float expected, const char* description) { + for (const HGauge* gauge : alcGauges) { + check(gauge->value() == expected, description); + } + }; + meters.updateValues({21}, {50}); + checkAlc(-10.0f, "a percentage ALC sample reaches both gauges in dBFS"); + meters.setActiveTxSlice(0); + checkAlc(-20.0f, "changing TX slice sets both ALC gauges to empty"); + meters.updateValues({20}, {50}); + meters.removeMeter(20); + checkAlc(-20.0f, "active meter removal sets both ALC gauges to empty"); + return failures == 0 ? 0 : 1; }