用一个 HTML 文件复刻竹知了:手势、陀螺仪与 Web Audio 的组合实践

2026-08-03 45 预计阅读时间: 1 分钟
来源: oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

竹知了是一种结构简单、声音鲜明的民间玩具:竹签、竹筒、细线和松香共同把旋转动作变成类似蝉鸣的振动。如今实物不常见,开源项目“竹知了”把这套交互搬进浏览器:按住屏幕画圈,或者直接甩动手机,就能触发声音与页面反馈。更值得开发者研究的是,它可以被压缩进一个零依赖的单文件 HTML。

难点不在播放音频,而在把动作变成声音

普通网页音效通常由点击按钮触发,竹知了却需要连续感知运动。浏览器要完成三步转换:

  1. 用 Pointer Events 采集鼠标或触摸轨迹。
  2. 用 Device Motion API 读取手机加速度。
  3. 将运动强度映射到 Web Audio API 的音量和音高。

画圈时,不能只判断指针是否移动。缓慢拖动、快速直线和连续绕圈的听感应该不同。一个实用的简化方案是计算相邻采样点之间的距离,再除以时间差,得到指针速度:

speed = distance(currentPoint, previousPoint) / deltaTime

手机甩动也可以采用类似思路。读取包含重力的三轴加速度后,计算向量长度:

strength = sqrt(x² + y² + z²)

实际实现还要设置死区,过滤静止时的传感器抖动;同时设置上限,避免一次猛烈甩动造成突兀的音量尖峰。

可以这样实践:做一个可直接打开的单文件版本

下面是一个独立的实验实现,并非对开源项目源码的复述。它使用 Pointer Events、Device Motion 和 Web Audio,不需要安装依赖。将内容放入 index.html 后即可打开;手机运动传感器通常要求 HTTPS,iOS 还要求用户通过点击明确授权。

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
  <title>竹知了实验</title>
  <style>
    * { box-sizing: border-box; }
    body {
      margin: 0;
      min-height: 100vh;
      display: grid;
      place-items: center;
      background: #f2ead8;
      color: #26352b;
      font-family: system-ui, sans-serif;
      touch-action: none;
    }
    main { width: min(88vw, 420px); text-align: center; }
    #toy {
      width: 220px;
      height: 220px;
      margin: 24px auto;
      border: 10px solid #688b45;
      border-radius: 50%;
      display: grid;
      place-items: center;
      background: #d9c27e;
      transform: scale(var(--pulse, 1));
    }
    button { padding: 12px 18px; font: inherit; cursor: pointer; }
  </style>
</head>
<body>
  <main>
    <h1>竹知了</h1>
    <p>按住圆盘快速画圈,或授权后甩动手机</p>
    <div id="toy">哇——</div>
    <button id="motion">启用手机动作</button>
  </main>

  <script>
    const toy = document.querySelector('#toy');
    const motionButton = document.querySelector('#motion');
    let audioContext;
    let oscillator;
    let gain;
    let previousPoint = null;
    let stopTimer;

    function ensureAudio() {
      if (audioContext) return;

      audioContext = new AudioContext();
      oscillator = new OscillatorNode(audioContext, {
        type: 'sawtooth',
        frequency: 115
      });
      gain = new GainNode(audioContext, { gain: 0 });
      oscillator.connect(gain).connect(audioContext.destination);
      oscillator.start();
    }

    function excite(rawStrength) {
      ensureAudio();
      const strength = Math.max(0, Math.min(rawStrength, 1));
      const now = audioContext.currentTime;

      gain.gain.cancelScheduledValues(now);
      gain.gain.setTargetAtTime(strength * 0.22, now, 0.025);
      oscillator.frequency.setTargetAtTime(95 + strength * 70, now, 0.04);
      toy.style.setProperty('--pulse', String(1 + strength * 0.08));

      clearTimeout(stopTimer);
      stopTimer = setTimeout(() => {
        gain.gain.setTargetAtTime(0, audioContext.currentTime, 0.12);
        toy.style.setProperty('--pulse', '1');
      }, 90);
    }

    toy.addEventListener('pointerdown', event => {
      ensureAudio();
      toy.setPointerCapture(event.pointerId);
      previousPoint = { x: event.clientX, y: event.clientY, t: performance.now() };
    });

    toy.addEventListener('pointermove', event => {
      if (!previousPoint || !toy.hasPointerCapture(event.pointerId)) return;

      const now = performance.now();
      const distance = Math.hypot(
        event.clientX - previousPoint.x,
        event.clientY - previousPoint.y
      );
      const speed = distance / Math.max(now - previousPoint.t, 1);
      excite(speed / 1.5);
      previousPoint = { x: event.clientX, y: event.clientY, t: now };
    });

    toy.addEventListener('pointerup', () => { previousPoint = null; });
    toy.addEventListener('pointercancel', () => { previousPoint = null; });

    function onMotion(event) {
      const a = event.accelerationIncludingGravity;
      if (!a || a.x == null || a.y == null || a.z == null) return;

      const magnitude = Math.hypot(a.x, a.y, a.z);
      const strength = (magnitude - 11) / 14;
      if (strength > 0.08) excite(strength);
    }

    motionButton.addEventListener('click', async () => {
      ensureAudio();

      if (typeof DeviceMotionEvent === 'undefined') {
        motionButton.textContent = '此设备不支持动作传感器';
        return;
      }

      if (typeof DeviceMotionEvent.requestPermission === 'function') {
        const result = await DeviceMotionEvent.requestPermission();
        if (result !== 'granted') return;
      }

      window.addEventListener('devicemotion', onMotion);
      motionButton.disabled = true;
      motionButton.textContent = '手机动作已启用';
    });
  </script>
</body>
</html>

桌面浏览器可以直接打开文件测试画圈操作:

open index.html

如果要在手机上测试传感器,可以在该文件所在目录启动静态服务,再通过 HTTPS 代理或支持 HTTPS 的开发服务器访问:

python3 -m http.server 8000

http://localhost:8000 适合本机调试,但手机通过局域网 IP 访问时,浏览器可能因为不是安全上下文而拒绝传感器权限。这不是代码故障,而是浏览器的安全边界。

为什么零依赖和单文件适合这种 Web 玩具

这类作品的核心价值是“点开就玩”。单文件避免构建步骤、包管理器和框架运行时,部署时只需托管一个静态文件,也便于长期保存。即使多年后依赖仓库或 CDN 发生变化,页面仍有较大概率继续运行。

代价也很明确:HTML、CSS 和 JavaScript 混在一起后,功能增长会迅速降低可维护性。单文件适合交互实验、数字纪念品和小型演示;如果继续加入音色编辑、录制、排行榜或多人功能,就应该拆分模块并补充自动化测试。

声音合成也需要克制。示例中的锯齿波只能提供粗糙的振动质感,不等于真实竹膜的声学模型。更接近实物的做法可以组合短音频采样、带通滤波器、轻微随机调制和包络控制,但应先测量移动设备的性能与扬声器表现。

上线前检查

  • 必须由用户手势创建或恢复 AudioContext,否则自动播放策略可能拦截声音。
  • 为传感器提供明确的授权按钮,并在拒绝授权后保留触摸玩法。
  • 对速度、加速度和音量做限幅,避免噪声抖动和刺耳峰值。
  • 在 iOS Safari、Android Chrome 和桌面浏览器上分别测试。
  • prefers-reduced-motion 做兼容,避免强烈缩放或旋转动画。
  • 不要上传原始传感器数据;如果确需统计,必须说明用途并尽量在本地聚合。

竹知了的 Web 化不是复杂框架的展示,而是一次完整的输入、反馈与声音映射练习。把范围控制在一个文件里,反而能迫使开发者看清浏览器最基础也最有表现力的能力。


相关推荐