@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.
Files changed (3) hide show
  1. package/README.md +30 -0
  2. package/index.js +258 -0
  3. 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
+ }