diff --git a/.gitignore b/.gitignore index 10f4b90a..11036ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ scripts/* # Local release artifacts release-symbols/ + +# Audio Haptics SDK lives in the sibling moonlight-audio-haptics repository. +/audio-haptics-sdk/ diff --git a/cmake/ResolveMoonlightAudioHaptics.cmake b/cmake/ResolveMoonlightAudioHaptics.cmake new file mode 100644 index 00000000..2bcb8ea7 --- /dev/null +++ b/cmake/ResolveMoonlightAudioHaptics.cmake @@ -0,0 +1,26 @@ +# Resolve the standalone Apache-2.0 audio-haptics SDK for host-side tools and +# HarmonyOS integration. CI should set AUDIO_HAPTICS_SDK_DIR to an immutable +# checkout; local sibling checkout is only a developer convenience. + +set(AUDIO_HAPTICS_SDK_DIR "" CACHE PATH + "Path to the standalone moonlight-audio-haptics checkout") + +function(resolve_moonlight_audio_haptics output_var repository_root) + set(_sdk_path "${AUDIO_HAPTICS_SDK_DIR}") + if(_sdk_path STREQUAL "" AND DEFINED ENV{AUDIO_HAPTICS_SDK_DIR}) + set(_sdk_path "$ENV{AUDIO_HAPTICS_SDK_DIR}") + endif() + if(_sdk_path STREQUAL "") + set(_sdk_path "${repository_root}/../moonlight-audio-haptics") + endif() + + get_filename_component(_sdk_path "${_sdk_path}" ABSOLUTE) + if(NOT EXISTS "${_sdk_path}/CMakeLists.txt") + message(FATAL_ERROR + "moonlight-audio-haptics not found at ${_sdk_path}. " + "Clone https://github.com/AlkaidLab/moonlight-audio-haptics and " + "set AUDIO_HAPTICS_SDK_DIR to its root.") + endif() + + set(${output_var} "${_sdk_path}" PARENT_SCOPE) +endfunction() diff --git a/docs/AUDIO_HAPTICS_ANDROID_DEVICE_BENCHMARK.md b/docs/AUDIO_HAPTICS_ANDROID_DEVICE_BENCHMARK.md new file mode 100644 index 00000000..ff65531e --- /dev/null +++ b/docs/AUDIO_HAPTICS_ANDROID_DEVICE_BENCHMARK.md @@ -0,0 +1,698 @@ +# 音频振动 SDK Android 真机基准 + +> 状态:第一台 Android 真机 SDK Core 基线与混合 DSP 门禁通过 +> 日期:2026-07-16 +> SDK:0.3.0 `game-p3-v1` 基线;0.5.14 `action-rpg-p4g-v4` 当前版本;ABI v1 不变 + +## 结论 + +在魅族 17(Android 13 / API 33 / arm64-v8a)上,SDK Core 完成五种确定性负载、每种 +10 秒的连续离线压测。`0.5.14 / action-rpg-p4g-v4` 所有 `ah_process_i16()` 调用均无错误, +P99 最高 274.270 微秒,低于 500 微秒门槛;最低实时倍数为 22.5x。Android arm64 第一类 +设备的 CPU 性能 +门禁通过。 + +| 场景 | Mean | P95 | P99 | Max | 实时倍数 | 错误 | +|---|---:|---:|---:|---:|---:|---:| +| 静音底噪 | 136.977 µs | 140.469 µs | 170.521 µs | 435.625 µs | 36.4x | 0 | +| 持续低频 | 202.411 µs | 211.458 µs | 253.489 µs | 483.542 µs | 24.7x | 0 | +| 游戏强瞬态 | 141.508 µs | 171.042 µs | 198.021 µs | 436.771 µs | 35.2x | 0 | +| 音乐型混合 | 221.387 µs | 227.605 µs | 274.270 µs | 490.833 µs | 22.5x | 0 | +| 语音型调制 | 221.401 µs | 227.084 µs | 274.115 µs | 513.959 µs | 22.5x | 0 | + +压测前后电池温度均为 28.7℃。构建使用 NDK 28.2.13676358,运行二进制及报告 +均记录 SHA-256;设备序列号只保留 12 位 SHA-256 截断哈希。 + +相对早期基线,trailing time median、frequency median 与 previous-spectrum maximum filter +显著增加了固定计算量;当前最慢内容仍有 22.5x 实时余量且 P99 通过移动端单会话 gate。 +Sunshine 多会话部署前需另做并发预算,不能直接用本次单会话结论外推。 + +### 0.4.0 混合 DSP 回归 + +加入 AOSP HapticGenerator 风格执行器包络并与 PCEN/Spectral Flux 混合判定后,在同一 +魅族 17 上按相同的 48 kHz、双声道、240-frame block 和五种负载各 10 秒复测: + +| 场景 | Mean | P95 | P99 | Max | 实时倍数 | 错误 | +|---|---:|---:|---:|---:|---:|---:| +| 静音底噪 | 102.792 µs | 102.812 µs | 129.010 µs | 414.687 µs | 48.5x | 0 | +| 持续低频 | 104.470 µs | 104.687 µs | 132.656 µs | 380.521 µs | 47.7x | 0 | +| 游戏强瞬态 | 103.896 µs | 104.584 µs | 130.729 µs | 395.573 µs | 48.0x | 0 | +| 音乐型混合 | 106.172 µs | 106.458 µs | 135.000 µs | 379.635 µs | 46.9x | 0 | +| 语音型调制 | 105.920 µs | 106.302 µs | 133.229 µs | 381.146 µs | 47.0x | 0 | + +新增每采样滤波、partial AGC 和非线性使均值相对 0.3.0 基线上升约 1.6~1.9 倍,但最高 +P99 最高为 135.000 微秒,低于 500 微秒门槛,且五种负载均为零错误。混合 DSP 的 +Android arm64 CPU gate 通过;OPPO PKJ110 当时不在 ADB 在线列表,仍需补第二类设备 +和真实串流 App 线程 P99。正式报告保存在 +`tools/audio_haptics_android_bench/out/hybrid_hg_p4_v1/`(该目录按约定不纳入版本控制)。 + +## 已验证范围 + +- SDK 公共 C ABI 可被 Android NDK arm64 程序直接链接和调用; +- 48kHz、双声道、240-frame block 的处理耗时和长循环稳定性; +- 静音、持续低频、强瞬态、音乐型与语音型输入下的不同输出分支; +- P50/P95/P99/max、错误数、输出数、实时倍数和温度元数据的自动采集。 + +## 尚未替代的门禁 + +本结果不能替代: + +- 真实游戏、音乐和语音数据集上的事件准确率与拟真度; +- Android App 实际解码音频线程的调度、负载竞争和 shadow 对比; +- Android 机型的振动器映射与主观体验审核; +- HarmonyOS 设备的音频线程性能及马达体验复核。 + +因此 aubio 移除总门禁仍保持 BLOCKED,但“Android arm64 SDK Core 实时性”子门禁已经 +解除。 + +真实远端主机音乐串流的 Android 解码线程 shadow 已于同日完成首轮采集。SDK-only +`-O2` 后 mean 为 400 微秒、P99 桶上界为 1000 微秒、错误为零;它满足 5ms block 的 +硬实时预算,但未达到 500 微秒目标门槛,因此 App 音频线程子门禁仍为 BLOCKED。 + +## Android 本地闭环首轮验证 + +2026-07-15 在同一魅族 17 上安装 SDK-output Debug APK,完成以下真实串流链路: + +```text +远端主机音频 + → Android Opus 解码 PCM + → GPL 宿主薄 C ABI 接线 + → AAR libmoonlight_haptics_android.so / moonlight-haptics-core + → AAR native SPSC HapticFrame 队列 + → PCM critical 外通知 NativeHapticsSession + → 私有线程批量 drain / AudioVibrationService 产品策略 + → AndroidHapticRenderer + → Android vibrator_manager +``` + +构建同时开启: + +```text +enableAudioHapticsShadow=true +enableAudioHapticsOutput=true +``` + +SDK output 开启时,旧 `BassEnergyAnalyzer` 只保留 shadow 对比,不再驱动 Renderer,避免双路振动。默认关闭 APK 与 SDK-output APK 均完成 native、Kotlin、R8 和 APK 打包。 + +系统证据: + +- `MUSIC` 场景:`vibrator_manager` 记录到应用 UID 发出的 `USAGE_MEDIA` 单次波形,首轮样本时长 54/57 ms、幅度约 0.67/0.75,无 repeat;音乐连续底座已在 Renderer 抑制。 +- `GAME` 场景:记录到 repeat continuous waveform,以及 transient + continuous 组合波形;连续更新增加 100 ms 限流和 0.08 幅度滞回,transient 保持低延迟抢占。 +- 会话被强制结束后,`mCurrentVibration` 和 `mNextVibration` 均为 `null`;测试配置随后恢复为 `MUSIC`。 +- 串流初始稳定段 `ah_process_i16()` mean 约 61~65 µs、错误为 0;该数据来自 Debug 集成观察,不替代 Release 精确 P99 gate。 + +因此“SDK IR 能驱动 Android 真实马达”的功能子门禁已解除。以下 P4-A 门禁仍为 BLOCKED: + +- 用户手持主观确认同步性、拟真度、疲劳感与强度; +- 设置关闭、正常退出、切后台、断流、重连的完整生命周期矩阵; +- 30 分钟连续串流稳定性; +- Release/等效优化构建的精确 P50/P95/P99; +- 第二类 Android 振动能力设备的降级验证。 + +### AAR native 边界收口后的回归状态 + +首轮马达证据之后,同日完成了 native 边界重构:`AhEngine`、固定容量 IR 队列、 +`STOP` 饱和优先级、丢帧计数和批量 JNI drain 已移入 Apache-2.0 AAR;GPL 宿主删除了 +重复 `AudioHapticsOutputFrame` 与逐帧十参数 JNI callback,只注册 opaque session +handle。重新启用 output 或音频重连时,Engine 由音频生产线程重建;会话 IR 时间戳 +跨重建保持单调,避免 Renderer stop 后 continuous 状态不再重发或新帧被误判为过期。 +native drain 与 Android Renderer 两层均增加同步 stop fence,防止后台/退出时已经取出的 +旧 batch 在 cancel 后再次补触发马达。 + +重构后已完成: + +- AAR 三 ABI Release 构建、单测、lint、Prefab/AAR 打包; +- 默认关闭、output-only、shadow+output 三种宿主 APK 的 native、Kotlin、R8 和打包; +- output-only APK 在魅族 17 覆盖安装与冷启动,无 linker/JNI/AndroidRuntime 崩溃; +- APK 同时包含 arm64 `libmoonlight-core.so` 和 + `libmoonlight_haptics_android.so`,宿主通过 `dlopen` 公共 C ABI 接线,不形成私有 + C++ ABI 依赖; +- 解锁设备并允许 USB 测试安装后,AAR instrumentation 1/1 通过,覆盖 native `.so` + 加载、`NativeHapticsSession` 创建/场景/stop/close 和 `AndroidHapticRenderer` + stop/close; +- 重构后的 output-only APK 完成真实音乐串流回归:AAR C ABI 解析为 + `resolved=true`,系统在 16:34:33.999 和 16:34:39.056 记录应用 UID 的 `MEDIA` + 单次脉冲;两次均约 55 ms、幅度约 0.70、`repeat=-1`,符合 `MUSIC transient`, + 且没有 linker/JNI/AndroidRuntime 异常; +- 音乐串流中触发 HOME 后,Flyme 将 `Game` 退到后台并回到应用列表;5 秒后系统 + `mCurrentVibration` 与 `mNextVibration` 均为 `null`,未观察到旧 batch 补振或崩溃, + 新增的双层同步 stop fence 通过本轮后台验证; +- 重新进入同一音乐串流后,新 `Game` 于 16:39:41 创建,native audio 在 389 ms 内 + 连接完成;16:39~16:40 的系统历史保留 27 次新 `MEDIA` transient,幅度范围 + 0.655~0.714、时长 53~56 ms,全部 `repeat=-1`。这证明新会话 Engine/队列在 stop + 后成功重建,单调时间戳没有把新帧误判为过期;期间无 native/JNI 运行时异常。 + +新的 AAR native 边界已通过 `MUSIC`、后台 stop、设置关闭/恢复、正常退出、网络断流 stop +和手动重新进入功能回归;`GAME` Core 首轮已经实现但仍需真机体验,客户端自动重连也仍是 +连接层待办。30 分钟长稳由用户在 2026-07-16 明确后置,不能把短时回归代替发布稳定性结论。 + +## 0.5.0 Real-Time PLP 节奏时钟回归 + +`0.5.0 / rtplp-hg-p5-v1` 在现有 PCEN/Spectral Flux 与 AOSP +HapticGenerator 双支路之后加入固定容量、零前瞻的因果节奏时钟。时钟覆盖 +60~180 BPM;至少需要六个强瞬态才能建立锁定;预测拍必须同时存在当前弱声学证据, +并避开 50 ms 内已输出的 onset;无弱证据约 2 秒后解锁。ABI v1 结构仍为 80 字节, +仅新增向后兼容的 `AH_FRAME_RHYTHM_PREDICTED` 位。 + +Host Release 新增 72/90/120/160 BPM 锁定、120→90 BPM 变速、静音解锁、非周期 +误锁、弱拍补强和 onset 去重测试,连同 C API、ABI、DSP 与许可证检查共 5/5 通过。 +Android SDK 单测、lint、三 ABI Release AAR,以及宿主 App Debug 单测、lint 和 APK +构建通过。 + +2026-07-15 在魅族 17(Android 13 / API 33 / arm64-v8a)复跑五类负载,每类 10 秒: + +| 场景 | Mean | P95 | P99 | Max | 实时倍数 | 错误 | +|---|---:|---:|---:|---:|---:|---:| +| 静音底噪 | 103.010 us | 102.917 us | 129.114 us | 248.333 us | 48.4x | 0 | +| 持续低频 | 104.682 us | 104.844 us | 131.875 us | 261.250 us | 47.6x | 0 | +| 游戏强瞬态 | 104.239 us | 104.896 us | 132.187 us | 474.583 us | 47.8x | 0 | +| 音乐型混合 | 106.468 us | 106.771 us | 135.990 us | 365.729 us | 46.8x | 0 | +| 语音型调制 | 106.156 us | 106.511 us | 134.896 us | 389.531 us | 46.9x | 0 | + +五类 P99 均低于 500 us 门槛;新增时钟相对 `hybrid-hg-p4-v1` 的 music P99 +没有显著回退(135.000 → 135.990 us)。最终源码对应的报告位于 +`tools/audio_haptics_android_bench/out/rtplp_hg_p5_v1_final/`。CPU 门禁通过不代替真实 +串流中的主观同步、补点量与疲劳感审核。 + +## 0.5.1 节奏补拍门控优化 + +真机 `0.5.0 / rtplp-hg-p5-v1` 的系统历史显示 55.38 秒内有 51 次 MEDIA +振动,但 28/50 个事件间隔大于 800 ms,且没有出现 Android 为 PLP 补拍预设的 +32~38 ms 波形。根因不是 onset 总数下降,而是节奏特征启动瞬态污染局部相干度, +加上只允许“跨零后的 20 ms 证据窗”,导致真实音乐中补拍门控没有放行。 + +`0.5.1 / rtplp-hg-p5-v2` 做了以下修正: + +- 节奏特征增加 750 ms 预热,不影响原 onset 输出; +- 强证据累计从依赖阈值回落改为 onset/局部攻击加 60 ms 冷却; +- 建立锁定所需强事件从 6 个降为 5 个,锁定/补拍置信门槛分别降至 0.58/0.50; +- 弱声学证据阈值降至 0.08,因果支持窗扩大至 40 ms; +- 弱证据进入拍点相位窗时立即补拍,不再等待相位跨零,每周期最多一次; +- ONE_SHOT 补拍最低幅度提高到 0.70,时长提高到 36~42 ms; +- ABI 结构大小不变,`reserved[0..2]` 承载 BPM、节奏置信度和相位诊断;Android + 每 5 秒只在非音频线程记录 transient/predicted 聚合计数。 + +Host Release 5/5、Android SDK 单测/lint/三 ABI AAR、宿主单测/lint/APK 均通过。 +魅族 17 最终 ARM 门禁结果: + +| 场景 | Mean | P95 | P99 | Max | 实时倍数 | 错误 | +|---|---:|---:|---:|---:|---:|---:| +| 静音底噪 | 103.328 us | 102.969 us | 131.980 us | 1317.395 us | 48.2x | 0 | +| 持续低频 | 104.975 us | 105.000 us | 133.906 us | 1413.542 us | 47.5x | 0 | +| 游戏强瞬态 | 104.622 us | 105.000 us | 134.583 us | 1425.833 us | 47.6x | 0 | +| 音乐型混合 | 106.699 us | 106.719 us | 138.177 us | 1566.146 us | 46.7x | 0 | +| 语音型调制 | 106.377 us | 106.458 us | 137.084 us | 1532.396 us | 46.8x | 0 | + +正式报告位于 `tools/audio_haptics_android_bench/out/rtplp_hg_p5_v2/`。 + +## 0.5.2 低频优先节奏激活与可观测性 + +真实串流中 `0.5.1 / rtplp-hg-p5-v2` 的 BPM 始终为 0、置信度只有 12%~28%, +且没有 predicted 事件;体感改善主要来自当轮网络/音频时序更稳定,并非 PLP 已经补拍。 +代码审查确认节奏钟把任何全频 onset 都直接抬到至少 0.6,容易让人声、镲片和普通瞬态 +污染候选速度。 + +`0.5.2 / rtplp-lowband-p5-v3` 将职责重新拆分: + +- 新增无分配、无前视的 `RhythmActivationExtractor`,低频 PCEN flux 与 AOSP 风格 + tactile envelope 是主要证据,中高频 novelty 只作辅助; +- “推动锁拍的强证据”和“防止同一 onset 重复补拍”分成两个信号;高频 onset 仍能抑制 + 重复预测,但不能独立增加锁拍事件数; +- `CausalRhythmClock` 只负责候选速度、锁定、相位、变速与补拍门控,不再计算音频特征; +- ABI v1 结构大小保持 80 bytes,`reserved[0..5]` 增加候选 BPM、locked、activation + 和 low-frequency support;Android 每 5 秒聚合输出这些诊断; +- Android Renderer 的幅度、时长和 predicted 策略保持 `0.5.1` 不变,使下一轮体感差异 + 可归因于节奏证据与锁拍,而不是马达强度变化; +- 回归覆盖 72/90/120/160 BPM、120→90 变速、弱拍补偿、静音解锁、非周期事件、 + 低频鼓点叠加周期性镲片,以及纯高频非周期瞬态不能锁拍。 + +Host Release 5/5、Android SDK 单测/lint/三 ABI AAR、宿主单测/lint/APK 均通过。 +魅族 17 ARM 门禁结果: + +| 场景 | Mean | P95 | P99 | Max | 实时倍数 | 错误 | +|---|---:|---:|---:|---:|---:|---:| +| 静音底噪 | 103.082 us | 103.020 us | 129.740 us | 371.979 us | 48.3x | 0 | +| 持续低频 | 104.699 us | 104.896 us | 131.146 us | 328.698 us | 47.6x | 0 | +| 游戏强瞬态 | 104.250 us | 104.896 us | 131.615 us | 415.156 us | 47.8x | 0 | +| 音乐型混合 | 106.470 us | 106.667 us | 136.094 us | 397.656 us | 46.8x | 0 | +| 语音型调制 | 106.156 us | 106.510 us | 134.948 us | 394.114 us | 46.9x | 0 | + +正式报告位于 `tools/audio_haptics_android_bench/out/rtplp_lowband_p5_v3/`。CPU 门禁不 +替代真实音乐的候选 BPM、锁定耗时、补拍量、同步性和误触发审核。 + +## 0.5.3 倍频候选族与 Tactus 稳定器 + +真实串流中 `0.5.2 / rtplp-lowband-p5-v3` 的 onset 与 groove bed 已能形成较协调的 +双层触感,但约三分钟诊断里 candidate BPM 在 60~170 间跳动,`locked` 始终为 +`false`、`predicted=0`。日志中的 85↔170、70↔140 属于典型半拍/倍拍歧义;原实现 +每 50 ms 直接采用最高单个共振峰,既没有候选族聚合,也没有挑战者持有时间,而且 +置信度只看单峰,真实音乐的能量被谐波分摊后难以跨过 0.58 锁定门槛。 + +`0.5.3 / rtplp-tactus-p5-v4` 保持音频对齐、onset、groove bed、瞬态增益、时长和 +Android Renderer 完全不变,只修改节奏钟: + +- 将 BPM 与其半速/倍速共振峰组成固定容量候选族,聚合族内相干度作为 acquisition + confidence;仍无堆分配、无 FFT、无前视; +- 使用方向性的交替重音证据:双倍速子拍可以支持较慢 tactus,半速分量只弱支持较快 + tactus;因此 70/85 BPM 的强弱交替 pattern 不再被固定识别成 140/170 BPM,而等强 + 的真实 160 BPM 脉冲仍保持 160 BPM; +- 60~72 与 150~180 BPM 只施加平滑、较弱的人体 tactus 区先验,不能压过清晰的 + 单峰证据; +- 新候选必须比当前候选强 8% 并连续保持 8 次评估(约 400 ms)才可切换;半拍/倍拍 + 切换要求强 12% 并连续保持 20 次评估(约 1 s); +- 首次锁定要求候选至少稳定 12 次评估(约 600 ms),避免启动瞬态在达到事件数量 + 门槛后立即锁到错误速度;短于 2 秒的无证据缺口继续保持时钟但禁止无声补拍。 + +回归新增 70/85 BPM 强弱交替双倍速消歧和 500 ms 短缺口保持,同时保留 +72/90/120/160 BPM、120→90 变速、弱拍补偿、静音解锁、非周期事件、低频鼓点叠加 +镲片,以及纯高频瞬态不能锁拍。Host Release 5/5、Android SDK/宿主单测、lint、三 +ABI native 与 SDK-output APK 构建通过。魅族 17 ARM 门禁结果: + +| 场景 | Mean | P95 | P99 | Max | 实时倍数 | 错误 | +|---|---:|---:|---:|---:|---:|---:| +| 静音底噪 | 103.332 us | 103.177 us | 130.625 us | 1423.750 us | 48.2x | 0 | +| 持续低频 | 105.360 us | 105.521 us | 135.781 us | 1503.698 us | 47.3x | 0 | +| 游戏强瞬态 | 104.834 us | 105.365 us | 135.521 us | 1432.343 us | 47.5x | 0 | +| 音乐型混合 | 107.130 us | 107.291 us | 139.166 us | 1427.916 us | 46.5x | 0 | +| 语音型调制 | 106.851 us | 107.188 us | 139.531 us | 1693.958 us | 46.6x | 0 | + +正式报告位于 `tools/audio_haptics_android_bench/out/rtplp_tactus_p5_v4/`。下一轮真机 +准入重点是 candidate 是否收敛、何时 locked、predicted 是否出现,以及补拍是否改善 +及时性而不制造双击或错误半拍。 + +## 0.5.4 锁定保持与低置信宽限 + +`0.5.3` 的有效真机日志已证明候选族开始工作:32 个节奏诊断窗口中有 2 个窗口进入 +`locked=true`,5 个窗口产生补拍、合计 `predicted=7`;candidate 大多收敛在 60~90 +BPM,仅出现一次 140 BPM。但锁定保持时间仍短,音乐中的切分音、填充段或短时低频 +证据下降会使时钟过快解锁,后续补拍又需要重新完成 acquisition,体感表现为律动偶尔 +建立、很快消失。 + +`0.5.4 / rtplp-hold-p5-v5` 不改变 onset、groove bed、音频对齐、Android Renderer 或 +执行器参数,只增强已锁定节奏钟的保持策略: + +- 置信度上升仍使用 `0.35` 平滑系数;锁定后的下降改用 `0.08` 慢释放,降低单个弱段 + 对稳定节拍的破坏; +- 目标置信度低于 `0.30` 时立刻暂停预测补拍,但保留 tempo/phase;低置信必须连续约 + 2 秒才解锁,恢复真实 onset 后可沿原相位继续; +- 已锁定且当前 tempo 的证据暂时不足时冻结挑战者,避免短暂的 90 BPM 填充段改写 + 已建立的 120 BPM 时钟; +- 锁定后的非倍频切换要求新候选强 12% 且连续保持约 1 秒;半拍/倍拍切换要求强 20% + 且连续保持约 4 秒;持续变速仍允许跟随; +- 所有保持逻辑都位于固定容量、无堆分配、无前视的因果时钟内,不增加音频缓冲延迟。 + +回归新增:120 BPM 锁定后 500 ms 强非周期干扰不掉锁且干扰期间不补拍、恢复后仍保持 +120 BPM;同类干扰持续 3 秒必须解锁;短时 90 BPM 挑战不能改写锁定时钟;持续 +120→60 和 120→90 仍能完成跟随。Host Release 5/5、Android 宿主单测/lint 与 +SDK-output APK 构建均通过。魅族 17 ARM 门禁结果: + +| 场景 | Mean | P95 | P99 | Max | 实时倍数 | 错误 | +|---|---:|---:|---:|---:|---:|---:| +| 静音底噪 | 103.421 us | 103.750 us | 129.843 us | 387.083 us | 48.2x | 0 | +| 持续低频 | 105.321 us | 106.719 us | 133.541 us | 406.718 us | 47.3x | 0 | +| 游戏强瞬态 | 104.623 us | 105.521 us | 131.614 us | 392.552 us | 47.6x | 0 | +| 音乐型混合 | 106.916 us | 107.657 us | 133.959 us | 408.489 us | 46.6x | 0 | +| 语音型调制 | 106.603 us | 107.448 us | 134.167 us | 397.136 us | 46.7x | 0 | + +正式报告位于 `tools/audio_haptics_android_bench/out/rtplp_hold_p5_v5/`。真机体验重点从 +“能否锁定”转为“稳定段能否持续保持、切分/填充后能否快速恢复补拍,以及宽限期是否 +引入错误补拍”;诊断时同时核对 `locked`、`predicted` 与实际听感。 + +## 0.5.5 多假设节奏跟踪与休眠锁定 + +`0.5.4` 的下一次真机会话在 84 BPM 首次进入 `locked=true`,并在显示置信度降到 29% +时仍保持锁定且产生 1 次补拍,证明低置信宽限已生效;但下一次 5 秒诊断就解锁,candidate +短暂跳到 113 BPM,随后回到 83~87 BPM 却没有快速重锁。根因不只是宽限偏短:旧决策 +层只保留一个 candidate,tempo 与 phase 会随解锁一起丢失,稳定候选即使重新出现也要 +完整执行 acquisition。 + +`0.5.5 / rhythm-mht-p5-v6` 保持音频特征、onset、groove bed、执行器与 Android +Renderer 不变,重构因果节奏钟的时间序列决策层: + +- 在原有 121 个 60~180 BPM 共振器之上增加事件专用的 6 秒相位相关长窗,并将密集 + activation 证据与稀疏 evidence-event 相位似然融合;事件似然带 1 秒 freshness,不能 + 永久支撑已经过时的节拍; +- 每次评估从平滑后验中提取 8 个去重 tempo 模式,半拍、正拍和倍拍仍作为独立假设 + 联合竞争,不再由单个瞬时峰直接改写时钟; +- 使用显式因果 phase accumulator 和小幅 onset 相位校正,弱证据时相位仍按已建立 tempo + 前进,不再依赖短窗相关相位漂移; +- 状态拆分为 acquisition、active 与 coasting:低置信约 1.6 秒或 1 秒无有效声学证据 + 后进入 coasting,立即停止所有预测,但静默保留 tempo/phase 最长约 6 秒; +- coasting 中两个相位一致的 evidence event 可在 1 秒内恢复 active;错相事件不能唤醒; + 强而持续的新 tempo 仍可经过挑战者确认接管; +- 预测补拍除相位、置信度与声学支持外,还要求当前 activation 相对上一 hop 明显上升, + 持续底噪或宽带填充不能因为相位过零而产生连续“幽灵鼓点”; +- ABI v1 使用 `reserved[6]` 暴露 coasting,Android `HapticFrame.rhythmCoasting` 与 5 秒 + 诊断同步增加该字段,未扩大 `AhHapticFrame`,旧宿主可继续忽略诊断扩展。 + +回归新增:持续非周期段进入 coasting 且不补拍;恢复两个同相 onset 后 1 秒内回到 +120 BPM;三个错相 onset 不能唤醒;约 6 秒后休眠记忆必须过期。同时保留全部已知 BPM、 +70/85 BPM 强弱交替、短缺口、弱拍补偿、静音、非周期、120→90/60 变速和频带抗干扰 +回归。Host Release 5/5、Android 宿主单测/lint、三 ABI SDK 与 SDK-output APK 构建 +均通过。魅族 17 ARM 门禁结果: + +| 场景 | Mean | P95 | P99 | Max | 实时倍数 | 错误 | +|---|---:|---:|---:|---:|---:|---:| +| 静音底噪 | 104.140 us | 107.343 us | 132.813 us | 1462.552 us | 47.8x | 0 | +| 持续低频 | 105.991 us | 109.635 us | 136.041 us | 1462.656 us | 47.0x | 0 | +| 游戏强瞬态 | 105.193 us | 109.114 us | 133.750 us | 1390.260 us | 47.4x | 0 | +| 音乐型混合 | 107.612 us | 111.458 us | 138.021 us | 1415.990 us | 46.3x | 0 | +| 语音型调制 | 107.511 us | 111.771 us | 138.333 us | 1435.000 us | 46.3x | 0 | + +正式报告位于 `tools/audio_haptics_android_bench/out/rhythm_mht_p5_v6/`。真机重点观察 +`locked→coasting→locked` 是否能跨过切分与填充段、coasting 是否始终 `predicted=0`, +以及 candidate 是否避免此前的 84→113→86 短时漂移。 + +## Android MUSIC groove-bed v1 体验分支 + +在保持 SDK Core `0.5.2 / rtplp-lowband-p5-v3`、瞬态增益和瞬态时长不变的前提下, +Android 宿主将 `MUSIC` 从 transient-only 改为双层渲染: + +- `TRANSIENT` 继续负责 36~46 ms 的鼓点重音,最低幅度策略不变; +- SDK 已有的 `continuousAmplitude` 经过 `rhythmLowFrequencySupport` 门控后作为低强度 + groove bed,最大幅度限制为 0.26; +- 启动门槛为 continuous 0.12、low-frequency support 0.18,启动后分别降至 0.08、 + 0.12 形成迟滞;低频支持不足时不允许普通连续能量启动底座; +- 底座沿用 Core attack/release 和 Android Renderer 的 100 ms 更新间隔、0.08 幅度 + 迟滞,不恢复旧 BassEnergy 路径每 15 ms cancel/restart 的伪连续策略; +- `STOP`、场景切换、后台、退出和系统 HapticGenerator 接管均清空底座状态; +- 5 秒诊断增加 `grooveBed` 当前幅度,供真机确认底座是否真正开启。 + +Android 宿主单元测试、lint 与 SDK-output APK 构建通过。该分支只改变 Android 产品 +渲染策略,不改变公共 C ABI、SDK 参数版本或 HarmonyOS 行为;准入仍需验证持续感、 +鼓点清晰度、疲劳感,以及停播/退出无残留振动。 + +## Android latency-alignment v1 体验分支 + +`MUSIC groove-bed v1` 的首轮反馈是持续感明显改善,但仍能感到触觉滞后。真机日志与 +vibrator history 将问题分成两类: + +- 串流突发时音频解码队列达到 40~70 ms,并伴随密集的 network dropped audio; +- native PCM 回调先执行触觉分析,Java `AndroidAudioRenderer` 随后才按 40 ms 门槛丢弃 + 音频,因此触觉会响应并未真正交给 `AudioTrack` 的 PCM; +- groove bed 生效后,Android Renderer 仍在每次强度更新前显式 `cancel()`,真机历史中 + 约每 80~200 ms 重建一次持续波形,造成厂商相关的停启间隙和马达重复起振。 + +`latency-alignment v1` 不改变 Core DSP、参数版本、幅度或鼓点时长,只调整时序边界: + +- 把 40 ms backlog shedding 移到 native 解码回调、所有音频派生分析之前;被丢弃的 PCM + 不再进入 bass、shadow 或 SDK-output,保留的 PCM 则保证继续交给 `AudioTrack`; +- Android Renderer 更新波形时直接提交新的 `vibrate()` 请求,由系统替换当前效果;仅在 + `STOP`、生命周期结束、低于停振门槛或异常兜底时调用 `cancel()`; +- 保持 SPSC 队列、私有渲染线程、100 ms continuous 更新间隔和 0.08 幅度迟滞不变,便于 + 将主观差异归因于 PCM 对齐和马达停启,而非新的强度调参。 + +NonRoot Debug 的三 ABI native 构建、SDK Android 编译、宿主单元测试、lint 和 APK 构建 +通过;lint 仍为基线内/既有的 55 warnings、1 hint,无新增阻断错误。该修复能消除客户端 +自身制造的错位,但无法掩盖网络造成的音频丢包;复测时应同时观察触觉协调性和 +`Network dropped audio data` 的频率。 + +首个 `latency-alignment v1` 体验 APK 曾因构建命令遗漏 +`-PenableAudioHapticsOutput=true` 而回退到默认关闭的旧 bass 后端。真机证据为:没有 +`AudioHaptics rhythm` 日志,系统历史只有旧 `triggerMusicOneShot()` 特征的 42~70 ms +单点波形,且生成的 `BuildConfig.AUDIO_HAPTICS_OUTPUT=false`。该包不作为算法 A/B +结论。随后使用显式属性重建,并同时检查 BuildConfig 和 APK 内 +`libmoonlight_haptics_android.so`;正确体验包 SHA-256 为 +`1B726150473D3D32111FAC1AC5262E9BADDC9160E6FA488B04283A2DD8ED9217`。后续所有 +SDK-output 真机包必须把这两项检查作为安装前门禁,不能只依据 Gradle 构建成功。 + +## Android latency-alignment v2 路由实测 + +2026-07-16 在魅族 17 上验证 `0.5.6 / latency-alignment v2`。Core 参数集保持 +`rhythm-mht-p5-v6` 不变;本轮只验证 native IR 生产时间、共享 display-priority worker、 +`AudioTrack` presentation clock、10 ms 执行器提前量、25 ms stale deadline 和 +presentation-aware latest-wins。 + +蓝牙 A2DP 路由下,AudioFlinger 确认应用处于 FAST track,但厂商 FastMixer/Track +仍报告约 283~288 ms 输出延迟。SDK 测得 IR 相对当前可听播放头领先约 298~303 ms, +与系统报告一致。将合理调度保护窗从 100 ms 放宽至 500 ms 后: + +- presentation clock 决策持续接受,无时间轴回退; +- 生产到 `vibrate()` 调用约 292~297 ms,这是为等待蓝牙声音的主动调度; +- audio-target skew P95/P99 约 1.2~1.5 ms; +- 稳态无 stale transient、无 superseded transient,主观反馈为振动与蓝牙声音同步。 + +同一会话切换到机身扬声器后,无需重建参数或硬编码路由延迟。切换后的首个统计窗口 +混合了蓝牙已排期帧,随后 production-to-dispatch 自动收敛到约 42~45 ms, +audio-target skew P99 约 1.2~1.8 ms,clock 持续接受且稳定后无新增 stale/superseded。 +因此 500 ms 是异常保护上限而非固定等待值;实际等待始终由当前 AudioTrack timestamp +决定。其他机型仍需复测马达起振提前量,默认 10 ms 不应被解释为所有设备的最终校准值。 + +### OPPO PKJ110 第二机型验证与厂商缩放标记 + +2026-07-16 在 OPPO PKJ110(Android 16 / API 36)扬声器路由复测同一 +`0.5.6 / latency-alignment v2`。系统公开 `AMPLITUDE_CONTROL`,并支持 +`CLICK/DOUBLE_CLICK/TICK/THUD/POP/HEAVY_CLICK` 等 predefined effect;但 +supported primitives 为空,composition size、PWLE/envelope 上限均为 0。因此该机型虽然 +系统版本较新,实际仍是“幅度 waveform + predefined effect”,不能按 API level 推断为 +composition/envelope 设备。系统 `HapticGenerator.isAvailable()` 也为 false。 + +稳态数据与听感如下: + +- presentation clock 持续接受,audio-target skew P99 约 0.9~1.8 ms; +- production-to-dispatch 从约 172 ms 随厂商 AudioFlinger track latency 动态增长到 + 203~209 ms,SDK 跟随当前播放时钟而非使用固定机型延迟; +- 稳态无 stale/superseded transient;只在重连起始阶段观察到约 40~80 ms 音频 backlog + 丢弃,不计入稳态算法结论; +- 主观反馈为“同步”,但“连续振动更多”。vibrator history 显示 OPlus 对 + `USAGE_MEDIA` continuous waveform 使用 `VERY_HIGH` scale,原始幅度约 0.15/0.26 + 被播放为约 0.31/0.50,而不是 Core 检测出更多节拍。 + +`0.5.7` 不为该机型降低宿主 groove 参数,只记录已知差异。`0.5.8` 开始在 SDK Renderer +加入可关闭、可测量的 device profile;任何连续增益校准都不能进入 Core 或 Moonlight +场景业务代码,predefined effect 仍作为后续独立能力分支。 + +## 0.5.7 MUSIC authoring SDK 边界迁移 + +`AH-023` 将已通过真机体验的通用音乐创作从 Moonlight Android 宿主迁入可复用 SDK: + +- Core 新增 `MusicSceneAuthor`,负责 groove 低频门控与滞回、连续幅度上限、音乐瞬态 + gain,以及首拍/长间隔 restart 衰减;IR 新增可向后忽略的 `AH_FRAME_MUSIC_RESTART`; +- Android SDK 新增 `HapticDevicePolicy`,根据 amplitude/on-off 与 primitive/envelope + 执行器能力映射音乐瞬态幅度下限和时长;已知 OPlus continuous scaling 仍只作标记, + 本轮不引入隐式机型降 gain; +- Moonlight `AudioVibrationService` 删除上述通用曲线,只保留用户总强度、系统效果仲裁、 + 生命周期与手机/手柄路由;旧 BassEnergy 仍只作为关闭 SDK 时的互斥回滚路径; +- ABI v1 结构保持 80 bytes;Core Release 6/6、Android SDK 与宿主定向单元测试、三 ABI + native 和显式 `AUDIO_HAPTICS_OUTPUT=true` 的 NonRoot Debug APK 构建通过。 +- 体验包 SHA-256 为 + `8D8D593A6073925FF972BC65D2EB494F6F147A928641ED63629F18961236DBC6`;已覆盖安装到 + OPPO PKJ110,包进程可正常启动且无 AndroidRuntime/libc 启动异常。 + +该迁移先保证职责和参数等价,不声称引入新的体感增益。当前第一轮门禁是确认 MUSIC +鼓点、groove 连续感与 0.5.6 基线无明显回退;随后进入独立 GAME profile 和可关闭 device +profile,不再把通用触觉创作放回客户端业务层。 + +## 0.5.8 OPPO 连续层次补偿 + +`0.5.7` 在 PKJ110 真实音乐会话中保持 `stale=0 / superseded=0`,稳态 audio-target skew +大多约 0.7~1.4 ms;每 5 秒约有 9~15 个真实瞬态。体感反馈仍为连续振动层次不足,系统 +history 同时确认 OPlus 将原始 continuous `0.21` 播放为约 `0.41`,而原始 transient +`0.72` 只增至约 `0.93`,强弱比从约 3.4 压缩至约 2.3。 + +`0.5.8` 仅在 Android SDK Renderer 增加第一版显式 device profile: + +- 精确匹配 `manufacturer=OPPO / model=PKJ110`,MUSIC continuous gain 为 `0.60`; +- transient、Core DSP、节拍数量、presentation clock 和 GAME 场景保持不变; +- profile id 为 `oplus-pkj110-media-v1`,Moonlight 会话初始化日志输出该 id; +- `HapticRenderConfig.enableDeviceProfiles=false` 可恢复 neutral profile,未知设备默认 + gain 为 `1.0`,不按 Android API level 或厂商名泛化; +- Core Release 6/6、SDK profile/policy 单测、宿主定向单测、三 ABI native 和完整 APK + 构建通过;体验包 SHA-256 为 + `899E8B8C77E212173B82C684A56B621AB254944ACCA22BBF1CE540B1727D5E9A`,已安装到 PKJ110。 + +本轮真机门禁是确认底座仍能感知、鼓点突出且同步不回退;在主观确认前不把该倍率扩展到 +其他 OPlus 机型。 + +## 0.5.9 OPPO 有限 groove 尾段 + +`0.5.8` 真机 history 确认 `0.60` gain 已生效:典型 continuous 原始幅度由约 `0.21` +降至 `0.12`,OPlus 播放值由约 `0.41` 降至 `0.25`。但体感仍认为连续量偏多,根因是 +每个 transient 后的 1000 ms continuous segment 使用 `repeat=2` 无限循环,直到下一次 +effect 或 stop 才结束;密集事件间常实际持续 200~500 ms,甚至出现约 1.5 s 连续段。 + +`0.5.9 / oplus-pkj110-media-v2` 保持 `0.60` gain,同时把 PKJ110 MUSIC continuous +改为 90 ms 有限尾段: + +- transient 后的尾段和 standalone groove pulse 都使用 `repeat=-1`,到时自然结束; +- transient 幅度/时长、Core IR、节拍数量、同步调度、GAME 和其他设备完全不变; +- neutral profile 仍使用原有可循环 continuous 语义,避免未经验证地改变其他执行器; +- Core Release 6/6、Android SDK 单测、三 ABI native 和完整 APK 构建通过;体验包 + SHA-256 为 `5E1DD82E18C8534D115DC90DE5751FF19B80D8436BD8A70F00D2CAE0F7B47BE6`, + 已安装到 PKJ110。 + +真机 system history 确认请求为 `90ms / repeat=-1`,但 OPlus 将单个有限 amplitude step +优化成固定约 45 ms 的 prebaked effect;主观结果变成“全是单击、完全没有连绵振动”。 +因此 `0.5.9` 证明有限时长方向正确,但单段波形在该厂商实现上不可用。 + +## 0.5.10 OPPO 多段有限持续尾部 + +`0.5.10 / oplus-pkj110-media-v3` 不回到无限循环,而是把有限尾部编码为 4 个连续的 +60 ms amplitude step,总请求 240 ms;相邻 step 使用 `1.0 / 0.92` 的轻微调制,避免 +OPlus 把整个尾部合并成单个 45 ms 点击。按该机此前每个 step 约 45 ms 的实际映射,目标 +持续触感约 180 ms,位于 `0.5.8` 偏多与 `0.5.9` 消失之间。 + +Core、transient、同步调度、`0.60` continuous gain、GAME 与 neutral profile 均不变。 +Core Release 6/6、Android SDK 单测、三 ABI native 和完整 APK 构建通过;体验包 SHA-256 +为 `BB4DEE6654468FF97CC71794D62B24CAF45F69F7E899807F71C9929E7A49A56D`,已安装到 +PKJ110。真机 history 保留了 4 个 segment,但 OPlus 将每段分别改写为约 45 ms 的 +`effectId=69` prebaked effect;主观结果仍是连续点击,没有形成连续材质。因此只要 waveform +是有限 step,增加 segment 数量不能绕过该厂商优化。 + +## 0.5.11 OPPO 循环波形租约 + +`0.5.11 / oplus-pkj110-media-v4` 恢复该机已证明能够产生连续材质的 repeating amplitude +waveform,但不恢复无限驻留语义。Android SDK Renderer 为 PKJ110 的 MUSIC continuous +增加 `220 ms` 最大租约: + +- continuous 使用原生 repeating step,绕过有限 step 被重写为 prebaked 点击的问题; +- Renderer 在 `220 ms` 后主动 cancel;后续有效 effect 通过 generation 更新租约,旧的 + 延迟 stop 不能误停新 effect; +- `STOP`、场景切换和生命周期 stop 仍立即 cancel,并使所有未执行租约失效; +- `0.60` continuous gain、Core IR、transient、同步调度、GAME、neutral profile 与公共 ABI + 均不变,机型 workaround 仍严格位于 SDK Renderer 边界。 + +Core Release 6/6、Android SDK 单测和完整 APK 构建通过;体验包 SHA-256 为 +`C3A1E7F32D37E5DC286048AA7A402EDBD8044318D0A8F7C4474218B9BAC87F9D`,已安装到 PKJ110。 +真机验收要求 history 出现 repeating step,并在稀疏输入时约 `220 ms` 内 cancel,而不是只出现 +`effectId=69` 列表;主观目标是恢复短连绵尾部,同时不退回 `0.5.8` 的长时间常振。 + +真机验收通过:history 确认 continuous 与 transient tail 分别以 `repeat=1/2` 的 Step waveform +播放,典型效果在 SDK cancel 后由系统记录为约 241~246 ms;短于该值的效果来自后续 effect +抢占或显式 stop。独立 transient 仍允许表现为约 45 ms 点击,但连续层不再退化为点击列表。 +同一会话诊断保持 `stale=0 / superseded=0`,audio-target skew 观测约 0.4~1.6 ms;主观反馈 +确认短连绵振动已恢复。该 profile 作为 PKJ110 当前接受基线保留,后续参数优化不得跨回 Core +或 Moonlight 宿主边界。 + +## 0.5.11 生命周期租约回归 + +为防止有限租约引入“旧定时 stop 误停新 effect/session”,SDK 将 generation 竞争收口为 +worker-thread-only 的 `HapticEffectLeaseGuard`,并增加三类自动回归:当前租约只能到期一次、 +新 effect 使旧租约失效、显式生命周期 stop 使所有待执行租约失效。Core Release 6/6、Android +SDK 与 Moonlight 宿主定向单测、完整 APK 构建通过;本轮魅族 17 体验包 SHA-256 为 +`54CF43149C46B7BB3DC0E0B120AAFBAB3D37654A03321D3F0F1F39E009FB3DE8`。 + +2026-07-16 在魅族 17 的真实串流 MUSIC 会话完成首轮后台/重进矩阵: + +- 切后台 700 ms 后系统报告 `mIsVibrating=false / mCurrentVibration=null`,无残留马达; +- 该机当前会结束串流 Activity 并返回主界面,重新进入后 App 进程仍为同一 PID,但音频 session + 从 `1401` 更新为 `1409`; +- 新 session 立即恢复 `repeat=1/2` MEDIA waveform,旧 session 的队列或延迟任务没有误停新输出; +- 新 session 保持 `stale=0 / superseded=0`,audio-target skew 约 0.46~2.33 ms。 + +魅族 vibrator history 对已被 supersede/cancel 的记录仍显示 `endTime=null / status=running`,这是 +该系统 history 记账行为;验收以 controller 的 `mIsVibrating=false` 和 `mCurrentVibration=null` +为准。上述首轮之后继续补测设置、正常退出与网络断流。 + +同日继续完成设置、正常退出和网络断流首轮: + +- 将 `checkbox_audio_vibration` 从 `true` 切为 `false` 后重新进入真实串流,连续 4 秒无新增 + MEDIA history、无 current/next effect、无新 rhythm 输出;恢复为 `true` 后,新音频 session + `1425` 立即恢复 MEDIA waveform; +- 在新 `repeat=2` waveform 后通过游戏菜单“断开连接”,600 ms 内 controller、current/next + effect 均为空,随后 6 秒无新增 MEDIA history,Activity 正常返回 AppView; +- 在新 `repeat=1/2` waveform 后关闭 Wi-Fi,700 ms 时马达已为空,3 秒断流窗口无新增 + waveform;Wi-Fi 通过 `finally` 恢复到原 SSID; +- 网络恢复后底层短暂收到音频并输出一条 rhythm 诊断,但连接约 7 秒后以错误 `-1` 终止, + Moonlight 当前没有自动重连;终止过程无残振; +- 手动重新进入后,同一 App 进程创建音频 session `1441`,4 秒观察窗内 MEDIA waveform + 持续更新,`stale=0 / superseded=0`,audio-target skew 约 0.40~1.60 ms。 + +因此 SDK/Renderer 的“设置关闭、正常退出、断流 stop、新 session 干净恢复”门禁通过;“网络 +恢复后自动重连”属于 Moonlight 客户端连接层,不能由 SDK 隐式接管,作为独立客户端待办保留。 +生命周期矩阵只剩自动重连策略和 30 分钟稳定运行未通过;后者按本轮决策暂不阻塞 GAME +体验迭代,发布前仍需恢复该 gate。 + +## 0.5.12 GAME Core 首轮 + +`0.5.12 / scene-core-p4g-v2` 新增独立 `GameSceneAuthor`,不修改已经确认的 MUSIC profile: + +- GAME 瞬态只来自当前 PCM 的因果 onset,不接收 PLP predicted beat,也不会携带 + `MUSIC_RESTART`; +- 低频持续意图需连续 8 hop(约 40 ms)达到 input/support/low-band 三重门槛才启动, + 并使用独立 attack、release、迟滞和最高 `0.55` 的设备无关 IR 上限; +- impact/click 根据低频支持、分带 novelty、tactile impulse 和 onset sharpness 分层, + 输出仍使用 ABI v1 的 amplitude/duration/sharpness/confidence; +- leaky fatigue budget 只 duck 长时间 continuous,明确 transient 不减弱;强冲击后短时降低 + bed,保留事件与引擎底座的层次; +- Android Renderer 继续按设备能力选择 envelope/primitive/amplitude waveform,PKJ110 的 + MUSIC 专用 gain/220 ms lease 不会泄漏到 GAME。 + +Host Release 7/7、Android SDK 单测、Moonlight 宿主单测和 +`:app:assembleNonRootDebug -PenableAudioHapticsOutput=true` 已通过。首个待测体验包 SHA-256 为 +`325C03B46121FF3A5187196D74FC351B9937EF33C462D06CD89A017C59CE1884`。下一步用真实游戏覆盖 +爆炸/枪声/碰撞/脚步/UI click、引擎/载具、对白/风噪和断流 stop;当前阶段不把合成回归当作 +拟真度通过结论。 + +魅族 17 安装后已确认宿主设置为 `scene=0 / sensitivity=1.0 / strength=80`。首轮无法进入实际 +gameplay,改用正在串流的游戏 BGM 作为 GAME 配乐负样本:系统 vibrator history 的连续 +8.3 秒窗口记录 35 次 MEDIA repeating waveform,约 4.2 次/秒,提交幅度约 0.059~0.439; +窗口后抽样 `mIsVibrating=false / mCurrentVibration=null / mNextVibration=null`,未形成卡死或 +无限残留。该结果证明 stop/迟滞路径工作,但提交密度说明这首配乐对 GAME 低频门控仍较活跃; +是否属于可接受的节奏反馈要结合主观“连续感/干扰感”判断,不能替代 gameplay impact recall。 + +## 0.5.13 GAME 旧式风格回退 + +由于当前无法进入真实 gameplay,无法可靠校准 0.5.12 的 impact/click、8-hop continuous +准入和 fatigue 参数。`0.5.13 / game-legacy-p4g-v3` 因此只回退默认 GAME authoring:恢复 +改造前的共享低频 continuous 包络与当前因果 onset 单击,不启用实验 profile 的持续门控、 +冲击重塑和 fatigue duck。`GameSceneAuthor` 与单元测试仍保留,`MUSIC`、Renderer device +profile、时钟调度和 ABI v1 不变。 + +公共 IR 回归已增加旧式 full-scale continuous 断言;Host Release 7/7、Android SDK 单测、 +Moonlight 宿主单测及完整 APK 构建通过。体验包 SHA-256 为 +`B246A1F9338C0DFE4C246AE0CAFC56ABF008F9C94C185944B224A333ACFF5F1D`。本轮用同一游戏 BGM +与 0.5.12 的约 4.2 次/秒、0.059~0.439 幅度窗口做主观对照,不从 BGM 推导 gameplay +冲击召回结论。 + +## 0.5.14 Action-RPG GAME profile + +`0.5.14 / action-rpg-p4g-v4` 不复现旧 BassEnergy,也不沿用 0.5.13 的默认旧式接线。 +新 GAME profile 面向《原神》一类 BGM-heavy action RPG:探索/对白/配乐应尽量安静,技能、 +受击和重物理冲击应清晰分层,continuous 只保留给明确的非音调低频轰鸣。 + +- 特征层新增项目自研、固定容量的 causal median-HPSS 近似和 SuperFlux-style 振音抑制; +- MUSIC 继续消费原来的 novelty,避免本轮影响已确认的音乐体验; +- GAME 对稳定管弦/人声、纯音和已锁定节拍上的中等 BGM percussion 降权,强低频冲击绕过; +- 重冲击使用较长、较钝 transient,锐攻击使用较短、较利落 transient; +- continuous 需累计 12 hop 非音调低频证据,上限 `0.24`,4-hop 释放迟滞并带 fatigue duck; +- GAME 不使用 PLP predicted beat,也不携带 `MUSIC_RESTART`。 + +Host Release 新增 percussive feature 回归,覆盖稳态纯音的 harmonic dominance、宽带攻击的 +percussive salience 与单 hop 因果时序、振音 novelty 抑制;GAME author 回归覆盖管弦拒绝、 +稳定 BGM beat 降权、物理冲击绕过、impact/skill 层次、continuous 准入/上限/release/fatigue; +公共 IR 回归从旧式“60 Hz full-scale continuous”改为“稳定 60 Hz 纯音安静、非音调低频轰鸣 +可启动且不超过 0.24”。Host 8/8 已通过。Android AAR、宿主构建、安装和真机 BGM 数据待本节 +后续补录,不能用 BGM 负载替代实际 gameplay 的战斗召回结论。 + +## 复现 + +连接并授权 Android 设备后,在仓库根目录运行: + +```powershell +python tools/audio_haptics_android_bench/run_android_benchmark.py ` + --duration-seconds 10 ` + --device-class android_phone_performance ` + --output-dir tools/audio_haptics_android_bench/out/formal +``` + +工具自动跳过缺失 CMake toolchain 的不完整 NDK,选择最新的完整版本。正式门禁要求每个 +场景至少运行 10 秒、错误为零、P99 不超过 500 微秒。 + +## 后续顺序 + +1. ~~把 SDK Core 作为独立静态库接入相邻 `moonlight-android` 工程。~~ 已完成。 +2. ~~在 Android 解码 PCM 回调中增加默认关闭的 shadow,只记录聚合指标,不驱动振动。~~ 已完成。 +3. ~~把 SDK HapticFrame 通过 JNI 送入 Android Renderer,并确保旧 bass 输出互斥。~~ 已完成。 +4. ~~在魅族 17 上验证 `MUSIC/GAME` 马达输出与强制结束 cancel。~~ 基础验证已完成。 +5. ~~在新的 AAR native 边界上复跑 `MUSIC`,并补跑 NativeHapticsSession 真机 instrumentation。~~ 已完成。 +6. ~~按实施文档第 3.3 节收口 MUSIC authoring 边界。~~ `0.5.7` 已完成;下一步按第 6.8 节 + 实现并复跑 `GAME` continuous/stop。 +7. ~~验证后台 stop 无残留振动。~~ 已完成首轮。 +8. ~~验证 stop 后重新进入会话可恢复 `MUSIC` transient。~~ 已完成首轮。 +9. 正常退出、网络断流 stop 与手动重进已完成首轮;客户端自动重连另列连接层待办,30 分钟 + 长稳本轮后置;当前补齐 GAME 主观体验矩阵。 +10. 用 Release/等效优化构建采集真实串流精确 P50/P95/P99。 +11. ~~再选择第二类 Android 设备复跑相同门禁。~~ OPPO PKJ110 已完成音乐同步首轮, + 发现 predefined 能力未利用与厂商 continuous scaling;仍需完成游戏和生命周期矩阵。 +12. Android 闭环通过后才启动 Sunshine 接入。 diff --git a/docs/AUDIO_HAPTICS_P0_BASELINE.md b/docs/AUDIO_HAPTICS_P0_BASELINE.md new file mode 100644 index 00000000..09601b16 --- /dev/null +++ b/docs/AUDIO_HAPTICS_P0_BASELINE.md @@ -0,0 +1,133 @@ +# 音频驱动触觉 P0 基线报告 + +> 状态:P0 完成 +> 日期:2026-07-15 +> 基线提交:`a9092a5` 加当前工作区 P0 工具 +> 对应方案:`AUDIO_HAPTICS_SDK_IMPLEMENTATION_PLAN.md` 第 9、10 节 + +## 1. P0 交付结果 + +已建立可重复运行的主机评测链路: + +- Release CMake/Ninja host runner,同时编译当前 aubio 最小集和当前 `SpectralOnsetDetector`。 +- PCM16 RIFF WAV 读取、onset 事件 CSV、单文件 JSON 汇总。 +- 基于标签的 precision、recall、F1 和输出时间误差。 +- 不含初始化/WAV I/O 的 `ProcessFrame` P50/P95/P99/Max 与 realtime factor。 +- 六类确定性合成 WAV、标签和 manifest 生成器。 +- 一键构建、生成数据、逐用例执行和聚合 CSV/JSON 的脚本。 +- CTest fixture 生成与端到端 runner smoke test。 + +P0 工具仍属于当前 GPLv3 宿主仓库,因为它需要链接 aubio 基线。它不进入后续 Apache-2.0 SDK。 + +## 2. 复现方式 + +```bash +python tools/audio_haptics_eval/run_baseline.py +``` + +输出目录: + +```text +tools/audio_haptics_eval/out/ +├─ baseline.csv +├─ baseline.json +└─ / + ├─ events.csv + └─ summary.json +``` + +运行 smoke test: + +```bash +cmake -S tools/audio_haptics_eval \ + -B tools/audio_haptics_eval/build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release +cmake --build tools/audio_haptics_eval/build +ctest --test-dir tools/audio_haptics_eval/build --output-on-failure +``` + +## 3. 测试环境 + +| 项目 | 值 | +|---|---| +| OS | Windows NT 10.0.26200.0 | +| CPU | AMD Ryzen 7 7840HS,8C/16T | +| Compiler | MinGW-w64 GCC 13.2.0 | +| CMake | 3.29.2 | +| Ninja | 1.12.0 | +| Python | 3.12.10 | +| Build | Release | +| Audio | PCM16、48 kHz、mono/stereo | +| Hop | 240 samples / 5 ms | +| Benchmark | 3 warmup + 20 measured runs | +| Label match window | ±50 ms | + +主机 benchmark 只用于算法迭代回归,不能替代 HarmonyOS/Android 目标真机数据。 + +## 4. 初始数据集 + +| Case | Labels | 目的 | +|---|---:|---| +| `impulse_train_mono` | 5 | 宽带瞬态 recall 与输出时间 | +| `kick_train_stereo` | 6 | 低频冲击、重复触发和立体声输入 | +| `antiphase_impulses_stereo` | 4 | 暴露波形下混导致的反相抵消 | +| `silence_then_hit_mono` | 1 | 数字静音后的状态恢复 | +| `steady_tone_stereo` | 0 | 持续低频负样本误触发 | +| `speech_like_mono` | 0 | 语音类负样本误触发 | + +所有 fixture 由固定代码和固定随机种子生成,不提交 WAV 二进制。该集合是工程 smoke baseline,不替代上线前需要的授权真实游戏/音乐/语音数据集。 + +## 5. 首轮结果 + +### 5.1 事件质量与输出时间 + +| Case | Backend | Events | F1 | Median abs error | P95 abs error | +|---|---|---:|---:|---:|---:| +| impulse train | aubio | 5 | 1.000 | 15 ms | 15 ms | +| impulse train | native current | 2 | 0.571 | 30 ms | 30 ms | +| kick train | aubio | 12 | 0.667 | 15 ms | 15 ms | +| kick train | native current | 12 | 0.667 | 35 ms | 35 ms | +| antiphase | aubio | 0 | 0.000 | — | — | +| antiphase | native current | 0 | 0.000 | — | — | +| silence then hit | aubio | 1 | 1.000 | 15 ms | 15 ms | +| silence then hit | native current | 1 | 1.000 | 30 ms | 30 ms | +| steady tone(negative) | aubio | 29 false events | 0.000 | — | — | +| steady tone(negative) | native current | 3 false events | 0.000 | — | — | +| speech-like(negative) | aubio | 9 false events | 0.000 | — | — | +| speech-like(negative) | native current | 7 false events | 0.000 | — | — | + +说明:timestamp 记录 detector 在 hop 末尾返回 onset 的时刻,因此包含 detector 本身的前视/确认延迟。Kick burst 的余振被两个后端各自重复识别一次,导致每个标注约产生两个事件。 + +### 5.2 主机性能 + +| Backend | 跨全部 fixture 的最高 call P99 | 最低 realtime factor | +|---|---:|---:| +| aubio | 66.5 µs | 93.6x | +| native current | 37.6 µs | 179.9x | + +两者在该主机上都低于每 5 ms 音频块 0.5 ms 的 P99 预算,native current 约有明显余量。重复测量时观察到 Windows 调度造成的少数毫秒级 `call_max` 离群点,因此 P0 不用单次 max 作为算法 gate;移动端必须重新测 P99 和音频 underrun。 + +## 6. 基线结论 + +1. **评测基础设施已可用**:同一 PCM 可以稳定输出 aubio/native 事件、标签分数和调用耗时。 +2. **现有 native 不能直接替换 aubio**:普通宽带 impulse 只召回 2/5,说明当前白化/历史谱或 picker 状态存在明显问题。 +3. **25 ms 前视问题可被观测**:native current 的输出中位误差为 30~35 ms,aubio 为 15 ms;P2 必须将未来前视压到 0~1 hop。 +4. **反相抵消已经被固定用例捕获**:两个后端在 antiphase case 都为 0/4,P2 的 channel-aware magnitude/energy fusion 必须把该用例提升到 4/4。 +5. **持续声和语音必须进入产品门控**:aubio 在两个负样本上产生 38 次事件,native current 产生 10 次;仅以“接近 aubio”为目标不足以获得拟真触感。 +6. **低频 burst 会产生重复触发**:需要结合 refractory、持续声判定或 transient/body 分离,不能只靠 80 ms MinIOI。 + +## 7. P1/P2 的明确输入条件 + +后续实现不得降低以下可观测性: + +- 保持 `events.csv` 与 `summary.json` schema version,破坏性修改必须升版本。 +- 新 Core 后端必须接入同一个 runner,不能建立不可比较的另一套工具。 +- `antiphase_impulses_stereo` 目标 recall 为 1.0。 +- `impulse_train_mono` 目标 recall 不低于 aubio 的 1.0,输出 median error 目标不超过 10 ms。 +- 两个 negative case 的 false events 必须明显低于当前 aubio 基线,并在真实数据集上复验。 +- 性能结果必须同时报告 host 与至少两类目标移动设备,禁止用本报告的桌面数据代替真机 gate。 + +## 8. 下一步 + +进入 P1:建立 Apache-2.0 SDK 目录、公共 C ABI 与 `HapticFrame`,同时让新 Core 能作为第三个 backend 接入本评测器。aubio 与 `native_current` 仅保留为基线,不迁入 SDK。 diff --git a/docs/AUDIO_HAPTICS_P1_SDK_FOUNDATION.md b/docs/AUDIO_HAPTICS_P1_SDK_FOUNDATION.md new file mode 100644 index 00000000..d81a38e6 --- /dev/null +++ b/docs/AUDIO_HAPTICS_P1_SDK_FOUNDATION.md @@ -0,0 +1,142 @@ +# 音频驱动触觉 P1 SDK 基础报告 + +> 状态:P1 完成 +> 日期:2026-07-15 +> SDK 版本:0.1.0 +> ABI 版本:1 +> 许可证:Apache License 2.0 +> 迁移说明:本文记录 P1 当时的仓库内路径;SDK 已于 2026-07-17 迁移到 [独立仓库](https://github.com/AlkaidLab/moonlight-audio-haptics),当前发布基线为 `v0.5.14`。 + +## 1. P1 交付结果 + +已在仓库顶层建立独立 `audio-haptics-sdk`: + +- Apache-2.0 `LICENSE`、SPDX 文件头和第三方依赖清单。 +- 可被 C11、C++17、HarmonyOS BiSheng 和 Android NDK 使用的公共 C ABI。 +- 80-byte 固定 `AhHapticFrame` v1 中间表示。 +- Engine 创建、配置更新、输入容量计算、PCM 消费、reset 和 destroy 生命周期。 +- 初始化后处理路径零 heap allocation、无锁、无回调、无日志和无平台 API。 +- C API 行为测试、C++ ABI layout 测试和许可证边界测试。 +- P0 evaluator 的 `sdk_core_p1` 第三后端。 +- HarmonyOS `nativelib` 静态链接与 arm64-v8a/x86_64 构建验证。 + +P1 Core 故意不产生触觉事件。它只验证 SDK 边界和生命周期;共享特征、PCEN 与因果 onset 在 P2 实现。 + +## 2. 公共 ABI + +公共入口位于: + +```text +audio-haptics-sdk/include/moonlight_haptics/ +├─ audio_haptics.h +└─ version.h +``` + +v1 提供: + +```text +ah_config_init +ah_create +ah_update_config +ah_get_max_output_frames +ah_process_i16 +ah_reset +ah_destroy +ah_get_abi_version +ah_get_version_string +ah_status_string +``` + +关键决定: + +- `AhStatus`、`AhScene`、`AhFrameFlags` 均为明确的 32-bit 类型,不依赖编译器 enum 尺寸。 +- `AhHapticFrame` v1 固定 80 bytes,时间戳偏移 8,幅度区偏移 16,场景偏移 44,reserved 偏移 48。 +- IR 是数组输出,因此不能在后续版本简单追加结构字段并改变元素步长。小版本消耗 8 个 reserved 字段;更大结构必须新增 API/ABI。 +- `AhConfig` 的 sample rate/channel count 不允许运行时变化,变化返回 `AH_STATUS_RECREATE_REQUIRED`。 +- 一次 PCM 可能跨越多个 hop;调用方用 `ah_get_max_output_frames()` 预分配固定输出数组。 +- 输出空间不足时不消费 PCM,便于宿主无损重试。 + +## 3. 线程与内存模型 + +- `ah_create()` 是唯一允许的 Engine heap allocation。 +- `ah_process_i16()` 由单一音频线程调用。 +- sensitivity、gain、scene 和 feature flags 使用 lock-free 32-bit atomic 存储,可由控制线程更新。 +- `reset/destroy` 由宿主保证不与 `process` 并发。 +- Core 不创建线程,也不依赖 N-API、JNI、HarmonyOS 或 Android 头文件。 + +P2 增加 ring buffer/FFT scratch 时必须在 `ah_create()` 一次性完成固定容量分配,并保持处理路径零分配。 + +## 4. 构建接入 + +### 4.1 独立 host + +```bash +cmake -S audio-haptics-sdk \ + -B audio-haptics-sdk/build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release +cmake --build audio-haptics-sdk/build +ctest --test-dir audio-haptics-sdk/build --output-on-failure +``` + +### 4.2 P0 evaluator + +Evaluator 通过 `moonlight::haptics` 链接 SDK,并提供 `sdk_core_p1` backend: + +```bash +python tools/audio_haptics_eval/run_baseline.py --backend all +``` + +P1 backend 在所有 fixture 上应为零事件。这是预期行为,不是算法分数;P2 将在该 backend 内产生第一批可比较 IR。 + +### 4.3 HarmonyOS + +`nativelib/src/main/cpp/CMakeLists.txt` 通过 `add_subdirectory` 和静态链接消费同一 Core。生产 `BassEnergyAnalyzer` 未切换,也没有新增运行时回调。 + +验证产物: + +| ABI | Core static library | 本次 Debug 大小 | +|---|---|---:| +| arm64-v8a | `libmoonlight_haptics_core.a` | 66,732 bytes | +| x86_64 | `libmoonlight_haptics_core.a` | 65,788 bytes | + +`nativelib.har` 已成功生成;大小 12,377,581 bytes。静态库目前没有被生产代码引用,最终链接器可移除未使用对象,因此该数字不代表发布包净增量。 + +## 5. 验证结果 + +| 验证 | 结果 | +|---|---| +| SDK Release configure/build | 通过 | +| C11 API lifecycle test | 通过 | +| C++17 ABI/layout test | 通过 | +| Apache-2.0 license boundary test | 通过 | +| P0 evaluator build | 通过 | +| Evaluator fixture + end-to-end smoke | 通过 | +| 六类 fixture 的 `sdk_core_p1` backend | 通过,预期零事件 | +| HarmonyOS arm64-v8a native build | 通过 | +| HarmonyOS x86_64 native build | 通过 | +| HarmonyOS debug HAR package | 通过,24.752 s | + +Harmony 构建只有项目已有的 N-API `.d.ts` 验证警告,与 SDK Core 无关。 + +## 6. 许可证边界 + +- SDK 目录内不包含或链接 aubio/GPL 源码。 +- SDK P1 没有第三方 runtime 源码或二进制依赖。 +- CMake 测试会扫描 SDK C/C++/Python/CMake 文件的 Apache-2.0 SPDX,并拒绝 aubio include、GPL/AGPL 标记。 +- P0 evaluator 继续为 GPL,因为它同时链接 aubio 基线;其中的 adapter 只通过 SDK 公共 C ABI 调用 Core。 +- 引入 FFT、模型或 runtime 前必须更新 `THIRD_PARTY_NOTICES.md` 并通过许可证准入。 + +## 7. P2 入口条件 + +P2 在不改变 ABI v1 的前提下实现: + +1. 固定容量多声道 PCM ring buffer。 +2. channel-aware 能量融合,先让 antiphase fixture 从 0/4 提升到 4/4。 +3. 共享 Hann/STFT 特征层和预分配 scratch。 +4. PCEN/自适应归一化。 +5. 0~1 hop 因果 onset picker。 +6. transient amplitude、sharpness、confidence 和 timestamp 输出。 +7. `sdk_core_p1` backend 更名为稳定的 `sdk_core`,开始纳入 P0 质量对比。 + +P2 完成前不接管 HarmonyOS 正式振动输出。 diff --git a/docs/AUDIO_HAPTICS_P2_CAUSAL_DSP.md b/docs/AUDIO_HAPTICS_P2_CAUSAL_DSP.md new file mode 100644 index 00000000..20107962 --- /dev/null +++ b/docs/AUDIO_HAPTICS_P2_CAUSAL_DSP.md @@ -0,0 +1,124 @@ +# 音频驱动触觉 P2 因果 DSP 报告 + +> 状态:P2 完成 +> 日期:2026-07-15 +> SDK 版本:0.2.0 +> ABI 版本:1 +> 许可证:Apache License 2.0 + +## 1. 交付结论 + +P2 已在不改变公共 ABI v1、也不接管 HarmonyOS 生产振动路径的前提下,完成第一版可运行的跨平台音频触觉 DSP: + +- 自研 radix-2 FFT、Hann STFT 和预分配 scratch。 +- 每声道独立频谱、功率域融合,避免反相波形抵消。 +- 约 5 ms hop、400 ms PCEN-style 自适应、多频带正向 spectral flux。 +- 64 帧 median/MAD 鲁棒阈值、120 ms refractory 和纯因果 onset picker。 +- 输出瞬态强度、持续时间、锐度、低频比例、声像、置信度和时间戳。 +- 低频连续触感使用 attack/release 平滑和变化/停止事件抑制。 +- evaluator backend 从 `sdk_core_p1` 升级为稳定的 `sdk_core`。 +- SDK 版本由 0.1.0 升至 0.2.0,ABI 仍为 1。 + +## 2. 实时处理链路 + +```mermaid +flowchart LR + A["Interleaved PCM16"] --> B["每声道固定容量 history"] + B --> C["Hann + radix-2 FFT"] + C --> D["功率域跨声道融合"] + D --> E["PCEN-style 多频带 flux"] + E --> F["median/MAD 因果 onset"] + E --> G["低频连续包络"] + F --> H["HapticFrame v1"] + G --> H +``` + +48 kHz 下 hop 为 240 帧(5 ms),FFT 为 1024 点。初始化时一次性分配多声道历史、频谱、PCEN 状态、FFT 实部/虚部和 twiddle 表;`ah_process_i16()` 内不进行 heap allocation,不加锁,不调用日志、回调或平台 API。 + +多声道输入不先做波形相加。每个声道分别做 FFT,再平均频谱功率,因此左右声道完全反相时仍保留瞬态能量。声像则独立根据左右 hop 能量计算。 + +## 3. 因果与时间戳 + +检测器只读取当前和过去的特征帧,不等待未来峰值。事件时间戳使用产生决策的 hop 末端时间: + +```text +timestamp = input.first_sample_time_us + + processed_frames_in_this_call / sample_rate +``` + +确定性测试中四类瞬态的输出误差中位数均为 +5 ms,即 1 hop;不存在旧 native picker 的 5 帧前视。 + +## 4. 合成质量结果 + +命令: + +```bash +python tools/audio_haptics_eval/run_baseline.py --runs 30 --warmup-runs 3 +``` + +正向和边界用例: + +| 用例 | 标注 | aubio 事件/F1 | native current 事件/F1 | sdk_core 事件/F1 | sdk_core 中位误差 | +|---|---:|---:|---:|---:|---:| +| impulse train mono | 5 | 5 / 1.000 | 2 / 0.571 | 5 / 1.000 | 5 ms | +| kick train stereo | 6 | 12 / 0.667 | 12 / 0.667 | 6 / 1.000 | 5 ms | +| antiphase impulses stereo | 4 | 0 / 0.000 | 0 / 0.000 | 4 / 1.000 | 5 ms | +| silence then hit mono | 1 | 1 / 1.000 | 1 / 1.000 | 1 / 1.000 | 5 ms | + +负向用例: + +| 用例 | aubio 误事件 | native current 误事件 | sdk_core 误事件 | +|---|---:|---:|---:| +| steady 60 Hz tone | 29 | 3 | 0 | +| speech-like mono | 9 | 7 | 0 | + +这些数据说明 P2 已满足合成阶段退出条件,但不能代替真实游戏、音乐、语音和压缩伪影数据集上的产品准入。 + +## 5. Host 性能 + +30 次 Release benchmark 的 `sdk_core` 每 5 ms 调用 P99: + +| 用例 | 通道 | P99 | 实时倍数 | +|---|---:|---:|---:| +| impulse train | 1 | 85.501 us | 74.887x | +| kick train | 2 | 125.501 us | 58.408x | +| antiphase impulses | 2 | 104.102 us | 62.880x | +| silence then hit | 1 | 94.401 us | 69.384x | +| steady tone | 2 | 133.801 us | 61.136x | +| speech-like | 1 | 117.000 us | 57.043x | + +最差 P99 为 133.801 us,低于 Host 阶段每 5 ms block 的 500 us 预算。该结果来自 Windows x64 主机,不代表 HarmonyOS/Android 真机性能;P3/P4 仍需采集 ARM P99、音频 underrun 和端到端振动提交延迟。 + +## 6. 自动化验证 + +SDK 目前有四项 CTest: + +1. C11 API 生命周期、容量和错误码。 +2. C++ ABI/layout 固定性。 +3. DSP 集成:mono clicks 4/4、antiphase stereo 4/4、稳态音零瞬态、时间误差 0~1 hop、所有 IR 数值范围。 +4. Apache-2.0/SPDX 与 aubio/GPL 边界扫描。 + +算法来源和 clean-room 边界记录在独立仓库的 +[`ALGORITHM_PROVENANCE.md`](https://github.com/AlkaidLab/moonlight-audio-haptics/blob/v0.5.14/ALGORITHM_PROVENANCE.md);SDK 无第三方 DSP runtime 源码或二进制依赖。 + +HarmonyOS `nativelib` 也已完成 Debug HAR 回归,arm64-v8a 与 x86_64 均成功编译。P2 静态库分别为 630,818 bytes 和 614,858 bytes,`nativelib.har` 为 12,377,548 bytes。生产代码尚未引用 Core 符号,链接器仍可移除未使用对象;这些大小不能直接当作最终包增量。 + +## 7. 当前限制 + +- 0.82 的默认触觉瞬态输出门槛只经过确定性夹具调优;真实内容需要按 precision/recall 和主观触感重新标定。 +- 连续低频映射已输出稳定 IR,但尚未做不同设备振动能力曲线和主观 A/B。 +- `AH_SCENE_AUTO` 在 P2 暂时退化为 GAME,不包含场景分类器。 +- 暂未验证 PCM 时间戳跳变、超长流、1~8 通道全部组合和 fuzz 输入。 +- CNN 仍不进入实时 DSP;只有在 P3 证明规则基线的场景误判是主要瓶颈时再立项。 + +## 8. 下一阶段:P3 + +P3 建议保持生产输出不变,先做双路 shadow A/B: + +1. 收集有授权的真实游戏/音乐/语音片段及事件标注。 +2. 在相同 PCM 上并行记录 aubio/current 与 `sdk_core`,不重复 FFT 特征计算到生产路径。 +3. 固化漏检、误检、重复触发、时间误差、强度误差和 CPU P99 报表。 +4. 为 sensitivity、连续映射和场景 preset 建立版本化参数集。 +5. 达到删除指标后,P4 再把 HarmonyOS Renderer 接到 Haptic IR,并保留回滚开关。 + +结论:P2 技术退出条件已满足,可以进入 P3 真实数据双路验证;目前不建议直接替换线上 aubio/现有振动输出。 diff --git a/docs/AUDIO_HAPTICS_P3_DEVICE_BENCHMARK.md b/docs/AUDIO_HAPTICS_P3_DEVICE_BENCHMARK.md new file mode 100644 index 00000000..b9679255 --- /dev/null +++ b/docs/AUDIO_HAPTICS_P3_DEVICE_BENCHMARK.md @@ -0,0 +1,70 @@ +# 音频振动 P3 真机采集与准入 + +> 状态:工具链完成,等待实体设备采集 +> 日期:2026-07-15 +> SDK:0.3.0 / ABI v1 / `game-p3-v1` + +## 目标 + +本阶段验证 SDK 候选算法在 HarmonyOS 真机音频线程上的稳定性和实时性,不改变现有 +`BassEnergyAnalyzer` 的生产振动输出。设备端只输出累计计数和固定耗时直方图,不保存 +PCM、频谱或可还原内容的特征。 + +准入报告默认要求: + +- 至少两台、两类目标设备; +- 每类设备都覆盖强瞬态游戏、持续低频、音乐、语音、静音底噪、断流重连六类场景; +- 每次采集 `errors=0`,直方图计数与 block 数一致; +- 由固定直方图推导的处理耗时 P99 桶上界不超过 500 微秒; +- 所有输入都标记为真实设备数据,合成日志只能用于测试工具本身。 + +## 准备内部验证包 + +仅在内部验证构建中把 +`entry/src/main/ets/service/streaming/StreamingSession.ets` 的 +`ENABLE_AUDIO_HAPTICS_SHADOW` 改为 `true`,构建并安装 Debug HAP。生产构建必须保持 +`false`;需要硬回滚时把 CMake 的 `MOONLIGHT_AUDIO_HAPTICS_SHADOW` 设为 `OFF`。 + +设备连接后先确认: + +```powershell +& "C:\Program Files\Huawei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" list targets +``` + +## 单场景采集 + +从 `tools/audio_haptics_eval` 目录执行,开始命令后立即在设备上运行对应场景: + +```powershell +python capture_device_shadow.py ` + --output-dir out/device ` + --device-class phone_performance ` + --scenario-id game-combat-01 ` + --scenario-category game_strong_transient ` + --duration-seconds 60 +``` + +工具会自动发现单台 HDC 设备、读取型号/系统/ABI、对序列号做 SHA-256 截断哈希,并输出: + +- `shadow.hilog`:仅保留 `[HAPTICS_SHADOW]` 聚合日志; +- `metadata.json`:设备类别、场景、SDK 与参数集版本; +- `device_shadow_summary.json/.md`:单次采集指标与门禁结果。 + +若连接多台设备,增加 `--serial `。原始序列号不会写入产物。每个场景建议 +连续运行至少 60 秒;断流重连场景应包含至少三次断开与恢复。 + +## 汇总准入 + +完成两类设备的六类场景后执行: + +```powershell +python device_shadow_gate.py out/device --output-dir out/device-admission +``` + +输出 `device_shadow_admission.json/.md`。P99 根据累计直方图保守计算;单次 `maxUs` 只作 +诊断,不能代替 P99。单场景指标使用采集窗口首尾累计快照的差值,因此不会把前一个 +场景混进当前场景;新增的累计 `totalUs` 用于精确计算窗口平均耗时。若 P99 落入开放的 +`>1000` 微秒桶,会直接阻断而不是伪造上界。 + +工程门禁通过后,仍需完成内部体验审核和旧算法回滚演练,才能解除 aubio 替换项目的 +最终 BLOCKED 状态。 diff --git a/docs/AUDIO_HAPTICS_P3_DEVICE_SHADOW.md b/docs/AUDIO_HAPTICS_P3_DEVICE_SHADOW.md new file mode 100644 index 00000000..58d66de4 --- /dev/null +++ b/docs/AUDIO_HAPTICS_P3_DEVICE_SHADOW.md @@ -0,0 +1,89 @@ +# 音频驱动触觉 P3 HarmonyOS 设备侧 Shadow 报告 + +> 状态:设备侧基础设施完成,运行时默认关闭 +> 日期:2026-07-15 +> SDK:0.3.0 / ABI v1 / `game-p3-v1` + +## 1. 接入结论 + +HarmonyOS 音频解码线程现在可以把同一份解码后 PCM 同时送入: + +1. 现有 `BassEnergyAnalyzer`,继续作为唯一生产振动来源。 +2. 独立 aubio `specflux` 参考检测器,仅用于 shadow 比较。 +3. `sdk_core` 候选检测器,仅生成内存中的统计数据。 + +`sdk_core` 的 `AhHapticFrame` 不进入 `tsfn_bassEnergy`,不会调用 ArkTS 振动服务,也不会改变现有 intensity、low-frequency ratio 或 stereo balance。shadow 即使发生错误,也只增加 `processErrors`,不影响生产分析器和音频播放。 + +## 2. 双重开关 + +编译开关: + +```cmake +MOONLIGHT_AUDIO_HAPTICS_SHADOW=ON # 编译能力,当前默认 +MOONLIGHT_AUDIO_HAPTICS_SHADOW=OFF # 完全编译关闭 +``` + +运行时开关位于 `StreamingSession.ets`: + +```ts +const ENABLE_AUDIO_HAPTICS_SHADOW: boolean = false; +``` + +生产包必须保持 `false`。内部验证包将它改为 `true` 后,第四个 N-API 参数会开启 shadow: + +```ts +setBassVibrationConfig(enabled, sensitivity, sceneMode, shadowEnabled) +``` + +前三个参数的旧调用完全兼容;省略第四个参数时原生层强制使用 `false`。每次从关闭切到开启都会在音频线程重置检测器、事件队列和统计,控制线程不直接修改 DSP 状态。 + +## 3. 在线比较 + +设备侧使用固定容量 16-event 队列,以 50 ms 窗口贪心匹配 aubio 与 SDK 事件,输出以下聚合字段: + +- 输入 block/frame 数。 +- aubio 与 SDK 瞬态总数。 +- matched、aubio-only、sdk-only。 +- 待匹配事件数。 +- 匹配时间差总和与最大绝对差。 +- 处理错误数。 +- 总/最大 shadow 处理耗时。 +- `≤50/100/200/500/1000 μs` 和 `>1000 μs` 固定直方图。 + +处理路径没有日志拼接、文件 I/O 或逐帧 heap allocation。每约 1 秒由已有音频诊断窗口读取原子快照并输出两条 `[HAPTICS_SHADOW]` HiLog。 + +## 4. 隐私边界 + +设备侧不保存、不上传、不打印: + +- 原始 PCM 或频谱。 +- 游戏、音乐或语音内容。 +- 可还原内容的逐采样特征。 + +日志只包含计数、时间差和耗时桶。若后续增加 telemetry,仍只能使用这些聚合字段,并必须遵守现有隐私策略和用户授权。 + +## 5. 验证结果 + +| 验证 | 结果 | +|---|---| +| 设备侧 shadow Host 单测 | 通过 | +| 反相立体声:aubio 0 / SDK 4 | 通过,正确记录 4 个 sdk-only | +| 运行时关闭后不再消费 block | 通过 | +| 编译开关 `=0` stub | 通过 | +| evaluator 全套 CTest | 7/7 通过 | +| HarmonyOS arm64-v8a/x86_64 HAR | 通过 | +| Entry ArkTS 编译 | 通过 | +| Debug HAP 打包与签名 | 通过 | + +首次 HAP 打包因 shell 中没有 `java` 报 `spawn java ENOENT`;设置 DevEco Studio JBR 为 `JAVA_HOME` 后构建成功,确认与代码无关。 + +## 6. 真机采集步骤 + +1. 仅在内部验证分支把 `ENABLE_AUDIO_HAPTICS_SHADOW` 改为 `true`。 +2. 构建并安装 Debug HAP,确认初始化日志显示 `compiled=true ready=true`。 +3. 分别运行强冲击、持续低频、音乐、对话、静音/底噪和断流重连场景。 +4. 过滤 `[HAPTICS_SHADOW]`,保存设备型号、系统版本、场景时长和参数集版本。 +5. 检查 `errors=0`,并从直方图计算 P99;不能只使用单次 max。 +6. 测试结束立即恢复运行时常量为 `false`。需要硬回滚时再将 CMake 开关设为 `OFF`。 + +设备侧 shadow 已具备采集条件,但尚未在实体 ARM 设备执行,因此“两类目标设备 benchmark”和“内部体验审核”仍保持 BLOCKED。 diff --git a/docs/AUDIO_HAPTICS_P3_SHADOW_FOUNDATION.md b/docs/AUDIO_HAPTICS_P3_SHADOW_FOUNDATION.md new file mode 100644 index 00000000..9353ddb6 --- /dev/null +++ b/docs/AUDIO_HAPTICS_P3_SHADOW_FOUNDATION.md @@ -0,0 +1,116 @@ +# 音频驱动触觉 P3 Shadow 基础报告 + +> 状态:P3 离线与设备侧工具链完成,产品准入进行中 +> 日期:2026-07-15 +> SDK 版本:0.3.0 +> ABI 版本:1 +> 参数集:`game-p3-v1` + +## 1. 本阶段结果 + +P3 第一阶段已经建立可重复的离线 shadow A/B 和真实数据准入边界,仍不改变 HarmonyOS 生产振动输出: + +- 所有 DSP 参数集中到版本化的 `dsp_parameters.h`。 +- 公共 C ABI 增加 `ah_get_parameter_set_version()`,报表同时记录 SDK 与参数集版本。 +- 数据集 manifest 增加 rights、是否可再分发、真实/合成、关键事件、期望触觉、split 和双 SHA-256。 +- `validate_dataset.py` 校验路径越界、WAV 格式、采样率/声道/时长、标签排序/数量、rights 和文件 hash。 +- `shadow_compare.py` 对 aubio 与 `sdk_core` 做逐事件匹配,输出 matched、reference-only、candidate-only 和时间差。 +- 自动计算 aubio 删除门槛,并把“算法通过”和“可删除 aubio”拆成两个结论。 +- shadow 结果只包含聚合指标、事件时间戳和 descriptor,不复制原始 PCM。 +- HarmonyOS 解码线程已接入同源 PCM 的运行时关闭 shadow;详见 [P3 设备侧 Shadow 报告](./AUDIO_HAPTICS_P3_DEVICE_SHADOW.md)。 + +## 2. 输出产物 + +一次 `run_baseline.py --backend all` 现在会生成: + +| 文件 | 内容 | +|---|---| +| `baseline.json/csv` | 各用例、各后端标注指标与性能 | +| `/events.csv` | 原始事件级输出 | +| `shadow_events.csv` | aubio 与 sdk_core 的一对一事件差异 | +| `shadow_report.json` | 可供 CI 读取的准入 gate | +| `shadow_report.md` | 人工审核报告 | + +合成基准的当前结果: + +| 指标 | aubio | sdk_core | +|---|---:|---:| +| 全集聚合 F1 | 0.333333 | 1.000000 | +| 关键事件 recall | 0.750000 | 1.000000 | +| 两类负样本误事件 | 38 | 0 | +| 最差标注用例中位时间误差 | 15 ms | 5 ms | +| 最差标注用例 P95 时间误差 | 15 ms | 5 ms | +| 最差 Host block P99 | 67.600 us | 129.401 us | + +事件差异为 12 个 matched、44 个 aubio-only、4 个 sdk-only。4 个 sdk-only 全部来自反相立体声夹具,是 aubio 波形下混抵消造成的漏检;aubio-only 主要来自 kick 重复触发和负样本误触发。两个后端 descriptor 的量纲不同,本阶段不直接计算强度误差,后续必须用主观标注或归一化等级比较。 + +## 3. Gate 结果 + +| Gate | 当前结果 | +|---|---| +| F1 不低于 aubio 超过 0.02 | PASS | +| 关键冲击 recall 不下降 | PASS | +| 时间中位数 ≤10 ms | PASS | +| 时间 P95 ≤25 ms | PASS | +| 负样本误触发 ≤ aubio 1.1 倍 | PASS | +| Host block P99 ≤500 us | PASS | +| 存在真实世界标注数据 | BLOCKED | +| 至少两类目标真机 benchmark | BLOCKED | +| 内部体验审核通过 | BLOCKED | +| 回滚路径验证 | BLOCKED | + +因此当前机器可读结论为: + +```text +algorithm_gates_pass = true +ready_for_aubio_removal = false +``` + +合成集证明算法和工具链可进入真实验证,不构成删除 aubio 或切换生产输出的授权。 + +## 4. 真实数据接入 + +真实 WAV 默认放在仓库外;模板位于: + +```text +tools/audio_haptics_eval/datasets/manifest.template.csv +``` + +接入命令: + +```bash +python tools/audio_haptics_eval/validate_dataset.py \ + --manifest /manifest.csv \ + --require-real-world + +python tools/audio_haptics_eval/run_baseline.py \ + --fixtures-dir \ + --manifest /manifest.csv \ + --skip-generate \ + --runs 30 \ + --warmup-runs 3 +``` + +建议第一批至少覆盖:游戏强冲击、游戏持续低频、带鼓/无鼓音乐、对话、静音/底噪、反相或多声道、削波/PLC/断流。不能明确说明使用权的素材不得进入 CI 或共享目录。 + +## 5. 验证结果 + +| 验证 | 结果 | +|---|---| +| SDK C API / ABI / DSP / license CTest | 4/4 通过 | +| evaluator fixture / smoke / dataset / shadow CTest | 5/5 通过 | +| `--require-real-world` 对纯合成集的阻断测试 | 通过,预期拒绝 | +| 30 次 Host shadow benchmark | 通过 | +| HarmonyOS arm64-v8a | 构建通过,静态库 635,568 bytes | +| HarmonyOS x86_64 | 构建通过,静态库 619,456 bytes | +| HarmonyOS `nativelib.har` | 构建通过,12,377,547 bytes | + +## 6. 下一步 + +P3 后半段不再是继续堆算法,而是补齐真实证据: + +1. 准备或指定有使用权的真实数据目录并完成标签。 +2. 在内部包开启设备侧 shadow,采集至少两类 ARM 设备的耗时直方图。 +3. 跑首轮离线/真机 shadow 报告,按类别检查 false positive/negative,而不是只看总 F1。 +4. 根据误差建立 `game-p3-v2`,保持旧参数集可复现,不直接覆盖 v1。 +5. 完成盲测体验表与 runtime/compile-time 回滚演练后,才允许进入 P4 Renderer 迁移。 diff --git a/docs/AUDIO_HAPTICS_SDK_IMPLEMENTATION_PLAN.md b/docs/AUDIO_HAPTICS_SDK_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..24f22d80 --- /dev/null +++ b/docs/AUDIO_HAPTICS_SDK_IMPLEMENTATION_PLAN.md @@ -0,0 +1,1062 @@ +# 音频驱动触觉 SDK 整体实施方案 + +> 状态:实施中 +> 适用范围:Sunshine 服务端、Moonlight HarmonyOS/Android 客户端、独立 Audio-to-Haptics SDK +> 核心决策:采用“**Sunshine 编码前 PCM 检测 + Haptic IR 传输 + 客户端渲染 + 客户端本地检测回退**”的混合架构;服务端与客户端复用同一 C++17 Core,不复制算法;CNN 仅作为后续可选语义控制器。 +> 当前实施优先级:**先在 Android 真机完成本地闭环,再启动 Sunshine/Wire 实现**。Android 闭环未达到功能、生命周期、性能和回滚 gate 前,不并行扩展服务端协议。 +> 许可证决策:独立 SDK 采用 **Apache License 2.0**(2026-07-15 确认);现有 GPLv3 应用代码不进入 Apache-2.0 SDK 边界。 +> 独立仓库:SDK 已于 2026-07-17 迁移至 [AlkaidLab/moonlight-audio-haptics](https://github.com/AlkaidLab/moonlight-audio-haptics),发布基线为 [`v0.5.14`](https://github.com/AlkaidLab/moonlight-audio-haptics/releases/tag/v0.5.14),宿主按不可变 commit SHA 获取源码,不再从 Harmony 仓库内嵌目录消费。 +> 实施进度:**P0、P1、P2 已完成;P3 离线、HarmonyOS 设备侧 shadow 与 Android 真机串流 shadow 链路已跑通;P4-A 已将 `AhEngine`、native SPSC IR 队列、批量 JNI drain 和 capability-aware Renderer 收入 Apache-2.0 AAR。`0.5.6 / latency-alignment v2` 已在魅族 17 与 OPPO PKJ110 验证真实串流音乐的播放时钟对齐。`0.5.14 / action-rpg-p4g-v4` 已把 GAME 默认路径切换为面向《原神》一类 BGM-heavy action RPG 的因果 profile:使用项目自研的 trailing-median HPSS 近似、SuperFlux 振音抑制、稳定节拍降权、冲击/锐攻击分层和受限 continuous;不复现旧 BassEnergy,也不修改已确认的 MUSIC 路径。Host 8/8 回归通过,下一步安装 Android 真机,用当前游戏 BGM 验证配乐负载,再在可进入 gameplay 时补战斗召回矩阵。SDK Renderer 生命周期矩阵已完成设置、后台、退出、断流 stop 和手动新会话恢复;客户端自动重连仍属连接层待办,30 分钟长稳按本轮决策后置**(2026-07-16)。结果见 [P0 基线报告](./AUDIO_HAPTICS_P0_BASELINE.md)、[P1 SDK 基础报告](./AUDIO_HAPTICS_P1_SDK_FOUNDATION.md)、[P2 因果 DSP 报告](./AUDIO_HAPTICS_P2_CAUSAL_DSP.md)、[P3 Shadow 基础报告](./AUDIO_HAPTICS_P3_SHADOW_FOUNDATION.md)、[P3 设备侧 Shadow 报告](./AUDIO_HAPTICS_P3_DEVICE_SHADOW.md)和 [Android 真机 Benchmark](./AUDIO_HAPTICS_ANDROID_DEVICE_BENCHMARK.md)。 + +## 1. 结论与实施边界 + +本项目采用以下整体方案: + +1. 将当前 `BassEnergyAnalyzer` 拆分为“平台无关分析核心”和“平台触觉渲染器”。 +2. 使用混合因果 DSP 替代 aubio `specflux`:项目自研的多频带 PCEN/Spectral Flux 负责谱域瞬态,Apache-2.0 AOSP HapticGenerator 风格执行器包络负责压缩音乐中的可触觉攻击。 +3. 核心不直接输出 HarmonyOS 的频率、Android 的 `VibrationEffect` 或手柄马达值,而是输出稳定的 `HapticFrame` 中间表示(Haptic IR)。 +4. HarmonyOS 通过 N-API 适配器和 ArkTS Renderer 消费 IR;Android 通过 JNI/AAR 和 Kotlin Renderer 消费相同 IR。 +5. 采用 aubio/native 双路影子运行、离线标注集和真机指标完成替换,不做一次性切换。 +6. 第一阶段不引入 CNN。算法稳定、SDK 跑通后,再评估 tiny causal CNN 是否能改善场景识别和参数控制。 +7. Sunshine 在 Opus 编码前的原始 PCM 上运行同一 Core,并通过专用、带音频时间戳的 Haptic Wire 发送 IR;不传原始 PCM 或中间音频特征。 +8. 客户端 Renderer 始终保留在终端侧;服务端 IR 可用时作为权威输入,旧版 Sunshine、协商失败或 IR 超时/丢失时自动切换到解码后 PCM 的本地 Core。 +9. 任一时刻只能由“服务端 IR”或“本地回退 IR”中的一路驱动 Renderer,禁止双路叠加触发。 +10. 实施顺序以 Android 本地闭环为先:先证明同一 SDK IR 能稳定驱动真实马达并可靠 stop/回滚,再把已验证的 Core/IR 接到 Sunshine;服务端方案不作为 Android 闭环的依赖。 + +非目标: + +- 不承诺从混合音频中精确识别“枪声、爆炸、脚步”等具体游戏事件。 +- 不在 DSP 核心中调用系统振动 API、N-API、JNI 或日志 API。 +- 不让 CNN 直接逐采样生成马达波形。 +- 不追求逐项复刻 aubio 内部实现;产品体验、时延和误触发指标优先。 +- 不把平台马达波形、Android primitive 或 HarmonyOS 效果 ID 放进服务端协议。 +- 不复用现有 gamepad rumble 消息承载音频触觉;Haptic IR 使用独立能力标识和消息语义。 + +## 2. 当前基线 + +### 2.1 当前链路 + +```mermaid +flowchart LR + A["Opus 解码 PCM"] --> B["BassEnergyAnalyzer"] + B --> C["aubio specflux onset"] + B --> D["低通、包络、能量与场景规则"] + C --> E["intensity / lowFreqRatio / stereoBalance"] + D --> E + E --> F["N-API TSFN"] + F --> G["AudioVibrationService"] + G --> H["HarmonyOS 设备振动"] + G --> I["USB 手柄双马达"] +``` + +| 组件 | 当前职责 | 主要问题 | +|---|---|---| +| `bass_energy_analyzer.h` | 低频能量、包络、场景模式、onset 后处理 | DSP、策略和输出协议耦合在单个类中 | +| `aubio_onset_wrapper.h` | 使用 aubio `specflux` | 为单一 onset 功能引入较大的 GPL 依赖子集 | +| `spectral_onset_detector.h` | 自研 STFT、白化、频谱通量和峰值检测 | 尚未接入;存在 5 帧前视和边界问题 | +| `callbacks.cpp` | 解码线程分析并通过 TSFN 回调 ArkTS | 每次事件动态分配;协议只有三个整数 | +| `AudioVibrationService.ets` | 防抖、设备能力判断、设备与手柄路由 | 同时承担产品策略与 HarmonyOS API 细节,不可直接移植 Android | + +### 2.2 必须解决的问题 + +- 当前自研 detector 使用 5 个未来 hop 确认局部峰值;在 48 kHz、240 samples/hop 下增加约 25 ms 算法前视。 +- 多声道先求和再分析会使反相左右声道发生抵消。 +- 自研 detector 的静音分支和正常分支保存了不同特征域的历史频谱。 +- `hopSize` 与最大 FFT 缓冲区之间缺少完整的输入校验。 +- 当前三个整数不足以同时表达持续震感、瞬态冲击、材质锐度、空间位置和置信度。 +- HarmonyOS 层频繁 stop/start 振动可能造成触感断续、API 压力和额外延迟。 + +## 3. 目标架构 + +```mermaid +flowchart TB + PCM["PCM int16/float,多声道"] --> CORE + + subgraph CORE["moonlight-haptics-core · C++17"] + IN["Input Adapter / Ring Buffer"] --> FE["Shared Feature Extractor"] + FE --> EN["包络与多频带能量"] + FE --> ON["Causal PCEN Spectral Onset"] + IN --> HG["AOSP-style Actuator Envelope"] + FE --> SC["Scene Controller"] + EN --> MAP["Perceptual Haptic Mapper"] + ON --> MAP + HG --> ON + SC --> MAP + MAP --> IR["HapticFrame IR"] + end + + IR --> CABI["稳定 C ABI"] + CABI --> NAPI["HarmonyOS N-API Adapter"] + CABI --> JNI["Android JNI Adapter"] + + NAPI --> OHRENDER["ArkTS Haptic Renderer"] + JNI --> ANDRENDER["Kotlin Haptic Renderer"] + + OHRENDER --> OHDEV["HarmonyOS HD/Time Haptic"] + OHRENDER --> PAD1["USB 手柄 Renderer"] + ANDRENDER --> ANDDEV["Android Vibrator/VibrationEffect"] + ANDRENDER --> PAD2["Android 应用自有手柄通道"] +``` + +### 3.1 分层原则 + +- **Core 只理解声音和感知参数**:不包含平台头文件,不知道系统振动 API。 +- **IR 表达意图,不表达设备实现**:使用 `sharpness` 而不是硬编码 45 Hz,使用 `transientAmplitude` 而不是直接输出右马达数值。 +- **Renderer 负责能力降级**:宽频马达、幅度可控马达和只有开关能力的马达使用不同映射。 +- **实时线程只做有界工作**:初始化后不分配内存、不加锁、不阻塞、不写日志、不执行平台回调。 +- **算法和平台可分别演进**:更换 onset 或增加 CNN 不改变 C ABI;平台 API 变化不影响 DSP。 + +### 3.2 Sunshine 混合架构与职责拆分 + +```mermaid +flowchart LR + subgraph HOST["Sunshine 主机"] + CAP["采集 PCM · Opus 编码前"] --> SCORE["haptics-core"] + SCORE --> SIR["Haptic IR"] + SIR --> WIRE["haptics-wire"] + CAP --> OPUS["Opus Encoder"] + end + + OPUS --> AUDIO["音频流"] + WIRE --> META["带 audio PTS 的 IR 消息"] + + subgraph CLIENT["Moonlight Android / HarmonyOS"] + AUDIO --> DEC["Opus Decoder / 播放时间线"] + DEC --> LCORE["本地 haptics-core · 回退"] + META --> SELECTOR["对齐 / 去重 / 输入仲裁"] + LCORE --> SELECTOR + SELECTOR --> RENDER["平台 Renderer"] + RENDER --> ACT["设备马达 / 手柄"] + end +``` + +推荐拆分如下: + +| 模块 | 部署位置 | 职责 | 不负责 | +|---|---|---|---| +| `haptics-core` | Sunshine 与客户端共用 | PCM → 特征/事件 → Haptic IR | 网络、平台 API、日志上报 | +| `haptics-wire` | 服务端与客户端共用 | IR 序列化、版本协商、时间戳、去重/乱序规则 | 音频分析、设备映射 | +| `haptics-sunshine-adapter` | Sunshine | 从 Opus 编码前 PCM 喂入 Core,按会话发送 IR | 生成具体设备波形 | +| `haptics-local-fallback` | Moonlight 客户端 | 用解码后 PCM 调用同一 Core;在服务端 IR 不可用时接管 | 与服务端 IR 同时驱动 | +| `haptics-renderer-android` | Android 客户端 | IR → `VibrationEffect`/composition/降级脉冲 | PCM 分析、网络协议 | +| `haptics-renderer-harmony` | HarmonyOS 客户端 | IR → HD/Time Haptic/USB 手柄 | PCM 分析、网络协议 | + +Sunshine 当前音频链路在采集 PCM 后进入 Opus 编码线程,因此服务端检测应挂在编码前的 PCM 分支,不能从压缩码流恢复特征;以 Sunshine 当前实现为准,入口需在集成阶段再次核对 [Sunshine `audio.cpp` 文档](https://docs.lizardbyte.dev/projects/sunshine/master/audio_8cpp.html?lng=en-US)。现有 Sunshine 通用反馈结构主要表达手柄 rumble 等设备反馈,不具备音频时间线、感知参数与版本协商语义,不能直接替代 Haptic Wire,参见 [Sunshine `common.h` 源码文档](https://docs.lizardbyte.dev/projects/sunshine/master/common_8h_source.html)。 + +客户端会话采用三个互斥状态: + +1. `SERVER_IR`:能力协商成功,服务端 IR 连续且时间戳有效;只消费服务端 IR。 +2. `LOCAL_FALLBACK`:服务端不支持、协商失败或 IR 超时;只消费本地 Core 输出。 +3. `OFF`:用户关闭、应用失焦、会话结束或平台无可用振动能力;立即提交 stop 并清空两路状态。 + +状态切换必须在音频时间线上完成去重和短窗口抑制,避免同一 onset 在切换边界被触发两次。 + +当前迭代只实现客户端图中的 `Opus Decoder → 本地 haptics-core → Android Renderer → 手机马达`。`haptics-wire`、Sunshine Adapter 和 `SERVER_IR` 状态保留接口设计,但冻结实现,直到 Android 本地闭环通过第 10.2 节 gate。 + +### 3.3 SDK 与客户端边界审计(2026-07-16) + +这里的“SDK”包含平台无关 Core 与平台 Renderer;“客户端”指 Moonlight Android/HarmonyOS +宿主应用。最终边界按“触觉意图是否跨设备成立”判断,而不是按当前代码所在目录判断: + +| 层 | 应拥有 | 不应拥有 | +|---|---|---| +| `haptics-core` | PCM 特征、因果 onset、节奏状态、`GAME/MUSIC/AUTO` 场景语义、瞬态/连续包络与设备无关 IR | Android/Harmony API、具体机型增益、网络与 UI | +| 平台 Adapter/Renderer(AAR/Harmony SDK) | 固定队列、播放时钟与 deadline、stale/latest-wins、能力探测、predefined/primitive/envelope/waveform 降级、设备幅度/时长归一化、可靠 stop | 从 PCM 重做场景算法、产品开关、手机/手柄业务路由 | +| Moonlight 客户端 | 接入解码 PCM、选择场景/profile、用户开关与总强度、生命周期、手机/手柄路由、系统 `HapticGenerator` 仲裁、聚合遥测和旧链路回滚 | 通用 groove 曲线、onset 增益、瞬态时长、场景 attack/release 等可移植触觉创作 | + +`0.5.14 / action-rpg-p4g-v4` 启用新 GAME 默认接线后,当前实现状态如下: + +- `AhEngine`、IR 队列、JNI drain、presentation clock 调度和手机马达能力降级已在 AAR,边界正确。 +- 通用 `MUSIC` authoring 已从 Android 宿主迁入 Core 的 `MusicSceneAuthor`:groove + 启停滞回、低频支持门控、瞬态增益、首拍/长间隔 restart 衰减和连续幅度上限均生成 + 设备无关 IR;`AH_FRAME_MUSIC_RESTART` 显式携带 restart 意图。 +- Android Renderer 的 `HapticDevicePolicy` 根据 amplitude/on-off 与 primitive/envelope + 能力映射音乐瞬态下限和时长。Moonlight `AudioVibrationService` 不再重写通用音乐曲线, + 只应用用户总强度、产品仲裁和手机/手柄路由。 +- 独立 `GameSceneAuthor` 在 Core 中默认启用。GAME 消费 HPSS/SuperFlux 衍生的 + percussive/harmonic salience,并把稳定节拍上的中等事件视作 BGM 证据降权;明确的低频物理 + 冲击可绕过该惩罚。连续意图需要至少 12 个因果证据 hop、非音调低频支持和迟滞,IR 上限为 + `0.24`,长时间底震由 fatigue 预算压低但不削弱 transient。 +- Core 的 `AUTO` 当前直接落到 `GAME`,还没有实现文档第 6.6 节的分类器。 +- 旧 `BassEnergy` 保留在 GPL 宿主仅用于关闭 SDK 时回滚,不进入 Apache-2.0 SDK,也不作为 + 新算法的第二个并行输出。 + +`MUSIC` 边界迁移与新 GAME author 均有 Core/Renderer 回归。后续顺序固定为:先用游戏 BGM +验证负载和误触发,再用真实 gameplay 校准战斗召回,之后补 predefined/手柄 Renderer,最后 +实现 `AUTO`;厂商连续缩放作为可关闭 device profile 独立推进。每一步保持 IR 单路输出和真机 +A/B,不同时重写检测器、Renderer 与客户端接线。 + +## 4. SDK 独立仓库与构建产物 + +SDK 已建立为独立仓库,公共核心不再放在 HarmonyOS `nativelib` 或本仓库顶层: + +```text +moonlight-audio-haptics/ +├─ CMakeLists.txt +├─ LICENSE # Apache License 2.0 英文原文 +├─ NOTICE # 第三方归属信息;没有适用信息时可不创建 +├─ include/ +│ └─ moonlight_haptics/ +│ ├─ audio_haptics.h # 稳定 C ABI +│ ├─ haptic_wire.h # IR wire schema 与编解码 API +│ └─ version.h +├─ src/ +│ ├─ core/ +│ │ ├─ audio_haptics_engine.cpp +│ │ ├─ feature_extractor.cpp +│ │ ├─ causal_onset_detector.cpp +│ │ ├─ scene_controller.cpp +│ │ └─ haptic_mapper.cpp +│ └─ dsp/ +│ ├─ fft.cpp +│ ├─ biquad.cpp +│ ├─ pcen.cpp +│ └─ window.cpp +├─ wire/ +│ ├─ haptic_wire_codec.cpp +│ ├─ capability_negotiation.cpp +│ └─ sequence_tracker.cpp +├─ adapters/ +│ ├─ sunshine/ +│ │ └─ sunshine_haptics_adapter.cpp +│ ├─ harmony/ +│ │ ├─ haptics_napi.cpp +│ │ ├─ HapticInputSelector.ets +│ │ └─ HapticRenderer.ets +│ └─ android/ +│ ├─ src/main/cpp/haptics_jni.cpp +│ ├─ src/main/java/.../HapticInputSelector.kt +│ └─ src/main/java/.../HapticRenderer.kt +├─ tests/ +│ ├─ unit/ +│ ├─ golden/ +│ └─ fixtures/ +├─ benchmarks/ +└─ tools/ + └─ evaluator/ +``` + +构建产物: + +| 目标 | 产物 | 用途 | +|---|---|---| +| `moonlight_haptics_core` | CMake 静态库 | 被 Sunshine、HarmonyOS `.so` 和 Android JNI `.so` 链接 | +| `moonlight_haptics_wire` | CMake 静态库 | Haptic Wire 编解码、版本与序列处理;服务端和客户端共用 | +| Sunshine Adapter | 宿主集成静态库/源集 | 接入 Sunshine 编码前 PCM 与会话传输层 | +| Harmony Adapter | 当前 `moonlight_nativelib` 的一部分 | 保持现有 N-API 集成方式 | +| Android SDK | AAR,内含 JNI 库与 Kotlin API | Android 应用直接依赖 | +| Host Test | Windows/Linux/macOS 测试程序 | 离线跑 WAV、golden test 和 benchmark | + +第一版 Android 产物至少支持 `arm64-v8a`;`x86_64` 用于模拟器和 CI,可作为开发产物。Core 本身不绑定 Android minSdk,minSdk 由 Renderer 使用的 API 和宿主应用决定。 + +## 5. 公共数据协议 + +### 5.1 输入约束 + +- PCM 格式:第一版必须支持 interleaved signed 16-bit;内部统一归一化为 `[-1, 1]`。 +- 采样率:优先优化 48 kHz,同时验证 44.1 kHz。 +- 声道:1~8 声道;未知布局时至少保留左右主声道并对其余声道做能量融合。 +- 输入帧长:允许变化。Core 内部用固定容量 ring buffer 重新切为分析 hop,不能假设 Android 一定是 240 samples。 +- 时间戳:宿主可传入首样本单调时钟时间;未提供时由 Core 按已处理样本数推导。 + +### 5.2 Haptic IR + +第一版建议使用以下 ABI 结构;所有归一化值在输出前必须 clamp: + +```c +typedef uint32_t AhScene; +enum { + AH_SCENE_GAME = 0, + AH_SCENE_MUSIC = 1, + AH_SCENE_AUTO = 2, + AH_SCENE_UNKNOWN = 3 +}; + +typedef uint32_t AhFrameFlags; +enum { + AH_FRAME_NONE = 0, + AH_FRAME_CONTINUOUS_CHANGED = 1u << 0, + AH_FRAME_TRANSIENT = 1u << 1, + AH_FRAME_STOP = 1u << 2, + AH_FRAME_SCENE_CHANGED = 1u << 3 +}; + +typedef struct AhHapticFrame { + uint32_t struct_size; + uint32_t flags; + uint64_t timestamp_us; + + float continuous_amplitude; // 0..1,持续冲击/轰鸣底座 + float transient_amplitude; // 0..1,瞬态敲击强度 + float transient_duration_ms; // 建议时长,由 Renderer 按能力修正 + float sharpness; // 0..1,沉重 -> 清脆;不是物理频率 + float low_band_ratio; // 0..1,用于双马达或宽频映射 + float stereo_pan; // -1..1,左 -> 右 + float confidence; // 0..1,本帧触觉意图置信度 + + uint32_t active_scene; // AhScene;固定宽度以保持 C ABI 稳定 + uint32_t reserved[8]; // 保证 AhHapticFrame v1 固定为 80 bytes +} AhHapticFrame; +``` + +设计约束: + +- `continuous_amplitude` 和 `transient_amplitude` 必须分开,避免用一个 intensity 同时表达爆炸余震和鼓点。 +- `sharpness` 是跨设备感知维度。具体频率或 primitive 由 Renderer 决定。 +- `timestamp_us` 表示该触觉意图对应的音频时间,不表示平台 API 实际提交时间。 +- `AhHapticFrame` v1 固定为 80 bytes;小版本只能消耗 `reserved`,不得改变已有字段语义或数组步长。需要更大结构时必须新增 API/ABI 版本。 +- Core 只在状态有意义地改变、检测到 transient 或需要 stop 时设置 flags,避免每 5 ms 都穿越 N-API/JNI。 + +### 5.3 Haptic Wire v1 + +Haptic Wire 是网络传输格式,不直接 `memcpy` C ABI 结构。首版逻辑消息至少包含: + +```text +protocol_version +session_id +sequence +audio_pts_us +parameter_set_version +flags +frames[] +``` + +协议约束: + +- 能力名暂定为 `audio-haptics-ir-v1`,必须通过会话能力协商显式启用;未协商时服务端不得发送、客户端不得假定存在。 +- `audio_pts_us` 与音频包使用同一媒体时间线;客户端根据实际播放时钟调度,不按网络到达时刻直接振动。 +- Wire 只传平台无关 IR,不传 PCM、频谱、mel/PCEN 特征或具体设备波形。 +- 每条消息携带 `sequence`;客户端处理去重、有限乱序、过期丢弃与会话重建,旧会话消息不能驱动新会话。 +- 优先采用小型、无队头阻塞的消息;关键 transient/stop 可在后续消息中有限冗余。具体复用 GameStream 扩展通道还是新增数据通道,在 Sunshine 原型阶段验证后定稿。 +- 客户端持续监测消息新鲜度。超时即切换 `LOCAL_FALLBACK`,恢复时先完成时间戳连续性和去重检查,再切回 `SERVER_IR`。 +- `parameter_set_version` 必须随消息或会话配置明确传递,防止同一 IR 字段在服务端与客户端按不同参数含义解释。 + +### 5.4 C ABI + +公共边界使用 C ABI,内部继续使用 C++17: + +```c +typedef struct AhEngine AhEngine; + +typedef struct AhConfig { + uint32_t struct_size; + uint32_t sample_rate; + uint32_t channel_count; + uint32_t requested_scene; // AhScene + float sensitivity; // 0.1..3.0 + float output_gain; // 0..1 + uint32_t feature_flags; + uint32_t reserved[8]; +} AhConfig; + +typedef struct AhProcessInput { + uint32_t struct_size; + const int16_t* interleaved_pcm; + uint32_t frame_count; // 每声道样本数 + uint64_t first_sample_time_us; +} AhProcessInput; + +int32_t ah_create(const AhConfig* config, AhEngine** out_engine); +int32_t ah_update_config(AhEngine* engine, const AhConfig* config); +uint32_t ah_get_max_output_frames(const AhEngine* engine, + uint32_t input_frame_count); +int32_t ah_process_i16(AhEngine* engine, + const AhProcessInput* input, + AhHapticFrame* out_frames, + uint32_t out_capacity, + uint32_t* out_count); +void ah_reset(AhEngine* engine); +void ah_destroy(AhEngine* engine); +uint32_t ah_get_abi_version(void); +``` + +返回值需要区分:成功但无输出、产生输出、输出缓冲区不足、非法参数、未初始化和内部错误。一次输入可能跨越多个内部 hop,因此 API 使用调用方提供的固定输出数组,而不能只返回一个事件;输出容量不足时必须在消费 PCM 前返回,异常不得穿越 ABI。 + +### 5.5 线程模型 + +- 每个 `AhEngine` 的 `ah_process_i16()` 仅由一个音频解码/采集线程调用;Sunshine 为每个音频会话维护独立 Engine 状态。 +- `ah_update_config()` 不直接修改正在处理的状态;使用双缓冲配置快照,在下一个 hop 边界生效。 +- `reset/destroy` 由宿主保证不与 `process` 并发。 +- 创建阶段允许一次性分配;创建成功后,`process` 路径禁止 heap allocation。 +- Core 不主动创建线程。模型推理若后续加入,也必须保持有界、可关闭并明确调度策略。 +- Sunshine 音频捕获/编码实时线程只执行有界 Core 调用并写入固定容量队列;序列化与网络发送由非实时发送路径完成。 +- 客户端本地 Core 可持续 shadow 运行以缩短接管时间,但在 `SERVER_IR` 状态下其输出只能进入统计/去重缓存,不能进入 Renderer。 +- Renderer 调度以音频播放时钟为准;网络线程、解码线程和平台振动 API 线程之间使用固定容量队列,不互相阻塞。 + +## 6. 核心算法 + +### 6.1 共享特征提取 + +默认基线参数: + +| 参数 | 初始值 | 说明 | +|---|---:|---| +| Sample rate | 48 kHz | 串流音频主路径 | +| Analysis hop | 240 samples | 5 ms;其他输入帧长由 ring buffer 适配 | +| FFT size | 1024 | 约 21.3 ms 历史窗口,不使用未来样本 | +| Window | Hann | 预计算 | +| 最小 onset 间隔 | 80 ms | 可由 profile 调整 | +| 静音门限 | -70 dBFS 起步 | 最终由数据集校准 | + +处理步骤: + +1. 校验 PCM 指针、帧长、声道数和采样率。 +2. 分声道计算绝对能量/RMS,再进行能量域融合,禁止先相加波形后分析。 +3. 低频连续路径保留 biquad、attack/release envelope 和自适应 noise floor。 +4. STFT 只计算一次,为 onset、频带能量、场景规则和未来 CNN 共享。 +5. 频谱按触觉用途聚合为低频冲击、主体、瞬态攻击和高频纹理等逻辑频带。 +6. 对频带或谱 bin 使用 PCEN/自适应归一化,降低音量变化、压缩器和持续背景音的影响。 + +推荐 PCEN 形式: + +```text +M[t] = (1-s) * M[t-1] + s * E[t] +PCEN[t] = (E[t] / (epsilon + M[t])^alpha + delta)^r - delta^r +``` + +PCEN 参数不作为第一版公共 API 暴露,统一通过 `game/music/auto` profile 管理。 + +### 6.2 因果 onset 检测 + +```text +STFT magnitude + -> PCEN / adaptive whitening + -> log compression + -> positive spectral flux + -> multi-band weighted sum + -> causal adaptive threshold + -> refractory / silence gate + -> transient strength + sharpness +``` + +峰值检测必须满足: + +- 不再等待 5 个未来帧。使用当前 flux 相对前一帧上升、动态阈值、斜率变化或至多 1 hop 延迟完成触发。 +- 阈值由过去窗口的 median/EMA/MAD 估计,不能引用未来样本。 +- 低频 band 和中高频 band 分别计算 novelty,再根据 profile 加权。 +- onset 强度使用“当前 novelty 超过动态阈值的幅度”连续表达,而不是只有 bool。 +- 静音结束时重置或平滑恢复历史谱,避免第一下漏检或误触发。 + +### 6.3 对现有 `SpectralOnsetDetector` 的改造清单 + +| 编号 | 改造 | 完成标准 | +|---|---|---| +| OSD-01 | 将 `PICKER_WIN_POST=5` 改为因果 picker | 算法前视不超过 1 hop | +| OSD-02 | 分声道 magnitude/energy 融合 | 反相立体声测试不会丢失 onset | +| OSD-03 | 统一 `prevSpectrum` 的特征域 | 静音前后使用相同的白化/压缩状态规则 | +| OSD-04 | 校验 sample rate、channel、hop 和 FFT size | 非法输入返回错误且无越界 | +| OSD-05 | 将局部大数组移入预分配 scratch | 实时路径栈使用可控 | +| OSD-06 | 抽出共享 FFT/特征层 | onset 与场景分类不重复 FFT | +| OSD-07 | 增加 PCEN 或等效自适应归一化 | 音量变化和持续底噪下阈值稳定 | +| OSD-08 | 输出强度、sharpness、confidence | 不再只返回 onset bool | + +### 6.4 AOSP 风格执行器包络与混合判定 + +为改善“开头有一下、后续压缩鼓点漏检”的问题,P4-B 引入第二条完全因果的触觉特征支路。实现依据 Android Open Source Project 的 +[HapticGenerator](https://android.googlesource.com/platform/frameworks/av/+/master/media/libeffects/hapticgenerator/),上游和本项目适配文件均按 Apache License 2.0 保留归属;参数调优遵循 Android 的 +[音频耦合触觉设计说明](https://source.android.com/docs/core/interaction/haptics/haptics-ux-foundation)。 + +```text +每声道 PCM + -> 50 Hz HPF2 -> 9 kHz LPF2 -> 半波整流 + -> 60 Hz HPF2 -> 700/400/500 Hz LPF2 + -> 执行器谐振 BPF(默认 150 Hz,Q=1) + -> 5 Hz 慢包络 / partial AGC(指数 -0.8,offset 0.01) + -> 谐振带阻 -> 三次非线性 -> 300 Hz LPF2 -> soft limiter + -> 每 5 ms 输出最近 40 ms 因果窗的 tactile RMS / peak / mean-absolute +``` + +实现约束: + +- 每个声道独立运行滤波状态,再使用绝对峰值融合,避免反相立体声抵消。 +- 初始化后不分配、不加锁、不调用平台 API;处理只依赖当前和历史样本,没有 look-ahead。 +- 40 ms 滚动能量窗覆盖约 6 个默认 150 Hz 谐振周期,抑制 5 ms hop 内的载波相位起伏;它每 5 ms 更新且不等待未来样本。 +- AOSP 原效果直接生成 audio-rate haptic channel;SDK 只将其汇总为固定 hop 的执行器特征,并与谱域候选做 OR/fusion,公共 `AhHapticFrame` ABI 不变。 +- 普通灵敏度仍要求较强的执行器攻击;音乐灵敏度降低 tactile peak/rise/slope 门槛并缩短 refractory,以召回经压缩的连续鼓点。 +- 稳态低频不能仅凭电平持续产生 transient;候选必须同时满足相对慢包络的 rise 与相邻 hop slope,现有持续 60 Hz 负例继续作为 gate。 +- 实现来源、改动范围和许可证记录在 SDK 的 `ALGORITHM_PROVENANCE.md` 与 `THIRD_PARTY_NOTICES.md`,不链接或分发 AOSP 运行时二进制。 + +### 6.5 连续触觉路径 + +连续路径负责爆炸尾部、引擎、撞击和低频轰鸣: + +- 20~80 Hz 低频能量作为基础,但不直接线性映射到振动强度。 +- 结合全频 RMS、noise floor、attack/release 和短长时能量比抑制环境底噪。 +- 使用 soft-knee/gamma 曲线,低于感知阈值保持为零,强冲击保留动态范围。 +- 释放时间由场景控制:游戏冲击可稍长,音乐节拍应短。 +- `AH_FRAME_STOP` 必须可靠发出,避免平台维持残留振动。 + +### 6.6 场景控制 + +第一版保留 `GAME/MUSIC/AUTO`: + +- `GAME`:低频连续路径权重高,允许强冲击自然衰减。 +- `MUSIC`:onset/transient 权重高,持续底座弱,强调节奏而不持续嗡鸣。 +- `AUTO`:使用 transient density、onset interval 稳定性、低频占比和谱平坦度做低频率决策;必须有滞回和最短驻留时间。 + +自动模式的切换周期建议为 1~3 秒,连续两次同方向判定才切换。场景控制不应阻塞每个瞬态的低延迟检测。 + +### 6.7 CNN 的位置 + +CNN 作为后续可选模块,仅用于输出低频更新的控制量。混合架构下优先部署在 Sunshine,因为主机通常有更稳定的算力且可避免移动端重复推理;客户端 tiny CNN 只作为可编译关闭的本地回退增强: + +```text +log-mel/PCEN patch -> tiny causal CNN -> scene probabilities / material hint +``` + +约束: + +- 每 20~50 ms 推理一次,而不是每 5 ms hop 推理。 +- 参数量目标小于 100k,INT8 优先,模型及 runtime 可编译关闭。 +- CNN 只调节 profile、sharpness 或 confidence;瞬态时序仍由因果 DSP 决定。 +- 模型关闭、加载失败或设备性能不足时,DSP 结果必须完整可用。 +- 服务端模型输出仍转换为同一 Haptic IR;Wire 不暴露模型 tensor,消息携带可审计的 `parameter_set_version`。 +- 客户端与服务端模型版本不要求相同,但同一会话只能选择一路 IR;客户端不得把本地 CNN 结果叠加到服务端 IR。 + +### 6.8 `GAME` 场景(0.5.12 首轮已实现) + +混合游戏音频只能提供“此刻声音具有冲击/持续低频/锐利攻击”等证据,不能可靠判断它是 +玩家枪声、远处爆炸、配乐还是语音。第一版 `GAME` 因此仍是低延迟 audio-to-haptics, +不伪装成游戏事件 API;游戏遥测或 Sunshine 显式事件可在后续作为更高权重的独立输入。 + +Core 的 `GAME` profile 按以下顺序实现: + +1. **关闭节奏预测补拍**:只允许当前 PCM 的因果证据产生瞬态,避免把背景音乐节拍扩写成 + 游戏反馈;共享 onset detector 继续使用,但不使用 PLP 的 predicted beat。 +2. **分离“重冲击”和“锐攻击”意图**:低频占比、短长时能量比和执行器包络共同形成 + impact score;谱通量和 sharpness 形成 click score。两者仍写入现有 + `transientAmplitude/sharpness/lowBandRatio`,首轮不扩 C ABI。 +3. **重写持续路径**:引擎/载具/轰鸣要求低频能量持续若干 hop 才启动,使用快速 attack、 + 可控 release 和迟滞;短爆炸尾部可自然衰减,但语音、宽带风噪和配乐不能仅凭 RMS 长时间 + 保持底座。 +4. **增加疲劳与误触发控制**:自适应 noise floor、连续 duty-cycle 预算和强事件后的短恢复窗 + 共同抑制常驻震动;上限只作用于 continuous,不削掉明确 transient。 +5. **保留设备无关 IR**:Core 不选择 `THUD/CLICK`,也不写具体频率。Android Renderer 根据 + `sharpness/lowBandRatio/confidence` 与设备能力选择 predefined、primitive、envelope 或 + amplitude waveform;手柄 Renderer 独立映射低/高频马达和左右声像。 + +`0.5.12 / scene-core-p4g-v2` 已完成上述 Core 首轮并保持 ABI v1 的 80-byte frame 不变。 +自动回归覆盖低频持续准入、宽带噪声拒绝、release/stop tail、长时间 continuous duck、 +疲劳不削弱 transient、impact/click 层次,以及 GAME IR 不带 MUSIC restart/predicted flag。 +当前尚未宣称体验 gate 通过;仍需用真实游戏完成下述手机矩阵,再决定参数和 predefined 分支。 + +`0.5.13 / game-legacy-p4g-v3` 按体验决策暂不让该实验 author 驱动默认 GAME IR,恢复改造前的 +共享 continuous + 因果 onset 风格;MUSIC、Android Renderer 和 ABI 不变。公共 IR 回归额外 +要求持续 60 Hz 输入能超过实验 profile 的 `0.55` 上限,以防默认路径被无意切回。待可进入 +实际 gameplay 后,再以单项开关方式重新评估准入、impact/click 和 fatigue,而不是整包切换。 + +`0.5.14 / action-rpg-p4g-v4` 不再复现旧 BassEnergy,改为针对《原神》一类配乐占比较高、 +战斗事件稀疏但需要层次的 action RPG profile: + +1. `FeatureExtractor` 保留 MUSIC 的原始 PCEN flux,同时新增 trailing 9-frame 时间中值、 + current-frame 7-bin 频率中值和 soft mask,作为 Fitzgerald median-filter HPSS 的无前视近似; +2. 当前 PCEN 谱与上一帧 5-bin frequency maximum 比较,按 SuperFlux 思路抑制弦乐、人声振音 + 引起的伪 onset,再用 percussive mask 得到 GAME 专用 novelty; +3. `GameSceneAuthor` 只接受足够打击性/惊奇度的瞬态;现有因果 PLP 已锁定且事件稳定踩拍时, + 对中等事件降权,强低频物理冲击绕过该惩罚;GAME 仍不使用 predicted beat 补拍; +4. 重冲击输出更长、更钝的 transient,锐攻击输出更短、更利落的 transient;稳定管弦、人声 + 和纯音底座被拒绝; +5. continuous 只接受持续的非音调低频轰鸣,累计至少 12 个证据 hop,IR 上限从旧式 full-scale + 路径收紧到 `0.24`,4-hop 释放迟滞与 fatigue 预算避免频繁启停和长震疲劳; +6. 算法只使用当前/历史帧,固定缓冲在 `ah_create()` 分配,不复制论文或外部实现,不引入模型、 + runtime 或新的第三方二进制依赖。 + +论文依据为 Fitzgerald 的 +[Harmonic/Percussive Separation using Median Filtering](https://www.dafx.de/paper-archive/details/DsmIVcydPX66AuaqEmKyTQ) +与 Böck/Widmer 的 +[Maximum Filter Vibrato Suppression for Onset Detection](https://www.dafx.de/paper-archive/details.php?id=0oee-99Z88WL7pSo749gcA)。 +实现与许可边界详见 SDK 的 `ALGORITHM_PROVENANCE.md` 和 `THIRD_PARTY_NOTICES.md`。 + +首轮 A/B 固定覆盖:爆炸、枪声、碰撞、受击、脚步、UI click、引擎/载具、持续风噪、 +对白、带配乐战斗、纯过场音乐和安静场景。除主观同步/力度/协调感外,记录关键瞬态 recall、 +语音/音乐误触发率、continuous duty cycle、stop tail、audio-target skew,并分别在手机马达和 +手柄上验收。CNN 若进入后续,只能每 20~50 ms 提供 scene/material hint;瞬态时序继续由 +因果 DSP 决定。 + +## 7. 平台 Renderer + +### 7.1 HarmonyOS + +迁移后的数据流: + +```text +ah_process_i16 + -> 固定容量 SPSC event queue + -> 单次非阻塞 TSFN 唤醒 + -> ArkTS 批量 drain HapticFrame + -> HarmonyHapticRenderer +``` + +实施要求: + +- 用固定容量 SPSC queue 替代每次 callback `new CallbackData`;队列满时合并连续状态,优先保留 transient 和 stop。 +- TSFN 只负责唤醒 ArkTS,不为每个 5 ms hop 创建 JS 调用。 +- `AudioVibrationService` 拆为通用路由策略和 `HarmonyDeviceHapticRenderer`。 +- 首次缓存 `isHdHapticSupported()` 等能力;会话结束时清理缓存状态和停止效果。 +- 对支持 HD Haptic 的设备映射 amplitude/sharpness/duration;不支持时降级为短 pulse 或有限的 time vibration。 +- 避免每个强度变化都 stop/start。连续效果使用最小更新时间、滞回和合并窗口;瞬态事件可抢占或叠加由能力层决定。 +- USB 手柄继续按 `low_band_ratio` 和 `stereo_pan` 映射双马达,但映射逻辑移到独立 Renderer。 + +### 7.2 Android + +Android SDK 分两层: + +1. JNI 包装:持有 `AhEngine*`,接收 `ShortBuffer`/native PCM,返回或回调 `HapticFrame`。 +2. Kotlin Renderer:检测设备能力并选择 `VibratorManager/Vibrator`、`VibrationEffect` waveform、composition 或简单 fallback。 + +能力不是单一系统版本或严格等级;predefined effect、composition primitive、envelope 和 +幅度控制必须分别探测: + +| 能力维度 | 渲染策略 | +|---|---| +| 只有开关/时长 | 稀疏短脉冲,严格限流;不模拟连续细节 | +| 幅度控制 | waveform/amplitude 映射,平滑首尾;允许设备 profile 做幅度归一化 | +| predefined effects | 对每个 effect 调用支持查询;将明确瞬态映射到 `CLICK/THUD/HEAVY_CLICK` 等受支持效果 | +| composition primitives | 仅组合受支持 primitive;不支持时降级到 predefined 或 waveform | +| envelope/频率能力 | 查询 envelope/frequency profile 后把 sharpness 映射到可用频段 | + +Android 官方文档指出触觉效果取决于执行器和驱动,predefined、primitive 与 envelope 都有 +独立的支持查询,因此 Renderer 必须在运行时检查能力,不能只按系统版本判断: + +- [Android Haptics API](https://developer.android.com/develop/ui/views/haptics/haptics-apis) +- [自定义 Haptic Effects 与能力降级](https://developer.android.com/develop/ui/views/haptics/custom-haptic-effects) +- [Android 执行器与频率特性](https://developer.android.com/develop/ui/views/haptics/actuators) + +手柄振动不纳入第一版 Android 通用 SDK 承诺。若 Android 宿主已有 HID、SDL 或厂商通道,则实现单独的 `GamepadHapticRenderer`,消费相同 IR。 + +### 7.3 Android AAR 当前落地状态(2026-07-16) + +Android Renderer 已从 GPL 宿主的 IR 设备渲染路径抽离到 Apache-2.0 SDK: + +- 模块位于独立仓库的 `platform/android`,可独立生成 + `moonlight-haptics-android-release.aar`,开发版本坐标为 + `com.moonlight.haptics:moonlight-haptics-android:0.5.14-SNAPSHOT`。 +- AAR 提供标准 Kotlin `HapticFrame`、`AndroidHapticCapabilities`、 + `HapticRenderConfig`、`AndroidHapticRenderer` 和 `NativeHapticsSession`。 +- AAR 内的 `libmoonlight_haptics_android.so` 持有 `AhEngine`,通过公开的 + `android_adapter.h` C ABI 接收 PCM;生成的标准 `AhHapticFrame` 进入固定容量 + native SPSC 队列,再由 AAR 私有线程批量 drain 为 Kotlin `HapticFrame`。 +- PCM 临界区只执行 Core 和固定队列写入;JNI 通知发生在释放 PCM critical 后, + 单次 worker drain 最多批量取 32 帧。队列满时不阻塞音频线程,并优先保留 `STOP`。 +- native drain 与 Android Renderer 都使用 delivery epoch 和同步 worker fence; + `stop()` 返回后旧 batch 不再回调或补触发马达,`close()` 完成队列清理与 cancel。 +- `AndroidHapticRenderer.submit()` 只向预分配、固定容量 SPSC 队列复制字段; + `Vibrator`、`VibrationEffect`、限流、滞回和能力降级均在私有 + `HandlerThread` 上执行,手机马达平台调用不再阻塞音频解码线程。 +- 用户强度、手机/手柄路由、会话前后台、系统 HapticGenerator 仲裁和旧 BassEnergy 回滚 + 留在 Moonlight Android 宿主,符合产品策略归宿主的边界。`MUSIC` 的 groove 门控、瞬态 + gain 与 restart 意图已迁入 Core;能力相关最低瞬态和时长已迁入 SDK Renderer。宿主不再 + 对通用 `MUSIC` IR 二次创作。 +- Moonlight 过渡阶段通过 `audioHapticsSdkDir` 依赖固定 commit SHA 的独立 SDK + checkout;正式发布后改为版本化 Maven/AAR 依赖。本地允许并列目录自动发现, + CI 不依赖可变分支或隐式相邻路径。 +- GPL 宿主只保留薄接线:从解码回调取得 PCM、注册 AAR opaque session handle, + 并在 PCM critical 外触发 drain 通知。宿主不再声明 `AhEngine`、 + `AudioHapticsOutputFrame` 或逐帧十参数 JNI callback。 +- `enableAudioHapticsOutput` 与 `enableAudioHapticsShadow` 已解耦;output-only 构建 + 由 AAR 提供 Core,宿主无需为了输出而编译 shadow Core。 +- OPPO PKJ110(Android 16 / API 36)的系统配置虽声明 `libhapticgenerator.so` 和 + haptics output,公开 `HapticGenerator.isAvailable()` 仍返回 false;按 audio session + best-effort 创建也未出现系统 effect chain。SDK 因此只把系统 audio-coupled haptics + 作为可探测加速路径,能力不可用时继续使用同一便携 DSP + Renderer,不依赖系统均衡器 + 或厂商私有 effect。 +- 同一 OPPO 公开能力为 amplitude control + 多个 predefined effects,但没有 composition + primitive 或 envelope/PWLE;现有 `AndroidHapticCapabilities` 将其归为 amplitude-only, + 尚未单独利用 predefined effect。OPlus 服务的 `VERY_HIGH` scaling 还把连续 + `USAGE_MEDIA` waveform 的观测幅度约从 0.15/0.26 放大到 0.31/0.50。`0.5.8` 已在 + Renderer 增加精确匹配 PKJ110、可通过 `enableDeviceProfiles` 关闭的 `0.60` MUSIC + continuous 补偿;`0.5.9` 的 90 ms 单段被 OPlus 折叠成 45 ms 点击,`0.5.10` 改用 + 4×60 ms 后仍被逐段改写成 prebaked 点击。`0.5.11` 改用系统能真实连续播放的 repeating + waveform,并由 Renderer 用 generation-safe 的 220 ms 租约主动 cancel,兼顾连续材质与 + duty 上限。宿主和 Core 不含机型 gain/duty 参数,其他机型默认 neutral profile。 +- SDK 0.5.7 的 `music-core-p4g-v1` 参数集在既有混合 DSP 与节奏时钟上固化通用音乐 + authoring;这与 Android 系统 effect 是否开放无关。ABI v1 仍为 80 bytes,只新增可忽略的 + `AH_FRAME_MUSIC_RESTART` flag。 +- SDK 0.5.12 将参数集推进为 `scene-core-p4g-v2`,加入独立 GAME authoring;Android + Renderer 继续只消费 sharpness/low-band/confidence 和设备能力,不把机型参数写回 Core。 +- SDK 0.5.13 使用 `game-legacy-p4g-v3`,只回退 GAME Core 默认 authoring 接线;0.5.12 的 + 实验实现及测试保留,MUSIC authoring、Renderer device profile 和 ABI v1 均未回退。 +- SDK 0.5.14 使用 `action-rpg-p4g-v4`,默认启用 HPSS/SuperFlux 衍生 GAME authoring; + 稳定纯音不再触发 continuous,低频非音调轰鸣仍跨过公共 IR 边界,且 continuous 不超过 + `0.24`。MUSIC authoring、Renderer device profile 和 ABI v1 保持不变。 + +剩余发布边界是把开发期源码模块依赖替换成版本化 Maven/AAR 消费,并验证从 +Prefab/C ABI 头到多 ABI `.so` 的独立制品,不再依赖 `audioHapticsSdkDir` 相邻路径。 + +## 8. aubio 渐进迁移 + +### 8.1 后端接口 + +在删除 aubio 前,先定义内部接口: + +```cpp +class IOnsetDetector { +public: + virtual ~IOnsetDetector() = default; + virtual Status Configure(const DetectorConfig&) = 0; + virtual OnsetResult Process(const SpectralFeatures&) = 0; + virtual void Reset() = 0; +}; +``` + +短期后端: + +- `AubioOnsetBackend`:只用于基线和回滚。 +- `CausalOnsetBackend`:目标实现。 +- `ShadowOnsetBackend`:同一份特征/PCM 同时运行两个后端,正式输出仍由选定后端产生,差异写入离线 telemetry buffer。 + +编译/运行开关建议: + +| 开关 | 用途 | +|---|---| +| `AUDIO_HAPTICS_ENABLE_AUBIO` | 是否把 aubio 编译进内部验证包 | +| `AUDIO_HAPTICS_BACKEND=aubio` | 基线或紧急回滚 | +| `AUDIO_HAPTICS_BACKEND=native` | 目标生产路径 | +| `AUDIO_HAPTICS_BACKEND=shadow` | 双路比较,不双重触发振动 | + +### 8.2 删除条件 + +同时满足以下条件后才能删除 aubio: + +1. native 后端完成单元测试、golden test 和至少两类真机 benchmark。 +2. 标注集 F1 不低于 aubio 基线超过 0.02,且关键冲击类 recall 不下降。 +3. onset 时间误差中位数不超过 10 ms,P95 不超过 25 ms。 +4. 安静、语音和持续环境声负样本的误触发率不高于 aubio基线的 1.1 倍。 +5. 一轮内部体验测试无 P0/P1 触感问题。 +6. 已验证 runtime 切回 aubio 或版本回滚流程。 + +Sunshine 混合链路不是删除 aubio 的前置条件:客户端本地回退 Core 先通过上述门槛即可移除 aubio。服务端接入后必须复用已经验收的 native Core 和参数集,不重新引入 aubio。 + +完成后: + +- 从 `bass_energy_analyzer.h` 移除 `aubio_onset_wrapper.h`。 +- 从 CMake 移除 `aubio_static`、include path 和链接项。 +- 删除不再使用的 aubio 源码目录和生成配置。 +- 更新 `OPEN_SOURCE_LICENSES.md`、SBOM 和发布说明。 + +## 9. 验证体系 + +### 9.1 数据集 + +建立版本化、可重复的内部音频集,每类至少包含不同音量、动态范围压缩和声道布局: + +| 类别 | 代表内容 | 关注点 | +|---|---|---| +| 游戏强瞬态 | 枪声、爆炸、碰撞、重击 | recall、时序、冲击强度 | +| 游戏持续声 | 引擎、载具、风、低频 ambience | 持续触觉稳定性、不过振 | +| 音乐 | 鼓点、电子乐、摇滚、古典、无鼓音乐 | 节拍 onset、重复触发 | +| 语音 | 对话、直播、播报 | 误触发率 | +| 安静/底噪 | 空场景、编解码噪声、静音重连 | stop、噪声门、状态恢复 | +| 声道极端 | 单声道、反相立体声、5.1/7.1 | 声道融合与空间输出 | +| 异常输入 | 削波、PLC、帧长变化、断流重启 | 稳定性和无残留振动 | + +标注至少包含:onset timestamp、事件重要度、期望触觉类型(continuous/transient/none)和可选 sharpness 等级。数据集不得包含无法用于 CI/分发的受限音频;必要时保存特征或使用自制/授权素材。 + +### 9.2 自动指标 + +| 维度 | 首版验收目标 | +|---|---:| +| 算法未来前视 | 0 hop,最多允许 1 hop | +| Native onset 相对标注 F1 | 不低于 aubio 基线 0.02 以上差距 | +| Onset timing median | ≤ 10 ms | +| Onset timing P95 | ≤ 25 ms | +| `ah_process_i16` P99 | ≤ 0.5 ms/5 ms 音频块,目标中档真机 | +| Core 平均 CPU | ≤ 单核 3%,以目标真机实测为准 | +| Engine 固定状态内存 | ≤ 128 KiB,模型关闭时 | +| 实时路径 heap allocation | 0 | +| PCM 到 IR P95 | ≤ 10 ms | +| PCM 到平台提交 P95 | ≤ 20 ms | +| 负样本误触发 | 不高于 aubio 基线 1.1 倍 | +| 30 分钟稳定运行 | 无崩溃、无队列无限增长、无残留振动 | +| 服务端/客户端同源 PCM IR 差异 | onset P95 ≤ 1 hop;连续强度 MAE ≤ 0.05 | +| 服务端原始 PCM / 客户端解码 PCM onset 差异 | P95 ≤ 25 ms,并形成按音频类别的差异报告 | +| Haptic Wire 额外码率 | 目标 ≤ 16 kbit/s/会话,最终按真实 IR 密度校准 | +| IR 到播放时间线的调度误差 | P95 ≤ 25 ms | +| 1%/5%/10% 丢包与 0/10/30 ms 抖动 | 无卡死、无残留、无重复风暴;可自动回退 | +| 服务端/本地切换 | 同一 onset 不重复驱动;过期 IR 不进入 Renderer | + +以上是产品 gate,不是当前已达到的数据。当前 Android 真机 SDK-only `-O2` 采样平均约 0.4 ms,但 P99 仍只确认落在 `≤1 ms` 桶,尚未证明 `≤0.5 ms` gate;必须以 Release、细粒度分位统计复测。测试报告必须同时记录设备型号、系统版本、构建类型、采样率、声道数、场景参数、温控状态和网络损伤配置。 + +### 9.3 测试层次 + +- **DSP 单元测试**:FFT、Hann、biquad、PCEN、flux、threshold、reset、非法输入。 +- **合成信号测试**:impulse、sine burst、chirp、noise、反相立体声、静音后首击。 +- **Golden test**:固定 WAV 输入生成 HapticFrame 序列,比较容差内的 timestamp 和数值。 +- **Differential test**:aubio/native 对同一 PCM 的 onset 差异报告。 +- **跨部署 Differential test**:同一 PCM 分别在 Sunshine 与客户端 Core 运行;原始 PCM 与 Opus 解码 PCM 也分别对比 IR 差异。 +- **Wire conformance test**:版本协商、大小端/长度校验、未知字段、乱序、重复、过期、会话重建与 fuzz。 +- **网络损伤测试**:注入丢包、抖动、突发丢包和重连,验证按 PTS 调度、有限冗余、自动回退与无双触发。 +- **Fuzz/边界测试**:0 帧、超大帧、随机声道数、随机 PCM、反复 init/reset。 +- **Benchmark**:Debug/Release 分开,采集平均、P95、P99、最大耗时和分配次数。 +- **真机 renderer 测试**:能力探测、降级、前后台、来电/焦点切换、连接中断和关闭开关。 +- **主观体验测试**:盲测 native/aubio/off,分别评价同步性、拟真度、疲劳感和误触发。 + +### 9.4 延迟打点 + +客户端本地路径统一使用单调时钟记录: + +```text +t0 = PCM 解码完成 +t1 = Core 产生 HapticFrame +t2 = 跨 N-API/JNI 到达 Renderer +t3 = 平台振动 API 提交 +``` + +至少输出 `t1-t0`、`t2-t1`、`t3-t2` 和总 `t3-t0` 的直方图。音频线程只写固定容量统计结构,日志由非实时线程定期读取。 + +服务端路径额外记录: + +```text +s0 = Sunshine 捕获 PCM 的 audio PTS +s1 = 服务端 Core 产生 HapticFrame +s2 = IR 消息发送 +c0 = IR 消息接收 +c1 = IR 按音频播放时钟进入 Renderer +c2 = 平台振动 API 提交 +``` + +跨设备不能直接用墙钟相减;端到端以共同的 audio PTS 为基准,分别报告服务端处理、网络到达裕量、客户端排队和平台提交耗时。若 IR 到达时已超过其播放 deadline,则丢弃过期 transient,并按状态机评估是否进入本地回退。 + +## 10. 分阶段实施计划 + +工作量按 1 名熟悉 C++、实时音频与移动端的工程师估算,不包含产品大规模听感调参时间。Sunshine 传输扩展的工作量需在原型确认可复用通道后重新校准。 + +| 阶段 | 工作量 | 主要交付物 | 退出条件 | +|---|---:|---|---| +| P0 基线与测试骨架 | 2~3 人日 | WAV runner、aubio 基线、初始数据集、benchmark | 当前实现可重复测量 | +| P1 SDK 骨架与 IR | 3~5 人日 | 独立目录、Apache-2.0 文件、C ABI、HapticFrame、核心生命周期 | Host/Harmony 编译通过,API 与许可证检查通过 | +| P2 共享特征与因果 onset(已完成) | 5~8 人日 | channel-aware STFT、PCEN、causal picker、强度输出 | 合成测试和边界测试通过,前视 ≤1 hop | +| P3 双路 A/B 与调参(进行中) | 5~7 人日 | shadow backend、差异报告、真实音乐/游戏标注集 | Android 串流 shadow 已跑通;Release P99、Music/Auto 参数和主观体验达标 | +| P4-A Android 本地闭环(进行中) | 4~7 人日 | native IR 桥接、Android Renderer、旧 bass 互斥、构建/运行开关、真机报告 | 串流音乐/游戏可由 SDK IR 驱动手机马达;stop、重连、切后台、性能和回滚 gate 通过 | +| P4-G SDK 边界收口与 GAME profile(下一阶段) | 5~8 人日 | MUSIC 宿主试验逻辑迁移、真实 GAME profile、predefined effect 能力分支、游戏 A/B 矩阵 | 宿主不再创作通用 IR;游戏关键冲击、误触发、持续疲劳和 stop tail 达标 | +| P4-H HarmonyOS Renderer(后置) | 3~5 人日 | N-API IR、固定队列、能力降级 | Android 闭环通过;Harmony 真机可用后再进入准入 | +| P5 Sunshine 编码前 Shadow(冻结至 P4-A 完成) | 4~7 人日 | 编码前 PCM 接入、每会话 Engine、IR 日志/统计但不传输 | 不影响音频编码与串流;与客户端解码后结果形成差异报告 | +| P6 Haptic Wire v1 | 6~10 人日 | schema、编解码、能力协商、序列/PTS、Sunshine/Moonlight 只读接入 | 客户端可接收并对齐记录,但不驱动马达;旧版两端兼容 | +| P7 混合链路验证与启用 | 7~12 人日 | 网络损伤测试、互斥状态机、服务端权威 IR、客户端自动回退 | 时延/丢包/重连/无双触发 gate 通过,Android 真机灰度可回滚 | +| P8 移除 aubio 与 SDK 发布 | 2~4 人日 | CMake/源码/许可证清理、Core/Wire/AAR 版本与文档 | 生产默认 native,Apache-2.0 发布检查通过 | +| P9 可选服务端 tiny CNN | 10~15 人日 | 数据、模型、量化、可选 runtime | 明确优于规则基线且不破坏服务端多会话实时 gate | + +不含 CNN 的新增混合链路目标周期初估约 7~11 周。当前不并行启动 P5:先完成 P4-A,形成稳定的 Core、IR、Renderer 和验收数据;随后 P5 只做 Sunshine 本地 shadow,P6 只读传输,P7 才允许服务端 IR 驱动马达,避免算法、协议和触觉体验同时切换。 + +### 10.1 推荐 issue 拆分 + +| Issue | 内容 | 依赖 | +|---|---|---| +| AH-001 | 建立 host WAV runner 与 benchmark | 无 | +| AH-002 | 固化 aubio 基线输出与标注格式 | AH-001 | +| AH-003 | 定义 C ABI、HapticFrame 和版本策略 | 无 | +| AH-004 | 将 FFT/窗口/ring buffer 抽为 shared feature extractor | AH-003 | +| AH-005 | 修复多声道反相抵消与布局策略 | AH-004 | +| AH-006 | 实现 PCEN/自适应归一化 | AH-004 | +| AH-007 | 实现因果 onset picker | AH-004、AH-006 | +| AH-008 | 输出连续/瞬态/sharpness/confidence | AH-005、AH-007 | +| AH-009 | 实现 aubio/native/shadow backend | AH-002、AH-008 | +| AH-010 | Harmony 固定队列与 N-API IR 适配 | AH-003 | +| AH-011 | 拆分 Harmony Router/Renderer | AH-010 | +| AH-012 | Android JNI 与 AAR 构建 | AH-003 | +| AH-013 | Android capability-aware Renderer | AH-012 | +| AH-014 | 真机性能、延迟和主观测试 | AH-009、AH-011、AH-013 | +| AH-015 | 删除 aubio 并清理许可证 | AH-014 | +| AH-016 | Sunshine Opus 编码前 PCM 接入与每会话 Core shadow | AH-003、AH-008 | +| AH-017 | 定义 Haptic Wire v1 schema、codec 与 conformance test | AH-003 | +| AH-018 | Sunshine/Moonlight 能力协商与传输通道原型 | AH-016、AH-017 | +| AH-019 | Android/Harmony PTS 对齐、去重与互斥输入状态机 | AH-010、AH-012、AH-017 | +| AH-020 | 服务端原始 PCM / 客户端解码 PCM Differential test | AH-016、AH-019 | +| AH-021 | 丢包、抖动、重连和 stale IR 网络损伤测试 | AH-018、AH-019 | +| AH-022 | 服务端权威 IR 灰度、遥测与自动回退 | AH-020、AH-021 | +| AH-023 | ~~将 Android 宿主 MUSIC authoring 分拆到 Core/Renderer 并建立等价 golden test~~(0.5.7 已完成) | AH-013、AH-014 | +| AH-024 | Android 分别探测 predefined/primitive/envelope,加入可关闭 device profile | AH-013、AH-023 | +| AH-025 | 实现 Core GAME profile 与手机/手柄游戏体验矩阵 | AH-023、AH-024 | +| AH-026 | 实现带滞回与最短驻留时间的 AUTO scene controller | AH-025 | + +### 10.2 Android 本地闭环 gate + +Android 闭环的固定链路为: + +```text +Opus 解码 PCM + → GPL 宿主薄 C ABI 接线 + → AAR libmoonlight_haptics_android.so / ah_process_i16 + → AAR 固定容量 native SPSC HapticFrame 队列 + → 释放 JNI critical PCM 后通知 NativeHapticsSession + → AAR 私有线程批量 drain 为 typed HapticFrame + → AudioVibrationService 产品策略 / AndroidHapticRenderer + → Vibrator/VibrationEffect 或明确的能力降级 +``` + +必须同时满足: + +- 构建开关关闭时不链接 SDK、不改变原有 bass 输出;开启 SDK output 时旧 bass 输出自动关闭,绝不双路驱动。 +- `TRANSIENT`、`CONTINUOUS_CHANGED`、`STOP` 三类 IR 均在真机观察到正确行为;持续效果不会因无更新永久残留。 +- 音频振动设置关闭、退出会话、切后台、断流和重连均会 cancel 马达并清理 listener/Engine。 +- `GAME/MUSIC/AUTO` 场景能同步到 SDK;音乐验证必须使用 `MUSIC` 或 `AUTO`,不能用 `GAME` 数据代替结论。 +- Moonlight 宿主只选择场景和总强度,不再保留通用的 scene gain、瞬态时长或 groove 曲线; + 相同 Core 输入在 Sunshine、Android 与 Harmony 产生等价 IR。 +- `GAME` 至少通过第 6.8 节矩阵:关键冲击不明显漏检,语音/配乐不形成常驻振动, + continuous duty cycle 与 stop tail 可量化且不造成明显疲劳。 +- 默认关闭与 SDK-output 两种 APK 都能完成 native、Kotlin、R8 和打包;output 包可回滚到默认路径。 +- Release/等效优化构建满足第 9.2 节性能 gate,并至少完成 30 分钟串流稳定性验证。 +- 形成一次可复现真机报告,记录设备、系统、构建开关、SDK/参数版本、场景、IR 计数、P95/P99、触感问题和回滚结果。 + +## 11. 发布、灰度与回滚 + +### 11.1 HarmonyOS + +1. 内部包默认 `shadow`,但只有 aubio 驱动振动。 +2. 指标达标后内部包切换为 native,保留 aubio runtime 回滚。 +3. 正式包默认 native;一个稳定版本内保留 aubio 编译选项。 +4. 下一稳定版本删除 aubio 代码和构建项。 + +影子模式不得上传原始 PCM。若需要 telemetry,只保存聚合计数、延迟直方图和不含内容的差异指标;遵循现有隐私策略。 + +### 11.2 Android SDK + +- 首版标记 `0.x`,C ABI 固定但 Kotlin API 允许小范围演进。 +- AAR 提供 `isSupported()`、能力查询和无振动 fallback;不可因设备无马达而抛出致命异常。 +- 版本采用 SemVer;破坏 C ABI 必须提升 major,IR 新字段通过 `struct_size/reserved` 扩展。 +- 提供最小示例:PCM 输入、配置切换、生命周期、前后台和 Renderer 关闭。 + +### 11.3 Sunshine 与混合链路 + +1. Sunshine 首先只运行编码前 Core shadow,只记录聚合耗时和 IR 差异,不发送网络消息。 +2. Haptic Wire 上线后,客户端只接收、校验和记录对齐结果,仍由本地路径驱动。 +3. 指标通过后,对内部 Android 真机启用 `SERVER_IR`;HarmonyOS 在具备真机后进入同一准入流程。 +4. 灰度期始终保留会话级开关、服务端能力关闭和客户端 `LOCAL_FALLBACK`;旧版 Sunshine/Moonlight 按“未协商”正常播放音频。 +5. 遥测只包含版本、计数、时延桶、丢包/乱序、回退原因和差异指标,不上传 PCM、可逆音频特征或用户内容。 + +### 11.4 回滚条件 + +出现任一情况立即回滚到上一后端或关闭音频振动: + +- 音频线程 P99 超过预算并造成播放 underrun。 +- 断流、重连或切后台后存在无法停止的振动。 +- 特定设备出现高频 stop/start、崩溃或系统 API 异常风暴。 +- 关键游戏冲击漏检或负样本持续误触发明显高于基线。 +- IR 丢包/乱序导致重复触发、过期触发、残留振动或频繁在两路间振荡。 +- Haptic Wire 引入音频/视频串流退化、明显额外带宽或兼容性故障。 + +混合链路的首选回滚顺序为:`SERVER_IR` → `LOCAL_FALLBACK` → `OFF`;无需回滚音视频串流本身。协议故障时服务端停止声明能力,客户端在超时窗口后自动接管。 + +## 12. 风险与控制 + +| 风险 | 影响 | 控制措施 | +|---|---|---| +| clean-room 代码权利不明确 | 无法以宽松许可证发布 SDK | 保存作者、提交和算法参考记录;发布前完成权利确认 | +| 当前仓库 GPL 代码直接搬入新 SDK | SDK 许可证目标落空 | 不复制 aubio 实现;逐文件确认原创性与可重许可权利 | +| 不同设备马达能力差异巨大 | 同一 IR 触感不一致 | capability profile、设备分级、保守 fallback、真机矩阵 | +| 因果 picker 降低精度 | 漏检或提前误触发 | shadow A/B、斜率与动态阈值、1-hop 可选确认 | +| PCEN 参数不稳定 | 音量或底噪场景误触发 | profile 化、golden set、参数不开放给业务层 | +| N-API/JNI 回调过密 | GC、调度延迟、队列堆积 | 固定队列、状态合并、事件优先级和丢弃策略 | +| Android composition 不支持 | 无效果或体验突变 | 运行时 capability 检查,逐级降级 | +| CNN runtime 增大体积/耗电 | SDK 不再轻量 | 默认关闭、独立 feature、量化和明确收益 gate | +| IR 网络丢包、抖动或晚到 | 触觉错拍、重复或缺失 | audio PTS 调度、序列去重、过期丢弃、有限冗余和本地回退 | +| 服务端与客户端版本/参数不一致 | 同一字段触感含义漂移 | 显式 capability、Wire 版本、`parameter_set_version` 与保守拒绝策略 | +| 服务端与本地同时驱动 | 强度叠加、同一 onset 双触发 | 互斥状态机、切换窗口去重、单一 Renderer 输入端口 | +| Opus 前后 PCM 差异 | 服务端与现有客户端体验偏移 | 原始/解码 PCM Differential test,按内容类型校准但保持同一 Core | +| Sunshine 多会话 CPU 放大 | 主机编码抖动或串流退化 | 每会话固定预算、预分配、Release benchmark、过载时优先关闭触觉分析 | +| 自定义传输扩展兼容性不足 | 只能配套特定版本使用 | 能力协商、旧端无损忽略、协议 conformance test、独立关闭开关 | + +## 13. 许可证决策(已确认) + +### 13.1 最终选择 + +独立 `moonlight-audio-haptics` 仓库统一采用 **Apache License 2.0**。选择范围包括: + +- C++ Core、公共 C ABI 头文件和 host 工具。 +- Haptic Wire schema、codec、测试工具与版本协商公共实现。 +- SDK 自带的 Sunshine、HarmonyOS、Android 适配器。 +- Kotlin/ArkTS 包装、示例和 SDK 文档。 +- 后续由项目原创并随 SDK 分发的模型结构和权重;训练数据按各自授权单独管理。 + +宿主应用仍可保持 GPL-3.0。Apache-2.0 代码可以被纳入 GPLv3 组合项目,但组合发行物仍需满足 GPLv3;反方向不能把现有 GPLv3 代码直接并入并作为 Apache-2.0 SDK 发布。该兼容关系可参考 [Apache Software Foundation 的 GPL 兼容说明](https://www.apache.org/licenses/GPL-compatibility) 和 [GNU 许可证列表](https://www.gnu.org/licenses/license-list.html#apache2)。 + +### 13.2 许可证边界 + +```text +Apache-2.0 GPL-3.0 +┌──────────────────────────┐ ┌────────────────────────────┐ +│ moonlight-audio-haptics │ │ moonlight-harmonyos app │ +│ Core / C ABI / Wire │<─────│ Sunshine / Moonlight 宿主 │ +│ Sunshine/移动端 adapters │ │ 宿主集成与业务代码 │ +│ tests / examples │ │ 现有 aubio(迁移期) │ +└──────────────────────────┘ └────────────────────────────┘ +``` + +- 依赖方向为 Sunshine/Moonlight 等 GPLv3 宿主调用 Apache-2.0 SDK;宿主内少量接线代码保持宿主许可证。 +- SDK 不 include、复制或链接 aubio/GPL 源码;aubio 只存在于迁移期宿主基线后端。 +- 当前 `spectral_onset_detector.h` 只有在作者和贡献权利确认后才能迁入;否则按公开论文和测试向量在 SDK 内重新实现,并保留 clean-room 记录。 +- GPL 宿主中的 N-API 调用胶水可以继续 GPL;计划作为 SDK 通用适配器发布的部分必须独立实现并使用 Apache-2.0 文件头。 + +### 13.3 文件与发布要求 + +SDK 独立仓库必须持续满足: + +1. 根目录 `LICENSE` 放置未修改的 Apache License 2.0 英文原文。 +2. 源文件使用 `SPDX-License-Identifier: Apache-2.0`,并保留正确的版权声明。 +3. 如第三方组件要求归属信息,生成并随源码包、AAR 和二进制包分发 `NOTICE`。 +4. 建立 `THIRD_PARTY_NOTICES.md` 或等效清单,记录名称、版本、来源、许可证和是否进入发布二进制。 +5. AAR/POM metadata、包说明和生成的源码包明确标注 `Apache-2.0`。 +6. CI 增加许可证扫描;未识别文件、缺少 SPDX 或禁用许可证依赖直接阻断发布。 + +Apache 官方的应用指引要求在新发行包顶层包含完整许可证文本;如存在适用的归属信息,还需要正确保留 NOTICE。以 [Apache License 2.0 应用指引](https://www.apache.org/legal/apply-license) 为准。 + +### 13.4 第三方依赖准入 + +默认允许进入 SDK Core/发布二进制的许可证: + +- Apache-2.0 +- MIT +- BSD-2-Clause / BSD-3-Clause +- ISC +- Zlib +- CC0(仅在来源和适用对象清晰时) + +GPL、AGPL 及其他强 copyleft 依赖禁止进入 SDK。LGPL、MPL、EPL、专有免费库和带非商业/研究用途限制的代码默认不准入,确有必要时必须单独完成法律和发布方式审查。模型权重与训练数据同样执行该规则,不能只审查推理代码。 + +### 13.5 贡献策略 + +- SDK 采用 inbound=outbound:贡献默认按 Apache-2.0 提交。 +- 内部迁移代码必须记录原文件、作者、提交范围和权利确认结果。 +- 外部贡献启用 DCO sign-off;若未来涉及公司级大额贡献或专利风险,再引入 CLA。 +- 技术上的 clean-room 声明不等同于具备重许可权;有疑问的历史代码不迁移,重新实现。 + +## 14. Definition of Done + +整体方案完成必须同时满足: + +- [ ] HarmonyOS 与 Android 使用同一 Core 和同一 Haptic IR。 +- [ ] Sunshine 与客户端链接同一 Core/参数集;Sunshine 适配器从 Opus 编码前 PCM 取样且不阻塞编码路径。 +- [ ] Haptic Wire v1 的 schema、能力协商、PTS、序列、版本兼容和 conformance test 完整。 +- [ ] Core 初始化后实时路径零堆分配、无锁、无平台 API。 +- [ ] 自研 detector 无超过 1 hop 的算法前视。 +- [ ] 反相立体声、静音首击、变帧长和断流重启测试通过。 +- [ ] 自动指标达到第 9.2 节 gate,并附可复现报告。 +- [ ] HarmonyOS 至少两台不同能力设备完成真机验证。 +- [ ] Android 至少一台幅度可控设备和一台降级设备完成验证。 +- [ ] Renderer 能正确 stop,前后台和连接重建无残留振动。 +- [ ] `SERVER_IR`、`LOCAL_FALLBACK`、`OFF` 互斥切换;丢包、抖动、重连和旧端兼容测试无双触发。 +- [ ] Android 真机完成服务端 IR 与本地回退 A/B;HarmonyOS 真机 gate 在设备可用后补齐,未补齐前不得宣称 Harmony 生产准入。 +- [ ] aubio 已从生产构建、源码包和许可证清单移除。 +- [ ] C ABI、Kotlin/ArkTS API、示例、调参说明和迁移说明齐全。 +- [ ] SDK 的 Apache-2.0 `LICENSE`、SPDX、NOTICE/第三方清单和发布 metadata 完整。 +- [ ] 所有迁移文件完成权利确认,所有第三方依赖通过宽松许可证准入审计。 + +## 15. 当前推荐的第一批动作 + +按 Android-first 决策,下一批动作如下: + +1. ~~**安装 SDK-output APK 并验证真实马达基础闭环**:用 `MUSIC` 串流音乐观察 transient,再用 `GAME` 验证 continuous/stop。~~ 已完成首轮。 +2. ~~**收口 Android SDK native 边界**:把 Engine、IR 队列和批量 JNI drain 移入 AAR,删除宿主重复帧结构与十参数回调。~~ 已完成。 +3. ~~**加入成熟开源 DSP 参照**:将 Apache-2.0 AOSP HapticGenerator 风格执行器包络移植为平台无关、无前视特征支路,与 PCEN/Spectral Flux 混合判定。~~ Host Release 与魅族 17 ARM 性能 gate 已完成。 +4. ~~**完成混合 DSP 真机 A/B**:比较 `hybrid-hg-p4-v1` 的鼓点召回、同步性、误触发和疲劳感。~~ 已完成多轮真实串流体验;结论是 onset 召回明显改善,但仅跟随鼓点仍缺少稳定律动,因此进入节奏时钟试验。 +5. ~~**体验 Real-Time PLP 节奏时钟**:`0.5.5 / rhythm-mht-p5-v6` 将单 candidate 决策升级为 8 模式平滑后验,融合 6 秒 evidence-event 相位长窗和显式因果 phase accumulator,并引入最长约 6 秒、完全静默的 coasting。~~ 休眠恢复、错相拒绝、记忆过期和既有节奏回归已通过;真实音乐反馈从“容易漏拍”改善到“基本不漏”,无鼓点段不强制补拍属于预期行为。 +6. **完成生命周期矩阵**:魅族 17 已通过切后台/重进、设置关闭/恢复、正常断开、网络断流 stop 与手动重进。后台或断流 700 ms 后 controller 为非振动且 current effect 为空;设置关闭的 4 秒窗口无 MEDIA/rhythm 输出;正常断开后 6 秒无新增 effect;同一进程的新音频 session `1409/1425/1441` 均可恢复 MEDIA waveform,旧 session 的延迟任务未误停新输出。Wi-Fi 恢复后当前客户端连接约 7 秒以 `-1` 终止,没有自动重连;这是 Moonlight 连接层待办,不进入 SDK。2026-07-16 用户决定当前迭代跳过 30 分钟长稳,它仍是发布前 gate,但不阻塞 GAME 首轮。 +7. ~~**完成 SDK Core Release 性能准入**:输出精确 P50/P95/P99,达到 `ah_process_i16 P99 ≤ 0.5 ms`。~~ `rhythm-mht-p5-v6` 五类 ARM 负载最高 P99 为 138.333 us,已通过;真实 App 调度延迟仍需单独采集。 +8. **固化 Android Renderer 策略**:`MUSIC groove-bed v1` 已确认持续感明显改善;`0.5.6 / latency-alignment v2` 使用 `AudioTrack` presentation timestamp、10 ms 执行器提前量、25 ms stale deadline 和 presentation-aware latest-wins。魅族 17 的蓝牙/扬声器与 OPPO PKJ110 扬声器均主观同步,稳态 audio-target P99 约 1~2 ms。`0.5.8` 对 PKJ110 continuous 放大加入可关闭 profile;`0.5.9` 单段和 `0.5.10` 多段有限尾部均被厂商改写为 prebaked 点击;`0.5.11` 改用真正 repeating waveform,并由 Renderer 以 generation-safe 的 220 ms 租约限制最长持续时间。PKJ110 history 已确认 `repeat=1/2` 连续波形与约 241~246 ms 的系统侧停止,主观短连绵感通过。Core 与宿主参数不变;Renderer 生命周期首轮通过。客户端自动重连仍为连接层待办;30 分钟长稳按本轮决策后置。 +9. **实现 GAME profile 与设备分支**:`0.5.14 / action-rpg-p4g-v4` 已完成 action RPG + 因果 GAME author、HPSS/SuperFlux 衍生特征和 Host 8/8 回归;不复现旧 BassEnergy。 + 当前安装 Android 真机并用游戏 BGM 验证配乐负载;可用 gameplay 后再验证技能冲击、受击、 + 环境轰鸣、对白/风噪和 stop tail,按数据校准,不在客户端增加二次 authoring。 +10. **发布独立 AAR 并冻结 v1**:验证 Maven/Prefab 制品不依赖相邻源码路径;闭环通过后再恢复 Sunshine P5 与 Haptic Wire P6。 + +下一里程碑定义为:Android 真机在真实串流中完全由 SDK Haptic IR 驱动手机马达,旧 bass 路径保持关闭;`MUSIC/GAME`、stop、后台/退出、断流/重连、30 分钟稳定性和性能 gate 全部通过,并能通过关闭 `enableAudioHapticsOutput` 构建开关回滚。Sunshine 不进入该里程碑。 diff --git a/docs/AUDIO_HAPTICS_SDK_REPOSITORY.md b/docs/AUDIO_HAPTICS_SDK_REPOSITORY.md new file mode 100644 index 00000000..76a9ca52 --- /dev/null +++ b/docs/AUDIO_HAPTICS_SDK_REPOSITORY.md @@ -0,0 +1,39 @@ +# Audio Haptics SDK 仓库迁移 + +Audio Haptics SDK 已从本仓库的 `audio-haptics-sdk/` 迁移到独立的 +[AlkaidLab/moonlight-audio-haptics](https://github.com/AlkaidLab/moonlight-audio-haptics)。 + +当前宿主验证基线: + +- Release:[`v0.5.14`](https://github.com/AlkaidLab/moonlight-audio-haptics/releases/tag/v0.5.14) +- Commit:`e243a6ce65d6dec35976ca0eace3c5aff0d82cc4` +- C ABI:v1 +- 参数集:`action-rpg-p4g-v4` +- 许可证:Apache License 2.0 + +## 本地目录 + +推荐把两个仓库并列放置: + +```text +StudioProjects/ +├─ moonlight-harmonyos/ +└─ moonlight-audio-haptics/ +``` + +此布局会被 HarmonyOS 模块和评测工具自动识别。使用其他目录时设置: + +```powershell +$env:AUDIO_HAPTICS_SDK_DIR = 'D:\src\moonlight-audio-haptics' +``` + +或在配置评测工具时传入: + +```bash +cmake -S tools/audio_haptics_eval -B build/audio-haptics-eval \ + -DAUDIO_HAPTICS_SDK_DIR=/path/to/moonlight-audio-haptics +``` + +CI 和发布构建必须固定完整 commit SHA;tag 只作为可读版本标识。SDK +算法、ABI、Android AAR、许可证和发布资产由独立仓库负责,本仓库继续负责 +HarmonyOS PCM 接入、生命周期、产品策略和宿主评测。 diff --git a/tools/audio_haptics_android_bench/.gitignore b/tools/audio_haptics_android_bench/.gitignore new file mode 100644 index 00000000..4a7b776c --- /dev/null +++ b/tools/audio_haptics_android_bench/.gitignore @@ -0,0 +1,4 @@ +/build/ +/out/ +__pycache__/ +*.pyc diff --git a/tools/audio_haptics_android_bench/CMakeLists.txt b/tools/audio_haptics_android_bench/CMakeLists.txt new file mode 100644 index 00000000..d67e9576 --- /dev/null +++ b/tools/audio_haptics_android_bench/CMakeLists.txt @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +cmake_minimum_required(VERSION 3.20) +project(audio_haptics_android_bench LANGUAGES C CXX) + +if(NOT ANDROID) + message(FATAL_ERROR "This benchmark must be cross-compiled with the Android NDK") +endif() + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +get_filename_component(REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +include("${REPO_ROOT}/cmake/ResolveMoonlightAudioHaptics.cmake") +resolve_moonlight_audio_haptics(MOONLIGHT_HAPTICS_SDK_PATH "${REPO_ROOT}") +set(MOONLIGHT_HAPTICS_BUILD_TESTS OFF CACHE BOOL "" FORCE) +add_subdirectory( + "${MOONLIGHT_HAPTICS_SDK_PATH}" + "${CMAKE_CURRENT_BINARY_DIR}/moonlight-audio-haptics" +) + +add_executable(audio_haptics_android_bench src/main.cpp) +target_link_libraries(audio_haptics_android_bench PRIVATE moonlight::haptics) + +if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") + target_compile_options(audio_haptics_android_bench PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wshadow + ) +endif() diff --git a/tools/audio_haptics_android_bench/README.md b/tools/audio_haptics_android_bench/README.md new file mode 100644 index 00000000..4e0a9347 --- /dev/null +++ b/tools/audio_haptics_android_bench/README.md @@ -0,0 +1,35 @@ +# Android audio haptics SDK Core benchmark + +This GPL-3.0 host/deployment tool cross-compiles the Apache-2.0 portable SDK +Core with the Android NDK, deploys a static arm64 executable through ADB, runs +five deterministic workloads, and emits JSON plus Markdown reports. + +It is a CPU latency, stability, and C ABI gate. It does not evaluate real-world +event accuracy or actuator feel. + +## Run + +Requirements: CMake, Ninja, an installed Android NDK, ADB, and one authorized +Android device. + +```powershell +python tools/audio_haptics_android_bench/run_android_benchmark.py ` + --duration-seconds 10 ` + --device-class android_phone_performance ` + --output-dir tools/audio_haptics_android_bench/out/formal +``` + +The runner automatically: + +1. Selects one authorized ADB device. +2. Selects the newest complete NDK installation. +3. Builds a static `arm64-v8a` executable. +4. Pushes it to `/data/local/tmp` and runs every workload. +5. Hashes the device serial instead of storing it. +6. Records model, Android/API/ABI, NDK, binary hash, battery temperature, and + per-call latency distributions. + +Formal admission requires at least 10 wall-clock seconds per scenario, zero +SDK errors, and P99 `ah_process_i16()` latency no greater than 500 microseconds +for every scenario. Build products and reports under `build/` and `out/` are +intentionally ignored by Git. diff --git a/tools/audio_haptics_android_bench/run_android_benchmark.py b/tools/audio_haptics_android_bench/run_android_benchmark.py new file mode 100644 index 00000000..23455f2c --- /dev/null +++ b/tools/audio_haptics_android_bench/run_android_benchmark.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Build, deploy, run, and report the portable SDK benchmark on Android.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[1] +REMOTE_BINARY = "/data/local/tmp/audio_haptics_android_bench" + + +def run(command: list[str], timeout: int = 120, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=check, + ) + + +def find_adb() -> str: + candidates = [shutil.which("adb")] + for variable in ("ANDROID_HOME", "ANDROID_SDK_ROOT"): + if os.environ.get(variable): + candidates.append(str(Path(os.environ[variable]) / "platform-tools" / "adb.exe")) + candidates.append(str(Path.home() / "AppData" / "Local" / "Android" / "Sdk" / "platform-tools" / "adb.exe")) + for candidate in candidates: + if candidate and Path(candidate).is_file(): + return candidate + return "adb" + + +def _version_key(path: Path) -> tuple[int, ...]: + return tuple(int(value) for value in re.findall(r"\d+", path.name)) + + +def find_ndk() -> Path: + for variable in ("ANDROID_NDK_HOME", "ANDROID_NDK_ROOT"): + if os.environ.get(variable): + candidate = Path(os.environ[variable]) + if candidate.is_dir(): + return candidate + roots = [] + for variable in ("ANDROID_HOME", "ANDROID_SDK_ROOT"): + if os.environ.get(variable): + roots.append(Path(os.environ[variable]) / "ndk") + roots.append(Path.home() / "AppData" / "Local" / "Android" / "Sdk" / "ndk") + versions = [ + path + for root in roots + if root.is_dir() + for path in root.iterdir() + if path.is_dir() and (path / "build" / "cmake" / "android.toolchain.cmake").is_file() + ] + if not versions: + raise RuntimeError("Android NDK not found; set ANDROID_NDK_HOME") + return max(versions, key=_version_key) + + +def adb_command(adb: str, serial: str | None, *arguments: str) -> list[str]: + command = [adb] + if serial: + command.extend(["-s", serial]) + command.extend(arguments) + return command + + +def select_device(adb: str, requested: str | None) -> str: + if requested: + return requested + output = run([adb, "devices"]).stdout.splitlines()[1:] + devices = [line.split()[0] for line in output if "\tdevice" in line] + if not devices: + raise RuntimeError("no authorized Android device found") + if len(devices) > 1: + raise RuntimeError(f"found {len(devices)} Android devices; select one with --serial") + return devices[0] + + +def getprop(adb: str, serial: str, name: str) -> str: + return run(adb_command(adb, serial, "shell", "getprop", name), timeout=15).stdout.strip() or "unknown" + + +def battery_temperature(adb: str, serial: str) -> float | None: + output = run(adb_command(adb, serial, "shell", "dumpsys", "battery"), timeout=15).stdout + match = re.search(r"^\s*temperature:\s*(\d+)\s*$", output, re.MULTILINE) + return int(match.group(1)) / 10.0 if match else None + + +def build_binary(ndk: Path, build_dir: Path, abi: str) -> Path: + toolchain = ndk / "build" / "cmake" / "android.toolchain.cmake" + if not toolchain.is_file(): + raise RuntimeError(f"invalid NDK, toolchain missing: {toolchain}") + configure = [ + "cmake", + "-S", str(SCRIPT_DIR), + "-B", str(build_dir), + "-G", "Ninja", + f"-DCMAKE_TOOLCHAIN_FILE={toolchain}", + f"-DANDROID_ABI={abi}", + "-DANDROID_PLATFORM=android-23", + "-DANDROID_STL=c++_static", + "-DCMAKE_BUILD_TYPE=Release", + ] + run(configure, timeout=120) + run(["cmake", "--build", str(build_dir)], timeout=180) + binary = build_dir / "audio_haptics_android_bench" + if not binary.is_file(): + raise RuntimeError(f"benchmark binary not produced: {binary}") + return binary + + +def render_markdown(report: dict[str, Any]) -> str: + metadata = report["metadata"] + lines = [ + "# Android audio haptics SDK Core benchmark", + "", + f"Overall: **{'PASS' if report['android_core_gate_pass'] else 'BLOCKED'}**", + "", + f"- Device: `{metadata['manufacturer']} {metadata['model']}` (`{metadata['device_class']}`)", + f"- Android/API/ABI: `{metadata['android_release']}` / `{metadata['api_level']}` / `{metadata['abi']}`", + f"- SDK/parameters: `{report['benchmark']['sdk_version']}` / `{report['benchmark']['parameter_set_version']}`", + f"- Duration gate: `{'PASS' if report['duration_gate']['pass'] else 'BLOCKED'}` " + f"({report['duration_gate']['actual_seconds']} / {report['duration_gate']['required_seconds']} seconds per scenario)", + f"- Battery temperature: `{metadata['battery_temperature_before_c']}` -> `{metadata['battery_temperature_after_c']}` °C", + "", + "| Scenario | Mean us | P95 us | P99 us | Max us | Realtime factor | Errors | Gate |", + "|---|---:|---:|---:|---:|---:|---:|---|", + ] + gates = report["scenario_gates"] + for scenario in report["benchmark"]["scenarios"]: + gate = gates[scenario["name"]] + lines.append( + f"| {scenario['name']} | {scenario['mean_us']:.3f} | {scenario['p95_us']:.3f} | " + f"{scenario['p99_us']:.3f} | {scenario['max_us']:.3f} | " + f"{scenario['realtime_factor']:.1f}x | {scenario['errors']} | " + f"{'PASS' if gate['pass'] else 'BLOCKED'} |" + ) + lines.extend([ + "", + "> This is an Android arm64 SDK Core CPU gate using deterministic synthetic workloads. It does not replace real-content accuracy evaluation or HarmonyOS actuator experience review.", + "", + ]) + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, default=SCRIPT_DIR / "out") + parser.add_argument("--build-dir", type=Path, default=SCRIPT_DIR / "build" / "arm64-v8a") + parser.add_argument("--device-class", default="android_phone_performance") + parser.add_argument("--duration-seconds", type=int, default=5, help="Wall time per scenario") + parser.add_argument("--minimum-gate-duration-seconds", type=int, default=10) + parser.add_argument("--serial") + parser.add_argument("--adb", default=find_adb()) + parser.add_argument("--ndk", type=Path) + parser.add_argument("--skip-build", action="store_true") + args = parser.parse_args() + if args.duration_seconds < 1 or args.duration_seconds > 3600: + parser.error("--duration-seconds must be between 1 and 3600") + if args.minimum_gate_duration_seconds < 1: + parser.error("--minimum-gate-duration-seconds must be positive") + + try: + serial = select_device(args.adb, args.serial) + abi_list = getprop(args.adb, serial, "ro.product.cpu.abilist") + abi = "arm64-v8a" if "arm64-v8a" in abi_list.split(",") else abi_list.split(",")[0] + ndk = args.ndk or find_ndk() + binary = args.build_dir / "audio_haptics_android_bench" if args.skip_build else build_binary( + ndk, args.build_dir, abi + ) + if not binary.is_file(): + raise RuntimeError(f"benchmark binary not found: {binary}") + + run(adb_command(args.adb, serial, "push", str(binary), REMOTE_BINARY), timeout=60) + run(adb_command(args.adb, serial, "shell", "chmod", "755", REMOTE_BINARY), timeout=15) + temperature_before = battery_temperature(args.adb, serial) + completed = run( + adb_command( + args.adb, + serial, + "shell", + REMOTE_BINARY, + "--duration-seconds", + str(args.duration_seconds), + ), + timeout=args.duration_seconds * 5 + 60, + ) + temperature_after = battery_temperature(args.adb, serial) + benchmark = json.loads(completed.stdout) + except (OSError, RuntimeError, subprocess.SubprocessError, json.JSONDecodeError) as exc: + parser.error(str(exc)) + + scenario_gates = { + scenario["name"]: { + "pass": scenario["errors"] == 0 and scenario["p99_us"] <= 500.0, + "errors_zero": scenario["errors"] == 0, + "p99_process_le_500_us": scenario["p99_us"] <= 500.0, + } + for scenario in benchmark["scenarios"] + } + duration_gate = { + "pass": args.duration_seconds >= args.minimum_gate_duration_seconds, + "actual_seconds": args.duration_seconds, + "required_seconds": args.minimum_gate_duration_seconds, + } + now = datetime.now(timezone.utc).replace(microsecond=0) + metadata = { + "schema_version": 1, + "captured_at_utc": now.isoformat().replace("+00:00", "Z"), + "device_id_hash": hashlib.sha256(serial.encode("utf-8")).hexdigest()[:12], + "device_class": args.device_class, + "manufacturer": getprop(args.adb, serial, "ro.product.manufacturer"), + "model": getprop(args.adb, serial, "ro.product.model"), + "android_release": getprop(args.adb, serial, "ro.build.version.release"), + "api_level": int(getprop(args.adb, serial, "ro.build.version.sdk")), + "abi": abi, + "ndk_version": ndk.name, + "binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(), + "battery_temperature_before_c": temperature_before, + "battery_temperature_after_c": temperature_after, + } + report = { + "schema_version": 1, + "metadata": metadata, + "benchmark": benchmark, + "scenario_gates": scenario_gates, + "duration_gate": duration_gate, + "android_core_gate_pass": duration_gate["pass"] and all( + gate["pass"] for gate in scenario_gates.values() + ), + "scope": { + "validates": ["Android arm64 SDK Core CPU latency", "long-running process stability", "C ABI compatibility"], + "does_not_validate": ["real-content accuracy", "Android vibrator experience", "HarmonyOS actuator experience"], + }, + } + args.output_dir.mkdir(parents=True, exist_ok=True) + json_path = args.output_dir / "android_core_benchmark.json" + markdown_path = args.output_dir / "android_core_benchmark.md" + json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + markdown_path.write_text(render_markdown(report), encoding="utf-8") + print(json_path) + print(markdown_path) + return 0 if report["android_core_gate_pass"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/audio_haptics_android_bench/src/main.cpp b/tools/audio_haptics_android_bench/src/main.cpp new file mode 100644 index 00000000..ada6648e --- /dev/null +++ b/tools/audio_haptics_android_bench/src/main.cpp @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "moonlight_haptics/audio_haptics.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr uint32_t kSampleRate = 48000U; +constexpr uint32_t kChannels = 2U; +constexpr uint32_t kBlockFrames = 240U; +constexpr uint32_t kPatternSeconds = 2U; +constexpr uint32_t kWarmupBlocks = 400U; +constexpr double kPi = 3.14159265358979323846; + +struct Scenario { + const char* name; + std::vector pcm; +}; + +struct Result { + const char* name = ""; + uint64_t blocks = 0U; + uint64_t frames = 0U; + uint64_t outputFrames = 0U; + uint64_t transientFrames = 0U; + uint64_t errors = 0U; + double wallSeconds = 0.0; + double cpuSeconds = 0.0; + double p50Us = 0.0; + double p95Us = 0.0; + double p99Us = 0.0; + double maximumUs = 0.0; + double meanUs = 0.0; + double realtimeFactor = 0.0; +}; + +int16_t ToPcm16(double value) { + const double clamped = std::max(-1.0, std::min(1.0, value)); + return static_cast(std::lround(clamped * 32767.0)); +} + +uint32_t NextRandom(uint32_t& state) { + state ^= state << 13U; + state ^= state >> 17U; + state ^= state << 5U; + return state; +} + +std::vector RenderPattern(const std::string& name) { + const uint32_t frames = kSampleRate * kPatternSeconds; + std::vector pcm(static_cast(frames) * kChannels, 0); + uint32_t randomState = 0x29aU; + for (uint32_t frame = 0U; frame < frames; ++frame) { + const double time = static_cast(frame) / kSampleRate; + double left = 0.0; + double right = 0.0; + if (name == "continuous_low_frequency") { + left = right = 0.24 * std::sin(2.0 * kPi * 60.0 * time); + } else if (name == "game_strong_transient") { + const uint32_t phase = frame % (kSampleRate / 2U); + if (phase < 720U) { + const double envelope = std::exp(-static_cast(phase) / 150.0); + const double noise = static_cast(NextRandom(randomState) & 0xffffU) / 32767.5 - 1.0; + left = 0.88 * envelope * noise; + right = -left; + } + } else if (name == "music_mix") { + const double beatPhase = std::fmod(time, 0.5); + const double beat = 0.30 * std::exp(-beatPhase * 18.0) * std::sin(2.0 * kPi * 70.0 * time); + left = beat + 0.10 * std::sin(2.0 * kPi * 440.0 * time); + right = beat + 0.10 * std::sin(2.0 * kPi * 554.37 * time); + } else if (name == "speech_modulated") { + const double envelope = 0.5 + 0.5 * std::sin(2.0 * kPi * 3.7 * time); + left = right = 0.20 * envelope * ( + std::sin(2.0 * kPi * 180.0 * time) + + 0.35 * std::sin(2.0 * kPi * 360.0 * time)); + } + const size_t index = static_cast(frame) * kChannels; + pcm[index] = ToPcm16(left); + pcm[index + 1U] = ToPcm16(right); + } + return pcm; +} + +double ProcessCpuSeconds() { + timespec value{}; + if (clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &value) != 0) return 0.0; + return static_cast(value.tv_sec) + static_cast(value.tv_nsec) / 1.0e9; +} + +double Percentile(const std::vector& sortedNs, double percentile) { + if (sortedNs.empty()) return 0.0; + const double rank = std::ceil(percentile * static_cast(sortedNs.size())); + const size_t index = static_cast(std::max(1.0, rank) - 1.0); + return static_cast(sortedNs[index]) / 1000.0; +} + +Result RunScenario(const Scenario& scenario, uint32_t durationSeconds) { + AhConfig config{}; + if (ah_config_init(&config, kSampleRate, kChannels) != AH_STATUS_OK) { + return Result{scenario.name, 0U, 0U, 0U, 0U, 1U}; + } + config.requested_scene = AH_SCENE_GAME; + AhEngine* engine = nullptr; + if (ah_create(&config, &engine) != AH_STATUS_OK || engine == nullptr) { + return Result{scenario.name, 0U, 0U, 0U, 0U, 1U}; + } + + const uint32_t capacity = ah_get_max_output_frames(engine, kBlockFrames); + std::vector outputs(std::max(1U, capacity)); + const uint32_t patternBlocks = kSampleRate * kPatternSeconds / kBlockFrames; + uint64_t sampleFrame = 0U; + + auto processOne = [&](uint64_t blockIndex, bool record, Result& result, + std::vector& latencies) { + const uint32_t patternBlock = static_cast(blockIndex % patternBlocks); + AhProcessInput input{}; + input.struct_size = AH_PROCESS_INPUT_V1_SIZE; + input.interleaved_pcm = scenario.pcm.data() + + static_cast(patternBlock) * kBlockFrames * kChannels; + input.frame_count = kBlockFrames; + input.first_sample_time_us = sampleFrame * 1000000ULL / kSampleRate; + uint32_t outputCount = 0U; + const auto start = std::chrono::steady_clock::now(); + const AhStatus status = ah_process_i16( + engine, &input, outputs.data(), static_cast(outputs.size()), &outputCount); + const auto stop = std::chrono::steady_clock::now(); + sampleFrame += kBlockFrames; + if (!record) { + if (status != AH_STATUS_OK && status != AH_STATUS_OUTPUT_AVAILABLE) ++result.errors; + return; + } + latencies.push_back(static_cast( + std::chrono::duration_cast(stop - start).count())); + ++result.blocks; + result.frames += kBlockFrames; + if (status != AH_STATUS_OK && status != AH_STATUS_OUTPUT_AVAILABLE) { + ++result.errors; + return; + } + result.outputFrames += outputCount; + for (uint32_t index = 0U; index < outputCount; ++index) { + if ((outputs[index].flags & AH_FRAME_TRANSIENT) != 0U) ++result.transientFrames; + } + }; + + Result result{}; + result.name = scenario.name; + std::vector latencies; + latencies.reserve(static_cast(durationSeconds) * 20000U); + for (uint64_t block = 0U; block < kWarmupBlocks; ++block) { + processOne(block, false, result, latencies); + } + ah_reset(engine); + sampleFrame = 0U; + + const auto wallStart = std::chrono::steady_clock::now(); + const double cpuStart = ProcessCpuSeconds(); + const auto deadline = wallStart + std::chrono::seconds(durationSeconds); + uint64_t blockIndex = 0U; + while (std::chrono::steady_clock::now() < deadline) { + processOne(blockIndex++, true, result, latencies); + } + const double cpuStop = ProcessCpuSeconds(); + const auto wallStop = std::chrono::steady_clock::now(); + ah_destroy(engine); + + result.wallSeconds = std::chrono::duration(wallStop - wallStart).count(); + result.cpuSeconds = std::max(0.0, cpuStop - cpuStart); + const uint64_t totalNs = std::accumulate(latencies.begin(), latencies.end(), uint64_t{0U}); + result.meanUs = latencies.empty() ? 0.0 : + static_cast(totalNs) / static_cast(latencies.size()) / 1000.0; + std::sort(latencies.begin(), latencies.end()); + result.p50Us = Percentile(latencies, 0.50); + result.p95Us = Percentile(latencies, 0.95); + result.p99Us = Percentile(latencies, 0.99); + result.maximumUs = latencies.empty() ? 0.0 : static_cast(latencies.back()) / 1000.0; + const double audioSeconds = static_cast(result.frames) / kSampleRate; + result.realtimeFactor = result.wallSeconds > 0.0 ? audioSeconds / result.wallSeconds : 0.0; + return result; +} + +uint32_t ParseDuration(int argc, char** argv) { + uint32_t duration = 5U; + for (int index = 1; index < argc; ++index) { + const std::string argument = argv[index]; + if (argument == "--duration-seconds" && index + 1 < argc) { + const long parsed = std::strtol(argv[++index], nullptr, 10); + if (parsed < 1L || parsed > 3600L) return 0U; + duration = static_cast(parsed); + } else { + return 0U; + } + } + return duration; +} + +void PrintResult(const std::vector& results, uint32_t durationSeconds) { + std::cout << std::fixed << std::setprecision(3); + std::cout << "{\n" + << " \"schema_version\": 1,\n" + << " \"sdk_version\": \"" << ah_get_version_string() << "\",\n" + << " \"abi_version\": " << ah_get_abi_version() << ",\n" + << " \"parameter_set_version\": \"" << ah_get_parameter_set_version() << "\",\n" + << " \"sample_rate_hz\": " << kSampleRate << ",\n" + << " \"channel_count\": " << kChannels << ",\n" + << " \"block_frames\": " << kBlockFrames << ",\n" + << " \"duration_seconds_per_scenario\": " << durationSeconds << ",\n" + << " \"synthetic_workload\": true,\n" + << " \"scenarios\": [\n"; + for (size_t index = 0U; index < results.size(); ++index) { + const Result& result = results[index]; + std::cout << " {\"name\": \"" << result.name + << "\", \"blocks\": " << result.blocks + << ", \"frames\": " << result.frames + << ", \"output_frames\": " << result.outputFrames + << ", \"transient_frames\": " << result.transientFrames + << ", \"errors\": " << result.errors + << ", \"wall_seconds\": " << result.wallSeconds + << ", \"cpu_seconds\": " << result.cpuSeconds + << ", \"mean_us\": " << result.meanUs + << ", \"p50_us\": " << result.p50Us + << ", \"p95_us\": " << result.p95Us + << ", \"p99_us\": " << result.p99Us + << ", \"max_us\": " << result.maximumUs + << ", \"realtime_factor\": " << result.realtimeFactor << "}"; + std::cout << (index + 1U == results.size() ? "\n" : ",\n"); + } + std::cout << " ]\n}\n"; +} + +} // namespace + +int main(int argc, char** argv) { + const uint32_t durationSeconds = ParseDuration(argc, argv); + if (durationSeconds == 0U) { + std::cerr << "usage: audio_haptics_android_bench [--duration-seconds 1..3600]\n"; + return 2; + } + const std::array names = { + "silence_noise_floor", + "continuous_low_frequency", + "game_strong_transient", + "music_mix", + "speech_modulated", + }; + std::vector scenarios; + scenarios.reserve(names.size()); + for (const char* name : names) scenarios.push_back(Scenario{name, RenderPattern(name)}); + + std::vector results; + results.reserve(scenarios.size()); + for (const Scenario& scenario : scenarios) { + results.push_back(RunScenario(scenario, durationSeconds)); + } + PrintResult(results, durationSeconds); + return std::any_of(results.begin(), results.end(), + [](const Result& result) { return result.errors != 0U; }) ? 1 : 0; +} diff --git a/tools/audio_haptics_eval/.gitignore b/tools/audio_haptics_eval/.gitignore new file mode 100644 index 00000000..fa4a07e3 --- /dev/null +++ b/tools/audio_haptics_eval/.gitignore @@ -0,0 +1,6 @@ +/build/ +/out/ +/fixtures/generated/ +/datasets/local/ +__pycache__/ +*.pyc diff --git a/tools/audio_haptics_eval/CMakeLists.txt b/tools/audio_haptics_eval/CMakeLists.txt new file mode 100644 index 00000000..d8e92846 --- /dev/null +++ b/tools/audio_haptics_eval/CMakeLists.txt @@ -0,0 +1,201 @@ +cmake_minimum_required(VERSION 3.20) + +project(audio_haptics_eval LANGUAGES C CXX) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +get_filename_component(REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +set(NATIVE_CPP_DIR "${REPO_ROOT}/nativelib/src/main/cpp") +set(AUBIO_SRC_DIR "${NATIVE_CPP_DIR}/aubio/src") +set(AUBIO_CONFIG_HEADER "${CMAKE_CURRENT_BINARY_DIR}/generated/config.h") + +include("${REPO_ROOT}/cmake/ResolveMoonlightAudioHaptics.cmake") +resolve_moonlight_audio_haptics(MOONLIGHT_HAPTICS_SDK_PATH "${REPO_ROOT}") +set(MOONLIGHT_HAPTICS_BUILD_TESTS OFF) +add_subdirectory( + "${MOONLIGHT_HAPTICS_SDK_PATH}" + "${CMAKE_CURRENT_BINARY_DIR}/moonlight-audio-haptics" +) + +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/generated") +configure_file( + "${REPO_ROOT}/ci/sdk-stubs/aubio-config.h" + "${AUBIO_CONFIG_HEADER}" + COPYONLY +) + +# This target intentionally remains in the GPL host-side evaluation tool. +# It must not be copied into the future Apache-2.0 SDK. +add_library(aubio_baseline STATIC + ${AUBIO_SRC_DIR}/fvec.c + ${AUBIO_SRC_DIR}/cvec.c + ${AUBIO_SRC_DIR}/lvec.c + ${AUBIO_SRC_DIR}/mathutils.c + ${AUBIO_SRC_DIR}/vecutils.c + ${AUBIO_SRC_DIR}/musicutils.c + ${AUBIO_SRC_DIR}/spectral/ooura_fft8g.c + ${AUBIO_SRC_DIR}/spectral/fft.c + ${AUBIO_SRC_DIR}/spectral/phasevoc.c + ${AUBIO_SRC_DIR}/spectral/specdesc.c + ${AUBIO_SRC_DIR}/spectral/statistics.c + ${AUBIO_SRC_DIR}/spectral/awhitening.c + ${AUBIO_SRC_DIR}/temporal/filter.c + ${AUBIO_SRC_DIR}/temporal/biquad.c + ${AUBIO_SRC_DIR}/onset/onset.c + ${AUBIO_SRC_DIR}/onset/peakpicker.c + ${AUBIO_SRC_DIR}/utils/log.c + ${AUBIO_SRC_DIR}/utils/hist.c + ${AUBIO_SRC_DIR}/utils/scale.c + ${AUBIO_SRC_DIR}/utils/strutils.c +) + +target_include_directories(aubio_baseline PUBLIC + "${AUBIO_SRC_DIR}" + "${CMAKE_CURRENT_BINARY_DIR}/generated" +) +target_compile_definitions(aubio_baseline PRIVATE HAVE_CONFIG_H) + +if(MSVC) + target_compile_options(aubio_baseline PRIVATE /W3) +else() + target_compile_options(aubio_baseline PRIVATE + -Wall -Wextra + -Wno-sign-compare + -Wno-unused-parameter + -Wno-missing-field-initializers + ) +endif() + +add_executable(audio_haptics_eval + src/main.cpp +) + +target_include_directories(audio_haptics_eval PRIVATE + "${NATIVE_CPP_DIR}" +) +target_link_libraries(audio_haptics_eval PRIVATE + aubio_baseline + moonlight::haptics +) + +if(NOT MSVC) + target_compile_options(audio_haptics_eval PRIVATE -Wall -Wextra -Wpedantic) +endif() + +if(UNIX AND NOT APPLE) + target_link_libraries(audio_haptics_eval PRIVATE m) +endif() + +include(CTest) +if(BUILD_TESTING) + find_package(Python3 COMPONENTS Interpreter REQUIRED) + set(TEST_FIXTURE_DIR "${CMAKE_CURRENT_BINARY_DIR}/test_fixtures") + set(TEST_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/test_output") + + add_test( + NAME audio_haptics_generate_fixtures + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/generate_fixtures.py" + --output-dir "${TEST_FIXTURE_DIR}" + ) + set_tests_properties(audio_haptics_generate_fixtures PROPERTIES + FIXTURES_SETUP audio_haptics_fixtures + ) + + add_test( + NAME audio_haptics_eval_smoke + COMMAND audio_haptics_eval + --input "${TEST_FIXTURE_DIR}/impulse_train_mono.wav" + --labels "${TEST_FIXTURE_DIR}/impulse_train_mono.labels.csv" + --backend all + --events-out "${TEST_OUTPUT_DIR}/events.csv" + --summary-out "${TEST_OUTPUT_DIR}/summary.json" + --runs 1 + --warmup-runs 0 + ) + set_tests_properties(audio_haptics_eval_smoke PROPERTIES + FIXTURES_REQUIRED audio_haptics_fixtures + ) + + add_test( + NAME audio_haptics_dataset_validate + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/validate_dataset.py" + --manifest "${TEST_FIXTURE_DIR}/manifest.csv" + ) + set_tests_properties(audio_haptics_dataset_validate PROPERTIES + FIXTURES_REQUIRED audio_haptics_fixtures + ) + + add_test( + NAME audio_haptics_real_world_dataset_gate + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/validate_dataset.py" + --manifest "${TEST_FIXTURE_DIR}/manifest.csv" + --require-real-world + ) + set_tests_properties(audio_haptics_real_world_dataset_gate PROPERTIES + FIXTURES_REQUIRED audio_haptics_fixtures + WILL_FAIL TRUE + ) + + add_test( + NAME audio_haptics_shadow_pipeline + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/run_baseline.py" + --build-dir "${CMAKE_CURRENT_BINARY_DIR}" + --fixtures-dir "${TEST_FIXTURE_DIR}" + --output-dir "${TEST_OUTPUT_DIR}/shadow" + --skip-build + --skip-generate + --runs 1 + --warmup-runs 0 + ) + set_tests_properties(audio_haptics_shadow_pipeline PROPERTIES + FIXTURES_REQUIRED audio_haptics_fixtures + ) + + add_executable(audio_haptics_device_shadow_test + tests/audio_haptics_shadow_test.cpp + "${NATIVE_CPP_DIR}/audio_haptics_shadow.cpp" + ) + target_include_directories(audio_haptics_device_shadow_test PRIVATE + "${NATIVE_CPP_DIR}" + ) + target_compile_definitions(audio_haptics_device_shadow_test PRIVATE + MOONLIGHT_AUDIO_HAPTICS_SHADOW=1 + ) + target_link_libraries(audio_haptics_device_shadow_test PRIVATE + aubio_baseline + moonlight::haptics + ) + add_test(NAME audio_haptics_device_shadow + COMMAND audio_haptics_device_shadow_test) + + add_executable(audio_haptics_shadow_disabled_test + tests/audio_haptics_shadow_disabled_test.cpp + "${NATIVE_CPP_DIR}/audio_haptics_shadow.cpp" + ) + target_include_directories(audio_haptics_shadow_disabled_test PRIVATE + "${NATIVE_CPP_DIR}" + ) + target_compile_definitions(audio_haptics_shadow_disabled_test PRIVATE + MOONLIGHT_AUDIO_HAPTICS_SHADOW=0 + ) + target_link_libraries(audio_haptics_shadow_disabled_test PRIVATE + aubio_baseline + moonlight::haptics + ) + add_test(NAME audio_haptics_shadow_compile_off + COMMAND audio_haptics_shadow_disabled_test) + + add_test( + NAME audio_haptics_device_shadow_tools + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_device_shadow_tools.py" + ) +endif() diff --git a/tools/audio_haptics_eval/README.md b/tools/audio_haptics_eval/README.md new file mode 100644 index 00000000..fc1d5c69 --- /dev/null +++ b/tools/audio_haptics_eval/README.md @@ -0,0 +1,102 @@ +# Audio haptics P0 evaluator + +This host tool establishes the reproducible aubio baseline required by Phase +P0 of `docs/AUDIO_HAPTICS_SDK_IMPLEMENTATION_PLAN.md`. + +It deliberately links the current GPL aubio subset and compares it with the +current clean-room `SpectralOnsetDetector` and the public ABI of the new SDK. +The evaluator belongs to the GPL host project and must not be copied into the +Apache-2.0 SDK. + +## One-command baseline + +Requirements: CMake 3.20+, Ninja, a C/C++17 compiler, and Python 3.10+. + +```bash +python tools/audio_haptics_eval/run_baseline.py +``` + +This command: + +1. Configures and builds a Release host executable. +2. Generates deterministic PCM16 WAV fixtures and labels. +3. Runs aubio, the current native detector, and the SDK Core on every fixture. +4. Writes per-case event CSV/JSON and aggregate `baseline.csv/json` under + `tools/audio_haptics_eval/out`. +5. Validates dataset hashes/rights metadata and writes `shadow_events.csv`, + `shadow_report.json`, and a human-readable `shadow_report.md`. + +## Run one file + +```bash +tools/audio_haptics_eval/build/audio_haptics_eval \ + --input sample.wav \ + --labels sample.labels.csv \ + --backend all \ + --events-out events.csv \ + --summary-out summary.json +``` + +Input is little-endian RIFF PCM16 with 1–8 interleaved channels. Event +timestamps describe when the detector returned an onset, measured at the end +of the input hop. This intentionally includes algorithmic lookahead and is the +timestamp used for P0 latency comparisons. + +## Output metrics + +- Precision, recall and F1 use greedy one-to-one matching within 50 ms. +- Timing error is detector output time minus the annotated onset time. +- `call_pXX_us` measures one `ProcessFrame` call, excluding initialization and + WAV I/O. +- `realtime_factor` is audio duration divided by median processing time. + +Synthetic fixture scores are diagnostic. Removal gates must be evaluated again +on the versioned, licensed real-world dataset and on target mobile hardware. + +## Real-world dataset + +Keep licensed WAV files outside Git and create a manifest using the schema in +`datasets/manifest.template.csv`. Every item records its rights basis, +redistribution policy, expected haptic behavior, critical-event status, split, +and SHA-256 hashes. Validate and run it with: + +```bash +python tools/audio_haptics_eval/validate_dataset.py --manifest /manifest.csv --require-real-world +python tools/audio_haptics_eval/run_baseline.py --fixtures-dir --manifest /manifest.csv --skip-generate +``` + +The shadow report stores event timestamps, descriptors, metrics, and gate +results only; it does not copy raw PCM into the report directory. + +The `sdk_core` backend exercises the portable P2 C ABI implementation: a +channel-aware STFT, adaptive normalization, causal onset detector, and haptic +IR mapper. It is evaluated without changing the Harmony production path. + +# Device-side shadow capture + +The device path logs aggregate counters and fixed latency buckets only. It never +captures PCM. With an internal HAP where the runtime shadow switch is enabled, +capture one reproducible scenario with: + +```powershell +python capture_device_shadow.py ` + --output-dir out/device ` + --device-class phone_performance ` + --scenario-id game-combat-01 ` + --scenario-category game_strong_transient ` + --duration-seconds 60 +``` + +The command discovers a single HDC target, hashes its serial, queries non-content +device properties, filters `[HAPTICS_SHADOW]` lines, and emits metadata plus JSON +and Markdown summaries. Build the admission report after collecting all required +scenarios on at least two device classes: + +```powershell +python device_shadow_gate.py out/device --output-dir out/device-admission +``` + +The admission gate requires real captures, two distinct devices/classes, all +six scenario categories on each class, zero processing errors, consistent +latency histograms, and a P99 +processing-time bucket upper bound no greater than 500 microseconds. diff --git a/tools/audio_haptics_eval/capture_android_shadow.py b/tools/audio_haptics_eval/capture_android_shadow.py new file mode 100644 index 00000000..089afc11 --- /dev/null +++ b/tools/audio_haptics_eval/capture_android_shadow.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Capture aggregate-only audio haptics shadow Logcat from one Android scenario.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +from device_shadow_log import build_summary, parse_snapshots, render_markdown + + +def find_adb() -> str: + candidates = [shutil.which("adb")] + for variable in ("ANDROID_HOME", "ANDROID_SDK_ROOT"): + if os.environ.get(variable): + candidates.append(str(Path(os.environ[variable]) / "platform-tools" / "adb.exe")) + candidates.append(str(Path.home() / "AppData" / "Local" / "Android" / "Sdk" / "platform-tools" / "adb.exe")) + for candidate in candidates: + if candidate and Path(candidate).is_file(): + return candidate + return "adb" + + +def adb_command(adb: str, serial: str | None, *arguments: str) -> list[str]: + command = [adb] + if serial: + command.extend(["-s", serial]) + command.extend(arguments) + return command + + +def run(adb: str, serial: str | None, *arguments: str) -> str: + completed = subprocess.run( + adb_command(adb, serial, *arguments), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=True, + timeout=15, + ) + return completed.stdout.strip() + + +def select_device(adb: str, requested: str | None) -> str: + if requested: + return requested + devices = [ + line.split()[0] + for line in run(adb, None, "devices").splitlines()[1:] + if "\tdevice" in line + ] + if not devices: + raise RuntimeError("no authorized Android device found") + if len(devices) > 1: + raise RuntimeError(f"found {len(devices)} Android devices; select one with --serial") + return devices[0] + + +def getprop(adb: str, serial: str, name: str, fallback: str = "unknown") -> str: + try: + return run(adb, serial, "shell", "getprop", name) or fallback + except (OSError, subprocess.SubprocessError): + return fallback + + +def capture_logcat(adb: str, serial: str, duration_seconds: int) -> str: + command = adb_command( + adb, + serial, + "logcat", + "-v", + "threadtime", + "-T", + "1", + "moonlight-haptics:I", + "*:S", + ) + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + timed_out = False + try: + stdout, stderr = process.communicate(timeout=duration_seconds) + except subprocess.TimeoutExpired: + timed_out = True + process.terminate() + stdout, stderr = process.communicate(timeout=10) + if not timed_out and process.returncode != 0: + raise RuntimeError(f"logcat capture failed ({process.returncode}): {stderr.strip()}") + filtered = "\n".join(line for line in stdout.splitlines() if "[HAPTICS_SHADOW]" in line) + return filtered + ("\n" if filtered else "") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--device-class", required=True) + parser.add_argument("--scenario-id", required=True) + parser.add_argument( + "--scenario-category", + required=True, + choices=( + "game_strong_transient", + "continuous_low_frequency", + "music", + "speech", + "silence_noise_floor", + "stream_reconnect", + ), + ) + parser.add_argument("--duration-seconds", type=int, default=60) + parser.add_argument("--sample-rate-hz", type=int, default=48000) + parser.add_argument("--serial") + parser.add_argument("--adb", default=find_adb()) + parser.add_argument("--notes", default="") + args = parser.parse_args() + if args.duration_seconds < 5: + parser.error("--duration-seconds must be at least 5") + + try: + serial = select_device(args.adb, args.serial) + now = datetime.now(timezone.utc).replace(microsecond=0) + timestamp = now.strftime("%Y%m%dT%H%M%SZ") + capture_id = f"android_{args.device_class}_{args.scenario_category}_{timestamp}" + android_release = getprop(args.adb, serial, "ro.build.version.release") + api_level = getprop(args.adb, serial, "ro.build.version.sdk") + metadata = { + "schema_version": 1, + "capture_id": capture_id, + "captured_at_utc": now.isoformat().replace("+00:00", "Z"), + "device_id_hash": hashlib.sha256(serial.encode("utf-8")).hexdigest()[:12], + "device_class": args.device_class, + "model": getprop(args.adb, serial, "ro.product.model"), + "os_version": f"Android {android_release} API {api_level}", + "cpu_abi": getprop(args.adb, serial, "ro.product.cpu.abilist"), + "scenario_id": args.scenario_id, + "scenario_category": args.scenario_category, + "sample_rate_hz": args.sample_rate_hz, + "sdk_version": "0.3.0", + "parameter_set_version": "game-p3-v1", + "shadow_runtime_enabled": True, + "source": "android_logcat_aggregate_only", + "synthetic": False, + "notes": args.notes, + } + print(f"Capturing {args.duration_seconds}s for {capture_id}; run the stream scenario now...", flush=True) + filtered_log = capture_logcat(args.adb, serial, args.duration_seconds) + except (OSError, RuntimeError, subprocess.SubprocessError) as exc: + parser.error(str(exc)) + + capture_dir = args.output_dir / capture_id + capture_dir.mkdir(parents=True, exist_ok=False) + (capture_dir / "shadow.logcat").write_text(filtered_log, encoding="utf-8") + (capture_dir / "metadata.json").write_text( + json.dumps(metadata, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + snapshots = parse_snapshots(filtered_log.splitlines()) + if len(snapshots) < 2: + parser.error( + f"need at least two complete shadow snapshots for a window delta; " + f"captured {len(snapshots)}; raw capture saved to {capture_dir}" + ) + try: + summary = build_summary(metadata, snapshots) + except ValueError as exc: + parser.error(f"{exc}; raw capture saved to {capture_dir}") + (capture_dir / "device_shadow_summary.json").write_text( + json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + (capture_dir / "device_shadow_summary.md").write_text(render_markdown(summary), encoding="utf-8") + print(capture_dir) + return 0 if summary["capture_pass"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/audio_haptics_eval/capture_device_shadow.py b/tools/audio_haptics_eval/capture_device_shadow.py new file mode 100644 index 00000000..d5af1682 --- /dev/null +++ b/tools/audio_haptics_eval/capture_device_shadow.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Capture aggregate-only audio haptics shadow HiLog from one HarmonyOS scenario.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +from device_shadow_log import build_summary, parse_snapshots, render_markdown + + +def find_hdc() -> str: + candidates = [shutil.which("hdc")] + sdk_home = os.environ.get("DEVECO_SDK_HOME") or os.environ.get("OHOS_SDK_HOME") + if sdk_home: + candidates.append(str(Path(sdk_home) / "openharmony" / "toolchains" / "hdc.exe")) + candidates.append(str(Path(sdk_home) / "toolchains" / "hdc.exe")) + candidates.append( + r"C:\Program Files\Huawei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" + ) + for candidate in candidates: + if candidate and Path(candidate).is_file(): + return candidate + return "hdc" + + +def _run(hdc: str, serial: str | None, *args: str) -> str: + command = [hdc] + if serial: + command.extend(["-t", serial]) + command.extend(args) + completed = subprocess.run(command, capture_output=True, text=True, check=True, timeout=15) + return completed.stdout.strip() + + +def _property(hdc: str, serial: str | None, name: str, fallback: str) -> str: + try: + value = _run(hdc, serial, "shell", "param", "get", name) + return value or fallback + except (OSError, subprocess.SubprocessError): + return fallback + + +def _resolve_serial(hdc: str, requested: str | None) -> str: + if requested: + return requested + targets = [line.strip() for line in _run(hdc, None, "list", "targets").splitlines()] + targets = [target for target in targets if target and target != "[Empty]"] + if not targets: + raise RuntimeError("no HDC target found; connect and authorize a HarmonyOS device") + if len(targets) > 1: + raise RuntimeError(f"found {len(targets)} HDC targets; select one with --serial") + return targets[0] + + +def capture_hilog(hdc: str, serial: str, duration_seconds: int) -> tuple[str, int]: + command = [hdc, "-t", serial, "shell", "hilog"] + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + timed_out = False + try: + stdout, stderr = process.communicate(timeout=duration_seconds) + except subprocess.TimeoutExpired: + timed_out = True + process.terminate() + stdout, stderr = process.communicate(timeout=10) + if not timed_out and process.returncode != 0: + raise RuntimeError(f"hilog capture failed ({process.returncode}): {stderr.strip()}") + filtered = "\n".join(line for line in stdout.splitlines() if "[HAPTICS_SHADOW]" in line) + if filtered: + filtered += "\n" + return filtered, process.returncode or 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--device-class", required=True) + parser.add_argument("--scenario-id", required=True) + parser.add_argument( + "--scenario-category", + required=True, + choices=( + "game_strong_transient", + "continuous_low_frequency", + "music", + "speech", + "silence_noise_floor", + "stream_reconnect", + ), + ) + parser.add_argument("--duration-seconds", type=int, default=60) + parser.add_argument("--sample-rate-hz", type=int, default=48000) + parser.add_argument("--serial", help="HDC target; omitted when exactly one device is connected") + parser.add_argument("--hdc", default=find_hdc()) + parser.add_argument("--notes", default="") + args = parser.parse_args() + + if args.duration_seconds < 5: + parser.error("--duration-seconds must be at least 5") + + try: + serial = _resolve_serial(args.hdc, args.serial) + now = datetime.now(timezone.utc).replace(microsecond=0) + timestamp = now.strftime("%Y%m%dT%H%M%SZ") + capture_id = f"{args.device_class}_{args.scenario_category}_{timestamp}" + device_hash = hashlib.sha256(serial.encode("utf-8")).hexdigest()[:12] + metadata = { + "schema_version": 1, + "capture_id": capture_id, + "captured_at_utc": now.isoformat().replace("+00:00", "Z"), + "device_id_hash": device_hash, + "device_class": args.device_class, + "model": _property(args.hdc, serial, "const.product.model", "unknown"), + "os_version": _property(args.hdc, serial, "const.ohos.fullname", "unknown"), + "cpu_abi": _property(args.hdc, serial, "const.product.cpu.abilist", "unknown"), + "scenario_id": args.scenario_id, + "scenario_category": args.scenario_category, + "sample_rate_hz": args.sample_rate_hz, + "sdk_version": "0.3.0", + "parameter_set_version": "game-p3-v1", + "shadow_runtime_enabled": True, + "source": "hilog_aggregate_only", + "synthetic": False, + "notes": args.notes, + } + print(f"Capturing {args.duration_seconds}s for {capture_id}; run the scenario now...", flush=True) + filtered_log, _ = capture_hilog(args.hdc, serial, args.duration_seconds) + except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc: + parser.error(str(exc)) + + capture_dir = args.output_dir / capture_id + capture_dir.mkdir(parents=True, exist_ok=False) + (capture_dir / "shadow.hilog").write_text(filtered_log, encoding="utf-8") + (capture_dir / "metadata.json").write_text( + json.dumps(metadata, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + try: + snapshots = parse_snapshots(filtered_log.splitlines()) + summary = build_summary(metadata, snapshots) + except ValueError as exc: + parser.error(f"{exc}; raw filtered capture saved to {capture_dir}") + (capture_dir / "device_shadow_summary.json").write_text( + json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + (capture_dir / "device_shadow_summary.md").write_text(render_markdown(summary), encoding="utf-8") + print(capture_dir) + return 0 if summary["capture_pass"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/audio_haptics_eval/datasets/README.md b/tools/audio_haptics_eval/datasets/README.md new file mode 100644 index 00000000..043d381a --- /dev/null +++ b/tools/audio_haptics_eval/datasets/README.md @@ -0,0 +1,14 @@ +# Real-world dataset boundary + +Audio under test may be copyrighted, private, or licensed only for internal +evaluation. Do not commit it to this repository and do not upload raw PCM as +telemetry. + +Copy `manifest.template.csv` to a local dataset directory, store WAV and label +files below that directory, document the rights basis, and calculate SHA-256 +for both files. `validate_dataset.py` rejects missing rights metadata, path +escapes, format mismatches, unsorted labels, count mismatches, and hash changes. + +Only manifest metadata, aggregate metrics, and event-level differences should +be shared by default. Redistributable audio requires an explicit `yes` in the +manifest and independent confirmation that the stated rights permit it. diff --git a/tools/audio_haptics_eval/datasets/manifest.template.csv b/tools/audio_haptics_eval/datasets/manifest.template.csv new file mode 100644 index 00000000..aea754eb --- /dev/null +++ b/tools/audio_haptics_eval/datasets/manifest.template.csv @@ -0,0 +1,2 @@ +case_id,wav,labels,category,channels,sample_rate,duration_seconds,label_count,description,dataset_kind,rights,redistributable,critical,expected_haptic,split,audio_sha256,labels_sha256 +game_impact_001,local/game_impact_001.wav,local/game_impact_001.labels.csv,game/strong-transient,2,48000,10.000,3,Licensed gameplay impact excerpt,real_world,REPLACE_WITH_RIGHTS_BASIS,no,yes,transient,test,REPLACE_WITH_SHA256,REPLACE_WITH_SHA256 diff --git a/tools/audio_haptics_eval/device_shadow_gate.py b/tools/audio_haptics_eval/device_shadow_gate.py new file mode 100644 index 00000000..b31397df --- /dev/null +++ b/tools/audio_haptics_eval/device_shadow_gate.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Build the real-device admission report from device shadow summaries.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any, Iterable + + +DEFAULT_REQUIRED_SCENARIOS = ( + "game_strong_transient", + "continuous_low_frequency", + "music", + "speech", + "silence_noise_floor", + "stream_reconnect", +) + + +def build_gate_report( + summaries: Iterable[dict[str, Any]], + required_scenarios: Iterable[str] = DEFAULT_REQUIRED_SCENARIOS, + minimum_device_classes: int = 2, +) -> dict[str, Any]: + captures = list(summaries) + device_classes = sorted({item["metadata"]["device_class"] for item in captures}) + device_ids = sorted({item["metadata"]["device_id_hash"] for item in captures}) + scenarios = sorted({item["metadata"]["scenario_category"] for item in captures}) + required = sorted(set(required_scenarios)) + scenarios_by_device_class = { + device_class: sorted({ + item["metadata"]["scenario_category"] + for item in captures + if item["metadata"]["device_class"] == device_class + }) + for device_class in device_classes + } + missing_scenarios_by_device_class = { + device_class: sorted(set(required) - set(device_scenarios)) + for device_class, device_scenarios in scenarios_by_device_class.items() + if set(required) - set(device_scenarios) + } + failed_capture_ids = [ + item["metadata"]["capture_id"] for item in captures if not item.get("capture_pass", False) + ] + synthetic_capture_ids = [ + item["metadata"]["capture_id"] for item in captures if item["metadata"].get("synthetic", True) + ] + + gates = { + "minimum_two_device_classes": { + "pass": len(device_classes) >= minimum_device_classes, + "actual": len(device_classes), + "required": minimum_device_classes, + }, + "minimum_two_distinct_devices": { + "pass": len(device_ids) >= minimum_device_classes, + "actual": len(device_ids), + "required": minimum_device_classes, + }, + "required_scenario_coverage_per_device_class": { + "pass": bool(device_classes) and not missing_scenarios_by_device_class, + "missing_by_device_class": missing_scenarios_by_device_class, + }, + "all_capture_gates_pass": { + "pass": not failed_capture_ids and bool(captures), + "failed_capture_ids": failed_capture_ids, + }, + "real_device_data_only": { + "pass": not synthetic_capture_ids and bool(captures), + "synthetic_capture_ids": synthetic_capture_ids, + }, + } + return { + "schema_version": 1, + "capture_count": len(captures), + "distinct_device_count": len(device_ids), + "device_classes": device_classes, + "scenarios_by_device_class": scenarios_by_device_class, + "scenario_categories": scenarios, + "required_scenario_categories": required, + "gates": gates, + "admission_pass": all(gate["pass"] for gate in gates.values()), + } + + +def render_markdown(report: dict[str, Any]) -> str: + lines = [ + "# Audio haptics real-device admission report", + "", + f"Overall: **{'PASS' if report['admission_pass'] else 'BLOCKED'}**", + "", + f"- Captures: {report['capture_count']}", + f"- Distinct devices: {report['distinct_device_count']}", + f"- Device classes: {', '.join(report['device_classes']) or '(none)'}", + f"- Scenario categories: {', '.join(report['scenario_categories']) or '(none)'}", + "", + "## Gates", + "", + ] + for name, gate in report["gates"].items(): + details = {key: value for key, value in gate.items() if key != "pass"} + suffix = f" — `{json.dumps(details, ensure_ascii=False)}`" if details else "" + lines.append(f"- {'PASS' if gate['pass'] else 'BLOCKED'}: `{name}`{suffix}") + lines.extend([ + "", + "> This report is an engineering admission gate. Product rollout still requires internal experience review and rollback verification.", + "", + ]) + return "\n".join(lines) + + +def _expand_inputs(paths: list[Path]) -> list[Path]: + expanded: list[Path] = [] + for path in paths: + if path.is_dir(): + expanded.extend(sorted(path.rglob("device_shadow_summary.json"))) + else: + expanded.append(path) + return expanded + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("inputs", type=Path, nargs="+", help="Summary JSON files or directories") + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument( + "--required-scenario", + action="append", + dest="required_scenarios", + help="Override the default scenario set; repeat for each required category", + ) + parser.add_argument("--minimum-device-classes", type=int, default=2) + args = parser.parse_args() + + input_paths = _expand_inputs(args.inputs) + if not input_paths: + parser.error("no device_shadow_summary.json files found") + summaries = [json.loads(path.read_text(encoding="utf-8")) for path in input_paths] + report = build_gate_report( + summaries, + required_scenarios=args.required_scenarios or DEFAULT_REQUIRED_SCENARIOS, + minimum_device_classes=args.minimum_device_classes, + ) + + args.output_dir.mkdir(parents=True, exist_ok=True) + json_path = args.output_dir / "device_shadow_admission.json" + md_path = args.output_dir / "device_shadow_admission.md" + json_path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + md_path.write_text(render_markdown(report), encoding="utf-8") + print(json_path) + print(md_path) + return 0 if report["admission_pass"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/audio_haptics_eval/device_shadow_log.py b/tools/audio_haptics_eval/device_shadow_log.py new file mode 100644 index 00000000..e11c6c0b --- /dev/null +++ b/tools/audio_haptics_eval/device_shadow_log.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Parse aggregate-only HarmonyOS audio haptics shadow HiLog snapshots.""" + +from __future__ import annotations + +import argparse +import json +import math +import re +from pathlib import Path +from typing import Any, Iterable + + +PRIMARY_RE = re.compile( + r"blocks=(?P\d+)\s+frames=(?P\d+)\s+" + r"ref=(?P\d+)\s+sdk=(?P\d+)\s+" + r"matched=(?P\d+)\s+refOnly=(?P\d+)\s+" + r"sdkOnly=(?P\d+)\s+pending=(?P\d+)/(?P\d+)\s+" + r"(?:totalUs=(?P\d+)\s+)?" + r"meanUs=(?P\d+)\s+maxUs=(?P\d+)\s+" + r"errors=(?P\d+)" +) + +HISTOGRAM_RE = re.compile( + r"latencyBucketsUs\s+le50=(?P\d+)\s+le100=(?P\d+)\s+" + r"le200=(?P\d+)\s+le500=(?P\d+)\s+" + r"le1000=(?P\d+)\s+gt1000=(?P\d+)\s+" + r"matchDeltaSumUs=(?P-?\d+)\s+" + r"matchDeltaAbsMaxUs=(?P\d+)" +) + +REQUIRED_METADATA_FIELDS = ( + "schema_version", + "capture_id", + "captured_at_utc", + "device_id_hash", + "device_class", + "model", + "os_version", + "cpu_abi", + "scenario_id", + "scenario_category", + "sample_rate_hz", + "sdk_version", + "parameter_set_version", + "shadow_runtime_enabled", + "source", + "synthetic", +) + +HISTOGRAM_BUCKETS = ( + ("le50", 50), + ("le100", 100), + ("le200", 200), + ("le500", 500), + ("le1000", 1000), + ("gt1000", None), +) + + +def _ints(match: re.Match[str]) -> dict[str, int]: + return {key: int(value) for key, value in match.groupdict().items() if value is not None} + + +def parse_snapshots(lines: Iterable[str]) -> list[dict[str, Any]]: + """Return complete primary/histogram snapshot pairs from the final run.""" + snapshots: list[dict[str, Any]] = [] + pending: dict[str, int] | None = None + + for line in lines: + if "[HAPTICS_SHADOW]" not in line: + continue + primary_match = PRIMARY_RE.search(line) + if primary_match: + pending = _ints(primary_match) + continue + histogram_match = HISTOGRAM_RE.search(line) + if histogram_match and pending is not None: + snapshot: dict[str, Any] = dict(pending) + snapshot["latency_buckets_us"] = _ints(histogram_match) + if snapshots and snapshot["blocks"] < snapshots[-1]["blocks"]: + # Runtime re-enable resets cumulative counters. Only the final run + # is eligible for a capture summary. + snapshots.clear() + snapshots.append(snapshot) + pending = None + + return snapshots + + +def p99_bucket_upper_bound(histogram: dict[str, int], total: int) -> int | None: + """Return the inclusive P99 bucket upper bound, or None for the open bucket.""" + if total <= 0: + return None + target = math.ceil(total * 0.99) + cumulative = 0 + for name, upper_bound in HISTOGRAM_BUCKETS: + cumulative += histogram[name] + if cumulative >= target: + return upper_bound + return None + + +def validate_metadata(metadata: dict[str, Any]) -> list[str]: + errors = [f"missing metadata field: {name}" for name in REQUIRED_METADATA_FIELDS if name not in metadata] + if errors: + return errors + if metadata["schema_version"] != 1: + errors.append("metadata schema_version must be 1") + if not re.fullmatch(r"[0-9a-f]{12}", str(metadata["device_id_hash"])): + errors.append("device_id_hash must be 12 lowercase hexadecimal characters") + if metadata["shadow_runtime_enabled"] is not True: + errors.append("shadow_runtime_enabled must be true") + if metadata["source"] not in ("hilog_aggregate_only", "android_logcat_aggregate_only"): + errors.append("source must be an aggregate-only HarmonyOS or Android log source") + return errors + + +def build_summary(metadata: dict[str, Any], snapshots: list[dict[str, Any]]) -> dict[str, Any]: + metadata_errors = validate_metadata(metadata) + if not snapshots: + raise ValueError("no complete [HAPTICS_SHADOW] snapshot pair found") + + final = snapshots[-1] + metrics = dict(final) + metrics["latency_buckets_us"] = dict(final["latency_buckets_us"]) + measurement_mode = "cumulative_single_snapshot" + if len(snapshots) >= 2: + baseline = snapshots[0] + if final["blocks"] <= baseline["blocks"]: + raise ValueError("no new audio blocks between the first and final shadow snapshots") + measurement_mode = "snapshot_delta" + cumulative_fields = ( + "blocks", + "frames", + "reference_events", + "sdk_events", + "matched_events", + "reference_only_events", + "sdk_only_events", + "process_errors", + ) + for field in cumulative_fields: + metrics[field] = final[field] - baseline[field] + for name, _ in HISTOGRAM_BUCKETS: + metrics["latency_buckets_us"][name] = ( + final["latency_buckets_us"][name] - baseline["latency_buckets_us"][name] + ) + metrics["latency_buckets_us"]["match_delta_sum_us"] = ( + final["latency_buckets_us"]["match_delta_sum_us"] + - baseline["latency_buckets_us"]["match_delta_sum_us"] + ) + if "total_process_us" in final and "total_process_us" in baseline: + metrics["total_process_us"] = final["total_process_us"] - baseline["total_process_us"] + metrics["mean_process_us"] = metrics["total_process_us"] // metrics["blocks"] + else: + # Backward-compatible estimate for logs produced before totalUs was + # added. Each cumulative mean may contain up to one microsecond of + # integer truncation error. + estimated_total = ( + final["mean_process_us"] * final["blocks"] + - baseline["mean_process_us"] * baseline["blocks"] + ) + metrics["mean_process_us"] = estimated_total // metrics["blocks"] + + histogram = metrics["latency_buckets_us"] + histogram_total = sum(histogram[name] for name, _ in HISTOGRAM_BUCKETS) + histogram_nonnegative = all(histogram[name] >= 0 for name, _ in HISTOGRAM_BUCKETS) + histogram_consistent = histogram_nonnegative and histogram_total == metrics["blocks"] + p99_upper = p99_bucket_upper_bound(histogram, histogram_total) if histogram_consistent else None + p99_gate_pass = histogram_consistent and p99_upper is not None and p99_upper <= 500 + mean_delta = ( + histogram["match_delta_sum_us"] / metrics["matched_events"] + if metrics["matched_events"] + else None + ) + sample_rate = int(metadata["sample_rate_hz"]) + + summary = { + "schema_version": 1, + "metadata": metadata, + "snapshot_count": len(snapshots), + "measurement_mode": measurement_mode, + "duration_seconds_from_frames": metrics["frames"] / sample_rate if sample_rate > 0 else None, + "metrics": metrics, + "session_final_metrics": final, + "derived": { + "histogram_total": histogram_total, + "histogram_consistent": histogram_consistent, + "p99_process_us_upper_bound": p99_upper, + "p99_process_us_display": f"<={p99_upper}" if p99_upper is not None else ">1000 or unavailable", + "mean_match_delta_us": mean_delta, + }, + "gates": { + "metadata_valid": {"pass": not metadata_errors, "details": metadata_errors}, + "histogram_consistent": {"pass": histogram_consistent}, + "process_errors_zero": {"pass": metrics["process_errors"] == 0}, + "p99_process_le_500_us": {"pass": p99_gate_pass}, + }, + } + summary["capture_pass"] = all(gate["pass"] for gate in summary["gates"].values()) + return summary + + +def render_markdown(summary: dict[str, Any]) -> str: + metadata = summary["metadata"] + metrics = summary["metrics"] + derived = summary["derived"] + lines = [ + f"# Device shadow capture: {metadata['capture_id']}", + "", + f"- Result: **{'PASS' if summary['capture_pass'] else 'BLOCKED'}**", + f"- Device class/model: `{metadata['device_class']}` / `{metadata['model']}`", + f"- OS/ABI: `{metadata['os_version']}` / `{metadata['cpu_abi']}`", + f"- Scenario: `{metadata['scenario_category']}` / `{metadata['scenario_id']}`", + f"- SDK/parameters: `{metadata['sdk_version']}` / `{metadata['parameter_set_version']}`", + f"- Synthetic: `{str(metadata['synthetic']).lower()}`", + f"- Measurement mode: `{summary['measurement_mode']}`", + "", + "| Metric | Value |", + "|---|---:|", + f"| Blocks | {metrics['blocks']} |", + f"| Frames | {metrics['frames']} |", + f"| Mean process time | {metrics['mean_process_us']} us |", + f"| Session maximum process time (informational) | {metrics['max_process_us']} us |", + f"| P99 bucket upper bound | {derived['p99_process_us_display']} us |", + f"| Process errors | {metrics['process_errors']} |", + f"| Reference / SDK events | {metrics['reference_events']} / {metrics['sdk_events']} |", + f"| Matched / reference-only / SDK-only | {metrics['matched_events']} / {metrics['reference_only_events']} / {metrics['sdk_only_events']} |", + "", + "## Gates", + "", + ] + for name, gate in summary["gates"].items(): + lines.append(f"- {'PASS' if gate['pass'] else 'BLOCKED'}: `{name}`") + lines.extend([ + "", + "> Window metrics are the final snapshot minus the first snapshot. P99 is derived conservatively from the fixed histogram; the session maximum is informational only.", + "", + ]) + return "\n".join(lines) + + +def parse_capture(log_path: Path, metadata_path: Path) -> dict[str, Any]: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + snapshots = parse_snapshots(log_path.read_text(encoding="utf-8", errors="replace").splitlines()) + return build_summary(metadata, snapshots) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--log", type=Path, required=True, help="Raw aggregate-only HiLog capture") + parser.add_argument("--metadata", type=Path, required=True, help="Capture metadata JSON") + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args() + + try: + summary = parse_capture(args.log, args.metadata) + except (OSError, ValueError, json.JSONDecodeError) as exc: + parser.error(str(exc)) + + args.output_dir.mkdir(parents=True, exist_ok=True) + json_path = args.output_dir / "device_shadow_summary.json" + md_path = args.output_dir / "device_shadow_summary.md" + json_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + md_path.write_text(render_markdown(summary), encoding="utf-8") + print(json_path) + print(md_path) + return 0 if summary["capture_pass"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/audio_haptics_eval/device_shadow_metadata.schema.json b/tools/audio_haptics_eval/device_shadow_metadata.schema.json new file mode 100644 index 00000000..b8816434 --- /dev/null +++ b/tools/audio_haptics_eval/device_shadow_metadata.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://moonlight-stream.org/schemas/audio-haptics-device-shadow-metadata-v1.json", + "title": "Moonlight audio haptics device shadow capture metadata", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "capture_id", + "captured_at_utc", + "device_id_hash", + "device_class", + "model", + "os_version", + "cpu_abi", + "scenario_id", + "scenario_category", + "sample_rate_hz", + "sdk_version", + "parameter_set_version", + "shadow_runtime_enabled", + "source", + "synthetic" + ], + "properties": { + "schema_version": { "const": 1 }, + "capture_id": { "type": "string", "minLength": 1 }, + "captured_at_utc": { "type": "string", "format": "date-time" }, + "device_id_hash": { "type": "string", "pattern": "^[0-9a-f]{12}$" }, + "device_class": { "type": "string", "minLength": 1 }, + "model": { "type": "string", "minLength": 1 }, + "os_version": { "type": "string", "minLength": 1 }, + "cpu_abi": { "type": "string", "minLength": 1 }, + "scenario_id": { "type": "string", "minLength": 1 }, + "scenario_category": { + "enum": [ + "game_strong_transient", + "continuous_low_frequency", + "music", + "speech", + "silence_noise_floor", + "stream_reconnect" + ] + }, + "sample_rate_hz": { "type": "integer", "minimum": 8000 }, + "sdk_version": { "type": "string", "minLength": 1 }, + "parameter_set_version": { "type": "string", "minLength": 1 }, + "shadow_runtime_enabled": { "const": true }, + "source": { + "enum": ["hilog_aggregate_only", "android_logcat_aggregate_only"] + }, + "synthetic": { "type": "boolean" }, + "notes": { "type": "string" } + } +} diff --git a/tools/audio_haptics_eval/device_shadow_metadata.template.json b/tools/audio_haptics_eval/device_shadow_metadata.template.json new file mode 100644 index 00000000..6012f9ab --- /dev/null +++ b/tools/audio_haptics_eval/device_shadow_metadata.template.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "capture_id": "device-class_scenario_YYYYMMDDTHHMMSSZ", + "captured_at_utc": "2026-07-15T00:00:00Z", + "device_id_hash": "000000000000", + "device_class": "phone_performance", + "model": "replace-with-device-model", + "os_version": "replace-with-HarmonyOS-version", + "cpu_abi": "arm64-v8a", + "scenario_id": "replace-with-reproducible-scenario-name", + "scenario_category": "game_strong_transient", + "sample_rate_hz": 48000, + "sdk_version": "0.3.0", + "parameter_set_version": "game-p3-v1", + "shadow_runtime_enabled": true, + "source": "hilog_aggregate_only", + "synthetic": false, + "notes": "Do not put a raw device serial, account, IP address, title, or PCM content here." +} diff --git a/tools/audio_haptics_eval/fixtures/README.md b/tools/audio_haptics_eval/fixtures/README.md new file mode 100644 index 00000000..da94b35d --- /dev/null +++ b/tools/audio_haptics_eval/fixtures/README.md @@ -0,0 +1,28 @@ +# P0 deterministic fixtures + +The fixtures are generated instead of committed as binary WAV files. Run: + +```bash +python tools/audio_haptics_eval/generate_fixtures.py +``` + +The generator always uses 48 kHz signed PCM16 and fixed random seeds. + +| Case | Type | Purpose | +|---|---|---| +| `impulse_train_mono` | Positive | Broadband transient timing and recall | +| `kick_train_stereo` | Positive | Low-frequency impact and stereo input | +| `antiphase_impulses_stereo` | Edge | Exposes cancellation caused by waveform downmix | +| `silence_then_hit_mono` | Edge | Detector state recovery after digital silence | +| `steady_tone_stereo` | Negative | False triggers on continuous low-frequency audio | +| `speech_like_mono` | Negative | False triggers on deterministic speech-like audio | + +Each WAV has a sibling `*.labels.csv` file with this schema: + +```text +time_ms,event_type,importance +``` + +Generated fixtures are local test artifacts and are ignored by Git. They are a +smoke-test baseline, not a replacement for the licensed real-world evaluation +set required before the aubio removal gate. diff --git a/tools/audio_haptics_eval/generate_fixtures.py b/tools/audio_haptics_eval/generate_fixtures.py new file mode 100644 index 00000000..27927bea --- /dev/null +++ b/tools/audio_haptics_eval/generate_fixtures.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Generate deterministic PCM16 fixtures and onset labels for P0 evaluation.""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import math +import random +import wave +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Sequence + + +SAMPLE_RATE = 48_000 +PCM_MAX = 32_767 + + +@dataclass(frozen=True) +class Case: + case_id: str + category: str + description: str + channels: int + duration_seconds: float + labels_ms: tuple[float, ...] + renderer: Callable[[int, int], Sequence[float]] + + +def clamp(value: float) -> float: + return max(-1.0, min(1.0, value)) + + +def add_mono_burst( + signal: list[float], + start_ms: float, + duration_ms: float, + sample_fn: Callable[[float, int], float], +) -> None: + start = round(start_ms * SAMPLE_RATE / 1000.0) + length = round(duration_ms * SAMPLE_RATE / 1000.0) + for local_index in range(length): + index = start + local_index + if index >= len(signal): + break + t = local_index / SAMPLE_RATE + signal[index] += sample_fn(t, local_index) + + +def render_impulse_train(frame_count: int, channels: int) -> Sequence[float]: + signal = [0.0] * frame_count + rng = random.Random(10_001) + for event_ms in (500, 1000, 1500, 2000, 2500): + noise = [rng.uniform(-1.0, 1.0) for _ in range(round(0.012 * SAMPLE_RATE))] + + def click(t: float, index: int, source=noise) -> float: + return 0.90 * math.exp(-t / 0.0035) * source[index] + + add_mono_burst(signal, event_ms, 12.0, click) + return signal + + +def render_kick_train(frame_count: int, channels: int) -> Sequence[float]: + left = [0.0] * frame_count + right = [0.0] * frame_count + for event_index, event_ms in enumerate((500, 1000, 1500, 2250, 3000, 3500)): + pan = -0.35 if event_index % 2 == 0 else 0.35 + + def kick(t: float, _: int) -> float: + frequency = 78.0 - 32.0 * min(t / 0.12, 1.0) + body = math.sin(2.0 * math.pi * frequency * t) * math.exp(-t / 0.055) + attack = math.sin(2.0 * math.pi * 1100.0 * t) * math.exp(-t / 0.0025) + return 0.78 * body + 0.20 * attack + + mono = [0.0] * frame_count + add_mono_burst(mono, event_ms, 160.0, kick) + for i, value in enumerate(mono): + left[i] += value * (1.0 - max(0.0, pan)) + right[i] += value * (1.0 + min(0.0, pan)) + interleaved: list[float] = [] + for l_value, r_value in zip(left, right): + interleaved.extend((l_value, r_value)) + return interleaved + + +def render_antiphase(frame_count: int, channels: int) -> Sequence[float]: + mono = [0.0] * frame_count + rng = random.Random(20_002) + for event_ms in (500, 1000, 1500, 2000): + noise = [rng.uniform(-1.0, 1.0) for _ in range(round(0.010 * SAMPLE_RATE))] + + def click(t: float, index: int, source=noise) -> float: + return 0.82 * math.exp(-t / 0.003) * source[index] + + add_mono_burst(mono, event_ms, 10.0, click) + interleaved: list[float] = [] + for value in mono: + interleaved.extend((value, -value)) + return interleaved + + +def render_silence_then_hit(frame_count: int, channels: int) -> Sequence[float]: + signal = [0.0] * frame_count + rng = random.Random(30_003) + noise = [rng.uniform(-1.0, 1.0) for _ in range(round(0.080 * SAMPLE_RATE))] + + def impact(t: float, index: int) -> float: + low = math.sin(2.0 * math.pi * 55.0 * t) * math.exp(-t / 0.050) + attack = noise[index] * math.exp(-t / 0.004) + return 0.75 * low + 0.30 * attack + + add_mono_burst(signal, 2000.0, 80.0, impact) + return signal + + +def render_steady_tone(frame_count: int, channels: int) -> Sequence[float]: + interleaved: list[float] = [] + for index in range(frame_count): + t = index / SAMPLE_RATE + gain = min(1.0, t / 0.30) * 0.22 + value = gain * math.sin(2.0 * math.pi * 60.0 * t) + interleaved.extend((value, value)) + return interleaved + + +def render_speech_like(frame_count: int, channels: int) -> Sequence[float]: + signal: list[float] = [] + rng = random.Random(40_004) + for index in range(frame_count): + t = index / SAMPLE_RATE + syllable_phase = t % 0.42 + envelope = 0.0 + if syllable_phase < 0.26: + attack = min(1.0, syllable_phase / 0.025) + release = min(1.0, (0.26 - syllable_phase) / 0.060) + envelope = min(attack, release) + pitch = 125.0 + 12.0 * math.sin(2.0 * math.pi * 0.8 * t) + voiced = ( + math.sin(2.0 * math.pi * pitch * t) + + 0.45 * math.sin(2.0 * math.pi * pitch * 2.0 * t) + + 0.22 * math.sin(2.0 * math.pi * pitch * 3.0 * t) + ) + noise = rng.uniform(-1.0, 1.0) * 0.04 + signal.append(0.16 * envelope * voiced + envelope * noise) + return signal + + +CASES = ( + Case( + "impulse_train_mono", + "positive/transient", + "Five broadband decaying clicks with clean spacing", + 1, + 3.0, + (500, 1000, 1500, 2000, 2500), + render_impulse_train, + ), + Case( + "kick_train_stereo", + "positive/low-frequency", + "Six low-frequency kick bursts with alternating stereo pan", + 2, + 4.0, + (500, 1000, 1500, 2250, 3000, 3500), + render_kick_train, + ), + Case( + "antiphase_impulses_stereo", + "edge/phase-cancellation", + "Four clicks with right channel exactly inverted; exposes waveform downmix cancellation", + 2, + 2.6, + (500, 1000, 1500, 2000), + render_antiphase, + ), + Case( + "silence_then_hit_mono", + "edge/state-recovery", + "One impact after two seconds of digital silence", + 1, + 3.0, + (2000,), + render_silence_then_hit, + ), + Case( + "steady_tone_stereo", + "negative/continuous", + "Slow fade-in 60 Hz tone with no annotated transient", + 2, + 3.0, + (), + render_steady_tone, + ), + Case( + "speech_like_mono", + "negative/speech-like", + "Deterministic voiced syllable-like signal with no haptic onset labels", + 1, + 4.0, + (), + render_speech_like, + ), +) + + +def float_to_pcm16(value: float) -> int: + return round(clamp(value) * PCM_MAX) + + +def write_wav(path: Path, case: Case) -> None: + frame_count = round(case.duration_seconds * SAMPLE_RATE) + samples = case.renderer(frame_count, case.channels) + expected_sample_count = frame_count * case.channels + if len(samples) != expected_sample_count: + raise ValueError( + f"{case.case_id}: renderer returned {len(samples)} samples, " + f"expected {expected_sample_count}" + ) + pcm = bytearray() + for sample in samples: + pcm.extend(float_to_pcm16(sample).to_bytes(2, "little", signed=True)) + with wave.open(str(path), "wb") as output: + output.setnchannels(case.channels) + output.setsampwidth(2) + output.setframerate(SAMPLE_RATE) + output.writeframes(pcm) + + +def write_labels(path: Path, labels_ms: Sequence[float]) -> None: + with path.open("w", newline="", encoding="utf-8") as output: + writer = csv.writer(output, lineterminator="\n") + writer.writerow(("time_ms", "event_type", "importance")) + for time_ms in labels_ms: + writer.writerow((f"{time_ms:.3f}", "onset", "1.0")) + + +def generate(output_dir: Path) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + manifest_rows = [] + for case in CASES: + wav_name = f"{case.case_id}.wav" + labels_name = f"{case.case_id}.labels.csv" + write_wav(output_dir / wav_name, case) + write_labels(output_dir / labels_name, case.labels_ms) + wav_sha256 = hashlib.sha256((output_dir / wav_name).read_bytes()).hexdigest() + labels_sha256 = hashlib.sha256((output_dir / labels_name).read_bytes()).hexdigest() + expected_haptic = "none" if case.category.startswith("negative/") else "transient" + critical = "yes" if expected_haptic == "transient" else "no" + manifest_rows.append( + ( + case.case_id, + wav_name, + labels_name, + case.category, + case.channels, + SAMPLE_RATE, + f"{case.duration_seconds:.3f}", + len(case.labels_ms), + case.description, + "synthetic", + "project-generated", + "yes", + critical, + expected_haptic, + "test", + wav_sha256, + labels_sha256, + ) + ) + + with (output_dir / "manifest.csv").open("w", newline="", encoding="utf-8") as output: + writer = csv.writer(output, lineterminator="\n") + writer.writerow( + ( + "case_id", + "wav", + "labels", + "category", + "channels", + "sample_rate", + "duration_seconds", + "label_count", + "description", + "dataset_kind", + "rights", + "redistributable", + "critical", + "expected_haptic", + "split", + "audio_sha256", + "labels_sha256", + ) + ) + writer.writerows(manifest_rows) + + print(f"Generated {len(CASES)} deterministic fixtures in {output_dir.resolve()}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--output-dir", + type=Path, + default=Path(__file__).resolve().parent / "fixtures" / "generated", + ) + args = parser.parse_args() + generate(args.output_dir) + + +if __name__ == "__main__": + main() diff --git a/tools/audio_haptics_eval/run_baseline.py b/tools/audio_haptics_eval/run_baseline.py new file mode 100644 index 00000000..2889c37d --- /dev/null +++ b/tools/audio_haptics_eval/run_baseline.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Build the evaluator, validate a dataset, and run offline shadow A/B.""" + +from __future__ import annotations + +import argparse +import csv +import json +import shutil +import subprocess +import sys +from pathlib import Path + +from shadow_compare import generate_shadow_report +from validate_dataset import validate_manifest + + +TOOL_DIR = Path(__file__).resolve().parent + + +def run(command: list[str]) -> None: + print("+", subprocess.list2cmdline(command), flush=True) + subprocess.run(command, check=True) + + +def build_evaluator(build_dir: Path) -> Path: + cmake = shutil.which("cmake") + if cmake is None: + raise RuntimeError("cmake was not found in PATH") + configure = [ + cmake, + "-S", + str(TOOL_DIR), + "-B", + str(build_dir), + "-G", + "Ninja", + "-DCMAKE_BUILD_TYPE=Release", + ] + run(configure) + run([cmake, "--build", str(build_dir), "--config", "Release"]) + + candidates = ( + build_dir / "audio_haptics_eval.exe", + build_dir / "audio_haptics_eval", + build_dir / "Release" / "audio_haptics_eval.exe", + ) + for candidate in candidates: + if candidate.exists(): + return candidate + raise RuntimeError(f"evaluator executable not found under {build_dir}") + + +def generate_fixtures(fixtures_dir: Path) -> None: + run( + [ + sys.executable, + str(TOOL_DIR / "generate_fixtures.py"), + "--output-dir", + str(fixtures_dir), + ] + ) + + +def run_case( + executable: Path, + fixtures_dir: Path, + output_dir: Path, + row: dict[str, str], + backend: str, + runs: int, + warmup_runs: int, +) -> dict: + case_id = row["case_id"] + case_output = output_dir / case_id + case_output.mkdir(parents=True, exist_ok=True) + summary_path = case_output / "summary.json" + command = [ + str(executable), + "--input", + str(fixtures_dir / row["wav"]), + "--labels", + str(fixtures_dir / row["labels"]), + "--backend", + backend, + "--events-out", + str(case_output / "events.csv"), + "--summary-out", + str(summary_path), + "--runs", + str(runs), + "--warmup-runs", + str(warmup_runs), + ] + run(command) + with summary_path.open(encoding="utf-8") as input_file: + summary = json.load(input_file) + summary["case"] = row + return summary + + +def write_aggregate(output_dir: Path, summaries: list[dict]) -> None: + aggregate_json = output_dir / "baseline.json" + with aggregate_json.open("w", encoding="utf-8") as output: + json.dump( + {"schema_version": 1, "cases": summaries}, + output, + ensure_ascii=False, + indent=2, + ) + output.write("\n") + + fields = ( + "case_id", + "category", + "backend", + "labels", + "events", + "true_positive", + "false_positive", + "false_negative", + "precision", + "recall", + "f1", + "median_abs_error_ms", + "p95_abs_error_ms", + "call_p50_us", + "call_p95_us", + "call_p99_us", + "call_max_us", + "realtime_factor", + ) + with (output_dir / "baseline.csv").open("w", newline="", encoding="utf-8") as output: + writer = csv.DictWriter(output, fieldnames=fields, lineterminator="\n") + writer.writeheader() + for summary in summaries: + for backend in summary["backends"]: + metrics = backend["metrics"] + benchmark = backend["benchmark"] + writer.writerow( + { + "case_id": summary["case"]["case_id"], + "category": summary["case"]["category"], + "backend": backend["name"], + "labels": summary["label_count"], + "events": backend["event_count"], + "true_positive": metrics["true_positive"], + "false_positive": metrics["false_positive"], + "false_negative": metrics["false_negative"], + "precision": metrics["precision"], + "recall": metrics["recall"], + "f1": metrics["f1"], + "median_abs_error_ms": metrics["median_abs_error_ms"], + "p95_abs_error_ms": metrics["p95_abs_error_ms"], + "call_p50_us": benchmark["call_p50_us"], + "call_p95_us": benchmark["call_p95_us"], + "call_p99_us": benchmark["call_p99_us"], + "call_max_us": benchmark["call_max_us"], + "realtime_factor": benchmark["realtime_factor"], + } + ) + print(f"Aggregate baseline: {aggregate_json.resolve()}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--build-dir", type=Path, default=TOOL_DIR / "build") + parser.add_argument( + "--fixtures-dir", type=Path, default=TOOL_DIR / "fixtures" / "generated" + ) + parser.add_argument("--output-dir", type=Path, default=TOOL_DIR / "out") + parser.add_argument( + "--manifest", + type=Path, + help="Dataset manifest; defaults to /manifest.csv", + ) + parser.add_argument( + "--backend", + choices=("aubio", "native", "sdk", "both", "all"), + default="all", + ) + parser.add_argument("--runs", type=int, default=30) + parser.add_argument("--warmup-runs", type=int, default=3) + parser.add_argument( + "--skip-build", + action="store_true", + help="Use an existing executable in --build-dir", + ) + parser.add_argument( + "--skip-generate", + action="store_true", + help="Evaluate an existing dataset instead of regenerating fixtures", + ) + args = parser.parse_args() + + if args.runs <= 0 or args.warmup_runs < 0: + parser.error("--runs must be positive and --warmup-runs non-negative") + + executable = ( + args.build_dir / ("audio_haptics_eval.exe" if sys.platform == "win32" else "audio_haptics_eval") + if args.skip_build + else build_evaluator(args.build_dir) + ) + if not executable.exists(): + raise RuntimeError(f"evaluator executable not found: {executable}") + + if not args.skip_generate: + generate_fixtures(args.fixtures_dir) + manifest_path = args.manifest or args.fixtures_dir / "manifest.csv" + validation = validate_manifest(manifest_path) + if not validation["valid"]: + raise RuntimeError("dataset validation failed: " + "; ".join(validation["errors"])) + print( + f"dataset-check: {validation['case_count']} cases, " + f"{validation['real_world_case_count']} real-world, manifest OK" + ) + args.output_dir.mkdir(parents=True, exist_ok=True) + with manifest_path.open(encoding="utf-8", newline="") as input_file: + manifest = list(csv.DictReader(input_file)) + + summaries = [ + run_case( + executable, + manifest_path.parent, + args.output_dir, + row, + args.backend, + args.runs, + args.warmup_runs, + ) + for row in manifest + ] + write_aggregate(args.output_dir, summaries) + if args.backend == "all": + report = generate_shadow_report( + args.output_dir / "baseline.json", + args.output_dir, + manifest_path, + ) + print( + "Shadow gates: algorithm=" + f"{'PASS' if report['algorithm_gates_pass'] else 'FAIL'}, " + "aubio_removal=" + f"{'READY' if report['ready_for_aubio_removal'] else 'BLOCKED'}" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/audio_haptics_eval/shadow_compare.py b/tools/audio_haptics_eval/shadow_compare.py new file mode 100644 index 00000000..b499be74 --- /dev/null +++ b/tools/audio_haptics_eval/shadow_compare.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Create event-level shadow A/B diffs and aubio-removal gate results.""" + +from __future__ import annotations + +import argparse +import csv +import json +from pathlib import Path +from typing import Any + + +def _ratio(numerator: int, denominator: int) -> float: + return numerator / denominator if denominator else 1.0 + + +def _f1(metrics: dict[str, int]) -> float: + tp = metrics["true_positive"] + return _ratio(2 * tp, 2 * tp + metrics["false_positive"] + metrics["false_negative"]) + + +def _backend(case: dict[str, Any], name: str) -> dict[str, Any]: + for backend in case["backends"]: + if backend["name"] == name: + return backend + raise ValueError(f"{case['case']['case_id']}: backend {name!r} is missing") + + +def _sum_metrics(cases: list[dict[str, Any]], backend_name: str) -> dict[str, int]: + totals = {"true_positive": 0, "false_positive": 0, "false_negative": 0} + for case in cases: + metrics = _backend(case, backend_name)["metrics"] + for key in totals: + totals[key] += int(metrics[key]) + return totals + + +def _read_events(path: Path, backend: str) -> list[dict[str, str]]: + with path.open(encoding="utf-8", newline="") as input_file: + return [row for row in csv.DictReader(input_file) if row["backend"] == backend] + + +def _match_events( + reference: list[dict[str, str]], + candidate: list[dict[str, str]], + window_ms: float, +) -> list[dict[str, Any]]: + possible: list[tuple[float, int, int]] = [] + for reference_index, reference_event in enumerate(reference): + for candidate_index, candidate_event in enumerate(candidate): + delta = float(candidate_event["output_ms"]) - float(reference_event["output_ms"]) + if abs(delta) <= window_ms: + possible.append((abs(delta), reference_index, candidate_index)) + possible.sort() + used_reference: set[int] = set() + used_candidate: set[int] = set() + rows: list[dict[str, Any]] = [] + for _, reference_index, candidate_index in possible: + if reference_index in used_reference or candidate_index in used_candidate: + continue + used_reference.add(reference_index) + used_candidate.add(candidate_index) + reference_ms = float(reference[reference_index]["output_ms"]) + candidate_ms = float(candidate[candidate_index]["output_ms"]) + rows.append( + { + "status": "matched", + "reference_ms": reference_ms, + "candidate_ms": candidate_ms, + "delta_ms": candidate_ms - reference_ms, + "reference_descriptor": float(reference[reference_index]["descriptor"]), + "candidate_descriptor": float(candidate[candidate_index]["descriptor"]), + } + ) + for index, event in enumerate(reference): + if index not in used_reference: + rows.append( + { + "status": "reference_only", + "reference_ms": float(event["output_ms"]), + "candidate_ms": None, + "delta_ms": None, + "reference_descriptor": float(event["descriptor"]), + "candidate_descriptor": None, + } + ) + for index, event in enumerate(candidate): + if index not in used_candidate: + rows.append( + { + "status": "candidate_only", + "reference_ms": None, + "candidate_ms": float(event["output_ms"]), + "delta_ms": None, + "reference_descriptor": None, + "candidate_descriptor": float(event["descriptor"]), + } + ) + rows.sort( + key=lambda row: min( + value for value in (row["reference_ms"], row["candidate_ms"]) if value is not None + ) + ) + return rows + + +def generate_shadow_report( + aggregate_path: Path, + output_dir: Path, + manifest_path: Path, + reference_name: str = "aubio", + candidate_name: str = "sdk_core", + event_match_window_ms: float = 50.0, + target_benchmark_count: int = 0, + experience_approved: bool = False, + rollback_verified: bool = False, +) -> dict[str, Any]: + aggregate = json.loads(aggregate_path.read_text(encoding="utf-8")) + cases: list[dict[str, Any]] = aggregate["cases"] + with manifest_path.open(encoding="utf-8", newline="") as input_file: + manifest = {row["case_id"]: row for row in csv.DictReader(input_file)} + + reference_totals = _sum_metrics(cases, reference_name) + candidate_totals = _sum_metrics(cases, candidate_name) + reference_f1 = _f1(reference_totals) + candidate_f1 = _f1(candidate_totals) + + critical_cases = [ + case for case in cases if manifest[case["case"]["case_id"]]["critical"] == "yes" + ] + negative_cases = [ + case + for case in cases + if manifest[case["case"]["case_id"]]["expected_haptic"] == "none" + ] + critical_reference = _sum_metrics(critical_cases, reference_name) + critical_candidate = _sum_metrics(critical_cases, candidate_name) + critical_reference_recall = _ratio( + critical_reference["true_positive"], + critical_reference["true_positive"] + critical_reference["false_negative"], + ) + critical_candidate_recall = _ratio( + critical_candidate["true_positive"], + critical_candidate["true_positive"] + critical_candidate["false_negative"], + ) + negative_reference_fp = sum( + int(_backend(case, reference_name)["metrics"]["false_positive"]) + for case in negative_cases + ) + negative_candidate_fp = sum( + int(_backend(case, candidate_name)["metrics"]["false_positive"]) + for case in negative_cases + ) + positive_cases = [case for case in cases if int(case["label_count"]) > 0] + worst_median_ms = max( + (float(_backend(case, candidate_name)["metrics"]["median_abs_error_ms"]) + for case in positive_cases), + default=0.0, + ) + worst_p95_ms = max( + (float(_backend(case, candidate_name)["metrics"]["p95_abs_error_ms"]) + for case in positive_cases), + default=0.0, + ) + worst_host_p99_us = max( + float(_backend(case, candidate_name)["benchmark"]["call_p99_us"]) + for case in cases + ) + real_world_case_count = sum( + row["dataset_kind"] == "real_world" for row in manifest.values() + ) + + negative_fp_pass = ( + negative_candidate_fp == 0 + if negative_reference_fp == 0 + else negative_candidate_fp <= 1.1 * negative_reference_fp + ) + gates = { + "f1_gap_within_0_02": candidate_f1 >= reference_f1 - 0.02, + "critical_recall_not_lower": critical_candidate_recall >= critical_reference_recall, + "timing_median_at_most_10_ms": worst_median_ms <= 10.0, + "timing_p95_at_most_25_ms": worst_p95_ms <= 25.0, + "negative_false_positives_within_1_1x": negative_fp_pass, + "host_call_p99_at_most_500_us": worst_host_p99_us <= 500.0, + "real_world_dataset_present": real_world_case_count > 0, + "two_target_device_classes_benchmarked": target_benchmark_count >= 2, + "internal_experience_approved": experience_approved, + "rollback_verified": rollback_verified, + } + algorithm_gate_names = ( + "f1_gap_within_0_02", + "critical_recall_not_lower", + "timing_median_at_most_10_ms", + "timing_p95_at_most_25_ms", + "negative_false_positives_within_1_1x", + "host_call_p99_at_most_500_us", + ) + + event_rows: list[dict[str, Any]] = [] + for case in cases: + case_id = case["case"]["case_id"] + events_path = output_dir / case_id / "events.csv" + for row in _match_events( + _read_events(events_path, reference_name), + _read_events(events_path, candidate_name), + event_match_window_ms, + ): + event_rows.append({"case_id": case_id, **row}) + + versions = { + str(case.get("parameter_set_version", "unknown")) for case in cases + } + sdk_versions = {str(case.get("sdk_version", "unknown")) for case in cases} + report = { + "schema_version": 1, + "reference_backend": reference_name, + "candidate_backend": candidate_name, + "sdk_version": next(iter(sdk_versions)) if len(sdk_versions) == 1 else "mixed", + "parameter_set_version": next(iter(versions)) if len(versions) == 1 else "mixed", + "dataset": { + "case_count": len(cases), + "real_world_case_count": real_world_case_count, + }, + "metrics": { + "reference_f1": reference_f1, + "candidate_f1": candidate_f1, + "f1_gap": candidate_f1 - reference_f1, + "critical_reference_recall": critical_reference_recall, + "critical_candidate_recall": critical_candidate_recall, + "negative_reference_false_positives": negative_reference_fp, + "negative_candidate_false_positives": negative_candidate_fp, + "candidate_worst_case_median_ms": worst_median_ms, + "candidate_worst_case_p95_ms": worst_p95_ms, + "candidate_worst_host_call_p99_us": worst_host_p99_us, + "matched_events": sum(row["status"] == "matched" for row in event_rows), + "reference_only_events": sum( + row["status"] == "reference_only" for row in event_rows + ), + "candidate_only_events": sum( + row["status"] == "candidate_only" for row in event_rows + ), + }, + "gates": gates, + "algorithm_gates_pass": all(gates[name] for name in algorithm_gate_names), + "ready_for_aubio_removal": all(gates.values()), + } + + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "shadow_report.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + with (output_dir / "shadow_events.csv").open("w", encoding="utf-8", newline="") as output: + fieldnames = ( + "case_id", + "status", + "reference_ms", + "candidate_ms", + "delta_ms", + "reference_descriptor", + "candidate_descriptor", + ) + writer = csv.DictWriter(output, fieldnames=fieldnames, lineterminator="\n") + writer.writeheader() + writer.writerows(event_rows) + + gate_lines = [ + f"| {name} | {'PASS' if passed else 'BLOCKED'} |" + for name, passed in gates.items() + ] + markdown = f"""# Audio haptics shadow A/B report + +- Reference: `{reference_name}` +- Candidate: `{candidate_name}` +- SDK: `{report['sdk_version']}` +- Parameter set: `{report['parameter_set_version']}` +- Dataset: {len(cases)} cases ({real_world_case_count} real-world) +- Algorithm gates: **{'PASS' if report['algorithm_gates_pass'] else 'FAIL'}** +- Ready for aubio removal: **{'YES' if report['ready_for_aubio_removal'] else 'NO'}** + +| Metric | Reference | Candidate | +|---|---:|---:| +| Aggregate F1 | {reference_f1:.6f} | {candidate_f1:.6f} | +| Critical recall | {critical_reference_recall:.6f} | {critical_candidate_recall:.6f} | +| Negative false positives | {negative_reference_fp} | {negative_candidate_fp} | +| Worst labeled-case median timing | - | {worst_median_ms:.3f} ms | +| Worst labeled-case P95 timing | - | {worst_p95_ms:.3f} ms | +| Worst Host call P99 | - | {worst_host_p99_us:.3f} us | + +## Gates + +| Gate | Result | +|---|---| +{chr(10).join(gate_lines)} + +Raw PCM is not included in this report. Event-level output contains timestamps, +descriptors, and aggregate differences only. +""" + (output_dir / "shadow_report.md").write_text(markdown, encoding="utf-8") + return report + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--aggregate", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--reference", default="aubio") + parser.add_argument("--candidate", default="sdk_core") + parser.add_argument("--event-match-window-ms", type=float, default=50.0) + parser.add_argument("--target-benchmark-count", type=int, default=0) + parser.add_argument("--experience-approved", action="store_true") + parser.add_argument("--rollback-verified", action="store_true") + args = parser.parse_args() + report = generate_shadow_report( + args.aggregate, + args.output_dir, + args.manifest, + args.reference, + args.candidate, + args.event_match_window_ms, + args.target_benchmark_count, + args.experience_approved, + args.rollback_verified, + ) + print( + "shadow-report: algorithm_gates=" + f"{'PASS' if report['algorithm_gates_pass'] else 'FAIL'}, " + "aubio_removal=" + f"{'READY' if report['ready_for_aubio_removal'] else 'BLOCKED'}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/audio_haptics_eval/src/main.cpp b/tools/audio_haptics_eval/src/main.cpp new file mode 100644 index 00000000..039db973 --- /dev/null +++ b/tools/audio_haptics_eval/src/main.cpp @@ -0,0 +1,671 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// P0 host evaluator. This file links the GPL aubio baseline and is not part of +// the future Apache-2.0 SDK. + +#include "aubio_onset_wrapper.h" +#include "sdk_core_onset_wrapper.h" +#include "spectral_onset_detector.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +constexpr int kSchemaVersion = 1; + +struct Options { + fs::path input; + fs::path labels; + fs::path eventsOut; + fs::path summaryOut; + std::string backend = "all"; + int hopSize = 240; + int runs = 30; + int warmupRuns = 3; + double matchWindowMs = 50.0; +}; + +struct WavData { + uint32_t sampleRate = 0; + uint16_t channels = 0; + std::vector samples; + + size_t FrameCount() const { + return channels == 0 ? 0 : samples.size() / channels; + } + + double DurationSeconds() const { + return sampleRate == 0 ? 0.0 : static_cast(FrameCount()) / sampleRate; + } +}; + +struct Label { + double timeMs = 0.0; + std::string eventType; + double importance = 1.0; +}; + +struct Event { + std::string backend; + uint64_t outputSample = 0; + double outputMs = 0.0; + float descriptor = 0.0f; + float thresholdedDescriptor = 0.0f; +}; + +struct Metrics { + size_t truePositive = 0; + size_t falsePositive = 0; + size_t falseNegative = 0; + double precision = 0.0; + double recall = 0.0; + double f1 = 0.0; + double medianAbsErrorMs = 0.0; + double p95AbsErrorMs = 0.0; + double meanSignedErrorMs = 0.0; +}; + +struct Benchmark { + size_t processCalls = 0; + double callP50Us = 0.0; + double callP95Us = 0.0; + double callP99Us = 0.0; + double callMaxUs = 0.0; + double runMedianMs = 0.0; + double realtimeFactor = 0.0; +}; + +struct BackendResult { + std::string name; + std::vector events; + Metrics metrics; + Benchmark benchmark; +}; + +uint16_t ReadLe16(std::istream& in) { + uint8_t bytes[2]{}; + in.read(reinterpret_cast(bytes), sizeof(bytes)); + if (!in) throw std::runtime_error("Unexpected EOF while reading uint16"); + return static_cast(bytes[0]) | + static_cast(static_cast(bytes[1]) << 8); +} + +uint32_t ReadLe32(std::istream& in) { + uint8_t bytes[4]{}; + in.read(reinterpret_cast(bytes), sizeof(bytes)); + if (!in) throw std::runtime_error("Unexpected EOF while reading uint32"); + return static_cast(bytes[0]) | + (static_cast(bytes[1]) << 8) | + (static_cast(bytes[2]) << 16) | + (static_cast(bytes[3]) << 24); +} + +std::string ReadFourCc(std::istream& in) { + char chars[4]{}; + in.read(chars, sizeof(chars)); + if (!in) throw std::runtime_error("Unexpected EOF while reading FourCC"); + return std::string(chars, sizeof(chars)); +} + +WavData ReadPcm16Wav(const fs::path& path) { + std::ifstream in(path, std::ios::binary); + if (!in) throw std::runtime_error("Cannot open WAV: " + path.string()); + + if (ReadFourCc(in) != "RIFF") { + throw std::runtime_error("Only little-endian RIFF WAV is supported"); + } + (void)ReadLe32(in); + if (ReadFourCc(in) != "WAVE") { + throw std::runtime_error("Invalid WAVE header"); + } + + bool haveFmt = false; + bool haveData = false; + uint16_t formatTag = 0; + uint16_t channels = 0; + uint16_t bitsPerSample = 0; + uint16_t blockAlign = 0; + uint32_t sampleRate = 0; + std::vector pcmBytes; + + while (in && !(haveFmt && haveData)) { + const std::string chunkId = ReadFourCc(in); + const uint32_t chunkSize = ReadLe32(in); + const std::streampos chunkStart = in.tellg(); + + if (chunkId == "fmt ") { + if (chunkSize < 16) throw std::runtime_error("Invalid fmt chunk"); + formatTag = ReadLe16(in); + channels = ReadLe16(in); + sampleRate = ReadLe32(in); + (void)ReadLe32(in); + blockAlign = ReadLe16(in); + bitsPerSample = ReadLe16(in); + haveFmt = true; + } else if (chunkId == "data") { + pcmBytes.resize(chunkSize); + if (chunkSize > 0) { + in.read(reinterpret_cast(pcmBytes.data()), chunkSize); + if (!in) throw std::runtime_error("Truncated data chunk"); + } + haveData = true; + } + + const std::streamoff paddedSize = static_cast(chunkSize + (chunkSize & 1u)); + in.clear(); + in.seekg(chunkStart + paddedSize); + } + + if (!haveFmt || !haveData) throw std::runtime_error("WAV is missing fmt or data chunk"); + if (formatTag != 1 || bitsPerSample != 16) { + throw std::runtime_error("Only PCM signed 16-bit WAV is supported"); + } + if (channels == 0 || channels > 8 || sampleRate == 0) { + throw std::runtime_error("Unsupported WAV channel count or sample rate"); + } + if (blockAlign != channels * sizeof(int16_t) || pcmBytes.size() % blockAlign != 0) { + throw std::runtime_error("Invalid PCM block alignment"); + } + + WavData wav; + wav.sampleRate = sampleRate; + wav.channels = channels; + wav.samples.resize(pcmBytes.size() / 2); + for (size_t i = 0; i < wav.samples.size(); ++i) { + const uint16_t value = static_cast(pcmBytes[i * 2]) | + static_cast(static_cast(pcmBytes[i * 2 + 1]) << 8); + wav.samples[i] = static_cast(value); + } + return wav; +} + +std::string Trim(std::string text) { + const auto first = text.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return {}; + const auto last = text.find_last_not_of(" \t\r\n"); + return text.substr(first, last - first + 1); +} + +std::vector SplitCsvLine(const std::string& line) { + std::vector fields; + std::string current; + bool quoted = false; + for (size_t i = 0; i < line.size(); ++i) { + const char c = line[i]; + if (c == '"') { + if (quoted && i + 1 < line.size() && line[i + 1] == '"') { + current.push_back('"'); + ++i; + } else { + quoted = !quoted; + } + } else if (c == ',' && !quoted) { + fields.push_back(Trim(current)); + current.clear(); + } else { + current.push_back(c); + } + } + fields.push_back(Trim(current)); + return fields; +} + +std::vector