Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions docs/automation-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
194 changes: 192 additions & 2 deletions src/core/AudioEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<AudioEngine*>(this),
[this, &endpoints]() {
endpoints = audioEndpointDiagnostics();
},
Qt::BlockingQueuedConnection);
return invoked ? endpoints : QJsonArray{};
}

const auto outputDescription = [this]() {
const QAudioDevice dev = m_outputDevice.isNull()
? QMediaDevices::defaultAudioOutput()
Expand Down Expand Up @@ -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<double>(txCaptureBufferedBytes());
Comment on lines +2544 to +2546

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

audioEndpointDiagnostics() is const and invoked from AutomationServer / DeviceDiagnostics on the main thread, but m_txCaptureHealth and m_micDevice are owned and mutated on the audio thread (onTxAudioReady, the queued stateChanged lambda, recordMicRead). So snapshot() here — and txCaptureBufferedBytes()'s m_micDevice->bytesAvailable() call one line below — race the audio thread's writes on non-atomic members.

This matches the pre-existing pattern in this same function (it already reads m_audioSink->state(), m_micDevice->isOpen(), etc. cross-thread), so it's not a regression and the torn reads are diagnostic-only. Worth flagging only because you deliberately marshaled recordLocalTxAttempt onto the owning thread "so diagnostics cannot race readyRead/stateChanged" — the read side of the same data isn't marshaled. Fine to leave as-is for a support-log snapshot; just noting the asymmetry.

tx["buffer_capacity_bytes"] = static_cast<double>(txCaptureBufferCapacityBytes());
tx["source_was_active"] = txHealth.sourceWasActive;
tx["saturation_observed"] = txHealth.saturationObserved;
tx["tci_suppressed_callbacks"] = static_cast<double>(txHealth.tciSuppressedCallbacks);
tx["full_buffer_during_tci_observations"] =
static_cast<double>(txHealth.fullBufferDuringTciObservations);
tx["idle_during_tci_transitions"] = static_cast<double>(txHealth.idleDuringTciTransitions);
tx["post_tci_local_tx_while_saturated"] =
static_cast<double>(txHealth.postTciLocalTxWhileSaturated);
tx["last_mic_read_age_ms"] = txHealth.lastMicReadAgeMs >= 0
? QJsonValue(static_cast<double>(txHealth.lastMicReadAgeMs))
: QJsonValue();
endpoints.append(tx);

const bool sidetoneRunning = m_sidetoneSink && m_sidetoneSink->isRunning();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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());
}
Comment on lines +7599 to +7606

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit fix (per-callback work when the diagnostic is off): gate the hot-path tracker updates + their device queries on the same category logTxCaptureHealthSummary already checks, so a default-off support toggle does no per-callback work on the audio thread. The suppression return below stays unconditional. Built + tx_capture_health_test green.

Suggested change
const TxCaptureHealthTracker::Event event = m_txCaptureHealth.recordSuppressedCallback(
txCaptureBufferedBytes(), txCaptureBufferCapacityBytes());
if (event != TxCaptureHealthTracker::Event::None) {
logTxCaptureHealthEvent(event);
}
if (m_audioSource) {
observeTxCaptureState(m_audioSource->state());
}
// Per-callback capture-health tracking is only ever surfaced when the
// opt-in support toggle is on (logTxCaptureHealthSummary gates on the
// same category), so skip the tracker updates and their device queries
// on this hot path when the diagnostic is disabled. The suppression
// early-return below is core behavior and must always run.
if (lcCat().isDebugEnabled()) {
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
Expand All @@ -7450,6 +7624,8 @@ void AudioEngine::onTxAudioReady()
m_txReceivedAnyBytes = true; // disarms the WASAPI silent-open watchdog (#2929)
#endif

m_txCaptureHealth.recordMicRead(txCaptureNowMs());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same gate for the normal-path consume marker.

Suggested change
m_txCaptureHealth.recordMicRead(txCaptureNowMs());
if (lcCat().isDebugEnabled()) {
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.
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/core/AudioEngine.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
#include <QStringList>

#include "TxMicChannelNormalizer.h"
#include "TxCaptureHealthTracker.h"
#include "SpectralNR.h"

class QMediaDevices;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -841,6 +851,8 @@ private slots:
std::atomic<bool> 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.
Expand Down
42 changes: 42 additions & 0 deletions src/core/AudioSummaryLogger.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Comment on lines +229 to +234

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit fix (%N in a device name): substitute the first line in one multi-arg .arg() so a stray %N in a hardware device description can't be re-substituted by the trailing numeric .arg(lifecycleMs).

Suggested change
<< 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)
// Single-pass multi-arg substitution: a stray "%N" inside a
// hardware device description must not be re-substituted by a
// trailing numeric .arg().
<< 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),
QString::number(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));
Expand Down Expand Up @@ -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
Loading
Loading