[@layout]: ../docs.layout.md

# Sound effects

SFX attaches audio to authored moments and ordinary browser events. A cue plays
once; a loop continues until stopped. The viewer controls listening through
shared mute and volume settings.

---

## Setup

```sh
bun run pmx plugins add sfx
```

This activates `@pathmx/sfx`. Install [Player](../learning/player.reference)
for Block and Beat choreography. Custom event triggers work without Player.
Effects are invisible by default. Mute and volume are shared viewer preferences,
available in site Settings when the Settings UI is installed. For a custom layout,
optionally place these controls once alongside other shared preferences:

```html
<x-sfx-toggle />
<x-sfx-volume />
```

Keep listening controls out of individual effects. Visible playback buttons are
authored explicitly, as in the literate component example below.

Controls share one preference and reflect each other's changes. With PathMX
Settings, preferences persist for this site on this device. Without Settings,
they last for the document session. Defaults are sound enabled at 70% volume;
the browser still requires a user interaction to initialize audio.

Try the [authored sound example](../../examples/sfx/sounds.demo).

---

## Play at an authored moment

```md
# The reveal

<x-sfx-cue src="./sounds/reveal.mp3" on="player:block-enter" />

The unexpected result.
```

Without `for`, `on` and `off` listen on the containing Block. SFX declarations
are hidden and do not become Beats. A loop can follow the same moment:

```html
<x-sfx-loop
  src="./sounds/ambience.mp3"
  on="player:block-enter"
  off="player:block-leave"
/>
```

For an individual Beat, give its element an ID and name it with `for`:

```html
<p id="alternative" data-pmx-beat="alternative">
  Now consider the alternative.
</p>

<x-sfx-cue
  src="./sounds/tick.mp3"
  for="alternative"
  on="player:beat-enter"
/>
```

`for` is an element ID without `#`, not a CSS selector. Targets live in the same
browser document; embedded pages own their own bindings. Ordinary DOM bubbling
applies to custom events. Player bindings match the target element itself so a
Block listener does not accidentally consume its descendant Beats' events.
Missing runtime targets produce a console diagnostic and stay silent.

| Component | Attributes | Behavior |
| --- | --- | --- |
| `x-sfx-cue` | Required `src`; optional `on`, `off`, `for`, `volume`, `id`, `class` | Plays one sound per trigger; another trigger restarts it. |
| `x-sfx-loop` | Same as cue | Repeats until stopped. |
| `x-sfx-toggle` | Optional `id`, `class` | Mutes or unmutes all SFX in the document. |
| `x-sfx-volume` | Optional `id`, `class` | Adjusts the shared volume with a native range input. |

`src` accepts a local audio asset or HTTPS URL, using a format the browser can
decode. Relative paths resolve beside the Source that authors the component.
Remote audio must permit cross-origin fetching. A sound's `volume` is a number
from 0 to 1 multiplied by the viewer's volume. It cannot override mute.

---

## Trigger from a literate component

A cue needs no automatic binding when its component controls playback directly.
Use this HTML in a component's `html` fence:

```html
<div>
  <button type="button" data-pmx-play-affordance data-pmx-play-icon="play">Try sound</button>
  <x-sfx-cue class="feedback-sound" src="./sounds/success.mp3" />
</div>
```

The button also appears in Player’s [Beat actions](../learning/player.reference#beat-actions),
which activate the same authored control. Its `js` fence can use instance-scoped helpers:

```js
on("button", "click", () => {
  $(".feedback-sound").dispatchEvent(new Event("sfx:play"))
})
```

Send `sfx:stop` to the same element to stop it. Commands are ordinary events
addressed directly to that cue or loop. Instance-scoped selection keeps repeated
components independent without a global sound registry.

For outcomes with multiple consumers, emit a domain event from an element:

```js
answerElement.dispatchEvent(
  new CustomEvent("exercise:correct", {
    bubbles: true,
    detail: { attempt: 2 },
  }),
)
```

If that element has `id="answer"`, an authored sound can bind with
`for="answer" on="exercise:correct"`. IDs must be unique within the document;
prefer direct instance-scoped commands inside repeated component templates.
SFX matches the event name and does not interpret domain data.

---

## Playback and continuity

Player-triggered sounds play on starting Play or moving forward. Backward
movement, scroll adoption, history restoration, and live updates stay quiet.
Opening a page with a Play URL restores its position silently. Custom events and
direct SFX commands play when explicitly triggered, including outside Play.

Leaving a Player-bound moment cancels its pending audio and stops playback.
Loops also stop on their `off` event, removal, or page exit. A live update that
preserves the declaration and its target preserves playback; changing its asset
or binding stops it without automatically replaying it.

Mute immediately silences playback. Short effects triggered while muted are
ignored and never replayed. An active loop resumes on unmute only while its
moment remains active. Volume zero follows the same silence/resume behavior;
changing volume does not clear the mute preference.

Audio initializes on a pointer or keyboard interaction. Effects triggered before
initialization are not queued. Audio loading failures remain local to the sound;
Player navigation and the rest of the content remain usable. SFX uses browser
playback and ordinary assets in both live sites and static publication.

See [Player lifecycle events](../learning/player.reference#lifecycle-events)
for the event payload and ordering. These DOM events are separate from
[committed Events and analytics observations](../extend/events.reference).
