summaryrefslogtreecommitdiff
path: root/stacks/crdop
diff options
context:
space:
mode:
authorinfo@mode42.com <info@mode42.com>2026-08-07 18:25:13 +0000
committerinfo@mode42.com <info@mode42.com>2026-08-07 18:25:13 +0000
commit04d965d67a7264a1c7c211494aebda1953df7603 (patch)
tree0ebd700a6e219f84a26a656f4bee8778bc75da7b /stacks/crdop
Initial push
Diffstat (limited to 'stacks/crdop')
-rw-r--r--stacks/crdop/.github/workflows/build.yml54
-rw-r--r--stacks/crdop/.gitignore13
-rw-r--r--stacks/crdop/CMakeLists.txt55
-rw-r--r--stacks/crdop/LICENSE22
-rw-r--r--stacks/crdop/NOTICE.md9
-rw-r--r--stacks/crdop/README.md36
-rw-r--r--stacks/crdop/ROADMAP.md445
-rw-r--r--stacks/crdop/VERSION1
-rw-r--r--stacks/crdop/cmake/CRDOPCompiler.cmake37
-rw-r--r--stacks/crdop/cmake/CRDOPPlatform.cmake71
-rw-r--r--stacks/crdop/cmake/CRDOPTests.cmake104
-rw-r--r--stacks/crdop/cmake/CRDOPVendorArdopcf.cmake127
-rw-r--r--stacks/crdop/cmake/toolchains/aarch64-linux-gnu.cmake4
-rw-r--r--stacks/crdop/cmake/toolchains/arm-linux-gnueabihf.cmake4
-rw-r--r--stacks/crdop/cmake/toolchains/x86_64-w64-mingw32.cmake4
-rw-r--r--stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md84
-rw-r--r--stacks/crdop/docs/AUDIO-ARCHITECTURE.md80
-rw-r--r--stacks/crdop/docs/BUILD.md99
-rw-r--r--stacks/crdop/docs/CHANGELOG.md25
-rw-r--r--stacks/crdop/docs/CONFIG.md50
-rw-r--r--stacks/crdop/docs/DEVELOPER.md188
-rw-r--r--stacks/crdop/docs/EXAMPLES.md91
-rw-r--r--stacks/crdop/docs/FEC-SPEC.md99
-rw-r--r--stacks/crdop/docs/G3RUH-DESIGN.md72
-rw-r--r--stacks/crdop/docs/HARDWARE-INTERFACE.md194
-rw-r--r--stacks/crdop/docs/HOST-PROTOCOL-SPEC.md103
-rw-r--r--stacks/crdop/docs/INDEX.md77
-rw-r--r--stacks/crdop/docs/LICENSE-USAGE.md116
-rw-r--r--stacks/crdop/docs/MAX25-USAGE.md292
-rw-r--r--stacks/crdop/docs/PROTOCOL.md36
-rw-r--r--stacks/crdop/docs/README.md31
-rw-r--r--stacks/crdop/docs/SOFTMODEM.md127
-rw-r--r--stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md95
-rw-r--r--stacks/crdop/include/crdop/sound_proxy.h52
-rw-r--r--stacks/crdop/include/crdop/version.h8
-rw-r--r--stacks/crdop/lib/__init__.py1
-rw-r--r--stacks/crdop/lib/acoustic_engine.py89
-rw-r--r--stacks/crdop/lib/afsk_demodulator.py56
-rw-r--r--stacks/crdop/lib/afsk_modulator.py51
-rw-r--r--stacks/crdop/lib/bell202_line_code.py50
-rw-r--r--stacks/crdop/lib/hdlc_codec.py110
-rw-r--r--stacks/crdop/lib/m25_host_protocol.py142
-rw-r--r--stacks/crdop/lib/sound_proxy.py185
-rw-r--r--stacks/crdop/lib/sound_proxy_oss.py151
-rw-r--r--stacks/crdop/lib/test_bell202_line_code.py34
-rw-r--r--stacks/crdop/patches/ardopcf/0001-crdop-linux-gcc-branding.patch186
-rw-r--r--stacks/crdop/patches/ardopcf/0002-crdop-version-override.patch18
-rwxr-xr-xstacks/crdop/scripts/apply-vendor-patches.sh36
-rwxr-xr-xstacks/crdop/scripts/build-crdop.sh48
-rwxr-xr-xstacks/crdop/scripts/crdopc171
-rwxr-xr-xstacks/crdop/scripts/import-ardopcf.sh17
-rwxr-xr-xstacks/crdop/scripts/install-crdop.sh16
-rwxr-xr-xstacks/crdop/scripts/refresh-vendor-ardopcf.sh26
-rwxr-xr-xstacks/crdop/scripts/sync-upstream.sh21
-rwxr-xr-xstacks/crdop/scripts/test-all.sh17
-rwxr-xr-xstacks/crdop/scripts/test-smoke.sh24
-rwxr-xr-xstacks/crdop/scripts/test-with-host.sh9
-rw-r--r--stacks/crdop/share/crdop-amateur.ini.example15
-rw-r--r--stacks/crdop/share/crdop-dual.ini.example15
-rw-r--r--stacks/crdop/share/crdop.freebsd.ini.example25
-rw-r--r--stacks/crdop/share/crdop.ini.example37
-rw-r--r--stacks/crdop/src/crdop_version.c5
-rwxr-xr-xstacks/crdop/tools/audio-dummyd4
-rw-r--r--stacks/crdop/tools/audio-dummyd.py66
-rwxr-xr-xstacks/crdop/tools/max25-signal-sniffer5
-rw-r--r--stacks/crdop/tools/max25-signal-sniffer.py78
-rw-r--r--stacks/crdop/vendor/README.md5
-rw-r--r--stacks/crdop/vendor/ardopcf.ref9
68 files changed, 4627 insertions, 0 deletions
diff --git a/stacks/crdop/.github/workflows/build.yml b/stacks/crdop/.github/workflows/build.yml
new file mode 100644
index 0000000..a9d6465
--- /dev/null
+++ b/stacks/crdop/.github/workflows/build.yml
@@ -0,0 +1,54 @@
+name: Build
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ linux-gcc:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install deps
+ run: sudo apt-get update && sudo apt-get install -y build-essential cmake libasound2-dev pkg-config libcmocka-dev
+ - name: Build and test
+ run: |
+ chmod +x scripts/*.sh
+ CRDOP_BUILD_TESTS=ON CRDOP_RUN_TESTS=ON ./scripts/build-crdop.sh
+
+ linux-clang:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install deps
+ run: sudo apt-get update && sudo apt-get install -y clang cmake libasound2-dev pkg-config libcmocka-dev
+ - name: Build and test (Clang)
+ run: |
+ chmod +x scripts/*.sh
+ CC=clang CRDOP_BUILD_TESTS=ON CRDOP_RUN_TESTS=ON ./scripts/build-crdop.sh
+
+ linux-aarch64-cross:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install cross toolchain
+ run: sudo apt-get update && sudo apt-get install -y cmake gcc-aarch64-linux-gnu libasound2-dev:arm64 pkg-config
+ - name: Cross-build ARM64
+ run: |
+ chmod +x scripts/*.sh
+ CRDOP_TOOLCHAIN=aarch64-linux-gnu ./scripts/build-crdop.sh
+
+ windows-cross:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install MinGW
+ run: sudo apt-get update && sudo apt-get install -y cmake mingw-w64
+ - name: Cross-build Windows x64
+ run: |
+ chmod +x scripts/*.sh
+ CRDOP_TOOLCHAIN=x86_64-w64-mingw32 ./scripts/build-crdop.sh
+ - name: Verify exe
+ run: test -x build/crdopc.exe
diff --git a/stacks/crdop/.gitignore b/stacks/crdop/.gitignore
new file mode 100644
index 0000000..715a31d
--- /dev/null
+++ b/stacks/crdop/.gitignore
@@ -0,0 +1,13 @@
+build/
+build-*/
+*.o
+*.a
+*.so
+*.exe
+*.log
+.DS_Store
+vendor/ardopcf/
+vendor/ardopcf/.crdop-patches-applied
+
+# vault/agent — never ship
+AGENT-INDEX.md
diff --git a/stacks/crdop/CMakeLists.txt b/stacks/crdop/CMakeLists.txt
new file mode 100644
index 0000000..27ab174
--- /dev/null
+++ b/stacks/crdop/CMakeLists.txt
@@ -0,0 +1,55 @@
+cmake_minimum_required(VERSION 3.16)
+# Product label CRDOP-CUR999 (pre-release until v0.5). CMake project() requires a
+# numeric VERSION; 0.0.999 is the build-time fallback. Human-facing strings live
+# in VERSION (CUR999) and include/crdop/version.h (CRDOP-CUR999 / CUR999).
+project(crdop VERSION 0.0.999 LANGUAGES C)
+
+set(CMAKE_C_STANDARD 17)
+set(CMAKE_C_STANDARD_REQUIRED ON)
+
+include(GNUInstallDirs)
+
+if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+ set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
+endif()
+
+option(CRDOP_VENDOR_ARDOPCF
+ "Dev-only: build legacy ARDOP (ardopcf) vendor — OFF by default; never installed in MAX25 releases"
+ OFF)
+
+set(CRDOP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}")
+set(VENDOR_DIR "${CRDOP_ROOT}/vendor/ardopcf")
+
+function(crdop_install_scaffold)
+ install(FILES
+ "${CRDOP_ROOT}/share/crdop.ini.example"
+ "${CRDOP_ROOT}/share/crdop-dual.ini.example"
+ "${CRDOP_ROOT}/share/crdop-amateur.ini.example"
+ "${CRDOP_ROOT}/share/crdop.freebsd.ini.example"
+ DESTINATION ${CMAKE_INSTALL_DATADIR}/crdop
+ )
+ install(FILES "${CRDOP_ROOT}/VERSION" DESTINATION ${CMAKE_INSTALL_DATADIR}/crdop)
+ install(PROGRAMS "${CRDOP_ROOT}/scripts/crdopc"
+ DESTINATION ${CMAKE_INSTALL_BINDIR} RENAME crdop)
+ install(PROGRAMS
+ "${CRDOP_ROOT}/tools/max25-signal-sniffer"
+ "${CRDOP_ROOT}/tools/audio-dummyd"
+ DESTINATION ${CMAKE_INSTALL_BINDIR}
+ )
+ install(DIRECTORY "${CRDOP_ROOT}/lib/"
+ DESTINATION ${CMAKE_INSTALL_DATADIR}/crdop/lib
+ FILES_MATCHING PATTERN "*.py")
+endfunction()
+
+if(CRDOP_VENDOR_ARDOPCF AND EXISTS "${VENDOR_DIR}/Makefile")
+ include("${CRDOP_ROOT}/cmake/CRDOPVendorArdopcf.cmake")
+ crdop_install_scaffold()
+ message(STATUS "CRDOP ${PROJECT_VERSION} → crdopc (legacy vendor/ardopcf)")
+elseif(CRDOP_VENDOR_ARDOPCF)
+ message(WARNING "CRDOP: CRDOP_VENDOR_ARDOPCF=ON but vendor/ardopcf missing — scaffold only")
+ crdop_install_scaffold()
+ message(STATUS "CRDOP: scaffold only (native modem in development)")
+else()
+ crdop_install_scaffold()
+ message(STATUS "CRDOP: scaffold only (native Eigenentwicklung; -DCRDOP_VENDOR_ARDOPCF=ON for legacy vendor build)")
+endif()
diff --git a/stacks/crdop/LICENSE b/stacks/crdop/LICENSE
new file mode 100644
index 0000000..3488377
--- /dev/null
+++ b/stacks/crdop/LICENSE
@@ -0,0 +1,22 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ CRDOP (MAX25-SoftModem) — subproject of MainAX25-Stack (MAX25-Stack)
+ Copyright (C) 2026 ngteq
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+---
+
+Full license text: [../../LICENSE](../../LICENSE) (repository root).
diff --git a/stacks/crdop/NOTICE.md b/stacks/crdop/NOTICE.md
new file mode 100644
index 0000000..43c302f
--- /dev/null
+++ b/stacks/crdop/NOTICE.md
@@ -0,0 +1,9 @@
+# Third-party notices
+
+**CRDOP (MAX25-SoftModem)** — Copyright (c) 2026 ngteq, **GNU GPL v3** ([LICENSE](LICENSE), [../../LICENSE](../../LICENSE))
+
+In-house sound-card modem subproject of MainAX25-Stack. Sound-card based; kernel ALSA via MAX25 sound-proxy. Project rule: [docs/CRDOP.md](../../docs/CRDOP.md).
+
+Operators must comply with local radio regulations.
+
+Third-party **ARDOP** host software (optional, separate from CRDOP): [plugins/external/ardop/README.md](../../plugins/external/ardop/README.md).
diff --git a/stacks/crdop/README.md b/stacks/crdop/README.md
new file mode 100644
index 0000000..aeb701d
--- /dev/null
+++ b/stacks/crdop/README.md
@@ -0,0 +1,36 @@
+# CRDOP — MAX25-SoftModem
+
+**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`).
+
+Sound-card software modem subproject of MAX25-Stack.
+
+**Stack version:** MAX25-Stack **v1.0.0** · **Dev track:** `CUR999` in `$SRC/stacks/crdop/VERSION`.
+
+**Canonical project rule:** [docs/CRDOP.md](../../docs/CRDOP.md)
+**License:** GNU GPL v3 — [LICENSE](LICENSE)
+
+## Summary
+
+| | |
+|---|---|
+| Type | MAX25-Stack **subproject** (`stacks/crdop/`) |
+| Interface | Sound card IN/OUT → kernel ALSA → MAX25 sound-proxy |
+| Goal | **AX.25 / KISS / TNC / modem compatible** software modem |
+| Mission | **Hardware solutions** (primary) + **pure software** on computers |
+| Build | Default ON (`MAX25_BUILD_CRDOP=ON`) |
+
+Read [docs/CRDOP.md](../../docs/CRDOP.md) before changing modem, audio, or plugin code.
+
+## Technical docs
+
+**Index:** [docs/INDEX.md](docs/INDEX.md)
+
+| Doc | Topic |
+|-----|--------|
+| [docs/MAX25-USAGE.md](docs/MAX25-USAGE.md) | MAX25-Stack operator guide |
+| [docs/DEVELOPER.md](docs/DEVELOPER.md) | Source tree, modules, tests |
+| [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md) | Generic radio interface spec |
+| [docs/SOFTMODEM.md](docs/SOFTMODEM.md) | Baud, duplex, acoustic AX.25 |
+| [docs/AUDIO-ARCHITECTURE.md](docs/AUDIO-ARCHITECTURE.md) | Kernel ALSA only, no PulseAudio |
+| [docs/CONFIG.md](docs/CONFIG.md) | INI / launcher |
+| [ROADMAP.md](ROADMAP.md) | P0/P1/P2 milestones |
diff --git a/stacks/crdop/ROADMAP.md b/stacks/crdop/ROADMAP.md
new file mode 100644
index 0000000..0ae1aa4
--- /dev/null
+++ b/stacks/crdop/ROADMAP.md
@@ -0,0 +1,445 @@
+# MAX25-SoftModem (CRDOP) — development roadmap
+
+> **Static rule:** **CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`). Authoritative everywhere CRDOP is named in MAX25-Stack.
+
+**Stack version:** MAX25-Stack **v1.0.0** · **Dev track:** `CUR999` in `$SRC/stacks/crdop/VERSION`
+
+**Stack dependency:** CRDOP sources live in `stacks/crdop/` and are **standalone-capable** in principle, but **MAX25-Stack is required for integration** (build, `max25d`, INI, operator tooling) until **CRDOP-v1.0.0** (or later) marks a mature standalone release. Current stack product: **MAX25-Stack-v1.0.0**.
+
+**Nature:** MAX25 in-house development — open development and test program for the kernel-ALSA sound-card modem. CRDOP is the standard modem; ARDOP is a separate optional plugin.
+
+**Research synthesis:** 2026-07-13 — modem and integration priorities synthesized into this ROADMAP and linked in-repo docs below. **No duplicate re-copy** of external notes into this tree.
+
+---
+
+## Protocol taxonomy (research catalog)
+
+| Class | Description | CRDOP relation |
+|-------|-------------|----------------|
+| **A** | Classic AX.25 packet (HDLC, frequency-toggle line code) | **P0/P1/P2 targets** — 1200 AFSK, G3RUH, 300 HF |
+| **B** | AX.25 + FEC extension | **Reference** — FX.25 informs FEC block design |
+| **C** | New L2 (not AX.25 on-air) | **Reference** — IL2P lessons (no bit-stuffing, RS FEC) |
+| **D** | HF/VHF data modems (Winlink ecosystem) | **Out of scope** on-air — separate ARDOP-plugin registry |
+| **E** | Keyboard/chat modes (PSK31, RTTY, …) | **Out of scope** — boundary only |
+
+---
+
+## MAX25-Stack integration (until CRDOP-v1.0.0+)
+
+| Topic | Policy |
+|-------|--------|
+| **Source tree** | All CRDOP sources in `stacks/crdop/` — standalone CMake possible; **operator integration via MAX25-Stack** until mature **CRDOP-v1.0.0** (or later) |
+| **Build** | `MAX25_BUILD_CRDOP=ON` default — installs launcher, `lib/`, tools, INI |
+| **Daemon** | `max25d` → `CrdopTcpBackend` / `AudioDummyBackend` — see [docs/MAX25-USAGE.md](docs/MAX25-USAGE.md) |
+| **AX.25 runtime** | Native `ax25_codec.py` in MAX25 — no libax25 bundle in releases |
+| **HyBBX** | External consumer — serial ownership boundary; CRDOP via host TCP attach |
+| **Docs entry** | [docs/INDEX.md](docs/INDEX.md) · project rule [docs/CRDOP.md](../../docs/CRDOP.md) |
+
+HyBBX boundary: [docs/HYBBX.md](../../docs/HYBBX.md) · [docs/PLUGINS-DEVICE-MODEL.md](../../docs/PLUGINS-DEVICE-MODEL.md)
+
+---
+
+## MAX25-Stack DEV-Level gate
+
+> Stack DEV-Levels are approximate (*ca.*) — see [docs/V2.0.0-SCOPE.md](../../docs/V2.0.0-SCOPE.md#dev-levels-roadmap-stack-wide). CRDOP modulation milestones (P0/P1/P2 below) are **subproject delivery** labels, not stack DEV-Levels.
+
+**DEV-Level 1 (*ca.* current — stack priority):** modular TCP/IP + Linux/FreeBSD platform compat. CRDOP work in DEV-Level 1 is **minimal/native only** — existing backend, bench DSP, enough OSS path for FreeBSD TCP/IP hub. **Do not expand CRDOP scope** ahead of DEV-Level 1.
+
+**DEV-Level 4 (*ca.*):** CRDOP expansion (OSS polish, G3RUH, acoustic validation, hardware docs). Modulation milestones below remain valid but are **scheduled for DEV-Level 4** unless required to unblock DEV-Level 1 platform work.
+
+**WebSocket** is **DEV-Level 3** — separate from CRDOP expansion.
+
+Stack roadmap: [docs/V2.0.0-SCOPE.md](../../docs/V2.0.0-SCOPE.md#dev-levels-roadmap-stack-wide).
+
+---
+
+## Current phase: development and test
+
+| Area | Status |
+|------|--------|
+| MAX25-Stack standard build/install (`MAX25_BUILD_CRDOP=ON`) | **Done** |
+| `max25d` `crdop-tcp` backend + offline tests | **Done** |
+| INI scaffold, launcher (`scripts/crdopc`), HyBBX attach examples | **Done** |
+| Native modem DSP — 1200 baud Bell 202 AFSK (`stacks/crdop/lib/`) | **In progress** |
+| Acoustic AX.25 validation vs reference 1200 AFSK paths | **In progress** |
+| Half- and full-duplex operator paths + FEC strategy | **In progress** |
+| Open hardware interface documentation | **In progress** |
+| G3RUH 9600–19200 (direct FSK rig path) | **Planned** (DEV-Level 4 / P1) |
+| 300 baud HF AFSK | **Optional** (P2) |
+| Speeds >19200 baud | **Out of scope** |
+
+---
+
+## Modulation priority matrix
+
+Synthesized from research catalog — implementation order for CRDOP native DSP.
+
+| Priority | Mode | Modulation | Baud | RF path | Bands | CRDOP target |
+|----------|------|------------|------|---------|-------|--------------|
+| **P0** | AX.25 Packet | **AFSK Bell 202** (1200/2200 Hz) | **1200** | Audio AFSK (Mic/Spkr/Line) | **CB, VHF/UHF** | **Primary on-air** |
+| **P0** | FEC + duplex profiles | Short frames, frequent TX | 1200 | same | CB, VHF | **Strategy** (see below) |
+| **P1** | AX.25 Packet | **G3RUH FSK/GMSK** (scrambled baseband) | **9600–19200** | **Direct FSK** (varactor + discriminator) | VHF/UHF backbone | **Hard max 19200** |
+| **P2** | AX.25 Packet | AFSK/FSK tone pairs | **300** | SSB audio (USB/LSB) | HF | **Optional** |
+| — | 2400/4800 AFSK | V.26/V.27 class | 2400–4800 | Audio | VHF | Low priority incremental |
+| 📋 | FX.25 / IL2P | FEC wrappers / new L2 | 1200+ | Audio or FSK PHY | VHF/HF | **Reference only** — inform FEC design |
+| ❌ | VARA / PACTOR / WINMOR | Proprietary DSP | various | Audio / SSB / hardware | HF/VHF/CB | **Out of scope** |
+| 📋 | ARDOP | 4FSK/PSK/QAM Winlink modes | adaptiv | Soundcard SSB | HF/VHF | **Separate plugin** — not CRDOP |
+
+**Rule:** CRDOP on-air goal is **AX.25-compatible PHY + framing** at P0/P1/P2. Winlink-class protocols (VARA, PACTOR, ARDOP OTA) are **not** CRDOP targets.
+
+---
+
+## RF path matrix
+
+What CRDOP implements per physical interface — hardware choice drives baud ceiling.
+
+| RF path | Connection | Practical max baud | CB | Amateur (VHF/UHF) | Amateur (HF) | CRDOP milestone |
+|---------|------------|-------------------|-----|-------------------|--------------|-----------------|
+| **Audio AFSK** | Mic IN / Speaker OUT / Line | ~4800 theory; **1200 field** | ✅ K24/K25 | ✅ APRS, digipeater access | — | **P0 bench–on-air** |
+| **Audio wide** | Line, de-emphasis off | up to ~9600 (difficult) | rare | experimental | — | not primary |
+| **Direct FSK** | Varactor TX + FM discriminator RX | **9600–19200** | ❌ | ✅ backbone, sat | ❌ | **P1** (DEV-Level 4) |
+| **SSB audio** | USB/LSB suppressed carrier | **300** packet; kHz-BW modes separate | ❌ | 10 m APRS exception | ✅ | **P2 optional** |
+
+**CB note:** 9600+ G3RUH requires direct RF modulation and wide IF — standard CB FM rigs are **not** 9k6-ready. CRDOP does **not** target high baud on 27 MHz.
+
+---
+
+## Band context
+
+### CB (27 MHz CEPT)
+
+| Item | Detail |
+|------|--------|
+| **Primary channel** | **K24** (27.235 MHz) — Packet QSO, APRS-like traffic |
+| **Net / forward** | **K25** (27.245 MHz) — nets, data traffic; proprietary FM-narrow DSP modes appear in practice (reference only — not CRDOP OTA) |
+| **Legal (brief)** | EN 300 433 / ECC (11)03 — voice service primary; FM max **4 W** ERP typical; **data tolerated or explicit** on K24/K25 in several EU administrations. Operator responsibility. |
+| **CRDOP mode** | **1200 AFSK Bell 202**, half-duplex, `persist=255`, short UI frames |
+| **Why not 9600+** | CB rigs lack direct FSK port and wide-IF path; FM voice channel ≈3 kHz — fits 1200/2200 Hz, not G3RUH |
+
+### VHF/UHF (2 m, 70 cm, …)
+
+| Application | Baud | Modulation | CRDOP |
+|-------------|------|------------|-------|
+| **APRS**, packet BBS, digipeater **access** | 1200 | AFSK Bell 202 | **P0** — acoustic/line |
+| Digipeater **backbone**, satellite | 9600–19200 | G3RUH FSK | **P1** — requires data-port radio |
+| IL2P | 1200/2400 | BPSK/QPSK audio | 📋 reference |
+| FX.25 | 1200+ | AX.25 + RS FEC wrapper | 📋 reference for FEC block sizing |
+
+### HF (160 m – 10 m)
+
+| Item | Detail |
+|------|--------|
+| **Standard packet** | **300 baud** AFSK/FSK, **200 Hz shift** |
+| **Common tone pairs** | 1600/1800 Hz (common default), 2110/2310 Hz, others — dial frequency must match pair |
+| **10 m exception** | 1200 AFSK on FM/USB for APRS — same PHY as VHF |
+| **Winlink boundary** | Proprietary HF mail modems = **separate protocol ecosystems** — not CRDOP OTA goals |
+| **CRDOP** | **P2 optional** — 300 bd after P0/P1 stable; SSB USB/LSB audio path |
+
+### Satellite / space (reference)
+
+| Application | Baud | Modulation | CRDOP |
+|-------------|------|------------|-------|
+| Legacy FM packet | 1200 | AFSK Bell 202 | P0 PHY same |
+| Pacsat / wide-IF links | 9600–38400+ | G3RUH FSK/GMSK | P1 extended; >19200 out of scope |
+
+---
+
+## Layer model
+
+CRDOP splits **host integration** from **on-air PHY**. Code mapping to `stacks/crdop/lib/`:
+
+```
+Application (APRS, BBS, HyBBX, max25-terminal)
+ ↓
+AX.25 v2.0 (UI / I-frames) ← stacks/daemon/ax25_codec.py
+ ↓
+HDLC + frequency-toggle + CRC-16 ← hdlc_codec.py (+ bell202_line_code.py)
+ ↓
+Modem PHY (AFSK / future G3RUH) ← afsk_modulator.py, afsk_demodulator.py
+ ↓
+MAX25 sound-proxy → kernel ALSA ← sound_proxy.py (+ include/crdop/sound_proxy.h scaffold)
+ ↓
+Radio (FM audio / direct FSK / SSB)
+```
+
+| Layer | Responsibility | Module / tool |
+|-------|----------------|---------------|
+| **L2 AX.25** | Addresses, UI body, FCS semantics | `ax25_codec.py` (daemon), used by `acoustic_engine.py` |
+| **Framing** | HDLC flags `0x7E`, bit-stuffing, CRC-16-CCITT | `hdlc_codec.py` |
+| **Line code** | Bell 202 frequency-toggle (bit 0 → tone change) | `bell202_line_code.py` |
+| **PHY 1200** | Continuous-phase AFSK 1200/2200 Hz, Goertzel demod | `afsk_modulator.py`, `afsk_demodulator.py` |
+| **PHY 9k6+** | G3RUH scrambler + FSK — **not yet in lib/** | planned P1 (DEV-Level 4) |
+| **PHY 300 HF** | 200 Hz shift tone pairs — **not yet in lib/** | planned P2 optional |
+| **Audio I/O** | ALSA capture/playback, no PulseAudio | `sound_proxy.py` |
+| **Bench integration** | Loopback encode/decode, sniffer | `acoustic_engine.py` |
+| **Host TCP** | M25-family ctrl + KISS-semantics data | `m25_host_protocol.py` → `audio-dummyd` |
+| **Operator tools** | Sniff, calibrate mark/space | `tools/max25-signal-sniffer` |
+
+**Host vs on-air:** `m25_host_protocol.py` carries AX.25 UI **without** HDLC on the data port; `acoustic_engine.encode_ax25_ui()` builds full on-air HDLC + AFSK. Do not confuse MAX25 TCP host with ARDOP FEC/ARQ wire modes.
+
+---
+
+## FEC, duplex & retransmission strategy
+
+**Leitprinzip:** *Lieber öfter senden als oft lange.*
+
+Packet on CB/VHF (poor SNR, QRM, half-duplex collisions) favours **short UI frames** and **frequent repetition** over long payloads and heavy block FEC.
+
+### Half-duplex (CB default)
+
+| Aspect | Strategy |
+|--------|----------|
+| Channel access | CSMA + persist — CB: **`persist=255`** |
+| TX slot | Short UI frames; minimal but stable TXDELAY/TXTAIL |
+| FEC | Shorter codewords; optional 2–3 repeats vs one large block |
+| Retransmit | Application/beacon: **send more often** with small payloads (≤128 B info) |
+| PTT | Explicit — VOX off on CB |
+| INI | `duplex = half`, `EXTRADELAY 150` (CB profile) |
+
+### Full-duplex
+
+| Aspect | Strategy |
+|--------|----------|
+| Channel | Simultaneous TX/RX — no CSMA |
+| FEC | Stronger codes possible when bandwidth allows |
+| Audio | Echo suppression / separate paths — full-duplex sound card |
+| INI | `duplex = full`, `EXTRADELAY 0` |
+
+### FEC layers
+
+| Level | Mechanism | CRDOP status |
+|-------|-----------|--------------|
+| **L1 modem** | CRC-16-CCITT (mandatory), preamble sync | **Done** in `hdlc_codec.py` |
+| **L1 optional** | Lightweight FEC for short blocks | **Planned** — offline AWGN simulator |
+| **L2 AX.25** | UI = no ARQ; I-frames = host may ARQ | Host policy |
+| **Application** | Frequent beacons, staggered dual-TNC | HyBBX / max25d INI |
+| **Reference** | FX.25 (RS wrapper), IL2P (new L2) | Inform design; not mandatory on-air |
+
+### FEC design takeaways (see [docs/FEC-SPEC.md](docs/FEC-SPEC.md))
+
+| Source | Lesson for CRDOP |
+|--------|------------------|
+| **FX.25** | Reed-Solomon FEC **wrapper** around AX.25 — backward-compatible idea; AX.25 bit-stuffing weakens naive FEC |
+| **IL2P** | Eliminates bit-stuffing; packet-sync LFSR scramble; RS FEC — **reference** for future optional L2, not P0 on-air |
+| **Operator** | UI frames have no L2-ARQ — reliability via **short payloads + frequent TX** before heavy block FEC |
+| **VARA / IL2P DSP** | Multi-stage FEC and adaptive rate — **reference for DSP strategy only**, not protocol target |
+
+### Host-protocol alignment
+
+| Component | Status |
+|-----------|--------|
+| Spec | **[HOST-PROTOCOL-SPEC.md](docs/HOST-PROTOCOL-SPEC.md)** — frozen v1.0.0 |
+| `max25d CrdopTcpBackend` (default) | `PROTOCOLMODE KISS`, `[CRDOP AX25 UI …]` display |
+| `m25_host_protocol.py` | Defaults **8515/8516** — matches `crdopc` INI |
+| `audio-dummyd` bench | Same ports via CLI; `audio-dummy` max25d **host** mode may use **8520** when :8515 busy |
+
+---
+
+## Implementation status (`stacks/crdop/lib/`)
+
+Honest mapping — 2026-07-13.
+
+| Component | File / tool | Status | Notes |
+|-----------|-------------|--------|-------|
+| Bell 202 line code | `bell202_line_code.py` | **Done** | Mark 1200 / Space 2200; frequency-toggle encode/decode |
+| Line code tests | `test_bell202_line_code.py` | **Done** | Round-trip unit tests |
+| AFSK modulator | `afsk_modulator.py` | **Done** | Continuous-phase PCM @ 48 kHz default |
+| AFSK demodulator | `afsk_demodulator.py` | **Done** | Per-symbol Goertzel demod |
+| HDLC + CRC | `hdlc_codec.py` | **Done** | Flags, bit-stuff, CRC-16 via `ax25_crc` |
+| Sound-proxy (Python) | `sound_proxy.py` | **In progress** | `arecord`/`aplay` subprocess path; C header scaffold separate |
+| Acoustic bench engine | `acoustic_engine.py` | **In progress** | Full loopback UI→HDLC→AFSK→demod→parse; RF path not closed |
+| M25 host protocol | `m25_host_protocol.py` | **In progress** | TCP ctrl/data for bench; port alignment with `max25d` pending |
+| `audio-dummyd` | `tools/audio-dummyd.py` | **In progress** | M25 host + `AcousticEngine`; default launcher target |
+| `max25-signal-sniffer` | `tools/max25-signal-sniffer.py` | **In progress** | ALSA/WAV/loopback Bell 202 analysis |
+| G3RUH 9600–19200 | — | **Planned** | No module yet; direct FSK radio required |
+| 300 HF AFSK | — | **Planned** | Optional P2 |
+| FEC codec (short blocks) | — | **Planned** | Offline inject-bit-error tests first |
+| `speed_baud` INI → launcher | — | **Not implemented** | Research gap |
+| Native C `crdopc` DSP binary | `include/crdop/` | **Scaffold** | Python lib leads; C port follows validation |
+| RF automated CI | — | **Planned** | Manual acoustic/TNC tests today |
+
+---
+
+## Milestones — modulation delivery (P0 / P1 / P2)
+
+> CRDOP subproject milestones — distinct from stack [DEV-Levels](../../docs/V2.0.0-SCOPE.md#dev-levels-roadmap-stack-wide). P0/P1/P2 work lands primarily in **DEV-Level 4** except bench/minimal path in DEV-Level 1.
+
+### P0 bench — Acoustic bench (current)
+
+**Goal:** Prove Bell 202 + HDLC + AX.25 UI in software loopback and ALSA bench — no RF required.
+
+| Deliverable | Status |
+|-------------|--------|
+| `bell202_line_code` + `afsk_*` + `hdlc_codec` unit path | Done |
+| `acoustic_engine.loopback_self_test()` | Done |
+| `max25-signal-sniffer --loopback` | In progress |
+| `audio-dummyd` + `audio-dummy` max25d device | In progress |
+| Document acceptable sound cards (community list) | Planned |
+| Host protocol single definition (launcher ↔ max25d) | Planned |
+
+**Exit criteria:** Stable loopback decode; sniffer reports AX.25 UI from generated PCM; `test_crdop_backend.py` green.
+
+### P0 on-air — CB/VHF 1200 on-air
+
+**Goal:** Field-validated 1200 AFSK interoperable with standard AX.25 packet peers on CB and VHF APRS-class paths.
+
+| Deliverable | Status |
+|-------------|--------|
+| Acoustic and line-level coupling validation | In progress |
+| Line interface to FM rig (CB channels) | Planned |
+| Half-duplex PTT timing in INI + launcher | In progress |
+| FEC strategy: short frames, beacon stagger | In progress |
+| Hardware interface guide (generic spec) | In progress — [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md) |
+| Decode parity vs reference 1200 AFSK captures | Planned |
+
+**Exit criteria:** Verified AX.25 UI exchange with at least one reference 1200 AFSK path (acoustic or line-level).
+
+### P1 — G3RUH 9600–19200
+
+**Goal:** Direct-FSK VHF/UHF backbone rates up to **19200 baud** hard maximum.
+
+| Deliverable | Status |
+|-------------|--------|
+| G3RUH scrambler + FSK mod/demod module | Planned |
+| Data-port radio integration docs | Planned |
+| Wide-IF rig requirements checklist | Planned |
+| Loopback with recorded IQ/baseband fixtures | Planned |
+
+**Prerequisite:** P0 on-air stable; radio with suitable data port and wide-IF path for direct FSK.
+
+**Exit criteria:** Scrambler-compatible exchange with G3RUH reference captures or peer at 9600 minimum.
+
+### P2 — HF 300 baud (optional)
+
+**Goal:** SSB USB/LSB 300 bd packet — 1600/1800 Hz pair first.
+
+| Deliverable | Status |
+|-------------|--------|
+| 300 bd demod/mod (200 Hz shift) | Planned |
+| Tone-pair INI selection | Planned |
+| HF dial-frequency calculator notes | Planned |
+
+**Scope:** Optional; does not block CRDOP-v0.5 if deferred.
+
+---
+
+## Validation targets (generic)
+
+Interop validation uses **reference signal classes**, not product endorsements.
+
+| Reference class | Mode | Interface | CRDOP milestone |
+|-----------------|------|-----------|-----------------|
+| **1200 AFSK hardware modem** | Bell 202 | Serial KISS or acoustic | P0 bench–on-air |
+| **Kernel bit-bang modem** | 1200 AFSK | Parallel/serial | P0 bench tone reference |
+| **Mobile KISS → audio** | 1200 AFSK | Bluetooth/audio bridge | P0 on-air mobile |
+| **G3RUH FSK modem** | 4800–19200 | Direct FSK data port | P1 golden reference |
+| **Multi-mode HF TNC** | 300 HF + 1200 VHF | Multi-mode serial | P2 optional |
+| **Sound-card reference decoder** | 300–9600+ | ALSA | Software decode parity (1200) |
+
+Open-hardware documentation: [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md). Audio QA: [docs/SOUNDCARD-QUALIFICATION.md](docs/SOUNDCARD-QUALIFICATION.md).
+
+---
+
+## Technical goals (unchanged intent)
+
+1. **1200 baud Bell 202** — reference AX.25 AFSK; primary on-air target (P0).
+2. **Acoustic compatibility** — verifiable against standard 1200 AFSK tone paths.
+3. **Hardware modem parity** — sound IN/OUT + radio usable like serial/KISS hardware in `max25d`.
+4. **Documented DIY hardware** — generic interface spec for builders ([HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md)).
+5. **Good sound card enforcement** — kernel ALSA direct; MAX25 sound-proxy API; requirements scale with baud.
+6. **G3RUH to 19200** — incremental after 1200 stable (P1).
+7. **FEC via operator strategy** — frequent short sends before heavy on-air FEC (P0).
+
+---
+
+## Not in MAX25 releases
+
+| Item | Policy |
+|------|--------|
+| `vendor/ardopcf` dev tree | Local dev only (`CRDOP_VENDOR_ARDOPCF=ON`); not in release install |
+| **libax25 / ax25-tools / ax25-apps** bundles | Reference tarballs only — not shipped as CRDOP dependency |
+| VARA / PACTOR / WINMOR implementations | Out of scope |
+| Speeds **>19200 baud** | Out of scope (current phase) |
+
+---
+
+## Next release checklist (CRDOP-v0.5 target)
+
+- [ ] Loopback + acoustic bench test automation in CI
+- [ ] Published acceptable sound-card list (community + lab)
+- [ ] Native `crdopc` / `audio-dummyd` path unified in default build
+- [ ] Field validation on CB reference channel (K24)
+- [ ] Hardware interface guide — [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md)
+- [ ] Host protocol alignment (`m25_host_protocol` ↔ `max25d` backend)
+- [ ] `speed_baud` INI wired to launcher
+
+---
+
+## Research synthesis (document map)
+
+Synthesis **2026-07-13** — topics below are covered in this ROADMAP and in-repo CRDOP docs. Site-specific operator INI and lab hardware runbooks stay outside this tree.
+
+| Source topic | Synthesized in this ROADMAP |
+|----------------|----------------------------|
+| `reference/modems-packet-radio/MASTER-SOLUTIONS-CATALOG.md` | Taxonomy, baud table, RF matrix, CRDOP priorities |
+| `cb-packet-solutions.md` | CB band context, K24/K25, legal, 1200-only rationale |
+| `amateur-vhf-uhf-solutions.md` | VHF/UHF table, APRS, G3RUH backbone |
+| `amateur-hf-packet-solutions.md` | HF 300 bd, Winlink boundary |
+| `l2-fec-protocols.md` | FEC layers, FX.25/IL2P takeaways |
+| `modern-softmodems.md` | Class D out-of-scope, decision matrix (generic) |
+| `bell-202-afsk-1200.md` | P0 PHY parameters, P0 bench–on-air |
+| `g3ruh-fsk-9600-19200.md` | P1 direct FSK, DEV-Level 4 |
+| `hf-300-baud-afsk.md` | P2 tone pairs, SSB path |
+| `nrzi-hdlc-ax25-layers.md` | Layer model + `lib/` mapping |
+| `MODULATION-REFERENCE.md` | Modulation priority matrix |
+| `soundcard-requirements.md` | ALSA requirements, baud scaling |
+| `acoustic-coupling-hardware.md` | → [HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md) |
+| `projects/max25-stack/2026-07-13-crdop-development-master.md` | Product scope, MAX25 integration |
+| `projects/max25-stack/2026-07-13-crdop-fec-duplex-strategy.md` | FEC/duplex section |
+| `reference/2026-07-12-cb-eu-packet.md` | CB legal/channel detail |
+| `reference/2026-07-12-packet-radio-fundamentals.md` | Background — no CRDOP code impact |
+| `reference/2026-07-12-modems-linux.md` | Linux sound-modem landscape — reference |
+| `reference/2026-07-12-linux-ax25-ecosystem.md` | Kernel AX.25 deprecation context |
+| `projects/integration/2026-07-12-max25-hybbx-boundary-final.md` | HyBBX attach / serial ownership |
+| `DEVELOPMENT.md` (operator P0–P2) | Parallel to stack release — see [docs/DEVELOPER.md](docs/DEVELOPER.md) |
+
+**Not in CRDOP tree:** site TNC hardware runbooks, production INI, serial acceptance logs — operator-local only.
+
+### Scenario → CRDOP mode (generic, from research)
+
+| Scenario | CRDOP mode |
+|----------|------------|
+| CB packet QSO (FM audio) | **P0** — 1200 AFSK AX.25 |
+| CB net with heavy QRM | **P0** + short-frame FEC strategy; proprietary FM-narrow DSP = separate protocol |
+| VHF APRS / digipeater access | **P0** — 1200 AFSK |
+| VHF/UHF backbone | **P1** — G3RUH direct FSK (≤19200) |
+| HF packet 300 bd | **P2** optional |
+| HF mail / Winlink-class | **Out of scope** — separate protocols |
+| MAX25 RF broadcast / soft-modem path | **P0** target — native CRDOP in stack |
+
+---
+
+## In-repo technical docs
+
+| Document | Topic |
+|----------|--------|
+| [docs/INDEX.md](docs/INDEX.md) | Documentation index |
+| [docs/SOFTMODEM.md](docs/SOFTMODEM.md) | Product definition |
+| [docs/MAX25-USAGE.md](docs/MAX25-USAGE.md) | MAX25-Stack operator guide |
+| [docs/DEVELOPER.md](docs/DEVELOPER.md) | Developer guide |
+| [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md) | Generic radio interface spec |
+| [docs/LICENSE-USAGE.md](docs/LICENSE-USAGE.md) | GPLv3 private + commercial |
+| [docs/HOST-PROTOCOL-SPEC.md](../stacks/crdop/docs/HOST-PROTOCOL-SPEC.md) | M25 host wire (frozen) |
+| [docs/ACOUSTIC-TEST-PROTOCOL.md](../stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md) | P0 bench |
+| [docs/FEC-SPEC.md](../stacks/crdop/docs/FEC-SPEC.md) | FEC/duplex parameters |
+| [docs/SOUNDCARD-QUALIFICATION.md](../stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md) | Audio interface QA |
+| [docs/G3RUH-DESIGN.md](../stacks/crdop/docs/G3RUH-DESIGN.md) | P1 — 9600–19200 direct FSK |
+| [docs/CRDOP.md](../../docs/CRDOP.md) | Static project rule |
+| [docs/AUDIO-ARCHITECTURE.md](docs/AUDIO-ARCHITECTURE.md) | Kernel ALSA, sound-proxy |
+| [docs/PROTOCOL.md](docs/PROTOCOL.md) | Host TCP interface |
+| [docs/CONFIG.md](docs/CONFIG.md) | INI keys |
+
+---
+
+*Roadmap synthesized 2026-07-13 from modem catalog and MAX25 integration notes. Update when DEV-Level gates close or modulation priorities change.*
+.*
diff --git a/stacks/crdop/VERSION b/stacks/crdop/VERSION
new file mode 100644
index 0000000..2f5e876
--- /dev/null
+++ b/stacks/crdop/VERSION
@@ -0,0 +1 @@
+CUR999
diff --git a/stacks/crdop/cmake/CRDOPCompiler.cmake b/stacks/crdop/cmake/CRDOPCompiler.cmake
new file mode 100644
index 0000000..e1aab37
--- /dev/null
+++ b/stacks/crdop/cmake/CRDOPCompiler.cmake
@@ -0,0 +1,37 @@
+# CRDOP compiler detection and warning flags (GCC, Clang, MSVC)
+
+if(CMAKE_C_COMPILER_ID STREQUAL "GNU")
+ set(CRDOP_COMPILER "gcc")
+elseif(CMAKE_C_COMPILER_ID MATCHES "Clang|AppleClang")
+ set(CRDOP_COMPILER "clang")
+elseif(MSVC)
+ set(CRDOP_COMPILER "msvc")
+else()
+ set(CRDOP_COMPILER "${CMAKE_C_COMPILER_ID}")
+endif()
+
+message(STATUS "CRDOP compiler=${CRDOP_COMPILER} (${CMAKE_C_COMPILER_ID} ${CMAKE_C_COMPILER_VERSION})")
+
+function(crdop_set_compile_options target)
+ if(MSVC)
+ target_compile_options(${target} PRIVATE /W3 /utf-8)
+ else()
+ target_compile_options(${target} PRIVATE -Wall -Wextra -Wno-unused-parameter)
+ if(CRDOP_COMPILER STREQUAL "clang")
+ target_compile_options(${target} PRIVATE
+ -Wno-gnu-zero-variadic-macro-arguments
+ -Wno-deprecated-non-prototype
+ )
+ endif()
+ if(CMAKE_BUILD_TYPE STREQUAL "Release")
+ target_compile_options(${target} PRIVATE -O2)
+ endif()
+ endif()
+endfunction()
+
+include(CheckLinkerFlag)
+if(NOT WIN32 AND NOT APPLE)
+ check_linker_flag(C "-Wl,--wrap=malloc" CRDOP_LINKER_SUPPORTS_WRAP)
+else()
+ set(CRDOP_LINKER_SUPPORTS_WRAP FALSE)
+endif()
diff --git a/stacks/crdop/cmake/CRDOPPlatform.cmake b/stacks/crdop/cmake/CRDOPPlatform.cmake
new file mode 100644
index 0000000..9d78683
--- /dev/null
+++ b/stacks/crdop/cmake/CRDOPPlatform.cmake
@@ -0,0 +1,71 @@
+# CRDOP platform / architecture detection (standalone project)
+#
+# Tested: Linux, *BSD, Windows, macOS — GCC and Clang/LLVM
+
+if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64)$")
+ set(CRDOP_ARCH "aarch64")
+elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(armv7|armv7l|arm)$")
+ set(CRDOP_ARCH "arm32")
+elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(i[3-6]86)$")
+ set(CRDOP_ARCH "x86")
+elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$")
+ set(CRDOP_ARCH "x86_64")
+else()
+ set(CRDOP_ARCH "${CMAKE_SYSTEM_PROCESSOR}")
+endif()
+
+set(CRDOP_PLATFORM_INCLUDES "")
+set(CRDOP_PLATFORM_LIBS "")
+set(CRDOP_PLATFORM_SOURCES "")
+
+if(WIN32)
+ set(CRDOP_PLATFORM "windows")
+ set(CRDOP_PLATFORM_SOURCES
+ src/windows/Waveout.c
+ lib/hid/hid.c
+ )
+ set(CRDOP_PLATFORM_LIBS wsock32 winmm setupapi ws2_32)
+elseif(APPLE)
+ set(CRDOP_PLATFORM "darwin")
+ set(CRDOP_PLATFORM_SOURCES
+ src/linux/ALSASound.c
+ src/linux/LinSerial.c
+ src/darwin/alsa_shim.c
+ )
+ list(APPEND CRDOP_PLATFORM_INCLUDES "${VENDOR_DIR}/src")
+ find_library(CRDOP_COREAUDIO_LIB CoreAudio REQUIRED)
+ find_library(CRDOP_AUDIOTOOLBOX_LIB AudioToolbox REQUIRED)
+ find_library(CRDOP_COREFOUNDATION_LIB CoreFoundation REQUIRED)
+ set(CRDOP_PLATFORM_LIBS
+ ${CRDOP_COREAUDIO_LIB}
+ ${CRDOP_AUDIOTOOLBOX_LIB}
+ ${CRDOP_COREFOUNDATION_LIB}
+ pthread m
+ )
+elseif(CMAKE_SYSTEM_NAME STREQUAL "FreeBSD")
+ set(CRDOP_PLATFORM "freebsd-oss")
+ set(CRDOP_PLATFORM_SOURCES
+ src/linux/LinSerial.c
+ )
+ set(CRDOP_PLATFORM_LIBS pthread m)
+elseif(CMAKE_SYSTEM_NAME MATCHES "OpenBSD|NetBSD|DragonFly")
+ set(CRDOP_PLATFORM "bsd-oss")
+ set(CRDOP_PLATFORM_SOURCES
+ src/linux/LinSerial.c
+ )
+ set(CRDOP_PLATFORM_LIBS pthread m)
+elseif(UNIX)
+ set(CRDOP_PLATFORM "linux")
+ set(CRDOP_PLATFORM_SOURCES
+ src/linux/ALSASound.c
+ src/linux/LinSerial.c
+ )
+ find_package(PkgConfig REQUIRED)
+ pkg_check_modules(ALSA REQUIRED alsa)
+ set(CRDOP_PLATFORM_INCLUDES ${ALSA_INCLUDE_DIRS})
+ set(CRDOP_PLATFORM_LIBS ${ALSA_LIBRARIES} rt pthread m)
+else()
+ message(FATAL_ERROR "CRDOP: unsupported system ${CMAKE_SYSTEM_NAME}")
+endif()
+
+message(STATUS "CRDOP platform=${CRDOP_PLATFORM} arch=${CRDOP_ARCH} system=${CMAKE_SYSTEM_NAME}")
diff --git a/stacks/crdop/cmake/CRDOPTests.cmake b/stacks/crdop/cmake/CRDOPTests.cmake
new file mode 100644
index 0000000..64b6b48
--- /dev/null
+++ b/stacks/crdop/cmake/CRDOPTests.cmake
@@ -0,0 +1,104 @@
+# Upstream ardopcf cmocka unit tests (Unix + GNU/Clang ld --wrap)
+
+option(CRDOP_BUILD_TESTS "Build and register upstream ardopcf unit tests" OFF)
+
+if(NOT CRDOP_BUILD_TESTS)
+ return()
+endif()
+
+if(WIN32 OR APPLE)
+ message(STATUS "CRDOP_BUILD_TESTS: skipped on ${CRDOP_PLATFORM} (cmocka/--wrap needs Unix GNU or lld linker)")
+ return()
+endif()
+
+find_package(PkgConfig QUIET)
+if(PkgConfig_FOUND)
+ pkg_check_modules(CMOCKA QUIET cmocka)
+endif()
+
+if(NOT CMOCKA_FOUND)
+ find_path(CMOCKA_INCLUDE_DIR cmocka.h)
+ find_library(CMOCKA_LIBRARY cmocka)
+ if(CMOCKA_INCLUDE_DIR AND CMOCKA_LIBRARY)
+ set(CMOCKA_FOUND TRUE)
+ set(CMOCKA_INCLUDE_DIRS "${CMOCKA_INCLUDE_DIR}")
+ set(CMOCKA_LIBRARIES "${CMOCKA_LIBRARY}")
+ endif()
+endif()
+
+if(NOT CMOCKA_FOUND)
+ message(WARNING "CRDOP_BUILD_TESTS: libcmocka not found — install cmocka dev package (see docs/BUILD.md)")
+ return()
+endif()
+
+if(NOT CRDOP_LINKER_SUPPORTS_WRAP)
+ message(WARNING "CRDOP_BUILD_TESTS: linker does not support --wrap; unit tests disabled")
+ return()
+endif()
+
+enable_testing()
+
+set(CRDOP_VENDOR_TEST_DIR "${VENDOR_DIR}/test/ardop")
+set(CRDOP_VENDOR_TEST_COMMON
+ "${CRDOP_VENDOR_TEST_DIR}/setup.c"
+)
+
+function(crdop_add_vendor_test name)
+ cmake_parse_arguments(ARG "" "" "SOURCES;WRAP" ${ARGN})
+ set(test_src "${CRDOP_VENDOR_TEST_DIR}/test_${name}.c")
+ if(NOT EXISTS "${test_src}")
+ message(FATAL_ERROR "CRDOP test source missing: ${test_src}")
+ endif()
+
+ set(sources "${test_src}" ${CRDOP_VENDOR_TEST_COMMON})
+ if(ARG_SOURCES)
+ foreach(rel IN LISTS ARG_SOURCES)
+ list(APPEND sources "${VENDOR_DIR}/${rel}")
+ endforeach()
+ endif()
+
+ add_executable(crdop_test_${name} ${sources})
+ crdop_set_compile_options(crdop_test_${name})
+
+ target_include_directories(crdop_test_${name} PRIVATE
+ "${CRDOP_ROOT}/include"
+ "${VENDOR_DIR}/src"
+ "${VENDOR_DIR}/lib"
+ ${CRDOP_PLATFORM_INCLUDES}
+ )
+
+ if(ARG_SOURCES)
+ target_link_libraries(crdop_test_${name} PRIVATE m pthread)
+ if(CRDOP_PLATFORM STREQUAL "linux")
+ target_link_libraries(crdop_test_${name} PRIVATE rt)
+ endif()
+ else()
+ target_link_libraries(crdop_test_${name} PRIVATE ardopcf_vendor)
+ endif()
+
+ target_link_libraries(crdop_test_${name} PRIVATE ${CMOCKA_LIBRARIES})
+ target_include_directories(crdop_test_${name} PRIVATE ${CMOCKA_INCLUDE_DIRS})
+
+ if(ARG_WRAP)
+ foreach(sym IN LISTS ARG_WRAP)
+ target_link_options(crdop_test_${name} PRIVATE "-Wl,--wrap=${sym}")
+ endforeach()
+ endif()
+
+ add_test(NAME crdop_test_${name} COMMAND crdop_test_${name})
+endfunction()
+
+crdop_add_vendor_test(ARDOPCommon)
+crdop_add_vendor_test(HostInterface)
+crdop_add_vendor_test(Locator)
+crdop_add_vendor_test(Packed6)
+crdop_add_vendor_test(StationId)
+crdop_add_vendor_test(log
+ SOURCES
+ src/common/log_file.c
+ src/common/log.c
+ WRAP
+ fopen fclose fwrite fflush freopen
+)
+
+message(STATUS "CRDOP_BUILD_TESTS: registered upstream ardopcf cmocka tests")
diff --git a/stacks/crdop/cmake/CRDOPVendorArdopcf.cmake b/stacks/crdop/cmake/CRDOPVendorArdopcf.cmake
new file mode 100644
index 0000000..e82c605
--- /dev/null
+++ b/stacks/crdop/cmake/CRDOPVendorArdopcf.cmake
@@ -0,0 +1,127 @@
+# Legacy vendor/ardopcf build (opt-in only; not shipped in MAX25 v1).
+
+include("${CRDOP_ROOT}/cmake/CRDOPCompiler.cmake")
+include("${CRDOP_ROOT}/cmake/CRDOPPlatform.cmake")
+
+option(CRDOP_BUILD_TESTS "Build upstream ardopcf cmocka unit tests" OFF)
+
+set(CRDOP_VENDOR_COMMON
+ lib/rawhid/rawhid.c
+ lib/rockliff/rrs.c
+ lib/ws_server/ws_server.c
+ src/common/ARDOPC.c
+ src/common/ARDOPCommon.c
+ src/common/ardopSampleArrays.c
+ src/common/ARQ.c
+ src/common/BusyDetect.c
+ src/common/FEC.c
+ src/common/FFT.c
+ src/common/HostInterface.c
+ src/common/Locator.c
+ src/common/log_file.c
+ src/common/log.c
+ src/common/Modulate.c
+ src/common/Packed6.c
+ src/common/RXO.c
+ src/common/sdft.c
+ src/common/SoundInput.c
+ src/common/StationId.c
+ src/common/TCPHostInterface.c
+ src/common/txframe.c
+ src/common/wav.c
+ src/common/Webgui.c
+ src/common/noise.c
+ src/common/ardopcf.c
+)
+
+set(CRDOP_VENDOR_SRCS "")
+foreach(rel IN LISTS CRDOP_VENDOR_COMMON CRDOP_PLATFORM_SOURCES)
+ list(APPEND CRDOP_VENDOR_SRCS "${VENDOR_DIR}/${rel}")
+endforeach()
+
+set(TXT2C "${CMAKE_BINARY_DIR}/txt2c")
+add_executable(txt2c "${VENDOR_DIR}/lib/txt2c/txt2c.c")
+crdop_set_compile_options(txt2c)
+set_target_properties(txt2c PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}")
+
+set(WEBGUI_HTML_C "${CMAKE_BINARY_DIR}/gen-webgui.html.c")
+set(WEBGUI_JS_C "${CMAKE_BINARY_DIR}/gen-webgui.js.c")
+add_custom_command(
+ OUTPUT "${WEBGUI_HTML_C}"
+ COMMAND txt2c "${VENDOR_DIR}/webgui/webgui.html" "${WEBGUI_HTML_C}" webgui_html
+ DEPENDS txt2c "${VENDOR_DIR}/webgui/webgui.html"
+ COMMENT "Embedding webgui.html"
+)
+add_custom_command(
+ OUTPUT "${WEBGUI_JS_C}"
+ COMMAND txt2c "${VENDOR_DIR}/webgui/webgui.js" "${WEBGUI_JS_C}" webgui_js
+ DEPENDS txt2c "${VENDOR_DIR}/webgui/webgui.js"
+ COMMENT "Embedding webgui.js"
+)
+
+add_library(ardopcf_vendor_objs OBJECT
+ ${CRDOP_VENDOR_SRCS}
+ "${WEBGUI_HTML_C}"
+ "${WEBGUI_JS_C}"
+)
+
+target_include_directories(ardopcf_vendor_objs PRIVATE
+ "${CRDOP_ROOT}/include"
+ "${VENDOR_DIR}/src"
+ "${VENDOR_DIR}/lib"
+ ${CRDOP_PLATFORM_INCLUDES}
+)
+
+target_compile_definitions(ardopcf_vendor_objs PRIVATE CRDOP_BUILD=1)
+
+if(WIN32)
+ target_compile_definitions(ardopcf_vendor_objs PRIVATE WIN32 _CRT_SECURE_NO_DEPRECATE)
+endif()
+
+if(APPLE)
+ target_compile_definitions(ardopcf_vendor_objs PRIVATE __APPLE__)
+endif()
+
+crdop_set_compile_options(ardopcf_vendor_objs)
+
+add_executable(crdopc
+ "${CRDOP_ROOT}/src/crdop_version.c"
+ $<TARGET_OBJECTS:ardopcf_vendor_objs>
+)
+
+target_include_directories(crdopc PRIVATE
+ "${CRDOP_ROOT}/include"
+ "${VENDOR_DIR}/src"
+ "${VENDOR_DIR}/lib"
+ ${CRDOP_PLATFORM_INCLUDES}
+)
+
+target_compile_definitions(crdopc PRIVATE CRDOP_BUILD=1)
+
+if(WIN32)
+ target_compile_definitions(crdopc PRIVATE WIN32 _CRT_SECURE_NO_DEPRECATE)
+endif()
+
+if(APPLE)
+ target_compile_definitions(crdopc PRIVATE __APPLE__)
+endif()
+
+crdop_set_compile_options(crdopc)
+target_link_libraries(crdopc PRIVATE ${CRDOP_PLATFORM_LIBS})
+
+add_library(ardopcf_vendor STATIC
+ "${CRDOP_ROOT}/src/crdop_version.c"
+ $<TARGET_OBJECTS:ardopcf_vendor_objs>
+)
+target_include_directories(ardopcf_vendor PUBLIC
+ "${CRDOP_ROOT}/include"
+ "${VENDOR_DIR}/src"
+ "${VENDOR_DIR}/lib"
+ ${CRDOP_PLATFORM_INCLUDES}
+)
+target_compile_definitions(ardopcf_vendor PUBLIC CRDOP_BUILD=1)
+target_link_libraries(ardopcf_vendor PUBLIC ${CRDOP_PLATFORM_LIBS})
+
+include("${CRDOP_ROOT}/cmake/CRDOPTests.cmake")
+
+install(TARGETS crdopc RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
diff --git a/stacks/crdop/cmake/toolchains/aarch64-linux-gnu.cmake b/stacks/crdop/cmake/toolchains/aarch64-linux-gnu.cmake
new file mode 100644
index 0000000..59e3a37
--- /dev/null
+++ b/stacks/crdop/cmake/toolchains/aarch64-linux-gnu.cmake
@@ -0,0 +1,4 @@
+set(CMAKE_SYSTEM_NAME Linux)
+set(CMAKE_SYSTEM_PROCESSOR aarch64)
+set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc)
+set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)
diff --git a/stacks/crdop/cmake/toolchains/arm-linux-gnueabihf.cmake b/stacks/crdop/cmake/toolchains/arm-linux-gnueabihf.cmake
new file mode 100644
index 0000000..bb70e64
--- /dev/null
+++ b/stacks/crdop/cmake/toolchains/arm-linux-gnueabihf.cmake
@@ -0,0 +1,4 @@
+set(CMAKE_SYSTEM_NAME Linux)
+set(CMAKE_SYSTEM_PROCESSOR armv7l)
+set(CMAKE_C_COMPILER arm-linux-gnueabihf-gcc)
+set(CMAKE_CXX_COMPILER arm-linux-gnueabihf-g++)
diff --git a/stacks/crdop/cmake/toolchains/x86_64-w64-mingw32.cmake b/stacks/crdop/cmake/toolchains/x86_64-w64-mingw32.cmake
new file mode 100644
index 0000000..1b44215
--- /dev/null
+++ b/stacks/crdop/cmake/toolchains/x86_64-w64-mingw32.cmake
@@ -0,0 +1,4 @@
+set(CMAKE_SYSTEM_NAME Windows)
+set(CMAKE_SYSTEM_PROCESSOR x86_64)
+set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
+set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
diff --git a/stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md b/stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md
new file mode 100644
index 0000000..9656c2a
--- /dev/null
+++ b/stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md
@@ -0,0 +1,84 @@
+# Acoustic bench test protocol — P0 bench acceptance
+
+**Purpose:** Close CRDOP P0 bench without RF. All steps runnable offline in CI or on a dev host.
+
+**Related:** [HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md) · [DEVELOPER.md](DEVELOPER.md) · [ROADMAP.md](../ROADMAP.md) P0 bench.
+
+---
+
+## Prerequisites
+
+| Item | Requirement |
+|------|-------------|
+| Build | `MAX25_BUILD_CRDOP=ON`, `cmake --install` or source tree |
+| Python | 3.10+ with repo on `PYTHONPATH` |
+| ALSA | Optional for tests 3–4; loopback tests need no hardware |
+| PulseAudio | **Off** or bypassed (`no_pulse=yes` in INI) |
+
+---
+
+## Test matrix
+
+| ID | Name | RF | Pass criteria |
+|----|------|-----|---------------|
+| **T0** | Bell 202 round-trip | No | `test_bell202_line_code.py` green |
+| **T1** | HDLC + CRC | No | `hdlc_codec` round-trip in unit tests |
+| **T2** | Software loopback | No | `max25-signal-sniffer.py --loopback` decodes AX.25 UI |
+| **T3** | `acoustic_engine` self-test | No | `AcousticEngine.loopback_self_test()` returns True |
+| **T4** | `audio-dummyd` + host TCP | No | Connect :8515/:8516, `PROTOCOLMODE KISS` → `OK` |
+| **T5** | `max25d` audio-dummy backend | No | `test_audio_dummy_backend.py` green |
+| **T6** | `max25d` crdop-tcp backend | No | `test_crdop_backend.py` green |
+| **T7** | ALSA loopback cable | No* | Sniffer decodes tone from `aplay`/`arecord` pair |
+| **T8** | WAV fixture | No | Sniffer `--wav` decodes recorded Bell 202 capture |
+
+\*Requires `snd-aloop` or physical loopback interface.
+
+---
+
+## Commands (copy-paste)
+
+```bash
+cd MAX25-Stack
+./scripts/build.sh
+
+# T0–T1
+python3 -m pytest stacks/crdop/test_bell202_line_code.py -q
+python3 -m pytest stacks/daemon/test_crdop_backend.py stacks/daemon/test_audio_dummy_backend.py -q
+
+# T2
+python3 stacks/crdop/tools/max25-signal-sniffer.py --loopback
+
+# T4 (background)
+python3 stacks/crdop/tools/audio-dummyd.py --ctrl-port 8515 --data-port 8516 &
+sleep 1
+python3 -c "
+import socket
+c=socket.create_connection(('127.0.0.1',8515),timeout=2)
+c.sendall(b'PROTOCOLMODE KISS\n')
+print(c.recv(64))
+"
+```
+
+---
+
+## P0 bench exit gate
+
+**100 % P0 bench** when:
+
+- [x] T0, T1, T2, T5, T6 pass in `release-check.sh`
+- [ ] T3 documented green in CI log (or explicit skip reason)
+- [ ] T4 manual or scripted smoke in release-check
+- [ ] Host ports unified: launcher, `audio-dummyd`, `max25d` use [HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md) defaults
+
+P0 on-air starts only after P0 bench exit — see [docs/HARDWARE-ACCEPTANCE.md](../../docs/HARDWARE-ACCEPTANCE.md).
+
+---
+
+## Failure triage
+
+| Symptom | Check |
+|---------|-------|
+| Wrong tones | Mark 1200 Hz / Space 2200 Hz, sample rate 48000 |
+| CRC fail | `ax25_codec.ax25_crc` vs `hdlc_codec` |
+| TCP refuse | Port 8515 free; `audio-dummyd` running for host tests |
+| ALSA xrun | `period_frames`, cable levels — [SOUNDCARD-QUALIFICATION.md](SOUNDCARD-QUALIFICATION.md) |
diff --git a/stacks/crdop/docs/AUDIO-ARCHITECTURE.md b/stacks/crdop/docs/AUDIO-ARCHITECTURE.md
new file mode 100644
index 0000000..6320253
--- /dev/null
+++ b/stacks/crdop/docs/AUDIO-ARCHITECTURE.md
@@ -0,0 +1,80 @@
+# CRDOP audio architecture — kernel ALSA only
+
+**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`).
+
+MAX25-SoftModem (CRDOP) talks to the radio **through the sound hardware directly**. There is **no PulseAudio**, **no PipeWire default route**, and **no desktop sound server** in the path.
+
+## Stack layers
+
+```
+┌─────────────┐ M25/1 TCP ┌──────────────┐ host TCP ┌─────────────┐
+│ max25d │ ◄────────────► │ CRDOP modem │ ◄──────────► │ max25- │
+│ (stack) │ │ (DSP/AX.25) │ │ terminal │
+└─────────────┘ └──────┬───────┘ └─────────────┘
+ │
+ MAX25 sound-proxy
+ (buffer, timing, duplex)
+ │
+ ▼
+ libasound (userspace)
+ │
+ ▼
+ Linux kernel ALSA
+ (/dev/snd/*, drivers)
+ │
+ ▼
+ Sound hardware (USB/PCI codec)
+ │
+ ▼
+ Radio interface (line / acoustic)
+```
+
+| Layer | Role | Allowed |
+|-------|------|---------|
+| **max25d** | Device lifecycle, `CrdopTcpBackend`, no audio I/O | — |
+| **CRDOP modem** | AFSK encode/decode, AX.25 framing, duplex policy | — |
+| **MAX25 sound-proxy** | **Only** audio shim between modem and ALSA — period size, xrun recovery, hw params, PTT timing hooks | MAX25-owned |
+| **ALSA userspace** | `snd_pcm_*` on **`hw:` / `plughw:`** devices | Direct libasound |
+| **Kernel ALSA** | Driver, DMA, card registry | Required |
+| **PulseAudio / PipeWire** | — | **Forbidden** in production path |
+
+The sound-proxy is **not** a second network hop — it is the in-process (or co-process) module that owns ALSA opens and keeps modem timing off the desktop audio stack.
+
+## Why no PulseAudio
+
+PulseAudio and PipeWire insert mixing, resampling, and variable latency between applications and the kernel. CRDOP must:
+
+- Reproduce **exact mark/space frequencies** on playback
+- Sample capture with **stable phase** for demodulation
+- Meet **tighter deadlines** as baud rises toward 19200
+
+A userspace mixer breaks those guarantees. Operators must bind **card and device explicitly** (`hw:Card,Device` or tested `plughw:`).
+
+## Operator setup
+
+1. Identify hardware: `arecord -l` and `aplay -l` (kernel cards, not `pulse` pseudo-devices).
+2. Set INI `[audio] capture` and `playback` to **hardware ALSA names**.
+3. Ensure no session steals the device (`pasuspender` / stop PulseAudio on dedicated hosts).
+4. Verify loopback before on-air traffic.
+
+Example INI:
+
+```ini
+[audio]
+backend = alsa-kernel
+no_pulse = yes
+capture = hw:1,0
+playback = hw:1,0
+```
+
+Dedicated packet-radio hosts: prefer **single-purpose Linux** without a desktop sound daemon, or `PULSE_SERVER=` / `PIPEWIRE_RUNTIME_DIR=` unset for the `crdop` service unit.
+
+## Development (Eigenentwicklung)
+
+Native CRDOP will implement the sound-proxy in-tree (`include/crdop/sound_proxy.h`). Legacy optional vendor builds used `ALSASound.c` with the same rule: **open ALSA devices directly**.
+
+## See also
+
+- [SOFTMODEM.md](SOFTMODEM.md) — product scope, baud, duplex
+- [CONFIG.md](CONFIG.md) — INI keys
+- [BUILD.md](BUILD.md) — `libasound2-dev` dependency
diff --git a/stacks/crdop/docs/BUILD.md b/stacks/crdop/docs/BUILD.md
new file mode 100644
index 0000000..e72fac0
--- /dev/null
+++ b/stacks/crdop/docs/BUILD.md
@@ -0,0 +1,99 @@
+# Build
+
+Standalone CRDOP (**MAX25-SoftModem** — MAX25-SoftModem) — embedded `vendor/ardopcf/`, no submodules.
+
+## Requirements
+
+- CMake ≥ 3.16
+- **GCC** or **Clang / LLVM**
+- Platform libraries (see table below)
+- Optional tests: **cmocka** (Linux, *BSD)
+
+## Platform matrix (tested)
+
+| OS | Toolchain | Audio / libs | Status |
+|----|-----------|----------------|--------|
+| **Linux** | GCC, Clang | `libasound2-dev`, `pkg-config` | tested |
+| **FreeBSD** | GCC, Clang | `alsa-lib`, `pkgconf` | tested |
+| **OpenBSD / NetBSD** | GCC, Clang | `alsa-lib` if available | experimental |
+| **Windows** | MinGW-w64, MSVC | WinMM (built-in) | **untested** |
+| **macOS** | Clang (Xcode) | CoreAudio (built-in) | **untested** |
+
+Other operating systems are not tested.
+
+### Debian / Ubuntu
+
+```bash
+sudo apt install build-essential cmake libasound2-dev pkg-config
+sudo apt install libcmocka-dev # optional unit tests
+```
+
+### FreeBSD
+
+```bash
+pkg install cmake alsa-lib pkgconf cmocka
+```
+
+### macOS
+
+```bash
+xcode-select --install
+# or: brew install cmake
+```
+
+### Windows (MinGW-w64 on Linux host)
+
+```bash
+sudo apt install mingw-w64
+CRDOP_TOOLCHAIN=x86_64-w64-mingw32 ./scripts/build-crdop.sh
+# → build/crdopc.exe
+```
+
+Native Windows: Visual Studio 2022+ with CMake, or MSYS2 MinGW.
+
+## Build
+
+```bash
+./scripts/build-crdop.sh
+CC=clang ./scripts/build-crdop.sh
+./scripts/test-all.sh # smoke + cmocka (Unix)
+```
+
+Output: `build/crdopc` (Windows: `build/crdopc.exe`)
+
+## Install
+
+```bash
+./scripts/install-crdop.sh # → /usr/local
+CRDOP_PREFIX=$HOME/.local ./scripts/install-crdop.sh
+```
+
+Installs:
+
+| Path | Contents |
+|------|----------|
+| `bin/crdopc` | Modem binary |
+| `bin/crdop` | Profile launcher (`scripts/crdopc`) |
+| `share/crdop/` | INI examples, `VERSION` |
+
+Run: `crdop` or `CRDOP_INI=~/.config/crdop/crdop.ini crdop`
+
+## Cross-compile (Linux host)
+
+```bash
+CRDOP_TOOLCHAIN=aarch64-linux-gnu ./scripts/build-crdop.sh
+CRDOP_TOOLCHAIN=arm-linux-gnueabihf ./scripts/build-crdop.sh
+CRDOP_TOOLCHAIN=x86_64-w64-mingw32 ./scripts/build-crdop.sh
+```
+
+## Troubleshooting
+
+| Problem | Fix |
+|---------|-----|
+| `vendor/ardopcf missing` | Full repo checkout |
+| No ALSA (Linux/BSD) | Install dev package / `alsa-lib` port |
+| macOS build fails | Xcode CLI tools; CMake ≥ 3.16 |
+| Tests skipped | Install cmocka dev package |
+| Stale build | `rm -rf build && ./scripts/build-crdop.sh` |
+
+Vendor refresh (maintainers): `./scripts/refresh-vendor-ardopcf.sh`
diff --git a/stacks/crdop/docs/CHANGELOG.md b/stacks/crdop/docs/CHANGELOG.md
new file mode 100644
index 0000000..2bbfbf6
--- /dev/null
+++ b/stacks/crdop/docs/CHANGELOG.md
@@ -0,0 +1,25 @@
+# Changelog
+
+## [CUR999] — dev track (current)
+
+**CUR999** — internal dev track id for MAX25-SoftModem inside MAX25-Stack v1.0.0. Dev track `CUR999`; ships with MAX25-Stack v1.0.0.
+
+- Dev track id `CUR999` in `$SRC/stacks/crdop/VERSION`
+- Native modem DSP in development; optional legacy vendor build (`-DCRDOP_VENDOR_ARDOPCF=ON`); **ARDOP-plugin** documented separately
+- `max25d` `crdop-tcp` backend, INI scaffold, launcher, HyBBX attach examples
+
+## [0.5.0] — planned (not yet released)
+
+Target standalone CRDOP v0.5 after v1.0.0 stack ship.
+
+- Standalone CRDOP modem (`crdopc`) with native DSP
+- Embedded optional `vendor/ardopcf` (MIT) for dev-only legacy builds only
+- CB / dual / amateur profiles; Linux and *BSD tested
+
+## [0.1.0-l2-cb] — 2026-07-03
+
+Experimental CB profile bootstrap.
+
+## [0.0.0-bootstrap]
+
+Repository scaffold.
diff --git a/stacks/crdop/docs/CONFIG.md b/stacks/crdop/docs/CONFIG.md
new file mode 100644
index 0000000..821fd1c
--- /dev/null
+++ b/stacks/crdop/docs/CONFIG.md
@@ -0,0 +1,50 @@
+# Configuration
+
+`scripts/crdopc` reads INI → starts native `audio-dummyd` (M25/KISS host).
+
+## INI search order
+
+1. `$CRDOP_INI`
+2. `~/.config/crdop/crdop.ini`
+3. `share/crdop.ini.example`
+
+Templates: `share/crdop.ini.example` · `share/crdop-dual.ini.example` · `share/crdop-amateur.ini.example`
+
+## Keys
+
+### `[profile]`
+
+| Key | Default | Values |
+|-----|---------|--------|
+| `radio_profile` | `cb` | `cb` · `dual` · `amateur` |
+
+### `[modem]`
+
+| Key | Default | Notes |
+|-----|---------|-------|
+| `arq_bandwidth` | profile | `500MAX` (cb/dual) · `1000MAX` (amateur) |
+| `duplex` | `half` | `half` · `full` (`full` → `EXTRADELAY 0`) |
+| `extra_delay_ms` | _(auto)_ | Override delay; empty = profile default |
+
+### `[host]` · `[mycall]` · `[audio]`
+
+| Section | Key | Default | Notes |
+|---------|-----|---------|-------|
+| host | `port` | `8515` | TCP control |
+| mycall | `call` | `NOCALL-0` | On-air ID |
+| audio | `backend` | `alsa-kernel` | Kernel ALSA only — see [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md) |
+| audio | `no_pulse` | `yes` | Reject PulseAudio/PipeWire pseudo devices |
+| audio | `capture` | _(required)_ | e.g. `hw:1,0` from `arecord -l` |
+| audio | `playback` | _(required)_ | e.g. `hw:1,0` from `aplay -l` |
+| audio | `sample_rate` | modem default | Hz |
+| audio | `period_frames` | auto | Buffer tuning |
+
+**No PulseAudio** in the production path. The **MAX25 sound-proxy** opens ALSA directly; `scripts/crdopc` clears Pulse/PipeWire session env when `no_pulse=yes`.
+
+## Environment
+
+| Variable | Purpose |
+|----------|---------|
+| `CRDOP_INI` | Config file path |
+
+Examples: [EXAMPLES.md](EXAMPLES.md)
diff --git a/stacks/crdop/docs/DEVELOPER.md b/stacks/crdop/docs/DEVELOPER.md
new file mode 100644
index 0000000..af43252
--- /dev/null
+++ b/stacks/crdop/docs/DEVELOPER.md
@@ -0,0 +1,188 @@
+# CRDOP developer guide
+
+**CRDOP** = **MAX25-SoftModem**. In-house GPLv3 sound-card modem (`stacks/crdop/`). Project rule: [docs/CRDOP.md](../../../docs/CRDOP.md).
+
+---
+
+## Source tree
+
+```
+stacks/crdop/
+├── lib/ # Native DSP (Python — leads validation)
+│ ├── bell202_line_code.py
+│ ├── afsk_modulator.py
+│ ├── afsk_demodulator.py
+│ ├── hdlc_codec.py
+│ ├── sound_proxy.py
+│ ├── m25_host_protocol.py
+│ ├── acoustic_engine.py
+│ └── test_bell202_line_code.py
+├── include/crdop/ # C API scaffold (future native binary)
+│ ├── sound_proxy.h
+│ └── version.h
+├── tools/
+│ ├── audio-dummyd.py # M25 host TCP daemon
+│ └── max25-signal-sniffer.py
+├── scripts/
+│ ├── crdopc # Launcher → audio-dummyd by default
+│ ├── build-crdop.sh
+│ └── install-crdop.sh
+├── share/ # crdop.ini.example (+ dual, amateur)
+├── cmake/ # Platform, vendor (dev-only), tests
+├── docs/ # This tree
+├── ROADMAP.md
+└── CMakeLists.txt
+```
+
+**Daemon integration:** `stacks/daemon/device_backends.py` → `CrdopTcpBackend` (`soft-crdop`), `AudioDummyBackend` (`audio-dummy`).
+
+**AX.25 codec:** `stacks/daemon/ax25_codec.py` — shared with on-air framing in `acoustic_engine.py`.
+
+---
+
+## Module map
+
+| Module | Layer | Responsibility |
+|--------|-------|----------------|
+| `bell202_line_code.py` | Line code | Bell 202 frequency-toggle: bit 0 → tone change; mark 1200 Hz / space 2200 Hz |
+| `afsk_modulator.py` | PHY TX | Continuous-phase AFSK PCM @ configurable sample rate (default 48 kHz) |
+| `afsk_demodulator.py` | PHY RX | Per-symbol Goertzel demod (1200 baud class) |
+| `hdlc_codec.py` | Framing | HDLC flags `0x7E`, bit-stuffing, CRC-16-CCITT |
+| `sound_proxy.py` | Audio I/O | ALSA capture/playback via `arecord`/`aplay`; buffer config |
+| `m25_host_protocol.py` | Host | TCP ctrl (:8515) + data (:8516); M25-family + KISS-semantics |
+| `acoustic_engine.py` | Bench | Full path: AX.25 UI → HDLC → AFSK → demod → parse; loopback self-test |
+
+**Host vs on-air:** `m25_host_protocol.py` carries AX.25 UI on the data port **without** HDLC wrapping. `acoustic_engine.encode_ax25_ui()` builds full on-air HDLC + AFSK for RF/audio output.
+
+**Not yet in `lib/`:** G3RUH scrambler/FSK (P1), 300 baud HF AFSK (P2), optional short-block FEC.
+
+---
+
+## Running tests
+
+### MAX25-Stack (recommended)
+
+```bash
+./scripts/build.sh
+cmake --build build --target max25_test # all offline tests
+cmake --build build --target max25_daemon_smoke # daemon + CRDOP subset
+```
+
+CRDOP-related tests in `max25_daemon_smoke`:
+
+| Test file | What it checks |
+|-----------|----------------|
+| `stacks/crdop/lib/test_bell202_line_code.py` | Bell 202 encode/decode round-trip |
+| `stacks/daemon/test_crdop_backend.py` | `CrdopTcpBackend` attach, TX (native M25/KISS) |
+| `stacks/daemon/test_audio_dummy_backend.py` | `audio-dummy` loopback backend |
+| `stacks/daemon/test_multi_device.py` | Multi-device registry incl. `soft-crdop` |
+
+### Direct (no CMake)
+
+```bash
+python3 stacks/crdop/lib/test_bell202_line_code.py
+python3 stacks/daemon/test_crdop_backend.py
+python3 stacks/daemon/test_audio_dummy_backend.py
+```
+
+### Sniffer loopback (no audio hardware)
+
+```bash
+python3 stacks/crdop/tools/max25-signal-sniffer.py --loopback
+# or after install:
+max25-signal-sniffer --loopback
+```
+
+Exit 0 when loopback decode produces AX.25 UI lines.
+
+### audio-dummyd bench
+
+```bash
+python3 stacks/crdop/tools/audio-dummyd.py --ctrl-port 8515 --data-port 8516
+# Terminal probe (separate shell):
+max25-terminal -H 127.0.0.1 -P 8515
+```
+
+### Standalone CRDOP vendor tests (dev-only)
+
+When built with `-DCRDOP_VENDOR_ARDOPCF=ON` and cmocka installed:
+
+```bash
+./scripts/build-crdop.sh
+CRDOP_BUILD_TESTS=ON ./scripts/test-all.sh
+```
+
+Vendor tree is **never** installed in MAX25 releases.
+
+---
+
+## Extending the modem
+
+### Add a modulation mode
+
+1. Add PHY module under `lib/` (e.g. `g3ruh_fsk.py`).
+2. Wire into `acoustic_engine.py` — select by `baud` / `modulation` parameter.
+3. Extend `crdop.ini` `[modem]` keys in `share/crdop.ini.example` and [CONFIG.md](CONFIG.md).
+4. Add unit tests beside the module (`test_*.py`).
+5. Update [ROADMAP.md](../ROADMAP.md) implementation status table.
+
+### Extend host protocol
+
+1. Edit `m25_host_protocol.py` — keep ctrl/data port split.
+2. Mirror changes in `stacks/daemon/device_backends.py` (`CrdopTcpBackend`).
+3. Document in [PROTOCOL.md](PROTOCOL.md).
+4. Add cases to `test_crdop_backend.py`.
+
+### C port path
+
+`include/crdop/sound_proxy.h` is the C ABI target. Python `lib/` leads until acoustic bench gates close; then port validated algorithms to native `crdopc` binary.
+
+---
+
+## Build modes
+
+| Mode | CMake flag | Output |
+|------|------------|--------|
+| **MAX25-Stack (default)** | `MAX25_BUILD_CRDOP=ON` | `bin/crdop`, `bin/audio-dummyd`, `bin/max25-signal-sniffer`, `share/crdop/` |
+| **MAX25 without CRDOP** | `-DMAX25_BUILD_CRDOP=OFF` | No CRDOP install artifacts |
+| **Standalone CRDOP** | `stacks/crdop/scripts/build-crdop.sh` | Same scaffold; optional vendor `crdopc` with `-DCRDOP_VENDOR_ARDOPCF=ON` |
+| **Dev vendor ARDOP** | `-DCRDOP_VENDOR_ARDOPCF=ON` | Legacy ardopcf binary — local only, never released |
+
+### Install paths (MAX25 `cmake --install`)
+
+| Artifact | Destination |
+|----------|-------------|
+| `crdop` (launcher) | `${CMAKE_INSTALL_PREFIX}/bin/` |
+| `audio-dummyd`, `max25-signal-sniffer` | `${CMAKE_INSTALL_PREFIX}/bin/` |
+| `crdop.ini.example`, `lib/*.py`, `VERSION` | `${CMAKE_INSTALL_PREFIX}/share/crdop/` |
+
+---
+
+## Dependencies
+
+| Component | Requirement |
+|-----------|-------------|
+| Runtime | Python 3, ALSA utils (`arecord`/`aplay`) on Linux |
+| Build | CMake ≥ 3.16 |
+| Audio | `libasound` — kernel ALSA direct opens only |
+| Tests | None beyond Python 3 (cmocka optional for vendor C tests) |
+
+---
+
+## Conventions
+
+- **English** for all shipped docs and user-facing strings.
+- **No PulseAudio / PipeWire** in production audio path.
+- **ARDOP** is a separate optional plugin — not part of CRDOP — see [plugins/external/ardop/README.md](../../../plugins/external/ardop/README.md).
+- Baud ceiling: **19200** (P1 G3RUH direct FSK). Above that: out of scope.
+
+---
+
+## Related
+
+| Doc | Topic |
+|-----|--------|
+| [MAX25-USAGE.md](MAX25-USAGE.md) | Operator workflow in MAX25-Stack |
+| [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md) | Radio interface spec for builders |
+| [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md) | Sound-proxy design |
+| [ROADMAP.md](../ROADMAP.md) | P0/P1/P2 milestones and module status |
diff --git a/stacks/crdop/docs/EXAMPLES.md b/stacks/crdop/docs/EXAMPLES.md
new file mode 100644
index 0000000..b6d32c5
--- /dev/null
+++ b/stacks/crdop/docs/EXAMPLES.md
@@ -0,0 +1,91 @@
+# Examples
+
+MAX25-Stack integration (plugins, `max25d`, terminal): [MAX25-USAGE.md](MAX25-USAGE.md).
+
+## Build
+
+```bash
+./scripts/build-crdop.sh
+CC=clang ./scripts/build-crdop.sh
+CRDOP_BUILD_TESTS=ON ./scripts/test-all.sh # needs libcmocka-dev
+```
+
+Cross-build (Linux host): see [BUILD.md](BUILD.md).
+
+## Run — CB (default, native M25 host)
+
+```bash
+./scripts/crdopc
+# or explicit example INI:
+CRDOP_INI=share/crdop.ini.example ./scripts/crdopc
+```
+
+This starts `audio-dummyd` with native M25/KISS host on TCP :8515/:8516.
+
+Copy config for daily use:
+
+```bash
+mkdir -p ~/.config/crdop
+cp share/crdop.ini.example ~/.config/crdop/crdop.ini
+$EDITOR ~/.config/crdop/crdop.ini
+./scripts/crdopc
+```
+
+## Run — dual (CB ↔ amateur)
+
+```bash
+CRDOP_INI=share/crdop-dual.ini.example ./scripts/crdopc
+```
+
+## Run — amateur (secondary)
+
+```bash
+CRDOP_INI=share/crdop-amateur.ini.example ./scripts/crdopc
+```
+
+## Full-duplex CB
+
+`share/crdop.ini.example` with:
+
+```ini
+[modem]
+duplex = full
+arq_bandwidth = 500MAX
+```
+
+## Custom call / ALSA devices
+
+```ini
+[mycall]
+call = CB01-0
+
+[audio]
+capture = plughw:1,0
+playback = plughw:1,0
+```
+
+Pass extra args after port (reserved for future native modem flags):
+
+```bash
+./scripts/crdopc 8515
+```
+
+## Direct audio-dummyd (no launcher)
+
+```bash
+./build/bin/audio-dummyd --ctrl-port 8515 --data-port 8516
+```
+
+## max25d integration
+
+```ini
+[devices]
+soft-crdop = crdop:default
+
+[device.soft-crdop]
+host = 127.0.0.1
+port = 8515
+listen = yes
+```
+
+See [../../docs/PLUGINS-DEVICE-MODEL.md](../../docs/PLUGINS-DEVICE-MODEL.md).
diff --git a/stacks/crdop/docs/FEC-SPEC.md b/stacks/crdop/docs/FEC-SPEC.md
new file mode 100644
index 0000000..53b1f93
--- /dev/null
+++ b/stacks/crdop/docs/FEC-SPEC.md
@@ -0,0 +1,99 @@
+# CRDOP FEC and duplex — implementation spec
+
+**Principle:** *Send more often with shorter frames rather than one long payload.*
+
+**Related:** [ROADMAP.md](../ROADMAP.md) FEC section · [docs/CRDOP.md](../../../docs/CRDOP.md)
+
+**Status:** MAX25-Stack **v1.0.0** / dev track `CUR999` — L1 optional FEC **planned**; operator-layer strategy **active now**.
+
+---
+
+## Layer model
+
+| Layer | Mechanism | v1.0.0 |
+|-------|-----------|--------|
+| **L3 Application** | Short beacons, staggered intervals | HyBBX / `max25d.ini` |
+| **L2 AX.25** | UI = no ARQ; I-frames optional ARQ via host | `ax25_codec.py` |
+| **L1 Modem** | CRC-16-CCITT mandatory; optional repeat/FEC blocks | `hdlc_codec.py` — CRC done |
+| **PHY** | Bell 202 AFSK (P0) | `afsk_*` |
+
+---
+
+## Half-duplex (CB default)
+
+| Parameter | Value | INI / host |
+|-----------|-------|------------|
+| Duplex | half | `[modem] duplex = half` |
+| CSMA persist | 255 | KISS `0x02` / CB profile |
+| Extra TX delay | 150 ms class | `extra_delay_ms` / `EXTRADELAY` |
+| Max UI info | **≤128 bytes** recommended | Application |
+| PTT | explicit | No VOX on CB |
+
+### Retransmission strategy (no L2 ARQ on UI)
+
+1. Prefer **N small identical beacons** over one large burst.
+2. Stagger dual-radio sites (`ax25_auto_interval` + offset).
+3. Optional L1: **2–3 repeats** of same short codeword vs one RS block >256 B.
+
+---
+
+## Full-duplex
+
+| Parameter | Value |
+|-----------|-------|
+| `duplex` | `full` |
+| `EXTRADELAY` | `0` |
+| FEC | Stronger codes allowed when bandwidth permits |
+| Audio | Full-duplex sound card; echo control required |
+
+---
+
+## Reference protocols (not on-air targets)
+
+| Protocol | Lesson for CRDOP |
+|----------|------------------|
+| **FX.25** | RS wrapper around AX.25; bit-stuffing hurts naive FEC |
+| **IL2P** | No bit-stuffing; packet-sync scramble — reference for future L2 |
+| **VARA DSP** | Multi-stage FEC — PHY reference only |
+
+---
+
+## Planned L1 FEC (post P0 on-air)
+
+| Requirement | Target |
+|-------------|--------|
+| Block size | ≤64 B payload + CRC per repeat unit |
+| Latency | <300 ms added on 1200 bd half-duplex |
+| Compatibility | Plain AX.25 receivers must not break (FX.25-style optional path) |
+| Test | AWGN + FM clip simulator before on-air |
+
+### INI keys (future)
+
+```ini
+[fec]
+enabled = no # default off v1.0.0
+repeat_count = 0 # 0 = no repeat; 2–3 for CB marginal
+profile = half # half | full
+```
+
+---
+
+## CB 1200 parameters (normative operator)
+
+| Parameter | Value |
+|-----------|-------|
+| Baud | 1200 |
+| Modulation | Bell 202 (1200/2200 Hz) |
+| Frame | AX.25 UI |
+| persist | 255 |
+| Beacon | 300 s + stagger (site INI) |
+
+---
+
+## Implementation checklist
+
+- [x] CRC-16 in `hdlc_codec.py`
+- [x] Duplex INI keys in `crdop.ini.example`
+- [ ] `fec.enabled` INI + launcher
+- [ ] Offline BER vs frame-length simulator
+- [ ] Document measured CB SNR thresholds (field)
diff --git a/stacks/crdop/docs/G3RUH-DESIGN.md b/stacks/crdop/docs/G3RUH-DESIGN.md
new file mode 100644
index 0000000..ae1a164
--- /dev/null
+++ b/stacks/crdop/docs/G3RUH-DESIGN.md
@@ -0,0 +1,72 @@
+# G3RUH FSK — CRDOP P1 design spec
+
+**Scope:** 9600–**19200** baud direct FSK. **Hard maximum 19200** — nothing above.
+
+**Prerequisite:** P0 on-air (1200 AFSK) stable. **Not** CB FM mic path.
+
+See [ROADMAP.md](../ROADMAP.md) P1 milestone (stack scheduling: **DEV-Level 4**).
+
+---
+
+## PHY difference from P0
+
+| | 1200 AFSK (P0) | G3RUH FSK (P1) |
+|---|----------------|----------------|
+| Modulation | Audio tones 1200/2200 Hz | Scrambled baseband FSK |
+| RF path | Mic / speaker / line | **Direct** varactor + discriminator |
+| CB FM voice channel | ✅ | ❌ |
+| VHF backbone | Access only at 1200 | ✅ at 9600–19200 |
+
+---
+
+## Parameters (normative)
+
+| Baud | Peak deviation | RF bandwidth (~-60 dB) |
+|------|----------------|------------------------|
+| 4800 | ±1.5 kHz | ~8 kHz |
+| **9600** | **±3 kHz** | **~20 kHz** |
+| **19200** | **±6 kHz** | **~30 kHz** |
+
+CRDOP implements **up to 19200** only.
+
+---
+
+## Radio interface requirements
+
+| Requirement | Detail |
+|-------------|--------|
+| TX | Direct modulation input (varactor), not acoustic |
+| RX | Discriminator / data output, wide IF |
+| Filter | Flat amplitude 10 Hz–5 kHz, constant group delay |
+| Scrambler | G3RUH compatible — interoperate with standard 9600 peers |
+
+Standard narrow FM voice audio **fails** eye-diagram tests at 9600+.
+
+---
+
+## Software modules (planned)
+
+| Module | Path | Status |
+|--------|------|--------|
+| Scrambler | `stacks/crdop/lib/g3ruh_scrambler.py` | Not started |
+| FSK modulator | `stacks/crdop/lib/g3ruh_modulator.py` | Not started |
+| FSK demodulator | `stacks/crdop/lib/g3ruh_demodulator.py` | Not started |
+| HDLC/framing | Reuse `hdlc_codec.py` | Done |
+
+Host protocol unchanged — same M25/KISS TCP; baud selected via INI `speed_baud` (future).
+
+---
+
+## P1 exit criteria
+
+1. Loopback with recorded baseband fixtures at 9600.
+2. Over-the-air exchange with reference 9600 FSK peer.
+3. 19200 optional stretch after 9600 stable.
+
+---
+
+## Out of scope
+
+- 38400+ satellite/backbone rates
+- Acoustic coupling at 9600+
+- CB 27 MHz deployment
diff --git a/stacks/crdop/docs/HARDWARE-INTERFACE.md b/stacks/crdop/docs/HARDWARE-INTERFACE.md
new file mode 100644
index 0000000..9c36e21
--- /dev/null
+++ b/stacks/crdop/docs/HARDWARE-INTERFACE.md
@@ -0,0 +1,194 @@
+# CRDOP hardware interface specification
+
+Generic specification for builders who manufacture **radio ↔ sound-card interfaces** for CRDOP (MAX25-SoftModem). **No product-specific wiring examples** — apply these rules to your own design.
+
+**CRDOP** = **MAX25-SoftModem**. Project rule: [docs/CRDOP.md](../../../docs/CRDOP.md).
+
+---
+
+## Scope
+
+CRDOP connects a host sound card to a transceiver via one of:
+
+| Path | Typical use | Max practical baud |
+|------|-------------|-------------------|
+| **Audio AFSK** | Mic IN / speaker OUT / line level | **1200** (P0) |
+| **Direct FSK** | Varactor TX + FM discriminator RX | **9600–19200** (P1) |
+| **SSB audio** | USB/LSB suppressed carrier | **300** (P2 optional) |
+| **Acoustic coupling** | Speaker ↔ microphone (test / field) | **1200** |
+
+RF path matrix and delivery milestones: [ROADMAP.md](../ROADMAP.md).
+
+---
+
+## Audio levels
+
+### Line level (preferred)
+
+| Signal | Typical level | Notes |
+|--------|---------------|-------|
+| **Playback → radio mic IN** | −20 to −10 dBV (adjust per rig sensitivity) | Use potentiometer or fixed attenuator; verify with sniffer or oscilloscope |
+| **Radio speaker/line → capture** | 0.1–1.0 V RMS typical | Attenuate hot speaker outputs before codec input |
+| **Full-scale digital** | Avoid clipping on TX and RX | Leave 6–12 dB headroom |
+
+### Mic level
+
+Some transceivers expose mic-level inputs only. Attenuate line output to mic sensitivity (often 5–20 mV). Use coupling capacitor if DC bias is present on the mic jack.
+
+### Isolation
+
+| Method | Purpose |
+|--------|---------|
+| **Audio transformer** (1:1 or step-down) | Galvanic isolation; breaks ground loops |
+| **Opto-isolator** (digital PTT path) | Isolate PTT from radio ground |
+| **Separate ground reference** | Star ground at interface PCB; one tie point to radio chassis if required |
+
+**Ground loops** cause hum, false transitions, and decode failures. Always isolate PC USB ground from radio chassis when using direct cable coupling.
+
+---
+
+## PTT control
+
+CRDOP is **half-duplex by default**. The host must key the transmitter before playback and unkey after tail.
+
+| Method | Interface | Caveats |
+|--------|-----------|---------|
+| **GPIO** | USB-serial RTS/DTR, parallel port, dedicated GPIO | Cleanest; software-controlled timing |
+| **Serial CAT** | Radio command port | Latency depends on radio firmware |
+| **VOX** | Audio-derived keying | **Not recommended** for packet — tail timing unpredictable; false keys from noise |
+| **Manual** | Operator PTT | Acceptable for bench only |
+
+### Timing parameters
+
+Configure in `crdop.ini` / `max25d.ini`:
+
+| Parameter | Typical half-duplex | Full-duplex |
+|-----------|--------------------|-------------|
+| Pre-TX delay (`extra_delay_ms`) | 80–200 ms | 0 |
+| TXTAIL | Match radio requirement | N/A |
+| PTT release | After last space tone + margin | N/A |
+
+**Rule:** explicit GPIO/serial PTT beats VOX for AX.25 UI bursts.
+
+---
+
+## Acoustic coupling
+
+Valid for **bench validation** and some field setups:
+
+```
+[Host speaker] ──air gap──► [Radio mic]
+[Radio speaker] ──air gap──► [Host mic]
+```
+
+| Principle | Detail |
+|-----------|--------|
+| **Distance** | Minimize path loss; foam gasket reduces room noise |
+| **Frequency response** | FM voice channel ≈ 3 kHz — sufficient for 1200/2200 Hz Bell 202 |
+| **Level** | Low volume on playback; avoid speaker distortion |
+| **Duplex** | Half-duplex only — acoustic feedback if TX and RX paths overlap |
+| **Test** | Run `max25-signal-sniffer --loopback` on host before acoustic trial |
+
+Acoustic coupling does **not** scale to G3RUH direct FSK (P1).
+
+---
+
+## Direct FSK path (P1 — G3RUH)
+
+When audio AFSK is insufficient (9600–19200 baud):
+
+| Element | Requirement |
+|---------|-------------|
+| **TX** | Discriminator input bypassed; varactor or dedicated FSK input on data port |
+| **RX** | Flat FM discriminator output (de-emphasis off or compensated) |
+| **Bandwidth** | ~20 kHz IF for 9600; wider for 19200 |
+| **Scrambler** | G3RUH polynomial — implemented in future `lib/` module |
+| **Radio** | Data-port capable; standard FM mic/speaker path **not** sufficient |
+
+CB FM rigs at 27 MHz are generally **not** direct-FSK capable at 9600+ — P1 targets VHF/UHF amateur backbone paths.
+
+---
+
+## Sound card requirements
+
+| Requirement | 1200 baud (P0) | 9600–19200 (P1) |
+|-------------|----------------|-----------------|
+| **Sample rate** | ≥ 48 kHz recommended | ≥ 48 kHz; 96 kHz preferred |
+| **Bit depth** | 16-bit mono | 16-bit mono |
+| **Duplex** | Full-duplex USB codec or half-duplex with PTT | Full-duplex, low jitter |
+| **Clock** | Stable crystal; avoid drift during TX | Jitter < 50 ppm class |
+| **Driver** | Kernel ALSA (`hw:` / `plughw:`) | Same |
+| **Isolation** | Transformer on radio side | Same + verified loopback |
+
+### Unsuitable interfaces
+
+- Onboard laptop mic/speaker without external interface
+- Bluetooth audio bridges
+- PulseAudio / PipeWire virtual devices
+- Consumer "USB sound sticks" without galvanic isolation
+
+Configure devices in `crdop.ini`:
+
+```ini
+[audio]
+backend = alsa-kernel
+no_pulse = yes
+capture = plughw:1,0
+playback = plughw:1,0
+sample_rate = 48000
+```
+
+Verify with:
+
+```bash
+max25-signal-sniffer --loopback
+max25-signal-sniffer -D plughw:1,0 -t 2.0
+```
+
+---
+
+## Compliance and operator responsibility
+
+| Topic | Policy |
+|-------|--------|
+| **Transmit licence** | Operator must hold valid authorization for the band and mode |
+| **CB (27 MHz EU)** | EN 300 433 / national rules — voice primary; data may be restricted or channel-specific |
+| **Amateur bands** | Band plan, power limits, identification |
+| **EMC** | Builder responsible for conducted/radiated emissions from interface hardware |
+| **CRDOP software** | GPLv3 — no warranty; see [LICENSE-USAGE.md](LICENSE-USAGE.md) |
+
+CRDOP documentation does **not** constitute legal advice. Check local regulations before on-air operation.
+
+---
+
+## Acceptance checklist (builder)
+
+| Step | Pass criterion |
+|------|----------------|
+| Loopback | `max25-signal-sniffer --loopback` decodes test UI |
+| Level | Mark/space tones within radio spec; no clipping |
+| Isolation | No hum on decode when PC and radio share mains |
+| PTT | Key before TX audio; clean tail; no truncation |
+| On-air | AX.25 UI exchange with known-good peer at 1200 baud |
+| Duplex | Half-duplex CSMA behaviour matches `persist` / `extra_delay` INI |
+
+---
+
+## Related in-repo docs
+
+| Topic | Document |
+|-------|----------|
+| Sound-card requirements | [SOUNDCARD-QUALIFICATION.md](SOUNDCARD-QUALIFICATION.md) |
+| Bell 202 / 1200 AFSK (P0) | [ROADMAP.md](../ROADMAP.md) · [SOFTMODEM.md](SOFTMODEM.md) |
+| G3RUH 9600–19200 (P1) | [G3RUH-DESIGN.md](G3RUH-DESIGN.md) |
+| AX.25 / packet context | [docs/PACKET-RADIO.md](../../../docs/PACKET-RADIO.md) |
+
+---
+
+## Related
+
+| Doc | Topic |
+|-----|--------|
+| [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md) | Kernel ALSA, sound-proxy |
+| [MAX25-USAGE.md](MAX25-USAGE.md) | Stack integration and bench modes |
+| [ROADMAP.md](../ROADMAP.md) | P0/P1/P2 phases |
diff --git a/stacks/crdop/docs/HOST-PROTOCOL-SPEC.md b/stacks/crdop/docs/HOST-PROTOCOL-SPEC.md
new file mode 100644
index 0000000..d45c7e5
--- /dev/null
+++ b/stacks/crdop/docs/HOST-PROTOCOL-SPEC.md
@@ -0,0 +1,103 @@
+# M25 SoftModem host protocol — implementation spec (frozen for v1.0.0)
+
+**CRDOP** = **MAX25-SoftModem** · Native host wire for MAX25-SoftModem · **Not** ARDOP FEC/ARQ.
+
+**Normative code:** `stacks/crdop/lib/m25_host_protocol.py` · **Consumer:** `max25d` `CrdopTcpBackend`, `audio-dummyd`, `crdopc` launcher.
+
+---
+
+## Transport
+
+| Channel | Default port | Wire |
+|---------|--------------|------|
+| **Control** | **8515** | Line-oriented ASCII, **`\n`** terminated (native) |
+| **Data** | **8516** (= ctrl + 1) | Binary AX.25 UI body **without** HDLC flags or FCS |
+
+| Mode | Control terminator | Data semantics |
+|------|-------------------|----------------|
+| **Native M25/KISS** | `\n` | KISS DATA equivalent — 16+ byte UI body |
+
+### Bench alternate ports
+
+When `soft-crdop` and `audio-dummyd` run on one host, bench daemon may use **8520/8521** via CLI. Production `crdopc` and INI default **8515/8516**.
+
+---
+
+## Control commands (native)
+
+All commands are single lines. Reply is one line ending `\n`.
+
+| Command | Args | Reply | Behaviour |
+|---------|------|-------|-----------|
+| `INITIALIZE` | — | `OK` | Reset session state |
+| `PROTOCOLMODE` | `KISS` | `OK` | Select KISS-semantics data channel |
+| `MYCALL` | `CALL-SSID` | `OK` | Store station ID (uppercase) |
+| `LISTEN` | `TRUE`/`FALSE` | `OK` | RX enable (default true) |
+| `PING` | — | `OK` | Liveness |
+| `STATUS` | — | `STATUS ready mycall=…` | State snapshot |
+| *(unknown)* | — | `ERR unknown command` | |
+
+**Not supported on native path:** `PROTOCOLMODE FEC`, `FECMODE`, ARQ bandwidth commands — ARDOP is a separate optional plugin, not part of CRDOP.
+
+---
+
+## Data channel
+
+### TX (host → modem)
+
+1. TCP connect to data port.
+2. Send **one** AX.25 UI frame body: `dest(7) + src(7) + ctrl(1) + pid(1) + info(0..256)`.
+3. Modem responds `OK` or `ERR …` (ASCII line).
+
+Modem builds on-air: HDLC flags, bit-stuffing, frequency-toggle line code, AFSK PHY — see `acoustic_engine.encode_ax25_ui()`.
+
+### RX (modem → host)
+
+Native bench: optional `STATUS` lines on control channel. Production: `max25d` parses demodulated UI and displays `[CRDOP AX25 UI src>dst] text`.
+
+---
+
+## max25d integration
+
+```ini
+[devices]
+soft-crdop = crdop:default
+
+[device.soft-crdop]
+host = 127.0.0.1
+port = 8515
+listen = yes
+```
+
+`CrdopTcpBackend` on `open()`:
+
+1. TCP connect `host:port` (ctrl), `host:port+1` (data).
+2. Send `INITIALIZE`, `PROTOCOLMODE KISS`, `MYCALL …`, `LISTEN TRUE` (each `\n`).
+
+---
+
+## Layer separation
+
+| Layer | On data TCP | On air |
+|-------|-------------|--------|
+| AX.25 UI body | ✅ | Inside HDLC |
+| HDLC + CRC-16 | ❌ | ✅ |
+| Bell 202 AFSK | ❌ | ✅ |
+
+Same rule as KISS `0x00` DATA frames in `kiss_bridge.py`.
+
+---
+
+## Compliance tests
+
+| Test | Command |
+|------|---------|
+| Unit | `pytest stacks/daemon/test_crdop_backend.py` |
+| Bench host | `python3 stacks/crdop/tools/audio-dummyd.py --ctrl-port 8515 --data-port 8516` |
+| Loopback DSP | `pytest stacks/crdop/test_bell202_line_code.py` |
+
+---
+
+## Version
+
+Frozen for **MAX25-Stack v1.0.0** (dev track `CUR999`). Changes require bump in this file + `PROTOCOL.md` + `test_crdop_backend.py`.
diff --git a/stacks/crdop/docs/INDEX.md b/stacks/crdop/docs/INDEX.md
new file mode 100644
index 0000000..91a75b4
--- /dev/null
+++ b/stacks/crdop/docs/INDEX.md
@@ -0,0 +1,77 @@
+# CRDOP documentation index
+
+**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`). Product: **MAX25-SoftModem** (`stacks/crdop/`). Version: MAX25-Stack **v1.0.0**; dev track `CUR999` in `$SRC/stacks/crdop/VERSION`.
+
+**Project rule (authoritative):** [docs/CRDOP.md](../../../docs/CRDOP.md)
+**AI agent map:** [DEVELOPMENT.md](../../../docs/DEVELOPMENT.md) (repo root)
+
+---
+
+## Start here
+
+| Audience | Doc | One-line purpose |
+|----------|-----|------------------|
+| Everyone | [SOFTMODEM.md](SOFTMODEM.md) | What CRDOP is — baud scope, duplex, ALSA path |
+| MAX25 operator | [MAX25-USAGE.md](MAX25-USAGE.md) | Build, `max25d`, plugins, terminal, HyBBX attach |
+| Developer | [DEVELOPER.md](DEVELOPER.md) | Source tree, modules, tests, extending the modem |
+| Hardware builder | [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md) | Generic radio/audio interface specification |
+| Legal / distribution | [LICENSE-USAGE.md](LICENSE-USAGE.md) | GPLv3 — private and commercial use rights |
+
+## Implementation specs (v1.0.0)
+
+| Doc | One-line purpose |
+|-----|------------------|
+| [HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md) | **Frozen** M25/KISS TCP :8515/:8516 — normative host wire |
+| [ACOUSTIC-TEST-PROTOCOL.md](ACOUSTIC-TEST-PROTOCOL.md) | P0 bench acceptance matrix |
+| [FEC-SPEC.md](FEC-SPEC.md) | Duplex + FEC strategy — implementable parameters |
+| [SOUNDCARD-QUALIFICATION.md](SOUNDCARD-QUALIFICATION.md) | Interface qualification tests (class A/B/C) |
+| [G3RUH-DESIGN.md](G3RUH-DESIGN.md) | P1 — 9600–19200 direct FSK (DEV-Level 4) |
+
+---
+
+## Technical reference
+
+| Doc | One-line purpose |
+|-----|------------------|
+| [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md) | Kernel ALSA only — sound-proxy, no PulseAudio |
+| [PROTOCOL.md](PROTOCOL.md) | M25 host summary — **normative:** [HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md) |
+| [CONFIG.md](CONFIG.md) | `crdop.ini` keys and launcher behaviour |
+| [BUILD.md](BUILD.md) | Standalone CMake build and cross-compile |
+| [EXAMPLES.md](EXAMPLES.md) | INI profiles, launcher, `max25d` snippets |
+| [CHANGELOG.md](CHANGELOG.md) | Release notes |
+
+---
+
+## Planning
+
+| Doc | One-line purpose |
+|-----|------------------|
+| [ROADMAP.md](../ROADMAP.md) | P0/P1/P2 modulation, RF paths, FEC, delivery milestones |
+
+---
+
+## Plugin registry
+
+| Id | Type | Role |
+|----|------|------|
+| `soft-crdop` | device | Production sound-card modem (`soft-modems` hardware) |
+| `audio-dummy` | device | Acoustic bench — loopback / ALSA / host (`acoustic-bench`) |
+| `soft-modems` | hardware | CRDOP device family |
+| `acoustic-bench` | hardware | Dev/test bench — no RF required |
+| `ardop-plugin` | optional | Third-party ARDOP host registry (separate from CRDOP) |
+
+---
+
+## Install tree (MAX25-Stack build)
+
+| Path | Contents |
+|------|----------|
+| `bin/crdop` | Launcher (`scripts/crdopc`) |
+| `bin/audio-dummyd` | M25 host + acoustic engine |
+| `bin/max25-signal-sniffer` | Bell 202 analysis tool |
+| `share/crdop/` | INI examples, `lib/*.py`, `VERSION` |
+| `share/hybbx/crdop-host.ini.example` | HyBBX Secondary attach |
+
+---
+
+*Compact index — depth in linked docs. Roadmap: [ROADMAP.md](../ROADMAP.md).*
diff --git a/stacks/crdop/docs/LICENSE-USAGE.md b/stacks/crdop/docs/LICENSE-USAGE.md
new file mode 100644
index 0000000..84ad5ff
--- /dev/null
+++ b/stacks/crdop/docs/LICENSE-USAGE.md
@@ -0,0 +1,116 @@
+# CRDOP license and usage rights
+
+**CRDOP** (MAX25-SoftModem) is **free software** under the **GNU General Public License version 3** (GPL-3.0-or-later).
+
+License files:
+
+| File | Scope |
+|------|-------|
+| [stacks/crdop/LICENSE](../LICENSE) | CRDOP subproject |
+| [LICENSE](../../../LICENSE) | MainAX25-Stack repository root |
+
+---
+
+## Summary (not legal advice)
+
+GPLv3 grants everyone — **private individuals and commercial entities** — the right to:
+
+| Right | Meaning |
+|-------|---------|
+| **Use** | Run CRDOP for any purpose (personal, club, business, government) |
+| **Study** | Read and learn from source code |
+| **Modify** | Change code to suit your needs |
+| **Distribute** | Share originals or modified versions |
+| **Commercial use** | Sell services, products, or support built on CRDOP |
+
+**Copyleft obligation:** When you **distribute** CRDOP (or a combined work based on it), you must:
+
+1. Provide **corresponding source** (or a written offer valid ≥ 3 years).
+2. License derivatives under **GPLv3** (or later, per "or later" clause).
+3. Preserve copyright notices and license text.
+4. Document changes if you modify files.
+5. Pass through patent retaliation and anti-tivoization terms as GPLv3 requires.
+
+**No warranty:** Software is provided **AS IS** — no implied fitness for a particular purpose.
+
+---
+
+## Private use
+
+| Scenario | Obligation |
+|----------|------------|
+| Run CRDOP on your own computer | None beyond GPLv3 acceptance |
+| Modify for personal use only | No distribution → no source-offer obligation |
+| Internal company use (no binary redistribution) | Same as private — no copyleft trigger until you distribute |
+
+---
+
+## Commercial use
+
+| Scenario | Obligation |
+|----------|------------|
+| Sell pre-built PCs with CRDOP installed | Provide source (or written offer) to recipients |
+| Ship hardware interface **with** CRDOP binaries | GPL applies to distributed software; hardware schematics you create separately may use another license if they are not a derivative of the GPL code |
+| SaaS / hosted service (no binary to users) | AGPL would impose network copyleft — **CRDOP is GPL, not AGPL**; consult counsel for your deployment model |
+| OEM integration in a product | Ensure GPL compliance for all distributed GPL components; document how users obtain source |
+
+**Practical path:** Include `LICENSE`, offer source at your support URL or ship `stacks/crdop/` tree, and state "based on CRDOP (GPLv3)".
+
+---
+
+## Combined works (MAX25-Stack)
+
+MAX25-Stack bundles CRDOP with daemon, terminal, and plugins. Distribution of the **combined stack** triggers GPLv3 obligations for the GPL-licensed portions. Other components may carry their own licenses — check root `LICENSE` and per-directory notices.
+
+| Component | Typical license |
+|-----------|-----------------|
+| `stacks/crdop/` | GPL-3.0-or-later |
+| MAX25 daemon / terminal | GPL-3.0-or-later (repository default) |
+
+---
+
+## What you may not do
+
+| Restriction | Reason |
+|-------------|--------|
+| Relicense CRDOP under proprietary terms | GPLv3 copyleft |
+| Remove copyright / license notices | GPLv3 §4 |
+| Imply warranty or liability from authors | GPLv3 §15–16 |
+| Distribute without source (when distributing binaries) | GPLv3 §6 |
+
+Patent suits against GPL users can terminate your license (GPLv3 §8).
+
+---
+
+## ARDOP-plugin (separate)
+
+**ARDOP-plugin** is an optional MAX25-Stack registry entry — **not** part of the CRDOP GPLv3 tree.
+
+| Item | Policy |
+|------|--------|
+| CRDOP coupling | **None** — third-party ARDOP host software |
+| Registry | [plugins/external/ardop/README.md](../../../plugins/external/ardop/README.md) |
+
+CRDOP GPLv3 obligations apply to **CRDOP code**, not to operator ARDOP host software.
+
+---
+
+## Contributor grants
+
+By contributing to `stacks/crdop/`, you agree that contributions are licensed under **GPL-3.0-or-later**, consistent with the repository [CONTRIBUTING.md](../../../CONTRIBUTING.md).
+
+---
+
+## Full license text
+
+- Online: <https://www.gnu.org/licenses/gpl-3.0.html>
+- In tree: [stacks/crdop/LICENSE](../LICENSE) · [LICENSE](../../../LICENSE)
+
+---
+
+## Related
+
+| Doc | Topic |
+|-----|--------|
+| [docs/CRDOP.md](../../../docs/CRDOP.md) | Project rule §5 License |
+| [INDEX.md](INDEX.md) | Documentation index |
diff --git a/stacks/crdop/docs/MAX25-USAGE.md b/stacks/crdop/docs/MAX25-USAGE.md
new file mode 100644
index 0000000..616093c
--- /dev/null
+++ b/stacks/crdop/docs/MAX25-USAGE.md
@@ -0,0 +1,292 @@
+# CRDOP in MAX25-Stack — usage guide
+
+Complete operator and integrator workflow for **MAX25-SoftModem (CRDOP)** inside MainAX25-Stack.
+
+**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`). Project rule: [docs/CRDOP.md](../../../docs/CRDOP.md).
+
+**Versions:** MAX25-Stack **v1.0.0** · CRDOP dev track **CUR999** (`$SRC/stacks/crdop/VERSION`).
+
+**Dependency:** CRDOP sources are standalone-capable in principle, but **MAX25-Stack is required** for build, `max25d`, INI, operator tooling, and plugin registry until **CRDOP-v1.0.0** marks a mature standalone release.
+
+---
+
+## Quick start
+
+```bash
+# 1. Build (CRDOP ON by default)
+./scripts/build.sh
+
+# 2. Configure daemon
+sudo cp share/max25/max25d.ini.example /etc/max25/max25d.ini
+# Edit: enable soft-crdop block (see below)
+
+# 3. Start stack
+./scripts/max25-ctl start --hardware soft-modems --device soft-crdop
+
+# 4. Terminal session
+max25-terminal -U /run/max25/modem.sock
+# or TCP: max25-terminal -H 127.0.0.1 -P 7325
+```
+
+Inside terminal: `SET DEVICE soft-crdop` → `CONNECT` → `SEND …`
+
+---
+
+## Build and install
+
+### CMake options
+
+| Option | Default | Effect |
+|--------|---------|--------|
+| `MAX25_BUILD_CRDOP` | **ON** | Build/install CRDOP scaffold + tools |
+| `MAX25_BUILD_DAEMON` | ON | `max25d` with `CrdopTcpBackend` |
+| `MAX25_BUILD_TERMINAL` | ON | `max25-terminal` / `max25-client` |
+| `CRDOP_VENDOR_ARDOPCF` | OFF | Dev-only legacy ARDOP — **never** in releases |
+
+Disable CRDOP only when not needed:
+
+```bash
+cmake -B build -DMAX25_BUILD_CRDOP=OFF
+cmake --build build -j$(nproc)
+```
+
+### Install tree
+
+After `cmake --install` (or `./scripts/build.sh` + local prefix):
+
+| Path | Contents |
+|------|----------|
+| `bin/crdop` | Launcher — starts `audio-dummyd` with INI |
+| `bin/audio-dummyd` | M25 host TCP daemon + acoustic engine |
+| `bin/max25-signal-sniffer` | Bell 202 analysis |
+| `share/crdop/crdop.ini.example` | CB profile template |
+| `share/crdop/crdop-dual.ini.example` | CB ↔ amateur preset |
+| `share/crdop/crdop-amateur.ini.example` | Amateur secondary preset |
+| `share/crdop/lib/*.py` | DSP modules (installed for tooling) |
+| `share/crdop/VERSION` | `CUR999` |
+| `share/hybbx/crdop-host.ini.example` | HyBBX Secondary attach |
+| `share/clients/soft-crdop.yaml` | Terminal device profile |
+
+---
+
+## Plugin model
+
+| Plugin id | Type | Hardware family | Role |
+|-----------|------|-----------------|------|
+| `soft-crdop` | device | `soft-modems` | Production sound-card modem |
+| `audio-dummy` | device | `acoustic-bench` | Bench — loopback / ALSA / host |
+| `soft-modems` | hardware | — | CRDOP device family |
+| `acoustic-bench` | hardware | — | Dev/test without RF |
+
+Registry: `plugins/manifest.yaml`. Discovery:
+
+```bash
+./scripts/discover-plugins.sh --json | jq '.devices[] | select(.id|test("crdop|audio-dummy"))'
+```
+
+---
+
+## max25d configuration
+
+### Production path — `soft-crdop`
+
+```ini
+[daemon]
+hardware = soft-modems
+device = soft-crdop
+
+[devices]
+default = soft-crdop
+soft-crdop = crdop:default
+
+[device.soft-crdop]
+host = 127.0.0.1
+port = 8515
+listen = yes
+```
+
+`max25-ctl start` launches `crdop` (→ `audio-dummyd`) when `auto_start = yes` in `[stack]`.
+
+### Bench path — `audio-dummy`
+
+No RF; validates DSP loopback or ALSA capture:
+
+```ini
+[daemon]
+hardware = acoustic-bench
+device = audio-dummy
+
+[devices]
+default = audio-dummy
+audio-dummy = audio:loopback
+```
+
+Modes for `audio-dummy` spec:
+
+| Spec | Behaviour |
+|------|-----------|
+| `audio:loopback` | Internal DSP encode/decode |
+| `audio:alsa:plughw:N,M` | Sniff live ALSA capture |
+| `audio:host` | Attach to running `audio-dummyd` on :8515 |
+
+Start bench:
+
+```bash
+./scripts/max25-ctl start --hardware acoustic-bench --device audio-dummy
+max25-terminal -U /run/max25/modem.sock
+```
+
+### Sniffer (no daemon)
+
+```bash
+max25-signal-sniffer --loopback
+max25-signal-sniffer -D plughw:1,0 -t 3.0
+max25-signal-sniffer --mark # 1200 Hz calibration tone
+max25-signal-sniffer --space # 2200 Hz calibration tone
+```
+
+---
+
+## crdop.ini (modem side)
+
+Copy and edit:
+
+```bash
+mkdir -p ~/.config/crdop
+cp share/crdop/crdop.ini.example ~/.config/crdop/crdop.ini
+```
+
+Key sections — full reference: [CONFIG.md](CONFIG.md).
+
+```ini
+[profile]
+radio_profile = cb ; cb | dual | amateur
+
+[modem]
+duplex = half ; half | full
+arq_bandwidth = 500MAX
+
+[mycall]
+call = CB01-0
+
+[audio]
+backend = alsa-kernel
+no_pulse = yes
+capture = plughw:1,0
+playback = plughw:1,0
+
+[host]
+port = 8515
+```
+
+Launch:
+
+```bash
+CRDOP_INI=~/.config/crdop/crdop.ini ./scripts/crdopc
+# or after install:
+crdop
+```
+
+---
+
+## max25-terminal / M25/1
+
+CRDOP uses the same M25/1 session flow as other `max25d` device backends:
+
+```
+SET DEVICE soft-crdop
+CONNECT
+SEND Hello packet world
+```
+
+| Item | Value |
+|------|-------|
+| Daemon socket | `/run/max25/modem.sock` (Linux) |
+| Daemon TCP | `7325` (configurable in `max25d.ini`) |
+| Modem host TCP | `8515` ctrl, `8516` data (CRDOP native) |
+| Client profile | `share/clients/soft-crdop.yaml` |
+
+Protocol reference: [PROTOCOL.md](PROTOCOL.md) · [include/max25/protocol.md](../../../include/max25/protocol.md).
+
+When `ax25_ui = yes` in `max25d.ini`, terminal `SEND` lines encode as AX.25 UI frames.
+
+---
+
+## HyBBX attach
+
+HyBBX is **external** — attach after MAX25 stack is up.
+
+1. Start CRDOP path:
+
+ ```bash
+ ./scripts/max25-ctl start --hardware soft-modems --device soft-crdop
+ ```
+
+2. Merge `share/hybbx/crdop-host.ini.example` into HyBBX `hybbx.ini` on Secondary:
+
+ ```ini
+ [networks]
+ crdop = yes
+
+ [transport.crdop1]
+ enabled = yes
+ modem_host = 127.0.0.1
+ modem_port = 8515
+ mycall = CB-0
+ listen = yes
+ circuit_host = main.example.com
+ circuit_port = 7323
+ ```
+
+MAX25 owns modem lifecycle; HyBBX owns sessions. See [docs/HYBBX.md](../../../docs/HYBBX.md).
+
+---
+
+## Operator profiles
+
+| INI template | Profile | Use |
+|--------------|---------|-----|
+| `crdop.ini.example` | CB | Primary — K24/K25 class channels |
+| `crdop-dual.ini.example` | dual | CB ↔ amateur switching |
+| `crdop-amateur.ini.example` | amateur | Amateur secondary |
+
+```bash
+CRDOP_INI=share/crdop-dual.ini.example ./scripts/crdopc
+```
+
+---
+
+## Testing in MAX25-Stack
+
+```bash
+./scripts/test.sh # cmake --build build --target max25_test
+cmake --build build --target max25_daemon_smoke
+./scripts/release-check.sh # install + policy checks
+```
+
+CRDOP-specific offline tests: `test_crdop_backend.py`, `test_audio_dummy_backend.py`, `test_bell202_line_code.py`.
+
+---
+
+## Troubleshooting
+
+| Symptom | Check |
+|---------|-------|
+| `crdopc not found` | Run `./scripts/build.sh`; launcher uses `audio-dummyd` from install or source tree |
+| No decode | `max25-signal-sniffer --loopback`; verify ALSA devices in INI |
+| PulseAudio hijack | `no_pulse = yes`; use `hw:` / `plughw:` explicitly |
+| Port conflict | Default :8515/:8516 — change `[host] port` + `[device.soft-crdop] port` |
+| Terminal no device | `SET DEVICE soft-crdop`; check `[devices]` in `max25d.ini` |
+
+---
+
+## Related
+
+| Doc | Topic |
+|-----|--------|
+| [INDEX.md](INDEX.md) | Full doc table |
+| [DEVELOPER.md](DEVELOPER.md) | Source, modules, extending |
+| [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md) | Radio interface spec |
+| [docs/PLUGINS-DEVICE-MODEL.md](../../../docs/PLUGINS-DEVICE-MODEL.md) | Unified device workflow |
+| [docs/LINUX-HOST-SETUP.md](../../../docs/LINUX-HOST-SETUP.md) | Host prerequisites |
+| [ROADMAP.md](../ROADMAP.md) | P0/P1/P2 milestones and modulation priorities |
diff --git a/stacks/crdop/docs/PROTOCOL.md b/stacks/crdop/docs/PROTOCOL.md
new file mode 100644
index 0000000..2d28bd4
--- /dev/null
+++ b/stacks/crdop/docs/PROTOCOL.md
@@ -0,0 +1,36 @@
+# Protocol
+
+Normative wire: **[HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md)** (frozen v1.0.0).
+
+CRDOP (**MAX25-SoftModem**) uses the **native MAX25 M25/KISS host protocol** on TCP :8515 (control) / :8516 (data).
+
+## Stack (default)
+
+```
+[Radio] ↔ [audio/ALSA] ↔ audio-dummyd / native modem ↔ TCP :8515 ctrl, :8516 data ↔ max25d
+```
+
+## Over-the-air
+
+AX.25-compatible AFSK tones (1200 baud primary, up to 19200 baud design range). See [SOFTMODEM.md](SOFTMODEM.md).
+
+## Host TCP (native M25)
+
+| Port | Role |
+|------|------|
+| 8515 | Control (line-oriented ASCII, `\n` terminated) |
+| 8516 | Data (AX.25 UI body without HDLC/FCS) |
+
+Common commands: `INITIALIZE` · `PROTOCOLMODE KISS` · `MYCALL` · `LISTEN` · `STATUS` · `PING`
+
+Implementation reference: `stacks/crdop/lib/m25_host_protocol.py`
+
+## CRDOP launch presets
+
+| Profile | Role |
+|---------|------|
+| `cb` | Default — CB / 500MAX class |
+| `dual` | CB ↔ amateur turnaround |
+| `amateur` | Amateur bandwidth class |
+
+Runtime overrides via host TCP or `crdop.ini` always apply.
diff --git a/stacks/crdop/docs/README.md b/stacks/crdop/docs/README.md
new file mode 100644
index 0000000..4a763a2
--- /dev/null
+++ b/stacks/crdop/docs/README.md
@@ -0,0 +1,31 @@
+# CRDOP documentation
+
+**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`).
+
+**MAX25-SoftModem** — in-house sound-card modem subproject (`stacks/crdop/`).
+
+## Entry point
+
+**[INDEX.md](INDEX.md)** — complete table of all CRDOP docs with one-line purpose.
+
+Project rule: [docs/CRDOP.md](../../../docs/CRDOP.md)
+
+## Policy
+
+| Rule | |
+|------|--|
+| Standard | Native MAX25 M25/KISS host on TCP :8515/:8516 |
+| Focus | CB-first; `dual` and `amateur` are presets |
+| Scope | Modem only — no host application |
+
+## Dev-only vendor tree
+
+Legacy **[pflarue/ardop](https://github.com/pflarue/ardop)** (ardopcf, MIT) may exist under `vendor/ardopcf/` for local dev builds (`-DCRDOP_VENDOR_ARDOPCF=ON`). **Never installed in MAX25 releases.**
+
+Pin: `a7c9228` · record: `vendor/ardopcf.ref`
+
+## Platforms (tested)
+
+Linux · FreeBSD — **tested** (GCC, Clang)
+
+Windows · macOS — build supported, **not yet tested**
diff --git a/stacks/crdop/docs/SOFTMODEM.md b/stacks/crdop/docs/SOFTMODEM.md
new file mode 100644
index 0000000..fef0a0e
--- /dev/null
+++ b/stacks/crdop/docs/SOFTMODEM.md
@@ -0,0 +1,127 @@
+# MAX25-SoftModem — product definition
+
+> **Authoritative rule:** [docs/CRDOP.md](../../../docs/CRDOP.md) · **Doc index:** [INDEX.md](INDEX.md)
+
+## Name and role
+
+- **Product:** MAX25-SoftModem (stack acronym **CRDOP**; plugin id `soft-crdop`)
+- **Stack version:** MAX25-Stack **v1.0.0** · **Dev track:** `CUR999` in `$SRC/stacks/crdop/VERSION`
+- **Stack:** MainAX25-Stack — **standard** component (built/installed unless `MAX25_BUILD_CRDOP=OFF`)
+- **Nature:** Pure MAX25 in-house development — in **development and test phase**, openly documented
+
+## Use like a hardware modem
+
+CRDOP uses the host **sound card IN and OUT** plus a **suitable radio** (or acoustic coupling to existing RF hardware). In `max25d` it registers like any other device backend — operators use the same M25/1 flow (`SET DEVICE`, `CONNECT`, `SEND`).
+
+```
+┌──────────┐ line or acoustic ┌─────────────┐ TCP ┌─────────┐
+│ Radio │ ◄──────────────────► │ CRDOP/ALSA │ ◄──────► │ max25d │
+└──────────┘ └─────────────┘ └─────────┘
+ │
+ 1200–19200 baud
+ half or full duplex
+```
+
+### Acoustic AX.25 compatibility
+
+On the **acoustic / RF layer**, CRDOP targets **AX.25 Packet Radio** interoperability at the PHY level:
+
+- **1200 baud Bell 202 AFSK** (1200/2200 Hz) — standard VHF/UHF and CB packet class
+- **Half- and full-duplex** per radio and audio capability
+- **Acoustic or line-level** coupling — valid test and field configuration
+
+CRDOP is an **alternative modem** when the sound card is the RF interface. It does not replace an existing UART/KISS path when that path is already in use.
+
+Interface specification for builders: [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md). MAX25-Stack usage: [MAX25-USAGE.md](MAX25-USAGE.md).
+
+## Protocol stack
+
+| Layer | Policy |
+|-------|--------|
+| On-air / acoustic | AX.25-compatible AFSK tones (1200 baud primary) |
+| Host to max25d | MAX25-native TCP control + data (see [PROTOCOL.md](PROTOCOL.md)) |
+
+We study legacy stacks; we implement our own modem and document it for **reproducible hardware builds**.
+
+## Sound path — kernel ALSA only
+
+CRDOP does **not** use PulseAudio, PipeWire-as-default, or any desktop sound server.
+
+| Layer | Function |
+|-------|----------|
+| **MAX25 sound-proxy** | Sole shim between modem DSP and ALSA — buffers, timing, duplex, xrun recovery |
+| **libasound** | Direct `hw:` / `plughw:` opens |
+| **Kernel ALSA** | Driver + `/dev/snd/*` |
+| **Hardware** | Sound card → radio interface |
+
+Configure explicit devices in `crdop.ini` `[audio]` (`capture`, `playback`). See [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md).
+
+## Baud scope
+
+| Priority | Range | Status |
+|----------|-------|--------|
+| **P0** | **1200 baud** Bell 202 AFSK | Primary — CB + VHF on-air |
+| **P1** | **9600–19200** G3RUH direct FSK | Planned after 1200 stable (VHF backbone) |
+| **P2** | **300** HF AFSK | Optional |
+| — | **>19200 baud** | **Not implemented** — do not configure or expect |
+
+Full modulation matrix, RF paths, band context, delivery milestones: **[ROADMAP.md](../ROADMAP.md)**.
+
+Higher baud rates demand **proportionally better** audio hardware (sample rate, jitter, SNR, isolation). G3RUH requires a **direct FSK radio path** — not the CB FM mic/speaker route.
+
+## Duplex
+
+| Mode | Status |
+|------|--------|
+| **Half-duplex** | Standard CB / packet-radio (PTT or VOX-style timing) |
+| **Full-duplex** | Supported when radio and audio hardware allow simultaneous TX/RX paths |
+
+Configure in `crdop.ini`: `[modem] duplex = half|full`.
+
+## Sound card — required (not optional)
+
+CRDOP must:
+
+1. **Generate** precise mark/space frequencies on **playback**.
+2. **Decode** those tones reliably on **capture**.
+
+Requirements **scale with baud**:
+
+| Baud class | Audio expectation |
+|------------|-------------------|
+| 1200 | Stable full-duplex or half-duplex USB codec or PCI interface; galvanic isolation to radio |
+| 9600–19200 | Low-jitter interface, adequate bandwidth, verified loopback before on-air |
+
+**Unsuitable:** onboard laptop mic/speaker shortcuts, anonymous USB “sound sticks”, Bluetooth bridges, unisolated consumer line inputs.
+
+Document your ALSA device in INI (`[audio] capture` / `playback`) and verify with loopback or sidetone before traffic.
+
+## Open hardware
+
+Documentation targets builders who want to **manufacture radio ↔ sound-card interfaces**:
+
+- Level matching (radio mic/speaker ↔ line level)
+- Galvanic isolation (transformers / opto)
+- PTT integration (GPIO, serial — VOX not recommended for packet)
+- Acoustic coupler fixtures for bench validation
+
+Generic interface specification: [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md). Delivery milestones: [ROADMAP.md](../ROADMAP.md).
+
+## Build / release (MAX25-Stack-v1.0.0)
+
+**CRDOP dev track:** `CUR999` in `$SRC/stacks/crdop/VERSION` (ships with MAX25-Stack v1.0.0).
+
+| Item | Shipped |
+|------|---------|
+| CRDOP scaffold (INI, launcher, install) | **Yes** — default CMake |
+| `max25d` `crdop-tcp` backend | **Yes** — offline-tested |
+| Native `crdopc` DSP binary | **In development** — in-house modem DSP |
+| ARDOP / ardopcf vendor tree | **Never** in release tarball |
+
+## HyBBX
+
+When CRDOP is active, HyBBX Secondary attaches via `share/hybbx/crdop-host.ini.example`. HyBBX remains an external project.
+
+## Testing phase
+
+Current work: loopback audio, acoustic bench validation, field on-air trials, acceptable sound-card criteria. Report results via project issues — RF acceptance remains manual until automated gates exist. Developer tests: [DEVELOPER.md](DEVELOPER.md).
diff --git a/stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md b/stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md
new file mode 100644
index 0000000..b35d55b
--- /dev/null
+++ b/stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md
@@ -0,0 +1,95 @@
+# Sound card qualification — CRDOP / MAX25-SoftModem
+
+**Purpose:** Measurable criteria for interface hardware used with CRDOP. No product endorsements — class-based requirements.
+
+See also [ROADMAP.md](../ROADMAP.md) (P0/P1 audio requirements).
+
+---
+
+## Mandatory architecture
+
+```
+CRDOP DSP → sound_proxy → libasound (hw:) → codec → radio interface
+```
+
+| Forbidden in production path | Reason |
+|------------------------------|--------|
+| PulseAudio default route | Mixing, resampling, latency |
+| PipeWire default | Variable buffer |
+| Bluetooth A2DP/HFP | Codec + AGC |
+
+INI: `[audio] backend = alsa-kernel`, `no_pulse = yes`.
+
+---
+
+## Sample rate
+
+| Baud target | Minimum rate | CRDOP default |
+|-------------|--------------|---------------|
+| 1200 AFSK | 48000 Hz | **48000** |
+| 9600 G3RUH (audio tap) | 48000–96000 | 48000 + tuning |
+| 19200 | 96000 Hz | 96000 if hardware supports |
+
+---
+
+## Qualification tests
+
+Run on candidate interface **before** field deployment.
+
+| ID | Test | Pass |
+|----|------|------|
+| **Q1** | Loopback cable, 1200 Hz sine 2 s | THD acceptable by ear + FFT peak at 1200 ±2 Hz |
+| **Q2** | Loopback Bell 202 sniffer | `max25-signal-sniffer --loopback` or ALSA loop |
+| **Q3** | Mark/space calibration tones | `--mark` / `--space` tools within ±5 Hz |
+| **Q4** | 60 s full-duplex record+play | No xrun in `dmesg` / ALSA log |
+| **Q5** | Clock drift | 10 min loopback: no cumulative symbol slip at 1200 bd |
+
+---
+
+## Interface classes
+
+| Class | Isolation | 1200 AFSK | 19200 path |
+|-------|-----------|-----------|------------|
+| **A** | Galvanic line I/O, fixed gain | Required minimum | Test Q1–Q5 |
+| **B** | USB codec, headphone/mic level | Acceptable with level match | Not recommended |
+| **C** | Onboard mic/speaker only | Acoustic bench only | Fail |
+
+CRDOP production path: **Class A or B** with line-level radio interface — see [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md).
+
+---
+
+## Level guidelines
+
+| Path | Typical level |
+|------|---------------|
+| Line OUT → radio mic in | 100–300 mV RMS (adjust pad) |
+| Radio speaker/line → Line IN | Attenuate to avoid clipping |
+| DC offset | AC coupling required |
+
+---
+
+## Baud scaling
+
+Requirements **rise faster than linear** above 1200 bd:
+
+| Baud | Clock jitter tolerance | Filter flatness |
+|------|------------------------|-----------------|
+| 1200 | moderate | 300–3000 Hz FM audio path OK |
+| 9600 | strict | 10 Hz–5 kHz flat, constant group delay |
+| 19200 | professional | Direct FSK tap — not soundcard mic path |
+
+---
+
+## Failure actions
+
+| Symptom | Action |
+|---------|--------|
+| xruns | Increase `period_frames`; reduce system load |
+| Wrong tones | Verify 48000 Hz; check resampler not inserted |
+| Intermittent decode | Replace Class C with A/B; check ground loop |
+
+---
+
+## Community list (planned)
+
+P0 bench exit: publish operator-submitted **class + Q1–Q2 result** table — no vendor ranking.
diff --git a/stacks/crdop/include/crdop/sound_proxy.h b/stacks/crdop/include/crdop/sound_proxy.h
new file mode 100644
index 0000000..ce0efcb
--- /dev/null
+++ b/stacks/crdop/include/crdop/sound_proxy.h
@@ -0,0 +1,52 @@
+/*
+ * MAX25 sound-proxy — CRDOP ↔ kernel ALSA (no PulseAudio).
+ *
+ * In-process shim between modem DSP and libasound. Only this layer opens
+ * snd_pcm capture/playback on hw:/plughw: devices. max25d does not touch audio.
+ *
+ * Eigenentwicklung — API stable for v1 scaffold; implementation in progress.
+ */
+#ifndef MAX25_CRDOP_SOUND_PROXY_H
+#define MAX25_CRDOP_SOUND_PROXY_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** ALSA backend policy — kernel path only; no PulseAudio/PipeWire route. */
+#define MAX25_SOUND_BACKEND_ALSA_KERNEL "alsa-kernel"
+
+typedef struct max25_sound_proxy max25_sound_proxy_t;
+
+typedef struct max25_sound_config {
+ const char *capture_device; /* e.g. "hw:1,0" */
+ const char *playback_device; /* e.g. "hw:1,0" */
+ unsigned int sample_rate; /* Hz — modem-dependent */
+ unsigned int period_frames;
+ unsigned int buffer_frames;
+ int full_duplex; /* 0 = half, 1 = full */
+ int forbid_pulse; /* non-zero: reject pulse/pipewire pseudo devices */
+} max25_sound_config_t;
+
+/** Open ALSA via kernel path; returns NULL on failure (pulse device rejected if forbid_pulse). */
+max25_sound_proxy_t *max25_sound_proxy_open(const max25_sound_config_t *cfg);
+
+void max25_sound_proxy_close(max25_sound_proxy_t *sp);
+
+/** Capture PCM frames (RX path). Returns frames read or negative errno-style code. */
+int max25_sound_proxy_read(max25_sound_proxy_t *sp, int16_t *buf, size_t frames);
+
+/** Playback PCM frames (TX path). Returns frames written or negative code. */
+int max25_sound_proxy_write(max25_sound_proxy_t *sp, const int16_t *buf, size_t frames);
+
+/** Recover from xrun — modem may call between frames. */
+int max25_sound_proxy_recover(max25_sound_proxy_t *sp);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* MAX25_CRDOP_SOUND_PROXY_H */
diff --git a/stacks/crdop/include/crdop/version.h b/stacks/crdop/include/crdop/version.h
new file mode 100644
index 0000000..249ef32
--- /dev/null
+++ b/stacks/crdop/include/crdop/version.h
@@ -0,0 +1,8 @@
+#ifndef CRDOP_VERSION_H
+#define CRDOP_VERSION_H
+
+#define CRDOP_PRODUCT_NAME "CRDOP-CUR999"
+#define CRDOP_VERSION_STRING "CUR999"
+#define CRDOP_VENDOR_BASE "native"
+
+#endif
diff --git a/stacks/crdop/lib/__init__.py b/stacks/crdop/lib/__init__.py
new file mode 100644
index 0000000..2137436
--- /dev/null
+++ b/stacks/crdop/lib/__init__.py
@@ -0,0 +1 @@
+"""MAX25-SoftModem (CRDOP) — native DSP library (Bell 202 AFSK, HDLC, ALSA)."""
diff --git a/stacks/crdop/lib/acoustic_engine.py b/stacks/crdop/lib/acoustic_engine.py
new file mode 100644
index 0000000..47989ec
--- /dev/null
+++ b/stacks/crdop/lib/acoustic_engine.py
@@ -0,0 +1,89 @@
+"""
+Acoustic bench engine — modulate/demodulate/sniff without RF.
+
+Used by audio-dummy device and max25-signal-sniffer.
+"""
+from __future__ import annotations
+
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+
+_LIB = Path(__file__).resolve().parent
+if str(_LIB) not in sys.path:
+ sys.path.insert(0, str(_LIB))
+
+from afsk_demodulator import AfskDemodulator # noqa: E402
+from afsk_modulator import AfskModulator # noqa: E402
+from bell202_line_code import TONE_MARK, TONE_SPACE # noqa: E402
+from hdlc_codec import build_hdlc_frame, parse_hdlc_stream # noqa: E402
+from sound_proxy import SoundConfig, create_sound_proxy # noqa: E402
+
+_ROOT = Path(__file__).resolve().parents[3]
+_DAEMON = _ROOT / "stacks" / "daemon"
+if str(_DAEMON) not in sys.path:
+ sys.path.insert(0, str(_DAEMON))
+from ax25_codec import ax25_build_ui, ax25_parse_ui # noqa: E402
+
+
+@dataclass
+class SniffReport:
+ samples: int = 0
+ symbols: int = 0
+ mark_ratio: float = 0.0
+ space_ratio: float = 0.0
+ transitions: int = 0
+ frames: list[bytes] = field(default_factory=list)
+ decode_lines: list[str] = field(default_factory=list)
+
+
+class AcousticEngine:
+ def __init__(
+ self,
+ sample_rate: int = 48000,
+ baud: int = 1200,
+ sound: Optional[SoundConfig] = None,
+ ) -> None:
+ self.sample_rate = sample_rate
+ self.baud = baud
+ self.mod = AfskModulator(sample_rate, baud)
+ self.demod = AfskDemodulator(sample_rate, baud)
+ self.sound = sound or SoundConfig(sample_rate=sample_rate)
+
+ def encode_ax25_ui(self, src: str, dst: str, text: str) -> bytes:
+ body = ax25_build_ui(src, dst, text.encode("utf-8"))
+ # strip FCS for KISS-style host body, then build on-air HDLC
+ if len(body) >= 2:
+ body = body[:-2]
+ hdlc = build_hdlc_frame(body)
+ return self.mod.modulate_bits(hdlc)
+
+ def analyze_pcm(self, pcm: bytes) -> SniffReport:
+ rep = SniffReport(samples=len(pcm) // 2)
+ tones = self.demod.demodulate_pcm(pcm)
+ rep.symbols = len(tones)
+ if not tones:
+ return rep
+ marks = sum(1 for t in tones if t == TONE_MARK)
+ rep.mark_ratio = marks / len(tones)
+ rep.space_ratio = 1.0 - rep.mark_ratio
+ rep.transitions = sum(1 for i in range(1, len(tones)) if tones[i] != tones[i - 1])
+ raw_bits = self.demod.demodulate_to_bits(pcm)
+ for frame in parse_hdlc_stream(raw_bits):
+ rep.frames.append(frame)
+ parsed = ax25_parse_ui(frame)
+ if parsed:
+ src, dst, info = parsed
+ rep.decode_lines.append(f"[AX25 UI {src}>{dst}] {info.decode('utf-8', errors='replace')}")
+ return rep
+
+ def loopback_self_test(self, src: str = "TST-0", dst: str = "QST") -> SniffReport:
+ pcm = self.encode_ax25_ui(src, dst, "LOOP")
+ return self.analyze_pcm(pcm)
+
+ def play_mark_calibration(self, seconds: float = 1.0) -> None:
+ create_sound_proxy(self.sound).play_pcm(self.mod.steady_tone(TONE_MARK, seconds))
+
+ def play_space_calibration(self, seconds: float = 1.0) -> None:
+ create_sound_proxy(self.sound).play_pcm(self.mod.steady_tone(TONE_SPACE, seconds))
diff --git a/stacks/crdop/lib/afsk_demodulator.py b/stacks/crdop/lib/afsk_demodulator.py
new file mode 100644
index 0000000..5ba3d8b
--- /dev/null
+++ b/stacks/crdop/lib/afsk_demodulator.py
@@ -0,0 +1,56 @@
+"""
+AFSK demodulator — Bell 202 mark/space discrimination per symbol period.
+
+Uses per-symbol Goertzel energy at 1200 Hz and 2200 Hz (Dire Wolf class approach).
+"""
+from __future__ import annotations
+
+import math
+import struct
+from array import array
+
+from bell202_line_code import TONE_MARK, TONE_SPACE, decode_tones_to_bits
+
+_TWO_PI = 2.0 * math.pi
+
+
+def _goertzel_power(samples: array, freq: float, sample_rate: int) -> float:
+ n = len(samples)
+ if n < 8:
+ return 0.0
+ k = int(0.5 + (n * freq) / sample_rate)
+ w = _TWO_PI * k / n
+ coeff = 2.0 * math.cos(w)
+ s0 = s1 = s2 = 0.0
+ for x in samples:
+ s0 = x + coeff * s1 - s2
+ s2 = s1
+ s1 = s0
+ return s1 * s1 + s2 * s2 - coeff * s1 * s2
+
+
+class AfskDemodulator:
+ def __init__(self, sample_rate: int = 48000, baud: int = 1200) -> None:
+ self.sample_rate = sample_rate
+ self.baud = baud
+ self.samples_per_symbol = sample_rate // baud
+
+ def demodulate_pcm(self, pcm: bytes) -> list[int]:
+ """Return mark/space tone sequence from mono S16_LE PCM."""
+ count = len(pcm) // 2
+ if count < self.samples_per_symbol:
+ return []
+ samples = array("h")
+ samples.frombytes(pcm[: count * 2])
+ tones: list[int] = []
+ pos = 0
+ while pos + self.samples_per_symbol <= len(samples):
+ window = samples[pos : pos + self.samples_per_symbol]
+ pos += self.samples_per_symbol
+ p_mark = _goertzel_power(window, 1200.0, self.sample_rate)
+ p_space = _goertzel_power(window, 2200.0, self.sample_rate)
+ tones.append(TONE_MARK if p_mark >= p_space else TONE_SPACE)
+ return tones
+
+ def demodulate_to_bits(self, pcm: bytes, start_tone: int = TONE_SPACE) -> bytes:
+ return decode_tones_to_bits(self.demodulate_pcm(pcm), start_tone=start_tone)
diff --git a/stacks/crdop/lib/afsk_modulator.py b/stacks/crdop/lib/afsk_modulator.py
new file mode 100644
index 0000000..1a6bf68
--- /dev/null
+++ b/stacks/crdop/lib/afsk_modulator.py
@@ -0,0 +1,51 @@
+"""
+Continuous-phase AFSK modulator — Bell 202 mark 1200 Hz / space 2200 Hz.
+"""
+from __future__ import annotations
+
+import math
+import struct
+from array import array
+
+from bell202_line_code import MARK_HZ, SPACE_HZ, TONE_MARK, encode_bits_to_tones
+
+_TWO_PI = 2.0 * math.pi
+
+
+class AfskModulator:
+ def __init__(self, sample_rate: int = 48000, baud: int = 1200) -> None:
+ self.sample_rate = sample_rate
+ self.baud = baud
+ self._phase = 0.0
+
+ def _tone_freq(self, tone: int) -> float:
+ return MARK_HZ if tone == TONE_MARK else SPACE_HZ
+
+ def modulate_bits(self, bits: bytes, start_tone: int = 0) -> bytes:
+ """Return mono S16_LE PCM for the given bit stream."""
+ tones = encode_bits_to_tones(bits, start_tone=start_tone)
+ samples_per_symbol = self.sample_rate // self.baud
+ out: array[int] = array("h")
+ for tone in tones:
+ freq = self._tone_freq(tone)
+ step = _TWO_PI * freq / self.sample_rate
+ for _ in range(samples_per_symbol):
+ sample = int(0.7 * 32767.0 * math.sin(self._phase))
+ out.append(sample)
+ self._phase += step
+ if self._phase >= _TWO_PI:
+ self._phase -= _TWO_PI
+ return struct.pack(f"<{len(out)}h", *out)
+
+ def steady_tone(self, tone: int, duration_s: float) -> bytes:
+ """Calibration tone (Dire Wolf -x style mark/space hold)."""
+ n = int(self.sample_rate * duration_s)
+ freq = self._tone_freq(tone)
+ step = _TWO_PI * freq / self.sample_rate
+ out: array[int] = array("h")
+ for _ in range(n):
+ out.append(int(0.7 * 32767.0 * math.sin(self._phase)))
+ self._phase += step
+ if self._phase >= _TWO_PI:
+ self._phase -= _TWO_PI
+ return struct.pack(f"<{len(out)}h", *out)
diff --git a/stacks/crdop/lib/bell202_line_code.py b/stacks/crdop/lib/bell202_line_code.py
new file mode 100644
index 0000000..cd2621f
--- /dev/null
+++ b/stacks/crdop/lib/bell202_line_code.py
@@ -0,0 +1,50 @@
+"""
+Bell 202 frequency-toggle line code (1200 baud AFSK layer).
+
+On-air rule: bit 0 → toggle mark/space tone; bit 1 → hold current tone.
+We avoid the legacy term in API names — this is Bell 202 / amateur packet radio.
+"""
+from __future__ import annotations
+
+MARK_HZ = 1200.0
+SPACE_HZ = 2200.0
+BAUD_1200 = 1200
+
+TONE_MARK = 1
+TONE_SPACE = 0
+
+
+def encode_bits_to_tones(bits: bytes, start_tone: int = TONE_SPACE) -> list[int]:
+ """Map HDLC bit stream to mark/space tone sequence (one tone per bit)."""
+ tone = start_tone
+ out: list[int] = []
+ for byte in bits:
+ for bit_i in range(7, -1, -1):
+ bit = (byte >> bit_i) & 1
+ if bit == 0:
+ tone ^= 1
+ out.append(tone)
+ return out
+
+
+def decode_tones_to_bits(tones: list[int], start_tone: int = TONE_SPACE) -> bytes:
+ """Recover bit stream from mark/space tone sequence."""
+ if not tones:
+ return b""
+ prev = start_tone
+ bits: list[int] = []
+ for tone in tones:
+ if tone not in (TONE_MARK, TONE_SPACE):
+ continue
+ bits.append(0 if tone != prev else 1)
+ prev = tone
+ buf = bytearray()
+ for i in range(0, len(bits), 8):
+ chunk = bits[i : i + 8]
+ if len(chunk) < 8:
+ break
+ val = 0
+ for b in chunk:
+ val = (val << 1) | b
+ buf.append(val)
+ return bytes(buf)
diff --git a/stacks/crdop/lib/hdlc_codec.py b/stacks/crdop/lib/hdlc_codec.py
new file mode 100644
index 0000000..942168b
--- /dev/null
+++ b/stacks/crdop/lib/hdlc_codec.py
@@ -0,0 +1,110 @@
+"""
+HDLC framing for AX.25 — flags, bit-stuffing, CRC-16-CCITT.
+"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parents[3]
+_DAEMON = _ROOT / "stacks" / "daemon"
+if str(_DAEMON) not in sys.path:
+ sys.path.insert(0, str(_DAEMON))
+
+from ax25_codec import ax25_crc # noqa: E402
+
+HDLC_FLAG = 0x7E
+HDLC_ESCAPE = 0x7D
+HDLC_XOR = 0x20
+
+
+def bit_stuff(data: bytes) -> bytes:
+ out = bytearray()
+ ones = 0
+ for b in data:
+ for bit_i in range(7, -1, -1):
+ bit = (b >> bit_i) & 1
+ if bit:
+ ones += 1
+ out.append(1)
+ if ones == 5:
+ out.append(0)
+ ones = 0
+ else:
+ ones = 0
+ out.append(0)
+ # pack bits to bytes MSB-first
+ buf = bytearray()
+ acc = 0
+ n = 0
+ for bit in out:
+ acc = (acc << 1) | bit
+ n += 1
+ if n == 8:
+ buf.append(acc)
+ acc = 0
+ n = 0
+ if n:
+ buf.append(acc << (8 - n))
+ return bytes(buf)
+
+
+def bit_unstuff(data: bytes) -> bytes:
+ bits: list[int] = []
+ for b in data:
+ for bit_i in range(7, -1, -1):
+ bits.append((b >> bit_i) & 1)
+ out: list[int] = []
+ ones = 0
+ i = 0
+ while i < len(bits):
+ bit = bits[i]
+ if bit:
+ ones += 1
+ out.append(1)
+ if ones == 5 and i + 1 < len(bits) and bits[i + 1] == 0:
+ i += 1
+ ones = 0
+ elif ones == 6:
+ break
+ else:
+ ones = 0
+ out.append(0)
+ i += 1
+ buf = bytearray()
+ for j in range(0, len(out), 8):
+ chunk = out[j : j + 8]
+ if len(chunk) < 8:
+ break
+ val = 0
+ for bit in chunk:
+ val = (val << 1) | bit
+ buf.append(val)
+ return bytes(buf)
+
+
+def build_hdlc_frame(ax25_body: bytes) -> bytes:
+ """AX.25 body (no FCS) → on-air HDLC bit stream as bytes (pre tone encoding)."""
+ crc = ax25_crc(ax25_body)
+ payload = ax25_body + bytes((crc & 0xFF, crc >> 8))
+ stuffed = bit_stuff(payload)
+ return bytes([HDLC_FLAG]) + stuffed + bytes([HDLC_FLAG])
+
+
+def parse_hdlc_stream(raw_bits: bytes) -> list[bytes]:
+ """Extract AX.25 bodies (with FCS) from demodulated byte stream between flags."""
+ frames: list[bytes] = []
+ in_frame = False
+ cur = bytearray()
+ for b in raw_bits:
+ if b == HDLC_FLAG:
+ if in_frame and cur:
+ unstuffed = bit_unstuff(bytes(cur))
+ if len(unstuffed) >= 2:
+ frames.append(unstuffed)
+ cur.clear()
+ in_frame = True
+ continue
+ if in_frame:
+ cur.append(b)
+ return frames
diff --git a/stacks/crdop/lib/m25_host_protocol.py b/stacks/crdop/lib/m25_host_protocol.py
new file mode 100644
index 0000000..9212336
--- /dev/null
+++ b/stacks/crdop/lib/m25_host_protocol.py
@@ -0,0 +1,142 @@
+"""
+MAX25 native host protocol for SoftModem / audio-dummy (design freeze).
+
+Payload on data channel = AX.25 UI body **without** HDLC/FCS (same as KISS DATA).
+Control channel = line-oriented ASCII commands (M25-family, not ARDOP FEC/ARQ).
+"""
+from __future__ import annotations
+
+import socket
+import threading
+from typing import Callable, Optional
+
+# KISS-compatible data semantics; control is text lines ending in \\n
+DEFAULT_CTRL_PORT = 8515
+DEFAULT_DATA_PORT = 8516
+
+CmdFn = Callable[[str], str]
+DataRxFn = Callable[[bytes], None]
+
+
+class M25SoftModemHost:
+ """Minimal TCP host for bench / audio-dummy (ctrl + data ports)."""
+
+ def __init__(
+ self,
+ ctrl_port: int = DEFAULT_CTRL_PORT,
+ data_port: int = DEFAULT_DATA_PORT,
+ on_data_tx: Optional[Callable[[bytes], str]] = None,
+ ) -> None:
+ self.ctrl_port = ctrl_port
+ self.data_port = data_port
+ self._on_data_tx = on_data_tx or (lambda _b: "OK")
+ self._stop = threading.Event()
+ self._ctrl_srv: Optional[socket.socket] = None
+ self._data_srv: Optional[socket.socket] = None
+ self._threads: list[threading.Thread] = []
+ self._mycall = "NOCALL-0"
+ self._listen = True
+
+ def start(self) -> None:
+ self._ctrl_srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self._ctrl_srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ self._ctrl_srv.bind(("127.0.0.1", self.ctrl_port))
+ self._ctrl_srv.listen(4)
+ self._data_srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self._data_srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ self._data_srv.bind(("127.0.0.1", self.data_port))
+ self._data_srv.listen(4)
+ self._stop.clear()
+ for target, name in ((self._accept_ctrl, "ctrl"), (self._accept_data, "data")):
+ t = threading.Thread(target=target, name=f"m25-host-{name}", daemon=True)
+ t.start()
+ self._threads.append(t)
+
+ def stop(self) -> None:
+ self._stop.set()
+ for srv in (self._ctrl_srv, self._data_srv):
+ if srv is not None:
+ try:
+ srv.close()
+ except OSError:
+ pass
+ self._ctrl_srv = None
+ self._data_srv = None
+
+ def _accept_ctrl(self) -> None:
+ assert self._ctrl_srv is not None
+ while not self._stop.is_set():
+ try:
+ self._ctrl_srv.settimeout(0.5)
+ conn, _ = self._ctrl_srv.accept()
+ except (OSError, socket.timeout):
+ continue
+ threading.Thread(
+ target=self._ctrl_session,
+ args=(conn,),
+ daemon=True,
+ ).start()
+
+ def _ctrl_session(self, conn: socket.socket) -> None:
+ buf = b""
+ try:
+ conn.settimeout(0.5)
+ while not self._stop.is_set():
+ try:
+ chunk = conn.recv(4096)
+ except socket.timeout:
+ continue
+ if not chunk:
+ break
+ buf += chunk
+ while b"\n" in buf:
+ raw, buf = buf.split(b"\n", 1)
+ line = raw.decode("ascii", errors="replace").strip()
+ if not line:
+ continue
+ reply = self._handle_cmd(line)
+ conn.sendall((reply + "\n").encode("ascii"))
+ finally:
+ conn.close()
+
+ def _handle_cmd(self, line: str) -> str:
+ parts = line.split()
+ cmd = parts[0].upper() if parts else ""
+ if cmd == "INITIALIZE":
+ return "OK"
+ if cmd == "PROTOCOLMODE" and len(parts) > 1 and parts[1].upper() == "KISS":
+ return "OK"
+ if cmd == "MYCALL" and len(parts) > 1:
+ self._mycall = parts[1].upper()
+ return "OK"
+ if cmd == "LISTEN":
+ self._listen = len(parts) < 2 or parts[1].upper() in ("TRUE", "1", "ON", "YES")
+ return "OK"
+ if cmd == "PING":
+ return "OK"
+ if cmd == "STATUS":
+ return f"STATUS ready mycall={self._mycall}"
+ return "ERR unknown command"
+
+ def _accept_data(self) -> None:
+ assert self._data_srv is not None
+ while not self._stop.is_set():
+ try:
+ self._data_srv.settimeout(0.5)
+ conn, _ = self._data_srv.accept()
+ except (OSError, socket.timeout):
+ continue
+ threading.Thread(
+ target=self._data_session,
+ args=(conn,),
+ daemon=True,
+ ).start()
+
+ def _data_session(self, conn: socket.socket) -> None:
+ try:
+ payload = conn.recv(4096)
+ if payload:
+ reply = self._on_data_tx(payload)
+ conn.sendall(reply.encode("ascii", errors="replace"))
+ finally:
+ conn.close()
diff --git a/stacks/crdop/lib/sound_proxy.py b/stacks/crdop/lib/sound_proxy.py
new file mode 100644
index 0000000..de9b71d
--- /dev/null
+++ b/stacks/crdop/lib/sound_proxy.py
@@ -0,0 +1,185 @@
+"""
+MAX25 sound-proxy — host audio capture/playback.
+
+Linux/KLinux: ALSA (arecord/aplay).
+FreeBSD: OSS via sound_proxy_oss (sox or /dev/dsp).
+"""
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import sys
+import threading
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable, Optional, Protocol, runtime_checkable
+
+
+@dataclass
+class SoundConfig:
+ capture: str = "default"
+ playback: str = "default"
+ sample_rate: int = 48000
+ channels: int = 1
+ period_frames: int = 256
+ forbid_pulse: bool = True
+ backend: str = "" # alsa | oss — empty = auto from platform
+
+
+@runtime_checkable
+class SoundProxyProto(Protocol):
+ def start_capture(self) -> None: ...
+ def read_capture(self, nbytes: int) -> bytes: ...
+ def play_pcm(self, pcm: bytes) -> None: ...
+ def sniff_loop(
+ self,
+ chunk_symbols: int,
+ on_pcm: Callable[[bytes], None],
+ stop: Optional[threading.Event] = None,
+ ) -> None: ...
+ def close(self) -> None: ...
+
+
+def _detect_backend(cfg: SoundConfig) -> str:
+ explicit = (cfg.backend or os.environ.get("MAX25_AUDIO_BACKEND", "")).strip().lower()
+ if explicit in ("alsa", "oss"):
+ return explicit
+ if sys.platform.startswith("freebsd"):
+ return "oss"
+ return "alsa"
+
+
+def create_sound_proxy(cfg: SoundConfig) -> SoundProxyProto:
+ backend = _detect_backend(cfg)
+ if backend == "oss":
+ from sound_proxy_oss import OssSoundConfig, OssSoundProxy
+
+ cap = cfg.capture if cfg.capture not in ("", "default") else "/dev/dsp"
+ pb = cfg.playback if cfg.playback not in ("", "default") else cap
+ return OssSoundProxy(
+ OssSoundConfig(
+ capture=cap,
+ playback=pb,
+ sample_rate=cfg.sample_rate,
+ channels=cfg.channels,
+ )
+ )
+ return AlsaSoundProxy(cfg)
+
+
+class AlsaSoundProxy:
+ def __init__(self, cfg: SoundConfig) -> None:
+ self.cfg = cfg
+ self._rec_proc: Optional[subprocess.Popen[bytes]] = None
+ self._play_proc: Optional[subprocess.Popen[bytes]] = None
+ self._stop = threading.Event()
+
+ def _alsa_env(self) -> dict[str, str]:
+ env = os.environ.copy()
+ if self.cfg.forbid_pulse:
+ env.pop("PULSE_SERVER", None)
+ env.pop("PIPEWIRE_RUNTIME_DIR", None)
+ env["PULSE_SERVER"] = ""
+ return env
+
+ @staticmethod
+ def _check_device(name: str) -> None:
+ low = name.lower()
+ if low in ("default", "pulse", "pipewire") or "pulse" in low or "pipewire" in low:
+ raise ValueError(
+ f"audio device {name!r} not allowed — use hw: or plughw: (kernel ALSA)"
+ )
+
+ def start_capture(self) -> None:
+ if not shutil.which("arecord"):
+ raise RuntimeError("arecord not found — install alsa-utils")
+ self._check_device(self.cfg.capture)
+ cmd = [
+ "arecord",
+ "-q",
+ "-D",
+ self.cfg.capture,
+ "-f",
+ "S16_LE",
+ "-r",
+ str(self.cfg.sample_rate),
+ "-c",
+ str(self.cfg.channels),
+ "-t",
+ "raw",
+ ]
+ self._rec_proc = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ env=self._alsa_env(),
+ )
+
+ def read_capture(self, nbytes: int) -> bytes:
+ if self._rec_proc is None or self._rec_proc.stdout is None:
+ return b""
+ return self._rec_proc.stdout.read(nbytes) or b""
+
+ def play_pcm(self, pcm: bytes) -> None:
+ if not pcm:
+ return
+ if not shutil.which("aplay"):
+ raise RuntimeError("aplay not found — install alsa-utils")
+ self._check_device(self.cfg.playback)
+ cmd = [
+ "aplay",
+ "-q",
+ "-D",
+ self.cfg.playback,
+ "-f",
+ "S16_LE",
+ "-r",
+ str(self.cfg.sample_rate),
+ "-c",
+ str(self.cfg.channels),
+ "-t",
+ "raw",
+ ]
+ subprocess.run(
+ cmd,
+ input=pcm,
+ check=False,
+ env=self._alsa_env(),
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+ def sniff_loop(
+ self,
+ chunk_symbols: int,
+ on_pcm: Callable[[bytes], None],
+ stop: Optional[threading.Event] = None,
+ ) -> None:
+ stop_ev = stop or self._stop
+ frame_bytes = (self.cfg.sample_rate // 1200) * 2 * chunk_symbols
+ self.start_capture()
+ try:
+ while not stop_ev.is_set():
+ chunk = self.read_capture(frame_bytes)
+ if not chunk:
+ break
+ on_pcm(chunk)
+ finally:
+ self.close()
+
+ def close(self) -> None:
+ self._stop.set()
+ for proc in (self._rec_proc, self._play_proc):
+ if proc is not None and proc.poll() is None:
+ proc.terminate()
+ try:
+ proc.wait(timeout=1.0)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ self._rec_proc = None
+ self._play_proc = None
+
+
+# Backward-compatible alias
+SoundProxy = AlsaSoundProxy
diff --git a/stacks/crdop/lib/sound_proxy_oss.py b/stacks/crdop/lib/sound_proxy_oss.py
new file mode 100644
index 0000000..66cf765
--- /dev/null
+++ b/stacks/crdop/lib/sound_proxy_oss.py
@@ -0,0 +1,151 @@
+"""
+MAX25 sound-proxy — FreeBSD/OSS capture and playback.
+
+Uses `sox` with OSS devices when available; falls back to raw /dev/dsp read/write.
+"""
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import threading
+from dataclasses import dataclass
+from typing import Callable, Optional
+
+
+@dataclass
+class OssSoundConfig:
+ capture: str = "/dev/dsp"
+ playback: str = "/dev/dsp"
+ sample_rate: int = 48000
+ channels: int = 1
+
+
+class OssSoundProxy:
+ def __init__(self, cfg: OssSoundConfig) -> None:
+ self.cfg = cfg
+ self._rec_proc: Optional[subprocess.Popen[bytes]] = None
+ self._dsp_fd: Optional[int] = None
+ self._stop = threading.Event()
+ self._use_sox = shutil.which("sox") is not None
+
+ def _sox_capture_cmd(self) -> list[str]:
+ dev = self.cfg.capture
+ return [
+ "sox",
+ "-q",
+ "-t",
+ "oss",
+ dev,
+ "-r",
+ str(self.cfg.sample_rate),
+ "-c",
+ str(self.cfg.channels),
+ "-b",
+ "16",
+ "-e",
+ "signed-integer",
+ "-t",
+ "raw",
+ "-",
+ ]
+
+ def _sox_play_cmd(self) -> list[str]:
+ dev = self.cfg.playback
+ return [
+ "sox",
+ "-q",
+ "-t",
+ "raw",
+ "-r",
+ str(self.cfg.sample_rate),
+ "-c",
+ str(self.cfg.channels),
+ "-b",
+ "16",
+ "-e",
+ "signed-integer",
+ "-",
+ "-t",
+ "oss",
+ dev,
+ ]
+
+ def start_capture(self) -> None:
+ if self._use_sox:
+ self._rec_proc = subprocess.Popen(
+ self._sox_capture_cmd(),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ )
+ return
+ flags = os.O_RDONLY
+ try:
+ self._dsp_fd = os.open(self.cfg.capture, flags)
+ except OSError as exc:
+ raise RuntimeError(f"OSS open {self.cfg.capture}: {exc}") from exc
+
+ def read_capture(self, nbytes: int) -> bytes:
+ if self._rec_proc is not None and self._rec_proc.stdout is not None:
+ return self._rec_proc.stdout.read(nbytes) or b""
+ if self._dsp_fd is not None:
+ try:
+ return os.read(self._dsp_fd, nbytes) or b""
+ except OSError:
+ return b""
+ return b""
+
+ def play_pcm(self, pcm: bytes) -> None:
+ if not pcm:
+ return
+ if self._use_sox:
+ subprocess.run(
+ self._sox_play_cmd(),
+ input=pcm,
+ check=False,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ return
+ try:
+ fd = os.open(self.cfg.playback, os.O_WRONLY)
+ except OSError:
+ return
+ try:
+ os.write(fd, pcm)
+ finally:
+ os.close(fd)
+
+ def sniff_loop(
+ self,
+ chunk_symbols: int,
+ on_pcm: Callable[[bytes], None],
+ stop: Optional[threading.Event] = None,
+ ) -> None:
+ stop_ev = stop or self._stop
+ frame_bytes = max(256, (self.cfg.sample_rate // 1200) * 2 * chunk_symbols)
+ self.start_capture()
+ try:
+ while not stop_ev.is_set():
+ chunk = self.read_capture(frame_bytes)
+ if not chunk:
+ break
+ on_pcm(chunk)
+ finally:
+ self.close()
+
+ def close(self) -> None:
+ self._stop.set()
+ if self._rec_proc is not None and self._rec_proc.poll() is None:
+ self._rec_proc.terminate()
+ try:
+ self._rec_proc.wait(timeout=1.0)
+ except subprocess.TimeoutExpired:
+ self._rec_proc.kill()
+ self._rec_proc = None
+ if self._dsp_fd is not None:
+ try:
+ os.close(self._dsp_fd)
+ except OSError:
+ pass
+ self._dsp_fd = None
diff --git a/stacks/crdop/lib/test_bell202_line_code.py b/stacks/crdop/lib/test_bell202_line_code.py
new file mode 100644
index 0000000..3b22af6
--- /dev/null
+++ b/stacks/crdop/lib/test_bell202_line_code.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""Unit tests for Bell 202 frequency-toggle line code."""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+LIB = Path(__file__).resolve().parent
+sys.path.insert(0, str(LIB))
+
+from bell202_line_code import decode_tones_to_bits, encode_bits_to_tones # noqa: E402
+
+
+def test_roundtrip_single_byte() -> None:
+ bits = bytes([0x55]) # 01010101 → many toggles
+ tones = encode_bits_to_tones(bits, start_tone=0)
+ back = decode_tones_to_bits(tones, start_tone=0)
+ assert back == bits
+
+
+def test_all_ones_no_toggle() -> None:
+ tones = encode_bits_to_tones(bytes([0xFF]), start_tone=0)
+ assert len(set(tones)) == 1
+
+
+def main() -> int:
+ test_roundtrip_single_byte()
+ test_all_ones_no_toggle()
+ print("OK: bell202_line_code tests")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/stacks/crdop/patches/ardopcf/0001-crdop-linux-gcc-branding.patch b/stacks/crdop/patches/ardopcf/0001-crdop-linux-gcc-branding.patch
new file mode 100644
index 0000000..9b37183
--- /dev/null
+++ b/stacks/crdop/patches/ardopcf/0001-crdop-linux-gcc-branding.patch
@@ -0,0 +1,186 @@
+From: CRDOP <dev@ngteq.local>
+Subject: [PATCH] CRDOP: Linux/GCC build fixes and crdopc branding
+
+Build fixes for GCC 15 (prototypes, ws_server, rawhid fd cast).
+Product identity crdopc / 0.1.0-l2-cb; MIT attribution for ngteq changes.
+---
+ lib/rawhid/rawhid.c | 8 ++++----
+ lib/ws_server/ws_server.c | 2 +-
+ src/common/ARDOPC.c | 4 ++--
+ src/common/ARDOPC.h | 2 +-
+ src/common/ARDOPCommon.c | 5 +++--
+ src/common/ARQ.c | 4 ++--
+ src/common/HostInterface.c | 2 +-
+ src/common/ardopcommon.h | 2 +-
+ src/common/version.h | 2 +-
+ src/linux/ALSASound.c | 4 ++--
+ 10 files changed, 18 insertions(+), 17 deletions(-)
+
+diff --git a/lib/rawhid/rawhid.c b/lib/rawhid/rawhid.c
+index 8791788..256641f 100644
+--- a/lib/rawhid/rawhid.c
++++ b/lib/rawhid/rawhid.c
+@@ -358,7 +358,7 @@ int HID_Read_Block()
+ #ifdef WIN32
+ Len = rawhid_recv(0, Msg, 64, 100);
+ #else
+- Len = read(CM108Handle, Msg, 64);
++ Len = read((int)(intptr_t)CM108Handle, Msg, 64);
+ #endif
+
+ if (Len <= 0)
+@@ -403,7 +403,7 @@ int HID_Write_Block()
+ #ifdef WIN32
+ ret = rawhid_send(0, Msg, 64, 100); // Always send 64
+
+- if (ret < 0)
++ if (ret != 64)
+ {
+ ZF_LOGE("Rigcontrol HID Write Failed %d", errno);
+ rawhid_close(0);
+@@ -411,12 +411,12 @@ int HID_Write_Block()
+ return 0;
+ }
+ #else
+- ret = write(CM108Handle, Msg, 64);
++ ret = write((int)(intptr_t)CM108Handle, Msg, 64);
+
+ if (ret != 64)
+ {
+ printf ("Write to %s failed, n=%d, errno=%d\n", HIDDevice, ret, errno);
+- close (CM108Handle);
++ close((int)(intptr_t)CM108Handle);
+ CM108Handle = 0;
+ return 0;
+ }
+diff --git a/lib/ws_server/ws_server.c b/lib/ws_server/ws_server.c
+index a542627..e8db176 100644
+--- a/lib/ws_server/ws_server.c
++++ b/lib/ws_server/ws_server.c
+@@ -1139,7 +1139,7 @@ int client_handler(char *data, int data_size)
+ int data_len;
+ int opcode;
+ if (state[cnum] == WS_HTTP)
+- return process_http_req(cnum);
++ return process_http_req();
+
+ if ((data_len = read_websocket_frame(data, data_size, &opcode)) < 0)
+ return (-1);
+diff --git a/src/common/ARDOPC.c b/src/common/ARDOPC.c
+index dc69a06..34e3f59 100644
+--- a/src/common/ARDOPC.c
++++ b/src/common/ARDOPC.c
+@@ -2,7 +2,7 @@
+ //
+
+ // ardopcf is a fork by pflarue of ardopc by John Wiseman
+-const char ProductName[] = "ardopcf";
++/* ProductName: CRDOP src/crdop_version.c */
+
+ // Version k Fix conflicting definitions of bytDataToSend
+ // Version m Add CM108 PTT (Sept 2021)
+@@ -56,7 +56,7 @@ BOOL SerialHostInit();
+ void SerialHostPoll();
+ void TCPHostPoll();
+ BOOL MainPoll();
+-void PlatformSleep();
++void PlatformSleep(int mS);
+ const char* PlatformSignalAbbreviation(int signal);
+ BOOL BusyDetect2(float * dblMag, int intStart, int intStop);
+ BOOL IsPingToMe(const StationId* caller, const StationId* target);
+diff --git a/src/common/ARDOPC.h b/src/common/ARDOPC.h
+index da2deb3..bdb8c5e 100644
+--- a/src/common/ARDOPC.h
++++ b/src/common/ARDOPC.h
+@@ -176,7 +176,7 @@ void SendReplyToHost(char * strText);
+ void TCPSendReplyToHost(char * strText);
+ void LogStats();
+ int GetNextFrameData(int * intUpDn, UCHAR * bytFrameTypeToSend, UCHAR * strMod, BOOL blnInitialize);
+-void SendData();
++void SendData(void);
+ int ComputeInterFrameInterval(int intRequestedIntervalMS);
+ int Encode4FSKControl(UCHAR bytFrameType, UCHAR bytSessionID, UCHAR * bytreturn);
+ int EncodeConACKwTiming(UCHAR bytFrameType, int intRcvdLeaderLenMs, UCHAR bytSessionID, UCHAR * bytreturn);
+diff --git a/src/common/ARDOPCommon.c b/src/common/ARDOPCommon.c
+index c6c068c..85db9cf 100644
+--- a/src/common/ARDOPCommon.c
++++ b/src/common/ARDOPCommon.c
+@@ -184,10 +184,11 @@ void processargs(int argc, char * argv[])
+ {
+ case 'h':
+
+- printf("%s Version %s (https://www.github.com/pflarue/ardop)\n", ProductName, ProductVersion);
++ printf("%s Version %s (https://github.com/ngteq/CRDOP)\n", ProductName, ProductVersion);
+ printf("Copyright (c) 2014-2024 Rick Muething, John Wiseman, Peter LaRue\n");
++ printf("CRDOPC modifications Copyright (c) 2026 ngteq (MIT)\n");
+ printf(
+- "See https://github.com/pflarue/ardop/blob/master/LICENSE for licence details including\n"
++ "See https://github.com/ngteq/CRDOP/blob/main/NOTICE.md and https://github.com/pflarue/ardop/blob/master/LICENSE for licence details including\n"
+ " information about authors of external libraries used and their licenses.\n"
+ );
+ printf(HelpScreen, ProductName);
+diff --git a/src/common/ARQ.c b/src/common/ARQ.c
+index 1b44fa7..b67c1d3 100644
+--- a/src/common/ARQ.c
++++ b/src/common/ARQ.c
+@@ -822,7 +822,7 @@ int GetNumCarriers(UCHAR bytFrameType)
+
+ // Function to determine the next data frame to send (or IDLE if none)
+
+-void SendData()
++void SendData(void)
+ {
+ char strMod[16];
+ int Len;
+@@ -1772,7 +1772,7 @@ void ProcessRcvdARQFrame(UCHAR intFrameType, UCHAR * bytData, int DataLen, BOOL
+
+ SetARDOPProtocolState(ISS);
+ ARQState = ISSData;
+- SendData(FALSE);
++ SendData();
+ return;
+ }
+ // In an active ARQ Connection, only send an IDFrame when ProtocolState
+diff --git a/src/common/HostInterface.c b/src/common/HostInterface.c
+index 0ba4eca..e4e6ddd 100644
+--- a/src/common/HostInterface.c
++++ b/src/common/HostInterface.c
+@@ -7,7 +7,7 @@
+ BOOL blnHostRDY = FALSE;
+ extern int intFECFramesSent;
+
+-void SendData();
++void SendData(void);
+ BOOL CheckForDisconnect();
+ int Encode4FSKControl(UCHAR bytFrameType, UCHAR bytSessionID, UCHAR * bytreturn);
+ int ComputeInterFrameInterval(int intRequestedIntervalMS);
+diff --git a/src/common/ardopcommon.h b/src/common/ardopcommon.h
+index 91cf281..1a334eb 100644
+--- a/src/common/ardopcommon.h
++++ b/src/common/ardopcommon.h
+@@ -165,7 +165,7 @@ void SendReplyToHost(char * strText);
+ void TCPSendReplyToHost(char * strText);
+ void LogStats();
+ int GetNextFrameData(int * intUpDn, UCHAR * bytFrameTypeToSend, UCHAR * strMod, BOOL blnInitialize);
+-void SendData();
++void SendData(void);
+ int ComputeInterFrameInterval(int intRequestedIntervalMS);
+ int Encode4FSKControl(UCHAR bytFrameType, UCHAR bytSessionID, UCHAR * bytreturn);
+ VOID WriteExceptionLog(const char * format, ...);
+diff --git a/src/linux/ALSASound.c b/src/linux/ALSASound.c
+index 4938b47..d15dbcc 100644
+--- a/src/linux/ALSASound.c
++++ b/src/linux/ALSASound.c
+@@ -404,10 +404,10 @@ int platform_main(int argc, char * argv[])
+
+ setlinebuf(stdout); // So we can redirect output to file and tail
+
+- ZF_LOGI("%s Version %s (https://www.github.com/pflarue/ardop)", ProductName, ProductVersion);
++ ZF_LOGI("%s Version %s (https://github.com/ngteq/CRDOP)", ProductName, ProductVersion);
+ ZF_LOGI("Copyright (c) 2014-2024 Rick Muething, John Wiseman, Peter LaRue");
+ ZF_LOGI(
+- "See https://github.com/pflarue/ardop/blob/master/LICENSE for licence details including\n"
++ "See https://github.com/ngteq/CRDOP/blob/main/NOTICE.md and https://github.com/pflarue/ardop/blob/master/LICENSE for licence details including\n"
+ " information about authors of external libraries used and their licenses."
+ );
+ ZF_LOGD("Command line: %s", cmdstr);
diff --git a/stacks/crdop/patches/ardopcf/0002-crdop-version-override.patch b/stacks/crdop/patches/ardopcf/0002-crdop-version-override.patch
new file mode 100644
index 0000000..cda40d2
--- /dev/null
+++ b/stacks/crdop/patches/ardopcf/0002-crdop-version-override.patch
@@ -0,0 +1,18 @@
+From: CRDOP <dev@ngteq.local>
+Subject: [PATCH 2/2] CRDOP: version from CRDOP tree (single source of truth)
+
+ProductName and ProductVersion live in src/crdop_version.c / include/crdop/version.h.
+---
+ src/common/version.h | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/src/common/version.h b/src/common/version.h
+index faabb3e..c4d0e1f 100644
+--- a/src/common/version.h
++++ b/src/common/version.h
+@@ -4,4 +4,4 @@
+ // ardopcf was a mistake. This was due to a bug and misunderstanding
+ // of the ardop version numbering scheme. So ardopcf v1.0.4.1.1 comes
+ // after and replaces v2.0.3.2.1
+-const char ProductVersion[] = "1.0.4.1.3";
++extern const char ProductVersion[]; /* CRDOP src/crdop_version.c */
diff --git a/stacks/crdop/scripts/apply-vendor-patches.sh b/stacks/crdop/scripts/apply-vendor-patches.sh
new file mode 100755
index 0000000..da62bf5
--- /dev/null
+++ b/stacks/crdop/scripts/apply-vendor-patches.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Apply CRDOP patch series — only if vendor tree is still pristine upstream.
+# Released CRDOP commits ship vendor/ardopcf with patches already integrated.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+VENDOR="${ROOT}/vendor/ardopcf"
+PATCH_DIR="${ROOT}/patches/ardopcf"
+STAMP="${VENDOR}/.crdop-patches-applied"
+MARKER="${VENDOR}/src/common/ARDOPC.c"
+
+if [[ ! -d "${VENDOR}/src" ]]; then
+ echo "error: ${VENDOR} missing" >&2
+ exit 1
+fi
+
+if grep -q 'CRDOP src/crdop_version.c' "${MARKER}" 2>/dev/null; then
+ date -u +'%Y-%m-%dT%H:%M:%SZ' > "${STAMP}"
+ echo "CRDOP vendor already integrated ($(cat "${STAMP}"))"
+ exit 0
+fi
+
+if [[ ! -d "${PATCH_DIR}" ]] || ! compgen -G "${PATCH_DIR}/*.patch" >/dev/null; then
+ echo "error: no patches in ${PATCH_DIR}" >&2
+ exit 1
+fi
+
+cd "${VENDOR}"
+for p in "${PATCH_DIR}"/*.patch; do
+ echo "applying ${p#${ROOT}/}"
+ git apply --check "${p}" 2>/dev/null || patch -p1 --forward -r - < "${p}"
+ git apply "${p}" 2>/dev/null || patch -p1 --forward < "${p}"
+done
+
+date -u +'%Y-%m-%dT%H:%M:%SZ' > "${STAMP}"
+echo "CRDOP patches applied ($(cat "${STAMP}"))"
diff --git a/stacks/crdop/scripts/build-crdop.sh b/stacks/crdop/scripts/build-crdop.sh
new file mode 100755
index 0000000..2253d5a
--- /dev/null
+++ b/stacks/crdop/scripts/build-crdop.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# CRDOP unified build — vendor embedded; GCC, Clang, or MSVC via CMake.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+BUILD="${CRDOP_BUILD_DIR:-${ROOT}/build}"
+TYPE="${CRDOP_BUILD_TYPE:-Release}"
+TOOLCHAIN="${CRDOP_TOOLCHAIN:-}"
+BUILD_TESTS="${CRDOP_BUILD_TESTS:-OFF}"
+RUN_TESTS="${CRDOP_RUN_TESTS:-OFF}"
+
+if [[ -f "${BUILD}/CMakeCache.txt" ]]; then
+ cached_home="$(grep -m1 '^CMAKE_HOME_DIRECTORY:INTERNAL=' "${BUILD}/CMakeCache.txt" 2>/dev/null | cut -d= -f2- || true)"
+ if [[ -n "${cached_home}" && "${cached_home}" != "${ROOT}" ]]; then
+ echo "CRDOP: removing stale CMake cache (was ${cached_home}, now ${ROOT})" >&2
+ rm -rf "${BUILD}"
+ fi
+fi
+
+"${ROOT}/scripts/import-ardopcf.sh"
+
+CMAKE_ARGS=(-B "${BUILD}" -DCMAKE_BUILD_TYPE="${TYPE}" -DCRDOP_BUILD_TESTS="${BUILD_TESTS}")
+
+if [[ -n "${TOOLCHAIN}" ]]; then
+ CMAKE_ARGS+=(-DCMAKE_TOOLCHAIN_FILE="${ROOT}/cmake/toolchains/${TOOLCHAIN}.cmake")
+fi
+
+if [[ -n "${CC:-}" ]]; then
+ CMAKE_ARGS+=(-DCMAKE_C_COMPILER="${CC}")
+fi
+if [[ -n "${CXX:-}" ]]; then
+ CMAKE_ARGS+=(-DCMAKE_CXX_COMPILER="${CXX}")
+fi
+
+cmake "${ROOT}" "${CMAKE_ARGS[@]}"
+cmake --build "${BUILD}" -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"
+
+"${ROOT}/scripts/test-smoke.sh"
+
+if [[ "${BUILD_TESTS}" == "ON" || "${BUILD_TESTS}" == "1" || "${RUN_TESTS}" == "ON" || "${RUN_TESTS}" == "1" ]]; then
+ if [[ -f "${BUILD}/CTestTestfile.cmake" ]]; then
+ ctest --test-dir "${BUILD}" --output-on-failure
+ else
+ echo "NOTE: CRDOP_BUILD_TESTS requested but no CTest targets (install libcmocka-dev?)" >&2
+ fi
+fi
+
+echo "CRDOP $(cat "${ROOT}/VERSION") built: ${BUILD}/crdopc"
diff --git a/stacks/crdop/scripts/crdopc b/stacks/crdop/scripts/crdopc
new file mode 100755
index 0000000..2fc82bd
--- /dev/null
+++ b/stacks/crdop/scripts/crdopc
@@ -0,0 +1,171 @@
+#!/usr/bin/env bash
+# CRDOP launcher — native MAX25-SoftModem (audio-dummyd / M25 host).
+# ARDOP is a separate optional MAX25-Stack plugin — not part of CRDOP.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+MAX25_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+PREFIX_SHARE="${SCRIPT_DIR}/../share/crdop"
+
+if [[ -n "${CRDOP_INI:-}" ]]; then
+ INI="${CRDOP_INI}"
+elif [[ -f "${HOME}/.config/crdop/crdop.ini" ]]; then
+ INI="${HOME}/.config/crdop/crdop.ini"
+elif [[ -f "${PREFIX_SHARE}/crdop.ini.example" ]]; then
+ INI="${PREFIX_SHARE}/crdop.ini.example"
+else
+ INI="${ROOT}/share/crdop.ini.example"
+fi
+
+ini_get() {
+ local section="$1" key="$2" default="$3" file="$4"
+ [[ -f "${file}" ]] || { echo "${default}"; return; }
+ awk -F= -v sec="[${section}]" -v k="${key}" -v d="${default}" '
+ $0 ~ /^[[:space:]]*[#;]/ { next }
+ $1 ~ /^\[/ { cur=$0; gsub(/[][]/, "", cur); next }
+ cur == substr(sec, 2, length(sec)-2) {
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1)
+ if ($1 == k) {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/, "", v); gsub(/^"|"$/, "", v)
+ print v; found=1; exit
+ }
+ }
+ END { if (!found) print d }
+ ' "${file}"
+}
+
+resolve_ini() {
+ if [[ ! -f "${INI}" && -f "${ROOT}/share/crdop.ini.example" ]]; then
+ INI="${ROOT}/share/crdop.ini.example"
+ fi
+}
+
+normalize_profile() {
+ case "${1,,}" in
+ cb|dual|amateur) echo "${1,,}" ;;
+ *)
+ echo "warn: unknown radio_profile '${1}', using cb" >&2
+ echo "cb"
+ ;;
+ esac
+}
+
+normalize_duplex() {
+ case "${1,,}" in
+ full|half) echo "${1,,}" ;;
+ *)
+ echo "warn: unknown duplex '${1}', using half" >&2
+ echo "half"
+ ;;
+ esac
+}
+
+enforce_host_audio() {
+ local backend
+ backend="$(ini_get audio backend "" "${INI}")"
+ if [[ -z "${backend}" ]]; then
+ case "$(uname -s)" in
+ FreeBSD) backend="oss" ;;
+ *) backend="alsa" ;;
+ esac
+ fi
+ if [[ "${backend}" == "alsa" ]]; then
+ local no_pulse
+ no_pulse="$(ini_get audio no_pulse yes "${INI}")"
+ case "${no_pulse,,}" in
+ 1|yes|true|on)
+ unset PULSE_SERVER PULSE_COOKIE PIPEWIRE_RUNTIME_DIR 2>/dev/null || true
+ export PULSE_SERVER=
+ export ALSA_CARD="${ALSA_CARD:-}"
+ ;;
+ esac
+ fi
+}
+
+audio_cli_args() {
+ local cap pb backend
+ backend="$(ini_get audio backend "" "${INI}")"
+ if [[ -z "${backend}" ]]; then
+ case "$(uname -s)" in
+ FreeBSD) backend="oss" ;;
+ *) backend="alsa" ;;
+ esac
+ fi
+ cap="$(ini_get audio capture "" "${INI}")"
+ pb="$(ini_get audio playback "" "${INI}")"
+ local args=()
+ [[ -n "${cap}" ]] && args+=("${cap}")
+ [[ -n "${pb}" ]] && args+=("${pb}")
+ if [[ ${#args[@]} -gt 0 ]]; then
+ if [[ "${backend}" == "alsa" ]]; then
+ for a in "${args[@]}"; do
+ if [[ "${a}" == *pulse* || "${a}" == *pipewire* || "${a}" == default ]]; then
+ echo "error: audio device '${a}' — use hw:/plughw: kernel ALSA" >&2
+ exit 1
+ fi
+ done
+ fi
+ printf '%s\n' "${args[@]}"
+ elif [[ "${backend}" == "oss" ]]; then
+ echo "/dev/dsp"
+ echo "/dev/dsp"
+ fi
+}
+
+find_audio_dummyd() {
+ for candidate in \
+ "${MAX25_ROOT}/build/bin/audio-dummyd" \
+ "${MAX25_ROOT}/stacks/crdop/tools/audio-dummyd" \
+ "$(command -v audio-dummyd 2>/dev/null || true)"; do
+ [[ -n "${candidate}" && -x "${candidate}" ]] || continue
+ echo "${candidate}"
+ return
+ done
+ return 1
+}
+
+resolve_ini
+
+if [[ -f "${PREFIX_SHARE}/VERSION" ]]; then
+ CRDOP_VERSION="$(tr -d '[:space:]' < "${PREFIX_SHARE}/VERSION")"
+elif [[ -f "${ROOT}/VERSION" ]]; then
+ CRDOP_VERSION="$(tr -d '[:space:]' < "${ROOT}/VERSION")"
+else
+ CRDOP_VERSION="CUR999"
+fi
+
+PROFILE="$(normalize_profile "$(ini_get profile radio_profile cb "${INI}")")"
+DUPLEX="$(normalize_duplex "$(ini_get modem duplex half "${INI}")")"
+MYCALL="$(ini_get mycall call NOCALL-0 "${INI}")"
+HOST_PORT="$(ini_get host port 8515 "${INI}")"
+DATA_PORT=$((HOST_PORT + 1))
+SAMPLE_RATE="$(ini_get audio sample_rate 48000 "${INI}")"
+
+if [[ $# -ge 1 && "${1}" =~ ^[0-9]+$ ]]; then
+ HOST_PORT="$1"
+ DATA_PORT=$((HOST_PORT + 1))
+ shift
+fi
+
+mapfile -t AUDIO_ARGS < <(audio_cli_args || true)
+enforce_host_audio
+
+DUMMY="$(find_audio_dummyd || true)"
+if [[ -z "${DUMMY}" ]]; then
+ echo "error: audio-dummyd not found — build MAX25 or set path" >&2
+ exit 1
+fi
+
+DUMMY_ARGS=(--ctrl-port "${HOST_PORT}" --data-port "${DATA_PORT}" -r "${SAMPLE_RATE}")
+if [[ ${#AUDIO_ARGS[@]} -ge 1 ]]; then
+ DUMMY_ARGS+=(-D "${AUDIO_ARGS[0]}")
+fi
+if [[ ${#AUDIO_ARGS[@]} -ge 2 ]]; then
+ DUMMY_ARGS+=(-P "${AUDIO_ARGS[1]}")
+fi
+
+AUDIO_BACKEND="$(ini_get audio backend "" "${INI}")"
+[[ -z "${AUDIO_BACKEND}" ]] && case "$(uname -s)" in FreeBSD) AUDIO_BACKEND="oss" ;; *) AUDIO_BACKEND="alsa" ;; esac
+echo "CRDOP ${CRDOP_VERSION} profile=${PROFILE} duplex=${DUPLEX} ctrl=:${HOST_PORT} data=:${DATA_PORT} call=${MYCALL} audio=${AUDIO_BACKEND} (native M25)"
+exec "${DUMMY}" "${DUMMY_ARGS[@]}" "$@"
diff --git a/stacks/crdop/scripts/import-ardopcf.sh b/stacks/crdop/scripts/import-ardopcf.sh
new file mode 100755
index 0000000..26ac003
--- /dev/null
+++ b/stacks/crdop/scripts/import-ardopcf.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Verify vendor/ardopcf is present (embedded in repo — no submodule/download).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+VENDOR="${ROOT}/vendor/ardopcf"
+
+if [[ ! -f "${VENDOR}/LICENSE" || ! -f "${VENDOR}/src/common/ARDOPC.c" ]]; then
+ echo "error: vendor/ardopcf missing — clone CRDOP with full tree or run scripts/refresh-vendor-ardopcf.sh" >&2
+ exit 1
+fi
+
+if ! grep -q 'CRDOP src/crdop_version.c' "${VENDOR}/src/common/ARDOPC.c" 2>/dev/null; then
+ echo "warn: vendor/ardopcf may need CRDOP patches — run scripts/apply-vendor-patches.sh" >&2
+fi
+
+echo "vendor/ardopcf OK (embedded, commit $(grep '^commit=' "${ROOT}/vendor/ardopcf.ref" 2>/dev/null | cut -d= -f2 || echo unknown))"
diff --git a/stacks/crdop/scripts/install-crdop.sh b/stacks/crdop/scripts/install-crdop.sh
new file mode 100755
index 0000000..23f631c
--- /dev/null
+++ b/stacks/crdop/scripts/install-crdop.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# Build and install CRDOP to PREFIX (default /usr/local).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+PREFIX="${CRDOP_PREFIX:-/usr/local}"
+BUILD="${CRDOP_BUILD_DIR:-${ROOT}/build}"
+
+"${ROOT}/scripts/build-crdop.sh"
+
+cmake --install "${BUILD}" --prefix "${PREFIX}"
+
+echo "Installed:"
+echo " ${PREFIX}/bin/crdopc — modem binary"
+echo " ${PREFIX}/bin/crdop — profile launcher"
+echo " ${PREFIX}/share/crdop/ — INI examples, VERSION"
diff --git a/stacks/crdop/scripts/refresh-vendor-ardopcf.sh b/stacks/crdop/scripts/refresh-vendor-ardopcf.sh
new file mode 100755
index 0000000..b9d2f93
--- /dev/null
+++ b/stacks/crdop/scripts/refresh-vendor-ardopcf.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# Maintainer tool: re-download upstream ardopcf and re-apply CRDOP patches.
+# Normal builds use the committed vendor/ardopcf tree — no network required.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+VENDOR="${ROOT}/vendor/ardopcf"
+REF_FILE="${ROOT}/vendor/ardopcf.ref"
+URL="${CRDOP_UPSTREAM_URL:-https://github.com/pflarue/ardop.git}"
+PIN="$(grep '^commit_full=' "${REF_FILE}" 2>/dev/null | cut -d= -f2- | head -1)"
+PIN="${PIN:-$(grep '^commit=' "${REF_FILE}" 2>/dev/null | cut -d= -f2- | head -1)}"
+
+echo "This replaces vendor/ardopcf from upstream. Press Ctrl+C to abort."
+sleep 2
+
+rm -rf "${VENDOR}"
+git clone --depth 1 --branch master "${URL}" "${VENDOR}"
+if [[ -n "${PIN}" ]]; then
+ git -C "${VENDOR}" fetch --depth 1 origin "${PIN}"
+ git -C "${VENDOR}" checkout "${PIN}"
+fi
+rm -rf "${VENDOR}/.git"
+rm -f "${VENDOR}/.crdop-patches-applied"
+
+"${ROOT}/scripts/apply-vendor-patches.sh"
+echo "Done. Review diff, update vendor/ardopcf.ref if needed, commit vendor/ardopcf/"
diff --git a/stacks/crdop/scripts/sync-upstream.sh b/stacks/crdop/scripts/sync-upstream.sh
new file mode 100755
index 0000000..a1353ad
--- /dev/null
+++ b/stacks/crdop/scripts/sync-upstream.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# Test whether CRDOP patches still apply to fresh upstream (maintainers).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+TMP="$(mktemp -d)"
+trap 'rm -rf "${TMP}"' EXIT
+
+REF="${ROOT}/vendor/ardopcf.ref"
+PIN="$(grep '^commit_full=' "${REF}" | cut -d= -f2-)"
+URL="https://github.com/pflarue/ardop.git"
+
+git clone --depth 1 "${URL}" "${TMP}/ardopcf"
+git -C "${TMP}/ardopcf" fetch --depth 1 origin "${PIN}"
+git -C "${TMP}/ardopcf" checkout "${PIN}"
+
+for p in "${ROOT}"/patches/ardopcf/*.patch; do
+ git -C "${TMP}/ardopcf" apply --check "${p}"
+done
+
+echo "OK: patches apply to upstream ${PIN}"
diff --git a/stacks/crdop/scripts/test-all.sh b/stacks/crdop/scripts/test-all.sh
new file mode 100755
index 0000000..f12181e
--- /dev/null
+++ b/stacks/crdop/scripts/test-all.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Smoke test + optional upstream cmocka unit tests (CTest).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+BUILD="${CRDOP_BUILD_DIR:-${ROOT}/build}"
+
+export CRDOP_BUILD_TESTS="${CRDOP_BUILD_TESTS:-ON}"
+export CRDOP_RUN_TESTS="${CRDOP_RUN_TESTS:-ON}"
+
+"${ROOT}/scripts/build-crdop.sh"
+
+if [[ ! -f "${BUILD}/CTestTestfile.cmake" ]]; then
+ echo "NOTE: unit tests not built (libcmocka missing or unsupported platform)" >&2
+fi
+
+echo "OK: CRDOP test-all finished"
diff --git a/stacks/crdop/scripts/test-smoke.sh b/stacks/crdop/scripts/test-smoke.sh
new file mode 100755
index 0000000..6ee6eff
--- /dev/null
+++ b/stacks/crdop/scripts/test-smoke.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+REPO="$(cd "${ROOT}/../.." && pwd)"
+BIN="${CRDOP_BIN:-${ROOT}/build/crdopc}"
+
+if [[ ! -x "${BIN}" ]]; then
+ for alt in "${REPO}/build/bin/crdopc" "${REPO}/build/crdop/crdopc"; do
+ if [[ -x "${alt}" ]]; then
+ BIN="${alt}"
+ break
+ fi
+ done
+fi
+VER="$(tr -d '[:space:]' < "${ROOT}/VERSION")"
+
+[[ -x "${BIN}" ]] || { echo "FAIL: ${BIN} missing" >&2; exit 1; }
+
+OUT="$("${BIN}" -h 2>&1 || true)"
+echo "${OUT}" | grep -qi crdopc || { echo "FAIL: crdopc branding"; exit 1; }
+echo "${OUT}" | grep -q "${VER}" || { echo "FAIL: expected version ${VER}"; exit 1; }
+
+echo "OK: CRDOP ${VER} smoke test passed"
diff --git a/stacks/crdop/scripts/test-with-host.sh b/stacks/crdop/scripts/test-with-host.sh
new file mode 100755
index 0000000..f14baac
--- /dev/null
+++ b/stacks/crdop/scripts/test-with-host.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# P1 — loopback host integration (placeholder).
+set -euo pipefail
+
+echo "Host loopback test not automated yet."
+echo " 1. ./scripts/crdopc"
+echo " 2. Connect M25/KISS host client to 127.0.0.1:8515"
+echo " 3. INITIALIZE / PROTOCOLMODE KISS / LISTEN — verify STATUS ready"
+exit 0
diff --git a/stacks/crdop/share/crdop-amateur.ini.example b/stacks/crdop/share/crdop-amateur.ini.example
new file mode 100644
index 0000000..dd76f68
--- /dev/null
+++ b/stacks/crdop/share/crdop-amateur.ini.example
@@ -0,0 +1,15 @@
+; CRDOP — amateur profile (secondary)
+; Docs: docs/EXAMPLES.md
+
+[profile]
+radio_profile = amateur
+
+[modem]
+arq_bandwidth = 1000MAX
+duplex = half
+
+[host]
+port = 8515
+
+[mycall]
+call = CALL-1
diff --git a/stacks/crdop/share/crdop-dual.ini.example b/stacks/crdop/share/crdop-dual.ini.example
new file mode 100644
index 0000000..4601d5c
--- /dev/null
+++ b/stacks/crdop/share/crdop-dual.ini.example
@@ -0,0 +1,15 @@
+; CRDOP — dual profile (CB ↔ amateur turnaround)
+; Docs: docs/EXAMPLES.md
+
+[profile]
+radio_profile = dual
+
+[modem]
+arq_bandwidth = 500MAX
+duplex = half
+
+[host]
+port = 8515
+
+[mycall]
+call = CB01-0
diff --git a/stacks/crdop/share/crdop.freebsd.ini.example b/stacks/crdop/share/crdop.freebsd.ini.example
new file mode 100644
index 0000000..3d0f347
--- /dev/null
+++ b/stacks/crdop/share/crdop.freebsd.ini.example
@@ -0,0 +1,25 @@
+; MAX25-SoftModem (CRDOP) — FreeBSD / OSS
+; Copy: cp share/crdop/crdop.freebsd.ini.example ~/.config/crdop/crdop.ini
+; pkg install sox (recommended for OSS capture/playback)
+
+[profile]
+radio_profile = cb
+
+[modem]
+speed_baud = 1200
+duplex = half
+max_speed_baud = 19200
+
+[host]
+port = 8515
+
+[mycall]
+call = NOCALL-0
+
+[audio]
+backend = oss
+no_pulse = yes
+; pcm0 from sysctl dev.pcm — typical OSS path:
+capture = /dev/dsp
+playback = /dev/dsp
+sample_rate = 48000
diff --git a/stacks/crdop/share/crdop.ini.example b/stacks/crdop/share/crdop.ini.example
new file mode 100644
index 0000000..52dc3a1
--- /dev/null
+++ b/stacks/crdop/share/crdop.ini.example
@@ -0,0 +1,37 @@
+; MAX25-SoftModem (CRDOP) — CB profile (default)
+; Copy: cp share/crdop.ini.example ~/.config/crdop/crdop.ini
+; Docs: docs/SOFTMODEM.md · docs/AUDIO-ARCHITECTURE.md · docs/CONFIG.md
+;
+; Development/test phase — in-house MAX25-Stack standard.
+; Audio: kernel ALSA ONLY via MAX25 sound-proxy — NO PulseAudio / PipeWire route.
+; Good sound card REQUIRED (accurate tones; stricter at higher baud).
+; 1200 baud primary, max 19200 baud. Duplex: half | full
+
+[profile]
+radio_profile = cb
+
+[modem]
+speed_baud = 1200
+duplex = half
+max_speed_baud = 19200
+
+[host]
+port = 8515
+
+[mycall]
+call = NOCALL-0
+
+[audio]
+; MAX25 sound-proxy → libasound → kernel ALSA → hardware (direct)
+backend = alsa-kernel
+; Reject pulse/pipewire pseudo devices — production path is kernel-only
+no_pulse = yes
+; REQUIRED: explicit hw: or plughw: from arecord -l / aplay -l
+; capture = hw:1,0
+; playback = hw:1,0
+; sample_rate = 48000
+; period_frames = 256
+
+[coupling]
+; line | acoustic — radio or TNC/BayCom reference path
+mode = line
diff --git a/stacks/crdop/src/crdop_version.c b/stacks/crdop/src/crdop_version.c
new file mode 100644
index 0000000..7136d4a
--- /dev/null
+++ b/stacks/crdop/src/crdop_version.c
@@ -0,0 +1,5 @@
+#include "crdop/version.h"
+
+/* CRDOP product identity — overrides vendor version.h when linked first. */
+const char ProductName[] = CRDOP_PRODUCT_NAME;
+const char ProductVersion[] = CRDOP_VERSION_STRING;
diff --git a/stacks/crdop/tools/audio-dummyd b/stacks/crdop/tools/audio-dummyd
new file mode 100755
index 0000000..e64bda5
--- /dev/null
+++ b/stacks/crdop/tools/audio-dummyd
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
+exec python3 "${ROOT}/stacks/crdop/tools/audio-dummyd.py" "$@"
diff --git a/stacks/crdop/tools/audio-dummyd.py b/stacks/crdop/tools/audio-dummyd.py
new file mode 100644
index 0000000..a4bb922
--- /dev/null
+++ b/stacks/crdop/tools/audio-dummyd.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+"""Audio dummy daemon — M25 host TCP + acoustic DSP bench for max25d."""
+from __future__ import annotations
+
+import argparse
+import sys
+import threading
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+LIB = ROOT / "stacks" / "crdop" / "lib"
+sys.path.insert(0, str(LIB))
+
+from acoustic_engine import AcousticEngine # noqa: E402
+from m25_host_protocol import DEFAULT_CTRL_PORT, DEFAULT_DATA_PORT, M25SoftModemHost # noqa: E402
+from sound_proxy import SoundConfig # noqa: E402
+
+_DAEMON = ROOT / "stacks" / "daemon"
+sys.path.insert(0, str(_DAEMON))
+from ax25_codec import ax25_build_ui # noqa: E402
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="MAX25 audio-dummy device daemon")
+ ap.add_argument("--ctrl-port", type=int, default=DEFAULT_CTRL_PORT)
+ ap.add_argument("--data-port", type=int, default=DEFAULT_DATA_PORT)
+ ap.add_argument("-D", "--capture", default="")
+ ap.add_argument("-P", "--playback", default="")
+ ap.add_argument("-r", "--rate", type=int, default=48000)
+ args = ap.parse_args()
+
+ sound = SoundConfig(
+ capture=args.capture or "default",
+ playback=args.playback or args.capture or "default",
+ sample_rate=args.rate,
+ )
+ eng = AcousticEngine(sample_rate=args.rate, sound=sound)
+ rx_lock = threading.Lock()
+ last_rx: list[str] = []
+
+ def on_tx(payload: bytes) -> str:
+ if len(payload) < 16:
+ return "ERR short payload"
+ pcm = eng.mod.modulate_bits(payload)
+ eng.sound.playback = sound.playback
+ from sound_proxy import create_sound_proxy
+
+ create_sound_proxy(sound).play_pcm(pcm)
+ return "OK"
+
+ host = M25SoftModemHost(args.ctrl_port, args.data_port, on_data_tx=on_tx)
+ host.start()
+ print(
+ f"audio-dummyd ctrl=:{args.ctrl_port} data=:{args.data_port} rate={args.rate}",
+ flush=True,
+ )
+ try:
+ while True:
+ threading.Event().wait(3600.0)
+ except KeyboardInterrupt:
+ host.stop()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/stacks/crdop/tools/max25-signal-sniffer b/stacks/crdop/tools/max25-signal-sniffer
new file mode 100755
index 0000000..e412997
--- /dev/null
+++ b/stacks/crdop/tools/max25-signal-sniffer
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+# MAX25 signal sniffer launcher
+set -euo pipefail
+ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
+exec python3 "${ROOT}/stacks/crdop/tools/max25-signal-sniffer.py" "$@"
diff --git a/stacks/crdop/tools/max25-signal-sniffer.py b/stacks/crdop/tools/max25-signal-sniffer.py
new file mode 100644
index 0000000..fed95f7
--- /dev/null
+++ b/stacks/crdop/tools/max25-signal-sniffer.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""MAX25 signal sniffer — analyze Bell 202 AFSK on ALSA capture or PCM file."""
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+LIB = ROOT / "stacks" / "crdop" / "lib"
+sys.path.insert(0, str(LIB))
+
+from acoustic_engine import AcousticEngine # noqa: E402
+from sound_proxy import SoundConfig, SoundProxy # noqa: E402
+
+
+def read_wav_pcm(path: Path) -> bytes:
+ import wave
+
+ with wave.open(str(path), "rb") as wf:
+ if wf.getsampwidth() != 2 or wf.getnchannels() != 1:
+ raise SystemExit("WAV must be mono S16_LE")
+ return wf.readframes(wf.getnframes())
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="MAX25 acoustic signal sniffer (Bell 202 1200 bd)")
+ ap.add_argument("-D", "--device", default="", help="ALSA capture device (hw: or plughw:)")
+ ap.add_argument("-r", "--rate", type=int, default=48000)
+ ap.add_argument("-f", "--file", type=Path, help="Read mono S16 WAV instead of ALSA")
+ ap.add_argument("-t", "--seconds", type=float, default=2.0, help="ALSA capture duration")
+ ap.add_argument("--loopback", action="store_true", help="Internal DSP loopback (no audio hw)")
+ ap.add_argument("--mark", action="store_true", help="Play mark calibration tone (1200 Hz)")
+ ap.add_argument("--space", action="store_true", help="Play space calibration tone (2200 Hz)")
+ args = ap.parse_args()
+
+ cfg = SoundConfig(capture=args.device or "default", sample_rate=args.rate)
+ eng = AcousticEngine(sample_rate=args.rate, sound=cfg)
+
+ if args.loopback:
+ rep = eng.loopback_self_test()
+ print(f"loopback samples={rep.samples} symbols={rep.symbols} transitions={rep.transitions}")
+ print(f"mark={rep.mark_ratio:.2%} space={rep.space_ratio:.2%}")
+ for line in rep.decode_lines:
+ print(line)
+ return 0 if rep.decode_lines else 1
+
+ if args.mark:
+ eng.play_mark_calibration(args.seconds)
+ print(f"played mark tone {args.seconds}s")
+ return 0
+ if args.space:
+ eng.play_space_calibration(args.seconds)
+ print(f"played space tone {args.seconds}s")
+ return 0
+
+ if args.file:
+ pcm = read_wav_pcm(args.file)
+ else:
+ if not args.device:
+ print("error: -D hw:... or --file or --loopback required", file=sys.stderr)
+ return 2
+ proxy = SoundProxy(cfg)
+ proxy.start_capture()
+ nbytes = int(args.rate * args.seconds) * 2
+ pcm = proxy.read_capture(nbytes)
+ proxy.close()
+
+ rep = eng.analyze_pcm(pcm)
+ print(f"sniff samples={rep.samples} symbols={rep.symbols} transitions={rep.transitions}")
+ print(f"mark={rep.mark_ratio:.2%} space={rep.space_ratio:.2%} frames={len(rep.frames)}")
+ for line in rep.decode_lines:
+ print(line)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/stacks/crdop/vendor/README.md b/stacks/crdop/vendor/README.md
new file mode 100644
index 0000000..d5f9c02
--- /dev/null
+++ b/stacks/crdop/vendor/README.md
@@ -0,0 +1,5 @@
+# Vendor tree (dev-only)
+
+Legacy **ardopcf** may exist under `vendor/ardopcf/` for local developer builds (`-DCRDOP_VENDOR_ARDOPCF=ON`). **Not** part of MAX25 release install.
+
+Operator ARDOP (third-party host): [plugins/external/ardop/README.md](../../plugins/external/ardop/README.md) — separate from CRDOP.
diff --git a/stacks/crdop/vendor/ardopcf.ref b/stacks/crdop/vendor/ardopcf.ref
new file mode 100644
index 0000000..54a0ab6
--- /dev/null
+++ b/stacks/crdop/vendor/ardopcf.ref
@@ -0,0 +1,9 @@
+# Pinned upstream for CRDOP vendor submodule (vendor/ardopcf)
+# Update with: scripts/sync-upstream.sh
+
+url=https://github.com/pflarue/ardop.git
+branch=master
+commit=a7c9228
+commit_full=a7c92289b569afbe4259dc556d749405ebc008f5
+describe=ardopcf baseline (Update CONTRIBUTING.md fix typo)
+imported=2026-07-04
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com