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
@@ -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 RESP510). */
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,
+31
View File
@@ -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`.
+1
View File
@@ -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
+13
View File
@@ -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
+14
View File
@@ -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;
}
+57
View File
@@ -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
+28
View File
@@ -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
+15 -1
View File
@@ -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);
}
+19
View File
@@ -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())
+184
View File
@@ -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())