import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AudioManager, type PlayOptions } from '../AudioManager';
/** Параметр WebAudio: значение + планировщик огибающей (мгновенно применяет). */
function makeParam(value = 1) {
const p = {
value,
setValueAtTime: vi.fn((v: number) => {
p.value = v;
}),
linearRampToValueAtTime: vi.fn((v: number) => {
p.value = v;
}),
cancelScheduledValues: vi.fn()
};
return p;
}
/** Минимальный граф WebAudio: считает узлы, запоминает соединения. */
function makeCtx() {
const graph = {
gains: [] as { gain: ReturnType<typeof makeParam>; dests: unknown[] }[],
sources: [] as { buffer: unknown; loop: boolean; playbackRate: ReturnType<typeof makeParam>; start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>; dests: unknown[] }[],
panners: [] as { pan: ReturnType<typeof makeParam>; dests: unknown[] }[],
destination: { destination: true }
};
const connect = (dests: unknown[]) => (dest: unknown) => {
dests.push(dest);
return dest;
};
const ctx = {
currentTime: 0,
state: 'running',
destination: graph.destination,
resume: vi.fn(async () => undefined),
createGain: () => {
const node: {
gain: ReturnType<typeof makeParam>;
dests: unknown[];
connect: (d: unknown) => unknown;
disconnect: () => void;
} = {
gain: makeParam(1),
dests: [],
connect: () => undefined,
disconnect: () => undefined
};
node.connect = connect(node.dests);
graph.gains.push(node);
return node;
},
createBufferSource: () => {
const node: { buffer: unknown; loop: boolean; playbackRate: ReturnType<typeof makeParam>; start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>; dests: unknown[]; connect: (d: unknown) => unknown } = {
buffer: null,
loop: false,
playbackRate: makeParam(1),
start: vi.fn(),
stop: vi.fn(),
dests: [],
connect: () => undefined
};
node.connect = connect(node.dests);
graph.sources.push(node);
return node;
},
createStereoPanner: () => {
const node: { pan: ReturnType<typeof makeParam>; dests: unknown[]; connect: (d: unknown) => unknown } = {
pan: makeParam(0),
dests: [],
connect: () => undefined
};
node.connect = connect(node.dests);
graph.panners.push(node);
return node;
},
decodeAudioData: vi.fn(async () => ({ decoded: true, duration: 1.25 })),
createBuffer: vi.fn((channels: number, length: number, rate: number) => {
const buf: { channels: number; length: number; sampleRate: number; data: Float32Array | null; copyToChannel: ReturnType<typeof vi.fn> } = {
channels,
length,
sampleRate: rate,
data: null,
copyToChannel: vi.fn((data: Float32Array) => {
buf.data = data;
})
};
return buf;
})
};
return { ctx, graph };
}
function makeManager(opts: { maxVoices?: number } = {}) {
const { ctx, graph } = makeCtx();
const audio = new AudioManager((key) => `url:${key}`, () => ctx as unknown as AudioContext, opts);
return { audio, graph, ctx: ctx as unknown as { createBuffer: ReturnType<typeof vi.fn> } };
}
describe('AudioManager', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) })));
vi.useFakeTimers();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('unlock строит 4 шины: music/sfx/ambience ведут в master, master — в выход', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
expect(graph.gains).toHaveLength(4);
const [master, music, sfx, ambience] = graph.gains;
expect(master!.dests).toContain(graph.destination);
for (const bus of [music, sfx, ambience]) {
expect(bus!.dests).toContain(master);
}
});
it('setBusVolume: до unlock — без броска, после unlock — меняет громкость шины', async () => {
const { audio, graph } = makeManager();
expect(() => audio.setBusVolume('sfx', 0.3)).not.toThrow();
expect(graph.gains).toHaveLength(0);
await audio.unlock();
audio.setBusVolume('sfx', 0.3);
audio.setBusVolume('ambience', 0.4);
expect(graph.gains[2]!.gain.value).toBe(0.3);
expect(graph.gains[3]!.gain.value).toBe(0.4);
});
it('play(key, 0.5) ≡ play(key, {volume: 0.5}) — источник в sfx через gain', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('bell');
await audio.play('bell', 0.5);
await audio.play('bell', { volume: 0.5 });
const sfxBus = graph.gains[2]!;
for (const src of graph.sources) {
const gainNode = src.dests[0] as { gain: ReturnType<typeof makeParam>; dests: unknown[] };
expect(gainNode.gain.value).toBe(0.5);
expect(gainNode.dests).toContain(sfxBus);
}
});
it('rate и pan доезжают до узлов; pan ≈ 0 — StereoPanner не создаётся', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('bell');
await audio.play('bell', { rate: 1.5, pan: 0.7 });
expect(graph.sources[0]!.playbackRate.value).toBe(1.5);
expect(graph.panners).toHaveLength(1);
expect(graph.panners[0]!.pan.value).toBe(0.7);
await audio.play('bell');
expect(graph.panners).toHaveLength(1); // второй sfx без панорамы
expect(graph.sources[1]!.playbackRate.value).toBe(1);
});
it('незагруженный ключ — тихо ничего (null), onPlayed не зовётся', async () => {
const { audio } = makeManager();
const played: { key: string; opts: PlayOptions }[] = [];
audio.onPlayed = (key, opts) => played.push({ key, opts });
await expect(audio.play('ghost')).resolves.toBeNull();
expect(played).toHaveLength(0);
});
it('onPlayed зовётся после фактического запуска с нормализованными опциями', async () => {
const { audio, graph } = makeManager();
const played: { key: string; opts: PlayOptions }[] = [];
audio.onPlayed = (key, opts) => played.push({ key, opts });
await audio.unlock();
await audio.load('bell');
await audio.play('bell', { volume: 0.6, rate: 1.2, pan: -0.4 });
await audio.play('bell', 0.5);
expect(played).toEqual([
{ key: 'bell', opts: { volume: 0.6, rate: 1.2, pan: -0.4 } },
{ key: 'bell', opts: { volume: 0.5, rate: 1, pan: 0 } }
]);
expect(graph.sources).toHaveLength(2);
});
it('play возвращает хендл: setVolume/setRate/setPan меняют узлы, stop глушит', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('bell');
const h = await audio.play('bell', { volume: 0.5, pan: 0.3 });
expect(h).not.toBeNull();
const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number } };
h!.setVolume(0.8);
expect(gainNode.gain.value).toBe(0.8);
h!.setRate(1.4);
expect(graph.sources[0]!.playbackRate.value).toBe(1.4);
h!.setPan(-0.5);
expect(graph.panners[0]!.pan.value).toBe(-0.5);
h!.stop(0);
vi.advanceTimersByTime(100);
expect(graph.sources[0]!.stop).toHaveBeenCalled();
});
it('setPan создаёт panner, если его не было', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('bell');
const h = await audio.play('bell', 0.5);
expect(graph.panners).toHaveLength(0);
h!.setPan(0.4);
expect(graph.panners).toHaveLength(1);
expect(graph.panners[0]!.pan.value).toBeCloseTo(0.4);
// Пересоединение: gain теперь ведёт и в panner
const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] };
expect(gainNode.dests).toContain(graph.panners[0]);
});
it('после stop хендл мёртв: setVolume/setRate — no-op', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('bell');
const h = await audio.play('bell', 0.5);
h!.stop(0);
h!.setVolume(0.9);
h!.setRate(1.5);
// stop затушил громкость; setVolume мёртвого хендла её не вернул
const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number } };
expect(gainNode.gain.value).toBeCloseTo(0.0001);
expect(graph.sources[0]!.playbackRate.value).toBe(1);
vi.advanceTimersByTime(100);
expect(graph.sources[0]!.stop).toHaveBeenCalledTimes(1);
});
it('restart: новый экземпляр ключа глушит прежний; без restart — играют оба', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('bell');
await audio.play('bell', 0.5);
await audio.play('bell', { volume: 0.5, restart: true });
vi.advanceTimersByTime(100);
expect(graph.sources[0]!.stop).toHaveBeenCalled();
expect(graph.sources[1]!.stop).not.toHaveBeenCalled();
await audio.play('bell', 0.5);
vi.advanceTimersByTime(100);
expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); // без restart живут оба
expect(graph.sources[2]!.stop).not.toHaveBeenCalled();
});
it('maxVoices: при переполнении воруется самый тихий голос', async () => {
const { audio, graph } = makeManager({ maxVoices: 2 });
await audio.unlock();
await audio.load('bell');
await audio.play('bell', 0.9);
await audio.play('bell', 0.1);
await audio.play('bell', 0.5);
vi.advanceTimersByTime(100);
expect(graph.sources[1]!.stop).toHaveBeenCalled(); // самый тихий (0.1)
expect(graph.sources[0]!.stop).not.toHaveBeenCalled();
expect(graph.sources[2]!.stop).not.toHaveBeenCalled();
});
it('onPlayed зовётся и для лупов (музыка/амбиент), и для sfx', async () => {
const { audio } = makeManager();
const played: { key: string; opts: PlayOptions }[] = [];
audio.onPlayed = (key, opts) => played.push({ key, opts });
await audio.unlock();
await audio.load('m');
await audio.load('a');
await audio.load('bell');
await audio.playMusic('m', { volume: 0.6, fade: 0 });
await audio.playAmbience('a', { fade: 0 });
await audio.play('bell', 0.5);
expect(played).toEqual([
{ key: 'm', opts: { volume: 0.6, rate: 1, pan: 0 } },
{ key: 'a', opts: { volume: 1, rate: 1, pan: 0 } },
{ key: 'bell', opts: { volume: 0.5, rate: 1, pan: 0 } }
]);
});
it('onEnded зовётся на естественном конце звука', async () => {
const { audio, graph } = makeManager();
const ended: string[] = [];
audio.onEnded = (key) => ended.push(key);
await audio.unlock();
await audio.load('bell');
await audio.play('bell');
expect(ended).toHaveLength(0);
// Фейковый источник не завершается сам — дёргаем onended руками.
(graph.sources[0] as unknown as { onended?: () => void }).onended?.();
expect(ended).toEqual(['bell']);
});
it('заглушенный голос (stop) onEnded не зовёт', async () => {
const { audio, graph } = makeManager();
const ended: string[] = [];
audio.onEnded = (key) => ended.push(key);
await audio.unlock();
await audio.load('bell');
const h = await audio.play('bell');
h!.stop(0);
// Источник «остановился» после затухания — голос убит, событие не для него.
(graph.sources[0] as unknown as { onended?: () => void }).onended?.();
expect(ended).toEqual([]);
});
it('duration: после load+preload — секунды буфера, иначе null', async () => {
const { audio } = makeManager();
await audio.unlock();
expect(audio.duration('m')).toBeNull();
await audio.load('m');
await audio.preload('m');
expect(audio.duration('m')).toBeCloseTo(1.25);
});
it('createBuffer: до unlock — null, после — буфер с сэмплами', async () => {
const { audio, ctx } = makeManager();
const data = new Float32Array([0, 0.5, -0.5]);
expect(audio.createBuffer(data)).toBeNull(); // контекста ещё нет
await audio.unlock();
const buf = audio.createBuffer(data, 44100) as unknown as { data: Float32Array | null; copyToChannel: (d: Float32Array, ch: number) => void };
expect(ctx.createBuffer).toHaveBeenCalledWith(1, 3, 44100);
expect(buf.data).toBe(data);
expect(buf!.copyToChannel).toHaveBeenCalledWith(data, 0);
});
it('playBuffer играет буфер в sfx с заданным ключом и возвращает хендл', async () => {
const { audio, graph } = makeManager();
const played: { key: string; opts: PlayOptions }[] = [];
audio.onPlayed = (key, opts) => played.push({ key, opts });
await audio.unlock();
const buf = audio.createBuffer(new Float32Array(100).fill(0.3))!;
const h = await audio.playBuffer('sfx/step_grass#0', buf, { volume: 0.4 });
expect(h).not.toBeNull();
const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number }; dests: unknown[] };
expect(gainNode.gain.value).toBe(0.4);
expect(gainNode.dests).toContain(graph.gains[2]); // шина sfx
expect(played).toEqual([{ key: 'sfx/step_grass#0', opts: { volume: 0.4, rate: 1, pan: 0 } }]);
h!.stop(0);
vi.advanceTimersByTime(100);
expect(graph.sources[0]!.stop).toHaveBeenCalled();
});
it('playSpec: спек компилируется в буфер и кэшируется по ключу', async () => {
const { audio, graph, ctx } = makeManager();
const played: { key: string; opts: PlayOptions }[] = [];
audio.onPlayed = (key, opts) => played.push({ key, opts });
await audio.unlock();
const spec = { kind: 'chime', dur: 0.1, freq: 660 } as const;
const h1 = await audio.playSpec('agent/door', spec, 0.7);
await audio.playSpec('agent/door', spec, 0.7); // из кэша
expect(graph.sources).toHaveLength(2);
expect(ctx.createBuffer).toHaveBeenCalledTimes(1); // компиляция один раз
const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number }; dests: unknown[] };
expect(gainNode.gain.value).toBe(0.7);
expect(gainNode.dests).toContain(graph.gains[2]); // шина sfx
expect(played.map((p) => p.key)).toEqual(['agent/door', 'agent/door']);
expect(h1).not.toBeNull();
// Другой ключ — своя компиляция
await audio.playSpec('agent/bush', spec, 0.5);
expect(ctx.createBuffer).toHaveBeenCalledTimes(2);
});
it('playSpec: спек рендерится в буфер с длиной dur×RATE (моно, 22050)', async () => {
const { audio, ctx } = makeManager();
await audio.playSpec('x', { kind: 'hit', dur: 0.1 });
expect(ctx.createBuffer).toHaveBeenCalledWith(1, 2205, 22050);
});
it('setVolume хендла сразу задаёт громкость слота', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('m');
const handle = await audio.playLoop('m', { fade: 0, volume: 1 });
handle!.setVolume(0.3);
const slotGain = (graph.sources[0]!.dests[0] as { gain: { value: number } }).gain;
expect(slotGain.value).toBeCloseTo(0.3);
});
it('playMusic и playAmbience независимы: остановка музыки не глушит амбиент', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('m');
await audio.load('a');
const music = await audio.playMusic('m', { fade: 0 });
const amb = await audio.playAmbience('a', { fade: 0 });
expect(music).not.toBeNull();
expect(amb).not.toBeNull();
expect(graph.sources).toHaveLength(2);
music!.stop(0);
vi.advanceTimersByTime(100);
expect(graph.sources[0]!.stop).toHaveBeenCalled();
expect(graph.sources[1]!.stop).not.toHaveBeenCalled();
});
it('playAmbience дважды — прежний слот остановлен', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('a');
const first = await audio.playAmbience('a', { fade: 0 });
await audio.playAmbience('a', { fade: 0 });
vi.advanceTimersByTime(100);
expect(first).not.toBeNull();
expect(graph.sources[0]!.stop).toHaveBeenCalled();
expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); // новый играет
});
it('playMusic с негодным ключом не глушит текущий трек', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('m');
await audio.playMusic('m', { fade: 0 });
const bad = await audio.playMusic('ghost', { fade: 0 });
expect(bad).toBeNull();
expect(graph.sources[0]!.stop).not.toHaveBeenCalled();
});
it('playLoop играет в заданную шину и не занимает слот музыки', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('m');
const layer = await audio.playLoop('m', { bus: 'sfx', fade: 0 });
expect(layer).not.toBeNull();
const sfxBus = graph.gains[2]!;
expect(sfxBus.dests).not.toContain(graph.destination);
// gain лупа ведёт в sfx
const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] };
expect(gainNode.dests).toContain(sfxBus);
// музыка стартует отдельно, луп не заглушается
await audio.playMusic('m', { fade: 0 });
expect(graph.sources).toHaveLength(2);
audio.stopMusic(0);
vi.advanceTimersByTime(100);
expect(graph.sources[0]!.stop).not.toHaveBeenCalled();
expect(graph.sources[1]!.stop).toHaveBeenCalled();
});
});