threlte logo
@threlte/extras

<PositionalAudio>

webgl <PositionalAudio> works with WebGL and is available from the @threlte/extras export. webgpu <PositionalAudio> works with WebGPU and is available from the @threlte/extras/webgpu export.

Creates a positional audio entity. This uses the Web Audio API.

You need to have an <AudioListener> component in your scene in order to use <Audio>and <PositionalAudio>components. The <AudioListener> component needs to be mounted before any <Audio> or <PositionalAudio> components:

<T.PerspectiveCamera makeDefault>
  <AudioListener />
</T.PerspectiveCamera>

<PositionalAudio />
<script lang="ts">
  import { Canvas } from '@threlte/core'
  import Scene from './Scene.svelte'
</script>

<div>
  <Canvas>
    <Scene />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { T } from '@threlte/core'
  import { Edges, Text, useCursor } from '@threlte/extras'
  import { Spring } from 'svelte/motion'
  import { MathUtils } from 'three'
  import type { ButtonProps } from './types'

  let { text, onClick, ...rest }: ButtonProps = $props()

  const buttonOffsetY = new Spring(0)

  let buttonColor = $state('#111111')
  let textColor = $state('#eedbcb')

  const { onPointerEnter, onPointerLeave } = useCursor()
</script>

<T.Group {...rest}>
  <T.Group position.y={0.05 - buttonOffsetY.current}>
    <T.Mesh
      onclick={onClick}
      onpointerenter={(e) => {
        e.stopPropagation()
        buttonColor = '#eedbcb'
        textColor = '#111111'
        onPointerEnter()
      }}
      onpointerleave={(e) => {
        e.stopPropagation()
        buttonColor = '#111111'
        textColor = '#eedbcb'
        buttonOffsetY.set(0)
        onPointerLeave()
      }}
      onpointerdown={(e) => {
        e.stopPropagation()
        buttonOffsetY.set(0.05)
      }}
      onpointerup={(e) => {
        e.stopPropagation()
        buttonOffsetY.set(0)
      }}
    >
      <T.BoxGeometry args={[1.2, 0.1, 0.8]} />
      <T.MeshStandardMaterial color={buttonColor} />

      <Edges
        color="black"
        raycast={() => null}
      />
    </T.Mesh>
    <Text
      renderOrder={-100}
      ignorePointer
      color={textColor}
      {text}
      rotation.x={MathUtils.DEG2RAD * -90}
      position.y={0.055}
      fontSize={0.35}
      anchorX="50%"
      anchorY="50%"
    />
  </T.Group>
</T.Group>
<script lang="ts">
  import { T, useTask } from '@threlte/core'
  import { Edges, useGltf } from '@threlte/extras'
  import type { Mesh } from 'three'
  import type { DiscProps } from './types'

  let { discSpeed = 0, ...rest }: DiscProps = $props()

  let discRotation = $state(0)

  useTask(
    (delta) => {
      discRotation += delta * discSpeed
    },
    {
      running: () => discSpeed > 0
    }
  )

  const gltf = useGltf<{
    nodes: {
      Logo: Mesh
    }
    materials: {}
  }>('/models/turntable/disc-logo.glb')

  const logoGeometry = $derived($gltf?.nodes.Logo.geometry)
</script>

<T.Group {...rest}>
  <T.Group rotation.y={-discRotation}>
    <!-- DISH (?) -->
    <T.Mesh
      receiveShadow
      castShadow
      position.y={0.1}
    >
      <T.CylinderGeometry args={[1.85, 2, 0.2, 64]} />
      <T.MeshStandardMaterial color="#111111" />
      <Edges
        color="black"
        thresholdAngle={20}
      />
    </T.Mesh>

    <!-- ACTUAL DISC -->
    <T.Mesh
      receiveShadow
      castShadow
      position.y={0.2 + 0.05}
    >
      <T.CylinderGeometry args={[1.75, 1.75, 0.05, 64]} />
      <T.MeshStandardMaterial color="#111111" />
      <Edges
        thresholdAngle={50}
        scale={1}
        color="black"
      />
    </T.Mesh>

    <!-- ROUND LABEL -->
    <T.Mesh
      receiveShadow
      castShadow
      position.y={0.2 + 0.05 + 0.005}
    >
      <T.CylinderGeometry args={[0.8, 0.8, 0.05, 64]} />
      <T.MeshStandardMaterial color="#eedbcb" />
      <Edges
        thresholdAngle={50}
        scale={1}
        color="black"
      />
    </T.Mesh>

    <!-- LOGO -->
    {#if logoGeometry}
      <T.Mesh
        geometry={logoGeometry}
        position.y={0.2 + 0.05 + 0.025 + 0.01}
      >
        <T.MeshBasicMaterial
          color="#ff3e00"
          toneMapped={false}
        />
      </T.Mesh>
    {/if}
  </T.Group>
</T.Group>
<script lang="ts">
  import { T, useThrelte } from '@threlte/core'
  import { AudioListener, Environment, interactivity, OrbitControls } from '@threlte/extras'
  import { Spring } from 'svelte/motion'
  import { MathUtils } from 'three'
  import Speaker from './Speaker.svelte'
  import Turntable from './Turntable.svelte'

  let volume = $state(0)
  let isPlaying = $state(false)

  const smoothVolume = new Spring(0)
  $effect(() => {
    smoothVolume.set(volume)
  })

  const { size } = useThrelte()

  let zoom = $derived($size.width / 18)

  interactivity({
    filter: (hits) => {
      // only return first hit, we don't care
      // about propagation in this example
      return hits.slice(0, 1)
    }
  })
</script>

<Environment url="/textures/equirectangular/hdr/shanghai_riverside_1k.hdr" />

<T.OrthographicCamera
  {zoom}
  makeDefault
  position={[6, 9, 9]}
  oncreate={(ref) => {
    ref.lookAt(0, 1.5, 0)
  }}
>
  <OrbitControls
    autoRotate={isPlaying}
    autoRotateSpeed={0.5}
    enableDamping
    maxPolarAngle={MathUtils.DEG2RAD * 80}
    target.y={1.5}
  />
  <AudioListener />
</T.OrthographicCamera>

<!-- FLOOR -->
<T.Mesh
  receiveShadow
  rotation.x={MathUtils.DEG2RAD * -90}
>
  <T.CircleGeometry args={[10, 64]} />
  <T.MeshStandardMaterial color="#333333" />
</T.Mesh>

<Turntable
  bind:isPlaying
  bind:volume
/>

<Speaker
  position.x={6}
  rotation.y={MathUtils.DEG2RAD * -7}
  {volume}
/>
<Speaker
  position.x={-6}
  rotation.y={MathUtils.DEG2RAD * 7}
  {volume}
/>

<T.DirectionalLight
  castShadow
  shadow.camera.left={-10}
  shadow.camera.bottom={-10}
  shadow.camera.right={10}
  shadow.camera.top={10}
  position={[10, 20, 8]}
  intensity={0.3}
/>
<script lang="ts">
  import { MathUtils } from 'three'
  import { T } from '@threlte/core'
  import { Edges } from '@threlte/extras'
  import { cubicIn, cubicOut } from 'svelte/easing'
  import { Tween } from 'svelte/motion'
  import type { SpeakerProps } from './types'

  let { volume = 0, ...rest }: SpeakerProps = $props()

  let jumpOffsetY = new Tween(0)
  let jumpRotationX = new Tween(0)
  let jumpRotationZ = new Tween(0)
  let isJumping = $state(false)

  const randomSign = () => Math.round(Math.random()) * 2 - 1

  const jump = () => {
    isJumping = true
    const upDuration = 10 + Math.random() * 50

    jumpOffsetY.set(0.2, {
      duration: upDuration,
      easing: cubicOut
    })
    jumpRotationX.set(Math.random() * 4 * randomSign(), {
      duration: upDuration,
      easing: cubicOut
    })
    jumpRotationZ.set(Math.random() * 4 * randomSign(), {
      duration: upDuration,
      easing: cubicOut
    })

    setTimeout(() => {
      const downDuration = 40 + Math.random() * 70

      jumpOffsetY.set(0, {
        duration: downDuration,
        easing: cubicIn
      })
      jumpRotationX.set(0, {
        duration: downDuration,
        easing: cubicIn
      })
      jumpRotationZ.set(0, {
        duration: downDuration,
        easing: cubicIn
      })

      setTimeout(() => {
        isJumping = false
      }, downDuration * 1.5)
    }, upDuration)
  }

  $effect(() => {
    if (volume > 0.25 && !isJumping) jump()
  })
</script>

<T.Group {...rest}>
  <T.Group
    position.y={jumpOffsetY.current}
    rotation.z={MathUtils.DEG2RAD * jumpRotationZ.current}
    rotation.x={MathUtils.DEG2RAD * jumpRotationX.current}
  >
    <!-- CASE -->
    <T.Mesh
      castShadow
      receiveShadow
      position.y={2.5}
    >
      <T.BoxGeometry args={[3, 5, 3]} />
      <T.MeshStandardMaterial color="#eedbcb" />
      <Edges
        color={'black'}
        scale={1.001}
      />
    </T.Mesh>

    <!-- CONE -->
    <T.Mesh
      position.z={1.1}
      position.y={3.5}
      scale={1 + volume}
      rotation.x={MathUtils.DEG2RAD * -90}
    >
      <T.ConeGeometry args={[1, 1, 64]} />
      <T.MeshStandardMaterial
        flatShading
        color="#111111"
      />
      <Edges
        color="black"
        scale={1.001}
        thresholdAngle={20}
      />
    </T.Mesh>
  </T.Group>
</T.Group>
<script lang="ts">
  import { T, useTask } from '@threlte/core'
  import { Edges, PositionalAudio, useAudioListener, useCursor, useGltf } from '@threlte/extras'
  import { Spring, Tween } from 'svelte/motion'
  import { DoubleSide, Mesh, PositionalAudio as ThreePositionalAudio, MathUtils } from 'three'
  import Button from './Button.svelte'
  import Disc from './Disc.svelte'
  import type { TurntableProps } from './types'

  let { isPlaying = $bindable(false), volume = $bindable(0), ...rest }: TurntableProps = $props()

  let discSpeed = new Tween(0, {
    duration: 1e3
  })

  let armPos = new Spring(0)

  let started = $state(false)

  export const toggle = async () => {
    if (!started) {
      await context.resume()
      started = true
    }
    if (isPlaying) {
      discSpeed.set(0)
      armPos.set(0)
      isPlaying = false
    } else {
      discSpeed.set(1)
      armPos.set(1)
      isPlaying = true
    }
  }

  let audio = $state.raw<ThreePositionalAudio>()

  const { context } = useAudioListener()
  const analyser = context.createAnalyser()
  $effect(() => {
    if (audio) audio.getOutput().connect(analyser)
  })
  const pcmData = new Float32Array(analyser.fftSize)
  useTask(() => {
    if (!audio) return
    analyser.getFloatTimeDomainData(pcmData)
    let sumSquares = 0.0
    for (const amplitude of pcmData) {
      sumSquares += amplitude * amplitude
    }
    volume = Math.sqrt(sumSquares / pcmData.length)
  })

  let sideA = '/audio/side_a.mp3'
  let sideB = '/audio/side_b.mp3'
  let source = $state(sideA)
  const changeSide = () => {
    source = source === sideA ? sideB : sideA
  }

  let coverOpen = $state(false)
  const coverAngle = new Spring(0)
  $effect(() => {
    if (coverOpen) coverAngle.set(80)
    else coverAngle.set(0)
  })

  const { onPointerEnter, onPointerLeave } = useCursor()

  const gltf = useGltf<{
    nodes: {
      Cover: Mesh
    }
    materials: {}
  }>('/models/turntable/cover.glb')

  const coverGeometry = $derived($gltf?.nodes.Cover.geometry)
</script>

<T.Group {...rest}>
  <!-- DISC -->
  <Disc
    position.x={0.5}
    position.y={1.01}
    discSpeed={discSpeed.current}
  />

  <!-- CASE -->
  <T.Mesh
    receiveShadow
    castShadow
    position.y={0.5}
  >
    <T.BoxGeometry args={[6, 1, 4.4]} />
    <T.MeshStandardMaterial color="#eedbcb" />
    <Edges
      scale={1.001}
      color="black"
      raycast={() => null}
    />
  </T.Mesh>

  <!-- COVER -->
  <T.Group
    position.y={1}
    position.z={-2.2}
    rotation.x={-coverAngle.current * MathUtils.DEG2RAD}
  >
    {#if coverGeometry}
      <T.Mesh
        geometry={coverGeometry}
        scale={[3, 0.5, 2.2]}
        position.y={0.5}
        position.z={2.2}
        onclick={() => (coverOpen = !coverOpen)}
        onpointerenter={onPointerEnter}
        onpointerleave={onPointerLeave}
      >
        <T.MeshStandardMaterial
          color="#ffffff"
          roughness={0.08}
          metalness={0.8}
          envMapIntensity={1}
          side={DoubleSide}
          transparent
          opacity={0.65}
        />
        <Edges
          color="white"
          raycast={() => null}
        />
      </T.Mesh>
    {/if}
  </T.Group>

  <!-- SIDE BUTTON -->
  <Button
    position={[-2.3, 1.01, 0.8]}
    onClick={changeSide}
    text={source === sideA ? 'SIDE B' : 'SIDE A'}
  />

  <!-- PLAY/PAUSE BUTTON -->
  <Button
    position={[-2.3, 1.01, 1.7]}
    onClick={toggle}
    text={isPlaying ? 'PAUSE' : 'PLAY'}
  />

  <!-- ARM -->
  <T.Group
    position={[2.5, 1.55, -1.8]}
    rotation.z={MathUtils.DEG2RAD * 90}
    rotation.y={MathUtils.DEG2RAD * 90 - armPos.current * 0.3}
  >
    <T.Mesh
      castShadow
      position.y={1.5}
    >
      <T.CylinderGeometry args={[0.1, 0.1, 3, 12]} />
      <T.MeshStandardMaterial color="#ffffff" />
      <Edges
        color="black"
        thresholdAngle={80}
        raycast={() => null}
      />
    </T.Mesh>
  </T.Group>

  {#if started}
    <PositionalAudio
      autoplay
      bind:ref={audio}
      refDistance={15}
      loop
      playbackRate={discSpeed.current}
      src={source}
      directionalCone={{
        coneInnerAngle: 90,
        coneOuterAngle: 220,
        coneOuterGain: 0.3
      }}
    />
  {/if}
</T.Group>
import type { Props } from '@threlte/core'
import type { Group } from 'three'

export type TurntableProps = Props<Group> & {
  isPlaying?: boolean
  volume?: number
}

export type SpeakerProps = Props<Group> & {
  volume?: number
}

export type DiscProps = Props<Group> & {
  discSpeed?: number
}

export type ButtonProps = Props<Group> & {
  text: string
  onClick: () => void
}
Music: legrisch

Example

<script>
  import { T, Canvas } from '@threlte/core'
  import { AudioListener, PositionalAudio } from '@threlte/extras'
  import Car from './Car.svelte'
</script>

<Canvas>
  <T.PerspectiveCamera
    makeDefault
    position={[3, 3, 3]}
    lookAt={[0, 0, 0]}
  >
    <AudioListener />
  </T.PerspectiveCamera>

  <Car>
    <PositionalAudio
      loop
      refDistance={10}
      volume={0.2}
      src={'/audio/car-noise.mp3'}
    />
  </Car>
</Canvas>

Component Signature

<PositionalAudio> extends < T . PositionalAudio > and supports all its props, snippets, bindings and events.

Props

name
type
required
default
description

src
AudioSource
yes
The audio to play: a URL (loaded with three's AudioLoader), a decoded AudioBuffer, an <audio> or <video> element, an AudioBufferSourceNode, or a MediaStream such as a microphone.

autoplay
boolean
no
false
Starts playing as soon as src has loaded, resuming the audio context if needed.

detune
number
no
0
Pitch shift in cents, where 100 is one semitone. Only applies once a source is playing.

directionalCone
{ coneInnerAngle: number; coneOuterAngle: number; coneOuterGain: number; }
no
Makes the sound directional, pointing along the object's forward axis. Inside coneInnerAngle (degrees) the volume is unchanged, outside coneOuterAngle it is multiplied by coneOuterGain, and it blends in between. Omnidirectional when not set.

distanceModel
'linear' | 'inverse' | 'exponential'
no
'inverse'
The formula used to reduce the volume with distance. See https://developer.mozilla.org/en-US/docs/Web/API/PannerNode/distanceModel.

id
string
no
'default'
The id of the <AudioListener> to play through.

loop
boolean
no
false
Restarts playback when the end is reached.

maxDistance
number
no
10000
The distance beyond which the volume stops falling off. Only used by the 'linear' distance model. See https://developer.mozilla.org/en-US/docs/Web/API/PannerNode/maxDistance.

playbackRate
number
no
1
Playback speed, where 1 is normal speed. Also shifts the pitch.

refDistance
number
no
1
The distance at which the volume starts to fall off. See https://developer.mozilla.org/en-US/docs/Web/API/PannerNode/refDistance.

rolloffFactor
number
no
1
How quickly the volume falls off with distance. See https://developer.mozilla.org/en-US/docs/Web/API/PannerNode/rolloffFactor.

volume
number
no
1
The gain, where 1 is the original volume and 0 is silent. Values above 1 amplify and can clip.

Events

name
payload
description

onload
AudioBuffer | null
Called when src has loaded, with its buffer when it has one.

onerror
ErrorEvent
Called when src fails to load.

onprogress
ProgressEvent
Called with loading progress while a URL src downloads.

Exports

name
type

play
(delay?: number) => Promise<THREE.Audio>

pause
() => THREE.Audio

stop
() => THREE.Audio