Browse Source

feat: 音符,节拍,关闭指针切换功能

TIANYONG 1 năm trước cách đây
mục cha
commit
bf4ae7fca2

+ 8 - 1
src/components/music-score/index.module.less

@@ -9,7 +9,7 @@
     background-color: var(--section-background-color);
   }
   img {
-    display: inline-block !important;
+    // display: inline-block !important;
     min-height: calc(80PX * var(--osdm-zoom));
     margin: auto;
     aspect-ratio: unset;
@@ -24,6 +24,13 @@
     overflow: hidden;
   }
 }
+:global(.hideCursor) {
+  .container{
+    img{
+      opacity: 0;
+    }
+  }
+}
 :global(.eyeProtection) {
   .container {
     :global(#custom-cursor-hint) {

+ 477 - 0
src/helpers/metronome.ts

@@ -0,0 +1,477 @@
+import { reactive, watch } from "vue";
+import { tickUrl as tick, tockUrl as tock } from "/src/constant/audios";
+import { browser } from "./utils";
+import state from "../pages/detail/state";
+type IOptions = {
+	speed: number;
+};
+const ac = window.AudioContext || (window as any).webkitAudioContext || (window as any).mozAudioContext || (window as any).msAudioContext;
+const browserInfo = browser();
+let tipsTimer: any = null; // 光标提示定时器
+export const metronomeData = reactive({
+	disable: true,
+	lineShow: false,
+	isClick: false,
+	metro: null as unknown as Metronome,
+	metroList: [] as number[],
+	activeList: [] as number[],
+	metroMeasure: [] as any[],
+	activeIndex: null as unknown as number,
+	activeMetro: {} as any,
+	cursorMode: 1 as number, // 光标模式:1:音符指针;2:节拍指针;3:关闭指针
+	cursorTips: '' as string, // 光标模式提示文字
+});
+
+watch(
+	() => metronomeData.cursorMode,
+	() => {
+		const img: HTMLElement = document.querySelector("#cursorImg-0")!;
+		if (img) {
+			switch (metronomeData.cursorMode) {
+				case 1:
+					img.classList.remove("lineHide");
+                    img.style.opacity = 'inherit'
+					metronomeData.cursorTips = '您已切换到指针跟随音符播放';
+                    img.style.opacity = 'inherit'
+					break;
+				case 2:
+					img.classList.add("lineHide");
+                    img.style.opacity = 'inherit'
+					metronomeData.cursorTips = '您已切换到指针跟随节拍播放';
+                    console.log('光标',img)
+					break;
+				case 3:
+                    img.style.opacity = '0'
+					metronomeData.cursorTips = '您已关闭指针显示';
+					console.log('隐藏光标')
+					break;
+				default:
+					break;
+			}
+			hideCursorTip()
+		}
+	}
+);
+class Metronome {
+	ctx = new ac();
+	playType = "tick";
+	source = null as any; // 创建音频源头
+	source1 = null as any;
+	source2 = null as any;
+
+	constructor(option?: IOptions) {}
+	init(times: any[]) {
+		this.calculation(times);
+		metronomeData.activeList = [];
+		return new Promise(async (resolve) => {
+			if (this.source1 && this.source2) return resolve(true);
+			this.source1 = await this.loadAudio1();
+			this.source2 = await this.loadAudio2();
+			resolve(true);
+		});
+	}
+
+	// 播放
+	sound = (currentTime: number) => {
+		// console.log("🚀 ~ currentTime", currentTime)
+		// currentTime = setCurrentTime(currentTime);
+		let index = -1;
+		let activeMetro = -1;
+		for (let i = 0; i < metronomeData.metroList.length; i++) {
+			const item = metronomeData.metroList[i];
+
+			if (currentTime >= item) {
+				// console.log(currentTime , item)
+				index = i;
+				activeMetro = item;
+			} else {
+				break;
+			}
+		}
+		if (index > -1 && metronomeData.activeIndex !== index) {
+			metronomeData.activeIndex = index;
+			// console.log("播放", metronomeData.activeIndex);
+			metronomeData.activeMetro = this.getStep(activeMetro);
+			// console.log("🚀 ~ 节拍metronomeData.activeMetro",metronomeData.activeMetro.measureNumberIndex, metronomeData.activeMetro.index, metronomeData.activeMetro)
+			this.playAudio();
+			metronomeData.isClick = false;
+			return;
+		}
+		metronomeData.isClick = false;
+	};
+	// 播放
+	playAudio = () => {
+		this.source = this.ctx.createBufferSource();
+		this.source.buffer = metronomeData.activeMetro?.index === 0 ? this.source1 : this.source2;
+		const gainNode = this.ctx.createGain();
+		gainNode.gain.value = metronomeData.disable ? 0 : 0.4;
+		this.source.connect(gainNode);
+		gainNode.connect(this.ctx.destination);
+		this.source.start(0); //立即播放
+	};
+
+	// 切换
+	selectPlay() {}
+
+	loadAudio1 = async () => {
+		const audioUrl = tick; // "/tick.wav";
+		const res = await fetch(audioUrl);
+		const arrayBuffer = await res.arrayBuffer(); // byte array字节数组
+		// console.log("🚀 ~ arrayBuffer", arrayBuffer)
+		const audioBuffer = await this.ctx.decodeAudioData(arrayBuffer, function (decodeData) {
+			return decodeData;
+		});
+		return audioBuffer;
+	};
+	loadAudio2 = async () => {
+		const audioUrl = tock; //"/tock.wav";
+		const res = await fetch(audioUrl);
+		const arrayBuffer = await res.arrayBuffer(); // byte array字节数组
+		const audioBuffer = await this.ctx.decodeAudioData(arrayBuffer, function (decodeData) {
+			return decodeData;
+		});
+		return audioBuffer;
+	};
+	getStep(time: number) {
+		for (let i = 0; i < metronomeData.metroMeasure.length; i++) {
+			const list = metronomeData.metroMeasure[i];
+			const item = list.find((n: any) => n.time === time);
+			if (item) {
+				// console.log('index',item)
+				return item;
+			}
+		}
+		return {};
+	}
+
+	// 计算 所有的拍子的时间
+	calculation(times: any[]) {
+		console.log("🚀 ~ times", times);
+		// 1.统计有多少小节
+		const measures: any[] = [];
+		let xmlNumber = -1;
+		for (let i = 0; i < times.length; i++) {
+			const note = times[i];
+			const measureNumberXML = note?.noteElement?.sourceMeasure?.measureNumber + 1 || -1;
+			// console.log("🚀 ~ note?.noteElement?.sourceMeasure", note?.noteElement?.sourceMeasure)
+			// console.log("🚀 ~ measureNumberXML", measureNumberXML, note)
+			// console.log("🚀 ~ measureNumberXML", note)
+			const measureListIndex = note?.noteElement?.sourceMeasure?.measureListIndex;
+			if (measureNumberXML > -1) {
+				if (measureNumberXML != xmlNumber) {
+					const m = {
+						measureNumberXML: measureNumberXML,
+						measureNumberIndex: measureListIndex,
+						numerator: note?.noteElement?.sourceMeasure?.ActiveTimeSignature?.numerator || 0,
+						start: note.measures[0].time,
+						end: note.measures[note.measures.length - 1].endtime,
+						time: note.measures[note.measures.length - 1].endtime - note.measures[0].time,
+						stave_x: note?.noteElement?.sourceMeasure?.verticalMeasureList?.[0]?.stave?.x || 0,
+						end_x: (note?.stave?.end_x || 0) || 0,
+						stepList: [] as number[],
+						svgs: [] as any[],
+					};
+					// 2.统计小节的拍数
+					// 3.统计小节的时长, 开始时间,结束时间
+					// console.log(measureNumberXML,note.measures, times.filter((n: any) => n?.noteElement?.sourceMeasure?.measureListIndex == measureListIndex))
+					if ([121].includes(state.subjectId)) {
+						const _measures = times.filter((n: any) => n?.noteElement?.sourceMeasure?.measureListIndex == measureListIndex);
+						note.measures = _measures;
+						m.start = note.measures[0].time;
+						m.end = note.measures[note.measures.length - 1].endtime;
+						m.time = note.measures[note.measures.length - 1].endtime - note.measures[0].time;
+						try {
+							const tickables = note.noteElement.sourceMeasure.verticalMeasureList.reduce((arr: any[], value: any) => {
+								arr.push(...value.vfVoices["1"].tickables);
+								return arr;
+							}, []);
+							const xList: any[] = [];
+							m.svgs = tickables
+								.map((n: any) => {
+									const x = n.getBoundingBox().x;
+									if (!xList.includes(x) && n.duration !== "w") {
+										xList.push(x);
+										n._start_x = x;
+										return n;
+									}
+								})
+								.filter(Boolean)
+								.sort((a: any, b: any) => a._start_x - b._start_x);
+							// console.log(measureNumberXML, m.svgs)
+						} catch (error) {
+							console.log(error);
+						}
+						m.stepList = calculateMutilpleMetroStep(note.measures, m);
+					} else {
+						m.stepList = calculateMetroStep(note.measures, m);
+					}
+					measures.push(m);
+					xmlNumber = measureNumberXML;
+				}
+			}
+		}
+		// console.log(measures, measures.length);
+
+		let metroList: number[] = [];
+		const metroMeasure: any[] = [];
+		// 4.按照拍数将时长平均分配
+		try {
+			for (let i = 0; i < measures.length; i++) {
+				const measure = measures[i];
+				const noteStep = measure.time / measure.numerator;
+				// console.log("🚀 ~ measure.measureNumberXML",measure.measureNumberXML, noteStep)
+				const WIDTH = [121].includes(state.subjectId) ? 95 : 100;
+				const widthStep = WIDTH / (measure.numerator + 1);
+				metroMeasure[i] = [] as number[];
+				// console.log('stepList', [...measure.stepList], measure.measureNumberXML)
+				for (let j = 0; j < measure.numerator; j++) {
+					const time = noteStep * j + measure.start;
+					metroList.push(time);
+					let left = "";
+					if (measure.stepList[j] === -1 || (measure.measureNumberXML === 1 && !measure.stepList[j])) {
+						continue
+					}
+					if (measure.stepList[j]) {
+						left = measure.stepList[j] + "px";
+					} else {
+						const preLeft = measure.stepList[j - 1];
+						left = !preLeft ? `${widthStep}%` : preLeft.toString().indexOf("%") > -1 ? `${preLeft} + ${widthStep}%` : `${preLeft}px + ${widthStep}%`;
+						measure.stepList[j] = left;
+					}
+					metroMeasure[i].push({
+						index: j,
+						time,
+						// left: (measure.stepList[j] ? measure.stepList[j] + 'px' : (j + 1) * widthStep + '%'),
+						left: left?.indexOf("%") > -1 ? `calc(${left})` : left,
+						measureNumberXML: measure.measureNumberXML,
+					});
+				}
+			}
+		} catch (error) {
+			console.log(error);
+		}
+		// console.log(metroList, metroMeasure);
+		// 5.得到所有的节拍时间
+		metronomeData.metroList = metroList;
+		metronomeData.metroMeasure = metroMeasure;
+	}
+}
+
+// 计算拍子的时值
+function calculateMetroStep(arr: any[], m: any): number[] {
+	const measureLength = arr.reduce((total: number, item: any) => {
+		total += item._noteLength;
+		return total;
+	}, 0);
+	const clap = measureLength / m.numerator;
+	if (arr.length === 1) {
+		const wholeNote = arr[0].svgElelent
+		if (wholeNote && !wholeNote.isRest()) {
+			const measure_bbox = wholeNote?.attrs?.el?.parentElement?.parentElement?.getBoundingClientRect?.() || { x: 0, right: 0 };
+			let bbox = wholeNote?.attrs?.el?.getBoundingClientRect?.() || { x: 0 };
+			let stepWidth = Math.abs(measure_bbox.right - bbox.x) / m.numerator
+			let stepList: number[] = [];
+			for(let i = 0; i < m.numerator; i++){
+				// 是第一个小节,并且不是全音符,是弱起
+				if (m.measureNumberXML === 1 && wholeNote.duration !== "w") {
+					stepList.push(i === 0 ? bbox.x - measure_bbox.x : -1)
+				} else {
+					stepList.push(bbox.x - measure_bbox.x + i * stepWidth)
+				}
+			}
+			// console.log("🚀 ~ stepList:", stepList, m.measureNumberXML)
+			return stepList;
+		}
+		try {
+			// 开头是休止符
+			if (m.measureNumberXML === 1 && wholeNote && wholeNote.isRest()) {
+				const measure_bbox = wholeNote?.attrs?.el?.parentElement?.parentElement?.getBoundingClientRect?.() || { x: 0, right: 0 };
+				let bbox = wholeNote?.attrs?.el?.getBoundingClientRect?.() || { x: 0 };
+				let stepWidth = Math.abs(measure_bbox.right - bbox.x) / m.numerator
+				let stepList: number[] = [];
+				for(let i = -1; i < m.numerator - 1; i++){
+					stepList.push(bbox.x - measure_bbox.x + i * stepWidth)
+				}
+				// console.log(wholeNote?.attrs?.el, m.measureNumberXML)
+				// console.log("🚀 ~ stepList:", stepList, m.measureNumberXML)
+				return stepList;
+			}
+		} catch (error) {
+			console.log("🚀 ~ error:", error)
+		}
+		
+		return [];
+	}
+	// console.log("🚀 ~ arr", [...arr],`小节总时值: ${measureLength}`, clap, m.measureNumberXML);
+	let totalLength = 0;
+	let notes: any[] = [];
+	let stepList: number[] = [];
+	for (let i = 0; i < arr.length; i++) {
+		const item = arr[i];
+		item.index = i;
+		const noteLength = item._noteLength;
+		totalLength += noteLength;
+		// 大于一拍
+		const exceedStep = totalLength / clap;
+		// console.log(`note`, item?.svgElelent?.attrs?.el,notes.length,{noteLength, exceedStep,clap}, m.measureNumberXML)
+		if (exceedStep >= 1) {
+			totalLength -= clap;
+			// 一拍
+			const measure_bbox = item?.svgElelent?.attrs?.el?.parentElement?.parentElement?.getBoundingClientRect?.() || { x: 0 };
+			if (notes.length > 0) {
+				let bbox = notes[0]?.svgElelent?.attrs?.el?.querySelector('.vf-note')?.getBoundingClientRect?.() || { x: 0 };
+				let x: any = bbox.x - measure_bbox.x;
+				if ((notes[0]._noteLength / clap) >= 1) {
+					const nextNote = arr[notes[0].index + 1]?.svgElelent?.attrs?.el?.getBoundingClientRect?.() || { x: measure_bbox.right } || { x: 0 };
+					const stepWidth = Math.abs(bbox.x - nextNote.x) / 2;
+					x = bbox.x - measure_bbox.x + stepWidth;
+					// console.log(`音符超一拍`, notes[0]?.svgElelent?.attrs?.el, arr[notes[0].index + 1]?.svgElelent?.attrs?.el, bbox.x - nextNote.x, stepWidth, m.measureNumberXML);
+				}
+				// console.log(`一拍`, notes[0]?.svgElelent?.attrs?.el, m.measureNumberXML, notes[0]._noteLength , clap, 'aa')
+				stepList.push(x);
+			} else {
+				let bbox = item?.svgElelent?.attrs?.el?.querySelector('.vf-note')?.getBoundingClientRect?.() || { x: 0 };
+				let x: any = bbox.x - measure_bbox.x
+				// console.log(`一拍`, item?.svgElelent?.attrs?.el, m.measureNumberXML)
+				stepList.push(x);
+			}
+			notes = [];
+			let bbox = item?.svgElelent?.attrs?.el?.querySelector('.vf-note')?.getBoundingClientRect?.() || { x: 0 };
+			let x: any = bbox.x - measure_bbox.x;
+			let stepWidth = 0;
+			if (exceedStep > 1) {
+				// 二拍以上
+				const nextNote = arr[i + 1]?.svgElelent?.attrs?.el?.querySelector('.vf-note')?.getBoundingClientRect?.() || { x: measure_bbox.right } || { x: 0 };
+				stepWidth = Math.abs(bbox.x - nextNote.x) / Math.ceil(exceedStep);
+				// console.log("二拍以上 ~ nextNote:",bbox.x , nextNote.x,stepWidth, item?.svgElelent?.attrs?.el,arr[i + 1]?.svgElelent?.attrs?.el, exceedStep);
+			}
+
+			for (let j = 1; j < exceedStep; j++) {
+				totalLength -= clap;
+				// console.log(`超一拍`,item?.svgElelent?.attrs?.el, m.measureNumberXML)
+				stepList.push(x + stepWidth * j);
+			}
+		}
+
+		//有时值就将音符加入
+		if (totalLength > Number.EPSILON && totalLength > 0) {
+			notes.push(item);
+		}
+	}
+	stepList = stepList.reduce((list: any[], n: number) => {
+		if (list.includes(n)) {
+			list.push(undefined as any);
+		} else {
+			list.push(n);
+		}
+		return list;
+	}, []);
+	// console.log("stepList", [...stepList], m.measureNumberXML);
+	// for (let i in stepList) {
+	// 	stepList[i] = stepList[i] / state.musicZoom
+	// }
+	console.log('🚀 ~ stepList:',stepList)
+	return stepList;
+}
+// 计算单声部多声轨的拍子的时值
+function calculateMutilpleMetroStep(arr: any[], m: any): number[] {
+	// console.log("🚀 ~ m:", [...m.svgs])
+	const step = m.time / m.numerator;
+	const measure_bbox = arr[0]?.svgElelent?.attrs?.el?.parentElement?.parentElement?.getBoundingClientRect?.() || { x: 0 };
+	if (arr.length === 1) {
+		const staveNote = m.svgs[0];
+		// 大于一拍
+		let bbox = staveNote?.attrs?.el?.getBoundingClientRect?.() || { x: 0 };
+		if (staveNote && !staveNote.isRest()) {
+			return [bbox.x - measure_bbox.x];
+		}
+		return [];
+	}
+	// console.log("🚀 ~ arr", arr, step, m.measureNumberXML);
+	let total = 0;
+	let notes: any[] = [];
+	let stepList: number[] = [];
+	for (let i = 0; i < arr.length; i++) {
+		const item = arr[i];
+		item._index = i;
+		const noteTime = item.endtime - item.time;
+		total += noteTime;
+		let svgEle = m.svgs[i]?.attrs?.el;
+		// 大于一拍
+		let bbox = svgEle?.getBoundingClientRect?.() || { x: 0 };
+		// console.log(m.measureNumberXML, svgEle, i)
+		if (noteTime > step) {
+			total -= step;
+			// console.log('超过一拍了', notes, m.measureNumberXML)
+			let x = bbox.x - measure_bbox.x;
+			if (notes.length > 0) {
+				svgEle = m.svgs[notes[0]._index]?.attrs?.el;
+				bbox = svgEle?.getBoundingClientRect?.() || { x: 0 };
+				x = bbox.x - measure_bbox.x;
+			}
+			stepList.push(x);
+			notes = [];
+		} else {
+			notes.push(item);
+		}
+		// console.log(notes)
+		if (Math.abs(total - step) < 0.001) {
+			let x = bbox.x - measure_bbox.x;
+			if (notes.length > 0) {
+				svgEle = m.svgs[notes[0]._index]?.attrs?.el;
+				bbox = svgEle?.getBoundingClientRect?.() || { x: 0 };
+				x = bbox.x - measure_bbox.x;
+			}
+			// console.log("一拍",svgEle,notes,m.svgs, m.measureNumberXML);
+			stepList.push(x);
+			total = 0;
+			notes = [];
+		}
+	}
+	stepList = stepList.reduce((list: any[], n: number) => {
+		if (list.includes(n)) {
+			list.push(undefined as any);
+		} else {
+			list.push(n);
+		}
+		return list;
+	}, []); //Array.from(new Set(stepList))
+	// console.log('stepList', stepList, m.measureNumberXML)
+	// for (let i in stepList) {
+	// 	stepList[i] = stepList[i] / state.musicZoom
+	// }
+	console.log('🚀 ~ stepList:',stepList)
+	return stepList;
+}
+
+// 延迟兼容处理
+function setCurrentTime(time: number) {
+	if (browserInfo.huawei || browserInfo.xiaomi) {
+		time += 0.125;
+	} else if (browserInfo.android) {
+		time += 0.11;
+	} else if (browserInfo.ios) {
+		time += 0.01;
+	}
+	return time;
+}
+
+// 自动隐藏光标提示
+function hideCursorTip() {
+	if (!tipsTimer) {
+		tipsTimer = setTimeout(() => {
+			metronomeData.cursorTips = ''
+			clearTimeout(tipsTimer)
+			tipsTimer = null
+		}, 2000);
+	} else {
+		clearTimeout(tipsTimer)
+		tipsTimer = setTimeout(() => {
+			metronomeData.cursorTips = ''
+			clearTimeout(tipsTimer)
+			tipsTimer = null
+		}, 2000);
+	}
+}
+
+export default Metronome;

+ 1 - 0
src/music-sheet/index.tsx

@@ -121,6 +121,7 @@ export default defineComponent({
           // 初始化cursor
           res.osmd.cursor = new Cursor({ ...detailState.times?.[0]?.cursorBox })
           // console.log("🚀 ~ res.osmd.cursor", res.osmd.cursor)
+          res.osmd.cursor.img.id = 'cursorImg-0'
           container.value?.appendChild(res.osmd.cursor.img)
         }
         detailState.renderType = 'cache'

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 1019 - 1104
src/pages/detail/helpers.ts


+ 2 - 0
src/pages/detail/index.tsx

@@ -47,6 +47,7 @@ import {
   appoggianceFormate,
 } from '/src/pages/detail/helpers'
 import qs from 'query-string'
+import { metronomeData } from '/src/helpers/metronome'
 
 let dakaTimer: any = null
 
@@ -597,6 +598,7 @@ export default defineComponent({
                 [styles.homework]: SettingState.sett.camera || mode === 'homework',
                 eyeProtection: SettingState.sett.eyeProtection,
                 ipad: browserInfo.iPad,
+                hideCursor: metronomeData.cursorMode === 3
               })}
               key={this.firstLib?.examSongId}
             >

+ 10 - 0
src/pages/detail/runtime.ts

@@ -34,6 +34,7 @@ import { useClientType, useOriginSearch } from '/src/subpages/colexiu/uses'
 import { evaluatPlayerStop } from '/src/subpages/colexiu/buttons/evaluating'
 import { unitTestData } from '/src/subpages/colexiu/unitTest'
 import { modelType } from '/src/subpages/colexiu/buttons'
+import { metronomeData } from '/src/helpers/metronome'
 
 export const event = new EventEmitter()
 
@@ -356,6 +357,10 @@ export const refreshIndex = (ctime?: number) => {
   const { osmd }: any = state
   if (osmd && (ctime || state.audiosInstance.audio)) {
     const currentTimeNum = ctime || (state.audiosInstance.audio as HTMLAudioElement).currentTime
+    try {
+      metronomeData?.metro?.sound(currentTimeNum);
+    } catch (error) {}
+
     const index = getIndex(detailState.times, currentTimeNum)
 
     state.activeIndex = index
@@ -405,6 +410,11 @@ export const refreshPlayer = async (ctime?: number) => {
     // state.playState = status
     const currentTimeNum = ctime || (state.audiosInstance.audio as HTMLAudioElement).currentTime
     // console.log('refreshPlayer', currentTimeNum)
+
+    try {
+      metronomeData?.metro?.sound(currentTimeNum);
+    } catch (error) {}
+
     const mintime = 0 //detailState.times[0].time
     if (currentTimeNum + 1 < mintime) {
       setCurrentTime(mintime)

+ 16 - 0
src/pages/detail/section-box/index.module.less

@@ -149,7 +149,23 @@
       font-size: 14Px;
     }
   }
+  .lineHide {
+    display: none !important;
+  }
+}
+.lineHide {
+  display: none !important;
 }
+
+.lineTEST {
+	position: absolute;
+	min-height: 80 * .7PX;
+	margin-top: -20 * .7PX;
+  background-color: var(--cursor-background-color);
+  width: calc(10PX * var(--osdm-zoom));
+  border-radius: calc(4PX * var(--osdm-zoom));
+}
+
 @keyframes flash {
   0% {opacity: 0;}
   50% {opacity: 1;}

+ 12 - 1
src/pages/detail/section-box/index.tsx

@@ -11,6 +11,7 @@ import classNames from 'classnames'
 import { modelType } from '/src/subpages/colexiu/buttons'
 import { restPromptData } from '/src/helpers/restPrompt'
 import { unitTestData } from '/src/subpages/colexiu/unitTest'
+import { metronomeData } from "/src/helpers/metronome";
 
 const sectionRef: Ref = ref(null)
 
@@ -187,6 +188,7 @@ export default defineComponent({
       }
     },
     sectionClick(evt: MouseEvent): void {
+      metronomeData.isClick = true;
       if (!state.sectionStatus) {
         if (state.mode !== 'contact' || runtime.evaluatingStatus) {
           return
@@ -237,6 +239,12 @@ export default defineComponent({
       return n.allRests && m >= 0 && m < n.multipleRestMeasures
     })
     const restNumber = restMeasure ? activeNumberXml - restMeasure.measureNumberXML + 1 : 0
+    const img: HTMLElement = document.querySelector('#cursorImg-0')!
+    if (restMeasure){
+      img && metronomeData.cursorMode === 2 && img.classList.remove('lineHide')
+    } else {
+      img && metronomeData.cursorMode === 2 && img.classList.add('lineHide')
+    }
     return (
       <div class={styles.section} ref={sectionRef}>
         {/* 为每个音符添加一个遮罩方便点击 */}
@@ -267,6 +275,7 @@ export default defineComponent({
 
           let boundingBox = null
           // let measureBg = false
+          const activeNumberIndex = (item?.noteElement?.sourceMeasure?.measureNumber + 1) || -2;
           if (item.si === 0) {
             boundingBox = this.getBoundingBoxByNote(item.noteElement)
           }
@@ -294,7 +303,9 @@ export default defineComponent({
                       : '',
                   }}
                   onClick={state.sectionStatus ? this.sectionClick : undefined}
-                ></div>
+                >
+                  {metronomeData.cursorMode === 2 && activeNumberIndex === metronomeData.activeMetro?.measureNumberXML && <div class={styles.lineTEST} style={{ left: metronomeData.activeMetro.left }}></div>}
+                </div>
               )}
               <div
                 data-id={item.id}

+ 17 - 1
src/pages/detail/state.ts

@@ -5,6 +5,12 @@ import { IDifficulty } from './setting-state'
 type IRenderType = 'native' | 'cache'
 type IMode = 'homework' | 'contact' | 'evaluating'
 
+export enum GradualVersion {
+  BASE,
+  ENSEMBLE
+}
+
+
 type URLSetting = {
   mode?: 'EVALUATING',
   resets?: ['SPEED'],
@@ -71,9 +77,19 @@ const state = reactive({
   gradual: [] as GradualNote[],
   /** 渐变速度版本 */
   /** 渐变时间信息 */
-  gradualTimes: null as GradualTimes
+  gradualTimes: null as GradualTimes,
+  /** 单声部多声轨 */
+  multitrack: 0,
+  /** 渐变速度版本 */
+  gradualVersion: GradualVersion.BASE as GradualVersion,
 })
 
+export const isRhythmicExercises = () => {
+  const examSongName = state?.activeDetail?.examSongName || ''
+  // console.log("🚀 ~ examSongName:", examSongName,state)
+  return examSongName.indexOf('节奏练习') > -1
+}
+
 export default state
 export type GradualTimes = null | {
   [key: string]: string

+ 23 - 0
src/subpages/colexiu/buttons/icons/cursor-icon-1.svg

@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="29px" height="29px" viewBox="0 0 29 29" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>音符指针</title>
+    <defs>
+        <linearGradient x1="-7.37257477e-16%" y1="-4.51028104e-15%" x2="100%" y2="100%" id="linearGradient-1">
+            <stop stop-color="#FF9C63" offset="0%"></stop>
+            <stop stop-color="#FF7144" offset="100%"></stop>
+        </linearGradient>
+    </defs>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="画板" transform="translate(-20.000000, -27.000000)">
+            <g id="编组-5备份-3" transform="translate(15.000000, 27.000000)">
+                <g id="音符指针" transform="translate(5.000000, 0.000000)">
+                    <circle id="椭圆形" fill="url(#linearGradient-1)" cx="14.5" cy="14.5" r="14.5"></circle>
+                    <g id="编组" transform="translate(9.500000, 4.000000)" fill="#FFFFFF">
+                        <rect id="矩形" opacity="0.347728911" x="2" y="0" width="6" height="22" rx="1"></rect>
+                        <path d="M8.10330055,2.25120225 C7.87639869,1.98341174 7.48794121,1.92145821 7.18899698,2.10538362 C6.94867021,2.20392714 6.7916895,2.43789665 6.79162391,2.69764225 L6.79162391,11.2173207 C6.04522781,10.5782918 5.09452643,10.2279237 4.11194732,10.2297622 C1.84173775,10.2297622 0,12.0590605 0,14.3157939 C0,16.5725274 1.84035559,18.4018256 4.11194732,18.4018256 C6.38353905,18.4018256 8.22389464,16.5725274 8.22389464,14.3157939 C8.22389464,14.1990008 8.21871152,14.0835898 8.20903634,13.9702521 C8.21883164,13.925531 8.22381277,13.8798899 8.22389464,13.8341087 L8.22389464,5.18052517 L9.49407213,6.7244221 C9.7596145,7.0207141 10.2815366,6.88938529 10.5833148,6.64410705 C10.7932609,6.47346778 10.9816503,6.18864132 10.997479,6.00232982 C11.0133077,5.81601831 10.954132,5.63109339 10.8330807,5.48858331 L8.10330055,2.25120225 Z" id="路径" fill-rule="nonzero"></path>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 24 - 0
src/subpages/colexiu/buttons/icons/cursor-icon-2.svg

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="29px" height="29px" viewBox="0 0 29 29" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>节拍指针</title>
+    <defs>
+        <linearGradient x1="-7.37257477e-16%" y1="-4.51028104e-15%" x2="100%" y2="100%" id="linearGradient-1">
+            <stop stop-color="#FF9C63" offset="0%"></stop>
+            <stop stop-color="#FF7144" offset="100%"></stop>
+        </linearGradient>
+    </defs>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="画板" transform="translate(-73.000000, -27.000000)">
+            <g id="编组-5备份-8" transform="translate(68.000000, 27.000000)">
+                <g id="节拍指针" transform="translate(5.000000, 0.000000)">
+                    <g id="编组-2">
+                        <circle id="椭圆形" fill="url(#linearGradient-1)" cx="14.5" cy="14.5" r="14.5"></circle>
+                        <rect id="矩形" fill="#FFFFFF" opacity="0.347728911" x="11.2951222" y="4" width="5.81088201" height="21.3065674" rx="1"></rect>
+                        <path d="M15.6450859,5.8 L13.161022,5.8 C12.4487588,5.8 11.7955891,6.35139505 11.6066345,7.10706476 L7.5646309,17.8957605 L7.55192599,17.9974532 C7.36158798,18.6570202 7.24878302,19.3437043 7.21172159,20.0069002 C7.14144899,21.16499 7.38706812,22.030375 7.95070818,22.5965135 C8.37666016,22.9988072 8.90712493,23.2001243 9.55495798,23.2001243 L19.2170982,23.2001243 C19.8639973,23.2017406 20.3951137,23.0004652 20.8182769,22.6028117 C21.3947139,22.0935076 21.6332069,21.2614132 21.5963084,20.0452008 L21.5648027,19.6419562 C21.5240858,19.2363778 21.4555125,18.8236085 21.3570795,18.4019405 L21.2510124,17.988 L19.9497116,14.6532837 C19.8019891,14.3282274 19.4301651,14.1525086 19.0975612,14.2428626 C18.7439808,14.3841921 18.5528207,14.7448334 18.652166,15.0718013 L19.9497116,18.3578559 L20.0651769,18.8882582 L20.1070124,19.107 L20.0367725,19.0548306 C19.0543245,18.3596771 17.798317,17.8493584 16.4277908,17.6005981 L16.3650124,17.59 L21.5034123,8.27827317 C21.6952043,7.96299563 21.5593838,7.57889323 21.2185186,7.39607102 L21.118299,7.35202608 C20.8136898,7.24617381 20.4804683,7.37409641 20.3060903,7.66105506 L14.8820124,17.408 L14.4520041,17.3815515 C14.4298899,17.3809723 14.408173,17.3807135 14.3851768,17.3807135 L14.0431062,17.3858125 C11.9978793,17.4468359 10.0898219,18.0526267 8.7304013,19.0432241 L8.69701235,19.068 L8.78257671,18.6584442 C8.80567826,18.5527879 8.83205182,18.4351069 8.86221148,18.3022202 L12.9040107,7.48289762 L12.9323221,7.42128138 C13.0143084,7.21599958 13.1275263,7.099892 13.1899627,7.099892 L15.6093317,7.099892 C15.6568291,7.099892 15.7352584,7.18260646 15.8226875,7.37770929 L16.8659387,9.82241207 C17.0120244,10.1433299 17.3259492,10.3692228 17.7193463,10.2677839 C18.0729267,10.1264544 18.240984,9.76897881 18.1416386,9.442011 L17.1910361,7.08103741 C17.0085744,6.35125149 16.3555748,5.8 15.6450859,5.8 Z" id="路径" fill="#FFF7F7" fill-rule="nonzero"></path>
+                    </g>
+                    <g id="编组" transform="translate(11.295122, 4.000000)"></g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 24 - 0
src/subpages/colexiu/buttons/icons/cursor-icon-3.svg

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="29px" height="29px" viewBox="0 0 29 29" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>关闭指针</title>
+    <defs>
+        <linearGradient x1="-7.37257477e-16%" y1="-4.51028104e-15%" x2="100%" y2="100%" id="linearGradient-1">
+            <stop stop-color="#FF9C63" offset="0%"></stop>
+            <stop stop-color="#FF7144" offset="100%"></stop>
+        </linearGradient>
+    </defs>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="画板" transform="translate(-129.000000, -27.000000)">
+            <g id="编组-5备份-5" transform="translate(124.000000, 27.000000)">
+                <g id="关闭指针" transform="translate(5.000000, 0.000000)">
+                    <circle id="椭圆形" fill="url(#linearGradient-1)" cx="14.5" cy="14.5" r="14.5"></circle>
+                    <g id="编组" transform="translate(8.130908, 4.000000)">
+                        <rect id="矩形" fill="#FFFFFF" opacity="0.347728911" x="3.36909222" y="0" width="6" height="22" rx="1"></rect>
+                        <path d="M9.59209222,12.457 L9.59298686,13.8341087 C9.59290499,13.8798899 9.58792386,13.925531 9.57812856,13.9702521 C9.58780373,14.0835898 9.59298686,14.1990008 9.59298686,14.3157939 C9.59298686,16.5725274 7.75263127,18.4018256 5.48103954,18.4018256 C4.81443695,18.4018256 4.1849698,18.2442975 3.62824694,17.9646669 L9.59209222,12.457 Z M5.48103954,10.2297622 C6.28077626,10.2282658 7.05939528,10.4600918 7.72392612,10.8909773 L1.87876267,16.2879862 C1.55393344,15.7032676 1.36909222,15.0309961 1.36909222,14.3157939 C1.36909222,12.0590605 3.21082996,10.2297622 5.48103954,10.2297622 Z M8.55808919,2.10538362 C8.85703342,1.92145821 9.24549091,1.98341174 9.47239277,2.25120225 L12.2021729,5.48858331 C12.3232242,5.63109339 12.3823999,5.81601831 12.3665712,6.00232982 C12.3507426,6.18864132 12.1623531,6.47346778 11.952407,6.64410705 C11.6506289,6.88938529 11.1287067,7.0207141 10.8631643,6.7244221 L9.59298686,5.18052517 L9.59209222,9.166 L8.16009222,10.488 L8.16071612,2.69764225 C8.16077351,2.47036485 8.28096905,2.26282227 8.47201719,2.14847137 Z" id="形状结合" fill="#FFFFFF" fill-rule="nonzero"></path>
+                        <line x1="0.531038992" y1="5.72996127" x2="14.3380528" y2="18.4748971" id="直线备份-3" stroke="#FFFFFF" stroke-width="1.5" stroke-linecap="round" transform="translate(7.434546, 12.102429) scale(-1, 1) translate(-7.434546, -12.102429) "></line>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 25 - 0
src/subpages/colexiu/buttons/index.module.less

@@ -65,6 +65,7 @@
   border: none;
   background: none;
   font-size: 0;
+  position: relative;
   &.hasText {
     color: #777;
     font-size: 5px;
@@ -92,6 +93,30 @@
       margin-bottom: 1px;
     }
   }
+  .botton-tips {
+    position: absolute;
+    left: -20PX;
+    bottom: -24PX;
+    background: rgba(33, 33, 33, 0.56);
+    font-size: 14PX;
+    font-weight: 500;
+    color: #FFFFFF;
+    padding: 3PX 10PX;
+    word-break: keep-all;
+    z-index: 1;
+    border-radius: 20PX;
+    &::before {
+      content: "";
+      position: absolute;
+      left: 48PX;
+      top: -8PX;
+      width: 0;
+      height: 0;
+      border-bottom: 8PX solid rgba(33, 33, 33, 0.56);
+      border-right: 8PX solid transparent;
+      border-left: 8PX solid transparent;      
+    }
+  }    
 }
 .evaluatBtn {
   width: 54px;

+ 25 - 1
src/subpages/colexiu/buttons/index.tsx

@@ -32,6 +32,10 @@ import styles from './index.module.less'
 import { sendBackRecordTotalTime } from '../App'
 import { unitTestData } from '../unitTest'
 import { toggleMusicSheet } from '../plugins/toggleMusicSheet'
+import classNames from 'classnames'
+import Metronome, { metronomeData } from '/src/helpers/metronome'
+import { getAllNodes } from '/src/pages/detail/helpers'
+
 export const confirmShow: Ref<boolean> = ref(false)
 
 /**评测开始按钮状态 */
@@ -125,6 +129,12 @@ export default defineComponent({
   },
   emits: ['setMusicScoreType'],
   setup(props, { emit }) {
+    try {
+      detailState.times = getAllNodes(runtime.osmd)
+      // console.log('state.times', detailState.times)
+    } catch (error) {
+      console.log(error)
+    }
     const search = useOriginSearch()
     const speedRef = ref()
     const [show] = useMenu()
@@ -221,6 +231,20 @@ export default defineComponent({
             </Transition>
           </div>
           <div class={[styles.moreButton]} style={{ opacity: detailState.initRendered ? 1 : 0 }}>
+            <>
+              <Button
+                class={classNames(styles.button, styles.hasText)}
+                onClick={() => {
+                  // 切换光标模式
+                  const mode = metronomeData.cursorMode === 3 ? 1 : metronomeData.cursorMode + 1
+                  metronomeData.cursorMode = mode
+                }}
+              >
+                <ButtonIcon key="modelType" name={metronomeData.cursorMode === 1 ? 'cursor-icon-1' : metronomeData.cursorMode === 2 ? 'cursor-icon-2' : metronomeData.cursorMode === 3 ? 'cursor-icon-3' : ''} />
+                <span>{metronomeData.cursorMode === 1 ? '音符指针' : metronomeData.cursorMode === 2 ? '节拍指针' : metronomeData.cursorMode === 3 ? '关闭指针' : ''}</span>
+                {metronomeData.cursorTips && <div class={classNames(styles['botton-tips'])}>{metronomeData.cursorTips}</div>}
+              </Button>            
+            </>            
             {!search?.modelType && modelType.value !== 'init' && !detailState.frozenMode && (
               <Button
                 data-step="m0"
@@ -473,7 +497,7 @@ export default defineComponent({
                   }
                 >
                   <ButtonIcon name="setting" />
-                  <span>设置</span>
+                  <span>设置1</span>
                 </Button>
                 <Popups
                   ref={settingPopup}

+ 8 - 0
src/subpages/colexiu/index.tsx

@@ -48,6 +48,7 @@ import { renderError } from './App'
 import { musicInfo } from './state'
 import ToggleMusicSheet from './plugins/toggleMusicSheet'
 import request from '/src/helpers/request'
+import Metronome, { metronomeData } from '/src/helpers/metronome'
 
 // json化曲谱的note信息和svg
 export const musicJSON = reactive({
@@ -250,6 +251,13 @@ export default defineComponent({
       if (!runtime.durationNum) {
         runtime.durationNum = songEndTime
       }
+      // 初始化metronome
+      try {
+        metronomeData.metro = new Metronome({speed: detailState.activeSpeed})
+        metronomeData.metro.init(detailState.times)
+      } catch (error) {
+        // 
+      }
       // const freeRate = await useConfigMusicSheetFreeRate()
       // detailState.freeRate = freeRate.value
       useCamera()

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác