├── favicon.ico ├── img ├── logo.png ├── logo-small.png ├── logo_text.png ├── bilibili-white.png └── github-mark-white.png ├── style ├── icon │ ├── iconfont.ttf │ ├── iconfont.woff │ ├── iconfont.woff2 │ └── iconfont.css ├── contextMenu.css ├── askUI.css ├── myRange.css ├── siderMenu.css ├── channelDiv.css └── style.css ├── dataProcess ├── AI │ ├── basicamt_44100.onnx │ ├── septimbre_44100.onnx │ ├── dist │ │ └── ort-wasm-simd.wasm │ ├── basicamt_worker.js │ ├── basicamt.js │ ├── septimbre.js │ ├── septimbre_worker.js │ ├── postprocess.js │ └── SpectralClustering.js ├── CQT │ ├── cqt.js │ └── cqt_worker.js ├── aboutANA.md ├── fft_real.js ├── ANA.js └── analyser.js ├── jsconfig.json ├── snapshot.js ├── app_midiplayer.js ├── app_hscrollbar.js ├── fakeAudio.js ├── contextMenu.js ├── app_spectrogram.js ├── app_keyboard.js ├── myRange.js ├── app_timebar.js ├── siderMenu.js ├── todo.md ├── app_audioplayer.js ├── saver.js ├── app_beatbar.js ├── beatBar.js ├── README.md ├── app_analyser.js ├── app_midiaction.js └── app_io.js /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/HEAD/favicon.ico -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/HEAD/img/logo.png -------------------------------------------------------------------------------- /img/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/HEAD/img/logo-small.png -------------------------------------------------------------------------------- /img/logo_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/HEAD/img/logo_text.png -------------------------------------------------------------------------------- /img/bilibili-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/HEAD/img/bilibili-white.png -------------------------------------------------------------------------------- /style/icon/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/HEAD/style/icon/iconfont.ttf -------------------------------------------------------------------------------- /img/github-mark-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/HEAD/img/github-mark-white.png -------------------------------------------------------------------------------- /style/icon/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/HEAD/style/icon/iconfont.woff -------------------------------------------------------------------------------- /style/icon/iconfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/HEAD/style/icon/iconfont.woff2 -------------------------------------------------------------------------------- /dataProcess/AI/basicamt_44100.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/HEAD/dataProcess/AI/basicamt_44100.onnx -------------------------------------------------------------------------------- /dataProcess/AI/septimbre_44100.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/HEAD/dataProcess/AI/septimbre_44100.onnx -------------------------------------------------------------------------------- /dataProcess/AI/dist/ort-wasm-simd.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/HEAD/dataProcess/AI/dist/ort-wasm-simd.wasm -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2023", 4 | "checkJs": false, // 不启用类型检查 因为用了很多流氓写法 5 | "allowJs": true, // 允许分析JS文件 6 | "noEmit": true // 不生成输出文件 7 | }, 8 | "exclude": ["node_modules"] 9 | } -------------------------------------------------------------------------------- /style/contextMenu.css: -------------------------------------------------------------------------------- 1 | .contextMenuCard { 2 | min-width: 100px; 3 | padding: 3px 5px; 4 | margin: 2px; 5 | background-color: #ffffff; 6 | border: 1px solid #dadce0; 7 | border-radius: 4px; 8 | box-shadow: 1px 1px 2px #878787; 9 | position: fixed; 10 | user-select: none; 11 | z-index: 100; 12 | } 13 | 14 | .contextMenuCard:focus { 15 | outline: none; 16 | } 17 | 18 | .contextMenuCard li { 19 | list-style: none; 20 | margin: 2px 0px; 21 | cursor: pointer; 22 | min-height: 26px; 23 | padding: 0px 8px; 24 | color: black; 25 | } 26 | 27 | .contextMenuCard li:hover { 28 | background-color: #f5f5f5; 29 | } -------------------------------------------------------------------------------- /dataProcess/AI/basicamt_worker.js: -------------------------------------------------------------------------------- 1 | // const ort_folder = 'https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/'; 2 | // self.importScripts(ort_folder + 'ort.wasm.min.js'); 3 | // ort.env.wasm.wasmPaths = ort_folder; 4 | self.importScripts('./postprocess.js'); 5 | self.importScripts('./dist/bundle.min.js') 6 | ort.env.wasm.wasmPaths = './dist/' 7 | 8 | const model = ort.InferenceSession.create( 9 | './basicamt_44100.onnx', 10 | { executionProviders: ['wasm'] } 11 | ); 12 | 13 | self.onmessage = function ({data}) { 14 | const tensorInput = new ort.Tensor('float32', data, [1, 1, data.length]); 15 | model.then((m) => { 16 | return m.run({ audio: tensorInput }); 17 | }).then((results) => { 18 | const note_events = createNotes( 19 | results.onset, results.frame, 20 | 0.22, 0.38 21 | ); 22 | self.postMessage(note_events); 23 | }).catch((e) => { 24 | // promise中的报错不会触发worker.onerror回调,即使这里throw了。所以只能用onmessage 25 | self.postMessage({ type: 'error', message: e.message }); 26 | }); 27 | }; -------------------------------------------------------------------------------- /dataProcess/AI/basicamt.js: -------------------------------------------------------------------------------- 1 | function basicamt(audioChannel) { 2 | let timeDomain = new Float32Array(audioChannel.getChannelData(0)); 3 | let audioLen = timeDomain.length; 4 | // 求和。不求平均是因为模型内部有归一化 5 | if (audioChannel.numberOfChannels !== 1) { 6 | for (let i = 1; i < audioChannel.numberOfChannels; i++) { 7 | const channelData = audioChannel.getChannelData(i); 8 | for (let j = 0; j < audioLen; j++) timeDomain[j] += channelData[j]; 9 | } 10 | } 11 | return new Promise((resolve, reject) => { 12 | const basicamtWorker = new Worker("./dataProcess/AI/basicamt_worker.js"); 13 | basicamtWorker.onmessage = ({data}) => { 14 | if (data.type === 'error') { 15 | console.error(data.message); 16 | reject("疑似因为音频过长导致内存不足!"); 17 | basicamtWorker.terminate(); 18 | } 19 | resolve(data); // 返回的是音符事件 20 | basicamtWorker.terminate(); 21 | }; 22 | basicamtWorker.onerror = (e) => { 23 | console.error(e.message); 24 | reject(e); 25 | basicamtWorker.terminate(); 26 | }; 27 | basicamtWorker.postMessage(timeDomain, [timeDomain.buffer]); 28 | }); 29 | } -------------------------------------------------------------------------------- /dataProcess/AI/septimbre.js: -------------------------------------------------------------------------------- 1 | function septimbre(audioChannel, k) { 2 | let timeDomain = new Float32Array(audioChannel.getChannelData(0)); 3 | let audioLen = timeDomain.length; 4 | // 求和。不求平均是因为模型内部有归一化 5 | if (audioChannel.numberOfChannels !== 1) { 6 | for (let i = 1; i < audioChannel.numberOfChannels; i++) { 7 | const channelData = audioChannel.getChannelData(i); 8 | for (let j = 0; j < audioLen; j++) timeDomain[j] += channelData[j]; 9 | } 10 | } 11 | return new Promise((resolve, reject) => { 12 | const septimbreWorker = new Worker("./dataProcess/AI/septimbre_worker.js"); 13 | septimbreWorker.onmessage = ({data}) => { 14 | if (data.type === 'error') { 15 | console.error(data.message); 16 | reject("疑似因为音频过长导致内存不足!"); 17 | septimbreWorker.terminate(); 18 | } 19 | resolve(data); // 返回的是音符事件 20 | septimbreWorker.terminate(); 21 | }; 22 | septimbreWorker.onerror = (e) => { 23 | console.error(e.message); 24 | reject(e); 25 | septimbreWorker.terminate(); 26 | }; 27 | septimbreWorker.postMessage(k); 28 | septimbreWorker.postMessage(timeDomain, [timeDomain.buffer]); 29 | }); 30 | } -------------------------------------------------------------------------------- /style/icon/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "iconfont"; /* Project id 4420000 */ 3 | src: url('iconfont.woff2?t=1742105746978') format('woff2'), 4 | url('iconfont.woff?t=1742105746978') format('woff'), 5 | url('iconfont.ttf?t=1742105746978') format('truetype'); 6 | } 7 | 8 | .iconfont { 9 | font-family: "iconfont" !important; 10 | font-size: 16px; 11 | font-style: normal; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | .icon-lock:before { 17 | content: "\e76f"; 18 | } 19 | 20 | .icon-unlock:before { 21 | content: "\e879"; 22 | } 23 | 24 | .icon-file:before { 25 | content: "\e63d"; 26 | } 27 | 28 | .icon-analysis:before { 29 | content: "\e600"; 30 | } 31 | 32 | .icon-pageTurns:before { 33 | content: "\e6d5"; 34 | } 35 | 36 | .icon-repeat:before { 37 | content: "\e628"; 38 | } 39 | 40 | .icon-mixer:before { 41 | content: "\e660"; 42 | } 43 | 44 | .icon-setting:before { 45 | content: "\e673"; 46 | } 47 | 48 | .icon-list:before { 49 | content: "\e603"; 50 | } 51 | 52 | .icon-pen-l:before { 53 | content: "\e64d"; 54 | } 55 | 56 | .icon-select:before { 57 | content: "\ea60"; 58 | } 59 | 60 | .icon-range:before { 61 | content: "\e63c"; 62 | } 63 | 64 | .icon-eyeslash-fill:before { 65 | content: "\e7aa"; 66 | } 67 | 68 | .icon-eye-fill:before { 69 | content: "\e7ab"; 70 | } 71 | 72 | .icon-close_volume:before { 73 | content: "\e6a0"; 74 | } 75 | 76 | .icon-volume:before { 77 | content: "\e6a3"; 78 | } 79 | 80 | -------------------------------------------------------------------------------- /style/askUI.css: -------------------------------------------------------------------------------- 1 | /* 依赖:style.css中的.card和.hvCenter*/ 2 | .request-cover { 3 | position: fixed; 4 | top: 0; 5 | right: 0; 6 | bottom: 0; 7 | left: 0; 8 | background-color: #6c6c6c78; 9 | z-index: 99; 10 | opacity: 1; 11 | transition: opacity 0.2s ease-in-out; 12 | } 13 | 14 | .request-cover .card { 15 | position: fixed; 16 | width: auto; 17 | height: auto; 18 | background-color: rgb(37, 38, 45);; 19 | padding: 1em; 20 | overflow: hidden; 21 | color: rgb(235, 235, 235); 22 | } 23 | 24 | .request-cover .title { 25 | font-size: 1.2em; 26 | font-weight: bold; 27 | color: white; 28 | } 29 | 30 | .card.hvCenter .layout { 31 | display: flex; 32 | justify-content: space-between; 33 | align-items: center; 34 | padding: 10px 8px 0 8px; 35 | margin: 0.2em; 36 | background: inherit; 37 | color: inherit; 38 | } 39 | 40 | .request-cover .card.hvCenter button { 41 | margin: 0 0.25em; 42 | border: none; 43 | border-radius: 0.2em; 44 | padding: 0.5em 1em; 45 | cursor: pointer; 46 | outline: none; 47 | color: inherit; 48 | } 49 | .request-cover .ui-confirm { 50 | flex: 1; 51 | background: rgb(60, 87, 221); 52 | color: white; 53 | } 54 | .request-cover .ui-cancel { 55 | flex: 1; 56 | background-color: #2e3039; 57 | color: white; 58 | } 59 | 60 | .card.hvCenter input, 61 | .card.hvCenter select { 62 | flex: 1; 63 | background: inherit; 64 | border: 1px solid rgb(54, 58, 69); 65 | border-radius: 0.25em; 66 | color: inherit; 67 | margin: 0 0.8em; 68 | padding-left: 0.6em; 69 | } 70 | 71 | /* 进度条 */ 72 | .porgress-track { 73 | display: block; 74 | width: 16em; 75 | height: 1em; 76 | border-radius: 2em; 77 | background-color: #1e1f24; 78 | overflow: hidden; 79 | } 80 | 81 | .porgress-value { 82 | width: 0; 83 | height: inherit; 84 | background-color: #761fc8; 85 | } -------------------------------------------------------------------------------- /snapshot.js: -------------------------------------------------------------------------------- 1 | // 基于快照的撤销重做数据结构 2 | // 为了不改变数组大小减小开销,使用循环队列 3 | class Snapshot extends Array { 4 | /** 5 | * 新建快照栈 6 | * @param {Number} maxLen 快照历史数 7 | * @param {*} iniState 初始状态 8 | */ 9 | constructor(maxLen, iniState = '') { 10 | super(maxLen); 11 | // 模型位置 从1开始计数 12 | this.now = 1; 13 | this.size = 1; 14 | this[0] = iniState; 15 | // 实际位置 16 | this.pointer = 0; 17 | } 18 | /** 19 | * 增加快照。在当前时间点上延展新的分支,并抛弃老的分支 20 | * @param {*} snapshot 快照 建议是JSON字符串 21 | */ 22 | add(snapshot) { 23 | if (this.now < this.length) this.size = ++this.now; // 没满 24 | this.pointer = (this.pointer + 1) % this.length; // 目标位置,直接覆盖 25 | this[this.pointer] = snapshot; 26 | } 27 | /** 28 | * 回到上一个快照状态,相当于撤销 29 | * @returns 上一刻的快照。如果无法回退则返回null 30 | */ 31 | undo() { 32 | if (this.now <= 1) return null; 33 | this.now--; 34 | this.pointer = (this.pointer + this.length - 1) % this.length; 35 | return this[this.pointer]; 36 | } 37 | /** 38 | * 重新回到下一个状态,相当于重做 39 | * @returns 下一刻的快照。如果下一状态则返回null 40 | */ 41 | redo() { 42 | if (this.now >= this.size) return null; 43 | this.now++; 44 | this.pointer = (this.pointer + 1) % this.length; 45 | return this[this.pointer]; 46 | } 47 | /** 48 | * 查看上一个快照状态,相当于撤销但不改变当前状态 49 | * @returns 上一个状态的快照。如果无法回退则返回null 50 | */ 51 | lastState() { 52 | if (this.now <= 1) return null; 53 | return this[(this.pointer + this.length - 1) % this.length]; 54 | } 55 | /** 56 | * 查看下一个快照状态,相当于重做但不改变当前状态 57 | * @returns 下一个状态的快照。如果下一状态则返回null 58 | */ 59 | nextState() { 60 | if (this.now >= this.size) return null; 61 | return this[(this.pointer + 1) % this.length]; 62 | } 63 | nowState() { 64 | return this[this.pointer]; 65 | } 66 | } -------------------------------------------------------------------------------- /app_midiplayer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 管理用户绘制的midi的播放 3 | * @param {App} parent 4 | */ 5 | function _MidiPlayer(parent) { 6 | this.priorT = 1000 / 59; // 实际稳定在60帧,波动极小 7 | this.realT = 1000 / 59; 8 | this._last = performance.now(); 9 | this.lastID = -1; 10 | this.restart = () => { 11 | // 需要-1,防止当前时刻开始的音符不被播放 12 | this.lastID = ((parent.AudioPlayer.audio.currentTime * 1000 / parent.dt) | 0) - 1; 13 | }; 14 | this.update = () => { 15 | // 一阶预测 16 | let tnow = performance.now(); 17 | // 由于requestAnimationFrame在离开界面的时候会停止,所以要设置必要的限定 18 | if (tnow - this._last < (this.priorT << 1)) this.realT = 0.2 * (tnow - this._last) + 0.8 * this.realT; // IIR低通滤波 19 | this._last = tnow; 20 | if (parent.AudioPlayer.audio.paused) return; 21 | let predictT = parent.time + 0.5 * (this.realT + this.priorT); // 先验和实测的加权和 22 | let predictID = (predictT / parent.dt) | 0; 23 | // 寻找(mp.lastID, predictID]之间的音符 24 | const m = parent.MidiAction.midi; 25 | if (m.length > 0) { // 二分查找要求长度大于0 26 | let lastAt = m.length; 27 | { // 二分查找到第一个x1>mp.lastID的音符 28 | let l = 0, r = lastAt - 1; 29 | while (l <= r) { 30 | let mid = (l + r) >> 1; 31 | if (m[mid].x1 > this.lastID) { 32 | r = mid - 1; 33 | lastAt = mid; 34 | } else l = mid + 1; 35 | } 36 | } 37 | for (; lastAt < m.length; lastAt++) { 38 | const nt = m[lastAt]; 39 | if (nt.x1 > predictID) break; 40 | if (parent.MidiAction.channelDiv.channel[nt.ch].mute) continue; 41 | parent.synthesizer.play({ 42 | id: nt.ch, 43 | f: parent.Keyboard.freqTable[nt.y], 44 | v: nt.v, // 用户创建的音符不可单独调整音量,为undefined,会使用默认值 45 | t: parent.AudioPlayer.audio.currentTime - (nt.x1 * parent.dt) / 1000, 46 | last: (nt.x2 - nt.x1) * parent.dt / 1000 47 | }); 48 | } 49 | } 50 | this.lastID = predictID; 51 | } 52 | } -------------------------------------------------------------------------------- /dataProcess/CQT/cqt.js: -------------------------------------------------------------------------------- 1 | // 开启CQT的Worker线程,因为CQT是耗时操作,所以放在Worker线程中 2 | function cqt(audioBuffer, tNum, channel, fmin) { 3 | var audioChannel; 4 | switch (channel) { 5 | case 0: audioChannel = [audioBuffer.getChannelData(0)]; break; 6 | case 1: audioChannel = [audioBuffer.getChannelData(audioBuffer.numberOfChannels - 1)]; break; 7 | case 2: { // L+R 8 | let length = audioBuffer.length; 9 | const timeDomain = new Float32Array(audioBuffer.getChannelData(0)); 10 | if (audioBuffer.numberOfChannels > 1) { 11 | let channelData = audioBuffer.getChannelData(1); 12 | for (let i = 0; i < length; i++) timeDomain[i] = (timeDomain[i] + channelData[i]) * 0.5; 13 | } audioChannel = [timeDomain]; break; 14 | } 15 | case 3: { // L-R 16 | let length = audioBuffer.length; 17 | const timeDomain = new Float32Array(audioBuffer.getChannelData(0)); 18 | if (audioBuffer.numberOfChannels > 1) { 19 | let channelData = audioBuffer.getChannelData(1); 20 | for (let i = 0; i < length; i++) timeDomain[i] = (timeDomain[i] - channelData[i]) * 0.5; 21 | } audioChannel = [timeDomain]; break; 22 | } 23 | default: { // cqt(L) + cqt(R) 24 | if (audioBuffer.numberOfChannels > 1) { 25 | audioChannel = [audioBuffer.getChannelData(0), audioBuffer.getChannelData(1)]; 26 | } else { 27 | audioChannel = [audioBuffer.getChannelData(0)]; 28 | } break; 29 | } 30 | } 31 | return new Promise((resolve, reject) => { 32 | const worker = new Worker("./dataProcess/CQT/cqt_worker.js"); 33 | worker.onerror = (e) => { 34 | reject(e); 35 | worker.terminate(); 36 | }; 37 | worker.onmessage = ({ data }) => { 38 | resolve(data); 39 | worker.terminate(); 40 | }; 41 | worker.postMessage({ 42 | audioChannel: audioChannel, 43 | sampleRate: audioBuffer.sampleRate, 44 | hop: Math.round(audioBuffer.sampleRate / tNum), 45 | fmin: fmin, 46 | }, audioChannel.map(x => x.buffer)); 47 | }); 48 | } -------------------------------------------------------------------------------- /app_hscrollbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 配合scroll的滑动条 3 | * @param {App} parent 4 | */ 5 | function _HscrollBar(parent) { 6 | this.refreshPosition = () => { // 在parent.scroll2中调用 7 | let all = parent._width * parent._xnum - parent.spectrum.width; 8 | let pos = (track.offsetWidth - thumb.offsetWidth) * parent.scrollX / all; 9 | thumb.style.left = pos + 'px'; 10 | }; 11 | this.refreshSize = () => { // 需要在parent.xnum parent.width改变之后调用 在二者的setter中调用 12 | track.style.display = 'block'; 13 | let p = Math.min(1, parent.spectrum.width / (parent._width * parent._xnum)); // 由于有min存在所以xnum即使为零也能工作 14 | let nw = p * track.offsetWidth; 15 | thumb.style.width = Math.max(nw, 10) + 'px'; // 限制最小宽度 16 | }; 17 | 18 | const track = document.getElementById('scrollbar-track'); 19 | const thumb = document.getElementById('scrollbar-thumb'); 20 | const thumbMousedown = (event) => { // 滑块跟随鼠标 21 | event.stopPropagation(); // 防止触发track的mousedown 22 | const startX = event.clientX; 23 | const thumbLeft = thumb.offsetLeft; 24 | const moveThumb = (event) => { 25 | let currentX = event.clientX - startX + thumbLeft; 26 | let maxThumbLeft = track.offsetWidth - thumb.offsetWidth; 27 | let maxScrollX = parent._width * parent._xnum - parent.spectrum.width; 28 | parent.scroll2(currentX / maxThumbLeft * maxScrollX, parent.scrollY); 29 | } 30 | const stopMoveThumb = () => { 31 | document.removeEventListener("mousemove", moveThumb); 32 | document.removeEventListener("mouseup", stopMoveThumb); 33 | } 34 | document.addEventListener("mousemove", moveThumb); 35 | document.addEventListener("mouseup", stopMoveThumb); 36 | }; 37 | const trackMousedown = (e) => { // 滑块跳转 38 | e.stopPropagation(); 39 | let maxScrollX = parent._width * parent._xnum - parent.spectrum.width; 40 | let maxThumbLeft = track.offsetWidth - thumb.offsetWidth; 41 | let p = (e.offsetX - (thumb.offsetWidth >> 1)) / maxThumbLeft; // nnd 减法优先级比位运算高 42 | parent.scroll2(p * maxScrollX, parent.scrollY); 43 | }; 44 | thumb.addEventListener('mousedown', thumbMousedown); 45 | track.addEventListener('mousedown', trackMousedown); 46 | } -------------------------------------------------------------------------------- /style/myRange.css: -------------------------------------------------------------------------------- 1 | .myrange { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | display: inline-flex; 6 | align-items: center; 7 | color: inherit; 8 | } 9 | 10 | .myrange span { 11 | color: inherit; 12 | cursor: pointer; 13 | user-select: none; 14 | } 15 | 16 | /* === 各浏览器中统一滑动条 === */ 17 | .myrange input[type="range"] { 18 | -webkit-appearance: none; 19 | appearance: none; 20 | margin: 0 5px 0 0; 21 | padding: 0; 22 | outline: none; 23 | border: none; 24 | background: rgb(60, 87, 221); 25 | height: 6px; 26 | border-radius: 10px; 27 | transform: translateY(1px); 28 | } 29 | 30 | .myrange input[type="range"]::-webkit-slider-thumb { 31 | -webkit-appearance: none; 32 | background: rgb(60, 87, 221); 33 | width: 16px; 34 | height: 16px; 35 | border: none; 36 | border-radius: 50%; 37 | box-shadow: 0px 3px 6px 0px rgba(255, 255, 255, 0.15); 38 | } 39 | 40 | .myrange input[type="range"]::-moz-range-thumb { 41 | background: rgb(60, 87, 221); 42 | width: 16px; 43 | height: 16px; 44 | border: none; 45 | border-radius: 50%; 46 | box-shadow: 0px 3px 6px 0px rgba(255, 255, 255, 0.15); 47 | } 48 | /* 解决Firefox中虚线显示在周围的问题 */ 49 | .myrange input[type="range"]::-moz-focus-outer { 50 | border: 0; 51 | } 52 | 53 | .myrange input[type="range"]:active::-webkit-slider-thumb { 54 | box-shadow: 0px 5px 10px -2px rgba(0, 0, 0, 0.3); 55 | } 56 | /* === 滑动条end === */ 57 | 58 | /* 隐藏数值的滑动条 */ 59 | .hidelabelrange { 60 | position: relative; 61 | } 62 | 63 | .hidelabelrange span.thelabel { 64 | position: absolute; 65 | z-index: 1; 66 | padding: 3px 6px; 67 | background-color: #373943; 68 | box-shadow: 0px 0px 0px 1px rgba(255, 255, 255, 0.15); 69 | color: white; 70 | font-size: 12px; 71 | border-radius: 4px; 72 | top: 7px; 73 | transform: translateX(-50%) translateY(50%); 74 | } 75 | 76 | .hidelabelrange span.thelabel::after { 77 | content: ''; 78 | position: absolute; 79 | z-index: -1; 80 | width: 10px; 81 | height: 10px; 82 | background-color: #373943; 83 | top: -3px; 84 | left: 50%; 85 | transform: translateX(-50%) rotate(45deg); 86 | } 87 | 88 | .fullRange { 89 | flex: 1; 90 | width: 100%; 91 | input { 92 | width: 100%; 93 | } 94 | } -------------------------------------------------------------------------------- /style/siderMenu.css: -------------------------------------------------------------------------------- 1 | .siderTabs { 2 | --tab-width: 48px; 3 | width: var(--tab-width); 4 | height: 100%; 5 | background-color: var(--theme-dark); 6 | position: relative; 7 | z-index: 1; 8 | } 9 | 10 | .siderTab { 11 | color: var(--theme-text); 12 | width: var(--tab-width); 13 | height: var(--tab-width); 14 | position: relative; 15 | cursor: pointer; 16 | } 17 | 18 | /* 图标位置与大小 */ 19 | .siderTab::before { 20 | position: absolute; 21 | top: 50%; 22 | left: 50%; 23 | transform: translate(-50%, -50%); 24 | font-size: calc(var(--tab-width) * 0.5); 25 | } 26 | 27 | .siderTab.selected { 28 | color: white; 29 | background-color: var(--theme-middle); 30 | border-left: white solid 2px; 31 | } 32 | .siderTab.selected { 33 | color: white; 34 | background-color: var(--theme-middle); 35 | border-left: white solid 2px; 36 | } 37 | 38 | 39 | .siderTab:hover { 40 | color: white; 41 | } 42 | 43 | .siderTab::after { 44 | content: attr(data-name); 45 | font-size: calc(var(--tab-width) * 0.25); 46 | color: var(--theme-text); 47 | background-color: var(--theme-light); 48 | white-space: nowrap; 49 | padding: 4px 8px; 50 | position: absolute; 51 | z-index: 3; 52 | top: calc(var(--tab-width) * 0.5); 53 | left: calc(var(--tab-width) + 2px); 54 | transform: translateY(-50%); 55 | border-radius: 4px; 56 | border: var(--theme-dark) solid 2px; 57 | display: none; 58 | } 59 | .siderTab:hover::after { 60 | display: block; 61 | } 62 | 63 | /* 展示内容 */ 64 | .siderContent { 65 | background-color: transparent; 66 | width: 206px; 67 | height: 100%; 68 | overflow: hidden; 69 | } 70 | 71 | .siderBar { 72 | opacity: 0; 73 | transition: 0.3s; 74 | width: 4px; 75 | margin-left: -2px; 76 | margin-right: -2px; 77 | height: 100%; 78 | background-color: royalblue; 79 | cursor: ew-resize; 80 | user-select: none; 81 | position: relative; 82 | z-index: 2; 83 | } 84 | 85 | .siderBar:hover { 86 | opacity: 1; 87 | } 88 | 89 | .siderContent .siderItem { 90 | background-color: var(--theme-middle); 91 | width: 100%; 92 | height: 100%; 93 | } 94 | 95 | /* siderItem不提供内边距,用paddingbox类实现 */ 96 | .paddingbox { 97 | box-sizing: border-box; 98 | padding: 0.4em 0.8em; 99 | overflow: auto; 100 | } 101 | 102 | .siderItem h3 { 103 | margin: 0; 104 | padding: 0; 105 | } -------------------------------------------------------------------------------- /fakeAudio.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 模拟没有声音、时长可变的Audio。模拟了: 3 | * 设置currentTime跳转播放位置 4 | * 设置playbackRate改变播放速度 5 | * play()和pause()控制播放 6 | * 到duration后自动停止,触发onended 7 | * duration改变后,触发ondurationchange 8 | * 构造后,下一个时刻触发ondurationchange和onloadeddata 9 | */ 10 | function FakeAudio(duration = Infinity) { 11 | this.readyState = 4; 12 | this.paused = true; 13 | this.volume = 0; // 废物属性 14 | this.loop = false; // 是否循环。和下面的_loop不一样 15 | this._currentTime = 0; 16 | this._duration = duration; 17 | this._playbackRate = 1; 18 | this._loop = 0; 19 | this._beginTime = 0; 20 | this._lastTime = 0; 21 | this.onended = Function.prototype; 22 | this.onloadeddata = Function.prototype; 23 | this.ondurationchange = Function.prototype; 24 | const update = (t) => { 25 | let dt = t - this._beginTime; 26 | this._currentTime = this._lastTime + dt * this._playbackRate / 1000; 27 | if (this._currentTime >= this._duration) { 28 | if (this.loop) { 29 | this.currentTime = 0; 30 | } else { 31 | this.pause(); 32 | this.onended(); 33 | return; 34 | } 35 | } 36 | this._loop = requestAnimationFrame(update); 37 | }; 38 | this.pause = () => { 39 | cancelAnimationFrame(this._loop); 40 | this._lastTime = this._currentTime; 41 | this.paused = true; 42 | } 43 | this.play = () => { 44 | if (this._currentTime >= this._duration) this._lastTime = this._currentTime = 0; 45 | this._beginTime = document.timeline.currentTime; 46 | this._loop = requestAnimationFrame(update); 47 | this.paused = false; 48 | } 49 | Object.defineProperty(this, 'currentTime', { 50 | get: function () { return this._currentTime; }, 51 | set: function (t) { 52 | if (t < 0) t = 0; 53 | if (t > this._duration) t = this._duration; 54 | this._lastTime = this._currentTime = t; 55 | this._beginTime = document.timeline.currentTime; 56 | } 57 | }); 58 | Object.defineProperty(this, 'playbackRate', { 59 | get: function () { return this._playbackRate; }, 60 | set: function (r) { 61 | this._playbackRate = r; 62 | this.currentTime = this._currentTime; 63 | } 64 | }); 65 | Object.defineProperty(this, 'duration', { 66 | get: function () { return this._duration; }, 67 | set: function (d) { 68 | if (d < 0) return; 69 | this._duration = d; 70 | this.ondurationchange(); 71 | } 72 | }); 73 | // 给设置handler留时间 74 | setTimeout(() => { 75 | this.ondurationchange(); 76 | this.onloadeddata(); 77 | }, 0); 78 | } -------------------------------------------------------------------------------- /style/channelDiv.css: -------------------------------------------------------------------------------- 1 | /* 可拖拽的列表 */ 2 | .drag_list { 3 | --bg-color: var(--theme-middle); 4 | --li-hover: var(--theme-light); 5 | } 6 | .drag_list .takeplace { 7 | height: 8em; 8 | } 9 | /* 列表本体 */ 10 | ul.drag_list { 11 | height: 100%; 12 | list-style: none; 13 | background-color: var(--bg-color); 14 | padding: 0; 15 | margin: 0; 16 | overflow: auto; 17 | } 18 | ul.drag_list::-webkit-scrollbar { 19 | width: 12px; 20 | } 21 | ul.drag_list::-webkit-scrollbar-thumb { 22 | background-color: rgb(50, 53, 62); 23 | border: 3px solid rgb(37, 38, 45); 24 | border-radius: 6px; 25 | } 26 | ul.drag_list::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { 27 | background-color: rgb(37, 38, 45); 28 | } 29 | 30 | ul.drag_list li.drag_list-item { 31 | width: 100%; 32 | transition: 0.3s; 33 | } 34 | ul.drag_list li.moving { 35 | position: relative; 36 | } 37 | ul.drag_list li.moving::before { 38 | content: ""; 39 | position: absolute; 40 | z-index: 2; 41 | top: 0; 42 | left: 0; 43 | right: 0; 44 | bottom: 0; 45 | background-color: var(--bg-color); 46 | border: 0.125em dashed #ccc; 47 | border-radius: 0.3em; /* 和列表项保持一致 */ 48 | margin: 0 0.6em; 49 | } 50 | 51 | /* 列表项 */ 52 | .channel-Container { 53 | position: relative; 54 | background-color: transparent; 55 | border-radius: 0.32em; 56 | margin: 0.6em; 57 | border-left: 0.5em solid; 58 | border-color: var(--tab-color); 59 | padding: 0.4em; 60 | } 61 | .channel-Container:hover { 62 | background-color: var(--li-hover); 63 | } 64 | .channel-Container.selected { 65 | background-color: var(--li-hover); 66 | } 67 | /* 序号 */ 68 | .channel-Container::after { 69 | content: attr(data-tab-index); 70 | position: absolute; 71 | bottom: 0.32em; 72 | right: 0.32em; 73 | background-color: transparent; 74 | font-size: 0.5em; 75 | color: #a5abba; 76 | z-index: 1; 77 | } 78 | .channel-Container .upper { 79 | display: flex; 80 | flex-direction: row; 81 | align-items: center; 82 | } 83 | /* 音轨名 */ 84 | .channel-Name { 85 | overflow: hidden; /* 隐藏超出容器的内容 */ 86 | white-space: nowrap; /* 不换行 */ 87 | text-overflow: ellipsis;/* 超出部分用省略号表示 */ 88 | flex: 1; 89 | font-size: 1em; 90 | font-weight: bold; 91 | color: white; 92 | cursor: pointer; 93 | } 94 | /* 快捷按钮 */ 95 | .channel-Tab { 96 | display: flex; /* 消除子block之间的间隙 */ 97 | flex: 0 1 auto; 98 | } 99 | .upper .tab { 100 | border-radius: 50%; 101 | width: 1.8em; 102 | height: 1.8em; 103 | text-align: center; 104 | display: inline; 105 | background-color: transparent; 106 | border: none; 107 | color: var(--tab-color); 108 | } 109 | .channel-Container .tab:hover { 110 | background-color: #363944; 111 | } 112 | /* 乐器选择 */ 113 | .channel-Instrument { 114 | display: inline-block; 115 | font-size: 0.9em; 116 | color: white; 117 | overflow: hidden; 118 | } -------------------------------------------------------------------------------- /contextMenu.js: -------------------------------------------------------------------------------- 1 | class ContextMenu { 2 | /** 3 | * 创建菜单 4 | * @param {Array} items [{ 5 | * name: "菜单项", 6 | * callback: (e_father, e_self) => { // 点击菜单项时调用的函数,传参是(触发右键菜单的事件,点击本项的事件) 7 | * return false/true; 8 | * }, // 返回false(或不返回)表示删除菜单,返回true表示不删除菜单 9 | * onshow: function (e) { // 在菜单项显示前调用,传参是触发右键菜单的事件 10 | * // this指向菜单项对象,可以修改其属性 11 | * return true/false; 12 | * }, // 返回true/false控制本项是否显示 13 | * event: "click" // 确认触发本项的事件,默认是click 14 | * },...] 15 | * @param {Array} mustShow 如果菜单项为空,是否显示 16 | */ 17 | constructor(items = [], mustShow = false) { 18 | this.items = items; 19 | this.mustShow = mustShow; 20 | } 21 | 22 | addItem(name, callback, onshow = null, event = "click") { 23 | let existingItem = this.items.find(item => item.name === name); 24 | if (existingItem) existingItem.callback = callback; 25 | else this.items.push({ name: name, callback: callback, onshow: onshow, event: event }); 26 | } 27 | removeItem(name) { 28 | for (let i = 0; i < this.items.length; i++) { 29 | if (this.items[i].name === name) { 30 | this.items.splice(i, 1); 31 | break; 32 | } 33 | } 34 | } 35 | 36 | show(e) { 37 | const contextMenuCard = document.createElement('ul'); 38 | contextMenuCard.classList.add('contextMenuCard'); 39 | contextMenuCard.oncontextmenu = () => false; // 禁用右键菜单 40 | this.items.forEach(item => { 41 | if (item.onshow) if (!item.onshow(e)) return; 42 | const listItem = document.createElement('li'); 43 | listItem.innerHTML = item.name; // 从textContent改为innerHTML,可以使用html标签嵌套 44 | listItem.addEventListener(item.event || 'click', (e_self) => { 45 | if (!item.callback(e, e_self)) { 46 | contextMenuCard.onblur = null; // 如果没有这行,onblur会在contextMenuCard被item删除后再次触发删除,引发报错 47 | contextMenuCard.remove(); 48 | } 49 | }); 50 | contextMenuCard.appendChild(listItem); 51 | }); 52 | if (contextMenuCard.children.length === 0 && !this.mustShow) return; 53 | 54 | contextMenuCard.style.top = `${e.clientY}px`; 55 | contextMenuCard.style.left = `${e.clientX}px`; 56 | 57 | // 添加blur事件监听器 58 | contextMenuCard.tabIndex = -1; // 使元素可以接收焦点 59 | contextMenuCard.onblur = (e) => { 60 | // 如果在contextMenuCard内部点击,就不删除contextMenuCard 61 | if (e.relatedTarget && e.relatedTarget.classList.contains('contextMenuCard')) { 62 | e.stopPropagation(); 63 | return; 64 | } 65 | contextMenuCard.remove(); 66 | } 67 | setTimeout(() => { 68 | document.body.appendChild(contextMenuCard); 69 | // 使元素立即获取焦点(要设置css:focue属性:outline:none;) 70 | contextMenuCard.focus(); 71 | }, 0); // 延时是因为让show可以被mousedown事件调用(否则mousedown触发后再触发contextmenu将导致菜单消失) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app_spectrogram.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 管理频谱显示 3 | * @param {App} parent 4 | */ 5 | function _Spectrogram(parent) { 6 | this.colorStep1 = 100; 7 | this.colorStep2 = 240; 8 | this.multiple = parseFloat(document.getElementById('multiControl').value); // 幅度的倍数 9 | this._spectrogram = null; 10 | this.mask = '#25262daa'; 11 | this.getColor = (value) => { // 0-step1,是蓝色的亮度从0变为50%;step1-step2,是颜色由蓝色变为红色;step2-255,保持红色 12 | value = value || 0; 13 | let hue = 0, lightness = 50; // Red hue 14 | if (value <= this.colorStep1) { 15 | hue = 240; // Blue hue 16 | lightness = (value / this.colorStep1) * 50; // Lightness from 0% to 50% 17 | } else if (value <= this.colorStep2) { 18 | hue = 240 - ((value - this.colorStep1) / (this.colorStep2 - this.colorStep1)) * 240; 19 | } return `hsl(${hue}, 100%, ${lightness}%)`; 20 | }; 21 | this.update = () => { // 不能用画图的坐标去限制,因为数据可能填不满画布 必须用id 22 | const canvas = parent.spectrum; 23 | const ctx = parent.spectrum.ctx; 24 | let rectx = parent.rectXstart; 25 | for (let x = parent.idXstart; x < parent.idXend; x++) { 26 | const s = this._spectrogram[x]; 27 | let recty = parent.rectYstart; 28 | for (let y = parent.idYstart; y < parent.idYend; y++) { 29 | ctx.fillStyle = this.getColor(s[y] * this.multiple); 30 | ctx.fillRect(rectx, recty, parent._width, -parent._height); 31 | recty -= parent._height; 32 | } 33 | rectx += parent._width; 34 | } 35 | let w = canvas.width - rectx; 36 | // 画分界线 37 | ctx.strokeStyle = "#FFFFFF"; 38 | ctx.beginPath(); 39 | for (let y = (((parent.idYstart / 12) | 0) + 1) * 12, 40 | rectY = canvas.height - parent.height * y + parent.scrollY, 41 | dy = -12 * parent.height; 42 | y < parent.idYend; y += 12, rectY += dy) { 43 | ctx.moveTo(0, rectY); 44 | ctx.lineTo(canvas.width, rectY); 45 | } ctx.stroke(); 46 | // 填涂剩余部分 47 | if (w > 0) { 48 | ctx.fillStyle = '#25262d'; 49 | ctx.fillRect(rectx, 0, w, canvas.height); 50 | } 51 | // 铺底色以凸显midi音符 52 | ctx.fillStyle = this.mask; 53 | ctx.fillRect(0, 0, rectx, canvas.height); 54 | // 更新note 55 | ctx.fillStyle = "#ffffff4f"; 56 | rectx = canvas.height - (parent.Keyboard.highlight - 24) * parent._height + parent.scrollY; 57 | ctx.fillRect(0, rectx, canvas.width, -parent._height); 58 | }; 59 | 60 | Object.defineProperty(this, 'spectrogram', { 61 | get: function() { 62 | return this._spectrogram; 63 | }, 64 | set: function(s) { 65 | if (!s) { 66 | this._spectrogram = null; 67 | parent.xnum = 0; 68 | } else { 69 | this._spectrogram = s; 70 | parent.xnum = s.length; 71 | parent.scroll2(); 72 | } 73 | } 74 | }); 75 | 76 | Object.defineProperty(this, 'Alpha', { 77 | get: function() { 78 | return parseInt(this.mask.substring(7), 16); 79 | }, 80 | set: function(a) { 81 | a = Math.min(255, Math.max(a | 0, 0)); 82 | this.mask = '#25262d' + a.toString(16).padStart(2, '0'); 83 | } 84 | }); 85 | } -------------------------------------------------------------------------------- /dataProcess/AI/septimbre_worker.js: -------------------------------------------------------------------------------- 1 | // const ort_folder = 'https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/'; 2 | // self.importScripts(ort_folder + 'ort.wasm.min.js'); 3 | // ort.env.wasm.wasmPaths = ort_folder; 4 | self.importScripts('./postprocess.js'); 5 | self.importScripts('./SpectralClustering.js'); 6 | self.importScripts('./dist/bundle.min.js') 7 | ort.env.wasm.wasmPaths = './dist/' 8 | 9 | const model = ort.InferenceSession.create( 10 | './septimbre_44100.onnx', 11 | { executionProviders: ['wasm'] } 12 | ); 13 | 14 | self.onmessage = function ({data}) { 15 | if (typeof data === 'number') { 16 | // 接收到的是k 17 | self.k = data; 18 | return; 19 | } 20 | const tensorInput = new ort.Tensor('float32', data, [1, 1, data.length]); 21 | model.then((m) => { 22 | return m.run({ audio: tensorInput }); 23 | }).then((results) => { 24 | const note_events = createNotes( 25 | results.onset, results.frame, 26 | 0.31, 0.35 27 | ); 28 | console.time('clusterNotes'); 29 | const clustered_notes = clusterNotes( 30 | note_events, 31 | results.embedding, 32 | results.frame, 33 | self.k || 2 34 | ); 35 | console.timeEnd('clusterNotes'); 36 | self.postMessage(clustered_notes); 37 | }).catch((e) => { 38 | // promise中的报错不会触发worker.onerror回调,即使这里throw了。所以只能用onmessage 39 | self.postMessage({ type: 'error', message: e.message }); 40 | }); 41 | }; 42 | 43 | 44 | function clusterNotes(note_events, embTensor, frameTensor, k=2) { 45 | // 模型中已经对onset和frame进行归一化了 46 | const raw_frameData = frameTensor.cpuData; 47 | const frameDim = frameTensor.dims; // [1, 84, frames] 48 | const raw_embData = embTensor.cpuData; 49 | const embDim = embTensor.dims; // [1, 12, 84, frames] 50 | 51 | const frameNum = frameDim[2]; 52 | const noteNum = frameDim[1]; 53 | 54 | const frameData = Array(noteNum); 55 | for (let i = 0; i < noteNum; i++) { 56 | // 和raw共享内存 57 | frameData[i] = new Float32Array(raw_frameData.buffer, i * frameNum * 4, frameNum); 58 | } 59 | 60 | const spaceSize = noteNum * frameNum; 61 | function getEmbedding(note, time, emb) { 62 | // embDim: [1, 12, 84, frames] 63 | // raw_embData: Float32Array 64 | for (let i = 0; i < embDim[1]; i++) { 65 | // 计算在一维数组中的索引 66 | // 索引 = i * noteNum * frameNum + note * frameNum + time 67 | emb[i] = raw_embData[ 68 | i * spaceSize + note * frameNum + time 69 | ]; 70 | } 71 | return emb; 72 | } 73 | 74 | const embeddings = []; 75 | const buffer = new Float32Array(embDim[1]); 76 | for (const note_event of note_events) { 77 | const { onset, offset, note } = note_event; 78 | const emb = new Float32Array(embDim[1]); 79 | // 取音符中间的embedding 80 | for (let t = onset; t < offset; t++) { 81 | const e = getEmbedding(note - 24, t, buffer); 82 | const frame = frameData[note - 24][t]; 83 | const w = frame * frame; // 用frame的值作为权重 84 | for (let i = 0; i < embDim[1]; i++) { 85 | emb[i] += e[i] * w; 86 | } 87 | } 88 | let norm = 0.0; 89 | for (let i = 0; i < embDim[1]; i++) norm += emb[i] * emb[i]; 90 | norm = Math.sqrt(norm); 91 | for (let i = 0; i < embDim[1]; i++) emb[i] /= norm; 92 | embeddings.push(emb); 93 | } 94 | 95 | const labels = SpectralClustering(embeddings, k); 96 | const clustered_notes = Array.from({ length: k }, () => []); 97 | for (let i = 0; i < note_events.length; i++) { 98 | clustered_notes[labels[i]].push(note_events[i]); 99 | } 100 | return clustered_notes; 101 | } -------------------------------------------------------------------------------- /app_keyboard.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * 左侧键盘 5 | * @param {App} parent 6 | */ 7 | function _Keyboard(parent) { 8 | /** 9 | * 选中了哪个音,音的编号以midi协议为准(C1序号为24) 10 | * 11 | * 更新链: 'onmousemove' -> parent.mouseY setter -> this.highlight 12 | */ 13 | this.highlight = -1; 14 | this.freqTable = new FreqTable(440); // 在parent.Analyser.stft中更新 15 | 16 | // 以下为画键盘所需 17 | const _idchange = new Int8Array([2, 2, 1, 2, 2, 2, -10, 2, 3, 2, 2, 2]); // id变化 18 | const _ychange = new Float32Array(12); // 纵坐标变化,随this.height一起变化 19 | this.setYchange = (h) => { // 需注册到parent.height setter中 且需要一次立即的更新(在parent中实现) 20 | _ychange.set([ 21 | -1.5 * h, -2 * h, -1.5 * h, -1.5 * h, -2 * h, -2 * h, -1.5 * h, 22 | -2 * h, -3 * h, -2 * h, -2 * h, -2 * h 23 | ]); 24 | }; 25 | 26 | /** 27 | * 仅当: 视野垂直变化 或 this.highlight 更改 时需要更新 28 | * 是否更新的判断 交给parent完成 29 | */ 30 | this.update = () => { 31 | const ctx = parent.keyboard.ctx; 32 | const w = parent.keyboard.width; 33 | const w2 = w * 0.618; 34 | ctx.fillStyle = '#fff'; 35 | ctx.fillRect(0, 0, w, parent.keyboard.height); 36 | 37 | let noteID = parent.idYstart + 24; // 最下面对应的音的编号 38 | const note = noteID % 12; // 一个八度中的第几个音 39 | let baseY = parent.rectYstart + note * parent._height; // 这个八度左下角的y坐标 40 | noteID -= note; // 这个八度C的编号 41 | 42 | while (true) { 43 | ctx.beginPath(); // 必须写循环内 44 | ctx.fillStyle = 'orange'; 45 | for (let i = 0, rectY = baseY, id = noteID; i < 7 & rectY > 0; i++) { // 画白键 46 | let dy = _ychange[i]; 47 | if (this.highlight == id) ctx.fillRect(0, rectY, w, dy); // 被选中的 48 | ctx.moveTo(0, rectY); // 画线即可 下划线 49 | ctx.lineTo(w, rectY); 50 | rectY += dy; 51 | id += _idchange[i]; 52 | } ctx.stroke(); 53 | // 写音阶名 54 | ctx.fillStyle = "black"; ctx.fillText(Math.floor(noteID / 12) - 1, w - parent._height * 0.75, baseY - parent._height * 0.3); 55 | baseY -= parent._height; noteID++; 56 | for (let i = 7; i < 12; i++) { 57 | if (this.highlight == noteID) { // 考虑到只要画一次高亮,不必每次都改fillStyle 58 | ctx.fillStyle = '#Ffa500ff'; 59 | ctx.fillRect(0, baseY, w2, -parent._height); 60 | ctx.fillStyle = 'black'; 61 | } else ctx.fillRect(0, baseY, w2, -parent._height); 62 | baseY += _ychange[i]; 63 | noteID += _idchange[i]; 64 | if (baseY < 0) return; 65 | } 66 | } 67 | }; 68 | // 鼠标点击后发声 69 | this.mousedown = () => { 70 | let ch = parent.MidiAction.channelDiv.selected; 71 | if (!ch || ch.mute) return; 72 | ch = ch ? ch.ch : parent.synthesizer; 73 | let nt = ch.play({ f: this.freqTable[this.highlight - 24] }); 74 | let last = this.highlight; // 除颤 75 | const tplay = parent.audioContext.currentTime; 76 | const move = () => { 77 | if (last === this.highlight) return; 78 | last = this.highlight; 79 | let dt = parent.audioContext.currentTime - tplay; 80 | parent.synthesizer.stop(nt, dt > 0.3 ? 0 : dt - 0.3); 81 | nt = ch.play({ f: this.freqTable[this.highlight - 24] }); 82 | }; document.addEventListener('mousemove', move); 83 | const up = () => { 84 | let dt = parent.audioContext.currentTime - tplay; 85 | parent.synthesizer.stop(nt, dt > 0.5 ? 0 : dt - 0.5); 86 | document.removeEventListener('mousemove', move); 87 | document.removeEventListener('mouseup', up); 88 | }; document.addEventListener('mouseup', up); 89 | }; 90 | } -------------------------------------------------------------------------------- /myRange.js: -------------------------------------------------------------------------------- 1 | class myRange extends HTMLInputElement { 2 | /** 3 | * 设置原型并初始化 4 | * @param {HTMLInputElement} ele 5 | * @returns {myRange} 6 | */ 7 | static new(ele) { 8 | Object.setPrototypeOf(ele, myRange.prototype); 9 | myRange.prototype.init.call(ele); 10 | return ele; 11 | } 12 | /** 13 | * 设置一个容器 14 | * 执行了构造函数的内容 15 | */ 16 | init() { 17 | this.default = super.value; // 默认值在html中设置 18 | this.container = document.createElement('div'); 19 | this.container.classList.add('myrange'); 20 | this.insertAdjacentElement('beforebegin', this.container); 21 | // 将当前元素插入到容器中 22 | this.container.appendChild(this); 23 | this.addEventListener('click', this.blur); // 极速取消焦点 防止空格触发 24 | } 25 | set value(v) { 26 | super.value = v; 27 | this.dispatchEvent(new Event('input')); 28 | } 29 | get value() { 30 | return super.value; 31 | } 32 | reset() { 33 | this.value = this.default; 34 | return this; // 可以链式调用,比如let r = myRange.new(document.querySelector('input')).reset(); 35 | } 36 | } 37 | 38 | class LableRange extends myRange { 39 | static new(ele) { 40 | Object.setPrototypeOf(ele, LableRange.prototype); 41 | LableRange.prototype.init.call(ele); 42 | return ele; 43 | } 44 | /** 45 | * 添加一个容器、标签 46 | */ 47 | init() { 48 | super.init(); 49 | this.container.classList.add('labelrange'); 50 | // 设置标签显示当前值 51 | this.label = document.createElement('span'); 52 | this.label.className = "thelabel"; 53 | this.insertAdjacentElement('afterend', this.label); 54 | // 设置label的宽度固定为range的最大值的宽度 55 | if (!this.max) this.max = 100; 56 | let maxStepStr = (this.max - this.step).toFixed(10).replace(/\.?0+$/, ''); // 限制小数位数并去除末尾的零 57 | let len = Math.max(this.max.toString().length, maxStepStr.length); 58 | this.label.style.width = `${len}ch`; 59 | this.addEventListener('input', () => { 60 | this.updateLabel(); // 【不直接传函数,可以篡改this.updateLabel】 61 | }); 62 | // 标签的另一个作用:重置range的值 63 | this.label.addEventListener('click', this.reset.bind(this)); 64 | // 【没有初始化label,需要用户手动调用reset()】 65 | } 66 | updateLabel() { 67 | this.label.textContent = super.value; 68 | } 69 | } 70 | 71 | class hideLableRange extends myRange { 72 | static _expand = 16; // 和css有关 滑块的宽度 73 | static new(ele) { 74 | Object.setPrototypeOf(ele, hideLableRange.prototype); 75 | hideLableRange.prototype.init.call(ele); 76 | return ele; 77 | } 78 | /** 79 | * 添加一个容器、标签 80 | */ 81 | init() { 82 | super.init(); 83 | this.container.classList.add('hidelabelrange'); 84 | // 设置标签显示当前值 85 | this.label = document.createElement('span'); 86 | this.label.className = "thelabel"; 87 | this.insertAdjacentElement('afterend', this.label); 88 | this.addEventListener('input', () => { 89 | this.updateLabel(); // 【不直接传函数,可以篡改this.updateLabel】 90 | }); 91 | // 标签的另一个作用:重置range的值 92 | this.label.addEventListener('click', this.reset.bind(this)); 93 | // 【没有初始化label,需要用户手动调用reset()】 94 | // 滑动时显示label 95 | this.label.style.display = 'none'; 96 | this.addEventListener('focus', function () { 97 | this.label.style.display = 'block'; 98 | }); 99 | this.addEventListener('blur', function () { 100 | this.label.style.display = 'none'; 101 | }); 102 | this.addEventListener('input', this.labelPosition); 103 | } 104 | updateLabel() { 105 | this.label.textContent = super.value; 106 | } 107 | labelPosition() { 108 | let rangeRect = this.getBoundingClientRect(); 109 | let rangeWidth = rangeRect.width - hideLableRange._expand; 110 | this.label.style.left = `${((this.value - this.min) / (this.max - this.min)) * rangeWidth + (hideLableRange._expand >> 1)}px`; 111 | } 112 | } -------------------------------------------------------------------------------- /dataProcess/aboutANA.md: -------------------------------------------------------------------------------- 1 | # JE数字谱自动对齐音频 Auto Note Alignment 2 | 3 | - 问题:给定音高序列和时频谱,求对应关系。音频为polyphonic,还可能为多音色,而音高序列为monophonic,比如只有人声或某种乐器的。最终得到赋予时值信息的音符。此任务类似自动排布视频字幕。 4 | - 输入:时频谱(而不是音频)、音高序列 5 | - 输出:音符在时频谱上的坐标 6 | - 定位:计算复杂度少于AMT(自动扒谱),最好不使用神经网络。因为如果AMT更快,就没必要让用户给出音高序列了。好比有自动识别、添加字幕功能时,用户就没必要重新打一遍字幕了。 7 | 8 | ## 难点 9 | - 音符之间可能需要插入“间隔” 10 | - 对于连续的同一音高的音符序列,如何断开 11 | 12 | ## 最初建模:隐马尔可夫模型 HMM 13 | 使用时频谱的时间轴,每一帧的隐状态即为音符,目标是最大化全局概率。假设第t帧频谱为 $\vec{s}_t$ ,第t帧的隐状态记为 $v_t$ ,第i个音符(音高)为 $n_i$ ,间隔记为"O"。则有: 14 | $$ 15 | v_t = \begin{cases} 16 | n_i \rightarrow v_{t+1}=& 17 | \begin{cases} 18 | n_i:&保持不变\\ 19 | O:&间隔\\ 20 | n_{i+1}:&下一个音符 21 | \end{cases}\\ 22 | \\ 23 | O \rightarrow v_{t+1}=& 24 | \begin{cases} 25 | O:&保持间隔\\ 26 | n_{i+1}:&下一个音符 27 | \end{cases}\\ 28 | \end{cases}\tag{1} 29 | $$ 30 | 31 | HMM有三个要素:初始状态分布,状态转移矩阵,激发概率。 32 | - 初始状态可以设置为"O"。 33 | - 按照传统HMM的建模方法,每个音高都对应各自的状态,比如noteDigger有84个半音,则有(84+1)个状态(考虑了"O")。而用户给出的音符序列,相当于约束了状态转移矩阵。然而,由于每个音符的长度未定,同一个状态可能承载多种分支,使得状态转移矩阵无法构建:比如音符“123124”,第一条转移路径在t时到达第一个2,第二条转移路径在t时到达第二个2,那此时状态2的转移概率如何构建?显然此时两个分支的转移概率应该分开来算。因此,传统的HMM状态建模不可行。 34 | - 激发概率难以合理设置。由于频谱状态空间无限大,必须动态求解。而频谱反应的是分量的幅度,值域为 $[0,+\infty)$,没有合理的、到 $[0, 1]$ 的映射。 35 | 36 | ## 简化:DTW (失败) 37 | DTW 即 `Dynamic Time Warping`,常用于序列对齐。初见DTW是在MusicNet数据集中,DTW免去了人工的对齐,但也因此此数据集的精度不高。想到DTW是因为本任务就是两个序列的对齐:音高数组和(所有帧的)频谱(构成的)数组的对齐。 38 | 39 | DTW也是状态的转移,不过将每个元素视为一个状态。上述传统HMM状态建模仅仅编码了`音高`,导致了时序的混乱;DTW相当于将 `(音高,顺序)` 作为整体编码,因此音高序列有多长,就有多少个状态。这一点值得学习。 40 | 41 | DTW和HMM的Viterbi解码基本原理都是动态规划。如果将问题建模为使用上述状态编码的HMM,且状态转移概率均等,那就可以用DTW来解码路径。 42 | 43 | 相比于HMM使用激发概率,DTW使用距离(其实概率也可以算一种距离)。距离的好处是直接加减、没有范围限制,很容易从幅度谱中映射,比如我选择了用“当前频谱当前音高的幅度的相反数”,因为幅度越大表明越有可能有该音高,而“距离越大越不相似”与之相反,所以加个负号。 44 | 45 | 如何解决提到的难点?可以在每个音符之间插入一个"O"。那如果两个音符理应紧密相连怎么办?DTW中有一对多、多对一的情况,理论上不匹配的会被折叠,即"O"可以不出现。 46 | 47 | 最困难的是"O"到各个频谱的距离。最终我选择了频谱均值,在统计了一些数据后设置为0.08的相反数,这大概能代表整个时频谱每个单元的能量均值。 48 | 49 | 实现上为了减少空间复杂度,使用Hirschberg algorithm,虽然运算量*2,但节省了`帧数/2`倍的空间(帧数往往在4k以上)。 50 | 51 | 然而效果并不好,仅有局部可以对齐。我认为是DTW的无记忆性和缺少约束导致的: 52 | 1. 无记忆:特指求距离时仅依赖当前状态,缺少上一个状态。比如同为切换到"O",从音符切换到"O"时的距离应该和“音符和频谱的距离”有关,因为是音符的结尾;而若上一个状态就是"O",此时的距离应该和“下一个音符和频谱的距离”有关,因为要识别下一个音符的开头。 53 | 2. 缺少约束:比如结果中出现了宽度为0的音符,这是被折叠了,而实际音符应该有长度约束。 54 | 55 | 长度约束可以在DTW的路径回溯添加,具体为限制音符的回溯方向。而使用Hirschberg algorithm的DTW无法解决“无记忆”的问题,因为此算法不按顺序,根基就是“DTW的无记忆距离计算”。为此只能回到DTW最初的实现。而要增加的记忆的作用是可以利用上一帧的状态计算本帧本状态的距离,意味着距离和路径有关。这会导致什么呢?比如考虑下面的D格: 56 | 57 | | 累积距离 | 上一帧 | 当前帧 | 58 | | - | ----- | ------ | 59 | | 状态X | A: -1 | B: -2 | 60 | | 状态Y | C: -3 | D: ? | 61 | 62 | | 方向 | 距离增加 | 63 | | --- | -------- | 64 | | A→D | -5 | 65 | | B→D | -3 | 66 | | C→D | -1 | 67 | 68 | 一通计算发现应该选A→D,更新D的距离为-6。但在DTW的路径回溯中,上一步选择的是距离最小值,因此D的上一步会选择C,而不是计算距离用的A。因此DTW整个框架是没法引入记忆的。 69 | 70 | ## 融合HMM和DTW 71 | DTW被其路径回溯限制,那我们可以改成Viterbi算法的路径记录啊~于是回归HMM建模与Viterbi解码。 72 | 73 | 状态设置同DTW,即首先给音符序列间插"O",然后每个元素作为一个状态。 74 | 75 | 此时可以设置状态转移概率了,即给式(1)中每一行设置一个概率,具体为多少可以根据实验调参。马尔可夫链基于概率,是累乘的,但只要取对数就变成了DTW的累加。问题是如何得到合理的激发概率。 76 | 77 | 映射的难点在于无界到有界,而对于实际的样本其实最大值就是上界。所以我使用样本的最值作为边界、线性缩放至 $[0, 1]$。当然样本的最值取的不是所有时频单元的最值,而是时频谱中音高序列中出现的音高能取到的最值。但这样的概率合理吗? 78 | 79 | 我统计了一下每帧能量的分布,近似为卡方分布。每一个时频单元的能量分布应该也近似(没统计)。这意味着能量大的单元少,而中间或者低能单元的数目多。所以简单的线性缩放会导致大量单元的概率仅在0.5以下,所以我选择了再开根号。假设给出时频谱 $s_{t,f}\in R^{T \times F}$ 和音高序列 $\vec{n} \in Z^N$(为了下标方便,不考虑间插的"O";在$n_i$和$n_{i+1}之间的间隔记为$O_i$),可以这样得到 $n_i \in Z$ 激发出 $\vec{s}_t \in R^F$ 的概率: 80 | $$ 81 | Amp = \max_{t,i} s_{t,n_i}, \\ 82 | amp = \min_{t,i} s_{t,n_i}, \\ 83 | 84 | t \in 1,2,3,...,T\\ 85 | i \in 1,2,3,...,N \\ 86 | p(\vec{s}_t | n_i) = (\frac{s_{t,n_i}-amp}{Amp - amp})^{0.5} 87 | $$ 88 | 89 | 则从 $t$ 到 $t+1$ 的转移概率如下: 90 | $$ 91 | \begin{aligned} 92 | p(v_{t+1}=n_{i+1} | v_t=O_i) &= p(\vec{s}_t | n_{i+1})\\ 93 | p(v_{t+1}=O_i | v_t=O_i) &= 1 - p(\vec{s}_t | n_{i+1})\\ 94 | p(v_{t+1}=O_i | v_t=n_i) &= 1 - p(\vec{s}_t | n_i)\\ 95 | p(v_{t+1}=n_{i} | v_t=n_i) &= p(\vec{s}_t | n_i)\\ 96 | p(v_{t+1}=n_{i+1} | v_t=n_i) &= (1 - p(\vec{s}_t | n_i)) \cdot p(\vec{s}_t | n_{i+1})\\ 97 | &= p(v_{t+1}=O_i | v_t=n_i) \cdot p(v_{t+1}=n_{i+1} | v_t=O_i) 98 | \end{aligned} 99 | $$ 100 | 101 | 注意最后一行,转移判定是“不是$n_i$且是$n_{i+1}$”,所以是两个相乘,进而发现其实是转移两次的结果。 102 | 103 | 实际应用中的状态转移和式(1)有不同。首先添加长度约束。需要记录当前音符状态(不考虑"O"的长度约束)已经保持了多长,小于最小长度时设置为99%的概率继续延续。 104 | 105 | 其次是转移方式。先做如下约定: 106 | - 从左到右——时频谱从开始到结尾 107 | - 从上到下——音符序列从开始到结尾 108 | - 起点:左上角;终点:右下角 109 | 110 | 输入的音符序列已经间插了"O"了,如果当前是音符,直接跳到下一个音符需要跨一行更新,不优雅;遇到边界还需要重新调整转移概率,麻烦。而从 $p(v_{t+1}=n_{i+1} | v_t=n_i)$ 的表达式可以看出,到音符的转移可以建立在到"O"的基础上。于是有了以下设计: 111 | 1. 只允许向右和右下转移,转移概率和为1。这保证了音符不会被折叠。 112 | 2. 同一列中,允许"O"向下转变,可以设置一个转变概率。这保证了"O"可以被折叠 113 | 3. 最终取最大值,并记录上一步。 114 | 115 | “转变”其实不是很严谨,等效到状态转移相当于总概率超过1了。 116 | 117 | 最终效果优于DTW,人肉调参许久得到了如今使用的参数。 -------------------------------------------------------------------------------- /dataProcess/fft_real.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 目前我写的最快的实数FFT。为音乐频谱分析设计 3 | */ 4 | class realFFT { 5 | /** 6 | * 位反转数组 最大支持2^16点 7 | * @param {Number} N 2的正整数幂 8 | * @returns {Uint16Array} 位反转序列 9 | */ 10 | static reverseBits(N) { 11 | const reverseBits = new Uint16Array(N); // 实际N最大2^15 12 | reverseBits[0] = 0; 13 | // 计算位数 14 | let bits = 15; 15 | while ((1 << bits) > N) bits--; 16 | // 由于是实数FFT,偶次为实部,奇次为虚部,故最终结果要乘2,所以不是16-bits 17 | bits = 15 - bits; 18 | for (let i = 1; i < N; i++) { 19 | // 基于二分法的位翻转 20 | let r = ((i & 0xaaaa) >> 1) | ((i & 0x5555) << 1); 21 | r = ((r & 0xcccc) >> 2) | ((r & 0x3333) << 2); 22 | r = ((r & 0xf0f0) >> 4) | ((r & 0x0f0f) << 4); 23 | reverseBits[i] = ((r >> 8) | (r << 8)) >> bits; 24 | } return reverseBits; 25 | } 26 | /** 27 | * 复数乘法 28 | * @param {Number} a 第一个数的实部 29 | * @param {Number} b 第一个数的虚部 30 | * @param {Number} c 第二个数的实部 31 | * @param {Number} d 第二个数的虚部 32 | * @returns {Array} [实部, 虚部] 33 | */ 34 | static ComplexMul(a = 0, b = 0, c = 0, d = 0) { 35 | return [a * c - b * d, a * d + b * c]; 36 | } 37 | /** 38 | * 计算复数的幅度 39 | * @param {Float32Array} r 实部数组 40 | * @param {Float32Array} i 虚部数组 41 | * @returns {Float32Array} 幅度 42 | */ 43 | static ComplexAbs(r, i, l) { 44 | l = l || r.length; 45 | const ABS = new Float32Array(l); 46 | for (let j = 0; j < l; j++) { 47 | ABS[j] = Math.sqrt(r[j] * r[j] + i[j] * i[j]); 48 | } return ABS; 49 | } 50 | 51 | /** 52 | * 53 | * @param {Number} N 要做几点的实数FFT 54 | */ 55 | constructor(N) { 56 | this.ini(N); 57 | this.bufferr = new Float32Array(this.N); 58 | this.bufferi = new Float32Array(this.N); 59 | this.Xr = new Float32Array(this.N); 60 | this.Xi = new Float32Array(this.N); 61 | } 62 | /** 63 | * 预计算常量 64 | * @param {Number} N 2的正整数次幂 65 | */ 66 | ini(N) { 67 | // 确定FFT长度 68 | N = Math.pow(2, Math.ceil(Math.log2(N)) - 1); 69 | this.N = N; // 存的是实际FFT的点数 70 | // 位反转预计算 实际做N/2的FFT 71 | this.reverseBits = realFFT.reverseBits(N); 72 | // 旋转因子预计算 仍然需要N点的,但是只取前一半 73 | this._Wr = new Float32Array(Array.from({ length: N }, (_, i) => Math.cos(Math.PI / N * i))); 74 | this._Wi = new Float32Array(Array.from({ length: N }, (_, i) => -Math.sin(Math.PI / N * i))); 75 | } 76 | /** 77 | * 78 | * @param {Float32Array} input 输入 79 | * @param {Number} offset 偏移量 80 | * @returns [实部, 虚部] 81 | */ 82 | fft(input, offset = 0) { 83 | // 偶数次和奇数次组合并计算第一层 84 | for (let i = 0, ii = 1, offseti = offset + 1; i < this.N; i += 2, ii += 2) { 85 | let xr1 = input[this.reverseBits[i] + offset] || 0; 86 | let xi1 = input[this.reverseBits[i] + offseti] || 0; 87 | let xr2 = input[this.reverseBits[ii] + offset] || 0; 88 | let xi2 = input[this.reverseBits[ii] + offseti] || 0; 89 | this.bufferr[i] = xr1 + xr2; 90 | this.bufferi[i] = xi1 + xi2; 91 | this.bufferr[ii] = xr1 - xr2; 92 | this.bufferi[ii] = xi1 - xi2; 93 | } 94 | for (let groupNum = this.N >> 2, groupMem = 2; groupNum; groupNum >>= 1) { 95 | // groupNum: 组数;groupMem:一组里有几个蝶形结构,同时也是一个蝶形结构两个元素的序号差值 96 | // groupNum: N/4, N/8, ..., 1 97 | // groupMem: 2, 4, ..., N/2 98 | // W's base: 4, 8, ..., N 99 | // W's base desired: 2N 100 | // times to k: N/2, N/4 --> equals to 2*groupNum (W_base*k_times=W_base_desired) 101 | // offset between groups: 4, 8, ..., N --> equals to 2*groupMem 102 | let groupOffset = groupMem << 1; 103 | for (let mem = 0, k = 0, dk = groupNum << 1; mem < groupMem; mem++, k += dk) { 104 | let [Wr, Wi] = [this._Wr[k], this._Wi[k]]; 105 | for (let gn = mem; gn < this.N; gn += groupOffset) { 106 | let gn2 = gn + groupMem; 107 | let [gwr, gwi] = realFFT.ComplexMul(this.bufferr[gn2], this.bufferi[gn2], Wr, Wi); 108 | this.Xr[gn] = this.bufferr[gn] + gwr; 109 | this.Xi[gn] = this.bufferi[gn] + gwi; 110 | this.Xr[gn2] = this.bufferr[gn] - gwr; 111 | this.Xi[gn2] = this.bufferi[gn] - gwi; 112 | } 113 | } 114 | [this.bufferr, this.bufferi, this.Xr, this.Xi] = [this.Xr, this.Xi, this.bufferr, this.bufferi]; 115 | groupMem = groupOffset; 116 | } 117 | // 合并为实数FFT的结果 118 | this.Xr[0] = this.bufferi[0] + this.bufferr[0]; 119 | this.Xi[0] = 0; 120 | for (let k = 1, Nk = this.N - 1; Nk; k++, Nk--) { 121 | let [Ir, Ii] = realFFT.ComplexMul(this.bufferi[k] + this.bufferi[Nk], this.bufferr[Nk] - this.bufferr[k], this._Wr[k], this._Wi[k]); 122 | this.Xr[k] = (this.bufferr[k] + this.bufferr[Nk] + Ir) * 0.5; 123 | this.Xi[k] = (this.bufferi[k] - this.bufferi[Nk] + Ii) * 0.5; 124 | } 125 | return [this.Xr, this.Xi]; 126 | } 127 | } -------------------------------------------------------------------------------- /app_timebar.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * 顶部时间轴 5 | * @param {App} parent 6 | */ 7 | function _TimeBar(parent) { 8 | this.interval = 10; // 每个标注的间隔块数 在updateInterval中更新 9 | // 重复区间参数 单位:毫秒 如果start>end则区间不起作用 10 | this.repeatStart = -1; 11 | this.repeatEnd = -1; 12 | /** 13 | * 设置重复区间专用函数 便于统一管理行为副作用 14 | * @param {number || null} start 单位:毫秒 15 | * @param {number || null} end 单位:毫秒 16 | */ 17 | this.setRepeat = (start = null, end = null) => { 18 | if (start !== null) this.repeatStart = start; 19 | if (end !== null) this.repeatEnd = end; 20 | }; 21 | /** 22 | * 毫秒转 分:秒:毫秒 23 | * @param {Number} ms 毫秒数 24 | * @returns [分,秒,毫秒] 25 | */ 26 | this.msToClock = (ms) => { 27 | return [ 28 | Math.floor(ms / 60000), 29 | Math.floor((ms % 60000) / 1000), 30 | ms % 1000 | 0 31 | ]; 32 | }; 33 | this.msToClockString = (ms) => { 34 | const t = this.msToClock(ms); 35 | return `${t[0].toString().padStart(2, "0")}:${t[1].toString().padStart(2, "0")}:${t[2].toString().padStart(3, "0")}`; 36 | }; 37 | // timeBar的上半部分画时间轴 38 | this.update = () => { 39 | const canvas = parent.timeBar; 40 | const ctx = parent.timeBar.ctx; 41 | let idstart = Math.ceil(parent.idXstart / this.interval - 0.1); // 画面中第一个时间点的序号 42 | let dt = this.interval * parent.dt; // 时间的步长 43 | let dp = parent.width * this.interval; // 像素的步长 44 | let timeAt = dt * idstart; // 对应的毫秒 45 | let p = idstart * dp - parent.scrollX; // 对应的像素 46 | let h = canvas.height >> 1; // 上半部分 47 | ctx.fillStyle = '#25262d'; 48 | ctx.fillRect(0, 0, canvas.width, h); 49 | ctx.fillStyle = '#8e95a6'; 50 | //== 画刻度 标时间 ==// 51 | ctx.strokeStyle = '#ff0000'; 52 | ctx.beginPath(); 53 | for (let endPix = canvas.width + (dp >> 1); p < endPix; p += dp, timeAt += dt) { 54 | ctx.moveTo(p, 0); 55 | ctx.lineTo(p, h); 56 | ctx.fillText(this.msToClockString(timeAt), p - 28, 16); 57 | } ctx.stroke(); 58 | //== 画重复区间 ==// 59 | let begin = parent._width * this.repeatStart / parent.dt - parent.scrollX; // 单位:像素 60 | let end = parent._width * this.repeatEnd / parent.dt - parent.scrollX; 61 | const spectrum = parent.spectrum.ctx; 62 | const spectrumHeight = parent.spectrum.height; 63 | // 画线 64 | if (begin >= 0 && begin < canvas.width) { // 画左边 65 | ctx.beginPath(); spectrum.beginPath(); 66 | ctx.strokeStyle = spectrum.strokeStyle = '#20ff20'; 67 | ctx.moveTo(begin, 0); ctx.lineTo(begin, canvas.height); 68 | spectrum.moveTo(begin, 0); spectrum.lineTo(begin, spectrumHeight); 69 | ctx.stroke(); spectrum.stroke(); 70 | } 71 | if (end >= 0 && end < canvas.width) { // 画右边 72 | ctx.beginPath(); spectrum.beginPath(); 73 | ctx.strokeStyle = spectrum.strokeStyle = '#ff2020'; 74 | ctx.moveTo(end, 0); ctx.lineTo(end, canvas.height); 75 | spectrum.moveTo(end, 0); spectrum.lineTo(end, spectrumHeight); 76 | ctx.stroke(); spectrum.stroke(); 77 | } 78 | // 画区间 如果begin>end则区间不起作用,不绘制 79 | if (begin < end) { 80 | begin = Math.max(begin + 1, 0); end = Math.min(end - 1, canvas.width); 81 | ctx.fillStyle = spectrum.fillStyle = '#80808044'; 82 | ctx.fillRect(begin, 0, end - begin, canvas.height); 83 | spectrum.fillRect(begin, 0, end - begin, spectrumHeight); 84 | } 85 | //== 画当前时间指针 ==// 86 | spectrum.strokeStyle = 'white'; 87 | begin = parent.time / parent.dt * parent._width - parent.scrollX; 88 | if (begin >= 0 && begin < canvas.width) { 89 | spectrum.beginPath(); 90 | spectrum.moveTo(begin, 0); 91 | spectrum.lineTo(begin, spectrumHeight); 92 | spectrum.stroke(); 93 | } 94 | }; 95 | this.updateInterval = () => { // 根据parent.width改变 在width的setter中调用 96 | const fontWidth = parent.timeBar.ctx.measureText('00:00:000').width * 1.2; 97 | // 如果间距小于fontWidth则细分 98 | this.interval = Math.max(1, Math.ceil(fontWidth / parent._width)); 99 | }; 100 | this.contextMenu = new ContextMenu([ 101 | { 102 | name: "设置重复区间开始位置", 103 | callback: (e_father, e_self) => { 104 | this.setRepeat((e_father.offsetX + parent.scrollX) * parent.TperP, null); 105 | } 106 | }, { 107 | name: "设置重复区间结束位置", 108 | callback: (e_father, e_self) => { 109 | this.setRepeat(null, (e_father.offsetX + parent.scrollX) * parent.TperP); 110 | } 111 | }, { 112 | name: '取消重复区间', 113 | onshow: () => this.repeatStart >= 0 || this.repeatEnd >= 0, 114 | callback: () => { 115 | this.setRepeat(-1, -1); 116 | } 117 | }, { 118 | name: "从此处播放", 119 | callback: (e_father, e_self) => { 120 | parent.AudioPlayer.stop(); 121 | parent.AudioPlayer.start((e_father.offsetX + parent.scrollX) * parent.TperP); 122 | } 123 | } 124 | ]); 125 | } -------------------------------------------------------------------------------- /siderMenu.js: -------------------------------------------------------------------------------- 1 | class SiderContent extends HTMLDivElement { 2 | static new(ele, minWidth) { 3 | Object.setPrototypeOf(ele, SiderContent.prototype); 4 | SiderContent.prototype.init.call(ele, minWidth); 5 | return ele; 6 | } 7 | init(minWidth) { 8 | this.resize = this._resize.bind(this); 9 | this.mouseup = this._mouseup.bind(this); 10 | this.mousedown = this._mousedown.bind(this); 11 | 12 | this.classList.add('siderContent'); 13 | this.minWidth = minWidth; 14 | this.judge = (minWidth >> 1) + this.getBoundingClientRect().left; 15 | this.style.width = minWidth + 'px'; 16 | 17 | const bar = document.createElement('div'); 18 | bar.className = 'siderBar'; 19 | this.insertAdjacentElement('afterend', bar); 20 | bar.addEventListener('mousedown', this.mousedown); 21 | this.bar = bar; 22 | } 23 | _mousedown(e) { 24 | if (e.button) return; 25 | document.addEventListener('mousemove', this.resize); 26 | document.addEventListener('mouseup', this.mouseup); 27 | } 28 | _resize(e) { 29 | if (e.clientX < this.judge) this.display = 'none'; 30 | else { 31 | let rect = this.getBoundingClientRect(); 32 | let w = e.clientX - rect.left; 33 | if (w < this.minWidth) return; 34 | // 触发刷新 35 | this.width = w + 'px'; 36 | this.display = 'block'; 37 | } 38 | } 39 | _mouseup() { 40 | document.removeEventListener('mousemove', this.resize); 41 | document.removeEventListener('mouseup', this.mouseup); 42 | this.bar.blur(); 43 | window.dispatchEvent(new Event("resize")); // 触发app.resize 44 | } 45 | get display() { 46 | return this.style.display; 47 | } 48 | // 设置display可以触发刷新 因为app.resize绑定在window.onresize上 49 | set display(state) { 50 | if (this.style.display != state) { 51 | this.style.display = state; 52 | window.dispatchEvent(new Event("resize")); 53 | } 54 | } 55 | get width() { 56 | return this.style.width; 57 | } 58 | set width(w) { 59 | if (this.style.width != w) { 60 | this.style.width = w; 61 | window.dispatchEvent(new Event("resize")); 62 | } 63 | } 64 | } 65 | 66 | class SiderMenu extends HTMLDivElement { 67 | /** 68 | * 构造tabMenu和container 69 | * @param {HTMLDivElement} menu 存放tab的 样式: .siderTabs 每一个tab: .siderTab 70 | * @param {HTMLDivElement} container 展示具体内容的 样式: .siderContent 拖动条: .siderBar 每一个子内容都会加上siderItem类 71 | * @param {Number} minWidth 展示具体内容的最小宽度 72 | * @returns 73 | */ 74 | static new(menu, container, minWidth) { 75 | Object.setPrototypeOf(menu, SiderMenu.prototype); 76 | SiderMenu.prototype.init.call(menu, container, minWidth); 77 | return menu; 78 | } 79 | init(box, minWidth) { 80 | this.classList.add('siderTabs'); 81 | this.container = SiderContent.new(box, minWidth); 82 | box.display = 'none'; 83 | this.tabClick = this._tabClick.bind(this); 84 | this.tabs = []; 85 | } 86 | /** 87 | * 添加一个菜单项及其内容 88 | * @param {String} name tab的名字 89 | * @param {String} tabClass tab的类名 用空格分隔 90 | * @param {HTMLElement} dom tab对应的内容 91 | * @param {Boolean} selected 是否默认选中 92 | * @returns {HTMLDivElement} 添加的tab 93 | */ 94 | add(name, tabClass, dom, selected = false) { 95 | const tab = document.createElement('div'); 96 | tab.className = 'siderTab'; 97 | tab.classList.add(...tabClass.split(' ')); 98 | tab.dataset.name = name; 99 | 100 | this.container.appendChild(dom); 101 | dom.classList.add('siderItem'); 102 | dom.style.display = 'none'; 103 | tab.item = dom; 104 | 105 | tab.addEventListener('click', this.tabClick); 106 | this.appendChild(tab); 107 | if (this.tabs.push(tab) == 1) { 108 | tab.classList.add('selected'); 109 | dom.style.display = 'block'; 110 | } else if (selected) this.select(tab); 111 | return tab; 112 | } 113 | /** 114 | * 选中一个标签 115 | * @param {HTMLDivElement || Number} tab 116 | * @returns {HTMLDivElement} 选择的标签 117 | */ 118 | select(tab) { 119 | if (typeof tab == 'number') tab = this.tabs[tab]; 120 | if (!tab) return; 121 | for (const t of this.tabs) { 122 | t.classList.remove('selected'); 123 | t.item.style.display = 'none'; 124 | } 125 | tab.classList.add('selected'); 126 | tab.item.style.display = 'block'; 127 | return tab; 128 | } 129 | /** 130 | * 控制面板的显示 131 | * @param {Boolean} ifshow 是否显示面板 132 | */ 133 | show(ifshow = true) { 134 | this.container.display = ifshow ? 'block' : 'none'; 135 | } 136 | // 绑定给tab用,不应该用户被调用 137 | _tabClick(e) { 138 | const tab = e.target; 139 | if (tab.classList.contains('selected')) { // 如果显示的就是tab的,则隐藏 140 | // 用style.dispaly是读取,用.display = 是为了刷新 141 | this.container.display = this.container.style.display == 'none' ? 'block' : 'none'; 142 | } else { // 否则只显示tab的 143 | for (const t of this.tabs) { 144 | t.classList.remove('selected'); 145 | t.item.style.display = 'none'; 146 | } 147 | tab.classList.add('selected'); 148 | this.container.display = 'block'; 149 | } 150 | tab.item.style.display = 'block'; 151 | tab.blur(); 152 | } 153 | } -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # 在线扒谱应用设计 2 | 1. 获取时域数据(Web Audio API 解码上传的音频文件)【done】 3 | 2. 获取频域信息(FFT类)【done】 4 | 采样率设置为44100,取8192点的实数FFT,分析范围:C1-B7,但点数限制只能区分F#2以上的音符。 5 | 3. 特征提取:提取84个音符的幅度。 6 | 粗糙地实现了。思路是只在需要的频率附近查找。 7 | 面临三个问题: 8 | 3.1. 频谱泄露如何处理 9 | 3.2. 最高到22050Hz,但是音符最高3950Hz,取到C8,即只需777点。后面的是否保留? 10 | 3.3. 有的音乐中心频率不是正好440Hz,是否需要自适应?和频谱泄露处理有关。 11 | ——目前的解决方案:在相邻音周围求平方和。因为有频谱泄露,所以需要收集泄露的频谱的能量。而频谱泄露是对称的,所以相邻音中间的频谱对半分。自适应不实现,因为上述解决方案对音不准有一定的适应能力(音乐高适应性越强,但低音容易出现误判)。每次处理以音符为单位,只搜索周围的能量,所以后面的没用到,随垃圾回收而释放。 12 | 4. 画图。交互 13 | todo。面临问题: 14 | 4.1. 实时刷新还是一次画完?——选择实时刷新,用无限画布的思路 15 | 4.2. 幅度达到多少认为完全可信?——手调吧。设置一个参数。 16 | 功能:是否自动跟随? 17 | 5. 播放音乐和midi 18 | todo。问题很多,主要是前端的midi播放。在后文列举。 19 | 20 | ## 边角功能: 21 | 文件拖拽上传。done 22 | 自动识别音符?比如利用频谱后面的内容,分辨出谐波 23 | 24 | ## 画图要点 25 | 无限画布 https://blog.csdn.net/shulianghan/article/details/120863626 26 | 27 | ## 键盘画图 28 | C1对应24 全部按照midi协议编号。 29 | 以一个八度作为最小绘制单元,目前实现的绘图有所冗余,性能未达最优,但是懒得改了。 30 | 31 | ## midi可视化创建 32 | 关键是如何响应鼠标操作!用状态代替动作 33 | 描述一个midi音符:音高,起始,终止,是否选中 34 | 35 | 效果描述: 36 | 鼠标按下: 37 | - 如果按在空白则新建音符,进入调整时长模式 38 | - 如果按在音符上: 39 | ctrl是否按下? 40 | - 按下:选择多个 41 | - 没按下:是否已经选择了多个? 42 | - 是:鼠标抬起的时候,如果之前有拖动则什么也不做,否则仅选中当前那个 43 | - 否:仅选一个 44 | 判断点击位置: 45 | - 前一半:后面的是拖动模式 46 | - 后一半:后面的是调整时长模式 47 | 无论是那种模式,都支持音高上的调整 48 | 49 | 如何添加音符? 50 | 1. 点击的时候确认位置,已经添加进去了。 51 | 2. 设置这个音符为选中,模式是调整时长。 52 | 53 | 选中有两个方案: 54 | 1. 设置一个选中列表,存选中的midi音符对象的地址 55 | 有一个难点:绘制的时候如何让选中的不绘制两次? 56 | 2. 每个音符设置一个选中状态 57 | 有一个难点:每次调整音符状态的时候,都需要遍历所有音符 58 | 59 | 结论是:两者结合,空间换时间。 60 | 61 | 播放如何同步? 62 | 63 | ## 多音轨 64 | 此功能似乎用得不多。 65 | wavetone中,每个音轨之间不存在明显的界限,而signal中,只有选中的音轨可以点击音符。 66 | 我觉得前者适合,可以加一个mute、visible选项控制音轨以达到signal中的效果 67 | 数据结构? 68 | ### midi音符的结构 69 | 两个方案:所有轨都在一个数组中,和每轨一个数组,或者……两者结合 70 | - 所有轨都在一个数组:可以一次遍历实现音符拾取,绘制也只要一次遍历 71 | - 每轨一个数组:可以方便地实现单轨样式的应用,更改音轨顺序容易 72 | - 两者结合:维护较为麻烦 73 | 需要实现的功能: 74 | - 撤销重做: 用一个数组合适 75 | - 单音轨播放(静音、乐器):其实也是一个数组简单,因为播放的时候只需要维护一次遍历 76 | - 多音轨拖拽:音符拾取是单音轨简单。拖拽影响的是selected数组,两者平局 77 | 综上,存放音符还是单个数组合适。要实现以上功能,只需要给音符加一个channel属性,而每个音轨的设置需要维护一个数组。 78 | 79 | ### 音轨的结构 80 | 音轨的添加采用动态添加的方式还是静态?wavetone是静态,最大16。我做动态吧。 81 | 动态音轨涉及很多ui的东西:音轨的位置(设计为可拖拽排序)、属性设置 82 | 需要暴露的接口: 83 | - 音轨变化事件(顺序、个数):用于触发存档点,目前考虑封装成Event 84 | - 音轨状态(颜色、当前选中) 85 | - 序列化音轨、根据音轨参数数组创建音轨:用于实现音轨的快照 86 | ChannelList的音轨列表及其属性似乎不需要暴露 87 | 下一步推进的关键: 88 | MidiPlayer!需要成为ChannelList和ChannelItem的公共可访问对象,然后完成ui和数据的绑定。ChannelItem的instrument是否需要用序号代替? 89 | 耦合关系: 90 | MidiAction监听ChannelList的事件,而ChannelList不监听MidiAction,但受其控制 91 | - ChannelList->音轨变化(顺序、个数)->MidiAction&MidiPlayer 92 | 如何传递这个变化?改变顺序用reorder事件,删除用remove事件,添加似乎不涉及midi音符的操作。【修正:新增channel也需要事件,用于存档】 93 | 删除一个channel时,先触发remove,再触发reorder,remove事件用于删除midi列表对应通道的音符,reorder用于更改剩下的音符的音轨序号 94 | 由于reorder只在序号发生变化时触发,如果是最后一个删除或添加就不会触发,这意味着对此事件监听不能响应所有变化,那如何设置存档点?存档单独注册一个reorder的监听,add/remove前先取消注册,由add/remove自行设置存档,操作结束再注册回来,防止两次存档。 95 | - ChannelList->音轨音量改变->MidiPlayer 96 | - ChannelList<-选中音符<-MidiAction 97 | 98 | ### 撤销 99 | 本想改了什么存什么(以节省内存),但是没存的会丢失当前信息,所以必须midi和channel都存快照。 100 | 101 | ### 绘制 102 | 使用多音轨后,绘制逻辑需要改变 103 | 重叠:序号越低的音轨图层越上 & 选中的音轨置于顶层?——还是不要后者了。后者可以通过移动音轨实现 104 | 由于scroll相比刷新是稀疏的,可以维护一个“视野中的音符”列表insight,更新时机: 105 | 1. channelDiv的reorder 106 | 2. midi的增删移动改变长度。由于都会调用且最后调用changeNoteY,所以只需要在changeNoteY中调用 107 | 3. scroll2 108 | 4. deleteNote 109 | 5. ctrlZ、ctrlY 110 | 为了实现小序号音轨在上层,insight是一个列表的列表,每个列表中是一个音轨的视野内的音符。绘制的时候,倒序遍历绘制,同时查询是否显示。 111 | 112 | ## 音符播放技术要点 113 | 参考 https://github.com/g200kg/webaudio-tinysynth 完成了精简版的合成器,相比原版,有如下变化: 114 | - 抽象为类,音色变成static属性。 115 | - 用animationFrame而非timeInterval实现了音符检查与停止。 116 | - 为了契合“动态音轨”的设计,合成器中以channel为单位组织数组,而非原版以audioNode为单位。 117 | - 每个音轨的原型都是合成器,故可以单独拿出来使用。 118 | - 没有做成midi的形式,音符频率依赖外部传参 119 | - 没有实现通道的调制、左右声道、混响。 120 | - 没有做鼓的音色。 121 | 122 | ## 音频播放 123 | 使用管理,因为方便。为了实现EQ效果: 124 | ```js 125 | var source = audioContext.createMediaElementSource(audioElement); 126 | var filter = audioContext.createBiquadFilter(); 127 | filter.type = 'lowpass'; 128 | filter.frequency.value = 1000; 129 | source.connect(filter); 130 | filter.connect(audioContext.destination); 131 | ``` 132 | 仍然可以通过audioElement控制整体的播放。需要注意audioContext的状态: 133 | 如果是suspend,则需要resume(); audioContext刚创建就是这个状态,此时调用audioElement.play()无效。 134 | 但只要有osc被调用了start(),audioContext就会变成running。 135 | 136 | ## 不依赖音频的空白画布,即midi编辑器模式 137 | 由于使用了扒谱架构,同样需要确定时间精度(和一般midi编辑器不一样),因此需要复用onfile逻辑。 138 | 思想是替代Audio类,整理一下需要实现的功能: 139 | - AudioPlayer.update: 用到了audio.readyState,audio.paused,audio.cuttentTime(更新app.time、重复区间) 140 | - audio.readyState判断是否可以播放 141 | - 设置audio.currentTime可以指定位置播放 142 | - 设置audio.playbackRate可以指定播放速度 143 | - 还有一些handler在createAudio中。 144 | 145 | 因此有了fakeAudio.js。 146 | 147 | 其次是Spectrogram._spectrogram,需要全部置为零(供绘制用)此外,查询Spectrogram._spectrogram以获取是否已经分析(onfile中据此判断是工作区否有文件,鼠标事件据此判断是否绘制音符) 148 | 于是设计了一个proxy,有length属性,用[][]访问总是零。 149 | 150 | FakeAudio和这个Proxy的连接点在于时长,midi编辑器模式下总时长是会变的。于是借助setter完成了两者的数据关联。同时xnum也要能改变,于是也改为了setter。 151 | 152 | ## 去掉等待的动画 153 | 指一边分析一边把频谱绘制出来。改的地方:Array不能初始化好长度,每次应该用push添加元素造成Array.length在增加。每次更新进度条的地方改成频谱赋值。 154 | 这样确实能看到频谱生长出来,但是分析会导致此时的UI操作很卡顿。解决这个问题需要开worker后台计算,每次移交一个时刻的频谱。用了worker就不能双击打开了,不做。 155 | 此外这样意味着音频要先加载,如果出错了,会出现一堆未定义事件。 156 | 所以还是放弃。 157 | 158 | ## CQT 159 | CQT太慢了。可以引入worker,后台计算,去掉进度条。或者先计算STFT,然后后台计算CQT。直接用CQT太唐氏了。 160 | worker不能再双击打开的file协议下用,所以既然用了worker,不如CQT用wasm实现。 161 | https://github.com/madderscientist/codeRoad/tree/main/TimeFrequency 162 | 163 | ## 关于Web Auido API的自动采样 164 | 当AudioContext的采样率和输入音频的采样率不一样的时候会发是什么? 165 | https://dvcs.w3.org/hg/audio/raw-file/tip/webaudio/specification.html搜索“resample”可以看到会重新采样。问题是:是否会抗混叠滤波? 166 | 测试:首先创建了一个只含有一个G7音符(3136Hz)的midi,然后利用musescore转为WAV,采样率选择44100Hz。导入分析后,频谱符合预期。 167 | 改变AudioContext的采样率为4186Hz,如果抗混叠滤波,则这个G7肯定要被滤除。如果不抗混叠,则混叠到3136-(4186/2)=1043,位于C6(1046Hz)。 168 | 结果是在C6处出现了能量。所以不会抗混叠。 -------------------------------------------------------------------------------- /dataProcess/CQT/cqt_worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 用定义计算CQT,时间复杂度很高,但是分析效果好 3 | */ 4 | class CQT { 5 | /** 6 | * 创建窗函数 幅度加起来为1 7 | * @param {number} N 8 | * @returns {Float32Array} 窗幅度 9 | */ 10 | static blackmanHarris(N) { 11 | let w = new Float32Array(N); 12 | const temp = 2 * Math.PI / (N - 1); 13 | let sum = 0; 14 | for (let n = 0; n < N; n++) { 15 | w[n] = 0.35875 16 | - 0.48829 * Math.cos(temp * n) 17 | + 0.14128 * Math.cos(temp * n * 2) 18 | - 0.01168 * Math.cos(temp * n * 3); 19 | sum += w[n]; 20 | } 21 | // 归一化(幅度归一化,和矩形窗FFT除以N的效果一样) 22 | for (let n = 0; n < N; n++) w[n] /= sum; 23 | return w; 24 | } 25 | 26 | /** 27 | * 预计算CQT参数 28 | * @param {number} fs 采样率 29 | * @param {number} fmin 最低的频率(最低八度的C) 默认为C1 30 | * @param {number} octaves 要分析几个八度 31 | * @param {number} bins_per_octave 几平均律 32 | * @param {number} filter_scale Q的缩放倍数 越大频率选择性越好 33 | */ 34 | constructor(fs, fmin = 32.7, octaves = 7, bins_per_octave = 12, filter_scale = 1) { 35 | this.bins = bins_per_octave * octaves; 36 | const Q = filter_scale / (Math.pow(2, 1 / bins_per_octave) - 1); 37 | [this.kernel_r, this.kernel_i] = CQT.iniKernel( 38 | Q, fs, fmin, bins_per_octave, this.bins 39 | ); 40 | } 41 | /** 42 | * 得到CQT kernel 时域数据 43 | * @param {number} Q 44 | * @param {number} fs 采样率 45 | * @param {number} fmin 最低音 46 | * @param {number} bins_per_octave 八度内的频率个数 47 | * @param {number} binNum 一共多少个频率 48 | * @returns {Array} [kernel_r, kernel_i] 49 | */ 50 | static iniKernel(Q, fs, fmin, bins_per_octave = 12, binNum = 84) { 51 | const kernel_r = Array(binNum); 52 | const kernel_i = Array(binNum); 53 | for (let i = 0; i < binNum; i++) { 54 | const freq = fmin * Math.pow(2, i / bins_per_octave); 55 | const len = Math.ceil(Q * fs / freq); 56 | const temp_kernel_r = kernel_r[i] = new Float32Array(len); 57 | const temp_kernel_i = kernel_i[i] = new Float32Array(len); 58 | const window = CQT.blackmanHarris(len); 59 | const omega = 2 * Math.PI * freq / fs; 60 | const half_len = len >> 1; 61 | for (let j = 0; j < len; j++) { 62 | const angle = omega * (j - half_len); // 中心的相位为0 63 | temp_kernel_r[j] = Math.cos(angle) * window[j]; 64 | temp_kernel_i[j] = Math.sin(angle) * window[j]; // 按DFT应该加负号,但是最后的结果是能量,加不加都一样 65 | // 而且CQT1992继承自本类,用正相位增加的旋转因子可以让频域带宽在正频率上 66 | } 67 | } return [kernel_r, kernel_i]; 68 | } 69 | /** 70 | * 计算CQT 71 | * @param {Float32Array} x 输入实数时序信号 会被改变! 72 | * @param {number} stride 73 | * @returns {Array} 第一维是时间,第二维是频率 74 | */ 75 | cqt(x, stride) { 76 | let offset = stride >> 1; 77 | const output_length = Math.ceil((x.length - offset) / stride); 78 | const output = Array(output_length); 79 | const frameEnergy = new Float32Array(output_length); 80 | let pointer = 0; 81 | let energySum = 0; 82 | for (; offset < x.length; offset += stride) { 83 | const energy = output[pointer] = new Float32Array(this.bins); 84 | let _energySum = 0; 85 | for (let b = 0; b < this.bins; b++) { // 每个频率 86 | const kernel_r = this.kernel_r[b]; 87 | const kernel_i = this.kernel_i[b]; 88 | let real = 0, imag = 0; 89 | const left = offset - (kernel_r.length >> 1); 90 | const right = Math.min(kernel_r.length, x.length - left); 91 | for (let i = left >= 0 ? 0 : -left; i < right; i++) { 92 | const index = left + i; 93 | if (index >= x.length) break; 94 | real += x[index] * kernel_r[i]; 95 | imag += x[index] * kernel_i[i]; 96 | } 97 | energy[b] = real * real + imag * imag; 98 | _energySum += energy[b]; 99 | } 100 | frameEnergy[pointer] = _energySum; 101 | energySum += _energySum; 102 | ++pointer; 103 | } 104 | // 归一化 105 | let sigma = 1e-8; 106 | const meanEnergy = energySum / output_length; 107 | for (let t = 0; t < output_length; t++) { 108 | const delta = frameEnergy[t] - meanEnergy; 109 | sigma += delta * delta; 110 | } 111 | sigma = Math.sqrt(sigma / (output_length - 1)); 112 | for (const frame of output) { 113 | for (let i = 0; i < frame.length; i++) { 114 | frame[i] = Math.sqrt(frame[i] / sigma); 115 | } 116 | } 117 | return output; 118 | } 119 | } 120 | 121 | self.onmessage = async ({data}) => { 122 | let { audioChannel, sampleRate, hop, fmin } = data; 123 | const cqt = new CQT(sampleRate, fmin, 7, 12, 2.4); 124 | let cqtData = cqt.cqt(audioChannel[0], hop); 125 | // 第二个通道 126 | if (audioChannel.length > 1) { 127 | let cqtData2 = cqt.cqt(audioChannel[1], hop); 128 | for (let i = 0; i < cqtData.length; i++) { 129 | const temp1 = cqtData[i]; 130 | const temp2 = cqtData2[i]; 131 | for (let j = 0; j < cqtData[i].length; j++) 132 | temp1[j] = (temp1[j] + temp2[j]) * 0.5; 133 | } 134 | } 135 | self.postMessage(cqtData, [...cqtData.map(x => x.buffer), ...audioChannel.map(x => x.buffer)]); 136 | self.close(); 137 | }; 138 | -------------------------------------------------------------------------------- /app_audioplayer.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * 音频播放 5 | * @param {App} parent 6 | */ 7 | function _AudioPlayer(parent) { 8 | this.name = "请上传文件"; // 在parent.io.onfile中赋值 9 | this.audio = new Audio(); // 在parent.io.onfile中重新赋值 此处需要一个占位 10 | this.play_btn = document.getElementById("play-btn"); 11 | this.durationString = ''; // 在parent.Analyser.audio.ondurationchange中更新 12 | this.autoPage = false; // 自动翻页 13 | this.repeat = true; // 是否区间循环 14 | this.EQfreq = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; 15 | // midiMode下url为duration 16 | this.createAudio = (url) => { 17 | return new Promise((resolve, reject) => { 18 | const a = parent.midiMode ? new FakeAudio(url) : new Audio(url); 19 | a.loop = false; 20 | a.volume = parseFloat(document.getElementById('audiovolumeControl').value); 21 | a.ondurationchange = () => { 22 | let ms = a.duration * 1000; 23 | this.durationString = parent.TimeBar.msToClockString(ms); 24 | parent.BeatBar.beats.maxTime = ms; 25 | }; 26 | a.onended = () => { 27 | parent.time = 0; 28 | this.stop(); 29 | }; 30 | a.onloadeddata = () => { 31 | if (!parent.midiMode) { 32 | this.setEQ(); 33 | if (parent.audioContext.state == 'suspended') parent.audioContext.resume().then(() => a.pause()); 34 | document.title = this.name + "~扒谱"; 35 | } else { 36 | document.title = this.name; 37 | } 38 | a.playbackRate = document.getElementById('speedControl').value; // load之后会重置速度 39 | parent.time = 0; 40 | resolve(a); 41 | a.onloadeddata = null; // 一次性 防止多次构造 42 | this.play_btn.firstChild.textContent = parent.TimeBar.msToClockString(parent.time); 43 | this.play_btn.lastChild.textContent = this.durationString; 44 | }; 45 | a.onerror = (e) => { // 如果正常分析,是用不到这个回调的,因为WebAudioAPI读取就会报错。但上传已有结果不会再分析 46 | // 发现一些如mov格式的视频,不在video/的支持列表中,用.readAsDataURL转为base64后无法播放,会触发这个错误 47 | // 改正方法是用URL.createObjectURL(file)生成一个blob地址而不是解析为base64 48 | console.error("Audio load error", e); 49 | reject(e); 50 | // 不再抛出错误事件 调用者自行处理 51 | // parent.event.dispatchEvent(new Event('fileerror')); 52 | }; 53 | this.setAudio(a); 54 | }); 55 | }; 56 | 57 | let _crossFlag = false; // 上一时刻是否在重复区间终点左侧 58 | this.update = () => { 59 | const a = this.audio; 60 | if (a.readyState != 4 || a.paused) return; 61 | parent.time = a.currentTime * 1000; // 【重要】更新时间 62 | // 重复区间 63 | let crossFlag = parent.time < parent.TimeBar.repeatEnd; 64 | if (this.repeat && parent.TimeBar.repeatEnd >= parent.TimeBar.repeatStart) { // 重复且重复区间有效 65 | if (_crossFlag && !crossFlag) { // 从重复区间终点左侧到右侧 66 | parent.time = parent.TimeBar.repeatStart; 67 | a.currentTime = parent.time / 1000; 68 | } 69 | } 70 | _crossFlag = crossFlag; 71 | this.play_btn.firstChild.textContent = parent.TimeBar.msToClockString(parent.time); 72 | this.play_btn.lastChild.textContent = this.durationString; 73 | // 自动翻页 74 | if (this.autoPage && (parent.time > parent.idXend * parent.dt || parent.time < parent.idXstart * parent.dt)) { 75 | parent.scroll2(((parent.time / parent.dt - 1) | 0) * parent._width, parent.scrollY); // 留一点空位 76 | } else parent.makeDirty(); 77 | }; 78 | /** 79 | * 在指定的毫秒数开始播放 80 | * @param {Number} at 开始的毫秒数 如果是负数,则从当下开始 81 | */ 82 | this.start = (at) => { 83 | const a = this.audio; 84 | if (a.readyState != 4) return; 85 | if (at >= 0) a.currentTime = at / 1000; 86 | _crossFlag = false; // 置此为假可以暂时取消重复区间 87 | parent.MidiPlayer.restart(); 88 | if (a.readyState == 4) a.play(); 89 | else a.oncanplay = () => { 90 | a.play(); 91 | a.oncanplay = null; 92 | }; 93 | }; 94 | this.stop = () => { 95 | this.audio.pause(); 96 | parent.synthesizer.stopAll(); 97 | }; 98 | this.setEQ = (f = this.EQfreq) => { 99 | const a = this.audio; 100 | if (a.EQ) return; 101 | // 由于createMediaElementSource对一个audio只能调用一次,所以audio的EQ属性只能设置一次 102 | const source = parent.audioContext.createMediaElementSource(a); 103 | let last = source; 104 | a.EQ = { 105 | source: source, 106 | filter: f.map((v) => { 107 | const filter = parent.audioContext.createBiquadFilter(); 108 | filter.type = "peaking"; 109 | filter.frequency.value = v; 110 | filter.Q.value = 1; 111 | filter.gain.value = 0; 112 | last.connect(filter); 113 | last = filter; 114 | return filter; 115 | }) 116 | }; 117 | last.connect(parent.audioContext.destination); 118 | }; 119 | this.setAudio = (newAudio) => { 120 | const a = this.audio; 121 | if (a) { 122 | a.pause(); 123 | a.onerror = null; // 防止触发fileerror 124 | a.src = ''; 125 | if (a.EQ) { 126 | a.EQ.source.disconnect(); 127 | for (const filter of a.EQ.filter) filter.disconnect(); 128 | } 129 | // 配合传参为URL.createObjectURL(file)使用,防止内存泄露 130 | URL.revokeObjectURL(a.src); 131 | } 132 | this.audio = newAudio; 133 | }; 134 | 135 | this.play_btn.onclick = () => { 136 | if (this.audio.paused) this.start(-1); 137 | else this.stop(); 138 | this.play_btn.blur(); // 防止焦点在按钮上导致空格响应失败 139 | }; 140 | } -------------------------------------------------------------------------------- /dataProcess/ANA.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file ANA.js (auto note alignment) 3 | * @abstract 融合HMM和DTW的音符自动对齐 4 | * @description 5 | * ## 记法 6 | * 从左到右——时频谱从开始到结尾 7 | * 从上到下——音符序列从开始到结尾 8 | * 起点:左上角;终点:右下角 9 | * 10 | * ## 转移规则: 11 | * 从左到右从上到下计算 12 | * 仅有当前格为-1时才能计算向下扩散,向下扩散不使用状态转移概率(实际还是用了,提高切换的门槛) 13 | * 其余都只能向右和右下扩散 14 | * 计算第t列仅能使用第(t-1)列,需要乘上状态转移概率 15 | * 16 | * P(s_{t}=-1 | s_{t-1}=-1) = 0.32 17 | * P(s_{t}!=-1 | s_{t-1}=-1) = 0.68 18 | * 19 | * P(s_{t}=-1 | s_{t-1}!=-1) = 0.2 20 | * P(s_{t}=s_{t-1} | s_{t-1}!=-1) = 0.8 21 | * 22 | * ## 激发概率 23 | * 取可能遇到的bin的max,以此为边界缩放,并小值补偿(开根号),记为当前bin存在音符的概率 24 | */ 25 | 26 | /** 27 | * HMM 进行自动音符-音频对齐 规则见上 28 | * @param {Array} noteSeq 音符序列,已经插入了间隔-1 29 | * @param {Array} spectrum 时频谱 第一维是时间 30 | * @param {number} minLen 建议的音符最小长度 可为小数 31 | * @returns {Array>} 全局最优路径 [[n, s], [n2, s2], ...] 32 | */ 33 | function autoNoteAlign(noteSeq, spectrum, minLen = 2) { 34 | class AlignInfo { 35 | constructor(v = -Infinity, k = 0) { 36 | this.value = v; // 指向上一帧的某个bin 37 | this.keep = k; // 音符长度 38 | } 39 | }; 40 | 41 | /** 42 | * 找到可能的最大最小值,返回线性归一化函数 43 | * @param {Set} noteSet 44 | * @param {Array} spectrum 45 | * @returns {Function(number)} 归一化函数 46 | */ 47 | function _getNormalizeFN(noteSet, spectrum) { 48 | let max = -Infinity; 49 | let min = Infinity; 50 | for (const s of spectrum) { 51 | for (const bin of noteSet) { 52 | if (s[bin] > max) max = s[bin]; 53 | if (s[bin] < min) min = s[bin]; 54 | } 55 | } 56 | const len = max - min; 57 | return (x) => (x - min) / len; 58 | } 59 | 60 | const fn = _getNormalizeFN(new Set(noteSeq), spectrum); 61 | let buffer_curr = Array(noteSeq.length); 62 | let buffer_prev = Array(noteSeq.length); 63 | for (let i = 0; i < noteSeq.length; i++) { 64 | buffer_curr[i] = new AlignInfo(); 65 | buffer_prev[i] = new AlignInfo(); 66 | } 67 | buffer_prev[0].value = 0; 68 | 69 | const k = 0.52; // 由于大值很少,0很多,因此要提高对小值的敏感度 70 | 71 | const P = Array(spectrum.length); 72 | for (let frame = 0; frame < spectrum.length; frame++) { 73 | const from = P[frame] = new Uint16Array(noteSeq.length).fill(-1); 74 | const frameSpectrum = spectrum[frame]; 75 | // 先向右和右下扩散 76 | for (let i = 0; i < noteSeq.length; ++i) { 77 | const root = buffer_prev[i]; 78 | // 由于路径限制,并不是每个位置都能到达,可以跳过 79 | if (root.value === -Infinity) break; 80 | if (noteSeq[i] === -1) { 81 | if (i + 1 < noteSeq.length) { 82 | const hasNote = Math.pow(fn(frameSpectrum[noteSeq[i + 1]]), k); 83 | // 以下的概率和不为1...然而强制缩放为1效果很差,不如就这样,可解释性还高 84 | const keepP = 0.32; 85 | // 保持空状态 86 | const right = root.value + Math.log(Math.max((1 - hasNote) * keepP, 1e-12)); 87 | if (buffer_curr[i].value < right) { 88 | buffer_curr[i].value = right; 89 | from[i] = i; 90 | buffer_curr[i].keep = root.keep + 1; 91 | } 92 | // 切换为音符 93 | const rightdown = root.value + Math.log(Math.max(hasNote * (1 - keepP), 1e-12)); 94 | if (buffer_curr[i + 1].value < rightdown) { 95 | buffer_curr[i + 1].value = rightdown; 96 | from[i + 1] = i; 97 | buffer_curr[i + 1].keep = 0; 98 | } 99 | } else { // 没有下一个了 保持较小速率降低 100 | const p = root.value - 1; 101 | if (buffer_curr[i].value < p) { 102 | buffer_curr[i].value = p; 103 | from[i] = i; 104 | buffer_curr[i].keep = root.keep + 1; 105 | } 106 | } 107 | } else { // 是音符 108 | const hasNote = Math.pow(fn(frameSpectrum[noteSeq[i]]), k); 109 | let keepP = 0.8; 110 | if (root.keep < minLen) { 111 | // 初始概率必须大 不然高时间分辨率频谱下容易出现很碎的音 112 | keepP = 0.999 - 0.09 * (root.keep / minLen); 113 | } 114 | // 保持音符 115 | const right = root.value + Math.log(Math.max(hasNote * keepP, 1e-12)); 116 | if (buffer_curr[i].value < right) { 117 | buffer_curr[i].value = right; 118 | from[i] = i; 119 | buffer_curr[i].keep = root.keep + 1; 120 | } 121 | // 暂停 122 | const rightdown = root.value + Math.log(Math.max((1 - hasNote) * (1 - keepP), 1e-12)); 123 | if (buffer_curr[i + 1].value < rightdown) { 124 | buffer_curr[i + 1].value = rightdown; 125 | from[i + 1] = i; 126 | buffer_curr[i + 1].keep = 0; 127 | } 128 | } 129 | } 130 | // 再处理纵向扩散 第一位永远是-1可以跳过 131 | for (let i = 1; i < noteSeq.length; ++i) { 132 | if (buffer_curr[i - 1].value === -Infinity) break; 133 | if (noteSeq[i - 1] === -1) { 134 | const hasNote = Math.pow(fn(frameSpectrum[noteSeq[i]]), k); 135 | const down = buffer_curr[i - 1].value + Math.log(Math.max(hasNote * 0.8, 1e-12)); 136 | if (buffer_curr[i].value < down) { 137 | buffer_curr[i].value = down; 138 | from[i] = from[i - 1]; 139 | buffer_curr[i].keep = 0; 140 | } 141 | } 142 | } 143 | // 交换位置并复原 144 | [buffer_curr, buffer_prev] = [buffer_prev, buffer_curr]; 145 | for (const i of buffer_curr) { 146 | i.keep = 0; 147 | i.value = -Infinity; 148 | } 149 | } 150 | // 寻路 151 | const path = []; 152 | let noteidx = noteSeq.length - 1; 153 | for (let frame = P.length - 1; frame >= 0; frame--) { 154 | path.push([noteidx, frame]); 155 | noteidx = P[frame][noteidx]; 156 | } 157 | path.reverse(); 158 | return path; 159 | } -------------------------------------------------------------------------------- /saver.js: -------------------------------------------------------------------------------- 1 | /* 示例 2 | function save() { 3 | let B = bSaver.Float32Mat2Buffer(b); 4 | let A = bSaver.Object2Buffer(a); 5 | bSaver.saveArrayBuffer(bSaver.combineArrayBuffers( 6 | [B, A] 7 | ), "test.nd"); 8 | } 9 | var result; 10 | function parse() { 11 | let input = document.createElement("input"); 12 | input.type = "file"; 13 | input.onchange = function() { 14 | let file = input.files[0]; 15 | bSaver.readBinary(file, (arrayBuffer)=>{ 16 | let [B, o] = bSaver.Buffer2Float32Mat(arrayBuffer, 0); 17 | let [A, o2] = bSaver.Buffer2Object(arrayBuffer, o); 18 | result = [A,B]; 19 | console.log(result); 20 | }) 21 | } input.click(); 22 | } 23 | */ 24 | // 保存和读取二进制数据的工具 25 | // 每个数据段开头会有Uint32(可能多个)的长度信息, 用于保存该数据段的长度 26 | window.bSaver = { 27 | /** 28 | * 将二维的Float32Array的Array转为可解析的一维ArrayBuffer 29 | * 要求每个Float32Array的长度相同 30 | * @param {Array} Float32Mat 31 | * @returns {ArrayBuffer} 二进制数组 开头有两个Uint32的长度信息, 用于保存每个Float32Array的长度和Float32Mat的长度 32 | */ 33 | Float32Mat2Buffer(Float32Mat) { 34 | // 先保存两个维度的长度: 每个Float32Array的长度和Float32Mat的长度 35 | const lengthArray = new Uint32Array([Float32Mat[0].length, Float32Mat.length]); 36 | let offset = lengthArray.byteLength; 37 | let bn = Float32Mat[0].byteLength; 38 | const finalArrayBuffer = new ArrayBuffer(offset + bn * Float32Mat.length); 39 | new Uint32Array(finalArrayBuffer, 0, 2).set(lengthArray); 40 | // 再将每个Float32Array的数据拷贝到finalArrayBuffer中 41 | for (const floatArray of Float32Mat) { 42 | new Float32Array(finalArrayBuffer, offset).set(floatArray); 43 | offset += bn; 44 | } return finalArrayBuffer; 45 | }, 46 | /** 47 | * 解析Float32Mat2Buffer得到的二进制数组为二维的Float32Array的Array 48 | * @param {ArrayBuffer} arrayBuffer 待解析的二进制数组 49 | * @param {Number} offset 读取的byte偏移量 50 | * @returns {[Array, Number]} 解析后的二维Float32Array数组和读取结束后的byte偏移量 51 | */ 52 | Buffer2Float32Mat(arrayBuffer, offset = 0) { 53 | offset = Math.ceil(offset / 4) << 2; // offset变为4的倍数 54 | let lengthArray = new Uint32Array(arrayBuffer, offset, 2); 55 | offset += lengthArray.byteLength; 56 | let [n, N] = lengthArray; 57 | const mergedFloatArray = new Float32Array(arrayBuffer, offset, n * N); 58 | const Float32Mat = new Array(N); 59 | for (let i = 0, j = 0; i < N; i++, j += n) { 60 | Float32Mat[i] = mergedFloatArray.subarray(j, j + n); 61 | } return [Float32Mat, offset + mergedFloatArray.byteLength]; 62 | }, 63 | /** 64 | * 将字符串转为可解析的二进制数组 65 | * @param {String} str 字符串 66 | * @returns {ArrayBuffer} 二进制数组 开头有一个Uint32的长度信息记录BinaryData的长度 67 | */ 68 | String2Buffer(str) { 69 | const jsonBinaryData = new TextEncoder().encode(str); 70 | // 用一个Uint32Array保存jsonBinaryData的长度 71 | const lengthArray = new Uint32Array([jsonBinaryData.byteLength]); 72 | const finalArrayBuffer = new ArrayBuffer(lengthArray.byteLength + jsonBinaryData.byteLength); 73 | new Uint32Array(finalArrayBuffer, 0, 1).set(lengthArray); 74 | new Uint8Array(finalArrayBuffer, lengthArray.byteLength).set(new Uint8Array(jsonBinaryData)); 75 | return finalArrayBuffer; 76 | }, 77 | /** 78 | * 解析String2Buffer得到的二进制数组为字符串 79 | * @param {ArrayBuffer} arrayBuffer 待解析的二进制数组 80 | * @param {Number} offset 读取的byte偏移量 81 | * @returns {[String, Number]} 解析后的对象和读取结束后的byte偏移量 82 | */ 83 | Buffer2String(arrayBuffer, offset = 0) { 84 | offset = Math.ceil(offset / 4) << 2; // offset变为4的倍数 85 | const lengthArray = new Uint32Array(arrayBuffer, offset, 1); 86 | offset += lengthArray.byteLength; 87 | const strBinaryData = new Uint8Array(arrayBuffer, offset, lengthArray[0]); 88 | const str = new TextDecoder().decode(strBinaryData); 89 | return [str, offset + strBinaryData.byteLength]; 90 | }, 91 | /** 92 | * 将一个可以被JSON.stringify的对象转为可解析的二进制数组 93 | * @param {Object} obj 可以被JSON.stringify的对象 94 | * @returns {ArrayBuffer} 二进制数组 开头有一个Uint32的长度信息记录jsonBinaryData的长度 95 | */ 96 | Object2Buffer(obj) { 97 | return this.String2Buffer(JSON.stringify(obj)); 98 | }, 99 | /** 100 | * 解析Object2Buffer得到的二进制数组为一个可以被JSON.stringify的对象 101 | * @param {ArrayBuffer} arrayBuffer 待解析的二进制数组 102 | * @param {Number} offset 读取的byte偏移量 103 | * @returns {[Object, Number]} 解析后的对象和读取结束后的byte偏移量 104 | */ 105 | Buffer2Object(arrayBuffer, offset = 0) { 106 | let [jsonString, o] = this.Buffer2String(arrayBuffer, offset); 107 | return [JSON.parse(jsonString), o]; 108 | }, 109 | /** 110 | * 合并多个ArrayBuffer为一个ArrayBuffer 111 | * 会按4byte对齐每一个ArrayBuffer的起始位置 112 | * @param {Array} arrayBuffers ArrayBuffer的数组 113 | * @returns {ArrayBuffer} 合并后的ArrayBuffer 114 | */ 115 | combineArrayBuffers(arrayBuffers) { 116 | let totalByteLength = 0; 117 | const lengthArray = arrayBuffers.map((arrayBuffer) => { 118 | // 用4字节对齐,因为开头是Uint32的长度信息: start offset of Uint32Array should be a multiple of 4 119 | let len4 = Math.ceil(arrayBuffer.byteLength / 4) << 2; 120 | totalByteLength += len4; 121 | return len4; 122 | }); 123 | const combinedArrayBuffer = new ArrayBuffer(totalByteLength); 124 | for(let i = 0, offset = 0; i < arrayBuffers.length; i++) { 125 | new Uint8Array(combinedArrayBuffer, offset).set(new Uint8Array(arrayBuffers[i])); 126 | offset += lengthArray[i]; 127 | } return combinedArrayBuffer; 128 | }, 129 | /** 130 | * 将二进制数组保存为文件 131 | * @param {ArrayBuffer} arrayBuffer 待保存的二进制数组 132 | * @param {String} filename 保存的文件名 133 | */ 134 | saveArrayBuffer(arrayBuffer, filename) { 135 | // 创建一个 Blob 对象,将合并后的 ArrayBuffer 保存为二进制文件 136 | const blob = new Blob([arrayBuffer], { type: 'application/octet-stream' }); 137 | // 创建一个临时 URL,用于下载文件 138 | const downloadUrl = URL.createObjectURL(blob); 139 | // 创建一个虚拟的下载链接并触发点击事件 140 | const downloadLink = document.createElement('a'); 141 | downloadLink.href = downloadUrl; 142 | downloadLink.download = filename; 143 | downloadLink.click(); 144 | // 释放临时 URL 对象 145 | URL.revokeObjectURL(downloadUrl); 146 | }, 147 | // 读取文件为ArrayBuffer 148 | readBinary(file, callback) { 149 | const fileReader = new FileReader(); 150 | fileReader.onload = (e) => { 151 | callback(e.target.result); 152 | }; fileReader.readAsArrayBuffer(file); 153 | } 154 | }; -------------------------------------------------------------------------------- /app_beatbar.js: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | /** 5 | * 顶部小节轴 6 | * @param {App} parent 7 | */ 8 | function _BeatBar(parent) { 9 | this.beats = new Beats(); 10 | this.minInterval = 20; // 最小画线间隔 11 | this.update = () => { 12 | const canvas = parent.timeBar; 13 | const ctx = parent.timeBar.ctx; 14 | 15 | ctx.fillStyle = '#2e3039'; 16 | const h = canvas.height >> 1; 17 | ctx.fillRect(0, h, canvas.width, canvas.width); 18 | ctx.fillStyle = '#8e95a6'; 19 | const spectrum = parent.spectrum.ctx; 20 | const spectrumHeight = parent.spectrum.height; 21 | ctx.strokeStyle = '#f0f0f0'; 22 | spectrum.strokeStyle = '#c0c0c0'; 23 | 24 | const beatX = []; // 小节内每一拍 25 | const noteX = []; // 一拍内x分音符对齐线 26 | 27 | const iterator = this.beats.iterator(parent.scrollX * parent.TperP, true); 28 | ctx.beginPath(); spectrum.beginPath(); 29 | while (1) { 30 | let measure = iterator.next(); 31 | if (measure.done) break; 32 | measure = measure.value; 33 | let x = measure.start * parent.PperT - parent.scrollX; 34 | if (x > canvas.width) break; 35 | ctx.moveTo(x, h); 36 | ctx.lineTo(x, canvas.height); 37 | spectrum.moveTo(x, 0); 38 | spectrum.lineTo(x, spectrumHeight); 39 | // 写字 会根据间隔决定是否显示拍型 40 | let Interval = measure.interval * parent.PperT; 41 | ctx.fillText(Interval < 38 ? measure.id : `${measure.id}. ${measure.beatNum}/${measure.beatUnit}`, x + 2, h + 14); 42 | // 画更细的节拍线 43 | let dp = Interval / measure.beatNum; 44 | if (dp < this.minInterval) continue; 45 | x += dp; 46 | for (let i = measure.beatNum - 1; i > 0; i--, x += dp) beatX.push(x); 47 | // 画x分音符的线 48 | let noteNum = 1 << Math.log2(dp / this.minInterval); 49 | if (noteNum < 2) continue; 50 | let noteInterval = dp / noteNum; 51 | for (let i = 0, n = noteNum * measure.beatNum; i < n; i++, x -= noteInterval) { 52 | if (i % noteNum == 0) continue; // 跳过beat线 53 | noteX.push(x); 54 | } 55 | } ctx.stroke(); spectrum.stroke(); 56 | 57 | if (beatX.length == 0) return; 58 | spectrum.beginPath(); 59 | spectrum.strokeStyle = '#909090'; 60 | for (const x of beatX) { 61 | spectrum.moveTo(x, 0); 62 | spectrum.lineTo(x, spectrumHeight); 63 | } spectrum.stroke(); 64 | 65 | if (noteX.length == 0) return; 66 | spectrum.beginPath(); 67 | spectrum.setLineDash([4, 4]); 68 | spectrum.strokeStyle = '#606060'; 69 | for (const x of noteX) { 70 | spectrum.moveTo(x, 0); 71 | spectrum.lineTo(x, spectrumHeight); 72 | } spectrum.stroke(); 73 | spectrum.setLineDash([]); // 恢复默认 74 | }; 75 | this.contextMenu = new ContextMenu([ 76 | { 77 | name: "设置小节", 78 | callback: (e_father, e_self) => { 79 | const bs = this.beats; 80 | const m = bs.setMeasure((e_father.offsetX + parent.scrollX) * parent.TperP, undefined, true); 81 | const tempDiv = document.createElement('div'); 82 | tempDiv.innerHTML = ` 83 | 84 | 小节${m.id}设置 85 | 拍数 86 | 音符 87 | 2分 88 | 4分 89 | 8分 90 | 16分 91 | 92 | BPM: 93 | (忽略以上)和上一小节一样 94 | 应用到后面相邻同类型小节 95 | 取消确定 96 | 97 | `; 98 | const Pannel = tempDiv.firstElementChild; 99 | document.body.insertBefore(Pannel, document.body.firstChild); 100 | Pannel.tabIndex = 0; 101 | Pannel.focus(); 102 | function close() { Pannel.remove(); } 103 | const inputs = Pannel.querySelectorAll('[name="ui-ask"]'); 104 | const btns = Pannel.getElementsByTagName('button'); 105 | inputs[0].value = m.beatNum; // 拍数 106 | inputs[1].value = m.beatUnit; // 音符类型 107 | inputs[2].value = m.bpm; // bpm 108 | btns[0].onclick = close; 109 | btns[1].onclick = () => { 110 | if (!inputs[4].checked) { // 后面不变 111 | bs.setMeasure(m.id + 1, false); // 让下一个生成实体 112 | } 113 | if (inputs[3].checked) { // 和上一小节一样 114 | let last = bs.getMeasure(m.id - 1, false); 115 | m.copy(last); 116 | } else { 117 | m.beatNum = parseInt(inputs[0].value); 118 | m.beatUnit = parseInt(inputs[1].value); 119 | m.bpm = parseInt(inputs[2].value); 120 | } bs.check(); close(); 121 | }; 122 | } 123 | }, { 124 | name: "后方插入一小节", 125 | callback: (e_father) => { 126 | this.beats.add((e_father.offsetX + parent.scrollX) * parent.TperP, true); 127 | } 128 | }, { 129 | name: "重置后面所有小节", 130 | callback: (e_father) => { 131 | let base = this.beats.getBaseIndex((e_father.offsetX + parent.scrollX) * parent.TperP, true); 132 | this.beats.splice(base + 1); 133 | } 134 | }, { 135 | name: '删除该小节', 136 | callback: (e_father, e_self) => { 137 | this.beats.delete((e_father.offsetX + parent.scrollX) * parent.TperP, true); 138 | } 139 | } 140 | ]); 141 | this.belongID = -1; // 小节线前一个小节的id 142 | this.moveCatch = (e) => { // 画布上光标移动到小节线上可以进入调整模式 143 | // 判断是否在小节轴上 144 | if (e.offsetY < parent.timeBar.height >> 1) { 145 | parent.timeBar.classList.remove('selecting'); 146 | this.belongID = -1; 147 | return; 148 | } 149 | const timeNow = (e.offsetX + parent.scrollX) * parent.TperP; 150 | const m = this.beats.getMeasure(timeNow, true); 151 | if (m == null) { 152 | this.belongID = -1; 153 | parent.timeBar.classList.remove('selecting'); 154 | return; 155 | } 156 | let threshold = 6 * parent.TperP; 157 | if (timeNow - m.start < threshold) { 158 | this.belongID = m.id - 1; 159 | parent.timeBar.classList.add('selecting'); 160 | } else if (m.start + m.interval - timeNow < threshold) { 161 | this.belongID = m.id; 162 | parent.timeBar.classList.add('selecting'); 163 | } else { 164 | this.belongID = -1; 165 | parent.timeBar.classList.remove('selecting'); 166 | } 167 | } 168 | } -------------------------------------------------------------------------------- /dataProcess/AI/postprocess.js: -------------------------------------------------------------------------------- 1 | function createNotes( 2 | onsetTensor, frameTensor, 3 | frame_thresh = 0.22, 4 | onset_thresh = 0.38, 5 | min_note_len = 6, 6 | energy_tol = 10, 7 | midi_offset = 24 8 | ) { 9 | // 模型中已经对onset和frame进行归一化了 10 | const raw_frameData = frameTensor.cpuData; 11 | const frameDim = frameTensor.dims; // [1, 84, frames] 12 | const raw_onsetData = onsetTensor.cpuData; 13 | const onsetDim = onsetTensor.dims; // [1, 84, frames] 14 | // 两个dim应该一样 15 | if (frameDim[1] !== onsetDim[1] || frameDim[2] !== onsetDim[2]) { 16 | throw new Error("frameDim[1] !== onsetDim[1] || frameDim[2] !== onsetDim[2]"); 17 | } 18 | const frameNum = frameDim[2]; 19 | const noteNum = frameDim[1]; 20 | 21 | const frameData = Array(noteNum); 22 | const onsetData = Array(noteNum); 23 | for (let i = 0; i < noteNum; i++) { 24 | // 和raw共享内存 25 | frameData[i] = new Float32Array(raw_frameData.buffer, i * frameNum * 4, frameNum); 26 | onsetData[i] = new Float32Array(raw_onsetData.buffer, i * frameNum * 4, frameNum); 27 | } 28 | 29 | get_infered_onsets(onsetData, frameData, 3); 30 | 31 | const peaks = findPeak(onsetData, onset_thresh); 32 | peaks.sort((a, b) => b[0] - a[0]); // 按照时间反过来排序 33 | 34 | const remaining_energy = Array(noteNum); // 复制一份frameData,用于修改数据 35 | for (let i = 0; i < noteNum; i++) remaining_energy[i] = new Float32Array(frameData[i]); 36 | 37 | const note_events = []; 38 | for (const [note_start_idx, freq_idx] of peaks) { 39 | // 如果剩下的距离不够放一个最短的音符,就跳过 40 | if (note_start_idx >= frameNum - min_note_len) continue; 41 | 42 | let note_end_idx = note_start_idx + 1; 43 | let k = 0; // 连续k个小于frame_thresh的帧 44 | const freqArray = remaining_energy[freq_idx]; 45 | // 向后搜索,连续energy_tol帧小于frame_thresh(或者到达最后一帧),就认为这个音符结束。目的是将分散的frames合并 46 | while (note_end_idx < frameNum && k < energy_tol) { 47 | if (freqArray[note_end_idx] < frame_thresh) k++; 48 | else k = 0; 49 | note_end_idx++; 50 | } 51 | note_end_idx -= k; // 回到音符结尾 52 | 53 | if (note_end_idx - note_start_idx < min_note_len) continue; // 跳过短音符 54 | freqArray.fill(0, note_start_idx, note_end_idx); // 将这个音符的frame清零 55 | 56 | // 认为半音不会同时出现,因为不能构成和弦 57 | if (freq_idx < noteNum - 1) 58 | remaining_energy[freq_idx + 1].fill(0, note_start_idx, note_end_idx); 59 | if (freq_idx > 0) 60 | remaining_energy[freq_idx - 1].fill(0, note_start_idx, note_end_idx); 61 | 62 | // 对frameData在start和end中间的求平均 63 | let sum = 0; 64 | for (let i = note_start_idx; i < note_end_idx; i++) 65 | sum += frameData[freq_idx][i]; 66 | 67 | note_events.push({ 68 | onset: note_start_idx, 69 | offset: note_end_idx, 70 | note: freq_idx + midi_offset, 71 | velocity: sum / (note_end_idx - note_start_idx) 72 | }); 73 | } 74 | 75 | // 不依赖onset,根据frames中的极大值找额外的音符 76 | const maxes = []; 77 | for (let n = 0; n < noteNum; n++) { 78 | const thisNote = frameData[n]; 79 | for (let t = 1; t < frameNum; t++) { 80 | if (thisNote[t] > frame_thresh) maxes.push([thisNote[t], n, t]); 81 | } 82 | } 83 | maxes.sort((a, b) => b[0] - a[0]); // 按照能量从大到小排序 84 | 85 | for (const [_, n, t] of maxes) { 86 | // 可能被前面的循环置零了 87 | if (remaining_energy[n][t] < frame_thresh) continue; 88 | // 后向搜索 89 | let note_end_idx = t + 1; 90 | let k = 0; 91 | const freqArray = remaining_energy[n]; 92 | while (note_end_idx < frameNum && k < energy_tol) { 93 | if (freqArray[note_end_idx] < frame_thresh) k++; 94 | else k = 0; 95 | note_end_idx++; 96 | } 97 | note_end_idx -= k; 98 | // 前向搜索 99 | let note_start_idx = t - 1; 100 | k = 0; 101 | while (note_start_idx > 0 && k < energy_tol) { 102 | if (freqArray[note_start_idx] < frame_thresh) k++; 103 | else k = 0; 104 | note_start_idx--; 105 | } 106 | note_start_idx += (k + 1); // 之前多减了1,而fill是左闭右开 107 | 108 | // 不管长度符不符合,都置零 109 | freqArray.fill(0, note_start_idx, note_end_idx); 110 | if (n < noteNum - 1) 111 | remaining_energy[n + 1].fill(0, note_start_idx, note_end_idx); 112 | if (n > 0) 113 | remaining_energy[n - 1].fill(0, note_start_idx, note_end_idx); 114 | 115 | 116 | if (note_end_idx - note_start_idx < min_note_len) continue; 117 | 118 | let sum = 0; 119 | for (let i = note_start_idx; i < note_end_idx; i++) 120 | sum += frameData[n][i]; 121 | 122 | note_events.push({ 123 | onset: note_start_idx, 124 | offset: note_end_idx, 125 | note: n + midi_offset, 126 | velocity: sum / (note_end_idx - note_start_idx) 127 | }); 128 | } 129 | return note_events; 130 | } 131 | 132 | /** 133 | * 从frame中推断新的onset 会修改传入的的onsets 134 | * @param {Array} onsets 135 | * @param {Array} frames 136 | * @param {number} n_diff 137 | */ 138 | function get_infered_onsets(onsets, frames, n_diff = 2) { 139 | const frameNum = frames[0].length; 140 | const noteNum = frames.length; 141 | const inffered_onsets = Array(noteNum); 142 | let infered_max = -1e10; // 用于归一化 143 | for (let n = 0; n < noteNum; n++) { 144 | const notetime = new Float32Array(frameNum); 145 | const thisFrame = frames[n]; 146 | for (let t = n_diff; t < frameNum; t++) { 147 | let min_diff = 1e10; 148 | // 对每个时间点求最小的差值 149 | for (let k = 1; k <= n_diff; k++) { 150 | let diff = thisFrame[t] - thisFrame[t - k]; 151 | if (diff < min_diff) min_diff = diff; 152 | } 153 | if (min_diff > infered_max) infered_max = min_diff; 154 | notetime[t] = min_diff; 155 | } 156 | inffered_onsets[n] = notetime 157 | } 158 | // 归一化 由于onset在模型内部已经归一化了,所以onset的最大值就是1 159 | for (let n = 0; n < noteNum; n++) { 160 | for (let t = 0; t < frameNum; t++) { 161 | let temp = inffered_onsets[n][t] / infered_max; 162 | if (temp > onsets[n][t]) onsets[n][t] = temp; 163 | } 164 | } 165 | } 166 | 167 | function findPeak(x2d, threshold = 0) { 168 | const H = x2d.length; 169 | const W = x2d[0].length - 1; 170 | let peak = []; 171 | for (let h = 0; h < H; h++) { 172 | const row = x2d[h]; 173 | let last_is_up = true; // 由于模型用的是sigmoid,所以全部大于零,所以第一个之前的导数一定大于零 174 | for (let w = 0; w < W; w++) { 175 | if (row[w] < threshold) continue; 176 | if (last_is_up) { 177 | if (row[w] > row[w + 1]) { // 下一个小于当前,说明当前是峰值 178 | peak.push([w, h]); 179 | last_is_up = false; 180 | } else if (row[w] == row[w + 1]) { 181 | let _w = w + 1; 182 | // 下一个等于当前,要看后面第一个非零导数是否小于零 183 | while (_w < W) { 184 | if (row[_w] == row[_w + 1]) _w++; 185 | else if (row[_w] < row[_w + 1]) break; 186 | else { // 后面变小了,说明当前是峰值 187 | last_is_up = false; 188 | peak.push([w, h]); 189 | w = _w; 190 | break; 191 | } 192 | } 193 | } 194 | } else { 195 | last_is_up = (row[w] < row[w + 1]); 196 | } 197 | } 198 | } return peak; 199 | } -------------------------------------------------------------------------------- /style/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --theme-light: #2e3039; 3 | --theme-middle: #25262d; 4 | --theme-dark: #1e1f24; 5 | --theme-text: #8e95a6; 6 | } 7 | 8 | * { 9 | margin: 0; 10 | padding: 0; 11 | user-select: none; 12 | } 13 | 14 | html, 15 | body { 16 | height: 100%; 17 | font-size: 16px; 18 | color: var(--theme-text); 19 | background-color: var(--theme-dark); 20 | overflow: hidden; 21 | } 22 | 23 | @media (max-width: 890px) { 24 | .top-logo { 25 | display: none; 26 | } 27 | } 28 | 29 | canvas { 30 | display: block; 31 | margin: 0; 32 | border: none; 33 | } 34 | 35 | ul { 36 | list-style: none; 37 | } 38 | 39 | button { 40 | background-color: var(--theme-dark); 41 | border: none; 42 | cursor: pointer; 43 | color: var(--theme-text); 44 | font-size: 16px; 45 | } 46 | 47 | /* flex */ 48 | .f { 49 | display: flex; 50 | } 51 | 52 | .fc { 53 | display: flex; 54 | flex-direction: column; 55 | } 56 | 57 | .fr { 58 | display: flex; 59 | flex-direction: row; 60 | } 61 | 62 | /* width full */ 63 | .wf { 64 | width: 100%; 65 | } 66 | 67 | .dragIn::before { 68 | content: "Drag and drop your file here"; 69 | display: flex; 70 | justify-content: center; 71 | align-items: center; 72 | position: absolute; 73 | z-index: 98; 74 | width: 100%; 75 | height: 100%; 76 | background-color: #1e1f24bb; 77 | border: var(--theme-text) 3px dashed; 78 | box-sizing: border-box; 79 | font-size: 2em; 80 | } 81 | 82 | .card { 83 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); 84 | border-radius: 10px; 85 | } 86 | 87 | .hvCenter { 88 | top: 50%; 89 | left: 50%; 90 | transform: translate(-50%, -50%); 91 | } 92 | 93 | #scrollbar-track { 94 | margin: 0; 95 | padding: 0; 96 | border: none; 97 | position: relative; 98 | height: 1em; 99 | background-color: rgb(37, 38, 45); 100 | } 101 | 102 | #scrollbar-thumb { 103 | margin: 0; 104 | padding: 0; 105 | border: none; 106 | position: absolute; 107 | height: 100%; 108 | width: 50px; 109 | background-color: rgb(69, 72, 81); 110 | cursor: ew-resize; 111 | } 112 | 113 | #play-btn { 114 | background-color: var(--theme-middle); 115 | color: var(--theme-text); 116 | border: none; 117 | border-right: var(--theme-dark) solid 3px; 118 | position: relative; 119 | font-size: 0.8em; 120 | cursor: pointer; 121 | } 122 | 123 | .flexfull { 124 | flex: 1; 125 | /* 下面这行必须加 不然被画布撑开了就缩不回去了 */ 126 | overflow: hidden; 127 | } 128 | 129 | .tools { 130 | background-color: transparent; 131 | display: flex; 132 | justify-content: space-between; 133 | align-items: center; 134 | padding: 2px; 135 | position: relative; 136 | z-index: 1; 137 | } 138 | 139 | .top-logo { 140 | height: 36px; 141 | padding: 0 8px; 142 | cursor: pointer; 143 | } 144 | 145 | /* 可以套在外面的盒子,盒子中可以放属性名 */ 146 | .rangeBox { 147 | height: 1em; 148 | line-height: 1em; 149 | display: flex; 150 | flex-direction: row; 151 | align-items: center; 152 | justify-content: left; 153 | margin: 0.25em; 154 | } 155 | 156 | /* 工具选择 */ 157 | .switch-bar { 158 | display: inline-block; 159 | background-color: transparent; 160 | /* 消除因为换行和缩进带来的间隔 */ 161 | white-space: nowrap; 162 | font-size: 0; 163 | } 164 | 165 | .switch-bar button { 166 | color: white; 167 | padding: 0.5em 0.6em; 168 | border-radius: 0; 169 | background: rgb(50, 53, 62); 170 | position: relative; 171 | z-index: 0; 172 | /* 恢复默认大小 */ 173 | font-size: 16px; 174 | } 175 | 176 | .switch-bar button:first-child { 177 | border-top-left-radius: 5px; 178 | border-bottom-left-radius: 5px; 179 | } 180 | 181 | .switch-bar button:last-child { 182 | border-top-right-radius: 5px; 183 | border-bottom-right-radius: 5px; 184 | } 185 | 186 | .switch-bar .selected { 187 | background-color: rgb(60, 87, 221); 188 | } 189 | /* 下方标签 */ 190 | .labeled { 191 | position: relative; 192 | } 193 | .labeled::after { 194 | content: attr(data-tooltip); 195 | font-size: 12px; 196 | color: var(--theme-text); 197 | background-color: var(--theme-light); 198 | white-space: pre; 199 | padding: 4px 8px; 200 | position: absolute; 201 | z-index: 2; 202 | bottom: 0; 203 | left: 50%; 204 | transform: translateY(105%) translateX(-50%); 205 | border-radius: 4px; 206 | border: var(--theme-dark) solid 2px; 207 | display: none; 208 | } 209 | .labeled:hover::after { 210 | display: block; 211 | } 212 | 213 | /* 用列表组织的菜单 */ 214 | .btn-ul li { 215 | margin: 0 -0.3em; 216 | padding: 0.6em; 217 | border-radius: 4px; 218 | } 219 | .btn-ul li:hover { 220 | background-color: var(--theme-light); 221 | color: white; 222 | } 223 | .btn-ul button { 224 | padding: 0.25em 0.6em; 225 | margin-bottom: 0.3em; 226 | border-radius: 6px; 227 | border: black solid 2px; 228 | } 229 | .btn-ul button:hover { 230 | color: white; 231 | } 232 | .btn-ul button:active { 233 | background-color: black; 234 | } 235 | textarea { 236 | width: calc(100% - 0.5em); 237 | height: 100%; 238 | padding: 0.3em; 239 | border: none; 240 | border-radius: 6px; 241 | background-color: var(--theme-dark); 242 | color: var(--theme-text); 243 | resize: vertical; 244 | } 245 | 246 | /* EQ控制面板 */ 247 | #EQcontrol { 248 | display: flex; 249 | flex-direction: column; 250 | align-items: center; 251 | margin-bottom: 1em; 252 | } 253 | #EQcontrol h5 { /* 频率值 */ 254 | margin: 0.4em 0 0 0; 255 | padding: 0; 256 | } 257 | #EQcontrol .myrange { 258 | width: 100%; 259 | margin: 0 0 0.3em 0; 260 | } 261 | #EQcontrol input { 262 | width: 100%; 263 | } 264 | 265 | /* 漂亮的滑动条 */ 266 | .niceScroll { 267 | overflow: auto; 268 | } 269 | .niceScroll::-webkit-scrollbar { 270 | width: 12px; 271 | } 272 | .niceScroll::-webkit-scrollbar-thumb { 273 | background-color: rgb(50, 53, 62); 274 | border: 3px solid rgb(37, 38, 45); 275 | border-radius: 6px; 276 | } 277 | .niceScroll::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { 278 | background-color: rgb(37, 38, 45); 279 | } 280 | 281 | /* 分析面板 */ 282 | .tonalityResult { 283 | width: 100%; 284 | border-left: white 1px solid; 285 | } 286 | 287 | .tonalityResult div { 288 | color: var(--theme-dark); 289 | height: 1em; 290 | font-size: 1em; 291 | line-height: 1em; 292 | border-top-right-radius: 4px; 293 | border-bottom-right-radius: 4px; 294 | } 295 | 296 | /* 设置面板 */ 297 | #settingPannel button { 298 | margin: 0; 299 | } 300 | #settingPannel li { 301 | display: flex; 302 | flex-direction: row; 303 | justify-content: center; 304 | align-items: center; 305 | flex-wrap: wrap; 306 | position: relative; 307 | } 308 | #settingPannel li::after { 309 | content: attr(data-value); 310 | position: absolute; 311 | right: 0; 312 | bottom: 0; 313 | font-size: 10px; 314 | } 315 | #settingPannel li button:first-of-type { 316 | font-family: monospace; 317 | margin-right: 0.2em; 318 | } 319 | #settingPannel li button:last-of-type { 320 | font-family: monospace; 321 | margin-left: 0.2em; 322 | } 323 | #repeatRange { 324 | display: flex; 325 | flex-wrap: nowrap; 326 | align-items: center; 327 | justify-content: center; 328 | width: 100%; 329 | margin: 0.4em 0; 330 | } 331 | #repeatRange input[type="text"] { 332 | width: 46%; 333 | border-radius: 4px; 334 | padding-left: 4px; 335 | border: none; 336 | border: black solid 1px; 337 | font-size: 0.9em; 338 | color: var(--theme-text); 339 | background: var(--theme-dark); 340 | } 341 | #repeatRange input[type="text"]:focus { 342 | color: white; 343 | } 344 | 345 | canvas.selecting { 346 | cursor: ew-resize; 347 | } -------------------------------------------------------------------------------- /beatBar.js: -------------------------------------------------------------------------------- 1 | // 本文件用于管理小节信息,实现了稀疏存储小节的数据结构 2 | class aMeasure { 3 | /** 4 | * 构造一个小节 5 | * @param {Number | aMeasure} beatNum 分子 几拍为一小节; 如果是aMeasure对象则复制构造 6 | * @param {Number} beatUnit 分母 几分音符是一拍 7 | * @param {Number} interval 一个小节的时间,单位ms 8 | */ 9 | constructor(beatNum = 4, beatUnit = 4, interval = 2000) { 10 | if (typeof beatNum === 'number') { 11 | this.beatNum = beatNum; 12 | this.beatUnit = beatUnit; 13 | this.interval = interval; 14 | } else { // 复制构造 15 | this.beatNum = beatNum.beatNum; 16 | this.beatUnit = beatNum.beatUnit; 17 | this.interval = beatNum.interval; 18 | } 19 | } 20 | static fromBpm(beatNum, beatUnit, bpm) { 21 | let interval = 60000 * beatNum / bpm; 22 | return new aMeasure(beatNum, beatUnit, interval); 23 | } 24 | copy(obj) { 25 | this.beatNum = obj.beatNum; 26 | this.beatUnit = obj.beatUnit; 27 | this.interval = obj.interval; 28 | return this; 29 | } 30 | // 不关注bpm,而关注interval。所以修改了beatNum会导致bpm变化 31 | get bpm() { 32 | return 60000 / this.interval * this.beatNum; 33 | } 34 | set bpm(value) { 35 | this.interval = 60000 * this.beatNum / value; 36 | } 37 | isEqual(other) { 38 | return this.interval === other.interval && this.beatNum === other.beatNum && this.beatUnit === other.beatUnit; 39 | } 40 | } 41 | 42 | // extended aMeasure 43 | class eMeasure extends aMeasure { 44 | /** 45 | * 构造一个有位置信息的小节 46 | * @param {Number | eMeasure} id 小节号 或 eMeasure对象(复制构造) 47 | * @param {Number} start 小节开始时间 单位ms 48 | * @param {Number | aMeasure} beatNum 49 | * @param {Number} beatUnit 50 | * @param {Number} interval 51 | */ 52 | constructor(id = 0, start = 0, beatNum, beatUnit, interval) { 53 | if(typeof id === 'number') { 54 | super(beatNum, beatUnit, interval); 55 | this.id = id; // 第几小节 56 | this.start = start; // 开始的时间 单位ms 57 | } else { 58 | super(id); 59 | this.id = id.id; 60 | this.start = id.start; 61 | } 62 | } 63 | /** 64 | * 基于某个小节构造一个新的小节 65 | * @param {eMeasure} base 同类型的小节 66 | * @param {Number} id 小节号 67 | * @param {aMeasure} measure 如果要修改值就传 否则参数同base 68 | * @returns 69 | */ 70 | static baseOn(base, id, measure = undefined) { 71 | return new eMeasure(id, (id - base.id) * base.interval + base.start, measure || base); 72 | } 73 | } 74 | 75 | class Beats extends Array { 76 | /** 77 | * 构造一个稀疏数组,只存储节奏变化 78 | * @param {Number} maxTime 乐曲时长 单位ms 79 | */ 80 | constructor(maxTime = 60000) { 81 | super(1); 82 | this.maxTime = maxTime; // 用于迭代 83 | this[0] = new eMeasure(0, 0); 84 | } 85 | /** 86 | * 找到当前小节模式的小节头 87 | * @param {Number} at 当前小节的时间或小节号 88 | * @param {Boolean} timeMode at是否表示毫秒时间 89 | * @returns {Number} 小节头在实际数组中的位置 90 | */ 91 | getBaseIndex(at, timeMode = false) { 92 | let attr = timeMode ? 'start' : 'id'; 93 | for (let i = this.length - 1; i >= 0; i--) { 94 | if (this[i][attr] <= at) return i; 95 | } return -1; 96 | } 97 | /** 98 | * 迭代器屏蔽了数组的稀疏性 如要连续取值,在元素多的时候效果比getMeasure(id)好 99 | * 注意传入的参数需要自行匹配好,否则后果未知 建议用this.iterator()代替此函数 100 | * @param {Number} index 开始的序号 101 | * @param {Number} baseAt 基于的eMeasure对象在实际数组中的位置 102 | * @returns next() 103 | */ 104 | [Symbol.iterator](index = 0, baseAt = 0) { 105 | return { 106 | next: () => { 107 | // 确定base 108 | let nextBase = this[baseAt + 1]; 109 | if (nextBase && nextBase.id === index) baseAt++; 110 | else nextBase = this[baseAt]; 111 | // 得到小节信息 112 | let value = eMeasure.baseOn(nextBase, index++); 113 | // 判断是否越界 114 | if (value.start >= this.maxTime) return { done: true }; 115 | return { 116 | value: value, 117 | done: false 118 | }; 119 | } 120 | }; 121 | } 122 | /** 123 | * 从任意位置开始的迭代器 124 | * @param {Number} at 位置 125 | * @param {Boolean} timeMode at是否表示毫秒时间 126 | * @returns 迭代器 127 | */ 128 | iterator(at, timeMode = false) { // 由于在绘制更新中使用,故没有复用getBaseIndex以加速运行 129 | let attr = timeMode ? 'start' : 'id'; 130 | for (let i = this.length - 1; i >= 0; i--) { 131 | if (this[i][attr] <= at) { 132 | let id = timeMode ? this[i].id + ((at - this[i].start) / this[i].interval) | 0 : at; 133 | return this[Symbol.iterator](id, i); 134 | } 135 | } return { 136 | next: () => ({ done: true }) 137 | } 138 | } 139 | /** 140 | * 根据小节号返回一个只读的小节 141 | * @param {Number} at 小节号或覆盖该时刻的小节 142 | * @param {Boolean} timeMode 传入的是否是时间 143 | * @returns {eMeasure} 小节信息,修改返回值不会影响原数组 如果越界则返回null 144 | */ 145 | getMeasure(at, timeMode = false) { 146 | let i = this.getBaseIndex(at, timeMode); 147 | if (i == -1) return null; 148 | let id = timeMode ? this[i].id + ((at - this[i].start) / this[i].interval) | 0 : at; 149 | let m = eMeasure.baseOn(this[i], id); 150 | if (m.start >= this.maxTime) return null; 151 | return m; 152 | } 153 | /** 154 | * 返回一个可以修改的对象。若修改返回值会影响原数组,修改后应调用this.check() 155 | * @param {Number} at 修改第几小节或覆盖该时间的小节 156 | * @param {aMeasure} measure 小节信息 157 | * @param {Boolean} timeMode at是否表示毫秒时间 158 | * @returns {eMeasure} 可以修改的对象 可以不传,通过修改返回值+check()来设置 如果越界则返回null 159 | */ 160 | setMeasure(at, measure = undefined, timeMode = false) { 161 | let i = this.getBaseIndex(at, timeMode); 162 | if (i == -1) return null; 163 | // 检查id是否存在 如果不存在就找到第一个data.id > id的位置插入 164 | let id = timeMode ? this[i].id + ((at - this[i].start) / this[i].interval) | 0 : at; 165 | if (this[i].id == id) { 166 | if (measure) this[i].copy(measure); 167 | return this[i]; 168 | } 169 | if (this[i].id < id) { 170 | // 不管是否重复 重复性的检查交给check 171 | let m = eMeasure.baseOn(this[i], id, measure); 172 | if (m.start >= this.maxTime) return null; 173 | this.splice(i + 1, 0, m); return m; 174 | } 175 | } 176 | 177 | /** 178 | * 整理小节信息: 179 | * 1. 合并前后参数一样的小节 (由merge控制是否合并) 180 | * 2. 校准每个小节的开始时间 181 | * 应该在添加、删除、修改数组元素后调用 需要手动调用 182 | * 在鼠标操作预览时不应合并小节 (会造成中途小节丢失) 183 | */ 184 | check(merge = true) { 185 | this[0].start = 0; 186 | this[0].id = 0; 187 | for (let i = 0, end = this.length - 1; i < end; i++) { 188 | if (this[i].start > this.maxTime) { 189 | this.splice(i); return; 190 | } 191 | if (merge && this[i].isEqual(this[i + 1])) { 192 | this.splice(i + 1, 1); 193 | i--; end--; 194 | } else this[i + 1].start = (this[i + 1].id - this[i].id) * this[i].interval + this[i].start; 195 | } 196 | } 197 | /** 198 | * 删除一个小节 199 | * @param {Number} at 位置 200 | * @param {Boolean} timeMode at是否表示毫秒时间 201 | */ 202 | delete(at, timeMode = false) { 203 | let attr = timeMode ? 'start' : 'id'; 204 | for (let i = this.length - 1; i >= 0; i--) { 205 | if (this[i][attr] <= at) { 206 | // 如果只有一个小节,则删除小节头 207 | if (this[i + 1] && this[i].id === this[i + 1].id) { // this[i+1].id不用减1,因为已经减过了 208 | this.splice(i, 1); 209 | } break; 210 | } this[i].id--; // 后面的都前移一格 211 | } this.check(); 212 | } 213 | /** 214 | * 增加一个小节,小节属性同前一个小节 215 | * @param {Number} at 位置 216 | * @param {Boolean} timeMode at是否表示毫秒时间 217 | */ 218 | add(at, timeMode = false) { 219 | let attr = timeMode ? 'start' : 'id'; 220 | for (let i = this.length - 1; i >= 0; i--) { 221 | if (this[i][attr] <= at) break; 222 | this[i].id++; // 后面的都后移一格 223 | } this.check(); 224 | } 225 | /** 226 | * 拷贝数据 用户撤销恢复 227 | * @param {Beats} beatArray 228 | * @returns {Beats} this 229 | */ 230 | copy(beatArray) { 231 | this.length = beatArray.length; 232 | for (let i = beatArray.length - 1; i >= 0; i--) { 233 | this[i] = new eMeasure(beatArray[i]); 234 | } return this; 235 | } 236 | } -------------------------------------------------------------------------------- /dataProcess/analyser.js: -------------------------------------------------------------------------------- 1 | class FreqTable extends Float32Array { 2 | constructor(A4 = 440) { 3 | super(84); // 范围是C1-B7 4 | this.A4 = A4; 5 | } 6 | set A4(A4) { 7 | let Note4 = [ 8 | A4 * 0.5946035575013605, A4 * 0.6299605249474366, 9 | A4 * 0.6674199270850172, A4 * 0.7071067811865475, 10 | A4 * 0.7491535384383408, 11 | A4 * 0.7937005259840998, A4 * 0.8408964152537146, 12 | A4 * 0.8908987181403393, A4 * 0.9438743126816935, 13 | A4, A4 * 1.0594630943592953, 14 | A4 * 1.122462048309373 15 | ]; 16 | this.set(Note4.map(v => v / 8), 0); 17 | this.set(Note4.map(v => v / 4), 12); 18 | this.set(Note4.map(v => v / 2), 24); 19 | this.set(Note4, 36); 20 | this.set(Note4.map(v => v * 2), 48); 21 | this.set(Note4.map(v => v * 4), 60); 22 | this.set(Note4.map(v => v * 8), 72); 23 | } 24 | get A4() { 25 | return this[45]; 26 | } 27 | } 28 | 29 | class NoteAnalyser { // 负责解析频谱数据 30 | /** 31 | * @param {Number} df FFT的频率分辨率 32 | * @param {FreqTable || Number} freq 频率表(将被引用)或中央A的频率 33 | */ 34 | constructor(df, freq) { 35 | this.df = df; 36 | if (typeof freq === 'number') { 37 | this.freqTable = new FreqTable(freq); 38 | } else { 39 | this.freqTable = freq; 40 | } this.updateRange(); 41 | } 42 | set A4(freq) { 43 | this.freqTable.A4 = freq; 44 | this.updateRange(); 45 | } 46 | get A4() { 47 | return this.freqTable.A4; 48 | } 49 | updateRange() { 50 | let at = Array.from(this.freqTable.map((value) => Math.round(value / this.df))); 51 | at.push(Math.round((this.freqTable[this.freqTable.length - 1] * 1.059463) / this.df)) 52 | const range = new Float32Array(84); // 第i个区间的终点 53 | for (let i = 0; i < at.length - 1; i++) { 54 | range[i] = (at[i] + at[i + 1]) / 2; 55 | } this.rangeTable = range; 56 | } 57 | /** 58 | * 从频谱提取音符的频谱 原理是区间内求和 59 | * @param {Float32Array} real 实部 60 | * @param {Float32Array} imag 虚部 61 | * @returns {Float32Array} 音符的幅度谱 数据很小 62 | */ 63 | analyse(real, imag) { 64 | const noteAm = new Float32Array(84); 65 | let at = this.rangeTable[0] | 0; 66 | for (let i = 0; i < this.rangeTable.length; i++) { 67 | let end = this.rangeTable[i]; 68 | if (at == end) { // 如果相等则就算一次 乘法比幂运算快 69 | noteAm[i] = real[at] * real[at] + imag[at] * imag[at]; 70 | } else { 71 | for (; at < end; at++) { 72 | noteAm[i] += real[at] * real[at] + imag[at] * imag[at]; 73 | } 74 | if (at == end) { // end是整数,需要对半分 75 | let a2 = (real[end] * real[end] + imag[end] * imag[end]) / 2; 76 | noteAm[i] += a2; 77 | if (i < noteAm.length - 1) noteAm[i + 1] += a2; 78 | } 79 | } 80 | // FFT的结果需要除以N才是DTFT的结果 由于结果太小,统一放大10倍 经验得到再乘700可在0~255得到较好效果 81 | // 由于后续有归一化,所以这里不除也不开方 82 | // noteAm[i] = Math.sqrt(noteAm[i]) * 16 / real.length; 83 | } return noteAm; 84 | } 85 | /** 86 | * 能量谱归一化 87 | * @param {Array} engSpectrum 能量谱 每个元素未开方 88 | */ 89 | static normalize(engSpectrum) { 90 | // 1. 求每一帧的能量 91 | let energySum = 0; 92 | let frameEnergy = new Float32Array(engSpectrum.length); 93 | for (let t = 0; t < engSpectrum.length; t++) { 94 | const frame = engSpectrum[t]; 95 | for (let i = 0; i < frame.length; i++) { 96 | frameEnergy[t] += frame[i]; 97 | } 98 | energySum += frameEnergy[t]; 99 | } 100 | // 2. 计算能量方差 101 | let sigma = 1e-8; 102 | const meanEnergy = energySum / engSpectrum.length; 103 | for (let t = 0; t < engSpectrum.length; t++) { 104 | const delta = frameEnergy[t] - meanEnergy; 105 | sigma += delta * delta; 106 | } 107 | sigma = Math.sqrt(sigma / (engSpectrum.length - 1)); 108 | // 3. 归一化 109 | for (const frame of engSpectrum) { 110 | for (let i = 0; i < frame.length; i++) { 111 | frame[i] = Math.sqrt(frame[i] / sigma); 112 | } 113 | } 114 | return engSpectrum; 115 | } 116 | /** 117 | * 调性分析,原理是音符能量求和 118 | * @param {Array} noteTable 119 | * @returns {Array} 调性和音符的能量 120 | */ 121 | static Tonality(noteTable) { 122 | let energy = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; 123 | for (const atime of noteTable) { 124 | energy[0] += atime[0] ** 2 + atime[12] ** 2 + atime[24] ** 2 + atime[36] ** 2 + atime[48] ** 2 + atime[60] ** 2 + atime[72] ** 2; 125 | energy[1] += atime[1] ** 2 + atime[13] ** 2 + atime[25] ** 2 + atime[37] ** 2 + atime[49] ** 2 + atime[61] ** 2 + atime[73] ** 2; 126 | energy[2] += atime[2] ** 2 + atime[14] ** 2 + atime[26] ** 2 + atime[38] ** 2 + atime[50] ** 2 + atime[62] ** 2 + atime[74] ** 2; 127 | energy[3] += atime[3] ** 2 + atime[15] ** 2 + atime[27] ** 2 + atime[39] ** 2 + atime[51] ** 2 + atime[63] ** 2 + atime[75] ** 2; 128 | energy[4] += atime[4] ** 2 + atime[16] ** 2 + atime[28] ** 2 + atime[40] ** 2 + atime[52] ** 2 + atime[64] ** 2 + atime[76] ** 2; 129 | energy[5] += atime[5] ** 2 + atime[17] ** 2 + atime[29] ** 2 + atime[41] ** 2 + atime[53] ** 2 + atime[65] ** 2 + atime[77] ** 2; 130 | energy[6] += atime[6] ** 2 + atime[18] ** 2 + atime[30] ** 2 + atime[42] ** 2 + atime[54] ** 2 + atime[66] ** 2 + atime[78] ** 2; 131 | energy[7] += atime[7] ** 2 + atime[19] ** 2 + atime[31] ** 2 + atime[43] ** 2 + atime[55] ** 2 + atime[67] ** 2 + atime[79] ** 2; 132 | energy[8] += atime[8] ** 2 + atime[20] ** 2 + atime[32] ** 2 + atime[44] ** 2 + atime[56] ** 2 + atime[68] ** 2 + atime[80] ** 2; 133 | energy[9] += atime[9] ** 2 + atime[21] ** 2 + atime[33] ** 2 + atime[45] ** 2 + atime[57] ** 2 + atime[69] ** 2 + atime[81] ** 2; 134 | energy[10] += atime[10] ** 2 + atime[22] ** 2 + atime[34] ** 2 + atime[46] ** 2 + atime[58] ** 2 + atime[70] ** 2 + atime[82] ** 2; 135 | energy[11] += atime[11] ** 2 + atime[23] ** 2 + atime[35] ** 2 + atime[47] ** 2 + atime[59] ** 2 + atime[71] ** 2 + atime[83] ** 2; 136 | } 137 | // notes根据最大值归一化 138 | let max = Math.max(...energy); 139 | energy = energy.map((num) => num / max); 140 | // 找到最大的前7个音符 141 | const sortedIndices = energy.map((num, index) => index) 142 | .sort((a, b) => energy[b] - energy[a]) 143 | .slice(0, 7); 144 | sortedIndices.sort((a, b) => a - b); 145 | // 判断调性 146 | let tonality = sortedIndices.map((num) => { 147 | return num.toString(16); 148 | }).join(''); 149 | switch (tonality) { 150 | case '024579b': tonality = 'C'; break; 151 | case '013568a': tonality = 'C#'; break; 152 | case '124679b': tonality = 'D'; break; 153 | case '023578a': tonality = 'Eb'; break; 154 | case '134689b': tonality = 'E'; break; 155 | case '024579a': tonality = 'F'; break; 156 | case '13568ab': tonality = 'Gb'; break; 157 | case '024679b': tonality = 'G'; break; 158 | case '013578a': tonality = 'Ab'; break; 159 | case '124689b': tonality = 'A'; break; 160 | case '023579a': tonality = 'Bb'; break; 161 | case '13468ab': tonality = 'B'; break; 162 | default: tonality = 'Unknown'; break; 163 | } return [tonality, energy]; 164 | } 165 | /** 166 | * 标记大于阈值的音符 167 | * @param {Array} noteTable 时频图 168 | * @param {Number} threshold 阈值 169 | * @param {Number} from 170 | * @param {Number} to 171 | * @returns {Array} {x1,x2,y,ch,selected} 172 | */ 173 | static autoFill(noteTable, threshold, from = 0, to = 0) { 174 | let notes = []; 175 | let lastAt = new Uint16Array(noteTable[0].length).fill(65535); 176 | let time = from; // 迭代器指示 177 | if (!to || to > noteTable.length) to = noteTable.length; 178 | for (; time < to; time++) { 179 | const t = noteTable[time]; 180 | for (let i = 0; i < lastAt.length; i++) { 181 | let now = t[i] < threshold; // 现在不达标 182 | if (lastAt[i] != 65535) { 183 | if (now) { 184 | notes.push({ // 上一次有但是这次没有 185 | y: i, 186 | x1: lastAt[i], 187 | x2: time, 188 | ch: -1, selected: false 189 | }); lastAt[i] = 65535; 190 | } 191 | } else if (!now) lastAt[i] = time; // 上次没有这次有 192 | } 193 | } 194 | // 扫尾 195 | for (let i = 0; i < lastAt.length; i++) { 196 | if (lastAt[i] != 65535) notes.push({ 197 | y: i, 198 | x1: lastAt[i], 199 | x2: time, 200 | ch: -1, selected: false 201 | }); 202 | } return notes; 203 | } 204 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ~前端辅助人工扒谱工具~ 7 | 8 | 9 | # noteDigger! 10 | “Note Digger”——音符挖掘者,即扒谱。模仿的是软件wavetone,但是是双击即用、现代UI的前端应用。 11 | 目标是全部自己造轮子!即:不使用框架、不使用外部库;目的是减小项目大小,并掌握各个环节。目前频谱分析的软件非常多,功能也超级强大,自知比不过……所以唯一能一战的就是项目体积了!作为一个纯前端项目,就要把易用的优点完全发扬! 12 | [在线使用](https://madderscientist.github.io/noteDigger/) 13 | 视频演示(视频发布于更新节奏对齐之前) 14 | 15 | 16 | ## 使用流程 17 | 1. 在线or下载到本地,用主流现代浏览器打开(开发使用Chrome)。 18 | 2. 导入音频——文件-上传,或直接将音频拖拽进去! 19 | 3. 选择声道分析,或者导入之前分析的结果(只有选择音频之后才有导入之前结果的接口) 20 | 4. 根据频谱分析,开始绘制midi音符!调整音量,反复比对。 21 | 5. 导出为midi等,或者暂时导出项目(下次继续) 22 | 23 | ## 导入导出说明 24 | - 导出进度: 结果是.nd的二进制文件,保存分析结果(频谱图)和音符音轨。导入的时候并不会强制要求匹配原曲!(会根据文件名判断一下,但不强制) 25 | - 导出为midi: 有两个模式。模式二只保证能听,节拍默认4/4,bpm默认60,midi类型默认1(同步多音轨),时间精度和设置的精度一致(因此如果midi先导入再导出会有量化误差);模式一会根据小节线进行对齐(需要用户设置好小节线),可以直接用于制谱,算法概述见下面“节奏对齐”。由于midi协议规定第十轨用于打击乐,因此扒谱时旋律需要避开第十轨,可以设置一个空音轨占位。本应用没有设计避开第十轨,也没设计扒鼓点,因此扒谱时第十轨虽然听起来还是乐音,但导出为midi后会变成鼓点。 26 | - 导入midi: 将midi音符导入,只保证音轨、音符、音色能对应,音量默认127。如果导入后没有超过总音轨数,会在后面增加;否则会覆盖后面几轨(有提示)。 27 | 28 | ## 常规操作 29 | - 空格: 播放 30 | - **双击**时间轴: 从双击的位置开始播放 31 | - 在时间轴上拖拽: 设置重复区间 32 | - 在时间轴上拉动小节线: 设置小节bpm 33 | - 鼠标**中键**时间轴: 将时间设置到点击位置,播放状态保持上一刻 34 | - 鼠标**右键**时间轴(上半/下半): 具体设置重复时间/小节 35 | - 按住空白拖动: 在当前音轨绘制一个音符 36 | - 按住音符左半边拖动: 改变位置 37 | - 按住音符右半边拖动: 改变时长 38 | - Ctrl+点击音符: 多选音符 39 | - delete: 删除选中的音符 40 | - Ctrl+滚轮: 横向缩放 41 | - 按住**中键**拖拽、**触摸板**滑动: 移动视野 42 | 43 | ## 快捷键 44 | 只有在导入并分析音频之后才能使用这些快捷键 45 | - Ctrl+Z: 撤销(只记录16次历史,音轨、音符、小节线操作均会被记录) 46 | - Ctrl+Y: 重做 47 | - Ctrl+A: 全选当前音轨 48 | - Ctrl+Shift+A: 全选所有音轨 49 | - Ctrl+D: 取消选中 50 | - Ctrl+C: 复制选中的音符 51 | - Ctrl+X: 剪贴选中的音符 52 | - Ctrl+V: 粘贴到选中的音轨上(暂不实现跨页面粘贴) 53 | - Ctrl+B: 呼出/收回音轨面板 54 | - Shift+右键: 菜单,包含撤销/重做、复制/粘贴、反选当前轨、删除 55 | - ←↑→↓: 视野移动一格 56 | - PageUp、PageDown:向前翻页/向后翻页 57 | - Home:设置播放位置为0,播放状态保持上一刻 58 | 59 | ## 小细节 60 | - 滑动条,如果旁边有数字,点击就可以恢复初始值。 61 | - 多次点击“笔”右侧的选择工具,可以切换选择模式。(注意,只能选中当前音轨的音符) 62 | - 点击某个音符可以选中该轨。 63 | - 选择乐器时,展开下拉框并且按首字母可以快速跳转(浏览器下拉框自带)。 64 | - 音轨中,“闭眼”只是看不见,还是可以操作的;一般要搭配“锁定”使用,默认两者会联动。 65 | 66 | ## 支持的格式 67 | 推荐使用常见的mp3、wav文件;除此之外,视频类文件也可以使用,比如mp4、mov、m4v。 68 | 但是如下格式不支持(浏览器API不支持解析)(仅仅在Chrome浏览器尝试过): 69 | - aiff(苹果的音频格式) 70 | 71 | 对于ios的Safari浏览器,上传音频文件也许有些困难。可以选择视频。(不过为什么要用触屏控制啊,根本没适配) 72 | 73 | ## 其他说明 74 | 分析-自动填充,原理是将大于阈值的标记出来,效果不堪入目……于是研究并引入了基于神经网络的扒谱(分析-人工智障扒谱),但是效果非常初级。如有想法欢迎call me。 75 | 76 | ## 关于节奏对齐 77 | 我一直以来都是扒数字谱的,所以没关注过节奏。但是只能用于数字谱这个应用也太弱了。所以加入了小节对齐功能。“丑话说在前面”,绘制音符大概是不可能对齐小节线了(但是导出midi的时候会对齐),**需要强迫症忍受一下**。 78 | 乐谱的单位是"x分音符",而音乐的单位是"秒"。如果要实现"小节对齐",单位要换成"x分音符"。整个程序时间轴一定要按照"秒"为单位,这是由频谱分析决定的;如果要实现制谱软件一样的对齐,那么音符绘制需要换成"x分音符"的对齐方式。这意味着在120bpm的小节下的音符,拉到60bpm的小节下,在以秒为尺度的时间轴下,音符会变长。wavetone就是这样处理的。 79 | 但是对着原曲扒谱,最好还是根据"秒"来绘制音符。用wavetone扒谱的体验中,我最讨厌的就是被"x分音符"限制。用秒可以保证和原曲完全贴合,使用很灵活。但是这样导出的midi就不能直接制谱。按照"x分音符"来绘制音符还会导致程序很难写。开发者和使用者都不快乐。 80 | 扒谱用秒为单位合适,而制谱用x分音符合适。为了跨越这个鸿沟,我决定这样设计程序:使用midi文件作为对外的桥梁,在我的程序内用秒为单位扒谱,导出为midi的时候根据小节进行四舍五入的量化,形成规整的midi用于制谱。具体实现是:在秒轴上加入小节轴,用户可以拖动小节轴的某个小节调节后面紧跟的bpm相同的小节。小节轴只提供视觉上的辅助,对于画音符没一点限制。 81 | 对齐算法有一定的限制,比如四分音符按照八分音符的划分对齐、八分音符按十六分音符的划分对齐……比如四分音符不可能在第三个16分音符开始,只可能在整数倍个8分音符的时长处开始。所以,绘制音符的时候到底可以偏差小节线多远心里有数了吧? 82 | 83 | ## 文件结构 84 | ``` 85 | │ app.js: 最重要的文件,主程序 86 | │ app_analyser.js: 时频分析相关 87 | │ app_audioplayer.js: 音频播放 88 | │ app_beatbar.js: 小节轴 89 | │ app_hscrollbar.js: 底部滑动条 90 | │ app_keyboard.js: 左侧键盘 91 | │ app_midiaction.js: 与音符的交互 92 | │ app_midiplayer.js: 音符播放 93 | │ app_spectrogram.js: 频谱绘制 94 | │ app_timebar.js: 时间轴 95 | | 96 | | beatBar.js: 节奏信息的稀疏数组存储 97 | │ channelDiv.js: 多音轨的UI界面类, 可拖拽列表 98 | │ contextMenu.js: 右键菜单类 99 | | fakeAudio.js: 模拟了不会响的Audio,用于midi编辑器模式 100 | │ favicon.ico: 小图标 101 | │ index.html: 程序入口, 其js主要是按钮的onclick 102 | │ jsconfig.json: 开发用 跨文件JS解析 103 | │ LICENSE 104 | │ midi.js: midi创建、解析类 105 | │ myRange.js: 横向滑动条的封装类 106 | │ README.md 107 | │ saver.js: 二进制保存相关 108 | │ siderMenu.js: 侧边栏菜单类 109 | │ snapshot.js: 快照类, 实现撤销和重做 110 | │ tinySynth.js: 合成器类, 负责播放音频 111 | │ todo.md: 一些设计思路和权衡 112 | │ 113 | ├─dataProcess 114 | | | aboutANA.md: 自动音符对齐的数学建模 115 | | | ANA.js: 自动音符对齐 116 | | │ analyser.js: 频域数据分析与简化 117 | | │ fft_real.js: 执行实数FFT获取频域数据 118 | | │ midiExport.js: 对绘制的音符进行近似以导出为足以制谱的midi 119 | | | 120 | | ├─AI 121 | | │ │ basicamt.js: 开启worker进行后台AMT 122 | | │ │ basicamt_44100.onnx: 神经网络模型 123 | | │ │ basicamt_worker.js: 新线程 124 | | │ │ 125 | | │ └─dist: onnxruntime打包 126 | | │ bundle.min.js 127 | | │ ort-wasm-simd.wasm 128 | | | 129 | │ └─CQT 130 | │ cqt.js: 开启worker进行后台CQT 131 | │ cqt_worker.js: CQT类与新线程 132 | | 133 | ├─img 134 | │ bilibili-white.png 135 | │ github-mark-white.png 136 | │ logo-small.png 137 | │ logo.png 138 | │ logo_text.png 139 | │ 140 | └─style 141 | │ askUI.css: 达到类似效果 142 | │ channelDiv.css: 多音轨UI样式 143 | │ contextMenu.css: 右键菜单样式 144 | │ myRange.css: 包装滑动条 145 | │ siderMenu.css: 侧边菜单样式 146 | │ style.css: index中独立元素的样式 147 | │ 148 | └─icon: 从阿里图标库得到的icon 149 | iconfont.css 150 | iconfont.ttf 151 | iconfont.woff 152 | iconfont.woff2 153 | ``` 154 | 155 | ## 重要更新记录 156 | ### 2025 12 157 | 更新了音色无关转录模型,效果比之前好,且运行时间更短。 158 | 加入了音色分离转录,但是效果不怎么好,只适用于几个音色差异较大的简单场景,不同音色的音符不能重叠。推荐用于二重奏的情况,比如钢琴伴奏的某乐器独奏。 159 | 160 | ### 2025 9 20 161 | 降低重绘频率,降低闲时CPU占用为原来的1/10。感谢[Initsnow的pr](https://github.com/madderscientist/noteDigger/pull/7)指出问题。 162 | 有三类地方会触发刷新: 163 | 1. 播放时保持高刷 在 `AudioPlayer.update` 中触发刷新 164 | 2. 所有键鼠操作。最初的想法是需要的地方触发刷新,但是牵扯的非常多—— 165 | - 快捷键。有些快捷键需要触发刷新,不如一刀切。 166 | - 鼠标操作 167 | - 在频谱画布上的操作:`MidiAction.updateView` 触发更新还不够(只负责了音符绘制模式),还有选择区域 168 | - 在时间轴上的动作:重复区间 -> 可以在 `TimeBar.setRepeat` 中触发更新;设置节拍 -> 可以在回调中触发更新 169 | - 在钢琴画布上的动作:垂直方向移动,回调触发即可 170 | - 画布之外的操作 只能设置统一的api;而音轨操作这种却不好加 171 | - 没考虑到的功能——谁知道漏了会发生什么问题 172 | 173 | 综合考虑下,选择终极一刀切——所有用户操作都会触发刷新,仅需额外添加回调,不需要考虑具体动作,便于维护。 174 | 3. `MidiAction.updateView` 处理一切音符变化。之所以不能被键鼠操作取代,因为ai扒谱延迟修改了音符。 175 | 由于`scroll2`调用了`updateView`,因此还覆盖了`resize`、Spectrogram setter 176 | 177 | ### 2025 8 21 178 | 实现了自动音符对齐(auto note alignment),输入数字谱,得到和音频同步的音符,即“数字谱+音频→midi”。入口为:“分析”-“数字谱对齐音频”,输入单声部的数字谱,并调整八度范围,确定即可。注意,"1"对应的是C5,比常规约定高了八度,这是因为我发现往往分析泛音更为准确。如果效果不好,可以通过前后增加中/小括号实现八度升降。 179 | 180 | 效果并不优秀,因为使用了传统的数学建模方法,但胜在计算量小。相关说明与建模见[aboutANA.md](./dataProcess/aboutANA.md)。 181 | 182 | ### 2025 8 14 183 | 项目代码的整理,将功能拆分到单独文件(虽然模块间仍未解耦,但终于拆分了!),并增加了开发配置文件(感觉TS是对的啊!但放不下JS里面的奇技淫巧)。 184 | 修复了陈年bug:调整小节时不时“拉不动”、STFT偏早。 185 | 186 | ### 2025 8 12 187 | 进行了频谱的归一化,便于后续的研究与分析。归一化基于能量谱,同[timbreAMT](https://github.com/madderscientist/timbreAMT/blob/main/model/CQT.py)的做法,简而言之:令每一帧的能量的方差为1。 188 | 重大的改动:WASM的CQT → JS的CQT。早期实验发现两者用时接近,为了纪念第一次使用WASM加速而保留。但升级CPU后发现JS版本用时不到WASM的一半,而且代码更少、文件更小。遂欣然废除。若要学习“C++编译为WASM”可以查看此前的历史提交。 189 | 190 | ### 2025 3 25 191 | 引入了“自动音乐转录”,即“AI扒谱”,导入音频后(或进入MIDI编辑器模式)在“分析”页面点击“**人工智障扒谱**”选项,一首两分半的曲子大概需要半分钟分析。由于追求低内存开销,我没保存音频数据,因此AI扒谱前要重新选择文件。 192 | 使用的模型是我毕设的一部分,设计与训练过程请查看[timbreAMT](https://github.com/madderscientist/timbreAMT)的basicamt文件夹,我称之为“音色无关转录”,即不会根据乐器种类分轨输出,但对大部分音色有适用性。对标的是basicPitch,效果接近且更加轻量,但无论是我的还是他的,结果都仅仅能听,而我的相比basicPitch优点在于集成在了noteDigger中,可以便捷地进行人工后处理,如删去多余音符、对齐节奏等。 193 | 为了支持人工后处理,有了前几次更新,最重要的是: 194 | 1. 音符力度用透明度体现,便于用户看清AI扒谱结果中重要的音符。在“设置”中可以关掉透明度。 195 | 2. 音轨的锁,用于锁定AI扒谱结果,用户可以在新的音轨中“描”一遍,相当于把AI扒谱结果当做频谱。 196 | 197 | ### 2024 8 29 198 | 引入了理论上更精确的CQT分析。非file协议时(不是双击html文件打开时),当STFT(默认的计算方法)计算完成会在后台自动开启CQT计算,CQT结果将与当前频谱融合(会发现突然频谱变了)。CQT计算非常慢,因此在后台计算以防阻塞,且用C++实现、编译为WASM以提速。 199 | 中途遇到很多坑,记录分布在/dataProcess/CQT的各个文件中,但效果其实并不值得这样的计算量。5分30秒的音频进行双声道CQT分析,需要45秒(从开启worker开始算),和直接进行js版的CQT用时差不多,加速了个寂寞。 200 | 关于CQT的研究,记录在[《CQT:从理论到代码实现》](https://zhuanlan.zhihu.com/p/716574483)。 201 | 此外尝试了“一边分析一边绘制频谱”,试图通过删除进度条达到感官上加速的效果。但是放在主线程造成严重卡顿,放弃。 202 | 203 | ### 2024 8 2 204 | 完成了issue2:不导入音频的midi编辑器。点击文件菜单下的“MIDI编辑器模式”就可以进入。 205 | 视野的宽度取决于最后一个音符,模仿的是[signal](https://signal.vercel.app/edit)。也尝试过自动增加视野,可以一直往右拉,但是这样在播放的时候,开启“自动翻页”会永远停不下来(翻一页就自动拓展宽度)。 206 | 扒谱框架下的midi编辑器还是有些反人类,因为绘制音符时的单位是时间而不是x分音符。不过也能用。 207 | 原理是实现了一个空壳的Audio,只有计时功能,没有发声功能。一些做法写在了todo.md上。 208 | 209 | ### 2024 2 22 210 | 加入了节拍对齐功能,使用逻辑是:扒谱界面提供视觉辅助,导出midi会自动对齐,以实现制谱友好。详细对齐的原理请参看“关于节奏对齐”板块和midiExport.js文件。 211 | 有一些细节: 212 | 1. 如果每个小节bpm都不一样(原曲的速度不稳,有波动),那导出midi前的对齐操作会以上一小节bpm为基准进行动态适应:先根据本小节的bpm量化音符为"x分音符",如果本小节bpm和上一小节的bpm差别在一定范围内,则再将"x分音符"的bpm设置全局量BPM;否则将全局BPM设置为当前小节的bpm。这个算法的要求是:的确要变速的前后bpm差异应该较大。 213 | 2. 在一个小节内,音符的近似方法: 214 | 215 | 1. 记一个四分音符的格数为aqt(因为音符的实际使用单位是格。这里隐含了一个时间到格数的变换),某时刻t对应音符长度为ntlen,小节开始时刻记为mt。首先获取音符时刻相对小节开头的位置nt=t-mt。(音符时刻:将一个音符拆分为开始时刻和结束时刻。一个音符可能跨好几个小节,因此这样处理最为合适) 216 | 2. 假设前提:时长长的音符的起点和终点的精度也低(精度这里指最小单位时长,低精度指单位时长对应的实际时间长)。因此近似精度accu采用自适应的方式:该音符可以用(ntlen/aqt)个四份音符表示,设其可以用一个(4*2^n)分音符近似,其中n满足:(1/2)^n<=ntlen/aqt<(1/2)^(n-1),则该音符的时长为aqt/(2^n),则精度设置为这个近似音符的一半:accu = aqt/(2^(n+1))。比如四份音符的精度是一个八分音符的时长。 217 | 3. 近似后的时刻为:round(nt/accu)*accu。同时设置一个最低精度:八分音符。因此accu=min(aqt/2, aqt/(2^(n+1))),其中(1/2)^n<=ntlen/aqt<(1/2)^(n-1)。 218 | 219 | 3. 小节信息如何存储、数据结构如何设计需要好好想想。大部分情况下(在原音频节奏稳定的情况下)只会变速几次,此时存变动时刻的bpm值就足矣。极端情况下每个小节都单独设置了bpm。如何设计数据结构能在两种情况下都取得较好的性能?使用稀疏数组。 220 | 221 | ### 2024 2 9 222 | 在今年完成了所有基本功能!本次更新了设置相关,简单地设计了调性分析的算法,已经完全可以用了!【随后在bilibil投稿了视频】 223 | 224 | ### 2024 2 8 225 | 文件系统已经完善!已经可以随心所欲导入导出保存啦!同时修复了一些小bug、完善了一些api。 226 | 界面上,本打算将文件相关选项放到logo上,但是侧边菜单似乎有些空了,于是就加入到侧边栏,而logo设置为刷新或开新界面(考察了其他网站的logo的用途)。同时给侧边菜单加入了“设置”和“分析”,但本次更新没做。 227 | midi相关操作来自[我的另一个项目](https://github.com/madderscientist/je_score_operator)的midi类。将用midi转的wav导入分析,再导入原midi,两者同步播放的感觉真好! 228 | 229 | ### 2024 2 5 230 | 已经能用于扒谱了!完成了midi和原曲的播放与同步,填补了扒谱过程最重要的一环。 231 | UI基本完成!将侧边栏、滑动条封装成了js类。在此基础上,设计了类似VScode的菜单,用于存放不常用的功能和界面;而顶部窄窄一条用于放置常用功能。 232 | 此外,完成了logo的设计。在2月4日的commit记录中(因为现在已经删除)可以看到设计的多种logo,最终选定了“在勺子里的音符”,这是一个被勺子dig出来的音符。其他思路可以概括为:“音符和铲子的组合”(logo2)、“埋在地里的音符”(logo5 logo6)、“像植物一样生长的八分音符”(logo8 logo10)、“音符和铲子结合”(logo12)。 233 | 234 | ### 2024 2 1 235 | 完成了多音轨、合成器和主线的整合,象征着midi系统的完成! 236 | 统一了UI风格;完善了快捷键功能;新增框选功能;修复了大部分bug。 237 | 238 | ### 2024 1 30 239 | 完成了midi合成器tinySynth.js,实现了128种音色的播放。只有演奏音符的作用,控制器一点没做。 240 | 原理是多个基础波形合成一个音色。波形参数来自 https://github.com/g200kg/webaudio-tinysynth ,因此程序设计也参考了它的设计。修改记录在todo.md中 241 | 对于reference的解析(作者注释一点没写,变量命名极为简单,因此主要是变量解释)存放于“./tone/解析.md”(文件夹已被删除,请去历史提交查看)。文件夹中还有tinySynth的测试页面。在下一次push时将删除tone文件夹。 242 | 这段时间内还完成了以下内容(全部记录在commit history的comments内): 243 | - 基本程序界面(三个画布:键盘、时频图、时间轴;UI界面:右键菜单、多音轨、滑动条) 244 | - 基本逻辑功能:音符交互绘制、快捷键以及模块的关联协同 245 | 246 | ### 2023 12 13 247 | 从11月14日开始造js版fft轮子起,时隔一个月第一次提交项目,因为项目逻辑日渐复杂,需要能及时回退。主要完成了频谱绘制、钢琴键盘绘制、数据处理三部分,并初步确定了程序的结构框架。 248 | 数据处理核心:实数FFT,编写于我《数字信号处理》刚刚学完FFT算法之时,针对本项目的应用场景做了专门的设计,即针对音频STFT做了适配,具体表现为:实数加速、数据预计算、空间预分配、共用数组。 249 | 由于整个项目还没搭建起来,因此不能测试NoteAnalyser类的数据处理效果。此类用于将频域数据进一步离散为音符强度数据。 250 | 关于程序结构有一版废案,在文件夹"deprecated"中,设计思路是解耦、插件化,废弃理由是根本解耦不了。因此现在的代码耦合成一坨了。这个文件夹将在下一次push时被删除,存活于历史提交之中。 251 | tone文件夹将存放我的合成器轮子,audioplaytest是我音频播放的实验文件夹,todo.md是部分设计思路。 252 | 2024/4/8补记:时频分析方法是STFT,但是面临时间和频率分辨率矛盾的问题,现在的分析精度只能到F#2。解决办法是用小波变换,或者更本质一点:用84个滤波器提取84个基准音以及其周围的频率的能量。这样能达到更高的频率分辨率和时间分辨率。但是现在的STFT用起来效果还可以,就不换了哈。 253 | -------------------------------------------------------------------------------- /dataProcess/AI/SpectralClustering.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 谱聚类算法 3 | * @param {Array} feats 4 | * @param {number} numClusters 5 | */ 6 | function SpectralClustering(feats, numClusters, affinityFunc) { 7 | // 1. 计算修改后的归一化拉普拉斯矩阵 Lsym = I + D^(-1/2) * W * D^(-1/2) 8 | const L = TriangleMatrix.Lsym(feats, affinityFunc); 9 | // 2. 使用正交迭代法计算前k个特征向量 10 | const U = TriangleMatrix.orthogonalIteration(L, numClusters); 11 | // console.log(U); 12 | // 3. 转置并归一化 13 | const { flatMatrix, n, k } = transposeAndNormalize(U); 14 | // 4. 基于 Pivoted QR 选择聚类中心 15 | return clusterQR(flatMatrix, n, k); 16 | } 17 | 18 | /** 19 | * 转置并归一化 (Transpose & Normalize) 20 | * 优化点: 21 | * 1. 预计算范数:利用原始数据的内存布局(顺序读取)计算模长;乘法代替除法:预先计算 1/norm 22 | * 2. 分块写入 (Tiling):将转置过程分块,确保写入 `flat` 数组时命中缓存 23 | * 3. 减少重复计算:提前解构引用,避免在循环内多次查找 `eigenVectors[r]` 24 | * 4. 手动维护索引,消除循环内的乘法运算 25 | * @param {Array} eigenVectors 大小为 k 的数组,每个元素长 n 26 | * @param {number} BLOCK_SIZE L1 缓存分块大小 27 | * @returns {{flatMatrix: Float32Array, n: number, k: number}} 28 | */ 29 | function transposeAndNormalize(eigenVectors, BLOCK_SIZE = 1024) { 30 | const k = eigenVectors.length; 31 | const n = eigenVectors[0].length; 32 | const flat = new Float32Array(n * k); 33 | 34 | // normSq[i] 存储第 i 个数据点(即第 i 行)的模长平方 35 | const normSq = new Float32Array(n); 36 | 37 | // 1. 预计算模长 (保持不变,因为这是最高效的) 38 | for (let r = 0; r < k; r++) { 39 | const vec = eigenVectors[r]; 40 | for (let i = 0; i < n; i++) { 41 | normSq[i] += vec[i] * vec[i]; 42 | } 43 | } 44 | // 归一化系数 45 | for (let i = 0; i < n; i++) normSq[i] = 1.0 / Math.sqrt(normSq[i] + 1e-10); 46 | 47 | // 2. 分块转置 48 | // 由于 k 很小,一行的数据量很小 (20 bytes)。 49 | // 我们可以适当增大 BLOCK_SIZE,比如 1024 或 2048 50 | 51 | // 提前解构引用,避免在循环里查找 eigenVectors[r] 52 | const vecs = Array.from({ length: k }, (_, i) => eigenVectors[i]); 53 | 54 | for (let iBase = 0; iBase < n; iBase += BLOCK_SIZE) { 55 | // 确定当前块的边界 56 | const iLimit = (iBase + BLOCK_SIZE < n) ? (iBase + BLOCK_SIZE) : n; 57 | 58 | for (let r = 0; r < k; r++) { 59 | const vec = vecs[r]; 60 | // 手动维护索引,消除循环内的 (i * k) 乘法 61 | // 初始索引:当前块起始行(iBase) * k + 当前列(r) 62 | let flatIndex = iBase * k + r; 63 | for (let i = iBase; i < iLimit; i++) { 64 | // 直接使用指针 65 | flat[flatIndex] = vec[i] * normSq[i]; 66 | // 步进为 k,因为 flat 是行优先存储, 67 | // 同一列的下一个元素在 flat 中相隔 k 个位置 68 | flatIndex += k; 69 | } 70 | } 71 | } 72 | return { flatMatrix: flat, n, k }; 73 | } 74 | 75 | 76 | /** 77 | * 完整的 Cluster QR 聚类 78 | * 包含:中心点选择 + 标签分配 79 | * 80 | * @param {Float32Array} flatMatrix (n * k) 归一化后的特征矩阵 (只读) 81 | * @param {number} n 点的数量 82 | * @param {number} k 聚类数量 83 | * @returns {Int32Array} 长度为 n 的数组,labels[i] 表示第 i 个点属于第几类 (0 到 k-1) 84 | */ 85 | function clusterQR(flatMatrix, n, k) { 86 | // --- 阶段 1: 准备工作 --- 87 | 88 | // 1. 必须复制一份数据用于 QR 分解的残差计算 89 | // 因为 MGS 算法会破坏性地修改数据,而我们最后分配时需要原始数据 90 | // 这里的内存开销是必要的 (n * k * 4 bytes) 91 | const residualsMatrix = flatMatrix.slice(); 92 | 93 | const centroidIndices = new Int32Array(k); 94 | const residualNorms = new Float32Array(n); 95 | 96 | // 初始化残差模长 (由于输入已归一化,初始全为 1.0) 97 | // 但为了保险,还是算一下,或者直接 fill(1.0) 如果上一步很自信 98 | residualNorms.fill(1.0); 99 | 100 | const currentPivot = new Float32Array(k); 101 | 102 | // --- 阶段 2: 选择中心点 (Pivot Selection) --- 103 | for (let step = 0; step < k; step++) { 104 | // 2.1 寻找残差最大的点 105 | let maxNorm = -1.0; 106 | let pivotIdx = -1; 107 | 108 | for (let i = 0; i < n; i++) { 109 | if (residualNorms[i] > maxNorm) { 110 | maxNorm = residualNorms[i]; 111 | pivotIdx = i; 112 | } 113 | } 114 | 115 | // 记录中心点索引 116 | centroidIndices[step] = pivotIdx; 117 | 118 | if (maxNorm < 1e-6) break; // 剩余点都几乎为0了 119 | 120 | // 2.2 提取 pivot 向量 (从残差矩阵中提取) 121 | const pivotOffset = pivotIdx * k; 122 | const pivotScale = 1.0 / Math.sqrt(maxNorm); 123 | 124 | for (let j = 0; j < k; j++) { 125 | currentPivot[j] = residualsMatrix[pivotOffset + j] * pivotScale; 126 | } 127 | 128 | // 2.3 正交化 (更新残差矩阵) 129 | for (let i = 0; i < n; i++) { 130 | if (residualNorms[i] < 0) continue; // 已选过的跳过 131 | if (i === pivotIdx) { 132 | residualNorms[i] = -1.0; 133 | continue; 134 | } 135 | 136 | const offset = i * k; 137 | 138 | // dot = 139 | let dot = 0.0; 140 | for (let j = 0; j < k; j++) { 141 | dot += residualsMatrix[offset + j] * currentPivot[j]; 142 | } 143 | 144 | // residual_i = residual_i - dot * pivot 145 | let newNorm = 0.0; 146 | for (let j = 0; j < k; j++) { 147 | const val = residualsMatrix[offset + j] - dot * currentPivot[j]; 148 | residualsMatrix[offset + j] = val; 149 | newNorm += val * val; 150 | } 151 | residualNorms[i] = newNorm; 152 | } 153 | } 154 | 155 | // --- 阶段 3: 分配标签 (Label Assignment) --- 156 | // 既然数据已经归一化,欧氏距离最近等价于余弦相似度最大 (Dot Product Largest) 157 | 158 | const labels = new Int32Array(n); 159 | 160 | // 为了极致性能,先将 k 个中心点的原始向量提取到连续内存中 161 | // 这样在遍历 n 个点时,中心点数据能更好地待在 Cache 里 162 | const centerVectors = new Float32Array(k * k); 163 | for (let c = 0; c < k; c++) { 164 | const centerIdx = centroidIndices[c]; 165 | const srcOffset = centerIdx * k; 166 | const destOffset = c * k; 167 | for(let j=0; j maxSim) { 190 | maxSim = dot; 191 | bestCluster = c; 192 | } 193 | } 194 | labels[i] = bestCluster; 195 | } 196 | 197 | return labels; 198 | } 199 | 200 | 201 | class TriangleMatrix { 202 | constructor(size) { 203 | this.size = size; 204 | this.data = new Float32Array((size * (size + 1)) / 2); 205 | } 206 | 207 | // 发现效果比exp(cos-1)好 208 | static cosineAffinityExp(featureA, featureB) { 209 | let dot = 0; 210 | for (let i = 0; i < featureA.length; i++) { 211 | dot += featureA[i] * featureB[i]; 212 | } 213 | return Math.exp(dot); 214 | } 215 | 216 | /** 217 | * 直接计算归一化拉普拉斯矩阵 218 | * 但是正交迭代法求的是绝对值最大特征值对应的特征向量,而需要的是最小特征值对应的特征向量 219 | * 因此将本来的"I-"换成了"I+",此时特征向量不变,特征值变为原来的2-λ 220 | * @param {Array} features 221 | * @param {Function(Float32Array, Float32Array): number} func W(i, j) = func(features[i], features[j]) 222 | * @returns {TriangleMatrix} D^(-1/2) * W * D^(-1/2) + I 223 | */ 224 | static Lsym(features, func = TriangleMatrix.cosineAffinityExp) { 225 | const size = features.length; 226 | const affine = new TriangleMatrix(size); 227 | const rowSums = new Float32Array(size); 228 | const data = affine.data; 229 | // 不算自环,直接跳过第一个 230 | for (let j = 1, idx = 1; j < size; j++, idx++) { // col 231 | const colFeature = features[j]; 232 | for (let i = 0; i < j; i++, idx++) { // row 233 | const affi = func(colFeature, features[i]); 234 | data[idx] = affi; 235 | rowSums[i] += affi; 236 | rowSums[j] += affi; 237 | } 238 | } 239 | // 此时对角线元素均为0 240 | // 归一化 241 | for (let j = 0, idx = 0; j < size; j++, idx++) { 242 | for (let i = 0; i < j; i++, idx++) { // 本应是i <= j,但是对角线元素单独处理,所以将最后一个idx++放在外层 243 | const div = Math.sqrt(rowSums[i] * rowSums[j]); 244 | if (div > 1e-10) data[idx] = data[idx] / div; 245 | else data[idx] = 0; 246 | } 247 | // 对角线元素设为1 248 | data[idx] = 1; 249 | } 250 | return affine; 251 | } 252 | 253 | _index(i, j) { 254 | // 内联优化建议:在热路径中尽量手动计算,减少函数调用开销 255 | if (i > j) return (i * (i + 1)) / 2 + j; 256 | return (j * (j + 1)) / 2 + i; 257 | } 258 | 259 | /** 260 | * 优化的矩阵乘法 Z = A * Q 261 | * @param {Array} Q_in 输入向量组 (k个) 262 | * @param {Array} Z_out 输出向量组 (k个,预分配好) 263 | */ 264 | mult_mat_optimized(Q_in, Z_out) { 265 | const size = this.size; 266 | const k = Q_in.length; 267 | const data = this.data; 268 | 269 | // 清空输出 buffer 270 | for (let r = 0; r < k; r++) Z_out[r].fill(0); 271 | 272 | for (let j = 0, idx = 0; j < size; j++) { 273 | for (let i = 0; i <= j; i++, idx++) { 274 | const val = data[idx]; 275 | for (let r = 0; r < k; r++) { 276 | const vecIn = Q_in[r]; 277 | const vecOut = Z_out[r]; 278 | vecOut[i] += val * vecIn[j]; 279 | if (i !== j) vecOut[j] += val * vecIn[i]; 280 | } 281 | } 282 | } 283 | } 284 | 285 | /** 286 | * 正交迭代法 求前k个绝对值最大的特征值对应的特征向量 287 | * @param {TriangleMatrix} A 建议是 I + D^{-0.5}WD^{-0.5} 288 | * @param {number} numVectors 需要的特征向量数量 289 | * @param {number} numIterations 迭代次数 290 | * @return {Array} 特征向量矩阵 size * numVectors 291 | */ 292 | static orthogonalIteration(A, numVectors, numIterations = 30) { 293 | const size = A.size; 294 | 295 | // 双缓冲 296 | let Q = Array.from({ length: numVectors }, () => new Float32Array(size)); 297 | let Z = Array.from({ length: numVectors }, () => new Float32Array(size)); 298 | 299 | // 初始化 Q 为随机并正交化 300 | for (let r = 0; r < numVectors; r++) { 301 | for (let i = 0; i < size; i++) Q[r][i] = Math.random(); 302 | } 303 | SchmidtInPlace(Q); // 原地正交化 304 | 305 | // 迭代 306 | for (let iter = 0; iter < numIterations; iter++) { 307 | // Z = A * Q (写入 Z buffer) 308 | A.mult_mat_optimized(Q, Z); 309 | // Q = Schmidt(Z) (原地正交化 Z,结果仍在 Z buffer 中) 310 | SchmidtInPlace(Z); 311 | // 交换 buffer:Z 变成了下一次的 Q,原来的 Q 变成下一次的废弃 buffer (Z) 312 | const temp = Q; Q = Z; Z = temp; 313 | } 314 | return Q; 315 | } 316 | } 317 | 318 | /** 319 | * 施密特正交化 (原地修改版 / In-Place MGS) 320 | * 没有任何内存分配,速度极快 321 | * @param {Array} V 向量组 322 | */ 323 | function SchmidtInPlace(V) { 324 | const k = V.length; 325 | const n = V[0].length; 326 | 327 | for (let i = 0; i < k; i++) { 328 | const qi = V[i]; 329 | 330 | // 归一化当前向量 331 | let dot = 0.0; 332 | for (let x = 0; x < n; x++) dot += qi[x] * qi[x]; 333 | const norm = Math.sqrt(dot); 334 | const scale = norm < 1e-10 ? 0 : 1.0 / norm; 335 | for (let x = 0; x < n; x++) qi[x] *= scale; 336 | 337 | // 正交化后续向量 (MGS) 338 | for (let j = i + 1; j < k; j++) { 339 | const vj = V[j]; 340 | 341 | // 计算投影 proj = 342 | let proj = 0.0; 343 | for (let x = 0; x < n; x++) proj += vj[x] * qi[x]; 344 | 345 | // 减去投影 vj = vj - proj * qi 346 | for (let x = 0; x < n; x++) vj[x] -= proj * qi[x]; 347 | } 348 | } 349 | } -------------------------------------------------------------------------------- /app_analyser.js: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | /// 6 | 7 | /** 8 | * 数据解析相关算法 9 | * @param {App} parent 10 | */ 11 | function _Analyser(parent) { 12 | /** 13 | * 对audioBuffer执行STFT 14 | * @param {AudioBuffer} audioBuffer 音频缓冲区 15 | * @param {Number} tNum 一秒几次分析 决定步距 16 | * @param {Number} channel 选择哪个channel分析 0:left 1:right 2:l+r 3:l-r else:fft(l)+fft(r) 17 | * @param {Number} fftPoints 实数fft点数 18 | * @returns {Array} 时频谱数据 19 | */ 20 | this.stft = async (audioBuffer, tNum = 20, A4 = 440, channel = -1, fftPoints = 8192) => { 21 | parent.dt = 1000 / tNum; 22 | parent.TperP = parent.dt / parent._width; parent.PperT = parent._width / parent.dt; 23 | let dN = Math.round(audioBuffer.sampleRate / tNum); 24 | if (parent.Keyboard.freqTable.A4 != A4) parent.Keyboard.freqTable.A4 = A4; // 更新频率表 25 | let progressTrans = (x) => x; // 如果分阶段执行则需要自定义进度的变换 26 | 27 | // 创建分析工具 28 | var fft = new realFFT(fftPoints); // 8192点在44100采样率下,最低能分辨F#2,但是足矣 29 | var analyser = new NoteAnalyser(audioBuffer.sampleRate / fftPoints, parent.Keyboard.freqTable); 30 | 31 | const a = async (t) => { // 对t执行STFT,并整理为时频谱 32 | let nFinal = t.length - fftPoints; 33 | const result = new Array((nFinal / dN) | 0); 34 | const window_left = fftPoints >> 1; // 窗口左边界偏移量 35 | for (let n = dN >> 1, k = 0; n <= nFinal; n += dN) { // n为窗口中心 36 | result[k++] = analyser.analyse(...fft.fft(t, n - window_left)); 37 | // 一帧一次也太慢了。这里固定更新帧率 38 | let tnow = performance.now(); 39 | if (tnow - lastFrame > 200) { 40 | lastFrame = tnow; 41 | // 打断分析 更新UI 等待下一周期 42 | parent.event.dispatchEvent(new CustomEvent("progress", { 43 | detail: progressTrans(k / (result.length - 1)) 44 | })); 45 | await new Promise(resolve => setTimeout(resolve, 0)); 46 | } 47 | } // 通知UI关闭的事件分发移到了audio.onloadeddata中 48 | return result; 49 | }; 50 | 51 | await new Promise(resolve => setTimeout(resolve, 0)); // 等待UI 52 | var lastFrame = performance.now(); 53 | const getEnergyData = async () => { 54 | switch (channel) { 55 | case 0: return await a(audioBuffer.getChannelData(0)); 56 | case 1: return await a(audioBuffer.getChannelData(audioBuffer.numberOfChannels - 1)); 57 | case 2: { // L+R 58 | let length = audioBuffer.length; 59 | const timeDomain = new Float32Array(audioBuffer.getChannelData(0)); 60 | if (audioBuffer.numberOfChannels > 1) { 61 | let channelData = audioBuffer.getChannelData(1); 62 | for (let i = 0; i < length; i++) timeDomain[i] = (timeDomain[i] + channelData[i]) * 0.5; 63 | } return await a(timeDomain); 64 | } 65 | case 3: { // L-R 66 | let length = audioBuffer.length; 67 | const timeDomain = new Float32Array(audioBuffer.getChannelData(0)); 68 | if (audioBuffer.numberOfChannels > 1) { 69 | let channelData = audioBuffer.getChannelData(1); 70 | for (let i = 0; i < length; i++) timeDomain[i] = (timeDomain[i] - channelData[i]) * 0.5; 71 | } return await a(timeDomain); 72 | } 73 | default: { // fft(L) + fft(R) 74 | if (audioBuffer.numberOfChannels > 1) { 75 | progressTrans = (x) => x / 2; 76 | const l = await a(audioBuffer.getChannelData(0)); 77 | progressTrans = (x) => 0.5 + x / 2; 78 | const r = await a(audioBuffer.getChannelData(1)); 79 | for (let i = 0; i < l.length; i++) { 80 | const li = l[i]; 81 | for (let j = 0; j < li.length; j++) 82 | // 由于归一化,这里无需平均 83 | li[j] += r[i][j]; 84 | } return l; 85 | } else { 86 | progressTrans = (x) => x; 87 | return await a(audioBuffer.getChannelData(0)); 88 | } 89 | } 90 | } 91 | }; 92 | return NoteAnalyser.normalize(await getEnergyData()); 93 | }; 94 | 95 | /** 96 | * 后台(worker)计算CQT 97 | * @param {AudioBuffer} audioBuffer 音频缓冲区 98 | * @param {Number} tNum 一秒几次分析 决定步距 99 | * @param {Number} channel 选择哪个channel分析 0:left 1:right 2:l+r 3:l-r else:fft(l)+fft(r) 100 | * @returns 不返回,直接作用于Spectrogram.spectrogram 101 | */ 102 | this.cqt = (audioData, tNum, channel) => { 103 | if (window.location.protocol == 'file:' || window.cqt == undefined) return; // 开worker和fetch要求http 104 | console.time("CQT计算"); 105 | cqt(audioData, tNum, channel, parent.Keyboard.freqTable[0]).then((cqtData) => { 106 | // CQT结果准确但琐碎,STFT结果粗糙但平滑,所以混合一下 107 | const s = parent.Spectrogram.spectrogram; 108 | let tLen = Math.min(cqtData.length, s.length); 109 | for (let i = 0; i < tLen; i++) { 110 | const cqtBins = cqtData[i]; 111 | const stftBins = s[i]; 112 | for (let j = 0; j < cqtBins.length; j++) { 113 | // 用非线性混合,当两者极大的时候取最大值,否则相互压制 114 | if (stftBins[j] < cqtBins[j]) stftBins[j] = cqtBins[j]; 115 | else stftBins[j] = Math.sqrt(stftBins[j] * cqtBins[j]); 116 | } 117 | } 118 | console.timeEnd("CQT计算"); 119 | parent.Spectrogram.spectrogram = s; // 通知更新 120 | }).catch(console.error); 121 | }; 122 | 123 | /** 124 | * 后台(worker)AI音色无关扒谱 125 | * @param {AudioBuffer} audioBuffer 音频缓冲区 126 | * @param {Boolean} judgeOnly 是否只判断是否可以扒谱 127 | * @returns promise,用于指示扒谱完成。如果judgeOnly为true则返回值代表是否可以AI扒谱 128 | */ 129 | this.basicamt = (audioData, judgeOnly = false) => { 130 | if (window.location.protocol == 'file:' || window.basicamt == undefined) { // 开worker和fetch要求https 131 | alert("file协议下无法使用AI扒谱!"); 132 | return false; 133 | } 134 | if (!parent.Spectrogram._spectrogram) { 135 | alert('请导入音频或进入midi编辑模式!'); 136 | return false; 137 | } 138 | if (!parent.MidiAction.channelDiv.colorMask) { 139 | alert("音轨不足!请至少删除一个音轨!"); 140 | return false; 141 | } 142 | if (judgeOnly) return true; 143 | console.time("AI扒谱"); 144 | return basicamt(audioData).then((events) => { 145 | console.timeEnd("AI扒谱"); 146 | const timescale = (256 * 1000) / (22050 * parent.dt); // basicAMT在22050Hz下以hop=256分析 147 | // 逻辑同index.html中导入midi 148 | const chdiv = parent.MidiAction.channelDiv; 149 | chdiv.switchUpdateMode(false); 150 | const ch = chdiv.addChannel(); 151 | if (!ch) return; 152 | const chid = ch.index; 153 | ch.name = `AI扒谱${chid}`; 154 | ch.instrument = TinySynth.instrument[(ch.ch.instrument = 4)]; 155 | const maxIntensity = events.reduce((a, b) => a.velocity > b.velocity ? a : b).velocity; 156 | ch.ch.volume = maxIntensity * 127; 157 | const notes = events.map(({ onset, offset, note, velocity }) => { 158 | return { 159 | x1: onset * timescale, 160 | x2: offset * timescale, 161 | y: note - 24, 162 | ch: chid, 163 | selected: false, 164 | v: velocity / maxIntensity * 127 165 | }; 166 | }); 167 | parent.MidiAction.midi.push(...notes); 168 | parent.MidiAction.midi.sort((a, b) => a.x1 - b.x1); 169 | chdiv.switchUpdateMode(true); 170 | }).catch(alert); 171 | }; 172 | 173 | /** 174 | * 后台(worker)AI音色分离扒谱 175 | * @param {AudioBuffer} audioBuffer 音频缓冲区 176 | * @returns promise,用于指示扒谱完成 177 | */ 178 | this.septimbre = (audioData, k = 2) => { 179 | console.time("AI音色分离扒谱"); 180 | return septimbre(audioData, k).then((tracks) => { 181 | console.timeEnd("AI音色分离扒谱"); 182 | const timescale = (256 * 1000) / (22050 * parent.dt); 183 | // 逻辑同index.html中导入midi 184 | const chdiv = parent.MidiAction.channelDiv; 185 | chdiv.switchUpdateMode(false); 186 | tracks.forEach((events) => { 187 | const ch = chdiv.addChannel(); 188 | if (!ch) return; 189 | const chid = ch.index; 190 | ch.name = `AI分离${chid}`; 191 | ch.instrument = TinySynth.instrument[(ch.ch.instrument = 4)]; 192 | const maxIntensity = events.reduce((a, b) => a.velocity > b.velocity ? a : b).velocity; 193 | ch.ch.volume = maxIntensity * 127; 194 | const notes = events.map(({ onset, offset, note, velocity }) => { 195 | return { 196 | x1: onset * timescale, 197 | x2: offset * timescale, 198 | y: note - 24, 199 | ch: chid, 200 | selected: false, 201 | v: velocity / maxIntensity * 127 202 | }; 203 | }); 204 | parent.MidiAction.midi.push(...notes); 205 | }); 206 | parent.MidiAction.midi.sort((a, b) => a.x1 - b.x1); 207 | chdiv.switchUpdateMode(true); 208 | }).catch(alert); 209 | }; 210 | 211 | /** 212 | * “自动对齐音符”的入口 原理见 ~/dataProcess/aboutANA.md 213 | */ 214 | this.autoNoteAlign = () => { 215 | if (!parent.Spectrogram._spectrogram || parent.midiMode) { 216 | alert('请先导入音频!'); 217 | return false; 218 | } 219 | if (!parent.MidiAction.channelDiv.colorMask) { 220 | alert("音轨不足!请至少删除一个音轨!"); 221 | return false; 222 | } 223 | let tempDiv = document.createElement('div'); 224 | tempDiv.innerHTML = ` 225 | 226 | 227 | 228 | 数字谱对齐音频 229 | 230 | 取消 231 | 232 | 233 | 降低八度 234 | 235 | 升高八度 236 | 237 | 238 | 243 | 244 | 245 | 重复区间内 246 | 247 | 所有时间 248 | 249 | 250 | `; 251 | const UI = tempDiv.firstElementChild; 252 | const textarea = UI.querySelector('textarea'); 253 | const close = () => { 254 | UI.remove(); 255 | parent.preventShortCut = false; 256 | } 257 | const btns = UI.getElementsByTagName('button'); 258 | btns[0].onclick = close; 259 | btns[1].onclick = () => { 260 | textarea.value = '(' + textarea.value + ')'; 261 | }; 262 | btns[2].onclick = () => { 263 | textarea.value = '[' + textarea.value + ']'; 264 | }; 265 | btns[3].onclick = () => { // 重复区间内 266 | const numberedScore = textarea.value.trim(); 267 | if (!numberedScore) { 268 | alert("请输入数字谱!"); 269 | return; 270 | } 271 | try { 272 | this._autoNoteAlign( 273 | numberedScore, 274 | parent.TimeBar.repeatStart / parent.dt, 275 | parent.TimeBar.repeatEnd / parent.dt 276 | ); close(); 277 | } catch (error) { 278 | alert(error.message); 279 | } 280 | }; 281 | btns[4].onclick = () => { // 所有时间 282 | const numberedScore = textarea.value.trim(); 283 | if (!numberedScore) { 284 | alert("请输入数字谱!"); 285 | return; 286 | } 287 | try { 288 | this._autoNoteAlign(numberedScore); 289 | close(); 290 | } catch (error) { 291 | alert(error.message); 292 | } 293 | } 294 | parent.preventShortCut = true; // 禁止快捷键 295 | document.body.insertBefore(UI, document.body.firstChild); 296 | }; 297 | this._autoNoteAlign = (noteSeq, begin, end) => { 298 | noteSeq = parseJE(noteSeq); 299 | let spectrum = parent.Spectrogram.spectrogram; 300 | if (begin != undefined) { 301 | begin = Math.max(0, Math.floor(begin)); 302 | end = Math.min(spectrum.length, Math.ceil(end)); 303 | spectrum = spectrum.slice(begin, end); 304 | } else begin = 0; 305 | if (noteSeq.length > spectrum.length) { 306 | throw new Error("数字谱长度超过频谱长度!(时长太短)"); 307 | } 308 | // 插入间隔(用-1表示) 309 | const paddedNoteSeq = [-1]; 310 | for (let i = 0; i < noteSeq.length; i++) { 311 | // 0对应C4 312 | paddedNoteSeq.push(noteSeq[i] + 48, -1); 313 | } 314 | const path = autoNoteAlign(paddedNoteSeq, spectrum, 100 / parent.dt); 315 | const chdiv = parent.MidiAction.channelDiv; 316 | chdiv.switchUpdateMode(false); 317 | const ch = chdiv.addChannel(); 318 | if (!ch) return; 319 | const chid = ch.index; 320 | ch.name = `自动对齐${chid}`; 321 | for (let i = 0; i < path.length; ++i) { 322 | const [noteIdx, frameIdx] = path[i]; 323 | const n = paddedNoteSeq[noteIdx]; 324 | if (n == -1) continue; 325 | while (i < path.length && path[i][0] == noteIdx) ++i; 326 | --i; 327 | const frameEnd = path[i][1] + 1; 328 | parent.MidiAction.midi.push({ 329 | y: n, 330 | x1: frameIdx + begin, 331 | x2: frameEnd + begin, 332 | ch: chid, 333 | selected: false, 334 | }); 335 | } 336 | parent.MidiAction.midi.sort((a, b) => a.x1 - b.x1); 337 | chdiv.switchUpdateMode(true); 338 | } 339 | // 1(C4)->0 340 | function parseJE(txt) { 341 | const parts = []; 342 | let n = 0; 343 | let octave = 0; 344 | const JEnotes = ["1", "#1", "2", "#2", "3", "4", "#4", "5", "#5", "6", "#6", "7"]; 345 | while (n < txt.length) { 346 | if (txt[n] == ')' || txt[n] == '[') ++octave; 347 | else if (txt[n] == '(' || txt[n] == ']') --octave; 348 | else { 349 | let m = 0; 350 | if (txt[n] == '#') m = 1; 351 | else if (txt[n] == 'b') m = -1; 352 | const noteEnd = n + Math.abs(m); 353 | const position = noteEnd < txt.length ? JEnotes.indexOf(txt[noteEnd]) : -1; 354 | if (position != -1) { 355 | parts.push(m + position + octave * 12); 356 | n = noteEnd; 357 | } 358 | } 359 | ++n; 360 | } return parts; 361 | } 362 | } -------------------------------------------------------------------------------- /app_midiaction.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * 管理用户在钢琴卷帘上的动作 5 | * @param {App} parent 6 | */ 7 | function _MidiAction(parent) { 8 | this.clickXid = 0; 9 | this.clickYid = 0; 10 | 11 | this.mode = 0; // 0: 笔模式 1: 选择模式 12 | this.frameMode = 0; // 0: 框选 1: 列选 2: 行选 13 | this.frameXid = -1; // 框选的终点的X序号(Y序号=this.Keyboard.highlight-24) 此变量便于绘制 如果是负数则不绘制 14 | 15 | this.alphaIntensity = true; // 绘制音符时是否根据音量使用透明度 16 | 17 | /* 一个音符 = { 18 | y: 离散 和spectrum的y一致 19 | x1: 离散 起点 20 | x2: 离散 终点 21 | ch: 音轨序号 22 | selected: 是否选中 23 | v: 音量,0~127,用户创建的音符无此选项,但导入的midi有 需要undefined兼容 24 | } */ 25 | this.selected = []; // 选中的音符 无序即可 26 | this.midi = []; // 所有音符 需要维护有序性 27 | 28 | let _tempdx = 0; // 鼠标移动记录上次 29 | let _tempdy = 0; 30 | let _anyAction = false;// 用于在选中多个后判断松开鼠标时应该如何处理选中 31 | 32 | if (!parent.synthesizer) throw new Error('MidiAction requires a synthesizer to be created.'); 33 | const cd = this.channelDiv = new ChannelList(document.getElementById('funcSider'), parent.synthesizer); 34 | // 导入midi时创建音轨不应该update,而是应该在音符全创建完成后存档 35 | cd.updateCount = -1; // -1表示需要update 否则表示禁用更新但记录了请求次数 36 | cd.switchUpdateMode = (state, forceUpdate = false) => { // 控制音轨的更新 37 | if (state) { // 切换回使能update 38 | if (cd.updateCount > 0 || forceUpdate) { // 如果期间有更新请求 39 | this.updateView(); 40 | parent.snapshot.save(0b11); 41 | } cd.updateCount = -1; 42 | } else if (cd.updateCount < 0) { // 如果是从true切换为false 43 | cd.updateCount = 0; 44 | } 45 | } 46 | const updateOnReorder = () => { 47 | if (cd.updateCount < 0) { 48 | this.updateView(); 49 | parent.snapshot.save(0b11); 50 | } else cd.updateCount++; 51 | }; 52 | /** 53 | * 触发add和remove后也可能会触发reorder,取决于增删的是否是最后一项(见channelDiv.js) 54 | * 故不是总能触发reorder的更新存档功能updateOnReorder 55 | * 而更新与存档必须在reorder之后,因为reorder会重新映射channel 56 | * 为了避免重复存档,需要暂时屏蔽reorder的存档功能 57 | * 等到reorder之后一定会发生的added和removed事件触发后再恢复 58 | */ 59 | const resumeReroderCallback = () => { 60 | updateOnReorder(); // 稳定触发 61 | cd.addEventListener('reorder', updateOnReorder); 62 | }; 63 | 64 | cd.addEventListener('reorder', ({ detail }) => { 65 | for (const nt of this.midi) nt.ch = detail[nt.ch]; 66 | }); // 重新映射音符 更新视图在updateOnReorder中 67 | cd.addEventListener('reorder', updateOnReorder); 68 | 69 | cd.addEventListener('remove', ({ detail }) => { 70 | this.midi = this.midi.filter((nt) => nt.ch != detail.index); 71 | this.selected = this.selected.filter((nt) => nt.ch != detail.index); 72 | cd.removeEventListener('reorder', updateOnReorder); 73 | }); 74 | cd.addEventListener('removed', resumeReroderCallback); 75 | 76 | cd.addEventListener('add', () => { 77 | cd.removeEventListener('reorder', updateOnReorder); 78 | }); 79 | cd.addEventListener('added', resumeReroderCallback); 80 | 81 | const saveOnStateChange = () => { 82 | parent.snapshot.save(0b1); 83 | } 84 | cd.container.addEventListener('lock', ({ target }) => { 85 | this.selected = this.selected.filter((nt) => { 86 | if (nt.ch == target.index) return nt.selected = false; 87 | return true; 88 | }); 89 | }); 90 | cd.container.addEventListener('lock', saveOnStateChange); 91 | // cd.container.addEventListener('visible', saveOnStateChange); // visible会联动lock,因此无需存档 92 | cd.container.addEventListener('mute', saveOnStateChange); 93 | cd.addEventListener('setted', saveOnStateChange); 94 | 95 | this.insight = []; // 二维数组,每个元素为一个音轨视野内的音符 音符拾取依赖此数组 96 | /** 97 | * 更新this.MidiAction.insight 98 | * 步骤繁琐,不必每次更新。触发时机: 99 | * 1. channelDiv的reorder、added、removed,实际为updateOnReorder和switchUpdateMode 100 | * 2. midi的增加、移动、改变长度(用户操作)。由于都会调用且最后调用changeNoteY,所以只需要在changeNoteY中调用 101 | * 3. scroll2 102 | * 4. midi的删除(用户操作):deleteNote 103 | * 5. ctrlZ、ctrlY、ctrlV 104 | */ 105 | this.updateView = () => { 106 | const m = this.midi; 107 | const channel = Array.from(this.channelDiv.channel, () => []); 108 | this.insight = channel; 109 | // 原来用的二分有bug,所以干脆全部遍历 110 | for (const nt of m) { 111 | if (nt.x1 >= parent.idXend) break; 112 | if (nt.x2 < parent.idXstart) continue; 113 | if (nt.y < parent.idYstart || nt.y >= parent.idYend) continue; 114 | channel[nt.ch].push(nt); 115 | } 116 | // midi模式下,视野要比音符宽一页,或超出视野半页 117 | if (parent.midiMode) { 118 | const currentLen = parent.Spectrogram.spectrogram.length; 119 | let apage = parent.spectrum.width / parent._width; 120 | let minLen = (m.length ? m[m.length - 1].x2 : 0) + apage * 1.5 | 0; 121 | let viewLen = parent.idXstart + apage | 0; // 如果视野在很外面,需要保持视野 122 | if (viewLen > minLen) minLen = viewLen; 123 | if (minLen != currentLen) parent.Spectrogram.spectrogram.length = minLen; // length触发audio.duration和this.xnum 124 | } 125 | parent.makeDirty(); 126 | }; 127 | this.update = () => { // 按照insight绘制音符 128 | const m = this.insight; 129 | const s = parent.spectrum.ctx; 130 | const c = this.channelDiv.channel; 131 | for (let ch = m.length - 1; ch >= 0; ch--) { 132 | if (m[ch].length === 0 || !c[ch].visible) continue; 133 | let ntcolor = c[ch].color; 134 | if (c[ch].lock) s.setLineDash([5, 5]); 135 | for (const note of m[ch]) { 136 | const params = [note.x1 * parent._width - parent.scrollX, parent.spectrum.height - note.y * parent._height + parent.scrollY, (note.x2 - note.x1) * parent._width, -parent._height]; 137 | if (note.selected) { 138 | s.fillStyle = '#ffffff'; 139 | s.fillRect(...params); 140 | s.strokeStyle = ntcolor; 141 | s.strokeRect(...params); 142 | } else { 143 | if (this.alphaIntensity && note.v) { 144 | s.fillStyle = ntcolor + Math.round(note.v ** 2 * 0.01581).toString(16); // 平方律显示强度 145 | } else s.fillStyle = ntcolor; 146 | s.fillRect(...params); 147 | s.strokeStyle = '#ffffff'; 148 | s.strokeRect(...params); 149 | } 150 | } 151 | s.setLineDash([]); 152 | } if (!this.mode || this.frameXid < 0) return; 153 | // 绘制框选动作 154 | s.fillStyle = '#f0f0f088'; 155 | let [xmin, xmax] = this.clickXid <= this.frameXid ? [this.clickXid, this.frameXid + 1] : [this.frameXid, this.clickXid + 1]; 156 | const Y = parent.Keyboard.highlight - 24; 157 | let [ymin, ymax] = Y <= this.clickYid ? [Y, this.clickYid + 1] : [this.clickYid, Y + 1]; 158 | let x1, x2, y1, y2; 159 | if (this.frameMode == 1) { // 列选 160 | x1 = xmin * parent._width - parent.scrollX; 161 | x2 = (xmax - xmin) * parent._width; 162 | y1 = 0; 163 | y2 = parent.spectrum.height; 164 | } else if (this.frameMode == 2) { // 行选 165 | x1 = 0; 166 | x2 = parent.spectrum.width; 167 | y1 = parent.spectrum.height - ymax * parent._height + parent.scrollY; 168 | y2 = (ymax - ymin) * parent._height; 169 | } else { // 框选 170 | x1 = xmin * parent._width - parent.scrollX; 171 | x2 = (xmax - xmin) * parent._width; 172 | y1 = parent.spectrum.height - ymax * parent._height + parent.scrollY; 173 | y2 = (ymax - ymin) * parent._height; 174 | } s.fillRect(x1, y1, x2, y2); 175 | }; 176 | /** 177 | * 删除选中的音符 触发updateView 178 | * @param {boolean} save 是否存档 179 | */ 180 | this.deleteNote = (save = true) => { 181 | this.selected.forEach((v) => { 182 | let i = this.midi.indexOf(v); 183 | if (i != -1) this.midi.splice(i, 1); 184 | }); 185 | this.selected.length = 0; 186 | if (save) parent.snapshot.save(0b10); 187 | this.updateView(); 188 | }; 189 | this.clearSelected = () => { // 取消已选 190 | this.selected.forEach(v => { v.selected = false; }); 191 | this.selected.length = 0; 192 | }; 193 | /** 194 | * 改变选中的音符的时长 依赖相对于点击位置的移动改变长度 所以需要提前准备好clickX 195 | * 需要保证和changeNoteX同时只能使用一个 196 | * @param {MouseEvent} e 197 | */ 198 | this.changeNoteDuration = (e) => { 199 | _anyAction = true; 200 | // 兼容窗口滑动,以绝对坐标进行运算 201 | let dx = (((e.offsetX + parent.scrollX) / parent._width) | 0) - this.clickXid; 202 | this.selected.forEach((v) => { 203 | if ((v.x2 += dx - _tempdx) <= v.x1) v.x2 = v.x1 + 1; 204 | }); 205 | _tempdx = dx; 206 | }; 207 | this.changeNoteY = () => { // 要求在trackMouse之后添加入spectrum的mousemoveEnent 208 | _anyAction = true; 209 | let dy = parent.Keyboard.highlight - 24 - this.clickYid; 210 | this.selected.forEach((v) => { 211 | v.y += dy - _tempdy; 212 | }); 213 | _tempdy = dy; 214 | this.updateView(); 215 | }; 216 | this.changeNoteX = (e) => { // 由this.onclick_L调用 217 | _anyAction = true; 218 | let dx = (((e.offsetX + parent.scrollX) / parent._width) | 0) - this.clickXid; 219 | this.selected.forEach((v) => { 220 | let d = v.x2 - v.x1; 221 | if ((v.x1 += dx - _tempdx) < 0) v.x1 = 0; // 越界则设置为0 222 | v.x2 = v.x1 + d; 223 | }); 224 | _tempdx = dx; 225 | }; 226 | /** 227 | * 框选音符的鼠标动作 由this.onclick_L调用 228 | * 选中的标准:框住了音头 229 | */ 230 | this.selectAction = (mode = 0) => { 231 | this.frameXid = this.clickXid; // 先置大于零,表示开始绘制 232 | if (mode == 1) { // 列选 233 | parent.spectrum.addEventListener('mousemove', parent.trackMouseX); 234 | const up = () => { 235 | parent.spectrum.removeEventListener('mousemove', parent.trackMouseX); 236 | document.removeEventListener('mouseup', up); 237 | let ch = this.channelDiv.selected; 238 | if (ch && !ch.lock) { 239 | ch = ch.index; 240 | let [xmin, xmax] = this.clickXid <= this.frameXid ? [this.clickXid, this.frameXid + 1] : [this.frameXid, this.clickXid + 1]; 241 | for (const nt of this.midi) nt.selected = (nt.x1 >= xmin && nt.x1 < xmax && nt.ch == ch); 242 | this.selected = this.midi.filter(v => v.selected); 243 | } this.frameXid = -1; 244 | }; document.addEventListener('mouseup', up); 245 | } else if (mode == 2) { // 行选 246 | const up = () => { 247 | document.removeEventListener('mouseup', up); 248 | let ch = this.channelDiv.selected; 249 | if (ch && !ch.lock) { 250 | ch = ch.index; 251 | const Y = parent.Keyboard.highlight - 24; 252 | let [ymin, ymax] = Y <= this.clickYid ? [Y, this.clickYid + 1] : [this.clickYid, Y + 1]; 253 | for (const nt of this.midi) nt.selected = (nt.y >= ymin && nt.y < ymax && nt.ch == ch); 254 | this.selected = this.midi.filter(v => v.selected); 255 | } this.frameXid = -1; 256 | }; document.addEventListener('mouseup', up); 257 | } else { // 框选 258 | parent.spectrum.addEventListener('mousemove', parent.trackMouseX); 259 | const up = () => { 260 | parent.spectrum.removeEventListener('mousemove', parent.trackMouseX); 261 | document.removeEventListener('mouseup', up); 262 | let ch = this.channelDiv.selected; 263 | if (ch && !ch.lock) { 264 | ch = ch.index; 265 | const Y = parent.Keyboard.highlight - 24; 266 | let [xmin, xmax] = this.clickXid <= this.frameXid ? [this.clickXid, this.frameXid + 1] : [this.frameXid, this.clickXid + 1]; 267 | let [ymin, ymax] = Y <= this.clickYid ? [Y, this.clickYid + 1] : [this.clickYid, Y + 1]; 268 | for (const nt of this.midi) nt.selected = (nt.x1 >= xmin && nt.x1 < xmax && nt.y >= ymin && nt.y < ymax && nt.ch == ch); 269 | this.selected = this.midi.filter(v => v.selected); 270 | } this.frameXid = -1; // 表示不在框选 271 | }; document.addEventListener('mouseup', up); 272 | } 273 | }; 274 | /** 275 | * 添加音符的鼠标动作 由this.onclick_L调用 276 | */ 277 | this.addNoteAction = () => { 278 | if (!this.channelDiv.selected && !this.channelDiv.selectChannel(0)) return; // 如果没有选中则默认第一个 279 | if (this.channelDiv.selected.lock) return; // 锁定的音轨不能添加音符 280 | // 取消已选 281 | this.clearSelected(); 282 | // 添加新音符,设置已选 283 | const note = { 284 | y: this.clickYid, 285 | x1: this.clickXid, 286 | x2: this.clickXid + 1, 287 | ch: this.channelDiv.selected.index, 288 | selected: true 289 | }; this.selected.push(note); 290 | { // 二分插入 291 | let l = 0, r = this.midi.length; 292 | while (l < r) { 293 | let mid = (l + r) >> 1; 294 | if (this.midi[mid].x1 < note.x1) l = mid + 1; 295 | else r = mid; 296 | } this.midi.splice(l, 0, note); 297 | } 298 | _anyAction = true; 299 | this.updateView(); 300 | parent.spectrum.addEventListener('mousemove', this.changeNoteDuration); 301 | parent.spectrum.addEventListener('mousemove', this.changeNoteY); 302 | const removeEvent = () => { 303 | parent.spectrum.removeEventListener('mousemove', this.changeNoteDuration); 304 | parent.spectrum.removeEventListener('mousemove', this.changeNoteY); 305 | document.removeEventListener('mouseup', removeEvent); 306 | // 鼠标松开则存档 307 | if (_anyAction) parent.snapshot.save(0b10); 308 | }; document.addEventListener('mouseup', removeEvent); 309 | }; 310 | /** 311 | * MidiAction所有鼠标操作都由此分配 312 | */ 313 | this.onclick_L = (e) => { 314 | //== step 1: 判断是否点在了音符上 ==// 315 | _anyAction = false; 316 | // 为了支持在鼠标操作的时候能滑动,记录绝对位置 317 | _tempdx = _tempdy = 0; 318 | const x = this.clickXid = ((e.offsetX + parent.scrollX) / parent._width) | 0; 319 | if (x >= parent._xnum) { // 越界 320 | this.clearSelected(); return; 321 | } 322 | const y = this.clickYid = parent.Keyboard.highlight - 24; 323 | // 找到点击的最近的音符 由于点击不经常,所以用遍历足矣 只需要遍历insight的音符 324 | let n = null; 325 | for (let ch_id = 0; ch_id < this.insight.length; ch_id++) { 326 | const chitem = this.channelDiv.channel[ch_id] // insight和channelDiv的顺序是一致的 327 | if (!chitem.visible || chitem.lock) continue; // 隐藏、锁定的音轨选不中 328 | const ch = this.insight[ch_id]; 329 | // 每层挑选左侧最靠近的(如果有多个) 330 | let distance = parent._width * parent._xnum; 331 | for (const nt of ch) { // 由于来自midi,因此每个音轨内部是有序的 332 | let dis = x - nt.x1; 333 | if (dis < 0) break; 334 | if (y == nt.y && x < nt.x2) { 335 | if (dis < distance) { 336 | distance = dis; 337 | n = nt; 338 | } 339 | } 340 | } if (n) break; // 只找最上层的 341 | } 342 | if (!n) { // 添加或框选音符 关于lock的处理在函数中 343 | if (this.mode) this.selectAction(this.frameMode); 344 | else this.addNoteAction(); 345 | return; 346 | } 347 | this.channelDiv.selectChannel(n.ch); 348 | //== step 2: 如果点击到了音符,ctrl是否按下 ==/ 349 | if (e.ctrlKey) { // 有ctrl表示多选 350 | if (n.selected) { // 已经选中了,取消选中 351 | this.selected.splice(this.selected.indexOf(n), 1); 352 | n.selected = false; 353 | } else { // 没选中,添加选中 354 | this.selected.push(n); 355 | n.selected = true; 356 | } return; 357 | } 358 | //== step 3: 单选时,是否选中了多个(事关什么时候取消选中) ==// 359 | if (this.selected.length > 1 && n.selected) { // 如果选择了多个,在松开鼠标的时候处理选中 360 | const up = () => { 361 | if (!_anyAction) { // 没有任何拖拽动作,说明为了单选 362 | this.selected.forEach(v => { v.selected = false; }); 363 | this.selected.length = 0; 364 | n.selected = true; 365 | this.selected.push(n); 366 | } 367 | document.removeEventListener('mouseup', up); 368 | }; document.addEventListener('mouseup', up); 369 | } else { // 只选一个 370 | if (n.selected) { 371 | const up = () => { 372 | if (!_anyAction) { // 没有任何拖拽动作,说明为了取消选中 373 | this.selected.forEach(v => { v.selected = false; }); 374 | this.selected.length = 0; 375 | } document.removeEventListener('mouseup', up); 376 | }; document.addEventListener('mouseup', up); 377 | } else { 378 | this.selected.forEach(v => { v.selected = false; }); 379 | this.selected.length = 0; 380 | n.selected = true; 381 | this.selected.push(n); 382 | } 383 | } 384 | //== step 4: 如果点击到了音符,添加移动事件 ==// 385 | if (((e.offsetX + parent.scrollX) << 1) > (n.x2 + n.x1) * parent._width) { // 靠近右侧,调整时长 386 | parent.spectrum.addEventListener('mousemove', this.changeNoteDuration); 387 | parent.spectrum.addEventListener('mousemove', this.changeNoteY); 388 | const removeEvent = () => { 389 | parent.spectrum.removeEventListener('mousemove', this.changeNoteDuration); 390 | parent.spectrum.removeEventListener('mousemove', this.changeNoteY); 391 | document.removeEventListener('mouseup', removeEvent); 392 | // 鼠标松开则存档 393 | if (_anyAction) parent.snapshot.save(0b10); 394 | }; document.addEventListener('mouseup', removeEvent); 395 | } else { // 靠近左侧,调整位置 396 | parent.spectrum.addEventListener('mousemove', this.changeNoteX); 397 | parent.spectrum.addEventListener('mousemove', this.changeNoteY); 398 | const removeEvent = () => { 399 | parent.spectrum.removeEventListener('mousemove', this.changeNoteX); 400 | parent.spectrum.removeEventListener('mousemove', this.changeNoteY); 401 | document.removeEventListener('mouseup', removeEvent); 402 | this.midi.sort((a, b) => a.x1 - b.x1); // 排序非常重要 因为查找被点击的音符依赖顺序 403 | // 鼠标松开则存档 404 | if (_anyAction) parent.snapshot.save(0b10); 405 | }; document.addEventListener('mouseup', removeEvent); 406 | } 407 | }; 408 | } -------------------------------------------------------------------------------- /app_io.js: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | /** 5 | * 文件相关操作 6 | * @param {App} parent 7 | */ 8 | function _IO(parent) { 9 | /** 10 | * 会发出如下event: 11 | * - fileui: 展示本函数的UI,需要外界关闭drag功能 12 | * - fileuiclose: UI关闭,外界恢复drag功能 13 | * - fileerror: 文件解析错误,外界提示用户; detail为Error对象 14 | * - progress: 解析进度,detail为0~1的数字,-1表示完成 15 | * event的意义是可以反复触发(比如进度文件错误可重试),而返回值的promise只能触发一次 16 | * @param {File} file 音频文件 如果不传则进入midi编辑器模式 17 | * @returns Promise 指示用户操作完成,即UI关闭 18 | */ 19 | this.onfile = (file) => { // 依赖askUI.css 20 | let midimode = file == void 0; // 在确认后才能parent.midiMode=midimode 21 | if (midimode) { // 不传则说明是midi编辑器模式 22 | file = { name: "MIDI编辑模式" }; 23 | } else if (!(file.type.startsWith('audio/') || file.type.startsWith('video/'))) { 24 | parent.event.dispatchEvent(new CustomEvent('fileerror', { detail: new Error("不支持的文件类型") })); 25 | return Promise.reject(); 26 | } else if (file.type == "audio/mid") { 27 | if (parent.Spectrogram._spectrogram) { 28 | this.midiFile.import(file); 29 | return Promise.resolve(); 30 | } 31 | // 没有设置时间精度,先弹设置UI 32 | return this.onfile().then(() => { 33 | this.midiFile.import(file); 34 | }); 35 | } 36 | if (parent.Spectrogram._spectrogram && !confirm("本页面已加载音频,是否替换?")) { 37 | return Promise.reject(); 38 | } 39 | 40 | // 指示是否完成 41 | let resolve, reject; 42 | const donePromise = new Promise((res, rej) => { resolve = res; reject = rej; }); 43 | const loadAudio = (URLmode = true) => new Promise((res, rej) => { 44 | const fileReader = new FileReader(); 45 | // 音频文件错误标志本次会话结束 46 | // 调用loadAudio不需要再写catch 47 | fileReader.onerror = (e) => { 48 | parent.event.dispatchEvent(new CustomEvent('fileerror', { detail: e })); 49 | console.error("FileReader error", e); 50 | reject(e); 51 | rej(e); 52 | }; 53 | fileReader.onload = (e) => { 54 | res(e.target.result); 55 | }; 56 | if (URLmode) fileReader.readAsDataURL(file); 57 | else fileReader.readAsArrayBuffer(file); 58 | }); 59 | 60 | parent.event.dispatchEvent(new Event('fileui')); // 关闭drag功能 61 | let tempDiv = document.createElement('div'); 62 | // 为了不影响下面的事件绑定,midi模式下用display隐藏 63 | tempDiv.innerHTML = ` 64 | 65 | ${file.name} 使用已有结果 66 | 每秒的次数: 67 | 标准频率A4= 68 | 分析声道: 69 | 70 | Stereo 71 | L+R 72 | L-R 73 | L 74 | R 75 | 76 | 77 | 取消 78 | 79 | ${midimode ? '确认' : '解析'} 80 | 81 | 82 | `; 83 | parent.AudioPlayer.name = file.name; 84 | const ui = tempDiv.firstElementChild; 85 | const close = () => ui.remove(); 86 | let btns = ui.getElementsByTagName('button'); 87 | btns[0].onclick = () => { // 进度上传 88 | const input = document.createElement("input"); 89 | input.type = "file"; 90 | input.onchange = () => { 91 | parent.io.projFile.parse(input.files[0]).then((data) => { 92 | if (parent.AudioPlayer.name != data[0].name && 93 | !confirm(`音频文件与进度(${data[0].name})不同,是否继续?`)) 94 | return; 95 | // 再读取音频看看是否成功 96 | loadAudio(true).then((audioBuffer) => { 97 | // 设置音频源 缓存到浏览器 98 | parent.AudioPlayer.createAudio(audioBuffer).then(() => { 99 | parent.io.projFile.import(data); 100 | // 触发html中的iniEQUI 101 | parent.event.dispatchEvent(new CustomEvent('progress', { detail: -1 })); 102 | resolve(); 103 | }).catch((e) => { 104 | parent.event.dispatchEvent(new CustomEvent('fileerror', { detail: e })); 105 | console.error("AudioPlayer error", e); 106 | reject(e); 107 | }).finally(() => { 108 | close(); // 不管音频结果如何都要关闭UI 109 | parent.event.dispatchEvent(new Event('fileuiclose')); // 恢复drag功能 110 | }); 111 | }); 112 | }).catch((e) => { 113 | // 进度文件错误,允许重试,不reject 114 | parent.event.dispatchEvent(new CustomEvent('fileerror', { detail: e })); 115 | }); 116 | }; input.click(); 117 | }; 118 | btns[1].onclick = () => { // 取消按钮 119 | close(); 120 | parent.event.dispatchEvent(new Event('fileuiclose')); // 恢复drag功能 121 | resolve(false); 122 | }; 123 | btns[2].onclick = () => { // 确认按钮 124 | // 获取分析参数 125 | const params = ui.querySelectorAll('[name="ui-ask"]'); // getElementsByName只能在document中用 126 | let tNum = params[0].value; 127 | let A4 = params[1].value; 128 | let channel = 4; 129 | for (let i = 2; i < 7; i++) { 130 | if (params[i].checked) { 131 | channel = parseInt(params[i].value); 132 | break; 133 | } 134 | } 135 | close(); 136 | parent.midiMode = midimode; 137 | 138 | //==== midi模式 ====// 139 | if (midimode) { 140 | // 在Anaylse中的设置全局的 141 | parent.dt = 1000 / tNum; 142 | parent.TperP = parent.dt / parent._width; parent.PperT = parent._width / parent.dt; 143 | if (parent.Keyboard.freqTable.A4 != A4) parent.Keyboard.freqTable.A4 = A4; 144 | let l = Math.ceil((parent.spectrum.width << 1) / parent.width); // 视野外还有一面 145 | // 一个怎么取值都返回0的东西,充当频谱 146 | parent.Spectrogram.spectrogram = new Proxy({ 147 | spectrogram: new Uint8Array(parent.ynum).fill(0), 148 | _length: l, 149 | get length() { return this._length; }, 150 | set length(l) { // 只要改变频谱的长度就可以改变时长 长度改变在MidiAction.updateView中 151 | if (l < 0) return; 152 | this._length = parent.xnum = l; 153 | parent.AudioPlayer.audio.duration = l / tNum; 154 | parent.AudioPlayer.play_btn.lastChild.textContent = parent.AudioPlayer.durationString; 155 | } 156 | }, { 157 | get(obj, prop) { // 方括号传递的总是string 158 | if (isNaN(Number(prop))) return obj[prop]; 159 | return obj.spectrogram; 160 | }, 161 | set(obj, prop, value) { 162 | if (isNaN(Number(prop))) obj[prop] = value; 163 | } 164 | }); 165 | // 假音频 需要设置parent.midiMode=true; 166 | parent.AudioPlayer.createAudio(l / tNum).then(() => { 167 | resolve(); 168 | }).catch((e) => { 169 | reject(e); 170 | }); 171 | parent.event.dispatchEvent(new Event('fileuiclose')); // 恢复drag功能 172 | return; 173 | } 174 | 175 | //==== 音频文件分析 ====// 176 | // 打开另一个ui analyse加入回调以显示进度 177 | let tempDiv = document.createElement('div'); 178 | tempDiv.innerHTML = ` 179 | 180 | 解析中 181 | 00% 182 | 183 | 184 | 185 | 186 | 187 | 188 | `; 189 | const progressUI = tempDiv.firstElementChild; 190 | const progress = progressUI.querySelector('.porgress-value'); 191 | const percent = progressUI.querySelector('span'); 192 | document.body.insertBefore(progressUI, document.body.firstChild); 193 | const onprogress = ({ detail }) => { 194 | if (detail < 0) { 195 | parent.event.removeEventListener('progress', onprogress); 196 | progress.style.width = '100%'; 197 | percent.textContent = '100%'; 198 | progressUI.style.opacity = 0; 199 | setTimeout(() => progressUI.remove(), 200); 200 | } else if (detail >= 1) { 201 | detail = 1; 202 | progress.style.width = '100%'; 203 | percent.textContent = "加载界面……"; 204 | } else { 205 | progress.style.width = (detail * 100) + '%'; 206 | percent.textContent = (detail * 100).toFixed(2) + '%'; 207 | } 208 | }; 209 | parent.event.addEventListener('progress', onprogress); 210 | // 读取文件 211 | loadAudio(false).then((audioBuffer) => { 212 | let audioData; 213 | // 解码音频文件为音频缓冲区 214 | parent.audioContext.decodeAudioData(audioBuffer).then((decodedData) => { 215 | audioData = decodedData; 216 | return Promise.all([ 217 | parent.Analyser.stft(decodedData, tNum, A4, channel, 8192), 218 | parent.AudioPlayer.createAudio(URL.createObjectURL(file)) // fileReader.readAsDataURL(file) 将mov文件decode之后变成base64,audio无法播放 故不用 219 | ]); 220 | }).then(([v, audio]) => { 221 | parent.Spectrogram.spectrogram = v; 222 | resolve(); 223 | // 后台执行CQT CQT的报错已经被拦截不会冒泡到下面的catch中 224 | parent.Analyser.cqt(audioData, tNum, channel); 225 | }).catch((e) => { 226 | console.error(e); 227 | parent.event.dispatchEvent(new CustomEvent('fileerror', { detail: e })); 228 | reject(e); 229 | }).finally(() => { 230 | // 最终都要关闭进度条 231 | parent.event.dispatchEvent(new CustomEvent('progress', { detail: -1 })); 232 | parent.event.dispatchEvent(new Event('fileuiclose')); // 恢复drag功能 233 | }); 234 | }); 235 | }; 236 | document.body.insertBefore(ui, document.body.firstChild); // 插入body的最前面 237 | return donePromise; 238 | }; 239 | 240 | // 进度文件 241 | this.projFile = { 242 | export() { 243 | if (!parent.Spectrogram._spectrogram) return null; 244 | const data = { 245 | midi: parent.MidiAction.midi, 246 | channel: parent.MidiAction.channelDiv.channel, 247 | beat: parent.BeatBar.beats, 248 | dt: parent.dt, 249 | A4: parent.Keyboard.freqTable.A4, 250 | name: parent.AudioPlayer.name 251 | }; return [data, parent.Spectrogram._spectrogram]; 252 | }, 253 | import(data) { 254 | const obj = data[0]; 255 | parent.MidiAction.midi = obj.midi; 256 | parent.MidiAction.selected = parent.MidiAction.midi.filter((obj) => obj.selected); 257 | parent.MidiAction.channelDiv.fromArray(obj.channel); 258 | parent.BeatBar.beats.copy(obj.beat); 259 | parent.dt = obj.dt; 260 | parent.Keyboard.freqTable.A4 = obj.A4; 261 | parent.Spectrogram.spectrogram = data[1]; 262 | parent.snapshot.save(); 263 | }, 264 | write(fileName = parent.AudioPlayer.name) { 265 | const data = this.export(); 266 | bSaver.saveArrayBuffer(bSaver.combineArrayBuffers([ 267 | bSaver.String2Buffer("noteDigger"), 268 | bSaver.Object2Buffer(data[0]), 269 | bSaver.Float32Mat2Buffer(data[1]) 270 | ]), fileName + '.nd'); 271 | }, 272 | parse(file) { 273 | return new Promise((resolve, reject) => { 274 | bSaver.readBinary(file, (b) => { 275 | let [name, o] = bSaver.Buffer2String(b, 0); 276 | if (name != "noteDigger") { 277 | reject(new Error("incompatible file!")); 278 | return; 279 | } 280 | let [obj, o1] = bSaver.Buffer2Object(b, o); 281 | let [f32, _] = bSaver.Buffer2Float32Mat(b, o1); 282 | resolve([obj, f32]); 283 | }); 284 | }); 285 | } 286 | }; 287 | 288 | // midi文件 289 | this.midiFile = { 290 | export: { 291 | UI() { 292 | let tempDiv = document.createElement('div'); 293 | tempDiv.innerHTML = ` 294 | 295 | 导出为midi 296 | 导出时节奏对齐 297 | 和听起来一样 298 | 取消 299 | 300 | `; 301 | const card = tempDiv.firstElementChild; 302 | const close = () => { card.remove(); }; 303 | const btns = card.querySelectorAll('button'); 304 | btns[0].onclick = () => { 305 | const midi = this.beatAlign(); 306 | bSaver.saveArrayBuffer(midi.export(1), midi.name + '.mid'); 307 | close(); 308 | }; 309 | btns[1].onclick = () => { 310 | const midi = this.keepTime(); 311 | bSaver.saveArrayBuffer(midi.export(1), midi.name + '.mid'); 312 | close(); 313 | }; 314 | btns[2].onclick = close; 315 | document.body.insertBefore(card, document.body.firstChild); 316 | card.tabIndex = 0; 317 | card.focus(); 318 | }, 319 | /** 320 | * 100%听感还原扒谱结果,但节奏是乱的 321 | */ 322 | keepTime() { 323 | const accuracy = 10; 324 | const newMidi = new midi(60, [4, 4], Math.round(1000 * accuracy / parent.dt), [], parent.AudioPlayer.name); 325 | const mts = []; 326 | for (const ch of parent.synthesizer.channel) { 327 | let mt = newMidi.addTrack(); 328 | mt.addEvent(midiEvent.instrument(0, ch.instrument)); 329 | mt._volume = ch.volume; 330 | mts.push(mt); 331 | } 332 | for (const nt of parent.MidiAction.midi) { 333 | const midint = nt.y + 24; 334 | let v = mts[nt.ch]._volume; 335 | if (nt.v) v = Math.min(127, v * nt.v / 127); 336 | mts[nt.ch].addEvent(midiEvent.note(nt.x1 * accuracy, (nt.x2 - nt.x1) * accuracy, midint, v)); 337 | } return newMidi; 338 | }, 339 | beatAlign() { 340 | // 初始化midi 341 | let begin = parent.BeatBar.beats[0]; 342 | let lastbpm = begin.bpm; // 用于自适应bpm 343 | const newMidi = new midi(lastbpm, [begin.beatNum, begin.beatUnit], 480, [], parent.AudioPlayer.name); 344 | const mts = []; 345 | for (const ch of parent.synthesizer.channel) { 346 | let mt = newMidi.addTrack(); 347 | mt.addEvent(midiEvent.instrument(0, ch.instrument)); 348 | mt._volume = ch.volume; 349 | mts.push(mt); 350 | } 351 | // 将每个音符拆分为两个时刻 352 | const Midis = parent.MidiAction.midi; 353 | const mlen = Midis.length << 1; 354 | const moment = new Array(mlen); 355 | for (let i = 0, j = 0; i < mlen; j++) { 356 | const nt = Midis[j]; 357 | let duration = nt.x2 - nt.x1; 358 | let midint = nt.y + 24; 359 | let v = mts[nt.ch]._volume; 360 | if (nt.v) v = Math.min(127, v * nt.v / 127); 361 | moment[i++] = new midiEvent({ 362 | _d: duration, 363 | ticks: nt.x1, 364 | code: 0x9, 365 | value: [midint, v], 366 | _ch: nt.ch 367 | }, true); 368 | moment[i++] = new midiEvent({ 369 | _d: duration, 370 | ticks: nt.x2, 371 | code: 0x9, 372 | value: [midint, 0], 373 | _ch: nt.ch 374 | }, true); 375 | } moment.sort((a, b) => a.ticks - b.ticks); 376 | // 对每个小节进行对齐 377 | let m_i = 0; // moment的指针 378 | let tickNow = 0; // 维护总时长 379 | for (const measure of parent.BeatBar.beats) { 380 | if (m_i == mlen) break; 381 | 382 | //== 判断bpm是否变化 假设小节之间bpm相关性很强 ==// 383 | const bpmnow = measure.bpm; 384 | if (Math.abs(bpmnow - lastbpm) > lastbpm * 0.065) { 385 | mts[0].events.push(midiEvent.tempo(tickNow, bpmnow * 4 / measure.beatUnit)); 386 | } lastbpm = bpmnow; 387 | 388 | //== 对齐音符 ==// 389 | const begin = measure.start / parent.dt; // 转换为以“格”为单位 390 | const end = (measure.interval + measure.start) / parent.dt; 391 | // 一个八音符的格数 392 | const aot = measure.interval * measure.beatUnit / (measure.beatNum * 8 * parent.dt); 393 | while (m_i < mlen) { 394 | const n = moment[m_i]; 395 | if (n.ticks > end) break; // 给下一小节 396 | const threshold = n._d / 2; 397 | let accuracy = aot; 398 | while (accuracy > threshold) accuracy /= 2; 399 | n.ticks = tickNow + ((Math.round((n.ticks - begin) / accuracy) * newMidi.tick * accuracy / aot) >> 1); 400 | mts[n._ch].events.push(n); 401 | m_i++; 402 | } tickNow += newMidi.tick * measure.beatNum * 4 / measure.beatUnit; 403 | } return newMidi; 404 | } 405 | }, 406 | import(file) { 407 | bSaver.readBinary(file, (data) => { 408 | let m; 409 | try { 410 | m = midi.import(new Uint8Array(data)).JSON(); 411 | } catch (error) { 412 | console.error("Error importing MIDI:", error); 413 | alert("导入MIDI文件时出错"); 414 | return; 415 | } 416 | const chdiv = parent.MidiAction.channelDiv; 417 | chdiv.switchUpdateMode(false); // 下面会一次性创建大量音符,所以先关闭更新 418 | let tickTimeTable = m.header.tempos; // bpm会随着时间改变 419 | const chArray = []; 420 | let chArrayIndex = 0; 421 | for (const mt of m.tracks) { 422 | if (mt.notes.length == 0) continue; 423 | 424 | var tickTimeAt = -1; 425 | var nexttickTimeChange = 0; 426 | var tickTime = 0; // 一个tick的毫秒数/parent.dt 427 | function checkChange(tick) { 428 | if (tick > nexttickTimeChange) { 429 | tickTimeAt++; 430 | nexttickTimeChange = tickTimeTable[tickTimeAt + 1] ? tickTimeTable[tickTimeAt + 1].ticks : Infinity; 431 | tickTime = 60000 / (tickTimeTable[tickTimeAt].bpm * m.header.tick * parent.dt); 432 | } return tickTime; 433 | } checkChange(1); 434 | 435 | const ch = chdiv.addChannel(); 436 | if (!ch) break; // 音轨已满,addChannel会返回undefined同时alert,所以只要break 437 | const chid = ch.index; 438 | ch.name = `导入音轨${chid}`; 439 | ch.ch.instrument = mt.instruments[0]?.number || 0; 440 | ch.instrument = TinySynth.instrument[ch.ch.instrument]; 441 | 442 | // 音符强度归一化到0-127 演奏和导出时用的是“通道音量*音符音量/127” 443 | let maxIntensity = mt.notes.reduce((a, b) => a.intensity > b.intensity ? a : b).intensity; 444 | ch.ch.volume = maxIntensity; 445 | 446 | chArray[chArrayIndex++] = mt.notes.map((nt) => { 447 | const t = checkChange(nt.ticks); 448 | return { // 理应给x1和x2取整,但是为了尽量不损失信息就不取整了 不取整会导致导出midi时要取整 449 | x1: nt.ticks * t, 450 | x2: (nt.ticks + nt.durationTicks) * t, 451 | y: nt.midi - 24, 452 | ch: chid, 453 | selected: false, 454 | v: nt.intensity / maxIntensity * 127 455 | }; 456 | }); 457 | } 458 | for (const ch of chArray) parent.MidiAction.midi.push(...ch); 459 | parent.MidiAction.midi.sort((a, b) => a.x1 - b.x1); 460 | chdiv.switchUpdateMode(true); // 打开更新并一次性处理积压请求 461 | }); 462 | }, 463 | } 464 | } --------------------------------------------------------------------------------