|
@@ -7,40 +7,115 @@ 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 } from "/src/utils/frequency"
|
|
|
|
|
|
|
|
|
export default defineComponent({
|
|
|
name: "music-list",
|
|
|
setup() {
|
|
|
const query: any = getQuery();
|
|
|
+
|
|
|
+ // 设定可视化参数
|
|
|
+ interface VisualConfig {
|
|
|
+ msPerPixel: number; // 1 像素代表多少毫秒
|
|
|
+ semitoneHeight: number; // 1 个半音占多少像素
|
|
|
+ baseA4: number; // A4 基准
|
|
|
+ topNoteIndex: number; // 顶部音符
|
|
|
+ bottomNoteIndex: number; // 底部音符
|
|
|
+ }
|
|
|
+ // 音符事件
|
|
|
+ interface NoteEvent {
|
|
|
+ freq: number;
|
|
|
+ startTime: number;
|
|
|
+ endTime: number;
|
|
|
+ }
|
|
|
const scoreData = reactive({
|
|
|
videoFilePath: "", // 回放视频路径
|
|
|
openRealPitch: false,
|
|
|
xmlInfo: {} as any,
|
|
|
xmlNotes: [],
|
|
|
+ realMaxFrequency: 0, // 实际的最高音(xml和用户演奏的取高值)
|
|
|
+ realMinFrequency: 0, // 实际的最低音(xml和用户演奏的取低值)
|
|
|
+ measureList: [] as any, // 构造的小节数组
|
|
|
+ timeChunkAnalysisList: [] as any, // 用户实际演奏的音符数据
|
|
|
});
|
|
|
|
|
|
// 根据音符构造小节信息
|
|
|
const createMeasureList = (notes: Array<any>) => {
|
|
|
let measureList = [];
|
|
|
- let addNum = 1;
|
|
|
- const preMeasureIndex = 0;
|
|
|
+ let noteList = [];
|
|
|
+ // 小节数从0开始,addMeasureNumber为0,否则为1
|
|
|
+ let addMeasureNumber = 1;
|
|
|
+ // 记录上一个音符的小节索引
|
|
|
+ let preMeasureIndex = null;
|
|
|
+ // 音符开始和结束时间
|
|
|
+ let startTime = 0, endTime = 0;
|
|
|
+ // xml的最高音和最低音
|
|
|
+ let xmlMaxFrequency = 0, xmlMinFrequency = 0;
|
|
|
+ // 上一个小节的节拍数,如果相邻的小节节拍数相同,则不重复添加
|
|
|
+ let preMeasureTimeSignature = null;
|
|
|
for (let index = 0; index < notes.length; index++) {
|
|
|
const note = notes[index];
|
|
|
- const { measureRenderIndex, frequencyList, dontEvaluating, duration } = notes[index];
|
|
|
+ const { measureRenderIndex, frequencyList, dontEvaluating, duration, timeSignature = {numerator: 2, denominator: 4} } = note;
|
|
|
+ startTime = endTime;
|
|
|
+ endTime = endTime + duration / 1000
|
|
|
+ xmlMaxFrequency = Math.max(...frequencyList,xmlMaxFrequency)
|
|
|
+ xmlMinFrequency = Math.min(...frequencyList,xmlMinFrequency)
|
|
|
if (index === 0) {
|
|
|
- measureList.push({
|
|
|
- MeasureNumberXML: measureRenderIndex + 1,
|
|
|
+ preMeasureTimeSignature = `${timeSignature.numerator}` + '/' + `${timeSignature.denominator}`;
|
|
|
+ preMeasureIndex = measureRenderIndex;
|
|
|
+ noteList.push({
|
|
|
+ MeasureNumberXML: measureRenderIndex + addMeasureNumber,
|
|
|
frequencyList,
|
|
|
duration: duration / 1000,
|
|
|
+ dontEvaluating,
|
|
|
+ startTime,
|
|
|
+ endTime,
|
|
|
+ })
|
|
|
+ measureList.push({
|
|
|
+ MeasureNumberXML: measureRenderIndex + addMeasureNumber,
|
|
|
+ timeSignature,
|
|
|
+ timeSignatureDiff: true,
|
|
|
+ noteList,
|
|
|
+ measureDuration: 1
|
|
|
})
|
|
|
} else {
|
|
|
-
|
|
|
- }
|
|
|
- if ( measureRenderIndex ) {
|
|
|
-
|
|
|
+ if (preMeasureIndex === measureRenderIndex) {
|
|
|
+ // 小节索引相同,说明相邻的两个音符还在同一个小节
|
|
|
+ noteList.push({
|
|
|
+ MeasureNumberXML: measureRenderIndex + addMeasureNumber,
|
|
|
+ frequencyList,
|
|
|
+ duration: duration / 1000,
|
|
|
+ dontEvaluating,
|
|
|
+ startTime,
|
|
|
+ endTime,
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ // 小节索引不同,说明不是同一个小节
|
|
|
+ const currentTimeSignature = `${timeSignature.numerator}` + '/' + `${timeSignature.denominator}`;
|
|
|
+ noteList = []
|
|
|
+ preMeasureIndex = measureRenderIndex;
|
|
|
+ noteList.push({
|
|
|
+ MeasureNumberXML: measureRenderIndex + addMeasureNumber,
|
|
|
+ frequencyList,
|
|
|
+ duration: duration / 1000,
|
|
|
+ dontEvaluating,
|
|
|
+ startTime,
|
|
|
+ endTime,
|
|
|
+ })
|
|
|
+ measureList.push({
|
|
|
+ MeasureNumberXML: measureRenderIndex + addMeasureNumber,
|
|
|
+ timeSignature: timeSignature,
|
|
|
+ timeSignatureDiff: currentTimeSignature !== preMeasureTimeSignature ? true: false,
|
|
|
+ noteList,
|
|
|
+ measureDuration: 1
|
|
|
+ })
|
|
|
+ preMeasureTimeSignature = currentTimeSignature
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
+ // console.log(222,measureList)
|
|
|
+ scoreData.measureList = measureList;
|
|
|
}
|
|
|
|
|
|
const getRecordDetail = async () => {
|
|
@@ -52,20 +127,159 @@ export default defineComponent({
|
|
|
resultData = eval('(' + res?.data?.scoreData + ')');
|
|
|
scoreData.xmlInfo = eval('(' + JSON.parse(res.data.scoreData)?.musicalNotesPlayStats?.musicXmlBasicInfo + ')') || {}
|
|
|
scoreData.xmlNotes = scoreData.xmlInfo.musicXmlInfos || []
|
|
|
+ scoreData.timeChunkAnalysisList = resultData?.musicalNotesPlayStats?.timeChunkAnalysisList || []
|
|
|
createMeasureList(scoreData.xmlNotes)
|
|
|
- console.log(11111,resultData,scoreData.xmlInfo,scoreData.xmlNotes)
|
|
|
+ // console.log(11111,resultData,scoreData)
|
|
|
+ drawTable()
|
|
|
} catch (error) {
|
|
|
console.error("解析评测结果:", error);
|
|
|
}
|
|
|
scoreData.videoFilePath = res.data?.videoFilePath || res.data?.recordFilePath;
|
|
|
}
|
|
|
|
|
|
+// 将频率转换为音名和八度
|
|
|
+function frequencyToNote(freq: number, baseA4: number = 442): { note: string; octave: number } {
|
|
|
+ const noteNames: string[] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
|
+ const A4Index = 57; // A4 在 12平均律中的绝对索引
|
|
|
+ const semitoneOffset = 12 * Math.log2(freq / baseA4);
|
|
|
+ const nearestSemitone = Math.round(semitoneOffset);
|
|
|
+ const absoluteIndex = nearestSemitone + A4Index;
|
|
|
+ const octave = Math.floor(absoluteIndex / 12);
|
|
|
+ const noteIndex = absoluteIndex % 12;
|
|
|
+ const note = noteNames[noteIndex];
|
|
|
+ return { note, octave };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算音符索引
|
|
|
+ function getAbsoluteIndex(note: string, octave: number): number {
|
|
|
+ const noteNames = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
|
+ const noteIndex = noteNames.indexOf(note);
|
|
|
+ return octave * 12 + noteIndex;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建网格
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 画时间刻度
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 绘制小节序号和拍号
|
|
|
+ let preMeasureWidth: any = 0;
|
|
|
+ const measureDom = document.createElement("div");
|
|
|
+ measureDom.className = "measure-list";
|
|
|
+ for (let index = 0; index < scoreData.measureList.length; index++) {
|
|
|
+ const measure = scoreData.measureList[index];
|
|
|
+
|
|
|
+ const left = `${measure.measureDuration * 1000 / msPerPixel + preMeasureWidth}px`;
|
|
|
+ const width = `${measure.measureDuration * 1000 / msPerPixel}px`;
|
|
|
+ preMeasureWidth = `${measure.measureDuration * 1000 / msPerPixel}`;
|
|
|
+ const newDom = measure.timeSignatureDiff ? `
|
|
|
+ <div class=${styles.stMeasure} style="width:${width}">
|
|
|
+ <p class=${styles.mBeat}>
|
|
|
+ <span>${measure.timeSignature.numerator}</span>
|
|
|
+ <span>${measure.timeSignature.denominator}</span>
|
|
|
+ </p>
|
|
|
+ <div class=${styles.mNumber}>${measure.MeasureNumberXML}</div>
|
|
|
+ </div>` : `
|
|
|
+ <div class=${styles.stMeasure}>
|
|
|
+ <div class=${styles.mNumber}>${measure.MeasureNumberXML}</div>
|
|
|
+ </div>`
|
|
|
+ measureDom.innerHTML = measureDom.innerHTML + newDom;
|
|
|
+
|
|
|
+ }
|
|
|
+ container.appendChild(measureDom);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 渲染音符
|
|
|
+ function renderNotes(container: HTMLElement, noteEvents: NoteEvent[], config: VisualConfig) {
|
|
|
+ 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 = "note";
|
|
|
+ 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 container = document.getElementById("visualizer") as HTMLElement;
|
|
|
+ if (!container) return;
|
|
|
+
|
|
|
+ const config: VisualConfig = {
|
|
|
+ msPerPixel: 5,
|
|
|
+ semitoneHeight: 30,
|
|
|
+ baseA4: 442,
|
|
|
+ topNoteIndex: 87,
|
|
|
+ bottomNoteIndex: 0,
|
|
|
+ };
|
|
|
+
|
|
|
+ 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 },
|
|
|
+ ];
|
|
|
+
|
|
|
+ renderNotes(container, noteEvents, config);
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
onBeforeMount(() => {
|
|
|
|
|
|
});
|
|
|
|
|
|
onMounted(async () => {
|
|
|
+ // 示例:使用 A4 = 442Hz 生成频率对照表
|
|
|
+ // const freqTable442 = buildFreqTable(442);
|
|
|
+ // console.log("A4 = 442Hz:", freqTable442);
|
|
|
+
|
|
|
+ // // 示例:使用 A4 = 440Hz 生成频率对照表
|
|
|
+ // const freqTable440 = buildFreqTable(440);
|
|
|
+ // console.log("A4 = 440Hz:", freqTable440);
|
|
|
+
|
|
|
+ const baseA4_442 = 442;
|
|
|
+ const testFreq1 = 373.11;
|
|
|
+ console.log(`频率 ${testFreq1}Hz 属于:`, frequencyToNote(testFreq1, baseA4_442));
|
|
|
+
|
|
|
getRecordDetail()
|
|
|
+
|
|
|
+
|
|
|
});
|
|
|
|
|
|
|
|
@@ -104,39 +318,48 @@ export default defineComponent({
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
+
|
|
|
<div class={styles.scoreTable}>
|
|
|
<div class={styles.stContent}>
|
|
|
- {
|
|
|
- [1,1,1,1,1,1,1,1,1,1].map((item: any, mIndex: number) =>
|
|
|
+ {/* {
|
|
|
+ scoreData.measureList.map((measure: any, mIndex: number) =>
|
|
|
<>
|
|
|
<div class={styles.stColumn}>
|
|
|
<div class={styles.stMeasure}>
|
|
|
- <p class={styles.mBeat}>
|
|
|
- <span>4</span>
|
|
|
- <span>4</span>
|
|
|
- </p>
|
|
|
- <div class={styles.mNumber}>{mIndex+1}</div>
|
|
|
- </div>
|
|
|
+ {
|
|
|
+ measure.timeSignatureDiff &&
|
|
|
+ <p class={styles.mBeat}>
|
|
|
+ <span>{measure.timeSignature.numerator}</span>
|
|
|
+ <span>{measure.timeSignature.denominator}</span>
|
|
|
+ </p>
|
|
|
+ }
|
|
|
+ <div class={styles.mNumber}>{measure.MeasureNumberXML}</div>
|
|
|
+ </div>
|
|
|
{
|
|
|
- [1,1,1,1,1,1,1,1,1,1].map((gItem: any) =>
|
|
|
+ [1,2].map((note: any) =>
|
|
|
<ul class={styles.stBeat}>
|
|
|
<li></li>
|
|
|
- <li></li>
|
|
|
- <li></li>
|
|
|
- <li></li>
|
|
|
</ul>
|
|
|
)
|
|
|
}
|
|
|
</div>
|
|
|
</>
|
|
|
)
|
|
|
- }
|
|
|
-
|
|
|
+ } */}
|
|
|
+ <div id="visualizer" class={styles.rcTable}></div>
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ {/* <div id="visualizer-container" class={styles.reportContainer}>
|
|
|
+ <div id="visualizer" class={styles.rcTable}></div>
|
|
|
+ </div> */}
|
|
|
|
|
|
</div>
|
|
|
+
|
|
|
);
|
|
|
},
|
|
|
});
|