diff --git a/CHANGELOG.md b/CHANGELOG.md index 62d8a51..c367141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 0–200 = **−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 diff --git a/src/audio.cpp b/src/audio.cpp index 1a31674..300de8a 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -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; diff --git a/src/config.cpp b/src/config.cpp index a9b3667..27fcfae 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -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"); diff --git a/src/config.h b/src/config.h index 69f665b..323b6a6 100644 --- a/src/config.h +++ b/src/config.h @@ -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 { diff --git a/src/oled.cpp b/src/oled.cpp index 9805ad2..6b87c40 100644 --- a/src/oled.cpp +++ b/src/oled.cpp @@ -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; } }