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
1 change: 1 addition & 0 deletions docs/story-data-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[<cue sheet id>]._cueSheetName` the directory `story.json` `audio` names. Lip sync reads the
Expand Down
5 changes: 5 additions & 0 deletions docs/story.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions scripts/validate-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
15 changes: 13 additions & 2 deletions src/engine/audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 "<name>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
Expand Down Expand Up @@ -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: [],
Expand Down
26 changes: 24 additions & 2 deletions tests/unit/engine-audio.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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 });
Expand Down
Loading