Conversation
Improve the maximum rate at which the front-panel encoders can be turned without losing counts, and fix the display artifacts that the faster event rate exposed. Encoder servicing: - Raise the front-panel I2C bus (Wire1) from the 100 kHz default to 400 kHz. The two front-panel MCP23017s are the only devices on this bus, so every encoder/button register read is ~4x faster. - Drain the MCP23017s from a 1 ms IntervalTimer instead of polling once per main loop, decoupling encoder servicing from the display/DSP cadence so fast edges are no longer collapsed between polls. The MCP INT lines now drive flag-setting ISRs (no I2C in the ISR); the timer does the deferred I2C drain. - Run the front-panel drain below the audio update ISR (priority 208). All IntervalTimers share IRQ_PIT at the highest requested priority, so timer1ms is also lowered to 240; both now yield to audio, keeping encoder I2C bursts from delaying audio servicing. Display: - Defer the spectrum static-overlay refresh (frequency labels + tuning bar) until a chunked sweep is at a frame boundary (x1 == 0). DrawBandWidthIndicatorBar()'s fillRect clears the L2 back-buffer; with the frequency now changing every loop during a fast spin it was firing mid-sweep, wiping already-drawn chunks (blank spectrum regions) and publishing a stale/duplicated tuning bar. Also bump version to Phx V1.3.1 and add setClock()/IntervalTimer::priority() to the host test mocks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce the wiki/ knowledge base (firmware / theory / hardware / roadmap) with its maintenance schema, index, and append-only log, plus the ingested T41-EP KiCad schematics under raw/. Includes this session's work: - rf-board: pre-V12.8 single-Si5351 shared-quadrature VFO history; dHz->cHz unit correction; attenuator GPIO = atten_dB x 2 fix. - Split board *electronics* out of the firmware module pages into hardware/rf-board-electronics and hardware/filter-board-electronics; the firmware pages now describe the code/API and link across. - Lint pass (no broken links/orphans/dup basenames); flagged + noted the _dHz->_cHz rename not yet present on this branch. - Ignore Obsidian per-vault editor state (.obsidian/). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Post-merge health-check of the wiki. The _dHz→_cHz rename (65e6bba) and the doc-fix commit (c95f932) are now on encoder-i2c-speed (source: 0 _dHz / 46 _cHz), closing the contradiction an earlier lint left open. - rf-board.md: drop the "not yet present on encoder-i2c-speed" branch caveat - tune-frequency-control.md: GetTXRXFreq_dHz/GetCWTXFreq_dHz → _cHz (4 refs), rewrite the "historical misnomer" prose to past tense - documentation-todos.md: mark the VFO-unit item reconciled; update the stale- comment block from "not yet committed" to committed & merged (c95f932) - bump updated: to 2026-06-16 on the three pages; append lint entry to log.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The encoder-i2c-speed branch made fast tuning responsive enough that a quick spin enqueues a tune event nearly every loop, reprogramming the VFO each time. At speed this produced glitchy audio and a jerky, half-drawn spectrum sweep. Add a compile-time option that masks both. New MUTE_ON_RAPID_TUNE option (Config.h) with engage/release thresholds: - Detection (Loop.cpp): NoteTuneActivity(bool) is called from the four HOME-state center/fine tune cases; it latches "rapid tuning" when the inter-event gap is short and auto-clears after an idle timeout. The idle subtraction is done in uint32_t so it wraps like the Teensy millis() (the host test mock returns a wider type). Exposes IsRapidTuning / IsRapidCenterTuning / IsRapidFineTuning. - Audio (DSP.cpp): AdjustVolume() mutes the output while rapid tuning, for both encoders. The spectrum PSD is computed upstream, so the trace is unaffected. - Spectrum (MainBoard_DisplayHome.cpp): Center Tune re-centers the VFO, so the whole trace + waterfall are frozen. Fine Tune leaves the center fixed, so the trace is held but the blue filter bar + cyan marker keep tracking: a clean bar-less backdrop is built once on L2 (BuildFrozenBackdrop) and each frequency change does a single L2->L1 BTE blit + StampTuningBar (refactored out of DrawBandWidthIndicatorBar), driven on freq change rather than the 50 ms refresh timer. Adds six host unit tests (Loop_test.cpp) covering engage/release, slow tuning, center-vs-fine classification, and switching encoders mid-spin. Wiki: new firmware/rapid-tune-mute-freeze.md plus cross-links from front-panel, tune-frequency-control, display-subsystem, main-loop, and spectrum-refresh-floor; index and log updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "Sample Rate" top-level menu and full support for changing the audio
sample rate at run time, plus fixes to the DSP/display paths that assumed a
fixed 192 ksps rate.
Firmware:
- New "Sample Rate" primary menu ("192 ksps" / "176.4 ksps") and
ChangeSampleRate(): reconfigures the I2S clock, rebuilds the DSP filter
chain / AGC / CW, updates the sidetone + TX-IQ-cal oscillators, and flushes
the input queues. Compensates centerFreq_Hz for the change in the Fs/4 IF
offset and re-runs the tune so the dial frequency is preserved, then
re-programs the Si5351.
- Persist the rate in ED: SampleRate is now a reference to ED.sampleRate,
serialized in Storage.cpp, so the choice survives a reboot (restored before
audio/DSP init) and is applied automatically at boot.
- InitializeDecimationFilter now frees previous buffers before malloc, so
re-initializing at run time (a rate change) does not leak.
- Spectrum frequency labels: position ticks via FreqToBin() at round
frequencies (instead of a fixed pixel table + non-round step) so they stay
aligned with the trace at any rate, and widen the clear strip so old ticks
are wiped on zoom/tune changes.
- Audio spectrum: derive the axis span from Fs/32 instead of a hard-coded
0..6000 Hz, so tone frequencies read correctly at any rate.
- Settings pane: add a "Rate:" row (Key Type moved down one row).
Simulator:
- Make the OpenAudio mock rate-aware: test-tone carrier at Fs/4, sample pacing
and SDL output rate derived from the current rate; re-init SDL audio when the
rate changes.
Tests / docs:
- 176.4 kHz stage-response tests, dial-frequency-preservation test, and a
zoom-FFT/axis-agreement test; AudioNoInterrupts mock; menu-size externs
updated for the new primary menu.
- Add compare_176k_vs_192k.ipynb and docs/RX_DSP_Chain_Parameters.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABs1WDZPCHyEFPXZnqjCXL
Several filter stages shipped coefficient tables designed offline for one
audio sample rate (24 ksps: 192 ksps at the ADC, decimated by 8). Run at
176.4 ksps their corners and centres scaled by 176400/192000 = 0.91875, so a
CW filter labelled 2.0 kHz cut at 1.84 kHz and the 4000 Hz equaliser cell
peaked at 3675 Hz.
They are now generated from an analog design spec on every rate change, which
puts each response back on its labelled frequency at any rate. The filter
families are unchanged - what was a 12 pole Chebyshev is still a 12 pole
Chebyshev - because the design constants were recovered from the shipped
tables rather than guessed:
* The CW tables measure 0.0200 dB of passband ripple and fit cheby1(12, 0.02)
to 0.013 dB RMSE, so they are Chebyshev type I with the ripple edge as the
natural design parameter.
* Every equaliser biquad has b1 = 0 and b2 = -b0, putting its zeros at
z = +/-1. That is the bilinear image of an analog bandpass, so an inverse
bilinear transform recovers the analog prototype exactly.
code/tools/extract_filter_prototypes.py does that recovery and prints the C
literals; it reads the original tables, kept verbatim as test fixtures in
code/test/reference_filters.cpp.
Prewarping the design frequency cancels the bilinear transform's frequency
compression, which is what makes the result rate independent rather than
merely re-derived.
Also fixed while in here:
* The transmit decimate-by-2 feeding the Hilbert stage was flat to 0.425*Fs
where a decimate-by-2 needs its stopband below 0.25*Fs, so 6-9.5 kHz
folded back into the transmit audio unattenuated. Transmitted audio will
differ from previous releases even at 192 ksps.
* The AM DC blocker's pole was a fixed 0.99 and its state a file-scope static
shared across all three ReceiveFilterConfig instances.
* ApplyEQBandFilter scrubbed the receive instance's state even on the
transmit path.
Deliberately not changed: the zoom FFT tables and the transmit decimation,
interpolation and Hilbert filters. Their corners are specified as a fraction
of Fs, which is correct for anti-alias and anti-image filtering at any rate.
Verification is in code/test/FilterDesign_test.cpp, which measures each
generated filter against the table it replaces - CW audio within 0.05 dB,
equaliser cells within 0.01 dB - and then checks that every corner holds
across the two rates where it used to move by 8.125%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tsW6HGrRthm1is6U8tFYG
…r test
The filters made rate independent in the previous commit were only verified in
simulation. This adds a suite that measures them on the actual radio, and the
CAT commands it needs to drive settings that were previously touchscreen-only.
New CAT commands (NUM_SUPPORTED_COMMANDS 25 -> 29):
SR SRn; / SR; sample rate, 0 = 176.4k, 1 = 192k
CF CFn; / CF; receive CW audio filter index 0-5
EQ EQbbvvv; / EQbb; receive equaliser cell bb = 00-13, level 000-100
FL FL####; / FL; filter low cut, the mirror of FW
Note for anyone adding more: command_parser tests the write form first, so
set_len and read_len must differ or the read function is unreachable.
Three fixes to existing commands, all of which affect CAT users today:
* MD_write set bands[].mode but never ED.modulation[]. Demodulate() switches
on the latter and InitFilterMask() compares the two, treating a difference
as a deliberate departure from the band default and *mirroring the
passband*. So MD did not merely fail to change the demodulator - it
inverted the receive filter.
* MD1/MD2 now dispatch TO_SSB_MODE when in CW receive. That transition
existed only on the front-panel button, so CAT could enter CW and never
leave.
* FW declared set_len == read_len, making FW; unreachable and the filter
bandwidth write-only. MD4 now selects AM, which was previously unreachable
(MD5 gives SAM).
The suite lives in code/tools/filter_hil/. It drives the AD2's W1/W2 into the
I/Q receive inputs, reads the demodulated audio off the speaker, and compares
each filter's position at both sample rates. Every response is measured as the
difference between two captures at the same injected frequency - filter engaged
and bypassed - so the AWG, codec, decimation rolloff, volume and speaker
amplifier all cancel. The SSB filter, which always derived its coefficients
from the true sample rate, is measured as a control on the method.
The injection frequency is the part worth knowing about. ReceiveProcessing
shifts twice, by Fs/4 and then by the fine tune, so the tone that demodulates
to DC sits at |Fs/4 + fineTuneFreq_Hz|. The fine tune is whatever the operator
last tuned to and is routinely several kilohertz - far wider than the passband
- so leaving it out produces silence at any drive level, which is
indistinguishable from a dead rig.
Measured on the bench, 95 checks passed and none failed. Every CW corner and
equaliser centre holds to within 0.3% across the rate change where the frozen
tables moved them by 8.125%; the SSB control reads 0.04%.
code/tools/filter_hil/test_filter_hil.py covers the measurement maths without
hardware, including a check that the comparison fails when handed a simulated
8.125% shift - without that, a suite that quietly passed everything would look
identical to a working radio.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tsW6HGrRthm1is6U8tFYG
The post-commit hook wrote this file after the commit was made, so it was left permanently modified in the working tree and a stale stamp got swept into whatever was committed next. It is now written and staged before the commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tsW6HGrRthm1is6U8tFYG
The pre-commit hook restamps BuildInfo.h on every commit, so every branch
touches it and any merge or rebase between two of them conflicts on it. A
rebase across a one-commit side branch was enough to trigger it.
The file is derived, never authored, so taking either side is always correct -
the next commit restamps it regardless. merge=ours makes git do that silently
instead of stopping.
The driver has to be defined once per clone, which the file explains:
git config merge.ours.driver true
Without it git falls back to a normal merge and the conflicts come back, so
this degrades to the current behaviour rather than to anything worse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tsW6HGrRthm1is6U8tFYG
The BuildInfo.h stamping hook lived in .git/hooks, which git does not clone, so it existed only on the machine it was written on. It now lives in hooks/ with an installer that points core.hooksPath at it and defines the merge driver the .gitattributes entry needs. Git has no way to install hooks automatically on clone, so ./hooks/install.sh has to be run once per working copy. CONTRIBUTING.md gains a Repository Setup section saying so, and explaining why the recorded hash is the parent commit rather than the commit itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tsW6HGrRthm1is6U8tFYG
Brings the fast encoder handling, the dHz->cHz frequency unit rename, the rapid-tune audio mute / spectrum freeze option and the LLM-maintained wiki together with the runtime-switchable sample rate, run-time filter generation and CAT DSP control work. Conflicts resolved: - SDT.h: version bumped to Phx V1.4.0 (supersedes both 1.3.1 and 1.3.2). - MainBoard_DisplayHome.cpp: comment-only clash - kept the EffectivePassbandEdges_Hz() helper alongside fast_encoder's updated StampTuningBar() docstring. Also updated the CWOptions sidetone-volume tests, which had been left behind by the earlier sidetone volume fix (range is 0-500 step 1 with an UpdateSidetoneOscillator post-update callback, not 0-100 step 0.5). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wiki was written entirely on fast_encoder, so it knew nothing of the
sample-rate work that rx-dsp-176k-stage-test brought into the merge.
Three new pages:
firmware/sample-rate-switching 192 / 176.4 ksps at run time, persisted.
ChangeSampleRate() step by step, and why
centerFreq_Hz has to be compensated for the
change in the Fs/4 IF offset before
SampleRate is updated.
firmware/runtime-filter-design Why the Hz-specified filters had to be
regenerated first, how the analog prototypes
were recovered from the shipped tables, and
why prewarping is what makes the result rate
independent rather than merely re-derived.
firmware/filter-hil-test The bench method, the |Fs/4 + fineTune|
injection trap, and the AGC-off requirement.
theory/multirate-decimation said the runtime design path's rate independence
was "dormant - SampleRate is set once and never reassigned anywhere in the
firmware". That was true on fast_encoder and is now false. Corrected in place
with a date, and the corresponding clause of the 2026-06-14 Resolved entry
marked superseded rather than deleted, per the wiki's contradiction rule. The
heritage explanation for the RX/TX asymmetry still stands.
Two open questions closed by the new code: the 14 equaliser band centre
frequencies, which the generator exposed as named constants, and the rate the
CW decoder runs at. The second turned up an error in the wiki itself - it had
quoted the stale "/ 24000" comment at DSP_CWProcessing.cpp:64 as if it were the
code, when the line below it divides by the live rate.
19 other pages updated, the largest being cat-control: 25 -> 29 commands, the
new SR/CF/EQ/FL group, and the three pre-existing bugs, including MD, which did
not merely fail to change the demodulator but mirrored the receive passband.
Not answered anywhere in the tree: why 176.4 ksps was added. Left as an owner
question rather than guessed at.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tx_filter_hil/ is the transmit-side companion to filter_hil/: it drives the
microphone input with a tone and captures both exciter outputs synchronously,
so I + jQ reconstructs the two-sided spectrum. That gives the audio response
and the opposite-sideband suppression from one capture; two single-channel
captures would have no defined phase relationship and could not measure
suppression at all.
The tolerance is 2.5 %, looser than the receive suite's 1.5 %, and deliberately
so. Both generated transmit stages are 48-tap Kaiser-Bessel windowed sincs, and
a 48-tap design does not scale exactly when its normalised cutoff changes:
evaluating the tap sets gives a cascade corner of 2726 Hz at 24 ksps and
2759 Hz at 22.05 ksps, +1.2 % on a correct radio. The regression being hunted
is still -8.125 %, so there is better than a factor of three between correct
and broken. A tolerance tighter than the design's own reproducibility would
cry wolf.
What it cannot do is recorded in its README rather than glossed: nothing in the
transmit chain can be bypassed over CAT, so every response is a composite of
the whole path. That costs absolute accuracy but nothing in the rate
comparison, since no part of the analog path changes when the rate does.
Also in this commit:
flag_timing.py Captures the Flag() pin transitions on AD2 digital
plot_flag_timing.py inputs and reports where the firmware spends its time.
JSON on stdout, PNGs from the plotter.
siglent_capture.py Pulls traces off the networked scope over VISA.
usb_audio_test.py Tone into a host audio device.
filter_hil/radio.py SILENT_SHORT_WRITES, because TX;/RX; are writes that
answer nothing and a length-based read/write inference
otherwise blocks for its full timeout on every key.
serial_diag.py carried three bugs, all found by checking it against CAT.cpp
rather than by running it. It sent "TX1;" to key, which the firmware rejected,
so the tool printed "TX ON" at a radio sitting in receive; success was assumed
for every command rather than tested, which is what let that go unnoticed; and
in single-port mode a semicolon-terminated line arriving unsolicited stayed in
the buffer and was handed back as the response to the next command. Note that
an accepted write is frequently silent - TX_write and RX_write both return an
empty string - so "?;" is the only failure signal available and the obvious
"no reply means it failed" test would have been backwards.
The tools README described only serial_diag.py and told the reader to enable
USB_AUDIO_RX_DIAGNOSTICS in usb_audio_48k.cpp, a file that exists only on the
unmerged usb_audio branch. Rewritten to cover every tool in the tree, with the
dead instrumentation labelled as such instead of silently dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9L3rdDh97Ywei8kr3YC6j
ts_480_pc.pdf p.21 defines the TX set form as "TX P1;" where P1 is 0 (normal
transmission using the MIC input), 1 (DTS transmission using the ANI input) or
2 (TX Tune), and states that "if no P1 parameter is specified, P1=0 is used".
So TX;, TX0;, TX1; and TX2; are all legal ways to key. Phoenix accepted only
TX;, because the table entry was { "TX", 3, 0, ... } and command_parser
dispatches purely on where the semicolon falls.
That is an interoperability bug, not a pedantic one. Hamlib - the layer WSJT-X,
fldigi and Pat all drive the radio through - picks the form from the PTT type:
TX; for RIG_PTT_ON, TX0; for RIG_PTT_ON_MIC, TX1; for RIG_PTT_ON_DATA, RX; to
unkey. Which one a given user sends depends on their backend and data-mode
settings, so the radio keyed for some clients and silently refused for others.
It was noticed because serial_diag.py sent TX1; and got "?;" back.
The idiom for an optional parameter is the *write* function in both slots:
set_len covers the parameterised form, read_len the bare one. There is
deliberately no TX_read - adding one would make TX; read instead of key, which
would break RIG_PTT_ON and both HIL suites - and a comment now says so, because
the read column holding a write function otherwise looks like a mistake worth
"fixing". TX_write ignores P1, so every form keys identically; the radio has
neither a separate data input nor a tune mode to distinguish them. RX gets the
same treatment and so now also accepts the TS-2000 style RX0;/RX1;.
The same change closes a latent defect in command_parser, which evaluated
command[set_len - 1] and command[read_len - 1] unconditionally. Nine entries
carry a 0 in one of those fields, so the expression was command[-1], reading
off the front of the static catCommand[128]. It never crashed, but ID;, IF;,
PD;, ED; and PR; reached their read handlers only because the byte that happens
to precede that buffer is not a semicolon. Both tests are now guarded with > 0.
Worth recording how this survived: CAT_test.cpp already asserted that TX
accepted TX;, TX0; and TX1;. It called TX_write() directly, and TX_write
ignores its argument, so the assertion held while the dispatcher rejected two
of the three forms. The suite passed for the entire life of the bug. The tests
added here go through command_parser(), and were confirmed failing before the
fix. 745/745 pass after it, and all four TX forms were verified keying real
hardware, with IF P8 read back to confirm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9L3rdDh97Ywei8kr3YC6j
New page firmware/tx-filter-hil-test for the transmit suite, and the finding that bounds it: the 48-tap FIR transmit stages are only rate-independent to about 1.2 %, against 0.3 % for the IIR receive stages. That is a property of re-quantising a 48-tap design onto the same 48 positions at a different normalised cutoff, not a defect, but it sets a floor under any rate-invariance tolerance and so belongs in runtime-filter-design, multirate-decimation and filter-hil-test as well. filter-hil-test claimed 0.3 % for receive rate invariance. That holds only once the edge-limited equaliser cells are set aside - and the three worst cells are exactly the three the suite already flags as edge limited, which corroborates that classification rather than undermining it. Replaced the prose with a table that says which figure applies to what. cat-control gains the TS-480 TX parameter finding. Its "TX; and RX; are parameterless writes" warning was half wrong once TX accepted TXn;, but the useful half - that they are writes whose empty reply command_parser suppresses, so a client inferring "3 characters means read" blocks for its full read timeout - is kept. Two things recorded alongside the fix: that Hamlib's TX1; is the standard "transmit from the data port" signal, which may remove the need for the non-Kenwood UM; command when usb-audio (#13) resumes; and that the test suite passed throughout the bug because it exercised the handler rather than the dispatcher. The open question about reconciling TS-480 and TS-2000 is narrowed rather than closed: TX and RX have now been checked against p.21 and were wrong, so the rest of the table should be assumed unaudited rather than assumed correct. Also in README.md: the V1.3 and V1.4.0 release notes, which were missing; code/Contributors.txt corrected to code/src/PhoenixSketch/Contributors.txt; the non-existent TuneSm removed from the architecture section, since the tune state is HandleTuneState in HardwareSm.cpp; "160m to 6m" corrected to 160m to 4m plus general coverage; the four auto-calibration routines and the selectable sample rate added to the feature list; and the ctest sample output refreshed from a real run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T9L3rdDh97Ywei8kr3YC6j
The skill hardcoded the FQBN in both of its commands and then, in prose, told
the reader to prefer code/.vscode/arduino.json if the two disagreed. They did
disagree: the skill said speed=600,opt=o1lto and the file says
speed=528,opt=o2lto, so following the skill literally built a binary at the
wrong clock with different optimisation flags. It now reads board,
configuration, sketch, output and port out of the file with jq and echoes them,
so there is nothing left to drift and the values used are visible in the
transcript.
The upload step never worked. The Teensy loader cannot resolve relative paths,
which produces two different and equally unhelpful errors:
upload --input-dir ../ArduinoOutput [sketch] "Teensy Loader could not find
the file PhoenixSketch.ino"
upload --input-file ../ArduinoOutput/...hex "Teensy Loader is unable to
read your compiled sketch"
upload --input-file /abs/path/...hex works
compile --upload -p PORT works
The main path is now compile --upload, which arduino-cli serves from its build
cache, with the absolute-path --input-file form documented for re-flashing
without a recompile. All four rows above were run against the radio rather than
reasoned about, and the table is in the skill so nobody re-derives it.
Added the things that otherwise read as failures: the "Teensy should be
selected from teensy ports" warning prints on every upload and means nothing,
board list shows the ACM ports as Unknown, the tree builds with many
pre-existing -Wunused warnings, and the auto-reboot into the bootloader is
unreliable, so the PROGRAM button may be needed - tell the user rather than
retrying silently.
Deleted the flash skill, which did the same job with the same stale FQBN and
the same broken upload, plus a --libraries lib pointing at a directory that
does not exist in this tree. Two skills with near-identical descriptions also
left the choice between them to chance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9L3rdDh97Ywei8kr3YC6j
A third operating mode alongside SSB and CW. In receive the demodulated audio is
streamed to the host over USB audio (and still to the speaker); in transmit the
audio source is the host instead of the microphone.
The rate coincidence this rests on: Teensy USB audio is a fixed 44.1 kHz endpoint,
and at 176.4 ksps the receive chain's Fs/4 tap IS 44,100 Hz, one DSP block there is
exactly 512 samples, and the audio library's graph clock is exactly 4x the USB
audio block rate. So DIGITAL mode forces 176.4 ksps and needs no resampler and no
modified Teensy core file. The rate switch lives on the enter/exit actions of a
DIGITAL_STATES composite in ModeSm, which is what makes it restore on every exit
path including calibration.
USB transport reuses the stock AudioInputUSB/AudioOutputUSB (what OpenAudio's
USB_Audio_F32.h wraps), keeping all the core descriptor, DMA, ISR and isochronous
feedback code. They cannot go in the AudioConnection graph - it is clocked by I2S,
4x too fast - so they are taken off update_all() via AudioStream::active and driven
by a 4:1 pacer.
Also fixes a pre-existing sideband-selection bug, unrelated to digital mode but
found while setting up the bench test. SetModulation() wrote bands[].mode, which is
the band *default* and the reference InitFilterMask() measures its passband
mirroring against. Making it agree with the requested modulation switched the
mirroring off, so on 40 m "MD2;" (select USB) tuned the radio to LSB while the
display, which reads ED.modulation[], went on saying USB. MD_read/IF_read read the
same field and so reported the band default rather than the mode in use.
CAT_test's MD_write_SetsModulationNotJustBandMode had asserted that behaviour and
passed for its whole life; it is rewritten to assert the effective passband.
New CAT commands: DG (enter/leave digital mode), DR (receive level), DS (transmit
path counters, read-only). New settings ED.digitalDriveLevel and ED.digitalRxLevel,
both persisted with menu entries.
Verified on hardware, both directions:
RX 0 dropouts in 60 s, H2 -82 dB, noise floor -135 dB, no drift over 10 min
(|drift| < ~5 ppm, so no clock compensation is needed)
TX sideband sense flips correctly (USB -90.0 deg / +41.0 dB image rejection,
LSB +90.1 deg / -41.2 dB), imbalance -0.16 dB, IMD3 -51 dB, envelope ripple
0.08 dB, spur floor -95 dBc, stable across a sustained key-down
Two buffering bugs were found and fixed during that testing. The play queue was
never prefilled, giving 147 dropouts per 12 s of receive. The transmit record queue
was begun for all of digital mode but only drained while transmitting, so it sat at
AudioRecordQueue's 209-block ceiling and held ~418 of the 500 audio blocks; bounding
it in the pacer improved transmit spurs by 47 dB and receive distortion and noise
floor by 17.5 and 16 dB.
Build note: digital mode requires usb=serialmidiaudio. Teensyduino has no stock
"Dual Serial + Audio" type, so that build has one CDC port - CAT moves to the
primary Serial via the new CATSerial alias and Debug() compiles out. CAT clients
must be repointed. code/.vscode/arduino.json is untracked in this repo, so that
setting is NOT carried by this commit and has to be set locally.
Tests: 776 pass (745 before, 31 new). Both usb=serialmidiaudio and usb=serial2
firmware configurations compile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9L3rdDh97Ywei8kr3YC6j
transmit_test.py existed only on the unmerged transmit_audio_purity branch, so the transmit-test skill that has been tracked all along invoked a file no clone contained. It comes back at the path the skill already documents. receive_chain_test.py and the receive-test and transmit-filter-test skills were untracked working-tree files alongside the tools they drive. code/test/transmit/ holds ~313 MB of bench captures, and the tool writes more of them into whatever directory it runs from, so that directory is ignored except for the script itself. Same for code/tools/venv/, ArduinoOutput_DMA/ and the stray txiq_log*.txt files: large, machine-local, regenerable. Both new tools are documented in code/tools/README.md, transmit_test.py with a note that it is the one bench tool living outside that directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tests that walk the transmit stages the way TransmitProcessing() does - DigitalTxChain() entering at Fs/4 as ReadUSBTransmitBuffer() hands it over, MicTxChain() entering at Fs with TXDecimateBy4 in series. The bench measured a ~2.8 dB spread over 200-2200 Hz on the exciter I/Q at 176.4 ksps, and modelling the filters accounted for only ~0.6 dB of it plus 1.9 dB from BandEQ. Running the chain with no codec, no analog path and no instrument separates the DSP's contribution from everything downstream, so whatever the bench sees on top of this is not the DSP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The V1.4.0 release notes predated digital mode entirely: no feature entry, the CAT table still at 29 commands rather than 32, the test count at 740, and the build instructions naming Dual Serial without saying that digital mode needs Serial + MIDI + Audio and that this leaves one CDC port with CAT on it. Three bug fixes were missing too - the bands[].mode sideband inversion, the optional TX; parameter, and the two digital-mode buffering fixes. Memory figures and the ctest sample output are re-measured rather than inherited. The wiki had the mirror-image gap. digital-mode.md was written and the pages that mention digital mode by name were updated, but four pages documenting code the same commit edited were not: hardware-state-machine (the DIGITAL_STATES composite in both switch tables, which digital-mode.md itself calls the load-bearing detail), persistent-config (the two new ED fields), overview (USBAudio.cpp, the ModeSm role) and development-backlog, which still listed #13 as not yet wired in and sequenced behind #14. Verified while checking: 779/779 unit tests pass, and both firmware configurations compile clean at 600 MHz o1lto - usb=serial2 and usb=serialmidiaudio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pane became large if the fine tune encoder was turned while the radio was in CW mode with decoder enabled. Thanks to Rick Price KN4AIE for reporting the bug!
With the serial+midi+audio USB type there is only one CDC port, so CATSerial is Serial and CheckForCATSerialEvents() drains it before CheckForSerialTimeSync() ever runs. The PJRC time packet was therefore swallowed whole: the clock was never set, and because the packet has no ';' the orphaned bytes stayed in catCommand and prefixed the next real command, which came back "?;". Confirmed on the radio - a "T<stamp>\n" followed by "FA;" answered "?;" instead of the frequency. The packet format is a PJRC standard and is unchanged on the wire. What changes is which reader sees it. CheckForCATSerialEvents() now treats a newline terminator as proof the bytes are not CAT traffic, since no Kenwood command contains one: a 'T' prefix hands the digits to the new shared ApplyTimeSyncDigits(), and anything else newline-terminated is discarded instead of corrupting the command after it. That second half also fixes CAT clients that end their commands with CRLF. CheckForSerialTimeSync() keeps its own byte loop for two-port builds but now shares the same validation, so both USB types accept exactly the same bytes. It is no longer called under AUDIO_INTERFACE, where it could only steal the tail of a packet the CAT reader is already handling. The "Time set:" confirmation is suppressed there too - on a shared port it would land inside the CAT client's response stream, the same reason Debug() is stubbed out in those builds. ApplyTimeSyncDigits() also checks every character is a digit. atoll() accepts a leading sign and stops at the first non-digit, which mattered little when only the dedicated reader could reach it but matters now that the CAT reader hands over arbitrary newline-terminated bytes. Verified on the radio in both USB types: the clock sets, and CAT answers normally immediately afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016evrfRjUh8P1mviUCdXNJM
set_radio_time.py (Python 3 + pyserial, so Linux/macOS/Windows) and Set-RadioTime.ps1 (Windows PowerShell 5.1, no modules needed, for operators who would rather not install Python). Both send the standard PJRC time packet and find the radio by PJRC's USB vendor ID, and both align transmission to a second boundary so the radio's seconds tick with the PC's. The radio applies no time-zone offset of its own: the timestamp goes straight into TimeLib and the display reads hour()/minute()/second() back out of it, so it shows the UTC decomposition of whatever number it is given. MY_TIMEZONE in Config.h is only a label - "EST: " is pasted in front of the digits as a string and shifts nothing. So the scripts send epoch + the local UTC offset by default, taken from the OS time-zone database so DST is handled, and --utc/-Utc sends a true UTC stamp for anyone running MY_TIMEZONE "UTC: ". --offset/-Offset overrides the zone and --list/-List shows candidate ports. The README explains the offset question with a table mapping the Config.h setting to the right invocation, and notes that this clock is cosmetic - the display is its only consumer, and WSJT-X takes its timing from the PC - so nobody mistakes it for an FT8 decode fix. The Python script is tested against the radio in both USB types, including the offset forms, the argument errors, a missing port, and the suppressed confirmation on a shared port. The PowerShell script is UNTESTED: there is no PowerShell on the development machine, so it has only been reviewed by inspection. It needs a run on Windows before anyone relies on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016evrfRjUh8P1mviUCdXNJM
Under Set-StrictMode a single object returned by Get-TeensyPort/Get-AnyPort has no .Count property in PS 5.1, raising PropertyNotFoundStrict before any packet is sent. Wrap both in @() so .Count works for 0, 1, or many ports. Tested on PS 5.1 / Win 11.
Fix Set-RadioTime.ps1 .Count error on Windows PowerShell 5.1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Merge in V1.4RC, which adds variable sample rates and digital audio over USB for FT8 and other digital modes.