diff --git a/src/audio/AudioBus.ts b/src/audio/AudioBus.ts index 91ddb3550..fda67488e 100644 --- a/src/audio/AudioBus.ts +++ b/src/audio/AudioBus.ts @@ -332,7 +332,10 @@ export class AudioBus { const outputNode = audioContext.createGain(); // Internal chain: input → [effects...] → pan → output - outputNode.gain.setTargetAtTime(this._muted ? 0 : this._volume, audioContext.currentTime, 0.01); + // Set, not ramped: the node is new and nothing flows through it yet. A + // ramp from the GainNode default of unity would play the first sound + // after the unlock gesture at full level until it settled. + outputNode.gain.setValueAtTime(this._muted ? 0 : this._volume, audioContext.currentTime); panNode.pan.setTargetAtTime(this._pan, audioContext.currentTime, 0.01); this._setup = { audioContext, inputNode, outputNode, panNode }; diff --git a/src/audio/BaseVoice.ts b/src/audio/BaseVoice.ts index 135a10786..c1f91c526 100644 --- a/src/audio/BaseVoice.ts +++ b/src/audio/BaseVoice.ts @@ -133,7 +133,11 @@ export abstract class BaseVoice implements Voice { this._volume = clamp(init.volume, 0, 1); this._spatialConfig = { ...defaultSpatialConfig }; - this._output.gain.setTargetAtTime(this._volume, this._audioContext.currentTime, 0.01); + // Set, not ramped: a fresh GainNode starts at unity, and the source has not + // started yet, so there is nothing to click. A ramp from unity would let a + // one-shot's first milliseconds - usually its loudest transient - through + // at full level before the requested volume took hold. + this._output.gain.setValueAtTime(this._volume, this._audioContext.currentTime); if (init.autoConnect !== false) { this._connectOutput(); diff --git a/test/audio/audio-bus.test.ts b/test/audio/audio-bus.test.ts index 081fc35c1..dce870281 100644 --- a/test/audio/audio-bus.test.ts +++ b/test/audio/audio-bus.test.ts @@ -183,6 +183,29 @@ describe('AudioBus', () => { expect(bus.pan).toBe(0.5); }); + test('a new bus starts at its volume instead of ramping down from unity', () => { + const spy = spyOnBusCreation(); + + const bus = new AudioBus('initial-volume-test', { volume: 0.4 }); + + expect(spy.outputNode.gain.setValueAtTime).toHaveBeenCalledWith(0.4, expect.any(Number)); + expect(spy.outputNode.gain.setTargetAtTime).not.toHaveBeenCalled(); + + spy.restore(); + bus.destroy(); + }); + + test('a bus created muted starts silent', () => { + const spy = spyOnBusCreation(); + + const bus = new AudioBus('initial-muted-test', { volume: 0.4, muted: true }); + + expect(spy.outputNode.gain.setValueAtTime).toHaveBeenCalledWith(0, expect.any(Number)); + + spy.restore(); + bus.destroy(); + }); + // 5. Muted zeroes the gain via setTargetAtTime test('setting muted=true calls setTargetAtTime(0) on outputNode', () => { const spy = spyOnBusCreation(); @@ -709,7 +732,7 @@ describe('AudioBus', () => { vi.resetModules(); const fakeSignal = new Signal<[AudioContext]>(); - const makeFakeGain = () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn() } }); + const makeFakeGain = () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), setValueAtTime: vi.fn() } }); const fakeCtx = { currentTime: 0, destination: {}, @@ -746,7 +769,7 @@ describe('AudioBus', () => { vi.resetModules(); const fakeSignal = new Signal<[AudioContext]>(); - const makeFakeGain = () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn() } }); + const makeFakeGain = () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), setValueAtTime: vi.fn() } }); const fakeCtx = { currentTime: 0, destination: {}, @@ -787,7 +810,7 @@ describe('AudioBus', () => { vi.resetModules(); const fakeSignal = new Signal<[AudioContext]>(); - const makeFakeGain = () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn() } }); + const makeFakeGain = () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), setValueAtTime: vi.fn() } }); const fakeCtx = { currentTime: 0, destination: {}, diff --git a/test/audio/audio-generator.test.ts b/test/audio/audio-generator.test.ts index 0fa119cf3..436ade889 100644 --- a/test/audio/audio-generator.test.ts +++ b/test/audio/audio-generator.test.ts @@ -485,7 +485,7 @@ describe('AudioGenerator', () => { system.play(gen, { muted: true, volume: 0.8 }); - expect(spy.gains[0].gain.setTargetAtTime).toHaveBeenCalledWith(0, expect.any(Number), expect.any(Number)); + expect(spy.gains[0].gain.setValueAtTime).toHaveBeenCalledWith(0, expect.any(Number)); spy.restore(); gen.destroy(); @@ -498,7 +498,7 @@ describe('AudioGenerator', () => { system.play(gen, { volume: 0.3 }); - expect(spy.gains[0].gain.setTargetAtTime).toHaveBeenCalledWith(0.3, expect.any(Number), expect.any(Number)); + expect(spy.gains[0].gain.setValueAtTime).toHaveBeenCalledWith(0.3, expect.any(Number)); spy.restore(); gen.destroy(); @@ -511,7 +511,7 @@ describe('AudioGenerator', () => { system.play(gen); - expect(spy.gains[0].gain.setTargetAtTime).toHaveBeenCalledWith(0, expect.any(Number), expect.any(Number)); + expect(spy.gains[0].gain.setValueAtTime).toHaveBeenCalledWith(0, expect.any(Number)); spy.restore(); gen.destroy(); diff --git a/test/audio/base-voice.test.ts b/test/audio/base-voice.test.ts index 589831e38..5a605704d 100644 --- a/test/audio/base-voice.test.ts +++ b/test/audio/base-voice.test.ts @@ -282,6 +282,38 @@ describe('BaseVoice — post-ended no-ops', () => { }); }); +describe('BaseVoice - initial volume', () => { + afterEach(() => vi.restoreAllMocks()); + + test.each([0, 0.05, 0.25, 1])('a voice played at volume %s starts there instead of ramping down from unity', volume => { + const system = new AudioSystem(); // before capture, so bus gains are not captured + const out = captureVoiceOutput(); + const sound = new Sound(createAudioBufferStub()); + + system.play(sound, { volume }); + + expect(out.node!.gain.setValueAtTime).toHaveBeenCalledWith(volume, expect.any(Number)); + expect(out.node!.gain.setTargetAtTime).not.toHaveBeenCalled(); + + out.restore(); + }); + + test('a later volume change still ramps rather than jumping', () => { + const system = new AudioSystem(); + const out = captureVoiceOutput(); + const sound = new Sound(createAudioBufferStub()); + const voice = system.play(sound, { volume: 0.5 }); + + out.node!.gain.setValueAtTime.mockClear(); + voice.volume = 0.1; + + expect(out.node!.gain.setTargetAtTime).toHaveBeenCalledWith(0.1, expect.any(Number), 0.01); + expect(out.node!.gain.setValueAtTime).not.toHaveBeenCalled(); + + out.restore(); + }); +}); + describe('BaseVoice — fade()', () => { afterEach(() => vi.restoreAllMocks());