广告位联系
返回顶部
分享到

用js实现一个网页版节拍器

JavaScript 来源:互联网 作者:佚名 发布时间:2023-01-25 10:41:41 人浏览
摘要

平时练尤克里里经常用到节拍器,突发奇想自己用js开发一个。 最后实现的效果如下:ahao430.github.io/metronome/。 代码见github仓库:github.com/ahao430/met。 1. 需求分析 节拍器主要是可以设定

平时练尤克里里经常用到节拍器,突发奇想自己用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 模块设计

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

<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]]。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

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 播放逻辑

播放组件这里比较复杂。当点击播放按钮时,要开始打节拍。这是先播放一次重声,然后根据拍速、节拍和节奏型计算下一次声音的间隔,后续都按照这个间隔播放轻声,直到小节结束。

1

2

3

4

5

6

7

// 点击播放,重置节拍和节奏型计数,状态置为true,执行播放小节函数

function play() {

  beatCount.value = 0

  rhythmCount.value = 0

  isPlaying.value = true

  playBeat()

}

1

2

3

4

5

6

7

8

9

// 播放整个小节,节拍计数重置为0,允许播放重声,播放节奏型

function playBeat () {

  if (!isPlaying.value) return false

  beat = useBeatStore().beat

  console.log('播放节拍:', beat)

  beatCount.value = 0

  heavy = true

  playRhythm()

}

1

2

3

4

5

6

7

8

9

10

11

12

13

// 播放整个节奏型(可能多拍), 节奏型音符计数重置

  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就不播放,然后继续定时器。注意一个节奏型或者一个小节完成去重置计数。这里就不看单拍完成情况了。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

// 播放单个音符位置,可能是空拍

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对象。

1

2

3

4

  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字段,凭感觉调节了。

1

2

3

// 调整播放倍速

player.playbackRate = Math.max(1, Math.min(10, speed / rhythmRate))

player2.playbackRate = player.playbackRate

在每次播放音符重新取值,是可以做到切换后在下一个音符修正的,但是如果前面速度选的过慢,到下一次播放要等很久。改为三个选项切换任意值时,停止播放,再启动。

1

2

3

4

5

6

7

8

watch([

  () => beatStore.beat,

  () => rhythmStore.rhythm,

  () => speedStore.speed

], () => {

  console.log('restart')

  restart()

})

3.6 动效

在播放的时候,按照节拍数量做了n个小圆点,第几拍就亮哪一个。

然后做了一个呼吸动效,每个音符播放时,都有一个圆环从播放按钮下方向外扩散开来。

1

2

3

4

5

6

7

8

// 呼吸样式

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...。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

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分支根目录展示。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

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可以实现。


版权声明 : 本文内容来源于互联网或用户自行发布贡献,该文观点仅代表原作者本人。本站仅提供信息存储空间服务和不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权, 违法违规的内容, 请发送邮件至2530232025#qq.cn(#换@)举报,一经查实,本站将立刻删除。
原文链接 : https://juejin.cn/post/7189518295929159736
相关文章
  • Node.js参数max-old-space-size的介绍
    前言 Old space是 V8 托管(也称为垃圾收集)堆(即 JavaScript 对象所在的位置)中最大和最可配置的部分,而--max-old-space-size标志控制其最大大
  • 用js实现一个网页版节拍器

    用js实现一个网页版节拍器
    平时练尤克里里经常用到节拍器,突发奇想自己用js开发一个。 最后实现的效果如下:ahao430.github.io/metronome/。 代码见github仓库:github.com/
  • js日期格式化yyyy-MM-dd问题

    js日期格式化yyyy-MM-dd问题
    js日期格式化yyyy-MM-dd 方法一 1 2 3 4 5 6 7 8 9 10 11 12 13 function formatDate(date) { console.log(date); // date = new Date(); date = new Date(Date.parse(date.replace(/-/g
  • Nodejs如何解决跨域(CORS)
    Nodejs解决跨域(CORS) 前后端分离的大环境下,受制于同源策略,我们需要懂得实现CORS(Cross-Origin Resource Sharing) 手动配置 在nodejs中,req 和
  • JS图形编辑器场景坐标视口坐标的相互转换

    JS图形编辑器场景坐标视口坐标的相互转换
    图形编辑器的坐标系有两种。 一个是场景(scene)坐标系,一个是视口(viewport)坐标系。视口就是场景的一个子区域。 假设我们的视口的
  • JS图形编辑器实现标尺功能

    JS图形编辑器实现标尺功能
    项目地址: https://github.com/F-star/suika 线上体验: https://blog.fstars.wang/app/suika/ 标尺指的是画布上边和左边的两个有刻度的尺子,作用让用户知
  • JS快速检索碰撞图形之四叉树碰撞检测

    JS快速检索碰撞图形之四叉树碰撞检测
    在上篇文章我们讨论了使用脏矩形渲染,通过重渲染局部的图形来提优化 Canvas 的性能,将 GPU 密集转换为 CPU 密集。 CPU 密集在哪? 在需要
  • three.js简单实现类似七圣召唤的掷骰子

    three.js简单实现类似七圣召唤的掷骰子
    1基本工作 笔者利用业余时间自学了three.js。为了更好的了解WebGL以及更熟练的使用three,想模仿原神中的小游戏七圣召唤中的投掷骰子效果,
  • JS技巧多状态页面中的mock方案介绍

    JS技巧多状态页面中的mock方案介绍
    我们有时候会遇到一个业务页面存在很多个状态,甚至子状态,比如订单详情就是其中的典型,涉及从订单创建到订单结束,以及售后等流
  • Node如何实现在浏览器预览项目的所有图片介绍

    Node如何实现在浏览器预览项目的所有图片介绍
    背景 在前端实际项目开发中,会有这样一种场景。每次引入新的图片,并不知道这个资源是否被引用过,所以会点开存放图片的资源一个个
  • 本站所有内容来源于互联网或用户自行发布,本站仅提供信息存储空间服务,不拥有版权,不承担法律责任。如有侵犯您的权益,请您联系站长处理!
  • Copyright © 2017-2022 F11.CN All Rights Reserved. F11站长开发者网 版权所有 | 苏ICP备2022031554号-1 | 51LA统计