Pixi'VN sound: channels, playback, volume, and filters

SkillMedia

Use when playing or stopping background music, sound effects, or voice lines; creating/organizing audio channels; controlling volume, mute, or pan; or wiring Tone.js audio filters (reverb, delay, EQ, etc.) in a Pixi'VN game.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Pixi'VN sound: channels, playback, volume, and filters skill

What this skill tells your AI

The instructions your AI receives, as published by drincs-productions/pixi-vn in skills/sound/SKILL.md and read by ahel’s review.

Official docs: pixi-vn.com/start/sound. The sound module is a wrapper around Tone.js; the whole audio system was revised in v1.6.0/1.8.0, so if you find older examples elsewhere, prefer this skill and the doc link above.

When to use this skill

Use this skill whenever a task touches audio in a Pixi'VN project: starting/stopping music, playing a one-shot sound effect or a voice line, pausing/resuming playback, adjusting volume or mute state, or attaching Tone.js filters/effects. Requires Game.init to have already run (see pixi-vn-getting-started) and the tone peer dependency to be installed.

Core mental model

sound (typed SoundManagerInterface) is the single entry point — do not import SoundRegistry directly, it's internal bookkeeping. Import it from the main package (as the official docs do):

import { sound } from "@drincs/pixi-vn";

(Also available from the narrower @drincs/pixi-vn/sound subpath — see pixi-vn-getting-started for when to prefer that.)

The docs describe four elements, from broadest to narrowest:

  • sound (manager) — controls the entire audio system: manages channels, starts/controls media, and can set overall audio levels (volumeAll, muteAll(), etc.). Not saved in game saves — intended for game settings.
  • channels (AudioChannelInterface) — named buses (e.g. "music", "sfx", "voice") that contain, start, and manage media. Channel-level settings (volume, pan, muted, filters) affect only what's routed through that channel and are not saved. Every sound is played through a channel — if you don't create one explicitly, everything plays through the implicit default channel, sound.defaultChannelAlias ("general").
  • media (MediaInterface) — a single started sound instance, returned by play(). Each has its own paused, volume (a raw Tone.js decibel Param, not linear), loop, mute, playbackRate, etc. These settings are saved in the game save. Media instances are tracked by a media alias you choose (defaults to the sound's asset alias if you don't pass a separate one), so you can sound.find()/pause()/resume()/stop() them later by that alias.
  • assets (sound assets) — the raw audio files, referenced by alias from the assets manifest (see pixi-vn-assets for registering and loading them, local or online). Asset-level settings affect every media created from that asset and are meant to be configured once at load time, not saved.

Key distinction: channel volume and sound.volumeAll are linear in [0, 1] (channel.volume = 0.5, sound.volumeAll = 0.7), while a media instance's volume is the underlying Tone.js Param<"decibels"> inherited from Tone.Player (media.volume.value = -6, media.volume.rampTo(-Infinity, 2)). The volume you pass in play()'s options, however, is linear [0, 1] — it gets converted to decibels internally. (The official doc's inline examples set things like channel.volume = 90 / media.volume.value = 90 for illustration only — don't copy those numbers literally: on a real channel, 90 is wildly out of the linear [0, 1] range and will clip.)

channel.background marks a channel whose sounds should not be stopped automatically on scene/step transitions (useful for music that should keep playing across narration steps) — non-background channels are auto-stopped on every "continue".

Playing music on a dedicated channel

import { sound } from "@drincs/pixi-vn";

// Create a background music channel once (e.g. right after Game.init resolves)
sound.channels.add("music", { background: true, volume: 0.8 });

// Start looping music through it
await sound.play("theme-song", { channel: "music", loop: true, volume: 0.8 });

play() accepts either:

sound.play(alias: string, options?: SoundPlayOptionsWithChannel): Promise<MediaInterface>;
sound.play(mediaAlias: string, soundAlias: string, options?: SoundPlayOptionsWithChannel): Promise<MediaInterface>;

Use the two-argument-alias form when you want to reference the instance under a different name than the underlying asset (e.g. multiple simultaneous instances of the same sound file). options.channel defaults to sound.defaultChannelAlias ("general"); if the named channel doesn't exist yet it is created automatically. The sound asset is auto-loaded (sound.load()) if it isn't already.

Calling play() again with the same media alias stops the previous instance and starts a new one, carrying over its previous options unless overridden — a handy way to restart or reconfigure something already playing.

Equivalently, you can call play() directly on a channel instead of passing channel in the options — sound.channels.find("music").play("theme-song", { loop: true }) — the two forms are interchangeable; the docs show both.

Fade in / fade out

play()'s fadeIn/fadeOut options (seconds) drive smooth transitions without any manual rampTo calls: fadeIn only affects the start, while fadeOut is remembered on the media and applied automatically both when playback ends naturally and when you call stop() later.

const music = await sound.play("theme-song", {
  channel: "music",
  loop: true,
  fadeIn: 2, // 2s fade-in on start
  fadeOut: 3, // 3s fade-out, applied on both natural end and manual stop()
});

sound.stop("theme-song"); // fades out over the 3s configured above, instead of cutting off

Playing a one-shot sound effect

For an SFX that should just fire and not be individually tracked/paused later, use sound.unsaved.playTransient — it returns a raw Tone.Player, is not added to sound.find()'s registry, and disposes itself automatically when it finishes:

await sound.unsaved.playTransient("click-sound", { volume: 0.5 });

If you do need to reference the effect later (pause it, stop it by alias, check if it's still playing), use regular play() on an "sfx" channel instead:

sound.channels.add("sfx");
await sound.play("explosion", { channel: "sfx", volume: 1 });

Playing a voice line

Voice is just another channel by convention — nothing special in the API distinguishes it from music/sfx, but keeping it on its own channel lets you separately mute/adjust volume for voice vs. music vs. effects (e.g. an in-game "voice volume" slider):

sound.channels.add("voice");
const line = await sound.play("narrator-intro", { channel: "voice" });

Pause / resume

Both the channel and the manager expose bulk helpers; individual instances expose paused:

// By media alias, via the manager
sound.pause("narrator-intro");
sound.resume("narrator-intro");

// Or directly on the returned instance
line.paused = true;
line.paused = false;

// Everything on one channel
sound.channels.find("voice").pauseAll();
sound.channels.find("voice").resumeAll();

// Everything, globally
sound.pauseAll();
sound.resumeAll();

Changing volume

// Channel volume — linear [0, 1], affects everything played through it
const music = sound.channels.find("music");
music.volume = 0.4;

// Smooth channel-wide fade using the raw Tone.js Param (decibels)
music.volumeParam.rampTo(-12, 3); // fade to -12dB over 3s
music.volumeParam.rampTo(-Infinity, 2); // fade to silence over 2s

// A single playing instance — volume is a Tone.js decibel Param, not linear
const instance = sound.find("narrator-intro");
if (instance) {
  instance.volume.value = -6; // set instantly, in decibels
  instance.volume.rampTo(0, 1); // fade back up to unity gain over 1s
}

// Global master volume — linear [0, 1]
sound.volumeAll = 0.7;

Muting works the same way at every level: sound.muteAll() / sound.unmuteAll() / sound.toggleMuteAll(), channel.muted = true, and instance.mute = true (the MediaInterface.muted alias still works but is deprecated in favor of mute).

Stopping sounds

sound.stop("narrator-intro"); // stop one instance by media alias
sound.channels.find("music").stopAll(); // stop everything on a channel
sound.stopAll(); // stop every tracked instance, everywhere
sound.unsaved.stopTransientAll(); // stop one-shot sounds started with playTransient

Filters

Filters are plain Tone.js audio nodes — construct them directly from tone (new Tone.Reverb(...), new Tone.FeedbackDelay(...), new Tone.Distortion(...), new Tone.Chorus(...), etc.; tone must be installed as a peer dependency). They can be attached per-sound or per-channel, and are currently only settable when a media/channel is created (no live filter add/remove after the fact).

Per-sound, via play()'s filters option:

import * as Tone from "tone";
import { sound } from "@drincs/pixi-vn";

await sound.play("thunder", {
  channel: "sfx",
  filters: [new Tone.Reverb({ decay: 2.5, wet: 0.4 })],
});

Per-channel, so every sound routed through it is affected — either at creation time or later via chain():

sound.channels.add("music", { filters: [new Tone.FeedbackDelay("8n", 0.5)] });

// or on an existing channel
sound.channels.find("music").chain(new Tone.Reverb({ decay: 2.5 }));

Other/menu features

For settings screens or other menus, use the unsaved pause helpers instead of the regular ones — they don't perturb what gets restored from a save:

// On menu open: pause everything that isn't already paused
sound.unsaved.pauseAll();

// On menu close: resume only what pauseAll paused (leaves already-paused media alone)
sound.unsaved.resumeAll();

For transient UI sounds (button clicks, hovers) that must never leak into a save file, prefer sound.unsaved.playTransient() (see above) and sound.unsaved.stopTransientAll() to stop all of them at once.

Real-world project convention (official React template)

The library imposes no particular channel layout or settings architecture — everything above (channel names, how many channels, whether/how to persist volume) is up to the project. The following is how the official "TS narration + React" template (what npm create pixi-vn@latest scaffolds) does it in practice; treat it as one proven convention to copy or adapt, not a requirement of the library itself.

Named constants instead of hardcoded strings. Channel ids live in a constants file and are imported everywhere, so a rename is a one-line change:

// constants.ts
export const BGM_CHANNEL_NAME = "bgm";
export const SFX_CHANNEL_NAME = "sfx";

Set up channels once, right after Game.init resolves, mirroring the background distinction from earlier in this doc — the music channel keeps playing across narration steps, the SFX channel is left non-background so one-shots don't linger:

import { BGM_CHANNEL_NAME, SFX_CHANNEL_NAME } from "@/constants";
import { sound } from "@drincs/pixi-vn";

Game.init(body, options).then(() => {
  sound.channels.add(BGM_CHANNEL_NAME, { background: true }); // looping music
  sound.channels.add(SFX_CHANNEL_NAME); // one-shot effects
  sound.defaultChannelAlias = SFX_CHANNEL_NAME; // so play() calls needn't pass {channel: ...} every time
});

Persist a settings-screen master volume/mute to localStorage, syncing it to the manager on init and on every user change (UI works in a 0-100 scale; the library is linear 0-1):

export namespace MasterSound {
  export function init() {
    setVolume(storedVolume); // from localStorage, defaulting to sound.volumeAll * 100
    setMuted(storedMuted);
  }
  export function setVolume(volume: number) {
    sound.volumeAll = volume / 100;
    localStorage.setItem("master_volume", volume.toString());
  }
  export function setMuted(muted: boolean) {
    muted ? sound.muteAll() : sound.unmuteAll();
    localStorage.setItem("master_muted", muted.toString());
  }
}

Do the same per channel (e.g. separate music/SFX sliders), keyed by channel alias via a small cache of reactive state, using sound.channels.find(alias).volume/.muted in place of the *All manager calls:

export function setVolume(alias: string, volume: number) {
  sound.channels.find(alias).volume = volume / 100;
  localStorage.setItem(`${alias}_volume`, volume.toString());
}
export function setMuted(alias: string, muted: boolean) {
  sound.channels.find(alias).muted = muted;
  localStorage.setItem(`${alias}_muted`, muted.toString());
}

Both init()s run right after the channels are created (in the Game.init(...).then(...) block above), re-applying stored preferences to the manager/channels on every app load, not just persisting them for later.

Related skills

  • pixi-vn-getting-started
  • pixi-vn-assets — registering and loading the audio files referenced here by alias
  • pixi-vn-narration
  • pixi-vn-storage

Signals

GitHub stars
145
Forks
9
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
pixi-vn-sound
Source
github.com/drincs-productions/pixi-vn