From 13e2f74304b2e14aeacf033a72895f82fdab28a2 Mon Sep 17 00:00:00 2001 From: madderscientist Date: Fri, 9 Feb 2024 22:36:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BA=86=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=92=8C=E5=88=86=E6=9E=90=E7=9A=84UI=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 48 +++++++++++++++- app.js | 15 ++++- dataProcess/analyser.js | 51 ++++++++++++++++- index.html | 122 +++++++++++++++++++++++++++++++++++----- style/style.css | 66 +++++++++++++++++++++- todo.md | 6 +- 6 files changed, 284 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 7a49f70..72e31c2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ 5. 导出为midi等,或者暂时导出项目(下次继续) ## 导入导出说明 -- 导出进度: 结果是.nt的二进制文件,保存分析结果(频谱图)和音符音轨。导入的时候并不会强制要求匹配原曲!(会根据文件名判断一下,但不强制) +- 导出进度: 结果是.nd的二进制文件,保存分析结果(频谱图)和音符音轨。导入的时候并不会强制要求匹配原曲!(会根据文件名判断一下,但不强制) - 导出为midi: 只保证能听,节拍默认4/4,bpm默认60,midi类型默认1(同步多音轨)。第10轨不会分配为鼓点轨(本项目设计并不考虑扒鼓)。 - 导入midi: 将midi音符导入,只保证音轨、音符、音色能对应,音量默认127。如果导入后没有超过总音轨数,会在后面增加;否则会覆盖后面几轨(有提示)。 @@ -53,7 +53,53 @@ - 滑动条,如果旁边有数字,点击就可以恢复初始值。 - 多次点击“笔”右侧的选择工具,可以切换选择模式。 + +## 文件结构 +``` +│ app.js: 最重要的文件,主程序 +│ channelDiv.js: 多音轨的UI界面类, 可拖拽列表 +│ contextMenu.js: 右键菜单类 +│ favicon.ico: 小图标 +│ index.html: 程序入口, 其js主要是按钮的onclick +│ LICENSE +│ midi.js: midi创建、解析类 +│ myRange.js: 横向滑动条的封装类 +│ README.md +│ saver.js: 二进制保存相关 +│ siderMenu.js: 侧边栏菜单类 +│ snapshot.js: 快照类, 实现撤销和重做 +│ tinySynth.js: 合成器类, 负责播放音频 +│ todo.md: 一些设计思路和权衡 +│ +├─dataProcess +│ analyser.js: 频域数据分析与简化 +│ fft_real.js: 执行实数FFT获取频域数据 +│ +├─img +│ github-mark-white.png +│ logo-small.png +│ logo.png +│ logo_text.png +│ +└─style + │ askUI.css: 达到类似效果 + │ channelDiv.css: 多音轨UI样式 + │ contextMenu.css: 右键菜单样式 + │ myRange.css: 包装滑动条 + │ siderMenu.css: 侧边菜单样式 + │ style.css: index中独立元素的样式 + │ + └─icon: 从阿里图标库得到的icon + iconfont.css + iconfont.ttf + iconfont.woff + iconfont.woff2 +``` + ## 重要更新记录 +### 2024 2 9 +在今年完成了所有基本功能!本次更新了设置相关,简单地设计了调性分析的算法,已经完全可以用了! + ### 2024 2 8 文件系统已经完善!已经可以随心所欲导入导出保存啦!同时修复了一些小bug、完善了一些api。
界面上,本打算将文件相关选项放到logo上,但是侧边菜单似乎有些空了,于是就加入到侧边栏,而logo设置为刷新或开新界面(考察了其他网站的logo的用途)。同时给侧边菜单加入了“设置”和“分析”,但本次更新没做。
diff --git a/app.js b/app.js index 22917f6..b9bac18 100644 --- a/app.js +++ b/app.js @@ -12,7 +12,7 @@ function App() { Object.defineProperty(this, 'width', { get: function () { return this._width; }, set: function (w) { - if (w < 0) return; + if (w <= 0) return; this._width = w; this.TimeBar.updateInterval(); this.HscrollBar.refreshSize(); // 刷新横向滑动条 @@ -22,7 +22,7 @@ function App() { Object.defineProperty(this, 'height', { get: function () { return this._height; }, set: function (h) { - if (h < 0) return; + if (h <= 0) return; this._height = h; this.Keyboard._ychange.set([ -1.5 * h, -2 * h, -1.5 * h, -1.5 * h, -2 * h, -2 * h, -1.5 * h, @@ -68,6 +68,7 @@ function App() { colorStep2: 240, multiple: parseFloat(document.getElementById('multiControl').value),// 幅度的倍数 _spectrogram: null, + mask: '#25262daa', getColor: (value) => { // 0-step1,是蓝色的亮度从0变为50%;step1-step2,是颜色由蓝色变为红色;step2-255,保持红色 value = value || 0; let hue = 0, lightness = 50; // Red hue @@ -110,7 +111,7 @@ function App() { ctx.fillRect(rectx, 0, w, canvas.height); } // 铺底色以凸显midi音符 - ctx.fillStyle = '#25262daa'; + ctx.fillStyle = sp.mask; ctx.fillRect(0, 0, rectx, canvas.height); // 更新note ctx.fillStyle = "#ffffff4f"; @@ -131,6 +132,13 @@ function App() { this.parent.scroll2(0, (this.parent._height * this.parent.ynum - this.parent.spectrum.height) >> 1); // 垂直方向上,视野移到中间 } this.parent.HscrollBar.refreshSize(); + }, + get Alpha() { + return parseInt(this.mask.substring(7), 16); + }, + set Alpha(a) { + a = Math.min(255, Math.max(a | 0, 0)); + this.mask = '#25262d' + a.toString(16); } }; this.MidiAction = { @@ -837,6 +845,7 @@ function App() { }, { name: "从此处播放", callback: (e_father, e_self) => { + this.AudioPlayer.stop(); this.AudioPlayer.start((e_father.offsetX + this.scrollX) * this.dt / this._width); } } diff --git a/dataProcess/analyser.js b/dataProcess/analyser.js index add0af6..3a236d5 100644 --- a/dataProcess/analyser.js +++ b/dataProcess/analyser.js @@ -78,7 +78,56 @@ class NoteAnalyser { // 负责解析频谱数据 } } // FFT的结果需要除以N才是DTFT的结果 由于结果太小,统一放大10倍 经验得到再乘700可在0~255得到较好效果 - noteAm[i] = Math.sqrt(noteAm[i]) * 10 / real.length; + noteAm[i] = Math.sqrt(noteAm[i]) * 16 / real.length; } return noteAm; } + /** + * 调性分析,原理是音符能量求和 + * @param {Array} noteTable + * @returns {Array} 调性和音符的能量 + */ + static Tonality(noteTable) { + let energy = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + for (const atime of noteTable) { + energy[0] += atime[0]**2 + atime[12]**2 + atime[24]**2 + atime[36]**2 + atime[48]**2 + atime[60]**2 + atime[72]**2; + energy[1] += atime[1]**2 + atime[13]**2 + atime[25]**2 + atime[37]**2 + atime[49]**2 + atime[61]**2 + atime[73]**2; + energy[2] += atime[2]**2 + atime[14]**2 + atime[26]**2 + atime[38]**2 + atime[50]**2 + atime[62]**2 + atime[74]**2; + energy[3] += atime[3]**2 + atime[15]**2 + atime[27]**2 + atime[39]**2 + atime[51]**2 + atime[63]**2 + atime[75]**2; + energy[4] += atime[4]**2 + atime[16]**2 + atime[28]**2 + atime[40]**2 + atime[52]**2 + atime[64]**2 + atime[76]**2; + energy[5] += atime[5]**2 + atime[17]**2 + atime[29]**2 + atime[41]**2 + atime[53]**2 + atime[65]**2 + atime[77]**2; + energy[6] += atime[6]**2 + atime[18]**2 + atime[30]**2 + atime[42]**2 + atime[54]**2 + atime[66]**2 + atime[78]**2; + energy[7] += atime[7]**2 + atime[19]**2 + atime[31]**2 + atime[43]**2 + atime[55]**2 + atime[67]**2 + atime[79]**2; + energy[8] += atime[8]**2 + atime[20]**2 + atime[32]**2 + atime[44]**2 + atime[56]**2 + atime[68]**2 + atime[80]**2; + energy[9] += atime[9]**2 + atime[21]**2 + atime[33]**2 + atime[45]**2 + atime[57]**2 + atime[69]**2 + atime[81]**2; + energy[10] += atime[10]**2 + atime[22]**2 + atime[34]**2 + atime[46]**2 + atime[58]**2 + atime[70]**2 + atime[82]**2; + energy[11] += atime[11]**2 + atime[23]**2 + atime[35]**2 + atime[47]**2 + atime[59]**2 + atime[71]**2 + atime[83]**2; + } + // notes根据最大值归一化 + let max = Math.max(...energy); + energy = energy.map((num) => num / max); + // 找到最大的前7个音符 + const sortedIndices = energy.map((num, index) => index) + .sort((a, b) => energy[b] - energy[a]) + .slice(0, 7); + sortedIndices.sort((a, b) => a - b); + // 判断调性 + let tonality = sortedIndices.map((num) => { + return num.toString(16); + }).join(''); + switch (tonality) { + case '024579b': tonality = 'C'; break; + case '013568a': tonality = 'C#'; break; + case '124679b': tonality = 'D'; break; + case '023578a': tonality = 'Eb'; break; + case '134689b': tonality = 'E'; break; + case '024579a': tonality = 'F'; break; + case '13568ab': tonality = 'Gb'; break; + case '024679b': tonality = 'G'; break; + case '013578a': tonality = 'Ab'; break; + case '124689b': tonality = 'A'; break; + case '023579a': tonality = 'Bb'; break; + case '13468ab': tonality = 'B'; break; + default: tonality = 'Unknown'; break; + } return [tonality, energy]; + } } \ No newline at end of file diff --git a/index.html b/index.html index 4430616..f1cdc07 100644 --- a/index.html +++ b/index.html @@ -100,7 +100,7 @@
  • 导出为midi
  • - +
  • @@ -111,20 +111,21 @@

    EQ设置(dB)

    -
    - ( ̄ε(# ̄) 还没做 - -
    +
      +
    • 调性分析
    • + ( ̄ε(# ̄) 别的还没做 +
      -
    • 宽度
    • -
    • 高度
    • -
    • 遮罩透明度
    • +
    • 宽度
    • +
    • 高度
    • +
    • 遮罩厚度
    • -
      精准设置重复区间
      - ~ - - + 精准设置重复区间 +
      + ~ +
      +
    @@ -282,8 +283,17 @@
    精准设置重复区间
    }); }; input.click(); }; - lis[2].onclick = () => app.Saver.write(); + lis[2].onclick = () => { + if(!app.Spectrogram._spectrogram) { + alert("请先导入音频!"); + return; + } app.Saver.write(); + } lis[3].onclick = () => { // midi导出 + if(!app.Spectrogram._spectrogram) { + alert("请先导入音频!"); + return; + } const newMidi = new midi(60,[4,4],Math.round(1000/app.dt),[],app.AudioPlayer.name); const mts = []; for(const ch of app.synthesizer.channel) { @@ -327,6 +337,92 @@
    精准设置重复区间
    }; } + // ==== 分析界面 ==== // + { + const lis = document.getElementById('analysePannel').children; + lis[0].onclick = function() { + if(!app.Spectrogram._spectrogram) { + alert("请先导入音频!"); + return; + } + let [tonality, energy] = NoteAnalyser.Tonality(app.Spectrogram._spectrogram); + const div = document.createElement('div'); + div.innerHTML = `
    调性: ${tonality}
    +
    +
    C
    +
    C#
    +
    D
    +
    D#
    +
    E
    +
    F
    +
    F#
    +
    G
    +
    G#
    +
    A
    +
    A#
    +
    B
    +
    `; + this.appendChild(div); + }; + } + + // ==== 设置界面 ==== // + { + const lis = document.getElementById('settingPannel').children; + lis[0].firstChild.onclick = () => { + --app.width; + lis[0].dataset.value = app.width; + }; + lis[0].lastChild.onclick = () => { + ++app.width; + lis[0].dataset.value = app.width; + }; + lis[1].firstChild.onclick = () => { + --app.height; + lis[1].dataset.value = app.height; + }; + lis[1].lastChild.onclick = () => { + ++app.height; + lis[1].dataset.value = app.height; + }; + lis[2].firstChild.onclick = () => { + --app.Spectrogram.Alpha; + lis[2].dataset.value = app.Spectrogram.Alpha; + }; + lis[2].lastChild.onclick = () => { + ++app.Spectrogram.Alpha; + lis[2].dataset.value = app.Spectrogram.Alpha; + }; + const repeatInput = lis[3].querySelectorAll('input'); + function checkTime(time) { + const timeRegex = /^\d{1,2}:\d{1,2}:\d{1,3}$/; + return timeRegex.test(time); + } + function Time2Ms(time) { + const t = time.split(':'); + return parseInt(t[0]) * 60000 + parseInt(t[1]) * 1000 + parseInt(t[2]); + } + repeatInput[0].oninput = repeatInput[1].oninput = function() { + this.style.color = checkTime(this.value) ? 'var(--theme-text)' : 'red'; + }; + const repbtn = lis[3].querySelectorAll('button'); + repbtn[0].onclick = () => { + app.TimeBar.repeatStart = -1; + app.TimeBar.repeatEnd = -1; + }; + repbtn[1].onclick = () => { + if(checkTime(repeatInput[0].value) && checkTime(repeatInput[1].value)) { + let i1 = Time2Ms(repeatInput[0].value); + let i2 = Time2Ms(repeatInput[1].value); + if(i1 > i2) { + let temp = i2; + i2 = i1; i1 = temp; + } + app.TimeBar.repeatStart = i1; + app.TimeBar.repeatEnd = i2; + } else alert('时间格式错误!'); + } + } // ==== 交互细节 ==== // document.querySelector('.top-logo').addEventListener('click', () => { // 如果已经有分析数据了,就打开新的界面 diff --git a/style/style.css b/style/style.css index 83b5d6d..bed3bcb 100644 --- a/style/style.css +++ b/style/style.css @@ -228,7 +228,7 @@ li textarea { border-radius: 6px; background-color: var(--theme-dark); color: var(--theme-text); - resize: none; + resize: vertical; } /* EQ控制面板 */ @@ -264,4 +264,68 @@ li textarea { } .niceScroll::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background-color: rgb(37, 38, 45); +} + +/* 分析面板 */ +.tonalityResult { + width: 100%; + border-left: white 1px solid; +} + +.tonalityResult div { + color: var(--theme-dark); + height: 1em; + font-size: 1em; + line-height: 1em; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} + +/* 设置面板 */ +#settingPannel button { + margin: 0; +} +#settingPannel li { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + flex-wrap: wrap; + position: relative; +} +#settingPannel li::after { + content: attr(data-value); + position: absolute; + right: 0; + bottom: 0; + font-size: 10px; +} +#settingPannel li button:first-of-type { + font-family: monospace; + margin-right: 0.2em; +} +#settingPannel li button:last-of-type { + font-family: monospace; + margin-left: 0.2em; +} +#repeatRange { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: center; + width: 100%; + margin: 0.4em 0; +} +#repeatRange input[type="text"] { + width: 46%; + border-radius: 4px; + padding-left: 4px; + border: none; + border: black solid 1px; + font-size: 0.9em; + color: var(--theme-text); + background: var(--theme-dark); +} +#repeatRange input[type="text"]:focus { + color: white; } \ No newline at end of file diff --git a/todo.md b/todo.md index b34b22c..4d3fb55 100644 --- a/todo.md +++ b/todo.md @@ -131,8 +131,4 @@ filter.connect(audioContext.destination); ``` 仍然可以通过audioElement控制整体的播放。需要注意audioContext的状态: 如果是suspend,则需要resume(); audioContext刚创建就是这个状态,此时调用audioElement.play()无效。 -但只要有osc被调用了start(),audioContext就会变成running。 - - -## icon -缺少favicon.ico \ No newline at end of file +但只要有osc被调用了start(),audioContext就会变成running。 \ No newline at end of file