From 4540987537bbd99c9629426b6e09e688b316b1e2 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Mon, 13 Jul 2026 07:57:34 -0700 Subject: [PATCH 1/3] Add default TX capture health summaries. Principle VIII. --- CMakeLists.txt | 10 ++ docs/automation-bridge.md | 10 ++ src/core/AudioEngine.cpp | 162 +++++++++++++++++++++++++++++- src/core/AudioEngine.h | 12 +++ src/core/AudioSummaryLogger.cpp | 40 ++++++++ src/core/AudioSummaryLogger.h | 19 ++++ src/core/LogManager.cpp | 2 +- src/core/TxCaptureHealthTracker.h | 147 +++++++++++++++++++++++++++ tests/tx_capture_health_test.cpp | 83 +++++++++++++++ 9 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 src/core/TxCaptureHealthTracker.h create mode 100644 tests/tx_capture_health_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e4210d7bd..a7d6b55f7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2422,6 +2422,16 @@ target_include_directories(audio_output_router_test PRIVATE src) target_link_libraries(audio_output_router_test PRIVATE Qt6::Core Qt6::Multimedia) add_test(NAME audio_output_router_test COMMAND audio_output_router_test) +# Hardware-free state-machine coverage for the default-on TX capture health +# summary. Reproduces the Qt pull-mode Active -> Idle/full-buffer signature and +# verifies anomaly rate limiting without requiring PipeWire or an audio device. +add_executable(tx_capture_health_test + tests/tx_capture_health_test.cpp +) +target_include_directories(tx_capture_health_test PRIVATE src) +target_link_libraries(tx_capture_health_test PRIVATE Qt6::Core) +add_test(NAME tx_capture_health_test COMMAND tx_capture_health_test) + # Regression test for #4003 — QsoRecorder must not dereference a SliceModel that # was freed (reconnect prune) before recording starts. QPointer auto-nulls the # reference; the test deletes the slice and asserts the metadata is cleared. diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index 50690d903..2eac8215f 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -602,6 +602,16 @@ chunks dispatched to Receive Presentation Sync analysis, while that were captured for automation but skipped because no KiwiSDR audio source was active. +The TX input endpoint also exposes default-on capture-health evidence for TCI +handoffs: `buffer_bytes_available`, `buffer_capacity_bytes`, +`source_was_active`, `saturation_observed`, `tci_suppressed_callbacks`, +`idle_during_tci_transitions`, `post_tci_local_tx_while_idle`, and +`last_mic_read_age_ms`. `saturation_observed` is deliberately conservative: it +requires a source that was previously Active to become Idle while TCI is fresh, +after at least one microphone callback was suppressed, with unread bytes still +buffered. The same evidence is summarized in the default Audio Summary support +log without enabling verbose audio logging. + ### `get cwx` CWX keyer state, including the **queue-drain watch** that the #3949 fix relies on. Firmware never emits `cwx queue=`, so the client detects a drained CWX buffer diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index d8dd40117..20a343122 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -2525,6 +2525,19 @@ QJsonArray AudioEngine::audioEndpointDiagnostics() const tx["sample_format"] = txRunning ? QStringLiteral("Int16") : QString(); tx["resampling_active"] = txRunning ? QJsonValue(m_txNeedsResample) : QJsonValue(); tx["note"] = m_txInputMono ? QStringLiteral("mono input promoted to stereo for radio TX") : QString(); + const TxCaptureHealthTracker::Snapshot txHealth = + m_txCaptureHealth.snapshot(txCaptureNowMs()); + tx["buffer_bytes_available"] = static_cast(txCaptureBufferedBytes()); + tx["buffer_capacity_bytes"] = static_cast(txCaptureBufferCapacityBytes()); + tx["source_was_active"] = txHealth.sourceWasActive; + tx["saturation_observed"] = txHealth.saturationObserved; + tx["tci_suppressed_callbacks"] = static_cast(txHealth.tciSuppressedCallbacks); + tx["idle_during_tci_transitions"] = static_cast(txHealth.idleDuringTciTransitions); + tx["post_tci_local_tx_while_idle"] = + static_cast(txHealth.postTciLocalTxWhileIdle); + tx["last_mic_read_age_ms"] = txHealth.lastMicReadAgeMs >= 0 + ? QJsonValue(static_cast(txHealth.lastMicReadAgeMs)) + : QJsonValue(); endpoints.append(tx); const bool sidetoneRunning = m_sidetoneSink && m_sidetoneSink->isRunning(); @@ -6944,6 +6957,118 @@ void AudioEngine::logTxInputChannelDiagnostics(const TxMicChannelNormalizer::Dia << TxMicChannelNormalizer::channelModeName(diagnostics.selectedMode); } +TxCaptureHealthTracker::CaptureState AudioEngine::txCaptureState(QAudio::State state) +{ + switch (state) { + case QAudio::ActiveState: return TxCaptureHealthTracker::CaptureState::Active; + case QAudio::IdleState: return TxCaptureHealthTracker::CaptureState::Idle; + case QAudio::SuspendedState: return TxCaptureHealthTracker::CaptureState::Suspended; + case QAudio::StoppedState: return TxCaptureHealthTracker::CaptureState::Stopped; + } + return TxCaptureHealthTracker::CaptureState::Stopped; +} + +qint64 AudioEngine::txCaptureBufferedBytes() const +{ +#ifdef Q_OS_MAC + return m_micBuffer ? m_micBuffer->size() : 0; +#else + return m_micDevice ? m_micDevice->bytesAvailable() : 0; +#endif +} + +qint64 AudioEngine::txCaptureBufferCapacityBytes() const +{ + return m_audioSource ? m_audioSource->bufferSize() : 0; +} + +qint64 AudioEngine::txCaptureNowMs() const +{ + return m_txCaptureHealthClock.isValid() ? m_txCaptureHealthClock.elapsed() : 0; +} + +bool AudioEngine::tciAudioFresh() const +{ + return m_tciAudioTimer.isValid() + && m_tciAudioTimer.elapsed() < kTciAudioActiveWindowMs; +} + +void AudioEngine::observeTxCaptureState(QAudio::State state) +{ + const TxCaptureHealthTracker::Event event = m_txCaptureHealth.observeState( + txCaptureState(state), tciAudioFresh(), txCaptureBufferedBytes()); + if (event != TxCaptureHealthTracker::Event::None) { + logTxCaptureHealthEvent(event); + } +} + +void AudioEngine::recordTxCaptureLocalTxAttempt() +{ + if (!m_audioSource) { + return; + } + + const TxCaptureHealthTracker::Event event = m_txCaptureHealth.recordLocalTxAttempt( + txCaptureState(m_audioSource->state()), + m_transmitting.load(std::memory_order_acquire), + m_daxTxMode.load(std::memory_order_acquire), + tciAudioFresh(), + txCaptureBufferedBytes()); + if (event != TxCaptureHealthTracker::Event::None) { + logTxCaptureHealthEvent(event); + } +} + +void AudioEngine::logTxCaptureHealthEvent(TxCaptureHealthTracker::Event event) +{ + switch (event) { + case TxCaptureHealthTracker::Event::BufferSaturatedDuringTci: + logTxCaptureHealthSummary(QStringLiteral("buffer saturated during TCI suppression"), true); + break; + case TxCaptureHealthTracker::Event::LocalTxWhileSaturated: + logTxCaptureHealthSummary(QStringLiteral("local TX with saturated post-TCI capture"), true); + break; + case TxCaptureHealthTracker::Event::None: + break; + } +} + +void AudioEngine::logTxCaptureHealthSummary(const QString& reason, bool anomaly) +{ + const TxCaptureHealthTracker::Snapshot health = + m_txCaptureHealth.snapshot(txCaptureNowMs()); + if (!anomaly && health.tciSuppressedCallbacks == 0 + && health.idleDuringTciTransitions == 0 + && health.postTciLocalTxWhileIdle == 0) { + return; + } + + const QAudioDevice device = m_inputDevice.isNull() + ? QMediaDevices::defaultAudioInput() + : m_inputDevice; + + AudioSummaryLogger::TxCaptureHealthSummary summary; + summary.reason = reason; + summary.deviceDescription = device.description(); + summary.state = m_audioSource + ? audioStateName(m_audioSource->state()) + : QStringLiteral("Stopped"); + summary.error = m_audioSource + ? audioErrorName(m_audioSource->error()) + : QStringLiteral("NoError"); + summary.lifecycleMs = health.lifecycleMs; + summary.bufferedBytes = txCaptureBufferedBytes(); + summary.bufferCapacityBytes = txCaptureBufferCapacityBytes(); + summary.lastMicReadAgeMs = health.lastMicReadAgeMs; + summary.tciSuppressedCallbacks = health.tciSuppressedCallbacks; + summary.suppressedBufferPeakBytes = health.suppressedBufferPeakBytes; + summary.idleDuringTciTransitions = health.idleDuringTciTransitions; + summary.postTciLocalTxWhileIdle = health.postTciLocalTxWhileIdle; + summary.sourceWasActive = health.sourceWasActive; + summary.saturationObserved = health.saturationObserved; + AudioSummaryLogger::logTxCaptureHealth(summary, anomaly); +} + // ─── TX stream ──────────────────────────────────────────────────────────────── bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioPort) @@ -7284,6 +7409,17 @@ bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioP #endif #endif + m_txCaptureHealthClock.restart(); + m_txCaptureHealth.reset(txCaptureState(m_audioSource->state())); + QAudioSource* const observedSource = m_audioSource; + connect(observedSource, &QAudioSource::stateChanged, this, + [this, observedSource](QAudio::State state) { + if (observedSource != m_audioSource) { + return; + } + observeTxCaptureState(state); + }, Qt::QueuedConnection); + m_txSourceStartTime.restart(); qCWarning(lcAudio) << "AudioEngine: TX stream started ->" << radioAddress.toString() << ":" << radioPort << "streamId:" << Qt::hex << m_txStreamId @@ -7304,6 +7440,9 @@ bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioP void AudioEngine::stopTxStream() { + if (m_audioSource) { + logTxCaptureHealthSummary(QStringLiteral("source lifecycle ended"), false); + } ++m_txLifecycleGeneration; #ifdef Q_OS_MAC QTimer* pollTimer = m_txPollTimer; @@ -7428,8 +7567,11 @@ void AudioEngine::onTxAudioReady() // where the default CoreAudio input is a real webcam mic that // produces continuous ambient packets. The 200 ms window comfortably // covers the 50 ms TCI frame cadence. - if (m_tciAudioTimer.isValid() - && m_tciAudioTimer.elapsed() < kTciAudioActiveWindowMs) { + if (tciAudioFresh()) { + m_txCaptureHealth.recordSuppressedCallback(txCaptureBufferedBytes()); + if (m_audioSource) { + observeTxCaptureState(m_audioSource->state()); + } return; } #ifdef Q_OS_MAC @@ -7450,6 +7592,8 @@ void AudioEngine::onTxAudioReady() m_txReceivedAnyBytes = true; // disarms the WASAPI silent-open watchdog (#2929) #endif + m_txCaptureHealth.recordMicRead(txCaptureNowMs()); + // Canonicalize immediately after capture: TX voice is logically mono // carried as stereo int16, so choose/average the real mic channel before // any resampling, RADE/DAX branch, test tone, DSP, gain, limiter, or meter. @@ -7930,6 +8074,20 @@ void AudioEngine::setRadioTransmitting(bool tx) if (previous == tx) return; + if (tx) { + // radioTransmittingChanged originates on the UI thread while AudioEngine + // owns its QAudioSource and health tracker on the audio thread. Preserve + // the existing immediate atomic TX edge, but sample capture state only + // on the owning thread so diagnostics cannot race readyRead/stateChanged. + if (thread() == QThread::currentThread()) { + recordTxCaptureLocalTxAttempt(); + } else { + QMetaObject::invokeMethod(this, [this]() { + recordTxCaptureLocalTxAttempt(); + }, Qt::QueuedConnection); + } + } + // Close the CW-record over on unkey so the next over re-arms cleanly (the // pump latches on our keyer, clears here). #2539. if (!tx) m_cwKeyedThisOver.store(false, std::memory_order_release); diff --git a/src/core/AudioEngine.h b/src/core/AudioEngine.h index 71ad41e73..7ae94ab02 100644 --- a/src/core/AudioEngine.h +++ b/src/core/AudioEngine.h @@ -23,6 +23,7 @@ #include #include "TxMicChannelNormalizer.h" +#include "TxCaptureHealthTracker.h" #include "SpectralNR.h" class QMediaDevices; @@ -800,6 +801,15 @@ private slots: void accumulatePcMicMeterInt16Stereo(const QByteArray& int16stereo); void logTxInputChannelDiagnostics(const TxMicChannelNormalizer::Diagnostics& diagnostics, const char* route); + static TxCaptureHealthTracker::CaptureState txCaptureState(QAudio::State state); + qint64 txCaptureBufferedBytes() const; + qint64 txCaptureBufferCapacityBytes() const; + qint64 txCaptureNowMs() const; + bool tciAudioFresh() const; + void observeTxCaptureState(QAudio::State state); + void recordTxCaptureLocalTxAttempt(); + void logTxCaptureHealthEvent(TxCaptureHealthTracker::Event event); + void logTxCaptureHealthSummary(const QString& reason, bool anomaly); // Apply the whole RX DSP chain in the configured order. Phase 0 // ships the dispatcher with no implemented stages — every entry is @@ -841,6 +851,8 @@ private slots: std::atomic m_daxTxMode{false}; // DAX TX mode: VirtualAudioBridge handles TX QElapsedTimer m_txSourceStartTime; quint64 m_txLifecycleGeneration{0}; + QElapsedTimer m_txCaptureHealthClock; + TxCaptureHealthTracker m_txCaptureHealth; // WASAPI silent-open watchdog (#2929): some USB PnP mics report mono-only // capture but Qt accepts an unsupported stereo open and then delivers no // bytes. The watchdog reopens as mono if no bytes arrive within ~1.5 s. diff --git a/src/core/AudioSummaryLogger.cpp b/src/core/AudioSummaryLogger.cpp index 3eba7f709..b5d0fe31c 100644 --- a/src/core/AudioSummaryLogger.cpp +++ b/src/core/AudioSummaryLogger.cpp @@ -26,6 +26,11 @@ QString yesNo(bool value) return value ? QStringLiteral("yes") : QStringLiteral("no"); } +QString ageText(qint64 ageMs) +{ + return ageMs >= 0 ? QStringLiteral("%1ms").arg(ageMs) : QStringLiteral("unknown"); +} + QString valueOrUnavailable(QString value) { value = value.trimmed(); @@ -217,6 +222,31 @@ QString formatOpenFailure(const OpenFailureSummary& summary) return lines.join(QLatin1Char('\n')); } +QString formatTxCaptureHealth(const TxCaptureHealthSummary& summary) +{ + QStringList lines; + lines << QStringLiteral("Audio TX capture health summary:") + << QStringLiteral(" reason=\"%1\" %2 state=%3 error=%4 lifetime=%5ms") + .arg(valueOrUnknown(summary.reason), + field(QStringLiteral("device"), summary.deviceDescription), + valueOrUnknown(summary.state), + valueOrUnknown(summary.error)) + .arg(summary.lifecycleMs) + << QStringLiteral(" buffered=%1/%2B peakSuppressed=%3B lastMicReadAge=%4") + .arg(summary.bufferedBytes) + .arg(summary.bufferCapacityBytes) + .arg(summary.suppressedBufferPeakBytes) + .arg(ageText(summary.lastMicReadAgeMs)) + << QStringLiteral(" suppressedCallbacks=%1 idleDuringTci=%2 postTciLocalTxWhileIdle=%3") + .arg(summary.tciSuppressedCallbacks) + .arg(summary.idleDuringTciTransitions) + .arg(summary.postTciLocalTxWhileIdle) + << QStringLiteral(" sourceWasActive=%1 saturationObserved=%2") + .arg(yesNo(summary.sourceWasActive), + yesNo(summary.saturationObserved)); + return lines.join(QLatin1Char('\n')); +} + void logStartupEnvironment(const QJsonObject& audioDevices) { emitIfChanged(QStringLiteral("startup"), formatStartupEnvironment(audioDevices)); @@ -250,4 +280,14 @@ void logOpenFailure(const OpenFailureSummary& summary) formatOpenFailure(summary)); } +void logTxCaptureHealth(const TxCaptureHealthSummary& summary, bool anomaly) +{ + const QString text = formatTxCaptureHealth(summary); + if (anomaly) { + qCWarning(lcAudioSummary).noquote() << text; + return; + } + qCInfo(lcAudioSummary).noquote() << text; +} + } // namespace AetherSDR::AudioSummaryLogger diff --git a/src/core/AudioSummaryLogger.h b/src/core/AudioSummaryLogger.h index 4055b2c48..78567dee6 100644 --- a/src/core/AudioSummaryLogger.h +++ b/src/core/AudioSummaryLogger.h @@ -54,6 +54,23 @@ struct OpenFailureSummary { QString fallbackReason; }; +struct TxCaptureHealthSummary { + QString reason; + QString deviceDescription; + QString state; + QString error; + qint64 lifecycleMs{0}; + qint64 bufferedBytes{0}; + qint64 bufferCapacityBytes{0}; + qint64 lastMicReadAgeMs{-1}; + quint64 tciSuppressedCallbacks{0}; + qint64 suppressedBufferPeakBytes{0}; + quint64 idleDuringTciTransitions{0}; + quint64 postTciLocalTxWhileIdle{0}; + bool sourceWasActive{false}; + bool saturationObserved{false}; +}; + QString sampleFormatName(QAudioFormat::SampleFormat format); QString formatStartupEnvironment(const QJsonObject& audioDevices); QString formatRxSink(const RxSinkSummary& summary); @@ -61,6 +78,7 @@ QString formatTxSource(const TxSourceSummary& summary); QString formatCwSidetone(const CwSidetoneSummary& summary); QString formatAuxiliarySink(const AuxiliarySinkSummary& summary); QString formatOpenFailure(const OpenFailureSummary& summary); +QString formatTxCaptureHealth(const TxCaptureHealthSummary& summary); void logStartupEnvironment(const QJsonObject& audioDevices); void logRxSink(const RxSinkSummary& summary); @@ -68,5 +86,6 @@ void logTxSource(const TxSourceSummary& summary); void logCwSidetone(const CwSidetoneSummary& summary); void logAuxiliarySink(const AuxiliarySinkSummary& summary); void logOpenFailure(const OpenFailureSummary& summary); +void logTxCaptureHealth(const TxCaptureHealthSummary& summary, bool anomaly); } // namespace AetherSDR::AudioSummaryLogger diff --git a/src/core/LogManager.cpp b/src/core/LogManager.cpp index 433399ce9..cb7448edf 100644 --- a/src/core/LogManager.cpp +++ b/src/core/LogManager.cpp @@ -52,7 +52,7 @@ LogManager::LogManager() {"aether.connection", "Connection / Commands", "Raw TCP command channel lines: TX commands, RX responses, and socket state"}, {"aether.protocol", "Protocol / Status", "Parsed SmartSDR protocol handling and model status updates"}, {"aether.audio", "Audio", "RX/TX audio, device negotiation, volume"}, - {"aether.audio.summary", "Audio Summary", "Default support log summaries for audio routing and sink/source negotiation"}, + {"aether.audio.summary", "Audio Summary", "Default support log summaries for audio routing, negotiation, and TX capture health"}, {"aether.vita49", "VITA-49", "UDP packet routing: FFT, waterfall, meters, DAX"}, {"aether.dsp", "DSP", "NR2, RN2, CW decoder processing"}, {"aether.rade", "RADE", "FreeDV Radio Autoencoder digital voice"}, diff --git a/src/core/TxCaptureHealthTracker.h b/src/core/TxCaptureHealthTracker.h new file mode 100644 index 000000000..3e184b033 --- /dev/null +++ b/src/core/TxCaptureHealthTracker.h @@ -0,0 +1,147 @@ +#pragma once + +#include + +#include + +namespace AetherSDR { + +// Hardware-free state machine for TX capture observability. QAudioSource starts +// in Idle on some backends, so Idle alone is not a fault: the strong signature +// is Active -> Idle after TCI callbacks were suppressed while unread bytes +// remain. Keeping this policy separate makes the field diagnostic testable +// without an audio device or PipeWire server. +class TxCaptureHealthTracker { +public: + enum class CaptureState { + Stopped, + Active, + Idle, + Suspended, + }; + + enum class Event { + None, + BufferSaturatedDuringTci, + LocalTxWhileSaturated, + }; + + struct Snapshot { + qint64 lifecycleMs{0}; + qint64 lastMicReadAgeMs{-1}; + quint64 tciSuppressedCallbacks{0}; + qint64 suppressedBufferPeakBytes{0}; + quint64 idleDuringTciTransitions{0}; + quint64 postTciLocalTxWhileIdle{0}; + bool sourceWasActive{false}; + bool saturationObserved{false}; + }; + + void reset(CaptureState initialState, qint64 nowMs = 0) + { + m_startedAtMs = nowMs; + m_lastMicReadMs = -1; + m_lastState = initialState; + m_sourceWasActive = initialState == CaptureState::Active; + m_saturationReported = false; + m_stalledLocalTxReported = false; + m_tciSuppressedCallbacks = 0; + m_suppressedBufferPeakBytes = 0; + m_idleDuringTciTransitions = 0; + m_postTciLocalTxWhileIdle = 0; + } + + void recordSuppressedCallback(qint64 bufferedBytes) + { + ++m_tciSuppressedCallbacks; + m_suppressedBufferPeakBytes = std::max(m_suppressedBufferPeakBytes, + std::max(0, bufferedBytes)); + } + + void recordMicRead(qint64 nowMs) + { + m_lastMicReadMs = nowMs; + } + + Event observeState(CaptureState state, bool tciAudioFresh, qint64 bufferedBytes) + { + const bool changed = state != m_lastState; + m_lastState = state; + + if (state == CaptureState::Active) { + m_sourceWasActive = true; + } + + const bool saturatedDuringTci = changed + && state == CaptureState::Idle + && m_sourceWasActive + && tciAudioFresh + && m_tciSuppressedCallbacks > 0 + && bufferedBytes > 0; + if (!saturatedDuringTci) { + return Event::None; + } + + ++m_idleDuringTciTransitions; + if (m_saturationReported) { + return Event::None; + } + m_saturationReported = true; + return Event::BufferSaturatedDuringTci; + } + + Event recordLocalTxAttempt(CaptureState state, + bool localTxOwned, + bool daxTxMode, + bool tciAudioFresh, + qint64 bufferedBytes) + { + const bool stalled = localTxOwned + && !daxTxMode + && !tciAudioFresh + && state == CaptureState::Idle + && m_sourceWasActive + && m_tciSuppressedCallbacks > 0 + && bufferedBytes > 0; + if (!stalled) { + return Event::None; + } + + ++m_postTciLocalTxWhileIdle; + if (m_stalledLocalTxReported) { + return Event::None; + } + m_stalledLocalTxReported = true; + return Event::LocalTxWhileSaturated; + } + + Snapshot snapshot(qint64 nowMs) const + { + Snapshot out; + out.lifecycleMs = std::max(0, nowMs - m_startedAtMs); + out.lastMicReadAgeMs = m_lastMicReadMs >= 0 + ? std::max(0, nowMs - m_lastMicReadMs) + : -1; + out.tciSuppressedCallbacks = m_tciSuppressedCallbacks; + out.suppressedBufferPeakBytes = m_suppressedBufferPeakBytes; + out.idleDuringTciTransitions = m_idleDuringTciTransitions; + out.postTciLocalTxWhileIdle = m_postTciLocalTxWhileIdle; + out.sourceWasActive = m_sourceWasActive; + out.saturationObserved = m_saturationReported; + return out; + } + +private: + qint64 m_startedAtMs{0}; + qint64 m_lastMicReadMs{-1}; + CaptureState m_lastState{CaptureState::Stopped}; + bool m_sourceWasActive{false}; + bool m_saturationReported{false}; + bool m_stalledLocalTxReported{false}; + quint64 m_tciSuppressedCallbacks{0}; + qint64 m_suppressedBufferPeakBytes{0}; + quint64 m_idleDuringTciTransitions{0}; + quint64 m_postTciLocalTxWhileIdle{0}; +}; + +} // namespace AetherSDR diff --git a/tests/tx_capture_health_test.cpp b/tests/tx_capture_health_test.cpp new file mode 100644 index 000000000..64dc7cc18 --- /dev/null +++ b/tests/tx_capture_health_test.cpp @@ -0,0 +1,83 @@ +// Hardware-free regression test for default-on TX capture health summaries. +// It pins the strong PipeWire/Qt pull-mode signature without requiring an +// audio device: initial Idle is healthy; Active -> Idle after suppressed TCI +// callbacks with unread bytes is saturation; later local TX attempts are +// counted and each anomaly class is reported only once per source lifecycle. + +#include "core/TxCaptureHealthTracker.h" + +#include + +using AetherSDR::TxCaptureHealthTracker; + +namespace { + +int g_failed = 0; +int g_total = 0; + +void expect(bool condition, const char* label) +{ + ++g_total; + std::printf("%s %s\n", condition ? "[ OK ]" : "[FAIL]", label); + if (!condition) { + ++g_failed; + } +} + +} // namespace + +int main() +{ + using CaptureState = TxCaptureHealthTracker::CaptureState; + using Event = TxCaptureHealthTracker::Event; + + TxCaptureHealthTracker tracker; + tracker.reset(CaptureState::Idle, 0); + + expect(tracker.observeState(CaptureState::Idle, true, 4096) == Event::None, + "initial QAudioSource Idle is not a saturation event"); + + expect(tracker.observeState(CaptureState::Active, false, 0) == Event::None, + "Active marks the source as having delivered capture data"); + tracker.recordMicRead(25); + + tracker.recordSuppressedCallback(4096); + tracker.recordSuppressedCallback(8192); + expect(tracker.observeState(CaptureState::Idle, true, 8192) + == Event::BufferSaturatedDuringTci, + "Active to Idle with suppressed callbacks and unread bytes reports saturation"); + expect(tracker.observeState(CaptureState::Idle, true, 8192) == Event::None, + "unchanged Idle state does not repeat the saturation record"); + + expect(tracker.recordLocalTxAttempt(CaptureState::Idle, true, false, true, 8192) + == Event::None, + "local TX while TCI audio is fresh is not classified as post-TCI"); + expect(tracker.recordLocalTxAttempt(CaptureState::Idle, true, false, false, 8192) + == Event::LocalTxWhileSaturated, + "first post-TCI local TX against saturated capture emits an anomaly"); + expect(tracker.recordLocalTxAttempt(CaptureState::Idle, true, false, false, 8192) + == Event::None, + "later stalled local TX attempts are counted without log spam"); + expect(tracker.recordLocalTxAttempt(CaptureState::Idle, false, false, false, 8192) + == Event::None, + "another client's TX is not attributed to the local capture source"); + expect(tracker.recordLocalTxAttempt(CaptureState::Idle, true, true, false, 8192) + == Event::None, + "local DAX TX is not classified as stalled microphone capture"); + + const TxCaptureHealthTracker::Snapshot snapshot = tracker.snapshot(1000); + expect(snapshot.sourceWasActive && snapshot.saturationObserved, + "summary preserves the strong saturation signature"); + expect(snapshot.tciSuppressedCallbacks == 2 + && snapshot.suppressedBufferPeakBytes == 8192, + "summary reports suppressed callback count and peak unread bytes"); + expect(snapshot.idleDuringTciTransitions == 1, + "summary counts one Active-to-Idle transition during TCI"); + expect(snapshot.postTciLocalTxWhileIdle == 2, + "summary counts every post-TCI stalled local TX attempt"); + expect(snapshot.lastMicReadAgeMs == 975, + "summary reports age of the last successful microphone read"); + + std::printf("\n%d of %d TX capture health tests failed.\n", g_failed, g_total); + return g_failed == 0 ? 0 : 1; +} From f389892a111afa2e3bbe85b08dc98d796e8736b2 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Tue, 14 Jul 2026 20:31:57 -0700 Subject: [PATCH 2/3] Detect full active TX capture buffers. Principle VIII. --- docs/automation-bridge.md | 13 ++++---- src/core/AudioEngine.cpp | 21 ++++++++---- src/core/AudioSummaryLogger.cpp | 6 ++-- src/core/AudioSummaryLogger.h | 3 +- src/core/TxCaptureHealthTracker.h | 54 +++++++++++++++++++++++-------- tests/tx_capture_health_test.cpp | 49 +++++++++++++++++----------- 6 files changed, 99 insertions(+), 47 deletions(-) diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index 2eac8215f..f0bedc6c3 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -605,12 +605,13 @@ was active. The TX input endpoint also exposes default-on capture-health evidence for TCI handoffs: `buffer_bytes_available`, `buffer_capacity_bytes`, `source_was_active`, `saturation_observed`, `tci_suppressed_callbacks`, -`idle_during_tci_transitions`, `post_tci_local_tx_while_idle`, and -`last_mic_read_age_ms`. `saturation_observed` is deliberately conservative: it -requires a source that was previously Active to become Idle while TCI is fresh, -after at least one microphone callback was suppressed, with unread bytes still -buffered. The same evidence is summarized in the default Audio Summary support -log without enabling verbose audio logging. +`full_buffer_during_tci_observations`, `idle_during_tci_transitions`, +`post_tci_local_tx_while_saturated`, and `last_mic_read_age_ms`. +`saturation_observed` is set when the capture buffer reaches its reported +capacity during TCI suppression. An Active-to-Idle transition with suppressed +callbacks and unread bytes remains a fallback for backends that do not expose a +useful capacity. The same evidence is summarized in the default Audio Summary +support log without enabling verbose audio logging. ### `get cwx` CWX keyer state, including the **queue-drain watch** that the #3949 fix relies diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 20a343122..391604615 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -2532,9 +2532,11 @@ QJsonArray AudioEngine::audioEndpointDiagnostics() const tx["source_was_active"] = txHealth.sourceWasActive; tx["saturation_observed"] = txHealth.saturationObserved; tx["tci_suppressed_callbacks"] = static_cast(txHealth.tciSuppressedCallbacks); + tx["full_buffer_during_tci_observations"] = + static_cast(txHealth.fullBufferDuringTciObservations); tx["idle_during_tci_transitions"] = static_cast(txHealth.idleDuringTciTransitions); - tx["post_tci_local_tx_while_idle"] = - static_cast(txHealth.postTciLocalTxWhileIdle); + tx["post_tci_local_tx_while_saturated"] = + static_cast(txHealth.postTciLocalTxWhileSaturated); tx["last_mic_read_age_ms"] = txHealth.lastMicReadAgeMs >= 0 ? QJsonValue(static_cast(txHealth.lastMicReadAgeMs)) : QJsonValue(); @@ -7013,7 +7015,8 @@ void AudioEngine::recordTxCaptureLocalTxAttempt() m_transmitting.load(std::memory_order_acquire), m_daxTxMode.load(std::memory_order_acquire), tciAudioFresh(), - txCaptureBufferedBytes()); + txCaptureBufferedBytes(), + txCaptureBufferCapacityBytes()); if (event != TxCaptureHealthTracker::Event::None) { logTxCaptureHealthEvent(event); } @@ -7038,8 +7041,9 @@ void AudioEngine::logTxCaptureHealthSummary(const QString& reason, bool anomaly) const TxCaptureHealthTracker::Snapshot health = m_txCaptureHealth.snapshot(txCaptureNowMs()); if (!anomaly && health.tciSuppressedCallbacks == 0 + && health.fullBufferDuringTciObservations == 0 && health.idleDuringTciTransitions == 0 - && health.postTciLocalTxWhileIdle == 0) { + && health.postTciLocalTxWhileSaturated == 0) { return; } @@ -7062,8 +7066,9 @@ void AudioEngine::logTxCaptureHealthSummary(const QString& reason, bool anomaly) summary.lastMicReadAgeMs = health.lastMicReadAgeMs; summary.tciSuppressedCallbacks = health.tciSuppressedCallbacks; summary.suppressedBufferPeakBytes = health.suppressedBufferPeakBytes; + summary.fullBufferDuringTciObservations = health.fullBufferDuringTciObservations; summary.idleDuringTciTransitions = health.idleDuringTciTransitions; - summary.postTciLocalTxWhileIdle = health.postTciLocalTxWhileIdle; + summary.postTciLocalTxWhileSaturated = health.postTciLocalTxWhileSaturated; summary.sourceWasActive = health.sourceWasActive; summary.saturationObserved = health.saturationObserved; AudioSummaryLogger::logTxCaptureHealth(summary, anomaly); @@ -7568,7 +7573,11 @@ void AudioEngine::onTxAudioReady() // produces continuous ambient packets. The 200 ms window comfortably // covers the 50 ms TCI frame cadence. if (tciAudioFresh()) { - m_txCaptureHealth.recordSuppressedCallback(txCaptureBufferedBytes()); + const TxCaptureHealthTracker::Event event = m_txCaptureHealth.recordSuppressedCallback( + txCaptureBufferedBytes(), txCaptureBufferCapacityBytes()); + if (event != TxCaptureHealthTracker::Event::None) { + logTxCaptureHealthEvent(event); + } if (m_audioSource) { observeTxCaptureState(m_audioSource->state()); } diff --git a/src/core/AudioSummaryLogger.cpp b/src/core/AudioSummaryLogger.cpp index b5d0fe31c..e0772d1dc 100644 --- a/src/core/AudioSummaryLogger.cpp +++ b/src/core/AudioSummaryLogger.cpp @@ -237,10 +237,12 @@ QString formatTxCaptureHealth(const TxCaptureHealthSummary& summary) .arg(summary.bufferCapacityBytes) .arg(summary.suppressedBufferPeakBytes) .arg(ageText(summary.lastMicReadAgeMs)) - << QStringLiteral(" suppressedCallbacks=%1 idleDuringTci=%2 postTciLocalTxWhileIdle=%3") + << QStringLiteral(" suppressedCallbacks=%1 fullDuringTci=%2 idleDuringTci=%3") .arg(summary.tciSuppressedCallbacks) + .arg(summary.fullBufferDuringTciObservations) .arg(summary.idleDuringTciTransitions) - .arg(summary.postTciLocalTxWhileIdle) + << QStringLiteral(" postTciLocalTxWhileSaturated=%1") + .arg(summary.postTciLocalTxWhileSaturated) << QStringLiteral(" sourceWasActive=%1 saturationObserved=%2") .arg(yesNo(summary.sourceWasActive), yesNo(summary.saturationObserved)); diff --git a/src/core/AudioSummaryLogger.h b/src/core/AudioSummaryLogger.h index 78567dee6..c07d99759 100644 --- a/src/core/AudioSummaryLogger.h +++ b/src/core/AudioSummaryLogger.h @@ -65,8 +65,9 @@ struct TxCaptureHealthSummary { qint64 lastMicReadAgeMs{-1}; quint64 tciSuppressedCallbacks{0}; qint64 suppressedBufferPeakBytes{0}; + quint64 fullBufferDuringTciObservations{0}; quint64 idleDuringTciTransitions{0}; - quint64 postTciLocalTxWhileIdle{0}; + quint64 postTciLocalTxWhileSaturated{0}; bool sourceWasActive{false}; bool saturationObserved{false}; }; diff --git a/src/core/TxCaptureHealthTracker.h b/src/core/TxCaptureHealthTracker.h index 3e184b033..6a9b1d195 100644 --- a/src/core/TxCaptureHealthTracker.h +++ b/src/core/TxCaptureHealthTracker.h @@ -7,10 +7,11 @@ namespace AetherSDR { // Hardware-free state machine for TX capture observability. QAudioSource starts -// in Idle on some backends, so Idle alone is not a fault: the strong signature -// is Active -> Idle after TCI callbacks were suppressed while unread bytes -// remain. Keeping this policy separate makes the field diagnostic testable -// without an audio device or PipeWire server. +// in Idle on some backends, so Idle alone is not a fault. PipeWire can also keep +// reporting Active after its pull device fills, so capacity exhaustion is the +// primary signature; Active -> Idle with unread bytes remains a fallback when a +// backend does not report a useful capacity. Keeping this policy separate makes +// the field diagnostic testable without an audio device or PipeWire server. class TxCaptureHealthTracker { public: enum class CaptureState { @@ -31,8 +32,9 @@ class TxCaptureHealthTracker { qint64 lastMicReadAgeMs{-1}; quint64 tciSuppressedCallbacks{0}; qint64 suppressedBufferPeakBytes{0}; + quint64 fullBufferDuringTciObservations{0}; quint64 idleDuringTciTransitions{0}; - quint64 postTciLocalTxWhileIdle{0}; + quint64 postTciLocalTxWhileSaturated{0}; bool sourceWasActive{false}; bool saturationObserved{false}; }; @@ -47,15 +49,27 @@ class TxCaptureHealthTracker { m_stalledLocalTxReported = false; m_tciSuppressedCallbacks = 0; m_suppressedBufferPeakBytes = 0; + m_fullBufferDuringTciObservations = 0; m_idleDuringTciTransitions = 0; - m_postTciLocalTxWhileIdle = 0; + m_postTciLocalTxWhileSaturated = 0; } - void recordSuppressedCallback(qint64 bufferedBytes) + Event recordSuppressedCallback(qint64 bufferedBytes, qint64 bufferCapacityBytes) { ++m_tciSuppressedCallbacks; m_suppressedBufferPeakBytes = std::max(m_suppressedBufferPeakBytes, std::max(0, bufferedBytes)); + + if (!bufferIsFull(bufferedBytes, bufferCapacityBytes)) { + return Event::None; + } + + ++m_fullBufferDuringTciObservations; + if (m_saturationReported) { + return Event::None; + } + m_saturationReported = true; + return Event::BufferSaturatedDuringTci; } void recordMicRead(qint64 nowMs) @@ -94,20 +108,27 @@ class TxCaptureHealthTracker { bool localTxOwned, bool daxTxMode, bool tciAudioFresh, - qint64 bufferedBytes) + qint64 bufferedBytes, + qint64 bufferCapacityBytes) { + const bool sourceRunning = state == CaptureState::Active + || state == CaptureState::Idle; + const bool saturated = m_saturationReported + || (m_tciSuppressedCallbacks > 0 + && bufferIsFull(bufferedBytes, bufferCapacityBytes)); const bool stalled = localTxOwned && !daxTxMode && !tciAudioFresh - && state == CaptureState::Idle + && sourceRunning && m_sourceWasActive - && m_tciSuppressedCallbacks > 0 + && saturated && bufferedBytes > 0; if (!stalled) { return Event::None; } - ++m_postTciLocalTxWhileIdle; + m_saturationReported = true; + ++m_postTciLocalTxWhileSaturated; if (m_stalledLocalTxReported) { return Event::None; } @@ -124,14 +145,20 @@ class TxCaptureHealthTracker { : -1; out.tciSuppressedCallbacks = m_tciSuppressedCallbacks; out.suppressedBufferPeakBytes = m_suppressedBufferPeakBytes; + out.fullBufferDuringTciObservations = m_fullBufferDuringTciObservations; out.idleDuringTciTransitions = m_idleDuringTciTransitions; - out.postTciLocalTxWhileIdle = m_postTciLocalTxWhileIdle; + out.postTciLocalTxWhileSaturated = m_postTciLocalTxWhileSaturated; out.sourceWasActive = m_sourceWasActive; out.saturationObserved = m_saturationReported; return out; } private: + static bool bufferIsFull(qint64 bufferedBytes, qint64 bufferCapacityBytes) + { + return bufferCapacityBytes > 0 && bufferedBytes >= bufferCapacityBytes; + } + qint64 m_startedAtMs{0}; qint64 m_lastMicReadMs{-1}; CaptureState m_lastState{CaptureState::Stopped}; @@ -140,8 +167,9 @@ class TxCaptureHealthTracker { bool m_stalledLocalTxReported{false}; quint64 m_tciSuppressedCallbacks{0}; qint64 m_suppressedBufferPeakBytes{0}; + quint64 m_fullBufferDuringTciObservations{0}; quint64 m_idleDuringTciTransitions{0}; - quint64 m_postTciLocalTxWhileIdle{0}; + quint64 m_postTciLocalTxWhileSaturated{0}; }; } // namespace AetherSDR diff --git a/tests/tx_capture_health_test.cpp b/tests/tx_capture_health_test.cpp index 64dc7cc18..3cdabec56 100644 --- a/tests/tx_capture_health_test.cpp +++ b/tests/tx_capture_health_test.cpp @@ -1,8 +1,9 @@ // Hardware-free regression test for default-on TX capture health summaries. // It pins the strong PipeWire/Qt pull-mode signature without requiring an -// audio device: initial Idle is healthy; Active -> Idle after suppressed TCI -// callbacks with unread bytes is saturation; later local TX attempts are -// counted and each anomaly class is reported only once per source lifecycle. +// audio device: initial Idle is healthy; a full buffer is saturation even when +// PipeWire keeps reporting Active; Active -> Idle with unread bytes remains a +// fallback; later local TX attempts are counted and each anomaly class is +// reported only once per source lifecycle. #include "core/TxCaptureHealthTracker.h" @@ -41,43 +42,53 @@ int main() "Active marks the source as having delivered capture data"); tracker.recordMicRead(25); - tracker.recordSuppressedCallback(4096); - tracker.recordSuppressedCallback(8192); - expect(tracker.observeState(CaptureState::Idle, true, 8192) + expect(tracker.recordSuppressedCallback(4096, 8192) == Event::None, + "partially filled Active capture remains healthy"); + expect(tracker.recordSuppressedCallback(8192, 8192) == Event::BufferSaturatedDuringTci, - "Active to Idle with suppressed callbacks and unread bytes reports saturation"); - expect(tracker.observeState(CaptureState::Idle, true, 8192) == Event::None, - "unchanged Idle state does not repeat the saturation record"); + "full buffer reports saturation while capture remains Active"); + expect(tracker.recordSuppressedCallback(8192, 8192) == Event::None, + "repeated full callbacks do not repeat the saturation record"); - expect(tracker.recordLocalTxAttempt(CaptureState::Idle, true, false, true, 8192) + expect(tracker.recordLocalTxAttempt(CaptureState::Active, true, false, true, 8192, 8192) == Event::None, "local TX while TCI audio is fresh is not classified as post-TCI"); - expect(tracker.recordLocalTxAttempt(CaptureState::Idle, true, false, false, 8192) + expect(tracker.recordLocalTxAttempt(CaptureState::Active, true, false, false, 8192, 8192) == Event::LocalTxWhileSaturated, - "first post-TCI local TX against saturated capture emits an anomaly"); - expect(tracker.recordLocalTxAttempt(CaptureState::Idle, true, false, false, 8192) + "first post-TCI local TX against full Active capture emits an anomaly"); + expect(tracker.recordLocalTxAttempt(CaptureState::Active, true, false, false, 8192, 8192) == Event::None, "later stalled local TX attempts are counted without log spam"); - expect(tracker.recordLocalTxAttempt(CaptureState::Idle, false, false, false, 8192) + expect(tracker.recordLocalTxAttempt(CaptureState::Active, false, false, false, 8192, 8192) == Event::None, "another client's TX is not attributed to the local capture source"); - expect(tracker.recordLocalTxAttempt(CaptureState::Idle, true, true, false, 8192) + expect(tracker.recordLocalTxAttempt(CaptureState::Active, true, true, false, 8192, 8192) == Event::None, "local DAX TX is not classified as stalled microphone capture"); const TxCaptureHealthTracker::Snapshot snapshot = tracker.snapshot(1000); expect(snapshot.sourceWasActive && snapshot.saturationObserved, "summary preserves the strong saturation signature"); - expect(snapshot.tciSuppressedCallbacks == 2 + expect(snapshot.tciSuppressedCallbacks == 3 && snapshot.suppressedBufferPeakBytes == 8192, "summary reports suppressed callback count and peak unread bytes"); - expect(snapshot.idleDuringTciTransitions == 1, - "summary counts one Active-to-Idle transition during TCI"); - expect(snapshot.postTciLocalTxWhileIdle == 2, + expect(snapshot.fullBufferDuringTciObservations == 2, + "summary counts direct full-buffer observations during TCI"); + expect(snapshot.idleDuringTciTransitions == 0, + "Active PipeWire saturation does not require an Idle transition"); + expect(snapshot.postTciLocalTxWhileSaturated == 2, "summary counts every post-TCI stalled local TX attempt"); expect(snapshot.lastMicReadAgeMs == 975, "summary reports age of the last successful microphone read"); + TxCaptureHealthTracker idleFallback; + idleFallback.reset(CaptureState::Active, 0); + expect(idleFallback.recordSuppressedCallback(4096, 0) == Event::None, + "unknown capacity does not invent a full-buffer event"); + expect(idleFallback.observeState(CaptureState::Idle, true, 4096) + == Event::BufferSaturatedDuringTci, + "Active-to-Idle with unread bytes remains a saturation fallback"); + std::printf("\n%d of %d TX capture health tests failed.\n", g_failed, g_total); return g_failed == 0 ? 0 : 1; } From c7227734c7e2dcc930947dbdb415e777ed50f713 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Sat, 18 Jul 2026 09:29:28 -0700 Subject: [PATCH 3/3] Harden opt-in TX capture diagnostics. Principle VIII. --- CMakeLists.txt | 2 +- docs/automation-bridge.md | 7 ++++--- src/core/AudioEngine.cpp | 23 +++++++++++++++++++++++ src/core/LogManager.cpp | 2 +- src/core/TxCaptureHealthTracker.h | 11 ++++++++++- tests/tx_capture_health_test.cpp | 17 ++++++++++++++++- 6 files changed, 55 insertions(+), 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c509dce4..0bd5743b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2427,7 +2427,7 @@ target_include_directories(audio_output_router_test PRIVATE src) target_link_libraries(audio_output_router_test PRIVATE Qt6::Core Qt6::Multimedia) add_test(NAME audio_output_router_test COMMAND audio_output_router_test) -# Hardware-free state-machine coverage for the default-on TX capture health +# Hardware-free state-machine coverage for the opt-in TX capture health # summary. Reproduces the Qt pull-mode Active -> Idle/full-buffer signature and # verifies anomaly rate limiting without requiring PipeWire or an audio device. add_executable(tx_capture_health_test diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index f0bedc6c3..5566a8961 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -602,7 +602,7 @@ chunks dispatched to Receive Presentation Sync analysis, while that were captured for automation but skipped because no KiwiSDR audio source was active. -The TX input endpoint also exposes default-on capture-health evidence for TCI +The TX input endpoint also exposes in-memory capture-health evidence for TCI handoffs: `buffer_bytes_available`, `buffer_capacity_bytes`, `source_was_active`, `saturation_observed`, `tci_suppressed_callbacks`, `full_buffer_during_tci_observations`, `idle_during_tci_transitions`, @@ -610,8 +610,9 @@ handoffs: `buffer_bytes_available`, `buffer_capacity_bytes`, `saturation_observed` is set when the capture buffer reaches its reported capacity during TCI suppression. An Active-to-Idle transition with suppressed callbacks and unread bytes remains a fallback for backends that do not expose a -useful capacity. The same evidence is summarized in the default Audio Summary -support log without enabling verbose audio logging. +useful capacity. The same evidence is written to the Audio Summary support log +only when Help → Support's **CAT/rigctld** logging toggle (the category used by +TCI debug logging) is enabled; TX capture-health summaries are off by default. ### `get cwx` CWX keyer state, including the **queue-drain watch** that the #3949 fix relies diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 391604615..596522081 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -2441,6 +2441,22 @@ QAudioFormat AudioEngine::makeFormat() const QJsonArray AudioEngine::audioEndpointDiagnostics() const { + QThread* const ownerThread = thread(); + if (ownerThread && ownerThread != QThread::currentThread()) { + if (!ownerThread->isRunning()) { + return {}; + } + + QJsonArray endpoints; + const bool invoked = QMetaObject::invokeMethod( + const_cast(this), + [this, &endpoints]() { + endpoints = audioEndpointDiagnostics(); + }, + Qt::BlockingQueuedConnection); + return invoked ? endpoints : QJsonArray{}; + } + const auto outputDescription = [this]() { const QAudioDevice dev = m_outputDevice.isNull() ? QMediaDevices::defaultAudioOutput() @@ -7038,6 +7054,13 @@ void AudioEngine::logTxCaptureHealthEvent(TxCaptureHealthTracker::Event event) void AudioEngine::logTxCaptureHealthSummary(const QString& reason, bool anomaly) { + // TCI server diagnostics use lcCat today. Keep these support summaries + // opt-in with the same Help -> Support debug toggle; warnings must not make + // the capture-health instrumentation default-on by bypassing that choice. + if (!lcCat().isDebugEnabled()) { + return; + } + const TxCaptureHealthTracker::Snapshot health = m_txCaptureHealth.snapshot(txCaptureNowMs()); if (!anomaly && health.tciSuppressedCallbacks == 0 diff --git a/src/core/LogManager.cpp b/src/core/LogManager.cpp index cb7448edf..433399ce9 100644 --- a/src/core/LogManager.cpp +++ b/src/core/LogManager.cpp @@ -52,7 +52,7 @@ LogManager::LogManager() {"aether.connection", "Connection / Commands", "Raw TCP command channel lines: TX commands, RX responses, and socket state"}, {"aether.protocol", "Protocol / Status", "Parsed SmartSDR protocol handling and model status updates"}, {"aether.audio", "Audio", "RX/TX audio, device negotiation, volume"}, - {"aether.audio.summary", "Audio Summary", "Default support log summaries for audio routing, negotiation, and TX capture health"}, + {"aether.audio.summary", "Audio Summary", "Default support log summaries for audio routing and sink/source negotiation"}, {"aether.vita49", "VITA-49", "UDP packet routing: FFT, waterfall, meters, DAX"}, {"aether.dsp", "DSP", "NR2, RN2, CW decoder processing"}, {"aether.rade", "RADE", "FreeDV Radio Autoencoder digital voice"}, diff --git a/src/core/TxCaptureHealthTracker.h b/src/core/TxCaptureHealthTracker.h index 6a9b1d195..ddd32e92c 100644 --- a/src/core/TxCaptureHealthTracker.h +++ b/src/core/TxCaptureHealthTracker.h @@ -45,6 +45,7 @@ class TxCaptureHealthTracker { m_lastMicReadMs = -1; m_lastState = initialState; m_sourceWasActive = initialState == CaptureState::Active; + m_currentlySaturated = false; m_saturationReported = false; m_stalledLocalTxReported = false; m_tciSuppressedCallbacks = 0; @@ -65,6 +66,7 @@ class TxCaptureHealthTracker { } ++m_fullBufferDuringTciObservations; + m_currentlySaturated = true; if (m_saturationReported) { return Event::None; } @@ -75,6 +77,10 @@ class TxCaptureHealthTracker { void recordMicRead(qint64 nowMs) { m_lastMicReadMs = nowMs; + // A successful consume proves the pull device is making progress again. + // Preserve the lifecycle-level observation/rate limit, but do not use a + // historical saturation to classify later healthy TX attempts. + m_currentlySaturated = false; } Event observeState(CaptureState state, bool tciAudioFresh, qint64 bufferedBytes) @@ -97,6 +103,7 @@ class TxCaptureHealthTracker { } ++m_idleDuringTciTransitions; + m_currentlySaturated = true; if (m_saturationReported) { return Event::None; } @@ -113,7 +120,7 @@ class TxCaptureHealthTracker { { const bool sourceRunning = state == CaptureState::Active || state == CaptureState::Idle; - const bool saturated = m_saturationReported + const bool saturated = m_currentlySaturated || (m_tciSuppressedCallbacks > 0 && bufferIsFull(bufferedBytes, bufferCapacityBytes)); const bool stalled = localTxOwned @@ -127,6 +134,7 @@ class TxCaptureHealthTracker { return Event::None; } + m_currentlySaturated = true; m_saturationReported = true; ++m_postTciLocalTxWhileSaturated; if (m_stalledLocalTxReported) { @@ -163,6 +171,7 @@ class TxCaptureHealthTracker { qint64 m_lastMicReadMs{-1}; CaptureState m_lastState{CaptureState::Stopped}; bool m_sourceWasActive{false}; + bool m_currentlySaturated{false}; bool m_saturationReported{false}; bool m_stalledLocalTxReported{false}; quint64 m_tciSuppressedCallbacks{0}; diff --git a/tests/tx_capture_health_test.cpp b/tests/tx_capture_health_test.cpp index 3cdabec56..c59a707f9 100644 --- a/tests/tx_capture_health_test.cpp +++ b/tests/tx_capture_health_test.cpp @@ -1,4 +1,4 @@ -// Hardware-free regression test for default-on TX capture health summaries. +// Hardware-free regression test for opt-in TX capture health summaries. // It pins the strong PipeWire/Qt pull-mode signature without requiring an // audio device: initial Idle is healthy; a full buffer is saturation even when // PipeWire keeps reporting Active; Active -> Idle with unread bytes remains a @@ -81,6 +81,21 @@ int main() expect(snapshot.lastMicReadAgeMs == 975, "summary reports age of the last successful microphone read"); + tracker.recordMicRead(1100); + expect(tracker.recordLocalTxAttempt(CaptureState::Active, true, false, false, 1024, 8192) + == Event::None, + "successful microphone consumption clears current saturation"); + const TxCaptureHealthTracker::Snapshot recovered = tracker.snapshot(1200); + expect(recovered.saturationObserved + && recovered.postTciLocalTxWhileSaturated == 2, + "recovery preserves history without inflating stalled TX counts"); + + expect(tracker.recordLocalTxAttempt(CaptureState::Active, true, false, false, 8192, 8192) + == Event::None, + "a later real saturation is counted but remains rate-limited"); + expect(tracker.snapshot(1300).postTciLocalTxWhileSaturated == 3, + "post-recovery count advances only for a newly full buffer"); + TxCaptureHealthTracker idleFallback; idleFallback.reset(CaptureState::Active, 0); expect(idleFallback.recordSuppressedCallback(4096, 0) == Event::None,