Skip to content

Commit 84610e3

Browse files
committed
fix: apply max plugin delay compensation
1 parent 7c0b4a6 commit 84610e3

9 files changed

Lines changed: 234 additions & 90 deletions

File tree

src/engine/PluginEngine.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,19 +230,54 @@ export class PluginEngine {
230230

231231
/**
232232
* Get total latency of plugin chain for a track (in samples).
233-
* Sums latencySamples from all plugins in the chain.
233+
* Sums latencySamples from all active plugins in the chain.
234234
* WAP plugins without latencySamples are assumed to have 0 latency.
235235
*/
236236
getChainLatency(trackId: string): number {
237237
const chain = this.chains.get(trackId);
238238
if (!chain) return 0;
239239
let total = 0;
240240
for (const node of chain) {
241-
total += node.plugin.latencySamples ?? 0;
241+
if (node.bypassed) continue;
242+
const latencySamples = node.plugin.latencySamples ?? 0;
243+
total += Number.isFinite(latencySamples) ? Math.max(0, Math.floor(latencySamples)) : 0;
242244
}
243245
return total;
244246
}
245247

248+
/**
249+
* Update a live plugin's reported latency.
250+
* VST3 plugins can report latency after instantiation or after parameter changes.
251+
*/
252+
setPluginLatency(trackId: string, instanceId: string, latencySamples: number): void {
253+
const chain = this.chains.get(trackId);
254+
if (!chain) return;
255+
const node = chain.find((candidate) => candidate.instanceId === instanceId);
256+
if (!node) return;
257+
258+
const sanitizedLatency = Number.isFinite(latencySamples)
259+
? Math.max(0, Math.floor(latencySamples))
260+
: 0;
261+
const plugin = node.plugin as WAPPlugin & {
262+
setLatencySamples?: (samples: number) => void;
263+
latencySamples?: number;
264+
};
265+
if (typeof plugin.setLatencySamples === 'function') {
266+
plugin.setLatencySamples(sanitizedLatency);
267+
return;
268+
}
269+
270+
try {
271+
Object.defineProperty(plugin, 'latencySamples', {
272+
value: sanitizedLatency,
273+
configurable: true,
274+
enumerable: true,
275+
});
276+
} catch {
277+
// Some third-party plugin wrappers may expose latency as a non-configurable readonly field.
278+
}
279+
}
280+
246281
/**
247282
* Dispose the plugin chain for a track.
248283
*/

src/hooks/__tests__/useEffectsSync.vst3.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { pluginEngine } from '../../engine/PluginEngine';
99
import { effectsEngine } from '../../engine/EffectsEngine';
1010
import {
1111
buildCombinedEffectsChain,
12+
calculatePluginDelayCompensation,
1213
} from '../useEffectsSync';
1314

1415
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -85,3 +86,35 @@ describe('buildCombinedEffectsChain', () => {
8586
expect(pluginOutput.connect).toHaveBeenCalledWith(effectsInput);
8687
});
8788
});
89+
90+
describe('calculatePluginDelayCompensation', () => {
91+
beforeEach(() => {
92+
vi.restoreAllMocks();
93+
});
94+
95+
it('delays lower-latency tracks to match the maximum active plugin chain latency', () => {
96+
vi.spyOn(pluginEngine, 'getChainLatency').mockImplementation((trackId) => {
97+
if (trackId === 'track-late') return 512;
98+
if (trackId === 'track-mid') return 128;
99+
return 0;
100+
});
101+
102+
const result = calculatePluginDelayCompensation(
103+
['track-late', 'track-mid', 'track-dry'],
104+
48000,
105+
);
106+
107+
expect(result.get('track-late')).toBe(0);
108+
expect(result.get('track-mid')).toBe(384);
109+
expect(result.get('track-dry')).toBe(512);
110+
});
111+
112+
it('clears compensation when active plugin latencies are all zero', () => {
113+
vi.spyOn(pluginEngine, 'getChainLatency').mockReturnValue(0);
114+
115+
const result = calculatePluginDelayCompensation(['track-a', 'track-b'], 48000);
116+
117+
expect(result.get('track-a')).toBe(0);
118+
expect(result.get('track-b')).toBe(0);
119+
});
120+
});

src/hooks/useEffectsSync.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useWAMStore } from '../store/wamStore';
1313
import { effectsEngine, initWasmDsp } from '../engine/EffectsEngine';
1414
import { pluginEngine } from '../engine/PluginEngine';
1515
import { getAudioEngine } from './useAudioEngine';
16+
import { VST3LatencyCompensation } from '../services/vst3bridge/VST3LatencyCompensation';
1617
import { createDebugLogger } from '../utils/debugLogger';
1718
import type { CompressorParams } from '../types/project';
1819
import type { WAMActiveInstance } from '../types/wam';
@@ -51,12 +52,12 @@ export function buildCombinedEffectsChain(trackId: string): { input: AudioNode |
5152

5253
/**
5354
* Derive a stable fingerprint of VST3 instances for effect chain syncing.
54-
* Only includes fields that affect audio routing (instanceId, trackId, enabled).
55+
* Only includes fields that affect audio routing or timing.
5556
*/
5657
function selectVst3EffectChainKey(s: { instances: Record<string, VST3ActiveInstance> }): string {
5758
const parts: string[] = [];
5859
for (const [id, inst] of Object.entries(s.instances)) {
59-
parts.push(`${id}:${inst.trackId ?? ''}:${inst.enabled ? '1' : '0'}`);
60+
parts.push(`${id}:${inst.trackId ?? ''}:${inst.enabled ? '1' : '0'}:${inst.latencySamples ?? 0}`);
6061
}
6162
return parts.sort().join('|');
6263
}
@@ -69,6 +70,16 @@ function selectWamEffectChainKey(s: { instances: Record<string, WAMActiveInstanc
6970
return parts.sort().join('|');
7071
}
7172

73+
export function calculatePluginDelayCompensation(trackIds: string[], sampleRate: number): Map<string, number> {
74+
return VST3LatencyCompensation.calculateCompensation(
75+
trackIds.map((id) => ({
76+
id,
77+
pluginLatencies: [pluginEngine.getChainLatency(id)],
78+
})),
79+
sampleRate,
80+
);
81+
}
82+
7283
export function useEffectsSync() {
7384
const tracks = useProjectStore((s) => s.project?.tracks);
7485
const dspBackend = useUIStore((s) => s.dspBackend);
@@ -116,6 +127,8 @@ export function useEffectsSync() {
116127
instancesByTrack.set(inst.trackId, list);
117128
}
118129

130+
const trackNodes = new Map<string, ReturnType<typeof engine.getOrCreateTrackNode>>();
131+
119132
// First pass: rebuild built-in effect chains + sync VST3 bypass, then splice combined chain
120133
for (const track of tracks) {
121134
const effects = track.effects ?? [];
@@ -133,14 +146,16 @@ export function useEffectsSync() {
133146
if (trackNode) {
134147
const { input, output } = buildCombinedEffectsChain(track.id);
135148
trackNode.spliceEffects(input, output);
136-
137-
// Always apply latency compensation (including clearing to 0 when plugins removed/bypassed)
138-
const pluginLatency = pluginEngine.getChainLatency(track.id);
139-
const sampleRate = engine.ctx?.sampleRate ?? 44100;
140-
trackNode.setLatencyCompensation(pluginLatency, sampleRate);
149+
trackNodes.set(track.id, trackNode);
141150
}
142151
}
143152

153+
const sampleRate = engine.ctx?.sampleRate ?? 44100;
154+
const compensationByTrack = calculatePluginDelayCompensation([...trackNodes.keys()], sampleRate);
155+
for (const [trackId, trackNode] of trackNodes) {
156+
trackNode.setLatencyCompensation(compensationByTrack.get(trackId) ?? 0, sampleRate);
157+
}
158+
144159
// Second pass: wire sidechain connections (all chains must exist first)
145160
for (const track of tracks) {
146161
for (const effect of track.effects ?? []) {

src/hooks/useVST3Connection.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,18 @@ export function useVST3Connection() {
9292
});
9393
};
9494

95+
const onLatencyInfo = (msg: Record<string, unknown>) => {
96+
const instanceId = msg.instanceId as string | undefined;
97+
if (!instanceId) return;
98+
store._updateLatency(instanceId, Number(msg.samples ?? 0));
99+
};
100+
95101
const unsubs = [
96102
client.on('statusChange', onStatusChange),
97103
client.on('error', onError),
98104
client.on('scanComplete', onScanComplete),
99105
client.on('scanProgress', onScanProgress),
106+
client.on('latencyInfo', onLatencyInfo),
100107
];
101108

102109
// Sync current state immediately (handles HMR / singleton already connected)

src/services/vst3bridge/VST3PluginAdapter.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ const AUDIO_PUMP_INTERVAL_MS = 5;
3737
/** Audio block size sent per pump iteration (in sample frames). */
3838
const BLOCK_SIZE = 128;
3939

40+
function sanitizeLatencySamples(samples: number): number {
41+
return Number.isFinite(samples) ? Math.max(0, Math.floor(samples)) : 0;
42+
}
43+
4044
// ─── Adapter ────────────────────────────────────────────────────────────────
4145

4246
export class VST3PluginAdapter implements WAPPlugin {
@@ -45,8 +49,8 @@ export class VST3PluginAdapter implements WAPPlugin {
4549
readonly version: string;
4650
readonly author: string;
4751
readonly description: string;
48-
readonly latencySamples: number;
4952

53+
private _latencySamples: number;
5054
private instanceId: string;
5155
private bridgeClient: VST3BridgeClient;
5256
private paramDescriptors: PluginParamDescriptor[] = [];
@@ -74,7 +78,7 @@ export class VST3PluginAdapter implements WAPPlugin {
7478
this.author = pluginInfo.vendor;
7579
this.description = `VST3: ${pluginInfo.name} by ${pluginInfo.vendor}`;
7680
this.bridgeClient = bridgeClient;
77-
this.latencySamples = instantiateResponse.latencySamples;
81+
this._latencySamples = sanitizeLatencySamples(instantiateResponse.latencySamples);
7882
this.instanceIdHash = fnv1aHash(instanceId);
7983

8084
// Map VST3 params to WAP param descriptors
@@ -234,9 +238,19 @@ export class VST3PluginAdapter implements WAPPlugin {
234238
return this.instanceId;
235239
}
236240

241+
/** Latency in samples introduced by this plugin. */
242+
get latencySamples(): number {
243+
return this._latencySamples;
244+
}
245+
246+
/** Update latency reported by the companion. */
247+
setLatencySamples(samples: number): void {
248+
this._latencySamples = sanitizeLatencySamples(samples);
249+
}
250+
237251
/** Latency in samples introduced by this plugin. */
238252
get pluginLatency(): number {
239-
return this.latencySamples;
253+
return this._latencySamples;
240254
}
241255

242256
/** Ask the companion to open the native VST3 editor window. */

src/services/vst3bridge/__tests__/PluginEngineLatency.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,33 @@ describe('PluginEngine.getChainLatency', () => {
5959
engine.addPlugin('track-1', 'inst-3', p3, ctx);
6060
expect(engine.getChainLatency('track-1')).toBe(384);
6161
});
62+
63+
it('excludes bypassed plugins from chain latency', () => {
64+
const p1 = createMockPlugin(128);
65+
const p2 = createMockPlugin(256);
66+
engine.addPlugin('track-1', 'inst-1', p1, ctx);
67+
engine.addPlugin('track-1', 'inst-2', p2, ctx);
68+
69+
engine.setPluginBypassed('track-1', 'inst-2', true);
70+
71+
expect(engine.getChainLatency('track-1')).toBe(128);
72+
});
73+
74+
it('updates a live plugin latency report', () => {
75+
const plugin = createMockPlugin(128);
76+
engine.addPlugin('track-1', 'inst-1', plugin, ctx);
77+
78+
engine.setPluginLatency('track-1', 'inst-1', 512);
79+
80+
expect(engine.getChainLatency('track-1')).toBe(512);
81+
});
82+
83+
it('normalizes invalid and fractional latency samples', () => {
84+
const p1 = createMockPlugin(128.8);
85+
const p2 = createMockPlugin(Number.NaN);
86+
engine.addPlugin('track-1', 'inst-1', p1, ctx);
87+
engine.addPlugin('track-1', 'inst-2', p2, ctx);
88+
89+
expect(engine.getChainLatency('track-1')).toBe(128);
90+
});
6291
});

src/store/__tests__/vst3Store.test.ts

Lines changed: 27 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const mockInstance = (overrides: Partial<VST3ActiveInstance> = {}): VST3ActiveIn
2525
parameters: [],
2626
presets: [],
2727
activePreset: null,
28+
latencySamples: 0,
2829
...overrides,
2930
});
3031

@@ -249,29 +250,21 @@ describe('vst3Store', () => {
249250
const { _getBridgeClient } = await import('../../hooks/useVST3Connection');
250251
const client = _getBridgeClient();
251252

252-
// Intercept on/off to capture event listeners registered by loadPlugin
253-
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
254-
client.on = vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
255-
if (!listeners[event]) listeners[event] = [];
256-
listeners[event].push(handler);
257-
});
258-
client.off = vi.fn();
259-
260-
// Mock createInstance to simulate the companion responding with instanceCreated
261-
const mockCreateInstance = vi.fn().mockImplementation((_pluginUid: string, instanceId: string) => {
262-
queueMicrotask(() => {
263-
for (const fn of listeners['instanceCreated'] ?? []) {
264-
fn({ type: 'instanceCreated', instanceId, parameters: [] });
265-
}
266-
});
267-
return Promise.resolve();
268-
});
269-
client.createInstance = mockCreateInstance;
253+
const mockInstantiate = vi.fn().mockImplementation((_pluginUid: string, instanceId: string) => Promise.resolve({
254+
type: 'instantiated',
255+
reqId: 'req-1',
256+
instanceId,
257+
parameters: [],
258+
latencySamples: 384,
259+
tailSamples: 0,
260+
presets: [],
261+
}));
262+
client.instantiate = mockInstantiate;
270263

271264
await useVST3Store.getState().loadPlugin('com.xfer.serum', 'track-1');
272265

273-
// Should have called createInstance with the pluginId and a generated instanceId
274-
expect(mockCreateInstance).toHaveBeenCalledWith('com.xfer.serum', expect.any(String));
266+
// Should have called instantiate with the pluginId and a generated instanceId
267+
expect(mockInstantiate).toHaveBeenCalledWith('com.xfer.serum', expect.any(String));
275268

276269
// Should have created an instance in the store
277270
const { instances } = useVST3Store.getState();
@@ -285,28 +278,26 @@ describe('vst3Store', () => {
285278
expect(instance.trackId).toBe('track-1');
286279
expect(instance.enabled).toBe(true);
287280
expect(instance.online).toBe(true);
281+
expect(instance.latencySamples).toBe(384);
282+
});
283+
284+
it('updates store and PluginEngine when the companion reports latency changes', () => {
285+
const setPluginLatencySpy = vi.spyOn(pluginEngine, 'setPluginLatency').mockImplementation(() => undefined);
286+
useVST3Store.getState()._upsertInstance(mockInstance({ instanceId: 'inst-1', trackId: 'track-1', latencySamples: 128 }));
287+
288+
useVST3Store.getState()._updateLatency('inst-1', 512.8);
289+
290+
expect(setPluginLatencySpy).toHaveBeenCalledWith('track-1', 'inst-1', 512);
291+
expect(useVST3Store.getState().instances['inst-1'].latencySamples).toBe(512);
292+
293+
setPluginLatencySpy.mockRestore();
288294
});
289295

290296
it('does not create an instance when bridge call fails', async () => {
291297
const { _getBridgeClient } = await import('../../hooks/useVST3Connection');
292298
const client = _getBridgeClient();
293299

294-
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
295-
client.on = vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
296-
if (!listeners[event]) listeners[event] = [];
297-
listeners[event].push(handler);
298-
});
299-
client.off = vi.fn();
300-
301-
client.createInstance = vi.fn().mockImplementation(() => {
302-
// Trigger the error listener
303-
queueMicrotask(() => {
304-
for (const fn of listeners['error'] ?? []) {
305-
fn('Connection lost');
306-
}
307-
});
308-
return Promise.resolve();
309-
});
300+
client.instantiate = vi.fn().mockRejectedValue(new Error('Connection lost'));
310301

311302
await useVST3Store.getState().loadPlugin('com.xfer.serum', 'track-1');
312303

0 commit comments

Comments
 (0)