diff --git a/docs/RouteEngine.md b/docs/RouteEngine.md index eba053ae..931f9e96 100644 --- a/docs/RouteEngine.md +++ b/docs/RouteEngine.md @@ -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 @@ -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: diff --git a/package.json b/package.json index 4ad1b59e..7e7ffa9a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/spec/RouteEngine.audioEffects.test.js b/spec/RouteEngine.audioEffects.test.js index ad03ee66..8e720cfc 100644 --- a/spec/RouteEngine.audioEffects.test.js +++ b/spec/RouteEngine.audioEffects.test.js @@ -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; diff --git a/spec/resolveAudioEffects.test.js b/spec/resolveAudioEffects.test.js index 1b5e2bb1..b4b668b6 100644 --- a/spec/resolveAudioEffects.test.js +++ b/spec/resolveAudioEffects.test.js @@ -611,15 +611,103 @@ describe("resolveAudioEffect", () => { ["startAt", 1], ["endAt", 10], ["startDelayMs", 100], - ])("treats a changed %s as a source-identity change", (field, value) => { - const nextChannel = { - ...oldChannel, - children: [{ ...oldChannel.children[0], [field]: value, volume: 30 }], - }; + ])( + "plays an update as the incoming track when %s changes", + (field, value) => { + const nextChannel = { + ...oldChannel, + children: [{ ...oldChannel.children[0], [field]: value, volume: 30 }], + }; - expect(() => resolveWith({ nextChannel })).toThrow( - "changes source identity", - ); + const effect = resolveWith({ nextChannel }); + + expect(effect).toMatchObject({ + targetId: "bgm:main", + properties: { + volume: { + enter: { + keyframes: [ + expect.objectContaining({ value: 50, duration: 50 }), + expect.objectContaining({ value: 30, duration: 100 }), + ], + }, + }, + }, + }); + expect(Object.keys(effect.properties.volume)).toEqual(["enter"]); + }, + ); + + it("plays an update as the incoming track when music starts", () => { + const effect = resolveWith({ + selection: { resourceId: "fadeIn", playback: { speed: 2 } }, + resources: { + audioEffects: { + fadeIn: { + type: "update", + tween: { + volume: { + initialValue: 0, + keyframes: [{ value: 30, duration: 800, easing: "easeIn" }], + }, + }, + }, + }, + }, + previousChannel: null, + }); + + expect(effect).toEqual({ + id: "audio-effect:engine:g1:l2:audio1", + type: "audio-transition", + targetId: "bgm:main", + properties: { + volume: { + enter: { + initialValue: 0, + keyframes: [ + { value: 30, delay: 0, duration: 400, easing: "easeIn" }, + ], + }, + }, + }, + }); + }); + + it("requires an update played on incoming music to end at its mix", () => { + expect(() => + resolveWith({ + previousChannel: null, + nextChannel: { + ...oldChannel, + children: [{ ...oldChannel.children[0], volume: 40 }], + }, + }), + ).toThrow("must match the persistent BGM volume value"); + }); + + it("plays an update as the outgoing track when music stops", () => { + const effect = resolveWith({ nextChannel: null }); + + expect(effect.properties).toEqual({ + volume: { + exit: { + keyframes: [ + { value: 50, delay: 0, duration: 50, easing: "linear" }, + { value: 30, delay: 0, duration: 100, easing: "linear" }, + ], + }, + }, + }); + }); + + it("validates an update played on music that stops", () => { + expect(() => + resolveWith({ + selection: { resourceId: "smooth", playback: { speed: 0 } }, + nextChannel: null, + }), + ).toThrow("must be a finite number greater than 0"); }); it.each([0, -1, Number.POSITIVE_INFINITY, Number.NaN])( @@ -952,6 +1040,136 @@ describe("resolveAudioEffect", () => { }); }); + it("plays an update as the incoming track of a replacement BGM sound", () => { + const effects = resolveAudioEffects({ + occurrence: { ...occurrence, selection: { resourceId: "smooth" } }, + resources: { audioEffects: { smooth: updateResource } }, + previousChannel: oldChannel, + nextChannel: { + ...oldChannel, + children: [ + { + ...oldChannel.children[0], + id: "bgm:other", + src: "other.ogg", + volume: 30, + }, + ], + }, + }); + + expect(effects).toHaveLength(1); + expect(effects[0]).toMatchObject({ + targetId: "bgm:other", + properties: { + volume: { + enter: { + keyframes: [ + expect.objectContaining({ value: 50 }), + expect.objectContaining({ value: 30 }), + ], + }, + }, + }, + }); + expect(effects[0].properties.volume).not.toHaveProperty("exit"); + }); + + it("updates retained BGM sounds and enters new ones with one update preset", () => { + const nextChannel = { + ...oldChannel, + children: [ + { ...oldChannel.children[0], volume: 30 }, + { + ...oldChannel.children[0], + id: "bgm:ambience", + src: "ambience.ogg", + volume: 30, + }, + ], + }; + + const effects = resolveAudioEffects({ + occurrence: { ...occurrence, selection: { resourceId: "smooth" } }, + resources: { audioEffects: { smooth: updateResource } }, + previousChannel: oldChannel, + nextChannel, + }); + + expect(effects.map((effect) => effect.targetId)).toEqual([ + "bgm:main", + "bgm:ambience", + ]); + expect(Object.keys(effects[0].properties.volume)).toEqual(["update"]); + expect(Object.keys(effects[1].properties.volume)).toEqual(["enter"]); + expect(new Set(effects.map((effect) => effect.id)).size).toBe(2); + }); + + it("plays an update as the outgoing track of every stopped BGM sound", () => { + const previousChannel = { + ...oldChannel, + children: [ + oldChannel.children[0], + { ...oldChannel.children[0], id: "bgm:ambience", src: "a.ogg" }, + ], + }; + + const effects = resolveAudioEffects({ + occurrence: { ...occurrence, selection: { resourceId: "smooth" } }, + resources: { audioEffects: { smooth: updateResource } }, + previousChannel, + nextChannel: { ...oldChannel, children: [] }, + }); + + expect(effects.map((effect) => effect.targetId)).toEqual([ + "bgm:main", + "bgm:ambience", + ]); + effects.forEach((effect) => { + expect(Object.keys(effect.properties.volume)).toEqual(["exit"]); + }); + }); + + it("prefers current BGM sounds over removed ones for an update preset", () => { + const previousChannel = { + ...oldChannel, + children: [ + oldChannel.children[0], + { ...oldChannel.children[0], id: "bgm:removed", src: "removed.ogg" }, + ], + }; + const nextChannel = { + ...oldChannel, + children: [ + { ...oldChannel.children[0], volume: 30 }, + { + ...oldChannel.children[0], + id: "bgm:added", + src: "added.ogg", + volume: 30, + }, + ], + }; + + const effects = resolveAudioEffects({ + occurrence: { ...occurrence, selection: { resourceId: "smooth" } }, + resources: { audioEffects: { smooth: updateResource } }, + previousChannel, + nextChannel, + }); + + expect( + effects.map((effect) => [ + effect.id, + effect.targetId, + Object.keys(effect.properties.volume), + ]), + ).toEqual([ + ["audio-effect:engine:g1:l2:audio1", "bgm:main", ["update"]], + ["audio-effect:engine:g1:l2:audio1:1", "bgm:added", ["enter"]], + ]); + }); + it("normalizes channel updates per sound without flattening the local mix", () => { const previousChannel = { ...oldChannel, @@ -1114,6 +1332,93 @@ describe("resolveAudioEffect", () => { }); }); + describe("relative pan in an update played as an incoming track", () => { + const panResources = { + sounds: { old: { fileId: "old.ogg" }, next: { fileId: "new.ogg" } }, + audioEffects: { + panSweep: { + type: "update", + tween: { + pan: { + keyframes: [ + { value: 0.2, relative: true, duration: 100 }, + { value: 0.5, duration: 200 }, + ], + }, + }, + }, + }, + }; + const resolvePan = ({ previousBgm, previousChannel }) => + resolveAudioEffects({ + occurrence: { ...occurrence, selection: { resourceId: "panSweep" } }, + resources: panResources, + previousBgm, + nextBgm: { pan: 0.5, sounds: [{ id: "main", resourceId: "next" }] }, + previousChannel, + nextChannel: { + ...oldChannel, + children: [{ ...oldChannel.children[0], src: "new.ogg", pan: 0.5 }], + }, + })[0].properties.pan.enter.keyframes.map((keyframe) => keyframe.value); + + it("continues from the channel pan before a source change", () => { + expect( + resolvePan({ + previousBgm: { + pan: -0.4, + sounds: [{ id: "main", resourceId: "old" }], + }, + previousChannel: { + ...oldChannel, + children: [{ ...oldChannel.children[0], pan: -0.4 }], + }, + }), + ).toEqual([-0.2, 0.5]); + }); + + it("starts from the new channel pan when music starts", () => { + expect( + resolvePan({ previousBgm: undefined, previousChannel: null }), + ).toEqual([0.7, 0.5]); + }); + }); + + it("plays a playback-rate update as the incoming track of new music", () => { + const effect = resolveWith({ + selection: { resourceId: "speedUp" }, + resources: { + audioEffects: { + speedUp: { + type: "update", + tween: { + playbackRate: { + initialValue: 1, + keyframes: [{ value: 1.5, duration: 300 }], + }, + }, + }, + }, + }, + previousChannel: null, + nextChannel: { + ...oldChannel, + children: [{ ...oldChannel.children[0], playbackRate: 1.5 }], + }, + }); + + expect(effect.properties).toEqual({ + playbackRate: { + enter: { + initialValue: 1, + keyframes: [ + { value: 1.5, delay: 0, duration: 300, easing: "linear" }, + ], + }, + }, + }); + }); + it("writes update endpoints across a multi-sound BGM channel", () => { const bgm = { volume: 80, diff --git a/src/resolveAudioEffects.js b/src/resolveAudioEffects.js index 21a291a6..2141ced2 100644 --- a/src/resolveAudioEffects.js +++ b/src/resolveAudioEffects.js @@ -355,6 +355,56 @@ const getTopLevelTransitionSoundGraph = (sound) => { return graph; }; +// Compile one authored property track on one side of a handoff. `side` is the +// sound with its BGM and resources: the outgoing side for an exit track, the +// incoming side otherwise. `baselineBgm` holds the channel pan that relative +// pan keyframes start from. Enter and update tracks must end at the incoming +// sound's persistent mix. +const compileLifecycleTrack = ({ + phase, + side, + baselineBgm, + authored, + property, + propertyPath, + actionPath, + speed, +}) => { + const track = compileChannelProperty({ + authored, + bgm: side.bgm, + baselineBgm, + property, + propertyPath, + renderedSound: side.sound, + resources: side.resources, + speed, + }); + if (phase !== "exit") { + settlePropertyEndpoint({ + actionPath, + compiled: track, + property, + propertyPath, + renderedSound: side.sound, + }); + } + return track; +}; + +// The phase an update resource plays on one sound, as a visual update +// animation does when its element appears, changes, or is removed: a retained +// sound updates, an added or source-replaced sound plays the tween as its +// incoming track, and a removed sound plays it as its outgoing track. +// resolveAudioEffects passes removed sounds only when a line stops them all. +const getUpdatePhase = (previousSound, nextSound) => { + if (!nextSound) return "exit"; + if (!previousSound || !isSameSourceIdentity(previousSound, nextSound)) { + return "enter"; + } + return "update"; +}; + export const resolveAudioEffect = ({ occurrence, resources = {}, @@ -390,55 +440,55 @@ export const resolveAudioEffect = ({ } const targetId = previousSound?.id ?? nextSound.id; - const sameSource = isSameSourceIdentity(previousSound, nextSound); - const sameGraph = isSameValue( - getTopLevelTransitionSoundGraph(previousSound), - getTopLevelTransitionSoundGraph(nextSound), - ); + const previous = { + sound: previousSound, + bgm: previousBgm, + resources: previousResources, + }; + const next = { sound: nextSound, bgm: nextBgm, resources: nextResources }; + const properties = {}; if (resource.type === "transition") { - if (sameSource) { + if (isSameSourceIdentity(previousSound, nextSound)) { + const sameGraph = isSameValue( + getTopLevelTransitionSoundGraph(previousSound), + getTopLevelTransitionSoundGraph(nextSound), + ); if (sameGraph) return null; throw new Error( `[${actionPath}.audioEffects]\n[${resourcePath}] Audio effect resource "${resourceId}" has type "transition", but the BGM action only updates a retained sound. Use an update resource.`, ); } - const properties = {}; for (const property of AUDIO_EFFECT_PROPERTIES) { const lifecycle = {}; const previousTrack = previousSound ? resource.prev?.[property] : undefined; if (previousTrack) { - lifecycle.exit = compileChannelProperty({ + lifecycle.exit = compileLifecycleTrack({ + phase: "exit", + side: previous, + baselineBgm: previousBgm, authored: previousTrack, - bgm: previousBgm, property, propertyPath: `${resourcePath}.prev.${property}`, - renderedSound: previousSound, - resources: previousResources, + actionPath, speed, }); } const nextTrack = nextSound ? resource.next?.[property] : undefined; if (nextTrack) { - lifecycle.enter = compileChannelProperty({ + lifecycle.enter = compileLifecycleTrack({ + phase: "enter", + side: next, + baselineBgm: nextBgm, authored: nextTrack, - bgm: nextBgm, property, propertyPath: `${resourcePath}.next.${property}`, - renderedSound: nextSound, - resources: nextResources, - speed, - }); - settlePropertyEndpoint({ actionPath, - compiled: lifecycle.enter, - property, - propertyPath: `${resourcePath}.next.${property}`, - renderedSound: nextSound, + speed, }); } @@ -446,55 +496,40 @@ export const resolveAudioEffect = ({ properties[property] = lifecycle; } } - if (Object.keys(properties).length === 0) return null; - - return { - ...createBaseEffect(occurrence, targetId), - properties, - }; - } + } else if (resource.type === "update") { + const phase = getUpdatePhase(previousSound, nextSound); + // Relative pan continues from the channel as it was before the line; a + // channel that only starts now has its own pan. + const baselineBgm = + phase === "enter" ? (previousBgm ?? nextBgm) : previousBgm; + for (const property of AUDIO_EFFECT_PROPERTIES) { + if (!hasOwn(resource.tween, property)) continue; + const track = compileLifecycleTrack({ + phase, + side: phase === "exit" ? previous : next, + baselineBgm, + authored: resource.tween[property], + property, + propertyPath: `${resourcePath}.tween.${property}`, + actionPath, + speed, + }); - if (resource.type !== "update") { + // A retained update only animates values that change. + if ( + phase === "update" && + (previousSound[property] ?? DEFAULT_AUDIO_VALUES[property]) === + (nextSound[property] ?? DEFAULT_AUDIO_VALUES[property]) + ) { + continue; + } + properties[property] = { [phase]: track }; + } + } else { throw new Error( `[${resourcePath}.type] Unsupported audio effect type "${resource.type}".`, ); } - if (!previousSound || !nextSound || !sameSource) { - throw new Error( - `[${actionPath}.audioEffects]\n[${resourcePath}] Audio effect resource "${resourceId}" has type "update", but the BGM action changes source identity. Use a transition resource.`, - ); - } - const properties = {}; - for (const property of AUDIO_EFFECT_PROPERTIES) { - if (!hasOwn(resource.tween, property)) continue; - const propertyPath = `${resourcePath}.tween.${property}`; - const update = compileChannelProperty({ - property, - authored: resource.tween[property], - speed, - propertyPath, - baselineBgm: previousBgm, - bgm: nextBgm, - resources: nextResources, - renderedSound: nextSound, - }); - const nextValue = nextSound[property] ?? DEFAULT_AUDIO_VALUES[property]; - settlePropertyEndpoint({ - actionPath, - compiled: update, - property, - propertyPath, - renderedSound: nextSound, - }); - - const previousValue = - previousSound[property] ?? DEFAULT_AUDIO_VALUES[property]; - if (previousValue === nextValue) continue; - - properties[property] = { - update, - }; - } if (Object.keys(properties).length === 0) return null; @@ -578,28 +613,25 @@ export const resolveAudioEffects = (options) => { const previousById = new Map( previousSounds.map((sound) => [sound.id, sound]), ); - const nextById = new Map(nextSounds.map((sound) => [sound.id, sound])); if (resource.type === "update") { - const targets = nextSounds.map((nextSound) => ({ - previousSound: previousById.get(nextSound.id), - nextSound, - })); - const missingPreviousSound = targets.find( - ({ previousSound }) => !previousSound, - ); - if (missingPreviousSound) { - return resolveTargets([missingPreviousSound]); + // Like a visual update animation, prefer the current sounds: retained + // sounds update, and added or replaced sounds enter. Removed sounds play + // the update as their outgoing track only when the line stops them all. + if (nextSounds.length === 0) { + return resolveTargets( + previousSounds.map((previousSound) => ({ previousSound })), + ); } - const removedSound = previousSounds.find( - (previousSound) => !nextById.has(previousSound.id), + return resolveTargets( + nextSounds.map((nextSound) => ({ + previousSound: previousById.get(nextSound.id), + nextSound, + })), ); - if (removedSound) { - return resolveTargets([{ previousSound: removedSound }]); - } - return resolveTargets(targets); } + const nextById = new Map(nextSounds.map((sound) => [sound.id, sound])); const targets = []; for (const previousSound of previousSounds) { const nextSound = nextById.get(previousSound.id); diff --git a/src/schemas/projectData/audioEffectResource.yaml b/src/schemas/projectData/audioEffectResource.yaml index 2b97949b..26c1b65f 100644 --- a/src/schemas/projectData/audioEffectResource.yaml +++ b/src/schemas/projectData/audioEffectResource.yaml @@ -1,6 +1,6 @@ $schema: http://json-schema.org/draft-07/schema# title: Audio Effect Resource -description: Reusable BGM transition or retained-property update compiled to Route Graphics audioEffects +description: Reusable BGM transition, or update played as an update, incoming, or outgoing track depending on the sound's lifecycle, compiled to Route Graphics audioEffects type: object required: [type] $defs: @@ -40,7 +40,7 @@ $defs: - easeInOutElastic keyframeValue: type: number - description: Numeric tween value; the final absolute value becomes the persistent BGM property value + description: Numeric tween value; when the line keeps or starts music, the final absolute value becomes the persistent BGM property value keyframe: type: object required: [value, duration] diff --git a/vt/specs/audioChannels/audio-effects-update-initial-entry.yaml b/vt/specs/audioChannels/audio-effects-update-initial-entry.yaml new file mode 100644 index 00000000..0a9b75fb --- /dev/null +++ b/vt/specs/audioChannels/audio-effects-update-initial-entry.yaml @@ -0,0 +1,76 @@ +--- +title: Audio Effects Update Initial Entry +description: An update audio effect on the line that starts the music plays as the sound's incoming track. +skipInitialScreenshot: true +specs: + - the initial BGM has no outgoing sound, and the line does not throw + - the engine emits one entry track built from the update tween +steps: + - action: wait + ms: 250 + - action: assert + type: js + global: __vtLastRenderState.audio.0.children.0.src + value: 3ka3s + - action: assert + type: js + global: __vtLastAudioEffect.targetId + value: bgm:main + - action: assert + type: js + global: __vtLastAudioEffect.properties.volume.enter.initialValue + value: 0 + - action: assert + type: js + global: __vtLastAudioEffect.properties.volume.enter.keyframes.0.value + value: 70 + - action: assert + type: js + global: __vtAudioEffectOccurrenceCount + value: 1 +--- +screen: + width: 640 + height: 360 +resources: + sounds: + theme: + fileId: 3ka3s + audioEffects: + fade-up: + type: update + tween: + volume: + initialValue: 0 + keyframes: + - { value: 70, duration: 800, easing: easeOutSine } + colors: + initial: + hex: "#163A2C" + controls: + initial: + elements: + - id: initial + type: rect + width: 640 + height: 360 + colorId: initial +story: + initialSceneId: scene1 + scenes: + scene1: + initialSectionId: section1 + sections: + section1: + lines: + - id: initial + actions: + control: + resourceId: initial + bgm: + volume: 70 + audioEffects: + resourceId: fade-up + sounds: + - id: main + resourceId: theme diff --git a/vt/specs/audioChannels/audio-effects-update-source-change.yaml b/vt/specs/audioChannels/audio-effects-update-source-change.yaml new file mode 100644 index 00000000..f7cf6253 --- /dev/null +++ b/vt/specs/audioChannels/audio-effects-update-source-change.yaml @@ -0,0 +1,110 @@ +--- +title: Audio Effects Update Source Change +description: An update audio effect on a line that switches the music plays as the new track's incoming track. +skipInitialScreenshot: true +specs: + - the first music plays without an effect + - switching tracks with an update effect does not throw + - the new track enters with the update tween, and the old one stops +steps: + - action: wait + ms: 250 + - action: assert + type: js + global: __vtLastAudioEffect + value: null + - action: click + x: 320 + y: 180 + - action: wait + ms: 250 + - action: assert + type: js + global: __vtLastRenderState.audio.0.children.0.src + value: 39csl + - action: assert + type: js + global: __vtLastAudioEffect.targetId + value: bgm:main + - action: assert + type: js + global: __vtLastAudioEffect.properties.volume.enter.initialValue + value: 0 + - action: assert + type: js + global: __vtLastAudioEffect.properties.volume.enter.keyframes.0.value + value: 70 + - action: assert + type: js + global: __vtAudioEffectOccurrenceCount + value: 1 +--- +screen: + width: 640 + height: 360 +resources: + sounds: + theme: + fileId: 3ka3s + other: + fileId: 39csl + audioEffects: + fade-up: + type: update + tween: + volume: + initialValue: 0 + keyframes: + - { value: 70, duration: 800, easing: easeOutSine } + colors: + advance: + hex: "#10131A" + switched: + hex: "#163A2C" + controls: + advance: + elements: + - id: advance + type: rect + width: 640 + height: 360 + colorId: advance + click: + payload: + actions: + nextLine: {} + switched: + elements: + - id: switched + type: rect + width: 640 + height: 360 + colorId: switched +story: + initialSceneId: scene1 + scenes: + scene1: + initialSectionId: section1 + sections: + section1: + lines: + - id: theme + actions: + control: + resourceId: advance + bgm: + volume: 70 + sounds: + - id: main + resourceId: theme + - id: switch + actions: + control: + resourceId: switched + bgm: + volume: 70 + audioEffects: + resourceId: fade-up + sounds: + - id: main + resourceId: other diff --git a/vt/specs/audioChannels/audio-effects-update-stop.yaml b/vt/specs/audioChannels/audio-effects-update-stop.yaml new file mode 100644 index 00000000..bfeceafd --- /dev/null +++ b/vt/specs/audioChannels/audio-effects-update-stop.yaml @@ -0,0 +1,100 @@ +--- +title: Audio Effects Update Stop +description: An update audio effect on a line that stops the music plays as the sound's outgoing track, so an update to volume 0 fades the music out. +skipInitialScreenshot: true +specs: + - the music plays without an effect + - stopping it with an update effect does not throw + - the engine emits one exit track built from the update tween +steps: + - action: wait + ms: 250 + - action: assert + type: js + global: __vtLastRenderState.audio.0.children.0.src + value: 3ka3s + - action: click + x: 320 + y: 180 + - action: wait + ms: 250 + - action: assert + type: js + global: __vtLastRenderState.audio.length + value: 0 + - action: assert + type: js + global: __vtLastAudioEffect.targetId + value: bgm:main + - action: assert + type: js + global: __vtLastAudioEffect.properties.volume.exit.keyframes.0.value + value: 0 + - action: assert + type: js + global: __vtAudioEffectOccurrenceCount + value: 1 +--- +screen: + width: 640 + height: 360 +resources: + sounds: + theme: + fileId: 3ka3s + audioEffects: + fade-out: + type: update + tween: + volume: + keyframes: + - { value: 0, duration: 800, easing: easeInSine } + colors: + advance: + hex: "#10131A" + stopped: + hex: "#421B1B" + controls: + advance: + elements: + - id: advance + type: rect + width: 640 + height: 360 + colorId: advance + click: + payload: + actions: + nextLine: {} + stopped: + elements: + - id: stopped + type: rect + width: 640 + height: 360 + colorId: stopped +story: + initialSceneId: scene1 + scenes: + scene1: + initialSectionId: section1 + sections: + section1: + lines: + - id: theme + actions: + control: + resourceId: advance + bgm: + volume: 70 + sounds: + - id: main + resourceId: theme + - id: stop + actions: + control: + resourceId: stopped + bgm: + audioEffects: + resourceId: fade-out + sounds: []