Fix Si4684 pitch distortion: uncalibrated crystal (CTUN/XTAL_FREQ)
Root cause of FM/DAB "stonata" audio: xtalCtun=31/nominal XTAL_FREQ defaults were never measured against this board's actual crystal (Abracon ABM8-19.200MHZ-10-1-U-T, CL=10pF, two external 15pF load caps). Fixed via CTUN trim by ear (31->0) plus a new live calibration loop that reads the Si4684's own FM_RSQ FREQOFF field (broadcast carriers are GPS-locked, so it's a free precision frequency reference) to trim XTAL_FREQ with no lab equipment. Converged to CTUN=0, XTAL_FREQ=19199750 Hz, residual -3/-4 ppm cross-checked on two stations. New tools/infrastructure (kept, not one-off): - Si4684Driver::recalibrateXtal() + POST /api/tuner/xtal-calibrate: live crystal re-trim without an ESP32 reflash. - FM_RSQ FREQOFF exposed as "freqoff_ppm" in GET /api/tuner/status. - tools/si4684_xtal_calibration.py: automates the trim loop. - tools/si4684_antenna_calibration.py: AN851 Appendix A ANTCAP/VARM/VARB sweep tool (same session, separate calibration). - CONFIG_ESP32_I2S_TEST_TONE (off by default): isolates Si4684-specific audio issues from shared-downstream ones by writing a tone directly over the I2S bus the Si4684 also uses. - SIGMA_WRITE_REGISTER_BLOCK/sigma_safeload_block now retry on NACK and verify via read-back instead of firing I2C writes blind. - FM de-emphasis set to European 50us (was left at the US 75us default). - DAB_ACF_ENABLE restored to its previous 0x0000 with a citation explaining why (tested the datasheet default of 0x0003, made things audibly worse). See docs/si4684-rf-investigation-report.md's 2026-08-23 entry for the full elimination chain and docs/TODO.md for follow-up work (persisting calibration results to EEPROM instead of requiring a firmware edit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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). */
|
||||
|
||||
@@ -276,4 +276,41 @@ struct AntennaCalibrationRequest {
|
||||
[[nodiscard]] std::expected<AntennaCalibrationRequest, ParseError>
|
||||
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<XtalCalibrationRequest, ParseError>
|
||||
parseXtalCalibrationJson(std::string_view json);
|
||||
|
||||
} // namespace core
|
||||
|
||||
@@ -63,6 +63,11 @@ struct TunerStatus {
|
||||
std::optional<FrequencyKHz> fmChipReadFrequency; ///< FM_RSQ READFREQ (may lag commanded).
|
||||
std::optional<std::int8_t> fmRssiDbuV; ///< FM RSSI in dBµV.
|
||||
std::optional<std::int8_t> 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<std::int8_t> fmFreqOffBppm;
|
||||
std::optional<bool> fmStereo; ///< FM stereo pilot detected.
|
||||
std::optional<BroadcastLabel> fmStationName; ///< FM RDS program service name.
|
||||
std::optional<BroadcastLabel> fmRadiotext; ///< FM RDS radiotext (RT).
|
||||
|
||||
@@ -129,6 +129,10 @@ std::string serializeTunerStatusJson(const TunerStatus& status)
|
||||
if (status.fmSnrDb) {
|
||||
out << ",\"snr_db\":" << static_cast<int>(*status.fmSnrDb);
|
||||
}
|
||||
if (status.fmFreqOffBppm) {
|
||||
out << ",\"freqoff_ppm\":"
|
||||
<< (static_cast<int>(*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<XtalCalibrationRequest, ParseError>
|
||||
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<std::uint8_t>(ibias);
|
||||
}
|
||||
|
||||
unsigned long ctun = 0U;
|
||||
if (extractJsonUint(json, "ctun", ctun)) {
|
||||
if (ctun > 63U) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
req.ctun = static_cast<std::uint8_t>(ctun);
|
||||
}
|
||||
|
||||
unsigned long xtalFreqHz = 0U;
|
||||
if (extractJsonUint(json, "xtal_freq_hz", xtalFreqHz)) {
|
||||
req.xtalFreqHz = static_cast<std::uint32_t>(xtalFreqHz);
|
||||
}
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
|
||||
@@ -68,6 +68,7 @@ namespace adau1701
|
||||
{
|
||||
const unsigned char deviceAddr =
|
||||
static_cast<unsigned char>(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<unsigned int>(data.size()),
|
||||
const_cast<ADI_REG_TYPE *>(
|
||||
reinterpret_cast<const ADI_REG_TYPE *>(data.data())));
|
||||
auto *bytes = const_cast<ADI_REG_TYPE *>(
|
||||
reinterpret_cast<const ADI_REG_TYPE *>(data.data()));
|
||||
const auto length = static_cast<unsigned int>(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<unsigned>(index),
|
||||
static_cast<unsigned>(write.address()),
|
||||
static_cast<unsigned>(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<unsigned>(index),
|
||||
static_cast<unsigned>(write.address()),
|
||||
static_cast<unsigned>(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<std::int32_t>(
|
||||
(static_cast<std::uint32_t>(raw[0]) << 24) |
|
||||
(static_cast<std::uint32_t>(raw[1]) << 16) |
|
||||
(static_cast<std::uint32_t>(raw[2]) << 8) |
|
||||
static_cast<std::uint32_t>(raw[3]));
|
||||
ESP_LOGI(kTag,
|
||||
"EQ band0 param[%u] addr=0x%04X raw=0x%08X "
|
||||
"value=%f",
|
||||
i, baseAddr + i,
|
||||
static_cast<unsigned>(fixpoint),
|
||||
static_cast<double>(fixpoint) /
|
||||
static_cast<double>(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<unsigned char>(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;
|
||||
|
||||
@@ -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 <string.h>
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<void, Si4684Error> 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<void, Si4684Error> recalibrateXtal(
|
||||
std::uint8_t xtalIbias, std::uint8_t xtalCtun,
|
||||
std::uint32_t xtalFreqHz);
|
||||
|
||||
/**
|
||||
* @brief isBooted — query whether boot completed successfully.
|
||||
|
||||
@@ -178,6 +178,24 @@ public:
|
||||
[[nodiscard]] std::expected<void, core::TunerError> 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<void, core::TunerError> recalibrateXtal(
|
||||
std::uint8_t ibias, std::uint8_t ctun, std::uint32_t xtalFreqHz);
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief mapError — translate Si4684Error to core::TunerError.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void, Si4684Error> 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<void, Si4684Error> 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<void, Si4684Error> Si4684Driver::configureAfterBoot(
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> 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<void, Si4684Error> 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<std::uint8_t>(xtalFreqHz & 0xFFU),
|
||||
static_cast<std::uint8_t>((xtalFreqHz >> 8) & 0xFFU),
|
||||
static_cast<std::uint8_t>((xtalFreqHz >> 16) & 0xFFU),
|
||||
static_cast<std::uint8_t>((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<void, Si4684Error> Si4684Driver::boot(
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> 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<Si4684FmRsq, Si4684Error> Si4684Driver::readFmRsq()
|
||||
static_cast<std::int8_t>(raw[kFmRsqOffSnr]),
|
||||
freqInBand && chipValid,
|
||||
false,
|
||||
static_cast<std::int8_t>(raw[kFmRsqOffFreqOff]),
|
||||
};
|
||||
return rsq;
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ std::expected<core::TunerStatus, core::TunerError> 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<void, core::TunerError> Si4684Tuner::setVolume(std::uint8_t level)
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> 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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<char, 128> 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<void, NetError> 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -8,6 +8,7 @@ idf_component_register(
|
||||
"antenna_calibration.cpp"
|
||||
"$<$<BOOL:${CONFIG_TEST_FIRMWARE}>:test_firmware.cpp>"
|
||||
"$<$<BOOL:${CONFIG_I2S_SDATA_PROBE}>:i2s_sdata_probe.cpp>"
|
||||
"$<$<BOOL:${CONFIG_ESP32_I2S_TEST_TONE}>: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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<bool>(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;
|
||||
}
|
||||
|
||||
@@ -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 <cmath>
|
||||
#include <cstdint>
|
||||
#include <numbers>
|
||||
|
||||
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<float>;
|
||||
} // namespace
|
||||
|
||||
[[noreturn]] void run(float freqHz)
|
||||
{
|
||||
esp32_i2s_sink::tryAcquire();
|
||||
|
||||
const float phaseStep = kTwoPi * freqHz / static_cast<float>(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::int32_t>(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
|
||||
@@ -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
|
||||
@@ -160,7 +160,21 @@ std::expected<void, HardwareBootError> 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<int>(tunerResult.error()));
|
||||
return std::unexpected(HardwareBootError::Si4684BootFailed);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user