threlte logo
@threlte/extras

<PositionalAudio>

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>

<Canvas>
  <Scene />
</Canvas>
<script lang="ts">
  import { T, forwardEventHandlers } from '@threlte/core'
  import { Edges, Text, useCursor } from '@threlte/extras'
  import { spring } from 'svelte/motion'
  import { DEG2RAD } from 'three/src/math/MathUtils'

  export let text: string

  const buttonOffsetY = spring(0)

  let buttonColor = '#111111'
  let textColor = '#eedbcb'

  const { onPointerEnter, onPointerLeave } = useCursor()

  const component = forwardEventHandlers()
</script>

<T.Group {...$$restProps}>
  <T.Group position.y={0.05 - $buttonOffsetY}>
    <T.Mesh
      bind:this={$component}
      on:pointerenter={(e) => {
        e.stopPropagation()
        buttonColor = '#eedbcb'
        textColor = '#111111'
        onPointerEnter()
      }}
      on:pointerleave={(e) => {
        e.stopPropagation()
        buttonColor = '#111111'
        textColor = '#eedbcb'
        buttonOffsetY.set(0)
        onPointerLeave()
      }}
      on:pointerdown={(e) => {
        e.stopPropagation()
        buttonOffsetY.set(0.05)
      }}
      on:pointerup={(e) => {
        e.stopPropagation()
        buttonOffsetY.set(0)
      }}
    >
      <T.BoxGeometry args={[1.2, 0.1, 0.8]} />
      <T.MeshStandardMaterial color={buttonColor} />

      <Edges
        color="black"
        raycast={() => {
          return false
        }}
      />
    </T.Mesh>
    <Text
      renderOrder={-100}
      ignorePointer
      color={textColor}
      {text}
      rotation.x={DEG2RAD * -90}
      position.y={0.055}
      fontSize={0.35}
      anchorX="50%"
      anchorY="50%"
    />
  </T.Group>
</T.Group>
import type { Events, Props, Slots } from '@threlte/core'
import { SvelteComponentTyped } from 'svelte'
import type { Group } from 'three'

export default class Button extends SvelteComponentTyped<
	Props<Group>,
	Events<Group>,
	Slots<Group>
> {}
<script lang="ts">
  import { T, useFrame } from '@threlte/core'
  import { Edges, useGltf } from '@threlte/extras'
  import { derived } from 'svelte/store'
  import { Color, type Mesh } from 'three'

  export let discSpeed = 0

  let discRotation = 0
  const { start, stop, started } = useFrame(
    () => {
      discRotation += 0.02 * discSpeed
    },
    {
      autostart: false
    }
  )
  $: {
    if (discSpeed <= 0 && $started) stop()
    else if (discSpeed > 0 && !$started) start()
  }

  const gltf = useGltf<{
    nodes: {
      Logo: Mesh
    }
    materials: {}
  }>('/models/turntable/disc-logo.glb')
  const logoGeometry = derived(gltf, (gltf) => {
    if (!gltf) return undefined
    const mesh = gltf.nodes.Logo as Mesh
    return mesh.geometry
  })
</script>

<T.Group {...$$restProps}>
  <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={new Color('#ff3e00')}
          toneMapped={false}
        />
      </T.Mesh>
    {/if}
  </T.Group>
</T.Group>
import type { Events, Props, Slots } from '@threlte/core'
import { SvelteComponentTyped } from 'svelte'
import type { Group } from 'three'

export default class Disc extends SvelteComponentTyped<Props<Group>, Events<Group>, Slots<Group>> {}
<script lang="ts">
  import { T, useThrelte } from '@threlte/core'
  import { AudioListener, Environment, interactivity, OrbitControls } from '@threlte/extras'
  import { spring } from 'svelte/motion'
  import { DEG2RAD } from 'three/src/math/MathUtils'
  import Speaker from './Speaker.svelte'
  import Turntable from './Turntable.svelte'

  let volume = 0
  let isPlaying = false

  const smoothVolume = spring(0)
  $: smoothVolume.set(volume)

  const { size } = useThrelte()

  let zoom = $size.width / 18
  $: zoom = $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
  path="/hdr/"
  files="shanghai_riverside_1k.hdr"
/>

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

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

<Turntable
  bind:isPlaying
  bind:volume
/>

<Speaker
  position.x={6}
  rotation.y={DEG2RAD * -7}
  {volume}
/>
<Speaker
  position.x={-6}
  rotation.y={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 { T } from '@threlte/core'
	import { Edges } from '@threlte/extras'
	import { cubicIn, cubicOut } from 'svelte/easing'
	import { tweened } from 'svelte/motion'
	import { DEG2RAD } from 'three/src/math/MathUtils'

	export let volume: number = 0

	let jumpOffsetY = tweened(0)
	let jumpRotationX = tweened(0)
	let jumpRotationZ = tweened(0)
	let isJumping = 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)
	}

	$: if (volume > 0.25 && !isJumping) jump()
</script>

<T.Group {...$$restProps}>
	<T.Group
		position.y={$jumpOffsetY}
		rotation.z={DEG2RAD * $jumpRotationZ}
		rotation.x={DEG2RAD * $jumpRotationX}
	>
		<!-- 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={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>
import type { Events, Props, Slots } from '@threlte/core'
import { SvelteComponentTyped } from 'svelte'
import type { Group } from 'three'

export default class Speaker extends SvelteComponentTyped<
	Props<Group>,
	Events<Group>,
	Slots<Group>
> {}
<script lang="ts">
  import { T, useFrame } from '@threlte/core'
  import { Edges, PositionalAudio, useAudioListener, useCursor, useGltf } from '@threlte/extras'
  import { spring, tweened } from 'svelte/motion'
  import {
    BufferGeometry,
    CylinderGeometry,
    DoubleSide,
    Mesh,
    MeshStandardMaterial,
    PositionalAudio as ThreePositionalAudio
  } from 'three'
  import { DEG2RAD } from 'three/src/math/MathUtils'
  import Button from './Button.svelte'
  import Disc from './Disc.svelte'

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

  let armPos = spring(0)

  let started = false
  export let isPlaying = 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: ThreePositionalAudio
  const { context } = useAudioListener()
  const analyser = context.createAnalyser()
  $: if (audio) audio.getOutput().connect(analyser)
  const pcmData = new Float32Array(analyser.fftSize)
  export let volume = 0
  useFrame(() => {
    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 = sideA
  const changeSide = () => {
    source = source === sideA ? sideB : sideA
  }

  let coverOpen = false
  const coverAngle = spring(0)
  $: {
    if (coverOpen) coverAngle.set(80)
    else coverAngle.set(0)
  }

  const { onPointerEnter, onPointerLeave } = useCursor()

  const gltf = useGltf<{
    nodes: {
      Cover: Mesh
    }
    materials: {}
  }>('/models/turntable/cover.glb')
  let coverGeometry: BufferGeometry | undefined
  $: if ($gltf) {
    const coverMesh = $gltf.nodes.Cover as Mesh
    coverGeometry = coverMesh.geometry
  }
</script>

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

  <!-- 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"
    />
  </T.Mesh>

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

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

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

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

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

export default class Turntable extends SvelteComponentTyped<
  Props<Group>,
  Events<Group>,
  Slots<Group>
> {}
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
      autostart
      loop
      refDistance={10}
      volume={0.2}
      src={'/audio/car-noise.mp3'}
    />
  </Car>
</Canvas>

Component Signature

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

Props

name
type
required
default
description

src
string | AudioBuffer | HTMLMediaElement | AudioBufferSourceNode | MediaStream
yes

autoplay
boolean
no

detune
number
no

directionalCone
{ coneInnerAngle: number, coneOuterAngle: number, coneOuterGain: number }
no

distanceModel
string
no

id
string
no
default
The id of the AudioListener this Audio will be attached to.

loop
boolean
no

maxDistance
number
no

playbackRate
number
no

refDistance
number
no

rolloffFactor
number
no

volume
number
no

Events

name
payload
description

load
AudioBuffer
Fired when the audio has loaded.

progress
ProgressEvent<EventTarget>
Fired when the audio is loading.

error
ErrorEvent
Fired when the audio fails to load.

Bindings

name
type

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

pause
() => THREE.Audio

stop
() => THREE.Audio