From e1ff16dd9010bf90fda8b25ab614d25683eeaa82 Mon Sep 17 00:00:00 2001 From: luoxiadesu <249937048+luoxiadesu@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:04:59 +0900 Subject: [PATCH] fix(story): play the voices on the Voice category The -Sound rows of the linkstory episodes ADV 10579-10608 put their voices on Bgm (_category 0) where every other episode has Voice (2). The voices then played on the Bgm bus, so the music volume turned them down and muting the music silenced them. tryPlayVoice now plays every voice of a Talk or Voice row on the Voice category whatever its row says: Audio.play and SilentAudio.play take an optional `category` that overrides the resolved cue's. Without it they play as before. docs/story.md lists this departure from the data. Co-Authored-By: Claude Opus 5.5 --- docs/story.md | 6 ++++++ src/engine/audio.js | 10 +++++++--- src/story/commands/talk.js | 6 +++++- src/story/silent-audio.js | 7 ++++--- tests/unit/engine-audio.test.mjs | 25 +++++++++++++++++++++++-- tests/unit/story-feature-voice.test.mjs | 8 +++++--- 6 files changed, 50 insertions(+), 12 deletions(-) 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]);