詳解如何用js實現(xiàn)一個網(wǎng)頁版節(jié)拍器
引言
平時練尤克里里經(jīng)常用到節(jié)拍器,突發(fā)奇想自己用js開發(fā)一個。
最后實現(xiàn)的效果如下:ahao430.github.io/metronome/。
代碼見github倉庫:github.com/ahao430/met…。
1. 需求分析
節(jié)拍器主要是可以設(shè)定不同的速度和節(jié)奏打拍子??锤鞣N節(jié)拍器,有簡單的,也有復(fù)雜的。
- 設(shè)定不同的速度,每分鐘多少拍
- 選擇節(jié)拍,比如4/4拍、3/4拍、6/8拍等等。
- 選擇節(jié)拍的節(jié)奏型,一拍一個,一拍兩個,一拍三個(三連音),一拍四個,swing,等等。這個很多簡易節(jié)拍器就沒有了。
- 切換不同的音色,比如敲擊聲、鼓聲、人聲等等。
這里拍速是指一分鐘有多少拍。
而節(jié)拍是以幾分音符為1拍,每小節(jié)幾拍。這個不影響拍速,觀察各種節(jié)拍器,這里會展示幾個小點,每一拍一個點,其中第一拍第一下重聲,后面的輕聲。
節(jié)奏型決定每一拍響幾下,以及這幾下之前的節(jié)奏。比如這一拍響一下、響兩下、響三下、響四下;如果是一個swing就是前8后16分音符的時長;也可能這個節(jié)奏型的時長是兩拍,比如民謠掃弦的下----,下空下上。
2. 素材準(zhǔn)備
這里沒有UI,就簡單的寫下樣式,沒有做什么圖。去找了個節(jié)拍器的圖標(biāo)做favicon,找了幾個不同節(jié)奏型的圖片(截圖->裁剪o(╥﹏╥)o),最后音頻素材扒到一個強(qiáng)一個弱的敲擊聲。
準(zhǔn)備開工。
3. 開發(fā)實現(xiàn)
3.1 框架選型
這里選了 vue3,沒啥特別的原因,就是平常經(jīng)常用vue2和react,vue3沒怎么用過,練練手。試試vue3+vite的開發(fā)體驗。直接用官方腳手架開搞。
配置rem,引入amfe-flexible和ostcss-px2rem-exclude。
ui組件引入nutui。
3.2 模塊設(shè)計
<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>
將頁面按照功能模塊劃分了幾個組件,上面是調(diào)節(jié)拍速,中間是選擇節(jié)拍和節(jié)奏型,最下面是播放。
由于播放組件要用到其他組件的設(shè)置,引入pinia狀態(tài)管理,數(shù)據(jù)都存放到store。由于播放組件要獲取其他組件的數(shù)據(jù),就每個組件都建了一個store,數(shù)據(jù)和計算邏輯都放到里面了。
這里寫組件時遇到vue3的第一個坑,數(shù)據(jù)解構(gòu)失去響應(yīng)性了,后面使用store的數(shù)據(jù),直接用store.xxx。
3.3 數(shù)據(jù)結(jié)構(gòu)設(shè)計
拍速、節(jié)拍、節(jié)奏型組件都很簡單,下拉選擇就行了。重點需要設(shè)計一下數(shù)據(jù)結(jié)構(gòu)。
節(jié)拍我是用一個數(shù)組來存儲,如[3,4],看數(shù)組第一項知道這一小節(jié)節(jié)拍的數(shù)量。
節(jié)奏型考慮到有的節(jié)奏型不止一拍,用了一個二維數(shù)組來表示,每一項是一拍,然后這一拍由1和0的數(shù)組來表示,如民謠掃弦的↓ ↓ ↓↑,讀作下空空空下空下上,寫成[[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)點擊播放按鈕時,要開始打節(jié)拍。這是先播放一次重聲,然后根據(jù)拍速、節(jié)拍和節(jié)奏型計算下一次聲音的間隔,后續(xù)都按照這個間隔播放輕聲,直到小節(jié)結(jié)束。
// 點擊播放,重置節(jié)拍和節(jié)奏型計數(shù),狀態(tài)置為true,執(zhí)行播放小節(jié)函數(shù) function play() { beatCount.value = 0 rhythmCount.value = 0 isPlaying.value = true playBeat() }
// 播放整個小節(jié),節(jié)拍計數(shù)重置為0,允許播放重聲,播放節(jié)奏型 function playBeat () { if (!isPlaying.value) return false beat = useBeatStore().beat console.log('播放節(jié)拍:', beat) beatCount.value = 0 heavy = true playRhythm() }
// 播放整個節(jié)奏型(可能多拍), 節(jié)奏型音符計數(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é)奏型的值。因此在播放音符時,動態(tài)計算拍速,再根據(jù)節(jié)奏型的音符數(shù)量,去計算到下個音符的timeout時間。下個音符如果是1就播放,如果是0就不播放,然后繼續(xù)定時器。注意一個節(jié)奏型或者一個小節(jié)完成去重置計數(shù)。這里就不看單拍完成情況了。
// 播放單個音符位置,可能是空拍 function playNote () { // 一個節(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() } } // 計算間隔時間 const oneBeatTime = ONE_MINUTE / speed const rhythmNoteTime = oneBeatTime / rhythmItem.length // 定時器,播放下一個音符 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對象。
const player = new Audio('./audio/beat1.mp3') const player2 = new Audio('./audio/beat2.mp3') // player.play() // player.pause()
我們找的音頻播放速度和時長是固定的,但是當(dāng)拍速調(diào)快,或者一拍的節(jié)奏型有多個音符,前一次播放還沒結(jié)束,后一次播放就開始了,聽起來無法區(qū)分。這時我們可以調(diào)整播放速度,根據(jù)前面的音符間的間隔時間來調(diào)整倍率,修改player的playbackRate值。
不過實際發(fā)現(xiàn)瀏覽器的倍數(shù)有上限和下限,超出范圍會報錯。而且計算的也不是特別的準(zhǔn),前面音符數(shù)量我們用[1]表示一拍一下,其實不是很準(zhǔn),應(yīng)該是[1,0,0,0,...],但是幾個0也得看節(jié)拍。干脆直接在那幾個節(jié)奏型的選項加了個rate字段,憑感覺調(diào)節(jié)了。
// 調(diào)整播放倍速 player.playbackRate = Math.max(1, Math.min(10, speed / rhythmRate)) player2.playbackRate = player.playbackRate
在每次播放音符重新取值,是可以做到切換后在下一個音符修正的,但是如果前面速度選的過慢,到下一次播放要等很久。改為三個選項切換任意值時,停止播放,再啟動。
watch([ () => beatStore.beat, () => rhythmStore.rhythm, () => speedStore.speed ], () => { console.log('restart') restart() })
3.6 動效
在播放的時候,按照節(jié)拍數(shù)量做了n個小圓點,第幾拍就亮哪一個。
然后做了一個呼吸動效,每個音符播放時,都有一個圓環(huán)從播放按鈕下方向外擴(kuò)散開來。
// 呼吸樣式 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會始終按照屏幕寬度計算rem。實際上我們只做了移動端樣式,大屏的時候最好居中固定寬度展示,所以自己寫一個rem.js,設(shè)置最大寬度,超過最大寬度時,只按照最大寬度計算rem,同時給body添加maxWidth屬性。
3.8 新增人聲發(fā)音
增加一個組件,支持下拉選擇聲音類型,暫時有人聲和敲擊聲。選擇人聲時,改為播報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àn)push分支后利用github actions自動部署。
在項目根目錄新建.github/workflows目錄,然后新建一個任意名稱,.yml后綴的文件,填入下面配置推送即可。其中branches指定了main,看實際情況可以改成master。推送后action會自動打包main分支代碼,將dist目錄放到gh-pages分支根目錄,并將settings/pages自動設(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 目前存在的問題
ios聲音
目前最大的問題是IOS沒有聲音,這個目前沒啥好辦法,因為ios的權(quán)限問題,只有手動點擊才能播放,所以只播放了一下,就不再播放了,定時器后面的播放沒法觸發(fā)。
目測要解決這個問題,只有換平臺了,利用小程序或者app的native api去實現(xiàn)。
5.2 TODO
切換不同音效
這個功能好實現(xiàn),就是素材不好找。不過有些節(jié)拍器支持人聲,如果播放1234,,2234, 需要在播放時加些邏輯。人聲貌似用api可以實現(xiàn)。
以上就是詳解如何用js實現(xiàn)一個網(wǎng)頁版節(jié)拍器的詳細(xì)內(nèi)容,更多關(guān)于js實現(xiàn)網(wǎng)頁版節(jié)拍器的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
JavaScript數(shù)組對象高階函數(shù)reduce的妙用詳解
這篇文章主要為大家介紹了JavaScript數(shù)組對象高階函數(shù)reduce的妙用詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-04-04