Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 85 additions & 22 deletions src/components/waterfall/WaterfallCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -474,15 +515,15 @@ 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]);
const km = calculateDistanceKm(ft8BaseLatLon[0], ft8BaseLatLon[1], target[0], target[1]);
if (Number.isFinite(km) && km > max) max = km;
}
return max > 0 ? max : 0;
}, [ft8BaseLatLon, ft8Lines]);
}, [ft8BaseLatLon, wsjtLines]);

return (
<Card className="shadow-none">
Expand Down Expand Up @@ -586,9 +627,9 @@ export function WaterfallCard({
<Button type="button" variant="secondary" className="gap-2">
<Cpu className="h-4 w-4" />
Decoders
{ft8Unread > 0 ? (
{wsjtUnread > 0 ? (
<span className="ml-1 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary-foreground">
{ft8Unread > 99 ? '99+' : ft8Unread}
{wsjtUnread > 99 ? '99+' : wsjtUnread}
</span>
) : null}
</Button>
Expand Down Expand Up @@ -619,9 +660,18 @@ export function WaterfallCard({
FT8
</DropdownMenuCheckboxItem>

<DropdownMenuCheckboxItem
checked={msk144Enabled}
onCheckedChange={(checked) => {
decoders.toggle('msk144', !!checked);
}}
>
MSK144
</DropdownMenuCheckboxItem>

<DropdownMenuSeparator />
<DropdownMenuItem
disabled={!ft8Enabled}
disabled={!wsjtEnabled && wsjtLines.length === 0}
onSelect={(e) => {
// Avoid the same click that closes the dropdown also immediately closing the dialog.
e.preventDefault();
Expand All @@ -632,12 +682,12 @@ export function WaterfallCard({
Show decodes
</DropdownMenuItem>
<DropdownMenuItem
disabled={ft8Lines.length === 0}
disabled={wsjtLines.length === 0}
onSelect={() => {
decoders.clear('ft8');
wsjtClear();
}}
>
Clear FT8
Clear Decodes
</DropdownMenuItem>
</motion.div>
</DropdownMenuPrimitive.Content>
Expand All @@ -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={
Expand All @@ -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
</Button>
Expand All @@ -693,16 +743,16 @@ export function WaterfallCard({
<div className="rounded-md border bg-muted/10">
<ScrollArea className="h-[260px]">
<div className="space-y-2 p-3">
{ft8Lines.length === 0 ? (
{wsjtLines.length === 0 ? (
<div className="text-sm text-muted-foreground">
{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.'}
</div>
) : (
ft8Lines.map((l) => {
wsjtLines.map((l) => {
const locs = extractGridLocators(l.text);
const first = locs[0];
const km =
Expand Down Expand Up @@ -1067,12 +1117,25 @@ export function WaterfallCard({
</Button>
</div>

<div className="flex items-center justify-between rounded-md border bg-muted/10 px-3 py-2">
<div className="text-sm font-medium">MSK144</div>
<Button
type="button"
variant={msk144Enabled ? 'default' : 'secondary'}
size="sm"
className="h-8"
onClick={() => decoders.toggle('msk144', !msk144Enabled)}
>
{msk144Enabled ? 'On' : 'Off'}
</Button>
</div>

<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="secondary"
className="h-10"
disabled={!ft8Enabled}
disabled={!wsjtEnabled && wsjtLines.length === 0}
onClick={() => {
setMobileDecodersOpen(false);
window.setTimeout(() => setDecodesOpen(true), 0);
Expand All @@ -1084,8 +1147,8 @@ export function WaterfallCard({
type="button"
variant="secondary"
className="h-10"
disabled={ft8Lines.length === 0}
onClick={() => decoders.clear('ft8')}
disabled={wsjtLines.length === 0}
onClick={() => wsjtClear()}
>
Clear
</Button>
Expand Down
121 changes: 121 additions & 0 deletions src/decoders/msk144/msk144Worker.ts
Original file line number Diff line number Diff line change
@@ -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<Msg>) => {
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);
}
};
52 changes: 52 additions & 0 deletions src/decoders/msk144/pkg/msk144_decoder.d.ts
Original file line number Diff line number Diff line change
@@ -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<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
Loading