From 84393c00e8f3204f3ac3909d27c5fdd672fdc1ad Mon Sep 17 00:00:00 2001 From: MarcelineVPQ Date: Tue, 2 Jun 2026 19:11:39 -0600 Subject: [PATCH] wip: park audio-crackle investigation + haptic/OLED defaults + debug tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserves in-progress work that had been sitting uncommitted in the working tree (predates this session) so it is protected in git history. This is NOT a verified/shipped fix — it is parked. Audio path is unverified on hardware and still carries debug instrumentation. - src/audio.cpp: drop the 512->480 resampler and retime the 0x36 frame to a true 10ms/100Hz grid (SAMPLE_SIZE 64->60, 480-sample buffer) to chase the ~45kHz-vs-48kHz speaker underrun theory behind the crackle; dynamic speaker sub-report offset; debug printf + Opus-frame-dump instrumentation. - src/config.cpp: default audio_buffer_length 64->16; auto_haptics_enable default 1 (Fallback) -> 0 (Off). - src/oled.cpp: chunked non-blocking SPI flush (issue #7 OLED-stall angle) and a battery-% midpoint display tweak. - scripts/: audio debug helpers (opus-dump decode, pi audio test, sine ch1/2). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/decode_opus_dump.py | 73 ++++++++++++++++++ scripts/pi_ds5_audio_test.py | 146 +++++++++++++++++++++++++++++++++++ scripts/sine_ch12.py | 45 +++++++++++ src/audio.cpp | 76 ++++++++++++------ src/config.cpp | 6 +- src/oled.cpp | 66 +++++++++++++--- 6 files changed, 375 insertions(+), 37 deletions(-) create mode 100644 scripts/decode_opus_dump.py create mode 100644 scripts/pi_ds5_audio_test.py create mode 100644 scripts/sine_ch12.py diff --git a/scripts/decode_opus_dump.py b/scripts/decode_opus_dump.py new file mode 100644 index 0000000..fb44654 --- /dev/null +++ b/scripts/decode_opus_dump.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Decode raw Opus frames captured from firmware serial output. + +Reads [OPUS_FRAME_N] hex lines from stdin or a file, decodes each frame +with libopus, and writes the result as a WAV file + prints a summary +(peak amplitude, zero-crossing rate) to diagnose encoder output quality. +""" +import ctypes +import struct +import sys +import wave +import re + +lib = ctypes.cdll.LoadLibrary("libopus.so.0") + +SAMPLE_RATE = 48000 +CHANNELS = 2 +FRAME_SIZE = 480 # 10ms at 48kHz + +# Create decoder +err = ctypes.c_int(0) +decoder = lib.opus_decoder_create(SAMPLE_RATE, CHANNELS, ctypes.byref(err)) +if err.value != 0: + print(f"opus_decoder_create failed: {err.value}", file=sys.stderr) + sys.exit(1) + +infile = sys.argv[1] if len(sys.argv) > 1 else "/tmp/ds5_opus.log" +with open(infile) as f: + lines = f.readlines() + +frames = [] +for line in lines: + m = re.match(r'\[OPUS_FRAME_\d+\]\s+([0-9a-fA-F]+)', line.strip()) + if m: + frames.append(bytes.fromhex(m.group(1))) + +if not frames: + print("No [OPUS_FRAME_N] lines found in input.", file=sys.stderr) + sys.exit(1) + +print(f"Found {len(frames)} Opus frames, decoding...") + +all_pcm = b"" +for i, frame_data in enumerate(frames): + pcm = (ctypes.c_int16 * (FRAME_SIZE * CHANNELS))() + ret = lib.opus_decode( + decoder, + frame_data, len(frame_data), + pcm, FRAME_SIZE, + 0 # no FEC + ) + if ret < 0: + errstr = lib.opus_strerror(ret) + print(f" Frame {i+1}: DECODE ERROR {ret} ({ctypes.string_at(errstr).decode()})") + continue + + samples = list(pcm) + peak = max(abs(s) for s in samples) + nonzero = sum(1 for s in samples if s != 0) + print(f" Frame {i+1}: {ret} samples decoded, peak={peak}, nonzero={nonzero}/{len(samples)}") + print(f" TOC=0x{frame_data[0]:02x} first 8 bytes: {frame_data[:8].hex()}") + all_pcm += struct.pack(f"<{ret * CHANNELS}h", *samples[:ret * CHANNELS]) + +outpath = "/tmp/ds5_opus_decoded.wav" +with wave.open(outpath, "w") as wf: + wf.setnchannels(CHANNELS) + wf.setsampwidth(2) + wf.setframerate(SAMPLE_RATE) + wf.writeframes(all_pcm) + +print(f"\nDecoded audio written to {outpath}") +print(f"Play with: aplay {outpath}") +print(f"View spectrogram: sox {outpath} -n spectrogram -o /tmp/ds5_opus_spectrogram.png") diff --git a/scripts/pi_ds5_audio_test.py b/scripts/pi_ds5_audio_test.py new file mode 100644 index 0000000..3ba245b --- /dev/null +++ b/scripts/pi_ds5_audio_test.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Send a 440 Hz sine to the DualSense speaker over Bluetooth from a Linux host +(Raspberry Pi), via raw /dev/hidraw writes — replicating the DS5Dongle firmware's +0x36 audio report BYTE-FOR-BYTE (same Opus settings, same SetStateData, same CRC). + +Purpose: test whether a NON-tunneled BT path (Pi's BlueZ over a real UART) delivers +clean audio to the DS5 speaker, vs the Pico 2 W's BT-over-gSPI tunnel. If clean here, +the Pico's tunnel is the crackle culprit. + +Pair the DS5 first (bluetoothctl), then run as root (hidraw needs RW): + sudo python3 pi_ds5_audio_test.py [seconds] [--headset] [--audbuf N] +""" +import ctypes, struct, math, sys, os, glob, time + +# ---- libopus via ctypes (same loader style as decode_opus_dump.py) ---- +opus = ctypes.cdll.LoadLibrary("libopus.so.0") +opus.opus_encoder_create.restype = ctypes.c_void_p +opus.opus_encoder_create.argtypes = [ctypes.c_int32, ctypes.c_int, ctypes.c_int, + ctypes.POINTER(ctypes.c_int)] +opus.opus_encode_float.restype = ctypes.c_int32 +opus.opus_encode_float.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_float), + ctypes.c_int, ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_int32] +# opus_encoder_ctl is variadic; leave argtypes unset and pass c_int values. +OPUS_APPLICATION_AUDIO = 2049 +OPUS_SET_BITRATE_REQUEST = 4002 +OPUS_SET_VBR_REQUEST = 4006 +OPUS_SET_COMPLEXITY_REQUEST = 4010 + +RATE, CH, FRAME, OPUS_BYTES = 48000, 2, 480, 200 # 480 = 10 ms; 200 B = 160 kbps CBR + +def make_encoder(): + err = ctypes.c_int(0) + enc = opus.opus_encoder_create(RATE, CH, OPUS_APPLICATION_AUDIO, ctypes.byref(err)) + if err.value != 0 or not enc: + sys.exit(f"opus_encoder_create failed: {err.value}") + enc_p = ctypes.c_void_p(enc) + # match firmware core1_entry(): 160 kbps, CBR, complexity 0 + opus.opus_encoder_ctl(enc_p, OPUS_SET_BITRATE_REQUEST, ctypes.c_int(OPUS_BYTES*8*100)) + opus.opus_encoder_ctl(enc_p, OPUS_SET_VBR_REQUEST, ctypes.c_int(0)) + opus.opus_encoder_ctl(enc_p, OPUS_SET_COMPLEXITY_REQUEST, ctypes.c_int(0)) + return enc_p + +# ---- 63-byte SetStateData, verbatim from firmware src/state_mgr.cpp ---- +STATE_DATA = bytes([ + 0xfd, 0xf7, 0x00, 0x00, + 0x7f, 0x64, # VolHeadphonesMax, VolSpeaker + 0x40, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, + 0x07, 0x00, 0x00, 0x02, 0x01, + 0x00, + 0xff, 0xd7, 0x00, # RGB +]) + bytes(16) # trailing zeros -> 63 total +assert len(STATE_DATA) == 63 + +# ---- CRC-32, 0xA2-seeded, verbatim from firmware src/utils.h ---- +def ds_crc32(data): + crc = (~0xEADA2D49) & 0xFFFFFFFF + for b in data: + crc ^= b + for _ in range(8): + if crc & 1: crc = (crc >> 1) ^ 0xEDB88320 + else: crc >>= 1 + return (~crc) & 0xFFFFFFFF + +REPORT_SIZE, SAMPLE_SIZE = 398, 64 # original firmware layout + +def build_packet(payload, seq, counter, headset, audbuf): + pkt = bytearray(REPORT_SIZE) + pkt[0] = 0x36 + pkt[1] = (seq & 0x0F) << 4 + pkt[2] = 0x11 | 0x80 # 0x91 + pkt[3] = 7 + pkt[4] = 0xFE # audio enable, mic OFF + for i in range(5, 10): pkt[i] = audbuf & 0xFF + pkt[10] = counter & 0xFF + pkt[11] = 0x10 | 0x80 # 0x90 SetStateData + pkt[12] = 63 + pkt[13:76] = STATE_DATA + pkt[76] = 0x12 | 0x80 # 0x92 haptic + pkt[77] = SAMPLE_SIZE # haptic data pkt[78..141] = 0 (silent) + pkt[142] = (0x16 if headset else 0x13) | 0x80 # 0x96 headset / 0x93 speaker + pkt[143] = OPUS_BYTES + pkt[144:144+OPUS_BYTES] = payload + struct.pack_into(" 100 pkt/s + dt = target - time.monotonic() + if dt > 0: time.sleep(dt) + os.close(fd) + print(f"Done — {n+1} frames sent (~{(n+1)/100:.1f}s).", end="") + print(f" WARNING: {short} short writes (hidraw truncating the 398 B report!)" if short else " All writes full-length.") + +if __name__ == "__main__": + main() diff --git a/scripts/sine_ch12.py b/scripts/sine_ch12.py new file mode 100644 index 0000000..501cd94 --- /dev/null +++ b/scripts/sine_ch12.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Send a 440 Hz sine wave on channels 1+2 only (speaker), silence on 3+4 (haptic). + +Usage: python3 scripts/sine_ch12.py [seconds] +""" +import subprocess, struct, math, sys, re + +RATE = 48000 +FREQ = 440 +DURATION = int(sys.argv[1]) if len(sys.argv) > 1 else 5 +CHANNELS = 4 +SAMPLES = RATE * DURATION + +# Generate 4-channel S16_LE: sine on ch1+ch2, silence on ch3+ch4 +data = bytearray() +for i in range(SAMPLES): + val = int(6000 * math.sin(2 * math.pi * FREQ * i / RATE)) # ~-15 dBFS (was 32767 / 0 dBFS) — clipping test + s = struct.pack(' 10 && dump_count < 5) { + dump_count++; + printf("[OPUS_FRAME_%d] ", dump_count); + critical_section_enter_blocking(&opus_cs); + for (int di = 0; di < 200; di++) printf("%02x", opus_buf[di]); + critical_section_exit(&opus_cs); + printf("\n"); + } + if ((g_bt_packets % 94) == 0) { + printf("[AUD] usb=%lu enc=%lu bt=%lu opus_ret=%ld fifo_drop=%lu hs=%d\n", + (unsigned long)g_usb_frames, (unsigned long)g_opus_encodes, + (unsigned long)g_bt_packets, (long)g_opus_last_ret, + (unsigned long)g_fifo_drops, + plug_headset ? 1 : 0); + } + } } } @@ -443,7 +473,6 @@ void audio_init() { } static OpusEncoder *encoder; -static WDL_Resampler resampler_audio; void core1_entry() { int error = 0; @@ -455,28 +484,25 @@ void core1_entry() { opus_encoder_ctl(encoder,OPUS_SET_EXPERT_FRAME_DURATION(OPUS_FRAMESIZE_10_MS)); opus_encoder_ctl(encoder,OPUS_SET_BITRATE(200 * 8 * 100)); opus_encoder_ctl(encoder,OPUS_SET_VBR(false)); - opus_encoder_ctl(encoder,OPUS_SET_COMPLEXITY(0)); // max 4 - resampler_audio.SetMode(true, 0, false); - resampler_audio.SetRates(51200, 48000); - resampler_audio.SetFeedMode(true); - resampler_audio.Prealloc(2, 512, 480); + opus_encoder_ctl(encoder,OPUS_SET_COMPLEXITY(0)); // 5 overloaded core1 (stale frames -> worse); 0 keeps up while (true) { static audio_raw_element audio_element{}; queue_remove_blocking(&audio_fifo, &audio_element); - // 将 512 frames 重采样成 480 frames 以解决噪音问题。感谢 @Junhoo - WDL_ResampleSample *in_buf; - int nframes = resampler_audio.ResamplePrepare(512, 2, &in_buf); - for (int i = 0; i < nframes * 2; i++) { - in_buf[i] = audio_element.data[i]; - } - static WDL_ResampleSample out_buf[480 * 2]; - resampler_audio.ResampleOut(out_buf, nframes, 480, 2); - + // audio_element is exactly 480 stereo frames (10 ms @ 48 kHz) = one native + // Opus frame, so encode it directly. The old 512→480 (51200→48000) resample + // only existed to coerce a 512-sample buffer into a legal Opus frame size; it + // shipped 480 samples every 10.667 ms (haptic-gated cadence) = 45 kHz into the + // DS5's free-running 48 kHz DAC → ~6.25% underrun = the periodic gaps/crackle. + // SAMPLE_SIZE 60 + a 480-sample buffer put the whole 0x36 frame on a true + // 10 ms / 100 Hz grid: 100 × 480 = 48000 samples/s, matched, no gaps. static uint8_t out[200]; - (void) opus_encode_float(encoder, out_buf, 480, out, 200); + int enc_ret = opus_encode_float(encoder, audio_element.data, 480, out, 200); + g_opus_last_ret = enc_ret; + g_opus_encodes++; critical_section_enter_blocking(&opus_cs); memcpy(opus_buf, out, 200); critical_section_exit(&opus_cs); + g_opus_ready = true; } } diff --git a/src/config.cpp b/src/config.cpp index 049810d..a9b3667 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -72,7 +72,7 @@ void config_valid() { printf("[Config] polling_rate_mode is invalid\n"); } if (body->audio_buffer_length < 16 || body->audio_buffer_length > 128) { - body->audio_buffer_length = 64; + body->audio_buffer_length = 16; // low buffer avoids the DS5's periodic re-buffer gap printf("[Config] haptics_buffer_length is invalid\n"); } if (body->controller_mode > 2) { @@ -84,8 +84,8 @@ void config_valid() { printf("[Config] current_slot is invalid\n"); } if (body->auto_haptics_enable > 3) { - body->auto_haptics_enable = 1; // Fallback default - printf("[Config] auto_haptics_enable invalid, defaulting to 1 (Fallback)\n"); + body->auto_haptics_enable = 0; // default OFF (was 1/Fallback) — it derives erratic rumble from the speaker + printf("[Config] auto_haptics_enable invalid, defaulting to 0 (Off)\n"); } if (body->auto_haptics_gain > 200) { body->auto_haptics_gain = 100; diff --git a/src/oled.cpp b/src/oled.cpp index 5b866b4..9805ad2 100644 --- a/src/oled.cpp +++ b/src/oled.cpp @@ -52,6 +52,10 @@ constexpr int kRowBytes = kW / 8; constexpr int kFbBytes = kRowBytes * kH; uint8_t fb[kFbBytes]; +uint8_t fb_tx[kFbBytes]; +uint8_t reverse_lut[256]; +int flush_progress = -1; +constexpr int kFlushChunkRows = 8; uint32_t last_render_us = 0; constexpr uint32_t kFrameUs = 100000; @@ -237,7 +241,9 @@ void sh1107_init() { // lives near the other text-drawing helpers below. void draw_button_chrome(); -void flush_fb_raw() { +// Blocking SPI flush — used only during boot splash before the main loop +// starts (no audio/BT to service yet, so blocking is fine). +void flush_fb_raw_blocking() { cmd(0xB0); for (int j = 0; j < kH; j++) { const uint8_t col = kH - 1 - j; @@ -249,11 +255,45 @@ void flush_fb_raw() { } } -void flush_fb() { - draw_button_chrome(); - flush_fb_raw(); +// Prepare a chunked (non-blocking) SPI flush. Pre-reverses the framebuffer +// into fb_tx via the LUT and sets flush_progress = 0. The actual SPI +// transfer is driven by flush_chunk(), called from oled_loop() on +// subsequent iterations — each chunk flushes kFlushChunkRows rows (~130 µs) +// then yields back to the main loop so audio_loop / tud_task / +// cyw43_arch_poll stay serviced. This eliminates the ~1.1 ms blocking +// window that caused audio distortion when the OLED was active (issue #7). +void flush_prepare(bool chrome) { + if (chrome) draw_button_chrome(); + for (int i = 0; i < kFbBytes; i++) fb_tx[i] = reverse_lut[fb[i]]; + flush_progress = 0; } +bool flush_chunk() { + if (flush_progress < 0) return true; + if (flush_progress == 0) cmd(0xB0); + const int end = (flush_progress + kFlushChunkRows < kH) + ? flush_progress + kFlushChunkRows : kH; + for (int j = flush_progress; j < end; j++) { + const uint8_t col = kH - 1 - j; + cmd(0x00 + (col & 0x0F)); + cmd(0x10 + (col >> 4)); + gpio_put(kPinDC, 1); + gpio_put(kPinCS, 0); + spi_write_blocking(spi1, &fb_tx[j * kRowBytes], kRowBytes); + gpio_put(kPinCS, 1); + } + flush_progress = end; + if (flush_progress >= kH) { + flush_progress = -1; + return true; + } + return false; +} + +void flush_fb_raw() { flush_prepare(false); } + +void flush_fb() { flush_prepare(true); } + void fb_clear() { memset(fb, 0, sizeof(fb)); } void px(int x, int y, bool on) { @@ -542,8 +582,10 @@ void handle_buttons() { // --- Charge ETA tracker -------------------------------------------------- // The DS5 only reports battery in 10% steps (interrupt_in_data[52] low -// nibble, 0..10; high nibble is power-state, 1 == charging). We can't read a -// finer percentage over BT, so a smooth countdown is impossible. Instead we +// nibble, 0..10; high nibble is power-state, 1 == charging). We display the +// midpoint of each band (+5), matching the kernel hid-playstation driver and +// Steam: 5, 15, 25, … 95, 100%. We can't read a finer percentage over BT, +// so a smooth countdown is impossible. Instead we // time how long each 10% step takes while charging and extrapolate the // remaining steps. Sampled once per frame from oled_loop (continuously, so // the estimate stays current even while the panel is dimmed/off and even when @@ -701,8 +743,8 @@ __attribute__((noinline)) void render_screen() { draw_text(kContentX, 9, buf); const uint8_t pwr = interrupt_in_data[52]; - int pct = (pwr & 0x0F) * 10; - if (pct > 100) pct = 100; + int raw = pwr & 0x0F; + int pct = (raw >= 10) ? 100 : raw * 10 + 5; const uint8_t pstate = pwr >> 4; char marker = ' '; if (pstate == 1) marker = '+'; // Charging @@ -1807,13 +1849,15 @@ void boot_splash() { draw_text(cx_for(l1), 16, l1); draw_text(cx_for(l2), 30, l2); draw_text(cx_for(l3), 44, l3); - flush_fb(); + draw_button_chrome(); + flush_fb_raw_blocking(); sleep_ms(1500); } } // namespace void oled_init() { + for (int i = 0; i < 256; i++) reverse_lut[i] = reverse_byte((uint8_t)i); spi_init(spi1, 10 * 1000 * 1000); gpio_set_function(kPinCLK, GPIO_FUNC_SPI); gpio_set_function(kPinMOSI, GPIO_FUNC_SPI); @@ -1874,6 +1918,10 @@ void oled_loop() { handle_buttons(); const uint32_t now = time_us_32(); rumble_burst_tick(now); + if (flush_progress >= 0) { + flush_chunk(); + return; + } if ((now - last_render_us) < kFrameUs) return; last_render_us = now; // Track charge progress every frame — before the power-ladder early-returns