A Sound-Rich Simulation with Tambo
How tambo's Sound Generation Fits Together explains the pieces individually — soundManager, SoundClip, PitchedPopGenerator, sharedSoundPlayers. This page wires several of them into one small screen at once, each covering a different kind of interaction, so the differences between them show up concretely rather than in isolation.
The scenario: a draggable ball on a number line, where dragging plays a pitch that tracks position, hitting either boundary plays a boundary sound, a "launch" button plays a standard UI click, and a reset plays the shared reset sound — four sound-generation techniques, one small sim.
Enabling sound for the sim
Sound is off unless a PreferencesModel declares support for it — this is a Sim-level setting, not something each sound generator configures itself:
import { Sim, onReadyToLaunch, PreferencesModel } from 'scenerystack/sim';
import { Property } from 'scenerystack/axon';
const preferencesModel = new PreferencesModel( {
audioOptions: {
supportsSound: true
}
} );The model
The ball's position is a Vector2Property confined to a thin horizontal strip via validBounds — matching the shape DragListener/RichDragListener expect (see Drag Listeners), even though only the x-coordinate is actually meaningful here:
import { Vector2Property, Vector2, Bounds2 } from 'scenerystack/dot';
import { Tandem } from 'scenerystack/tandem';
class SoundDemoModel {
public readonly positionProperty: Vector2Property;
public static readonly DRAG_BOUNDS = new Bounds2( -200, -5, 200, 5 );
public constructor( tandem: Tandem ) {
this.positionProperty = new Vector2Property( new Vector2( 0, 0 ), {
validBounds: SoundDemoModel.DRAG_BOUNDS,
tandem: tandem.createTandem( 'positionProperty' )
} );
}
public reset(): void {
this.positionProperty.reset();
}
}Four sound-generation techniques in one view
import { ScreenView, type ScreenViewOptions } from 'scenerystack/sim';
import { Circle, RichDragListener } from 'scenerystack/scenery';
import { RectangularPushButton } from 'scenerystack/sun';
import { ResetAllButton } from 'scenerystack/scenery-phet';
import { SoundClip, PitchedPopGenerator, soundManager } from 'scenerystack/tambo';
import { Vector2, Range } from 'scenerystack/dot';
import { Property } from 'scenerystack/axon';
import launchSound_mp3 from './launchSound_mp3.js';
import boundaryReached_mp3 from './boundaryReached_mp3.js';
class SoundDemoScreenView extends ScreenView {
public constructor( model: SoundDemoModel, providedOptions: ScreenViewOptions ) {
super( providedOptions );
// 1. sharedSoundPlayers - the standard, reused-everywhere reset sound.
// No SoundClip/registration to write; this is already wired up sitewide.
const resetAllButton = new ResetAllButton( {
listener: () => model.reset(),
right: this.layoutBounds.maxX - 20,
bottom: this.layoutBounds.maxY - 20,
tandem: this.tandem.createTandem( 'resetAllButton' )
} );
// 2. SoundClip - a one-shot custom sound tied to a specific button press.
const launchSound = new SoundClip( launchSound_mp3, { initialOutputLevel: 0.7 } );
soundManager.addSoundGenerator( launchSound, { categoryName: 'user-interface' } );
const launchButton = new RectangularPushButton( {
content: new Circle( 8, { fill: 'white' } ),
listener: () => {
model.positionProperty.value = Vector2.ZERO;
launchSound.play();
},
centerX: this.layoutBounds.centerX,
top: 20,
tandem: this.tandem.createTandem( 'launchButton' )
} );
// 3. Another SoundClip - a boundary cue, played only on the transition INTO a boundary.
const boundarySound = new SoundClip( boundaryReached_mp3, { initialOutputLevel: 0.6 } );
soundManager.addSoundGenerator( boundarySound );
const dragBounds = SoundDemoModel.DRAG_BOUNDS;
let wasAtBoundary = false;
model.positionProperty.link( position => {
const isAtBoundary = position.x === dragBounds.minX || position.x === dragBounds.maxX;
if ( isAtBoundary && !wasAtBoundary ) {
boundarySound.play();
}
wasAtBoundary = isAtBoundary;
} );
// 4. PitchedPopGenerator - a synthesized pop whose pitch tracks the ball's position
// continuously while dragging, rather than one pre-recorded file per position.
const dragPopGenerator = new PitchedPopGenerator( {
pitchRange: new Range( 220, 880 )
} );
soundManager.addSoundGenerator( dragPopGenerator );
const ballNode = new Circle( 15, {
fill: 'cornflowerblue',
cursor: 'pointer',
centerY: this.layoutBounds.centerY + 60
} );
model.positionProperty.link( position => {
ballNode.centerX = this.layoutBounds.centerX + position.x;
} );
ballNode.addInputListener( new RichDragListener( {
positionProperty: model.positionProperty,
dragBoundsProperty: new Property( dragBounds ),
drag: () => {
const relativePitch = ( model.positionProperty.value.x - dragBounds.minX ) /
( dragBounds.maxX - dragBounds.minX );
dragPopGenerator.playPop( relativePitch );
}
} ) );
this.children = [ ballNode, launchButton, resetAllButton ];
this.pdomOrder = [ launchButton, ballNode, resetAllButton ];
}
}| Technique | Trigger | Why this one, not another |
|---|---|---|
sharedSoundPlayers.get('resetAll') (used automatically by ResetAllButton) | Any reset-all action across the whole sim | Standard, sitewide interaction — reusing the shared player keeps the sound consistent with every other reset button in any PhET-style sim, with zero registration code |
SoundClip (launchSound) | One specific button press | A single custom one-shot tied to one interaction — the ordinary case for Adding a Custom Sound Effect on Interaction |
SoundClip (boundarySound), guarded by wasAtBoundary | A model-state transition, not a continuous condition | Fires once per crossing rather than replaying on every position update while already at the boundary — the same one-shot-on-transition pattern used for a "success" chime |
PitchedPopGenerator | Continuous drag, many times per second | The position varies continuously; a SoundClip per discrete pitch would need dozens of recorded files, where playPop(relativePitch) synthesizes the right pitch on demand |
Wiring the Screen and Sim
import { Sim, Screen, onReadyToLaunch } from 'scenerystack/sim';
import { Property } from 'scenerystack/axon';
import { Tandem } from 'scenerystack/tandem';
const screenTandem = Tandem.ROOT.createTandem( 'soundDemoScreen' );
const soundDemoScreen = new Screen(
() => new SoundDemoModel( screenTandem.createTandem( 'model' ) ),
model => new SoundDemoScreenView( model, { tandem: screenTandem.createTandem( 'view' ) } ),
{
name: new Property( 'Sound Demo' ),
backgroundColorProperty: new Property( 'white' ),
tandem: screenTandem
}
);
onReadyToLaunch( () => {
const sim = new Sim( new Property( 'Sound Demo' ), [ soundDemoScreen ], { preferencesModel } );
sim.start();
} );Match the sound-generation technique to how the triggering value changes
The four techniques above aren't interchangeable by preference — they map to four different shapes of trigger: a reused sitewide event (sharedSoundPlayers), a one-off discrete event (SoundClip), a state transition that must not repeat while the state holds (SoundClip plus a guard flag), and a continuously-varying value (PitchedPopGenerator). Picking the wrong one for a given trigger is the most common way custom sound design in a sim ends up either silent, spammy, or needlessly bloated with recorded assets.
Where to go next
- How tambo's Sound Generation Fits Together — the conceptual overview this example assembles
- Adding a Custom Sound Effect on Interaction — the same
SoundClippatterns as standalone recipes - Sound Design — sound as a distinct accessibility channel alongside Voicing and the PDOM
- Drag Listeners — the
RichDragListenerwiring used for the ball above