diff --git a/docs/story.md b/docs/story.md index f2ba2a4..ae02e0d 100644 --- a/docs/story.md +++ b/docs/story.md @@ -249,6 +249,12 @@ story player in their host screen ([story-simple.md](story-simple.md)). - The backlog (`TalkLogView`) and the choices (`ChoiceView`): they stay hidden as the game's refresh leaves them (the backlog at alpha 0, the choice items inactive). The backlog does not open; its entries are the `log` events. +### Departures from the data + +- The voices of Talk and Voice rows play on the Voice category whatever their `-Sound` row's `_category` says. The + linkstory episodes ADV 10579–10608 put their voices on Bgm (`_category` 0), which would have the music volume turn + the voices down; here the voice volume governs them, as it does every other episode's voices. + ### Engine behaviour Behaviour of the engine (Unity, URP, CRI) that the game's code does not show is marked `ENGINE:` in the sources, as in diff --git a/src/engine/audio.js b/src/engine/audio.js index e29f66d..1e1f58a 100644 --- a/src/engine/audio.js +++ b/src/engine/audio.js @@ -24,6 +24,9 @@ export const SOUND_CATEGORY = { Bgm: 0, Se: 1, Voice: 2, NotesSe: 3, All: 9999 } export const framesAt = (n, from, to, round = Math.round) => (from === to ? n : round(n * to / from)); export const SOUND_BUS_GAIN = { 0: 0.7, 1: 1.0, 2: 1.0 }; +// a resolved cue on another category than its row's (null: the row's) +export const withCategory = (c, category) => (category === null || category === c.category ? c : { ...c, category }); + // Fwk.Sound.SoundVolume over the CRI category volumes. SoundVolumeSettings gives each category name its // DefaultVolume; SoundManager.Boot calls ChangeVolume("All", 1.0), so every category starts at DefaultVolume. // ChangeVolume: "All" (SoundCategoryNames.All) -> ChangeVolumeAll (every settings entry); @@ -226,10 +229,11 @@ export class Audio { } } - // SoundPlayer.Play; returns the unique sound id (synchronous) + // SoundPlayer.Play; returns the unique sound id (synchronous). `category`: the bus to play on instead of the row's + // _category (the story's voices, see tryPlayVoice) play(soundId, { isAutoCrossFade = false, crossFade = 0.3, startSec = 0, speed = 1, onPlayStart = null, loop = false, - forceFadeIn = false, volume = 1 } = {}) { - const c = this.cueOf(soundId); + forceFadeIn = false, volume = 1, category = null } = {}) { + const c = withCategory(this.cueOf(soundId), category); const entry = this.buffers.get(`${c.sheet}/${c.cue}`); if (!entry) throw new Error(`cue ${c.cue} not preloaded`); const rate = playbackSpeed(speed); diff --git a/src/story/commands/talk.js b/src/story/commands/talk.js index 6c73638..c4c2a91 100644 --- a/src/story/commands/talk.js +++ b/src/story/commands/talk.js @@ -1,3 +1,4 @@ +import { SOUND_CATEGORY } from "../../engine/audio.js"; import { floatParam } from "../params.js"; import { addLogEntry } from "../features/talklog.js"; import { removeTagsKeepingRuby } from "../ui-talk.js"; @@ -53,9 +54,12 @@ export const stopCurrentVoices = (p, preserveAutoAdvToken = false) => { // AdvTalkVoicePlaybackHelper.TryPlayVoice: SoundManager.Play at the playback speed; onPlayStart runs before the sound // starts (the lip sync is attached first) +// DEVIATION: the voice plays on the Voice category whatever its -Sound row's _category says. The rows of some episodes +// put their voices on Bgm (_category 0: the linkstory episodes of ADV 10579-10608), which would have the music volume +// turn the voices down; the Voice volume is the one the app's options give the voices. export const tryPlayVoice = (p, id, sounds, onStart) => { const a = p.ctx.audio, rate = p.speedRate(); - const pid = a.play(id, { crossFade: 0.3, ...(rate !== 1 ? { speed: rate } : {}), + const pid = a.play(id, { crossFade: 0.3, category: SOUND_CATEGORY.Voice, ...(rate !== 1 ? { speed: rate } : {}), onPlayStart: (info) => { sounds.push(info); if (onStart) onStart(info); } }); p.session.voicePlayIds.push(pid); return pid; diff --git a/src/story/silent-audio.js b/src/story/silent-audio.js index c79eaac..8929224 100644 --- a/src/story/silent-audio.js +++ b/src/story/silent-audio.js @@ -1,4 +1,4 @@ -import { SOUND_CATEGORY, SoundCategoryVolumes } from "../engine/audio.js"; +import { SOUND_CATEGORY, SoundCategoryVolumes, withCategory } from "../engine/audio.js"; // SilentAudio: the SoundManager of a story session without Web Audio (Node, or a page that plays the story muted). // It keeps the sounds' timing on the player loop's clock and plays nothing: a cue lasts its length in cues.json @@ -31,9 +31,10 @@ export class SilentAudio { async preload(soundIds) { if (this.sounds) for (const id of soundIds) this.cueOf(id); } - play(soundId, { isAutoCrossFade = false, crossFade = 0.3, startSec = 0, speed = 1, onPlayStart = null, loop = false } = {}) { + play(soundId, { isAutoCrossFade = false, crossFade = 0.3, startSec = 0, speed = 1, onPlayStart = null, loop = false, + category = null } = {}) { if (!this.sounds) return -1; - const c = this.cueOf(soundId); + const c = withCategory(this.cueOf(soundId), category); if (isAutoCrossFade && c.category === SOUND_CATEGORY.Bgm) for (const i of [...this.playing.values()]) if (i.category === SOUND_CATEGORY.Bgm) this._stopInfo(i, true, crossFade); const length = Math.max(0, c.meta.samples / c.meta.sampleRate - Math.trunc(startSec * 1000) / 1000) / speed; diff --git a/tests/unit/engine-audio.test.mjs b/tests/unit/engine-audio.test.mjs index 835604f..2228ebb 100644 --- a/tests/unit/engine-audio.test.mjs +++ b/tests/unit/engine-audio.test.mjs @@ -2,7 +2,8 @@ // a voice played time-stretched and the movie bus of the videos' sound; engine/timestretch.js on synthetic PCM. import assert from "node:assert/strict"; import { test } from "node:test"; -import { Audio, playbackSpeed } from "../../src/engine/audio.js"; +import { Audio, SOUND_CATEGORY, playbackSpeed } from "../../src/engine/audio.js"; +import { SilentAudio } from "../../src/story/silent-audio.js"; import { timeStretch } from "../../src/engine/timestretch.js"; const F = Math.fround; @@ -16,7 +17,7 @@ const fakeContext = () => { }; return { sampleRate: 48000, currentTime: 0, state: "running", destination: {}, - createGain() { return { gain: param(1), connect(n) { return n; } }; }, + createGain() { return { gain: param(1), to: null, connect(n) { this.to = n; return n; } }; }, createMediaElementSource(el) { const n = { el, to: null, connect(x) { n.to = x; return x; }, disconnect() { n.to = null; } }; this.media.push(n); @@ -65,6 +66,26 @@ test("option volumes multiply the category bus; All scales by DefaultVolume, a n assert.ok(Number.isNaN(a.getOptionVolume("Nope"))); }); +test("play's category: the cue on that bus instead of its row's category; stopAll by the category played on", () => { + const ctx = fakeContext(), loop = { time: 0 }; + const cue = { sheet: "s", cue: "c", category: SOUND_CATEGORY.Bgm, row: {} }; // a voice row on Bgm (ADV 10579) + const a = new Audio(() => cue, loop, { context: ctx }); + a.buffers.set("s/c", { buf: ctx.buffer(1, 48000, 48000), meta: { sampleRate: 48000, samples: 48000 } }); + const voice = a.get(a.play(1, { crossFade: 0, category: SOUND_CATEGORY.Voice })); + const bgm = a.get(a.play(1, { crossFade: 0 })); + assert.deepEqual([voice.category, voice.cue.category, bgm.category], [SOUND_CATEGORY.Voice, SOUND_CATEGORY.Voice, SOUND_CATEGORY.Bgm]); + assert.deepEqual([voice.gain.to === a.buses[SOUND_CATEGORY.Voice], bgm.gain.to === a.buses[SOUND_CATEGORY.Bgm]], [true, true]); + assert.equal(cue.category, SOUND_CATEGORY.Bgm); // the resolved row is left as it is + a.stopAll(SOUND_CATEGORY.Bgm); + assert.deepEqual([a.isPlaying(voice.id), a.isPlaying(bgm.id)], [true, false]); + + const silent = new SilentAudio(() => cue, loop, { assets: { json: () => ({ c: { samples: 48000, sampleRate: 48000 } }) } }); + const sv = silent.play(1, { category: SOUND_CATEGORY.Voice }), sb = silent.play(1); + assert.deepEqual([silent.get(sv).category, silent.get(sb).category], [SOUND_CATEGORY.Voice, SOUND_CATEGORY.Bgm]); + silent.stopAll(SOUND_CATEGORY.Bgm); + assert.deepEqual([silent.isPlaying(sv), silent.isPlaying(sb)], [true, false]); +}); + test("a video's sound: on the movie bus at the movie volume, which no category or option volume changes", () => { const ctx = fakeContext(); const a = new Audio(() => null, { time: 0 }, { context: ctx }); diff --git a/tests/unit/story-feature-voice.test.mjs b/tests/unit/story-feature-voice.test.mjs index 9030700..94ee48c 100644 --- a/tests/unit/story-feature-voice.test.mjs +++ b/tests/unit/story-feature-voice.test.mjs @@ -23,9 +23,9 @@ const steps = async (loop, n) => { await flush(); for (let i = 0; i < n; i++) { // a sound manager whose voices finish `len` frames after they start (onFinished runs on stop and on the end) const fakeAudio = (loop, len) => { const a = { nextId: 1, playing: new Map(), log: [], lastBgmOrSeFrame: -1, - play(soundId, { onPlayStart = null } = {}) { - const info = { id: a.nextId++, soundId, onFinished: [], end: loop.frameCount + len }; - a.playing.set(info.id, info); a.log.push(["play", soundId, loop.frameCount]); + play(soundId, { onPlayStart = null, category = null } = {}) { + const info = { id: a.nextId++, soundId, category, onFinished: [], end: loop.frameCount + len }; + a.playing.set(info.id, info); a.log.push(["play", soundId, loop.frameCount, category]); if (onPlayStart) onPlayStart(info); return info.id; }, @@ -64,6 +64,8 @@ test("Voice (auto): the voices, then 0.6 s from the frame after the last one's e // costs one tick; then 18 frames of DelayWithSpeedAdjustment(0.6) assert.equal(frames, 20 + 1 + 2 + 18); assert.deepEqual(t.ctx.audio.log.filter((e) => e[0] === "play").map((e) => e[1]), [7, 8, 9]); + // on the Voice category whatever the rows say (ADV 10579-10608 put their voices on Bgm) + assert.deepEqual(t.ctx.audio.log.filter((e) => e[0] === "play").map((e) => e[3]), [2, 2, 2]); // lip sync on the voice (A: voice 1, B: voice 2), stopped at the voice's end and again by the row's end; voice 3 has // no speaker assert.deepEqual(t.chLog.filter((e) => e[0] === "A" && e[1] === "lips").map((e) => e[2]), [1, null, null]);