Thanks for your reply George!
Here’s the solution I landed on in the hopes that it’ll help someone else!
I went the custom component route, one of the key methods was to wait for the VPS location to be found to trigger the animation and audio at the same time.
Custom component code:
// This is a component file. You can use this file to define a custom component for your project.
// This component will appear as a custom component in the editor.
import * as ecs from '@8thwall/ecs'
const SyncComponent = ecs.registerComponent({
name: 'Sync',
schema: {
// @label Audio File Path
// @asset
audioUrl: ecs.string,
// @label Audio Delay (ms)
// @min 0
// @max 2000
audioDelayMs: ecs.i32,
// @label Audio Volume
// @min 0
// @max 1
volume: ecs.f32,
// @label Loop Audio
loopAudio: ecs.boolean,
// @label Animation Clip Name
animationClip: ecs.string,
},
schemaDefaults: {
audioUrl: 'assets/Audio.wav',
audioDelayMs: 500,
volume: 1.0,
loopAudio: true,
animationClip: 'Animation',
},
data: {
audioCreated: ecs.boolean,
modelReady: ecs.boolean,
audioDelayTimeout: ecs.i32,
locationFound: ecs.boolean,
lastAnimationTime: ecs.f32,
isPlaying: ecs.boolean,
},
add: (world, component) => {
// Initialize data
if (component.data) {
component.data.audioCreated = false
component.data.modelReady = false
component.data.audioDelayTimeout = 0
component.data.locationFound = false
component.data.lastAnimationTime = 0
component.data.isPlaying = false
}
console.log('Sync component added - waiting for model and location')
},
tick: (world, component) => {
// Monitor animation loops by checking animation time
if (!component.data || !component.data.audioCreated || !component.data.isPlaying) return
const model = ecs.GltfModel.get(world, component.eid)
if (!model || model.paused) return
const currentAnimTime = model.time || 0
const lastAnimTime = component.data.lastAnimationTime || 0
// Detect animation loop (time reset to 0 or decreased significantly)
if (lastAnimTime > 1.0 && currentAnimTime < 0.5) {
console.log('Animation looped detected - restarting audio sync')
console.log('Animation time went from', lastAnimTime.toFixed(2), 'to', currentAnimTime.toFixed(2))
// Clear any pending timeout
if (component.data.audioDelayTimeout) {
world.time.clearTimeout(component.data.audioDelayTimeout)
}
const syncComponent = SyncComponent.get(world, component.eid)
const audio = ecs.Audio.get(world, component.eid)
const delayMs = syncComponent?.audioDelayMs ?? 500
console.log('Using delay for loop restart:', delayMs, 'ms')
if (audio) {
// Pause and reset audio
ecs.Audio.set(world, component.eid, {
...audio,
paused: true,
})
// Start audio again after delay
component.data.audioDelayTimeout = world.time.setTimeout(() => {
const currentAudio = ecs.Audio.get(world, component.eid)
if (currentAudio) {
ecs.Audio.set(world, component.eid, {
...currentAudio,
paused: false,
})
console.log('Audio restarted after animation loop with', delayMs, 'ms delay')
}
}, delayMs)
}
}
component.data.lastAnimationTime = currentAnimTime
},
remove: (world, component) => {
// Clean up any pending timeouts
if (component.data && component.data.audioDelayTimeout) {
world.time.clearTimeout(component.data.audioDelayTimeout)
}
console.log('Sync component removed')
},
stateMachine: ({world, eid, schemaAttribute, dataAttribute}) => {
ecs.defineState('waiting')
.initial()
.onEnter(() => {
console.log('Waiting for GLTF model to load and VPS location to be found')
})
.listen(eid, ecs.events.GLTF_MODEL_LOADED, (properties) => {
console.log('Model loaded:', properties.name)
// Get the component to access schema properly
const syncComponent = SyncComponent.get(world, eid)
// Set up the model with animation paused
ecs.GltfModel.set(world, eid, {
animationClip: syncComponent?.animationClip ?? 'Animation',
loop: syncComponent?.loopAudio ?? true,
paused: true, // Keep paused until location found
})
// Create audio (also paused)
ecs.Audio.set(world, eid, {
url: syncComponent?.audioUrl ?? 'assets/Audio.wav',
volume: syncComponent?.volume ?? 1.0,
loop: syncComponent?.loopAudio ?? true,
paused: true, // Keep paused until location found
positional: false,
})
dataAttribute.audioCreated = true
dataAttribute.modelReady = true
console.log('Model and audio setup complete - both paused, waiting for location found')
console.log('Animation clip set to:', syncComponent?.animationClip ?? 'Animation')
console.log('Audio URL set to:', syncComponent?.audioUrl ?? 'assets/Audio.wav')
// Check if we can start (if location was already found)
if (dataAttribute.locationFound) {
console.log('Location was already found - starting animation and audio')
startAnimationAndAudio(world, eid, syncComponent?.audioDelayMs ?? 500)
}
})
.listen(world.events.globalId, 'reality.locationfound', (e) => {
console.log('VPS Location found:', e.name)
dataAttribute.locationFound = true
// Check if we can start (if model is ready)
if (dataAttribute.modelReady && dataAttribute.audioCreated) {
const syncComponent = SyncComponent.get(world, eid)
console.log('Model is ready - starting animation and audio')
console.log('Using delay value:', syncComponent?.audioDelayMs)
startAnimationAndAudio(world, eid, syncComponent?.audioDelayMs ?? 500)
} else {
console.log('Model not ready yet - will start when loaded')
}
})
// Helper function to start both animation and audio
function startAnimationAndAudio(world, eid, delayMs) {
// Start the animation
const model = ecs.GltfModel.get(world, eid)
if (model) {
ecs.GltfModel.set(world, eid, {
...model,
paused: false,
})
console.log('Animation started')
dataAttribute.isPlaying = true
}
// Start audio after delay
dataAttribute.audioDelayTimeout = world.time.setTimeout(() => {
const audio = ecs.Audio.get(world, eid)
if (audio) {
ecs.Audio.set(world, eid, {
...audio,
paused: false,
})
console.log('Audio started after', delayMs, 'ms delay')
}
}, delayMs)
}
},
})
export {SyncComponent}