The Stims audio system provides pooled microphone access and audio analysis for reactive visualizations. It minimizes permission prompts and stream allocations by reusing a single MediaStream across multiple toys.
Audio Architecture
The audio system is built around the concept of pooled microphone access where a single microphone stream is shared across multiple toy instances:
Audio Handle Interface
The audio service provides an AudioHandle that includes all resources needed for audio-reactive toys:
export type AudioHandle = {
analyser : FrequencyAnalyser ;
listener : THREE . AudioListener ;
audio : THREE . Audio | THREE . PositionalAudio ;
stream ?: MediaStream ;
release : () => void ;
};
AudioHandle Components
Show analyser - Frequency Analysis
A FrequencyAnalyser instance that provides real-time frequency data and audio features: const freqData = audioHandle . analyser . getFrequencyData ();
const bass = audioHandle . analyser . getBass ();
const treble = audioHandle . analyser . getTreble ();
Show listener - Three.js Audio Listener
A Three.js AudioListener typically attached to the camera for spatial audio support.
Show audio - Three.js Audio Object
A THREE.Audio or THREE.PositionalAudio object connected to the microphone stream.
Show stream - MediaStream (optional)
The underlying browser MediaStream from getUserMedia(). Only exposed when reuseMicrophone is disabled.
Show release() - Cleanup Function
Releases the audio resources back to the pool. Must be called when the toy is disposed to prevent memory leaks.
Acquiring Audio
Use acquireAudioHandle() to get audio resources for your toy:
import { acquireAudioHandle } from './services/audio-service' ;
export async function start ( options ?: ToyStartOptions ) : Promise < ToyInstance > {
// Request audio with default settings (pooled microphone)
const audioHandle = await acquireAudioHandle ();
// Use the analyser in your animation loop
function animate () {
const freqData = audioHandle . analyser . getFrequencyData ();
// Update visualization based on audio...
}
return {
dispose () {
// Always release audio handle
audioHandle . release ();
},
};
}
Audio Init Options
export interface AudioInitOptions {
/**
* Reuse the pooled microphone stream (default: true)
*/
reuseMicrophone ?: boolean ;
/**
* MediaStream constraints for getUserMedia()
*/
constraints ?: MediaStreamConstraints ;
/**
* Existing stream to use instead of requesting a new one
*/
stream ?: MediaStream ;
/**
* Custom initAudio implementation for testing
*/
initAudioImpl ?: typeof initAudio ;
/**
* Stop the stream when release() is called (default: false with pooling)
*/
teardownOnRelease ?: boolean ;
}
Microphone Pooling
The audio service maintains a global pool to avoid repeated permission prompts:
type AudioPoolEntry = {
stream : MediaStream ;
users : number ;
};
let pooledStream : AudioPoolEntry | null = null ;
let streamPromise : Promise < MediaStream | null > | null = null ;
Pool Lifecycle
First acquisition : Prompts user for microphone permission via getUserMedia()
Subsequent acquisitions : Reuses the same stream, incrementing the user count
Release : Decrements the user count; stream remains active for next toy
Reset : Optionally stops all tracks when navigating back to library
By default, the microphone stream stays active even when no toys are using it. This provides instant audio when loading the next toy, but you can call resetAudioPool({ stopStreams: true }) to fully stop it.
Stream Management
The audio service provides several functions for managing the stream lifecycle:
getOrCreateStream()
async function getOrCreateStream ( constraints ?: MediaStreamConstraints ) {
if ( pooledStream ?. stream ) return pooledStream . stream ;
if ( streamPromise ) return streamPromise ;
streamPromise = navigator . mediaDevices
?. getUserMedia ( constraints ?? DEFAULT_MICROPHONE_CONSTRAINTS )
. catch (( error ) => {
streamPromise = null ;
throw error ;
});
const stream = await streamPromise ;
if ( ! stream ) return null ;
pooledStream = { stream , users: 0 };
return stream ;
}
Ensures only one getUserMedia() call is active at a time, preventing race conditions.
prewarmMicrophone()
export async function prewarmMicrophone (
constraints ?: MediaStreamConstraints
) : Promise < PermissionState > {
const permission = await getMicrophonePermissionState ();
if ( permission !== 'granted' ) return permission ;
await getOrCreateStream ( constraints );
return permission ;
}
Preloads the microphone stream before a toy starts. Use this during app initialization to hide microphone latency:
// In app.ts bootstrap
await prewarmMicrophone ();
resetAudioPool()
export async function resetAudioPool ({ stopStreams = true } = {}) {
if ( stopStreams ) {
stopPooledStream ();
return ;
}
pooledStream = null ;
streamPromise = null ;
}
Resets the audio pool state. Call with stopStreams: true to fully stop microphone tracks (e.g., when navigating to the library).
Audio Handle Lifecycle
The acquireAudioHandle() function manages the complete lifecycle:
export async function acquireAudioHandle (
options : AudioInitOptions = {},
) : Promise < AudioHandle > {
const {
reuseMicrophone = true ,
initAudioImpl = initAudio ,
teardownOnRelease = false ,
... audioOptions
} = options ;
let stream : MediaStream | null = audioOptions . stream ?? null ;
let pooledEntry : AudioPoolEntry | null = null ;
// Get or reuse stream
if ( reuseMicrophone && ! stream ) {
stream = await getOrCreateStream ( audioOptions . constraints );
if ( stream && pooledStream ) {
pooledStream . users += 1 ;
pooledEntry = pooledStream ;
}
}
// Initialize audio components
let audio : Awaited < ReturnType < typeof initAudioImpl >>;
try {
audio = await initAudioImpl ({
... audioOptions ,
stream: stream ?? audioOptions . stream ,
stopStreamOnCleanup: ! reuseMicrophone ,
});
} catch ( error ) {
// Rollback pool user count on error
if ( pooledEntry ) {
pooledEntry . users = Math . max ( 0 , pooledEntry . users - 1 );
if ( pooledEntry . users === 0 ) {
stopPooledStream ();
}
}
throw error ;
}
// Create release function
const release = () => {
audio . cleanup ?.();
if ( reuseMicrophone && pooledStream && stream === pooledStream . stream ) {
pooledStream . users = Math . max ( 0 , pooledStream . users - 1 );
if ( pooledStream . users === 0 && teardownOnRelease ) {
stopPooledStream ();
}
}
};
return {
analyser: audio . analyser ,
listener: audio . listener ,
audio: audio . audio ,
stream: audio . stream ,
release ,
};
}
Error Handling
If audio initialization fails after acquiring a pooled stream, the user count is automatically rolled back to prevent pool corruption.
Microphone Permission Flow
The audio system integrates with the UI permission flow:
Usage Patterns
Standard Audio-Reactive Toy
import { acquireAudioHandle } from '../core/services/audio-service' ;
import type { ToyInstance , ToyStartOptions } from '../core/toy-interface' ;
export async function start ( options ?: ToyStartOptions ) : Promise < ToyInstance > {
// Get pooled audio
const audioHandle = await acquireAudioHandle ();
// Set up scene with audio listener
const camera = new THREE . PerspectiveCamera ();
camera . add ( audioHandle . listener );
function animate () {
const analyser = audioHandle . analyser ;
// Get frequency bins
const freqData = analyser . getFrequencyData ();
// Or use convenience methods
const bass = analyser . getBass ();
const mid = analyser . getMid ();
const treble = analyser . getTreble ();
// Update visualization...
}
return {
dispose () {
audioHandle . release ();
},
};
}
Custom Stream (No Pooling)
// For specialized audio toys that need their own stream
const audioHandle = await acquireAudioHandle ({
reuseMicrophone: false ,
teardownOnRelease: true ,
constraints: {
audio: {
echoCancellation: false ,
noiseSuppression: false ,
autoGainControl: false ,
},
},
});
Prewarming for Instant Start
// In loader or app bootstrap
import { prewarmMicrophone } from '../core/services/audio-service' ;
// Before loading an audio toy
const permission = await prewarmMicrophone ();
if ( permission === 'granted' ) {
// Stream is ready, toy will start instantly
await loadToy ( slug );
} else {
// Show permission UI
showMicrophonePrompt ();
}
Audio Service Integration Points
Module Purpose Integration microphone-flow.tsUI permission buttons Wires buttons to prewarmMicrophone() and acquireAudioHandle() web-toy.tsToy runtime helpers Optionally requests audio via acquireAudioHandle() loader.tsToy lifecycle Calls resetAudioPool({ stopStreams: true }) on navigate to library app.tsApp bootstrap Can call prewarmMicrophone() after capability preflight
Best Practices
Always release audio handles - Call audioHandle.release() in your toy’s dispose() method to prevent memory leaks and pool corruption.
Don’t stop pooled streams manually - Let the audio service manage stream lifecycle. Stopping a pooled stream will break other toys.
Do’s and Don’ts
✅ Do:
Use default pooling (reuseMicrophone: true) for standard toys
Call release() in your dispose() method
Use prewarmMicrophone() to hide latency
Check permission state before acquiring audio
❌ Don’t:
Manually call stream.getTracks().forEach(t => t.stop())
Acquire multiple handles in the same toy without releasing
Forget to release handles on disposal
Bypass the audio service for microphone access
Debugging Audio Issues
Show No audio data in analyser
Check that microphone permission is granted
Verify the stream has active audio tracks: stream.getAudioTracks()[0].enabled
Check browser console for getUserMedia() errors
Ensure the analyser is connected: audioHandle.audio.isPlaying
Show Multiple permission prompts
Ensure you’re using reuseMicrophone: true (default)
Check that previous toys called release()
Verify the pool isn’t being reset between toys
Use Chrome DevTools Memory profiler to find unreleased handles
Ensure every acquireAudioHandle() has a matching release()
Check that animation loops are stopped before releasing
Next Steps
Rendering Learn about renderer pooling and WebGL/WebGPU backend selection
Architecture Overview Return to the architecture overview