Working with Sound (tambo Overview)
tambo (scenerystack/tambo) is SceneryStack's sound-generation library: instead of every part of a simulation calling into the Web Audio API directly, each sound-producing thing in the sim is a small sound generator object, and a single global soundManager owns the actual audio graph — enabling/disabling sound globally, mixing generators together, applying per-category gain, and connecting the result to the browser's audio output. This page is a subsystem-level tour of that architecture; for using sound specifically as an accessibility channel (sonification alongside Voicing and the PDOM), see Sound Design.
soundManager: the mixer
soundManager is a singleton — you don't construct it, you register generators with it. Conceptually it's the mixing desk: every sound generator you create gets added to it once, and from then on soundManager controls whether it's audible, how loud, and through which category-level gain it's routed:
import { soundManager } from 'scenerystack/tambo';
import { SoundClip } from 'scenerystack/tambo';
import popSound_mp3 from './sounds/popSound_mp3.js';
const popSoundClip = new SoundClip( popSound_mp3 );
soundManager.addSoundGenerator( popSoundClip, {
categoryName: 'sim-specific'
} );A generator that's never added to soundManager never produces audible output — construction alone doesn't connect it to anything. removeSoundGenerator reverses this, and should be called as part of disposing whatever owns the generator (see Dispose and Memory Management) so a long-running sim doesn't accumulate generators nothing plays through anymore.
soundManager also exposes setOutputLevelForCategory( categoryName, outputLevel ), letting you (or a Preferences panel) turn a whole category up or down together — the standard categories are 'sim-specific' and 'user-interface', so a user can, for instance, mute UI click sounds while keeping model-driven sonification audible.
The sound generator hierarchy
Every concrete generator (SoundClip, PitchedPopGenerator, OscillatorSoundGenerator, and others) extends a common SoundGenerator base class, which is what soundManager.addSoundGenerator actually expects. That shared base is what lets soundManager treat "play a recorded clip" and "synthesize a tone" identically for the purposes of enabling, disabling, and mixing — the differences between generator types are only in how each produces its waveform, not in how it's registered or controlled.
| Generator | Produces | Typical use |
|---|---|---|
SoundClip | Plays a decoded audio asset, one-shot or looping | The default choice for any pre-recorded sound effect |
SoundClipPlayer | A thin wrapper managing a pool of SoundClip-like players for rapid repeated triggering | Sounds fired often enough that overlapping playback needs pooling rather than one clip instance |
PitchedPopGenerator | A synthesized "pop" with a controllable pitch, no audio asset needed | Feedback tied to a continuous value (a slider position, a count) where discrete recorded pitches would be impractical |
OscillatorSoundGenerator | A raw oscillator tone | Low-level procedural sound, rarely used directly outside other generators built on it |
NoiseGenerator | Synthesized noise (not a recorded sample) | Ambient/textural sound rather than a discrete event |
CompositeSoundClip | Plays several SoundClips together as one logical unit | Layering (e.g. a base hit sound plus a variable layer) without hand-managing multiple independent clips |
Playing a recorded sound: SoundClip
SoundClip is the generator you reach for most: it wraps a single decoded audio asset (imported the same way an image asset would be) and exposes play()/stop():
import { SoundClip, soundManager } from 'scenerystack/tambo';
import collisionSound_mp3 from './sounds/collisionSound_mp3.js';
const collisionSoundClip = new SoundClip( collisionSound_mp3, {
loop: false,
initialPlaybackRate: 1
} );
soundManager.addSoundGenerator( collisionSoundClip );
model.collisionEmitter.addListener( () => collisionSoundClip.play() );play( delay ) and stop( delay ) both accept an optional delay in seconds — useful for lining a sound up with an animation rather than triggering it the instant the model event fires. Setting loop: true turns the same class into a continuous ambient sound (a running motor, an idle hum) instead of a one-shot effect; a looping clip is started once and left playing (or its initialOutputLevel/gain adjusted) rather than re-triggered per event.
Synthesized feedback: PitchedPopGenerator
Not every sound corresponds to a discrete recorded asset — feedback tied to a continuously varying value (dragging a slider, a running count) is often better served by a synthesized tone whose pitch tracks the value than by trying to pre-record enough discrete pitches to sound smooth. PitchedPopGenerator is tambo's built-in example of this pattern: it produces a short "pop" whose pitch you control per-call, pooling a small number of oscillator/gain pairs internally so rapid successive calls don't cut each other off:
import { PitchedPopGenerator, soundManager } from 'scenerystack/tambo';
import { Range } from 'scenerystack/dot';
const popGenerator = new PitchedPopGenerator( {
pitchRange: new Range( 220, 660 )
} );
soundManager.addSoundGenerator( popGenerator );
// relativePitch is 0-1; 0 maps to pitchRange.min, 1 to pitchRange.max
countProperty.link( count => {
const relativePitch = countProperty.range!.getNormalizedValue( count );
popGenerator.playPop( relativePitch );
} );This is the general shape for any procedurally-driven feedback: construct one generator, register it once with soundManager, then call its play method repeatedly as the underlying value changes, rather than constructing a new sound generator per event.
Where to go next
- Sound Design — using
soundManager/SoundClip/shared sound players specifically as an accessibility (sonification) channel, and thesharedSoundPlayersregistry for common UI sounds - Dispose and Memory Management — why generators need
removeSoundGeneratoron disposal, same as any other listener-holding object - Scenery Basics — the Node tree that sound-triggering interactions (drag, press, model events) live alongside