0.2.6-dev: no BGM download while the music is muted or at BGM volume 0; the wanted track is fetched once it is audible again (host bandwidth layer 0.1); golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 15:45:38 +08:00
1 parent a8ff5e7c1d
commit 69011d2b54
3 files changed
+79 -3

No files matched your search

+1 -1
View File
@@ -62,7 +62,7 @@ its board-space position and facing, including on a mirrored boss half. They can
from this preview. The inspection affordance does not make the preview a combat unit; the battle sim still creates
the selected variant at battle start (§30.17).
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15.
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15. While the music is silent — muted, or the BGM volume at 0 — no BGM is downloaded: the track the screen wants is remembered, a different one still playing stops, and the wanted track is fetched once the music is audible again (`AudioManager.bgmSilent`; about 14 MB a match for a player who turned the music off).
---
+21 -2
View File
@@ -13,7 +13,9 @@
// never block or warn; everything requested before that is remembered (BGM) or dropped (SFX).
// - Channels: master → { bgm, sfx, voice } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
// keeps playing across phases (prep and combat share a track).
// keeps playing across phases (prep and combat share a track). While the music is silent — muted, or the BGM volume
// at 0 — no track is downloaded (≈ 14 MB a match): the wanted one is remembered, another one playing stops, and the
// track the screen wants then starts once the music is audible again (`setVolumes`; host bandwidth, layer 0).
// - 开战 BGM: `bgm.combatAlts` are the mode's own battle tracks (塞壬唱片 骑士之日 / 无畏者). The track is fixed per
// round, not drawn: 无畏者 through rounds 1–7 and 骑士之日 from round 8 on (`combatTrackFor`, the official
// schedule), so every client of a match hears the same one, a fight never switches track halfway through and the
@@ -662,6 +664,7 @@ export class AudioManager {
*/
setVolumes(v) {
const n = (x, d) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : d);
const wasSilent = this.bgmSilent;
this.volumes = {
bgm: n(v?.bgm, this.volumes.bgm),
sfx: n(v?.sfx, this.volumes.sfx),
@@ -670,8 +673,13 @@ export class AudioManager {
};
this._applyVolumes();
if (this.volumes.muted || this.volumes.sfx <= 0) this.stopBondCue();
// the music became audible again: download and start the track the screen wants now (none was fetched meanwhile)
if (wasSilent && !this.bgmSilent && this.ctx && this.wantBgm) { const k = this.wantBgm; this.wantBgm = null; this.playBgm(k); }
}
/** Is the music silent — muted, or the BGM volume at 0? Then no BGM is downloaded (playBgm). */
get bgmSilent() { return this.volumes.muted || this.volumes.bgm <= 0; }
/**
* The voice dub (settings 语音语言): 'jp' plays `audio.voiceJp`, anything else `audio.voice` (中文, the default). The
* line on air finishes in its own dub; the next one follows the setting.
@@ -766,12 +774,23 @@ export class AudioManager {
// ---- BGM ------------------------------------------------------------------------------------------------
/**
* Switch BGM (null stops). Same loop URL ⇒ no restart.
* Switch BGM (null stops). Same loop URL ⇒ no restart. Before the first gesture, or while the music is silent
* (`bgmSilent`), the key is only remembered (`wantBgm`) and nothing is downloaded; a different track that is still
* playing stops. `_unlock` / `setVolumes` start the remembered track once it can be heard.
* @param {string|null} key see bgmKeyFor
*/
playBgm(key) {
try {
if (!this.ctx) { this.wantBgm = key; return; }
if (this.bgmSilent) {
this.wantBgm = key;
const silentTrack = resolveBgm(this.getManifest(), key);
if (this.bgm && silentTrack && this.bgm.loopUrl === silentTrack.loop) { this.bgm.key = key; return; }
++this.bgmToken; // a start still waiting for its download stays silent too
this._fadeOutBgm();
return;
}
this.wantBgm = null;
const track = resolveBgm(this.getManifest(), key);
if (this.bgm && track && this.bgm.loopUrl === track.loop) { this.bgm.key = key; return; }
if (!track && !this.bgm) return;
+57
View File
@@ -577,6 +577,63 @@ describe('AudioManager', () => {
globalThis.fetch = origFetch;
}
});
test('BGM is not downloaded while the music is muted or at volume 0; the wanted track starts once it is audible', async () => {
const fw = fakeWindow();
const origFetch = globalThis.fetch;
const urls = [];
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
const tick = () => new Promise((r) => setTimeout(r, 10));
const lobby = manifest.audio.bgm.lobby.loop;
const prep = manifest.audio.bgm.prep.loop;
assert.notEqual(lobby, prep, 'premise: two different loops');
try {
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
a.setVolumes({ muted: true });
a.install();
a.playBgm('lobby');
fw.fire('pointerdown');
await tick();
assert.equal(a.unlocked, true);
assert.equal(a.bgmSilent, true);
assert.ok(!asked(urls, lobby) && !asked(urls, manifest.audio.bgm.lobby.intro), 'muted: nothing fetched at unlock');
a.playBgm('prep');
await tick();
assert.ok(!asked(urls, prep), 'muted: a phase change fetches nothing');
assert.equal(a.wantBgm, 'prep', 'the wanted track is remembered');
a.setVolumes({ muted: false });
await tick();
assert.ok(asked(urls, prep), 'unmuted: the track the screen wants now is fetched');
assert.ok(!asked(urls, lobby), 'never the track wanted before');
assert.equal(a.bgm?.loopUrl, prep);
// BGM volume 0 is silent too: another track stops and is not fetched; SFX keep playing
a.setVolumes({ bgm: 0 });
a.playBgm('lobby');
await tick();
assert.ok(!asked(urls, lobby), 'BGM volume 0: no download');
assert.equal(a.bgm, null, 'the other track stopped');
a.sfx('buy');
await tick();
assert.ok(asked(urls, manifest.audio.sfx.ui.buy), 'SFX are not gated by the BGM volume');
a.setVolumes({ bgm: 0.5 });
await tick();
assert.ok(asked(urls, lobby), 'audible again: fetched');
assert.equal(a.bgm?.loopUrl, lobby);
// the loop that already plays keeps playing through a silent phase change, and is not fetched again
a.setVolumes({ muted: true });
const n = urls.length;
const started = fw.made.started;
a.playBgm('lobby');
a.setVolumes({ muted: false });
await tick();
assert.equal(a.bgm?.loopUrl, lobby);
assert.equal(urls.length, n, 'no new request');
assert.equal(fw.made.started, started, 'no restart');
} finally {
globalThis.fetch = origFetch;
}
});
test('fetch failures are swallowed', async () => {
const fw = fakeWindow();
const origFetch = globalThis.fetch;