Skip to content

Commit 29f9846

Browse files
committed
fix: isolate native transport clocks
1 parent db1c9b6 commit 29f9846

8 files changed

Lines changed: 148 additions & 6 deletions

File tree

src/engine/bridge/TauriBackend.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,11 @@ export class TauriBackend implements AudioBridge {
282282
// ── Metering ──────────────────────────────────────────────────────
283283

284284
getTrackMeter(_trackId: string): MeterData {
285+
const scheduledClipMeter = this.getScheduledClipTrackMeter(_trackId);
286+
if (scheduledClipMeter) {
287+
this._trackMeters.set(_trackId, scheduledClipMeter);
288+
return scheduledClipMeter;
289+
}
285290
const entry = this._trackEntries.get(_trackId);
286291
if (entry?.handle && this.shouldRefreshTrackMeter(_trackId)) {
287292
invoke<NativeMeterReading>('audio_get_track_meter', { handle: entry.handle })
@@ -407,6 +412,49 @@ export class TauriBackend implements AudioBridge {
407412
.filter((clip): clip is NativeClipSource => clip !== null);
408413
}
409414

415+
private getScheduledClipTrackMeter(trackId: string): MeterData | null {
416+
const trackClips = this._lastScheduledClips.filter((clip) => clip.trackId === trackId);
417+
if (trackClips.length === 0) return null;
418+
419+
const params = this._trackEntries.get(trackId)?.params ?? {
420+
volume: 1,
421+
pan: 0,
422+
mute: false,
423+
solo: false,
424+
};
425+
const anySoloed = Array.from(this._trackEntries.values()).some((entry) => entry.params.solo);
426+
if (params.mute || (anySoloed && !params.solo)) return ZERO_METER;
427+
428+
const currentTime = this.getCurrentTime();
429+
const pan = getPanGains(params.pan);
430+
const volume = Math.max(0, Math.min(1, Number.isFinite(params.volume) ? params.volume : 1));
431+
let leftLevel = 0;
432+
let rightLevel = 0;
433+
434+
for (const clip of trackClips) {
435+
const clipEndTime = clip.startTime + clip.clipDuration;
436+
if (currentTime < clip.startTime || currentTime >= clipEndTime) continue;
437+
const sourceRate = clip.buffer.sampleRate || this.sampleRate;
438+
const sourceTime = clip.audioOffset + (currentTime - clip.startTime);
439+
const sampleIndex = Math.min(
440+
clip.buffer.length - 1,
441+
Math.max(0, Math.round(sourceTime * sourceRate)),
442+
);
443+
const left = clip.buffer.getChannelData(0);
444+
const right = clip.buffer.numberOfChannels > 1 ? clip.buffer.getChannelData(1) : left;
445+
leftLevel = Math.max(leftLevel, Math.abs(left[sampleIndex] ?? 0) * volume * pan.left);
446+
rightLevel = Math.max(rightLevel, Math.abs(right[sampleIndex] ?? 0) * volume * pan.right);
447+
}
448+
449+
const level = Math.max(leftLevel, rightLevel);
450+
return {
451+
level,
452+
leftLevel,
453+
rightLevel,
454+
clipped: level >= 1,
455+
};
456+
}
457+
410458
private meterNowMs(): number {
411459
return typeof performance !== 'undefined' ? performance.now() : Date.now();
412460
}

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,27 @@ describe('TauriBackend', () => {
477477
expect(invokeMock).toHaveBeenCalledWith('audio_get_track_meter', expect.any(Object));
478478
});
479479

480+
it('reflects active native clip audio in track meters', () => {
481+
const buffer = createMockAudioBuffer([0.5]);
482+
const centerPan = equalPowerPan(0);
483+
484+
backend.schedulePlayback([
485+
{
486+
clipId: 'clip-1',
487+
trackId: 'track-1',
488+
startTime: 0,
489+
buffer,
490+
audioOffset: 0,
491+
clipDuration: 1 / 48000,
492+
},
493+
], 0, 1);
494+
495+
const meter = backend.getTrackMeter('track-1');
496+
expect(meter.level).toBeCloseTo(0.5 * Math.max(centerPan.left, centerPan.right));
497+
expect(meter.leftLevel).toBeCloseTo(0.5 * centerPan.left);
498+
expect(meter.rightLevel).toBeCloseTo(0.5 * centerPan.right);
499+
});
500+
480501
it('throttles track meter refreshes while a native request is in flight', async () => {
481502
invokeMock.mockResolvedValueOnce({ slot: 0, generation: 1 });
482503
backend.ensureTrack('track-1');

src/hooks/__tests__/useAudioEngine.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,14 @@ vi.mock('../../engine/bridge', () => ({
3434
getAudioBridge: vi.fn(() => mockBridge),
3535
}));
3636

37-
import { getAudioEngine, getExistingAudioEngine, _setAudioResumed, useAudioEngine } from '../useAudioEngine';
37+
import {
38+
getAudioEngine,
39+
getExistingAudioEngine,
40+
getTauriPlaybackClockOwner,
41+
setTauriPlaybackClockOwner,
42+
_setAudioResumed,
43+
useAudioEngine,
44+
} from '../useAudioEngine';
3845
import { useTransportStore } from '../../store/transportStore';
3946
import { useProjectStore } from '../../store/projectStore';
4047

@@ -48,6 +55,7 @@ describe('useAudioEngine', () => {
4855
mockBridge.backend = 'web-audio';
4956
mockBridge.resume.mockReset();
5057
mockBridge.setTimeUpdateCallback.mockReset();
58+
setTauriPlaybackClockOwner('web-audio');
5159
});
5260

5361
afterEach(() => {
@@ -94,6 +102,22 @@ describe('useAudioEngine', () => {
94102
engineCallback(2.25);
95103
expect(useTransportStore.getState().currentTime).toBe(2.25);
96104

105+
bridgeCallback(3.5);
106+
expect(useTransportStore.getState().currentTime).toBe(2.25);
107+
expect(getTauriPlaybackClockOwner()).toBe('web-audio');
108+
});
109+
110+
it('uses the Tauri bridge clock when native playback owns transport', () => {
111+
mockBridge.backend = 'tauri';
112+
setTauriPlaybackClockOwner('native');
113+
renderHook(() => useAudioEngine());
114+
115+
const engineCallback = mockSetTimeUpdateCallback.mock.calls[0][0];
116+
const bridgeCallback = mockBridge.setTimeUpdateCallback.mock.calls[0][0];
117+
118+
engineCallback(2.25);
119+
expect(useTransportStore.getState().currentTime).toBe(0);
120+
97121
bridgeCallback(3.5);
98122
expect(useTransportStore.getState().currentTime).toBe(3.5);
99123
});

src/hooks/__tests__/useTransport.strudel.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ const mocks = vi.hoisted(() => ({
1818
masterVolume: 1,
1919
playing: false,
2020
},
21+
tauriClock: { owner: 'web-audio' as 'native' | 'web-audio' },
22+
setTauriPlaybackClockOwner: vi.fn((owner: 'native' | 'web-audio') => {
23+
mocks.tauriClock.owner = owner;
24+
}),
2125
stopRecording: vi.fn(async () => {}),
2226
onLoopCycle: vi.fn(async () => {}),
2327
stopAllStrudelTracks: vi.fn(),
@@ -27,6 +31,8 @@ const mocks = vi.hoisted(() => ({
2731
vi.mock('tone', () => ({}));
2832
vi.mock('../useAudioEngine', () => ({
2933
getAudioEngine: () => mocks.engine,
34+
getTauriPlaybackClockOwner: () => mocks.tauriClock.owner,
35+
setTauriPlaybackClockOwner: (owner: 'native' | 'web-audio') => mocks.setTauriPlaybackClockOwner(owner),
3036
}));
3137
vi.mock('../useRecording', () => ({
3238
useRecording: () => ({
@@ -83,6 +89,7 @@ import { useUIStore } from '../../store/uiStore';
8389
describe('useTransport strudel controls', () => {
8490
beforeEach(() => {
8591
vi.clearAllMocks();
92+
mocks.tauriClock.owner = 'web-audio';
8693
useProjectStore.getState().createProject('Transport Test');
8794
useTransportStore.setState({
8895
isPlaying: true,

src/hooks/__tests__/useTransport.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ const mocks = vi.hoisted(() => ({
3434
setTrackMute: vi.fn(),
3535
setTrackSolo: vi.fn(),
3636
},
37+
tauriClock: { owner: 'web-audio' as 'native' | 'web-audio' },
38+
setTauriPlaybackClockOwner: vi.fn((owner: 'native' | 'web-audio') => {
39+
mocks.tauriClock.owner = owner;
40+
}),
3741
stopRecording: vi.fn(async () => {}),
3842
onLoopCycle: vi.fn(async () => {}),
3943
stopAllStrudelTracks: vi.fn(),
@@ -49,6 +53,8 @@ vi.mock('tone', () => ({
4953
}));
5054
vi.mock('../useAudioEngine', () => ({
5155
getAudioEngine: () => mocks.engine,
56+
getTauriPlaybackClockOwner: () => mocks.tauriClock.owner,
57+
setTauriPlaybackClockOwner: (owner: 'native' | 'web-audio') => mocks.setTauriPlaybackClockOwner(owner),
5258
}));
5359
vi.mock('../useRecording', () => ({
5460
useRecording: () => ({
@@ -138,6 +144,7 @@ describe('useTransport', () => {
138144
useProjectStore.getState().createProject({ name: 'Transport Test' });
139145
useUIStore.setState({ mainView: 'arrangement' });
140146
mocks.engine.playing = false;
147+
mocks.tauriClock.owner = 'web-audio';
141148
});
142149

143150
// ── play() ──

src/hooks/useAudioEngine.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,21 @@ import { getAudioBridge } from '../engine/bridge';
66

77
let _engineInstance: AudioEngine | null = null;
88
let _audioResumed = false;
9+
let _tauriPlaybackClockOwner: 'native' | 'web-audio' = 'web-audio';
910

1011
/** @internal Set audio-resumed flag — for tests only */
1112
export function _setAudioResumed(value: boolean) {
1213
_audioResumed = value;
1314
}
1415

16+
export function setTauriPlaybackClockOwner(owner: 'native' | 'web-audio') {
17+
_tauriPlaybackClockOwner = owner;
18+
}
19+
20+
export function getTauriPlaybackClockOwner(): 'native' | 'web-audio' {
21+
return _tauriPlaybackClockOwner;
22+
}
23+
1524
export function getAudioEngine(): AudioEngine {
1625
if (!_engineInstance) {
1726
_engineInstance = new AudioEngine();
@@ -30,10 +39,12 @@ export function useAudioEngine() {
3039
const engine = engineRef.current;
3140
const bridge = getAudioBridge(engine);
3241
engine.setTimeUpdateCallback((time) => {
42+
if (bridge.backend === 'tauri' && _tauriPlaybackClockOwner === 'native') return;
3343
useTransportStore.getState().setCurrentTime(time);
3444
});
3545
if (bridge.backend === 'tauri') {
3646
bridge.setTimeUpdateCallback((time) => {
47+
if (_tauriPlaybackClockOwner !== 'native') return;
3748
useTransportStore.getState().setCurrentTime(time);
3849
});
3950
}

src/hooks/useTransport.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ import { useCallback, useEffect, useRef } from 'react';
22
import { useTransportStore } from '../store/transportStore';
33
import { useProjectStore } from '../store/projectStore';
44
import { useUIStore } from '../store/uiStore';
5-
import { getAudioEngine } from './useAudioEngine';
5+
import {
6+
getAudioEngine,
7+
getTauriPlaybackClockOwner,
8+
setTauriPlaybackClockOwner,
9+
} from './useAudioEngine';
610
import { getAudioBridge } from '../engine/bridge';
11+
import type { AudioBridge } from '../engine/bridge/types';
712
import { loadAudioBlobByKey } from '../services/audioFileManager';
813
import { synthEngine } from '../engine/SynthEngine';
914
import { subtractiveEngine } from '../engine/SubtractiveEngine';
@@ -117,6 +122,19 @@ function canUseNativeClipPlayback(project: Project, entries: NativePlaybackEntry
117122
return !project.tracks.some(trackNeedsWebAudio);
118123
}
119124

125+
function getActivePlaybackTime(
126+
engine: ReturnType<typeof getAudioEngine>,
127+
bridge: AudioBridge,
128+
): number {
129+
if (!useTransportStore.getState().isPlaying) {
130+
return useTransportStore.getState().currentTime;
131+
}
132+
if (bridge.backend === 'tauri' && getTauriPlaybackClockOwner() === 'native') {
133+
return bridge.getCurrentTime();
134+
}
135+
return engine.getCurrentTime();
136+
}
137+
120138
/**
121139
* Trim an AudioBuffer to a specific project-time region.
122140
* The input buffer may cover the full project duration; the output covers only
@@ -453,15 +471,18 @@ export function useTransport() {
453471
&& canUseNativeClipPlayback(nextProject, clipBuffers);
454472

455473
if (useNativeClipPlayback) {
474+
setTauriPlaybackClockOwner('native');
456475
bridge.schedulePlayback(clipBuffers, startFrom, effectiveEnd);
457476
// The native backend owns audio clip playback, but MIDI, synth,
458477
// sequencer, automation, and Strudel still use AudioEngine's
459478
// RAF clock until the Rust engine reaches full feature parity.
460479
engine.schedulePlayback([], startFrom, effectiveEnd);
461480
} else if (bridge.backend === 'tauri') {
481+
setTauriPlaybackClockOwner('web-audio');
462482
bridge.stopAllSources();
463483
engine.schedulePlayback(clipBuffers, startFrom, effectiveEnd);
464484
} else {
485+
setTauriPlaybackClockOwner('web-audio');
465486
// Playback reads from stretchedBufferCache (populated on clip stretch mouseup).
466487
// If Signalsmith/Rubber Band already finished → high quality buffer used.
467488
// If neither finished yet → legacy fallback via _getProcessedBuffer.
@@ -835,11 +856,12 @@ export function useTransport() {
835856
}
836857
const engine = getAudioEngine();
837858
const bridge = getAudioBridge(engine);
838-
const time = bridge.getCurrentTime();
859+
const time = getActivePlaybackTime(engine, bridge);
839860
finalizeSessionArrangementRecording(time);
840861
stopStrudelEditorPlayback();
841862
stopAllStrudelTracks();
842863
engine.stop();
864+
setTauriPlaybackClockOwner('web-audio');
843865
bridge.pauseAllSources();
844866
synthEngine.releaseAll();
845867
subtractiveEngine.releaseAll();
@@ -859,12 +881,11 @@ export function useTransport() {
859881
}
860882
const engine = getAudioEngine();
861883
const bridge = getAudioBridge(engine);
862-
const time = engine.playing || bridge.backend === 'tauri'
863-
? bridge.getCurrentTime()
864-
: useTransportStore.getState().currentTime;
884+
const time = getActivePlaybackTime(engine, bridge);
865885
finalizeSessionArrangementRecording(time);
866886
stopStrudelEditorPlayback();
867887
engine.stop();
888+
setTauriPlaybackClockOwner('web-audio');
868889
bridge.stopAllSources();
869890
synthEngine.releaseAll();
870891
subtractiveEngine.releaseAll();
@@ -884,6 +905,7 @@ export function useTransport() {
884905
stopAllStrudelTracks();
885906
if (engine.playing || (bridge.backend === 'tauri' && useTransportStore.getState().isPlaying)) {
886907
engine.stop();
908+
setTauriPlaybackClockOwner('web-audio');
887909
bridge.stopAllSources();
888910
synthEngine.releaseAll();
889911
subtractiveEngine.releaseAll();

tests/unit/useTransportScrubLifecycle.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ const engineMock = {
4242

4343
vi.mock('../../src/hooks/useAudioEngine', () => ({
4444
getAudioEngine: () => engineMock,
45+
getTauriPlaybackClockOwner: () => 'web-audio',
46+
setTauriPlaybackClockOwner: vi.fn(),
4547
}));
4648

4749
vi.mock('../../src/engine/SynthEngine', () => ({

0 commit comments

Comments
 (0)