import { describe, expect, it, vi } from "vitest"; import { FIXED_HEADROOM_GAIN } from "../src/audio/dsp"; import { AudioEngine } from "../src/audio/engine"; import { createQuestions } from "../src/game"; // 무결성은 실제 바이트 전수 검사와 Chrome 변조 테스트에서 검증한다. 이 파일은 노드 수명에 집중한다. vi.mock("../src/audio/integrity", () => ({ verifyAudioAsset: vi.fn(async () => {}), })); // 녹음 재생 성공만 흉내 내는 대신 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, // 실제 AudioBuffer처럼 복사를 반영해야 정규화 뒤 피크를 검증할 수 있다. copyToChannel: vi.fn((samples: Float32Array) => data.set(samples)), }; } 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(16000 * 12)), }; 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 < 24; i++) await Promise.resolve(); }; describe("오디오 재생 수명과 믹서", () => { it("intro와 목표 실제 fetch URL에 같은 명시적 cache-busting 버전을 붙인다", async () => { const f = fixture(); const done = f.engine.play(question, true, vi.fn()); await flush(); f.sources.at(-1)!.onended?.(); await done; expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([ "/audio/intro.wav?v=0.2.5", `/audio/${question.id}.wav?v=0.2.5`, question.environment.audioUrl, ]); f.engine.dispose(); }); it.each(["intro", "target"])( "%s 404 후 intro·목표를 동일한 versioned URL로 다시 요청한다", async (failed) => { const f = fixture(); let pendingFailure = true; f.fetcher.mockImplementation(async (input) => { const pathname = new URL(String(input), "http://localhost").pathname; if ( pendingFailure && pathname === `/audio/${failed === "intro" ? "intro" : question.id}.wav` ) { pendingFailure = false; return { ok: false, status: 404 } as Response; } return { ok: true, arrayBuffer: async () => new ArrayBuffer(8), } as Response; }); await expect(f.engine.play(question, true, vi.fn())).rejects.toThrow( "음성 파일", ); const done = f.engine.play(question, true, vi.fn()); await flush(); f.sources.at(-1)!.onended?.(); await done; expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([ "/audio/intro.wav?v=0.2.5", `/audio/${question.id}.wav?v=0.2.5`, question.environment.audioUrl, "/audio/intro.wav?v=0.2.5", `/audio/${question.id}.wav?v=0.2.5`, question.environment.audioUrl, ]); f.engine.dispose(); }, ); 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( ([url]) => new URL(String(url), "http://localhost").pathname, ), ).toEqual([ "/audio/intro.wav", `/audio/${question.id}.wav`, new URL(question.environment.audioUrl, "http://localhost").pathname, ]); // master, 배경, 목표, 안내의 gain을 분리한다. 안내는 점차 소음에 묻힌다. expect(f.gains[1]!.gain.setValueAtTime).toHaveBeenCalledWith( FIXED_HEADROOM_GAIN, expect.any(Number), ); expect(f.gains[2]!.gain.setValueAtTime).toHaveBeenCalledWith( FIXED_HEADROOM_GAIN * 10 ** (question.db / 20), expect.any(Number), ); expect( f.gains[3]!.gain.linearRampToValueAtTime.mock.calls[0]![0], ).toBeCloseTo(FIXED_HEADROOM_GAIN * 0.12, 10); 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( "해석", ); 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; }); }); // 배경 파일은 길이가 짧아도 루프 소스 하나로 유지하며 목표 ended에서 함께 정리한다. it("선택한 녹음을 디코드·루프하고 목표 종료에서 모든 소스와 fetch를 정리한다", async () => { const f = fixture(); const done = f.engine.play(question, false, vi.fn()); await flush(); expect(f.fetcher).toHaveBeenCalledWith( question.environment.audioUrl, expect.objectContaining({ signal: expect.any(AbortSignal) }), ); expect(f.context.decodeAudioData).toHaveBeenCalledTimes(2); expect(f.sources[0]!.loop).toBe(true); expect(f.sources[1]!.loop).toBe(false); f.sources[1]!.onended?.(); await done; expect(f.sources.every((s) => s.stop.mock.calls.length === 1)).toBe(true); for (const [, options] of f.fetcher.mock.calls) expect(options!.signal!.aborted).toBe(true); }); it("배경 404·디코드 실패는 한국어 복구 메시지이고 재시도로 성공한다", async () => { const f = fixture(); f.fetcher.mockImplementationOnce( async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }) as Response, ); f.fetcher.mockResolvedValueOnce({ ok: false } as Response); await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow( /배경.*다시/, ); f.context.decodeAudioData.mockRejectedValueOnce(new Error("EncodingError")); 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("로드 시간초과는 fetch를 취소하고 늦은 디코드가 소스를 만들지 않는다", async () => { vi.useFakeTimers(); try { const f = fixture(); let release!: (buffer: ReturnType) => void; f.context.decodeAudioData.mockImplementationOnce( () => new Promise((resolve) => { release = resolve; }), ); const done = f.engine.play(question, false, vi.fn()); const failed = expect(done).rejects.toThrow("준비 시간이 초과"); await flush(); await vi.advanceTimersByTimeAsync(15000); await failed; expect(f.fetcher.mock.calls[0]![1]!.signal!.aborted).toBe(true); release(f.context.createBuffer(1, 100, 24000)); await flush(); expect(f.sources).toHaveLength(0); expect(vi.getTimerCount()).toBe(0); f.engine.dispose(); } finally { vi.useRealTimers(); } }); // ebur128 실측 스칼라 gain으로 저장한 배경의 진폭을 로더가 다시 RMS .03으로 바꾸면 청감 보정이 사라진다. it("배경 PCM은 그대로 복사하고 voice/intro만 RMS .03을 유지한다", async () => { const f = fixture(); const done = f.engine.play(question, true, vi.fn()); await flush(); const energy = (data: Float32Array) => Math.sqrt(data.reduce((sum, x) => sum + x * x, 0) / data.length); expect(energy(f.sources[0]!.buffer!.getChannelData(0))).toBeGreaterThan(0.1); expect(energy(f.sources[1]!.buffer!.getChannelData(0))).toBeCloseTo(0.03, 6); expect(energy(f.sources[2]!.buffer!.getChannelData(0))).toBeCloseTo(0.03, 6); f.sources.at(-1)!.onended?.(); await done; f.engine.dispose(); }); it("배경의 과도 peak·NaN·무음은 소스 시작 전 거부하고 다시 시도할 수 있다", async () => { for (const sample of [0.9, NaN, 0]) { const f = fixture(); const decoded = await f.context.decodeAudioData(); f.context.decodeAudioData .mockResolvedValueOnce(decoded) .mockResolvedValueOnce({ ...decoded, getChannelData: () => new Float32Array([sample, -sample]), }); await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(); expect(f.sources).toHaveLength(0); f.engine.dispose(); } });