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:
2026-08-23 20:30:42 +02:00
co-authored by Claude Sonnet 5
parent a91c63fc31
commit fae2305164
24 changed files with 1309 additions and 29 deletions
@@ -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,