From 7e1daa83478f4e71c4a20b45b27b54fff62c8d2f Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:07:11 +0800 Subject: [PATCH 01/47] Add Q65 upstream patch overlay --- cmake/patch-wsjtx-q65.cmake | 110 ++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 cmake/patch-wsjtx-q65.cmake diff --git a/cmake/patch-wsjtx-q65.cmake b/cmake/patch-wsjtx-q65.cmake new file mode 100644 index 0000000..02f85c4 --- /dev/null +++ b/cmake/patch-wsjtx-q65.cmake @@ -0,0 +1,110 @@ +# Idempotent source overlay for Q65 TX/RX support in the wsjtx_lib submodule. +# The parent package currently consumes boybook/wsjtx_lib as a submodule, so +# these targeted replacements keep the Node 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") + +# 1) Add the C++ Q65 encoder declaration. +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);\n\t std::vector encode_wspr(wsjtxMode mode, int frequency, std::string message, std::string &msgsent);" + "declare wsjtx_encode::encode_q65") + +# 2) Add the C++ Q65 encoder implementation. Default is Q65-60A: 85 tones, +# 60 s period, 7200 samples/symbol at 12 kHz, 1x tone-spacing submode A. +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) +{ + 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); + + const int nsym = 85; + const int ntrperiod = 60; + const int nsubmode = 0; // Q65A. Tone spacing multiplier = 1. + int hmod = 1 << nsubmode; + int nsps = (sampleRate / 12000) * 7200; + if (nsps <= 0) nsps = 7200; + 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), const_cast(&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 wsjtx_encode::encode_q65") + +# 3) Route Q65 through wsjtx_lib::encode(). +set(_lib_cpp "${_WSJTX_LIB_DIR}/wsjtx_lib.cpp") +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);\n\t}\n\tdefault: return {};" + "route Q65 encode in wsjtx_lib") + +# 4) Route Q65 decoder results into the Node-visible message queue. +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") + +# 5) Let the C++ decoder select Q65 mode 66 and use a full 60 s/12 kHz frame. +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}" + "\tcase FT8: params.nmode = 8; break;\n\tcase FT4: params.nmode = 5; break;\n\tdefault: return;" + "\tcase FT8: params.nmode = 8; break;\n\tcase FT4: params.nmode = 5; break;\n\tcase Q65:\n\t\tparams.nmode = 66;\n\t\tparams.ntrperiod = 60;\n\t\tparams.kin = std::min(static_cast(audiosamples.size()), 60 * 12000);\n\t\tparams.nzhsym = 85;\n\t\tparams.nsubmode = 0;\n\t\tparams.ntxmode = 66;\n\t\tparams.max_drift = 50;\n\t\tbreak;\n\tdefault: return;" + "select Q65 decoder mode in float path") +wsjtx_replace_once( + "${_decode_cpp}" + "\tcase FT8: params.nmode = 8; break;\n\tcase FT4: params.nmode = 5; break;\n\tdefault: return;" + "\tcase FT8: params.nmode = 8; break;\n\tcase FT4: params.nmode = 5; break;\n\tcase Q65:\n\t\tparams.nmode = 66;\n\t\tparams.ntrperiod = 60;\n\t\tparams.kin = std::min(static_cast(audiosamples.size()), 60 * 12000);\n\t\tparams.nzhsym = 85;\n\t\tparams.nsubmode = 0;\n\t\tparams.ntxmode = 66;\n\t\tparams.max_drift = 50;\n\t\tbreak;\n\tdefault: return;" + "select Q65 decoder mode in int16 path") From 0e88c2b36e3a9da4639342ed293e8765be8c76d9 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:11:10 +0800 Subject: [PATCH 02/47] Apply Q65 overlay during core build --- CMakeLists.txt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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}) From b3b4000b1d231ca4020eaf237c4acd58fb376969 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:14:02 +0800 Subject: [PATCH 03/47] Patch native Q65 capability and wrapper plumbing --- cmake/patch-wsjtx-q65.cmake | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/cmake/patch-wsjtx-q65.cmake b/cmake/patch-wsjtx-q65.cmake index 02f85c4..ac1ec11 100644 --- a/cmake/patch-wsjtx-q65.cmake +++ b/cmake/patch-wsjtx-q65.cmake @@ -1,4 +1,4 @@ -# Idempotent source overlay for Q65 TX/RX support in the wsjtx_lib submodule. +# Idempotent source overlay for Q65 TX/RX support. # The parent package currently consumes boybook/wsjtx_lib as a submodule, so # these targeted replacements keep the Node binding self-contained without # requiring a forked submodule URL. @@ -20,6 +20,7 @@ function(wsjtx_replace_once file needle replacement description) endfunction() set(_WSJTX_LIB_DIR "${CMAKE_SOURCE_DIR}/wsjtx_lib") +set(_WSJTX_NATIVE_DIR "${CMAKE_SOURCE_DIR}/native") # 1) Add the C++ Q65 encoder declaration. set(_encode_h "${_WSJTX_LIB_DIR}/wsjtx_encode.h") @@ -50,9 +51,9 @@ std::vector wsjtx_encode::encode_q65(wsjtxMode mode, int frequency, std:: sendmsg[37] = '\0'; msgsent = std::string(sendmsg); - const int nsym = 85; - const int ntrperiod = 60; - const int nsubmode = 0; // Q65A. Tone spacing multiplier = 1. + int nsym = 85; + int ntrperiod = 60; + int nsubmode = 0; // Q65A. Tone spacing multiplier = 1. int hmod = 1 << nsubmode; int nsps = (sampleRate / 12000) * 7200; if (nsps <= 0) nsps = 7200; @@ -63,7 +64,7 @@ std::vector wsjtx_encode::encode_q65(wsjtxMode mode, int frequency, std:: signal.assign(static_cast(ntrperiod) * static_cast(sampleRate), 0.0f); std::vector cwave(static_cast(nwave) * 2U, 0.0f); - genwave_(const_cast(itone), const_cast(&nsym), &nsps, &nwave, + genwave_(const_cast(itone), &nsym, &nsps, &nwave, &fsample, &hmod, &f0, &icmplx, cwave.data(), signal.data()); return signal; } @@ -108,3 +109,29 @@ wsjtx_replace_once( "\tcase FT8: params.nmode = 8; break;\n\tcase FT4: params.nmode = 5; break;\n\tdefault: return;" "\tcase FT8: params.nmode = 8; break;\n\tcase FT4: params.nmode = 5; break;\n\tcase Q65:\n\t\tparams.nmode = 66;\n\t\tparams.ntrperiod = 60;\n\t\tparams.kin = std::min(static_cast(audiosamples.size()), 60 * 12000);\n\t\tparams.nzhsym = 85;\n\t\tparams.nsubmode = 0;\n\t\tparams.ntxmode = 66;\n\t\tparams.max_drift = 50;\n\t\tbreak;\n\tdefault: return;" "select Q65 decoder mode in int16 path") + +# 6) Expose Q65 as encode-capable through the C ABI metadata. +set(_c_api_cpp "${_WSJTX_NATIVE_DIR}/wsjtx_c_api.cpp") +wsjtx_replace_once( + "${_c_api_cpp}" + " /* Q65 */ { 12000, 60.0, 0, 1 }," + " /* Q65 */ { 12000, 60.0, 1, 1 }," + "mark Q65 encode-capable in C ABI metadata") + +# 7) Let the Node wrapper accept Q65 transmit messages and Q65 48 kHz opt-in. +set(_wrapper_cpp "${_WSJTX_NATIVE_DIR}/wsjtx_wrapper.cpp") +wsjtx_replace_once( + "${_wrapper_cpp}" + " if (mode == 0 || mode == 1) {\n return Napi::Number::New(env, encodeSampleRate_);\n }" + " if (mode == WSJTX_MODE_FT8 || mode == WSJTX_MODE_FT4 || mode == WSJTX_MODE_Q65) {\n return Napi::Number::New(env, encodeSampleRate_);\n }" + "return configured encode sample rate for Q65") +wsjtx_replace_once( + "${_wrapper_cpp}" + " const size_t maxLength = (mode == WSJTX_MODE_FT8 || mode == WSJTX_MODE_FT4) ? 37 : 22;" + " const size_t maxLength = (mode == WSJTX_MODE_FT8 || mode == WSJTX_MODE_FT4 || mode == WSJTX_MODE_Q65) ? 37 : 22;" + "allow 37-character Q65 messages") +wsjtx_replace_once( + "${_wrapper_cpp}" + " // FT8 at 48kHz for 12.64s = ~607,000 samples; 1M buffer is plenty.\n static const int MAX_SAMPLES = 1024 * 1024;" + " // Q65-60 at 48 kHz is 2,880,000 samples; keep enough headroom.\n static const int MAX_SAMPLES = 4 * 1024 * 1024;" + "increase encode worker buffer for Q65-60") From ff7cb4a52ee7f38ebbaa2673f1edbe8a48fb68b4 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:14:49 +0800 Subject: [PATCH 04/47] Add Q65 smoke regression coverage --- test/wsjtx.basic.test.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/test/wsjtx.basic.test.ts b/test/wsjtx.basic.test.ts index 07b79f9..a3b6fd3 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,26 @@ describe('WSJTX library — smoke', () => { assert.deepStrictEqual(r.messages, []); }); + it('Q65 encode emits a full 60 s 12 kHz 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 decode of silence completes successfully with empty messages', async () => { + const r = await lib.decode(WSJTXMode.Q65, new Float32Array(12000 * 60), { + frequency: 1500, + threads: 1, + lowFreq: 200, + highFreq: 4000, + tolerance: 50, + }); + 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 +151,4 @@ describe('WSJTX library — smoke', () => { }); assert.strictEqual(r.success, true); }); -}); +}); \ No newline at end of file From 3ac69b232adb4f37d35e683fa087b32ddcdfe95b Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:16:54 +0800 Subject: [PATCH 05/47] Document Q65 transmit and receive chain --- docs/q65-chain.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/q65-chain.md diff --git a/docs/q65-chain.md b/docs/q65-chain.md new file mode 100644 index 0000000..602f676 --- /dev/null +++ b/docs/q65-chain.md @@ -0,0 +1,56 @@ +# Q65 transmit/receive chain + +This branch exposes a conservative default Q65 path through the existing `WSJTXLib.encode()` and `WSJTXLib.decode()` APIs. + +## 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. +- The default public profile is Q65-60A at 12 kHz. + +## Default transmit parameters + +| Parameter | Value | +|---|---:| +| Mode enum | `WSJTXMode.Q65` / `6` | +| Period | 60 s | +| Submode | A | +| Symbols | 85 | +| Default sample rate | 12 kHz | +| Samples per symbol | 7200 at 12 kHz | +| Tone-spacing multiplier | 1 | +| Output length | `60 * sampleRate` samples | + +The `frequency` argument is the audio offset in Hz, not the RF dial frequency. + +## Default receive parameters + +The Q65 receive path sets the underlying decoder to: + +- `nmode = 66` +- `ntrperiod = 60` +- `nsubmode = 0` +- `ntxmode = 66` +- `nzhsym = 85` +- `max_drift = 50` + +Existing decode options continue to map onto the WSJT-X decoder: `frequency`, `txFrequency`, `lowFreq`, `highFreq`, `tolerance`, station/grid context, decode depth, and QSO progress. + +## Not yet exposed as public options + +- Q65-30, Q65-120, and Q65-300 periods. +- Q65 submodes B, C, D, and E. +- Non-default Q65 drift or averaging controls beyond the existing decode options. + +## Regression coverage + +The smoke test suite covers Q65 capability reporting, enum stability, Q65-60A encode frame length, and Q65 silence decode completion. + +Run: + +```bash +npm run build +npm test +``` From f5223071bba8b9623623a8a9a99bc17717d7f8ca Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:23:34 +0800 Subject: [PATCH 06/47] Expose Q65 mode-specific TypeScript options --- src/types.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) 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; From 667d4954a36520ea2c27cdfd65042bb79600f56a Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:24:23 +0800 Subject: [PATCH 07/47] Add Q65 encode and decode option plumbing --- src/index.ts | 90 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 82aacac..b728bd9 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,15 @@ export class WSJTXLib { }); } + private normalizeEncodeOptions(threadsOrOptions: number | EncodeOptions): NativeEncodeOptions { + const options = typeof threadsOrOptions === 'number' ? { threads: threadsOrOptions } : threadsOrOptions; + return { + threads: options.threads ?? this.config.maxThreads, + 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 +293,42 @@ export class WSJTXLib { throw new WSJTXError('audioData must be a non-empty Float32Array or Int16Array', 'INVALID'); } } + + private normalizeQ65Period(period: Q65Period | undefined): 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 | undefined): 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(String(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, From 583354a1777ccae7b226f26aa468bffb5b3318c7 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:25:29 +0800 Subject: [PATCH 08/47] Add Q65 options to native C ABI --- native/wsjtx_c_api.h | 79 ++++++++++++++++---------------------------- 1 file changed, 28 insertions(+), 51 deletions(-) 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, From b07fb7bf6399b04b9316d3a607aa162351641861 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:26:26 +0800 Subject: [PATCH 09/47] Implement native Q65 encode and decode options --- native/wsjtx_c_api.cpp | 56 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 8 deletions(-) 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; From 81def6a6aadde09f0f8478b28f2d697f36972ac5 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:27:06 +0800 Subject: [PATCH 10/47] Carry encode options through N-API worker --- native/wsjtx_wrapper.h | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) 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 From f9a0fc26f2d1df13d2e3334feb60a2c3408909aa Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:28:36 +0800 Subject: [PATCH 11/47] Support object encode options in N-API wrapper --- native/wsjtx_wrapper.cpp | 164 +++++++++++++++------------------------ 1 file changed, 62 insertions(+), 102 deletions(-) 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); From 823c95965009b03db78827e2e6ffd08a3bea412d Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:29:56 +0800 Subject: [PATCH 12/47] Parameterize Q65 overlay for all periods and submodes --- cmake/patch-wsjtx-q65.cmake | 149 +++++++++++++++++++++++------------- 1 file changed, 96 insertions(+), 53 deletions(-) diff --git a/cmake/patch-wsjtx-q65.cmake b/cmake/patch-wsjtx-q65.cmake index ac1ec11..ca08c10 100644 --- a/cmake/patch-wsjtx-q65.cmake +++ b/cmake/patch-wsjtx-q65.cmake @@ -1,7 +1,7 @@ # Idempotent source overlay for Q65 TX/RX support. -# The parent package currently consumes boybook/wsjtx_lib as a submodule, so -# these targeted replacements keep the Node binding self-contained without -# requiring a forked submodule URL. +# 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) @@ -20,21 +20,19 @@ function(wsjtx_replace_once file needle replacement description) endfunction() set(_WSJTX_LIB_DIR "${CMAKE_SOURCE_DIR}/wsjtx_lib") -set(_WSJTX_NATIVE_DIR "${CMAKE_SOURCE_DIR}/native") -# 1) Add the C++ Q65 encoder declaration. +# ---- 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);\n\t std::vector encode_wspr(wsjtxMode mode, int frequency, std::string message, std::string &msgsent);" - "declare wsjtx_encode::encode_q65") + "\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") -# 2) Add the C++ Q65 encoder implementation. Default is Q65-60A: 85 tones, -# 60 s period, 7200 samples/symbol at 12 kHz, 1x tone-spacing submode A. +# ---- 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) +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; @@ -52,11 +50,20 @@ std::vector wsjtx_encode::encode_q65(wsjtxMode mode, int frequency, std:: msgsent = std::string(sendmsg); int nsym = 85; - int ntrperiod = 60; - int nsubmode = 0; // Q65A. Tone spacing multiplier = 1. + 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 nsps = (sampleRate / 12000) * 7200; - if (nsps <= 0) nsps = 7200; + + 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; @@ -74,25 +81,63 @@ 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 wsjtx_encode::encode_q65") + "implement parameterized wsjtx_encode::encode_q65") -# 3) Route Q65 through wsjtx_lib::encode(). +# ---- 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::setDecodeQ65Controls") +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::setDecodeQ65Controls") +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);\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") -# 4) Route Q65 decoder results into the Node-visible message queue. -set(_callbacks_f90 "${_WSJTX_LIB_DIR}/lib/decode_callbacks.f90") +# ---- wsjtx_decode.h -------------------------------------------------------- +set(_decode_h "${_WSJTX_LIB_DIR}/wsjtx_decode.h") 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") + "${_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::setDecodeQ65Controls") +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") -# 5) Let the C++ decoder select Q65 mode 66 and use a full 60 s/12 kHz frame. +# ---- wsjtx_decode.cpp ------------------------------------------------------ set(_decode_cpp "${_WSJTX_LIB_DIR}/wsjtx_decode.cpp") wsjtx_replace_once( "${_decode_cpp}" @@ -101,37 +146,35 @@ wsjtx_replace_once( "include for Q65 frame sizing") wsjtx_replace_once( "${_decode_cpp}" - "\tcase FT8: params.nmode = 8; break;\n\tcase FT4: params.nmode = 5; break;\n\tdefault: return;" - "\tcase FT8: params.nmode = 8; break;\n\tcase FT4: params.nmode = 5; break;\n\tcase Q65:\n\t\tparams.nmode = 66;\n\t\tparams.ntrperiod = 60;\n\t\tparams.kin = std::min(static_cast(audiosamples.size()), 60 * 12000);\n\t\tparams.nzhsym = 85;\n\t\tparams.nsubmode = 0;\n\t\tparams.ntxmode = 66;\n\t\tparams.max_drift = 50;\n\t\tbreak;\n\tdefault: return;" - "select Q65 decoder mode in float path") + "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::setDecodeQ65Controls") +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;" - "\tcase FT8: params.nmode = 8; break;\n\tcase FT4: params.nmode = 5; break;\n\tcase Q65:\n\t\tparams.nmode = 66;\n\t\tparams.ntrperiod = 60;\n\t\tparams.kin = std::min(static_cast(audiosamples.size()), 60 * 12000);\n\t\tparams.nzhsym = 85;\n\t\tparams.nsubmode = 0;\n\t\tparams.ntxmode = 66;\n\t\tparams.max_drift = 50;\n\t\tbreak;\n\tdefault: return;" - "select Q65 decoder mode in int16 path") - -# 6) Expose Q65 as encode-capable through the C ABI metadata. -set(_c_api_cpp "${_WSJTX_NATIVE_DIR}/wsjtx_c_api.cpp") -wsjtx_replace_once( - "${_c_api_cpp}" - " /* Q65 */ { 12000, 60.0, 0, 1 }," - " /* Q65 */ { 12000, 60.0, 1, 1 }," - "mark Q65 encode-capable in C ABI metadata") + "${_q65_switch}" + "select parameterized Q65 decoder mode") -# 7) Let the Node wrapper accept Q65 transmit messages and Q65 48 kHz opt-in. -set(_wrapper_cpp "${_WSJTX_NATIVE_DIR}/wsjtx_wrapper.cpp") -wsjtx_replace_once( - "${_wrapper_cpp}" - " if (mode == 0 || mode == 1) {\n return Napi::Number::New(env, encodeSampleRate_);\n }" - " if (mode == WSJTX_MODE_FT8 || mode == WSJTX_MODE_FT4 || mode == WSJTX_MODE_Q65) {\n return Napi::Number::New(env, encodeSampleRate_);\n }" - "return configured encode sample rate for Q65") -wsjtx_replace_once( - "${_wrapper_cpp}" - " const size_t maxLength = (mode == WSJTX_MODE_FT8 || mode == WSJTX_MODE_FT4) ? 37 : 22;" - " const size_t maxLength = (mode == WSJTX_MODE_FT8 || mode == WSJTX_MODE_FT4 || mode == WSJTX_MODE_Q65) ? 37 : 22;" - "allow 37-character Q65 messages") +# ---- lib/decode_callbacks.f90 --------------------------------------------- +set(_callbacks_f90 "${_WSJTX_LIB_DIR}/lib/decode_callbacks.f90") wsjtx_replace_once( - "${_wrapper_cpp}" - " // FT8 at 48kHz for 12.64s = ~607,000 samples; 1M buffer is plenty.\n static const int MAX_SAMPLES = 1024 * 1024;" - " // Q65-60 at 48 kHz is 2,880,000 samples; keep enough headroom.\n static const int MAX_SAMPLES = 4 * 1024 * 1024;" - "increase encode worker buffer for Q65-60") + "${_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") From 6909c0b6b79294159a8e85b80fbd0454dc9bc06e Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:30:46 +0800 Subject: [PATCH 13/47] Cover Q65 period and submode options in smoke tests --- test/wsjtx.basic.test.ts | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/test/wsjtx.basic.test.ts b/test/wsjtx.basic.test.ts index a3b6fd3..cad5716 100644 --- a/test/wsjtx.basic.test.ts +++ b/test/wsjtx.basic.test.ts @@ -119,7 +119,7 @@ describe('WSJTX library — smoke', () => { assert.deepStrictEqual(r.messages, []); }); - it('Q65 encode emits a full 60 s 12 kHz frame', async () => { + 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); @@ -127,13 +127,46 @@ describe('WSJTX library — smoke', () => { assert.ok(encoded.messageSent.trim().length > 0); }); - it('Q65 decode of silence completes successfully with empty messages', async () => { - const r = await lib.decode(WSJTXMode.Q65, new Float32Array(12000 * 60), { + 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('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)); From c2e17a4dd6ea495ac1a030b36eb3de42cfc45620 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:31:31 +0800 Subject: [PATCH 14/47] Update Q65 docs for full public options --- docs/q65-chain.md | 93 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 66 insertions(+), 27 deletions(-) diff --git a/docs/q65-chain.md b/docs/q65-chain.md index 602f676..9fb91e8 100644 --- a/docs/q65-chain.md +++ b/docs/q65-chain.md @@ -1,6 +1,6 @@ # Q65 transmit/receive chain -This branch exposes a conservative default Q65 path through the existing `WSJTXLib.encode()` and `WSJTXLib.decode()` APIs. +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 @@ -8,45 +8,84 @@ This branch exposes a conservative default Q65 path through the existing `WSJTXL - 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. -- The default public profile is Q65-60A at 12 kHz. +- Q65 period and submode are public encode and decode options. +- Q65 drift and averaging controls are public decode options. -## Default transmit parameters +## Public API -| Parameter | Value | -|---|---:| -| Mode enum | `WSJTXMode.Q65` / `6` | -| Period | 60 s | -| Submode | A | -| Symbols | 85 | -| Default sample rate | 12 kHz | -| Samples per symbol | 7200 at 12 kHz | -| Tone-spacing multiplier | 1 | -| Output length | `60 * sampleRate` samples | +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. -## Default receive parameters +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. -The Q65 receive path sets the underlying decoder to: +## Q65 receive parameters -- `nmode = 66` -- `ntrperiod = 60` -- `nsubmode = 0` -- `ntxmode = 66` -- `nzhsym = 85` -- `max_drift = 50` +| 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, decode depth, and QSO progress. +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. -## Not yet exposed as public options +## Native mapping -- Q65-30, Q65-120, and Q65-300 periods. -- Q65 submodes B, C, D, and E. -- Non-default Q65 drift or averaging controls beyond the existing decode options. +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, Q65-60A encode frame length, and Q65 silence decode completion. +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: From b4335b8585c9fc5cd3101287c9f7716d4ed115e8 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:34:55 +0800 Subject: [PATCH 15/47] Add Q65 Linux smoke build workflow --- .github/workflows/build.yml | 71 +++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) 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 From f9054bd909d3afb73f1b112b8b01502dab74e29f Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:42:47 +0800 Subject: [PATCH 16/47] Fix strict Q65 option narrowing --- src/index.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index b728bd9..2097285 100644 --- a/src/index.ts +++ b/src/index.ts @@ -250,8 +250,9 @@ 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: options.threads ?? this.config.maxThreads, + threads, q65Period: this.normalizeQ65Period(options.q65Period ?? 60), q65Submode: this.normalizeQ65Submode(options.q65Submode ?? 'A'), }; @@ -294,21 +295,21 @@ export class WSJTXLib { } } - private normalizeQ65Period(period: Q65Period | undefined): number { + 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 | undefined): number { + 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(String(submode).toUpperCase()); + const normalized = Q65_SUBMODES.get(submode.toUpperCase()); if (normalized === undefined) { throw new WSJTXError('q65Submode must be A-E or 0..4', 'INVALID'); } From 3cffb9f78bcc28aa38625775391a5dedffa5553c Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:55:54 +0800 Subject: [PATCH 17/47] Add official Q65 sample decode probe --- scripts/decode-official-q65-samples.mjs | 176 ++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 scripts/decode-official-q65-samples.mjs diff --git a/scripts/decode-official-q65-samples.mjs b/scripts/decode-official-q65-samples.mjs new file mode 100644 index 0000000..3bdfc6d --- /dev/null +++ b/scripts/decode-official-q65-samples.mjs @@ -0,0 +1,176 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import https from 'node:https'; +import { WSJTXLib, WSJTXMode } from '../dist/src/index.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const cacheDir = join(root, '.cache', 'wsjtx-official-q65-samples'); + +const samples = [ + { + name: 'Q65-30A ionoscatter 6m', + path: 'samples/Q65/30A_Ionoscatter_6m/201203_022700.wav', + q65Period: 30, + q65Submode: 'A', + }, + { + name: 'Q65-60A EME 6m', + path: 'samples/Q65/60A_EME_6m/210106_1621.wav', + q65Period: 60, + q65Submode: 'A', + }, + { + name: 'Q65-60B 1296 troposcatter', + path: 'samples/Q65/60B_1296_Troposcatter/210109_0007.wav', + q65Period: 60, + q65Submode: 'B', + }, + { + name: 'Q65-120E ionoscatter 6m', + path: 'samples/Q65/120E_Ionoscatter_6m/210130_1438.wav', + q65Period: 120, + q65Submode: 'E', + }, +]; + +function download(url) { + return new Promise((resolve, reject) => { + https.get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + download(new URL(res.headers.location, url).toString()).then(resolve, reject); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`GET ${url} failed with HTTP ${res.statusCode}`)); + res.resume(); + return; + } + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks))); + }).on('error', reject); + }); +} + +async function getSample(path) { + await mkdir(cacheDir, { recursive: true }); + const local = join(cacheDir, path.replaceAll('/', '__')); + if (existsSync(local)) return readFile(local); + const url = `https://raw.githubusercontent.com/WSJTX/wsjtx/master/${path}`; + const data = await download(url); + await writeFile(local, data); + return data; +} + +function readAscii(buffer, offset, length) { + return buffer.subarray(offset, offset + length).toString('ascii'); +} + +function parseWav(buffer) { + if (readAscii(buffer, 0, 4) !== 'RIFF' || readAscii(buffer, 8, 4) !== 'WAVE') { + throw new Error('Not a RIFF/WAVE file'); + } + + let offset = 12; + let fmt = null; + let dataOffset = -1; + let dataLength = 0; + while (offset + 8 <= buffer.length) { + const chunkId = readAscii(buffer, offset, 4); + const chunkSize = buffer.readUInt32LE(offset + 4); + const chunkData = offset + 8; + if (chunkId === 'fmt ') { + fmt = { + audioFormat: buffer.readUInt16LE(chunkData), + channels: buffer.readUInt16LE(chunkData + 2), + sampleRate: buffer.readUInt32LE(chunkData + 4), + bitsPerSample: buffer.readUInt16LE(chunkData + 14), + }; + } else if (chunkId === 'data') { + dataOffset = chunkData; + dataLength = chunkSize; + } + offset = chunkData + chunkSize + (chunkSize % 2); + } + + if (!fmt) throw new Error('Missing fmt chunk'); + if (dataOffset < 0) throw new Error('Missing data chunk'); + if (fmt.channels !== 1) throw new Error(`Expected mono WAV, got ${fmt.channels} channels`); + + const view = new DataView(buffer.buffer, buffer.byteOffset + dataOffset, dataLength); + if (fmt.audioFormat === 1 && fmt.bitsPerSample === 16) { + const audio = new Int16Array(dataLength / 2); + for (let i = 0; i < audio.length; i++) audio[i] = view.getInt16(i * 2, true); + return { audio, sampleRate: fmt.sampleRate, format: 'pcm_s16le' }; + } + if (fmt.audioFormat === 3 && fmt.bitsPerSample === 32) { + const audio = new Float32Array(dataLength / 4); + for (let i = 0; i < audio.length; i++) audio[i] = view.getFloat32(i * 4, true); + return { audio, sampleRate: fmt.sampleRate, format: 'float32le' }; + } + + throw new Error(`Unsupported WAV format: audioFormat=${fmt.audioFormat}, bitsPerSample=${fmt.bitsPerSample}`); +} + +const lib = new WSJTXLib({ maxThreads: 4 }); +let failures = 0; + +for (const sample of samples) { + const buffer = await getSample(sample.path); + const { audio, sampleRate, format } = parseWav(buffer); + if (sampleRate !== 12000) { + throw new Error(`${sample.name}: expected 12000 Hz sample rate, got ${sampleRate}`); + } + + const result = await lib.decode(WSJTXMode.Q65, audio, { + frequency: 1500, + txFrequency: 1500, + threads: 4, + lowFreq: 0, + highFreq: 5000, + tolerance: 5000, + decodeDepth: 3, + q65Period: sample.q65Period, + q65Submode: sample.q65Submode, + q65MaxDrift: 100, + q65ClearAveraging: true, + q65SingleDecode: false, + q65Averaging: true, + }); + + const messages = result.messages.map((m) => ({ + text: m.text.trim(), + snr: m.snr, + dt: m.deltaTime, + freq: m.deltaFrequency, + sync: m.sync, + })); + + console.log(JSON.stringify({ + sample: sample.name, + path: sample.path, + wav: { sampleRate, samples: audio.length, seconds: audio.length / sampleRate, format }, + options: { + q65Period: sample.q65Period, + q65Submode: sample.q65Submode, + lowFreq: 0, + highFreq: 5000, + tolerance: 5000, + decodeDepth: 3, + q65MaxDrift: 100, + q65Averaging: true, + }, + decodedCount: messages.length, + messages, + })); + + if (messages.length === 0) failures++; +} + +if (failures > 0) { + throw new Error(`${failures} official Q65 sample(s) produced zero decodes`); +} From e8f7cff193cc6c84a5f7658157e191a5798f88ec Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 15:56:36 +0800 Subject: [PATCH 18/47] Add official Q65 sample decode workflow --- .github/workflows/q65-official-samples.yml | 57 ++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/q65-official-samples.yml diff --git a/.github/workflows/q65-official-samples.yml b/.github/workflows/q65-official-samples.yml new file mode 100644 index 0000000..52bce2e --- /dev/null +++ b/.github/workflows/q65-official-samples.yml @@ -0,0 +1,57 @@ +name: Q65 Official Sample Decode + +on: + workflow_dispatch: + push: + branches: [feature/q65-chain] + paths: + - 'scripts/decode-official-q65-samples.mjs' + - '.github/workflows/q65-official-samples.yml' + - 'src/**' + - 'native/**' + - 'cmake/**' + - 'CMakeLists.txt' + - 'package*.json' + - 'tsconfig.json' + +env: + NODE_VERSION: '20' + +jobs: + decode-official-q65-samples: + name: Decode official WSJT-X Q65 WAV samples + 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: Decode official WSJT-X Q65 samples + run: node scripts/decode-official-q65-samples.mjs + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: official-q65-samples-cache + path: .cache/wsjtx-official-q65-samples/ + retention-days: 7 + if-no-files-found: ignore From 829e23b6f13ccbe049215411b629dce5bdd28b5d Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 16:03:20 +0800 Subject: [PATCH 19/47] Diagnose official Q65 sample decode parameters --- scripts/decode-official-q65-samples.mjs | 105 +++++++++++------------- 1 file changed, 47 insertions(+), 58 deletions(-) diff --git a/scripts/decode-official-q65-samples.mjs b/scripts/decode-official-q65-samples.mjs index 3bdfc6d..61205d3 100644 --- a/scripts/decode-official-q65-samples.mjs +++ b/scripts/decode-official-q65-samples.mjs @@ -1,6 +1,6 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { dirname, join, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; import https from 'node:https'; import { WSJTXLib, WSJTXMode } from '../dist/src/index.js'; @@ -10,30 +10,10 @@ const root = join(__dirname, '..'); const cacheDir = join(root, '.cache', 'wsjtx-official-q65-samples'); const samples = [ - { - name: 'Q65-30A ionoscatter 6m', - path: 'samples/Q65/30A_Ionoscatter_6m/201203_022700.wav', - q65Period: 30, - q65Submode: 'A', - }, - { - name: 'Q65-60A EME 6m', - path: 'samples/Q65/60A_EME_6m/210106_1621.wav', - q65Period: 60, - q65Submode: 'A', - }, - { - name: 'Q65-60B 1296 troposcatter', - path: 'samples/Q65/60B_1296_Troposcatter/210109_0007.wav', - q65Period: 60, - q65Submode: 'B', - }, - { - name: 'Q65-120E ionoscatter 6m', - path: 'samples/Q65/120E_Ionoscatter_6m/210130_1438.wav', - q65Period: 120, - q65Submode: 'E', - }, + { name: 'Q65-30A ionoscatter 6m', path: 'samples/Q65/30A_Ionoscatter_6m/201203_022700.wav', q65Period: 30, q65Submode: 'A' }, + { name: 'Q65-60A EME 6m', path: 'samples/Q65/60A_EME_6m/210106_1621.wav', q65Period: 60, q65Submode: 'A' }, + { name: 'Q65-60B 1296 troposcatter', path: 'samples/Q65/60B_1296_Troposcatter/210109_0007.wav', q65Period: 60, q65Submode: 'B' }, + { name: 'Q65-120E ionoscatter 6m', path: 'samples/Q65/120E_Ionoscatter_6m/210130_1438.wav', q65Period: 120, q65Submode: 'E' }, ]; function download(url) { @@ -74,7 +54,6 @@ function parseWav(buffer) { if (readAscii(buffer, 0, 4) !== 'RIFF' || readAscii(buffer, 8, 4) !== 'WAVE') { throw new Error('Not a RIFF/WAVE file'); } - let offset = 12; let fmt = null; let dataOffset = -1; @@ -96,11 +75,9 @@ function parseWav(buffer) { } offset = chunkData + chunkSize + (chunkSize % 2); } - if (!fmt) throw new Error('Missing fmt chunk'); if (dataOffset < 0) throw new Error('Missing data chunk'); if (fmt.channels !== 1) throw new Error(`Expected mono WAV, got ${fmt.channels} channels`); - const view = new DataView(buffer.buffer, buffer.byteOffset + dataOffset, dataLength); if (fmt.audioFormat === 1 && fmt.bitsPerSample === 16) { const audio = new Int16Array(dataLength / 2); @@ -112,10 +89,26 @@ function parseWav(buffer) { for (let i = 0; i < audio.length; i++) audio[i] = view.getFloat32(i * 4, true); return { audio, sampleRate: fmt.sampleRate, format: 'float32le' }; } - throw new Error(`Unsupported WAV format: audioFormat=${fmt.audioFormat}, bitsPerSample=${fmt.bitsPerSample}`); } +function utcFromSamplePath(path) { + const stem = basename(path, '.wav').split('_').at(-1) ?? ''; + if (/^\d{6}$/.test(stem)) return Number(stem); + if (/^\d{4}$/.test(stem)) return Number(`${stem}00`); + return null; +} + +function summarizeMessages(result) { + return result.messages.map((m) => ({ + text: m.text.trim(), + snr: m.snr, + dt: m.deltaTime, + freq: m.deltaFrequency, + sync: m.sync, + })); +} + const lib = new WSJTXLib({ maxThreads: 4 }); let failures = 0; @@ -126,51 +119,47 @@ for (const sample of samples) { throw new Error(`${sample.name}: expected 12000 Hz sample rate, got ${sampleRate}`); } - const result = await lib.decode(WSJTXMode.Q65, audio, { - frequency: 1500, - txFrequency: 1500, + const base = { threads: 4, lowFreq: 0, highFreq: 5000, - tolerance: 5000, decodeDepth: 3, q65Period: sample.q65Period, q65Submode: sample.q65Submode, q65MaxDrift: 100, q65ClearAveraging: true, - q65SingleDecode: false, - q65Averaging: true, - }); - - const messages = result.messages.map((m) => ({ - text: m.text.trim(), - snr: m.snr, - dt: m.deltaTime, - freq: m.deltaFrequency, - sync: m.sync, - })); + }; + + const probes = [ + { label: 'wide-noavg', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65Averaging: false, q65SingleDecode: false }, + { label: 'wide-avg', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65Averaging: true, q65SingleDecode: false }, + { label: 'wide-single', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65Averaging: false, q65SingleDecode: true }, + { label: 'center-1000', frequency: 1000, txFrequency: 1000, tolerance: 1000, q65Averaging: false, q65SingleDecode: false }, + { label: 'center-1500', frequency: 1500, txFrequency: 1500, tolerance: 1000, q65Averaging: false, q65SingleDecode: false }, + { label: 'center-2000', frequency: 2000, txFrequency: 2000, tolerance: 1000, q65Averaging: false, q65SingleDecode: false }, + ]; + + const probeResults = []; + let bestMessages = []; + for (const probe of probes) { + const result = await lib.decode(WSJTXMode.Q65, audio, { ...base, ...probe }); + const messages = summarizeMessages(result); + probeResults.push({ label: probe.label, options: probe, decodedCount: messages.length, messages }); + if (messages.length > bestMessages.length) bestMessages = messages; + } console.log(JSON.stringify({ sample: sample.name, path: sample.path, + sampleUtcFromName: utcFromSamplePath(sample.path), wav: { sampleRate, samples: audio.length, seconds: audio.length / sampleRate, format }, - options: { - q65Period: sample.q65Period, - q65Submode: sample.q65Submode, - lowFreq: 0, - highFreq: 5000, - tolerance: 5000, - decodeDepth: 3, - q65MaxDrift: 100, - q65Averaging: true, - }, - decodedCount: messages.length, - messages, + q65: { period: sample.q65Period, submode: sample.q65Submode }, + probes: probeResults, })); - if (messages.length === 0) failures++; + if (bestMessages.length === 0) failures++; } if (failures > 0) { - throw new Error(`${failures} official Q65 sample(s) produced zero decodes`); + throw new Error(`${failures} official Q65 sample(s) produced zero decodes across all probes`); } From a94af330c9820e54c7521ef92fd97d779f4a9b45 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 16:10:52 +0800 Subject: [PATCH 20/47] Expose decode UTC option for disk samples --- src/types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/types.ts b/src/types.ts index 1bd9739..0829c7a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,6 +53,9 @@ export interface EncodeOptions extends Q65EncodeOptions { * * - frequency: nominal QSO frequency in Hz (decoder uses this as nfqso). * - txFrequency: transmit audio offset in Hz (decoder uses this as nftx). + * - utc: optional HHMMSS timestamp used as params.nutc. This is useful for + * disk/WAV regression samples whose capture time is encoded in the file name. + * Omit it for live decode; the native layer will use current local time. * - threads: thread hint forwarded to the decoder. Defaults to maxThreads. * - myCall / myGrid / dxCall / dxGrid: AP decode context for the named station. * - lowFreq / highFreq / tolerance: scan window and tone tolerance in Hz @@ -71,6 +74,7 @@ export interface EncodeOptions extends Q65EncodeOptions { export interface DecodeOptions extends Q65EncodeOptions { frequency: number; txFrequency?: number; + utc?: number; threads?: number; myCall?: string; myGrid?: string; From 50c582d489b1b3701a1ca14fd617745f65a006d0 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 16:11:46 +0800 Subject: [PATCH 21/47] Pass decode UTC option to native layer --- src/index.ts | 254 ++++++++++----------------------------------------- 1 file changed, 49 insertions(+), 205 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2097285..04d68e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,5 @@ /** * wsjtx-lib — Node.js binding for the WSJT-X 3.0.0 backend. - * - * Public surface: - * - 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/Q65 default encode rate: 12 kHz) */ import { @@ -22,7 +15,6 @@ import { type ModeCapabilities, type DecodeOptions, type EncodeOptions, - type Q65Period, type Q65Submode, } from './types.js'; import { createRequire } from 'node:module'; @@ -32,38 +24,16 @@ import path from 'node:path'; const require = createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); -interface NativeBinding { - WSJTXLib: new (config?: { encodeSampleRate?: number }) => NativeWSJTXLib; -} - +interface NativeBinding { WSJTXLib: new (config?: { encodeSampleRate?: number }) => NativeWSJTXLib; } interface NativeDecodeOptions { - frequency: number; - txFrequency: number; - threads: number; - lowFreq: number; - highFreq: number; - tolerance: number; - myCall: string; - myGrid: string; - dxCall: string; - dxGrid: string; - 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; + frequency: number; txFrequency: number; utc: number; threads: number; + lowFreq: number; highFreq: number; tolerance: number; + myCall: string; myGrid: string; dxCall: string; dxGrid: string; + 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, opts: NativeEncodeOptions, cb: (e: Error | null, r: EncodeResult) => void): void; @@ -82,29 +52,17 @@ function loadNativeBinding(): NativeBinding['WSJTXLib'] { } const NativeWSJTXLib = loadNativeBinding(); - const DEFAULT_CONFIG: Required = { - maxThreads: 4, - encodeSampleRate: 12000, - debug: false, - defaultLowFreq: 200, - defaultHighFreq: 4000, - defaultTolerance: 20, + maxThreads: 4, encodeSampleRate: 12000, debug: false, + defaultLowFreq: 200, defaultHighFreq: 4000, defaultTolerance: 20, }; - const FREQ_MIN = 0; 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], -]); +const Q65_SUBMODES = new Map([['A', 0], ['B', 1], ['C', 2], ['D', 3], ['E', 4]]); export class WSJTXLib { private readonly native: NativeWSJTXLib; @@ -122,9 +80,9 @@ export class WSJTXLib { 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 utc = options.utc ?? -1; + if (utc !== -1) this.validateUtc(utc); + 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'); @@ -134,20 +92,17 @@ export class WSJTXLib { const opts: NativeDecodeOptions = { frequency: options.frequency, txFrequency: options.txFrequency ?? options.frequency, + utc, threads, lowFreq: options.lowFreq ?? this.config.defaultLowFreq, highFreq: options.highFreq ?? this.config.defaultHighFreq, tolerance: options.tolerance ?? this.config.defaultTolerance, - myCall: options.myCall ?? '', - myGrid: options.myGrid ?? '', - dxCall: options.dxCall ?? '', - dxGrid: options.dxGrid ?? '', + myCall: options.myCall ?? '', myGrid: options.myGrid ?? '', + dxCall: options.dxCall ?? '', dxGrid: options.dxGrid ?? '', apDecode: options.apDecode ?? true, decodeDepth: options.decodeDepth ?? 1, qsoProgress: options.qsoProgress ?? 0, - q65Period, - q65Submode, - q65MaxDrift, + q65Period, q65Submode, q65MaxDrift, q65ClearAveraging: options.q65ClearAveraging ?? false, q65SingleDecode: options.q65SingleDecode ?? false, q65Averaging: options.q65Averaging ?? false, @@ -155,82 +110,40 @@ export class WSJTXLib { return new Promise((resolve, reject) => { this.native.decode(mode, audioData, opts, (err, result) => { - if (err) reject(new WSJTXError(err.message, 'DECODE_ERROR')); - else resolve(result); + if (err) reject(new WSJTXError(err.message, 'DECODE_ERROR')); else resolve(result); }); }); } - async encode( - mode: WSJTXMode, - message: string, - frequency: number, - threadsOrOptions: number | EncodeOptions = this.config.maxThreads, - ): Promise { - this.validateMode(mode); - this.validateMessage(message); - this.validateFrequency(frequency); + async encode(mode: WSJTXMode, message: string, frequency: number, threadsOrOptions: number | EncodeOptions = this.config.maxThreads): Promise { + this.validateMode(mode); this.validateMessage(message); this.validateFrequency(frequency); const opts = this.normalizeEncodeOptions(threadsOrOptions); this.validateThreads(opts.threads); - if (!this.isEncodingSupported(mode)) { - throw new WSJTXError('Encoding not supported for this mode', 'UNSUPPORTED'); - } - + 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, opts, (err, result) => { - if (err) reject(new WSJTXError(err.message, 'ENCODE_ERROR')); - else resolve(result); + if (err) reject(new WSJTXError(err.message, 'ENCODE_ERROR')); else resolve(result); }); }); } async decodeWSPR(audioData: Int16Array, options: WSPRDecodeOptions = {}): Promise { - if (!(audioData instanceof Int16Array) || audioData.length === 0) { - throw new WSJTXError('audioData must be a non-empty Int16Array', 'INVALID'); - } - - const opts = { - dialFrequency: 14_095_600, - callsign: '', - locator: '', - quickMode: false, - useHashTable: true, - passes: 2, - subtraction: true, - ...options, - }; - + if (!(audioData instanceof Int16Array) || audioData.length === 0) throw new WSJTXError('audioData must be a non-empty Int16Array', 'INVALID'); + const opts = { dialFrequency: 14_095_600, callsign: '', locator: '', quickMode: false, useHashTable: true, passes: 2, subtraction: true, ...options }; return new Promise((resolve, reject) => { this.native.decodeWSPR(audioData as unknown as Float32Array, opts, (err, results) => { - if (err) reject(new WSJTXError(err.message, 'WSPR_ERROR')); - else resolve(results); + if (err) reject(new WSJTXError(err.message, 'WSPR_ERROR')); else resolve(results); }); }); } - pullMessages(): WSJTXMessage[] { - return this.native.pullMessages(); - } - - isEncodingSupported(mode: WSJTXMode): boolean { - return this.native.isEncodingSupported(mode); - } - - isDecodingSupported(mode: WSJTXMode): boolean { - return this.native.isDecodingSupported(mode); - } - - getSampleRate(mode: WSJTXMode): number { - return this.native.getSampleRate(mode); - } - - getTransmissionDuration(mode: WSJTXMode): number { - return this.native.getTransmissionDuration(mode); - } - + pullMessages(): WSJTXMessage[] { return this.native.pullMessages(); } + isEncodingSupported(mode: WSJTXMode): boolean { return this.native.isEncodingSupported(mode); } + isDecodingSupported(mode: WSJTXMode): boolean { return this.native.isDecodingSupported(mode); } + getSampleRate(mode: WSJTXMode): number { return this.native.getSampleRate(mode); } + getTransmissionDuration(mode: WSJTXMode): number { return this.native.getTransmissionDuration(mode); } getAllModeCapabilities(): ModeCapabilities[] { - const numericModes = Object.values(WSJTXMode).filter((v): v is number => typeof v === 'number'); - return numericModes.map((mode) => ({ + return Object.values(WSJTXMode).filter((v): v is number => typeof v === 'number').map((mode) => ({ mode: mode as WSJTXMode, encodingSupported: this.isEncodingSupported(mode as WSJTXMode), decodingSupported: this.isDecodingSupported(mode as WSJTXMode), @@ -238,103 +151,34 @@ export class WSJTXLib { duration: this.getTransmissionDuration(mode as WSJTXMode), })); } - async convertAudioFormat(audioData: AudioData, targetFormat: 'float32' | 'int16'): Promise { - return new Promise((resolve, reject) => { - this.native.convertAudioFormat(audioData, targetFormat, (err, result) => { - if (err) reject(err); - else resolve(result); - }); - }); + return new Promise((resolve, reject) => this.native.convertAudioFormat(audioData, targetFormat, (err, result) => err ? reject(err) : resolve(result))); } 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'); + return { threads: options.threads ?? this.config.maxThreads, 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'); } + private validateFrequency(freq: number): void { if (!Number.isInteger(freq) || freq < FREQ_MIN || freq > FREQ_MAX) throw new WSJTXError('Invalid frequency', 'INVALID'); } + private validateEncodeSampleRate(sampleRate: number): void { if (sampleRate !== 12000 && sampleRate !== 48000) throw new WSJTXError('encodeSampleRate must be 12000 or 48000', 'INVALID'); } + private validateThreads(threads: number): void { if (!Number.isInteger(threads) || threads < THREADS_MIN || threads > THREADS_MAX) throw new WSJTXError(`Threads must be ${THREADS_MIN}..${THREADS_MAX}`, 'INVALID'); } + private validateMessage(message: string): void { if (typeof message !== 'string' || message.length === 0 || message.length > MESSAGE_MAX_LEN) throw new WSJTXError(`Message must be 1..${MESSAGE_MAX_LEN} characters`, 'INVALID'); } + private validateAudio(audio: AudioData): void { if (!(audio instanceof Float32Array || audio instanceof Int16Array) || audio.length === 0) throw new WSJTXError('audioData must be a non-empty Float32Array or Int16Array', 'INVALID'); } + private validateUtc(utc: number): void { + if (!Number.isInteger(utc) || utc < 0 || utc > 235959 || Math.floor(utc / 10000) > 23 || Math.floor((utc % 10000) / 100) > 59 || utc % 100 > 59) { + throw new WSJTXError('utc must be HHMMSS in the range 000000..235959', 'INVALID'); } } - - private validateFrequency(freq: number): void { - if (!Number.isInteger(freq) || freq < FREQ_MIN || freq > FREQ_MAX) { - throw new WSJTXError('Invalid frequency', 'INVALID'); - } - } - - private validateEncodeSampleRate(sampleRate: number): void { - if (sampleRate !== 12000 && sampleRate !== 48000) { - throw new WSJTXError('encodeSampleRate must be 12000 or 48000', 'INVALID'); - } - } - - private validateThreads(threads: number): void { - if (!Number.isInteger(threads) || threads < THREADS_MIN || threads > THREADS_MAX) { - throw new WSJTXError(`Threads must be ${THREADS_MIN}..${THREADS_MAX}`, 'INVALID'); - } - } - - private validateMessage(message: string): void { - if (typeof message !== 'string' || message.length === 0 || message.length > MESSAGE_MAX_LEN) { - throw new WSJTXError(`Message must be 1..${MESSAGE_MAX_LEN} characters`, 'INVALID'); - } - } - - private validateAudio(audio: AudioData): void { - const isTyped = audio instanceof Float32Array || audio instanceof Int16Array; - if (!isTyped || audio.length === 0) { - 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 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; - } + 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'); - } + 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'); - } - } + 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, - AudioData, - WSJTXConfig, - DecodeOptions, - ModeCapabilities, -}; +export type { DecodeResult, EncodeResult, EncodeOptions, Q65Submode, WSPRResult, WSPRDecodeOptions, WSJTXMessage, AudioData, WSJTXConfig, DecodeOptions, ModeCapabilities }; From 63ce2d3f5ff162fa8598ff234c0b2db7d2311b7c Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 16:12:37 +0800 Subject: [PATCH 22/47] Add UTC field to decode C ABI --- native/wsjtx_c_api.h | 51 +------------------------------------------- 1 file changed, 1 insertion(+), 50 deletions(-) diff --git a/native/wsjtx_c_api.h b/native/wsjtx_c_api.h index 5cf5d7f..b1f0520 100644 --- a/native/wsjtx_c_api.h +++ b/native/wsjtx_c_api.h @@ -1,12 +1,5 @@ /** * 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 @@ -29,10 +22,8 @@ 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 @@ -41,7 +32,6 @@ typedef void* wsjtx_handle_t; #define WSJTX_ERR_INVALID_SAMPLE_RATE -5 #define WSJTX_ERR_EXCEPTION -99 -/* Mode enumeration (must match wsjtxMode in wsjtx_lib.h) */ typedef enum { WSJTX_MODE_FT8 = 0, WSJTX_MODE_FT4 = 1, @@ -55,7 +45,6 @@ typedef enum { WSJTX_MODE_WSPR = 9 } wsjtx_mode_t; -/* Decoded message (C-compatible version of WsjtxMessage) */ typedef struct { int hh; int min; @@ -67,7 +56,6 @@ typedef struct { char msg[64]; } wsjtx_message_t; -/* WSPR decoder options (C-compatible version of decoder_options) */ typedef struct { int freq; char rcall[13]; @@ -78,7 +66,6 @@ typedef struct { int subtraction; } wsjtx_decoder_options_t; -/* WSPR decoder result (C-compatible version of decoder_results) */ typedef struct { double freq; float sync; @@ -93,40 +80,16 @@ 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) - * - threads: thread hint forwarded to the decoder (1..N) - * - low_freq: decoder scan low limit in Hz (default 200) - * - high_freq: decoder scan high limit in Hz (default 4000) - * - tolerance: frequency tolerance in Hz (default 20) - * - mycall: local callsign for AP decode (empty = none) - * - mygrid: local grid for AP decode (empty = none) - * - 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 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; int tx_frequency; + int utc; /* HHMMSS, or -1 to use current local time */ int threads; int low_freq; int high_freq; @@ -146,13 +109,9 @@ typedef struct { char hisgrid[7]; } wsjtx_decode_options_t; -/* ---- Lifecycle ---- */ - WSJTX_API wsjtx_handle_t wsjtx_create(void); WSJTX_API void wsjtx_destroy(wsjtx_handle_t handle); -/* ---- Decode ---- */ - WSJTX_API int wsjtx_decode_float(wsjtx_handle_t handle, int mode, float* samples, int num_samples, int freq, int threads); @@ -167,8 +126,6 @@ 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 ---- */ - 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, @@ -179,22 +136,16 @@ WSJTX_API int wsjtx_encode_v2(wsjtx_handle_t handle, int mode, int freq, int sam float* out_samples, int* out_num_samples, int out_buf_size, char* out_message_sent, int out_msg_buf_size); -/* ---- Message queue ---- */ - WSJTX_API int wsjtx_pull_message(wsjtx_handle_t handle, wsjtx_message_t* out_msg); WSJTX_API int wsjtx_pull_messages(wsjtx_handle_t handle, wsjtx_message_t* out_messages, int max_messages); -/* ---- 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); -/* ---- 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); From 869dcff77bf01cd5032fff124bae08f45e43153c Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 16:13:30 +0800 Subject: [PATCH 23/47] Restore Q65Period type export --- src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 04d68e5..21f9dd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { type ModeCapabilities, type DecodeOptions, type EncodeOptions, + type Q65Period, type Q65Submode, } from './types.js'; import { createRequire } from 'node:module'; @@ -181,4 +182,4 @@ export class WSJTXLib { } export { WSJTXMode, WSJTXError }; -export type { DecodeResult, EncodeResult, EncodeOptions, Q65Submode, WSPRResult, WSPRDecodeOptions, WSJTXMessage, AudioData, WSJTXConfig, DecodeOptions, ModeCapabilities }; +export type { DecodeResult, EncodeResult, EncodeOptions, Q65Period, Q65Submode, WSPRResult, WSPRDecodeOptions, WSJTXMessage, AudioData, WSJTXConfig, DecodeOptions, ModeCapabilities }; From afe022a9d06789deec8b0cd410de6f76f2cd909d Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 16:14:56 +0800 Subject: [PATCH 24/47] Wire decode UTC through Q65 overlay --- cmake/patch-wsjtx-q65.cmake | 49 ++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/cmake/patch-wsjtx-q65.cmake b/cmake/patch-wsjtx-q65.cmake index ca08c10..1927b3c 100644 --- a/cmake/patch-wsjtx-q65.cmake +++ b/cmake/patch-wsjtx-q65.cmake @@ -20,6 +20,22 @@ function(wsjtx_replace_once file needle replacement description) endfunction() set(_WSJTX_LIB_DIR "${CMAKE_SOURCE_DIR}/wsjtx_lib") +set(_WSJTX_NATIVE_DIR "${CMAKE_SOURCE_DIR}/native") + +# ---- native wrapper/C ABI UTC plumbing ------------------------------------- +set(_wrapper_cpp "${_WSJTX_NATIVE_DIR}/wsjtx_wrapper.cpp") +wsjtx_replace_once( + "${_wrapper_cpp}" + " opts.tx_frequency = getOptionalInt(optObj, \"txFrequency\", opts.frequency);" + " opts.tx_frequency = getOptionalInt(optObj, \"txFrequency\", opts.frequency);\n opts.utc = getOptionalInt(optObj, \"utc\", -1);" + "forward optional decode UTC from N-API wrapper") + +set(_c_api_cpp "${_WSJTX_NATIVE_DIR}/wsjtx_c_api.cpp") +wsjtx_replace_once( + "${_c_api_cpp}" + " lib->setDecodeRange(opts->low_freq, opts->high_freq, opts->tolerance);" + " lib->setDecodeRange(opts->low_freq, opts->high_freq, opts->tolerance);\n lib->setDecodeUtc(opts->utc);" + "forward optional decode UTC through C ABI") # ---- wsjtx_encode.h -------------------------------------------------------- set(_encode_h "${_WSJTX_LIB_DIR}/wsjtx_encode.h") @@ -93,26 +109,26 @@ wsjtx_replace_once( 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::setDecodeQ65Controls") + "\tvoid setDecodeControls(bool apDecode, int decodeDepth, int txFrequency, int qsoProgress);\n\tvoid setDecodeUtc(int utc);\n\tvoid setDecodeQ65Controls(int period, int submode, int maxDrift, bool clearAveraging, bool singleDecode, bool averaging);" + "declare wsjtx_lib::setDecodeUtc and Q65 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") + "\tint qso_progress_ = 0;\n\tint decode_utc_ = -1;\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 UTC/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::setDecodeQ65Controls") + "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::setDecodeUtc(int utc)\n{\n\tdecode_utc_ = utc;\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 UTC/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") + "\tptr->setDecodeControls(ap_decode_, decode_depth_, tx_frequency_, qso_progress_);\n\tptr->setDecodeUtc(decode_utc_);\n\tptr->setDecodeQ65Controls(q65_period_, q65_submode_, q65_max_drift_, q65_clear_averaging_, q65_single_decode_, q65_averaging_);" + "forward UTC/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)" @@ -129,13 +145,13 @@ 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::setDecodeQ65Controls") + "\tvoid setDecodeControls(bool apDecode, int decodeDepth, int txFrequency, int qsoProgress);\n\tvoid setDecodeUtc(int utc);\n\tvoid setDecodeQ65Controls(int period, int submode, int maxDrift, bool clearAveraging, bool singleDecode, bool averaging);" + "declare wstjx_decode UTC/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") + "\tint qso_progress_ = 0;\n\tint decode_utc_ = -1;\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 UTC/Q65 state") # ---- wsjtx_decode.cpp ------------------------------------------------------ set(_decode_cpp "${_WSJTX_LIB_DIR}/wsjtx_decode.cpp") @@ -147,8 +163,8 @@ wsjtx_replace_once( 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::setDecodeQ65Controls") + "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::setDecodeUtc(int utc) {\n\tdecode_utc_ = utc;\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 UTC/Q65 controls") set(_q65_switch [=[ case FT8: params.nmode = 8; break; case FT4: params.nmode = 5; break; @@ -170,6 +186,11 @@ wsjtx_replace_once( "\tcase FT8: params.nmode = 8; break;\n\tcase FT4: params.nmode = 5; break;\n\tdefault: return;" "${_q65_switch}" "select parameterized Q65 decoder mode") +wsjtx_replace_once( + "${_decode_cpp}" + "\tparams.nutc = local_tm.tm_hour * 10000 + local_tm.tm_min * 100 + local_tm.tm_sec;" + "\tparams.nutc = decode_utc_ >= 0 ? decode_utc_ : (local_tm.tm_hour * 10000 + local_tm.tm_min * 100 + local_tm.tm_sec);" + "use provided decode UTC when present") # ---- lib/decode_callbacks.f90 --------------------------------------------- set(_callbacks_f90 "${_WSJTX_LIB_DIR}/lib/decode_callbacks.f90") From 3d1a6281726414db996c334bc1c7643bb7696960 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 16:19:16 +0800 Subject: [PATCH 25/47] Pass sample UTC and add Q65 self-roundtrip probe --- scripts/decode-official-q65-samples.mjs | 77 ++++++++++++++++++++----- 1 file changed, 62 insertions(+), 15 deletions(-) diff --git a/scripts/decode-official-q65-samples.mjs b/scripts/decode-official-q65-samples.mjs index 61205d3..d684045 100644 --- a/scripts/decode-official-q65-samples.mjs +++ b/scripts/decode-official-q65-samples.mjs @@ -110,22 +110,16 @@ function summarizeMessages(result) { } const lib = new WSJTXLib({ maxThreads: 4 }); -let failures = 0; - -for (const sample of samples) { - const buffer = await getSample(sample.path); - const { audio, sampleRate, format } = parseWav(buffer); - if (sampleRate !== 12000) { - throw new Error(`${sample.name}: expected 12000 Hz sample rate, got ${sampleRate}`); - } +async function probeDecode({ audio, q65Period, q65Submode, utc }) { const base = { threads: 4, + utc, lowFreq: 0, highFreq: 5000, decodeDepth: 3, - q65Period: sample.q65Period, - q65Submode: sample.q65Submode, + q65Period, + q65Submode, q65MaxDrift: 100, q65ClearAveraging: true, }; @@ -144,22 +138,75 @@ for (const sample of samples) { for (const probe of probes) { const result = await lib.decode(WSJTXMode.Q65, audio, { ...base, ...probe }); const messages = summarizeMessages(result); - probeResults.push({ label: probe.label, options: probe, decodedCount: messages.length, messages }); + probeResults.push({ label: probe.label, options: { ...probe, utc }, decodedCount: messages.length, messages }); if (messages.length > bestMessages.length) bestMessages = messages; } + return { probeResults, bestMessages }; +} + +const selfCases = [ + { name: 'self-Q65-30A', q65Period: 30, q65Submode: 'A', message: 'CQ K1ABC FN20', utc: 120000 }, + { name: 'self-Q65-60A', q65Period: 60, q65Submode: 'A', message: 'CQ K1ABC FN20', utc: 120000 }, + { name: 'self-Q65-60B', q65Period: 60, q65Submode: 'B', message: 'CQ K1ABC FN20', utc: 120000 }, +]; + +let selfFailures = 0; +for (const sample of selfCases) { + const encoded = await lib.encode(WSJTXMode.Q65, sample.message, 1500, { + threads: 1, + q65Period: sample.q65Period, + q65Submode: sample.q65Submode, + }); + const { probeResults, bestMessages } = await probeDecode({ + audio: encoded.audioData, + q65Period: sample.q65Period, + q65Submode: sample.q65Submode, + utc: sample.utc, + }); + console.log(JSON.stringify({ + sample: sample.name, + generated: { + message: sample.message, + messageSent: encoded.messageSent.trim(), + sampleRate: encoded.sampleRate, + samples: encoded.audioData.length, + seconds: encoded.audioData.length / encoded.sampleRate, + }, + q65: { period: sample.q65Period, submode: sample.q65Submode }, + utc: sample.utc, + probes: probeResults, + })); + if (bestMessages.length === 0) selfFailures++; +} + +let officialFailures = 0; +for (const sample of samples) { + const buffer = await getSample(sample.path); + const { audio, sampleRate, format } = parseWav(buffer); + if (sampleRate !== 12000) { + throw new Error(`${sample.name}: expected 12000 Hz sample rate, got ${sampleRate}`); + } + + const utc = utcFromSamplePath(sample.path); + const { probeResults, bestMessages } = await probeDecode({ + audio, + q65Period: sample.q65Period, + q65Submode: sample.q65Submode, + utc, + }); console.log(JSON.stringify({ sample: sample.name, path: sample.path, - sampleUtcFromName: utcFromSamplePath(sample.path), + sampleUtcFromName: utc, wav: { sampleRate, samples: audio.length, seconds: audio.length / sampleRate, format }, q65: { period: sample.q65Period, submode: sample.q65Submode }, probes: probeResults, })); - if (bestMessages.length === 0) failures++; + if (bestMessages.length === 0) officialFailures++; } -if (failures > 0) { - throw new Error(`${failures} official Q65 sample(s) produced zero decodes across all probes`); +if (selfFailures > 0 || officialFailures > 0) { + throw new Error(`${selfFailures} self-generated Q65 sample(s) and ${officialFailures} official Q65 sample(s) produced zero decodes across all probes`); } From ca66ca761493a2f4a2dd58fbe6d7a41e75fcb20c Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 16:23:27 +0800 Subject: [PATCH 26/47] Probe Q65 decode with default drift and audio stats --- scripts/decode-official-q65-samples.mjs | 74 ++++++++++--------------- 1 file changed, 28 insertions(+), 46 deletions(-) diff --git a/scripts/decode-official-q65-samples.mjs b/scripts/decode-official-q65-samples.mjs index d684045..742ab7e 100644 --- a/scripts/decode-official-q65-samples.mjs +++ b/scripts/decode-official-q65-samples.mjs @@ -51,9 +51,7 @@ function readAscii(buffer, offset, length) { } function parseWav(buffer) { - if (readAscii(buffer, 0, 4) !== 'RIFF' || readAscii(buffer, 8, 4) !== 'WAVE') { - throw new Error('Not a RIFF/WAVE file'); - } + if (readAscii(buffer, 0, 4) !== 'RIFF' || readAscii(buffer, 8, 4) !== 'WAVE') throw new Error('Not a RIFF/WAVE file'); let offset = 12; let fmt = null; let dataOffset = -1; @@ -99,14 +97,21 @@ function utcFromSamplePath(path) { return null; } +function audioStats(audio) { + let peak = 0; + let sumSq = 0; + let nonzero = 0; + for (const v of audio) { + const x = Math.abs(v); + if (x > peak) peak = x; + if (x > 0) nonzero++; + sumSq += Number(v) * Number(v); + } + return { peak, rms: Math.sqrt(sumSq / audio.length), nonzero }; +} + function summarizeMessages(result) { - return result.messages.map((m) => ({ - text: m.text.trim(), - snr: m.snr, - dt: m.deltaTime, - freq: m.deltaFrequency, - sync: m.sync, - })); + return result.messages.map((m) => ({ text: m.text.trim(), snr: m.snr, dt: m.deltaTime, freq: m.deltaFrequency, sync: m.sync })); } const lib = new WSJTXLib({ maxThreads: 4 }); @@ -120,17 +125,17 @@ async function probeDecode({ audio, q65Period, q65Submode, utc }) { decodeDepth: 3, q65Period, q65Submode, - q65MaxDrift: 100, q65ClearAveraging: true, }; const probes = [ - { label: 'wide-noavg', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65Averaging: false, q65SingleDecode: false }, - { label: 'wide-avg', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65Averaging: true, q65SingleDecode: false }, - { label: 'wide-single', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65Averaging: false, q65SingleDecode: true }, - { label: 'center-1000', frequency: 1000, txFrequency: 1000, tolerance: 1000, q65Averaging: false, q65SingleDecode: false }, - { label: 'center-1500', frequency: 1500, txFrequency: 1500, tolerance: 1000, q65Averaging: false, q65SingleDecode: false }, - { label: 'center-2000', frequency: 2000, txFrequency: 2000, tolerance: 1000, q65Averaging: false, q65SingleDecode: false }, + { label: 'wide-default', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65MaxDrift: 50, q65Averaging: false, q65SingleDecode: false }, + { label: 'wide-avg', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65MaxDrift: 50, q65Averaging: true, q65SingleDecode: false }, + { label: 'wide-single', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65MaxDrift: 50, q65Averaging: false, q65SingleDecode: true }, + { label: 'center-1000', frequency: 1000, txFrequency: 1000, tolerance: 1000, q65MaxDrift: 50, q65Averaging: false, q65SingleDecode: false }, + { label: 'center-1500', frequency: 1500, txFrequency: 1500, tolerance: 1000, q65MaxDrift: 50, q65Averaging: false, q65SingleDecode: false }, + { label: 'center-2000', frequency: 2000, txFrequency: 2000, tolerance: 1000, q65MaxDrift: 50, q65Averaging: false, q65SingleDecode: false }, + { label: 'wide-drift100', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65MaxDrift: 100, q65Averaging: false, q65SingleDecode: false }, ]; const probeResults = []; @@ -152,26 +157,11 @@ const selfCases = [ let selfFailures = 0; for (const sample of selfCases) { - const encoded = await lib.encode(WSJTXMode.Q65, sample.message, 1500, { - threads: 1, - q65Period: sample.q65Period, - q65Submode: sample.q65Submode, - }); - const { probeResults, bestMessages } = await probeDecode({ - audio: encoded.audioData, - q65Period: sample.q65Period, - q65Submode: sample.q65Submode, - utc: sample.utc, - }); + const encoded = await lib.encode(WSJTXMode.Q65, sample.message, 1500, { threads: 1, q65Period: sample.q65Period, q65Submode: sample.q65Submode }); + const { probeResults, bestMessages } = await probeDecode({ audio: encoded.audioData, q65Period: sample.q65Period, q65Submode: sample.q65Submode, utc: sample.utc }); console.log(JSON.stringify({ sample: sample.name, - generated: { - message: sample.message, - messageSent: encoded.messageSent.trim(), - sampleRate: encoded.sampleRate, - samples: encoded.audioData.length, - seconds: encoded.audioData.length / encoded.sampleRate, - }, + generated: { message: sample.message, messageSent: encoded.messageSent.trim(), sampleRate: encoded.sampleRate, samples: encoded.audioData.length, seconds: encoded.audioData.length / encoded.sampleRate, stats: audioStats(encoded.audioData) }, q65: { period: sample.q65Period, submode: sample.q65Submode }, utc: sample.utc, probes: probeResults, @@ -183,27 +173,19 @@ let officialFailures = 0; for (const sample of samples) { const buffer = await getSample(sample.path); const { audio, sampleRate, format } = parseWav(buffer); - if (sampleRate !== 12000) { - throw new Error(`${sample.name}: expected 12000 Hz sample rate, got ${sampleRate}`); - } + if (sampleRate !== 12000) throw new Error(`${sample.name}: expected 12000 Hz sample rate, got ${sampleRate}`); const utc = utcFromSamplePath(sample.path); - const { probeResults, bestMessages } = await probeDecode({ - audio, - q65Period: sample.q65Period, - q65Submode: sample.q65Submode, - utc, - }); + const { probeResults, bestMessages } = await probeDecode({ audio, q65Period: sample.q65Period, q65Submode: sample.q65Submode, utc }); console.log(JSON.stringify({ sample: sample.name, path: sample.path, sampleUtcFromName: utc, - wav: { sampleRate, samples: audio.length, seconds: audio.length / sampleRate, format }, + wav: { sampleRate, samples: audio.length, seconds: audio.length / sampleRate, format, stats: audioStats(audio) }, q65: { period: sample.q65Period, submode: sample.q65Submode }, probes: probeResults, })); - if (bestMessages.length === 0) officialFailures++; } From 0201198e81f9f0b145fa923d9e681f9b725f9597 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 16:47:14 +0800 Subject: [PATCH 27/47] Expose raw decoder output and disk decode options --- src/types.ts | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/types.ts b/src/types.ts index 0829c7a..90d52c1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,23 +53,13 @@ export interface EncodeOptions extends Q65EncodeOptions { * * - frequency: nominal QSO frequency in Hz (decoder uses this as nfqso). * - txFrequency: transmit audio offset in Hz (decoder uses this as nftx). - * - utc: optional HHMMSS timestamp used as params.nutc. This is useful for - * disk/WAV regression samples whose capture time is encoded in the file name. - * Omit it for live decode; the native layer will use current local time. - * - threads: thread hint forwarded to the decoder. Defaults to maxThreads. - * - myCall / myGrid / dxCall / dxGrid: AP decode context for the named station. - * - lowFreq / highFreq / tolerance: scan window and tone tolerance in Hz - * (defaults: 200 / 4000 / 20). These are forwarded to the decoder via - * `setDecodeRange` and *do* take effect. - * - 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. + * - utc: optional HHMMSS timestamp used as params.nutc. Useful for disk/WAV + * regression samples whose capture time is encoded in the file name. + * - diskData: set params.ndiskdat. Defaults to true for Q65 and false for + * other modes, matching the WSJT-X disk-sample path for Q65 testing. + * - newData: set params.newdat. Defaults to true. + * - again: set params.nagain. Defaults to false. + * - captureRawOutput: return Fortran decoder output lines in `rawOutput`. */ export interface DecodeOptions extends Q65EncodeOptions { frequency: number; @@ -86,6 +76,10 @@ export interface DecodeOptions extends Q65EncodeOptions { apDecode?: boolean; decodeDepth?: number; qsoProgress?: number; + diskData?: boolean; + newData?: boolean; + again?: boolean; + captureRawOutput?: boolean; q65MaxDrift?: number; q65ClearAveraging?: boolean; q65SingleDecode?: boolean; @@ -95,6 +89,8 @@ export interface DecodeOptions extends Q65EncodeOptions { export interface DecodeResult { success: boolean; messages: WSJTXMessage[]; + /** Raw Fortran decoder lines captured during this decode call, when enabled. */ + rawOutput?: string[]; error?: string; } From aa6bd1b728a418a0ca294137e6bc110653e62c9b Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 16:56:01 +0800 Subject: [PATCH 28/47] Make Q65 sample probe action-friendly --- scripts/decode-official-q65-samples.mjs | 67 +++++++++++++++++++++---- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/scripts/decode-official-q65-samples.mjs b/scripts/decode-official-q65-samples.mjs index 742ab7e..edbb1e8 100644 --- a/scripts/decode-official-q65-samples.mjs +++ b/scripts/decode-official-q65-samples.mjs @@ -8,6 +8,9 @@ import { WSJTXLib, WSJTXMode } from '../dist/src/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); const cacheDir = join(root, '.cache', 'wsjtx-official-q65-samples'); +const reportPath = join(root, 'q65-diagnostic-report.json'); +const summaryPath = process.env.GITHUB_STEP_SUMMARY; +const requireDecode = process.env.Q65_REQUIRE_DECODE === '1' || process.env.Q65_REQUIRE_DECODE === 'true'; const samples = [ { name: 'Q65-30A ionoscatter 6m', path: 'samples/Q65/30A_Ionoscatter_6m/201203_022700.wav', q65Period: 30, q65Submode: 'A' }, @@ -155,21 +158,24 @@ const selfCases = [ { name: 'self-Q65-60B', q65Period: 60, q65Submode: 'B', message: 'CQ K1ABC FN20', utc: 120000 }, ]; -let selfFailures = 0; +const selfReports = []; for (const sample of selfCases) { const encoded = await lib.encode(WSJTXMode.Q65, sample.message, 1500, { threads: 1, q65Period: sample.q65Period, q65Submode: sample.q65Submode }); const { probeResults, bestMessages } = await probeDecode({ audio: encoded.audioData, q65Period: sample.q65Period, q65Submode: sample.q65Submode, utc: sample.utc }); - console.log(JSON.stringify({ + const entry = { + kind: 'self-generated', sample: sample.name, generated: { message: sample.message, messageSent: encoded.messageSent.trim(), sampleRate: encoded.sampleRate, samples: encoded.audioData.length, seconds: encoded.audioData.length / encoded.sampleRate, stats: audioStats(encoded.audioData) }, q65: { period: sample.q65Period, submode: sample.q65Submode }, utc: sample.utc, + bestDecodedCount: bestMessages.length, probes: probeResults, - })); - if (bestMessages.length === 0) selfFailures++; + }; + console.log(JSON.stringify(entry)); + selfReports.push(entry); } -let officialFailures = 0; +const officialReports = []; for (const sample of samples) { const buffer = await getSample(sample.path); const { audio, sampleRate, format } = parseWav(buffer); @@ -178,17 +184,60 @@ for (const sample of samples) { const utc = utcFromSamplePath(sample.path); const { probeResults, bestMessages } = await probeDecode({ audio, q65Period: sample.q65Period, q65Submode: sample.q65Submode, utc }); - console.log(JSON.stringify({ + const entry = { + kind: 'official-wsjtx-sample', sample: sample.name, path: sample.path, sampleUtcFromName: utc, wav: { sampleRate, samples: audio.length, seconds: audio.length / sampleRate, format, stats: audioStats(audio) }, q65: { period: sample.q65Period, submode: sample.q65Submode }, + bestDecodedCount: bestMessages.length, probes: probeResults, - })); - if (bestMessages.length === 0) officialFailures++; + }; + console.log(JSON.stringify(entry)); + officialReports.push(entry); } -if (selfFailures > 0 || officialFailures > 0) { +const selfFailures = selfReports.filter((r) => r.bestDecodedCount === 0).length; +const officialFailures = officialReports.filter((r) => r.bestDecodedCount === 0).length; +const report = { + generatedAt: new Date().toISOString(), + requireDecode, + summary: { + selfTotal: selfReports.length, + selfZeroDecode: selfFailures, + officialTotal: officialReports.length, + officialZeroDecode: officialFailures, + }, + self: selfReports, + official: officialReports, +}; +await writeFile(reportPath, JSON.stringify(report, null, 2)); + +const markdown = [ + '# Q65 diagnostic report', + '', + `- Self-generated samples: ${selfReports.length - selfFailures}/${selfReports.length} produced at least one decode`, + `- Official WSJT-X samples: ${officialReports.length - officialFailures}/${officialReports.length} produced at least one decode`, + `- Strict failure mode: ${requireDecode ? 'enabled' : 'disabled'}`, + '', + '## Self-generated', + '| sample | period | submode | seconds | peak | rms | decoded |', + '|---|---:|---|---:|---:|---:|---:|', + ...selfReports.map((r) => `| ${r.sample} | ${r.q65.period} | ${r.q65.submode} | ${r.generated.seconds} | ${r.generated.stats.peak} | ${r.generated.stats.rms.toFixed(6)} | ${r.bestDecodedCount} |`), + '', + '## Official WSJT-X samples', + '| sample | period | submode | seconds | peak | rms | decoded |', + '|---|---:|---|---:|---:|---:|---:|', + ...officialReports.map((r) => `| ${r.sample} | ${r.q65.period} | ${r.q65.submode} | ${r.wav.seconds} | ${r.wav.stats.peak} | ${r.wav.stats.rms.toFixed(6)} | ${r.bestDecodedCount} |`), + '', + `Full JSON report: \`${reportPath}\``, + '', +].join('\n'); + +if (summaryPath) await writeFile(summaryPath, markdown, { flag: 'a' }); +console.log(JSON.stringify(report.summary)); + +if (requireDecode && (selfFailures > 0 || officialFailures > 0)) { throw new Error(`${selfFailures} self-generated Q65 sample(s) and ${officialFailures} official Q65 sample(s) produced zero decodes across all probes`); } From 8e0e9bc5583f5ab9eda3247601bbde6568327590 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 16:56:21 +0800 Subject: [PATCH 29/47] Package Q65 diagnostic report as workflow artifact --- .github/workflows/q65-official-samples.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/q65-official-samples.yml b/.github/workflows/q65-official-samples.yml index 52bce2e..4bcc385 100644 --- a/.github/workflows/q65-official-samples.yml +++ b/.github/workflows/q65-official-samples.yml @@ -2,6 +2,15 @@ name: Q65 Official Sample Decode on: workflow_dispatch: + inputs: + require_decode: + description: 'Fail the workflow if any Q65 sample has zero decodes' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' push: branches: [feature/q65-chain] paths: @@ -46,12 +55,16 @@ jobs: run: npm run build - name: Decode official WSJT-X Q65 samples + env: + Q65_REQUIRE_DECODE: ${{ github.event.inputs.require_decode || 'false' }} run: node scripts/decode-official-q65-samples.mjs - uses: actions/upload-artifact@v4 if: always() with: - name: official-q65-samples-cache - path: .cache/wsjtx-official-q65-samples/ - retention-days: 7 + name: q65-diagnostic-report + path: | + q65-diagnostic-report.json + .cache/wsjtx-official-q65-samples/ + retention-days: 14 if-no-files-found: ignore From 48333e54e7b53383a1ac51bf35b200e86225ea33 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 17:17:46 +0800 Subject: [PATCH 30/47] Pass Q65 disk decode diagnostic options to native --- src/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/index.ts b/src/index.ts index 21f9dd0..c0d6dad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ interface NativeDecodeOptions { lowFreq: number; highFreq: number; tolerance: number; myCall: string; myGrid: string; dxCall: string; dxGrid: string; apDecode: boolean; decodeDepth: number; qsoProgress: number; + diskData: boolean; newData: boolean; again: boolean; captureOutput: boolean; q65Period: number; q65Submode: number; q65MaxDrift: number; q65ClearAveraging: boolean; q65SingleDecode: boolean; q65Averaging: boolean; } @@ -103,6 +104,10 @@ export class WSJTXLib { apDecode: options.apDecode ?? true, decodeDepth: options.decodeDepth ?? 1, qsoProgress: options.qsoProgress ?? 0, + diskData: options.diskData ?? mode === WSJTXMode.Q65, + newData: options.newData ?? true, + again: options.again ?? false, + captureOutput: options.captureRawOutput ?? mode === WSJTXMode.Q65, q65Period, q65Submode, q65MaxDrift, q65ClearAveraging: options.q65ClearAveraging ?? false, q65SingleDecode: options.q65SingleDecode ?? false, From 952ab4dbea906b1b50d68bbecfa855dcb6aff2a8 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 17:18:40 +0800 Subject: [PATCH 31/47] Add Q65 disk decode diagnostic fields to C ABI --- native/wsjtx_c_api.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/native/wsjtx_c_api.h b/native/wsjtx_c_api.h index b1f0520..7ca7838 100644 --- a/native/wsjtx_c_api.h +++ b/native/wsjtx_c_api.h @@ -97,6 +97,10 @@ typedef struct { int ap_decode; int decode_depth; int qso_progress; + int disk_data; + int new_data; + int again; + int capture_output; int q65_period; int q65_submode; int q65_max_drift; From 5bbd555f12f5066fb6f4299cd6bcc33cb20f5092 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 17:22:08 +0800 Subject: [PATCH 32/47] Add Q65 diagnostic native overlay --- cmake/patch-q65-diagnostics.cmake | 191 ++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 cmake/patch-q65-diagnostics.cmake diff --git a/cmake/patch-q65-diagnostics.cmake b/cmake/patch-q65-diagnostics.cmake new file mode 100644 index 0000000..fe4bc3c --- /dev/null +++ b/cmake/patch-q65-diagnostics.cmake @@ -0,0 +1,191 @@ +# Second-stage Q65 diagnostic overlay. +# This keeps the public API stable while making Q65 disk-decode state and +# raw Fortran stdout observable from Node during CI diagnostics. + +set(_WSJTX_LIB_DIR "${CMAKE_SOURCE_DIR}/wsjtx_lib") +set(_WSJTX_NATIVE_DIR "${CMAKE_SOURCE_DIR}/native") + +# ---- N-API wrapper: options and raw stdout capture ------------------------- +set(_wrapper_h "${_WSJTX_NATIVE_DIR}/wsjtx_wrapper.h") +wsjtx_replace_once( + "${_wrapper_h}" + " wsjtx_decode_options_t options_; std::vector messages_; int numMessages_ = 0;" + " wsjtx_decode_options_t options_; std::vector messages_; int numMessages_ = 0;\n std::vector rawOutput_;" + "store raw decoder output in DecodeWorker") + +set(_wrapper_cpp "${_WSJTX_NATIVE_DIR}/wsjtx_wrapper.cpp") +wsjtx_replace_once( + "${_wrapper_cpp}" + "#include \n#include \n#include " + "#include \n#include \n#include \n#include \n#include \n#include \n#ifndef _WIN32\n#include \n#endif" + "include helpers for raw stdout capture") + +set(_capture_helpers [=[ + std::vector splitCapturedLines(const std::string& text) + { + std::vector lines; + std::istringstream input(text); + std::string line; + while (std::getline(input, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (!line.empty()) lines.push_back(line); + } + return lines; + } + +#ifndef _WIN32 + std::mutex& stdoutCaptureMutex() + { + static std::mutex mutex; + return mutex; + } + + template + std::vector captureStdout(Fn&& fn) + { + std::lock_guard lock(stdoutCaptureMutex()); + fflush(stdout); + int pipefd[2]; + if (pipe(pipefd) != 0) { + fn(); + return {}; + } + int saved = dup(STDOUT_FILENO); + if (saved < 0) { + close(pipefd[0]); + close(pipefd[1]); + fn(); + return {}; + } + dup2(pipefd[1], STDOUT_FILENO); + fn(); + fflush(stdout); + dup2(saved, STDOUT_FILENO); + close(saved); + close(pipefd[1]); + std::string captured; + char buffer[4096]; + ssize_t n = 0; + while ((n = read(pipefd[0], buffer, sizeof(buffer))) > 0) { + captured.append(buffer, static_cast(n)); + } + close(pipefd[0]); + return splitCapturedLines(captured); + } +#endif +]=]) +wsjtx_replace_once( + "${_wrapper_cpp}" + " int g_encodeSampleRate = 0;\n\n int getOptionalInt" + " int g_encodeSampleRate = 0;\n${_capture_helpers}\n int getOptionalInt" + "add raw stdout capture helpers") + +wsjtx_replace_once( + "${_wrapper_cpp}" + " opts.tx_frequency = getOptionalInt(optObj, \"txFrequency\", opts.frequency);" + " opts.tx_frequency = getOptionalInt(optObj, \"txFrequency\", opts.frequency);\n opts.utc = getOptionalInt(optObj, \"utc\", -1);\n opts.disk_data = getOptionalBoolAsInt(optObj, \"diskData\", mode == WSJTX_MODE_Q65 ? 1 : 0);\n opts.new_data = getOptionalBoolAsInt(optObj, \"newData\", 1);\n opts.again = getOptionalBoolAsInt(optObj, \"again\", 0);\n opts.capture_output = getOptionalBoolAsInt(optObj, \"captureOutput\", mode == WSJTX_MODE_Q65 ? 1 : 0);" + "read Q65 disk decode diagnostic options") + +set(_decode_execute [=[ + void DecodeWorker::Execute() + { + auto runDecode = [&]() -> int { + if (useFloat_) { + return wsjtx_decode_float_v2(handle_, mode_, + floatData_.data(), static_cast(floatData_.size()), + &options_); + } + return wsjtx_decode_int16_v2(handle_, mode_, + reinterpret_cast(intData_.data()), + static_cast(intData_.size()), + &options_); + }; + + int rc; +#ifndef _WIN32 + if (options_.capture_output) { + rc = WSJTX_ERR_EXCEPTION; + rawOutput_ = captureStdout([&]() { rc = runDecode(); }); + } else { + rc = runDecode(); + } +#else + rc = runDecode(); +#endif + if (rc == WSJTX_OK) { + messages_.resize(MAX_MSGS); + numMessages_ = wsjtx_pull_messages(handle_, messages_.data(), MAX_MSGS); + } else { + SetError("Decode failed with error code " + std::to_string(rc)); + } + } +]=]) +wsjtx_replace_once( + "${_wrapper_cpp}" + " void DecodeWorker::Execute()\n {\n int rc;\n if (useFloat_) {\n rc = wsjtx_decode_float_v2(handle_, mode_,\n floatData_.data(), static_cast(floatData_.size()),\n &options_);\n } else {\n rc = wsjtx_decode_int16_v2(handle_, mode_,\n reinterpret_cast(intData_.data()),\n static_cast(intData_.size()),\n &options_);\n }\n if (rc == WSJTX_OK) {\n messages_.resize(MAX_MSGS);\n numMessages_ = wsjtx_pull_messages(handle_, messages_.data(), MAX_MSGS);\n } else {\n SetError(\"Decode failed with error code \" + std::to_string(rc));\n }\n }" + "${_decode_execute}" + "capture raw decoder stdout in DecodeWorker") + +wsjtx_replace_once( + "${_wrapper_cpp}" + " result.Set(\"messages\", msgs);\n result.Set(\"success\", Napi::Boolean::New(env, true));" + " result.Set(\"messages\", msgs);\n Napi::Array raw = Napi::Array::New(env, rawOutput_.size());\n for (size_t i = 0; i < rawOutput_.size(); i++) raw[i] = Napi::String::New(env, rawOutput_[i]);\n result.Set(\"rawOutput\", raw);\n result.Set(\"success\", Napi::Boolean::New(env, true));" + "return rawOutput from DecodeWorker") + +# ---- C API -> wsjtx_lib disk controls -------------------------------------- +set(_c_api_cpp "${_WSJTX_NATIVE_DIR}/wsjtx_c_api.cpp") +wsjtx_replace_once( + "${_c_api_cpp}" + " lib->setDecodeUtc(opts->utc);" + " lib->setDecodeUtc(opts->utc);\n lib->setDecodeDiskControls(opts->disk_data != 0, opts->new_data != 0, opts->again != 0);" + "forward disk decode controls through C ABI") + +# ---- wsjtx_lib disk-control plumbing --------------------------------------- +set(_lib_h "${_WSJTX_LIB_DIR}/wsjtx_lib.h") +wsjtx_replace_once( + "${_lib_h}" + "\tvoid setDecodeUtc(int utc);\n\tvoid setDecodeQ65Controls" + "\tvoid setDecodeUtc(int utc);\n\tvoid setDecodeDiskControls(bool diskData, bool newData, bool again);\n\tvoid setDecodeQ65Controls" + "declare wsjtx_lib::setDecodeDiskControls") +wsjtx_replace_once( + "${_lib_h}" + "\tint decode_utc_ = -1;\n\tint q65_period_" + "\tint decode_utc_ = -1;\n\tbool disk_data_ = false;\n\tbool new_data_ = true;\n\tbool again_ = false;\n\tint q65_period_" + "add wsjtx_lib disk decode state") + +set(_lib_cpp "${_WSJTX_LIB_DIR}/wsjtx_lib.cpp") +wsjtx_replace_once( + "${_lib_cpp}" + "void wsjtx_lib::setDecodeUtc(int utc)\n{\n\tdecode_utc_ = utc;\n}\n\nvoid wsjtx_lib::setDecodeQ65Controls" + "void wsjtx_lib::setDecodeUtc(int utc)\n{\n\tdecode_utc_ = utc;\n}\n\nvoid wsjtx_lib::setDecodeDiskControls(bool diskData, bool newData, bool again)\n{\n\tdisk_data_ = diskData;\n\tnew_data_ = newData;\n\tagain_ = again;\n}\n\nvoid wsjtx_lib::setDecodeQ65Controls" + "implement wsjtx_lib::setDecodeDiskControls") +wsjtx_replace_once( + "${_lib_cpp}" + "\tptr->setDecodeUtc(decode_utc_);\n\tptr->setDecodeQ65Controls" + "\tptr->setDecodeUtc(decode_utc_);\n\tptr->setDecodeDiskControls(disk_data_, new_data_, again_);\n\tptr->setDecodeQ65Controls" + "forward disk controls to decoder") + +# ---- wstjx_decode disk controls and WSJT-X-like params --------------------- +set(_decode_h "${_WSJTX_LIB_DIR}/wsjtx_decode.h") +wsjtx_replace_once( + "${_decode_h}" + "\tvoid setDecodeUtc(int utc);\n\tvoid setDecodeQ65Controls" + "\tvoid setDecodeUtc(int utc);\n\tvoid setDecodeDiskControls(bool diskData, bool newData, bool again);\n\tvoid setDecodeQ65Controls" + "declare wstjx_decode::setDecodeDiskControls") +wsjtx_replace_once( + "${_decode_h}" + "\tint decode_utc_ = -1;\n\tint q65_period_" + "\tint decode_utc_ = -1;\n\tbool disk_data_ = false;\n\tbool new_data_ = true;\n\tbool again_ = false;\n\tint q65_period_" + "add wstjx_decode disk decode state") + +set(_decode_cpp "${_WSJTX_LIB_DIR}/wsjtx_decode.cpp") +wsjtx_replace_once( + "${_decode_cpp}" + "void wstjx_decode::setDecodeUtc(int utc) {\n\tdecode_utc_ = utc;\n}\nvoid wstjx_decode::setDecodeQ65Controls" + "void wstjx_decode::setDecodeUtc(int utc) {\n\tdecode_utc_ = utc;\n}\nvoid wstjx_decode::setDecodeDiskControls(bool diskData, bool newData, bool again) {\n\tdisk_data_ = diskData;\n\tnew_data_ = newData;\n\tagain_ = again;\n}\nvoid wstjx_decode::setDecodeQ65Controls" + "implement wstjx_decode::setDecodeDiskControls") +wsjtx_replace_once( + "${_decode_cpp}" + "\tparams.nutc = decode_utc_ >= 0 ? decode_utc_ : (local_tm.tm_hour * 10000 + local_tm.tm_min * 100 + local_tm.tm_sec);" + "\tparams.nutc = decode_utc_ >= 0 ? decode_utc_ : (local_tm.tm_hour * 10000 + local_tm.tm_min * 100 + local_tm.tm_sec);\n\tparams.ndiskdat = disk_data_;\n\tparams.newdat = new_data_;\n\tparams.nagain = again_;" + "set disk decode params") From 9855176b4fb3f0f8d3faf3dba443982da4358744 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 17:23:03 +0800 Subject: [PATCH 33/47] Apply Q65 diagnostic overlay during native build --- CMakeLists.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index da44aa3..2190727 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -256,10 +256,11 @@ include_directories( # Dependencies for the core library set(LIBRARIES_FROM_REFERENCES ${FFTW3F_LIBRARIES} ${FFTW_THREADS_LIBRARIES}) -# Build the Fortran/C++ core. Apply the Q65 source overlay before the +# Build the Fortran/C++ core. Apply the Q65 source overlays 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") + include("${CMAKE_SOURCE_DIR}/cmake/patch-q65-diagnostics.cmake") endif() add_subdirectory(wsjtx_lib) link_directories(${FFTW3F_LIBRARY_DIRS}) From e95b6cc1902c13a2201a59f0608569c91125d320 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 17:24:00 +0800 Subject: [PATCH 34/47] Include raw decoder output in Q65 diagnostic report --- scripts/decode-official-q65-samples.mjs | 35 ++++++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/scripts/decode-official-q65-samples.mjs b/scripts/decode-official-q65-samples.mjs index edbb1e8..c124bbb 100644 --- a/scripts/decode-official-q65-samples.mjs +++ b/scripts/decode-official-q65-samples.mjs @@ -126,6 +126,10 @@ async function probeDecode({ audio, q65Period, q65Submode, utc }) { lowFreq: 0, highFreq: 5000, decodeDepth: 3, + diskData: true, + newData: true, + again: false, + captureRawOutput: true, q65Period, q65Submode, q65ClearAveraging: true, @@ -146,7 +150,14 @@ async function probeDecode({ audio, q65Period, q65Submode, utc }) { for (const probe of probes) { const result = await lib.decode(WSJTXMode.Q65, audio, { ...base, ...probe }); const messages = summarizeMessages(result); - probeResults.push({ label: probe.label, options: { ...probe, utc }, decodedCount: messages.length, messages }); + probeResults.push({ + label: probe.label, + options: { ...probe, utc, diskData: true, newData: true, again: false, captureRawOutput: true }, + decodedCount: messages.length, + messages, + rawOutput: result.rawOutput ?? [], + rawOutputLineCount: result.rawOutput?.length ?? 0, + }); if (messages.length > bestMessages.length) bestMessages = messages; } return { probeResults, bestMessages }; @@ -214,22 +225,32 @@ const report = { }; await writeFile(reportPath, JSON.stringify(report, null, 2)); +const firstRaw = [...selfReports, ...officialReports] + .flatMap((r) => r.probes.map((p) => p.rawOutput ?? [])) + .find((lines) => lines.length > 0) ?? []; + const markdown = [ '# Q65 diagnostic report', '', `- Self-generated samples: ${selfReports.length - selfFailures}/${selfReports.length} produced at least one decode`, `- Official WSJT-X samples: ${officialReports.length - officialFailures}/${officialReports.length} produced at least one decode`, `- Strict failure mode: ${requireDecode ? 'enabled' : 'disabled'}`, + `- First captured raw-output lines: ${firstRaw.length}`, '', '## Self-generated', - '| sample | period | submode | seconds | peak | rms | decoded |', - '|---|---:|---|---:|---:|---:|---:|', - ...selfReports.map((r) => `| ${r.sample} | ${r.q65.period} | ${r.q65.submode} | ${r.generated.seconds} | ${r.generated.stats.peak} | ${r.generated.stats.rms.toFixed(6)} | ${r.bestDecodedCount} |`), + '| sample | period | submode | seconds | peak | rms | decoded | raw lines |', + '|---|---:|---|---:|---:|---:|---:|---:|', + ...selfReports.map((r) => `| ${r.sample} | ${r.q65.period} | ${r.q65.submode} | ${r.generated.seconds} | ${r.generated.stats.peak} | ${r.generated.stats.rms.toFixed(6)} | ${r.bestDecodedCount} | ${Math.max(...r.probes.map((p) => p.rawOutputLineCount))} |`), '', '## Official WSJT-X samples', - '| sample | period | submode | seconds | peak | rms | decoded |', - '|---|---:|---|---:|---:|---:|---:|', - ...officialReports.map((r) => `| ${r.sample} | ${r.q65.period} | ${r.q65.submode} | ${r.wav.seconds} | ${r.wav.stats.peak} | ${r.wav.stats.rms.toFixed(6)} | ${r.bestDecodedCount} |`), + '| sample | period | submode | seconds | peak | rms | decoded | raw lines |', + '|---|---:|---|---:|---:|---:|---:|---:|', + ...officialReports.map((r) => `| ${r.sample} | ${r.q65.period} | ${r.q65.submode} | ${r.wav.seconds} | ${r.wav.stats.peak} | ${r.wav.stats.rms.toFixed(6)} | ${r.bestDecodedCount} | ${Math.max(...r.probes.map((p) => p.rawOutputLineCount))} |`), + '', + '## First captured raw output', + '```text', + ...firstRaw.slice(0, 40), + '```', '', `Full JSON report: \`${reportPath}\``, '', From d5ee0e6d1735e3d4fbafeee5fd227dc5a76f0288 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 17:35:44 +0800 Subject: [PATCH 35/47] Add Q65 decoder input preservation diagnostics --- cmake/patch-q65-input-diagnostics.cmake | 57 +++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 cmake/patch-q65-input-diagnostics.cmake diff --git a/cmake/patch-q65-input-diagnostics.cmake b/cmake/patch-q65-input-diagnostics.cmake new file mode 100644 index 0000000..56a1985 --- /dev/null +++ b/cmake/patch-q65-input-diagnostics.cmake @@ -0,0 +1,57 @@ +# Q65 input-path diagnostics and fixes. +# The original float decoder path moved the input vector into samplebuffer before +# copying samples into dec_data.d2. After std::move, audiosamples may be empty, +# which makes generated Float32 Q65 round-trip tests meaningless. Keep the input +# available and print the exact Q65 params that reach multimode_decoder_. + +set(_WSJTX_LIB_DIR "${CMAKE_SOURCE_DIR}/wsjtx_lib") +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 parameter diagnostics") + +wsjtx_replace_once( + "${_decode_cpp}" + "\tsamplebuffer.push(std::move(audiosamples));" + "\tsamplebuffer.push(WsjTxVector(audiosamples));" + "preserve Float32 decode samples after samplebuffer push") + +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_param_print [=[ + if (mode == Q65) { + std::printf(" nmode=%d ntr=%d kin=%d nzhsym=%d nsubmode=%d nfqso=%d nftx=%d nfa=%d nfb=%d ntol=%d ndiskdat=%d newdat=%d nagain=%d ndepth=%d nexp=%d max_drift=%d nclearave=%d nutc=%d\n", + params.nmode, + params.ntrperiod, + params.kin, + params.nzhsym, + params.nsubmode, + params.nfqso, + params.nftx, + params.nfa, + params.nfb, + params.ntol, + params.ndiskdat ? 1 : 0, + params.newdat ? 1 : 0, + params.nagain ? 1 : 0, + params.ndepth, + params.nexp_decode, + params.max_drift, + params.nclearave ? 1 : 0, + params.nutc); + std::fflush(stdout); + } + fftwf_plan_with_nthreads(threads);]=]) + +wsjtx_replace_once( + "${_decode_cpp}" + "\tfftwf_plan_with_nthreads(threads);" + "${_q65_param_print}" + "print Q65 params before multimode_decoder") From 2f5ad0c05d0d7b88ad9ef5aa636eccde3a03fca4 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 17:36:37 +0800 Subject: [PATCH 36/47] Include Q65 input preservation diagnostics overlay --- cmake/patch-q65-diagnostics.cmake | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmake/patch-q65-diagnostics.cmake b/cmake/patch-q65-diagnostics.cmake index fe4bc3c..10e2a37 100644 --- a/cmake/patch-q65-diagnostics.cmake +++ b/cmake/patch-q65-diagnostics.cmake @@ -189,3 +189,5 @@ wsjtx_replace_once( "\tparams.nutc = decode_utc_ >= 0 ? decode_utc_ : (local_tm.tm_hour * 10000 + local_tm.tm_min * 100 + local_tm.tm_sec);" "\tparams.nutc = decode_utc_ >= 0 ? decode_utc_ : (local_tm.tm_hour * 10000 + local_tm.tm_min * 100 + local_tm.tm_sec);\n\tparams.ndiskdat = disk_data_;\n\tparams.newdat = new_data_;\n\tparams.nagain = again_;" "set disk decode params") + +include("${CMAKE_SOURCE_DIR}/cmake/patch-q65-input-diagnostics.cmake") From 583592b2e37a69bb3a4df07c93e0687ec11c60b6 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 17:57:22 +0800 Subject: [PATCH 37/47] Remove Q65 diagnostic-only public options --- src/types.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/types.ts b/src/types.ts index 90d52c1..1bd9739 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,18 +53,24 @@ export interface EncodeOptions extends Q65EncodeOptions { * * - frequency: nominal QSO frequency in Hz (decoder uses this as nfqso). * - txFrequency: transmit audio offset in Hz (decoder uses this as nftx). - * - utc: optional HHMMSS timestamp used as params.nutc. Useful for disk/WAV - * regression samples whose capture time is encoded in the file name. - * - diskData: set params.ndiskdat. Defaults to true for Q65 and false for - * other modes, matching the WSJT-X disk-sample path for Q65 testing. - * - newData: set params.newdat. Defaults to true. - * - again: set params.nagain. Defaults to false. - * - captureRawOutput: return Fortran decoder output lines in `rawOutput`. + * - threads: thread hint forwarded to the decoder. Defaults to maxThreads. + * - myCall / myGrid / dxCall / dxGrid: AP decode context for the named station. + * - lowFreq / highFreq / tolerance: scan window and tone tolerance in Hz + * (defaults: 200 / 4000 / 20). These are forwarded to the decoder via + * `setDecodeRange` and *do* take effect. + * - 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 extends Q65EncodeOptions { frequency: number; txFrequency?: number; - utc?: number; threads?: number; myCall?: string; myGrid?: string; @@ -76,10 +82,6 @@ export interface DecodeOptions extends Q65EncodeOptions { apDecode?: boolean; decodeDepth?: number; qsoProgress?: number; - diskData?: boolean; - newData?: boolean; - again?: boolean; - captureRawOutput?: boolean; q65MaxDrift?: number; q65ClearAveraging?: boolean; q65SingleDecode?: boolean; @@ -89,8 +91,6 @@ export interface DecodeOptions extends Q65EncodeOptions { export interface DecodeResult { success: boolean; messages: WSJTXMessage[]; - /** Raw Fortran decoder lines captured during this decode call, when enabled. */ - rawOutput?: string[]; error?: string; } From eeac01fb654d7d1774ed3a0f2bcf5e6973098356 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 17:58:22 +0800 Subject: [PATCH 38/47] Restore stable public TypeScript API for Q65 --- src/index.ts | 258 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 204 insertions(+), 54 deletions(-) diff --git a/src/index.ts b/src/index.ts index c0d6dad..2097285 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,12 @@ /** * wsjtx-lib — Node.js binding for the WSJT-X 3.0.0 backend. + * + * Public surface: + * - 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/Q65 default encode rate: 12 kHz) */ import { @@ -25,17 +32,38 @@ import path from 'node:path'; const require = createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); -interface NativeBinding { WSJTXLib: new (config?: { encodeSampleRate?: number }) => NativeWSJTXLib; } +interface NativeBinding { + WSJTXLib: new (config?: { encodeSampleRate?: number }) => NativeWSJTXLib; +} + interface NativeDecodeOptions { - frequency: number; txFrequency: number; utc: number; threads: number; - lowFreq: number; highFreq: number; tolerance: number; - myCall: string; myGrid: string; dxCall: string; dxGrid: string; - apDecode: boolean; decodeDepth: number; qsoProgress: number; - diskData: boolean; newData: boolean; again: boolean; captureOutput: boolean; - q65Period: number; q65Submode: number; q65MaxDrift: number; - q65ClearAveraging: boolean; q65SingleDecode: boolean; q65Averaging: boolean; + frequency: number; + txFrequency: number; + threads: number; + lowFreq: number; + highFreq: number; + tolerance: number; + myCall: string; + myGrid: string; + dxCall: string; + dxGrid: string; + 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 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, opts: NativeEncodeOptions, cb: (e: Error | null, r: EncodeResult) => void): void; @@ -54,17 +82,29 @@ function loadNativeBinding(): NativeBinding['WSJTXLib'] { } const NativeWSJTXLib = loadNativeBinding(); + const DEFAULT_CONFIG: Required = { - maxThreads: 4, encodeSampleRate: 12000, debug: false, - defaultLowFreq: 200, defaultHighFreq: 4000, defaultTolerance: 20, + maxThreads: 4, + encodeSampleRate: 12000, + debug: false, + defaultLowFreq: 200, + defaultHighFreq: 4000, + defaultTolerance: 20, }; + const FREQ_MIN = 0; 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]]); +const Q65_SUBMODES = new Map([ + ['A', 0], + ['B', 1], + ['C', 2], + ['D', 3], + ['E', 4], +]); export class WSJTXLib { private readonly native: NativeWSJTXLib; @@ -82,9 +122,9 @@ export class WSJTXLib { this.validateFrequency(options.frequency); const threads = options.threads ?? this.config.maxThreads; this.validateThreads(threads); - const utc = options.utc ?? -1; - if (utc !== -1) this.validateUtc(utc); - if (!this.isDecodingSupported(mode)) throw new WSJTXError('Decoding not supported for this mode', 'UNSUPPORTED'); + 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'); @@ -94,21 +134,20 @@ export class WSJTXLib { const opts: NativeDecodeOptions = { frequency: options.frequency, txFrequency: options.txFrequency ?? options.frequency, - utc, threads, lowFreq: options.lowFreq ?? this.config.defaultLowFreq, highFreq: options.highFreq ?? this.config.defaultHighFreq, tolerance: options.tolerance ?? this.config.defaultTolerance, - myCall: options.myCall ?? '', myGrid: options.myGrid ?? '', - dxCall: options.dxCall ?? '', dxGrid: options.dxGrid ?? '', + myCall: options.myCall ?? '', + myGrid: options.myGrid ?? '', + dxCall: options.dxCall ?? '', + dxGrid: options.dxGrid ?? '', apDecode: options.apDecode ?? true, decodeDepth: options.decodeDepth ?? 1, qsoProgress: options.qsoProgress ?? 0, - diskData: options.diskData ?? mode === WSJTXMode.Q65, - newData: options.newData ?? true, - again: options.again ?? false, - captureOutput: options.captureRawOutput ?? mode === WSJTXMode.Q65, - q65Period, q65Submode, q65MaxDrift, + q65Period, + q65Submode, + q65MaxDrift, q65ClearAveraging: options.q65ClearAveraging ?? false, q65SingleDecode: options.q65SingleDecode ?? false, q65Averaging: options.q65Averaging ?? false, @@ -116,40 +155,82 @@ export class WSJTXLib { return new Promise((resolve, reject) => { this.native.decode(mode, audioData, opts, (err, result) => { - if (err) reject(new WSJTXError(err.message, 'DECODE_ERROR')); else resolve(result); + if (err) reject(new WSJTXError(err.message, 'DECODE_ERROR')); + else resolve(result); }); }); } - async encode(mode: WSJTXMode, message: string, frequency: number, threadsOrOptions: number | EncodeOptions = this.config.maxThreads): Promise { - this.validateMode(mode); this.validateMessage(message); this.validateFrequency(frequency); + async encode( + mode: WSJTXMode, + message: string, + frequency: number, + threadsOrOptions: number | EncodeOptions = this.config.maxThreads, + ): Promise { + this.validateMode(mode); + this.validateMessage(message); + this.validateFrequency(frequency); const opts = this.normalizeEncodeOptions(threadsOrOptions); this.validateThreads(opts.threads); - if (!this.isEncodingSupported(mode)) throw new WSJTXError('Encoding not supported for this mode', 'UNSUPPORTED'); + 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, opts, (err, result) => { - if (err) reject(new WSJTXError(err.message, 'ENCODE_ERROR')); else resolve(result); + if (err) reject(new WSJTXError(err.message, 'ENCODE_ERROR')); + else resolve(result); }); }); } async decodeWSPR(audioData: Int16Array, options: WSPRDecodeOptions = {}): Promise { - if (!(audioData instanceof Int16Array) || audioData.length === 0) throw new WSJTXError('audioData must be a non-empty Int16Array', 'INVALID'); - const opts = { dialFrequency: 14_095_600, callsign: '', locator: '', quickMode: false, useHashTable: true, passes: 2, subtraction: true, ...options }; + if (!(audioData instanceof Int16Array) || audioData.length === 0) { + throw new WSJTXError('audioData must be a non-empty Int16Array', 'INVALID'); + } + + const opts = { + dialFrequency: 14_095_600, + callsign: '', + locator: '', + quickMode: false, + useHashTable: true, + passes: 2, + subtraction: true, + ...options, + }; + return new Promise((resolve, reject) => { this.native.decodeWSPR(audioData as unknown as Float32Array, opts, (err, results) => { - if (err) reject(new WSJTXError(err.message, 'WSPR_ERROR')); else resolve(results); + if (err) reject(new WSJTXError(err.message, 'WSPR_ERROR')); + else resolve(results); }); }); } - pullMessages(): WSJTXMessage[] { return this.native.pullMessages(); } - isEncodingSupported(mode: WSJTXMode): boolean { return this.native.isEncodingSupported(mode); } - isDecodingSupported(mode: WSJTXMode): boolean { return this.native.isDecodingSupported(mode); } - getSampleRate(mode: WSJTXMode): number { return this.native.getSampleRate(mode); } - getTransmissionDuration(mode: WSJTXMode): number { return this.native.getTransmissionDuration(mode); } + pullMessages(): WSJTXMessage[] { + return this.native.pullMessages(); + } + + isEncodingSupported(mode: WSJTXMode): boolean { + return this.native.isEncodingSupported(mode); + } + + isDecodingSupported(mode: WSJTXMode): boolean { + return this.native.isDecodingSupported(mode); + } + + getSampleRate(mode: WSJTXMode): number { + return this.native.getSampleRate(mode); + } + + getTransmissionDuration(mode: WSJTXMode): number { + return this.native.getTransmissionDuration(mode); + } + getAllModeCapabilities(): ModeCapabilities[] { - return Object.values(WSJTXMode).filter((v): v is number => typeof v === 'number').map((mode) => ({ + const numericModes = Object.values(WSJTXMode).filter((v): v is number => typeof v === 'number'); + return numericModes.map((mode) => ({ mode: mode as WSJTXMode, encodingSupported: this.isEncodingSupported(mode as WSJTXMode), decodingSupported: this.isDecodingSupported(mode as WSJTXMode), @@ -157,34 +238,103 @@ export class WSJTXLib { duration: this.getTransmissionDuration(mode as WSJTXMode), })); } + async convertAudioFormat(audioData: AudioData, targetFormat: 'float32' | 'int16'): Promise { - return new Promise((resolve, reject) => this.native.convertAudioFormat(audioData, targetFormat, (err, result) => err ? reject(err) : resolve(result))); + return new Promise((resolve, reject) => { + this.native.convertAudioFormat(audioData, targetFormat, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); } private normalizeEncodeOptions(threadsOrOptions: number | EncodeOptions): NativeEncodeOptions { const options = typeof threadsOrOptions === 'number' ? { threads: threadsOrOptions } : threadsOrOptions; - return { threads: options.threads ?? this.config.maxThreads, 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'); } - private validateFrequency(freq: number): void { if (!Number.isInteger(freq) || freq < FREQ_MIN || freq > FREQ_MAX) throw new WSJTXError('Invalid frequency', 'INVALID'); } - private validateEncodeSampleRate(sampleRate: number): void { if (sampleRate !== 12000 && sampleRate !== 48000) throw new WSJTXError('encodeSampleRate must be 12000 or 48000', 'INVALID'); } - private validateThreads(threads: number): void { if (!Number.isInteger(threads) || threads < THREADS_MIN || threads > THREADS_MAX) throw new WSJTXError(`Threads must be ${THREADS_MIN}..${THREADS_MAX}`, 'INVALID'); } - private validateMessage(message: string): void { if (typeof message !== 'string' || message.length === 0 || message.length > MESSAGE_MAX_LEN) throw new WSJTXError(`Message must be 1..${MESSAGE_MAX_LEN} characters`, 'INVALID'); } - private validateAudio(audio: AudioData): void { if (!(audio instanceof Float32Array || audio instanceof Int16Array) || audio.length === 0) throw new WSJTXError('audioData must be a non-empty Float32Array or Int16Array', 'INVALID'); } - private validateUtc(utc: number): void { - if (!Number.isInteger(utc) || utc < 0 || utc > 235959 || Math.floor(utc / 10000) > 23 || Math.floor((utc % 10000) / 100) > 59 || utc % 100 > 59) { - throw new WSJTXError('utc must be HHMMSS in the range 000000..235959', 'INVALID'); + 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'); } } - 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 validateFrequency(freq: number): void { + if (!Number.isInteger(freq) || freq < FREQ_MIN || freq > FREQ_MAX) { + throw new WSJTXError('Invalid frequency', 'INVALID'); + } + } + + private validateEncodeSampleRate(sampleRate: number): void { + if (sampleRate !== 12000 && sampleRate !== 48000) { + throw new WSJTXError('encodeSampleRate must be 12000 or 48000', 'INVALID'); + } + } + + private validateThreads(threads: number): void { + if (!Number.isInteger(threads) || threads < THREADS_MIN || threads > THREADS_MAX) { + throw new WSJTXError(`Threads must be ${THREADS_MIN}..${THREADS_MAX}`, 'INVALID'); + } + } + + private validateMessage(message: string): void { + if (typeof message !== 'string' || message.length === 0 || message.length > MESSAGE_MAX_LEN) { + throw new WSJTXError(`Message must be 1..${MESSAGE_MAX_LEN} characters`, 'INVALID'); + } + } + + private validateAudio(audio: AudioData): void { + const isTyped = audio instanceof Float32Array || audio instanceof Int16Array; + if (!isTyped || audio.length === 0) { + 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; } + 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'); + 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'); } + + 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, AudioData, WSJTXConfig, DecodeOptions, ModeCapabilities }; +export type { + DecodeResult, + EncodeResult, + EncodeOptions, + Q65Period, + Q65Submode, + WSPRResult, + WSPRDecodeOptions, + WSJTXMessage, + AudioData, + WSJTXConfig, + DecodeOptions, + ModeCapabilities, +}; From 7789ffb4739e52a38325addfeeeff48d988ddada Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 17:59:17 +0800 Subject: [PATCH 39/47] Remove Q65 diagnostic workflow --- .github/workflows/q65-official-samples.yml | 70 ---------------------- 1 file changed, 70 deletions(-) delete mode 100644 .github/workflows/q65-official-samples.yml diff --git a/.github/workflows/q65-official-samples.yml b/.github/workflows/q65-official-samples.yml deleted file mode 100644 index 4bcc385..0000000 --- a/.github/workflows/q65-official-samples.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Q65 Official Sample Decode - -on: - workflow_dispatch: - inputs: - require_decode: - description: 'Fail the workflow if any Q65 sample has zero decodes' - required: false - default: 'false' - type: choice - options: - - 'false' - - 'true' - push: - branches: [feature/q65-chain] - paths: - - 'scripts/decode-official-q65-samples.mjs' - - '.github/workflows/q65-official-samples.yml' - - 'src/**' - - 'native/**' - - 'cmake/**' - - 'CMakeLists.txt' - - 'package*.json' - - 'tsconfig.json' - -env: - NODE_VERSION: '20' - -jobs: - decode-official-q65-samples: - name: Decode official WSJT-X Q65 WAV samples - 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: Decode official WSJT-X Q65 samples - env: - Q65_REQUIRE_DECODE: ${{ github.event.inputs.require_decode || 'false' }} - run: node scripts/decode-official-q65-samples.mjs - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: q65-diagnostic-report - path: | - q65-diagnostic-report.json - .cache/wsjtx-official-q65-samples/ - retention-days: 14 - if-no-files-found: ignore From f88887acbad2ba9d3cfc8be8053b825fefe00d30 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 17:59:50 +0800 Subject: [PATCH 40/47] Remove Q65 diagnostic sample script --- scripts/decode-official-q65-samples.mjs | 264 ------------------------ 1 file changed, 264 deletions(-) delete mode 100644 scripts/decode-official-q65-samples.mjs diff --git a/scripts/decode-official-q65-samples.mjs b/scripts/decode-official-q65-samples.mjs deleted file mode 100644 index c124bbb..0000000 --- a/scripts/decode-official-q65-samples.mjs +++ /dev/null @@ -1,264 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { dirname, join, basename } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import https from 'node:https'; -import { WSJTXLib, WSJTXMode } from '../dist/src/index.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const root = join(__dirname, '..'); -const cacheDir = join(root, '.cache', 'wsjtx-official-q65-samples'); -const reportPath = join(root, 'q65-diagnostic-report.json'); -const summaryPath = process.env.GITHUB_STEP_SUMMARY; -const requireDecode = process.env.Q65_REQUIRE_DECODE === '1' || process.env.Q65_REQUIRE_DECODE === 'true'; - -const samples = [ - { name: 'Q65-30A ionoscatter 6m', path: 'samples/Q65/30A_Ionoscatter_6m/201203_022700.wav', q65Period: 30, q65Submode: 'A' }, - { name: 'Q65-60A EME 6m', path: 'samples/Q65/60A_EME_6m/210106_1621.wav', q65Period: 60, q65Submode: 'A' }, - { name: 'Q65-60B 1296 troposcatter', path: 'samples/Q65/60B_1296_Troposcatter/210109_0007.wav', q65Period: 60, q65Submode: 'B' }, - { name: 'Q65-120E ionoscatter 6m', path: 'samples/Q65/120E_Ionoscatter_6m/210130_1438.wav', q65Period: 120, q65Submode: 'E' }, -]; - -function download(url) { - return new Promise((resolve, reject) => { - https.get(url, (res) => { - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - res.resume(); - download(new URL(res.headers.location, url).toString()).then(resolve, reject); - return; - } - if (res.statusCode !== 200) { - reject(new Error(`GET ${url} failed with HTTP ${res.statusCode}`)); - res.resume(); - return; - } - const chunks = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => resolve(Buffer.concat(chunks))); - }).on('error', reject); - }); -} - -async function getSample(path) { - await mkdir(cacheDir, { recursive: true }); - const local = join(cacheDir, path.replaceAll('/', '__')); - if (existsSync(local)) return readFile(local); - const url = `https://raw.githubusercontent.com/WSJTX/wsjtx/master/${path}`; - const data = await download(url); - await writeFile(local, data); - return data; -} - -function readAscii(buffer, offset, length) { - return buffer.subarray(offset, offset + length).toString('ascii'); -} - -function parseWav(buffer) { - if (readAscii(buffer, 0, 4) !== 'RIFF' || readAscii(buffer, 8, 4) !== 'WAVE') throw new Error('Not a RIFF/WAVE file'); - let offset = 12; - let fmt = null; - let dataOffset = -1; - let dataLength = 0; - while (offset + 8 <= buffer.length) { - const chunkId = readAscii(buffer, offset, 4); - const chunkSize = buffer.readUInt32LE(offset + 4); - const chunkData = offset + 8; - if (chunkId === 'fmt ') { - fmt = { - audioFormat: buffer.readUInt16LE(chunkData), - channels: buffer.readUInt16LE(chunkData + 2), - sampleRate: buffer.readUInt32LE(chunkData + 4), - bitsPerSample: buffer.readUInt16LE(chunkData + 14), - }; - } else if (chunkId === 'data') { - dataOffset = chunkData; - dataLength = chunkSize; - } - offset = chunkData + chunkSize + (chunkSize % 2); - } - if (!fmt) throw new Error('Missing fmt chunk'); - if (dataOffset < 0) throw new Error('Missing data chunk'); - if (fmt.channels !== 1) throw new Error(`Expected mono WAV, got ${fmt.channels} channels`); - const view = new DataView(buffer.buffer, buffer.byteOffset + dataOffset, dataLength); - if (fmt.audioFormat === 1 && fmt.bitsPerSample === 16) { - const audio = new Int16Array(dataLength / 2); - for (let i = 0; i < audio.length; i++) audio[i] = view.getInt16(i * 2, true); - return { audio, sampleRate: fmt.sampleRate, format: 'pcm_s16le' }; - } - if (fmt.audioFormat === 3 && fmt.bitsPerSample === 32) { - const audio = new Float32Array(dataLength / 4); - for (let i = 0; i < audio.length; i++) audio[i] = view.getFloat32(i * 4, true); - return { audio, sampleRate: fmt.sampleRate, format: 'float32le' }; - } - throw new Error(`Unsupported WAV format: audioFormat=${fmt.audioFormat}, bitsPerSample=${fmt.bitsPerSample}`); -} - -function utcFromSamplePath(path) { - const stem = basename(path, '.wav').split('_').at(-1) ?? ''; - if (/^\d{6}$/.test(stem)) return Number(stem); - if (/^\d{4}$/.test(stem)) return Number(`${stem}00`); - return null; -} - -function audioStats(audio) { - let peak = 0; - let sumSq = 0; - let nonzero = 0; - for (const v of audio) { - const x = Math.abs(v); - if (x > peak) peak = x; - if (x > 0) nonzero++; - sumSq += Number(v) * Number(v); - } - return { peak, rms: Math.sqrt(sumSq / audio.length), nonzero }; -} - -function summarizeMessages(result) { - return result.messages.map((m) => ({ text: m.text.trim(), snr: m.snr, dt: m.deltaTime, freq: m.deltaFrequency, sync: m.sync })); -} - -const lib = new WSJTXLib({ maxThreads: 4 }); - -async function probeDecode({ audio, q65Period, q65Submode, utc }) { - const base = { - threads: 4, - utc, - lowFreq: 0, - highFreq: 5000, - decodeDepth: 3, - diskData: true, - newData: true, - again: false, - captureRawOutput: true, - q65Period, - q65Submode, - q65ClearAveraging: true, - }; - - const probes = [ - { label: 'wide-default', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65MaxDrift: 50, q65Averaging: false, q65SingleDecode: false }, - { label: 'wide-avg', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65MaxDrift: 50, q65Averaging: true, q65SingleDecode: false }, - { label: 'wide-single', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65MaxDrift: 50, q65Averaging: false, q65SingleDecode: true }, - { label: 'center-1000', frequency: 1000, txFrequency: 1000, tolerance: 1000, q65MaxDrift: 50, q65Averaging: false, q65SingleDecode: false }, - { label: 'center-1500', frequency: 1500, txFrequency: 1500, tolerance: 1000, q65MaxDrift: 50, q65Averaging: false, q65SingleDecode: false }, - { label: 'center-2000', frequency: 2000, txFrequency: 2000, tolerance: 1000, q65MaxDrift: 50, q65Averaging: false, q65SingleDecode: false }, - { label: 'wide-drift100', frequency: 1500, txFrequency: 1500, tolerance: 5000, q65MaxDrift: 100, q65Averaging: false, q65SingleDecode: false }, - ]; - - const probeResults = []; - let bestMessages = []; - for (const probe of probes) { - const result = await lib.decode(WSJTXMode.Q65, audio, { ...base, ...probe }); - const messages = summarizeMessages(result); - probeResults.push({ - label: probe.label, - options: { ...probe, utc, diskData: true, newData: true, again: false, captureRawOutput: true }, - decodedCount: messages.length, - messages, - rawOutput: result.rawOutput ?? [], - rawOutputLineCount: result.rawOutput?.length ?? 0, - }); - if (messages.length > bestMessages.length) bestMessages = messages; - } - return { probeResults, bestMessages }; -} - -const selfCases = [ - { name: 'self-Q65-30A', q65Period: 30, q65Submode: 'A', message: 'CQ K1ABC FN20', utc: 120000 }, - { name: 'self-Q65-60A', q65Period: 60, q65Submode: 'A', message: 'CQ K1ABC FN20', utc: 120000 }, - { name: 'self-Q65-60B', q65Period: 60, q65Submode: 'B', message: 'CQ K1ABC FN20', utc: 120000 }, -]; - -const selfReports = []; -for (const sample of selfCases) { - const encoded = await lib.encode(WSJTXMode.Q65, sample.message, 1500, { threads: 1, q65Period: sample.q65Period, q65Submode: sample.q65Submode }); - const { probeResults, bestMessages } = await probeDecode({ audio: encoded.audioData, q65Period: sample.q65Period, q65Submode: sample.q65Submode, utc: sample.utc }); - const entry = { - kind: 'self-generated', - sample: sample.name, - generated: { message: sample.message, messageSent: encoded.messageSent.trim(), sampleRate: encoded.sampleRate, samples: encoded.audioData.length, seconds: encoded.audioData.length / encoded.sampleRate, stats: audioStats(encoded.audioData) }, - q65: { period: sample.q65Period, submode: sample.q65Submode }, - utc: sample.utc, - bestDecodedCount: bestMessages.length, - probes: probeResults, - }; - console.log(JSON.stringify(entry)); - selfReports.push(entry); -} - -const officialReports = []; -for (const sample of samples) { - const buffer = await getSample(sample.path); - const { audio, sampleRate, format } = parseWav(buffer); - if (sampleRate !== 12000) throw new Error(`${sample.name}: expected 12000 Hz sample rate, got ${sampleRate}`); - - const utc = utcFromSamplePath(sample.path); - const { probeResults, bestMessages } = await probeDecode({ audio, q65Period: sample.q65Period, q65Submode: sample.q65Submode, utc }); - - const entry = { - kind: 'official-wsjtx-sample', - sample: sample.name, - path: sample.path, - sampleUtcFromName: utc, - wav: { sampleRate, samples: audio.length, seconds: audio.length / sampleRate, format, stats: audioStats(audio) }, - q65: { period: sample.q65Period, submode: sample.q65Submode }, - bestDecodedCount: bestMessages.length, - probes: probeResults, - }; - console.log(JSON.stringify(entry)); - officialReports.push(entry); -} - -const selfFailures = selfReports.filter((r) => r.bestDecodedCount === 0).length; -const officialFailures = officialReports.filter((r) => r.bestDecodedCount === 0).length; -const report = { - generatedAt: new Date().toISOString(), - requireDecode, - summary: { - selfTotal: selfReports.length, - selfZeroDecode: selfFailures, - officialTotal: officialReports.length, - officialZeroDecode: officialFailures, - }, - self: selfReports, - official: officialReports, -}; -await writeFile(reportPath, JSON.stringify(report, null, 2)); - -const firstRaw = [...selfReports, ...officialReports] - .flatMap((r) => r.probes.map((p) => p.rawOutput ?? [])) - .find((lines) => lines.length > 0) ?? []; - -const markdown = [ - '# Q65 diagnostic report', - '', - `- Self-generated samples: ${selfReports.length - selfFailures}/${selfReports.length} produced at least one decode`, - `- Official WSJT-X samples: ${officialReports.length - officialFailures}/${officialReports.length} produced at least one decode`, - `- Strict failure mode: ${requireDecode ? 'enabled' : 'disabled'}`, - `- First captured raw-output lines: ${firstRaw.length}`, - '', - '## Self-generated', - '| sample | period | submode | seconds | peak | rms | decoded | raw lines |', - '|---|---:|---|---:|---:|---:|---:|---:|', - ...selfReports.map((r) => `| ${r.sample} | ${r.q65.period} | ${r.q65.submode} | ${r.generated.seconds} | ${r.generated.stats.peak} | ${r.generated.stats.rms.toFixed(6)} | ${r.bestDecodedCount} | ${Math.max(...r.probes.map((p) => p.rawOutputLineCount))} |`), - '', - '## Official WSJT-X samples', - '| sample | period | submode | seconds | peak | rms | decoded | raw lines |', - '|---|---:|---|---:|---:|---:|---:|---:|', - ...officialReports.map((r) => `| ${r.sample} | ${r.q65.period} | ${r.q65.submode} | ${r.wav.seconds} | ${r.wav.stats.peak} | ${r.wav.stats.rms.toFixed(6)} | ${r.bestDecodedCount} | ${Math.max(...r.probes.map((p) => p.rawOutputLineCount))} |`), - '', - '## First captured raw output', - '```text', - ...firstRaw.slice(0, 40), - '```', - '', - `Full JSON report: \`${reportPath}\``, - '', -].join('\n'); - -if (summaryPath) await writeFile(summaryPath, markdown, { flag: 'a' }); -console.log(JSON.stringify(report.summary)); - -if (requireDecode && (selfFailures > 0 || officialFailures > 0)) { - throw new Error(`${selfFailures} self-generated Q65 sample(s) and ${officialFailures} official Q65 sample(s) produced zero decodes across all probes`); -} From 4df01d4717345b5118f2bac0e886f1fee3f17acf Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 18:00:42 +0800 Subject: [PATCH 41/47] Remove Q65 diagnostic overlay --- cmake/patch-q65-diagnostics.cmake | 193 ------------------------------ 1 file changed, 193 deletions(-) delete mode 100644 cmake/patch-q65-diagnostics.cmake diff --git a/cmake/patch-q65-diagnostics.cmake b/cmake/patch-q65-diagnostics.cmake deleted file mode 100644 index 10e2a37..0000000 --- a/cmake/patch-q65-diagnostics.cmake +++ /dev/null @@ -1,193 +0,0 @@ -# Second-stage Q65 diagnostic overlay. -# This keeps the public API stable while making Q65 disk-decode state and -# raw Fortran stdout observable from Node during CI diagnostics. - -set(_WSJTX_LIB_DIR "${CMAKE_SOURCE_DIR}/wsjtx_lib") -set(_WSJTX_NATIVE_DIR "${CMAKE_SOURCE_DIR}/native") - -# ---- N-API wrapper: options and raw stdout capture ------------------------- -set(_wrapper_h "${_WSJTX_NATIVE_DIR}/wsjtx_wrapper.h") -wsjtx_replace_once( - "${_wrapper_h}" - " wsjtx_decode_options_t options_; std::vector messages_; int numMessages_ = 0;" - " wsjtx_decode_options_t options_; std::vector messages_; int numMessages_ = 0;\n std::vector rawOutput_;" - "store raw decoder output in DecodeWorker") - -set(_wrapper_cpp "${_WSJTX_NATIVE_DIR}/wsjtx_wrapper.cpp") -wsjtx_replace_once( - "${_wrapper_cpp}" - "#include \n#include \n#include " - "#include \n#include \n#include \n#include \n#include \n#include \n#ifndef _WIN32\n#include \n#endif" - "include helpers for raw stdout capture") - -set(_capture_helpers [=[ - std::vector splitCapturedLines(const std::string& text) - { - std::vector lines; - std::istringstream input(text); - std::string line; - while (std::getline(input, line)) { - if (!line.empty() && line.back() == '\r') line.pop_back(); - if (!line.empty()) lines.push_back(line); - } - return lines; - } - -#ifndef _WIN32 - std::mutex& stdoutCaptureMutex() - { - static std::mutex mutex; - return mutex; - } - - template - std::vector captureStdout(Fn&& fn) - { - std::lock_guard lock(stdoutCaptureMutex()); - fflush(stdout); - int pipefd[2]; - if (pipe(pipefd) != 0) { - fn(); - return {}; - } - int saved = dup(STDOUT_FILENO); - if (saved < 0) { - close(pipefd[0]); - close(pipefd[1]); - fn(); - return {}; - } - dup2(pipefd[1], STDOUT_FILENO); - fn(); - fflush(stdout); - dup2(saved, STDOUT_FILENO); - close(saved); - close(pipefd[1]); - std::string captured; - char buffer[4096]; - ssize_t n = 0; - while ((n = read(pipefd[0], buffer, sizeof(buffer))) > 0) { - captured.append(buffer, static_cast(n)); - } - close(pipefd[0]); - return splitCapturedLines(captured); - } -#endif -]=]) -wsjtx_replace_once( - "${_wrapper_cpp}" - " int g_encodeSampleRate = 0;\n\n int getOptionalInt" - " int g_encodeSampleRate = 0;\n${_capture_helpers}\n int getOptionalInt" - "add raw stdout capture helpers") - -wsjtx_replace_once( - "${_wrapper_cpp}" - " opts.tx_frequency = getOptionalInt(optObj, \"txFrequency\", opts.frequency);" - " opts.tx_frequency = getOptionalInt(optObj, \"txFrequency\", opts.frequency);\n opts.utc = getOptionalInt(optObj, \"utc\", -1);\n opts.disk_data = getOptionalBoolAsInt(optObj, \"diskData\", mode == WSJTX_MODE_Q65 ? 1 : 0);\n opts.new_data = getOptionalBoolAsInt(optObj, \"newData\", 1);\n opts.again = getOptionalBoolAsInt(optObj, \"again\", 0);\n opts.capture_output = getOptionalBoolAsInt(optObj, \"captureOutput\", mode == WSJTX_MODE_Q65 ? 1 : 0);" - "read Q65 disk decode diagnostic options") - -set(_decode_execute [=[ - void DecodeWorker::Execute() - { - auto runDecode = [&]() -> int { - if (useFloat_) { - return wsjtx_decode_float_v2(handle_, mode_, - floatData_.data(), static_cast(floatData_.size()), - &options_); - } - return wsjtx_decode_int16_v2(handle_, mode_, - reinterpret_cast(intData_.data()), - static_cast(intData_.size()), - &options_); - }; - - int rc; -#ifndef _WIN32 - if (options_.capture_output) { - rc = WSJTX_ERR_EXCEPTION; - rawOutput_ = captureStdout([&]() { rc = runDecode(); }); - } else { - rc = runDecode(); - } -#else - rc = runDecode(); -#endif - if (rc == WSJTX_OK) { - messages_.resize(MAX_MSGS); - numMessages_ = wsjtx_pull_messages(handle_, messages_.data(), MAX_MSGS); - } else { - SetError("Decode failed with error code " + std::to_string(rc)); - } - } -]=]) -wsjtx_replace_once( - "${_wrapper_cpp}" - " void DecodeWorker::Execute()\n {\n int rc;\n if (useFloat_) {\n rc = wsjtx_decode_float_v2(handle_, mode_,\n floatData_.data(), static_cast(floatData_.size()),\n &options_);\n } else {\n rc = wsjtx_decode_int16_v2(handle_, mode_,\n reinterpret_cast(intData_.data()),\n static_cast(intData_.size()),\n &options_);\n }\n if (rc == WSJTX_OK) {\n messages_.resize(MAX_MSGS);\n numMessages_ = wsjtx_pull_messages(handle_, messages_.data(), MAX_MSGS);\n } else {\n SetError(\"Decode failed with error code \" + std::to_string(rc));\n }\n }" - "${_decode_execute}" - "capture raw decoder stdout in DecodeWorker") - -wsjtx_replace_once( - "${_wrapper_cpp}" - " result.Set(\"messages\", msgs);\n result.Set(\"success\", Napi::Boolean::New(env, true));" - " result.Set(\"messages\", msgs);\n Napi::Array raw = Napi::Array::New(env, rawOutput_.size());\n for (size_t i = 0; i < rawOutput_.size(); i++) raw[i] = Napi::String::New(env, rawOutput_[i]);\n result.Set(\"rawOutput\", raw);\n result.Set(\"success\", Napi::Boolean::New(env, true));" - "return rawOutput from DecodeWorker") - -# ---- C API -> wsjtx_lib disk controls -------------------------------------- -set(_c_api_cpp "${_WSJTX_NATIVE_DIR}/wsjtx_c_api.cpp") -wsjtx_replace_once( - "${_c_api_cpp}" - " lib->setDecodeUtc(opts->utc);" - " lib->setDecodeUtc(opts->utc);\n lib->setDecodeDiskControls(opts->disk_data != 0, opts->new_data != 0, opts->again != 0);" - "forward disk decode controls through C ABI") - -# ---- wsjtx_lib disk-control plumbing --------------------------------------- -set(_lib_h "${_WSJTX_LIB_DIR}/wsjtx_lib.h") -wsjtx_replace_once( - "${_lib_h}" - "\tvoid setDecodeUtc(int utc);\n\tvoid setDecodeQ65Controls" - "\tvoid setDecodeUtc(int utc);\n\tvoid setDecodeDiskControls(bool diskData, bool newData, bool again);\n\tvoid setDecodeQ65Controls" - "declare wsjtx_lib::setDecodeDiskControls") -wsjtx_replace_once( - "${_lib_h}" - "\tint decode_utc_ = -1;\n\tint q65_period_" - "\tint decode_utc_ = -1;\n\tbool disk_data_ = false;\n\tbool new_data_ = true;\n\tbool again_ = false;\n\tint q65_period_" - "add wsjtx_lib disk decode state") - -set(_lib_cpp "${_WSJTX_LIB_DIR}/wsjtx_lib.cpp") -wsjtx_replace_once( - "${_lib_cpp}" - "void wsjtx_lib::setDecodeUtc(int utc)\n{\n\tdecode_utc_ = utc;\n}\n\nvoid wsjtx_lib::setDecodeQ65Controls" - "void wsjtx_lib::setDecodeUtc(int utc)\n{\n\tdecode_utc_ = utc;\n}\n\nvoid wsjtx_lib::setDecodeDiskControls(bool diskData, bool newData, bool again)\n{\n\tdisk_data_ = diskData;\n\tnew_data_ = newData;\n\tagain_ = again;\n}\n\nvoid wsjtx_lib::setDecodeQ65Controls" - "implement wsjtx_lib::setDecodeDiskControls") -wsjtx_replace_once( - "${_lib_cpp}" - "\tptr->setDecodeUtc(decode_utc_);\n\tptr->setDecodeQ65Controls" - "\tptr->setDecodeUtc(decode_utc_);\n\tptr->setDecodeDiskControls(disk_data_, new_data_, again_);\n\tptr->setDecodeQ65Controls" - "forward disk controls to decoder") - -# ---- wstjx_decode disk controls and WSJT-X-like params --------------------- -set(_decode_h "${_WSJTX_LIB_DIR}/wsjtx_decode.h") -wsjtx_replace_once( - "${_decode_h}" - "\tvoid setDecodeUtc(int utc);\n\tvoid setDecodeQ65Controls" - "\tvoid setDecodeUtc(int utc);\n\tvoid setDecodeDiskControls(bool diskData, bool newData, bool again);\n\tvoid setDecodeQ65Controls" - "declare wstjx_decode::setDecodeDiskControls") -wsjtx_replace_once( - "${_decode_h}" - "\tint decode_utc_ = -1;\n\tint q65_period_" - "\tint decode_utc_ = -1;\n\tbool disk_data_ = false;\n\tbool new_data_ = true;\n\tbool again_ = false;\n\tint q65_period_" - "add wstjx_decode disk decode state") - -set(_decode_cpp "${_WSJTX_LIB_DIR}/wsjtx_decode.cpp") -wsjtx_replace_once( - "${_decode_cpp}" - "void wstjx_decode::setDecodeUtc(int utc) {\n\tdecode_utc_ = utc;\n}\nvoid wstjx_decode::setDecodeQ65Controls" - "void wstjx_decode::setDecodeUtc(int utc) {\n\tdecode_utc_ = utc;\n}\nvoid wstjx_decode::setDecodeDiskControls(bool diskData, bool newData, bool again) {\n\tdisk_data_ = diskData;\n\tnew_data_ = newData;\n\tagain_ = again;\n}\nvoid wstjx_decode::setDecodeQ65Controls" - "implement wstjx_decode::setDecodeDiskControls") -wsjtx_replace_once( - "${_decode_cpp}" - "\tparams.nutc = decode_utc_ >= 0 ? decode_utc_ : (local_tm.tm_hour * 10000 + local_tm.tm_min * 100 + local_tm.tm_sec);" - "\tparams.nutc = decode_utc_ >= 0 ? decode_utc_ : (local_tm.tm_hour * 10000 + local_tm.tm_min * 100 + local_tm.tm_sec);\n\tparams.ndiskdat = disk_data_;\n\tparams.newdat = new_data_;\n\tparams.nagain = again_;" - "set disk decode params") - -include("${CMAKE_SOURCE_DIR}/cmake/patch-q65-input-diagnostics.cmake") From 99176dbf8132e853de31d2830bfea8b31d1b4ac0 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 18:02:38 +0800 Subject: [PATCH 42/47] Stop applying removed Q65 diagnostic overlay --- CMakeLists.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2190727..da44aa3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -256,11 +256,10 @@ include_directories( # Dependencies for the core library set(LIBRARIES_FROM_REFERENCES ${FFTW3F_LIBRARIES} ${FFTW_THREADS_LIBRARIES}) -# Build the Fortran/C++ core. Apply the Q65 source overlays before the +# 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") - include("${CMAKE_SOURCE_DIR}/cmake/patch-q65-diagnostics.cmake") endif() add_subdirectory(wsjtx_lib) link_directories(${FFTW3F_LIBRARY_DIRS}) From 33cb1d70a26b0a6745116225559ed51af0083852 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 18:03:59 +0800 Subject: [PATCH 43/47] Remove Q65 input diagnostic overlay file --- cmake/patch-q65-input-diagnostics.cmake | 57 ------------------------- 1 file changed, 57 deletions(-) delete mode 100644 cmake/patch-q65-input-diagnostics.cmake diff --git a/cmake/patch-q65-input-diagnostics.cmake b/cmake/patch-q65-input-diagnostics.cmake deleted file mode 100644 index 56a1985..0000000 --- a/cmake/patch-q65-input-diagnostics.cmake +++ /dev/null @@ -1,57 +0,0 @@ -# Q65 input-path diagnostics and fixes. -# The original float decoder path moved the input vector into samplebuffer before -# copying samples into dec_data.d2. After std::move, audiosamples may be empty, -# which makes generated Float32 Q65 round-trip tests meaningless. Keep the input -# available and print the exact Q65 params that reach multimode_decoder_. - -set(_WSJTX_LIB_DIR "${CMAKE_SOURCE_DIR}/wsjtx_lib") -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 parameter diagnostics") - -wsjtx_replace_once( - "${_decode_cpp}" - "\tsamplebuffer.push(std::move(audiosamples));" - "\tsamplebuffer.push(WsjTxVector(audiosamples));" - "preserve Float32 decode samples after samplebuffer push") - -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_param_print [=[ - if (mode == Q65) { - std::printf(" nmode=%d ntr=%d kin=%d nzhsym=%d nsubmode=%d nfqso=%d nftx=%d nfa=%d nfb=%d ntol=%d ndiskdat=%d newdat=%d nagain=%d ndepth=%d nexp=%d max_drift=%d nclearave=%d nutc=%d\n", - params.nmode, - params.ntrperiod, - params.kin, - params.nzhsym, - params.nsubmode, - params.nfqso, - params.nftx, - params.nfa, - params.nfb, - params.ntol, - params.ndiskdat ? 1 : 0, - params.newdat ? 1 : 0, - params.nagain ? 1 : 0, - params.ndepth, - params.nexp_decode, - params.max_drift, - params.nclearave ? 1 : 0, - params.nutc); - std::fflush(stdout); - } - fftwf_plan_with_nthreads(threads);]=]) - -wsjtx_replace_once( - "${_decode_cpp}" - "\tfftwf_plan_with_nthreads(threads);" - "${_q65_param_print}" - "print Q65 params before multimode_decoder") From 4cbabb57629e16573e7af0c474db829ed921f487 Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 18:04:46 +0800 Subject: [PATCH 44/47] Restore stable C ABI for Q65 --- native/wsjtx_c_api.h | 55 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/native/wsjtx_c_api.h b/native/wsjtx_c_api.h index 7ca7838..5cf5d7f 100644 --- a/native/wsjtx_c_api.h +++ b/native/wsjtx_c_api.h @@ -1,5 +1,12 @@ /** * 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 @@ -22,8 +29,10 @@ 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 @@ -32,6 +41,7 @@ typedef void* wsjtx_handle_t; #define WSJTX_ERR_INVALID_SAMPLE_RATE -5 #define WSJTX_ERR_EXCEPTION -99 +/* Mode enumeration (must match wsjtxMode in wsjtx_lib.h) */ typedef enum { WSJTX_MODE_FT8 = 0, WSJTX_MODE_FT4 = 1, @@ -45,6 +55,7 @@ typedef enum { WSJTX_MODE_WSPR = 9 } wsjtx_mode_t; +/* Decoded message (C-compatible version of WsjtxMessage) */ typedef struct { int hh; int min; @@ -56,6 +67,7 @@ typedef struct { char msg[64]; } wsjtx_message_t; +/* WSPR decoder options (C-compatible version of decoder_options) */ typedef struct { int freq; char rcall[13]; @@ -66,6 +78,7 @@ typedef struct { int subtraction; } wsjtx_decoder_options_t; +/* WSPR decoder result (C-compatible version of decoder_results) */ typedef struct { double freq; float sync; @@ -80,16 +93,40 @@ 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) + * - threads: thread hint forwarded to the decoder (1..N) + * - low_freq: decoder scan low limit in Hz (default 200) + * - high_freq: decoder scan high limit in Hz (default 4000) + * - tolerance: frequency tolerance in Hz (default 20) + * - mycall: local callsign for AP decode (empty = none) + * - mygrid: local grid for AP decode (empty = none) + * - 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 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; int tx_frequency; - int utc; /* HHMMSS, or -1 to use current local time */ int threads; int low_freq; int high_freq; @@ -97,10 +134,6 @@ typedef struct { int ap_decode; int decode_depth; int qso_progress; - int disk_data; - int new_data; - int again; - int capture_output; int q65_period; int q65_submode; int q65_max_drift; @@ -113,9 +146,13 @@ typedef struct { char hisgrid[7]; } wsjtx_decode_options_t; +/* ---- Lifecycle ---- */ + WSJTX_API wsjtx_handle_t wsjtx_create(void); WSJTX_API void wsjtx_destroy(wsjtx_handle_t handle); +/* ---- Decode ---- */ + WSJTX_API int wsjtx_decode_float(wsjtx_handle_t handle, int mode, float* samples, int num_samples, int freq, int threads); @@ -130,6 +167,8 @@ 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 ---- */ + 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, @@ -140,16 +179,22 @@ WSJTX_API int wsjtx_encode_v2(wsjtx_handle_t handle, int mode, int freq, int sam float* out_samples, int* out_num_samples, int out_buf_size, char* out_message_sent, int out_msg_buf_size); +/* ---- Message queue ---- */ + WSJTX_API int wsjtx_pull_message(wsjtx_handle_t handle, wsjtx_message_t* out_msg); WSJTX_API int wsjtx_pull_messages(wsjtx_handle_t handle, wsjtx_message_t* out_messages, int max_messages); +/* ---- 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); +/* ---- 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); From 598adcba7b93b037825258473f679f189a82164f Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 18:08:06 +0800 Subject: [PATCH 45/47] Fold Q65 input fix into production overlay --- cmake/patch-wsjtx-q65.cmake | 59 +++++++++++++++---------------------- 1 file changed, 24 insertions(+), 35 deletions(-) diff --git a/cmake/patch-wsjtx-q65.cmake b/cmake/patch-wsjtx-q65.cmake index 1927b3c..f445d1e 100644 --- a/cmake/patch-wsjtx-q65.cmake +++ b/cmake/patch-wsjtx-q65.cmake @@ -20,22 +20,6 @@ function(wsjtx_replace_once file needle replacement description) endfunction() set(_WSJTX_LIB_DIR "${CMAKE_SOURCE_DIR}/wsjtx_lib") -set(_WSJTX_NATIVE_DIR "${CMAKE_SOURCE_DIR}/native") - -# ---- native wrapper/C ABI UTC plumbing ------------------------------------- -set(_wrapper_cpp "${_WSJTX_NATIVE_DIR}/wsjtx_wrapper.cpp") -wsjtx_replace_once( - "${_wrapper_cpp}" - " opts.tx_frequency = getOptionalInt(optObj, \"txFrequency\", opts.frequency);" - " opts.tx_frequency = getOptionalInt(optObj, \"txFrequency\", opts.frequency);\n opts.utc = getOptionalInt(optObj, \"utc\", -1);" - "forward optional decode UTC from N-API wrapper") - -set(_c_api_cpp "${_WSJTX_NATIVE_DIR}/wsjtx_c_api.cpp") -wsjtx_replace_once( - "${_c_api_cpp}" - " lib->setDecodeRange(opts->low_freq, opts->high_freq, opts->tolerance);" - " lib->setDecodeRange(opts->low_freq, opts->high_freq, opts->tolerance);\n lib->setDecodeUtc(opts->utc);" - "forward optional decode UTC through C ABI") # ---- wsjtx_encode.h -------------------------------------------------------- set(_encode_h "${_WSJTX_LIB_DIR}/wsjtx_encode.h") @@ -109,26 +93,26 @@ wsjtx_replace_once( 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 setDecodeUtc(int utc);\n\tvoid setDecodeQ65Controls(int period, int submode, int maxDrift, bool clearAveraging, bool singleDecode, bool averaging);" - "declare wsjtx_lib::setDecodeUtc and Q65 controls") + "\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 decode_utc_ = -1;\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 UTC/Q65 decode state") + "\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::setDecodeUtc(int utc)\n{\n\tdecode_utc_ = utc;\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 UTC/Q65 controls") + "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->setDecodeUtc(decode_utc_);\n\tptr->setDecodeQ65Controls(q65_period_, q65_submode_, q65_max_drift_, q65_clear_averaging_, q65_single_decode_, q65_averaging_);" - "forward UTC/Q65 decode controls") + "\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)" @@ -145,13 +129,13 @@ 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 setDecodeUtc(int utc);\n\tvoid setDecodeQ65Controls(int period, int submode, int maxDrift, bool clearAveraging, bool singleDecode, bool averaging);" - "declare wstjx_decode UTC/Q65 controls") + "\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 decode_utc_ = -1;\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 UTC/Q65 state") + "\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") @@ -163,8 +147,18 @@ wsjtx_replace_once( 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::setDecodeUtc(int utc) {\n\tdecode_utc_ = utc;\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 UTC/Q65 controls") + "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; @@ -186,11 +180,6 @@ wsjtx_replace_once( "\tcase FT8: params.nmode = 8; break;\n\tcase FT4: params.nmode = 5; break;\n\tdefault: return;" "${_q65_switch}" "select parameterized Q65 decoder mode") -wsjtx_replace_once( - "${_decode_cpp}" - "\tparams.nutc = local_tm.tm_hour * 10000 + local_tm.tm_min * 100 + local_tm.tm_sec;" - "\tparams.nutc = decode_utc_ >= 0 ? decode_utc_ : (local_tm.tm_hour * 10000 + local_tm.tm_min * 100 + local_tm.tm_sec);" - "use provided decode UTC when present") # ---- lib/decode_callbacks.f90 --------------------------------------------- set(_callbacks_f90 "${_WSJTX_LIB_DIR}/lib/decode_callbacks.f90") From ac83f603a558b9847ea5caf24fd198f6a2de990c Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 18:10:16 +0800 Subject: [PATCH 46/47] Add Q65 self round-trip regression test --- test/wsjtx.basic.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/wsjtx.basic.test.ts b/test/wsjtx.basic.test.ts index cad5716..d2f0169 100644 --- a/test/wsjtx.basic.test.ts +++ b/test/wsjtx.basic.test.ts @@ -143,6 +143,29 @@ describe('WSJTX library — smoke', () => { 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 }), From 5ab2a08f44d39a57239753107a8bbc6a81dbdffc Mon Sep 17 00:00:00 2001 From: iu_yang1 Date: Sat, 23 May 2026 18:17:02 +0800 Subject: [PATCH 47/47] Document Q65 encode and decode usage --- README.md | 166 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 121 insertions(+), 45 deletions(-) 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)