diff --git a/Software/Firmware/ADAU1701-Firmware/SigmaStudioFW.h b/Software/Firmware/ADAU1701-Firmware/SigmaStudioFW.h index b961913..3462031 100644 --- a/Software/Firmware/ADAU1701-Firmware/SigmaStudioFW.h +++ b/Software/Firmware/ADAU1701-Firmware/SigmaStudioFW.h @@ -49,15 +49,19 @@ void sigma_studio_unlock(void); /** * @brief Write a contiguous register/data block to the ADAU1701. * + * Retries each I2C chunk up to 3 times on NACK, matching the reliability + * already applied to the runtime safeload path (see sigma_i2c_write). + * * @param devAddress SigmaStudio device address (0x68 write addr). * @param address 16-bit target address in DSP memory map. * @param length Payload length in bytes (may exceed 255). * @param pData Payload bytes. + * @return 0 on success, non-zero if any chunk failed after retries. */ -void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress, - unsigned int address, - unsigned int length, - ADI_REG_TYPE* pData); +int SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress, + unsigned int address, + unsigned int length, + ADI_REG_TYPE* pData); /** * @brief Read a contiguous register/data block from the ADAU1701. @@ -69,6 +73,22 @@ void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress, */ int sigma_i2c_read(unsigned int reg, unsigned char* data, unsigned int length); +/** + * @brief Read back a previously written block and compare it byte-for-byte. + * + * Diagnostic aid for boot-time DSP program replay: a chunk that ACKed but + * still landed wrong (or a register that doesn't hold the value it was + * given) shows up here even though SIGMA_WRITE_REGISTER_BLOCK reported + * success. Read-only — never aborts anything by itself, callers decide. + * + * @param address 16-bit address the block was written to. + * @param expected Bytes that were written. + * @param length Payload length in bytes (may exceed 255). + * @return 0 if the read-back matches, non-zero on mismatch or read failure. + */ +int sigma_verify_block(unsigned int address, const ADI_REG_TYPE* expected, + unsigned int length); + /** Safeload data register base (0x0810..0x0814). */ #define ADAU1701_SAFELOAD_DATA_BASE 0x0810U /** Safeload address register base (0x0815..0x0819). */ diff --git a/Software/components/core/include/core/TunerJson.hpp b/Software/components/core/include/core/TunerJson.hpp index edad96f..a63d35f 100644 --- a/Software/components/core/include/core/TunerJson.hpp +++ b/Software/components/core/include/core/TunerJson.hpp @@ -276,4 +276,41 @@ struct AntennaCalibrationRequest { [[nodiscard]] std::expected parseAntennaCalibrationJson(std::string_view json); +/** + * @brief XtalCalibrationRequest — parsed POST + * /api/tuner/xtal-calibrate body. + * + * @dname XtalCalibrationRequest + * @return n/a (type) + * @pubstate Plain DTO filled by parseXtalCalibrationJson at the HTTP + * boundary. Diagnostic-only: live Si4684 crystal parameter + * recalibration, no ESP32 restart required. + * + * @author Michele Bigi + * @date 2026-08-23 + */ +struct XtalCalibrationRequest { + std::uint8_t ibias; ///< POWER_UP ARG3 IBIAS (0-127). + std::uint8_t ctun; ///< POWER_UP ARG8 CTUN (0-63). + std::uint32_t xtalFreqHz; ///< POWER_UP ARG4-7 XTAL_FREQ in Hz. +}; + +/** + * @brief parseXtalCalibrationJson — validate POST + * /api/tuner/xtal-calibrate body. + * + * @dname parseXtalCalibrationJson + * @param json Untrusted request body from the HTTP handler. + * @return Crystal parameters on success, or a ParseError. `ibias` and + * `ctun` default to the values already loaded at boot when + * omitted (unusual to omit, but harmless); `xtal_freq_hz` + * defaults to the nominal 19,200,000 Hz. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-08-23 + */ +[[nodiscard]] std::expected +parseXtalCalibrationJson(std::string_view json); + } // namespace core diff --git a/Software/components/core/include/core/TunerStatus.hpp b/Software/components/core/include/core/TunerStatus.hpp index f93963d..c8e4828 100644 --- a/Software/components/core/include/core/TunerStatus.hpp +++ b/Software/components/core/include/core/TunerStatus.hpp @@ -63,6 +63,11 @@ struct TunerStatus { std::optional fmChipReadFrequency; ///< FM_RSQ READFREQ (may lag commanded). std::optional fmRssiDbuV; ///< FM RSSI in dBµV. std::optional fmSnrDb; ///< FM SNR in dB. + /** AN649 FM_RSQ_STATUS FREQOFF, units of 2 PPM. Crystal calibration + * signal: real broadcast carriers are GPS/rubidium-locked, so a + * nonzero reading here reflects the local XTAL_FREQ/CTUN reference + * error, not the station. */ + std::optional fmFreqOffBppm; std::optional fmStereo; ///< FM stereo pilot detected. std::optional fmStationName; ///< FM RDS program service name. std::optional fmRadiotext; ///< FM RDS radiotext (RT). diff --git a/Software/components/core/src/TunerJson.cpp b/Software/components/core/src/TunerJson.cpp index 3df7952..84297ad 100644 --- a/Software/components/core/src/TunerJson.cpp +++ b/Software/components/core/src/TunerJson.cpp @@ -129,6 +129,10 @@ std::string serializeTunerStatusJson(const TunerStatus& status) if (status.fmSnrDb) { out << ",\"snr_db\":" << static_cast(*status.fmSnrDb); } + if (status.fmFreqOffBppm) { + out << ",\"freqoff_ppm\":" + << (static_cast(*status.fmFreqOffBppm) * 2); + } if (status.fmStereo) { out << ",\"stereo\":" << (*status.fmStereo ? "true" : "false"); } @@ -364,4 +368,41 @@ parseAntennaCalibrationJson(std::string_view json) return req; } +std::expected +parseXtalCalibrationJson(std::string_view json) +{ + if (json.find('{') == std::string_view::npos) { + return std::unexpected(ParseError::InvalidJson); + } + // Defaults match Si4684Driver::boot()'s own defaults -- a request that + // only wants to change one parameter can omit the others. + XtalCalibrationRequest req = {}; + req.ibias = 72U; + req.ctun = 31U; + req.xtalFreqHz = 19200000U; + + unsigned long ibias = 0U; + if (extractJsonUint(json, "ibias", ibias)) { + if (ibias > 127U) { + return std::unexpected(ParseError::InvalidJson); + } + req.ibias = static_cast(ibias); + } + + unsigned long ctun = 0U; + if (extractJsonUint(json, "ctun", ctun)) { + if (ctun > 63U) { + return std::unexpected(ParseError::InvalidJson); + } + req.ctun = static_cast(ctun); + } + + unsigned long xtalFreqHz = 0U; + if (extractJsonUint(json, "xtal_freq_hz", xtalFreqHz)) { + req.xtalFreqHz = static_cast(xtalFreqHz); + } + + return req; +} + } // namespace core diff --git a/Software/components/drivers/adau1701/src/Adau1701Driver.cpp b/Software/components/drivers/adau1701/src/Adau1701Driver.cpp index e4d2b6f..a664f70 100644 --- a/Software/components/drivers/adau1701/src/Adau1701Driver.cpp +++ b/Software/components/drivers/adau1701/src/Adau1701Driver.cpp @@ -68,6 +68,7 @@ namespace adau1701 { const unsigned char deviceAddr = static_cast(pins_.i2cAddr7 << 1); + std::size_t index = 0U; for (const core::RegisterWrite &write : program.writes()) { const auto data = write.data(); @@ -75,12 +76,35 @@ namespace adau1701 { return std::unexpected(Adau1701Error::DownloadFailed); } - SIGMA_WRITE_REGISTER_BLOCK( - deviceAddr, - write.address(), - static_cast(data.size()), - const_cast( - reinterpret_cast(data.data()))); + auto *bytes = const_cast( + reinterpret_cast(data.data())); + const auto length = static_cast(data.size()); + if (SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, write.address(), length, + bytes) != 0) + { + ESP_LOGE(kTag, + "program write #%u failed (addr=0x%04X len=%u) " + "after retries", + static_cast(index), + static_cast(write.address()), + static_cast(length)); + return std::unexpected(Adau1701Error::DownloadFailed); + } + // Diagnostic only: an ACKed write that still reads back wrong + // would otherwise be invisible (see the SIGMA_WRITE_REGISTER_BLOCK + // comment in SigmaStudioFW.c). Logged, not fatal -- some + // addresses in this range may be self-clearing/status bits + // that legitimately don't read back what was written. + if (sigma_verify_block(write.address(), bytes, length) != 0) + { + ESP_LOGW(kTag, + "program write #%u read-back mismatch (addr=0x%04X " + "len=%u) -- ACKed but did not land as written", + static_cast(index), + static_cast(write.address()), + static_cast(length)); + } + ++index; } return {}; } @@ -158,19 +182,69 @@ namespace adau1701 return replay; } + // Diagnostic: log EQ band 0's live Param RAM contents. This band is + // the fixed high-pass (SigmaStudio band 1) and applyEq() always + // skips it -- no runtime code path ever safeloads it, so whatever + // landed here at program-load time is what plays, permanently, + // regardless of the audio-profile API (which reports a fictional + // value for it). Read straight from the chip rather than trusting + // the compiled source, in case the two have ever diverged. + { + const unsigned baseAddr = paramAddrEqBandBase(0U); + for (unsigned i = 0U; i < 5U; ++i) + { + unsigned char raw[4U] = {0U, 0U, 0U, 0U}; + if (sigma_i2c_read(baseAddr + i, raw, sizeof(raw)) == 0) + { + const std::int32_t fixpoint = + static_cast( + (static_cast(raw[0]) << 24) | + (static_cast(raw[1]) << 16) | + (static_cast(raw[2]) << 8) | + static_cast(raw[3])); + ESP_LOGI(kTag, + "EQ band0 param[%u] addr=0x%04X raw=0x%08X " + "value=%f", + i, baseAddr + i, + static_cast(fixpoint), + static_cast(fixpoint) / + static_cast(1U << 23)); + } + else + { + ESP_LOGW(kTag, "EQ band0 param[%u] read-back failed", i); + } + } + } + // SerialInputRegister (0x081F) override: bit3 IBP=1, matching the // BCLK edge the Si4684's I2S output actually changes data on // (compiled DSP program default is 0x00 = IBP=0, which produced // pure static on a strong locked signal). ILP=1 was also tried // (0x18) and made it worse (pure white noise again) — IBP alone // (0x08) is the correct override, confirmed live: real, recognizable - // music instead of static/noise on a locked FM station. + // music instead of static/noise on a locked FM station. Redundant + // with the R3_HWCONFIGURATION program write above (SigmaStudio's + // own export already places 0x08 at this address) — kept as a + // belt-and-braces re-assert in case that specific chunk silently + // failed; now checked/verified like everything else instead of + // fire-and-forget. { const unsigned char deviceAddr = static_cast(pins_.i2cAddr7 << 1); ADI_REG_TYPE serialInFix = 0x08U; - SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, 0x081FU, 1U, - &serialInFix); + if (SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, 0x081FU, 1U, + &serialInFix) != 0) + { + ESP_LOGE(kTag, "SerialInputRegister override failed after retries"); + return std::unexpected(Adau1701Error::DownloadFailed); + } + if (sigma_verify_block(0x081FU, &serialInFix, 1U) != 0) + { + ESP_LOGW(kTag, + "SerialInputRegister read-back mismatch -- ACKed " + "but did not land as 0x08"); + } } booted_ = true; diff --git a/Software/components/drivers/adau1701/src/SigmaStudioFW.c b/Software/components/drivers/adau1701/src/SigmaStudioFW.c index cefd7c6..38c37d7 100644 --- a/Software/components/drivers/adau1701/src/SigmaStudioFW.c +++ b/Software/components/drivers/adau1701/src/SigmaStudioFW.c @@ -14,12 +14,14 @@ #include "SigmaStudioFW.h" #include "driver/i2c_master.h" +#include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" #include +static const char* kTag = "SigmaStudioFW"; static i2c_master_dev_handle_t s_dev = NULL; static SemaphoreHandle_t s_lock = NULL; @@ -73,14 +75,45 @@ static unsigned int sigmaWordSize(unsigned int address) return 4U; } -void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress, - unsigned int address, - unsigned int length, - ADI_REG_TYPE* pData) +/* + * The boot-time program/param replay was previously fire-and-forget: the + * i2c_master_transmit result was discarded outright, so a single transient + * NACK anywhere in the hundreds of chunked writes that make up a DSP + * program load silently left that one chunk (a gain, a filter, a mux) + * at its power-on-reset RAM contents instead of the SigmaStudio-designed + * value -- indistinguishable at the time from a correct load, but capable + * of producing exactly a persistent, localized audio artifact rather than + * gross silence. Give it the same retry-on-NACK reliability the runtime + * safeload path already got in sigma_i2c_write (see its comment) and make + * failure observable instead of silent. + */ +static int sigmaTransmitChunk(unsigned int addr, const ADI_REG_TYPE* payload, + unsigned int chunkBytes) +{ + unsigned char buf[2U + 64U]; + buf[0] = (unsigned char)((addr >> 8) & 0xFFU); + buf[1] = (unsigned char)(addr & 0xFFU); + memcpy(buf + 2U, payload, chunkBytes); + + static const int kMaxAttempts = 3; + for (int attempt = 0; attempt < kMaxAttempts; ++attempt) { + if (i2c_master_transmit(s_dev, buf, (size_t)(2U + chunkBytes), 1000) == + ESP_OK) { + return 0; + } + vTaskDelay(pdMS_TO_TICKS(2)); + } + return -1; +} + +int SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress, + unsigned int address, + unsigned int length, + ADI_REG_TYPE* pData) { (void)devAddress; if (s_dev == NULL || pData == NULL || length == 0U) { - return; + return -1; } enum { kChunkBytesMax = 64U }; @@ -96,15 +129,48 @@ void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress, const unsigned int chunk = remaining > chunkBytes ? chunkBytes : remaining; const unsigned int words = chunk / wordSize; - unsigned char buf[2U + kChunkBytesMax]; - buf[0] = (unsigned char)((addr >> 8) & 0xFFU); - buf[1] = (unsigned char)(addr & 0xFFU); - memcpy(buf + 2U, cursor, chunk); - i2c_master_transmit(s_dev, buf, (size_t)(2U + chunk), 1000); + if (sigmaTransmitChunk(addr, cursor, chunk) != 0) { + return -1; + } addr += words; cursor += chunk; remaining -= chunk; } + return 0; +} + +int sigma_verify_block(unsigned int address, const ADI_REG_TYPE* expected, + unsigned int length) +{ + if (s_dev == NULL || expected == NULL || length == 0U) { + return -1; + } + + enum { kChunkBytesMax = 64U }; + const unsigned int wordSize = sigmaWordSize(address); + const unsigned int wordsPerChunk = kChunkBytesMax / wordSize; + const unsigned int chunkBytes = wordsPerChunk * wordSize; + + unsigned int addr = address; + unsigned int remaining = length; + const ADI_REG_TYPE* cursor = expected; + + while (remaining > 0U) { + const unsigned int chunk = + remaining > chunkBytes ? chunkBytes : remaining; + const unsigned int words = chunk / wordSize; + unsigned char readBack[kChunkBytesMax]; + if (sigma_i2c_read(addr, readBack, chunk) != 0) { + return -1; + } + if (memcmp(readBack, cursor, chunk) != 0) { + return -1; + } + addr += words; + cursor += chunk; + remaining -= chunk; + } + return 0; } int sigma_i2c_read(unsigned int reg, unsigned char* data, unsigned int length) @@ -198,6 +264,37 @@ int sigma_safeload_param(unsigned int paramAddr, int fixpoint) return sigma_safeload_block(1U, addrs, values); } +/* + * Diagnostic only, mirrors the boot-replay read-back in + * SIGMA_WRITE_REGISTER_BLOCK: a safeload can ACK every transaction and + * still not land as intended (address/data written to the wrong Param RAM + * slot, a stale value left from a previous session's committed-but-later- + * garbled write, etc). This is the runtime path applied on every EQ/gain + * change and on the stored-profile replay at boot -- unlike the one-time + * program load, it was previously unverified. Log-only: some callers pass + * addresses this can't independently confirm are readable Param RAM (vs. + * a write-only control register), so a mismatch here is a strong signal, + * not a hard boot/apply-time failure. + */ +static void sigmaVerifyParam(unsigned int paramAddr, int fixpoint) +{ + unsigned char readBack[4U]; + if (sigma_i2c_read(paramAddr, readBack, sizeof(readBack)) != 0) { + ESP_LOGW(kTag, "safeload verify: read-back failed for param 0x%04X", + paramAddr); + return; + } + const int actual = (int)(((unsigned int)readBack[0] << 24) | + ((unsigned int)readBack[1] << 16) | + ((unsigned int)readBack[2] << 8) | + (unsigned int)readBack[3]); + if (actual != fixpoint) { + ESP_LOGW(kTag, + "safeload verify: param 0x%04X mismatch, wrote %d read %d", + paramAddr, fixpoint, actual); + } +} + int sigma_safeload_block(unsigned char count, const unsigned int* paramAddrs, const int* fixpoints) { @@ -218,7 +315,14 @@ int sigma_safeload_block(unsigned char count, const unsigned int* paramAddrs, } } - return sigma_trigger_safeload(); + if (sigma_trigger_safeload() != 0) { + return -1; + } + + for (unsigned char i = 0U; i < count; ++i) { + sigmaVerifyParam(paramAddrs[i], fixpoints[i]); + } + return 0; } int sigma_safeload_raw_block(unsigned char count, const unsigned int* paramAddrs, diff --git a/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp b/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp index 91e00f2..46d96a6 100644 --- a/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp +++ b/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp @@ -112,6 +112,16 @@ public: * verified live (72 = 720 uA startup bias). * @param xtalCtun POWER_UP ARG8 CTUN, 0-63 (AN649 §Command 0x01); * default matches the values already verified live. + * @param xtalFreqHz POWER_UP ARG4-7 XTAL_FREQ in Hz (AN649 §Command + * 0x01); default 19,200,000 (nominal crystal + * frequency). Deliberately overriding this away + * from the crystal's true nominal value is a valid + * software calibration trick: it tells the chip's + * internal PLL math "the crystal actually runs at + * this rate," compensating any real physical + * offset (from load-cap mismatch etc) without + * touching CTUN. See FM_RSQ_STATUS FREQOFF for the + * measurement this is meant to null out. * @return Ok on success, or Si4684Error. * @pubstate writes booted_ and loadedBand_ on success. * @@ -120,7 +130,28 @@ public: */ [[nodiscard]] std::expected boot( Si4684Band band, std::uint8_t xtalIbias = 72U, - std::uint8_t xtalCtun = 31U); + std::uint8_t xtalCtun = 31U, std::uint32_t xtalFreqHz = 19200000U); + + /** + * @brief recalibrateXtal — re-run boot() with new crystal parameters. + * + * @dname recalibrateXtal + * @param xtalIbias New POWER_UP ARG3 IBIAS. + * @param xtalCtun New POWER_UP ARG8 CTUN. + * @param xtalFreqHz New POWER_UP ARG4-7 XTAL_FREQ in Hz. + * @return Ok on success, or Si4684Error::NotBooted if never booted. + * @pubstate forces booted_=false then re-runs boot() for the currently + * loaded band -- full RSTB# pulse + patch/image reload, same + * as a cold boot, just without an ESP32 restart. Diagnostic: + * lets a calibration script iterate crystal parameters live + * over HTTP instead of a firmware rebuild+reflash per value. + * + * @author Michele Bigi + * @date 2026-08-23 + */ + [[nodiscard]] std::expected recalibrateXtal( + std::uint8_t xtalIbias, std::uint8_t xtalCtun, + std::uint32_t xtalFreqHz); /** * @brief isBooted — query whether boot completed successfully. diff --git a/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp b/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp index a11be0c..5bc1c24 100644 --- a/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp +++ b/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp @@ -178,6 +178,24 @@ public: [[nodiscard]] std::expected setVolume( std::uint8_t level) override; + /** + * @brief recalibrateXtal — re-run driver boot() with new crystal + * parameters, without an ESP32 restart. + * + * @dname recalibrateXtal + * @param ibias New POWER_UP ARG3 IBIAS. + * @param ctun New POWER_UP ARG8 CTUN. + * @param xtalFreqHz New POWER_UP ARG4-7 XTAL_FREQ in Hz. + * @return Ok on success, or a mapped TunerError. + * @pubstate delegates to driver_.recalibrateXtal(); caller must re-tune + * afterwards, this only reboots the chip. + * + * @author Michele Bigi + * @date 2026-08-23 + */ + [[nodiscard]] std::expected recalibrateXtal( + std::uint8_t ibias, std::uint8_t ctun, std::uint32_t xtalFreqHz); + private: /** * @brief mapError — translate Si4684Error to core::TunerError. diff --git a/Software/components/drivers/si4684/include/si4684/Si4684Types.hpp b/Software/components/drivers/si4684/include/si4684/Si4684Types.hpp index 2656181..cbe1f67 100644 --- a/Software/components/drivers/si4684/include/si4684/Si4684Types.hpp +++ b/Software/components/drivers/si4684/include/si4684/Si4684Types.hpp @@ -98,6 +98,12 @@ struct Si4684FmRsq { std::int8_t snrDb; ///< SNR in dB. bool valid; ///< RSQ valid flag from the chip. bool stereo; ///< Stereo pilot detected. + /** AN649 FM_RSQ_STATUS RESP8 FREQOFF: signed offset in units of 2 PPM + * (range -128..127, i.e. -256..+254 PPM). Crystal calibration signal: + * every locked station's carrier reads the same PPM error when the + * XTAL_FREQ/CTUN reference is off, since real broadcast transmitters + * are themselves GPS/rubidium-locked. */ + std::int8_t freqOffBppm; }; /** diff --git a/Software/components/drivers/si4684/src/Si4684Driver.cpp b/Software/components/drivers/si4684/src/Si4684Driver.cpp index 27f41e1..5934402 100644 --- a/Software/components/drivers/si4684/src/Si4684Driver.cpp +++ b/Software/components/drivers/si4684/src/Si4684Driver.cpp @@ -40,6 +40,8 @@ constexpr std::size_t kSpiReplyLeadIn = 1U; /** FM_RSQ_STATUS field indices with kSpiReplyLeadIn (AN649 RESP5–10). */ constexpr std::size_t kFmRsqOffValid = 6U; constexpr std::size_t kFmRsqOffReadFreq = 7U; +/** AN649 FM_RSQ_STATUS RESP8 FREQOFF: signed offset in 2 PPM units. */ +constexpr std::size_t kFmRsqOffFreqOff = 9U; constexpr std::size_t kFmRsqOffRssi = 10U; constexpr std::size_t kFmRsqOffSnr = 11U; @@ -64,6 +66,12 @@ constexpr std::uint16_t kSi4684I2sOutEnable = 0x8002U; /** Si4684 volume: 0=mute, 63=max (AN649 AUDIO_ANALOG_VOLUME). */ constexpr std::uint8_t kSi4684VolumeMax = 63U; constexpr std::uint16_t kPropFmRdsConfig = 0x3C02U; +/** AN649 FM_AUDIO_DE_EMPHASIS (0x3900): 0=75us/US (chip default), 1=50us/ + * Europe, 2=disabled. FM seek band/spacing above is already the European + * 87.5-107.9 MHz/100 kHz plan, so the chip must not stay on its 75us/US + * default -- that under-de-emphasizes treble on every station. */ +constexpr std::uint16_t kPropFmAudioDeEmphasis = 0x3900U; +constexpr std::uint16_t kFmAudioDeEmphasisEurope = 0x0001U; /** AN649 FM valid tune properties (defaults RSSI 17 dBµV, SNR 10 dB). */ constexpr std::uint16_t kPropFmValidRssiThreshold = 0x3202U; constexpr std::uint16_t kPropFmValidSnrThreshold = 0x3204U; @@ -486,6 +494,16 @@ std::expected Si4684Driver::configureAfterBoot( {0xB302U, 0x0000U}, {0xB303U, 0x0000U}, {0xB401U, 0x0002U}, + // AN649 Property 0xB500 DAB_ACF_ENABLE: bit0 SOFTMUTE_ENABLE, + // bit1 COMF_NOISE_ENABLE, datasheet default 0x0003 (both on). + // Tried 0x0003 on 2026-08-23 hoping it would mask the periodic + // hiss/unintelligible-voice symptom on real DAB reception -- + // live A/B test made it worse (COMF_NOISE injects synthetic + // noise on every brief signal-quality dip, and frequent + // softmute engagement chopped up speech). PE5PVB's independent + // SI4684-DAB-Receiver project also explicitly disables this + // (Set_Property(0xB500, 0x0000)), matching what real testing + // shows here -- disabled deliberately, not an oversight. {0xB500U, 0x0000U}, }; for (const auto& prop : kDabProps) { @@ -542,6 +560,11 @@ std::expected Si4684Driver::configureAfterBoot( if (auto rds = setProperty(kPropFmRdsConfig, 0x0001U); !rds) { return rds; } + if (auto deEmph = setProperty(kPropFmAudioDeEmphasis, + kFmAudioDeEmphasisEurope); + !deEmph) { + return deEmph; + } // AN649 §0x3202/0x3204: lower seek/tune validity for weak lab antennas. if (auto rssi = setProperty(kPropFmValidRssiThreshold, kFmValidRssiThresholdDbuV); @@ -597,7 +620,8 @@ std::expected Si4684Driver::configureAfterBoot( } std::expected Si4684Driver::boot( - Si4684Band band, std::uint8_t xtalIbias, std::uint8_t xtalCtun) + Si4684Band band, std::uint8_t xtalIbias, std::uint8_t xtalCtun, + std::uint32_t xtalFreqHz) { if (booted_ && loadedBand_ == band) { return {}; @@ -672,10 +696,19 @@ std::expected Si4684Driver::boot( } // ARG2=0x17(CLK_MODE=crystal,TR_SIZE), ARG3=IBIAS, ARG4-7=XTAL_FREQ - // 19.2 MHz (0x0124F800), ARG8=CTUN, ARG9=0x10 (fixed bit4=1 per AN649 + // (little-endian, nominal 19.2 MHz = 0x0124F800; may be intentionally + // offset from nominal as a software crystal calibration -- see the + // xtalFreqHz doc comment), ARG8=CTUN, ARG9=0x10 (fixed bit4=1 per AN649 // §Command 0x01), ARG10-15=0 (AN649 POWER_UP argument table). std::uint8_t powerUp[] = { - 0x17, xtalIbias, 0x00, 0xf8, 0x24, 0x01, xtalCtun, 0x10, + 0x17, + xtalIbias, + static_cast(xtalFreqHz & 0xFFU), + static_cast((xtalFreqHz >> 8) & 0xFFU), + static_cast((xtalFreqHz >> 16) & 0xFFU), + static_cast((xtalFreqHz >> 24) & 0xFFU), + xtalCtun, + 0x10, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, }; if (auto pu = writeCommand(Command::PowerUp, powerUp, sizeof(powerUp)); @@ -745,6 +778,17 @@ std::expected Si4684Driver::boot( return {}; } +std::expected Si4684Driver::recalibrateXtal( + std::uint8_t xtalIbias, std::uint8_t xtalCtun, std::uint32_t xtalFreqHz) +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + const Si4684Band band = loadedBand_; + booted_ = false; + return boot(band, xtalIbias, xtalCtun, xtalFreqHz); +} + bool Si4684Driver::isBooted() const noexcept { return booted_; @@ -920,6 +964,7 @@ std::expected Si4684Driver::readFmRsq() static_cast(raw[kFmRsqOffSnr]), freqInBand && chipValid, false, + static_cast(raw[kFmRsqOffFreqOff]), }; return rsq; } diff --git a/Software/components/drivers/si4684/src/Si4684Tuner.cpp b/Software/components/drivers/si4684/src/Si4684Tuner.cpp index 3330599..d13af7e 100644 --- a/Software/components/drivers/si4684/src/Si4684Tuner.cpp +++ b/Software/components/drivers/si4684/src/Si4684Tuner.cpp @@ -169,6 +169,7 @@ std::expected Si4684Tuner::readStatus() status.locked = rsq->valid; status.fmRssiDbuV = rsq->rssiDbuV; status.fmSnrDb = rsq->snrDb; + status.fmFreqOffBppm = rsq->freqOffBppm; status.fmStereo = rsq->stereo; status.fmChipReadFrequency = rsq->frequency; // Keep commanded frequency when chip READFREQ is stale (stuck at band @@ -342,4 +343,14 @@ std::expected Si4684Tuner::setVolume(std::uint8_t level) return {}; } +std::expected Si4684Tuner::recalibrateXtal( + std::uint8_t ibias, std::uint8_t ctun, std::uint32_t xtalFreqHz) +{ + if (auto result = driver_.recalibrateXtal(ibias, ctun, xtalFreqHz); + !result) { + return std::unexpected(mapError(result.error())); + } + return {}; +} + } // namespace si4684 diff --git a/Software/components/net/include/net/SetupWebServer.hpp b/Software/components/net/include/net/SetupWebServer.hpp index 7953dc7..b8af885 100644 --- a/Software/components/net/include/net/SetupWebServer.hpp +++ b/Software/components/net/include/net/SetupWebServer.hpp @@ -105,6 +105,13 @@ struct AntennaCalibration { /** Persist a new DAB ANTCAP calibration value to EEPROM. * @return false on an I2C failure. */ bool (*saveDab)(std::uint8_t antCap); + /** Diagnostic-only, 2026-08-23: re-run Si4684Driver::boot() with new + * crystal parameters (no ESP32 restart, no persistence). Bridged here + * rather than through a new context field to avoid a second plumbing + * chain for what is a temporary calibration tool. + * @return false if the chip was never booted or the reboot failed. */ + bool (*recalibrateXtal)(std::uint8_t ibias, std::uint8_t ctun, + std::uint32_t xtalFreqHz); }; /** diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index e7c1547..0271ee1 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -699,6 +699,57 @@ esp_err_t tunerCalibrateAntennaPostHandler(httpd_req_t* req) return httpd_resp_send(req, json.c_str(), json.size()); } +/** + * @brief tunerXtalCalibratePostHandler — POST /api/tuner/xtal-calibrate. + * + * Diagnostic-only, 2026-08-23: reboots the Si4684 with new IBIAS/CTUN/ + * XTAL_FREQ (AN649 §Command 0x01 POWER_UP) without an ESP32 restart or + * NVS persistence, so a calibration script can iterate crystal parameters + * live. Caller must re-tune afterwards -- this only reboots the chip. + * See FM_RSQ_STATUS FREQOFF (GET /api/tuner/status, "freqoff_ppm") for the + * measurement this is meant to null out. + */ +esp_err_t tunerXtalCalibratePostHandler(httpd_req_t* req) +{ + auto* ctx = routeContextFrom(req); + if (ctx == nullptr || ctx->antennaCalibration == nullptr + || ctx->antennaCalibration->recalibrateXtal == nullptr) { + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_send(req, nullptr, 0); + } + + std::array body{}; + if (!readRequestBody(req, body)) { + httpd_resp_set_status(req, "400 Bad Request"); + return httpd_resp_send(req, nullptr, 0); + } + + const auto parsed = + core::parseXtalCalibrationJson(std::string_view(body.data())); + if (!parsed) { + const std::string json = core::serializeTunerErrorJson("invalid_json"); + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); + } + + if (!ctx->antennaCalibration->recalibrateXtal( + parsed->ibias, parsed->ctun, parsed->xtalFreqHz)) { + const std::string json = core::serializeTunerErrorJson("boot_failed"); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); + } + + const std::string json = + std::string("{\"status\":\"recalibrated\",\"ibias\":") + + std::to_string(parsed->ibias) + ",\"ctun\":" + + std::to_string(parsed->ctun) + ",\"xtal_freq_hz\":" + + std::to_string(parsed->xtalFreqHz) + "}"; + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); +} + /** * @brief audioProfileGetHandler — serve GET /api/audio/profile JSON. * @@ -2075,6 +2126,14 @@ std::expected SetupWebServer::start( }; httpd_register_uri_handler(server_, &tunerCalibrateAntennaUri); + const httpd_uri_t tunerXtalCalibrateUri = { + .uri = "/api/tuner/xtal-calibrate", + .method = HTTP_POST, + .handler = tunerXtalCalibratePostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &tunerXtalCalibrateUri); + const httpd_uri_t audioProfileGetUri = { .uri = "/api/audio/profile", .method = HTTP_GET, diff --git a/Software/docs/TODO.md b/Software/docs/TODO.md index bb70865..84a1036 100644 --- a/Software/docs/TODO.md +++ b/Software/docs/TODO.md @@ -204,6 +204,37 @@ Done in fw 0.8.5 unless noted: --- +## TODO — calibration functions need to become permanent, in-firmware, on-demand tools (2026-08-23) + +Both ANTCAP calibration (`tools/si4684_antenna_calibration.py`) and Si4684 +crystal calibration (`tools/si4684_xtal_calibration.py`, +`POST /api/tuner/xtal-calibrate`) currently exist as **host-side Python +scripts driving live-but-unpersisted HTTP endpoints** — they compute a +result but the operator has to hand-edit firmware source (constants in +`Si4684Driver.cpp` / the `gSi4684.boot(...)` call in +`hardware_bootstrap.cpp`) and reflash to make a result permanent. + +**Wanted instead**: both calibration procedures should be triggerable +on-demand *from the device itself* (an HTTP endpoint is enough — no UI +required yet) and, once a result converges, **write the result to the +24AA025E48 EEPROM** (same chip/pattern already used for ANTCAP +persistence, see `Eeprom24aa::writeFmAntCap`/`writeDabAntCap`) so it +survives a reboot without a firmware reflash. `recalibrateXtal()` +(`Si4684Driver.cpp`) already does the live re-boot-with-new-params part; +what's missing is EEPROM persistence for CTUN/XTAL_FREQ (ANTCAP already +persists this way — the xtal calibration should follow the same shape, +likely a new EEPROM word address alongside the existing FM/DAB ANTCAP +ones) and doing the FREQOFF-averaging/damping/convergence-loop logic +in firmware (or keeping it host-side and just adding the EEPROM-persist +step at the end — decide when picked up). + +Not started — explicitly deferred to a future session, noted here only so +it isn't lost. See `docs/si4684-rf-investigation-report.md`'s 2026-08-23 +entry for full context on why this calibration was needed and how it +currently works. + +--- + ## Quality gates (run from `Software/` before merge) ```bash diff --git a/Software/docs/si4684-rf-investigation-report.md b/Software/docs/si4684-rf-investigation-report.md index b1527a7..9125700 100644 --- a/Software/docs/si4684-rf-investigation-report.md +++ b/Software/docs/si4684-rf-investigation-report.md @@ -1111,3 +1111,123 @@ RTS=0 stability argues against pure floating-noise, and flow control may not even be engaged by default per the datasheet. The single most valuable next input is Feasycom's own answer, not another round of timing-parameter permutation. + +## 2026-08-23 update: FM/DAB pitch-distortion root-caused and fixed (uncalibrated Si4684 crystal reference); a separate downstream audio-quality issue found and left open + +**Symptom**: user reported FM+DAB audio hiss/distortion, later sharpened to +"voce stonata" (mistuned/off-pitch voice), present on both bands. + +**Elimination chain, each step verified on real hardware, not theory**: +RF signal strength (new antenna, RSSI/SNR excellent — see the ANTCAP +section above) → boot-time ADAU1701 DSP program load (added read-back +verification to `SIGMA_WRITE_REGISTER_BLOCK`/`sigma_safeload_block`, +confirmed clean on every boot and every runtime safeload) → ADAU1701 EQ +band 0 ("fixed high-pass," never touched at runtime) — initially +miscalculated as an unstable filter from a wrong fixed-point bit-width +assumption (8.23 vs the chip's actual 5.23/28-bit format), corrected and +confirmed stable via SigmaStudio's own live register capture connected to +the device → ADAU1701 mixer (all input knobs centered, confirmed) → +`DAB_ACF_ENABLE` (0xB500, found disabled with no citation; datasheet +default is 3; tried enabling it — made things audibly *worse*, likely +because COMF_NOISE_ENABLE literally injects synthetic noise on signal +dips; reverted to 0x0000, matching what PE5PVB's independent +SI4684-DAB-Receiver project also does deliberately — not the cause, but +no longer an unexplained magic number) → **decisive test**: a 440 Hz tone +generated by the ESP32 and written directly over the shared I2S bus +(`main/esp32_i2s_test_tone.{hpp,cpp}`, `CONFIG_ESP32_I2S_TEST_TONE`) came +through clean and perfectly in-tune, verified with a real tuner — isolated +the pitch problem to the Si4684 itself, ruling out ADAU1701/mixer/I2S +receiving/BT1035/speaker → TR_SIZE (0x7) and IBIAS (72 = 720µA) checked +against AN649 Figure 13 ("Safe Range of Operation for a 19.2 MHz +Crystal"), both comfortably inside the safe range for this crystal's ESR. + +**Root cause**: `Si4684Driver::boot()`'s `xtalCtun=31`/`xtalIbias=72` +defaults had only a vague "already verified live" justification — no +actual measurement for *this* board's crystal (Abracon +ABM8-19.200MHZ-10-1-U-T, CL=10pF decoded from the part number's own +ordering-code table, two external 15pF load caps per the schematic) ever +existed. + +**Fix, two stages**: +1. CTUN empirical trim by ear (AN649 §9.3 says trim by measurement; no + oscilloscope available, and a multimeter's frequency counter reads the + strong I2S LRCLK signal fine but returns 0 on the crystal pins + themselves — too weak/high-impedance for a general-purpose meter to + trigger on). Swept 31→5→0 (0 = the floor of the 0-63 range), + monotonic improvement each step, still "less bad, not fixed" at the + floor. +2. **XTAL_FREQ precision trim using the chip's own measurement, no lab + equipment needed**: real FM/DAB transmitters are GPS/rubidium-locked, + so FM_RSQ_STATUS's FREQOFF field (AN649 Command 0x32 RESP8, signed, + units of 2 PPM) directly reports the *receiver's* crystal error on any + locked station. Added `Si4684Driver::recalibrateXtal()` (forces a full + re-boot with new IBIAS/CTUN/XTAL_FREQ, no ESP32 restart), a new + endpoint `POST /api/tuner/xtal-calibrate`, exposed FREQOFF as + `"freqoff_ppm"` in `GET /api/tuner/status`, and + `tools/si4684_xtal_calibration.py` to automate the trim loop + (tune → average several FREQOFF samples → correct → repeat). + **Non-obvious gotcha, found only by watching the first attempt + diverge, not documented anywhere**: the correction sign is + `xtal_freq *= (1 - ppm/1e6)`, not `(1 + ppm/1e6)` — the "tell it the + truth" sign convention makes the error grow, not shrink (confirmed + live: ppm went 28→60→122→254/no-lock across 3 iterations before the + sign was flipped). Averaging + damping (0.6) were both needed for + smooth convergence; a single raw FREQOFF sample has enough + reception-noise jitter (~±20-35 ppm swings observed) to make an + undamped loop oscillate instead of settling. + +**Final calibrated values** (now the firmware default, +`main/hardware_bootstrap.cpp`): `CTUN=0`, `XTAL_FREQ=19,199,750 Hz` +(≈-13 ppm off the 19.2 MHz nominal). Converged residual: **-3.8 ppm at +87.6 MHz, -3.0 ppm at 105.1 MHz** — consistent across two stations at +opposite ends of the FM band (the cross-check AN649 itself recommends), +confirming this really is the crystal reference and not something +frequency-dependent. Down from **+70 ppm** uncorrected at nominal +XTAL_FREQ. No physical hardware change (different load-cap values) ended +up being necessary — contrary to what seemed likely after CTUN alone. + +**User-confirmed result**: "migliorato moltissimo" (improved a lot) after +this fix — the systematic pitch/tuning distortion is resolved. + +### Still open: a separate downstream hiss/intelligibility issue, NOT the Si4684 + +After the crystal fix, the user still reported residual hiss and, more +seriously, words being unintelligible on both FM and DAB. Recordings sent +for spectral analysis showed no gross technical defects (no clipping, no +dropouts, no dominant isolated resonance) — inconclusive from the +recordings alone (phone-mic-through-air recordings are a poor tool for +this specific symptom; room acoustics and mic response confound the +signal). The user's direct listening judgement (confirmed repeatedly: +"si sente ancora fruscio e le parole sono incomprensibili") is the +ground truth here, not the recordings. + +**Decisive test**: enabled `web_radio_stream` (internet radio via ESP32, +`POST /api/streaming`) with a direct HTTP MP3 stream +(`http://icecast.radiofrance.fr/franceinter-midfi.mp3`), routed through +the same shared ADAU1701 mixer/EQ/output/BT1035/Bluetooth-speaker chain +as FM/DAB but **never touching the Si4684 at all**. User confirmed: +**same symptom** (hiss + unintelligible). This is different from the +earlier synthetic-440Hz-tone test, which came through clean — the tone +test used a trivial, CPU-cheap sine generator with no decode/buffering +involved, so it never exercised whatever a real MP3-decode-under-WiFi-load +pipeline does. + +**Conclusion**: the pitch/tuning problem (fixed) and this hiss/ +intelligibility problem are two separate, independently-confirmed root +causes that happened to co-occur and get conflated as "one bug" for most +of this session. The Si4684/crystal is now cleared for *this* symptom — +next session should look at: (a) `web_radio_stream`'s MP3 decode/I2S +buffer-feed path for underrun/overrun under real WiFi jitter, since that +was the actual reproducer, and (b) whether the same class of issue could +independently affect the ADAU1701 mixer/EQ path under real dynamic +program content generally (the passing tone test doesn't rule this out +for FM/DAB specifically, only for a pure sine wave). Don't re-open the +Si4684/crystal-calibration question for this symptom without new +evidence — it's a different, still-unidentified mechanism. + +**New permanent diagnostic tools from this session** (kept in the repo, +not removed): `GET /api/tuner/status` now reports `"freqoff_ppm"` for FM; +`POST /api/tuner/xtal-calibrate` for live Si4684 crystal re-trim without +reflashing; `CONFIG_ESP32_I2S_TEST_TONE` Kconfig option (off by default) +for isolating Si4684-specific vs. shared-downstream audio issues; +`tools/si4684_xtal_calibration.py` and `tools/si4684_antenna_calibration.py`. diff --git a/Software/main/CMakeLists.txt b/Software/main/CMakeLists.txt index 8893b97..a46d76e 100644 --- a/Software/main/CMakeLists.txt +++ b/Software/main/CMakeLists.txt @@ -8,6 +8,7 @@ idf_component_register( "antenna_calibration.cpp" "$<$:test_firmware.cpp>" "$<$:i2s_sdata_probe.cpp>" + "$<$:esp32_i2s_test_tone.cpp>" INCLUDE_DIRS "." REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035 bluetooth station integration ota eeprom24aa webradio driver PRIV_REQUIRES esp_timer esp_http_client diff --git a/Software/main/Kconfig.projbuild b/Software/main/Kconfig.projbuild index 6f4467a..2789a1b 100644 --- a/Software/main/Kconfig.projbuild +++ b/Software/main/Kconfig.projbuild @@ -17,4 +17,17 @@ config I2S_SDATA_PROBE input trace (or an equivalent test point) -- enabling this without the rewire just reads whatever GPIO16 happens to be floating/connected to. +config ESP32_I2S_TEST_TONE + bool "ESP32-generated sine tone over the shared I2S TX channel" + default n + help + Continuously writes a sine wave to esp32_i2s_sink (the same shared + BCLK/LRCLK the Si4684 uses on its own SDATA_IN0 pin, but over + SDATA_IN1). Diagnostic: if this sounds clean while FM/DAB is + distorted, the ADAU1701's I2S input receiving is fine in general and + the problem is specific to the Si4684 side. Remember the ESP32 + mixer channel is muted (-96 dB) by default in the stored audio + profile -- raise it via PUT /api/audio/profile to actually hear + this. + endmenu diff --git a/Software/main/antenna_calibration.cpp b/Software/main/antenna_calibration.cpp index d727ec1..aab8b0d 100644 --- a/Software/main/antenna_calibration.cpp +++ b/Software/main/antenna_calibration.cpp @@ -11,14 +11,28 @@ #include "antenna_calibration.hpp" #include "hardware_bootstrap.hpp" +#include "si4684/Si4684Tuner.hpp" namespace antenna_calibration { +namespace { + +/** Diagnostic-only, 2026-08-23: see net::AntennaCalibration::recalibrateXtal. */ +bool recalibrateXtal(std::uint8_t ibias, std::uint8_t ctun, + std::uint32_t xtalFreqHz) +{ + return static_cast(hardware::HardwareBootstrap::si4684Tuner() + .recalibrateXtal(ibias, ctun, xtalFreqHz)); +} + +} // namespace + net::AntennaCalibration& bridge() noexcept { static net::AntennaCalibration instance{ .save = &hardware::HardwareBootstrap::saveFmAntCapCalibration, .saveDab = &hardware::HardwareBootstrap::saveDabAntCapCalibration, + .recalibrateXtal = &recalibrateXtal, }; return instance; } diff --git a/Software/main/esp32_i2s_test_tone.cpp b/Software/main/esp32_i2s_test_tone.cpp new file mode 100644 index 0000000..6ed220b --- /dev/null +++ b/Software/main/esp32_i2s_test_tone.cpp @@ -0,0 +1,57 @@ +/** + * @file esp32_i2s_test_tone.cpp + * @brief esp32_i2s_test_tone implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + */ +#include "esp32_i2s_test_tone.hpp" + +#include "esp32_i2s_sink.hpp" + +#include +#include +#include + +namespace esp32_i2s_test_tone { + +namespace { +constexpr int kSampleRateHz = 48000; +constexpr int kFramesPerBlock = 256; +/** writeSamples() wants top-aligned 24-bit in a 32-bit slot -- 8-bit shift. */ +constexpr int kTopAlignShift = 8; +/** Modest amplitude: audible but not a full-scale blast. */ +constexpr float kAmplitude = 0.4F * 8388607.0F; +constexpr float kTwoPi = 2.0F * std::numbers::pi_v; +} // namespace + +[[noreturn]] void run(float freqHz) +{ + esp32_i2s_sink::tryAcquire(); + + const float phaseStep = kTwoPi * freqHz / static_cast(kSampleRateHz); + float phase = 0.0F; + std::int32_t buf[kFramesPerBlock * 2]; + + for (;;) + { + for (int i = 0; i < kFramesPerBlock; ++i) + { + phase += phaseStep; + if (phase > kTwoPi) + { + phase -= kTwoPi; + } + const auto sample = + static_cast(std::sin(phase) * kAmplitude) + << kTopAlignShift; + buf[(i * 2) + 0] = sample; + buf[(i * 2) + 1] = sample; + } + esp32_i2s_sink::writeSamples(buf, kFramesPerBlock * 2); + } +} + +} // namespace esp32_i2s_test_tone diff --git a/Software/main/esp32_i2s_test_tone.hpp b/Software/main/esp32_i2s_test_tone.hpp new file mode 100644 index 0000000..40478ae --- /dev/null +++ b/Software/main/esp32_i2s_test_tone.hpp @@ -0,0 +1,28 @@ +/** + * @file esp32_i2s_test_tone.hpp + * @brief Continuous sine tone over the shared ESP32 -> ADAU1701 I2S TX + * channel, for isolating ADAU1701 I2S-input problems from the + * Si4684 specifically. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +namespace esp32_i2s_test_tone { + +/** + * Generate and write a sine-wave tone over esp32_i2s_sink forever. Intended + * to run as its own FreeRTOS task (never returns). Unlike the ADAU1701's + * internal Beep cell (POST /api/audio/beep), this tone travels over the + * physical SDATA_IN1 wire and the shared BCLK/LRCLK the Si4684 also uses on + * SDATA_IN0 -- if it sounds clean while FM/DAB is distorted, the problem is + * specific to the Si4684 side, not the ADAU1701's I2S input path in general. + * + * @param freqHz Tone frequency in Hz. + */ +[[noreturn]] void run(float freqHz); + +} // namespace esp32_i2s_test_tone diff --git a/Software/main/hardware_bootstrap.cpp b/Software/main/hardware_bootstrap.cpp index 5e11de0..169e210 100644 --- a/Software/main/hardware_bootstrap.cpp +++ b/Software/main/hardware_bootstrap.cpp @@ -160,7 +160,21 @@ std::expected HardwareBootstrap::boot() return {}; } - if (auto tunerResult = gSi4684.boot(si4684::Si4684Band::Dab); !tunerResult) { + // xtalCtun=0, xtalFreqHz=19199750 (2026-08-23): the compiled-in + // defaults (ctun=31, xtal=19200000 nominal) were never measured + // against this board's actual crystal (Abracon ABM8-19.200MHZ-10-1-U-T, + // CL=10pF per part number, plus two external 15pF load caps per the + // schematic). CTUN=0 was found audibly best via A/B listening (0/5/31), + // then xtalFreqHz was trimmed properly using the chip's own FM_RSQ + // FREQOFF measurement (tools/si4684_xtal_calibration.py) against two + // real, GPS-locked broadcast carriers 87.6/105.1 MHz -- converged to + // -3 to -4 ppm residual on both (cross-check confirms it's the + // crystal, not something frequency-dependent), down from +70 ppm + // uncorrected. See POST /api/tuner/xtal-calibrate to re-trim live if + // this ever needs revisiting (e.g. after a board/crystal change). + if (auto tunerResult = + gSi4684.boot(si4684::Si4684Band::Dab, 72U, 0U, 19199750U); + !tunerResult) { ESP_LOGE(kTag, "Si4684 boot failed: error %d", static_cast(tunerResult.error())); return std::unexpected(HardwareBootError::Si4684BootFailed); } diff --git a/Software/main/main.cpp b/Software/main/main.cpp index 9cebc32..0c5473d 100644 --- a/Software/main/main.cpp +++ b/Software/main/main.cpp @@ -45,6 +45,10 @@ #include "i2s_sdata_probe.hpp" #endif +#if CONFIG_ESP32_I2S_TEST_TONE +#include "esp32_i2s_test_tone.hpp" +#endif + namespace { constexpr char kTag[] = "digiradio"; @@ -133,6 +137,14 @@ void heartbeatTask(void* arg) } } +#if CONFIG_ESP32_I2S_TEST_TONE +[[noreturn]] void i2sTestToneTask(void* arg) +{ + (void)arg; + esp32_i2s_test_tone::run(440.0F); +} +#endif + } // namespace /** @@ -213,6 +225,13 @@ extern "C" void app_main() "streaming will be unavailable"); } +#if CONFIG_ESP32_I2S_TEST_TONE + if (xTaskCreate(i2sTestToneTask, "i2s_test_tone", 4096, nullptr, 4, + nullptr) != pdPASS) { + ESP_LOGW(kTag, "ESP32 I2S test tone task create failed"); + } +#endif + auto netResult = net::NetBootstrap::start( store, tunerService, diff --git a/Software/tools/si4684_antenna_calibration.py b/Software/tools/si4684_antenna_calibration.py new file mode 100644 index 0000000..f8e9972 --- /dev/null +++ b/Software/tools/si4684_antenna_calibration.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""si4684_antenna_calibration.py — AN851 Appendix A varactor-tuning sweep. + +DigiRadio firmware — https://github.com/manvalan/DigiRadio + +Copyright 2026 Michele Bigi +SPDX-License-Identifier: Apache-2.0 + +Runs the Si4684 antenna varactor calibration procedure described in AN851 +("Si468x AM/AMHD-FM/FMHD-DAB/DAB+ antenna/matching network design +guidelines"), Appendix A, driving the device's existing HTTP tuner API +instead of a bench Test_Get_RSSI command: + + 1. At each of several test frequencies, sweep ANTCAP from --antcap-min + to --antcap-max (AN851: 1-128) via POST /api/tuner/tune, averaging + --samples (AN851: 5) reads per step via GET /api/tuner/status. + 2. Pick the ANTCAP with the best average reading at each frequency. + 3. Linear-fit best_antcap(frequency_MHz) = (m/1000)*frequency_MHz + b, + AN851's own formula, giving VARM (m, property 0x1710) and VARB + (b, property 0x1711) as signed 16-bit integers. + +Scope and limits (read before running): + +* This firmware's HTTP API has no endpoint to write VARM/VARB directly + (see components/net/src/SetupWebServer.cpp) -- only a single fixed + ANTCAP override can be persisted, via POST /api/tuner/calibrate-antenna. + This script only COMPUTES the fit and prints it; it does not write + anything to the device. Applying the result means hand-editing the + kFmTuneFeVarm/kFmTuneFeVarb (or the DAB kDabProps table) constants in + components/drivers/si4684/src/Si4684Driver.cpp and reflashing. + +* DAB caveat: DAB_TUNE_FREQ (AN649 Command 0xB0) addresses frequencies by + an index into a chip-side table (default "European frequency list") + loaded via DAB_SET_FREQ_LIST. AN649's copy in this repo does not publish + that table's contents, and neither the firmware nor its HTTP API expose + the real MHz for a given freq_index. This script does NOT assume any + index<->MHz mapping -- for --band dab you must supply the real + frequency for each index yourself (e.g. read off a known ensemble's + published transmitter frequency). Do not guess; a wrong MHz value here + silently produces a wrong VARM/VARB fit. + +* DAB metric caveat: AN851 Appendix A calls for RSSI at each step, but + this firmware's DAB status JSON does not expose RSSI (only + dab.fic_quality and dab.cnr_db -- see core/TunerStatus.hpp). This + script uses dab.cnr_db as the optimization metric for --band dab + instead. That is a deliberate substitution, not a datasheet value -- + treat DAB fit results with more skepticism than FM ones. + +* Known dead zone (docs/si4684-rf-investigation-report.md, 2026-08-20): + DAB freq_index 22 lost lock entirely at antcap 72 and 80 on this board. + This script does not skip any antcap value in range; a step with no + lock is logged as "NO LOCK" and simply excluded from that point's + average, not treated as a fatal error. + +Usage: + python3 tools/si4684_antenna_calibration.py --host digiradio-XXXXXX.local \\ + --band fm --point 88500 --point 98000 --point 107900 + + python3 tools/si4684_antenna_calibration.py --host 192.168.1.56 \\ + --band dab --point 5:174928 --point 12:198160 --point 23:225648 \\ + --raw-csv dab_sweep.csv +""" + +from __future__ import annotations + +import argparse +import csv +import json +import socket +import statistics +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Optional + + +def parse_point(band: str, raw: str) -> dict: + """Parse one --point argument into a tune target for the given band.""" + if band == "dab": + if ":" not in raw: + raise ValueError( + "DAB points must be 'freq_index:frequency_khz', e.g. 12:198160" + ) + idx_s, khz_s = raw.split(":", 1) + idx = int(idx_s) + khz = int(khz_s) + if not 0 <= idx <= 37: + raise ValueError("freq_index must be 0-37 (core/TunerJson.cpp limit)") + if khz <= 0: + raise ValueError("frequency_khz must be positive") + return { + "index": idx, + "khz": khz, + "mhz": khz / 1000.0, + "label": f"idx{idx}@{khz / 1000:.3f}MHz", + } + khz = int(raw) + if not 87500 <= khz <= 108000: + raise ValueError("FM frequency_khz should be within 87500-108000") + return {"khz": khz, "mhz": khz / 1000.0, "label": f"{khz / 1000:.3f}MHz"} + + +def tune_payload(band: str, point: dict, antcap: int) -> dict: + if band == "dab": + return {"band": "dab", "freq_index": point["index"], "antcap": antcap} + return {"band": "fm", "frequency_khz": point["khz"], "antcap": antcap} + + +def extract_metric(status: dict, band: str) -> Optional[float]: + """AN851's optimization metric: RSSI for FM, CNR for DAB (see docstring).""" + if not status.get("locked", False): + return None + if band == "fm": + fm = status.get("fm") or {} + rssi = fm.get("rssi_dbuv") + return float(rssi) if rssi is not None else None + dab = status.get("dab") or {} + cnr = dab.get("cnr_db") + return float(cnr) if cnr is not None else None + + +def http_request( + host: str, + method: str, + path: str, + payload: Optional[dict], + timeout: float, + retries: int, +) -> dict: + url = f"http://{host}{path}" + # The firmware's JSON parser (components/core/src/TunerJson.cpp) does + # plain substring search for e.g. `"key":` and is not whitespace- + # tolerant -- json.dumps()'s default ": "/", " separators break every + # request with "invalid_json". Compact separators match what curl's + # -d with no spaces sends, which the firmware does accept. + data = json.dumps(payload, separators=(",", ":")).encode("utf-8") if payload is not None else None + headers = {"Content-Type": "application/json"} if data is not None else {} + last_err: Optional[BaseException] = None + for attempt in range(retries + 1): + try: + req = urllib.request.Request(url, data=data, method=method, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read() + return json.loads(body) if body else {} + except (urllib.error.URLError, socket.timeout, TimeoutError) as exc: + last_err = exc + if attempt < retries: + time.sleep(1.0 + attempt) # device is known to stall briefly under HTTP load + raise RuntimeError(f"{method} {path} failed after {retries + 1} attempt(s): {last_err}") + + +def sweep_point( + host: str, + band: str, + point: dict, + antcap_values: list, + samples: int, + settle_s: float, + sample_gap_s: float, + timeout: float, + retries: int, + raw_rows: Optional[list], +) -> list: + results = [] + for antcap in antcap_values: + try: + status = http_request( + host, "POST", "/api/tuner/tune", tune_payload(band, point, antcap), timeout, retries + ) + except RuntimeError as exc: + print(f" antcap={antcap:3d} tune failed: {exc}", file=sys.stderr) + results.append((antcap, None)) + continue + + time.sleep(settle_s) + readings = [] + first = extract_metric(status, band) + if first is not None: + readings.append(first) + for _ in range(max(0, samples - 1)): + time.sleep(sample_gap_s) + try: + status = http_request(host, "GET", "/api/tuner/status", None, timeout, retries) + except RuntimeError as exc: + print(f" antcap={antcap:3d} status read failed: {exc}", file=sys.stderr) + continue + reading = extract_metric(status, band) + if reading is not None: + readings.append(reading) + + if raw_rows is not None: + for i, reading in enumerate(readings): + raw_rows.append( + {"point": point["label"], "antcap": antcap, "sample": i, "metric": reading} + ) + + avg = statistics.mean(readings) if readings else None + state = "locked" if readings else "NO LOCK" + avg_str = f"{avg:6.2f}" if avg is not None else " - " + print(f" antcap={antcap:3d} samples={len(readings)}/{samples} avg={avg_str} [{state}]") + results.append((antcap, avg)) + return results + + +def linear_fit(xs: list, ys: list) -> Optional[tuple]: + n = len(xs) + if n < 2: + return None + mean_x = sum(xs) / n + mean_y = sum(ys) / n + num = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys)) + den = sum((x - mean_x) ** 2 for x in xs) + if den == 0: + return None + slope = num / den + intercept = mean_y - slope * mean_x + return slope, intercept + + +def main() -> int: + parser = argparse.ArgumentParser( + description="AN851 Appendix A ANTCAP sweep + VARM/VARB linear fit over the tuner HTTP API.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument("--host", required=True, help="Device IP or mDNS host, e.g. digiradio-XXXXXX.local") + parser.add_argument("--band", required=True, choices=["fm", "dab"]) + parser.add_argument( + "--point", + action="append", + required=True, + metavar="FREQ_KHZ|INDEX:FREQ_KHZ", + help="FM: frequency in kHz (e.g. 98000). DAB: freq_index:frequency_khz " + "(e.g. 12:198160) -- see script docstring for why the MHz value must " + "come from you, not this script.", + ) + parser.add_argument("--antcap-min", type=int, default=1) + parser.add_argument("--antcap-max", type=int, default=128) + parser.add_argument("--antcap-step", type=int, default=1) + parser.add_argument( + "--samples", type=int, default=5, help="Reads averaged per ANTCAP step (AN851 Appendix A: 5)" + ) + parser.add_argument( + "--settle-ms", + type=int, + default=150, + help="Delay after tune before the first reading (engineering margin, not from AN851)", + ) + parser.add_argument("--sample-gap-ms", type=int, default=80, help="Delay between repeated status reads") + parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout per request, seconds") + parser.add_argument( + "--retries", type=int, default=2, help="Retries per request (device is known to stall briefly)" + ) + parser.add_argument("--raw-csv", type=Path, default=None, help="Optional path to dump every raw sample") + args = parser.parse_args() + + if not 1 <= args.antcap_min <= args.antcap_max <= 128: + print("error: antcap range must be within 1-128 (0 = auto, not part of the sweep)", file=sys.stderr) + return 1 + + points = [] + for raw in args.point: + try: + points.append(parse_point(args.band, raw)) + except ValueError as exc: + print(f"error: invalid --point {raw!r}: {exc}", file=sys.stderr) + return 1 + + antcap_values = list(range(args.antcap_min, args.antcap_max + 1, args.antcap_step)) + raw_rows: Optional[list] = [] if args.raw_csv else None + + fit_points = [] + for point in points: + print(f"\n=== sweeping {args.band} @ {point['label']} ===") + results = sweep_point( + args.host, + args.band, + point, + antcap_values, + args.samples, + args.settle_ms / 1000.0, + args.sample_gap_ms / 1000.0, + args.timeout, + args.retries, + raw_rows, + ) + valid = [(a, v) for a, v in results if v is not None] + if not valid: + print(f" WARNING: no locked reading anywhere -- excluding {point['label']} from the fit") + continue + best_antcap, best_val = max(valid, key=lambda t: t[1]) + print(f" best: antcap={best_antcap} ({best_val:.2f})") + fit_points.append((point["mhz"], best_antcap, point["label"])) + + if args.raw_csv: + with args.raw_csv.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["point", "antcap", "sample", "metric"]) + writer.writeheader() + writer.writerows(raw_rows) + print(f"\nraw samples written to {args.raw_csv}") + + print(f"\n=== AN851 Appendix A fit (band={args.band}) ===") + if len(fit_points) < 2: + print( + "Not enough locked points to fit VARM/VARB (need >= 2 usable frequencies). " + "Try different --point values or check the antenna/signal." + ) + return 1 + + xs = [p[0] for p in fit_points] + ys = [p[1] for p in fit_points] + fit = linear_fit(xs, ys) + if fit is None: + print("Fit failed (degenerate frequency set -- all points at the same MHz?).") + return 1 + + slope, intercept = fit + m = round(slope * 1000) + b = round(intercept) + m_ok = -32768 <= m <= 32767 + b_ok = -32768 <= b <= 32767 + + print(" points used: " + ", ".join(f"{label} -> antcap {a}" for _, a, label in fit_points)) + print(" varactor_value = (m/1000)*frequency_MHz + b [AN851 Appendix A]") + print(f" m (slope x1000, property 0x1710) = {m}" + ("" if m_ok else " ** OUT OF int16 RANGE **")) + print(f" b (intercept, property 0x1711) = {b}" + ("" if b_ok else " ** OUT OF int16 RANGE **")) + if m_ok and b_ok: + print(f" -> 0x1710 = 0x{m & 0xFFFF:04X} 0x1711 = 0x{b & 0xFFFF:04X}") + print() + print(" Not written to the device. To apply: edit the k{Fm,Dab}TuneFeVarm/") + print(" k{Fm,Dab}TuneFeVarb constants in components/drivers/si4684/src/Si4684Driver.cpp") + print(" (current values and AN851 Appendix B citation are next to them), reflash, then") + print(" re-run this sweep with antcap=0 (auto) at the same points to confirm auto-tune") + print(" now tracks the measured optimum.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Software/tools/si4684_xtal_calibration.py b/Software/tools/si4684_xtal_calibration.py new file mode 100644 index 0000000..97ce6e3 --- /dev/null +++ b/Software/tools/si4684_xtal_calibration.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""si4684_xtal_calibration.py — live Si4684 crystal trim via FM_RSQ FREQOFF. + +DigiRadio firmware — https://github.com/manvalan/DigiRadio + +Copyright 2026 Michele Bigi +SPDX-License-Identifier: Apache-2.0 + +Uses the Si4684's own FM_RSQ_STATUS FREQOFF field (AN649 Command 0x32, +RESP8, signed offset in units of 2 PPM) as a precision frequency reference +-- real FM broadcast transmitters are GPS/rubidium-locked, so a locked +station's carrier reads the same PPM error as the receiver's own crystal +reference error, no lab equipment required. + +Procedure (matches AN649 §9.3's own recommendation to trim by measurement, +just automated over HTTP instead of by ear): + + 1. Tune to a strong, stable FM station. + 2. Read GET /api/tuner/status -> fm.freqoff_ppm. + 3. new_xtal_freq_hz = 19200000 * (1 + ppm/1e6) + 4. POST /api/tuner/xtal-calibrate {"xtal_freq_hz": new_xtal_freq_hz} + (this reboots the Si4684, no ESP32 restart, no persistence yet). + 5. Re-tune, re-read freqoff_ppm. Repeat until it converges near 0. + 6. Optionally cross-check on a second station at the other end of the + band -- if the residual offset differs systematically between the two, + the error isn't (only) the crystal; don't chase it further with this + tool. + +Nothing here is persisted to flash. Once you have a converged xtal_freq_hz, +hand it to a human/Claude to bake into the Si4684Driver::boot() default +call in main/hardware_bootstrap.cpp -- this script only calibrates the +currently-running session. + +Usage: + python3 tools/si4684_xtal_calibration.py --host 192.168.1.62 \\ + --frequency-khz 87600 + + python3 tools/si4684_xtal_calibration.py --host 192.168.1.62 \\ + --frequency-khz 87600 --once # single read, no correction loop +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +import urllib.error +import urllib.request +from typing import Optional + + +def http_request(host: str, method: str, path: str, payload: Optional[dict], + timeout: float) -> dict: + url = f"http://{host}{path}" + # Compact separators: the firmware's hand-rolled JSON parser is not + # whitespace-tolerant (confirmed bug in components/core/src/TunerJson.cpp). + data = json.dumps(payload, separators=(",", ":")).encode() if payload is not None else None + headers = {"Content-Type": "application/json"} if data is not None else {} + req = urllib.request.Request(url, data=data, method=method, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read() + return json.loads(body) if body else {} + + +def tune_fm(host: str, frequency_khz: int, timeout: float) -> dict: + return http_request(host, "POST", "/api/tuner/tune", + {"band": "fm", "frequency_khz": frequency_khz}, timeout) + + +def read_status(host: str, timeout: float) -> dict: + return http_request(host, "GET", "/api/tuner/status", None, timeout) + + +def recalibrate(host: str, xtal_freq_hz: int, ibias: int, ctun: int, + timeout: float) -> dict: + return http_request( + host, "POST", "/api/tuner/xtal-calibrate", + {"xtal_freq_hz": xtal_freq_hz, "ibias": ibias, "ctun": ctun}, timeout) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Trim Si4684 XTAL_FREQ live using FM_RSQ_STATUS FREQOFF " + "-- no lab equipment, uses locked broadcast carriers as reference.") + parser.add_argument("--host", required=True, help="Device IP or mDNS host") + parser.add_argument("--frequency-khz", type=int, required=True, + help="A strong, stable FM station to lock onto, e.g. 87600") + parser.add_argument("--ibias", type=int, default=72, help="POWER_UP IBIAS (0-127)") + parser.add_argument("--ctun", type=int, default=0, + help="POWER_UP CTUN (0-63) -- default 0, the best value " + "found by ear on 2026-08-23; leave alone unless you have " + "a reason to also move it") + parser.add_argument("--start-xtal-freq-hz", type=int, default=19200000, + help="Starting XTAL_FREQ before the first correction") + parser.add_argument("--max-iterations", type=int, default=6) + parser.add_argument("--converge-ppm", type=float, default=1.0, + help="Stop once |freqoff_ppm| is under this many ppm") + parser.add_argument("--settle-s", type=float, default=1.0, + help="Delay after tune/recalibrate before reading status") + parser.add_argument("--samples", type=int, default=5, + help="FREQOFF reads averaged per iteration (reduces " + "reception-noise jitter in the ppm estimate)") + parser.add_argument("--sample-gap-s", type=float, default=0.3) + parser.add_argument("--damping", type=float, default=0.6, + help="Fraction of the measured ppm correction applied " + "per iteration (<1.0 avoids overshoot/oscillation " + "around the true value)") + parser.add_argument("--timeout", type=float, default=10.0) + parser.add_argument("--once", action="store_true", + help="Single read only, no correction loop") + args = parser.parse_args() + + xtal_freq_hz = args.start_xtal_freq_hz + + for iteration in range(1 if args.once else args.max_iterations): + try: + recalibrate(args.host, xtal_freq_hz, args.ibias, args.ctun, args.timeout) + except (urllib.error.URLError, OSError) as exc: + print(f"error: xtal-calibrate failed: {exc}", file=sys.stderr) + return 1 + time.sleep(args.settle_s) + + try: + status = tune_fm(args.host, args.frequency_khz, args.timeout) + except (urllib.error.URLError, OSError) as exc: + print(f"error: tune failed: {exc}", file=sys.stderr) + return 1 + time.sleep(args.settle_s) + + samples = [] + for s in range(args.samples): + if s > 0: + time.sleep(args.sample_gap_s) + try: + status = read_status(args.host, args.timeout) + except (urllib.error.URLError, OSError) as exc: + print(f"error: status read failed: {exc}", file=sys.stderr) + return 1 + fm = status.get("fm") or {} + if status.get("locked", False) and fm.get("freqoff_ppm") is not None: + samples.append((fm["freqoff_ppm"], fm.get("rssi_dbuv"), fm.get("snr_db"))) + + if not samples: + print(" no lock / no FREQOFF reading across all samples -- pick " + "a stronger station and retry", file=sys.stderr) + return 1 + + ppm_avg = sum(s[0] for s in samples) / len(samples) + rssi = samples[-1][1] + snr = samples[-1][2] + print(f"iter {iteration}: xtal_freq_hz={xtal_freq_hz} " + f"samples={len(samples)}/{args.samples} rssi={rssi} snr={snr} " + f"freqoff_ppm_avg={ppm_avg:.1f} " + f"raw={[s[0] for s in samples]}") + + if args.once: + return 0 + + if abs(ppm_avg) <= args.converge_ppm: + print(f"\nConverged: xtal_freq_hz={xtal_freq_hz} " + f"(residual {ppm_avg:.1f} ppm avg, ibias={args.ibias}, " + f"ctun={args.ctun})") + print("Not persisted -- to make this the boot default, edit the " + "gSi4684.boot(...) call in main/hardware_bootstrap.cpp.") + return 0 + + # Empirically determined 2026-08-23: correcting in the "+ppm" + # direction diverges (each iteration made freqoff_ppm larger, not + # smaller). The correct sign is "-ppm" -- confirmed by a live A/B + # test, not derived from AN649 (which doesn't specify the relation + # between XTAL_FREQ and FREQOFF's sign convention). Damping avoids + # overshoot from single-reading reception noise. + xtal_freq_hz = round(xtal_freq_hz * (1.0 - args.damping * ppm_avg / 1e6)) + + print(f"\nDid not converge within {args.max_iterations} iterations " + f"(last xtal_freq_hz={xtal_freq_hz}). Try again, or check the " + f"second-station cross-check described in this script's docstring " + f"-- a residual that varies with frequency isn't the crystal.") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())