diff --git a/CMakeLists.txt b/CMakeLists.txt index bcb5606be..0bd5743b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2427,6 +2427,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 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 + 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..5566a8961 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -602,6 +602,18 @@ 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 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`, +`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 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 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..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() @@ -2525,6 +2541,21 @@ 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["full_buffer_during_tci_observations"] = + static_cast(txHealth.fullBufferDuringTciObservations); + tx["idle_during_tci_transitions"] = static_cast(txHealth.idleDuringTciTransitions); + 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(); endpoints.append(tx); const bool sidetoneRunning = m_sidetoneSink && m_sidetoneSink->isRunning(); @@ -6944,6 +6975,128 @@ 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(), + txCaptureBufferCapacityBytes()); + 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) +{ + // 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 + && health.fullBufferDuringTciObservations == 0 + && health.idleDuringTciTransitions == 0 + && health.postTciLocalTxWhileSaturated == 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.fullBufferDuringTciObservations = health.fullBufferDuringTciObservations; + summary.idleDuringTciTransitions = health.idleDuringTciTransitions; + summary.postTciLocalTxWhileSaturated = health.postTciLocalTxWhileSaturated; + summary.sourceWasActive = health.sourceWasActive; + summary.saturationObserved = health.saturationObserved; + AudioSummaryLogger::logTxCaptureHealth(summary, anomaly); +} + // ─── TX stream ──────────────────────────────────────────────────────────────── bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioPort) @@ -7284,6 +7437,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 +7468,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 +7595,15 @@ 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()) { + const TxCaptureHealthTracker::Event event = m_txCaptureHealth.recordSuppressedCallback( + txCaptureBufferedBytes(), txCaptureBufferCapacityBytes()); + if (event != TxCaptureHealthTracker::Event::None) { + logTxCaptureHealthEvent(event); + } + if (m_audioSource) { + observeTxCaptureState(m_audioSource->state()); + } return; } #ifdef Q_OS_MAC @@ -7450,6 +7624,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 +8106,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..e0772d1dc 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,33 @@ 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 fullDuringTci=%2 idleDuringTci=%3") + .arg(summary.tciSuppressedCallbacks) + .arg(summary.fullBufferDuringTciObservations) + .arg(summary.idleDuringTciTransitions) + << QStringLiteral(" postTciLocalTxWhileSaturated=%1") + .arg(summary.postTciLocalTxWhileSaturated) + << 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 +282,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..c07d99759 100644 --- a/src/core/AudioSummaryLogger.h +++ b/src/core/AudioSummaryLogger.h @@ -54,6 +54,24 @@ 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 fullBufferDuringTciObservations{0}; + quint64 idleDuringTciTransitions{0}; + quint64 postTciLocalTxWhileSaturated{0}; + bool sourceWasActive{false}; + bool saturationObserved{false}; +}; + QString sampleFormatName(QAudioFormat::SampleFormat format); QString formatStartupEnvironment(const QJsonObject& audioDevices); QString formatRxSink(const RxSinkSummary& summary); @@ -61,6 +79,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 +87,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/TxCaptureHealthTracker.h b/src/core/TxCaptureHealthTracker.h new file mode 100644 index 000000000..ddd32e92c --- /dev/null +++ b/src/core/TxCaptureHealthTracker.h @@ -0,0 +1,184 @@ +#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. 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 { + 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 fullBufferDuringTciObservations{0}; + quint64 idleDuringTciTransitions{0}; + quint64 postTciLocalTxWhileSaturated{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_currentlySaturated = false; + m_saturationReported = false; + m_stalledLocalTxReported = false; + m_tciSuppressedCallbacks = 0; + m_suppressedBufferPeakBytes = 0; + m_fullBufferDuringTciObservations = 0; + m_idleDuringTciTransitions = 0; + m_postTciLocalTxWhileSaturated = 0; + } + + 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; + m_currentlySaturated = true; + if (m_saturationReported) { + return Event::None; + } + m_saturationReported = true; + return Event::BufferSaturatedDuringTci; + } + + 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) + { + 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; + m_currentlySaturated = true; + if (m_saturationReported) { + return Event::None; + } + m_saturationReported = true; + return Event::BufferSaturatedDuringTci; + } + + Event recordLocalTxAttempt(CaptureState state, + bool localTxOwned, + bool daxTxMode, + bool tciAudioFresh, + qint64 bufferedBytes, + qint64 bufferCapacityBytes) + { + const bool sourceRunning = state == CaptureState::Active + || state == CaptureState::Idle; + const bool saturated = m_currentlySaturated + || (m_tciSuppressedCallbacks > 0 + && bufferIsFull(bufferedBytes, bufferCapacityBytes)); + const bool stalled = localTxOwned + && !daxTxMode + && !tciAudioFresh + && sourceRunning + && m_sourceWasActive + && saturated + && bufferedBytes > 0; + if (!stalled) { + return Event::None; + } + + m_currentlySaturated = true; + m_saturationReported = true; + ++m_postTciLocalTxWhileSaturated; + 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.fullBufferDuringTciObservations = m_fullBufferDuringTciObservations; + out.idleDuringTciTransitions = m_idleDuringTciTransitions; + 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}; + bool m_sourceWasActive{false}; + bool m_currentlySaturated{false}; + bool m_saturationReported{false}; + bool m_stalledLocalTxReported{false}; + quint64 m_tciSuppressedCallbacks{0}; + qint64 m_suppressedBufferPeakBytes{0}; + quint64 m_fullBufferDuringTciObservations{0}; + quint64 m_idleDuringTciTransitions{0}; + quint64 m_postTciLocalTxWhileSaturated{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..c59a707f9 --- /dev/null +++ b/tests/tx_capture_health_test.cpp @@ -0,0 +1,109 @@ +// 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 +// fallback; 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); + + expect(tracker.recordSuppressedCallback(4096, 8192) == Event::None, + "partially filled Active capture remains healthy"); + expect(tracker.recordSuppressedCallback(8192, 8192) + == Event::BufferSaturatedDuringTci, + "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::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::Active, true, false, false, 8192, 8192) + == Event::LocalTxWhileSaturated, + "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::Active, false, false, false, 8192, 8192) + == Event::None, + "another client's TX is not attributed to the local capture source"); + 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 == 3 + && snapshot.suppressedBufferPeakBytes == 8192, + "summary reports suppressed callback count and peak unread bytes"); + 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"); + + 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, + "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; +}