目录
前言
本文章通过使用原生的音频播放器提供一个可自由组合播放不同音频的思路和示例,可自由控制重音和轻音。在了解如何实现节拍器之前,我们需要知道什么是bpm和节拍,如已经了解过或只想白嫖代码,请看最后的代码示例。
1.bpm
BPM 是 "Beats Per Minute" 的缩写,意思是“每分钟的拍子数”。在音乐中,BPM 表示一首歌曲的速度有多快或多慢。简单来说,它告诉我们一首歌在一分钟内有多少个拍子。
例如,如果一首歌的 BPM 是 120,那就意味着这首歌每分钟有 120 个拍子。你可以想象一下,如果跟着这首歌数拍子,每秒钟你会数到 2 个拍子(因为 60 秒 × 2 = 120)。
总之,BPM 越高,音乐的速度就越快;BPM 越低,音乐的速度就越慢。
2. 节拍
音乐节拍是指音乐中强拍和弱拍的组合规律。简单来说,它告诉我们音乐中的节奏是如何安排的。在乐谱里,每一小节的音符总长度是固定的,我们可以通过不同的节拍来组织这些音符。
这里有一些常见的节拍类型:
- 1/4拍:每小节1拍,通常用在特别简单的例子中。
- 2/4拍:每小节2拍,比如“强-弱”。
- 3/4拍:每小节3拍,比如“强-弱-弱”,常用于华尔兹等舞曲。
- 4/4拍:每小节4拍,比如“强-弱-次强-弱”,这是最常见的节拍之一,给人一种稳定的感觉。
- 3/8拍:每小节3拍,但每拍用8分音符来计算,比如“强-弱-弱”。
- 6/8拍:每小节6拍,但通常感觉像是两大拍,比如“强-弱-弱;次强-弱-弱”。
在乐谱中,节拍是用一个分数来表示的,称为拍号。这个分数的分母告诉你哪个音符代表一拍(比如4分音符或者8分音符),分子则告诉你每小节有多少拍。
例如:
- 2/4 表示每小节有2拍,4分音符为一拍。
- 3/4 表示每小节有3拍,4分音符为一拍。
- 6/8 表示每小节有6拍,8分音符为一拍。
音乐中的节拍就像是给音乐打上了稳定的节奏基础,帮助演奏者和听众更好地理解音乐的结构。
3.节拍和 BPM 的关系
- 确定拍子的时值:当你知道了节拍,你就能知道每小节中有多少拍以及每拍的时值(例如 4 分音符、8 分音符等)。
- 确定速度:BPM 告诉你音乐的播放速度。结合节拍的信息,你可以计算出每个音符持续的时间。例如,如果 BPM 是 120,而节拍是 4/4,那么每个 4 分音符持续的时间就是 1 秒钟(因为一分钟有 120 个 4 分音符)。
- 实际应用:当你知道节拍和 BPM 后,就可以更好地理解音乐的节奏结构,这对于演奏者来说非常重要。例如,在练习打击乐器时,了解这些信息可以帮助你准确地击打每一个音符。
由此我们可以推算出
总之,节拍定义了音乐的结构和节奏模式,而 BPM 则定义了这个结构的速度。两者结合,共同构成了音乐的基本节奏框架。
控制节拍
既然我们知道了bpm的含义和计算方式,那么接下来在代码中就容易实现了。以下是此功能的核心方法之一,计算出播放音频的间隔并补全节拍数组。
1.计算节拍
这个方法接受两个参数bpm和节拍,bpm是数字类型如50,100;节拍是分数如3/8,3/4。
//计算节拍
const calculateBeat = ({ bpm, timeSignature }) => {
const [numerator, denominator] = timeSignature.split('/').map(Number);
beat.value = numerator
note.value = denominator
console.log(beat.value)
// interval.value = 60000 / (bpm * (denominator / 4));
interval.value = 60000 / bpm;
console.log("播放间隔", interval.value)
//重音数组
let beatArray = getStrongBeatPattern(numerator, denominator);
if (beatArray.length == 1) {
beatArray.length = numerator
strongBeatPattern.value = beatArray.fill(0, 1, numerator)
}
strongBeatPattern.value=beatArray
console.log(strongBeatPattern.value)
};
2.返回重音
此函数是为了计算出重音数组,这里用0表示轻音,1表示重音。还可对特定的节拍进行定制化。
const getStrongBeatPattern = (numerator, denominator) => {
//4/2拍
if (denominator === 2 && numerator === 4) {
return [1, 0, 1, 0];
}
// 对于4/4拍,每小节有四拍,重音在第一拍
if (denominator === 4) {
if (numerator === 4) {
return [1, 0, 0, 0]; // 4/4拍的重音模式
}
if (numerator === 5) {
return [1, 0, 0, 1, 0]; // 5/4拍的重音模式,第一拍和第四拍是强音
}
}
// 对于3/8拍,每小节有三拍,重音同样在第一拍
if (denominator === 8 && numerator === 3) {
return [1, 0, 0]; // 3/8拍的重音模式
}
// 对于6/8拍,重音在第1和第4拍
if (denominator === 8) {
if (numerator === 6) {// 6/8拍的重音模式
return [1, 0, 0, 1, 0, 0];
}
if (numerator === 12) {// 12/8拍的重音模式
return [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0];
}
}
// 默认情况下,假设每拍都是强拍
return [1]; // 默认强音模式
};
3.播放节拍
3.1音频播放
在页面中使用最基础的播放器,通过ref控制音频播放。这里我定义了两段音频,你可以定义更多音频,相应的可以在上述函数中用2或3表示。
<audio ref="audio">
<source src="../assets/metronome.mp3" type="audio/ogg" />
</audio>
<audio ref="strongAudio">
<source src="../assets/metronomeT.mp3" type="audio/ogg" />
</audio>
3.2节拍播放
播放节拍使用到了第一个方法中计算出的播放间隔,创建一个定时器并使用它。然后使用一个方法播放轻重音
//播放节拍器
const playMetronome = () => {
intervalId.value = setInterval(() => {
// 使用模式数组判断当前拍子是否为强音
const isStrongBeat = strongBeatPattern.value && strongBeatPattern.value[currentBeat.value] === 1;
playSound(isStrongBeat);
currentBeat.value = (currentBeat.value % beat.value) + 1;
showBeat.value = (showBeat.value % beat.value) + 1;
// 当currentBeat.value超过numerator时,需要重置为1
//计算用
if (currentBeat.value >= beat.value) {
currentBeat.value = 0;
}
//展示用
if (showBeat.value > beat.value) {
currentBeat.value = 0;
}
}, interval.value);
};
const playSound = (isStrong) => {
if (isStrong) {
console.log('strong')
strongAudio.value.play();
} else {
console.log('weak')
audio.value.play();
}
};
节拍器问题
使用这个方法实现节拍的优点是思路简单,便于理解,但是因为每次播放音频都要调用播放器播放约有100ms延迟,如bpm较大会出现实际音频播放跟不上理想播放的情况(我在完整代码示例中使用了简单的动画来表示节拍)
本人对于节拍的理解可能不是太到位,如有错误请评论指出。
我使用了tone.js优化了这个问题,我会在下一篇文章说明这个优化。
完整代码示例
1.vue3
<template>
<div class="meteronome">
<div>bpm: <input type="text" v-model="bpm">
<input id="slider" type="range" value="100" min="1" max="184" step="1" @change="bpmChange" />
<div class="bpmList">
<span v-for="(item, index) in bpmList" :key="index" @click="changeBpm(item.bpm)">
{{ item.name }}
</span>
</div>
</div>
<!-- 节拍点 -->
<div class="meteronomeShow">
<div class="meteronomePoint" v-for="(p, index) in strongBeatPattern" :key="index"
:style="{
backgroundColor: showBeat === index + 1 ? 'red' : '',
width:p==1 ? '30px' : '20px',
height:p==1 ? '30px' : '20px',
}"></div>
</div>
{{ showBeat }}
<div>节拍:{{ timeSignature }}</div>
<div class="beatClassList">
<span v-for="(item, index) in beatClassList" :key="index" @click="changeBeat(item.name)">
{{ item.name }}
</span>
</div>
<div class="btn">
<button @click="playMetronome">播放</button>
<button @click="stopMetronome">暂停</button>
</div>
<audio ref="audio">
<source src="../assets/metronome.mp3" type="audio/ogg" />
</audio>
<audio ref="strongAudio">
<source src="../assets/metronomeT.mp3" type="audio/ogg" />
</audio>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted, reactive } from 'vue';
const bpm = ref(100);
let beat = ref()
let note = ref();
let currentBeat = ref(0);
let showBeat = ref(0);
//输入节拍
const timeSignature = ref('3/8');
let strongBeatPattern = ref([])
let bpmList = reactive([
{
name: "庄板",
bpm: 40
}, {
name: "广板",
bpm: 46
},
{
name: "慢板",
bpm: 52
},
{
name: "小广板",
bpm: 56
},
{
name: "柔板",
bpm: 60
},
{
name: "小柔板",
bpm: 66
},
{
name: "行板",
bpm: 72
},
{
name: "小行板",
bpm: 80
},
{
name: "庄严",
bpm: 88
},
{
name: "中板",
bpm: 96
},
{
name: "小快板",
bpm: 108
},
{
name: "活跃",
bpm: 132
},
{
name: "快板",
bpm: 132
},
{
name: "极快版",
bpm: 160
},
{
name: "急板",
bpm: 184
},
]);
const beatClassList = reactive([
{
name: "2/2",
timeSignature: "12/8"
}, {
name: "3/2",
timeSignature: "4/4"
}, {
name: "4/2",
timeSignature: "3/8"
}, {
name: "1/4",
timeSignature: "6/8"
}, {
name: "2/4",
timeSignature: "12/8"
}, {
name: "3/4",
timeSignature: "3/8"
}, {
name: "4/4",
timeSignature: "3/8"
}, {
name: "5/4",
timeSignature: "3/8"
}, {
name: "3/8",
timeSignature: "3/8"
}, {
name: "6/8",
timeSignature: "3/8"
}, {
name: "9/8",
timeSignature: "3/8"
}, {
name: "12/8",
timeSignature: "3/8"
}
])
const audio = ref(null);
const strongAudio = ref(null);
const intervalId = ref(null);
let interval = ref()
const playSound = (isStrong) => {
if (isStrong) {
console.log('strong')
strongAudio.value.play();
} else {
console.log('weak')
audio.value.play();
}
};
//计算节拍
const calculateBeat = ({ bpm, timeSignature }) => {
const [numerator, denominator] = timeSignature.split('/').map(Number);
beat.value = numerator
note.value = denominator
console.log(beat.value)
// interval.value = 60000 / (bpm * (denominator / 4));
interval.value = 60000 / bpm;
console.log("播放间隔", interval.value)
//重音数组
let beatArray = getStrongBeatPattern(numerator, denominator);
if (beatArray.length == 1) {
beatArray.length = numerator
strongBeatPattern.value = beatArray.fill(0, 1, numerator)
}
strongBeatPattern.value=beatArray
console.log(strongBeatPattern.value)
};
//播放节拍器
const playMetronome = () => {
intervalId.value = setInterval(() => {
// 使用模式数组判断当前拍子是否为强音
const isStrongBeat = strongBeatPattern.value && strongBeatPattern.value[currentBeat.value] === 1;
playSound(isStrongBeat);
currentBeat.value = (currentBeat.value % beat.value) + 1;
showBeat.value = (showBeat.value % beat.value) + 1;
// 当currentBeat.value超过numerator时,需要重置为1
//计算用
if (currentBeat.value >= beat.value) {
currentBeat.value = 0;
}
//展示用
if (showBeat.value > beat.value) {
currentBeat.value = 0;
}
}, interval.value);
};
const getStrongBeatPattern = (numerator, denominator) => {
//4/2拍
if (denominator === 2 && numerator === 4) {
return [1, 0, 1, 0];
}
// 对于4/4拍,每小节有四拍,重音在第一拍
if (denominator === 4) {
if (numerator === 4) {
return [1, 0, 0, 0]; // 4/4拍的重音模式
}
if (numerator === 5) {
return [1, 0, 0, 1, 0]; // 5/4拍的重音模式,第一拍和第四拍是强音
}
}
// 对于3/8拍,每小节有三拍,重音同样在第一拍
if (denominator === 8 && numerator === 3) {
return [1, 0, 0]; // 3/8拍的重音模式
}
// 对于6/8拍,重音在第1和第4拍
if (denominator === 8) {
if (numerator === 6) {// 6/8拍的重音模式
return [1, 0, 0, 1, 0, 0];
}
if (numerator === 12) {// 12/8拍的重音模式
return [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0];
}
}
// 默认情况下,假设每拍都是强拍
return [1]; // 默认强音模式
};
//暂停播放
const stopMetronome = () => {
if (intervalId.value) {
clearInterval(intervalId.value);
intervalId.value = null;
}
};
// 滑块bpm改变
const bpmChange = (event) => {
bpm.value = event.target.value;
if (intervalId.value) {
calculateBeat({ bpm: bpm.value, timeSignature: timeSignature.value });
stopMetronome();
playMetronome();
}
};
// 点击bpm改变
const changeBpm = (newBpm) => {
bpm.value = newBpm;
if (intervalId.value) {
calculateBeat({ bpm: bpm.value, timeSignature: timeSignature.value });
stopMetronome();
playMetronome();
}
};
//更新节拍
const changeBeat = (newTimeSignature) => {
timeSignature.value = newTimeSignature;
calculateBeat({ bpm: bpm.value, timeSignature: timeSignature.value });
if (intervalId.value) {
stopMetronome();
playMetronome();
}
};
onMounted(() => {
// 初始计算bpm 拍子
calculateBeat({ bpm: bpm.value, timeSignature: timeSignature.value })
});
onUnmounted(() => {
stopMetronome();
});
</script>
<style scoped>
.meteronome {
display: flex;
height: 430px;
flex-direction: column;
justify-content: space-around;
}
#slider {
margin-top: 8px;
width: 100%;
}
.bpmList,
.beatClassList {
display: grid;
margin-top: 8px;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
text-align: center;
}
.meteronomeShow {
display: flex;
width: 100%;
justify-content: space-around;
align-items: center;
}
.meteronomePoint {
border-radius: 50%;
background-color: #FEE082;
width: 20px;
height: 20px;
}
.btn {
display: flex;
justify-content: space-around;
}
.btn>button {
width: 100px;
}
</style>
2.uniapp
<template>
<div class="meteronome">
<div>bpm: <input type="text" v-model="bpm">
<slider :value="bpm" activeColor="#EDEDED" backgroundColor="#EDEDED" block-color="#F8EABC" block-size="12"
max="184" min="46" @change="bpmChange" step="1" />
<div class="bpmList">
<span v-for="(item, index) in bpmList" :key="index" @click="changeBpm(item.bpm)">
{{ item.name }}
</span>
</div>
</div>
<!-- 节拍点 -->
<div class="meteronomeShow">
<div class="meteronomePoint" v-for="(p, index) in strongBeatPattern" :key="index" :style="{
backgroundColor: showBeat === index + 1 ? 'red' : '',
width:p==1 ? '55rpx' : '40rpx',
height:p==1 ? '55rpx' : '40rpx',
}"></div>
</div>
{{ showBeat }}
<div>节拍:{{ timeSignature }}</div>
<div class="beatClassList">
<span v-for="(item, index) in beatClassList" :key="index" @click="changeBeat(item.name)">
{{ item.name }}
</span>
</div>
<div class="btn">
<button @click="playMetronome">播放</button>
<button @click="stopMetronome">暂停</button>
</div>
</div>
</template>
<script setup>
import {
ref,
watch,
onMounted,
onUnmounted,
reactive,
onBeforeMount
} from 'vue';
const bpm = ref(100);
let beat = ref()
let note = ref();
let currentBeat = ref(0);
let showBeat = ref(0);
//输入节拍
const timeSignature = ref('3/8');
let strongBeatPattern = ref([])
let bpmList = reactive([{
name: "庄板",
bpm: 40
}, {
name: "广板",
bpm: 46
},
{
name: "慢板",
bpm: 52
},
{
name: "小广板",
bpm: 56
},
{
name: "柔板",
bpm: 60
},
{
name: "小柔板",
bpm: 66
},
{
name: "行板",
bpm: 72
},
{
name: "小行板",
bpm: 80
},
{
name: "庄严",
bpm: 88
},
{
name: "中板",
bpm: 96
},
{
name: "小快板",
bpm: 108
},
{
name: "活跃",
bpm: 132
},
{
name: "快板",
bpm: 132
},
{
name: "极快版",
bpm: 160
},
{
name: "急板",
bpm: 184
},
]);
const beatClassList = reactive([{
name: "2/2",
timeSignature: "12/8"
}, {
name: "3/2",
timeSignature: "4/4"
}, {
name: "4/2",
timeSignature: "3/8"
}, {
name: "1/4",
timeSignature: "6/8"
}, {
name: "2/4",
timeSignature: "12/8"
}, {
name: "3/4",
timeSignature: "3/8"
}, {
name: "4/4",
timeSignature: "3/8"
}, {
name: "5/4",
timeSignature: "3/8"
}, {
name: "3/8",
timeSignature: "3/8"
}, {
name: "6/8",
timeSignature: "3/8"
}, {
name: "9/8",
timeSignature: "3/8"
}, {
name: "12/8",
timeSignature: "3/8"
}])
const audio = uni.createInnerAudioContext();
// audio.src = "../../static/metronome.mp3"
const strongAudio = uni.createInnerAudioContext();
// strongAudio.src = "../../static/metronomeT.mp3"
const intervalId = ref(null);
let interval = ref()
// let asddd = new Date()
// audio.onCanplay(() => {
// console.log("播放延迟", new Date() - asddd)
// asddd = new Date()
// })
const playSound = (isStrong) => {
if (isStrong) {
// console.log('strong')
// audio.src = "../../static/metronomeT.mp3"
strongAudio.play();
} else {
// console.log('weak')
// audio.src = "../../static/metronome.mp3"
audio.play();
}
};
//计算节拍
const calculateBeat = ({
bpm,
timeSignature
}) => {
const [numerator, denominator] = timeSignature.split('/').map(Number);
// beat.value = getStrongBeatPattern(numerator, denominator)
beat.value = numerator
note.value = denominator
console.log(beat.value)
// interval.value = 60000 / (bpm * (denominator / 4));
interval.value = 60000 / bpm;
// console.log("播放间隔", interval.value)
//重音数组
let beatArray = getStrongBeatPattern(numerator, denominator);
if (beatArray.length == 1) {
beatArray.length = numerator
strongBeatPattern.value = beatArray.fill(0, 1, numerator)
}
strongBeatPattern.value = beatArray
console.log(strongBeatPattern.value)
};
//播放节拍器
const playMetronome = () => {
if (intervalId.value) {
return
}
intervalId.value = setInterval(() => {
// 使用模式数组判断当前拍子是否为强音
const isStrongBeat = strongBeatPattern.value && strongBeatPattern.value[currentBeat.value] === 1;
playSound(isStrongBeat);
currentBeat.value = (currentBeat.value % beat.value) + 1;
showBeat.value = (showBeat.value % beat.value) + 1;
// 当currentBeat.value超过numerator时,需要重置为1
//计算用
if (currentBeat.value >= beat.value) {
currentBeat.value = 0;
}
//展示用
if (showBeat.value > beat.value) {
currentBeat.value = 0;
}
}, interval.value);
};
const getStrongBeatPattern = (numerator, denominator) => {
//4/2拍
if (denominator === 2 && numerator === 4) {
return [1, 0, 1, 0];
}
// 对于4/4拍,每小节有四拍,重音在第一拍
if (denominator === 4) {
if (numerator === 4) {
return [1, 0, 0, 0]; // 4/4拍的重音模式
}
if (numerator === 5) {
return [1, 0, 0, 1, 0]; // 5/4拍的重音模式,第一拍和第四拍是强音
}
}
// 对于3/8拍,每小节有三拍,重音同样在第一拍
if (denominator === 8 && numerator === 3) {
return [1, 0, 0]; // 3/8拍的重音模式
}
// 对于6/8拍,重音在第1和第4拍
if (denominator === 8) {
if (numerator === 6) { // 6/8拍的重音模式
return [1, 0, 0, 1, 0, 0];
}
if (numerator === 12) { // 12/8拍的重音模式
return [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0];
}
}
// 默认情况下,假设每拍都是强拍
return [1]; // 默认强音模式
};
//暂停播放
const stopMetronome = () => {
if (intervalId.value) {
clearInterval(intervalId.value);
intervalId.value = null;
currentBeat.value = 0
showBeat.value = 0
audio.destroy()
}
};
// 滑块bpm改变
const bpmChange = (event) => {
bpm.value = event.detail.value;
if (intervalId.value) {
calculateBeat({
bpm: bpm.value,
timeSignature: timeSignature.value
});
stopMetronome();
playMetronome();
}
};
// 输入框bpm改变
const changeBpm = (newBpm) => {
bpm.value = newBpm;
if (intervalId.value) {
calculateBeat({
bpm: bpm.value,
timeSignature: timeSignature.value
});
stopMetronome();
playMetronome();
}
};
//更新节拍
const changeBeat = (newTimeSignature) => {
timeSignature.value = newTimeSignature;
calculateBeat({
bpm: bpm.value,
timeSignature: timeSignature.value
});
if (intervalId.value) {
stopMetronome();
playMetronome();
}
};
onMounted(() => {
// 初始计算bpm 拍子
calculateBeat({
bpm: bpm.value,
timeSignature: timeSignature.value
})
});
onBeforeMount(() => {
audio.src = "../../static/metronome.mp3"
strongAudio.src = "../../static/metronomeT.mp3"
})
onUnmounted(() => {
stopMetronome();
});
</script>
<style lang="scss" scoped>
.meteronome {
display: flex;
height: 80%;
padding: 40rpx;
box-sizing: border-box;
flex-direction: column;
justify-content: space-between;
}
.bpmList,
.beatClassList {
display: grid;
margin-top: 8px;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
text-align: center;
}
.meteronomeShow {
display: flex;
width: 100%;
justify-content: space-around;
align-items: center;
}
.meteronomePoint {
border-radius: 50%;
background-color: #FEE082;
width: 20px;
height: 20px;
}
.btn {
display: flex;
justify-content: space-around;
}
.btn>button {
width: 100px;
}
</style>