import { describe, expect, it, vi } from "vitest"; import { AudioEngine } from "../src/audio/engine"; import { createQuestions } from "../src/game"; // 녹음 재생 성공만 흉내 내는 대신 Web Audio 그래프의 노드·예약 시간·정리 // 호출을 기록한다. HTTP와 장치 경계를 주입하여 경합을 결정론적으로 재현한다. function fixture() { const param = () => ({ value: 1, setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn(), setTargetAtTime: vi.fn(), }); const node = () => ({ connect: vi.fn(), disconnect: vi.fn() }); const gains: ReturnType[] = []; function makeGain(): ReturnType & { gain: ReturnType; } { const gain = { ...node(), gain: param() }; gains.push(gain); return gain; } function buffer(length = 16000, rate = 16000) { const data = Float32Array.from( { length }, (_, i) => 0.2 * Math.sin(i / 20), ); return { duration: length / rate, length, sampleRate: rate, numberOfChannels: 1, getChannelData: () => data, copyToChannel: vi.fn(), }; } const sources: ReturnType[] = []; function makeSource(): ReturnType & { buffer: AudioBuffer | null; loop: boolean; start: ReturnType void>>; stop: ReturnType; onended: null | (() => void); } { const source = { ...node(), buffer: null, loop: false, start: vi.fn(), stop: vi.fn(), onended: null as null | (() => void), }; sources.push(source); return source; } const context = { currentTime: 2, sampleRate: 16000, state: "suspended", destination: {}, resume: vi.fn(async () => { context.state = "running"; }), close: vi.fn(async () => {}), createGain: makeGain, createBufferSource: makeSource, createBuffer: (_channels: number, length: number, rate: number) => buffer(length, rate), decodeAudioData: vi.fn(async () => buffer()), }; const fetcher = vi.fn( async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }) as Response, ); const factory = vi.fn(() => context as unknown as AudioContext); return { engine: new AudioEngine(factory, fetcher), context, sources, gains, fetcher, factory, }; } const question = createQuestions(42, "choice")[0]!; const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); }; describe("오디오 재생 수명과 믹서", () => { it("생성 시 무음이며 gesture의 play에서만 장치를 연다", async () => { const f = fixture(); expect(f.factory).not.toHaveBeenCalled(); const started = vi.fn(); const done = f.engine.play(question, true, started); expect(f.factory).toHaveBeenCalledOnce(); expect(f.context.resume).toHaveBeenCalledOnce(); await flush(); expect(started).toHaveBeenCalledOnce(); expect(f.sources).toHaveLength(3); expect(f.fetcher.mock.calls.map((call) => call[0])).toEqual([ "/audio/intro.wav", question.audioUrl, ]); // master, 배경, 목표, 안내의 gain을 분리한다. 안내는 점차 소음에 묻힌다. expect(f.gains[1]!.gain.setValueAtTime).toHaveBeenCalledWith( 1, expect.any(Number), ); expect(f.gains[2]!.gain.setValueAtTime).toHaveBeenCalledWith( 10 ** (question.db / 20), expect.any(Number), ); expect(f.gains[3]!.gain.linearRampToValueAtTime).toHaveBeenCalledWith( 0.12, expect.any(Number), ); expect(f.sources[2]!.start.mock.calls[0]![0]!).toBeGreaterThan( f.sources[1]!.start.mock.calls[0]![0]!, ); f.sources[2]!.onended?.(); await done; expect(f.sources.every((s) => s.disconnect.mock.calls.length > 0)).toBe( true, ); }); it("동시 시작을 거부하고 정지하면 모든 노드와 대기 Promise를 취소한다", async () => { const f = fixture(); const done = f.engine.play(question, false, vi.fn()); const cancelled = expect(done).rejects.toMatchObject({ name: "AbortError", }); await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow( "재생 중", ); await flush(); f.engine.stop(); await cancelled; expect(f.sources.every((s) => s.stop.mock.calls.length > 0)).toBe(true); expect(f.sources.every((s) => s.onended === null)).toBe(true); f.engine.dispose(); expect(f.context.close).toHaveBeenCalledOnce(); }); it("로드 도중 취소한 이전 작업이 늦게 완료되어도 소리를 시작하지 않는다", async () => { const f = fixture(); let release!: (value: Response) => void; f.fetcher.mockImplementationOnce( () => new Promise((resolve) => { release = resolve; }), ); const done = f.engine.play(question, false, vi.fn()); const cancelled = expect(done).rejects.toMatchObject({ name: "AbortError", }); await flush(); f.engine.stop(); await cancelled; release({ ok: true, arrayBuffer: async () => new ArrayBuffer(8), } as Response); await flush(); expect(f.sources).toHaveLength(0); }); it("HTTP·디코드·장치 실패를 전달하고 다음 시도는 다시 로드한다", async () => { const f = fixture(); f.fetcher.mockResolvedValueOnce({ ok: false } as Response); await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow( "음성 파일", ); f.context.decodeAudioData.mockRejectedValueOnce(new Error("decode")); await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow( "decode", ); f.context.resume.mockRejectedValueOnce(new Error("권한")); await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow( "권한", ); const done = f.engine.play(question, false, vi.fn()); await flush(); f.sources.at(-1)!.onended?.(); await done; }); it("전체 음량은 두 신호의 비율을 보존하며 0부터 1 범위로 제한한다", async () => { const f = fixture(); f.engine.setVolume(0.3); const done = f.engine.play(question, false, vi.fn()); await flush(); expect(f.gains[0]!.gain.value).toBe(0.3); f.engine.setVolume(8); expect(f.gains[0]!.gain.setTargetAtTime).toHaveBeenCalledWith(1, 2, 0.02); f.engine.setVolume(-1); expect(f.gains[0]!.gain.setTargetAtTime).toHaveBeenCalledWith(0, 2, 0.02); f.sources.at(-1)!.onended?.(); await done; }); });