From 7a4792e4e855eb84b4830d9aae4520ad84004bb1 Mon Sep 17 00:00:00 2001 From: bmathews Date: Sat, 15 Nov 2025 16:33:18 -0800 Subject: [PATCH] Add audio --- app/page.tsx | 9 +- src/components/AudioContext.tsx | 66 ++++++++++++ src/components/AudioEmitter.tsx | 147 +++++++++++++++++++++++++++ src/components/InspectorControls.tsx | 13 +++ src/components/SettingsProvider.tsx | 6 +- src/components/renderObject.tsx | 2 + src/loaders.ts | 4 + 7 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 src/components/AudioContext.tsx create mode 100644 src/components/AudioEmitter.tsx diff --git a/app/page.tsx b/app/page.tsx index 46ca25c9..00e1b62d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,6 +9,7 @@ import { ObserverControls } from "@/src/components/ObserverControls"; import { InspectorControls } from "@/src/components/InspectorControls"; import { SettingsProvider } from "@/src/components/SettingsProvider"; import { ObserverCamera } from "@/src/components/ObserverCamera"; +import { AudioProvider } from "@/src/components/AudioContext"; // three.js has its own loaders for textures and models, but we need to load other // stuff too, e.g. missions, terrains, and more. This client is used for those. @@ -35,9 +36,11 @@ function MapInspector() {
- - - + + + + + diff --git a/src/components/AudioContext.tsx b/src/components/AudioContext.tsx new file mode 100644 index 00000000..aa50c678 --- /dev/null +++ b/src/components/AudioContext.tsx @@ -0,0 +1,66 @@ +import { + createContext, + useContext, + useEffect, + useState, + ReactNode, +} from "react"; +import { useThree } from "@react-three/fiber"; +import { AudioListener, AudioLoader } from "three"; + +interface AudioContextType { + audioLoader: AudioLoader | null; + audioListener: AudioListener | null; +} + +const AudioContext = createContext(undefined); + +/** + * AudioProvider initializes the AudioLoader and AudioListener for spatial audio. + * Must be rendered inside the Canvas component. + */ +export function AudioProvider({ children }: { children: ReactNode }) { + const { camera } = useThree(); + const [audioContext, setAudioContext] = useState({ + audioLoader: null, + audioListener: null, + }); + + useEffect(() => { + // Create audio loader + const audioLoader = new AudioLoader(); + + // Create listener if not already present + let listener = camera.children.find( + (child) => child instanceof AudioListener + ) as AudioListener | undefined; + + if (!listener) { + listener = new AudioListener(); + camera.add(listener); + } + + setAudioContext({ + audioLoader, + audioListener: listener, + }); + }, [camera]); + + return ( + + {children} + + ); +} + +/** + * Hook to access audio resources (AudioLoader and AudioListener). + * Must be used within an AudioProvider. + */ +export function useAudio(): AudioContextType { + const context = useContext(AudioContext); + if (context === undefined) { + throw new Error("useAudio must be used within AudioProvider"); + } + return context; +} diff --git a/src/components/AudioEmitter.tsx b/src/components/AudioEmitter.tsx new file mode 100644 index 00000000..2a558893 --- /dev/null +++ b/src/components/AudioEmitter.tsx @@ -0,0 +1,147 @@ +import { useEffect, useRef } from "react"; +import { useThree } from "@react-three/fiber"; +import { PositionalAudio, Audio } from "three"; +import { ConsoleObject, getPosition, getProperty } from "../mission"; +import { audioToUrl } from "../loaders"; +import { useAudio } from "./AudioContext"; +import { useSettings } from "./SettingsProvider"; + +export function AudioEmitter({ object }: { object: ConsoleObject }) { + const fileName = getProperty(object, "fileName")?.value ?? ""; + const volume = parseFloat(getProperty(object, "volume")?.value ?? "1"); + const minDistance = parseFloat( + getProperty(object, "minDistance")?.value ?? "1" + ); + const maxDistance = parseFloat( + getProperty(object, "maxDistance")?.value ?? "1" + ); + const minLoopGap = parseFloat( + getProperty(object, "minLoopGap")?.value ?? "0" + ); + const maxLoopGap = parseFloat( + getProperty(object, "maxLoopGap")?.value ?? "0" + ); + const is3D = parseInt(getProperty(object, "is3D")?.value ?? "0"); + + const [z, y, x] = getPosition(object); + const { scene } = useThree(); + const { audioLoader, audioListener } = useAudio(); + const { audioEnabled } = useSettings(); + const soundRef = useRef(null); + const loopTimerRef = useRef(null); + const loopGapIntervalRef = useRef(null); + + useEffect(() => { + if (!fileName || !audioLoader || !audioListener || !audioEnabled) { + if (!fileName) { + console.warn("AudioEmitter: No fileName provided"); + } + if (!audioLoader) { + console.warn("AudioEmitter: No audio loader available"); + } + if (!audioListener) { + console.warn("AudioEmitter: No audio listener available"); + } + return; + } + + const audioUrl = audioToUrl(fileName); + + let sound; + + // Configure distance properties + if (is3D) { + sound = new PositionalAudio(audioListener); + sound.position.set(x - 1024, y, z - 1024); + sound.setDistanceModel("exponential"); + sound.setRefDistance(minDistance / 25); + sound.setMaxDistance(maxDistance / 50); + sound.setVolume(volume); + } else { + sound = new Audio(audioListener); + sound.setVolume(Math.min(volume, 0.5)); + } + + soundRef.current = sound; + + // Setup looping with gap + const setupLooping = () => { + if (minLoopGap > 0 || maxLoopGap > 0) { + const gapMin = Math.max(0, minLoopGap); + const gapMax = Math.max(gapMin, maxLoopGap); + const gap = + gapMin === gapMax + ? gapMin + : Math.random() * (gapMax - gapMin) + gapMin; + + sound.loop = false; + + // Check periodically when audio ends. onEnded wasn't working + const checkLoop = () => { + if (sound.isPlaying === false) { + loopTimerRef.current = setTimeout(() => { + try { + sound.play(); + setupLooping(); + } catch (err) {} + }, gap); + } else { + loopGapIntervalRef.current = setTimeout(checkLoop, 100); + } + }; + loopGapIntervalRef.current = setTimeout(checkLoop, 100); + } else { + sound.setLoop(true); + } + }; + + // Load and play audio + audioLoader.load( + audioUrl, + (audioBuffer: any) => { + sound.setBuffer(audioBuffer); + + try { + sound.play(); + setupLooping(); + } catch (err) {} + }, + undefined, + (err: any) => {} + ); + + // Add to scene + scene.add(sound); + + return () => { + if (loopTimerRef.current) { + clearTimeout(loopTimerRef.current); + } + if (loopGapIntervalRef.current) { + clearTimeout(loopGapIntervalRef.current); + } + try { + sound.stop(); + } catch (e) { + // May fail if already stopped + } + sound.disconnect(); + scene.remove(sound); + }; + }, [ + fileName, + volume, + minLoopGap, + maxLoopGap, + is3D, + minDistance, + maxDistance, + audioLoader, + audioListener, + audioEnabled, + scene, + ]); + + // Render debug visualization and invisible marker + return null; +} diff --git a/src/components/InspectorControls.tsx b/src/components/InspectorControls.tsx index 36a3d6d4..4d0a5406 100644 --- a/src/components/InspectorControls.tsx +++ b/src/components/InspectorControls.tsx @@ -27,6 +27,8 @@ export function InspectorControls({ setSpeedMultiplier, fov, setFov, + audioEnabled, + setAudioEnabled, } = useSettings(); return ( @@ -56,6 +58,17 @@ export function InspectorControls({ /> +
+ { + setAudioEnabled(event.target.checked); + }} + /> + +
({ @@ -25,8 +27,10 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { setSpeedMultiplier, fov, setFov, + audioEnabled, + setAudioEnabled, }), - [fogEnabled, speedMultiplier, fov] + [fogEnabled, speedMultiplier, fov, audioEnabled] ); // Read persisted settings from localStoarge. diff --git a/src/components/renderObject.tsx b/src/components/renderObject.tsx index 05556f6b..3beaebb3 100644 --- a/src/components/renderObject.tsx +++ b/src/components/renderObject.tsx @@ -9,8 +9,10 @@ import { TSStatic } from "./TSStatic"; import { StaticShape } from "./StaticShape"; import { Item } from "./Item"; import { Turret } from "./Turret"; +import { AudioEmitter } from "./AudioEmitter"; const componentMap = { + AudioEmitter, InteriorInstance, Item, SimGroup, diff --git a/src/loaders.ts b/src/loaders.ts index 8a06101f..c9b25ef3 100644 --- a/src/loaders.ts +++ b/src/loaders.ts @@ -58,6 +58,10 @@ export function textureToUrl(name: string) { } } +export function audioToUrl(fileName: string) { + return getUrlForPath(`audio/${fileName}`); +} + export async function loadDetailMapList(name: string) { const url = getUrlForPath(`textures/${name}`); const res = await fetch(url);