Files
Find-Word/tests/audio.test.ts
2026-10-14 10:01:46 +00:00

322 lines
11 KiB
TypeScript

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<typeof makeGain>[] = [];
function makeGain(): ReturnType<typeof node> & {
gain: ReturnType<typeof param>;
} {
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<typeof makeSource>[] = [];
function makeSource(): ReturnType<typeof node> & {
buffer: AudioBuffer | null;
loop: boolean;
start: ReturnType<typeof vi.fn<(when?: number) => void>>;
stop: ReturnType<typeof vi.fn>;
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<typeof fetch>(
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("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.3",
`/audio/${question.id}.wav?v=0.2.3`,
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.3",
`/audio/${question.id}.wav?v=0.2.3`,
question.environment.audioUrl,
"/audio/intro.wav?v=0.2.3",
`/audio/${question.id}.wav?v=0.2.3`,
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(
1,
expect.any(Number),
);
expect(f.gains[2]!.gain.setValueAtTime).toHaveBeenCalledWith(
10 ** (question.db / 20),
expect.any(Number),
);
expect(
f.gains[3]!.gain.linearRampToValueAtTime.mock.calls[0]![0],
).toBeCloseTo(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<typeof f.context.createBuffer>) => 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();
}
});