Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions docs/RouteEngine.md
Original file line number Diff line number Diff line change
Expand Up @@ -1338,12 +1338,15 @@ Animation selections use `animations.resourceId` plus optional
render-scoped behavior when omitted.

BGM update effects use numeric keyframes for `volume`, `pan`, and
`playbackRate`. The final keyframe must be absolute. Its value becomes the
persistent BGM value after the effect finishes; intermediate keyframes only
shape the tween. An update is applied to every retained BGM sound and requires
the same sound IDs and source identities on both sides. Channel-level volume
and pan keyframes are flattened into each rendered sound while preserving that
sound's authored or resource-level volume and pan.
`playbackRate`. The final keyframe must be absolute. When the line keeps or
starts music, its value becomes the persistent BGM value after the effect
finishes; intermediate keyframes only shape the tween. Retained sounds play the
update as an update, added or source-replaced sounds as their incoming track,
and, when the line stops every BGM sound, those sounds as their outgoing track
(see Audio Channels). Relative pan keyframes continue from the channel pan
before the line, or from the new channel pan when music starts. Channel-level
volume and pan keyframes are flattened into each rendered sound while
preserving that sound's authored or resource-level volume and pan.

### Visual Layers

Expand Down Expand Up @@ -2122,8 +2125,16 @@ Engine audio is authored with `sounds` and rendered as Route Graphics
stable and globally unique.
- Canonical sound IDs must be unique within their channel, and canonical SFX
channel IDs must be unique within the SFX action.
- BGM `audioEffects` updates fan out across retained sounds. Transitions target
only added, removed, or source-replaced sounds.
- BGM `audioEffects` updates animate one track per sound, like the visual
update fallback in `AnimationModel.md`, preferring the sounds the line
plays. A retained sound plays the update as an update. An added or
source-replaced sound plays it as its incoming (`enter`) track, and its final
keyframes must still match the sound's persistent mix. When the line stops
every sound, each one plays the update as its outgoing (`exit`) track, so an
update that fades to 0 fades the music out. A sound removed while others play
or enter stops without an effect; if those others do not change, the update
has no effect at all. Transitions target only added, removed, or
source-replaced sounds.

```yaml
actions:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "route-engine-js",
"version": "1.46.5",
"version": "1.46.6",
"description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines",
"repository": {
"type": "git",
Expand Down
107 changes: 107 additions & 0 deletions spec/RouteEngine.audioEffects.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,113 @@ describe("RouteEngine audioEffects occurrences", () => {
expect(effect.properties.volume).not.toHaveProperty("exit");
});

it("plays an update effect on the initial BGM as its incoming track", () => {
const projectData = createProjectData();
projectData.story.scenes.scene.sections.section.lines[0].actions.bgm.audioEffects =
{ resourceId: "smooth" };

const engine = createEngine({ projectData });
const effect = engine.selectRenderState().audioEffects?.[0];

expect(effect).toMatchObject({
targetId: "bgm:main",
properties: {
volume: {
enter: {
keyframes: [
expect.objectContaining({ value: 50 }),
expect.objectContaining({ value: 30 }),
],
},
},
},
});
expect(Object.keys(effect.properties.volume)).toEqual(["enter"]);
expect(engine.selectPresentationState().bgm.volume).toBe(30);
expect(engine.selectRenderState().audio[0].children[0].volume).toBe(30);
});

it("plays an update effect as the incoming track of a new BGM source", () => {
const projectData = createProjectData();
projectData.story.scenes.scene.sections.section.lines[1].actions.bgm.audioEffects =
{ resourceId: "smooth" };
const engine = createEngine({ projectData });

expect(() => enterNextLine(engine)).not.toThrow();
const effect = engine.selectRenderState().audioEffects?.[0];

expect(effect).toMatchObject({
targetId: "bgm:main",
properties: {
volume: {
enter: {
keyframes: [
expect.objectContaining({ value: 50, duration: 400 }),
expect.objectContaining({ value: 30, duration: 600 }),
],
},
},
},
});
expect(Object.keys(effect.properties.volume)).toEqual(["enter"]);
expect(engine.selectRenderState().audio[0].children[0]).toMatchObject({
src: "next.ogg",
volume: 30,
});
});

it("plays an update effect as the outgoing track when a line stops the BGM", () => {
const projectData = createProjectData();
projectData.story.scenes.scene.sections.section.lines[1].actions.bgm = {
audioEffects: { resourceId: "smooth" },
sounds: [],
};
const engine = createEngine({ projectData });

expect(() => enterNextLine(engine)).not.toThrow();
const effect = engine.selectRenderState().audioEffects?.[0];

expect(effect).toMatchObject({
targetId: "bgm:main",
properties: {
volume: {
exit: {
keyframes: [
expect.objectContaining({ value: 50, duration: 400 }),
expect.objectContaining({ value: 30, duration: 600 }),
],
},
},
},
});
expect(Object.keys(effect.properties.volume)).toEqual(["exit"]);
expect(engine.selectRenderState().audio).toEqual([]);
});

it("stops a removed BGM sound without an effect while another keeps playing", () => {
const projectData = createProjectData();
projectData.resources.audioEffects.hold = {
type: "update",
tween: { volume: { keyframes: [{ value: 80, duration: 500 }] } },
};
const lines = projectData.story.scenes.scene.sections.section.lines;
lines[0].actions.bgm.sounds.push({ id: "ambience", resourceId: "next" });
lines[1].actions.bgm = {
volume: 80,
audioEffects: { resourceId: "hold" },
sounds: [{ id: "main", resourceId: "old" }],
};
const engine = createEngine({ projectData });

expect(() => enterNextLine(engine)).not.toThrow();
expect(engine.selectRenderState().audioEffects).toBeUndefined();
expect(
engine
.selectRenderState()
.audio[0].children.map((sound) => [sound.id, sound.volume]),
).toEqual([["bgm:main", 80]]);
});

it("compiles a channel transition across multiple BGM sounds", () => {
const projectData = createProjectData();
const lines = projectData.story.scenes.scene.sections.section.lines;
Expand Down
Loading
Loading