Quellcode durchsuchen

节拍器 工具 未写完

黄琪勇 vor 2 Monaten
Ursprung
Commit
ff6809b694
43 geänderte Dateien mit 970 neuen und 0 gelöschten Zeilen
  1. 542 0
      src/components/Metronome/Metronome.vue
  2. 185 0
      src/components/Metronome/MetronomeBtn.vue
  3. BIN
      src/components/Metronome/imgs/a1.png
  4. BIN
      src/components/Metronome/imgs/a2.png
  5. BIN
      src/components/Metronome/imgs/a3.png
  6. BIN
      src/components/Metronome/imgs/a4.png
  7. BIN
      src/components/Metronome/imgs/a5.png
  8. BIN
      src/components/Metronome/imgs/a6.png
  9. BIN
      src/components/Metronome/imgs/a7.png
  10. BIN
      src/components/Metronome/imgs/b1.png
  11. BIN
      src/components/Metronome/imgs/b2.png
  12. BIN
      src/components/Metronome/imgs/b3.png
  13. BIN
      src/components/Metronome/imgs/b4.png
  14. BIN
      src/components/Metronome/imgs/b5.png
  15. BIN
      src/components/Metronome/imgs/b6.png
  16. BIN
      src/components/Metronome/imgs/b7.png
  17. BIN
      src/components/Metronome/imgs/c1.png
  18. BIN
      src/components/Metronome/imgs/c2.png
  19. BIN
      src/components/Metronome/imgs/c3.png
  20. BIN
      src/components/Metronome/imgs/c4.png
  21. BIN
      src/components/Metronome/imgs/c5.png
  22. BIN
      src/components/Metronome/imgs/c6.png
  23. BIN
      src/components/Metronome/imgs/c7.png
  24. BIN
      src/components/Metronome/imgs/dot.png
  25. BIN
      src/components/Metronome/imgs/dot1.png
  26. BIN
      src/components/Metronome/imgs/jia.png
  27. BIN
      src/components/Metronome/imgs/jian.png
  28. BIN
      src/components/Metronome/imgs/metronomeBtn.png
  29. BIN
      src/components/Metronome/imgs/pause.png
  30. BIN
      src/components/Metronome/imgs/play.png
  31. BIN
      src/components/Metronome/imgs/sound.png
  32. BIN
      src/components/Metronome/imgs/yin.png
  33. BIN
      src/components/Metronome/imgs/yuan.png
  34. BIN
      src/components/Metronome/imgs/zhen.png
  35. 2 0
      src/components/Metronome/index.ts
  36. 135 0
      src/components/Metronome/useMetronome.ts
  37. BIN
      src/page-instrument/header-top/image/icon_pause.png
  38. BIN
      src/page-instrument/header-top/image/icon_play.png
  39. BIN
      src/page-instrument/header-top/image/icon_reset.png
  40. BIN
      src/page-instrument/header-top/image/submit.png
  41. 28 0
      src/page-instrument/header-top/index.module.less
  42. 4 0
      src/page-instrument/header-top/index.tsx
  43. 74 0
      src/utils/crunker.ts

+ 542 - 0
src/components/Metronome/Metronome.vue

@@ -0,0 +1,542 @@
+<template>
+   <div class="Metronome">
+      <div class="headCon"><img class="closeImg" src="/src/page-instrument/header-top/image/closeImg.png" /></div>
+      <div class="MetronomeBox">
+         <div class="beatWrap">
+            <div class="dot" ref="dotDom" @mousedown="handleDotMousedown" @touchstart="handleDotTouchstart"></div>
+            <div class="round" ref="roundDom"></div>
+            <canvas ref="mycanvasDom" class="mycanvas" @click="handleEventCanvas"></canvas>
+            <div class="beatCon">
+               <img
+                  class="optImg"
+                  src="./imgs/jia.png"
+                  @click="
+                     () => {
+                        if (speedNum < 200) {
+                           speedNum++
+                        }
+                     }
+                  "
+               />
+               <div class="optMid">
+                  <img class="yinImg" src="./imgs/yin.png" />
+                  <div class="speedNum">{{ speedNum }}</div>
+               </div>
+               <img
+                  class="optImg"
+                  src="./imgs/jian.png"
+                  @click="
+                     () => {
+                        if (speedNum > 50) {
+                           speedNum--
+                        }
+                     }
+                  "
+               />
+            </div>
+         </div>
+         <div class="selectCon">
+            <popover v-model:show="beatSymbolShow" placement="top" :show-arrow="false" class="selectBeatPopover" :offset="[0, 6]">
+               <div class="selectBeatCon">
+                  <div
+                     class="selectBeatItem"
+                     :class="{ actBeatItem: beatSymbol === item.value }"
+                     v-for="(item, index) in beatSymbolOpt"
+                     :key="item.value"
+                  >
+                     <div :class="['beatSymbolImg', `beatSymbolImg${index + 1}`]"></div>
+                  </div>
+               </div>
+               <template #reference>
+                  <div class="beatSymbolSel">
+                     <div class="beatSymbol"></div>
+                  </div>
+               </template>
+            </popover>
+         </div>
+         <div class="sliderList">
+            <img src="./imgs/sound.png" alt="" />
+            <Slider v-model="volumeNum" :step="1">
+               <template #button>
+                  <img class="thumbDot" src="./imgs/dot1.png" alt="" />
+               </template>
+            </Slider>
+            <span class="sliderText">{{ volumeNum }}</span>
+         </div>
+         <div v-if="playState === 'pause'" class="playBtn" @click="startPlay">
+            <span>播放</span>
+            <img src="./imgs/play.png" />
+         </div>
+         <div v-else class="playBtn" @click="pausePlay">
+            <span>暂停</span>
+            <img src="./imgs/pause.png" />
+         </div>
+      </div>
+   </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, onUnmounted, h, watch } from "vue"
+import { Popover, Slider } from "vant"
+import useMetronome from "./useMetronome"
+
+const emits = defineEmits<{
+   (e: "playStateChange", state: "play" | "pause"): void
+   (e: "close"): void
+}>()
+
+/* dom */
+const mycanvasDom = ref<HTMLCanvasElement>()
+const dotDom = ref<HTMLElement>()
+const roundDom = ref<HTMLElement>()
+const fontSizeScale = window.fontSize / 37.5
+/* select */
+const beatVal = ref("1")
+const beatValOpt = [
+   {
+      value: "0"
+   },
+   {
+      value: "1"
+   },
+   {
+      value: "1-1"
+   },
+   {
+      value: "1-1-1"
+   },
+   {
+      value: "1-1-1-1"
+   },
+   {
+      value: "1-1-1-1-1"
+   },
+   {
+      value: "1-1-1-1-1-1"
+   },
+   {
+      value: "1-1-1-1-1-1-1"
+   },
+   {
+      value: "1-1-1-1-1-1-1-1"
+   },
+   {
+      value: "1-1-1-1-1-1-1-1-1"
+   }
+]
+const beatSymbol = ref("1")
+const beatSymbolShow = ref(false)
+const beatSymbolOpt = [
+   {
+      value: "1"
+   },
+   {
+      value: "0.5-0.5"
+   },
+   {
+      value: "0.3333333-0.3333333-0.3333333"
+   },
+   {
+      value: "0.25-0.25-0.25-0.25"
+   },
+   {
+      value: "0.6666666-0.3333333"
+   },
+   {
+      value: "0.75-0.25"
+   },
+   {
+      value: "0.5-0.25-0.25"
+   }
+]
+
+const { volumeNum, playState, speedNum, startPlay, pausePlay } = useMetronome(beatVal, beatSymbol)
+
+onMounted(() => {
+   getCircleBar(speedToScalc(speedNum.value))
+})
+
+watch(playState, () => {
+   emits("playStateChange", playState.value)
+})
+watch(speedNum, () => {
+   if (playState.value === "play") {
+      pausePlay()
+   }
+   getCircleBar(speedToScalc(speedNum.value))
+})
+watch([beatVal, beatSymbol], () => {
+   if (playState.value === "play") {
+      pausePlay()
+   }
+})
+function speedToScalc(speed: number) {
+   return ((speed - 50) / 150) * 100
+}
+function scalcToSpeed(scalc: number) {
+   return Math.round((scalc * 150) / 100 + 50)
+}
+const handleEventCanvas = (e: MouseEvent | TouchEvent) => {
+   const circle = mycanvasDom.value!.getBoundingClientRect()
+   const centerX = circle.left + circle.width / 2
+   const centerY = circle.top + circle.height / 2
+   const event = isTouchEvent(e) ? e.touches[0] : e
+   const angle = Math.atan2(event.clientY - centerY, event.clientX - centerX)
+   const percentage = (angle * (180 / Math.PI) + 180) / 360
+   const scalc = Math.round(percentage * 100) - 25
+   speedNum.value = scalcToSpeed(scalc < 0 ? 100 + scalc : scalc)
+}
+function handleDotMousedown() {
+   function onMouseup() {
+      document.removeEventListener("mousemove", onMousemove)
+      document.removeEventListener("mouseup", onMouseup)
+   }
+   document.addEventListener("mousemove", onMousemove)
+   document.addEventListener("mouseup", onMouseup)
+}
+function handleDotTouchstart() {
+   function onTouchend() {
+      document.removeEventListener("touchmove", onTouchmove)
+      document.removeEventListener("touchend", onTouchend)
+   }
+   document.addEventListener("touchmove", onTouchmove)
+   document.addEventListener("touchend", onTouchend)
+}
+function onMousemove(e: MouseEvent) {
+   handleEventCanvas(e)
+}
+function onTouchmove(e: TouchEvent) {
+   handleEventCanvas(e)
+}
+// 根据百分比画圆
+function getCircleBar(steps: number) {
+   const radius = 74 * fontSizeScale //半径
+   const colorList = ["#04C8BB", "#60E0C5"]
+   const mycanvas = mycanvasDom.value!
+   mycanvas.width = 153 * fontSizeScale
+   mycanvas.height = 153 * fontSizeScale
+   const ctx = mycanvas.getContext("2d")!
+   // 每次进来清空画布
+   ctx.clearRect(0, 0, mycanvas.width, mycanvas.height)
+   //找到画布的中心点
+   const canvasX = mycanvas.width / 2
+   const canvasY = mycanvas.height / 2
+   //进度条是100%,所以要把一圈360度分成100份
+   const progress = (Math.PI * 2) / 100
+   const ao = -Math.PI / 2 + steps * progress
+   const x1 = radius + Math.cos(ao) * radius
+   const y1 = radius + Math.sin(ao) * radius
+   // 这里开始算坐标
+   dotDom.value!.style.left = x1 - 5 * fontSizeScale + "Px"
+   dotDom.value!.style.top = y1 - 5 * fontSizeScale + "Px"
+   // 绘制渐变色
+   const gradientDirection = {
+      x1: 0,
+      y1: 0,
+      x2: radius * 2, // 半径*2
+      y2: 0
+   }
+   gradientDirection.y2 = radius * 2
+   const gradient = ctx.createLinearGradient(gradientDirection.x1, gradientDirection.y1, gradientDirection.x2, gradientDirection.y2)
+   colorList.map((color, index) => {
+      gradient.addColorStop(index, color)
+   })
+   ctx.strokeStyle = gradient
+   ctx.lineWidth = 5.1 * fontSizeScale
+   ctx.save()
+   ctx.beginPath()
+   ctx.arc(canvasX, canvasY, radius, -Math.PI / 2, -Math.PI / 2 + steps * progress, false)
+   ctx.stroke()
+   ctx.closePath()
+   ctx.restore()
+}
+function isTouchEvent(e: MouseEvent | TouchEvent): e is TouchEvent {
+   return window.TouchEvent && e instanceof window.TouchEvent
+}
+defineExpose({
+   startPlay,
+   pausePlay,
+   speedNum,
+   beatSymbol
+})
+</script>
+
+<style lang="less" scoped>
+.Metronome {
+   width: 266px;
+   height: 308px;
+   background: #ffffff;
+   border-radius: 16px;
+   position: relative;
+   .headCon {
+      position: absolute;
+      right: -38px;
+      top: -16px;
+      .closeImg {
+         width: 28px;
+         height: 28px;
+      }
+   }
+   .MetronomeBox {
+      width: 100%;
+      height: 100%;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      z-index: 2;
+   }
+   .beatWrap {
+      flex-shrink: 0;
+      margin-top: 20px;
+      width: 152px;
+      height: 152px;
+      position: relative;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      .dot {
+         position: absolute;
+         width: 14px;
+         height: 14px;
+         background: url("./imgs/dot.png") no-repeat;
+         background-size: 100% 100%;
+         cursor: pointer;
+         z-index: 3;
+      }
+      .round {
+         position: absolute;
+         left: 0;
+         top: 0;
+         width: 100%;
+         height: 100%;
+         border-radius: 50%;
+         border: 5px solid #d1d1d1;
+         z-index: 1;
+      }
+      .mycanvas {
+         position: absolute;
+         left: 0;
+         top: 0;
+         z-index: 2;
+      }
+      .beatCon {
+         border-radius: 50%;
+         z-index: 4;
+         width: 122px;
+         height: 122px;
+         background: url("./imgs/yuan.png") no-repeat;
+         background-size: 100% 100%;
+         display: flex;
+         flex-direction: column;
+         align-items: center;
+         justify-content: center;
+         .optMid {
+            margin: 6px 0;
+            display: flex;
+            align-items: center;
+            .yinImg {
+               width: 16px;
+               height: 16px;
+            }
+            .speedNum {
+               margin-left: 3px;
+               font-weight: bold;
+               font-size: 26px;
+               color: #000000;
+               line-height: 30px;
+            }
+         }
+         .optImg {
+            cursor: pointer;
+            width: 20px;
+            height: 20px;
+         }
+      }
+   }
+   .selectCon {
+      display: flex;
+      margin-top: 12px;
+      .beatSymbolSel {
+         width: 78px;
+         height: 28px;
+         background: #f6f6f6;
+         border-radius: 14px;
+      }
+   }
+   .sliderList {
+      margin-top: 13px;
+      display: flex;
+      align-items: center;
+      & > img {
+         width: 18px;
+         height: 18px;
+         margin-right: 8px;
+      }
+      &:deep(.van-slider) {
+         height: 3px;
+         width: 120px;
+         background: #ebebeb;
+         border-radius: 2px;
+         .thumbDot {
+            width: 12px;
+            height: 12px;
+         }
+      }
+      .sliderText {
+         width: 24px;
+         margin-left: 8px;
+         font-weight: 500;
+         font-size: 14px;
+         color: #04c8bb;
+         line-height: 20px;
+      }
+      .thumbDot {
+         width: 21px;
+         height: 21px;
+      }
+   }
+   .playBtn {
+      cursor: pointer;
+      margin-top: 13px;
+      width: 162px;
+      height: 36px;
+      background: linear-gradient(135deg, #04c8bb 0%, #60e0c5 100%);
+      border-radius: 18px;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      font-weight: 600;
+      font-size: 14px;
+      color: #ffffff;
+      line-height: 20px;
+      & > img {
+         margin-left: 4px;
+         width: 12px;
+         height: 12px;
+      }
+   }
+}
+</style>
+<style lang="less">
+.selectBeatPopover {
+   .selectBeatCon {
+      width: 82px;
+      height: 176px;
+      background: #ffffff;
+      box-shadow: 0px 2px 9px 0px rgba(0, 0, 0, 0.09);
+      border-radius: 10px;
+      padding: 4px 6px;
+      overflow-y: auto;
+      &::-webkit-scrollbar {
+         width: 0;
+         display: none;
+      }
+      .selectBeatItem {
+         width: 100%;
+         height: 34px;
+         border-radius: 6px;
+         display: flex;
+         align-items: center;
+         justify-content: center;
+         cursor: pointer;
+         &.actBeatItem {
+            .beatSymbolImg {
+               &.beatSymbolImg1 {
+                  background: url("./imgs/a1.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+               &.beatSymbolImg2 {
+                  background: url("./imgs/a2.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+               &.beatSymbolImg3 {
+                  background: url("./imgs/a3.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+               &.beatSymbolImg4 {
+                  background: url("./imgs/a4.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+               &.beatSymbolImg5 {
+                  background: url("./imgs/a5.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+               &.beatSymbolImg6 {
+                  background: url("./imgs/a6.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+               &.beatSymbolImg7 {
+                  background: url("./imgs/a7.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+            }
+         }
+         &:hover {
+            background: #04c8bb;
+            .beatSymbolImg {
+               &.beatSymbolImg1 {
+                  background: url("./imgs/c1.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+               &.beatSymbolImg2 {
+                  background: url("./imgs/c2.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+               &.beatSymbolImg3 {
+                  background: url("./imgs/c3.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+               &.beatSymbolImg4 {
+                  background: url("./imgs/c4.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+               &.beatSymbolImg5 {
+                  background: url("./imgs/c5.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+               &.beatSymbolImg6 {
+                  background: url("./imgs/c6.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+               &.beatSymbolImg7 {
+                  background: url("./imgs/c7.png") no-repeat;
+                  background-size: 100% 100%;
+               }
+            }
+         }
+         .beatSymbolImg {
+            width: 35px;
+            height: 37px;
+            &.beatSymbolImg1 {
+               background: url("./imgs/b1.png") no-repeat;
+               background-size: 100% 100%;
+            }
+            &.beatSymbolImg2 {
+               background: url("./imgs/b2.png") no-repeat;
+               background-size: 100% 100%;
+            }
+            &.beatSymbolImg3 {
+               background: url("./imgs/b3.png") no-repeat;
+               background-size: 100% 100%;
+            }
+            &.beatSymbolImg4 {
+               background: url("./imgs/b4.png") no-repeat;
+               background-size: 100% 100%;
+            }
+            &.beatSymbolImg5 {
+               background: url("./imgs/b5.png") no-repeat;
+               background-size: 100% 100%;
+            }
+            &.beatSymbolImg6 {
+               background: url("./imgs/b6.png") no-repeat;
+               background-size: 100% 100%;
+            }
+            &.beatSymbolImg7 {
+               background: url("./imgs/b7.png") no-repeat;
+               background-size: 100% 100%;
+            }
+         }
+      }
+   }
+}
+</style>

+ 185 - 0
src/components/Metronome/MetronomeBtn.vue

@@ -0,0 +1,185 @@
+<template>
+   <div class="MetronomeBtn" @click="metronomeShow = true">
+      <img
+         class="zhen"
+         :style="{
+            '--rotateWagTime': rotateWagTime + 's'
+         }"
+         :class="[playState === 'play' && 'playWagAnimation' + beatSymbolOpt[beatSymbol]]"
+         src="./imgs/zhen.png"
+      />
+      <img class="bai" src="./imgs/metronomeBtn.png" />
+      <Popup
+         v-model:show="metronomeShow"
+         class="popup-custom van-scale"
+         transition="van-scale"
+         teleport="body"
+         :overlay-style="{ background: 'rgba(0, 0, 0, 0.7)' }"
+      >
+         <Metronome></Metronome>
+      </Popup>
+   </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from "vue"
+import { Popup } from "vant"
+import Metronome from "./Metronome.vue"
+
+const playState = ref<"play" | "pause">("pause")
+// function handlePlayStateChange(state: "play" | "pause") {
+//    if (state === "play") {
+//       rotateWagTime.value = parseFloat((60 / metronomeDom.value!.speedNum).toFixed(4))
+//       beatSymbol.value = metronomeDom.value!.beatSymbol
+//    }
+//    playState.value = state
+// }
+const metronomeShow = ref(false)
+const beatSymbol = ref("1")
+const beatSymbolOpt: Record<string, any> = {
+   "1": "1",
+   "0.5-0.5": "2",
+   "0.3333333-0.3333333-0.3333333": "3",
+   "0.25-0.25-0.25-0.25": "4",
+   "0.6666666-0.3333333": "5",
+   "0.75-0.25": "6",
+   "0.5-0.25-0.25": "7"
+}
+const rotateWagTime = ref(60 / 90)
+</script>
+
+<style lang="less" scoped>
+.MetronomeBtn {
+   width: 50px;
+   height: 50px;
+   .bai {
+      width: 100%;
+      height: 100%;
+   }
+   .zhen {
+      width: 7px;
+      height: 20px;
+      position: absolute;
+      bottom: 8px;
+      left: 50%;
+      transform: translateX(-50%);
+      transform-origin: 50% 100%;
+      &.playWagAnimation1 {
+         animation: rotateWag1 var(--rotateWagTime) linear infinite alternate;
+      }
+      @keyframes rotateWag1 {
+         0% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+         100% {
+            transform: translateX(-50%) rotate(90deg);
+         }
+      }
+      &.playWagAnimation2 {
+         animation: rotateWag2 var(--rotateWagTime) linear infinite;
+      }
+      @keyframes rotateWag2 {
+         0% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+         50% {
+            transform: translateX(-50%) rotate(90deg);
+         }
+         100% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+      }
+      &.playWagAnimation3 {
+         animation: rotateWag3 var(--rotateWagTime) linear infinite alternate;
+      }
+      @keyframes rotateWag3 {
+         0% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+         33.33% {
+            transform: translateX(-50%) rotate(90deg);
+         }
+         66.66% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+         100% {
+            transform: translateX(-50%) rotate(90deg);
+         }
+      }
+      &.playWagAnimation4 {
+         animation: rotateWag4 var(--rotateWagTime) linear infinite;
+      }
+      @keyframes rotateWag4 {
+         0% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+         25% {
+            transform: translateX(-50%) rotate(90deg);
+         }
+         50% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+         75% {
+            transform: translateX(-50%) rotate(90deg);
+         }
+         100% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+      }
+      &.playWagAnimation5 {
+         animation: rotateWag5 var(--rotateWagTime) linear infinite;
+      }
+      @keyframes rotateWag5 {
+         0% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+         66.66% {
+            transform: translateX(-50%) rotate(90deg);
+         }
+         100% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+      }
+      &.playWagAnimation6 {
+         animation: rotateWag6 var(--rotateWagTime) linear infinite;
+      }
+      @keyframes rotateWag6 {
+         0% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+         75% {
+            transform: translateX(-50%) rotate(90deg);
+         }
+         100% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+      }
+      &.playWagAnimation7 {
+         animation: rotateWag7 calc(var(--rotateWagTime) * 2) linear infinite;
+      }
+      @keyframes rotateWag7 {
+         0% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+         25% {
+            transform: translateX(-50%) rotate(90deg);
+         }
+         37.5% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+         50% {
+            transform: translateX(-50%) rotate(90deg);
+         }
+         75% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+         87.5% {
+            transform: translateX(-50%) rotate(90deg);
+         }
+         100% {
+            transform: translateX(-50%) rotate(-90deg);
+         }
+      }
+   }
+}
+</style>

BIN
src/components/Metronome/imgs/a1.png


BIN
src/components/Metronome/imgs/a2.png


BIN
src/components/Metronome/imgs/a3.png


BIN
src/components/Metronome/imgs/a4.png


BIN
src/components/Metronome/imgs/a5.png


BIN
src/components/Metronome/imgs/a6.png


BIN
src/components/Metronome/imgs/a7.png


BIN
src/components/Metronome/imgs/b1.png


BIN
src/components/Metronome/imgs/b2.png


BIN
src/components/Metronome/imgs/b3.png


BIN
src/components/Metronome/imgs/b4.png


BIN
src/components/Metronome/imgs/b5.png


BIN
src/components/Metronome/imgs/b6.png


BIN
src/components/Metronome/imgs/b7.png


BIN
src/components/Metronome/imgs/c1.png


BIN
src/components/Metronome/imgs/c2.png


BIN
src/components/Metronome/imgs/c3.png


BIN
src/components/Metronome/imgs/c4.png


BIN
src/components/Metronome/imgs/c5.png


BIN
src/components/Metronome/imgs/c6.png


BIN
src/components/Metronome/imgs/c7.png


BIN
src/components/Metronome/imgs/dot.png


BIN
src/components/Metronome/imgs/dot1.png


BIN
src/components/Metronome/imgs/jia.png


BIN
src/components/Metronome/imgs/jian.png


BIN
src/components/Metronome/imgs/metronomeBtn.png


BIN
src/components/Metronome/imgs/pause.png


BIN
src/components/Metronome/imgs/play.png


BIN
src/components/Metronome/imgs/sound.png


BIN
src/components/Metronome/imgs/yin.png


BIN
src/components/Metronome/imgs/yuan.png


BIN
src/components/Metronome/imgs/zhen.png


+ 2 - 0
src/components/Metronome/index.ts

@@ -0,0 +1,2 @@
+import MetronomeBtn from "./MetronomeBtn.vue"
+export default MetronomeBtn

+ 135 - 0
src/components/Metronome/useMetronome.ts

@@ -0,0 +1,135 @@
+import { ref, Ref, watch, onUnmounted, computed, onMounted } from "vue"
+import tickWav from "/src/assets/tick.wav"
+import tockWav from "/src/assets/tock.wav"
+
+/*  播放相关 */
+export default function useMetronome(beatVal: Ref<string>, beatSymbol: Ref<string>) {
+   /* 音量 */
+   const volumeNum = ref(100)
+   watch(volumeNum, () => {
+      changeVolume(volumeNum.value / 100)
+   })
+   /* 播放状态 */
+   const playState = ref<"play" | "pause">("pause")
+   /* 速度 */
+   const speedNum = ref(90)
+   /* 音频hooks */
+   const { start, stop, changeVolume } = useHandleAudio([tickWav, tockWav])
+   onUnmounted(() => {
+      pausePlay()
+   })
+   // 开始播放
+   async function startPlay() {
+      ;(await start(computeTimeArr.value, {
+         volume: volumeNum.value / 100,
+         playbackRate: speedNum.value / 60
+      })) && (playState.value = "play")
+   }
+   //暂停播放
+   function pausePlay() {
+      stop()
+      playState.value = "pause"
+   }
+   const computeTimeArr = computed(() => {
+      if (beatSymbol.value === "1") {
+         return beatVal.value.split("-")
+      }
+      return beatSymbol.value.split("-")
+   })
+
+   return {
+      volumeNum,
+      playState,
+      speedNum,
+      startPlay,
+      pausePlay
+   }
+}
+
+import Crunker from "/src/utils/crunker"
+function useHandleAudio(files: [File | Blob | string, File | Blob | string]) {
+   const crunker = new Crunker()
+   async function handleBatetimeToAudio(files: [File | Blob | string, File | Blob | string], timeArr: string[], playbackRate: number) {
+      try {
+         const buffersArr = await crunker.fetchAudio(...files)
+         const tickAudioBuff = buffersArr[0]!
+         const tockAudioBuff = buffersArr[1]!
+         let mergeAudio: AudioBuffer | undefined
+         /* 处理音频合并 */
+         timeArr.map((time, index) => {
+            const timeNum = Number(time)
+            let nowBuff = index === 0 && timeNum !== 0 ? tickAudioBuff : tockAudioBuff
+            /*  当速度过快时候 响的时候大于整个拍子时候 对响进行裁剪  当间隔小于响的时候也进行裁剪 */
+            if (1 / playbackRate - nowBuff.duration * timeArr.length <= 0 || (timeNum || 1) / playbackRate - nowBuff.duration <= 0) {
+               nowBuff = crunker.sliceAudio(nowBuff, 0, nowBuff.duration / playbackRate, 0, 0.12)
+            }
+            mergeAudio ? (mergeAudio = crunker.concatAudio([mergeAudio, nowBuff])) : (mergeAudio = nowBuff)
+            mergeAudio = crunker.padAudio(
+               mergeAudio,
+               mergeAudio.duration - 0.01, // 预留0.01的安全距离 crunker这里有bug duration时长精确度不够
+               (timeNum || 1) / playbackRate - nowBuff.duration
+            )
+         })
+         return mergeAudio
+      } catch (err) {
+         console.log(err)
+         return undefined
+      }
+   }
+
+   const audioCtx = crunker.context
+   let audioSourceNode: AudioBufferSourceNode | null
+   let audioGainNode: GainNode | null
+   async function start(timeArr: string[], opt: { volume: number; playbackRate: number }) {
+      const buffer = await handleBatetimeToAudio(files, timeArr, opt.playbackRate)
+      if (buffer) {
+         audioSourceNode = audioCtx.createBufferSource()
+         audioSourceNode.buffer = buffer
+         audioGainNode = audioCtx.createGain()
+         audioSourceNode.connect(audioGainNode)
+         audioGainNode.connect(audioCtx.destination)
+         audioGainNode.gain.value = opt.volume
+         audioSourceNode.loop = true
+         audioSourceNode.start()
+         return true
+      } else {
+         return false
+      }
+   }
+   function stop() {
+      audioSourceNode?.stop()
+      audioSourceNode = null
+      audioGainNode = null
+   }
+   function changeVolume(volume: number) {
+      audioGainNode && (audioGainNode.gain.value = volume)
+   }
+   return {
+      start,
+      stop,
+      changeVolume
+   }
+}
+
+export const beatOpt: Record<string, any> = {
+   "0": "0",
+   "1": "1",
+   "1-1": "2",
+   "1-1-1": "3",
+   "1-1-1-1": "4",
+   "1-1-1-1-1": "5",
+   "1-1-1-1-1-1": "6",
+   "1-1-1-1-1-1-1": "7",
+   "1-1-1-1-1-1-1-1": "8",
+   "1-1-1-1-1-1-1-1-1": "9"
+}
+
+export const beatSymbolOpt: Record<string, any> = {
+   "1": "1",
+   "0.5-0.5": "2",
+   "0.3333333-0.3333333-0.3333333": "3",
+   "0.25-0.25-0.25-0.25": "4",
+   "0.6666666-0.3333333": "5",
+   "0.75-0.25": "6",
+   "0.5-0.25-0.25": "7"
+}

BIN
src/page-instrument/header-top/image/icon_pause.png


BIN
src/page-instrument/header-top/image/icon_play.png


BIN
src/page-instrument/header-top/image/icon_reset.png


BIN
src/page-instrument/header-top/image/submit.png


+ 28 - 0
src/page-instrument/header-top/index.module.less

@@ -369,6 +369,34 @@
     }
 }
 
+.metBtn{
+    cursor: pointer;
+    position: fixed;
+    right: 100px;
+    bottom: 12px;
+    transition: bottom .2s ease;
+    &.metBtnLeft {
+        left: 102px !important;
+        right: auto !important;
+        bottom: 12px !important;
+    }
+
+    &.metBtnRight {
+        right: 102px !important;
+        left: auto !important;
+        bottom: 12px !important;
+    }
+    &.resetBtnShow{
+        right: 170px;
+        &.metBtnLeft {
+            left: 172px !important;
+        }
+        &.metBtnRight {
+            right: 172px !important;
+        }
+    }
+}
+
 :global {
     .var-popup {
         overflow: hidden;

+ 4 - 0
src/page-instrument/header-top/index.tsx

@@ -33,6 +33,7 @@ import { isMusicList, musicListShow } from "../component/the-music-list";
 import { EvaluatingDriver, FollowDriver, PractiseDriver } from "../custom-plugins/guide-driver";
 import { fingerRef } from "/src/page-instrument/view-detail/index"
 import { handleLoadBeatMusic } from "/src/view/audio-list"
+import Metronome from "/src/components/Metronome"
 
 const ModeView = defineAsyncComponent(() =>
   import('./modeView')
@@ -984,6 +985,9 @@ export default defineComponent({
           <img class={styles.iconBtn} src={headImg("icon_reset.png")} />
         </div>
 
+        {/* 节拍器按钮 */}
+        <Metronome class={[styles.metBtn,resetBtn.value.display?styles.resetBtnShow:'',state.platform === IPlatform.PC && state.musicScoreBtnDirection === "left" ? styles.metBtnLeft : state.platform === IPlatform.PC && state.musicScoreBtnDirection === "right" ? styles.metBtnRight : ""]}></Metronome>
+
         <Popup v-model:show={headTopData.settingMode} class="popup-custom van-scale center-closeBtn settingBoxClass_drag" transition="van-scale" teleport="body" style={positionInfo.styleDrag.value} overlay-style={{ background: "rgba(0, 0, 0, 0.7)" }}>
           <Settting />
           {state.platform === IPlatform.PC && <Dragbom showGuide={!state.guideInfo?.teacherDrag} onGuideDone={handleGuide} />}

+ 74 - 0
src/utils/crunker.ts

@@ -16,6 +16,9 @@ export default class Crunker {
       this._sampleRate = sampleRate
       this._concurrentNetworkRequests = concurrentNetworkRequests
    }
+   get context(): AudioContext {
+      return this._context
+   }
    private _createContext(sampleRate = 22050): AudioContext {
       window.AudioContext = window.AudioContext || (window as any).webkitAudioContext || (window as any).mozAudioContext
       return new AudioContext({ sampleRate })
@@ -118,6 +121,77 @@ export default class Crunker {
       silenceDuration = silenceDuration / sampleRate
       return silenceDuration
    }
+   /**
+    * 裁剪音频
+    */
+   sliceAudio(buffer: AudioBuffer, start: number, end: number, fadeIn: number = 0, fadeOut: number = 0): AudioBuffer {
+      if (start >= end) throw new Error("")
+      const length = Math.round((end - start) * this._sampleRate)
+      const offset = Math.round(start * this._sampleRate)
+      const newBuffer = this._context.createBuffer(buffer.numberOfChannels, length, this._sampleRate)
+      for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
+         const inputData = buffer.getChannelData(channel)
+         const outputData = newBuffer.getChannelData(channel)
+         for (let i = 0; i < length; i++) {
+            outputData[i] = inputData[offset + i]
+            // Apply fade in
+            if (i < fadeIn * this._sampleRate) {
+               outputData[i] *= i / (fadeIn * this._sampleRate)
+            }
+            // Apply fade out
+            if (i > length - fadeOut * this._sampleRate) {
+               outputData[i] *= (length - i) / (fadeOut * this._sampleRate)
+            }
+         }
+      }
+      return newBuffer
+   }
+   /**
+    * 连接音频
+    */
+   concatAudio(buffers: AudioBuffer[]): AudioBuffer {
+      const output = this._context.createBuffer(this._maxNumberOfChannels(buffers), this._totalLength(buffers), this._sampleRate)
+      let offset = 0
+
+      buffers.forEach(buffer => {
+         for (let channelNumber = 0; channelNumber < buffer.numberOfChannels; channelNumber++) {
+            output.getChannelData(channelNumber).set(buffer.getChannelData(channelNumber), offset)
+         }
+
+         offset += buffer.length
+      })
+
+      return output
+   }
+   /**
+    *从指定的开始时间开始用静音填充指定的音频缓冲区
+    */
+   padAudio(buffer: AudioBuffer, padStart: number = 0, seconds: number = 0): AudioBuffer {
+      if (seconds === 0) return buffer
+      padStart
+      if (padStart < 0) throw new Error("")
+      if (seconds < 0) throw new Error('Crunker: Parameter "seconds" in padAudio must be positive')
+
+      const updatedBuffer = this._context.createBuffer(
+         buffer.numberOfChannels,
+         Math.ceil(buffer.length + seconds * buffer.sampleRate),
+         buffer.sampleRate
+      )
+
+      for (let channelNumber = 0; channelNumber < buffer.numberOfChannels; channelNumber++) {
+         const channelData = buffer.getChannelData(channelNumber)
+         updatedBuffer.getChannelData(channelNumber).set(channelData.subarray(0, Math.ceil(padStart * buffer.sampleRate) + 1), 0)
+
+         updatedBuffer
+            .getChannelData(channelNumber)
+            .set(
+               channelData.subarray(Math.ceil(padStart * buffer.sampleRate) + 2, updatedBuffer.length + 1),
+               Math.ceil((padStart + seconds) * buffer.sampleRate)
+            )
+      }
+
+      return updatedBuffer
+   }
    private _maxNumberOfChannels(buffers: AudioBuffer[]): number {
       return Math.max(...buffers.map(buffer => buffer.numberOfChannels))
    }