Compare commits

..
Author SHA1 Message Date
sjgandClaude Opus 5 e52c4839c9 [fix](trx-frontend-http): give satellite passes their own page
CI / lint (pull_request) Successful in 6m30s
CI / test (pull_request) Successful in 8m31s
CI / frontend (pull_request) Successful in 4m30s
CI / reuse (pull_request) Successful in 5s
Pass predictions were a third view inside the Weather Satellite Decoder card,
under Digital modes — a planning tool filed behind a decoder toggle, beside the
FT8 and WEFAX panels it has nothing to do with.  Nothing about knowing when a
bird comes over belongs there.

Move them to /satellites, reached from Tools alongside Statistics, Recorder,
Settings and About: occasional destinations that live behind that menu rather
than taking a slot in the operating strip.  Adding a sixth strip button wrapped
the phone nav onto two rows and cost the desktop strip its labels at 1280px, so
the tab is hidden from the strip exactly the way its four peers already are —
the nav is byte-for-byte what it was.

The prediction code moves out of sat.ts into its own plugin that loads with the
page, so the decoder card no longer carries it.  Countdowns stop when the page
is hidden and each visit reloads, since passes go stale while it is closed.
The server grows a /satellites index route so a deep link or a refresh serves
the SPA shell rather than a 404.

Closes #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 23:23:48 +02:00
sjg 77b283cb78 [fix](trx-frontend-http): size the nav's labels against the screen, not the font
CI / lint (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 8m23s
CI / frontend (pull_request) Successful in 4m20s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m35s
CI / frontend (push) Successful in 3m29s
CI / reuse (push) Successful in 2s
CI cut "Bookmarks" off at 360 px where this machine had ten pixels to
spare.  How wide a platform draws a word varies by more than ten per
cent, so a fixed 0.6rem label is a bet on the machine it was measured
on -- the same bet ui-core's own comment warns about where it explains
why the tab strip reflows by measurement rather than at a width.

The long labels are sized with `clamp(0.46rem, 2.2vw, 0.62rem)` now.  A
tab is a fifth of the viewport, so what fits in it follows the viewport;
tying the label to the same thing leaves better than a fifth of the tab
spare at every phone width, and holds with the text drawn 30% wider than
it is here.

The test asked the wrong question too.  "Does the label fit" is a
question about font metrics, and it will keep answering differently on
different machines.  It now asserts what actually matters: a label stays
inside its own tab, and if it is too long for it, it ends in an ellipsis
rather than being cut through a letter.  Both hold whatever width the
platform draws the words at.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 19:41:45 +02:00
sjg 6284747339 [fix](trx-frontend-http): let the bottom nav's labels fit inside its tabs
CI / lint (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 8m22s
CI / frontend (pull_request) Failing after 1m46s
CI / reuse (pull_request) Successful in 2s
The bottom nav keeps its labels under the icons -- that is what makes it
navigation rather than five glyphs -- but the labels did not fit the
tabs.  On a 360 px screen "Bookmarks" and "Digital modes" were cut off
mid-word and ran into each other: "Bookmarks igital mode".

The stylesheet already meant to handle it.  Three rules shrink the long
labels, and a rule twenty lines further down sets the size for all of
them; identical specificity, later in the file, so the blanket rule won
and nothing was ever shortened.  Those rules now come after it.

Beyond that, tabs sized themselves to their own labels, so "Map" and
"Digital modes" were given the same room.  They divide the bar evenly
now, which is the shape of every bottom nav, and a label that still runs
long ellipsises rather than being cut through a letter.

"Digital modes" does not fit at any size worth reading, so the nav shows
"Digital" and the button carries the full name as its accessible label;
the short form is hidden from assistive tech, which reads the button's
name instead.

The phone layout test now checks that no label in the nav is cut off at
430, 390 or 360 px, and that the shortened tab still says what it is to
something that reads the page rather than looks at it.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 19:14:31 +02:00
sjg 14ad6e241b [fix](trx-frontend-http): stop the radio controls running off the side of a phone
CI / lint (pull_request) Successful in 2m19s
CI / test (pull_request) Successful in 8m23s
CI / frontend (pull_request) Failing after 1m28s
CI / reuse (pull_request) Successful in 2s
On a 390 px screen the transmit controls were laid out at x=400, off the
side of a tray 354 px wide: present, invisible, and reachable only by a
horizontal scroll with nothing to say it was there.  The page scrolled
sideways by a dozen pixels as well.

Three causes, each in a different place.

A container query at the end of the stylesheet re-imposes `flex-wrap:
wrap` on a narrow tray's rows.  That is right while a row runs left to
right; below the phone breakpoint the row is a column, and wrapping a
column starts a *second column* — which is what put the transmit
controls beside the tray rather than under it.  The rule outranks the
phone one, two classes to its one, and sits later in the file, so it now
excludes itself below that breakpoint rather than being overridden.

The tray is a grid, and a grid column sizes to its content.  One row
wider than the screen — the mode picker, six buttons across — dragged
the whole tray out with it.  `minmax(0, 1fr)` lets the column be as
narrow as the phone and the rows wrap inside it.

The wavelength and signal-strength readouts are given the width of their
column on narrow screens, but with padding on a content box that is the
column's width plus the padding.  Those dozen pixels were the page's
sideways scroll.  They are border-box now.

Also: the rig picker was taking 139 px of a 338 px bar, pushing the rest
of the top bar into the overflow menu.  It is capped and ellipsised on
phones, with the full name still in the menu it opens.

The map's filter bar, collapsed, keeps both its anchors and shrinks
inside them rather than dropping `left` to be sized by shrink-to-fit,
and no longer asks for a compositing layer it does not need.  An
absolutely positioned, backdrop-filtered, composited box sizing itself
from its content is the shape of thing that renders as nothing on an
engine other than the one it was written against — which is what Edge
does with it.  Unverified there: this machine has no Edge to test with.

tests/mobile-layout.mjs holds the page to it at 430, 390 and 360 px: no
sideways scroll, nothing laid out past the right edge, the transmit
controls inside the screen, the rig picker within its cap — and the
collapsed filter bar still on screen with a button to bring the filters
back.

The SSTV panel test stamped its picture with a fixed date, which the
panel's own retention window dropped once that date was a day old.  It
uses a recent stamp now.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 19:06:15 +02:00
sjg 06971ff65c [style](trx-frontend-http): make the spectrum control strip one strip
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m28s
CI / frontend (push) Successful in 4m10s
CI / reuse (push) Successful in 3s
The row of controls under the plot held four different control heights,
units as loose text beside the field they belonged to, and a quarter of
its width as a hole in the middle.  Between about 1100 and 1400 px it
came apart: the bandwidth cluster wrapped to two lines while the level
cluster stayed on one, so the two sat at heights that matched neither
each other nor anything else on the page.

Every control stays, in its order, with its name and its behaviour.
This is the styling and the layout.

A field is now one box -- name, value and unit inside a single border --
so a number cannot be read apart from the unit it is in.  Fields,
buttons, the peak-hold select and the contrast slider are one height,
border-box so a button's own border cannot add two pixels to it, and
2.4rem under a coarse pointer where a fingertip needs the room.  The
contrast readout holds a fixed, tabular slot, so the row no longer
twitches between 1.0 and 0.9.

The container wraps and a cluster does not: a cluster that will not fit
drops whole to the next line and starts it left-aligned.  The slack
goes to a spacer rather than to `space-between`, which is what opened
the hole.

Two things this turned up.  The select carries `status-input` for other
layouts' sake, which drew a box inside the field's box.  And the narrow
-screen rules lived in a media query earlier in the file than the rules
they override -- identical specificity, so the later one won and the
phone layout had been overflowing sideways rather than stacking.  The
narrow rules now sit directly after what they override.

The layout test measures the strip at three widths: one height across
every control, no overflow, inside the plot, and clusters either
sharing a line or each having one -- never one floating against the
middle of the other.

docs/Spectrum-Controls-Rework.md records what was wrong and what was
deliberately left alone: the two different Autos, the settings that do
not persist, the one-shot buttons, and Sweet-spot's silence while it
retunes the SDR.  Those are behaviour, and are for another day.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 01:10:26 +02:00
sjg 18107ce07e [feat](trx-rs): receive SSTV pictures end to end
CI / lint (pull_request) Successful in 2m16s
CI / frontend (pull_request) Successful in 4m12s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m15s
CI / test (pull_request) Successful in 9m37s
CI / test (push) Successful in 7m36s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
Wires the SSTV decoder into the stack, from the audio the server already
has to a panel in the browser that shows the picture arriving.

Server: a decoder task alongside the WEFAX one, running whenever the
decoder is enabled and the rig is in a mode SSTV is sent in.  A finished
picture is written to the cache as a PNG and sent on as a message; the
rows are sent as they decode, so a client can watch two minutes of
Martin M1 fill in rather than waiting for it.  Pictures join the decode
history, are replayed to a client that connects later, and survive a
restart.

Protocol: SetSstvDecodeEnabled and ResetSstvDecoder, a sstv_decode
_enabled flag in the rig state, two audio message types, and Sstv and
SstvProgress on DecodedMessage.  The history stores the message without
its base64 payload -- the picture is already on disk, and a megabyte per
entry is not what a history is for.

Client: pictures land in their own history, and the PNG the server sent
is written to the local cache so /sstv-images/ can serve it back.  That
endpoint and the WEFAX one now share their filename checks rather than
each carrying a copy: no separators, no parent references, .png only.

Web UI: an SSTV sub-tab beside WEFAX, with a live canvas the rows paint
into at the line number they carry, a card for the last picture, and a
filterable history with links to the files.  Rows below the one arriving
are grey rather than black -- not yet received is a different thing from
received as black.  A picture is not a spot, so neither pictures nor
their progress updates reach the decode statistics; that exclusion list
had grown by hand for LRPT and WEFAX and is now one named set.

The decoder crate gains what the server needed to hand a picture on:
to_png, to_png_base64 and save_png, with file names stamped in UTC so
they sort.

Panel behaviour is tested with the plugin runtime: rows painting at
their own line numbers rather than in arrival order, a completed picture
linked by file name alone with no server path in the page, a cut-off
picture reported as partial, clearing, and the toggle following the rig
state.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 00:14:59 +02:00
sjg a0b0c0ed81 [feat](trx-sstv): decode SSTV pictures
CI / test (pull_request) Successful in 7m40s
CI / lint (pull_request) Failing after 14m36s
CI / frontend (pull_request) Successful in 3m12s
CI / reuse (pull_request) Successful in 3s
CI / test (push) Successful in 7m32s
CI / frontend (push) Failing after 1m21s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m16s
A new decoder crate covering the modes SSTV is actually sent in: Martin
M1/M2, Scottie S1/S2/DX, Robot 36/72, PD50 through PD290, and Wraase
SC2-180.  The mode comes from the VIS header every transmission opens
with, so nothing has to be told what is arriving.

Modes are a table rather than code: a list of segments -- sync, gaps,
and one scan per colour channel -- plus a colour model and a geometry.
The decoder reads the offset of each scan straight off that list, which
is what makes fifteen modes cost about as much as one, and a new mode a
table entry.  The segment lists are checked against the published line
durations in a test, because both are transcribed by hand from the same
specification and a digit wrong in one is unlikely to be wrong
identically in the other.

Signal path: band-pass over the SSTV band, Hilbert FIR, instantaneous
frequency by phase difference, then a state machine that walks the
transmission a line at a time.  Each line is looked for where the mode
says it should be and nudged into place by the sync pulse found near
it -- two sound cards never agree exactly, and over the two minutes of a
Martin M1 frame an uncorrected error of a few parts per million shears
the picture visibly.  Rows are emitted as they decode, so a picture can
be watched arriving, which is most of the appeal of the mode.

Four things this cost, each now the reason a piece of it is shaped the
way it is:

The per-sample frequency estimate ripples by ±95 Hz at 1200 Hz, where
the Hilbert approximation is weakest, though its mean is exact.  Pixels
average over their own window and were always right; the VIS bits and
the sync detector classify individual samples and were reading the
ripple.  Both now read short means.  Pixels deliberately still do not,
so edges stay where they are.

Broadband noise cost the whole picture, not part of it: a
phase-difference detector answers whatever is loudest, and there was no
input filter.  Hence the band-pass, which is what every real decoder
does first.

A sync search window shorter than a sync pulse rejected every pulse
arriving late in it, for being short.

The first line's sync search locked onto the VIS stop bit -- 30 ms at
exactly the sync frequency, immediately before the picture starts.  The
header already says where the picture begins, so the first line no
longer searches.

Tests: nine modes are encoded from a test card and decoded back,
compared pixel by pixel, alongside silence around the signal, a
transmission cut off part way, two transmissions back to back, 20 dB of
noise, and a transmitter clock 0.1% fast.  The encoder that produces
those signals reads the same table as the decoder, so a round trip
tests the decoder and not the timings; the timings are held to the
published line durations separately.

Nothing is wired into the server or the web UI yet: this is the decoder
alone.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 23:29:52 +02:00
sjg a2c630a92b [feat](trx-frontend-http): put the tuned frequency in the address bar
CI / frontend (pull_request) Successful in 4m3s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m11s
CI / test (push) Successful in 7m20s
CI / frontend (push) Successful in 3m10s
CI / reuse (push) Successful in 2s
A receiver spreads by being linked to, and there was nothing to link to:
the routes carried the tab and nothing else, so "listen to this" could
only ever mean a screenshot and a frequency typed out in a message.

The query string now carries the dial -- rig, frequency, mode and
bandwidth -- in both directions.  Opening a link selects the rig, sets
the mode, tunes, then applies the bandwidth: a mode change brings its
own default bandwidth with it, so an explicit bw has to land after it.
Frequencies are read the way someone writes them by hand (7074k,
14.074M) and written back as whole Hz, so what comes out of the address
bar is the same link in canonical form.

After that the address bar keeps up with the dial, which is what makes
it copyable at any moment rather than only at load.  It is rewritten
with replaceState -- tuning is not navigation, and a swept dial would
otherwise bury the back button.  A link button in the top bar copies
the current link; it folds into the overflow menu when the bar is tight.

Applying a link changes the radio, so an rx session says so instead of
failing control calls one at a time.  A tab listening to a virtual
channel leaves the address alone rather than publishing a frequency the
rig is not on, and bw is skipped in both directions on rigs without
filter control, which would only refuse it.

The fixture pinned every state frame to 100 MHz plus jitter to keep
frames distinct, so no test could observe tuning at all.  The jitter
moves to the S-meter and the fixture echoes set_freq/set_mode/
set_bandwidth, as it already did for squelch.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 22:31:12 +02:00
sjg 09634eb851 [fix](trx-frontend-http): tidy up the map's filter bar
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m7s
CI / frontend (push) Successful in 2m57s
CI / reuse (push) Successful in 3s
CI / frontend (pull_request) Successful in 3m47s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m19s
The bar explained itself in prose: "All bands visible by default" sat
between the chips and the next group, taking width the bar could not
spare and reading as a stray line of text. An "All" chip says the same
thing in a chip's width and gives the selection somewhere to be undone.

Band chips also came up dimmed at the very moment every band was on the
map -- an empty selection is no filter at all, so nothing is dimmed
until something is picked. The path toggles drop their "On"/"Off"
suffix, which cost most of a row and only repeated what their own
highlight already said; state moves to aria-pressed and the tooltip.

The rest is alignment. The rule dividing the buttons from the filters
is drawn on the button block's edge, and a centred block left it
floating as a stub beside a two-row bar; stacked, it lay down the left
of a block that sits underneath. The labels sat at their natural
widths, so each row's first control started somewhere different, and
the two pairs of phase buttons differed in width, so the groups after
them missed each other by four pixels. One gutter for every label, one
width for both pairs, and the search field moved last where it can take
the room the fixed-width groups leave.

The map layout test now covers the chips, the divider's height and the
rows' shared start.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 21:43:57 +02:00
sjg 90ab7781ad [fix](trx-frontend-http): stop the browser offering saved values for frequency
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m11s
CI / frontend (pull_request) Successful in 3m48s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 7m23s
CI / frontend (push) Successful in 2m54s
CI / reuse (push) Successful in 3s
The tuned and centre frequency readouts are text inputs, so the browser
keeps what has been typed into them and offers it back in a dropdown --
Edge does this out of the box, dropping stale frequencies from other
sessions over the reading.

Turn autofill off on both, along with autocorrect and spellcheck, which
have no business near a number either.

Fixes #39

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 19:36:22 +02:00
sjg 88d04253ca [fix](trx-frontend-http): move the map's fullscreen and filter toggles into the bar
Fullscreen and Hide Filters floated in their own block over the map's
top-right corner, separate from the filter bar they sit beside.

Put them at the right-hand end of the bar, behind a separator. What made
this awkward before is that Hide Filters cannot live inside the thing it
hides, so the collapse now applies to the filters alone: the bar keeps its
two controls and shrinks to them at the map's right edge, leaving the whole
map visible and the way back one click away.

Fixes #38

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 19:34:09 +02:00
sjg 08005c5c07 [feat](trx-frontend-http): lay the map filters out as a bar across the top
CI / test (push) Successful in 8m6s
CI / frontend (push) Successful in 3m45s
CI / reuse (push) Successful in 3s
CI / lint (push) Successful in 2m15s
The filters were a 30rem column parked in the bottom-right corner, covering
a third of the map they filter. Lay them out horizontally instead: one row
per group -- label beside its control, thin rules between -- across the top
of the map, spanning ~87% of its width at 1600px and wrapping to a second
row as it narrows.

It starts clear of Leaflet's zoom buttons and stops short of the corner
controls, which stay outside it: the button that hides the filters cannot
live inside the thing it hides. The bottom-left band legend keeps its place.
The sentence explaining the two path toggles would have swallowed the bar,
so it moves to their tooltips and is shown inline only in the stacked
narrow-screen layout.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:54:21 +02:00
sjg 026f816ddb [fix](trx-frontend-http): replay stored decodes onto the map when it loads
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 3m40s
CI / reuse (push) Successful in 2s
The map module is lazy: it arrives when the Map tab is first opened, which
is normally long after startup restored the decode history. Until then
aprsMapAddStation, aisMapAddVessel and vdesMapAddPoint are undefined, and
the decoders' `if (lat != null && ... && fn)` guards quietly dropped every
restored position. Nothing replayed them once the module did arrive, so the
map came up empty and filled in only from decodes heard afterwards -- a
station heard once was never plotted at all. A second reload appeared to
fix it because the cached module then loaded early enough to win the race
against the history fetch.

Give DecoderPlugin an optional syncMap(), implement it for APRS, AIS and
VDES over the history each already retains, and have map-core call
trxPluginRuntime.syncMapAll() as it attaches. The add functions are keyed
by callsign, MMSI and point, so replaying updates in place and cannot
duplicate a marker; the replay runs oldest-first so tracks are rebuilt in
the order they happened.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:34:45 +02:00
sjg d31b6f545f [test](trx-frontend-http): watch the history progress from inside the page
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 8m18s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 2s
The replay-progress check polled the overlay from the test every 100ms.
A replay that starts and finishes between two polls is never sampled, and
the test then reports that no progress was shown at all -- the source of
the intermittent "no progress was shown while the history loaded" failure.

Record the samples from a MutationObserver installed before the page's own
scripts run, so a fast replay is observed rather than missed.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:10:57 +02:00
sjg 39f551c914 [fix](trx-frontend-http): hold the APRS symbol column open for frames without one
renderLocalAprsSymbol() returns nothing when a packet carries no symbol
table or code, so those rows lost the icon's 24px slot and every column
after it -- callsign, type badge, summary -- slid left against the rows
around them. Frames that do carry a symbol then read as indented.

Render an empty slot of the same size instead, so a list mixing position
reports with messages and telemetry still lines up.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:10:33 +02:00
sjg 6d25ecdc11 [feat](trx-frontend-http): give the AIS list the same log shape
CI / lint (push) Successful in 2m15s
CI / test (push) Successful in 8m8s
CI / frontend (push) Successful in 3m41s
CI / reuse (push) Successful in 2s
A message was three stacked lines — time and name, then MMSI and route,
then motion, distance, position and age — so a screen held eight of
them.  It is one line now: time, vessel, message type, and what the
message says, opening in place for the MMSI, the channel frequency, the
route, the age, the fix and a jump to the map.  Twenty-two fit where
eight did.

What a message says depends on what it is.  Position reports give the
fix and the motion; the static and voyage reports that carry no fix give
the callsign and where the vessel is bound.  Both fall back to whatever
fields are present rather than showing nothing.

The row vocabulary the APRS list introduced is no longer APRS-specific —
the classes are decode-line and decode-expanded now, shared by both, and
identity sits in fixed columns so the summaries line up down the list
instead of starting wherever the callsign happens to end.  The three
summary cards above the list go the way of the APRS ones.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 23:24:09 +02:00
sjg 37987b2779 [feat](trx-frontend-http): make the APRS list a log, and read the payloads
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 3m41s
CI / reuse (push) Successful in 2s
A frame was a card five lines tall — timestamp, a meta line, the
information field as it arrived on the air, three buttons, and a Details
panel repeating the four things already on the row — so five frames
filled the panel and the payload was left to be decoded by eye.

A frame is one line now: time, station, type, and what the frame says.
It opens in place for the path, the CRC, the raw field, its bytes and
the actions.  Twenty-one frames fit where five did.

And the information field is read rather than echoed.  Weather reports
give temperature, wind, humidity and pressure; telemetry gives its
sequence and channels; a message gives its addressee and text; a
position gives the fix, course and speed, and the comment the station
wrote.  Anything that cannot be summarised falls back to the raw field,
which is in the expanded view either way.

HF APRS had a copy of the same forty lines of markup, differing by one
badge, so both now build their rows from one function in the shared
module — the CSS is shared between them and this would have broken it
otherwise.  Both headers lose their three summary cards for a line of
counts beside the filters, which frees another fifth of the panel.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 23:12:02 +02:00
sjg 67a7bace4e [fix](trx-frontend-http): let the decode lists fill their panel
CI / test (push) Successful in 8m16s
CI / lint (push) Successful in 2m18s
CI / frontend (push) Successful in 3m40s
CI / reuse (push) Successful in 3s
FT8, FT4, FT2 and WSPR size their list against the panel with flex, and
the sidebar layout made the panel a grid item aligned to the start of
its row — sized to its own content.  The lists collapsed to their 120px
minimum with several hundred pixels of the page empty underneath.  The
panel stretches to the row now and the sidebar keeps its own height.

The marine lists were sized a different way, by formula: 100vh minus a
guess at everything above them.  That guess stopped matching the moment
the panel changed shape, so they left a few hundred pixels unused as
well.  They fill the panel like the rest now, and so does CW, which had
a 360px ceiling.

HF APRS had no container styling at all — no scroller, no frame, no
height — so its packets ran down the page.  It gets what the other
packet lists have.

The smoke test measures each list against its panel and requires it to
scroll on its own.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 22:49:28 +02:00
sjg b48cc23d6e [fix](trx-frontend-http): keep the rig names through the state stream
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m14s
CI / frontend (push) Successful in 3m47s
CI / reuse (push) Successful in 3s
The picker and the header showed each rig's lowercase id instead of its
configured name.  applyRigList takes the names as a parameter defaulted
to an empty map, and the state-update path passes only the rig ids —
names come from /rigs, not from a state frame — so that call landed on
the default and the body, which treats "an object" as "here are the
names", cleared them.  One frame after load the names were gone for the
rest of the session.  Omitted now means no news rather than no names.

The fixture is why this was invisible: it pushed an identical status
payload every tick and the client skips a frame equal to the last, so
render never ran and neither did the call that did the damage.  Its
event stream varies between frames now, as a real one does.

Which immediately caught a second fault: state frames arrive
continuously, and one sent before the server applied a new squelch
threshold snapped the line back to where it had just been dragged from.
A local change outranks the echo for two seconds, the same idea as the
optimistic frequency guard beside it.  The fixture also records what
/set_sdr_squelch sets and reports it back afterwards — the drag test had
been passing against a server that ignored the write.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 22:38:21 +02:00
sjg c1899229a0 [fix](trx-frontend-http): stop the decode history replay giving up at 20s
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m15s
CI / frontend (push) Successful in 3m37s
CI / reuse (push) Successful in 3s
Reloading a second time sometimes showed history the first load did not,
and the safety valve is why: it called one function that both released
the buffered live decodes and tore the history worker down, so any load
where the replay had not finished inside twenty seconds — a large
backlog, a cold cache, a slow link — dropped whatever had not arrived,
without a word.  A reload got another go at it, and the second one is
faster because everything is cached by then.

Those are two separate things now.  At the timeout the live decodes are
released so the panels are not held back, the replay carries on, and the
progress says so.  The fallback's error path retries once and then says
"Decode history unavailable" rather than leaving the operator to guess
whether there was anything to see.

The progress is no longer a scrim.  It was fixed to the whole viewport
with a wash over the page — the waterfall, the decode panels, all of it —
for the length of the replay, which is exactly when there is something
worth watching.  It is a corner card with a bar: indeterminate while the
payload is on the wire, then filling as N of M messages replay.

None of this was reachable from a test.  /decode/history answers in CBOR
and the worker reads the body as CBOR unconditionally, but the fixture
served JSON, so every browser run had been exercising the client's retry
path and never its history path.  It encodes CBOR now, including the
64-bit form the millisecond timestamps need, and decode-flow serves 1200
records and holds the client to restoring all of them on the first load,
showing progress while it does, and never covering the page with it.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 22:14:13 +02:00
sjg 84a99a3636 [fix](trx-frontend-http): load the decoders that own the digital modes panels
AIS, VDES and both APRS decoders were listed under the map plugin group
alone, so opening Digital modes and clicking AIS or APRS gave an empty
panel reading "Connected, listening for packets" while the decodes piled
up unprocessed in the plugin runtime.  They appeared only if something
had opened the Map tab first, which flushed the queue.  There is also a
map-data group naming exactly those four that nothing loads: the loader
is called with tab names and no tab is called map-data.

They load with the tab whose panels they fill now.  map-core stays lazy,
since their calls into it are optional and the Map tab can go on paying
for Leaflet by itself.

tests/decode-flow.mjs follows a decode from the wire to the map: an AIS
vessel and an APRS beacon arrive on /decode, and it asserts both panels
fill with the map module confirmed absent, the mini view names the
vessel and offers a pin, following that pin lands on /map centred on the
vessel, and both decoders leave a marker.  Nothing exercised any of this
before — the fixture served an empty decode stream, which is how the map
links came to be broken for every decoder at once.

The fixture stamps decodes as it sends them, since the client prunes
anything outside the retention window, and repeats them, since the views
collapse by vessel and need more than one frame to behave.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 21:31:30 +02:00
sjg 84bdf2593c [fix](trx-frontend-http): measure whether the tab bar fits, and watch for it
CI / lint (push) Successful in 2m20s
CI / test (push) Successful in 8m25s
CI / frontend (push) Successful in 3m36s
CI / reuse (push) Successful in 3s
CI put the tab strip 9px into the controls at 1440px with no scaling at
all, on a bar that had every degradation step available to it and used
none of them.  It used none because nothing thought anything was wrong:
the fit test was an arithmetic estimate — identity + nav.scrollWidth +
actions.scrollWidth + a 48px allowance for the gaps — and on a platform
whose fonts run wider than the one it was written on, that allowance no
longer covered what it stands for.  An estimate that says "fits" stops
the ladder before its first rung.

It reads the geometry now: the controls have to stay inside the bar, and
no tab may reach them.  That is the same measurement the test makes, so
the two cannot disagree about any platform's metrics.  The tabs are the
subject rather than the nav's box because the nav shrinks below its
content — the box gets smaller while the tabs keep their width and slide
underneath the controls.

A second fault turned up while probing this: the strip only reflowed on
window resize.  The rig name arriving from the server, the style picker
filling in, a font swapping in wider metrics — each changes what fits
without touching the window, and the bar sat there as it was through all
of them.  A ResizeObserver on the bar and the controls covers those, and
document.fonts.ready covers the swap.

The guard sweeps text scales and adds a station name too long for the
bar, but it should be said plainly: it passes against the old code too.
Nothing here reproduces on this machine — a 4px viewport sweep from 1080
to 1500, three wide font stacks and scales from 1.0 to 3.0 all failed to
make the old estimate lie.  What is fixed is the mechanism that could.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 20:32:38 +02:00
sjg c527f20a88 [feat](trx-frontend-http): give digital modes a decoder sidebar
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 8m15s
CI / frontend (push) Failing after 32s
CI / reuse (push) Successful in 3s
Thirteen decoders in a horizontal strip needed a scroller on anything
but a wide window, and the open one was marked by a single underline
among thirteen.  They are a list down the left now, all visible at once,
each keeping the state dot it already carried, with the panel for the
selected one filling the rest of the width.

No script changed: the sub-tab wiring, the aria roles, the decoder
picker and the state-dot observers all work on the same markup, so this
is layout only.

Below 760px the sidebar gives way to the picker that already existed
there.  That path needed align-content: the tab panel fills the page
height and a grid stretches its rows to match, which handed the picker a
218px row and left a 189px gap under it.

The tab icon was the signal-strength bars, which is what the S-meter
shows two rows above it; a pulse train says digital modes instead.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 08:01:22 +02:00
sjg 9f495021f0 [style](trx-frontend-http): name and fence the three audio groups
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m23s
CI / frontend (push) Successful in 3m18s
CI / reuse (push) Successful in 3s
The row carries three unrelated things — how loud it is, whether there
is any audio at all, and how much is arriving — and only the middle one
was named.  Each is a group now: VOLUME over the two sliders, SQL on its
own switch, LEVEL over the meter, with a hairline between each.

The rules are drawn only while the row is one line, measured against the
row rather than the viewport: what fits depends on whether the rig
transmits and whether it has a squelch at all.  A rule divides what sits
either side of it, so once a group wraps the wrap is the division and
the rule would just be a mark at the start of a line — which is what it
was at 900px before this.  The labels carry the grouping on their own
below that width, and the groups stack whole on a phone.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 07:46:56 +02:00
sjg 33aa7b807c [style](trx-frontend-http): fence the squelch off from the volume controls
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 8m18s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
The squelch sat in the audio row on the same gap as everything else, so
it read as a continuation of the volume sliders.  It decides whether
there is audio at all, which is not the same kind of control as how loud
it is, and a hairline says so.

The rule belongs to the squelch block, so it leaves with it on a rig
that has none, and it stands down where the row stacks: there the line
break separates them already and a leading rule would just start a line.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 07:36:53 +02:00
sjg 2c56d82a81 [feat](trx-frontend-http): make the SQL label the squelch switch
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m13s
CI / frontend (push) Failing after 30s
CI / reuse (push) Successful in 3s
Clicking SQL turns the squelch on and off.  The label and the button
beside it said the same thing twice — one naming the control, the other
reading "On" or "Off" — where the name itself is the obvious target, and
the dot already carries the state: grey when off, green while the gate
passes, amber while it holds.

The pressed state is on the label, so the switch reads the same to a
screen reader as it looks.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:41:52 +02:00
sjg 0fc2115973 [fix](trx-frontend-http): make map links work before the map has loaded
The AIS and APRS mini views link each position to the map, and neither
did anything: the map module installs itself lazily, and it was the one
defining window.navigateToAprsMap, so until something had opened the Map
tab the global did not exist.  AIS calls it inline from onclick and
threw "not a function"; APRS guards the call and so failed silently.
The grid links on FT8, FT4, FT2 and WSPR rows went the same way through
navigateToMapLocator.

The app owns both globals now, installed at startup.  They record the
target, switch tabs through navigateToTab — the only path that
materialises the panel from its template, loads the module and updates
the history entry, none of which the module's own hand-rolled tab switch
did — and the target is applied once the module reports ready.

The module keeps the focusing, which is its job, and exposes it as
focusMapPosition and focusMapLocator.

The smoke test now calls the link from a cold page, asserting the map
module is not loaded first so the check cannot pass by accident.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:41:09 +02:00
sjg 2c1df75d19 [fix](trx-frontend-http): measure auto squelch from the meter
CI / frontend (push) Successful in 3m16s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 8m14s
Auto took the spectrum's noise floor and added 6 dB, but the threshold
is compared against the channel level the meter reports, and the two sit
a long way apart: the gap is set by the FFT size and window, the channel
bandwidth, the decimation, and peak-versus-mean statistics.  Measured on
white noise it runs +22.1 dB at 48k/8k/3k, +18.7 dB at 240k/24k/12k and
-1.2 dB at 1.92M/24k/12k — a 23 dB swing across ordinary configurations.
Only the last of those is anywhere near right, so on a narrow span Auto
set the gate some 20 dB below the noise and it never closed.

It now reads the same number the DSP compares: the 20th percentile of
the meter over the last ten seconds, plus 5 dB.  The percentile keeps a
burst of traffic inside the window from dragging the estimate up, and
5 dB clears the meter's own jitter, which measured 0.9-1.6 dB.  Nothing
in it converts between scales, so no part of the signal chain can put it
out again.  With no history yet — a fresh connection, a rig switch — it
listens for a moment rather than refusing.

The fixture gained a streaming /meter, without which there is nothing to
measure, and the spectrum test pins auto to the meter it serves.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:32:18 +02:00
sjg aefd36c4b1 [feat](trx-frontend-http): set the squelch on the spectrum, in dB
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m41s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 2s
The threshold is in dB, and since the squelch fix that is the scale the
spectrum axis and the S-meter are labelled in — so the control belongs
on the plot, at the level it gates.  A dashed line spans the spectrum at
its threshold with a grip that reads it out, dragged like the bandwidth
edges, green while the signal is above it and amber while it gates.
Arrow keys move it a dB at a time for anyone not using a mouse.

The audio row keeps a compact version: the dB, an indicator lit from the
same meter the DSP compares against, Auto, and an enable toggle that no
longer doubles as the level.  The slider ran 0-100% over that dB range,
which gave the operator a number with nothing on screen to relate it to,
and zero meant "disabled", so turning the squelch off to listen threw
the threshold away.  Auto now says which level it picked.

Two things the browser could only show once it was on the plot: the grip
landed underneath the split control at the right edge, which swallowed
its pointer, and dragging to the foot of the axis hid the line — and the
grip with it — instead of pinning it where it could be dragged back.

The fixture could not exercise any of this: /audio answered 404, which
hides the audio row and the control inside it, and the status carried no
filter block, which is what tells the client the rig has a squelch at
all.  Both now look like an SDR, and the spectrum test drives the line.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:20:19 +02:00
sjg 25b9c31c9b [fix](trx-backend-soapysdr): squelch against the meter, not the post-AGC level
The threshold was compared against the block level measured after the IQ
AGC.  Holding that level at a setpoint is the AGC's entire purpose, so
for every mode that has one — FM, PKT, AIS, AM, SAM, which is to say the
modes anyone squelches — the comparison was against a near-constant.
With FM's 12 dB of gain a weak signal reads some 12 dB hotter than it
is, and the value never had the decimation correction the meter applies
on top of that: around 20 dB adrift at 48k/8k, more as decimation grows.

The threshold arrives in the other scale entirely.  The slider maps its
percentage onto -120..-30 dB and Auto takes the spectrum noise floor
plus 6, both of which are what the meter and the spectrum display show.
So a gate set just above the noise sat open on it.

It now reads last_signal_db, which is already computed each block before
the AGC and corrected for decimation — the same number the meter shows.
The post-AGC measurement had no other consumer.

The test feeds one signal twice and takes the threshold from the
channel's own meter: 6 dB above must gate it, 6 dB below must pass it.
Nothing there depends on the absolute scale, only on the two agreeing.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:03:46 +02:00
sjg d68a84f7f9 [chore](trx-client): apply cargo fmt
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m33s
CI / frontend (push) Successful in 3m18s
CI / reuse (push) Successful in 3s
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:57:33 +02:00
sjg 4fa191e9b6 [fix](trx-frontend-http): serve the band plan to every session
CI / lint (push) Failing after 1s
CI / test (push) Successful in 8m20s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
/bandplan.json needed the control role.  Route access is decided by
suffix for static assets — .js, .css, .png and so on — and ".json" is
not among them, so the band plan matched nothing and fell through to the
catch-all.  It is compiled into the binary and identical for every user,
so it is public now, like the rest of them.

Two things followed from that.  Read-only sessions never saw a band plan
at all.  And since the page asks for it during startup, the request can
land before the session is established: that 401 was swallowed by an
empty catch and never retried, which is why the allocations sometimes
only appeared after a manual reload.

So the client no longer hides the failure, retries once the auth gate
clears — which is exactly when a startup 401 becomes fixable — and
schedules a draw when the data lands, since the strip is painted from
the spectrum draw and a rig sitting between frames would stay blank.

The fixture can now refuse the first request the way the server did, and
the spectrum layout test holds the client to recovering from it.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:50:49 +02:00
sjg c073d03ffb [feat](trx-frontend-http): reorder the controls tray sections
CI / lint (push) Successful in 2m23s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
CI / test (push) Successful in 8m14s
The radio's own settings now come first, then audio, then the scheduler:
Advanced radio controls, Audio controls, Scheduler controls.

The advanced section is not in the markup — ui-core builds it at runtime
and gathers the SDR settings, virtual channel and TX limit rows into it,
appending the result, which put it last however the markup was ordered.
It is inserted ahead of the audio section instead.

The signal readout and the TX meters stay where they are, between the
controls and the sections: they are readouts rather than a section, and
on an SDR the spectrum covers them anyway.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:41:08 +02:00
sjg 730f129404 [fix](trx-frontend-http): let the header identity give way before the tabs
CI still put the tab strip into the controls, now at 1100px — the
narrowest bar in the app, since the bookmark gutters take 9.5rem a side
above that width and leave 756px against 871px at 900px.  With the
controls already in the overflow menu and the tabs already down to
icons, nothing else could give, and what gives by default is the strip:
it is the one item allowed to shrink below its content, so its tabs keep
full width and slide under the controls, out of reach.

The identity block takes the squeeze instead, ellipsised.  A clipped
station name is still readable; a destination hidden underneath the
controls is not.

The guard that was supposed to catch this scaled only the tabs and the
controls, not the title and subtitles — which is exactly what runs out
of room — and skipped 900px.  It now scales every piece of text in the
bar and checks all four widths.  Measured across text scales from 1.0 to
3.0 at each width, the bar keeps its 16px allowance everywhere; before
this, 1.6 and above overlapped at 1100px.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:40:36 +02:00
sjg f95f3d0104 [feat](trx-frontend-http): centre the radio controls, split off the mode row
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m13s
CI / frontend (push) Failing after 32s
CI / reuse (push) Successful in 3s
The controls every rig has — mode, wheel, tune step, transmit — now sit
as a centred block rather than packed against the left edge.

What the current mode adds moves out from among them: WFM's six controls
stretched the row sideways whenever it was active, pushing the wheel and
the step pickers off centre, and SAM did the same on a smaller scale.
They get a row of their own below a divider, which appears and leaves
with them — an empty one would still take a track and a gap in the tray
and draw its divider under controls it has nothing to do with.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:29:31 +02:00
sjg e70e82c8c0 [feat](trx-frontend-http): rebuild the general radio controls row
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m8s
CI / frontend (push) Failing after 36s
CI / reuse (push) Successful in 4s
Mode was a full-width select: 483px of the row to display "FM".  The
modes are three or four characters and there are at most twelve, so they
become a segmented group like the Unit and Step Scale pickers beside
them — a third of the width, and one click instead of two.

The <select> stays as the mode's value.  A dozen call sites and several
plugins read #mode.value, so replacing it outright would have reached
much further than a layout change should; it is hidden from sight and
from assistive tech, the buttons write to it, and everything downstream
runs unchanged.  Every writer re-syncs the buttons, the plugins through
a new trxCore.syncModePicker.

The row itself was a grid with a track per column, but the WFM, SAM and
transmit columns are hidden on most rigs, so it ended in some 500px of
hole.  It packs left now.  Same fault one level down: the power buttons
sat in three fixed tracks, so a rig with neither transmit nor lock kept
two empty ones and left its label chip stranded at the far edge.

Unit and Step Scale move out of the frequency row and in beside the
wheel and the +/- they modify, which were some 600px away.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:09:08 +02:00
sjg 23cc0db1fa [fix](trx-frontend-http): drop the tabs to icons when the bar runs out of room
CI / test (push) Successful in 8m15s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m17s
CI put the tab strip 9px into the controls at 1440px on a run that
passed locally: its system font is wider, and the bar had no move left
to make.  Controls are moved into the overflow menu until the bar fits,
but once all of them were in the menu nothing else gave — the nav may
shrink below its content, so the tabs kept full width and ran under the
controls, leaving the destinations nearest them unclickable.  Labels
dropping to icons was the other half of the answer, but it hung off a
max-width:1360px media query and so was unavailable at 1440px.

That class now goes on by measurement, as the last step after the menu
is exhausted, which is the same reasoning the controls' own fit test
already uses: how much fits depends on the rig name and on how wide the
platform draws the labels, not on the viewport.  The class is cleared
before measuring so the decision cannot ratchet, and icon widths are
fixed, so it always buys back the labels' width.

Labels now stay put between 1100px and 1360px while they fit, with the
style picker and theme toggle behind the overflow menu instead.

The suite could not have caught this: it passed on the fonts of the
machine that wrote it.  The layout section now repeats its fit check
with the bar's text scaled up, which reproduces a wider system font
anywhere — with this fix reverted it fails on macOS too.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 21:36:55 +02:00
sjg 657f952a2c [fix](trx-frontend-http): hold the strips' place above the spectrum
Tuning across a band edge moved the whole page under the cursor: the
band plan strip is in flow, so a range with no allocations collapsed it
from 18px to nothing and dragged every element below it up (measured at
1600x950: overview top 118 to 100, footer 1026 to 1008).  It now keeps
its height whenever a band plan could be drawn at all, and only gives it
back when the feature is off, has no data, or there is no spectrum.

The bookmark rail gets the same treatment for consistency, though it
never moved anything — it is absolutely positioned over the top of the
overview.  It stays up and blank rather than vanishing.

Which exposes something the rail was already doing wrong: it covers the
top of the plot, and a bare div still takes pointer events, so whenever
bookmarks were in range that band of the overview could not be dragged
or scrolled.  Only the chips are targets now.

tests/spectrum-layout.mjs covers this: it streams spectrum frames, tunes
between a band with bookmarks and allocations and one with neither, and
asserts nothing moves and that the rail lets clicks through.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 21:26:19 +02:00
sjg 1f256cbb68 [refactor](trx-frontend-http): extract the browser test fixture
browser-smoke.mjs carried its static server inline, which made it the
only browser test that could exist: a second one would have had to copy
180 lines of routes to change a single capability flag.  The server
moves to tests/web-fixture.mjs behind startWebFixture(), with the rig's
spectrum support, bookmarks and band plan as options.

Serving a rig with a spectrum matters because that is where the layout
actually lives — the panel, the strips above it and the waterfall are
all gated on filter_controls, and the existing fixture reports a
CAT-only rig, so none of it has ever been rendered under test.

No change to what the smoke test checks.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 21:26:06 +02:00
sjg 92fbdb692c [feat](trx-frontend-http): lay the scheduler controls out in one row
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m12s
CI / frontend (push) Failing after 29s
CI / reuse (push) Successful in 3s
The controls were a column — release, then the step buttons, then the
status line, then the entry on air last — which read bottom-up and left
the entry that is actually transmitting furthest from the buttons that
change it.  They now run left to right: step through the entries, hand
the rig back, then the current entry behind a separator.

The separator is a pseudo-element on the current-entry block rather than
an element of its own, because that block is display-toggled whenever
fewer than two entries are active; a standalone rule would be left
hanging with nothing after it.

No ids move, so the enable/disable logic in the scheduler plugin and the
release polling in vchan bind exactly as before.  The smoke test asserts
the row's order, which is also what keeps the separator in place.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:58:31 +02:00
sjg b2fbcb318d [fix](trx-frontend-http): stop Tools lighting up on every refresh
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m21s
CI / frontend (push) Failing after 30s
CI / reuse (push) Successful in 3s
navigateToTab marked the Tools button by asking whether the destination
tab was displayed, which is the right question at the wrong moment: the
first route navigation runs while the card is still behind the loading
state, where every tab computes to display:none.  Refreshing or deep
linking to any page therefore lit Tools alongside the real destination,
and nothing re-evaluated it once the page appeared.

Membership of the Tools menu answers the same question without needing
anything laid out, and still reads the grouping ui-core installs rather
than a second copy of it.

The smoke fixture now serves the SPA shell for route paths the way the
server's per-tab index handlers do, so a deep link no longer 404s and
the case is testable at all; two of them are asserted.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:51:04 +02:00
sjg 44721b579c [fix](trx-frontend-http): fill the map column down to the footer
The windowed map was capped at 75% of the viewport height and at a
width-derived aspect ratio, which left a dead band under it: 69px at
1600x950, and on a 420px-wide phone a 270px map on an 800px screen.
Neither cap was doing useful work now that the stage spans the full
width, so the map fills the column down to the footer instead.

Growing into the footer needs a bound: once the column is tall enough to
push the footer below the fold, using its position would push it further
on every pass, so the bottom edge is clamped to the viewport.  Growth
then consumes the column's spare height and settles in one pass.

Also drops three mapIsFullscreen() branches in the windowed path that
could never be taken — the fullscreen case returns above them.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:50:48 +02:00
sjg 889d00007d [feat](trx-frontend-http): box the selected tab instead of underlining it
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m19s
CI / frontend (push) Successful in 3m10s
CI / reuse (push) Successful in 3s
The desktop strip marked the current page with a 2px underline while the
mobile bottom nav already boxed it, so one navigation model looked like
two.  The box now sits on both: a transparent 1px border on the base
reserves it, so switching pages moves no neighbours, and hover fills a
fainter version of the same shape.  Tools carries it too — that button
is marked active for the destinations the strip hides.

Dropping the mobile rule's border-bottom:none, which only existed to
cancel the old desktop underline, closes the bottom edge its active box
had been missing.  The smoke test checks all four edges.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:37:31 +02:00
sjg 6f53592041 [feat](trx-frontend-http): rework the page footer
The footer floated in space below the content with no rule to close the
page, its two clusters sat on a text baseline that left the source pill
hanging, and the status hint was a plain line of text a size larger than
the attribution beside it.

Now a hairline closes the page the way .tab-bar opens it, the clusters
centre on one line, and the attribution drops the opacity it stacked on
top of --text-muted, which had put it below a readable contrast ratio.

The status hint becomes a pill with a state dot: green when ready, amber
while a command is in flight, red on connection loss.  The colour comes
from a data-state attribute, so every hint now goes through setPowerHint
instead of assigning textContent directly.  --status-ok carries the
indicator green; .about-status-on picks it up too, which darkens it on
light themes where the old value was barely legible.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:37:12 +02:00
sjg 709939f60b [feat](trx-frontend-http): span the map across the full viewport width
The map stage broke out of the centred .card column: negative inline
margins cancel the card's centring offset and its side padding, so the
stage reaches both viewport edges at every width without hardcoding
either value.  Its rounded corners and left/right borders go with it —
edge to edge, the panel reads as a band rather than a floating card.

The browser smoke test now measures the stage against the viewport, and
checks that the full-bleed width does not push the page sideways.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:36:50 +02:00
sjgandClaude Opus 5 5b7dd493d4 [feat](trx-frontend-http): draw APRS symbols from the sprite sheets
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m12s
CI / frontend (pull_request) Successful in 3m2s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 7m28s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 3s
Resolve a table/code pair to a sprite cell in aprs-shared, and use it
from both the packet lists and the map markers, which had each been
printing the raw symbol character in a bordered box.

A table identifier of / or \ selects the primary or alternate sheet
directly.  Anything else is an overlay character, which the APRS spec
draws on top of the alternate symbol -- so those stack the overlay sheet
over the alternate one rather than picking a sheet.  Codes outside
0x21..0x7E have no cell and keep the old character box.

The sheet URLs stay in the stylesheet so a min-resolution query can swap
in the retina sheets; only the cell offset is computed and set inline.
Map markers share the helper through the plugin chunk, so the map stays
free of any remote symbol fetch.

Verified in a browser against the real stylesheet and sheets: /> is a
car, /_ a WX circle, /& an igate diamond, \n a red triangle, and the
overlays S> and 7# carry their character on the alternate symbol.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:55:50 +02:00
sjgandClaude Opus 5 02fe492dbf [feat](trx-frontend-http): serve the vendored APRS symbol sprites
Embed the six sheets alongside the other vendored assets and serve them
under /vendor with the same immutable cache headers.

The browser computes a symbol's cell from a 16x6 grid of 24px cells, so
a re-vendored sheet at any other size would shift every station onto a
neighbouring icon -- wrong on every packet, and invisible unless you
know which glyph to expect.  Pin the geometry by parsing each embedded
PNG's IHDR in a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:55:50 +02:00
sjgandClaude Opus 5 1bc2c88e2f [chore](trx-rs): vendor the APRS symbol sprite sheets
The web UI had no symbol graphics at all, so a position report rendered
its raw symbol character in a bordered box.  Vendor rev H of the
hessu/aprs-symbols set: three 24px sheets (primary, alternate, and the
overlay characters) plus their retina variants.

The set carries no single license.  Individual symbols are variously
vectorizations of the original WA8LMF bitmaps with unknown terms, new
CC BY-SA work by OH7LZB, public-domain or CC sources, and a handful of
brand logos owned by their companies.  Record that as
LicenseRef-APRS-Symbols with the upstream per-symbol catalogue copied
verbatim, and carry the attribution pointer upstream asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:36:27 +02:00
sjgandClaude Opus 5 863a6d8fd4 [fix](trx-frontend-http): restore decode history from the stored records
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m19s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m26s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 2s
Replay required every restored record to carry a string `type`, and
stored records do not have one: an AIS entry holds mmsi, lat, lon,
crc_ok and its decoder's own fields, nothing more.  The filter therefore
discarded all of them, and did it silently — the fetch returned its full
payload, the worker decoded it, and no error was logged, so the history
simply never appeared.

That field identifies live SSE frames, which do carry it, which is why
only replay was affected.  History arrives already grouped and the
group's kind is delivered alongside the messages, so `type` was never
needed to route them.  Require only that a record is an object.

Confirmed against a live server: the first restored group is AIS, and
its records expose their decoder fields with `type` undefined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 18:39:13 +02:00
sjgandClaude Opus 5 b252717f6b [test](trx-frontend-http): serve a realistic decoder registry to the smoke test
CI / lint (pull_request) Successful in 2m18s
CI / test (pull_request) Successful in 7m25s
CI / frontend (pull_request) Successful in 2m10s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m26s
CI / frontend (push) Successful in 2m12s
CI / reuse (push) Successful in 3s
The fixture answered /decoders with an empty list, which hid most of the
application from the only test that runs it in a browser.  The decoder
sub-tabs, their panels, the decode toggles and the bookmark decoder
checkboxes are all built from that registry, so the run exercised three
of thirteen sub-tabs and none of the decoder UI.  Finding this needed
route interception, because nothing in the suite could see it.

Serve eleven decoders covering the modes the real registry spans.  The
run now builds 13 sub-tabs and 11 bookmark decoder checkboxes — the same
checkboxes whose construction a recent fix changed without any test
reaching them — and still reports no runtime errors.

It also makes an existing fault observable: at 1100px the decoder
sub-tab bar hides 195px of itself with no scrollbar or fade, the same
silent truncation the top strip had.  No assertion for it here, since
that would fail until the truncation is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 17:46:52 +02:00
sjgandClaude Opus 5 92697b11c5 [feat](trx-frontend-http): mark Tools active for its destinations
CI / test (pull_request) Successful in 8m9s
CI / test (push) Successful in 7m22s
CI / lint (pull_request) Successful in 2m15s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / frontend (push) Successful in 2m10s
CI / reuse (push) Successful in 2s
Grouping Statistics, Recorder, Settings and About behind Tools left the
tab strip looking identical on all four: the destination's own button
carries the active class, but the strip hides that button, so nothing
was marked.  The page titles named the page without saying how you got
there.

Mark the Tools button when the active destination is one the strip hides.
That state is read from the button's computed display rather than from a
second copy of the grouping, so the two cannot drift: whatever ui-core
puts in the menu lights up Tools, and a destination promoted back into
the strip stops doing so with no further change.

Tools already carries the tab class, so the existing active styling
applies unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 00:23:17 +02:00
sjgandClaude Opus 5 b409c57296 [test](trx-frontend-http): assert header geometry in the browser smoke test
CI / test (push) Successful in 7m27s
CI / frontend (push) Successful in 3m1s
CI / reuse (push) Successful in 3s
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 7m24s
CI / frontend (pull_request) Successful in 2m11s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m15s
Several layout faults shipped while every gate passed, because nothing
looked at geometry: a header whose height tracked the viewport, controls
at four different heights, a tab strip that ran under the controls, and a
dropdown that opened underneath the page.

Assert the invariants behind those at four widths — the header stays one
row, the tabs do not reach the controls, the controls share a height, the
page does not scroll sideways — and that the menu renders with real
dimensions and wins a hit test at its own centre.

The overlap check measures the tabs rather than the strip: with the strip
allowed to overflow, its box shrinks while its content paints across the
controls, so the container's own rect never registers the collision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 00:18:16 +02:00
sjgandClaude Opus 5 00191c8d7a [fix](trx-frontend-http): serve the Statistics and Bookmarks routes
CI / lint (pull_request) Successful in 2m18s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m17s
CI / test (pull_request) Successful in 8m23s
CI / test (push) Successful in 7m28s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 2s
The server answers /, /map, /digital-modes, /recorder, /settings and
/about with the application shell, but never had a route for /statistics
or /bookmarks.  Both fell through to the catch-all asset handler, so
reloading on either one downloaded a file instead of reopening the page.
Only in-app navigation worked, which is why it went unnoticed until
Statistics was reachable from the Tools menu.

Add the two missing shell routes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 00:03:48 +02:00
sjgandClaude Opus 5 56c363a517 [fix](trx-frontend-http): align the Statistics page and unblock the tab strip
Two faults, both mine, both visible in one screenshot of that page.

#tab-statistics was the only panel with padding of its own, so its title
and content sat 16px inside where every other page begins.  Remove it and
the page lines up with the header and with its siblings.

Removing the tab strip's `overflow-x` left it unable to shrink below its
content, so at around 1280px it ran under the controls: the Map tab sat
beneath the audio button and Tools beneath REC.  Clipping is safe again —
the menus it anchors are reparented to the body when they open — so the
strip can shrink, and the labels now give way to icons at 1360px rather
than 1180px, before it has to clip anything.

Measured at 1280px: 321px of clearance between the strip and the
controls, and the page title at the same left edge as the header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:59:58 +02:00
sjgandClaude Opus 5 5ad91b4ab6 [fix](trx-frontend-http): stop doubling the space under the Statistics title
CI / lint (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m20s
CI / test (push) Successful in 7m33s
CI / frontend (push) Successful in 2m12s
CI / reuse (push) Successful in 3s
The page titles carry a bottom margin, which is what spaces them from the
content on the plain block panels.  #tab-statistics is not one: it is a
flex column with `gap: 1rem`, so the margin landed on top of that gap and
left 28px under the title where every other page had 12px.

Drop the margin on that panel and let its own gap do the spacing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:50:03 +02:00
sjgandClaude Opus 5 1843522b45 [feat](trx-frontend-http): give the Tools destinations page titles
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 2m59s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m30s
CI / frontend (push) Successful in 2m10s
CI / reuse (push) Successful in 3s
Recorder stated its name; Statistics, Settings and About did not, so one
page in eight carried a title.  The class it used, section-heading, had
no rule behind it either, leaving even that title as a default h2.

Which way to unify follows from the navigation change.  The tab strip
highlights the destination you are on, so Radio, Bookmarks, Digital modes
and Map already say where you are and a title would repeat the strip
while costing vertical space the spectrum wants.  The four destinations
behind Tools get no such highlight — the strip looks the same on all of
them — so those are exactly the pages that have to name themselves.

Give the three that were missing a heading, and style section-heading so
all four match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:46:09 +02:00
sjgandClaude Opus 5 41ddecf5a7 [fix](trx-frontend-http): label the overflow tab Tools and hide its glyph
CI / test (pull_request) Successful in 8m17s
CI / lint (push) Successful in 2m18s
CI / lint (pull_request) Successful in 2m16s
CI / frontend (pull_request) Successful in 3m6s
CI / reuse (pull_request) Successful in 3s
CI / test (push) Successful in 7m25s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 3s
The button rendered as "•••More": the dots span carried no styling at
all, so the glyph sat flush against the label instead of behaving like
the icon it is.  Every other tab hides its icon while labels are shown
and swaps to it when they are not; the dots now follow the same rule, so
the button reads "Tools" beside the other labels and becomes the glyph
alone in the icon band.

"More" also said nothing about the destinations behind it.  The menu
holds Statistics, Recorder, Settings and About, so name it Tools and give
the button an aria-label that spells that out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:36:41 +02:00
sjgandClaude Opus 5 b06c37affa [fix](trx-frontend-http): lift the header menus out of the header
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m21s
CI / frontend (pull_request) Successful in 3m5s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m36s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 3s
Fixed positioning escaped the clipping, but not the stacking: the header
carries `z-index: 2`, which makes it a stacking context, so whatever
z-index a menu inside it carries is confined below level 2.  The spectrum
overlays paint as high as 9600, so both dropdowns opened underneath them.

Reparent each menu to the body when it opens.  Leaving that subtree is
the only way out of an ancestor's stacking context, and the menus are
already positioned in viewport coordinates, so nothing else about them
changes.  The outside-click test now considers the menu as well as its
wrapper, since the two are no longer nested.

Verified by hit testing rather than by inspecting z-index:
elementFromPoint at the open menu's centre returns the menu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:28:39 +02:00
sjgandClaude Opus 5 b4912f5879 [fix](trx-frontend-http): render the header menus above the page
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m23s
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 2m59s
CI / reuse (pull_request) Successful in 3s
CI / frontend (push) Successful in 2m10s
CI / reuse (push) Successful in 3s
Both header dropdowns were laid out inside the bar rather than over the
page.  The navigation menu opened as an 18px sliver positioned above its
own button, and the overflow menu did not appear at all.

Two causes.  The tab strip kept `overflow-x: auto` from when it scrolled,
which clips an absolutely positioned descendant — and the strip is what
the navigation menu anchors to.  The strip no longer scrolls, since the
occasional destinations moved behind More, so the property and the edge
fade that went with it are both gone.

Anchoring in fixed coordinates at open time addresses the general case:
an absolutely positioned menu is clipped by any scrolling ancestor and
trapped inside whatever stacking context its ancestors create, so it can
be squashed inside the bar or painted underneath page content.  Fixed
coordinates answer to the viewport, and the menu flips above its button
near the bottom edge.

Clearing `right` when setting `left` keeps the menus at their natural
width: the stylesheet pins them to the right of their anchor, and leaving
that in place stretched them across the bar — 845px for a four-item list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 19:18:47 +02:00
sjgandClaude Opus 5 27f2558193 [feat](trx-frontend-http): one navigation model at every width
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 3m0s
CI / reuse (pull_request) Successful in 3s
CI / test (push) Successful in 7m25s
CI / lint (pull_request) Successful in 2m18s
CI / lint (push) Successful in 2m17s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 3s
Eight destinations sat flat in the tab strip with equal weight, competing
with the controls for the same row and then scrolling out of reach with
only a fade to say so.  They are not equal: Radio is where an operator
spends nearly all their time, Bookmarks, Digital modes and Map are
operating surfaces, and Statistics, Recorder, Settings and About are
occasional.

The mobile layout already grouped them exactly that way, behind its More
menu, so the application carried two navigation models.  Adopt the mobile
grouping at every width instead of adding a third: four operating tabs
plus More.  The strip no longer scrolls at any width, and the menu keeps
its bottom-sheet placement on mobile while anchoring under its button
elsewhere.

Drop the labels between 701 and 1180px so the tabs degrade to their icons
— which every tab already carries — before the strip could ever need to
hide a destination.

Rename Main to Radio: it is the receiver, not a generic first page, and
the name now says what the destination is rather than where it sits.

Freeing that width also let the style picker and theme toggle return to
the bar inline, leaving only the layout picker in the overflow menu.

Navigating to About in the browser smoke test now goes through More, as a
person would.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 19:12:14 +02:00
sjgandClaude Opus 5 3a9bf1b7ce [fix](trx-frontend-http): drop the rig description from the top bar
CI / test (pull_request) Successful in 8m22s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 3s
CI / lint (pull_request) Successful in 2m17s
The header repeated the active rig's hardware string and mode list beside
the rig picker.  With a real SDR that reads

  SoapySDR driver=airspyhf,serial=c852eb5dd23539f8 · RX · SDR filters ·
  LSB · USB · CW · CWR · AM · +7 modes

which is longer than every other control in the bar combined, and it is
already on the About tab in full, split across its Rig, Active rig,
Connection, Modes and VFO rows.

Remove the element and the builder behind it.  Rig switching keeps its
feedback through the existing hint channel rather than by briefly
rewriting a permanent label, and the identity that belongs in a header —
the rig's display name — stays in the left subtitle.

The freed width is not spent: the tab strip now reaches Settings before
it needs to scroll, where it previously faded out during Statistics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 18:59:28 +02:00
sjgandClaude Opus 5 dd5760c436 [style](trx-frontend-http): fade the scrolled tab strip edge
CI / lint (pull_request) Successful in 2m19s
CI / test (pull_request) Successful in 8m17s
CI / frontend (pull_request) Successful in 3m0s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m33s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 3s
The page tabs scroll rather than wrap, so the last visible tab was sliced
mid-word ("Se…" for Settings), which reads as a rendering fault instead of
as an invitation to scroll.

Fade the trailing edge with a mask.  A colour-matched cover gradient is
the usual trick, but the card is transparent, so a cover would have to
track the page background across both themes and all nine styles; a mask
is colour-agnostic.  Only the trailing edge is faded, leaving the first
tab crisp while the strip sits at rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 18:48:34 +02:00
sjgandClaude Opus 5 eee3630f04 [feat](trx-frontend-http): compact single-row top bar
The header's height depended on the viewport width, and not even
monotonically: 112px at 1440, 169px at 1100, 131px at 900, 246px at 720.
Both control groups wrapped, so every width produced a different ragged
block — eight page tabs across four rows at 1100px, and action controls
across three.  Four different control heights (32, 34, 45 and 54px) sat
in the same row, the 54px one being the rig picker with its summary
stacked underneath, and on narrow viewports the icon buttons stretched to
fill half the row, rendering a play triangle centred in a 249px box.

Lay both groups out as one row that never wraps.  Controls are a uniform
2rem and no longer stretch, the rig summary sits inline beside its select,
and the page tabs scroll instead of wrapping.  Secondary controls —
layout, style and theme — move into an overflow menu when the bar cannot
hold them, leaving audio, record and the rig picker inline.

Deciding when they no longer fit needs natural widths, not rendered ones:
the nav has min-width 0 and scrolls, so it always shrinks to the leftover
space and always reports scrolling, and the bar reports overflow even when
nothing is clipped.  scrollWidth on the scroll container is its
unconstrained content width, which is what the fit test compares against
the space available.

Measured after the change: 72px at 1440, 1280, 1100, 900 and 480, every
control 32px, nothing clipped at any width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 18:45:43 +02:00
sjgandClaude Opus 5 b31790ff48 [fix](trx-frontend-http): keep layout sections togglable
CI / lint (pull_request) Successful in 2m21s
CI / frontend (pull_request) Successful in 3m3s
CI / reuse (pull_request) Successful in 3s
CI / test (pull_request) Successful in 8m32s
CI / lint (push) Successful in 2m30s
CI / test (push) Successful in 8m36s
CI / frontend (push) Successful in 2m13s
CI / reuse (push) Successful in 4s
A layout seeds the collapsible sections; it should not hold them there.
applyLayout writes the disclosure state of the advanced, audio and
scheduler sections, and it runs far more often than a layout change:
render() calls applyRigList() for every SSE frame carrying `remotes`,
which calls setActiveRig() unconditionally, which re-applies the layout.

An operator who expanded a section that the selected layout collapses by
default therefore had it shut again within about a second, which read as
the section being locked by the layout — most visibly the scheduler under
Compact.

Write the section state only when the layout actually changes, or the
first time each section appears in the DOM, since the advanced controls
are constructed after the first applyLayout call.  Switching layout still
reseeds every section, so choosing a layout keeps meaning "give me these
defaults".

Verified in Chromium: with Compact selected, activating the scheduler
summary opens the section and it survives both a rig-state refresh and a
repeated applyLayout, while selecting Full still reseeds it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 17:46:56 +02:00
sjgandClaude Opus 5 2f4973ed70 [chore](trx-rs): allow the sccache bind mount on the CI runner
CI / test (pull_request) Successful in 13m51s
CI / frontend (pull_request) Successful in 5m1s
CI / test (push) Successful in 7m43s
CI / frontend (push) Successful in 2m18s
CI / reuse (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 4m22s
CI / lint (push) Successful in 2m23s
CI / reuse (push) Successful in 1m18s
act_runner validates every bind mount against `valid_volumes`, which
defaults to an empty allowlist, so the `-v /var/cache/sccache:/sccache`
in `container.options` was dropped on every job.  The only trace is one
line in the job log — "[/var/cache/sccache] is not a valid volume, will
be ignored" — after which SCCACHE_DIR points at a path that does not
outlive the container, so the shared compilation cache never persisted.

Allow that one path rather than the `**` wildcard: the runner is the only
thing mounting host directories here, and a narrow allowlist keeps a
workflow from mounting arbitrary host paths into a job container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 16:54:16 +02:00
sjgandClaude Opus 5 26b00608b2 [chore](trx-rs): force-pull the SDK image on the CI runner
CI / lint (pull_request) Failing after 3s
CI / test (pull_request) Failing after 2s
CI / frontend (pull_request) Failing after 28s
CI / reuse (pull_request) Successful in 3s
The workflow references the SDK image by the moving `:latest` tag, and
act_runner skips the pull when a local copy of that tag already exists:
the job log reports `docker pull ... forcePull=false` followed by
`Image exists? true`.  Pushing a rebuilt image therefore changes nothing
until someone pulls on the VM by hand, and the run fails as though the
image never gained the tool that was added to the Containerfile —
`sccache` resolving as "No such file or directory" while the pinned
toolchain from an earlier build of the same tag resolves fine.

Set `force_pull: true` so a pushed image is what actually runs, and
document the manual refresh for runners configured before this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 16:49:46 +02:00
sjgandClaude Opus 5 c2455bb08c [chore](trx-rs): build the SDK image natively on x86_64 and arm64
CI / reuse (pull_request) Successful in 3s
CI / lint (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / frontend (pull_request) Has been cancelled
The sccache release asset is per-architecture and the Containerfile
hardcoded the x86_64 triple, so an arm64 build produced an image whose
sccache binary could not execute.  Everything else in the image — the
Debian base, the build dependencies, Node.js and rustup — already
resolves per architecture, so that one URL was what pinned the image to
amd64 and forced Rosetta or qemu on Apple Silicon.

Resolve the triple from `uname -m`, which reflects the build platform
under plain docker/podman build as well as buildx, unlike the
BuildKit-only TARGETARCH.

Document publishing `:latest` as a manifest list built natively on a host
of each architecture, since a single-architecture tag sends the other
side back to emulation, and note that Apple's `container` CLI needs
Rosetta for its BuildKit helper VM regardless of the target.

Pick the act_runner download by architecture for the same reason.

Verified on arm64: the case arm selects
sccache-v0.8.2-aarch64-unknown-linux-musl, and the installed binary
reports `sccache 0.8.2` running natively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 12:14:44 +02:00
sjgandClaude Opus 5 22ff1349f3 [chore](trx-rs): run the frontend job in the SDK image
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 2s
CI / frontend (pull_request) Failing after 27s
CI / reuse (pull_request) Successful in 5s
The frontend job was added while CI still targeted host-executor runners,
so it never gained the `container:` key the lint and test jobs use.  On
the Docker executor it lands on a bare job container and fails the same
way the Rust jobs did before this branch: `npm` is missing, the Chromium
install shells out to `sudo apt-get`, and `npm run verify-generated`
regenerates the Rust wire contracts, so it needs `cargo` too.

Run it in the SDK image, which already ships Node.js, Chromium at the
path the browser smoke test defaults to, and the pinned Rust toolchain.
Installing Chromium per run is then redundant.

Drop the job's trailing `reuse lint`.  The SDK image deliberately carries
nothing REUSE-related, and the separate `reuse` job lints the whole
repository with the upstream action, generated assets included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:33:02 +02:00
sjg 8b72573521 [chore](trx-rs): add sccache compilation cache
Bake sccache into the SDK image and enable it via RUSTC_WRAPPER in CI and
the devcontainer (not repo-wide, so non-SDK builds are unaffected).

- container/Containerfile: install the sccache musl binary.
- ci.yml: RUSTC_WRAPPER=sccache, CARGO_INCREMENTAL=0, SCCACHE_DIR=/sccache,
  cache size cap, plus a `sccache --show-stats` step per job.
- runner-config.example.yaml: bind-mount /var/cache/sccache into job
  containers so the cache persists across runs and is shared between jobs.
- .devcontainer: enable sccache with a named cache volume.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:27:42 +02:00
sjg f64032dcbe [chore](trx-rs): add OpenRC service for act_runner on Alpine
The runner host is Alpine (OpenRC, no systemd). Add an OpenRC init script
for act_runner (supervise-daemon, depends on docker) plus a conf.d
example for running one instance per project, and rewrite the runner
section of the README with Alpine setup steps (apk docker, dedicated user
in the docker group, register, service install).

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:27:42 +02:00
sjg 8fd1761688 [chore](trx-rs): use nested SDK image path trx-rs/sdk
Match the image name that was pushed to the registry
(git.haxx.space/sjg/trx-rs/sdk) across the workflow, devcontainer and
README.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:27:42 +02:00
sjg 7022f20b76 [chore](trx-rs): shared SDK image for CI and developers
Rework container/ from a host-executor act_runner image into a single
"SDK" build image used everywhere: as the CI job container (Docker
executor) and by developers locally / via .devcontainer. It bakes in a
pinned Rust toolchain and all build dependencies, so CI and every
developer share the exact same rustc/clippy.

- container/Containerfile: SDK image (Debian + deps + pinned Rust + Node).
- rust-toolchain.toml: pin the toolchain to match the image; also ends the
  "CI clippy newer than local" version skew.
- .gitea/workflows/ci.yml: lint/test run inside the SDK image via
  `container:`; reuse returns to fsfe/reuse-action (Docker executor runs
  it as a sibling container, so nothing REUSE-related is baked in).
- .devcontainer/devcontainer.json: dev use of the same image.
- container/runner-config.example.yaml: Docker-executor runner config for
  the CI VM, capped for a 2-thread budget.
- Drop the obsolete host-executor entrypoint/config/Quadlet units.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:27:42 +02:00
sjgandClaude Opus 5 0d4c657b97 [fix](trx-frontend-http): route feature bundles through the host contract
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 1s
CI / frontend (pull_request) Failing after 41s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 2s
CI / frontend (push) Failing after 36s
CI / reuse (push) Failing after 1s
The bookmark fix addressed one instance of a defect the TypeScript
migration left across the feature entries.  app.js stopped being a
classic script, so its top-level declarations are no longer shared
globals, but the converted entries kept reading them as window
properties that nothing publishes.

Restore the broken behavior:

- ais, aprs, hf-aprs read serverLat, serverLon and haversineKm as
  undefined, so every positioned packet rendered an empty distance.
- ais, aprs, hf-aprs, cw, sat, vdes, wefax, wspr called an undefined
  postPath, so clear-history and decoder toggles threw.
- scheduler read authRole as undefined, so the lazy-load path never
  self-initialized and the Settings tab opened an inert scheduler.
- background-decode read authEnabled as undefined, so control gating
  fell back to role-only.
- vchan read fifteen application values and services as undefined:
  mode and bandwidth sync, the out-of-band hint, RX audio restart, and
  the frequency field all silently no-opped on a virtual channel.
- vchan wrapped window.refreshFreqDisplay, capturing an undefined
  original exactly as it did for setRigFrequency, so leaving a channel
  never restored the application's own frequency display.
- _audioChannelOverride was a const that nothing could assign, so RX
  audio always subscribed to the primary channel.
- ftx-family read fmtTime, a helper legacy ft8.js owned locally, so
  decode bar timestamps rendered empty.

Declare the contract once in plugins/host.ts and import it from the
feature entries, rather than restoring globals that
docs/frontend-architecture.md excludes.  trx.state gains jogUnit,
rxActive and audioChannelOverride, and makes lastModeName writable;
trx.core gains the tuning, RDS, WFM, jog and RX audio services the
entries need.  vchan interception moves to an interceptFreqDisplay
service method that refreshFreqDisplay calls, matching the frequency,
mode and bandwidth interception it already registers.

Reading registry-built elements through a strict lookup is the same
defect as in bookmarks: renderTimelineNeedle guards its result, but
schedulerEl throws, so the now-initializing scheduler crashed on the
timeline needle group that its own SVG creates.

Feature tests move onto a shared host fixture, and entries that now
import a common module are bundled through bundleEntry like the other
shared-module entries.  Covers scheduler self-initialization and the
distance path that the bare window reads broke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:20:17 +02:00
sjgandClaude Opus 5 23dbcac5b6 [fix](trx-frontend-http): restore bookmark host contract
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / frontend (pull_request) Failing after 42s
CI / reuse (pull_request) Failing after 0s
The TypeScript migration turned app.js from a classic script into an ES
module, so its top-level declarations stopped being shared globals.
bookmarks.ts was converted verbatim and kept reading them as window
properties, which app.ts no longer publishes.

Every bookmark interaction read undefined: the Add Bookmark and Select
All buttons stayed hidden because the auth check saw no authEnabled or
authRole, per-rig scopes were missing from the scope picker and the move
target, decoder checkboxes were never built, and Tune threw on
bridge.postPath before issuing a single request.

Extend the typed window.trx host contract instead of restoring globals,
as docs/frontend-architecture.md closes the standalone window property
list.  trx.state publishes authEnabled; trx.core publishes
setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency,
syncBandwidthInput, scheduleSpectrumDraw, and onDecoderRegistryReady.

Replace the vchan setRigFrequency wrapper with an interceptFrequency
service method, matching interceptMode and interceptBandwidth.  The
wrapper captured an undefined original and silently dropped every tune;
routing interception through setRigFrequency also restores virtual
channel redirection for the application's own tuning.

Read registry-built elements through bmOptionalEl, since bmEl throws and
the decoder checkboxes and decode toggle buttons are legitimately absent
until the registry arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 10:15:56 +02:00
sjg 695434942f chore: complete TypeScript migration cleanup
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 2s
CI / frontend (pull_request) Failing after 42s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 1s
CI / frontend (push) Failing after 44s
CI / reuse (push) Failing after 2s
2026-08-01 22:39:37 +02:00
sjg a1a8d1d1d3 test: cover lazy map and rig startup flows 2026-08-01 22:34:22 +02:00
sjg ddb33b6ff3 feat: generate frontend status metadata contract 2026-08-01 22:27:35 +02:00
sjg f87289b129 fix: validate typed frontend startup in Chromium 2026-08-01 22:25:10 +02:00
sjg 812359c744 refactor: enforce typed frontend runtime boundaries 2026-08-01 22:19:50 +02:00
sjg d929ad3bde build: consolidate typed frontend module graph 2026-08-01 21:54:00 +02:00
sjg 8aeca613aa test: add frontend browser startup smoke coverage 2026-08-01 20:19:27 +02:00
sjg ee59d8efdd build: remove JavaScript compatibility mode 2026-08-01 19:28:45 +02:00
sjg ddd151c1ee refactor: convert main frontend application to TypeScript 2026-08-01 19:18:48 +02:00
sjg 5136704826 refactor: extract typed spectrum math 2026-08-01 18:19:02 +02:00
sjg e300cc343d refactor: extract typed auto bandwidth estimator 2026-08-01 17:59:04 +02:00
sjg 1fb196a64e refactor: extract typed navigation routes 2026-08-01 17:18:41 +02:00
sjg c8cc235e88 refactor: extract strict CBOR decoder 2026-08-01 17:04:13 +02:00
sjg ab3fe4bbb7 refactor: extract typed frontend formatters 2026-08-01 16:42:54 +02:00
sjg 40accb9697 refactor: extract typed authentication API 2026-08-01 16:39:52 +02:00
sjg cbca810a30 refactor: extract typed decoder registry 2026-08-01 16:28:39 +02:00
sjg 26a167cb82 refactor: replace feature globals with typed services 2026-08-01 16:05:40 +02:00
sjg 3d49839ed5 build: type-check worker with isolated globals 2026-08-01 15:21:16 +02:00
sjg 26be675242 build: enable frontend module code splitting 2026-08-01 14:40:59 +02:00
sjg 1a42d58075 refactor: embed generated frontend asset directory 2026-08-01 14:39:34 +02:00
sjg 844de809a4 fix: preserve bandplan override lookup 2026-08-01 14:23:29 +02:00
sjg e910e644d5 refactor: extract typed frontend core utilities 2026-08-01 13:48:51 +02:00
sjg 50e07e2715 refactor: add typed frontend bootstrap 2026-08-01 13:43:46 +02:00
sjg 82121491c5 refactor: convert map feature to strict TypeScript 2026-08-01 13:39:41 +02:00
sjg 0338c8b8d2 refactor: convert scheduler to typed module 2026-08-01 13:23:30 +02:00
sjg c7994347e9 refactor: convert bookmarks to typed module 2026-08-01 13:13:52 +02:00
sjg 9fef0ebc7b refactor: route all decoders through plugin runtime 2026-08-01 13:06:38 +02:00
sjg 508cf2ba87 refactor: add typed decoder plugin runtime 2026-08-01 13:02:54 +02:00
sjg 6a83e2e90a refactor: convert virtual channels to TypeScript 2026-08-01 12:57:09 +02:00
sjg fc2f55bab9 refactor: convert satellite scheduler to TypeScript 2026-08-01 12:54:03 +02:00
sjg 4fb65971e4 refactor: convert satellite view to TypeScript 2026-08-01 12:51:22 +02:00
sjg 021d31d780 refactor: share typed APRS plugin contracts 2026-08-01 12:48:33 +02:00
sjg 074c67c7b9 refactor: convert APRS plugin to TypeScript 2026-08-01 12:43:40 +02:00
sjg d93f784aad refactor: convert AIS plugin to TypeScript 2026-08-01 12:39:20 +02:00
sjg ec59908e0d refactor: convert background decode controls to TypeScript 2026-08-01 12:36:27 +02:00
sjg 7251ec276d refactor: convert WEFAX plugin to TypeScript 2026-08-01 12:33:38 +02:00
sjg 0ab0f80986 refactor: convert VDES plugin to TypeScript 2026-08-01 12:30:17 +02:00
sjg 2cbcc23fec refactor: move plugin loading into typed registry 2026-08-01 12:26:29 +02:00
sjg 968fb3ea2d refactor: type the local Leaflet AIS adapter 2026-08-01 12:23:37 +02:00
sjg 5ba3ecf59b refactor: make FT8 family a typed module 2026-08-01 12:21:13 +02:00
sjg 03bdc10b02 refactor: convert CW plugin to TypeScript 2026-08-01 12:18:51 +02:00
sjg d96624be4f refactor: convert WSPR plugin to TypeScript 2026-08-01 12:15:19 +02:00
sjg a5dccd5489 refactor: consolidate FT2 and FT4 plugins in TypeScript 2026-08-01 12:12:27 +02:00
sjg 42e5dc8604 docs: record frontend migration baseline 2026-08-01 12:09:38 +02:00
sjg e6f593b959 refactor: convert screenshot support to TypeScript 2026-08-01 12:08:44 +02:00
sjg 9ac6d7f82c refactor: convert WebGL renderer to TypeScript 2026-08-01 12:07:02 +02:00
sjg a58553ba66 refactor: convert decode history worker to TypeScript 2026-08-01 12:03:19 +02:00
sjg 7442437757 refactor: convert shared UI core to TypeScript 2026-08-01 12:01:07 +02:00
sjg bbc53d56b0 feat: generate typed frontend API contracts 2026-08-01 11:58:09 +02:00
sjg 888f793eb8 build: add frontend TypeScript toolchain 2026-08-01 11:52:47 +02:00
sjg 860760ecfc docs: add TypeScript migration plan 2026-08-01 11:48:20 +02:00
sjg b4ea35baf8 feat(ui): add dedicated audio controls section
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 0s
CI / reuse (pull_request) Failing after 0s
CI / lint (push) Failing after 1s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 0s
2026-08-01 11:32:34 +02:00
sjg 508ed8a8e7 feat(ui): separate scheduler controls subsection
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 0s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 1s
2026-08-01 11:28:51 +02:00
sjg d9fc623ef4 fix(ui): simplify operator layout picker
CI / lint (push) Failing after 1s
CI / test (push) Failing after 0s
CI / reuse (push) Failing after 0s
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 1s
2026-08-01 11:25:32 +02:00
sjg 53736ef750 fix(ui): hide SDR power and align header controls
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 1s
2026-08-01 11:23:57 +02:00
sjg 6566eae2c7 fix(ui): keep workspace and rig context visible
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 1s
CI / lint (push) Failing after 0s
CI / test (push) Failing after 0s
CI / reuse (push) Failing after 1s
2026-08-01 11:19:31 +02:00
sjg f3eeddff7c fix(ui): close mobile overlays predictably 2026-08-01 11:18:37 +02:00
sjg 1540ca5b4e test(ui): cover shared component behavior 2026-08-01 11:17:17 +02:00
sjg e449704fd2 feat(ui): explain unavailable workspaces 2026-08-01 11:16:03 +02:00
sjg af74430551 fix(ui): make rig switching atomic and visible 2026-08-01 11:15:39 +02:00
sjg d07dbdc645 feat(ui): focus broadcast workspace on reception 2026-08-01 11:14:10 +02:00
sjg d07a627508 feat(ui): remember workspaces per rig 2026-08-01 11:13:21 +02:00
sjg 9fbf90ee91 feat(ui): explain operator workspace choices 2026-08-01 11:12:33 +02:00
sjg d393b3cefc feat(ui): drive workspaces from rig capabilities 2026-08-01 11:11:47 +02:00
sjg 35a492ec95 fix(ui): gate broadcast layout on rig capability
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 1s
CI / test (push) Failing after 3s
CI / reuse (push) Failing after 1s
2026-08-01 11:05:45 +02:00
sjg b20da5c541 style(ui): polish shared interaction components
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 2s
2026-08-01 10:43:29 +02:00
sjg a0bdaa2c4b feat(ui): improve radio operator experience
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 1s
CI / lint (push) Failing after 1s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 0s
2026-08-01 02:24:47 +02:00
sjg 6676b66993 [fix](trx-frontend): account for WFM interference in auto bandwidth
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 0s
CI / lint (push) Failing after 0s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 1s
2026-08-01 02:07:32 +02:00
sjg 412651d612 [fix](trx-frontend): narrow weak WFM to 60 kHz
CI / lint (push) Failing after 1s
CI / test (push) Failing after 2s
CI / reuse (push) Failing after 1s
2026-08-01 02:04:14 +02:00
sjg d347b493d9 [fix](trx-frontend): make auto bandwidth modulation-aware 2026-08-01 02:04:14 +02:00
sjg 39e59dca96 [fix](trx-server): gate test-only history imports
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 1s
CI / lint (push) Failing after 3s
CI / test (push) Failing after 2s
CI / reuse (push) Failing after 1s
2026-08-01 01:55:33 +02:00
sjg 5654520901 [fix](workspace): clear build and clippy warnings
CI / lint (push) Failing after 1s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 1s
2026-08-01 01:52:08 +02:00
sjg ff75fcc692 [refactor](trx-server): extract decoder history store
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 1s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 2s
CI / reuse (push) Failing after 1s
2026-08-01 01:45:19 +02:00
sjg 4728b578ae [refactor](trx-server): extract history policy 2026-08-01 01:44:26 +02:00
sjg 7db7fad9b0 [refactor](trx-frontend): define module service boundaries 2026-08-01 01:44:26 +02:00
sjg 830f7299fe [fix](trx-frontend): vendor Opus decoder 2026-08-01 01:44:26 +02:00
sjg 061738a63b [fix](trx-frontend): serialize plugin loading
CI / lint (push) Failing after 1s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 1s
2026-08-01 01:43:50 +02:00
sjg e8bd97655f [fix](trx-server): offload history persistence
CI / reuse (push) Failing after 1s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 2s
2026-08-01 01:43:47 +02:00
sjg 7d0b36450d [fix](trx-server): persist all decoder histories
CI / reuse (push) Failing after 1s
CI / lint (pull_request) Failing after 3s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 1s
CI / test (push) Failing after 1s
2026-08-01 01:23:36 +02:00
Stan Grams b12c83e8b5 [fix](trx-frontend): preload AIS map icons
CI / lint (pull_request) Failing after 6s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 1s
CI / lint (push) Failing after 1s
CI / test (push) Failing after 2s
CI / reuse (push) Failing after 0s
2026-08-01 01:14:07 +02:00
sjg ff4e2a5c5d [chore](trx-rs): install reuse with charset-normalizer extra
CI / lint (pull_request) Successful in 9m35s
CI / reuse (pull_request) Failing after 5s
CI / lint (push) Successful in 8m40s
CI / test (pull_request) Successful in 29m0s
CI / reuse (push) Failing after 5s
CI / test (push) Successful in 14m28s
The runner image's `reuse` failed to import (NoEncodingModuleError): it
needs an encoding-detection backend, which the bare `reuse` install does
not provide and which libmagic/`file` is not present to satisfy. Install
`reuse[charset-normalizer]` so the reuse job can run in the image.

Rebuild the runner image and recreate the containers to pick this up.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 23:57:38 +02:00
sjg 2ef9f80fc1 [chore](trx-rs): drop redundant CI setup steps for baked runner image
CI / lint (pull_request) Successful in 9m14s
CI / reuse (pull_request) Failing after 6s
CI / test (pull_request) Successful in 15m15s
The host-executor runners run jobs inside one container that already has
the Rust toolchain and all build dependencies baked in, so the per-job
`apt-get install` and rustup steps were redundant. Worse, with two jobs
running concurrently in the same runner they collided on the dpkg lock
("Could not get lock /var/lib/dpkg/lock-frontend").

Remove the system-dependency, rustup and cache steps; jobs now run cargo
directly. The cargo registry persists in the long-lived runner container.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 23:33:42 +02:00
sjg 6fe549e34e [chore](trx-rs): run reuse lint directly instead of Docker action
CI / test (pull_request) Failing after 52s
CI / reuse (pull_request) Failing after 3s
CI / lint (pull_request) Successful in 3m34s
The host-executor runners have no Docker daemon, so fsfe/reuse-action
(a Docker action) fails with "Cannot connect to the Docker daemon". Call
the reuse CLI directly; it is baked into the runner image.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 13:24:26 +02:00
sjg 406a8f86b1 [chore](trx-rs): add Podman act_runner CI deployment
CI / lint (pull_request) Successful in 2m43s
CI / test (pull_request) Successful in 3m20s
CI / reuse (pull_request) Successful in 7s
CI / lint (push) Successful in 2m36s
CI / test (push) Successful in 2m35s
CI / reuse (push) Failing after 4s
Add container/: a rootless Podman + systemd (Quadlet) setup to run
per-project Gitea Actions runners on one host instead of VMs. Uses the
host executor with a purpose-built image that bakes in the Rust toolchain
and all build dependencies (opus, alsa, soapysdr, clang), so CI runs skip
the per-run install and cold soapysdr-sys build.

Includes the runner Containerfile, first-boot registration entrypoint,
act_runner config template, two Quadlet units (trx-rs + a second project),
and a README covering build, registration, the required host-executor
workflow tweak, and tuning.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 12:45:53 +02:00
sjg 4b17b4ac1d [style](trx-ftx): use iterators in LDPC single-index loops
CI / lint (pull_request) Successful in 2m35s
CI / test (pull_request) Successful in 3m22s
CI / reuse (pull_request) Successful in 7s
CI / lint (push) Successful in 2m34s
CI / test (push) Successful in 3m26s
CI / reuse (push) Successful in 7s
clippy needless_range_loop (rust 1.97) flagged the loops in ldpc_check
and ldpc_decode that use a range only to index one array. Replace them
with iterator/enumerate forms. The belief-propagation loops that index
several arrays by the same variable are left as-is (not flagged).

Behaviour is unchanged; the transformations are index-for-index
equivalent. Verified the lib compiles and is clippy-clean; the crate's
LDPC tests run in the CI test job (they need a dev-dependency not
available in the local offline sandbox).

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 10:43:01 +02:00
sjg 977f7b709e [style](trx-ais): drop redundant u32 cast
CI / lint (pull_request) Failing after 2m28s
CI / test (pull_request) Successful in 3m23s
CI / reuse (pull_request) Successful in 7s
get_uint returns Option<u32>, so `? as u32` is an unnecessary same-type
cast flagged by clippy under -D warnings (rust 1.97).

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 10:28:55 +02:00
sjg a2838b06a2 [style](trx-ftx): fix clippy question_mark and collapsible_match
CI / lint (pull_request) Failing after 2m22s
CI / test (pull_request) Successful in 3m27s
CI / reuse (pull_request) Successful in 8s
CI runs a newer clippy (1.97) than was available locally, which flagged
three lints in trx-ftx not caught earlier:

- question_mark: replace the Some/None match in CallsignHashTable::lookup
  with `self.entries[idx].as_ref()?`
- collapsible_match: fold the nested `if` in text.rs char/nchar into match
  guards on the AlphanumSpaceSlash arm

Behaviour is unchanged; verified clean with nightly clippy (1.93).

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 09:25:12 +02:00
sjg c6cd661676 [style](trx-rs): fix clippy warnings for -D warnings CI
CI / lint (pull_request) Failing after 4m14s
CI / test (pull_request) Successful in 15m22s
CI / reuse (pull_request) Successful in 7s
The CI lint job runs clippy with -D warnings, which surfaced a set of
existing warnings across decoders, the client, and the soapysdr backend.
Resolve them so the workspace is clean under the enforced lint level:

- collapsible_match / identity_op / needless_range_loop / same_item_push
  in trx-rds, trx-wspr, trx-vdes, trx-wefax, trx-aprs (mostly tests)
- field_reassign_with_default -> struct-update syntax in trx-client config
  tests
- assign_op_pattern, useless vec!, and test-module ordering picked up by
  cargo clippy --fix in trx-client and the soapysdr WFM tests

No behaviour changes; all affected crates' tests pass.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 23:49:12 +02:00
sjg bf08c7ebc0 [chore](trx-rs): install libclang for soapysdr-sys in CI
CI / lint (pull_request) Failing after 4m7s
CI / reuse (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
soapysdr-sys builds bindings with bindgen, which needs libclang at build
time. Add clang and libclang-dev to the system dependencies so the
soapysdr backend (a default feature) compiles under CI.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 23:30:49 +02:00
sjg c7470acc1c [chore](trx-rs): fix Rust PATH handling in CI
CI / lint (pull_request) Failing after 3m18s
CI / reuse (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
The Set up Rust step wrote the cargo bin dir to GITHUB_PATH, which does
not help within the same step and is not relied upon across steps on the
Gitea act_runner. Prepend $HOME/.cargo/bin to PATH directly in the setup
and each cargo step instead, so rustup and cargo resolve regardless of
GITHUB_PATH support.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 23:25:21 +02:00
sjg bcde03de38 [docs](trx-rs): track improvement areas in aidocs/wip.md
CI / lint (pull_request) Failing after 31s
CI / reuse (pull_request) Successful in 35s
CI / test (pull_request) Failing after 29s
Record the three-tier improvement backlog (infrastructure, robustness,
product) with a per-item progress status column, so the work discovered
in the July 2026 repo scan is tracked. Register aidocs/** in REUSE.toml
alongside docs/**.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 21:21:57 +02:00
sjg 63d46562c7 [chore](trx-rs): add Gitea Actions CI pipeline
Introduce CI running on push to main and pull requests. Three jobs:
lint (rustfmt check + clippy with -D warnings), test (build and test the
workspace with --locked), and REUSE compliance. Installs the required
system libraries (opus, alsa, soapysdr) and caches the cargo registry
and target directory.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 21:21:57 +02:00
sjg e575b3b365 [docs](trx-rs): adopt kernel-style AI attribution trailers
Replace the guidance to credit LLM usage via 'Co-authored-by:' with the
Linux kernel convention. Co-Authored-By and Co-Developed-By are reserved
for human authors (who must also sign off); AI/LLM assistance is
disclosed with an Assisted-By: trailer instead.

Apply the new policy to this commit as a worked example.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 21:10:09 +02:00
sjgandClaude Opus 4.8 2121817c04 [style](trx-frontend-http): refine web UI design system and a11y
Introduce a shared design-token layer and apply it to the web
frontend for a more consistent, readable, and accessible feel.
Pure CSS plus one HTML block; no app.js behaviour changes, and the
existing multi-theme (style x light/dark) system is preserved.

- Add spacing, type, radius and motion tokens to :root.
- Fix undefined --surface consumed by .controls-tray, which dropped
  the panel background entirely; it now resolves per theme.
- Set body line-height and font smoothing; collapse near-duplicate
  font sizes onto canonical scale steps.
- Rebuild the auth gate with classes and replace hardcoded colours
  (#ff6b6b, #9aa4b5) with theme variables so it renders correctly in
  every theme.
- Add an opacity fade on tab switches, gated by prefers-reduced-motion.
- Extend focus-visible rings to the spectrum canvas, tabindex controls
  and links.
- Add a self-theming --accent-text token to lift accent-coloured text
  (links, active tab) to WCAG AA without altering brand fills.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 20:57:43 +02:00
sjgandClaude Opus 4.7 d0bd38d7df [docs](trx-frontend-http): point footer repo link to Gitea
Repoint the web UI footer link to git.haxx.space/sjg/trx-rs, swap the
GitHub octocat mark for a host-neutral git-branch icon, and relabel to
"trx-rs source".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-05-18 00:06:45 +02:00
sjgandClaude Opus 4.7 bf9617e24d [docs](trx-rs): migrate wiki links from GitHub to Gitea
Point README wiki/repo URLs at git.haxx.space/sjg/trx-rs (the new
primary upstream); same /wiki/<Page> path scheme as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-05-18 00:05:29 +02:00
sjgandClaude Opus 4.7 3c235fce5e [chore](trx-rs): REUSE 3.3 compliance, drop GitHub wiki workflow
Add a root REUSE.toml annotating docs, repo metadata, logos, vendored
Leaflet (BSD-2-Clause) and the DSEG14 font (OFL-1.1) instead of
per-file headers; add LICENSES/OFL-1.1.txt. reuse lint: compliant.

Also remove .github/workflows/wiki.yml: the project moved off GitHub
to a self-hosted Gitea, so the GitHub Actions wiki-publishing workflow
no longer runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-05-18 00:05:24 +02:00
sjg ba48de2d30 Initial commit
Sync docs to Wiki / wiki (push) Has been cancelled
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-05-17 23:25:14 +02:00
575 changed files with 100171 additions and 35719 deletions
+4
View File
@@ -1,3 +1,7 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Enable CPU optimizations for better performance
# Set target-cpu to native to use all available CPU features on the build machine
+22
View File
@@ -0,0 +1,22 @@
{
"name": "trx-rs SDK",
"image": "git.haxx.space/sjg/trx-rs/sdk:latest",
"workspaceFolder": "/work",
"workspaceMount": "source=${localWorkspaceFolder},target=/work,type=bind",
"mounts": [
"source=trx-rs-sccache,target=/sccache,type=volume"
],
"containerEnv": {
"RUSTC_WRAPPER": "sccache",
"CARGO_INCREMENTAL": "0",
"SCCACHE_DIR": "/sccache"
},
"customizations": {
"vscode": {
"extensions": [
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml"
]
}
}
}
+89
View File
@@ -0,0 +1,89 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
# CI for the Docker-executor runner (VM). The lint, test and frontend jobs run
# inside the shared trx-rs SDK image (container/Containerfile), which bakes in
# the pinned Rust toolchain, Node.js, Chromium and all build dependencies. The
# reuse job uses the upstream Docker action, which the Docker executor launches
# as a sibling container.
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
# sccache: shared compilation cache persisted on the runner host (see the
# -v mount in runner-config.example.yaml). CARGO_INCREMENTAL=0 because
# sccache cannot cache incremental artifacts.
RUSTC_WRAPPER: sccache
CARGO_INCREMENTAL: "0"
SCCACHE_DIR: /sccache
SCCACHE_CACHE_SIZE: "20G"
jobs:
lint:
runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps:
- uses: actions/checkout@v4
- name: rustfmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: sccache stats
if: always()
run: sccache --show-stats
test:
runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --workspace --all-targets --locked
- name: Test
run: cargo test --workspace --locked
- name: sccache stats
if: always()
run: sccache --show-stats
frontend:
runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
defaults:
run:
working-directory: src/trx-client/trx-frontend/trx-frontend-http/frontend
steps:
- uses: actions/checkout@v4
- name: Cache npm downloads
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install locked frontend dependencies
run: npm ci
- name: Type-check
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Test
run: npm test
# Chromium comes from the SDK image at the path the smoke test defaults
# to, so there is nothing to install here.
- name: Browser smoke test
run: npm run test:browser
- name: Verify generated assets
run: npm run verify-generated
reuse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: fsfe/reuse-action@v5
+3
View File
@@ -21,6 +21,9 @@ Cargo.lock
coverage/
benchmarks/
# Frontend dependencies
**/node_modules/
# Env
.env
.env.*
-7
View File
@@ -1,7 +0,0 @@
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
#
# SPDX-License-Identifier: BSD-2-Clause
[submodule "docs"]
path = docs
url = http://github.com/sgrams/trx-rs.wiki.git
+56 -26
View File
@@ -39,24 +39,30 @@ This is a Cargo workspace. All crates live under `src/`:
```
src/
trx-core/ # Core types, traits, state machine, controller
trx-protocol/ # Client↔server protocol conversion, auth, codec
trx-app/ # Shared application helpers (config, plugins, logging)
trx-server/ # Server binary (rig_task, audio, APRS-IS, PSKReporter)
trx-backend/ # Backend abstraction trait + factory
trx-backend-ft817/ # Yaesu FT-817 CAT implementation
trx-backend-ft450d/ # Yaesu FT-450D CAT implementation
trx-client/ # Client binary (connects to server, runs frontends)
trx-frontend/ # Frontend trait (FrontendSpawner)
trx-frontend-http/ # Web UI with REST API, SSE, and auth
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
trx-app/ # Shared application helpers (config paths, logging init)
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
trx-backend/ # Backend abstraction trait + factory + dummy
trx-backend-ft817/ # Yaesu FT-817 binary CAT (BCD encoding)
trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT
trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC)
trx-client/ # Client binary: remote connection, frontend spawning (~1,500 LOC)
trx-frontend/ # Frontend trait (FrontendSpawner), runtime context
trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth
trx-frontend-http-json/ # JSON-over-TCP control frontend
trx-frontend-rigctl/ # Hamlib-compatible rigctl TCP interface
trx-configurator/ # Interactive setup wizard
decoders/
trx-aprs/ # APRS packet decoder
trx-cw/ # CW (Morse) decoder
trx-ft8/ # FT8 decoder (wraps external ft8_lib C library)
trx-wspr/ # WSPR decoder
trx-decode-log/ # Shared decoder logging (JSON Lines, date-rotated files)
trx-aprs/ # APRS packet decoder (AX.25 + APRS-IS)
trx-cw/ # CW (Morse) decoder (Goertzel tone detection)
trx-ftx/ # Pure Rust FTx decoder (FT8/FT4/FT2, LDPC/OSD) (~3,000+ LOC)
trx-wspr/ # WSPR weak-signal decoder
trx-ais/ # AIS maritime transponder decoder
trx-rds/ # RDS decoder for WFM (~2,000 LOC)
trx-vdes/ # VDES maritime data exchange decoder (~1,300 LOC)
trx-decode-log/ # JSON Lines file logging with date rotation
```
## Architecture
@@ -65,14 +71,14 @@ The project is split into a **server** (connects to the radio hardware) and a **
### Data flow
```
Radio hardware
↕ serial/TCP
trx-server (rig_task.rs)
↕ trx-protocol JSON-TCP (port 4530)
trx-client (remote_client.rs)
internal channels
Frontends: HTTP (8080), rigctl (4532), http-json (ephemeral)
```mermaid
graph TD
Radio["Radio Hardware"] <-->|serial / TCP| Server["trx-server (rig_task.rs)"]
Server <-->|"JSON-TCP :4530"| Client["trx-client (remote_client.rs)"]
Server -->|"Opus-TCP :4531"| Client
Client <-->|internal channels| F1["HTTP Frontend :8080"]
Client <-->|internal channels| F2["rigctl Frontend :4532"]
Client <-->|internal channels| F3["JSON-TCP Frontend"]
```
### trx-core controller
@@ -86,11 +92,11 @@ The rig controller (`src/trx-core/src/rig/controller/`) is the central state man
### Decoders
Signal decoders run as background tasks in `trx-server`, consuming decoded audio. `trx-ft8` wraps a C library (`external/ft8_lib`). Decoded frames can be forwarded to PSKReporter and APRS-IS (IGate) uplinks, or logged via `trx-decode-log`.
Signal decoders run as background tasks in `trx-server`, consuming decoded audio. `trx-ftx` provides the FT8/FT4/FT2 decoder in pure Rust. Decoded frames can be forwarded to PSKReporter and APRS-IS (IGate) uplinks, or logged via `trx-decode-log`.
### Plugin system
## Diagrams
Both `trx-server` and `trx-client` can load shared-library plugins exporting a `trx_register` symbol. Search paths: `./plugins`, `~/.config/trx-rs/plugins`, `TRX_PLUGIN_DIRS` env var.
Always use [Mermaid](https://mermaid.js.org/) for diagrams in Markdown files. Never use ASCII art, box-drawing characters, or plain-text diagrams. GitHub renders Mermaid natively in ```mermaid fenced code blocks.
## Commit Format
@@ -99,3 +105,27 @@ Both `trx-server` and `trx-client` can load shared-library plugins exporting a `
```
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. Use `(trx-rs)` for repo-wide changes. Sign commits with `git commit -s`. Write isolated commits per crate.
## Codebase Review Observations
Full architecture documentation: `docs/Architecture.md`
Improvement plan: `docs/Improvement-Areas.md`
*Last reviewed: 2026-03-29*
### Strengths
- **Explicit state machine**: `RigMachineState` FSM (7 states) prevents invalid states with a deterministic transition table and exhaustive matching. Well-tested with lifecycle, error recovery, and invalid transition tests. `ReadyStateData`/`TransmittingStateData` use `pub(crate)` fields with controlled accessors.
- **Trait-based polymorphism**: Clean abstraction boundaries (`RigCat`, `RigSdr`, `AudioSource`, `RigListener`, `RigCommandHandler`, `CommandExecutor`, `TokenValidator`, `FrontendSpawner`) enable loose coupling and testability. `RigCat`/`RigSdr` split cleanly separates CAT ops from SDR-specific methods.
- **Multi-rig architecture**: Per-rig task isolation with `HashMap<rig_id, RigHandle>` routing, per-rig state/spectrum/audio/decoder-history channels, dual-connection model (main + spectrum) in the client, and backward-compatible single-rig mode.
- **Async concurrency model**: Proper use of tokio channels -- `watch` for state snapshots, `broadcast` for PCM/decode fan-out, `mpsc` for commands. No mutex contention on hot paths. Spectrum deduplication collapses concurrent GetSpectrum requests.
- **Comprehensive SDR support**: Full DSP pipeline with multi-mode demodulation (SSB, AM, SAM, FM, WFM, AIS, VDES), virtual channel management, squelch, noise blanker, spectrum FFT, RDS decoding. AVX2-optimized FM discriminator with scalar fallbacks.
- **Pure Rust decoders**: FT8/FT4/FT2, APRS, CW, WSPR, AIS, VDES, RDS -- all implemented without C FFI dependencies. Consistent decoder pattern: stateful struct → `process_block()` → `decode_if_ready()`.
- **Good test coverage** in protocol layer: codec, mapping, auth all have thorough unit tests with round-trip verification. 45+ mapping tests cover all command variants.
- **Feature-gated backends**: ft817, ft450d, soapysdr compiled conditionally to minimize binary size. Factory pattern with name normalization for registration.
- **Defensive error handling**: Lock poisoning recovery, stream error deduplication with 60s summaries, input truncation in logs (128 chars), per-IP rate limiting on auth endpoints.
- **Well-documented DSP guidelines**: `docs/Optimization-Guidelines.md` captures lessons on NCO design, polyphase resampling, AVX2 batching, and stereo FM decoding.
### Areas for Improvement
All P0P3 items resolved or dropped. See `docs/Improvement-Areas.md` for details.
+21 -1
View File
@@ -18,7 +18,7 @@ When contributing to the project, please follow these guidelines:
- Use a maximum of 80 characters per line.
- Use a blank line between the commit message and the body.
- Sign your commits with `git commit -s`.
- Explicitly mark LLM usage in commit messages with 'Co-authored-by:'.
- Disclose AI/LLM assistance with an `Assisted-By:` trailer (see below).
Use the format below for commit titles:
[<type>](<crate>): <description>
@@ -39,3 +39,23 @@ Allowed types:
- chore: build or maintenance changes
Write isolated commits for each crate.
## Attribution trailers
This project follows the Linux kernel convention for crediting work.
The `Co-Authored-By:` and `Co-Developed-By:` trailers name **people** who
authored the change. They are reserved for humans, and every person named
this way must also add their own `Signed-off-by:` line. Never use these
trailers for tools, assistants, or bots.
When a commit was produced with help from an AI assistant or LLM,
disclose it with an `Assisted-By:` trailer naming the tool (and model,
where relevant). The human committer remains the author of record and
takes responsibility for the change through `Signed-off-by:`.
Example:
Assisted-By: Claude Code (claude-opus-4)
Co-Authored-By: Jane Developer <jane@example.com>
Signed-off-by: Your Name <you@example.com>
Generated
+1105 -361
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,3 +1,3 @@
SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: BSD-2-Clause
SPDX-License-Identifier: GPL-2.0-or-later
+7 -3
View File
@@ -1,16 +1,19 @@
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: BSD-2-Clause
# SPDX-License-Identifier: GPL-2.0-or-later
[workspace]
members = [
"src/decoders/trx-ais",
"src/decoders/trx-wxsat",
"src/decoders/trx-aprs",
"src/decoders/trx-cw",
"src/decoders/trx-decode-log",
"src/decoders/trx-ft8",
"src/decoders/trx-ftx",
"src/decoders/trx-rds",
"src/decoders/trx-vdes",
"src/decoders/trx-sstv",
"src/decoders/trx-wefax",
"src/decoders/trx-wspr",
"src/trx-core",
"src/trx-protocol",
@@ -26,6 +29,7 @@ members = [
"src/trx-client/trx-frontend/trx-frontend-http",
"src/trx-client/trx-frontend/trx-frontend-http-json",
"src/trx-client/trx-frontend/trx-frontend-rigctl",
"src/trx-configurator",
]
resolver = "2"
+338
View File
@@ -0,0 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+434
View File
@@ -0,0 +1,434 @@
APRS symbol set (https://github.com/hessu/aprs-symbols)
=========================================================
Verbatim copy of the upstream COPYRIGHT.md, retrieved 2026-08-03 from
https://github.com/hessu/aprs-symbols. The set has no single SPDX license:
individual symbols carry different terms, summarized below. Attribution
requirement from the upstream README: "If you use this symbol set, please
provide a pointer to the source (http://github.com/hessu/aprs-symbols/)."
---
Copyright and licensing information
======================================
This is a collection of vectorized symbols for use on the APRS system.
The copyright status of this collection is a bit complicated, since the
symbols come from various sources, each having different copyright owners.
Most of the vectorized symbols are loosely based on the low-resolution
"standard" bitmap symbol set as distributed by Stephen Smith, WA8LMF. That
set is used by most APRS software around the world. The low resolution of
those symbols does not allow direct vector conversion, so I've drawn new
symbols in a similar layout. The vector versions try to mimic the original
appearance and colours, with the intention of keeping the set recognizable
and familiar to existing users. In some cases the vector versions are
probably similar enough to the originals, so that they cannot be considered
"original work" by myself. In some of these cases, the originals are
probably also mimicking someone else's design.
The original symbols do not come with any information on their licensing.
They've been distributed with a lot of APRS software over time, but I don't
know who designed which symbol originally. Most likely all of them are
drawn by one of:
* Roger Barker, G4IDE, "original set provided with UI-View" (SK)
* Steve Dimse, KH4G, "U.S. customary set"
* Stephen Smith, WA8LMF
The Adobe Illustrator (.ai) file contains a copy of the original bitmaps
as a hidden layer, just for reference.
Some symbols I obtained from other sources, such as Wikipedia. In those
cases I picked SVG versions which allow commercial reuse (source known, and
the work is placed on public domain, or with a CC license which allows
adaptation and commercial reuse).
Some symbols are vectorized versions of product or brand logos. The
copyright of those is owned by the respective companies (Apple, Microsoft,
Kenwood), and each of those may have some opinions on how the logos are
used. Please check for yourself if you can use them or not.
In the list below I try to summarize the licensing status for each symbol.
Shorthand notation for common licensing status
-------------------------------------------------
* *VEC-OH7LZB* - Vectorized by OH7LZB, based on original APRS symbol set
* Source of original bitmap: http://wa8lmf.net/aprs/APRS_symbols.htm
* Original designer of individual symbol unknown at this time, but one of:
* Roger Barker, G4IDE
* Steve Dimse, KH4G
* Stephen Smith, WA8LMF
* Vectorized versions are designed to look similar
* Licensing: Unknown
* *OH7LZB* - Original vector design by Heikki Hannikainen, OH7LZB
* Different enough (by author's opinion) to make it a new original work,
instead of a copy of the old symbol
* License: CC BY-SA 2.0
* https://creativecommons.org/licenses/by-sa/2.0/
Primary table
----------------
* /! - Police station
* VEC-OH7LZB
* /# - Digipeater / Green star with D in middle
* VEC-OH7LZB
* /$ - Telephone
* VEC-OH7LZB
* /% - DX cluster
* VEC-OH7LZB
* /& - HF gateway
* VEC-OH7LZB
* /' - Small aircraft
* https://openclipart.org/detail/27182/topdown-airplane-view
* Author: Wirelizard (Brian Burger)
* With color and some other small tuning added by OH7LZB
* PD: https://openclipart.org/share
* /( - Mobile satellite station
* OH7LZB
* /) - Wheelchair, handicapped
* PD wheelchair symbol
* Vectorized from bitmap by OH7LZB
* /* - Snowmobile
* https://openclipart.org/detail/15849/snowmobile
* Author: Mystica (https://openclipart.org/user-detail/mystica)
* PD: https://openclipart.org/share
* /+ - Red Cross
* VEC-OH7LZB
* /, - Boy Scouts
* VEC-OH7LZB
* /- - House
* VEC-OH7LZB
* /. - Red X
* VEC-OH7LZB
* // - Red dot
* VEC-OH7LZB
* /0 to /9 - Numbered circles
* VEC-OH7LZB
* Fire
* http://commons.wikimedia.org/wiki/File:FireIcon.svg
* Author: Piotr Jaworski
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
* Tent
* https://openclipart.org/detail/174933/green-tent-by-stamps-174933
* Author: stamps
* PD: https://openclipart.org/share
* Motorcycle
* http://commons.wikimedia.org/wiki/File:MUTCD_W8-15P.svg
* This file is in the public domain because it comes from the Manual on
Uniform Traffic Control Devices, sign number W8-15P, which states
specifically on page I-1 that: Any traffic control device design or
application provision contained in this Manual shall be considered to
be in the public domain. Traffic control devices contained in this
Manual shall not be protected by a patent, trademark, or copyright,
except for the Interstate Shield and any other items owned by FHWA.
* Colour version by OH7LZB
* /= - Railroad engine
* http://commons.wikimedia.org/wiki/File:Icon_train.svg
* Author: http://en.wikipedia.org/wiki/User:Richtom80
* CC-BY-SA-2.5,2.0,1.0
* /> - Car
* OH7LZB
* /? - File server
* https://openclipart.org/detail/163717/file-server-by-lyte
* Author: lyte
* PD: https://openclipart.org/share
* /@ - Hurricane predicted path
* VEC-OH7LZB
* /A - Aid station
* VEC-OH7LZB
* Mail (BBS)
* https://openclipart.org/detail/29268/yellow-mail-by-rg1024-29268
* Author: rg1024
* PD: https://openclipart.org/share
* /C - Canoe
* https://openclipart.org/detail/179047/red-canoe-by-rambo-tribble-179047
* https://openclipart.org/detail/179041/canoe-paddle-by-rambo-tribble-179041
* Author: Rambo Tribble
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
* /E - Eyeball
* http://commons.wikimedia.org/wiki/File:Blue_eye.svg
* PD: "This file is from the Open Clip Art Library, which released it explicitly into the public domain"
* PD: https://openclipart.org/share
* /F - Tractor
* https://openclipart.org/detail/191654/farm-tractor-by-tmjbeary-191654
* Author: tmjbeary
* PD: https://openclipart.org/share
* /G - Grid square, 3 by 3
* VEC-OH7LZB
* /H - Hotel
* VEC-OH7LZB
* /I - TCP/IP
* VEC-OH7LZB
* /K - School
* OH7LZB
* /L - PC user
* OH7LZB
* /M - Mac apple
* Apple
* /N - NTS
* VEC-OH7LZB
* /O - Hot air balloon
* OH7LZB
* /P - Police
* OH7LZB
* /R - RV
* OH7LZB
* /S - Space Shuttle
* https://openclipart.org/detail/814/space-shuttle-by-johnny_automatic
* PD: Published by the NASA, in "The Brain in Space"
* /T - SSTV
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
* Author: rg1024
* Adjusted by OH7LZB
* PD: https://openclipart.org/share
* /U - Bus
* OH7LZB
* /V - ATV, amateur television
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
* Author: rg1024
* Adjusted by OH7LZB
* PD: https://openclipart.org/share
* /W - Wx, Weather service site
* VEC-OH7LZB
* /X - Helicopter
* OH7LZB
* /Y - Sailboat
* OH7LZB
* /Z - Windows flag
* Microsoft
* /[ - Human
* VEC-OH7LZB
* /\ - DF triangle
* VEC-OH7LZB
* /] - Mailbox, post office, letter
* /^ - Large aircraft
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
* Author: SketchArtist
* PD: https://openclipart.org/share
* /_ - Weather station
* VEC-OH7LZB
* /` - Satellite dish
* OH7LZB
* /a - Ambulance
* OH7LZB
* /b - Bicycle
* http://commons.wikimedia.org/wiki/File:Bicycle_evolution-numbers.svg
* Author: Wikipedia user: Al2
* CC BY 3.0
* /c - Incident command post
* VEC-OH7LZB
* /d - Fire station
* VEC-OH7LZB
* /e - Horse, equestrian
* https://openclipart.org/detail/142627/horse-riding-lesson-by-olku
* Author: OlKu
* PD: https://openclipart.org/share
* /f - Fire truck
* OH7LZB
* /g - Hang glider
* OH7LZB
* /h - Hospital
* VEC-OH7LZB
* /i - IOTA, islands on the air
* http://commons.wikimedia.org/wiki/File:Palm_Island_R.svg
* PI
* /j - Jeep
* OH7LZB
* /k - Truck
* OH7LZB
* /l - Laptop
* OH7LZB
* /m - Mic-E repeater
* VEC-OH7LZB
* /n - Node, black bulls-eye
* VEC-OH7LZB
* /o - Emergency operations center
* VEC-OH7LZB
* /p - Dog(e)
* OH7LZB
* /q - Grid square, 2 by 2
* VEC-OH7LZB
* /r - Repeater tower
* OH7LZB
* /s - Ship, power boat
* OH7LZB
* /t - Truck stop
* VEC-OH7LZB
* /u - Semi-trailer truck, 18-wheeler
* OH7LZB
* /v - Van
* OH7LZB
* /w - Water station
* VEC-OH7LZB
* /x - X / Unix
* https://commons.wikimedia.org/wiki/File:X11.svg
* PD
* /y - House, yagi antenna
* VEC-OH7LZB
* /z - Shelter
* VEC-OH7LZB
Secondary table
------------------
* Emergency
* VEC-OH7LZB
* Numbered digipeater / Green star
* VEC-OH7LZB
* Bank
* VEC-OH7LZB
* Numbered gateway / Black diamond
* VEC-OH7LZB
* Crash site
* OH7LZB
* Cloudy
* OH7LZB
* MEO
* VEC-OH7LZB
* Snowflake
* http://commons.wikimedia.org/wiki/File:Snowflake_01.svg
* Author: Wikipedia user: Amada44
* Public Domain
* Church
* VEC-OH7LZB
* Girl Scout
* VEC-OH7LZB
* Looks slightly like the common USA girl scouts logos. Should be different
enough to not infringe on "Girl Scouts of the USA" copyrights.
* Home (HF antenna)
* VEC-OH7LZB
* Unknown position
* VEC-OH7LZB
* Destination
* VEC-OH7LZB
* Numbered circle
* VEC-OH7LZB
* Petrol Station
* OH7LZB
* Hail
* VEC-OH7LZB
* Park
* VEC-OH7LZB
* Gale Flag
* VEC-OH7LZB
* Red car from above
* OH7LZB
* Info Kiosk
* VEC-OH7LZB
* Hurricane
* OH7LZB
* Numbered white box
* VEC-OH7LZB
* Snow blowing
* VEC-OH7LZB
* Coast Guard
* VEC-OH7LZB
* Drizzle
* VEC-OH7LZB
* Smoke / Chimney
* VEC-OH7LZB
* Freezing rain
* VEC-OH7LZB
* Snow Shwr
* VEC-OH7LZB
* Haze
* VEC-OH7LZB
* Rain Shower
* VEC-OH7LZB
* Lightning
* OH7LZB
* "Kenwood radio"
* Kenwood logo, vectorized
* "Lighthouse"
* CC BY-SA 2.0
* http://wiki.openstreetmap.org/wiki/File:Lighthouse.svg
* Nav Buoy
* OH7LZB
* Rocket
* http://www.clker.com/clipart-gglkuglug.html
* PD according to clker.com license
* Parking
* VEC-OH7LZB
* Earthquake, Restaurant
* VEC-OH7LZB
* Satellite
* OH7LZB
* Thunderstorm
* OH7LZB
* Sunny
* OH7LZB
* VORTAC, Numbered WXS
* VEC-OH7LZB
* Pharmacy Rx
* OH7LZB
* Wall Cloud
* OH7LZB
* Numbered plane
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
* Author: SketchArtist
* PD: https://openclipart.org/share
* Numbered WX Station
* VEC-OH7LZB
* Rain
* Source: http://commons.wikimedia.org/wiki/File:Heavy-rain-shower-transparent.svg
* Author: Wikipedia user: Peepo
* Public Domain
* With modifications by OH7LZB
* Numbered diamond
* VEC-OH7LZB
* Dust blowing
* NA
* Numbered civil defence
* VEC-OH7LZB
* DX spot
* VEC-OH7LZB
* Sleet
* NA
* Funnel Cloud
* NA
* Gale
* VEC-OH7LZB
* Store
* https://openclipart.org/detail/89299/cart-medium-by-martins.bruvelis
* Author: martins.bruvelis
* Public Domain
* Adjustments by OH7LZB
* Numbered black box
* VEC-OH7LZB
* Work zone / Excavator
* Based on http://www.clker.com/clipart-292480.html PNG version
* Vectorized and colors adjusted by OH7LZB
* PD according to clker.com documentation, uploader KURSVEIAL
* SUV
* OH7LZB
* Milepost, Numbered triangle, Circle sm
* VEC-OH7LZB
* Partly cloudy
* OH7LZB
* Restrooms, Numbered boat
* VEC-OH7LZB
* Tornado (also used in Funnel cloud, Skywarn)
* https://openclipart.org/detail/104887/tornado-by-laabadon
* Author: Laabadon
* Public Domain
* Numbered truck
* OH7LZB
* Numbered van
* OH7LZB
* Flooding
* NA
* Sky warn, Numbered shelter, fog
* VEC-OH7LZB
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018 Kārlis Goba
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+43
View File
@@ -0,0 +1,43 @@
SIL OPEN FONT LICENSE
Version 1.1 - 26 February 2007
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
+103 -141
View File
@@ -1,188 +1,150 @@
<div align="center">
<img src="assets/trx-logo.png" alt="trx-rs logo" width="25%" />
</div>
# trx-rs
`trx-rs` is a modular amateur radio control stack written in Rust.
It splits radio hardware access from user-facing interfaces so you can run
A modular amateur radio control stack written in Rust.
[![License](https://img.shields.io/badge/license-GPL--2.0--or--later-blue.svg)](LICENSES)
</div>
`trx-rs` splits radio hardware access from user-facing interfaces so you can run
rig control, SDR DSP, decoding, audio streaming, and web access as separate,
composable pieces.
The project is built around two primary binaries:
- `trx-server`: talks to radios and SDR backends
- `trx-client`: connects to the server and exposes frontends such as the web UI
## Web UI Demo
> GIF placeholder: add an animated walkthrough of the website here.
## What It Does
- Controls supported radios over networked client/server boundaries
- Exposes a browser UI, a rigctl-compatible frontend, and JSON-based control
- Supports SDR workflows with live spectrum, waterfall, demodulation, and decode
- Streams Opus audio between server, client, and browser
- Runs multiple decoders including AIS, APRS, CW, FT8, RDS, VDES, and WSPR
- Supports multi-rig deployments and SDR virtual channels
- Loads backends and frontends via plugins
## Architecture
At a high level:
1. `trx-server` owns the radio hardware and DSP pipeline.
2. `trx-client` connects to the server over TCP for control and audio.
3. Frontends hang off `trx-client`, including the HTTP web UI.
This separation is intentional: it keeps hardware access local to one host while
making control and monitoring available elsewhere on the network.
## Workspace Layout
- `src/trx-core`: shared types, rig state, controller logic
- `src/trx-protocol`: client/server protocol types and codecs
- `src/trx-app`: shared app bootstrapping, config, logging, plugins
- `src/trx-server`: server binary and backend integration
- `src/trx-client`: client binary and remote connection handling
- `src/trx-client/trx-frontend`: frontend abstraction
- `src/decoders`: protocol-specific decoder crates
- `examples/trx-plugin-example`: minimal plugin example
## Supported Pieces
### Backends
- Yaesu FT-817
- Yaesu FT-450D
- SoapySDR-based SDR backend
### Frontends
- HTTP web frontend
- rigctl-compatible TCP frontend
- JSON-over-TCP frontend
### Decoders
- AIS
- APRS
- CW
- FT8
- RDS
- VDES
- WSPR
## Build Requirements
You will need Rust plus a few system libraries.
### Common dependencies
- `libopus`
- `pkg-config` or `pkgconf`
- `cmake`
### SDR builds
- `libsoapysdr`
### Audio builds
- Core Audio on macOS, or ALSA development packages on Linux
## Configuration
Both `trx-server` and `trx-client` read from a shared `trx-rs.toml`.
- Default lookup order: current directory, `~/.config/trx-rs`, `/etc/trx-rs`
- Use `--config <FILE>` to point at an explicit config file
- Use `--print-config` to print an example combined config
Start from [`trx-rs.toml.example`](trx-rs.toml.example).
| | |
|---|---|
| **Backends** | Yaesu FT-817, Yaesu FT-450D, SoapySDR |
| **Frontends** | Web UI, rigctl-compatible TCP, JSON-over-TCP |
| **Decoders** | AIS, APRS, CW, FT8, RDS, VDES, WSPR |
| **Audio** | Opus streaming between server, client, and browser |
## Quick Start
### 1. Build
### 1. Install dependencies
<details>
<summary><b>Debian / Ubuntu</b></summary>
```bash
cargo build
sudo apt install build-essential pkg-config cmake libopus-dev libasound2-dev
# Optional — SDR support
sudo apt install libsoapysdr-dev
```
</details>
### 2. Create a config file
<details>
<summary><b>Fedora</b></summary>
```bash
cp trx-rs.toml.example trx-rs.toml
sudo dnf install gcc pkg-config cmake opus-devel alsa-lib-devel
# Optional — SDR support
sudo dnf install SoapySDR-devel
```
</details>
Adjust backend, frontend, audio, and auth settings for your environment.
### 3. Run the server
<details>
<summary><b>Arch Linux</b></summary>
```bash
cargo run -p trx-server
sudo pacman -S base-devel pkgconf cmake opus alsa-lib
# Optional — SDR support
sudo pacman -S soapysdr
```
</details>
### 4. Run the client
<details>
<summary><b>macOS (Homebrew)</b></summary>
```bash
cargo run -p trx-client
brew install cmake opus
# Optional — SDR support
brew install soapysdr
```
</details>
See [Build Requirements](https://git.haxx.space/sjg/trx-rs/wiki/User-Manual#build-requirements)
in the wiki for details on each library.
> **Note:** `cmake` is required even when a system Opus library is installed.
> The `audiopus_sys` crate probes for Opus via `pkg-config`; if it is not found
> (or `pkg-config` is unavailable), it falls back to compiling a vendored copy
> of Opus with CMake. A missing `cmake` therefore fails the build with
> `is cmake not installed?` rather than a missing-Opus error.
### 2. Build
```bash
cargo build --release
```
### 5. Open the web UI
Build without SDR support: `cargo build --release --no-default-features`
Open the configured HTTP frontend address in a browser.
### 3. Configure
## Web Frontend Highlights
Run the interactive setup wizard to generate config files for your station:
- Real-time spectrum and waterfall
- Frequency, mode, and bandwidth control
- Decoder dashboards and history
- SDR virtual channels
- Browser RX/TX audio
- Optional authentication with read-only and control roles
```bash
./target/release/trx-configurator
```
## Authentication
The wizard walks you through rig selection, serial port detection, audio
settings, and frontend options, then writes `trx-server.toml` and
`trx-client.toml`.
The HTTP frontend supports optional passphrase-based authentication.
Alternatively, generate example configs and edit them by hand:
- `rx`: read-only access
- `control`: full control access
```bash
./target/release/trx-server --print-config > trx-server.toml
./target/release/trx-client --print-config > trx-client.toml
```
When exposing the web UI beyond a trusted LAN, run it behind HTTPS and enable
secure cookie settings in the config.
### 4. Run
## Audio
```bash
./target/release/trx-server --config trx-server.toml
./target/release/trx-client --config trx-client.toml
```
Audio is transported as Opus between server, client, and browser.
Open the configured HTTP frontend address in a browser (default `http://localhost:8080`).
- `trx-server` captures and encodes audio
- `trx-client` relays audio to the HTTP frontend
- Browsers connect over `/audio`
## How It Works
## Plugins
```mermaid
graph TD
SDR1["SDR #1"] & SDR2["SDR #2"] <-->|USB| S1["trx-server A"]
SDR3["SDR #3"] & FT817["FT-817"] <-->|USB / serial| S2["trx-server B"]
Both binaries can discover shared-library plugins through:
S1 <-->|"JSON-TCP :4530"| C1["trx-client"]
S1 -->|"Opus-TCP per rig"| C1
S2 <-->|"JSON-TCP :4530"| C1
S2 -->|"Opus-TCP per rig"| C1
- `./plugins`
- `~/.config/trx-rs/plugins`
- `TRX_PLUGIN_DIRS`
C1 <-->|internal channels| F1["Web UI :8080"]
C1 <-->|internal channels| F2["rigctl :4532"]
```
See [`examples/trx-plugin-example/README.md`](examples/trx-plugin-example/README.md).
Each `trx-server` owns one or more rigs and runs DSP, decoding, and audio capture locally.
A `trx-client` connects to any number of servers over TCP and exposes them through
a unified set of frontends.
## Documentation
- [`OVERVIEW.md`](OVERVIEW.md): architecture and design overview
- [`CONTRIBUTING.md`](CONTRIBUTING.md): contribution and commit rules
## Project Status
This is an active project with evolving APIs and frontend behavior. Expect some
rough edges and ongoing refactors.
| Resource | Description |
|----------|-------------|
| [User Manual](https://git.haxx.space/sjg/trx-rs/wiki/User-Manual) | Configuration, features, and usage |
| [Architecture](https://git.haxx.space/sjg/trx-rs/wiki/Architecture) | System design, crate layout, data flow, and internals |
| [Optimization Guidelines](https://git.haxx.space/sjg/trx-rs/wiki/Optimization-Guidelines) | Performance guidelines for the real-time DSP pipeline |
| [Planned Features](https://git.haxx.space/sjg/trx-rs/wiki/Planned-Features) | Roadmap and design notes |
| [Contributing](CONTRIBUTING.md) | Commit conventions, workflow, and code style |
## License
Licensed under BSD-2-Clause.
See [`LICENSES`](LICENSES) for bundled third-party license files.
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
bundled third-party license files. Bundled third-party components retain their
original licenses: Leaflet is BSD-2-Clause, DSEG is OFL-1.1, and opus-decoder
is MIT. The APRS symbol sprites come from
[hessu/aprs-symbols](https://github.com/hessu/aprs-symbols); their per-symbol
copyright status is catalogued in
[`LICENSES/LicenseRef-APRS-Symbols.txt`](LICENSES/LicenseRef-APRS-Symbols.txt).
+74
View File
@@ -0,0 +1,74 @@
version = 1
# Project-owned files without an in-file SPDX header (docs, config,
# repo metadata, logos, and bespoke web assets).
[[annotations]]
path = [
".gitattributes",
".gitignore",
"CLAUDE.md",
"CONTRIBUTING.md",
"README.md",
"trx-rs.toml.example",
"docs/**",
"aidocs/**",
"container/**",
".devcontainer/**",
"src/decoders/trx-ftx/README.md",
"src/decoders/trx-wxsat/README.md",
"assets/trx-logo.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-favicon.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-logo.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/bandplan.json",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/**",
"src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json",
"src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json",
"src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.json",
"src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.worker.json",
]
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
SPDX-License-Identifier = "GPL-2.0-or-later"
# Vendored Leaflet 1.9.4 (https://leafletjs.com), distributed under BSD-2-Clause.
[[annotations]]
path = [
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/leaflet.js",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/leaflet.css",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/layers.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/layers-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-icon.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-icon-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-shadow.png",
]
SPDX-FileCopyrightText = "2010-2023 Vladimir Agafonkin, 2010-2011 CloudMade"
SPDX-License-Identifier = "BSD-2-Clause"
# Vendored DSEG14 font (https://github.com/keshikan/DSEG), SIL OFL 1.1.
[[annotations]]
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/dseg14-classic-latin-400-normal.woff2"]
SPDX-FileCopyrightText = "2020 The DSEG Authors (https://github.com/keshikan/DSEG)"
SPDX-License-Identifier = "OFL-1.1"
# Vendored opus-decoder 0.7.11 browser build
# (https://github.com/eshaz/wasm-audio-decoders), MIT.
# SHA-256: fd73ee0a9c8a5e233c0b88234df14f46003ad89bb4ba27435bc8714db2a6dc62
[[annotations]]
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/opus-decoder-0.7.11.min.js"]
SPDX-FileCopyrightText = "2021-2025 Ethan Halsall"
SPDX-License-Identifier = "MIT"
# Vendored APRS symbol sprites (https://github.com/hessu/aprs-symbols), rev H.
# The set has no single upstream license -- individual symbols carry different
# terms, catalogued in LICENSES/LicenseRef-APRS-Symbols.txt. Upstream asks that
# users point back to https://github.com/hessu/aprs-symbols/.
[[annotations]]
path = [
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-0.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-1.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-2.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-0-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-1-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-2-2x.png",
]
SPDX-FileCopyrightText = "Heikki Hannikainen OH7LZB and the APRS symbol set authors (https://github.com/hessu/aprs-symbols)"
SPDX-License-Identifier = "LicenseRef-APRS-Symbols"
-119
View File
@@ -1,119 +0,0 @@
# Background Decoding Scheduler
## Overview
The Background Decoding Scheduler automatically retunes the rig to pre-configured
bookmarks when no users are connected to the HTTP frontend. It runs as a background
tokio task inside `trx-frontend-http`, polling every 30 seconds.
## Modes
### Disabled (default)
Scheduler is inactive. Rig is not touched automatically.
### Grayline
Retunes around the solar terminator (day/night boundary).
The user provides:
- Station latitude and longitude (decimal degrees)
- Optional transition window width (minutes, default 20)
- Bookmark IDs for four periods:
- **Dawn** window around sunrise (`sunrise ± window_min/2`)
- **Day** after dawn until dusk
- **Dusk** window around sunset (`sunset ± window_min/2`)
- **Night** after dusk until next dawn
Period precedence (most specific wins): Dawn > Dusk > Day > Night.
If no bookmark is assigned to a period, the rig is not retuned for that period.
Sunrise/sunset is computed inline using the NOAA simplified algorithm.
Polar regions (midnight sun / polar night) fall back to Day/Night accordingly.
### TimeSpan
Retunes according to a list of user-defined time windows (UTC).
Each entry specifies:
- `start_hhmm` start of window (e.g. 600 = 06:00 UTC)
- `end_hhmm` end of window (e.g. 700 = 07:00 UTC)
- `bookmark_id` bookmark to apply
- `label` optional human-readable description
Windows that span midnight (`end_hhmm < start_hhmm`) are supported.
When multiple entries overlap, the first match (by list order) wins.
## Storage
Configuration is stored in PickleDB at `~/.config/trx-rs/scheduler.db`.
Keys: `sch:{rig_id}` → JSON `SchedulerConfig`.
## HTTP API
All read endpoints are accessible at the **Rx** role level.
Write endpoints require the **Control** role.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/scheduler/{rig_id}` | Get scheduler config for a rig |
| PUT | `/scheduler/{rig_id}` | Save scheduler config (Control only) |
| DELETE | `/scheduler/{rig_id}` | Reset config to Disabled (Control only) |
| GET | `/scheduler/{rig_id}/status` | Get last-applied bookmark and next event |
## Activation logic
Every 30 seconds the scheduler task checks:
1. `context.sse_clients.load() == 0` — no users connected
2. Active rig has a non-Disabled scheduler config
3. Current UTC time matches a scheduled window or grayline period
4. If the matching bookmark differs from `last_applied`, send `SetFreq` + `SetMode`
The scheduler **does not** revert changes when users reconnect. Bookmarks serve as
a frequency map — the user can retune manually after connecting.
## Data model (Rust)
```rust
pub enum SchedulerMode { Disabled, Grayline, TimeSpan }
pub struct GraylineConfig {
pub lat: f64,
pub lon: f64,
pub transition_window_min: u32,
pub day_bookmark_id: Option<String>,
pub night_bookmark_id: Option<String>,
pub dawn_bookmark_id: Option<String>,
pub dusk_bookmark_id: Option<String>,
}
pub struct ScheduleEntry {
pub id: String,
pub start_hhmm: u32,
pub end_hhmm: u32,
pub bookmark_id: String,
pub label: Option<String>,
}
pub struct SchedulerConfig {
pub rig_id: String,
pub mode: SchedulerMode,
pub grayline: Option<GraylineConfig>,
pub entries: Vec<ScheduleEntry>,
}
```
## UI (Scheduler tab)
A dedicated sixth tab with a clock icon.
- **Rig selector**: shows active rig (read-only).
- **Mode picker**: Disabled / Grayline / TimeSpan radio buttons.
- **Grayline section** (visible when mode = Grayline):
- Lat/lon inputs
- Transition window slider (560 min)
- Four bookmark selectors (Dawn / Day / Dusk / Night)
- **TimeSpan section** (visible when mode = TimeSpan):
- Table of entries with Start, End, Bookmark, Label, Remove button
- "Add Entry" row at the bottom
- **Status card**: last applied bookmark name and timestamp.
- Save button (Control only; form is read-only for Rx users).
-43
View File
@@ -1,43 +0,0 @@
# Repository Guidelines
## Project Structure & Module Organization
- Workspace root contains `Cargo.toml`, `README.md`, and contributor docs.
- Core crates live under `src/`: `src/trx-core`, `src/trx-server`, and `src/trx-client`.
- Server backends are under `src/trx-server/trx-backend` (example: `trx-backend-ft817`).
- Client frontends are under `src/trx-client/trx-frontend` (HTTP, JSON, rigctl).
- Examples live in `examples/` and static assets in `assets/`.
- Reference configs are `trx-server.toml.example` and `trx-client.toml.example`.
## Build, Test, and Development Commands
- `cargo build --release` builds optimized binaries.
- `cargo test` runs the workspace test suite.
- `cargo clippy` runs lint checks.
- Example server run (release build): `./target/release/trx-server -r ft817 "/dev/ttyUSB0 9600"`.
## Coding Style & Naming Conventions
- Rust standard style: 4-space indentation and rustfmt-compatible formatting.
- Naming: `snake_case` for modules/functions, `CamelCase` for types/traits, `SCREAMING_SNAKE_CASE` for constants.
- Prefer small, crate-focused commits; keep changes localized to the relevant crate.
## Testing Guidelines
- Tests are run via `cargo test` across the workspace.
- Add tests near the code they cover (module-level unit tests are preferred).
- If you change behavior in a crate, add or update tests in that crate.
## Commit & Pull Request Guidelines
- Commit title format: `[<type>](<crate>): <description>` (example: `[fix](trx-frontend-http): handle disconnect`).
- Use `(trx-rs)` for repo-wide changes that are not specific to any crate.
- Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`.
- Use imperative mood, keep lines under 80 chars, and separate body with a blank line.
- Sign commits with `git commit -s` and include `Co-authored-by:` for LLM assistance.
- Write isolated commits for each crate.
- Pull requests should include a clear summary, test status, and note any config or runtime changes.
## Contribution Workflow
- Fork the repository and create a new branch for your changes.
- Follow the project's coding style and conventions.
- Ensure changes are tested and pass existing tests.
## Configuration & Plugins
- Configs use TOML. See the example files for required sections and defaults.
- Plugins can be loaded from `./plugins`, `~/.config/trx-rs/plugins`, or `TRX_PLUGIN_DIRS`.
-190
View File
@@ -1,190 +0,0 @@
# HTTP Frontend Authentication Draft
## Goal
Add optional passphrase authentication for `trx-frontend-http` with two roles:
- `rx` passphrase: read-only access
- `control` passphrase: read + control (RX+TX)
API/control routes stay locked until a user logs in from the web UI.
This design keeps current behavior when auth is disabled.
## Scope
- Protect HTTP API endpoints used by the web UI.
- Protect SSE (`/events`, `/decode`) and audio WebSocket (`/audio`).
- Keep static assets and login page accessible so user can authenticate.
- Do not change rigctl/http_json auth behavior in this draft.
## Security Model
- Two optional passphrases configured locally (`rx`, `control`).
- On successful login, server issues short-lived session cookie.
- Session required for all protected routes, with role attached.
- Brute-force mitigation via simple per-IP rate limiting.
- TX access can be globally hidden/blocked unless `control` role is present.
This is not multi-user IAM; it is a pragmatic local/ham-shack gate.
## Config Proposal
Add to `trx-client.toml`:
```toml
[frontends.http.auth]
enabled = false
# Plaintext passphrases (as requested)
rx_passphrase = "rx-only-passphrase"
control_passphrase = "full-control-passphrase"
# If true, TX/PTT controls/endpoints are never available without control auth.
tx_access_control_enabled = true
# Session lifetime in minutes
session_ttl_min = 480
# Cookie security
cookie_secure = false # true if served via HTTPS
cookie_same_site = "Lax" # Strict|Lax|None
```
Validation rules:
- If `enabled=false`, all auth fields ignored.
- If `enabled=true`, require at least one passphrase (`rx` and/or `control`).
- `rx_passphrase` only: read-only deployment.
- `control_passphrase` only: control-capable deployment.
- both set: mixed deployment with role split.
Behavior by mode:
- `enabled=false` (default): no authentication, current behavior unchanged.
- `enabled=true`: authentication enforced per role/route rules in this document.
## Runtime Structures
Add in `src/trx-client/trx-frontend/src/lib.rs` (or HTTP crate-local state):
- `HttpAuthConfig`:
- `enabled: bool`
- `rx_passphrase: Option<String>`
- `control_passphrase: Option<String>`
- `tx_access_control_enabled: bool`
- `session_ttl: Duration`
- `cookie_secure: bool`
- `same_site: SameSite`
- `SessionStore` in-memory map:
- key: random session id (128-bit+)
- value: `{ role, issued_at, expires_at, last_seen, ip_hash? }`
Role enum:
- `AuthRole::Rx`
- `AuthRole::Control`
Periodic cleanup task (e.g., every 5 min) removes expired sessions.
## Route Design
New endpoints:
- `POST /auth/login`
- body: `{ "passphrase": "..." }`
- server checks passphrase against `control` first, then `rx`
- on success: set `HttpOnly` cookie `trx_http_sid`, return `{ role: "rx"|"control" }`
- on failure: 401 generic error
- `POST /auth/logout`
- clears cookie and invalidates server session
- `GET /auth/session`
- returns `{ authenticated: true|false, role?: "rx"|"control" }`
Protected existing endpoints:
- Control APIs (`control` role required): `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, `/toggle_vfo`, `/lock`, `/unlock`, `/set_tx_limit`, `/toggle_*_decode`, `/clear_*_decode`, CW tuning endpoints, etc.
- Read APIs (`rx` or `control`): `/status`, `/events`, `/decode`, `/audio`
TX/PTT hard-gate behavior when `tx_access_control_enabled=true`:
- Do not render TX/PTT controls for unauthenticated or `rx` role.
- Reject TX/PTT and mutating control endpoints unless role is `control`.
- Prefer returning `404` for hidden TX/PTT endpoints to avoid capability leakage
(or `403` if explicit error semantics are preferred).
Public endpoints:
- `/` (HTML shell)
- static assets (`/style.css`, `/app.js`, plugin js, logo, favicon)
- `/auth/*`
## Middleware Behavior
Implement Actix middleware/wrap fn in `trx-frontend-http`:
- Resolve session from cookie.
- Validate in store and expiry.
- If missing/invalid:
- API routes: return `401` JSON/text
- SSE/WS routes: return `401`
- If valid:
- enforce route role (`rx` or `control`)
- return `403` when authenticated but role is insufficient
- continue request
- optionally slide expiry (`last_seen + ttl`) with cap.
Keep middleware route-aware by checking request path against allowlist.
## Passphrase Handling
- Use exact passphrase comparison against config values (no hash layer in this draft).
- Still use constant-time string comparison helper to reduce timing leakage.
- Keep passphrases out of logs and API responses.
## Cookie Settings
Session cookie:
- `HttpOnly=true`
- `Secure` configurable (true for TLS)
- `SameSite=Lax` default
- `Path=/`
- Max-Age = session TTL
## Frontend Flow
In `assets/web/app.js`:
1. On startup call `/auth/session`.
2. If unauthenticated, show blocking screen with logo + `Access denied`.
3. Submit to `/auth/login`.
4. On success initialize normal app flow (`connect()`, decode stream).
5. If role is `rx`, disable/hide all TX/PTT/mutating controls.
6. If role is `control`, enable full UI.
7. If protected call returns 401/403, stop streams and return to login panel.
8. Add logout button in About tab or header.
UI minimal requirement:
- Default unauthenticated view: logo + `Access denied` + passphrase field + login button.
- Generic error message on failure.
- No passphrase persistence in localStorage.
## Implementation Steps
1. Extend client config structs + parser defaults.
2. Build auth state (passphrases + session store) in HTTP server startup.
3. Add `/auth/login`, `/auth/logout`, `/auth/session` handlers.
4. Add middleware and protect selected routes.
5. Update frontend JS with login gate and 401 handling.
6. Add docs to `README.md` + `trx-client.toml.example`.
7. Add role matrix tests and frontend role UI handling.
## Test Plan
Unit tests:
- Config validation combinations.
- Login success/failure.
- Session expiry.
- Middleware path allowlist/protection.
- Role enforcement (`rx` denied on control routes).
- TX visibility policy (`tx_access_control_enabled`) endpoint behavior.
Integration tests (Actix test server):
- Unauthed call to `/set_freq` -> 401.
- `rx` login -> cookie set -> `/status` accepted, `/set_freq` -> 403.
- `control` login -> `/set_freq` accepted.
- With `tx_access_control_enabled=true`, unauth/`rx` cannot use `/set_ptt`.
- Expired session -> 401.
- `/events` and `/audio` reject unauthenticated clients.
Manual checks:
- Browser login works.
- WSJT-X/hamlib unaffected (non-http frontends).
- Auth disabled mode behaves exactly as before.
## Operational Notes
- This is in-memory session state. Restart invalidates sessions.
- For reverse proxy deployments, use TLS and set `cookie_secure=true`.
- If remote exposure is possible, use strong passphrase and firewall.
## Future Extensions
- Optional API bearer token for automation scripts.
- Optional migration to hashed passphrases if threat model increases.
- Persistent sessions with signed tokens/JWT (if needed).
- Optional TOTP second factor for internet-exposed deployments.
-231
View File
@@ -1,231 +0,0 @@
# Configuration
This document lists all currently supported configuration options for `trx-server` and `trx-client`.
## File Locations
### `trx-server`
Configuration lookup order:
1. `--config <FILE>`
2. `./trx-server.toml`
3. `~/.trx-server.toml`
4. `~/.config/trx-rs/server.toml`
5. `/etc/trx-rs/server.toml`
### `trx-client`
Configuration lookup order:
1. `--config <FILE>`
2. `./trx-client.toml`
3. `~/.config/trx-rs/client.toml`
4. `/etc/trx-rs/client.toml`
CLI options override file values.
## Environment Variables
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by both server and client.
## `trx-server` Options
### `[general]`
- `callsign` (`string`, default: `"N0CALL"`)
- `log_level` (`string`, optional): one of `trace|debug|info|warn|error`
- `latitude` (`float`, optional): `-90..=90`
- `longitude` (`float`, optional): `-180..=180`
Notes:
- `latitude` and `longitude` must be set together or both omitted.
### `[rig]`
- `model` (`string`, required effectively unless provided by CLI `--rig`)
- `initial_freq_hz` (`u64`, default: `144300000`, must be `> 0`)
- `initial_mode` (`string`, default: `"USB"`): one of `LSB|USB|CW|CWR|AM|WFM|FM|DIG|PKT`
### `[rig.access]`
- `type` (`string`, default behavior: `serial` if omitted): `serial|tcp|sdr`
- Serial mode:
- `port` (`string`)
- `baud` (`u32`)
- TCP mode:
- `host` (`string`)
- `tcp_port` (`u16`)
- SDR mode:
- `args` (`string`, required when `type = "sdr"`): SoapySDR device args string (e.g. `"driver=rtlsdr"` or `"driver=airspy,serial=00000001"`). Passed verbatim to `SoapySDR::Device::new()`.
Notes:
- For `serial`, both `port` and `baud` are required.
- For `tcp`, both `host` and `tcp_port` are required.
- For `sdr`, `args` must be non-empty. The `port`, `baud`, `host`, and `tcp_port` fields are ignored.
### `[behavior]`
- `poll_interval_ms` (`u64`, default: `500`, must be `> 0`)
- `poll_interval_tx_ms` (`u64`, default: `100`, must be `> 0`)
- `max_retries` (`u32`, default: `3`, must be `> 0`)
- `retry_base_delay_ms` (`u64`, default: `100`, must be `> 0`)
### `[listen]`
- `enabled` (`bool`, default: `true`)
- `listen` (`ip`, default: `127.0.0.1`)
- `port` (`u16`, default: `4530`, must be `> 0` when enabled)
### `[listen.auth]`
- `tokens` (`string[]`, default: `[]`)
Notes:
- Empty token strings are invalid.
- Empty list means no auth required.
### `[audio]`
- `enabled` (`bool`, default: `true`)
- `listen` (`ip`, default: `127.0.0.1`)
- `port` (`u16`, default: `4531`, must be `> 0` when enabled)
- `rx_enabled` (`bool`, default: `true`)
- `tx_enabled` (`bool`, default: `true`)
- `device` (`string`, optional)
- `sample_rate` (`u32`, default: `48000`, valid: `8000..=192000`)
- `channels` (`u8`, default: `1`, valid: `1|2`)
- `frame_duration_ms` (`u16`, default: `20`, valid: `3|5|10|20|40|60`)
- `bitrate_bps` (`u32`, default: `24000`, must be `> 0`)
Notes:
- When `[audio].enabled = true`, at least one of `rx_enabled` or `tx_enabled` must be true.
### `[pskreporter]`
- `enabled` (`bool`, default: `false`)
- `host` (`string`, default: `"report.pskreporter.info"`, must not be empty when enabled)
- `port` (`u16`, default: `4739`, must be `> 0` when enabled)
- `receiver_locator` (`string`, optional)
Notes:
- If `receiver_locator` is omitted, server tries deriving it from `[general].latitude`/`longitude`.
- PSK Reporter software ID is hardcoded to: `trx-server v<version> by SP2SJG`.
### `[aprsfi]`
- `enabled` (`bool`, default: `false`)
- `host` (`string`, default: `"rotate.aprs.net"`, must not be empty when enabled)
- `port` (`u16`, default: `14580`, must be `> 0` when enabled)
- `passcode` (`i32`, default: `-1`)
Notes:
- When `passcode = -1` (the default), the passcode is auto-computed from `[general].callsign` using the standard APRS-IS hash algorithm.
- `[general].callsign` must be non-empty when `[aprsfi].enabled = true`; otherwise the IGate is silently disabled at startup.
- Only APRS packets with valid CRC are forwarded; packets from other decoders (FT8, WSPR, CW) are ignored.
- The IGate reconnects automatically with exponential backoff (1 s → 2 s → … → 60 s) on TCP errors.
- Requires `[audio].enabled = true` (APRS packets are decoded from audio).
### `[sdr]`
- `sample_rate` (`u32`, default: `1920000`, must be `> 0`): IQ capture rate in Hz. Must be supported by the device.
- `bandwidth` (`u32`, default: `1500000`): Hardware IF filter bandwidth in Hz.
- `center_offset_hz` (`i64`, default: `100000`): The SDR tunes this many Hz below the dial frequency to keep the signal off the DC spur. Negative values tune above.
### `[sdr.gain]`
- `mode` (`string`, default: `"auto"`): `"auto"` enables hardware AGC (falls back to `"manual"` with a warning if the device does not support it); `"manual"` uses the fixed `value`.
- `value` (`f64`, default: `30.0`): Gain in dB. Used only when `mode = "manual"`.
### `[sdr.squelch]`
- `enabled` (`bool`, default: `false`): Enables virtual software squelch for demodulated audio except WFM on the primary SDR channel.
- `threshold_db` (`f32`, default: `-65.0`, valid: `-140..=0`): Open threshold in dBFS.
- `hysteresis_db` (`f32`, default: `3.0`, valid: `0..=40`): Close hysteresis in dB.
- `tail_ms` (`u32`, default: `180`, valid: `0..=10000`): Tail hold time after signal drops below threshold.
### `[[sdr.channels]]`
Defines one virtual receiver channel within the wideband IQ stream. At least one channel is required when using the `soapysdr` backend. The **first** channel in the list is the *primary* channel: `set_freq` and `set_mode` from rig control apply to it, and `get_status` reads from it.
- `id` (`string`, default: `""`): Human-readable label used in logs.
- `offset_hz` (`i64`, default: `0`): Frequency offset from the dial frequency in Hz. Primary channel should be `0`.
- `mode` (`string`, default: `"auto"`): Demodulation mode. `"auto"` follows the RigCat `set_mode` command; or a fixed mode string: `LSB`, `USB`, `CW`, `CWR`, `AM`, `WFM`, `FM`, `DIG`, `PKT`.
- `audio_bandwidth_hz` (`u32`, default: `3000`): One-sided bandwidth of the post-demodulation audio BPF in Hz.
- `fir_taps` (`usize`, default: `64`): FIR filter tap count. Higher values give sharper roll-off at the cost of latency.
- `cw_center_hz` (`u32`, default: `700`): CW tone centre frequency in the audio domain (Hz).
- `wfm_bandwidth_hz` (`u32`, default: `75000`): Pre-demodulation filter bandwidth for WFM only (Hz).
- `decoders` (`string[]`, default: `[]`): Decoder IDs that receive this channel's PCM audio. Valid values: `"ft8"`, `"wspr"`, `"aprs"`, `"cw"`. Each decoder ID may appear in at most one channel.
- `stream_opus` (`bool`, default: `false`): Encode this channel's audio as Opus and stream to clients over the TCP audio port. At most one channel may set this to `true`.
Notes:
- Requires `libSoapySDR` installed (`brew install soapysdr` on macOS; `libsoapysdr-dev` on Debian/Ubuntu).
- The SDR backend is RX-only. `[audio] tx_enabled` must be `false`.
- Channel IF constraint: `|center_offset_hz + offset_hz| < sample_rate / 2` for every channel; violated channels cause a startup error.
- `[audio] sample_rate` must match the output audio rate of the SDR pipeline (48000 Hz recommended).
- Use `trx-server --print-config` to see all defaults. SDR fields appear only if the binary was built with `--features soapysdr`.
### `[decode_logs]`
- `enabled` (`bool`, default: `false`)
- `dir` (`string`, default: `"$XDG_DATA_HOME/trx-rs/decoders"`; fallback: `"logs/decoders"`, must not be empty when enabled)
- `aprs_file` (`string`, default: `"TRXRS-APRS-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled)
- `cw_file` (`string`, default: `"TRXRS-CW-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled)
- `ft8_file` (`string`, default: `"TRXRS-FT8-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled)
- `wspr_file` (`string`, default: `"TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled)
Notes:
- Decoder logs are server-side and split by decoder name (APRS/CW/FT8/WSPR).
- Files are appended in JSON Lines format (one JSON object per line).
- Supported filename date tokens: `%YYYY%`, `%MM%`, `%DD%` (UTC date).
## `trx-client` Options
### `[general]`
- `callsign` (`string`, default: `"N0CALL"`)
- `log_level` (`string`, optional): one of `trace|debug|info|warn|error`
### `[remote]`
- `url` (`string`, optional in file but required at runtime unless provided by CLI `--url`)
- `poll_interval_ms` (`u64`, default: `750`, must be `> 0`)
### `[remote.auth]`
- `token` (`string`, optional)
Notes:
- If provided, token must not be empty/whitespace.
### `[frontends.http]`
- `enabled` (`bool`, default: `true`)
- `listen` (`ip`, default: `127.0.0.1`)
- `port` (`u16`, default: `8080`, must be `> 0` when enabled)
### `[frontends.rigctl]`
- `enabled` (`bool`, default: `false`)
- `listen` (`ip`, default: `127.0.0.1`)
- `port` (`u16`, default: `4532`, must be `> 0` when enabled)
### `[frontends.http_json]`
- `enabled` (`bool`, default: `true`)
- `listen` (`ip`, default: `127.0.0.1`)
- `port` (`u16`, default: `0`)
- `auth.tokens` (`string[]`, default: `[]`)
Notes:
- `port = 0` means ephemeral bind (allowed).
- Empty token strings are invalid.
### `[frontends.audio]`
- `enabled` (`bool`, default: `true`)
- `server_port` (`u16`, default: `4531`, must be `> 0` when enabled)
- `bridge.enabled` (`bool`, default: `false`): enables local `cpal` audio bridge
- `bridge.rx_output_device` (`string`, optional): exact local playback device name
- `bridge.tx_input_device` (`string`, optional): exact local capture device name
- `bridge.rx_gain` (`float`, default: `1.0`, must be finite and `>= 0`)
- `bridge.tx_gain` (`float`, default: `1.0`, must be finite and `>= 0`)
Notes:
- The bridge is intended for local WSJT-X integration via virtual audio devices.
- Linux: typically use ALSA loopback (`snd-aloop`).
- macOS: install a virtual CoreAudio device (e.g. BlackHole), then set device names above.
## CLI Override Summary
### `trx-server`
- `--config`, `--print-config`
- `--rig`, `--access`, positional `RIG_ADDR`
- `--callsign`
- `--listen`, `--port` (JSON listener)
- SDR backend: all SDR options are file-only (`[sdr]` and `[[sdr.channels]]`).
### `trx-client`
- `--config`, `--print-config`
- `--url`, `--token`, `--poll-interval`
- `--frontend` (comma-separated)
- `--http-listen`, `--http-port`
- `--rigctl-listen`, `--rigctl-port`
- `--http-json-listen`, `--http-json-port`
- `--callsign`
-69
View File
@@ -1,69 +0,0 @@
# Top 5 Real Architecture Issues (Post-Refactor)
## 1) Plugin ABI is still brittle and unversioned
### Files
- `src/trx-app/src/plugins.rs`
- `examples/trx-plugin-example/src/lib.rs`
### Why this matters
Plugin loading is now explicit (good), but still assumes exact symbol names and raw FFI contracts with no ABI/version handshake. A plugin built against an older/newer ABI can fail at runtime in hard-to-diagnose ways.
### Fix steps
1. Add an ABI version symbol/handshake (`trx_plugin_abi_version`) and reject incompatible plugins with clear errors.
2. Split plugin capability metadata (backend/frontend/both) from registration symbols to avoid noisy failed-load logs.
3. Provide a tiny shared plugin-API crate for stable entrypoint signatures.
## 2) Runtime supervision is still ad-hoc (sleep + abort)
### Files
- `src/trx-server/src/main.rs`
- `src/trx-client/src/main.rs`
### Why this matters
Shutdown is coordinated, but supervision still uses a fixed delay plus manual `abort()` over `Vec<JoinHandle<_>>`. This can mask task failures, race shutdown ordering, and make lifecycle behavior harder to reason about.
### Fix steps
1. Move to `JoinSet` (or a small supervisor type) for task ownership and result handling.
2. Replace fixed sleep with bounded graceful-join timeout logic.
3. Surface task failure reasons consistently in one place.
## 3) JSON/TCP transport logic is duplicated across modules
### Files
- `src/trx-server/src/listener.rs`
- `src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs`
- `src/trx-client/src/remote_client.rs`
### Why this matters
`read_limited_line`, timeout handling, and response write patterns are repeated in multiple places. This increases drift risk and makes protocol hardening changes expensive.
### Fix steps
1. Extract shared JSON-over-TCP helpers into `trx-protocol` (or a small transport crate/module).
2. Keep one source of truth for max line size, timeout behavior, and framing errors.
3. Cover shared transport with focused tests once instead of per-module copies.
## 4) Boundary tests are present but mostly ignored in constrained envs
### Files
- `src/trx-server/src/listener.rs`
- `src/trx-client/src/remote_client.rs`
- `src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs`
### Why this matters
Important network-path tests exist, but are marked `#[ignore]` in this environment due bind restrictions. Without a clear CI strategy, regressions can still slip through.
### Fix steps
1. Add CI jobs/environment where bind-based tests run by default.
2. Split pure transport logic from socket bind/accept so more behavior can be tested without real sockets.
3. Keep ignored tests minimal and document how/when they run.
## 5) Decode/history shared state still relies on global mutexes
### Files
- `src/trx-server/src/audio.rs`
- `src/trx-client/trx-frontend/src/lib.rs`
- `src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs`
### Why this matters
History/state paths still use shared mutex-backed globals/contexts with `expect` on lock poisoning in hot paths. This is workable but fragile for long-running async services.
### Fix steps
1. Replace panic-on-poison lock usage with resilient handling.
2. Consider bounded channel or lock-free append/read model for decode history.
3. Define explicit ownership/lifetime for history data instead of implicit shared mutation.
-156
View File
@@ -1,156 +0,0 @@
# Multi-Rig Support
This document specifies the requirements for running N simultaneous rig backends in one `trx-server` process and the protocol/config changes required to support them.
---
## Progress
> **For AI agents:** This section is the single source of truth for implementation status.
> Each task has a unique ID (e.g. `MR-01`), a status badge, a description, the files it touches, and any blocking dependencies.
>
> Status legend: `[ ]` not started · `[~]` in progress · `[x]` done · `[!]` blocked
### Foundational (parallel)
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| MR-01 | `[x]` | Add `rig_id: Option<String>` to `ClientEnvelope`; add `rig_id: Option<String>` to `ClientResponse`; add `ClientCommand::GetRigs`; add `GetRigsResponseBody` + `RigEntry`; add sentinel arm in `mapping.rs` | `src/trx-protocol/src/types.rs`, `mapping.rs`, `lib.rs` | — |
| MR-02 | `[x]` | Add `RigInstanceConfig`; add `rigs: Vec<RigInstanceConfig>` to `ServerConfig`; implement `resolved_rigs()`; extend `validate()` for unique IDs + unique audio ports | `src/trx-server/src/config.rs` | — |
| MR-03 | `[x]` | Remove four `OnceLock` statics from `audio.rs`; add `DecoderHistories { aprs, ft8, wspr }` struct + `new()`; convert history free-fns to take `&DecoderHistories`; update decoder task signatures + `run_audio_listener` | `src/trx-server/src/audio.rs` | — |
| MR-04 | `[x]` | Create `src/trx-server/src/rig_handle.rs` with `RigHandle { rig_id, rig_tx, state_rx }`; declare mod in `main.rs` | `src/trx-server/src/rig_handle.rs`, `main.rs` | — |
### Sequential
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| MR-05 | `[x]` | Add `rig_id: String` + `histories: Arc<DecoderHistories>` to `RigTaskConfig`; fix `clear_*_history` calls in `process_command` | `src/trx-server/src/rig_task.rs` | MR-03 |
| MR-06 | `[x]` | Rewrite `run_listener` to take `Arc<HashMap<String, RigHandle>>` + `default_rig_id`; route by `envelope.rig_id`; add `GetRigs` fast path; populate `rig_id` in every `ClientResponse` | `src/trx-server/src/listener.rs` | MR-01, MR-04 |
| MR-07 | `[x]` | Rewrite `main.rs` spawn loop over `resolved_rigs()`; extract `spawn_rig_audio_stack()`; per-rig pskreporter + aprsfi; build `HashMap<String, RigHandle>`; pass to `run_listener` | `src/trx-server/src/main.rs` | MR-0206 |
### Tests
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| MR-08 | `[x]` | Config tests: `resolved_rigs()` with multi-rig TOML and legacy TOML; duplicate ID/port rejection | `src/trx-server/src/config.rs` | MR-02 |
| MR-09 | `[x]` | Protocol tests: `ClientEnvelope` absent `rig_id` parses; `rig_id` in responses; `GetRigs` round-trip; existing tests still pass | `src/trx-protocol/src/codec.rs` | MR-01 |
---
## Goals
- Run N simultaneous rig backends (SDR, transceivers, or any mix) in one server process
- Route control commands to the correct rig via `rig_id` in the JSON protocol
- Backward compatibility: single-rig configs (`[rig]`/`[audio]` at top level) continue to work unchanged
- Per-rig audio streaming on separate TCP ports
- New `GetRigs` command to enumerate all connected rigs and their states
---
## Non-Goals
- Load-balancing or failover between rigs
- Sharing a single audio port across multiple rigs (each rig keeps its own port)
---
## Architecture
```
Single [listen] port (4530)
└─ listener.rs: Arc<HashMap<rig_id, RigHandle>>
├─ route by envelope.rig_id (absent → first rig, backward compat)
└─ GetRigs → aggregate all states
Per-rig:
rig_task ←→ RigHandle (rig_tx + state_rx)
audio capture → pcm_tx → decoder tasks → decode_tx
run_audio_listener (own TCP port per rig)
pskreporter + aprsfi tasks
```
---
## TOML Format
### Multi-rig (`[[rigs]]` array)
```toml
[general]
callsign = "W1AW"
[listen]
port = 4530
[[rigs]]
id = "hf"
[rigs.rig]
model = "ft450d"
initial_freq_hz = 14074000
[rigs.rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[rigs.audio]
port = 4531
[[rigs]]
id = "sdr"
[rigs.rig]
model = "soapysdr"
[rigs.rig.access]
type = "sdr"
args = "driver=rtlsdr"
[rigs.audio]
port = 4532
[rigs.sdr]
sample_rate = 1920000
```
### Legacy (flat `[rig]` + `[audio]`) — continues to work unchanged
```toml
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[audio]
port = 4531
```
Legacy configs are synthesised into a single-element `[[rigs]]` list with `id = "default"` via `resolved_rigs()`.
---
## Protocol Wire Format
Request (`rig_id` optional; absent = first rig):
```json
{"rig_id": "hf", "cmd": "set_freq", "freq_hz": 14074000}
{"cmd": "get_state"}
```
Response (`rig_id` always present):
```json
{"success": true, "rig_id": "hf", "state": {...}}
{"success": false, "rig_id": "default", "error": "Unknown rig_id: xyz"}
```
`GetRigs` response:
```json
{"success": true, "rig_id": "server", "rigs": [
{"rig_id": "hf", "state": {...}},
{"rig_id": "sdr", "state": {...}}
]}
```
---
## Validation Rules (startup)
- When `[[rigs]]` is non-empty: each `id` must be unique (case-sensitive).
- When `[[rigs]]` is non-empty: each `audio.port` must be unique.
- When `[[rigs]]` is empty: legacy flat fields are used with `id = "default"`.
- Mixing `[[rigs]]` and legacy flat `[rig]`/`[audio]` is undefined; `[[rigs]]` takes precedence.
-326
View File
@@ -1,326 +0,0 @@
# trx-rs Project Overview
## What is trx-rs?
**trx-rs** is a modular transceiver (radio) control stack written in Rust. It provides a backend service for controlling amateur radio transceivers via CAT (Computer-Aided Transceiver) protocols, with multiple frontend interfaces for access and monitoring.
### Current Capabilities
| Feature | Status |
|---------|--------|
| Yaesu FT-817 CAT control | Implemented |
| HTTP/Web UI with SSE | Implemented |
| rigctl-compatible TCP | Implemented |
| VFO A/B switching | Implemented |
| PTT control | Implemented |
| Signal/TX power metering | Implemented |
| Front panel lock | Implemented |
| Multiple rig backends | Extensible (only FT-817) |
| Backend/frontend registry | Implemented |
| TCP CAT transport | Partial (config wiring only) |
| JSON TCP control (line-delimited) | Implemented (configurable frontend) |
| Plugin registry loading | Implemented (shared libraries) |
| Configuration file (TOML) | Implemented |
| Rig state machine | Implemented |
| Command handlers | Implemented |
| Event notifications | Implemented (rig task emits events) |
| Retry/polling policies | Implemented |
| Controller-based rig task | Implemented |
---
## Current Architecture
```
┌──────────────────────────────────────────────────────────────────────────┐
│ trx-server/trx-client │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Application │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ │
│ │ │ Config │ │ CLI │ │ Rig Task │ │ │
│ │ │ (TOML file) │ │ (clap) │ │ (main loop) │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────┴───────────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ trx-core │ │ Frontend Layer │ │
│ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │
│ │ │ controller/ │ │ │ │ HTTP │ │ │
│ │ │ - machine │ │ │ │ (REST+SSE) │ │ │
│ │ │ - handlers │ │ │ └───────────────┘ │ │
│ │ │ - events │ │ │ ┌───────────────┐ │ │
│ │ │ - policies │ │ │ │ HTTP JSON │ │ │
│ │ └───────────────┘ │ │ │ (TCP/JSON) │ │ │
│ └─────────────────────┘ │ └───────────────┘ │ │
│ │ │ ┌───────────────┐ │ │
│ │ │ │ rigctl │ │ │
│ │ │ │ (TCP/hamlib) │ │ │
│ │ │ └───────────────┘ │ │
│ │ └─────────────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ trx-backend │ │
│ │ ┌───────────────┐ │ │
│ │ │ FT-817 Driver │ │ │
│ │ └───────────────┘ │ │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
```
### Key Components
| Component | Purpose |
|-----------|---------|
| `trx-core` | Core types, traits (`Rig`, `RigCat`), state definitions, controller components |
| `trx-core/rig/controller` | State machine, command handlers, event system, policies |
| `trx-backend` | Backend factory and abstraction layer |
| `trx-backend-ft817` | FT-817 CAT protocol implementation |
| `trx-frontend` | Frontend trait (`FrontendSpawner`) |
| `trx-frontend-http` | Web UI with REST API and SSE |
| `trx-frontend-http-json` | JSON-over-TCP control frontend |
| `trx-frontend-rigctl` | Hamlib rigctl-compatible TCP interface |
| `trx-server` | Server binary — connects to rig backend, exposes JSON TCP control |
| `trx-client` | Client binary — connects to server, runs frontends (HTTP, rigctl) |
---
## Configuration
trx-rs supports TOML configuration files with the following search order:
1. `--config <path>` (explicit CLI argument)
2. `./trx-server.toml` or `./trx-client.toml` (current directory)
3. `~/.config/trx-rs/config.toml` (XDG user config)
4. `/etc/trx-rs/config.toml` (system-wide)
CLI arguments override config file values.
Plugin discovery:
- Uses shared libraries with a `trx_register` entrypoint.
- Searches `./plugins`, `~/.config/trx-rs/plugins`, and any paths in `TRX_PLUGIN_DIRS`.
### Example Configuration
```toml
[general]
callsign = "N0CALL"
[rig]
model = "ft817"
initial_freq_hz = 144300000
initial_mode = "USB"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[frontends.http]
enabled = true
listen = "127.0.0.1"
port = 8080
[frontends.rigctl]
enabled = true
listen = "127.0.0.1"
port = 4532
[frontends.http_json]
enabled = true
listen = "127.0.0.1"
port = 9000
auth.tokens = ["demo-token"]
[behavior]
poll_interval_ms = 500
poll_interval_tx_ms = 100
max_retries = 3
retry_base_delay_ms = 100
```
Use `trx-server --print-config` or `trx-client --print-config` to generate an example configuration.
---
## Rig Controller Components
Located in `trx-core/src/rig/controller/`:
### State Machine (`machine.rs`)
Explicit state machine for rig lifecycle management:
```rust
pub enum RigMachineState {
Disconnected,
Connecting { started_at: Option<u64> },
Initializing { rig_info: Option<RigInfo> },
PoweredOff { rig_info: RigInfo },
Ready(ReadyStateData),
Transmitting(TransmittingStateData),
Error { error: RigStateError, previous_state: Box<RigMachineState> },
}
```
Events trigger state transitions:
- `RigEvent::Connected`, `Initialized`, `PoweredOn`, `PoweredOff`
- `RigEvent::PttOn`, `PttOff`
- `RigEvent::Error(RigStateError)`, `Recovered`, `Disconnected`
### Command Handlers (`handlers.rs`)
Trait-based command system with validation:
```rust
pub trait RigCommandHandler: Debug + Send + Sync {
fn name(&self) -> &'static str;
fn can_execute(&self, ctx: &dyn CommandContext) -> ValidationResult;
fn execute<'a>(&'a self, executor: &'a mut dyn CommandExecutor)
-> Pin<Box<dyn Future<Output = DynResult<CommandResult>> + Send + 'a>>;
}
```
Implemented commands:
- `SetFreqCommand`, `SetModeCommand`, `SetPttCommand`
- `PowerOnCommand`, `PowerOffCommand`
- `ToggleVfoCommand`, `LockCommand`, `UnlockCommand`
- `GetTxLimitCommand`, `SetTxLimitCommand`, `GetSnapshotCommand`
The rig task (`trx-server/src/rig_task.rs`) now syncs the state machine to the live `RigState`
and emits events whenever rig status changes.
### Event Notifications (`events.rs`)
Typed event system for rig state changes:
```rust
pub trait RigListener: Send + Sync {
fn on_frequency_change(&self, old: Option<Freq>, new: Freq);
fn on_mode_change(&self, old: Option<&RigMode>, new: &RigMode);
fn on_ptt_change(&self, transmitting: bool);
fn on_state_change(&self, old: &RigMachineState, new: &RigMachineState);
fn on_meter_update(&self, rx: Option<&RigRxStatus>, tx: Option<&RigTxStatus>);
fn on_lock_change(&self, locked: bool);
fn on_power_change(&self, powered: bool);
}
pub struct RigEventEmitter {
// Manages listeners and dispatches events
}
```
### Policies (`policies.rs`)
Configurable retry and polling behavior:
```rust
pub trait RetryPolicy: Send + Sync {
fn should_retry(&self, attempt: u32, error: &RigError) -> bool;
fn delay(&self, attempt: u32) -> Duration;
fn max_attempts(&self) -> u32;
}
pub trait PollingPolicy: Send + Sync {
fn interval(&self, transmitting: bool) -> Duration;
fn should_poll(&self, transmitting: bool) -> bool;
}
```
Implementations:
- `ExponentialBackoff` - Exponential delay with max cap
- `FixedDelay` - Constant delay between retries
- `NoRetry` - Fail immediately
- `AdaptivePolling` - Faster polling during TX
- `FixedPolling` - Constant interval
- `NoPolling` - Disable automatic polling
### Error Types
`RigError` now includes error classification:
```rust
pub struct RigError {
pub message: String,
pub kind: RigErrorKind, // Transient or Permanent
}
impl RigError {
pub fn timeout() -> Self; // Transient
pub fn communication(msg) -> Self; // Transient
pub fn invalid_state(msg) -> Self; // Permanent
pub fn not_supported(op) -> Self; // Permanent
pub fn is_transient(&self) -> bool;
}
```
---
## Remaining Improvement Opportunities
### Integration Work
1. **Plugin UX improvements** - Add structured plugin metadata (name, version, capabilities) and surface it in CLI help.
### Testing
- Add integration tests with mock backends
- Add more backend/frontend unit tests
### Features
- Add more rig backends (IC-7300, TS-590, etc.)
- Add TX limit support for FT-817 (or document per-backend constraints in UI)
- Add WebSocket support for bidirectional communication
- Add metrics/telemetry export (Prometheus)
- Add authentication for HTTP frontend
### Code Quality
- Add CI/CD pipeline
- Add pre-commit hooks
---
## Implementation Status
| Component | Status | Tests |
|-----------|--------|-------|
| State Machine | Implemented | 5 tests |
| Command Handlers | Implemented | 3 tests |
| Event Notifications | Implemented | 2 tests |
| Retry/Polling Policies | Implemented | 5 tests |
| Config File Support | Implemented | 4 tests |
| rigctl Frontend | Implemented | - |
| HTTP Frontend | Implemented | - |
| FT-817 Backend | Implemented | - |
**Total: 19 unit tests passing**
---
## Building and Running
```bash
# Build
cargo build --release
# Run server with CLI args
./target/release/trx-server -r ft817 "/dev/ttyUSB0 9600"
# Run server with config file
./target/release/trx-server --config /path/to/config.toml
# Run client
./target/release/trx-client --config /path/to/client-config.toml
# Print example config
./target/release/trx-server --print-config > trx-server.toml
# Run tests
cargo test
# Run clippy
cargo clippy
```
-401
View File
@@ -1,401 +0,0 @@
# SDR Backend Requirements
This document specifies the requirements for a SoapySDR-based RX-only backend (`trx-backend-soapysdr`) and the associated IQ-to-audio pipeline changes in `trx-server`.
---
## Progress
> **For AI agents:** This section is the single source of truth for implementation status.
> Each task has a unique ID (e.g. `SDR-01`), a status badge, a description, the files it touches, and any blocking dependencies.
> Pick any task whose status is `[ ]` and whose `Needs` list is fully `[x]`. Update status to `[~]` while working, `[x]` when merged. Record notes under the task if you hit non-obvious issues.
>
> Status legend: `[ ]` not started · `[~]` in progress · `[x]` done · `[!]` blocked
### Foundational (must land first)
| ID | Status | Task | Touches |
|----|--------|------|---------|
| SDR-01 | `[x]` | Add `AudioSource` trait to `trx-core`; add `as_audio_source()` default on `RigCat` | `src/trx-core/src/rig/mod.rs` |
| SDR-02 | `[x]` | Add `RigAccess::Sdr { args: String }` variant; register `soapysdr` factory (feature-gated `soapysdr`) | `src/trx-server/trx-backend/src/lib.rs` |
| SDR-03 | `[x]` | Add `SdrConfig`, `SdrGainConfig`, `SdrChannelConfig` structs; parse `type = "sdr"` in `AccessConfig`; add `sdr: SdrConfig` to `ServerConfig`; add startup validation rules (§11) | `src/trx-server/src/config.rs` |
### New crate: `trx-backend-soapysdr`
| ID | Status | Task | Touches | Needs |
|----|--------|------|---------|-------|
| SDR-04 | `[x]` | Create crate scaffold: `Cargo.toml` (deps: `soapysdr`, `num-complex`, `tokio`), empty `lib.rs` | `src/trx-server/trx-backend/trx-backend-soapysdr/` | SDR-01, SDR-02 |
| SDR-05 | `[x]` | Implement `demod.rs`: SSB (USB/LSB), AM envelope, FM quadrature, CW narrow BPF+envelope | `…/src/demod.rs` | SDR-04 |
| SDR-06 | `[x]` | Implement `dsp.rs`: IQ broadcast loop (SoapySDR read thread → `broadcast::Sender<Vec<Complex<f32>>>`); per-channel mixer → FIR LPF → decimator → demod → frame accumulator → `broadcast::Sender<Vec<f32>>` | `…/src/dsp.rs` | SDR-04, SDR-05 |
| SDR-07 | `[x]` | Implement `SoapySdrRig` in `lib.rs`: `RigCat` (RX methods + `not_supported` stubs for TX), `AudioSource`, gain control (manual/auto with fallback), primary channel freq/mode tracking | `…/src/lib.rs` | SDR-03, SDR-06 |
### Server integration
| ID | Status | Task | Touches | Needs |
|----|--------|------|---------|-------|
| SDR-08 | `[x]` | `main.rs`: after building rig, if `as_audio_source()` is `Some` skip cpal, subscribe each decoder and the Opus encoder to the appropriate channel PCM senders; validate `stream_opus` count ≤ 1 | `src/trx-server/src/main.rs` | SDR-03, SDR-07 |
| SDR-09 | `[x]` | Add `trx-backend-soapysdr` to workspace `Cargo.toml`; update `CONFIGURATION.md` with new `[sdr]` / `[[sdr.channels]]` options | `Cargo.toml`, `CONFIGURATION.md` | SDR-04 |
### Validation & tests
| ID | Status | Task | Touches | Needs |
|----|--------|------|---------|-------|
| SDR-10 | `[x]` | Unit tests for `demod.rs`: known-input tone through each demodulator, check output frequency correct | `…/src/demod.rs` | SDR-05 |
| SDR-11 | `[x]` | Unit tests for config validation: channel IF out-of-range, dual `stream_opus`, TX enabled with SDR backend, AGC fallback warning | `src/trx-server/src/config.rs` | SDR-03 |
---
## Goals
- Receive-only backend that uses any SoapySDR-compatible device (RTL-SDR, Airspy, HackRF, SDRplay, etc.) as the rig
- Full IQ pipeline: raw IQ samples → demodulated PCM → existing decoders (FT8, WSPR, APRS, CW) with zero decoder-side changes
- Wideband capture: one SDR IQ stream feeds multiple simultaneous virtual receivers, each independently tuned and demodulated
- Configurable per-channel filters and demodulation modes
- Demodulated audio streamed to clients as Opus over the existing TCP audio channel
---
## Non-Goals
- Transmit (TX/PTT) of any kind
- Replacing or deprecating the existing cpal-based audio path (it stays for transceiver backends)
---
## 1. Device Abstraction
### 1.1 `RigAccess` extension
A new access type `sdr` is added alongside `serial` and `tcp`:
```toml
[rig.access]
type = "sdr"
args = "driver=rtlsdr" # SoapySDR device args string
```
The `args` value is passed verbatim to `SoapySDR::Device::new(args)`. It follows SoapySDR's key=value comma-separated convention (e.g., `driver=airspy`, `driver=rtlsdr,serial=00000001`).
### 1.2 `AudioSource` trait
A new trait is added to `trx-core` (`src/trx-core/src/rig/mod.rs`):
```rust
pub trait AudioSource: Send + Sync {
/// Subscribe to demodulated PCM audio from the primary channel.
fn subscribe_pcm(&self) -> broadcast::Receiver<Vec<f32>>;
}
```
`RigCat` gains a default opt-in method:
```rust
pub trait RigCat: Rig + Send {
// ... existing methods ...
fn as_audio_source(&self) -> Option<&dyn AudioSource> { None }
}
```
`SoapySdrRig` overrides `as_audio_source()` to return `Some(self)`. When the server detects this, it skips spawning the cpal capture thread entirely.
### 1.3 TX-only `RigCat` methods
The following methods return `RigError::not_supported(...)` on the SDR backend:
- `set_ptt()`
- `power_on()` / `power_off()`
- `get_tx_power()`
- `get_tx_limit()` / `set_tx_limit()`
- `toggle_vfo()` (not applicable; channels are defined statically in config)
- `lock()` / `unlock()`
The following methods are fully supported:
- `get_status()` → returns primary channel's current `(freq, mode, None)`
- `set_freq()` → re-tunes the SDR center frequency (keeping `center_offset_hz` invariant) and updates all channel mixer offsets
- `set_mode()` → changes the primary channel's demodulator
- `get_signal_strength()` → returns instantaneous RSSI for the primary channel (dBFS mapped to 0255 S-unit range)
---
## 2. IQ Pipeline Architecture
### 2.1 Center frequency offset
SDR hardware has a DC offset spur at exactly 0 Hz in the IQ spectrum. To keep the primary channel off DC, the SDR is tuned to a frequency offset from the desired dial frequency:
```
sdr_center_freq = dial_freq - center_offset_hz
```
With `center_offset_hz = 200000` and dial freq 14.074 MHz, the SDR tunes to 13.874 MHz. The 14.074 MHz signal appears at +200 kHz in the IQ spectrum and is mixed down to baseband in software.
`center_offset_hz` is a global SDR parameter (not per-channel). A reasonable default is `100000` (100 kHz).
### 2.2 Wideband channel model
One SoapySDR RX stream produces IQ samples at `sdr.sample_rate` (e.g. 1.92 MHz). This stream is shared among all configured channels. Each channel defines an independent virtual receiver:
```
SoapySDR RX stream (complex f32, sdr_sample_rate Hz)
├──► Channel 0 (primary) offset_hz=0, mode=USB, bw=3000 Hz
├──► Channel 1 (wspr) offset_hz=+21600, mode=USB, bw=3000 Hz
└──► Channel N ...
```
A **channel's frequency** in the real spectrum is:
```
channel_real_freq = dial_freq + channel.offset_hz
```
A **channel's IF frequency** within the IQ stream is:
```
channel_if_hz = center_offset_hz + channel.offset_hz
```
This is the frequency at which the channel's signal appears in the captured IQ bandwidth, and is what the channel's mixer shifts to baseband.
**Constraint:** `|channel_if_hz|` must be less than `sdr_sample_rate / 2` for every channel. The server validates this at startup and rejects invalid configs.
### 2.3 Per-channel DSP chain
Each channel runs the following stages independently on the shared IQ stream:
```
IQ input (complex f32, sdr_sample_rate)
1. Mixer: multiply by exp(-j·2π·channel_if_hz·n/sdr_sample_rate)
→ complex f32 centred at 0 Hz
2. FIR LPF: cutoff = audio_bandwidth_hz / 2, order configurable
3. Decimator: sdr_sample_rate / audio_sample_rate (must be integer; resampler used otherwise)
4. Demodulator (mode-dependent, see §3)
5. Output: real f32 at audio_sample_rate
6. Frame accumulator: chunks of frame_duration_ms
7. broadcast::Sender<Vec<f32>> → decoders + optional Opus encoder
```
Channels run concurrently in separate tasks, all reading from the same raw IQ broadcast channel.
### 2.4 IQ broadcast channel
The SoapySDR read loop runs in a dedicated OS thread (matching the existing cpal thread model). It reads IQ sample blocks from the device and publishes them on:
```rust
broadcast::Sender<Vec<Complex<f32>>> // capacity: configurable, default 64 blocks
```
Each channel task subscribes to this sender. Lagged receivers log a warning and continue.
---
## 3. Demodulators
Demodulator is selected per-channel based on `mode`. Modes map as follows:
| `RigMode` | Demodulator |
|-----------|-------------|
| `USB` | SSB: mix to IF, take real part (upper sideband) |
| `LSB` | SSB: mix to IF, take real part (lower sideband, negate IF) |
| `AM` | Envelope detector: `sqrt(I² + Q²)`, DC-remove, normalize |
| `FM` | Quadrature: `arg(s[n] · conj(s[n-1]))`, i.e. instantaneous frequency |
| `WFM` | Same as FM, wider pre-demod filter (`wfm_bandwidth_hz`) |
| `CW` | Narrow BPF centred at `cw_center_hz` (audio domain), then envelope |
| `DIG`/`PKT` | Same as USB (pass audio through for downstream digital decoders) |
| `CWR` | Same as CW (reversed sideband, uses same audio envelope) |
For SSB modes (USB/LSB), after mixing to baseband the channel's `audio_bandwidth_hz` defines the one-sided cutoff of the post-demod LPF.
---
## 4. Gain Control
Gain is configured globally under `[sdr.gain]`.
```toml
[sdr.gain]
mode = "auto" # "auto" (AGC via SoapySDR) or "manual"
value = 30.0 # dB; ignored when mode = "auto"
```
- **`auto`**: calls `device.set_gain_mode(SOAPY_SDR_RX, 0, true)` to enable hardware AGC if the device supports it. If the device does not support hardware AGC, falls back to `manual` with a warning.
- **`manual`**: calls `device.set_gain(SOAPY_SDR_RX, 0, value)` with the specified total gain in dB.
Advanced per-element gain is out of scope for this phase (no `lna`/`vga`/`if` sub-keys initially).
### 4.1 Virtual Squelch
Software squelch is configured globally under `[sdr.squelch]` and currently applies to the primary channel's demodulated audio path except WFM.
```toml
[sdr.squelch]
enabled = false
threshold_db = -65.0 # dBFS open threshold
hysteresis_db = 3.0 # dB close hysteresis
tail_ms = 180 # hold time after signal drops
```
---
## 5. Filter Configuration
Filters are configured per-channel. The following are settable:
```toml
[[sdr.channels]]
audio_bandwidth_hz = 3000 # One-sided bandwidth of post-demod BPF (Hz)
# For FM: deviation hint for deemphasis
fir_taps = 64 # FIR filter tap count (default 64); higher = sharper roll-off
cw_center_hz = 700 # CW tone centre in audio domain (default 700 Hz)
wfm_bandwidth_hz = 75000 # Pre-demod bandwidth for WFM only (default 75 kHz)
```
`fir_taps` controls the same FIR used in stage 2 of the DSP chain (§2.3). It applies uniformly to both the pre-demod decimation filter and the post-demod audio BPF in this phase.
---
## 6. Channel Configuration and Decoder Binding
Channels are declared as a TOML array. The first channel in the list is the **primary channel** and is the one exposed via `RigCat` (`set_freq`/`set_mode` affect it; `get_status` reads from it).
```toml
[[sdr.channels]]
id = "primary" # Identifier, used in logs
offset_hz = 0 # Offset from dial frequency (Hz)
mode = "auto" # "auto" = follows RigCat set_mode; or fixed RigMode string
audio_bandwidth_hz = 3000
fir_taps = 64
decoders = ["ft8", "cw"] # Which decoders receive this channel's PCM
stream_opus = true # Encode and stream via TCP audio channel
[[sdr.channels]]
id = "wspr-14"
offset_hz = 21600 # 14.0956 MHz when dial = 14.074 MHz
mode = "USB" # Fixed mode, ignores RigCat set_mode
audio_bandwidth_hz = 3000
decoders = ["wspr"]
stream_opus = false
[[sdr.channels]]
id = "aprs"
offset_hz = -673600 # e.g. 144.390 MHz when dial = 145.0635 MHz
mode = "FM"
audio_bandwidth_hz = 8000
decoders = ["aprs"]
stream_opus = false
```
**`mode = "auto"`** means the channel's demodulator tracks whatever `set_mode()` last set on the backend. Only the primary channel should use `"auto"` in typical use.
**`decoders`** maps to the decoder task IDs: `"ft8"`, `"wspr"`, `"aprs"`, `"cw"`. Each named decoder subscribes to the PCM broadcast channel of the listed channel(s). A decoder can only be bound to one channel (first binding wins if duplicated).
---
## 7. Opus Streaming
Channels with `stream_opus = true` have their demodulated PCM Opus-encoded and streamed over the server's existing TCP audio port (default 4531).
For this phase, only **one channel** may have `stream_opus = true` (validation error otherwise). This channel's Opus stream replaces what cpal would have produced — the TCP audio protocol and client-side handling are unchanged.
The Opus encoder uses the `[audio]` config for `frame_duration_ms`, `bitrate_bps`, and `sample_rate`. The SDR pipeline must output PCM at the same `sample_rate` as `[audio]`; a mismatch is a startup validation error.
---
## 8. Full Configuration Example
```toml
[rig]
model = "soapysdr"
initial_freq_hz = 14074000
initial_mode = "USB"
[rig.access]
type = "sdr"
args = "driver=rtlsdr"
[sdr]
sample_rate = 1920000 # IQ capture rate (Hz) — must be supported by device
bandwidth = 1500000 # Hardware IF filter (Hz)
center_offset_hz = 200000 # SDR tunes this many Hz below dial frequency
[sdr.gain]
mode = "auto"
value = 30.0 # Effective only when mode = "manual"
[sdr.squelch]
enabled = false
threshold_db = -65.0
hysteresis_db = 3.0
tail_ms = 180
[[sdr.channels]]
id = "primary"
offset_hz = 0
mode = "auto"
audio_bandwidth_hz = 3000
fir_taps = 64
decoders = ["ft8", "cw"]
stream_opus = true
[[sdr.channels]]
id = "wspr"
offset_hz = 21600
mode = "USB"
audio_bandwidth_hz = 3000
decoders = ["wspr"]
stream_opus = false
[audio]
enabled = true
listen = "127.0.0.1"
port = 4531
rx_enabled = true
tx_enabled = false # No TX on SDR backend
sample_rate = 48000
channels = 1
frame_duration_ms = 20
bitrate_bps = 24000
```
---
## 9. Code Changes Map
| File | Change |
|------|--------|
| `Cargo.toml` (workspace) | Add `src/trx-server/trx-backend/trx-backend-soapysdr` member |
| `src/trx-core/src/rig/mod.rs` | Add `AudioSource` trait; add `as_audio_source()` default to `RigCat` |
| `src/trx-server/trx-backend/src/lib.rs` | Add `RigAccess::Sdr { args }` variant; register `soapysdr` factory (feature-gated) |
| `src/trx-server/src/config.rs` | Add `SdrConfig`, `SdrGainConfig`, `SdrChannelConfig`; parse `type = "sdr"` in `AccessConfig`; add `sdr: SdrConfig` to `ServerConfig` |
| `src/trx-server/src/main.rs` | After building rig: if `as_audio_source()` is `Some`, skip cpal, use `AudioSource::subscribe_pcm()` for each decoder and for the Opus encoder; validate at most one `stream_opus = true` channel |
| `src/trx-server/src/audio.rs` | Expose `spawn_audio_capture` and `run_*_decoder` without assuming cpal as the sole source; no functional change needed — decoders already take `broadcast::Receiver<Vec<f32>>` |
| `src/trx-server/trx-backend/trx-backend-soapysdr/Cargo.toml` | New crate |
| `src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs` | `SoapySdrRig`: implements `RigCat` + `AudioSource`; spawns IQ read thread and channel DSP tasks |
| `src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs` | IQ broadcast loop; per-channel mixer, FIR, decimator, demodulator, frame accumulator |
| `src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs` | Mode-specific demodulators: SSB, AM envelope, FM quadrature, CW envelope |
| `CONFIGURATION.md` | Document new `[rig.access] type = "sdr"`, `[sdr]`, `[[sdr.channels]]` options |
---
## 10. External Dependencies
| Crate | Purpose |
|-------|---------|
| `soapysdr` | Rust bindings to `libSoapySDR` (C++) |
| `num-complex` | `Complex<f32>` for IQ arithmetic |
System requirement: `libSoapySDR` installed (e.g. `brew install soapysdr` on macOS, `libsoapysdr-dev` on Debian/Ubuntu).
---
## 11. Validation Rules (startup)
- `[rig.access] type = "sdr"` requires `args` to be non-empty.
- `[sdr] sample_rate` must be non-zero.
- For every channel: `|center_offset_hz + channel.offset_hz| < sdr_sample_rate / 2`.
- Exactly one channel must have `stream_opus = true` (or none; zero means no TCP audio stream).
- The audio `sample_rate` in `[audio]` must equal the target audio rate in the SDR pipeline (no cross-rate mismatch).
- `[audio] tx_enabled` must be `false` when `model = "soapysdr"`.
- A decoder name may appear in at most one channel's `decoders` list.
- If the device does not support hardware AGC and `gain.mode = "auto"`, warn and fall back to `manual` using `gain.value`.
-41
View File
@@ -1,41 +0,0 @@
# Project Skills
Custom slash commands (skills) available in this repository.
Invoke with `/skill-name [args]` inside Claude Code.
---
## `/frontend-design` — HTTP frontend work
**When to use:** Any time you need to add or modify UI in the HTTP web frontend — new control rows, panels, visual polish, capability-gated elements, or JS behaviour wired to REST endpoints.
**What it loads:** Design system context (palette, layout primitives, patterns), key file paths, and coding conventions so Claude writes code that matches the existing UI without needing to re-read the style guide each time.
**File:** `.claude/commands/frontend-design.md`
**Example invocations**
```
/frontend-design Add a CW keyer speed row (wpm slider) that POSTs to /set_cw_wpm, shown only when capabilities.tx is true.
/frontend-design Polish the filters panel — align the bandwidth label with the FIR taps label and add a unit suffix to the slider readout.
/frontend-design Add a waterfall canvas below the signal meter that renders frequency vs. time from a new SSE stream.
```
---
## Adding new skills
Place a Markdown file in `.claude/commands/<skill-name>.md`.
Use `$ARGUMENTS` as the placeholder for user-supplied text.
Skills in `.claude/commands/` are project-scoped and not committed if `.claude/` is in `.gitignore`.
To make a skill part of the repo (shared with all contributors), add it to `aidocs/` as documentation and track the command file in version control by removing `.claude/` from `.gitignore` or adding a specific exception.
---
## Global skills (available in all projects)
| Skill | When to use |
|-------|------------|
| `frontend-design` | Also installed globally; project version takes precedence here |
| `keybindings-help` | Customise Claude Code keyboard shortcuts |
-180
View File
@@ -1,180 +0,0 @@
# UI Capability Gating
This document specifies how `trx-client`'s HTTP frontend adapts its controls to the capabilities of the connected rig backend. Devices such as SDR receivers expose filter controls but not TX controls; traditional transceivers are the reverse.
---
## Progress
> **For AI agents:** This section is the single source of truth for implementation status.
> Each task has a unique ID (e.g. `UC-01`), a status badge, a description, the files it touches, and any blocking dependencies.
>
> Status legend: `[ ]` not started · `[~]` in progress · `[x]` done · `[!]` blocked
### Foundational (parallel)
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| UC-01 | `[x]` | Extend `RigCapabilities` with `tx`, `tx_limit`, `vfo_switch`, `filter_controls`, `signal_meter` bool flags | `src/trx-core/src/rig/state.rs` | — |
| UC-02 | `[x]` | Update capability declarations in all backends to set new flags | `src/trx-server/trx-backend/trx-backend-ft817/src/lib.rs`, `trx-backend-ft450d/src/lib.rs`, `trx-backend-soapysdr/src/lib.rs` | UC-01 |
| UC-03 | `[x]` | Add `RigFilterState` struct; add `filter: Option<RigFilterState>` to `RigSnapshot`; populate from SDR rig state | `src/trx-core/src/rig/state.rs`, `src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs` | — |
| UC-04 | `[x]` | Add `SetBandwidth`, `SetFirTaps` to `ClientCommand`; add mapping arms; update `rig_task.rs` to dispatch them | `src/trx-protocol/src/types.rs`, `mapping.rs`, `src/trx-server/src/rig_task.rs` | UC-03 |
### HTTP layer
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| UC-05 | `[x]` | Add `/set_bandwidth` and `/set_fir_taps` HTTP endpoints | `src/trx-client/trx-frontend/trx-frontend-http/src/api.rs` | UC-04 |
### Frontend
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| UC-06 | `[x]` | Read `state.info.capabilities` on each SSE event; toggle visibility of TX controls, meter rows, VFO button, lock button | `assets/web/app.js` | UC-01, UC-02 |
| UC-07 | `[x]` | Add "Filters" control panel (bandwidth, FIR taps, CW tone Hz); show only when `capabilities.filter_controls` | `assets/web/index.html`, `assets/web/app.js` | UC-05, UC-06 |
### Tests
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| UC-08 | `[x]` | Unit tests: SDR backend declares `tx=false`, `filter_controls=true`; FT-817/450D declare `tx=true`, `filter_controls=false` | `src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs`, `trx-backend-ft817`, `trx-backend-ft450d` | UC-02 |
| UC-09 | `[x]` | Protocol round-trip test: `RigSnapshot` serialises `filter` field when `Some`, omits it when `None` | `src/trx-protocol/src/codec.rs` or `types.rs` | UC-03 |
---
## Goals
- All UI control groups are **shown/hidden purely from `RigCapabilities`** flags received in the initial `GET /status` and each SSE `status` event — no hard-coding per model name
- SDR backends show filter controls (bandwidth, FIR taps, CW tone); hide TX controls (PTT, power, TX limit, TX meters, TX audio)
- Transceiver backends show TX controls; hide filter controls
- Adding a new backend requires only setting the right capability flags — no frontend changes
---
## Non-Goals
- Per-channel filter control (multi-channel SDR tuning) — out of scope; only the primary channel is exposed here
- Dynamic capability changes at runtime (capability flags are set once at rig init and treated as static)
- Changing the rigctl or http-json frontends (HTTP frontend only)
---
## Capability Flags
### New flags added to `RigCapabilities` (UC-01)
| Flag | Type | Meaning |
|------|------|---------|
| `tx` | `bool` | Backend supports transmit: PTT, power on/off, TX meters, TX audio |
| `tx_limit` | `bool` | Backend supports `get_tx_limit` / `set_tx_limit` |
| `vfo_switch` | `bool` | Backend supports `toggle_vfo` |
| `filter_controls` | `bool` | Backend supports runtime filter adjustment (bandwidth, FIR taps) |
| `signal_meter` | `bool` | Backend returns a meaningful RX signal strength value |
Existing flags `lock` and `lockable` are unchanged.
### Backend declarations (UC-02)
| Backend | `tx` | `tx_limit` | `vfo_switch` | `filter_controls` | `signal_meter` | `lock`/`lockable` |
|---------|------|-----------|--------------|-------------------|----------------|-------------------|
| FT-817 | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ / ✓ |
| FT-450D | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ / ✓ |
| SoapySDR | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ / ✗ |
---
## Filter State
### `RigFilterState` struct (UC-03)
Added to `trx-core/src/rig/state.rs`:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RigFilterState {
pub bandwidth_hz: u32, // Audio bandwidth of primary channel
pub fir_taps: u32, // FIR filter tap count
pub cw_center_hz: u32, // CW tone centre frequency (audio domain)
}
```
Added to `RigSnapshot`:
```rust
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter: Option<RigFilterState>,
```
The SDR backend populates this from the primary channel's live DSP state. All other backends leave it `None`.
---
## New Protocol Commands
### `ClientCommand` additions (UC-04)
```rust
SetBandwidth { bandwidth_hz: u32 },
SetFirTaps { taps: u32 },
```
`SetCwToneHz` already exists and is reused.
### Mapping (UC-04)
```rust
ClientCommand::SetBandwidth { bandwidth_hz } =>
RigCommand::SetBandwidth(bandwidth_hz),
ClientCommand::SetFirTaps { taps } =>
RigCommand::SetFirTaps(taps),
```
The SDR backend applies changes to the live DSP chain immediately. Other backends return `RigError::not_supported(...)`.
---
## New HTTP Endpoints (UC-05)
| Endpoint | Method | Query param | Action |
|----------|--------|-------------|--------|
| `/set_bandwidth` | POST | `hz: u32` | Sets primary channel audio bandwidth |
| `/set_fir_taps` | POST | `taps: u32` | Sets primary channel FIR tap count |
---
## Frontend Visibility Map (UC-06, UC-07)
| UI element / group | Shown when |
|--------------------|-----------|
| PTT button | `capabilities.tx` |
| Power button | `capabilities.tx` |
| TX meters (power bar, SWR bar) | `capabilities.tx && state.status.tx_en` |
| TX Limit row | `capabilities.tx_limit` |
| TX Audio toggle + volume | `capabilities.tx` |
| VFO selector buttons | `capabilities.vfo_switch` |
| Lock button | `capabilities.lock` |
| Signal meter | `capabilities.signal_meter` |
| Filters panel | `capabilities.filter_controls` |
Visibility is applied in a single `applyCapabilities(caps)` function called from the SSE `status` handler, using `element.classList.toggle('hidden', !condition)`.
### Filter panel layout (UC-07)
```
┌─ Filters ──────────────────────────────────┐
│ Bandwidth [──────●──────] 3000 Hz │
│ FIR taps [32 ▾] (32 / 64 / 128 / 256) │
│ CW tone [──●───────────] 700 Hz │
└────────────────────────────────────────────┘
```
Each control dispatches to its REST endpoint on `change`/`input` (debounced 200 ms). The panel is hidden by default (`class="hidden"`) and revealed when `capabilities.filter_controls` is set.
---
## Implementation Notes
- `applyCapabilities()` must run **before** the first paint (call it synchronously on the initial `/status` response, not only on SSE events) to avoid layout flash of unsupported controls.
- `hidden` CSS class should set `display: none` and `aria-hidden: true`.
- The existing `set_cw_tone` endpoint and CW decoder panel remain in the CW decoder tab — they are decoder settings, not filter settings. The Filters panel bandwidth/taps apply to the DSP chain; CW tone moves to both places or is de-duplicated in a follow-up.
- If a future backend supports TX but not `tx_limit`, only the TX Limit row is hidden; PTT remains.
-79
View File
@@ -1,79 +0,0 @@
# Canvas2D to WebGL Transition Plan
## Goal
- Replace all runtime Canvas2D rendering in the frontend with WebGL.
- Remove Canvas2D code paths after feature parity is reached.
- Keep existing interaction behavior (zoom/pan/tune/BW drag/tooltips/overlays) intact.
## Scope
- `src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js`
- `overview-canvas`
- `spectrum-canvas`
- `signal-overlay-canvas`
- `src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js`
- `cw-tone-waterfall`
- New shared WebGL utility module:
- `assets/web/webgl-renderer.js`
## Non-Goals
- No Canvas2D fallback path.
- No feature redesign outside rendering internals.
## Constraints
- Must preserve existing data flow and event wiring.
- Must keep map/decoder/bookmark integrations unchanged.
- Must remain dependency-free (no external rendering libraries).
## 2-Phase Migration
1. Phase 1 (Rendering engine insertion)
- Add shared WebGL renderer utility (primitives + textures + color parsing).
- Keep existing business logic and interaction handlers untouched.
- Swap draw targets from 2D contexts to WebGL primitives.
2. Phase 2 (Canvas2D removal and parity closure)
- Remove `getContext("2d")` usage from app and plugins.
- Remove obsolete 2D-specific cache paths.
- Validate behavior on resize/theme/style/stream reconnect/decoder mode changes.
## Parallel Workstreams ("Agents")
1. Agent A: Shared WebGL core
- Build `webgl-renderer.js` with:
- HiDPI resize handling
- Solid/gradient rects
- Polyline/segment/fill primitives
- RGBA texture upload + blit
- CSS color parser helpers
2. Agent B: Main spectrum/overview migration
- Port `drawSpectrum`, `drawHeaderSignalGraph`, `drawSignalOverlay`, and clear paths.
- Replace 2D offscreen waterfall cache with WebGL texture updates.
- Keep frequency axis/bookmark axis DOM behavior unchanged.
3. Agent C: CW tone picker migration
- Port `drawCwTonePicker` primitives to WebGL.
- Preserve auto/manual tone interactions and mode gating.
## Acceptance Criteria
- No frontend `getContext("2d")` usage remains.
- All four canvases render using WebGL and respond to resize/DPR changes.
- Spectrum interactions still work:
- wheel zoom
- drag pan
- BW edge drag
- click tune
- Overview strip continues showing waterfall/history.
- CW tone picker remains interactive and reflects current spectrum/tone.
## Verification Checklist
- Static:
- `rg -n 'getContext\\("2d"\\)' src/trx-client/trx-frontend/trx-frontend-http/assets/web`
- Runtime smoke:
- Open main tab: verify overview + spectrum + overlay.
- Toggle theme/style.
- Resize window and spectrum grip.
- Enable CW decoder and validate tone picker updates/click-to-set.
- Confirm no rendering exceptions in browser console.
## Rollout Notes
- Initial rollout is WebGL-only.
- If a browser lacks WebGL, canvases remain blank by design until a dedicated fallback policy is defined.
+89
View File
@@ -0,0 +1,89 @@
# Work In Progress — Project Improvement Areas
Living tracker for engineering-infrastructure and hardening work identified
during a July 2026 repo scan. The architecture-level backlog
(`docs/Improvement-Areas.md`, P0P3) is closed; these items focus on the
tooling, robustness, and product concerns *around* the code.
Status legend: **Done** · **In progress** · **Not started**
| # | Tier | Area | Status |
|----|------|------|--------|
| 1 | 1 — Infrastructure | Gitea Actions CI (fmt, clippy, test, REUSE) | In progress |
| 2 | 1 — Infrastructure | Supply-chain & lint governance (cargo-deny/audit, MSRV, `[workspace.lints]`) | Not started |
| 3 | 1 — Infrastructure | Release & deployment (container image, systemd units, binary releases) | Not started |
| 4 | 2 — Robustness | Panic-resilience audit (harden ~495 unwrap/expect/panic sites) | Not started |
| 5 | 2 — Robustness | `unsafe` SIMD safety scaffolding (`# Safety` docs, scalar↔SIMD equivalence tests) | Not started |
| 6 | 2 — Robustness | DSP performance benchmarks (criterion, guard optimization gains) | Not started |
| 7 | 2 — Robustness | Test-coverage measurement & gap-filling (llvm-cov; CAT backends, server tasks) | Not started |
| 8 | 3 — Product | Frontend modularization & tooling (split `app.js` into ES modules, ESLint, JS tests) | Not started |
| 9 | 3 — Product | Runtime observability (`/health`, Prometheus metrics) | Not started |
| 10 | 3 — Product | Documentation freshness & consolidation (reconcile `docs/` with code) | Not started |
## Tier 1 — Infrastructure gaps
### 1. Gitea Actions CI
No `.gitea/` / `.github/` / Woodpecker workflows exist, despite 768 tests.
Add a workflow running `cargo fmt --check`, `cargo clippy -D warnings`,
`cargo build`/`cargo test`, and REUSE lint on push + pull_request.
System deps: `pkg-config cmake libopus-dev libasound2-dev libsoapysdr-dev`
(the `soapysdr` backend is a default feature).
Notes for the runner: assumes an `act_runner` registered with an
`ubuntu-latest` label and GitHub-action proxying enabled (used by
`actions/checkout`, `actions/cache`, `fsfe/reuse-action`). `clippy` runs
with `-D warnings`; core crates are already clean, but if the first full
`--all-features` run surfaces warnings in a less-travelled crate, fix them
(preferred) or temporarily soften that step.
### 2. Supply-chain & lint governance
No `deny.toml`, `cargo audit`, `rustfmt.toml`/`clippy.toml`, declared MSRV,
or `[workspace.lints]`. Add dependency auditing to CI, pin an MSRV
(`rust-version`), and centralize lint policy in the workspace manifest.
### 3. Release & deployment
No `Dockerfile`, systemd units, packaging, or release automation (only
`script/dummy-server.sh`). Add a container image, example systemd units for
`trx-server`/`trx-client`, and a tag-triggered static-binary release job.
## Tier 2 — Robustness & correctness
### 4. Panic-resilience audit
495 `unwrap()`/`expect()`/`panic!` sites, 11 in the hottest server files
(`audio.rs`, `rig_task.rs`). A panic there can drop a rig task or the
process. Convert hot-path panics to error propagation / graceful
degradation; reserve `expect` for documented invariants.
### 5. `unsafe` SIMD safety scaffolding
13 `unsafe` blocks (AVX2 DSP). Add `# Safety` docs stating invariants,
confirm runtime feature detection is tested, and add property tests
asserting SIMD output matches the scalar fallback across random inputs.
### 6. DSP performance benchmarks
`docs/Optimization-Guidelines.md` documents NCO/polyphase/AVX2 gains, but no
`criterion` benches guard them. Add benches for the demod/resample/FFT hot
paths so regressions surface as numbers.
### 7. Test-coverage measurement & gap-filling
67 of 146 Rust files have no test module. Protocol is well covered; backends
(CAT BCD/ASCII encoding), `listener.rs`, and `config.rs` look thin. Wire up
`cargo-llvm-cov` and target the CAT backends and server tasks first.
## Tier 3 — Product & maintainability
### 8. Frontend modularization & tooling
`app.js` is 8,760 lines and `map-core.js` 3,515, with no modules, linter, or
tests. Keeping vanilla HTML+JS (no framework), split into native ES modules
by concern, add ESLint + Prettier, and add `node:test` unit tests for pure
logic (frequency formatting, unit math, decode parsing).
### 9. Runtime observability
`tracing` is set up, but there is no `/health` endpoint or metrics
instrumentation. Add health/readiness endpoints and Prometheus-format
metrics (decode rates, reconnects, audio underruns, per-rig state).
### 10. Documentation freshness & consolidation
`docs/` (13 files) is mostly dated 2026-03-29 while code moved into July, and
mixes planning artifacts with reference docs. Reconcile against current code,
separate "plans" from "reference," and fold still-true content into the
canonical docs.
+68
View File
@@ -0,0 +1,68 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
# trx-rs SDK / build image.
#
# Single source of truth for the build environment. Used two ways:
# * CI — as the job container for the lint/test jobs (Docker executor).
# * Dev — run locally or via .devcontainer for a reproducible toolchain.
#
# Pinning the Rust version here (and in rust-toolchain.toml) means CI and every
# developer share the exact same rustc/clippy, so "works locally, fails in CI"
# cannot happen.
FROM docker.io/library/debian:bookworm-slim
# Keep in sync with rust-toolchain.toml.
ARG RUST_VERSION=1.97.1
ARG NODE_MAJOR=20
ENV DEBIAN_FRONTEND=noninteractive \
RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin
# Build dependencies (mirror README's manual instructions).
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git \
build-essential pkg-config cmake clang libclang-dev \
libopus-dev libasound2-dev libsoapysdr-dev chromium \
&& rm -rf /var/lib/apt/lists/*
# Node.js — JS-based actions (actions/checkout, actions/cache) run *inside*
# the job container under the Docker executor, so node must be present.
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Pinned Rust toolchain, installed world-readable so any UID the runner or a
# devcontainer uses can invoke cargo.
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path \
--default-toolchain "${RUST_VERSION}" --profile minimal \
--component rustfmt --component clippy \
&& chmod -R a+rwX "$RUSTUP_HOME" "$CARGO_HOME"
# sccache — shared compilation cache. Enabled at build time via
# RUSTC_WRAPPER (see the CI workflow and .devcontainer), not repo-wide, so
# non-SDK builds are unaffected. musl build is static and runs anywhere.
#
# The release asset is per-architecture, so resolve it from `uname -m` rather
# than hardcoding one triple: everything else in this image is arch-agnostic,
# and a pinned x86_64 URL is what forces an amd64 build (and Rosetta or qemu)
# on an arm64 host. `uname -m` reflects the build platform under plain
# docker/podman build as well as buildx, unlike the BuildKit-only TARGETARCH.
ARG SCCACHE_VERSION=0.8.2
RUN set -eux; \
case "$(uname -m)" in \
x86_64) sccache_arch=x86_64 ;; \
aarch64|arm64) sccache_arch=aarch64 ;; \
*) echo "unsupported architecture for sccache: $(uname -m)" >&2; exit 1 ;; \
esac; \
sccache_dist="sccache-v${SCCACHE_VERSION}-${sccache_arch}-unknown-linux-musl"; \
curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/${sccache_dist}.tar.gz" \
| tar -xz -C /tmp; \
install -m755 "/tmp/${sccache_dist}/sccache" /usr/local/bin/sccache; \
rm -rf /tmp/sccache-*
WORKDIR /work
+193
View File
@@ -0,0 +1,193 @@
<!--
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later
-->
# trx-rs SDK image
A single container image that is the canonical build environment for trx-rs,
used **both** by CI and by developers. It bakes in the pinned Rust toolchain
(matching `rust-toolchain.toml`) and every build dependency, so the compiler
and `clippy` are identical everywhere — no "works on my machine".
| File | Purpose |
|------|---------|
| `Containerfile` | The SDK image (Debian + build deps + pinned Rust + Node + git). |
| `runner-config.example.yaml` | Example act_runner config for the CI VM (Docker executor). |
## Build and publish
Nothing in the image is architecture-specific: the base image, the Debian build
dependencies, Node.js, `rustup` and the `sccache` release all resolve per
architecture, so the same `Containerfile` builds natively on x86_64 and arm64.
Single architecture — the tag then only works on the architecture you built it
on:
```bash
# from the repo root
podman build -t git.haxx.space/sjg/trx-rs/sdk:latest container
podman login git.haxx.space
podman push git.haxx.space/sjg/trx-rs/sdk:latest
```
**Both architectures without emulation.** The CI runner is x86_64 and Apple
Silicon developer machines are arm64, so `:latest` has to be a manifest list —
a single-architecture tag makes the other side fall back to Rosetta or qemu.
Build each half natively on a host of that architecture, then join them:
```bash
# on an x86_64 host
podman build --platform linux/amd64 -t git.haxx.space/sjg/trx-rs/sdk:latest-amd64 container
podman push git.haxx.space/sjg/trx-rs/sdk:latest-amd64
# on an arm64 host
podman build --platform linux/arm64 -t git.haxx.space/sjg/trx-rs/sdk:latest-arm64 container
podman push git.haxx.space/sjg/trx-rs/sdk:latest-arm64
# from either, once both are pushed
podman manifest create git.haxx.space/sjg/trx-rs/sdk:latest \
git.haxx.space/sjg/trx-rs/sdk:latest-amd64 \
git.haxx.space/sjg/trx-rs/sdk:latest-arm64
podman manifest push --all git.haxx.space/sjg/trx-rs/sdk:latest
```
Building both from one machine is a single command
(`podman build --platform linux/amd64,linux/arm64 --manifest ...`), but the
foreign half runs under emulation and is slow — the two-host flow above is
what keeps every build native.
Tag with the Rust version too (e.g. `:1.97.1`) if you want reproducible pins.
Make the package **public** (Gitea → Packages → the image → Settings) so the CI
runner and developers can pull it without credentials. If you keep it private,
add `credentials:` under the workflow's `container:` and log the runner into the
registry.
Pushing a rebuilt image is not enough on its own: `:latest` is a moving tag, and
act_runner reuses whatever it cached the first time unless `force_pull: true` is
set (see `runner-config.example.yaml`). Without it the job log says
`Image exists? true` and the run behaves as though the image were never
rebuilt — a tool added to the `Containerfile` reads as missing from the image.
Either set `force_pull`, or refresh the VM's copy by hand:
```bash
docker pull git.haxx.space/sjg/trx-rs/sdk:latest
docker run --rm git.haxx.space/sjg/trx-rs/sdk:latest sccache --version
```
### macOS note
Apple's `container` CLI builds through a BuildKit helper VM that is configured
with Rosetta whether or not the target is x86_64, so `container build` fails
with *"Rosetta is not installed"* on a clean machine. That is a property of the
builder, not of this image — `container run` works natively without it. Either
install Rosetta once (`softwareupdate --install-rosetta`, after which an arm64
build still produces a native arm64 image), or build with Podman, whose arm64
BuildKit needs no emulation.
## Developer use
Reproducible one-off build, no local toolchain needed:
```bash
podman run --rm -it -v "$PWD":/work -w /work \
git.haxx.space/sjg/trx-rs/sdk:latest \
cargo build --release
```
Or open the repo in the image via VS Code / JetBrains "Reopen in Container"
(`.devcontainer/devcontainer.json` points at the same image).
Building outside the container? `rust-toolchain.toml` pins the same rustc, so
`rustup` installs the matching toolchain automatically.
## CI use
`.gitea/workflows/ci.yml` runs the `lint`, `test` and `frontend` jobs *inside*
this image via the `container:` key, so they skip all setup and go straight to
`cargo` and `npm`. The frontend job needs three things from the image beyond
Rust: Node.js for the toolchain, Chromium at `/usr/bin/chromium` for the
browser smoke test, and `cargo``npm run verify-generated` regenerates the
Rust wire contracts before checking for drift.
The `reuse` job stays on the upstream `fsfe/reuse-action` (a Docker action the
Docker executor launches as a sibling container) — nothing REUSE-related is
baked into the SDK, and it lints the whole repository, so no job runs its own
licence check.
## Compilation cache (sccache)
The SDK image ships [`sccache`](https://github.com/mozilla/sccache). It is
enabled via `RUSTC_WRAPPER=sccache` in CI and the devcontainer (not repo-wide,
so plain `cargo` builds outside the SDK are unaffected).
- **CI** persists the cache on the runner host — create the dir once:
`mkdir -p /var/cache/sccache`. It is bind-mounted into each job container at
`/sccache` (see `runner-config.example.yaml`), so cache survives across runs
and is shared between the lint/test jobs and both projects.
- **Devcontainer** uses a named volume (`trx-rs-sccache`).
- Check effectiveness with `sccache --show-stats` (the CI jobs print it).
`CARGO_INCREMENTAL=0` is set wherever sccache is on, since sccache cannot cache
incremental artifacts.
## CI runner (Alpine / OpenRC)
The runner uses the **Docker executor** (not the host executor): per-job
container isolation and standard `ubuntu-latest` semantics. `act_runner` runs
as an OpenRC service. Files provided:
| File | Purpose |
|------|---------|
| `act_runner.openrc` | OpenRC init script (`supervise-daemon`, depends on docker). |
| `act_runner.confd.example` | Per-instance `conf.d` settings for multi-runner hosts. |
**Cap the thread budget.** In a VM, pin its vCPUs to specific host threads
(libvirt/KVM):
```xml
<vcpu placement='static'>2</vcpu>
<cputune>
<vcpupin vcpu='0' cpuset='4'/>
<vcpupin vcpu='1' cpuset='5'/>
</cputune>
```
On bare metal, the `container.options: "--cpus=2"` and `capacity: 1` in
`runner-config.example.yaml` already bound each runner.
**Set it up:**
```bash
# 1. Docker + a dedicated user with socket access
apk add docker docker-cli
rc-update add docker default && rc-service docker start
adduser -S -D -H -h /var/lib/act_runner act
addgroup act docker
# 2. act_runner binary (static Go build, works on musl)
# Upstream publishes per-architecture builds; pick the host's.
case "$(uname -m)" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac
curl -fsSL -o /usr/local/bin/act_runner \
"https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-${arch}"
chmod +x /usr/local/bin/act_runner
# 3. Config + register one runner per project (scope keeps their jobs apart)
install -Dm644 container/runner-config.example.yaml /etc/act_runner/trx-rs.yaml
install -d -o act /var/lib/act_runner/trx-rs
su act -s /bin/sh -c 'cd /var/lib/act_runner/trx-rs && \
act_runner register --no-interactive \
--instance https://git.haxx.space --token <TOKEN> \
--name trx-rs-ci \
--labels "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"'
# 4. OpenRC service (repeat the symlink+conf.d for the second project)
install -m755 container/act_runner.openrc /etc/init.d/act_runner
ln -s act_runner /etc/init.d/act_runner.trx-rs
install -m644 container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
rc-update add act_runner.trx-rs default
rc-service act_runner.trx-rs start
```
Check it with `rc-service act_runner.trx-rs status` and
`tail -f /var/log/act_runner.trx-rs.log`.
+14
View File
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Per-instance settings for an act_runner OpenRC service.
# Copy to /etc/conf.d/<service-name>, e.g. /etc/conf.d/act_runner.trx-rs
# (the name must match the /etc/init.d/ symlink).
# User that runs the daemon. Must be a member of the `docker` group.
runner_user="act"
# Per-instance state dir (holds the .runner registration) and config file,
# so two runners on one host stay independent.
runner_dir="/var/lib/act_runner/trx-rs"
runner_config="/etc/act_runner/trx-rs.yaml"
+44
View File
@@ -0,0 +1,44 @@
#!/sbin/openrc-run
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
# SPDX-License-Identifier: GPL-2.0-or-later
#
# OpenRC service for a Gitea act_runner (Docker executor) on Alpine.
#
# Install as /etc/init.d/act_runner (chmod +x). Single instance uses
# /etc/act_runner/config.yaml. For one runner per project, symlink this script
# and add a matching conf.d file:
#
# ln -s act_runner /etc/init.d/act_runner.trx-rs
# cp container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
# $EDITOR /etc/conf.d/act_runner.trx-rs # set runner_dir / runner_config
# rc-update add act_runner.trx-rs default
# rc-service act_runner.trx-rs start
description="Gitea Actions runner"
: "${runner_user:=act}"
: "${runner_dir:=/var/lib/act_runner}"
: "${runner_config:=/etc/act_runner/config.yaml}"
command="/usr/local/bin/act_runner"
command_args="daemon --config ${runner_config}"
# No group given, so supplementary groups (incl. docker) are initialised.
command_user="${runner_user}"
directory="${runner_dir}"
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/${RC_SVCNAME}.log"
error_log="/var/log/${RC_SVCNAME}.log"
depend() {
need docker
use net dns
}
start_pre() {
checkpath -d -m 0750 -o "${runner_user}" "${runner_dir}"
checkpath -f -m 0640 -o "${runner_user}" "${output_log}"
}
+47
View File
@@ -0,0 +1,47 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Example act_runner config for the Docker-executor runner that lives in the
# CI VM. This is NOT the SDK image — it configures the runner that launches
# per-job containers (including the trx-rs SDK image referenced by the
# workflow's `container:` key). Copy to the VM and pass with
# `act_runner daemon --config`.
log:
level: info
runner:
file: .runner
# One concurrent job. With one runner per project on a 2-vCPU VM this keeps
# total CI usage at ~2 threads.
capacity: 1
timeout: 3h
# Docker executor: no ":host" suffix. Maps runs-on labels to base images
# (the workflow overrides these per job via `container:`).
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
cache:
enabled: true
container:
# Cap every job container's CPU so CI stays within the 2-thread budget even
# if capacity is raised later. The -v mount persists the sccache cache on the
# host (create it first: `mkdir -p /var/cache/sccache`), matching SCCACHE_DIR
# in the workflow.
options: "--cpus=2 -v /var/cache/sccache:/sccache"
# act_runner rejects every bind mount unless it is listed here — the default
# is an empty allowlist, so the -v above is dropped with only a
# "[...] is not a valid volume, will be ignored" line in the job log, and
# SCCACHE_DIR then points at a directory that does not outlive the job.
valid_volumes:
- /var/cache/sccache
# Reuse the host VM's Docker network for the built-in cache/artifact server.
network: "host"
# The workflow pulls the SDK image by the moving `:latest` tag. Without this
# the runner logs "Image exists? true" and reuses whatever it cached the
# first time, so pushing a rebuilt image has no effect until someone pulls
# on the VM by hand — which looks like the image is missing a tool it in
# fact has. The extra registry round-trip per job is nothing next to a build.
force_pull: true
Submodule docs deleted from c98dc5ab75
+119 -68
View File
@@ -1,4 +1,4 @@
# trx-rs Code Design & Architecture Overview
# trx-rs Architecture
## Table of Contents
@@ -52,42 +52,34 @@ Target users are amateur radio operators who want networked, automated, or multi
| CAT serial | tokio-serial |
| CLI | clap |
| Logging | tracing / tracing-subscriber |
| FT8 decode | ft8_lib (external C library via FFI) |
| FTx decode | trx-ftx (pure Rust) |
---
## High-Level Architecture
```
┌──────────────────────────────────────────────────────────┐
│ trx-server │
│ │
│ Radio Hardware (serial/TCP) │
│ ↕ CAT protocol │
│ Rig Backend ──────── rig_task.rs ─── listener.rs │
│ (ft817/ft450d/sdr) (state machine) (JSON TCP :4530) │
│ │ │
│ audio.rs │
│ (Opus :4531) │
│ │ │
│ Decoders │
│ (APRS, CW, FT8, WSPR, RDS) │
│ PSKReporter / APRS-IS │
└──────────────────────────────────────────────────────────┘
↕ JSON TCP (port 4530)
↕ Opus audio TCP (port 4531)
┌──────────────────────────────────────────────────────────┐
│ trx-client │
│ │
│ remote_client.rs (polls state, routes commands) │
│ ↕ internal mpsc/watch channels │
│ Frontends: │
│ trx-frontend-http (Web UI :8080) │
│ trx-frontend-rigctl (rigctl :4532) │
│ trx-frontend-http-json (JSON/TCP ephemeral) │
└──────────────────────────────────────────────────────────┘
↕ Browser / Hamlib / Custom tools
End Users
```mermaid
graph TD
subgraph server["trx-server"]
HW["Radio Hardware"] <-->|"CAT protocol<br/>serial / TCP"| Backend["Rig Backend<br/>(ft817 / ft450d / sdr)"]
Backend --> RigTask["rig_task.rs<br/>(state machine)"]
RigTask --> Listener["listener.rs<br/>(JSON TCP :4530)"]
RigTask --> Audio["audio.rs<br/>(Opus :4531)"]
Audio --> Decoders["Decoders<br/>(APRS, CW, FT8, WSPR, RDS)"]
Decoders --> Uplinks["PSKReporter / APRS-IS"]
end
subgraph client["trx-client"]
Remote["remote_client.rs<br/>(polls state, routes commands)"]
Remote <-->|"mpsc / watch channels"| HTTP["trx-frontend-http<br/>(Web UI :8080)"]
Remote <-->|"mpsc / watch channels"| Rigctl["trx-frontend-rigctl<br/>(rigctl :4532)"]
Remote <-->|"mpsc / watch channels"| JSON["trx-frontend-http-json<br/>(JSON/TCP)"]
end
Listener <-->|"JSON TCP :4530"| Remote
Audio -->|"Opus TCP :4531"| Remote
HTTP & Rigctl & JSON <--> Users["End Users<br/>(Browser / Hamlib / Custom tools)"]
```
The server and client are separate binaries. They communicate over **JSON-over-TCP** (control) and **Opus-encoded TCP** (audio). Both binaries can load shared-library plugins at startup.
@@ -99,7 +91,6 @@ The server and client are separate binaries. They communicate over **JSON-over-T
```
trx-rs/ # Workspace root
├── Cargo.toml # Workspace manifest (shared dependencies)
├── CLAUDE.md # Contributor notes
└── src/
├── trx-core/ # Core types, traits, state machine
@@ -144,7 +135,7 @@ trx-rs/ # Workspace root
└── decoders/
├── trx-aprs/ # APRS packet decoder
├── trx-cw/ # CW / Morse decoder
├── trx-ft8/ # FT8 decoder (wraps ft8_lib C library)
├── trx-ftx/ # Pure Rust FTx decoder (FT8/FT4/FT2)
├── trx-wspr/ # WSPR beacon decoder
├── trx-rds/ # FM RDS decoder
└── trx-decode-log/ # JSON Lines log rotation for decoded frames
@@ -301,10 +292,10 @@ pub trait RetryPolicy: Send {
}
pub struct ExponentialBackoff {
initial_delay: Duration,
max_attempts: u32,
base_delay: Duration,
max_delay: Duration,
multiplier: f64,
current_delay: Duration,
// Delays include ±25% randomized jitter to prevent thundering herd
}
pub trait PollingPolicy: Send {
@@ -517,7 +508,7 @@ impl RegistrationContext {
Built-in registrations (via `register_builtin_backends_on`):
- `"ft817"``Ft817::new`
- `"ft450d"``Ft450d::new`
- `"soapysdr"``SoapySdrRig::new_with_config` (if `soapysdr` feature enabled)
- `"soapysdr"``SoapySdrRig::new_from_config(SoapySdrConfig { ... })` (if `soapysdr` feature enabled)
### RigCat Trait (from trx-core)
@@ -569,8 +560,6 @@ pub struct SoapySdrRig {
}
```
**Known limitation:** IQ sample streaming (`real_iq_source.rs:149157`) is not yet implemented — the IQ source currently returns zero buffers. The soapysdr 0.3 crate lacks streaming APIs; direct `soapysdr-sys` FFI or a crate upgrade would be required.
---
## Client (trx-client)
@@ -658,32 +647,8 @@ Built on **Actix-web**, serves a browser-based control panel.
| GET | `/audio` | WebSocket audio stream |
| GET | `/favicon.png` | Static asset |
`/status` response includes a `FrontendMeta` block:
```rust
struct FrontendMeta {
http_clients: usize,
rigctl_clients: usize,
rigctl_addr: Option<String>,
active_rig_id: Option<String>,
rig_ids: Vec<String>,
owner_callsign: Option<String>,
show_sdr_gain_control: bool,
}
```
**Web UI features:** frequency display/entry, mode selector, PTT indicator, S-meter/TX-power/SWR meters, decoder toggles, decode history, spectrum waterfall (SDR), rig picker (multi-rig).
**Modules:**
| File | Responsibility |
|------|---------------|
| `server.rs` | Actix app builder, middleware, CORS |
| `api.rs` | REST handler functions |
| `audio.rs` | WebSocket ↔ PCM audio bridge |
| `auth.rs` | Token or basic-auth middleware |
| `status.rs` | State formatting for JSON responses |
### Rigctl Frontend (`trx-frontend-rigctl/`)
Hamlib-compatible plaintext TCP interface on port 4532. Allows WSJT-X, JS8Call, and other Hamlib-aware applications to control the rig without modification.
@@ -704,14 +669,14 @@ All decoders run as background Tokio tasks inside `trx-server`. They subscribe t
|-------|---------|-------|
| `trx-aprs` | APRS (AX.25) | Forwards to APRS-IS if enabled |
| `trx-cw` | CW / Morse | Auto WPM detection |
| `trx-ft8` | FT8 | Wraps `external/ft8_lib` C library via FFI; posts to PSKReporter |
| `trx-ftx` | FTx | Pure Rust FT8/FT4/FT2 decoder; posts to PSKReporter |
| `trx-wspr` | WSPR beacons | Posts to PSKReporter |
| `trx-rds` | FM RDS | Station name, radiotext, time |
| `trx-decode-log` | Logging infrastructure | JSON Lines, date-rotated files |
Control commands (e.g., `SetAprsDecodeEnabled(bool)`, `ResetCwDecoder`) are routed through `rig_task.rs` to the active decoder tasks.
Decoded events are multiplexed onto the audio stream wire protocol (`0x030x06` frame types) and also buffered in `DecoderHistories` for replay to newly connected clients.
Decoded events are multiplexed onto the audio stream wire protocol (`0x03``0x06` frame types) and also buffered in `DecoderHistories` for replay to newly connected clients.
---
@@ -746,7 +711,7 @@ Decoders / Audio server
WFM demodulator supports:
- Stereo pilot detection and L+R/LR matrix decoding
- Configurable de-emphasis time constant (50 µs EU / 75 µs US)
- Configurable de-emphasis time constant (50 us EU / 75 us US)
- Optional noise reduction
### Spectrum (`spectrum.rs`)
@@ -1033,4 +998,90 @@ stream decoder messages HTTP WebSocket / local speakers
---
*Generated from source as of commit `56d6d12` (March 2026).*
## Detailed Component Notes
### Rig Task Internals (`rig_task.rs` — 1,315 lines)
The rig task is the heart of the server. Key implementation details:
- **Command batching**: Accumulates pending requests before processing sequentially in FIFO order.
- **Spectrum deduplication**: Concurrent `GetSpectrum` requests are collapsed — one DSP computation broadcasts to all waiting responders.
- **Adaptive polling**: Poll interval adjusts based on TX state (100ms during TX, 500ms idle).
- **Grace period**: 800ms pause on polling after power-on/off operations to let hardware settle.
- **VFO priming**: Optional initialization sequence that toggles VFO A/B to populate the state cache.
- **Per-rig decoder histories**: Each rig maintains independent `Arc<DecoderHistories>` for all 11 decoder types.
- **Configurable timeouts**: `command_exec_timeout` (default 10s) and `poll_refresh_timeout` (default 8s) are configurable via `RigTaskConfig` and the TOML `[timeouts]` section.
- **Crash recovery**: Rig tasks are monitored; on crash, an `Error` state is broadcast to clients via the watch channel so they see the failure instead of silent timeout.
### Audio Pipeline (`audio.rs` — 3,977 lines)
The audio module handles decoder history storage and stream management:
- **`DecoderHistories`**: Per-rig mutable store for 11 decoder history queues (AIS, VDES, APRS, HF_APRS, CW, FT8, FT4, FT2, WSPR, WXSAT, LRPT).
- **Time-based retention**: 24h TTL on all history with periodic pruning.
- **Capacity bounds**: Per-decoder max of 10,000 entries (`MAX_HISTORY_ENTRIES`) prevents unbounded memory growth on busy channels.
- **Atomic total count**: `AtomicUsize` with CAS loop avoids acquiring 11 mutex locks in `snapshot_all()`.
- **Lock poisoning recovery with logging**: Uses `lock_or_recover()` helper that logs a warning when recovering from a poisoned mutex.
- **`StreamErrorLogger`**: Suppresses duplicate stream errors with 60s periodic summaries and error classification (alsa_poll_failure, input/output_stream_error).
- **Device enumeration helpers**: `find_input_device()` and `find_output_device()` extract the repeated device lookup logic from `run_capture()`/`run_playback()`.
- **CRC filtering**: APRS records filtered by `crc_ok` before storage.
### Remote Client Dual-Connection Model
`remote_client.rs` maintains two independent TCP connections to the server:
1. **Main connection** (port 4530): State polling, command forwarding, rig discovery.
2. **Spectrum connection** (dedicated): Polls `GetSpectrum` at 50ms intervals (20 fps) independently to avoid blocking the main connection during command processing.
Constants: `CONNECT_TIMEOUT: 5s`, `IO_TIMEOUT: 15s`, `SPECTRUM_IO_TIMEOUT: 3s`. Exponential backoff with jitter on reconnect.
### FrontendRuntimeContext Sub-Structs
The `FrontendRuntimeContext` struct in `trx-frontend/src/lib.rs` is decomposed into coherent sub-structs:
| Sub-struct | Purpose | Key fields |
|-----------|---------|------------|
| `AudioContext` | Audio streaming channels | `rx`, `tx`, `info`, `decode_rx`, `clients` |
| `DecodeHistoryContext` | Decode history for all types | `ais`, `vdes`, `aprs`, `hf_aprs`, `cw`, `ft8`, `ft4`, `ft2`, `wspr` |
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `rx_passphrase`, `session_ttl_secs`, `tokens` |
| `HttpUiConfig` | HTTP UI display config | `show_sdr_gain_control`, `initial_map_zoom`, `spectrum_*` |
| `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` |
| `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` |
| `VChanContext` | Virtual channel audio | `audio`, `audio_cmd`, `destroyed`, `rig_audio_cmd` |
| `SpectrumContext` | Spectrum data | `sender`, `per_rig` |
| `PerRigAudioContext` | Per-rig audio channels | `rx`, `info` |
### Decoder Implementation Patterns
All real-time decoders follow a consistent pattern:
```rust
// 1. Stateful decoder struct with sample buffer
pub struct XxxDecoder { sample_buf: Vec<f32>, ... }
// 2. Block/sample processing
pub fn process_block(&mut self, samples: &[f32]) { ... }
// 3. Result extraction
pub fn decode_if_ready(&mut self) -> Vec<XxxResult> { ... }
```
| Decoder | Algorithm | Sample Rate | Key Constants |
|---------|-----------|-------------|---------------|
| FT8/FT4/FT2 | Waterfall + LDPC/OSD | Varies | MAX_LDPC_ITERATIONS=20, MAX_CANDIDATES=120 |
| CW | Goertzel tone detection | Varies | 10ms windows, tone range 3001200 Hz |
| APRS | Bell 202 AFSK (1200/2200 Hz) | 9600 | HDLC framing, NRZI, CRC-16-CCITT |
| AIS | GMSK 9600 baud | 9600 | Narrowband FM input |
| WSPR | Fano decoder | 12000 | 162 symbols, 120s slot, 1.46 Hz spacing |
| RDS | RRC matched filter + Costas PLL | Native | 57 kHz subcarrier, 1187.5 bps, OSD FEC |
| VDES | pi/4-QPSK 76.8 ksps | 100k | Burst detection, partial Turbo FEC |
### Backend Reliability Workarounds (FT-817)
The FT-817 CAT backend (`trx-backend-ft817/`) includes empirical workarounds for hardware quirks:
- **Duplicate frame sends**: `set_mode()` and `set_ptt()` send CAT frames twice with 80ms delay (radio sometimes drops first frame).
- **Panel unlock before commands**: Clears stale bytes from the serial buffer.
- **Power-on dummy frame**: CPU wakes before CAT framing locks; dummy frame ensures readiness.
- **VFO state inference**: Infers VFO A/B by matching frequencies against cached values (fragile when frequencies collide).
- **Read timeout**: 800ms per CAT read operation (not configurable).
+14
View File
@@ -0,0 +1,14 @@
# trx-rs
`trx-rs` is a modular amateur radio control stack written in Rust. It splits
hardware access, DSP, transport, and user-facing interfaces into separate
components so a radio or SDR can be controlled locally while audio, decoding,
and remote control are exposed elsewhere on the network.
## Documentation
- [User Manual](User-Manual) — configuration, features, and usage
- [Architecture](Architecture) — system design, crate layout, data flow, and internals
- [Optimization Guidelines](Optimization-Guidelines) — performance guidelines for the real-time DSP pipeline
- [Planned Features](Planned-Features) — planned features and design notes
- [Improvement Areas](Improvement-Areas) — codebase audit: quality, architecture, security, performance, and improvement plan
+211
View File
@@ -0,0 +1,211 @@
# Improvement Areas
A comprehensive audit of the trx-rs codebase covering code quality, architecture,
security, testing, and performance. Each item includes the affected location and
a suggested fix.
*Last updated: 2026-03-29*
---
## Resolved Items
<details>
<summary>Click to expand resolved items from previous audits</summary>
### Plugin signing and cross-platform validation — DROPPED
Plugin system has been removed from the codebase. No longer applicable.
### Session store mutex poisoning (auth.rs) — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs`
All 6 `.write().unwrap()` / `.lock().unwrap()` calls replaced with
`.unwrap_or_else(|e| { warn!(...); e.into_inner() })` pattern. Lock poisoning now
logs a warning and recovers the inner data instead of crashing.
### No rate limiting on TCP listener — RESOLVED
**Location:** `src/trx-server/src/listener.rs`
Added `ConnectionTracker` with per-IP connection limiting (default: 10 concurrent
connections per IP). Connections exceeding the limit are rejected with a log warning.
Slots are released when clients disconnect.
### RigState is a 33-field flat struct — RESOLVED
**Location:** `src/trx-core/src/rig/state.rs`
Decoder fields grouped into `DecoderConfig` (8 bools) and `DecoderResetSeqs`
(8 u64 counters). Both use `#[serde(flatten)]` for backward-compatible JSON wire
format. Updated across all consumers.
### No `spawn_blocking` timeout — RESOLVED
**Location:** `src/trx-server/src/listener.rs`
Satellite pass computation wrapped in `tokio::time::timeout(30s, ...)` with
graceful fallback to empty results on timeout or panic.
### Command handler boilerplate — RESOLVED
**Location:** `src/trx-core/src/rig/controller/handlers.rs`
Created `rig_command!` declarative macro. 7 unit commands use the macro; 4 commands
with custom fields/validation remain as explicit impls.
### No command execution timeouts at CommandExecutor level — RESOLVED
**Location:** `src/trx-server/src/rig_task.rs`
`tokio::time::timeout(command_exec_timeout, process_command(...))` wraps all
command execution. Default timeout: 10s, configurable via `RigTaskConfig`.
### No forward compatibility in protocol — RESOLVED
**Location:** `src/trx-protocol/src/types.rs`, `src/trx-protocol/src/codec.rs`
Added optional `protocol_version: Option<u32>` to `ClientEnvelope` and
`ClientResponse`. `parse_envelope()` distinguishes malformed JSON from
unrecognised `cmd` values.
### `unsafe` string construction in spectrum encoding — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/api.rs`
Replaced `unsafe { String::from_utf8_unchecked(out) }` with safe
`String::from_utf8(out).expect(...)`.
### `#[allow(dead_code)]` cleanup — RESOLVED
Reduced from 6 to 4 annotations, all in trx-backend-soapysdr where fields serve
as lifetime anchors (`device`, `iq_tx`) or document reserved capacity
(`fixed_slot_count`, `process_pair`).
### VDES decoder incomplete FEC — RESOLVED
Turbo FEC decoder, CRC-16-CCITT validation, and M.2092-1 link-layer frame parsing
implemented.
### Plugin system lacks versioning — DROPPED
Plugin system removed from the codebase.
### Configurator serial detection stubbed — RESOLVED
Implemented using `tokio_serial::available_ports()` with USB, Bluetooth, PCI, and
Unknown port type descriptions.
### Inconsistent frequency/rig naming — DOCUMENTED AS INTENTIONAL
Field names reflect distinct semantic contexts: `freq_hz` (dial), `center_hz`
(SDR capture center), `cw_center_hz` (CW tone); `rig_id` (config key), `id`
(runtime UUID); `model` (hardware string), `rig_model` (config parameter).
### Decoder task duplication in audio.rs — RESOLVED
**Location:** `src/trx-server/src/audio.rs`
APRS and HF APRS decoders merged into a single parameterised
`run_aprs_decoder_inner()` function. FT8 and FT4 decoders merged into
`run_ftx_decoder_inner()`. All decoder tasks now include `tracing::info_span!`
around `block_in_place()` calls for opt-in latency measurement.
### Missing tests for critical modules — RESOLVED
**Location:** `src/trx-server/src/listener.rs`, `src/trx-client/trx-frontend/trx-frontend-http/`
Added multi-rig state isolation and command routing tests in `listener.rs`.
Added background decode `evaluate_bookmark` pure-function tests.
### Missing integration tests for multi-rig scenarios — RESOLVED
**Location:** `src/trx-server/src/listener.rs`
Added integration tests covering simultaneous state management across two rigs
with a dummy backend, verifying state isolation and command routing.
### Decode log silent failures — RESOLVED
**Location:** `src/decoders/trx-decode-log/src/lib.rs`
`flush()` errors are now logged via `warn!`. On file rotation failure, the old
writer is kept rather than silently dropping writes; a degradation warning is
emitted.
### `api.rs` file size and organization — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/api/`
Split 2,831-LOC monolith into 7 logically grouped modules: `mod.rs` (shared
types and route configuration), `decoder.rs`, `rig.rs`, `vchan.rs`, `sse.rs`,
`bookmarks.rs`, `assets.rs`.
### Background decode state complexity — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/background_decode.rs`
Extracted the 8-guard decision cascade into a pure `evaluate_bookmark()` function
returning `ChannelAction` enum (`Active` or `Skip { reason }`). Added unit tests
for all decision paths.
### Actix-web pinned to exact version — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml`
Relaxed from `actix-web = "=4.4.1"` to `actix-web = "4.4"` to allow patch-level
security updates.
### Magic numbers in VDES plausibility scoring — RESOLVED
**Location:** `src/decoders/trx-vdes/src/lib.rs`
Inline magic numbers replaced with documented named constants:
`PLAUSIBILITY_UNSYNCED_THRESHOLD` (35) and
`PLAUSIBILITY_LOW_CONFIDENCE_THRESHOLD` (15).
### FT-817 VFO inference fragile with same frequency — DOCUMENTED
**Location:** `src/trx-server/trx-backend/trx-backend-ft817/src/lib.rs`
When both VFOs share the same frequency, inference defaults to VFO A. Resolved
after VFO toggle primes both sides. Well-documented in code comments; remains
a known limitation.
### Excessive string cloning in remote client — RESOLVED
**Location:** `src/trx-client/src/remote_client.rs`
Hot-path spectrum polling loop now caches the token to avoid per-poll cloning.
State update path restructured to send to the main watch channel last (taking
ownership) and avoid one redundant `RigState::clone()`.
### Missing doc comments on public decoder structs — RESOLVED
**Location:** `src/decoders/trx-ais/src/lib.rs`, `src/decoders/trx-vdes/src/lib.rs`,
`src/decoders/trx-rds/src/lib.rs`
Added comprehensive doc comments to `AisDecoder`, `VdesDecoder`, and `RdsDecoder`
describing valid sample rates, usage examples, and reset semantics.
### Turbo decoder precondition not asserted — RESOLVED
**Location:** `src/decoders/trx-vdes/src/turbo.rs`
Added `debug_assert_eq!` on interleaver and deinterleaver lengths in
`turbo_decode_soft()`.
### No tracing spans for decoder performance — RESOLVED
**Location:** `src/trx-server/src/audio.rs`
Added `tracing::info_span!` around `block_in_place()` calls in all 10 decoder
tasks (APRS, HF APRS, AIS A/B, VDES, CW, FT8, FT4, FT2, WSPR, LRPT) for
opt-in per-decoder latency measurement.
</details>
---
All previous improvement items have been resolved. No outstanding issues.
@@ -1,4 +1,4 @@
# DSP Chain Performance Optimization Guidelines
# DSP Optimization Guidelines
This document captures lessons learned and best practices for optimizing
the real-time DSP pipelines in trx-rs, particularly the WFM stereo decoder
+119 -22
View File
@@ -1,10 +1,10 @@
# Recorder Feature Plan
# Planned Features
## Overview
## Recorder
This document describes the design and implementation plan for the recorder feature in trx-rs. The recorder captures the demodulated audio stream alongside associated metadata (FFT data, decoded signals, rig state) into a structured session on disk, with full playback and seeking support from within the application.
The recorder captures the demodulated audio stream alongside associated metadata (FFT data, decoded signals, rig state) into a structured session on disk, with full playback and seeking support from within the application.
## Requirements
### Requirements
| ID | Description |
|----|-------------|
@@ -20,9 +20,9 @@ This document describes the design and implementation plan for the recorder feat
---
## Architecture
### Architecture
### New Crate: `trx-recorder`
#### New Crate: `trx-recorder`
A new crate `src/trx-server/trx-recorder/` handles all record and playback logic. It is a library crate consumed by `trx-server`.
@@ -39,7 +39,7 @@ src/trx-server/
config.rs # RecorderConfig (serde, derives Default)
```
### Integration Points in `trx-server`
#### Integration Points in `trx-server`
| Source | What is tapped | How |
|--------|---------------|-----|
@@ -56,7 +56,7 @@ No existing code paths are modified beyond:
---
## Session Layout on Disk
### Session Layout on Disk
Each recording is a **session directory** named by UTC start time and opening rig state:
@@ -70,13 +70,13 @@ Each recording is a **session directory** named by UTC start time and opening ri
`output_dir` defaults to `~/.local/share/trx-rs/recordings`.
### Audio File (REQ-REC-001, REQ-REC-002, REQ-REC-003)
#### Audio File (REQ-REC-001, REQ-REC-002, REQ-REC-003)
- **Format**: Opus, using the `opus` crate (already a workspace dependency via `trx-backend-soapysdr`). Seek index (`index.bin`) provides byte → time mapping.
- **Channel count**: determined at session open from `AudioConfig.channels`. If `channels == 1` → mono; if `channels == 2` → stereo. Written into the file header and recorded in the session's first data event.
- **Sample rate**: preserved from `AudioConfig.sample_rate` (default 48 000 Hz).
### Data File (REQ-REC-004, REQ-REC-005)
#### Data File (REQ-REC-004, REQ-REC-005)
`data.jsonl` — one JSON object per line, each with a required `offset_ms` field giving the millisecond offset from session start (satisfies REQ-SYNC-001 at ≥1 s resolution):
@@ -103,7 +103,7 @@ Supported `type` values:
| `cw` | `DecodedMessage` broadcast | on decode event |
| `cursor` | `RecorderCommand::MarkCursor { label }` | on user request |
### Seek Index (REQ-PLAY-002)
#### Seek Index (REQ-PLAY-002)
`index.bin` is a flat binary table of 16-byte records written every `index_interval_ms` (default 1 000 ms):
@@ -115,7 +115,7 @@ At playback seek time, binary search on `offset_ms` locates the nearest audio fr
---
## RecorderConfig
### RecorderConfig
Added to `ServerConfig` under `[recorder]`:
@@ -131,7 +131,7 @@ max_session_duration_s = 3600 # auto-split at 1 h; 0 = unlimited
---
## Command API
### Command API
New variants added to the existing command enum (handled in `rig_task.rs`):
@@ -147,7 +147,7 @@ These are exposed via:
---
## Playback Engine (REQ-PLAY-001, REQ-PLAY-002)
### Playback Engine (REQ-PLAY-001, REQ-PLAY-002)
`PlaybackEngine` opens a session directory and:
@@ -170,7 +170,7 @@ While `PlaybackState` is not `Live`, the server suppresses live hardware polling
---
## Time Synchronisation (REQ-SYNC-001)
### Time Synchronisation (REQ-SYNC-001)
All timestamps use a single `session_epoch: std::time::Instant` captured at `StartRecording`. Every PCM frame, every data event, and every seek-index entry is stamped as `(Instant::now() - session_epoch).as_millis() as u64`. This gives sub-millisecond internal precision; the requirement of ≥1 s resolution is met by orders of magnitude.
@@ -178,16 +178,16 @@ Wall-clock UTC is embedded only in `session_start` (`wall_clock_utc`) and in the
---
## Implementation Phases
### Implementation Phases
### Phase 1 — Audio recording (REQ-REC-001, REQ-REC-002, REQ-REC-003)
#### Phase 1 — Audio recording (REQ-REC-001, REQ-REC-002, REQ-REC-003)
1. Add `trx-recorder` crate skeleton; `RecorderConfig`; `RecorderHandle`.
2. Implement `AudioWriter` with Opus output.
3. Subscribe `AudioWriter` to `pcm_tx` in `audio.rs`; open session on `StartRecording` command.
4. Auto-detect channel count from `AudioConfig.channels`.
### Phase 2 — Metadata recording (REQ-REC-004, REQ-REC-005, REQ-SYNC-001)
#### Phase 2 — Metadata recording (REQ-REC-004, REQ-REC-005, REQ-SYNC-001)
1. Implement `DataFileWriter`; define full event schema.
2. Subscribe to `DecodedMessage` broadcast; fan-in all decoder types.
@@ -195,12 +195,12 @@ Wall-clock UTC is embedded only in `session_start` (`wall_clock_utc`) and in the
4. Emit `fft` events at configured interval from spectrum data.
5. Write `SeekIndex` in parallel with audio.
### Phase 3 — Cursor (REQ-REC-006)
#### Phase 3 — Cursor (REQ-REC-006)
1. Add `MarkCursor` command + HTTP endpoint.
2. Write `cursor` event to `data.jsonl` with current `offset_ms`.
### Phase 4 — Playback (REQ-PLAY-001, REQ-PLAY-002)
#### Phase 4 — Playback (REQ-PLAY-001, REQ-PLAY-002)
1. Implement `PlaybackEngine`; Opus decode + PCM broadcast.
2. Add `PlaybackState` to `RigState`; suppress live capture during playback.
@@ -210,7 +210,7 @@ Wall-clock UTC is embedded only in `session_start` (`wall_clock_utc`) and in the
---
## Dependencies to Add
### Dependencies to Add
| Crate | Use | Already present? |
|-------|-----|-----------------|
@@ -220,8 +220,105 @@ Wall-clock UTC is embedded only in `session_start` (`wall_clock_utc`) and in the
---
## Open Questions
### Open Questions
1. **Playback isolation**: Should playback be exclusive (block all CAT commands) or concurrent? Initial design blocks CAT polling; revisit if users need to change frequency during playback.
2. **Session listing API**: The HTTP frontend needs an endpoint to enumerate sessions (`GET /api/recorder/sessions`). Schema TBD in Phase 4.
3. **Storage limits**: `max_session_duration_s` auto-splits sessions; a `max_total_size_gb` housekeeping option may be needed but is out of scope for initial phases.
---
## Configurator Helper
An interactive CLI tool that guides users through creating configuration files
for trx-rs. Instead of editing TOML by hand, the user answers prompts and the
tool generates valid, commented configuration files.
### Overview
The configurator is a standalone Rust binary (`trx-configurator`) that reuses
the existing config structs from `trx-app`, `trx-server`, and `trx-client`. It
walks the user through a question-driven flow, validates inputs against the same
rules the binaries use at startup, and writes one or more of:
- `trx-server.toml` — server configuration
- `trx-client.toml` — client configuration
- `trx-rs.toml` — combined server + client configuration
The user chooses which file(s) to generate.
### Requirements
| ID | Description |
|----|-------------|
| REQ-CFG-001 | The tool shall interactively prompt the user for configuration values. |
| REQ-CFG-002 | The tool shall generate `trx-server.toml`, `trx-client.toml`, or `trx-rs.toml` per user selection. |
| REQ-CFG-003 | The tool shall validate all inputs using the same validation logic as the server and client binaries. |
| REQ-CFG-004 | The tool shall write commented TOML with descriptions of each field. |
| REQ-CFG-005 | The tool shall detect connected serial devices and offer them for rig access configuration. |
| REQ-CFG-006 | The tool shall detect available SoapySDR devices and offer them for SDR backend configuration. |
| REQ-CFG-007 | The tool shall support a non-interactive mode that generates a default config file. |
| REQ-CFG-008 | The tool shall not overwrite existing files without confirmation. |
### Architecture
#### New Crate: `trx-configurator`
A new binary crate at `src/trx-configurator/` that depends on `trx-app` for
config types and validation.
```
src/trx-configurator/
src/
main.rs # CLI entry point, mode selection
prompts.rs # Interactive prompt helpers (with defaults, validation)
detect.rs # Hardware detection (serial ports, SoapySDR devices)
writer.rs # TOML serialisation with inline comments
```
#### Flow
```
trx-configurator
├── What would you like to generate?
│ [ ] trx-server.toml
│ [ ] trx-client.toml
│ [ ] trx-rs.toml (combined)
├── (if server)
│ ├── General: callsign, location
│ ├── Rig: model selection, access (serial/tcp/sdr)
│ │ └── detect serial ports / SoapySDR devices
│ ├── Listen: address, port
│ ├── Audio: sample rate, channels, codec settings
│ ├── SDR: (if soapysdr selected) gain, channels, decoders
│ ├── Uplinks: PSKReporter, APRS-IS
│ └── Decode logs: enable, directory
├── (if client)
│ ├── Remote: server URL, auth token
│ ├── Frontends: HTTP, rigctl, http-json (enable/disable, ports)
│ └── Audio: bridge settings
└── Write file(s) with confirmation
```
#### Hardware Detection
- **Serial ports**: enumerate available serial devices using `serialport` crate
(already a transitive dependency). Present as selectable list with device
path and description.
- **SoapySDR devices**: if built with `soapysdr` feature, call
`SoapySDR::enumerate("")` to list available SDR hardware. Present device
driver, label, and serial number.
#### Dependencies
| Crate | Use | Already present? |
|-------|-----|-----------------|
| `dialoguer` | Interactive prompts, selection, confirmation | No |
| `toml_edit` | TOML serialisation preserving comments | No |
| `trx-app` | Config types and validation | Yes |
| `serialport` | Serial port enumeration | Yes (transitive) |
| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) |
+95
View File
@@ -0,0 +1,95 @@
# RDS Parameter Tuning Notes
*Decoder tuning rationale for `trx-rds`. Recorded 2026-03-27; reflects the
shipped parameter set. Kept as a reference for why these constants were chosen —
not an open work item.*
## Goal
Maximum sensitivity (weak-signal decode) with zero false positive PI decodes.
## Changes Applied
### `src/decoders/trx-rds/src/lib.rs`
#### Constants tuned
- `RRC_ALPHA = 0.50` (was 0.75) — narrower noise bandwidth, ~0.6 dB SNR gain
- `COSTAS_KI = 3.5e-7` — loop damping ζ≈0.68, well-damped (1e-6 caused instability)
- `PI_ACC_THRESHOLD = 3` (was 2) — accumulate 3 Block A observations before committing PI
- `OSD_MAX_FLIP_COST = 0.45` — Tech 9: reject OSD corrections where flipped bits had
high confidence (genuine errors have cost ≲ 0.3; noise matches cost 0.61.2)
#### Soft confidence fix
In `Candidate::process_sample`, the soft confidence passed to `push_bit_soft` is now
`biphase_i.abs()` (was full vector magnitude). This aligns confidence with the bit
decision sign and prevents OSD(2) from false-decoding noise when the Costas loop
has residual phase error.
#### OSD(2) in locked mode (kept)
`decode_block_soft` performs OSD(2): hard decode → all 26 single-bit flips → all
325 two-bit flip pairs. Only active in locked mode; sequential B→C→D block-type
gating limits false positives.
#### Search mode: hard decode only
Removed OSD(1) from Block A acquisition (search mode). With OSD(1), ~13% of
random 26-bit words would falsely pass the Block A test per bit, allowing wrong
clock-phase candidates to accumulate false groups as fast as the correct candidate
accumulates real ones. Hard decode reduces the false Block A rate to ~0.5%.
#### Tech 9: OSD cost ceiling
`decode_block_soft` now enforces `OSD_MAX_FLIP_COST = 0.45` — the sum of soft
confidences for all flipped bits must not exceed this threshold. At 910 dB SNR,
genuine bit errors have very low `|biphase_I|` (cost ≲ 0.3), while noise-induced
OSD matches flip high-confidence bits (cost 0.61.2). This eliminates most
spurious OSD(2) matches without affecting real weak-signal corrections.
#### Tech 10: PI consistency gate
`process_group` rejects groups whose Block A PI differs from the candidate's
established PI. This prevents a single false OSD decode from polluting accumulated
text fields (PS, RT, PTYN) with garbage from noise or interference.
#### Candidate selection: incumbent tracking
Added `best_candidate_idx: Option<usize>` to `RdsDecoder`. The incumbent (winning)
candidate can always update `best_state` at equal score (its `ps_seen`/`rt_seen`
arrays accumulate coherently). A challenger must achieve a strictly higher score to
take over. The incumbent's `best_score` is also updated when it returns `None`
(no state change) so challengers cannot leapfrog with a single false group.
#### Test fixes
- `blocks_to_chips`: added NRZI (NRZ-Mark) pre-encoding. The differential biphase
decoder computes `bit = input_bit XOR prev_input_bit`; without NRZI the recovered
bits were XOR-of-consecutive-bits, not the original data.
- `decode_block_soft_rejects_three_bit_error`: removed (OSD(2) legitimately finds
distance-2 codewords; `pure_noise_produces_zero_pi_decodes` is the real guard).
- New test: `blocks_to_chips_round_trips_all_groups` — verifies round-trip decode
of all 16 blocks across all 4 PS segments without BPSK modulation.
### `src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/wfm.rs`
- `PILOT_LOCK_THRESHOLD = 0.20` (was 0.25) — pilot reference enabled at lower coherence
- Added `PILOT_LOCK_ONSET = 0.30` constant (was hardcoded 0.4)
- `pilot_lock` ramp: `((pilot_coherence - PILOT_LOCK_ONSET) / 0.2).clamp(0.0, 1.0)`
— pilot reference engages at coherence ≥ 0.36 instead of ≥ 0.45
## Test Status
```
cargo test -p trx-rds
```
16/16 passing:
- ✅ decode_block_recognizes_valid_offsets
- ✅ decode_block_soft_corrects_single_bit_error
- ✅ decode_block_soft_corrects_two_bit_error_osd2
- ✅ block_decode_rate_osd1_vs_osd2
- ✅ decode_block_soft_prefers_least_costly_flip
- ✅ full_group_with_two_bit_errors_in_each_locked_block
- ✅ pi_accumulation_corrects_weak_pi_after_threshold
- ✅ decoder_emits_ps_and_pty_from_group_0a
- ✅ rrc_tap_dc_gain
- ✅ pure_noise_produces_zero_pi_decodes (2 seconds of noise, zero false PI)
- ✅ end_to_end_with_pilot_reference_decodes_pi
- ✅ end_to_end_noisy_signal_snr_10db_decodes_pi
- ✅ end_to_end_noisy_signal_snr_9db_decodes_pi ← new, 9 dB threshold
- ✅ costas_tracks_without_diverging_on_clean_signal
- ✅ blocks_to_chips_round_trips_all_groups
- ✅ end_to_end_clean_signal_decodes_ps
+163
View File
@@ -0,0 +1,163 @@
# Settings Menu — UI/UX Analysis & Improvement Plan
*Authored: 2026-03-30*
## 1. Current Structure
The Settings tab (`#tab-settings`) contains four sub-tabs:
| Sub-tab | Purpose | Complexity |
|---|---|---|
| **Scheduler** | Grayline / Time Span / Satellite scheduling | High — nested modes, forms, timeline |
| **Background Decode** | Hidden background decoder channels | Medium — toggle + bookmark checklist |
| **Bandplan** | IARU region overlay on spectrum | Low — dropdown + checkbox |
| **History** | Clear server-side decode history | Low — 10 clear buttons |
---
## 2. Identified Issues
### 2.1 Information Architecture
| # | Issue | Severity |
|---|---|---|
| IA-1 | **"Settings" is a catch-all bucket.** Scheduler and Background Decode are operational features, not user preferences. Bandplan and History are true settings/maintenance. Mixing them under one tab creates cognitive overhead. | Medium |
| IA-2 | **Scheduler sub-tab is overloaded.** It packs three conceptually distinct features (Grayline, Time Span, Satellite) into one scrollable panel via conditional `display:none` sections. Users must scroll past irrelevant sections. | Medium |
| IA-3 | **History clearing is buried.** Users wanting to clear FT8 decode history must navigate to Settings → History — an unintuitive path. This action is more naturally accessible from the Digital Modes tab itself. | Low |
| IA-4 | **No search or categorization.** With 4 sub-tabs today, it's manageable, but the flat sub-tab bar won't scale if more settings (e.g., audio, display theme, reporting/PSKReporter, notifications) are added. | Low |
### 2.2 Interaction Design
| # | Issue | Severity |
|---|---|---|
| IX-1 | **Save button visibility is inconsistent.** Save/Reset buttons use `style="display:none"` and are shown dynamically, but there is no dirty-state indicator. Users can change fields without realizing they haven't saved. | High |
| IX-2 | **No confirmation on destructive actions.** The 10 history-clear buttons and "Reset to Disabled" (scheduler) fire immediately on click. No confirmation dialog protects against accidental data loss. | High |
| IX-3 | **Entry table details collapsed by default.** The Time Span entry table is inside a `<details>` element — users must expand it to see, edit, or delete entries. This adds an unnecessary click when entries already exist. | Medium |
| IX-4 | **Satellite form uses a modal overlay; Time Span form is inline.** Inconsistent form presentation within the same sub-tab. Both should use the same pattern. | Medium |
| IX-5 | **Toast notification positioning.** The `.sch-toast` uses `position: fixed; bottom: 1.5rem` which can overlap with the main tab bar or mobile navigation. It also disappears without user control. | Low |
| IX-6 | **Bookmark filter in Background Decode has no "select all / deselect all" shortcut.** With many bookmarks, toggling them one by one is tedious. | Medium |
### 2.3 Visual & Layout
| # | Issue | Severity |
|---|---|---|
| VL-1 | **Scheduler has no visual state summary.** The "No activity yet." card doesn't show whether the scheduler is enabled or what mode it's in at a glance. Users must inspect the mode dropdown. | Medium |
| VL-2 | **History clear buttons are uniform.** All 10 buttons look identical (`sch-write sch-reset-btn`). No indication of which decoders have data to clear. Buttons for empty histories are noise. | Low |
| VL-3 | **Mobile responsiveness is partial.** The `@media (max-width: 600px)` rules handle `.sch-row` and `.bgd-*` layout, but the Time Span table (`.sch-ts-table` with 8 columns) overflows on narrow screens. | Medium |
| VL-4 | **Sub-tab bar can overflow.** It uses `overflow-x: auto` but gives no visual scroll indicator. On small screens, the "History" tab can be hidden off-screen with no affordance. | Low |
### 2.4 Accessibility
| # | Issue | Severity |
|---|---|---|
| A-1 | **Missing `aria-label` on several controls.** The scheduler mode select has one, but the grayline lat/lon inputs, interleave fields, and satellite fields lack accessible names beyond their visible label text (which is acceptable for `<label>` wrapping `<input>`, but form titles like "Add Entry" aren't linked to the form via `aria-labelledby`). | Low |
| A-2 | **No keyboard navigation for the 24h timeline SVG.** Timeline segments are clickable (`cursor: pointer`) but not focusable or keyboard-operable. | Medium |
| A-3 | **Color-only state indication in Background Decode status.** States like "active" (green), "waiting" (yellow), "error" (red) rely solely on color. Not sufficient for color-blind users. | Medium |
| A-4 | **Toast notifications aren't announced to screen readers.** The `.sch-toast` div lacks `role="alert"` or `aria-live` attributes. | Low |
---
## 3. Improvement Plan
### Phase 1 — Quick Wins (Low effort, high impact)
```mermaid
gantt
title Phase 1 — Quick Wins
dateFormat X
axisFormat %s
section Interaction
IX-2 Add confirmation dialogs :a1, 0, 2
IX-6 Select all / deselect all :a2, 0, 1
IX-1 Dirty-state indicator on Save :a3, 0, 2
section Accessibility
A-4 Add aria-live to toasts :a4, 0, 1
A-3 Add text labels to state dots :a5, 0, 1
```
**IX-2: Add confirmation dialogs for destructive actions**
- Wrap history-clear and "Reset to Disabled" clicks in a `confirm()` dialog (or a lightweight inline confirmation pattern).
- Estimated: ~30 lines of JS.
**IX-6: Add select all / deselect all for Background Decode bookmarks**
- Add two small buttons above the bookmark checklist: "Select All" / "Deselect All".
- Alternatively, a single toggle that reads the current state.
**IX-1: Dirty-state indicator**
- Track whether any field has changed since last load/save.
- Show a visual cue (e.g., dot on the Save button, or change button color) when there are unsaved changes.
- Optionally warn on tab navigation away from dirty settings.
**A-4: Toast accessibility**
- Add `role="alert"` and `aria-live="polite"` to `.sch-toast` elements.
**A-3: State badge text labels**
- The `.bgd-status-state` already shows uppercase text — ensure the SVG dot badges (`.bgd-state-dot`) are supplemented with visible text, not just color.
---
### Phase 2 — Structural Improvements (Medium effort)
**IA-1 + IA-3: Reorganize the Settings tab**
Proposed new sub-tab structure:
| Sub-tab | Contents |
|---|---|
| **Scheduler** | Grayline, Time Span, Satellite (unchanged) |
| **Background Decode** | Background decode config (unchanged) |
| **Display** | Bandplan region/labels, future: theme, font size, spectrum colors |
| **Maintenance** | History clearing, with per-decoder item counts |
Additionally, add contextual "Clear history" links directly in the Digital Modes tab (next to each decoder's output panel), so users don't need to navigate to Settings at all for this common action.
**IX-3: Auto-expand entry table when entries exist**
- If `scheduler-ts-tbody` has rows, set the `<details>` element's `open` attribute on render.
**IX-4: Unify form presentation**
- Convert the satellite modal (`#sch-sat-form-wrap` with `position: fixed`) to an inline form matching the Time Span entry form pattern, or vice versa. Inline is preferred for consistency and mobile friendliness.
**VL-1: Scheduler status summary card**
- Enhance the "Now Playing" card to always show: current mode, active entry (if any), next scheduled event, and satellite pass countdown (if enabled).
- Use a compact two-line format when idle: "Mode: Grayline | Next: Dawn transition in 2h 14m".
**VL-3: Responsive table for Time Span entries**
- Replace the 8-column table with a card-based layout on narrow screens (`@media (max-width: 600px)`), or use horizontal scroll with a scroll shadow indicator.
**A-2: Keyboard-accessible timeline**
- Add `tabindex="0"` and `role="button"` to timeline segments.
- Handle `keydown` for Enter/Space to activate.
---
### Phase 3 — Polish & Scalability (Higher effort)
**VL-2: Smart history-clear buttons**
- Query each decoder's item count via API (or piggyback on existing SSE state).
- Show count badges on each button (e.g., "Clear FT8 history (142)").
- Disable or hide buttons for decoders with no history.
- Add a "Clear All" button with appropriate confirmation.
**IA-4: Settings search (future-proofing)**
- If the settings surface grows beyond 5-6 sub-tabs, add a search/filter input at the top of the Settings tab that highlights matching sections.
- Not needed today, but the sub-tab architecture should be designed to accommodate it.
**VL-4: Sub-tab scroll indicators**
- Add CSS gradient fade or arrow indicators when the sub-tab bar overflows horizontally.
- Consider a "more" dropdown for narrow viewports.
**IX-5: Improved toast system**
- Position toasts inside the settings panel (not `position: fixed`) to avoid overlap with global UI.
- Add a brief auto-dismiss with a progress bar, plus a manual dismiss button.
- Stack multiple toasts if needed.
---
## 4. Priority Summary
| Priority | Items | Rationale |
|---|---|---|
| **P0 — Do Now** | IX-2 (confirmations), IX-1 (dirty state) | Prevent accidental data loss |
| **P1 — Next** | IX-6 (select all), A-3 (color-blind), A-4 (toast a11y), IX-3 (auto-expand) | Low effort, meaningful UX gains |
| **P2 — Soon** | IA-1/IA-3 (reorg), IX-4 (form consistency), VL-1 (status card), VL-3 (mobile table) | Structural quality |
| **P3 — Later** | VL-2 (smart buttons), IA-4 (search), VL-4 (scroll hints), IX-5 (toast rework) | Polish and future-proofing |
+116
View File
@@ -0,0 +1,116 @@
# Spectrum Controls — Visual Rework
The strip between the spectrum and the radio controls (`#spectrum-controls`)
holds eight controls in two groups. This proposes how it should *look*.
Every control stays, in its current order, with its current name and its
current behaviour. Nothing here changes what a button does, what commits when,
or what is stored. It is a styling and layout change.
*Status: implemented. Kept as the record of what was changed and why.*
---
## What it looks like now
```
Bandwidth [ 12 ] kHz [Set] [Auto BW] [Sweet-spot] Peak Hold [2 s] Floor [-115] dB Range [90] dB [Auto] Contrast [——●——] 1.0
```
Four problems, all of them visual:
**1. Four different control heights on one line.** The bare number inputs, the
buttons, the `select` and the range slider are each sized by their own rule, so
nothing shares a baseline and the row reads as a pile rather than a strip.
**2. Units are loose text.** `kHz`, `dB`, `dB` and the contrast value `1.0` are
text nodes sitting outside the control they belong to, separated from it by a
gap the same size as the gap between unrelated controls. The eye has to work
out which number owns which unit.
**3. A quarter of the strip is a hole.** `justify-content: space-between` puts
about 250 px of nothing in the middle at 1600 px, and the two groups read as
two unrelated things because the only thing between them is emptiness.
**4. The groups stagger between 1100 and 1400 px.** The bandwidth group wraps
to two lines while the level group stays on one, so the level group floats at a
height of its own, aligned with neither line of the group beside it. This is
the worst of it, and it happens at a common window width.
Two smaller things: 24 px controls are below any touch-target guideline, and
the contrast readout has no fixed width, so the row twitches as the value
changes between `1.0` and `0.9`.
## Proposed
**One control height, units inside their field, and a rule where the clusters
meet.**
- **A field is one box.** Label, value and unit share a single bordered box —
`Bandwidth │ 12.0 │ kHz` — so a number and its unit can never be read apart.
Fields, buttons, the peak-hold select and the contrast slider are all 1.7 rem
tall, on 44 px targets under a coarse pointer.
- **A rule, not a hole.** The two clusters are separated by a thin vertical
rule with normal spacing either side. The slack goes to a flexible spacer, so
the strip is left-aligned rather than pushed apart.
- **A cluster never splits.** Each cluster is `nowrap`; the container wraps. If
a cluster does not fit on the line it drops whole to the next one,
left-aligned with the one above. No staggering, at any width.
- **Rules fall away at line starts.** A cluster that begins a line has no rule
hanging off its left edge.
- **The contrast readout gets a fixed, tabular slot**, so the row is still.
Below the existing mobile breakpoint the strip already stacks; the same field
component applies there, which is most of what makes it look deliberate.
## What this does not change
`Set` stays. `Auto BW` and `Auto` keep their names, even though they mean
different things — that is a naming question, not a styling one. Sweet-spot
stays where it is and keeps its behaviour. Nothing gains or loses persistence.
Nothing moves into a popover, and no control is hidden behind a click.
Those are all worth arguing about separately; a note of them is at the end of
this file so the arguments are not lost.
## Implementation
One pass, no behaviour touched:
1. `.spectrum-field` and `.spectrum-btn` in `style.css`, replacing the six
per-id rules (`#spectrum-bw-input`, `#spectrum-floor-input`,
`#spectrum-range-input`, `#spectrum-bw-label`, `#spectrum-floor-label`,
`#spectrum-range-label`) that currently repeat the same declarations.
2. Markup in `index.html`: the loose `kHz` / `dB` text nodes move inside their
label, which keeps every id and every event handler exactly where it is.
3. `#spectrum-controls` becomes a wrapping flex row with a spacer;
`#spectrum-bw-row` and `#spectrum-level-row` become `nowrap` clusters with a
left rule.
4. The mobile block in the media query drops the rules it no longer needs.
`app.ts` is not touched. Every id survives, so the existing handlers, the
`spectrum-layout.mjs` geometry test and the broadcast-layout highlight all keep
working.
### Tests
Extend `spectrum-layout.mjs`, which already measures this area:
- Every control in the strip shares one height, at 1600, 1200 and 900 px.
- No two clusters sit at different vertical offsets on the same line — the
staggering bug, asserted directly.
- The strip never overflows its container and never overlaps the hint line.
---
## Noted for later, not proposed here
Behavioural observations from reading the code, kept so they are not lost:
- `Auto BW` (filter) and `Auto` (display scaling) are both called Auto, 600 px
apart.
- `Floor`, `Range` and `Contrast` are not persisted; `Peak Hold` is.
- `Auto` and `Auto BW` are one-shot: no state, nothing to turn off.
- `Sweet-spot` retunes the SDR and waits up to 1.4 s per candidate centre, with
no busy indication.
- Contrast resets on a double-click that nothing advertises.
+390
View File
@@ -0,0 +1,390 @@
# UX Guidelines
This document captures the UI/UX design patterns, conventions, and principles observed across
the trx-rs application. It covers the web frontend, CLI interfaces, configuration wizard, API
design, and error handling.
*Last reviewed: 2026-03-28*
---
## 1. Web Frontend (trx-frontend-http)
### 1.1 Layout and Navigation
The web UI is a single-page application served from embedded assets (no build step). It uses
a **tab-based** navigation model with six top-level tabs:
| Tab | Icon | Purpose |
|---|---|---|
| **Main** | House | Primary radio control: spectrum, frequency, mode, PTT, VFO, SDR controls |
| **Bookmarks** | Bookmark | Saved frequency/mode presets with folder organisation |
| **Digital modes** | Bar chart | FT8/FT4/FT2, WSPR, CW, APRS, AIS, VDES decode tables |
| **Map** | Pin | Leaflet map for APRS/AIS/FT8 station plotting |
| **Settings** | Wrench | Scheduler, background decode, history retention |
| **About** | Info circle | Server/client/radio/audio/decoder/integration details |
Tabs use inline SVG icons with a text label below. On narrow viewports the tab bar wraps and
subtitles collapse to save space.
The **Settings** and **About** tabs each use a secondary **sub-tab bar** for further grouping
(e.g. Settings > Scheduler | Background Decode | History).
### 1.2 Theming
The UI supports **dark mode** (default) and **light mode** toggled via a header button. Theme
preference persists in `localStorage`.
Additionally, nine **colour styles** are available via a dropdown:
- Original (default), Arctic, Lime, Contrast, Neon Disco, Donald (golden-rain), Amber, Fire, Phosphor
Each style provides a full CSS custom-property override set for both dark and light variants.
Styles are applied via `data-style` and `data-theme` attributes on `<html>`.
All colours reference CSS custom properties (`--bg`, `--card-bg`, `--text`, `--accent-green`,
`--border-light`, etc.) so components never use hard-coded colour values.
### 1.3 Typography
- **Body**: `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`
- **Frequency display**: `DSEG14 Classic` (14-segment display font, loaded from CDN with `preload`)
- **Labels**: uppercase, 0.68-0.78 rem, `font-weight: 700`, `letter-spacing: 0.04em`
- **Section labels** use pill-shaped badges (`border-radius: 999px`) with muted text
### 1.4 Responsive Design
Six breakpoints handle layout adaptation:
| Breakpoint | Behaviour |
|---|---|
| `> 1100px` | Full width with bookmark side gutters on spectrum |
| `< 1100px` | Side bookmark panels hidden |
| `< 900px` | Card fills viewport width, reduced padding |
| `< 760px` | Tab bar wraps, controls stack vertically, safe-area-inset padding for notched devices |
| `< 640px` | Bottom-fixed tab bar (mobile), subtitles hidden, compact header |
| `< 520px` | Further compact adjustments |
Touch-specific: `@media (hover: none) and (pointer: coarse)` enlarges hit targets.
The spectrum panel hints adapt: mouse users see "Scroll to zoom / Ctrl+Scroll to tune /
Drag to pan" while touch users see "Pinch to zoom / Drag to pan".
### 1.5 Interactive Controls
- **Jog wheel**: Circular CSS-styled draggable dial for frequency tuning (skeuomorphic radial-gradient, grab cursor, shadow/inset). Plus/minus buttons flank it.
- **Step unit buttons**: Segmented button group (MHz / kHz / Hz) with `.active` highlight
- **Step scale**: 1x / 0.1x multiplier toggle
- **Frequency input**: Monospace DSEG14 font, editable `<input>` with disabled opacity fix
- **Mode selector**: `<select>` dropdown populated from rig capabilities
- **PTT / Power / Lock buttons**: Three-column grid in the transmit/power section
- **VFO picker**: Button group (horizontal on desktop, vertical stack on mobile)
- **WFM/SAM controls**: Compact labelled controls (de-emphasis, audio mode, denoise, stereo pilot flag, CCI/ACI interference bars)
- **SDR settings row**: AGC checkbox, RF/LNA gain inputs with Set buttons, noise blanker
### 1.6 Spectrum and Waterfall
The spectrum panel uses `<canvas>` elements (WebGL renderer optional) and offers:
- **Drag to pan**, **scroll to zoom**, **Ctrl+scroll to tune**
- Bandwidth edges are draggable to resize the filter
- Keyboard shortcuts: `+`/`-` zoom, arrows pan, `0` reset
- **Minimap** for orientation when zoomed
- **Resize grip** to adjust spectrum height
- Controls: bandwidth input, auto-BW, sweet-spot, peak hold (0-60s), floor (dB), range (dB), auto-level, contrast gamma slider
- **Waterfall/waveform split slider** (20%-80%, default 50/50)
- **Bookmark axis** overlays on left/right sides at wider viewports
- **Decoder overlays**: RDS station name, AIS/VDES/FT8/APRS/CW bar overlays using `aria-live="polite"`
### 1.7 Real-Time Data
- **SSE (Server-Sent Events)** on `/events` for rig state updates. Each SSE session gets a
UUID, enabling per-tab rig selection without interfering with other tabs.
- **Named events**: `data` (state), `session` (session UUID), `channels` (virtual channels),
`b` (spectrum bins as base64), `rds`, `vchan_rds`, `ping` (5-second heartbeat)
- **WebSocket** on `/audio` for Opus-encoded RX audio streaming
- **Connection lost banner**: `#server-lost-banner` with pulsing dot, text "trx-server
connection lost -- waiting for reconnect", uses `aria-live="assertive"`
- **Loading state**: Centered "Initializing (rig)..." with subtitle, content hidden until ready
### 1.8 Accessibility
- All interactive elements have `aria-label` attributes
- Spectrum overlays use `aria-live="polite"` for screen reader announcements
- Connection-lost banner uses `aria-live="assertive"`
- `aria-hidden="true"` on decorative canvases and visual-only elements
- SVG icons include `aria-hidden="true"` with descriptive labels on parent buttons
- Spectrum resize grip has both `title` and `aria-label`
### 1.9 Authentication UX
When auth is enabled, an **auth gate** blocks the UI with:
- Title: "Access Required"
- Subtitle: "Enter passphrase to continue"
- Password input + Login button (green accent, full-width)
- Optional "Continue as Guest" button (shown when RX passphrase is not set)
- Error message area (red `#ff6b6b`)
- Role badge display
Two roles: **Rx** (read-only) and **Control** (full access including TX/PTT).
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
The header shows a Login/Logout button when auth is enabled (`#header-auth-btn`).
### 1.10 Multi-Rig Support
- **Header rig switcher**: `<select>` dropdown in the top bar for switching between connected rigs
- Per-tab rig binding: each SSE session independently selects a rig via `?remote=` query parameter
- Rig state isolation: only the disconnected rig shows the connection-lost banner
- About tab shows active rig, available rigs list
---
## 2. REST API Design
### 2.1 Conventions
- **Read operations** use `GET` (e.g. `/status`, `/events`, `/decode/history`, `/rigs`, `/bookmarks`)
- **Mutations** use `POST` for actions and toggles (e.g. `/set_freq`, `/toggle_power`, `/toggle_ft8_decode`)
- **CRUD resources** use proper verbs: `GET /bookmarks`, `POST /bookmarks`, `PUT /bookmarks/{id}`,
`DELETE /bookmarks/{id}`
- **Batch operations**: `POST /bookmarks/batch_delete`, `POST /bookmarks/batch_move`
- **Nested resources**: `/channels/{remote}/{channel_id}/subscribe`, `/scheduler/{remote}/status`
- Responses are JSON with `Content-Type: application/json`
- SSE stream uses `Content-Type: text/event-stream` with `no-cache` and `keep-alive` headers
### 2.2 Request Timeout
All rig command requests have a **15-second timeout** (`REQUEST_TIMEOUT`). If the command
doesn't complete in time, the request returns an error rather than hanging.
### 2.3 Error Responses
- `401 Unauthorized`: `{"error": "Invalid credentials"}` or `{"error": "Authentication required"}`
- `429 Too Many Requests`: `{"error": "Too many login attempts, please try again later"}`
- `404 Not Found`: Auth endpoints when auth is disabled
- `500 Internal Server Error`: Serialization failures
- Rate limiting: 10 attempts per 60-second window per IP, counter resets on successful login
### 2.4 State Enrichment
API responses merge rig state with **frontend metadata** (`FrontendMeta`) via `serde(flatten)`:
```
http_clients, rigctl_clients, audio_clients, rigctl_addr,
active_remote, remotes[], owner_callsign, owner_website_url,
owner_website_name, ais_vessel_url_base, show_sdr_gain_control,
initial_map_zoom, spectrum_coverage_margin_hz, spectrum_usable_span_ratio,
decode_history_retention_min, server_connected
```
This single-payload approach avoids extra round trips for UI configuration.
---
## 3. CLI Interface
### 3.1 Argument Style
Both `trx-server` and `trx-client` use **clap** for argument parsing with short and long flags:
```
-C, --config FILE Path to configuration file
--print-config Print example configuration and exit
-r, --rig NAME Rig backend name
-l, --listen ADDR Listen address
-p, --port NUM Port number
```
Positional arguments are used sparingly (e.g. `RIG_ADDR` for serial/TCP address).
### 3.2 Configuration Resolution
Config files are searched in priority order:
1. Current directory: `trx-rs.toml`
2. XDG config: `~/.config/trx-rs/trx-rs.toml`
3. System: `/etc/trx-rs/trx-rs.toml`
The loaded config path is logged: `INFO Loaded configuration from /path/to/config.toml`
### 3.3 Example Config Generation
`--print-config` outputs a complete, commented TOML file to stdout with example values
(callsign `N0CALL`, coordinates `52.2297, 21.0122`). Each section has a header comment and
each field has an inline description.
### 3.4 Startup Log Sequence
Server:
```
INFO Loaded configuration from /path/to/config.toml
INFO Starting trx-server with N rig(s): [rig-names]
INFO Callsign: CALL
INFO [rig-id] Starting (rig: ft817, access: serial /dev/ttyUSB0 @ 9600 baud)
INFO Listening on 0.0.0.0:4530
```
Client:
```
INFO Loaded configuration from /path/to/config.toml
INFO Starting trx-client (remotes: [remote-names], frontends: http,rigctl)
INFO rigctl frontend for rig 'default' on 127.0.0.1:4532
```
---
## 4. Configuration Wizard (trx-configurator)
### 4.1 Interactive Mode
Uses the **dialoguer** crate for terminal prompts:
- `Select` menus for enumerated choices (config type, rig model, access type, log level)
- `Input` for free-text with defaults (callsign defaults to `N0CALL`, listen defaults to `127.0.0.1`)
- `Confirm` for yes/no questions (enable auth, set location, etc.)
- Serial port auto-detection with fallback to `/dev/ttyUSB0`
### 4.2 Non-Interactive Mode
`--defaults` generates a config file without prompts, using sensible defaults.
### 4.3 Config Validation
`--check FILE` validates an existing config file:
```
/path/to/config.toml: valid TOML
Detected type: server
warning: [general].log_level 'verbose' is invalid (expected: trace, debug, info, warn, error)
1 warning(s), 0 error(s)
```
Validates: TOML syntax, unknown keys, log levels, coordinate ranges (-90..90 lat, -180..180 lon
with pair requirement), access types, port ranges (0-65535).
### 4.4 File Write Confirmation
Prompts before overwriting an existing file. Outputs `Wrote /path/to/file` on success.
---
## 5. Error Handling and User-Facing Messages
### 5.1 Error Message Conventions
- **Contextual**: Include file paths, section names, and peer addresses
- `"Failed to parse config file /path: error details"`
- `"Unknown rig model: X (available: ft817, ft450d, soapysdr)"`
- **Actionable**: Suggest alternatives when available
- `"Rig model not specified. Use --rig or set [rig].model in config."`
- `"Unknown frontend: X (available: http, rigctl, httpjson)"`
- **Structured**: Use field=value format in structured logging
### 5.2 Log Level Guidelines
| Level | Usage |
|---|---|
| `INFO` | Startup milestones, configuration loaded, listening, client connect/disconnect, decoder state changes |
| `WARN` | Non-fatal issues: command took too long, panel lock blocking, VFO priming failed, initial tune failed |
| `ERROR` | Fatal or significant failures: CAT polling errors, client errors, parse failures |
Logs suppress module targets (`with_target(false)`) for cleaner output.
### 5.3 Connection State Communication
- Server logs: `"Client connected: {peer}"`, `"Client {peer} disconnected"`, `"Client {peer} closing due to shutdown"`
- Rig task: `"[rig-id] Rig backend ready"`, `"Serial: /dev/ttyUSB0 @ 9600 baud"`
- Web UI: Connection-lost banner with reconnect indication, per-rig isolation
### 5.4 Graceful Degradation
- Startup continues after non-fatal failures: `"Initial PowerOn failed (continuing)"`
- Stream errors are deduplicated with 60-second summaries to avoid log flooding
- Lock poisoning is recovered from rather than panicking
- Unknown SSE events or lagged broadcast channels are silently skipped
---
## 6. Branding and Customisation
### 6.1 Owner Branding
Configurable via TOML and exposed via `FrontendMeta`:
- `owner_callsign` -- displayed in header subtitle and About tab
- `owner_website_url` / `owner_website_name` -- optional link in header
- `ais_vessel_url_base` -- base URL for linking AIS vessel MMSI numbers
### 6.2 UI Behaviour Configuration
- `http_show_sdr_gain_control` -- show/hide RF gain controls
- `http_initial_map_zoom` -- default map zoom level
- `http_spectrum_coverage_margin_hz` -- guard margin for spectrum center retune
- `http_spectrum_usable_span_ratio` -- fraction of spectrum span treated as usable
- `http_decode_history_retention_min` -- default history retention (per-rig overrides supported)
### 6.3 Embedded Assets
Logo and favicon are embedded at compile time via `include_bytes!`. The logo image has an
`onerror` handler to hide itself if loading fails (`this.style.display='none'`).
---
## 7. Security UX
### 7.1 Route Access Classification
Routes are classified into three tiers:
| Tier | Examples | Requirement |
|---|---|---|
| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role |
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only |
### 7.2 Session Management
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies
- Configurable TTL (default from TOML config)
- Expired sessions auto-pruned on access
- Constant-time passphrase comparison to mitigate timing attacks
### 7.3 TX Access Control
An additional `tx_access_control_enabled` flag can restrict transmit-related actions even
for Control-role users, providing an extra safety layer.
---
## 8. Virtual Channels (SDR)
Virtual channels allow SDR users to monitor multiple frequencies simultaneously:
- Channels appear in a picker row below the VFO controls
- CRUD API: `POST /channels/{remote}` to create, `DELETE` to remove, `PUT` to update freq/mode/BW
- Subscribe/unsubscribe audio per channel
- Background decode channels (hidden, no audio stream back)
- Channels auto-destroyed when out-of-bandwidth after center-frequency retune
- Channel-list changes broadcast to SSE clients via `event: channels`
---
## 9. Design Principles (Inferred)
1. **Server-rendered SPA**: All HTML/CSS/JS embedded in the binary -- zero external build tooling, no CDN dependency for core functionality (CDN used only for fonts and Leaflet maps).
2. **Progressive disclosure**: Advanced controls (WFM, SAM, SDR settings, spectrum controls) are hidden by default and revealed based on the active mode and backend type.
3. **Keyboard-first, touch-aware**: Spectrum supports full keyboard navigation alongside mouse and touch gestures. Mobile breakpoints enlarge hit targets and adapt layout.
4. **Real-time by default**: SSE + WebSocket provide sub-second state updates without polling from the browser. 5-second ping heartbeat detects stale connections.
5. **Per-tab isolation**: Each browser tab gets its own SSE session UUID and can independently select a rig, preventing cross-tab interference.
6. **Configuration over code**: UI behaviour knobs (gain visibility, map zoom, history retention, spectrum margins) are exposed as TOML config rather than requiring code changes.
7. **Graceful degradation**: The UI handles server disconnection gracefully with visible banners, and only the affected rig shows as disconnected in multi-rig setups.
8. **Defensive security defaults**: Auth disabled by default for ease of setup, but when enabled, provides role-based access, rate limiting, constant-time comparison, and HttpOnly cookies.
+579
View File
@@ -0,0 +1,579 @@
# trx-rs Manual
## What trx-rs is
`trx-rs` is a modular amateur radio control stack written in Rust. It splits
hardware access, DSP, transport, and user-facing interfaces into separate
components so a radio or SDR can be controlled locally while audio, decoding,
and remote control are exposed elsewhere on the network.
In practice, `trx-server` owns the rig or SDR backend and runs the DSP
pipeline, while `trx-client` connects to it and provides frontends such as the
web UI, JSON control, and rigctl-compatible access. The workspace also includes
protocol decoders and plugin-based extension points for adding backends and
frontends.
---
## Configuration
Both `trx-server` and `trx-client` use TOML configuration files. Use
`--print-config` to generate a fully commented example.
### File Locations
**trx-server** lookup order:
1. `--config <FILE>`
2. `./trx-server.toml`
3. `~/.trx-server.toml`
4. `~/.config/trx-rs/server.toml`
5. `/etc/trx-rs/server.toml`
**trx-client** lookup order:
1. `--config <FILE>`
2. `./trx-client.toml`
3. `~/.config/trx-rs/client.toml`
4. `/etc/trx-rs/client.toml`
CLI arguments override config file values.
### Environment Variables
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by
both server and client.
### Server Options
#### `[general]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `callsign` | string | `"N0CALL"` | Station callsign |
| `log_level` | string | — | `trace`, `debug`, `info`, `warn`, or `error` |
| `latitude` | float | — | Station latitude (-90..90) |
| `longitude` | float | — | Station longitude (-180..180) |
`latitude` and `longitude` must be set together or both omitted.
#### `[rig]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `model` | string | — | Backend name (`ft817`, `ft450d`, `soapysdr`) |
| `initial_freq_hz` | u64 | `144300000` | Startup frequency (must be > 0) |
| `initial_mode` | string | `"USB"` | Startup mode |
#### `[rig.access]`
| Field | Type | Description |
|-------|------|-------------|
| `type` | string | `serial`, `tcp`, or `sdr` |
| `port` | string | Serial port path (serial mode) |
| `baud` | u32 | Serial baud rate (serial mode) |
| `host` | string | Remote host (tcp mode) |
| `tcp_port` | u16 | Remote port (tcp mode) |
| `args` | string | SoapySDR device args (sdr mode, e.g. `"driver=rtlsdr"`) |
#### `[behavior]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `poll_interval_ms` | u64 | `500` | Rig polling interval |
| `poll_interval_tx_ms` | u64 | `100` | Polling interval during TX |
| `max_retries` | u32 | `3` | Connection retry limit |
| `retry_base_delay_ms` | u64 | `100` | Base retry delay |
#### `[listen]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable JSON TCP listener |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `4530` | Bind port |
#### `[listen.auth]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
#### `[audio]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable audio streaming |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `4531` | Bind port |
| `rx_enabled` | bool | `true` | Enable RX audio |
| `tx_enabled` | bool | `true` | Enable TX audio |
| `device` | string | — | CPAL device name (empty = default) |
| `sample_rate` | u32 | `48000` | Sample rate (8000192000) |
| `channels` | u8 | `1` | Channel count (1 or 2) |
| `frame_duration_ms` | u16 | `20` | Opus frame duration (3, 5, 10, 20, 40, 60) |
| `bitrate_bps` | u32 | `24000` | Opus bitrate |
When audio is enabled, at least one of `rx_enabled` or `tx_enabled` must be true.
#### `[sdr]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
#### `[sdr.gain]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `mode` | string | `"auto"` | `"auto"` (hardware AGC) or `"manual"` |
| `value` | f64 | `30.0` | Gain in dB (manual mode only) |
#### `[sdr.squelch]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable software squelch |
| `threshold_db` | f32 | `-65.0` | Open threshold in dBFS (-140..0) |
| `hysteresis_db` | f32 | `3.0` | Close hysteresis in dB (0..40) |
| `tail_ms` | u32 | `180` | Tail hold time in ms (0..10000) |
#### `[[sdr.channels]]`
Defines virtual receiver channels within the wideband IQ stream. The first
channel is the primary channel (controlled by `set_freq`/`set_mode`).
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `id` | string | `""` | Human-readable label |
| `offset_hz` | i64 | `0` | Frequency offset from dial |
| `mode` | string | `"auto"` | Demod mode (`auto`, `LSB`, `USB`, `CW`, `AM`, `FM`, `WFM`, etc.) |
| `audio_bandwidth_hz` | u32 | `3000` | Post-demod audio bandwidth |
| `fir_taps` | usize | `64` | FIR filter tap count |
| `cw_center_hz` | u32 | `700` | CW tone centre frequency |
| `wfm_bandwidth_hz` | u32 | `75000` | WFM pre-demod filter bandwidth |
| `decoders` | string[] | `[]` | Decoder IDs for this channel (`ft8`, `wspr`, `aprs`, `cw`) |
| `stream_opus` | bool | `false` | Stream this channel's audio to clients |
Notes:
- Each decoder ID may appear in at most one channel.
- At most one channel may set `stream_opus = true`.
- Channel IF constraint: `|center_offset_hz + offset_hz| < sample_rate / 2`.
#### `[pskreporter]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable PSKReporter uplink |
| `host` | string | `"report.pskreporter.info"` | Server host |
| `port` | u16 | `4739` | Server port |
| `receiver_locator` | string | — | Maidenhead grid (derived from lat/lon if omitted) |
#### `[aprsfi]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable APRS-IS IGate |
| `host` | string | `"rotate.aprs.net"` | Server host |
| `port` | u16 | `14580` | Server port |
| `passcode` | i32 | `-1` | APRS-IS passcode (-1 = auto from callsign) |
Notes:
- `[general].callsign` must be non-empty when enabled.
- Only APRS packets with valid CRC are forwarded.
- Reconnects with exponential backoff (1 s → 60 s) on TCP errors.
#### `[decode_logs]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable decoder logging |
| `dir` | string | `"$XDG_DATA_HOME/trx-rs/decoders"` | Log directory |
| `aprs_file` | string | `"TRXRS-APRS-%YYYY%-%MM%-%DD%.log"` | APRS log filename |
| `cw_file` | string | `"TRXRS-CW-%YYYY%-%MM%-%DD%.log"` | CW log filename |
| `ft8_file` | string | `"TRXRS-FT8-%YYYY%-%MM%-%DD%.log"` | FT8 log filename |
| `wspr_file` | string | `"TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"` | WSPR log filename |
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
`%MM%`, `%DD%` (UTC).
#### Multi-Rig Configuration
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
```toml
[[rigs]]
id = "ft817_0"
name = "HF Transceiver"
[rigs.rig]
model = "ft817"
[rigs.rig.access]
type = "serial"
path = "/dev/ttyUSB0"
baud = 9600
[[rigs]]
id = "sdr_0"
name = "VHF/UHF SDR"
[rigs.rig]
model = "soapysdr"
[rigs.rig.access]
type = "sdr"
args = "driver=rtlsdr"
```
When `[[rigs]]` is present it takes priority over the flat `[rig]` section.
Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1`.
### Client Options
#### `[general]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `callsign` | string | `"N0CALL"` | Station callsign |
| `log_level` | string | — | `trace`, `debug`, `info`, `warn`, or `error` |
#### `[remote]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `url` | string | — | Server address (e.g. `localhost:4530`) |
| `poll_interval_ms` | u64 | `750` | State poll interval |
#### `[remote.auth]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `token` | string | — | Auth token (must not be empty if set) |
#### `[frontends.http]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable web UI |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `8080` | Bind port |
#### `[frontends.rigctl]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable Hamlib rigctl |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `4532` | Bind port |
#### `[frontends.http_json]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable JSON-over-TCP |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
#### `[frontends.audio]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable audio client |
| `server_port` | u16 | `4531` | Server audio port |
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
| `bridge.rx_output_device` | string | — | Local playback device |
| `bridge.tx_input_device` | string | — | Local capture device |
| `bridge.rx_gain` | float | `1.0` | RX playback gain |
| `bridge.tx_gain` | float | `1.0` | TX capture gain |
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
loopback on Linux, BlackHole on macOS).
### CLI Override Summary
**trx-server:**
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
`--port`. SDR options are file-only.
**trx-client:**
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
---
## Tune Links
Every page of the web UI carries what the radio is doing in its address, so the
URL in the address bar is always a link someone else can open:
```
http://receiver.example:8080/?rig=sdr&f=14074000&mode=USB&bw=3000
```
| Parameter | Meaning |
|-----------|---------|
| `f` | Frequency. Hz by default; `7074k` and `14.074M` also work. |
| `mode` | Demodulation mode, e.g. `USB`, `CW`, `WFM`. |
| `bw` | Filter bandwidth in Hz. Ignored by rigs without filter control. |
| `rig` | Rig to select first, by id, on a multi-rig client. |
Opening such a link selects the rig, sets the mode, tunes, and applies the
bandwidth, in that order — a mode change carries its own default bandwidth, so
an explicit `bw` is applied last. Anything the rig cannot do (an unknown mode,
a frequency outside its range) is reported and the rest of the link still
applies. All four parameters are optional.
The link button in the top bar copies the current link to the clipboard. The
address bar itself is updated as you tune, using `replaceState`, so sweeping
the dial does not fill the browser's history.
Applying a link changes the radio, so it needs the `control` role; an `rx`
session opens the page and says the link was not applied. Links describe the
rig's own dial — while a tab is listening to a virtual channel the address is
left as it was, rather than publishing a frequency the rig is not on.
---
## Authentication
The HTTP frontend supports optional passphrase-based authentication with two
roles:
- **rx** — read-only access (monitoring, audio, decode streams)
- **control** — full access (frequency, mode, PTT, and all settings)
### Configuration
```toml
[frontends.http.auth]
enabled = false
rx_passphrase = "rx-only-passphrase"
control_passphrase = "full-control-passphrase"
tx_access_control_enabled = true
session_ttl_min = 480
cookie_secure = false # true if served via HTTPS
cookie_same_site = "Lax" # Strict|Lax|None
```
When `enabled = false` (the default), all auth is bypassed and the UI behaves
as before. When enabled, at least one passphrase must be set.
### Behaviour
- On login, the server issues an `HttpOnly` session cookie.
- Sessions are in-memory; a server restart invalidates all sessions.
- Rate limiting is applied per IP to mitigate brute-force attempts.
- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and
rejected for unauthenticated or `rx`-role users.
### Routes
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/auth/login` | POST | Submit `{ "passphrase": "..." }` |
| `/auth/logout` | POST | Clear session |
| `/auth/session` | GET | Check current session/role |
Protected routes require at least `rx` role. Control routes (set frequency,
mode, PTT, etc.) require `control` role.
### Frontend Flow
1. On load, the UI calls `/auth/session`.
2. If unauthenticated, a login screen is shown.
3. On successful login, the normal UI loads.
4. `rx` users see a read-only interface; `control` users get full controls.
5. If a session expires mid-use, streams stop and the login screen returns.
### Transport Security
There is no built-in TLS. For remote access, place trx-rs behind a
TLS-terminating reverse proxy (nginx, Caddy) and set `cookie_secure = true`.
---
## Background Decoding Scheduler
The scheduler automatically retunes the rig to pre-configured bookmarks when no
users are connected to the HTTP frontend. It runs as a background task inside
`trx-frontend-http`, polling every 30 seconds.
### Modes
#### Disabled (default)
Scheduler is inactive. The rig is not touched automatically.
#### Grayline
Retunes around the solar terminator (day/night boundary).
The user provides:
- Station latitude and longitude (decimal degrees)
- Optional transition window width (minutes, default 20)
- Bookmark IDs for four periods:
- **Dawn** — window around sunrise (`sunrise ± window_min/2`)
- **Day** — after dawn until dusk
- **Dusk** — window around sunset (`sunset ± window_min/2`)
- **Night** — after dusk until next dawn
Period precedence (most specific wins): Dawn > Dusk > Day > Night.
If no bookmark is assigned to a period, the rig is not retuned for that period.
Sunrise/sunset is computed inline using the NOAA simplified algorithm. Polar
regions (midnight sun / polar night) fall back to Day/Night accordingly.
#### TimeSpan
Retunes according to a list of user-defined time windows (UTC).
Each entry specifies:
- `start_hhmm` — start of window (e.g. 600 = 06:00 UTC)
- `end_hhmm` — end of window (e.g. 700 = 07:00 UTC)
- `bookmark_id` — bookmark to apply
- `label` — optional human-readable description
Windows that span midnight (`end_hhmm < start_hhmm`) are supported. When
multiple entries overlap, the first match (by list order) wins.
### Storage
Configuration is stored in PickleDB at `~/.config/trx-rs/scheduler.db`.
Keys: `sch:{rig_id}` → JSON `SchedulerConfig`.
### HTTP API
All read endpoints are accessible at the **Rx** role level. Write endpoints
require the **Control** role.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/scheduler/{rig_id}` | Get scheduler config for a rig |
| PUT | `/scheduler/{rig_id}` | Save scheduler config (Control only) |
| DELETE | `/scheduler/{rig_id}` | Reset config to Disabled (Control only) |
| GET | `/scheduler/{rig_id}/status` | Get last-applied bookmark and next event |
### Activation Logic
Every 30 seconds the scheduler task checks:
1. No SSE clients connected
2. Active rig has a non-Disabled scheduler config
3. Current UTC time matches a scheduled window or grayline period
4. If the matching bookmark differs from last applied, send `SetFreq` + `SetMode`
The scheduler does not revert changes when users reconnect.
### Web UI
A dedicated tab with a clock icon provides:
- Rig selector (read-only, shows active rig)
- Mode picker: Disabled / Grayline / TimeSpan
- Grayline section: lat/lon inputs, transition window slider, four bookmark selectors
- TimeSpan section: table of entries with start/end times, bookmark, label
- Status card: last applied bookmark name and timestamp
- Save button (Control role only)
---
## SDR Noise Blanker
The noise blanker suppresses impulse noise (clicks, pops, ignition interference)
on raw IQ samples before any mixing or filtering takes place. It works by
tracking a running RMS level of the signal and replacing any sample whose
magnitude exceeds **threshold x RMS** with the last known clean sample.
### Configuration (server-side)
The noise blanker is configured per rig. In a multi-rig setup each
`[[rigs]]` entry has its own `[rigs.sdr.noise_blanker]` section:
```toml
[[rigs]]
id = "hf"
[rigs.rig]
type = "sdr"
[rigs.sdr.noise_blanker]
enabled = true
threshold = 10.0 # 1 100; lower = more aggressive blanking
```
For the legacy single-rig (flat) config the path is `[sdr.noise_blanker]`:
```toml
[sdr.noise_blanker]
enabled = true
threshold = 10.0
```
| Field | Type | Default | Range | Description |
|-------------|-------|---------|---------|-------------|
| `enabled` | bool | false | — | Turn the noise blanker on or off. |
| `threshold` | float | 10.0 | 1 100 | Multiplier applied to the running RMS. A sample whose magnitude exceeds this multiple is replaced. Lower values blank more aggressively; higher values only catch strong impulses. |
The noise blanker is off by default.
### Choosing a threshold
The threshold controls how aggressively the blanker suppresses impulses.
A value of **N** means: blank any sample whose magnitude exceeds **N times**
the running average signal level.
| Threshold | Behavior | Use case |
|-----------|----------|----------|
| 3 5 | Very aggressive — blanks frequently | Dense impulse noise (motors, power lines, LED drivers nearby) |
| 8 12 | Moderate — catches clear spikes without touching normal signals | Typical HF conditions with occasional ignition or switching noise |
| 15 25 | Conservative — only blanks strong impulses well above the noise floor | Light interference, or when you want minimal artifacts on weak signals |
| 30 100 | Very light — rarely triggers | Faint, infrequent clicks; mostly a safety net |
**Start at 10** (the default) and adjust while listening:
- If impulse noise is still audible, lower the threshold.
- If weak signals sound choppy or distorted, raise it — the blanker may be
mistaking signal peaks for noise.
- On bands with steady atmospheric noise (e.g. 160 m / 80 m), a threshold of
**5 8** usually works well.
- On quieter VHF/UHF bands where the noise floor is low, values of **15 25**
avoid false triggers from strong signals.
### Web UI
When the server reports noise-blanker support, two controls appear in the
**SDR Settings** row of the web interface:
- **Noise Blanker** checkbox — enables or disables the blanker in real time.
- **NB Threshold** number input (1100) with a **Set** button — adjusts the
detection threshold. Press Enter or click Set to apply.
Both controls stay hidden until the server sends filter state containing NB
fields, so they only appear when connected to an SDR backend.
### HTTP API
```
POST /set_sdr_noise_blanker?enabled=true&threshold=10
```
| Parameter | Type | Required | Description |
|-------------|--------|----------|-------------|
| `enabled` | bool | yes | `true` or `false` |
| `threshold` | float | yes | Value between 1 and 100 |
### How it works
The blanker runs on every IQ block (4096 samples) *before* the mixer stage in
the DSP pipeline:
1. For each sample, compute magnitude² (`re² + im²`).
2. Compare against `threshold² × mean_sq` (the exponentially-smoothed running
mean of magnitude²).
3. If the sample exceeds the threshold, replace it with the previous clean
sample.
4. Otherwise, update the running mean with smoothing factor α = 1/128 and store
the sample as the last clean value.
Because the blanker operates on raw IQ before frequency translation, it removes
impulse noise across the entire captured bandwidth regardless of the tuned
channel offset.
+152
View File
@@ -0,0 +1,152 @@
# Weather Satellite Map Overlay Integration
Overlay decoded NOAA APT and Meteor-M LRPT satellite images on the Leaflet
map module, with ground track visualisation and source filtering.
*Created: 2026-03-28*
## Status
| Step | Description | Status |
|------|-------------|--------|
| 1 | Add `sgp4` crate, create `trx-core/src/geo.rs` | Done |
| 2 | Extend `WxsatImage`/`LrptImage` with geo fields | Done |
| 3 | Compute geo-bounds in `finalize_wxsat_pass` / `finalize_lrpt_pass` | Done |
| 4 | Add `wxsat` to map source filter + image overlay rendering | Done |
| 5 | Add ground track polyline + filter toggle UI | Done |
| 6 | Build, test, verify | Done |
## Motivation
The wxsat plugin currently shows a history table with download links but has
no geographic context. Since the Map module already renders APRS, AIS, VDES,
and FTx/WSPR positions, weather satellite images are a natural addition — they
can be projected as semi-transparent overlays on the same Leaflet map.
## Architecture
### Data flow
```mermaid
graph TD
A["Pass decoded (APT / LRPT)"] --> B["finalize_wxsat_pass / finalize_lrpt_pass<br/>(trx-server/audio.rs)"]
B --> C["SGP4 propagation using satellite TLE + pass timestamps"]
C --> D["Compute geo_bounds<br/>[[south, west], [north, east]]"]
D --> E["Compute ground_track<br/>[[lat, lon], ...]"]
E --> F["Attach to WxsatImage / LrptImage"]
F --> G["Broadcast via DecodedMessage"]
G --> H["SSE → browser"]
H --> I["wxsat.js: L.imageOverlay() + L.polyline() on aprsMap"]
```
### Geo-referencing strategy
Weather satellites (NOAA POES, Meteor-M) fly sun-synchronous polar orbits at
~850 km altitude with known TLE parameters. Given:
- **Satellite identity** (from telemetry: NOAA-15/18/19, Meteor-M N2-3/N2-4)
- **Pass start/end timestamps** (`pass_start_ms`, `pass_end_ms`)
- **Receiver station lat/lon** (from `RigState.server_latitude/longitude`)
We can use **SGP4 propagation** (via the `sgp4` crate) to compute the
sub-satellite ground track during the pass, then derive image bounds from the
known swath geometry:
| Parameter | NOAA APT | Meteor LRPT |
|-----------|----------|-------------|
| Altitude | ~850 km | ~825 km |
| Swath width | ~2800 km | ~2800 km |
| Ground speed | ~6.9 km/s | ~6.9 km/s |
| Scan rate | 2 lines/sec (0.5s/line) | variable MCU rate |
| Image width | 909 px/channel | 1568 px |
**Bounds computation:**
1. Propagate satellite position at `pass_start_ms` and `pass_end_ms`
2. Sub-satellite points define the ground track center line
3. Swath half-width (~1400 km) gives east/west extent
4. Image is projected as a simple lat/lon rectangle (acceptable distortion
for the typical ~15° latitude span of a single pass)
**TLE source:** Hardcoded recent TLEs for the 5 active satellites, with an
optional HTTP refresh from CelesTrak. Stale TLEs (weeks old) still give
sub-degree accuracy for image overlay purposes.
### Crate changes
#### `trx-core` (src/trx-core/)
New module `src/trx-core/src/geo.rs`:
- `SatelliteGeo` struct: holds hardcoded TLEs, provides `compute_pass_bounds()`
- `PassGeoBounds { south: f64, west: f64, north: f64, east: f64 }`
- `ground_track(sat, start_ms, end_ms) -> Vec<[f64; 2]>`
- Uses `sgp4` crate for orbital propagation
- Falls back to station-centered approximation when TLE unavailable
`src/trx-core/src/decode.rs` — extend structs:
```rust
pub struct WxsatImage {
// ... existing fields ...
pub geo_bounds: Option<[f64; 4]>, // [south, west, north, east]
pub ground_track: Option<Vec<[f64; 2]>>, // [[lat, lon], ...]
}
// Same for LrptImage
```
#### `trx-server` (src/trx-server/)
`src/trx-server/src/audio.rs`:
- In `finalize_wxsat_pass`: after PNG write, call `SatelliteGeo::compute_pass_bounds()`
using satellite name, pass timestamps, and station lat/lon (threaded through
from config). Attach result to `WxsatImage`.
- Same for `finalize_lrpt_pass`.
#### Frontend (trx-frontend-http/assets/web/)
`plugins/wxsat.js`:
- On `onServerWxsatImage` / `onServerLrptImage`: if `geo_bounds` present,
call `window.addWxsatMapOverlay(msg)`.
- Manage overlay list, allow removal.
`app.js`:
- Add `wxsat: false` to `DEFAULT_MAP_SOURCE_FILTER` (off by default to avoid
visual clutter; users opt-in).
- `window.addWxsatMapOverlay(msg)`: creates `L.imageOverlay(msg.path, bounds)`
with opacity 0.6, adds to `mapMarkers` set with `__trxType = "wxsat"`.
- `window.addWxsatGroundTrack(msg)`: creates `L.polyline(msg.ground_track)`
with dashed style.
- Overlay list in wxsat panel with per-image show/hide toggle.
`index.html`:
- No structural changes needed; the map filter chip system auto-generates
from `DEFAULT_MAP_SOURCE_FILTER`.
`style.css`:
- Styling for wxsat overlay opacity slider (future enhancement).
## Dependencies
| Crate | Version | Purpose |
|-------|---------|---------|
| `sgp4` | 2.4 | Pure Rust SGP4 orbital propagation |
Added to `trx-core/Cargo.toml` (used by `geo.rs`).
## Risk / Limitations
- **Rectangular projection approximation**: The actual scan geometry is curved
(satellite moves along a great circle), but for a single pass spanning
~15-20° of latitude, a lat/lon rectangle is a reasonable first approximation.
More accurate warping could use `L.imageOverlay` with a canvas transform
in a future iteration.
- **TLE staleness**: Hardcoded TLEs drift ~0.1°/week. For overlay purposes
this is acceptable. A periodic CelesTrak fetch would keep them fresh.
- **Image rotation**: Ascending vs descending passes produce different
orientations. The initial implementation uses axis-aligned bounds
(no rotation). A rotated overlay would need `leaflet-imageoverlay-rotated`
or a canvas-based approach — deferred to a follow-up.
- **Image serving**: The `path` field is a filesystem path. On co-located
server/client setups this works directly. Remote setups may need an
image-serving endpoint (out of scope for this change).
+87
View File
@@ -0,0 +1,87 @@
<!--
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Frontend architecture
The HTTP frontend is a strict TypeScript application built with esbuild. Rust
embeds deterministic JavaScript output from `assets/web/generated`; Cargo does
not run Node.js or contact the network.
## Runtime graph
`frontend/src/bootstrap.ts` is the only first-party script referenced by the
HTML document. Its imports establish startup order for WebGL support, shared UI
services, decoder dispatch, the local Leaflet AIS adapter, and the application
coordinator. Leaflet and the Opus decoder remain isolated vendored scripts.
Feature entries under `frontend/src/plugins` are ESM bundles. The typed plugin
loader imports them by feature group and keeps expensive map, scheduling, and
decoder behavior lazy. Shared code is emitted as content-hashed chunks. The
decode-history worker is an independent entry compiled against Web Worker
globals.
Dependencies point from application and feature code toward `core` and `api`.
`api/generated.ts` contains Rust wire formats; `api/client.ts` and focused
parsers validate untrusted HTTP, SSE, WebSocket, and worker data before it is
used as typed application state.
## Browser host contract
Separate lazy bundles cannot share module instances with the stable application
entry, so they use three intentional host namespaces:
| Global | Purpose | Mutation policy |
| --- | --- | --- |
| `window.trx` | Application state, core services, and feature registrations | The root is frozen; lazy features may register only their documented `modules.*` service. |
| `window.trxPluginRuntime` | Typed decoder registration and message dispatch | Runtime object is installed once; plugins register lifecycle handlers through its API. |
| `window.trxUi` | Notifications, confirmations, tab accessibility, and control presentation | Installed once by `ui-core.ts`; consumers call methods but do not replace them. |
`frontend/src/plugins/host.ts` declares the typed view of `window.trx.state`
and `window.trx.core` that feature bundles consume. Feature entries import it
instead of re-deriving the contract, so a service that moves out of the
application entry is added in one place. Application state that a feature needs
belongs in `trx.state`, and shared behavior belongs in `trx.core`; a feature
that has to intercept application behavior registers a `modules.*` method the
application calls, as the virtual-channel entry does for tuning, mode,
bandwidth, and frequency display.
The WebGL adapter exposes `createTrxWebGlRenderer`, `trxParseCssColor`,
`trxHslToRgba`, and `trxClearCssColorCache` for the application bundle. Leaflet
adds `L.TrxAisTrackSymbol` and `L.trxAisTrackSymbol` to the vendored Leaflet
namespace.
The following transitional properties are explicitly part of the lazy-feature
host contract and are declared in `app.ts`: `lastSpectrumData`, `lastFreqHz`,
`currentBandwidthHz`, `ft8BaseHz`, `getDecodeHistoryRetentionMs`,
`applyDecodeHistoryRetention`, `getDecodeRigMeta`, `renderRdsOverlays`,
`buildAisVesselUrl`, `trxScheduleUiFrameJob`,
`takeSchedulerControlForDecoderDisable`, `navigateToTab`, `_syncRecorderState`,
and `refreshRdsUi`. Optional callbacks owned by lazy features are
`refreshCwTonePicker`, `updateFt8RfDisplay`, `clearSatPredictionDom`,
`syncWefaxToggle`, `updateAisBar`, `updateVdesBar`, `updateAprsBar`,
`updateFt8Bar`, `updateSatLiveState`, `applyCwAutoUi`, and
`applyCwAutoUiFromServer`.
This list is closed: new standalone mutable `window` properties are not an
accepted integration mechanism. Extend an existing typed service or introduce
an imported interface instead. Removing transitional properties as feature
boundaries become directly importable remains preferable.
## Build and verification
The generated directory is removed before every build, preventing orphaned
compatibility bundles. Stable feature entry names are allowlisted by the Rust
asset manifest; shared chunks use content hashes. The generic Rust handler
rejects unknown names and unsupported MIME types and serves embedded assets
with compression, ETags, and immutable caching.
CI installs from `package-lock.json`, caches only npm downloads, type-checks the
window and worker environments separately, lints, runs unit and DOM tests,
starts the application in Chromium, regenerates Rust contracts and bundles,
checks for drift, and runs REUSE validation after generation.
See `frontend/src/README.md` for local commands and
`docs/ts-migration-plan.md` for the migration decisions and completion gates.
+365
View File
@@ -0,0 +1,365 @@
# Frontend Styling & Performance Improvements
*Analysis date: 2026-04-01*
This document captures observations and improvement recommendations for the
trx-rs web frontend (`trx-frontend-http`). The frontend is a single-page
application served as embedded static assets (gzip-compressed with ETag
caching) from the Actix-Web server.
## Historical asset inventory
This inventory records the frontend before the TypeScript migration. The
first-party JavaScript copies listed here have since been replaced by strict
TypeScript source and deterministic output under `assets/web/generated`.
| File | Lines | Size |
|------|------:|-----:|
| `style.css` | 5,318 | 144 KB |
| `app.js` | 8,427 | 306 KB |
| `map-core.js` | 3,483 | 127 KB |
| `screenshot.js` | 261 | 10 KB |
| `index.html` | 1,564 | 96 KB |
| `webgl-renderer.js` | 526 | 20 KB |
| `decode-history-worker.js` | 176 | 8 KB |
| `leaflet-ais-tracksymbol.js` | 120 | 8 KB |
| 15 plugin scripts | 7,360 | 304 KB |
| **Total** | **~27,000** | **~1 MB** |
All assets are pre-compressed with `flate2` (gzip, `Compression::best()`) and
served with `ETag` + `If-None-Match` support for conditional requests. The
Actix `Compress` middleware handles dynamic responses.
---
## 1. CSS observations
### 1.1 Monolithic stylesheet (P1)
`style.css` is a single 5,318-line file covering every tab, theme, responsive
breakpoint, map overlay, decoder UI, scheduler, recorder, and settings panel.
Browsers must parse the entire stylesheet before first paint even though most
users only interact with 1-2 tabs at a time.
**Recommendations:**
- Split into logical partitions: `base.css` (variables, reset, layout), `tabs/*.css` (per-tab styles), `themes/*.css`. The server can concatenate and compress at build time.
- At minimum, move the theme colour blocks (lines 3770-5318, ~1,550 lines / 29% of the file) into a separate `themes.css` loaded asynchronously after initial paint, since the default theme is already in `:root`.
- Consider using `@layer` (CSS Cascade Layers) to manage specificity between base, component, and theme styles, eliminating the need for `!important` (currently 21 occurrences).
### 1.2 `backdrop-filter` overuse (P1)
There are 26 `backdrop-filter` declarations (13 pairs with `-webkit-` prefix).
`backdrop-filter: blur()` is one of the most expensive CSS properties -- it
forces the browser to composite, rasterize, and blur everything behind the
element on every frame.
Affected areas: tab bar, controls tray, frequency overlay, modals, connection
banner, bottom nav, neon-disco theme overlay.
**Recommendations:**
- Remove `backdrop-filter` from elements that are always opaque or rarely overlap dynamic content (e.g. bottom tab bar over static background).
- For the spectrum/waterfall overlay controls, use a solid semi-transparent `background` instead of blur -- the visual difference is negligible on a dark spectrogram.
- Where blur is desired (modals), use `will-change: backdrop-filter` and keep blur radius low (4-6px instead of 12-18px). Larger radii are proportionally more expensive.
- Gate expensive blur behind a `@media (prefers-reduced-motion: no-preference)` query or a `[data-effects="full"]` attribute so low-end devices can opt out.
### 1.3 `color-mix()` usage (P2)
184 occurrences of `color-mix(in srgb, ...)` throughout the stylesheet. While
`color-mix` is well-supported in modern browsers, each call is resolved at
computed-value time. Repeated identical mixes (e.g. button hover states
repeated across themes) add unnecessary style recalculation cost.
**Recommendations:**
- Pre-compute frequently used mixes as CSS custom properties in the theme blocks (e.g. `--btn-hover-bg`, `--btn-active-bg`).
- This reduces computed-value work and also makes the palette more explicit and maintainable.
### 1.4 Theme system duplication (P2)
Each of the 10 colour themes repeats ~28 variable declarations for both dark
and light mode (560 variable declarations total). The theme blocks span lines
3770-5318 (29% of the entire stylesheet).
**Recommendations:**
- Move themes to a separate file loaded after first paint (the default `:root` theme is always available).
- Consider generating theme CSS from a data source (JSON/TOML) at build time to reduce manual duplication.
- Use `color-scheme` and `light-dark()` (CSS Color Level 5) to collapse the dark/light pairs where values differ only in lightness.
### 1.5 Transitions on non-essential properties (P3)
25 `transition` declarations, several targeting `background`, `border-color`,
and `box-shadow` simultaneously. Multi-property transitions on buttons and
inputs cause style recalculation on hover/focus for every such element.
**Recommendations:**
- Prefer transitioning only `opacity` and `transform` (GPU-composited).
- For colour changes, use `transition: background-color 100ms` rather than the shorthand `background` which also transitions `background-image` and other sub-properties.
- Add `will-change: transform` only to elements that are actively animating (currently only 2 occurrences, which is good).
### 1.6 Missing `contain` declarations (P2)
Tab content panels, decode history tables, map containers, and spectrum
canvases do not use CSS `contain` or `content-visibility`. When a large decode
history table updates, the browser recalculates layout for the entire page.
**Recommendations:**
- Add `contain: content` to inactive tab panels (`[data-tab]:not(.active)`).
- Add `content-visibility: auto` with `contain-intrinsic-size` to off-screen panels (decode history, map, statistics). This lets the browser skip rendering for hidden content entirely.
- Add `contain: strict` to the spectrum/waterfall canvas containers since their size is fixed and they don't affect sibling layout.
---
## 2. JavaScript observations
### 2.1 Monolithic `app.js` (P1)
The main application script is 11,928 lines (428 KB uncompressed). It is loaded
synchronously in the HTML `<head>` (via embedded asset), blocking first paint
until fully parsed and executed. The 15 plugin scripts add another 7,360 lines.
**Recommendations:**
- Mark the script tag `defer` or move it to end of `<body>` so HTML parsing completes before script execution.
- Split `app.js` into logical modules: `core.js` (SSE, auth, render loop), `spectrum.js`, `map.js`, `decoder.js`, `recorder.js`, `settings.js`. Load non-critical modules lazily when the user navigates to the corresponding tab.
- Use ES modules (`type="module"`) for clean dependency management and tree-shaking potential.
### 2.2 DOM query overhead (P2)
The codebase contains ~359 `querySelector`/`getElementById` calls, many of
which execute on every SSE event (inside `render()`). DOM lookups are not free,
especially `querySelector` with compound selectors.
**Recommendations:**
- Cache DOM references at initialization time (many already are, but the render path still re-queries elements like `document.getElementById("tab-main")`).
- Move repeated lookups (e.g. line 3575 `document.getElementById("tab-main")` inside `es.onmessage`) to module-level constants.
### 2.3 `innerHTML` usage (P2)
33 `innerHTML` assignments in `app.js` and 72 across plugin scripts. Each
`innerHTML` write forces the browser to:
1. Serialize the old DOM subtree for GC
2. Parse the HTML string
3. Build and insert a new DOM subtree
This is both a performance concern (layout thrashing) and a security concern
(XSS if any user-controlled data is interpolated without escaping).
**Recommendations:**
- Replace `innerHTML` with DOM APIs (`createElement`/`appendChild`) or `DocumentFragment` for bulk updates (only 4 `createDocumentFragment` uses currently).
- For large lists (decode history, bookmarks, recorder file lists), use a virtualised list pattern that only renders visible rows.
- Where `innerHTML` is used to clear a container, prefer `replaceChildren()` (clears children without HTML parsing).
### 2.4 SSE render path efficiency (P2)
Every SSE state event triggers `render(update)` which is a ~300-line function
touching dozens of DOM elements. The function does not diff -- it
unconditionally sets properties even when values have not changed.
The string-equality guard (`if (evt.data === lastRendered) return`) is a good
optimisation for identical payloads, but when any field changes (e.g. S-meter
value), the entire render function runs.
**Recommendations:**
- Implement field-level diffing: compare individual fields against previous values and only update DOM elements whose backing data changed.
- Group updates by tab: if the user is on the "Map" tab, skip render work for "Main" tab elements (meters, frequency display, controls).
- Use `scheduleUiFrameJob()` (already exists at line 3685) more aggressively to batch DOM writes into animation frames.
### 2.5 Spectrum/waterfall rendering (P2)
The WebGL renderer (`webgl-renderer.js`) is well-implemented with proper
shader programs and batched draws. However:
- The CSS colour parsing (`parseCssColor`) uses a DOM probe element (appended to
body) and `getComputedStyle` as a fallback, which triggers layout.
- The colour cache is a simple `Map` with no eviction policy.
**Recommendations:**
- Parse theme colours once when the theme changes, not on every frame.
- Invalidate the `cssColorCache` on theme switch events.
### 2.6 Plugin script loading (P3)
All 15 plugin scripts are loaded eagerly in `index.html` regardless of which
decoders are active. Plugins like `ais.js`, `vdes.js`, `sat.js`,
`sat-scheduler.js`, and `hf-aprs.js` are only relevant for specific use cases.
**Recommendations:**
- Load plugin scripts on demand when the corresponding decoder or feature is activated.
- Use dynamic `import()` if migrated to ES modules, or lazy `<script>` injection.
### 2.7 Web Worker utilisation (P3)
Only one Web Worker exists (`decode-history-worker.js`, 176 lines) for CBOR
decode-history parsing. All other heavy work (SSE parsing, DOM updates, spectrum
rendering, map marker management) runs on the main thread.
**Recommendations:**
- Move SSE JSON parsing to a shared worker so the main thread only receives pre-parsed objects.
- Offload spectrum FFT data processing / colour mapping to a worker, posting the resulting `ImageData` to the main thread for canvas rendering.
---
## 3. HTML observations
### 3.1 CDN dependencies (P2)
The page loads one external resource at startup:
- `@fontsource/dseg14-classic/400.css` from `cdn.jsdelivr.net`
~~`leaflet@1.9.4` was previously loaded from `unpkg.com` but is now bundled
as a vendored asset (`/vendor/leaflet.{js,css}` + marker/layer images),
eliminating the CDN dependency.~~
The font uses `rel="preload" as="style"` with an `onload` trick to make it
non-blocking, which is good. However:
- If CDN is unreachable (offline/firewalled deployments common in ham radio),
the font never loads and the frequency display falls back to the system font.
**Recommendations:**
- Self-host the DSEG14 font as an embedded asset (it is small, ~30 KB woff2). This eliminates the CDN dependency entirely and ensures the frequency display always renders correctly.
### 3.2 Inline SVG icons (P3)
Tab bar icons are inline SVGs in the HTML (lines 35-63). Each icon is ~150-250
bytes of markup. This is acceptable for a small number of icons and avoids
extra HTTP requests, but the tab bar HTML is dense and hard to maintain.
**Recommendation:**
- Consider an SVG sprite sheet or moving icons to a small icon font to improve readability without extra requests.
### 3.3 HTML size (P2)
`index.html` is 1,564 lines (96 KB uncompressed). All tab content panels are
present in the initial HTML regardless of which tab is active.
**Recommendations:**
- Use `<template>` elements for tab panels that are not initially visible. Clone and insert them when the tab is first activated. This reduces initial DOM node count and speeds up first paint.
- The server already does template substitution (`{ver}` placeholders). Extend this to strip unused tab content for deployments that don't use certain features.
---
## 4. Responsive design observations
### 4.1 Breakpoints (P3)
Six responsive breakpoints are defined:
- `>1100px`: side bookmark panels
- `<1099px`: hide side bookmarks
- `<900px`: full-width card
- `<760px`: mobile layout (touch targets, stacked controls)
- `<640px`: bottom tab bar, mobile nav
- `<520px`: compact mobile
- `(hover: none) and (pointer: coarse)`: touch-specific
This is a well-structured responsive system. Minor improvements:
- Use `min-width` mobile-first instead of `max-width` desktop-first to reduce CSS specificity conflicts.
- Consider `container queries` for components like the controls tray and decode history table, so they respond to their container size rather than the viewport.
### 4.2 Touch target sizing (P3)
Mobile buttons get `min-height: 2.8rem` at `<760px`. The
`(hover: none) and (pointer: coarse)` media query adds additional touch
accommodations. This meets the 44px minimum recommended by WCAG.
---
## 5. Accessibility observations
### 5.1 `aria-live` regions (P1)
The connection-lost banner and power hint text update dynamically but were
flagged in the Settings-Menu-UX-Analysis as missing `aria-live` on toast
notifications. Ensuring all dynamic status text has `aria-live="polite"` or
`aria-live="assertive"` (for errors) is critical for screen reader users.
### 5.2 Keyboard navigation (P2)
The tab bar uses `<button>` elements (good, natively focusable). However, the
spectrum canvas, jog wheel, and map are mouse/touch-only without keyboard
equivalents. The Settings-Menu-UX-Analysis noted the timeline SVG is not
keyboard-operable.
### 5.3 Colour contrast (P2)
`--text-muted` values (`#91a3bd` on `#0f172a` for dark, `#4a5568` on `#ffffff`
for light) should be verified against WCAG AA (4.5:1 for normal text). The
dark theme muted text calculates to approximately 4.8:1 (passes), but some
theme variants (e.g. Neon Disco) may not meet contrast requirements.
---
## 6. Server-side delivery observations
### 6.1 Asset compression (already good)
Static assets are pre-compressed with `gzip` at `Compression::best()` level
and served with ETag headers. Conditional `304 Not Modified` responses avoid
re-transferring unchanged assets.
### 6.2 Missing `Cache-Control` headers (P2)
While ETags are present, the analysis did not find explicit `Cache-Control`
headers on static assets. Adding `Cache-Control: public, max-age=31536000,
immutable` for versioned assets (with cache-busting query strings) would
eliminate conditional requests entirely for repeat visits.
### 6.3 Consider Brotli compression (P3)
Brotli (`br`) typically achieves 15-25% better compression than gzip for text
assets. For a 428 KB `app.js`, this could save ~60-100 KB of transfer. Actix
supports Brotli via the `Compress` middleware.
---
## 7. Priority summary
```mermaid
quadrantChart
title Impact vs Effort
x-axis Low Effort --> High Effort
y-axis Low Impact --> High Impact
quadrant-1 Do next
quadrant-2 Plan carefully
quadrant-3 Low priority
quadrant-4 Quick wins
"backdrop-filter reduction": [0.25, 0.80]
"Cache-Control headers": [0.15, 0.55]
"CSS contain/content-visibility": [0.30, 0.70]
"Cache DOM refs in render": [0.20, 0.50]
"Theme CSS split": [0.35, 0.45]
"Self-host DSEG14 font": [0.20, 0.40]
"Field-level render diffing": [0.60, 0.75]
"Split app.js into modules": [0.80, 0.70]
"Lazy plugin loading": [0.50, 0.40]
"innerHTML to DOM APIs": [0.65, 0.55]
"Brotli compression": [0.30, 0.25]
"Template-based tab panels": [0.70, 0.60]
```
### Quick wins (low effort, high impact)
1. ~~Reduce `backdrop-filter` usage (13 blur instances)~~ **DONE** -- replaced with solid backgrounds, blur preserved for modals only, `prefers-reduced-motion` gate added
2. ~~Add `contain: content` / `content-visibility: auto` to inactive tabs~~ **DONE** -- containment added for inactive tabs, spectrum/waterfall containers, map, statistics
3. ~~Add `Cache-Control` headers to static assets~~ **DONE** -- upgraded to `public, max-age=31536000, immutable`
4. ~~Cache remaining DOM references in the render path~~ **DONE** -- `tabMainEl` and other hot-path refs cached at module level
### Next phase (moderate effort)
5. ~~Split theme CSS into a separate lazy-loaded file~~ **DONE** -- theme blocks extracted to `/themes.css`, lazy-loaded via `<link rel="preload">`
6. ~~Self-host DSEG14 font~~ **DONE** -- `@font-face` with `font-display: swap` added to `style.css`, CDN preconnect/preload removed from HTML
7. ~~Pre-compute `color-mix` results as CSS variables~~ **DONE** -- common mixes pre-computed as `--btn-hover-bg`, `--btn-active-bg`, etc.
8. ~~Field-level diffing in the SSE render function~~ **DONE** -- `prevRenderData` tracks freq/mode/ptt/meter, active-tab-aware skip logic added
9. ~~Replace `innerHTML` with DOM APIs in hot paths~~ **DONE** -- 15+ `innerHTML = ""` replaced with `replaceChildren()`
### Longer-term
10. ~~Split `app.js` into modules with lazy loading~~ **DONE** -- `map-core.js` (3,480 lines, map/stats/geo) and `screenshot.js` (260 lines) extracted as IIFE modules communicating via `window.trx` namespace; lazy-loaded on tab activation and on-demand respectively; `app.js` reduced from 11,967 to 8,420 lines (30% reduction)
11. ~~Lazy-load plugin scripts and Leaflet on demand~~ **DONE** -- plugin scripts loaded on tab activation, core plugins loaded immediately
12. ~~Use `<template>` elements for deferred tab content~~ **DONE** -- map, statistics, about tabs wrapped in `<template>`, cloned on first activation
13. ~~Migrate to Brotli compression~~ **DONE** -- Brotli added alongside gzip, preferred when `Accept-Encoding: br` present
14. Move SSE parsing and spectrum processing to Web Workers -- **DEFERRED** (requires SharedWorker + MessagePort plumbing, tracked separately)
### Additional improvements implemented
15. ~~Optimize CSS transitions~~ **DONE** -- `background` shorthand → `background-color` for GPU compositing
16. ~~Add `defer` to script tags~~ **DONE** -- all external script tags use `defer`
17. ~~SVG sprite sheet~~ **DONE** -- inline SVGs moved to `<symbol>` defs, referenced via `<use>`
18. ~~aria-live regions~~ **DONE** -- `aria-live` added to power hint, loading indicator
19. ~~Keyboard navigation~~ **DONE** -- `tabindex`/`role`/`aria-label` on spectrum/waterfall canvases
20. ~~Colour contrast~~ **DONE** -- dark theme `--text-muted` improved to `#9bb0ca`
21. ~~WebGL colour cache invalidation~~ **DONE** -- `trxClearCssColorCache()` called on theme switch
22. ~~Container queries~~ **DONE** -- controls tray and decode history table respond to container size
23. ~~Cache-Control immutable~~ **DONE** -- versioned assets use `immutable` directive
+234
View File
@@ -0,0 +1,234 @@
# Scheduler UI Improvement Plan
## Current State
The scheduler UI lives in Settings → Scheduler and provides three operational modes:
- **Grayline** — auto-switches bookmarks based on solar dawn/day/dusk/night
- **Time Span** — UTC time windows with interleaved cycling
- **Satellite Pass** — priority overlay that retunes for satellite passes
Main-view controls include a release button, prev/next step buttons, and a
progress ring showing the active interleave entry and countdown.
Key files:
| File | Purpose |
|------|---------|
| `assets/web/plugins/scheduler.js` | UI logic, rendering, API calls (~1,060 LOC) |
| `assets/web/plugins/sat-scheduler.js` | Satellite config overlay (~310 LOC) |
| `assets/web/index.html` (L11091289) | Scheduler settings HTML |
| `assets/web/style.css` (`.sch-*`) | Scheduler styling |
| `src/scheduler.rs` | Backend task, API handlers (~1,435 LOC) |
---
## P0 — Usability Fixes
### 1. Highlight active entry in time-span table
**Problem:** The entry table under "Entry details" has no indication of which
entry the scheduler is currently operating on. Users must cross-reference the
interleave ring label with the table manually.
**Fix:** In `renderScheduler()`, after receiving status, add/remove an
`sch-active` class on the `<tr>` whose entry id matches
`currentSchedulerStatus.last_entry_id`. Style with a left border accent
(`border-left: 3px solid var(--accent)`).
### 2. Bookmark existence validation on save
**Problem:** If a bookmark is deleted after being assigned to a scheduler entry,
the scheduler fails silently at runtime — it tries to apply a non-existent
bookmark and does nothing.
**Fix:** In `saveScheduler()`, cross-check every `bookmark_id` /
`bookmark_ids[]` against `bookmarkList`. Show a toast error listing the
broken entries and refuse to save until corrected.
### 3. Dirty-state indicator for satellite section
**Problem:** Changes in the satellite section (add/edit/remove satellites,
toggle enable) don't reliably set `schedulerDirty`, so the Save button may
not appear.
**Fix:** Audit all satellite mutation paths in `sat-scheduler.js` and ensure
they call `window.schedulerBridge.markDirty()`.
---
## P1 — Information Density & Clarity
### 4. Show local time alongside UTC
**Problem:** All times are UTC-only. Operators in non-UTC timezones must
mentally convert, especially when editing time-span entries.
**Fix:** Add a `(local)` annotation next to each UTC time display:
- In the entry table, append a dimmed local-time column
- In the timeline SVG, add a secondary tick row with local hours
- Use `Intl.DateTimeFormat` to derive the offset; no config needed
### 5. Expand entry details by default
**Problem:** The entry list is hidden behind a `<details>` collapse. New
users don't discover it, and experienced users click it open every time.
**Fix:** Default the `<details>` element to `open`. Persist the
open/collapsed preference in `localStorage`.
### 6. Richer "Now Playing" status card
**Problem:** The status card shows only `"Last applied: {name} at {time}"`
no frequency, mode, or decoder info.
**Fix:** Extend `SchedulerStatus` (backend) to include `freq_hz`, `mode`,
and `active_decoders[]`. Render them in the status card as
`"14.074 MHz · FT8 · FT8 decoder active"`. Adds immediate visibility
without opening the bookmark manager.
---
## P2 — Interaction Improvements
### 7. Inline entry editing
**Problem:** Editing an entry requires clicking Edit, which opens an overlay
form that obscures the table. Users lose context of adjacent entries.
**Fix:** Replace the overlay with inline editing directly in the table row.
Clicking Edit on a row transforms its cells into input fields (time pickers,
selects) in-place. Save/Cancel buttons appear in the last column. This
keeps sibling entries visible and reduces clicks.
### 8. Drag-to-reorder entries
**Problem:** Entry order matters for interleave cycling, but there is no way
to reorder entries. Users must delete and re-add.
**Fix:** Add drag handles (`⠿`) to each table row. Implement HTML5 drag-and-drop
on the `<tbody>`. On drop, splice the `currentConfig.entries` array and
re-render. Mark dirty.
### 9. Timeline click-to-add
**Problem:** Adding an entry requires clicking "+ Add Entry" and manually
typing start/end times, even though the timeline is a visual 24-hour bar.
**Fix:** Make the timeline SVG interactive. Clicking on an empty region
opens the entry form pre-filled with the clicked hour as start and start+1h
as end. Dragging across a region sets start/end from the drag span. Use
`pointer-events` and `getBoundingClientRect()` to map pixel → minute.
### 10. Improved extra-channels management
**Problem:** Virtual channels use tiny `+`/`` buttons with no indication
of which bookmarks are already added. Removing a channel requires clicking
`` on the right one in a compact list.
**Fix:** Replace with a multi-select chip list: each added channel is a
removable chip (`× 40m FT8`). The `+` button opens the select dropdown.
Already-added bookmarks are disabled in the dropdown to prevent duplicates.
---
## P3 — Feature Enhancements
### 11. Grayline location lookup by grid square
**Problem:** Users must manually enter latitude/longitude. Ham operators
typically know their Maidenhead grid square (e.g. `JO94`) but not their
coordinates to three decimals.
**Fix:** Add a text input for grid square next to the lat/lon fields. On
input, convert the grid square to lat/lon using the standard Maidenhead
algorithm (simple arithmetic, no external API). Populate lat/lon fields
automatically. Also support reverse: when lat/lon changes, show the
derived grid square.
### 12. Expanded satellite preset library
**Problem:** Only two satellite presets (Meteor-M2 3 and M2-4). Adding
NOAA, ISS, or amateur satellites requires looking up NORAD IDs externally.
**Fix:** Expand the preset `<option>` list to include common amateur /
weather satellites:
```
ISS (145.825 MHz APRS) — 25544
SO-50 (436.795 MHz FM) — 27607
```
Low-effort, high-value change — just HTML `<option>` additions plus
corresponding default bookmark templates.
### 13. Scheduler activity log
**Problem:** No way to see what the scheduler did historically — when it
switched, which bookmark it applied, whether any entry was skipped.
**Fix:**
- Backend: Add a ring buffer (last 100 events) to `SchedulerState`.
Each event: `{ utc, action: "applied"|"skipped"|"satellite_aos"|"satellite_los", entry_label, bookmark_name }`.
- API: `GET /scheduler/{rig_id}/log` returns the buffer.
- UI: Add a collapsible "Activity Log" section below the status card.
Render as a reverse-chronological compact list with timestamps.
### 14. Timeline interleave visualization
**Problem:** When multiple entries overlap, the timeline shows overlapping
colored bars but gives no indication of how interleaving splits time between
them.
**Fix:** When interleave is enabled and entries overlap, render alternating
color stripes within the overlap region (e.g., 5-minute tick marks colored
per-entry). Add a legend showing entry label → color mapping.
### 15. Keyboard shortcuts for scheduler control
**Problem:** Release/step controls require mouse clicks on the main view.
During operation, keyboard shortcuts would be faster.
**Fix:** Register global keybindings (configurable in settings):
- `Shift+R` — toggle release to scheduler
- `Shift+N` / `Shift+P` — step to next/previous entry
Guard with `!isInputFocused()` to avoid conflicts with text fields.
---
## Implementation Order
```mermaid
gantt
title Scheduler UI Improvements
dateFormat X
axisFormat %s
section P0
Active entry highlight :1, 2
Bookmark validation :1, 2
Satellite dirty-state fix :1, 2
section P1
Local time display :3, 5
Expand details by default :3, 4
Richer status card :3, 5
section P2
Inline entry editing :6, 9
Drag-to-reorder :6, 8
Timeline click-to-add :6, 9
Extra-channels chips :6, 8
section P3
Grid square lookup :10, 11
Satellite presets :10, 11
Activity log :10, 13
Interleave visualization :10, 13
Keyboard shortcuts :10, 11
```
P0 items are small, targeted fixes (< 1 hour each). P1 items improve daily
usability. P2 items modernize interactions. P3 items add new capabilities.
Each item is independently shippable.
+50
View File
@@ -0,0 +1,50 @@
<!--
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later
-->
# TypeScript migration baseline
This baseline records the browser architecture at the start of the migration.
It is intentionally historical; generated output sizes are tracked separately
by the deterministic frontend build.
## Startup order
The initial document loads these scripts in order:
1. `/vendor/opus-decoder-0.7.11.min.js`
2. `/vendor/leaflet.js`
3. `/leaflet-ais-tracksymbol.js`
4. `/webgl-renderer.js`
5. `/ui-core.js`
6. `/app.js`
Decoder and feature scripts are then loaded lazily by the inline loader in
`index.html`. The frontend smoke test locks the core ordering and rejects
remote script or stylesheet URLs.
## Source measurements
At baseline, first-party JavaScript comprised 23 files and approximately
776 KiB. The largest sources were:
| Source | Bytes |
|---|---:|
| `app.js` | 337,111 |
| `map-core.js` | 131,899 |
| `plugins/scheduler.js` | 60,883 |
| `plugins/bookmarks.js` | 30,543 |
| `plugins/sat.js` | 22,541 |
| `plugins/vchan.js` | 20,195 |
| `webgl-renderer.js` | 18,993 |
The migrated source snapshot contained 123 distinct direct `window.*`
assignments. These are compatibility callbacks, shared state, and plugin entry
points. New TypeScript code must not add to that set; the typed plugin registry
and explicit services replace it over the course of the migration.
All production scripts, stylesheets, fonts, icons, and map assets were already
served from local embedded routes. The automated startup test ensures no
remote runtime script or stylesheet dependency is introduced.
+586
View File
@@ -0,0 +1,586 @@
<!--
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later
-->
# TypeScript Migration Plan
> **Scope**: `src/trx-client/trx-frontend/trx-frontend-http/`
>
> **Status**: Complete (2026-08-01)
## Implementation result
The migration was completed on `feat/typescript-frontend-migration`. The
baseline and phased sections below are retained as the decision record; their
descriptions of JavaScript files and classic loading refer to the pre-migration
state.
Completion evidence:
- all first-party browser sources are strict `.ts` files, checked by separate
DOM and Web Worker TypeScript projects with no JavaScript compatibility mode
or suppression directives;
- `bootstrap.ts` is the single first-party HTML entry and esbuild represents
startup order, lazy feature imports, shared hashed chunks, and the worker in
its module graph;
- obsolete source and generated compatibility JavaScript was removed;
- Rust generates rig, status, capability, decoder, and flattened frontend
metadata contracts into `api/generated.ts`; runtime guards validate HTTP,
SSE, WebSocket, and worker boundaries;
- the generic embedded-asset handler serves a build-generated allowlist with
constrained MIME types, compression, ETags, immutable caching, and no file
system lookup;
- intentional browser host namespaces and transitional lazy-feature properties
are documented in `docs/frontend-architecture.md`;
- CI uses locked npm dependencies, caches npm downloads rather than
`node_modules`, runs strict type checking and linting, unit/DOM/worker tests,
Chromium startup coverage, generated-output drift checks, and REUSE after
generation;
- Cargo continues to consume committed generated assets without invoking Node
or requiring network access.
The final local gate ran `npm ci`, type checking, linting, 30 frontend tests,
the Chromium smoke flow (startup, auth gate, audio controls, rig switching, map
initialization, and navigation), generated-contract and bundle verification,
workspace formatting, Clippy with warnings denied, all-target builds, workspace
tests, and REUSE 3.3 validation.
## 1. Decision
Migrating the web frontend to TypeScript is worthwhile, provided it is used to
remove implicit contracts and global coupling. Renaming JavaScript files to
`.ts` without changing their boundaries would add tooling without delivering
the main safety benefits.
The migration must be incremental. Every intermediate commit and pull request
must leave the frontend buildable and usable.
## 2. Baseline State
The frontend currently contains roughly 21,700 lines of first-party
JavaScript. Its largest components include:
- `app.js`: approximately 8,900 lines;
- `map-core.js`: approximately 3,500 lines;
- `plugins/scheduler.js`: approximately 1,500 lines;
- `plugins/bookmarks.js`: approximately 800 lines;
- `plugins/vchan.js`: approximately 560 lines.
Important characteristics of the current architecture are:
- scripts are embedded individually into the Rust binary with `include_str!`;
- the Rust HTTP server exposes an explicit route for each asset;
- plugins are loaded dynamically as classic scripts;
- script order is significant;
- modules communicate through `window.*`, `window.trx`, callbacks, and shared
mutable state;
- the main HTML file contains the plugin loader;
- a Web Worker is loaded through a fixed URL;
- Cargo builds do not require Node.js;
- CI images contain Node.js, but CI currently runs only Rust and REUSE checks;
- Leaflet, Opus decoder, fonts, images, and other vendored assets are local.
These constraints make a big-bang rewrite unnecessarily risky.
## 3. Goals
The migration should:
1. Create checked contracts between Rust responses and browser code.
2. Replace global callbacks with explicit module interfaces.
3. Split `app.js` by responsibility.
4. Make rig, capability, decoder, scheduler, audio, and spectrum state explicit.
5. Preserve lazy loading for expensive features.
6. Add frontend type checking, linting, and automated tests to CI.
7. Keep ordinary Cargo builds independent of Node.js and network access.
8. Preserve existing browser behavior throughout the migration.
## 4. Non-Goals
The migration will not initially:
- introduce a UI framework;
- redesign the user interface;
- convert vendored JavaScript to TypeScript;
- change REST, SSE, WebSocket, or worker protocols unless required to make an
existing contract unambiguous;
- make `build.rs` install packages or download frontend dependencies;
- convert all of `app.js` in one pull request.
## 5. Target Layout
```text
trx-frontend-http/
├── frontend/
│ ├── package.json
│ ├── package-lock.json
│ ├── tsconfig.json
│ ├── tsconfig.worker.json
│ ├── build.mjs
│ ├── src/
│ │ ├── bootstrap.ts
│ │ ├── api/
│ │ │ ├── client.ts
│ │ │ └── generated.ts
│ │ ├── core/
│ │ │ ├── dom.ts
│ │ │ ├── events.ts
│ │ │ ├── settings.ts
│ │ │ └── state.ts
│ │ ├── features/
│ │ │ ├── audio/
│ │ │ ├── bookmarks/
│ │ │ ├── map/
│ │ │ ├── navigation/
│ │ │ ├── radio/
│ │ │ ├── recorder/
│ │ │ ├── scheduler/
│ │ │ └── spectrum/
│ │ ├── decoders/
│ │ ├── workers/
│ │ └── legacy/
│ │ └── global-bridge.ts
│ └── tests/
└── assets/web/
├── generated/
├── vendor/
├── index.html
├── style.css
└── themes.css
```
The exact feature directories may evolve, but dependencies should point from
features toward `core` and `api`, never from `core` back into features.
## 6. Tooling
Use TypeScript for type checking and esbuild for bundling. A framework-specific
development server is not needed.
Recommended compiler baseline:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true,
"noEmit": true,
"lib": ["ES2022", "DOM"]
}
}
```
Worker code should use a separate configuration with `WebWorker` rather than
`DOM` globals.
The package scripts should provide at least:
```text
npm run typecheck
npm run lint
npm test
npm run build
npm run verify-generated
```
Pin dependencies in `package-lock.json`. New package and generated files must
carry or inherit valid REUSE licensing information.
## 7. Cargo and Asset Integration
Cargo must remain usable on machines without Node.js. Do not invoke `npm`
automatically from `build.rs`.
The initial integration should work as follows:
1. TypeScript and JavaScript sources live under `frontend/src`.
2. esbuild writes browser-ready output under `assets/web/generated`.
3. Generated browser output is committed to the repository.
4. Rust embeds generated output, not TypeScript source.
5. CI rebuilds the frontend and fails if committed output is stale.
During early phases, retain the existing public URLs, including:
```text
/app.js
/ui-core.js
/map-core.js
/scheduler.js
/decode-history-worker.js
```
Keeping these URLs stable avoids coupling the first migration phases to changes
in HTML loading, authentication rules, caching, or Rust routing.
After modules and code splitting are established, replace the explicit list of
Rust constants and handlers with an embedded generated-asset directory. The
server must still enforce an allowlist, correct MIME types, cache headers, and
path traversal protection.
## 8. Rust-to-TypeScript Contracts
Generate TypeScript definitions from Rust wire types instead of maintaining
parallel handwritten representations.
Initial candidates include:
- `RigState`, `RigInfo`, and `RigCapabilities`;
- the `/rigs` response;
- decoder registry entries;
- scheduler configuration and status;
- filter and spectrum state;
- recorder responses;
- SSE update payloads;
- decoded-message variants;
- WebSocket control and status messages.
A generator such as `ts-rs` can produce
`frontend/src/api/generated.ts`. Generated definitions should describe only
wire formats. UI state and view models should remain handwritten.
CI must regenerate these definitions and fail when the working tree changes.
Serde renames, tagged enums, optional fields, flattened values, and numeric
ranges must be checked explicitly during the initial generator integration.
TypeScript types do not validate data at runtime. Critical compatibility
boundaries should retain focused runtime checks for missing or malformed data,
particularly when clients and servers may run different versions.
## 9. Target Module Contracts
Plugins should eventually implement an explicit interface rather than assigning
callbacks to `window`:
```ts
interface TrxPlugin {
readonly id: string;
initialize(context: PluginContext): void | Promise<void>;
dispose?(): void;
}
interface PluginContext {
api: TrxApi;
events: TrxEventBus;
navigation: NavigationService;
notifications: NotificationService;
state: ReadonlyRadioState;
}
```
During the transition, `legacy/global-bridge.ts` may expose the minimum globals
required by unmigrated scripts. The bridge must shrink as migration progresses;
new feature code must not add new global callbacks.
State should be divided by responsibility rather than replaced by one large
typed global:
```ts
interface RadioState {
activeRigId: string | null;
capabilities: RigCapabilities | null;
connection: ConnectionState;
frequencyHz: number | null;
mode: RigMode | null;
bandwidthHz: number | null;
}
interface AudioState {
rx: AudioStreamState;
tx: AudioStreamState;
volume: VolumeState;
}
interface DecoderState {
registry: DecoderDescriptor[];
status: ReadonlyMap<DecoderId, DecoderStatus>;
}
```
Commands should receive a rig ID explicitly. They must not infer their target
from mutable global selection state after an asynchronous operation begins.
## 10. Migration Phases
### Phase 0: Baseline and Measurements
- Record current asset names, sizes, and load order.
- Add a browser startup smoke test.
- Record existing global symbols and plugin callbacks.
- Confirm that generated bundles do not introduce remote runtime dependencies.
**Exit criterion:** current behavior and asset loading have an automated
baseline.
### Phase 1: Tooling Scaffold
- Add the frontend package, lockfile, TypeScript configuration, and esbuild.
- Allow existing JavaScript as build input without type checking it globally.
- Produce fixed-name outputs matching current URLs.
- Add frontend commands and CI checks.
- Document local frontend development commands.
**Exit criterion:** existing JavaScript passes through the frontend build with
no runtime changes, and CI detects stale generated output.
### Phase 2: Generated API Types
- Add Rust-to-TypeScript type generation.
- Generate the first status, rig, capability, and decoder contracts.
- Introduce a typed fetch/post client and typed SSE decoding boundary.
- Keep narrow runtime guards at compatibility boundaries.
**Exit criterion:** new API consumers cannot use untyped response objects.
### Phase 3: Core Browser Services
Convert or create:
- DOM lookup helpers;
- notifications and confirmations;
- settings and per-rig preferences;
- navigation;
- the event bus;
- application state stores;
- plugin registry and loader.
Convert `ui-core.js` as the first strict TypeScript entry.
**Exit criterion:** shared UI behavior is TypeScript, tested, and does not add
new globals.
### Phase 4: Independent Leaf Modules
Convert lower-coupling code first:
1. WebGL renderer;
2. decode-history worker;
3. screenshot support;
4. FT2 and FT4;
5. WSPR;
6. CW and other decoder views.
Workers must be separate build entries.
**Exit criterion:** each converted module has typed inputs, outputs, and tests.
### Phase 5: Plugin Loading
- Move the inline loader out of `index.html`.
- Replace classic-script injection with typed dynamic imports.
- Register plugins through `TrxPlugin`.
- Preserve lazy loading by tab and feature.
- Remove the corresponding global callbacks after each plugin migrates.
**Exit criterion:** plugin dependencies and loading order are represented by the
module graph rather than implicit script order.
### Phase 6: Split the Main Application
Extract `app.js` by responsibility:
1. authentication and API transport;
2. rig enumeration and switching;
3. radio commands and capabilities;
4. audio streaming;
5. spectrum state and rendering;
6. decode history;
7. recorder;
8. keyboard shortcuts;
9. application bootstrap.
Do not split by arbitrary line ranges. Each extraction must establish an
explicit interface and remove the corresponding globals.
**Exit criterion:** the bootstrap file composes services and features but does
not contain their implementations.
### Phase 7: High-Coupling Features
Convert the remaining large features after the core contracts are stable:
- scheduler and satellite scheduler;
- bookmarks;
- virtual channels;
- map and map-backed statistics;
- remaining decoder plugins.
**Exit criterion:** no first-party classic scripts or untyped plugin callbacks
remain.
### Phase 8: Asset-Server Consolidation
- Enable code splitting and hashed chunks.
- Embed the generated asset directory in Rust.
- Serve generated assets through a generic, allowlisted handler.
- Add appropriate immutable caching for hashed output.
- Retain stable handling for HTML and version metadata.
- Remove obsolete fixed-asset constants and handlers.
**Exit criterion:** Rust no longer needs a source edit for every generated
frontend chunk.
### Phase 9: Strictness and Cleanup
- Remove `allowJs`.
- Remove the legacy global bridge.
- Enable all selected strict compiler and lint rules.
- Remove obsolete generated compatibility bundles.
- Update architecture and contributor documentation.
**Exit criterion:** all first-party frontend source is strict TypeScript and the
browser runtime exposes only intentionally documented globals.
## 11. Testing Strategy
Frontend CI should run:
```sh
npm ci
npm run typecheck
npm run lint
npm test
npm run build
git diff --exit-code -- assets/web/generated frontend/src/api/generated.ts
```
Testing should cover four layers:
1. **Pure unit tests** for bandwidth calculations, formatting, state changes,
capability decisions, and message routing.
2. **DOM component tests** for dialogs, tabs, workspace selection, rig
switching, and collapsible control sections.
3. **Worker tests** for decode-history pruning, batching, and message formats.
4. **Browser smoke tests** for startup, authentication gating, plugin loading,
navigation, rig switching, audio controls, and map initialization.
The existing dependency-free `ui-core` test can remain during the scaffold
phase. It should be moved to the standard TypeScript test runner once that
runner is established.
## 12. CI Changes
Add a dedicated frontend job rather than hiding frontend work inside the Cargo
jobs. The job should:
- use the Node.js version provided by the runner image;
- run with `npm ci`, never a mutable install;
- cache the npm download cache, not `node_modules`;
- type-check, lint, test, and build;
- verify generated artifacts and Rust-generated types are current;
- run REUSE validation after generated files are produced.
Rust lint and test jobs should continue to consume committed generated assets.
## 13. Pull Request Strategy
Use small, independently reversible pull requests. A recommended sequence is:
1. tooling and unchanged JavaScript build;
2. generated Rust wire types and typed API client;
3. `ui-core` conversion;
4. worker and WebGL conversion;
5. decoder plugin conversions in small groups;
6. plugin registry and dynamic imports;
7. one `app.js` responsibility per PR;
8. scheduler, bookmarks, and map conversions;
9. generic embedded asset serving;
10. removal of JavaScript compatibility mode.
Every PR should include:
- runtime behavior preserved or intentionally documented;
- frontend type checking and tests;
- strict Rust formatting and Clippy;
- generated-artifact drift verification;
- no newly introduced undocumented `window` globals;
- no remote runtime asset dependency.
## 14. Risks and Mitigations
### Superficial Conversion
**Risk:** globals receive declarations but remain coupled and mutable.
**Mitigation:** require every converted module to expose explicit inputs and
outputs and remove at least the globals it replaces.
### Cargo Becomes Dependent on Node
**Risk:** builds fail on systems without Node or network access.
**Mitigation:** commit deterministic generated output and keep npm outside
`build.rs`.
### Script-Order Regressions
**Risk:** switching to modules changes execution timing and scope.
**Mitigation:** retain current public entries initially, then replace script
loading only after the plugin registry is in place.
### Unreviewable `app.js` Rewrite
**Risk:** a large conversion is difficult to review, test, or bisect.
**Mitigation:** extract one responsibility at a time and keep bootstrap working
after every extraction.
### Rust and Browser Contracts Drift
**Risk:** TypeScript compiles against stale wire definitions.
**Mitigation:** generate types from Rust and enforce a clean working tree after
generation in CI.
### Generated Asset Noise
**Risk:** committed bundles make reviews noisy.
**Mitigation:** separate source and generated commits when useful, include
source maps, and require deterministic output.
### Bundle or Startup Regression
**Risk:** bundling loads too much code eagerly.
**Mitigation:** record the current baseline, preserve feature-level lazy loads,
and track entry/chunk sizes in CI.
## 15. Initial Proof of Concept
The first implementation PR should be deliberately limited to:
1. adding the frontend package and locked toolchain;
2. building existing JavaScript to fixed-name generated output;
3. adding frontend CI and drift checks;
4. generating initial rig/status TypeScript definitions;
5. introducing the typed API client;
6. converting `ui-core.js` to strict TypeScript;
7. preserving every existing asset URL and observable behavior.
This proof of concept is the decision gate for the remainder of the migration.
If it materially improves contract safety without making Cargo development
unwieldy, continue with the phased plan. If it does not, the repository can
retain the tooling and the converted core without committing to a full rewrite.
## 16. Completion Criteria
The migration is complete when:
- all first-party browser source is strict TypeScript;
- vendor files remain isolated and declared through narrow type adapters;
- Rust wire types generate their browser contracts;
- no feature depends on accidental script ordering;
- no undocumented mutable `window` callbacks remain;
- `app.js` has been replaced by a small typed bootstrap and feature modules;
- frontend type checking, linting, unit tests, component tests, browser smoke
tests, and generated-output checks run in CI;
- Cargo builds remain possible without Node.js or network access;
- production assets remain local, embedded, cacheable, and reproducible.
+837
View File
@@ -0,0 +1,837 @@
# WEFAX / Radiofax Decoder Implementation Plan
> **Crate**: `trx-wefax` &mdash; `src/decoders/trx-wefax/`
> **Status**: Implemented (Phases 13b) &mdash; 2026-04-02
## 1. Overview
WEFAX (Weather Facsimile, ITU-T T.4 / WMO) is an analog image transmission
mode used by meteorological agencies worldwide (NOAA, DWD, JMH, etc.) on HF
and satellite downlinks. The decoder converts FM-modulated audio tones into
greyscale (or colour-composited) image lines.
### Goals
- Pure Rust, zero C FFI dependencies (matching project conventions).
- Multi-speed support: **60, 90, 120, 240 LPM** (lines per minute).
- Multi-IOC support: **288 and 576** (Index of Cooperation &mdash; defines
line pixel width).
- Automatic start/stop detection via APT tones.
- Phase-aligned line assembly from phasing signal.
- Incremental image output (line-by-line progress + final PNG).
- Follow existing decoder patterns (`process_block` / `decode_if_ready`).
## 2. WEFAX Signal Structure
```
Carrier (1900 Hz center, ±400 Hz deviation)
Black = 1500 Hz
White = 2300 Hz
(linear mapping between frequency and luminance)
Transmission sequence:
┌─────────────┐
│ Start tone │ 300 Hz (5s) or 675 Hz (3s) — selects IOC 576 / 288
├─────────────┤
│ Phasing │ >95% white line + narrow black pulse — phase alignment
│ (30 lines) │
├─────────────┤
│ Image lines │ N lines at configured LPM
├─────────────┤
│ Stop tone │ 450 Hz (5s) — signals end of transmission
└─────────────┘
```
### Key parameters
| Parameter | IOC 576 | IOC 288 |
|-----------|---------|---------|
| Pixels per line | 1809 | 904 |
| Line duration (120 LPM) | 500 ms | 500 ms |
| Line duration (60 LPM) | 1000 ms | 1000 ms |
| Pixel clock | ~3618 px/s (120 LPM) | ~1808 px/s (120 LPM) |
Pixel count per line = `IOC × π` (rounded: 576×π ≈ 1809, 288×π ≈ 904).
## 3. Architecture
```mermaid
graph TD
PCM["PCM audio (f32, 48 kHz)"] --> RS["Resampler (to internal rate)"]
RS --> FM["FM Discriminator"]
FM --> LPF["Low-pass filter (anti-alias)"]
LPF --> TD["Tone Detector (APT start/stop)"]
LPF --> PA["Phase Aligner"]
PA --> LS["Line Slicer"]
LS --> IMG["Image Assembler"]
IMG --> OUT["WefaxMessage (line / image)"]
TD --> SM["State Machine"]
SM -->|controls| PA
SM -->|controls| LS
```
### Internal sample rate
Resample input to **11,025 Hz** (sufficient for 2300 Hz max tone with
comfortable margin; matches common WEFAX decoder practice and keeps DSP
cost low).
## 4. Module Layout
```
src/decoders/trx-wefax/
Cargo.toml
src/
lib.rs # Public API: WefaxDecoder, WefaxConfig, WefaxEvent
decoder.rs # Top-level decoder state machine + process_block/decode_if_ready
demod.rs # FM discriminator (instantaneous frequency from analytic signal)
tone_detect.rs # Goertzel-based APT tone detector (300/450/675 Hz)
phase.rs # Phasing signal detector and line-start alignment
line_slicer.rs # Pixel clock recovery, line buffer assembly
resampler.rs # Polyphase rational resampler (48k → 11025)
image.rs # Image buffer, PNG encoding, optional colour compositing
config.rs # WefaxConfig: speed, IOC, auto-detect, output path
```
## 5. Core Types
### 5.1 Configuration
```rust
pub struct WefaxConfig {
/// Lines per minute: 60, 90, 120, 240. `None` = auto-detect from APT.
pub lpm: Option<u16>,
/// Index of Cooperation: 288 or 576. `None` = auto-detect from start tone.
pub ioc: Option<u16>,
/// Centre frequency of the FM subcarrier (default 1900 Hz).
pub center_freq_hz: f32,
/// Deviation (default ±400 Hz, so black=1500, white=2300).
pub deviation_hz: f32,
/// Directory for saving decoded images.
pub output_dir: Option<String>,
/// Whether to emit line-by-line progress events.
pub emit_progress: bool,
}
```
### 5.2 Decoder state machine
```rust
pub enum WefaxState {
/// Listening for APT start tone.
Idle,
/// Start tone detected; waiting for phasing signal.
StartDetected { ioc: u16, tone_start_sample: u64 },
/// Receiving phasing lines; aligning line-start phase.
Phasing { ioc: u16, lpm: u16, phase_offset: Option<usize> },
/// Actively decoding image lines.
Receiving { ioc: u16, lpm: u16, line_number: u32 },
/// Stop tone detected; finalising image.
Stopping,
}
```
### 5.3 Output messages (for `trx-core::DecodedMessage`)
```rust
/// A complete or in-progress WEFAX image.
pub struct WefaxMessage {
pub rig_id: Option<String>,
pub ts_ms: Option<i64>,
/// Number of image lines decoded so far.
pub line_count: u32,
/// Detected or configured LPM.
pub lpm: u16,
/// Detected or configured IOC.
pub ioc: u16,
/// Pixels per line (IOC × π, rounded).
pub pixels_per_line: u16,
/// Filesystem path to saved PNG (set on completion).
pub path: Option<String>,
/// True when image is complete (stop tone received).
pub complete: bool,
}
/// Progress update emitted every N lines during active reception.
pub struct WefaxProgress {
pub rig_id: Option<String>,
pub line_count: u32,
pub lpm: u16,
pub ioc: u16,
}
```
## 6. DSP Pipeline Detail
### 6.1 Resampling
Rational polyphase resampler: 48000 → 11025 Hz (ratio 441/1920, simplified
from 11025/48000). Follow `docs/Optimization-Guidelines.md` polyphase
resampler guidance. Same pattern as FT8 decoder's 48k→12k resampler.
### 6.2 FM Discriminator
Compute instantaneous frequency from the analytic signal:
1. **Hilbert transform** (FIR, 65-tap) to produce analytic signal `z[n]`.
2. **Instantaneous frequency**: `f[n] = arg(z[n] · conj(z[n-1])) / (2π·Ts)`
3. Map frequency to luminance: `pixel = clamp((f - 1500) / 800, 0, 1)`.
The Hilbert + frequency discriminator approach avoids PLL complexity and works
well for the relatively low data rate of WEFAX.
### 6.3 APT Tone Detection
Use **Goertzel filters** at three frequencies (matching `trx-cw` pattern):
| Tone | Frequency | Meaning |
|------|-----------|---------|
| Start (IOC 576) | 300 Hz | Begin reception, IOC=576 |
| Start (IOC 288) | 675 Hz | Begin reception, IOC=288 |
| Stop | 450 Hz | End of transmission |
Detection window: ~200 ms (2205 samples at 11025 Hz). Require sustained
detection for ≥1.5 s to confirm (debounce against noise). Energy ratio
vs broadband noise for reliability.
### 6.4 Phasing Signal Detection
During phasing, each line is >95% white (2300 Hz) with a narrow black pulse
(~5% of line width) at the line-start position.
1. After start tone, begin accumulating demodulated samples.
2. Slice into line-duration windows (e.g., 500 ms for 120 LPM).
3. Cross-correlate against expected phasing template (short black pulse).
4. Average pulse position over 10+ phasing lines → line-start phase offset.
5. Transition to `Receiving` once phase is stable (variance < 2 samples).
### 6.5 Line Slicing and Pixel Clock
Once phased:
1. Accumulate demodulated (frequency → luminance) samples.
2. At each line boundary (determined by LPM and phase offset), extract
one line of `pixels_per_line` values via linear interpolation from
the sample buffer.
3. Push completed line into the image assembler.
4. Emit `WefaxProgress` every 50 lines (configurable).
### 6.6 Image Assembly
- Maintain a `Vec<Vec<u8>>` of greyscale lines (0255).
- On stop tone or manual stop: encode to 8-bit greyscale PNG.
- Save to `output_dir` with filename pattern:
`WEFAX-{YYYY}-{MM}-{DD}T{HH}{mm}{ss}-IOC{ioc}-{lpm}lpm.png`
- Return `WefaxMessage` with `complete: true` and `path` set.
## 7. Integration with trx-rs
### 7.1 Workspace registration
Add to root `Cargo.toml` workspace members:
```toml
"src/decoders/trx-wefax"
```
### 7.2 `trx-core` changes
Add variants to `DecodedMessage`:
```rust
#[serde(rename = "wefax")]
Wefax(WefaxMessage),
#[serde(rename = "wefax_progress")]
WefaxProgress(WefaxProgress),
```
Update `set_rig_id()` / `rig_id()` match arms.
### 7.3 `trx-server` integration
Add `run_wefax_decoder()` in `audio.rs` following the existing pattern:
```rust
pub async fn run_wefax_decoder(
sample_rate: u32,
channels: u16,
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
state_rx: watch::Receiver<RigState>,
decode_tx: broadcast::Sender<DecodedMessage>,
logs: Option<Arc<DecoderLoggers>>,
histories: Arc<DecoderHistories>,
)
```
Spawn in `main.rs` alongside other decoders, gated by mode (USB/LSB on
HF WEFAX frequencies).
### 7.4 History and logging
- Add `wefax: Arc<Mutex<VecDeque<WefaxMessage>>>` to `DecoderHistories`.
- Add optional `wefax` logger to `DecoderLoggers` (JSON Lines).
### 7.5 Frontend exposure
The web frontend follows the existing decoder plugin pattern used by WSPR,
FT8, AIS, etc. WEFAX is unique among decoders because it produces **images**
rather than text rows, so the UI uses a `<canvas>` for live line-by-line
rendering instead of the tabular layout used by other decoders.
#### 7.5.1 Rust backend wiring (`trx-frontend-http`)
**`src/status.rs`** &mdash; embed the plugin script:
```rust
pub const WEFAX_JS: &str = include_str!("../assets/web/plugins/wefax.js");
```
**`src/api/assets.rs`** &mdash; define the gzip-cached route:
```rust
define_gz_cache!(gz_wefax_js, status::WEFAX_JS, "wefax.js");
#[get("/wefax.js")]
pub(crate) async fn wefax_js(req: HttpRequest) -> impl Responder {
let c = gz_wefax_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
```
**`src/api/decoder.rs`** &mdash; add endpoints:
```rust
#[post("/toggle_wefax_decode")]
pub async fn toggle_wefax_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().decoders.wefax_decode_enabled;
send_command(
&rig_tx,
RigCommand::SetWefaxDecodeEnabled(!enabled),
query.into_inner().remote,
)
.await
}
#[post("/clear_wefax_decode")]
pub async fn clear_wefax_decode(
query: web::Query<RemoteQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_wefax_history(context.get_ref());
send_command(
&rig_tx,
RigCommand::ResetWefaxDecoder,
query.into_inner().remote,
)
.await
}
```
**`src/api/mod.rs`** &mdash; register in `configure()`:
```rust
.service(decoder::toggle_wefax_decode)
.service(decoder::clear_wefax_decode)
.service(assets::wefax_js)
```
**Decode history** &mdash; add `"wefax"` key to the CBOR payload returned
by `GET /decode/history`, containing `Vec<WefaxMessage>` (completed images
only; in-progress images are streamed via SSE).
**SSE `/decode` stream** &mdash; broadcast two event shapes:
```json
{"wefax_progress": {"line_count": 142, "lpm": 120, "ioc": 576, "pixels_per_line": 1809,
"line_data": "<base64-encoded u8 greyscale row>"}}
{"wefax": {"ts_ms": 1712000000000, "line_count": 800, "lpm": 120, "ioc": 576,
"pixels_per_line": 1809, "complete": true,
"path": "/images/WEFAX-2026-04-02T1430-IOC576-120lpm.png"}}
```
`wefax_progress` events carry a base64 `line_data` field (one image row of
greyscale bytes) so the browser can paint each line as it arrives without
needing a separate WebSocket channel.
**Decoder registry** &mdash; add entry to `DECODER_REGISTRY` in
`trx-protocol`:
```rust
DecoderRegistryEntry {
id: "wefax",
label: "WEFAX",
activation: "toggle", // enable/disable button
active_modes: &["usb", "lsb", "am"],
background_decode: false,
bookmark_selectable: true,
}
```
#### 7.5.2 HTML additions (`index.html`)
**Sub-tab button** (inside `.sub-tab-bar`, after the existing decoder
buttons):
```html
<button class="sub-tab" data-subtab="wefax" id="subtab-wefax">WEFAX</button>
```
**Sub-tab panel** (alongside other `sub-tab-panel` divs):
```html
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls">
<button id="wefax-decode-toggle-btn" type="button">Enable WEFAX</button>
<button id="wefax-clear-btn" type="button"
style="margin-left:0.5rem; font-size:0.8rem;">Clear</button>
<small id="wefax-status" style="color:var(--text-muted);">Idle</small>
</div>
<!-- Live image canvas — painted line-by-line during reception -->
<div id="wefax-live-container" style="display:none; margin:0.5rem 0;">
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.3rem;">
<strong>Receiving</strong>
<small id="wefax-live-info" style="color:var(--text-muted);"></small>
</div>
<canvas id="wefax-live-canvas" width="1809" height="800"
style="width:100%; image-rendering:pixelated; background:#000;"></canvas>
</div>
<!-- Gallery of completed images -->
<div id="wefax-gallery" style="display:flex; flex-wrap:wrap; gap:0.5rem;"></div>
</div>
```
**Overview section** (inside the digital-modes overview panel):
```html
<div class="plugin-item" data-decoder="wefax">
<strong>WEFAX Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
Weather Facsimile &mdash; HF/satellite image reception (60/90/120/240 LPM)
</div>
</div>
```
**About section** (in the About tab decoder list):
```html
<tr id="about-dec-wefax"><td>WEFAX</td><td>Weather Facsimile decoder</td></tr>
```
#### 7.5.3 Plugin script registration
**`index.html` plugin map** &mdash; add `'/wefax.js'` to the
`'digital-modes'` array in `pluginScripts`:
```javascript
var pluginScripts = {
'digital-modes': ['/ft8.js', ..., '/wefax.js'],
// ...
};
```
#### 7.5.4 SSE dispatch in `app.js`
Add WEFAX to the decode event dispatcher (inside `decodeSource.onmessage`):
```javascript
if (msg.wefax_progress && window.onServerWefaxProgress) {
window.onServerWefaxProgress(msg.wefax_progress);
}
if (msg.wefax && window.onServerWefax) {
window.onServerWefax(msg.wefax);
}
```
Add `"wefax"` to the decode history restore loop:
```javascript
// In loadDecodeHistoryOnMainThread / worker dispatch:
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs",
"cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
```
Add WEFAX to `restoreDecodeHistoryGroup()`:
```javascript
case "wefax":
if (window.restoreWefaxHistory) window.restoreWefaxHistory(messages);
break;
```
#### 7.5.5 Plugin file (`assets/web/plugins/wefax.js`)
Full plugin structure following the project's vanilla-JS decoder plugin
pattern:
```javascript
// ---------------------------------------------------------------------------
// wefax.js — WEFAX decoder plugin for trx-frontend-http
// ---------------------------------------------------------------------------
// --- DOM refs ---
const wefaxStatus = document.getElementById('wefax-status');
const wefaxLiveContainer= document.getElementById('wefax-live-container');
const wefaxLiveInfo = document.getElementById('wefax-live-info');
const wefaxLiveCanvas = document.getElementById('wefax-live-canvas');
const wefaxGallery = document.getElementById('wefax-gallery');
const wefaxToggleBtn = document.getElementById('wefax-decode-toggle-btn');
const wefaxClearBtn = document.getElementById('wefax-clear-btn');
// --- State ---
let wefaxImageHistory = []; // completed WefaxMessage objects
let wefaxLiveCtx = null; // canvas 2D context
let wefaxLiveLineCount = 0; // lines painted so far
let wefaxLivePixelsPerLine = 1809;
// --- Helpers ---
function currentWefaxHistoryRetentionMs() {
return window.getDecodeHistoryRetentionMs?.() || 24 * 60 * 60 * 1000;
}
function pruneWefaxHistory() {
const cutoff = Date.now() - currentWefaxHistoryRetentionMs();
wefaxImageHistory = wefaxImageHistory.filter(m => (m._tsMs || 0) > cutoff);
}
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
// --- Live canvas rendering ---
/** Reset canvas for a new image reception. */
function resetLiveCanvas(pixelsPerLine) {
wefaxLivePixelsPerLine = pixelsPerLine;
wefaxLiveLineCount = 0;
wefaxLiveCanvas.width = pixelsPerLine;
wefaxLiveCanvas.height = 800; // grows if needed
wefaxLiveCtx = wefaxLiveCanvas.getContext('2d');
wefaxLiveCtx.fillStyle = '#000';
wefaxLiveCtx.fillRect(0, 0, wefaxLiveCanvas.width, wefaxLiveCanvas.height);
wefaxLiveContainer.style.display = '';
}
/** Append one greyscale line (Uint8Array) to the live canvas. */
function paintLine(lineBytes) {
if (!wefaxLiveCtx) return;
const y = wefaxLiveLineCount;
// Grow canvas vertically if needed (double height strategy).
if (y >= wefaxLiveCanvas.height) {
const old = wefaxLiveCtx.getImageData(
0, 0, wefaxLiveCanvas.width, wefaxLiveCanvas.height);
wefaxLiveCanvas.height *= 2;
wefaxLiveCtx.putImageData(old, 0, 0);
}
const w = wefaxLivePixelsPerLine;
const imgData = wefaxLiveCtx.createImageData(w, 1);
const d = imgData.data;
for (let x = 0; x < w; x++) {
const v = x < lineBytes.length ? lineBytes[x] : 0;
const i = x * 4;
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
}
wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++;
}
// --- Gallery rendering ---
function renderGalleryThumbnail(msg) {
const card = document.createElement('div');
card.className = 'wefax-card';
card.style.cssText =
'border:1px solid var(--border-color); border-radius:4px; ' +
'padding:0.4rem; max-width:280px; cursor:pointer;';
const ts = msg._tsMs
? new Date(msg._tsMs).toLocaleString()
: '—';
const info = `${msg.ioc} IOC · ${msg.lpm} LPM · ${msg.line_count} lines`;
// If a server path is available, show a thumbnail linking to it.
if (msg.path) {
card.innerHTML =
`<img src="/images/${escapeHtml(msg.path.split('/').pop())}"
alt="WEFAX" loading="lazy"
style="width:100%; image-rendering:pixelated;" />` +
`<div style="font-size:0.8rem; margin-top:0.2rem;">${escapeHtml(ts)}</div>` +
`<div style="font-size:0.75rem; color:var(--text-muted);">${info}</div>`;
} else {
card.innerHTML =
`<div style="font-size:0.8rem;">${escapeHtml(ts)}</div>` +
`<div style="font-size:0.75rem; color:var(--text-muted);">${info}</div>`;
}
return card;
}
function renderWefaxGallery() {
pruneWefaxHistory();
const frag = document.createDocumentFragment();
for (const msg of wefaxImageHistory) {
frag.appendChild(renderGalleryThumbnail(msg));
}
wefaxGallery.innerHTML = '';
wefaxGallery.appendChild(frag);
}
function scheduleWefaxGalleryRender() {
if (window.trxScheduleUiFrameJob) {
window.trxScheduleUiFrameJob('wefax-gallery', renderWefaxGallery);
} else {
requestAnimationFrame(renderWefaxGallery);
}
}
// --- SSE event handlers (public API) ---
/** Called for each wefax_progress SSE event (one image line). */
window.onServerWefaxProgress = function (msg) {
// First progress event of a new image → reset canvas.
if (msg.line_count <= 1 || !wefaxLiveCtx) {
resetLiveCanvas(msg.pixels_per_line || 1809);
}
// Decode base64 line_data → Uint8Array → paint.
if (msg.line_data) {
const binary = atob(msg.line_data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
paintLine(bytes);
}
// Update status text.
if (wefaxLiveInfo) {
wefaxLiveInfo.textContent =
`Line ${msg.line_count} · ${msg.ioc} IOC · ${msg.lpm} LPM`;
}
if (wefaxStatus) {
wefaxStatus.textContent = `Receiving — line ${msg.line_count}`;
wefaxStatus.style.color = 'var(--text-accent)';
}
};
/** Called when a complete WEFAX image is received. */
window.onServerWefax = function (msg) {
msg._tsMs = msg.ts_ms || Date.now();
wefaxImageHistory.unshift(msg);
pruneWefaxHistory();
scheduleWefaxGalleryRender();
// Finalise live canvas — trim height to actual line count.
if (wefaxLiveCtx && wefaxLiveLineCount > 0) {
const trimmed = wefaxLiveCtx.getImageData(
0, 0, wefaxLiveCanvas.width, wefaxLiveLineCount);
wefaxLiveCanvas.height = wefaxLiveLineCount;
wefaxLiveCtx.putImageData(trimmed, 0, 0);
}
if (wefaxStatus) {
wefaxStatus.textContent = `Complete — ${msg.line_count} lines`;
wefaxStatus.style.color = '';
}
};
/** Batch restore from decode history (page load). */
window.restoreWefaxHistory = function (messages) {
if (!messages || !messages.length) return;
for (const m of messages) {
m._tsMs = m.ts_ms || Date.now();
}
wefaxImageHistory = messages.concat(wefaxImageHistory);
pruneWefaxHistory();
scheduleWefaxGalleryRender();
};
/** Called by history retention pruning cycle. */
window.pruneWefaxHistoryView = function () {
pruneWefaxHistory();
scheduleWefaxGalleryRender();
};
/** Full reset (rig change, clear). */
window.resetWefaxHistoryView = function () {
wefaxImageHistory = [];
wefaxGallery.innerHTML = '';
wefaxLiveContainer.style.display = 'none';
wefaxLiveCtx = null;
wefaxLiveLineCount = 0;
if (wefaxStatus) {
wefaxStatus.textContent = 'Idle';
wefaxStatus.style.color = '';
}
};
// --- Button handlers ---
if (wefaxClearBtn) {
wefaxClearBtn.addEventListener('click', function () {
fetch('/clear_wefax_decode', { method: 'POST' });
window.resetWefaxHistoryView();
});
}
```
#### 7.5.6 Data flow summary
```mermaid
sequenceDiagram
participant Server as trx-server (wefax decoder)
participant SSE as SSE /decode
participant Plugin as wefax.js
participant Canvas as <canvas>
participant Gallery as Gallery div
Server->>SSE: wefax_progress (line_data base64)
SSE->>Plugin: onServerWefaxProgress()
Plugin->>Canvas: paintLine() — one greyscale row
Note over Server: ...repeats per line...
Server->>SSE: wefax (complete=true, path)
SSE->>Plugin: onServerWefax()
Plugin->>Canvas: trim canvas to final height
Plugin->>Gallery: renderGalleryThumbnail()
```
#### 7.5.7 Image serving
Completed PNG files saved by the decoder need an HTTP route for browser
access. Add a static-file route in `assets.rs`:
```rust
#[get("/images/{filename}")]
pub(crate) async fn wefax_image(
req: HttpRequest,
path: web::Path<String>,
) -> impl Responder {
// Serve from WefaxConfig::output_dir, validate filename (no path traversal).
// Content-Type: image/png, Cache-Control: public, max-age=86400.
}
```
Register in `api/mod.rs`:
```rust
.service(assets::wefax_image)
```
#### 7.5.8 Decode history worker update
Add `"wefax"` to `HISTORY_GROUP_KEYS` in `decode-history-worker.js`:
```javascript
const HISTORY_GROUP_KEYS = [
"ais", "vdes", "aprs", "hf_aprs", "cw",
"ft8", "ft4", "ft2", "wspr", "wefax"
];
```
## 8. Implementation Phases
### Phase 1: Core DSP (MVP) ✅
1.**Resampler** &mdash; 48k→11025 polyphase resampler with tests.
2.**FM discriminator** &mdash; Hilbert FIR + instantaneous freq, verify
against synthetic 15002300 Hz sweeps.
3.**Tone detector** &mdash; Goertzel at 300/450/675 Hz with debounce.
4.**Line slicer** &mdash; Fixed-config (manual LPM+IOC) line extraction.
5.**Image buffer + PNG** &mdash; Greyscale line accumulation, `png`
crate for encoding.
Deliverable: decode a known WEFAX WAV recording at a single speed/IOC.
### Phase 2: Automatic Detection ✅
6.**State machine** &mdash; Full `Idle→StartDetected→Phasing→Receiving→Stopping`
transitions driven by tone detector.
7.**Phase alignment** &mdash; Cross-correlation phasing detector.
8.**Auto IOC/LPM** &mdash; IOC from start tone frequency; LPM from phasing
line duration measurement.
Deliverable: fully automatic reception of a single image without manual config.
### Phase 3: Server Integration ✅
9.**`trx-core` message types** &mdash; `WefaxMessage`, `WefaxProgress` in
`DecodedMessage`.
10.**`trx-server` task** &mdash; `run_wefax_decoder()`, history, logging.
11.**Protocol registry** &mdash; `DECODER_REGISTRY` entry for `"wefax"`.
Deliverable: backend wefax decoding with SSE event broadcast.
### Phase 3b: Frontend Wiring ✅
12.**Rust asset pipeline** &mdash; `status.rs` embed, `assets.rs` gzip
cache + route, `decoder.rs` toggle/clear endpoints, `api/mod.rs`
registration (§7.5.1).
13.**HTML scaffold** &mdash; sub-tab button, sub-tab panel with canvas +
gallery, overview entry, about row (§7.5.2).
14.**Plugin loading** &mdash; add `/wefax.js` to `pluginScripts`
`'digital-modes'` array (§7.5.3).
15.**SSE dispatch** &mdash; `wefax` / `wefax_progress` handlers in
`app.js` decode event dispatcher (§7.5.4).
16.**`wefax.js` plugin** &mdash; live canvas rendering, gallery
thumbnails, history restore, toggle/clear wiring (§7.5.5).
17. **Image serving** &mdash; `/images/{filename}` static route for
completed PNGs (§7.5.7). *(deferred: images served from output_dir)*
18.**History worker** &mdash; add `"wefax"` to `HISTORY_GROUP_KEYS`
(§7.5.8).
Deliverable: end-to-end live WEFAX decoding with in-browser image preview.
### Phase 4: Polish
19. **Multi-speed runtime switching** &mdash; handle back-to-back
transmissions at different LPM within one session.
20. **Slant correction** &mdash; fine-tune sample clock drift compensation
using phasing pulse tracking.
21. **Colour compositing** &mdash; optional IR + visible overlay for
satellite WEFAX (future).
22. **Test suite** &mdash; synthetic signal generation, round-trip tests,
edge cases (partial images, noise, frequency offset).
## 9. Dependencies
```toml
[dependencies]
trx-core = { path = "../../trx-core" }
rustfft = "6" # Hilbert transform FIR via FFT overlap-save (optional)
png = "0.17" # PNG encoding (lightweight, no image full dep)
```
No additional heavy dependencies required. The DSP components (Goertzel,
polyphase resampler, Hilbert FIR) are small enough to implement inline,
consistent with the pure-Rust approach of `trx-rds`, `trx-cw`, and
`trx-ftx`.
## 10. Testing Strategy
| Test | Method |
|------|--------|
| FM discriminator accuracy | Synthesise known-frequency tones, verify ±1 Hz |
| Tone detection | Inject 300/450/675 Hz bursts, verify timing |
| Phase alignment | Synthetic phasing signal with known pulse position |
| Line pixel accuracy | Known gradient pattern → verify pixel values |
| Full decode round-trip | Reference WEFAX WAV → compare output PNG against known-good |
| Multi-speed switching | Sequential 120 LPM + 60 LPM images in one stream |
| Noise resilience | Add white noise at various SNR, verify graceful degradation |
## 11. References
- ITU-R BT.601 (facsimile signal characteristics)
- WMO Manual on the GTS, Attachment II-13 (HF radiofax schedule/format)
- NOAA Radiofax Charts: frequency schedules and IOC/LPM per product
- Existing open-source implementations: `fldigi` WEFAX module, `multimon-ng`
-18
View File
@@ -1,18 +0,0 @@
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
#
# SPDX-License-Identifier: BSD-2-Clause
[package]
name = "trx-plugin-example"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
trx-backend = { path = "../../src/trx-server/trx-backend" }
trx-core = { path = "../../src/trx-core" }
trx-frontend = { path = "../../src/trx-client/trx-frontend" }
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
-19
View File
@@ -1,19 +0,0 @@
# trx-plugin-example
This is a minimal shared-library plugin that registers a backend and frontend.
The backend is a stub that returns an error; the frontend is a no-op spawner.
Build:
```bash
cargo build -p trx-plugin-example --release
```
Install (example):
```bash
mkdir -p plugins
cp target/release/libtrx_plugin_example.* plugins/
```
Run `trx-server` or `trx-client` with `TRX_PLUGIN_DIRS=./plugins` to discover the plugin.
-50
View File
@@ -1,50 +0,0 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
use std::net::SocketAddr;
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use tracing::info;
use trx_backend::{RegistrationContext, RigAccess};
use trx_core::{DynResult, RigRequest, RigState};
use trx_frontend::{FrontendRuntimeContext, FrontendSpawner, FrontendRegistrationContext};
const BACKEND_NAME: &str = "example";
const FRONTEND_NAME: &str = "example-frontend";
/// Entry point called by trx-server when the plugin is loaded.
#[no_mangle]
pub extern "C" fn trx_register_backend(context: *mut std::ffi::c_void) {
let context = unsafe { &mut *(context as *mut RegistrationContext) };
context.register_backend(BACKEND_NAME, example_backend_factory);
}
/// Entry point called by trx-client when the plugin is loaded.
#[no_mangle]
pub extern "C" fn trx_register_frontend(context: *mut std::ffi::c_void) {
let context = unsafe { &mut *(context as *mut FrontendRegistrationContext) };
context.register_frontend(FRONTEND_NAME, ExampleFrontend::spawn_frontend);
}
fn example_backend_factory(_access: RigAccess) -> DynResult<Box<dyn trx_core::rig::RigCat>> {
Err("example plugin backend not implemented".into())
}
struct ExampleFrontend;
impl FrontendSpawner for ExampleFrontend {
fn spawn_frontend(
_state_rx: watch::Receiver<RigState>,
_rig_tx: mpsc::Sender<RigRequest>,
_callsign: Option<String>,
listen_addr: SocketAddr,
_context: std::sync::Arc<FrontendRuntimeContext>,
) -> JoinHandle<()> {
tokio::spawn(async move {
info!("example frontend loaded at {} (no-op)", listen_addr);
})
}
}
-33
View File
@@ -1,33 +0,0 @@
BasedOnStyle: WebKit
# Cpp11BracedListStyle: false
# ColumnLimit: 120
IndentCaseLabels: false
IndentExternBlock: false
IndentWidth: 4
TabWidth: 8
UseTab: Never
PointerAlignment: Left
SortIncludes: false
AlignConsecutiveMacros: true
AllowShortBlocksOnASingleLine: false
AllowShortCaseLabelsOnASingleLine: false
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
AllowShortFunctionsOnASingleLine: false
AlignTrailingComments: true
BreakConstructorInitializers: BeforeColon
ConstructorInitializerAllOnOneLineOrOnePerLine: true
ConstructorInitializerIndentWidth: 0
BreakBeforeBraces: Custom
BreakBeforeBinaryOperators: All
BraceWrapping:
AfterControlStatement: true
AfterClass: true
AfterEnum: true
AfterFunction: true
AfterNamespace: true
AfterStruct: true
AfterUnion: true
AfterExternBlock: true
BeforeElse: true
BeforeCatch: true
-9
View File
@@ -1,9 +0,0 @@
gen_ft8
decode_ft8
test_ft8
libft8.a
wsjtx2/
.build/
.DS_Store
.vscode/
__pycache__/
-58
View File
@@ -1,58 +0,0 @@
BUILD_DIR = .build
FT8_SRC = $(wildcard ft8/*.c)
FT8_OBJ = $(patsubst %.c,$(BUILD_DIR)/%.o,$(FT8_SRC))
COMMON_SRC = $(wildcard common/*.c)
COMMON_OBJ = $(patsubst %.c,$(BUILD_DIR)/%.o,$(COMMON_SRC))
FFT_SRC = $(wildcard fft/*.c)
FFT_OBJ = $(patsubst %.c,$(BUILD_DIR)/%.o,$(FFT_SRC))
TARGETS = libft8.a gen_ft8 decode_ft8 test_ft8
ifdef FT8_DEBUG
CFLAGS = -fsanitize=address -ggdb3 -DHAVE_STPCPY -I. -DFTX_DEBUG_PRINT
LDFLAGS = -fsanitize=address -lm
else
CFLAGS = -O3 -DHAVE_STPCPY -I.
LDFLAGS = -lm
endif
# Optionally, use Portaudio for live audio input
# Portaudio is a C++ library, so then you need to set CC=clang++ or CC=g++
ifdef PORTAUDIO_PREFIX
CFLAGS += -DUSE_PORTAUDIO -I$(PORTAUDIO_PREFIX)/include
LDFLAGS += -lportaudio -L$(PORTAUDIO_PREFIX)/lib
endif
.PHONY: all clean run_tests install
all: $(TARGETS)
clean:
rm -rf $(BUILD_DIR) $(TARGETS)
run_tests: test_ft8
@./test_ft8
install: libft8.a
install libft8.a /usr/lib/libft8.a
gen_ft8: $(BUILD_DIR)/demo/gen_ft8.o libft8.a
$(CC) $(CFLAGS) -o $@ .build/demo/gen_ft8.o -lft8 -L. -lm
decode_ft8: $(BUILD_DIR)/demo/decode_ft8.o libft8.a $(FFT_OBJ)
$(CC) $(CFLAGS) -o $@ $(BUILD_DIR)/demo/decode_ft8.o $(FFT_OBJ) -lft8 -L. -lm
test_ft8: $(BUILD_DIR)/test/test.o libft8.a
$(CC) $(CFLAGS) -o $@ .build/test/test.o -lft8 -L. -lm
$(BUILD_DIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -o $@ -c $^
lib: libft8.a
libft8.a: $(FT8_OBJ) $(COMMON_OBJ)
$(AR) rc libft8.a $(FT8_OBJ) $(COMMON_OBJ)
-54
View File
@@ -1,54 +0,0 @@
# FT8 (and now FT4) library
C implementation of a lightweight FT8/FT4 decoder and encoder, mostly intended for experimental use on microcontrollers.
The intent of this library is to allow FT8/FT4 encoding and decoding in standalone environments (i.e. without a PC or RPi), e.g. automated beacons or SDR transceivers. It's also my learning process, optimization problem and source of fun.
The encoding process is relatively light on resources, and an Arduino should be perfectly capable of running this code.
The decoder is designed with memory and computing efficiency in mind, in order to be usable with a fast enough microcontroller. It is shown to be working on STM32F7 boards fast enough for real work, but the embedded application itself is beyond this repository. This repository provides an example decoder which can decode a 15-second WAV file on a desktop machine or SBC. The decoder needs to access the whole 15-second window in spectral magnitude representation (the window can be also shorter, and messages can have varying starting time within the window). The example FT8 decoder can work with slightly less than 200 KB of RAM.
# Current state
Currently the basic message set for establishing QSOs, as well as telemetry and free-text message modes are supported:
* CQ {call} {grid}, e.g. CQ CA0LL GG77
* CQ {xy} {call} {grid}, e.g. CQ JA CA0LL GG77
* {call} {call} {report}, e.g. CA0LL OT7ER R-07
* {call} {call} 73/RRR/RR73, e.g. OT7ER CA0LL 73
* Free-text messages (up to 13 characters from a limited alphabet) (decoding only, untested)
* Telemetry data (71 bits as 18 hex symbols)
Encoding and decoding works for both FT8 and FT4. For encoding and decoding, there is a console application provided for each, which serves mostly as test code, and could be a starting point for your potential application on an MCU. The console apps should run perfectly well on a RPi or a PC/Mac. I don't provide a concrete example for a particular MCU hardware here, since it would be very specific.
The code is not yet really a library, rather a collection of routines and example code.
# Future ideas
Incremental decoding (processing during the 15 second window) is something that I would like to explore, but haven't started.
These features are low on my priority list:
* Contest modes
* Compound callsigns with country prefixes and special callsigns
# What to do with it
You can generate 15-second WAV files with your own messages as a proof of concept or for testing purposes. They can either be played back or opened directly from WSJT-X. To do that, run ```make```. Then run ```gen_ft8``` (run it without parameters to check what parameters are supported). Currently messages are modulated at 1000-1050 Hz.
You can decode 15-second (or shorter) WAV files with ```decode_ft8```. This is only an example application and does not support live processing/recording. For that you could use third party code (PortAudio, for example).
# References and credits
Thanks goes out to:
* my contributors who have provided me with various improvements which have often been beyond my skill set.
* Robert Morris, AB1HL, whose Python code (https://github.com/rtmrtmrtmrtm/weakmon) inspired this and helped to test various parts of the code.
* Mark Borgerding for his FFT implementation (https://github.com/mborgerding/kissfft). I have included a portion of his code.
* WSJT-X authors, who developed a very interesting and novel communications protocol
The details of FT4 and FT8 procotols and decoding/encoding are described here: https://physics.princeton.edu/pulsar/k1jt/FT4_FT8_QEX.pdf
The public part of FT4/FT8 implementation is included in this repository under ft4_ft8_public.
Of course in moments of frustration I have looked up the original WSJT-X code, which is mostly written in Fortran (http://physics.princeton.edu/pulsar/K1JT/wsjtx.html). However, this library contains my own original DSP routines and a different implementation of the decoder which is suitable for resource-constrained embedded environments.
Karlis Goba,
YL3JG
-191
View File
@@ -1,191 +0,0 @@
#include "audio.h"
#include <stdio.h>
#include <string.h>
#ifdef USE_PORTAUDIO
#include <portaudio.h>
typedef struct
{
PaStream* instream;
} audio_context_t;
static audio_context_t audio_context;
static int audio_cb(void* inputBuffer, void* outputBuffer, unsigned long framesPerBuffer,
const PaStreamCallbackTimeInfo* timeInfo, PaStreamCallbackFlags statusFlags, void* userData)
{
audio_context_t* context = (audio_context_t*)userData;
float* samples_in = (float*)inputBuffer;
// PaTime time = data->startTime + timeInfo->inputBufferAdcTime;
printf("Callback with %ld samples\n", framesPerBuffer);
return 0;
}
void audio_list(void)
{
PaError pa_rc;
pa_rc = Pa_Initialize(); // Initialize PortAudio
if (pa_rc != paNoError)
{
printf("Error initializing PortAudio.\n");
printf("\tErrortext: %s\n\tNumber: %d\n", Pa_GetErrorText(pa_rc), pa_rc);
return;
}
int numDevices;
numDevices = Pa_GetDeviceCount();
if (numDevices < 0)
{
printf("ERROR: Pa_CountDevices returned 0x%x\n", numDevices);
return;
}
printf("%d audio devices found:\n", numDevices);
for (int i = 0; i < numDevices; i++)
{
const PaDeviceInfo* deviceInfo = Pa_GetDeviceInfo(i);
PaStreamParameters inputParameters = {
.device = i,
.channelCount = 1, // 1 = mono, 2 = stereo
.sampleFormat = paFloat32,
.suggestedLatency = 0.2,
.hostApiSpecificStreamInfo = NULL
};
double sample_rate = 12000; // sample rate (frames per second)
pa_rc = Pa_IsFormatSupported(&inputParameters, NULL, sample_rate);
printf("%d: [%s] [%s]\n", (i + 1), deviceInfo->name, (pa_rc == paNoError) ? "OK" : "NOT SUPPORTED");
}
}
int audio_init(void)
{
PaError pa_rc;
pa_rc = Pa_Initialize(); // Initialize PortAudio
if (pa_rc != paNoError)
{
printf("Error initializing PortAudio.\n");
printf("\tErrortext: %s\n\tNumber: %d\n", Pa_GetErrorText(pa_rc), pa_rc);
Pa_Terminate(); // I don't think we need this but...
return -1;
}
return 0;
}
int audio_open(const char* name)
{
PaError pa_rc;
audio_context.instream = NULL;
PaDeviceIndex ndevice_in = -1;
int numDevices = Pa_GetDeviceCount();
for (int i = 0; i < numDevices; i++)
{
const PaDeviceInfo* deviceInfo = Pa_GetDeviceInfo(i);
if (0 == strcmp(deviceInfo->name, name))
{
ndevice_in = i;
break;
}
}
if (ndevice_in < 0)
{
printf("Could not find device [%s].\n", name);
audio_list();
return -1;
}
unsigned long nfpb = 1920 / 4; // frames per buffer
double sample_rate = 12000; // sample rate (frames per second)
PaStreamParameters inputParameters = {
.device = ndevice_in,
.channelCount = 1, // 1 = mono, 2 = stereo
.sampleFormat = paFloat32,
.suggestedLatency = 0.2,
.hostApiSpecificStreamInfo = NULL
};
// Test if this configuration actually works, so we do not run into an ugly assertion
pa_rc = Pa_IsFormatSupported(&inputParameters, NULL, sample_rate);
if (pa_rc != paNoError)
{
printf("Error opening input audio stream.\n");
printf("\tErrortext: %s\n\tNumber: %d\n", Pa_GetErrorText(pa_rc), pa_rc);
return -2;
}
PaStream* instream;
pa_rc = Pa_OpenStream(
&instream, // address of stream
&inputParameters,
NULL,
sample_rate, // Sample rate
nfpb, // Frames per buffer
paNoFlag,
NULL /*(PaStreamCallback*)audio_cb*/, // Callback routine
NULL /*(void*)&audio_context*/); // address of data structure
if (pa_rc != paNoError)
{ // We should have no error here usually
printf("Error opening input audio stream:\n");
printf("\tErrortext: %s\n\tNumber: %d\n", Pa_GetErrorText(pa_rc), pa_rc);
return -3;
}
// printf("Successfully opened audio input.\n");
pa_rc = Pa_StartStream(instream); // Start input stream
if (pa_rc != paNoError)
{
printf("Error starting input audio stream!\n");
printf("\tErrortext: %s\n\tNumber: %d\n", Pa_GetErrorText(pa_rc), pa_rc);
return -4;
}
audio_context.instream = instream;
// while (Pa_IsStreamActive(instream))
// {
// Pa_Sleep(100);
// }
// Pa_AbortStream(instream); // Abort stream
// Pa_CloseStream(instream); // Close stream, we're done.
return 0;
}
int audio_read(float* buffer, int num_samples)
{
PaError pa_rc;
pa_rc = Pa_ReadStream(audio_context.instream, (void*)buffer, num_samples);
return 0;
}
#else
int audio_init(void)
{
return -1;
}
void audio_list(void)
{
}
int audio_open(const char* name)
{
return -1;
}
int audio_read(float* buffer, int num_samples)
{
return -1;
}
#endif
-18
View File
@@ -1,18 +0,0 @@
#ifndef _INCLUDE_AUDIO_H_
#define _INCLUDE_AUDIO_H_
#ifdef __cplusplus
extern "C"
{
#endif
int audio_init(void);
void audio_list(void);
int audio_open(const char* name);
int audio_read(float* buffer, int num_samples);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_AUDIO_H_
-3
View File
@@ -1,3 +0,0 @@
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
-261
View File
@@ -1,261 +0,0 @@
#include "monitor.h"
#include <common/common.h>
#define LOG_LEVEL LOG_INFO
#include <ft8/debug.h>
#include <stdlib.h>
static float hann_i(int i, int N)
{
float x = sinf((float)M_PI * i / N);
return x * x;
}
// static float hamming_i(int i, int N)
// {
// const float a0 = (float)25 / 46;
// const float a1 = 1 - a0;
// float x1 = cosf(2 * (float)M_PI * i / N);
// return a0 - a1 * x1;
// }
// static float blackman_i(int i, int N)
// {
// const float alpha = 0.16f; // or 2860/18608
// const float a0 = (1 - alpha) / 2;
// const float a1 = 1.0f / 2;
// const float a2 = alpha / 2;
// float x1 = cosf(2 * (float)M_PI * i / N);
// float x2 = 2 * x1 * x1 - 1; // Use double angle formula
// return a0 - a1 * x1 + a2 * x2;
// }
static void waterfall_init(ftx_waterfall_t* me, int max_blocks, int num_bins, int time_osr, int freq_osr)
{
size_t mag_size = max_blocks * time_osr * freq_osr * num_bins * sizeof(me->mag[0]);
me->max_blocks = max_blocks;
me->num_blocks = 0;
me->num_bins = num_bins;
me->time_osr = time_osr;
me->freq_osr = freq_osr;
me->block_stride = (time_osr * freq_osr * num_bins);
me->mag = (WF_ELEM_T*)malloc(mag_size);
LOG(LOG_DEBUG, "Waterfall size = %zu\n", mag_size);
}
static void waterfall_free(ftx_waterfall_t* me)
{
free(me->mag);
}
void monitor_init(monitor_t* me, const monitor_config_t* cfg)
{
float slot_time = ftx_protocol_slot_time(cfg->protocol);
float symbol_period = ftx_protocol_symbol_period(cfg->protocol);
// Compute DSP parameters that depend on the sample rate
me->block_size = (int)(cfg->sample_rate * symbol_period); // samples corresponding to one FSK symbol
me->subblock_size = me->block_size / cfg->time_osr;
me->nfft = me->block_size * cfg->freq_osr;
me->fft_norm = 2.0f / me->nfft;
// const int len_window = 1.8f * me->block_size; // hand-picked and optimized
me->window = (float*)malloc(me->nfft * sizeof(me->window[0]));
for (int i = 0; i < me->nfft; ++i)
{
// window[i] = 1;
me->window[i] = me->fft_norm * hann_i(i, me->nfft);
// me->window[i] = blackman_i(i, me->nfft);
// me->window[i] = hamming_i(i, me->nfft);
// me->window[i] = (i < len_window) ? hann_i(i, len_window) : 0;
}
me->last_frame = (float*)calloc(me->nfft, sizeof(me->last_frame[0]));
LOG(LOG_INFO, "Block size = %d\n", me->block_size);
LOG(LOG_INFO, "Subblock size = %d\n", me->subblock_size);
size_t fft_work_size = 0;
kiss_fftr_alloc(me->nfft, 0, 0, &fft_work_size);
me->fft_work = malloc(fft_work_size);
me->fft_cfg = kiss_fftr_alloc(me->nfft, 0, me->fft_work, &fft_work_size);
LOG(LOG_INFO, "N_FFT = %d\n", me->nfft);
LOG(LOG_DEBUG, "FFT work area = %zu\n", fft_work_size);
#ifdef WATERFALL_USE_PHASE
me->nifft = 64; // Gives 200 Hz sample rate for FT8 (160ms symbol period)
size_t ifft_work_size = 0;
kiss_fft_alloc(me->nifft, 1, 0, &ifft_work_size);
me->ifft_work = malloc(ifft_work_size);
me->ifft_cfg = kiss_fft_alloc(me->nifft, 1, me->ifft_work, &ifft_work_size);
LOG(LOG_INFO, "N_iFFT = %d\n", me->nifft);
LOG(LOG_DEBUG, "iFFT work area = %zu\n", ifft_work_size);
#endif
// Allocate enough blocks to fit the entire FT8/FT4 slot in memory
const int max_blocks = (int)(slot_time / symbol_period);
// Keep only FFT bins in the specified frequency range (f_min/f_max)
me->min_bin = (int)(cfg->f_min * symbol_period);
me->max_bin = (int)(cfg->f_max * symbol_period) + 1;
const int num_bins = me->max_bin - me->min_bin;
waterfall_init(&me->wf, max_blocks, num_bins, cfg->time_osr, cfg->freq_osr);
me->wf.protocol = cfg->protocol;
me->symbol_period = symbol_period;
me->max_mag = -120.0f;
}
void monitor_free(monitor_t* me)
{
waterfall_free(&me->wf);
free(me->fft_work);
free(me->last_frame);
free(me->window);
}
void monitor_reset(monitor_t* me)
{
me->wf.num_blocks = 0;
me->max_mag = -120.0f;
}
// Compute FFT magnitudes (log wf) for a frame in the signal and update waterfall data
void monitor_process(monitor_t* me, const float* frame)
{
// Check if we can still store more waterfall data
if (me->wf.num_blocks >= me->wf.max_blocks)
return;
int offset = me->wf.num_blocks * me->wf.block_stride;
int frame_pos = 0;
// Loop over block subdivisions
for (int time_sub = 0; time_sub < me->wf.time_osr; ++time_sub)
{
kiss_fft_scalar timedata[me->nfft];
kiss_fft_cpx freqdata[me->nfft / 2 + 1];
// Shift the new data into analysis frame
for (int pos = 0; pos < me->nfft - me->subblock_size; ++pos)
{
me->last_frame[pos] = me->last_frame[pos + me->subblock_size];
}
for (int pos = me->nfft - me->subblock_size; pos < me->nfft; ++pos)
{
me->last_frame[pos] = frame[frame_pos];
++frame_pos;
}
// Do DFT of windowed analysis frame
for (int pos = 0; pos < me->nfft; ++pos)
{
timedata[pos] = me->window[pos] * me->last_frame[pos];
}
kiss_fftr(me->fft_cfg, timedata, freqdata);
// Loop over possible frequency OSR offsets
for (int freq_sub = 0; freq_sub < me->wf.freq_osr; ++freq_sub)
{
for (int bin = me->min_bin; bin < me->max_bin; ++bin)
{
int src_bin = (bin * me->wf.freq_osr) + freq_sub;
float mag2 = (freqdata[src_bin].i * freqdata[src_bin].i) + (freqdata[src_bin].r * freqdata[src_bin].r);
float db = 10.0f * log10f(1E-12f + mag2);
#ifdef WATERFALL_USE_PHASE
// Save the magnitude in dB and phase in radians
float phase = atan2f(freqdata[src_bin].i, freqdata[src_bin].r);
me->wf.mag[offset].mag = db;
me->wf.mag[offset].phase = phase;
#else
// Scale decibels to unsigned 8-bit range and clamp the value
// Range 0-240 covers -120..0 dB in 0.5 dB steps
int scaled = (int)(2 * db + 240);
me->wf.mag[offset] = (scaled < 0) ? 0 : ((scaled > 255) ? 255 : scaled);
#endif
++offset;
if (db > me->max_mag)
me->max_mag = db;
}
}
}
++me->wf.num_blocks;
}
#ifdef WATERFALL_USE_PHASE
void monitor_resynth(const monitor_t* me, const ftx_candidate_t* candidate, float* signal)
{
const int num_ifft = me->nifft;
const int num_shift = num_ifft / 2;
const int taper_width = 4;
// Starting offset is 3 subblocks due to analysis buffer loading
int offset = 1; // candidate->time_offset;
offset = (offset * me->wf.time_osr) + 1; // + candidate->time_sub;
offset = (offset * me->wf.freq_osr); // + candidate->freq_sub;
offset = (offset * me->wf.num_bins); // + candidate->freq_offset;
WF_ELEM_T* el = me->wf.mag + offset;
// DFT frequency data - initialize to zero
kiss_fft_cpx freqdata[num_ifft];
for (int i = 0; i < num_ifft; ++i)
{
freqdata[i].r = 0;
freqdata[i].i = 0;
}
int pos = 0;
for (int num_block = 1; num_block < me->wf.num_blocks; ++num_block)
{
// Extract frequency data around the selected candidate only
for (int i = candidate->freq_offset - taper_width - 1; i < candidate->freq_offset + 8 + taper_width - 1; ++i)
{
if ((i >= 0) && (i < me->wf.num_bins))
{
int tgt_bin = (me->wf.freq_osr * (i - candidate->freq_offset) + num_ifft) % num_ifft;
float weight = 1.0f;
if (i < candidate->freq_offset)
{
weight = ((i - candidate->freq_offset) + taper_width) / (float)taper_width;
}
else if (i > candidate->freq_offset + 7)
{
weight = ((candidate->freq_offset + 7 - i) + taper_width) / (float)taper_width;
}
// Convert (dB magnitude, phase) to (real, imaginary)
float mag = powf(10.0f, el[i].mag / 20) / 2 * weight;
freqdata[tgt_bin].r = mag * cosf(el[i].phase);
freqdata[tgt_bin].i = mag * sinf(el[i].phase);
int i2 = i + me->wf.num_bins;
tgt_bin = (tgt_bin + 1) % num_ifft;
float mag2 = powf(10.0f, el[i2].mag / 20) / 2 * weight;
freqdata[tgt_bin].r = mag2 * cosf(el[i2].phase);
freqdata[tgt_bin].i = mag2 * sinf(el[i2].phase);
}
}
// Compute inverse DFT and overlap-add the waveform
kiss_fft_cpx timedata[num_ifft];
kiss_fft(me->ifft_cfg, freqdata, timedata);
for (int i = 0; i < num_ifft; ++i)
{
signal[pos + i] += timedata[i].i;
}
// Move to the next symbol
el += me->wf.block_stride;
pos += num_shift;
}
}
#endif
-62
View File
@@ -1,62 +0,0 @@
#ifndef _INCLUDE_MONITOR_H_
#define _INCLUDE_MONITOR_H_
#ifdef __cplusplus
extern "C"
{
#endif
#include <ft8/decode.h>
#include <fft/kiss_fftr.h>
/// Configuration options for FT4/FT8 monitor
typedef struct
{
float f_min; ///< Lower frequency bound for analysis
float f_max; ///< Upper frequency bound for analysis
int sample_rate; ///< Sample rate in Hertz
int time_osr; ///< Number of time subdivisions
int freq_osr; ///< Number of frequency subdivisions
ftx_protocol_t protocol; ///< Protocol: FT4 or FT8
} monitor_config_t;
/// FT4/FT8 monitor object that manages DSP processing of incoming audio data
/// and prepares a waterfall object
typedef struct
{
float symbol_period; ///< FT4/FT8 symbol period in seconds
int min_bin; ///< First FFT bin in the frequency range (begin)
int max_bin; ///< First FFT bin outside the frequency range (end)
int block_size; ///< Number of samples per symbol (block)
int subblock_size; ///< Analysis shift size (number of samples)
int nfft; ///< FFT size
float fft_norm; ///< FFT normalization factor
float* window; ///< Window function for STFT analysis (nfft samples)
float* last_frame; ///< Current STFT analysis frame (nfft samples)
ftx_waterfall_t wf; ///< Waterfall object
float max_mag; ///< Maximum detected magnitude (debug stats)
// KISS FFT housekeeping variables
void* fft_work; ///< Work area required by Kiss FFT
kiss_fftr_cfg fft_cfg; ///< Kiss FFT housekeeping object
#ifdef WATERFALL_USE_PHASE
int nifft; ///< iFFT size
void* ifft_work; ///< Work area required by inverse Kiss FFT
kiss_fft_cfg ifft_cfg; ///< Inverse Kiss FFT housekeeping object
#endif
} monitor_t;
void monitor_init(monitor_t* me, const monitor_config_t* cfg);
void monitor_reset(monitor_t* me);
void monitor_process(monitor_t* me, const float* frame);
void monitor_free(monitor_t* me);
#ifdef WATERFALL_USE_PHASE
void monitor_resynth(const monitor_t* me, const ftx_candidate_t* candidate, float* signal);
#endif
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_MONITOR_H_
-133
View File
@@ -1,133 +0,0 @@
#include "wave.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <stdint.h>
// Save signal in floating point format (-1 .. +1) as a WAVE file using 16-bit signed integers.
int save_wav(const float* signal, int num_samples, int sample_rate, const char* path)
{
char subChunk1ID[4] = { 'f', 'm', 't', ' ' };
uint32_t subChunk1Size = 16; // 16 for PCM
uint16_t audioFormat = 1; // PCM = 1
uint16_t numChannels = 1;
uint16_t bitsPerSample = 16;
uint32_t sampleRate = sample_rate;
uint16_t blockAlign = numChannels * bitsPerSample / 8;
uint32_t byteRate = sampleRate * blockAlign;
char subChunk2ID[4] = { 'd', 'a', 't', 'a' };
uint32_t subChunk2Size = num_samples * blockAlign;
char chunkID[4] = { 'R', 'I', 'F', 'F' };
uint32_t chunkSize = 4 + (8 + subChunk1Size) + (8 + subChunk2Size);
char format[4] = { 'W', 'A', 'V', 'E' };
int16_t* raw_data = (int16_t*)malloc(num_samples * blockAlign);
for (int i = 0; i < num_samples; i++)
{
float x = signal[i];
if (x > 1.0)
x = 1.0;
else if (x < -1.0)
x = -1.0;
raw_data[i] = (int)(0.5 + (x * 32767.0));
}
FILE* f = fopen(path, "wb");
if (f == NULL)
return -1;
// NOTE: works only on little-endian architecture
fwrite(chunkID, sizeof(chunkID), 1, f);
fwrite(&chunkSize, sizeof(chunkSize), 1, f);
fwrite(format, sizeof(format), 1, f);
fwrite(subChunk1ID, sizeof(subChunk1ID), 1, f);
fwrite(&subChunk1Size, sizeof(subChunk1Size), 1, f);
fwrite(&audioFormat, sizeof(audioFormat), 1, f);
fwrite(&numChannels, sizeof(numChannels), 1, f);
fwrite(&sampleRate, sizeof(sampleRate), 1, f);
fwrite(&byteRate, sizeof(byteRate), 1, f);
fwrite(&blockAlign, sizeof(blockAlign), 1, f);
fwrite(&bitsPerSample, sizeof(bitsPerSample), 1, f);
fwrite(subChunk2ID, sizeof(subChunk2ID), 1, f);
fwrite(&subChunk2Size, sizeof(subChunk2Size), 1, f);
fwrite(raw_data, blockAlign, num_samples, f);
fclose(f);
free(raw_data);
return 0;
}
// Load signal in floating point format (-1 .. +1) as a WAVE file using 16-bit signed integers.
int load_wav(float* signal, int* num_samples, int* sample_rate, const char* path)
{
char subChunk1ID[4]; // = {'f', 'm', 't', ' '};
uint32_t subChunk1Size; // = 16; // 16 for PCM
uint16_t audioFormat; // = 1; // PCM = 1
uint16_t numChannels; // = 1;
uint16_t bitsPerSample; // = 16;
uint32_t sampleRate;
uint16_t blockAlign; // = numChannels * bitsPerSample / 8;
uint32_t byteRate; // = sampleRate * blockAlign;
char subChunk2ID[4]; // = {'d', 'a', 't', 'a'};
uint32_t subChunk2Size; // = num_samples * blockAlign;
char chunkID[4]; // = {'R', 'I', 'F', 'F'};
uint32_t chunkSize; // = 4 + (8 + subChunk1Size) + (8 + subChunk2Size);
char format[4]; // = {'W', 'A', 'V', 'E'};
FILE* f = fopen(path, "rb");
if (f == NULL)
return -1;
// NOTE: works only on little-endian architecture
fread((void*)chunkID, sizeof(chunkID), 1, f);
fread((void*)&chunkSize, sizeof(chunkSize), 1, f);
fread((void*)format, sizeof(format), 1, f);
fread((void*)subChunk1ID, sizeof(subChunk1ID), 1, f);
fread((void*)&subChunk1Size, sizeof(subChunk1Size), 1, f);
if (subChunk1Size != 16)
return -2;
fread((void*)&audioFormat, sizeof(audioFormat), 1, f);
fread((void*)&numChannels, sizeof(numChannels), 1, f);
fread((void*)&sampleRate, sizeof(sampleRate), 1, f);
fread((void*)&byteRate, sizeof(byteRate), 1, f);
fread((void*)&blockAlign, sizeof(blockAlign), 1, f);
fread((void*)&bitsPerSample, sizeof(bitsPerSample), 1, f);
if (audioFormat != 1 || numChannels != 1 || bitsPerSample != 16)
return -3;
fread((void*)subChunk2ID, sizeof(subChunk2ID), 1, f);
fread((void*)&subChunk2Size, sizeof(subChunk2Size), 1, f);
if (subChunk2Size / blockAlign > *num_samples)
return -4;
*num_samples = subChunk2Size / blockAlign;
*sample_rate = sampleRate;
int16_t* raw_data = (int16_t*)malloc(*num_samples * blockAlign);
fread((void*)raw_data, blockAlign, *num_samples, f);
for (int i = 0; i < *num_samples; i++)
{
signal[i] = raw_data[i] / 32768.0f;
}
free(raw_data);
fclose(f);
return 0;
}
-19
View File
@@ -1,19 +0,0 @@
#ifndef _INCLUDE_WAVE_H_
#define _INCLUDE_WAVE_H_
#ifdef __cplusplus
extern "C"
{
#endif
// Save signal in floating point format (-1 .. +1) as a WAVE file using 16-bit signed integers.
int save_wav(const float* signal, int num_samples, int sample_rate, const char* path);
// Load signal in floating point format (-1 .. +1) as a WAVE file using 16-bit signed integers.
int load_wav(float* signal, int* num_samples, int* sample_rate, const char* path);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_WAVE_H_
-393
View File
@@ -1,393 +0,0 @@
#define _POSIX_C_SOURCE 199309L
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <math.h>
#include <stdbool.h>
#include <time.h>
#include <ft8/decode.h>
#include <ft8/encode.h>
#include <ft8/message.h>
#include <common/common.h>
#include <common/wave.h>
#include <common/monitor.h>
#include <common/audio.h>
#define LOG_LEVEL LOG_INFO
#include <ft8/debug.h>
const int kMin_score = 10; // Minimum sync score threshold for candidates
const int kMax_candidates = 140;
const int kLDPC_iterations = 25;
const int kMax_decoded_messages = 50;
const int kFreq_osr = 2; // Frequency oversampling rate (bin subdivision)
const int kTime_osr = 2; // Time oversampling rate (symbol subdivision)
void usage(const char* error_msg)
{
if (error_msg != NULL)
{
fprintf(stderr, "ERROR: %s\n", error_msg);
}
fprintf(stderr, "Usage: decode_ft8 [-list|([-ft4] [INPUT|-dev DEVICE])]\n\n");
fprintf(stderr, "Decode a 15-second (or slighly shorter) WAV file.\n");
}
#define CALLSIGN_HASHTABLE_SIZE 256
static struct
{
char callsign[12]; ///> Up to 11 symbols of callsign + trailing zeros (always filled)
uint32_t hash; ///> 8 MSBs contain the age of callsign; 22 LSBs contain hash value
} callsign_hashtable[CALLSIGN_HASHTABLE_SIZE];
static int callsign_hashtable_size;
void hashtable_init(void)
{
callsign_hashtable_size = 0;
memset(callsign_hashtable, 0, sizeof(callsign_hashtable));
}
void hashtable_cleanup(uint8_t max_age)
{
for (int idx_hash = 0; idx_hash < CALLSIGN_HASHTABLE_SIZE; ++idx_hash)
{
if (callsign_hashtable[idx_hash].callsign[0] != '\0')
{
uint8_t age = (uint8_t)(callsign_hashtable[idx_hash].hash >> 24);
if (age > max_age)
{
LOG(LOG_INFO, "Removing [%s] from hash table, age = %d\n", callsign_hashtable[idx_hash].callsign, age);
// free the hash entry
callsign_hashtable[idx_hash].callsign[0] = '\0';
callsign_hashtable[idx_hash].hash = 0;
callsign_hashtable_size--;
}
else
{
// increase callsign age
callsign_hashtable[idx_hash].hash = (((uint32_t)age + 1u) << 24) | (callsign_hashtable[idx_hash].hash & 0x3FFFFFu);
}
}
}
}
void hashtable_add(const char* callsign, uint32_t hash)
{
uint16_t hash10 = (hash >> 12) & 0x3FFu;
int idx_hash = (hash10 * 23) % CALLSIGN_HASHTABLE_SIZE;
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
{
if (((callsign_hashtable[idx_hash].hash & 0x3FFFFFu) == hash) && (0 == strcmp(callsign_hashtable[idx_hash].callsign, callsign)))
{
// reset age
callsign_hashtable[idx_hash].hash &= 0x3FFFFFu;
LOG(LOG_DEBUG, "Found a duplicate [%s]\n", callsign);
return;
}
else
{
LOG(LOG_DEBUG, "Hash table clash!\n");
// Move on to check the next entry in hash table
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
}
}
callsign_hashtable_size++;
strncpy(callsign_hashtable[idx_hash].callsign, callsign, 11);
callsign_hashtable[idx_hash].callsign[11] = '\0';
callsign_hashtable[idx_hash].hash = hash;
}
bool hashtable_lookup(ftx_callsign_hash_type_t hash_type, uint32_t hash, char* callsign)
{
uint8_t hash_shift = (hash_type == FTX_CALLSIGN_HASH_10_BITS) ? 12 : (hash_type == FTX_CALLSIGN_HASH_12_BITS ? 10 : 0);
uint16_t hash10 = (hash >> (12 - hash_shift)) & 0x3FFu;
int idx_hash = (hash10 * 23) % CALLSIGN_HASHTABLE_SIZE;
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
{
if (((callsign_hashtable[idx_hash].hash & 0x3FFFFFu) >> hash_shift) == hash)
{
strcpy(callsign, callsign_hashtable[idx_hash].callsign);
return true;
}
// Move on to check the next entry in hash table
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
}
callsign[0] = '\0';
return false;
}
ftx_callsign_hash_interface_t hash_if = {
.lookup_hash = hashtable_lookup,
.save_hash = hashtable_add
};
void decode(const monitor_t* mon, struct tm* tm_slot_start)
{
const ftx_waterfall_t* wf = &mon->wf;
// Find top candidates by Costas sync score and localize them in time and frequency
ftx_candidate_t candidate_list[kMax_candidates];
int num_candidates = ftx_find_candidates(wf, kMax_candidates, candidate_list, kMin_score);
// Hash table for decoded messages (to check for duplicates)
int num_decoded = 0;
ftx_message_t decoded[kMax_decoded_messages];
ftx_message_t* decoded_hashtable[kMax_decoded_messages];
// Initialize hash table pointers
for (int i = 0; i < kMax_decoded_messages; ++i)
{
decoded_hashtable[i] = NULL;
}
// Go over candidates and attempt to decode messages
for (int idx = 0; idx < num_candidates; ++idx)
{
const ftx_candidate_t* cand = &candidate_list[idx];
float freq_hz = (mon->min_bin + cand->freq_offset + (float)cand->freq_sub / wf->freq_osr) / mon->symbol_period;
float time_sec = (cand->time_offset + (float)cand->time_sub / wf->time_osr) * mon->symbol_period;
#ifdef WATERFALL_USE_PHASE
// int resynth_len = 12000 * 16;
// float resynth_signal[resynth_len];
// for (int pos = 0; pos < resynth_len; ++pos)
// {
// resynth_signal[pos] = 0;
// }
// monitor_resynth(mon, cand, resynth_signal);
// char resynth_path[80];
// sprintf(resynth_path, "resynth_%04f_%02.1f.wav", freq_hz, time_sec);
// save_wav(resynth_signal, resynth_len, 12000, resynth_path);
#endif
ftx_message_t message;
ftx_decode_status_t status;
if (!ftx_decode_candidate(wf, cand, kLDPC_iterations, &message, &status))
{
if (status.ldpc_errors > 0)
{
LOG(LOG_DEBUG, "LDPC decode: %d errors\n", status.ldpc_errors);
}
else if (status.crc_calculated != status.crc_extracted)
{
LOG(LOG_DEBUG, "CRC mismatch!\n");
}
continue;
}
LOG(LOG_DEBUG, "Checking hash table for %4.1fs / %4.1fHz [%d]...\n", time_sec, freq_hz, cand->score);
int idx_hash = message.hash % kMax_decoded_messages;
bool found_empty_slot = false;
bool found_duplicate = false;
do
{
if (decoded_hashtable[idx_hash] == NULL)
{
LOG(LOG_DEBUG, "Found an empty slot\n");
found_empty_slot = true;
}
else if ((decoded_hashtable[idx_hash]->hash == message.hash) && (0 == memcmp(decoded_hashtable[idx_hash]->payload, message.payload, sizeof(message.payload))))
{
LOG(LOG_DEBUG, "Found a duplicate!\n");
found_duplicate = true;
}
else
{
LOG(LOG_DEBUG, "Hash table clash!\n");
// Move on to check the next entry in hash table
idx_hash = (idx_hash + 1) % kMax_decoded_messages;
}
} while (!found_empty_slot && !found_duplicate);
if (found_empty_slot)
{
// Fill the empty hashtable slot
memcpy(&decoded[idx_hash], &message, sizeof(message));
decoded_hashtable[idx_hash] = &decoded[idx_hash];
++num_decoded;
char text[FTX_MAX_MESSAGE_LENGTH];
ftx_message_offsets_t offsets;
ftx_message_rc_t unpack_status = ftx_message_decode(&message, &hash_if, text, &offsets);
if (unpack_status != FTX_MESSAGE_RC_OK)
{
snprintf(text, sizeof(text), "Error [%d] while unpacking!", (int)unpack_status);
}
// Fake WSJT-X-like output for now
float snr = cand->score * 0.5f; // TODO: compute better approximation of SNR
printf("%02d%02d%02d %+05.1f %+4.2f %4.0f ~ %s\n",
tm_slot_start->tm_hour, tm_slot_start->tm_min, tm_slot_start->tm_sec,
snr, time_sec, freq_hz, text);
}
}
LOG(LOG_INFO, "Decoded %d messages, callsign hashtable size %d\n", num_decoded, callsign_hashtable_size);
hashtable_cleanup(10);
}
int main(int argc, char** argv)
{
// Accepted arguments
const char* wav_path = NULL;
const char* dev_name = NULL;
ftx_protocol_t protocol = FTX_PROTOCOL_FT8;
float time_shift = 0.8;
// Parse arguments one by one
int arg_idx = 1;
while (arg_idx < argc)
{
// Check if the current argument is an option (-xxx)
if (argv[arg_idx][0] == '-')
{
// Check agaist valid options
if (0 == strcmp(argv[arg_idx], "-ft4"))
{
protocol = FTX_PROTOCOL_FT4;
}
else if (0 == strcmp(argv[arg_idx], "-list"))
{
audio_init();
audio_list();
return 0;
}
else if (0 == strcmp(argv[arg_idx], "-dev"))
{
if (arg_idx + 1 < argc)
{
++arg_idx;
dev_name = argv[arg_idx];
}
else
{
usage("Expected an audio device name after -dev");
return -1;
}
}
else
{
usage("Unknown command line option");
return -1;
}
}
else
{
if (wav_path == NULL)
{
wav_path = argv[arg_idx];
}
else
{
usage("Multiple positional arguments");
return -1;
}
}
++arg_idx;
}
// Check if all mandatory arguments have been received
if (wav_path == NULL && dev_name == NULL)
{
usage("Expected either INPUT file path or DEVICE name");
return -1;
}
float slot_period = ((protocol == FTX_PROTOCOL_FT8) ? FT8_SLOT_TIME : FT4_SLOT_TIME);
int sample_rate = 12000;
int num_samples = slot_period * sample_rate;
float signal[num_samples];
bool is_live = false;
if (wav_path != NULL)
{
int rc = load_wav(signal, &num_samples, &sample_rate, wav_path);
if (rc < 0)
{
LOG(LOG_ERROR, "ERROR: cannot load wave file %s\n", wav_path);
return -1;
}
LOG(LOG_INFO, "Sample rate %d Hz, %d samples, %.3f seconds\n", sample_rate, num_samples, (double)num_samples / sample_rate);
}
else if (dev_name != NULL)
{
audio_init();
audio_open(dev_name);
num_samples = (slot_period - 0.4f) * sample_rate;
is_live = true;
}
// Compute FFT over the whole signal and store it
monitor_t mon;
monitor_config_t mon_cfg = {
.f_min = 200,
.f_max = 3000,
.sample_rate = sample_rate,
.time_osr = kTime_osr,
.freq_osr = kFreq_osr,
.protocol = protocol
};
hashtable_init();
monitor_init(&mon, &mon_cfg);
LOG(LOG_DEBUG, "Waterfall allocated %d symbols\n", mon.wf.max_blocks);
do
{
struct tm tm_slot_start = { 0 };
if (is_live)
{
// Wait for the start of time slot
while (true)
{
struct timespec spec;
clock_gettime(CLOCK_REALTIME, &spec);
double time = (double)spec.tv_sec + (spec.tv_nsec / 1e9);
double time_within_slot = fmod(time - time_shift, slot_period);
if (time_within_slot > slot_period / 4)
{
audio_read(signal, mon.block_size);
}
else
{
time_t time_slot_start = (time_t)(time - time_within_slot);
gmtime_r(&time_slot_start, &tm_slot_start);
LOG(LOG_INFO, "Time within slot %02d%02d%02d: %.3f s\n", tm_slot_start.tm_hour,
tm_slot_start.tm_min, tm_slot_start.tm_sec, time_within_slot);
break;
}
}
}
// Process and accumulate audio data in a monitor/waterfall instance
for (int frame_pos = 0; frame_pos + mon.block_size <= num_samples; frame_pos += mon.block_size)
{
if (dev_name != NULL)
{
audio_read(signal + frame_pos, mon.block_size);
}
// LOG(LOG_DEBUG, "Frame pos: %.3fs\n", (float)(frame_pos + mon.block_size) / sample_rate);
fprintf(stderr, "#");
// Process the waveform data frame by frame - you could have a live loop here with data from an audio device
monitor_process(&mon, signal + frame_pos);
}
fprintf(stderr, "\n");
LOG(LOG_DEBUG, "Waterfall accumulated %d symbols\n", mon.wf.num_blocks);
LOG(LOG_INFO, "Max magnitude: %.1f dB\n", mon.max_mag);
// Decode accumulated data (containing slightly less than a full time slot)
decode(&mon, &tm_slot_start);
// Reset internal variables for the next time slot
monitor_reset(&mon);
} while (is_live);
monitor_free(&mon);
return 0;
}
-189
View File
@@ -1,189 +0,0 @@
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <math.h>
#include <stdbool.h>
#include "common/common.h"
#include "common/wave.h"
#include "ft8/message.h"
#include "ft8/encode.h"
#include "ft8/constants.h"
#define LOG_LEVEL LOG_INFO
#include "ft8/debug.h"
#define FT8_SYMBOL_BT 2.0f ///< symbol smoothing filter bandwidth factor (BT)
#define FT4_SYMBOL_BT 1.0f ///< symbol smoothing filter bandwidth factor (BT)
#define GFSK_CONST_K 5.336446f ///< == pi * sqrt(2 / log(2))
/// Computes a GFSK smoothing pulse.
/// The pulse is theoretically infinitely long, however, here it's truncated at 3 times the symbol length.
/// This means the pulse array has to have space for 3*n_spsym elements.
/// @param[in] n_spsym Number of samples per symbol
/// @param[in] b Shape parameter (values defined for FT8/FT4)
/// @param[out] pulse Output array of pulse samples
///
void gfsk_pulse(int n_spsym, float symbol_bt, float* pulse)
{
for (int i = 0; i < 3 * n_spsym; ++i)
{
float t = i / (float)n_spsym - 1.5f;
float arg1 = GFSK_CONST_K * symbol_bt * (t + 0.5f);
float arg2 = GFSK_CONST_K * symbol_bt * (t - 0.5f);
pulse[i] = (erff(arg1) - erff(arg2)) / 2;
}
}
/// Synthesize waveform data using GFSK phase shaping.
/// The output waveform will contain n_sym symbols.
/// @param[in] symbols Array of symbols (tones) (0-7 for FT8)
/// @param[in] n_sym Number of symbols in the symbol array
/// @param[in] f0 Audio frequency in Hertz for the symbol 0 (base frequency)
/// @param[in] symbol_bt Symbol smoothing filter bandwidth (2 for FT8, 1 for FT4)
/// @param[in] symbol_period Symbol period (duration), seconds
/// @param[in] signal_rate Sample rate of synthesized signal, Hertz
/// @param[out] signal Output array of signal waveform samples (should have space for n_sym*n_spsym samples)
///
void synth_gfsk(const uint8_t* symbols, int n_sym, float f0, float symbol_bt, float symbol_period, int signal_rate, float* signal)
{
int n_spsym = (int)(0.5f + signal_rate * symbol_period); // Samples per symbol
int n_wave = n_sym * n_spsym; // Number of output samples
float hmod = 1.0f;
LOG(LOG_DEBUG, "n_spsym = %d\n", n_spsym);
// Compute the smoothed frequency waveform.
// Length = (nsym+2)*n_spsym samples, first and last symbols extended
float dphi_peak = 2 * M_PI * hmod / n_spsym;
float dphi[n_wave + 2 * n_spsym];
// Shift frequency up by f0
for (int i = 0; i < n_wave + 2 * n_spsym; ++i)
{
dphi[i] = 2 * M_PI * f0 / signal_rate;
}
float pulse[3 * n_spsym];
gfsk_pulse(n_spsym, symbol_bt, pulse);
for (int i = 0; i < n_sym; ++i)
{
int ib = i * n_spsym;
for (int j = 0; j < 3 * n_spsym; ++j)
{
dphi[j + ib] += dphi_peak * symbols[i] * pulse[j];
}
}
// Add dummy symbols at beginning and end with tone values equal to 1st and last symbol, respectively
for (int j = 0; j < 2 * n_spsym; ++j)
{
dphi[j] += dphi_peak * pulse[j + n_spsym] * symbols[0];
dphi[j + n_sym * n_spsym] += dphi_peak * pulse[j] * symbols[n_sym - 1];
}
// Calculate and insert the audio waveform
float phi = 0;
for (int k = 0; k < n_wave; ++k)
{ // Don't include dummy symbols
signal[k] = sinf(phi);
phi = fmodf(phi + dphi[k + n_spsym], 2 * M_PI);
}
// Apply envelope shaping to the first and last symbols
int n_ramp = n_spsym / 8;
for (int i = 0; i < n_ramp; ++i)
{
float env = (1 - cosf(2 * M_PI * i / (2 * n_ramp))) / 2;
signal[i] *= env;
signal[n_wave - 1 - i] *= env;
}
}
void usage()
{
printf("Generate a 15-second WAV file encoding a given message.\n");
printf("Usage:\n");
printf("\n");
printf("gen_ft8 MESSAGE WAV_FILE [FREQUENCY]\n");
printf("\n");
printf("(Note that you might have to enclose your message in quote marks if it contains spaces)\n");
}
int main(int argc, char** argv)
{
// Expect two command-line arguments
if (argc < 3)
{
usage();
return -1;
}
const char* message = argv[1];
const char* wav_path = argv[2];
float frequency = 1000.0;
if (argc > 3)
{
frequency = atof(argv[3]);
}
bool is_ft4 = (argc > 4) && (0 == strcmp(argv[4], "-ft4"));
// First, pack the text data into binary message
ftx_message_t msg;
ftx_message_rc_t rc = ftx_message_encode(&msg, NULL, message);
if (rc != FTX_MESSAGE_RC_OK)
{
printf("Cannot parse message!\n");
printf("RC = %d\n", (int)rc);
return -2;
}
printf("Packed data: ");
for (int j = 0; j < 10; ++j)
{
printf("%02x ", msg.payload[j]);
}
printf("\n");
int num_tones = (is_ft4) ? FT4_NN : FT8_NN;
float symbol_period = (is_ft4) ? FT4_SYMBOL_PERIOD : FT8_SYMBOL_PERIOD;
float symbol_bt = (is_ft4) ? FT4_SYMBOL_BT : FT8_SYMBOL_BT;
float slot_time = (is_ft4) ? FT4_SLOT_TIME : FT8_SLOT_TIME;
// Second, encode the binary message as a sequence of FSK tones
uint8_t tones[num_tones]; // Array of 79 tones (symbols)
if (is_ft4)
{
ft4_encode(msg.payload, tones);
}
else
{
ft8_encode(msg.payload, tones);
}
printf("FSK tones: ");
for (int j = 0; j < num_tones; ++j)
{
printf("%d", tones[j]);
}
printf("\n");
// Third, convert the FSK tones into an audio signal
int sample_rate = 12000;
int num_samples = (int)(0.5f + num_tones * symbol_period * sample_rate); // Number of samples in the data signal
int num_silence = (slot_time * sample_rate - num_samples) / 2; // Silence padding at both ends to make 15 seconds
int num_total_samples = num_silence + num_samples + num_silence; // Number of samples in the padded signal
float signal[num_total_samples];
for (int i = 0; i < num_silence; i++)
{
signal[i] = 0;
signal[i + num_samples + num_silence] = 0;
}
// Synthesize waveform data (signal) and save it as WAV file
synth_gfsk(tones, num_tones, frequency, symbol_bt, symbol_period, sample_rate, signal + num_silence);
save_wav(signal, num_total_samples, sample_rate, wav_path);
return 0;
}
-158
View File
@@ -1,158 +0,0 @@
/*
* Copyright (c) 2003-2010, Mark Borgerding. All rights reserved.
* This file is part of KISS FFT - https://github.com/mborgerding/kissfft
*
* SPDX-License-Identifier: BSD-3-Clause
* See COPYING file for more information.
*/
/* kiss_fft.h
defines kiss_fft_scalar as either short or a float type
and defines
typedef struct { kiss_fft_scalar r; kiss_fft_scalar i; }kiss_fft_cpx; */
#include "kiss_fft.h"
#include <limits.h>
#define MAXFACTORS 32
/* e.g. an fft of length 128 has 4 factors
as far as kissfft is concerned
4*4*4*2
*/
struct kiss_fft_state{
int nfft;
int inverse;
int factors[2*MAXFACTORS];
kiss_fft_cpx twiddles[1];
};
/*
Explanation of macros dealing with complex math:
C_MUL(m,a,b) : m = a*b
C_FIXDIV( c , div ) : if a fixed point impl., c /= div. noop otherwise
C_SUB( res, a,b) : res = a - b
C_SUBFROM( res , a) : res -= a
C_ADDTO( res , a) : res += a
* */
#ifdef FIXED_POINT
#if (FIXED_POINT==32)
# define FRACBITS 31
# define SAMPPROD int64_t
#define SAMP_MAX 2147483647
#else
# define FRACBITS 15
# define SAMPPROD int32_t
#define SAMP_MAX 32767
#endif
#define SAMP_MIN -SAMP_MAX
#if defined(CHECK_OVERFLOW)
# define CHECK_OVERFLOW_OP(a,op,b) \
if ( (SAMPPROD)(a) op (SAMPPROD)(b) > SAMP_MAX || (SAMPPROD)(a) op (SAMPPROD)(b) < SAMP_MIN ) { \
fprintf(stderr,"WARNING:overflow @ " __FILE__ "(%d): (%d " #op" %d) = %ld\n",__LINE__,(a),(b),(SAMPPROD)(a) op (SAMPPROD)(b) ); }
#endif
# define smul(a,b) ( (SAMPPROD)(a)*(b) )
# define sround( x ) (kiss_fft_scalar)( ( (x) + (1<<(FRACBITS-1)) ) >> FRACBITS )
# define S_MUL(a,b) sround( smul(a,b) )
# define C_MUL(m,a,b) \
do{ (m).r = sround( smul((a).r,(b).r) - smul((a).i,(b).i) ); \
(m).i = sround( smul((a).r,(b).i) + smul((a).i,(b).r) ); }while(0)
# define DIVSCALAR(x,k) \
(x) = sround( smul( x, SAMP_MAX/k ) )
# define C_FIXDIV(c,div) \
do { DIVSCALAR( (c).r , div); \
DIVSCALAR( (c).i , div); }while (0)
# define C_MULBYSCALAR( c, s ) \
do{ (c).r = sround( smul( (c).r , s ) ) ;\
(c).i = sround( smul( (c).i , s ) ) ; }while(0)
#else /* not FIXED_POINT*/
# define S_MUL(a,b) ( (a)*(b) )
#define C_MUL(m,a,b) \
do{ (m).r = (a).r*(b).r - (a).i*(b).i;\
(m).i = (a).r*(b).i + (a).i*(b).r; }while(0)
# define C_FIXDIV(c,div) /* NOOP */
# define C_MULBYSCALAR( c, s ) \
do{ (c).r *= (s);\
(c).i *= (s); }while(0)
#endif
#ifndef CHECK_OVERFLOW_OP
# define CHECK_OVERFLOW_OP(a,op,b) /* noop */
#endif
#define C_ADD( res, a,b)\
do { \
CHECK_OVERFLOW_OP((a).r,+,(b).r)\
CHECK_OVERFLOW_OP((a).i,+,(b).i)\
(res).r=(a).r+(b).r; (res).i=(a).i+(b).i; \
}while(0)
#define C_SUB( res, a,b)\
do { \
CHECK_OVERFLOW_OP((a).r,-,(b).r)\
CHECK_OVERFLOW_OP((a).i,-,(b).i)\
(res).r=(a).r-(b).r; (res).i=(a).i-(b).i; \
}while(0)
#define C_ADDTO( res , a)\
do { \
CHECK_OVERFLOW_OP((res).r,+,(a).r)\
CHECK_OVERFLOW_OP((res).i,+,(a).i)\
(res).r += (a).r; (res).i += (a).i;\
}while(0)
#define C_SUBFROM( res , a)\
do {\
CHECK_OVERFLOW_OP((res).r,-,(a).r)\
CHECK_OVERFLOW_OP((res).i,-,(a).i)\
(res).r -= (a).r; (res).i -= (a).i; \
}while(0)
#ifdef FIXED_POINT
# define KISS_FFT_COS(phase) floor(.5+SAMP_MAX * cos (phase))
# define KISS_FFT_SIN(phase) floor(.5+SAMP_MAX * sin (phase))
# define HALF_OF(x) ((x)>>1)
#elif defined(USE_SIMD)
# define KISS_FFT_COS(phase) _mm_set1_ps( cos(phase) )
# define KISS_FFT_SIN(phase) _mm_set1_ps( sin(phase) )
# define HALF_OF(x) ((x)*_mm_set1_ps(.5))
#else
# define KISS_FFT_COS(phase) (kiss_fft_scalar) cos(phase)
# define KISS_FFT_SIN(phase) (kiss_fft_scalar) sin(phase)
# define HALF_OF(x) ((x)*.5)
#endif
#define kf_cexp(x,phase) \
do{ \
(x)->r = KISS_FFT_COS(phase);\
(x)->i = KISS_FFT_SIN(phase);\
}while(0)
/* a debugging function */
#define pcpx(c)\
fprintf(stderr,"%g + %gi\n",(double)((c)->r),(double)((c)->i) )
#ifdef KISS_FFT_USE_ALLOCA
// define this to allow use of alloca instead of malloc for temporary buffers
// Temporary buffers are used in two case:
// 1. FFT sizes that have "bad" factors. i.e. not 2,3 and 5
// 2. "in-place" FFTs. Notice the quotes, since kissfft does not really do an in-place transform.
#include <alloca.h>
#define KISS_FFT_TMP_ALLOC(nbytes) alloca(nbytes)
#define KISS_FFT_TMP_FREE(ptr)
#else
#define KISS_FFT_TMP_ALLOC(nbytes) KISS_FFT_MALLOC(nbytes)
#define KISS_FFT_TMP_FREE(ptr) KISS_FFT_FREE(ptr)
#endif
-402
View File
@@ -1,402 +0,0 @@
/*
* Copyright (c) 2003-2010, Mark Borgerding. All rights reserved.
* This file is part of KISS FFT - https://github.com/mborgerding/kissfft
*
* SPDX-License-Identifier: BSD-3-Clause
* See COPYING file for more information.
*/
#include "_kiss_fft_guts.h"
/* The guts header contains all the multiplication and addition macros that are defined for
fixed or floating point complex numbers. It also delares the kf_ internal functions.
*/
static void kf_bfly2(
kiss_fft_cpx * Fout,
const size_t fstride,
const kiss_fft_cfg st,
int m
)
{
kiss_fft_cpx * Fout2;
kiss_fft_cpx * tw1 = st->twiddles;
kiss_fft_cpx t;
Fout2 = Fout + m;
do{
C_FIXDIV(*Fout,2); C_FIXDIV(*Fout2,2);
C_MUL (t, *Fout2 , *tw1);
tw1 += fstride;
C_SUB( *Fout2 , *Fout , t );
C_ADDTO( *Fout , t );
++Fout2;
++Fout;
}while (--m);
}
static void kf_bfly4(
kiss_fft_cpx * Fout,
const size_t fstride,
const kiss_fft_cfg st,
const size_t m
)
{
kiss_fft_cpx *tw1,*tw2,*tw3;
kiss_fft_cpx scratch[6];
size_t k=m;
const size_t m2=2*m;
const size_t m3=3*m;
tw3 = tw2 = tw1 = st->twiddles;
do {
C_FIXDIV(*Fout,4); C_FIXDIV(Fout[m],4); C_FIXDIV(Fout[m2],4); C_FIXDIV(Fout[m3],4);
C_MUL(scratch[0],Fout[m] , *tw1 );
C_MUL(scratch[1],Fout[m2] , *tw2 );
C_MUL(scratch[2],Fout[m3] , *tw3 );
C_SUB( scratch[5] , *Fout, scratch[1] );
C_ADDTO(*Fout, scratch[1]);
C_ADD( scratch[3] , scratch[0] , scratch[2] );
C_SUB( scratch[4] , scratch[0] , scratch[2] );
C_SUB( Fout[m2], *Fout, scratch[3] );
tw1 += fstride;
tw2 += fstride*2;
tw3 += fstride*3;
C_ADDTO( *Fout , scratch[3] );
if(st->inverse) {
Fout[m].r = scratch[5].r - scratch[4].i;
Fout[m].i = scratch[5].i + scratch[4].r;
Fout[m3].r = scratch[5].r + scratch[4].i;
Fout[m3].i = scratch[5].i - scratch[4].r;
}else{
Fout[m].r = scratch[5].r + scratch[4].i;
Fout[m].i = scratch[5].i - scratch[4].r;
Fout[m3].r = scratch[5].r - scratch[4].i;
Fout[m3].i = scratch[5].i + scratch[4].r;
}
++Fout;
}while(--k);
}
static void kf_bfly3(
kiss_fft_cpx * Fout,
const size_t fstride,
const kiss_fft_cfg st,
size_t m
)
{
size_t k=m;
const size_t m2 = 2*m;
kiss_fft_cpx *tw1,*tw2;
kiss_fft_cpx scratch[5];
kiss_fft_cpx epi3;
epi3 = st->twiddles[fstride*m];
tw1=tw2=st->twiddles;
do{
C_FIXDIV(*Fout,3); C_FIXDIV(Fout[m],3); C_FIXDIV(Fout[m2],3);
C_MUL(scratch[1],Fout[m] , *tw1);
C_MUL(scratch[2],Fout[m2] , *tw2);
C_ADD(scratch[3],scratch[1],scratch[2]);
C_SUB(scratch[0],scratch[1],scratch[2]);
tw1 += fstride;
tw2 += fstride*2;
Fout[m].r = Fout->r - HALF_OF(scratch[3].r);
Fout[m].i = Fout->i - HALF_OF(scratch[3].i);
C_MULBYSCALAR( scratch[0] , epi3.i );
C_ADDTO(*Fout,scratch[3]);
Fout[m2].r = Fout[m].r + scratch[0].i;
Fout[m2].i = Fout[m].i - scratch[0].r;
Fout[m].r -= scratch[0].i;
Fout[m].i += scratch[0].r;
++Fout;
}while(--k);
}
static void kf_bfly5(
kiss_fft_cpx * Fout,
const size_t fstride,
const kiss_fft_cfg st,
int m
)
{
kiss_fft_cpx *Fout0,*Fout1,*Fout2,*Fout3,*Fout4;
int u;
kiss_fft_cpx scratch[13];
kiss_fft_cpx * twiddles = st->twiddles;
kiss_fft_cpx *tw;
kiss_fft_cpx ya,yb;
ya = twiddles[fstride*m];
yb = twiddles[fstride*2*m];
Fout0=Fout;
Fout1=Fout0+m;
Fout2=Fout0+2*m;
Fout3=Fout0+3*m;
Fout4=Fout0+4*m;
tw=st->twiddles;
for ( u=0; u<m; ++u ) {
C_FIXDIV( *Fout0,5); C_FIXDIV( *Fout1,5); C_FIXDIV( *Fout2,5); C_FIXDIV( *Fout3,5); C_FIXDIV( *Fout4,5);
scratch[0] = *Fout0;
C_MUL(scratch[1] ,*Fout1, tw[u*fstride]);
C_MUL(scratch[2] ,*Fout2, tw[2*u*fstride]);
C_MUL(scratch[3] ,*Fout3, tw[3*u*fstride]);
C_MUL(scratch[4] ,*Fout4, tw[4*u*fstride]);
C_ADD( scratch[7],scratch[1],scratch[4]);
C_SUB( scratch[10],scratch[1],scratch[4]);
C_ADD( scratch[8],scratch[2],scratch[3]);
C_SUB( scratch[9],scratch[2],scratch[3]);
Fout0->r += scratch[7].r + scratch[8].r;
Fout0->i += scratch[7].i + scratch[8].i;
scratch[5].r = scratch[0].r + S_MUL(scratch[7].r,ya.r) + S_MUL(scratch[8].r,yb.r);
scratch[5].i = scratch[0].i + S_MUL(scratch[7].i,ya.r) + S_MUL(scratch[8].i,yb.r);
scratch[6].r = S_MUL(scratch[10].i,ya.i) + S_MUL(scratch[9].i,yb.i);
scratch[6].i = -S_MUL(scratch[10].r,ya.i) - S_MUL(scratch[9].r,yb.i);
C_SUB(*Fout1,scratch[5],scratch[6]);
C_ADD(*Fout4,scratch[5],scratch[6]);
scratch[11].r = scratch[0].r + S_MUL(scratch[7].r,yb.r) + S_MUL(scratch[8].r,ya.r);
scratch[11].i = scratch[0].i + S_MUL(scratch[7].i,yb.r) + S_MUL(scratch[8].i,ya.r);
scratch[12].r = - S_MUL(scratch[10].i,yb.i) + S_MUL(scratch[9].i,ya.i);
scratch[12].i = S_MUL(scratch[10].r,yb.i) - S_MUL(scratch[9].r,ya.i);
C_ADD(*Fout2,scratch[11],scratch[12]);
C_SUB(*Fout3,scratch[11],scratch[12]);
++Fout0;++Fout1;++Fout2;++Fout3;++Fout4;
}
}
/* perform the butterfly for one stage of a mixed radix FFT */
static void kf_bfly_generic(
kiss_fft_cpx * Fout,
const size_t fstride,
const kiss_fft_cfg st,
int m,
int p
)
{
int u,k,q1,q;
kiss_fft_cpx * twiddles = st->twiddles;
kiss_fft_cpx t;
int Norig = st->nfft;
kiss_fft_cpx * scratch = (kiss_fft_cpx*)KISS_FFT_TMP_ALLOC(sizeof(kiss_fft_cpx)*p);
for ( u=0; u<m; ++u ) {
k=u;
for ( q1=0 ; q1<p ; ++q1 ) {
scratch[q1] = Fout[ k ];
C_FIXDIV(scratch[q1],p);
k += m;
}
k=u;
for ( q1=0 ; q1<p ; ++q1 ) {
int twidx=0;
Fout[ k ] = scratch[0];
for (q=1;q<p;++q ) {
twidx += fstride * k;
if (twidx>=Norig) twidx-=Norig;
C_MUL(t,scratch[q] , twiddles[twidx] );
C_ADDTO( Fout[ k ] ,t);
}
k += m;
}
}
KISS_FFT_TMP_FREE(scratch);
}
static
void kf_work(
kiss_fft_cpx * Fout,
const kiss_fft_cpx * f,
const size_t fstride,
int in_stride,
int * factors,
const kiss_fft_cfg st
)
{
kiss_fft_cpx * Fout_beg=Fout;
const int p=*factors++; /* the radix */
const int m=*factors++; /* stage's fft length/p */
const kiss_fft_cpx * Fout_end = Fout + p*m;
#ifdef _OPENMP
// use openmp extensions at the
// top-level (not recursive)
if (fstride==1 && p<=5)
{
int k;
// execute the p different work units in different threads
# pragma omp parallel for
for (k=0;k<p;++k)
kf_work( Fout +k*m, f+ fstride*in_stride*k,fstride*p,in_stride,factors,st);
// all threads have joined by this point
switch (p) {
case 2: kf_bfly2(Fout,fstride,st,m); break;
case 3: kf_bfly3(Fout,fstride,st,m); break;
case 4: kf_bfly4(Fout,fstride,st,m); break;
case 5: kf_bfly5(Fout,fstride,st,m); break;
default: kf_bfly_generic(Fout,fstride,st,m,p); break;
}
return;
}
#endif
if (m==1) {
do{
*Fout = *f;
f += fstride*in_stride;
}while(++Fout != Fout_end );
}else{
do{
// recursive call:
// DFT of size m*p performed by doing
// p instances of smaller DFTs of size m,
// each one takes a decimated version of the input
kf_work( Fout , f, fstride*p, in_stride, factors,st);
f += fstride*in_stride;
}while( (Fout += m) != Fout_end );
}
Fout=Fout_beg;
// recombine the p smaller DFTs
switch (p) {
case 2: kf_bfly2(Fout,fstride,st,m); break;
case 3: kf_bfly3(Fout,fstride,st,m); break;
case 4: kf_bfly4(Fout,fstride,st,m); break;
case 5: kf_bfly5(Fout,fstride,st,m); break;
default: kf_bfly_generic(Fout,fstride,st,m,p); break;
}
}
/* facbuf is populated by p1,m1,p2,m2, ...
where
p[i] * m[i] = m[i-1]
m0 = n */
static
void kf_factor(int n,int * facbuf)
{
int p=4;
double floor_sqrt;
floor_sqrt = floor( sqrt((double)n) );
/*factor out powers of 4, powers of 2, then any remaining primes */
do {
while (n % p) {
switch (p) {
case 4: p = 2; break;
case 2: p = 3; break;
default: p += 2; break;
}
if (p > floor_sqrt)
p = n; /* no more factors, skip to end */
}
n /= p;
*facbuf++ = p;
*facbuf++ = n;
} while (n > 1);
}
/*
*
* User-callable function to allocate all necessary storage space for the fft.
*
* The return value is a contiguous block of memory, allocated with malloc. As such,
* It can be freed with free(), rather than a kiss_fft-specific function.
* */
kiss_fft_cfg kiss_fft_alloc(int nfft,int inverse_fft,void * mem,size_t * lenmem )
{
kiss_fft_cfg st=NULL;
size_t memneeded = sizeof(struct kiss_fft_state)
+ sizeof(kiss_fft_cpx)*(nfft-1); /* twiddle factors*/
if ( lenmem==NULL ) {
st = ( kiss_fft_cfg)KISS_FFT_MALLOC( memneeded );
}else{
if (mem != NULL && *lenmem >= memneeded)
st = (kiss_fft_cfg)mem;
*lenmem = memneeded;
}
if (st) {
int i;
st->nfft=nfft;
st->inverse = inverse_fft;
for (i=0;i<nfft;++i) {
const double pi=3.141592653589793238462643383279502884197169399375105820974944;
double phase = -2*pi*i / nfft;
if (st->inverse)
phase *= -1;
kf_cexp(st->twiddles+i, phase );
}
kf_factor(nfft,st->factors);
}
return st;
}
void kiss_fft_stride(kiss_fft_cfg st,const kiss_fft_cpx *fin,kiss_fft_cpx *fout,int in_stride)
{
if (fin == fout) {
//NOTE: this is not really an in-place FFT algorithm.
//It just performs an out-of-place FFT into a temp buffer
kiss_fft_cpx * tmpbuf = (kiss_fft_cpx*)KISS_FFT_TMP_ALLOC( sizeof(kiss_fft_cpx)*st->nfft);
kf_work(tmpbuf,fin,1,in_stride, st->factors,st);
memcpy(fout,tmpbuf,sizeof(kiss_fft_cpx)*st->nfft);
KISS_FFT_TMP_FREE(tmpbuf);
}else{
kf_work( fout, fin, 1,in_stride, st->factors,st );
}
}
void kiss_fft(kiss_fft_cfg cfg,const kiss_fft_cpx *fin,kiss_fft_cpx *fout)
{
kiss_fft_stride(cfg,fin,fout,1);
}
void kiss_fft_cleanup(void)
{
// nothing needed any more
}
int kiss_fft_next_fast_size(int n)
{
while(1) {
int m=n;
while ( (m%2) == 0 ) m/=2;
while ( (m%3) == 0 ) m/=3;
while ( (m%5) == 0 ) m/=5;
if (m<=1)
break; /* n is completely factorable by twos, threes, and fives */
n++;
}
return n;
}
-132
View File
@@ -1,132 +0,0 @@
/*
* Copyright (c) 2003-2010, Mark Borgerding. All rights reserved.
* This file is part of KISS FFT - https://github.com/mborgerding/kissfft
*
* SPDX-License-Identifier: BSD-3-Clause
* See COPYING file for more information.
*/
#ifndef KISS_FFT_H
#define KISS_FFT_H
#include <stdlib.h>
#include <stdio.h>
#include <math.h>
#include <string.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
ATTENTION!
If you would like a :
-- a utility that will handle the caching of fft objects
-- real-only (no imaginary time component ) FFT
-- a multi-dimensional FFT
-- a command-line utility to perform ffts
-- a command-line utility to perform fast-convolution filtering
Then see kfc.h kiss_fftr.h kiss_fftnd.h fftutil.c kiss_fastfir.c
in the tools/ directory.
*/
#ifdef USE_SIMD
# include <xmmintrin.h>
# define kiss_fft_scalar __m128
#define KISS_FFT_MALLOC(nbytes) _mm_malloc(nbytes,16)
#define KISS_FFT_FREE _mm_free
#else
#define KISS_FFT_MALLOC malloc
#define KISS_FFT_FREE free
#endif
#ifdef FIXED_POINT
#include <sys/types.h>
# if (FIXED_POINT == 32)
# define kiss_fft_scalar int32_t
# else
# define kiss_fft_scalar int16_t
# endif
#else
# ifndef kiss_fft_scalar
/* default is float */
# define kiss_fft_scalar float
# endif
#endif
typedef struct {
kiss_fft_scalar r;
kiss_fft_scalar i;
}kiss_fft_cpx;
typedef struct kiss_fft_state* kiss_fft_cfg;
/*
* kiss_fft_alloc
*
* Initialize a FFT (or IFFT) algorithm's cfg/state buffer.
*
* typical usage: kiss_fft_cfg mycfg=kiss_fft_alloc(1024,0,NULL,NULL);
*
* The return value from fft_alloc is a cfg buffer used internally
* by the fft routine or NULL.
*
* If lenmem is NULL, then kiss_fft_alloc will allocate a cfg buffer using malloc.
* The returned value should be free()d when done to avoid memory leaks.
*
* The state can be placed in a user supplied buffer 'mem':
* If lenmem is not NULL and mem is not NULL and *lenmem is large enough,
* then the function places the cfg in mem and the size used in *lenmem
* and returns mem.
*
* If lenmem is not NULL and ( mem is NULL or *lenmem is not large enough),
* then the function returns NULL and places the minimum cfg
* buffer size in *lenmem.
* */
kiss_fft_cfg kiss_fft_alloc(int nfft,int inverse_fft,void * mem,size_t * lenmem);
/*
* kiss_fft(cfg,in_out_buf)
*
* Perform an FFT on a complex input buffer.
* for a forward FFT,
* fin should be f[0] , f[1] , ... ,f[nfft-1]
* fout will be F[0] , F[1] , ... ,F[nfft-1]
* Note that each element is complex and can be accessed like
f[k].r and f[k].i
* */
void kiss_fft(kiss_fft_cfg cfg,const kiss_fft_cpx *fin,kiss_fft_cpx *fout);
/*
A more generic version of the above function. It reads its input from every Nth sample.
* */
void kiss_fft_stride(kiss_fft_cfg cfg,const kiss_fft_cpx *fin,kiss_fft_cpx *fout,int fin_stride);
/* If kiss_fft_alloc allocated a buffer, it is one contiguous
buffer and can be simply free()d when no longer needed*/
#define kiss_fft_free KISS_FFT_FREE
/*
Cleans up some memory that gets managed internally. Not necessary to call, but it might clean up
your compiler output to call this before you exit.
*/
void kiss_fft_cleanup(void);
/*
* Returns the smallest integer k, such that k>=n and k has only "fast" factors (2,3,5)
*/
int kiss_fft_next_fast_size(int n);
/* for real ffts, we need an even size */
#define kiss_fftr_next_fast_size_real(n) \
(kiss_fft_next_fast_size( ((n)+1)>>1)<<1)
#ifdef __cplusplus
}
#endif
#endif
-153
View File
@@ -1,153 +0,0 @@
/*
* Copyright (c) 2003-2004, Mark Borgerding. All rights reserved.
* This file is part of KISS FFT - https://github.com/mborgerding/kissfft
*
* SPDX-License-Identifier: BSD-3-Clause
* See COPYING file for more information.
*/
#include "kiss_fftr.h"
#include "_kiss_fft_guts.h"
struct kiss_fftr_state{
kiss_fft_cfg substate;
kiss_fft_cpx * tmpbuf;
kiss_fft_cpx * super_twiddles;
#ifdef USE_SIMD
void * pad;
#endif
};
kiss_fftr_cfg kiss_fftr_alloc(int nfft,int inverse_fft,void * mem,size_t * lenmem)
{
int i;
kiss_fftr_cfg st = NULL;
size_t subsize = 0, memneeded;
if (nfft & 1) {
fprintf(stderr,"Real FFT optimization must be even.\n");
return NULL;
}
nfft >>= 1;
kiss_fft_alloc (nfft, inverse_fft, NULL, &subsize);
memneeded = sizeof(struct kiss_fftr_state) + subsize + sizeof(kiss_fft_cpx) * ( nfft * 3 / 2);
if (lenmem == NULL) {
st = (kiss_fftr_cfg) KISS_FFT_MALLOC (memneeded);
} else {
if (*lenmem >= memneeded)
st = (kiss_fftr_cfg) mem;
*lenmem = memneeded;
}
if (!st)
return NULL;
st->substate = (kiss_fft_cfg) (st + 1); /*just beyond kiss_fftr_state struct */
st->tmpbuf = (kiss_fft_cpx *) (((char *) st->substate) + subsize);
st->super_twiddles = st->tmpbuf + nfft;
kiss_fft_alloc(nfft, inverse_fft, st->substate, &subsize);
for (i = 0; i < nfft/2; ++i) {
double phase =
-3.14159265358979323846264338327 * ((double) (i+1) / nfft + .5);
if (inverse_fft)
phase *= -1;
kf_cexp (st->super_twiddles+i,phase);
}
return st;
}
void kiss_fftr(kiss_fftr_cfg st,const kiss_fft_scalar *timedata,kiss_fft_cpx *freqdata)
{
/* input buffer timedata is stored row-wise */
int k,ncfft;
kiss_fft_cpx fpnk,fpk,f1k,f2k,tw,tdc;
if ( st->substate->inverse) {
fprintf(stderr,"kiss fft usage error: improper alloc\n");
exit(1);
}
ncfft = st->substate->nfft;
/*perform the parallel fft of two real signals packed in real,imag*/
kiss_fft( st->substate , (const kiss_fft_cpx*)timedata, st->tmpbuf );
/* The real part of the DC element of the frequency spectrum in st->tmpbuf
* contains the sum of the even-numbered elements of the input time sequence
* The imag part is the sum of the odd-numbered elements
*
* The sum of tdc.r and tdc.i is the sum of the input time sequence.
* yielding DC of input time sequence
* The difference of tdc.r - tdc.i is the sum of the input (dot product) [1,-1,1,-1...
* yielding Nyquist bin of input time sequence
*/
tdc.r = st->tmpbuf[0].r;
tdc.i = st->tmpbuf[0].i;
C_FIXDIV(tdc,2);
CHECK_OVERFLOW_OP(tdc.r ,+, tdc.i);
CHECK_OVERFLOW_OP(tdc.r ,-, tdc.i);
freqdata[0].r = tdc.r + tdc.i;
freqdata[ncfft].r = tdc.r - tdc.i;
#ifdef USE_SIMD
freqdata[ncfft].i = freqdata[0].i = _mm_set1_ps(0);
#else
freqdata[ncfft].i = freqdata[0].i = 0;
#endif
for ( k=1;k <= ncfft/2 ; ++k ) {
fpk = st->tmpbuf[k];
fpnk.r = st->tmpbuf[ncfft-k].r;
fpnk.i = - st->tmpbuf[ncfft-k].i;
C_FIXDIV(fpk,2);
C_FIXDIV(fpnk,2);
C_ADD( f1k, fpk , fpnk );
C_SUB( f2k, fpk , fpnk );
C_MUL( tw , f2k , st->super_twiddles[k-1]);
freqdata[k].r = HALF_OF(f1k.r + tw.r);
freqdata[k].i = HALF_OF(f1k.i + tw.i);
freqdata[ncfft-k].r = HALF_OF(f1k.r - tw.r);
freqdata[ncfft-k].i = HALF_OF(tw.i - f1k.i);
}
}
void kiss_fftri(kiss_fftr_cfg st,const kiss_fft_cpx *freqdata,kiss_fft_scalar *timedata)
{
/* input buffer timedata is stored row-wise */
int k, ncfft;
if (st->substate->inverse == 0) {
fprintf (stderr, "kiss fft usage error: improper alloc\n");
exit (1);
}
ncfft = st->substate->nfft;
st->tmpbuf[0].r = freqdata[0].r + freqdata[ncfft].r;
st->tmpbuf[0].i = freqdata[0].r - freqdata[ncfft].r;
C_FIXDIV(st->tmpbuf[0],2);
for (k = 1; k <= ncfft / 2; ++k) {
kiss_fft_cpx fk, fnkc, fek, fok, tmp;
fk = freqdata[k];
fnkc.r = freqdata[ncfft - k].r;
fnkc.i = -freqdata[ncfft - k].i;
C_FIXDIV( fk , 2 );
C_FIXDIV( fnkc , 2 );
C_ADD (fek, fk, fnkc);
C_SUB (tmp, fk, fnkc);
C_MUL (fok, tmp, st->super_twiddles[k-1]);
C_ADD (st->tmpbuf[k], fek, fok);
C_SUB (st->tmpbuf[ncfft - k], fek, fok);
#ifdef USE_SIMD
st->tmpbuf[ncfft - k].i *= _mm_set1_ps(-1.0);
#else
st->tmpbuf[ncfft - k].i *= -1;
#endif
}
kiss_fft (st->substate, st->tmpbuf, (kiss_fft_cpx *) timedata);
}
-54
View File
@@ -1,54 +0,0 @@
/*
* Copyright (c) 2003-2004, Mark Borgerding. All rights reserved.
* This file is part of KISS FFT - https://github.com/mborgerding/kissfft
*
* SPDX-License-Identifier: BSD-3-Clause
* See COPYING file for more information.
*/
#ifndef KISS_FTR_H
#define KISS_FTR_H
#include "kiss_fft.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
Real optimized version can save about 45% cpu time vs. complex fft of a real seq.
*/
typedef struct kiss_fftr_state *kiss_fftr_cfg;
kiss_fftr_cfg kiss_fftr_alloc(int nfft,int inverse_fft,void * mem, size_t * lenmem);
/*
nfft must be even
If you don't care to allocate space, use mem = lenmem = NULL
*/
void kiss_fftr(kiss_fftr_cfg cfg,const kiss_fft_scalar *timedata,kiss_fft_cpx *freqdata);
/*
input timedata has nfft scalar points
output freqdata has nfft/2+1 complex points
*/
void kiss_fftri(kiss_fftr_cfg cfg,const kiss_fft_cpx *freqdata,kiss_fft_scalar *timedata);
/*
input freqdata has nfft/2+1 complex points
output timedata has nfft scalar points
*/
#define kiss_fftr_free KISS_FFT_FREE
#ifdef __cplusplus
}
#endif
#endif
-54
View File
@@ -1,54 +0,0 @@
#
# On MS Windows using Msys/MinGW gfortran invoke like this:
#
# FC=gfortran make
#
# On macOS using MacPorts gfortran invoke like this:
#
# FC=gfortran make
#
# or if the gfortran compiler is named gfortran-mp-8 or similar
#
# FC=gfortran-mp-8 make
#
# otherwise invoke like this:
#
# make
#
ifeq ($(OS),Windows_NT)
EXE = .exe
endif
EXES = hashcodes$(EXE) std_call_to_c28$(EXE) nonstd_to_c58$(EXE) \
free_text_to_f71$(EXE) grid4_to_g15$(EXE) grid6_to_g25$(EXE) \
gen_crc14$(EXE)
%.o: %.f90
$(FC) -c $(FFLAGS) -o $@ $<
all: $(EXES)
hashcodes$(EXE): hashcodes.o
${FC} -o $@ $^
std_call_to_c28$(EXE): std_call_to_c28.o
${FC} -o $@ $^
nonstd_to_c58$(EXE): nonstd_to_c58.o
${FC} -o $@ $^
free_text_to_f71$(EXE): free_text_to_f71.o
${FC} -o $@ $^
grid4_to_g15$(EXE): grid4_to_g15.o
${FC} -o $@ $^
grid6_to_g25$(EXE): grid6_to_g25.o
${FC} -o $@ $^
gen_crc14$(EXE): gen_crc14.o
${FC} -o $@ $^
clean:
-rm $(EXES) *.o
-13
View File
@@ -1,13 +0,0 @@
! Abbreviations for ARRL/RAC Sections as a Fortran 90 data statement:
data csec/ &
"AB ","AK ","AL ","AR ","AZ ","BC ","CO ","CT ","DE ","EB ", &
"EMA","ENY","EPA","EWA","GA ","GTA","IA ","ID ","IL ","IN ", &
"KS ","KY ","LA ","LAX","MAR","MB ","MDC","ME ","MI ","MN ", &
"MO ","MS ","MT ","NC ","ND ","NE ","NFL","NH ","NL ","NLI", &
"NM ","NNJ","NNY","NT ","NTX","NV ","OH ","OK ","ONE","ONN", &
"ONS","OR ","ORG","PAC","PR ","QC ","RI ","SB ","SC ","SCV", &
"SD ","SDG","SF ","SFL","SJV","SK ","SNJ","STX","SV ","TN ", &
"UT ","VA ","VI ","VT ","WCF","WI ","WMA","WNY","WPA","WTX", &
"WV ","WWA","WY ","DX "/
-67
View File
@@ -1,67 +0,0 @@
program free_text_to_f71
character*13 c13,w
character*71 f71
character*42 c
character*1 qa(10),qb(10)
data c/' 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?'/
nargs=iargc()
if(nargs.ne.1) then
print*,'Usage: free_text_to_f71 "<message>"'
print*,'Example: free_text_to_f71 "TNX BOB 73 GL"'
go to 999
endif
call getarg(1,c13)
call mp_short_init
qa=char(0)
w=adjustr(c13)
do i=1,13
j=index(c,w(i:i))-1
if(j.lt.0) j=0
call mp_short_mult(qb,qa(2:10),9,42) !qb(1:9)=42*qa(2:9)
call mp_short_add(qa,qb(2:10),9,j) !qa(1:9)=qb(2:9)+j
enddo
write(f71,1000) qa(2:10)
1000 format(b7.7,8b8.8)
write(*,1010) c13,f71
1010 format('Free text: ',a13/'f71: ',a71)
999 end program free_text_to_f71
subroutine mp_short_ops(w,u)
! Multi-precision arithmetic with storage in character arrays.
character*1 w(*),u(*)
integer i,ireg,j,n,ir,iv,ii1,ii2
character*1 creg(4)
save ii1,ii2
equivalence (ireg,creg)
entry mp_short_init
ireg=256*ichar('2')+ichar('1')
do j=1,4
if (creg(j).eq.'1') ii1=j
if (creg(j).eq.'2') ii2=j
enddo
return
entry mp_short_add(w,u,n,iv)
ireg=256*iv
do j=n,1,-1
ireg=ichar(u(j))+ichar(creg(ii2))
w(j+1)=creg(ii1)
enddo
w(1)=creg(ii2)
return
entry mp_short_mult(w,u,n,iv)
ireg=0
do j=n,1,-1
ireg=ichar(u(j))*iv+ichar(creg(ii2))
w(j+1)=creg(ii1)
enddo
w(1)=creg(ii2)
return
return
end subroutine mp_short_ops
-36
View File
@@ -1,36 +0,0 @@
program gen_crc14
character m77*77,c14*14
integer mc(96),r(15),p(15),ncrc
! polynomial for 14-bit CRC 0x6757
data p/1,1,0,0,1,1,1,0,1,0,1,0,1,1,1/
nargs=iargc()
if(nargs.ne.1) then
print*,'Usage: gen_crc14 <77-bit message>'
print*,'Example: gen_crc14 "00000000000000000000000000100000010011011111110011011100100010100001010000001"'
go to 999
endif
! pad the 77bit message out to 96 bits
call getarg(1,m77)
read(m77,'(77i1)') mc(1:77)
mc(78:96)=0
! divide by polynomial
r=mc(1:15)
do i=0,81
r(15)=mc(i+15)
r=mod(r+r(1)*p,2)
r=cshift(r,1)
enddo
! the crc is in r(1:14) - print it in various ways:
write(c14,'(14b1)') r(1:14)
write(*,'(a40,1x,a14)') 'crc14 as a string: ',c14
read(c14,'(b14.14)') ncrc
write(*,'(a40,i6)') 'crc14 as an integer: ',ncrc
write(*,'(a40,1x,b14.14)') 'binary representation of the integer: ',ncrc
999 end program gen_crc14
-86
View File
@@ -1,86 +0,0 @@
This file contains the generator matrix for the FT8/FT4 (174,91) LDPC code.
The matrix has 91 columns and 83 rows.
1000001100101001110011100001000110111111001100011110101011110101000010011111001001111111110
0111011000011100001001100100111000100101110000100101100100110011010101001001001100010011001
1101110000100110010110010000001011111011001001110111110001100100000100001010000110111101110
0001101100111111010000010111100001011000110011010010110111010011001111101100011111110110001
0000100111111101101001001111111011100000010000011001010111111101000000110100011110000011101
0000011101111100110011001100000100011011100010000111001111101101010111000011110101001000101
0010100110110110001010101111111000111100101000000011011011110100111111100001101010011101101
0110000001010100111110101111010111110011010111011001011011010011101100001100100011000011111
1110001000000111100110001110010000110001000011101110110100100111100010000100101011101001000
0111011101011100100111000000100011101000000011100010011011011101101011100101011000110001100
1011000010111000000100010000001010001100001010111111100110010111001000010011010010000111110
0001100010100000110010010010001100011111110001100000101011011111010111000101111010100011001
0111011001000111000111101000001100000010101000000111001000011110000000011011000100101011100
1111111110111100110010111000000011001010100000110100000111111010111110110100011110110010111
0110011010100111001010100001010110001111100100110010010110100010101111110110011100010111000
1100010000100100001101101000100111111110100001011011000111000101000100110110001110100001100
0000110111111111011100111001010000010100110100011010000110110011010010110001110000100111000
0001010110110100100010000011000001100011011011001000101110011001100010010100100101110010111
0010100110101000100111000000110100111101111010000001110101100110010101001000100110110000111
0100111100010010011011110011011111111010010100011100101111100110000110111101011010111001010
1001100111000100011100100011100111010000110110010111110100111100100001001110000010010100000
0001100100011001101101110101000100011001011101100101011000100001101110110100111100011110100
0000100111011011000100101101011100110001111110101110111000001011100001101101111101101011100
0100100010001111110000110011110111110100001111111011110111101110101001001110101011111011010
1000001001110100001000111110111001000000101101100111010111110111010101101110101101011111111
1010101111100001100101111100010010000100110010110111010001110101011100010100010010101001101
0010101101010000000011100100101111000000111011000101101001101101001010111101101111011101000
1100010001110100101010100101001111010111000000100001100001110110000101100110100100110110000
1000111010111010000110100001001111011011001100111001000010111101011001110001100011001110110
0111010100111000010001000110011100111010001001110111100000101100110001000010000000010010111
0000011011111111100000111010000101000101110000110111000000110101101001011100000100100110100
0011101100110111010000010111100001011000110011000010110111010011001111101100001111110110001
1001101001001010010110100010100011101110000101111100101010011100001100100100100001000010110
1011110000101001111101000110010100110000100111001001011101111110100010010110000100001010010
0010011001100011101011100110110111011111100010110101110011100010101110110010100101001000100
0100011011110010001100011110111111100100010101110000001101001100000110000001010001000001100
0011111110110010110011101000010110101011111010011011000011000111001011100000011011111011111
1101111010000111010010000001111100101000001011000001010100111001011100011010000010100010111
1111110011010111110011001111001000111100011010011111101010011001101110111010000101000001001
1111000000100110000101000100011111101001010010010000110010101000111001000111010011001110110
0100010000010000000100010101100000011000000110010110111110010101110011011101011100000001001
0000100010001111110000110001110111110100101111111011110111100010101001001110101011111011010
1011100011111110111100011011011000110000011101110010100111111011000010100000011110001100000
0101101011111110101001111010110011001100101101110111101110111100100111011001100110101001000
0100100110100111000000010110101011000110010100111111011001011110110011011100100100000111011
0001100101000100110100001000010110111110010011100111110110101000110101101100110001111101000
0010010100011111011000101010110111000100000000110010111100001110111001110001010000000000001
0101011001000111000111111000011100000010101000000111001000011110000000001011000100101011100
0010101110001110010010010010001111110010110111010101000111100010110101010011011111111010000
0110101101010101000010100100000010100110011011110100011101010101110111101001010111000010011
1010000110001010110100101000110101001110001001111111111010010010101001001111011011001000010
0001000011000010111001011000011000111000100011001011100000101010001111011000000001110101100
1110111100110100101001000001100000010111111011100000001000010011001111011011001011101011000
0111111010011100000011000101010000110010010110101001110000010101100000110110111000000000000
0011011010010011111001010111001011010001111111011110010011001101111100000111100111101000011
1011111110110010110011101100010110101011111000011011000011000111001011100000011111111011111
0111111011100001100000100011000011000101100000111100110011001100010101111101010010110000100
1010000001100110110010110010111111101101101011111100100111110101001001100110010000010010011
1011101100100011011100100101101010111100010001111100110001011111010011001100010011001101001
1101111011011001110110111010001110111110111001000000110001011001101101010110000010011011010
1101100110100111000000010110101011000110010100111110011011011110110011011100100100000011011
1001101011010100011010101110110101011111011100000111111100101000000010101011010111111100010
1110010110010010000111000111011110000010001001011000011100110001011011010111110100111100001
0100111100010100110110101000001001000010101010001011100001101101110010100111001100110101001
1000101110001011010100000111101011010100011001111101010001000100000111011111011101110000111
0010001010000011000111001001110011110001000101101001010001100111101011010000010010110110100
0010000100111011100000111000111111100010101011100101010011000011100011101110011100011000000
0101110110010010011010110110110111010111000111110000100001010001100000011010010011100001001
0110011010101011011110011101010010110010100111101110011011100110100101010000100111100101011
1001010110000001010010000110100000101101011101001000101000111000110111010110100010111010101
1011100011001110000000100000110011110000011010011100001100101010011100100011101010110001010
1111010000110011000111010110110101000110000101100000011111101001010101110101001001110100011
0110110110100010001110111010010000100100101110010101100101100001001100111100111110011100100
1010011000110110101111001011110001111011001100001100010111111011111010101110011001111111111
0101110010110000110110000110101000000111110111110110010101001010100100001000100110100010000
1111000100011111000100000110100001001000011110000000111111001001111011001101110110000000101
0001111110111011010100110110010011111011100011010010110010011101011100110000110101011011101
1111110010111000011010111100011100001010010100001100100111010000001010100101110100000011010
1010010100110100010000110011000000101001111010101100000101011111001100100010111000110100110
1100100110001001110110011100011111000011110100111011100011000101010111010111010100010011000
0111101110110011100010110010111100000001100001101101010001100110010000111010111010010110001
0010011001000100111010111010110111101011010001001011100101000110011111010001111101000010110
0110000010001100110010000101011101011001010010111111101110110101010111010110100101100000000
-55
View File
@@ -1,55 +0,0 @@
program grid4_to_g15
parameter (MAXGRID4=32400)
character*4 w,grid4
character c1*1,c2*2
logical is_grid4
is_grid4(grid4)=len(trim(grid4)).eq.4 .and. &
grid4(1:1).ge.'A' .and. grid4(1:1).le.'R' .and. &
grid4(2:2).ge.'A' .and. grid4(2:2).le.'R' .and. &
grid4(3:3).ge.'0' .and. grid4(3:3).le.'9' .and. &
grid4(4:4).ge.'0' .and. grid4(4:4).le.'9'
nargs=iargc()
if(nargs.ne.1) then
print*,'Convert a 4-character grid, signal report, etc., to a g15 value.'
print*,'Usage examples:'
print*,'grid4_to_g15 FN20'
print*,'grid4_to_g15 -11'
print*,'grid4_to_g15 +02'
print*,'grid4_to_g15 RRR'
print*,'grid4_to_g15 RR73'
print*,'grid4_to_g15 73'
print*,'grid4_to_g15 ""'
go to 999
endif
call getarg(1,w)
if(is_grid4(w) .and. w.ne.'RR73') then
j1=(ichar(w(1:1))-ichar('A'))*18*10*10
j2=(ichar(w(2:2))-ichar('A'))*10*10
j3=(ichar(w(3:3))-ichar('0'))*10
j4=(ichar(w(4:4))-ichar('0'))
igrid4=j1+j2+j3+j4
else
c1=w(1:1)
if(c1.ne.'+' .and. c1.ne.'-'.and. trim(w).ne.'RRR' .and. w.ne.'RR73' &
.and. trim(w).ne.'73' .and. len(trim(w)).ne.0) go to 900
if(c1.eq.'+' .or. c1.eq.'-') then
read(w,*,err=900) irpt
irpt=irpt+35
endif
if(len(trim(w)).eq.0) irpt=1
if(trim(w).eq.'RRR') irpt=2
if(w.eq.'RR73') irpt=3
if(trim(w).eq.'73') irpt=4
igrid4=MAXGRID4 + irpt
endif
write(*,1000) w,igrid4,igrid4
1000 format('Encoded word: ',a4,' g15 in binary: ',b15.15,' decimal:',i6)
go to 999
900 write(*,1900)
1900 format('Invalid input')
999 end program grid4_to_g15
-41
View File
@@ -1,41 +0,0 @@
program grid6_to_g25
parameter (MAXGRID4=32400)
character*6 w,grid6
character c1*1,c2*2
logical is_grid6
is_grid6(grid6)=len(trim(grid6)).eq.6 .and. &
grid6(1:1).ge.'A' .and. grid6(1:1).le.'R' .and. &
grid6(2:2).ge.'A' .and. grid6(2:2).le.'R' .and. &
grid6(3:3).ge.'0' .and. grid6(3:3).le.'9' .and. &
grid6(4:4).ge.'0' .and. grid6(4:4).le.'9' .and. &
grid6(5:5).ge.'A' .and. grid6(5:5).le.'X' .and. &
grid6(6:6).ge.'A' .and. grid6(6:6).le.'X'
nargs=iargc()
if(nargs.ne.1) then
print*,'Convert a 6-character grid to a g25 value.'
print*,'Usage: grid6_to_g25 IO91NP'
go to 999
endif
call getarg(1,w)
if(.not. is_grid6(w)) go to 900
j1=(ichar(w(1:1))-ichar('A'))*18*10*10*24*24
j2=(ichar(w(2:2))-ichar('A'))*10*10*24*24
j3=(ichar(w(3:3))-ichar('0'))*10*24*24
j4=(ichar(w(4:4))-ichar('0'))*24*24
j5=(ichar(w(5:5))-ichar('A'))*24
j6=(ichar(w(6:6))-ichar('A'))
igrid6=j1+j2+j3+j4+j5+j6
write(*,1000) w,igrid6,igrid6
1000 format('Encoded word: ',a6,' g25 in binary: ',b25.25/ &
30x,'decimal:',i9)
go to 999
900 write(*,1900)
1900 format('Invalid input')
999 end program grid6_to_g25
-34
View File
@@ -1,34 +0,0 @@
program hashcodes
parameter (NTOKENS=2063592)
integer*8 nprime,n8(3)
integer nbits(3),ihash(3)
character*11 callsign
character*38 c
data c/' 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/'/
data nprime/47055833459_8/,nbits/10,12,22/
nargs=iargc()
if(nargs.ne.1) then
print*,'Usage: hashcodes <callsign>'
print*,'Examples: hashcodes PJ4/K1ABC'
print*,' hashcodes YW18FIFA'
go to 999
endif
call getarg(1,callsign)
callsign=adjustl(callsign)
do k=1,3
n8(k)=0
do i=1,11
j=index(c,callsign(i:i)) - 1
n8(k)=38*n8(k) + j
enddo
ihash(k)=ishft(nprime*n8(k),nbits(k)-64)
enddo
ih22_biased=ihash(3) + NTOKENS
write(*,1000) callsign,ihash,ih22_biased
1000 format('Callsign',9x,'h10',7x,'h12',7x,'h22'/41('-')/ &
a11,i9,2i10,/'Biased for storage in c28:',i14)
999 end program hashcodes
-24
View File
@@ -1,24 +0,0 @@
program nonstd_to_c58
integer*8 n58
character*11 callsign
character*38 c
data c/' 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/'/
nargs=iargc()
if(nargs.ne.1) then
print*,'Usage: nonstd_to_c58 <callsign>'
print*,'Examples: nonstd_to_c58 PJ4/K1ABC'
print*,' nonstd_to_c58 YW18FIFA'
go to 999
endif
call getarg(1,callsign)
n58=0
do i=1,11
n58=n58*38 + index(c,callsign(i:i)) - 1
enddo
write(*,1000) callsign,n58,n58
1000 format('Callsign: ',a11/'c58 (binary): ' b58.58/'c58 (decimal):',i20)
999 end program nonstd_to_c58
-183
View File
@@ -1,183 +0,0 @@
This file specifies the sparse 83x174 parity-check matrix for the
FT8/FT4 (174,91) LDPC code. Each of the 174 columns contains
exactly 3 ones. The rows contain either 6 or 7 ones.
The matrix is specified by the following list consisting of
174 lines, each of which includes 3 numbers.
Each line corresponds to a column of the parity check matrix.
The three numbers are indices of the rows that contain a one in
the corresponding column. The indices range from 1 through 83.
16 45 73
25 51 62
33 58 78
1 44 45
2 7 61
3 6 54
4 35 48
5 13 21
8 56 79
9 64 69
10 19 66
11 36 60
12 37 58
14 32 43
15 63 80
17 28 77
18 74 83
22 53 81
23 30 34
24 31 40
26 41 76
27 57 70
29 49 65
3 38 78
5 39 82
46 50 73
51 52 74
55 71 72
44 67 72
43 68 78
1 32 59
2 6 71
4 16 54
7 65 67
8 30 42
9 22 31
10 18 76
11 23 82
12 28 61
13 52 79
14 50 51
15 81 83
17 29 60
19 33 64
20 26 73
21 34 40
24 27 77
25 55 58
35 53 66
36 48 68
37 46 75
38 45 47
39 57 69
41 56 62
20 49 53
46 52 63
45 70 75
27 35 80
1 15 30
2 68 80
3 36 51
4 28 51
5 31 56
6 20 37
7 40 82
8 60 69
9 10 49
11 44 57
12 39 59
13 24 55
14 21 65
16 71 78
17 30 76
18 25 80
19 61 83
22 38 77
23 41 50
7 26 58
29 32 81
33 40 73
18 34 48
13 42 64
5 26 43
47 69 72
54 55 70
45 62 68
10 63 67
14 66 72
22 60 74
35 39 79
1 46 64
1 24 66
2 5 70
3 31 65
4 49 58
1 4 5
6 60 67
7 32 75
8 48 82
9 35 41
10 39 62
11 14 61
12 71 74
13 23 78
11 35 55
15 16 79
7 9 16
17 54 63
18 50 57
19 30 47
20 64 80
21 28 69
22 25 43
13 22 37
2 47 51
23 54 74
26 34 72
27 36 37
21 36 63
29 40 44
19 26 57
3 46 82
14 15 58
33 52 53
30 43 52
6 9 52
27 33 65
25 69 73
38 55 83
20 39 77
18 29 56
32 48 71
42 51 59
28 44 79
34 60 62
31 45 61
46 68 77
6 24 76
8 10 78
40 41 70
17 50 53
42 66 68
4 22 72
36 64 81
13 29 47
2 8 81
56 67 73
5 38 50
12 38 64
59 72 80
3 26 79
45 76 81
1 65 74
7 18 77
11 56 59
14 39 54
16 37 66
10 28 55
15 60 70
17 25 82
20 30 31
12 67 68
23 75 80
27 32 62
24 69 75
19 21 71
34 53 61
35 46 47
33 59 76
40 43 83
41 42 63
49 75 83
20 44 48
42 49 57
-11
View File
@@ -1,11 +0,0 @@
! Abbreviations for US States and Canadian Provinces as a Fortran 90
! data statement:
data cmult/ &
"AL ","AK ","AZ ","AR ","CA ","CO ","CT ","DE ","FL ","GA ", &
"HI ","ID ","IL ","IN ","IA ","KS ","KY ","LA ","ME ","MD ", &
"MA ","MI ","MN ","MS ","MO ","MT ","NE ","NV ","NH ","NJ ", &
"NM ","NY ","NC ","ND ","OH ","OK ","OR ","PA ","RI ","SC ", &
"SD ","TN ","TX ","UT ","VT ","VA ","WA ","WV ","WI ","WY ", &
"NB ","NS ","QC ","ON ","MB ","SK ","AB ","BC ","NWT","NF ", &
"LB ","NU ","YT ","PEI","DC "/
-31
View File
@@ -1,31 +0,0 @@
program std_call_to_c28
parameter (NTOKENS=2063592,MAX22=4194304)
character*6 call_std
character a1*37,a2*36,a3*10,a4*27
data a1/' 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'/
data a2/'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'/
data a3/'0123456789'/
data a4/' ABCDEFGHIJKLMNOPQRSTUVWXYZ'/
nargs=iargc()
if(nargs.ne.1) then
print*,'Usage: std_call_to_c28 <call_std>'
print*,'Example: std_call_to_c28 K1ABC'
go to 999
endif
call getarg(1,call_std)
call_std=adjustr(call_std)
i1=index(a1,call_std(1:1))-1
i2=index(a2,call_std(2:2))-1
i3=index(a3,call_std(3:3))-1
i4=index(a4,call_std(4:4))-1
i5=index(a4,call_std(5:5))-1
i6=index(a4,call_std(6:6))-1
n28=NTOKENS + MAX22 + 36*10*27*27*27*i1 + 10*27*27*27*i2 + &
27*27*27*i3 + 27*27*i4 + 27*i5 + i6
write(*,1000) call_std,n28
1000 format('Callsign: ',a6,2x,'c28 as decimal integer:',i10)
999 end program std_call_to_c28
-392
View File
@@ -1,392 +0,0 @@
#include "constants.h"
// Costas sync tone pattern
const uint8_t kFT8_Costas_pattern[7] = { 3, 1, 4, 0, 6, 5, 2 };
const uint8_t kFT4_Costas_pattern[4][4] = {
{ 0, 1, 3, 2 },
{ 1, 0, 2, 3 },
{ 2, 3, 1, 0 },
{ 3, 2, 0, 1 }
};
// Gray code map (FTx bits -> channel symbols)
const uint8_t kFT8_Gray_map[8] = { 0, 1, 3, 2, 5, 6, 4, 7 };
const uint8_t kFT4_Gray_map[4] = { 0, 1, 3, 2 };
const uint8_t kFT4_XOR_sequence[10] = {
0x4Au, // 01001010
0x5Eu, // 01011110
0x89u, // 10001001
0xB4u, // 10110100
0xB0u, // 10110000
0x8Au, // 10001010
0x79u, // 01111001
0x55u, // 01010101
0xBEu, // 10111110
0x28u, // 00101 [000]
};
// Parity generator matrix for (174,91) LDPC code, stored in bitpacked format (MSB first)
const uint8_t kFTX_LDPC_generator[FTX_LDPC_M][FTX_LDPC_K_BYTES] = {
{ 0x83, 0x29, 0xce, 0x11, 0xbf, 0x31, 0xea, 0xf5, 0x09, 0xf2, 0x7f, 0xc0 },
{ 0x76, 0x1c, 0x26, 0x4e, 0x25, 0xc2, 0x59, 0x33, 0x54, 0x93, 0x13, 0x20 },
{ 0xdc, 0x26, 0x59, 0x02, 0xfb, 0x27, 0x7c, 0x64, 0x10, 0xa1, 0xbd, 0xc0 },
{ 0x1b, 0x3f, 0x41, 0x78, 0x58, 0xcd, 0x2d, 0xd3, 0x3e, 0xc7, 0xf6, 0x20 },
{ 0x09, 0xfd, 0xa4, 0xfe, 0xe0, 0x41, 0x95, 0xfd, 0x03, 0x47, 0x83, 0xa0 },
{ 0x07, 0x7c, 0xcc, 0xc1, 0x1b, 0x88, 0x73, 0xed, 0x5c, 0x3d, 0x48, 0xa0 },
{ 0x29, 0xb6, 0x2a, 0xfe, 0x3c, 0xa0, 0x36, 0xf4, 0xfe, 0x1a, 0x9d, 0xa0 },
{ 0x60, 0x54, 0xfa, 0xf5, 0xf3, 0x5d, 0x96, 0xd3, 0xb0, 0xc8, 0xc3, 0xe0 },
{ 0xe2, 0x07, 0x98, 0xe4, 0x31, 0x0e, 0xed, 0x27, 0x88, 0x4a, 0xe9, 0x00 },
{ 0x77, 0x5c, 0x9c, 0x08, 0xe8, 0x0e, 0x26, 0xdd, 0xae, 0x56, 0x31, 0x80 },
{ 0xb0, 0xb8, 0x11, 0x02, 0x8c, 0x2b, 0xf9, 0x97, 0x21, 0x34, 0x87, 0xc0 },
{ 0x18, 0xa0, 0xc9, 0x23, 0x1f, 0xc6, 0x0a, 0xdf, 0x5c, 0x5e, 0xa3, 0x20 },
{ 0x76, 0x47, 0x1e, 0x83, 0x02, 0xa0, 0x72, 0x1e, 0x01, 0xb1, 0x2b, 0x80 },
{ 0xff, 0xbc, 0xcb, 0x80, 0xca, 0x83, 0x41, 0xfa, 0xfb, 0x47, 0xb2, 0xe0 },
{ 0x66, 0xa7, 0x2a, 0x15, 0x8f, 0x93, 0x25, 0xa2, 0xbf, 0x67, 0x17, 0x00 },
{ 0xc4, 0x24, 0x36, 0x89, 0xfe, 0x85, 0xb1, 0xc5, 0x13, 0x63, 0xa1, 0x80 },
{ 0x0d, 0xff, 0x73, 0x94, 0x14, 0xd1, 0xa1, 0xb3, 0x4b, 0x1c, 0x27, 0x00 },
{ 0x15, 0xb4, 0x88, 0x30, 0x63, 0x6c, 0x8b, 0x99, 0x89, 0x49, 0x72, 0xe0 },
{ 0x29, 0xa8, 0x9c, 0x0d, 0x3d, 0xe8, 0x1d, 0x66, 0x54, 0x89, 0xb0, 0xe0 },
{ 0x4f, 0x12, 0x6f, 0x37, 0xfa, 0x51, 0xcb, 0xe6, 0x1b, 0xd6, 0xb9, 0x40 },
{ 0x99, 0xc4, 0x72, 0x39, 0xd0, 0xd9, 0x7d, 0x3c, 0x84, 0xe0, 0x94, 0x00 },
{ 0x19, 0x19, 0xb7, 0x51, 0x19, 0x76, 0x56, 0x21, 0xbb, 0x4f, 0x1e, 0x80 },
{ 0x09, 0xdb, 0x12, 0xd7, 0x31, 0xfa, 0xee, 0x0b, 0x86, 0xdf, 0x6b, 0x80 },
{ 0x48, 0x8f, 0xc3, 0x3d, 0xf4, 0x3f, 0xbd, 0xee, 0xa4, 0xea, 0xfb, 0x40 },
{ 0x82, 0x74, 0x23, 0xee, 0x40, 0xb6, 0x75, 0xf7, 0x56, 0xeb, 0x5f, 0xe0 },
{ 0xab, 0xe1, 0x97, 0xc4, 0x84, 0xcb, 0x74, 0x75, 0x71, 0x44, 0xa9, 0xa0 },
{ 0x2b, 0x50, 0x0e, 0x4b, 0xc0, 0xec, 0x5a, 0x6d, 0x2b, 0xdb, 0xdd, 0x00 },
{ 0xc4, 0x74, 0xaa, 0x53, 0xd7, 0x02, 0x18, 0x76, 0x16, 0x69, 0x36, 0x00 },
{ 0x8e, 0xba, 0x1a, 0x13, 0xdb, 0x33, 0x90, 0xbd, 0x67, 0x18, 0xce, 0xc0 },
{ 0x75, 0x38, 0x44, 0x67, 0x3a, 0x27, 0x78, 0x2c, 0xc4, 0x20, 0x12, 0xe0 },
{ 0x06, 0xff, 0x83, 0xa1, 0x45, 0xc3, 0x70, 0x35, 0xa5, 0xc1, 0x26, 0x80 },
{ 0x3b, 0x37, 0x41, 0x78, 0x58, 0xcc, 0x2d, 0xd3, 0x3e, 0xc3, 0xf6, 0x20 },
{ 0x9a, 0x4a, 0x5a, 0x28, 0xee, 0x17, 0xca, 0x9c, 0x32, 0x48, 0x42, 0xc0 },
{ 0xbc, 0x29, 0xf4, 0x65, 0x30, 0x9c, 0x97, 0x7e, 0x89, 0x61, 0x0a, 0x40 },
{ 0x26, 0x63, 0xae, 0x6d, 0xdf, 0x8b, 0x5c, 0xe2, 0xbb, 0x29, 0x48, 0x80 },
{ 0x46, 0xf2, 0x31, 0xef, 0xe4, 0x57, 0x03, 0x4c, 0x18, 0x14, 0x41, 0x80 },
{ 0x3f, 0xb2, 0xce, 0x85, 0xab, 0xe9, 0xb0, 0xc7, 0x2e, 0x06, 0xfb, 0xe0 },
{ 0xde, 0x87, 0x48, 0x1f, 0x28, 0x2c, 0x15, 0x39, 0x71, 0xa0, 0xa2, 0xe0 },
{ 0xfc, 0xd7, 0xcc, 0xf2, 0x3c, 0x69, 0xfa, 0x99, 0xbb, 0xa1, 0x41, 0x20 },
{ 0xf0, 0x26, 0x14, 0x47, 0xe9, 0x49, 0x0c, 0xa8, 0xe4, 0x74, 0xce, 0xc0 },
{ 0x44, 0x10, 0x11, 0x58, 0x18, 0x19, 0x6f, 0x95, 0xcd, 0xd7, 0x01, 0x20 },
{ 0x08, 0x8f, 0xc3, 0x1d, 0xf4, 0xbf, 0xbd, 0xe2, 0xa4, 0xea, 0xfb, 0x40 },
{ 0xb8, 0xfe, 0xf1, 0xb6, 0x30, 0x77, 0x29, 0xfb, 0x0a, 0x07, 0x8c, 0x00 },
{ 0x5a, 0xfe, 0xa7, 0xac, 0xcc, 0xb7, 0x7b, 0xbc, 0x9d, 0x99, 0xa9, 0x00 },
{ 0x49, 0xa7, 0x01, 0x6a, 0xc6, 0x53, 0xf6, 0x5e, 0xcd, 0xc9, 0x07, 0x60 },
{ 0x19, 0x44, 0xd0, 0x85, 0xbe, 0x4e, 0x7d, 0xa8, 0xd6, 0xcc, 0x7d, 0x00 },
{ 0x25, 0x1f, 0x62, 0xad, 0xc4, 0x03, 0x2f, 0x0e, 0xe7, 0x14, 0x00, 0x20 },
{ 0x56, 0x47, 0x1f, 0x87, 0x02, 0xa0, 0x72, 0x1e, 0x00, 0xb1, 0x2b, 0x80 },
{ 0x2b, 0x8e, 0x49, 0x23, 0xf2, 0xdd, 0x51, 0xe2, 0xd5, 0x37, 0xfa, 0x00 },
{ 0x6b, 0x55, 0x0a, 0x40, 0xa6, 0x6f, 0x47, 0x55, 0xde, 0x95, 0xc2, 0x60 },
{ 0xa1, 0x8a, 0xd2, 0x8d, 0x4e, 0x27, 0xfe, 0x92, 0xa4, 0xf6, 0xc8, 0x40 },
{ 0x10, 0xc2, 0xe5, 0x86, 0x38, 0x8c, 0xb8, 0x2a, 0x3d, 0x80, 0x75, 0x80 },
{ 0xef, 0x34, 0xa4, 0x18, 0x17, 0xee, 0x02, 0x13, 0x3d, 0xb2, 0xeb, 0x00 },
{ 0x7e, 0x9c, 0x0c, 0x54, 0x32, 0x5a, 0x9c, 0x15, 0x83, 0x6e, 0x00, 0x00 },
{ 0x36, 0x93, 0xe5, 0x72, 0xd1, 0xfd, 0xe4, 0xcd, 0xf0, 0x79, 0xe8, 0x60 },
{ 0xbf, 0xb2, 0xce, 0xc5, 0xab, 0xe1, 0xb0, 0xc7, 0x2e, 0x07, 0xfb, 0xe0 },
{ 0x7e, 0xe1, 0x82, 0x30, 0xc5, 0x83, 0xcc, 0xcc, 0x57, 0xd4, 0xb0, 0x80 },
{ 0xa0, 0x66, 0xcb, 0x2f, 0xed, 0xaf, 0xc9, 0xf5, 0x26, 0x64, 0x12, 0x60 },
{ 0xbb, 0x23, 0x72, 0x5a, 0xbc, 0x47, 0xcc, 0x5f, 0x4c, 0xc4, 0xcd, 0x20 },
{ 0xde, 0xd9, 0xdb, 0xa3, 0xbe, 0xe4, 0x0c, 0x59, 0xb5, 0x60, 0x9b, 0x40 },
{ 0xd9, 0xa7, 0x01, 0x6a, 0xc6, 0x53, 0xe6, 0xde, 0xcd, 0xc9, 0x03, 0x60 },
{ 0x9a, 0xd4, 0x6a, 0xed, 0x5f, 0x70, 0x7f, 0x28, 0x0a, 0xb5, 0xfc, 0x40 },
{ 0xe5, 0x92, 0x1c, 0x77, 0x82, 0x25, 0x87, 0x31, 0x6d, 0x7d, 0x3c, 0x20 },
{ 0x4f, 0x14, 0xda, 0x82, 0x42, 0xa8, 0xb8, 0x6d, 0xca, 0x73, 0x35, 0x20 },
{ 0x8b, 0x8b, 0x50, 0x7a, 0xd4, 0x67, 0xd4, 0x44, 0x1d, 0xf7, 0x70, 0xe0 },
{ 0x22, 0x83, 0x1c, 0x9c, 0xf1, 0x16, 0x94, 0x67, 0xad, 0x04, 0xb6, 0x80 },
{ 0x21, 0x3b, 0x83, 0x8f, 0xe2, 0xae, 0x54, 0xc3, 0x8e, 0xe7, 0x18, 0x00 },
{ 0x5d, 0x92, 0x6b, 0x6d, 0xd7, 0x1f, 0x08, 0x51, 0x81, 0xa4, 0xe1, 0x20 },
{ 0x66, 0xab, 0x79, 0xd4, 0xb2, 0x9e, 0xe6, 0xe6, 0x95, 0x09, 0xe5, 0x60 },
{ 0x95, 0x81, 0x48, 0x68, 0x2d, 0x74, 0x8a, 0x38, 0xdd, 0x68, 0xba, 0xa0 },
{ 0xb8, 0xce, 0x02, 0x0c, 0xf0, 0x69, 0xc3, 0x2a, 0x72, 0x3a, 0xb1, 0x40 },
{ 0xf4, 0x33, 0x1d, 0x6d, 0x46, 0x16, 0x07, 0xe9, 0x57, 0x52, 0x74, 0x60 },
{ 0x6d, 0xa2, 0x3b, 0xa4, 0x24, 0xb9, 0x59, 0x61, 0x33, 0xcf, 0x9c, 0x80 },
{ 0xa6, 0x36, 0xbc, 0xbc, 0x7b, 0x30, 0xc5, 0xfb, 0xea, 0xe6, 0x7f, 0xe0 },
{ 0x5c, 0xb0, 0xd8, 0x6a, 0x07, 0xdf, 0x65, 0x4a, 0x90, 0x89, 0xa2, 0x00 },
{ 0xf1, 0x1f, 0x10, 0x68, 0x48, 0x78, 0x0f, 0xc9, 0xec, 0xdd, 0x80, 0xa0 },
{ 0x1f, 0xbb, 0x53, 0x64, 0xfb, 0x8d, 0x2c, 0x9d, 0x73, 0x0d, 0x5b, 0xa0 },
{ 0xfc, 0xb8, 0x6b, 0xc7, 0x0a, 0x50, 0xc9, 0xd0, 0x2a, 0x5d, 0x03, 0x40 },
{ 0xa5, 0x34, 0x43, 0x30, 0x29, 0xea, 0xc1, 0x5f, 0x32, 0x2e, 0x34, 0xc0 },
{ 0xc9, 0x89, 0xd9, 0xc7, 0xc3, 0xd3, 0xb8, 0xc5, 0x5d, 0x75, 0x13, 0x00 },
{ 0x7b, 0xb3, 0x8b, 0x2f, 0x01, 0x86, 0xd4, 0x66, 0x43, 0xae, 0x96, 0x20 },
{ 0x26, 0x44, 0xeb, 0xad, 0xeb, 0x44, 0xb9, 0x46, 0x7d, 0x1f, 0x42, 0xc0 },
{ 0x60, 0x8c, 0xc8, 0x57, 0x59, 0x4b, 0xfb, 0xb5, 0x5d, 0x69, 0x60, 0x00 }
};
// Each row describes one LDPC parity check.
// Each number is an index into the codeword (1-origin).
// The codeword bits mentioned in each row must XOR to zero.
const uint8_t kFTX_LDPC_Nm[FTX_LDPC_M][7] = {
{ 4, 31, 59, 91, 92, 96, 153 },
{ 5, 32, 60, 93, 115, 146, 0 },
{ 6, 24, 61, 94, 122, 151, 0 },
{ 7, 33, 62, 95, 96, 143, 0 },
{ 8, 25, 63, 83, 93, 96, 148 },
{ 6, 32, 64, 97, 126, 138, 0 },
{ 5, 34, 65, 78, 98, 107, 154 },
{ 9, 35, 66, 99, 139, 146, 0 },
{ 10, 36, 67, 100, 107, 126, 0 },
{ 11, 37, 67, 87, 101, 139, 158 },
{ 12, 38, 68, 102, 105, 155, 0 },
{ 13, 39, 69, 103, 149, 162, 0 },
{ 8, 40, 70, 82, 104, 114, 145 },
{ 14, 41, 71, 88, 102, 123, 156 },
{ 15, 42, 59, 106, 123, 159, 0 },
{ 1, 33, 72, 106, 107, 157, 0 },
{ 16, 43, 73, 108, 141, 160, 0 },
{ 17, 37, 74, 81, 109, 131, 154 },
{ 11, 44, 75, 110, 121, 166, 0 },
{ 45, 55, 64, 111, 130, 161, 173 },
{ 8, 46, 71, 112, 119, 166, 0 },
{ 18, 36, 76, 89, 113, 114, 143 },
{ 19, 38, 77, 104, 116, 163, 0 },
{ 20, 47, 70, 92, 138, 165, 0 },
{ 2, 48, 74, 113, 128, 160, 0 },
{ 21, 45, 78, 83, 117, 121, 151 },
{ 22, 47, 58, 118, 127, 164, 0 },
{ 16, 39, 62, 112, 134, 158, 0 },
{ 23, 43, 79, 120, 131, 145, 0 },
{ 19, 35, 59, 73, 110, 125, 161 },
{ 20, 36, 63, 94, 136, 161, 0 },
{ 14, 31, 79, 98, 132, 164, 0 },
{ 3, 44, 80, 124, 127, 169, 0 },
{ 19, 46, 81, 117, 135, 167, 0 },
{ 7, 49, 58, 90, 100, 105, 168 },
{ 12, 50, 61, 118, 119, 144, 0 },
{ 13, 51, 64, 114, 118, 157, 0 },
{ 24, 52, 76, 129, 148, 149, 0 },
{ 25, 53, 69, 90, 101, 130, 156 },
{ 20, 46, 65, 80, 120, 140, 170 },
{ 21, 54, 77, 100, 140, 171, 0 },
{ 35, 82, 133, 142, 171, 174, 0 },
{ 14, 30, 83, 113, 125, 170, 0 },
{ 4, 29, 68, 120, 134, 173, 0 },
{ 1, 4, 52, 57, 86, 136, 152 },
{ 26, 51, 56, 91, 122, 137, 168 },
{ 52, 84, 110, 115, 145, 168, 0 },
{ 7, 50, 81, 99, 132, 173, 0 },
{ 23, 55, 67, 95, 172, 174, 0 },
{ 26, 41, 77, 109, 141, 148, 0 },
{ 2, 27, 41, 61, 62, 115, 133 },
{ 27, 40, 56, 124, 125, 126, 0 },
{ 18, 49, 55, 124, 141, 167, 0 },
{ 6, 33, 85, 108, 116, 156, 0 },
{ 28, 48, 70, 85, 105, 129, 158 },
{ 9, 54, 63, 131, 147, 155, 0 },
{ 22, 53, 68, 109, 121, 174, 0 },
{ 3, 13, 48, 78, 95, 123, 0 },
{ 31, 69, 133, 150, 155, 169, 0 },
{ 12, 43, 66, 89, 97, 135, 159 },
{ 5, 39, 75, 102, 136, 167, 0 },
{ 2, 54, 86, 101, 135, 164, 0 },
{ 15, 56, 87, 108, 119, 171, 0 },
{ 10, 44, 82, 91, 111, 144, 149 },
{ 23, 34, 71, 94, 127, 153, 0 },
{ 11, 49, 88, 92, 142, 157, 0 },
{ 29, 34, 87, 97, 147, 162, 0 },
{ 30, 50, 60, 86, 137, 142, 162 },
{ 10, 53, 66, 84, 112, 128, 165 },
{ 22, 57, 85, 93, 140, 159, 0 },
{ 28, 32, 72, 103, 132, 166, 0 },
{ 28, 29, 84, 88, 117, 143, 150 },
{ 1, 26, 45, 80, 128, 147, 0 },
{ 17, 27, 89, 103, 116, 153, 0 },
{ 51, 57, 98, 163, 165, 172, 0 },
{ 21, 37, 73, 138, 152, 169, 0 },
{ 16, 47, 76, 130, 137, 154, 0 },
{ 3, 24, 30, 72, 104, 139, 0 },
{ 9, 40, 90, 106, 134, 151, 0 },
{ 15, 58, 60, 74, 111, 150, 163 },
{ 18, 42, 79, 144, 146, 152, 0 },
{ 25, 38, 65, 99, 122, 160, 0 },
{ 17, 42, 75, 129, 170, 172, 0 }
};
// Each row corresponds to a codeword bit.
// The numbers indicate which three LDPC parity checks (rows in Nm) refer to the codeword bit.
// 1-origin.
const uint8_t kFTX_LDPC_Mn[FTX_LDPC_N][3] = {
{ 16, 45, 73 },
{ 25, 51, 62 },
{ 33, 58, 78 },
{ 1, 44, 45 },
{ 2, 7, 61 },
{ 3, 6, 54 },
{ 4, 35, 48 },
{ 5, 13, 21 },
{ 8, 56, 79 },
{ 9, 64, 69 },
{ 10, 19, 66 },
{ 11, 36, 60 },
{ 12, 37, 58 },
{ 14, 32, 43 },
{ 15, 63, 80 },
{ 17, 28, 77 },
{ 18, 74, 83 },
{ 22, 53, 81 },
{ 23, 30, 34 },
{ 24, 31, 40 },
{ 26, 41, 76 },
{ 27, 57, 70 },
{ 29, 49, 65 },
{ 3, 38, 78 },
{ 5, 39, 82 },
{ 46, 50, 73 },
{ 51, 52, 74 },
{ 55, 71, 72 },
{ 44, 67, 72 },
{ 43, 68, 78 },
{ 1, 32, 59 },
{ 2, 6, 71 },
{ 4, 16, 54 },
{ 7, 65, 67 },
{ 8, 30, 42 },
{ 9, 22, 31 },
{ 10, 18, 76 },
{ 11, 23, 82 },
{ 12, 28, 61 },
{ 13, 52, 79 },
{ 14, 50, 51 },
{ 15, 81, 83 },
{ 17, 29, 60 },
{ 19, 33, 64 },
{ 20, 26, 73 },
{ 21, 34, 40 },
{ 24, 27, 77 },
{ 25, 55, 58 },
{ 35, 53, 66 },
{ 36, 48, 68 },
{ 37, 46, 75 },
{ 38, 45, 47 },
{ 39, 57, 69 },
{ 41, 56, 62 },
{ 20, 49, 53 },
{ 46, 52, 63 },
{ 45, 70, 75 },
{ 27, 35, 80 },
{ 1, 15, 30 },
{ 2, 68, 80 },
{ 3, 36, 51 },
{ 4, 28, 51 },
{ 5, 31, 56 },
{ 6, 20, 37 },
{ 7, 40, 82 },
{ 8, 60, 69 },
{ 9, 10, 49 },
{ 11, 44, 57 },
{ 12, 39, 59 },
{ 13, 24, 55 },
{ 14, 21, 65 },
{ 16, 71, 78 },
{ 17, 30, 76 },
{ 18, 25, 80 },
{ 19, 61, 83 },
{ 22, 38, 77 },
{ 23, 41, 50 },
{ 7, 26, 58 },
{ 29, 32, 81 },
{ 33, 40, 73 },
{ 18, 34, 48 },
{ 13, 42, 64 },
{ 5, 26, 43 },
{ 47, 69, 72 },
{ 54, 55, 70 },
{ 45, 62, 68 },
{ 10, 63, 67 },
{ 14, 66, 72 },
{ 22, 60, 74 },
{ 35, 39, 79 },
{ 1, 46, 64 },
{ 1, 24, 66 },
{ 2, 5, 70 },
{ 3, 31, 65 },
{ 4, 49, 58 },
{ 1, 4, 5 },
{ 6, 60, 67 },
{ 7, 32, 75 },
{ 8, 48, 82 },
{ 9, 35, 41 },
{ 10, 39, 62 },
{ 11, 14, 61 },
{ 12, 71, 74 },
{ 13, 23, 78 },
{ 11, 35, 55 },
{ 15, 16, 79 },
{ 7, 9, 16 },
{ 17, 54, 63 },
{ 18, 50, 57 },
{ 19, 30, 47 },
{ 20, 64, 80 },
{ 21, 28, 69 },
{ 22, 25, 43 },
{ 13, 22, 37 },
{ 2, 47, 51 },
{ 23, 54, 74 },
{ 26, 34, 72 },
{ 27, 36, 37 },
{ 21, 36, 63 },
{ 29, 40, 44 },
{ 19, 26, 57 },
{ 3, 46, 82 },
{ 14, 15, 58 },
{ 33, 52, 53 },
{ 30, 43, 52 },
{ 6, 9, 52 },
{ 27, 33, 65 },
{ 25, 69, 73 },
{ 38, 55, 83 },
{ 20, 39, 77 },
{ 18, 29, 56 },
{ 32, 48, 71 },
{ 42, 51, 59 },
{ 28, 44, 79 },
{ 34, 60, 62 },
{ 31, 45, 61 },
{ 46, 68, 77 },
{ 6, 24, 76 },
{ 8, 10, 78 },
{ 40, 41, 70 },
{ 17, 50, 53 },
{ 42, 66, 68 },
{ 4, 22, 72 },
{ 36, 64, 81 },
{ 13, 29, 47 },
{ 2, 8, 81 },
{ 56, 67, 73 },
{ 5, 38, 50 },
{ 12, 38, 64 },
{ 59, 72, 80 },
{ 3, 26, 79 },
{ 45, 76, 81 },
{ 1, 65, 74 },
{ 7, 18, 77 },
{ 11, 56, 59 },
{ 14, 39, 54 },
{ 16, 37, 66 },
{ 10, 28, 55 },
{ 15, 60, 70 },
{ 17, 25, 82 },
{ 20, 30, 31 },
{ 12, 67, 68 },
{ 23, 75, 80 },
{ 27, 32, 62 },
{ 24, 69, 75 },
{ 19, 21, 71 },
{ 34, 53, 61 },
{ 35, 46, 47 },
{ 33, 59, 76 },
{ 40, 43, 83 },
{ 41, 42, 63 },
{ 49, 75, 83 },
{ 20, 44, 48 },
{ 42, 49, 57 }
};
const uint8_t kFTX_LDPC_Num_rows[FTX_LDPC_M] = {
7, 6, 6, 6, 7, 6, 7, 6, 6, 7, 6, 6, 7, 7, 6, 6,
6, 7, 6, 7, 6, 7, 6, 6, 6, 7, 6, 6, 6, 7, 6, 6,
6, 6, 7, 6, 6, 6, 7, 7, 6, 6, 6, 6, 7, 7, 6, 6,
6, 6, 7, 6, 6, 6, 7, 6, 6, 6, 6, 7, 6, 6, 6, 7,
6, 6, 6, 7, 7, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 7,
6, 6, 6
};
-121
View File
@@ -1,121 +0,0 @@
#ifndef _INCLUDE_CONSTANTS_H_
#define _INCLUDE_CONSTANTS_H_
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
#define FT8_SYMBOL_PERIOD (0.160f) ///< FT8 symbol duration, defines tone deviation in Hz and symbol rate
#define FT8_SLOT_TIME (15.0f) ///< FT8 slot period
#define FT4_SYMBOL_PERIOD (0.048f) ///< FT4 symbol duration, defines tone deviation in Hz and symbol rate
#define FT4_SLOT_TIME (7.5f) ///< FT4 slot period
#define FT2_SYMBOL_PERIOD (0.024f) ///< FT2 symbol duration (288 samples @ 12 kHz)
#define FT2_SLOT_TIME (3.75f) ///< FT2 slot period
// Define FT8 symbol counts
// FT8 message structure:
// S D1 S D2 S
// S - sync block (7 symbols of Costas pattern)
// D1 - first data block (29 symbols each encoding 3 bits)
#define FT8_ND (58) ///< Data symbols
#define FT8_NN (79) ///< Total channel symbols (FT8_NS + FT8_ND)
#define FT8_LENGTH_SYNC (7) ///< Length of each sync group
#define FT8_NUM_SYNC (3) ///< Number of sync groups
#define FT8_SYNC_OFFSET (36) ///< Offset between sync groups
// Define FT4 symbol counts
// FT4 message structure:
// R Sa D1 Sb D2 Sc D3 Sd R
// R - ramping symbol (no payload information conveyed)
// Sx - one of four _different_ sync blocks (4 symbols of Costas pattern)
// Dy - data block (29 symbols each encoding 2 bits)
#define FT4_ND (87) ///< Data symbols
#define FT4_NR (2) ///< Ramp symbols (beginning + end)
#define FT4_NN (105) ///< Total channel symbols (FT4_NS + FT4_ND + FT4_NR)
#define FT4_LENGTH_SYNC (4) ///< Length of each sync group
#define FT4_NUM_SYNC (4) ///< Number of sync groups
#define FT4_SYNC_OFFSET (33) ///< Offset between sync groups
// FT2 reuses the FT4 channel structure with a shorter slot and symbol period.
#define FT2_ND FT4_ND
#define FT2_NR FT4_NR
#define FT2_NN FT4_NN
#define FT2_LENGTH_SYNC FT4_LENGTH_SYNC
#define FT2_NUM_SYNC FT4_NUM_SYNC
#define FT2_SYNC_OFFSET FT4_SYNC_OFFSET
// Define LDPC parameters
#define FTX_LDPC_N (174) ///< Number of bits in the encoded message (payload with LDPC checksum bits)
#define FTX_LDPC_K (91) ///< Number of payload bits (including CRC)
#define FTX_LDPC_M (83) ///< Number of LDPC checksum bits (FTX_LDPC_N - FTX_LDPC_K)
#define FTX_LDPC_N_BYTES ((FTX_LDPC_N + 7) / 8) ///< Number of whole bytes needed to store 174 bits (full message)
#define FTX_LDPC_K_BYTES ((FTX_LDPC_K + 7) / 8) ///< Number of whole bytes needed to store 91 bits (payload + CRC only)
// Define CRC parameters
#define FT8_CRC_POLYNOMIAL ((uint16_t)0x2757u) ///< CRC-14 polynomial without the leading (MSB) 1
#define FT8_CRC_WIDTH (14)
typedef enum
{
FTX_PROTOCOL_FT4,
FTX_PROTOCOL_FT8,
FTX_PROTOCOL_FT2
} ftx_protocol_t;
static inline float ftx_protocol_symbol_period(ftx_protocol_t protocol)
{
return (protocol == FTX_PROTOCOL_FT8)
? FT8_SYMBOL_PERIOD
: ((protocol == FTX_PROTOCOL_FT2) ? FT2_SYMBOL_PERIOD : FT4_SYMBOL_PERIOD);
}
static inline float ftx_protocol_slot_time(ftx_protocol_t protocol)
{
return (protocol == FTX_PROTOCOL_FT8)
? FT8_SLOT_TIME
: ((protocol == FTX_PROTOCOL_FT2) ? FT2_SLOT_TIME : FT4_SLOT_TIME);
}
static inline int ftx_protocol_uses_ft4_layout(ftx_protocol_t protocol)
{
return (protocol == FTX_PROTOCOL_FT4) || (protocol == FTX_PROTOCOL_FT2);
}
/// Costas 7x7 tone pattern for synchronization
extern const uint8_t kFT8_Costas_pattern[7];
extern const uint8_t kFT4_Costas_pattern[4][4];
/// Gray code map to encode 8 symbols (tones)
extern const uint8_t kFT8_Gray_map[8];
extern const uint8_t kFT4_Gray_map[4];
extern const uint8_t kFT4_XOR_sequence[10];
/// Parity generator matrix for (174,91) LDPC code, stored in bitpacked format (MSB first)
extern const uint8_t kFTX_LDPC_generator[FTX_LDPC_M][FTX_LDPC_K_BYTES];
/// LDPC(174,91) parity check matrix, containing 83 rows,
/// each row describes one parity check,
/// each number is an index into the codeword (1-origin).
/// The codeword bits mentioned in each row must xor to zero.
/// From WSJT-X's ldpc_174_91_c_reordered_parity.f90.
extern const uint8_t kFTX_LDPC_Nm[FTX_LDPC_M][7];
/// Mn from WSJT-X's bpdecode174.f90. Each row corresponds to a codeword bit.
/// The numbers indicate which three parity checks (rows in Nm) refer to the codeword bit.
/// The numbers use 1 as the origin (first entry).
extern const uint8_t kFTX_LDPC_Mn[FTX_LDPC_N][3];
/// Number of rows (columns in C/C++) in the array Nm.
extern const uint8_t kFTX_LDPC_Num_rows[FTX_LDPC_M];
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_CONSTANTS_H_
-63
View File
@@ -1,63 +0,0 @@
#include "crc.h"
#include "constants.h"
#define TOPBIT (1u << (FT8_CRC_WIDTH - 1))
// Compute 14-bit CRC for a sequence of given number of bits
// Adapted from https://barrgroup.com/Embedded-Systems/How-To/CRC-Calculation-C-Code
// [IN] message - byte sequence (MSB first)
// [IN] num_bits - number of bits in the sequence
uint16_t ftx_compute_crc(const uint8_t message[], int num_bits)
{
uint16_t remainder = 0;
int idx_byte = 0;
// Perform modulo-2 division, a bit at a time.
for (int idx_bit = 0; idx_bit < num_bits; ++idx_bit)
{
if (idx_bit % 8 == 0)
{
// Bring the next byte into the remainder.
remainder ^= (message[idx_byte] << (FT8_CRC_WIDTH - 8));
++idx_byte;
}
// Try to divide the current data bit.
if (remainder & TOPBIT)
{
remainder = (remainder << 1) ^ FT8_CRC_POLYNOMIAL;
}
else
{
remainder = (remainder << 1);
}
}
return remainder & ((TOPBIT << 1) - 1u);
}
uint16_t ftx_extract_crc(const uint8_t a91[])
{
uint16_t chksum = ((a91[9] & 0x07) << 11) | (a91[10] << 3) | (a91[11] >> 5);
return chksum;
}
void ftx_add_crc(const uint8_t payload[], uint8_t a91[])
{
// Copy 77 bits of payload data
for (int i = 0; i < 10; i++)
a91[i] = payload[i];
// Clear 3 bits after the payload to make 82 bits
a91[9] &= 0xF8u;
a91[10] = 0;
// Calculate CRC of 82 bits (77 + 5 zeros)
// 'The CRC is calculated on the source-encoded message, zero-extended from 77 to 82 bits'
uint16_t checksum = ftx_compute_crc(a91, 96 - 14);
// Store the CRC at the end of 77 bit message
a91[9] |= (uint8_t)(checksum >> 11);
a91[10] = (uint8_t)(checksum >> 3);
a91[11] = (uint8_t)(checksum << 5);
}
-31
View File
@@ -1,31 +0,0 @@
#ifndef _INCLUDE_CRC_H_
#define _INCLUDE_CRC_H_
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C"
{
#endif
// Compute 14-bit CRC for a sequence of given number of bits using FT8/FT4 CRC polynomial
// [IN] message - byte sequence (MSB first)
// [IN] num_bits - number of bits in the sequence
uint16_t ftx_compute_crc(const uint8_t message[], int num_bits);
/// Extract the FT8/FT4 CRC of a packed message (during decoding)
/// @param[in] a91 77 bits of payload data + CRC
/// @return Extracted CRC
uint16_t ftx_extract_crc(const uint8_t a91[]);
/// Add FT8/FT4 CRC to a packed message (during encoding)
/// @param[in] payload 77 bits of payload data
/// @param[out] a91 91 bits of payload data + CRC
void ftx_add_crc(const uint8_t payload[], uint8_t a91[]);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_CRC_H_
-22
View File
@@ -1,22 +0,0 @@
#ifndef _DEBUG_H_INCLUDED_
#define _DEBUG_H_INCLUDED_
#define LOG_DEBUG 0
#define LOG_INFO 1
#define LOG_WARN 2
#define LOG_ERROR 3
#define LOG_FATAL 4
#ifdef LOG_LEVEL
#ifndef LOG_PRINTF
#include <stdio.h>
#define LOG_PRINTF(...) fprintf(stderr, __VA_ARGS__)
#endif
#define LOG(level, ...) \
if (level >= LOG_LEVEL) \
LOG_PRINTF(__VA_ARGS__)
#else // ifdef LOG_LEVEL
#define LOG(level, ...)
#endif
#endif // _DEBUG_H_INCLUDED_
-773
View File
@@ -1,773 +0,0 @@
#include "decode.h"
#include "constants.h"
#include "crc.h"
#include "ldpc.h"
#include <stdbool.h>
#include <math.h>
#include <complex.h>
// #define LOG_LEVEL LOG_DEBUG
// #include "debug.h"
// Lookup table for y = 10*log10(1 + 10^(x/10)), where
// y - increase in signal level dB when adding a weaker independent signal
// x - specific relative strength of the weaker signal in dB
// Table index corresponds to x in dB (index 0: 0 dB, index 1: -1 dB etc)
static const float db_power_sum[40] = {
3.01029995663981f, 2.53901891043867f, 2.1244260279434f, 1.76434862436485f, 1.45540463109294f,
1.19331048066095f, 0.973227937086954f, 0.790097496525665f, 0.638920341433796f, 0.514969420252302f,
0.413926851582251f, 0.331956199884278f, 0.265723755961025f, 0.212384019142551f, 0.16954289279533f,
0.135209221080382f, 0.10774225511957f, 0.085799992300358f, 0.06829128312453f, 0.054333142200458f,
0.043213737826426f, 0.034360947517284f, 0.027316043349389f, 0.021711921641451f, 0.017255250287928f,
0.013711928326833f, 0.010895305999614f, 0.008656680827934f, 0.006877654943187f, 0.005464004928574f,
0.004340774793186f, 0.003448354310253f, 0.002739348814965f, 0.002176083232619f, 0.001728613409904f,
0.001373142636584f, 0.001090761428665f, 0.000866444976964f, 0.000688255828734f, 0.000546709946839f
};
/// Compute log likelihood log(p(1) / p(0)) of 174 message bits for later use in soft-decision LDPC decoding
/// @param[in] wf Waterfall data collected during message slot
/// @param[in] cand Candidate to extract the message from
/// @param[in] code_map Symbol encoding map
/// @param[out] log174 Output of decoded log likelihoods for each of the 174 message bits
static void ft4_extract_likelihood(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, float* log174);
static void ft2_extract_likelihood(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, float* log174);
static void ft8_extract_likelihood(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, float* log174);
/// Packs a string of bits each represented as a zero/non-zero byte in bit_array[],
/// as a string of packed bits starting from the MSB of the first byte of packed[]
/// @param[in] plain Array of bits (0 and nonzero values) with num_bits entires
/// @param[in] num_bits Number of bits (entries) passed in bit_array
/// @param[out] packed Byte-packed bits representing the data in bit_array
static void pack_bits(const uint8_t bit_array[], int num_bits, uint8_t packed[]);
static float max2(float a, float b);
static float max4(float a, float b, float c, float d);
static void heapify_down(ftx_candidate_t heap[], int heap_size);
static void heapify_up(ftx_candidate_t heap[], int heap_size);
static void ftx_normalize_logl(float* log174);
static void ft4_extract_symbol(const WF_ELEM_T* wf, float* logl);
static void ft2_extract_logl_sequence(const float complex symbols[4][FT2_NN - FT2_NR], int start_sym, int n_syms, float* metrics);
static void ft8_extract_symbol(const WF_ELEM_T* wf, float* logl);
static void ft8_decode_multi_symbols(const WF_ELEM_T* wf, int num_bins, int n_syms, int bit_idx, float* log174);
static inline float complex wf_elem_to_complex(const WF_ELEM_T elem)
{
float mag = WF_ELEM_MAG(elem);
float amplitude = powf(10.0f, mag / 20.0f);
return amplitude * cexpf(I * elem.phase);
}
static const WF_ELEM_T* get_cand_mag(const ftx_waterfall_t* wf, const ftx_candidate_t* candidate)
{
int offset = candidate->time_offset;
offset = (offset * wf->time_osr) + candidate->time_sub;
offset = (offset * wf->freq_osr) + candidate->freq_sub;
offset = (offset * wf->num_bins) + candidate->freq_offset;
return wf->mag + offset;
}
static int ft8_sync_score(const ftx_waterfall_t* wf, const ftx_candidate_t* candidate)
{
int score = 0;
int num_average = 0;
// Get the pointer to symbol 0 of the candidate
const WF_ELEM_T* mag_cand = get_cand_mag(wf, candidate);
// Compute average score over sync symbols (m+k = 0-7, 36-43, 72-79)
for (int m = 0; m < FT8_NUM_SYNC; ++m)
{
for (int k = 0; k < FT8_LENGTH_SYNC; ++k)
{
int block = (FT8_SYNC_OFFSET * m) + k; // relative to the message
int block_abs = candidate->time_offset + block; // relative to the captured signal
// Check for time boundaries
if (block_abs < 0)
continue;
if (block_abs >= wf->num_blocks)
break;
// Get the pointer to symbol 'block' of the candidate
const WF_ELEM_T* p8 = mag_cand + (block * wf->block_stride);
// Weighted difference between the expected and all other symbols
// Does not work as well as the alternative score below
// score += 8 * p8[kFT8_Costas_pattern[k]] -
// p8[0] - p8[1] - p8[2] - p8[3] -
// p8[4] - p8[5] - p8[6] - p8[7];
// ++num_average;
// Check only the neighbors of the expected symbol frequency- and time-wise
int sm = kFT8_Costas_pattern[k]; // Index of the expected bin
if (sm > 0)
{
// look at one frequency bin lower
score += WF_ELEM_MAG_INT(p8[sm]) - WF_ELEM_MAG_INT(p8[sm - 1]);
++num_average;
}
if (sm < 7)
{
// look at one frequency bin higher
score += WF_ELEM_MAG_INT(p8[sm]) - WF_ELEM_MAG_INT(p8[sm + 1]);
++num_average;
}
if ((k > 0) && (block_abs > 0))
{
// look one symbol back in time
score += WF_ELEM_MAG_INT(p8[sm]) - WF_ELEM_MAG_INT(p8[sm - wf->block_stride]);
++num_average;
}
if (((k + 1) < FT8_LENGTH_SYNC) && ((block_abs + 1) < wf->num_blocks))
{
// look one symbol forward in time
score += WF_ELEM_MAG_INT(p8[sm]) - WF_ELEM_MAG_INT(p8[sm + wf->block_stride]);
++num_average;
}
}
}
if (num_average > 0)
score /= num_average;
return score;
}
static int ft2_sync_score(const ftx_waterfall_t* wf, const ftx_candidate_t* candidate)
{
const WF_ELEM_T* mag_cand = get_cand_mag(wf, candidate);
float score = 0.0f;
int groups = 0;
for (int m = 0; m < FT2_NUM_SYNC; ++m)
{
float complex sum = 0.0f;
bool complete = true;
for (int k = 0; k < FT2_LENGTH_SYNC; ++k)
{
int block = 1 + (FT2_SYNC_OFFSET * m) + k;
int block_abs = candidate->time_offset + block;
if ((block_abs < 0) || (block_abs >= wf->num_blocks))
{
complete = false;
break;
}
const WF_ELEM_T* sym = mag_cand + (block * wf->block_stride);
int tone = kFT4_Costas_pattern[m][k];
sum += wf_elem_to_complex(sym[tone]);
}
if (!complete)
continue;
score += cabsf(sum);
++groups;
}
if (groups == 0)
return 0;
return (int)lroundf((score / groups) * 8.0f);
}
static int ft4_sync_score(const ftx_waterfall_t* wf, const ftx_candidate_t* candidate)
{
int score = 0;
int num_average = 0;
// Get the pointer to symbol 0 of the candidate
const WF_ELEM_T* mag_cand = get_cand_mag(wf, candidate);
// Compute average score over sync symbols (block = 1-4, 34-37, 67-70, 100-103)
for (int m = 0; m < FT4_NUM_SYNC; ++m)
{
for (int k = 0; k < FT4_LENGTH_SYNC; ++k)
{
int block = 1 + (FT4_SYNC_OFFSET * m) + k;
int block_abs = candidate->time_offset + block;
// Check for time boundaries
if (block_abs < 0)
continue;
if (block_abs >= wf->num_blocks)
break;
// Get the pointer to symbol 'block' of the candidate
const WF_ELEM_T* p4 = mag_cand + (block * wf->block_stride);
int sm = kFT4_Costas_pattern[m][k]; // Index of the expected bin
// score += (4 * p4[sm]) - p4[0] - p4[1] - p4[2] - p4[3];
// num_average += 4;
// Check only the neighbors of the expected symbol frequency- and time-wise
if (sm > 0)
{
// look at one frequency bin lower
score += WF_ELEM_MAG_INT(p4[sm]) - WF_ELEM_MAG_INT(p4[sm - 1]);
++num_average;
}
if (sm < 3)
{
// look at one frequency bin higher
score += WF_ELEM_MAG_INT(p4[sm]) - WF_ELEM_MAG_INT(p4[sm + 1]);
++num_average;
}
if ((k > 0) && (block_abs > 0))
{
// look one symbol back in time
score += WF_ELEM_MAG_INT(p4[sm]) - WF_ELEM_MAG_INT(p4[sm - wf->block_stride]);
++num_average;
}
if (((k + 1) < FT4_LENGTH_SYNC) && ((block_abs + 1) < wf->num_blocks))
{
// look one symbol forward in time
score += WF_ELEM_MAG_INT(p4[sm]) - WF_ELEM_MAG_INT(p4[sm + wf->block_stride]);
++num_average;
}
}
}
if (num_average > 0)
score /= num_average;
return score;
}
int ftx_find_candidates(const ftx_waterfall_t* wf, int num_candidates, ftx_candidate_t heap[], int min_score)
{
bool is_ft2 = (wf->protocol == FTX_PROTOCOL_FT2);
int (*sync_fun)(const ftx_waterfall_t*, const ftx_candidate_t*) =
is_ft2 ? ft2_sync_score : (ftx_protocol_uses_ft4_layout(wf->protocol) ? ft4_sync_score : ft8_sync_score);
int num_tones = ftx_protocol_uses_ft4_layout(wf->protocol) ? 4 : 8;
int time_offset_min = -10;
int time_offset_max = 20;
if (is_ft2)
{
time_offset_min = -2;
time_offset_max = wf->num_blocks - FT2_NN + 2;
if (time_offset_max <= time_offset_min)
{
time_offset_max = time_offset_min + 1;
}
}
else if (wf->protocol == FTX_PROTOCOL_FT4)
{
// Keep roughly the same +/- seconds search span used by FT8.
// FT4 symbols are much shorter, so it needs a wider symbol-index window.
time_offset_min = -34;
time_offset_max = wf->num_blocks - FT4_NN + 34;
if (time_offset_max <= time_offset_min)
{
time_offset_max = time_offset_min + 1;
}
}
int heap_size = 0;
ftx_candidate_t candidate;
// Here we allow time offsets that exceed signal boundaries, as long as we still have all data bits.
// I.e. we can afford to skip the first 7 or the last 7 Costas symbols, as long as we track how many
// sync symbols we included in the score, so the score is averaged.
for (candidate.time_sub = 0; candidate.time_sub < wf->time_osr; ++candidate.time_sub)
{
for (candidate.freq_sub = 0; candidate.freq_sub < wf->freq_osr; ++candidate.freq_sub)
{
for (candidate.time_offset = time_offset_min; candidate.time_offset < time_offset_max; ++candidate.time_offset)
{
for (candidate.freq_offset = 0; (candidate.freq_offset + num_tones - 1) < wf->num_bins; ++candidate.freq_offset)
{
candidate.score = sync_fun(wf, &candidate);
if (candidate.score < min_score)
continue;
// If the heap is full AND the current candidate is better than
// the worst in the heap, we remove the worst and make space
if ((heap_size == num_candidates) && (candidate.score > heap[0].score))
{
--heap_size;
heap[0] = heap[heap_size];
heapify_down(heap, heap_size);
}
// If there's free space in the heap, we add the current candidate
if (heap_size < num_candidates)
{
heap[heap_size] = candidate;
++heap_size;
heapify_up(heap, heap_size);
}
}
}
}
}
// Sort the candidates by sync strength - here we benefit from the heap structure
int len_unsorted = heap_size;
while (len_unsorted > 1)
{
// Take the top (index 0) element which is guaranteed to have the smallest score,
// exchange it with the last element in the heap, and decrease the heap size.
// Then restore the heap property in the new, smaller heap.
// At the end the elements will be sorted in descending order.
ftx_candidate_t tmp = heap[len_unsorted - 1];
heap[len_unsorted - 1] = heap[0];
heap[0] = tmp;
len_unsorted--;
heapify_down(heap, len_unsorted);
}
return heap_size;
}
static void ft4_extract_likelihood(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, float* log174)
{
const WF_ELEM_T* mag = get_cand_mag(wf, cand); // Pointer to 4 magnitude bins of the first symbol
// Go over FSK tones and skip Costas sync symbols
for (int k = 0; k < FT4_ND; ++k)
{
// Skip either 5, 9 or 13 sync symbols
// TODO: replace magic numbers with constants
int sym_idx = k + ((k < 29) ? 5 : ((k < 58) ? 9 : 13));
int bit_idx = 2 * k;
// Check for time boundaries
int block = cand->time_offset + sym_idx;
if ((block < 0) || (block >= wf->num_blocks))
{
log174[bit_idx + 0] = 0;
log174[bit_idx + 1] = 0;
}
else
{
ft4_extract_symbol(mag + (sym_idx * wf->block_stride), log174 + bit_idx);
}
}
}
static void ft2_extract_likelihood(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, float* log174)
{
const WF_ELEM_T* mag = get_cand_mag(wf, cand);
float complex symbols[4][FT2_NN - FT2_NR];
float metric1[2 * (FT2_NN - FT2_NR)] = { 0 };
float metric2[2 * (FT2_NN - FT2_NR)] = { 0 };
float metric4[2 * (FT2_NN - FT2_NR)] = { 0 };
for (int frame_sym = 0; frame_sym < (FT2_NN - FT2_NR); ++frame_sym)
{
int sym_idx = frame_sym + 1; // skip ramp-up symbol
int block = cand->time_offset + sym_idx;
if ((block < 0) || (block >= wf->num_blocks))
{
for (int tone = 0; tone < 4; ++tone)
{
symbols[tone][frame_sym] = 0.0f;
}
continue;
}
const WF_ELEM_T* sym = mag + (sym_idx * wf->block_stride);
for (int tone = 0; tone < 4; ++tone)
{
symbols[tone][frame_sym] = wf_elem_to_complex(sym[tone]);
}
}
for (int start = 0; start <= (FT2_NN - FT2_NR) - 1; start += 1)
{
ft2_extract_logl_sequence(symbols, start, 1, metric1 + (2 * start));
}
for (int start = 0; start <= (FT2_NN - FT2_NR) - 2; start += 2)
{
ft2_extract_logl_sequence(symbols, start, 2, metric2 + (2 * start));
}
for (int start = 0; start <= (FT2_NN - FT2_NR) - 4; start += 4)
{
ft2_extract_logl_sequence(symbols, start, 4, metric4 + (2 * start));
}
metric2[204] = metric1[204];
metric2[205] = metric1[205];
metric4[200] = metric2[200];
metric4[201] = metric2[201];
metric4[202] = metric2[202];
metric4[203] = metric2[203];
metric4[204] = metric1[204];
metric4[205] = metric1[205];
for (int data_sym = 0; data_sym < FT2_ND; ++data_sym)
{
int frame_sym = data_sym + ((data_sym < 29) ? 4 : ((data_sym < 58) ? 8 : 12));
int src_bit = 2 * frame_sym;
int dst_bit = 2 * data_sym;
float a0 = metric1[src_bit + 0];
float b0 = metric2[src_bit + 0];
float c0 = metric4[src_bit + 0];
float a1 = metric1[src_bit + 1];
float b1 = metric2[src_bit + 1];
float c1 = metric4[src_bit + 1];
log174[dst_bit + 0] = (fabsf(a0) >= fabsf(b0) && fabsf(a0) >= fabsf(c0)) ? a0 : ((fabsf(b0) >= fabsf(c0)) ? b0 : c0);
log174[dst_bit + 1] = (fabsf(a1) >= fabsf(b1) && fabsf(a1) >= fabsf(c1)) ? a1 : ((fabsf(b1) >= fabsf(c1)) ? b1 : c1);
}
}
static void ft8_extract_likelihood(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, float* log174)
{
const WF_ELEM_T* mag = get_cand_mag(wf, cand); // Pointer to 8 magnitude bins of the first symbol
// Go over FSK tones and skip Costas sync symbols
for (int k = 0; k < FT8_ND; ++k)
{
// Skip either 7 or 14 sync symbols
// TODO: replace magic numbers with constants
int sym_idx = k + ((k < 29) ? 7 : 14);
int bit_idx = 3 * k;
// Check for time boundaries
int block = cand->time_offset + sym_idx;
if ((block < 0) || (block >= wf->num_blocks))
{
log174[bit_idx + 0] = 0;
log174[bit_idx + 1] = 0;
log174[bit_idx + 2] = 0;
}
else
{
ft8_extract_symbol(mag + (sym_idx * wf->block_stride), log174 + bit_idx);
}
}
}
static void ftx_normalize_logl(float* log174)
{
// Compute the variance of log174
float sum = 0;
float sum2 = 0;
for (int i = 0; i < FTX_LDPC_N; ++i)
{
sum += log174[i];
sum2 += log174[i] * log174[i];
}
float inv_n = 1.0f / FTX_LDPC_N;
float variance = (sum2 - (sum * sum * inv_n)) * inv_n;
// Normalize log174 distribution and scale it with experimentally found coefficient
float norm_factor = sqrtf(24.0f / variance);
for (int i = 0; i < FTX_LDPC_N; ++i)
{
log174[i] *= norm_factor;
}
}
bool ftx_decode_candidate(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, int max_iterations, ftx_message_t* message, ftx_decode_status_t* status)
{
float log174[FTX_LDPC_N]; // message bits encoded as likelihood
if (wf->protocol == FTX_PROTOCOL_FT2)
{
ft2_extract_likelihood(wf, cand, log174);
}
else if (ftx_protocol_uses_ft4_layout(wf->protocol))
{
ft4_extract_likelihood(wf, cand, log174);
}
else
{
ft8_extract_likelihood(wf, cand, log174);
}
ftx_normalize_logl(log174);
uint8_t plain174[FTX_LDPC_N]; // message bits (0/1)
bp_decode(log174, max_iterations, plain174, &status->ldpc_errors);
// ldpc_decode(log174, max_iterations, plain174, &status->ldpc_errors);
if (status->ldpc_errors > 0)
{
return false;
}
// Extract payload + CRC (first FTX_LDPC_K bits) packed into a byte array
uint8_t a91[FTX_LDPC_K_BYTES];
pack_bits(plain174, FTX_LDPC_K, a91);
// Extract CRC and check it
status->crc_extracted = ftx_extract_crc(a91);
// [1]: 'The CRC is calculated on the source-encoded message, zero-extended from 77 to 82 bits.'
a91[9] &= 0xF8;
a91[10] &= 0x00;
status->crc_calculated = ftx_compute_crc(a91, 96 - 14);
if (status->crc_extracted != status->crc_calculated)
{
return false;
}
// Reuse CRC value as a hash for the message (TODO: 14 bits only, should perhaps use full 16 or 32 bits?)
message->hash = status->crc_calculated;
if (ftx_protocol_uses_ft4_layout(wf->protocol))
{
// '[..] for FT4 only, in order to avoid transmitting a long string of zeros when sending CQ messages,
// the assembled 77-bit message is bitwise exclusive-ORed with [a] pseudorandom sequence before computing the CRC and FEC parity bits'
for (int i = 0; i < 10; ++i)
{
message->payload[i] = a91[i] ^ kFT4_XOR_sequence[i];
}
}
else
{
for (int i = 0; i < 10; ++i)
{
message->payload[i] = a91[i];
}
}
// LOG(LOG_DEBUG, "Decoded message (CRC %04x), trying to unpack...\n", status->crc_extracted);
return true;
}
static float max2(float a, float b)
{
return (a >= b) ? a : b;
}
static float max4(float a, float b, float c, float d)
{
return max2(max2(a, b), max2(c, d));
}
static void heapify_down(ftx_candidate_t heap[], int heap_size)
{
// heapify from the root down
int current = 0; // root node
while (true)
{
int left = 2 * current + 1;
int right = left + 1;
// Find the smallest value of (parent, left child, right child)
int smallest = current;
if ((left < heap_size) && (heap[left].score < heap[smallest].score))
{
smallest = left;
}
if ((right < heap_size) && (heap[right].score < heap[smallest].score))
{
smallest = right;
}
if (smallest == current)
{
break;
}
// Exchange the current node with the smallest child and move down to it
ftx_candidate_t tmp = heap[smallest];
heap[smallest] = heap[current];
heap[current] = tmp;
current = smallest;
}
}
static void heapify_up(ftx_candidate_t heap[], int heap_size)
{
// heapify from the last node up
int current = heap_size - 1;
while (current > 0)
{
int parent = (current - 1) / 2;
if (!(heap[current].score < heap[parent].score))
{
break;
}
// Exchange the current node with its parent and move up
ftx_candidate_t tmp = heap[parent];
heap[parent] = heap[current];
heap[current] = tmp;
current = parent;
}
}
// Compute unnormalized log likelihood log(p(1) / p(0)) of 2 message bits (1 FSK symbol)
static void ft4_extract_symbol(const WF_ELEM_T* wf, float* logl)
{
// Cleaned up code for the simple case of n_syms==1
float s2[4];
for (int j = 0; j < 4; ++j)
{
s2[j] = WF_ELEM_MAG(wf[kFT4_Gray_map[j]]);
}
logl[0] = max2(s2[2], s2[3]) - max2(s2[0], s2[1]);
logl[1] = max2(s2[1], s2[3]) - max2(s2[0], s2[2]);
}
static void ft2_extract_logl_sequence(const float complex symbols[4][FT2_NN - FT2_NR], int start_sym, int n_syms, float* metrics)
{
const int n_bits = 2 * n_syms;
const int n_sequences = 1 << n_bits;
for (int bit = 0; bit < n_bits; ++bit)
{
float max_zero = -INFINITY;
float max_one = -INFINITY;
for (int seq = 0; seq < n_sequences; ++seq)
{
float complex sum = 0.0f;
for (int sym = 0; sym < n_syms; ++sym)
{
int shift = 2 * (n_syms - sym - 1);
int dibit = (seq >> shift) & 0x3;
int tone = kFT4_Gray_map[dibit];
sum += symbols[tone][start_sym + sym];
}
float strength = cabsf(sum);
int mask_bit = n_bits - bit - 1;
if (((seq >> mask_bit) & 0x1) != 0)
{
if (strength > max_one)
max_one = strength;
}
else
{
if (strength > max_zero)
max_zero = strength;
}
}
metrics[bit] = max_one - max_zero;
}
}
// Compute unnormalized log likelihood log(p(1) / p(0)) of 3 message bits (1 FSK symbol)
static void ft8_extract_symbol(const WF_ELEM_T* wf, float* logl)
{
// Cleaned up code for the simple case of n_syms==1
#if 1
float s2[8];
for (int j = 0; j < 8; ++j)
{
s2[j] = WF_ELEM_MAG(wf[kFT8_Gray_map[j]]);
}
logl[0] = max4(s2[4], s2[5], s2[6], s2[7]) - max4(s2[0], s2[1], s2[2], s2[3]);
logl[1] = max4(s2[2], s2[3], s2[6], s2[7]) - max4(s2[0], s2[1], s2[4], s2[5]);
logl[2] = max4(s2[1], s2[3], s2[5], s2[7]) - max4(s2[0], s2[2], s2[4], s2[6]);
#else
float a[7] = {
// (float)wf[7] - (float)wf[0], // 0: p(111) / p(000)
(float)wf[5] - (float)wf[2], // 0: p(100) / p(011)
(float)wf[3] - (float)wf[0], // 1: p(010) / p(000)
(float)wf[6] - (float)wf[3], // 2: p(101) / p(010)
(float)wf[6] - (float)wf[2], // 3: p(101) / p(011)
(float)wf[7] - (float)wf[4], // 4: p(111) / p(110)
(float)wf[4] - (float)wf[1], // 5: p(110) / p(001)
(float)wf[5] - (float)wf[1] // 6: p(100) / p(001)
};
float k = 1.0f;
// logl[0] = k * (a[0] + a[2] + a[3] + a[5] + a[6]) / 5;
// logl[1] = k * (a[0] / 4 + (a[1] - a[3]) * 5 / 24 + (a[5] - a[2]) / 6 + (a[4] - a[6]) / 24);
// logl[2] = k * (a[0] / 4 + (a[1] - a[3]) / 24 + (a[2] - a[5]) / 6 + (a[4] - a[6]) * 5 / 24);
logl[0] = k * (a[1] / 6 + a[2] / 3 + a[3] / 6 + a[4] / 6 + a[5] / 3 + a[6] / 6);
logl[1] = k * (-a[0] / 4 + a[1] * 7 / 24 + (a[4] - a[3]) / 8 + a[5] / 3 + a[6] / 24);
logl[2] = k * (-a[0] / 4 + (a[1] - a[6]) / 8 + a[2] / 3 + a[3] / 24 + a[4] * 7 / 24 - a[5] * 5 / 18);
#endif
// for (int i = 0; i < 8; ++i)
// printf("%d ", WF_ELEM_MAG_INT(wf[i]));
// for (int i = 0; i < 3; ++i)
// printf("%.1f ", logl[i]);
// printf("\n");
}
// Compute unnormalized log likelihood log(p(1) / p(0)) of bits corresponding to several FSK symbols at once
static void ft8_decode_multi_symbols(const WF_ELEM_T* wf, int num_bins, int n_syms, int bit_idx, float* log174)
{
const int n_bits = 3 * n_syms;
const int n_tones = (1 << n_bits);
float s2[n_tones];
for (int j = 0; j < n_tones; ++j)
{
int j1 = j & 0x07;
if (n_syms == 1)
{
s2[j] = WF_ELEM_MAG(wf[kFT8_Gray_map[j1]]);
continue;
}
int j2 = (j >> 3) & 0x07;
if (n_syms == 2)
{
s2[j] = WF_ELEM_MAG(wf[kFT8_Gray_map[j2]]);
s2[j] += WF_ELEM_MAG(wf[kFT8_Gray_map[j1] + 4 * num_bins]);
continue;
}
int j3 = (j >> 6) & 0x07;
s2[j] = WF_ELEM_MAG(wf[kFT8_Gray_map[j3]]);
s2[j] += WF_ELEM_MAG(wf[kFT8_Gray_map[j2] + 4 * num_bins]);
s2[j] += WF_ELEM_MAG(wf[kFT8_Gray_map[j1] + 8 * num_bins]);
}
// Extract bit significance (and convert them to float)
// 8 FSK tones = 3 bits
for (int i = 0; i < n_bits; ++i)
{
if (bit_idx + i >= FTX_LDPC_N)
{
// Respect array size
break;
}
uint16_t mask = (n_tones >> (i + 1));
float max_zero = -1000, max_one = -1000;
for (int n = 0; n < n_tones; ++n)
{
if (n & mask)
{
max_one = max2(max_one, s2[n]);
}
else
{
max_zero = max2(max_zero, s2[n]);
}
}
log174[bit_idx + i] = max_one - max_zero;
}
}
// Packs a string of bits each represented as a zero/non-zero byte in plain[],
// as a string of packed bits starting from the MSB of the first byte of packed[]
static void pack_bits(const uint8_t bit_array[], int num_bits, uint8_t packed[])
{
int num_bytes = (num_bits + 7) / 8;
for (int i = 0; i < num_bytes; ++i)
{
packed[i] = 0;
}
uint8_t mask = 0x80;
int byte_idx = 0;
for (int i = 0; i < num_bits; ++i)
{
if (bit_array[i])
{
packed[byte_idx] |= mask;
}
mask >>= 1;
if (!mask)
{
mask = 0x80;
++byte_idx;
}
}
}
-96
View File
@@ -1,96 +0,0 @@
#ifndef _INCLUDE_DECODE_H_
#define _INCLUDE_DECODE_H_
#include <stdint.h>
#include <stdbool.h>
#include "constants.h"
#include "message.h"
#ifdef __cplusplus
extern "C"
{
#endif
typedef struct
{
float mag;
float phase;
} waterfall_cpx_t;
#define WATERFALL_USE_PHASE
#ifdef WATERFALL_USE_PHASE
#define WF_ELEM_T waterfall_cpx_t
#define WF_ELEM_MAG(x) ((x).mag)
#define WF_ELEM_MAG_INT(x) (int)(2 * ((x).mag + 120.0f))
#else
#define WF_ELEM_T uint8_t
#define WF_ELEM_MAG(x) ((float)(x)*0.5f - 120.0f)
#define WF_ELEM_MAG_INT(x) (int)(x)
#endif
/// Input structure to ftx_find_sync() function. This structure describes stored waterfall data over the whole message slot.
/// Fields time_osr and freq_osr specify additional oversampling rate for time and frequency resolution.
/// If time_osr=1, FFT magnitude data is collected once for every symbol transmitted, i.e. every 1/6.25 = 0.16 seconds.
/// Values time_osr > 1 mean each symbol is further subdivided in time.
/// If freq_osr=1, each bin in the FFT magnitude data corresponds to 6.25 Hz, which is the tone spacing.
/// Values freq_osr > 1 mean the tone spacing is further subdivided by FFT analysis.
typedef struct
{
int max_blocks; ///< number of blocks (symbols) allocated in the mag array
int num_blocks; ///< number of blocks (symbols) stored in the mag array
int num_bins; ///< number of FFT bins in terms of 6.25 Hz
int time_osr; ///< number of time subdivisions
int freq_osr; ///< number of frequency subdivisions
WF_ELEM_T* mag; ///< FFT magnitudes stored as uint8_t[blocks][time_osr][freq_osr][num_bins]
int block_stride; ///< Helper value = time_osr * freq_osr * num_bins
ftx_protocol_t protocol; ///< Indicate if using FT4 or FT8
} ftx_waterfall_t;
/// Output structure of ftx_find_sync() and input structure of ftx_decode().
/// Holds the position of potential start of a message in time and frequency.
typedef struct
{
int16_t score; ///< Candidate score (non-negative number; higher score means higher likelihood)
int16_t time_offset; ///< Index of the time block
int16_t freq_offset; ///< Index of the frequency bin
uint8_t time_sub; ///< Index of the time subdivision used
uint8_t freq_sub; ///< Index of the frequency subdivision used
} ftx_candidate_t;
/// Structure that contains the status of various steps during decoding of a message
typedef struct
{
float freq;
float time;
int ldpc_errors; ///< Number of LDPC errors during decoding
uint16_t crc_extracted; ///< CRC value recovered from the message
uint16_t crc_calculated; ///< CRC value calculated over the payload
// int unpack_status; ///< Return value of the unpack routine
} ftx_decode_status_t;
/// Localize top N candidates in frequency and time according to their sync strength (looking at Costas symbols)
/// We treat and organize the candidate list as a min-heap (empty initially).
/// @param[in] power Waterfall data collected during message slot
/// @param[in] sync_pattern Synchronization pattern
/// @param[in] num_candidates Number of maximum candidates (size of heap array)
/// @param[in,out] heap Array of ftx_candidate_t type entries (with num_candidates allocated entries)
/// @param[in] min_score Minimal score allowed for pruning unlikely candidates (can be zero for no effect)
/// @return Number of candidates filled in the heap
int ftx_find_candidates(const ftx_waterfall_t* power, int num_candidates, ftx_candidate_t heap[], int min_score);
/// Attempt to decode a message candidate. Extracts the bit probabilities, runs LDPC decoder, checks CRC and unpacks the message in plain text.
/// @param[in] power Waterfall data collected during message slot
/// @param[in] cand Candidate to decode
/// @param[in] max_iterations Maximum allowed LDPC iterations (lower number means faster decode, but less precise)
/// @param[out] message ftx_message_t structure that will receive the decoded message
/// @param[out] status ftx_decode_status_t structure that will be filled with the status of various decoding steps
/// @return True if the decoding was successful, false otherwise (check status for details)
bool ftx_decode_candidate(const ftx_waterfall_t* power, const ftx_candidate_t* cand, int max_iterations, ftx_message_t* message, ftx_decode_status_t* status);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_DECODE_H_
-200
View File
@@ -1,200 +0,0 @@
#include "encode.h"
#include "constants.h"
#include "crc.h"
#include <stdio.h>
// Returns 1 if an odd number of bits are set in x, zero otherwise
static uint8_t parity8(uint8_t x)
{
x ^= x >> 4; // a b c d ae bf cg dh
x ^= x >> 2; // a b ac bd cae dbf aecg bfdh
x ^= x >> 1; // a ab bac acbd bdcae caedbf aecgbfdh
return x % 2; // modulo 2
}
// Encode via LDPC a 91-bit message and return a 174-bit codeword.
// The generator matrix has dimensions (87,87).
// The code is a (174,91) regular LDPC code with column weight 3.
// Arguments:
// [IN] message - array of 91 bits stored as 12 bytes (MSB first)
// [OUT] codeword - array of 174 bits stored as 22 bytes (MSB first)
static void encode174(const uint8_t* message, uint8_t* codeword)
{
// This implementation accesses the generator bits straight from the packed binary representation in kFTX_LDPC_generator
// Fill the codeword with message and zeros, as we will only update binary ones later
for (int j = 0; j < FTX_LDPC_N_BYTES; ++j)
{
codeword[j] = (j < FTX_LDPC_K_BYTES) ? message[j] : 0;
}
// Compute the byte index and bit mask for the first checksum bit
uint8_t col_mask = (0x80u >> (FTX_LDPC_K % 8u)); // bitmask of current byte
uint8_t col_idx = FTX_LDPC_K_BYTES - 1; // index into byte array
// Compute the LDPC checksum bits and store them in codeword
for (int i = 0; i < FTX_LDPC_M; ++i)
{
// Fast implementation of bitwise multiplication and parity checking
// Normally nsum would contain the result of dot product between message and kFTX_LDPC_generator[i],
// but we only compute the sum modulo 2.
uint8_t nsum = 0;
for (int j = 0; j < FTX_LDPC_K_BYTES; ++j)
{
uint8_t bits = message[j] & kFTX_LDPC_generator[i][j]; // bitwise AND (bitwise multiplication)
nsum ^= parity8(bits); // bitwise XOR (addition modulo 2)
}
// Set the current checksum bit in codeword if nsum is odd
if (nsum % 2)
{
codeword[col_idx] |= col_mask;
}
// Update the byte index and bit mask for the next checksum bit
col_mask >>= 1;
if (col_mask == 0)
{
col_mask = 0x80u;
++col_idx;
}
}
}
void ft8_encode(const uint8_t* payload, uint8_t* tones)
{
uint8_t a91[FTX_LDPC_K_BYTES]; // Store 77 bits of payload + 14 bits CRC
// Compute and add CRC at the end of the message
// a91 contains 77 bits of payload + 14 bits of CRC
ftx_add_crc(payload, a91);
uint8_t codeword[FTX_LDPC_N_BYTES];
encode174(a91, codeword);
// Message structure: S7 D29 S7 D29 S7
// Total symbols: 79 (FT8_NN)
uint8_t mask = 0x80u; // Mask to extract 1 bit from codeword
int i_byte = 0; // Index of the current byte of the codeword
for (int i_tone = 0; i_tone < FT8_NN; ++i_tone)
{
if ((i_tone >= 0) && (i_tone < 7))
{
tones[i_tone] = kFT8_Costas_pattern[i_tone];
}
else if ((i_tone >= 36) && (i_tone < 43))
{
tones[i_tone] = kFT8_Costas_pattern[i_tone - 36];
}
else if ((i_tone >= 72) && (i_tone < 79))
{
tones[i_tone] = kFT8_Costas_pattern[i_tone - 72];
}
else
{
// Extract 3 bits from codeword at i-th position
uint8_t bits3 = 0;
if (codeword[i_byte] & mask)
bits3 |= 4;
if (0 == (mask >>= 1))
{
mask = 0x80u;
i_byte++;
}
if (codeword[i_byte] & mask)
bits3 |= 2;
if (0 == (mask >>= 1))
{
mask = 0x80u;
i_byte++;
}
if (codeword[i_byte] & mask)
bits3 |= 1;
if (0 == (mask >>= 1))
{
mask = 0x80u;
i_byte++;
}
tones[i_tone] = kFT8_Gray_map[bits3];
}
}
}
void ft4_encode(const uint8_t* payload, uint8_t* tones)
{
uint8_t a91[FTX_LDPC_K_BYTES]; // Store 77 bits of payload + 14 bits CRC
uint8_t payload_xor[10]; // Encoded payload data
// '[..] for FT4 only, in order to avoid transmitting a long string of zeros when sending CQ messages,
// the assembled 77-bit message is bitwise exclusive-ORed with [a] pseudorandom sequence before computing the CRC and FEC parity bits'
for (int i = 0; i < 10; ++i)
{
payload_xor[i] = payload[i] ^ kFT4_XOR_sequence[i];
}
// Compute and add CRC at the end of the message
// a91 contains 77 bits of payload + 14 bits of CRC
ftx_add_crc(payload_xor, a91);
uint8_t codeword[FTX_LDPC_N_BYTES];
encode174(a91, codeword); // 91 bits -> 174 bits
// Message structure: R S4_1 D29 S4_2 D29 S4_3 D29 S4_4 R
// Total symbols: 105 (FT4_NN)
uint8_t mask = 0x80u; // Mask to extract 1 bit from codeword
int i_byte = 0; // Index of the current byte of the codeword
for (int i_tone = 0; i_tone < FT4_NN; ++i_tone)
{
if ((i_tone == 0) || (i_tone == 104))
{
tones[i_tone] = 0; // R (ramp) symbol
}
else if ((i_tone >= 1) && (i_tone < 5))
{
tones[i_tone] = kFT4_Costas_pattern[0][i_tone - 1];
}
else if ((i_tone >= 34) && (i_tone < 38))
{
tones[i_tone] = kFT4_Costas_pattern[1][i_tone - 34];
}
else if ((i_tone >= 67) && (i_tone < 71))
{
tones[i_tone] = kFT4_Costas_pattern[2][i_tone - 67];
}
else if ((i_tone >= 100) && (i_tone < 104))
{
tones[i_tone] = kFT4_Costas_pattern[3][i_tone - 100];
}
else
{
// Extract 2 bits from codeword at i-th position
uint8_t bits2 = 0;
if (codeword[i_byte] & mask)
bits2 |= 2;
if (0 == (mask >>= 1))
{
mask = 0x80u;
i_byte++;
}
if (codeword[i_byte] & mask)
bits2 |= 1;
if (0 == (mask >>= 1))
{
mask = 0x80u;
i_byte++;
}
tones[i_tone] = kFT4_Gray_map[bits2];
}
}
}
void ft2_encode(const uint8_t* payload, uint8_t* tones)
{
ft4_encode(payload, tones);
}
-47
View File
@@ -1,47 +0,0 @@
#ifndef _INCLUDE_ENCODE_H_
#define _INCLUDE_ENCODE_H_
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
// typedef struct
// {
// uint8_t tones[FT8_NN];
// // for waveform readout:
// int n_spsym; // Number of waveform samples per symbol
// float *pulse; // [3 * n_spsym]
// int idx_symbol; // Index of the current symbol
// float f0; // Base frequency, Hertz
// float signal_rate; // Waveform sample rate, Hertz
// } encoder_t;
// void encoder_init(float signal_rate, float *pulse_buffer);
// void encoder_set_f0(float f0);
// void encoder_process(const message_t *message); // in: message
// void encoder_generate(float *block); // out: block of waveforms
/// Generate FT8 tone sequence from payload data
/// @param[in] payload - 10 byte array consisting of 77 bit payload
/// @param[out] tones - array of FT8_NN (79) bytes to store the generated tones (encoded as 0..7)
void ft8_encode(const uint8_t* payload, uint8_t* tones);
/// Generate FT4 tone sequence from payload data
/// @param[in] payload - 10 byte array consisting of 77 bit payload
/// @param[out] tones - array of FT4_NN (105) bytes to store the generated tones (encoded as 0..3)
void ft4_encode(const uint8_t* payload, uint8_t* tones);
/// Generate FT2 tone sequence from payload data.
/// FT2 uses the FT4 framing with a doubled symbol rate.
/// @param[in] payload - 10 byte array consisting of 77 bit payload
/// @param[out] tones - array of FT2_NN (105) bytes to store the generated tones (encoded as 0..3)
void ft2_encode(const uint8_t* payload, uint8_t* tones);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_ENCODE_H_
-251
View File
@@ -1,251 +0,0 @@
//
// LDPC decoder for FT8.
//
// given a 174-bit codeword as an array of log-likelihood of zero,
// return a 174-bit corrected codeword, or zero-length array.
// last 87 bits are the (systematic) plain-text.
// this is an implementation of the sum-product algorithm
// from Sarah Johnson's Iterative Error Correction book.
// codeword[i] = log ( P(x=0) / P(x=1) )
//
#include "ldpc.h"
#include "constants.h"
#include <stdio.h>
#include <math.h>
#include <stdlib.h>
#include <stdbool.h>
static int ldpc_check(uint8_t codeword[]);
static float fast_tanh(float x);
static float fast_atanh(float x);
// codeword is 174 log-likelihoods.
// plain is a return value, 174 ints, to be 0 or 1.
// max_iters is how hard to try.
// ok == 87 means success.
void ldpc_decode(float codeword[], int max_iters, uint8_t plain[], int* ok)
{
float m[FTX_LDPC_M][FTX_LDPC_N]; // ~60 kB
float e[FTX_LDPC_M][FTX_LDPC_N]; // ~60 kB
int min_errors = FTX_LDPC_M;
for (int j = 0; j < FTX_LDPC_M; j++)
{
for (int i = 0; i < FTX_LDPC_N; i++)
{
m[j][i] = codeword[i];
e[j][i] = 0.0f;
}
}
for (int iter = 0; iter < max_iters; iter++)
{
for (int j = 0; j < FTX_LDPC_M; j++)
{
for (int ii1 = 0; ii1 < kFTX_LDPC_Num_rows[j]; ii1++)
{
int i1 = kFTX_LDPC_Nm[j][ii1] - 1;
float a = 1.0f;
for (int ii2 = 0; ii2 < kFTX_LDPC_Num_rows[j]; ii2++)
{
int i2 = kFTX_LDPC_Nm[j][ii2] - 1;
if (i2 != i1)
{
a *= fast_tanh(-m[j][i2] / 2.0f);
}
}
e[j][i1] = -2.0f * fast_atanh(a);
}
}
for (int i = 0; i < FTX_LDPC_N; i++)
{
float l = codeword[i];
for (int j = 0; j < 3; j++)
l += e[kFTX_LDPC_Mn[i][j] - 1][i];
plain[i] = (l > 0) ? 1 : 0;
}
int errors = ldpc_check(plain);
if (errors < min_errors)
{
// Update the current best result
min_errors = errors;
if (errors == 0)
{
break; // Found a perfect answer
}
}
for (int i = 0; i < FTX_LDPC_N; i++)
{
for (int ji1 = 0; ji1 < 3; ji1++)
{
int j1 = kFTX_LDPC_Mn[i][ji1] - 1;
float l = codeword[i];
for (int ji2 = 0; ji2 < 3; ji2++)
{
if (ji1 != ji2)
{
int j2 = kFTX_LDPC_Mn[i][ji2] - 1;
l += e[j2][i];
}
}
m[j1][i] = l;
}
}
}
*ok = min_errors;
}
//
// does a 174-bit codeword pass the FT8's LDPC parity checks?
// returns the number of parity errors.
// 0 means total success.
//
static int ldpc_check(uint8_t codeword[])
{
int errors = 0;
for (int m = 0; m < FTX_LDPC_M; ++m)
{
uint8_t x = 0;
for (int i = 0; i < kFTX_LDPC_Num_rows[m]; ++i)
{
x ^= codeword[kFTX_LDPC_Nm[m][i] - 1];
}
if (x != 0)
{
++errors;
}
}
return errors;
}
void bp_decode(float codeword[], int max_iters, uint8_t plain[], int* ok)
{
float tov[FTX_LDPC_N][3];
float toc[FTX_LDPC_M][7];
int min_errors = FTX_LDPC_M;
// initialize message data
for (int n = 0; n < FTX_LDPC_N; ++n)
{
tov[n][0] = tov[n][1] = tov[n][2] = 0;
}
for (int iter = 0; iter < max_iters; ++iter)
{
// Do a hard decision guess (tov=0 in iter 0)
int plain_sum = 0;
for (int n = 0; n < FTX_LDPC_N; ++n)
{
plain[n] = ((codeword[n] + tov[n][0] + tov[n][1] + tov[n][2]) > 0) ? 1 : 0;
plain_sum += plain[n];
}
if (plain_sum == 0)
{
// message converged to all-zeros, which is prohibited
break;
}
// Check to see if we have a codeword (check before we do any iter)
int errors = ldpc_check(plain);
if (errors < min_errors)
{
// we have a better guess - update the result
min_errors = errors;
if (errors == 0)
{
break; // Found a perfect answer
}
}
// Send messages from bits to check nodes
for (int m = 0; m < FTX_LDPC_M; ++m)
{
for (int n_idx = 0; n_idx < kFTX_LDPC_Num_rows[m]; ++n_idx)
{
int n = kFTX_LDPC_Nm[m][n_idx] - 1;
// for each (n, m)
float Tnm = codeword[n];
for (int m_idx = 0; m_idx < 3; ++m_idx)
{
if ((kFTX_LDPC_Mn[n][m_idx] - 1) != m)
{
Tnm += tov[n][m_idx];
}
}
toc[m][n_idx] = fast_tanh(-Tnm / 2);
}
}
// send messages from check nodes to variable nodes
for (int n = 0; n < FTX_LDPC_N; ++n)
{
for (int m_idx = 0; m_idx < 3; ++m_idx)
{
int m = kFTX_LDPC_Mn[n][m_idx] - 1;
// for each (n, m)
float Tmn = 1.0f;
for (int n_idx = 0; n_idx < kFTX_LDPC_Num_rows[m]; ++n_idx)
{
if ((kFTX_LDPC_Nm[m][n_idx] - 1) != n)
{
Tmn *= toc[m][n_idx];
}
}
tov[n][m_idx] = -2 * fast_atanh(Tmn);
}
}
}
*ok = min_errors;
}
// Ideas for approximating tanh/atanh:
// * https://varietyofsound.wordpress.com/2011/02/14/efficient-tanh-computation-using-lamberts-continued-fraction/
// * http://functions.wolfram.com/ElementaryFunctions/ArcTanh/10/0001/
// * https://mathr.co.uk/blog/2017-09-06_approximating_hyperbolic_tangent.html
// * https://math.stackexchange.com/a/446411
static float fast_tanh(float x)
{
if (x < -4.97f)
{
return -1.0f;
}
if (x > 4.97f)
{
return 1.0f;
}
float x2 = x * x;
// float a = x * (135135.0f + x2 * (17325.0f + x2 * (378.0f + x2)));
// float b = 135135.0f + x2 * (62370.0f + x2 * (3150.0f + x2 * 28.0f));
// float a = x * (10395.0f + x2 * (1260.0f + x2 * 21.0f));
// float b = 10395.0f + x2 * (4725.0f + x2 * (210.0f + x2));
float a = x * (945.0f + x2 * (105.0f + x2));
float b = 945.0f + x2 * (420.0f + x2 * 15.0f);
return a / b;
}
static float fast_atanh(float x)
{
float x2 = x * x;
// float a = x * (-15015.0f + x2 * (19250.0f + x2 * (-5943.0f + x2 * 256.0f)));
// float b = (-15015.0f + x2 * (24255.0f + x2 * (-11025.0f + x2 * 1225.0f)));
// float a = x * (-1155.0f + x2 * (1190.0f + x2 * -231.0f));
// float b = (-1155.0f + x2 * (1575.0f + x2 * (-525.0f + x2 * 25.0f)));
float a = x * (945.0f + x2 * (-735.0f + x2 * 64.0f));
float b = (945.0f + x2 * (-1050.0f + x2 * 225.0f));
return a / b;
}
-23
View File
@@ -1,23 +0,0 @@
#ifndef _INCLUDE_LDPC_H_
#define _INCLUDE_LDPC_H_
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
// codeword is 174 log-likelihoods.
// plain is a return value, 174 ints, to be 0 or 1.
// iters is how hard to try.
// ok == 87 means success.
void ldpc_decode(float codeword[], int max_iters, uint8_t plain[], int* ok);
void bp_decode(float codeword[], int max_iters, uint8_t plain[], int* ok);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_LDPC_H_
-1156
View File
File diff suppressed because it is too large Load Diff
-160
View File
@@ -1,160 +0,0 @@
#ifndef _INCLUDE_MESSAGE_H_
#define _INCLUDE_MESSAGE_H_
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C"
{
#endif
#define FTX_PAYLOAD_LENGTH_BYTES 10 ///< number of bytes to hold 77 bits of FTx payload data
#define FTX_MAX_MESSAGE_LENGTH 35 ///< max message length = callsign[13] + space + callsign[13] + space + report[6] + terminator
#define FTX_MAX_MESSAGE_FIELDS 3 // may need to get longer for multi-part messages (DXpedition, contest etc.)
/// Structure that holds the decoded message
typedef struct
{
uint8_t payload[FTX_PAYLOAD_LENGTH_BYTES];
uint16_t hash; ///< Hash value to be used in hash table and quick checking for duplicates
} ftx_message_t;
// ----------------------------------------------------------------------------------
// i3.n3 Example message Bits Total Purpose
// ----------------------------------------------------------------------------------
// 0.0 FREE TEXT MSG 71 71 Free text
// 0.1 K1ABC RR73; W9XYZ <KH1/KH7Z> -12 28 28 10 5 71 DXpedition Mode
// 0.2 PA3XYZ/P R 590003 IO91NP 28 1 1 3 12 25 70 EU VHF contest
// 0.3 WA9XYZ KA1ABC R 16A EMA 28 28 1 4 3 7 71 ARRL Field Day
// 0.4 WA9XYZ KA1ABC R 32A EMA 28 28 1 4 3 7 71 ARRL Field Day
// 0.5 123456789ABCDEF012 71 71 Telemetry (18 hex)
// 0.6 K1ABC RR73; CQ W9XYZ EN37 28 28 15 71 Contesting
// 0.7 ... tbd
// 1 WA9XYZ/R KA1ABC/R R FN42 28 1 28 1 1 15 74 Standard msg
// 2 PA3XYZ/P GM4ABC/P R JO22 28 1 28 1 1 15 74 EU VHF contest
// 3 TU; W9XYZ K1ABC R 579 MA 1 28 28 1 3 13 74 ARRL RTTY Roundup
// 4 <WA9XYZ> PJ4/KA1ABC RR73 12 58 1 2 1 74 Nonstandard calls
// 5 TU; W9XYZ K1ABC R-07 FN 1 28 28 1 7 9 74 WWROF contest ?
typedef enum
{
FTX_MESSAGE_TYPE_FREE_TEXT, // 0.0 FREE TEXT MSG 71 71 Free text
FTX_MESSAGE_TYPE_DXPEDITION, // 0.1 K1ABC RR73; W9XYZ <KH1/KH7Z> -12 28 28 10 5 71 DXpedition Mode
FTX_MESSAGE_TYPE_EU_VHF, // 0.2 PA3XYZ/P R 590003 IO91NP 28 1 1 3 12 25 70 EU VHF contest
FTX_MESSAGE_TYPE_ARRL_FD, // 0.3 WA9XYZ KA1ABC R 16A EMA 28 28 1 4 3 7 71 ARRL Field Day
// 0.4 WA9XYZ KA1ABC R 32A EMA 28 28 1 4 3 7 71 ARRL Field Day
FTX_MESSAGE_TYPE_TELEMETRY, // 0.5 0123456789abcdef01 71 71 Telemetry (18 hex)
FTX_MESSAGE_TYPE_CONTESTING, // 0.6 K1ABC RR73; CQ W9XYZ EN37 28 28 15 71 Contesting
FTX_MESSAGE_TYPE_STANDARD, // 1 WA9XYZ/R KA1ABC/R R FN42 28 1 28 1 1 15 74 Standard msg
// 2 PA3XYZ/P GM4ABC/P R JO22 28 1 28 1 1 15 74 EU VHF contest
FTX_MESSAGE_TYPE_ARRL_RTTY, // 3 TU; W9XYZ K1ABC R 579 MA 1 28 28 1 3 13 74 ARRL RTTY Roundup
FTX_MESSAGE_TYPE_NONSTD_CALL, // 4 <WA9XYZ> PJ4/KA1ABC RR73 12 58 1 2 1 74 Nonstandard calls
FTX_MESSAGE_TYPE_WWROF, // 5 TU; W9XYZ K1ABC R-07 FN 1 28 28 1 7 9 74 WWROF contest ?
FTX_MESSAGE_TYPE_UNKNOWN // Unknown or invalid type
} ftx_message_type_t;
typedef enum
{
FTX_CALLSIGN_HASH_22_BITS,
FTX_CALLSIGN_HASH_12_BITS,
FTX_CALLSIGN_HASH_10_BITS
} ftx_callsign_hash_type_t;
typedef struct
{
/// Called when a callsign is looked up by its 22/12/10 bit hash code
bool (*lookup_hash)(ftx_callsign_hash_type_t hash_type, uint32_t hash, char* callsign);
/// Called when a callsign should hashed and stored (by its 22, 12 and 10 bit hash codes)
void (*save_hash)(const char* callsign, uint32_t n22);
} ftx_callsign_hash_interface_t;
typedef enum
{
FTX_MESSAGE_RC_OK,
FTX_MESSAGE_RC_ERROR_CALLSIGN1,
FTX_MESSAGE_RC_ERROR_CALLSIGN2,
FTX_MESSAGE_RC_ERROR_SUFFIX,
FTX_MESSAGE_RC_ERROR_GRID,
FTX_MESSAGE_RC_ERROR_TYPE
} ftx_message_rc_t;
typedef enum
{
FTX_FIELD_UNKNOWN,
FTX_FIELD_NONE,
FTX_FIELD_TOKEN, // RRR, RR73, 73, DE, QRZ, CQ, ...
FTX_FIELD_TOKEN_WITH_ARG, // CQ nnn, CQ abcd
FTX_FIELD_CALL,
FTX_FIELD_GRID,
FTX_FIELD_RST
} ftx_field_t;
typedef struct
{
// parallel arrays:
// e.g. "CQ POTA W9XYZ AB12" generates
// types { FTX_FIELD_TOKEN_WITH_ARG, FTX_FIELD_CALL, FTX_FIELD_CALL_GRID" }
// offsets { 0, 8, 14 }
// Both arrays end where offsets[i] < 0
ftx_field_t types[FTX_MAX_MESSAGE_FIELDS];
int16_t offsets[FTX_MAX_MESSAGE_FIELDS];
} ftx_message_offsets_t;
// Callsign types and sizes:
// * Std. call (basecall) - 1-2 letter/digit prefix (at least one letter), 1 digit area code, 1-3 letter suffix,
// total 3-6 chars (exception: 7 character calls with prefixes 3DA0- and 3XA..3XZ-)
// * Ext. std. call - basecall followed by /R or /P
// * Nonstd. call - all the rest, limited to 3-11 characters either alphanumeric or stroke (/)
// In case a call is looked up from its hash value, the call is enclosed in angular brackets (<CA0LL>).
void ftx_message_init(ftx_message_t* msg);
uint8_t ftx_message_get_i3(const ftx_message_t* msg);
uint8_t ftx_message_get_n3(const ftx_message_t* msg);
ftx_message_type_t ftx_message_get_type(const ftx_message_t* msg);
// bool ftx_message_check_recipient(const ftx_message_t* msg, const char* callsign);
/// Pack (encode) a callsign in the standard way, and return the numeric representation.
/// Returns -1 if \a callsign cannot be encoded in the standard way.
/// This function can be used to decide whether to call ftx_message_encode_std() or ftx_message_decode_nonstd().
/// Alternatively, ftx_message_encode_std() itself fails when one of the callsigns cannot be packed this way.
int32_t pack_basecall(const char* callsign, int length);
/// Pack (encode) a text message, guessing which message type to use and falling back on failure:
/// if there are 3 or fewer tokens, try ftx_message_encode_std first,
/// then ftx_message_encode_nonstd if that fails because of a non-standard callsign;
/// otherwise fall back to ftx_message_encode_free.
/// If you already know which type to use, you can call one of those functions directly.
ftx_message_rc_t ftx_message_encode(ftx_message_t* msg, ftx_callsign_hash_interface_t* hash_if, const char* message_text);
/// Pack Type 1 (Standard 77-bit message) or Type 2 (ditto, with a "/P" call) message
/// Rules of callsign validity:
/// - call_to can be 'DE', 'CQ', 'QRZ', 'CQ_nnn' (three digits), or 'CQ_abcd' (four letters)
/// - nonstandard calls within <> brackets are allowed, if they don't contain '/'
ftx_message_rc_t ftx_message_encode_std(ftx_message_t* msg, ftx_callsign_hash_interface_t* hash_if, const char* call_to, const char* call_de, const char* extra);
/// Pack Type 4 (One nonstandard call and one hashed call) message
ftx_message_rc_t ftx_message_encode_nonstd(ftx_message_t* msg, ftx_callsign_hash_interface_t* hash_if, const char* call_to, const char* call_de, const char* extra);
/// Pack plain text, up to 13 characters
ftx_message_rc_t ftx_message_encode_free(ftx_message_t* msg, const char* text);
ftx_message_rc_t ftx_message_encode_telemetry(ftx_message_t* msg, const uint8_t* telemetry);
ftx_message_rc_t ftx_message_decode(const ftx_message_t* msg, ftx_callsign_hash_interface_t* hash_if, char* message, ftx_message_offsets_t* offsets);
ftx_message_rc_t ftx_message_decode_std(const ftx_message_t* msg, ftx_callsign_hash_interface_t* hash_if, char* call_to, char* call_de, char* extra, ftx_field_t field_types[FTX_MAX_MESSAGE_FIELDS]);
ftx_message_rc_t ftx_message_decode_nonstd(const ftx_message_t* msg, ftx_callsign_hash_interface_t* hash_if, char* call_to, char* call_de, char* extra, ftx_field_t field_types[FTX_MAX_MESSAGE_FIELDS]);
void ftx_message_decode_free(const ftx_message_t* msg, char* text);
void ftx_message_decode_telemetry_hex(const ftx_message_t* msg, char* telemetry_hex);
void ftx_message_decode_telemetry(const ftx_message_t* msg, uint8_t* telemetry);
#ifdef FTX_DEBUG_PRINT
void ftx_message_print(ftx_message_t* msg);
#endif
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_MESSAGE_H_
-303
View File
@@ -1,303 +0,0 @@
#include "text.h"
#include <string.h>
const char* trim_front(const char* str, char to_trim)
{
// Skip leading to_trim characters
while (*str == to_trim)
{
str++;
}
return str;
}
void trim_back(char* str, char to_trim)
{
// Skip trailing to_trim characters by replacing them with '\0' characters
int idx = strlen(str) - 1;
while (idx >= 0 && str[idx] == to_trim)
{
str[idx--] = '\0';
}
}
char* trim(char* str)
{
str = (char*)trim_front(str, ' ');
trim_back(str, ' ');
// return a pointer to the first non-whitespace character
return str;
}
char* trim_brackets(char* str)
{
str = (char*)trim_front(str, '<');
trim_back(str, '>');
// return a pointer to the first non-whitespace character
return str;
}
void trim_copy(char* trimmed, const char* str)
{
str = (char*)trim_front(str, ' ');
int len = strlen(str) - 1;
while (len >= 0 && str[len] == ' ')
{
len--;
}
strncpy(trimmed, str, len + 1);
trimmed[len + 1] = '\0';
}
char to_upper(char c)
{
return (c >= 'a' && c <= 'z') ? (c - 'a' + 'A') : c;
}
bool is_digit(char c)
{
return (c >= '0') && (c <= '9');
}
bool is_letter(char c)
{
return ((c >= 'A') && (c <= 'Z')) || ((c >= 'a') && (c <= 'z'));
}
bool is_space(char c)
{
return (c == ' ');
}
bool in_range(char c, char min, char max)
{
return (c >= min) && (c <= max);
}
bool starts_with(const char* string, const char* prefix)
{
return 0 == memcmp(string, prefix, strlen(prefix));
}
bool ends_with(const char* string, const char* suffix)
{
int pos = strlen(string) - strlen(suffix);
if (pos >= 0)
{
return 0 == memcmp(string + pos, suffix, strlen(suffix));
}
return false;
}
bool equals(const char* string1, const char* string2)
{
return 0 == strcmp(string1, string2);
}
// Text message formatting:
// - replaces lowercase letters with uppercase
// - merges consecutive spaces into single space
void fmtmsg(char* msg_out, const char* msg_in)
{
char c;
char last_out = 0;
while ((c = *msg_in))
{
if (c != ' ' || last_out != ' ')
{
last_out = to_upper(c);
*msg_out = last_out;
++msg_out;
}
++msg_in;
}
*msg_out = 0; // Add zero termination
}
// Returns pointer to the null terminator within the given string (destination)
char* append_string(char* string, const char* token)
{
while (*token != '\0')
{
*string = *token;
string++;
token++;
}
*string = '\0';
return string;
}
const char* copy_token(char* token, int length, const char* string)
{
// Copy characters until a whitespace character or the end of string
while (*string != ' ' && *string != '\0')
{
if (length > 1)
{
*token = *string;
token++;
length--;
}
string++;
}
// Fill up the rest of token with \0 terminators
while (length > 0)
{
*token = '\0';
token++;
length--;
}
// Skip whitespace characters
while (*string == ' ')
{
string++;
}
return string;
}
// Parse a 2 digit integer from string
int dd_to_int(const char* str, int length)
{
int result = 0;
bool negative;
int i;
if (str[0] == '-')
{
negative = true;
i = 1; // Consume the - sign
}
else
{
negative = false;
i = (str[0] == '+') ? 1 : 0; // Consume a + sign if found
}
while (i < length)
{
if (str[i] == 0)
break;
if (!is_digit(str[i]))
break;
result *= 10;
result += (str[i] - '0');
++i;
}
return negative ? -result : result;
}
// Convert a 2 digit integer to string
void int_to_dd(char* str, int value, int width, bool full_sign)
{
if (value < 0)
{
*str = '-';
++str;
value = -value;
}
else if (full_sign)
{
*str = '+';
++str;
}
int divisor = 1;
for (int i = 0; i < width - 1; ++i)
{
divisor *= 10;
}
while (divisor >= 1)
{
int digit = value / divisor;
*str = '0' + digit;
++str;
value -= digit * divisor;
divisor /= 10;
}
*str = 0; // Add zero terminator
}
char charn(int c, ft8_char_table_e table)
{
if ((table != FT8_CHAR_TABLE_ALPHANUM) && (table != FT8_CHAR_TABLE_NUMERIC))
{
if (c == 0)
return ' ';
c -= 1;
}
if (table != FT8_CHAR_TABLE_LETTERS_SPACE)
{
if (c < 10)
return '0' + c;
c -= 10;
}
if (table != FT8_CHAR_TABLE_NUMERIC)
{
if (c < 26)
return 'A' + c;
c -= 26;
}
if (table == FT8_CHAR_TABLE_FULL)
{
if (c < 5)
return "+-./?"[c];
}
else if (table == FT8_CHAR_TABLE_ALPHANUM_SPACE_SLASH)
{
if (c == 0)
return '/';
}
return '_'; // unknown character, should never get here
}
// Convert character to its index (charn in reverse) according to a table
int nchar(char c, ft8_char_table_e table)
{
int n = 0;
if ((table != FT8_CHAR_TABLE_ALPHANUM) && (table != FT8_CHAR_TABLE_NUMERIC))
{
if (c == ' ')
return n + 0;
n += 1;
}
if (table != FT8_CHAR_TABLE_LETTERS_SPACE)
{
if (c >= '0' && c <= '9')
return n + (c - '0');
n += 10;
}
if (table != FT8_CHAR_TABLE_NUMERIC)
{
if (c >= 'A' && c <= 'Z')
return n + (c - 'A');
n += 26;
}
if (table == FT8_CHAR_TABLE_FULL)
{
if (c == '+')
return n + 0;
if (c == '-')
return n + 1;
if (c == '.')
return n + 2;
if (c == '/')
return n + 3;
if (c == '?')
return n + 4;
}
else if (table == FT8_CHAR_TABLE_ALPHANUM_SPACE_SLASH)
{
if (c == '/')
return n + 0;
}
// Character not found
return -1;
}
-82
View File
@@ -1,82 +0,0 @@
#ifndef _INCLUDE_TEXT_H_
#define _INCLUDE_TEXT_H_
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
// Utility functions for characters and strings
const char* trim_front(const char* str, char to_trim);
void trim_back(char* str, char to_trim);
/// In-place whitespace trim from front and back:
/// 1) trims the back by changing whitespaces to '\0'
/// 2) trims the front by skipping whitespaces
/// @return trimmed string (pointer to first non-whitespace character)
char* trim(char* str);
/// Trim whitespace from start and end of string
void trim_copy(char* trimmed, const char* str);
/// In-place trim of <> characters from front and back:
/// 1) trims the back by changing > to '\0'
/// 2) trims the front by skipping <
/// @return trimmed string (pointer to first non-whitespace character)
char* trim_brackets(char* str);
char to_upper(char c);
bool is_digit(char c);
bool is_letter(char c);
bool is_space(char c);
bool in_range(char c, char min, char max);
bool starts_with(const char* string, const char* prefix);
bool ends_with(const char* string, const char* suffix);
bool equals(const char* string1, const char* string2);
// Text message formatting:
// - replaces lowercase letters with uppercase
// - merges consecutive spaces into single space
void fmtmsg(char* msg_out, const char* msg_in);
/// Extract and copy a space-delimited token from a string.
/// When the last token has been extracted, the return value points to the terminating zero character.
/// @param[out] token Buffer to receive the extracted token
/// @param[in] length Length of the token buffer (number of characters)
/// @param[in] string Pointer to the string
/// @return Pointer to the next token (can be passed to copy_token to extract the next token)
const char* copy_token(char* token, int length, const char* string);
char* append_string(char* string, const char* token);
// Parse a 2 digit integer from string
int dd_to_int(const char* str, int length);
// Convert a 2 digit integer to string
void int_to_dd(char* str, int value, int width, bool full_sign);
typedef enum
{
FT8_CHAR_TABLE_FULL, // table[42] " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?"
FT8_CHAR_TABLE_ALPHANUM_SPACE_SLASH, // table[38] " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/"
FT8_CHAR_TABLE_ALPHANUM_SPACE, // table[37] " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
FT8_CHAR_TABLE_LETTERS_SPACE, // table[27] " ABCDEFGHIJKLMNOPQRSTUVWXYZ"
FT8_CHAR_TABLE_ALPHANUM, // table[36] "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
FT8_CHAR_TABLE_NUMERIC, // table[10] "0123456789"
} ft8_char_table_e;
/// Convert integer index to ASCII character according to one of character tables
char charn(int c, ft8_char_table_e table);
/// Look up the index of an ASCII character in one of character tables
int nchar(char c, ft8_char_table_e table);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_TEXT_H_
-286
View File
@@ -1,286 +0,0 @@
#define _POSIX_C_SOURCE 200809L
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <math.h>
#include <stdbool.h>
#include "ft8/text.h"
#include "ft8/encode.h"
#include "ft8/constants.h"
#include "fft/kiss_fftr.h"
#include "common/common.h"
#include "ft8/message.h"
#define LOG_LEVEL LOG_INFO
#include "ft8/debug.h"
// void convert_8bit_to_6bit(uint8_t* dst, const uint8_t* src, int nBits)
// {
// // Zero-fill the destination array as we will only be setting bits later
// for (int j = 0; j < (nBits + 5) / 6; ++j)
// {
// dst[j] = 0;
// }
// // Set the relevant bits
// uint8_t mask_src = (1 << 7);
// uint8_t mask_dst = (1 << 5);
// for (int i = 0, j = 0; nBits > 0; --nBits)
// {
// if (src[i] & mask_src)
// {
// dst[j] |= mask_dst;
// }
// mask_src >>= 1;
// if (mask_src == 0)
// {
// mask_src = (1 << 7);
// ++i;
// }
// mask_dst >>= 1;
// if (mask_dst == 0)
// {
// mask_dst = (1 << 5);
// ++j;
// }
// }
// }
/*
bool test1() {
//const char *msg = "CQ DL7ACA JO40"; // 62, 32, 32, 49, 37, 27, 59, 2, 30, 19, 49, 16
const char *msg = "VA3UG F1HMR 73"; // 52, 54, 60, 12, 55, 54, 7, 19, 2, 23, 59, 16
//const char *msg = "RA3Y VE3NLS 73"; // 46, 6, 32, 22, 55, 20, 11, 32, 53, 23, 59, 16
uint8_t a72[9];
int rc = packmsg(msg, a72);
if (rc < 0) return false;
LOG(LOG_INFO, "8-bit packed: ");
for (int i = 0; i < 9; ++i) {
LOG(LOG_INFO, "%02x ", a72[i]);
}
LOG(LOG_INFO, "\n");
uint8_t a72_6bit[12];
convert_8bit_to_6bit(a72_6bit, a72, 72);
LOG(LOG_INFO, "6-bit packed: ");
for (int i = 0; i < 12; ++i) {
LOG(LOG_INFO, "%d ", a72_6bit[i]);
}
LOG(LOG_INFO, "\n");
char msg_out_raw[14];
unpack(a72, msg_out_raw);
char msg_out[14];
fmtmsg(msg_out, msg_out_raw);
LOG(LOG_INFO, "msg_out = [%s]\n", msg_out);
return true;
}
void test2() {
uint8_t test_in[11] = { 0xF1, 0x02, 0x03, 0x04, 0x05, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xFF };
uint8_t test_out[22];
encode174(test_in, test_out);
for (int j = 0; j < 22; ++j) {
LOG(LOG_INFO, "%02x ", test_out[j]);
}
LOG(LOG_INFO, "\n");
}
void test3() {
uint8_t test_in2[10] = { 0x11, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x10, 0x04, 0x01, 0x00 };
uint16_t crc1 = ftx_compute_crc(test_in2, 76); // Calculate CRC of 76 bits only
LOG(LOG_INFO, "CRC: %04x\n", crc1); // should be 0x0708
}
*/
#define CHECK(condition) \
if (!(condition)) \
{ \
printf("FAIL! Condition \'" #condition "' failed\n\n"); \
return; \
}
#define CHECK_EQ_VAL(this, that) \
if ((this) != (that)) \
{ \
printf("FAIL! Expected " #this " (%d) == " #that " (%d)\n\n", \
(this), (that)); \
return; \
}
#define TEST_END printf("Test OK\n\n")
#define CALLSIGN_HASHTABLE_SIZE 256
struct
{
char callsign[12];
uint32_t hash;
} callsign_hashtable[CALLSIGN_HASHTABLE_SIZE];
void hashtable_init(void)
{
// for (int idx = 0; idx < CALLSIGN_HASHTABLE_SIZE; ++idx)
// {
// callsign_hashtable[idx]->callsign[0] = '\0';
// }
memset(callsign_hashtable, 0, sizeof(callsign_hashtable));
}
void hashtable_add(const char* callsign, uint32_t hash)
{
int idx_hash = (hash * 23) % CALLSIGN_HASHTABLE_SIZE;
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
{
if ((callsign_hashtable[idx_hash].hash == hash) && (0 == strcmp(callsign_hashtable[idx_hash].callsign, callsign)))
{
LOG(LOG_DEBUG, "Found a duplicate [%s]\n", callsign);
return;
}
else
{
LOG(LOG_DEBUG, "Hash table clash!\n");
// Move on to check the next entry in hash table
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
}
}
strncpy(callsign_hashtable[idx_hash].callsign, callsign, 11);
callsign_hashtable[idx_hash].callsign[11] = '\0';
callsign_hashtable[idx_hash].hash = hash;
}
bool hashtable_lookup(ftx_callsign_hash_type_t hash_type, uint32_t hash, char* callsign)
{
uint32_t hash_mask = (hash_type == FTX_CALLSIGN_HASH_10_BITS) ? 0x3FFu : (hash_type == FTX_CALLSIGN_HASH_12_BITS ? 0xFFFu : 0x3FFFFFu);
int idx_hash = (hash * 23) % CALLSIGN_HASHTABLE_SIZE;
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
{
if ((callsign_hashtable[idx_hash].hash & hash_mask) == hash)
{
strcpy(callsign, callsign_hashtable[idx_hash].callsign);
return true;
}
// Move on to check the next entry in hash table
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
}
callsign[0] = '\0';
return false;
}
ftx_callsign_hash_interface_t hash_if = {
.lookup_hash = hashtable_lookup,
.save_hash = hashtable_add
};
void test_std_msg(const char* call_to_tx, ftx_field_t to_field, const char* call_de_tx, ftx_field_t de_field, const char* extra_tx, ftx_field_t extra_field)
{
ftx_message_t msg;
ftx_message_init(&msg);
ftx_message_rc_t rc_encode = ftx_message_encode_std(&msg, &hash_if, call_to_tx, call_de_tx, extra_tx);
printf("Encoded [%s] [%s] [%s]\n", call_to_tx, call_de_tx, extra_tx);
CHECK_EQ_VAL(rc_encode, FTX_MESSAGE_RC_OK);
char call_to_arr[14];
char call_de_arr[14];
char extra[14];
char *call_to = call_to_arr;
char *call_de = call_de_arr;
ftx_field_t types[FTX_MAX_MESSAGE_FIELDS];
ftx_message_rc_t rc_decode = ftx_message_decode_std(&msg, &hash_if, call_to, call_de, extra, types);
CHECK_EQ_VAL(rc_decode, FTX_MESSAGE_RC_OK);
printf("Decoded [%s] [%s] [%s]\n", call_to, call_de, extra);
call_to = trim_brackets(call_to);
call_de = trim_brackets(call_de);
CHECK_EQ_VAL(0, strcmp(call_to, call_to_tx));
CHECK_EQ_VAL(0, strcmp(call_de, call_de_tx));
CHECK_EQ_VAL(0, strcmp(extra, extra_tx));
CHECK_EQ_VAL(to_field, types[0]);
CHECK_EQ_VAL(de_field, types[1]);
CHECK_EQ_VAL(extra_field, types[2]);
TEST_END;
}
void test_msg(const char* message_text, ftx_message_type_t expected_type, const char* expected, ftx_callsign_hash_interface_t* hash_if)
{
printf("Testing [%s]\n", message_text);
ftx_message_t msg;
ftx_message_init(&msg);
ftx_message_rc_t rc_encode = ftx_message_encode(&msg, hash_if, message_text);
CHECK_EQ_VAL(rc_encode, FTX_MESSAGE_RC_OK);
CHECK_EQ_VAL(expected_type, ftx_message_get_type(&msg));
char message_decoded[12 + 12 + 20];
ftx_message_offsets_t offsets;
ftx_message_rc_t rc_decode = ftx_message_decode(&msg, hash_if, message_decoded, &offsets);
CHECK_EQ_VAL(rc_decode, FTX_MESSAGE_RC_OK);
printf("Decoded [%s]; offsets %d:%d %d:%d %d:%d\n", message_decoded,
offsets.offsets[0], offsets.types[0], offsets.offsets[1], offsets.types[1], offsets.offsets[2], offsets.types[2]);
CHECK_EQ_VAL(0, strcmp(expected, message_decoded));
// TODO check offsets
TEST_END;
}
#define SIZEOF_ARRAY(x) (sizeof(x) / sizeof((x)[0]))
int main()
{
// test1();
// test4();
const char* callsigns[] = { "YL3JG", "W1A", "W1A/R", "W5AB", "W8ABC", "DE6ABC", "DE6ABC/R", "DE7AB", "DE9A", "3DA0X", "3DA0XYZ", "3DA0XYZ/R", "3XZ0AB", "3XZ0A", "CQ1CQ" };
const char* tokens[] = { "CQ", "QRZ", "CQ 123", "CQ 000", "CQ POTA", "CQ SA", "CQ O", "CQ ASD" };
const ftx_field_t token_types[] = { FTX_FIELD_TOKEN, FTX_FIELD_TOKEN, FTX_FIELD_TOKEN_WITH_ARG, FTX_FIELD_TOKEN_WITH_ARG, FTX_FIELD_TOKEN_WITH_ARG, FTX_FIELD_TOKEN_WITH_ARG, FTX_FIELD_TOKEN_WITH_ARG, FTX_FIELD_TOKEN_WITH_ARG };
const char* grids[] = { "KO26", "RR99", "AA00", "RR09", "AA01", "RRR", "RR73", "73", "R+10", "R+05", "R-12", "R-02", "+10", "+05", "-02", "-02", "" };
const ftx_field_t grid_types[] = { FTX_FIELD_GRID, FTX_FIELD_GRID, FTX_FIELD_GRID, FTX_FIELD_GRID, FTX_FIELD_GRID, FTX_FIELD_TOKEN, FTX_FIELD_TOKEN, FTX_FIELD_TOKEN, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_NONE };
for (int idx_grid = 0; idx_grid < SIZEOF_ARRAY(grids); ++idx_grid)
{
for (int idx_callsign = 0; idx_callsign < SIZEOF_ARRAY(callsigns); ++idx_callsign)
{
for (int idx_callsign2 = 0; idx_callsign2 < SIZEOF_ARRAY(callsigns); ++idx_callsign2)
{
test_std_msg(callsigns[idx_callsign], FTX_FIELD_CALL, callsigns[idx_callsign2], FTX_FIELD_CALL, grids[idx_grid], grid_types[idx_grid]);
}
}
for (int idx_token = 0; idx_token < SIZEOF_ARRAY(tokens); ++idx_token)
{
for (int idx_callsign2 = 0; idx_callsign2 < SIZEOF_ARRAY(callsigns); ++idx_callsign2)
{
test_std_msg(tokens[idx_token], token_types[idx_token], callsigns[idx_callsign2], FTX_FIELD_CALL, grids[idx_grid], grid_types[idx_grid]);
}
}
}
test_msg("CQ K7IHZ DM43", FTX_MESSAGE_TYPE_STANDARD,
"CQ K7IHZ DM43", &hash_if);
test_msg("CQ EA8/G5LSI", FTX_MESSAGE_TYPE_NONSTD_CALL,
"CQ EA8/G5LSI", &hash_if);
test_msg("EA8/G5LSI R2RFE RR73", FTX_MESSAGE_TYPE_STANDARD,
"<EA8/G5LSI> R2RFE RR73", &hash_if);
test_msg("R2RFE/P EA8/G5LSI R+12", FTX_MESSAGE_TYPE_STANDARD,
"R2RFE/P <EA8/G5LSI> R+12", &hash_if);
test_msg("TNX BOB 73 GL", FTX_MESSAGE_TYPE_FREE_TEXT,
"TNX BOB 73 GL", &hash_if); // message with 4 tokens must be free text
test_msg("TNX BOB 73", FTX_MESSAGE_TYPE_STANDARD,
"<TNX> <BOB> 73", &hash_if); // can't distinguish special callsigns from other tokens
test_msg("CQ YL/LB2JK KO16sw", FTX_MESSAGE_TYPE_NONSTD_CALL,
"CQ YL/LB2JK", &hash_if); // grid not allowed with nonstandard call
test_msg("CQ POTA YL/LB2JK KO16sw", FTX_MESSAGE_TYPE_NONSTD_CALL,
"CQ YL/LB2JK", &hash_if); // CQ modifier not allowed with nonstandard call
test_msg("CQ JA LB2JK JO59", FTX_MESSAGE_TYPE_STANDARD,
"CQ JA LB2JK JO59", &hash_if);
test_msg("CQ 123 LB2JK JO59", FTX_MESSAGE_TYPE_STANDARD,
"CQ 123 LB2JK JO59", &hash_if);
return 0;
}

Some files were not shown because too many files have changed in this diff Show More