詳解如何用js實(shí)現(xiàn)一個(gè)網(wǎng)頁(yè)版節(jié)拍器
引言
平時(shí)練尤克里里經(jīng)常用到節(jié)拍器,突發(fā)奇想自己用js開(kāi)發(fā)一個(gè)。
最后實(shí)現(xiàn)的效果如下:ahao430.github.io/metronome/。
代碼見(jiàn)github倉(cāng)庫(kù):github.com/ahao430/met…。

1. 需求分析
節(jié)拍器主要是可以設(shè)定不同的速度和節(jié)奏打拍子??锤鞣N節(jié)拍器,有簡(jiǎn)單的,也有復(fù)雜的。
- 設(shè)定不同的速度,每分鐘多少拍
- 選擇節(jié)拍,比如4/4拍、3/4拍、6/8拍等等。
- 選擇節(jié)拍的節(jié)奏型,一拍一個(gè),一拍兩個(gè),一拍三個(gè)(三連音),一拍四個(gè),swing,等等。這個(gè)很多簡(jiǎn)易節(jié)拍器就沒(méi)有了。
- 切換不同的音色,比如敲擊聲、鼓聲、人聲等等。



這里拍速是指一分鐘有多少拍。
而節(jié)拍是以幾分音符為1拍,每小節(jié)幾拍。這個(gè)不影響拍速,觀察各種節(jié)拍器,這里會(huì)展示幾個(gè)小點(diǎn),每一拍一個(gè)點(diǎn),其中第一拍第一下重聲,后面的輕聲。
節(jié)奏型決定每一拍響幾下,以及這幾下之前的節(jié)奏。比如這一拍響一下、響兩下、響三下、響四下;如果是一個(gè)swing就是前8后16分音符的時(shí)長(zhǎng);也可能這個(gè)節(jié)奏型的時(shí)長(zhǎng)是兩拍,比如民謠掃弦的下----,下空下上。
2. 素材準(zhǔn)備
這里沒(méi)有UI,就簡(jiǎn)單的寫(xiě)下樣式,沒(méi)有做什么圖。去找了個(gè)節(jié)拍器的圖標(biāo)做favicon,找了幾個(gè)不同節(jié)奏型的圖片(截圖->裁剪o(╥﹏╥)o),最后音頻素材扒到一個(gè)強(qiáng)一個(gè)弱的敲擊聲。
準(zhǔn)備開(kāi)工。

3. 開(kāi)發(fā)實(shí)現(xiàn)
3.1 框架選型
這里選了 vue3,沒(méi)啥特別的原因,就是平常經(jīng)常用vue2和react,vue3沒(méi)怎么用過(guò),練練手。試試vue3+vite的開(kāi)發(fā)體驗(yàn)。直接用官方腳手架開(kāi)搞。
配置rem,引入amfe-flexible和ostcss-px2rem-exclude。
ui組件引入nutui。
3.2 模塊設(shè)計(jì)
<script setup lang="ts">
import Speed from "./components/Speed.vue";
import Rhythm from "./components/Rhythm.vue";
import Beat from "./components/Beat.vue";
import Play from "./components/Play.vue";
</script>
<template>
<p class="title">節(jié)拍器</p>
<main>
<Speed></Speed>
<div class="flex">
<Beat></Beat>
<Rhythm></Rhythm>
</div>
<Play></Play>
</main>
</template>
將頁(yè)面按照功能模塊劃分了幾個(gè)組件,上面是調(diào)節(jié)拍速,中間是選擇節(jié)拍和節(jié)奏型,最下面是播放。
由于播放組件要用到其他組件的設(shè)置,引入pinia狀態(tài)管理,數(shù)據(jù)都存放到store。由于播放組件要獲取其他組件的數(shù)據(jù),就每個(gè)組件都建了一個(gè)store,數(shù)據(jù)和計(jì)算邏輯都放到里面了。
這里寫(xiě)組件時(shí)遇到vue3的第一個(gè)坑,數(shù)據(jù)解構(gòu)失去響應(yīng)性了,后面使用store的數(shù)據(jù),直接用store.xxx。
3.3 數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)
拍速、節(jié)拍、節(jié)奏型組件都很簡(jiǎn)單,下拉選擇就行了。重點(diǎn)需要設(shè)計(jì)一下數(shù)據(jù)結(jié)構(gòu)。
節(jié)拍我是用一個(gè)數(shù)組來(lái)存儲(chǔ),如[3,4],看數(shù)組第一項(xiàng)知道這一小節(jié)節(jié)拍的數(shù)量。
節(jié)奏型考慮到有的節(jié)奏型不止一拍,用了一個(gè)二維數(shù)組來(lái)表示,每一項(xiàng)是一拍,然后這一拍由1和0的數(shù)組來(lái)表示,如民謠掃弦的↓ ↓ ↓↑,讀作下空空空下空下上,寫(xiě)成[[1,0,0,0],[1,0,1,1]]。
export const MIN_SPEED = 40
export const MAX_SPEED = 400
export const DEF_SPEED = 120
export const DEF_BEAT = [4,4]
export const BEAT_OPTIONS = [
[1,4],
[2,4],
[3,4],
[4,4],
[3,8],
[6,8],
[7,8],
]
export const DEF_RHYTHM = 1
export const RHYTHM_OPTIONS = [
{ id: 1, name: '?', value: [[1]], img: './img/1.jpg', rate: 30},
{ id: 2, name: '??', value: [[1,1]], img: './img/2.jpg', rate: 15},
{ id: 3, name: '三連音', value: [[1, 1, 1]], img: './img/3.jpg', rate: 10},
{ id: 4, name: '????', value: [[1,1,1,1]], img: './img/4.jpg', rate: 10},
{ id: 5, name: 'swing', value: [[1, 0, 1]], img: './img/5.jpg', rate: 10},
{ id: 6, name: '民謠掃弦', value: [[1, 0, 0,0], [1,0,1,1]], img: './img/6.png', rate: 10},
{ id: 7, name: '民謠掃弦2', value: [[1, 0, 1, 1], [0,1,1,1]], img: './img/7.png', rate: 10},
]
3.4 播放邏輯
播放組件這里比較復(fù)雜。當(dāng)點(diǎn)擊播放按鈕時(shí),要開(kāi)始打節(jié)拍。這是先播放一次重聲,然后根據(jù)拍速、節(jié)拍和節(jié)奏型計(jì)算下一次聲音的間隔,后續(xù)都按照這個(gè)間隔播放輕聲,直到小節(jié)結(jié)束。
// 點(diǎn)擊播放,重置節(jié)拍和節(jié)奏型計(jì)數(shù),狀態(tài)置為true,執(zhí)行播放小節(jié)函數(shù)
function play() {
beatCount.value = 0
rhythmCount.value = 0
isPlaying.value = true
playBeat()
}
// 播放整個(gè)小節(jié),節(jié)拍計(jì)數(shù)重置為0,允許播放重聲,播放節(jié)奏型
function playBeat () {
if (!isPlaying.value) return false
beat = useBeatStore().beat
console.log('播放節(jié)拍:', beat)
beatCount.value = 0
heavy = true
playRhythm()
}
// 播放整個(gè)節(jié)奏型(可能多拍), 節(jié)奏型音符計(jì)數(shù)重置
function playRhythm () {
if (!isPlaying.value) return false
rhythm = useRhythmStore().rhythm.value
rhythmRate = useRhythmStore().rhythm.rate
console.log('播放節(jié)奏型:', rhythm)
rhythmNotesLen = 0
rhythmCount.value = 0
rhythm.forEach(item => {
rhythmNotesLen += item.length
})
playNote()
}
播放期間,可能在不暫停播放的情況下,修改拍速、節(jié)拍和節(jié)奏型的值。因此在播放音符時(shí),動(dòng)態(tài)計(jì)算拍速,再根據(jù)節(jié)奏型的音符數(shù)量,去計(jì)算到下個(gè)音符的timeout時(shí)間。下個(gè)音符如果是1就播放,如果是0就不播放,然后繼續(xù)定時(shí)器。注意一個(gè)節(jié)奏型或者一個(gè)小節(jié)完成去重置計(jì)數(shù)。這里就不看單拍完成情況了。
// 播放單個(gè)音符位置,可能是空拍
function playNote () {
// 一個(gè)節(jié)奏型可能有多拍
speed = useSpeedStore().speed
// 調(diào)整播放倍速
player.playbackRate = Math.max(1, Math.min(10, speed / rhythmRate))
player2.playbackRate = player.playbackRate
const rhythmItemIndex = beatCount.value % rhythm.length
// 播放音頻
const rhythmItem = rhythm[rhythmItemIndex]
const note = rhythmItem[rhythmCount.value]
console.log('播放音頻:',
note ?
(heavy ? '重' : '輕')
: '空'
)
if (note) {
// 播放
if (heavy) {
player.currentTime = 0;
player.play()
heavy = false
} else {
player2.currentTime = 0;
player2.play()
}
}
// 計(jì)算間隔時(shí)間
const oneBeatTime = ONE_MINUTE / speed
const rhythmNoteTime = oneBeatTime / rhythmItem.length
// 定時(shí)器,播放下一個(gè)音符
timer = setTimeout(() => {
let newRhythmCount = rhythmCount.value + 1
if (newRhythmCount >= rhythmItem.length) {
if (newRhythmCount >= rhythmNotesLen) {
// 新的節(jié)奏型
newRhythmCount = 0
rhythmCount.value = newRhythmCount
} else {
// 當(dāng)前節(jié)奏型新的一拍
rhythmCount.value = newRhythmCount
}
let newBeatCount = beatCount.value + 1
if (newBeatCount >= beat[0]) {
newBeatCount = 0
// 新的節(jié)拍
beatCount.value = newBeatCount
playBeat()
} else {
beatCount.value = newBeatCount
playRhythm()
}
} else {
rhythmCount.value = newRhythmCount
playNote()
}
}, rhythmNoteTime)
// 呼吸樣式
if (note) {
const styleTime = rhythmNoteTime * 0.8
rhythmCircleStyle.value = `transform: scale(1.5); transition: all linear ${styleTime / 1000}s; opacity: 0.5;`
timer2 = setTimeout(() => {
rhythmCircleStyle.value = 'transform: scale(0); transition: none; opacity: 0;'
}, styleTime)
}
}
3.5 音頻控制
音頻的播放,用到了Audio對(duì)象。
const player = new Audio('./audio/beat1.mp3')
const player2 = new Audio('./audio/beat2.mp3')
// player.play()
// player.pause()
我們找的音頻播放速度和時(shí)長(zhǎng)是固定的,但是當(dāng)拍速調(diào)快,或者一拍的節(jié)奏型有多個(gè)音符,前一次播放還沒(méi)結(jié)束,后一次播放就開(kāi)始了,聽(tīng)起來(lái)無(wú)法區(qū)分。這時(shí)我們可以調(diào)整播放速度,根據(jù)前面的音符間的間隔時(shí)間來(lái)調(diào)整倍率,修改player的playbackRate值。
不過(guò)實(shí)際發(fā)現(xiàn)瀏覽器的倍數(shù)有上限和下限,超出范圍會(huì)報(bào)錯(cuò)。而且計(jì)算的也不是特別的準(zhǔn),前面音符數(shù)量我們用[1]表示一拍一下,其實(shí)不是很準(zhǔn),應(yīng)該是[1,0,0,0,...],但是幾個(gè)0也得看節(jié)拍。干脆直接在那幾個(gè)節(jié)奏型的選項(xiàng)加了個(gè)rate字段,憑感覺(jué)調(diào)節(jié)了。
// 調(diào)整播放倍速 player.playbackRate = Math.max(1, Math.min(10, speed / rhythmRate)) player2.playbackRate = player.playbackRate
在每次播放音符重新取值,是可以做到切換后在下一個(gè)音符修正的,但是如果前面速度選的過(guò)慢,到下一次播放要等很久。改為三個(gè)選項(xiàng)切換任意值時(shí),停止播放,再啟動(dòng)。
watch([
() => beatStore.beat,
() => rhythmStore.rhythm,
() => speedStore.speed
], () => {
console.log('restart')
restart()
})
3.6 動(dòng)效
在播放的時(shí)候,按照節(jié)拍數(shù)量做了n個(gè)小圓點(diǎn),第幾拍就亮哪一個(gè)。
然后做了一個(gè)呼吸動(dòng)效,每個(gè)音符播放時(shí),都有一個(gè)圓環(huán)從播放按鈕下方向外擴(kuò)散開(kāi)來(lái)。
// 呼吸樣式
if (note) {
const styleTime = rhythmNoteTime * 0.8
rhythmCircleStyle.value = `transform: scale(1.5); transition: all linear ${styleTime / 1000}s; opacity: 0.5;`
timer2 = setTimeout(() => {
rhythmCircleStyle.value = 'transform: scale(0); transition: none; opacity: 0;'
}, styleTime)
}
3.7 大屏展示
amfe-flexible會(huì)始終按照屏幕寬度計(jì)算rem。實(shí)際上我們只做了移動(dòng)端樣式,大屏的時(shí)候最好居中固定寬度展示,所以自己寫(xiě)一個(gè)rem.js,設(shè)置最大寬度,超過(guò)最大寬度時(shí),只按照最大寬度計(jì)算rem,同時(shí)給body添加maxWidth屬性。
3.8 新增人聲發(fā)音
增加一個(gè)組件,支持下拉選擇聲音類型,暫時(shí)有人聲和敲擊聲。選擇人聲時(shí),改為播報(bào)1234,,2234...。
import Speech from 'speak-tts'
const speech = new Speech()
speech.init({
volume: 1,
rate: 1,
pitch: 1,
lang: 'zh-CN',
})
function playVoice () {
const voice = useVoiceStore().voice
console.log('voice: ', voice)
if (voice === 'human') {
const text = rhythmCount.value === 0 ? (beatCount.value + 1) : (rhythmCount.value + 1)
speech.speak({
text: '' + text,
queue: false
})
if (heavy) {
heavy = false
speech.setPitch(0.5)
}
} else {
if (heavy) {
player.currentTime = 0;
player.play()
heavy = false
speech.setPitch(0.5)
} else {
player2.currentTime = 0;
player2.play()
}
}
}
4. 部署
用github pages部署項(xiàng)目打包文件。這里找了一個(gè)別人提供的配置文件,實(shí)現(xiàn)push分支后利用github actions自動(dòng)部署。
在項(xiàng)目根目錄新建.github/workflows目錄,然后新建一個(gè)任意名稱,.yml后綴的文件,填入下面配置推送即可。其中branches指定了main,看實(shí)際情況可以改成master。推送后action會(huì)自動(dòng)打包main分支代碼,將dist目錄放到gh-pages分支根目錄,并將settings/pages自動(dòng)設(shè)置為gh-pages分支根目錄展示。
name: CI
on:
push:
branches:
- main
jobs:
job:
name: Deployment
runs-on: macos-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout
uses: actions/checkout@v3
# setup node
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 16.16.0
# setup pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v2
id: pnpm-install
with:
version: 7
run_install: false
# cache
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
# cache fail and install dependencies
- name: Install dependencies
if: steps.pnpm-cache.outputs.cache-hit != 'true'
run: |
pnpm install
- name: Build
run: pnpm run build
- name: upload production artifacts
uses: actions/upload-pages-artifact@v1
with:
path: dist
# deploy
- name: Deploy Page To Release
id: deployment
uses: actions/deploy-pages@v1
5. 后續(xù)工作
5.1 目前存在的問(wèn)題
ios聲音
目前最大的問(wèn)題是IOS沒(méi)有聲音,這個(gè)目前沒(méi)啥好辦法,因?yàn)閕os的權(quán)限問(wèn)題,只有手動(dòng)點(diǎn)擊才能播放,所以只播放了一下,就不再播放了,定時(shí)器后面的播放沒(méi)法觸發(fā)。
目測(cè)要解決這個(gè)問(wèn)題,只有換平臺(tái)了,利用小程序或者app的native api去實(shí)現(xiàn)。
5.2 TODO
切換不同音效
這個(gè)功能好實(shí)現(xiàn),就是素材不好找。不過(guò)有些節(jié)拍器支持人聲,如果播放1234,,2234, 需要在播放時(shí)加些邏輯。人聲貌似用api可以實(shí)現(xiàn)。
以上就是詳解如何用js實(shí)現(xiàn)一個(gè)網(wǎng)頁(yè)版節(jié)拍器的詳細(xì)內(nèi)容,更多關(guān)于js實(shí)現(xiàn)網(wǎng)頁(yè)版節(jié)拍器的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
js前端實(shí)現(xiàn)word?excel?pdf?ppt?mp4圖片文本等文件預(yù)覽
這篇文章主要為大家介紹了js前端實(shí)現(xiàn)word?excel?pdf?ppt?mp4圖片文本等文件預(yù)覽示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-07-07
微信小程序 本地存儲(chǔ)及登錄頁(yè)面處理實(shí)例詳解
這篇文章主要介紹了微信小程序 本地存儲(chǔ)實(shí)例詳解的相關(guān)資料,需要的朋友可以參考下2017-01-01
Moment的feature導(dǎo)致線上bug解決分析
這篇文章主要為大家介紹了Moment的feature導(dǎo)致線上bug解決分析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-09-09
JS前端架構(gòu)pnpm構(gòu)建Monorepo方式管理demo
這篇文章主要為大家介紹了JS前端架構(gòu)pnpm構(gòu)建Monorepo方式的管理demo,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-07-07
JavaScript嚴(yán)格模式use strict的介紹
這篇文章主要介紹了JavaScript嚴(yán)格模式use strict,嚴(yán)格模式是JavaScript中的一種限制性更強(qiáng)的變種方式。嚴(yán)格模式并不是JavaScript中的子集,它在語(yǔ)義上與正常的代碼有明顯的差異,下面我們就一起來(lái)學(xué)習(xí)該內(nèi)容吧,需要的朋友也可以參考一下2021-12-12

