简介这是一份微信小程序节拍器demo完整源码包面向刚入门小程序开发或希望了解音频播放与定时器应用的开发者。项目按标准小程序目录组织wxml与wxss构建页面结构样式js处理节拍逻辑与事件png为界面图标与背景mp3提供节拍音效json保存全局配置。整个压缩包共18个文件仅67KB轻量精悍适合快速阅读与二次修改文件夹内页面、脚本、音频与图标分区明确便于逐模块对照学习。源码包含wx.createInnerAudioContext音频播放、动画API、定时器等关键技术的实际调用同时兼顾页面导航与数据绑定帮助理解从界面渲染到音频触发的完整链路。已有469人学习下载对想从零跑通一个微信小程序demo、梳理小程序框架与音视频处理流程的开发者来说是一份不错的实操参考。1. 微信小程序节拍器demo功能少但「准」字难微信小程序里做节拍器功能列表短到一页纸一个 BPM 滑杆、一个开始按钮、一个会闪的圆。可真正把 demo 跑在真机上才发现这类工具类小程序的难点全在「准」字上。倒计时器差 100ms 没人注意节拍器差 30ms 就明显拖拍再叠上小程序逻辑层与渲染层分离的架构setInterval 在这种场景下几乎是必抖。下面这套微信小程序节拍器 demo 源码按原生小程序组织不依赖第三方组件把绝对时间线调度、WebAudio 合成音、CSS 动画相位对齐和 WXS 手势直控串在一起。想交微信小程序毕业设计、要微信小程序项目实例做底子的同学可以直接跑被定时抖动折腾过的前端也能在参数细节里找到原因。2. 微信小程序节拍器demo选型定时从哪抖音频从哪慢2.1 逻辑层与渲染层分离下setInterval 为什么必抖小程序的双线程模型和浏览器不一样逻辑层跑在独立的 JS 引擎线程里渲染层在 WebView 侧。setInterval 注册在逻辑层回调本身由 JS 引擎按固定周期排队但实际触发时刻取决于两件事当前回调的执行时长以及任务队列里排了多少活。一旦回调里出现 setData、音频 buffer 创建、字符串拼接这类耗时操作单次回调就能吃进去 20~60ms。更麻烦的是任务队列的积压行为当某一次回调耗时超过一拍间隔引擎会把积压的回调连续执行听感变成「一拍提前、一拍停顿」。节拍器 120BPM 时一拍间隔是 500ms这种抖动会直接毁掉节奏。开发者工具里又是一套表现。PC 浏览器对后台标签页有节流策略切走再切回来setInterval 会突然补发一堆回调。真机上锁屏或切后台逻辑层直接被挂起。所以我的结论是setInterval 不能作为节拍器的唯一时间源它只配当「轮询器」。2.2 按拍次累加的绝对时间线把间隔改成对表常见的错误写法是每拍执行完再setTimeout(play, 60000 / bpm)这属于「每步重新对齐」听着没问题但每拍回调里的耗时都会被累加进下一拍的等待里。更稳的做法是维护一条绝对时间线启动时定好起点之后每拍只做一件事当前时间有没有越过下一拍的时间点。const BPM 120 const INTERVAL 60000 / BPM // 一拍 500ms let beatIndex 0 let nextTime Date.now() INTERVAL function scheduler() { const now Date.now() const ahead 80 // 提前量单位 ms while (nextTime now ahead) { playClick(beatIndex) beatIndex nextTime INTERVAL } } setInterval(scheduler, 25)解释一下这段代码nextTime从启动那一刻开始每发一拍就累加一个INTERVAL。scheduler被一个 25ms 的 setInterval 反复调用每次只检查now ahead是否越过了nextTime。ahead 80表示提前 80ms 触发播放用来抵消调用本身的调度延迟。如果某次回调被长任务卡了 200mswhile 循环会一口气把欠下的拍全部补发听感依然是等间隔而不是两拍挤在一起。这里三个参数要按需调轮询间隔 25ms比一拍小一个数量级足够敏感ahead推荐 60~120ms太小容易迟到太大会让声音提前于预期INTERVAL在 BPM 变化时必须重算不能复用旧值。2.3 四种定时方案对照什么时候该用谁方案误差表现适用位置备注setInterval 固定轮询回调耗时直接叠加页面级低频率轮询长时间运行会漂移不可作唯一时钟递归 setTimeout每次重新计时单次延迟触发比 setInterval 略稳仍受长任务影响绝对时间线 while 补发只依赖 Date.now 单调性节拍调度主循环本 demo 采用卡顿后能自我修复WXS 事件回调视觉层渲染不占逻辑层时间手势、滑杆直控不能定时只做视图层更新如果项目是 uni-app 微信小程序定时器模型完全一样算法可以直接平移只是音频 API 名会变原生的wx.createInnerAudioContext在 uni-app 里是uni.createInnerAudioContextWebAudio 相关能力各端支持度不一建议按条件编译处理。核心思路不变时间线归时间线UI 更新归 UI 更新。2.4 出声延迟是第二个瓶颈文件音频 vs 合成音定时算准了还不够声音本身也得准。wx.createInnerAudioContext播放 mp3 文件时首次调用从 play 到出声之间要经过解码、加载、音频会话启动真机上第一次延迟可能上百毫秒。就算预加载后续每次 play 也有不可忽略的延迟抖动。对节拍器这种「同一音色高频重复触发」的场景更好的选择是合成音。基础库 2.19.0 起可以用wx.createWebAudioContext把 click 声直接写进 AudioBuffer再通过 AudioBufferSourceNode 输出链路短、延迟稳定。做法是启动时把重拍和弱拍的短音各合成一个 buffer 缓存起来后续每拍只需要 new 一个 bufferSource 播放缓存CPU 开销极低。音频先行、视觉后跟宁让视觉慢 20ms也别让声音提前。3. 把节拍器demo的完整源码落进小程序目录3.1 工程结构与 app.json 注册一个能直接跑的最小工程只需要四个文件加一个页面目录app.json pages/index/index.wxml pages/index/index.wxss pages/index/index.js utils/audio.jsapp.js里写App({})留空即可本 demo 没有全局状态。关键是app.json的页面注册小程序启动时会找pages数组的第一项{ pages: [pages/index/index], window: { navigationBarTitleText: 微信小程序节拍器demo } }如果之前从模板创建的项目里带了sitemap.json和project.config.json保留不动即可。pages数组只能有一个页面时也合法demo 不需要 tabBar。看清楚navigationBarTitleText的位置旧版本模板里可能写在window外面导致标题不生效。3.2 页面骨架BPM滑杆、拍号选择与节拍圆盘index.wxml 的职责是把三件事摆清楚显示节奏的圆盘、调整 BPM 的滑杆、选择拍号的 picker。圆盘用 CSS 动画驱动按钮控制启停。view classpage view classbeat-wrap view classbeat-ring {{running ? running : }} styleanimation-duration: {{durationMs}}ms; animation-delay: {{phaseDelay}}ms; /view view classbeat-dot {{accent ? accent : }} {{beatNo 1}} /view /view view classcontrol text classlabelBPM: {{bpm}}/text slider min40 max240 step1 value{{bpm}} bindchangeonBpmChange / picker modeselector range{{barOptions}} value{{barIndex}} bindchangeonBarChange view classbar-picker{{barOptions[barIndex]}} 拍/小节/view /picker button bindtaponToggle{{running ? 停止 : 开始}}/button /view /viewbeat-ring是外圈扩散的圆环beat-dot是中间的小球显示当前是第几拍。style里的animation-duration和animation-delay由 JS 侧算好塞进 data这是后面动效对齐的关键。slider 用bindchange而不是bindchanging前者只在拖动结束时触发后者会高频 setDatademo 阶段先避开这个坑第 5 章再讲 WXS 直控方案。3.3 wxss 里只用 transform 和 opacity 做动效小程序里频繁触发width、height、left的动画会反复走布局管线在低端 Android 机型上直接表现为掉帧。节拍器的扩散圆环只改缩放和透明度这两个属性走合成器不触发 layout渲染进程压力小得多。.beat-ring { width: 220rpx; height: 220rpx; border-radius: 50%; background: rgba(38, 132, 255, 0.15); opacity: 0; transform: scale(0.6); } .beat-ring.running { animation-name: ringPulse; animation-timing-function: ease-out; animation-iteration-count: infinite; } keyframes ringPulse { 0% { transform: scale(0.6); opacity: 0.6; } 100% { transform: scale(1.6); opacity: 0; } } .beat-dot.accent { background: #ff4d4f; transform: scale(1.15); }动画的时长由内联 style 传入所以keyframes里不写固定时间。ease-out让圆环扩散时前快后慢符合节拍器摆锤的视觉惯性。accent 类只负责重拍变色切换 class 不会让整个圆盘重排。3.4 逻辑层调度循环、时间线重排与生命周期清理index.js 是这份源码的核心。启动时记录启动时刻、初始化nextTime然后开一个 25ms 的轮询器。每次轮询检查绝对时间线到点就播放声音并更新拍号。const { createSynth } require(../../utils/audio) Page({ data: { bpm: 120, barOptions: [2, 3, 4, 6], barIndex: 2, // 4 拍 running: false, beatNo: 0, accent: true, durationMs: 500, phaseDelay: 0 }, onLoad() { this.synth createSynth() this.timer null this.nextTime 0 this.beatIndex 0 }, onToggle() { if (this.data.running) { this.stop() } else { this.start() } }, start() { const interval 60000 / this.data.bpm const now Date.now() const lead 30 // 启动后第一拍实际发生的时间 this.nextTime now lead this.beatIndex 0 this.setData({ running: true, durationMs: interval, phaseDelay: -(interval - lead), beatNo: 0, accent: true }) this.timer setInterval(() this.scheduler(), 25) }, stop() { clearInterval(this.timer) this.timer null this.setData({ running: false }) }, scheduler() { const now Date.now() const ahead 80 while (this.nextTime now ahead) { const inBar this.beatIndex % this.data.barOptions[this.data.barIndex] this.synth.playClick(inBar 0) this.setData({ beatNo: inBar, accent: inBar 0 }) this.nextTime 60000 / this.data.bpm this.beatIndex } }, onBpmChange(e) { this.setData({ bpm: e.detail.value }) if (this.data.running) { this.start() // 重排时间线避免旧间隔残留 } }, onBarChange(e) { this.setData({ barIndex: Number(e.detail.value) }) this.beatIndex 0 if (this.data.running) { this.start() } }, onUnload() { this.stop() if (this.synth) { this.synth.dispose() } } })逻辑说明start()里用nextTime now lead把第一拍定在 30ms 后phaseDelay同步算出 CSS 动画的负延迟让圆环和声音从同一起点出发。scheduler()的 while 循环是绝对时间线的实体每拍先播放、再更新 UI、最后推进nextTime。onBpmChange里直接调用this.start()是刻意为之BPM 变了但旧nextTime还按旧间隔累加会出现错拍最省事的方式是整条时间线重拉。onBarChange重置beatIndex否则切到三拍后第一拍不是重拍。onUnload里必须清定时器微信小程序的页面实例不会自动释放 setInterval切换页面后驻留会导致声音继续响。3.5 音频合成WebAudio 三音色替代音频文件utils/audio.js 里只做一件事生成两种短音 buffer重拍一个频率、弱拍一个频率播放时从缓存里取不重复计算。function makeClick(ctx, freq, duration) { const sampleRate ctx.sampleRate const length Math.floor(sampleRate * duration) const buffer ctx.createBuffer(1, length, sampleRate) const data buffer.getChannelData(0) for (let i 0; i length; i) { const t i / sampleRate // 正弦波 衰减包络衰减指数 2.5 让尾音短促干净 data[i] Math.sin(2 * Math.PI * freq * t) * Math.pow(1 - t / duration, 2.5) } return buffer } function createSynth() { const ctx wx.createWebAudioContext ? wx.createWebAudioContext() : null if (!ctx) { return { playClick: () {}, dispose: () {} } } const downbeat makeClick(ctx, 1000, 0.06) const weak makeClick(ctx, 660, 0.04) return { playClick(isDownbeat) { const source ctx.createBufferSource() source.buffer isDownbeat ? downbeat : weak source.connect(ctx.destination) source.start(ctx.currentTime) }, dispose() { ctx.close ctx.close() } } } module.exports { createSynth }参数说明重拍频率 1000Hz 偏亮弱拍 660Hz 偏沉人耳能瞬间区分。时长上重拍 60ms、弱拍 40ms太长的 click 在高速 BPM 下会叠音。衰减指数 2.5 是把振幅快速压下去的关键改成 1 的话音尾会拖出明显的嗡嗡声。createSynth里做了能力检测wx.createWebAudioContext不存在时返回空实现页面不会白屏只是没有声音。4. 动效对齐与真机校准让第一拍和圆环同时起来4.1 用 animation-delay 负值把视觉相位锁到声音上CSS 动画启动时永远从 0% 开始播但声音的第一拍发生在启动后 30ms这 30ms 不足以让动画从 0 走到有存在感的位置。解决方式是给动画一个负延迟让浏览器认为动画已经播了interval - lead毫秒。启动那一刻的圆环状态正好落在「上一拍刚结束、下一拍即将扩散」的位置。const interval 60000 / this.data.bpm const lead 30 this.setData({ durationMs: interval, phaseDelay: -(interval - lead) })WXML 里animation-delay: {{phaseDelay}}ms会被渲染成负值这是标准 CSS 行为。注意此处的对齐只在启动时校准一次后续 CSS 动画自己按durationMs循环JS 时间线负责声音两者不再逐拍同步。对节拍器来说足够耳朵做主时钟眼睛只负责给出大致的节奏轮廓。如果想做到视觉逐拍严格咬合那要放弃 CSS 动画改成每拍由 JS 切换 class但每次 setData 都会引入一帧延迟真机上反而不稳。4.2 重拍高亮与拍号切换的索引重置重拍判定放在scheduler里做const inBar this.beatIndex % this.data.barOptions[this.data.barIndex] this.synth.playClick(inBar 0) this.setData({ beatNo: inBar, accent: inBar 0 })beatIndex从 0 累加取模后 0 就是每小节第一拍。两个细节容易踩一是barIndex是 picker 的选中索引不是拍号本身要写成barOptions[barIndex]二是 picker 返回的e.detail.value是字符串Number()转一下再存否则取模会变成隐式转换逻辑上没问题但代码里留字符串索引迟早出事。4.3 统一时间基准把 Date.now 和 ctx.currentTime 换算到同一根轴真机上ctx.currentTime由音频线程推进不受逻辑层长任务影响理论上比 Date.now 更稳。但它是音频时钟和系统时间不对齐。常见做法是记录两者差值后面都走换算后的统一时钟function createClock(ctx) { const useAudioClock !!(ctx typeof ctx.currentTime number) const offset useAudioClock ? Date.now() - ctx.currentTime * 1000 : 0 return { now() { return useAudioClock ? ctx.currentTime * 1000 offset : Date.now() } } }offset在启动时算一次。之后scheduler()里的now一律用clock.now()播放声音时把ctx.currentTime留白让音频线程自己接管视觉上的phaseDelay也按这个时钟推。如果真机上currentTime不推进部分 Android 内核的 WebAudio 实现有这个问题useAudioClock判定成立但数值卡死要再包一层连续两次读取差值小于 1ms 就回退 Date.now。这种降级逻辑在低端机上比单纯用 WebAudio 更抗造。5. WXS 直控 BPM 滑杆demo 手感的最后一公里5.1 高频 setData 会让节拍呼吸slider 的bindchanging在拖动过程中高频触发每触发一次就是一次逻辑层到渲染层的全量数据通信。拖动两秒可能触发三四十次 setData每次都把逻辑层的任务队列塞满25ms 的调度轮询被挤到 100ms 之后节拍立刻变慢松手后又突然追上。这不是 API 的问题是通信模型的问题。处理思路是把「拖动时的视觉反馈」留在视图层交给 WXS 直接改样式松手后再通知逻辑层更新 BPM。5.2 用 WXS 响应手势样式直接改数据最后传在pages/index/index.wxs里写一个触摸控制器用三个事件函数模拟滑杆拖动。WXS 是 ES5 子集不能用箭头函数变量声明用var。var startX 0 var startBpm 120 var currentBpm 120 function onTouchStart(e, ownerInstance) { var touch e.touches[0] startX touch.clientX startBpm currentBpm } function onTouchMove(e, ownerInstance) { var touch e.touches[0] var dx touch.clientX - startX var next Math.min(240, Math.max(40, Math.round(startBpm dx * 0.5))) currentBpm next var bar ownerInstance.selectComponent(.bpm-fill) var num ownerInstance.selectComponent(.bpm-num) if (bar) { bar.setStyle({ width: ((next - 40) / 200 * 100) % }) } if (num) { num.setStyle({ transform: scale(1.1) }) } } function onTouchEnd(e, ownerInstance) { ownerInstance.selectComponent(.bpm-num).setStyle({ transform: scale(1) }) ownerInstance.triggerEvent(bpmchange, { value: currentBpm }) } module.exports { onTouchStart: onTouchStart, onTouchMove: onTouchMove, onTouchEnd: onTouchEnd }对应 WXML 里用{{ }}引用模块方法这是 WXS 事件绑定和普通事件绑定最容易写错的地方wxs modulebpmTouch src./index.wxs / view classbpm-track bindtouchstart{{bpmTouch.onTouchStart}} bindtouchmove{{bpmTouch.onTouchMove}} bindtouchend{{bpmTouch.onTouchEnd}} view classbpm-fill stylewidth: {{bpmFill}}%/view text classbpm-num{{bpm}}/text /viewownerInstance.selectComponent(.bpm-fill)拿的是视图层组件实例setStyle直接写内联样式完全不经过逻辑层。拖动阈值是每 1 个 px 加 0.5 BPM40~240 的范围映射到 200px 宽的滑轨换算比例里把 200 这个数看清楚换机型改滑轨宽度时要同步改。松手时triggerEvent触发一次页面级的bpmchange事件页面里bind:bpmchangeonBpmChange接收后调用start()重排时间线。整条链路上 setData 只发生一次拖动过程中逻辑层的调度循环不会被干扰。5.3 验证方法连续打点 50 次看最大偏差手感做没做上去测出来才算数。页面里维护一个间隔日志每次 scheduler 播放时记录当前时间攒满 50 拍后对比理论间隔startMeasure() { this.gapLog [] this.lastMark 0 }, markBeat(now) { const last this.lastMark if (last) { this.gapLog.push(now - last) } this.lastMark now if (this.gapLog.length 50) { const expect 60000 / this.data.bpm const maxDev this.gapLog.reduce( (max, gap) Math.max(max, Math.abs(gap - expect)), 0 ) console.warn(50 拍最大偏差(ms):, maxDev) } }把markBeat插到scheduler()的 playClick 之后真机跑一分钟看日志。120BPM 下理论间隔 500ms最大偏差能压在 30ms 以内算合格超过 80ms 基本就是逻辑层被长任务堵住回到 5.1 拆 setData。这个打点方法同样适用于验证 WXS 改造前后的差距改造前拖动滑杆时日志里会出现 600ms、800ms 的离群点改造后离群点基本消失。本文还有配套的精品资源点击获取
