Skip to content
Closed
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
6 changes: 6 additions & 0 deletions docs/story.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/engine/audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/story/commands/talk.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions src/story/silent-audio.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 23 additions & 2 deletions tests/unit/engine-audio.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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 });
Expand Down
8 changes: 5 additions & 3 deletions tests/unit/story-feature-voice.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down Expand Up @@ -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]);
Expand Down