ソースを参照

feat: 新版报告开发

TIANYONG 2 ヶ月 前
コミット
bb86162dde

+ 13 - 0
src/page-instrument/evaluat-model/index.tsx

@@ -290,6 +290,16 @@ export default defineComponent({
         // 音符是否不需要评测
         let noteNeedEvaluat = item.hasGraceNote || ListenMode || dontEvaluatingMode || !!item?.voiceEntry?.ornamentContainer || !!item.noteElement?.speedInfo?.startWord?.includes('rit.') || item.skipMode
         noteNeedEvaluat = noteNeedEvaluat == true ? true : false;
+
+        // 添加额外的一些参数(新版评测报告需要使用)
+        /**
+         * timeSignature:拍号;noteKey:音符音阶;measureDuration:小节时长;
+         */
+        // 拍号
+        const timeSignature = item?.stave?.timeSignature
+        const noteKey = item?.svgElement?.keys
+        const measureDuration = item.measureLength;
+
         const data: any = {
           timeStamp: (start * 1000) / rate,
           duration: ((end * 1000) / rate - (start * 1000) / rate) * noteRate,
@@ -307,6 +317,9 @@ export default defineComponent({
           isTenutoSound,
           isStaccato: item?.voiceEntry?.isStaccato ? true : false, // 是否是重音
           frequencyList: item.frequencyList, // 如果是和弦音符,需要添加多个音符的频率,用于评测
+          timeSignature,
+          noteKey,
+          measureDuration
         };
         datas.push(data);
       }

+ 85 - 3
src/page-instrument/report-new/index.module.less

@@ -1,3 +1,10 @@
+body {
+    -ms-overflow-style: none;
+    scrollbar-width: none;
+    &::-webkit-scrollbar {
+        display: none;
+    }
+}
 .reportDetail {
     width: 100%;
     min-height: 100vh;
@@ -8,6 +15,7 @@
     position: fixed;
     left: 0;
     top: 0;
+    z-index: 99;
     width: 100%;
     display: flex;
     align-items: center;
@@ -137,10 +145,41 @@
         &::-webkit-scrollbar {
             display: none;
         }
+        :global {
+            .note-line {
+                position: absolute;
+                width: 100%;
+                height: 1px;
+                background: rgba(120, 211, 205, 0.25);
+              }
+              .measure-list {
+                display: flex;
+                align-items: center;
+                
+              }
+              .time-marker {
+                position: absolute;
+                top: 0;
+                font-size: 12px;
+                color: #666;
+              }
+              
+              .note {
+                position: absolute;
+                height: 30PX;
+                line-height: 30PX;
+                padding-left: 2px;
+                background: rgba(253, 255, 171, 0.41);
+                border: 1px solid rgba(136, 151, 76, 1);
+                color: #000;
+                font-size: 12px;
+                font-weight: normal;
+              }          
+        }        
     }
     .stMeasure {
-        width: 160px;
-        height: 20px;
+        // width: 160px;
+        height: 30PX;
         border-bottom: 1px solid rgba(120, 211, 205, 0.25);
         border-right: 1px solid rgba(120, 211, 205, 0.58);
         display: flex;
@@ -196,4 +235,47 @@
             }
         }
     }
-}
+}
+
+
+
+.reportContainer {
+    width: 1000px;
+    //height: 400px;
+    overflow: auto;
+    background: #EFF7FF;
+    border: 1px solid #aaa;
+    position: relative;
+    margin: 20px auto;
+    :global {
+        .note-line {
+            position: absolute;
+            width: 100%;
+            height: 1px;
+            background: #ddd;
+          }
+          .time-marker {
+            position: absolute;
+            top: 0;
+            font-size: 12px;
+            color: #666;
+          }
+          
+          .note {
+            position: absolute;
+            height: 20PX;
+            background: #98c;
+            color: white;
+            text-align: center;
+            line-height: 20PX;
+            border-radius: 4px;
+            padding: 2px;
+          }          
+    }
+  }
+  
+  .rcTable {
+    position: relative;
+    width: 2000px; /* 适应长时间序列 */
+  }
+  

+ 247 - 24
src/page-instrument/report-new/index.tsx

@@ -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>
+    
     );
   },
 });

+ 81 - 0
src/utils/frequency.ts

@@ -0,0 +1,81 @@
+// 定义音高对照表的类型,每个八度(number)映射到一个音符(string)和其频率(number)的对象
+type FrequencyTable = {
+    [octave: number]: {
+      [note: string]: number;
+    };
+  };
+  
+/**
+ * 根据给定音名、八度和基准 A4 频率计算音符频率
+ * @param note - 音名(例如 "C", "C#", "D", ..., "B")
+ * @param octave - 八度,数字(例如 4 表示第 4 八度)
+ * @param baseA4 - A4 的基准频率(Hz),默认为 442
+ * @returns 计算得到的频率(Hz)
+ */
+function getFrequency(note: string, octave: number, baseA4: number = 442): number {
+    const noteNames: string[] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
+    const noteIndex = noteNames.indexOf(note);
+    if (noteIndex === -1) {
+        throw new Error("Invalid note: " + note);
+    }
+    // A4 的序号:4*12 + 9 = 57 (因为 A 的索引为 9)
+    const A4Index = 4 * 12 + 9;
+    const currentIndex = octave * 12 + noteIndex;
+    const semitoneOffset = currentIndex - A4Index;
+    return baseA4 * Math.pow(2, semitoneOffset / 12);
+}
+
+/**
+ * 生成一个从 C0 到 B9 的完整音名与频率对照表
+ * @param baseA4 - A4 的基准频率(Hz),例如 442 或 440
+ * @returns 一个 FrequencyTable 对象,结构如下:
+ * {
+ *   0: { C: 16.352, "C#": 17.324, ..., B: 30.868 },
+ *   1: { C: 32.703, "C#": 34.648, ..., B: 61.735 },
+ *   ...
+ *   9: { ... }
+ * }
+ */
+export function buildFreqTable(baseA4: number = 442): FrequencyTable {
+    const noteNames: string[] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
+    const freqTable: FrequencyTable = {};
+
+    // 遍历八度范围 0 ~ 9
+    for (let octave = 0; octave <= 9; octave++) {
+        freqTable[octave] = {};
+        noteNames.forEach(note => {
+        const freq = getFrequency(note, octave, baseA4);
+        // 保留3位小数
+        freqTable[octave][note] = parseFloat(freq.toFixed(3));
+        });
+    }
+    return freqTable;
+}
+  
+
+
+/**
+ * 根据给定频率值和基准 A4 频率,计算该频率对应的音名和八度
+ * @param freq 待计算的频率(Hz)
+ * @param baseA4 A4 的基准频率(Hz),默认值为 442
+ * @returns 返回一个对象,包含音名(note)和八度(octave)
+ */
+export 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"];
+    // A4 对应的绝对半音索引:A 在 noteNames 中索引为 9,加上 4 个八度(每个八度 12 个半音),所以 A4Index = 4 * 12 + 9 = 57
+    const A4Index = 57;
+    // 计算待测频率相对于基准 A4 的半音偏移值(可能是小数)
+    const semitoneOffset = 12 * Math.log2(freq / baseA4);
+    // 四舍五入到最近的半音
+    const nearestSemitone = Math.round(semitoneOffset);
+    // 计算绝对音高索引:以 C0 为起点
+    const absoluteIndex = nearestSemitone + A4Index;
+    // 计算所属八度:整数除以 12
+    const octave = Math.floor(absoluteIndex / 12);
+    // 计算音符在该八度内的索引(0~11)
+    const noteIndex = absoluteIndex % 12;
+    const note = noteNames[noteIndex];
+
+    return { note, octave };
+}
+