diff --git a/.claude/skills/host-objects/SKILL.md b/.claude/skills/host-objects/SKILL.md index 27d27bd66..fc10b693c 100644 --- a/.claude/skills/host-objects/SKILL.md +++ b/.claude/skills/host-objects/SKILL.md @@ -72,6 +72,7 @@ HostObjects/ │ ├── DelayNodeHostObject.h/.cpp │ ├── IIRFilterNodeHostObject.h/.cpp │ ├── StereoPannerNodeHostObject.h/.cpp +│ ├── PannerNodeHostObject.h/.cpp │ ├── WaveShaperNodeHostObject.h/.cpp │ ├── ConvolverNodeHostObject.h/.cpp │ ├── ChannelMergerNodeHostObject.h/.cpp # Composite: routes inputs to slot hosts diff --git a/.claude/skills/web-audio-api/SKILL.md b/.claude/skills/web-audio-api/SKILL.md index edb287e58..188e11705 100644 --- a/.claude/skills/web-audio-api/SKILL.md +++ b/.claude/skills/web-audio-api/SKILL.md @@ -115,10 +115,11 @@ Current status (from `packages/audiodocs/docs/other/web-audio-api-coverage.mdx`) |---|---| | `AudioContext` | `close`, `suspend`, `resume`, `currentTime`, `destination`, `sampleRate`, `state` | | `BaseAudioContext` | `currentTime`, `destination`, `listener`, `sampleRate`, `state`, `decodeAudioData`, all `create*` for available nodes | -| `AudioListener` | All nine AudioParams; deprecated `setPosition` / `setOrientation` omitted; no audible effect until `PannerNode` | +| `AudioListener` | All nine AudioParams; used by `PannerNode` for 3D spatialization; deprecated `setPosition` / `setOrientation` omitted | +| `PannerNode` | Equal-power spatialization, distance models, cone gain; `'HRTF'` throws `NotSupportedError` on native | ### Not yet implemented ❌ -`AudioSinkInfo`, `AudioWorklet`, `AudioWorkletGlobalScope`, `AudioWorkletNode`, `AudioWorkletProcessor`, `DynamicsCompressorNode`, `MediaStreamAudioDestinationNode`, `MediaStreamAudioSourceNode`, `PannerNode` +`AudioSinkInfo`, `AudioWorklet`, `AudioWorkletGlobalScope`, `AudioWorkletNode`, `AudioWorkletProcessor`, `DynamicsCompressorNode`, `MediaStreamAudioDestinationNode`, `MediaStreamAudioSourceNode` **Goal**: everything in the Web Audio API spec should eventually be in this library. If you implement a node from the ❌ list, update the coverage table in `packages/audiodocs/docs/other/web-audio-api-coverage.mdx`. diff --git a/apps/common-app/src/examples/PannerNode/PannerNode.tsx b/apps/common-app/src/examples/PannerNode/PannerNode.tsx new file mode 100644 index 000000000..801657682 --- /dev/null +++ b/apps/common-app/src/examples/PannerNode/PannerNode.tsx @@ -0,0 +1,549 @@ +import React, { useEffect, useRef, useState, FC } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { + AudioContext, + GainNode, + OscillatorNode, + PannerNode, +} from 'react-native-audio-api'; +import type { DistanceModelType } from 'react-native-audio-api'; + +import { Button, Container, Slider, Spacer } from '../../components'; +import { colors, layout } from '../../styles'; + +const LABEL_WIDTH = 100; +const DISTANCE_MODELS: DistanceModelType[] = [ + 'inverse', + 'linear', + 'exponential', +]; + +type Vec3State = { + positionX: number; + positionY: number; + positionZ: number; +}; + +type SpatialState = Vec3State & { + orientationX: number; + orientationY: number; + orientationZ: number; +}; + +const INITIAL_LISTENER: Vec3State = { + positionX: 0, + positionY: 0, + positionZ: 0, +}; + +const INITIAL_SPATIAL: SpatialState = { + positionX: 0, + positionY: 0, + positionZ: -1, + orientationX: 0, + orientationY: 0, + orientationZ: 1, +}; + +type Preset = { + label: string; + spatial: SpatialState; + coneEnabled?: boolean; + hint: string; +}; + +const PRESETS: Preset[] = [ + { + label: 'Center', + spatial: { ...INITIAL_SPATIAL }, + coneEnabled: false, + hint: 'In front of the listener', + }, + { + label: 'Left', + spatial: { ...INITIAL_SPATIAL, positionX: -3 }, + coneEnabled: false, + hint: 'Stronger in the left ear (with cone off)', + }, + { + label: 'Right', + spatial: { ...INITIAL_SPATIAL, positionX: 3 }, + coneEnabled: false, + hint: 'Stronger in the right ear (with cone off)', + }, + { + label: 'Far', + spatial: { ...INITIAL_SPATIAL, positionZ: -8 }, + coneEnabled: false, + hint: 'Quieter via distance attenuation (cone off)', + }, + { + label: 'Away', + spatial: { + ...INITIAL_SPATIAL, + orientationX: 0, + orientationY: 0, + orientationZ: -1, + }, + coneEnabled: true, + hint: 'Cone on + facing away - steer with inner and outer angle and outer gain', + }, +]; + +const PannerNodeExample: FC = () => { + const [isPlaying, setIsPlaying] = useState(false); + const [gain, setGain] = useState(0.4); + const [spatial, setSpatial] = useState(INITIAL_SPATIAL); + const [listener, setListener] = useState(INITIAL_LISTENER); + const [coneEnabled, setConeEnabled] = useState(false); + const [coneInnerAngle, setConeInnerAngle] = useState(60); + const [coneOuterAngle, setConeOuterAngle] = useState(90); + const [coneOuterGain, setConeOuterGain] = useState(0); + const [distanceModel, setDistanceModel] = + useState('inverse'); + const [rolloffFactor, setRolloffFactor] = useState(1); + const [hint, setHint] = useState( + 'Equal-power model. Use headphones. Listener orientation forward is -Z. Move source and/or listener. Cone OFF: source "Pos X" pans L/R. Turn Cone ON to test orientation together with inner and outer angle and outer gain.' + ); + + const audioContextRef = useRef(null); + const oscillatorRef = useRef(null); + const gainRef = useRef(null); + const pannerRef = useRef(null); + + useEffect(() => { + audioContextRef.current = new AudioContext(); + return () => { + audioContextRef.current?.close(); + audioContextRef.current = null; + }; + }, []); + + const applyCone = ( + panner: PannerNode, + enabled: boolean, + inner = coneInnerAngle, + outer = coneOuterAngle, + outerGain = coneOuterGain + ) => { + if (enabled) { + panner.coneInnerAngle = inner; + panner.coneOuterAngle = Math.max(outer, inner); + panner.coneOuterGain = outerGain; + } else { + panner.coneInnerAngle = 360; + panner.coneOuterAngle = 360; + panner.coneOuterGain = 0; + } + }; + + const applySpatial = (next: SpatialState) => { + const panner = pannerRef.current; + if (!panner) { + return; + } + panner.positionX.value = next.positionX; + panner.positionY.value = next.positionY; + panner.positionZ.value = next.positionZ; + panner.orientationX.value = next.orientationX; + panner.orientationY.value = next.orientationY; + panner.orientationZ.value = next.orientationZ; + }; + + const applyListener = (next: Vec3State) => { + const ctx = audioContextRef.current; + if (!ctx) { + return; + } + ctx.listener.positionX.value = next.positionX; + ctx.listener.positionY.value = next.positionY; + ctx.listener.positionZ.value = next.positionZ; + }; + + const updateSpatial = ( + key: K, + value: SpatialState[K] + ) => { + setSpatial((prev) => { + const next = { ...prev, [key]: value }; + applySpatial(next); + return next; + }); + }; + + const updateListener = ( + key: K, + value: Vec3State[K] + ) => { + setListener((prev) => { + const next = { ...prev, [key]: value }; + applyListener(next); + return next; + }); + }; + + const setup = () => { + if (!audioContextRef.current) { + audioContextRef.current = new AudioContext(); + } + + const ctx = audioContextRef.current; + oscillatorRef.current = ctx.createOscillator(); + oscillatorRef.current.type = 'sine'; + oscillatorRef.current.frequency.value = 440; + + gainRef.current = ctx.createGain(); + gainRef.current.gain.value = gain; + + pannerRef.current = ctx.createPanner(); + pannerRef.current.distanceModel = distanceModel; + pannerRef.current.rolloffFactor = rolloffFactor; + applyCone(pannerRef.current, coneEnabled); + applySpatial(spatial); + applyListener(listener); + + oscillatorRef.current.connect(gainRef.current); + gainRef.current.connect(pannerRef.current); + pannerRef.current.connect(ctx.destination); + }; + + const handlePlayPause = () => { + if (isPlaying) { + oscillatorRef.current?.stop(0); + oscillatorRef.current = null; + gainRef.current = null; + pannerRef.current = null; + } else { + setup(); + oscillatorRef.current?.start(0); + } + setIsPlaying((prev) => !prev); + }; + + const applyPreset = (preset: Preset) => { + setSpatial(preset.spatial); + applySpatial(preset.spatial); + setListener(INITIAL_LISTENER); + applyListener(INITIAL_LISTENER); + if (preset.coneEnabled !== undefined) { + setConeEnabled(preset.coneEnabled); + if (pannerRef.current) { + applyCone(pannerRef.current, preset.coneEnabled); + } + } + setHint(preset.hint); + }; + + return ( + + + PannerNode + {hint} + + +