@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>
<div>
<Canvas>
<Scene />
</Canvas>
</div>
<style>
div {
height: 100%;
}
</style>
<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.js'
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 { SvelteComponent } from 'svelte'
import type { Group } from 'three'
export default class Button extends SvelteComponent<Props<Group>, Events<Group>, Slots<Group>> {}
<script lang="ts">
import { T, useTask } 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 } = useTask(
(delta) => {
discRotation += delta * 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 { SvelteComponent } from 'svelte'
import type { Group } from 'three'
export default class Disc extends SvelteComponent<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.js'
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.js'
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 { SvelteComponent } from 'svelte'
import type { Group } from 'three'
export default class Speaker extends SvelteComponent<Props<Group>, Events<Group>, Slots<Group>> {}
<script lang="ts">
import { T, useTask } 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.js'
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
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 = 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 { SvelteComponent } from 'svelte'
import type { Group } from 'three'
export default class Turntable extends SvelteComponent<Props<Group>, Events<Group>, Slots<Group>> {}
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.