diff --git a/docs/story-data-format.md b/docs/story-data-format.md index a7360de..7d18f45 100644 --- a/docs/story-data-format.md +++ b/docs/story-data-format.md @@ -332,6 +332,7 @@ Other keys are not read. | `sampleRate`, `channels`, `samples` | As in a live's [sound layers](data-format.md#sounds-audiolive-audiojson): `samples` counts frames at `sampleRate`, without encoder padding. | | `loopStart`, `loopEnd` | Optional: loop points in sample frames. | | `encoderDelay` | Optional, AAC only: the priming samples at the start of the stream, handled as for a live's layers. | +| `categories` | Optional: the CRI category names the cue plays in, from its cue sheet (`["Voice", "VoiceConfig"]`). The cue plays on the bus of the first of `Bgm`, `Se` and `Voice` among them; without it, on the bus of its `-Sound` row's `_category`. The row's `_category` still decides which sounds a stop of a category and a BGM crossfade reach. | A sound id of a row (`BgmID`, `SeID`, `VoiceIDs`) resolves through `episode.json`: `sounds[id]` gives the cue name (`_cueName`) and the cue sheet id (`_soundCueSheetID`), `cuesheets[]._cueSheetName` the directory `story.json` `audio` names. Lip sync reads the diff --git a/docs/story.md b/docs/story.md index f2ba2a4..edb5958 100644 --- a/docs/story.md +++ b/docs/story.md @@ -253,3 +253,8 @@ story player in their host screen ([story-simple.md](story-simple.md)). Behaviour of the engine (Unity, URP, CRI) that the game's code does not show is marked `ENGINE:` in the sources, as in the rest of the player ([fidelity.md](fidelity.md)). + +- Which volume governs a sound: the CRI category volumes (music, sound effect, voice) reach a cue through the + categories its cue sheet puts it in (`categories` of the cue in `cues.json`), not through the `_category` of its + `-Sound` row; the row's `_category` decides which sounds a stop of a category and a BGM crossfade reach. The voices + of the linkstory episodes ADV 10579–10608 are rows of category Bgm in cue sheets of category Voice. diff --git a/scripts/validate-data.mjs b/scripts/validate-data.mjs index 765673b..24aa36b 100644 --- a/scripts/validate-data.mjs +++ b/scripts/validate-data.mjs @@ -940,6 +940,7 @@ class Story extends Chart { if (extOf(c.file) !== WAVE_EXT[m.audioFormat]) this.err(cp, `${cue}: ${c.file} does not match audioFormat ${m.audioFormat}`); if ((c.loopStart === undefined) !== (c.loopEnd === undefined)) this.err(cp, `${cue}: loopStart and loopEnd must both be set or both absent`); else if (c.loopStart !== undefined && !(c.loopStart < c.loopEnd && c.loopEnd <= c.samples)) this.err(cp, `${cue}: loop points outside the waveform`); + if (c.categories !== undefined && !(Array.isArray(c.categories) && c.categories.every((x) => typeof x === "string"))) this.err(cp, `${cue}: categories must be an array of strings`); if (c.encoderDelay !== undefined && (extOf(c.file) !== "m4a" || !Number.isInteger(c.encoderDelay) || c.encoderDelay < 0)) this.err(cp, `${cue}: encoderDelay ${c.encoderDelay}`); const p = `${dir}/${c.file}`; if (!has(this.common, p)) { this.err(cp, `${cue}: ${p} is not a common file`); continue; } diff --git a/src/engine/audio.js b/src/engine/audio.js index b1a3680..3114d55 100644 --- a/src/engine/audio.js +++ b/src/engine/audio.js @@ -22,8 +22,20 @@ export const SOUND_CATEGORY = { Bgm: 0, Se: 1, Voice: 2, NotesSe: 3, All: 9999 } // buffer (rounded by `round`). At equal rates the count is n itself. Times in seconds (loop points, start offsets, the // audio-synced time) do not depend on the rate. export const framesAt = (n, from, to, round = Math.round) => (from === to ? n : round(n * to / from)); +export const SOUND_CATEGORY_NAME = { 0: "Bgm", 1: "Se", 2: "Voice" }; // SoundCategory enum names export const SOUND_BUS_GAIN = { 0: 0.7, 1: 1.0, 2: 1.0 }; +// The bus of a cue. ENGINE: the game never attaches a category to a sound (SoundCache.CreateSoundSource and +// SoundPlayer.Play call no CriAtomExPlayer.SetCategory), so the CRI category volumes reach a cue through the +// categories its sheet puts it in (cues.json `categories`: the CRI category names of the cue in the ACB), whatever +// category its -Sound row names. The row's category decides which sounds StopAll and the BGM crossfade reach +// (SoundInfo.Category). A cue without categories plays on its row's. +export const busOf = (meta, category) => { + for (const name of (meta && meta.categories) || []) + for (const k of Object.keys(SOUND_CATEGORY_NAME)) if (SOUND_CATEGORY_NAME[k] === name) return Number(k); + return 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); @@ -37,7 +49,6 @@ export const SOUND_BUS_GAIN = { 0: 0.7, 1: 1.0, 2: 1.0 }; // ChangeConfigVolume (the app's volume options): "All" -> every settings name's "Config" = DefaultVolume x v; // any other name -> CriAtomExCategory.SetVolume(name + "Config", v); RestoreConfigVolume = ChangeConfigVolume(1). export const SOUND_VOLUME_SETTINGS = { Bgm: 0.7, Se: 1.0, Voice: 1.0, LiveBgm: 1.0, LiveSe: 1.0, LiveNotesSe: 1.0, LiveVoice: 1.0 }; -export const SOUND_CATEGORY_NAME = { 0: "Bgm", 1: "Se", 2: "Voice" }; // SoundCategory enum names export class SoundCategoryVolumes { constructor(onChange) { this.onChange = onChange; // (name, volume) after a CRI category volume changed @@ -247,7 +258,7 @@ export class Audio { forceFadeIn = true; for (const i of [...this.playing.values()]) if (i.category === SOUND_CATEGORY.Bgm) this._stopInfo(i, true, crossFade); } - const bus = this.buses[c.category]; + const bus = this.buses[busOf(entry.meta, c.category)]; if (!bus) throw new Error(`sound category ${c.category}`); const info = { id: this.nextId++, soundId, category: c.category, cue: c, meta: entry.meta, buf: entry.buf, fade: 0, fadeStart: 0, fadeDur: 0, finished: false, stopped: false, onFinished: [], diff --git a/tests/unit/engine-audio.test.mjs b/tests/unit/engine-audio.test.mjs index 2671127..ae03899 100644 --- a/tests/unit/engine-audio.test.mjs +++ b/tests/unit/engine-audio.test.mjs @@ -2,7 +2,7 @@ // 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, busOf, playbackSpeed } from "../../src/engine/audio.js"; import { timeStretch } from "../../src/engine/timestretch.js"; const F = Math.fround; @@ -16,7 +16,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 +65,28 @@ test("option volumes multiply the category bus; All scales by DefaultVolume, a n assert.ok(Number.isNaN(a.getOptionVolume("Nope"))); }); +test("a cue plays on the bus of its sheet's CRI categories; its row's category decides what stopAll reaches", () => { + const ctx = fakeContext(), loop = { time: 0 }; + const cue = { sheet: "s", cue: "c", category: SOUND_CATEGORY.Bgm, row: {} }; // a voice cue whose row says Bgm + const a = new Audio(() => cue, loop, { context: ctx }); + const put = (categories) => a.buffers.set("s/c", { buf: ctx.buffer(1, 48000, 48000), + meta: { sampleRate: 48000, samples: 48000, ...(categories ? { categories } : {}) } }); + put(["Voice", "VoiceConfig"]); + const voice = a.get(a.play(1, { crossFade: 0 })); + put(null); + const row = a.get(a.play(1, { crossFade: 0 })); + assert.deepEqual([voice.gain.to === a.buses[SOUND_CATEGORY.Voice], row.gain.to === a.buses[SOUND_CATEGORY.Bgm]], [true, true]); + assert.deepEqual([voice.category, row.category], [SOUND_CATEGORY.Bgm, SOUND_CATEGORY.Bgm]); + a.stopAll(SOUND_CATEGORY.Bgm); + assert.deepEqual([a.isPlaying(voice.id), a.isPlaying(row.id)], [false, false]); + a.setOptionVolume("Bgm", 0); + put(["Voice", "VoiceConfig"]); + a.play(1, { crossFade: 0 }); + assert.equal(a.buses[SOUND_CATEGORY.Voice].gain.value, 1); + assert.equal(busOf({ categories: ["Other", "Se"] }, SOUND_CATEGORY.Bgm), SOUND_CATEGORY.Se); + assert.equal(busOf({ categories: ["Other"] }, SOUND_CATEGORY.Voice), SOUND_CATEGORY.Voice); +}); + 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 });