Explorar o código

更新节拍器

黄琪勇 hai 1 ano
pai
achega
be7a612529

BIN=BIN
src/assets/tick.mp3


BIN=BIN
src/assets/tick.wav


BIN=BIN
src/assets/tock.mp3


BIN=BIN
src/assets/tock.wav


+ 7 - 3
src/helpers/metronome.ts

@@ -9,8 +9,8 @@ import { browser } from "/src/utils/index";
 import state from "/src/state";
 import { Howl } from "howler";
 import tockAndTick from "/src/constant/tockAndTick.json";
-import tickWav from "/src/assets/tick.wav";
-import tockWav from "/src/assets/tock.wav";
+import tickWav from "/src/assets/tick.mp3";
+import tockWav from "/src/assets/tock.mp3";
 
 type IOptions = {
 	speed: number;
@@ -119,7 +119,7 @@ class Metronome {
 		// 	this.source2 = this.loadAudio2();
 		// }
 		// metronomeData.initPlayerState = true;
-
+		if(metronomeData.initPlayerState) return
 		Promise.all([this.createAudio(tickWav), this.createAudio(tockWav)]).then(
 			([tick, tock]) => {
 				if (tick) {
@@ -179,6 +179,10 @@ class Metronome {
 	};
 	// 播放
 	playAudio = () => {
+		/* 如果是 评测模式且不为MIDI  不运行节拍器播放 */
+		if (state.modeType === "practise" && state.playMode !== "MIDI") {
+			return
+		}
 		if (!metronomeData.initPlayerState || state.playState === 'paused') return;
 		const beatVolume = state.setting.beatVolume / 100
 		// this.source = metronomeData.activeMetro?.index === 0 ? this.source1 : this.source2;

+ 13 - 1
src/page-instrument/header-top/index.tsx

@@ -133,6 +133,17 @@ export default defineComponent({
         display: true,
       };
     });
+    /** 节拍器按钮 */
+    const metronomeBtn = computed(() => {
+      // 选择模式  不显示
+      if (headTopData.modeType !== "show") return { display: false, disabled: true };
+      // 音频播放中 禁用
+      if (state.playState === "play") return { display: true, disabled: true };
+      return {
+        disabled: false,
+        display: true,
+      };
+    });
 
     /** 指法按钮 */
     const fingeringBtn = computed(() => {
@@ -465,7 +476,8 @@ export default defineComponent({
             {
               state.modeType !== "evaluating" && 
                 <div
-                  class={[styles.btn]}
+                  style={{ display: metronomeBtn.value.display ? "" : "none" }}
+                  class={[styles.btn, metronomeBtn.value.disabled && styles.disabled]}
                   onClick={async () => {
                     metronomeData.disable = !metronomeData.disable;
                     metronomeData.metro?.initPlayer();

+ 3 - 2
src/page-instrument/header-top/settting/index.tsx

@@ -142,7 +142,8 @@ export default defineComponent({
 									extra: () => <Switch v-model={state.setting.eyeProtection}></Switch>,
 								}}
 							</Cell>
-							<Cell
+							{/* 节拍器 音量注释掉了  这里的代码也一并注释了 state.setting.beatVolume = state.setting.beatVolume || 50 */}
+							{/* <Cell
 								title="节拍器音量"
 								class={styles.sliderWrap}
 								center
@@ -161,7 +162,7 @@ export default defineComponent({
 										</Slider>
 									),
 								}}
-							</Cell>							
+							</Cell>							 */}
 							<div class={styles.btnsbar}>
 								{/* <div class={styles.btn} onClick={downPng}>
 									<img src={iconDown} />

+ 3 - 2
src/page-instrument/view-detail/index.tsx

@@ -32,7 +32,7 @@ import { setCustomGradual, setCustomNoteRealValue } from "/src/helpers/customMus
 import { usePageVisibility } from "@vant/use";
 import { initMidi } from "/src/helpers/midiPlay"
 import TheAudio from "/src/components/the-audio"
-import tickWav from "/src/assets/tick.wav";
+import tickWav from "/src/assets/tick.mp3";
 import Title from "../header-top/title";
 
 const DelayCheck = defineAsyncComponent(() =>
@@ -110,7 +110,8 @@ export default defineComponent({
       const settting = store.get("musicscoresetting");
       if (settting) {
         state.setting = settting;
-        state.setting.beatVolume = state.setting.beatVolume || 50
+        //state.setting.beatVolume = state.setting.beatVolume || 50
+        state.setting.beatVolume = 50
         if (state.setting.camera) {
           const res = await api_openCamera();
           // 没有授权

+ 15 - 1
src/state.ts

@@ -1,5 +1,5 @@
 import { closeToast, showToast } from "vant";
-import { nextTick, reactive } from "vue";
+import { nextTick, reactive, watch } from "vue";
 import { OpenSheetMusicDisplay } from "../osmd-extended/src";
 import { metronomeData } from "./helpers/metronome";
 import { GradualNote, GradualTimes, GradualVersion } from "./type";
@@ -14,6 +14,7 @@ import { verifyCanRepeat, getDuration } from "./helpers/formateMusic";
 import { getMusicSheetDetail } from "./utils/baseApi"
 import { getQuery } from "/src/utils/queryString";
 import { followData } from "/src/view/follow-practice/index"
+import { changeSongSourceByBate } from "/src/view/audio-list"
 
 const query: any = getQuery();
 
@@ -650,6 +651,14 @@ export const skipNotePlay = async (itemIndex: number, isStart = false) => {
   }
 };
 
+/* 还原音频源 */
+watch(()=>state.playState,()=>{
+  // 播放之前  当为评测模式和不为MIDI时候按  是否禁用节拍器  切换音源
+  if (state.playState==='paused' && state.modeType === "practise" && state.playMode !== "MIDI") {
+    console.log("还原音源")
+    changeSongSourceByBate(true)
+  }
+})
 /**
  * 切换曲谱播放状态
  * @param playState 可选: 默认 undefined, 需要切换的状态 play:播放, paused: 暂停
@@ -660,6 +669,11 @@ export const togglePlay = async (playState?: "play" | "paused", sourceType?: str
     if (sourceType !== 'courseware') showToast('音频资源加载中,请稍后')
     return
   }
+  // 播放之前  当为评测模式和不为MIDI时候按  是否禁用节拍器  切换音源
+  if (state.modeType === "practise" && state.playMode !== "MIDI") {
+    console.log("设置音源")
+    changeSongSourceByBate(metronomeData.disable)
+  }
   // midi播放
   if (state.isAppPlay) {
     if( playState === "paused" ) {

+ 167 - 0
src/utils/crunker.ts

@@ -0,0 +1,167 @@
+interface CrunkerConstructorOptions {
+   sampleRate: number
+   concurrentNetworkRequests: number
+}
+
+type CrunkerInputTypes = string | File | Blob
+
+export default class Crunker {
+   private readonly _sampleRate: number
+   private readonly _concurrentNetworkRequests: number
+   private readonly _context: AudioContext
+
+   constructor({ sampleRate, concurrentNetworkRequests = 200 }: Partial<CrunkerConstructorOptions> = {}) {
+      this._context = this._createContext(sampleRate)
+      sampleRate ||= this._context.sampleRate
+      this._sampleRate = sampleRate
+      this._concurrentNetworkRequests = concurrentNetworkRequests
+   }
+   private _createContext(sampleRate = 44_100): AudioContext {
+      window.AudioContext = window.AudioContext || (window as any).webkitAudioContext || (window as any).mozAudioContext
+      return new AudioContext({ sampleRate })
+   }
+   /**
+    *转换url等类型为buffer
+    */
+   async fetchAudio(...filepaths: CrunkerInputTypes[]): Promise<AudioBuffer[]> {
+      const buffers: AudioBuffer[] = []
+      const groups = Math.ceil(filepaths.length / this._concurrentNetworkRequests)
+      for (let i = 0; i < groups; i++) {
+         const group = filepaths.slice(i * this._concurrentNetworkRequests, (i + 1) * this._concurrentNetworkRequests)
+         buffers.push(...(await this._fetchAudio(...group)))
+      }
+      return buffers
+   }
+   private async _fetchAudio(...filepaths: CrunkerInputTypes[]): Promise<AudioBuffer[]> {
+      return await Promise.all(
+         filepaths.map(async filepath => {
+            let buffer: ArrayBuffer
+            if (filepath instanceof File || filepath instanceof Blob) {
+               buffer = await filepath.arrayBuffer()
+            } else {
+               buffer = await fetch(filepath).then(response => {
+                  if (response.headers.has("Content-Type") && !response.headers.get("Content-Type")!.includes("audio/")) {
+                     console.warn(
+                        `Crunker: Attempted to fetch an audio file, but its MIME type is \`${
+                           response.headers.get("Content-Type")!.split(";")[0]
+                        }\`. We'll try and continue anyway. (file: "${filepath}")`
+                     )
+                  }
+                  return response.arrayBuffer()
+               })
+            }
+            return await this._context.decodeAudioData(buffer)
+         })
+      )
+   }
+   /**
+    * 根据时间合并音频
+    */
+   mergeAudioBuffers(buffers: AudioBuffer[], times: number[]): AudioBuffer {
+      if (buffers.length !== times.length) {
+         throw new Error("buffer数量和times数量必须一致")
+      }
+      const output = this._context.createBuffer(this._maxNumberOfChannels(buffers), this._sampleRate * this._maxDuration(buffers), this._sampleRate)
+      buffers.forEach((buffer, index) => {
+         for (let channelNumber = 0; channelNumber < buffer.numberOfChannels; channelNumber++) {
+            const outputData = output.getChannelData(channelNumber)
+            const bufferData = buffer.getChannelData(channelNumber)
+            const offsetNum = Math.round(times[index] * this._sampleRate) //时间偏差
+            for (let i = buffer.getChannelData(channelNumber).length - 1; i >= 0; i--) {
+               outputData[i + offsetNum] += bufferData[i]
+               // 当合并大于1或者小于-1的时候可能会爆音  所以这里取最大值和最小值
+               if (outputData[i + offsetNum] > 1) {
+                  outputData[i + offsetNum] = 1
+               }
+               if (outputData[i + offsetNum] < -1) {
+                  outputData[i + offsetNum] = -1
+               }
+            }
+            output.getChannelData(channelNumber).set(outputData)
+         }
+      })
+
+      return output
+   }
+   /**
+    * 根据buffer导出audio标签
+    */
+   exportAudioElement(buffer: AudioBuffer, type = "audio/mp3"): HTMLAudioElement {
+      const recorded = this._interleave(buffer)
+      const dataview = this._writeHeaders(recorded, buffer.numberOfChannels, buffer.sampleRate)
+      const audioBlob = new Blob([dataview], { type })
+      return this._renderAudioElement(audioBlob)
+   }
+   private _maxNumberOfChannels(buffers: AudioBuffer[]): number {
+      return Math.max(...buffers.map(buffer => buffer.numberOfChannels))
+   }
+   private _maxDuration(buffers: AudioBuffer[]): number {
+      return Math.max(...buffers.map(buffer => buffer.duration))
+   }
+   private _interleave(input: AudioBuffer): Float32Array {
+      if (input.numberOfChannels === 1) {
+         return input.getChannelData(0)
+      }
+      const channels = []
+      for (let i = 0; i < input.numberOfChannels; i++) {
+         channels.push(input.getChannelData(i))
+      }
+      const length = channels.reduce((prev, channelData) => prev + channelData.length, 0)
+      const result = new Float32Array(length)
+      let index = 0
+      let inputIndex = 0
+      while (index < length) {
+         channels.forEach(channelData => {
+            result[index++] = channelData[inputIndex]
+         })
+         inputIndex++
+      }
+      return result
+   }
+   private _renderAudioElement(blob: Blob): HTMLAudioElement {
+      const audio = document.createElement("audio")
+      audio.src = this._renderURL(blob)
+      audio.load()
+      return audio
+   }
+   private _renderURL(blob: Blob): string {
+      return (window.URL || window.webkitURL).createObjectURL(blob)
+   }
+   private _writeHeaders(buffer: Float32Array, numOfChannels: number, sampleRate: number): DataView {
+      const bitDepth = 16
+      const bytesPerSample = bitDepth / 8
+      const sampleSize = numOfChannels * bytesPerSample
+      const fileHeaderSize = 8
+      const chunkHeaderSize = 36
+      const chunkDataSize = buffer.length * bytesPerSample
+      const chunkTotalSize = chunkHeaderSize + chunkDataSize
+      const arrayBuffer = new ArrayBuffer(fileHeaderSize + chunkTotalSize)
+      const view = new DataView(arrayBuffer)
+      this._writeString(view, 0, "RIFF")
+      view.setUint32(4, chunkTotalSize, true)
+      this._writeString(view, 8, "WAVE")
+      this._writeString(view, 12, "fmt ")
+      view.setUint32(16, 16, true)
+      view.setUint16(20, 1, true)
+      view.setUint16(22, numOfChannels, true)
+      view.setUint32(24, sampleRate, true)
+      view.setUint32(28, sampleRate * sampleSize, true)
+      view.setUint16(32, sampleSize, true)
+      view.setUint16(34, bitDepth, true)
+      this._writeString(view, 36, "data")
+      view.setUint32(40, chunkDataSize, true)
+      return this._floatTo16BitPCM(view, buffer, fileHeaderSize + chunkHeaderSize)
+   }
+   private _floatTo16BitPCM(dataview: DataView, buffer: Float32Array, offset: number): DataView {
+      for (let i = 0; i < buffer.length; i++, offset += 2) {
+         const tmp = Math.max(-1, Math.min(1, buffer[i]))
+         dataview.setInt16(offset, tmp < 0 ? tmp * 0x8000 : tmp * 0x7fff, true)
+      }
+      return dataview
+   }
+   private _writeString(dataview: DataView, offset: number, header: string): void {
+      for (let i = 0; i < header.length; i++) {
+         dataview.setUint8(offset + i, header.charCodeAt(i))
+      }
+   }
+}

+ 71 - 7
src/view/audio-list/index.tsx

@@ -12,10 +12,21 @@ import state, { IPlayState, onEnded, onPlay } from "/src/state";
 import { api_playProgress, api_cloudTimeUpdae, api_cloudplayed, api_remove_cloudplayed, api_remove_cloudTimeUpdae } from "/src/helpers/communication";
 import { evaluatingData } from "/src/view/evaluating";
 import { cloudToggleState } from "/src/helpers/midiPlay"
+import { metronomeData } from "../../helpers/metronome";
+import Crunker from "../../utils/crunker"
+const crunker = new Crunker()
+import tickWav from "/src/assets/tick.mp3";
+import tockWav from "/src/assets/tock.mp3";
 
 export const audioData = reactive({
 	songEle: null as unknown as HTMLAudioElement,
 	backgroundEle: null as unknown as HTMLAudioElement,
+	songCollection: {  // 音乐源合集   beatSongEle和bateBackgroundEle是带节拍的音乐源   评测模式的时候用
+		songEle: null as unknown as HTMLAudioElement,
+		backgroundEle: null as unknown as HTMLAudioElement,
+		beatSongEle: null as unknown as HTMLAudioElement,
+		bateBackgroundEle: null as unknown as HTMLAudioElement,
+	},
 	midiRender: false,
 	progress: 0, // midi播放进度(单位:秒)
 	duration: 0 // 音频总时长(单位:秒)
@@ -112,6 +123,26 @@ export const detectTheNumberOfSoundSources = () => {
 	return total;
 };
 
+/** 切换节拍器音源 */
+export const changeSongSourceByBate = (isDisBate:boolean) => {
+	// isDisBate 为true 切换到不带节拍的,为false 切换到带节拍的
+	if(audioData.songEle && audioData.backgroundEle){
+		const songEleCurrentTime = audioData.songEle.currentTime
+		const backgroundEleCurrentTime = audioData.backgroundEle.currentTime
+		console.log("当前音乐时间:",songEleCurrentTime,backgroundEleCurrentTime)
+		if(isDisBate){
+			audioData.songEle = audioData.songCollection.songEle
+			audioData.backgroundEle = audioData.songCollection.backgroundEle
+			audioData.songEle.currentTime = songEleCurrentTime
+			audioData.backgroundEle.currentTime = backgroundEleCurrentTime
+		}else{
+			audioData.songEle = audioData.songCollection.beatSongEle
+			audioData.backgroundEle = audioData.songCollection.bateBackgroundEle
+			audioData.songEle.currentTime = songEleCurrentTime
+			audioData.backgroundEle.currentTime = backgroundEleCurrentTime
+		}
+	}
+}
 export default defineComponent({
 	name: "audio-list",
 	setup() {
@@ -203,8 +234,35 @@ export default defineComponent({
 			}
 		}
 
-		onMounted(() => {
+		onMounted(async () => {
 			if (state.playMode !== "MIDI") {
+				/* 合并节拍到音频 */
+				const [musicBuff,accompanyBuff,tickWavBuff,tockWavBuff] = await crunker.fetchAudio(state.music+'?v='+Date.now(), state.accompany+'?v='+Date.now(), tickWav, tockWav)
+				const beats:AudioBuffer[] = []
+				const beatsTime:number[] = []
+				metronomeData.metroMeasure.map(Measures=>{
+					Measures.map((item:any)=>{
+						beats.push(item.index===0?tickWavBuff:tockWavBuff)
+						beatsTime.push(item.time)
+					})
+				})
+				//合并
+				console.time("音频合并时间")
+				const musicBuffMeg = crunker.mergeAudioBuffers([musicBuff,...beats],[0,...beatsTime])
+				const accompanyBuffMeg = crunker.mergeAudioBuffers([accompanyBuff,...beats],[0,...beatsTime])
+				console.timeEnd("音频合并时间")
+				console.time("音频audioDom生成时间")
+				const musicAudio = crunker.exportAudioElement(musicBuffMeg)
+				const accompanyAudio = crunker.exportAudioElement(accompanyBuffMeg)
+				console.timeEnd("音频audioDom生成时间")
+				if (musicAudio) {
+					musicAudio.addEventListener("play", onPlay);
+					musicAudio.addEventListener("ended", onEnded);
+					accompanyAudio && (accompanyAudio.muted = true);
+				} else if (accompanyAudio) {
+					accompanyAudio.addEventListener("play", onPlay);
+					accompanyAudio.addEventListener("ended", onEnded);
+				}
 				Promise.all([createAudio(state.music), createAudio(state.accompany)]).then(
 					([music, accompany]) => {
 						state.audioDone = true;
@@ -215,14 +273,20 @@ export default defineComponent({
 						if (accompany) {
 							audioData.backgroundEle = accompany;
 						}
-						if (audioData.songEle) {
-							audioData.songEle.addEventListener("play", onPlay);
-							audioData.songEle.addEventListener("ended", onEnded);
+						if (music) {
+							music.addEventListener("play", onPlay);
+							music.addEventListener("ended", onEnded);
 							accompany && (accompany.muted = true);
-						} else if (audioData.backgroundEle) {
-							audioData.backgroundEle.addEventListener("play", onPlay);
-							audioData.backgroundEle.addEventListener("ended", onEnded);
+						} else if (accompany) {
+							accompany.addEventListener("play", onPlay);
+							accompany.addEventListener("ended", onEnded);
 						}
+						Object.assign(audioData.songCollection,{
+							songEle:music,
+							backgroundEle:accompany,
+							beatSongEle:musicAudio,
+							bateBackgroundEle:accompanyAudio
+						})
 					}
 				);
 

+ 2 - 2
src/view/tick/index.tsx

@@ -5,8 +5,8 @@ import { Popup } from "vant";
 import styles from "./index.module.less";
 import state from "/src/state";
 import { browser } from "/src/utils/index";
-import tickWav from "/src/assets/tick.wav";
-import tockWav from "/src/assets/tock.wav";
+import tickWav from "/src/assets/tick.mp3";
+import tockWav from "/src/assets/tock.mp3";
 
 const browserInfo = browser();
 export const tickData = reactive({