@audio/quality 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/index.js +258 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @audio/quality
|
|
2
|
+
|
|
3
|
+
> Objective audio quality metrics — how close is the processed signal to the reference?
|
|
4
|
+
|
|
5
|
+
Reference-vs-output comparison for evaluating denoisers, stretchers, codecs, resamplers. Deterministic, no model weights.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { snr, segSnr, lsd, spectralSim, modulationDepth } from '@audio/quality'
|
|
9
|
+
|
|
10
|
+
snr(clean, processed) // global SNR, dB
|
|
11
|
+
segSnr(clean, processed) // frame-averaged, PESQ-style clamp
|
|
12
|
+
lsd(a, b, { trim: 0.1 }) // log-spectral distance, dB — ~1 transparent, >5 degraded
|
|
13
|
+
spectralSim(a, b) // cosine similarity of magnitude spectra, 1 = identical
|
|
14
|
+
modulationDepth(out, [220, 440], 44100) // per-partial hop-rate beating ("crumble")
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| fn | measures | notes |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `snr(clean, out)` | global SNR (dB) | time-aligned inputs |
|
|
20
|
+
| `segSnr(clean, out, opts?)` | segmental SNR (dB) | per-frame, [-10, 35] clamp, silence-skipped |
|
|
21
|
+
| `lsd(a, b, opts?)` | log-spectral distance (dB) | `trim` crops edge artifacts (default 0) |
|
|
22
|
+
| `spectralSim(a, b, opts?)` | spectral cosine similarity | alignment-free |
|
|
23
|
+
| `nrr(noisy, out, from?, to?)` | noise-floor drop (dB) | pass a quiet-segment range |
|
|
24
|
+
| `speechAttenuation(clean, out, from?, to?)` | speech-energy loss (dB) | should be ≈0 |
|
|
25
|
+
| `goertzelEnergy(data, f, sr)` | single-frequency energy | Goertzel, 20% edge trim |
|
|
26
|
+
| `chordBalance(data, freqs, sr)` | min/max partial ratio | 1 = balanced |
|
|
27
|
+
| `chordRetention(data, ref, freqs, sr)` | chord energy kept | 1 = no loss |
|
|
28
|
+
| `modulationDepth(data, freqs, sr, opts?)` | AM depth per partial | catches tremolo LSD misses |
|
|
29
|
+
|
|
30
|
+
Consolidated from the [denoise](https://github.com/audiojs/denoise) and [stretch](https://github.com/audiojs/stretch) families' evaluation suites. FFT via [fourier-transform](https://github.com/scijs/fourier-transform). MIT.
|
package/index.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Objective audio quality metrics — reference-vs-output comparison.
|
|
2
|
+
// - snr / segSnr: global and segmental signal-to-noise ratio (dB)
|
|
3
|
+
// - lsd: frame-averaged log-spectral distance (dB) — spectral colouration
|
|
4
|
+
// - spectralSim: frame-averaged cosine similarity of magnitude spectra
|
|
5
|
+
// - nrr / speechAttenuation: noise-suppression vs speech-retention (dB)
|
|
6
|
+
// - goertzelEnergy / chordBalance / chordRetention / modulationDepth: per-partial
|
|
7
|
+
// energy tracking — catches hop-rate beating ("crumble") that LSD misses
|
|
8
|
+
//
|
|
9
|
+
// All metrics expect Float32Array inputs; equal length unless noted. Consolidated
|
|
10
|
+
// from the denoise and stretch families' evaluation suites (one lsd, one source).
|
|
11
|
+
|
|
12
|
+
import { fft } from 'fourier-transform'
|
|
13
|
+
|
|
14
|
+
const PI2 = Math.PI * 2
|
|
15
|
+
|
|
16
|
+
let _hannCache = new Map()
|
|
17
|
+
function hannWindow(N) {
|
|
18
|
+
let w = _hannCache.get(N)
|
|
19
|
+
if (w) return w
|
|
20
|
+
w = new Float64Array(N)
|
|
21
|
+
for (let i = 0; i < N; i++) w[i] = 0.5 * (1 - Math.cos(PI2 * i / N))
|
|
22
|
+
_hannCache.set(N, w)
|
|
23
|
+
return w
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const lin2db = x => 20 * Math.log10(Math.max(x, 1e-30))
|
|
27
|
+
|
|
28
|
+
// fft() returns zero-copy views into a per-size cache — copy magnitudes immediately.
|
|
29
|
+
let _scratch = new Map()
|
|
30
|
+
function magFrame(data, pos, win, N, half, magOut) {
|
|
31
|
+
let f = _scratch.get(N)
|
|
32
|
+
if (!f) { f = new Float64Array(N); _scratch.set(N, f) }
|
|
33
|
+
let e = 0
|
|
34
|
+
for (let i = 0; i < N; i++) {
|
|
35
|
+
let v = (data[pos + i] || 0) * win[i]
|
|
36
|
+
f[i] = v
|
|
37
|
+
e += v * v
|
|
38
|
+
}
|
|
39
|
+
let [re, im] = fft(f)
|
|
40
|
+
for (let k = 0; k <= half; k++) magOut[k] = Math.sqrt(re[k] * re[k] + im[k] * im[k])
|
|
41
|
+
return e
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Trim edges (algorithm startup/tail artifacts) and crop to common length.
|
|
45
|
+
function align(a, b, trim) {
|
|
46
|
+
let n = Math.min(a.length, b.length)
|
|
47
|
+
let cut = Math.floor(n * trim)
|
|
48
|
+
let len = n - 2 * cut
|
|
49
|
+
return [a.subarray(cut, cut + len), b.subarray(cut, cut + len)]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Global SNR (dB). Clean = reference, out = processed. SNR = 10·log10(∑clean² / ∑(clean-out)²)
|
|
53
|
+
export function snr(clean, out) {
|
|
54
|
+
let n = Math.min(clean.length, out.length)
|
|
55
|
+
let s = 0, e = 0
|
|
56
|
+
for (let i = 0; i < n; i++) {
|
|
57
|
+
let c = clean[i], d = c - out[i]
|
|
58
|
+
s += c * c
|
|
59
|
+
e += d * d
|
|
60
|
+
}
|
|
61
|
+
return e > 0 ? 10 * Math.log10(s / e) : Infinity
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Segmental SNR — average per-frame SNR clamped to [-10, 35] dB (PESQ-style).
|
|
65
|
+
// Drops silent frames (energy < floor) so silence doesn't bias the mean.
|
|
66
|
+
export function segSnr(clean, out, opts = {}) {
|
|
67
|
+
let N = opts.frameSize || 512
|
|
68
|
+
let hop = opts.hopSize || (N >> 1)
|
|
69
|
+
let floor = opts.floor ?? 1e-5
|
|
70
|
+
let n = Math.min(clean.length, out.length)
|
|
71
|
+
let sum = 0, frames = 0
|
|
72
|
+
for (let pos = 0; pos + N <= n; pos += hop) {
|
|
73
|
+
let s = 0, e = 0
|
|
74
|
+
for (let i = 0; i < N; i++) {
|
|
75
|
+
let c = clean[pos + i], d = c - out[pos + i]
|
|
76
|
+
s += c * c
|
|
77
|
+
e += d * d
|
|
78
|
+
}
|
|
79
|
+
if (s < floor * N) continue
|
|
80
|
+
let snr = 10 * Math.log10(s / Math.max(e, 1e-30))
|
|
81
|
+
sum += Math.max(-10, Math.min(35, snr))
|
|
82
|
+
frames++
|
|
83
|
+
}
|
|
84
|
+
return frames ? sum / frames : 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Frame-averaged log-spectral distance in dB. Lower is better.
|
|
88
|
+
// ~1 dB: transparent. 2–4 dB: audible colouration. >5 dB: degraded.
|
|
89
|
+
// `trim` crops that fraction off each edge before comparing (startup/tail artifacts);
|
|
90
|
+
// default 0 = compare everything (time-aligned use); stretch/shift evaluation
|
|
91
|
+
// typically passes ~0.1.
|
|
92
|
+
export function lsd(a, b, opts = {}) {
|
|
93
|
+
let N = opts.frameSize || 1024
|
|
94
|
+
let hop = opts.hopSize || (N >> 1)
|
|
95
|
+
let trim = opts.trim ?? 0
|
|
96
|
+
let floor = opts.floor ?? 1e-5
|
|
97
|
+
|
|
98
|
+
let [x, y] = trim > 0 ? align(a, b, trim) : [a, b]
|
|
99
|
+
let win = hannWindow(N)
|
|
100
|
+
let half = N >> 1
|
|
101
|
+
let mx = new Float64Array(half + 1), my = new Float64Array(half + 1)
|
|
102
|
+
let n = Math.min(x.length, y.length)
|
|
103
|
+
let sum = 0, frames = 0
|
|
104
|
+
|
|
105
|
+
for (let pos = 0; pos + N <= n; pos += hop) {
|
|
106
|
+
let ex = magFrame(x, pos, win, N, half, mx)
|
|
107
|
+
let ey = magFrame(y, pos, win, N, half, my)
|
|
108
|
+
if (ex < floor && ey < floor) continue
|
|
109
|
+
|
|
110
|
+
// Per-frame peak-relative magnitude floor: prevents log amplification of
|
|
111
|
+
// near-silent bins (critical for pure tones where one bin holds all energy).
|
|
112
|
+
let peak = 0
|
|
113
|
+
for (let k = 0; k <= half; k++) {
|
|
114
|
+
if (mx[k] > peak) peak = mx[k]
|
|
115
|
+
if (my[k] > peak) peak = my[k]
|
|
116
|
+
}
|
|
117
|
+
let mfloor = peak * 1e-3 + 1e-12
|
|
118
|
+
|
|
119
|
+
let acc = 0
|
|
120
|
+
for (let k = 0; k <= half; k++) {
|
|
121
|
+
let d = 20 * Math.log10((mx[k] + mfloor) / (my[k] + mfloor))
|
|
122
|
+
acc += d * d
|
|
123
|
+
}
|
|
124
|
+
sum += Math.sqrt(acc / (half + 1))
|
|
125
|
+
frames++
|
|
126
|
+
}
|
|
127
|
+
return frames ? sum / frames : 0
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Frame-averaged cosine similarity of magnitude spectra. 1 = identical.
|
|
131
|
+
export function spectralSim(a, b, opts = {}) {
|
|
132
|
+
let N = opts.frameSize || 1024
|
|
133
|
+
let hop = opts.hopSize || (N >> 1)
|
|
134
|
+
let trim = opts.trim ?? 0.1
|
|
135
|
+
let floor = opts.floor ?? 1e-5
|
|
136
|
+
|
|
137
|
+
let [x, y] = align(a, b, trim)
|
|
138
|
+
let win = hannWindow(N)
|
|
139
|
+
let half = N >> 1
|
|
140
|
+
let mx = new Float64Array(half + 1), my = new Float64Array(half + 1)
|
|
141
|
+
let sum = 0, frames = 0
|
|
142
|
+
|
|
143
|
+
for (let pos = 0; pos + N <= x.length; pos += hop) {
|
|
144
|
+
let ex = magFrame(x, pos, win, N, half, mx)
|
|
145
|
+
let ey = magFrame(y, pos, win, N, half, my)
|
|
146
|
+
if (ex < floor || ey < floor) continue
|
|
147
|
+
|
|
148
|
+
let dot = 0, nx = 0, ny = 0
|
|
149
|
+
for (let k = 0; k <= half; k++) {
|
|
150
|
+
dot += mx[k] * my[k]
|
|
151
|
+
nx += mx[k] * mx[k]
|
|
152
|
+
ny += my[k] * my[k]
|
|
153
|
+
}
|
|
154
|
+
let denom = Math.sqrt(nx * ny)
|
|
155
|
+
if (denom > 0) { sum += dot / denom; frames++ }
|
|
156
|
+
}
|
|
157
|
+
return frames ? sum / frames : 0
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Noise reduction ratio (dB): drop in noise-floor RMS after processing.
|
|
161
|
+
// Caller selects a quiet segment by index. Positive = suppression worked.
|
|
162
|
+
export function nrr(noisy, out, from = 0, to) {
|
|
163
|
+
to = to ?? Math.min(noisy.length, out.length)
|
|
164
|
+
let nIn = 0, nOut = 0, len = to - from
|
|
165
|
+
for (let i = from; i < to; i++) { nIn += noisy[i] * noisy[i]; nOut += out[i] * out[i] }
|
|
166
|
+
return lin2db(Math.sqrt(nIn / len)) - lin2db(Math.sqrt(nOut / len))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Speech attenuation (dB): drop in clean-signal-active RMS — should be near 0.
|
|
170
|
+
// Pass a clean-segment index range so we measure speech retention.
|
|
171
|
+
export function speechAttenuation(clean, out, from = 0, to) {
|
|
172
|
+
to = to ?? Math.min(clean.length, out.length)
|
|
173
|
+
let cIn = 0, cOut = 0, len = to - from
|
|
174
|
+
for (let i = from; i < to; i++) {
|
|
175
|
+
cIn += clean[i] * clean[i]
|
|
176
|
+
cOut += out[i] * out[i]
|
|
177
|
+
}
|
|
178
|
+
return lin2db(Math.sqrt(cIn / len)) - lin2db(Math.sqrt(cOut / len))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Goertzel energy at a single frequency. Trims 20% edges to avoid artifacts.
|
|
182
|
+
export function goertzelEnergy(data, freq, sr) {
|
|
183
|
+
let w = 2 * Math.PI * freq / sr, c = 2 * Math.cos(w), s1 = 0, s2 = 0
|
|
184
|
+
let start = Math.floor(data.length * 0.2), end = Math.floor(data.length * 0.8)
|
|
185
|
+
for (let i = start; i < end; i++) { let s = data[i] + c * s1 - s2; s2 = s1; s1 = s }
|
|
186
|
+
return Math.sqrt(s1 * s1 + s2 * s2 - c * s1 * s2) / (end - start)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Ratio of min/max partial energies. 1.0 = perfectly balanced, 0 = one partial destroyed.
|
|
190
|
+
export function chordBalance(data, freqs, sr) {
|
|
191
|
+
let energies = freqs.map(f => goertzelEnergy(data, f, sr))
|
|
192
|
+
let mn = Math.min(...energies), mx = Math.max(...energies)
|
|
193
|
+
return mx > 1e-10 ? mn / mx : 0
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Total chord energy preserved relative to reference. 1.0 = no loss.
|
|
197
|
+
export function chordRetention(data, ref, freqs, sr) {
|
|
198
|
+
let refE = freqs.reduce((s, f) => s + goertzelEnergy(ref, f, sr), 0)
|
|
199
|
+
let outE = freqs.reduce((s, f) => s + goertzelEnergy(data, f, sr), 0)
|
|
200
|
+
return refE > 1e-10 ? outE / refE : 0
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Per-partial amplitude-modulation depth — captures hop-rate beating ("crumble")
|
|
204
|
+
// that LSD misses. 0 = stable, ~0.05 = transparent, ~0.3+ = obvious tremolo.
|
|
205
|
+
export function modulationDepth(data, freqs, sr, opts = {}) {
|
|
206
|
+
let win = opts.envWindow || 2048
|
|
207
|
+
let hop = opts.envHop || 256
|
|
208
|
+
let trim = opts.trim ?? 0.1
|
|
209
|
+
let cut = Math.floor(data.length * trim)
|
|
210
|
+
let len = data.length - 2 * cut
|
|
211
|
+
if (len < win * 2) return 0
|
|
212
|
+
let frames = Math.floor((len - win) / hop) + 1
|
|
213
|
+
if (frames < 8) return 0
|
|
214
|
+
|
|
215
|
+
let hann = new Float64Array(win)
|
|
216
|
+
let hannSum = 0
|
|
217
|
+
for (let i = 0; i < win; i++) { hann[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (win - 1))); hannSum += hann[i] }
|
|
218
|
+
|
|
219
|
+
// Two-pass: first measure each partial's mean amplitude, then only score those
|
|
220
|
+
// above 5% of the loudest. Otherwise weak/absent partials score huge spurious
|
|
221
|
+
// modulation from spectral-leakage noise.
|
|
222
|
+
let envs = freqs.map(() => new Float64Array(frames))
|
|
223
|
+
let means = new Float64Array(freqs.length)
|
|
224
|
+
for (let fi = 0; fi < freqs.length; fi++) {
|
|
225
|
+
let f = freqs[fi]
|
|
226
|
+
let w = 2 * Math.PI * f / sr
|
|
227
|
+
let env = envs[fi]
|
|
228
|
+
let mean = 0
|
|
229
|
+
for (let k = 0; k < frames; k++) {
|
|
230
|
+
let re = 0, im = 0, base = cut + k * hop
|
|
231
|
+
for (let i = 0; i < win; i++) {
|
|
232
|
+
let s = data[base + i] * hann[i]
|
|
233
|
+
let phase = w * i
|
|
234
|
+
re += s * Math.cos(phase)
|
|
235
|
+
im -= s * Math.sin(phase)
|
|
236
|
+
}
|
|
237
|
+
let mag = Math.sqrt(re * re + im * im) / hannSum
|
|
238
|
+
env[k] = mag
|
|
239
|
+
mean += mag
|
|
240
|
+
}
|
|
241
|
+
means[fi] = mean / frames
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let maxMean = 0
|
|
245
|
+
for (let i = 0; i < means.length; i++) if (means[i] > maxMean) maxMean = means[i]
|
|
246
|
+
let floor = maxMean * 0.05
|
|
247
|
+
|
|
248
|
+
let worst = 0
|
|
249
|
+
for (let i = 0; i < freqs.length; i++) {
|
|
250
|
+
if (means[i] < floor) continue
|
|
251
|
+
let env = envs[i], mean = means[i]
|
|
252
|
+
let acVar = 0
|
|
253
|
+
for (let k = 0; k < frames; k++) { let d = env[k] - mean; acVar += d * d }
|
|
254
|
+
let depth = Math.sqrt(acVar / frames) / mean
|
|
255
|
+
if (depth > worst) worst = depth
|
|
256
|
+
}
|
|
257
|
+
return worst
|
|
258
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/quality",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Objective audio quality metrics — SNR, segmental SNR, log-spectral distance, spectral similarity, noise-reduction ratio, per-partial modulation depth",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"fourier-transform": "^2.2.0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"audio",
|
|
20
|
+
"dsp",
|
|
21
|
+
"quality",
|
|
22
|
+
"metrics",
|
|
23
|
+
"snr",
|
|
24
|
+
"log-spectral-distance",
|
|
25
|
+
"lsd",
|
|
26
|
+
"evaluation"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"author": "Dmitry Iv. <dfcreative@gmail.com>",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/audiojs/measure.git",
|
|
33
|
+
"directory": "packages/quality"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/audiojs/measure/tree/main/packages/quality",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|