Skip to content

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:

ts
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:

ts
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

ts
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 ];
  }
}
TechniqueTriggerWhy this one, not another
sharedSoundPlayers.get('resetAll') (used automatically by ResetAllButton)Any reset-all action across the whole simStandard, 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 pressA single custom one-shot tied to one interaction — the ordinary case for Adding a Custom Sound Effect on Interaction
SoundClip (boundarySound), guarded by wasAtBoundaryA model-state transition, not a continuous conditionFires 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
PitchedPopGeneratorContinuous drag, many times per secondThe 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

ts
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