From f47e011a5b8bd02b6fa3619bcaa4e8ced55149a3 Mon Sep 17 00:00:00 2001 From: boybook Date: Sat, 7 Mar 2026 12:12:18 +0800 Subject: [PATCH 1/5] feat: add pure C API isolation layer for cross-compiler compatibility Introduce a pure C ABI boundary between the C++/Fortran core library (wsjtx_core) and the Node.js N-API binding (.node). This solves the Windows crash where MSVC-compiled Node.js failed to load MinGW-compiled native extensions due to ABI incompatibility. Architecture: .node (N-API) -> C ABI -> wsjtx_core shared library (C++/Fortran) - New: native/wsjtx_c_api.h and .cpp (pure C interface with opaque handle) - Modified: wsjtx_wrapper to use C API instead of C++ direct linking - Rewritten: CMakeLists.txt with dual-target build (wsjtx_core + .node) - Windows: two-phase build (MinGW core DLL + MSVC .node) - Linux/macOS: single cmake build produces both targets - CI workflow simplified from 773 to ~200 lines Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 791 ++++++------------------------------ .gitignore | 9 +- CMakeLists.txt | 688 ++++++++++++++----------------- native/wsjtx_c_api.cpp | 228 +++++++++++ native/wsjtx_c_api.h | 173 ++++++++ native/wsjtx_wrapper.cpp | 588 +++++++++++---------------- native/wsjtx_wrapper.h | 112 ++--- package.json | 5 +- 8 files changed, 1111 insertions(+), 1483 deletions(-) create mode 100644 native/wsjtx_c_api.cpp create mode 100644 native/wsjtx_c_api.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f1f56a2..596a5bf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,123 +2,59 @@ name: Build and Package on: push: - branches: [ main, develop ] - tags: [ 'v*' ] + branches: [main, develop] + tags: ['v*'] pull_request: - branches: [ main ] + branches: [main] env: NODE_VERSION: '20' jobs: build: - name: Build on ${{ matrix.os }} (${{ matrix.arch }}) + name: Build ${{ matrix.platform }}-${{ matrix.arch }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - # Linux builds - - os: ubuntu-latest - arch: x64 - node_arch: x64 - cmake_arch: x86_64 - - # Linux ARM64 builds (native) - - os: ubuntu-24.04-arm - arch: arm64 - node_arch: arm64 - cmake_arch: arm64 - - # macOS builds - - os: macos-latest - arch: arm64 - node_arch: arm64 - cmake_arch: arm64 - - # macOS Intel builds - - os: macos-15-intel - arch: x64 - node_arch: x64 - cmake_arch: x86_64 - - # Windows builds with MSYS2/MinGW-w64 - - os: windows-latest - arch: x64 - node_arch: x64 - cmake_arch: x64 + - { os: ubuntu-latest, arch: x64, platform: linux, node_arch: x64 } + - { os: ubuntu-24.04-arm, arch: arm64, platform: linux, node_arch: arm64 } + - { os: macos-latest, arch: arm64, platform: darwin, node_arch: arm64 } + - { os: macos-15-intel, arch: x64, platform: darwin, node_arch: x64 } + - { os: windows-latest, arch: x64, platform: win32, node_arch: x64 } steps: - # 1. 检出代码 - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - # 2. 设置 Node.js 环境 - - name: Setup Node.js - id: setup_node - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} architecture: ${{ matrix.node_arch }} - # 3. 安装系统依赖 - Linux - - name: Install Linux dependencies + # Linux dependencies + - name: Install dependencies (Linux) if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y \ - cmake \ - build-essential \ - gfortran \ - libfftw3-dev \ - libboost-all-dev \ - pkg-config \ - patchelf - - # 3. 安装系统依赖 - macOS - - name: Install macOS dependencies + sudo apt-get install -y cmake build-essential gfortran libfftw3-dev libboost-all-dev pkg-config patchelf + + # macOS dependencies + - name: Install dependencies (macOS) if: runner.os == 'macOS' run: | brew install cmake fftw boost gcc pkg-config dylibbundler - - # 根据架构设置不同的路径 - if [ "${{ matrix.arch }}" = "arm64" ]; then - # Apple Silicon (ARM64) - BREW_PREFIX="/opt/homebrew" - else - # Intel (x64) - BREW_PREFIX="/usr/local" - fi - - # 确保brew路径在PATH中 + BREW_PREFIX=$([[ "${{ matrix.arch }}" == "arm64" ]] && echo "/opt/homebrew" || echo "/usr/local") echo "${BREW_PREFIX}/bin" >> $GITHUB_PATH - - # 验证gfortran安装并设置环境变量 - echo "Checking gfortran installation..." - ls -la ${BREW_PREFIX}/bin/gfortran* || echo "gfortran not found" - - # 找到正确的gfortran路径并设置为环境变量 GFORTRAN_PATH=$(find ${BREW_PREFIX}/bin -name "gfortran*" | head -1) - if [ -n "$GFORTRAN_PATH" ] && [ -x "$GFORTRAN_PATH" ]; then - echo "Found gfortran at: $GFORTRAN_PATH" - echo "FC=$GFORTRAN_PATH" >> $GITHUB_ENV - echo "CMAKE_Fortran_COMPILER=$GFORTRAN_PATH" >> $GITHUB_ENV - - # 测试gfortran - $GFORTRAN_PATH --version || echo "gfortran test failed" - else - echo "ERROR: gfortran not found or not executable" - exit 1 - fi - - # 设置库路径 + echo "FC=$GFORTRAN_PATH" >> $GITHUB_ENV + echo "CMAKE_Fortran_COMPILER=$GFORTRAN_PATH" >> $GITHUB_ENV echo "LIBRARY_PATH=${BREW_PREFIX}/lib:$LIBRARY_PATH" >> $GITHUB_ENV - echo "LD_LIBRARY_PATH=${BREW_PREFIX}/lib:$LD_LIBRARY_PATH" >> $GITHUB_ENV - # 3. 设置 MSYS2 环境 - Windows + # Windows: MSYS2 for core DLL build - name: Setup MSYS2 (Windows) if: runner.os == 'Windows' uses: msys2/setup-msys2@v2 @@ -133,640 +69,167 @@ jobs: mingw-w64-x86_64-fftw mingw-w64-x86_64-boost mingw-w64-x86_64-gcc-fortran - mingw-w64-x86_64-nodejs - # 4. 安装 npm 依赖 - - name: Install npm dependencies - run: npm ci --ignore-scripts + - run: npm ci --ignore-scripts + - run: npm run build:ts - # 5. 构建 TypeScript - - name: Build TypeScript - run: npm run build:ts + # Build native: Linux/macOS (single-step, both targets) + - name: Build native module (Linux/macOS) + if: runner.os != 'Windows' + run: | + rm -rf build/ + npx cmake-js compile --arch=${{ matrix.arch }} - # 6. 验证构建环境 - Windows (基于 build_mingw.sh) - - name: Verify build environment (Windows MSYS2) + # Build native: Windows Phase 1 - wsjtx_core.dll with MinGW + - name: Build wsjtx_core.dll (Windows MinGW) if: runner.os == 'Windows' shell: msys2 {0} run: | - echo "=== Verifying essential tools ===" - which cmake && cmake --version || { echo "❌ CMake not found"; exit 1; } - which gcc && gcc --version || { echo "❌ GCC not found"; exit 1; } - which g++ && g++ --version || { echo "❌ G++ not found"; exit 1; } - which gfortran && gfortran --version || { echo "❌ gfortran not found"; exit 1; } - which pkg-config && pkg-config --version || { echo "❌ pkg-config not found"; exit 1; } - which node && node --version || { echo "❌ Node.js not found"; exit 1; } - which npm && npm --version || { echo "❌ npm not found"; exit 1; } - which npx && npx --version || { echo "❌ npx not found"; exit 1; } - - echo -e "\n=== Checking node-addon-api headers ===" - if [ -d "node_modules/node-addon-api" ]; then - echo "✅ node-addon-api package found" - if [ -f "node_modules/node-addon-api/napi.h" ]; then - echo "✅ napi.h found" - else - echo "❌ napi.h not found" - exit 1 - fi - else - echo "❌ node-addon-api package not found" - exit 1 - fi - - echo -e "\n=== Checking FFTW3 availability ===" - if pkg-config --exists fftw3f; then - echo "✅ FFTW3F found" - echo "FFTW3F libraries: $(pkg-config --libs fftw3f)" - else - echo "❌ FFTW3F not found" - exit 1 - fi - - echo -e "\n=== Checking Boost installation ===" - if [ -d "/mingw64/include/boost" ]; then - echo "✅ Boost headers found" - else - echo "❌ Boost headers not found" - exit 1 - fi - - if ls /mingw64/lib/libboost* >/dev/null 2>&1; then - echo "✅ Boost libraries found" - else - echo "❌ Boost libraries not found" - exit 1 - fi + export PKG_CONFIG_PATH="/mingw64/lib/pkgconfig" + export CC=gcc CXX=g++ FC=gfortran CMAKE_MAKE_PROGRAM=mingw32-make + unset VSINSTALLDIR VCINSTALLDIR WindowsSDKDir VCPKG_ROOT - # 7. 构建原生模块 - Linux/macOS - - name: Build native module (Linux/macOS) - if: runner.os != 'Windows' - run: | - # 清理可能存在的CMake缓存 - rm -rf build/ - - # 原生编译(支持所有架构) - npx cmake-js compile --arch=${{ matrix.cmake_arch }} + rm -rf build-core/ && mkdir build-core && cd build-core + cmake .. -G "MinGW Makefiles" -DWSJTX_BUILD_CORE_ONLY=ON -DCMAKE_BUILD_TYPE=Release + mingw32-make -j$(nproc) - # 8. 构建原生模块 - Windows MSYS2/MinGW-w64 (基于 build_mingw.sh) - - name: Build native module (Windows MSYS2) + # Generate MSVC-compatible import library + cp Release/libwsjtx_core.dll Release/wsjtx_core.dll + gendef Release/wsjtx_core.dll + dlltool -d wsjtx_core.def -l Release/wsjtx_core.lib -D wsjtx_core.dll + cp ../native/wsjtx_c_api.h Release/ + + # Build native: Windows Phase 2 - .node with MSVC + - name: Build .node module (Windows MSVC) + if: runner.os == 'Windows' + run: npx cmake-js compile --CDWSJTX_BUILD_NODE_ONLY=ON --CDWSJTX_CORE_DIR=build-core/Release + + # Windows: Copy DLLs for runtime + - name: Prepare Windows runtime (Windows) if: runner.os == 'Windows' shell: msys2 {0} run: | - echo "=== Building with MSYS2/MinGW-w64 toolchain ===" - - # 调试:检查关键DLL的存在 - echo "🔍 Pre-build DLL availability check:" - for dll in libfftw3f-3.dll libfftw3f_threads-3.dll libgfortran-5.dll libgcc_s_seh-1.dll libwinpthread-1.dll libstdc++-6.dll; do - if [ -f "/mingw64/bin/$dll" ]; then - echo " ✅ $dll: $(stat -c%s /mingw64/bin/$dll) bytes" - else - echo " ❌ $dll: NOT FOUND" - fi + # Copy wsjtx_core.dll and MinGW runtime DLLs to build/Release + cp build-core/Release/wsjtx_core.dll build/Release/ + for dll in libgfortran-5.dll libgcc_s_seh-1.dll libstdc++-6.dll libwinpthread-1.dll libfftw3f-3.dll libfftw3f_threads-3.dll libquadmath-0.dll; do + [ -f "/mingw64/bin/$dll" ] && cp "/mingw64/bin/$dll" build/Release/ done - echo "" - - # 设置环境变量强制使用 MinGW 工具链 - export PKG_CONFIG_PATH="/mingw64/lib/pkgconfig" - export CMAKE_PREFIX_PATH="/mingw64" - export CC="gcc" - export CXX="g++" - export FC="gfortran" - export CMAKE_MAKE_PROGRAM="mingw32-make" - - # 禁用 Visual Studio 检测 - unset VSINSTALLDIR - unset VCINSTALLDIR - unset WindowsSDKDir - unset VCPKG_ROOT - - echo -e "\n=== Environment variables ===" - echo "PKG_CONFIG_PATH=$PKG_CONFIG_PATH" - echo "CMAKE_PREFIX_PATH=$CMAKE_PREFIX_PATH" - echo "CC=$CC, CXX=$CXX, FC=$FC" - - # 清理构建目录 - rm -rf build/ - - echo -e "\n=== Starting build process ===" - npx cmake-js compile --arch=${{ matrix.cmake_arch }} \ - --generator="MinGW Makefiles" \ - --no-retry \ - --verbose - - # 10. 运行测试 - Linux/macOS + + # Test - name: Run tests (Linux/macOS) if: runner.os != 'Windows' - run: | - echo "📁 Checking compiled files structure:" - ls -la dist/ || echo "dist directory not found" - find dist -name "*.js" -type f || echo "No JS files found in dist" - - # 检查测试文件是否存在 - if [ -f "dist/test/wsjtx.basic.test.js" ]; then - echo "✅ Basic test file found: dist/test/wsjtx.basic.test.js" - - # 检查原生模块是否存在 - if [ -f "build/Release/wsjtx_lib_nodejs.node" ]; then - echo "✅ Native module found: build/Release/wsjtx_lib_nodejs.node" - ls -la build/Release/wsjtx_lib_nodejs.node - # 运行基础测试 - npm test - elif [ -f "build/wsjtx_lib_nodejs.node" ]; then - echo "✅ Native module found: build/wsjtx_lib_nodejs.node" - ls -la build/wsjtx_lib_nodejs.node - # 确保在正确位置,某些系统可能需要Release子目录 - mkdir -p build/Release - cp build/wsjtx_lib_nodejs.node build/Release/ - # 运行基础测试 - npm test - else - echo "❌ Native module not found! Searching for .node files..." - find . -name "*.node" || echo "No .node files found" - exit 1 - fi - else - echo "❌ Basic test file not found!" - echo "Available test files:" - find dist -name "*.test.js" -type f || echo "No test files found" - exit 1 - fi - shell: bash + run: npm test - # 10a. 运行测试 - Windows (MSYS2/MinGW) - name: Run tests (Windows) if: runner.os == 'Windows' - shell: msys2 {0} + run: npm test + + # Package prebuilds + - name: Package prebuilds (Linux) + if: runner.os == 'Linux' run: | - echo "📁 Checking compiled files structure:" - - if [ -d "dist" ]; then - find dist -name "*.js" -type f | head -10 - else - echo "dist directory not found" - fi - - # 检查测试文件是否存在 - if [ -f "dist/test/wsjtx.basic.test.js" ]; then - echo "✅ Basic test file found: dist/test/wsjtx.basic.test.js" - - # 检查原生模块是否存在(统一要求 build/Release) - if [ -f "build/Release/wsjtx_lib_nodejs.node" ]; then - echo "✅ Native module found: build/Release/wsjtx_lib_nodejs.node" - ls -la build/Release/wsjtx_lib_nodejs.node - - # 检查模块依赖 - echo "🔍 Checking module dependencies..." - if command -v ldd >/dev/null 2>&1; then - ldd build/Release/wsjtx_lib_nodejs.node || echo "ldd failed, trying objdump" - fi - - echo "" - echo "🔍 Checking DLL dependencies with objdump:" - objdump -p build/Release/wsjtx_lib_nodejs.node | grep "DLL Name" || echo "objdump failed" - - # 检查关键库是否在PATH中可用 - echo "" - echo "🔍 Checking library availability in MinGW environment:" - echo "PATH directories with libraries:" - echo $PATH | tr ':' '\n' | while read dir; do - if [ -d "$dir" ] && ls "$dir"/*.dll >/dev/null 2>&1; then - echo " 📁 $dir" - ls "$dir"/libfftw* "$dir"/libgfortran* "$dir"/libgcc* 2>/dev/null | head -3 || true - fi - done - - # 设置环境变量以确保库能被找到 - echo "" - echo "🔧 Setting up library paths for testing..." - export PATH="/mingw64/bin:$PATH" - export LD_LIBRARY_PATH="/mingw64/lib:$LD_LIBRARY_PATH" - - echo "Updated PATH (first few entries):" - echo $PATH | tr ':' '\n' | head -3 - - # 验证Node.js版本兼容性 - echo "" - echo "🔍 Verifying Node.js environment:" - echo "MinGW Node.js version:" - which node && node --version || echo "Node.js not found in MinGW PATH" - - # 强制使用GitHub Actions安装的Node.js版本(与构建时一致) - echo "" - echo "🔧 Ensuring Node.js version consistency..." - echo "Expected Node.js version: ${{ env.NODE_VERSION }}" - - # 查找GitHub Actions安装的Node.js路径 - GITHUB_NODE_PATH="" - if [ -f "/c/hostedtoolcache/node/${{ env.NODE_VERSION }}"*/x64/node.exe ]; then - GITHUB_NODE_PATH=$(dirname $(find /c/hostedtoolcache/node -name "node.exe" | grep "${{ env.NODE_VERSION }}" | head -1)) - echo "Found GitHub Actions Node.js at: $GITHUB_NODE_PATH" - elif [ -f "/c/Program Files/nodejs/node.exe" ]; then - GITHUB_NODE_PATH="/c/Program Files/nodejs" - echo "Using system Node.js at: $GITHUB_NODE_PATH" - fi - - # 设置PATH,确保使用正确的Node.js版本,但保持MinGW库路径 - if [ -n "$GITHUB_NODE_PATH" ]; then - export PATH="$GITHUB_NODE_PATH:/mingw64/bin:$PATH" - echo "Updated PATH to use GitHub Actions Node.js" - echo "Active Node.js version: $(node --version)" - echo "Active npm version: $(npm --version)" - else - echo "⚠️ Warning: Could not find GitHub Actions Node.js, using MinGW Node.js" - echo "This may cause version mismatch issues" - fi - - # 运行测试 - echo "" - echo "🧪 Running tests with version-matched Node.js..." - npm test - test_result=$? - - if [ $test_result -eq 0 ]; then - echo "✅ Tests passed!" - else - echo "❌ Test failed with exit code: $test_result" - - # 尝试直接加载模块 - echo "🔍 Trying to load module directly..." - node -e "try { require('./build/Release/wsjtx_lib_nodejs.node'); console.log('✅ Module loaded successfully'); } catch(e) { console.error('❌ Load error:', e.message); console.error(' Code:', e.code); }" - - exit $test_result - fi - - else - echo "❌ Native module not found! Searching for .node files..." - find . -name "*.node" -type f || echo "No .node files found" - exit 1 + TARGET_DIR="prebuilds/${{ matrix.platform }}-${{ matrix.arch }}" + mkdir -p "$TARGET_DIR" + cp build/Release/wsjtx_lib_nodejs.node "$TARGET_DIR/" + NODE_FILE="$TARGET_DIR/wsjtx_lib_nodejs.node" + + # Copy libwsjtx_core.so + cp build/Release/libwsjtx_core.so "$TARGET_DIR/" 2>/dev/null || true + + # Bundle runtime shared libraries + ldd "$NODE_FILE" | awk '{print $3}' | while read lib; do + if [ -f "$lib" ] && [[ "$lib" == *libfftw* || "$lib" == *libgfortran* || "$lib" == *libgcc* || "$lib" == *libquadmath* || "$lib" == *libstdc++* ]]; then + cp -n "$lib" "$TARGET_DIR/" 2>/dev/null || true fi - else - echo "❌ Basic test file not found!" - echo "Available test files:" - find dist -name "*.test.js" -type f || echo "No test files found" - exit 1 - fi + done + patchelf --set-rpath '$ORIGIN' "$NODE_FILE" || true + patchelf --set-rpath '$ORIGIN' "$TARGET_DIR/libwsjtx_core.so" 2>/dev/null || true - # 11. 创建预构建二进制文件 - Linux/macOS - - name: Create prebuilt binaries (Linux/macOS) - if: runner.os != 'Windows' + echo '{}' | jq --arg p "${{ matrix.platform }}" --arg a "${{ matrix.arch }}" \ + '{platform: $p, arch: $a, build_time: now | todate}' > "$TARGET_DIR/build-info.json" + ls -la "$TARGET_DIR" + + - name: Package prebuilds (macOS) + if: runner.os == 'macOS' run: | - # 映射GitHub Actions平台名称到Node.js标准平台名称 - case "${{ matrix.os }}" in - ubuntu-latest|ubuntu-*) PLATFORM_NAME="linux" ;; - macos*) PLATFORM_NAME="darwin" ;; - windows-latest) PLATFORM_NAME="win32" ;; - *) PLATFORM_NAME="${{ matrix.os }}" ;; - esac - - # 创建目标目录 - TARGET_DIR="prebuilds/$PLATFORM_NAME-${{ matrix.arch }}" - echo "📁 Creating target directory: $TARGET_DIR" - echo " • GitHub runner: ${{ matrix.os }}" - echo " • Node.js platform: $PLATFORM_NAME" - echo " • Architecture: ${{ matrix.arch }}" + TARGET_DIR="prebuilds/${{ matrix.platform }}-${{ matrix.arch }}" mkdir -p "$TARGET_DIR" - - # 复制构建的 .node 文件(统一来自 build/Release) - if [ -f "build/Release/wsjtx_lib_nodejs.node" ]; then - cp build/Release/wsjtx_lib_nodejs.node "$TARGET_DIR/" - echo "Copied from build/Release/" - # 重要: NODE_FILE 必须指向目标目录中的文件,这样 dylibbundler 才会修改正确的文件 - NODE_FILE="$TARGET_DIR/wsjtx_lib_nodejs.node" - else - echo "❌ Native module not found for packaging at build/Release!" - echo "当前 build 目录结构:" - find build -maxdepth 2 -type f -name "*.node" -print || true - exit 1 - fi - - # 检查并复制动态依赖库 - echo "🔍 Checking dynamic dependencies..." - - if [ "${{ runner.os }}" = "Linux" ]; then - echo "Checking Linux shared libraries..." - # 将依赖库与 .node 放在同级目录,与 macOS 和 Windows 保持一致 - ldd "$NODE_FILE" | grep -v "linux-vdso\|ld-linux\|libc\|libm\|libpthread\|libdl" | awk '{print $3}' | grep -v "not found" | while read lib; do - if [ -f "$lib" ] && [[ "$lib" == *"libfftw"* || "$lib" == *"libgfortran"* || "$lib" == *"libgcc"* || "$lib" == *"libquadmath"* || "$lib" == *"libstdc++"* ]]; then - lib_name=$(basename "$lib") - echo "📦 Bundling: $lib_name -> $TARGET_DIR/" - cp -n "$lib" "$TARGET_DIR/" || cp "$lib" "$TARGET_DIR/" - fi - done - echo "Setting RPATH to \$ORIGIN (same directory as .node)" - patchelf --set-rpath '$ORIGIN' "$NODE_FILE" || true - echo "🔎 ldd after RPATH:" - ldd "$NODE_FILE" || true - echo "" - echo "📁 Bundled .so files in $TARGET_DIR:" - ls -la "$TARGET_DIR"/*.so* 2>/dev/null || echo "No .so files found" - elif [ "${{ runner.os }}" = "macOS" ]; then - echo "Bundling macOS dylibs with dylibbundler..." - # 优先在 Homebrew 的常见路径搜索 - SEARCH_PATHS=( - "/opt/homebrew/opt/fftw/lib" - "/opt/homebrew/opt/gcc@14/lib/gcc/14" - "/opt/homebrew/opt/gcc/lib/gcc/current" - "/usr/local/opt/fftw/lib" - "/usr/local/opt/gcc@14/lib/gcc/14" - "/usr/local/opt/gcc/lib/gcc/current" - ) - SP_ARGS="" - for p in "${SEARCH_PATHS[@]}"; do - if [ -d "$p" ]; then - SP_ARGS="$SP_ARGS -s $p" - fi - done - # 动态加入 Cellar 路径,避免某些环境下找不到实际文件 - BREW_PREFIX=$(brew --prefix 2>/dev/null || echo "/opt/homebrew") - for p in "$BREW_PREFIX/Cellar/fftw"/*/lib "$BREW_PREFIX/Cellar/gcc"/*/lib/gcc/current; do - if [ -d "$p" ]; then - SP_ARGS="$SP_ARGS -s $p" - fi - done - - # 将依赖库与 .node 放在同级目录,避免嵌套路径问题 - # 注意: -od 会清空目标目录,但 .node 已经复制到 TARGET_DIR,dylibbundler 不会删除它 - # 使用 -b 启用实际复制 - dylibbundler -x "$NODE_FILE" -d "$TARGET_DIR" -p "@loader_path/" $SP_ARGS -b - - echo "" - echo "📁 Bundled dylibs in $TARGET_DIR:" - ls -la "$TARGET_DIR"/*.dylib 2>/dev/null || echo "No dylib files found" - - echo "" - echo "🔎 Final otool -L of packaged .node file ($NODE_FILE):" - otool -L "$NODE_FILE" - - echo "" - echo "🔍 Verifying all dylib paths are relative:" - if otool -L "$NODE_FILE" | grep -E '^\s+(/opt/homebrew|/usr/local)'; then - echo "❌ ERROR: Found absolute Homebrew paths in final binary!" - exit 1 - else - echo "✅ All dependency paths are relative" - fi + cp build/Release/wsjtx_lib_nodejs.node "$TARGET_DIR/" + NODE_FILE="$TARGET_DIR/wsjtx_lib_nodejs.node" + + # Copy libwsjtx_core.dylib + cp build/Release/libwsjtx_core.dylib "$TARGET_DIR/" 2>/dev/null || true + + # Bundle dylibs + BREW_PREFIX=$(brew --prefix 2>/dev/null || echo "/opt/homebrew") + SP_ARGS="" + for p in "$BREW_PREFIX/opt/fftw/lib" "$BREW_PREFIX/opt/gcc@14/lib/gcc/14" \ + "$BREW_PREFIX/opt/gcc/lib/gcc/current" "$BREW_PREFIX/Cellar/fftw"/*/lib \ + "$BREW_PREFIX/Cellar/gcc"/*/lib/gcc/current; do + [ -d "$p" ] && SP_ARGS="$SP_ARGS -s $p" + done + dylibbundler -x "$NODE_FILE" -d "$TARGET_DIR" -p "@loader_path/" $SP_ARGS -b + if [ -f "$TARGET_DIR/libwsjtx_core.dylib" ]; then + dylibbundler -x "$TARGET_DIR/libwsjtx_core.dylib" -d "$TARGET_DIR" -p "@loader_path/" $SP_ARGS -b 2>/dev/null || true fi - - # 显示构建结果 - echo "📁 构建完成的文件:" + + echo '{}' | jq --arg p "${{ matrix.platform }}" --arg a "${{ matrix.arch }}" \ + '{platform: $p, arch: $a, build_time: now | todate}' > "$TARGET_DIR/build-info.json" ls -la "$TARGET_DIR" - - # 创建构建信息文件 - cat > "$TARGET_DIR/build-info.json" << EOF - { - "platform": "$PLATFORM_NAME", - "github_runner": "${{ matrix.os }}", - "arch": "${{ matrix.arch }}", - "node_version": "${{ env.NODE_VERSION }}", - "build_time": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", - "cmake_arch": "${{ matrix.cmake_arch }}", - "file_size": $(stat -c%s "$TARGET_DIR"/*.node 2>/dev/null || stat -f%z "$TARGET_DIR"/*.node 2>/dev/null || echo "unknown"), - "bundled_libraries": $(ls "$TARGET_DIR" | grep -E "\\.(so|dylib)$" | wc -l) - } - EOF - - echo "📋 构建信息:" - cat "$TARGET_DIR/build-info.json" - shell: bash - # 11a. 创建预构建二进制文件 - Windows (MSYS2) - - name: Create prebuilt binaries (Windows) + - name: Package prebuilds (Windows) if: runner.os == 'Windows' shell: msys2 {0} run: | - echo "🔧 Windows prebuilt packaging started" - echo "Environment info:" - echo " • Shell: $0" - echo " • PWD: $(pwd)" - echo " • User: $(whoami)" - echo "" - - # 映射GitHub Actions平台名称到Node.js标准平台名称 - case "${{ matrix.os }}" in - ubuntu-latest|ubuntu-*) PLATFORM_NAME="linux" ;; - macos*) PLATFORM_NAME="darwin" ;; - windows-latest) PLATFORM_NAME="win32" ;; - *) PLATFORM_NAME="${{ matrix.os }}" ;; - esac - - # 创建目标目录 - TARGET_DIR="prebuilds/$PLATFORM_NAME-${{ matrix.arch }}" - echo "📁 Creating target directory: $TARGET_DIR" - echo " • GitHub runner: ${{ matrix.os }}" - echo " • Node.js platform: $PLATFORM_NAME" - echo " • Architecture: ${{ matrix.arch }}" + TARGET_DIR="prebuilds/${{ matrix.platform }}-${{ matrix.arch }}" mkdir -p "$TARGET_DIR" - - # 复制构建的 .node 文件 - 检查两个可能的位置 - if [ -f "build/Release/wsjtx_lib_nodejs.node" ]; then - cp build/Release/wsjtx_lib_nodejs.node "$TARGET_DIR/" - echo "✅ Copied from build/Release/" - NODE_FILE="build/Release/wsjtx_lib_nodejs.node" - elif [ -f "build/wsjtx_lib_nodejs.node" ]; then - cp build/wsjtx_lib_nodejs.node "$TARGET_DIR/" - echo "✅ Copied from build/" - NODE_FILE="build/wsjtx_lib_nodejs.node" - else - echo "❌ Native module not found for packaging!" - exit 1 - fi - - # 检查并复制必要的DLL依赖 - echo "🔍 Analyzing DLL dependencies..." - - # 获取DLL依赖列表 - REQUIRED_DLLS=$(objdump -p "$NODE_FILE" | grep "DLL Name" | awk '{print $3}' | grep -E "(libfftw|libgfortran|libgcc|libwinpthread|libstdc)") - - echo "📦 Required DLLs to bundle:" - for dll in $REQUIRED_DLLS; do - echo " - $dll" - done - - # 从MinGW复制必要的DLL(Windows 不使用子目录,需与 .node 同级) - echo "" - echo "📋 Copying DLLs from MinGW..." - bundled_count=0 - missing_dlls="" - - # 首先检查MinGW目录是否存在 - echo "🔍 Checking MinGW directory..." - if [ -d "/mingw64/bin" ]; then - echo " ✅ /mingw64/bin exists" - echo " Available DLLs in /mingw64/bin:" - ls /mingw64/bin/lib*.dll | head -10 || echo " No lib*.dll files found" - else - echo " ❌ /mingw64/bin not found!" - exit 1 - fi - - echo "" - echo "📋 Processing each required DLL..." - for dll in $REQUIRED_DLLS; do - echo " 🔍 Checking: $dll" - if [ -f "/mingw64/bin/$dll" ]; then - if cp "/mingw64/bin/$dll" "$TARGET_DIR/" 2>/dev/null; then - echo " ✅ Bundled: $dll" - bundled_count=$((bundled_count + 1)) - else - echo " ❌ Copy failed: $dll" - missing_dlls="$missing_dlls $dll" - fi - else - echo " ❌ Missing: $dll" - missing_dlls="$missing_dlls $dll" - - # 尝试查找类似名称的文件 - echo " 🔍 Looking for similar files..." - find /mingw64/bin -name "*${dll%%-*}*" -type f | head -3 || echo " No similar files found" - fi - done - - # 报告结果 - echo "" - echo "📊 DLL bundling summary:" - echo " • Successfully bundled: $bundled_count DLLs" - if [ -n "$missing_dlls" ]; then - echo " • Missing DLLs:$missing_dlls" - echo " ⚠️ Some DLLs are missing, but continuing..." - fi - - # 显示最终结果 - echo "" - echo "📁 Final package contents:" - ls -la "$TARGET_DIR" - - # 获取文件大小 - node_size=$(stat -c%s "$TARGET_DIR"/*.node) - total_size=$(du -sb "$TARGET_DIR" | cut -f1) - - # 创建构建信息文件 - # 获取实际捆绑的DLL列表 - bundled_dll_list="" - for dll_file in "$TARGET_DIR"/*.dll; do - if [ -f "$dll_file" ]; then - dll_name=$(basename "$dll_file") - if [ -z "$bundled_dll_list" ]; then - bundled_dll_list="\"$dll_name\"" - else - bundled_dll_list="$bundled_dll_list, \"$dll_name\"" - fi - fi - done - - cat > "$TARGET_DIR/build-info.json" << EOF - { - "platform": "$PLATFORM_NAME", - "github_runner": "${{ matrix.os }}", - "arch": "${{ matrix.arch }}", - "node_version": "${{ env.NODE_VERSION }}", - "build_time": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", - "cmake_arch": "${{ matrix.cmake_arch }}", - "file_size": $node_size, - "bundled_libraries": $bundled_count, - "total_package_size": $total_size, - "required_dlls": [$(echo "$REQUIRED_DLLS" | sed 's/^/"/;s/$/"/' | paste -sd, -)], - "bundled_dlls": [$bundled_dll_list], - "missing_dlls": "$(echo $missing_dlls | sed 's/^ *//' | sed 's/ *$//')" - } + cp build/Release/wsjtx_lib_nodejs.node "$TARGET_DIR/" + cp build/Release/wsjtx_core.dll "$TARGET_DIR/" + cp build/Release/*.dll "$TARGET_DIR/" 2>/dev/null || true + + # build-info.json + cat > "$TARGET_DIR/build-info.json" < build-summary.md - echo "构建时间: $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> build-summary.md - echo "" >> build-summary.md - echo "## 支持的平台:" >> build-summary.md - - for info_file in final-prebuilds/*/build-info.json; do - if [ -f "$info_file" ]; then - platform=$(jq -r '.platform' "$info_file") - arch=$(jq -r '.arch' "$info_file") - file_size=$(jq -r '.file_size' "$info_file") - echo "- $platform-$arch ($(numfmt --to=iec $file_size 2>/dev/null || echo $file_size))" >> build-summary.md - fi - done - - echo "" >> build-summary.md - echo "## 文件详情:" >> build-summary.md - find final-prebuilds -name "*.node" | while read file; do - size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo "unknown") - size_human=$(numfmt --to=iec $size 2>/dev/null || echo "$size bytes") - echo "- \`$(basename "$file")\`: $size_human" >> build-summary.md - done - - name: Upload combined artifacts - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 with: name: all-prebuilds - path: | - final-prebuilds/ - build-summary.md + path: final-prebuilds/ retention-days: 90 diff --git a/.gitignore b/.gitignore index 79ca5ee..51eb329 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,16 @@ node_modules/ build/ +build-mingw/ +build-core/ prebuilds/ dist/ +.idea/ +.claude/ # FFTW wisdom files (machine-specific optimization cache) fftw_wisdom.dat *.wisdom -hashtable.txt \ No newline at end of file +hashtable.txt + +# Build logs +*.log \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 90821d9..cb2402e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,487 +1,417 @@ cmake_minimum_required(VERSION 3.15) -# 禁用 vcpkg Manifest 模式,使用经典模式 +# ============================================================================ +# Build modes (for Windows two-phase build): +# WSJTX_BUILD_CORE_ONLY - Build only wsjtx_core shared library (MinGW) +# WSJTX_BUILD_NODE_ONLY - Build only .node module (MSVC, needs wsjtx_core) +# Neither - Build both targets (Linux/macOS, same compiler) +# ============================================================================ +option(WSJTX_BUILD_CORE_ONLY "Build only wsjtx_core shared library" OFF) +option(WSJTX_BUILD_NODE_ONLY "Build only .node module (requires pre-built wsjtx_core)" OFF) + +# Disable vcpkg manifest mode if detected if(DEFINED CMAKE_TOOLCHAIN_FILE AND CMAKE_TOOLCHAIN_FILE MATCHES "vcpkg") - set(VCPKG_MANIFEST_MODE OFF CACHE BOOL "Disable vcpkg manifest mode" FORCE) - set(VCPKG_MANIFEST_INSTALL OFF CACHE BOOL "Disable vcpkg manifest install" FORCE) - message(STATUS "Detected vcpkg toolchain, disabling manifest mode") + set(VCPKG_MANIFEST_MODE OFF CACHE BOOL "" FORCE) + set(VCPKG_MANIFEST_INSTALL OFF CACHE BOOL "" FORCE) endif() -project(wsjtx_lib_nodejs LANGUAGES C CXX Fortran) +# Project languages depend on build mode +if(WSJTX_BUILD_NODE_ONLY) + project(wsjtx_lib_nodejs LANGUAGES C CXX) +else() + project(wsjtx_lib_nodejs LANGUAGES C CXX Fortran) +endif() -# Set C++ standard set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) - -# Enable position independent code for all targets (including subprojects) set(CMAKE_POSITION_INDEPENDENT_CODE ON) -# Include cmake-js - this must come first to get CMAKE_JS_* variables -if(CMAKE_JS_INC) - include_directories(${CMAKE_JS_INC}) +# CMake policy for newer Boost versions +if(POLICY CMP0167) + cmake_policy(SET CMP0167 NEW) endif() -# Add Node.js include path for node_api.h (for all platforms) -execute_process( - COMMAND node -p "require('path').dirname(process.execPath) + '/../include/node'" - OUTPUT_VARIABLE NODE_INCLUDE_DIR - OUTPUT_STRIP_TRAILING_WHITESPACE - ERROR_QUIET -) -if(NODE_INCLUDE_DIR AND EXISTS "${NODE_INCLUDE_DIR}") - include_directories(${NODE_INCLUDE_DIR}) - message(STATUS "Added Node.js include directory: ${NODE_INCLUDE_DIR}") +# ============================================================================ +# Platform-specific compiler flags +# ============================================================================ +if(UNIX AND NOT APPLE) + add_compile_options(-fPIC -fvisibility=hidden) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-stringop-overflow -Wno-deprecated-declarations") + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wno-stringop-overflow -Wno-deprecated-declarations") +elseif(APPLE) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fvisibility=hidden -Wno-deprecated-declarations -Wno-unqualified-std-cast-call") +elseif(WIN32 AND CMAKE_CXX_COMPILER_ID MATCHES "GNU") + # MinGW environment + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fvisibility=hidden -Wno-deprecated-declarations") + set(CMAKE_SHARED_LINKER_FLAGS "") + + # MSYS2 paths (check both C: and D: drives) + foreach(_msys_root "C:/msys64" "D:/msys64") + if(EXISTS "${_msys_root}/mingw64") + include_directories("${_msys_root}/mingw64/include") + link_directories("${_msys_root}/mingw64/lib") + if(EXISTS "${_msys_root}/mingw64/lib/pkgconfig") + set(ENV{PKG_CONFIG_PATH} "${_msys_root}/mingw64/lib/pkgconfig") + endif() + break() + endif() + endforeach() endif() -# **关键修复**: Windows + MinGW 环境检测并清除 MSVC 特定标志 -if(WIN32 AND CMAKE_CXX_COMPILER_ID MATCHES "GNU") - message(STATUS "Detected MinGW-w64 environment on Windows") - - # 设置 PKG_CONFIG_PATH 以便查找 MSYS2 库 - if(EXISTS "C:/msys64/mingw64/lib/pkgconfig") - set(ENV{PKG_CONFIG_PATH} "C:/msys64/mingw64/lib/pkgconfig") - message(STATUS "Set PKG_CONFIG_PATH to C:/msys64/mingw64/lib/pkgconfig") +# ============================================================================ +# NODE-ONLY build path (MSVC on Windows) +# Only builds the .node module, links against pre-built wsjtx_core +# ============================================================================ +if(WSJTX_BUILD_NODE_ONLY) + if(NOT WSJTX_CORE_DIR) + message(FATAL_ERROR "WSJTX_CORE_DIR must be set to the directory containing wsjtx_core.lib and wsjtx_core.dll") endif() - - # 添加 MSYS2 的 include 和 library 路径 - if(EXISTS "C:/msys64/mingw64") - include_directories("C:/msys64/mingw64/include") - link_directories("C:/msys64/mingw64/lib") - message(STATUS "Added MSYS2 include and library paths") + + # cmake-js headers + if(CMAKE_JS_INC) + include_directories(${CMAKE_JS_INC}) endif() - - # **关键**: 清空 cmake-js 可能添加的 MSVC 专属链接标志 (/DELAYLOAD:NODE.EXE) - set(CMAKE_SHARED_LINKER_FLAGS "") - message(STATUS "Cleared MSVC-specific linker flags for MinGW compatibility") -endif() -# Force avoid MSVC detection on Windows when using MinGW -if(WIN32 AND MSVC) - message(FATAL_ERROR "MSVC compiler detected. This project requires MinGW-w64. Please use MSYS2/MinGW-w64 environment and specify -G \"MinGW Makefiles\"") -endif() + # Node.js include path + execute_process( + COMMAND node -p "require('path').dirname(process.execPath) + '/../include/node'" + OUTPUT_VARIABLE NODE_INCLUDE_DIR + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) + if(NODE_INCLUDE_DIR AND EXISTS "${NODE_INCLUDE_DIR}") + include_directories(${NODE_INCLUDE_DIR}) + endif() -# Additional check for cmake-js on Windows - prevent Visual Studio generator -if(WIN32 AND CMAKE_GENERATOR MATCHES "Visual Studio") - message(FATAL_ERROR "Visual Studio generator detected. This project requires MinGW-w64. Please set CMAKE_GENERATOR=MinGW Makefiles") -endif() + # node-addon-api + set(NODE_ADDON_API_PATHS + "${CMAKE_SOURCE_DIR}/node_modules/node-addon-api" + "${CMAKE_SOURCE_DIR}/../../node_modules/node-addon-api" + ) + foreach(path ${NODE_ADDON_API_PATHS}) + if(EXISTS "${path}/napi.h") + set(NODE_ADDON_API_PATH "${path}") + break() + endif() + endforeach() + if(NOT NODE_ADDON_API_PATH) + message(FATAL_ERROR "Could not find node-addon-api") + endif() -# Set compiler flags for better compatibility -if(UNIX AND NOT APPLE) - # Linux specific flags - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC -fvisibility=hidden") - set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fPIC") - set(CMAKE_Fortran_FLAGS "${CMAKE_Fortran_FLAGS} -fPIC") - - # Suppress some warnings that are common in the wsjtx_lib code - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-stringop-overflow -Wno-deprecated-declarations") - set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wno-stringop-overflow -Wno-deprecated-declarations") - - # Force PIC for all targets in subdirectories - set(CMAKE_POSITION_INDEPENDENT_CODE ON CACHE BOOL "Build position independent code" FORCE) - - # Additional flags to ensure PIC compilation - add_compile_options(-fPIC) - set(CMAKE_SHARED_LIBRARY_CXX_FLAGS "${CMAKE_SHARED_LIBRARY_CXX_FLAGS} -fPIC") - set(CMAKE_SHARED_LIBRARY_C_FLAGS "${CMAKE_SHARED_LIBRARY_C_FLAGS} -fPIC") -elseif(APPLE) - # macOS specific flags - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fvisibility=hidden") - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-deprecated-declarations -Wno-unqualified-std-cast-call") - - # Set CMake policy for newer Boost versions - if(POLICY CMP0167) - cmake_policy(SET CMP0167 NEW) + include_directories( + ${CMAKE_SOURCE_DIR}/native + ${NODE_ADDON_API_PATH} + ) + + # .node target + set(NODE_SOURCES native/wsjtx_wrapper.cpp native/wsjtx_wrapper.h) + if(CMAKE_JS_SRC) + list(APPEND NODE_SOURCES ${CMAKE_JS_SRC}) endif() -elseif(WIN32) - # Windows with MinGW-w64 specific flags - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fvisibility=hidden") - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-deprecated-declarations") - - # Set CMake policy for newer Boost versions - if(POLICY CMP0167) - cmake_policy(SET CMP0167 NEW) + add_library(${PROJECT_NAME} SHARED ${NODE_SOURCES}) + + set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "" SUFFIX ".node") + + target_compile_definitions(${PROJECT_NAME} PRIVATE + NAPI_DISABLE_CPP_EXCEPTIONS + BUILDING_NODE_EXTENSION + NAPI_VERSION=4 + NODE_GYP_MODULE_NAME=${PROJECT_NAME} + ) + + target_include_directories(${PROJECT_NAME} PRIVATE + ${CMAKE_JS_INC} + "${NODE_ADDON_API_PATH}" + "${WSJTX_CORE_DIR}" + ) + + # Link wsjtx_core import library + Node.js + find_library(WSJTX_CORE_LIB + NAMES wsjtx_core + PATHS "${WSJTX_CORE_DIR}" + NO_DEFAULT_PATH + ) + if(NOT WSJTX_CORE_LIB) + message(FATAL_ERROR "wsjtx_core.lib not found in ${WSJTX_CORE_DIR}") + endif() + + target_link_libraries(${PROJECT_NAME} PRIVATE ${WSJTX_CORE_LIB}) + if(CMAKE_JS_LIB) + target_link_libraries(${PROJECT_NAME} PRIVATE ${CMAKE_JS_LIB}) endif() + + # Output to build/Release (handle multi-config generators like VS) + set(_OUTPUT_DIR "${CMAKE_BINARY_DIR}/Release") + set_target_properties(${PROJECT_NAME} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${_OUTPUT_DIR}" + RUNTIME_OUTPUT_DIRECTORY "${_OUTPUT_DIR}" + ARCHIVE_OUTPUT_DIRECTORY "${_OUTPUT_DIR}" + ) + foreach(cfg Debug Release RelWithDebInfo MinSizeRel) + string(TOUPPER ${cfg} CFG_UPPER) + set_target_properties(${PROJECT_NAME} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY_${CFG_UPPER} "${_OUTPUT_DIR}" + RUNTIME_OUTPUT_DIRECTORY_${CFG_UPPER} "${_OUTPUT_DIR}" + ARCHIVE_OUTPUT_DIRECTORY_${CFG_UPPER} "${_OUTPUT_DIR}" + ) + endforeach() + + return() # Skip the rest of the file endif() -# Use pkg-config for all platforms (including Windows with MSYS2) -find_package(PkgConfig REQUIRED) +# ============================================================================ +# Full build path (Linux/macOS) or core-only build (Windows MinGW) +# ============================================================================ -# Set CMake policy for newer Boost versions -if(POLICY CMP0167) - cmake_policy(SET CMP0167 NEW) +# cmake-js headers (needed for full build) +if(CMAKE_JS_INC) + include_directories(${CMAKE_JS_INC}) endif() -# Find Boost - use different methods based on platform -if(WIN32 AND CMAKE_CXX_COMPILER_ID MATCHES "GNU") - # Windows with MinGW-w64: try both find_package and manual detection - find_package(Boost QUIET) - if(NOT Boost_FOUND) - # Manual detection for MSYS2 Boost - message(STATUS "Boost not found via find_package, trying manual detection...") - - # Look for Boost in MSYS2 locations - find_path(Boost_INCLUDE_DIRS - NAMES boost/version.hpp - PATHS /mingw64/include C:/msys64/mingw64/include - NO_DEFAULT_PATH - ) - - if(Boost_INCLUDE_DIRS) - message(STATUS "Found Boost headers at: ${Boost_INCLUDE_DIRS}") - set(Boost_FOUND TRUE) - # For header-only libraries, we don't need to link anything - set(BOOST_LIBRARIES "") - else() - message(WARNING "Boost headers not found") - set(BOOST_LIBRARIES "") - endif() - else() - message(STATUS "Boost found via find_package: ${Boost_VERSION}") - set(BOOST_LIBRARIES ${Boost_LIBRARIES}) - endif() -else() - # Other platforms: use standard find_package - find_package(Boost REQUIRED) - set(BOOST_LIBRARIES ${Boost_LIBRARIES}) +# Node.js include path +execute_process( + COMMAND node -p "require('path').dirname(process.execPath) + '/../include/node'" + OUTPUT_VARIABLE NODE_INCLUDE_DIR + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET +) +if(NODE_INCLUDE_DIR AND EXISTS "${NODE_INCLUDE_DIR}") + include_directories(${NODE_INCLUDE_DIR}) endif() -# Find FFTW3 using pkg-config +# ---- Dependencies ---- + +find_package(PkgConfig REQUIRED) pkg_check_modules(FFTW3F REQUIRED fftw3f) -# Check for FFTW3 threads support - 改进的检测逻辑 +# FFTW3 threads support if(UNIX AND NOT APPLE) - # Linux: search in standard locations - find_library(FFTW3F_THREADS_LIB - NAMES fftw3f_threads libfftw3f_threads - PATHS /usr/lib /usr/local/lib /usr/lib/x86_64-linux-gnu /usr/lib/aarch64-linux-gnu - ) + find_library(FFTW3F_THREADS_LIB NAMES fftw3f_threads + PATHS /usr/lib /usr/local/lib /usr/lib/x86_64-linux-gnu /usr/lib/aarch64-linux-gnu) elseif(APPLE) - # macOS: search in homebrew locations - find_library(FFTW3F_THREADS_LIB - NAMES fftw3f_threads libfftw3f_threads - PATHS /opt/homebrew/lib /usr/local/lib - NO_DEFAULT_PATH - ) + find_library(FFTW3F_THREADS_LIB NAMES fftw3f_threads + PATHS /opt/homebrew/lib /usr/local/lib NO_DEFAULT_PATH) elseif(WIN32 AND CMAKE_CXX_COMPILER_ID MATCHES "GNU") - # Windows with MSYS2/MinGW: use pkg-config if available, otherwise force enable - execute_process( - COMMAND pkg-config --exists fftw3f - RESULT_VARIABLE PKG_CONFIG_FFTW_RESULT - OUTPUT_QUIET ERROR_QUIET - ) - - if(PKG_CONFIG_FFTW_RESULT EQUAL 0) - # FFTW3 found via pkg-config, assume threads support exists - message(STATUS "FFTW3 found via pkg-config, enabling threads support") + execute_process(COMMAND pkg-config --exists fftw3f RESULT_VARIABLE _r OUTPUT_QUIET ERROR_QUIET) + if(_r EQUAL 0) set(FFTW3F_THREADS_LIB "fftw3f_threads") - set(FFTW_THREADS_LIBRARIES "fftw3f_threads") - set(FFTW_HAS_THREADS TRUE) - else() - message(STATUS "FFTW3 threads support detection failed, disabling") - set(FFTW_THREADS_LIBRARIES "") - set(FFTW_HAS_THREADS FALSE) endif() -else() - # Other Windows: search in mingw64 locations - find_library(FFTW3F_THREADS_LIB - NAMES fftw3f_threads libfftw3f_threads - PATHS /mingw64/lib D:/msys64/mingw64/lib - NO_DEFAULT_PATH - ) endif() if(FFTW3F_THREADS_LIB) - message(STATUS "FFTW3 with threads support found: ${FFTW3F_THREADS_LIB}") set(FFTW_THREADS_LIBRARIES fftw3f_threads) set(FFTW_HAS_THREADS TRUE) else() - message(STATUS "FFTW3 threads not found, using single-threaded version") set(FFTW_THREADS_LIBRARIES "") set(FFTW_HAS_THREADS FALSE) endif() -# Auto-detect node-addon-api path (monorepo vs standalone) -set(NODE_ADDON_API_PATHS - "${CMAKE_SOURCE_DIR}/node_modules/node-addon-api" # Standalone project - "${CMAKE_SOURCE_DIR}/../../node_modules/node-addon-api" # Monorepo (2 levels up) - "${CMAKE_SOURCE_DIR}/../../../node_modules/node-addon-api" # Monorepo (3 levels up) -) - -set(NODE_ADDON_API_PATH "") -foreach(path ${NODE_ADDON_API_PATHS}) - if(EXISTS "${path}/napi.h") - set(NODE_ADDON_API_PATH "${path}") - message(STATUS "Found node-addon-api at: ${NODE_ADDON_API_PATH}") - break() +# Boost +if(WIN32 AND CMAKE_CXX_COMPILER_ID MATCHES "GNU") + find_package(Boost QUIET) + if(NOT Boost_FOUND) + find_path(Boost_INCLUDE_DIRS NAMES boost/version.hpp + PATHS /mingw64/include C:/msys64/mingw64/include D:/msys64/mingw64/include NO_DEFAULT_PATH) + if(Boost_INCLUDE_DIRS) + set(Boost_FOUND TRUE) + endif() endif() -endforeach() +else() + find_package(Boost REQUIRED) +endif() -if(NOT NODE_ADDON_API_PATH) - message(FATAL_ERROR "Could not find node-addon-api. Searched paths: ${NODE_ADDON_API_PATHS}") +# macOS: find gfortran library path +if(APPLE) + find_program(GFORTRAN_EXECUTABLE + NAMES gfortran gfortran-14 gfortran-13 gfortran-12 gfortran-11 + PATHS /opt/homebrew/bin /usr/local/bin) + if(NOT GFORTRAN_EXECUTABLE) + find_program(GFORTRAN_EXECUTABLE NAMES gfortran gfortran-14 gfortran-13 gfortran-12) + endif() + if(GFORTRAN_EXECUTABLE) + execute_process(COMMAND ${GFORTRAN_EXECUTABLE} --print-file-name=libgfortran.dylib + OUTPUT_VARIABLE GFORTRAN_LIB_PATH OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + if(GFORTRAN_LIB_PATH AND NOT GFORTRAN_LIB_PATH STREQUAL "libgfortran.dylib") + get_filename_component(GFORTRAN_LIB_DIR "${GFORTRAN_LIB_PATH}" DIRECTORY) + link_directories(${GFORTRAN_LIB_DIR}) + endif() + execute_process(COMMAND ${GFORTRAN_EXECUTABLE} --print-file-name=libgcc_s.1.dylib + OUTPUT_VARIABLE LIBGCC_S_PATH OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + if(LIBGCC_S_PATH AND NOT LIBGCC_S_PATH STREQUAL "libgcc_s.1.dylib") + get_filename_component(LIBGCC_S_DIR "${LIBGCC_S_PATH}" DIRECTORY) + link_directories(${LIBGCC_S_DIR}) + endif() + endif() endif() # Include directories include_directories( ${CMAKE_SOURCE_DIR}/wsjtx_lib ${CMAKE_SOURCE_DIR}/native - ${NODE_ADDON_API_PATH} ${FFTW3F_INCLUDE_DIRS} ${Boost_INCLUDE_DIRS} ) -# Define LIBRARIES_FROM_REFERENCES for wsjtx_lib submodule -set(LIBRARIES_FROM_REFERENCES +# Dependencies for the core library +set(LIBRARIES_FROM_REFERENCES ${FFTW3F_LIBRARIES} ${FFTW_THREADS_LIBRARIES}) + +# Build the Fortran/C++ core +add_subdirectory(wsjtx_lib) +link_directories(${FFTW3F_LIBRARY_DIRS}) + +# ============================================================================ +# Target 1: wsjtx_core shared library (pure C API) +# ============================================================================ +add_library(wsjtx_core SHARED native/wsjtx_c_api.cpp native/wsjtx_c_api.h) + +target_compile_definitions(wsjtx_core PRIVATE WSJTX_CORE_EXPORTS) +set_target_properties(wsjtx_core PROPERTIES + CXX_VISIBILITY_PRESET hidden + C_VISIBILITY_PRESET hidden +) + +target_include_directories(wsjtx_core PRIVATE + ${CMAKE_SOURCE_DIR}/wsjtx_lib + ${FFTW3F_INCLUDE_DIRS} + ${Boost_INCLUDE_DIRS} +) + +target_link_libraries(wsjtx_core PRIVATE + wsjtx_lib ${FFTW3F_LIBRARIES} - ${FFTW_THREADS_LIBRARIES} ) -# Platform-specific library setup +# Platform-specific linking for wsjtx_core if(APPLE) - # Find gfortran library path for macOS - support both Intel and ARM64 - set(GFORTRAN_SEARCH_PATHS - "/opt/homebrew/bin" # ARM64 (Apple Silicon) - "/usr/local/bin" # x64 (Intel) - ) - - # Try to find gfortran in common locations - find_program(GFORTRAN_EXECUTABLE - NAMES gfortran gfortran-14 gfortran-13 gfortran-12 gfortran-11 - PATHS ${GFORTRAN_SEARCH_PATHS} - NO_DEFAULT_PATH - ) - - # Also try system PATH as fallback - if(NOT GFORTRAN_EXECUTABLE) - find_program(GFORTRAN_EXECUTABLE - NAMES gfortran gfortran-14 gfortran-13 gfortran-12 gfortran-11 - ) - endif() - - if(GFORTRAN_EXECUTABLE) - message(STATUS "Found gfortran: ${GFORTRAN_EXECUTABLE}") - - # Get library path (but don't set CMAKE_Fortran_COMPILER here to avoid cache conflicts) - execute_process( - COMMAND ${GFORTRAN_EXECUTABLE} --print-file-name=libgfortran.dylib - OUTPUT_VARIABLE GFORTRAN_LIB_PATH - OUTPUT_STRIP_TRAILING_WHITESPACE - ERROR_QUIET - ) - - if(GFORTRAN_LIB_PATH AND NOT GFORTRAN_LIB_PATH STREQUAL "libgfortran.dylib") - get_filename_component(GFORTRAN_LIB_DIR "${GFORTRAN_LIB_PATH}" DIRECTORY) - - # Find libgcc_s.1 for nested function support - execute_process( - COMMAND ${GFORTRAN_EXECUTABLE} --print-file-name=libgcc_s.1.dylib - OUTPUT_VARIABLE LIBGCC_S_PATH - OUTPUT_STRIP_TRAILING_WHITESPACE - ERROR_QUIET - ) - - if(LIBGCC_S_PATH AND NOT LIBGCC_S_PATH STREQUAL "libgcc_s.1.dylib") - get_filename_component(LIBGCC_S_DIR "${LIBGCC_S_PATH}" DIRECTORY) - link_directories(${LIBGCC_S_DIR}) - endif() - - link_directories(${GFORTRAN_LIB_DIR}) - endif() - else() - message(WARNING "gfortran not found, Fortran compilation may fail") - message(STATUS "Searched in paths: ${GFORTRAN_SEARCH_PATHS}") - message(STATUS "You may need to install gfortran: brew install gcc") - endif() - - list(APPEND LIBRARIES_FROM_REFERENCES - "-framework Accelerate" - gfortran - gcc_s.1 - ) + target_link_libraries(wsjtx_core PRIVATE "-framework Accelerate" gfortran gcc_s.1) if(FFTW_HAS_THREADS) - list(APPEND LIBRARIES_FROM_REFERENCES fftw3f_threads) + target_link_libraries(wsjtx_core PRIVATE fftw3f_threads) endif() elseif(UNIX) - # Linux specific libraries - list(APPEND LIBRARIES_FROM_REFERENCES - gfortran - gcc_s - pthread - ) + target_link_libraries(wsjtx_core PRIVATE gfortran gcc_s pthread) if(FFTW_HAS_THREADS) - list(APPEND LIBRARIES_FROM_REFERENCES fftw3f_threads) + target_link_libraries(wsjtx_core PRIVATE fftw3f_threads) endif() elseif(WIN32) - # Windows with MinGW-w64 specific libraries - list(APPEND LIBRARIES_FROM_REFERENCES - gfortran - gcc_s - pthread - ) + target_link_libraries(wsjtx_core PRIVATE gfortran gcc_s pthread) if(FFTW_HAS_THREADS) - list(APPEND LIBRARIES_FROM_REFERENCES fftw3f_threads) + target_link_libraries(wsjtx_core PRIVATE fftw3f_threads) endif() endif() -# Add wsjtx_lib as subdirectory -add_subdirectory(wsjtx_lib) +# Output wsjtx_core to build/Release alongside .node +set(_OUTPUT_DIR "${CMAKE_BINARY_DIR}/Release") +set_target_properties(wsjtx_core PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${_OUTPUT_DIR}" + RUNTIME_OUTPUT_DIRECTORY "${_OUTPUT_DIR}" + ARCHIVE_OUTPUT_DIRECTORY "${_OUTPUT_DIR}" +) -# Link directories (must be before creating target) -link_directories(${FFTW3F_LIBRARY_DIRS}) +# Linux RPATH +if(UNIX AND NOT APPLE) + set_target_properties(wsjtx_core PROPERTIES + BUILD_RPATH "\$ORIGIN" + INSTALL_RPATH "\$ORIGIN" + ) +endif() + +# If core-only build, stop here +if(WSJTX_BUILD_CORE_ONLY) + return() +endif() + +# ============================================================================ +# Target 2: .node N-API module (links wsjtx_core, no Fortran/FFTW needed) +# ============================================================================ + +# node-addon-api +set(NODE_ADDON_API_PATHS + "${CMAKE_SOURCE_DIR}/node_modules/node-addon-api" + "${CMAKE_SOURCE_DIR}/../../node_modules/node-addon-api" + "${CMAKE_SOURCE_DIR}/../../../node_modules/node-addon-api" +) +foreach(path ${NODE_ADDON_API_PATHS}) + if(EXISTS "${path}/napi.h") + set(NODE_ADDON_API_PATH "${path}") + break() + endif() +endforeach() +if(NOT NODE_ADDON_API_PATH) + message(FATAL_ERROR "Could not find node-addon-api") +endif() -# Source files for the Node.js addon -file(GLOB_RECURSE NATIVE_SOURCES "native/*.cpp" "native/*.h") +# .node target +set(NODE_SOURCES native/wsjtx_wrapper.cpp native/wsjtx_wrapper.h) -# Create the Node.js addon with MinGW-specific handling if(WIN32 AND CMAKE_CXX_COMPILER_ID MATCHES "GNU") - # MinGW build: exclude Windows delay load hook - add_library(${PROJECT_NAME} SHARED - ${NATIVE_SOURCES} - ) - message(STATUS "MinGW build: excluding CMAKE_JS_SRC (Windows delay load hook)") + # MinGW: exclude delay load hook + add_library(${PROJECT_NAME} SHARED ${NODE_SOURCES}) +elseif(CMAKE_JS_SRC) + add_library(${PROJECT_NAME} SHARED ${NODE_SOURCES} ${CMAKE_JS_SRC}) else() - # Other platforms: include CMAKE_JS_SRC if available - if(CMAKE_JS_SRC) - add_library(${PROJECT_NAME} SHARED - ${NATIVE_SOURCES} - ${CMAKE_JS_SRC} - ) - message(STATUS "Non-MinGW build: including CMAKE_JS_SRC for delay-load hook") - else() - add_library(${PROJECT_NAME} SHARED - ${NATIVE_SOURCES} - ) - endif() + add_library(${PROJECT_NAME} SHARED ${NODE_SOURCES}) endif() -# Set properties for Node.js addon -set_target_properties(${PROJECT_NAME} PROPERTIES - PREFIX "" +set_target_properties(${PROJECT_NAME} PROPERTIES + PREFIX "" SUFFIX ".node" CXX_VISIBILITY_PRESET hidden - POSITION_INDEPENDENT_CODE ON ) -# Ensure output goes to build/Release across generators and platforms -set(_OUTPUT_DIR "${CMAKE_BINARY_DIR}/Release") -set_target_properties(${PROJECT_NAME} PROPERTIES - LIBRARY_OUTPUT_DIRECTORY "${_OUTPUT_DIR}" - RUNTIME_OUTPUT_DIRECTORY "${_OUTPUT_DIR}" - ARCHIVE_OUTPUT_DIRECTORY "${_OUTPUT_DIR}" +target_compile_definitions(${PROJECT_NAME} PRIVATE + NAPI_DISABLE_CPP_EXCEPTIONS + BUILDING_NODE_EXTENSION + NAPI_VERSION=4 + NODE_GYP_MODULE_NAME=${PROJECT_NAME} ) -# Also set configuration-specific output directories for multi-config generators -foreach(cfg Debug Release RelWithDebInfo MinSizeRel) - string(TOUPPER ${cfg} CFG_UPPER) - set_target_properties(${PROJECT_NAME} PROPERTIES - LIBRARY_OUTPUT_DIRECTORY_${CFG_UPPER} "${CMAKE_BINARY_DIR}/${cfg}" - RUNTIME_OUTPUT_DIRECTORY_${CFG_UPPER} "${CMAKE_BINARY_DIR}/${cfg}" - ARCHIVE_OUTPUT_DIRECTORY_${CFG_UPPER} "${CMAKE_BINARY_DIR}/${cfg}" - ) -endforeach() - -# On Linux, ensure the module can find bundled .so beside the .node via RPATH -if(UNIX AND NOT APPLE) - # Use $ORIGIN so the loader searches the .node's directory - set_target_properties(${PROJECT_NAME} PROPERTIES - BUILD_RPATH "\$ORIGIN" - INSTALL_RPATH "\$ORIGIN" - ) -endif() - -# 设置 Node.js 和 node-addon-api 头文件路径(仅针对 C++ 目标) if(WIN32 AND CMAKE_CXX_COMPILER_ID MATCHES "GNU") - # Windows MinGW: 使用 MSYS2 的 Node.js 头文件 + 项目的 node-addon-api - target_include_directories(${PROJECT_NAME} PRIVATE + target_include_directories(${PROJECT_NAME} PRIVATE "${CMAKE_JS_INC}" "/mingw64/include/node" "/mingw64/include/node/node" - "/mingw64/include" "${NODE_ADDON_API_PATH}" ) - message(STATUS "MinGW build: using MSYS2 Node.js headers and local node-addon-api") - message(STATUS "CMAKE_JS_INC: ${CMAKE_JS_INC}") else() - # 其他平台:使用 cmake-js 提供的头文件路径 + 项目的 node-addon-api - target_include_directories(${PROJECT_NAME} PRIVATE + target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_JS_INC} "${NODE_ADDON_API_PATH}" ) - message(STATUS "Using cmake-js provided headers: ${CMAKE_JS_INC}") endif() -# Compiler-specific options -target_compile_definitions(${PROJECT_NAME} PRIVATE - NAPI_DISABLE_CPP_EXCEPTIONS - BUILDING_NODE_EXTENSION - NAPI_VERSION=4 - NODE_GYP_MODULE_NAME=${PROJECT_NAME} -) - -# Add compile flags -target_compile_options(${PROJECT_NAME} PRIVATE ${FFTW3F_CFLAGS_OTHER}) +# Link wsjtx_core (not wsjtx_lib directly) +target_link_libraries(${PROJECT_NAME} PRIVATE wsjtx_core) -# Link libraries with MinGW-specific handling +# Node.js library if(WIN32 AND CMAKE_CXX_COMPILER_ID MATCHES "GNU") - # Windows MinGW: 复制库文件到本地目录避免路径问题 - set(LOCAL_NODE_LIB "${CMAKE_BINARY_DIR}/node.lib") - - # 检查并复制 Node.js 库文件 if(EXISTS "${CMAKE_JS_LIB}") - message(STATUS "Copying Node.js library from: ${CMAKE_JS_LIB}") + set(LOCAL_NODE_LIB "${CMAKE_BINARY_DIR}/node.lib") configure_file("${CMAKE_JS_LIB}" "${LOCAL_NODE_LIB}" COPYONLY) target_link_libraries(${PROJECT_NAME} PRIVATE "${LOCAL_NODE_LIB}") - message(STATUS "MinGW build: Using copied Node.js library: ${LOCAL_NODE_LIB}") - else() - message(STATUS "MinGW build: CMAKE_JS_LIB not found, skipping Node.js library linking") - endif() -else() - # Other platforms: link CMAKE_JS_LIB if available - if(CMAKE_JS_LIB) - target_link_libraries(${PROJECT_NAME} PRIVATE ${CMAKE_JS_LIB}) - message(STATUS "Using cmake-js provided library: ${CMAKE_JS_LIB}") endif() +elseif(CMAKE_JS_LIB) + target_link_libraries(${PROJECT_NAME} PRIVATE ${CMAKE_JS_LIB}) endif() -target_link_libraries(${PROJECT_NAME} PRIVATE - wsjtx_lib - ${FFTW3F_LIBRARIES} - ${BOOST_LIBRARIES} +# Output directories +set_target_properties(${PROJECT_NAME} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${_OUTPUT_DIR}" + RUNTIME_OUTPUT_DIRECTORY "${_OUTPUT_DIR}" + ARCHIVE_OUTPUT_DIRECTORY "${_OUTPUT_DIR}" ) - -# Platform-specific linking -if(APPLE) - target_link_libraries(${PROJECT_NAME} PRIVATE - "-framework Accelerate" - gfortran - gcc_s.1 - ) - if(FFTW_HAS_THREADS) - target_link_libraries(${PROJECT_NAME} PRIVATE fftw3f_threads) - endif() -elseif(UNIX) - target_link_libraries(${PROJECT_NAME} PRIVATE - gfortran - gcc_s - pthread - ) - if(FFTW_HAS_THREADS) - target_link_libraries(${PROJECT_NAME} PRIVATE fftw3f_threads) - endif() - - # Linux specific linker flags for Node.js extensions - # Note: We don't use --no-undefined because Node.js extensions - # have symbols that are resolved at runtime by the Node.js process - target_link_options(${PROJECT_NAME} PRIVATE - -Wl,--as-needed +foreach(cfg Debug Release RelWithDebInfo MinSizeRel) + string(TOUPPER ${cfg} CFG_UPPER) + set_target_properties(${PROJECT_NAME} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY_${CFG_UPPER} "${CMAKE_BINARY_DIR}/${cfg}" + RUNTIME_OUTPUT_DIRECTORY_${CFG_UPPER} "${CMAKE_BINARY_DIR}/${cfg}" + ARCHIVE_OUTPUT_DIRECTORY_${CFG_UPPER} "${CMAKE_BINARY_DIR}/${cfg}" ) -elseif(WIN32) - # Windows with MinGW-w64 linking - target_link_libraries(${PROJECT_NAME} PRIVATE - gfortran - gcc_s - pthread +endforeach() + +# Linux RPATH for .node +if(UNIX AND NOT APPLE) + set_target_properties(${PROJECT_NAME} PROPERTIES + BUILD_RPATH "\$ORIGIN" + INSTALL_RPATH "\$ORIGIN" ) - if(FFTW_HAS_THREADS) - target_link_libraries(${PROJECT_NAME} PRIVATE fftw3f_threads) - endif() endif() diff --git a/native/wsjtx_c_api.cpp b/native/wsjtx_c_api.cpp new file mode 100644 index 0000000..cec7917 --- /dev/null +++ b/native/wsjtx_c_api.cpp @@ -0,0 +1,228 @@ +/** + * wsjtx_c_api.cpp - C API implementation for wsjtx_lib + * + * This file is compiled into the wsjtx_core shared library. + * It wraps the C++ wsjtx_lib class with pure C functions. + * All C++ exceptions are caught at this boundary. + */ + +#include "wsjtx_c_api.h" +#include +#include +#include +#include +#include + +/* Mode metadata table (mirrors wsjtx_wrapper.cpp MODE_INFO) */ +struct ModeMetadata { + int sampleRate; + double duration; + int encodingSupported; + int decodingSupported; +}; + +static const ModeMetadata MODE_TABLE[] = { + /* FT8 */ { 48000, 12.64, 1, 1 }, + /* FT4 */ { 48000, 6.0, 1, 1 }, + /* JT4 */ { 11025, 47.1, 0, 1 }, + /* JT65 */ { 11025, 46.8, 0, 1 }, + /* JT9 */ { 12000, 49.0, 0, 1 }, + /* FST4 */ { 12000, 60.0, 0, 1 }, + /* Q65 */ { 12000, 60.0, 0, 1 }, + /* FST4W */ { 12000, 120.0, 0, 1 }, + /* JT65JT9 */ { 11025, 46.8, 0, 1 }, + /* WSPR */ { 12000, 110.6, 0, 1 }, +}; + +static const int MODE_COUNT = sizeof(MODE_TABLE) / sizeof(MODE_TABLE[0]); + +static inline int valid_mode(int mode) { + return mode >= 0 && mode < MODE_COUNT; +} + +static inline wsjtx_lib* to_lib(wsjtx_handle_t h) { + return static_cast(h); +} + +/* ---- Lifecycle ---- */ + +WSJTX_API wsjtx_handle_t wsjtx_create(void) { + try { + return static_cast(new wsjtx_lib()); + } catch (...) { + return nullptr; + } +} + +WSJTX_API void wsjtx_destroy(wsjtx_handle_t handle) { + delete to_lib(handle); +} + +/* ---- Decode ---- */ + +WSJTX_API int wsjtx_decode_float(wsjtx_handle_t handle, int mode, + float* samples, int num_samples, int freq, int threads) +{ + if (!handle) return WSJTX_ERR_INVALID_HANDLE; + if (!valid_mode(mode)) return WSJTX_ERR_INVALID_MODE; + + try { + std::vector data(samples, samples + num_samples); + to_lib(handle)->decode(static_cast(mode), data, freq, threads); + return WSJTX_OK; + } catch (...) { + return WSJTX_ERR_EXCEPTION; + } +} + +WSJTX_API int wsjtx_decode_int16(wsjtx_handle_t handle, int mode, + int16_t* samples, int num_samples, int freq, int threads) +{ + if (!handle) return WSJTX_ERR_INVALID_HANDLE; + if (!valid_mode(mode)) return WSJTX_ERR_INVALID_MODE; + + try { + std::vector data(samples, samples + num_samples); + to_lib(handle)->decode(static_cast(mode), data, freq, threads); + return WSJTX_OK; + } catch (...) { + return WSJTX_ERR_EXCEPTION; + } +} + +/* ---- Encode ---- */ + +WSJTX_API int wsjtx_encode(wsjtx_handle_t handle, int mode, int freq, + const char* message, + float* out_samples, int* out_num_samples, int out_buf_size, + char* out_message_sent, int out_msg_buf_size) +{ + if (!handle) return WSJTX_ERR_INVALID_HANDLE; + if (!valid_mode(mode)) return WSJTX_ERR_INVALID_MODE; + + try { + std::string messageSent; + std::vector audio = to_lib(handle)->encode( + static_cast(mode), freq, std::string(message), messageSent); + + if (audio.empty()) return WSJTX_ERR_ENCODE_FAILED; + + int n = static_cast(audio.size()); + if (n > out_buf_size) return WSJTX_ERR_BUFFER_TOO_SMALL; + + memcpy(out_samples, audio.data(), n * sizeof(float)); + *out_num_samples = n; + + if (out_message_sent && out_msg_buf_size > 0) { + strncpy(out_message_sent, messageSent.c_str(), out_msg_buf_size - 1); + out_message_sent[out_msg_buf_size - 1] = '\0'; + } + + return WSJTX_OK; + } catch (...) { + return WSJTX_ERR_EXCEPTION; + } +} + +/* ---- Message queue ---- */ + +WSJTX_API int wsjtx_pull_message(wsjtx_handle_t handle, wsjtx_message_t* out_msg) { + if (!handle || !out_msg) return 0; + + try { + WsjtxMessage msg; + if (!to_lib(handle)->pullMessage(msg)) return 0; + + out_msg->hh = msg.hh; + out_msg->min = msg.min; + out_msg->sec = msg.sec; + out_msg->snr = msg.snr; + out_msg->freq = msg.freq; + out_msg->sync = msg.sync; + out_msg->dt = msg.dt; + + memset(out_msg->msg, 0, sizeof(out_msg->msg)); + strncpy(out_msg->msg, msg.msg.c_str(), sizeof(out_msg->msg) - 1); + + return 1; + } catch (...) { + return 0; + } +} + +/* ---- WSPR ---- */ + +WSJTX_API int wsjtx_wspr_decode(wsjtx_handle_t handle, + float* iq_interleaved, int num_iq_samples, + wsjtx_decoder_options_t* options, + wsjtx_decoder_result_t* out_results, int max_results) +{ + if (!handle) return WSJTX_ERR_INVALID_HANDLE; + + try { + /* Reconstruct complex vector from interleaved floats */ + std::vector> iqData; + iqData.reserve(num_iq_samples); + for (int i = 0; i < num_iq_samples; i++) { + iqData.emplace_back(iq_interleaved[i * 2], iq_interleaved[i * 2 + 1]); + } + + /* Convert C options to C++ decoder_options */ + decoder_options opts; + opts.freq = options->freq; + opts.quickmode = options->quickmode; + opts.usehashtable = options->usehashtable; + opts.npasses = options->npasses; + opts.subtraction = options->subtraction; + strncpy(opts.rcall, options->rcall, sizeof(opts.rcall) - 1); + opts.rcall[sizeof(opts.rcall) - 1] = '\0'; + strncpy(opts.rloc, options->rloc, sizeof(opts.rloc) - 1); + opts.rloc[sizeof(opts.rloc) - 1] = '\0'; + + std::vector results = to_lib(handle)->wspr_decode(iqData, opts); + + int count = static_cast(results.size()); + if (count > max_results) count = max_results; + + for (int i = 0; i < count; i++) { + out_results[i].freq = results[i].freq; + out_results[i].sync = results[i].sync; + out_results[i].snr = results[i].snr; + out_results[i].dt = results[i].dt; + out_results[i].drift = results[i].drift; + out_results[i].jitter = results[i].jitter; + out_results[i].cycles = results[i].cycles; + + memcpy(out_results[i].message, results[i].message, sizeof(results[i].message)); + memcpy(out_results[i].call, results[i].call, sizeof(results[i].call)); + memcpy(out_results[i].loc, results[i].loc, sizeof(results[i].loc)); + memcpy(out_results[i].pwr, results[i].pwr, sizeof(results[i].pwr)); + } + + return count; + } catch (...) { + return WSJTX_ERR_EXCEPTION; + } +} + +/* ---- Stateless queries ---- */ + +WSJTX_API int wsjtx_is_encoding_supported(int mode) { + if (!valid_mode(mode)) return 0; + return MODE_TABLE[mode].encodingSupported; +} + +WSJTX_API int wsjtx_is_decoding_supported(int mode) { + if (!valid_mode(mode)) return 0; + return MODE_TABLE[mode].decodingSupported; +} + +WSJTX_API int wsjtx_get_sample_rate(int mode) { + if (!valid_mode(mode)) return 12000; + return MODE_TABLE[mode].sampleRate; +} + +WSJTX_API double wsjtx_get_transmission_duration(int mode) { + if (!valid_mode(mode)) return 60.0; + return MODE_TABLE[mode].duration; +} diff --git a/native/wsjtx_c_api.h b/native/wsjtx_c_api.h new file mode 100644 index 0000000..d1d95e2 --- /dev/null +++ b/native/wsjtx_c_api.h @@ -0,0 +1,173 @@ +/** + * wsjtx_c_api.h - Pure C interface for wsjtx_lib + * + * This header provides a stable C ABI boundary between the wsjtx_core + * shared library (compiled with MinGW/GCC on Windows, or system compiler + * on Linux/macOS) and the Node.js N-API binding (compiled with MSVC on + * Windows, or system compiler on Linux/macOS). + * + * All types are C-compatible. No C++ headers or types are exposed. + */ + +#ifndef WSJTX_C_API_H +#define WSJTX_C_API_H + +#include +#include + +#ifdef _WIN32 + #ifdef WSJTX_CORE_EXPORTS + #define WSJTX_API __declspec(dllexport) + #else + #define WSJTX_API __declspec(dllimport) + #endif +#else + #define WSJTX_API __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* Opaque handle to the library instance */ +typedef void* wsjtx_handle_t; + +/* Error codes */ +#define WSJTX_OK 0 +#define WSJTX_ERR_INVALID_HANDLE -1 +#define WSJTX_ERR_INVALID_MODE -2 +#define WSJTX_ERR_ENCODE_FAILED -3 +#define WSJTX_ERR_BUFFER_TOO_SMALL -4 +#define WSJTX_ERR_EXCEPTION -99 + +/* Mode enumeration (must match wsjtxMode in wsjtx_lib.h) */ +typedef enum { + WSJTX_MODE_FT8 = 0, + WSJTX_MODE_FT4 = 1, + WSJTX_MODE_JT4 = 2, + WSJTX_MODE_JT65 = 3, + WSJTX_MODE_JT9 = 4, + WSJTX_MODE_FST4 = 5, + WSJTX_MODE_Q65 = 6, + WSJTX_MODE_FST4W = 7, + WSJTX_MODE_JT65JT9 = 8, + WSJTX_MODE_WSPR = 9 +} wsjtx_mode_t; + +/* Decoded message (C-compatible version of WsjtxMessage) */ +typedef struct { + int hh; + int min; + int sec; + int snr; + int freq; + float sync; + float dt; + char msg[64]; +} wsjtx_message_t; + +/* WSPR decoder options (C-compatible version of decoder_options) */ +typedef struct { + int freq; + char rcall[13]; + char rloc[7]; + int quickmode; + int usehashtable; + int npasses; + int subtraction; +} wsjtx_decoder_options_t; + +/* WSPR decoder result (C-compatible version of decoder_results) */ +typedef struct { + double freq; + float sync; + float snr; + float dt; + float drift; + int jitter; + char message[23]; + char call[13]; + char loc[7]; + char pwr[3]; + int cycles; +} wsjtx_decoder_result_t; + +/* ---- Lifecycle ---- */ + +WSJTX_API wsjtx_handle_t wsjtx_create(void); +WSJTX_API void wsjtx_destroy(wsjtx_handle_t handle); + +/* ---- Decode ---- */ + +/** + * Decode audio samples (float format). + * Results are placed in the internal message queue; use wsjtx_pull_message() to retrieve. + * Returns WSJTX_OK on success, negative error code on failure. + */ +WSJTX_API int wsjtx_decode_float(wsjtx_handle_t handle, int mode, + float* samples, int num_samples, int freq, int threads); + +/** + * Decode audio samples (int16 format). + * Results are placed in the internal message queue; use wsjtx_pull_message() to retrieve. + * Returns WSJTX_OK on success, negative error code on failure. + */ +WSJTX_API int wsjtx_decode_int16(wsjtx_handle_t handle, int mode, + int16_t* samples, int num_samples, int freq, int threads); + +/* ---- Encode ---- */ + +/** + * Encode a message into audio samples. + * + * @param out_samples Caller-allocated buffer for output audio samples + * @param out_num_samples On return, the number of samples written + * @param out_buf_size Size of out_samples buffer (in floats) + * @param out_message_sent Caller-allocated buffer for the actual message sent + * @param out_msg_buf_size Size of out_message_sent buffer (in bytes) + * + * Returns WSJTX_OK on success, WSJTX_ERR_BUFFER_TOO_SMALL if buffer is insufficient. + */ +WSJTX_API int wsjtx_encode(wsjtx_handle_t handle, int mode, int freq, + const char* message, + float* out_samples, int* out_num_samples, int out_buf_size, + char* out_message_sent, int out_msg_buf_size); + +/* ---- Message queue ---- */ + +/** + * Pull one decoded message from the queue. + * Returns 1 if a message was retrieved, 0 if the queue is empty. + */ +WSJTX_API int wsjtx_pull_message(wsjtx_handle_t handle, wsjtx_message_t* out_msg); + +/* ---- WSPR ---- */ + +/** + * Decode WSPR from IQ data. + * + * @param iq_interleaved Interleaved float array [re0, im0, re1, im1, ...] + * @param num_iq_samples Number of IQ sample pairs (array length / 2) + * @param options Decoder options + * @param out_results Caller-allocated array for results + * @param max_results Maximum number of results to write + * + * Returns the number of results decoded (>= 0), or negative error code. + */ +WSJTX_API int wsjtx_wspr_decode(wsjtx_handle_t handle, + float* iq_interleaved, int num_iq_samples, + wsjtx_decoder_options_t* options, + wsjtx_decoder_result_t* out_results, int max_results); + +/* ---- Stateless queries ---- */ + +WSJTX_API int wsjtx_is_encoding_supported(int mode); +WSJTX_API int wsjtx_is_decoding_supported(int mode); +WSJTX_API int wsjtx_get_sample_rate(int mode); +WSJTX_API double wsjtx_get_transmission_duration(int mode); + +#ifdef __cplusplus +} +#endif + +#endif /* WSJTX_C_API_H */ diff --git a/native/wsjtx_wrapper.cpp b/native/wsjtx_wrapper.cpp index 1482c5e..227b2cf 100644 --- a/native/wsjtx_wrapper.cpp +++ b/native/wsjtx_wrapper.cpp @@ -1,37 +1,15 @@ #include "wsjtx_wrapper.h" -#include -#include -#include -#include -#include -#include #include #include +#include +#include +#include namespace wsjtx_nodejs { - // Static mode information - struct ModeInfo - { - int sampleRate; - double duration; - bool encodingSupported; - bool decodingSupported; - }; - - static const std::map MODE_INFO = { - {FT8, {48000, 12.64, true, true}}, - {FT4, {48000, 6.0, true, true}}, - {JT4, {11025, 47.1, false, true}}, - {JT65, {11025, 46.8, false, true}}, - {JT9, {12000, 49.0, false, true}}, - {FST4, {12000, 60.0, false, true}}, - {Q65, {12000, 60.0, false, true}}, - {FST4W, {12000, 120.0, false, true}}, - {WSPR, {12000, 110.6, false, true}}}; - - // WSJTXLibWrapper implementation + // ---- WSJTXLibWrapper ---- + Napi::Object WSJTXLibWrapper::Init(Napi::Env env, Napi::Object exports) { Napi::Function func = DefineClass(env, "WSJTXLib", { @@ -50,63 +28,26 @@ namespace wsjtx_nodejs return exports; } - // New method: convertAudioFormat(audioData, targetFormat, callback) - Napi::Value WSJTXLibWrapper::ConvertAudioFormat(const Napi::CallbackInfo& info) + WSJTXLibWrapper::WSJTXLibWrapper(const Napi::CallbackInfo &info) + : Napi::ObjectWrap(info) { - Napi::Env env = info.Env(); - - if (info.Length() < 3) - { - Napi::TypeError::New(env, "Expected 3 arguments: audioData, targetFormat, callback").ThrowAsJavaScriptException(); - return env.Null(); - } - - if (!info[0].IsTypedArray() || !info[1].IsString() || !info[2].IsFunction()) - { - Napi::TypeError::New(env, "Invalid argument types").ThrowAsJavaScriptException(); - return env.Null(); - } - - std::string target = info[1].As().Utf8Value(); - AudioConvertWorker::Target tgt; - if (target == "float32") tgt = AudioConvertWorker::Target::Float32; - else if (target == "int16") tgt = AudioConvertWorker::Target::Int16; - else { - Napi::TypeError::New(env, "targetFormat must be 'float32' or 'int16'").ThrowAsJavaScriptException(); - return env.Null(); - } - - Napi::Function callback = info[2].As(); - - Napi::TypedArray ta = info[0].As(); - if (ta.TypedArrayType() == napi_float32_array) - { - auto input = ConvertToFloatArray(env, info[0]); - auto* worker = new AudioConvertWorker(callback, input, tgt); - worker->Queue(); - } - else if (ta.TypedArrayType() == napi_int16_array) - { - auto input = ConvertToIntArray(env, info[0]); - auto* worker = new AudioConvertWorker(callback, input, tgt); - worker->Queue(); - } - else - { - Napi::TypeError::New(env, "audioData must be Float32Array or Int16Array").ThrowAsJavaScriptException(); - return env.Null(); + handle_ = wsjtx_create(); + if (!handle_) { + Napi::Error::New(info.Env(), "Failed to create wsjtx_lib instance") + .ThrowAsJavaScriptException(); } - - return env.Undefined(); } - WSJTXLibWrapper::WSJTXLibWrapper(const Napi::CallbackInfo &info) - : Napi::ObjectWrap(info) + WSJTXLibWrapper::~WSJTXLibWrapper() { - lib_ = std::make_unique(); + if (handle_) { + wsjtx_destroy(handle_); + handle_ = nullptr; + } } - // Decode method - supports Float32Array and Int16Array audio data + // ---- Decode ---- + Napi::Value WSJTXLibWrapper::Decode(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); @@ -118,7 +59,6 @@ namespace wsjtx_nodejs return env.Null(); } - // Validate arguments if (!info[0].IsNumber() || !info[2].IsNumber() || !info[3].IsNumber() || !info[4].IsFunction()) { Napi::TypeError::New(env, "Invalid argument types").ThrowAsJavaScriptException(); @@ -130,52 +70,33 @@ namespace wsjtx_nodejs int threads = info[3].As().Int32Value(); Napi::Function callback = info[4].As(); - // Validate parameters - try - { + try { ValidateMode(env, mode); ValidateFrequency(env, frequency); ValidateThreads(env, threads); - } - catch (const std::exception &e) - { + } catch (const std::exception &e) { Napi::Error::New(env, e.what()).ThrowAsJavaScriptException(); return env.Null(); } - wsjtxMode wsjtxModeVal = ConvertToWSJTXMode(mode); - - // Check if audio data is Float32Array or Int16Array Napi::Value audioData = info[1]; - - if (audioData.IsTypedArray()) - { - Napi::TypedArray typedArray = audioData.As(); - - if (typedArray.TypedArrayType() == napi_float32_array) - { - // Float32Array - auto floatData = ConvertToFloatArray(env, audioData); - auto worker = new DecodeWorker(callback, lib_.get(), wsjtxModeVal, floatData, frequency, threads); - worker->Queue(); - } - else if (typedArray.TypedArrayType() == napi_int16_array) - { - // Int16Array - auto intData = ConvertToIntArray(env, audioData); - auto worker = new DecodeWorker(callback, lib_.get(), wsjtxModeVal, intData, frequency, threads); - worker->Queue(); - } - else - { - Napi::TypeError::New(env, "Audio data must be Float32Array or Int16Array") - .ThrowAsJavaScriptException(); - return env.Null(); - } + if (!audioData.IsTypedArray()) { + Napi::TypeError::New(env, "Audio data must be a typed array").ThrowAsJavaScriptException(); + return env.Null(); } - else - { - Napi::TypeError::New(env, "Audio data must be a typed array") + + Napi::TypedArray typedArray = audioData.As(); + + if (typedArray.TypedArrayType() == napi_float32_array) { + auto floatData = ConvertToFloatArray(env, audioData); + auto worker = new DecodeWorker(callback, handle_, mode, floatData, frequency, threads); + worker->Queue(); + } else if (typedArray.TypedArrayType() == napi_int16_array) { + auto intData = ConvertToIntArray(env, audioData); + auto worker = new DecodeWorker(callback, handle_, mode, intData, frequency, threads); + worker->Queue(); + } else { + Napi::TypeError::New(env, "Audio data must be Float32Array or Int16Array") .ThrowAsJavaScriptException(); return env.Null(); } @@ -183,7 +104,8 @@ namespace wsjtx_nodejs return env.Undefined(); } - // Encode method - generates audio waveform for transmission + // ---- Encode ---- + Napi::Value WSJTXLibWrapper::Encode(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); @@ -195,7 +117,6 @@ namespace wsjtx_nodejs return env.Null(); } - // Validate arguments if (!info[0].IsNumber() || !info[1].IsString() || !info[2].IsNumber() || !info[3].IsNumber() || !info[4].IsFunction()) { @@ -209,38 +130,30 @@ namespace wsjtx_nodejs int threads = info[3].As().Int32Value(); Napi::Function callback = info[4].As(); - // Validate parameters - try - { + try { ValidateMode(env, mode); ValidateFrequency(env, frequency); ValidateThreads(env, threads); ValidateMessage(env, message); - } - catch (const std::exception &e) - { + } catch (const std::exception &e) { Napi::Error::New(env, e.what()).ThrowAsJavaScriptException(); return env.Null(); } - wsjtxMode wsjtxModeVal = ConvertToWSJTXMode(mode); - - // Check encoding support - auto it = MODE_INFO.find(wsjtxModeVal); - if (it == MODE_INFO.end() || !it->second.encodingSupported) - { + if (!wsjtx_is_encoding_supported(mode)) { Napi::Error::New(env, "Encoding not supported for this mode") .ThrowAsJavaScriptException(); return env.Null(); } - auto worker = new EncodeWorker(callback, lib_.get(), wsjtxModeVal, message, frequency, threads); + auto worker = new EncodeWorker(callback, handle_, mode, message, frequency, threads); worker->Queue(); return env.Undefined(); } - // WSPR specific decode method with IQ data and options + // ---- WSPR Decode ---- + Napi::Value WSJTXLibWrapper::DecodeWSPR(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); @@ -252,14 +165,12 @@ namespace wsjtx_nodejs return env.Null(); } - // Validate arguments if (!info[0].IsTypedArray() || !info[1].IsObject() || !info[2].IsFunction()) { Napi::TypeError::New(env, "Invalid argument types").ThrowAsJavaScriptException(); return env.Null(); } - // Convert IQ data (interleaved I,Q samples) Napi::Float32Array iqArray = info[0].As(); size_t length = iqArray.ElementLength(); @@ -270,271 +181,239 @@ namespace wsjtx_nodejs return env.Null(); } - WsjtxIQSampleVector iqData; - iqData.reserve(length / 2); - + // Copy the interleaved IQ data directly (no complex conversion needed) float *data = iqArray.Data(); - for (size_t i = 0; i < length; i += 2) - { - iqData.emplace_back(data[i], data[i + 1]); - } + std::vector iqInterleaved(data, data + length); // Parse decoder options - Napi::Object options = info[1].As(); - decoder_options decoderOptions; + Napi::Object optObj = info[1].As(); + wsjtx_decoder_options_t options; + memset(&options, 0, sizeof(options)); - if (options.Has("dialFrequency")) - { - decoderOptions.freq = options.Get("dialFrequency").As().Int32Value(); - } + if (optObj.Has("dialFrequency")) + options.freq = optObj.Get("dialFrequency").As().Int32Value(); - if (options.Has("callsign")) - { - std::string callsign = options.Get("callsign").As().Utf8Value(); - strncpy(decoderOptions.rcall, callsign.c_str(), sizeof(decoderOptions.rcall) - 1); - decoderOptions.rcall[sizeof(decoderOptions.rcall) - 1] = '\0'; + if (optObj.Has("callsign")) { + std::string cs = optObj.Get("callsign").As().Utf8Value(); + strncpy(options.rcall, cs.c_str(), sizeof(options.rcall) - 1); } - if (options.Has("locator")) - { - std::string locator = options.Get("locator").As().Utf8Value(); - strncpy(decoderOptions.rloc, locator.c_str(), sizeof(decoderOptions.rloc) - 1); - decoderOptions.rloc[sizeof(decoderOptions.rloc) - 1] = '\0'; + if (optObj.Has("locator")) { + std::string loc = optObj.Get("locator").As().Utf8Value(); + strncpy(options.rloc, loc.c_str(), sizeof(options.rloc) - 1); } - if (options.Has("quickMode")) - { - decoderOptions.quickmode = options.Get("quickMode").As().Value() ? 1 : 0; - } + if (optObj.Has("quickMode")) + options.quickmode = optObj.Get("quickMode").As().Value() ? 1 : 0; - if (options.Has("useHashTable")) - { - decoderOptions.usehashtable = options.Get("useHashTable").As().Value() ? 1 : 0; - } + if (optObj.Has("useHashTable")) + options.usehashtable = optObj.Get("useHashTable").As().Value() ? 1 : 0; - if (options.Has("passes")) - { - decoderOptions.npasses = options.Get("passes").As().Int32Value(); - } + if (optObj.Has("passes")) + options.npasses = optObj.Get("passes").As().Int32Value(); - if (options.Has("subtraction")) - { - decoderOptions.subtraction = options.Get("subtraction").As().Value() ? 1 : 0; - } + if (optObj.Has("subtraction")) + options.subtraction = optObj.Get("subtraction").As().Value() ? 1 : 0; Napi::Function callback = info[2].As(); - auto worker = new WSPRDecodeWorker(callback, lib_.get(), iqData, decoderOptions); + auto worker = new WSPRDecodeWorker(callback, handle_, iqInterleaved, options); worker->Queue(); return env.Undefined(); } - // Pull decoded messages from the queue + // ---- Pull Messages ---- + Napi::Value WSJTXLibWrapper::PullMessages(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); Napi::Array results = Napi::Array::New(env); - WsjtxMessage msg; + wsjtx_message_t msg; uint32_t count = 0; - while (lib_->pullMessage(msg)) + while (wsjtx_pull_message(handle_, &msg) == 1) { - results[count++] = CreateWSJTXMessage(env, msg); + results[count++] = CreateMessageObject(env, msg); } return results; } - // Check if encoding is supported for a mode + // ---- Query methods ---- + Napi::Value WSJTXLibWrapper::IsEncodingSupported(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); - - if (info.Length() < 1 || !info[0].IsNumber()) - { + if (info.Length() < 1 || !info[0].IsNumber()) { Napi::TypeError::New(env, "Expected mode number").ThrowAsJavaScriptException(); return env.Null(); } - int mode = info[0].As().Int32Value(); - wsjtxMode wsjtxModeVal = ConvertToWSJTXMode(mode); - - auto it = MODE_INFO.find(wsjtxModeVal); - bool supported = (it != MODE_INFO.end()) && it->second.encodingSupported; - - return Napi::Boolean::New(env, supported); + return Napi::Boolean::New(env, wsjtx_is_encoding_supported(mode) != 0); } - // Check if decoding is supported for a mode Napi::Value WSJTXLibWrapper::IsDecodingSupported(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); - - if (info.Length() < 1 || !info[0].IsNumber()) - { + if (info.Length() < 1 || !info[0].IsNumber()) { Napi::TypeError::New(env, "Expected mode number").ThrowAsJavaScriptException(); return env.Null(); } - int mode = info[0].As().Int32Value(); - wsjtxMode wsjtxModeVal = ConvertToWSJTXMode(mode); - - auto it = MODE_INFO.find(wsjtxModeVal); - bool supported = (it != MODE_INFO.end()) && it->second.decodingSupported; - - return Napi::Boolean::New(env, supported); + return Napi::Boolean::New(env, wsjtx_is_decoding_supported(mode) != 0); } - // Get sample rate for a mode Napi::Value WSJTXLibWrapper::GetSampleRate(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); - - if (info.Length() < 1 || !info[0].IsNumber()) - { + if (info.Length() < 1 || !info[0].IsNumber()) { Napi::TypeError::New(env, "Expected mode number").ThrowAsJavaScriptException(); return env.Null(); } - int mode = info[0].As().Int32Value(); - wsjtxMode wsjtxModeVal = ConvertToWSJTXMode(mode); - - auto it = MODE_INFO.find(wsjtxModeVal); - int sampleRate = (it != MODE_INFO.end()) ? it->second.sampleRate : 12000; - - return Napi::Number::New(env, sampleRate); + return Napi::Number::New(env, wsjtx_get_sample_rate(mode)); } - // Get transmission duration for a mode Napi::Value WSJTXLibWrapper::GetTransmissionDuration(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); - - if (info.Length() < 1 || !info[0].IsNumber()) - { + if (info.Length() < 1 || !info[0].IsNumber()) { Napi::TypeError::New(env, "Expected mode number").ThrowAsJavaScriptException(); return env.Null(); } - int mode = info[0].As().Int32Value(); - wsjtxMode wsjtxModeVal = ConvertToWSJTXMode(mode); - - auto it = MODE_INFO.find(wsjtxModeVal); - double duration = (it != MODE_INFO.end()) ? it->second.duration : 60.0; - - return Napi::Number::New(env, duration); + return Napi::Number::New(env, wsjtx_get_transmission_duration(mode)); } - // Helper functions - wsjtxMode ConvertToWSJTXMode(int mode) + // ---- Audio Format Conversion ---- + + Napi::Value WSJTXLibWrapper::ConvertAudioFormat(const Napi::CallbackInfo& info) { - return static_cast(mode); + Napi::Env env = info.Env(); + + if (info.Length() < 3) { + Napi::TypeError::New(env, "Expected 3 arguments: audioData, targetFormat, callback") + .ThrowAsJavaScriptException(); + return env.Null(); + } + + if (!info[0].IsTypedArray() || !info[1].IsString() || !info[2].IsFunction()) { + Napi::TypeError::New(env, "Invalid argument types").ThrowAsJavaScriptException(); + return env.Null(); + } + + std::string target = info[1].As().Utf8Value(); + AudioConvertWorker::Target tgt; + if (target == "float32") tgt = AudioConvertWorker::Target::Float32; + else if (target == "int16") tgt = AudioConvertWorker::Target::Int16; + else { + Napi::TypeError::New(env, "targetFormat must be 'float32' or 'int16'") + .ThrowAsJavaScriptException(); + return env.Null(); + } + + Napi::Function callback = info[2].As(); + Napi::TypedArray ta = info[0].As(); + + if (ta.TypedArrayType() == napi_float32_array) { + auto input = ConvertToFloatArray(env, info[0]); + auto* worker = new AudioConvertWorker(callback, input, tgt); + worker->Queue(); + } else if (ta.TypedArrayType() == napi_int16_array) { + auto input = ConvertToIntArray(env, info[0]); + auto* worker = new AudioConvertWorker(callback, input, tgt); + worker->Queue(); + } else { + Napi::TypeError::New(env, "audioData must be Float32Array or Int16Array") + .ThrowAsJavaScriptException(); + return env.Null(); + } + + return env.Undefined(); } - void WSJTXLibWrapper::ValidateMode(Napi::Env env, int mode) - { - if (mode < 0 || mode > WSPR) - { + // ---- Helpers ---- + + void WSJTXLibWrapper::ValidateMode(Napi::Env env, int mode) { + if (mode < 0 || mode > WSJTX_MODE_WSPR) throw std::invalid_argument("Invalid mode value"); - } } - void WSJTXLibWrapper::ValidateFrequency(Napi::Env env, int frequency) - { + void WSJTXLibWrapper::ValidateFrequency(Napi::Env env, int frequency) { if (frequency < 0 || frequency > 30000000) - { // 30 MHz max throw std::invalid_argument("Invalid frequency value"); - } } - void WSJTXLibWrapper::ValidateThreads(Napi::Env env, int threads) - { + void WSJTXLibWrapper::ValidateThreads(Napi::Env env, int threads) { if (threads < 1 || threads > 16) - { throw std::invalid_argument("Thread count must be between 1 and 16"); - } } - void WSJTXLibWrapper::ValidateMessage(Napi::Env env, const std::string &message) - { + void WSJTXLibWrapper::ValidateMessage(Napi::Env env, const std::string &message) { if (message.empty() || message.length() > 22) - { throw std::invalid_argument("Message must be 1-22 characters long"); - } } - std::vector WSJTXLibWrapper::ConvertToFloatArray(Napi::Env env, const Napi::Value& value) - { + std::vector WSJTXLibWrapper::ConvertToFloatArray(Napi::Env env, const Napi::Value& value) { Napi::Float32Array array = value.As(); - size_t length = array.ElementLength(); float *data = array.Data(); - - return WsjTxVector(data, data + length); + return std::vector(data, data + array.ElementLength()); } - std::vector WSJTXLibWrapper::ConvertToIntArray(Napi::Env env, const Napi::Value& value) - { + std::vector WSJTXLibWrapper::ConvertToIntArray(Napi::Env env, const Napi::Value& value) { Napi::Int16Array array = value.As(); - size_t length = array.ElementLength(); int16_t *data = array.Data(); - - return IntWsjTxVector(data, data + length); + return std::vector(data, data + array.ElementLength()); } - Napi::Object WSJTXLibWrapper::CreateWSJTXMessage(Napi::Env env, const WsjtxMessage &msg) + Napi::Object WSJTXLibWrapper::CreateMessageObject(Napi::Env env, const wsjtx_message_t &msg) { Napi::Object result = Napi::Object::New(env); - result.Set("text", Napi::String::New(env, msg.msg)); result.Set("snr", Napi::Number::New(env, msg.snr)); result.Set("deltaTime", Napi::Number::New(env, msg.dt)); result.Set("deltaFrequency", Napi::Number::New(env, msg.freq)); - result.Set("timestamp", Napi::Number::New(env, - msg.hh * 3600 + msg.min * 60 + msg.sec)); + result.Set("timestamp", Napi::Number::New(env, msg.hh * 3600 + msg.min * 60 + msg.sec)); result.Set("sync", Napi::Number::New(env, msg.sync)); - return result; } - // Async Workers Implementation + // ---- Async Workers ---- - // Base async worker class - AsyncWorkerBase::AsyncWorkerBase(Napi::Function &callback, wsjtx_lib *lib) - : Napi::AsyncWorker(callback), lib_(lib) {} + AsyncWorkerBase::AsyncWorkerBase(Napi::Function &callback, wsjtx_handle_t handle) + : Napi::AsyncWorker(callback), handle_(handle) {} - // Decode Worker - DecodeWorker::DecodeWorker(Napi::Function &callback, wsjtx_lib *lib, - wsjtxMode mode, const std::vector &audioData, + // DecodeWorker (float) + DecodeWorker::DecodeWorker(Napi::Function &callback, wsjtx_handle_t handle, + int mode, const std::vector &audioData, int frequency, int threads) - : AsyncWorkerBase(callback, lib), mode_(mode), floatData_(audioData), + : AsyncWorkerBase(callback, handle), mode_(mode), floatData_(audioData), frequency_(frequency), threads_(threads), useFloat_(true) {} - DecodeWorker::DecodeWorker(Napi::Function &callback, wsjtx_lib *lib, - wsjtxMode mode, const std::vector &audioData, + // DecodeWorker (int16) + DecodeWorker::DecodeWorker(Napi::Function &callback, wsjtx_handle_t handle, + int mode, const std::vector &audioData, int frequency, int threads) - : AsyncWorkerBase(callback, lib), mode_(mode), intData_(audioData), + : AsyncWorkerBase(callback, handle), mode_(mode), intData_(audioData), frequency_(frequency), threads_(threads), useFloat_(false) {} void DecodeWorker::Execute() { - try - { - if (useFloat_) - { - std::vector data = floatData_; // Copy for thread safety - lib_->decode(mode_, data, frequency_, threads_); - } - else - { - std::vector data = intData_; // Copy for thread safety - lib_->decode(mode_, data, frequency_, threads_); - } - } - catch (const std::exception &e) - { - SetError(e.what()); + int rc; + if (useFloat_) { + rc = wsjtx_decode_float(handle_, mode_, + floatData_.data(), static_cast(floatData_.size()), + frequency_, threads_); + } else { + rc = wsjtx_decode_int16(handle_, mode_, + reinterpret_cast(intData_.data()), + static_cast(intData_.size()), + frequency_, threads_); + } + if (rc != WSJTX_OK) { + SetError("Decode failed with error code " + std::to_string(rc)); } } @@ -544,32 +423,39 @@ namespace wsjtx_nodejs Callback().Call({env.Null(), Napi::Boolean::New(env, true)}); } - // Encode Worker - EncodeWorker::EncodeWorker(Napi::Function &callback, wsjtx_lib *lib, - wsjtxMode mode, const std::string &message, + // EncodeWorker + EncodeWorker::EncodeWorker(Napi::Function &callback, wsjtx_handle_t handle, + int mode, const std::string &message, int frequency, int threads) - : AsyncWorkerBase(callback, lib), mode_(mode), message_(message), + : AsyncWorkerBase(callback, handle), mode_(mode), message_(message), frequency_(frequency), threads_(threads) {} void EncodeWorker::Execute() { - try - { - std::string messageSend; - audioData_ = lib_->encode(mode_, frequency_, message_, messageSend); - messageSent_ = messageSend; - } - catch (const std::exception &e) - { - SetError(e.what()); + // FT8 at 48kHz for 12.64s = ~607,000 samples; 1M buffer is plenty + static const int MAX_SAMPLES = 1024 * 1024; + audioData_.resize(MAX_SAMPLES); + int numSamples = 0; + char msgSent[256] = {0}; + + int rc = wsjtx_encode(handle_, mode_, frequency_, + message_.c_str(), + audioData_.data(), &numSamples, MAX_SAMPLES, + msgSent, sizeof(msgSent)); + + if (rc != WSJTX_OK) { + SetError("Encode failed with error code " + std::to_string(rc)); + return; } + + audioData_.resize(numSamples); + messageSent_ = std::string(msgSent); } void EncodeWorker::OnOK() { Napi::Env env = Env(); - // Create Float32Array for audio data Napi::Float32Array audioArray = Napi::Float32Array::New(env, audioData_.size()); std::copy(audioData_.begin(), audioData_.end(), audioArray.Data()); @@ -580,47 +466,52 @@ namespace wsjtx_nodejs Callback().Call({env.Null(), result}); } - // WSPR Decode Worker - WSPRDecodeWorker::WSPRDecodeWorker(Napi::Function &callback, wsjtx_lib *lib, - const std::vector> &iqData, - const decoder_options &options) - : AsyncWorkerBase(callback, lib), iqData_(iqData), options_(options) {} + // WSPRDecodeWorker + WSPRDecodeWorker::WSPRDecodeWorker(Napi::Function &callback, wsjtx_handle_t handle, + const std::vector &iqInterleaved, + const wsjtx_decoder_options_t &options) + : AsyncWorkerBase(callback, handle), iqInterleaved_(iqInterleaved), options_(options) {} void WSPRDecodeWorker::Execute() { - try - { - std::vector> data = iqData_; // Copy for thread safety - results_ = lib_->wspr_decode(data, options_); - } - catch (const std::exception &e) - { - SetError(e.what()); + static const int MAX_RESULTS = 256; + results_.resize(MAX_RESULTS); + + int numIqSamples = static_cast(iqInterleaved_.size() / 2); + int count = wsjtx_wspr_decode(handle_, + iqInterleaved_.data(), numIqSamples, + &options_, results_.data(), MAX_RESULTS); + + if (count < 0) { + SetError("WSPR decode failed with error code " + std::to_string(count)); + results_.clear(); + return; } + + results_.resize(count); } void WSPRDecodeWorker::OnOK() { Napi::Env env = Env(); - Napi::Array resultsArray = Napi::Array::New(env, results_.size()); for (size_t i = 0; i < results_.size(); i++) { - const auto &result = results_[i]; + const auto &r = results_[i]; Napi::Object obj = Napi::Object::New(env); - obj.Set("frequency", Napi::Number::New(env, result.freq)); - obj.Set("sync", Napi::Number::New(env, result.sync)); - obj.Set("snr", Napi::Number::New(env, result.snr)); - obj.Set("deltaTime", Napi::Number::New(env, result.dt)); - obj.Set("drift", Napi::Number::New(env, result.drift)); - obj.Set("jitter", Napi::Number::New(env, result.jitter)); - obj.Set("message", Napi::String::New(env, result.message)); - obj.Set("callsign", Napi::String::New(env, result.call)); - obj.Set("locator", Napi::String::New(env, result.loc)); - obj.Set("power", Napi::String::New(env, result.pwr)); - obj.Set("cycles", Napi::Number::New(env, result.cycles)); + obj.Set("frequency", Napi::Number::New(env, r.freq)); + obj.Set("sync", Napi::Number::New(env, r.sync)); + obj.Set("snr", Napi::Number::New(env, r.snr)); + obj.Set("deltaTime", Napi::Number::New(env, r.dt)); + obj.Set("drift", Napi::Number::New(env, r.drift)); + obj.Set("jitter", Napi::Number::New(env, r.jitter)); + obj.Set("message", Napi::String::New(env, r.message)); + obj.Set("callsign", Napi::String::New(env, r.call)); + obj.Set("locator", Napi::String::New(env, r.loc)); + obj.Set("power", Napi::String::New(env, r.pwr)); + obj.Set("cycles", Napi::Number::New(env, r.cycles)); resultsArray[i] = obj; } @@ -628,41 +519,27 @@ namespace wsjtx_nodejs Callback().Call({env.Null(), resultsArray}); } - // AudioConvertWorker implementation + // AudioConvertWorker void AudioConvertWorker::Execute() { - if (fromFloat_) - { - if (target_ == Target::Float32) - { - // No-op copy + if (fromFloat_) { + if (target_ == Target::Float32) { floatOut_ = floatInput_; - } - else - { + } else { intOut_.resize(floatInput_.size()); - for (size_t i = 0; i < floatInput_.size(); ++i) - { - float v = floatInput_[i]; - // Clamp then scale - if (v > 1.0f) v = 1.0f; - else if (v < -1.0f) v = -1.0f; - intOut_[i] = static_cast(std::max(-32768, std::min(32767, static_cast(std::lround(v * 32768.0f))))); + for (size_t i = 0; i < floatInput_.size(); ++i) { + float v = std::max(-1.0f, std::min(1.0f, floatInput_[i])); + intOut_[i] = static_cast( + std::max(-32768, std::min(32767, + static_cast(std::lround(v * 32768.0f))))); } } - } - else - { - if (target_ == Target::Int16) - { - // No-op copy + } else { + if (target_ == Target::Int16) { intOut_ = intInput_; - } - else - { + } else { floatOut_.resize(intInput_.size()); - for (size_t i = 0; i < intInput_.size(); ++i) - { + for (size_t i = 0; i < intInput_.size(); ++i) { floatOut_[i] = static_cast(intInput_[i]) / 32768.0f; } } @@ -672,17 +549,14 @@ namespace wsjtx_nodejs void AudioConvertWorker::OnOK() { Napi::Env env = Env(); - if (target_ == Target::Float32) - { + if (target_ == Target::Float32) { Napi::Float32Array out = Napi::Float32Array::New(env, floatOut_.size()); std::copy(floatOut_.begin(), floatOut_.end(), out.Data()); - Callback().Call({ env.Null(), out }); - } - else - { + Callback().Call({env.Null(), out}); + } else { Napi::Int16Array out = Napi::Int16Array::New(env, intOut_.size()); std::copy(intOut_.begin(), intOut_.end(), out.Data()); - Callback().Call({ env.Null(), out }); + Callback().Call({env.Null(), out}); } } diff --git a/native/wsjtx_wrapper.h b/native/wsjtx_wrapper.h index 2d0e470..4363486 100644 --- a/native/wsjtx_wrapper.h +++ b/native/wsjtx_wrapper.h @@ -1,29 +1,23 @@ #pragma once #include -#include #include #include -#include -#include +#include "wsjtx_c_api.h" namespace wsjtx_nodejs { -// Type aliases for convenience -using WsjTxVector = std::vector; -using IntWsjTxVector = std::vector; -using WsjtxIQSampleVector = std::vector>; - /** - * Native WSJTX library wrapper class + * Native WSJTX library wrapper class. + * Uses the pure C API (wsjtx_c_api.h) for all interactions with the core library. */ class WSJTXLibWrapper : public Napi::ObjectWrap { public: static Napi::Object Init(Napi::Env env, Napi::Object exports); WSJTXLibWrapper(const Napi::CallbackInfo& info); + ~WSJTXLibWrapper(); private: - // Instance methods Napi::Value Decode(const Napi::CallbackInfo& info); Napi::Value Encode(const Napi::CallbackInfo& info); Napi::Value DecodeWSPR(const Napi::CallbackInfo& info); @@ -34,36 +28,29 @@ class WSJTXLibWrapper : public Napi::ObjectWrap { Napi::Value GetTransmissionDuration(const Napi::CallbackInfo& info); Napi::Value ConvertAudioFormat(const Napi::CallbackInfo& info); - // Internal helper methods - Napi::Object CreateWSJTXMessage(Napi::Env env, const WsjtxMessage& msg); - Napi::Object CreateDecodeResult(Napi::Env env, wsjtxMode mode, const std::vector& messages); - Napi::Object CreateEncodeResult(Napi::Env env, const std::vector& audioData, int sampleRate, const std::string& actualMessage); - Napi::Object CreateWSPRResult(Napi::Env env, const decoder_results& result); - - // Validation helpers + Napi::Object CreateMessageObject(Napi::Env env, const wsjtx_message_t& msg); + void ValidateMode(Napi::Env env, int mode); void ValidateFrequency(Napi::Env env, int frequency); void ValidateThreads(Napi::Env env, int threads); void ValidateMessage(Napi::Env env, const std::string& message); - - // Audio data conversion + std::vector ConvertToFloatArray(Napi::Env env, const Napi::Value& audioData); std::vector ConvertToIntArray(Napi::Env env, const Napi::Value& audioData); - // Native library instance - std::unique_ptr lib_; + wsjtx_handle_t handle_; }; /** - * Base class for async workers + * Base class for async workers that need the library handle */ class AsyncWorkerBase : public Napi::AsyncWorker { public: - AsyncWorkerBase(Napi::Function& callback, wsjtx_lib* lib); + AsyncWorkerBase(Napi::Function& callback, wsjtx_handle_t handle); virtual ~AsyncWorkerBase() = default; protected: - wsjtx_lib* lib_; + wsjtx_handle_t handle_; }; /** @@ -71,34 +58,25 @@ class AsyncWorkerBase : public Napi::AsyncWorker { */ class DecodeWorker : public AsyncWorkerBase { public: - DecodeWorker(Napi::Function& callback, - wsjtx_lib* lib, - wsjtxMode mode, - const std::vector& audioData, - int frequency, - int threads); - - DecodeWorker(Napi::Function& callback, - wsjtx_lib* lib, - wsjtxMode mode, - const std::vector& audioData, - int frequency, - int threads); - - ~DecodeWorker() = default; + DecodeWorker(Napi::Function& callback, wsjtx_handle_t handle, + int mode, const std::vector& audioData, + int frequency, int threads); + + DecodeWorker(Napi::Function& callback, wsjtx_handle_t handle, + int mode, const std::vector& audioData, + int frequency, int threads); protected: void Execute() override; void OnOK() override; private: - wsjtxMode mode_; + int mode_; std::vector floatData_; std::vector intData_; bool useFloat_; int frequency_; int threads_; - std::vector results_; }; /** @@ -106,28 +84,21 @@ class DecodeWorker : public AsyncWorkerBase { */ class EncodeWorker : public AsyncWorkerBase { public: - EncodeWorker(Napi::Function& callback, - wsjtx_lib* lib, - wsjtxMode mode, - const std::string& message, - int frequency, - int threads); - - ~EncodeWorker() = default; + EncodeWorker(Napi::Function& callback, wsjtx_handle_t handle, + int mode, const std::string& message, + int frequency, int threads); protected: void Execute() override; void OnOK() override; private: - wsjtxMode mode_; + int mode_; std::string message_; int frequency_; int threads_; std::vector audioData_; - std::string actualMessage_; std::string messageSent_; - int sampleRate_; }; /** @@ -135,44 +106,35 @@ class EncodeWorker : public AsyncWorkerBase { */ class WSPRDecodeWorker : public AsyncWorkerBase { public: - WSPRDecodeWorker(Napi::Function& callback, - wsjtx_lib* lib, - const std::vector>& iqData, - const decoder_options& options); - - ~WSPRDecodeWorker() = default; + WSPRDecodeWorker(Napi::Function& callback, wsjtx_handle_t handle, + const std::vector& iqInterleaved, + const wsjtx_decoder_options_t& options); protected: void Execute() override; void OnOK() override; private: - std::vector> iqData_; - decoder_options options_; - std::vector results_; + std::vector iqInterleaved_; + wsjtx_decoder_options_t options_; + std::vector results_; }; /** - * Async worker for simple audio format conversion + * Async worker for audio format conversion (no library handle needed) */ class AudioConvertWorker : public Napi::AsyncWorker { public: enum class Target { Float32, Int16 }; - // From Float32Array to Int16Array AudioConvertWorker(Napi::Function& callback, - const std::vector& input, - Target target) + const std::vector& input, Target target) : Napi::AsyncWorker(callback), floatInput_(input), target_(target), fromFloat_(true) {} - // From Int16Array to Float32Array AudioConvertWorker(Napi::Function& callback, - const std::vector& input, - Target target) + const std::vector& input, Target target) : Napi::AsyncWorker(callback), intInput_(input), target_(target), fromFloat_(false) {} - ~AudioConvertWorker() = default; - protected: void Execute() override; void OnOK() override; @@ -186,14 +148,4 @@ class AudioConvertWorker : public Napi::AsyncWorker { bool fromFloat_; }; -// Module initialization functions -Napi::String GetVersion(const Napi::CallbackInfo& info); -Napi::Array GetSupportedModes(const Napi::CallbackInfo& info); - -// Utility functions -wsjtxMode ConvertToWSJTXMode(int mode); -int GetSampleRateForMode(wsjtxMode mode); -double GetTransmissionDurationForMode(wsjtxMode mode); -bool IsModeSupported(wsjtxMode mode, bool forEncoding); - } // namespace wsjtx_nodejs diff --git a/package.json b/package.json index 320b992..a712fe1 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,10 @@ ], "scripts": { "build": "npm run build:native && npm run build:ts", - "build:win": "npm run build:native:win && npm run build:ts", "build:native": "cmake-js compile", - "build:native:win": "cmake-js compile --generator=\"MinGW Makefiles\"", + "build:win": "npm run build:win:core && npm run build:win:node && npm run build:ts", + "build:win:core": "echo 'Run in MSYS2 MinGW64 shell: mkdir -p build-core && cd build-core && cmake .. -G \"MinGW Makefiles\" -DWSJTX_BUILD_CORE_ONLY=ON -DCMAKE_BUILD_TYPE=Release && mingw32-make -j$(nproc)'", + "build:win:node": "cmake-js compile --CDWSJTX_BUILD_NODE_ONLY=ON --CDWSJTX_CORE_DIR=build-core", "build:ts": "tsc", "clean": "cmake-js clean && rimraf dist", "test": "node --test dist/test/wsjtx.basic.test.js", From eb8ede2c15e6a5b5917697fbfcc2951f80fe917d Mon Sep 17 00:00:00 2001 From: boybook Date: Sat, 7 Mar 2026 12:23:43 +0800 Subject: [PATCH 2/5] fix: add missing include and fix macOS dylibbundler conflict - Linux/macOS build failed because std::invalid_argument needs (MSVC includes it transitively, GCC does not) - macOS packaging failed because libwsjtx_core.dylib was manually copied before dylibbundler tried to copy it as a dependency. Removed manual copy, added build/Release to search paths, and use -of flag for overwrite safety. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 13 ++++++------- native/wsjtx_wrapper.cpp | 1 + 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 596a5bf..3ee0a09 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -157,20 +157,19 @@ jobs: cp build/Release/wsjtx_lib_nodejs.node "$TARGET_DIR/" NODE_FILE="$TARGET_DIR/wsjtx_lib_nodejs.node" - # Copy libwsjtx_core.dylib - cp build/Release/libwsjtx_core.dylib "$TARGET_DIR/" 2>/dev/null || true - - # Bundle dylibs + # Bundle dylibs (dylibbundler copies dependencies automatically) BREW_PREFIX=$(brew --prefix 2>/dev/null || echo "/opt/homebrew") - SP_ARGS="" + SP_ARGS="-s build/Release" for p in "$BREW_PREFIX/opt/fftw/lib" "$BREW_PREFIX/opt/gcc@14/lib/gcc/14" \ "$BREW_PREFIX/opt/gcc/lib/gcc/current" "$BREW_PREFIX/Cellar/fftw"/*/lib \ "$BREW_PREFIX/Cellar/gcc"/*/lib/gcc/current; do [ -d "$p" ] && SP_ARGS="$SP_ARGS -s $p" done - dylibbundler -x "$NODE_FILE" -d "$TARGET_DIR" -p "@loader_path/" $SP_ARGS -b + # Step 1: bundle .node deps (including libwsjtx_core.dylib from build/Release) + dylibbundler -x "$NODE_FILE" -d "$TARGET_DIR" -p "@loader_path/" $SP_ARGS -b -of + # Step 2: bundle libwsjtx_core.dylib's own deps (fftw, gfortran, etc.) if [ -f "$TARGET_DIR/libwsjtx_core.dylib" ]; then - dylibbundler -x "$TARGET_DIR/libwsjtx_core.dylib" -d "$TARGET_DIR" -p "@loader_path/" $SP_ARGS -b 2>/dev/null || true + dylibbundler -x "$TARGET_DIR/libwsjtx_core.dylib" -d "$TARGET_DIR" -p "@loader_path/" $SP_ARGS -b -of fi echo '{}' | jq --arg p "${{ matrix.platform }}" --arg a "${{ matrix.arch }}" \ diff --git a/native/wsjtx_wrapper.cpp b/native/wsjtx_wrapper.cpp index 227b2cf..c93de9f 100644 --- a/native/wsjtx_wrapper.cpp +++ b/native/wsjtx_wrapper.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include From 5210a40aa4a784507925fc078d91b39a08cb0e0f Mon Sep 17 00:00:00 2001 From: boybook Date: Sat, 7 Mar 2026 12:29:33 +0800 Subject: [PATCH 3/5] fix: remove redundant dylibbundler step causing self-copy error on macOS dylibbundler already follows transitive dependencies when processing .node, so it bundles libwsjtx_core.dylib and all its deps (fftw, gfortran, etc.) in a single pass. The second dylibbundler call was redundant and failed because it tried to copy already-bundled files onto themselves. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ee0a09..75b82e7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -165,12 +165,8 @@ jobs: "$BREW_PREFIX/Cellar/gcc"/*/lib/gcc/current; do [ -d "$p" ] && SP_ARGS="$SP_ARGS -s $p" done - # Step 1: bundle .node deps (including libwsjtx_core.dylib from build/Release) + # dylibbundler follows transitive deps: .node → libwsjtx_core.dylib → fftw, gfortran, etc. dylibbundler -x "$NODE_FILE" -d "$TARGET_DIR" -p "@loader_path/" $SP_ARGS -b -of - # Step 2: bundle libwsjtx_core.dylib's own deps (fftw, gfortran, etc.) - if [ -f "$TARGET_DIR/libwsjtx_core.dylib" ]; then - dylibbundler -x "$TARGET_DIR/libwsjtx_core.dylib" -d "$TARGET_DIR" -p "@loader_path/" $SP_ARGS -b -of - fi echo '{}' | jq --arg p "${{ matrix.platform }}" --arg a "${{ matrix.arch }}" \ '{platform: $p, arch: $a, build_time: now | todate}' > "$TARGET_DIR/build-info.json" From 1a9101ee060bf2350f3fb91fb2b864d7e36517fb Mon Sep 17 00:00:00 2001 From: boybook Date: Sat, 7 Mar 2026 12:46:00 +0800 Subject: [PATCH 4/5] feat: automate npm publish and GitHub Release via CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `publish` job to build.yml: triggers only on v* tag push, downloads prebuilds from collect-artifacts, validates all 5 platforms, runs npm publish, and creates GitHub Release with platform archives - Simplify prepublishOnly to just build:ts (CI handles validation) - Remove manual scripts/create-release.sh (now integrated in CI) - Update PUBLISHING.md to reflect the new one-step workflow New release flow: npm version patch && git push --tags → fully automated. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 48 +++++++++ PUBLISHING.md | 208 ++++++++---------------------------- package.json | 2 +- scripts/create-release.sh | 164 ---------------------------- 4 files changed, 94 insertions(+), 328 deletions(-) delete mode 100755 scripts/create-release.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 75b82e7..5060ce7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -228,3 +228,51 @@ jobs: name: all-prebuilds path: final-prebuilds/ retention-days: 90 + + publish: + name: Publish + needs: collect-artifacts + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + submodules: false + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: 'https://registry.npmjs.org' + + - uses: actions/download-artifact@v4 + with: + name: all-prebuilds + path: prebuilds + + - run: npm ci --ignore-scripts + - run: npm run build:ts + + - name: Validate prebuilds + run: | + for p in linux-x64 linux-arm64 darwin-arm64 darwin-x64 win32-x64; do + test -f "prebuilds/$p/wsjtx_lib_nodejs.node" || { echo "Missing: $p"; exit 1; } + done + echo "All 5 platform prebuilds verified." + + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION=${GITHUB_REF#refs/tags/} + for dir in prebuilds/*/; do + platform=$(basename "$dir") + tar -czf "wsjtx-lib-${VERSION}-${platform}.tar.gz" -C prebuilds "$platform" + done + gh release create "$VERSION" --title "$VERSION" --generate-notes \ + wsjtx-lib-${VERSION}-*.tar.gz diff --git a/PUBLISHING.md b/PUBLISHING.md index 89414aa..9637f7d 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -1,184 +1,66 @@ # WSJTX-Lib 发布指南 -本文档说明如何以 prebuildify 风格发布带有预构建二进制的 npm 包,并由 node-gyp-build 在运行时自动查找与加载。 - -## 📦 依赖库捆绑策略 - -### 为什么需要捆绑依赖库? - -我们的 Node.js 原生模块依赖这些外部库: -- **FFTW3**: 快速傅里叶变换库 -- **Fortran运行时**: gfortran库 -- **GCC运行时**: libgcc, libstdc++等 - -这些库在不同系统上的位置和版本可能不同,为了确保用户安装后能正常使用,我们将必要的依赖库与`.node`文件一起打包。 - -### 捆绑的库文件 - -#### Windows (MinGW构建) -``` -prebuilds/windows-latest-x64/ -├── wsjtx_lib_nodejs.node # 主模块 -├── libfftw3f-3.dll # FFTW3单精度 -├── libfftw3f_threads-3.dll # FFTW3线程支持 -├── libgfortran-5.dll # Fortran运行时 -├── libgcc_s_seh-1.dll # GCC运行时 -├── libwinpthread-1.dll # 线程支持 -├── libstdc++-6.dll # C++标准库 -└── build-info.json # 构建信息 -``` - -#### Linux -``` -prebuilds/ubuntu-latest-x64/ -├── wsjtx_lib_nodejs.node # 主模块 -├── libfftw3f.so.3 # FFTW3库 -├── libgfortran.so.5 # Fortran运行时 -└── build-info.json # 构建信息 -``` - -#### macOS -``` -prebuilds/macos-latest-arm64/ -├── wsjtx_lib_nodejs.node # 主模块 -├── libfftw3f.3.dylib # FFTW3库 -├── libgfortran.5.dylib # Fortran运行时 -└── build-info.json # 构建信息 -``` - -## 🚀 发布流程 - -### 1. 准备发布 - -确保所有测试通过并且GitHub Actions构建成功: +## 发布流程 ```bash -# 检查构建状态 -git status -npm test - -# 下载GitHub Actions构建的预构建文件 -# (从Actions artifacts中下载all-prebuilds.zip并解压到项目根目录) +# 1. 确保代码在 main 分支且 CI 通过 +# 2. bump 版本(自动创建 commit + v* tag) +npm version patch # bug 修复 +npm version minor # 新功能 +npm version major # 破坏性更改 + +# 3. 推送代码和 tag +git push && git push --tags ``` -### 2. 验证预构建包 +推送 tag 后 CI 自动完成: +1. 5 平台构建 + 测试(linux-x64, linux-arm64, darwin-arm64, darwin-x64, win32-x64) +2. 汇总 prebuilds +3. 验证全部平台二进制完整 +4. `npm publish` 发布到 npm registry +5. 创建 GitHub Release 并上传各平台预构建压缩包 -运行打包验证脚本: +## 前置条件 -```bash -npm run package -``` +在 GitHub repo settings → Secrets and variables → Actions 中添加: +- `NPM_TOKEN`:npm access token(`npm token create` 生成) -这会显示类似输出: -``` -📦 Packaging prebuilt binaries for npm... - -✅ linux-x64: - • Native module: 1.06 MB - • Bundled libraries: 2 - • Total package: 3.2 MB - -✅ darwin-arm64: - • Native module: 0.96 MB - • Bundled libraries: 1 - • Total package: 2.1 MB +## 预构建包结构 -✅ windows-latest-x64: - • Native module: 1.64 MB - • Bundled libraries: 6 - • Total package: 8.7 MB - • Additional files: libfftw3f-3.dll, libfftw3f_threads-3.dll, ... - -📊 Summary: - • Valid packages: 3/3 - • Total size: 3.66 MB +``` +prebuilds/ +├── linux-x64/ +│ ├── wsjtx_lib_nodejs.node +│ ├── libwsjtx_core.so +│ ├── libfftw3f.so.3, libgfortran.so.5, ... +│ └── build-info.json +├── darwin-arm64/ +│ ├── wsjtx_lib_nodejs.node +│ ├── libwsjtx_core.dylib +│ ├── libfftw3f.3.dylib, libgfortran.5.dylib, ... +│ └── build-info.json +├── win32-x64/ +│ ├── wsjtx_lib_nodejs.node +│ ├── wsjtx_core.dll +│ ├── libfftw3f-3.dll, libgfortran-5.dll, ... +│ └── build-info.json +└── ... ``` -### 3. 版本管理 +运行时通过 `node-gyp-build` 自动加载对应平台的预构建二进制。 -更新版本号: +## 本地调试 ```bash -# 补丁版本 (bug修复) -npm version patch - -# 次要版本 (新功能) -npm version minor - -# 主要版本 (破坏性更改) -npm version major +# 查看 prebuilds 状态(不影响发布流程) +npm run package ``` -### 4. 发布到npm +## 用户安装 ```bash -# 发布 (会自动运行prepublishOnly脚本) -npm publish - -# 或者发布beta版本 -npm publish --tag beta -``` - -### 5. 创建GitHub Release - -1. 在GitHub上创建新的Release -2. 上传预构建的压缩包供直接下载 -3. 包含发布说明和更新日志 - -## 📋 用户安装体验 - -### 有预构建包的情况(默认) - -用户执行 `npm install wsjtx-lib` 后,运行时代码通过 `node-gyp-build` 在以下位置查找: - -1. `prebuilds/-/*.node` -2. 回退到 `build/Release/*.node`(本地开发场景) - -预构建二进制已随 npm 包内置,安装完成后无需编译与网络下载。 - -### 无预构建包的情况 - -如果用户的平台没有对应目录(例如非列出的 CPU/OS 组合或 musl/Alpine),运行时将报错并提示已尝试的搜索路径。 - -此时用户可选择从源码构建: - -1. 安装构建依赖(cmake、gfortran、FFTW3、Boost 等) -2. 执行 `npm run build` 生成 `build/Release/*.node` -3. 运行时会自动从 `build/Release` 回退加载 - -## 🔧 模块加载逻辑 - -运行时加载逻辑使用 `node-gyp-build`,并带有回退路径: - -```ts -const load = require('node-gyp-build'); -const pkgRoot = path.resolve(__dirname, '..', '..'); -const binding = load(pkgRoot); // 优先按 prebuildify 规范加载 -// 若失败,则回退到 prebuilds/-/ 与 build/Release 路径 +npm install wsjtx-lib +# 预构建二进制随包安装,无需编译 ``` -## 📊 包大小优化 - -虽然捆绑依赖库会增加包大小,但考虑到: - -1. **用户体验**: 安装即用,无需配置环境 -2. **兼容性**: 避免版本冲突问题 -3. **维护成本**: 减少支持请求 - -这是一个合理的权衡。 - -对于关注包大小的用户,我们提供了源码编译选项。 - -## 🚨 注意事项 - -1. **许可证兼容性**: 确保捆绑的库的许可证与项目兼容 -2. **安全更新**: 定期更新依赖库版本 -3. **平台测试**: 在目标平台上测试预构建包 -4. **版本一致性**: 确保所有平台使用相同版本的依赖库 - -## 📚 相关工具 - -- [prebuildify](https://github.com/prebuild/prebuildify): 预构建产物目录规范与工作流 -- [node-gyp-build](https://github.com/prebuild/node-gyp-build): 运行时自动加载预构建二进制 -- [cmake-js](https://github.com/cmake-js/cmake-js): 使用 CMake 构建 Node.js C++ 扩展 -- [GitHub Actions](https://github.com/features/actions): 自动化构建 +无对应预构建的平台需从源码构建(需要 cmake、gfortran、fftw3、boost)。 diff --git a/package.json b/package.json index a712fe1..8e7ca33 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "test:full": "node --test dist/test/wsjtx.test.js", "prepare": "npm run build:ts", "package": "node scripts/package-prebuilds.js", - "prepublishOnly": "npm run build:ts && npm run package" + "prepublishOnly": "npm run build:ts" }, "keywords": [ "wsjtx", diff --git a/scripts/create-release.sh b/scripts/create-release.sh deleted file mode 100755 index 3afadf2..0000000 --- a/scripts/create-release.sh +++ /dev/null @@ -1,164 +0,0 @@ -#!/bin/bash - -# WSJTX-Lib GitHub Release Creation Script -# -# 此脚本自动化创建GitHub Release并上传预构建包 -# 使用Node.js标准平台名称:linux, darwin, win32 - -set -e - -VERSION=$1 -RELEASE_NOTES=$2 - -if [ -z "$VERSION" ]; then - echo "❌ Error: Version is required" - echo "Usage: $0 [release_notes]" - echo "Example: $0 v1.0.0 \"First stable release\"" - exit 1 -fi - -if [ -z "$RELEASE_NOTES" ]; then - RELEASE_NOTES="Release $VERSION" -fi - -echo "🚀 Creating GitHub Release: $VERSION" -echo "📝 Release notes: $RELEASE_NOTES" -echo "" - -# 检查是否有预构建文件 -PREBUILDS_DIR="prebuilds" -if [ ! -d "$PREBUILDS_DIR" ]; then - echo "❌ Error: No prebuilds directory found!" - echo " Please download the GitHub Actions artifacts first." - exit 1 -fi - -# 验证必要的平台(使用Node.js标准名称) -REQUIRED_PLATFORMS=("linux-x64" "linux-arm64" "darwin-arm64" "darwin-x64" "win32-x64") -MISSING_PLATFORMS=() - -for platform in "${REQUIRED_PLATFORMS[@]}"; do - platform_dir="$PREBUILDS_DIR/$platform" - - if [ ! -d "$platform_dir" ] || [ ! -f "$platform_dir/wsjtx_lib_nodejs.node" ]; then - MISSING_PLATFORMS+=("$platform") - fi -done - -if [ ${#MISSING_PLATFORMS[@]} -gt 0 ]; then - echo "⚠️ Warning: Missing prebuilds for platforms: ${MISSING_PLATFORMS[*]}" - echo " Continue anyway? (y/N)" - read -r response - if [[ ! "$response" =~ ^[Yy]$ ]]; then - exit 1 - fi -fi - -# 创建临时目录用于打包 -TEMP_DIR=$(mktemp -d) -echo "📁 Working in temporary directory: $TEMP_DIR" - -# 打包函数 -package_platform() { - local platform=$1 - local source_dir=$2 - - echo "📦 Packaging $platform..." - - # 创建平台特定的临时目录 - local platform_temp="$TEMP_DIR/$platform" - mkdir -p "$platform_temp" - - # 复制所有文件到临时目录 - cp "$source_dir"/* "$platform_temp/" - - # 创建tar.gz包 - local package_name="wsjtx_lib_nodejs-$VERSION-$platform.tar.gz" - cd "$platform_temp" - tar -czf "$TEMP_DIR/$package_name" * - cd - > /dev/null - - echo " ✅ Created: $package_name" - echo " Size: $(du -h "$TEMP_DIR/$package_name" | cut -f1)" - echo " Files: $(tar -tzf "$TEMP_DIR/$package_name" | wc -l)" -} - -# 打包所有平台 -echo "" -echo "📦 Creating release packages..." - -# 遍历所有预构建目录(现在应该使用标准平台名称) -for dir in "$PREBUILDS_DIR"/*; do - if [ -d "$dir" ] && [ -f "$dir/wsjtx_lib_nodejs.node" ]; then - dir_name=$(basename "$dir") - package_platform "$dir_name" "$dir" - fi -done - -# 显示打包结果 -echo "" -echo "📋 Release packages created:" -ls -lh "$TEMP_DIR"/*.tar.gz 2>/dev/null | while read -r line; do - echo " $line" -done - -# 检查是否有生成的包 -if [ ! -f "$TEMP_DIR"/*.tar.gz ]; then - echo "❌ Error: No packages were created!" - exit 1 -fi - -# 检查GitHub CLI是否可用 -if ! command -v gh &> /dev/null; then - echo "" - echo "⚠️ GitHub CLI not found. Manual steps required:" - echo "1. Create tag: git tag $VERSION && git push origin $VERSION" - echo "2. Create release at: https://github.com/boybook/wsjtx_lib_nodejs/releases/new" - echo "3. Upload these files:" - ls "$TEMP_DIR"/*.tar.gz 2>/dev/null | while read -r file; do - echo " - $(basename "$file")" - done - echo "4. Files are located in: $TEMP_DIR" - echo "" - echo "Files will be kept in temp directory for manual upload." - exit 0 -fi - -# 检查是否已登录GitHub CLI -if ! gh auth status &> /dev/null; then - echo "❌ Error: Not logged in to GitHub CLI" - echo " Please run: gh auth login" - exit 1 -fi - -# 创建或检查tag -echo "" -echo "🏷️ Checking tag $VERSION..." -if git rev-parse "$VERSION" >/dev/null 2>&1; then - echo " Tag $VERSION already exists" -else - echo " Creating tag $VERSION..." - git tag "$VERSION" - git push origin "$VERSION" - echo " ✅ Tag created and pushed" -fi - -# 创建GitHub Release -echo "" -echo "🎯 Creating GitHub Release..." - -gh release create "$VERSION" \ - --title "$VERSION" \ - --notes "$RELEASE_NOTES" \ - "$TEMP_DIR"/*.tar.gz - -echo "" -echo "✅ Release $VERSION created successfully!" -echo "🔗 View at: https://github.com/boybook/wsjtx_lib_nodejs/releases/tag/$VERSION" - -# 清理临时文件 -echo "" -echo "🧹 Cleaning up temporary files..." -rm -rf "$TEMP_DIR" - -echo "✅ Done!" \ No newline at end of file From 9fc9d9219086727d677a8c1f44b96179dc07c9e9 Mon Sep 17 00:00:00 2001 From: boybook Date: Sat, 7 Mar 2026 12:57:28 +0800 Subject: [PATCH 5/5] ci: run full encode-decode cycle tests on all platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add npm run test:full to CI test steps, which runs the comprehensive test suite including FT8 encode→WAV→decode round-trip verification. This ensures the native binaries actually work end-to-end, not just load. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5060ce7..b6f21e9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -118,11 +118,11 @@ jobs: # Test - name: Run tests (Linux/macOS) if: runner.os != 'Windows' - run: npm test + run: npm test && npm run test:full - name: Run tests (Windows) if: runner.os == 'Windows' - run: npm test + run: npm test && npm run test:full # Package prebuilds - name: Package prebuilds (Linux)