|
@@ -1,20 +1,23 @@
|
|
|
-import { Skeleton, Switch } from "vant";
|
|
|
-import { defineComponent, onBeforeMount, onBeforeUnmount, onMounted, reactive, Transition, watch, ref } from "vue";
|
|
|
-import state, { isRhythmicExercises, getMusicDetail, EnumMusicRenderType } from "../../state";
|
|
|
-import MusicScore from "../../view/music-score";
|
|
|
+import { Skeleton, Switch, Popup } from "vant";
|
|
|
+import { defineComponent, onBeforeMount, onBeforeUnmount, onMounted, onUnmounted, reactive, Transition, watch, ref, computed, nextTick } from "vue";
|
|
|
+import state from "../../state";
|
|
|
import styles from "./index.module.less";
|
|
|
import { getQuery } from "/src/utils/queryString";
|
|
|
import backIcon from "./image/back_icon.png";
|
|
|
import videoIcon from "./image/video_icon.png";
|
|
|
import { api_musicPracticeRecordDetail } from "../api";
|
|
|
import { buildFreqTable, frequencyToNote, calculateNoteRange, getAbsoluteIndex } from "/src/utils/frequency"
|
|
|
-
|
|
|
+import "plyr/dist/plyr.css";
|
|
|
+import Plyr from "plyr";
|
|
|
+import { browser } from "/src/utils";
|
|
|
+import videobg from "./image/videobg.png";
|
|
|
+import { api_back } from "/src/helpers/communication";
|
|
|
|
|
|
export default defineComponent({
|
|
|
name: "music-list",
|
|
|
setup() {
|
|
|
const query: any = getQuery();
|
|
|
-
|
|
|
+ const browserInfo = browser();
|
|
|
// 设定可视化参数
|
|
|
interface VisualConfig {
|
|
|
msPerPixel: number; // 1 像素代表多少毫秒
|
|
@@ -29,10 +32,22 @@ export default defineComponent({
|
|
|
startTime: number;
|
|
|
endTime: number;
|
|
|
}
|
|
|
+ const level: any = {
|
|
|
+ BEGINNER: "入门级",
|
|
|
+ ADVANCED: "进阶级",
|
|
|
+ PERFORMER: "大师级",
|
|
|
+ };
|
|
|
const scoreData = reactive({
|
|
|
videoFilePath: "", // 回放视频路径
|
|
|
+ intonation: 0, // 音准
|
|
|
+ cadence: 0, // 节奏
|
|
|
+ integrity: 0, // 完整度
|
|
|
+ score: 0,
|
|
|
+ speed: 0,
|
|
|
+ musicSheetName: '',
|
|
|
+ evaluationStandard: '' as any, // evaluationStandard:("评测标准 节奏 AMPLITUDE 音准 FREQUENCY 分贝 DECIBELS")
|
|
|
+ heardLevel: "",
|
|
|
openRealPitch: false,
|
|
|
- xmlInfo: {} as any,
|
|
|
xmlNotes: [],
|
|
|
xmlMaxFrequency: 0, // 实际的最高音(xml和用户演奏的取高值)
|
|
|
xmlMinFrequency: 0, // 实际的最低音(xml和用户演奏的取低值)
|
|
@@ -43,6 +58,12 @@ export default defineComponent({
|
|
|
baseNoteList: [] as any, // xml标准音符
|
|
|
});
|
|
|
|
|
|
+ const shareData = reactive({
|
|
|
+ show: false,
|
|
|
+ isInitPlyr: false,
|
|
|
+ _plrl: null as any,
|
|
|
+ });
|
|
|
+
|
|
|
// 根据音符构造小节信息
|
|
|
const createMeasureList = (notes: Array<any>) => {
|
|
|
let measureList = [];
|
|
@@ -59,7 +80,8 @@ export default defineComponent({
|
|
|
let preMeasureTimeSignature = null;
|
|
|
for (let index = 0; index < notes.length; index++) {
|
|
|
const note = notes[index];
|
|
|
- const { measureRenderIndex, frequencyList, frequency, dontEvaluating, duration, timeSignature = {numerator: 2, denominator: 4} } = note;
|
|
|
+ const timeSignature = note.timeSignature ? JSON.parse(note.timeSignature) : {numerator: 2, denominator: 4};
|
|
|
+ const { measureRenderIndex, frequencyList, frequency, dontEvaluating, duration, measureDuration } = note;
|
|
|
startTime = endTime;
|
|
|
endTime = endTime + duration / 1000
|
|
|
xmlMaxFrequency = Math.max(...frequencyList,xmlMaxFrequency)
|
|
@@ -82,7 +104,7 @@ export default defineComponent({
|
|
|
timeSignature,
|
|
|
timeSignatureDiff: true,
|
|
|
noteList,
|
|
|
- measureDuration: 2
|
|
|
+ measureDuration: parseFloat(measureDuration)
|
|
|
})
|
|
|
} else {
|
|
|
if (preMeasureIndex === measureRenderIndex) {
|
|
@@ -113,7 +135,7 @@ export default defineComponent({
|
|
|
timeSignature: timeSignature,
|
|
|
timeSignatureDiff: currentTimeSignature !== preMeasureTimeSignature ? true: false,
|
|
|
noteList,
|
|
|
- measureDuration: 2
|
|
|
+ measureDuration: parseFloat(measureDuration)
|
|
|
})
|
|
|
preMeasureTimeSignature = currentTimeSignature
|
|
|
}
|
|
@@ -145,8 +167,7 @@ export default defineComponent({
|
|
|
let resultData = {} as any;
|
|
|
try {
|
|
|
resultData = eval('(' + res?.data?.scoreData + ')');
|
|
|
- scoreData.xmlInfo = eval('(' + JSON.parse(res.data.scoreData)?.musicalNotesPlayStats?.musicXmlBasicInfo + ')') || {}
|
|
|
- scoreData.xmlNotes = scoreData.xmlInfo.musicXmlInfos || []
|
|
|
+ scoreData.xmlNotes = JSON.parse(res.data.scoreData)?.musicalNotesPlayStats?.musicXmlBasicInfo || []
|
|
|
scoreData.useMinFrequency = resultData?.musicalNotesPlayStats?.timeChunkAnalysisList?.find((item: any) => item.baseFrequency > 0).baseFrequency
|
|
|
resultData?.musicalNotesPlayStats?.timeChunkAnalysisList.forEach((item: any) => {
|
|
|
const { baseFrequency, startTime, endTime } = item
|
|
@@ -160,37 +181,45 @@ export default defineComponent({
|
|
|
})
|
|
|
}
|
|
|
})
|
|
|
+ scoreData.heardLevel = res.data?.heardLevel;
|
|
|
+ scoreData.cadence = res.data?.cadence;
|
|
|
+ scoreData.integrity = res.data?.integrity;
|
|
|
+ scoreData.intonation = res.data?.intonation;
|
|
|
+ scoreData.score = res.data?.score;
|
|
|
+ scoreData.speed = res.data?.speed;
|
|
|
+ scoreData.videoFilePath = res.data?.videoFilePath || res.data?.recordFilePath;
|
|
|
+ scoreData.musicSheetName = res.data?.musicSheetName;
|
|
|
+ scoreData.evaluationStandard = res.data?.evaluationStandard;
|
|
|
createMeasureList(scoreData.xmlNotes)
|
|
|
console.log(11111,resultData,scoreData)
|
|
|
drawTable()
|
|
|
} catch (error) {
|
|
|
console.error("解析评测结果:", error);
|
|
|
}
|
|
|
- scoreData.videoFilePath = res.data?.videoFilePath || res.data?.recordFilePath;
|
|
|
}
|
|
|
|
|
|
- // 创建网格
|
|
|
- function createGrid(container: HTMLElement, config: VisualConfig) {
|
|
|
+ // 创建网格
|
|
|
+ function createGrid(container: HTMLElement, config: VisualConfig) {
|
|
|
const { topNoteIndex, bottomNoteIndex, semitoneHeight, msPerPixel } = config;
|
|
|
const numNotes = topNoteIndex - bottomNoteIndex + 1;
|
|
|
const containerHeight = numNotes * semitoneHeight;
|
|
|
-
|
|
|
+
|
|
|
container.style.height = `${containerHeight}px`;
|
|
|
-
|
|
|
+
|
|
|
for (let i = bottomNoteIndex; i <= topNoteIndex; i++) {
|
|
|
- const line = document.createElement("div");
|
|
|
- line.className = "note-line";
|
|
|
- line.style.top = `${(topNoteIndex - i) * semitoneHeight}px`;
|
|
|
- container.appendChild(line);
|
|
|
+ const line = document.createElement("div");
|
|
|
+ line.className = "note-line";
|
|
|
+ line.style.top = `${(topNoteIndex - i) * semitoneHeight}px`;
|
|
|
+ container.appendChild(line);
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
// 画时间刻度
|
|
|
for (let t = 0; t <= 5000; t += 1000) {
|
|
|
- const timeMarker = document.createElement("div");
|
|
|
- timeMarker.className = "time-marker";
|
|
|
- timeMarker.style.left = `${t / msPerPixel}px`;
|
|
|
- timeMarker.innerText = `${t / 1000}s`;
|
|
|
- // container.appendChild(timeMarker);
|
|
|
+ const timeMarker = document.createElement("div");
|
|
|
+ timeMarker.className = "time-marker";
|
|
|
+ timeMarker.style.left = `${t / msPerPixel}px`;
|
|
|
+ timeMarker.innerText = `${t / 1000}s`;
|
|
|
+ // container.appendChild(timeMarker);
|
|
|
}
|
|
|
|
|
|
// 绘制小节序号和拍号
|
|
@@ -228,64 +257,87 @@ export default defineComponent({
|
|
|
container.appendChild(measureDom);
|
|
|
container.appendChild(lineDom);
|
|
|
|
|
|
- }
|
|
|
-
|
|
|
- // 渲染音符
|
|
|
- function renderNotes(container: HTMLElement, noteEvents: NoteEvent[], config: VisualConfig, className: string) {
|
|
|
+ }
|
|
|
+
|
|
|
+ // 渲染音符
|
|
|
+ function renderNotes(container: HTMLElement, noteEvents: NoteEvent[], config: VisualConfig, className: string) {
|
|
|
noteEvents.forEach((event) => {
|
|
|
- const { note, octave } = frequencyToNote(event.freq, config.baseA4);
|
|
|
- const absIndex = getAbsoluteIndex(note, octave);
|
|
|
-
|
|
|
- if (absIndex < config.bottomNoteIndex || absIndex > config.topNoteIndex) return;
|
|
|
-
|
|
|
- const noteDiv = document.createElement("div");
|
|
|
- noteDiv.className = className;
|
|
|
- if (className === 'note') {
|
|
|
+ const { note, octave } = frequencyToNote(event.freq, config.baseA4);
|
|
|
+ const absIndex = getAbsoluteIndex(note, octave);
|
|
|
+
|
|
|
+ if (absIndex < config.bottomNoteIndex || absIndex > config.topNoteIndex) return;
|
|
|
+
|
|
|
+ const noteDiv = document.createElement("div");
|
|
|
+ noteDiv.className = className;
|
|
|
+ if (className === 'note') {
|
|
|
noteDiv.innerHTML = `<span>${note}${octave}</span>`
|
|
|
- }
|
|
|
- // noteDiv.innerText = `${note}${octave}`;
|
|
|
-
|
|
|
- // 计算位置
|
|
|
- const xStart = event.startTime / config.msPerPixel;
|
|
|
- const xEnd = event.endTime / config.msPerPixel;
|
|
|
- noteDiv.style.left = `${xStart}px`;
|
|
|
- noteDiv.style.width = `${xEnd - xStart}px`;
|
|
|
- noteDiv.style.top = `${(config.topNoteIndex - absIndex) * config.semitoneHeight}px`;
|
|
|
-
|
|
|
- container.appendChild(noteDiv);
|
|
|
+ }
|
|
|
+ // noteDiv.innerText = `${note}${octave}`;
|
|
|
+
|
|
|
+ // 计算位置
|
|
|
+ const xStart = event.startTime / config.msPerPixel;
|
|
|
+ const xEnd = event.endTime / config.msPerPixel;
|
|
|
+ noteDiv.style.left = `${xStart}px`;
|
|
|
+ noteDiv.style.width = `${xEnd - xStart}px`;
|
|
|
+ noteDiv.style.top = `${(config.topNoteIndex - absIndex) * config.semitoneHeight}px`;
|
|
|
+
|
|
|
+ container.appendChild(noteDiv);
|
|
|
});
|
|
|
- }
|
|
|
+ }
|
|
|
|
|
|
- // 主函数
|
|
|
- const drawTable = () => {
|
|
|
+ // 主函数
|
|
|
+ const drawTable = () => {
|
|
|
const container = document.getElementById("visualizer") as HTMLElement;
|
|
|
if (!container) return;
|
|
|
const realFreqRange = calculateNoteRange(scoreData.xmlMinFrequency, scoreData.xmlMaxFrequency)
|
|
|
const config: VisualConfig = {
|
|
|
- msPerPixel: 10,
|
|
|
- semitoneHeight: 30,
|
|
|
- baseA4: 442,
|
|
|
- topNoteIndex: realFreqRange.topNoteIndex + 3,
|
|
|
- bottomNoteIndex: realFreqRange.bottomNoteIndex - 3,
|
|
|
+ msPerPixel: 10,
|
|
|
+ semitoneHeight: 30,
|
|
|
+ baseA4: 442,
|
|
|
+ topNoteIndex: realFreqRange.topNoteIndex + 3,
|
|
|
+ bottomNoteIndex: realFreqRange.bottomNoteIndex - 3,
|
|
|
};
|
|
|
-
|
|
|
+
|
|
|
container.innerHTML = "";
|
|
|
createGrid(container, config);
|
|
|
-
|
|
|
+
|
|
|
const noteEvents: NoteEvent[] = [
|
|
|
- { freq: 261.63, startTime: 0, endTime: 500 },
|
|
|
- { freq: 293.66, startTime: 500, endTime: 1000 },
|
|
|
- { freq: 468.0, startTime: 1200, endTime: 1800 },
|
|
|
- { freq: 523.25, startTime: 1800, endTime: 2500 },
|
|
|
+ { freq: 261.63, startTime: 0, endTime: 500 },
|
|
|
+ { freq: 293.66, startTime: 500, endTime: 1000 },
|
|
|
+ { freq: 468.0, startTime: 1200, endTime: 1800 },
|
|
|
+ { freq: 523.25, startTime: 1800, endTime: 2500 },
|
|
|
];
|
|
|
// 绘制标准音符
|
|
|
renderNotes(container, scoreData.baseNoteList, config, "note");
|
|
|
// 绘制实际用户演奏音符
|
|
|
renderNotes(container, scoreData.timeChunkAnalysisList, config, "realNote");
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
+ }
|
|
|
|
|
|
+ /** 返回 */
|
|
|
+ const handleBack = () => {
|
|
|
+ api_back();
|
|
|
+ };
|
|
|
+ // 资源类型
|
|
|
+ const mediaType = computed((): "audio" | "video" => {
|
|
|
+ const subfix = (scoreData.videoFilePath || "").split(".").pop();
|
|
|
+ if (subfix === "wav" || subfix === "mp3" || subfix === "m4a") {
|
|
|
+ return "audio";
|
|
|
+ }
|
|
|
+ return "video";
|
|
|
+ });
|
|
|
+ const isPad = navigator?.userAgent?.includes("UAWEIVRD-W09") || browserInfo?.iPad || browserInfo.isTablet;
|
|
|
+ const openAudioAndVideo = () => {
|
|
|
+ shareData.show = true;
|
|
|
+ if (shareData.isInitPlyr) return;
|
|
|
+ nextTick(() => {
|
|
|
+ const id = mediaType.value === "audio" ? "#audioSrc" : "#videoSrc";
|
|
|
+ shareData._plrl = new Plyr(id, {
|
|
|
+ controls: ["play-large", "play", "progress", "current-time", "duration"],
|
|
|
+ fullscreen: { enabled: false },
|
|
|
+ });
|
|
|
+ shareData.isInitPlyr = true;
|
|
|
+ });
|
|
|
+ };
|
|
|
|
|
|
onBeforeMount(() => {
|
|
|
|
|
@@ -308,22 +360,24 @@ export default defineComponent({
|
|
|
|
|
|
|
|
|
});
|
|
|
-
|
|
|
+ onUnmounted(()=>{
|
|
|
+ shareData._plrl?.destroy()
|
|
|
+ })
|
|
|
|
|
|
return () => (
|
|
|
<div class={styles.reportDetail}>
|
|
|
<div class={styles.reportHead}>
|
|
|
- <img class={styles.backIcon} src={backIcon} />
|
|
|
+ <img class={styles.backIcon} src={backIcon} onClick={handleBack} />
|
|
|
<div class={styles.content}>
|
|
|
<div class={styles.title}>
|
|
|
- <span class={styles.titleName}>天空之城</span>
|
|
|
- <span class={styles.titleLevel}>入门级|速度90</span>
|
|
|
+ <span class={styles.titleName}>{scoreData.musicSheetName}</span>
|
|
|
+ <span class={styles.titleLevel}>{level[scoreData.heardLevel]}|速度{Math.floor(scoreData.speed)}</span>
|
|
|
</div>
|
|
|
<div class={styles.score}>
|
|
|
- <span class={styles.total}>总分: 78</span>
|
|
|
- <span>|音准: 67</span>
|
|
|
- <span>|节奏: 87</span>
|
|
|
- <span>|完整度: 87</span>
|
|
|
+ <span class={styles.total}>总分: {scoreData.score}</span>
|
|
|
+ <span>|音准: {scoreData.intonation}</span>
|
|
|
+ <span>|节奏: {scoreData.cadence}</span>
|
|
|
+ <span>|完整度: {scoreData.integrity}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class={styles.right}>
|
|
@@ -340,8 +394,10 @@ export default defineComponent({
|
|
|
onChange={ async (value) => {
|
|
|
//
|
|
|
}}
|
|
|
- ></Switch>
|
|
|
- <img class={styles.videoIcon} src={videoIcon} />
|
|
|
+ ></Switch>
|
|
|
+ {
|
|
|
+ mediaType.value === 'video' && <img class={styles.videoIcon} src={videoIcon} onClick={openAudioAndVideo} />
|
|
|
+ }
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
@@ -385,8 +441,26 @@ export default defineComponent({
|
|
|
<div id="visualizer" class={styles.rcTable}></div>
|
|
|
</div> */}
|
|
|
|
|
|
+ <Popup
|
|
|
+ teleport="body"
|
|
|
+ class={["popup-custom", "van-scale", styles.popup]}
|
|
|
+ transition="van-scale"
|
|
|
+ v-model:show={shareData.show}
|
|
|
+ closeable
|
|
|
+ onClose={() => {
|
|
|
+ shareData._plrl?.pause();
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div class={styles.playerBoxContent}>
|
|
|
+ <div class={[styles.playerBoxCon, isPad && styles.padPlayerBox]}>
|
|
|
+ <div class={[styles.playerBox, mediaType.value === "video" && styles.videoPlayerBox]}>
|
|
|
+ <video id="videoSrc" class={styles.videoBox} src={scoreData.videoFilePath} data-poster={videobg} preload="metadata" playsinline />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Popup>
|
|
|
+
|
|
|
</div>
|
|
|
-
|
|
|
);
|
|
|
},
|
|
|
});
|