
简介这是一个面向微信小程序初学者与音视频处理爱好者的节拍器Demo完整源码适合用于学习小程序开发流程和音频播放控制。项目以节拍器为实际场景完整展示了WXML页面布局、WXSS样式、JavaScript业务逻辑、mp3音频加载、定时器和动画API的协作方式可帮助读者理解从页面搭建到交互实现的完整链路。压缩包共18个文件以JS逻辑文件、WXML页面、WXSS样式、PNG图片和MP3音频资源为主另含JSON配置文件整体仅67KB结构清晰可直接导入微信开发者工具运行。已有469人学习/下载适合作为小程序入门练习与二次开发的参考。通过这份源码可以学习到数据绑定、事件处理、wx.createInnerAudioContext音频接口、setInterval定时器以及动画指示器实现等关键知识点同时借助简洁的目录结构和配套音频素材便于对照调试页面导航、权限请求与发布流程为后续开发工具类或媒体类小程序积累可复用的代码思路。1. 把“简单节拍器 demo”拆开看它不是一个播放器而是一个状态机很多练琴、练鼓的朋友手机上装过专业节拍器 App但每次想用还得解锁、切应用、找图标微信小程序做节拍器的价值不在功能多而在“打开即用”。这个 demo 标题里最关键的两个词是“简单”和“完整源码”简单意味着没有复杂音轨、没有节拍器预设库核心就是 BPMBeats 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.560000 除以 120.5 是 497.9ms直接取整会导致每拍误差 0.1ms几百拍后累计出可感知的相位偏移。所以参数表里要明确BPM 用整数或者用浮点数计算但每次都拿“下一次响的绝对时间”做差值。常见档位换算可以提前在 UI 上标注BPM单拍间隔ms每秒拍数常见用途4015000.67慢速练习6010001.0入门/慢速906671.5行板1205002.0中速流行1603752.67快板2082883.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.0createInnerAudioContext 在旧版本上有兼容问题再检查 obeyMuteSwitch 是否设置为 falseiPhone 静音键会导致默认路径无声最后确认音频资源有没有被打包进小程序资源放在 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-durationsetBpm(newBpm) { this.setData({ bpm: newBpm, handDuration: ${60 / newBpm}s }); }WXML 里把 hand 的 style 绑定成view classbeat-hand styleanimation-duration: {{handDuration}}/viewanimation-duration 的最小单位是 0.01sBPM 到 208 时一拍的秒数是 0.288s还在可控范围。真正的进阶点是让视觉动画和音频同步CSS animation 和 JS 定时器是两个独立时钟运行几十秒后可能出现视觉指针已经指到 12 点、声音却响在 11 点半的位置。解决思路是在每次音频回调时记录当前动画的偏移量用 animation-delay 的负值修正。我一贯的做法是只在 firstBeatTime 重设一次动画起点。start 时记录 Date.now()在 CSS 里通过 style 传入animation-delay: -{{elapsed}}s让指针从当前相位继续转而不是强行从 0 开始。这个修正能把视觉和听觉的偏差控制在人眼可接受范围。如果你要发布这个 demo把画面指针、音频 tick、BPM 换算三者的时间基准统一到“下一次绝对时间戳”这一个变量上比分别维护三个时钟要可靠得多。本文还有配套的精品资源点击获取