feat(audio): experimental speaker rate-trim to chase the crackle (#7)

The DS5 DAC and the USB host audio clock are independent ~48kHz crystals; the residual ppm drift slowly underruns the controller's audio buffer = the periodic crackle. The speaker path is 1:1 (no resampler), so it delivered exactly the host's 48kHz; this adds a fine rate trim that delivers 48000+(trim) samples/s via a zero-order-hold duplicate/drop accumulator to null the drift.

New speaker_rate_trim config field (stored 0..200 = -100..+100 Hz, default 100 = 0 Hz = exact no-op since 48000/48000 is exact in double). Swept on the OLED Settings screen (new 'SpkTrim' item) with the D-pad <</>> at 1 Hz/step. Reset/Wipe items shifted to idx 16/17 (named constants, action handlers auto-follow).

Diagnostic intent: if one value silences the crackle and HOLDS, drift was a static offset and this is the fix; if it nulls then creeps back, that proves dynamic drift needing adaptive resampling. Tune with scripts/sine_ch12.py + time the crackle interval.

NOT YET BUILT OR HIL-TESTED. Experimental branch only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MarcelineVPQ
2026-06-04 19:30:43 -06:00
co-authored by Claude Opus 4.8
parent 878a742bfc
commit 3b157cb9a1
5 changed files with 62 additions and 17 deletions
+4
View File
@@ -8,6 +8,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
## [Unreleased]
### Added (experimental — `audio/speaker-rate-trim` branch)
- **`SpkTrim` setting — fine speaker clock-drift trim to chase the periodic crackle (#7).** The DS5's DAC and the USB host's audio clock are independent ~48 kHz crystals; the residual ppm mismatch slowly drifts the controller's audio buffer, which underruns and crackles. The speaker path now delivers `48000 + trim` samples/s (via a zero-order-hold duplicate/drop accumulator) instead of exactly 48000, to null that drift. New `speaker_rate_trim` config field (stored 0200 = **100…+100 Hz**, default **0 Hz** = exact no-op), swept on the OLED **Settings** screen with the D-pad ▶◀ at 1 Hz/step. **Experimental / diagnostic:** if one value silences the crackle and *holds*, the drift was a static offset and this is the fix; if it nulls then creeps back, that proves the drift is dynamic and needs adaptive resampling. Tune with `scripts/sine_ch12.py` playing a steady tone and time the interval between crackles.
---
## [0.6.11-oled-edition] — 2026-06-02
+33 -12
View File
@@ -264,12 +264,20 @@ void audio_loop() {
static float audio_buf[480 * 2];
static uint audio_buf_pos = 0;
static double spk_acc = 0.0; // speaker rate-trim accumulator (persists across calls)
// 2. 从4ch中提取ch3/ch4,转换为float输入重采样器
WDL_ResampleSample *in_buf;
int nframes = resampler.ResamplePrepare(frames, OUTPUT_CHANNELS, &in_buf);
const float audio_gain = mute[0] ? 0.0f : powf(10.0f, get_config().speaker_volume / 20.0f);
const float haptics_gain = get_config().haptics_gain;
// EXPERIMENTAL speaker clock-drift trim (audio/speaker-rate-trim). Deliver
// 48000 + trim samples/s to the DS5 instead of exactly 48000, to null the slow
// inter-crystal drift behind the crackle (#7). trim = 0 → ratio == 1.0 → exact
// no-op (byte-identical to pre-trim, since 48000/48000 is exact in double).
const int spk_trim_hz = (int)get_config().speaker_rate_trim - 100; // -100..+100 Hz
const double spk_ratio = (48000.0 + spk_trim_hz) / 48000.0;
uint16_t spk_max = g_peak_spk;
uint16_t hap_max = g_peak_hap;
@@ -308,19 +316,32 @@ void audio_loop() {
if (b > hap_max) hap_max = b;
}
#if !DISABLE_SPEAKER_PROC
audio_buf[audio_buf_pos++] = raw[i * INPUT_CHANNELS] / 32768.0f * audio_gain;
audio_buf[audio_buf_pos++] = raw[i * INPUT_CHANNELS + 1] / 32768.0f * audio_gain;
if (audio_buf_pos == 480 * 2) {
static audio_raw_element element{};
memcpy(element.data, audio_buf, 480 * 2 * 4);
if (queue_is_full(&audio_fifo)) {
queue_try_remove(&audio_fifo,NULL);
g_fifo_drops++;
{
const float spk_l = raw[i * INPUT_CHANNELS] / 32768.0f * audio_gain;
const float spk_r = raw[i * INPUT_CHANNELS + 1] / 32768.0f * audio_gain;
// Rate-trim: emit `spk_ratio` output samples per input sample on average
// (usually 1; occasionally 2 when trim>0, or 0 when trim<0). Zero-order
// hold — at ppm-scale trims a single duplicated/dropped sample is inaudible
// vs a full 480-sample buffer underrun.
spk_acc += spk_ratio;
int emit = (int)spk_acc;
spk_acc -= emit;
while (emit-- > 0) {
audio_buf[audio_buf_pos++] = spk_l;
audio_buf[audio_buf_pos++] = spk_r;
if (audio_buf_pos == 480 * 2) {
static audio_raw_element element{};
memcpy(element.data, audio_buf, 480 * 2 * 4);
if (queue_is_full(&audio_fifo)) {
queue_try_remove(&audio_fifo,NULL);
g_fifo_drops++;
}
if (!queue_try_add(&audio_fifo, &element)) {
printf("[Audio] Warning: audio_fifo add failed\n");
}
audio_buf_pos = 0;
}
}
if (!queue_try_add(&audio_fifo, &element)) {
printf("[Audio] Warning: audio_fifo add failed\n");
}
audio_buf_pos = 0;
}
#endif
float h_l = raw[i * INPUT_CHANNELS + 2] / 32768.0f * haptics_gain;
+4
View File
@@ -121,6 +121,10 @@ void config_valid() {
body->controller_wakes_display = 1;
printf("[Config] controller_wakes_display invalid, defaulting to 1 (on)\n");
}
if (body->speaker_rate_trim > 200) { // 0xFF erased / upgrade → 0 Hz (centered)
body->speaker_rate_trim = 100; // 100 encodes 0 Hz; actual trim = stored - 100
printf("[Config] speaker_rate_trim invalid, defaulting to 100 (0 Hz)\n");
}
if (body->config_version != CONFIG_VERSION) {
body->config_version = CONFIG_VERSION;
printf("[Config] Warning: Config may breaking change\n");
+8
View File
@@ -54,6 +54,14 @@ struct __attribute__((packed)) Config_body {
// preserves the original "any controller activity wakes the screen"
// behavior. Issues #8 (dim timeout never fired during play) and #9.
uint8_t controller_wakes_display;
// EXPERIMENTAL (audio/speaker-rate-trim branch): fine speaker clock-drift trim.
// The DS5's DAC and the USB host's audio clock are independent ~48 kHz crystals;
// the residual ppm mismatch slowly drifts the DS5's buffer and produces the
// periodic crackle (#7). This nudges our delivered rate to 48000 + (trim-100) Hz
// to null that drift. Stored 0..200, offset by 100 so an erased-flash 0xFF clamps
// to 100 = 0 Hz (an exact no-op, byte-identical to pre-trim). Swept on the OLED
// Settings screen with the D-pad ▶◀, 1 Hz/step. Default 100 (0 Hz).
uint8_t speaker_rate_trim;
};
struct __attribute__((packed)) Config {
+13 -5
View File
@@ -130,7 +130,7 @@ constexpr int kLbModeHost = 8;
constexpr int kNumLbModes = 9;
// Settings screen state
constexpr int kNumSettingsItems = 17; // 8 fields + 3 auto-haptic + 2 screen-timeout + BT mic + Ctrl-wake + Reset + Wipe
constexpr int kNumSettingsItems = 18; // 8 fields + 3 auto-haptic + 2 screen-timeout + BT mic + Ctrl-wake + Spk-trim + Reset + Wipe
constexpr int kSettingsAutoHapEnaIdx = 8;
constexpr int kSettingsAutoHapGainIdx = 9;
constexpr int kSettingsAutoHapLpIdx = 10;
@@ -138,8 +138,9 @@ constexpr int kSettingsScrDimIdx = 11;
constexpr int kSettingsScrOffIdx = 12;
constexpr int kSettingsBtMicIdx = 13;
constexpr int kSettingsCtrlWakeIdx = 14;
constexpr int kSettingsResetIdx = 15;
constexpr int kSettingsWipeSlotsIdx = 16;
constexpr int kSettingsSpkTrimIdx = 15;
constexpr int kSettingsResetIdx = 16;
constexpr int kSettingsWipeSlotsIdx = 17;
Config_body settings_local{};
int settings_sel = 0;
bool settings_dirty = false;
@@ -1599,6 +1600,12 @@ void settings_adjust(int delta) {
}
case 13: c.bt_mic_enable ^= 1; break; // BT mic on/off
case 14: c.controller_wakes_display ^= 1; break; // controller activity wakes OLED on/off
case 15: { // speaker_rate_trim stored 0..200 = -100..+100 Hz, 1 Hz/step
int v = (int)c.speaker_rate_trim + delta;
if (v < 0) v = 0; if (v > 200) v = 200;
c.speaker_rate_trim = (uint8_t)v;
break;
}
}
}
@@ -1702,8 +1709,9 @@ __attribute__((noinline)) void format_settings_item(int idx, char* line, size_t
break;
case 13: snprintf(line, n, "%s BT Mic %s", cur, c.bt_mic_enable ? "on" : "off"); break;
case 14: snprintf(line, n, "%s CtrlWake %s", cur, c.controller_wakes_display ? "on" : "off"); break;
case 15: snprintf(line, n, "%s Reset to defaults", cur); break;
case 16: snprintf(line, n, "%s Wipe all slots", cur); break;
case 15: snprintf(line, n, "%s SpkTrim %+dHz", cur, (int)c.speaker_rate_trim - 100); break;
case 16: snprintf(line, n, "%s Reset to defaults", cur); break;
case 17: snprintf(line, n, "%s Wipe all slots", cur); break;
}
}