简介:这是一个面向微信小程序初学者与音视频处理爱好者的节拍器Demo完整源码,适合用于学习小程序开发流程和音频播放控制。项目以节拍器为实际场景,完整展示了WXML页面布局、WXSS样式、JavaScript业务逻辑、mp3音频加载、定时器和动画API的协作方式,可帮助读者理解从页面搭建到交互实现的完整链路。压缩包共18个文件,以JS逻辑文件、WXML页面、WXSS样式、PNG图片和MP3音频资源为主,另含JSON配置文件;整体仅67KB,结构清晰,可直接导入微信开发者工具运行。已有469人学习/下载,适合作为小程序入门练习与二次开发的参考。通过这份源码,可以学习到数据绑定、事件处理、wx.createInnerAudioContext音频接口、setInterval定时器以及动画指示器实现等关键知识点;同时借助简洁的目录结构和配套音频素材,便于对照调试页面导航、权限请求与发布流程,为后续开发工具类或媒体类小程序积累可复用的代码思路。
1. 把“简单节拍器 demo”拆开看:它不是一个播放器,而是一个状态机
很多练琴、练鼓的朋友手机上装过专业节拍器 App,但每次想用还得解锁、切应用、找图标;微信小程序做节拍器的价值不在功能多,而在“打开即用”。这个 demo 标题里最关键的两个词是“简单”和“完整源码”:简单意味着没有复杂音轨、没有节拍器预设库,核心就是 BPM(Beats Per Minute,每分钟拍数)、节拍声和开关按钮;完整源码意味着它具备一个微信小程序工程该有的 app.js、app.json、页面四件套和资源文件,可以直接导入开发者工具跑通。
对开发者来说,这个 demo 是少有的能把“定时器精度”“音频资源调度”“页面生命周期”三个知识点串起来的入门项目。你不需要懂 Web Audio 合成,也不需要处理流媒体,只要把 setInterval 或 setTimeout 链、wx.createInnerAudioContext、onHide/onUnload 这三样用对,节拍器就能跑得又稳又准。下文按“工程结构 → 节拍声与定时器 → 真机调优 → 视觉联动”的顺序,把一个可从零复现的最小实现讲清楚。
2. 从小程序工程到 BPM 状态:目录、注册与毫秒换算
2.1 最小可用工程长什么样:app.json 页面注册与四件套文件
先用微信开发者工具新建一个“小程序”项目,AppID 可以用测试号。工程目录按下面这样组织,这是微信小程序最标准的页面结构:
├── app.js ├── app.json ├── app.wxss ├── pages │ └── index │ ├── index.js │ ├── index.json │ ├── index.wxml │ └── index.wxss └── utils └── metronome.jsapp.json 里只注册一个页面,同时把导航栏标题改成“节拍器 demo”:
{ "pages": ["pages/index/index"], "window": { "navigationBarTitleText": "节拍器 demo", "navigationBarBackgroundColor": "#1f1f1f", "navigationBarTextStyle": "white" } }页面注册是微信小程序一切功能的前提:pages 数组第一项就是启动页,所有要用到的页面必须在这里登记,否则编译器直接报错。index.json 留空对象即可,不需要开启下拉刷新或自定义导航。把 app.wxss 里写两三条全局样式就够,比如页面背景色和 button 默认去边框,节拍器的核心样式其实都收在 index.wxss 里。
2.2 把 BPM 换算成一次间隔:计算公式与参数边界
节拍器的物理基础很直接:BPM 是每分钟拍数,一拍的时间间隔就是 60000 除以 BPM,单位是毫秒。
function bpmToMs(bpm) { return Math.round(60000 / bpm); } const INTERVAL_MS = bpmToMs(120); // 500ms,一秒两拍这个换算没有任何歧义,但要考虑两个实际边界:一是 BPM 的允许范围,我一般设 40 到 208,对应单拍间隔 1500ms 到约 288ms;低于 40 用户会怀疑节拍器坏了,高于 208 对多数乐器练习没有意义,而且音频播放本身也有开销。二是小数 BPM,比如 120.5,60000 除以 120.5 是 497.9ms,直接取整会导致每拍误差 0.1ms,几百拍后累计出可感知的相位偏移。所以参数表里要明确:BPM 用整数,或者用浮点数计算但每次都拿“下一次响的绝对时间”做差值。
常见档位换算可以提前在 UI 上标注:
| BPM | 单拍间隔(ms) | 每秒拍数 | 常见用途 |
|---|---|---|---|
| 40 | 1500 | 0.67 | 慢速练习 |
| 60 | 1000 | 1.0 | 入门/慢速 |
| 90 | 667 | 1.5 | 行板 |
| 120 | 500 | 2.0 | 中速流行 |
| 160 | 375 | 2.67 | 快板 |
| 208 | 288 | 3.47 | 极速练习 |
2.3 用 data 做界面状态,用局部变量做计时状态
微信小程序里最容易混淆的点是 data 和普通变量的分工。data 里放的是需要渲染到页面的内容:当前 BPM、是否正在播放、当前小节数。而 setTimeout 的句柄、下一次响拍的绝对时间戳,这些不能放 data,因为 setData 是异步且耗性能的,每 tick 都 setData 会卡顿。
data: { bpm: 120, playing: false, beatText: "1", bpmText: "120" }, onLoad() { this.timer = null; this.nextTickTime = 0; }onLoad 里初始化两个实例属性,_timer 用来存定时器句柄,_nextTickTime 是为了后面做时间校准。这两个字段不上 data,不参与渲染,只在 JS 逻辑层流转。界面上的 beatText 只在拍数变化时更新,而不是每 500ms 都 setData,这样能把渲染压力降到最低。
3. 节拍声与计时核心:Audio API、定时器精度和强弱拍逻辑
3.1 单次 tick 的音频播放与提前加载
微信小程序播放短音效,最常用的 API 是 wx.createInnerAudioContext,它返回一个 InnerAudioContext 实例。节拍器场景和普通音乐播放最大的不同在于:每拍要“立刻响”,不能有加载延迟。
createAudio(src) { const audio = wx.createInnerAudioContext(); audio.src = src; audio.volume = 0.8; audio.obeyMuteSwitch = false; return audio; }obeyMuteSwitch 是 iOS 上很关键的参数,默认 true 时,手机侧边静音键一开,节拍器就彻底没声了。false 表示声音从扬声器强制出来,符合乐器练习场景。资源用短促的“嗒”声文件,200ms 以内,格式 mp3 或 wav 都行,放在 assets/sounds/ 目录下。第一次点击开始之前可以先调用 audio.play() 再立刻 stop(),触发底层预加载,避免第一拍因为解码延迟而丢声。
3.2 用 setTimeout 链而不是 setInterval 保住节拍顺序
很多 demo 会随手写 setInterval,但它有两个问题:小程序切后台后定时器会被系统挂起;即使在前台,setInterval 也不保证每次回调都严格按间隔触发,而是“每隔一段时间检查一次是否到点”。对于节拍器这种每拍都要响的工具,我习惯用 setTimeout 做递归链:
start() { if (this.playing) return; this.playing = true; this.currentBeat = 0; this.nextTickTime = Date.now(); this.scheduleTick(); }, scheduleTick() { const intervalMs = Math.round(60000 / this.data.bpm); const delay = Math.max(0, this.nextTickTime - Date.now()); this.timer = setTimeout(() => { this.playTick(this.currentBeat); this.currentBeat = (this.currentBeat + 1) % this.data.beatsPerBar; this.nextTickTime += intervalMs; this.scheduleTick(); }, delay); }核心逻辑在 nextTickTime:它记录“下一次响拍的绝对时间”,每次回调里用当前时间减它算出延迟,再排下一个定时器。这样即使某一次回调被系统拖延了 20ms,下一次定时也会自动缩短间隔来追赶,不会累积漂移。currentBeat 用取模运算循环,模拟 4/4 拍的 1 2 3 4 循环。
3.3 强弱拍:beatsPerBar 与 accentIndex
真正有乐器练习经验的人会告诉你,节拍器不能每拍都一样响。4/4 拍的第一拍是重音,3/4 拍的第一拍也是重音,重音用尖锐高音,弱拍用闷音。
playTick(beatIndex) { const isAccent = beatIndex === 0; this.accentAudio.stop(); this.normalAudio.stop(); if (isAccent) { this.accentAudio.play(); } else { this.normalAudio.play(); } this.setData({ beatText: String(beatIndex + 1) }); }这里用两个 InnerAudioContext 实例分别加载重音音频和弱音音频,每次播放前先 stop 再 play,是为了处理极端快速连击时前一个声音还没放完的情况。stop 之后立刻 play 能保证声音从头开始,而不是叠加成一团噪声。如果你只需要一个能跑的 demo,不做强弱拍也能接受,但加上这个逻辑才能真正适配练琴场景。
3.4 停止时要做的清理:clearTimeout 与音频 stop
停止播放看起来只是 clearTimeout,但只做这一件事,真机会出现“点了停止还有一声余音”的怪现象。原因是最新一拍的音频可能正处于播放中,stop 只是停了定时器,没有停声音。
stop() { if (!this.playing) return; this.playing = false; if (this.timer) { clearTimeout(this.timer); this.timer = null; } this.accentAudio.stop(); this.normalAudio.stop(); }stop 里先置 playing 为 false,防止重复点击;再清定时器句柄;最后强制停掉两个音频实例。顺序不能反,因为如果先停音频再清定时器,可能清空之前定时器又排出了下一个,出现“幽灵节拍”。这个小细节是真机上最容易暴露的问题,模拟器因为音频通道特性不明显。
4. 真机调试参数调优:无声、漂移、后台暂停和触感替代
4.1 真机无声的三个原因与检查顺序
模拟器上声音正常,传到 iPhone 上没声,这是节拍器 demo 最常见的翻车现场。按以下顺序排查:先确认基础库版本是不是低于 2.1.0,createInnerAudioContext 在旧版本上有兼容问题;再检查 obeyMuteSwitch 是否设置为 false,iPhone 静音键会导致默认路径无声;最后确认音频资源有没有被打包进小程序,资源放在 assets 目录且没有超过 2MB 主包限制就不会有问题。真机上还可以用 wx.getSystemInfoSync 打一下 SDKVersion 做日志输出,确认基础库版本。
4.2 定时器累计漂移:Date.now 校准的参数细节
上一章提到用 nextTickTime 做绝对时间对齐,这里把参数细节说透。递归 setTimeout 的 delay 计算方式是“下一次的绝对时间减去当前时间”,当回调准时触发时 delay 等于 intervalMs,当回调因为其他任务延迟了 15ms 时 delay 自动变成 intervalMs 减 15,系统会在下一个周期追回来。
const intervalMs = Math.round(60000 / this.data.bpm); const drift = Date.now() - this.nextTickTime; if (Math.abs(drift) > intervalMs * 0.5) { this.nextTickTime = Date.now(); }上面这段是一个保护逻辑:如果某次回调被系统挂起超过半个节拍周期,说明小程序刚从后台恢复,此时再强行追赶会导致连续快速响好几声,体验极差。碰到这种情况直接把 nextTickTime 重置为当前时间,放弃追拍,下一拍从当前时刻重新建立节奏。真实场景里,微信小程序切后台再回来,setTimeout 可能被挂起十几秒甚至更久,这个保护是必须的。
4.3 onHide/onUnload 里的强制清理
微信小程序页面有五个生命周期:onLoad、onShow、onReady、onHide、onUnload。节拍器打开着切到微信聊天界面,再回来,这个过程中定时器可能被系统挂起,也可能仍然在跑。规范做法是在 onHide 和 onUnload 都做停止处理:
onHide() { if (this.playing) { this.stop(); } }, onUnload() { this.stop(); this.accentAudio.destroy(); this.normalAudio.destroy(); }onHide 里调用 stop 是让用户切出去后声音立刻停,避免“后台还在响”的投诉;onUnload 里额外调用 destroy 销毁音频实例,释放底层资源。InnerAudioContext 实例不销毁会一直占内存,页面卸载后没有及时销毁是内存告警的常见来源。
4.4 无音频场景:wx.vibrateShort 做触感节拍
如果你的 demo 没有音频资源,或者用户手机开了静音模式,可以用震动替代声音。wx.vibrateShort 是短震动 API,时长约 15ms,适合做触感节拍。
vibrateTick(beatIndex) { wx.vibrateShort({ type: beatIndex === 0 ? 'heavy' : 'light', fail: (err) => console.warn('vibrate failed', err) }); }type 字段在基础库 2.13.0 及以上支持 medium、heavy、light 三档,旧版本不传该参数则使用默认强度。把震动放触感上也符合鼓手用耳机监听时的手腕感知需求。注意它不支持后台调用,页面不可见时震动会失败,所以和定时器清理逻辑配套使用。
下面把模拟器和真机的差异整理成参数表,方便调试时对照:
| 检查项 | 模拟器表现 | 真机表现 | 处理方式 |
|---|---|---|---|
| 静音键 | 无影响 | 默认无声 | obeyMuteSwitch: false |
| 定时器精度 | 较稳 | 后台挂起约 5~30s | 绝对时间戳 + 漂移重置 |
| 快速连击音频 | 叠加不明显 | 明显叠加噪声 | 每次播放前 stop |
| 资源路径 | 本地文件直接读 | 需在包内 | 使用相对路径 |
| 版本兼容 | 统一最新 | 用户基础库各异 | 微信开发者工具中“真机调试”最低版本启动 |
5. 给 demo 加一个看得见的节拍器:CSS animation 指示器与 BPM 联动
节拍器不能只听,还要“看”。练习乐器的人经常需要目测节奏点来对齐手腕动作。常见做法是在页面上放一个圆形区域,里面有一个摆锤或者环形进度条。环形进度条用 CSS animation 实现最轻量:把 animation-duration 设成 60000 除以 BPM 的秒数,animation-timing-function 用 linear,每一圈代表一拍。
.beat-ring { width: 240rpx; height: 240rpx; border: 8rpx solid #3a3a3a; border-radius: 50%; position: relative; overflow: hidden; } .beat-hand { position: absolute; left: 50%; top: 50%; width: 4rpx; height: 50%; background: #f5a623; transform-origin: 50% 0%; animation: beat-rotate linear infinite; } @keyframes beat-rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }JS 侧只需要在 BPM 变化时更新 animation-duration:
setBpm(newBpm) { this.setData({ bpm: newBpm, handDuration: `${60 / newBpm}s` }); }WXML 里把 hand 的 style 绑定成:
<view class="beat-hand" style="animation-duration: {{handDuration}}"></view>animation-duration 的最小单位是 0.01s,BPM 到 208 时一拍的秒数是 0.288s,还在可控范围。真正的进阶点是让视觉动画和音频同步:CSS animation 和 JS 定时器是两个独立时钟,运行几十秒后可能出现视觉指针已经指到 12 点、声音却响在 11 点半的位置。解决思路是在每次音频回调时记录当前动画的偏移量,用 animation-delay 的负值修正。
我一贯的做法是:只在 firstBeatTime 重设一次动画起点。start 时记录 Date.now(),在 CSS 里通过 style 传入animation-delay: -{{elapsed}}s,让指针从当前相位继续转,而不是强行从 0 开始。这个修正能把视觉和听觉的偏差控制在人眼可接受范围。如果你要发布这个 demo,把画面指针、音频 tick、BPM 换算三者的时间基准统一到“下一次绝对时间戳”这一个变量上,比分别维护三个时钟要可靠得多。
本文还有配套的精品资源,点击获取