Skip to content

Commit 9db9d0d

Browse files
committed
fix: apply max plugin delay compensation
1 parent 7c0b4a6 commit 9db9d0d

9 files changed

Lines changed: 299 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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { pluginEngine } from '../../engine/PluginEngine';
99
import { effectsEngine } from '../../engine/EffectsEngine';
1010
import {
1111
buildCombinedEffectsChain,
12+
calculatePluginDelayCompensation,
13+
calculatePluginDelayCompensationForTracks,
1214
} from '../useEffectsSync';
1315

1416
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -85,3 +87,56 @@ describe('buildCombinedEffectsChain', () => {
8587
expect(pluginOutput.connect).toHaveBeenCalledWith(effectsInput);
8688
});
8789
});
90+
91+
describe('calculatePluginDelayCompensation', () => {
92+
beforeEach(() => {
93+
vi.restoreAllMocks();
94+
});
95+
96+
it('delays lower-latency tracks to match the maximum active plugin chain latency', () => {
97+
vi.spyOn(pluginEngine, 'getChainLatency').mockImplementation((trackId) => {
98+
if (trackId === 'track-late') return 512;
99+
if (trackId === 'track-mid') return 128;
100+
return 0;
101+
});
102+
103+
const result = calculatePluginDelayCompensation(
104+
['track-late', 'track-mid', 'track-dry'],
105+
48000,
106+
);
107+
108+
expect(result.get('track-late')).toBe(0);
109+
expect(result.get('track-mid')).toBe(384);
110+
expect(result.get('track-dry')).toBe(512);
111+
});
112+
113+
it('clears compensation when active plugin latencies are all zero', () => {
114+
vi.spyOn(pluginEngine, 'getChainLatency').mockReturnValue(0);
115+
116+
const result = calculatePluginDelayCompensation(['track-a', 'track-b'], 48000);
117+
118+
expect(result.get('track-a')).toBe(0);
119+
expect(result.get('track-b')).toBe(0);
120+
});
121+
122+
it('accounts for group-bus latency without applying extra compensation to the bus', () => {
123+
vi.spyOn(pluginEngine, 'getChainLatency').mockImplementation((trackId) => {
124+
if (trackId === 'group-bus') return 512;
125+
if (trackId === 'track-child') return 128;
126+
return 0;
127+
});
128+
129+
const result = calculatePluginDelayCompensationForTracks(
130+
[
131+
{ id: 'group-bus', isGroup: true },
132+
{ id: 'track-child', parentTrackId: 'group-bus' },
133+
{ id: 'track-dry' },
134+
],
135+
48000,
136+
);
137+
138+
expect(result.get('group-bus')).toBe(0);
139+
expect(result.get('track-child')).toBe(0);
140+
expect(result.get('track-dry')).toBe(640);
141+
});
142+
});

src/hooks/useEffectsSync.ts

Lines changed: 65 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,56 @@ 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+
83+
interface PluginDelayCompensationTrack {
84+
id: string;
85+
isGroup?: boolean;
86+
parentTrackId?: string;
87+
}
88+
89+
function getSignalPathTrackIds(
90+
track: PluginDelayCompensationTrack,
91+
trackById: Map<string, PluginDelayCompensationTrack>,
92+
): string[] {
93+
const path = [track.id];
94+
const visited = new Set(path);
95+
let parentTrackId = track.parentTrackId;
96+
while (parentTrackId && !visited.has(parentTrackId)) {
97+
visited.add(parentTrackId);
98+
path.push(parentTrackId);
99+
parentTrackId = trackById.get(parentTrackId)?.parentTrackId;
100+
}
101+
return path;
102+
}
103+
104+
export function calculatePluginDelayCompensationForTracks(
105+
tracks: PluginDelayCompensationTrack[],
106+
sampleRate: number,
107+
): Map<string, number> {
108+
const trackById = new Map(tracks.map((track) => [track.id, track]));
109+
const sourceTracks = tracks.filter((track) => !track.isGroup);
110+
const compensationByTrack = VST3LatencyCompensation.calculateCompensation(
111+
sourceTracks.map((track) => ({
112+
id: track.id,
113+
pluginLatencies: getSignalPathTrackIds(track, trackById).map((id) => pluginEngine.getChainLatency(id)),
114+
})),
115+
sampleRate,
116+
);
117+
for (const track of tracks) {
118+
if (track.isGroup) compensationByTrack.set(track.id, 0);
119+
}
120+
return compensationByTrack;
121+
}
122+
72123
export function useEffectsSync() {
73124
const tracks = useProjectStore((s) => s.project?.tracks);
74125
const dspBackend = useUIStore((s) => s.dspBackend);
@@ -116,6 +167,8 @@ export function useEffectsSync() {
116167
instancesByTrack.set(inst.trackId, list);
117168
}
118169

170+
const trackNodes = new Map<string, ReturnType<typeof engine.getOrCreateTrackNode>>();
171+
119172
// First pass: rebuild built-in effect chains + sync VST3 bypass, then splice combined chain
120173
for (const track of tracks) {
121174
const effects = track.effects ?? [];
@@ -133,14 +186,19 @@ export function useEffectsSync() {
133186
if (trackNode) {
134187
const { input, output } = buildCombinedEffectsChain(track.id);
135188
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);
189+
trackNodes.set(track.id, trackNode);
141190
}
142191
}
143192

193+
const sampleRate = engine.ctx?.sampleRate ?? 44100;
194+
const compensationByTrack = calculatePluginDelayCompensationForTracks(
195+
tracks.filter((track) => trackNodes.has(track.id)),
196+
sampleRate,
197+
);
198+
for (const [trackId, trackNode] of trackNodes) {
199+
trackNode.setLatencyCompensation(compensationByTrack.get(trackId) ?? 0, sampleRate);
200+
}
201+
144202
// Second pass: wire sidechain connections (all chains must exist first)
145203
for (const track of tracks) {
146204
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
});

0 commit comments

Comments
 (0)