首頁 > 軟體

詳解如何用js實現一個網頁版節拍器

2023-01-20 14:01:52

引言

平時練尤克里裡經常用到節拍器,突發奇想自己用js開發一個。

最後實現的效果如下:ahao430.github.io/metronome/

程式碼見github倉庫:github.com/ahao430/met…

1. 需求分析

節拍器主要是可以設定不同的速度和節奏打拍子。看各種節拍器,有簡單的,也有複雜的。

  • 設定不同的速度,每分鐘多少拍
  • 選擇節拍,比如4/4拍、3/4拍、6/8拍等等。
  • 選擇節拍的節奏型,一拍一個,一拍兩個,一拍三個(三連音),一拍四個,swing,等等。這個很多簡易節拍器就沒有了。
  • 切換不同的音色,比如敲擊聲、鼓聲、人聲等等。

這裡拍速是指一分鐘有多少拍。

而節拍是以幾分音符為1拍,每小節幾拍。這個不影響拍速,觀察各種節拍器,這裡會展示幾個小點,每一拍一個點,其中第一拍第一下重聲,後面的輕聲。

節奏型決定每一拍響幾下,以及這幾下之前的節奏。比如這一拍響一下、響兩下、響三下、響四下;如果是一個swing就是前8後16分音符的時長;也可能這個節奏型的時長是兩拍,比如民謠掃弦的下----,下空下上。

2. 素材準備

這裡沒有UI,就簡單的寫下樣式,沒有做什麼圖。去找了個節拍器的圖示做favicon,找了幾個不同節奏型的圖片(截圖->裁剪o(╥﹏╥)o),最後音訊素材扒到一個強一個弱的敲擊聲。

準備開工。

3. 開發實現

3.1 框架選型

這裡選了 vue3,沒啥特別的原因,就是平常經常用vue2和react,vue3沒怎麼用過,練練手。試試vue3+vite的開發體驗。直接用官方腳手架開搞。

設定rem,引入amfe-flexible和ostcss-px2rem-exclude。

ui元件引入nutui。

3.2 模組設計

<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">節拍器</p>
  <main>
    <Speed></Speed>
    <div class="flex">
      <Beat></Beat>
      <Rhythm></Rhythm>
    </div>
    <Play></Play>
  </main>
</template>

將頁面按照功能模組劃分了幾個元件,上面是調節拍速,中間是選擇節拍和節奏型,最下面是播放。

由於播放元件要用到其他元件的設定,引入pinia狀態管理,資料都存放到store。由於播放元件要獲取其他元件的資料,就每個元件都建了一個store,資料和計算邏輯都放到裡面了。

這裡寫元件時遇到vue3的第一個坑,資料解構失去響應性了,後面使用store的資料,直接用store.xxx。

3.3 資料結構設計

拍速、節拍、節奏型元件都很簡單,下拉選擇就行了。重點需要設計一下資料結構。

節拍我是用一個陣列來儲存,如[3,4],看陣列第一項知道這一小節節拍的數量。

節奏型考慮到有的節奏型不止一拍,用了一個二維陣列來表示,每一項是一拍,然後這一拍由1和0的陣列來表示,如民謠掃弦的↓ ↓ ↓↑,讀作下空空空下空下上,寫成[[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 播放邏輯

播放元件這裡比較複雜。當點選播放按鈕時,要開始打節拍。這是先播放一次重聲,然後根據拍速、節拍和節奏型計算下一次聲音的間隔,後續都按照這個間隔播放輕聲,直到小節結束。

// 點選播放,重置節拍和節奏型計數,狀態置為true,執行播放小節函數
function play() {
  beatCount.value = 0
  rhythmCount.value = 0
  isPlaying.value = true
  playBeat()
}
// 播放整個小節,節拍計數重置為0,允許播放重聲,播放節奏型
function playBeat () {
  if (!isPlaying.value) return false
  beat = useBeatStore().beat
  console.log('播放節拍:', beat)
  beatCount.value = 0
  heavy = true
  playRhythm()
}
// 播放整個節奏型(可能多拍), 節奏型音符計數重置
  function playRhythm () {
    if (!isPlaying.value) return false
    rhythm = useRhythmStore().rhythm.value
    rhythmRate = useRhythmStore().rhythm.rate
    console.log('播放節奏型:', rhythm)
    rhythmNotesLen = 0
    rhythmCount.value = 0
    rhythm.forEach(item => {
      rhythmNotesLen += item.length
    })
    playNote()
  }

播放期間,可能在不暫停播放的情況下,修改拍速、節拍和節奏型的值。因此在播放音符時,動態計算拍速,再根據節奏型的音符數量,去計算到下個音符的timeout時間。下個音符如果是1就播放,如果是0就不播放,然後繼續定時器。注意一個節奏型或者一個小節完成去重置計數。這裡就不看單拍完成情況了。

  // 播放單個音符位置,可能是空拍
  function playNote () {
    // 一個節奏型可能有多拍
    speed = useSpeedStore().speed
    // 調整播放倍速
      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) {
          // 新的節奏型
          newRhythmCount = 0
          rhythmCount.value = newRhythmCount
        } else {
          // 當前節奏型新的一拍
          rhythmCount.value = newRhythmCount
        }
        let newBeatCount = beatCount.value + 1
        if (newBeatCount >= beat[0]) {
          newBeatCount = 0
          // 新的節拍
          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()

我們找的音訊播放速度和時長是固定的,但是當拍速調快,或者一拍的節奏型有多個音符,前一次播放還沒結束,後一次播放就開始了,聽起來無法區分。這時我們可以調整播放速度,根據前面的音符間的間隔時間來調整倍率,修改player的playbackRate值。

不過實際發現瀏覽器的倍數有上限和下限,超出範圍會報錯。而且計算的也不是特別的準,前面音符數量我們用[1]表示一拍一下,其實不是很準,應該是[1,0,0,0,...],但是幾個0也得看節拍。乾脆直接在那幾個節奏型的選項加了個rate欄位,憑感覺調節了。

// 調整播放倍速
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 動效

在播放的時候,按照節拍數量做了n個小圓點,第幾拍就亮哪一個。

然後做了一個呼吸動效,每個音符播放時,都有一個圓環從播放按鈕下方向外擴散開來。

    // 呼吸樣式
    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,設定最大寬度,超過最大寬度時,只按照最大寬度計算rem,同時給body新增maxWidth屬性。

3.8 新增人聲發音

增加一個元件,支援下拉選擇聲音型別,暫時有人聲和敲擊聲。選擇人聲時,改為播報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部署專案打包檔案。這裡找了一個別人提供的組態檔,實現push分支後利用github actions自動部署。

在專案根目錄新建.github/workflows目錄,然後新建一個任意名稱,.yml字尾的檔案,填入下面設定推播即可。其中branches指定了main,看實際情況可以改成master。推播後action會自動打包main分支程式碼,將dist目錄放到gh-pages分支根目錄,並將settings/pages自動設定為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. 後續工作

5.1 目前存在的問題

ios聲音

目前最大的問題是IOS沒有聲音,這個目前沒啥好辦法,因為ios的許可權問題,只有手動點選才能播放,所以只播放了一下,就不再播放了,定時器後面的播放沒法觸發。

目測要解決這個問題,只有換平臺了,利用小程式或者app的native api去實現。

5.2 TODO

切換不同音效

這個功能好實現,就是素材不好找。不過有些節拍器支援人聲,如果播放1234,,2234, 需要在播放時加些邏輯。人聲貌似用api可以實現。

以上就是詳解如何用js實現一個網頁版節拍器的詳細內容,更多關於js實現網頁版節拍器的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com