From 744d61d0734f6e377368cc6b2d299c9610e414cd Mon Sep 17 00:00:00 2001 From: Oliver King Date: Sat, 30 May 2026 13:51:52 -0400 Subject: [PATCH 1/7] Raise spectrum frame rate to 15.6 fps (NCHUNKS=6 + Pass-2 throttle) The spectrum redraw was capped at ~11.7 fps (NCHUNKS=8): a frame draws one chunk per loop iteration, and each iteration must process one audio block within the ~10.67 ms real-time budget. Lowering NCHUNKS overran that budget because the per-frame draw work (~57 ms) was too large. Two changes free enough budget to run NCHUNKS=6 (15.6 fps) with the receive chain unaffected (SNR 30.4 dB, no dropouts, verified on the bench): - Generalize the chunk arithmetic to rounded column boundaries so any NCHUNKS (including non-divisors of 512 such as 6 or 7) sums to exactly 512 and the completion check still fires. - Throttle Pass 2 (the audio-spectrum bars + DisplaydbM S-meter, ~13 ms/frame, a separate low-priority display element) to every AUDIO_SPECTRUM_DECIMATE-th frame. The waterfall colour computation is NOT throttled. Tuned SPECTRUM_REFRESH_MS=60 to sit on the new 64 ms floor (R-sweep at NCHUNKS=6). Tradeoff: the audio-spectrum display and S-meter update at ~7.8 fps (half the main spectrum/waterfall rate). To revert: AUDIO_SPECTRUM_DECIMATE=1, NCHUNKS=8. Co-Authored-By: Claude Opus 4.8 --- .../PhoenixSketch/MainBoard_DisplayHome.cpp | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/code/src/PhoenixSketch/MainBoard_DisplayHome.cpp b/code/src/PhoenixSketch/MainBoard_DisplayHome.cpp index 52d8baf..ac9f9a9 100644 --- a/code/src/PhoenixSketch/MainBoard_DisplayHome.cpp +++ b/code/src/PhoenixSketch/MainBoard_DisplayHome.cpp @@ -42,7 +42,7 @@ If not, see . // External references to objects and variables defined in MainBoard_Display.cpp extern RA8875 tft; -#define SPECTRUM_REFRESH_MS 100 +#define SPECTRUM_REFRESH_MS 60 // Shared display state variables bool redrawParameter = true; @@ -427,7 +427,7 @@ uint16_t FILTER_PARAMETERS_Y = PaneSpectrum.y0+1; // Spectrum data buffers uint16_t pixelold[MAX_WATERFALL_WIDTH]; uint16_t waterfall[MAX_WATERFALL_WIDTH]; -#define NCHUNKS 8 +#define NCHUNKS 6 // S-meter constants (used by DisplaydbM function within spectrum rendering) #define SMETER_X PaneSMeter.x0+20 @@ -615,6 +615,9 @@ void DisplaydbM() { // State tracking for spectrum line rendering static int16_t x1 = 0; +static int16_t spectrumChunkIdx = 0; // which chunk of NCHUNKS is being drawn this sweep +static uint8_t audioSpectrumFrameCtr = 0; // throttles the audio-spectrum + S-meter redraw +#define AUDIO_SPECTRUM_DECIMATE 2 // draw the audio spectrum / S-meter every Nth spectrum frame static int16_t y_left; static int16_t offset = (SPECTRUM_TOP_Y+SPECTRUM_HEIGHT-ED.spectrumNoiseFloor[ED.currentBand[ED.activeVFO]]); static int16_t y_current = offset; @@ -656,6 +659,7 @@ FASTRUN int16_t pixelnew(uint32_t i){ */ FASTRUN void ShowSpectrum(void){ // Sweep-start: prime L2 with a fresh spectrum surface + current filter bar. + if (x1 == 0) spectrumChunkIdx = 0; // keep chunk index synced with x1 in every mode if (x1 == 0 && modeSM.state_id != ModeSm_StateId_SSB_TRANSMIT) { tft.writeTo(L2); DrawBandWidthIndicatorBar(); // its opening fillRect clears the spectrum body (y >= top+20) @@ -665,10 +669,16 @@ FASTRUN void ShowSpectrum(void){ } int16_t x1_start = x1; + // Column boundary for this chunk. Using rounded boundaries (rather than a fixed + // MAX_WATERFALL_WIDTH/NCHUNKS step) makes the chunks always sum to exactly + // MAX_WATERFALL_WIDTH even when NCHUNKS does not divide it evenly (e.g. NCHUNKS=6 or 7). + int16_t x1_end = (int16_t)(((int32_t)(spectrumChunkIdx + 1) * MAX_WATERFALL_WIDTH) / NCHUNKS); + if (x1_end > MAX_WATERFALL_WIDTH) x1_end = MAX_WATERFALL_WIDTH; + spectrumChunkIdx++; // Pass 1 - spectrum trace on L2 back buffer (no per-bin erase). tft.writeTo(L2); - for (int j = 0; j < MAX_WATERFALL_WIDTH/NCHUNKS; j++){ + for (; x1 < x1_end; ){ y_left = y_current; y_current = offset - pixelnew(x1); // offset is line on screen where -124 dBm is located if (ED.spectrumFloorAuto && y_current > pixelmax) pixelmax = y_current; @@ -685,10 +695,14 @@ FASTRUN void ShowSpectrum(void){ } // Pass 2 - audio spectrum + waterfall colour on L1 (preserves baseline post-increment indexing). + // The small audio-spectrum + S-meter redraw is a separate, low-priority display element costing + // ~13 ms/frame, so it is throttled to every AUDIO_SPECTRUM_DECIMATE-th frame to free real-time + // budget for the main spectrum/waterfall. The waterfall colour computation is NOT throttled. tft.writeTo(L1); if (modeSM.state_id != ModeSm_StateId_SSB_TRANSMIT){ + bool drawAudioSpectrum = (audioSpectrumFrameCtr % AUDIO_SPECTRUM_DECIMATE) == 0; for (int16_t xb = x1_start + 1; xb <= x1; xb++){ - if (xb < 128) { + if (drawAudioSpectrum && xb < 128) { tft.drawFastVLine(PaneAudioSpectrum.x0 + 2 + 2*xb, PaneAudioSpectrum.y0+2, AUDIO_SPECTRUM_BOTTOM-PaneAudioSpectrum.y0-3, RA8875_BLACK); if (audioYPixel[xb] > 2) { @@ -702,7 +716,7 @@ FASTRUN void ShowSpectrum(void){ audioYPixel[xb] - 2, RA8875_MAGENTA); } } - if (xb == 128){ + if (drawAudioSpectrum && xb == 128){ audioMaxSquaredAve = .5 * GetAudioPowerMax() + .5 * audioMaxSquaredAve; DisplaydbM(); } @@ -720,6 +734,7 @@ FASTRUN void ShowSpectrum(void){ y_current = offset; psdupdated = false; redrawSpectrum = false; + audioSpectrumFrameCtr++; // advance the audio-spectrum throttle counter once per frame if (modeSM.state_id == ModeSm_StateId_SSB_TRANSMIT) return; // don't do the rest of these steps in transmit mode From d15b17e298b52ea5baf64414c698fa39a5352532 Mon Sep 17 00:00:00 2001 From: Oliver King Date: Sat, 30 May 2026 14:18:06 -0400 Subject: [PATCH 2/7] Half-rate waterfall -> NCHUNKS=5, 18.8 fps spectrum Throttling the waterfall scroll (the ~8 ms full-buffer BTE_move that shifts the waterfall one line) to every other frame frees enough real-time budget to drop NCHUNKS from 6 to 5, raising the spectrum trace from 15.6 to ~18.8 fps with the receive chain still clean (SNR 29.5 dB, no dropouts). - Replace the audio-only frame counter with a single per-frame counter (spectrumFrameCtr), incremented at sweep-start so both throttles see a stable per-frame value. - Gate the waterfall scroll BTE + writeRect on WATERFALL_DECIMATE, phase-offset from the audio-spectrum throttle so the two heavy ops fall on alternate frames. - NCHUNKS=5, SPECTRUM_REFRESH_MS=50 (sits on the new ~53 ms floor). Tradeoffs: the waterfall and the audio-spectrum/S-meter both update at ~9.4 fps (half the spectrum rate), and the spectrum trace cadence has more jitter (~43-63 ms/frame) because throttled frames carry uneven load. To soften: raise SPECTRUM_REFRESH_MS, or set NCHUNKS=6 / WATERFALL_DECIMATE=1 to revert to the smooth 15.6 fps config. Co-Authored-By: Claude Opus 4.8 --- .../PhoenixSketch/MainBoard_DisplayHome.cpp | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/code/src/PhoenixSketch/MainBoard_DisplayHome.cpp b/code/src/PhoenixSketch/MainBoard_DisplayHome.cpp index ac9f9a9..5a82284 100644 --- a/code/src/PhoenixSketch/MainBoard_DisplayHome.cpp +++ b/code/src/PhoenixSketch/MainBoard_DisplayHome.cpp @@ -42,7 +42,7 @@ If not, see . // External references to objects and variables defined in MainBoard_Display.cpp extern RA8875 tft; -#define SPECTRUM_REFRESH_MS 60 +#define SPECTRUM_REFRESH_MS 50 // Shared display state variables bool redrawParameter = true; @@ -427,7 +427,7 @@ uint16_t FILTER_PARAMETERS_Y = PaneSpectrum.y0+1; // Spectrum data buffers uint16_t pixelold[MAX_WATERFALL_WIDTH]; uint16_t waterfall[MAX_WATERFALL_WIDTH]; -#define NCHUNKS 6 +#define NCHUNKS 5 // S-meter constants (used by DisplaydbM function within spectrum rendering) #define SMETER_X PaneSMeter.x0+20 @@ -616,8 +616,9 @@ void DisplaydbM() { // State tracking for spectrum line rendering static int16_t x1 = 0; static int16_t spectrumChunkIdx = 0; // which chunk of NCHUNKS is being drawn this sweep -static uint8_t audioSpectrumFrameCtr = 0; // throttles the audio-spectrum + S-meter redraw +static uint16_t spectrumFrameCtr = 0; // increments once per spectrum frame; drives the throttles #define AUDIO_SPECTRUM_DECIMATE 2 // draw the audio spectrum / S-meter every Nth spectrum frame +#define WATERFALL_DECIMATE 2 // scroll the waterfall every Nth frame (phase-offset from audio) static int16_t y_left; static int16_t offset = (SPECTRUM_TOP_Y+SPECTRUM_HEIGHT-ED.spectrumNoiseFloor[ED.currentBand[ED.activeVFO]]); static int16_t y_current = offset; @@ -659,7 +660,7 @@ FASTRUN int16_t pixelnew(uint32_t i){ */ FASTRUN void ShowSpectrum(void){ // Sweep-start: prime L2 with a fresh spectrum surface + current filter bar. - if (x1 == 0) spectrumChunkIdx = 0; // keep chunk index synced with x1 in every mode + if (x1 == 0) { spectrumChunkIdx = 0; spectrumFrameCtr++; } // new sweep: reset chunk idx, advance frame counter if (x1 == 0 && modeSM.state_id != ModeSm_StateId_SSB_TRANSMIT) { tft.writeTo(L2); DrawBandWidthIndicatorBar(); // its opening fillRect clears the spectrum body (y >= top+20) @@ -700,7 +701,7 @@ FASTRUN void ShowSpectrum(void){ // budget for the main spectrum/waterfall. The waterfall colour computation is NOT throttled. tft.writeTo(L1); if (modeSM.state_id != ModeSm_StateId_SSB_TRANSMIT){ - bool drawAudioSpectrum = (audioSpectrumFrameCtr % AUDIO_SPECTRUM_DECIMATE) == 0; + bool drawAudioSpectrum = (spectrumFrameCtr % AUDIO_SPECTRUM_DECIMATE) == 0; for (int16_t xb = x1_start + 1; xb <= x1; xb++){ if (drawAudioSpectrum && xb < 128) { tft.drawFastVLine(PaneAudioSpectrum.x0 + 2 + 2*xb, PaneAudioSpectrum.y0+2, @@ -734,7 +735,6 @@ FASTRUN void ShowSpectrum(void){ y_current = offset; psdupdated = false; redrawSpectrum = false; - audioSpectrumFrameCtr++; // advance the audio-spectrum throttle counter once per frame if (modeSM.state_id == ModeSm_StateId_SSB_TRANSMIT) return; // don't do the rest of these steps in transmit mode @@ -757,19 +757,24 @@ FASTRUN void ShowSpectrum(void){ SPECTRUM_LEFT_X, SPECTRUM_TOP_Y + 20, 2, 1); while (tft.readStatus()) ; - static int ping = 1, pong = 2; - tft.BTE_move(WATERFALL_LEFT_X, FIRST_WATERFALL_LINE, MAX_WATERFALL_WIDTH, MAX_WATERFALL_ROWS - 2, WATERFALL_LEFT_X, FIRST_WATERFALL_LINE + 1, ping, pong); - while (tft.readStatus()) ; - if(ping == 1) { - ping = 2; - pong = 1; - tft.writeTo(L2); - } else { - ping = 1; - pong = 2; - tft.writeTo(L1); + // EXPERIMENT: scroll the waterfall only every WATERFALL_DECIMATE-th frame (phase-offset + // from the audio-spectrum throttle so the two heavy ops fall on alternate frames). The + // waterfall therefore advances at spectrum_rate / WATERFALL_DECIMATE. + if ((spectrumFrameCtr % WATERFALL_DECIMATE) == 1) { + static int ping = 1, pong = 2; + tft.BTE_move(WATERFALL_LEFT_X, FIRST_WATERFALL_LINE, MAX_WATERFALL_WIDTH, MAX_WATERFALL_ROWS - 2, WATERFALL_LEFT_X, FIRST_WATERFALL_LINE + 1, ping, pong); + while (tft.readStatus()) ; + if(ping == 1) { + ping = 2; + pong = 1; + tft.writeTo(L2); + } else { + ping = 1; + pong = 2; + tft.writeTo(L1); + } + tft.writeRect(WATERFALL_LEFT_X, FIRST_WATERFALL_LINE, MAX_WATERFALL_WIDTH, 1, waterfall); } - tft.writeRect(WATERFALL_LEFT_X, FIRST_WATERFALL_LINE, MAX_WATERFALL_WIDTH, 1, waterfall); tft.writeTo(L1); } } From b63f090a07dde194aa8dc879e6149babce3013c8 Mon Sep 17 00:00:00 2001 From: Oliver King Date: Mon, 1 Jun 2026 07:15:14 -0400 Subject: [PATCH 3/7] Add serial time sync over USB Serial port 0 Accepts PJRC-format time packets (T<10-digit-unix-ts><\n|\r>) on Serial and sets the Teensy hardware RTC plus the TimeLib software clock so hour()/minute()/second() stay accurate. Lets a host PC keep the radio's clock within +/- 1 second of UTC, which is required by WSJT-X. Ported from W3RDL Phoenix 5-28-26. The Phoenix time pane already restales every second in DrawDisplay, so the W3RDL MarkTimePaneStale() helper was intentionally omitted. Includes Loop_test.cpp coverage for valid packets (\n and \r), short input, missing header, pre-epoch sanity rejection, overrun, and the empty-buffer no-op path. Required adding Teensy3Clock.wasSet/lastSet to the Arduino mock and a no-op setTime() to the TimeLib mock. Co-Authored-By: Claude Opus 4.7 --- code/src/PhoenixSketch/Loop.cpp | 53 ++++++++++++++++++++++++- code/src/PhoenixSketch/Loop.h | 8 ++++ code/test/Arduino.h | 4 +- code/test/Loop_test.cpp | 70 +++++++++++++++++++++++++++++++++ code/test/TimeLib.h | 2 + 5 files changed, 135 insertions(+), 2 deletions(-) diff --git a/code/src/PhoenixSketch/Loop.cpp b/code/src/PhoenixSketch/Loop.cpp index 037be45..207eead 100644 --- a/code/src/PhoenixSketch/Loop.cpp +++ b/code/src/PhoenixSketch/Loop.cpp @@ -1444,6 +1444,56 @@ void ShutdownTeensy(void){ * @see PerformSignalProcessing() for DSP implementation * @see ConsumeInterrupt() for event processing */ +// Serial time sync (USB Serial port 0) +// +// Accepts PJRC-standard time packets from a PC-side utility: +// 'T' + 10-digit Unix UTC timestamp + '\n' e.g. "T1748476800\n" +// +// Sets both the Teensy hardware RTC (coin-cell backed) and the TimeLib +// software clock so that hour()/minute()/second() stay accurate. +// WSJT-X requires the clock to be within +/- 1 second of UTC. +// +// PC-side one-liner (Python 3): +// python -c "import serial,time; s=serial.Serial('COMx',115200); s.write(('T'+str(int(time.time()))+'\n').encode()); s.close()" +// Replace COMx with the Teensy USB serial port (e.g. COM6). +#define TIME_SYNC_HEADER 'T' +#define TIME_SYNC_LEN 10 // digits in a Unix timestamp until ~year 2286 + +void CheckForSerialTimeSync(void) { + static char tsbuf[TIME_SYNC_LEN + 2]; + static uint8_t tsidx = 0; + static bool collecting = false; + + while (Serial.available() > 0) { + char c = (char)Serial.read(); + if (c == TIME_SYNC_HEADER) { + collecting = true; + tsidx = 0; + memset(tsbuf, 0, sizeof(tsbuf)); + } else if (collecting) { + if (c == '\n' || c == '\r') { + if (tsidx == TIME_SYNC_LEN) { + time_t t = (time_t)atoll(tsbuf); + if (t > 1000000000UL) { // sanity: after ~2001 + Teensy3Clock.set(t); + setTime(t); + Serial.print("Time set: "); + Serial.println((int64_t)t); + } + } + collecting = false; + tsidx = 0; + } else if (tsidx < TIME_SYNC_LEN) { + tsbuf[tsidx++] = c; + } else { + // Overrun - not a valid timestamp packet + collecting = false; + tsidx = 0; + } + } + } +} + FASTRUN void loop(void){ // Check for signal to begin shutdown and perform shutdown routine if requested if (digitalRead(BEGIN_TEENSY_SHUTDOWN)) ShutdownTeensy(); @@ -1453,8 +1503,9 @@ FASTRUN void loop(void){ ProcessPTTDebounce(); CheckForFrontPanelInterrupts(); CheckForCATSerialEvents(); + CheckForSerialTimeSync(); ConsumeInterrupt(); - + // Step 2: Perform signal processing PerformSignalProcessing(); diff --git a/code/src/PhoenixSketch/Loop.h b/code/src/PhoenixSketch/Loop.h index 1a8faf8..24122dc 100644 --- a/code/src/PhoenixSketch/Loop.h +++ b/code/src/PhoenixSketch/Loop.h @@ -85,6 +85,14 @@ void SetInterrupt(InterruptType i); */ void PrependInterrupt(InterruptType i); +/** + * @brief Poll USB Serial (port 0) for a PJRC-format time-sync packet + * @note Packet format: 'T' + 10-digit Unix UTC timestamp + '\n' (or '\r') + * @note On valid packet, sets the Teensy hardware RTC and TimeLib software clock + * @note Non-blocking; drains the input buffer each call + */ +void CheckForSerialTimeSync(void); + /** * @brief Main program loop executed repeatedly while radio is powered on * @note FASTRUN annotation places function in RAM for maximum execution speed diff --git a/code/test/Arduino.h b/code/test/Arduino.h index c5e4da4..3769cf8 100644 --- a/code/test/Arduino.h +++ b/code/test/Arduino.h @@ -243,8 +243,10 @@ extern SerialClass SerialUSB1; // Mock Teensy3Clock (Real-time clock) class Teensy3ClockClass { public: + time_t lastSet = 0; + bool wasSet = false; time_t get() { return 1234567890; } // Return a fixed timestamp for testing - void set(time_t t) { (void)t; } + void set(time_t t) { lastSet = t; wasSet = true; } }; extern Teensy3ClockClass Teensy3Clock; diff --git a/code/test/Loop_test.cpp b/code/test/Loop_test.cpp index f448922..985ff3c 100644 --- a/code/test/Loop_test.cpp +++ b/code/test/Loop_test.cpp @@ -1,6 +1,7 @@ #include "gtest/gtest.h" #include "../src/PhoenixSketch/SDT.h" +#include "../src/PhoenixSketch/Loop.h" // Forward declare CAT functions for testing char *BU_write(char* cmd); @@ -2085,3 +2086,72 @@ TEST(Loop, FineTuneIncrementButtonArrayValueVerification) { ConsumeInterrupt(); EXPECT_EQ(ED.stepFineTune, 10); // Back to beginning } + +// ───────────────────────────────────────────────────────────────────────────── +// Serial time sync tests +// +// CheckForSerialTimeSync() accepts PJRC-format time packets on Serial: +// 'T' + 10-digit Unix UTC timestamp + '\n' (or '\r') +// and sets both the Teensy hardware RTC and the TimeLib software clock. +// ───────────────────────────────────────────────────────────────────────────── + +// Helper: feed bytes to Serial, run the handler, drain the rest. +// Re-feeds whatever the test poked in via Serial and processes it in one call. +static void runSerialTimeSync(const char* packet) { + Serial.clearBuffer(); + Teensy3Clock.wasSet = false; + Teensy3Clock.lastSet = 0; + Serial.feedData(packet); + CheckForSerialTimeSync(); +} + +TEST(Loop, TimeSyncValidPacketSetsRTC) { + runSerialTimeSync("T1748476800\n"); + EXPECT_TRUE(Teensy3Clock.wasSet); + EXPECT_EQ(Teensy3Clock.lastSet, (time_t)1748476800); +} + +TEST(Loop, TimeSyncCarriageReturnTerminatorWorks) { + runSerialTimeSync("T1748476800\r"); + EXPECT_TRUE(Teensy3Clock.wasSet); + EXPECT_EQ(Teensy3Clock.lastSet, (time_t)1748476800); +} + +TEST(Loop, TimeSyncRejectsShortTimestamp) { + runSerialTimeSync("T12345\n"); // only 5 digits + EXPECT_FALSE(Teensy3Clock.wasSet); +} + +TEST(Loop, TimeSyncRejectsPreEpochSanityFailure) { + // 0000000001 parses but fails the t > 1000000000 sanity check + runSerialTimeSync("T0000000001\n"); + EXPECT_FALSE(Teensy3Clock.wasSet); +} + +TEST(Loop, TimeSyncIgnoresStreamWithoutHeader) { + runSerialTimeSync("1748476800\n"); // no 'T' header + EXPECT_FALSE(Teensy3Clock.wasSet); +} + +TEST(Loop, TimeSyncNewHeaderResetsCollection) { + // First 'T' starts collection with garbage, second 'T' must reset + // cleanly and the following valid packet must be accepted. + runSerialTimeSync("Tabc123T1748476800\n"); + EXPECT_TRUE(Teensy3Clock.wasSet); + EXPECT_EQ(Teensy3Clock.lastSet, (time_t)1748476800); +} + +TEST(Loop, TimeSyncRejectsOverrunPacket) { + // 11 digits then newline: tsidx hits TIME_SYNC_LEN before newline, + // 11th digit triggers overrun branch, clock must not be set. + runSerialTimeSync("T17484768000\n"); + EXPECT_FALSE(Teensy3Clock.wasSet); +} + +TEST(Loop, TimeSyncNoDataLeavesClockUntouched) { + Serial.clearBuffer(); + Teensy3Clock.wasSet = false; + Teensy3Clock.lastSet = 0; + CheckForSerialTimeSync(); + EXPECT_FALSE(Teensy3Clock.wasSet); +} diff --git a/code/test/TimeLib.h b/code/test/TimeLib.h index c2cc06b..ab53dc7 100644 --- a/code/test/TimeLib.h +++ b/code/test/TimeLib.h @@ -42,4 +42,6 @@ inline int year() { return timeinfo->tm_year + 1900; } +inline void setTime(time_t t) { (void)t; } + #endif From f56c8c7e371d10b3f070076b341f8ae1bcaba405 Mon Sep 17 00:00:00 2001 From: Oliver King Date: Mon, 1 Jun 2026 07:15:14 -0400 Subject: [PATCH 4/7] Add serial time sync over USB Serial port 0 Accepts PJRC-format time packets (T<10-digit-unix-ts><\n|\r>) on Serial and sets the Teensy hardware RTC plus the TimeLib software clock so hour()/minute()/second() stay accurate. Lets a host PC keep the radio's clock within +/- 1 second of UTC, which is required by WSJT-X. Ported from W3RDL Phoenix 5-28-26. The Phoenix time pane already restales every second in DrawDisplay, so the W3RDL MarkTimePaneStale() helper was intentionally omitted. --- code/src/PhoenixSketch/BuildInfo.h | 4 +- code/src/PhoenixSketch/Loop.cpp | 53 +++++++++++++- code/src/PhoenixSketch/Loop.h | 8 +++ .../PhoenixSketch/MainBoard_DisplayHome.cpp | 2 +- code/src/PhoenixSketch/SDT.h | 2 +- code/test/Arduino.h | 4 +- code/test/Loop_test.cpp | 70 +++++++++++++++++++ code/test/TimeLib.h | 2 + 8 files changed, 139 insertions(+), 6 deletions(-) diff --git a/code/src/PhoenixSketch/BuildInfo.h b/code/src/PhoenixSketch/BuildInfo.h index d465b7f..a49e1dc 100644 --- a/code/src/PhoenixSketch/BuildInfo.h +++ b/code/src/PhoenixSketch/BuildInfo.h @@ -9,7 +9,7 @@ #ifndef BUILDINFO_H #define BUILDINFO_H -#define GIT_COMMIT_HASH "30fb27a" -#define BUILD_TIMESTAMP "2026-05-29 17:12:10" +#define GIT_COMMIT_HASH "d15b17e" +#define BUILD_TIMESTAMP "2026-05-30 14:18:06" #endif // BUILDINFO_H diff --git a/code/src/PhoenixSketch/Loop.cpp b/code/src/PhoenixSketch/Loop.cpp index 037be45..207eead 100644 --- a/code/src/PhoenixSketch/Loop.cpp +++ b/code/src/PhoenixSketch/Loop.cpp @@ -1444,6 +1444,56 @@ void ShutdownTeensy(void){ * @see PerformSignalProcessing() for DSP implementation * @see ConsumeInterrupt() for event processing */ +// Serial time sync (USB Serial port 0) +// +// Accepts PJRC-standard time packets from a PC-side utility: +// 'T' + 10-digit Unix UTC timestamp + '\n' e.g. "T1748476800\n" +// +// Sets both the Teensy hardware RTC (coin-cell backed) and the TimeLib +// software clock so that hour()/minute()/second() stay accurate. +// WSJT-X requires the clock to be within +/- 1 second of UTC. +// +// PC-side one-liner (Python 3): +// python -c "import serial,time; s=serial.Serial('COMx',115200); s.write(('T'+str(int(time.time()))+'\n').encode()); s.close()" +// Replace COMx with the Teensy USB serial port (e.g. COM6). +#define TIME_SYNC_HEADER 'T' +#define TIME_SYNC_LEN 10 // digits in a Unix timestamp until ~year 2286 + +void CheckForSerialTimeSync(void) { + static char tsbuf[TIME_SYNC_LEN + 2]; + static uint8_t tsidx = 0; + static bool collecting = false; + + while (Serial.available() > 0) { + char c = (char)Serial.read(); + if (c == TIME_SYNC_HEADER) { + collecting = true; + tsidx = 0; + memset(tsbuf, 0, sizeof(tsbuf)); + } else if (collecting) { + if (c == '\n' || c == '\r') { + if (tsidx == TIME_SYNC_LEN) { + time_t t = (time_t)atoll(tsbuf); + if (t > 1000000000UL) { // sanity: after ~2001 + Teensy3Clock.set(t); + setTime(t); + Serial.print("Time set: "); + Serial.println((int64_t)t); + } + } + collecting = false; + tsidx = 0; + } else if (tsidx < TIME_SYNC_LEN) { + tsbuf[tsidx++] = c; + } else { + // Overrun - not a valid timestamp packet + collecting = false; + tsidx = 0; + } + } + } +} + FASTRUN void loop(void){ // Check for signal to begin shutdown and perform shutdown routine if requested if (digitalRead(BEGIN_TEENSY_SHUTDOWN)) ShutdownTeensy(); @@ -1453,8 +1503,9 @@ FASTRUN void loop(void){ ProcessPTTDebounce(); CheckForFrontPanelInterrupts(); CheckForCATSerialEvents(); + CheckForSerialTimeSync(); ConsumeInterrupt(); - + // Step 2: Perform signal processing PerformSignalProcessing(); diff --git a/code/src/PhoenixSketch/Loop.h b/code/src/PhoenixSketch/Loop.h index 1a8faf8..24122dc 100644 --- a/code/src/PhoenixSketch/Loop.h +++ b/code/src/PhoenixSketch/Loop.h @@ -85,6 +85,14 @@ void SetInterrupt(InterruptType i); */ void PrependInterrupt(InterruptType i); +/** + * @brief Poll USB Serial (port 0) for a PJRC-format time-sync packet + * @note Packet format: 'T' + 10-digit Unix UTC timestamp + '\n' (or '\r') + * @note On valid packet, sets the Teensy hardware RTC and TimeLib software clock + * @note Non-blocking; drains the input buffer each call + */ +void CheckForSerialTimeSync(void); + /** * @brief Main program loop executed repeatedly while radio is powered on * @note FASTRUN annotation places function in RAM for maximum execution speed diff --git a/code/src/PhoenixSketch/MainBoard_DisplayHome.cpp b/code/src/PhoenixSketch/MainBoard_DisplayHome.cpp index 5a82284..9b2251b 100644 --- a/code/src/PhoenixSketch/MainBoard_DisplayHome.cpp +++ b/code/src/PhoenixSketch/MainBoard_DisplayHome.cpp @@ -427,7 +427,7 @@ uint16_t FILTER_PARAMETERS_Y = PaneSpectrum.y0+1; // Spectrum data buffers uint16_t pixelold[MAX_WATERFALL_WIDTH]; uint16_t waterfall[MAX_WATERFALL_WIDTH]; -#define NCHUNKS 5 +#define NCHUNKS 6 // S-meter constants (used by DisplaydbM function within spectrum rendering) #define SMETER_X PaneSMeter.x0+20 diff --git a/code/src/PhoenixSketch/SDT.h b/code/src/PhoenixSketch/SDT.h index 07caa8e..827f554 100644 --- a/code/src/PhoenixSketch/SDT.h +++ b/code/src/PhoenixSketch/SDT.h @@ -23,7 +23,7 @@ If not, see . #include "Config.h" #define RIGNAME "T41-EP SDT" -#define VERSION "Phx V1.2.2" +#define VERSION "Phx V1.3" #include "BuildInfo.h" diff --git a/code/test/Arduino.h b/code/test/Arduino.h index c5e4da4..3769cf8 100644 --- a/code/test/Arduino.h +++ b/code/test/Arduino.h @@ -243,8 +243,10 @@ extern SerialClass SerialUSB1; // Mock Teensy3Clock (Real-time clock) class Teensy3ClockClass { public: + time_t lastSet = 0; + bool wasSet = false; time_t get() { return 1234567890; } // Return a fixed timestamp for testing - void set(time_t t) { (void)t; } + void set(time_t t) { lastSet = t; wasSet = true; } }; extern Teensy3ClockClass Teensy3Clock; diff --git a/code/test/Loop_test.cpp b/code/test/Loop_test.cpp index f448922..985ff3c 100644 --- a/code/test/Loop_test.cpp +++ b/code/test/Loop_test.cpp @@ -1,6 +1,7 @@ #include "gtest/gtest.h" #include "../src/PhoenixSketch/SDT.h" +#include "../src/PhoenixSketch/Loop.h" // Forward declare CAT functions for testing char *BU_write(char* cmd); @@ -2085,3 +2086,72 @@ TEST(Loop, FineTuneIncrementButtonArrayValueVerification) { ConsumeInterrupt(); EXPECT_EQ(ED.stepFineTune, 10); // Back to beginning } + +// ───────────────────────────────────────────────────────────────────────────── +// Serial time sync tests +// +// CheckForSerialTimeSync() accepts PJRC-format time packets on Serial: +// 'T' + 10-digit Unix UTC timestamp + '\n' (or '\r') +// and sets both the Teensy hardware RTC and the TimeLib software clock. +// ───────────────────────────────────────────────────────────────────────────── + +// Helper: feed bytes to Serial, run the handler, drain the rest. +// Re-feeds whatever the test poked in via Serial and processes it in one call. +static void runSerialTimeSync(const char* packet) { + Serial.clearBuffer(); + Teensy3Clock.wasSet = false; + Teensy3Clock.lastSet = 0; + Serial.feedData(packet); + CheckForSerialTimeSync(); +} + +TEST(Loop, TimeSyncValidPacketSetsRTC) { + runSerialTimeSync("T1748476800\n"); + EXPECT_TRUE(Teensy3Clock.wasSet); + EXPECT_EQ(Teensy3Clock.lastSet, (time_t)1748476800); +} + +TEST(Loop, TimeSyncCarriageReturnTerminatorWorks) { + runSerialTimeSync("T1748476800\r"); + EXPECT_TRUE(Teensy3Clock.wasSet); + EXPECT_EQ(Teensy3Clock.lastSet, (time_t)1748476800); +} + +TEST(Loop, TimeSyncRejectsShortTimestamp) { + runSerialTimeSync("T12345\n"); // only 5 digits + EXPECT_FALSE(Teensy3Clock.wasSet); +} + +TEST(Loop, TimeSyncRejectsPreEpochSanityFailure) { + // 0000000001 parses but fails the t > 1000000000 sanity check + runSerialTimeSync("T0000000001\n"); + EXPECT_FALSE(Teensy3Clock.wasSet); +} + +TEST(Loop, TimeSyncIgnoresStreamWithoutHeader) { + runSerialTimeSync("1748476800\n"); // no 'T' header + EXPECT_FALSE(Teensy3Clock.wasSet); +} + +TEST(Loop, TimeSyncNewHeaderResetsCollection) { + // First 'T' starts collection with garbage, second 'T' must reset + // cleanly and the following valid packet must be accepted. + runSerialTimeSync("Tabc123T1748476800\n"); + EXPECT_TRUE(Teensy3Clock.wasSet); + EXPECT_EQ(Teensy3Clock.lastSet, (time_t)1748476800); +} + +TEST(Loop, TimeSyncRejectsOverrunPacket) { + // 11 digits then newline: tsidx hits TIME_SYNC_LEN before newline, + // 11th digit triggers overrun branch, clock must not be set. + runSerialTimeSync("T17484768000\n"); + EXPECT_FALSE(Teensy3Clock.wasSet); +} + +TEST(Loop, TimeSyncNoDataLeavesClockUntouched) { + Serial.clearBuffer(); + Teensy3Clock.wasSet = false; + Teensy3Clock.lastSet = 0; + CheckForSerialTimeSync(); + EXPECT_FALSE(Teensy3Clock.wasSet); +} diff --git a/code/test/TimeLib.h b/code/test/TimeLib.h index c2cc06b..ab53dc7 100644 --- a/code/test/TimeLib.h +++ b/code/test/TimeLib.h @@ -42,4 +42,6 @@ inline int year() { return timeinfo->tm_year + 1900; } +inline void setTime(time_t t) { (void)t; } + #endif From 9edf6a4c3516d6f434b78f345f4668a5a672464a Mon Sep 17 00:00:00 2001 From: Oliver King Date: Sat, 6 Jun 2026 10:22:52 -0400 Subject: [PATCH 5/7] Fix NR audio dropout after cold power cycle (uninitialized DMAMEM) The convolution filter's overlap-add history buffers (last_sample_buffer_L/R) are declared static DMAMEM, which the Teensy 4.x does not zero-initialize at cold boot (only .bss in DTCM is cleared at startup). After a power cycle they contained random bit patterns - some NaN/Inf - that were fed into the convolution FFT on the first frame and then latched permanently into the noise reduction feedback state, collapsing the audio until NR was toggled off. A re-flash is a warm reset that preserves OCRAM, which is why it only reproduced after a cold power cycle. Two fixes: 1. InitializeFilters() now clears last_sample_buffer_L/R at boot. 2. The NR algorithms are made NaN/Inf tolerant so a transient bad sample can no longer latch permanently: - Kim1_NR: gain clamp now uses !(NR_G > 0.0), catching NaN/Inf before it poisons the NR_Gts time-smoothing feedback. - SpectralNoiseReduction: sanitize NR_X at its entry point and guard the self-referential xt noise estimate. - Xanr: sanitize the input sample before it reaches the adaptive weights. Adds NoiseReduction_test.cpp (target all_NoiseReduction_tests) with regression tests covering the overlap-buffer clearing and transient-NaN recovery for all three NR algorithms. Co-Authored-By: Claude Opus 4.8 --- code/src/PhoenixSketch/DSP_FFT.cpp | 11 +- code/src/PhoenixSketch/DSP_Noise.cpp | 27 +++- code/test/CMakeLists.txt | 7 + code/test/NoiseReduction_test.cpp | 213 +++++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 code/test/NoiseReduction_test.cpp diff --git a/code/src/PhoenixSketch/DSP_FFT.cpp b/code/src/PhoenixSketch/DSP_FFT.cpp index 0fe2ec5..b722e90 100644 --- a/code/src/PhoenixSketch/DSP_FFT.cpp +++ b/code/src/PhoenixSketch/DSP_FFT.cpp @@ -375,7 +375,16 @@ void InitializeFilters(uint32_t spectrum_zoom, ReceiveFilterConfig *RXfilters) { RXfilters->n_att_dB, RXfilters->n_desired_BW_Hz, READ_BUFFER_SIZE/RXfilters->DF1); // FIR filter mask - InitFilterMask(FIR_filter_mask, RXfilters); + InitFilterMask(FIR_filter_mask, RXfilters); + + // Clear the convolution overlap-add history buffers. These are declared as + // static DMAMEM, which is NOT zero-initialized at cold boot on the Teensy 4.x + // (only the regular .bss section in DTCM is cleared at startup). After a cold + // power cycle they contain random bit patterns - some of which are NaN/Inf - + // that would otherwise be fed straight into the convolution FFT on the first + // frame and propagate downstream into the noise reduction feedback state. + CLEAR_VAR(last_sample_buffer_L); + CLEAR_VAR(last_sample_buffer_R); // Equalizer RXfilters for (size_t i = 0; i<14; i++){ diff --git a/code/src/PhoenixSketch/DSP_Noise.cpp b/code/src/PhoenixSketch/DSP_Noise.cpp index 34e0abe..3bc73ec 100644 --- a/code/src/PhoenixSketch/DSP_Noise.cpp +++ b/code/src/PhoenixSketch/DSP_Noise.cpp @@ -231,12 +231,12 @@ void Kim1_NR(DataBlock *data){ for (int bindx = VAD_low; bindx < VAD_high; bindx++) { // take first 128 bin values of the FFT result if (NR_use_X) { NR_G[bindx] = 1.0 - (NR_lambda[bindx] * NR_KIM_K / NR_X[bindx][NR_X_pointer]); - if (NR_G[bindx] < 0.0) - NR_G[bindx] = 0.0; + if (!(NR_G[bindx] > 0.0)) // '!(x > 0)' also catches NaN/Inf, which would + NR_G[bindx] = 0.0; // otherwise latch permanently into NR_Gts below } else { NR_G[bindx] = 1.0 - (NR_lambda[bindx] * NR_KIM_K / NR_E[bindx][NR_E_pointer]); - if (NR_G[bindx] < 0.0) - NR_G[bindx] = 0.0; + if (!(NR_G[bindx] > 0.0)) // '!(x > 0)' also catches NaN/Inf, which would + NR_G[bindx] = 0.0; // otherwise latch permanently into NR_Gts below } // time smoothing @@ -306,7 +306,13 @@ void Xanr(DataBlock *data, uint8_t ANR_notch) { float32_t nel, nev; for (int i = 0; i < ANR_buff_size; i++) { - ANR_d[ANR_in_idx] = data->I[i]; + // Sanitize at the input: a non-finite sample would otherwise latch + // permanently into the adaptive weights ANR_w and never recover. + float32_t in_sample = data->I[i]; + if (!isfinite(in_sample)) { + in_sample = 0.0; + } + ANR_d[ANR_in_idx] = in_sample; y = 0; sigma = 0; @@ -476,6 +482,12 @@ void SpectralNoiseReduction(DataBlock *data){ for (int bindx = 0; bindx < NR_FFT_L / 2; bindx++) { // this is squared magnitude for the current frame NR_X[bindx][0] = (NR_FFT_buffer[bindx * 2] * NR_FFT_buffer[bindx * 2] + NR_FFT_buffer[bindx * 2 + 1] * NR_FFT_buffer[bindx * 2 + 1]); + // Sanitize at the single entry point: a NaN/Inf reaching the noise + // estimate (xt) or the gain feedback would otherwise latch permanently + // and silence the audio until NR is toggled off. + if (!isfinite(NR_X[bindx][0])) { + NR_X[bindx][0] = 0.0; + } } if (NR_first_time_2 == 2) { // TODO: properly initialize all the variables @@ -502,6 +514,11 @@ void SpectralNoiseReduction(DataBlock *data){ } xtr = (1.0 - ph1y[bindx]) * NR_X[bindx][0] + ph1y[bindx] * xt[bindx]; xt[bindx] = ax * xt[bindx] + (1.0 - ax) * xtr; + // xt is a self-referential noise estimate: once non-finite it can never + // recover on its own. Reset it to the current frame power if that happens. + if (!isfinite(xt[bindx])) { + xt[bindx] = NR_X[bindx][0]; + } } for (int bindx = 0; bindx < NR_FFT_L / 2; bindx++) { // 1. Step of NR - calculate the SNR's NR_SNR_post[bindx] = fmax(fmin(NR_X[bindx][0] / xt[bindx], 1000.0), snr_prio_min); // limited to +30 /-15 dB, might be still too much of reduction, let's try it? diff --git a/code/test/CMakeLists.txt b/code/test/CMakeLists.txt index 6875f12..afb3cf9 100644 --- a/code/test/CMakeLists.txt +++ b/code/test/CMakeLists.txt @@ -68,6 +68,12 @@ add_executable(all_SigProc_tests SignalProcessing_test.cpp ../src/PhoenixSketch ../src/PhoenixSketch/MainBoard_AudioIO.cpp ../src/PhoenixSketch/LPFBoard.cpp ../src/PhoenixSketch/LPFBoard_AD7991.cpp ../src/PhoenixSketch/FrontPanel.cpp ../src/PhoenixSketch/FrontPanel_Rotary.cpp ../src/PhoenixSketch/ParamSave.cpp si5351_mock.cpp Arduino_mock.cpp Adafruit_I2CDevice_mock.cpp OpenAudio_ArduinoLibrary_mock.cpp RA8875_mock.cpp LittleFS_mock.cpp ArduinoJson.cpp) target_link_libraries(all_SigProc_tests GTest::gtest_main) +add_executable(all_NoiseReduction_tests NoiseReduction_test.cpp ../src/PhoenixSketch/DSP.cpp ../src/PhoenixSketch/CAT.cpp ../src/PhoenixSketch/BPFBoard.cpp ../src/PhoenixSketch/Storage.cpp + ../src/PhoenixSketch/DSP_FFT.cpp ../src/PhoenixSketch/Loop.cpp ../src/PhoenixSketch/Tune.cpp ../src/PhoenixSketch/MainBoard_Display.cpp ../src/PhoenixSketch/MainBoard_DisplayHome.cpp ../src/PhoenixSketch/MainBoard_DisplayMenus.cpp ../src/PhoenixSketch/MainBoard_DisplayDFE.cpp ../src/PhoenixSketch/MainBoard_DisplayEqualizer.cpp ../src/PhoenixSketch/MainBoard_DisplayCalibration_Frequency.cpp ../src/PhoenixSketch/MainBoard_DisplayCalibration_RXIQ.cpp ../src/PhoenixSketch/ReceiveIQCalSm.cpp ../src/PhoenixSketch/TransmitIQCalSm.cpp ../src/PhoenixSketch/TransmitCarrierCalSm.cpp ../src/PhoenixSketch/MainBoard_DisplayCalibration_Power.cpp ../src/PhoenixSketch/PowerCalSm.cpp ../src/PhoenixSketch/MainBoard_DisplayCalibration_TXIQ.cpp ../src/PhoenixSketch/Mode.cpp ../src/PhoenixSketch/ModeSm.cpp ../src/PhoenixSketch/UISm.cpp ../src/PhoenixSketch/HardwareSm.cpp ../src/PhoenixSketch/HardwareSm_PowerCalibration.cpp ../src/PhoenixSketch/HardwareSm_ReceiveIQCalibration.cpp ../src/PhoenixSketch/HardwareSm_TransmitIQCalibration.cpp ../src/PhoenixSketch/HardwareSm_TransmitCarrierCalibration.cpp + ../src/PhoenixSketch/RFBoard.cpp DSP_FFT_stub_test.cpp ../src/PhoenixSketch/Globals.cpp arm_functions.c ../src/PhoenixSketch/DSP_FIR.cpp ../src/PhoenixSketch/DSP_Noise.cpp ../src/PhoenixSketch/DSP_CWProcessing.cpp + ../src/PhoenixSketch/MainBoard_AudioIO.cpp ../src/PhoenixSketch/LPFBoard.cpp ../src/PhoenixSketch/LPFBoard_AD7991.cpp ../src/PhoenixSketch/FrontPanel.cpp ../src/PhoenixSketch/FrontPanel_Rotary.cpp ../src/PhoenixSketch/ParamSave.cpp si5351_mock.cpp Arduino_mock.cpp Adafruit_I2CDevice_mock.cpp OpenAudio_ArduinoLibrary_mock.cpp RA8875_mock.cpp LittleFS_mock.cpp ArduinoJson.cpp) +target_link_libraries(all_NoiseReduction_tests GTest::gtest_main) + add_executable(all_TransmitChain_tests TransmitChain_test.cpp ../src/PhoenixSketch/DSP.cpp ../src/PhoenixSketch/CAT.cpp ../src/PhoenixSketch/HardwareSm.cpp ../src/PhoenixSketch/HardwareSm_PowerCalibration.cpp ../src/PhoenixSketch/HardwareSm_ReceiveIQCalibration.cpp ../src/PhoenixSketch/HardwareSm_TransmitIQCalibration.cpp ../src/PhoenixSketch/HardwareSm_TransmitCarrierCalibration.cpp ../src/PhoenixSketch/BPFBoard.cpp ../src/PhoenixSketch/Storage.cpp ../src/PhoenixSketch/DSP_FFT.cpp ../src/PhoenixSketch/Loop.cpp ../src/PhoenixSketch/Tune.cpp ../src/PhoenixSketch/MainBoard_Display.cpp ../src/PhoenixSketch/MainBoard_DisplayHome.cpp ../src/PhoenixSketch/MainBoard_DisplayMenus.cpp ../src/PhoenixSketch/MainBoard_DisplayDFE.cpp ../src/PhoenixSketch/MainBoard_DisplayEqualizer.cpp ../src/PhoenixSketch/MainBoard_DisplayCalibration_Frequency.cpp ../src/PhoenixSketch/MainBoard_DisplayCalibration_RXIQ.cpp ../src/PhoenixSketch/ReceiveIQCalSm.cpp ../src/PhoenixSketch/TransmitIQCalSm.cpp ../src/PhoenixSketch/TransmitCarrierCalSm.cpp ../src/PhoenixSketch/MainBoard_DisplayCalibration_Power.cpp ../src/PhoenixSketch/PowerCalSm.cpp ../src/PhoenixSketch/MainBoard_DisplayCalibration_TXIQ.cpp ../src/PhoenixSketch/Mode.cpp ../src/PhoenixSketch/ModeSm.cpp ../src/PhoenixSketch/UISm.cpp ../src/PhoenixSketch/RFBoard.cpp DSP_FFT_stub_test.cpp ../src/PhoenixSketch/Globals.cpp arm_functions.c ../src/PhoenixSketch/DSP_FIR.cpp ../src/PhoenixSketch/DSP_Noise.cpp ../src/PhoenixSketch/DSP_CWProcessing.cpp @@ -141,6 +147,7 @@ gtest_discover_tests(all_ModeSm_tests) gtest_discover_tests(all_UISm_tests) gtest_discover_tests(all_Loop_tests) gtest_discover_tests(all_SigProc_tests) +gtest_discover_tests(all_NoiseReduction_tests) gtest_discover_tests(all_TransmitChain_tests) gtest_discover_tests(all_FrontPanel_tests) gtest_discover_tests(all_CAT_tests) diff --git a/code/test/NoiseReduction_test.cpp b/code/test/NoiseReduction_test.cpp new file mode 100644 index 0000000..10c88aa --- /dev/null +++ b/code/test/NoiseReduction_test.cpp @@ -0,0 +1,213 @@ +#include "gtest/gtest.h" + +#include "../src/PhoenixSketch/SDT.h" +#include + +/* + * Regression tests for the noise-reduction NaN robustness fixes. + * + * Background: the radio worked when freshly flashed but went silent after a cold + * power cycle whenever noise reduction was enabled. The convolution filter's + * overlap-add history buffers (last_sample_buffer_L/R in DSP_FFT.cpp) live in + * DMAMEM, which the Teensy 4.x does NOT zero-initialize at cold boot. The random + * bit patterns there (some NaN/Inf) were fed into the convolution FFT on the + * first frame and then latched *permanently* into the noise-reduction feedback + * state (Kim's NR_Gts, Spectral's xt, Xanr's weights), collapsing the audio. + * + * Two fixes: + * 1. InitializeFilters() now clears the convolution overlap buffers. + * 2. The NR algorithms sanitize/clamp so a transient NaN/Inf cannot latch. + * + * These tests reproduce the failure mode on the host and prove recovery. + */ + +extern ReceiveFilterConfig RXfilters; + +namespace { + +constexpr uint32_t kFrame = NR_FFT_L; // 256 samples per NR frame +constexpr uint32_t kSampleRate = 24000; // post-decimation audio rate + +// True if every element of buf[0..N) is a finite number (not NaN or Inf). +bool AllFinite(const float32_t *buf, uint32_t N) { + for (uint32_t i = 0; i < N; i++) { + if (!std::isfinite(buf[i])) { + return false; + } + } + return true; +} + +// Fill a frame with a finite test tone in both I and Q. 'phase' lets successive +// frames be continuous so the overlapped FFT processing behaves realistically. +void FillTone(float32_t *I, float32_t *Q, uint32_t N, float32_t phase) { + for (uint32_t i = 0; i < N; i++) { + float32_t s = 0.2f * sinf(2.0f * (float32_t)M_PI * 30.0f * + ((float32_t)i + phase) / (float32_t)N); + I[i] = s; + Q[i] = s; + } +} + +// Configure the active band so the NR voice-activity detection range is valid. +void SetupBand() { + bands[ED.currentBand[ED.activeVFO]].FLoCut_Hz = 200; + bands[ED.currentBand[ED.activeVFO]].FHiCut_Hz = 3000; +} + +} // namespace + +// --------------------------------------------------------------------------- +// Fix #1: the convolution overlap buffers are cleared by InitializeFilters(). +// +// We poison the (file-static) overlap buffers by pushing a frame containing a +// NaN through ConvolutionFilter - exactly what cold-boot DMAMEM garbage would +// do. InitializeFilters() then simulates the boot-time init. A subsequent clean +// frame must produce finite output: without the CLEAR_VAR added to +// InitializeFilters the leftover NaN feeds straight back into the FFT. +// --------------------------------------------------------------------------- +TEST(NoiseReduction, ConvolutionFilterClearsOverlapBuffersOnInit) { + SetupBand(); + InitializeFilters(SPECTRUM_ZOOM_1, &RXfilters); + + float32_t I[kFrame]; + float32_t Q[kFrame]; + DataBlock data; + data.I = I; + data.Q = Q; + data.N = READ_BUFFER_SIZE / RXfilters.DF; // ConvolutionFilter requires this + data.sampleRate_Hz = kSampleRate; + + ASSERT_EQ(data.N, kFrame); + + // Poison the overlap history with a NaN (stored into last_sample_buffer_L/R). + FillTone(I, Q, data.N, 0); + I[42] = std::nanf(""); + Q[42] = std::nanf(""); + ConvolutionFilter(&data, &RXfilters, nullptr); + + // Simulate the boot-time initialization that the fix now performs. + InitializeFilters(SPECTRUM_ZOOM_1, &RXfilters); + + // A clean frame must yield finite output now that the history is cleared. + FillTone(I, Q, data.N, kFrame); + ConvolutionFilter(&data, &RXfilters, nullptr); + EXPECT_TRUE(AllFinite(data.I, data.N)); + EXPECT_TRUE(AllFinite(data.Q, data.N)); +} + +// --------------------------------------------------------------------------- +// Fix #2a: Kim NR recovers from a transient NaN. +// +// Without the NaN-aware gain clamp, a single bad frame poisons the NR_Gts time +// smoothing feedback, which then stays NaN forever and silences the audio. +// --------------------------------------------------------------------------- +TEST(NoiseReduction, Kim1RecoversFromTransientNan) { + SetupBand(); + InitializeKim1NoiseReduction(); + + float32_t I[kFrame]; + float32_t Q[kFrame]; + DataBlock data; + data.I = I; + data.Q = Q; + data.N = kFrame; + data.sampleRate_Hz = kSampleRate; + + // Warm up with clean audio. + for (int f = 0; f < 8; f++) { + FillTone(I, Q, kFrame, (float32_t)f * kFrame); + Kim1_NR(&data); + } + + // Inject one corrupted frame. + FillTone(I, Q, kFrame, 100); + I[37] = std::nanf(""); + Kim1_NR(&data); + + // Recover with clean audio. + for (int f = 0; f < 20; f++) { + FillTone(I, Q, kFrame, (float32_t)f * kFrame); + Kim1_NR(&data); + } + + EXPECT_TRUE(AllFinite(data.I, kFrame)); + EXPECT_TRUE(AllFinite(data.Q, kFrame)); +} + +// --------------------------------------------------------------------------- +// Fix #2b: Spectral NR recovers from a transient NaN. +// +// Without sanitizing NR_X (and guarding xt), the self-referential noise +// estimate xt latches NaN and corrupts the spectral weighting indefinitely. +// --------------------------------------------------------------------------- +TEST(NoiseReduction, SpectralRecoversFromTransientNan) { + SetupBand(); + InitializeKim1NoiseReduction(); // clears shared buffers (incl. overlap) + InitializeSpectralNoiseReduction(); + + float32_t I[kFrame]; + float32_t Q[kFrame]; + DataBlock data; + data.I = I; + data.Q = Q; + data.N = kFrame; + data.sampleRate_Hz = kSampleRate; + + // Warm up past the ~20-frame noise-learning phase. + for (int f = 0; f < 30; f++) { + FillTone(I, Q, kFrame, (float32_t)f * kFrame); + SpectralNoiseReduction(&data); + } + + // Inject one corrupted frame. + FillTone(I, Q, kFrame, 100); + I[91] = std::nanf(""); + SpectralNoiseReduction(&data); + + // Recover with clean audio. + for (int f = 0; f < 40; f++) { + FillTone(I, Q, kFrame, (float32_t)f * kFrame); + SpectralNoiseReduction(&data); + } + + EXPECT_TRUE(AllFinite(data.I, kFrame)); + EXPECT_TRUE(AllFinite(data.Q, kFrame)); +} + +// --------------------------------------------------------------------------- +// Fix #2c: Xanr (LMS noise reduction / notch) recovers from a transient NaN. +// +// Without input sanitization a NaN propagates into the adaptive weights ANR_w, +// which then never recover. Xanr writes its output to data->Q. +// --------------------------------------------------------------------------- +TEST(NoiseReduction, XanrRecoversFromTransientNan) { + InitializeXanrNoiseReduction(); + + float32_t I[kFrame]; + float32_t Q[kFrame]; + DataBlock data; + data.I = I; + data.Q = Q; + data.N = kFrame; + data.sampleRate_Hz = kSampleRate; + + // Warm up with clean audio. + for (int f = 0; f < 8; f++) { + FillTone(I, Q, kFrame, (float32_t)f * kFrame); + Xanr(&data, 0); + } + + // Inject one corrupted frame. + FillTone(I, Q, kFrame, 100); + I[10] = std::nanf(""); + Xanr(&data, 0); + + // Recover with clean audio. + for (int f = 0; f < 40; f++) { + FillTone(I, Q, kFrame, (float32_t)f * kFrame); + Xanr(&data, 0); + } + + EXPECT_TRUE(AllFinite(data.Q, kFrame)); +} From 024095aaab31efbf88b22a66ad6e454eb799f8c8 Mon Sep 17 00:00:00 2001 From: Oliver King Date: Sat, 6 Jun 2026 11:12:26 -0400 Subject: [PATCH 6/7] Fix failing/flaky DSP and keyer unit tests Update SignalProcessing.ReadDataIntoBuffers and TransmitChain.TransmitReceiveProcessingPSDContainsCorrectTone to the post-04b277a I/Q convention (I<-L, Q<-R). The image-formation fix swapped the channel assignment in ReadIQInputBuffer; the tests still encoded the old I=R/Q=L convention, so the direct comparison and the PSD tone bin (mirrored to the lower sideband) were wrong. Fix the flaky Radio.RadioStateRunThrough keyer test. The keyer state machine counts DO events as milliseconds, but the timer thread fired "tick then sleep_for(1ms)", which sleeps >=1ms and drifts behind the wall-clock millis() the test checks against. Over a dit/dah the drift exceeded the grace windows, so the test passed in isolation but failed in the full suite. Lock the DO clock to the wall clock (fire one DO per elapsed wall-ms), and make the two KEYER_WAIT upper bounds exclusive at the WAIT->RECEIVE transition to match the now-exact timing. Co-Authored-By: Claude Opus 4.8 --- code/test/Radio_test.cpp | 42 ++++++++++++++++++++++++----- code/test/SignalProcessing_test.cpp | 10 ++++--- code/test/TransmitChain_test.cpp | 6 +++-- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/code/test/Radio_test.cpp b/code/test/Radio_test.cpp index e8c3fb6..990ad13 100644 --- a/code/test/Radio_test.cpp +++ b/code/test/Radio_test.cpp @@ -33,9 +33,31 @@ void start_timer1ms() { timer_running.store(true); timer_thread = std::thread([]() { + // The CW keyer state machine measures elapsed time by *counting* DO events: + // every timer1ms() dispatch advances markCount_ms/spaceCount_ms by one, and + // transitions fire when those counts reach ditDuration_ms etc. So one DO + // event must correspond to one millisecond. The test, however, checks state + // transitions against the wall-clock millis(). + // + // The naive "timer1ms(); sleep_for(1ms);" loop drifts: sleep_for() sleeps for + // *at least* 1ms (and the thread can be starved under load), so the DO-event + // clock falls progressively behind the wall clock. Over a full dit/dah that + // drift exceeds the test's grace windows and the SM ends up a whole state + // behind what the test expects -- the source of the flakiness. + // + // Instead, lock the DO-event clock to the wall clock: fire exactly one DO per + // millisecond of wall time that has actually elapsed. Polling well under 1ms + // keeps the lag tiny, and detecting a backwards jump re-baselines after the + // StartMillis() resets the test performs. + int64_t last = millis(); while (timer_running.load()) { - timer1ms(); - std::this_thread::sleep_for(std::chrono::milliseconds(1)); + int64_t now = millis(); + if (now < last) last = now; // millis() was reset via StartMillis() + while (last < now) { // catch up: one DO per elapsed ms + timer1ms(); + last++; + } + std::this_thread::sleep_for(std::chrono::microseconds(100)); } }); } @@ -387,7 +409,7 @@ TEST(Radio, RadioStateRunThrough) { for (size_t i = 0; i < 600; i++){ loop(); MyDelay(1); int64_t m = millis(); - + // Check that the mode state machine is changing as expected if (m-m0 < DIT_DURATION_MS-2){ EXPECT_EQ(modeSM.state_id, ModeSm_StateId_CW_TRANSMIT_DIT_MARK); @@ -397,10 +419,14 @@ TEST(Radio, RadioStateRunThrough) { EXPECT_EQ(modeSM.state_id, ModeSm_StateId_CW_TRANSMIT_KEYER_SPACE); CheckThatStateIsCWTransmitSpace(); } - if ((m-m0 > 2*DIT_DURATION_MS+10) & (m-m0 < (2*DIT_DURATION_MS+CW_TRANSMIT_SPACE_TIMEOUT_MS+1))){ + // Upper bound is exclusive at the WAIT->RECEIVE transition (no +1): with the + // wall-clock-locked DO timer a transition can only land at or after its + // nominal time, so sampling the transition tick itself would spuriously see + // RECEIVE. (Matches the dit-dit-dah loop below.) + if ((m-m0 > 2*DIT_DURATION_MS+10) & (m-m0 < (2*DIT_DURATION_MS+CW_TRANSMIT_SPACE_TIMEOUT_MS))){ EXPECT_EQ(modeSM.state_id, ModeSm_StateId_CW_TRANSMIT_KEYER_WAIT); CheckThatStateIsCWTransmitSpace(); - } + } if (m-m0 > (2*DIT_DURATION_MS+CW_TRANSMIT_SPACE_TIMEOUT_MS+25+150)){ // 25 ms grace + 150 ms hardware change EXPECT_EQ(modeSM.state_id, ModeSm_StateId_CW_RECEIVE); CheckThatStateIsReceive(); @@ -431,10 +457,12 @@ TEST(Radio, RadioStateRunThrough) { EXPECT_EQ(modeSM.state_id, ModeSm_StateId_CW_TRANSMIT_KEYER_SPACE); CheckThatStateIsCWTransmitSpace(); } - if ((m-m0 > DIT_DURATION_MS*4+30) & (m-m0 < (DIT_DURATION_MS*4+CW_TRANSMIT_SPACE_TIMEOUT_MS+1))){ + // Upper bound is exclusive at the WAIT->RECEIVE transition (no +1); see note + // in the dit loop above. + if ((m-m0 > DIT_DURATION_MS*4+30) & (m-m0 < (DIT_DURATION_MS*4+CW_TRANSMIT_SPACE_TIMEOUT_MS))){ EXPECT_EQ(modeSM.state_id, ModeSm_StateId_CW_TRANSMIT_KEYER_WAIT); CheckThatStateIsCWTransmitSpace(); - } + } if (m-m0 > (DIT_DURATION_MS*4+CW_TRANSMIT_SPACE_TIMEOUT_MS+35+150)){ // 35 ms grace + 150 ms hardware change //Debug(String(m-m0)); EXPECT_EQ(modeSM.state_id, ModeSm_StateId_CW_RECEIVE); diff --git a/code/test/SignalProcessing_test.cpp b/code/test/SignalProcessing_test.cpp index 1a4b1be..5a4f85f 100644 --- a/code/test/SignalProcessing_test.cpp +++ b/code/test/SignalProcessing_test.cpp @@ -202,10 +202,12 @@ TEST(SignalProcessing, ReadDataIntoBuffers){ ReadIQInputBuffer(&data); #include "mock_R_data_int.c" #include "mock_L_data_int.c" - EXPECT_NEAR(data.I[1],(float)R_mock[1]/32768.0,0.00001); - EXPECT_NEAR(data.Q[1],(float)L_mock[1]/32768.0,0.00001); - EXPECT_NEAR(data.I[2047],(float)R_mock[2047]/32768.0,0.00001); - EXPECT_NEAR(data.Q[2047],(float)L_mock[2047]/32768.0,0.00001); + // I is read from the L channel and Q from the R channel (see ReadIQInputBuffer + // after the image-formation fix in commit 04b277a). + EXPECT_NEAR(data.I[1],(float)L_mock[1]/32768.0,0.00001); + EXPECT_NEAR(data.Q[1],(float)R_mock[1]/32768.0,0.00001); + EXPECT_NEAR(data.I[2047],(float)L_mock[2047]/32768.0,0.00001); + EXPECT_NEAR(data.Q[2047],(float)R_mock[2047]/32768.0,0.00001); } // Reading data into the input buffers returns false when it is empty diff --git a/code/test/TransmitChain_test.cpp b/code/test/TransmitChain_test.cpp index 763f037..02cf141 100644 --- a/code/test/TransmitChain_test.cpp +++ b/code/test/TransmitChain_test.cpp @@ -872,8 +872,10 @@ TEST(TransmitChain, TransmitReceiveProcessingPSDContainsCorrectTone){ // For spectrum_zoom = 3, decimation by 8 gives effective sample rate of 24 kHz // The FFT is 512 points covering 24 kHz // Bin width = 24000 / 512 = 46.875 Hz - // For 6 kHz tone: bin = 512/2 + 512*6000/24000 = 256 + 128 = 384 - int32_t expectedBin = frequency_to_bin(tone_Hz, 512, sampleRate_Hz / 8); + // ReadIQInputBuffer maps I<-L and Q<-R (image-formation fix, commit 04b277a), + // which places this tone in the lower sideband, so the peak is mirrored about + // the center bin: bin = 512/2 - 512*6000/24000 = 256 - 128 = 128 + int32_t expectedBin = 512 - frequency_to_bin(tone_Hz, 512, sampleRate_Hz / 8); // Find the peak in the PSD float32_t maxPsd = -1e10; From 436707575f65af590ea16e05a3b807b375549bf0 Mon Sep 17 00:00:00 2001 From: Oliver King Date: Sat, 6 Jun 2026 11:14:53 -0400 Subject: [PATCH 7/7] Added CLAUDE.md file and some skills --- .claude/skills/flash-radio/SKILL.md | 56 +++++++ .claude/skills/flash/SKILL.md | 77 ++++++++++ .claude/skills/run-tests/SKILL.md | 54 +++++++ .claude/skills/timing-measurement/SKILL.md | 166 +++++++++++++++++++++ .claude/skills/transmit-test/SKILL.md | 76 ++++++++++ .gitignore | 2 + CLAUDE.md | 151 +++++++++++++++++++ code/src/PhoenixSketch/BuildInfo.h | 4 +- 8 files changed, 584 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/flash-radio/SKILL.md create mode 100644 .claude/skills/flash/SKILL.md create mode 100644 .claude/skills/run-tests/SKILL.md create mode 100644 .claude/skills/timing-measurement/SKILL.md create mode 100644 .claude/skills/transmit-test/SKILL.md create mode 100644 CLAUDE.md diff --git a/.claude/skills/flash-radio/SKILL.md b/.claude/skills/flash-radio/SKILL.md new file mode 100644 index 0000000..afc5cac --- /dev/null +++ b/.claude/skills/flash-radio/SKILL.md @@ -0,0 +1,56 @@ +--- +name: flash-radio +description: Compile the PhoenixSketch production firmware and flash it to the Teensy 4.1 radio over USB. Use when the user asks to build, compile, upload, flash, deploy, or program the radio / Teensy / Phoenix firmware. +--- + +# flash-radio + +Compiles `code/src/PhoenixSketch/PhoenixSketch.ino` with `arduino-cli` and uploads the resulting `.hex` to the connected Teensy 4.1. + +The build settings come from `code/.vscode/arduino.json` and must stay in sync with it: + +| Setting | Value | +| --- | --- | +| Board (FQBN base) | `teensy:avr:teensy41` | +| FQBN options | `usb=serial2,speed=600,opt=o1lto,keys=en-us` | +| Sketch | `src/PhoenixSketch/PhoenixSketch.ino` (relative to `code/`) | +| Output dir | `../ArduinoOutput` (relative to `code/`) | +| Default port | `/dev/ttyACM0` | + +If those values diverge from `code/.vscode/arduino.json`, prefer the file and tell the user. + +## Steps + +1. **Check the board is connected.** Run `arduino-cli board list` and confirm a Teensy is present (it usually appears as `/dev/ttyACM0`, FQBN `teensy:avr:teensy41`). If no Teensy is listed, stop and tell the user to connect/power the radio — do not try to flash a missing device. + +2. **Compile.** From the `code/` directory: + ```bash + arduino-cli compile \ + --fqbn "teensy:avr:teensy41:usb=serial2,speed=600,opt=o1lto,keys=en-us" \ + --output-dir ../ArduinoOutput \ + src/PhoenixSketch/PhoenixSketch.ino + ``` + Compiles take a while; run with a generous Bash timeout (e.g. 600000 ms / 10 min). If compilation fails, surface the first error and stop — do not flash a broken build. + +3. **Flash.** If the port discovered in step 1 differs from `/dev/ttyACM0`, use that one. From the `code/` directory: + ```bash + arduino-cli upload \ + -p /dev/ttyACM0 \ + --fqbn "teensy:avr:teensy41:usb=serial2,speed=600,opt=o1lto,keys=en-us" \ + --input-dir ../ArduinoOutput \ + src/PhoenixSketch/PhoenixSketch.ino + ``` + The Teensy loader may prompt the user to press the physical button on the board — mention this if upload appears to hang. + +4. **Report.** Summarise in 1–2 lines: whether compile succeeded, whether upload succeeded, and any notable warnings. + +## Args + +- `compile-only` — run step 2 only; skip the flash step. Useful when no board is connected or the user just wants to verify the build. +- `flash-only` — skip compile and re-flash whatever is already in `../ArduinoOutput`. Only valid if that directory contains a recent build for this sketch; otherwise fall back to the full compile+flash. + +## Notes + +- Always run from `code/` so the relative paths in `arduino.json` resolve correctly. +- Do **not** invoke this skill to build the unit tests — those use CMake under `code/test/build/` and are a separate workflow. +- The `code/build/` directory is the CMake test build; the firmware artifacts go in `ArduinoOutput/` at the repo root. diff --git a/.claude/skills/flash/SKILL.md b/.claude/skills/flash/SKILL.md new file mode 100644 index 0000000..6a3566b --- /dev/null +++ b/.claude/skills/flash/SKILL.md @@ -0,0 +1,77 @@ +--- +name: flash +description: Compile and flash the PhoenixSketch firmware to the T41 radio (Teensy 4.1). Use when the user wants to build, compile, upload, or flash the production firmware to the radio. Requires the radio connected via USB at /dev/ttyACM0. +when_to_use: "flash radio", "compile firmware", "upload firmware", "build and flash", "program the radio", "flash the Teensy" +argument-hint: "[--compile-only]" +arguments: [compile_only] +allowed-tools: Bash(arduino-cli*) Bash(ls /dev/ttyACM*) Bash(ls /dev/ttyUSB*) +user-invocable: true +--- + +# Flash PhoenixSketch to T41 Radio + +Compile the PhoenixSketch Arduino firmware and upload it to the Teensy 4.1 via USB. + +## Build configuration + +From `code/.vscode/arduino.json`: +- **Sketch**: `code/src/PhoenixSketch/PhoenixSketch.ino` +- **Board**: `teensy:avr:teensy41` +- **Settings**: Dual Serial, 600 MHz, Fast with LTO (`usb=serial2,speed=600,opt=o1lto,keys=en-us`) +- **Output dir**: `ArduinoOutput/` (repo root) +- **Port**: `/dev/ttyACM0` + +## Steps + +### 1. Check the radio is connected + +```bash +ls /dev/ttyACM* +``` + +If `/dev/ttyACM0` is absent, the radio is not connected or not powered on. Stop and tell the user. + +### 2. Compile + +Run from the `code/` directory: + +```bash +cd /home/oliver/Sync/Ham/T41/Software/Phoenix/code && \ +arduino-cli compile \ + --fqbn "teensy:avr:teensy41:usb=serial2,speed=600,opt=o1lto,keys=en-us" \ + --libraries lib \ + --output-dir ../ArduinoOutput \ + src/PhoenixSketch/PhoenixSketch.ino +``` + +`--libraries lib` adds the in-tree `code/lib/` directory (which holds +the Phoenix-owned forked libraries, e.g. `RA8875_DMA`) to arduino-cli's +search path. + +Expected memory report on success (approximate): +``` +FLASH: code:~287KB free for files:~7.7MB + RAM1: variables:~127KB free for local variables:~102KB + RAM2: variables:~300KB free for malloc/new:~224KB +``` + +If compilation fails, report the full error output to the user and stop — do not attempt to upload. + +### 3. Upload (skip if `--compile-only` was passed) + +```bash +arduino-cli upload \ + --fqbn "teensy:avr:teensy41:usb=serial2,speed=600,opt=o1lto,keys=en-us" \ + --port /dev/ttyACM0 \ + --input-dir /home/oliver/Sync/Ham/T41/Software/Phoenix/ArduinoOutput \ + /home/oliver/Sync/Ham/T41/Software/Phoenix/code/src/PhoenixSketch/PhoenixSketch.ino +``` + +A successful upload ends with the Teensy rebooting and the radio display restarting. + +## Reporting results + +- **Success**: Report the memory usage numbers from the compile step, confirm upload succeeded. +- **Compile error**: Show the compiler error message. Common causes: missing library, syntax error. +- **Upload error**: The Teensy loader may need the reset button pressed — tell the user to press the white button on the Teensy board, then retry the upload command. +- **Port not found**: Tell the user to check USB connection and that the radio is powered. diff --git a/.claude/skills/run-tests/SKILL.md b/.claude/skills/run-tests/SKILL.md new file mode 100644 index 0000000..46d5da7 --- /dev/null +++ b/.claude/skills/run-tests/SKILL.md @@ -0,0 +1,54 @@ +--- +name: run-tests +description: Compile and run the PhoenixSketch unit tests under code/test using CMake + Google Test. Use when the user asks to run, build, or check the unit tests / ctest / Google Test suite for the radio firmware. +--- + +# run-tests + +Builds and runs the host-side unit tests in `code/test/` using CMake and `ctest`. The tests use mocked Arduino/hardware interfaces and run on the development machine — they do **not** touch the Teensy. + +| Setting | Value | +| --- | --- | +| Source dir | `code/test/` | +| Build dir | `code/test/build/` | +| Test runner | `ctest` (Google Test discovered via CMake) | + +## Steps + +1. **Ensure the build directory exists.** If `code/test/build/` is missing, create it. If the user passed `clean`, remove it first and recreate. + ```bash + mkdir -p code/test/build + ``` + +2. **Configure (if needed).** From `code/test/build/`, run cmake. CMake re-configures itself on subsequent runs as needed, so it's safe to run unconditionally: + ```bash + cmake .. + ``` + +3. **Build.** From `code/test/build/`: + ```bash + make + ``` + Builds can take a while on a clean tree; use a generous Bash timeout (e.g. 600000 ms). If the build fails, surface the first error and stop — do not run tests on a broken build. + +4. **Run tests.** From `code/test/build/`: + ```bash + ctest --output-on-failure + ``` + Add `-R ` if the user passed a filter arg, and `-V` if they passed `verbose`. + +5. **Report.** Summarise in 1–2 lines: how many tests passed/failed, and the names of any failing tests. If a specific filter was used, mention what it matched. + +## Args + +- `clean` — wipe `code/test/build/` and reconfigure from scratch before building. Use when CMake state seems stale or the user explicitly asks for a clean build. +- `verbose` — pass `-V` to ctest so individual test output is shown even on success. +- *Any other arg* — treat as a ctest `-R` regex filter (e.g. `RFBoard` runs `ctest -R RFBoard --output-on-failure`). Multiple words can be combined into one regex with `|`. + +`clean` and `verbose` may be combined with a filter arg. + +## Notes + +- Always run from `code/test/build/` once it exists — relative paths in `CMakeLists.txt` assume that. +- This skill is for host unit tests only. To build/flash the firmware itself, use the `flash-radio` skill instead. +- The CMake test build lives under `code/test/build/`; the firmware build artifacts (`code/build/`, `ArduinoOutput/`) are unrelated and should not be touched here. diff --git a/.claude/skills/timing-measurement/SKILL.md b/.claude/skills/timing-measurement/SKILL.md new file mode 100644 index 0000000..8b25ac6 --- /dev/null +++ b/.claude/skills/timing-measurement/SKILL.md @@ -0,0 +1,166 @@ +--- +name: timing-measurement +description: Measure execution time of functions or code regions in the PhoenixSketch firmware by instrumenting the source with Flag() calls and capturing the resulting pin transitions on an Analog Discovery 2. Use when the user wants to profile, time, benchmark, or characterize how long a piece of radio code takes to run. +when_to_use: "how long does X take", "measure execution time", "profile function", "time the radio code", "benchmark code on radio", "characterize timing", "how fast does X run" +argument-hint: "" +allowed-tools: Bash(arduino-cli*) Bash(ls /dev/ttyACM*) Bash(*flag_timing.py*) Bash(git diff*) Bash(sleep*) Read Edit +user-invocable: true +--- + +# Timing Measurement + +Measure how long a piece of code in the PhoenixSketch firmware takes to execute by instrumenting it with `Flag()` calls, capturing the resulting pin transitions on the AD2, and analyzing them with `code/tools/flag_timing.py`. + +## How it works + +`Flag(uint8_t val)` (defined in `Globals.cpp`) writes a 4-bit value to Teensy pins 28-31. The AD2 records those transitions; `flag_timing.py` decodes them back into flag values and reports the time between any pair. Each `Flag()` call takes roughly **1 µs** (four sequential `digitalWrite()`s); subtract from intervals when sub-µs precision matters. + +## Hardware requirements + +- Radio connected via USB at `/dev/ttyACM0` +- AD2 connected via USB with Teensy pins 28→DIO1, 29→DIO2, 30→DIO3, 31→DIO4 +- `libdwf.so` available (Digilent WaveForms SDK) + +Check both before starting. If the radio is missing, stop and tell the user. If the AD2 driver is missing, the capture step will fail with a clear error. + +## Flag value allocation + +Existing usage in `Loop.cpp::loop()`: **0, 1, 2, 4, 8**. Pick values from the unused set when instrumenting: **3, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15**. Trigger on a value that doesn't collide with anything along the path so the AD2 fires at the right point. + +Before instrumenting, grep for other `Flag(` calls in the area of interest to avoid colliding with code added in other parts of the firmware: + +```bash +grep -rn "Flag(" /home/oliver/Sync/Ham/T41/Software/Phoenix/code/src/PhoenixSketch/ +``` + +## Workflow + +### 1. Understand what to measure + +Parse the user's request to identify: + +- **The code region(s)**: a function call site, a block inside a function, multiple stages in a sequence +- **The trigger condition**: does the code run every loop iteration (easy), only on user input (need to prompt user to press a key during capture), or only in a specific mode (CW/SSB/transmit — confirm with the user) +- **The expected magnitude**: sub-µs, µs, ms — drives the sample-rate choice in step 5 + +If the request is ambiguous (e.g. "time the DSP"), ask the user which function or block before editing source. + +### 2. Plan the instrumentation + +- **Single-region timing**: pick one unused flag value `A` for "start" and a second `B` for "end". Insert `Flag(A);` immediately before the region and `Flag(B);` immediately after. +- **Multi-stage timing**: pick a distinct unused value for each stage boundary so all transitions are visible. +- Mark every inserted line with the comment `// TEMP timing instrumentation` so it's trivial to find and revert. + +State the plan back to the user — which files, which lines, which flag values — before editing if the change touches more than one file or more than ~4 insertion points. + +### 3. Insert instrumentation + +Use Edit to add the `Flag()` calls. Keep the rest of the file untouched. + +### 4. Compile and flash + +From `code/`: + +```bash +cd /home/oliver/Sync/Ham/T41/Software/Phoenix/code && \ +arduino-cli compile \ + --fqbn "teensy:avr:teensy41:usb=serial2,speed=600,opt=o1lto,keys=en-us" \ + --output-dir ../ArduinoOutput \ + src/PhoenixSketch/PhoenixSketch.ino + +arduino-cli upload \ + --fqbn "teensy:avr:teensy41:usb=serial2,speed=600,opt=o1lto,keys=en-us" \ + --port /dev/ttyACM0 \ + --input-dir /home/oliver/Sync/Ham/T41/Software/Phoenix/ArduinoOutput \ + /home/oliver/Sync/Ham/T41/Software/Phoenix/code/src/PhoenixSketch/PhoenixSketch.ino +``` + +If compilation fails, report the error and stop — don't attempt upload, and revert the instrumentation before exiting. + +### 5. Capture and measure + +Wait ~3 s for the radio to reboot, then run `flag_timing.py`. The script has two acquisition paths and picks automatically (`--mode auto`): + +- **Single-shot** (used when the requested capture fits in the AD2 on-board buffer, ~4-8K samples): low overhead, sub-µs trigger precision. Works well up to 10 MHz. +- **Record / streaming** (used for longer captures): streams samples over USB. The AD2's on-device ring is only ~4 KiB, so the script's Python polling loop has to drain it faster than `buffer_size / sample_rate`. Empirically, **250 kHz is the practical ceiling for clean 1-second captures**; 500 kHz typically loses ~1% of samples; 1 MHz loses 5–25%. + +Pick the sample rate by what you're measuring. Resolution is one sample period; buffer cost scales linearly with `sample_rate × duration` (each sample is 2 bytes). + +| Use case | --sample-rate | --duration | Mode | Memory | Resolution | +|---|---|---|---|---|---| +| Single tight event < 100 µs | `10e6` (default) | omit | single | small | 100 ns | +| Single event 0.1 – 1 ms | `5e6` | omit | single | small | 200 ns | +| Multi-iteration profile, 1 s, ms-scale work | `250e3` | `1` | record | ~0.5 MB | 4 µs | +| Multi-iteration profile, 1 s, sub-ms events | `500e3` | `1` | record | ~1 MB | 2 µs (expect ~1% lost) | + +Trigger on the first flag value in the sequence so the capture starts at a known point. For single-region timing use `--measure FROM TO` to get statistics over multiple loop iterations: + +```bash +sleep 3 && \ +/home/oliver/Sync/Ham/T41/Software/Phoenix/code/tools/venv/bin/python \ + /home/oliver/Sync/Ham/T41/Software/Phoenix/code/tools/flag_timing.py \ + --sample-rate 10e6 \ + --trigger flag --trigger-flag \ + --trigger-position 0.05 \ + --measure \ + --quiet +``` + +For a 1-second multi-iteration profile (many loop() cycles captured in a single run): + +```bash +sleep 3 && \ +/home/oliver/Sync/Ham/T41/Software/Phoenix/code/tools/venv/bin/python \ + /home/oliver/Sync/Ham/T41/Software/Phoenix/code/tools/flag_timing.py \ + --sample-rate 250e3 --duration 1 \ + --debounce-us 8 \ + --trigger flag --trigger-flag \ + --quiet > /tmp/profile.json +# Then: jq '.summary.flag_segment_stats' /tmp/profile.json +``` + +At 250 kHz the resolution is 4 µs, which is plenty for ms-scale draw work +but means very short transitions (Flag(4) event-processing windows, the +loop-idle Flag(0) gap) are absorbed into the trailing segment by debounce. +Bump `--sample-rate 500e3 --debounce-us 4` if you need to resolve sub-50-µs +events, accepting ~1% lost samples. + +For multi-stage timing, omit `--measure` and read `summary.flag_segment_stats` from the JSON: each flag value's entry reports `count, min_s, max_s, mean_s, median_s, p95_s, p99_s, stddev_s, total_s` across all occurrences in the capture. The full per-event list is in `segments` (each with `flag, start_time_s, end_time_s, duration_s`). + +If the capture times out with "Timed out waiting for acquisition" the instrumented code path isn't executing — check the radio mode, verify the user has done whatever action triggers the code (key press, PTT, etc.), and try again. Increase trigger position frac if you want more pre-trigger context. + +If `metadata.lost_samples > 0`, USB couldn't sustain the requested rate. Drop the sample rate (e.g. 5 MHz → 1 MHz) and re-capture; the timing in that run is suspect. The script also exits non-zero (code 2) automatically when lost samples exceed `--max-lost-frac` (default 0.1%). + +### 6. Analyze and report + +From the JSON output, report: + +- **Mean / median / p95 / p99 / max** of the measured interval(s) — for multi-iteration captures, p95 and p99 are the headline numbers for spotting tail regressions +- **Sample count** (how many iterations the capture saw) +- **Caveats**: subtract ~1 µs per intervening `Flag()` call if precision below 1 µs matters; flag any outliers +- **Anomalies**: unexpected flag values in the trace, very different mean vs. p99 (suggests intermittent slow paths), non-zero `lost_samples` (timing unreliable — re-capture at lower sample rate) + +Present as a short table or bullet list. Include the raw JSON only if the user asks. + +### 7. Revert instrumentation + +This step is mandatory — do not leave temporary `Flag()` calls in the source tree. + +1. Use Edit to remove every line tagged `// TEMP timing instrumentation` and the inserted `Flag()` call on the same line. +2. Confirm with `git diff code/src/PhoenixSketch/` that the only remaining diff is the pre-existing state (the file is back to where it started). +3. Re-compile and re-upload so the running firmware matches the source tree. + +If you skip the re-flash, the radio will keep emitting the instrumentation pins on every loop iteration, which is confusing for the next person who uses the radio. + +## Pitfalls + +- **Two acquisition modes**: single-shot uses the AD2's ~8K-sample on-board buffer (low overhead, sub-µs trigger precision). Record mode streams over USB for arbitrary-length captures (1+ second). `--mode auto` (the default) picks one based on `sample_rate × duration`; force with `--mode {single,record}`. +- **Lost samples in record mode**: the AD2's on-device ring buffer is only ~4 KiB, and the Python streaming loop in `flag_timing.py` has to drain it faster than the device fills it. In practice this means ~250 kHz for clean 1-second captures; 500 kHz typically loses ~1%; 1 MHz loses 5–25%. `metadata.lost_samples` and `lost_fraction` report this. The script exits non-zero (code 2) when `lost_fraction` exceeds `--max-lost-frac` (default 0.1%). Drop the sample rate and re-capture, or raise `--max-lost-frac` if you're willing to accept some gaps in the timeline. +- **Debounce eats short segments**: default `--debounce-us 2.0` absorbs glitches from `Flag()`'s ~1 µs sequential writes and stays effective across the full 500 kHz – 10 MHz sample-rate range. It also absorbs intentional flag values shorter than 2 µs. Set `--debounce-us 0` if you need to see every transition. +- **Multi-iteration captures**: a 1-second record at ~10 ms per `loop()` iteration captures ~100 iterations. `summary.flag_segment_stats` reports per-flag p50/p95/p99 across all of them — use this to characterize variability and detect tail regressions when comparing runs. +- **Loop-coupled measurements**: if the instrumented code is inside `loop()`, other loop work (interrupts, display refresh) can perturb timing. Compare mean to p99 to spot this. +- **Mode-gated code**: SSB/CW/transmit code only runs when the radio is in that mode. Ask the user to set the mode before capture if needed. For UI-state-gated draw functions (e.g. `DrawHome` only runs in the HOME UI state), confirm the radio is in the right state for the whole capture. + +## Reporting + +End with a one- or two-line summary of what was measured and the headline number (e.g. `PerformSignalProcessing: 2.84 ms mean (min 2.81, max 2.91, n=4 iterations)`), then a sentence confirming the instrumentation has been reverted and the radio reflashed. diff --git a/.claude/skills/transmit-test/SKILL.md b/.claude/skills/transmit-test/SKILL.md new file mode 100644 index 0000000..0b5db1c --- /dev/null +++ b/.claude/skills/transmit-test/SKILL.md @@ -0,0 +1,76 @@ +--- +name: transmit-test +description: Run the PTT signal integrity test on the radio transmitter hardware. Use when the user asks to test the transmitter, run a PTT test, check I/Q outputs, or verify transmit signal quality. Requires an Analog Discovery 2 connected to the radio. +when_to_use: "test transmitter", "run PTT test", "check I/Q", "verify transmit", "signal integrity test", "test radio" +argument-hint: "[iterations]" +arguments: [iterations] +allowed-tools: Bash(python3 transmit_test.py*) Read +user-invocable: true +--- + +# Transmit PTT Signal Integrity Test + +Run `transmit_test.py` to verify the radio transmitter's I/Q outputs are clean sinusoids across repeated PTT cycles. This detects intermittent failures where PTT activation produces discontinuities, zeroed channels, or distorted waveforms. + +## Hardware requirements + +An Analog Discovery 2 must be connected via USB with the following wiring: +- **DIO-0** controls PTT (LOW = transmit, HIGH = off) +- **W1** drives the microphone input (500 Hz, 100 mV sine) +- **Scope Ch1/Ch2** read the I and Q quadrature outputs + +## Running the test + +Run from the `transmit` directory: + +``` +cd /home/oliver/Sync/Ham/T41/Software/Phoenix/code/test/transmit +python3 transmit_test.py -n $iterations +``` + +If no iteration count is provided, default to 5 for a quick smoke test. For a full test run, use `-n 100`. + +### Interpreting results + +The program exits with code 0 if all iterations pass, or code 1 if any fail. Each iteration reports: + +- **PASS/FAIL** status +- **RMS amplitude** of each channel (expect 50-200 mV; below 5 mV = dead channel) +- **SNR** in dB (expect >40 dB; below 20 dB = distorted) +- **Dominant frequency** (expect 500 Hz) + +Failed iterations automatically save raw waveform data to `failures/` as `.npz` files along with a png plot. + +### Typical failure modes + +| Symptom | Likely cause | +|---------|-------------| +| RMS near zero on one/both channels | I/Q output not starting after PTT | +| Low SNR, correct frequency | Discontinuity or glitch in waveform | + +### Common options + +| Flag | Default | Description | +|------|---------|-------------| +| `-n`, `--iterations` | 100 | Number of PTT cycles | +| `--settle-time` | 0.5 | Seconds after PTT engage before capture | +| `--acquire-time` | 0.1 | Capture duration in seconds | +| `--interval` | 0.5 | Seconds between iterations | +| `--min-snr` | 20.0 | Minimum SNR (dB) to pass | +| `--save-all` | off | Save waveform data for passing iterations too | + +## If the test fails + +1. Report the summary line and any failure reasons to the user. +2. If `.npz` files were saved, you can load and inspect them: + ```python + import numpy as np + data = np.load("failures/fail_iter0001_TIMESTAMP.npz") + ch1, ch2 = data["ch1"], data["ch2"] # I and Q waveform arrays + sr = float(data["sample_rate"]) # 100000 Hz + ``` +3. Do NOT attempt to fix `transmit_test.py` based on test failures -- the failures indicate a hardware/firmware problem in the radio, not a bug in the test program. + +## If the AD2 is not connected + +The program will print `Failed to open device` and exit with code 1. This is expected if no AD2 is plugged in. Do not retry -- inform the user that the hardware is not available. diff --git a/.gitignore b/.gitignore index f665c04..bd7309d 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,5 @@ all_*_tests # MkDocs site/ .cache/ + +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c4fb1b5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,151 @@ +# CLAUDE.md - Phoenix SDR Radio Project + +## Project Overview + +This is the **Phoenix SDR (Software Defined Radio)** project - firmware for a Teensy 4.1-based amateur radio transceiver. The project implements a complete SDR radio system using state machines for hardware control and real-time digital signal processing. + +## Architecture + +### Core Components +- **Hardware Platform**: Teensy 4.1 microcontroller +- **Language**: C/C++ with Arduino framework (`.ino`, `.cpp`, `.h` files) +- **State Machines**: Generated from UML diagrams using StateSmith +- **Real-time DSP**: OpenAudio library for signal processing +- **Testing**: Google Test framework with comprehensive unit tests + +### Key Features +- Dual VFO (Variable Frequency Oscillator) operation +- SSB (Single Sideband) and CW (Morse Code) modes +- Real-time digital signal processing +- State machine-controlled hardware management +- CAT (Computer Aided Transceiver) control interface +- Comprehensive test coverage with mocking framework + +## Code Structure + +### Main Source Directory: `code/src/PhoenixSketch/` +- **PhoenixSketch.ino**: Main Arduino sketch entry point +- **Loop.cpp/h**: Main program loop implementation +- **ModeSm.cpp/h**: Radio mode state machine (generated code) +- **UISm.cpp/h**: User interface state machine (generated code) +- **RFBoard.cpp/h**: RF hardware control and VFO management +- **Tune.cpp/h**: Frequency tuning and VFO control with state machine +- **DSP*.cpp/h**: Digital signal processing modules +- **CAT.cpp/h**: Computer control interface +- **FrontPanel*.cpp/h**: Physical front panel interface +- **SDT.h**: Main system definitions and configuration + +### Test Directory: `code/test/` +- **CMakeLists.txt**: CMake build configuration for tests +- **\*_test.cpp**: Comprehensive unit tests for all modules +- **\*_mock.cpp**: Mock implementations for testing +- **Arduino.h/Arduino_mock.cpp**: Arduino framework mocking + +## Build System + +### Arduino IDE Build +```bash +# Install Arduino IDE 2.3.6+ with Teensyduino +# Install required libraries: +# - Adafruit MCP23017 (via Library Manager) +# - OpenAudio (manual install from GitHub) +``` + +### Unit Testing (Google Test + CMake) +```bash +# Create build directory +mkdir code/test/build + +# Build and run tests +cd code/test/build +cmake ../ && make +ctest --output-on-failure +``` + +## Key Development Patterns + +### State Machine Architecture +The project uses **StateSmith**-generated state machines for hardware control: +- `ModeSm`: Controls radio operating mode (SSB_RECEIVE, SSB_TRANSMIT, CW_TRANSMIT, etc.) +- `UISm`: Manages user interface states +- State transitions are event-driven and deterministic +- All hardware state changes go through the state machines + +### Frequency Control State Machine +The `Tune.cpp` module implements a frequency control state machine with three states: +- **TuneReceive**: Sets SSB VFO for RX operation +- **TuneSSBTX**: Sets SSB VFO for TX operation +- **TuneCWTX**: Sets CW VFO with tone offset for TX operation + +### Real-time Constraints +- Main loop must complete within 10ms to avoid audio buffer overflows +- Interrupt-driven button handling with event queuing +- State-based hardware control ensures deterministic timing + +### Testing Strategy +- Extensive mocking of Arduino and hardware interfaces +- Unit tests cover all major modules and state transitions +- Mock objects for Si5351 VFO, GPIO, and audio interfaces +- Test-driven development approach encouraged + +## Working with This Codebase + +### When Making Changes +1. **Run existing tests first**: `cd code/test/build && ctest` +2. **Follow state machine patterns**: Hardware changes should go through state machines +3. **Add unit tests**: All new functionality should have corresponding tests +4. **Respect real-time constraints**: Keep main loop execution under 10ms +5. **Use mocking for hardware**: Tests should use provided mock interfaces + +### Common Tasks +- **Adding new DSP functions**: Follow patterns in `DSP_*.cpp` files +- **Modifying state machines**: Update `.drawio` diagrams and regenerate with StateSmith +- **New hardware interfaces**: Create a `.cpp` and a `.h` file to hold hardware-specific code (look at `RFBoard.cpp` for an example) with corresponding tests +- **Frequency/tuning changes**: Work with the tune state machine in `Tune.cpp` + +### Build Commands +```bash +# Arduino build: Use Arduino IDE with Teensyduino +# Unit tests: +cd code/test/build +cmake ../ && make && ctest --output-on-failure + +# Lint/format (if available): +# Follow existing code style in the codebase +``` + +### Test Commands +```bash +# Run all tests +ctest --output-on-failure + +# Run specific test suite +ctest -R RFBoard --output-on-failure + +# Run with verbose output +ctest -V +``` + +## Dependencies + +### Runtime Dependencies +- Teensyduino (Teensy 4.1 Arduino framework) +- OpenAudio_ArduinoLibrary (real-time audio processing) +- Adafruit MCP23017 (I2C GPIO expander) + +### Development Dependencies +- Arduino IDE 2.3.6+ +- StateSmith (for state machine code generation) +- CMake (for unit testing) +- Google Test (automatically downloaded) + +### Optional Dependencies +- draw.io (for editing state machine diagrams) + +## Hardware Target +- **Primary**: Teensy 4.1 microcontroller (ARM Cortex-M7) +- **Audio**: Real-time 48kHz/96kHz/192kHz sampling +- **RF**: Si5351 clock generator for VFO control +- **Interface**: MCP23017 I2C GPIO expanders for front panel + +This is a sophisticated embedded project combining real-time signal processing, state machine control, and comprehensive testing in the amateur radio domain. \ No newline at end of file diff --git a/code/src/PhoenixSketch/BuildInfo.h b/code/src/PhoenixSketch/BuildInfo.h index a49e1dc..c5d7d69 100644 --- a/code/src/PhoenixSketch/BuildInfo.h +++ b/code/src/PhoenixSketch/BuildInfo.h @@ -9,7 +9,7 @@ #ifndef BUILDINFO_H #define BUILDINFO_H -#define GIT_COMMIT_HASH "d15b17e" -#define BUILD_TIMESTAMP "2026-05-30 14:18:06" +#define GIT_COMMIT_HASH "024095a" +#define BUILD_TIMESTAMP "2026-06-06 11:12:26" #endif // BUILDINFO_H