diff --git a/src/components/waterfall/WaterfallCard.tsx b/src/components/waterfall/WaterfallCard.tsx index 718a757..d69439d 100644 --- a/src/components/waterfall/WaterfallCard.tsx +++ b/src/components/waterfall/WaterfallCard.tsx @@ -461,9 +461,50 @@ export function WaterfallCard({ ); const ft8Unread = decoders.unread.ft8 ?? 0; + const msk144Unread = decoders.unread.msk144 ?? 0; + const wsjtUnread = useMemo(() => ft8Unread + msk144Unread, [ft8Unread, msk144Unread]); + const ft8Enabled = !!decoders.enabled.ft8; + const msk144Enabled = !!decoders.enabled.msk144; + const wsjtEnabled = useMemo(() => ft8Enabled || msk144Enabled, [ft8Enabled, msk144Enabled]); + const ft8Error = decoders.errors?.ft8 ?? null; - const ft8Lines = useMemo(() => decoders.lines.filter((l) => l.decoder === 'ft8'), [decoders.lines]); + const msk144Error = decoders.errors?.msk144 ?? null; + + const wsjtLines = useMemo(() => decoders.lines.filter((l) => l.decoder === 'ft8' || l.decoder === 'msk144'), [decoders.lines]); + + const wsjtClear = () => { + decoders.clear('ft8'); + decoders.clear('msk144'); + } + + const wsjtMarkRead = () => { + decoders.markRead('ft8'); + decoders.markRead('msk144'); + } + + const fmtTitle = useMemo(() => { + let arr = []; + if (ft8Enabled) { + arr.push("FT8") + } + if (msk144Enabled) { + arr.push("MSK144") + } + return arr.join("+"); + }, [ft8Enabled, msk144Enabled]); + + const fmtError = useMemo(() => { + let arr = []; + if (ft8Error) { + arr.push(`FT8 decoder error: ${ft8Error}`); + } + if (msk144Error) { + arr.push(`MSK144 decoder error: ${msk144Error}`); + } + return arr.join("; "); + }, [ft8Error, msk144Error]); + const serverGrid = (gridLocator ?? '').trim().toUpperCase(); const hasValidServerGrid = isValidGrid(serverGrid); const ft8BaseLatLon = useMemo(() => { @@ -474,7 +515,7 @@ export function WaterfallCard({ const farthestKm = useMemo(() => { if (!ft8BaseLatLon) return null; let max = 0; - for (const l of ft8Lines) { + for (const l of wsjtLines) { const locs = extractGridLocators(l.text); if (locs.length === 0) continue; const target = gridSquareToLatLong(locs[0]); @@ -482,7 +523,7 @@ export function WaterfallCard({ if (Number.isFinite(km) && km > max) max = km; } return max > 0 ? max : 0; - }, [ft8BaseLatLon, ft8Lines]); + }, [ft8BaseLatLon, wsjtLines]); return ( @@ -586,9 +627,9 @@ export function WaterfallCard({ @@ -619,9 +660,18 @@ export function WaterfallCard({ FT8 + { + decoders.toggle('msk144', !!checked); + }} + > + MSK144 + + { // Avoid the same click that closes the dropdown also immediately closing the dialog. e.preventDefault(); @@ -632,12 +682,12 @@ export function WaterfallCard({ Show decodes { - decoders.clear('ft8'); + wsjtClear(); }} > - Clear FT8 + Clear Decodes @@ -650,9 +700,9 @@ export function WaterfallCard({ open={decodesOpen} onOpenChange={(open) => { setDecodesOpen(open); - if (open) decoders.markRead('ft8'); + if (open) wsjtMarkRead(); }} - title="FT8 Decodes" + title={`${fmtTitle} Decodes`} description="Decoding runs in the background; this list updates automatically." contentClassName="max-w-xl" footer={ @@ -664,9 +714,9 @@ export function WaterfallCard({ type="button" variant="secondary" onClick={() => { - decoders.clear('ft8'); + wsjtClear(); }} - disabled={ft8Lines.length === 0} + disabled={wsjtLines.length === 0} > Clear @@ -693,16 +743,16 @@ export function WaterfallCard({
- {ft8Lines.length === 0 ? ( + {wsjtLines.length === 0 ? (
- {ft8Error - ? `FT8 decoder error: ${ft8Error}` - : ft8Enabled + {(ft8Error || msk144Error) + ? fmtError + : (wsjtEnabled) ? 'Waiting for decodes…' - : 'Enable FT8 in the Decoders menu to start.'} + : 'Enable FT8 or MSK144 in the Decoders menu to start.'}
) : ( - ft8Lines.map((l) => { + wsjtLines.map((l) => { const locs = extractGridLocators(l.text); const first = locs[0]; const km = @@ -1067,12 +1117,25 @@ export function WaterfallCard({
+
+
MSK144
+ +
+
diff --git a/src/decoders/msk144/msk144Worker.ts b/src/decoders/msk144/msk144Worker.ts new file mode 100644 index 0000000..d9fb9f2 --- /dev/null +++ b/src/decoders/msk144/msk144Worker.ts @@ -0,0 +1,121 @@ +// +// WebWorker: MSK144 decoder +// + +import init, { Msk144WasmDecoder, main } from './pkg/msk144_decoder.js'; + + +type InitMsg = { type: 'init'; inputSampleRate: number; timeOffsetMs?: number }; +type PcmMsg = { type: 'pcm'; pcm: Float32Array }; +type TimeOffsetMsg = { type: 'timeOffset'; offsetMs: number }; +type StopMsg = { type: 'stop' }; +type Msg = InitMsg | PcmMsg | TimeOffsetMsg | StopMsg; + +type WorkerOut = + | { type: 'ready' } + | { type: 'log'; text: string } + | { type: 'error'; message: string }; + + +function post(out: WorkerOut) { + (self as any).postMessage(out); +} + +function log(text: string) { + post({ type: 'log', text }); +} + +// msk144 accepts chunks of ~0.3 second. +const WORKING_LOOP_DELAY_IN_MILLISECONDS = 300; + +// 100k samples for 48ksps means ~2 seconds buffer. More than enough. +const BIG_BUFFER_SIZE_IN_ELEMENTS = 100_000; + +const big_array = new Float32Array(BIG_BUFFER_SIZE_IN_ELEMENTS); +let tail_pos = 0; + +let decoder_msk144: Msk144WasmDecoder | null = null; +let need_stop = false; +let scheduled = false; + +function scheduleDecodeLoop() { + if (scheduled || need_stop) return; + scheduled = true; + + const tick = () => { + if (need_stop) return; + + // feed data to process + for (;;) { + let taken = 0; + try { + const slice = big_array.subarray(0, tail_pos); + taken = decoder_msk144!.process(slice); + } catch (e: any) { + post({ type: 'error', message: e?.message ?? String(e) }); + } + if (taken == 0) break; + big_array.copyWithin(0, taken, tail_pos); + tail_pos -= taken; + if (tail_pos == 0) break; + } + + // gather results + for (;;) { + try { + const res = decoder_msk144!.take_next_result(); + if (!res) break; + log(res); + } catch (e: any) { + post({ type: 'error', message: e?.message ?? String(e) }); + } + } + + setTimeout(tick, WORKING_LOOP_DELAY_IN_MILLISECONDS); + }; + + setTimeout(tick, WORKING_LOOP_DELAY_IN_MILLISECONDS); +} + +async function worker_init(inputSampleRate: number, _offset: number) { + await init(); + main(); + let silence_threshold = 0.001; // this means it always works + let ntol = 250.0; // 1500Hz +-?Hz + decoder_msk144 = new Msk144WasmDecoder(inputSampleRate, silence_threshold, ntol); + console.log("created Msk144WasmDecoder for ", inputSampleRate, silence_threshold, ntol); + + scheduleDecodeLoop(); + post({ type: 'ready' }); +} + +function pushPcm(pcm: Float32Array) { + if (tail_pos + pcm.length > BIG_BUFFER_SIZE_IN_ELEMENTS) { + // overrun - drop audio samples + console.log("overrun"); + return; + } + big_array.set(pcm, tail_pos); + tail_pos += pcm.length; +} + +self.onmessage = (ev: MessageEvent) => { + const msg = ev.data; + if (msg.type === 'stop') { + need_stop = true; + return; + } + if (msg.type === 'timeOffset') { + // msk144 doesn't have timeOffset + return; + } + if (msg.type === 'init') { + worker_init(msg.inputSampleRate, msg.timeOffsetMs ?? 0).catch((e: any) => { + post({ type: 'error', message: e?.message ?? String(e) }); + }); + return; + } + if (msg.type === 'pcm') { + pushPcm(msg.pcm); + } +}; diff --git a/src/decoders/msk144/pkg/msk144_decoder.d.ts b/src/decoders/msk144/pkg/msk144_decoder.d.ts new file mode 100644 index 0000000..eafb39d --- /dev/null +++ b/src/decoders/msk144/pkg/msk144_decoder.d.ts @@ -0,0 +1,52 @@ +/* tslint:disable */ +/* eslint-disable */ + +export class Msk144WasmDecoder { + free(): void; + [Symbol.dispose](): void; + constructor(input_sample_rate: number, silence_threshold: number, ntol: number); + process(data: Float32Array): number; + set_mode(mode: number): void; + take_next_result(): string | undefined; +} + +export function main(): void; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly __wbg_msk144wasmdecoder_free: (a: number, b: number) => void; + readonly msk144wasmdecoder_new: (a: number, b: number, c: number) => number; + readonly msk144wasmdecoder_process: (a: number, b: number, c: number) => number; + readonly msk144wasmdecoder_set_mode: (a: number, b: number) => void; + readonly msk144wasmdecoder_take_next_result: (a: number) => [number, number]; + readonly main: () => void; + readonly __wbindgen_free: (a: number, b: number, c: number) => void; + readonly __wbindgen_malloc: (a: number, b: number) => number; + readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_externrefs: WebAssembly.Table; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/src/decoders/msk144/pkg/msk144_decoder.js b/src/decoders/msk144/pkg/msk144_decoder.js new file mode 100644 index 0000000..169fc60 --- /dev/null +++ b/src/decoders/msk144/pkg/msk144_decoder.js @@ -0,0 +1,301 @@ +/* @ts-self-types="./msk144_decoder.d.ts" */ + +export class Msk144WasmDecoder { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + Msk144WasmDecoderFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_msk144wasmdecoder_free(ptr, 0); + } + /** + * @param {number} input_sample_rate + * @param {number} silence_threshold + * @param {number} ntol + */ + constructor(input_sample_rate, silence_threshold, ntol) { + const ret = wasm.msk144wasmdecoder_new(input_sample_rate, silence_threshold, ntol); + this.__wbg_ptr = ret; + Msk144WasmDecoderFinalization.register(this, this.__wbg_ptr, this); + return this; + } + /** + * @param {Float32Array} data + * @returns {number} + */ + process(data) { + const ptr0 = passArrayF32ToWasm0(data, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.msk144wasmdecoder_process(this.__wbg_ptr, ptr0, len0); + return ret >>> 0; + } + /** + * @param {number} mode + */ + set_mode(mode) { + wasm.msk144wasmdecoder_set_mode(this.__wbg_ptr, mode); + } + /** + * @returns {string | undefined} + */ + take_next_result() { + const ret = wasm.msk144wasmdecoder_take_next_result(this.__wbg_ptr); + let v1; + if (ret[0] !== 0) { + v1 = getStringFromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 1, 1); + } + return v1; + } +} +if (Symbol.dispose) Msk144WasmDecoder.prototype[Symbol.dispose] = Msk144WasmDecoder.prototype.free; + +export function main() { + wasm.main(); +} +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_throw_9c31b086c2b26051: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) { + let deferred0_0; + let deferred0_1; + try { + deferred0_0 = arg0; + deferred0_1 = arg1; + console.error(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); + } + }, + __wbg_new_227d7c05414eb861: function() { + const ret = new Error(); + return ret; + }, + __wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) { + const ret = arg1.stack; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./msk144_decoder_bg.js": import0, + }; +} + +const Msk144WasmDecoderFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_msk144wasmdecoder_free(ptr, 1)); + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +let cachedFloat32ArrayMemory0 = null; +function getFloat32ArrayMemory0() { + if (cachedFloat32ArrayMemory0 === null || cachedFloat32ArrayMemory0.byteLength === 0) { + cachedFloat32ArrayMemory0 = new Float32Array(wasm.memory.buffer); + } + return cachedFloat32ArrayMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function passArrayF32ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 4, 4) >>> 0; + getFloat32ArrayMemory0().set(arg, ptr / 4); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedFloat32ArrayMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('msk144_decoder_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/src/decoders/msk144/pkg/msk144_decoder_bg.wasm b/src/decoders/msk144/pkg/msk144_decoder_bg.wasm new file mode 100644 index 0000000..769188a Binary files /dev/null and b/src/decoders/msk144/pkg/msk144_decoder_bg.wasm differ diff --git a/src/decoders/msk144/pkg/msk144_decoder_bg.wasm.d.ts b/src/decoders/msk144/pkg/msk144_decoder_bg.wasm.d.ts new file mode 100644 index 0000000..5394ff9 --- /dev/null +++ b/src/decoders/msk144/pkg/msk144_decoder_bg.wasm.d.ts @@ -0,0 +1,14 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const __wbg_msk144wasmdecoder_free: (a: number, b: number) => void; +export const msk144wasmdecoder_new: (a: number, b: number, c: number) => number; +export const msk144wasmdecoder_process: (a: number, b: number, c: number) => number; +export const msk144wasmdecoder_set_mode: (a: number, b: number) => void; +export const msk144wasmdecoder_take_next_result: (a: number) => [number, number]; +export const main: () => void; +export const __wbindgen_free: (a: number, b: number, c: number) => void; +export const __wbindgen_malloc: (a: number, b: number) => number; +export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_externrefs: WebAssembly.Table; +export const __wbindgen_start: () => void; diff --git a/src/decoders/msk144/pkg/package.json b/src/decoders/msk144/pkg/package.json new file mode 100644 index 0000000..7740ae8 --- /dev/null +++ b/src/decoders/msk144/pkg/package.json @@ -0,0 +1,24 @@ +{ + "name": "msk144-decoder", + "type": "module", + "collaborators": [ + "Alexander Sholokhov " + ], + "description": "MSK144 WASM Decoder", + "version": "0.1.0", + "license": "GPL-3.0-only", + "repository": { + "type": "git", + "url": "https://github.com/alexander-sholohov/msk144rs.git" + }, + "files": [ + "msk144_decoder_bg.wasm", + "msk144_decoder.js", + "msk144_decoder.d.ts" + ], + "main": "msk144_decoder.js", + "types": "msk144_decoder.d.ts", + "sideEffects": [ + "./snippets/*" + ] +} \ No newline at end of file diff --git a/src/lib/useDecoders.ts b/src/lib/useDecoders.ts index f9a8476..0153797 100644 --- a/src/lib/useDecoders.ts +++ b/src/lib/useDecoders.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { readTimeOffsetMs } from './timeSync'; -export type DecoderId = 'ft8'; +export type DecoderId = 'ft8' | 'msk144'; export type DecodeLine = { id: string; @@ -29,28 +29,31 @@ function makeId(): string { const GRID_RE = /[A-R]{2}[0-9]{2}([A-X]{2})?/i; export function useDecoders() { - const [enabled, setEnabled] = useState>({ ft8: false }); + const [enabled, setEnabled] = useState>({ ft8: false, msk144: false }); const [lines, setLines] = useState([]); - const [unread, setUnread] = useState>({ ft8: 0 }); - const [errors, setErrors] = useState>({ ft8: null }); + const [unread, setUnread] = useState>({ ft8: 0, msk144: 0 }); + const [errors, setErrors] = useState>({ ft8: null, msk144: null }); - const workerRef = useRef(null); - const readyRef = useRef(false); - const inputRateRef = useRef(null); + const workerFt8Ref = useRef(null); + const readyFt8Ref = useRef(false); + const inputRateFt8Ref = useRef(null); const timeOffsetRef = useRef(0); - const pcmAccRef = useRef(new Float32Array(0)); - const pcmAccLenRef = useRef(0); - const pcmFlushTimerRef = useRef(null); - - const stopWorker = useCallback(() => { - const w = workerRef.current; - workerRef.current = null; - readyRef.current = false; - inputRateRef.current = null; - if (pcmFlushTimerRef.current != null) { - window.clearInterval(pcmFlushTimerRef.current); - pcmFlushTimerRef.current = null; + const pcmFt8AccRef = useRef(new Float32Array(0)); + const pcmFt8AccLenRef = useRef(0); + const pcmFt8FlushTimerRef = useRef(null); + + const workerMsk144Ref = useRef(null); + const inputRateMsk144Ref = useRef(null); + + const stopFT8Worker = useCallback(() => { + const w = workerFt8Ref.current; + workerFt8Ref.current = null; + readyFt8Ref.current = false; + inputRateFt8Ref.current = null; + if (pcmFt8FlushTimerRef.current != null) { + window.clearInterval(pcmFt8FlushTimerRef.current); + pcmFt8FlushTimerRef.current = null; } try { w?.postMessage({ type: 'stop' } satisfies WorkerIn); @@ -64,20 +67,35 @@ export function useDecoders() { } }, []); - const ensureWorker = useCallback((inputSampleRate: number) => { - if (workerRef.current && inputRateRef.current === inputSampleRate) return; + const stopMsk144Worker = useCallback(() => { + const w = workerMsk144Ref.current; + workerMsk144Ref.current = null; + try { + w?.postMessage({ type: 'stop' } satisfies WorkerIn); + } catch { + // ignore + } + try { + w?.terminate(); + } catch { + // ignore + } + }, []); + + const ensureFt8Worker = useCallback((inputSampleRate: number) => { + if (workerFt8Ref.current && inputRateFt8Ref.current === inputSampleRate) return; - stopWorker(); + stopFT8Worker(); - inputRateRef.current = inputSampleRate; + inputRateFt8Ref.current = inputSampleRate; timeOffsetRef.current = readTimeOffsetMs(); const w = new Worker(new URL('../decoders/ft8/ft8Worker.ts', import.meta.url), { type: 'module' }); - workerRef.current = w; + workerFt8Ref.current = w; w.onmessage = (ev: MessageEvent) => { const msg = ev.data; if (msg.type === 'ready') { - readyRef.current = true; + readyFt8Ref.current = true; return; } if (msg.type === 'error') { @@ -99,11 +117,11 @@ export function useDecoders() { w.postMessage({ type: 'init', inputSampleRate, timeOffsetMs: timeOffsetRef.current } satisfies WorkerIn); // Flush PCM to the worker in batches (keeps the UI thread light). - pcmAccRef.current = new Float32Array(48_000); // ~1s at 48kHz (will grow if needed) - pcmAccLenRef.current = 0; - pcmFlushTimerRef.current = window.setInterval(() => { - const ww = workerRef.current; - if (!ww || !readyRef.current) return; + pcmFt8AccRef.current = new Float32Array(48_000); // ~1s at 48kHz (will grow if needed) + pcmFt8AccLenRef.current = 0; + pcmFt8FlushTimerRef.current = window.setInterval(() => { + const ww = workerFt8Ref.current; + if (!ww || !readyFt8Ref.current) return; const nextOffset = readTimeOffsetMs(); if (nextOffset !== timeOffsetRef.current) { @@ -115,32 +133,85 @@ export function useDecoders() { } } - const n = pcmAccLenRef.current; + const n = pcmFt8AccLenRef.current; if (n <= 0) return; - const chunk = pcmAccRef.current.subarray(0, n); + const chunk = pcmFt8AccRef.current.subarray(0, n); const copy = new Float32Array(chunk); // transferable copy - pcmAccLenRef.current = 0; + pcmFt8AccLenRef.current = 0; ww.postMessage({ type: 'pcm', pcm: copy } satisfies WorkerIn, [copy.buffer]); }, 250); - }, [stopWorker]); + }, [stopFT8Worker]); - const feedAudio = useCallback( + const ensureMsk144Worker = useCallback((inputSampleRate: number) => { + if (workerMsk144Ref.current && inputRateMsk144Ref.current === inputSampleRate) return; + + stopMsk144Worker(); + + inputRateMsk144Ref.current = inputSampleRate; + const w = new Worker(new URL('../decoders/msk144/msk144Worker.ts', import.meta.url), { type: 'module' }); + workerMsk144Ref.current = w; + + w.onmessage = (ev: MessageEvent) => { + const msg = ev.data; + if (msg.type === 'ready') { + return; + } + if (msg.type === 'error') { + setErrors((prev) => ({ ...prev, msk144: String(msg.message ?? 'MSK144 worker error') })); + return; + } + if (msg.type === 'log') { + const raw_text = String(msg.text ?? '').trim(); + if (!raw_text) return; + const d = new Date(); + const tc = d.toISOString().split('T')[1].split(/[:\.]/); + // prepend by UTC time as hhmmss + const text = `${tc[0]}${tc[1]}${tc[2]} ${raw_text}`; + setLines((prev) => [{ id: makeId(), ts: Date.now() , decoder: 'msk144' as const, text }, ...prev].slice(0, 500)); + setUnread((prev) => ({ ...prev, msk144: prev.msk144 + 1 })); + } + }; + w.postMessage({ type: 'init', inputSampleRate, timeOffsetMs: 0 } satisfies WorkerIn); + }, [stopMsk144Worker]); + + const feedFt8Audio = useCallback( (pcm: Float32Array, inputSampleRate: number) => { if (!enabled.ft8) return; - if (!Number.isFinite(inputSampleRate) || inputSampleRate <= 0) return; - ensureWorker(inputSampleRate); + ensureFt8Worker(inputSampleRate); // Accumulate PCM into a staging buffer. - const needed = pcmAccLenRef.current + pcm.length; - if (pcmAccRef.current.length < needed) { - const next = new Float32Array(Math.max(needed, pcmAccRef.current.length * 2, 48_000)); - next.set(pcmAccRef.current.subarray(0, pcmAccLenRef.current), 0); - pcmAccRef.current = next; + const needed = pcmFt8AccLenRef.current + pcm.length; + if (pcmFt8AccRef.current.length < needed) { + const next = new Float32Array(Math.max(needed, pcmFt8AccRef.current.length * 2, 48_000)); + next.set(pcmFt8AccRef.current.subarray(0, pcmFt8AccLenRef.current), 0); + pcmFt8AccRef.current = next; } - pcmAccRef.current.set(pcm, pcmAccLenRef.current); - pcmAccLenRef.current += pcm.length; + pcmFt8AccRef.current.set(pcm, pcmFt8AccLenRef.current); + pcmFt8AccLenRef.current += pcm.length; }, - [enabled.ft8, ensureWorker], + [enabled.ft8, ensureFt8Worker], + ); + + const feedMsk144Audio = useCallback( + (pcm: Float32Array, inputSampleRate: number) => { + if (!enabled.msk144) return; + ensureMsk144Worker(inputSampleRate); + + const ww = workerMsk144Ref.current!; + + const copy = new Float32Array(pcm); // transferable copy + ww.postMessage({ type: 'pcm', pcm: copy } satisfies WorkerIn, [copy.buffer]); + }, + [enabled.msk144, ensureMsk144Worker], + ); + + const feedAudio = useCallback( + (pcm: Float32Array, inputSampleRate: number) => { + if (!Number.isFinite(inputSampleRate) || inputSampleRate <= 0) return; + feedFt8Audio(pcm, inputSampleRate); + feedMsk144Audio(pcm, inputSampleRate); + }, + [feedFt8Audio, feedMsk144Audio] ); const toggle = useCallback( @@ -151,16 +222,20 @@ export function useDecoders() { }); if (id === 'ft8') { setErrors((prev) => ({ ...prev, ft8: null })); - if (next === false) stopWorker(); + if (next === false) stopFT8Worker(); + } + if (id === 'msk144') { + setErrors((prev) => ({ ...prev, msk144: null })); + if (next === false) stopMsk144Worker(); } }, - [stopWorker], + [stopFT8Worker, stopMsk144Worker], ); const clear = useCallback((id?: DecoderId) => { if (!id) { setLines([]); - setUnread({ ft8: 0 }); + setUnread({ ft8: 0, msk144: 0 }); return; } setLines((prev) => prev.filter((l) => l.decoder !== id)); @@ -173,8 +248,11 @@ export function useDecoders() { useEffect(() => { // cleanup on unmount - return () => stopWorker(); - }, [stopWorker]); + return () => { + stopFT8Worker(); + stopMsk144Worker(); + } + }, [stopFT8Worker, stopMsk144Worker]); const stats = useMemo(() => { return {