Using the PlayerAudio Playback

Audio

How audio works in the PanelWave Player — the WebAudio engine, its four channels, browser autoplay policy handling, volumes, and sequence tracks.

The player ships a WebAudio-based AudioEngineService for music, ambience, voice-over, and sound effects, plus a UserGestureService that deals with browser autoplay restrictions. Video sound is handled separately by the video layer (muted by default).

The shell drives the engine from the manifest on its own: a PanelAudioService starts each panel's audio when the panel becomes current and stops it when the reader leaves, and the toolbar's Audio / SFX toggles (and the settings modal's audio preferences) mute the corresponding buses. Hosts only need the API below for audio the manifest does not describe — a site-wide jingle, a reader-triggered voice sample, and so on.

What the shell plays automatically

For the current panel (after variant resolution, so a variant's audio override wins), the shell collects:

  • every entry of panels.<id>.audio[] (schema AudioTrack), and
  • every layer with kind: "audio" (schema AudioLayer; its bus comes from the catalog asset's role).

Each entry is resolved against the asset catalog (assets.catalog[assetId], category audio; the first variant the browser reports it can play, its src resolved against assets.base.audioBase / mediaBase) and started with:

Manifest fieldEffect
roleBus: ambient, music, voiceover, sfx as-is; ui plays on the sfx bus (so the SFX toggle covers it); none (the default) on the music bus (only the master Audio toggle silences it). Falls back to the catalog asset's role.
loopLoops while the panel is current.
gainPer-track gain 0–2 (a per-track gain node, so it never touches the bus level).
startAtMsDelay after panel enter; cancelled if the reader leaves first.
visibleIfJSON Logic against the variable context; re-evaluated on every variable change, so a track can start mid-panel when a condition becomes true.

Lifecycle rules:

  • Leaving a panel stops its tracks — looping ones with a short fade (PANEL_AUDIO_LEAVE_FADE_MS, 250 ms), one-shots immediately.
  • Continuity: a looping track that the next panel references too (same assetId) keeps playing uninterrupted; only its gain is updated. This is how a street ambience spans a scene.
  • One-shots restart on every panel enter that references them; a one-shot that finished releases its element on ended.
  • Autoplay policy: a track the browser refuses before the first user gesture is remembered and retried on that gesture (the AudioContext is resumed at the same time), so audio starts as early as the browser allows without any host code.
  • Same panel, variables changed: nothing restarts; only visibleIf results and gains are re-applied.
  • Locked panels stay silent (from the player release after 1.2.0): while the current panel is locked for the reader — by a paywall rule or as an x-locked stub — its tracks don't play, so an age-gated or paid entry panel no longer plays its music behind the age gate or paywall. The audio starts once the lock lifts (age confirmed, purchase completed). The same holds while the work's cover is shown.
  • Missing or non-audio assets are skipped with a single console warning per asset.

Chapter-level sequence tracks (chapter.sequenceAudioTracks) are not started by the shell — they need a playback clock the panel-based shell does not run; see Sequence tracks for driving them from a host.

Toggles and preferences

ControlEngine effect
Toolbar Audio (settings "Enable audio")setMasterMuted(!enabled) — every bus; video layers follow masterMuted$ and stay muted too.
Toolbar SFX (settings "Enable sound effects")setRoleMuted('sfx', !enabled)
Toolbar Speech (settings "Show speech bubbles")also setRoleMuted('voiceover', !enabled)
Stored masterVolume / sfxVolume preferencessetMasterVolume / setRoleVolume('sfx', …) — applied independently of the mute flags.

Initial state follows the reader's explicitly set preference when there is one (persisted in localStorage as pw-preferences, see PlayerStateService), else the work's settings.ui.audioDefault / sfxDefault / speechDefault. See Toolbar controls.

Channels (buses)

The engine mixes every track through a per-role gain node into a master gain:

track → role gain (ambient | music | voiceover | sfx) → master gain → output

The four roles match the manifest's audio roles (see Audio in the schema):

RoleTypical use
ambientBackground atmosphere (rain, crowd noise)
musicScore / theme music
voiceoverNarration, character voice
sfxShort effects (door slam, whoosh)

Playing a track

import { Component, inject } from '@angular/core';
import { AudioEngineService } from '@panelwave/player';
import type { AudioTrack } from '@panelwave/player';

@Component({ /* … */ })
export class ReaderComponent {
  private audio = inject(AudioEngineService);

  async playTheme(): Promise<void> {
    const track: AudioTrack = {
      id: 'main-theme',
      url: 'assets/my-story/audio/theme.mp3',
      role: 'music',
      loop: true,
      volume: 0.8,       // 0–1
      fadeIn: 1500,      // ms
      fadeOut: 1000,     // ms
    };
    await this.audio.play(track);
  }

  stopTheme(): void {
    void this.audio.stop('main-theme', 1000); // fade out over 1 s
  }
}

play() initializes the AudioContext on first use, resumes it if suspended, replaces any active track with the same id, and applies the fade-in. Other playback controls: pause(id), resume(id), stopAll(fadeOutMs?), getActiveTracks(), getPlaybackState(id).

Autoplay policy

Browsers block audible playback before the user interacts with the page. The player handles this in two pieces:

  • AudioEngineService tests the policy when it initializes (isAutoplayAllowed() tells you the result) and exposes resumeContext() to resume a suspended AudioContext — call it from a user interaction (click, key press).
  • UserGestureService listens globally for the first real gesture (pointerdown, mousedown, touchstart, keydown, click — hovering deliberately does not count) and exposes hasInteracted() / userHasInteracted$. Video layers use it: programmatic (on-view) video playback starts muted until a gesture has occurred.

A robust pattern for starting music as early as the browser allows:

import { AudioEngineService, UserGestureService } from '@panelwave/player';

constructor(
  private audio: AudioEngineService,
  private gesture: UserGestureService,
) {
  this.gesture.userHasInteracted$.subscribe(async (interacted) => {
    if (interacted) {
      await this.audio.resumeContext();
      await this.playTheme();
    }
  });
}

Volume and muting

MethodEffect
setMasterVolume(v) / getMasterVolume()Master gain, clamped to 0–1
setRoleVolume(role, v) / getRoleVolume(role)Per-channel gain, clamped to 0–1
setMasterMuted(bool) / isMasterMuted() / masterMuted$Master mute, independent of the master volume (the volume survives a mute/unmute round trip). This is what the toolbar's Audio toggle sets; video layers subscribe to masterMuted$.
setRoleMuted(role, bool) / isRoleMuted(role)Per-channel mute, independent of the channel volume (SFX toggle → sfx, Speech toggle → voiceover).
setTrackVolume(id, v)Per-track gain 0–2 of a playing track (its own gain node between the element and the bus).
isActive(id)Whether a track id is registered (playing or paused).

The signal chain is element → track gain → role gain → master gain → output; fades ramp the track gain, so fading one track never touches its bus.

Sequence tracks

Chapters can define sequence audio tracks (chapter.sequenceAudioTracks) — tracks positioned on a timeline that spans multiple panels, each with assetId, role, startTime, duration, and optional volume (0–2), loop, fadeIn/fadeOut, muted, and a panel range (startPanelId/endPanelId). The engine can start every track that should be audible at a given timeline position, seeking each to the correct offset:

// Start all chapter tracks that overlap the 12.5 s mark
await this.audio.playSequenceTracks(
  chapter.sequenceAudioTracks ?? [],
  12_500,                       // current playback time in ms
  'https://cdn.example.com/'    // base URL prepended to each track's assetId
);

// Later
await this.audio.stopSequenceTracks(); // or pass specific track ids

Muted tracks are skipped, per-track volume is clamped to 0–2, and fade-ins are shortened when starting mid-fade.

Video sound

Video layers manage their own audio element state via VideoControllerService (single active video with sound at a time) and default to muted: true; a work can change this with settings.ui.videoMutedDefault or per layer. Reduced motion degrades on-view autostart to click-to-play, which also satisfies gesture requirements. See Video for the format side.

Speech-bubble audio

pw-speech-bubbles emits bubbleAudioPlay with { bubble, audioAssetId } when a bubble with attached audio is activated — the host decides how to play it (typically through the voiceover channel of the audio engine).

Cleanup

Call destroy() on the engine if you tear down audio entirely: it stops all tracks, closes the AudioContext, and releases every audio element and source node.