├── 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 | 使用