Skip to content

Commit db1c9b6

Browse files
committed
fix: address native meter review feedback
1 parent aabbfce commit db1c9b6

2 files changed

Lines changed: 152 additions & 25 deletions

File tree

src/engine/bridge/TauriBackend.ts

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ import type {
2727
} from './types';
2828
import type { MasteringState } from '../../types/project';
2929

30-
const ZERO_METER: MeterData = { level: -Infinity, leftLevel: -Infinity, rightLevel: -Infinity, clipped: false };
31-
const ZERO_MASTER: MasterMeterData = { level: -Infinity, clipped: false };
30+
const ZERO_METER: MeterData = { level: 0, leftLevel: 0, rightLevel: 0, clipped: false };
31+
const ZERO_MASTER: MasterMeterData = { level: 0, clipped: false };
3232
const TRANSPORT_POSITION_EVENT = 'transport-position';
33+
const METER_REFRESH_INTERVAL_MS = 50;
3334

3435
function toMeterData(reading: NativeMeterReading): MeterData {
3536
const level = Number.isFinite(reading.peak) && reading.peak > 0 ? reading.peak : 0;
@@ -58,9 +59,11 @@ function isAlreadyRunningError(error: unknown): boolean {
5859

5960
function getPanGains(pan: number): { left: number; right: number } {
6061
const clamped = Math.max(-1, Math.min(1, Number.isFinite(pan) ? pan : 0));
61-
if (clamped < 0) return { left: 1, right: 1 + clamped };
62-
if (clamped > 0) return { left: 1 - clamped, right: 1 };
63-
return { left: 1, right: 1 };
62+
const angle = ((clamped + 1) * Math.PI) / 4;
63+
return {
64+
left: Math.cos(angle),
65+
right: Math.sin(angle),
66+
};
6467
}
6568

6669
function clipToNative(
@@ -122,6 +125,10 @@ export class TauriBackend implements AudioBridge {
122125
private _decodeContext: AudioContext | null = null;
123126
private _trackMeters = new Map<string, MeterData>();
124127
private _masterMeter: MasterMeterData = ZERO_MASTER;
128+
private _lastTrackMeterRefreshMs = new Map<string, number>();
129+
private _trackMeterRefreshInFlight = new Set<string>();
130+
private _lastMasterMeterRefreshMs = -Infinity;
131+
private _masterMeterRefreshInFlight = false;
125132
private _transportCommandToken = 0;
126133
private _lastScheduledClips: BridgeClipInfo[] = [];
127134

@@ -276,12 +283,15 @@ export class TauriBackend implements AudioBridge {
276283

277284
getTrackMeter(_trackId: string): MeterData {
278285
const entry = this._trackEntries.get(_trackId);
279-
if (entry?.handle) {
286+
if (entry?.handle && this.shouldRefreshTrackMeter(_trackId)) {
280287
invoke<NativeMeterReading>('audio_get_track_meter', { handle: entry.handle })
281288
.then((reading) => {
282289
this._trackMeters.set(_trackId, toMeterData(reading));
283290
})
284-
.catch(() => {});
291+
.catch(() => {})
292+
.finally(() => {
293+
this._trackMeterRefreshInFlight.delete(_trackId);
294+
});
285295
}
286296
return this._trackMeters.get(_trackId) ?? ZERO_METER;
287297
}
@@ -299,11 +309,16 @@ export class TauriBackend implements AudioBridge {
299309
}
300310

301311
getMasterMeter(_stage: 'input' | 'output'): MasterMeterData {
302-
invoke<NativeMeterReading>('audio_get_master_meter')
303-
.then((reading) => {
304-
this._masterMeter = toMasterMeterData(reading);
305-
})
306-
.catch(() => {});
312+
if (this.shouldRefreshMasterMeter()) {
313+
invoke<NativeMeterReading>('audio_get_master_meter')
314+
.then((reading) => {
315+
this._masterMeter = toMasterMeterData(reading);
316+
})
317+
.catch(() => {})
318+
.finally(() => {
319+
this._masterMeterRefreshInFlight = false;
320+
});
321+
}
307322
return this._masterMeter;
308323
}
309324

@@ -343,13 +358,14 @@ export class TauriBackend implements AudioBridge {
343358
const token = ++this._transportCommandToken;
344359
this._lastScheduledClips = clips;
345360
const nativeClips = this.buildNativeClips(clips);
361+
const seekSamplePosition = Math.max(0, Math.round(fromTime * this.sampleRate));
346362
this._scheduledEndSample = Math.max(0, Math.round(totalDuration * this.sampleRate));
347-
this._currentSamplePosition = Math.max(0, Math.round(fromTime * this.sampleRate));
363+
this._currentSamplePosition = seekSamplePosition;
348364

349365
void (async () => {
350366
await invoke('audio_clip_set_schedule', { clips: nativeClips });
351367
if (token !== this._transportCommandToken) return;
352-
await invoke('audio_transport_seek', { samplePosition: this._currentSamplePosition });
368+
await invoke('audio_transport_seek', { samplePosition: seekSamplePosition });
353369
if (token !== this._transportCommandToken) return;
354370
await invoke('audio_transport_play');
355371
})().catch(() => {});
@@ -391,6 +407,29 @@ export class TauriBackend implements AudioBridge {
391407
.filter((clip): clip is NativeClipSource => clip !== null);
392408
}
393409

410+
private meterNowMs(): number {
411+
return typeof performance !== 'undefined' ? performance.now() : Date.now();
412+
}
413+
414+
private shouldRefreshTrackMeter(trackId: string): boolean {
415+
if (this._trackMeterRefreshInFlight.has(trackId)) return false;
416+
const now = this.meterNowMs();
417+
const last = this._lastTrackMeterRefreshMs.get(trackId) ?? -Infinity;
418+
if (now - last < METER_REFRESH_INTERVAL_MS) return false;
419+
this._lastTrackMeterRefreshMs.set(trackId, now);
420+
this._trackMeterRefreshInFlight.add(trackId);
421+
return true;
422+
}
423+
424+
private shouldRefreshMasterMeter(): boolean {
425+
if (this._masterMeterRefreshInFlight) return false;
426+
const now = this.meterNowMs();
427+
if (now - this._lastMasterMeterRefreshMs < METER_REFRESH_INTERVAL_MS) return false;
428+
this._lastMasterMeterRefreshMs = now;
429+
this._masterMeterRefreshInFlight = true;
430+
return true;
431+
}
432+
394433
private republishActiveSchedule(): void {
395434
if (this._scheduledEndSample === null || this._lastScheduledClips.length === 0) return;
396435
const token = this._transportCommandToken;

src/engine/bridge/__tests__/TauriBackend.test.ts

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ function createMockAudioBuffer(samples: number[], sampleRate = 48000): AudioBuff
2424
} as unknown as AudioBuffer;
2525
}
2626

27+
function equalPowerPan(pan: number): { left: number; right: number } {
28+
const clamped = Math.max(-1, Math.min(1, pan));
29+
const angle = ((clamped + 1) * Math.PI) / 4;
30+
return { left: Math.cos(angle), right: Math.sin(angle) };
31+
}
32+
2733
describe('TauriBackend', () => {
2834
let backend: TauriBackend;
2935

@@ -70,22 +76,22 @@ describe('TauriBackend', () => {
7076

7177
it('getTrackMeter returns silent meter', () => {
7278
const meter = backend.getTrackMeter('any-track');
73-
expect(meter.level).toBe(-Infinity);
79+
expect(meter.level).toBe(0);
7480
expect(meter.clipped).toBe(false);
7581
});
7682

77-
it('getTrackLevel returns -Infinity', () => {
78-
expect(backend.getTrackLevel('any-track')).toBe(-Infinity);
83+
it('getTrackLevel returns silence by default', () => {
84+
expect(backend.getTrackLevel('any-track')).toBe(0);
7985
});
8086

8187
it('getMasterMeter returns silent meter', () => {
8288
const meter = backend.getMasterMeter('output');
83-
expect(meter.level).toBe(-Infinity);
89+
expect(meter.level).toBe(0);
8490
expect(meter.clipped).toBe(false);
8591
});
8692

87-
it('getMasterLevel returns -Infinity', () => {
88-
expect(backend.getMasterLevel('input')).toBe(-Infinity);
93+
it('getMasterLevel returns silence by default', () => {
94+
expect(backend.getMasterLevel('input')).toBe(0);
8995
});
9096

9197
it('getTrackSpectrum returns null', () => {
@@ -207,6 +213,7 @@ describe('TauriBackend', () => {
207213

208214
it('schedulePlayback sends clips through native schedule and starts transport', async () => {
209215
const buffer = createMockAudioBuffer([0.125, 0.25, 0.5, 1]);
216+
const centerPan = equalPowerPan(0);
210217

211218
backend.schedulePlayback([
212219
{
@@ -217,7 +224,7 @@ describe('TauriBackend', () => {
217224
audioOffset: 0,
218225
clipDuration: 4 / 48000,
219226
},
220-
], 1, 2);
227+
], 1, 3);
221228
await Promise.resolve();
222229
await Promise.resolve();
223230
await Promise.resolve();
@@ -228,17 +235,63 @@ describe('TauriBackend', () => {
228235
startSample: 48000,
229236
lengthSamples: 4,
230237
gain: 1,
231-
audioData: [0.125, 0.125, 0.25, 0.25, 0.5, 0.5, 1, 1],
238+
audioData: [
239+
0.125 * centerPan.left,
240+
0.125 * centerPan.right,
241+
0.25 * centerPan.left,
242+
0.25 * centerPan.right,
243+
0.5 * centerPan.left,
244+
0.5 * centerPan.right,
245+
centerPan.left,
246+
centerPan.right,
247+
],
232248
},
233249
],
234250
});
235251
expect(invokeMock).toHaveBeenCalledWith('audio_transport_seek', { samplePosition: 48000 });
236252
expect(invokeMock).toHaveBeenCalledWith('audio_transport_play');
237253
});
238254

255+
it('uses the scheduled seek sample even if transport events update the cache first', async () => {
256+
let positionHandler: ((event: { payload: number }) => void) | null = null;
257+
let resolveSchedule!: () => void;
258+
invokeMock.mockImplementation((command) => {
259+
if (command === 'audio_clip_set_schedule') {
260+
return new Promise((resolve) => {
261+
resolveSchedule = () => resolve(undefined);
262+
});
263+
}
264+
return Promise.resolve(undefined);
265+
});
266+
listenMock.mockImplementation(async (_event, handler) => {
267+
positionHandler = handler as (event: { payload: number }) => void;
268+
return vi.fn();
269+
});
270+
const buffer = createMockAudioBuffer([1]);
271+
backend.setTimeUpdateCallback(() => {});
272+
273+
backend.schedulePlayback([
274+
{
275+
clipId: 'clip-1',
276+
trackId: 'track-1',
277+
startTime: 1,
278+
buffer,
279+
audioOffset: 0,
280+
clipDuration: 1 / 48000,
281+
},
282+
], 1, 3);
283+
positionHandler?.({ payload: 96000 });
284+
resolveSchedule();
285+
await Promise.resolve();
286+
await Promise.resolve();
287+
288+
expect(invokeMock).toHaveBeenCalledWith('audio_transport_seek', { samplePosition: 48000 });
289+
});
290+
239291
it('applies cached track volume, pan, mute, and solo before native scheduling', async () => {
240292
const audibleBuffer = createMockAudioBuffer([1, 1]);
241293
const mutedBuffer = createMockAudioBuffer([1, 1]);
294+
const pan = equalPowerPan(-0.5);
242295
backend.ensureTrack('audible');
243296
backend.ensureTrack('muted');
244297
backend.setTrackParams('audible', { volume: 0.5, pan: -0.5, soloed: true });
@@ -272,14 +325,15 @@ describe('TauriBackend', () => {
272325
startSample: 0,
273326
lengthSamples: 2,
274327
gain: 1,
275-
audioData: [0.5, 0.25, 0.5, 0.25],
328+
audioData: [0.5 * pan.left, 0.5 * pan.right, 0.5 * pan.left, 0.5 * pan.right],
276329
},
277330
],
278331
});
279332
});
280333

281334
it('republishes active native schedule when track params change', async () => {
282335
const buffer = createMockAudioBuffer([1, 1]);
336+
const pan = equalPowerPan(0.5);
283337
invokeMock.mockResolvedValueOnce({ slot: 0, generation: 1 });
284338
backend.ensureTrack('track-1');
285339
await Promise.resolve();
@@ -307,7 +361,7 @@ describe('TauriBackend', () => {
307361
startSample: 0,
308362
lengthSamples: 2,
309363
gain: 1,
310-
audioData: [0.125, 0.25, 0.125, 0.25],
364+
audioData: [0.25 * pan.left, 0.25 * pan.right, 0.25 * pan.left, 0.25 * pan.right],
311365
},
312366
],
313367
});
@@ -374,6 +428,7 @@ describe('TauriBackend', () => {
374428

375429
it('resamples non-48kHz buffers before sending native clips', async () => {
376430
const buffer = createMockAudioBuffer([0, 1], 24000);
431+
const centerPan = equalPowerPan(0);
377432

378433
backend.schedulePlayback([
379434
{
@@ -395,7 +450,16 @@ describe('TauriBackend', () => {
395450
startSample: 0,
396451
lengthSamples: 4,
397452
gain: 1,
398-
audioData: [0, 0, 0.5, 0.5, 1, 1, 1, 1],
453+
audioData: [
454+
0,
455+
0,
456+
0.5 * centerPan.left,
457+
0.5 * centerPan.right,
458+
centerPan.left,
459+
centerPan.right,
460+
centerPan.left,
461+
centerPan.right,
462+
],
399463
},
400464
],
401465
});
@@ -412,4 +476,28 @@ describe('TauriBackend', () => {
412476

413477
expect(invokeMock).toHaveBeenCalledWith('audio_get_track_meter', expect.any(Object));
414478
});
479+
480+
it('throttles track meter refreshes while a native request is in flight', async () => {
481+
invokeMock.mockResolvedValueOnce({ slot: 0, generation: 1 });
482+
backend.ensureTrack('track-1');
483+
await Promise.resolve();
484+
invokeMock.mockClear();
485+
486+
invokeMock.mockResolvedValue({ rms: 0.2, peak: 0.4, clipped: false });
487+
backend.getTrackMeter('track-1');
488+
backend.getTrackMeter('track-1');
489+
490+
expect(invokeMock).toHaveBeenCalledTimes(1);
491+
expect(invokeMock).toHaveBeenCalledWith('audio_get_track_meter', expect.any(Object));
492+
});
493+
494+
it('throttles master meter refreshes while a native request is in flight', () => {
495+
invokeMock.mockResolvedValue({ rms: 0.2, peak: 0.4, clipped: false });
496+
497+
backend.getMasterMeter('output');
498+
backend.getMasterMeter('input');
499+
500+
expect(invokeMock).toHaveBeenCalledTimes(1);
501+
expect(invokeMock).toHaveBeenCalledWith('audio_get_master_meter');
502+
});
415503
});

0 commit comments

Comments
 (0)