diff --git a/demos/audio_visualizer/AudioAnalyser.js b/demos/audio_visualizer/AudioAnalyser.js new file mode 100644 index 00000000..c48c57ba --- /dev/null +++ b/demos/audio_visualizer/AudioAnalyser.js @@ -0,0 +1,174 @@ +/** + * AudioAnalyser — reusable Web Audio API wrapper. + * + * Wraps AudioContext + AnalyserNode + getUserMedia into a clean interface + * that any demo can import and use without touching the Web Audio API directly. + * + * Usage: + * const analyser = new AudioAnalyser({ fftSize: 128, smoothing: 0.8 }); + * await analyser.start(); // requests mic permission + * analyser.getFrequencyData(); // → Uint8Array, length = fftSize / 2 + * analyser.getWaveformData(); // → Uint8Array, length = fftSize / 2 + * analyser.getRMS(); // → 0–1 overall loudness + * analyser.isBeat(); // → true on detected bass beat + * analyser.stop(); + */ +export class AudioAnalyser { + /** + * @param {object} options + * @param {number} [options.fftSize=128] FFT size (power of 2, ≥ 32). + * @param {number} [options.smoothing=0.8] AnalyserNode smoothingTimeConstant. + * @param {number} [options.beatThreshold=1.4] Bass EMA multiplier that triggers a beat. + * @param {number} [options.beatCooldownMs=200] Min ms between beat events. + * @param {number} [options.bassRatio=0.15] Fraction of bins treated as "bass". + */ + constructor({ + fftSize = 128, + smoothing = 0.8, + beatThreshold = 1.4, + beatCooldownMs = 200, + bassRatio = 0.15, + } = {}) { + this._fftSize = fftSize; + this._smoothing = smoothing; + this._beatThreshold = beatThreshold; + this._beatCooldownMs = beatCooldownMs; + this._bassRatio = bassRatio; + + this._audioCtx = null; + this._analyserNode = null; + this._stream = null; + this._freqData = null; + this._waveData = null; + + // Beat detection state + this._bassEma = 0; + this._lastBeatTime = -Infinity; + } + + // ─── Public API ──────────────────────────────────────────────────────────── + + /** True while mic is connected and AudioContext is running. */ + get isListening() { + return this._analyserNode !== null; + } + + /** + * Requests microphone permission and starts audio analysis. + * @returns {Promise} Resolves on success, rejects if permission denied. + */ + async start() { + const stream = await navigator.mediaDevices.getUserMedia({audio: true}); + this._stream = stream; + + this._audioCtx = new AudioContext(); + const source = this._audioCtx.createMediaStreamSource(stream); + + this._analyserNode = this._audioCtx.createAnalyser(); + this._analyserNode.fftSize = this._fftSize; + this._analyserNode.smoothingTimeConstant = this._smoothing; + + source.connect(this._analyserNode); + + const binCount = this._analyserNode.frequencyBinCount; + this._freqData = new Uint8Array(binCount); + this._waveData = new Uint8Array(binCount); + } + + /** Stops microphone capture and closes AudioContext. */ + stop() { + if (this._stream) { + this._stream.getTracks().forEach((t) => t.stop()); + this._stream = null; + } + if (this._audioCtx) { + this._audioCtx.close(); + this._audioCtx = null; + } + this._analyserNode = null; + this._freqData = null; + this._waveData = null; + this._bassEma = 0; + } + + /** + * Returns the latest frequency-domain data (0–255 per bin). + * Call once per frame; the returned array is reused each call. + * @returns {Uint8Array|null} null when not listening. + */ + getFrequencyData() { + if (!this._analyserNode) return null; + this._analyserNode.getByteFrequencyData(this._freqData); + return this._freqData; + } + + /** + * Returns the latest time-domain waveform data (0–255 per sample). + * Call once per frame; the returned array is reused each call. + * @returns {Uint8Array|null} null when not listening. + */ + getWaveformData() { + if (!this._analyserNode) return null; + this._analyserNode.getByteTimeDomainData(this._waveData); + return this._waveData; + } + + /** + * Returns the root-mean-square loudness normalised to 0–1. + * Reads from the most recently fetched frequency data. + * @returns {number} + */ + getRMS() { + if (!this._freqData) return 0; + let sum = 0; + for (let i = 0; i < this._freqData.length; i++) { + const v = this._freqData[i] / 255; + sum += v * v; + } + return Math.sqrt(sum / this._freqData.length); + } + + /** + * Returns true on frames where a bass beat is detected. + * Should be called after getFrequencyData() each frame. + * + * Method: compare average of the lowest `bassRatio` fraction of bins + * against an exponential moving average (EMA). A beat fires when the + * current value exceeds `ema * beatThreshold`. A cooldown prevents + * rapid consecutive beats. + * + * @returns {boolean} + */ + isBeat() { + if (!this._freqData) return false; + + const bassBins = Math.max( + 1, + Math.floor(this._freqData.length * this._bassRatio) + ); + let bassSum = 0; + for (let i = 0; i < bassBins; i++) bassSum += this._freqData[i]; + const bassAvg = bassSum / bassBins / 255; // 0–1 + + // Exponential moving average — tracks baseline slowly + this._bassEma = this._bassEma * 0.95 + bassAvg * 0.05; + + const now = performance.now(); + const cooldownOk = now - this._lastBeatTime > this._beatCooldownMs; + const isSpike = + bassAvg > this._bassEma * this._beatThreshold && bassAvg > 0.1; + + if (isSpike && cooldownOk) { + this._lastBeatTime = now; + return true; + } + return false; + } + + /** Number of frequency bins (= fftSize / 2). */ + get binCount() { + return this._analyserNode + ? this._analyserNode.frequencyBinCount + : this._fftSize / 2; + } +} diff --git a/demos/audio_visualizer/AudioVisualizer.js b/demos/audio_visualizer/AudioVisualizer.js new file mode 100644 index 00000000..386e0ff9 --- /dev/null +++ b/demos/audio_visualizer/AudioVisualizer.js @@ -0,0 +1,483 @@ +import * as THREE from 'three'; +import * as xb from 'xrblocks'; + +import {AudioAnalyser} from './AudioAnalyser.js'; +import {FrequencyBars} from './FrequencyBars.js'; +import {WaveformRing} from './WaveformRing.js'; + +// ─── Visualization modes ──────────────────────────────────────────────────── +const MODE_BARS = 'bars'; +const MODE_WAVE = 'wave'; +const MODE_SPHERE = 'sphere'; + +// Pulse sphere constants +const SPHERE_BASE_RADIUS = 0.32; +const SPHERE_MAX_DISPLACE = 0.18; + +// ─── Color themes ──────────────────────────────────────────────────────────── +// Each theme is an array of THREE.Color stop-points; bars/ring lerp across them. +const THEMES = { + spectrum: _buildSpectrum(), // full HSL sweep + ember: _buildEmber(), // yellow → orange → red + frost: _buildFrost(), // cyan → blue → purple +}; +const THEME_ORDER = ['spectrum', 'ember', 'frost']; +const THEME_ICONS = ['gradient', 'local_fire_department', 'ac_unit']; + +function _buildSpectrum() { + const colors = []; + for (let i = 0; i < 64; i++) { + const hue = ((1 - i / 64) * 240) / 360; + colors.push(new THREE.Color().setHSL(hue, 1.0, 0.55)); + } + return colors; +} +function _buildEmber() { + return [ + new THREE.Color('#facc15'), // yellow + new THREE.Color('#f97316'), // orange + new THREE.Color('#ef4444'), // red + ]; +} +function _buildFrost() { + return [ + new THREE.Color('#22d3ee'), // cyan + new THREE.Color('#3b82f6'), // blue + new THREE.Color('#a855f7'), // purple + ]; +} + +// ─── Main class ────────────────────────────────────────────────────────────── + +/** + * AudioVisualizer — xb.Script that orchestrates three audio-reactive + * visualization modes and a draggable control panel. + * + * Modes: 'bars' (frequency bars + peak hold), 'wave' (waveform ring line), + * 'sphere' (morphing icosahedron) + * + * Constructor options (typically from URL params via main.js): + * numBars {number} default 64 + * radius {number} default 0.6 + * smoothing {number} default 0.8 + */ +export class AudioVisualizer extends xb.Script { + static dependencies = {camera: THREE.Camera}; + + constructor({numBars = 64, radius = 0.6, smoothing = 0.8} = {}) { + super(); + this._opts = {numBars, radius, smoothing}; + + // Audio + this._analyser = new AudioAnalyser({ + fftSize: numBars * 2, + smoothing, + }); + + // Visual group (follows camera) + this._vizGroup = new THREE.Group(); + this.add(this._vizGroup); + + // Mode instances + this._barsViz = new FrequencyBars({numBars, radius}); + this._waveViz = new WaveformRing({numSamples: numBars * 2, radius}); + this._sphereGroup = new THREE.Group(); + + this._vizGroup.add(this._barsViz.group); + this._vizGroup.add(this._waveViz.group); + this._vizGroup.add(this._sphereGroup); + + // Sphere mode setup + this._buildSphere(); + + // State + this._mode = MODE_BARS; + this._themeIdx = 0; + this._panelMoved = false; + + // Reusable objects to avoid per-frame allocations in _followCamera + this._camPos = new THREE.Vector3(); + this._camQuat = new THREE.Quaternion(); + this._camForward = new THREE.Vector3(); + + // UI panel (added in init after camera is available) + this._panel = null; + } + + // ─── Lifecycle ───────────────────────────────────────────────────────────── + + init({camera}) { + this._camera = camera; + + this._applyTheme(); + this._setMode(MODE_BARS); + this._buildUi(); + this._addLights(); + this._registerGestures(); + } + + update(time) { + this._followCamera(); + + // Slowly rotate the visualisation ring + const t = (time ?? performance.now()) / 1000; + this._vizGroup.rotation.y = t * 0.2; + + if (!this._analyser.isListening) return; + + if (this._mode === MODE_BARS) { + this._barsViz.update(this._analyser); + } else if (this._mode === MODE_WAVE) { + this._waveViz.update(this._analyser); + } else if (this._mode === MODE_SPHERE) { + this._updateSphere(); + } + } + + dispose() { + this._analyser.stop(); + this._barsViz.dispose(); + this._waveViz.dispose(); + this._sphereMaterial?.dispose(); + this._unregisterGestures(); + } + + // ─── Camera following ────────────────────────────────────────────────────── + + _followCamera() { + if (!this._camera) return; + + this._camera.getWorldPosition(this._camPos); + this._camera.getWorldQuaternion(this._camQuat); + this._camForward.set(0, 0, -1).applyQuaternion(this._camQuat); + + // Visualisation group: 1 m in front, slightly below eye level + this._vizGroup.position + .copy(this._camPos) + .addScaledVector(this._camForward, 1.0); + this._vizGroup.position.y -= 0.05; + this._vizGroup.quaternion.copy(this._camQuat); + + // Panel: only follow if user hasn't moved it manually + if (!this._panelMoved && this._panel) { + this._panel.position.copy(this._vizGroup.position); + this._panel.position.y -= 0.56; + this._panel.quaternion.copy(this._camQuat); + } + } + + // ─── Sphere mode ─────────────────────────────────────────────────────────── + + _buildSphere() { + const geo = new THREE.IcosahedronGeometry(SPHERE_BASE_RADIUS, 4); + // Store original vertex positions for reset each frame + this._sphereOrigPos = geo.attributes.position.array.slice(); + this._sphereGeo = geo; + + this._sphereMaterial = new THREE.MeshStandardMaterial({ + color: 0x4488ff, + emissive: 0x112244, + emissiveIntensity: 0.8, + roughness: 0.3, + metalness: 0.6, + wireframe: false, + }); + + this._sphereMesh = new THREE.Mesh(geo, this._sphereMaterial); + this._sphereGroup.add(this._sphereMesh); + } + + _updateSphere() { + const freqData = this._analyser.getFrequencyData(); + const beat = this._analyser.isBeat(); + if (!freqData) return; + + const pos = this._sphereGeo.attributes.position; + const orig = this._sphereOrigPos; + const binCount = freqData.length; + + // For each vertex, determine which frequency bin drives it + // by mapping the vertex's azimuthal angle to a bin index. + for (let i = 0; i < pos.count; i++) { + const ox = orig[i * 3]; + const oy = orig[i * 3 + 1]; + const oz = orig[i * 3 + 2]; + + // Azimuthal angle 0–2π → bin index + const azimuth = Math.atan2(oz, ox) + Math.PI; // 0–2π + const bin = Math.min( + Math.floor((azimuth / (Math.PI * 2)) * binCount), + binCount - 1 + ); + const norm = freqData[bin] / 255; + const scale = 1 + norm * (SPHERE_MAX_DISPLACE / SPHERE_BASE_RADIUS); + + pos.setXYZ(i, ox * scale, oy * scale, oz * scale); + } + pos.needsUpdate = true; + this._sphereGeo.computeVertexNormals(); + + // Pulse emissive on beat + const targetEmissive = beat ? 2.5 : 0.6 + this._analyser.getRMS() * 2.0; + this._sphereMaterial.emissiveIntensity = + this._sphereMaterial.emissiveIntensity * 0.85 + targetEmissive * 0.15; + + // Match sphere color to active theme (use mid-theme color) + const colors = THEMES[THEME_ORDER[this._themeIdx]]; + const midColor = colors[Math.floor(colors.length / 2)]; + this._sphereMaterial.color.lerp(midColor, 0.05); + this._sphereMaterial.emissive.lerp(midColor, 0.03); + } + + // ─── Mode switching ──────────────────────────────────────────────────────── + + _setMode(mode) { + this._mode = mode; + this._barsViz.setVisible(mode === MODE_BARS); + this._waveViz.setVisible(mode === MODE_WAVE); + this._sphereGroup.visible = mode === MODE_SPHERE; + this._updateModeButtons(); + } + + _updateModeButtons() { + if (!this._modeButtons) return; + const modes = [MODE_BARS, MODE_WAVE, MODE_SPHERE]; + for (let i = 0; i < modes.length; i++) { + const btn = this._modeButtons[i]; + if (!btn) continue; + btn.fontColor = modes[i] === this._mode ? '#ffffff' : '#4b5563'; + } + // Propagate fontColor changes to the underlying troika text objects + this._panel?.updateLayouts(); + } + + // ─── Theme switching ─────────────────────────────────────────────────────── + + _applyTheme() { + const themeName = THEME_ORDER[this._themeIdx]; + const colors = THEMES[themeName]; + + // Expand theme stops to full numBars array + const expanded = this._expandTheme(colors, this._opts.numBars); + this._barsViz.setTheme(expanded); + this._waveViz.setTheme(colors); + } + + _expandTheme(stops, count) { + const out = []; + for (let i = 0; i < count; i++) { + const t = i / (count - 1); + const scaled = t * (stops.length - 1); + const lo = Math.floor(scaled); + const hi = Math.min(lo + 1, stops.length - 1); + out.push(stops[lo].clone().lerp(stops[hi], scaled - lo)); + } + return out; + } + + _cycleTheme() { + this._themeIdx = (this._themeIdx + 1) % THEME_ORDER.length; + this._applyTheme(); + if (this._paletteButton) { + this._paletteButton.setText(THEME_ICONS[this._themeIdx]); + } + } + + // ─── Lights ──────────────────────────────────────────────────────────────── + + _addLights() { + this._vizGroup.add(new THREE.AmbientLight(0xffffff, 0.4)); + const dir = new THREE.DirectionalLight(0xffffff, 1.0); + dir.position.set(1, 2, 1); + this._vizGroup.add(dir); + } + + // ─── UI ──────────────────────────────────────────────────────────────────── + + _buildUi() { + const panel = new xb.Panel({ + backgroundColor: '#101218ee', + width: 0.9, + height: 0.22, + draggable: true, + showHighlights: true, + }); + this._panel = panel; + this.add(panel); + + // Track manual drag so camera-follow stops + panel.onSelectEnd = () => { + this._panelMoved = true; + }; + + const grid = panel.addGrid(); + const row = grid.addRow({weight: 1}); + const inner = row.addPanel({backgroundColor: '#00000000'}).addGrid(); + + // ── Spacer left + inner.addCol({weight: 0.04}); + + // ── Mic button + this._micButton = inner.addCol({weight: 0.1}).addIconButton({ + text: 'mic', + fontSize: 0.52, + fontColor: '#ffffff', + }); + this._micButton.onTriggered = () => this._toggleMic(); + + // ── Divider spacer + inner.addCol({weight: 0.03}); + + // ── Separator label + inner.addCol({weight: 0.01}).addText({ + text: '│', + fontColor: '#374151', + fontSize: 0.04, + }); + + inner.addCol({weight: 0.03}); + + // ── Mode buttons (bars / wave / sphere) + const modeIcons = ['equalizer', 'ssid_chart', 'circle']; + const modes = [MODE_BARS, MODE_WAVE, MODE_SPHERE]; + this._modeButtons = []; + + for (let i = 0; i < 3; i++) { + const btn = inner.addCol({weight: 0.1}).addIconButton({ + text: modeIcons[i], + fontSize: 0.5, + fontColor: '#4b5563', + }); + const mode = modes[i]; + btn.onTriggered = () => this._setMode(mode); + this._modeButtons.push(btn); + } + + // ── Spacer + inner.addCol({weight: 0.03}); + + // ── Separator + inner.addCol({weight: 0.01}).addText({ + text: '│', + fontColor: '#374151', + fontSize: 0.04, + }); + + inner.addCol({weight: 0.03}); + + // ── Palette/theme button + this._paletteButton = inner.addCol({weight: 0.1}).addIconButton({ + text: THEME_ICONS[0], + fontSize: 0.48, + fontColor: '#9ca3af', + }); + this._paletteButton.onTriggered = () => this._cycleTheme(); + + // ── Status text + inner.addCol({weight: 0.03}); + + this._statusText = inner.addCol({weight: 0.3}).addText({ + text: 'Pinch mic to start', + fontColor: '#6b7280', + fontSize: 0.026, + textAlign: 'left', + anchorX: 'left', + }); + this._statusText.x = -0.5; + + // ── Spacer right + inner.addCol({weight: 0.04}); + + panel.updateLayouts(); + this._updateModeButtons(); + } + + // ─── Mic control ─────────────────────────────────────────────────────────── + + async _toggleMic() { + if (this._analyser.isListening) { + this._analyser.stop(); + this._setStatus('Stopped'); + this._micButton.setText?.('mic'); + this._micButton.fontColor = '#ffffff'; + } else { + this._setStatus('Requesting mic…'); + try { + await this._analyser.start(); + this._setStatus('● Listening'); + this._micButton.setText?.('stop'); + this._micButton.fontColor = '#ef4444'; + } catch (err) { + console.error('Microphone access denied:', err); + this._setStatus('Mic access denied'); + } + } + } + + _setStatus(text) { + if (this._statusText) this._statusText.setText(text); + } + + // ─── Gesture control ─────────────────────────────────────────────────────── + + /** + * Map hand gestures to visualization modes and theme cycling. + * Requires options.enableGestures() + per-gesture enables in main.js. + * + * ✊ fist → bars mode + * 👆 point → wave mode + * 🖐 spread → sphere mode + * 👍 thumbs-up → cycle color theme + */ + _registerGestures() { + const gestures = xb.core.gestureRecognition; + if (!gestures) return; // not enabled — panel buttons still work + + this._gestureHandler = (event) => { + const {name} = event.detail; + switch (name) { + case 'fist': + this._setMode(MODE_BARS); + this._gestureToast('✊ Bars'); + break; + case 'point': + this._setMode(MODE_WAVE); + this._gestureToast('☝ Wave'); + break; + case 'spread': + this._setMode(MODE_SPHERE); + this._gestureToast('🖐 Sphere'); + break; + case 'thumbs-up': + this._cycleTheme(); + this._gestureToast('👍 Theme: ' + THEME_ORDER[this._themeIdx]); + break; + } + }; + + gestures.addEventListener('gesturestart', this._gestureHandler); + } + + _unregisterGestures() { + const gestures = xb.core.gestureRecognition; + if (gestures && this._gestureHandler) { + gestures.removeEventListener('gesturestart', this._gestureHandler); + } + } + + /** + * Briefly show a gesture confirmation in the status bar, then revert. + * @param {string} message + */ + _gestureToast(message) { + this._setStatus(message); + if (this._toastTimer) clearTimeout(this._toastTimer); + this._toastTimer = setTimeout(() => { + this._toastTimer = null; + // Revert to the appropriate idle or listening status + this._setStatus( + this._analyser.isListening ? '● Listening' : 'Pinch mic to start' + ); + }, 1500); + } +} diff --git a/demos/audio_visualizer/FrequencyBars.js b/demos/audio_visualizer/FrequencyBars.js new file mode 100644 index 00000000..86c85255 --- /dev/null +++ b/demos/audio_visualizer/FrequencyBars.js @@ -0,0 +1,174 @@ +import * as THREE from 'three'; + +const BAR_WIDTH = 0.025; +const BAR_DEPTH = 0.025; +const MAX_BAR_HEIGHT = 0.4; +const MIN_BAR_HEIGHT = 0.01; + +// Peak-hold decay multiplier per frame (~0.97 gives ~1s fall at 60 fps) +const PEAK_DECAY = 0.97; +// Emissive intensity when a beat fires +const BEAT_FLASH_INTENSITY = 4.0; +// Peak indicator height (thin strip above each bar) +const PEAK_BAR_HEIGHT = 0.008; + +/** + * FrequencyBars — circular ring of frequency-reactive bars with peak-hold + * indicators and beat-flash effect. + * + * Usage: + * const bars = new FrequencyBars({ numBars: 64, radius: 0.6 }); + * scene.add(bars.group); + * // each frame: + * bars.update(analyser); // pass AudioAnalyser instance + * bars.setTheme(themeColors); // array of THREE.Color, length = numBars + */ +export class FrequencyBars { + /** + * @param {object} options + * @param {number} [options.numBars=64] Number of bars in the ring. + * @param {number} [options.radius=0.6] Ring radius in metres. + */ + constructor({numBars = 64, radius = 0.6} = {}) { + this._numBars = numBars; + this._radius = radius; + + this._bars = []; + this._barMaterials = []; + this._peaks = new Float32Array(numBars).fill(MIN_BAR_HEIGHT); + this._peakMeshes = []; + this._peakMaterials = []; + + this.group = new THREE.Group(); + this._buildGeometry(); + } + + _buildGeometry() { + // Shared bar geometry — unit height, pivot at base via translate + this._barGeo = new THREE.BoxGeometry(BAR_WIDTH, 1, BAR_DEPTH); + this._barGeo.translate(0, 0.5, 0); + + // Shared peak geometry — flat strip + this._peakGeo = new THREE.BoxGeometry( + BAR_WIDTH * 1.2, + PEAK_BAR_HEIGHT, + BAR_DEPTH * 1.2 + ); + + const barGeo = this._barGeo; + const peakGeo = this._peakGeo; + + for (let i = 0; i < this._numBars; i++) { + const angle = (i / this._numBars) * Math.PI * 2; + const x = Math.sin(angle) * this._radius; + const z = Math.cos(angle) * this._radius; + + // ── Bar ── + const mat = new THREE.MeshStandardMaterial({ + color: 0xffffff, + emissive: 0x000000, + roughness: 0.4, + metalness: 0.2, + }); + this._barMaterials.push(mat); + + const mesh = new THREE.Mesh(barGeo, mat); + mesh.position.set(x, 0, z); + mesh.rotation.y = -angle; + mesh.scale.y = MIN_BAR_HEIGHT; + this._bars.push(mesh); + this.group.add(mesh); + + // ── Peak indicator ── + const peakMat = new THREE.MeshStandardMaterial({ + color: 0xffffff, + emissive: 0xffffff, + emissiveIntensity: 0.8, + roughness: 0.5, + metalness: 0, + }); + this._peakMaterials.push(peakMat); + + const peakMesh = new THREE.Mesh(peakGeo, peakMat); + peakMesh.position.set(x, MIN_BAR_HEIGHT, z); + peakMesh.rotation.y = -angle; + this._peakMeshes.push(peakMesh); + this.group.add(peakMesh); + } + + // Note: lighting is provided by the parent AudioVisualizer scene. + } + + /** + * Update bar heights, peak hold positions, and beat flash. + * Call once per frame with the AudioAnalyser instance. + * @param {import('./AudioAnalyser.js').AudioAnalyser} analyser + */ + update(analyser) { + const freqData = analyser.getFrequencyData(); + const beat = analyser.isBeat(); + + if (!freqData) { + // Gently reset when not listening + for (let i = 0; i < this._numBars; i++) { + this._bars[i].scale.y = MIN_BAR_HEIGHT; + this._peakMeshes[i].position.y = MIN_BAR_HEIGHT; + this._peaks[i] = MIN_BAR_HEIGHT; + this._barMaterials[i].emissiveIntensity = 0; + } + return; + } + + const binCount = freqData.length; + + for (let i = 0; i < this._numBars; i++) { + const bin = Math.min(i, binCount - 1); + const norm = freqData[bin] / 255; + const targetHeight = MIN_BAR_HEIGHT + norm * MAX_BAR_HEIGHT; + + // Smooth bar height + const cur = this._bars[i].scale.y; + const newHeight = cur + (targetHeight - cur) * 0.3; + this._bars[i].scale.y = newHeight; + + // Peak hold — rise instantly, decay slowly + if (newHeight > this._peaks[i]) { + this._peaks[i] = newHeight; + } else { + this._peaks[i] *= PEAK_DECAY; + if (this._peaks[i] < MIN_BAR_HEIGHT) this._peaks[i] = MIN_BAR_HEIGHT; + } + this._peakMeshes[i].position.y = this._peaks[i]; + + // Emissive glow — boost on beat, otherwise track amplitude + const targetEmissive = beat ? BEAT_FLASH_INTENSITY : norm * 2.5; + this._barMaterials[i].emissiveIntensity = targetEmissive; + } + } + + /** + * Apply a colour theme to all bars and peak indicators. + * @param {THREE.Color[]} colors Array of length numBars. + */ + setTheme(colors) { + for (let i = 0; i < this._numBars; i++) { + const c = colors[i % colors.length]; + this._barMaterials[i].color.copy(c); + this._barMaterials[i].emissive.copy(c).multiplyScalar(0.35); + this._peakMaterials[i].color.copy(c); + this._peakMaterials[i].emissive.copy(c); + } + } + + /** Show or hide this visualisation. */ + setVisible(visible) { + this.group.visible = visible; + } + + dispose() { + this._barGeo?.dispose(); + this._peakGeo?.dispose(); + for (const m of this._barMaterials) m.dispose(); + for (const m of this._peakMaterials) m.dispose(); + } +} diff --git a/demos/audio_visualizer/WaveformRing.js b/demos/audio_visualizer/WaveformRing.js new file mode 100644 index 00000000..addf4e27 --- /dev/null +++ b/demos/audio_visualizer/WaveformRing.js @@ -0,0 +1,164 @@ +import * as THREE from 'three'; + +const WAVEFORM_DISPLACEMENT = 0.25; // max radial displacement in metres + +/** + * WaveformRing — time-domain waveform visualised as a glowing 3D line ring. + * + * Samples are placed around a circle of `radius`. Each sample displaces its + * point radially inward/outward based on the audio amplitude, forming a + * pulsing loop that reacts to the live waveform shape. + * + * Usage: + * const ring = new WaveformRing({ numSamples: 128, radius: 0.6 }); + * scene.add(ring.group); + * ring.update(analyser); // call each frame with AudioAnalyser instance + * ring.setTheme(colors); // array of THREE.Color + */ +export class WaveformRing { + /** + * @param {object} options + * @param {number} [options.numSamples=128] Must match analyser.binCount. + * @param {number} [options.radius=0.6] Ring radius in metres. + */ + constructor({numSamples = 128, radius = 0.6} = {}) { + this._numSamples = numSamples; + this._radius = radius; + + this.group = new THREE.Group(); + this._buildGeometry(); + } + + _buildGeometry() { + // +1 point to close the loop + const count = this._numSamples + 1; + const positions = new Float32Array(count * 3); + const colors = new Float32Array(count * 3); + + // Initialise at rest (no displacement) + for (let i = 0; i < count; i++) { + const angle = (i / this._numSamples) * Math.PI * 2; + positions[i * 3 + 0] = Math.sin(angle) * this._radius; + positions[i * 3 + 1] = 0; + positions[i * 3 + 2] = Math.cos(angle) * this._radius; + colors[i * 3 + 0] = 0; + colors[i * 3 + 1] = 1; + colors[i * 3 + 2] = 1; // default cyan + } + + this._posAttr = new THREE.BufferAttribute(positions, 3); + this._posAttr.setUsage(THREE.DynamicDrawUsage); + this._colorAttr = new THREE.BufferAttribute(colors, 3); + this._colorAttr.setUsage(THREE.DynamicDrawUsage); + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', this._posAttr); + geo.setAttribute('color', this._colorAttr); + + const mat = new THREE.LineBasicMaterial({ + vertexColors: true, + linewidth: 2, // note: linewidth > 1 only works in WebGL1 on some systems + }); + + this._line = new THREE.Line(geo, mat); + this.group.add(this._line); + + // Subtle inner glow: a second slightly smaller line in white + const glowPositions = positions.slice(); + this._glowPosAttr = new THREE.BufferAttribute(glowPositions, 3); + this._glowPosAttr.setUsage(THREE.DynamicDrawUsage); + + const glowGeo = new THREE.BufferGeometry(); + glowGeo.setAttribute('position', this._glowPosAttr); + + const glowMat = new THREE.LineBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.15, + }); + this._glowLine = new THREE.Line(glowGeo, glowMat); + this.group.add(this._glowLine); + } + + /** + * Update the line ring from waveform data. + * @param {import('./AudioAnalyser.js').AudioAnalyser} analyser + */ + update(analyser) { + const waveData = analyser.getWaveformData(); + + if (!waveData) { + // Reset to rest circle + for (let i = 0; i <= this._numSamples; i++) { + const angle = (i / this._numSamples) * Math.PI * 2; + this._posAttr.setXYZ( + i, + Math.sin(angle) * this._radius, + 0, + Math.cos(angle) * this._radius + ); + this._glowPosAttr.setXYZ( + i, + Math.sin(angle) * this._radius, + 0, + Math.cos(angle) * this._radius + ); + } + this._posAttr.needsUpdate = true; + this._glowPosAttr.needsUpdate = true; + return; + } + + const len = waveData.length; + + for (let i = 0; i <= this._numSamples; i++) { + const sampleIdx = i % len; + // waveform data: 0–255, centre at 128 → -1 to +1 + const displacement = + ((waveData[sampleIdx] - 128) / 128) * WAVEFORM_DISPLACEMENT; + + const angle = (i / this._numSamples) * Math.PI * 2; + const r = this._radius + displacement; + + const x = Math.sin(angle) * r; + const z = Math.cos(angle) * r; + + this._posAttr.setXYZ(i, x, 0, z); + this._glowPosAttr.setXYZ(i, x, 0, z); + } + + this._posAttr.needsUpdate = true; + this._glowPosAttr.needsUpdate = true; + } + + /** + * Apply a colour theme. Colors are interpolated around the ring. + * @param {THREE.Color[]} colors + */ + setTheme(colors) { + if (!colors || colors.length < 2) return; + const count = this._numSamples + 1; + for (let i = 0; i < count; i++) { + const t = i / this._numSamples; + const scaled = t * (colors.length - 1); + const lo = Math.floor(scaled); + const hi = Math.min(lo + 1, colors.length - 1); + const frac = scaled - lo; + const c = colors[lo].clone().lerp(colors[hi], frac); + this._colorAttr.setXYZ(i, c.r, c.g, c.b); + } + this._colorAttr.needsUpdate = true; + } + + /** Show or hide this visualisation. */ + setVisible(visible) { + this.group.visible = visible; + } + + dispose() { + this._line.geometry.dispose(); + this._line.material.dispose(); + this._glowLine.geometry.dispose(); + this._glowLine.material.dispose(); + } +} diff --git a/demos/audio_visualizer/index.html b/demos/audio_visualizer/index.html new file mode 100644 index 00000000..0c842f2a --- /dev/null +++ b/demos/audio_visualizer/index.html @@ -0,0 +1,39 @@ + + + + Audio Visualizer Demo + + + + + + + + + + + + + diff --git a/demos/audio_visualizer/main.js b/demos/audio_visualizer/main.js new file mode 100644 index 00000000..312dca61 --- /dev/null +++ b/demos/audio_visualizer/main.js @@ -0,0 +1,32 @@ +import 'xrblocks/addons/simulator/SimulatorAddons.js'; +import * as xb from 'xrblocks'; + +import {AudioVisualizer} from './AudioVisualizer.js'; + +// Configurable via URL params — e.g. ?bars=32&radius=0.8&smoothing=0.5 +const numBars = xb.getUrlParamInt('bars', 64); +const radius = xb.getUrlParamFloat('radius', 0.6); +const smoothing = xb.getUrlParamFloat('smoothing', 0.8); + +const options = new xb.Options(); +options.hands.enabled = true; + +// Enable gesture recognition so users can switch modes hands-free. +// fist → bars | point → wave | spread → sphere | thumbs-up → cycle theme +options.enableGestures(); +options.gestures.setGestureEnabled('fist', true); +options.gestures.setGestureEnabled('point', true); +options.gestures.setGestureEnabled('spread', true); +options.gestures.setGestureEnabled('thumbs-up', true); + +options.setAppTitle('Audio Visualizer'); +options.setAppDescription( + 'Real-time 3D audio visualization — bars, waveform ring, and pulse sphere. ' + + 'Gestures: fist=bars, point=wave, spread=sphere, thumbs-up=theme.' +); +options.xrButton.showEnterSimulatorButton = true; + +document.addEventListener('DOMContentLoaded', () => { + xb.add(new AudioVisualizer({numBars, radius, smoothing})); + xb.init(options); +}); diff --git a/package-lock.json b/package-lock.json index 48cd6957..6b135846 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,9 +29,8 @@ "@types/node": "^25.0.8", "@types/three": "^0.184.0", "@types/webxr": "^0.5.23", - "@vitest/coverage-v8": "^4.1.8", - "concurrently": "^10.0.3", - "eslint": "^9.35.0", + "concurrently": "^9.2.1", + "eslint": "^9.39.4", "eslint-plugin-tsdoc": "^0.5.2", "glob": "^11.0.3", "http-server": "^14.1.1", @@ -403,15 +402,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -442,19 +441,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -465,20 +467,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -513,9 +515,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -536,13 +538,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -2263,9 +2265,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2901,27 +2903,26 @@ } }, "node_modules/eslint": { - "version": "9.35.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", - "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.35.0", - "@eslint/plugin-kit": "^0.3.5", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -2940,7 +2941,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3160,19 +3161,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/@eslint/js": { - "version": "9.35.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", - "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", diff --git a/package.json b/package.json index 3fa5cecc..506cbb16 100644 --- a/package.json +++ b/package.json @@ -57,9 +57,8 @@ "@types/node": "^25.0.8", "@types/three": "^0.184.0", "@types/webxr": "^0.5.23", - "@vitest/coverage-v8": "^4.1.8", - "concurrently": "^10.0.3", - "eslint": "^9.35.0", + "concurrently": "^9.2.1", + "eslint": "^9.39.4", "eslint-plugin-tsdoc": "^0.5.2", "glob": "^11.0.3", "http-server": "^14.1.1", diff --git a/src/addons/netblocks/server/relay.js b/src/addons/netblocks/server/relay.js index 8d05319e..32de9539 100644 --- a/src/addons/netblocks/server/relay.js +++ b/src/addons/netblocks/server/relay.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -/* eslint-env node */ +/* global */ /** * Minimal WebSocket relay for netblocks. *