diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3c6b024..07062a5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,9 @@ name: Build and Package on: + workflow_dispatch: push: - branches: [main, develop] + branches: [main, develop, feature/q65-chain] tags: ['v*'] pull_request: branches: [main] @@ -11,6 +12,72 @@ env: NODE_VERSION: '20' jobs: + q65-linux-smoke: + name: Q65 Linux smoke build + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install native build dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + cmake build-essential gfortran libfftw3-dev libboost-all-dev \ + pkg-config python3 ca-certificates + + - name: Install Node dependencies + run: npm ci --ignore-scripts + + - name: Build native addon and TypeScript + run: npm run build + + - name: Run Q65 smoke regression tests + run: npm test + + - name: Print Q65 option smoke output + run: | + node --input-type=module - <<'JS' + import { WSJTXLib, WSJTXMode } from './dist/src/index.js'; + const lib = new WSJTXLib(); + const cases = [ + { q65Period: 30, q65Submode: 'B' }, + { q65Period: 60, q65Submode: 'A' }, + { q65Period: 120, q65Submode: 'E' }, + ]; + for (const q65 of cases) { + const encoded = await lib.encode(WSJTXMode.Q65, 'CQ K1ABC FN20', 1500, { + threads: 1, + ...q65, + }); + console.log(JSON.stringify({ + mode: 'Q65', + ...q65, + sampleRate: encoded.sampleRate, + samples: encoded.audioData.length, + seconds: encoded.audioData.length / encoded.sampleRate, + messageSent: encoded.messageSent.trim(), + })); + } + JS + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: q65-linux-smoke-debug + path: | + build/ + CMakeFiles/ + CMakeCache.txt + retention-days: 7 + build: name: Build ${{ matrix.platform }}-${{ matrix.arch }} runs-on: ${{ matrix.os }} @@ -361,4 +428,4 @@ jobs: 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 + wsjtx-lib-${VERSION}-*.tar.gz \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index a5a8fcf..da44aa3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,6 +8,7 @@ cmake_minimum_required(VERSION 3.15) # ============================================================================ 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) +option(WSJTX_ENABLE_Q65_CHAIN "Apply local Q65 TX/RX overlay to wsjtx_lib" ON) # Disable vcpkg manifest mode if detected if(DEFINED CMAKE_TOOLCHAIN_FILE AND CMAKE_TOOLCHAIN_FILE MATCHES "vcpkg") @@ -255,7 +256,11 @@ include_directories( # Dependencies for the core library set(LIBRARIES_FROM_REFERENCES ${FFTW3F_LIBRARIES} ${FFTW_THREADS_LIBRARIES}) -# Build the Fortran/C++ core +# Build the Fortran/C++ core. Apply the Q65 source overlay before the +# submodule is configured so the generated wsjtx_lib target includes Q65 TX/RX. +if(WSJTX_ENABLE_Q65_CHAIN) + include("${CMAKE_SOURCE_DIR}/cmake/patch-wsjtx-q65.cmake") +endif() add_subdirectory(wsjtx_lib) link_directories(${FFTW3F_LIBRARY_DIRS}) diff --git a/README.md b/README.md index d29d19a..bb926ea 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ A high-performance Node.js C++ extension for digital amateur radio protocols, pr | JT65 | ❌ | ✅ | 11.025 kHz | 46.8s | ~180 Hz | | JT9 | ❌ | ✅ | 12 kHz | 49.0s | ~16 Hz | | FST4 | ❌ | ✅ | 12 kHz | 60.0s | Variable | -| Q65 | ❌ | ✅ | 12 kHz | 60.0s | Variable | +| Q65 | ✅ | ✅ | 12 kHz | 30/60/120/300s | Variable | | FST4W| ❌ | ✅ | 12 kHz | 120.0s | Variable | | WSPR | ❌ | ✅ | 12 kHz | 110.6s | ~6 Hz | @@ -140,19 +140,16 @@ async function example() { console.log(`Generated ${encodeResult.audioData.length} audio samples`); console.log(`Message sent: "${encodeResult.messageSent}"`); - // Decode audio data (example with proper resampling for FT8) + // Decode audio data const audioData = new Float32Array(48000 * 13); // 13 seconds at 48kHz // ... fill audioData with actual audio samples ... - const decodeResult = await lib.decode( - WSJTXMode.FT8, - audioData, - 1000 // Same audio frequency used for encoding - ); + const decodeResult = await lib.decode(WSJTXMode.FT8, audioData, { + frequency: 1000, + threads: 4 + }); - // Get decoded messages - const messages = lib.pullMessages(); - messages.forEach(msg => { + decodeResult.messages.forEach(msg => { console.log(`Decoded: "${msg.text}" (SNR: ${msg.snr} dB, ΔT: ${msg.deltaTime}s)`); }); } @@ -173,33 +170,52 @@ Creates a new WSJTX library instance. **Parameters:** - `config` (optional): Configuration options - `maxThreads`: Maximum number of threads (1-16, default: 4) + - `encodeSampleRate`: Process-global FT8/FT4/Q65 encode output sample rate (`12000` or `48000`, default: `12000`) - `debug`: Enable debug logging (default: false) #### Methods -##### `decode(mode, audioData, frequency, threads?): Promise` +##### `decode(mode, audioData, options): Promise` Decode digital radio signals from audio data. **Parameters:** - `mode`: WSJTXMode enum value - `audioData`: Float32Array or Int16Array of audio samples -- `frequency`: Audio frequency in Hz (typically 500-3000 Hz) -- `threads`: Number of threads to use (optional, default: 4) - -**Returns:** Promise resolving to DecodeResult with success status - -**Note:** For optimal FT8 decoding, audio may need resampling. See examples for details. - -##### `encode(mode, message, frequency, threads?): Promise` +- `options`: DecodeOptions object + - `frequency`: Audio frequency in Hz (typically 500-3000 Hz) + - `txFrequency`: Transmit audio frequency in Hz (optional, defaults to `frequency`) + - `threads`: Number of threads to use (optional, default: 4) + - `lowFreq`: Lower decode frequency limit in Hz (optional, default: 200) + - `highFreq`: Upper decode frequency limit in Hz (optional, default: 4000) + - `tolerance`: Frequency tolerance in Hz (optional, default: 20) + - `myCall`, `myGrid`, `dxCall`, `dxGrid`: Optional AP decode context + - `apDecode`: Enable AP decode passes (optional, default: true) + - `decodeDepth`: WSJT-X decoder depth (optional, default: 1) + - `qsoProgress`: WSJT-X QSO progress stage (optional, default: 0) + - `q65Period`: Q65 period in seconds: `30`, `60`, `120`, or `300` (optional, default: `60`) + - `q65Submode`: Q65 submode: `'A'`, `'B'`, `'C'`, `'D'`, `'E'`, or `0`-`4` (optional, default: `'A'`) + - `q65MaxDrift`: Q65 max drift control (optional, default: `50`) + - `q65ClearAveraging`: Clear Q65 averaging state before decode (optional, default: false) + - `q65SingleDecode`: Request Q65 single-candidate decode behavior (optional, default: false) + - `q65Averaging`: Enable Q65 averaged decode passes (optional, default: false) + +**Returns:** Promise resolving to DecodeResult with success status and decoded messages + +**Note:** Use `lib.getSampleRate(mode)` to determine the expected sample rate for a mode. Q65 uses 12 kHz audio by default. + +##### `encode(mode, message, frequency, threadsOrOptions?): Promise` Encode a message into audio waveform for transmission. **Parameters:** - `mode`: WSJTXMode enum value -- `message`: Message text to encode (FT8/FT4 structured messages: 1-37 characters; free text payloads are limited by WSJT-X to 13 characters) +- `message`: Message text to encode (FT8/FT4/Q65 structured messages: 1-37 characters; free text payloads are limited by WSJT-X to 13 characters) - `frequency`: Audio frequency in Hz (typically 500-3000 Hz) -- `threads`: Number of threads to use (optional, default: 4) +- `threadsOrOptions`: Either a thread count number or an EncodeOptions object (optional, default: 4) + - `threads`: Number of threads to use + - `q65Period`: Q65 period in seconds: `30`, `60`, `120`, or `300` (optional, default: `60`) + - `q65Submode`: Q65 submode: `'A'`, `'B'`, `'C'`, `'D'`, `'E'`, or `0`-`4` (optional, default: `'A'`) **Returns:** Promise resolving to EncodeResult with audio data and actual message sent @@ -252,7 +268,8 @@ enum WSJTXMode { FST4 = 5, Q65 = 6, FST4W = 7, - WSPR = 8 + JT65JT9 = 8, + WSPR = 9 } ``` @@ -264,6 +281,8 @@ interface WSJTXMessage { snr: number; // Signal-to-noise ratio in dB deltaTime: number; // Time offset in seconds deltaFrequency: number; // Frequency offset in Hz + timestamp: number; // seconds-of-day reported by the decoder + sync: number; // Sync quality } ``` @@ -271,11 +290,21 @@ interface WSJTXMessage { ```typescript interface EncodeResult { - audioData: Float32Array; // Generated audio waveform (48kHz sample rate) + audioData: Float32Array; // Generated audio waveform messageSent: string; // Actual message encoded + sampleRate: number; // Output sample rate } ``` +#### Q65 Options + +```typescript +type Q65Period = 30 | 60 | 120 | 300; +type Q65Submode = 'A' | 'B' | 'C' | 'D' | 'E' | 0 | 1 | 2 | 3 | 4; +``` + +Q65 options are accepted by both `encode()` and `decode()` so the TX/RX chain can be configured symmetrically. + #### WSPRResult ```typescript @@ -329,30 +358,73 @@ async function ft8Example() { writer.end(); // 3. Read back and decode - // Note: For optimal decode, you may need resampling - const resampled = resampleTo12kHz(encodeResult.audioData); - const audioForDecode = new Int16Array(resampled.length); - for (let i = 0; i < resampled.length; i++) { - audioForDecode[i] = Math.round(resampled[i] * 32767); - } - - lib.pullMessages(); // Clear queue - const decodeResult = await lib.decode(WSJTXMode.FT8, audioForDecode, audioFrequency); + const decodeResult = await lib.decode(WSJTXMode.FT8, encodeResult.audioData, { + frequency: audioFrequency, + threads: 1 + }); - const messages = lib.pullMessages(); - console.log(`Decoded ${messages.length} messages`); + console.log(`Decoded ${decodeResult.messages.length} messages`); } +``` -// Helper function for resampling (48kHz -> 12kHz) -function resampleTo12kHz(audioData48k: Float32Array): Float32Array { - const audioData12k = new Float32Array(Math.floor(audioData48k.length / 4)); - for (let i = 0; i < audioData12k.length; i++) { - audioData12k[i] = audioData48k[i * 4]; - } - return audioData12k; +### Q65 Encode-Decode Cycle + +```typescript +import { WSJTXLib, WSJTXMode } from 'wsjtx-lib'; + +async function q65Example() { + const lib = new WSJTXLib({ maxThreads: 4 }); + const message = 'CQ K1ABC FN20'; + const audioFrequency = 1500; + + // Encode a Q65-30A frame at 12 kHz. + const encoded = await lib.encode(WSJTXMode.Q65, message, audioFrequency, { + threads: 1, + q65Period: 30, + q65Submode: 'A' + }); + + console.log(`Encoded: "${encoded.messageSent.trim()}"`); + console.log(`Samples: ${encoded.audioData.length}`); + console.log(`Sample rate: ${encoded.sampleRate} Hz`); + + // Decode with matching Q65 period/submode and a wide enough search window. + const decoded = await lib.decode(WSJTXMode.Q65, encoded.audioData, { + frequency: audioFrequency, + txFrequency: audioFrequency, + threads: 1, + lowFreq: 0, + highFreq: 5000, + tolerance: 5000, + q65Period: 30, + q65Submode: 'A', + q65MaxDrift: 50, + q65ClearAveraging: true + }); + + decoded.messages.forEach((msg) => { + console.log(`Decoded: "${msg.text.trim()}" SNR=${msg.snr} dB DT=${msg.deltaTime}s Freq=${msg.deltaFrequency} Hz`); + }); } ``` +For other Q65 variants, use the same API and change the period/submode pair: + +```typescript +await lib.encode(WSJTXMode.Q65, 'CQ K1ABC FN20', 1500, { + q65Period: 120, + q65Submode: 'E' +}); + +await lib.decode(WSJTXMode.Q65, audioData, { + frequency: 1500, + q65Period: 120, + q65Submode: 'E', + q65MaxDrift: 100, + q65Averaging: true +}); +``` + ### WSPR Decoding ```typescript @@ -429,7 +501,9 @@ The library throws `WSJTXError` for all operation failures: import { WSJTXError } from 'wsjtx-lib'; try { - await lib.decode(WSJTXMode.FT8, audioData, 1000); + await lib.decode(WSJTXMode.FT8, audioData, { + frequency: 1000 + }); } catch (error) { if (error instanceof WSJTXError) { console.error(`WSJTX Error [${error.code}]: ${error.message}`); @@ -453,11 +527,13 @@ try { 2. **Sample Rates**: Different modes require different sample rates. Use `lib.getSampleRate(mode)` to get the correct rate. -3. **Audio Resampling**: For optimal FT8 decoding, audio may need to be resampled from 48kHz to 12kHz. See examples for implementation. +3. **Q65 Parameters**: Q65 TX and RX must use the same period/submode pair. Supported periods are `30`, `60`, `120`, and `300`; supported submodes are `A` through `E`. + +4. **Audio Resampling**: Input audio should match the sample rate expected by the selected mode. Q65 expects 12 kHz audio. -4. **Thread Safety**: Each WSJTXLib instance should be used from a single thread. Create separate instances for concurrent operations. +5. **Thread Safety**: Each WSJTXLib instance should be used from a single thread. Create separate instances for concurrent operations. -5. **Message Queue**: The `pullMessages()` method clears the internal message queue. Call it regularly to avoid memory buildup. +6. **Message Queue**: `decode()` returns decoded messages directly. `pullMessages()` is also available for compatibility with the internal message queue. ## Building from Source (Advanced) diff --git a/cmake/patch-wsjtx-q65.cmake b/cmake/patch-wsjtx-q65.cmake new file mode 100644 index 0000000..f445d1e --- /dev/null +++ b/cmake/patch-wsjtx-q65.cmake @@ -0,0 +1,190 @@ +# Idempotent source overlay for Q65 TX/RX support. +# The parent package consumes boybook/wsjtx_lib as a submodule, so these +# targeted replacements keep this binding self-contained without requiring a +# forked submodule URL. + +function(wsjtx_replace_once file needle replacement description) + file(READ "${file}" _content) + string(FIND "${_content}" "${replacement}" _already) + if(_already GREATER_EQUAL 0) + message(STATUS "Q65 patch already applied: ${description}") + return() + endif() + string(FIND "${_content}" "${needle}" _found) + if(_found LESS 0) + message(FATAL_ERROR "Q65 patch failed: ${description}; needle not found in ${file}") + endif() + string(REPLACE "${needle}" "${replacement}" _content "${_content}") + file(WRITE "${file}" "${_content}") + message(STATUS "Applied Q65 patch: ${description}") +endfunction() + +set(_WSJTX_LIB_DIR "${CMAKE_SOURCE_DIR}/wsjtx_lib") + +# ---- wsjtx_encode.h -------------------------------------------------------- +set(_encode_h "${_WSJTX_LIB_DIR}/wsjtx_encode.h") +wsjtx_replace_once( + "${_encode_h}" + "\t std::vector encode_ft4(wsjtxMode mode, int frequency, std::string message, std::string &msgsent, int sampleRate);\n\t std::vector encode_wspr(wsjtxMode mode, int frequency, std::string message, std::string &msgsent);" + "\t std::vector encode_ft4(wsjtxMode mode, int frequency, std::string message, std::string &msgsent, int sampleRate);\n\t std::vector encode_q65(wsjtxMode mode, int frequency, std::string message, std::string &msgsent, int sampleRate, int q65Period, int q65Submode);\n\t std::vector encode_wspr(wsjtxMode mode, int frequency, std::string message, std::string &msgsent);" + "declare parameterized wsjtx_encode::encode_q65") + +# ---- wsjtx_encode.cpp ------------------------------------------------------ +set(_encode_cpp "${_WSJTX_LIB_DIR}/wsjtx_encode.cpp") +set(_q65_impl [=[ +std::vector wsjtx_encode::encode_q65(wsjtxMode mode, int frequency, std::string message, std::string &msgsent, int sampleRate, int q65Period, int q65Submode) +{ + std::vector signal; + + int ichk = 0; + int i3 = -1; + int n3 = -1; + + std::memset(msg, 0, 38); + std::memset(sendmsg, 0, 38); + std::memset(itone, 0, sizeof(itone)); + std::copy_n(message.c_str(), std::min(message.size(), 37), msg); + + genq65_(msg, &ichk, sendmsg, const_cast(itone), &i3, &n3, 37, 37); + sendmsg[37] = '\0'; + msgsent = std::string(sendmsg); + + int nsym = 85; + int ntrperiod = q65Period; + if (ntrperiod != 30 && ntrperiod != 60 && ntrperiod != 120 && ntrperiod != 300) ntrperiod = 60; + int nsubmode = q65Submode; + if (nsubmode < 0 || nsubmode > 4) nsubmode = 0; + int hmod = 1 << nsubmode; + + int baseNsps = 7200; + if (ntrperiod == 30) baseNsps = 3600; + else if (ntrperiod == 60) baseNsps = 7200; + else if (ntrperiod == 120) baseNsps = 16000; + else if (ntrperiod == 300) baseNsps = 41472; + + int nsps = (sampleRate / 12000) * baseNsps; + if (nsps <= 0) nsps = baseNsps; + float fsample = static_cast(sampleRate); + float f0 = static_cast(frequency); + int icmplx = 0; + int nwave = nsym * nsps; + + signal.assign(static_cast(ntrperiod) * static_cast(sampleRate), 0.0f); + std::vector cwave(static_cast(nwave) * 2U, 0.0f); + genwave_(const_cast(itone), &nsym, &nsps, &nwave, + &fsample, &hmod, &f0, &icmplx, cwave.data(), signal.data()); + return signal; +} + +]=]) +wsjtx_replace_once( + "${_encode_cpp}" + "std::vector wsjtx_encode::encode_wspr(wsjtxMode mode, int frequency, std::string message, std::string &msgsent)" + "${_q65_impl}std::vector wsjtx_encode::encode_wspr(wsjtxMode mode, int frequency, std::string message, std::string &msgsent)" + "implement parameterized wsjtx_encode::encode_q65") + +# ---- wsjtx_lib.h ----------------------------------------------------------- +set(_lib_h "${_WSJTX_LIB_DIR}/wsjtx_lib.h") +wsjtx_replace_once( + "${_lib_h}" + "\tstd::vector encode(wsjtxMode mode, int frequency, std::string message, std::string &messagesend, int sampleRate);" + "\tstd::vector encode(wsjtxMode mode, int frequency, std::string message, std::string &messagesend, int sampleRate, int q65Period = 60, int q65Submode = 0);" + "extend wsjtx_lib::encode signature for Q65 options") +wsjtx_replace_once( + "${_lib_h}" + "\tvoid setDecodeControls(bool apDecode, int decodeDepth, int txFrequency, int qsoProgress);" + "\tvoid setDecodeControls(bool apDecode, int decodeDepth, int txFrequency, int qsoProgress);\n\tvoid setDecodeQ65Controls(int period, int submode, int maxDrift, bool clearAveraging, bool singleDecode, bool averaging);" + "declare wsjtx_lib Q65 decode controls") +wsjtx_replace_once( + "${_lib_h}" + "\tint qso_progress_ = 0;\n\tDataQueue messageQueue_;" + "\tint qso_progress_ = 0;\n\tint q65_period_ = 60;\n\tint q65_submode_ = 0;\n\tint q65_max_drift_ = 50;\n\tbool q65_clear_averaging_ = false;\n\tbool q65_single_decode_ = false;\n\tbool q65_averaging_ = false;\n\tDataQueue messageQueue_;" + "add wsjtx_lib Q65 decode state") + +# ---- wsjtx_lib.cpp --------------------------------------------------------- +set(_lib_cpp "${_WSJTX_LIB_DIR}/wsjtx_lib.cpp") +wsjtx_replace_once( + "${_lib_cpp}" + "void wsjtx_lib::setDecodeControls(bool apDecode, int decodeDepth, int txFrequency, int qsoProgress)\n{\n\tap_decode_ = apDecode;\n\tdecode_depth_ = decodeDepth < 1 ? 1 : decodeDepth;\n\ttx_frequency_ = txFrequency;\n\tqso_progress_ = qsoProgress < 0 ? 0 : qsoProgress;\n}" + "void wsjtx_lib::setDecodeControls(bool apDecode, int decodeDepth, int txFrequency, int qsoProgress)\n{\n\tap_decode_ = apDecode;\n\tdecode_depth_ = decodeDepth < 1 ? 1 : decodeDepth;\n\ttx_frequency_ = txFrequency;\n\tqso_progress_ = qsoProgress < 0 ? 0 : qsoProgress;\n}\n\nvoid wsjtx_lib::setDecodeQ65Controls(int period, int submode, int maxDrift, bool clearAveraging, bool singleDecode, bool averaging)\n{\n\tq65_period_ = (period == 30 || period == 60 || period == 120 || period == 300) ? period : 60;\n\tq65_submode_ = (submode >= 0 && submode <= 4) ? submode : 0;\n\tq65_max_drift_ = maxDrift < 0 ? 50 : maxDrift;\n\tq65_clear_averaging_ = clearAveraging;\n\tq65_single_decode_ = singleDecode;\n\tq65_averaging_ = averaging;\n}" + "implement wsjtx_lib Q65 controls") +wsjtx_replace_once( + "${_lib_cpp}" + "\tptr->setDecodeControls(ap_decode_, decode_depth_, tx_frequency_, qso_progress_);" + "\tptr->setDecodeControls(ap_decode_, decode_depth_, tx_frequency_, qso_progress_);\n\tptr->setDecodeQ65Controls(q65_period_, q65_submode_, q65_max_drift_, q65_clear_averaging_, q65_single_decode_, q65_averaging_);" + "forward Q65 decode controls") +wsjtx_replace_once( + "${_lib_cpp}" + "std::vector wsjtx_lib::encode(wsjtxMode mode, int frequency, std::string message, std::string &messagesend, int sampleRate)" + "std::vector wsjtx_lib::encode(wsjtxMode mode, int frequency, std::string message, std::string &messagesend, int sampleRate, int q65Period, int q65Submode)" + "extend wsjtx_lib::encode implementation signature") +wsjtx_replace_once( + "${_lib_cpp}" + "\tcase FT4: {\n\t\tauto ptr = std::make_unique();\n\t\treturn ptr->encode_ft4(mode, frequency, message, messagesend, sampleRate);\n\t}\n\tdefault: return {};" + "\tcase FT4: {\n\t\tauto ptr = std::make_unique();\n\t\treturn ptr->encode_ft4(mode, frequency, message, messagesend, sampleRate);\n\t}\n\tcase Q65: {\n\t\tauto ptr = std::make_unique();\n\t\treturn ptr->encode_q65(mode, frequency, message, messagesend, sampleRate, q65Period, q65Submode);\n\t}\n\tdefault: return {};" + "route Q65 encode in wsjtx_lib") + +# ---- wsjtx_decode.h -------------------------------------------------------- +set(_decode_h "${_WSJTX_LIB_DIR}/wsjtx_decode.h") +wsjtx_replace_once( + "${_decode_h}" + "\tvoid setDecodeControls(bool apDecode, int decodeDepth, int txFrequency, int qsoProgress);" + "\tvoid setDecodeControls(bool apDecode, int decodeDepth, int txFrequency, int qsoProgress);\n\tvoid setDecodeQ65Controls(int period, int submode, int maxDrift, bool clearAveraging, bool singleDecode, bool averaging);" + "declare wstjx_decode Q65 controls") +wsjtx_replace_once( + "${_decode_h}" + "\tint qso_progress_ = 0;\n\tstd::string my_call_, my_grid_;" + "\tint qso_progress_ = 0;\n\tint q65_period_ = 60;\n\tint q65_submode_ = 0;\n\tint q65_max_drift_ = 50;\n\tbool q65_clear_averaging_ = false;\n\tbool q65_single_decode_ = false;\n\tbool q65_averaging_ = false;\n\tstd::string my_call_, my_grid_;" + "add wstjx_decode Q65 state") + +# ---- wsjtx_decode.cpp ------------------------------------------------------ +set(_decode_cpp "${_WSJTX_LIB_DIR}/wsjtx_decode.cpp") +wsjtx_replace_once( + "${_decode_cpp}" + "#include \n#include " + "#include \n#include \n#include " + "include for Q65 frame sizing") +wsjtx_replace_once( + "${_decode_cpp}" + "void wstjx_decode::setDecodeControls(bool apDecode, int decodeDepth, int txFrequency, int qsoProgress) {\n\tap_decode_ = apDecode;\n\tdecode_depth_ = decodeDepth < 1 ? 1 : decodeDepth;\n\ttx_frequency_ = txFrequency;\n\tqso_progress_ = qsoProgress < 0 ? 0 : qsoProgress;\n}" + "void wstjx_decode::setDecodeControls(bool apDecode, int decodeDepth, int txFrequency, int qsoProgress) {\n\tap_decode_ = apDecode;\n\tdecode_depth_ = decodeDepth < 1 ? 1 : decodeDepth;\n\ttx_frequency_ = txFrequency;\n\tqso_progress_ = qsoProgress < 0 ? 0 : qsoProgress;\n}\nvoid wstjx_decode::setDecodeQ65Controls(int period, int submode, int maxDrift, bool clearAveraging, bool singleDecode, bool averaging) {\n\tq65_period_ = (period == 30 || period == 60 || period == 120 || period == 300) ? period : 60;\n\tq65_submode_ = (submode >= 0 && submode <= 4) ? submode : 0;\n\tq65_max_drift_ = maxDrift < 0 ? 50 : maxDrift;\n\tq65_clear_averaging_ = clearAveraging;\n\tq65_single_decode_ = singleDecode;\n\tq65_averaging_ = averaging;\n}" + "implement wstjx_decode Q65 controls") +wsjtx_replace_once( + "${_decode_cpp}" + "\tsamplebuffer.push(std::move(audiosamples));" + "\tsamplebuffer.push(WsjTxVector(audiosamples));" + "preserve Float32 decode samples before decoder copy") +wsjtx_replace_once( + "${_decode_cpp}" + "\tfor (size_t i = 0; i < audiosamples.size(); i++)\n\t\tdec_data.d2[i] = (short int)(audiosamples[i] * 32768.0f);" + "\tfor (size_t i = 0; i < audiosamples.size(); i++) {\n\t\tfloat sample = std::clamp(audiosamples[i], -1.0f, 0.9999695f);\n\t\tdec_data.d2[i] = static_cast(sample * 32768.0f);\n\t}" + "clamp Float32 samples before Int16 decoder conversion") +set(_q65_switch [=[ + case FT8: params.nmode = 8; break; + case FT4: params.nmode = 5; break; + case Q65: + params.nmode = 66; + params.ntrperiod = q65_period_; + params.kin = std::min(static_cast(audiosamples.size()), q65_period_ * 12000); + params.nzhsym = 85; + params.nsubmode = q65_submode_; + params.ntxmode = 66; + params.max_drift = q65_max_drift_; + params.nclearave = q65_clear_averaging_; + if (q65_single_decode_) params.nexp_decode |= 32; + if (q65_averaging_) params.ndepth |= 16; + break; + default: return;]=]) +wsjtx_replace_once( + "${_decode_cpp}" + "\tcase FT8: params.nmode = 8; break;\n\tcase FT4: params.nmode = 5; break;\n\tdefault: return;" + "${_q65_switch}" + "select parameterized Q65 decoder mode") + +# ---- lib/decode_callbacks.f90 --------------------------------------------- +set(_callbacks_f90 "${_WSJTX_LIB_DIR}/lib/decode_callbacks.f90") +wsjtx_replace_once( + "${_callbacks_f90}" + " endif\n call flush(6)\n\n select type(ctx => this)\n type is (counting_q65_decoder)" + " endif\n call wsjtx_decoded(nutc,nsnr,dt,nint(freq),decoded)\n call flush(6)\n\n select type(ctx => this)\n type is (counting_q65_decoder)" + "forward Q65 decode callback into C queue") diff --git a/docs/q65-chain.md b/docs/q65-chain.md new file mode 100644 index 0000000..9fb91e8 --- /dev/null +++ b/docs/q65-chain.md @@ -0,0 +1,95 @@ +# Q65 transmit/receive chain + +This branch exposes Q65 through the existing `WSJTXLib.encode()` and `WSJTXLib.decode()` APIs with mode-specific options, while preserving the legacy FT8/FT4-style call shape. + +## Implemented scope + +- Q65 is reported as both encode-capable and decode-capable. +- Transmit uses the WSJT-X `genq65_()` encoder and `genwave_()` waveform generator. +- Receive routes audio through WSJT-X multimode decoder mode `66`. +- Q65 decode callbacks are forwarded into the Node-visible decoded-message queue. +- Q65 period and submode are public encode and decode options. +- Q65 drift and averaging controls are public decode options. + +## Public API + +Legacy thread-count argument remains supported: + +```ts +await lib.encode(WSJTXMode.Q65, 'CQ K1ABC FN20', 1500, 1); +``` + +Object options can be used for Q65-specific transmit settings: + +```ts +await lib.encode(WSJTXMode.Q65, 'CQ K1ABC FN20', 1500, { + threads: 1, + q65Period: 30, + q65Submode: 'B', +}); +``` + +The same period/submode options can be used on receive: + +```ts +await lib.decode(WSJTXMode.Q65, audio, { + frequency: 1500, + threads: 1, + q65Period: 30, + q65Submode: 'B', + q65MaxDrift: 50, + q65ClearAveraging: true, + q65SingleDecode: true, + q65Averaging: true, +}); +``` + +## Q65 transmit parameters + +| Option | Accepted values | Default | +|---|---|---:| +| `q65Period` | `30`, `60`, `120`, `300` | `60` | +| `q65Submode` | `'A'..'E'` or `0..4` | `'A'` / `0` | +| `threads` | `1..16` | `WSJTXConfig.maxThreads` | + +The `frequency` argument is the audio offset in Hz, not the RF dial frequency. + +The encoder emits a complete frame with length `q65Period * sampleRate` samples. At the default 12 kHz sample rate this is 360000, 720000, 1440000, or 3600000 samples for Q65-30/60/120/300 respectively. + +## Q65 receive parameters + +| Option | Accepted values | Default | +|---|---|---:| +| `q65Period` | `30`, `60`, `120`, `300` | `60` | +| `q65Submode` | `'A'..'E'` or `0..4` | `'A'` / `0` | +| `q65MaxDrift` | non-negative integer | `50` | +| `q65ClearAveraging` | boolean | `false` | +| `q65SingleDecode` | boolean | `false` | +| `q65Averaging` | boolean | `false` | + +Existing decode options continue to map onto the WSJT-X decoder: `frequency`, `txFrequency`, `lowFreq`, `highFreq`, `tolerance`, station/grid context, AP decode toggle, decode depth, and QSO progress. + +## Native mapping + +The Q65 receive path sets or derives: + +- `nmode = 66` +- `ntrperiod = q65Period` +- `nsubmode = q65Submode` +- `ntxmode = 66` +- `nzhsym = 85` +- `max_drift = q65MaxDrift` +- `nclearave = q65ClearAveraging` +- `nexp_decode |= 32` when `q65SingleDecode` is true +- `ndepth |= 16` when `q65Averaging` is true + +## Regression coverage + +The smoke test suite covers Q65 capability reporting, enum stability, legacy thread-count encode calls, object encode options for period/submode, validation of invalid Q65 options, and decode option plumbing for drift and averaging controls. + +Run: + +```bash +npm run build +npm test +``` diff --git a/native/wsjtx_c_api.cpp b/native/wsjtx_c_api.cpp index 08951af..a0bf1cd 100644 --- a/native/wsjtx_c_api.cpp +++ b/native/wsjtx_c_api.cpp @@ -13,7 +13,6 @@ #include #include -/* Mode metadata table (mirrors wsjtx_wrapper.cpp MODE_INFO) */ struct ModeMetadata { int sampleRate; double duration; @@ -28,7 +27,7 @@ static const ModeMetadata MODE_TABLE[] = { /* JT65 */ { 11025, 46.8, 0, 1 }, /* JT9 */ { 12000, 49.0, 0, 1 }, /* FST4 */ { 12000, 60.0, 0, 1 }, - /* Q65 */ { 12000, 60.0, 0, 1 }, + /* Q65 */ { 12000, 60.0, 1, 1 }, /* FST4W */ { 12000, 120.0, 0, 1 }, /* JT65JT9 */ { 11025, 46.8, 0, 1 }, /* WSPR */ { 12000, 110.6, 0, 1 }, @@ -40,13 +39,30 @@ static inline int valid_mode(int mode) { return mode >= 0 && mode < MODE_COUNT; } +static inline int valid_q65_period(int period) { + return period == 30 || period == 60 || period == 120 || period == 300; +} + +static inline int normalize_q65_period(int period) { + return valid_q65_period(period) ? period : 60; +} + +static inline int normalize_q65_submode(int submode) { + return (submode >= 0 && submode <= 4) ? submode : 0; +} + static inline wsjtx_lib* to_lib(wsjtx_handle_t h) { return static_cast(h); } -/* Apply v2 decode options onto the lib instance. - * Station fields are always applied so consecutive decodes do not reuse - * stale AP context from a previous request. */ +static wsjtx_encode_options_t default_encode_options(void) { + wsjtx_encode_options_t opts; + opts.threads = 1; + opts.q65_period = 60; + opts.q65_submode = 0; + return opts; +} + static void apply_decode_options(wsjtx_lib* lib, const wsjtx_decode_options_t* opts) { lib->setDecodeStationInfo( std::string(opts->mycall), @@ -59,6 +75,13 @@ static void apply_decode_options(wsjtx_lib* lib, const wsjtx_decode_options_t* o opts->decode_depth, opts->tx_frequency, opts->qso_progress); + lib->setDecodeQ65Controls( + normalize_q65_period(opts->q65_period), + normalize_q65_submode(opts->q65_submode), + opts->q65_max_drift < 0 ? 50 : opts->q65_max_drift, + opts->q65_clear_averaging != 0, + opts->q65_single_decode != 0, + opts->q65_averaging != 0); } /* ---- Lifecycle ---- */ @@ -151,15 +174,34 @@ WSJTX_API int wsjtx_encode(wsjtx_handle_t handle, int mode, int freq, int sample const char* message, float* out_samples, int* out_num_samples, int out_buf_size, char* out_message_sent, int out_msg_buf_size) +{ + wsjtx_encode_options_t opts = default_encode_options(); + return wsjtx_encode_v2(handle, mode, freq, sample_rate, message, &opts, + out_samples, out_num_samples, out_buf_size, + out_message_sent, out_msg_buf_size); +} + +WSJTX_API int wsjtx_encode_v2(wsjtx_handle_t handle, int mode, int freq, int sample_rate, + const char* message, const wsjtx_encode_options_t* options, + 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; if (sample_rate != 12000 && sample_rate != 48000) return WSJTX_ERR_INVALID_SAMPLE_RATE; try { + wsjtx_encode_options_t defaults = default_encode_options(); + const wsjtx_encode_options_t* opts = options ? options : &defaults; std::string messageSent; std::vector audio = to_lib(handle)->encode( - static_cast(mode), freq, std::string(message), messageSent, sample_rate); + static_cast(mode), + freq, + std::string(message), + messageSent, + sample_rate, + normalize_q65_period(opts->q65_period), + normalize_q65_submode(opts->q65_submode)); if (audio.empty()) return WSJTX_ERR_ENCODE_FAILED; @@ -236,14 +278,12 @@ WSJTX_API int wsjtx_wspr_decode(wsjtx_handle_t handle, 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; diff --git a/native/wsjtx_c_api.h b/native/wsjtx_c_api.h index ea03299..5cf5d7f 100644 --- a/native/wsjtx_c_api.h +++ b/native/wsjtx_c_api.h @@ -93,6 +93,17 @@ typedef struct { int cycles; } wsjtx_decoder_result_t; +/* Encode options for v2 API. + * - threads: currently a worker hint kept for API symmetry. + * - q65_period: Q65 T/R period in seconds: 30, 60, 120, or 300. + * - q65_submode: Q65 submode A-E represented as 0-4. + */ +typedef struct { + int threads; + int q65_period; + int q65_submode; +} wsjtx_encode_options_t; + /* Decode options for v2 API. * - frequency: nominal QSO frequency in Hz (passed as nfqso to the decoder) * - tx_frequency: transmit audio offset in Hz (passed as nftx to the decoder) @@ -105,8 +116,13 @@ typedef struct { * - hiscall: DX callsign for AP decode (empty = none) * - hisgrid: DX 4-char grid for AP decode (empty = none) * - ap_decode: enable AP decode passes (default 1) - * - decode_depth: WSJT-X decode depth (default 1) + * - decode_depth: WSJT-X decoder depth (default 1) * - qso_progress: WSJT-X QSO progress stage (default 0) + * - q65_period/q65_submode: Q65 period and submode; ignored by other modes. + * - q65_max_drift: Q65 max drift control. + * - q65_clear_averaging: clear Q65 averaging state before decode. + * - q65_single_decode: request single-candidate Q65 decode behavior. + * - q65_averaging: enable averaged Q65 decode passes. */ typedef struct { int frequency; @@ -118,6 +134,12 @@ typedef struct { int ap_decode; int decode_depth; int qso_progress; + int q65_period; + int q65_submode; + int q65_max_drift; + int q65_clear_averaging; + int q65_single_decode; + int q65_averaging; char mycall[13]; char mygrid[7]; char hiscall[13]; @@ -131,86 +153,41 @@ WSJTX_API void wsjtx_destroy(wsjtx_handle_t handle); /* ---- Decode ---- */ -/** - * Decode audio samples (float format) — legacy API. - * 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) — legacy API. - * 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); -/** - * Decode audio samples (float format) with full options — v2 API. - * Applies dxCall/dxGrid (for A8 list decode) and the decode frequency range - * before invoking the decoder. Results are placed in the internal queue; - * use wsjtx_pull_messages() to retrieve them in batch. - */ WSJTX_API int wsjtx_decode_float_v2(wsjtx_handle_t handle, int mode, const float* samples, int num_samples, const wsjtx_decode_options_t* options); -/** - * Decode audio samples (int16 format) with full options — v2 API. - */ WSJTX_API int wsjtx_decode_int16_v2(wsjtx_handle_t handle, int mode, const int16_t* samples, int num_samples, const wsjtx_decode_options_t* options); /* ---- Encode ---- */ -/** - * Encode a message into audio samples. - * - * @param sample_rate Output sample rate for FT8/FT4 encode; must be 12000 or 48000 - * @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, int sample_rate, const char* message, float* out_samples, int* out_num_samples, int out_buf_size, char* out_message_sent, int out_msg_buf_size); +WSJTX_API int wsjtx_encode_v2(wsjtx_handle_t handle, int mode, int freq, int sample_rate, + const char* message, const wsjtx_encode_options_t* options, + 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); -/** - * Pull up to `max_messages` decoded messages from the queue in one call. - * Returns the number of messages written into `out_messages` (>= 0). - */ WSJTX_API int wsjtx_pull_messages(wsjtx_handle_t handle, wsjtx_message_t* out_messages, int max_messages); /* ---- 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, diff --git a/native/wsjtx_wrapper.cpp b/native/wsjtx_wrapper.cpp index 6755138..2f2040f 100644 --- a/native/wsjtx_wrapper.cpp +++ b/native/wsjtx_wrapper.cpp @@ -10,9 +10,17 @@ namespace wsjtx_nodejs { namespace { int g_encodeSampleRate = 0; - } - // ---- WSJTXLibWrapper ---- + int getOptionalInt(const Napi::Object& obj, const char* key, int fallback) + { + return obj.Has(key) ? obj.Get(key).As().Int32Value() : fallback; + } + + int getOptionalBoolAsInt(const Napi::Object& obj, const char* key, int fallback) + { + return obj.Has(key) ? (obj.Get(key).As().Value() ? 1 : 0) : fallback; + } + } Napi::Object WSJTXLibWrapper::Init(Napi::Env env, Napi::Object exports) { @@ -70,13 +78,12 @@ namespace wsjtx_nodejs } } - // ---- Decode ---- - Napi::Value WSJTXLibWrapper::Decode(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); - if (info.Length() < 4) { + if (info.Length() < 4 || !info[0].IsNumber() || !info[1].IsTypedArray() || + !info[2].IsObject() || !info[3].IsFunction()) { Napi::TypeError::New(env, "Expected: mode, audioData, options, callback").ThrowAsJavaScriptException(); return env.Null(); } @@ -86,14 +93,20 @@ namespace wsjtx_nodejs wsjtx_decode_options_t opts = {}; opts.frequency = optObj.Get("frequency").As().Int32Value(); - opts.tx_frequency = optObj.Has("txFrequency") ? optObj.Get("txFrequency").As().Int32Value() : opts.frequency; - opts.threads = optObj.Has("threads") ? optObj.Get("threads").As().Int32Value() : 4; - opts.low_freq = optObj.Has("lowFreq") ? optObj.Get("lowFreq").As().Int32Value() : 200; - opts.high_freq = optObj.Has("highFreq") ? optObj.Get("highFreq").As().Int32Value() : 4000; - opts.tolerance = optObj.Has("tolerance") ? optObj.Get("tolerance").As().Int32Value() : 20; - opts.ap_decode = optObj.Has("apDecode") ? (optObj.Get("apDecode").As().Value() ? 1 : 0) : 1; - opts.decode_depth = optObj.Has("decodeDepth") ? optObj.Get("decodeDepth").As().Int32Value() : 1; - opts.qso_progress = optObj.Has("qsoProgress") ? optObj.Get("qsoProgress").As().Int32Value() : 0; + opts.tx_frequency = getOptionalInt(optObj, "txFrequency", opts.frequency); + opts.threads = getOptionalInt(optObj, "threads", 4); + opts.low_freq = getOptionalInt(optObj, "lowFreq", 200); + opts.high_freq = getOptionalInt(optObj, "highFreq", 4000); + opts.tolerance = getOptionalInt(optObj, "tolerance", 20); + opts.ap_decode = getOptionalBoolAsInt(optObj, "apDecode", 1); + opts.decode_depth = getOptionalInt(optObj, "decodeDepth", 1); + opts.qso_progress = getOptionalInt(optObj, "qsoProgress", 0); + opts.q65_period = getOptionalInt(optObj, "q65Period", 60); + opts.q65_submode = getOptionalInt(optObj, "q65Submode", 0); + opts.q65_max_drift = getOptionalInt(optObj, "q65MaxDrift", 50); + opts.q65_clear_averaging = getOptionalBoolAsInt(optObj, "q65ClearAveraging", 0); + opts.q65_single_decode = getOptionalBoolAsInt(optObj, "q65SingleDecode", 0); + opts.q65_averaging = getOptionalBoolAsInt(optObj, "q65Averaging", 0); if (optObj.Has("myCall")) { auto s = optObj.Get("myCall").As().Utf8Value(); strncpy(opts.mycall, s.c_str(), 12); } if (optObj.Has("myGrid")) { auto s = optObj.Get("myGrid").As().Utf8Value(); strncpy(opts.mygrid, s.c_str(), 6); } if (optObj.Has("dxCall")) { auto s = optObj.Get("dxCall").As().Utf8Value(); strncpy(opts.hiscall, s.c_str(), 12); } @@ -113,22 +126,17 @@ namespace wsjtx_nodejs return env.Undefined(); } - // ---- Encode ---- - Napi::Value WSJTXLibWrapper::Encode(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); - if (info.Length() < 5) - { - Napi::TypeError::New(env, "Expected 5 arguments: mode, message, frequency, threads, callback") + if (info.Length() < 5) { + Napi::TypeError::New(env, "Expected 5 arguments: mode, message, frequency, options, callback") .ThrowAsJavaScriptException(); return env.Null(); } - if (!info[0].IsNumber() || !info[1].IsString() || !info[2].IsNumber() || - !info[3].IsNumber() || !info[4].IsFunction()) - { + if (!info[0].IsNumber() || !info[1].IsString() || !info[2].IsNumber() || !info[4].IsFunction()) { Napi::TypeError::New(env, "Invalid argument types").ThrowAsJavaScriptException(); return env.Null(); } @@ -136,13 +144,28 @@ namespace wsjtx_nodejs int mode = info[0].As().Int32Value(); std::string message = info[1].As().Utf8Value(); int frequency = info[2].As().Int32Value(); - int threads = info[3].As().Int32Value(); Napi::Function callback = info[4].As(); + wsjtx_encode_options_t options = {}; + options.threads = 4; + options.q65_period = 60; + options.q65_submode = 0; + if (info[3].IsNumber()) { + options.threads = info[3].As().Int32Value(); + } else if (info[3].IsObject()) { + Napi::Object optObj = info[3].As(); + options.threads = getOptionalInt(optObj, "threads", 4); + options.q65_period = getOptionalInt(optObj, "q65Period", 60); + options.q65_submode = getOptionalInt(optObj, "q65Submode", 0); + } else { + Napi::TypeError::New(env, "Encode options must be a thread count or an object").ThrowAsJavaScriptException(); + return env.Null(); + } + try { ValidateMode(env, mode); ValidateFrequency(env, frequency); - ValidateThreads(env, threads); + ValidateThreads(env, options.threads); ValidateMessage(env, mode, message); } catch (const std::exception &e) { Napi::Error::New(env, e.what()).ThrowAsJavaScriptException(); @@ -155,103 +178,74 @@ namespace wsjtx_nodejs return env.Null(); } - auto worker = new EncodeWorker(callback, handle_, mode, message, frequency, threads, encodeSampleRate_); + auto worker = new EncodeWorker(callback, handle_, mode, message, frequency, options, encodeSampleRate_); worker->Queue(); return env.Undefined(); } - // ---- WSPR Decode ---- - Napi::Value WSJTXLibWrapper::DecodeWSPR(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); - if (info.Length() < 3) - { + if (info.Length() < 3 || !info[0].IsTypedArray() || !info[1].IsObject() || !info[2].IsFunction()) { Napi::TypeError::New(env, "Expected 3 arguments: iqData, options, callback") .ThrowAsJavaScriptException(); return env.Null(); } - if (!info[0].IsTypedArray() || !info[1].IsObject() || !info[2].IsFunction()) - { - Napi::TypeError::New(env, "Invalid argument types").ThrowAsJavaScriptException(); - return env.Null(); - } - Napi::Float32Array iqArray = info[0].As(); size_t length = iqArray.ElementLength(); - - if (length % 2 != 0) - { + if (length % 2 != 0) { Napi::Error::New(env, "IQ data length must be even (interleaved I,Q samples)") .ThrowAsJavaScriptException(); return env.Null(); } - // Copy the interleaved IQ data directly (no complex conversion needed) float *data = iqArray.Data(); std::vector iqInterleaved(data, data + length); - // Parse decoder options Napi::Object optObj = info[1].As(); wsjtx_decoder_options_t options; memset(&options, 0, sizeof(options)); if (optObj.Has("dialFrequency")) options.freq = optObj.Get("dialFrequency").As().Int32Value(); - if (optObj.Has("callsign")) { std::string cs = optObj.Get("callsign").As().Utf8Value(); strncpy(options.rcall, cs.c_str(), sizeof(options.rcall) - 1); } - if (optObj.Has("locator")) { std::string loc = optObj.Get("locator").As().Utf8Value(); strncpy(options.rloc, loc.c_str(), sizeof(options.rloc) - 1); } - if (optObj.Has("quickMode")) options.quickmode = optObj.Get("quickMode").As().Value() ? 1 : 0; - if (optObj.Has("useHashTable")) options.usehashtable = optObj.Get("useHashTable").As().Value() ? 1 : 0; - if (optObj.Has("passes")) options.npasses = optObj.Get("passes").As().Int32Value(); - if (optObj.Has("subtraction")) options.subtraction = optObj.Get("subtraction").As().Value() ? 1 : 0; Napi::Function callback = info[2].As(); - auto worker = new WSPRDecodeWorker(callback, handle_, iqInterleaved, options); worker->Queue(); - return env.Undefined(); } - // ---- Pull Messages ---- - Napi::Value WSJTXLibWrapper::PullMessages(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); - Napi::Array results = Napi::Array::New(env); wsjtx_message_t msg; uint32_t count = 0; - - while (wsjtx_pull_message(handle_, &msg) == 1) - { + while (wsjtx_pull_message(handle_, &msg) == 1) { results[count++] = CreateMessageObject(env, msg); } - return results; } - // ---- Query methods ---- - Napi::Value WSJTXLibWrapper::IsEncodingSupported(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); @@ -282,7 +276,7 @@ namespace wsjtx_nodejs return env.Null(); } int mode = info[0].As().Int32Value(); - if (mode == 0 || mode == 1) { + if (mode == WSJTX_MODE_FT8 || mode == WSJTX_MODE_FT4 || mode == WSJTX_MODE_Q65) { return Napi::Number::New(env, encodeSampleRate_); } return Napi::Number::New(env, wsjtx_get_sample_rate(mode)); @@ -299,23 +293,15 @@ namespace wsjtx_nodejs return Napi::Number::New(env, wsjtx_get_transmission_duration(mode)); } - // ---- Audio Format Conversion ---- - Napi::Value WSJTXLibWrapper::ConvertAudioFormat(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); - - if (info.Length() < 3) { + if (info.Length() < 3 || !info[0].IsTypedArray() || !info[1].IsString() || !info[2].IsFunction()) { 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; @@ -328,7 +314,6 @@ namespace wsjtx_nodejs 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); @@ -342,12 +327,9 @@ namespace wsjtx_nodejs .ThrowAsJavaScriptException(); return env.Null(); } - return env.Undefined(); } - // ---- Helpers ---- - void WSJTXLibWrapper::ValidateMode(Napi::Env env, int mode) { if (mode < 0 || mode > WSJTX_MODE_WSPR) throw std::invalid_argument("Invalid mode value"); @@ -364,11 +346,8 @@ namespace wsjtx_nodejs } void WSJTXLibWrapper::ValidateMessage(Napi::Env env, int mode, const std::string &message) { - if (message.empty()) { - throw std::invalid_argument("Message must not be empty"); - } - - const size_t maxLength = (mode == WSJTX_MODE_FT8 || mode == WSJTX_MODE_FT4) ? 37 : 22; + if (message.empty()) throw std::invalid_argument("Message must not be empty"); + const size_t maxLength = (mode == WSJTX_MODE_FT8 || mode == WSJTX_MODE_FT4 || mode == WSJTX_MODE_Q65) ? 37 : 22; if (message.length() > maxLength) { throw std::invalid_argument("Message must be 1-" + std::to_string(maxLength) + " characters long"); } @@ -398,19 +377,15 @@ namespace wsjtx_nodejs return result; } - // ---- Async Workers ---- - AsyncWorkerBase::AsyncWorkerBase(Napi::Function &callback, wsjtx_handle_t handle) : Napi::AsyncWorker(callback), handle_(handle) {} - // DecodeWorker (float) DecodeWorker::DecodeWorker(Napi::Function &cb, wsjtx_handle_t h, int mode, const std::vector &d, const wsjtx_decode_options_t& o) : AsyncWorkerBase(cb, h), mode_(mode), floatData_(d), options_(o), useFloat_(true) {} - // DecodeWorker (int16) DecodeWorker::DecodeWorker(Napi::Function &cb, wsjtx_handle_t h, int mode, const std::vector &d, const wsjtx_decode_options_t& o) @@ -458,24 +433,23 @@ namespace wsjtx_nodejs Callback().Call({env.Null(), result}); } - // EncodeWorker EncodeWorker::EncodeWorker(Napi::Function &callback, wsjtx_handle_t handle, int mode, const std::string &message, - int frequency, int threads, int sampleRate) + int frequency, const wsjtx_encode_options_t& options, + int sampleRate) : AsyncWorkerBase(callback, handle), mode_(mode), message_(message), - frequency_(frequency), threads_(threads), sampleRate_(sampleRate) {} + frequency_(frequency), options_(options), sampleRate_(sampleRate) {} void EncodeWorker::Execute() { - // FT8 at 48kHz for 12.64s = ~607,000 samples; 1M buffer is plenty. - static const int MAX_SAMPLES = 1024 * 1024; + static const int MAX_SAMPLES = 16 * 1024 * 1024; audioData_.resize(MAX_SAMPLES); int numSamples = 0; char msgSent[256] = {0}; - int rc = wsjtx_encode(handle_, mode_, frequency_, + int rc = wsjtx_encode_v2(handle_, mode_, frequency_, sampleRate_, - message_.c_str(), + message_.c_str(), &options_, audioData_.data(), &numSamples, MAX_SAMPLES, msgSent, sizeof(msgSent)); @@ -491,7 +465,6 @@ namespace wsjtx_nodejs void EncodeWorker::OnOK() { Napi::Env env = Env(); - Napi::Float32Array audioArray = Napi::Float32Array::New(env, audioData_.size()); std::copy(audioData_.begin(), audioData_.end(), audioArray.Data()); @@ -499,11 +472,9 @@ namespace wsjtx_nodejs result.Set("audioData", audioArray); result.Set("messageSent", Napi::String::New(env, messageSent_)); result.Set("sampleRate", Napi::Number::New(env, sampleRate_)); - Callback().Call({env.Null(), result}); } - // WSPRDecodeWorker WSPRDecodeWorker::WSPRDecodeWorker(Napi::Function &callback, wsjtx_handle_t handle, const std::vector &iqInterleaved, const wsjtx_decoder_options_t &options) @@ -513,18 +484,14 @@ namespace wsjtx_nodejs { 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, + 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); } @@ -532,12 +499,9 @@ namespace wsjtx_nodejs { Napi::Env env = Env(); Napi::Array resultsArray = Napi::Array::New(env, results_.size()); - - for (size_t i = 0; i < results_.size(); i++) - { + for (size_t i = 0; i < results_.size(); i++) { const auto &r = results_[i]; Napi::Object obj = Napi::Object::New(env); - 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)); @@ -549,14 +513,11 @@ namespace wsjtx_nodejs 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; } - Callback().Call({env.Null(), resultsArray}); } - // AudioConvertWorker void AudioConvertWorker::Execute() { if (fromFloat_) { @@ -597,7 +558,6 @@ namespace wsjtx_nodejs } } - // Module initialization Napi::Object Init(Napi::Env env, Napi::Object exports) { return WSJTXLibWrapper::Init(env, exports); diff --git a/native/wsjtx_wrapper.h b/native/wsjtx_wrapper.h index 4d01434..6eb0957 100644 --- a/native/wsjtx_wrapper.h +++ b/native/wsjtx_wrapper.h @@ -42,9 +42,6 @@ class WSJTXLibWrapper : public Napi::ObjectWrap { int encodeSampleRate_ = 12000; }; -/** - * Base class for async workers that need the library handle - */ class AsyncWorkerBase : public Napi::AsyncWorker { public: AsyncWorkerBase(Napi::Function& callback, wsjtx_handle_t handle); @@ -54,9 +51,6 @@ class AsyncWorkerBase : public Napi::AsyncWorker { wsjtx_handle_t handle_; }; -/** - * Async worker for decode operations - */ class DecodeWorker : public AsyncWorkerBase { public: DecodeWorker(Napi::Function& cb, wsjtx_handle_t h, int mode, const std::vector& d, const wsjtx_decode_options_t& o); @@ -69,14 +63,12 @@ class DecodeWorker : public AsyncWorkerBase { wsjtx_decode_options_t options_; std::vector messages_; int numMessages_ = 0; }; -/** - * Async worker for encode operations - */ class EncodeWorker : public AsyncWorkerBase { public: EncodeWorker(Napi::Function& callback, wsjtx_handle_t handle, int mode, const std::string& message, - int frequency, int threads, int sampleRate); + int frequency, const wsjtx_encode_options_t& options, + int sampleRate); protected: void Execute() override; @@ -86,15 +78,12 @@ class EncodeWorker : public AsyncWorkerBase { int mode_; std::string message_; int frequency_; - int threads_; + wsjtx_encode_options_t options_; int sampleRate_; std::vector audioData_; std::string messageSent_; }; -/** - * Async worker for WSPR decode operations - */ class WSPRDecodeWorker : public AsyncWorkerBase { public: WSPRDecodeWorker(Napi::Function& callback, wsjtx_handle_t handle, @@ -111,9 +100,6 @@ class WSPRDecodeWorker : public AsyncWorkerBase { std::vector results_; }; -/** - * Async worker for audio format conversion (no library handle needed) - */ class AudioConvertWorker : public Napi::AsyncWorker { public: enum class Target { Float32, Int16 }; @@ -139,4 +125,4 @@ class AudioConvertWorker : public Napi::AsyncWorker { bool fromFloat_; }; -} // namespace wsjtx_nodejs +} // namespace wsjtx_nodejs \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 82aacac..2097285 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,11 +2,11 @@ * wsjtx-lib — Node.js binding for the WSJT-X 3.0.0 backend. * * Public surface: - * - WSJTXLib.encode(mode, message, frequency) + * - WSJTXLib.encode(mode, message, frequency, threadsOrOptions?) * - WSJTXLib.decode(mode, audio, options) * - WSJTXLib.decodeWSPR(audio, options) * - WSJTXLib.convertAudioFormat(audio, target) - * - capability/sample-rate query helpers (FT8/FT4 default encode rate: 12 kHz) + * - capability/sample-rate query helpers (FT8/FT4/Q65 default encode rate: 12 kHz) */ import { @@ -21,6 +21,9 @@ import { type WSJTXConfig, type ModeCapabilities, type DecodeOptions, + type EncodeOptions, + type Q65Period, + type Q65Submode, } from './types.js'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; @@ -47,11 +50,23 @@ interface NativeDecodeOptions { apDecode: boolean; decodeDepth: number; qsoProgress: number; + q65Period: number; + q65Submode: number; + q65MaxDrift: number; + q65ClearAveraging: boolean; + q65SingleDecode: boolean; + q65Averaging: boolean; +} + +interface NativeEncodeOptions { + threads: number; + q65Period: number; + q65Submode: number; } interface NativeWSJTXLib { decode(mode: number, audio: AudioData, opts: NativeDecodeOptions, cb: (e: Error | null, r: DecodeResult) => void): void; - encode(mode: number, message: string, frequency: number, threads: number, cb: (e: Error | null, r: EncodeResult) => void): void; + encode(mode: number, message: string, frequency: number, opts: NativeEncodeOptions, cb: (e: Error | null, r: EncodeResult) => void): void; decodeWSPR(audio: Float32Array, opts: Record, cb: (e: Error | null, r: WSPRResult[]) => void): void; pullMessages(): WSJTXMessage[]; isEncodingSupported(mode: number): boolean; @@ -82,6 +97,14 @@ const FREQ_MAX = 30_000_000; const THREADS_MIN = 1; const THREADS_MAX = 16; const MESSAGE_MAX_LEN = 37; +const Q65_PERIODS = new Set([30, 60, 120, 300]); +const Q65_SUBMODES = new Map([ + ['A', 0], + ['B', 1], + ['C', 2], + ['D', 3], + ['E', 4], +]); export class WSJTXLib { private readonly native: NativeWSJTXLib; @@ -97,14 +120,21 @@ export class WSJTXLib { this.validateMode(mode); this.validateAudio(audioData); this.validateFrequency(options.frequency); + const threads = options.threads ?? this.config.maxThreads; + this.validateThreads(threads); if (!this.isDecodingSupported(mode)) { throw new WSJTXError('Decoding not supported for this mode', 'UNSUPPORTED'); } + const q65Period = this.normalizeQ65Period(options.q65Period ?? 60); + const q65Submode = this.normalizeQ65Submode(options.q65Submode ?? 'A'); + const q65MaxDrift = options.q65MaxDrift ?? 50; + this.validateNonNegativeInteger(q65MaxDrift, 'q65MaxDrift'); + const opts: NativeDecodeOptions = { frequency: options.frequency, txFrequency: options.txFrequency ?? options.frequency, - threads: options.threads ?? this.config.maxThreads, + threads, lowFreq: options.lowFreq ?? this.config.defaultLowFreq, highFreq: options.highFreq ?? this.config.defaultHighFreq, tolerance: options.tolerance ?? this.config.defaultTolerance, @@ -115,6 +145,12 @@ export class WSJTXLib { apDecode: options.apDecode ?? true, decodeDepth: options.decodeDepth ?? 1, qsoProgress: options.qsoProgress ?? 0, + q65Period, + q65Submode, + q65MaxDrift, + q65ClearAveraging: options.q65ClearAveraging ?? false, + q65SingleDecode: options.q65SingleDecode ?? false, + q65Averaging: options.q65Averaging ?? false, }; return new Promise((resolve, reject) => { @@ -129,18 +165,19 @@ export class WSJTXLib { mode: WSJTXMode, message: string, frequency: number, - threads: number = this.config.maxThreads, + threadsOrOptions: number | EncodeOptions = this.config.maxThreads, ): Promise { this.validateMode(mode); this.validateMessage(message); this.validateFrequency(frequency); - this.validateThreads(threads); + const opts = this.normalizeEncodeOptions(threadsOrOptions); + this.validateThreads(opts.threads); if (!this.isEncodingSupported(mode)) { throw new WSJTXError('Encoding not supported for this mode', 'UNSUPPORTED'); } return new Promise((resolve, reject) => { - this.native.encode(mode, message, frequency, threads, (err, result) => { + this.native.encode(mode, message, frequency, opts, (err, result) => { if (err) reject(new WSJTXError(err.message, 'ENCODE_ERROR')); else resolve(result); }); @@ -211,6 +248,16 @@ export class WSJTXLib { }); } + private normalizeEncodeOptions(threadsOrOptions: number | EncodeOptions): NativeEncodeOptions { + const options = typeof threadsOrOptions === 'number' ? { threads: threadsOrOptions } : threadsOrOptions; + const threads = options.threads ?? this.config.maxThreads; + return { + threads, + q65Period: this.normalizeQ65Period(options.q65Period ?? 60), + q65Submode: this.normalizeQ65Submode(options.q65Submode ?? 'A'), + }; + } + private validateMode(mode: WSJTXMode): void { if (!Object.values(WSJTXMode).includes(mode)) { throw new WSJTXError('Invalid mode', 'INVALID'); @@ -247,12 +294,42 @@ export class WSJTXLib { throw new WSJTXError('audioData must be a non-empty Float32Array or Int16Array', 'INVALID'); } } + + private normalizeQ65Period(period: number): number { + if (!Number.isInteger(period) || !Q65_PERIODS.has(period)) { + throw new WSJTXError('q65Period must be one of 30, 60, 120, or 300', 'INVALID'); + } + return period; + } + + private normalizeQ65Submode(submode: Q65Submode): number { + if (typeof submode === 'number') { + if (!Number.isInteger(submode) || submode < 0 || submode > 4) { + throw new WSJTXError('q65Submode must be A-E or 0..4', 'INVALID'); + } + return submode; + } + const normalized = Q65_SUBMODES.get(submode.toUpperCase()); + if (normalized === undefined) { + throw new WSJTXError('q65Submode must be A-E or 0..4', 'INVALID'); + } + return normalized; + } + + private validateNonNegativeInteger(value: number, name: string): void { + if (!Number.isInteger(value) || value < 0) { + throw new WSJTXError(`${name} must be a non-negative integer`, 'INVALID'); + } + } } export { WSJTXMode, WSJTXError }; export type { DecodeResult, EncodeResult, + EncodeOptions, + Q65Period, + Q65Submode, WSPRResult, WSPRDecodeOptions, WSJTXMessage, diff --git a/src/types.ts b/src/types.ts index 4e1c0da..1bd9739 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,9 @@ export enum WSJTXMode { export type AudioData = Float32Array | Int16Array; +export type Q65Period = 30 | 60 | 120 | 300; +export type Q65Submode = 'A' | 'B' | 'C' | 'D' | 'E' | 0 | 1 | 2 | 3 | 4; + export interface WSJTXTime { hour: number; minute: number; @@ -33,6 +36,18 @@ export interface WSJTXMessage { sync: number; } +export interface Q65EncodeOptions { + /** Q65 transmit/receive period in seconds. Defaults to 60. */ + q65Period?: Q65Period; + /** Q65 submode A-E, or 0-4. Defaults to A / 0. */ + q65Submode?: Q65Submode; +} + +export interface EncodeOptions extends Q65EncodeOptions { + /** Worker thread hint. Defaults to WSJTXConfig.maxThreads. */ + threads?: number; +} + /** * Options accepted by `WSJTXLib.decode`. * @@ -46,8 +61,14 @@ export interface WSJTXMessage { * - apDecode: enables FT8/FT4 AP decode passes. Defaults to true. * - decodeDepth: WSJT-X decoder depth. Defaults to 1. * - qsoProgress: WSJT-X QSO progress stage. Defaults to 0. + * - q65Period / q65Submode: Q65-specific period and submode, also accepted + * by encode options so Q65 TX/RX can be configured symmetrically. + * - q65MaxDrift: Q65 max drift control forwarded to the WSJT-X decoder. + * - q65ClearAveraging: clear Q65 averaged-message state before decode. + * - q65SingleDecode: request Q65 single-candidate decode behavior. + * - q65Averaging: enable Q65 averaged decode passes. */ -export interface DecodeOptions { +export interface DecodeOptions extends Q65EncodeOptions { frequency: number; txFrequency?: number; threads?: number; @@ -61,6 +82,10 @@ export interface DecodeOptions { apDecode?: boolean; decodeDepth?: number; qsoProgress?: number; + q65MaxDrift?: number; + q65ClearAveraging?: boolean; + q65SingleDecode?: boolean; + q65Averaging?: boolean; } export interface DecodeResult { @@ -109,7 +134,7 @@ export class WSJTXError extends Error { export interface WSJTXConfig { /** Maximum threads used per decode call. Default 4. */ maxThreads?: number; - /** Process-global FT8/FT4 encode output sample rate. Default 12000. */ + /** Process-global FT8/FT4/Q65 encode output sample rate. Default 12000. */ encodeSampleRate?: 12000 | 48000; /** Reserved for future use; currently has no runtime effect. */ debug?: boolean; diff --git a/test/wsjtx.basic.test.ts b/test/wsjtx.basic.test.ts index 07b79f9..d2f0169 100644 --- a/test/wsjtx.basic.test.ts +++ b/test/wsjtx.basic.test.ts @@ -42,6 +42,13 @@ describe('WSJTX library — smoke', () => { assert.ok(lib.isDecodingSupported(WSJTXMode.FT8)); }); + it('reports Q65 supports both encode and decode', () => { + assert.ok(lib.isEncodingSupported(WSJTXMode.Q65)); + assert.ok(lib.isDecodingSupported(WSJTXMode.Q65)); + assert.strictEqual(lib.getSampleRate(WSJTXMode.Q65), 12000); + assert.strictEqual(lib.getTransmissionDuration(WSJTXMode.Q65), 60.0); + }); + it('reports JT65 is decode-only', () => { assert.strictEqual(lib.isEncodingSupported(WSJTXMode.JT65), false); assert.ok(lib.isDecodingSupported(WSJTXMode.JT65)); @@ -50,6 +57,7 @@ describe('WSJTX library — smoke', () => { it('numeric mode enum values match expectations', () => { assert.strictEqual(WSJTXMode.FT8, 0); assert.strictEqual(WSJTXMode.FT4, 1); + assert.strictEqual(WSJTXMode.Q65, 6); assert.strictEqual(WSJTXMode.JT65JT9, 8); assert.strictEqual(WSJTXMode.WSPR, 9); }); @@ -111,6 +119,82 @@ describe('WSJTX library — smoke', () => { assert.deepStrictEqual(r.messages, []); }); + it('Q65 encode accepts legacy thread-count argument and emits a 60 s frame', async () => { + const encoded = await lib.encode(WSJTXMode.Q65, 'CQ K1ABC FN20', 1500, 1); + assert.ok(encoded.audioData instanceof Float32Array); + assert.strictEqual(encoded.sampleRate, 12000); + assert.strictEqual(encoded.audioData.length, 12000 * 60); + assert.ok(encoded.messageSent.trim().length > 0); + }); + + it('Q65 encode accepts object options for period and submode', async () => { + const q65ThirtyB = await lib.encode(WSJTXMode.Q65, 'CQ K1ABC FN20', 1500, { + threads: 1, + q65Period: 30, + q65Submode: 'B', + }); + assert.strictEqual(q65ThirtyB.audioData.length, 12000 * 30); + + const q65OneTwentyE = await lib.encode(WSJTXMode.Q65, 'CQ K1ABC FN20', 1500, { + threads: 1, + q65Period: 120, + q65Submode: 4, + }); + assert.strictEqual(q65OneTwentyE.audioData.length, 12000 * 120); + }); + + it('Q65 self round-trip decodes a generated 30A frame', async () => { + const encoded = await lib.encode(WSJTXMode.Q65, 'CQ K1ABC FN20', 1500, { + threads: 1, + q65Period: 30, + q65Submode: 'A', + }); + const decoded = await lib.decode(WSJTXMode.Q65, encoded.audioData, { + frequency: 1500, + txFrequency: 1500, + threads: 1, + lowFreq: 0, + highFreq: 5000, + tolerance: 5000, + q65Period: 30, + q65Submode: 'A', + q65MaxDrift: 50, + q65ClearAveraging: true, + }); + + assert.strictEqual(decoded.success, true); + assert.ok(decoded.messages.some((m) => m.text.trim() === 'CQ K1ABC FN20')); + }); + + it('rejects invalid Q65 encode options before reaching native code', async () => { + await assert.rejects( + () => lib.encode(WSJTXMode.Q65, 'CQ K1ABC FN20', 1500, { q65Period: 45 as 60 }), + WSJTXError, + ); + await assert.rejects( + () => lib.encode(WSJTXMode.Q65, 'CQ K1ABC FN20', 1500, { q65Submode: 'F' as 'A' }), + WSJTXError, + ); + }); + + it('Q65 decode accepts period, submode, drift, and averaging controls', async () => { + const r = await lib.decode(WSJTXMode.Q65, new Float32Array(12000 * 30), { + frequency: 1500, + threads: 1, + lowFreq: 200, + highFreq: 4000, + tolerance: 50, + q65Period: 30, + q65Submode: 'B', + q65MaxDrift: 50, + q65ClearAveraging: true, + q65SingleDecode: true, + q65Averaging: true, + }); + assert.strictEqual(r.success, true); + assert.ok(Array.isArray(r.messages)); + }); + it('decode accepts dxCall, dxGrid, and freq range options without crashing', async () => { const r = await lib.decode(WSJTXMode.FT8, new Float32Array(12000 * 13), { frequency: 1500, @@ -123,4 +207,4 @@ describe('WSJTX library — smoke', () => { }); assert.strictEqual(r.success, true); }); -}); +}); \ No newline at end of file