|
| 1 | +import { describe, expect, it } from "vitest"; |
| 2 | +import { |
| 3 | + analyseCarveBands, |
| 4 | + carveBandsToChain, |
| 5 | + DEFAULT_CARVE, |
| 6 | + normalizeCarveSettings, |
| 7 | +} from "./audioCarve.js"; |
| 8 | + |
| 9 | +const SR = 48000; |
| 10 | + |
| 11 | +/** A tone-plus-harmonics stand-in for a voice, centred on `f0`. */ |
| 12 | +function voiceLike(f0: number, seconds = 0.5): Float32Array { |
| 13 | + const n = Math.floor(SR * seconds); |
| 14 | + const out = new Float32Array(n); |
| 15 | + for (let i = 0; i < n; i++) { |
| 16 | + const t = i / SR; |
| 17 | + out[i] = |
| 18 | + 0.6 * Math.sin(2 * Math.PI * f0 * t) + |
| 19 | + 0.3 * Math.sin(2 * Math.PI * f0 * 2 * t) + |
| 20 | + 0.1 * Math.sin(2 * Math.PI * f0 * 3 * t); |
| 21 | + } |
| 22 | + return out; |
| 23 | +} |
| 24 | + |
| 25 | +describe("normalizeCarveSettings", () => { |
| 26 | + it("fills defaults and clamps nonsense", () => { |
| 27 | + expect(normalizeCarveSettings(undefined)).toEqual(DEFAULT_CARVE); |
| 28 | + const v = normalizeCarveSettings({ maxCutDb: 500, bands: 99, q: 0, intelligibilityBias: -3 }); |
| 29 | + expect(v.maxCutDb).toBe(24); |
| 30 | + expect(v.bands).toBe(6); |
| 31 | + expect(v.q).toBe(0.3); |
| 32 | + expect(v.intelligibilityBias).toBe(0); |
| 33 | + }); |
| 34 | +}); |
| 35 | + |
| 36 | +describe("analyseCarveBands", () => { |
| 37 | + it("returns nothing for silence-length input", () => { |
| 38 | + expect(analyseCarveBands(new Float32Array(0), SR, DEFAULT_CARVE)).toEqual([]); |
| 39 | + }); |
| 40 | + |
| 41 | + it("returns the requested number of bands, ascending, all cuts", () => { |
| 42 | + const bands = analyseCarveBands(voiceLike(250), SR, { ...DEFAULT_CARVE, bands: 3 }); |
| 43 | + expect(bands).toHaveLength(3); |
| 44 | + expect(bands.map((b) => b.freq)).toEqual([...bands.map((b) => b.freq)].sort((a, b) => a - b)); |
| 45 | + for (const b of bands) expect(b.gainDb).toBeLessThan(0); |
| 46 | + }); |
| 47 | + |
| 48 | + it("never cuts deeper than the configured maximum", () => { |
| 49 | + const bands = analyseCarveBands(voiceLike(400), SR, { ...DEFAULT_CARVE, maxCutDb: 5 }); |
| 50 | + for (const b of bands) expect(Math.abs(b.gainDb)).toBeLessThanOrEqual(5); |
| 51 | + }); |
| 52 | + |
| 53 | + it("follows raw voice power when the bias is off", () => { |
| 54 | + // With no bias, a low-pitched voice should select its own fundamental |
| 55 | + // region — this is the behaviour that makes an unbiased carve thin the bed |
| 56 | + // rather than unmask the voice. |
| 57 | + const bands = analyseCarveBands(voiceLike(160), SR, { |
| 58 | + ...DEFAULT_CARVE, |
| 59 | + bands: 1, |
| 60 | + intelligibilityBias: 0, |
| 61 | + }); |
| 62 | + expect(bands[0]!.freq).toBeLessThanOrEqual(400); |
| 63 | + }); |
| 64 | + |
| 65 | + it("moves selection upward when the bias is on", () => { |
| 66 | + // The bias reweights ranking, it does not override the spectrum: a band the |
| 67 | + // voice has no energy in is not worth carving. So the guarantee is that |
| 68 | + // biasing never selects *lower* than the unbiased ranking, and lifts it |
| 69 | + // whenever there is competing energy up top to select. |
| 70 | + const broadband = (() => { |
| 71 | + const n = Math.floor(SR * 0.5); |
| 72 | + const out = new Float32Array(n); |
| 73 | + for (let i = 0; i < n; i++) { |
| 74 | + const t = i / SR; |
| 75 | + out[i] = |
| 76 | + 0.5 * Math.sin(2 * Math.PI * 160 * t) + |
| 77 | + 0.45 * Math.sin(2 * Math.PI * 1000 * t) + |
| 78 | + 0.4 * Math.sin(2 * Math.PI * 2500 * t); |
| 79 | + } |
| 80 | + return out; |
| 81 | + })(); |
| 82 | + const flat = analyseCarveBands(broadband, SR, { |
| 83 | + ...DEFAULT_CARVE, |
| 84 | + bands: 1, |
| 85 | + intelligibilityBias: 0, |
| 86 | + }); |
| 87 | + const biased = analyseCarveBands(broadband, SR, { |
| 88 | + ...DEFAULT_CARVE, |
| 89 | + bands: 1, |
| 90 | + intelligibilityBias: 1, |
| 91 | + }); |
| 92 | + expect(biased[0]!.freq).toBeGreaterThanOrEqual(flat[0]!.freq); |
| 93 | + expect(biased[0]!.freq).toBeGreaterThanOrEqual(1000); |
| 94 | + }); |
| 95 | + |
| 96 | + /** |
| 97 | + * A voice with the spectral tilt real speech has: energy falls off about 6 dB |
| 98 | + * per octave above the fundamental, so the low bands carry 20-30 dB more power |
| 99 | + * than the presence region. `broadband` above spreads its three tones over |
| 100 | + * roughly 2 dB, which is why it cannot tell a working bias from an inert one. |
| 101 | + */ |
| 102 | + function tiltedVoice(f0 = 120, seconds = 0.5): Float32Array { |
| 103 | + const n = Math.floor(SR * seconds); |
| 104 | + const out = new Float32Array(n); |
| 105 | + for (let i = 0; i < n; i++) { |
| 106 | + const t = i / SR; |
| 107 | + let v = 0; |
| 108 | + // Harmonics out past 6 kHz, each 6 dB/octave down, so every candidate band |
| 109 | + // has real energy to rank and the ranking is decided by the weighting |
| 110 | + // rather than by which band happens to be the only one occupied. |
| 111 | + for (let h = 1; h * f0 < SR / 2 && h <= 64; h++) { |
| 112 | + v += (1 / h) * Math.sin(2 * Math.PI * f0 * h * t); |
| 113 | + } |
| 114 | + out[i] = 0.5 * v; |
| 115 | + } |
| 116 | + return out; |
| 117 | + } |
| 118 | + |
| 119 | + it("reaches the presence region at the default bias, not just at bias 1", () => { |
| 120 | + // The point of the feature: dip the bed where the voice is MASKED, 1-3 kHz, |
| 121 | + // not where the voice is loudest. DEFAULT_CARVE is what a user gets from |
| 122 | + // clicking "Analyse and apply", so the default has to do this — a knob that |
| 123 | + // only works at its extreme does not work. |
| 124 | + const bands = analyseCarveBands(tiltedVoice(), SR, { ...DEFAULT_CARVE, bands: 3 }); |
| 125 | + expect(bands.map((b) => b.freq).some((f) => f >= 1000)).toBe(true); |
| 126 | + }); |
| 127 | + |
| 128 | + it("gives the bias enough authority to cross a speech-sized tilt", () => { |
| 129 | + // The authority, stated in the unit that decides it: a multiplicative weight |
| 130 | + // bounded below by (1 - bias) can move a ranking by at most |
| 131 | + // 10*log10(1/(1 - bias)) — 5.2 dB at the 0.7 default. Speech tilts 20-30 dB. |
| 132 | + // |
| 133 | + // Two tones, the low one 9 dB louder, both on candidate centres (the bands |
| 134 | + // are a third of an octave wide but their centres step about two thirds |
| 135 | + // apart, so a tone between two of them — 2 kHz, say — falls in a gap and is |
| 136 | + // invisible to the analysis). Raw power says 250 Hz; the default bias has to |
| 137 | + // be able to say 1.6 kHz anyway. |
| 138 | + // |
| 139 | + // 9 dB rather than the 21 dB the bias nominally carries at 0.7: a lone tone |
| 140 | + // is diluted by `bandPower` averaging across the band's bins, and the 1.6 kHz |
| 141 | + // band spans about six times as many bins as the 250 Hz one, which costs |
| 142 | + // roughly 8 dB. Broadband content — a real voice — fills both and keeps it. |
| 143 | + const twoTone = (() => { |
| 144 | + const n = Math.floor(SR * 0.5); |
| 145 | + const out = new Float32Array(n); |
| 146 | + const quiet = Math.pow(10, -9 / 20); |
| 147 | + for (let i = 0; i < n; i++) { |
| 148 | + const t = i / SR; |
| 149 | + out[i] = |
| 150 | + 0.5 * Math.sin(2 * Math.PI * 250 * t) + 0.5 * quiet * Math.sin(2 * Math.PI * 1600 * t); |
| 151 | + } |
| 152 | + return out; |
| 153 | + })(); |
| 154 | + const one = { ...DEFAULT_CARVE, bands: 1 }; |
| 155 | + expect(analyseCarveBands(twoTone, SR, { ...one, intelligibilityBias: 0 })[0]!.freq).toBe(250); |
| 156 | + expect(analyseCarveBands(twoTone, SR, one)[0]!.freq).toBe(1600); |
| 157 | + }); |
| 158 | + |
| 159 | + it("still follows raw power exactly when the bias is off", () => { |
| 160 | + // The escape hatch keeps working: at bias 0 selection is the voice's own |
| 161 | + // loudest band, whatever the weighting curve would have preferred. |
| 162 | + const bands = analyseCarveBands(tiltedVoice(), SR, { |
| 163 | + ...DEFAULT_CARVE, |
| 164 | + bands: 1, |
| 165 | + intelligibilityBias: 0, |
| 166 | + }); |
| 167 | + expect(bands[0]!.freq).toBeLessThanOrEqual(400); |
| 168 | + }); |
| 169 | +}); |
| 170 | + |
| 171 | +describe("carveBandsToChain", () => { |
| 172 | + it("turns bands into peaking nodes carrying the analysed values", () => { |
| 173 | + const chain = carveBandsToChain([{ freq: 1000, gainDb: -6, q: 1.4 }]); |
| 174 | + expect(chain.nodes).toHaveLength(1); |
| 175 | + expect(chain.nodes[0]!.type).toBe("peaking"); |
| 176 | + expect(chain.nodes[0]!.params).toMatchObject({ frequency: 1000, gain: -6, q: 1.4 }); |
| 177 | + }); |
| 178 | + |
| 179 | + it("produces an empty chain for no bands", () => { |
| 180 | + expect(carveBandsToChain([]).nodes).toEqual([]); |
| 181 | + }); |
| 182 | +}); |
0 commit comments