Skip to content

Commit 7511236

Browse files
feat:利用NNLS实现谐波去除
1 parent 803bbbd commit 7511236

4 files changed

Lines changed: 272 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
| │ analyser.js: 频域数据分析与简化
119119
| │ fft_real.js: 执行实数FFT获取频域数据
120120
| │ stftGPU.js: 使用WebGPU加速STFT
121+
| | NNLS.js: 非负最小二乘 用于去除谐波
121122
| |
122123
| ├─AI
123124
│ │ │ AIEntrance.js: 开启AI的worker的入口

app_analyser.js

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
/// <reference path="./dataProcess/AI/AIEntrance.js" />
55
/// <reference path="./dataProcess/ANA.js" />
66
/// <reference path="./dataProcess/bpmEst.js" />
7+
/// <reference path="./dataProcess/NNLS.js" />
78

89
/**
910
* 数据解析相关算法
@@ -426,11 +427,11 @@ function _Analyser(parent) {
426427
beatbar.push(new eMeasure(id, prev, 1, 4, at - prev));
427428
prev = at;
428429
}
429-
for (i = i - 1 + g_pattern; i < beatIdx.length; i+=g_pattern, id++) {
430+
for (i = i - 1 + g_pattern; i < beatIdx.length; i += g_pattern, id++) {
430431
const at = beatIdx[i] * parent.dt;
431432
beatbar.push(new eMeasure(id, prev, g_pattern, 4, at - prev));
432433
prev = at;
433-
}
434+
}
434435
}
435436
beatbar.check(true);
436437
parent.snapshot.save(0b100);
@@ -465,4 +466,93 @@ function _Analyser(parent) {
465466
++n;
466467
} return parts;
467468
}
469+
470+
/**
471+
* 利用非负最小二乘去除频谱中的谐波成分
472+
* 原理是将谐波模板(每个音符的基频和若干个谐波)作为特征,拟合出每一帧中各个音符的强度,然后将这些音符的谐波成分从频谱中减去
473+
* @param {number} decay 谐波衰减率,默认0.6,越大去除越彻底但可能过度拟合
474+
*/
475+
this.reduceHarmonic = async (decay = 0.6) => {
476+
const container = document.createElement('div');
477+
container.innerHTML = `<div class="request-cover">
478+
<div class="card hvCenter"><label class="title">分析中</label>
479+
<span>00%</span>
480+
<div class="layout">
481+
<div class="porgress-track">
482+
<div class="porgress-value"></div>
483+
</div>
484+
</div>
485+
</div>
486+
</div>`;
487+
const progressUI = container.firstElementChild;
488+
const progress = progressUI.querySelector('.porgress-value');
489+
const percent = progressUI.querySelector('span');
490+
document.body.insertBefore(progressUI, document.body.firstChild);
491+
const onprogress = (detail) => {
492+
if (detail < 0) {
493+
progress.style.width = '100%';
494+
percent.textContent = '100%';
495+
progressUI.style.opacity = 0;
496+
setTimeout(() => progressUI.remove(), 200);
497+
} else if (detail >= 1) {
498+
detail = 1;
499+
progress.style.width = '100%';
500+
percent.textContent = "加载界面……";
501+
} else {
502+
progress.style.width = (detail * 100) + '%';
503+
percent.textContent = (detail * 100).toFixed(2) + '%';
504+
}
505+
};
506+
var lastFrame = performance.now();
507+
508+
const s = parent.Spectrogram._spectrogram;
509+
const M = s[0].length, N = s.length;
510+
const M1 = M + 1;
511+
// 创建音符谐波模板
512+
const harmonicAmp = Array.from({ length: 10 }, (_, i) => decay ** i);
513+
const Harmonic = new Float32Array(M);
514+
for (let i = 0; i < harmonicAmp.length; i++) {
515+
const idx = 12 * Math.log2(i + 1);
516+
let l = Math.floor(idx), r = Math.ceil(idx);
517+
if (r < M) {
518+
if (l == r) Harmonic[l] = harmonicAmp[i];
519+
else {
520+
Harmonic[l] += harmonicAmp[i] * (r - idx);
521+
Harmonic[r] += harmonicAmp[i] * (idx - l);
522+
}
523+
}
524+
}
525+
// 填充到模板矩阵A
526+
const A = new Float32Array(M * M);
527+
for (let i = 0; i < M; i++)
528+
A.set(Harmonic.subarray(0, M - i), i * M1);
529+
// 对每一帧执行NNLS
530+
const nnls = new NNLSSolver(M, M, 2e-4);
531+
for (let t = 0; t < N; t++) {
532+
const f0 = s[t];
533+
const c = nnls.solve(A, f0);
534+
// 计算谐波
535+
Harmonic.fill(0);
536+
for (let i = 0; i < M; i++) {
537+
const a = i + 12; // 从二次谐波开始
538+
const off = i * M + a;
539+
for (let j = 0; j < M - a; j++) {
540+
Harmonic[a + j] += A[off + j] * c[i];
541+
}
542+
}
543+
// 从原始频谱中减去谐波
544+
for (let i = 0; i < M; i++) {
545+
f0[i] = Math.max(0, f0[i] - Harmonic[i]);
546+
}
547+
// UI更新
548+
let tnow = performance.now();
549+
if (tnow - lastFrame > 200) {
550+
onprogress(t / N);
551+
await new Promise(resolve => setTimeout(resolve, 0)); // 等待UI
552+
lastFrame = tnow;
553+
}
554+
}
555+
parent.layers.spectrum.dirty = true;
556+
onprogress(-1);
557+
}
468558
}

dataProcess/NNLS.js

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* 为密集计算设计的高性能非负最小二乘求解器
3+
*/
4+
class NNLSSolver {
5+
/**
6+
* @param {number} K 个数
7+
* @param {number} M 维度
8+
* @param {number} lambda 正则化参数 防止不稳定
9+
*/
10+
constructor(K, M, lambda = 1e-4) {
11+
this.K = K;
12+
this.M = M;
13+
this.lambda = lambda;
14+
// 预分配内存
15+
this.c = new Float32Array(K); // 最终系数 (K)
16+
this.s = new Float32Array(K); // 候选系数 (K)
17+
this.w = new Float32Array(K); // 梯度 (K)
18+
this.residual = new Float32Array(M); // 增量残差 (M)
19+
this.matM = new Float32Array(M * M); // 正规方程矩阵 (M*M)
20+
this.rhsM = new Float32Array(M); // 正规方程右侧向量 (M)
21+
this.L = new Float32Array(M * M); // Cholesky 分解矩阵 (M*M)
22+
this.z = new Float32Array(M); // 临时连续解向量 (M)
23+
this.isP = new Uint8Array(K);
24+
this.pIdx = new Int32Array(M);
25+
}
26+
27+
/**
28+
* 求解非负最小二乘问题 min ||Ax - b||_2^2 s.t. x >= 0
29+
* @param {Float32Array} A 每M个数为一组,一共K组
30+
* @param {Float32Array} b 长M
31+
* @returns {Float32Array} 长K的非负系数向量x 是this.c的引用
32+
*/
33+
solve(A, b) {
34+
const { K, M, c, s, w, residual, isP, pIdx } = this;
35+
c.fill(0);
36+
isP.fill(0);
37+
residual.set(b);
38+
let pCount = 0;
39+
const tol = 1e-7 * M; // 根据维度动态调整容差
40+
for (let iter = 0, maxIter = K << 1; iter < maxIter; iter++) {
41+
// 1. 计算梯度 w = A^T * residual
42+
let maxW = -1, jMax = -1;
43+
for (let j = 0; j < K; j++) {
44+
if (isP[j]) continue;
45+
let dot = 0;
46+
const offset = j * M;
47+
for (let i = 0; i < M; i++) dot += A[offset + i] * residual[i];
48+
w[j] = dot;
49+
if (dot > maxW) { maxW = dot; jMax = j; }
50+
}
51+
if (jMax === -1 || maxW < tol) break;
52+
isP[jMax] = 1;
53+
pIdx[pCount++] = jMax;
54+
while (pCount > 0) {
55+
// 求解子问题,结果暂存在 s 中
56+
if (!this._solveSubProblem(A, b, pCount, pIdx, s)) {
57+
const last = pIdx[--pCount];
58+
isP[last] = c[last] = 0;
59+
break;
60+
}
61+
let alpha = 2.0;
62+
let hasConstraintViolation = false;
63+
for (let i = 0; i < pCount; i++) {
64+
const idx = pIdx[i];
65+
if (s[idx] <= 0) {
66+
const ratio = c[idx] / (c[idx] - s[idx] + 1e-15);
67+
if (ratio < alpha) {
68+
alpha = ratio;
69+
hasConstraintViolation = true;
70+
}
71+
}
72+
}
73+
if (!hasConstraintViolation) {
74+
// 无冲突:更新残差并接受新系数
75+
this._updateResidual(A, c, s, pCount, pIdx);
76+
for (let i = 0; i < pCount; i++) c[pIdx[i]] = s[pIdx[i]];
77+
break;
78+
}
79+
// 有冲突:按 alpha 步长靠近,并剔除归零的变量
80+
for (let i = 0; i < pCount; i++) {
81+
const idx = pIdx[i];
82+
c[idx] += alpha * (s[idx] - c[idx]);
83+
}
84+
for (let i = 0; i < pCount; i++) {
85+
const idx = pIdx[i];
86+
if (c[idx] < 1e-9) { // 稍微放宽归零判定
87+
c[idx] = 0;
88+
isP[idx] = 0;
89+
pIdx[i] = pIdx[--pCount];
90+
i--;
91+
}
92+
}
93+
this._fullResidualUpdate(A, b, c, pCount, pIdx);
94+
}
95+
} return c;
96+
}
97+
98+
_solveSubProblem(A, b, n, pIdx, s) {
99+
const { M, matM, rhsM, L, z, lambda } = this;
100+
// 1. 构建正规方程
101+
for (let i = 0; i < n; i++) {
102+
const offI = pIdx[i] * M;
103+
let dotB = 0;
104+
for (let r = 0; r < M; r++) dotB += A[offI + r] * b[r];
105+
rhsM[i] = dotB;
106+
for (let j = 0; j <= i; j++) {
107+
const offJ = pIdx[j] * M;
108+
let dotA = 0;
109+
for (let r = 0; r < M; r++) dotA += A[offI + r] * A[offJ + r];
110+
if (i === j) dotA += lambda;
111+
matM[i * n + j] = dotA;
112+
}
113+
}
114+
// 2. Cholesky 分解
115+
for (let i = 0; i < n; i++) {
116+
for (let j = 0; j <= i; j++) {
117+
let sum = matM[i * n + j];
118+
for (let k = 0; k < j; k++) sum -= L[i * n + k] * L[j * n + k];
119+
if (i === j) {
120+
if (sum <= 0) return false;
121+
L[i * n + j] = Math.sqrt(sum);
122+
} else {
123+
L[i * n + j] = sum / L[j * n + j];
124+
}
125+
}
126+
}
127+
// 3. 前向替换 (L * y = rhsM -> 结果存入 z)
128+
for (let i = 0; i < n; i++) {
129+
let sum = rhsM[i];
130+
for (let k = 0; k < i; k++) sum -= L[i * n + k] * z[k];
131+
z[i] = sum / L[i * n + i];
132+
}
133+
// 4. 后向替换 (L^T * x = z -> 结果存入 z)
134+
for (let i = n - 1; i >= 0; i--) {
135+
let sum = z[i];
136+
for (let k = i + 1; k < n; k++) sum -= L[k * n + i] * z[k];
137+
z[i] = sum / L[i * n + i];
138+
}
139+
// 5. 映射回原始大向量 s
140+
s.fill(0); // 必须清零,因为s共享
141+
for (let i = 0; i < n; i++) {
142+
s[pIdx[i]] = z[i];
143+
} return true;
144+
}
145+
_updateResidual(A, oldC, newS, n, pIdx) {
146+
const { M, residual } = this;
147+
for (let i = 0; i < n; i++) {
148+
const idx = pIdx[i];
149+
const delta = newS[idx] - oldC[idx];
150+
if (Math.abs(delta) < 1e-14) continue;
151+
const offset = idx * M;
152+
for (let r = 0; r < M; r++) residual[r] -= A[offset + r] * delta;
153+
}
154+
}
155+
_fullResidualUpdate(A, b, c, n, pIdx) {
156+
const { M, residual } = this;
157+
residual.set(b);
158+
for (let i = 0; i < n; i++) {
159+
const idx = pIdx[i];
160+
if (c[idx] === 0) continue;
161+
const offset = idx * M;
162+
for (let r = 0; r < M; r++) residual[r] -= A[offset + r] * c[idx];
163+
}
164+
}
165+
// 在调用 solve() 之后可以使用此函数获取当前残差的 L2 范数
166+
calcError() {
167+
let sum = 0;
168+
for (let i = 0; i < this.M; i++) sum += this.residual[i] ** 2;
169+
return Math.sqrt(sum);
170+
}
171+
}

index.html

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
<script src="./dataProcess/bpmEst.js" async></script>
2222
<!-- 自动音符对齐 -->
2323
<script src="./dataProcess/ANA.js" async></script>
24+
<!-- 非负最小二乘 -->
25+
<script src="./dataProcess/NNLS.js" async></script>
2426

2527
<script src="./siderMenu.js"></script>
2628
<script src="./myRange.js"></script>
@@ -157,6 +159,7 @@ <h3>EQ设置(dB)</h3>
157159
<button>所有时间</button>
158160
</div>
159161
</li>
162+
<li class="pointer">去除谐波<span style="color: gray; font-size: 0.8em;"> 不可逆</span></li>
160163
<li class="pointer">数字谱对齐音频</li>
161164
<li class="pointer">人工智障扒谱</li>
162165
<li class="pointer">音色分离扒谱</li>
@@ -452,10 +455,12 @@ <h3>EQ设置(dB)</h3>
452455
} autoFill();
453456
};
454457
}
458+
// 去谐波
459+
lis[3].onclick = () => app.Analyser.reduceHarmonic();
455460
// 自动音符对齐
456-
lis[3].onclick = () => app.Analyser.autoNoteAlign();
461+
lis[4].onclick = () => app.Analyser.autoNoteAlign();
457462
// 人工智障扒谱
458-
lis[4].onclick = function () {
463+
lis[5].onclick = function () {
459464
if (!app.Analyser.basicamt(null, true)) return; // 仅仅判断是否可以进行AI扒谱
460465
const btn = this;
461466
// 由于效果并不好,因此不会自动执行;而程序为了省内存不会保留音频数据,因此需要重新上传音频
@@ -476,7 +481,7 @@ <h3>EQ设置(dB)</h3>
476481
}; input.click();
477482
};
478483
// 音色分离扒谱
479-
lis[5].onclick = function () {
484+
lis[6].onclick = function () {
480485
if (!app.Analyser.basicamt(null, true)) return; // 仅仅判断是否可以进行AI扒谱
481486
const btn = this;
482487
const input = document.createElement('input');

0 commit comments

Comments
 (0)