# Android 加速度计 One-Euro Filter 优化 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在 Android `uni.startAccelerometer` 路径上引入 One-Euro Filter,让光栅卡倾斜交互在静止时更稳定、动态时更跟手。 **Architecture:** 新增独立 composable `useOneEuroFilter.js`(纯函数式速度自适应低通滤波器,每轴一个实例),在 `useLenticularStudioTilt.js` 的 Android accel 闭包内嵌入,不污染 iOS / H5 / Native.js 路径。同步建立 Node.js 原生脚本测试体系(项目无 vitest)。 **Tech Stack:** uni-app Vue3、Node.js 原生 assert、`@dcloudio/uni` 原生 API、JavaScript ES2020+。 **参考 Spec:** `docs/superpowers/specs/2026-06-30-lenticular-android-accel-oneeuro-design.md` **不在本期:** vitest 引入(独立 spec 决策)、pre-existing 重入竞态修复(spec §10 已记录)。 --- ## 文件结构 | 文件 | 职责 | |------|------| | `frontend/composables/useOneEuroFilter.js` | **新建** — 纯函数式速度自适应低通滤波器 | | `frontend/composables/useLenticularStudioTilt.js` | **修改** — Android `startNativeApp` 闭包内嵌入 One-Euro 调用 | | `frontend/composables/__fixtures__/tilt_input_v1.json` | **新建** — 30 秒合成加速度计序列,用于回归对比 | | `frontend/composables/__tests__/useOneEuroFilter.test.mjs` | **新建** — One-Euro Filter 单元测试(Node.js 原生 assert)| | `frontend/composables/__tests__/tilt_regression.test.mjs` | **新建** — 集成回归测试(修改前 vs 修改后对比) | --- ## Task 1: 建立测试基础设施 **Files:** - Create: `frontend/composables/__fixtures__/` (目录) - Create: `frontend/composables/__tests__/` (目录) - Create: `frontend/composables/__tests__/.gitkeep` - [ ] **Step 1: 验证 Node.js 版本(≥ 16,必须支持 ESM)** ```bash node --version node -e "import('node:fs').then(m => console.log('ESM OK, writeFileSync:', typeof m.writeFileSync))" ``` Expected: 输出 Node 版本(≥ 16.x),第二行 `ESM OK, writeFileSync: function`。 如果 Node < 16:测试方案需要降级为 CommonJS(不在本计划范围,另起 spec)。 - [ ] **Step 2: 创建测试目录** ```bash mkdir -p frontend/composables/__fixtures__ frontend/composables/__tests__ touch frontend/composables/__tests__/.gitkeep ``` - [ ] **Step 3: 验证目录创建** ```bash ls -la frontend/composables/__tests__ frontend/composables/__fixtures__ ``` Expected: 两个空目录,`__tests__/` 内含 `.gitkeep`。 - [ ] **Step 4: 暂存** ```bash git add frontend/composables/__tests__/.gitkeep git status ``` Expected: 工作区显示新增的 `.gitkeep`,未提交(用户决定 commit 时机)。 --- ## Task 2: 生成测试 Fixture **Files:** - Create: `frontend/composables/__fixtures__/tilt_input_v1.json` - [ ] **Step 1: 创建 fixture 生成脚本(一次性,提交后保留作为可复现凭证)** 创建 `frontend/composables/__tests__/gen_tilt_fixture.mjs`: ```javascript /** * 生成 tilt_input_v1.json fixture * - 种子:Mulberry32(seed=20260630) — 确定性 * - 30 秒 @ 50Hz = 1500 帧 * - 噪声:白高斯 σ=0.3° + 50 Hz 工频干扰 amplitude=0.05° * - 轨迹见 spec §8.2 */ import { writeFileSync } from 'node:fs' function mulberry32(seed) { return function () { let t = seed += 0x6D2B79F5 t = Math.imul(t ^ (t >>> 15), t | 1) t ^= t + Math.imul(t ^ (t >>> 7), t | 61) return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } // Box-Muller 标准正态 function gauss(rng) { const u = Math.max(rng(), 1e-9) const v = rng() return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v) } const FS = 50 const N = FS * 30 const NOISE_STD_DEG = 0.3 const MAINS_AMP = 0.05 const rng = mulberry32(20260630) function targetRoll(t) { if (t < 4.5) return 0 if (t < 10) return 30 if (t < 10.5) return 0 if (t < 13) return 2 * Math.sin(2 * Math.PI * 0.5 * (t - 11)) if (t < 18.5) return 0 return -30 } const frames = [] for (let i = 0; i < N; i++) { const t = i / FS const noise = gauss(rng) * NOISE_STD_DEG const mains = MAINS_AMP * Math.sin(2 * Math.PI * 50 * t) frames.push({ tMs: Math.round(t * 1000), rollDeg: targetRoll(t) + noise + mains, pitchDeg: gauss(rng) * NOISE_STD_DEG + mains, }) } const fixture = { version: 1, sampleRateHz: FS, durationSec: 30, seed: 20260630, noiseModel: 'white_gaussian_0.3deg + 50Hz_mains_0.05deg', schedule: [ 't=0-4s: stationary 0deg', 't=4.5s: +30deg step', 't=5-10s: hold 30deg', 't=10.5s: 0deg step', 't=11-13s: 2deg @ 0.5Hz micro-oscillation', 't=14-18s: stationary 0deg', 't=18.5s: -30deg step', 't=19-30s: hold -30deg', ], frames, } writeFileSync( 'frontend/composables/__fixtures__/tilt_input_v1.json', JSON.stringify(fixture, null, 2) ) console.log(`Generated ${frames.length} frames`) ``` - [ ] **Step 2: 运行脚本生成 fixture** ```bash cd frontend && node composables/__tests__/gen_tilt_fixture.mjs cd .. ``` Expected: 输出 `Generated 1500 frames`。 - [ ] **Step 3: 验证 fixture 内容** ```bash node -e "const f=require('./frontend/composables/__fixtures__/tilt_input_v1.json'); console.log('frames:', f.frames.length, 'first:', f.frames[0], 'last:', f.frames[f.frames.length-1])" ``` Expected: `frames: 1500 first: { tMs: 0, rollDeg: , pitchDeg: } last: { tMs: 29800, ... }` - [ ] **Step 4: 暂存** ```bash git add frontend/composables/__tests__/gen_tilt_fixture.mjs frontend/composables/__fixtures__/tilt_input_v1.json git status ``` Expected: 两个新文件在工作区,未提交。 --- ## Task 3: One-Euro Filter 单元测试 (TDD - 红) **Files:** - Create: `frontend/composables/__tests__/useOneEuroFilter.test.mjs` - [ ] **Step 1: 编写失败的测试** ```javascript /** * useOneEuroFilter 单元测试 * 运行:node frontend/composables/__tests__/useOneEuroFilter.test.mjs * 退出码:0=全部通过,1=有失败 */ import assert from 'node:assert/strict' import { createOneEuroFilter, nowMs } from '../useOneEuroFilter.js' const results = { passed: 0, failed: 0, details: [] } function test(name, fn) { try { fn() results.passed++ results.details.push({ name, status: 'PASS' }) console.log(` ✓ ${name}`) } catch (e) { results.failed++ results.details.push({ name, status: 'FAIL', error: e.message }) console.log(` ✗ ${name}: ${e.message}`) } } console.log('\n--- useOneEuroFilter 单元测试 ---') test('nowMs 返回数字', () => { assert.equal(typeof nowMs(), 'number') assert.ok(nowMs() > 0) }) test('首帧返回原值', () => { const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 }) const out = f.filter(10, 1000) assert.equal(out, 10) }) test('reset 后下次 filter 返回原值', () => { const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 }) f.filter(5, 1000) f.filter(8, 1020) f.reset() const out = f.filter(42, 1100) assert.equal(out, 42) }) test('静止 3 秒输出波动 < 0.1°', () => { // 测 spec §5:「设备静止 3 秒 → 输出波动 < 0.1°」 // 验证方法:喂入白噪声输入 3 秒,统计滤波器输出波动范围 const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 }) // 预热 1 秒(让滤波器状态稳定) for (let i = 0; i < 50; i++) { f.filter(0, i * 20) } // 测 3 秒:喂 ±1° 噪声,统计输出 max - min const outputs = [] for (let i = 0; i < 3 * 50; i++) { const t = (i + 50) * 20 const noise = (Math.sin(i * 0.7) + Math.cos(i * 1.3)) * 0.5 // ±1° 噪声 outputs.push(f.filter(noise, t)) } const min = Math.min(...outputs) const max = Math.max(...outputs) const range = max - min assert.ok(range < 0.1, `波动范围=${range.toFixed(4)}° 应 < 0.1° (min=${min.toFixed(3)}, max=${max.toFixed(3)})`) }) test('阶跃响应 0→30° 在 5 帧内达到 95%', () => { const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 }) // 预热:5 帧 0 for (let i = 0; i < 5; i++) f.filter(0, i * 20) // 阶跃到 30 let reachedFrame = -1 for (let i = 0; i < 20; i++) { const t = (i + 5) * 20 const out = f.filter(30, t) if (reachedFrame === -1 && out >= 30 * 0.95) { reachedFrame = i } } assert.ok(reachedFrame >= 0 && reachedFrame <= 5, `reached at frame ${reachedFrame}, 应 ≤ 5`) }) test('阶跃响应无超调(峰值不超过目标)', () => { const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 }) for (let i = 0; i < 5; i++) f.filter(0, i * 20) let peak = 0 for (let i = 0; i < 30; i++) { const out = f.filter(30, (i + 5) * 20) if (out > peak) peak = out } assert.ok(peak <= 30.5, `peak=${peak.toFixed(4)} 应 ≤ 30.5°`) }) test('dt=0 边界不除零', () => { const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 }) f.filter(5, 1000) // 同时间戳 const out1 = f.filter(8, 1000) const out2 = f.filter(10, 1000) assert.ok(Number.isFinite(out1)) assert.ok(Number.isFinite(out2)) assert.ok(Math.abs(out1 - out2) < 30, 'dt=0 时不应产生极端跳变') }) test('10 阶连续阶跃稳态误差 < 0.5°', () => { // 测 spec §8.1 #6:「大阶跃无溢出:10 阶连续 30° 阶跃,输出不应超过 [target - 0.5°, target + 0.5°]」 // 验证方法:每阶跃结束后,等待稳态(30 帧),断言 |output - target| < 0.5° const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 }) for (let step = 0; step < 10; step++) { const target = step % 2 === 0 ? 30 : -30 // 喂 30 帧让滤波器收敛 const outputs = [] for (let i = 0; i < 30; i++) { outputs.push(f.filter(target, (step * 30 + i) * 20)) } const final = outputs[outputs.length - 1] const err = Math.abs(final - target) assert.ok(err < 0.5, `step=${step} 稳态误差=${err.toFixed(4)} 应 < 0.5° (target=${target}, final=${final.toFixed(4)})`) } }) console.log(`\n--- 总结 ---`) console.log(`通过: ${results.passed}, 失败: ${results.failed}`) process.exit(results.failed > 0 ? 1 : 0) ``` - [ ] **Step 2: 运行测试,验证失败** ```bash node frontend/composables/__tests__/useOneEuroFilter.test.mjs ``` Expected: 测试失败(`useOneEuroFilter.js` 不存在或函数未导出),退出码 1。 如果意外通过:停下来确认 `useOneEuroFilter.js` 是否已存在;按 TDD 必须先红再绿。 --- ## Task 4: One-Euro Filter 实现 (TDD - 绿) **Files:** - Create: `frontend/composables/useOneEuroFilter.js` - [ ] **Step 1: 实现 One-Euro Filter** ```javascript /** * One-Euro Filter(速度自适应低通滤波器) * 参考:Casiez et al., "1€ Filter: A Simple Speed-based Low-pass Filter for Noisy Input in Interactive Systems", CHI 2012 * https://cristal.univ-lille.fr/~casiez/1euro/ * * 用法:每个轴一个实例 * const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 }) * const out = f.filter(x, performance.now()) * f.reset() * * @param {object} [opts] * @param {number} [opts.mincutoff=0.7] - Hz,静止截止频率 * @param {number} [opts.beta=0.015] - 速度系数(越大快速运动越跟手) * @param {number} [opts.dcutoff=1.0] - Hz,速度估计的截止频率 */ export function createOneEuroFilter({ mincutoff = 0.7, beta = 0.015, dcutoff = 1.0 } = {}) { let xPrev = null // 上次 FILTERED 输出(与 Casiez 论文一致) let dxPrev = 0 // 平滑后的速度 let tPrev = null // 上次时间戳 function smoothingFactor(cutoff, dt) { const r = 2 * Math.PI * cutoff * dt return r / (r + 1) } function filter(x, t) { // 首帧:直接接受,建立基线 if (xPrev == null || tPrev == null) { xPrev = x dxPrev = 0 tPrev = t return x } const dt = Math.max((t - tPrev) / 1000, 1e-6) // ms → s // 1. 速度差分(用 filtered xPrev,与 Casiez 一致) const dx = (x - xPrev) / dt // 2. 平滑速度 const aD = smoothingFactor(dcutoff, dt) const edx = aD * dx + (1 - aD) * dxPrev // 3. 自适应截止频率 const cutoff = mincutoff + beta * Math.abs(edx) // 4. 平滑位置 const a = smoothingFactor(cutoff, dt) const result = a * x + (1 - a) * xPrev xPrev = result dxPrev = edx tPrev = t return result } function reset() { xPrev = null dxPrev = 0 tPrev = null } return { filter, reset } } /** * 取单调递增的高精度时间戳(毫秒)。 * 优先 performance.now()(sub-ms 精度),回退 Date.now()(WebView 上 ~15ms 精度)。 */ export function nowMs() { if (typeof performance !== 'undefined' && typeof performance.now === 'function') { return performance.now() } return Date.now() } ``` - [ ] **Step 2: 运行单元测试,验证全部通过** ```bash node frontend/composables/__tests__/useOneEuroFilter.test.mjs ``` Expected: 9/9 通过,退出码 0。 如果仍有失败: - 检查 `mincutoff` 是否被正确读取 - 检查 dt clamp 是否生效 - 不要修改测试代码来"让它通过" - [ ] **Step 3: 暂存 One-Euro 模块 + 测试** ```bash git add frontend/composables/useOneEuroFilter.js frontend/composables/__tests__/useOneEuroFilter.test.mjs git status ``` Expected: 两个新文件暂存。 --- ## Task 5: 集成回归测试脚本 (TDD - 红) **Files:** - Create: `frontend/composables/__tests__/tilt_regression.test.mjs` - [ ] **Step 1: 编写回归测试** ```javascript /** * tilt_regression.test.mjs * 用 fixture 模拟 30 秒合成加速度计序列,验证 One-Euro 修改后行为符合验收标准。 * * 对比对象: * - baselineDx[i]: 修改前的 dx 序列(用同样输入运行原 fast/slow 通道) * - actualDx[i]: 修改后(含 One-Euro)的 dx 序列 * * 运行:node frontend/composables/__tests__/tilt_regression.test.mjs */ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { createOneEuroFilter } from '../useOneEuroFilter.js' const FAST_ALPHA = 0.25 const SLOW_ALPHA = 0.08 const JUMP_REJECT_DEG = 15 const WARMUP_SKIP = 5 const FIXTURE_PATH = '../__fixtures__/tilt_input_v1.json' const fixture = JSON.parse(readFileSync(new URL(FIXTURE_PATH, import.meta.url), 'utf-8')) const frames = fixture.frames /** * 仿真"修改前"的 fast/slow 双通道 + clampJump + warmup */ function simulateBaseline(inputSeq) { let prevRawX = NaN, prevRawY = NaN let warmup = 0 let fastX = NaN, fastY = NaN let slowX = NaN, slowY = NaN const dxOut = [] for (const f of inputSeq) { if (warmup < WARMUP_SKIP) { warmup++ dxOut.push(0) continue } // clampJump let cx = f.rollDeg, cy = f.pitchDeg if (Number.isFinite(prevRawX)) { if (Math.abs(f.rollDeg - prevRawX) > JUMP_REJECT_DEG) { cx = prevRawX + Math.sign(f.rollDeg - prevRawX) * JUMP_REJECT_DEG } if (Math.abs(f.pitchDeg - prevRawY) > JUMP_REJECT_DEG) { cy = prevRawY + Math.sign(f.pitchDeg - prevRawY) * JUMP_REJECT_DEG } } prevRawX = f.rollDeg; prevRawY = f.pitchDeg // updateFast if (!Number.isFinite(fastX)) { fastX = cx; fastY = cy } else { fastX += (cx - fastX) * FAST_ALPHA fastY += (cy - fastY) * FAST_ALPHA } // updateSlow if (!Number.isFinite(slowX)) { slowX = 0; slowY = 0 } else { slowX += (cx - slowX) * SLOW_ALPHA slowY += (cy - slowY) * SLOW_ALPHA } dxOut.push(fastX - slowX) } return dxOut } /** * 仿真"修改后":在 atan2 后插 One-Euro,再走同一 fast/slow 通道 */ function simulateWithOneEuro(inputSeq) { const oneEuroRoll = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 }) const oneEuroPitch = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 }) // 预热 One-Euro 用前 5 帧(与 accelWarmup 一致) for (let i = 0; i < WARMUP_SKIP; i++) { oneEuroRoll.filter(frames[i].rollDeg, frames[i].tMs) oneEuroPitch.filter(frames[i].pitchDeg, frames[i].tMs) } let prevRawX = NaN, prevRawY = NaN let fastX = NaN, fastY = NaN let slowX = NaN, slowY = NaN const dxOut = [] for (let i = 0; i < inputSeq.length; i++) { const f = inputSeq[i] if (i < WARMUP_SKIP) { dxOut.push(0) continue } // One-Euro 平滑 const roll = oneEuroRoll.filter(f.rollDeg, f.tMs) const pitch = oneEuroPitch.filter(f.pitchDeg, f.tMs) // clampJump let cx = roll, cy = pitch if (Number.isFinite(prevRawX)) { if (Math.abs(roll - prevRawX) > JUMP_REJECT_DEG) { cx = prevRawX + Math.sign(roll - prevRawX) * JUMP_REJECT_DEG } if (Math.abs(pitch - prevRawY) > JUMP_REJECT_DEG) { cy = prevRawY + Math.sign(pitch - prevRawY) * JUMP_REJECT_DEG } } prevRawX = roll; prevRawY = pitch if (!Number.isFinite(fastX)) { fastX = cx; fastY = cy } else { fastX += (cx - fastX) * FAST_ALPHA fastY += (cy - fastY) * FAST_ALPHA } if (!Number.isFinite(slowX)) { slowX = 0; slowY = 0 } else { slowX += (cx - slowX) * SLOW_ALPHA slowY += (cy - slowY) * SLOW_ALPHA } dxOut.push(fastX - slowX) } return dxOut } const baselineDx = simulateBaseline(frames) const actualDx = simulateWithOneEuro(frames) let passed = 0, failed = 0 function check(name, fn) { try { fn(); passed++; console.log(` ✓ ${name}`) } catch (e) { failed++; console.log(` ✗ ${name}: ${e.message}`) } } console.log('\n--- tilt 集成回归测试 ---') // 1. 静止段不抖:前 4 秒(前 200 帧)dx 标准差,actual < baseline 的 50% check('静止段不抖(actual std < baseline std 的 50%)', () => { const slice = (arr, s, e) => arr.slice(s, e) const bSlice = slice(baselineDx, WARMUP_SKIP, 4 * 50) const aSlice = slice(actualDx, WARMUP_SKIP, 4 * 50) const std = arr => { const m = arr.reduce((a, b) => a + b, 0) / arr.length return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length) } const bStd = std(bSlice), aStd = std(aSlice) console.log(` baseline std=${bStd.toFixed(4)}, actual std=${aStd.toFixed(4)}`) assert.ok(aStd < bStd * 0.5, `aStd=${aStd} 应 < bStd*0.5=${bStd * 0.5}`) }) // 2. 阶跃响应:4.5s 注入 30° 阶跃,actual dx 在 100ms 内达到目标 95% check('阶跃响应时间(actual 在 100ms 内达到目标 95%)', () => { const stepFrame = Math.round(4.5 * 50) // 225 const target = 28.5 // 30° * 0.95 let reachedAt = -1 for (let i = 0; i < 10; i++) { if (actualDx[stepFrame + i] >= target) { reachedAt = i; break } } console.log(` reached at +${reachedAt} frames (${reachedAt * 20}ms)`) assert.ok(reachedAt >= 0 && reachedAt <= 5, `reachedAt=${reachedAt} 应 ≤ 5`) }) // 3. 阶跃事件保持:检测 baseline 与 actual 的阶跃位置(dx 变化 > 10°/帧),时间戳相差 < 50ms check('阶跃事件保持(baseline/actual 阶跃位置相差 < 50ms)', () => { function detectSteps(dxSeq, threshold = 10) { const out = [] for (let i = 1; i < dxSeq.length; i++) { if (Math.abs(dxSeq[i] - dxSeq[i - 1]) > threshold) { out.push(i) } } return out } const bSteps = detectSteps(baselineDx) const aSteps = detectSteps(actualDx) console.log(` baseline steps: ${bSteps.length}, actual steps: ${aSteps.length}`) // 主要阶跃位置应能找到对应 const mainSteps = bSteps.filter(f => Math.abs(baselineDx[f]) > 10).slice(0, 3) for (const f of mainSteps) { const nearest = aSteps.reduce((best, x) => Math.abs(x - f) < Math.abs(best - f) ? x : best, aSteps[0]) assert.ok(Math.abs(nearest - f) * 20 < 50, `baseline step@frame${f} 实际阶跃在 frame${nearest},相差 ${Math.abs(nearest - f) * 20}ms 应 < 50ms`) } }) // 4. 信号不丢失:actual RMS 偏差在 baseline 的 [0.7, 1.3] 倍区间内 check('信号不丢失(actual RMS 在 baseline [0.7, 1.3]x)', () => { const rms = arr => Math.sqrt(arr.reduce((s, v) => s + v * v, 0) / arr.length) const bRms = rms(baselineDx), aRms = rms(actualDx) console.log(` baseline rms=${bRms.toFixed(4)}, actual rms=${aRms.toFixed(4)}`) assert.ok(aRms >= bRms * 0.7 && aRms <= bRms * 1.3, `aRms/bRms=${(aRms / bRms).toFixed(3)} 应 ∈ [0.7, 1.3]`) }) // 5. 末尾稳态差:actual dx 最后一秒均值与 baseline 相差 < 0.5° check('末尾稳态差(最后 1 秒均值差 < 0.5°)', () => { const last = arr => arr.slice(-50).reduce((a, b) => a + b, 0) / 50 const bLast = last(baselineDx), aLast = last(actualDx) console.log(` baseline last=${bLast.toFixed(4)}, actual last=${aLast.toFixed(4)}`) assert.ok(Math.abs(aLast - bLast) < 0.5, `差=${Math.abs(aLast - bLast).toFixed(4)} 应 < 0.5`) }) // 6. 无 50Hz 谐波放大:对 actual dx 做简易 DFT,验证 50Hz 附近幅值不放大 check('无 50Hz 谐波放大(actual 50Hz-band 能量不高于 baseline 的 1.5 倍)', () => { const FS = 50 function bandPower(signal, fs, centerHz, bandwidthHz = 2) { // 计算 [centerHz - bw, centerHz + bw] 频带的能量(简化版 DFT) const N = signal.length let power = 0 for (let f = Math.round((centerHz - bandwidthHz) * N / fs); f <= Math.round((centerHz + bandwidthHz) * N / fs); f++) { if (f <= 0 || f >= N / 2) continue let re = 0, im = 0 for (let n = 0; n < N; n++) { const angle = 2 * Math.PI * f * n / N re += signal[n] * Math.cos(angle) im -= signal[n] * Math.sin(angle) } power += re * re + im * im } return power } const bPwr = bandPower(baselineDx, FS, 50) const aPwr = bandPower(actualDx, FS, 50) console.log(` baseline 50Hz-band power=${bPwr.toFixed(2)}, actual=${aPwr.toFixed(2)}`) // One-Euro 应该滤掉 50Hz,所以 actual 应该 < baseline(而非放大) assert.ok(aPwr < bPwr * 1.5, `actual 50Hz-band power 放大超过 1.5x(bPwr=${bPwr}, aPwr=${aPwr})`) }) console.log(`\n--- 总结 ---`) console.log(`通过: ${passed}, 失败: ${failed}`) process.exit(failed > 0 ? 1 : 0) ``` - [ ] **Step 2: 运行测试,验证"actual"分支在未修改 tilt 文件时仍跑通(即 baseline 路径有效)** ```bash node frontend/composables/__tests__/tilt_regression.test.mjs ``` Expected: 测试运行通过(因为 baseline 和 actual 都内联在测试脚本里,不依赖 tilt.js 的代码)。如果失败:检查 fixture 加载路径、参数。 > 注:此测试是"自我闭环"的,验证 One-Euro 参数选择是否正确,不是验证 tilt.js 修改后的行为。后者在 Task 7 通过手测或 diff 验证。 --- ## Task 6: 修改 `useLenticularStudioTilt.js` 集成 One-Euro **Files:** - Modify: `frontend/composables/useLenticularStudioTilt.js:1-12` (top import) - Modify: `frontend/composables/useLenticularStudioTilt.js:609-628` (Android `startNativeApp` closure) - [ ] **Step 1: 读取当前文件首部,确认 import 区域位置** ```bash head -15 frontend/composables/useLenticularStudioTilt.js ``` Expected: 文件顶部有 JSDoc 注释块(第 1-34 行),第 35 行开始是常量定义。import 应放在第 34 行之后、常量之前。 - [ ] **Step 2: 添加 import** 在 `useLenticularStudioTilt.js` 第 34 行(`SKIP_WARMUP_FRAMES = 5` 之前)插入: ```javascript import { createOneEuroFilter, nowMs } from '@/composables/useOneEuroFilter.js' ``` - [ ] **Step 3: 修改 Android `startNativeApp` 闭包(替换原第 609-628 行)** 定位:从 `if (plus.os && plus.os.name === 'Android') {` 开始,到 `return`(第 628 行)结束的整个 if 块。 将原代码: ```javascript if (plus.os && plus.os.name === 'Android') { uni.startAccelerometer({ interval: 'game' }) let accelWarmup = 0 let accelCb = function(res) { if (myGen !== tiltGen) return // 跳过前几帧初始化噪声 if (accelWarmup < SKIP_WARMUP_FRAMES) { accelWarmup++; return } // 不额外 LPF——交给管道的 fast/slow 双通道做平滑 const roll = Math.atan2(res.x, res.z) * (180 / Math.PI) const pitch = Math.atan2(res.y, Math.sqrt(res.x*res.x + res.z*res.z)) * (180 / Math.PI) gyroSourceLabel.value = 'accelerometer' handleNativeTiltFrame(roll, pitch) } uni.onAccelerometerChange(accelCb) nativeCleanup = function() { uni.offAccelerometerChange(accelCb) try { uni.stopAccelerometer() } catch (_) {} } console.log('[useLenticularStudioTilt] uni.onAccelerometerChange started (no LPF)') return } ``` 替换为: ```javascript if (plus.os && plus.os.name === 'Android') { uni.startAccelerometer({ interval: 'game' }) let accelWarmup = 0 // ——— Android-accel 路径专用:One-Euro Filter(速度自适应低通)——— // 必须在闭包内新建,不能放在 handleNativeTiltFrame // (handleNativeTiltFrame 被 iOS CMMotionManager 共用) const oneEuroRoll = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 }) const oneEuroPitch = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 }) let accelCb = function(res) { if (myGen !== tiltGen) return // 跳过前几帧初始化噪声 if (accelWarmup < SKIP_WARMUP_FRAMES) { accelWarmup++; return } const rollRaw = Math.atan2(res.x, res.z) * (180 / Math.PI) const pitchRaw = Math.atan2(res.y, Math.sqrt(res.x*res.x + res.z*res.z)) * (180 / Math.PI) // One-Euro 平滑(解决静止抖动 + 减小动态延迟) const t = nowMs() const roll = oneEuroRoll.filter(rollRaw, t) const pitch = oneEuroPitch.filter(pitchRaw, t) gyroSourceLabel.value = 'accelerometer' handleNativeTiltFrame(roll, pitch) } uni.onAccelerometerChange(accelCb) nativeCleanup = function() { uni.offAccelerometerChange(accelCb) try { uni.stopAccelerometer() } catch (_) {} } console.log('[useLenticularStudioTilt] uni.onAccelerometerChange started with One-Euro Filter') return } ``` > **关于 `nativeCleanup` 是否调 `oneEuroRoll.reset()`:** 本计划**不追加**此调用。Spec §7 列了「为对称性可追加」作为缓解,Spec §10 又把它列为 pre-existing 范围外问题。`startNativeApp` 每次调用都新建闭包实例,新实例首帧直接返回原值(无需 reset),所以行为正确。如后续需要对称性可单独立项修复(影响面小,独立 commit)。 - [ ] **Step 4: 验证仅 Android 闭包被修改** ```bash git diff frontend/composables/useLenticularStudioTilt.js | head -100 ``` Expected: diff 仅显示: - 顶部新增的 `import` 行 - Android `if` 块内的变更 - `console.log` 文案变更 **不应该**有: - `handleNativeTiltFrame` 函数体的变更 - iOS 路径的变更 - `handleDeviceOrientation` / `createAndroidTiltSensor` / `createIOSTiltSensor` 的变更 - `resetState` 的变更 - `simulate` / `simulateFromSignedDegrees` 的变更 如果有意外的 diff:停下来重新检查。 - [ ] **Step 5: 验证 import 解析正确** ```bash grep -n "import.*useOneEuroFilter" frontend/composables/useLenticularStudioTilt.js ``` Expected: 第 35 行(或附近)显示 `import { createOneEuroFilter, nowMs } from '@/composables/useOneEuroFilter.js'`。 - [ ] **Step 6: 运行单元测试确认 One-Euro 模块仍正常** ```bash node frontend/composables/__tests__/useOneEuroFilter.test.mjs ``` Expected: 9/9 通过。 - [ ] **Step 7: Build-time 验证 import 可解析** `@/` alias 由 `@dcloudio/uni-cli-shared` 在构建时解析(不是 vite.config.js alias)。需要真正跑一次 uni-app 构建确认 import 能解析: ```bash cd frontend # 用项目现有 dev 命令启动 H5 构建(不部署到设备,只看是否有解析错误) # 命令名根据项目 package.json 而定,常见的有: npm run dev:h5 # 或 yarn dev:h5 / pnpm dev:h5 # 让构建跑 5-10 秒,观察终端输出: # - 期望:无 `Could not resolve '@/composables/useOneEuroFilter.js'` 错误 # - 期望:无 `Failed to resolve import` 警告 # Ctrl+C 终止 ``` Expected: 构建无 import 解析错误。 如果有解析错误: - 确认文件路径 `frontend/composables/useOneEuroFilter.js` 存在 - 确认 import 路径拼写正确(区分大小写) - 确认 `frontend/package.json` 的 `@dcloudio/vite-plugin-uni` 版本 ≥ 3.0 > **注意**:此步骤必须用真构建(H5 / App 都行),不能用 `node --check`(Node 不会处理 `@/` alias)。 - [ ] **Step 8: 暂存** ```bash git add frontend/composables/useLenticularStudioTilt.js git status ``` Expected: 仅 `useLenticularStudioTilt.js` 暂存,无其他未暂存文件。 --- ## Task 7: 跨平台非受影响验证 **Files:** - 不修改任何文件,仅验证 - [ ] **Step 1: 阅读 iOS 分支,确认无 One-Euro 调用泄漏** ```bash sed -n '630,660p' frontend/composables/useLenticularStudioTilt.js ``` Expected: iOS 分支(`if (plus.os && plus.os.name === 'iOS')`)的代码与修改前完全一致,仍调用 `handleNativeTiltFrame(roll, pitch)`,无 One-Euro。 - [ ] **Step 2: 阅读 handleNativeTiltFrame,确认函数体未改** ```bash sed -n '152,166p' frontend/composables/useLenticularStudioTilt.js ``` Expected: 函数体从 clampJump → updateFast → updateSlow 流程未引入 One-Euro。 - [ ] **Step 3: 阅读 H5 DeviceOrientation 路径** ```bash sed -n '691,711p' frontend/composables/useLenticularStudioTilt.js ``` Expected: `handleDeviceOrientation` 函数体与修改前一致,无 One-Euro。 - [ ] **Step 4: 阅读 Android Native.js SensorManager 路径** ```bash sed -n '298,500p' frontend/composables/useLenticularStudioTilt.js | grep -E "(oneEuro|OneEuro|createOneEuro)" || echo "未发现 One-Euro 调用,✓" ``` Expected: `未发现 One-Euro 调用,✓` - [ ] **Step 5: 确认 import 路径在生产构建中正确解析** 检查 `frontend/vite.config.js` 或 `frontend/pages.json` 是否有 `@/` alias 配置: ```bash grep -rn '"@/' frontend/*.config.js frontend/vite.config.js 2>/dev/null | head -5 grep -n "useLenticularStudioTilt" frontend/composables/useLenticularCraftTiltPreview.js | head -3 ``` Expected: - `vite.config.js` 中应有 `resolve.alias['@']` 配置(如果 `useLenticularCraftTiltPreview.js` 已用 `@/composables/useLenticularStudioTilt.js` 导入并能工作,说明 alias 配置存在) - `useLenticularCraftTiltPreview.js` 第 13 行有同样的 `@/composables/...` 导入 --- ## Task 8: 回归测试矩阵 **Files:** - 不修改任何文件,整理回归证据 - [ ] **Step 1: 收集测试输出** ```bash echo "=== 单元测试 ===" > frontend/composables/__tests__/RESULTS.md node frontend/composables/__tests__/useOneEuroFilter.test.mjs >> frontend/composables/__tests__/RESULTS.md 2>&1 echo "" >> frontend/composables/__tests__/RESULTS.md echo "=== 集成回归 ===" >> frontend/composables/__tests__/RESULTS.md node frontend/composables/__tests__/tilt_regression.test.mjs >> frontend/composables/__tests__/RESULTS.md 2>&1 cat frontend/composables/__tests__/RESULTS.md ``` Expected: RESULTS.md 末尾显示两个测试套件的"通过: N, 失败: 0"。 - [ ] **Step 2: 列出本次所有变更文件清单** ```bash git status --short echo "---" git diff --stat HEAD ``` Expected: 5 个新文件 + 1 个修改文件: - `frontend/composables/useOneEuroFilter.js` (新建) - `frontend/composables/__fixtures__/tilt_input_v1.json` (新建) - `frontend/composables/__tests__/gen_tilt_fixture.mjs` (新建) - `frontend/composables/__tests__/useOneEuroFilter.test.mjs` (新建) - `frontend/composables/__tests__/tilt_regression.test.mjs` (新建) - `frontend/composables/__tests__/RESULTS.md` (新建) - `frontend/composables/useLenticularStudioTilt.js` (修改) - [ ] **Step 3: 暂存测试结果** ```bash git add frontend/composables/__tests__/RESULTS.md ``` > 注:commit 由用户触发,本任务不 commit。 --- ## Task 9: 真机回归(需 Android 设备) **Files:** - 不修改任何文件,需要 Android 真机 + 已构建的 App - [ ] **Step 1: 构建并安装 App 到测试设备** 按项目现有流程: ```bash # 具体命令取决于项目构建配置,此处仅说明 cd frontend # npm/yarn run build:android 或 HBuilderX 真机运行 ``` Expected: App 启动到任意一个光栅卡预览页(asset-detail / lenticular-result / generation-result 任一)。 - [ ] **Step 2: 验证「设备静止」场景** - 设备平放在桌面 - 打开任一光栅卡预览 - 观察 30 秒 - **期望**:档位稳定不动,无微颤切换 - [ ] **Step 3: 验证「缓慢倾斜」场景** - 设备从水平缓慢倾斜到约 30°(约 1 秒完成) - **期望**:档位切换平滑跟随,无阶跃跳变 - [ ] **Step 4: 验证「快速倾斜」场景** - 设备从水平快速倾斜到约 30°(约 0.2 秒完成) - **期望**:档位切换及时,无明显延迟(< 100ms 感知) - [ ] **Step 5: 验证「设备平移拿起/放下」场景** - 拿起设备(不倾斜,纯垂直拿起) - **期望**:不误触发档位切换(clampJump 应拒绝阶跃) - [ ] **Step 6: 记录真机结果** 在 `frontend/composables/__tests__/RESULTS.md` 末尾追加: ```markdown ### 真机回归(YYYY-MM-DD HH:MM) - 设备:[设备型号] / Android [版本] - 构建:[commit hash 或构建编号] | 场景 | 结果 | 备注 | |------|------|------| | 设备静止 30 秒 | [PASS/FAIL] | | | 缓慢倾斜 | [PASS/FAIL] | | | 快速倾斜 | [PASS/FAIL] | | | 设备平移拿起 | [PASS/FAIL] | | ``` - [ ] **Step 7: 暂存真机结果** ```bash git add frontend/composables/__tests__/RESULTS.md ``` --- ## Task 10: 自审与最终回归 **Files:** - 不修改代码,仅自审 - [ ] **Step 1: 对照 CLAUDE.md「自审清单」逐项检查** | 检查项 | 结果 | |-------|------| | 修改的函数所有调用方仍正常工作 | 6 个页面消费者、2 个 composable 消费者均未改接口 | | 相关单元测试是否通过 | 9/9 + 5/5 | | 是否有条件分支只改了其中一支 | 仅 Android accel 分支;iOS / H5 / Native.js 分支未动 | | 错误处理 / 边界条件 | dt 除零、reset、首帧、性能均覆盖 | | 数据库 / API 字段变更 | N/A | | 日志、错误信息、文档 | spec + RESULTS.md 同步 | - [ ] **Step 2: 验证 final commit 准备就绪** ```bash git status --short echo "---" ls -la frontend/composables/useOneEuroFilter.js ls -la frontend/composables/__fixtures__/tilt_input_v1.json ls -la frontend/composables/__tests__/*.mjs frontend/composables/__tests__/RESULTS.md ``` Expected: 所有目标文件存在。 --- ## 完成标准 ✅ Task 1-8 在开发环境完成(无需真机) ✅ Task 9 真机回归通过(需 Android 设备) ✅ Task 10 自审清单全部勾选 ✅ 单元测试 9/9 通过 ✅ 集成回归测试 5/5 通过 ✅ git status 仅显示本次 7 个新文件 + 1 个修改文件 **Commit 由用户触发**:根据 CLAUDE.md「未经用户明确指示,AI 严禁执行 `git commit`」。 建议的 commit 信息(仅供用户参考): ``` feat(光栅倾斜): Android 加速度计路径引入 One-Euro Filter - 新增 useOneEuroFilter composable(速度自适应低通) - 在 Android uni.startAccelerometer 闭包内嵌入(不动 iOS/H5/Native.js) - 新增 Node.js 单元测试 + 集成回归测试 + fixture - 参数:mincutoff=0.7Hz, beta=0.015, dcutoff=1.0Hz - 效果:静止时 alpha≈0.08(vs 原 0.25),快速运动自适应提速 参考 spec: docs/superpowers/specs/2026-06-30-lenticular-android-accel-oneeuro-design.md 参考 plan: docs/superpowers/plans/2026-06-30-android-accel-oneeuro-implementation.md ```