/** * The taps of the spectrum (#374): a voice's tap feeds the analyser of its side only while its node * is listened to, and once that stops – or the voice does – nothing feeds one any more. */ import { describe, expect, it } from 'vitest'; import type { FxTap } from '../../src/core/soundfx/nodes'; import { SpectrumTaps } from '../../src/viewport/spectrum'; /** A node that remembers what it is linked to. */ class FakeNode { readonly links = new Set(); fftSize = 0; frequencyBinCount = 4; constructor(readonly context: FakeContext) {} connect(to: FakeNode): FakeNode { this.links.add(to); return to; } disconnect(to?: FakeNode): void { if (!to) this.links.clear(); else if (!this.links.delete(to)) throw new Error('not linked'); } getFloatFrequencyData(data: Float32Array): void { data.fill(this.context.made.length); } /** What the analyser heard last: `wave`, at the end of `data` like the newest samples. */ wave: number[] = []; getFloatTimeDomainData(data: Float32Array): void { data.fill(0); data.set(this.wave, data.length - this.wave.length); } } class FakeContext { readonly sampleRate = 48000; readonly made: FakeNode[] = []; private make(): FakeNode { const node = new FakeNode(this); this.made.push(node); return node; } createGain(): FakeNode { return this.make(); } createAnalyser(): FakeNode { return this.make(); } } const tap = (id: string, at: FxTap['at']): FxTap => ({ kind: 'tap', id, at }); describe('the taps of the spectrum', () => { it('feed nothing and make nothing before their node is listened to', () => { const context = new FakeContext(); const taps = new SpectrumTaps(); taps.hold(tap('fx:eq', 'in'), new FakeNode(context) as never); expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 }); expect(context.made).toHaveLength(0); expect(taps.read('fx:eq')).toBeNull(); }); it('feed the analyser of their side while listened to, a voice started before as well', () => { const context = new FakeContext(); const taps = new SpectrumTaps(); const before = new FakeNode(context); taps.hold(tap('fx:eq', 'in'), before as never); taps.listen('fx:eq'); const after = new FakeNode(context); taps.hold(tap('fx:eq', 'out'), after as never); const other = new FakeNode(context); taps.hold(tap('fx:other', 'out'), other as never); expect(taps.info()).toEqual({ listened: 'fx:eq', held: 3, feeding: 2 }); // Two sums, each into its analyser: the in side made first. const [sumIn, , sumOut] = context.made; expect([...before.links]).toEqual([sumIn]); expect([...after.links]).toEqual([sumOut]); expect(other.links.size).toBe(0); expect(taps.read('fx:eq')?.binHz).toBeCloseTo(48000 / 8192, 9); }); it('let go of the analysers once nobody listens, or once the voice stops', () => { const context = new FakeContext(); const taps = new SpectrumTaps(); taps.listen('fx:eq'); const voice = new FakeNode(context); const stopVoice = taps.hold(tap('fx:eq', 'in'), voice as never); const bus = new FakeNode(context); taps.hold(tap('fx:eq', 'out'), bus as never); stopVoice(); stopVoice(); expect(voice.links.size).toBe(0); expect(taps.info()).toEqual({ listened: 'fx:eq', held: 1, feeding: 1 }); taps.stop(); expect(bus.links.size).toBe(0); expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 }); expect(taps.read('fx:eq')).toBeNull(); }); it('listen to two nodes at once, and let one go without the other (#378)', () => { const context = new FakeContext(); const taps = new SpectrumTaps(); const eq = new FakeNode(context); const compressor = new FakeNode(context); taps.hold(tap('fx:eq', 'in'), eq as never); taps.hold(tap('fx:comp', 'in'), compressor as never); taps.listen('fx:eq'); taps.listen('fx:comp'); expect(taps.info()).toEqual({ listened: 'fx:eq fx:comp', held: 2, feeding: 2 }); expect(eq.links.size).toBe(1); expect(compressor.links.size).toBe(1); taps.stop('fx:eq'); expect(eq.links.size).toBe(0); expect(compressor.links.size).toBe(1); expect(taps.info()).toEqual({ listened: 'fx:comp', held: 2, feeding: 1 }); expect(taps.read('fx:comp')).not.toBeNull(); }); it('give the loudest sample of the last stretch on each side, in dB (#378)', () => { const context = new FakeContext(); const taps = new SpectrumTaps(); expect(taps.levels('fx:comp', 0.01)).toBeNull(); taps.listen('fx:comp'); taps.hold(tap('fx:comp', 'in'), new FakeNode(context) as never); const [, analyserIn, , analyserOut] = context.made; // A peak of 1 long ago, then 0.5: a window of 10 ms (480 samples) only sees the 0.5. analyserIn!.wave = [1, ...new Array(1000).fill(0), -0.5, 0.25]; analyserOut!.wave = []; const levels = taps.levels('fx:comp', 0.01); expect(levels?.in).toBeCloseTo(20 * Math.log10(0.5), 9); expect(levels?.out).toBe(-Infinity); expect(taps.levels('fx:comp', 1)?.in).toBeCloseTo(0, 9); }); it('read the side in front as late as the node is, so a peak is met by what it became (#397)', () => { const context = new FakeContext(); const taps = new SpectrumTaps(); taps.listen('fx:lim'); taps.hold(tap('fx:lim', 'in'), new FakeNode(context) as never); const [, analyserIn, , analyserOut] = context.made; // A peak of 1 that came in 5 ms (240 samples) before the newest sample, and the sound at 0.125 since. analyserIn!.wave = [1, ...new Array(240).fill(0.125)]; analyserOut!.wave = [0.5]; const window = 1 / 480; expect(taps.levels('fx:lim', window)?.in).toBeCloseTo(20 * Math.log10(0.125), 9); const late = taps.levels('fx:lim', window, 0.005); expect(late?.in).toBeCloseTo(0, 9); expect(late?.out).toBeCloseTo(20 * Math.log10(0.5), 9); }); });