diff --git a/docs/design/client.md b/docs/design/client.md index 9f425265..4d2a59ff 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -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). --- diff --git a/public/js/audio.js b/public/js/audio.js index 53cb8686..5ab23809 100644 --- a/public/js/audio.js +++ b/public/js/audio.js @@ -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; diff --git a/test/ui/audio.test.js b/test/ui/audio.test.js index 6e6a5895..d11bb99b 100644 --- a/test/ui/audio.test.js +++ b/test/ui/audio.test.js @@ -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;