Skip to content

Commit 21f01a5

Browse files
Ozy311ten9876claude
authored
Add typed producer PCM contract and compatibility adapters (A1) (#5598)
This A1 change carries owned RX samples with their producer, session, receiver and format identity, rejects malformed or retired input, and adapts existing producers without changing accepted sample bits or processing behavior. It implements the first audio-foundation milestone of RFC #5468; it does not complete the RFC. **Signed linear stack #5608:** main `3b3312eb` → A1 `65e20686` (#5598) → NR `c50f9853` (#5604) → A2 `a9d9794b` (#5605). This PR targets `main`. The rebase changes history without adding a feature change: the 36-file A1 delta retains every published added/removed line, and its complete tree matches the independently computed combination of current main and published A1. Main's merged F4, #5591 TX coordinator and newer CW/TUNE/lifetime changes are preserved. The shared settings-consumer insertion retains both the PCM compatibility test and main's firmware-close test. There is one signed feature commit per stack layer and no merge commit above the frozen main. Original authors, dates and messages are retained; statements in those messages about former bases or validation are historical. ## Change - Add `PcmFrame`, `PcmProducer` and bounded gates: owned float32 samples; 24/48 kHz mono/stereo formats; separate source/session/format-generation/receiver-instance/slice identities; sample positions, discontinuities and atomic revocation. Reject empty, oversized, misaligned, nonfinite, backward, duplicate and unmarked-gap input. - Carry typed speaker/per-slice PCM through `IRadioBackend` and shared production/test bindings in `RadioModel`. Retire streams before teardown or slot reuse and check worker/session identity before adapting queued legacy output. - Adapt Flex decoding/DAX, HL2 mixing, ANAN, Icom, current RTL, Sim and Kiwi. Fixed-rate consumers unwrap after delivery. Existing recording, CW/RTTY, TCI, AetherClock, RADE and Kiwi source selection, and byte APIs remain available. (The raw `audioDataReady`/`daxAudioReady` signals were removed in review — see below.) - Preserve 24 kHz processing and device-rate `m_rxOutputRate`. RTL48 production and multi-receiver runtime remain disabled. A2 owns rate-aware playback; A3 recording, A4 TCI and A5 decoder/clock remain separate. The [contract and adapter inventory](https://github.com/aethersdr/AetherSDR/blob/4b7e0b202aab8af02a2a4cb323a9c55b3de2c006/docs/producer-pcm-contract.md) records ownership, execution contexts and admission limits. This feature adds no production thread, dependency, setting, UI/default, capability or frozen CI-gate entry. ## New-head validation Local results execute `4b7e0b202aab8af02a2a4cb323a9c55b3de2c006`; CI records its actual checkout refs. All seven current checks passed under native-stack trunk `main`. Local and GitHub signature verification passed; actual tested revisions are recorded below. | Evidence | Result | |---|---| | Mac build and focused tests | PASS at `4b7e0b20`; fresh aethercore and 22 C++ targets; 23/23 CTests, zero test-level skips (23.66 s). No A1 app/daemon build at this final head. | | Nobara focused PCM/TX and upstream regression tests | PASS at `4b7e0b20`; 35 C++ targets built incrementally (106.03 s), 35/35 focused CTests (26.01 s), zero test/internal skips. GCC 16.2.1/Qt 6.11.1, RelWithDebInfo `-O2 -g1 -gz=zlib -DNDEBUG`, RTL/RADE/DFNR/MQTT/Specbleach and ASR CPU/Vulkan ON. No standalone local A1 app/daemon or unfiltered-suite claim. | | Nobara ASan/UBSan | PASS at `4b7e0b20`; 35/35 focused tests, zero test/internal skips or sanitizer diagnostics. GCC 16.2.1/Qt 6.11.1 Debug, address+undefined, ASR OFF; leak/stack-use-after-return detection enabled. All 35 executed-binary hashes retained. System libraries and prebuilt Rust DFNR remain uninstrumented. | | GitHub signature and observed applicable checks, with tested refs | PASS; GitHub Verified and 7/7 current checks successful: [Code Quality: PR #5598](https://github.com/aethersdr/AetherSDR/actions/runs/34685207270), [Static Checks](https://github.com/aethersdr/AetherSDR/actions/runs/34685208810), [CI](https://github.com/aethersdr/AetherSDR/actions/runs/34685208833). Repository CI/static jobs checked out [`403c2b8a`](403c2b8), whose tree equals this signed head; dynamic Code Quality checked out the exact head. Superseded auto-cancelled runs are excluded. | Current-head mutation evidence at integrated A2 `011c2bf2715bca8018053be8bac5d267f29d569b`: removing only the merged DSP-geometry guards from a copied registry translation unit triggered exactly eight intended unsafe-geometry failures. The normal production binary passed 483/483 before and after. Final production compile/link flags and unchanged test objects were used; all 3,189 source blobs and 34 production file hashes/inodes/mtimes remained unchanged. This is a Mac native mutation, not a sanitizer run or standalone A1/NR execution. ## Historical evidence and limits Earlier head `e4973732ef08a5019bc9097a843d7bf808a2c075` passed Mac application/daemon builds and 23 focused tests; Nobara's full RTL-enabled suite had 405 pass, 3 skip and no failures out of 408; 25 ASan/UBSan tests passed. Its copied-production registry mutation removed the merged DSP-geometry guards, failed the unsafe-geometry assertions, and passed with the original binary. These are earlier-head results, not rebase executions. Original A1 `3b127b3a2efc4d21b2b270a06f2b619aed6b9e0e` also had PCM-contract concurrency TSan, four contract mutations, an AudioEngine replay mutation and an atomic-removal race mutation. That TSan evidence does not qualify the whole engine. The two A1 tests are socket-free and exercise actual backend → model → AudioEngine bindings, Flex/DAX lifetime, HL2 mixing, disconnect/reconnect, slot/family replacement, replay, A1's 48 kHz refusal and independent Kiwi streams. No synthetic firmware peer or socket-owning test is added. Full-suite runs use the documented private-state and isolated network/device harness. Historical skips were quarantined CRDV, the platform-specific explicit-profile-path settings scenario and opt-in radar GL; internal skips included NVIDIA AFX, Linux MNR, physical audio, opt-in GL and whisper fixtures. Mac used clang 21/Qt 6.11.1 with ASR OFF and no librtlsdr/Darwin DFNR. Nobara used GCC 16.2.1/Qt 6.11.1 with RTL/RADE/DFNR/MQTT/Specbleach and native ASR CPU+Vulkan; ASR was OFF under sanitizers. System libraries and prebuilt Rust DFNR were outside ASan/UBSan instrumentation. The new-head options and skips are recorded above. Producer revocation prevents later admission; A1 cannot retract samples already admitted to engine/device queues. Gates permit 32 live streams, and ownership copies/scans are not a real-time performance claim. Audible/native-device behavior, sustained latency/load, live Flex/RTL/USB/RF/TX and native Linux ARM remain unqualified here. Native Flex slice-0 before/after qualification requires separate authorization. The [#5554 §2.6 audio-track exit/sign-off](#5554 (comment)) remains outstanding for exactly-one producer with paired direct-route retirement, per-slice/DAX/pre-mute semantics and bounded latest-frame-wins queues across the completed track. A1 supplies milestone evidence. Independent review and applicable CODEOWNERS approval remain required; no merge is requested. 73, Ozy **K6OZY** · GPT-6 Astra-Ultra --- ## Review fixes applied (2026-09-12) Review feedback on this stack was addressed directly on the branches; the layers were rebased so the stack stays linear and each layer still carries its original feature commit with its original author, date and message. The evidence tables below describe the **pre-review-fix** heads and are retained as history; the current heads were rebuilt and retested on Arch/GCC 16 as recorded here. **Blocker — `PcmFrameGate` permanently silenced RX after a playback mute/unmute.** The gate refused a frame ahead of its cursor unless the frame was flagged discontinuous, but no A1 adapter sets that flag after the first frame of an epoch. A consumer that missed frames was therefore refused forever: a cursor only advances on an accepted frame. Playback mute does exactly that on both the Flex path (`MainWindow` disconnects the speaker feed) and the seam-backend path (`MainWindow_Session` returns early) while the producer keeps counting, so one QSO-recording playback left RX silent until the next reconnect, with no log line. The guard is now one-sided — refuse at or behind the cursor, admit ahead of it — and `pcm_frame_test` pins the resync, failing against the previous guard. **Removed `PanadapterStream::audioDataReady` / `daxAudioReady`.** This supersedes the "low-level compatibility signals remain available" line above: after the typed migration nothing in the tree connected to them, their arguments were still deep-copied per audio block (per DAX channel, on the network thread), and they bypassed the gate, so a later consumer would silently have skipped admission control. `AudioEngine`'s byte APIs are unaffected and do remain. **Other review fixes.** `AnanRxDsp` now warns when its producer refuses the configured audio rate — `PcmFormat` accepts 24000/48000 only and `AnanBackend` hardcodes 24000, so it cannot fail today, but if that rate ever moves a silent refusal would stop ANAN audio dead while the spectrum keeps updating. `rebindToEphemeralPort()` no longer rotates the epoch above its own failure guard; a speaker producer that refuses to start, and audio dropped for want of a live producer, now warn once per session instead of failing silently; `SimSignalSource::startSession()` no longer discards `PcmProducer::start()`'s return; the gate members moved out of the `private slots:` blocks they were declared in. **Not changed, deliberately.** No A1 adapter sets `discontinuity`, so a lost Flex UDP audio packet is still presented as continuous even though packet-loss concealment detected it. Propagating that would reset the chain on every lost packet — an audible-behaviour decision that belongs with the later milestones that own playback policy. `docs/producer-pcm-contract.md` now records this, and the one-sided gate rule, explicitly. **Current-head evidence.** Clean build, `422/422` CTests pass with the documented three skips, both on the A1 head and on its merge with current `main` (`3b3312eb`). Driven offscreen against the demo simulator (`DEMO-0001`, isolated settings, TX gated off): audio Active across four connect/disconnect cycles, and the new warnings stay silent on a healthy session. --------- Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d000483 commit 21f01a5

44 files changed

Lines changed: 1515 additions & 155 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/producer-pcm-contract.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Producer PCM contract (RFC #5468 A1)
2+
3+
`PcmFrame` in `src/core/PcmFrame.h` is the in-process RX producer envelope.
4+
It is not an aetherd wire resource or a sound-device format. A1 keeps the current
5+
24 kHz stereo audio paths active. The new RTL WDSP 48 kHz producer remains disabled;
6+
A2–A5 must qualify playback, recording, TCI and fixed-rate decoders before M1/S2
7+
activate it. No radio capability or default changes in this step.
8+
9+
## Format, identity and ownership
10+
11+
A frame carries owning, native-endian interleaved float32 samples, an immutable
12+
24/48 kHz mono/stereo format, and separate source, connection session,
13+
format-generation, receiver-instance and stable slice-slot identities.
14+
Speaker mixes and auxiliary sources have no slice slot. A slice stream accepts
15+
F4 `Handle::session`, `Handle::instance` and `Handle::slot` as separate values;
16+
legacy adapters use process-local identities until the native producer lands.
17+
These identifiers are not persistence keys. DAX channels and Kiwi source IDs
18+
travel alongside their frame and retain their existing meanings.
19+
20+
`firstSample` counts sample frames (LR pairs for stereo), not floats or bytes.
21+
It advances only on accepted input. A start or accepted format change resets its
22+
origin and marks the first frame discontinuous. Backward positions, overflow and
23+
repeated or older explicit
24+
sessions are refused. Legacy adapters count accepted output samples; this does
25+
not reconstruct missing capture timestamps or establish multi-receiver alignment.
26+
27+
Only `PcmProducer` constructs valid frames. It rejects empty, oversized (more than
28+
65,536 sample frames), incomplete-channel and nonfinite input. Finite values,
29+
including signed zero and peaks outside ±1, are preserved without clipping.
30+
Qt queued delivery copies the samples and metadata together with shared ownership;
31+
borrowed buffers are detached at publication. Consumers have no mutable access.
32+
33+
An epoch token contains immutable metadata and an atomic live bit, with no radio,
34+
worker, DSP or QObject ownership. Stop, reconnect, format change and slice retirement
35+
revoke old tokens. A queued frame stays invalid even after the same slot or rate is
36+
reused. `PcmFrameGate` independently rejects stale, replayed and out-of-order frames
37+
for each consumer, with at most 32 live stream cursors. Inactive entries are reused.
38+
39+
The gate is deliberately one-sided: it refuses a frame at or behind the cursor and
40+
admits one ahead of it. A forward gap means the consumer missed frames, which every
41+
consumer that can be detached from a running producer does legitimately — playback
42+
mute detaches the Flex speaker feed and short-circuits the seam-backend feed while
43+
the producer keeps counting. Because a cursor only advances on an accepted frame,
44+
refusing that gap would strand the consumer behind a live epoch with no way back,
45+
silencing RX until the next reconnect. Replay protection comes from the backward
46+
check plus epoch liveness, neither of which a forward gap weakens.
47+
48+
Each producer and consumer gate has one execution context. Only token revocation
49+
may overlap production/delivery; start, format change and destruction remain
50+
serialized with the producer, and its caller joins it before destruction. A
51+
revocation rejects a later admission; it cannot retract samples already admitted
52+
into an existing AudioEngine processing/device queue. Queue flushing and rate-domain
53+
transitions are A2 work. A1 does not claim bounded end-to-end latency or lock-free
54+
real-time performance: it adds ownership copies and finite-sample validation.
55+
56+
## Compatibility boundaries
57+
58+
`legacyStereo24()` refuses revoked frames and every format except 24 kHz stereo.
59+
It copies sample bits unchanged; it never converts or relabels 48 kHz or mono input.
60+
Fixed-rate consumers unwrap at the receiving callback, after queued delivery.
61+
62+
| Producer / route | A1 adapter and retained behavior |
63+
|---|---|
64+
| Flex LAN float/reduced-bandwidth/Opus | `PanadapterStream` publishes typed speaker PCM after the existing decode/concealment. Float alignment/finiteness is checked before concealment history. DAX registration owns revocable per-stream tokens. |
65+
| Hermes-Lite | The current DSP instance is checked before accepting queued worker output. Per-slice validation precedes the existing mixer. Its unity fast path, gain/balance, alignment and sum/clamp behavior stay unchanged. |
66+
| ANAN | `AnanRxDsp` tags its actual configured output rate before worker-to-owner delivery and revokes old tokens on channel installation. The production backend still requests 24 kHz, with the existing single-receiver speaker and slice outputs. |
67+
| Icom | The current session instance is checked; malformed/nonfinite mono input is refused before the existing 48 kHz mono to 24 kHz stereo converter. |
68+
| RTL | The current legacy worker is checked before publication. Existing DDC/demodulation, 24 kHz output, WFM behavior and slice-0 runtime remain in use. F4 preparation/registry is not activated as the audio producer. |
69+
| Simulator | Worker output has a connection epoch before queueing; the backend checks that epoch against its session before publishing. Existing pacing and speaker/slice samples remain unchanged. |
70+
| Kiwi | Existing resampling, silence, squelch and loss padding precede typed publication. Socket cleanup revokes queued audio; AudioEngine has an independent auxiliary ingress gate. |
71+
72+
`IRadioBackend` publishes typed speaker and per-slice signals. Its compatibility
73+
publishers initialize on `connected`, revoke on `disconnected`/slice removal, and
74+
refuse further publication from retired streams. `RadioModel` retires them before
75+
backend teardown and uses the same guarded PCM bindings for production and test
76+
injection. No new backend-family decision is introduced.
77+
78+
The existing single-producer choices remain: Flex stream playback, backend-owned
79+
speaker playback, and the simulator's existing direct speaker route. The normalized
80+
`rxDemodAudioReady` bus remains a separate subscriber for recording/CW/RTTY. Per-slice
81+
TCI/AetherClock, Flex DAX/RADE and concurrent Kiwi routes retain their attribution.
82+
Every production route uses the typed signals. `PanadapterStream`'s byte-valued
83+
`audioDataReady`/`daxAudioReady` are removed rather than retained: after the
84+
migration nothing in the tree connected to them, their arguments were still being
85+
deep-copied per audio block, and they bypassed the gate — so any later consumer
86+
wired to them would silently have skipped admission control.
87+
88+
No A1 adapter sets `discontinuity` after the first frame of an epoch: each one
89+
publishes contiguous positions, so a lost Flex UDP audio packet is presented as
90+
continuous even though packet-loss concealment detected it. Propagating that would
91+
make AudioEngine retire and reset the chain on every lost packet, which is an
92+
audible-behaviour change rather than a wiring fix, so it belongs with the later
93+
milestones that own playback policy. Today the field is exercised by tests and by
94+
explicit-position producers only.
95+
96+
AudioEngine's original byte APIs remain for legacy internal/playback callers. Its
97+
new typed entry points reject incompatible formats and duplicate/stale frames,
98+
then call the existing processing functions. The main RX L/R resamplers, TX CW
99+
sidetone adapter, decoded RADE speech adapter and device-rate state are unchanged.
100+
101+
## Validation scope
102+
103+
`pcm_frame_test` covers format, ownership, bounds, identity, continuity, queued
104+
revocation and replay/capacity rules without sockets or devices.
105+
`pcm_compatibility_test` injects the production backend/model/AudioEngine route,
106+
Flex decoders/DAX registration, and the Hermes mixer without a firmware peer or
107+
hardware connection. Existing simulator and ANAN DSP tests cover paired output
108+
and actual producer rates. Tests are registered in `tests/tests.cmake`; no frozen
109+
PR CI allow-list is expanded. Build, sanitizer and mutation results belong in the
110+
A1 execution report, with their host/configuration and hardware limits.

src/core/AetherClockEngine.h

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
// classes. The wiring layer (GUI applet, tests, any future host) injects a
1010
// DAX-hold provider wrapping the CENTRAL
1111
// PanadapterStream::acquireDaxChannel/releaseDaxChannel(ch,
12-
// DaxConsumer::Clock) registry, and connects the stream's daxAudioReady to
12+
// DaxConsumer::Clock) registry, and connects the stream's daxPcmReady to
1313
// feedRxAudio(). That keeps the engine above the radio seam and
1414
// source-agnostic: any 24 kHz float32-stereo feed (Flex DAX today, other
1515
// backends tomorrow) drives it through the same two seams.
@@ -44,7 +44,7 @@ class AetherClockEngine : public QObject {
4444
explicit AetherClockEngine(QObject* parent = nullptr);
4545
~AetherClockEngine() override;
4646

47-
// DAX RX audio sample rate (Hz) — the daxAudioReady contract.
47+
// DAX RX audio sample rate (Hz) — the daxPcmReady contract.
4848
static constexpr int kSampleRateHz = 24000;
4949

5050
// Station presets. Listening dial = carrier − 1 kHz, USB.
@@ -116,7 +116,7 @@ public slots:
116116
void applyStationPreset(SliceModel* slice, ClockStation station,
117117
double carrierMHz);
118118

119-
// PCM ingest — the daxAudioReady payload (float32 interleaved stereo,
119+
// PCM ingest — the daxPcmReady payload (float32 interleaved stereo,
120120
// native-endian, 24 kHz). The wiring layer connects the audio source
121121
// here; it is also the test-harness seam and the future non-Flex source
122122
// seam. Samples whose channel differs from the bound slice's live

src/core/AudioEngine.cpp

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1764,7 +1764,7 @@ AudioEngine::AudioEngine(QObject* parent)
17641764
return;
17651765
if (frames <= 0 || sampleRateHz <= 0) return;
17661766
// CwDecoder::feedAudio expects 24 kHz stereo float32 — the
1767-
// same shape PanadapterStream::audioDataReady() emits on
1767+
// same shape PanadapterStream::pcmFrameReady() carries on
17681768
// the RX side. Decimate 48→24 by averaging consecutive
17691769
// pairs; the sidetone is a single sine well below 12 kHz
17701770
// so the cheap two-tap LPF is sufficient for ggmorse. For
@@ -4363,6 +4363,30 @@ QByteArray AudioEngine::resampleStereo(const QByteArray& pcm,
43634363
return result;
43644364
}
43654365

4366+
void AudioEngine::feedPcmFrame(const PcmFrame& frame)
4367+
{
4368+
if (frame.stream().purpose != PcmPurpose::Speaker
4369+
|| frame.stream().format != PcmFormat{} || !m_pcmIngress.accept(frame)) {
4370+
return;
4371+
}
4372+
const QByteArray pcm = frame.legacyStereo24();
4373+
if (!pcm.isEmpty()) {
4374+
feedAudioData(pcm);
4375+
}
4376+
}
4377+
4378+
void AudioEngine::feedKiwiPcmFrame(const QString& sourceId, const PcmFrame& frame)
4379+
{
4380+
if (frame.stream().purpose != PcmPurpose::Auxiliary
4381+
|| frame.stream().format != PcmFormat{} || !m_kiwiPcmIngress.accept(frame)) {
4382+
return;
4383+
}
4384+
const QByteArray pcm = frame.legacyStereo24();
4385+
if (!pcm.isEmpty()) {
4386+
feedKiwiSdrAudioData(sourceId, pcm);
4387+
}
4388+
}
4389+
43664390
void AudioEngine::feedAudioData(const QByteArray& pcm)
43674391
{
43684392
captureAutomationAudio(QStringLiteral("raw"), QStringLiteral("flex"),

src/core/AudioEngine.h

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#pragma once
22

3+
#include "core/PcmFrame.h"
4+
35
#include <QObject>
46
#include <QAudioSink>
57
#include <QAudioSource>
@@ -70,7 +72,7 @@ class MacNRFilter;
7072
// AudioEngine handles audio playback (RX) and capture (TX).
7173
//
7274
// RX path:
73-
// Audio PCM arrives via PanadapterStream::audioDataReady() — the radio sends
75+
// Audio PCM arrives via PanadapterStream::pcmFrameReady() — the radio sends
7476
// VITA-49 IF-Data packets to the single "client udpport" socket owned by
7577
// PanadapterStream. PanadapterStream strips the header and emits the raw PCM;
7678
// connect that signal to feedAudioData() then call startRxStream() to open
@@ -679,8 +681,11 @@ class AudioEngine : public QObject {
679681
}
680682

681683
public slots:
682-
// Receives stripped PCM from PanadapterStream::audioDataReady().
684+
// Legacy internal/playback ingress: native float32 stereo at 24 kHz.
685+
// Live producers use feedPcmFrame so validation survives queued delivery.
683686
void feedAudioData(const QByteArray& pcm);
687+
void feedPcmFrame(const AetherSDR::PcmFrame& frame);
688+
void feedKiwiPcmFrame(const QString& sourceId, const AetherSDR::PcmFrame& frame);
684689
// Receives decoded KiwiSDR PCM after a clean protocol decoder exists.
685690
// Same format as feedAudioData(): 24 kHz stereo float32.
686691
void feedKiwiSdrAudioData(const QByteArray& pcm24kStereoFloat);
@@ -737,7 +742,7 @@ public slots:
737742
// from the audio thread; receivers should connect via Qt::AutoConnection
738743
// (which becomes queued across threads) so feedAudio() lands on the
739744
// decoder's thread. Format: 24 kHz stereo float32 — same as the
740-
// RX panStream::audioDataReady() path so CwDecoder::feedAudio()
745+
// RX panStream::pcmFrameReady() path so CwDecoder::feedAudio()
741746
// accepts it without a separate adapter.
742747
void txDecodeAudioReady(const QByteArray& pcm24kStereoFloat);
743748
// `channels` is carried explicitly (#4489) rather than left for a consumer
@@ -1200,6 +1205,7 @@ private slots:
12001205
QElapsedTimer m_lastDaxRadioChannelLog;
12011206
std::unique_ptr<Resampler> m_txResampler; // RADE e.g. 48k -> 24k (lazy init)
12021207

1208+
12031209
// DSP lifecycle mutex: held during feedAudioData() DSP section AND
12041210
// during enable/disable to prevent use-after-free (#502)
12051211
mutable std::recursive_mutex m_dspMutex;
@@ -1456,6 +1462,12 @@ private slots:
14561462
static constexpr quint16 FLEX_INFO_CLASS = 0x534C;
14571463
static constexpr quint16 PCC_IF_NARROW = 0x03E3;
14581464
static constexpr quint16 PCC_DAX_REDUCED = 0x0123; // reduced BW DAX (24kHz int16 mono)
1465+
1466+
private:
1467+
// Per-consumer replay cursors for the two typed PCM ingress slots. Data,
1468+
// not slots — kept out of the `private slots:` block above deliberately.
1469+
PcmFrameGate m_pcmIngress;
1470+
PcmFrameGate m_kiwiPcmIngress;
14591471
};
14601472

14611473
} // namespace AetherSDR

src/core/KiwiSdrClient.cpp

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,7 @@ void KiwiSdrClient::handleStatusPreflightFinished(QNetworkReply* reply)
780780

781781
void KiwiSdrClient::openWebSockets()
782782
{
783+
m_pcmProducer.start(PcmPurpose::Auxiliary);
783784
const QString scheme = m_secureWebSocket
784785
? QStringLiteral("wss")
785786
: QStringLiteral("ws");
@@ -1195,6 +1196,7 @@ bool KiwiSdrClient::diagnosticSoundCompressionRequested()
11951196

11961197
void KiwiSdrClient::cleanupSockets()
11971198
{
1199+
m_pcmProducer.invalidate();
11981200
if (m_keepaliveTimer) {
11991201
m_keepaliveTimer->stop();
12001202
}
@@ -1949,6 +1951,14 @@ void KiwiSdrClient::handleBinaryMessage(StreamKind stream,
19491951
}
19501952
}
19511953

1954+
void KiwiSdrClient::publishDecodedAudio(const QByteArray& pcm)
1955+
{
1956+
if (const auto frame = m_pcmProducer.legacyStereo24(pcm)) {
1957+
emit pcmFrameReady(*frame);
1958+
emit decodedAudioReady(frame->legacyStereo24());
1959+
}
1960+
}
1961+
19521962
void KiwiSdrClient::handleSoundFrame(const QByteArray& frame)
19531963
{
19541964
m_soundFrameSeen = true;
@@ -2166,10 +2176,10 @@ void KiwiSdrClient::handleSoundFrame(const QByteArray& frame)
21662176
kMaxSequenceGapPaddingFrames);
21672177
if (!compressedSound && !m_lastDecodedSoundPcm.isEmpty()) {
21682178
for (quint64 i = 0; i < padFrames; ++i) {
2169-
emit decodedAudioReady(m_lastDecodedSoundPcm);
2179+
publishDecodedAudio(m_lastDecodedSoundPcm);
21702180
}
21712181
}
2172-
emit decodedAudioReady(pcm);
2182+
publishDecodedAudio(pcm);
21732183
m_lastDecodedSoundPcm = pcm;
21742184
emit meterReadingReady(meterReading);
21752185
}

src/core/KiwiSdrClient.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#pragma once
22

3+
#include "core/PcmFrame.h"
4+
35
#include "KiwiSdrProtocol.h"
46

57
#include <QByteArray>
@@ -142,6 +144,7 @@ public slots:
142144
const QString& mode, int filterLowHz,
143145
int filterHighHz, const QString& panId);
144146
void decodedAudioReady(const QByteArray& pcm24kStereoFloat);
147+
void pcmFrameReady(const AetherSDR::PcmFrame& frame);
145148
void waterfallRowReady(const QString& panId, const QVector<float>& binsDbm,
146149
double lowFreqMhz, double highFreqMhz,
147150
quint32 timecode);
@@ -159,6 +162,8 @@ public slots:
159162
virtual void sendWaterfallCommand(const QString& command);
160163

161164
private:
165+
PcmProducer m_pcmProducer;
166+
void publishDecodedAudio(const QByteArray& pcm);
162167
friend class KiwiSdrWaterfallSetupTest;
163168
enum class StreamKind {
164169
Sound,

src/core/KiwiSdrManager.cpp

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1339,12 +1339,16 @@ KiwiSdrClient* KiwiSdrManager::ensureClient(const QString& id)
13391339
}
13401340
emit profileWaterfallAvailabilityChanged(id, available, detail);
13411341
}, Qt::QueuedConnection);
1342-
connect(c, &KiwiSdrClient::decodedAudioReady,
1343-
this, [this, id, c](const QByteArray& pcm) {
1342+
connect(c, &KiwiSdrClient::pcmFrameReady,
1343+
this, [this, id, c](const PcmFrame& frame) {
13441344
if (client(id) != c) {
13451345
return;
13461346
}
1347-
emit decodedAudioReady(id, pcm);
1347+
const QByteArray pcm = frame.legacyStereo24();
1348+
if (!pcm.isEmpty()) {
1349+
emit pcmFrameReady(id, frame);
1350+
emit decodedAudioReady(id, pcm);
1351+
}
13481352
}, Qt::QueuedConnection);
13491353
connect(c, &KiwiSdrClient::waterfallRowReady,
13501354
this, [this, id, c](const QString& panId, const QVector<float>& binsDbm,

src/core/KiwiSdrManager.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ public slots:
180180
// the per-source DSP state (disabling alone only quiesces it — #3668 review).
181181
void audioSourceRemoved(const QString& id);
182182
void decodedAudioReady(const QString& id, const QByteArray& pcm24kStereoFloat);
183+
void pcmFrameReady(const QString& id, const AetherSDR::PcmFrame& frame);
183184
void waterfallRowReady(const QString& id, const QString& panId,
184185
const QVector<float>& binsDbm,
185186
double lowFreqMhz, double highFreqMhz,

0 commit comments

Comments
 (0)