竹知了是一种结构简单、声音鲜明的民间玩具:竹签、竹筒、细线和松香共同把旋转动作变成类似蝉鸣的振动。如今实物不常见,开源项目“竹知了”把这套交互搬进浏览器:按住屏幕画圈,或者直接甩动手机,就能触发声音与页面反馈。更值得开发者研究的是,它可以被压缩进一个零依赖的单文件 HTML。
难点不在播放音频,而在把动作变成声音
普通网页音效通常由点击按钮触发,竹知了却需要连续感知运动。浏览器要完成三步转换:
- 用 Pointer Events 采集鼠标或触摸轨迹。
- 用 Device Motion API 读取手机加速度。
- 将运动强度映射到 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 化不是复杂框架的展示,而是一次完整的输入、反馈与声音映射练习。把范围控制在一个文件里,反而能迫使开发者看清浏览器最基础也最有表现力的能力。