Browse Source

Merge branch 'feature-tianyong' into gym-test

TIANYONG 8 months ago
parent
commit
97db7bd848

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

BIN
src/page-instrument/report-new/image/back_icon.png


BIN
src/page-instrument/report-new/image/guan.png


BIN
src/page-instrument/report-new/image/kai.png


BIN
src/page-instrument/report-new/image/video_icon.png


+ 281 - 0
src/page-instrument/report-new/index.module.less

@@ -0,0 +1,281 @@
+body {
+    -ms-overflow-style: none;
+    scrollbar-width: none;
+    &::-webkit-scrollbar {
+        display: none;
+    }
+}
+.reportDetail {
+    width: 100%;
+    min-height: 100vh;
+    --header-height: 62px;
+}
+
+.reportHead {
+    position: fixed;
+    left: 0;
+    top: 0;
+    z-index: 99;
+    width: 100%;
+    display: flex;
+    align-items: center;
+    background: #fff;
+    padding: 0 22px;
+    height: var(--header-height);
+    .backIcon {
+        width: 32px;
+        height: 32px;
+    }
+    .content {
+        flex: 1;
+        margin: 0 12px;
+        .title {
+            display: flex;
+            align-items: center;
+            .titleName {
+                font-size: 16px;
+                color: #131415;
+                font-weight: 600;
+                margin-right: 6px;
+            }
+            .titleLevel {
+                border: 1px solid #01C1B5;
+                border-radius: 2px;
+                padding: 0 3px;
+                line-height: 16px;
+                font-size: 12px;
+                color: #01C1B5;
+                font-weight: normal;
+                transform: scale(0.9);
+            }
+        }
+        .score {
+            margin-top: 4px;
+            display: flex;
+            align-items: center;
+            span {
+                font-size: 12px;
+                color: #777777;
+            }
+            .total {
+                font-weight: 500;
+                color: #FF2F0E;
+            }
+        }        
+    }
+    .right {
+        display: flex;
+        align-items: center;
+        .fItem {
+            color: #1A1A1A;
+            font-size: 13px;
+            display: flex;
+            align-items: center;
+            i {
+                width: 16px;
+                height: 16px;
+                border-radius: 4px;
+                border: 1px solid #88974C;
+                background: rgba(253, 255, 171, 0.41);
+                margin-right: 6px;
+            }
+        }
+        .sItem {
+            display: flex;
+            align-items: center;
+            color: #1A1A1A;
+            font-size: 13px;
+            margin-left: 20px;
+            i {
+                width: 16px;
+                height: 16px;
+                border-radius: 4px;
+                background: rgba(16, 216, 203, 0.6);
+                margin-right: 6px;
+            }
+        }
+        .videoIcon {
+            width: 71px;
+            height: 28px;
+            margin-left: 20px;
+            cursor: pointer;
+        }
+        :global{
+            .van-switch{
+                margin-left: 6px;
+                width: 25px;
+                height: 13px;
+                background-color: transparent!important;
+                background-image: url("./image/guan.png");
+                background-repeat: no-repeat;
+                background-size: 100% 100%;
+                border-radius: 0;
+                &.van-switch--on{
+                    background-image: url("./image/kai.png");
+                }
+                .van-switch__node{
+                    display: none;
+                }
+            }
+        }        
+    }
+}
+
+.scoreTable {
+    padding-top: calc(var(--header-height) + 1px);
+    background: #EFF7FF;
+    .stContent {
+        position: relative;
+        &::before {
+            content: "";
+            position: absolute;
+            left: 0;
+            top: 0;
+            width: 47px;
+            height: 100%;
+            background: linear-gradient( 90deg, #D4F5EE 0%, rgba(212,244,237,0) 100%);
+            z-index: 0;
+        }
+        border: 1px solid rgba(120, 211, 205, 0.58);
+        display: flex;
+        flex-wrap: nowrap;
+        overflow-x: scroll;
+        -ms-overflow-style: none;
+        scrollbar-width: none;
+        &::-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: 30PX;
+        border-bottom: 1px solid rgba(120, 211, 205, 0.25);
+        border-right: 1px solid rgba(120, 211, 205, 0.58);
+        display: flex;
+        align-items: center;
+        padding: 0 5px;
+        overflow: hidden;
+        .mBeat {
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            background: #C9E4EB;
+            width: 16px;
+            transform: scale(0.72);
+            font-size: 12px;
+            margin-right: 2px;
+            span {
+                font-size: 12px;
+                color: #007C74;
+                &:first-child {
+                    border-bottom: 1px solid #007C74;
+                }
+            }
+        }
+        .mNumber {
+            font-size: 12px;
+            color: #007C74;
+        }
+    }
+    .stBeat {
+        display: flex;
+        align-items: center;
+        border-bottom: 1px solid rgba(120, 211, 205, 0.25);
+        border-right: 1px solid rgba(120, 211, 205, 0.58);
+        width: 160px;
+        &:last-child {
+            border-bottom: none;
+        }
+        li {
+            position: relative;
+            width: 40px;
+            height: 15px;
+            border-right: 1px dashed;
+            /* 利用 repeating-linear-gradient 自定义 dash 长度 */
+            border-image: repeating-linear-gradient(
+                180deg,
+                rgba(120, 211, 205, 0.3) 0,
+                rgba(120, 211, 205, 0.3) 4px,
+                transparent 4px,
+                transparent 6px
+            ) 1;
+            &:last-child {
+                border-right: none;
+            }
+        }
+    }
+}
+
+
+
+.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; /* 适应长时间序列 */
+  }
+  

+ 365 - 0
src/page-instrument/report-new/index.tsx

@@ -0,0 +1,365 @@
+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 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 } 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 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, 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) {
+                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 (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 () => {
+        state.isEvaluatReport = true;
+        const res = await api_musicPracticeRecordDetail(query.id);
+        state.partIndex = Number(res?.data?.partIndex);
+        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.timeChunkAnalysisList = resultData?.musicalNotesPlayStats?.timeChunkAnalysisList || []
+          createMeasureList(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()
+
+        
+    });
+
+
+    return () => (
+      <div class={styles.reportDetail}>     
+        <div class={styles.reportHead}>
+            <img class={styles.backIcon} src={backIcon} />
+            <div class={styles.content}>
+                <div class={styles.title}>
+                    <span class={styles.titleName}>天空之城</span>
+                    <span class={styles.titleLevel}>入门级|速度90</span>
+                </div>
+                <div class={styles.score}>
+                    <span class={styles.total}>总分: 78</span>
+                    <span>|音准: 67</span>
+                    <span>|节奏: 87</span>
+                    <span>|完整度: 87</span>
+                </div>
+            </div>
+            <div class={styles.right}>
+                <div class={styles.fItem}>
+                    <i></i>
+                    <span>标准音高</span>
+                </div>
+                <div class={styles.sItem}>
+                    <i></i>
+                    <span>演奏音高</span>
+                </div>
+                <Switch 
+                    v-model={scoreData.openRealPitch}
+                    onChange={ async (value) => {
+                        // 
+                    }}
+                ></Switch>                
+                <img class={styles.videoIcon} src={videoIcon} />
+            </div>
+        </div>
+
+        
+        <div class={styles.scoreTable}>
+            <div class={styles.stContent}>
+                {/* {
+                    scoreData.measureList.map((measure: any, mIndex: number) => 
+                        <>
+                            <div class={styles.stColumn}>
+                                <div class={styles.stMeasure}>
+                                    {
+                                        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,2].map((note: any) => 
+                                        <ul class={styles.stBeat}>
+                                            <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>
+    
+    );
+  },
+});

+ 4 - 0
src/page-instrument/router.ts

@@ -28,6 +28,10 @@ const routes: RouteRecordRaw[] = [
     component: () => import("./simple-detail/index"),
   },
   {
+    path: "/report-new",
+    component: () => import("./report-new/index"),
+  },
+  {
     path: "/:pathMatch(.*)*",
     component: Notfind,
     meta: {

+ 1 - 1
src/page-instrument/view-evaluat-report/index.tsx

@@ -250,7 +250,7 @@ export default defineComponent({
           }
           stemEl?.classList.add(colorsClass[errType]);
           svgEl?.classList.add(colorsClass[errType]);
-          console.log(123456,'添加颜色',errType)
+          // console.log(123456,'添加颜色',errType)
           // 评测过的音符,需要给小节添加背景色
           // if (errType !== "NOT_PLAYED") {
           //   const staveNote = svgEl?.parentNode?.parentNode?.querySelector(".vf-stave");

+ 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 };
+}
+  

+ 1 - 1
vite.config.ts

@@ -81,7 +81,7 @@ export default defineConfig({
     // https: true,
     proxy: {
       "^/instrument/.*": {
-        target: "https://test.gym.lexiaoya.cn",
+        target: "https://dev.gym.lexiaoya.cn",
         changeOrigin: true,
         rewrite: (path) => path.replace(/^\/instrument/, ""),
       },