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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user