diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index ca250284f..0310551a7 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -10,9 +10,9 @@ use tauri::{Emitter, State}; use crate::engine::{ audio_io, AudioDeviceInfo, ClipSchedule, ClipSource, CommandError, CountIn, Engine, - EngineConfig, EngineError, EngineStatus, LoopRegion, MetronomeConfig, PositionEmitter, - PunchRegion, TempoEvent, TempoMap, TimeSignatureEvent, TimeSignatureMap, TrackParams, - POSITION_EVENT_DEFAULT_INTERVAL, + EngineConfig, EngineError, EngineStatus, LoopRegion, MeterReading, MetronomeConfig, + PositionEmitter, PunchRegion, TempoEvent, TempoMap, TimeSignatureEvent, TimeSignatureMap, + TrackParams, POSITION_EVENT_DEFAULT_INTERVAL, }; use crate::engine::slot::SlotHandle; @@ -102,6 +102,14 @@ pub fn audio_start_engine( .lock() .map_err(|_| EngineError::Open("emitter mutex poisoned".into()))?; + let current_status = engine.status(); + if current_status.is_running() { + // `Engine::start` returns AlreadyRunning, but the emitter is + // already valid in the normal running case. Return the current + // status without tearing it down. + return Ok(current_status); + } + // Stop any leftover emitter BEFORE starting the new engine — so // we never have two emitters pushing to the same event name, and // we don't leak a thread if `start` fails. @@ -218,6 +226,48 @@ pub fn audio_set_master_volume( engine.set_master_volume(volume) } +#[tauri::command] +pub fn audio_get_track_meter( + handle: SlotHandle, + state: State<'_, EngineState>, +) -> Result { + let mut engine = state + .0 + .lock() + .map_err(|_| CommandError::Disconnected)?; + Ok(engine.get_track_meter(handle)) +} + +#[tauri::command] +pub fn audio_get_master_meter(state: State<'_, EngineState>) -> Result { + let mut engine = state + .0 + .lock() + .map_err(|_| CommandError::Disconnected)?; + Ok(engine.get_master_meter()) +} + +#[tauri::command] +pub fn audio_reset_track_clip( + handle: SlotHandle, + state: State<'_, EngineState>, +) -> Result<(), CommandError> { + let mut engine = state + .0 + .lock() + .map_err(|_| CommandError::Disconnected)?; + engine.reset_track_clip(handle) +} + +#[tauri::command] +pub fn audio_reset_master_clip(state: State<'_, EngineState>) -> Result<(), CommandError> { + let mut engine = state + .0 + .lock() + .map_err(|_| CommandError::Disconnected)?; + engine.reset_master_clip() +} + // ── Transport (3A) ────────────────────────────────────────────────── #[tauri::command] diff --git a/src-tauri/src/engine/audio_io.rs b/src-tauri/src/engine/audio_io.rs index 916afe51e..19166ec8a 100644 --- a/src-tauri/src/engine/audio_io.rs +++ b/src-tauri/src/engine/audio_io.rs @@ -354,6 +354,14 @@ fn make_audio_callback( bus.enabled = enabled; } } + EngineCommand::ResetTrackClip { handle } => { + if graph.handle_matches(handle) { + meters.track_meters[handle.index()].reset_clip(); + } + } + EngineCommand::ResetMasterClip => { + meters.master_meter.reset_clip(); + } // Transport commands (3A) — route to the audio-thread // `Transport` instance. Position advance happens at // the tail of the callback, AFTER the drain, so that diff --git a/src-tauri/src/engine/command.rs b/src-tauri/src/engine/command.rs index 5bc443e74..c520db38b 100644 --- a/src-tauri/src/engine/command.rs +++ b/src-tauri/src/engine/command.rs @@ -142,6 +142,13 @@ pub enum EngineCommand { /// Enable or disable an aux return bus. SetAuxBusEnabled { bus_index: u8, enabled: bool }, + /// Clear the latched clip flag on a track meter. + /// Generation-checked like other track-targeted commands. + ResetTrackClip { handle: SlotHandle }, + + /// Clear the latched clip flag on the master meter. + ResetMasterClip, + // ── Transport (3A) ──────────────────────────────────────────────── /// Begin playback from the current position. diff --git a/src-tauri/src/engine/graph.rs b/src-tauri/src/engine/graph.rs index 27c4457e5..e873deccc 100644 --- a/src-tauri/src/engine/graph.rs +++ b/src-tauri/src/engine/graph.rs @@ -252,6 +252,8 @@ impl AudioGraph { | EngineCommand::SetTrackSendLevel { .. } | EngineCommand::SetAuxBusVolume { .. } | EngineCommand::SetAuxBusEnabled { .. } + | EngineCommand::ResetTrackClip { .. } + | EngineCommand::ResetMasterClip | EngineCommand::TransportPlay | EngineCommand::TransportStop | EngineCommand::TransportPause diff --git a/src-tauri/src/engine/meter_bank.rs b/src-tauri/src/engine/meter_bank.rs index 2cb23c685..0ba0d681b 100644 --- a/src-tauri/src/engine/meter_bank.rs +++ b/src-tauri/src/engine/meter_bank.rs @@ -68,6 +68,22 @@ impl MeterConsumers { drain_into(&mut self.master_consumer, &mut self.master_cache); self.master_cache } + + /// Clear the latched clip flag from the main-thread cache for a + /// track slot. The audio-thread meter is reset by command queue; + /// this avoids one stale UI poll before that command drains. + pub fn reset_track_clip(&mut self, slot: usize) { + if let Some(cons) = self.track_consumers.get_mut(slot) { + drain_into(cons, &mut self.track_cache[slot]); + self.track_cache[slot].clipped = false; + } + } + + /// Clear the latched clip flag from the main-thread master cache. + pub fn reset_master_clip(&mut self) { + drain_into(&mut self.master_consumer, &mut self.master_cache); + self.master_cache.clipped = false; + } } /// Drain the ring buffer into a cached value. If new entries exist, diff --git a/src-tauri/src/engine/mod.rs b/src-tauri/src/engine/mod.rs index 6f046bbbe..9d163f1ef 100644 --- a/src-tauri/src/engine/mod.rs +++ b/src-tauri/src/engine/mod.rs @@ -497,6 +497,20 @@ impl Engine { self.send_command(EngineCommand::SetMasterVolume { volume }) } + /// Clear a track meter's latched clip flag. + pub fn reset_track_clip(&mut self, handle: SlotHandle) -> Result<(), CommandError> { + let running = self.running.as_mut().ok_or(CommandError::NotRunning)?; + running.meter_consumers.reset_track_clip(handle.index()); + self.send_command(EngineCommand::ResetTrackClip { handle }) + } + + /// Clear the master meter's latched clip flag. + pub fn reset_master_clip(&mut self) -> Result<(), CommandError> { + let running = self.running.as_mut().ok_or(CommandError::NotRunning)?; + running.meter_consumers.reset_master_clip(); + self.send_command(EngineCommand::ResetMasterClip) + } + // ── Transport (3A) ─────────────────────────────────────────────── /// Begin playback from the current position. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 05828a9be..c0aef1c30 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,7 +5,8 @@ use tauri::Manager; use crate::commands::audio::{ audio_add_track, audio_clip_get_schedule, audio_clip_set_schedule, - audio_get_default_device, audio_get_engine_status, audio_list_devices, + audio_get_default_device, audio_get_engine_status, audio_get_master_meter, + audio_get_track_meter, audio_list_devices, audio_reset_master_clip, audio_reset_track_clip, audio_metronome_get_config, audio_metronome_set_config, audio_metronome_set_enabled, audio_remove_track, audio_set_master_volume, audio_set_track_params, audio_start_engine, audio_stop_engine, audio_transport_get_count_in, @@ -55,6 +56,10 @@ pub fn run() { audio_remove_track, audio_set_track_params, audio_set_master_volume, + audio_get_track_meter, + audio_get_master_meter, + audio_reset_track_clip, + audio_reset_master_clip, audio_transport_play, audio_transport_stop, audio_transport_pause, diff --git a/src/components/mixer/LevelMeter.tsx b/src/components/mixer/LevelMeter.tsx index ddecc1037..bafe8e4f8 100644 --- a/src/components/mixer/LevelMeter.tsx +++ b/src/components/mixer/LevelMeter.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useCallback } from 'react'; -import { getAudioEngine } from '../../hooks/useAudioEngine'; +import { getAudioEngine, getTauriPlaybackClockOwner } from '../../hooks/useAudioEngine'; +import { getAudioBridge } from '../../engine/bridge'; import { METER_CANVAS_STOPS, METER_DB_TICKS, METER_DB_TICKS_MINOR, METER_PADDING_PCT, dbToFill, levelToFill } from '../meter-colors'; const BAR_WIDTH = 4; @@ -77,6 +78,7 @@ export function LevelMeter({ trackId, masterStage, returnTrackId, stereo, showSc }; const engine = getAudioEngine(); + const bridge = getAudioBridge(engine); const tick = () => { const dpr = window.devicePixelRatio || 1; @@ -92,8 +94,12 @@ export function LevelMeter({ trackId, masterStage, returnTrackId, stereo, showSc let rightLevel = 0; let clipped = false; + const meterSource = bridge.backend === 'tauri' && getTauriPlaybackClockOwner() === 'native' + ? bridge + : engine; + if (masterStage) { - const meter = engine.getMasterMeter(masterStage); + const meter = meterSource.getMasterMeter(masterStage); leftLevel = meter.level; rightLevel = meter.level; clipped = meter.clipped; @@ -103,7 +109,7 @@ export function LevelMeter({ trackId, masterStage, returnTrackId, stereo, showSc rightLevel = meter.level; clipped = meter.clipped; } else if (trackId) { - const meter = engine.getTrackMeter(trackId); + const meter = meterSource.getTrackMeter(trackId); leftLevel = isStereo ? meter.leftLevel : meter.level; rightLevel = isStereo ? meter.rightLevel : meter.level; clipped = meter.clipped; @@ -183,12 +189,16 @@ export function LevelMeter({ trackId, masterStage, returnTrackId, stereo, showSc const resetClip = () => { const engine = getAudioEngine(); + const bridge = getAudioBridge(engine); + const meterSource = bridge.backend === 'tauri' && getTauriPlaybackClockOwner() === 'native' + ? bridge + : engine; if (masterStage) { - engine.resetMasterClip(masterStage); + meterSource.resetMasterClip(masterStage); } else if (returnTrackId) { engine.resetReturnTrackClip(returnTrackId); } else if (trackId) { - engine.resetTrackClip(trackId); + meterSource.resetTrackClip(trackId); } clippedRef.current = false; clippedStateRef.current = false; diff --git a/src/components/mixer/__tests__/LevelMeter.test.tsx b/src/components/mixer/__tests__/LevelMeter.test.tsx index 7b324d4e8..fd8449adc 100644 --- a/src/components/mixer/__tests__/LevelMeter.test.tsx +++ b/src/components/mixer/__tests__/LevelMeter.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react'; import { LevelMeter } from '../LevelMeter'; vi.mock('../../../hooks/useAudioEngine', () => ({ + getTauriPlaybackClockOwner: () => 'web-audio', getAudioEngine: () => ({ getTrackMeter: () => ({ level: 0.5, leftLevel: 0.4, rightLevel: 0.6, clipped: false }), getMasterMeter: () => ({ level: 0.3, clipped: false }), diff --git a/src/components/session/SessionMixerStrip.tsx b/src/components/session/SessionMixerStrip.tsx index 8eaee50bf..52fb0aae2 100644 --- a/src/components/session/SessionMixerStrip.tsx +++ b/src/components/session/SessionMixerStrip.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { getAudioEngine } from '../../hooks/useAudioEngine'; +import { getAudioEngine, getTauriPlaybackClockOwner } from '../../hooks/useAudioEngine'; +import { getAudioBridge } from '../../engine/bridge'; import { Knob } from '../ui/Knob'; /** Convert linear level (0..1+) to a 0..1 fill fraction mapping -60dB..0dB */ @@ -45,8 +46,12 @@ export function SessionMixerStrip({ // Animate meter levels useEffect(() => { const engine = getAudioEngine(); + const bridge = getAudioBridge(engine); const tick = () => { - const meter = engine.getTrackMeter(trackId); + const meterSource = bridge.backend === 'tauri' && getTauriPlaybackClockOwner() === 'native' + ? bridge + : engine; + const meter = meterSource.getTrackMeter(trackId); setLeftFill(levelToFill(meter.leftLevel)); setRightFill(levelToFill(meter.rightLevel)); rafRef.current = requestAnimationFrame(tick); diff --git a/src/components/session/__tests__/SessionMixer.test.tsx b/src/components/session/__tests__/SessionMixer.test.tsx index 8fc227ef4..ee42b91f5 100644 --- a/src/components/session/__tests__/SessionMixer.test.tsx +++ b/src/components/session/__tests__/SessionMixer.test.tsx @@ -9,6 +9,7 @@ vi.mock('../../../services/projectStorage', () => ({ })); vi.mock('../../../hooks/useAudioEngine', () => ({ + getTauriPlaybackClockOwner: () => 'web-audio', getAudioEngine: () => ({ getTrackMeter: () => ({ leftLevel: 0, rightLevel: 0, clipped: false }), getTrackLevel: () => 0, diff --git a/src/components/tracks/FaderMeter.tsx b/src/components/tracks/FaderMeter.tsx index 95770bf30..f63ce12f0 100644 --- a/src/components/tracks/FaderMeter.tsx +++ b/src/components/tracks/FaderMeter.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { getAudioEngine } from '../../hooks/useAudioEngine'; +import { getAudioEngine, getTauriPlaybackClockOwner } from '../../hooks/useAudioEngine'; +import { getAudioBridge } from '../../engine/bridge'; import { METER_GRADIENT_HORIZONTAL, levelToMeterFill } from '../meter-colors'; interface FaderMeterProps { @@ -33,8 +34,12 @@ export function FaderMeter({ trackId, volume, onVolumeChange, trackName }: Fader // Animate meter levels useEffect(() => { const engine = getAudioEngine(); + const bridge = getAudioBridge(engine); const tick = () => { - const meter = engine.getTrackMeter(trackId); + const meterSource = bridge.backend === 'tauri' && getTauriPlaybackClockOwner() === 'native' + ? bridge + : engine; + const meter = meterSource.getTrackMeter(trackId); setLeftFill(levelToMeterFill(meter.leftLevel)); setRightFill(levelToMeterFill(meter.rightLevel)); setClipping((was) => was || meter.clipped); @@ -46,7 +51,11 @@ export function FaderMeter({ trackId, volume, onVolumeChange, trackName }: Fader const resetClip = useCallback(() => { const engine = getAudioEngine(); - engine.resetTrackClip(trackId); + const bridge = getAudioBridge(engine); + const meterSource = bridge.backend === 'tauri' && getTauriPlaybackClockOwner() === 'native' + ? bridge + : engine; + meterSource.resetTrackClip(trackId); setClipping(false); }, [trackId]); diff --git a/src/components/tracks/StereoMeter.tsx b/src/components/tracks/StereoMeter.tsx index e173a8ff5..464ed0bc3 100644 --- a/src/components/tracks/StereoMeter.tsx +++ b/src/components/tracks/StereoMeter.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -import { getAudioEngine } from '../../hooks/useAudioEngine'; +import { getAudioEngine, getTauriPlaybackClockOwner } from '../../hooks/useAudioEngine'; +import { getAudioBridge } from '../../engine/bridge'; import { METER_GRADIENT_HORIZONTAL, levelToMeterFill } from '../meter-colors'; interface StereoMeterProps { @@ -14,9 +15,13 @@ export function StereoMeter({ trackId }: StereoMeterProps) { useEffect(() => { const engine = getAudioEngine(); + const bridge = getAudioBridge(engine); const tick = () => { - const meter = engine.getTrackMeter(trackId); + const meterSource = bridge.backend === 'tauri' && getTauriPlaybackClockOwner() === 'native' + ? bridge + : engine; + const meter = meterSource.getTrackMeter(trackId); setLeftFill(levelToMeterFill(meter.leftLevel)); setRightFill(levelToMeterFill(meter.rightLevel)); setClipping((was) => was || meter.clipped); @@ -29,7 +34,11 @@ export function StereoMeter({ trackId }: StereoMeterProps) { const resetClip = () => { const engine = getAudioEngine(); - engine.resetTrackClip(trackId); + const bridge = getAudioBridge(engine); + const meterSource = bridge.backend === 'tauri' && getTauriPlaybackClockOwner() === 'native' + ? bridge + : engine; + meterSource.resetTrackClip(trackId); setClipping(false); }; diff --git a/src/components/tracks/__tests__/StereoMeter.test.tsx b/src/components/tracks/__tests__/StereoMeter.test.tsx index e2b905939..afb31f26e 100644 --- a/src/components/tracks/__tests__/StereoMeter.test.tsx +++ b/src/components/tracks/__tests__/StereoMeter.test.tsx @@ -10,6 +10,7 @@ const engine = { vi.mock('../../../hooks/useAudioEngine', () => ({ getAudioEngine: () => engine, + getTauriPlaybackClockOwner: () => 'web-audio', })); describe('StereoMeter', () => { diff --git a/src/components/tracks/__tests__/TrackHeader.test.tsx b/src/components/tracks/__tests__/TrackHeader.test.tsx index d9f20edce..87d2767a8 100644 --- a/src/components/tracks/__tests__/TrackHeader.test.tsx +++ b/src/components/tracks/__tests__/TrackHeader.test.tsx @@ -23,6 +23,7 @@ vi.mock('../../../services/freezeTrack', () => ({ })); vi.mock('../../../hooks/useAudioEngine', () => ({ + getTauriPlaybackClockOwner: () => 'web-audio', getAudioEngine: () => ({ getTrackLevel: () => 0, getTrackMeter: () => ({ level: 0, clipped: false }), diff --git a/src/components/tracks/__tests__/TrackHeaderAnimation.test.tsx b/src/components/tracks/__tests__/TrackHeaderAnimation.test.tsx index 4bef0f7a3..439326d54 100644 --- a/src/components/tracks/__tests__/TrackHeaderAnimation.test.tsx +++ b/src/components/tracks/__tests__/TrackHeaderAnimation.test.tsx @@ -18,6 +18,7 @@ vi.mock('../../../services/freezeTrack', () => ({ flattenTrackToAudio: vi.fn(), })); vi.mock('../../../hooks/useAudioEngine', () => ({ + getTauriPlaybackClockOwner: () => 'web-audio', getAudioEngine: () => ({ getTrackLevel: () => 0, }), diff --git a/src/components/tracks/__tests__/TrackHeaderLayout.test.tsx b/src/components/tracks/__tests__/TrackHeaderLayout.test.tsx index a75fc7a2f..3466ae6d5 100644 --- a/src/components/tracks/__tests__/TrackHeaderLayout.test.tsx +++ b/src/components/tracks/__tests__/TrackHeaderLayout.test.tsx @@ -19,6 +19,7 @@ vi.mock('../../../services/freezeTrack', () => ({ flattenTrackToAudio: vi.fn(), })); vi.mock('../../../hooks/useAudioEngine', () => ({ + getTauriPlaybackClockOwner: () => 'web-audio', getAudioEngine: () => ({ getTrackLevel: () => 0, getTrackMeter: () => ({ level: 0, clipped: false }), diff --git a/src/components/tracks/__tests__/TrackHeaderMeter.test.tsx b/src/components/tracks/__tests__/TrackHeaderMeter.test.tsx index 9cb02ee4a..994959f1a 100644 --- a/src/components/tracks/__tests__/TrackHeaderMeter.test.tsx +++ b/src/components/tracks/__tests__/TrackHeaderMeter.test.tsx @@ -10,6 +10,7 @@ const engine = { vi.mock('../../../hooks/useAudioEngine', () => ({ getAudioEngine: () => engine, + getTauriPlaybackClockOwner: () => 'web-audio', })); describe('TrackHeaderMeter', () => { diff --git a/src/engine/bridge/TauriBackend.ts b/src/engine/bridge/TauriBackend.ts index 0cdf1d9ab..26dc13569 100644 --- a/src/engine/bridge/TauriBackend.ts +++ b/src/engine/bridge/TauriBackend.ts @@ -13,19 +13,103 @@ * default in browser mode. */ import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; import type { AudioBridge, BridgeClipInfo, MasterMeterData, MeterData, + NativeClipSource, + NativeMeterReading, NativeSlotHandle, NativeTrackParams, TrackParams, } from './types'; import type { MasteringState } from '../../types/project'; -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 }; +const TRANSPORT_POSITION_EVENT = 'transport-position'; +const METER_REFRESH_INTERVAL_MS = 50; + +function toMeterData(reading: NativeMeterReading): MeterData { + const level = Number.isFinite(reading.peak) && reading.peak > 0 ? reading.peak : 0; + return { + level, + leftLevel: level, + rightLevel: level, + clipped: reading.clipped, + }; +} + +function toMasterMeterData(reading: NativeMeterReading): MasterMeterData { + const level = Number.isFinite(reading.peak) && reading.peak > 0 ? reading.peak : 0; + return { + level, + clipped: reading.clipped, + }; +} + +function isAlreadyRunningError(error: unknown): boolean { + if (typeof error === 'object' && error !== null && 'kind' in error) { + return (error as { kind?: unknown }).kind === 'alreadyRunning'; + } + return String(error).includes('alreadyRunning'); +} + +function getPanGains(pan: number): { left: number; right: number } { + const clamped = Math.max(-1, Math.min(1, Number.isFinite(pan) ? pan : 0)); + const angle = ((clamped + 1) * Math.PI) / 4; + return { + left: Math.cos(angle), + right: Math.sin(angle), + }; +} + +function clipToNative( + clip: BridgeClipInfo, + sampleRate: number, + trackParams: NativeTrackParams, + anySoloed: boolean, +): NativeClipSource | null { + if (trackParams.mute || (anySoloed && !trackParams.solo)) return null; + const startSample = Math.max(0, Math.round(clip.startTime * sampleRate)); + const sourceRate = clip.buffer.sampleRate || sampleRate; + const sourceStart = Math.max(0, Math.round(clip.audioOffset * sourceRate)); + const availableFrames = Math.max(0, clip.buffer.length - sourceStart); + const sourceDuration = Math.min(Math.max(0, clip.clipDuration), availableFrames / sourceRate); + if (sourceDuration <= 0) return null; + const nativeFrames = Math.max(1, Math.round(sourceDuration * sampleRate)); + + const left = clip.buffer.getChannelData(0); + const right = clip.buffer.numberOfChannels > 1 ? clip.buffer.getChannelData(1) : left; + const volume = Math.max(0, Number.isFinite(trackParams.volume) ? trackParams.volume : 1); + const pan = getPanGains(trackParams.pan); + const lastSourceIndex = Math.max(sourceStart, clip.buffer.length - 1); + const sampleAt = (channel: Float32Array, sourcePosition: number): number => { + const clamped = Math.min(Math.max(sourceStart, sourcePosition), lastSourceIndex); + const lower = Math.floor(clamped); + const upper = Math.min(lastSourceIndex, lower + 1); + const mix = clamped - lower; + const lowerValue = channel[lower] ?? 0; + const upperValue = channel[upper] ?? lowerValue; + return lowerValue + (upperValue - lowerValue) * mix; + }; + + const audioData: number[] = new Array(nativeFrames * 2); + for (let i = 0; i < nativeFrames; i++) { + const sourcePosition = sourceStart + (i * sourceRate) / sampleRate; + audioData[i * 2] = sampleAt(left, sourcePosition) * volume * pan.left; + audioData[i * 2 + 1] = sampleAt(right, sourcePosition) * volume * pan.right; + } + + return { + startSample, + lengthSamples: nativeFrames, + gain: 1, + audioData, + }; +} export class TauriBackend implements AudioBridge { readonly backend = 'tauri' as const; @@ -33,6 +117,23 @@ export class TauriBackend implements AudioBridge { private _timeUpdateCb: ((time: number) => void) | null = null; private _onEndedCb: (() => void) | null = null; + private _currentSamplePosition = 0; + private _playbackLatencyCompensationSeconds = 0; + private _scheduledEndSample: number | null = null; + private _transportListenerStarted = false; + private _transportUnlisten: (() => void) | null = null; + private _decodeContext: AudioContext | null = null; + private _trackMeters = new Map(); + private _masterMeter: MasterMeterData = ZERO_MASTER; + private _lastTrackMeterRefreshMs = new Map(); + private _trackMeterRefreshInFlight = new Set(); + private _lastMasterMeterRefreshMs = -Infinity; + private _masterMeterRefreshInFlight = false; + private _transportCommandToken = 0; + private _transportEndArmedToken: number | null = null; + private _transportCommandQueue: Promise = Promise.resolve(); + private _lastScheduledClips: BridgeClipInfo[] = []; + private _republishQueued = false; /** * Maps the AudioBridge's string `trackId` to the native engine's @@ -50,22 +151,33 @@ export class TauriBackend implements AudioBridge { // ── Lifecycle ───────────────────────────────────────────────────── async resume(): Promise { - await invoke('audio_start_engine', { - config: { sampleRate: 48000, bufferSize: 256, deviceName: null }, - }); + try { + await invoke('audio_start_engine', { + config: { sampleRate: 48000, bufferSize: 256, deviceName: null }, + }); + } catch (error) { + if (!isAlreadyRunningError(error)) throw error; + } + this.startTransportListener(); + this.refreshTransportPosition(); } dispose(): void { + if (this._transportUnlisten) { + this._transportUnlisten(); + this._transportUnlisten = null; + } + this._transportListenerStarted = false; invoke('audio_stop_engine').catch(() => {}); this._trackEntries.clear(); + void this._decodeContext?.close(); + this._decodeContext = null; } // ── Transport ───────────────────────────────────────────────────── getCurrentTime(): number { - // In the Rust backend, time will be pushed via events. - // For now return 0 as this backend is not yet active. - return 0; + return this._currentSamplePosition / this.sampleRate; } getLookAhead(): number { @@ -73,15 +185,17 @@ export class TauriBackend implements AudioBridge { } getCompensatedTime(): number { - return 0; + return Math.max(0, this.getCurrentTime() - this._playbackLatencyCompensationSeconds); } - setPlaybackLatencyCompensation(_seconds: number): void { - // Will invoke Rust command when implemented + setPlaybackLatencyCompensation(seconds: number): void { + this._playbackLatencyCompensationSeconds = Number.isFinite(seconds) + ? Math.max(0, seconds) + : 0; } getPlaybackLatencyCompensation(): number { - return 0; + return this._playbackLatencyCompensationSeconds; } // ── Track Management ────────────────────────────────────────────── @@ -104,6 +218,10 @@ export class TauriBackend implements AudioBridge { const entry = this._trackEntries.get(trackId); if (entry) { entry.handle = handle; + invoke('audio_set_track_params', { + handle, + params: entry.params, + }).catch(() => {}); } // If removeTrack was called while the IPC was in-flight, the // entry has already been deleted — send a compensating remove @@ -132,7 +250,7 @@ export class TauriBackend implements AudioBridge { setTrackParams(trackId: string, params: TrackParams): void { const entry = this._trackEntries.get(trackId); - if (!entry || !entry.handle) return; + if (!entry) return; // Merge incoming partial params into the cached full state so // omitted fields preserve their existing values — codex found // that defaulting omitted fields to 1/0/false clobbers prior @@ -142,10 +260,12 @@ export class TauriBackend implements AudioBridge { if (params.pan !== undefined) entry.params.pan = params.pan; if (params.muted !== undefined) entry.params.mute = params.muted; if (params.soloed !== undefined) entry.params.solo = params.soloed; + if (!entry.handle) return; invoke('audio_set_track_params', { handle: entry.handle, params: entry.params, }).catch(() => {}); + this.requestRepublishActiveSchedule(); } setTrackGroupRouting(trackId: string, groupId: string | null): void { @@ -159,20 +279,43 @@ export class TauriBackend implements AudioBridge { // via `AudioGraph::any_solo()`. No explicit command needed — the // individual `setTrackParams` calls with `solo: true/false` // already propagate through the command queue. + this.requestRepublishActiveSchedule(); } // ── Metering ────────────────────────────────────────────────────── getTrackMeter(_trackId: string): MeterData { - return ZERO_METER; + const scheduledClipMeter = this.getScheduledClipTrackMeter(_trackId); + if (scheduledClipMeter) { + this._trackMeters.set(_trackId, scheduledClipMeter); + return scheduledClipMeter; + } + const entry = this._trackEntries.get(_trackId); + if (entry?.handle && this.shouldRefreshTrackMeter(_trackId)) { + invoke('audio_get_track_meter', { handle: entry.handle }) + .then((reading) => { + this._trackMeters.set(_trackId, toMeterData(reading)); + }) + .catch(() => {}) + .finally(() => { + this._trackMeterRefreshInFlight.delete(_trackId); + }); + } + return this._trackMeters.get(_trackId) ?? ZERO_METER; } - getTrackLevel(_trackId: string): number { - return -Infinity; + getTrackLevel(trackId: string): number { + return this.getTrackMeter(trackId).level; } - resetTrackClip(_trackId: string): void { - // Will be wired in Phase 2B-2 (metering) + resetTrackClip(trackId: string): void { + this._trackMeters.set(trackId, { + ...(this._trackMeters.get(trackId) ?? ZERO_METER), + clipped: false, + }); + const entry = this._trackEntries.get(trackId); + if (!entry?.handle) return; + invoke('audio_reset_track_clip', { handle: entry.handle }).catch(() => {}); } getTrackSpectrum(_trackId: string): Float32Array | null { @@ -180,15 +323,26 @@ export class TauriBackend implements AudioBridge { } getMasterMeter(_stage: 'input' | 'output'): MasterMeterData { - return ZERO_MASTER; + if (this.shouldRefreshMasterMeter()) { + invoke('audio_get_master_meter') + .then((reading) => { + this._masterMeter = toMasterMeterData(reading); + }) + .catch(() => {}) + .finally(() => { + this._masterMeterRefreshInFlight = false; + }); + } + return this._masterMeter; } - getMasterLevel(_stage: 'input' | 'output'): number { - return -Infinity; + getMasterLevel(stage: 'input' | 'output'): number { + return this.getMasterMeter(stage).level; } resetMasterClip(_stage: 'input' | 'output'): void { - // Will be wired in Phase 2B-2 (metering) + this._masterMeter = { ...this._masterMeter, clipped: false }; + invoke('audio_reset_master_clip').catch(() => {}); } getMasterSpectrum(): Float32Array { @@ -212,22 +366,169 @@ export class TauriBackend implements AudioBridge { // ── Clip Scheduling ─────────────────────────────────────────────── schedulePlayback( - _clips: BridgeClipInfo[], - _fromTime: number, - _totalDuration: number, - ): void { - // Clip audio data will be sent as binary blobs in Phase 3 + clips: BridgeClipInfo[], + fromTime: number, + totalDuration: number, + ): Promise { + const token = ++this._transportCommandToken; + this._transportEndArmedToken = null; + this._lastScheduledClips = clips; + const nativeClips = this.buildNativeClips(clips); + const seekSamplePosition = Math.max(0, Math.round(fromTime * this.sampleRate)); + this._scheduledEndSample = Math.max(0, Math.round(totalDuration * this.sampleRate)); + this._currentSamplePosition = seekSamplePosition; + + return this.enqueueTransportCommand(async () => { + if (token !== this._transportCommandToken) return; + await invoke('audio_clip_set_schedule', { clips: nativeClips }); + if (token !== this._transportCommandToken) return; + await invoke('audio_transport_seek', { samplePosition: seekSamplePosition }); + if (token !== this._transportCommandToken) return; + await invoke('audio_transport_play'); + if (token === this._transportCommandToken) { + this._transportEndArmedToken = token; + } + }); + } + + stopAllSources(): Promise { + const token = ++this._transportCommandToken; + this._transportEndArmedToken = null; + this._scheduledEndSample = null; + this._currentSamplePosition = 0; + this._lastScheduledClips = []; + return this.enqueueTransportCommand(async () => { + if (token !== this._transportCommandToken) return; + await invoke('audio_clip_set_schedule', { clips: [] }); + if (token !== this._transportCommandToken) return; + await invoke('audio_transport_stop'); + }); + } + + pauseAllSources(): Promise { + const token = ++this._transportCommandToken; + this._transportEndArmedToken = null; + return this.enqueueTransportCommand(async () => { + if (token !== this._transportCommandToken) return; + await invoke('audio_transport_pause'); + }); + } + + private buildNativeClips(clips: BridgeClipInfo[]): NativeClipSource[] { + const anySoloed = Array.from(this._trackEntries.values()).some((entry) => entry.params.solo); + return clips + .map((clip) => { + const params = this._trackEntries.get(clip.trackId)?.params ?? { + volume: 1, + pan: 0, + mute: false, + solo: false, + }; + return clipToNative(clip, this.sampleRate, params, anySoloed); + }) + .filter((clip): clip is NativeClipSource => clip !== null); } - stopAllSources(): void { - // Will invoke Rust command in Phase 3 + private getScheduledClipTrackMeter(trackId: string): MeterData | null { + const trackClips = this._lastScheduledClips.filter((clip) => clip.trackId === trackId); + if (trackClips.length === 0) return null; + + const params = this._trackEntries.get(trackId)?.params ?? { + volume: 1, + pan: 0, + mute: false, + solo: false, + }; + const anySoloed = Array.from(this._trackEntries.values()).some((entry) => entry.params.solo); + if (params.mute || (anySoloed && !params.solo)) return ZERO_METER; + + const currentTime = this.getCurrentTime(); + const pan = getPanGains(params.pan); + const volume = Math.max(0, Number.isFinite(params.volume) ? params.volume : 1); + let leftLevel = 0; + let rightLevel = 0; + + for (const clip of trackClips) { + const clipEndTime = clip.startTime + clip.clipDuration; + if (currentTime < clip.startTime || currentTime >= clipEndTime) continue; + const sourceRate = clip.buffer.sampleRate || this.sampleRate; + const sourceTime = clip.audioOffset + (currentTime - clip.startTime); + const sampleIndex = Math.min( + clip.buffer.length - 1, + Math.max(0, Math.round(sourceTime * sourceRate)), + ); + const left = clip.buffer.getChannelData(0); + const right = clip.buffer.numberOfChannels > 1 ? clip.buffer.getChannelData(1) : left; + leftLevel += (left[sampleIndex] ?? 0) * volume * pan.left; + rightLevel += (right[sampleIndex] ?? 0) * volume * pan.right; + } + + leftLevel = Math.abs(leftLevel); + rightLevel = Math.abs(rightLevel); + const level = Math.max(leftLevel, rightLevel); + return { + level, + leftLevel, + rightLevel, + clipped: level >= 1, + }; + } + + private meterNowMs(): number { + return typeof performance !== 'undefined' ? performance.now() : Date.now(); + } + + private shouldRefreshTrackMeter(trackId: string): boolean { + if (this._trackMeterRefreshInFlight.has(trackId)) return false; + const now = this.meterNowMs(); + const last = this._lastTrackMeterRefreshMs.get(trackId) ?? -Infinity; + if (now - last < METER_REFRESH_INTERVAL_MS) return false; + this._lastTrackMeterRefreshMs.set(trackId, now); + this._trackMeterRefreshInFlight.add(trackId); + return true; + } + + private shouldRefreshMasterMeter(): boolean { + if (this._masterMeterRefreshInFlight) return false; + const now = this.meterNowMs(); + if (now - this._lastMasterMeterRefreshMs < METER_REFRESH_INTERVAL_MS) return false; + this._lastMasterMeterRefreshMs = now; + this._masterMeterRefreshInFlight = true; + return true; + } + + private enqueueTransportCommand(command: () => Promise): Promise { + const run = this._transportCommandQueue.then(command, command); + this._transportCommandQueue = run.catch(() => {}); + void run.catch(() => {}); + return run; + } + + private requestRepublishActiveSchedule(): void { + if (this._scheduledEndSample === null || this._lastScheduledClips.length === 0) return; + if (this._republishQueued) return; + this._republishQueued = true; + queueMicrotask(() => { + this._republishQueued = false; + this.republishActiveSchedule(); + }); + } + + private republishActiveSchedule(): void { + if (this._scheduledEndSample === null || this._lastScheduledClips.length === 0) return; + const token = this._transportCommandToken; + const nativeClips = this.buildNativeClips(this._lastScheduledClips); + this.enqueueTransportCommand(async () => { + if (token !== this._transportCommandToken) return; + await invoke('audio_clip_set_schedule', { clips: nativeClips }); + }); } // ── Audio Data ──────────────────────────────────────────────────── - async decodeAudioData(_blob: Blob): Promise { - // Rust backend will decode audio natively in Phase 2C - throw new Error('TauriBackend: decodeAudioData not yet implemented'); + async decodeAudioData(blob: Blob): Promise { + this._decodeContext ??= new AudioContext({ sampleRate: this.sampleRate }); + return this._decodeContext.decodeAudioData(await blob.arrayBuffer()); } getAudioStream(): MediaStream { @@ -242,11 +543,50 @@ export class TauriBackend implements AudioBridge { setTimeUpdateCallback(cb: (time: number) => void): void { this._timeUpdateCb = cb; - // Will subscribe to Tauri event 'audio:time_update' in Phase 3 + this.startTransportListener(); } setOnEndedCallback(cb: () => void): void { this._onEndedCb = cb; - // Will subscribe to Tauri event 'audio:playback_ended' in Phase 3 + } + + private startTransportListener(): void { + if (this._transportListenerStarted) return; + this._transportListenerStarted = true; + void listen(TRANSPORT_POSITION_EVENT, (event) => { + const position = Number(event.payload); + if (!Number.isFinite(position)) return; + this._currentSamplePosition = Math.max(0, Math.floor(position)); + const currentTime = this.getCompensatedTime(); + this._timeUpdateCb?.(currentTime); + if ( + this._scheduledEndSample !== null + && this._transportEndArmedToken === this._transportCommandToken + && this._currentSamplePosition >= this._scheduledEndSample + ) { + this._transportEndArmedToken = null; + this._scheduledEndSample = null; + void this.stopAllSources(); + this._onEndedCb?.(); + } + }) + .then((unlisten) => { + this._transportUnlisten = unlisten; + }) + .catch(() => { + this._transportListenerStarted = false; + }); + } + + private refreshTransportPosition(): void { + const token = this._transportCommandToken; + invoke('audio_transport_get_position') + .then((position) => { + if (token !== this._transportCommandToken) return; + if (Number.isFinite(position)) { + this._currentSamplePosition = Math.max(0, Math.floor(position)); + } + }) + .catch(() => {}); } } diff --git a/src/engine/bridge/WebAudioBackend.ts b/src/engine/bridge/WebAudioBackend.ts index 6cf40409f..541b255e1 100644 --- a/src/engine/bridge/WebAudioBackend.ts +++ b/src/engine/bridge/WebAudioBackend.ts @@ -162,11 +162,21 @@ export class WebAudioBackend implements AudioBridge { fadeOutDuration: c.fadeOutDuration, fadeInCurve: c.fadeInCurve, fadeOutCurve: c.fadeOutCurve, + fadeInCurvePoint: c.fadeInCurvePoint, + fadeOutCurvePoint: c.fadeOutCurvePoint, timeStretchRate: c.timeStretchRate, + pitchShift: c.pitchShift, + gainEnvelope: c.gainEnvelope, + warpMarkers: c.warpMarkers, + stretchMode: c.stretchMode, })); this.engine.schedulePlayback(engineClips, fromTime, totalDuration); } + pauseAllSources(): void { + this.engine.stopAllSources(); + } + stopAllSources(): void { this.engine.stopAllSources(); } diff --git a/src/engine/bridge/__tests__/TauriBackend.test.ts b/src/engine/bridge/__tests__/TauriBackend.test.ts index f9175400f..43f0b774a 100644 --- a/src/engine/bridge/__tests__/TauriBackend.test.ts +++ b/src/engine/bridge/__tests__/TauriBackend.test.ts @@ -1,10 +1,59 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { TauriBackend } from '../TauriBackend'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@tauri-apps/api/event', () => ({ + listen: vi.fn().mockResolvedValue(vi.fn()), +})); + +const invokeMock = vi.mocked(invoke); +const listenMock = vi.mocked(listen); + +function createMockAudioBuffer(samples: number[], sampleRate = 48000): AudioBuffer { + const channel = Float32Array.from(samples); + return { + length: samples.length, + sampleRate, + numberOfChannels: 1, + getChannelData: vi.fn(() => channel), + } as unknown as AudioBuffer; +} + +function equalPowerPan(pan: number): { left: number; right: number } { + const clamped = Math.max(-1, Math.min(1, pan)); + const angle = ((clamped + 1) * Math.PI) / 4; + return { left: Math.cos(angle), right: Math.sin(angle) }; +} + +function deferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function flushTransportCommands(turns = 6): Promise { + for (let i = 0; i < turns; i++) { + await Promise.resolve(); + } +} describe('TauriBackend', () => { let backend: TauriBackend; beforeEach(() => { + vi.clearAllMocks(); + invokeMock.mockResolvedValue(undefined); + listenMock.mockResolvedValue(vi.fn()); backend = new TauriBackend(); }); @@ -18,7 +67,7 @@ describe('TauriBackend', () => { // ── Transport stubs return safe defaults ────────────────────────── - it('getCurrentTime returns 0 (stub)', () => { + it('getCurrentTime starts at 0', () => { expect(backend.getCurrentTime()).toBe(0); }); @@ -26,11 +75,17 @@ describe('TauriBackend', () => { expect(backend.getLookAhead()).toBe(0.1); }); - it('getCompensatedTime returns 0 (stub)', () => { + it('getCompensatedTime starts at 0', () => { expect(backend.getCompensatedTime()).toBe(0); }); - it('getPlaybackLatencyCompensation returns 0', () => { + it('stores playback latency compensation', () => { + backend.setPlaybackLatencyCompensation(0.25); + + expect(backend.getPlaybackLatencyCompensation()).toBe(0.25); + }); + + it('getPlaybackLatencyCompensation defaults to 0', () => { expect(backend.getPlaybackLatencyCompensation()).toBe(0); }); @@ -38,22 +93,22 @@ describe('TauriBackend', () => { it('getTrackMeter returns silent meter', () => { const meter = backend.getTrackMeter('any-track'); - expect(meter.level).toBe(-Infinity); + expect(meter.level).toBe(0); expect(meter.clipped).toBe(false); }); - it('getTrackLevel returns -Infinity', () => { - expect(backend.getTrackLevel('any-track')).toBe(-Infinity); + it('getTrackLevel returns silence by default', () => { + expect(backend.getTrackLevel('any-track')).toBe(0); }); it('getMasterMeter returns silent meter', () => { const meter = backend.getMasterMeter('output'); - expect(meter.level).toBe(-Infinity); + expect(meter.level).toBe(0); expect(meter.clipped).toBe(false); }); - it('getMasterLevel returns -Infinity', () => { - expect(backend.getMasterLevel('input')).toBe(-Infinity); + it('getMasterLevel returns silence by default', () => { + expect(backend.getMasterLevel('input')).toBe(0); }); it('getTrackSpectrum returns null', () => { @@ -74,8 +129,33 @@ describe('TauriBackend', () => { // ── Stub methods do not throw ───────────────────────────────────── - it('setMasterVolume does not throw', () => { - expect(() => backend.setMasterVolume(0.5)).not.toThrow(); + it('resume starts the native engine and transport listener', async () => { + invokeMock.mockResolvedValueOnce({ state: 'running' }); + invokeMock.mockResolvedValueOnce(96000); + + await backend.resume(); + + expect(invokeMock).toHaveBeenCalledWith('audio_start_engine', { + config: { sampleRate: 48000, bufferSize: 256, deviceName: null }, + }); + expect(invokeMock).toHaveBeenCalledWith('audio_transport_get_position'); + expect(listenMock).toHaveBeenCalledWith('transport-position', expect.any(Function)); + }); + + it('resume treats an already-running native engine as success', async () => { + invokeMock.mockRejectedValueOnce({ kind: 'alreadyRunning' }); + invokeMock.mockResolvedValueOnce(0); + + await expect(backend.resume()).resolves.toBeUndefined(); + + expect(listenMock).toHaveBeenCalledWith('transport-position', expect.any(Function)); + expect(invokeMock).toHaveBeenCalledWith('audio_transport_get_position'); + }); + + it('setMasterVolume invokes native master gain command', () => { + backend.setMasterVolume(0.5); + + expect(invokeMock).toHaveBeenCalledWith('audio_set_master_volume', { volume: 0.5 }); }); it('ensureTrack does not throw', () => { @@ -90,35 +170,47 @@ describe('TauriBackend', () => { expect(() => backend.setTrackParams('t1', { volume: 0.5 })).not.toThrow(); }); + it('preserves track params while native handle allocation is pending', async () => { + invokeMock.mockResolvedValueOnce({ slot: 2, generation: 1 }); + + backend.ensureTrack('track-1'); + backend.setTrackParams('track-1', { volume: 0.25, pan: -0.5, muted: true }); + await Promise.resolve(); + + expect(invokeMock).toHaveBeenCalledWith('audio_set_track_params', { + handle: { slot: 2, generation: 1 }, + params: { volume: 0.25, pan: -0.5, mute: true, solo: false }, + }); + }); + it('updateSoloState does not throw', () => { expect(() => backend.updateSoloState()).not.toThrow(); }); - it('stopAllSources does not throw', () => { - expect(() => backend.stopAllSources()).not.toThrow(); + it('stopAllSources clears the native schedule and stops transport', async () => { + await backend.stopAllSources(); + + expect(invokeMock).toHaveBeenCalledWith('audio_clip_set_schedule', { clips: [] }); + expect(invokeMock).toHaveBeenCalledWith('audio_transport_stop'); + }); + + it('pauseAllSources pauses native transport without clearing the schedule', async () => { + await backend.pauseAllSources(); + + expect(invokeMock).toHaveBeenCalledWith('audio_transport_pause'); + expect(invokeMock).not.toHaveBeenCalledWith('audio_clip_set_schedule', { clips: [] }); }); it('disposeAudioStream does not throw', () => { expect(() => backend.disposeAudioStream()).not.toThrow(); }); - it('dispose does not throw', () => { + it('dispose stops the native engine', () => { expect(() => backend.dispose()).not.toThrow(); + expect(invokeMock).toHaveBeenCalledWith('audio_stop_engine'); }); - // ── Methods that should throw (not yet implemented) ─────────────── - - it('resume rejects without Tauri runtime', async () => { - // resume() now calls invoke('audio_start_engine', ...) which - // rejects in a test environment because no Tauri webview - // context is available. The specific error message depends on - // the @tauri-apps/api internals — we only assert it rejects. - await expect(backend.resume()).rejects.toThrow(); - }); - - it('decodeAudioData throws (Rust engine not ready)', async () => { - await expect(backend.decodeAudioData(new Blob())).rejects.toThrow('not yet implemented'); - }); + // ── Methods that should throw ──────────────────────────────────── it('getAudioStream throws (not available in desktop)', () => { expect(() => backend.getAudioStream()).toThrow('not available in desktop'); @@ -133,4 +225,537 @@ describe('TauriBackend', () => { it('setOnEndedCallback does not throw', () => { expect(() => backend.setOnEndedCallback(() => {})).not.toThrow(); }); + + it('emits latency-compensated time updates from native transport events', async () => { + let positionHandler: ((event: { payload: number }) => void) | null = null; + listenMock.mockImplementation(async (_event, handler) => { + positionHandler = handler as (event: { payload: number }) => void; + return vi.fn(); + }); + const onTimeUpdate = vi.fn(); + + backend.setPlaybackLatencyCompensation(0.125); + backend.setTimeUpdateCallback(onTimeUpdate); + await Promise.resolve(); + positionHandler?.({ payload: 48000 }); + + expect(onTimeUpdate).toHaveBeenCalledWith(0.875); + }); + + it('schedulePlayback sends clips through native schedule and starts transport', async () => { + const buffer = createMockAudioBuffer([0.125, 0.25, 0.5, 1]); + const centerPan = equalPowerPan(0); + + backend.schedulePlayback([ + { + clipId: 'clip-1', + trackId: 'track-1', + startTime: 1, + buffer, + audioOffset: 0, + clipDuration: 4 / 48000, + }, + ], 1, 3); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(invokeMock).toHaveBeenCalledWith('audio_clip_set_schedule', { + clips: [ + { + startSample: 48000, + lengthSamples: 4, + gain: 1, + audioData: [ + 0.125 * centerPan.left, + 0.125 * centerPan.right, + 0.25 * centerPan.left, + 0.25 * centerPan.right, + 0.5 * centerPan.left, + 0.5 * centerPan.right, + centerPan.left, + centerPan.right, + ], + }, + ], + }); + expect(invokeMock).toHaveBeenCalledWith('audio_transport_seek', { samplePosition: 48000 }); + expect(invokeMock).toHaveBeenCalledWith('audio_transport_play'); + }); + + it('uses the scheduled seek sample even if transport events update the cache first', async () => { + let positionHandler: ((event: { payload: number }) => void) | null = null; + let resolveSchedule!: () => void; + invokeMock.mockImplementation((command) => { + if (command === 'audio_clip_set_schedule') { + return new Promise((resolve) => { + resolveSchedule = () => resolve(undefined); + }); + } + return Promise.resolve(undefined); + }); + listenMock.mockImplementation(async (_event, handler) => { + positionHandler = handler as (event: { payload: number }) => void; + return vi.fn(); + }); + const buffer = createMockAudioBuffer([1]); + backend.setTimeUpdateCallback(() => {}); + + backend.schedulePlayback([ + { + clipId: 'clip-1', + trackId: 'track-1', + startTime: 1, + buffer, + audioOffset: 0, + clipDuration: 1 / 48000, + }, + ], 1, 3); + await Promise.resolve(); + positionHandler?.({ payload: 96000 }); + resolveSchedule(); + await flushTransportCommands(); + + expect(invokeMock).toHaveBeenCalledWith('audio_transport_seek', { samplePosition: 48000 }); + }); + + it('does not let stale position refresh overwrite a scheduled seek', async () => { + const positionRefresh = deferred(); + invokeMock.mockImplementation((command) => { + if (command === 'audio_start_engine') return Promise.resolve({ state: 'running' }); + if (command === 'audio_transport_get_position') return positionRefresh.promise; + return Promise.resolve(undefined); + }); + const buffer = createMockAudioBuffer([1]); + + await backend.resume(); + backend.schedulePlayback([ + { + clipId: 'clip-1', + trackId: 'track-1', + startTime: 1, + buffer, + audioOffset: 0, + clipDuration: 1 / 48000, + }, + ], 1, 3); + expect(backend.getCurrentTime()).toBe(1); + + positionRefresh.resolve(0); + await Promise.resolve(); + await flushTransportCommands(); + + expect(backend.getCurrentTime()).toBe(1); + expect(invokeMock).toHaveBeenCalledWith('audio_transport_seek', { samplePosition: 48000 }); + }); + + it('ignores stale end-position events until the native seek and play land', async () => { + let positionHandler: ((event: { payload: number }) => void) | null = null; + const scheduleDeferred = deferred(); + invokeMock.mockImplementation((command) => { + if (command === 'audio_clip_set_schedule') return scheduleDeferred.promise; + return Promise.resolve(undefined); + }); + listenMock.mockImplementation(async (_event, handler) => { + positionHandler = handler as (event: { payload: number }) => void; + return vi.fn(); + }); + const onEnded = vi.fn(); + const buffer = createMockAudioBuffer([1]); + backend.setTimeUpdateCallback(() => {}); + backend.setOnEndedCallback(onEnded); + + backend.schedulePlayback([ + { + clipId: 'clip-1', + trackId: 'track-1', + startTime: 0, + buffer, + audioOffset: 0, + clipDuration: 1 / 48000, + }, + ], 0, 1 / 48000); + await Promise.resolve(); + positionHandler?.({ payload: 2 }); + + expect(onEnded).not.toHaveBeenCalled(); + expect(invokeMock).not.toHaveBeenCalledWith('audio_transport_stop'); + + scheduleDeferred.resolve(undefined); + await flushTransportCommands(); + positionHandler?.({ payload: 2 }); + + expect(onEnded).toHaveBeenCalledTimes(1); + }); + + it('applies cached track volume, pan, mute, and solo before native scheduling', async () => { + const audibleBuffer = createMockAudioBuffer([1, 1]); + const mutedBuffer = createMockAudioBuffer([1, 1]); + const pan = equalPowerPan(-0.5); + backend.ensureTrack('audible'); + backend.ensureTrack('muted'); + backend.setTrackParams('audible', { volume: 0.5, pan: -0.5, soloed: true }); + backend.setTrackParams('muted', { volume: 1, muted: true }); + + backend.schedulePlayback([ + { + clipId: 'clip-audible', + trackId: 'audible', + startTime: 0, + buffer: audibleBuffer, + audioOffset: 0, + clipDuration: 2 / 48000, + }, + { + clipId: 'clip-muted', + trackId: 'muted', + startTime: 0, + buffer: mutedBuffer, + audioOffset: 0, + clipDuration: 2 / 48000, + }, + ], 0, 1); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(invokeMock).toHaveBeenCalledWith('audio_clip_set_schedule', { + clips: [ + { + startSample: 0, + lengthSamples: 2, + gain: 1, + audioData: [0.5 * pan.left, 0.5 * pan.right, 0.5 * pan.left, 0.5 * pan.right], + }, + ], + }); + }); + + it('republishes active native schedule when track params change', async () => { + const buffer = createMockAudioBuffer([1, 1]); + const pan = equalPowerPan(0.5); + invokeMock.mockResolvedValueOnce({ slot: 0, generation: 1 }); + backend.ensureTrack('track-1'); + await Promise.resolve(); + + backend.schedulePlayback([ + { + clipId: 'clip-1', + trackId: 'track-1', + startTime: 0, + buffer, + audioOffset: 0, + clipDuration: 2 / 48000, + }, + ], 0, 1); + await flushTransportCommands(); + invokeMock.mockClear(); + + backend.setTrackParams('track-1', { volume: 0.25, pan: 0.5 }); + await flushTransportCommands(); + + expect(invokeMock).toHaveBeenCalledWith('audio_clip_set_schedule', { + clips: [ + { + startSample: 0, + lengthSamples: 2, + gain: 1, + audioData: [0.25 * pan.left, 0.25 * pan.right, 0.25 * pan.left, 0.25 * pan.right], + }, + ], + }); + }); + + it('does not resume stale playback after a stop interrupts scheduling', async () => { + let resolveFirstSchedule!: () => void; + let firstSchedule = true; + invokeMock.mockImplementation((command) => { + if (command === 'audio_clip_set_schedule' && firstSchedule) { + firstSchedule = false; + return new Promise((resolve) => { + resolveFirstSchedule = () => resolve(undefined); + }); + } + return Promise.resolve(undefined); + }); + const buffer = createMockAudioBuffer([1]); + + backend.schedulePlayback([ + { + clipId: 'clip-1', + trackId: 'track-1', + startTime: 0, + buffer, + audioOffset: 0, + clipDuration: 1 / 48000, + }, + ], 0, 1); + await Promise.resolve(); + backend.stopAllSources(); + resolveFirstSchedule(); + await flushTransportCommands(); + + expect(invokeMock).not.toHaveBeenCalledWith('audio_transport_play'); + }); + + it('fires ended callback from native transport position events', async () => { + let positionHandler: ((event: { payload: number }) => void) | null = null; + listenMock.mockImplementation(async (_event, handler) => { + positionHandler = handler as (event: { payload: number }) => void; + return vi.fn(); + }); + const onEnded = vi.fn(); + const buffer = createMockAudioBuffer([1]); + backend.setTimeUpdateCallback(() => {}); + backend.setOnEndedCallback(onEnded); + + backend.schedulePlayback([ + { + clipId: 'clip-1', + trackId: 'track-1', + startTime: 0, + buffer, + audioOffset: 0, + clipDuration: 1 / 48000, + }, + ], 0, 1 / 48000); + await flushTransportCommands(); + positionHandler?.({ payload: 1 }); + + expect(onEnded).toHaveBeenCalledTimes(1); + }); + + it('resamples non-48kHz buffers before sending native clips', async () => { + const buffer = createMockAudioBuffer([0, 1], 24000); + const centerPan = equalPowerPan(0); + + backend.schedulePlayback([ + { + clipId: 'clip-1', + trackId: 'track-1', + startTime: 0, + buffer, + audioOffset: 0, + clipDuration: 2 / 24000, + }, + ], 0, 1); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(invokeMock).toHaveBeenCalledWith('audio_clip_set_schedule', { + clips: [ + { + startSample: 0, + lengthSamples: 4, + gain: 1, + audioData: [ + 0, + 0, + 0.5 * centerPan.left, + 0.5 * centerPan.right, + centerPan.left, + centerPan.right, + centerPan.left, + centerPan.right, + ], + }, + ], + }); + }); + + it('refreshes track meters from native meter command', async () => { + invokeMock.mockResolvedValueOnce({ slot: 0, generation: 1 }); + backend.ensureTrack('track-1'); + await Promise.resolve(); + + invokeMock.mockResolvedValueOnce({ rms: 0.2, peak: 0.4, clipped: true }); + backend.getTrackMeter('track-1'); + await Promise.resolve(); + + expect(invokeMock).toHaveBeenCalledWith('audio_get_track_meter', expect.any(Object)); + }); + + it('resets native track clip state through Tauri IPC', async () => { + invokeMock.mockResolvedValueOnce({ slot: 0, generation: 1 }); + backend.ensureTrack('track-1'); + await Promise.resolve(); + invokeMock.mockResolvedValueOnce({ rms: 0.2, peak: 0.4, clipped: true }); + backend.getTrackMeter('track-1'); + await Promise.resolve(); + expect(backend.getTrackMeter('track-1').clipped).toBe(true); + invokeMock.mockClear(); + + backend.resetTrackClip('track-1'); + + expect(backend.getTrackMeter('track-1').clipped).toBe(false); + expect(invokeMock).toHaveBeenCalledWith('audio_reset_track_clip', { + handle: { slot: 0, generation: 1 }, + }); + }); + + it('resets native master clip state through Tauri IPC', async () => { + invokeMock.mockResolvedValueOnce({ rms: 0.2, peak: 0.4, clipped: true }); + backend.getMasterMeter('output'); + await Promise.resolve(); + expect(backend.getMasterMeter('output').clipped).toBe(true); + invokeMock.mockClear(); + + backend.resetMasterClip('output'); + + expect(backend.getMasterMeter('output').clipped).toBe(false); + expect(invokeMock).toHaveBeenCalledWith('audio_reset_master_clip'); + }); + + it('reflects active native clip audio in track meters', () => { + const buffer = createMockAudioBuffer([0.5]); + const centerPan = equalPowerPan(0); + + backend.schedulePlayback([ + { + clipId: 'clip-1', + trackId: 'track-1', + startTime: 0, + buffer, + audioOffset: 0, + clipDuration: 1 / 48000, + }, + ], 0, 1); + + const meter = backend.getTrackMeter('track-1'); + expect(meter.level).toBeCloseTo(0.5 * Math.max(centerPan.left, centerPan.right)); + expect(meter.leftLevel).toBeCloseTo(0.5 * centerPan.left); + expect(meter.rightLevel).toBeCloseTo(0.5 * centerPan.right); + }); + + it('sums overlapping native clip audio in simulated track meters', () => { + const bufferA = createMockAudioBuffer([0.75]); + const bufferB = createMockAudioBuffer([0.75]); + const centerPan = equalPowerPan(0); + + backend.schedulePlayback([ + { + clipId: 'clip-a', + trackId: 'track-1', + startTime: 0, + buffer: bufferA, + audioOffset: 0, + clipDuration: 1 / 48000, + }, + { + clipId: 'clip-b', + trackId: 'track-1', + startTime: 0, + buffer: bufferB, + audioOffset: 0, + clipDuration: 1 / 48000, + }, + ], 0, 1); + + const meter = backend.getTrackMeter('track-1'); + expect(meter.leftLevel).toBeCloseTo(1.5 * centerPan.left); + expect(meter.rightLevel).toBeCloseTo(1.5 * centerPan.right); + expect(meter.clipped).toBe(true); + }); + + it('preserves boosted native track volume when baking clip audio', async () => { + invokeMock.mockResolvedValueOnce({ slot: 0, generation: 1 }); + backend.ensureTrack('track-1'); + await flushTransportCommands(); + backend.setTrackParams('track-1', { volume: 1.25 }); + invokeMock.mockClear(); + + const buffer = createMockAudioBuffer([1]); + const centerPan = equalPowerPan(0); + backend.schedulePlayback([ + { + clipId: 'clip-1', + trackId: 'track-1', + startTime: 0, + buffer, + audioOffset: 0, + clipDuration: 1 / 48000, + }, + ], 0, 1); + await flushTransportCommands(); + + expect(invokeMock).toHaveBeenCalledWith('audio_clip_set_schedule', { + clips: [ + { + startSample: 0, + lengthSamples: 1, + gain: 1, + audioData: [1.25 * centerPan.left, 1.25 * centerPan.right], + }, + ], + }); + }); + + it('serializes stale native stop commands before a newer schedule', async () => { + const calls: string[] = []; + const clearSchedule = deferred(); + invokeMock.mockImplementation((command, args) => { + calls.push(command); + if ( + command === 'audio_clip_set_schedule' + && Array.isArray((args as { clips?: unknown[] } | undefined)?.clips) + && (args as { clips: unknown[] }).clips.length === 0 + ) { + return clearSchedule.promise; + } + return Promise.resolve(undefined); + }); + + backend.stopAllSources(); + await Promise.resolve(); + expect(calls).toEqual(['audio_clip_set_schedule']); + + const buffer = createMockAudioBuffer([0.5]); + backend.schedulePlayback([ + { + clipId: 'clip-1', + trackId: 'track-1', + startTime: 0, + buffer, + audioOffset: 0, + clipDuration: 1 / 48000, + }, + ], 0, 1); + await Promise.resolve(); + expect(calls).toEqual(['audio_clip_set_schedule']); + + clearSchedule.resolve(undefined); + await flushTransportCommands(); + + expect(calls).toEqual([ + 'audio_clip_set_schedule', + 'audio_clip_set_schedule', + 'audio_transport_seek', + 'audio_transport_play', + ]); + }); + + it('throttles track meter refreshes while a native request is in flight', async () => { + invokeMock.mockResolvedValueOnce({ slot: 0, generation: 1 }); + backend.ensureTrack('track-1'); + await Promise.resolve(); + invokeMock.mockClear(); + + invokeMock.mockResolvedValue({ rms: 0.2, peak: 0.4, clipped: false }); + backend.getTrackMeter('track-1'); + backend.getTrackMeter('track-1'); + + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock).toHaveBeenCalledWith('audio_get_track_meter', expect.any(Object)); + }); + + it('throttles master meter refreshes while a native request is in flight', () => { + invokeMock.mockResolvedValue({ rms: 0.2, peak: 0.4, clipped: false }); + + backend.getMasterMeter('output'); + backend.getMasterMeter('input'); + + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock).toHaveBeenCalledWith('audio_get_master_meter'); + }); }); diff --git a/src/engine/bridge/__tests__/WebAudioBackend.test.ts b/src/engine/bridge/__tests__/WebAudioBackend.test.ts index 5ed7319f3..73ed38c50 100644 --- a/src/engine/bridge/__tests__/WebAudioBackend.test.ts +++ b/src/engine/bridge/__tests__/WebAudioBackend.test.ts @@ -178,11 +178,50 @@ describe('WebAudioBackend', () => { expect(engine.schedulePlayback).toHaveBeenCalledWith([], 0, 10); }); + it('preserves advanced clip playback fields', () => { + const buffer = {} as AudioBuffer; + const gainEnvelope = [{ time: 0, gain: 0.5 }]; + const warpMarkers = [{ originalTime: 0, quantizedTime: 0 }]; + + backend.schedulePlayback([ + { + clipId: 'clip-1', + trackId: 'track-1', + startTime: 1, + buffer, + audioOffset: 0.25, + clipDuration: 2, + fadeInCurvePoint: { x: 0.2, y: 0.8 }, + fadeOutCurvePoint: { x: 0.7, y: 0.3 }, + pitchShift: 3, + gainEnvelope, + warpMarkers, + stretchMode: 'complexPro', + }, + ], 0, 4); + + expect(engine.schedulePlayback).toHaveBeenCalledWith([ + expect.objectContaining({ + fadeInCurvePoint: { x: 0.2, y: 0.8 }, + fadeOutCurvePoint: { x: 0.7, y: 0.3 }, + pitchShift: 3, + gainEnvelope, + warpMarkers, + stretchMode: 'complexPro', + }), + ], 0, 4); + }); + it('delegates stopAllSources', () => { backend.stopAllSources(); expect(engine.stopAllSources).toHaveBeenCalled(); }); + it('delegates pauseAllSources', () => { + backend.pauseAllSources(); + expect(engine.stopAllSources).toHaveBeenCalled(); + }); + // ── Audio Data ────────────────────────────────────────────────── it('delegates decodeAudioData', async () => { diff --git a/src/engine/bridge/__tests__/bridge-factory.test.ts b/src/engine/bridge/__tests__/bridge-factory.test.ts index 068a5495a..7255b0460 100644 --- a/src/engine/bridge/__tests__/bridge-factory.test.ts +++ b/src/engine/bridge/__tests__/bridge-factory.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { createBridge } from '../index'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { _resetAudioBridgeForTests, createBridge, getAudioBridge } from '../index'; import { WebAudioBackend } from '../WebAudioBackend'; +import { TauriBackend } from '../TauriBackend'; import type { AudioEngine } from '../../AudioEngine'; const fakeEngine = {} as AudioEngine; @@ -9,6 +10,8 @@ describe('createBridge', () => { afterEach(() => { delete (window as Record).__TAURI__; delete (window as Record).__TAURI_INTERNALS__; + vi.unstubAllEnvs(); + _resetAudioBridgeForTests(); }); it('returns WebAudioBackend when not in Tauri', () => { @@ -17,11 +20,36 @@ describe('createBridge', () => { expect(bridge.backend).toBe('web-audio'); }); - it('returns WebAudioBackend even inside Tauri shell (Phase 1 — Rust engine not ready)', () => { + it('returns WebAudioBackend inside Tauri shell when native audio gate is off', () => { (window as Record).__TAURI_INTERNALS__ = {}; const bridge = createBridge(fakeEngine); - // During Phase 1, always use WebAudioBackend until Rust engine is ready expect(bridge).toBeInstanceOf(WebAudioBackend); expect(bridge.backend).toBe('web-audio'); }); + + it('returns WebAudioBackend when native audio gate is on outside Tauri', () => { + vi.stubEnv('VITE_ENABLE_TAURI_AUDIO_BACKEND', 'true'); + + const bridge = createBridge(fakeEngine); + + expect(bridge).toBeInstanceOf(WebAudioBackend); + expect(bridge.backend).toBe('web-audio'); + }); + + it('returns TauriBackend inside Tauri shell when native audio gate is on', () => { + (window as Record).__TAURI_INTERNALS__ = {}; + vi.stubEnv('VITE_ENABLE_TAURI_AUDIO_BACKEND', 'true'); + + const bridge = createBridge(fakeEngine); + + expect(bridge).toBeInstanceOf(TauriBackend); + expect(bridge.backend).toBe('tauri'); + }); + + it('reuses the audio bridge singleton', () => { + const first = getAudioBridge(fakeEngine); + const second = getAudioBridge(fakeEngine); + + expect(second).toBe(first); + }); }); diff --git a/src/engine/bridge/index.ts b/src/engine/bridge/index.ts index 8d5b518e6..3a9c47476 100644 --- a/src/engine/bridge/index.ts +++ b/src/engine/bridge/index.ts @@ -18,19 +18,31 @@ import type { AudioBridge } from './types'; import type { AudioEngine } from '../AudioEngine'; import { WebAudioBackend } from './WebAudioBackend'; import { TauriBackend } from './TauriBackend'; -import { isTauri } from '../../utils/tauri'; +import { isTauriAudioBackendEnabled } from '../../utils/tauri'; /** * Create the appropriate AudioBridge for the current runtime. * - * During Phase 1 migration, always use the WebAudio-backed bridge, - * including inside the Tauri shell, until the Rust/Tauri backend - * fully implements the required AudioBridge lifecycle methods. + * The native Rust backend is opt-in while migration continues. Browser + * builds and Tauri shells without the gate keep the WebAudio backend. * * @param engine - The AudioEngine singleton used by WebAudioBackend. */ export function createBridge(engine: AudioEngine): AudioBridge { - // TODO: Switch to TauriBackend when Rust engine is ready (Phase 3+) - // if (isTauri()) return new TauriBackend(); + if (isTauriAudioBackendEnabled()) return new TauriBackend(); return new WebAudioBackend(engine); } + +let bridgeInstance: AudioBridge | null = null; + +export function getAudioBridge(engine: AudioEngine): AudioBridge { + if (!bridgeInstance) { + bridgeInstance = createBridge(engine); + } + return bridgeInstance; +} + +/** @internal Reset singleton for tests. */ +export function _resetAudioBridgeForTests(): void { + bridgeInstance = null; +} diff --git a/src/engine/bridge/types.ts b/src/engine/bridge/types.ts index 14f090f15..f3a7fdc2c 100644 --- a/src/engine/bridge/types.ts +++ b/src/engine/bridge/types.ts @@ -6,6 +6,11 @@ * In desktop mode it delegates to TauriBackend (IPC → Rust audio engine). */ import type { MasteringState } from '../../types/project'; +import type { + AudioWarpMarker, + GainEnvelopePoint, + StretchMode, +} from '../../types/project'; // ── Metering ──────────────────────────────────────────────────────── @@ -34,7 +39,13 @@ export interface BridgeClipInfo { fadeOutDuration?: number; fadeInCurve?: 'linear' | 'exponential' | 'equal-power'; fadeOutCurve?: 'linear' | 'exponential' | 'equal-power'; + fadeInCurvePoint?: { x: number; y: number }; + fadeOutCurvePoint?: { x: number; y: number }; timeStretchRate?: number; + pitchShift?: number; + gainEnvelope?: GainEnvelopePoint[]; + warpMarkers?: AudioWarpMarker[]; + stretchMode?: StretchMode; } // ── Track Parameters ──────────────────────────────────────────────── @@ -99,8 +110,9 @@ export interface AudioBridge { applyMastering(mastering: MasteringState | null | undefined): void; // ── Clip Scheduling ───────────────────────────────────────────── - schedulePlayback(clips: BridgeClipInfo[], fromTime: number, totalDuration: number): void; - stopAllSources(): void; + schedulePlayback(clips: BridgeClipInfo[], fromTime: number, totalDuration: number): void | Promise; + pauseAllSources(): void | Promise; + stopAllSources(): void | Promise; // ── Audio Data ────────────────────────────────────────────────── decodeAudioData(blob: Blob): Promise; @@ -200,3 +212,16 @@ export type NativeCommandError = | { kind: 'queueFull'; message: number } | { kind: 'disconnected' } | { kind: 'slotAllocatorFull'; message: number }; + +export interface NativeMeterReading { + rms: number; + peak: number; + clipped: boolean; +} + +export interface NativeClipSource { + startSample: number; + lengthSamples: number; + gain: number; + audioData: number[]; +} diff --git a/src/hooks/__tests__/useAudioEngine.test.ts b/src/hooks/__tests__/useAudioEngine.test.ts index f65c42dfb..2638d1eab 100644 --- a/src/hooks/__tests__/useAudioEngine.test.ts +++ b/src/hooks/__tests__/useAudioEngine.test.ts @@ -12,6 +12,11 @@ const mockResume = vi.fn(async () => {}); const mockSetTimeUpdateCallback = vi.fn(); const mockRefreshPlaybackLatencyCompensation = vi.fn(() => 5); const mockSetPlaybackLatencyCompensation = vi.fn(); +const mockBridge = vi.hoisted(() => ({ + backend: 'web-audio' as 'web-audio' | 'tauri', + resume: vi.fn(async () => {}), + setTimeUpdateCallback: vi.fn(), +})); vi.mock('../../engine/AudioEngine', () => { return { @@ -25,7 +30,18 @@ vi.mock('../../engine/AudioEngine', () => { }; }); -import { getAudioEngine, getExistingAudioEngine, _setAudioResumed, useAudioEngine } from '../useAudioEngine'; +vi.mock('../../engine/bridge', () => ({ + getAudioBridge: vi.fn(() => mockBridge), +})); + +import { + getAudioEngine, + getExistingAudioEngine, + getTauriPlaybackClockOwner, + setTauriPlaybackClockOwner, + _setAudioResumed, + useAudioEngine, +} from '../useAudioEngine'; import { useTransportStore } from '../../store/transportStore'; import { useProjectStore } from '../../store/projectStore'; @@ -36,6 +52,10 @@ describe('useAudioEngine', () => { useProjectStore.setState(useProjectStore.getInitialState(), true); useTransportStore.setState(useTransportStore.getInitialState(), true); useProjectStore.getState().createProject({ name: 'Audio Test' }); + mockBridge.backend = 'web-audio'; + mockBridge.resume.mockReset(); + mockBridge.setTimeUpdateCallback.mockReset(); + setTauriPlaybackClockOwner('web-audio'); }); afterEach(() => { @@ -72,6 +92,36 @@ describe('useAudioEngine', () => { expect(useTransportStore.getState().currentTime).toBe(5.5); }); + it('keeps the WebAudio clock active when the Tauri bridge is installed', () => { + mockBridge.backend = 'tauri'; + renderHook(() => useAudioEngine()); + + const engineCallback = mockSetTimeUpdateCallback.mock.calls[0][0]; + const bridgeCallback = mockBridge.setTimeUpdateCallback.mock.calls[0][0]; + + engineCallback(2.25); + expect(useTransportStore.getState().currentTime).toBe(2.25); + + bridgeCallback(3.5); + expect(useTransportStore.getState().currentTime).toBe(2.25); + expect(getTauriPlaybackClockOwner()).toBe('web-audio'); + }); + + it('uses the Tauri bridge clock when native playback owns transport', () => { + mockBridge.backend = 'tauri'; + setTauriPlaybackClockOwner('native'); + renderHook(() => useAudioEngine()); + + const engineCallback = mockSetTimeUpdateCallback.mock.calls[0][0]; + const bridgeCallback = mockBridge.setTimeUpdateCallback.mock.calls[0][0]; + + engineCallback(2.25); + expect(useTransportStore.getState().currentTime).toBe(0); + + bridgeCallback(3.5); + expect(useTransportStore.getState().currentTime).toBe(3.5); + }); + // ── resumeOnGesture ── it('resumes engine and starts Tone on gesture', async () => { @@ -84,6 +134,18 @@ describe('useAudioEngine', () => { expect(mockResume).toHaveBeenCalledTimes(1); }); + it('resumes WebAudio when the Tauri bridge is active', async () => { + mockBridge.backend = 'tauri'; + const { result } = renderHook(() => useAudioEngine()); + + await act(async () => { + await result.current.resumeOnGesture(); + }); + + expect(mockBridge.resume).toHaveBeenCalledTimes(1); + expect(mockResume).toHaveBeenCalledTimes(1); + }); + it('refreshes playback latency on resume', async () => { const { result } = renderHook(() => useAudioEngine()); diff --git a/src/hooks/__tests__/useTransport.strudel.test.ts b/src/hooks/__tests__/useTransport.strudel.test.ts index 40c45e882..41689f7e0 100644 --- a/src/hooks/__tests__/useTransport.strudel.test.ts +++ b/src/hooks/__tests__/useTransport.strudel.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ stop: vi.fn(), getCurrentTime: vi.fn(() => 3.5), setOnEndedCallback: vi.fn(), + stopAllSources: vi.fn(), trackNodes: new Map(), updateSoloState: vi.fn(), syncSends: vi.fn(), @@ -17,6 +18,10 @@ const mocks = vi.hoisted(() => ({ masterVolume: 1, playing: false, }, + tauriClock: { owner: 'web-audio' as 'native' | 'web-audio' }, + setTauriPlaybackClockOwner: vi.fn((owner: 'native' | 'web-audio') => { + mocks.tauriClock.owner = owner; + }), stopRecording: vi.fn(async () => {}), onLoopCycle: vi.fn(async () => {}), stopAllStrudelTracks: vi.fn(), @@ -26,6 +31,8 @@ const mocks = vi.hoisted(() => ({ vi.mock('tone', () => ({})); vi.mock('../useAudioEngine', () => ({ getAudioEngine: () => mocks.engine, + getTauriPlaybackClockOwner: () => mocks.tauriClock.owner, + setTauriPlaybackClockOwner: (owner: 'native' | 'web-audio') => mocks.setTauriPlaybackClockOwner(owner), })); vi.mock('../useRecording', () => ({ useRecording: () => ({ @@ -82,6 +89,7 @@ import { useUIStore } from '../../store/uiStore'; describe('useTransport strudel controls', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.tauriClock.owner = 'web-audio'; useProjectStore.getState().createProject('Transport Test'); useTransportStore.setState({ isPlaying: true, diff --git a/src/hooks/__tests__/useTransport.test.ts b/src/hooks/__tests__/useTransport.test.ts index 2f9568ea9..b12c23da5 100644 --- a/src/hooks/__tests__/useTransport.test.ts +++ b/src/hooks/__tests__/useTransport.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ play: vi.fn(async () => {}), schedulePlayback: vi.fn(), scheduleMetronome: vi.fn(), + stopAllSources: vi.fn(), clearBufferCache: vi.fn(), setMasterVolume: vi.fn(), ensureTrackNode: vi.fn(() => ({ @@ -33,6 +34,10 @@ const mocks = vi.hoisted(() => ({ setTrackMute: vi.fn(), setTrackSolo: vi.fn(), }, + tauriClock: { owner: 'web-audio' as 'native' | 'web-audio' }, + setTauriPlaybackClockOwner: vi.fn((owner: 'native' | 'web-audio') => { + mocks.tauriClock.owner = owner; + }), stopRecording: vi.fn(async () => {}), onLoopCycle: vi.fn(async () => {}), stopAllStrudelTracks: vi.fn(), @@ -48,6 +53,8 @@ vi.mock('tone', () => ({ })); vi.mock('../useAudioEngine', () => ({ getAudioEngine: () => mocks.engine, + getTauriPlaybackClockOwner: () => mocks.tauriClock.owner, + setTauriPlaybackClockOwner: (owner: 'native' | 'web-audio') => mocks.setTauriPlaybackClockOwner(owner), })); vi.mock('../useRecording', () => ({ useRecording: () => ({ @@ -123,11 +130,23 @@ vi.mock('../useToast', () => ({ toastError: vi.fn(), })); -import { useTransport } from '../useTransport'; +import { canUseNativeClipPlayback, useTransport } from '../useTransport'; import { useProjectStore } from '../../store/projectStore'; import { useTransportStore } from '../../store/transportStore'; import { useUIStore } from '../../store/uiStore'; +function nativeClipEntry(trackId: string, numberOfChannels = 1, clipDuration = 1) { + const sampleRate = 48000; + return { + clipId: 'clip-1', + trackId, + startTime: 0, + audioOffset: 0, + clipDuration, + buffer: { length: Math.ceil(clipDuration * sampleRate), numberOfChannels, sampleRate } as AudioBuffer, + }; +} + describe('useTransport', () => { beforeEach(() => { vi.clearAllMocks(); @@ -137,6 +156,7 @@ describe('useTransport', () => { useProjectStore.getState().createProject({ name: 'Transport Test' }); useUIStore.setState({ mainView: 'arrangement' }); mocks.engine.playing = false; + mocks.tauriClock.owner = 'web-audio'; }); // ── play() ── @@ -190,6 +210,92 @@ describe('useTransport', () => { ); }); + it('disables native clip playback when automation lanes are active', () => { + const project = useProjectStore.getState().project!; + expect(canUseNativeClipPlayback(project, [])).toBe(true); + + project.automationLanes = [{ + id: 'automation-1', + trackId: 'track-1', + parameter: { type: 'mixer', param: 'volume' }, + points: [ + { time: 0, value: 0.25 }, + { time: 1, value: 0.75 }, + ], + }]; + + expect(canUseNativeClipPlayback(project, [])).toBe(false); + }); + + it('disables native clip playback when enabled track plugins are active', () => { + useProjectStore.getState().addTrack('stems'); + const project = useProjectStore.getState().project!; + expect(canUseNativeClipPlayback(project, [])).toBe(true); + + project.tracks[0]!.plugins = [{ + id: 'plugin-1', + pluginId: 'ace-plugin', + enabled: true, + params: {}, + manifest: { + id: 'ace-plugin', + name: 'ACE Plugin', + pluginType: 'effect', + version: '1.0.0', + author: 'ACE', + description: 'Test plugin', + parameters: [], + }, + }]; + + expect(canUseNativeClipPlayback(project, [])).toBe(false); + }); + + it('disables native clip playback when WebAudio-rendered tracks are present', () => { + useProjectStore.getState().addTrack('pianoRoll'); + const project = useProjectStore.getState().project!; + + expect(canUseNativeClipPlayback(project, [])).toBe(false); + }); + + it('disables native clip playback for boosted track volume', () => { + useProjectStore.getState().addTrack('stems'); + const project = useProjectStore.getState().project!; + project.tracks[0]!.volume = 1.25; + + expect(canUseNativeClipPlayback(project, [nativeClipEntry(project.tracks[0]!.id)])).toBe(false); + }); + + it('disables native clip playback for panned stereo clips', () => { + useProjectStore.getState().addTrack('stems'); + const project = useProjectStore.getState().project!; + project.tracks[0]!.pan = 0.5; + + expect(canUseNativeClipPlayback(project, [nativeClipEntry(project.tracks[0]!.id, 2)])).toBe(false); + }); + + it('disables native clip playback for centered stereo clips', () => { + useProjectStore.getState().addTrack('stems'); + const project = useProjectStore.getState().project!; + + expect(canUseNativeClipPlayback(project, [nativeClipEntry(project.tracks[0]!.id, 2)])).toBe(false); + }); + + it('disables native clip playback when the native schedule exceeds Rust capacity', () => { + useProjectStore.getState().addTrack('stems'); + const project = useProjectStore.getState().project!; + const entries = Array.from({ length: 1025 }, () => nativeClipEntry(project.tracks[0]!.id)); + + expect(canUseNativeClipPlayback(project, entries)).toBe(false); + }); + + it('disables native clip playback when PCM payload would be too large', () => { + useProjectStore.getState().addTrack('stems'); + const project = useProjectStore.getState().project!; + + expect(canUseNativeClipPlayback(project, [nativeClipEntry(project.tracks[0]!.id, 1, 180)])).toBe(false); + }); + // ── pause() ── it('stops all engines and strudel when pausing', async () => { diff --git a/src/hooks/useAudioEngine.ts b/src/hooks/useAudioEngine.ts index cb64f6340..8f3d27237 100644 --- a/src/hooks/useAudioEngine.ts +++ b/src/hooks/useAudioEngine.ts @@ -2,15 +2,25 @@ import { useRef, useEffect, useCallback } from 'react'; import { AudioEngine } from '../engine/AudioEngine'; import { useTransportStore } from '../store/transportStore'; import { useProjectStore } from '../store/projectStore'; +import { getAudioBridge } from '../engine/bridge'; let _engineInstance: AudioEngine | null = null; let _audioResumed = false; +let _tauriPlaybackClockOwner: 'native' | 'web-audio' = 'web-audio'; /** @internal Set audio-resumed flag — for tests only */ export function _setAudioResumed(value: boolean) { _audioResumed = value; } +export function setTauriPlaybackClockOwner(owner: 'native' | 'web-audio') { + _tauriPlaybackClockOwner = owner; +} + +export function getTauriPlaybackClockOwner(): 'native' | 'web-audio' { + return _tauriPlaybackClockOwner; +} + export function getAudioEngine(): AudioEngine { if (!_engineInstance) { _engineInstance = new AudioEngine(); @@ -27,12 +37,23 @@ export function useAudioEngine() { useEffect(() => { const engine = engineRef.current; + const bridge = getAudioBridge(engine); engine.setTimeUpdateCallback((time) => { + if (bridge.backend === 'tauri' && _tauriPlaybackClockOwner === 'native') return; useTransportStore.getState().setCurrentTime(time); }); + if (bridge.backend === 'tauri') { + bridge.setTimeUpdateCallback((time) => { + if (_tauriPlaybackClockOwner !== 'native') return; + useTransportStore.getState().setCurrentTime(time); + }); + } return () => { engine.setTimeUpdateCallback(() => {}); + if (bridge.backend === 'tauri') { + bridge.setTimeUpdateCallback(() => {}); + } }; }, []); @@ -44,7 +65,12 @@ export function useAudioEngine() { // same context. The parallel `Promise.all([engine.resume(), // Tone.start()])` was redundant work. Verified by codex review // on PR #1727. - await engineRef.current.resume(); + const engine = engineRef.current; + const bridge = getAudioBridge(engine); + if (bridge.backend === 'tauri') { + await bridge.resume(); + } + await engine.resume(); const latency = engineRef.current.refreshPlaybackLatencyCompensation(); const store = (await import('../store/projectStore')).useProjectStore.getState(); store.detectPlaybackLatency(latency); diff --git a/src/hooks/useTransport.ts b/src/hooks/useTransport.ts index 477d858e3..c40aa2e74 100644 --- a/src/hooks/useTransport.ts +++ b/src/hooks/useTransport.ts @@ -2,7 +2,13 @@ import { useCallback, useEffect, useRef } from 'react'; import { useTransportStore } from '../store/transportStore'; import { useProjectStore } from '../store/projectStore'; import { useUIStore } from '../store/uiStore'; -import { getAudioEngine } from './useAudioEngine'; +import { + getAudioEngine, + getTauriPlaybackClockOwner, + setTauriPlaybackClockOwner, +} from './useAudioEngine'; +import { getAudioBridge } from '../engine/bridge'; +import type { AudioBridge } from '../engine/bridge/types'; import { loadAudioBlobByKey } from '../services/audioFileManager'; import { synthEngine } from '../engine/SynthEngine'; import { subtractiveEngine } from '../engine/SubtractiveEngine'; @@ -69,6 +75,102 @@ const DRUM_PAD_INDEX_BY_SAMPLE_KEY: Record = { perc: 15, }; +interface NativePlaybackEntry { + trackId?: string; + buffer?: Pick; + audioOffset?: number; + clipDuration?: number; + fadeInDuration?: number; + fadeOutDuration?: number; + fadeInCurvePoint?: { x: number; y: number }; + fadeOutCurvePoint?: { x: number; y: number }; + timeStretchRate?: number; + pitchShift?: number; + stretchMode?: import('../types/project').StretchMode; + gainEnvelope?: import('../types/project').GainEnvelopePoint[]; + warpMarkers?: import('../types/project').AudioWarpMarker[]; +} + +const NATIVE_MAX_CLIPS = 1024; +const NATIVE_MAX_PCM_FRAMES = 48000 * 10; + +function hasNonZero(value: number | undefined | null): boolean { + return typeof value === 'number' && Number.isFinite(value) && Math.abs(value) > 0.000001; +} + +function clipNeedsWebAudio(entry: NativePlaybackEntry): boolean { + return hasNonZero(entry.fadeInDuration) + || hasNonZero(entry.fadeOutDuration) + || entry.fadeInCurvePoint !== undefined + || entry.fadeOutCurvePoint !== undefined + || (entry.timeStretchRate !== undefined && Math.abs(entry.timeStretchRate - 1) > 0.000001) + || hasNonZero(entry.pitchShift) + || entry.stretchMode !== undefined + || (entry.gainEnvelope?.length ?? 0) > 0 + || (entry.warpMarkers?.length ?? 0) > 0; +} + +function getNativePcmFrameCount(entry: NativePlaybackEntry): number { + if (!entry.buffer) return 0; + const sourceRate = entry.buffer.sampleRate || 48000; + const sourceStart = Math.max(0, Math.round((entry.audioOffset ?? 0) * sourceRate)); + const availableFrames = Math.max(0, entry.buffer.length - sourceStart); + const sourceDuration = Math.min( + Math.max(0, entry.clipDuration ?? availableFrames / sourceRate), + availableFrames / sourceRate, + ); + return Math.max(0, Math.round(sourceDuration * 48000)); +} + +function trackNeedsWebAudio(track: Track): boolean { + const trackType = track.trackType ?? 'stems'; + return !['stems', 'sample', 'mix'].includes(trackType) + || track.isGroup === true + || track.parentTrackId !== undefined + || (typeof track.volume === 'number' && track.volume > 1.000001) + || track.panMode === 'dual-mono' + || hasNonZero(track.panLeft) + || hasNonZero(track.panRight) + || hasNonZero(track.eqLowGain) + || hasNonZero(track.eqMidGain) + || hasNonZero(track.eqHighGain) + || track.compressorEnabled === true + || hasNonZero(track.reverbMix) + || ((track.effects?.length ?? 0) > 0 && track.effectsBypassed !== true) + || (track.plugins?.some((plugin) => plugin.enabled !== false) ?? false) + || (track.sends?.some((send) => send.amount > 0.000001) ?? false); +} + +export function canUseNativeClipPlayback(project: Project, entries: NativePlaybackEntry[]): boolean { + if (entries.length > NATIVE_MAX_CLIPS) return false; + const totalNativeFrames = entries.reduce((sum, entry) => sum + getNativePcmFrameCount(entry), 0); + if (totalNativeFrames > NATIVE_MAX_PCM_FRAMES) return false; + if (project.mastering?.enabled) return false; + if ((project.returnTracks?.length ?? 0) > 0) return false; + if (project.automationLanes?.some((lane) => lane.points.length > 0)) return false; + if (entries.some(clipNeedsWebAudio)) return false; + const tracksById = new Map(project.tracks.map((track) => [track.id, track])); + if (entries.some((entry) => { + if (!entry.trackId || !entry.buffer) return false; + const track = tracksById.get(entry.trackId); + return entry.buffer.numberOfChannels > 1 || hasNonZero(track?.pan); + })) return false; + return !project.tracks.some(trackNeedsWebAudio); +} + +function getActivePlaybackTime( + engine: ReturnType, + bridge: AudioBridge, +): number { + if (!useTransportStore.getState().isPlaying) { + return useTransportStore.getState().currentTime; + } + if (bridge.backend === 'tauri' && getTauriPlaybackClockOwner() === 'native') { + return bridge.getCurrentTime(); + } + return engine.getCurrentTime(); +} + /** * Trim an AudioBuffer to a specific project-time region. * The input buffer may cover the full project duration; the output covers only @@ -170,8 +272,12 @@ export function useTransport() { const play = useCallback(async (fromTime?: number) => { const engine = getAudioEngine(); + const bridge = getAudioBridge(engine); stopStrudelEditorPlayback(); stopAllStrudelTracks(); + if (bridge.backend === 'tauri') { + await bridge.resume(); + } await engine.resume(); await synthEngine.ensureStarted(); await subtractiveEngine.ensureStarted(); @@ -190,9 +296,13 @@ export function useTransport() { // Sync master volume const nextProject = useProjectStore.getState().project; if (!nextProject) return; + const playbackLatencySeconds = getPlaybackLatencyCompensationSeconds(nextProject.playbackLatency); engine.masterVolume = nextProject.masterVolume ?? 1.0; - engine.setPlaybackLatencyCompensation(getPlaybackLatencyCompensationSeconds(nextProject.playbackLatency)); + engine.setPlaybackLatencyCompensation(playbackLatencySeconds); engine.applyMastering(nextProject.mastering); + bridge.setMasterVolume(nextProject.masterVolume ?? 1.0); + bridge.setPlaybackLatencyCompensation(playbackLatencySeconds); + bridge.applyMastering(nextProject.mastering); interface ScheduleEntry { clipId: string; @@ -220,8 +330,24 @@ export function useTransport() { // First pass: create all TrackNodes (groups first so children can route to them) for (const track of proj.tracks.filter((t) => t.isGroup)) { engine.getOrCreateTrackNode(track.id); + bridge.ensureTrack(track.id); } for (const track of proj.tracks) { + bridge.ensureTrack(track.id); + bridge.setTrackParams(track.id, { + volume: track.volume, + muted: track.muted, + soloed: track.soloed, + pan: track.pan ?? 0, + eqLowGain: track.eqLowGain ?? 0, + eqMidGain: track.eqMidGain ?? 0, + eqHighGain: track.eqHighGain ?? 0, + compressorEnabled: track.compressorEnabled ?? false, + compressorThreshold: track.compressorThreshold ?? -24, + compressorRatio: track.compressorRatio ?? 4, + reverbMix: track.reverbMix ?? 0, + reverbRoomSize: track.reverbRoomSize ?? 0.5, + }); const trackNode = engine.getOrCreateTrackNode(track.id); trackNode.volume = track.volume; trackNode.muted = track.muted; @@ -238,12 +364,13 @@ export function useTransport() { trackNode.setReverb(track.reverbMix ?? 0, track.reverbRoomSize ?? 0.5); // Route child tracks through their parent group bus engine.setTrackGroupRouting(track.id, track.parentTrackId ?? null); + bridge.setTrackGroupRouting(track.id, track.parentTrackId ?? null); // Frozen track: play frozen bounce instead of individual clips/MIDI if (track.frozen && track.frozenAudioKey && mainView !== 'session') { const frozenBlob = await loadAudioBlobByKey(track.frozenAudioKey); if (frozenBlob) { - const frozenBuffer = await engine.decodeAudioData(frozenBlob); + const frozenBuffer = await bridge.decodeAudioData(frozenBlob); clipBuffers.push({ clipId: `frozen-${track.id}`, trackId: track.id, @@ -280,7 +407,7 @@ export function useTransport() { } if (!blob) continue; - const rawBuffer = await engine.decodeAudioData(blob); + const rawBuffer = await bridge.decodeAudioData(blob); const buffer = alreadyTrimmed ? rawBuffer : trimBuffer(engine.ctx, rawBuffer, audibleStartTime, audibleDuration); @@ -351,6 +478,7 @@ export function useTransport() { } engine.updateSoloState(); + bridge.updateSoloState(); // Wire aux sends to return tracks engine.syncSends(proj.tracks, proj.returnTracks ?? []); @@ -374,10 +502,27 @@ export function useTransport() { if (lastClipEnd > 0) effectiveEnd = lastClipEnd; } - // Playback reads from stretchedBufferCache (populated on clip stretch mouseup). - // If Signalsmith/Rubber Band already finished → high quality buffer used. - // If neither finished yet → legacy fallback via _getProcessedBuffer. - engine.schedulePlayback(clipBuffers, startFrom, effectiveEnd); + const useNativeClipPlayback = bridge.backend === 'tauri' + && canUseNativeClipPlayback(nextProject, clipBuffers); + + if (useNativeClipPlayback) { + setTauriPlaybackClockOwner('native'); + await bridge.schedulePlayback(clipBuffers, startFrom, effectiveEnd); + // The native backend owns audio clip playback, but MIDI, synth, + // sequencer, automation, and Strudel still use AudioEngine's + // RAF clock until the Rust engine reaches full feature parity. + engine.schedulePlayback([], startFrom, effectiveEnd); + } else if (bridge.backend === 'tauri') { + setTauriPlaybackClockOwner('web-audio'); + await bridge.stopAllSources(); + engine.schedulePlayback(clipBuffers, startFrom, effectiveEnd); + } else { + setTauriPlaybackClockOwner('web-audio'); + // Playback reads from stretchedBufferCache (populated on clip stretch mouseup). + // If Signalsmith/Rubber Band already finished → high quality buffer used. + // If neither finished yet → legacy fallback via _getProcessedBuffer. + bridge.schedulePlayback(clipBuffers, startFrom, effectiveEnd); + } const { metronomeEnabled, metronomeSound, metronomeVolume } = useTransportStore.getState(); if (metronomeEnabled) { @@ -745,11 +890,14 @@ export function useTransport() { await stopRecording(); } const engine = getAudioEngine(); - const time = engine.getCurrentTime(); + const bridge = getAudioBridge(engine); + const time = getActivePlaybackTime(engine, bridge); finalizeSessionArrangementRecording(time); stopStrudelEditorPlayback(); stopAllStrudelTracks(); engine.stop(); + setTauriPlaybackClockOwner('web-audio'); + await bridge.pauseAllSources(); synthEngine.releaseAll(); subtractiveEngine.releaseAll(); wavetableEngine.releaseAll(); @@ -767,10 +915,13 @@ export function useTransport() { await stopRecording(); } const engine = getAudioEngine(); - const time = engine.playing ? engine.getCurrentTime() : useTransportStore.getState().currentTime; + const bridge = getAudioBridge(engine); + const time = getActivePlaybackTime(engine, bridge); finalizeSessionArrangementRecording(time); stopStrudelEditorPlayback(); engine.stop(); + setTauriPlaybackClockOwner('web-audio'); + await bridge.stopAllSources(); synthEngine.releaseAll(); subtractiveEngine.releaseAll(); wavetableEngine.releaseAll(); @@ -782,12 +933,15 @@ export function useTransport() { useTransportStore.getState().stop(); }, [finalizeSessionArrangementRecording, isRecording, stopRecording]); - const seek = useCallback((time: number) => { + const seek = useCallback(async (time: number) => { const engine = getAudioEngine(); + const bridge = getAudioBridge(engine); stopStrudelEditorPlayback(); stopAllStrudelTracks(); - if (engine.playing) { + if (engine.playing || (bridge.backend === 'tauri' && useTransportStore.getState().isPlaying)) { engine.stop(); + setTauriPlaybackClockOwner('web-audio'); + await bridge.stopAllSources(); synthEngine.releaseAll(); subtractiveEngine.releaseAll(); wavetableEngine.releaseAll(); @@ -795,7 +949,7 @@ export function useTransport() { granularEngine.releaseAll(); modulationEngine.releaseAll(); useTransportStore.getState().seek(time); - play(time); + await play(time); } else { useTransportStore.getState().seek(time); } @@ -803,6 +957,7 @@ export function useTransport() { const startScrub = useCallback(async (time: number) => { const engine = getAudioEngine(); + const bridge = getAudioBridge(engine); const transport = useTransportStore.getState(); const scrubProject = useProjectStore.getState().project; if (!scrubProject) return; @@ -813,6 +968,8 @@ export function useTransport() { const resumePlayback = transport.isPlaying || engine.playing; if (resumePlayback) { engine.stop(); + setTauriPlaybackClockOwner('web-audio'); + await bridge.stopAllSources(); synthEngine.releaseAll(); subtractiveEngine.releaseAll(); wavetableEngine.releaseAll(); @@ -935,7 +1092,8 @@ export function useTransport() { // Register the onEnded callback — respect loopEnabled useEffect(() => { const engine = getAudioEngine(); - engine.setOnEndedCallback(() => { + const bridge = getAudioBridge(engine); + const onEnded = () => { const { loopEnabled, isRecording, @@ -957,9 +1115,19 @@ export function useTransport() { } else { useTransportStore.getState().stop(); } + }; + engine.setOnEndedCallback(() => { + if (bridge.backend === 'tauri' && getTauriPlaybackClockOwner() === 'native') return; + onEnded(); }); + if (bridge.backend === 'tauri') { + bridge.setOnEndedCallback(onEnded); + } return () => { engine.setOnEndedCallback(() => {}); + if (bridge.backend === 'tauri') { + bridge.setOnEndedCallback(() => {}); + } }; }, [play, onLoopCycle]); @@ -967,10 +1135,49 @@ export function useTransport() { useEffect(() => { if (!playbackTracks || !isPlaying) return; const engine = getAudioEngine(); + const bridge = getAudioBridge(engine); + bridge.setMasterVolume(masterVolume); + bridge.setPlaybackLatencyCompensation(getPlaybackLatencyCompensationSeconds(playbackLatency)); + bridge.applyMastering(mastering); engine.masterVolume = masterVolume; engine.setPlaybackLatencyCompensation(getPlaybackLatencyCompensationSeconds(playbackLatency)); engine.applyMastering(mastering); + if ( + bridge.backend === 'tauri' + && getTauriPlaybackClockOwner() === 'native' + && ( + mastering?.enabled + || (playbackReturnTracks?.length ?? 0) > 0 + || (useProjectStore.getState().project?.automationLanes?.some((lane) => lane.points.length > 0) ?? false) + || playbackTracks.some(trackNeedsWebAudio) + ) + ) { + const restartTime = useTransportStore.getState().currentTime; + setTauriPlaybackClockOwner('web-audio'); + engine.stop(); + void (async () => { + await bridge.stopAllSources(); + await play(restartTime); + })(); + return; + } for (const track of playbackTracks) { + bridge.ensureTrack(track.id); + bridge.setTrackParams(track.id, { + volume: track.volume, + muted: track.muted, + soloed: track.soloed, + pan: track.pan ?? 0, + eqLowGain: track.eqLowGain ?? 0, + eqMidGain: track.eqMidGain ?? 0, + eqHighGain: track.eqHighGain ?? 0, + compressorEnabled: track.compressorEnabled ?? false, + compressorThreshold: track.compressorThreshold ?? -24, + compressorRatio: track.compressorRatio ?? 4, + reverbMix: track.reverbMix ?? 0, + reverbRoomSize: track.reverbRoomSize ?? 0.5, + }); + bridge.setTrackGroupRouting(track.id, track.parentTrackId ?? null); const trackNode = engine.trackNodes.get(track.id); if (trackNode) { trackNode.volume = track.volume; @@ -991,12 +1198,13 @@ export function useTransport() { } } engine.updateSoloState(); + bridge.updateSoloState(); // Sync aux send routing (handles amount, pre/post, and return track params) if (playbackTracks) { engine.syncSends(playbackTracks, playbackReturnTracks ?? []); } - }, [isPlaying, masterVolume, mastering, playbackLatency, playbackTracks, playbackReturnTracks]); + }, [isPlaying, masterVolume, mastering, play, playbackLatency, playbackTracks, playbackReturnTracks]); return { isPlaying, diff --git a/src/utils/__tests__/tauri.test.ts b/src/utils/__tests__/tauri.test.ts index 20a0c3dfe..ba4f8f32a 100644 --- a/src/utils/__tests__/tauri.test.ts +++ b/src/utils/__tests__/tauri.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; -import { isTauri, invokeTauri } from '../tauri'; +import { isTauri, isTauriAudioBackendEnabled, invokeTauri } from '../tauri'; describe('tauri bridge utilities', () => { afterEach(() => { @@ -7,6 +7,7 @@ describe('tauri bridge utilities', () => { delete (window as Record).__TAURI__; delete (window as Record).__TAURI_INTERNALS__; } + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -26,6 +27,27 @@ describe('tauri bridge utilities', () => { }); }); + describe('isTauriAudioBackendEnabled', () => { + it('returns false outside Tauri even when the native backend gate is enabled', () => { + vi.stubEnv('VITE_ENABLE_TAURI_AUDIO_BACKEND', 'true'); + + expect(isTauriAudioBackendEnabled()).toBe(false); + }); + + it('returns false inside Tauri when the native backend gate is disabled', () => { + (window as Record).__TAURI_INTERNALS__ = {}; + + expect(isTauriAudioBackendEnabled()).toBe(false); + }); + + it('returns true only inside Tauri with the native backend gate enabled', () => { + (window as Record).__TAURI_INTERNALS__ = {}; + vi.stubEnv('VITE_ENABLE_TAURI_AUDIO_BACKEND', 'true'); + + expect(isTauriAudioBackendEnabled()).toBe(true); + }); + }); + describe('invokeTauri', () => { it('returns null when not running in Tauri', async () => { const result = await invokeTauri('greet', { name: 'test' }); diff --git a/src/utils/tauri.ts b/src/utils/tauri.ts index 1a817cbbc..01a65cf18 100644 --- a/src/utils/tauri.ts +++ b/src/utils/tauri.ts @@ -13,6 +13,11 @@ export function isTauri(): boolean { return '__TAURI_INTERNALS__' in window || '__TAURI__' in window; } +/** Returns true when desktop should use the native Rust audio backend. */ +export function isTauriAudioBackendEnabled(): boolean { + return isTauri() && import.meta.env.VITE_ENABLE_TAURI_AUDIO_BACKEND === 'true'; +} + /** Invoke a Tauri command (no-op stub when running in browser). */ export async function invokeTauri( cmd: string, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 73e5dc60d..4b9e7fbdc 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -4,6 +4,7 @@ interface ImportMetaEnv { readonly VITE_SOURCE_CODE_URL?: string; readonly VITE_LICENSE_URL?: string; readonly VITE_COPYRIGHT_NOTICE?: string; + readonly VITE_ENABLE_TAURI_AUDIO_BACKEND?: string; } interface ImportMeta { diff --git a/tests/unit/levelMeter.test.tsx b/tests/unit/levelMeter.test.tsx index 5a10bfe32..ccd44b1b7 100644 --- a/tests/unit/levelMeter.test.tsx +++ b/tests/unit/levelMeter.test.tsx @@ -2,30 +2,71 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LevelMeter } from '../../src/components/mixer/LevelMeter'; -const engine = { - getTrackMeter: vi.fn(), - getMasterMeter: vi.fn(), - resetTrackClip: vi.fn(), - resetMasterClip: vi.fn(), - getTrackLevel: vi.fn(), - getMasterLevel: vi.fn(), -}; +const mocks = vi.hoisted(() => ({ + owner: 'web-audio' as 'native' | 'web-audio', + engine: { + getTrackMeter: vi.fn(), + getMasterMeter: vi.fn(), + resetTrackClip: vi.fn(), + resetMasterClip: vi.fn(), + getTrackLevel: vi.fn(), + getMasterLevel: vi.fn(), + }, + bridge: { + backend: 'tauri' as const, + getTrackMeter: vi.fn(), + getMasterMeter: vi.fn(), + resetTrackClip: vi.fn(), + resetMasterClip: vi.fn(), + }, +})); vi.mock('../../src/hooks/useAudioEngine', () => ({ - getAudioEngine: () => engine, + getAudioEngine: () => mocks.engine, + getTauriPlaybackClockOwner: () => mocks.owner, +})); + +vi.mock('../../src/engine/bridge', () => ({ + getAudioBridge: () => mocks.bridge, })); describe('LevelMeter', () => { + let rafCallbacks: Array; + beforeEach(() => { - engine.getTrackMeter.mockReset(); - engine.getMasterMeter.mockReset(); - engine.resetTrackClip.mockReset(); - engine.resetMasterClip.mockReset(); - engine.getTrackLevel.mockReset(); - engine.getMasterLevel.mockReset(); + mocks.owner = 'web-audio'; + rafCallbacks = []; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => ({ + createLinearGradient: () => ({ addColorStop: vi.fn() }), + clearRect: vi.fn(), + fillRect: vi.fn(), + save: vi.fn(), + beginPath: vi.fn(), + rect: vi.fn(), + clip: vi.fn(), + restore: vi.fn(), + }) as unknown as CanvasRenderingContext2D); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + rafCallbacks.push(cb); + return rafCallbacks.length; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + + mocks.engine.getTrackMeter.mockReset(); + mocks.engine.getMasterMeter.mockReset(); + mocks.engine.resetTrackClip.mockReset(); + mocks.engine.resetMasterClip.mockReset(); + mocks.engine.getTrackLevel.mockReset(); + mocks.engine.getMasterLevel.mockReset(); + mocks.bridge.getTrackMeter.mockReset(); + mocks.bridge.getMasterMeter.mockReset(); + mocks.bridge.resetTrackClip.mockReset(); + mocks.bridge.resetMasterClip.mockReset(); - engine.getTrackMeter.mockReturnValue({ level: 0.5, leftLevel: 0.4, rightLevel: 0.6, clipped: false }); - engine.getMasterMeter.mockReturnValue({ level: 0.3, clipped: false }); + mocks.engine.getTrackMeter.mockReturnValue({ level: 0.5, leftLevel: 0.4, rightLevel: 0.6, clipped: false }); + mocks.engine.getMasterMeter.mockReturnValue({ level: 0.3, clipped: false }); + mocks.bridge.getTrackMeter.mockReturnValue({ level: 0.9, leftLevel: 0.8, rightLevel: 0.9, clipped: false }); + mocks.bridge.getMasterMeter.mockReturnValue({ level: 0.7, clipped: false }); }); afterEach(() => { @@ -58,4 +99,32 @@ describe('LevelMeter', () => { // Stereo: BAR_WIDTH(4)*2 + BAR_GAP(1) + 6 = 15px expect(container.style.width).toBe('15px'); }); + + it('reads WebAudio meters when Tauri playback falls back to WebAudio', () => { + render(); + rafCallbacks.shift()?.(performance.now()); + + expect(mocks.engine.getTrackMeter).toHaveBeenCalledWith('track-1'); + expect(mocks.bridge.getTrackMeter).not.toHaveBeenCalled(); + }); + + it('reads native bridge meters when native playback owns the clock', () => { + mocks.owner = 'native'; + + render(); + rafCallbacks.shift()?.(performance.now()); + + expect(mocks.bridge.getTrackMeter).toHaveBeenCalledWith('track-1'); + expect(mocks.engine.getTrackMeter).not.toHaveBeenCalled(); + }); + + it('resets clip state on the active meter source', () => { + mocks.owner = 'native'; + + render(); + fireEvent.click(screen.getByTitle('Reset clip indicator')); + + expect(mocks.bridge.resetTrackClip).toHaveBeenCalledWith('track-1'); + expect(mocks.engine.resetTrackClip).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/useTransportScrubLifecycle.test.tsx b/tests/unit/useTransportScrubLifecycle.test.tsx index 807dd1783..3df282cf8 100644 --- a/tests/unit/useTransportScrubLifecycle.test.tsx +++ b/tests/unit/useTransportScrubLifecycle.test.tsx @@ -16,6 +16,7 @@ const engineMock = { startTimelineScrub: vi.fn().mockResolvedValue(undefined), updateTimelineScrub: vi.fn().mockResolvedValue(undefined), stopTimelineScrub: vi.fn(), + stopAllSources: vi.fn(), stop: vi.fn(), getCurrentTime: vi.fn(() => 6.5), setOnEndedCallback: vi.fn(), @@ -42,6 +43,8 @@ const engineMock = { vi.mock('../../src/hooks/useAudioEngine', () => ({ getAudioEngine: () => engineMock, + getTauriPlaybackClockOwner: () => 'web-audio', + setTauriPlaybackClockOwner: vi.fn(), })); vi.mock('../../src/engine/SynthEngine', () => ({ @@ -131,6 +134,7 @@ describe('useTransport scrub lifecycle', () => { }); expect(engineMock.stop).toHaveBeenCalledTimes(1); + expect(engineMock.stopAllSources).toHaveBeenCalledTimes(1); expect(useTransportStore.getState().isPlaying).toBe(false); expect(useTransportStore.getState().isScrubbing).toBe(true); expect(engineMock.startTimelineScrub).toHaveBeenCalledWith(expect.any(Array), expect.any(Array), 4, 0);