Skip to content

fix: gate native Tauri audio backend#1807

Merged
ChuxiJ merged 20 commits intomainfrom
fix/issue-1780
Apr 30, 2026
Merged

fix: gate native Tauri audio backend#1807
ChuxiJ merged 20 commits intomainfrom
fix/issue-1780

Conversation

@ChuxiJ
Copy link
Copy Markdown

@ChuxiJ ChuxiJ commented Apr 30, 2026

Summary

Closes #1780.

This hotfix makes the native Rust/Tauri audio backend product-activatable behind an explicit desktop-only gate while preserving the existing WebAudio default path.

  • Adds VITE_ENABLE_TAURI_AUDIO_BACKEND=true as the activation gate, only effective inside Tauri.
  • Routes shared transport/clip playback through the AudioBridge singleton so desktop builds can select TauriBackend.
  • Wires Tauri backend transport position events, play/pause/stop/seek scheduling, native meters, track params, master volume, and fallback webview decode into native clip scheduling.
  • Preserves existing WebAudio clip features when routed through the bridge.
  • Adds command ordering guards so stale async native play chains cannot resurrect playback after stop/pause/seek.
  • Adds native meter Tauri commands and registers them in the app shell.

Verification

  • npx tsc --noEmit --pretty false
  • npx vitest run src/engine/bridge/__tests__ src/utils/__tests__/tauri.test.ts src/hooks/__tests__/useAudioEngine.test.ts src/hooks/__tests__/useTransport.test.ts src/hooks/__tests__/useTransport.strudel.test.ts
  • npm test -- src/engine/bridge/__tests__ src/utils/__tests__/tauri.test.ts
  • npm run test:coverage — 740 files passed, 8731 tests passed, 3 skipped, 10 todo
  • cargo test --manifest-path src-tauri/Cargo.toml
  • git diff --check
  • Independent agent review after fixes: no blockers

Notes

The native path remains explicitly gated because full Rust parity is still in progress. Current native clip scheduling applies initial track volume/pan/mute/solo and supports transport/meter integration, but true Rust-native decode and advanced WebAudio-only clip processing such as fades, envelopes, warp markers, pitch shift, and stretch modes should continue as follow-up native-engine work.

Copilot AI review requested due to automatic review settings April 30, 2026 01:35
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an explicit, desktop-only feature gate (VITE_ENABLE_TAURI_AUDIO_BACKEND=true) to allow selecting the native Rust/Tauri audio backend in Tauri builds while keeping WebAudio as the default path, and routes key transport/clip scheduling through an AudioBridge singleton.

Changes:

  • Added a Tauri-only env gate helper (isTauriAudioBackendEnabled) and updated the bridge factory to select TauriBackend when enabled.
  • Introduced an AudioBridge singleton (getAudioBridge) and migrated transport playback/scheduling + track/master param syncing to go through the bridge.
  • Implemented significant TauriBackend functionality (transport events, clip scheduling, decode fallback, metering commands) and registered new Tauri commands for meters.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/vite-env.d.ts Adds env typing for the Tauri audio backend gate.
src/utils/tauri.ts Adds isTauriAudioBackendEnabled() helper.
src/utils/tests/tauri.test.ts Tests the new gate behavior + env stubbing cleanup.
src/engine/bridge/index.ts Enables gated backend selection + adds a bridge singleton for reuse.
src/engine/bridge/types.ts Extends bridge clip fields + adds pause/stop + native meter/clip wire types.
src/engine/bridge/WebAudioBackend.ts Passes through additional clip fields and adds pauseAllSources().
src/engine/bridge/TauriBackend.ts Implements native resume/transport listener, scheduling, metering calls, decode fallback.
src/hooks/useTransport.ts Routes clip playback + track/master params through AudioBridge.
src/hooks/useAudioEngine.ts Uses bridge for resume + time updates when on Tauri backend.
src/engine/bridge/tests/* Updates/adds tests for bridge selection and backend behaviors.
src/hooks/tests/* Updates transport mocks for new stop/pause behavior via bridge.
src-tauri/src/lib.rs Registers new meter commands.
src-tauri/src/commands/audio.rs Adds audio_get_track_meter and audio_get_master_meter commands.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/engine/bridge/TauriBackend.ts Outdated
Comment on lines +342 to +348
this._scheduledEndSample = Math.max(0, Math.round(totalDuration * this.sampleRate));
this._currentSamplePosition = Math.max(0, Math.round(fromTime * this.sampleRate));

void (async () => {
await invoke('audio_clip_set_schedule', { clips: nativeClips });
if (token !== this._transportCommandToken) return;
await invoke('audio_transport_seek', { samplePosition: this._currentSamplePosition });
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

schedulePlayback() uses this._currentSamplePosition inside an async invoke chain after awaiting audio_clip_set_schedule. Because _currentSamplePosition is also updated by transport-position events, the value used for audio_transport_seek can drift before the seek happens. Capture the intended seek sample position in a local constant and use that for the audio_transport_seek payload.

Suggested change
this._scheduledEndSample = Math.max(0, Math.round(totalDuration * this.sampleRate));
this._currentSamplePosition = Math.max(0, Math.round(fromTime * this.sampleRate));
void (async () => {
await invoke('audio_clip_set_schedule', { clips: nativeClips });
if (token !== this._transportCommandToken) return;
await invoke('audio_transport_seek', { samplePosition: this._currentSamplePosition });
const seekSamplePosition = Math.max(0, Math.round(fromTime * this.sampleRate));
this._scheduledEndSample = Math.max(0, Math.round(totalDuration * this.sampleRate));
this._currentSamplePosition = seekSamplePosition;
void (async () => {
await invoke('audio_clip_set_schedule', { clips: nativeClips });
if (token !== this._transportCommandToken) return;
await invoke('audio_transport_seek', { samplePosition: seekSamplePosition });

Copilot uses AI. Check for mistakes.
Comment on lines 263 to 273
getTrackMeter(_trackId: string): MeterData {
return ZERO_METER;
const entry = this._trackEntries.get(_trackId);
if (entry?.handle) {
invoke<NativeMeterReading>('audio_get_track_meter', { handle: entry.handle })
.then((reading) => {
this._trackMeters.set(_trackId, toMeterData(reading));
})
.catch(() => {});
}
return this._trackMeters.get(_trackId) ?? ZERO_METER;
}
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getTrackMeter() triggers a Tauri invoke() on every call. Meter reads in the UI are typically executed in requestAnimationFrame loops (60fps per meter), so this implementation would flood the IPC channel and repeatedly lock the Rust engine. Consider polling meters on a fixed interval with throttling (e.g., one invoke per track per N ms), batching reads, or switching to a push/event-driven meter stream so getTrackMeter() is a pure cache read.

Copilot uses AI. Check for mistakes.
Comment thread src/engine/bridge/TauriBackend.ts Outdated
Comment on lines 30 to 31
const ZERO_METER: MeterData = { level: -Infinity, leftLevel: -Infinity, rightLevel: -Infinity, clipped: false };
const ZERO_MASTER: MasterMeterData = { level: -Infinity, clipped: false };
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default meter constants use -Infinity, but both the WebAudio engine and the Rust MeterReading use linear 0..1 levels (silence = 0). Returning -Infinity is inconsistent with the rest of the codebase and can leak unexpected values to callers. Prefer { level: 0, leftLevel: 0, rightLevel: 0, clipped: false } / { level: 0, clipped: false } for the defaults (and update the affected tests accordingly).

Suggested change
const ZERO_METER: MeterData = { level: -Infinity, leftLevel: -Infinity, rightLevel: -Infinity, clipped: false };
const ZERO_MASTER: MasterMeterData = { level: -Infinity, clipped: false };
const ZERO_METER: MeterData = { level: 0, leftLevel: 0, rightLevel: 0, clipped: false };
const ZERO_MASTER: MasterMeterData = { level: 0, clipped: false };

Copilot uses AI. Check for mistakes.
Comment thread src/engine/bridge/TauriBackend.ts Outdated
Comment on lines +54 to +56
if (clamped < 0) return { left: 1, right: 1 + clamped };
if (clamped > 0) return { left: 1 - clamped, right: 1 };
return { left: 1, right: 1 };
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pan implementation in getPanGains() is linear (left=1,right=1±pan). WebAudio’s StereoPannerNode (used by the existing engine) uses an equal-power pan law, and the Rust engine exposes equal_power_pan as well. Using a different pan law will change perceived loudness across the stereo field and break parity between backends; update this to match the equal-power behavior.

Suggested change
if (clamped < 0) return { left: 1, right: 1 + clamped };
if (clamped > 0) return { left: 1 - clamped, right: 1 };
return { left: 1, right: 1 };
const angle = ((clamped + 1) * Math.PI) / 4;
return {
left: Math.cos(angle),
right: Math.sin(angle),
};

Copilot uses AI. Check for mistakes.
Comment on lines 135 to 141
async resume(): Promise<void> {
await invoke('audio_start_engine', {
config: { sampleRate: 48000, bufferSize: 256, deviceName: null },
});
this.startTransportListener();
this.refreshTransportPosition();
}
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resume() always calls audio_start_engine, but the Rust command returns EngineError::AlreadyRunning when the engine is already running. Since resume() is invoked on every play/gesture, this will start failing after the first successful call. Make resume() idempotent (e.g., check audio_get_engine_status first, or catch/ignore the alreadyRunning error and continue setting up listeners / refreshing position).

Copilot uses AI. Check for mistakes.
@ChuxiJ
Copy link
Copy Markdown
Author

ChuxiJ commented Apr 30, 2026

Final review and validation update:

  • GitHub Copilot review comments have been addressed.
  • Codex review was rerun after the final follow-up fixes and reported no actionable correctness issues.
  • Additional review follow-ups fixed native transport ordering, stale position refreshes, native/WebAudio fallback sequencing, native meter clip resets, latency-compensated transport time updates, batched native schedule republishes, and overlapping native clip meter summing.
  • Local validation passed: npx tsc --noEmit --pretty false, targeted Vitest suites for transport/bridge/meter behavior, cargo test --manifest-path src-tauri/Cargo.toml --lib, and full npm run test:coverage (740 files, 8757 tests passed, 3 skipped, 10 todo).
  • GitHub CI is green on head 349fafa: build, build-wasm, type-check, unit-test, rust-test, tauri-rust-test, e2e-critical, and e2e-extended.

Ready to merge.

@ChuxiJ ChuxiJ merged commit 2bebdd2 into main Apr 30, 2026
8 checks passed
@ChuxiJ ChuxiJ deleted the fix/issue-1780 branch April 30, 2026 05:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

QA: Desktop Tauri build still uses WebAudio backend instead of native Rust engine

2 participants