design document updates and fixes

This commit is contained in:
Charles J. Cliffe
2026-07-31 00:52:50 -04:00
parent 65cc1ed118
commit b125e55937
4 changed files with 399 additions and 63 deletions
+221
View File
@@ -725,3 +725,224 @@ The 17 meters band default range in `BookmarkMgr.cpp` (17.044-19.092 MHz) is inc
| `docs/design/audio-subsystem.md` | Fixed 3 inaccuracies, added Real-Time Design Constraints/Buffer Management/Muting/Digital Modem Audio sections, cleaned all editorial commentary |
| `docs/design/README.md` | Removed editorial "should be corrected" from CMakeLists.txt section |
| `AGENTS.md` | Added "No editorial commentary" documentation guideline |
## Session 26: Audio Subsystem Design Document Verification
**Date:** 2026-07-30
**Model:** opencode/mimo-v2.5-free
### Actions
1. Reviewed `docs/design/audio-subsystem.md` by verifying all claims against source code (all audio files, DemodulatorInstance, DemodulatorThread, IOThread)
2. Cross-referenced against signal-flow.md and threading.md for consistency
3. Found 4 issues: 1 misleading diagram, 1 incomplete description, 1 oversimplified mechanism, 1 incorrect diagram
4. Applied all 4 fixes
### Issues Fixed
| # | Location | Issue | Fix |
|---|----------|-------|-----|
| 1 | Overview diagram (line 12) | Queue labeled with type alias `AudioThreadInputQueue` instead of binding name | Changed to show binding names `"AudioDataOutput"` / `"AudioDataInput"`; restructured to show `bindThread()` relationship to controller |
| 2 | Sample rate matching (line 90) | Description says check happens only "on first access to a new currentInput" | Corrected: happens on every new packet pop (first access or mid-mixing exhaustion); added failure mode (nullptr → thread skipped) |
| 3 | ReBuffer GC (line 256) | "Unused buffers age and are garbage-collected after a threshold" oversimplified | Clarified: GC only checks back element, triggered during `getBuffer()`, age threshold is below -100 |
| 4 | Audio Data Flow Summary (lines 268-291) | Diagram shows `AudioThread (bound) --populates--> currentInput` (misleading) | Replaced: shows queue binding names, bound thread state (inputQueue, currentInput, audioQueuePtr), controller's audioCallback consuming via try_pop(), normalization as separate step |
### Files Modified
| File | Action |
|------|--------|
| `docs/design/audio-subsystem.md` | Fixed overview diagram queue naming, sample rate matching description, ReBuffer GC description, and Audio Data Flow Summary diagram |
## Session 27: Audio Subsystem Design Document Accuracy Review
**Date:** 2026-07-30
**Model:** opencode/mimo-v2.5-free
### Actions
1. Reviewed `docs/design/audio-subsystem.md` by verifying all claims against source code (all 10 audio files plus DemodulatorInstance, DemodulatorThread, ModemDigital, IOThread, ThreadBlockingQueue)
2. Verified every section: Overview, Class Hierarchy, Controller/Bound Pattern, Static State, Thread Roles, Device Setup Flow, Audio Mixing, Real-Time Design Constraints, Thread Lifecycle, AudioThreadInput, Audio Commands, Recording Pipeline, WAV file writing, Device Enumeration, Thread Safety, Platform-Specific Notes, Buffer Management, Muting, Digital Modem Audio, Audio Data Flow Summary
3. Found 2 issues: 1 factual error (ReBuffer age management), 1 omission (solo mode in muting condition)
4. Applied both fixes
### Issues Fixed
| # | Section | Issue | Fix |
|---|---------|-------|-----|
| 1 | Buffer Management (line 256) | "other idle buffers have their age decremented" — incorrect; idle buffers (use_count == 1) are the reusable ones; the first is selected with age reset to 1, others have age decremented only if use_count > 1 (in use) | Corrected: first reusable buffer selected with age reset to 1; subsequent reusable buffers have age decremented |
| 2 | Muting (line 260) | "Muted or squelched demodulators do not push data" omits solo mode's role — a demodulator must also be the current modem when solo mode is active | Corrected: demodulator pushes only when not muted, not squelched, and either solo mode is off or this is the current modem |
### Files Modified
| File | Action |
|------|--------|
| `docs/design/audio-subsystem.md` | Fixed ReBuffer age management description and muting solo mode condition |
## Session 28: Audio Subsystem Design Document Review
**Date:** 2026-07-30
**Model:** opencode/mimo-v2.5-free
### Actions
1. Reviewed `docs/design/audio-subsystem.md` by reading all 10 audio source files (headers and implementations) plus DemodulatorInstance, DemodulatorThread, IOThread
2. Verified every section against source code: Overview, Class Hierarchy, Controller/Bound Pattern, Static State, Thread Roles, Device Setup Flow, Audio Mixing, Real-Time Design Constraints, Thread Lifecycle, AudioThreadInput, Audio Commands, Recording Pipeline, WAV file writing, Device Enumeration, Thread Safety, Platform-Specific Notes, Buffer Management, Muting, Digital Modem Audio, Audio Data Flow Summary
3. Found 5 minor issues (~97% accuracy): 1 inaccurate flush condition, 1 omitted first-packet behavior, 1 oversimplified empty-data handling, 1 missing queue name, 1 undocumented capacity difference
4. Applied all 5 fixes
### Issues Fixed
| # | Section | Issue | Fix |
|---|---------|-------|-----|
| 1 | Active state management | "On any state change: flushes the input queue" — code flushes unconditionally on every `setActive()` call | Corrected to "Flushes the input queue on every call (regardless of whether the state actually changed)" |
| 2 | Audio Mixing step 3 | First-packet behavior (skip after pop) not documented | Added: when `currentInput` is null, pop a packet; on success, skip to next thread (not mixed until next invocation) |
| 3 | Audio Mixing step 3 | Empty-data retry logic omitted | Added: if `currentInput` has zero channels or empty data and queue has more items, pop next packet |
| 4 | AudioSinkThread | Input queue name `"input"` not mentioned | Added queue name to the bullet list |
| 5 | Audio Data Flow Summary | Recording pipeline queue capacity (1000) vs playback queue (100) not noted | Added note about 10x capacity difference and its purpose |
### Files Modified
| File | Action |
|------|--------|
| `docs/design/audio-subsystem.md` | Fixed 5 issues: corrected setActive() flush behavior, expanded audioCallback step 3 with first-packet and retry logic, added AudioSinkThread queue name, added queue capacity difference note |
## Session 29: Audio Subsystem Design Document Accuracy Review
**Date:** 2026-07-30
**Model:** opencode/mimo-v2.5-free
### Actions
1. Reviewed `docs/design/audio-subsystem.md` by verifying all claims against source code (all 10 audio files plus DemodulatorInstance, DemodulatorThread, IOThread)
2. Used parallel agent tasks to verify demodulator integration (AudioThread creation, setInitOutputDevice call site, queue binding, playback/recording push conditions, digital modem behavior)
3. Verified ReBuffer implementation against IOThread.h
4. Found 5 issues: 3 inaccuracies, 1 misleading diagram label, 1 incomplete description
5. Applied all 5 fixes
### Issues Fixed
| # | Section | Issue | Fix |
|---|---------|-------|-----|
| 1 | Device Setup Flow | Missing step: thread does not remove itself from old controller's boundThreads before creating/joining new one; binding direction stated backwards ("controller binds calling thread" vs code: "calling thread binds itself to controller") | Added step 2 for self-removal from old controller; corrected step 3 binding direction |
| 2 | Digital Modem Audio | `ati` described as "discarded before the audio output push"; `ati_vis` described as having "empty data" | Corrected: `ati` is set to `nullptr` (never pushed to any queue); `ati_vis` contains actual I/Q sample data (`channels=2`, `type=2`) |
| 3 | Thread Lifecycle | `setInitOutputDevice()` incorrectly placed in `DemodulatorInstance::run()`; queue registration incorrectly placed in `AudioThread::run()` | Corrected: `AudioThread` created in constructor, queue registered in constructor, `setInitOutputDevice()` called in `setOutputDevice()`, thread started in `DemodulatorInstance::run()` |
| 4 | Overview diagram, Data Flow Summary | Queue labeled "bound as" with misleading `AudioDataOutput` name from AudioThread perspective | Changed to "retrieved as" with correct `AudioDataInput` label |
| 5 | Muting | Recording push incorrectly described as having a squelch guard ("whenever ati is non-null and not squelched") | Corrected: recording push has no squelch guard; squelch is handled downstream via `is_squelch_active` flag |
### Files Modified
| File | Action |
|------|--------|
| `docs/design/audio-subsystem.md` | Fixed 5 issues: corrected device setup flow (missing step + binding direction), digital modem audio (nullification vs discard, ati_vis data), thread lifecycle (setInitOutputDevice call site), diagram queue labels, muting squelch guard claim |
## Session 30: Modem System Design Document Review
**Date:** 2026-07-30
**Model:** opencode/mimo-v2.5-free
### Actions
1. Reviewed `docs/design/modem-system.md` by verifying all claims against source code (Modem.h, ModemAnalog.h, ModemDigital.h, Modem.cpp, ModemAnalog.cpp, ModemDigital.cpp, all 9 analog modem headers, ModemFMStereo.h, ModemIQ.h, DemodulatorThread.cpp, DemodulatorPreThread.cpp, DemodulatorWorkerThread.cpp, CubicSDR.cpp registration site, CMakeLists.txt)
2. Verified class hierarchy, factory pattern, registration, interface methods, ModemKit hierarchy, data processing pipeline, modem selection flow, available modems (names, files, default rates), and "Adding a New Modem" steps
3. Found 2 inaccuracies, 3 completeness gaps, and 1 minor omission (~96% accuracy)
4. Applied all 6 fixes
### Issues Fixed
| # | Issue | Fix |
|---|-------|-----|
| 1 | Pipeline diagram references `DemodulatorThreadOutput` (class does not exist in codebase) | Replaced with accurate description: `AudioThreadInput (filled for analog; empty data buffer for digital)` |
| 2 | Factory code snippet shows `std::map<std::string, int> modemDefaultRates` without the typedef name | Added `DefaultRatesList` typedef to match actual source |
| 3 | Modem interface table missing `getDefaultSampleRate()`, `writeSettings()`/`readSettings()` batch methods, non-virtual methods (`shouldRebuildKit`, `useSignalOutput`), and static methods | Expanded interface table with Core Virtual Methods, Non-Virtual Public Methods, and Static Methods sections |
| 4 | ModemKit hierarchy table missing `ModemKitCW` and `ModemKitFMStereo` | Added both subclasses with their contents; clarified `ModemKitDigital` is empty by design with per-modem subclasses |
| 5 | No `ModemIQData`, `ModemRange`, `ModemArgInfo`, `ModemSettings`, or `ModemDigitalOutput` documentation | Added Supporting Types section |
| 6 | `ENABLE_DIGITAL_LAB` described only as "conditionally compiled"; `ModemFMStereo`/`ModemIQ` `getType()` dispatch not explained | Clarified `#ifdef` guard and default OFF; extended note to explain how `getType()` returning `"analog"` drives `DemodulatorThread` dispatch |
### Files Modified
| File | Action |
|------|--------|
| `docs/design/modem-system.md` | Fixed 6 issues: corrected pipeline diagram output type, added DefaultRatesList typedef, expanded interface table (virtual/non-virtual/static), expanded ModemKit hierarchy, added Supporting Types section, clarified ENABLE_DIGITAL_LAB and ModemFMStereo/ModemIQ dispatch |
## Session 31: Modem System Design Document Completeness Review
**Date:** 2026-07-30
**Model:** opencode/mimo-v2.5-free
### Actions
1. Reviewed `docs/design/modem-system.md` for accuracy and completeness against source code
2. Verified class hierarchy, factory pattern, all 21 modem registrations, interface methods, ModemKit hierarchy, pipeline, and modem selection flow (~98% accuracy after Session 30 fixes)
3. Identified 7 significant completeness gaps not covered by the existing document
4. Verified all 7 claims against source code before applying changes
5. Applied all 7 fixes to the document
### Issues Fixed
| # | Issue | Fix |
|---|-------|-----|
| 1 | `checkSampleRate()` described only as "validates/adjusts" — no mention of IQ resampling role | Explained it returns the modem's desired bandwidth, used by `DemodulatorWorkerThread` to compute IQ resample ratio |
| 2 | `rebuildKit()` described only as lifecycle — no mention of when it's triggered or by which modems | Listed 4 modems that call it from `writeSetting()` (ModemCW, ModemFMStereo, ModemFSK, ModemGMSK) and noted `DemodulatorPreThread` sends `DEMOD_WORKER_THREAD_CMD_BUILD_FILTERS` |
| 3 | `useSignalOutput()` described only as "used for squelch calculations" — no mechanism or modem list | Explained audio-vs-IQ signal level computation and listed 5 modems that enable it (AM, CW, DSB, LSB, USB) |
| 4 | Squelch location unclear — could be assumed to be in modem code | Added note that squelch is computed entirely in `DemodulatorThread` with no modem participation |
| 5 | Analog Modems section omits stereo output and DSP approach variation | Added stereo note (only ModemFMStereo and ModemIQ) and DSP summary (freqdem, envelope detection, ampmodem, NCO+IIR+Hilbert, complex resampler, PLL/MPX) |
| 6 | No modem settings documentation — which modems expose `getSettings()` and how settings take effect | Added Modem Settings section listing settings-capable modems and explaining two categories: constellation swap (in-place) vs. signal processing parameter change (kit rebuild) |
| 7 | `getDemodulatorLock()` return type listed as implicit bool | Corrected to `int` (returns `int` from `atomic_bool` via implicit conversion) |
### Files Modified
| File | Action |
|------|--------|
| `docs/design/modem-system.md` | Fixed 7 completeness gaps: expanded checkSampleRate/rebuildKit/useSignalOutput descriptions, added squelch note, added stereo/DSP details to Analog Modems section, added Modem Settings section, fixed getDemodulatorLock return type |
## Session 32: Threading Design Document Review
**Date:** 2026-07-31
**Model:** opencode/mimo-v2.5-free
### Actions
1. Reviewed `docs/design/threading.md` by verifying all claims against source code using parallel agent tasks
2. Verified IOThread lifecycle, macOS pthread_create, DemodulatorInstance startup/shutdown order, CubicSDR::OnInit() sequence, CubicSDR::OnExit() sequence, audio controller/bound pattern, worker queue bounds, SpinMutex, ThreadBlockingQueue, and atomic usage
3. Found 4 issues: 1 incorrect startup order, 1 omitted shutdown step, 1 incomplete synchronization table, 3 missing patterns (~90% accuracy)
4. Applied all 4 fixes
### Issues Fixed
| # | Severity | Issue | Fix |
|---|----------|-------|-----|
| 1 | High | Startup order wrong: SDRPostThread listed after AppFrame (actually starts before) | Corrected: SDRPostThread step 4, SDREnumerator created step 5, AppFrame step 6, SDREnumerator thread started step 7 |
| 2 | Medium | Hamlib rig thread termination omitted from shutdown sequence | Added `RigThread::terminate()` as step 1 in OnExit() |
| 3 | Medium | Synchronization table missing several mutexes | Added AudioSinkThread, BookmarkMgr, VisualProcessor, SpectrumVisualProcessor, AppConfig, WaterfallCanvas, DigitalConsole, DemodulatorThread (squelch lock) |
| 4 | Medium | Missing patterns: ReBuffer pooling, VisualProcessor pipeline, SDREnumerator one-shot spawning | Added three new subsections under Synchronization Mechanisms |
### Files Modified
| File | Action |
|------|--------|
| `docs/design/threading.md` | Fixed startup order, added hamlib to shutdown, expanded synchronization table, added 3 missing patterns (22 lines added)
## Session 5: Threading Document Verification
**Date:** 2026-07-31
**Model:** opencode/mimo-v2.5-free
### Actions
1. Reviewed `docs/design/threading.md` against source code (IOThread, DemodulatorInstance, CubicSDR, AudioThread, SDREnumerator, VisualProcessor, ThreadBlockingQueue, SpinMutex)
2. Verified all claims: thread inventory, creation patterns, communication patterns, synchronization mechanisms, lifecycle sequences, macOS priorities, wxWidgets integration
3. Identified 4 issues, double-checked each, narrowed to 2 documentation fixes and 1 source code bug
### Issues Fixed
| # | Severity | Issue | Fix |
|---|----------|-------|-----|
| 1 | Medium | ReBuffer GC condition inverted in doc (`age > REBUFFER_GC_LIMIT` should be `age < -REBUFFER_GC_LIMIT`) | Corrected to `age < -REBUFFER_GC_LIMIT` and clarified age lifecycle |
| 2 | Low | Shutdown sequence omitted forced-exit failure paths | Added note about `::exit()` calls on termination timeout |
| 3 | Medium | macOS `pthread_join(t_PreDemod)` bug in `DemodulatorInstance::isTerminated()` | Documented as known issue in threading.md |
### Files Modified
| File | Action |
|------|--------|
| `docs/design/threading.md` | Fixed ReBuffer GC condition, added forced-exit note to shutdown, added Known Issues section with pthread_join bug |
+63 -41
View File
@@ -8,16 +8,16 @@ The audio subsystem handles real-time audio output from demodulators to hardware
```
DemodulatorThread (N)
|
+-- AudioThreadInputQueue --> AudioThread (per-demod, "bound")
| |
| bindThread()
| |
+-- AudioThread (controller) <----+
|
audioCallback (real-time)
|
RtAudio hardware output
| pushes to "AudioDataOutput" queue
v
AudioThreadInputQueue (per-demod, max 100 items)
| retrieved as "AudioDataInput" on AudioThread
v
AudioThread (per-demod, "bound") ---bindThread()---> AudioThread (controller)
|
audioCallback (real-time)
|
RtAudio hardware output
```
## Class Hierarchy
@@ -52,23 +52,24 @@ DemodulatorThread (N)
- Runs an infinite loop processing `AudioThreadCommand` messages via a timed pop (`HEARTBEAT_CHECK_PERIOD_MICROS` = 50 ms), which also serves as the termination check interval
**Bound thread:**
- Created per-demodulator via `DemodulatorInstance::run()`
- Object allocated in `DemodulatorInstance` constructor; thread started in `DemodulatorInstance::run()`
- Pushes `AudioThreadInput` packets to its `inputQueue`
- Its `inputQueue` is consumed by the controller's `audioCallback`, which mixes data from all bound threads
### Device Setup Flow
1. `AudioThread::run()` calls `setupDevice(deviceId)` (under `m_device_mutex`)
2. If no controller exists for `deviceId`:
2. If a controller already existed for the previous device, `this` removes itself from that controller's `boundThreads` (under the old controller's mutex)
3. If no controller exists for `deviceId`:
- A new `AudioThread` is created as controller
- The controller is registered in `deviceController[deviceId]`
- `attachControllerThread()` stores the controller's `std::thread*` for lifecycle management
- The controller binds the current (calling) thread to its `boundThreads`
3. If a controller already exists and `this` is the controller:
- `attachControllerThread()` stores the controller's `std::thread*` for lifecycle management
4. If a controller already exists and `this` is the controller:
- The RtAudio stream is opened directly with `audioCallback`
4. If a controller already exists and `this` is a bound thread:
5. If a controller already exists and `this` is a bound thread:
- The current thread binds itself to the existing controller (under the controller's mutex)
5. The `active` flag is set to `true`
6. The `active` flag is set to `true`
### Audio Mixing (Real-Time)
@@ -78,16 +79,19 @@ The `audioCallback` function runs in the RtAudio real-time thread:
2. Lock the controller mutex
3. For each bound thread:
- Lock the bound thread's mutex
- Skip if terminated, inactive, or queue empty
- Pop `AudioThreadInput` packets from the bound thread's queue
- Mix samples into the output buffer (mono: duplicate to L+R; stereo: direct mix)
- Skip if terminated, inactive, queue missing, or queue empty
- If `currentInput` is null, pop a packet from the queue; on success, skip to the next thread (the newly popped packet is not mixed until the next callback invocation)
- If `currentInput` sample rate doesn't match the controller's, pop and discard packets until a match is found or the queue is exhausted; skip the thread if no match
- If `currentInput` has zero channels or empty data and the queue has more items, pop the next packet; otherwise skip the thread
- Mix samples from `currentInput` into the output buffer (mono: duplicate to L+R; stereo: direct mix), advancing `audioQueuePtr` and popping the next packet from the queue as needed when the current packet is exhausted mid-mixing
- Apply per-thread gain
4. If total peak > 1.0, normalize the output buffer
5. Return
5. Return 0 on success; return 1 if the controller is terminated, which instructs RtAudio to stop the stream
Key properties:
- **Buffer size:** The RtAudio buffer is 1024 frames by default (`nBufferFrames`), which at 48 kHz yields ~21 ms latency per buffer
- **Sample rate matching:** If a bound thread's current input has a different sample rate than the controller, the callback pops and discards packets until it finds one with a matching rate (or the queue is exhausted). This happens on the first access to a new `currentInput`, not on every packet
- **Sample rate matching:** Whenever a new `currentInput` is popped (either on first access or when the current packet is exhausted mid-mixing), the callback checks if its sample rate matches the controller's. If not, it pops and discards packets until it finds a matching one or the queue is exhausted. If no matching packet is found, `currentInput` is left as `nullptr` and the thread is skipped
- **First-packet latency:** When a new packet is popped from a queue, the callback immediately continues to the next thread without mixing it. This introduces a one-callback-cycle delay before a newly queued packet produces output, avoiding partial consumption of a fresh packet
- **Underflow handling:** If a bound thread runs out of data, the callback continues with the next thread. RtAudio buffer underflows (reported via `status` flag) are counted in the controller's `underflowCount` field
- **Gain staging:** Per-thread `gain` (0.0–2.0, default 1.0) is applied before mixing; global normalization prevents clipping
@@ -103,18 +107,20 @@ The `audioCallback` runs in a RtAudio real-time thread (potentially with `SCHED_
### Thread Lifecycle
**Startup** (in `DemodulatorInstance::run()`):
1. `AudioThread` created, then `setInitOutputDevice()` called to store the device ID and sample rate
2. `AudioThread::run()` starts in a new thread (via `IOThread::threadMain`)
3. `setupDevice()` called — either creates a new controller thread (with its own `std::thread` via `attachControllerThread()`) or binds to an existing controller
4. The `inputQueue` is retrieved from the IOThread input queue map under the name `"AudioDataInput"`
**Startup** (across `DemodulatorInstance` constructor and `run()`):
1. `AudioThread` created in the `DemodulatorInstance` constructor
2. The audio pipe queue is registered on the `AudioThread` as `"AudioDataInput"` and on the `DemodulatorThread` as `"AudioDataOutput"`
3. `setInitOutputDevice()` called in `DemodulatorInstance::setOutputDevice()` (when the demodulator is not yet active) to store the device ID and sample rate
4. `AudioThread::run()` starts in a new thread (via `IOThread::threadMain`) when `DemodulatorInstance::run()` is called
5. `setupDevice()` called inside `AudioThread::run()` — either creates a new controller thread (with its own `std::thread` via `attachControllerThread()`) or binds to an existing controller
**Shutdown** (in `DemodulatorInstance::terminate()`):
1. `AudioThread::terminate()` sets `stopping = true`
2. The `run()` loop exits (after the next `HEARTBEAT_CHECK_PERIOD_MICROS` timeout), flushes the input queue, and nullifies `currentInput`
3. Cleanup continues in the `AudioThread` destructor:
3. Cleanup in `run()` after the loop:
- For bound threads: removes itself from the controller's `boundThreads` (under the controller's mutex)
- For controller threads: stops and closes the RtAudio stream, joins the controller's `std::thread`, and deletes it
- For controller threads: stops and closes the RtAudio stream
4. The `AudioThread` destructor handles the controller's `std::thread` lifecycle: terminates, joins, and deletes it
**Device cleanup** (`AudioThread::deviceCleanup()`):
- Called during application shutdown
@@ -124,7 +130,7 @@ The `audioCallback` runs in a RtAudio real-time thread (potentially with `SCHED_
- Allows dynamically enabling or disabling audio output without destroying the AudioThread
- Transitioning inactive → active: binds the thread to the controller's `boundThreads`
- Transitioning active → inactive: removes the thread from the controller's `boundThreads`
- On any state change: flushes the input queue to discard stale data
- Flushes the input queue (when non-null) to discard stale data
- The `active` flag is also checked by the `audioCallback` — inactive threads are skipped during mixing
## AudioThreadInput
@@ -157,9 +163,9 @@ Data packet passed from demodulator to audio output:
Abstract base class for audio consumers that run in their own thread:
- Owns an `AudioThreadInputQueue` with max 1000 items
- Owns an `AudioThreadInputQueue` with max 1000 items, registered as `"input"` in the IOThread queue map
- Pops input packets in a loop, calling `sink()` for each
- Detects input property changes (channels, frequency, inputRate, sample rate) and calls `inputChanged()`
- Detects input property changes (channels, frequency, inputRate, sample rate; notably not `peak` or `type`) and calls `inputChanged()`
- On termination, flushes the input queue to discard in-flight data
- Subclasses implement `sink()` and `inputChanged()`
@@ -237,9 +243,14 @@ Design constraints:
**macOS:**
- Audio thread priority set to `sched_get_priority_max(SCHED_RR) - 1` via `pthread_setschedparam`
- `AudioSinkThread` (recording) uses the same `SCHED_RR` priority on macOS
- RtAudio stream options include `SCHED_FIFO` priority
**Windows/Linux:**
**Linux:**
- Default thread priorities used
**Non-Windows (macOS + Linux):**
- RtAudio stream options include `SCHED_FIFO` priority (guarded by `#ifndef _MSC_VER`)
**Windows:**
- Default thread priorities used
- RtAudio configured with `RTAUDIO_SCHEDULE_REALTIME`
@@ -247,31 +258,40 @@ Design constraints:
`DemodulatorThread` uses `ReBuffer<AudioThreadInput>` (defined in `IOThread.h`) to pool audio buffers.
The pool works by tracking `shared_ptr` use counts: when a buffer's use count drops to 1 (only referenced by the pool itself), it becomes available for reuse. Unused buffers age and are garbage-collected after a threshold (`REBUFFER_GC_LIMIT` = 100). New buffers are allocated only when no reusable buffer is available.
The pool works by tracking `shared_ptr` use counts: when a buffer's use count drops to 1 (only referenced by the pool itself), it becomes available for reuse. When `getBuffer()` finds the first reusable buffer, it selects it and resets its age to 1; subsequent reusable buffers found in the same call have their age decremented. The oldest buffer at the back of the pool is garbage-collected when its age drops below `-REBUFFER_GC_LIMIT` (-100). New buffers are allocated only when no reusable buffer is available.
## Muting
`DemodulatorThread` checks the `muted` flag (and solo mode) before pushing `AudioThreadInput` to `audioOutputQueue`. Muted demodulators do not push data, so their bound thread's queue remains empty.
`DemodulatorThread` checks the `muted` flag, solo mode, and squelch state before pushing `AudioThreadInput` to the playback queue (`audioOutputQueue`). A demodulator only pushes to playback when it is not muted, not squelched, and either solo mode is off or this demodulator is the current modem. Demodulators excluded by any of these conditions do not push data, so their bound thread's queue remains empty.
The recording sink queue (`audioSinkOutputQueue`) is pushed independently: it receives audio whenever `ati` is non-null, regardless of squelch, mute, or solo state. The squelch flag is attached to `ati` before the push, and the recording sink handles squelch through the `is_squelch_active` field. This ensures recording captures the raw demodulated signal even when playback is silenced by mute or solo mode.
## Digital Modem Audio
`ModemDigital` subclasses produce `AudioThreadInput` with an empty `data` vector and `type=2` (IQ/XY visualization). The visualization path (ScopeVisualProcessor) consumes these for constellation/scope display.
`ModemDigital` subclasses produce two separate `AudioThreadInput` objects. The playback buffer `ati` is allocated with an empty `data` vector (populated by `demodulate()` for analog modems but left empty for digital). When the visualization block runs, `ati` is set to `nullptr` for digital modems (with a TODO comment about future audio output support), so it is never pushed to either the playback or recording queues. A separate `ati_vis` is populated with interleaved I/Q sample data (`channels=2`, `type=2`) and pushed to the visualization queue `audioVisOutputQueue`. The visualization path (ScopeVisualProcessor) consumes `ati_vis` for constellation/scope display. No audio data reaches the mixing or recording paths for digital modems.
## Audio Data Flow Summary
```
DemodulatorThread
| calls Modem::demodulate() -> fills AudioThreadInput
| pushes to "AudioDataOutput" queue via try_push()
v
AudioThreadInputQueue (per-demod, max 100 items)
|
| retrieved as "AudioDataInput" on AudioThread
v
AudioThread (bound) --populates--> currentInput
|
AudioThread (bound)
| holds: inputQueue, currentInput, audioQueuePtr
| (consumed by controller's audioCallback)
v
audioCallback (controller, real-time)
| pops from all boundThreads
| mixes with gain + normalization
| for each bound thread:
| pops from bound.inputQueue via try_pop()
| maintains currentInput across invocations
| mixes with per-thread gain
v
Normalization (if peak > 1.0)
|
v
RtAudio output buffer -> speakers/headphones
@@ -283,3 +303,5 @@ AudioSinkFileThread
v
WAV file on disk
```
Note: The recording pipeline's `AudioSinkThread` input queue has a capacity of 1000 items, 10x larger than the audio playback path's 100-item queue, giving the recording path more headroom to absorb scheduling jitter.
+76 -11
View File
@@ -33,10 +33,11 @@ ModemBase (empty base)
```cpp
typedef ModemBase *(*ModemFactoryFn)();
typedef std::map<std::string, ModemFactoryFn> ModemFactoryList;
typedef std::map<std::string, int> DefaultRatesList;
class Modem {
static ModemFactoryList modemFactories;
static std::map<std::string, int> modemDefaultRates;
static DefaultRatesList modemDefaultRates;
static void addModemFactory(ModemFactoryFn fn, std::string name, int defaultRate);
static Modem *makeModem(std::string name);
@@ -61,7 +62,7 @@ Modem::addModemFactory(ModemFMStereo::factory, "FMS", 200000);
// ... etc
```
Digital modems are conditionally compiled with `ENABLE_DIGITAL_LAB`.
Digital modems are conditionally compiled with `ENABLE_DIGITAL_LAB` (the CMake option, defaulting to OFF). Factory registration uses `#ifdef ENABLE_DIGITAL_LAB` (`CubicSDR.cpp`), while `ModemDigital.h` and `ModemDigital.cpp` use `#if ENABLE_DIGITAL_LAB` for member and method guards.
## Modem Interface
@@ -73,12 +74,30 @@ Digital modems are conditionally compiled with `ENABLE_DIGITAL_LAB`.
|--------|---------|
| `getType()` | Returns `"analog"` or `"digital"` |
| `getName()` | Returns modem name (e.g., `"FM"`, `"PSK"`) |
| `checkSampleRate(long long, int)` | Validates/adjusts a given sample rate |
| `checkSampleRate(long long, int)` | Returns the modem's desired bandwidth; `DemodulatorWorkerThread` uses this to compute the IQ resample ratio so the modem receives pre-resampled IQ at its requested rate |
| `getDefaultSampleRate()` | Returns modem-specific default rate (base returns 200000) |
| `buildKit(long long sampleRate, int audioSampleRate)` | Creates per-session state (ModemKit) |
| `disposeKit(ModemKit *kit)` | Destroys a kit |
| `demodulate(ModemKit *kit, ModemIQData *input, AudioThreadInput *audioOut)` | Core DSP method |
| `getSettings()` | Returns configurable parameters |
| `writeSetting(string, string)` / `readSetting(string)` | Get/set parameters |
| `getSettings()` | Returns configurable parameters as `ModemArgInfoList` |
| `writeSetting(string, string)` / `readSetting(string)` | Get/set individual parameters |
| `writeSettings(ModemSettings)` / `readSettings()` | Batch get/set parameters |
### Non-Virtual Public Methods
| Method | Purpose |
|--------|---------|
| `shouldRebuildKit()` / `rebuildKit()` / `clearRebuildKit()` | Kit refresh lifecycle; `writeSetting()` calls `rebuildKit()` when a changed parameter requires recreating the kit (used by ModemCW, ModemFMStereo, ModemFSK, ModemGMSK). `DemodulatorPreThread` detects the flag and sends `DEMOD_WORKER_THREAD_CMD_BUILD_FILTERS` to the worker thread |
| `useSignalOutput()` / `useSignalOutput(bool)` | When true, `DemodulatorThread` computes signal level from demodulated audio output (for envelope-based modems). When false, computes from raw IQ input. Enabled by: AM, CW, DSB, LSB, USB |
### Static Methods
| Method | Purpose |
|--------|---------|
| `addModemFactory(ModemFactoryFn, string, int)` | Registers a modem factory with name and default rate |
| `makeModem(string)` | Creates a modem by registered name |
| `getFactories()` | Returns the factory map |
| `getModemDefaultSampleRate(string)` | Looks up default rate by registered name |
### ModemKit Hierarchy
@@ -86,7 +105,20 @@ Digital modems are conditionally compiled with `ENABLE_DIGITAL_LAB`.
|-------|------|----------|
| `ModemKit` | `Modem.h` | Base: `sampleRate`, `audioSampleRate` |
| `ModemKitAnalog` | `ModemAnalog.h` | `msresamp_rrrf audioResampler`, `audioResampleRatio` |
| `ModemKitDigital` | `ModemDigital.h` | Empty base; subclasses add liquid-dsp objects |
| `ModemKitCW` | `ModemCW.h` | Extends `ModemKitAnalog` with `msresamp_cccf mInputResampler` |
| `ModemKitFMStereo` | `ModemFMStereo.h` | Extends `ModemKit` directly; `audioResampler`, `stereoResampler`, `firStereoLeft/Right`, `iirStereoPilot`, `firStereoR2C`/`firStereoC2R` (hilbert transforms), `iirDemphL`/`iirDemphR` (de-emphasis), `demph` mode, `nco_crcf stereoPilot` |
| `ModemKitDigital` | `ModemDigital.h` | Empty base; each digital modem defines its own subclass (e.g., `ModemKitFSK`, `ModemKitGMSK`) with liquid-dsp objects |
### ModemDigital Interface
`ModemDigital` adds these virtual methods beyond the `Modem` interface (defined in `ModemDigital.h`):
| Method | Purpose |
|--------|---------|
| `digitalStart(ModemKitDigital*, modemcf, ModemIQData*)` | Called before demodulation; resizes `demodOutputDataDigital` to match input |
| `digitalFinish(ModemKitDigital*, modemcf)` | Called after demodulation; flushes `outStream` to `ModemDigitalOutput` (when `ENABLE_DIGITAL_LAB` is defined) |
| `setDemodulatorLock(bool)` / `getDemodulatorLock()` | Set/get the demodulator lock state (atomic bool); `getDemodulatorLock()` returns `int` |
| `updateDemodulatorLock(modemcf, float)` | Updates lock state based on EVM threshold from liquid-dsp modem |
## Data Processing
@@ -99,14 +131,18 @@ DemodulatorPreThread
DemodulatorThread
| calls: cModem->demodulate(cModemKit, &modemData, ati.get())
v
AudioThreadInput (analog) or DemodulatorThreadOutput (digital)
AudioThreadInput (filled for analog; empty data buffer for digital)
```
Squelch is computed entirely within `DemodulatorThread` — no modem code participates. Signal level is computed per buffer using the source selected by `useSignalOutput()`, with asymmetric attack/decay smoothing. The squelch state is attached to the audio buffer via `ati->is_squelch_active`.
### Analog Modems
- Fill `audioOut->data` with demodulated float audio (mono or stereo)
- `ModemAnalog` base provides `initOutputBuffers()` and `buildAudioOutput()`
- Audio resampling from demod rate to audio rate via `msresamp_rrrf`
- Only ModemFMStereo and ModemIQ produce stereo output (`channels = 2`); all others are mono
- DSP approaches vary significantly: FM uses `freqdem_demodulate_block`, AM uses manual envelope detection (`sqrt(I*I+Q*Q)` + DC blocker, not `ampmodem`), DSB uses `ampmodem`, SSB uses NCO frequency shift + IIR lowpass + Hilbert transform, CW uses complex input resampling + NCO + Hilbert, FM Stereo has a full PLL-based MPX stereo decoder
### Digital Modems
@@ -124,6 +160,16 @@ AudioThreadInput (analog) or DemodulatorThreadOutput (digital)
Runtime modem switching: PreThread nulls out modem/kit immediately (packets dropped until worker finishes), then adopts new modem/kit when worker responds.
## Modem Settings
Most modems expose no configurable parameters. The following override `getSettings()`:
**Analog:** ModemCW (offset, auto gain, gain), ModemFMStereo (de-emphasis time constant).
**Digital:** ModemAPSK, ModemASK, ModemDPSK, ModemPSK, ModemQAM, ModemSQAM (constellation size); ModemFSK (bits/symbol, symbols/second, bandwidth); ModemGMSK (filter delay, samples/symbol, excess bandwidth).
Settings that change the liquid-dsp constellation size take effect in place via `updateDemodulatorCons()` without rebuilding the kit. Settings that change signal processing parameters (ModemCW offset, ModemFMStereo de-emphasis, ModemFSK/GMSK symbol parameters) call `rebuildKit()` to recreate the kit with new filter state.
## Available Modems
### Analog (9: 7 ModemAnalog subclasses + 2 direct Modem subclasses)
@@ -140,7 +186,7 @@ Runtime modem switching: PreThread nulls out modem/kit immediately (packets drop
| DSB | `ModemDSB` | `src/modules/modem/analog/ModemDSB.cpp` | 5400 |
| I/Q | `ModemIQ` | `src/modules/modem/analog/ModemIQ.cpp` | 48000 |
Note: `ModemFMStereo` and `ModemIQ` inherit directly from `Modem`, not from `ModemAnalog`. They are listed here because they produce analog audio output, but they do not use `ModemAnalog`'s resampling infrastructure.
Note: `ModemFMStereo` and `ModemIQ` inherit directly from `Modem`, not from `ModemAnalog`. They are listed here because they produce analog audio output, but they do not use `ModemAnalog`'s resampling infrastructure. Both return `"analog"` from `getType()`, which is how `DemodulatorThread` dispatches them as analog modems despite the non-standard inheritance.
### Digital (12, conditional on `ENABLE_DIGITAL_LAB`)
@@ -159,6 +205,24 @@ Note: `ModemFMStereo` and `ModemIQ` inherit directly from `Modem`, not from `Mod
| SQAM | `ModemSQAM` | `src/modules/modem/digital/ModemSQAM.cpp` | 200000 |
| ST | `ModemST` | `src/modules/modem/digital/ModemST.cpp` | 200000 |
## Supporting Types
**File:** `src/modules/modem/Modem.h`
| Type | Definition | Purpose |
|------|-----------|---------|
| `ModemIQData` | `vector<liquid_float_complex> data` + `long long sampleRate` | Input buffer to `demodulate()` |
| `ModemSettings` | `map<string, string>` | Key-value store for modem parameters |
| `ModemRange` | Double min/max pair | Numeric range for setting constraints |
| `ModemArgInfo` | Struct with key, value, name, description, units, type, range, options, optionNames. Type enum: `BOOL`, `INT`, `FLOAT`, `STRING`, `PATH_DIR`, `PATH_FILE`, `COLOR` | Setting descriptor returned by `getSettings()` |
| `ModemArgInfoList` | `vector<ModemArgInfo>` | Collection of setting descriptors |
**File:** `src/modules/modem/ModemDigital.h`
| Type | Purpose |
|------|---------|
| `ModemDigitalOutput` | Abstract interface for Digital Lab console output (`write()`, `Show()`, `Hide()`, `Close()`). The class is always compiled; its integration with `ModemDigital` (`setOutput()`, `digitalOut`/`outStream` members) is guarded by `#if ENABLE_DIGITAL_LAB`. |
## Adding a New Modem
To add a new modem type:
@@ -166,6 +230,7 @@ To add a new modem type:
1. Create `src/modules/modem/analog/ModemXXX.h` and `.cpp` (or `digital/`)
2. Inherit from `ModemAnalog` or `ModemDigital`
3. Implement the pure virtual methods: `getType()`, `getName()`, `checkSampleRate()`, `buildKit()`, `disposeKit()`, `demodulate()`
4. Add a static `factory()` method
5. Register in `CubicSDR::OnInit()` in `src/CubicSDR.cpp`
6. Add to `CMakeLists.txt` source list
4. If the modem needs custom kit state, define a `ModemKitXXX` subclass in the header (inherit from `ModemKitAnalog`, `ModemKitDigital`, or `ModemKit` as appropriate)
5. Add a static `factory()` method
6. Register in `CubicSDR::OnInit()` in `src/CubicSDR.cpp` (inside `#ifdef ENABLE_DIGITAL_LAB` for digital modems)
7. Add to `CMakeLists.txt` source list (inside `IF(ENABLE_DIGITAL_LAB)` for digital modems)
+39 -11
View File
@@ -80,9 +80,9 @@ All data-carrying threads communicate via `ThreadBlockingQueue<T>`:
|-----------|----------|---------|
| `ThreadBlockingQueue<T>` | Throughout | Primary inter-thread data transfer |
| `SpinMutex` | `ThreadBlockingQueue`, `ReBuffer` | Lightweight lock for high-frequency queue operations |
| `std::atomic_bool` | SDRThread, DemodulatorPreThread, DemodulatorThread | Lock-free parameter change signaling |
| `std::recursive_mutex` | AudioThread, DemodulatorInstance, DemodulatorMgr | Protecting shared mutable state |
| `std::mutex` | IOThread queue bindings, SDRThread settings | Protecting infrequent mutations |
| `std::atomic_bool` | SDRThread, DemodulatorPreThread, DemodulatorThread, DemodulatorInstance, CubicSDR | Lock-free parameter change signaling |
| `std::recursive_mutex` | AudioThread, AudioSinkThread, DemodulatorInstance, DemodulatorMgr, BookmarkMgr | Protecting shared mutable state with re-entrant access |
| `std::mutex` | IOThread queue bindings, SDRThread settings/gains, VisualProcessor, SpectrumVisualProcessor, AppConfig, WaterfallCanvas, DigitalConsole, DemodulatorThread (squelch lock) | Protecting infrequent mutations and visualization state |
### SpinMutex
@@ -90,6 +90,26 @@ All data-carrying threads communicate via `ThreadBlockingQueue<T>`:
Non-recursive spinlock using `std::atomic_flag` with acquire/release memory ordering. Used as the internal lock for `ThreadBlockingQueue` and `ReBuffer` due to its low overhead for short critical sections.
### ReBuffer Pooling
**File:** `src/IOThread.h`
`ReBuffer<BufferType>` is a buffer pool with reference-counted recycling, used to minimize allocation overhead in hot data paths (audio output, visualization). `getBuffer()` checks each pooled `shared_ptr` — if `use_count() == 1`, the buffer is available for reuse. The first available buffer is selected (age reset to 1); other available buffers have their age decremented. Buffers that go unused age and are garbage-collected when `age < -REBUFFER_GC_LIMIT` (i.e. below -100). Used by `DemodulatorThread` (audio output buffers) and `VisualDataReDistributor` (visualization buffers).
### VisualProcessor Pipeline
**File:** `src/process/VisualProcessor.h`
`VisualProcessor<Input, Output>` is a template base class for the visualization pipeline. Each processor has one input queue and N output queues. `process()` is called by the owning thread's main loop; `distribute()` pushes results to all attached outputs. Two concrete subclasses handle different distribution strategies:
- `VisualDataDistributor` — zero-copy shared pointer forwarding
- `VisualDataReDistributor` — deep-copy distribution via `ReBuffer` pooling
Protected by `std::mutex busy_update` for queue list mutations.
### SDREnumerator One-Shot Spawning
`SDREnumerator` threads are spawned as needed (device refresh, remote add, re-enumeration) without joining the previous instance. Each call to `threadMain` performs a single enumeration pass and exits. The old thread pointer is overwritten without cleanup — a deliberate fire-and-forget pattern.
## Thread Lifecycle
### Startup Sequence
@@ -100,9 +120,10 @@ In `CubicSDR::OnInit()` (`src/CubicSDR.cpp`):
2. `SpectrumVisualDataThread` started
3. `DemodVisualDataThread` started (if enabled)
4. `SDRPostThread` started
5. `AppFrame` created (wxWidgets main window)
6. `SDREnumerator` started
7. Device selection triggers `SDRThread` start (in `CubicSDR::setDevice()`)
5. `SDREnumerator` created
6. `AppFrame` created (wxWidgets main window)
7. `SDREnumerator` thread started
8. Device selection triggers `SDRThread` start (in `CubicSDR::setDevice()`)
### Per-Demodulator Startup
@@ -116,11 +137,14 @@ When `DemodulatorInstance::run()` is called:
In `CubicSDR::OnExit()`:
1. `SDRThread::terminate()` — stops producing IQ data (waited up to 3s)
2. `SDRPostThread::terminate()` — stops channelizing (waited up to 3s)
3. `DemodulatorMgr::terminateAll()` — terminates all demodulator instances (queues flushed inside each `DemodulatorInstance::terminate()`)
4. Visual processor threads terminated
5. All threads joined
1. `RigThread::terminate()` — stops hamlib rig control (if active)
2. `SDRThread::terminate()` — stops producing IQ data (waited up to 3s)
3. `SDRPostThread::terminate()` — stops channelizing (waited up to 3s)
4. `DemodulatorMgr::terminateAll()` — terminates all demodulator instances (queues flushed inside each `DemodulatorInstance::terminate()`)
5. Visual processor threads terminated (waited up to 1s each)
6. All threads joined
If any termination step times out, the application calls `::exit()` with a platform-specific error code rather than risk hanging indefinitely.
### Per-Demodulator Shutdown
@@ -131,6 +155,10 @@ In `CubicSDR::OnExit()`:
3. `DemodulatorPreThread::terminate()` — stops resampling (also terminates worker thread)
4. All queues flushed to unblock pending pushes
### Known Issues
In `DemodulatorInstance::isTerminated()`, the macOS cleanup path for the audio thread calls `pthread_join(t_PreDemod, NULL)` instead of `pthread_join(t_Audio, NULL)`. At that point `t_PreDemod` has already been joined and set to `nullptr`, so this is a call to `pthread_join(NULL, ...)` which is undefined behavior per POSIX. The non-macOS path (`t_Audio->join()`) is correct. This is a copy-paste bug in `src/demod/DemodulatorInstance.cpp`.
## Thread Priorities (macOS)
On macOS, threads are assigned scheduling priorities: