Audio Sync With Character Animation

Hi,
I am trying to build an experience where an animated character moves around a VPS activated location, it’s going well so far but I’ve hit a a few hurdles putting the pieces together in 8th Wall Studio.
I have the character in the scene performing with their body and facial mocap but crucially I need to be able to sync the audio voice over to match up with the rest of the performance.
Is there a solution to have the audio file and animation clip start at the same time?

Thanks heaps in advance!
Noel

Hi, welcome to the forum!

Your best bet is putting the audio component on the same entity that has the model then making a custom component that sets the time property of the Audio component to the same time as the GLTFModel component. This assumes that both the Audio and Animation have the same length.

Alternatively hook it up with a custom component so that the audio doesn’t play until the GLTFModelLoaded event is fired on the entity.

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}

1 Like

This is what the logs look like:

1 Like

This topic was automatically closed 4 days after the last reply. New replies are no longer allowed.