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