음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -41,6 +41,9 @@ interface VoiceUiProbeState {
recorderStops: number;
trackStops: number;
audioPlays: number;
audioContextResumes: number;
audioBufferStarts: number;
mediaPlayRejections: number;
messages: VoiceUiProbeMessage[];
closeEvents: number[];
}
@ -206,6 +209,7 @@ async function startApi({
ENGINE_CONNECT_TIMEOUT: "2",
OPENAI_API_KEY: "e2e-fake-key",
OPENAI_BASE_URL: `${openAIBaseURL}/v1`,
VIGNETTE_VOICE_POC_SAMPLE_TTS: "false",
FRONTEND_BASE_URL: frontendBaseURL,
CORS_ORIGINS: JSON.stringify([frontendBaseURL]),
},
@ -356,8 +360,11 @@ async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: stri
);
}
async function installSyntheticVoiceCapture(page: Page): Promise<void> {
await page.addInitScript(() => {
async function installSyntheticVoiceCapture(
page: Page,
options: { blockMediaElementPlayback?: boolean } = {},
): Promise<void> {
await page.addInitScript((opts) => {
type ProbeMessage = {
direction: "sent" | "received";
kind: "text" | "binary";
@ -370,6 +377,9 @@ async function installSyntheticVoiceCapture(page: Page): Promise<void> {
recorderStops: number;
trackStops: number;
audioPlays: number;
audioContextResumes: number;
audioBufferStarts: number;
mediaPlayRejections: number;
messages: ProbeMessage[];
closeEvents: number[];
};
@ -380,6 +390,9 @@ async function installSyntheticVoiceCapture(page: Page): Promise<void> {
recorderStops: 0,
trackStops: 0,
audioPlays: 0,
audioContextResumes: 0,
audioBufferStarts: 0,
mediaPlayRejections: 0,
messages: [],
closeEvents: [],
};
@ -507,14 +520,93 @@ async function installSyntheticVoiceCapture(page: Page): Promise<void> {
value: ProbeWebSocket,
});
class FakeAnalyser {
fftSize = 1024;
connect() {
return this;
}
disconnect() {
return undefined;
}
getFloatTimeDomainData(buf: Float32Array) {
for (let i = 0; i < buf.length; i += 1) buf[i] = 0;
}
}
class FakeBufferSource {
buffer: unknown = null;
onended: ((event: Event) => void) | null = null;
connect() {
return this;
}
disconnect() {
return undefined;
}
start() {
probe.audioBufferStarts += 1;
window.setTimeout(() => {
this.onended?.(new Event("ended"));
}, 120);
}
stop() {
this.onended = null;
}
}
class FakeAudioContext {
state = "running";
destination = {};
async resume() {
probe.audioContextResumes += 1;
this.state = "running";
}
async close() {
this.state = "closed";
}
async decodeAudioData(_data: ArrayBuffer) {
return { duration: 0.12 };
}
createAnalyser() {
return new FakeAnalyser();
}
createBufferSource() {
return new FakeBufferSource();
}
}
Object.defineProperty(window, "AudioContext", {
configurable: true,
value: FakeAudioContext,
});
Object.defineProperty(window, "webkitAudioContext", {
configurable: true,
value: FakeAudioContext,
});
HTMLMediaElement.prototype.play = function patchedPlay() {
if (opts.blockMediaElementPlayback) {
probe.mediaPlayRejections += 1;
return Promise.reject(new DOMException("Synthetic autoplay block", "NotAllowedError"));
}
probe.audioPlays += 1;
window.setTimeout(() => {
this.dispatchEvent(new Event("ended"));
}, 120);
return Promise.resolve();
};
});
}, options);
}
async function readVoiceUiProbe(page: Page): Promise<VoiceUiProbeState> {
@ -562,6 +654,33 @@ test.describe("voice cascade success path", () => {
if (!login.ok) {
return { ok: false, step: "login", status: login.status, body: loginBody };
}
const onboarding = await fetch(`${apiBase}/users/me/onboarding`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
legal_name: "Voice Success",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-5555-5555",
contact_address: "경기도 오산시 한신대학교",
nickname: "Voice Success",
self_introduction: "음성 캐스케이드 성공 경로 검증용 사용자입니다.",
avatar_url: "",
terms_accepted: true,
privacy_accepted: true,
}),
});
const onboardingBody = await onboarding.text();
if (!onboarding.ok) {
return {
ok: false,
step: "onboarding",
status: onboarding.status,
body: onboardingBody,
};
}
const me = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
const meBody = await me.text();
if (!me.ok) {
@ -613,7 +732,6 @@ test.describe("voice cascade success path", () => {
expect.objectContaining({ type: "transcript", text: "요즘 잠을 잘 못 자요." }),
expect.objectContaining({ type: "reply", text: "괜찮아요. 천천히 말해볼게요." }),
expect.objectContaining({ type: "state", state: "speaking" }),
expect.objectContaining({ type: "tts_chunk", seq: 0 }),
expect.objectContaining({ type: "tts_end" }),
expect.objectContaining({ type: "state", state: "idle" }),
]),
@ -646,7 +764,7 @@ test.describe("voice cascade success path", () => {
}
});
await installSyntheticVoiceCapture(page);
await installSyntheticVoiceCapture(page, { blockMediaElementPlayback: true });
const openai = await startFakeOpenAI();
const engine = await startFakeEngine();
@ -703,6 +821,33 @@ test.describe("voice cascade success path", () => {
if (!login.ok) {
return { ok: false, step: "login", status: login.status, body: loginBody };
}
const onboarding = await fetch(`${apiBase}/users/me/onboarding`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
legal_name: "Voice UI",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-6666-6666",
contact_address: "경기도 오산시 한신대학교",
nickname: "Voice UI",
self_introduction: "브라우저 음성 UI 검증용 사용자입니다.",
avatar_url: "",
terms_accepted: true,
privacy_accepted: true,
}),
});
const onboardingBody = await onboarding.text();
if (!onboarding.ok) {
return {
ok: false,
step: "onboarding",
status: onboarding.status,
body: onboardingBody,
};
}
const me = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
const meBody = await me.text();
if (!me.ok) {
@ -794,7 +939,10 @@ test.describe("voice cascade success path", () => {
await expect(page.locator(".sx-utt").filter({ hasText: String(reply) })).toBeVisible();
const probe = await readVoiceUiProbe(page);
expect(probe.audioPlays).toBeGreaterThan(0);
expect(probe.audioContextResumes).toBeGreaterThan(0);
expect(probe.audioBufferStarts).toBeGreaterThan(0);
expect(probe.audioPlays).toBe(0);
expect(probe.mediaPlayRejections).toBe(0);
expect(probe.messages.some((message) => message.direction === "received" && message.kind === "binary")).toBe(
true,
);