#节拍器
节拍器
这是一个功能完整的网页版节拍器,拥有视觉化的单摆动画和可调节的节拍设置。下面我将从几个方面为你详细分析它的工作原理。
#整体功能概述
这个页面模拟了一个真实的机械节拍器。它通过一个左右摆动的单摆和同步的提示音,帮助音乐练习者掌握稳定的节奏。用户可以自由调整速度(BPM)和每小节的拍数。
#核心功能模块分析
#1. 视觉界面
- 单摆动画:页面顶部有一个带摆锤的摆杆,它会随着节拍左右摆动。摆动的幅度固定为40度。
- 节拍指示灯:下方有一排圆点,数量等于“每小节拍数”。每个节拍响起时,对应的圆点会高亮(第一拍为红色,其余为蓝色),直观地显示当前是第几拍。
- 控制面板:包含速度滑块、拍数输入框和开始/停止按钮。
#2. 核心逻辑与代码亮点
节拍调度器
- 代码使用Web Audio API高精度的
audioContext.currentTime来安排声音的播放时间。scheduler函数会提前0.1秒计算并安排好未来需要播放的节拍,确保声音稳定、不卡顿,即使在后台标签页也能相对准确地运行。
动画同步
- 为了实现声音与动画的精确同步,代码在
scheduleNote函数中,根据声音计划的播放时间 (time) 和当前时间,计算出精确的延迟 (timeUntilPlay),然后使用setTimeout来触发动画。这确保了摆锤每次摆到中心点(也就是发出“滴答”声的瞬间)时,动画恰好开始。 - 动画本身使用了现代、流畅的 Web Animations API。摆锤的运动曲线被设置为“减速-加速”,模拟了真实单摆受重力影响的效果。
节拍声音
- 声音通过Web Audio API动态生成。第一拍(强拍)使用1000Hz的高音,其余拍使用700Hz的低音,通过音高来区分重音。
- 每个声音都很短(0.05秒),并带有渐弱的效果 (
exponentialRampToValueAtTime),听起来清脆不刺耳。
状态管理
- 点击“开始”按钮,会初始化音频上下文、重置节拍计数器、启动调度器,按钮变为“停止”。
- 点击“停止”会清除所有待执行的定时器、取消动画、将摆锤归零,并熄灭所有指示灯。
#3. 用户交互与数据流
- 调整速度:拖动滑块时,
bpm变量实时更新,但只有在下次开始或下一轮调度时,新的速度才会生效。 - 调整拍数:改变“每小节拍数”后,下方的指示灯数量会立即变化。节拍器在运行时改变此值,也会在下一小节按新规则循环。
- 启动/停止:这是整个程序的主开关,控制着所有核心循环的运行与销毁。
#源码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>节拍器</title>
<link rel="canonical" href="https://xplanc.org/primers/document/zh/03.HTML/90.实践案例/AI.节拍器.md"/>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #2c3e50;
color: #ecf0f1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 0;
}
.metronome {
background: #34495e;
padding: 30px;
border-radius: 15px;
box-shadow: 0 15px 35px rgba(0,0,0,0.5);
box-sizing: border-box;
width: 100%;
max-width: 320px;
text-align: center;
}
/* 摆动动画区域 */
.pendulum-container {
position: relative;
height: 200px;
margin-bottom: 20px;
border-bottom: 2px solid #7f8c8d;
overflow: hidden;
}
/* 🔥 新增:中心线 */
.center-line {
position: absolute;
left: 50%;
bottom: 0;
width: 2px;
height: 180px;
transform: translateX(-50%);
z-index: 1;
border-style: dashed;
border-width: 0 2px 0 0;
border-color: rgba(231, 76, 60, 0.8);
}
.pivot {
width: 14px;
height: 14px;
background: #ecf0f1;
border-radius: 50%;
position: absolute;
bottom: -7px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.arm {
width: 6px;
height: 180px;
background: #bdc3c7;
position: absolute;
bottom: 0;
left: 50%;
transform-origin: bottom center;
margin-left: -3px;
transform: rotate(0deg);
z-index: 5;
/* 移除了之前的 CSS transition,改用 JS 控制 */
}
.weight {
width: 24px;
height: 24px;
background: #f1c40f;
border-radius: 50%;
position: absolute;
left: -9px;
top: 40px;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
/* 控制面板 */
.controls {
display: flex;
flex-direction: column;
gap: 15px;
}
.setting {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 18px;
}
input[type="range"] {
width: 100%;
margin-top: 10px;
}
input[type="number"] {
width: 60px;
padding: 5px;
border-radius: 5px;
border: none;
text-align: center;
font-size: 16px;
}
/* 节拍指示灯 */
.dots {
display: flex;
justify-content: center;
gap: 10px;
margin: 15px 0;
}
.dot {
width: 16px;
height: 16px;
border-radius: 50%;
background: #7f8c8d;
}
.dot.active {
background: #3498db;
box-shadow: 0 0 10px #3498db;
}
.dot.active-first {
background: #e74c3c;
box-shadow: 0 0 10px #e74c3c;
}
/* 播放按钮 */
#playBtn {
background: #2ecc71;
color: white;
border: none;
padding: 12px;
font-size: 20px;
border-radius: 8px;
cursor: pointer;
transition: background 0.3s;
font-weight: bold;
}
#playBtn.playing {
background: #e74c3c;
}
</style>
</head>
<body>
<h1>节拍器</h1>
<div class="metronome">
<div class="pendulum-container">
<div class="center-line"></div>
<div class="pivot"></div>
<div class="arm" id="arm">
<div class="weight"></div>
</div>
</div>
<div class="dots" id="dotsContainer"></div>
<div class="controls">
<div class="setting">
<label for="bpmSlider">速度 (BPM): </label>
<input type="number" id="bpmValue" min="1" max="240" value="120">
</div>
<input type="range" id="bpmSlider" min="1" max="240" value="120">
<div class="setting">
<label for="beatsInput">每小节拍数:</label>
<input type="number" id="beatsInput" min="1" max="12" value="4">
</div>
<button id="playBtn">开 始</button>
</div>
</div>
<p><a style="color: #bdc3c7; text-decoration: none;" href="https://xplanc.org/primers/document/zh/03.HTML/90.实践案例/AI.节拍器.md">获取源码</a></p>
<script>
let audioContext;
let isPlaying = false;
let beatsPerBar = 4;
let bpm = 120;
let currentBeat = 0;
let nextNoteTime = 0.0;
let timerID;
let swingRight = true;
let currentAnimation = null; // 用于追踪当前动画以便随时取消
const playBtn = document.getElementById('playBtn');
const bpmSlider = document.getElementById('bpmSlider');
const bpmValue = document.getElementById('bpmValue');
const beatsInput = document.getElementById('beatsInput');
const arm = document.getElementById('arm');
const dotsContainer = document.getElementById('dotsContainer');
function initDots() {
dotsContainer.innerHTML = '';
for (let i = 0; i < beatsPerBar; i++) {
const dot = document.createElement('div');
dot.classList.add('dot');
dotsContainer.appendChild(dot);
}
}
initDots();
function updateDots(beat) {
const dots = document.querySelectorAll('.dot');
dots.forEach(dot => dot.classList.remove('active', 'active-first'));
if (dots[beat]) {
dots[beat].classList.add(beat === 0 ? 'active-first' : 'active');
}
}
// 🔥 重新设计的物理动画逻辑
function animateArm() {
if (currentAnimation) currentAnimation.cancel();
const msPerBeat = (60.0 / bpm) * 1000;
const angle = 40; // 最大摆动角度
// 使用 Web Animations API 精确控制相位:
// 关键帧 1 (起点): 0度 (中心线发声时刻) -> 逐渐减速离开中心 (ease-out)
// 关键帧 2 (极点): 到达最高点 -> 逐渐加速回落 (ease-in)
// 关键帧 3 (终点): 回到 0度 (准备下一次发声)
const keyframes = [
{ transform: 'rotate(0deg)', easing: 'ease-out' },
{ transform: `rotate(${swingRight ? angle : -angle}deg)`, easing: 'ease-in' },
{ transform: 'rotate(0deg)' }
];
currentAnimation = arm.animate(keyframes, {
duration: msPerBeat,
iterations: 1,
fill: 'forwards'
});
swingRight = !swingRight;
}
function nextNote() {
const secondsPerBeat = 60.0 / bpm;
nextNoteTime += secondsPerBeat;
currentBeat++;
if (currentBeat >= beatsPerBar) currentBeat = 0;
}
function scheduleNote(beatNumber, time) {
const osc = audioContext.createOscillator();
const envelope = audioContext.createGain();
osc.connect(envelope);
envelope.connect(audioContext.destination);
osc.frequency.value = beatNumber === 0 ? 1000.0 : 700.0;
envelope.gain.value = 1;
envelope.gain.exponentialRampToValueAtTime(1, time + 0.001);
envelope.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
osc.start(time);
osc.stop(time + 0.05);
const timeUntilPlay = (time - audioContext.currentTime) * 1000;
// 确保动画和声音在经过中心线的那一毫秒精确同步触发
setTimeout(() => {
updateDots(beatNumber);
animateArm();
}, Math.max(0, timeUntilPlay));
}
function scheduler() {
while (nextNoteTime < audioContext.currentTime + 0.1) {
scheduleNote(currentBeat, nextNoteTime);
nextNote();
}
timerID = setTimeout(scheduler, 25.0);
}
playBtn.addEventListener('click', () => {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
isPlaying = !isPlaying;
if (isPlaying) {
if (audioContext.state === 'suspended') audioContext.resume();
currentBeat = 0;
nextNoteTime = audioContext.currentTime + 0.05;
swingRight = true;
scheduler();
playBtn.textContent = "停 止";
playBtn.classList.add('playing');
} else {
clearTimeout(timerID);
if (currentAnimation) currentAnimation.cancel();
playBtn.textContent = "开 始";
playBtn.classList.remove('playing');
arm.style.transform = 'rotate(0deg)'; // 停止时立刻回到中心线
updateDots(-1);
}
});
bpmSlider.addEventListener('input', (e) => {
bpm = parseInt(e.target.value);
bpmValue.value = bpm;
});
bpmValue.addEventListener('input', (e) =>{
let val = parseInt(e.target.value);
if (val < 1) val = 1;
if (val > 240) val = 240;
bpm = val;
bpmSlider.value = val;
})
beatsInput.addEventListener('change', (e) => {
let val = parseInt(e.target.value);
if (val < 1) val = 1;
if (val > 12) val = 12;
beatsPerBar = val;
e.target.value = val;
initDots();
if (currentBeat >= beatsPerBar) currentBeat = 0;
});
</script>
</body>
</html>